diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..1442d44 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + obj\$(MSBuildProjectName)\ + bin\$(Configuration)\$(MSBuildProjectName)\ + $(DefaultItemExcludes);obj\**;obj\**\*;bin\**;bin\**\* + + diff --git a/FontMod/FontMod-KM.csproj b/FontMod/FontMod-KM.csproj index f5f29b6..0fce140 100644 --- a/FontMod/FontMod-KM.csproj +++ b/FontMod/FontMod-KM.csproj @@ -5,7 +5,7 @@ net481 FontMod FontMod - 1.1.2 + 1.1.3 true latest FontMod @@ -28,7 +28,7 @@ - + diff --git a/FontMod/FontMod-RT.csproj b/FontMod/FontMod-RT.csproj index 972f65c..2c9f575 100644 --- a/FontMod/FontMod-RT.csproj +++ b/FontMod/FontMod-RT.csproj @@ -5,7 +5,7 @@ net481 FontMod FontMod - 1.1.2 + 1.1.3 true latest FontMod @@ -41,7 +41,7 @@ - + diff --git a/FontMod/FontMod-WOTR.csproj b/FontMod/FontMod-WOTR.csproj index 92c375e..a47e47d 100644 --- a/FontMod/FontMod-WOTR.csproj +++ b/FontMod/FontMod-WOTR.csproj @@ -5,7 +5,7 @@ net481 FontMod FontMod - 1.1.2 + 1.1.3 true latest FontMod diff --git a/FontMod/FontSwap/FontDataModel-KM.cs b/FontMod/FontSwap/FontDataModel-KM.cs index 5720ca8..7b0e9e2 100644 --- a/FontMod/FontSwap/FontDataModel-KM.cs +++ b/FontMod/FontSwap/FontDataModel-KM.cs @@ -1,18 +1,23 @@ #if KM using Newtonsoft.Json; using System; -using System.Drawing; +using System.Collections.Generic; using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using Kingmaker.Localization; using TMPro; using TMPro.EditorUtilities; -using UnityEngine; -using static RootMotion.FinalIK.GrounderQuadruped; namespace FontMod.FontSwap; [Serializable] public class FontDataModel { + private const int FontAtlasCacheKeyVersion = 2; + private static string requiredCharacters; + [JsonProperty] public string Name { get; set; } [JsonProperty] @@ -20,7 +25,22 @@ public class FontDataModel [JsonIgnore] public string FontPath { get; set; } [JsonIgnore] - public TMP_FontAsset TMP_FontAsset { get; private set; } + public TMP_FontAsset TMP_FontAsset + { + get + { + // We're lazy because large fonts (e.g. chinese fonts) can take a lot of time + if (!fontAssetCreationAttempted && !IsIgnored && !string.IsNullOrEmpty(FontPath)) { + fontAssetCreationAttempted = true; + tmpFontAsset = CreateFontAsset(FontPath); + } + + return tmpFontAsset; + } + } + + private TMP_FontAsset tmpFontAsset; + private bool fontAssetCreationAttempted; private FontDataModel() { } @@ -33,7 +53,6 @@ private FontDataModel(string fontPath) FontPath = fontPath; Name = Path.GetFileNameWithoutExtension(fontPath); - TMP_FontAsset = CreateFontAsset(fontPath); } catch (Exception e) { @@ -44,7 +63,7 @@ private FontDataModel(string fontPath) public override bool Equals(object obj) { if (obj is FontDataModel other) - return Equals(FontPath, other.FontPath) && Equals(TMP_FontAsset, other.TMP_FontAsset); + return Equals(FontPath, other.FontPath); return false; } @@ -55,7 +74,6 @@ public override int GetHashCode() { int hash = 17; hash = hash * 31 + (FontPath != null ? FontPath.GetHashCode() : 0); - hash = hash * 31 + (TMP_FontAsset != null ? TMP_FontAsset.GetHashCode() : 0); return hash; } } @@ -74,20 +92,47 @@ public static TMP_FontAsset CreateFontAsset(string fontPath) var create = new TMPro_FontAssetCreatorWindow(); create.font_TTF_path = fontPath; - create.GenerateFontAtlas(); + var characters = GetRequiredCharacters(); + create.SetCharacterSet(characters); + var renderType = create.UseBitmapFontAsset ? "bitmap" : "SDF"; + var cachePath = GetFontAtlasCachePath(fontPath, characters, create); + bool generated = false; + + if (create.TryLoadFontAtlasCache(cachePath)) + { + Main.Logger.Log($"Loaded cached {renderType} atlas for {name}."); + } + else + { + generated = true; + Main.Logger.Log($"Generating {create.CharacterCount} glyphs for {name} in a {create.AtlasSize}x{create.AtlasSize} {renderType} atlas."); + create.GenerateFontAtlas(); + } + create.CreateFontTexture(); - asset = create.Save_SDF_FontAsset(); + asset = create.UseBitmapFontAsset ? create.Save_Normal_FontAsset() : create.Save_SDF_FontAsset(); if (asset == null) throw new NullReferenceException($"Creation of TMP_FontAsset failed for font {name}"); - else - Main.Logger.Log($"Created font asset {asset.name}"); - asset.name = name; - - if (asset != null) - MaterialReferenceManager.AddFontAsset(asset); + asset.ReadFontDefinition(); + + if (generated) + { + try + { + create.SaveFontAtlasCache(cachePath); + } + catch (Exception e) + { + Main.Logger.Warning($"Could not cache the generated atlas for {name}: {e.Message}"); + } + } + + Main.Logger.Log($"Created font asset {asset.name}"); + + MaterialReferenceManager.AddFontAsset(asset); } catch (Exception e) { @@ -96,7 +141,77 @@ public static TMP_FontAsset CreateFontAsset(string fontPath) return asset; } + + private static string GetFontAtlasCachePath(string fontPath, string characters, TMPro_FontAssetCreatorWindow creator) + { + byte[] fontHash; + using (var stream = File.OpenRead(fontPath)) { + using var hash = SHA256.Create(); + fontHash = hash.ComputeHash(stream); + } + + string settings = $"{FontAtlasCacheKeyVersion}\n{creator.AtlasSize}\n{creator.UseBitmapFontAsset}\n{characters}"; + byte[] settingsBytes = Encoding.UTF8.GetBytes(settings); + byte[] keyData = new byte[fontHash.Length + settingsBytes.Length]; + Buffer.BlockCopy(fontHash, 0, keyData, 0, fontHash.Length); + Buffer.BlockCopy(settingsBytes, 0, keyData, fontHash.Length, settingsBytes.Length); + + byte[] cacheHash; + using (var hash = SHA256.Create()) { + cacheHash = hash.ComputeHash(keyData); + } + + string cacheKey = BitConverter.ToString(cacheHash).Replace("-", "").ToLowerInvariant(); + return Path.Combine(Main.ModEntry.Path, "Cache", cacheKey + ".fontatlas"); + } + + private static string GetRequiredCharacters() + { + if (requiredCharacters != null) { + return requiredCharacters; + } + + var characters = new HashSet(); + // Printable ASCII is required for TMP rich-text tags and ordinary UI text. + for (char character = ' '; character <= '~'; character++) { + characters.Add(character); + } + + // TMP support characters: non-breaking space, zero-width space, ellipsis, and missing-glyph box. + characters.Add('\u00a0'); + characters.Add('\u200b'); + characters.Add('\u2026'); + characters.Add('\u25a1'); + + AddLocalizationCharacters(characters, LocalizationManager.CurrentPack); + AddLocalizationCharacters(characters, LocalizationManager.CurrentPackFast); + + requiredCharacters = new string(characters.OrderBy(character => character).ToArray()); + Main.Logger.Log($"Preparing {requiredCharacters.Length} distinct characters for the current Kingmaker localization."); + return requiredCharacters; + } + + private static void AddLocalizationCharacters(HashSet characters, LocalizationPack pack) + { + if (pack?.Strings == null) { + return; + } + + foreach (string value in pack.Strings.Values) + { + if (string.IsNullOrEmpty(value)) { + continue; + } + + foreach (char character in value) + { + if (!char.IsControl(character)) { + characters.Add(character); + } + } + } + } } -#endif \ No newline at end of file +#endif diff --git a/FontMod/FontSwap/FontSwapperPatches.cs b/FontMod/FontSwap/FontSwapperPatches.cs index 490f4e4..89f35f6 100644 --- a/FontMod/FontSwap/FontSwapperPatches.cs +++ b/FontMod/FontSwap/FontSwapperPatches.cs @@ -1,10 +1,12 @@ using FontMod.Utility; using HarmonyLib; using Kingmaker.UI.Common; +#if KM +using Kingmaker.Localization; +#endif using System.Collections.Generic; using System.Linq; using System.Reflection.Emit; -using System.Text.RegularExpressions; using TMPro; using UnityEngine; namespace FontMod.FontSwap; @@ -12,6 +14,22 @@ namespace FontMod.FontSwap; [HarmonyPatch] public static class TMPTestPach { + private static bool CanSwapFonts + { + get + { +#if KM + return LocalizationManager.CurrentPack != null; +#else + return true; +#endif + } + } + + private static TMP_FontAsset GetMappedFontAsset(TMP_FontAsset fontAsset) + { + return fontAsset == null || !CanSwapFonts ? fontAsset : FontMapper.Instance.GetFontMapped(fontAsset); + } #if RT static bool _afterDelay = false; @@ -31,8 +49,7 @@ static void TextPatch(TextMeshProUGUI __instance) if (!_afterDelay) return; #endif - if (__instance.m_fontAsset != null) - __instance.m_fontAsset = FontMapper.Instance.GetFontMapped(__instance.m_fontAsset); + __instance.m_fontAsset = GetMappedFontAsset(__instance.m_fontAsset); } [HarmonyPatch(typeof(MaterialReferenceManager), nameof(MaterialReferenceManager.TryGetFontAsset))] @@ -45,8 +62,7 @@ static void TryGetFontAsset(ref TMP_FontAsset fontAsset) return; #endif - if (fontAsset != null) - fontAsset = FontMapper.Instance.GetFontMapped(fontAsset); + fontAsset = GetMappedFontAsset(fontAsset); } [HarmonyPatch(typeof(TMP_Text), nameof(TMP_Text.ValidateHtmlTag))] @@ -68,9 +84,8 @@ static IEnumerable TagPatch(IEnumerable instru var patchCodes = new CodeInstruction[] { - new(OpCodes.Call, AccessTools.PropertyGetter(typeof(FontMapper), nameof(FontMapper.Instance))), new(OpCodes.Ldloc_S, ldlocs.operand), - new(OpCodes.Callvirt, AccessTools.Method(typeof(FontMapper), nameof(FontMapper.GetFontMapped))), + new(OpCodes.Call, AccessTools.Method(typeof(TMPTestPach), nameof(GetMappedFontAsset))), new(OpCodes.Stloc_S, ldlocs.operand) }; @@ -91,12 +106,29 @@ static IEnumerable TagPatch(IEnumerable instru [HarmonyPrefix] static void GetSaberBookFormatPatch(string name, Color color, int size, ref Material material) { - if (material == null) + if (material == null || !CanSwapFonts) return; - material = FontMapper.Instance.FontMappings.ContainsKey("Saber_Dist32") ? - FontMapper.Instance.FontMappings["Saber_Dist32"].TMP_FontAsset.material : - FontMapper.Instance.DefaultFontMapping.TMP_FontAsset.material; + FontMapper mapper = FontMapper.Instance; + FontDataModel mapping; + + if (mapper.FontMappings.TryGetValue("Saber_Dist32", out mapping)) + { + // An ignored mapping means that both the original font and its original material must pass through untouched. + // => Otherwise e.g. nameplate disappears? + if (mapping.IsIgnored) { + return; + } + } + else + { + mapping = mapper.DefaultFontMapping; + } + + Material mappedMaterial = mapping?.TMP_FontAsset?.material; + if (mappedMaterial != null) { + material = mappedMaterial; + } } #endif -} \ No newline at end of file +} diff --git a/FontMod/Info.json b/FontMod/Info.json index 09e87ed..dad02f9 100644 --- a/FontMod/Info.json +++ b/FontMod/Info.json @@ -2,7 +2,7 @@ "Id": "FontMod", "DisplayName": "FontMod", "Author": "Hambeard", - "Version": "1.1.2", + "Version": "1.1.3", "ManagerVersion": "0.23.0", "Requirements": [], "AssemblyName": "FontMod.dll", diff --git a/FontMod/TMP_Utils/TMPro_FontAssetCreatorWindow.cs b/FontMod/TMP_Utils/TMPro_FontAssetCreatorWindow.cs index ec281a6..560d9fd 100644 --- a/FontMod/TMP_Utils/TMPro_FontAssetCreatorWindow.cs +++ b/FontMod/TMP_Utils/TMPro_FontAssetCreatorWindow.cs @@ -1,5 +1,6 @@ using UnityEngine; using UnityEditor; +using System; using System.Collections; using System.Collections.Generic; using System.Globalization; @@ -79,6 +80,10 @@ private enum PreviewSelectionTypes { PreviewFont, PreviewTexture, PreviewDistanc private int font_atlas_width = 512; private int font_atlas_height = 512; + public int CharacterCount => characterSequence.Distinct().Count(); + public int AtlasSize => font_atlas_width; + public bool UseBitmapFontAsset { get; private set; } + //private int m_shaderSelectionIndex; //private Shader m_shaderSelection; //private string[] m_availableShaderNames; @@ -98,6 +103,214 @@ private enum PreviewSelectionTypes { PreviewFont, PreviewTexture, PreviewDistanc private Texture2D m_destination_Atlas; private bool includeKerningPairs = true; private int[] m_kerningSet; + private KerningTable m_kerningTable; + + private const string FontAtlasCacheMagic = "FontModAtlas"; + private const int FontAtlasCacheVersion = 2; + private const int MaxKerningPairCount = 7500; + + // Allocate a large enough atlas. + // The sizes are guesses; a smaller atlas might be good enough? + public void SetCharacterSet(string characters) + { + if (string.IsNullOrEmpty(characters)) { + return; + } + + characterSequence = new string([.. characters.Distinct().OrderBy(c => c)]); + font_CharacterSet_Selection = 7; + + int characterCount = characterSequence.Length; + // Large localized sets are prohibitively slow ._. + // Game startup took like 5 minutes with the normal renderer + UseBitmapFontAsset = characterCount > 512; + font_renderMode = UseBitmapFontAsset ? RenderModes.Smooth : RenderModes.DistanceField16; + + int atlasSize = characterCount switch + { + <= 128 => 512, + <= 512 => 1024, + <= 2048 => 2048, + <= 8192 => 4096, + _ => 8192 + }; + + font_atlas_width = atlasSize; + font_atlas_height = atlasSize; + } + + public bool TryLoadFontAtlasCache(string path) + { + if (!File.Exists(path)) { + return false; + } + + try + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream); + + if (reader.ReadString() != FontAtlasCacheMagic || reader.ReadInt32() != FontAtlasCacheVersion) { + return false; + } + + int atlasWidth = reader.ReadInt32(); + int atlasHeight = reader.ReadInt32(); + var renderMode = (RenderModes)reader.ReadInt32(); + int glyphCount = reader.ReadInt32(); + int expectedTextureLength = checked(font_atlas_width * font_atlas_height); + + if (atlasWidth != font_atlas_width || atlasHeight != font_atlas_height || + renderMode != font_renderMode || glyphCount != CharacterCount) { + return false; + } + + var faceInfo = new FT_FaceInfo + { + name = reader.ReadString(), + pointSize = reader.ReadInt32(), + padding = reader.ReadInt32(), + lineHeight = reader.ReadSingle(), + baseline = reader.ReadSingle(), + ascender = reader.ReadSingle(), + descender = reader.ReadSingle(), + centerLine = reader.ReadSingle(), + underline = reader.ReadSingle(), + underlineThickness = reader.ReadSingle(), + characterCount = reader.ReadInt32(), + atlasWidth = reader.ReadInt32(), + atlasHeight = reader.ReadInt32() + }; + + var glyphInfo = new FT_GlyphInfo[glyphCount]; + for (int i = 0; i < glyphInfo.Length; i++) + { + glyphInfo[i].id = reader.ReadInt32(); + glyphInfo[i].x = reader.ReadSingle(); + glyphInfo[i].y = reader.ReadSingle(); + glyphInfo[i].width = reader.ReadSingle(); + glyphInfo[i].height = reader.ReadSingle(); + glyphInfo[i].xOffset = reader.ReadSingle(); + glyphInfo[i].yOffset = reader.ReadSingle(); + glyphInfo[i].xAdvance = reader.ReadSingle(); + } + + int kerningCount = reader.ReadInt32(); + if (kerningCount < 0 || kerningCount > MaxKerningPairCount) { + return false; + } + + var kerningTable = new KerningTable { kerningPairs = new List(kerningCount) }; + for (int i = 0; i < kerningCount; i++) + { + kerningTable.kerningPairs.Add(new KerningPair( + reader.ReadUInt32(), reader.ReadUInt32(), reader.ReadSingle())); + } + + if (reader.ReadInt32() != expectedTextureLength) { + return false; + } + + byte[] textureBuffer = reader.ReadBytes(expectedTextureLength); + if (textureBuffer.Length != expectedTextureLength || stream.Position != stream.Length) { + return false; + } + + m_font_faceInfo = faceInfo; + m_font_glyphInfo = glyphInfo; + m_kerningTable = kerningTable; + m_character_Count = glyphCount; + m_texture_buffer = textureBuffer; + isRenderingDone = true; + return true; + } + catch (IOException) + { + return false; + } + catch (UnauthorizedAccessException) + { + return false; + } + catch (OverflowException) + { + return false; + } + } + + public void SaveFontAtlasCache(string path) + { + if (m_texture_buffer == null || m_font_glyphInfo == null) { + throw new InvalidOperationException("A font atlas must be generated before it can be cached."); + } + + string directory = Path.GetDirectoryName(path); + Directory.CreateDirectory(directory); + string temporaryPath = path + ".tmp"; + + try + { + using (var stream = File.Create(temporaryPath)) + using (var writer = new BinaryWriter(stream)) + { + writer.Write(FontAtlasCacheMagic); + writer.Write(FontAtlasCacheVersion); + writer.Write(font_atlas_width); + writer.Write(font_atlas_height); + writer.Write((int)font_renderMode); + writer.Write(m_font_glyphInfo.Length); + + writer.Write(m_font_faceInfo.name ?? string.Empty); + writer.Write(m_font_faceInfo.pointSize); + writer.Write(m_font_faceInfo.padding); + writer.Write(m_font_faceInfo.lineHeight); + writer.Write(m_font_faceInfo.baseline); + writer.Write(m_font_faceInfo.ascender); + writer.Write(m_font_faceInfo.descender); + writer.Write(m_font_faceInfo.centerLine); + writer.Write(m_font_faceInfo.underline); + writer.Write(m_font_faceInfo.underlineThickness); + writer.Write(m_font_faceInfo.characterCount); + writer.Write(m_font_faceInfo.atlasWidth); + writer.Write(m_font_faceInfo.atlasHeight); + + foreach (var glyph in m_font_glyphInfo) + { + writer.Write(glyph.id); + writer.Write(glyph.x); + writer.Write(glyph.y); + writer.Write(glyph.width); + writer.Write(glyph.height); + writer.Write(glyph.xOffset); + writer.Write(glyph.yOffset); + writer.Write(glyph.xAdvance); + } + + var kerningPairs = m_kerningTable?.kerningPairs ?? new List(); + writer.Write(kerningPairs.Count); + foreach (var pair in kerningPairs) + { + writer.Write(pair.firstGlyph); + writer.Write(pair.secondGlyph); + writer.Write(pair.xOffset); + } + + writer.Write(m_texture_buffer.Length); + writer.Write(m_texture_buffer); + } + + if (File.Exists(path)) { + File.Delete(path); + } + File.Move(temporaryPath, path); + } + finally + { + if (File.Exists(temporaryPath)) { + File.Delete(temporaryPath); + } + } + } // Image Down Sampling Fields //private Texture2D sdf_Atlas; @@ -368,6 +581,9 @@ public TMP_FontAsset Save_Normal_FontAsset() string tex_FileName = Path.GetFileNameWithoutExtension(font_TTF_path); + // Name is included in Hash + font_asset.name = tex_FileName; + //Set Font Asset Type font_asset.fontAssetType = TMP_FontAsset.FontAssetTypes.Bitmap; @@ -417,6 +633,9 @@ public TMP_FontAsset Save_SDF_FontAsset() string tex_FileName = Path.GetFileNameWithoutExtension(font_TTF_path); + // Name is included in Hash + font_asset.name = tex_FileName; + // Reference to the source font file //font_asset.sourceFontFile = font_TTF as Font; @@ -569,11 +788,15 @@ TMP_Glyph[] GetGlyphInfo(FT_GlyphInfo[] ft_glyphs, int scaleFactor) // Get Kerning Pairs public KerningTable GetKerningTable(string fontFilePath, int pointSize) { + if (m_kerningTable != null) { + return m_kerningTable; + } + KerningTable kerningInfo = new KerningTable(); kerningInfo.kerningPairs = new List(); // Temporary Array to hold the kerning pairs from the Native Plug-in. - FT_KerningPair[] kerningPairs = new FT_KerningPair[7500]; + FT_KerningPair[] kerningPairs = new FT_KerningPair[MaxKerningPairCount]; int kpCount = TMPro_FontPlugin.FT_GetKerningPairs(fontFilePath, m_kerningSet, m_kerningSet.Length, kerningPairs); @@ -592,7 +815,8 @@ public KerningTable GetKerningTable(string fontFilePath, int pointSize) } - return kerningInfo; + m_kerningTable = kerningInfo; + return m_kerningTable; } } -} \ No newline at end of file +} diff --git a/Repository.json b/Repository.json index c77c286..a8117e6 100644 --- a/Repository.json +++ b/Repository.json @@ -2,7 +2,7 @@ "Releases": [ { "Id": "FontMod", - "Version": "1.0.0" + "Version": "1.0.3" } ] } \ No newline at end of file