diff --git a/.gitignore b/.gitignore index 6e201df0..32b6feb7 100644 --- a/.gitignore +++ b/.gitignore @@ -400,3 +400,4 @@ FodyWeavers.xsd # CnCNet /Compiled .idea/ +!/References/* \ No newline at end of file diff --git a/FontManagement/FontManager.cs b/FontManagement/FontManager.cs index c85059c0..a3161f32 100644 --- a/FontManagement/FontManager.cs +++ b/FontManagement/FontManager.cs @@ -7,6 +7,10 @@ using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using Rampastring.Tools; +#if !XNA +using Forme; +using Forme.MonoGame; +#endif namespace Rampastring.XNAUI.FontManagement; @@ -218,6 +222,12 @@ private static void CreateFontIndexesFromIni(IniFile iniFile, ContentManager con string sfName = Path.GetFileNameWithoutExtension(config.Path); LoadSpriteFont(contentManager, searchPath, sfName); break; + +#if !XNA + case FontType.Forme: + CreateFormeFontIndex(i, config, searchPath); + break; +#endif } } } @@ -337,6 +347,79 @@ public FontConfig(string path, int size, FontType fontType, int fallback) } } +#if !XNA + /// + /// Creates a Forme GPU font index from a TTF or .forme file. + /// + /// + /// + /// If a pre-processed .forme file (same base name as the TTF, with .forme + /// extension) exists in the same directory, it is loaded instead of the TTF to avoid + /// reprocessing on every launch. Otherwise the TTF is processed at startup and, if the + /// directory is writable, the result is saved as a .forme file for future runs. + /// + /// + /// Glyphs are baked for (Unicode Basic Latin, + /// U+0020–U+007F). To support a wider character set, pre-process the font with a custom + /// using FormeFont.FromTtf and save it as a + /// .forme file next to the TTF. + /// + /// + private static void CreateFormeFontIndex(int fontIndex, FontConfig config, string searchPath) + { + if (string.IsNullOrEmpty(config.Path)) + { + Logger.Log($"FontManager: Font{fontIndex} - Forme font has no path configured, skipping"); + return; + } + + string ttfFullPath = SafePath.GetFile(searchPath, config.Path).FullName; + if (!File.Exists(ttfFullPath)) + { + Logger.Log($"FontManager: Font{fontIndex} - Forme font file not found: {ttfFullPath}"); + return; + } + + try + { + // Look for a pre-processed .forme file alongside the TTF. + string formePath = Path.ChangeExtension(ttfFullPath, ".forme"); + FormeFont formeFont; + + if (File.Exists(formePath)) + { + formeFont = FormeFont.FromFile(formePath); + Logger.Log($"FontManager: Font{fontIndex} - Loaded pre-processed font from {formePath}"); + } + else + { + byte[] ttfData = File.ReadAllBytes(ttfFullPath); + formeFont = FormeFont.FromTtf(ttfData, CharacterSet.BasicLatin); + Logger.Log($"FontManager: Font{fontIndex} - Processed TTF font from {ttfFullPath}"); + + // Try to save the processed font for faster future loads. + try + { + formeFont.Save(formePath); + Logger.Log($"FontManager: Font{fontIndex} - Saved processed font to {formePath}"); + } + catch (Exception ex) + { + Logger.Log($"FontManager: Font{fontIndex} - Could not save processed font to {formePath}: {ex.Message}"); + } + } + + var fontDevice = new FormeFontDevice(Renderer.GraphicsDevice, formeFont); + fonts.Add(new FormeFontWrapper(fontDevice, Renderer.FormeRenderer, config.Size)); + Logger.Log($"FontManager: Created FontIndex {fonts.Count - 1}: Forme size {config.Size} ({Path.GetFileName(config.Path)})"); + } + catch (Exception ex) + { + Logger.Log($"FontManager: Font{fontIndex} - Failed to create Forme font from {config.Path}: {ex.Message}"); + } + } +#endif + /// /// Loads a SpriteFont and adds it to the font list. /// diff --git a/FontManagement/FontType.cs b/FontManagement/FontType.cs index b518c2fb..3ab0d9da 100644 --- a/FontManagement/FontType.cs +++ b/FontManagement/FontType.cs @@ -3,5 +3,6 @@ namespace Rampastring.XNAUI.FontManagement; public enum FontType { SpriteFont, - TrueType + TrueType, + Forme } diff --git a/FontManagement/FormeFontWrapper.cs b/FontManagement/FormeFontWrapper.cs new file mode 100644 index 00000000..0832cd26 --- /dev/null +++ b/FontManagement/FormeFontWrapper.cs @@ -0,0 +1,131 @@ +#if !XNA +using FontStashSharp; +using Forme; +using Forme.MonoGame; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using System; + +namespace Rampastring.XNAUI.FontManagement; + +/// +/// A font wrapper that renders text using the Forme GPU-accelerated Slug algorithm. +/// +/// +/// +/// Forme renders text directly from quadratic Bezier glyph outlines on the GPU without +/// precomputed textures, distance fields, or rasterized atlases. Text remains sharp at +/// any size and scale. +/// +/// +/// Because Forme uses its own renderer rather than SpriteBatch, each +/// call +/// flushes the active SpriteBatch, draws with , then +/// restarts the SpriteBatch. This preserves correct draw order at the cost of one +/// extra flush per text call. +/// +/// +public class FormeFontWrapper : IFont, IDisposable +{ + private readonly FormeFontDevice _fontDevice; + private readonly FormeRenderer _renderer; + private readonly int _sizePixels; + + // Ascent in pixels at size 1.0: multiply by (sizePixels * scale) when drawing. + private readonly float _ascentRatio; + + private bool _disposed; + + /// + /// Initializes a new . + /// + /// The GPU font device that owns the curve and band textures. + /// The shared used for drawing. + /// The nominal em-square height in pixels for this font size. + public FormeFontWrapper(FormeFontDevice fontDevice, FormeRenderer renderer, int sizePixels) + { + _fontDevice = fontDevice ?? throw new ArgumentNullException(nameof(fontDevice)); + _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); + _sizePixels = sizePixels; + + int unitsPerEm = Math.Max(1, fontDevice.Metrics.UnitsPerEm); + _ascentRatio = (float)fontDevice.Metrics.Ascent / unitsPerEm; + } + + /// + public Vector2 MeasureString(string text) + { + if (string.IsNullOrEmpty(text)) + return Vector2.Zero; + + var bounds = _fontDevice.Font.MeasureString(text.AsSpan(), (float)_sizePixels); + return new Vector2(bounds.Width, bounds.Height); + } + + /// + /// + /// The is interpreted as the top-left corner of the text, + /// consistent with the rest of the XNAUI framework. Internally this is converted to a + /// baseline origin as required by . + /// + public void DrawString(SpriteBatch spriteBatch, string text, Vector2 location, Color color, float scale, float depth) + { + if (string.IsNullOrEmpty(text)) + return; + + // Forme expects a baseline origin; convert from the top-left corner used by XNAUI. + float ascentPixels = _ascentRatio * _sizePixels * scale; + Vector2 baseline = new Vector2(location.X, location.Y + ascentPixels); + + // Flush the active SpriteBatch before using FormeRenderer, then restart it. + // FormeRenderer.Begin/End save and restore all GraphicsDevice state, so the + // SpriteBatch can resume with Begin() using the same settings. + Renderer.EndDraw(); + + _renderer.Begin(); + // Note: explicitly passing through `new TextLayoutOptions()` to workaround the following issue: + // '\n' in DrawString doesn't work without TextLayoutOptions #12 + // https://github.com/AristurtleDev/Forme/issues/12 + _renderer.DrawString(_fontDevice, text, baseline, _sizePixels * scale, color, options: new TextLayoutOptions()); + _renderer.End(); + + Renderer.BeginDraw(); + } + + /// + public void DrawString(SpriteBatch spriteBatch, StringSegment text, Vector2 location, Color color, float rotation, Vector2 origin, Vector2 scale, float depth) + { + // StringSegment overload: convert to string and delegate. + // Rotation is not supported by FormeRenderer. For non-uniform scale, the larger + // axis is used so that text is never clipped. + float uniformScale = Math.Max(scale.X, scale.Y); + DrawString(spriteBatch, text.ToString(), location, color, uniformScale, depth); + } + + /// + /// + /// Always returns . Forme skips glyphs not present in the baked + /// character set rather than substituting a replacement character, so missing characters + /// are simply not rendered. + /// + public bool HasCharacter(char c) => true; + + /// + /// + /// Returns unchanged. Forme handles missing glyphs silently by + /// skipping them, so no character substitution is needed. + /// + public string GetSafeString(string str) => str; + + /// + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _fontDevice.Dispose(); + GC.SuppressFinalize(this); + } +} +#endif diff --git a/Rampastring.XNAUI.csproj b/Rampastring.XNAUI.csproj index b07f0c91..40b30758 100644 --- a/Rampastring.XNAUI.csproj +++ b/Rampastring.XNAUI.csproj @@ -104,13 +104,11 @@ - - + - - + @@ -196,4 +194,9 @@ + + + + + \ No newline at end of file diff --git a/References/CnCNet.Forme.MonoGame.Content.Pipeline.NET48.0.0.3.1.nupkg b/References/CnCNet.Forme.MonoGame.Content.Pipeline.NET48.0.0.3.1.nupkg new file mode 100644 index 00000000..e2def9af Binary files /dev/null and b/References/CnCNet.Forme.MonoGame.Content.Pipeline.NET48.0.0.3.1.nupkg differ diff --git a/References/CnCNet.Forme.MonoGame.NET48.0.0.3.1.nupkg b/References/CnCNet.Forme.MonoGame.NET48.0.0.3.1.nupkg new file mode 100644 index 00000000..e17c2359 Binary files /dev/null and b/References/CnCNet.Forme.MonoGame.NET48.0.0.3.1.nupkg differ diff --git a/References/CnCNet.Forme.NET48.0.0.3.1.nupkg b/References/CnCNet.Forme.NET48.0.0.3.1.nupkg new file mode 100644 index 00000000..c166c952 Binary files /dev/null and b/References/CnCNet.Forme.NET48.0.0.3.1.nupkg differ diff --git a/Renderer.cs b/Renderer.cs index 42e8271e..53832eae 100644 --- a/Renderer.cs +++ b/Renderer.cs @@ -5,6 +5,9 @@ using Microsoft.Xna.Framework.Content; using FontStashSharp; using Rampastring.XNAUI.FontManagement; +#if !XNA +using Forme.MonoGame; +#endif #if XNA using System.Reflection; #endif @@ -40,12 +43,31 @@ public static class Renderer internal static SpriteBatchSettings CurrentSettings; +#if !XNA + /// + /// Gets the used for rendering. + /// Available after has been called. + /// + internal static GraphicsDevice GraphicsDevice { get; private set; } + + /// + /// Gets the shared used by . + /// Available after has been called. + /// + internal static FormeRenderer FormeRenderer { get; private set; } +#endif + public static SpriteBatchSettings GetCurrentSettings() => CurrentSettings; public static void Initialize(GraphicsDevice gd, ContentManager content) { spriteBatch = new SpriteBatch(gd); +#if !XNA + GraphicsDevice = gd; + FormeRenderer = new FormeRenderer(gd); +#endif + FontManager.Initialize(); FontManager.LoadFonts(content); diff --git a/nuget.config b/nuget.config index aa5beec8..546b02c0 100644 --- a/nuget.config +++ b/nuget.config @@ -1,5 +1,8 @@ + + + - + \ No newline at end of file