diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index d67bd5c409..0f565e6aae 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -14,7 +14,9 @@ + + @@ -123,4 +125,4 @@ - + \ No newline at end of file diff --git a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs new file mode 100644 index 0000000000..1b22c3a4e2 --- /dev/null +++ b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System.ComponentModel; +using Stride.Core; +using Stride.Core.Annotations; +using Stride.Core.Mathematics; + +namespace Stride.Assets.SpriteFont +{ + [DataContract("RuntimeSignedDistanceFieldSpriteFontType")] + [Display("Runtime SDF")] + public class RuntimeSignedDistanceFieldSpriteFontType : SpriteFontTypeBase + { + /// + [DataMember(30)] + [DataMemberRange(MathUtil.ZeroTolerance, 2)] + [DefaultValue(20)] + [Display("Default Size")] + public override float Size { get; set; } = 64; + + /// + /// Distance field range/spread (in pixels) used during MSDF generation. + /// + [DataMember(40)] + [DefaultValue(8)] + [DataMemberRange(1, 64, 1, 4, 0)] + [Display("Pixel Range")] + public int PixelRange { get; set; } = 8; + + /// + /// Extra padding around each glyph inside the atlas (in pixels). + /// + [DataMember(50)] + [DefaultValue(2)] + [DataMemberRange(0, 16, 1, 2, 0)] + [Display("Padding")] + public int Padding { get; set; } = 2; + } +} diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs index 25fe062bd7..5ce0796032 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs @@ -28,8 +28,7 @@ protected override void Prepare(AssetCompilerContext context, AssetItem assetIte UFile assetAbsolutePath = assetItem.FullPath; var colorSpace = context.GetColorSpace(); - var fontTypeSdf = asset.FontType as SignedDistanceFieldSpriteFontType; - if (fontTypeSdf != null) + if (asset.FontType is SignedDistanceFieldSpriteFontType fontTypeSdf) { // copy the asset and transform the source and character set file path to absolute paths var assetClone = AssetCloner.Clone(asset); @@ -40,39 +39,54 @@ protected override void Prepare(AssetCompilerContext context, AssetItem assetIte result.BuildSteps = new AssetBuildStep(assetItem); result.BuildSteps.Add(new SignedDistanceFieldFontCommand(targetUrlInStorage, assetClone, assetItem.Package)); } - else - if (asset.FontType is RuntimeRasterizedSpriteFontType) + else if (asset.FontType is RuntimeRasterizedSpriteFontType) + { + UFile fontPathOnDisk = asset.FontSource.GetFontPath(result); + if (fontPathOnDisk == null) { - UFile fontPathOnDisk = asset.FontSource.GetFontPath(result); - if (fontPathOnDisk == null) - { - result.Error($"Runtime rasterized font compilation failed. Font {asset.FontSource.GetFontName()} was not found on this machine."); - result.BuildSteps = new AssetBuildStep(assetItem); - result.BuildSteps.Add(new FailedFontCommand()); - return; - } - - var fontImportLocation = FontHelper.GetFontPath(asset.FontSource.GetFontName(), asset.FontSource.Style); - + result.Error($"Runtime rasterized font compilation failed. Font {asset.FontSource.GetFontName()} was not found on this machine."); result.BuildSteps = new AssetBuildStep(assetItem); - result.BuildSteps.Add(new ImportStreamCommand { SourcePath = fontPathOnDisk, Location = fontImportLocation }); - result.BuildSteps.Add(new RuntimeRasterizedFontCommand(targetUrlInStorage, asset, assetItem.Package)); + result.BuildSteps.Add(new FailedFontCommand()); + return; } - else - { - var fontTypeStatic = asset.FontType as OfflineRasterizedSpriteFontType; - if (fontTypeStatic == null) - throw new ArgumentException("Tried to compile a non-offline rasterized sprite font with the compiler for offline resterized fonts!"); - // copy the asset and transform the source and character set file path to absolute paths - var assetClone = AssetCloner.Clone(asset); - var assetDirectory = assetAbsolutePath.GetParent(); - assetClone.FontSource = asset.FontSource; - fontTypeStatic.CharacterSet = !string.IsNullOrEmpty(fontTypeStatic.CharacterSet) ? UPath.Combine(assetDirectory, fontTypeStatic.CharacterSet): null; + var fontImportLocation = FontHelper.GetFontPath(asset.FontSource.GetFontName(), asset.FontSource.Style); + result.BuildSteps = new AssetBuildStep(assetItem); + result.BuildSteps.Add(new ImportStreamCommand { SourcePath = fontPathOnDisk, Location = fontImportLocation }); + result.BuildSteps.Add(new RuntimeRasterizedFontCommand(targetUrlInStorage, asset, assetItem.Package)); + } + else if (asset.FontType is RuntimeSignedDistanceFieldSpriteFontType) + { + UFile fontPathOnDisk = asset.FontSource.GetFontPath(result); + if (fontPathOnDisk == null) + { + result.Error($"Runtime SDF font compilation failed. Font {asset.FontSource.GetFontName()} was not found on this machine."); result.BuildSteps = new AssetBuildStep(assetItem); - result.BuildSteps.Add(new OfflineRasterizedFontCommand(targetUrlInStorage, assetClone, colorSpace, assetItem.Package)); + result.BuildSteps.Add(new FailedFontCommand()); + return; } + + var fontImportLocation = FontHelper.GetFontPath(asset.FontSource.GetFontName(), asset.FontSource.Style); + + result.BuildSteps = new AssetBuildStep(assetItem); + result.BuildSteps.Add(new ImportStreamCommand { SourcePath = fontPathOnDisk, Location = fontImportLocation }); + result.BuildSteps.Add(new RuntimeSignedDistanceFieldFontCommand(targetUrlInStorage, asset, assetItem.Package)); + } + else + { + if (asset.FontType is not OfflineRasterizedSpriteFontType fontTypeStatic) + throw new ArgumentException("Tried to compile a non-offline rasterized sprite font with the compiler for offline resterized fonts!"); + + // copy the asset and transform the source and character set file path to absolute paths + var assetClone = AssetCloner.Clone(asset); + var assetDirectory = assetAbsolutePath.GetParent(); + assetClone.FontSource = asset.FontSource; + fontTypeStatic.CharacterSet = !string.IsNullOrEmpty(fontTypeStatic.CharacterSet) ? UPath.Combine(assetDirectory, fontTypeStatic.CharacterSet) : null; + + result.BuildSteps = new AssetBuildStep(assetItem); + result.BuildSteps.Add(new OfflineRasterizedFontCommand(targetUrlInStorage, assetClone, colorSpace, assetItem.Package)); + } } internal class OfflineRasterizedFontCommand : AssetCommand @@ -88,15 +102,13 @@ public OfflineRasterizedFontCommand(string url, SpriteFontAsset description, Col public override IEnumerable GetInputFiles() { var asset = Parameters; - var fontTypeStatic = asset.FontType as OfflineRasterizedSpriteFontType; - if (fontTypeStatic != null) + if (asset.FontType is OfflineRasterizedSpriteFontType fontTypeStatic) { if (File.Exists(fontTypeStatic.CharacterSet)) yield return new ObjectUrl(UrlType.File, fontTypeStatic.CharacterSet); } - var fontTypeSdf = asset.FontType as SignedDistanceFieldSpriteFontType; - if (fontTypeSdf != null) + if (asset.FontType is SignedDistanceFieldSpriteFontType fontTypeSdf) { if (File.Exists(fontTypeSdf.CharacterSet)) yield return new ObjectUrl(UrlType.File, fontTypeSdf.CharacterSet); @@ -118,7 +130,7 @@ protected override Task DoCommandOverride(ICommandContext commandC { staticFont = OfflineRasterizedFontCompiler.Compile(FontDataFactory, Parameters, colorspace == ColorSpace.Linear); } - catch (FontNotFoundException ex) + catch (FontNotFoundException ex) { commandContext.Logger.Error($"Font [{ex.FontName}] was not found on this machine.", ex); return Task.FromResult(ResultStatus.Failed); @@ -190,9 +202,9 @@ public RuntimeRasterizedFontCommand(string url, SpriteFontAsset description, IAs protected override Task DoCommandOverride(ICommandContext commandContext) { var dynamicFont = FontDataFactory.NewDynamic( - Parameters.FontType.Size, Parameters.FontSource.GetFontName(), Parameters.FontSource.Style, - Parameters.FontType.AntiAlias, useKerning:false, extraSpacing:Parameters.Spacing, extraLineSpacing:Parameters.LineSpacing, - defaultCharacter:Parameters.DefaultCharacter); + Parameters.FontType.Size, Parameters.FontSource.GetFontName(), Parameters.FontSource.Style, + Parameters.FontType.AntiAlias, useKerning: false, extraSpacing: Parameters.Spacing, extraLineSpacing: Parameters.LineSpacing, + defaultCharacter: Parameters.DefaultCharacter); var assetManager = new ContentManager(MicrothreadLocalDatabases.ProviderService); assetManager.Save(Url, dynamicFont); @@ -201,6 +213,37 @@ protected override Task DoCommandOverride(ICommandContext commandC } } + internal class RuntimeSignedDistanceFieldFontCommand : AssetCommand + { + public RuntimeSignedDistanceFieldFontCommand(string url, SpriteFontAsset description, IAssetFinder assetFinder) + : base(url, description, assetFinder) + { + } + + protected override Task DoCommandOverride(ICommandContext commandContext) + { + commandContext.Logger.Warning("Runtime SDF font is currently an experimental feature."); + + var runtimeSdfType = (RuntimeSignedDistanceFieldSpriteFontType)Parameters.FontType; + + var sdfFont = FontDataFactory.NewRuntimeSignedDistanceField( + runtimeSdfType.Size, + Parameters.FontSource.GetFontName(), + Parameters.FontSource.Style, + runtimeSdfType.PixelRange, + runtimeSdfType.Padding, + useKerning: false, + extraSpacing: Parameters.Spacing, + extraLineSpacing: Parameters.LineSpacing, + defaultCharacter: Parameters.DefaultCharacter); + + var assetManager = new ContentManager(MicrothreadLocalDatabases.ProviderService); + assetManager.Save(Url, sdfFont); + + return Task.FromResult(ResultStatus.Successful); + } + } + /// /// Proxy command which always fails, called when font is compiled with the wrong assets /// diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetFactories.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetFactories.cs index b20f521615..a3bdda116f 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetFactories.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetFactories.cs @@ -14,7 +14,7 @@ public static SpriteFontAsset Create() FontSource = new SystemFontProvider(), FontType = new OfflineRasterizedSpriteFontType() { - CharacterRegions = { new CharacterRegion(' ', (char)127) } + CharacterRegions = { new CharacterRegion(' ', (char)127) } }, }; } @@ -42,7 +42,7 @@ public override SpriteFontAsset New() } } - public class SignedDistanceFieldSpriteFontFactory: AssetFactory + public class SignedDistanceFieldSpriteFontFactory : AssetFactory { public static SpriteFontAsset Create() { @@ -61,4 +61,21 @@ public override SpriteFontAsset New() return Create(); } } + + public class RuntimeSignedDistanceFieldSpriteFontFactory : AssetFactory + { + public static SpriteFontAsset Create() + { + return new SpriteFontAsset + { + FontSource = new SystemFontProvider(), + FontType = new RuntimeSignedDistanceFieldSpriteFontType(), + }; + } + + public override SpriteFontAsset New() + { + return Create(); + } + } } diff --git a/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs b/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs new file mode 100644 index 0000000000..675fb08ba0 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs @@ -0,0 +1,135 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using System.Data; +using Stride.Core; + +namespace Stride.Graphics.Font +{ + /// + /// An RGBA bitmap representing a glyph (4 bytes per pixel). + /// Intended for runtime MSDF font (stored in RGB, alpha optional). + /// + internal sealed class CharacterBitmapRgba : IDisposable + { + private readonly int width; + private readonly int rows; + private readonly int pitch; + private readonly IntPtr buffer; + + private bool disposed; + + /// + /// Initializes a null bitmap. + /// + public CharacterBitmapRgba() + { + } + + /// + /// Allocates an RGBA bitmap (uninitialized). + /// + public CharacterBitmapRgba(int width, int rows) + { + if (width < 0) throw new ArgumentOutOfRangeException(nameof(width)); + if (rows < 0) throw new ArgumentOutOfRangeException(nameof(rows)); + + this.width = width; + this.rows = rows; + pitch = checked(width * 4); + + if (width != 0 && rows != 0) + { + buffer = MemoryUtilities.Allocate(checked(pitch * rows), 1); + } + } + + /// + /// Allocates an RGBA bitmap and copies data from a source buffer with the given pitch. + /// + public unsafe CharacterBitmapRgba(IntPtr srcRgba, int width, int rows, int srcPitchBytes) + : this(width, rows) + { + if (srcRgba == IntPtr.Zero && (width != 0 || rows != 0)) + throw new ArgumentNullException(nameof(srcRgba)); + if (srcPitchBytes < 0) throw new ArgumentOutOfRangeException(nameof(srcPitchBytes)); + + if (buffer == IntPtr.Zero) + return; + + var src = (byte*)srcRgba; + var dst = (byte*)buffer; + + // Copy row-by-row to handle pitch differences. + var copyBytesPerRow = Math.Min(srcPitchBytes, pitch); + for (int y = 0; y < rows; y++) + { + var srcRow = src + y * srcPitchBytes; + var dstRow = dst + y * pitch; + + MemoryUtilities.CopyWithAlignmentFallback(dstRow, srcRow, (uint)copyBytesPerRow); + + if (copyBytesPerRow < pitch) + { + MemoryUtilities.Clear(dstRow + copyBytesPerRow, (uint)(pitch - copyBytesPerRow)); + } + } + } + + public bool IsDisposed => disposed; + + public int Width + { + get + { + ThrowIfDisposed(); + return width; + } + } + + public int Rows + { + get + { + ThrowIfDisposed(); + return rows; + } + } + + public int Pitch + { + get + { + ThrowIfDisposed(); + return pitch; + } + } + + public IntPtr Buffer + { + get + { + ThrowIfDisposed(); + return buffer; + } + } + + public void Dispose() + { + if (disposed) + return; + + if (buffer != IntPtr.Zero) + MemoryUtilities.Free(buffer); + + disposed = true; + } + + private void ThrowIfDisposed() + { + if (disposed) + throw new ObjectDisposedException(nameof(CharacterBitmapRgba), "Cannot access a disposed object."); + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs new file mode 100644 index 0000000000..2a3665395b --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -0,0 +1,211 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using System.Collections.Generic; +using Stride.Core; +using Stride.Core.Mathematics; + +namespace Stride.Graphics.Font +{ + /// + /// GPU cache for RGBA glyphs (intended for runtime MSDF). + /// Parallel to to keep the R8 path unchanged. + /// + internal class FontCacheManagerMsdf : ComponentBase + { + private readonly FontSystem system; + + private readonly List cacheTextures = []; + private readonly LinkedList cachedGlyphs = new(); + private readonly GuillotinePacker packer = new(); + + public int AtlasPaddingPixels = 2; + + public IReadOnlyList Textures { get; private set; } + + public FontCacheManagerMsdf(FontSystem system, int textureDefaultSize = 2048) + { + this.system = system ?? throw new ArgumentNullException(nameof(system)); + Textures = cacheTextures; + + var newTexture = Texture.New2D(system.GraphicsDevice, textureDefaultSize, textureDefaultSize, PixelFormat.R8G8B8A8_UNorm); + cacheTextures.Add(newTexture); + newTexture.Reload = ReloadCache; + + ClearCache(); + } + + private void ReloadCache(GraphicsResourceBase graphicsResourceBase, IServiceRegistry services) + { + foreach (var cacheTexture in cacheTextures) + cacheTexture.Recreate(); + + ClearCache(); + } + + public void ClearCache() + { + foreach (var glyph in cachedGlyphs) + { + glyph.IsUploaded = false; + glyph.Owner?.IsBitmapUploaded = false; + } + + cachedGlyphs.Clear(); + packer.Clear(cacheTextures[0].ViewWidth, cacheTextures[0].ViewHeight); + + } + + /// + /// Upload an RGBA glyph bitmap into the MSDF cache and return its packed sub-rectangle. + /// + public MsdfCachedGlyph UploadGlyphBitmap( + CommandList commandList, + CharacterSpecification owner, + CharacterBitmapRgba bitmap, + ref Rectangle subrect, + out int bitmapIndex) + { + ArgumentNullException.ThrowIfNull(bitmap); + ArgumentNullException.ThrowIfNull(commandList); + + bitmapIndex = 0; + + var atlasPad = AtlasPaddingPixels; + + if (!packer.Insert(bitmap.Width + atlasPad * 2, bitmap.Rows + atlasPad * 2, ref subrect)) + { + if (!EnsureSpaceFor(bitmap.Width, bitmap.Rows, atlasPad)) + throw new InvalidOperationException("MSDF glyph does not fit in cache even after eviction."); + + if (!packer.Insert(bitmap.Width + atlasPad * 2, bitmap.Rows + atlasPad * 2, ref subrect)) + throw new InvalidOperationException("MSDF cache allocation failed unexpectedly after eviction."); + } + + if (bitmap.Rows != 0 && bitmap.Width != 0) + { + int dstX = subrect.Left + atlasPad; + int dstY = subrect.Top + atlasPad; + + var dataBox = new DataBox(bitmap.Buffer, bitmap.Pitch, bitmap.Pitch * bitmap.Rows); + var region = new ResourceRegion(dstX, dstY, 0, dstX + bitmap.Width, dstY + bitmap.Rows, 1); + commandList.UpdateSubResource(cacheTextures[0], 0, dataBox, region); + } + + // Track for eviction behavior parity (frame-based LRU). + var outer = subrect; + var inner = new Rectangle(outer.Left + atlasPad, outer.Top + atlasPad, bitmap.Width, bitmap.Rows); + + var cached = new MsdfCachedGlyph + { + Owner = owner, + OuterSubrect = outer, + InnerSubrect = inner, + BitmapIndex = 0, + LastUsedFrame = system.FrameCount, + IsUploaded = true, + }; + + cachedGlyphs.AddFirst(cached.ListNode); + return cached; + } + + public void NotifyGlyphUtilization(MsdfCachedGlyph glyph) + { + glyph.LastUsedFrame = system.FrameCount; + + if (glyph.ListNode.List != null) + cachedGlyphs.Remove(glyph.ListNode); + + cachedGlyphs.AddFirst(glyph.ListNode); + } + + private void RemoveLessUsedGlyphs(int frameCount = 1) + { + var limitFrame = system.FrameCount - frameCount; + var currentNode = cachedGlyphs.Last; + + while (currentNode != null && currentNode.Value.LastUsedFrame < limitFrame) + { + currentNode.Value.IsUploaded = false; + currentNode.Value.Owner?.IsBitmapUploaded = false; + packer.Free(ref currentNode.Value.OuterSubrect); + + var prev = currentNode.Previous; + cachedGlyphs.RemoveLast(); + currentNode = prev; + } + } + + protected override void Destroy() + { + base.Destroy(); + + foreach (var cacheTexture in cacheTextures) + cacheTexture.Dispose(); + + cacheTextures.Clear(); + cachedGlyphs.Clear(); + } + + /// + /// Internal tracking record for eviction parity with the R8 cache. + /// + internal sealed class MsdfCachedGlyph + { + public int BitmapIndex; + public int LastUsedFrame; + public bool IsUploaded; + public CharacterSpecification Owner; + + public readonly LinkedListNode ListNode; + public Rectangle OuterSubrect; + public Rectangle InnerSubrect; + + + public MsdfCachedGlyph() + { + ListNode = new LinkedListNode(this); + } + + + } + private bool EnsureSpaceFor(int w, int h, int pad) + { + for (int pass = 0; pass < 3; pass++) + { + RemoveLessUsedGlyphs(pass switch + { + 0 => 120, + 1 => 30, + _ => 1, + }); + + var test = new Rectangle(); + if (packer.Insert(w + pad * 2, h + pad * 2, ref test)) + { + packer.Free(ref test); + return true; + } + } + + // FINAL ATTEMPT: If partial eviction failed, wipe the whole cache. + // This handles high fragmentation or a very "busy" frame. + + ClearCache(); + + var finalTest = new Rectangle(); + if (packer.Insert(w + pad * 2, h + pad * 2, ref finalTest)) + { + packer.Free(ref finalTest); + return true; + } + + return false; + } + } + + + +} diff --git a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs index c2af015535..9c780966c1 100644 --- a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs +++ b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs @@ -50,6 +50,34 @@ public SpriteFont NewDynamic(float defaultSize, string fontName, FontStyle style }; } + public SpriteFont NewRuntimeSignedDistanceField( + float defaultSize, + string fontName, + FontStyle style, + int pixelRange, + int padding, + bool useKerning, + float extraSpacing, + float extraLineSpacing, + char defaultCharacter) + { + return new RuntimeSignedDistanceFieldSpriteFont + { + Size = defaultSize, + DefaultCharacter = defaultCharacter, + + FontName = fontName, + Style = style, + + PixelRange = pixelRange, + Padding = padding, + + UseKerning = useKerning, + ExtraSpacing = extraSpacing, + ExtraLineSpacing = extraLineSpacing, + }; + } + public SpriteFont NewScalable(float size, IList glyphs, IList textures, float baseOffset, float defaultLineSpacing, IList kernings = null, float extraSpacing = 0, float extraLineSpacing = 0, char defaultCharacter = ' ') { if (textures == null) throw new ArgumentNullException("textures"); diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index cac5ae1ab5..7e625a5390 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -10,6 +10,7 @@ using Stride.Core.IO; using Stride.Core.Mathematics; using Stride.Core.Serialization.Contents; +using Stride.Graphics.Font.RuntimeMsdf; namespace Stride.Graphics.Font { @@ -21,27 +22,27 @@ internal class FontManager : IDisposable /// /// Lock both and . /// - private readonly object dataStructuresLock = new object(); + private readonly Lock dataStructuresLock = new(); /// /// The font data that are currently cached in the registry /// - private readonly Dictionary cachedFontFaces = new Dictionary(); + private readonly Dictionary cachedFontFaces = []; /// /// The list of the bitmaps that have already been generated. /// - private readonly List generatedBitmaps = new List(); + private readonly List generatedBitmaps = []; /// /// The list of the bitmaps that are in generation or to generate /// - private readonly Queue bitmapsToGenerate = new Queue(); + private readonly Queue bitmapsToGenerate = new(); /// /// The used to signal the bitmap build thread that a build operation is requested. /// - private readonly AutoResetEvent bitmapBuildSignal = new AutoResetEvent(false); + private readonly AutoResetEvent bitmapBuildSignal = new(false); /// /// The thread in charge of building the characters bitmaps @@ -217,7 +218,7 @@ private static void ResetGlyph(CharacterSpecification character) character.Glyph.Subrect.Height = 0; } - private void SetFontFaceSize(Face fontFace, Vector2 size) + private static void SetFontFaceSize(Face fontFace, Vector2 size) { // calculate and set the size of the font // size is in 26.6 factional points (that is in 1/64th of points) @@ -274,11 +275,8 @@ public void Dispose() // free and clear the list of generated bitmaps foreach (var character in generatedBitmaps) { - if (character.Bitmap != null) - { - character.Bitmap.Dispose(); - character.Bitmap = null; - } + character.Bitmap?.Dispose(); + character.Bitmap = null; } generatedBitmaps.Clear(); @@ -288,8 +286,7 @@ public void Dispose() cachedFontFaces.Clear(); // free freetype library - if (freetypeLibrary != null) - freetypeLibrary.Dispose(); + freetypeLibrary?.Dispose(); freetypeLibrary = null; } @@ -310,15 +307,13 @@ private void LoadFontInMemory(string fontPath) return; // Load the font from the database - using (var fontStream = contentManager.OpenAsStream(fontPath)) - { - // create the font data from the stream - var newFontData = new byte[fontStream.Length]; - fontStream.Read(newFontData, 0, newFontData.Length); + using var fontStream = contentManager.OpenAsStream(fontPath); + // create the font data from the stream + var newFontData = new byte[fontStream.Length]; + fontStream.ReadExactly(newFontData); - lock (freetypeLibrary) - cachedFontFaces[fontPath] = freetypeLibrary.NewMemoryFace(newFontData, 0); - } + lock (freetypeLibrary) + cachedFontFaces[fontPath] = freetypeLibrary.NewMemoryFace(newFontData, 0); } /// @@ -360,13 +355,46 @@ private void BuildBitmapThread() RenderBitmap(character, fontFace); } - DequeueRequest: +DequeueRequest: - // update the generated cached data +// update the generated cached data lock (dataStructuresLock) bitmapsToGenerate.Dequeue(); } } } + + /// + /// Extracts a glyph outline (vector shape) for MSDF generation. + /// This is intentionally synchronous and protected by the same FreeType lock as bitmap generation. + /// If serialization/perf control is needed later, route this request through the existing + /// bitmap builder thread and return a copied . + /// + public bool TryGetGlyphOutline( + string fontFamily, + FontStyle fontStyle, + Vector2 size, // Use Vector2 as the primary input + char character, + out GlyphOutline outline, + out GlyphOutlineMetrics metrics, + LoadFlags loadFlags = LoadFlags.NoBitmap | LoadFlags.NoHinting) + { + outline = null; + metrics = default; + + var fontFace = GetOrCreateFontFace(fontFamily, fontStyle); + + lock (freetypeLibrary) + { + SetFontFaceSize(fontFace, size); + + return SharpFontOutlineExtractor.TryExtractGlyphOutline( + fontFace, + (uint)character, + out outline, + out metrics, + loadFlags); + } + } } } diff --git a/sources/engine/Stride.Graphics/Font/FontSystem.cs b/sources/engine/Stride.Graphics/Font/FontSystem.cs index a94daecb55..c9f40d20fd 100644 --- a/sources/engine/Stride.Graphics/Font/FontSystem.cs +++ b/sources/engine/Stride.Graphics/Font/FontSystem.cs @@ -19,6 +19,8 @@ public class FontSystem : IFontFactory internal FontManager FontManager { get; private set; } internal GraphicsDevice GraphicsDevice { get; private set; } internal FontCacheManager FontCacheManager { get; private set; } + internal FontCacheManagerMsdf FontCacheManagerMsdf { get; private set; } + internal readonly HashSet AllocatedSpriteFonts = new HashSet(); /// @@ -50,6 +52,7 @@ public void Load(GraphicsDevice graphicsDevice, IDatabaseFileProviderService fil GraphicsDevice = graphicsDevice; FontManager = new FontManager(fileProviderService); FontCacheManager = new FontCacheManager(this); + FontCacheManagerMsdf = new FontCacheManagerMsdf(this); RuntimeFonts = new RuntimeFontProvider(this); } @@ -61,7 +64,7 @@ public void Load(GraphicsDevice graphicsDevice, IDatabaseFileProviderService fil /// The default font size in pixels. /// The font style. /// A instance if the font is registered; otherwise, null. - public SpriteFont? LoadRuntimeFont(string fontName, float defaultSize = 16f, FontStyle style = FontStyle.Regular) + public SpriteFont LoadRuntimeFont(string fontName, float defaultSize = 16f, FontStyle style = FontStyle.Regular) { if (!RuntimeFonts.IsRegistered(fontName, style)) return null; @@ -79,6 +82,7 @@ public void Unload() // TODO possibly save generated characters bitmaps on the disk FontManager.Dispose(); FontCacheManager.Dispose(); + FontCacheManagerMsdf.Dispose(); // Dispose create sprite fonts foreach (var allocatedSpriteFont in AllocatedSpriteFonts.ToArray()) @@ -134,5 +138,37 @@ public SpriteFont NewDynamic(float defaultSize, string fontName, FontStyle style return font; } + + public SpriteFont NewRuntimeSignedDistanceField( + float defaultSize, + string fontName, + FontStyle style, + int pixelRange, + int padding, + bool useKerning, + float extraSpacing, + float extraLineSpacing, + char defaultCharacter) + { + var font = new RuntimeSignedDistanceFieldSpriteFont + { + Size = defaultSize, + DefaultCharacter = defaultCharacter, + + FontName = fontName, + Style = style, + + PixelRange = pixelRange, + Padding = padding, + + UseKerning = useKerning, + ExtraSpacing = extraSpacing, + ExtraLineSpacing = extraLineSpacing, + + FontSystem = this + }; + + return font; + } } } diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/DummyTestRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/DummyTestRasterizer.cs new file mode 100644 index 0000000000..b6d761908e --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/DummyTestRasterizer.cs @@ -0,0 +1,133 @@ +using System; +using Stride.Core.Mathematics; +using Stride.Graphics.Font; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Dummy MSDF rasterizer that generates simple test patterns. + /// Use this to isolate pipeline issues from MSDF generation issues. + /// + public sealed class DummyTestRasterizer : IGlyphMsdfRasterizer + { + private int glyphCounter = 0; + + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( + GlyphOutline outline, + DistanceFieldSettings df, + MsdfEncodeSettings encode) + { + var totalWidth = df.TotalWidth; + var totalHeight = df.TotalHeight; + + if (totalWidth <= 0 || totalHeight <= 0) + return new CharacterBitmapRgba(); + + var bmp = new CharacterBitmapRgba(totalWidth, totalHeight); + + // Increment counter for each glyph (helps identify unique glyphs) + int currentGlyph = System.Threading.Interlocked.Increment(ref glyphCounter); + + unsafe + { + byte* buffer = (byte*)bmp.Buffer; + int pitch = bmp.Pitch; + + // Choose pattern based on glyph number (mod 4) + int patternType = currentGlyph % 4; + + for (int y = 0; y < totalHeight; y++) + { + byte* row = buffer + y * pitch; + + for (int x = 0; x < totalWidth; x++) + { + byte r, g, b; + + switch (patternType) + { + case 0: // Solid circle (SDF-like) + { + float cx = totalWidth / 2f; + float cy = totalHeight / 2f; + float radius = Math.Min(totalWidth, totalHeight) * 0.35f; + + float dx = x - cx; + float dy = y - cy; + float dist = MathF.Sqrt(dx * dx + dy * dy); + + // SDF: inside = high value, outside = low value + float sdf = dist < radius ? 1.0f : 0.0f; + + // Smooth transition + float edge = 2f; + float alpha = Math.Clamp((radius - dist) / edge + 0.5f, 0f, 1f); + + byte val = (byte)(alpha * 255); + r = g = b = val; + break; + } + + case 1: // Gradient circle (test smooth rendering) + { + float cx = totalWidth / 2f; + float cy = totalHeight / 2f; + float maxDist = MathF.Sqrt(cx * cx + cy * cy); + + float dx = x - cx; + float dy = y - cy; + float dist = MathF.Sqrt(dx * dx + dy * dy); + + float t = 1.0f - Math.Clamp(dist / maxDist, 0f, 1f); + byte val = (byte)(t * 255); + r = g = b = val; + break; + } + + case 2: // Checkerboard (test texture coordinates) + { + int cellSize = 4; + bool checker = ((x / cellSize) + (y / cellSize)) % 2 == 0; + byte val = checker ? (byte)255 : (byte)64; + r = g = b = val; + break; + } + + case 3: // Border box (test padding/bounds) + { + bool isBorder = x < df.Padding || x >= totalWidth - df.Padding || + y < df.Padding || y >= totalHeight - df.Padding; + + if (isBorder) + { + // Red border + r = 255; + g = 0; + b = 0; + } + else + { + // White center + r = g = b = 200; + } + break; + } + + default: + r = g = b = 128; + break; + } + + int offset = x * 4; + row[offset + 0] = r; + row[offset + 1] = g; + row[offset + 2] = b; + row[offset + 3] = 255; // Full opacity + } + } + } + + return bmp; + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs new file mode 100644 index 0000000000..f717bee780 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using Stride.Core.Mathematics; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// A minimal, engine-friendly outline representation for a single glyph. + /// + /// This is intentionally NOT tied to any particular MSDF library. + /// The goal is: SharpFont -> GlyphOutline -> (any MSDF generator) -> CharacterBitmapRgba. + /// + public sealed class GlyphOutline + { + public readonly List Contours = []; + + /// + /// Outline bounds in the same coordinate space as the points. + /// + public RectangleF Bounds; + + /// + /// TrueType / FreeType winding can be CW/CCW depending on font. + /// Keep it explicit so downstream generators can choose to normalize. + /// + public GlyphWinding Winding = GlyphWinding.Unknown; + } + + public enum GlyphWinding + { + Unknown = 0, + Clockwise, + CounterClockwise, + } + + public sealed class GlyphContour + { + public readonly List Segments = []; + public bool IsClosed = true; + } + + public abstract record GlyphSegment(Vector2 P0, Vector2 P1); + + /// Line segment (P0 -> P1). + public sealed record LineSegment(Vector2 P0, Vector2 P1) : GlyphSegment(P0, P1); + + /// Quadratic Bezier (P0 -> C0 -> P1). + public sealed record QuadraticSegment(Vector2 P0, Vector2 C0, Vector2 P1) : GlyphSegment(P0, P1); + + /// Cubic Bezier (P0 -> C0 -> C1 -> P1). + public sealed record CubicSegment(Vector2 P0, Vector2 C0, Vector2 C1, Vector2 P1) : GlyphSegment(P0, P1); + + /// + /// Metrics that matter for layout. Values are in the same coordinate space as the outline. + /// + public readonly record struct GlyphOutlineMetrics( + float AdvanceX, + float BearingX, + float BearingY, + float Width, + float Height, + float Baseline); +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs new file mode 100644 index 0000000000..cfb59abf80 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs @@ -0,0 +1,283 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using Msdfgen; +using static Msdfgen.ErrorCorrectionConfig; +using MsdfVector2 = Msdfgen.Vector2; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// MSDFGen-Sharp (Msdfgen.Core) implementation for generating MSDF textures from font outlines. + /// For fonts with self intersecting contours, it's best to preprocess it with FontForge first. + /// + public sealed class MsdfGenCoreRasterizer : IGlyphMsdfRasterizer + { + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( + GlyphOutline outline, + DistanceFieldSettings df, + MsdfEncodeSettings encode) + { + ArgumentNullException.ThrowIfNull(outline); + + int width = df.TotalWidth; + int height = df.TotalHeight; + + if (width <= 0 || height <= 0) + return new CharacterBitmapRgba(); + + // Build MsdfGen shape from outline (NO Y-flip - MsdfGen uses Y-up like FreeType) + var shape = BuildMsdfGenShape(outline); + + if (shape.Contours.Count == 0) + return new CharacterBitmapRgba(width, height); + + // Normalize BEFORE orienting for self-intersecting shapes + shape.Normalize(); + + // Orient contours consistently + shape.OrientContours(); + + // Use ink trap aware edge coloring for better self-intersection handling + EdgeColoringInkTrap(shape, 3.0); + + // Calculate shape bounds + var bounds = shape.GetBounds(); + double shapeWidth = bounds.R - bounds.L; + double shapeHeight = bounds.T - bounds.B; + + if (shapeWidth <= 0 || shapeHeight <= 0) + return new CharacterBitmapRgba(width, height); + + // Calculate scale to fit shape into target size (excluding padding) + double scaleX = df.Width / shapeWidth; + double scaleY = df.Height / shapeHeight; + double scale = Math.Min(scaleX, scaleY); + + // Calculate translation to center the shape with padding + double translateX = df.Padding - bounds.L * scale; + double translateY = df.Padding - bounds.B * scale; + + // Create projection and range for MSDF generation + var projection = new Projection( + new MsdfVector2(scale, scale), + new MsdfVector2(translateX, translateY) + ); + + var range = new Msdfgen.Range(df.PixelRange); + + // Create output bitmap (3 channels for RGB MSDF) + var msdfBitmap = new Bitmap(width, height, 3); + + // Overlap support on by default. + var config = new MSDFGeneratorConfig + { + }; + + MsdfGenerator.GenerateMSDF(msdfBitmap, shape, projection, range, config); + + // Pack float RGB to CharacterBitmapRgba (flip Y here for Stride's Y-down pixels) + var result = new CharacterBitmapRgba(width, height); + PackToRgba8(msdfBitmap, result, encode, flipY: true); + + return result; + } + + private static Shape BuildMsdfGenShape(GlyphOutline outline) + { + var shape = new Shape(); + + foreach (var srcContour in outline.Contours) + { + if (srcContour?.Segments == null || srcContour.Segments.Count == 0) + continue; + + var contour = new Contour(); + + foreach (var segment in srcContour.Segments) + { + if (segment == null) continue; + + switch (segment) + { + case Stride.Graphics.Font.RuntimeMsdf.LineSegment line: + contour.Edges.Add(new Msdfgen.LinearSegment( + ToMsdfGen(line.P0), + ToMsdfGen(line.P1), + EdgeColor.WHITE + )); + break; + + case Stride.Graphics.Font.RuntimeMsdf.QuadraticSegment quad: + contour.Edges.Add(new Msdfgen.QuadraticSegment( + ToMsdfGen(quad.P0), + ToMsdfGen(quad.C0), + ToMsdfGen(quad.P1), + EdgeColor.WHITE + )); + break; + + case Stride.Graphics.Font.RuntimeMsdf.CubicSegment cubic: + contour.Edges.Add(new Msdfgen.CubicSegment( + ToMsdfGen(cubic.P0), + ToMsdfGen(cubic.C0), + ToMsdfGen(cubic.C1), + ToMsdfGen(cubic.P1), + EdgeColor.WHITE + )); + break; + } + } + + if (contour.Edges.Count > 0) + shape.AddContour(contour); + } + + return shape; + } + + private static MsdfVector2 ToMsdfGen(Stride.Core.Mathematics.Vector2 v) + { + // No Y-flip needed - both FreeType and MsdfGen use Y-up + return new MsdfVector2(v.X, v.Y); + } + + /// + /// Improved edge coloring that handles self-intersecting contours and ink traps better. + /// + private static void EdgeColoringInkTrap(Shape shape, double angleThreshold) + { + const double crossThreshold = 0.05; // sin(~3 degrees) for detecting corners + + foreach (var contour in shape.Contours) + { + if (contour.Edges.Count == 0) continue; + + EdgeColor[] colors = { EdgeColor.CYAN, EdgeColor.MAGENTA, EdgeColor.YELLOW }; + + // Initialize all edges to white + foreach (var edge in contour.Edges) + { + edge.Color = EdgeColor.WHITE; + } + + // Multi-pass coloring + // Pass 1: Assign initial colors avoiding neighbor conflicts + for (int i = 0; i < contour.Edges.Count; i++) + { + int prevIndex = (i - 1 + contour.Edges.Count) % contour.Edges.Count; + int nextIndex = (i + 1) % contour.Edges.Count; + + var prevColor = contour.Edges[prevIndex].Color; + var nextColor = contour.Edges[nextIndex].Color; + + // Find a color that doesn't conflict with neighbors + EdgeColor chosen = colors[0]; + foreach (var c in colors) + { + if (c != prevColor && c != nextColor) + { + chosen = c; + break; + } + } + + contour.Edges[i].Color = chosen; + } + + // Pass 2: Adjust colors at corners for better MSDF quality + for (int i = 0; i < contour.Edges.Count; i++) + { + int prevIndex = (i - 1 + contour.Edges.Count) % contour.Edges.Count; + + var prevEdge = contour.Edges[prevIndex]; + var edge = contour.Edges[i]; + + var prevDir = prevEdge.Direction(1).Normalize(); + var curDir = edge.Direction(0).Normalize(); + + double dot = MsdfVector2.DotProduct(prevDir, curDir); + double cross = Math.Abs(MsdfVector2.CrossProduct(prevDir, curDir)); + + // Detect sharp corners (angle > ~90 degrees or high curvature) + bool isCorner = dot < 0 || cross > crossThreshold; + + if (isCorner && edge.Color == prevEdge.Color) + { + // At corners, use different colors to prevent artifacts + foreach (var c in colors) + { + if (c != prevEdge.Color && c != edge.Color) + { + edge.Color = c; + break; + } + } + } + } + } + } + + private static unsafe void PackToRgba8(Bitmap source, CharacterBitmapRgba dest, MsdfEncodeSettings encode, bool flipY) + { + // MsdfGen outputs float values in [0,1] where 0.5 is the edge + // Inside shape: > 0.5, Outside shape: < 0.5 + + // For self-intersecting shapes, we may need median filtering + // to reduce artifacts at overlap points + + bool invertDistance = false; // MsdfGen convention matches Stride's SDF shader + float scaleFactor = encode.Scale * 2f; + + byte* buffer = (byte*)dest.Buffer; + int pitch = dest.Pitch; + + for (int y = 0; y < source.Height; y++) + { + // Flip Y when writing to output (MsdfGen is Y-up, Stride pixels are Y-down) + int destY = flipY ? (source.Height - 1 - y) : y; + byte* row = buffer + destY * pitch; + + for (int x = 0; x < source.Width; x++) + { + float r = source[x, y, 0]; + float g = source[x, y, 1]; + float b = source[x, y, 2]; + + if (invertDistance) + { + r = 1f - r; + g = 1f - g; + b = 1f - b; + } + + // Apply encoding (scale around 0.5 midpoint) + r = Encode(r, encode.Bias, scaleFactor); + g = Encode(g, encode.Bias, scaleFactor); + b = Encode(b, encode.Bias, scaleFactor); + + int offset = x * 4; + row[offset + 0] = FloatToByte(r); + row[offset + 1] = FloatToByte(g); + row[offset + 2] = FloatToByte(b); + row[offset + 3] = 255; + } + } + } + + private static float Encode(float value, float bias, float scaleFactor) + { + // Transform: output = bias + (value - 0.5) * scaleFactor + float result = bias + (value - 0.5f) * scaleFactor; + return Math.Clamp(result, 0f, 1f); + } + + private static byte FloatToByte(float value) + { + if (value <= 0f) return 0; + if (value >= 1f) return 255; + return (byte)(value * 255f + 0.5f); + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs new file mode 100644 index 0000000000..63df35e070 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs @@ -0,0 +1,46 @@ +using System; +using Stride.Core.Mathematics; +using Stride.Graphics; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Settings that are common across SDF/MSDF generators. + /// Keep this independent from any particular font class so we can reuse it + /// for pipeline-time atlas gen or runtime glyph gen. + /// + public readonly record struct DistanceFieldSettings( + int PixelRange, + int Padding, + int Width, + int Height) + { + public int TotalWidth => Width + Padding * 2; + public int TotalHeight => Height + Padding * 2; + } + + /// + /// MSDF output encoding choices. + /// Most MSDF implementations output float RGB, then you pack to RGBA8. + /// + public readonly record struct MsdfEncodeSettings(float Bias, float Scale) + { + public static readonly MsdfEncodeSettings Default = new(Bias: 0.5f, Scale: 0.5f); + } + + /// + /// Library-agnostic MSDF rasterizer interface. + /// Implementations can wrap Remora.MSDFGen today, and be swapped later. + /// + public interface IGlyphMsdfRasterizer + { + /// + /// Rasterizes an MSDF (RGB packed into RGBA8) for the provided glyph outline. + /// The output bitmap is expected to be (Width+2*Padding) x (Height+2*Padding). + /// + internal CharacterBitmapRgba RasterizeMsdf( + GlyphOutline outline, + DistanceFieldSettings df, + MsdfEncodeSettings encode); + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs new file mode 100644 index 0000000000..2a151b2fbd --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs @@ -0,0 +1,216 @@ +using System; +using System.Diagnostics; +using Stride.Core.Mathematics; +using Stride.Graphics.Font; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Diagnostic rasterizer that visualizes the outline data to identify extraction issues. + /// Draws the outline directly without MSDF to see if the geometry is correct. + /// + public sealed class OutlineDiagnosticRasterizer : IGlyphMsdfRasterizer + { + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( + GlyphOutline outline, + DistanceFieldSettings df, + MsdfEncodeSettings encode) + { + var totalWidth = df.TotalWidth; + var totalHeight = df.TotalHeight; + + if (totalWidth <= 0 || totalHeight <= 0) + return new CharacterBitmapRgba(); + + var bmp = new CharacterBitmapRgba(totalWidth, totalHeight); + + // Log outline information + Debug.WriteLine($"=== Outline Diagnostic ==="); + Debug.WriteLine($"Contours: {outline?.Contours?.Count ?? 0}"); + Debug.WriteLine($"Bounds: {outline?.Bounds}"); + Debug.WriteLine($"Target size: {df.Width}x{df.Height} (padded: {totalWidth}x{totalHeight})"); + + if (outline == null || outline.Contours == null || outline.Contours.Count == 0) + { + Debug.WriteLine("ERROR: No outline data!"); + // Return red bitmap to indicate error + FillSolid(bmp, 255, 0, 0); + return bmp; + } + + // Calculate bounds from actual segments + float minX = float.MaxValue, minY = float.MaxValue; + float maxX = float.MinValue, maxY = float.MinValue; + int totalSegments = 0; + + foreach (var contour in outline.Contours) + { + if (contour?.Segments == null) continue; + + foreach (var seg in contour.Segments) + { + if (seg == null) continue; + totalSegments++; + + UpdateBounds(seg.P0, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(seg.P1, ref minX, ref minY, ref maxX, ref maxY); + } + } + + Debug.WriteLine($"Total segments: {totalSegments}"); + Debug.WriteLine($"Actual bounds: ({minX}, {minY}) -> ({maxX}, {maxY})"); + Debug.WriteLine($"Actual size: {maxX - minX} x {maxY - minY}"); + + if (totalSegments == 0) + { + Debug.WriteLine("ERROR: No segments found!"); + FillSolid(bmp, 255, 0, 0); + return bmp; + } + + // Check for suspicious values + if (float.IsInfinity(minX) || float.IsNaN(minX)) + { + Debug.WriteLine("ERROR: Invalid bounds (infinity/NaN)"); + FillSolid(bmp, 255, 128, 0); + return bmp; + } + + float outlineWidth = maxX - minX; + float outlineHeight = maxY - minY; + + if (outlineWidth < 0.01f || outlineHeight < 0.01f) + { + Debug.WriteLine($"WARNING: Outline too small ({outlineWidth} x {outlineHeight})"); + FillSolid(bmp, 255, 255, 0); + return bmp; + } + + if (outlineWidth > 10000 || outlineHeight > 10000) + { + Debug.WriteLine($"WARNING: Outline too large ({outlineWidth} x {outlineHeight})"); + FillSolid(bmp, 128, 0, 255); + return bmp; + } + + // Calculate scale to fit outline into target area + float scaleX = df.Width / outlineWidth; + float scaleY = df.Height / outlineHeight; + float scale = Math.Min(scaleX, scaleY); + + Debug.WriteLine($"Scale: {scale} (scaleX={scaleX}, scaleY={scaleY})"); + + // Clear to black + FillSolid(bmp, 0, 0, 0); + + // Draw outline segments + unsafe + { + byte* buffer = (byte*)bmp.Buffer; + int pitch = bmp.Pitch; + + foreach (var contour in outline.Contours) + { + if (contour?.Segments == null) continue; + + foreach (var seg in contour.Segments) + { + if (seg == null) continue; + + // Transform points to bitmap space + var p0 = TransformPoint(seg.P0, minX, minY, scale, df.Padding); + var p1 = TransformPoint(seg.P1, minX, minY, scale, df.Padding); + + // Draw line segment + DrawLine(buffer, pitch, totalWidth, totalHeight, p0, p1, 255, 255, 255); + } + } + } + + Debug.WriteLine("Outline rendered successfully"); + return bmp; + } + + private static Vector2 TransformPoint(Vector2 p, float minX, float minY, float scale, int padding) + { + // Transform: (p - min) * scale + padding + return new Vector2( + (p.X - minX) * scale + padding, + (p.Y - minY) * scale + padding + ); + } + + private static void UpdateBounds(Vector2 p, ref float minX, ref float minY, ref float maxX, ref float maxY) + { + if (p.X < minX) minX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.X > maxX) maxX = p.X; + if (p.Y > maxY) maxY = p.Y; + } + + private static unsafe void FillSolid(CharacterBitmapRgba bmp, byte r, byte g, byte b) + { + byte* buffer = (byte*)bmp.Buffer; + int pitch = bmp.Pitch; + + for (int y = 0; y < bmp.Rows; y++) + { + byte* row = buffer + y * pitch; + for (int x = 0; x < bmp.Width; x++) + { + int offset = x * 4; + row[offset + 0] = r; + row[offset + 1] = g; + row[offset + 2] = b; + row[offset + 3] = 255; + } + } + } + + private static unsafe void DrawLine(byte* buffer, int pitch, int width, int height, + Vector2 p0, Vector2 p1, byte r, byte g, byte b) + { + // Simple Bresenham line drawing + int x0 = (int)p0.X; + int y0 = (int)p0.Y; + int x1 = (int)p1.X; + int y1 = (int)p1.Y; + + int dx = Math.Abs(x1 - x0); + int dy = Math.Abs(y1 - y0); + int sx = x0 < x1 ? 1 : -1; + int sy = y0 < y1 ? 1 : -1; + int err = dx - dy; + + int maxSteps = width + height; // Safety limit + int steps = 0; + + while (steps++ < maxSteps) + { + // Plot point if in bounds + if (x0 >= 0 && x0 < width && y0 >= 0 && y0 < height) + { + byte* pixel = buffer + y0 * pitch + x0 * 4; + pixel[0] = r; + pixel[1] = g; + pixel[2] = b; + pixel[3] = 255; + } + + if (x0 == x1 && y0 == y1) break; + + int e2 = 2 * err; + if (e2 > -dy) + { + err -= dy; + x0 += sx; + } + if (e2 < dx) + { + err += dx; + y0 += sy; + } + } + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs new file mode 100644 index 0000000000..5a990f9eec --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs @@ -0,0 +1,250 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using System; +using NVector2 = System.Numerics.Vector2; +using Remora.MSDFGen; +using Remora.MSDFGen.Graphics; +using Color3 = Remora.MSDFGen.Graphics.Color3; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Remora.MSDFGen-backed implementation of . + /// + /// This class is intentionally isolated from the rest of the runtime font pipeline so that + /// swapping MSDF backends later is just a matter of replacing this file. + /// + public sealed class RemoraMsdfRasterizer : IGlyphMsdfRasterizer + { + // The upstream msdfgen sample code uses ~3.0 radians as a common default. + private const double DefaultAngleThresholdRadians = 3.0; + + // We flip Y when converting from FreeType/Stride outline space (Y up) into pixel space (Y down). + // If your outline extractor already flips Y, set this to false. + private const bool FlipOutlineYAxis = true; + + // Stride's existing runtime SDF pipeline encodes "inside" as *higher* values. + // Remora.MSDFGen follows the msdfgen convention where inside is negative -> values below 0.5. + // Until Stride has an MSDF-aware shader, we invert to match the existing SDF shader convention. + private const bool InvertDistanceForStrideSdfShader = true; + + // Stride's current SDF font effect samples a single channel. + // To get correct visuals now (without a new MSDF shader), we pack the median of RGB into all channels. + // When you add an MSDF shader (median on GPU), set this to false to keep true MSDF in RGB. + private const bool PackMedianToAllChannels = true; + + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf(GlyphOutline outline, DistanceFieldSettings df, MsdfEncodeSettings encode) + { + if (outline == null) + throw new ArgumentNullException(nameof(outline)); + + var totalWidth = df.TotalWidth; + var totalHeight = df.TotalHeight; + + if (totalWidth <= 0 || totalHeight <= 0) + return new CharacterBitmapRgba(); + + // 1) Convert neutral outline -> Remora shape + var shape = BuildShape(outline, FlipOutlineYAxis, out var minX, out var minY, out var maxX, out var maxY); + + shape.Normalize(); + + if (shape.Contours.Count == 0) + return new CharacterBitmapRgba(totalWidth, totalHeight); + + // 2) Edge coloring (required for correct MSDF) + MSDF.EdgeColoringSimple(shape, DefaultAngleThresholdRadians); + + // 3) Generate float MSDF into a pixmap + var pix = new Pixmap(totalWidth, totalHeight); + + // We treat outline units as pixel units (after scaling in FreeType). + // Place the shape so its min corner starts at (Padding, Padding). + // Note: MSDF.GenerateMSDF computes p = (pixel - translate) / scale. + // So translate is in pixel space. + var translate = new NVector2((float)(df.Padding - minX), (float)(df.Padding - minY)); + var scale = new NVector2(1f, 1f); + + MSDF.GenerateMSDF(pix, shape, df.PixelRange, scale, translate); + + // 4) Pack float RGB -> RGBA8 in a Stride bitmap + var bmp = new CharacterBitmapRgba(totalWidth, totalHeight); + PackPixmapToRgba8(pix, bmp, encode); + + return bmp; + } + + private static Shape BuildShape(GlyphOutline outline, bool flipY, out double minX, out double minY, out double maxX, out double maxY) + { + var shape = new Shape + { + // Only affects output row order in Remora's generator; we handle Y by flipping coordinates. + InverseYAxis = false + }; + + minX = double.PositiveInfinity; + minY = double.PositiveInfinity; + maxX = double.NegativeInfinity; + maxY = double.NegativeInfinity; + + foreach (var srcContour in outline.Contours) + { + if (srcContour?.Segments == null || srcContour.Segments.Count == 0) + continue; + + var contour = new Contour(); + + foreach (var seg in srcContour.Segments) + { + if (seg == null) + continue; + + switch (seg) + { + case LineSegment line: + { + var a = ToRemora(line.P0, flipY); + var b = ToRemora(line.P1, flipY); + UpdateBounds(a, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(b, ref minX, ref minY, ref maxX, ref maxY); + contour.Edges.Add(new LinearSegment(a, b, EdgeColor.White)); + break; + } + case QuadraticSegment quad: + { + var a = ToRemora(quad.P0, flipY); + var c = ToRemora(quad.C0, flipY); + var b = ToRemora(quad.P1, flipY); + UpdateBounds(a, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(c, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(b, ref minX, ref minY, ref maxX, ref maxY); + contour.Edges.Add(new Remora.MSDFGen.QuadraticSegment(a, c, b, EdgeColor.White)); + break; + } + case CubicSegment cubic: + { + var a = ToRemora(cubic.P0, flipY); + var c0 = ToRemora(cubic.C0, flipY); + var c1 = ToRemora(cubic.C1, flipY); + var b = ToRemora(cubic.P1, flipY); + UpdateBounds(a, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(c0, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(c1, ref minX, ref minY, ref maxX, ref maxY); + UpdateBounds(b, ref minX, ref minY, ref maxX, ref maxY); + contour.Edges.Add(new Remora.MSDFGen.CubicSegment(a, c0, c1, b, EdgeColor.White)); + break; + } + default: + { + // Unknown segment type - ignore rather than crash. + break; + } + } + } + + if (contour.Edges.Count > 0) + shape.Contours.Add(contour); + } + + if (double.IsInfinity(minX) || double.IsInfinity(minY)) + { + minX = minY = 0; + maxX = maxY = 0; + } + + return shape; + } + + private static NVector2 ToRemora(Stride.Core.Mathematics.Vector2 v, bool flipY) + => new NVector2(v.X, flipY ? -v.Y : v.Y); + + private static void UpdateBounds(NVector2 p, ref double minX, ref double minY, ref double maxX, ref double maxY) + { + if (p.X < minX) minX = p.X; + if (p.Y < minY) minY = p.Y; + if (p.X > maxX) maxX = p.X; + if (p.Y > maxY) maxY = p.Y; + } + + private static unsafe void PackPixmapToRgba8(Pixmap pix, CharacterBitmapRgba dst, MsdfEncodeSettings encode) + { + // Encode settings apply around the 0.5 midpoint. + // encode.Scale is defined so that 0.5 means "identity". + var scaleFactor = encode.Scale * 2f; + + byte* basePtr = (byte*)dst.Buffer; + int pitch = dst.Pitch; + + for (int y = 0; y < pix.Height; y++) + { + byte* row = basePtr + y * pitch; + + for (int x = 0; x < pix.Width; x++) + { + var c = pix[x, y]; + + float r0 = c.R; + float g0 = c.G; + float b0 = c.B; + + if (PackMedianToAllChannels) + { + var m = Median3(r0, g0, b0); + r0 = g0 = b0 = m; + } + + if (InvertDistanceForStrideSdfShader) + { + r0 = 1f - r0; + g0 = 1f - g0; + b0 = 1f - b0; + } + + float r = ApplyEncode(r0, encode.Bias, scaleFactor); + float g = ApplyEncode(g0, encode.Bias, scaleFactor); + float b = ApplyEncode(b0, encode.Bias, scaleFactor); + + int o = x * 4; + row[o + 0] = FloatToByte(r); + row[o + 1] = FloatToByte(g); + row[o + 2] = FloatToByte(b); + row[o + 3] = 255; + } + } + } + + private static float ApplyEncode(float v, float bias, float scaleFactor) + { + // v is expected to be in [0,1], centered around 0.5. + // v' = bias + (v-0.5)*scaleFactor + var e = bias + (v - 0.5f) * scaleFactor; + if (e < 0f) return 0f; + if (e > 1f) return 1f; + return e; + } + + private static byte FloatToByte(float v) + { + // Clamp and round. + if (v <= 0f) return 0; + if (v >= 1f) return 255; + return (byte)(v * 255f + 0.5f); + } + + private static float Median3(float a, float b, float c) + { + // branchy but tiny; avoids allocations. + if (a > b) + { + if (b > c) return b; + return a > c ? c : a; + } + else + { + if (a > c) return a; + return b > c ? c : b; + } + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs new file mode 100644 index 0000000000..38aacd9148 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -0,0 +1,129 @@ +using System; +using SharpFont; +using Stride.Core.Mathematics; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Using SharpFont to extract an outline from a glyph. + /// + public static class SharpFontOutlineExtractor + { + public static bool TryExtractGlyphOutline( + Face face, + uint charCode, + out GlyphOutline outline, + out GlyphOutlineMetrics metrics, + LoadFlags loadFlags = LoadFlags.NoBitmap) + { + outline = null; + metrics = default; + + if (face == null) return false; + + try + { + face.LoadChar(charCode, loadFlags, LoadTarget.Normal); + } + catch (FreeTypeException) + { + return false; + } + + var slot = face.Glyph; + if (slot == null) return false; + + // Metrics are standard 26.6 fixed point + var m = slot.Metrics; + metrics = new GlyphOutlineMetrics( + AdvanceX: Fixed26Dot6ToFloat(slot.Advance.X), + BearingX: Fixed26Dot6ToFloat(m.HorizontalBearingX), + BearingY: Fixed26Dot6ToFloat(m.HorizontalBearingY), + Width: Fixed26Dot6ToFloat(m.Width), + Height: Fixed26Dot6ToFloat(m.Height), + Baseline: 0f); + + var ftOutline = slot.Outline; + if (ftOutline == null) return false; + + outline = DecomposeOutline(ftOutline); + + // FreeType bounding box is in 26.6 fixed point format + var bbox = ftOutline.GetBBox(); + float left = Fixed26Dot6ToFloat(bbox.Left); + float bottom = Fixed26Dot6ToFloat(bbox.Bottom); + float right = Fixed26Dot6ToFloat(bbox.Right); + float top = Fixed26Dot6ToFloat(bbox.Top); + + // FreeType uses Y-up coordinates (bottom < top) + outline.Bounds = new RectangleF( + left, // X position (left edge) + bottom, // Y position (bottom edge in Y-up space) + right - left, // Width + top - bottom // Height (positive because top > bottom) + ); + + return true; + } + + private static GlyphOutline DecomposeOutline(Outline ft) + { + var result = new GlyphOutline(); + GlyphContour currentContour = null; + Vector2 lastPoint = Vector2.Zero; + + // FreeType automatically closes contours, so we don't need to add closing segments + var funcs = new OutlineFuncs( + moveTo: (ref FTVector to, IntPtr user) => + { + currentContour = new GlyphContour { }; + result.Contours.Add(currentContour); + lastPoint = ConvertVector(to); + return 0; + }, + lineTo: (ref FTVector to, IntPtr user) => + { + var endPt = ConvertVector(to); + currentContour?.Segments.Add(new LineSegment(lastPoint, endPt)); + lastPoint = endPt; + return 0; + }, + conicTo: (ref FTVector control, ref FTVector to, IntPtr user) => + { + var cp = ConvertVector(control); + var endPt = ConvertVector(to); + currentContour?.Segments.Add(new QuadraticSegment(lastPoint, cp, endPt)); + lastPoint = endPt; + return 0; + }, + cubicTo: (ref FTVector c1, ref FTVector c2, ref FTVector to, IntPtr user) => + { + var cp1 = ConvertVector(c1); + var cp2 = ConvertVector(c2); + var endPt = ConvertVector(to); + currentContour?.Segments.Add(new CubicSegment(lastPoint, cp1, cp2, endPt)); + lastPoint = endPt; + return 0; + }, + shift: 0, + delta: 0 + ); + + ft.Decompose(funcs, IntPtr.Zero); + + return result; + } + + private static Vector2 ConvertVector(FTVector v) + { + // FreeType outline points are in 26.6 fixed point format + // Even though FTVector uses Fixed16Dot16 type, the actual values are 26.6 + // This is a quirk of the SharpFont wrapper that's included in Stride. + return new Vector2(v.X.Value / 64f, v.Y.Value / 64f); + } + + // Helper for 26.6 fixed point (used for Metrics and BBox) + private static float Fixed26Dot6ToFloat(Fixed26Dot6 v) => v.Value / 64f; + private static float Fixed26Dot6ToFloat(int v) => v / 64f; + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs new file mode 100644 index 0000000000..3d3524bac8 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -0,0 +1,645 @@ +using System; +using System.Buffers; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; +using Stride.Core; +using Stride.Core.Mathematics; +using Stride.Core.Serialization; +using Stride.Core.Serialization.Contents; +using Stride.Graphics.Font.RuntimeMsdf; + +namespace Stride.Graphics.Font +{ /// + /// A dynamic font that asynchronously generates multi-channel signed distance mapping for glyphs as needed, enabling sharp, smooth edges and resizability. + /// + [ReferenceSerializer, DataSerializerGlobal(typeof(ReferenceSerializer), Profile = "Content")] + [ContentSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontContentSerializer))] + [DataSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontSerializer))] + + internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont + { + internal string FontName; + internal FontStyle Style; + + internal int PixelRange = 8; + internal int Padding = 2; + + internal bool UseKerning; + + // --- Distance field configuration & generator seam (MSDF-ready) --- + + private readonly record struct DistanceEncodeParams(float Bias, float Scale); + private readonly record struct DistanceFieldParams(int PixelRange, int Pad, DistanceEncodeParams Encode); + + private readonly record struct GlyphKey(char C, int PixelRange, int Pad); + + // Keep today’s encoding behavior explicit and centralized. + private static readonly DistanceEncodeParams DefaultEncode = new(Bias: 0.4f, Scale: 0.5f); + + private DistanceFieldParams GetDfParams() + { + int pixelRange = Math.Max(1, PixelRange); + int pad = ComputeTotalPad(); + return new DistanceFieldParams(pixelRange, pad, DefaultEncode); + } + + private static GlyphKey MakeKey(char c, DistanceFieldParams p) => new(c, p.PixelRange, p.Pad); + + // --- Generator input: discriminated union (coverage today, outline later) --- + private abstract record GlyphInput; + + private sealed record CoverageInput( + byte[] Buffer, + int Length, + int Width, + int Rows, + int Pitch) : GlyphInput; + + // This keeps the scheduling/upload pipeline unchanged when MSDF generators are swapped. + private sealed record OutlineInput(GlyphOutline Outline, int Width, int Height) : GlyphInput; + + private interface IDistanceFieldGenerator + { + CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p); + } + + private sealed class SdfCoverageGenerator : IDistanceFieldGenerator + { + public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) + => input switch + { + CoverageInput c => BuildSdfRgbFromCoverage(c.Buffer, c.Width, c.Rows, c.Pitch, p.Pad, p.PixelRange, p.Encode), + _ => throw new ArgumentOutOfRangeException(nameof(input), "Unsupported input for SDF generator."), + }; + } + + /// + /// Composite generator: CoverageInput -> SDF (existing path), OutlineInput -> MSDF (Remora). + /// Keeps the scheduling/upload pipeline unchanged while we add MSDF support. + /// + private sealed class SdfOrMsdfGenerator(IGlyphMsdfRasterizer msdf, MsdfEncodeSettings msdfEncode) : IDistanceFieldGenerator + { + private readonly SdfCoverageGenerator sdf = new(); + private readonly IGlyphMsdfRasterizer msdf = msdf ?? throw new ArgumentNullException(nameof(msdf)); + private readonly MsdfEncodeSettings msdfEncode = msdfEncode; + + public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) + => input switch + { + CoverageInput => sdf.Generate(input, p), + + OutlineInput o => msdf.RasterizeMsdf( + o.Outline, + new DistanceFieldSettings( + PixelRange: p.PixelRange, + Padding: p.Pad, + Width: o.Width, + Height: o.Height), + msdfEncode), + + _ => throw new ArgumentOutOfRangeException(nameof(input)), + }; + } + + // Swap MSDF backend HERE without touching the runtime font pipeline. + private readonly IDistanceFieldGenerator generator = + new SdfOrMsdfGenerator(new MsdfGenCoreRasterizer(), MsdfEncodeSettings.Default); + + // Runtime SDF glyph cache key (future-proof for multiple ranges/modes) + private readonly ConcurrentDictionary characters = []; + private readonly ConcurrentDictionary cacheRecords = []; + + [DataMemberIgnore] + private FontManager FontManager => FontSystem?.FontManager; + + [DataMemberIgnore] + private FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem?.FontCacheManagerMsdf; + + // Async wiring + // 1) Dedup scheduling + private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight = new(); + + // 2) Generated SDF results waiting for GPU upload + private readonly System.Collections.Concurrent.ConcurrentQueue<(GlyphKey key, CharacterBitmapRgba sdf)> readyForUpload = new(); + + // --- Bounded work queue + fixed worker pool --- + + private const int WorkQueueCapacity = 1024; // backpressure / memory safety + private const int WorkerCount = 2; + + private Channel workChannel; + private CancellationTokenSource workCts; + private Task[] workers; + + private readonly record struct WorkItem( + GlyphKey Key, + GlyphInput Input, + DistanceFieldParams Params); + + internal override FontSystem FontSystem + { + set + { + if (FontSystem == value) + return; + + // if we're detaching, shut down background workers + if (FontSystem != null && value == null) + { + ShutdownWorkers(); + } + + base.FontSystem = value; + + if (FontSystem == null) + return; + + EnsureWorkersStarted(); + + // Metrics from font + FontManager.GetFontInfo(FontName, Style, out var relativeLineSpacing, out var relativeBaseOffsetY, out var relativeMaxWidth, out var relativeMaxHeight); + + DefaultLineSpacing = relativeLineSpacing * Size; + BaseOffsetY = relativeBaseOffsetY * Size; + + // Use RGBA MSDF cache textures + Textures = FontCacheManagerMsdf.Textures; + + // Keep channels as-is (RGB median used by shader) + swizzle = default; + } + } + + public RuntimeSignedDistanceFieldSpriteFont() + { + FontType = SpriteFontType.SDF; + } + + public override bool IsCharPresent(char c) + { + return FontManager != null && FontManager.DoesFontContains(FontName, Style, c); + } + + protected override Glyph GetGlyph(CommandList commandList, char character, in Vector2 fontSize, bool uploadGpuResources, out Vector2 fixScaling) + { + var cache = FontCacheManagerMsdf ?? throw new InvalidOperationException("RuntimeSignedDistanceFieldSpriteFont requires FontSystem.FontCacheManagerMsdf to be initialized."); + + // All glyphs are generated at Size + var sizeVec = new Vector2(Size, Size); + + var p = GetDfParams(); + + // IMPORTANT: + // SDF fonts are scaled by Stride using requestedFontSize vs SpriteFont.Size. + // Glyphs are baked at Size, so no compensating scaling is required. + fixScaling = Vector2.One; + + var key = MakeKey(character, p); + var spec = GetOrCreateCharacterData(key, sizeVec); + + // 1) Ensure we have the coverage bitmap + correct metrics (sync) + if (spec.Bitmap == null) + { + FontManager.GenerateBitmap(spec, true); + + //Apply padding offset once metrics are loaded + if (spec.Bitmap != null && spec.Glyph.XAdvance != 0) + { + spec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); + } + + // Missing glyph (glyphIndex == 0 => XAdvance==0 and Bitmap null/empty) + if (spec.Bitmap == null || spec.Bitmap.Width == 0 || spec.Bitmap.Rows == 0 || spec.Glyph.XAdvance == 0) + { + if (character != DefaultCharacter && DefaultCharacter.HasValue) + return GetGlyph(commandList, DefaultCharacter.Value, in fontSize, uploadGpuResources, out fixScaling); + + return null; + } + } + + // 2) Schedule async SDF generation (only once per char) + EnsureSdfScheduled(key, spec); + + // 3) Upload + if (commandList != null) + DrainUploads(commandList); + + if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(key, out var handle)) + { + // If evicted/cleared, this will flip false and we’ll reupload next draw + if (!handle.IsUploaded) + { + spec.IsBitmapUploaded = false; + cacheRecords.TryRemove(key, out _); + } + else + { + cache.NotifyGlyphUtilization(handle); + } + } + + return spec.Glyph; + } + + internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) + { + // Async pregen glyphs + var sizeVec = new Vector2(Size, Size); + var p = GetDfParams(); + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + var key = MakeKey(c, p); + var spec = GetOrCreateCharacterData(key, sizeVec); + + if (spec.Bitmap == null) + FontManager.GenerateBitmap(spec, true); + + // Apply padding offset + if (spec.Bitmap != null && spec.Glyph.XAdvance != 0) + { + spec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); + } + + EnsureSdfScheduled(key, spec); + } + } + + private CharacterSpecification GetOrCreateCharacterData(GlyphKey key, Vector2 size) + { + if (!characters.TryGetValue(key, out var spec)) + { + // AntiAlias: use AntiAliased so coverage bitmap is smooth + spec = new CharacterSpecification(key.C, FontName, size, Style, FontAntiAliasMode.Grayscale); + spec.Glyph.Subrect = Rectangle.Empty; + spec.Glyph.BitmapIndex = 0; + spec.IsBitmapUploaded = false; + characters[key] = spec; + } + + return spec; + } + + private int ComputeTotalPad() + { + // Generally, want enough room to represent distance out to PixelRange, + // plus explicit Padding. + var pad = Padding + PixelRange; + return Math.Max(1, pad); + } + + private void EnsureWorkersStarted() + { + if (workChannel != null) + return; + + workCts = new CancellationTokenSource(); + + workChannel = Channel.CreateBounded(new BoundedChannelOptions(WorkQueueCapacity) + { + SingleWriter = false, + SingleReader = false, + // Writers that await will wait; render thread uses TryWrite so it never blocks. + FullMode = BoundedChannelFullMode.Wait + }); + + workers = new Task[WorkerCount]; + for (int i = 0; i < workers.Length; i++) + workers[i] = Task.Run(() => WorkerLoop(workCts.Token)); + } + + private void ShutdownWorkers() + { + if (workChannel == null) + return; + + try + { + workCts.Cancel(); + workChannel.Writer.TryComplete(); + try { Task.WaitAll(workers); } catch { /* ignore shutdown exceptions */ } + } + finally + { + workCts.Dispose(); + workCts = null; + workChannel = null; + workers = null; + } + } + + private async Task WorkerLoop(CancellationToken token) + { + try + { + var reader = workChannel.Reader; + + while (await reader.WaitToReadAsync(token).ConfigureAwait(false)) + { + while (reader.TryRead(out var item)) + { + try + { + // CPU SDF build + var sdf = generator.Generate(item.Input, item.Params); + + // hand off to render thread for GPU upload + readyForUpload.Enqueue((item.Key, sdf)); + } + catch + { + // If generation fails, we just allow rescheduling later. + } + finally + { + if (item.Input is CoverageInput c) + ArrayPool.Shared.Return(c.Buffer); + + inFlight.TryRemove(item.Key, out _); + } + } + } + } + catch (OperationCanceledException) + { + // normal shutdown + } + } + + private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) + { + // Already uploaded? nothing to do. + if (spec.IsBitmapUploaded) return; + + // Ensure worker infrastructure is alive (safe even if already started) + EnsureWorkersStarted(); + + // Already scheduled? bail. + if (!inFlight.TryAdd(key, 0)) return; + + var p = new DistanceFieldParams(key.PixelRange, key.Pad, DefaultEncode); + + + // Try Outline-based MSDF first + // Uses the merged TryGetGlyphOutline signature + if (FontManager != null && + FontManager.TryGetGlyphOutline(FontName, Style, new Vector2(Size, Size), key.C, out var outline, out _)) + { + // Resolve dimensions: Prefer existing bitmap metrics, fallback to outline bounds + int w = (spec.Bitmap != null && spec.Bitmap.Width > 0) ? spec.Bitmap.Width : (outline != null ? (int)MathF.Ceiling(outline.Bounds.Width) : 0); + int h = (spec.Bitmap != null && spec.Bitmap.Rows > 0) ? spec.Bitmap.Rows : (outline != null ? (int)MathF.Ceiling(outline.Bounds.Height) : 0); + + // Handle zero-dimension glyphs (like spaces) AND oversized glyphs immediately + var cache = FontCacheManagerMsdf; + if (w <= 0 || h <= 0 || + w + cache.AtlasPaddingPixels * 2 > cache.Textures[0].ViewWidth || + h + cache.AtlasPaddingPixels * 2 > cache.Textures[0].ViewHeight) + { + inFlight.TryRemove(key, out _); + return; + } + + // If the queue is full, exit now. + // Do NOT fall through to the coverage logic if the channel is already saturated. + if (workChannel.Writer.TryWrite(new WorkItem(key, new OutlineInput(outline, w, h), p))) + return; + + inFlight.TryRemove(key, out _); + return; + } + + // Fallback: bitmap/coverage-based SDF. + var bmp = spec.Bitmap; + if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) + { + inFlight.TryRemove(key, out _); + return; + } + + // Copy coverage bitmap to a pooled array so background thread is safe (avoid per-glyph allocations). + int len = bmp.Pitch * bmp.Rows; + var srcCopy = ArrayPool.Shared.Rent(len); + try + { + System.Runtime.InteropServices.Marshal.Copy(bmp.Buffer, srcCopy, 0, len); + var input = new CoverageInput(srcCopy, len, bmp.Width, bmp.Rows, bmp.Pitch); + + // Render thread must NEVER block: TryWrite only. + if (!workChannel.Writer.TryWrite(new WorkItem(key, input, p))) + { + // Queue full; allow retry next frame + ArrayPool.Shared.Return(srcCopy); + inFlight.TryRemove(key, out _); + } + } + catch + { + ArrayPool.Shared.Return(srcCopy); + inFlight.TryRemove(key, out _); + throw; + } + } + + private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, GlyphKey key, CharacterSpecification spec, FontCacheManagerMsdf.MsdfCachedGlyph handle, int bitmapIndex) + { + spec.Glyph.Subrect = handle.InnerSubrect; + spec.Glyph.BitmapIndex = bitmapIndex; + spec.IsBitmapUploaded = true; + + cacheRecords[key] = handle; + cache.NotifyGlyphUtilization(handle); + + } + + private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) + { + var cache = FontCacheManagerMsdf; + if (cache == null) return; + + for (int i = 0; i < maxUploadsPerFrame; i++) + { + if (!readyForUpload.TryDequeue(out var item)) + break; + + var (key, sdfBitmap) = item; + + if (!characters.TryGetValue(key, out var spec) || spec == null) + { + sdfBitmap.Dispose(); + continue; + } + + // Might have been uploaded already while task was running + if (spec.IsBitmapUploaded) + { + sdfBitmap.Dispose(); + continue; + } + + var subrect = new Rectangle(); + var handle = cache.UploadGlyphBitmap(commandList, spec, sdfBitmap, ref subrect, out var bitmapIndex); + + ApplyUploadedGlyph(cache, key, spec, handle, bitmapIndex); + + sdfBitmap.Dispose(); + } + } + + + // --- Bitmap based SDF generation (CPU) for fallback purposes, packed into RGB so median(R,G,B) = SDF value --- + + private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(byte[] src, int srcW, int srcH, int srcPitch, int pad, int pixelRange, DistanceEncodeParams enc) + { + int w = srcW + pad * 2; + int h = srcH + pad * 2; + + var inside = new bool[w * h]; + + for (int y = 0; y < srcH; y++) + { + int dstRow = (y + pad) * w + pad; + int srcRow = y * srcPitch; + + for (int x = 0; x < srcW; x++) + { + inside[dstRow + x] = src[srcRow + x] >= 128; + } + } + + var distToOutsideSq = new float[w * h]; + ComputeEdtSquared(w, h, inside, featureIsInside: false, distToOutsideSq); + + var distToInsideSq = new float[w * h]; + ComputeEdtSquared(w, h, inside, featureIsInside: true, distToInsideSq); + + var bmp = new CharacterBitmapRgba(w, h); + byte* dst = (byte*)bmp.Buffer; + + float scale = enc.Scale / Math.Max(1, pixelRange); + + float bias = enc.Bias; + for (int y = 0; y < h; y++) + { + byte* row = dst + y * bmp.Pitch; + int baseIdx = y * w; + + for (int x = 0; x < w; x++) + { + int i = baseIdx + x; + float dOut = MathF.Sqrt(distToOutsideSq[i]); + float dIn = MathF.Sqrt(distToInsideSq[i]); + float signed = dOut - dIn; + + float encoded = Math.Clamp(bias + signed * scale, 0f, 1f); + byte b = (byte)(encoded * 255f + 0.5f); + + int o = x * 4; + row[o + 0] = b; + row[o + 1] = b; + row[o + 2] = b; + row[o + 3] = 255; + } + } + + return bmp; + } + + // Compute squared distances to the nearest feature pixels using Felzenszwalb/Huttenlocher EDT. + // If featureIsInside == true, features are where inside==true; else features are where inside==false. + private static void ComputeEdtSquared(int w, int h, bool[] inside, bool featureIsInside, float[] outDistSq) + { + const float INF = 1e20f; + int maxDim = Math.Max(w, h); + + // Rent buffers from the shared pool instead of 'new' + float[] tmp = ArrayPool.Shared.Rent(w * h); + float[] f = ArrayPool.Shared.Rent(maxDim); + float[] d = ArrayPool.Shared.Rent(maxDim); + int[] v = ArrayPool.Shared.Rent(maxDim); + float[] z = ArrayPool.Shared.Rent(maxDim + 1); + + try + { + // Stage 1: vertical transform + for (int x = 0; x < w; x++) + { + for (int y = 0; y < h; y++) + { + bool isFeature = (inside[y * w + x] == featureIsInside); + f[y] = isFeature ? 0f : INF; + } + + DistanceTransform1D(f, h, d, v, z); + + for (int y = 0; y < h; y++) + tmp[y * w + x] = d[y]; + } + + // Stage 2: horizontal transform + for (int y = 0; y < h; y++) + { + int row = y * w; + for (int x = 0; x < w; x++) + f[x] = tmp[row + x]; + + DistanceTransform1D(f, w, d, v, z); + + for (int x = 0; x < w; x++) + outDistSq[row + x] = d[x]; + } + } + finally + { + // ALWAYS return the arrays so they can be reused + ArrayPool.Shared.Return(tmp); + ArrayPool.Shared.Return(f); + ArrayPool.Shared.Return(d); + ArrayPool.Shared.Return(v); + ArrayPool.Shared.Return(z); + } + } + + // 1D squared distance transform for f[] using lower envelope of parabolas. + // Produces d[i] = min_j ( (i-j)^2 + f[j] ) + private static void DistanceTransform1D(float[] f, int n, float[] d, int[] v, float[] z) + { + int k = 0; + v[0] = 0; + z[0] = float.NegativeInfinity; + z[1] = float.PositiveInfinity; + + for (int q = 1; q < n; q++) + { + float s; + while (true) + { + int p = v[k]; + // intersection of parabolas from p and q + s = ((f[q] + q * q) - (f[p] + p * p)) / (2f * (q - p)); + + if (s > z[k]) break; + k--; + } + + k++; + v[k] = q; + z[k] = s; + z[k + 1] = float.PositiveInfinity; + } + + k = 0; + for (int q = 0; q < n; q++) + { + while (z[k + 1] < q) k++; + int p = v[k]; + float dx = q - p; + d[q] = dx * dx + f[p]; + } + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontContentSerializer.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontContentSerializer.cs new file mode 100644 index 0000000000..74250b9fa3 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontContentSerializer.cs @@ -0,0 +1,12 @@ +using Stride.Core.Serialization.Contents; + +namespace Stride.Graphics.Font +{ + internal sealed class RuntimeSignedDistanceFieldSpriteFontContentSerializer : DataContentSerializer + { + public override object Construct(ContentSerializerContext context) + { + return new RuntimeSignedDistanceFieldSpriteFont(); + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs new file mode 100644 index 0000000000..8f37e59f93 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs @@ -0,0 +1,57 @@ +using System; +using Stride.Core; +using Stride.Core.Serialization; + +namespace Stride.Graphics.Font +{ + internal sealed class RuntimeSignedDistanceFieldSpriteFontSerializer : DataSerializer + { + private DataSerializer parentSerializer; + + public override void PreSerialize(ref RuntimeSignedDistanceFieldSpriteFont texture, ArchiveMode mode, SerializationStream stream) + { + // Do not create object during pre-serialize (OK because not recursive) + } + + public override void Initialize(SerializerSelector serializerSelector) + { + // Match RuntimeRasterizedSpriteFontSerializer pattern + parentSerializer = SerializerSelector.Default.GetSerializer(); + if (parentSerializer == null) + throw new InvalidOperationException("Could not find parent serializer for type Stride.Graphics.SpriteFont"); + } + + public override void Serialize(ref RuntimeSignedDistanceFieldSpriteFont font, ArchiveMode mode, SerializationStream stream) + { + // Serialize base SpriteFont fields through parent serializer + SpriteFont spriteFont = font; + parentSerializer.Serialize(ref spriteFont, mode, stream); + font = (RuntimeSignedDistanceFieldSpriteFont)spriteFont; + + if (mode == ArchiveMode.Deserialize) + { + var services = stream.Context.Tags.Get(ServiceRegistry.ServiceRegistryKey); + var fontSystem = services.GetSafeServiceAs(); + + font.FontName = stream.Read(); + font.Style = stream.Read(); + font.UseKerning = stream.Read(); + + font.PixelRange = stream.Read(); + font.Padding = stream.Read(); + + // Critical: attach runtime FontSystem so caches/fonts work + font.FontSystem = fontSystem; + } + else + { + stream.Write(font.FontName); + stream.Write(font.Style); + stream.Write(font.UseKerning); + + stream.Write(font.PixelRange); + stream.Write(font.Padding); + } + } + } +} diff --git a/sources/engine/Stride.Graphics/Stride.Graphics.csproj b/sources/engine/Stride.Graphics/Stride.Graphics.csproj index 62f2fe13d1..8ee10e37b9 100644 --- a/sources/engine/Stride.Graphics/Stride.Graphics.csproj +++ b/sources/engine/Stride.Graphics/Stride.Graphics.csproj @@ -1,4 +1,4 @@ - + true true @@ -43,6 +43,8 @@ + +