From d49b9ffd97c66363fd73cfc3f755ef862107a895 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 28 Jan 2026 20:09:53 -0800 Subject: [PATCH 01/57] M1: Added Runtime SDF placeholder. --- ...untimeSignedDistanceFieldSpriteFontType.cs | 50 ++++++++ .../SpriteFont/SpriteFontAssetCompiler.cs | 121 ++++++++++++++---- .../SpriteFont/SpriteFontAssetFactories.cs | 21 ++- 3 files changed, 162 insertions(+), 30 deletions(-) create mode 100644 sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs diff --git a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs new file mode 100644 index 0000000000..9f07aada06 --- /dev/null +++ b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs @@ -0,0 +1,50 @@ +// 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; } = 20; + + /// + /// The fixed size (in pixels) used to generate MSDF glyph bitmaps at runtime. + /// The font can be rendered at other sizes by scaling in the SDF shader. + /// + [DataMember(40)] + [DefaultValue(64)] + [DataMemberRange(8, 256, 1, 8, 0)] + [Display("Bake Size")] + public int BakeSize { get; set; } = 64; + + /// + /// Distance field range/spread (in pixels) used during MSDF generation. + /// + [DataMember(50)] + [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(60)] + [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..0c7713d6fd 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs @@ -40,39 +40,73 @@ 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; - } + 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); + 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 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 RuntimeSignedDistanceFieldFontCommand(targetUrlInStorage, asset, assetItem.Package)); + } + else if (asset.FontType is RuntimeRasterizedSpriteFontType) + { + 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 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 RuntimeRasterizedFontCommand(targetUrlInStorage, asset, assetItem.Package)); + } + + 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; + + result.BuildSteps = new AssetBuildStep(assetItem); + result.BuildSteps.Add(new OfflineRasterizedFontCommand(targetUrlInStorage, assetClone, colorSpace, assetItem.Package)); + } } internal class OfflineRasterizedFontCommand : AssetCommand @@ -118,7 +152,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 +224,40 @@ 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); + + return Task.FromResult(ResultStatus.Successful); + } + } + + internal class RuntimeSignedDistanceFieldFontCommand : AssetCommand + { + public RuntimeSignedDistanceFieldFontCommand(string url, SpriteFontAsset description, IAssetFinder assetFinder) + : base(url, description, assetFinder) + { + } + + protected override Task DoCommandOverride(ICommandContext commandContext) + { + // M1 NOTE: + // This is a scaffolding step. We serialize a functional font so the pipeline works end-to-end. + // In M3/M4, this will be replaced by a real Runtime MSDF font object. + commandContext.Logger.Warning("Runtime SDF font is currently scaffolded in M1 (temporary). It will behave like a runtime raster font until the MSDF runtime generator is implemented."); + + 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); var assetManager = new ContentManager(MicrothreadLocalDatabases.ProviderService); assetManager.Save(Url, dynamicFont); 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(); + } + } } From 3275c162be620e543bb8fa591b5fb37edf6e7022 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 28 Jan 2026 20:36:18 -0800 Subject: [PATCH 02/57] M2. MSDF Cache Scaffold. --- .../Font/CharacterBitmapRgba.cs | 135 ++++++++++++++++ .../Font/FontCacheManagerMsdf.cs | 153 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs create mode 100644 sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs diff --git a/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs b/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs new file mode 100644 index 0000000000..5efa68176e --- /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 (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..f01a81ac95 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -0,0 +1,153 @@ +// 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 = new List(); + private readonly LinkedList cachedGlyphs = new LinkedList(); + private readonly GuillotinePacker packer = new GuillotinePacker(); + + public IReadOnlyList Textures { get; private set; } + + public FontCacheManagerMsdf(FontSystem system, int textureDefaultSize = 1024) + { + 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; + + 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 void UploadGlyphBitmap(CommandList commandList, CharacterBitmapRgba bitmap, ref Rectangle subrect, out int bitmapIndex) + { + if (bitmap == null) + throw new ArgumentNullException(nameof(bitmap)); + if (commandList == null) + throw new ArgumentNullException(nameof(commandList)); + + bitmapIndex = 0; + + var targetSize = new Int2(bitmap.Width, bitmap.Rows); + if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) + { + RemoveLessUsedGlyphs(); + if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) + { + // NOTE: same behavior as FontCacheManager today. Multi-page atlases come later. + ClearCache(); + if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) + throw new InvalidOperationException("The rendered glyph is too big for the MSDF cache texture."); + } + } + + if (bitmap.Rows != 0 && bitmap.Width != 0) + { + var dataBox = new DataBox(bitmap.Buffer, bitmap.Pitch, bitmap.Pitch * bitmap.Rows); + var region = new ResourceRegion(subrect.Left, subrect.Top, 0, subrect.Right, subrect.Bottom, 1); + commandList.UpdateSubResource(cacheTextures[0], 0, dataBox, region); + } + + // Track for eviction behavior parity (frame-based LRU). + var cached = new MsdfCachedGlyph + { + Subrect = subrect, + BitmapIndex = 0, + LastUsedFrame = system.FrameCount, + IsUploaded = true, + }; + + cachedGlyphs.AddFirst(cached.ListNode); + } + + 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 = 5) + { + var limitFrame = system.FrameCount - frameCount; + var currentNode = cachedGlyphs.Last; + + while (currentNode != null && currentNode.Value.LastUsedFrame < limitFrame) + { + currentNode.Value.IsUploaded = false; + packer.Free(ref currentNode.Value.Subrect); + + 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 Rectangle Subrect; + public int BitmapIndex; + public int LastUsedFrame; + public bool IsUploaded; + + public readonly LinkedListNode ListNode; + + public MsdfCachedGlyph() + { + ListNode = new LinkedListNode(this); + } + } + } +} From 5d9185f4d3d7d59f9228417862482f6a071a5fdc Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 28 Jan 2026 22:11:45 -0800 Subject: [PATCH 03/57] M3: Font works end to end, renders ugly circles. --- .../SpriteFont/SpriteFontAssetCompiler.cs | 12 +- .../Stride.Graphics/Font/FontDataFactory.cs | 30 +++ .../engine/Stride.Graphics/Font/FontSystem.cs | 38 ++++ .../RuntimeSignedDistanceFieldSpriteFont.cs | 179 ++++++++++++++++++ ...istanceFieldSpriteFontContentSerializer.cs | 12 ++ ...SignedDistanceFieldSpriteFontSerializer.cs | 59 ++++++ 6 files changed, 326 insertions(+), 4 deletions(-) create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontContentSerializer.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs index 0c7713d6fd..125dd0a7f7 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs @@ -249,18 +249,22 @@ protected override Task DoCommandOverride(ICommandContext commandC // In M3/M4, this will be replaced by a real Runtime MSDF font object. commandContext.Logger.Warning("Runtime SDF font is currently scaffolded in M1 (temporary). It will behave like a runtime raster font until the MSDF runtime generator is implemented."); - var dynamicFont = FontDataFactory.NewDynamic( - Parameters.FontType.Size, + var runtimeSdfType = (RuntimeSignedDistanceFieldSpriteFontType)Parameters.FontType; + + var sdfFont = FontDataFactory.NewRuntimeSignedDistanceField( + runtimeSdfType.Size, Parameters.FontSource.GetFontName(), Parameters.FontSource.Style, - Parameters.FontType.AntiAlias, + runtimeSdfType.BakeSize, + runtimeSdfType.PixelRange, + runtimeSdfType.Padding, useKerning: false, extraSpacing: Parameters.Spacing, extraLineSpacing: Parameters.LineSpacing, defaultCharacter: Parameters.DefaultCharacter); var assetManager = new ContentManager(MicrothreadLocalDatabases.ProviderService); - assetManager.Save(Url, dynamicFont); + assetManager.Save(Url, sdfFont); return Task.FromResult(ResultStatus.Successful); } diff --git a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs index c2af015535..48c7a1b312 100644 --- a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs +++ b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs @@ -50,6 +50,36 @@ public SpriteFont NewDynamic(float defaultSize, string fontName, FontStyle style }; } + public SpriteFont NewRuntimeSignedDistanceField( + float defaultSize, + string fontName, + FontStyle style, + int bakeSize, + int pixelRange, + int padding, + bool useKerning, + float extraSpacing, + float extraLineSpacing, + char defaultCharacter) + { + return new RuntimeSignedDistanceFieldSpriteFont + { + Size = defaultSize, + DefaultCharacter = defaultCharacter, + + FontName = fontName, + Style = style, + + BakeSize = bakeSize, + 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/FontSystem.cs b/sources/engine/Stride.Graphics/Font/FontSystem.cs index a94daecb55..ae2d9deff6 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); } @@ -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,39 @@ public SpriteFont NewDynamic(float defaultSize, string fontName, FontStyle style return font; } + + public SpriteFont NewRuntimeSignedDistanceField( + float defaultSize, + string fontName, + FontStyle style, + int bakeSize, + int pixelRange, + int padding, + bool useKerning, + float extraSpacing, + float extraLineSpacing, + char defaultCharacter) + { + var font = new RuntimeSignedDistanceFieldSpriteFont + { + Size = defaultSize, + DefaultCharacter = defaultCharacter, + + FontName = fontName, + Style = style, + + BakeSize = bakeSize, + PixelRange = pixelRange, + Padding = padding, + + UseKerning = useKerning, + ExtraSpacing = extraSpacing, + ExtraLineSpacing = extraLineSpacing, + + FontSystem = this + }; + + return font; + } } } diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs new file mode 100644 index 0000000000..c1acb59e12 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -0,0 +1,179 @@ +using System; +using System.Collections.Generic; +using Stride.Core; +using Stride.Core.Mathematics; +using Stride.Core.Serialization; +using Stride.Core.Serialization.Contents; + +namespace Stride.Graphics.Font +{ + [ReferenceSerializer, DataSerializerGlobal(typeof(ReferenceSerializer), Profile = "Content")] + [ContentSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontContentSerializer))] + [DataSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontSerializer))] + internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont + { + // Data set by FontDataFactory / serialized content + internal string FontName; + internal FontStyle Style; + + internal int BakeSize = 64; + internal int PixelRange = 8; + internal int Padding = 2; + + internal bool UseKerning; + + private readonly Dictionary characters = new Dictionary(); + + [DataMemberIgnore] + internal FontManager FontManager => FontSystem != null ? FontSystem.FontManager : null; + + [DataMemberIgnore] + internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem != null ? FontSystem.FontCacheManagerMsdf : null; + + internal override FontSystem FontSystem + { + set + { + if (FontSystem == value) + return; + + base.FontSystem = value; + + if (FontSystem == null) + return; + + // Pull metrics from the font (same pattern as RuntimeRasterizedSpriteFont) + float relativeLineSpacing; + float relativeBaseOffsetY; + float relativeMaxWidth; + float relativeMaxHeight; + FontManager.GetFontInfo(FontName, Style, out relativeLineSpacing, out relativeBaseOffsetY, out relativeMaxWidth, out relativeMaxHeight); + + DefaultLineSpacing = relativeLineSpacing * Size; + BaseOffsetY = relativeBaseOffsetY * Size; + + // Use the parallel MSDF cache atlas list + Textures = FontCacheManagerMsdf.Textures; + + // Identity swizzle (RGBA) — do NOT use Swizzle property; it doesn't exist here. + 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; + if (cache == null) + throw new InvalidOperationException("RuntimeSignedDistanceFieldSpriteFont requires FontSystem.FontCacheManagerMsdf to be initialized."); + + // Bake once at BakeSize, scale to requested fontSize + var baked = new Vector2(BakeSize, BakeSize); + fixScaling = fontSize / baked; + + if (!characters.TryGetValue(character, out var entry)) + { + entry = new CharacterEntry(); + characters.Add(character, entry); + } + + // 1) Ensure we have a CPU bitmap (placeholder for M3) + if (entry.Bitmap == null && !entry.IsBitmapUploaded) + { + entry.Bitmap = GeneratePlaceholderMsdfBitmap(character, BakeSize, PixelRange, Padding, out var missing); + + if (missing) + { + entry.Bitmap?.Dispose(); + entry.Bitmap = null; + + if (character != DefaultCharacter && DefaultCharacter.HasValue) + return GetGlyph(commandList, DefaultCharacter.Value, in fontSize, uploadGpuResources, out fixScaling); + + return null; + } + + // Ensure there is at least a glyph object for MeasureString/layout. + if (entry.Glyph == null) + { + entry.Glyph = new Glyph + { + Character = character, + Subrect = new Rectangle(0, 0, entry.Bitmap.Width, entry.Bitmap.Rows), // placeholder + Offset = Vector2.Zero, + XAdvance = BakeSize * 0.6f, + BitmapIndex = 0, + }; + } + } + + // 2) Upload only when requested (render path). MeasureString passes uploadGpuResources=false and commandList=null. + if (uploadGpuResources && commandList != null && entry.Bitmap != null && !entry.IsBitmapUploaded) + { + var subrect = new Rectangle(); + cache.UploadGlyphBitmap(commandList, entry.Bitmap, ref subrect, out var bitmapIndex); + + entry.Glyph.Subrect = subrect; + entry.Glyph.BitmapIndex = bitmapIndex; + + entry.IsBitmapUploaded = true; + + // Free CPU bitmap after upload (same spirit as runtime raster) + entry.Bitmap.Dispose(); + entry.Bitmap = null; + } + + return entry.Glyph; + } + + + private static unsafe CharacterBitmapRgba GeneratePlaceholderMsdfBitmap(char character, int bakeSize, int pixelRange, int padding, out bool missing) + { + missing = false; + + var w = Math.Max(1, bakeSize + padding * 2); + var h = Math.Max(1, bakeSize + padding * 2); + + var bmp = new CharacterBitmapRgba(w, h); + var ptr = (byte*)bmp.Buffer; + + // Placeholder: radial gradient replicated across RGB so median(R,G,B)=v + for (int y = 0; y < h; y++) + { + var row = ptr + y * bmp.Pitch; + for (int x = 0; x < w; x++) + { + float dx = (x - w * 0.5f) / (w * 0.5f); + float dy = (y - h * 0.5f) / (h * 0.5f); + float d = MathF.Sqrt(dx * dx + dy * dy); + + float v = Math.Clamp(1.0f - d, 0, 1); + byte b = (byte)(v * 255); + + row[x * 4 + 0] = b; + row[x * 4 + 1] = b; + row[x * 4 + 2] = b; + row[x * 4 + 3] = 255; + } + } + + return bmp; + } + + private sealed class CharacterEntry + { + public CharacterBitmapRgba Bitmap; + public bool IsBitmapUploaded; + public Glyph Glyph; + } + } +} 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..6cdf00e935 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs @@ -0,0 +1,59 @@ +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.BakeSize = 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.BakeSize); + stream.Write(font.PixelRange); + stream.Write(font.Padding); + } + } + } +} From 75f53693a4dba7a9bba0de58571c83ce2894878d Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 28 Jan 2026 23:53:23 -0800 Subject: [PATCH 04/57] M4 WIP: Buggy implementation with flickers so far. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 280 ++++++++++++++---- 1 file changed, 219 insertions(+), 61 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index c1acb59e12..de5976a7c9 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System; +using System.Collections.Generic; using Stride.Core; using Stride.Core.Mathematics; using Stride.Core.Serialization; @@ -12,7 +14,6 @@ namespace Stride.Graphics.Font [DataSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontSerializer))] internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont { - // Data set by FontDataFactory / serialized content internal string FontName; internal FontStyle Style; @@ -22,7 +23,11 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont internal bool UseKerning; - private readonly Dictionary characters = new Dictionary(); + // Single-size runtime SDF: key is just char + private readonly Dictionary characters = new Dictionary(); + private readonly Dictionary pendingSdfBitmaps = new Dictionary(); + + private readonly HashSet offsetAdjusted = new HashSet(); [DataMemberIgnore] internal FontManager FontManager => FontSystem != null ? FontSystem.FontManager : null; @@ -42,7 +47,7 @@ internal override FontSystem FontSystem if (FontSystem == null) return; - // Pull metrics from the font (same pattern as RuntimeRasterizedSpriteFont) + // Metrics from font float relativeLineSpacing; float relativeBaseOffsetY; float relativeMaxWidth; @@ -52,10 +57,10 @@ internal override FontSystem FontSystem DefaultLineSpacing = relativeLineSpacing * Size; BaseOffsetY = relativeBaseOffsetY * Size; - // Use the parallel MSDF cache atlas list + // Use RGBA MSDF cache textures Textures = FontCacheManagerMsdf.Textures; - // Identity swizzle (RGBA) — do NOT use Swizzle property; it doesn't exist here. + // Keep channels as-is (RGB median used by shader) swizzle = default; } } @@ -76,104 +81,257 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve if (cache == null) throw new InvalidOperationException("RuntimeSignedDistanceFieldSpriteFont requires FontSystem.FontCacheManagerMsdf to be initialized."); - // Bake once at BakeSize, scale to requested fontSize - var baked = new Vector2(BakeSize, BakeSize); - fixScaling = fontSize / baked; + // All glyphs baked at BakeSize + var bakedSizeVec = new Vector2(BakeSize, BakeSize); + fixScaling = fontSize / bakedSizeVec; - if (!characters.TryGetValue(character, out var entry)) - { - entry = new CharacterEntry(); - characters.Add(character, entry); - } + var spec = GetOrCreateCharacterData(character, bakedSizeVec); - // 1) Ensure we have a CPU bitmap (placeholder for M3) - if (entry.Bitmap == null && !entry.IsBitmapUploaded) + // 1) Ensure we have the coverage bitmap + correct metrics (sync) + if (spec.Bitmap == null) { - entry.Bitmap = GeneratePlaceholderMsdfBitmap(character, BakeSize, PixelRange, Padding, out var missing); + FontManager.GenerateBitmap(spec, true); - if (missing) + // 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) { - entry.Bitmap?.Dispose(); - entry.Bitmap = null; - if (character != DefaultCharacter && DefaultCharacter.HasValue) return GetGlyph(commandList, DefaultCharacter.Value, in fontSize, uploadGpuResources, out fixScaling); return null; } + } - // Ensure there is at least a glyph object for MeasureString/layout. - if (entry.Glyph == null) + // 2) Build SDF bitmap once (CPU) + if (!pendingSdfBitmaps.TryGetValue(character, out var sdfBitmap)) + { + var pad = ComputeTotalPad(); + sdfBitmap = BuildSdfRgbFromCoverage(spec.Bitmap, pad, Math.Max(1, PixelRange)); + + // IMPORTANT: our bitmap now has extra pixels on left/top => shift offset + if (offsetAdjusted.Add(character)) { - entry.Glyph = new Glyph - { - Character = character, - Subrect = new Rectangle(0, 0, entry.Bitmap.Width, entry.Bitmap.Rows), // placeholder - Offset = Vector2.Zero, - XAdvance = BakeSize * 0.6f, - BitmapIndex = 0, - }; + spec.Glyph.Offset -= new Vector2(pad, pad); } + + // Give subrect sane dimensions even before upload (not critical for MeasureString but harmless) + spec.Glyph.Subrect = new Rectangle(0, 0, sdfBitmap.Width, sdfBitmap.Rows); + spec.IsBitmapUploaded = false; + + pendingSdfBitmaps[character] = sdfBitmap; } - // 2) Upload only when requested (render path). MeasureString passes uploadGpuResources=false and commandList=null. - if (uploadGpuResources && commandList != null && entry.Bitmap != null && !entry.IsBitmapUploaded) - { + // 3) Upload only when drawing (MeasureString passes uploadGpuResources=false and commandList=null) + if (commandList != null && !spec.IsBitmapUploaded) + { var subrect = new Rectangle(); - cache.UploadGlyphBitmap(commandList, entry.Bitmap, ref subrect, out var bitmapIndex); + cache.UploadGlyphBitmap(commandList, sdfBitmap, ref subrect, out var bitmapIndex); - entry.Glyph.Subrect = subrect; - entry.Glyph.BitmapIndex = bitmapIndex; + spec.Glyph.Subrect = subrect; + spec.Glyph.BitmapIndex = bitmapIndex; + spec.IsBitmapUploaded = true; - entry.IsBitmapUploaded = true; + // Free CPU SDF bitmap after upload + sdfBitmap.Dispose(); + pendingSdfBitmaps.Remove(character); + } - // Free CPU bitmap after upload (same spirit as runtime raster) - entry.Bitmap.Dispose(); - entry.Bitmap = null; + return spec.Glyph; + } + + internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) + { + // Sync pregen for M4.1 proof-of-work (matches your preference) + var bakedSizeVec = new Vector2(BakeSize, BakeSize); + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + var spec = GetOrCreateCharacterData(c, bakedSizeVec); + + if (spec.Bitmap == null) + FontManager.GenerateBitmap(spec, true); + + if (spec.Bitmap != null && spec.Bitmap.Width != 0 && spec.Bitmap.Rows != 0 && !pendingSdfBitmaps.ContainsKey(c)) + { + var pad = ComputeTotalPad(); + var sdf = BuildSdfRgbFromCoverage(spec.Bitmap, pad, Math.Max(1, PixelRange)); + spec.Glyph.Offset -= new Vector2(pad, pad); + spec.Glyph.Subrect = new Rectangle(0, 0, sdf.Width, sdf.Rows); + pendingSdfBitmaps[c] = sdf; + } + } + } + + private CharacterSpecification GetOrCreateCharacterData(char character, Vector2 bakedSize) + { + if (!characters.TryGetValue(character, out var spec)) + { + // AntiAlias: use AntiAliased so coverage bitmap is smooth + spec = new CharacterSpecification(character, FontName, bakedSize, Style, FontAntiAliasMode.Aliased); + characters[character] = spec; } - return entry.Glyph; + return spec; } + private int ComputeTotalPad() + { + // You generally want enough room to represent distance out to PixelRange + // plus your own explicit Padding. + var pad = Padding + PixelRange; + return Math.Max(1, pad); + } + + // --- SDF generation (CPU), packed into RGB so median(R,G,B)=SDF value --- - private static unsafe CharacterBitmapRgba GeneratePlaceholderMsdfBitmap(char character, int bakeSize, int pixelRange, int padding, out bool missing) + private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(CharacterBitmap coverage, int pad, int pixelRange) { - missing = false; + int srcW = coverage.Width; + int srcH = coverage.Rows; + + int w = srcW + pad * 2; + int h = srcH + pad * 2; + + // Build inside mask from coverage into padded image + var inside = new bool[w * h]; + + byte* src = (byte*)coverage.Buffer; + int srcPitch = coverage.Pitch; // in CharacterBitmap, pitch == width + + for (int y = 0; y < srcH; y++) + { + int dstRow = (y + pad) * w + pad; + byte* srcRow = src + y * srcPitch; + + for (int x = 0; x < srcW; x++) + { + inside[dstRow + x] = srcRow[x] >= 128; + } + } - var w = Math.Max(1, bakeSize + padding * 2); - var h = Math.Max(1, bakeSize + padding * 2); + // EDT to nearest OUTSIDE (feature = outside) + var distToOutsideSq = new float[w * h]; + ComputeEdtSquared(w, h, inside, featureIsInside: false, distToOutsideSq); + // EDT to nearest INSIDE (feature = inside) + var distToInsideSq = new float[w * h]; + ComputeEdtSquared(w, h, inside, featureIsInside: true, distToInsideSq); + + // Pack signed distance into RGB var bmp = new CharacterBitmapRgba(w, h); - var ptr = (byte*)bmp.Buffer; + byte* dst = (byte*)bmp.Buffer; + + // Stride shader uses an edge around ~0.4 (see SignedDistanceFieldFont.sdsl) + // We encode: 0.4 at boundary, +/- pixelRange maps to +/-0.5 range. + float scale = 0.5f / Math.Max(1, pixelRange); - // Placeholder: radial gradient replicated across RGB so median(R,G,B)=v for (int y = 0; y < h; y++) { - var row = ptr + y * bmp.Pitch; + byte* row = dst + y * bmp.Pitch; + int baseIdx = y * w; + for (int x = 0; x < w; x++) { - float dx = (x - w * 0.5f) / (w * 0.5f); - float dy = (y - h * 0.5f) / (h * 0.5f); - float d = MathF.Sqrt(dx * dx + dy * dy); + int i = baseIdx + x; + + float dOut = MathF.Sqrt(distToOutsideSq[i]); + float dIn = MathF.Sqrt(distToInsideSq[i]); + + // signed: + inside, - outside + float signed = dOut - dIn; - float v = Math.Clamp(1.0f - d, 0, 1); - byte b = (byte)(v * 255); + float encoded = Math.Clamp(0.4f + signed * scale, 0f, 1f); + byte b = (byte)(encoded * 255f + 0.5f); - row[x * 4 + 0] = b; - row[x * 4 + 1] = b; - row[x * 4 + 2] = b; - row[x * 4 + 3] = 255; + int o = x * 4; + row[o + 0] = b; + row[o + 1] = b; + row[o + 2] = b; + row[o + 3] = 255; } } return bmp; } - private sealed class CharacterEntry + // 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) { - public CharacterBitmapRgba Bitmap; - public bool IsBitmapUploaded; - public Glyph Glyph; + const float INF = 1e20f; + + // Stage 1: vertical transform + var tmp = new float[w * h]; + var f = new float[Math.Max(w, h)]; + var d = new float[Math.Max(w, h)]; + var v = new int[Math.Max(w, h)]; + var z = new float[Math.Max(w, h) + 1]; + + 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]; + } + } + + // 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]; + } } } } From 1fa1cd46bc767c7741b4289631f07e07f61779a3 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Thu, 29 Jan 2026 01:24:01 -0800 Subject: [PATCH 05/57] It works, but each text spins computer like jet engine. --- .../Font/FontCacheManagerMsdf.cs | 107 +++++++++++++++--- .../RuntimeSignedDistanceFieldSpriteFont.cs | 30 ++++- 2 files changed, 113 insertions(+), 24 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs index f01a81ac95..2408e313db 100644 --- a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -20,9 +20,11 @@ internal class FontCacheManagerMsdf : ComponentBase private readonly LinkedList cachedGlyphs = new LinkedList(); private readonly GuillotinePacker packer = new GuillotinePacker(); + public int AtlasPaddingPixels = 2; + public IReadOnlyList Textures { get; private set; } - public FontCacheManagerMsdf(FontSystem system, int textureDefaultSize = 1024) + public FontCacheManagerMsdf(FontSystem system, int textureDefaultSize = 2048) { this.system = system ?? throw new ArgumentNullException(nameof(system)); Textures = cacheTextures; @@ -45,16 +47,27 @@ private void ReloadCache(GraphicsResourceBase graphicsResourceBase, IServiceRegi public void ClearCache() { foreach (var glyph in cachedGlyphs) + { glyph.IsUploaded = false; - + if (glyph.Owner != null) + glyph.Owner.IsBitmapUploaded = false; + } + cachedGlyphs.Clear(); packer.Clear(cacheTextures[0].ViewWidth, cacheTextures[0].ViewHeight); + + System.Diagnostics.Debug.WriteLine("MSDF ClearCache()"); } /// /// Upload an RGBA glyph bitmap into the MSDF cache and return its packed sub-rectangle. /// - public void UploadGlyphBitmap(CommandList commandList, CharacterBitmapRgba bitmap, ref Rectangle subrect, out int bitmapIndex) + public MsdfCachedGlyph UploadGlyphBitmap( + CommandList commandList, + CharacterSpecification owner, + CharacterBitmapRgba bitmap, + ref Rectangle subrect, + out int bitmapIndex) { if (bitmap == null) throw new ArgumentNullException(nameof(bitmap)); @@ -63,36 +76,50 @@ public void UploadGlyphBitmap(CommandList commandList, CharacterBitmapRgba bitma bitmapIndex = 0; - var targetSize = new Int2(bitmap.Width, bitmap.Rows); - if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) + var atlasPad = AtlasPaddingPixels; + + // Safety check: Is the glyph bigger than the entire atlas? + if (bitmap.Width + atlasPad * 2 > cacheTextures[0].ViewWidth || + bitmap.Rows + atlasPad * 2 > cacheTextures[0].ViewHeight) { - RemoveLessUsedGlyphs(); - if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) - { - // NOTE: same behavior as FontCacheManager today. Multi-page atlases come later. - ClearCache(); - if (!packer.Insert(targetSize.X, targetSize.Y, ref subrect)) - throw new InvalidOperationException("The rendered glyph is too big for the MSDF cache texture."); - } + throw new InvalidOperationException("Glyph is too large for the MSDF atlas settings."); + } + + 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(subrect.Left, subrect.Top, 0, subrect.Right, subrect.Bottom, 1); + 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 { - Subrect = subrect, + Owner = owner, + OuterSubrect = outer, + InnerSubrect = inner, BitmapIndex = 0, LastUsedFrame = system.FrameCount, IsUploaded = true, }; - + cached.Owner = owner; cachedGlyphs.AddFirst(cached.ListNode); + return cached; } public void NotifyGlyphUtilization(MsdfCachedGlyph glyph) @@ -105,7 +132,7 @@ public void NotifyGlyphUtilization(MsdfCachedGlyph glyph) cachedGlyphs.AddFirst(glyph.ListNode); } - private void RemoveLessUsedGlyphs(int frameCount = 5) + private void RemoveLessUsedGlyphs(int frameCount = 1) { var limitFrame = system.FrameCount - frameCount; var currentNode = cachedGlyphs.Last; @@ -113,7 +140,9 @@ private void RemoveLessUsedGlyphs(int frameCount = 5) while (currentNode != null && currentNode.Value.LastUsedFrame < limitFrame) { currentNode.Value.IsUploaded = false; - packer.Free(ref currentNode.Value.Subrect); + if (currentNode.Value.Owner != null) + currentNode.Value.Owner.IsBitmapUploaded = false; + packer.Free(ref currentNode.Value.OuterSubrect); var prev = currentNode.Previous; cachedGlyphs.RemoveLast(); @@ -141,13 +170,55 @@ 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/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index de5976a7c9..56eb241b6d 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -26,9 +26,11 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont // Single-size runtime SDF: key is just char private readonly Dictionary characters = new Dictionary(); private readonly Dictionary pendingSdfBitmaps = new Dictionary(); + private readonly Dictionary cacheRecords + = new Dictionary(); private readonly HashSet offsetAdjusted = new HashSet(); - + [DataMemberIgnore] internal FontManager FontManager => FontSystem != null ? FontSystem.FontManager : null; @@ -123,18 +125,33 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // 3) Upload only when drawing (MeasureString passes uploadGpuResources=false and commandList=null) if (commandList != null && !spec.IsBitmapUploaded) - { + { var subrect = new Rectangle(); - cache.UploadGlyphBitmap(commandList, sdfBitmap, ref subrect, out var bitmapIndex); + var handle = cache.UploadGlyphBitmap(commandList, spec, sdfBitmap, ref subrect, out var bitmapIndex); - spec.Glyph.Subrect = subrect; + spec.Glyph.Subrect = handle.InnerSubrect; spec.Glyph.BitmapIndex = bitmapIndex; spec.IsBitmapUploaded = true; - // Free CPU SDF bitmap after upload + cacheRecords[character] = handle; + cache.NotifyGlyphUtilization(handle); + sdfBitmap.Dispose(); pendingSdfBitmaps.Remove(character); } + else if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(character, out var handle)) + { + // If evicted/cleared, this will flip false and we’ll reupload next draw + if (!handle.IsUploaded) + { + spec.IsBitmapUploaded = false; + cacheRecords.Remove(character); + } + else + { + cache.NotifyGlyphUtilization(handle); + } + } return spec.Glyph; } @@ -156,7 +173,8 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) { var pad = ComputeTotalPad(); var sdf = BuildSdfRgbFromCoverage(spec.Bitmap, pad, Math.Max(1, PixelRange)); - spec.Glyph.Offset -= new Vector2(pad, pad); + if (offsetAdjusted.Add(c)) + spec.Glyph.Offset -= new Vector2(pad, pad); spec.Glyph.Subrect = new Rectangle(0, 0, sdf.Width, sdf.Rows); pendingSdfBitmaps[c] = sdf; } From 2f0b1659efaa479457c3008e15d31faa00617715 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Thu, 29 Jan 2026 03:22:32 -0800 Subject: [PATCH 06/57] Async implementation and various optimizations. --- .../Font/FontCacheManagerMsdf.cs | 3 +- .../RuntimeSignedDistanceFieldSpriteFont.cs | 260 +++++++++++------- 2 files changed, 166 insertions(+), 97 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs index 2408e313db..c8a501ed9f 100644 --- a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -117,7 +117,7 @@ public MsdfCachedGlyph UploadGlyphBitmap( LastUsedFrame = system.FrameCount, IsUploaded = true, }; - cached.Owner = owner; + cachedGlyphs.AddFirst(cached.ListNode); return cached; } @@ -166,7 +166,6 @@ protected override void Destroy() /// internal sealed class MsdfCachedGlyph { - public Rectangle Subrect; public int BitmapIndex; public int LastUsedFrame; public bool IsUploaded; diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 56eb241b6d..8c919c5af9 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,6 +1,5 @@ using System; -using System.Collections.Generic; -using System; +using System.Buffers; using System.Collections.Generic; using Stride.Core; using Stride.Core.Mathematics; @@ -17,7 +16,7 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont internal string FontName; internal FontStyle Style; - internal int BakeSize = 64; + internal int BakeSize = 32; internal int PixelRange = 8; internal int Padding = 2; @@ -25,7 +24,6 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont // Single-size runtime SDF: key is just char private readonly Dictionary characters = new Dictionary(); - private readonly Dictionary pendingSdfBitmaps = new Dictionary(); private readonly Dictionary cacheRecords = new Dictionary(); @@ -37,6 +35,17 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont [DataMemberIgnore] internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem != null ? FontSystem.FontCacheManagerMsdf : null; + // 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<(char c, CharacterBitmapRgba sdf, int pad)> readyForUpload + = new(); + + // 3) Limit CPU concurrency (otherwise “paragraph appears” spawns 500 tasks) + private readonly System.Threading.SemaphoreSlim genBudget = new(initialCount: 2, maxCount: 2); + internal override FontSystem FontSystem { set @@ -57,10 +66,10 @@ internal override FontSystem FontSystem FontManager.GetFontInfo(FontName, Style, out relativeLineSpacing, out relativeBaseOffsetY, out relativeMaxWidth, out relativeMaxHeight); DefaultLineSpacing = relativeLineSpacing * Size; - BaseOffsetY = relativeBaseOffsetY * Size; + BaseOffsetY = relativeBaseOffsetY * Size; - // Use RGBA MSDF cache textures - Textures = FontCacheManagerMsdf.Textures; + // Use RGBA MSDF cache textures + Textures = FontCacheManagerMsdf.Textures; // Keep channels as-is (RGB median used by shader) swizzle = default; @@ -85,7 +94,12 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // All glyphs baked at BakeSize var bakedSizeVec = new Vector2(BakeSize, BakeSize); - fixScaling = fontSize / bakedSizeVec; + + // IMPORTANT: + // SDF fonts are scaled by Stride using requestedFontSize vs SpriteFont.Size. + // fixScaling should only compensate baked glyph pixel size (BakeSize) -> logical font size (Size). + var logicalSizeVec = new Vector2(Size, Size); + fixScaling = logicalSizeVec / bakedSizeVec; var spec = GetOrCreateCharacterData(character, bakedSizeVec); @@ -104,42 +118,14 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve } } - // 2) Build SDF bitmap once (CPU) - if (!pendingSdfBitmaps.TryGetValue(character, out var sdfBitmap)) - { - var pad = ComputeTotalPad(); - sdfBitmap = BuildSdfRgbFromCoverage(spec.Bitmap, pad, Math.Max(1, PixelRange)); - - // IMPORTANT: our bitmap now has extra pixels on left/top => shift offset - if (offsetAdjusted.Add(character)) - { - spec.Glyph.Offset -= new Vector2(pad, pad); - } - - // Give subrect sane dimensions even before upload (not critical for MeasureString but harmless) - spec.Glyph.Subrect = new Rectangle(0, 0, sdfBitmap.Width, sdfBitmap.Rows); - spec.IsBitmapUploaded = false; + // 2) Schedule async SDF generation (only once per char) + EnsureSdfScheduled(character, spec); + if (commandList != null) + DrainUploads(commandList); - pendingSdfBitmaps[character] = sdfBitmap; - } + // 3) Upload - // 3) Upload only when drawing (MeasureString passes uploadGpuResources=false and commandList=null) - if (commandList != null && !spec.IsBitmapUploaded) - { - var subrect = new Rectangle(); - var handle = cache.UploadGlyphBitmap(commandList, spec, sdfBitmap, ref subrect, out var bitmapIndex); - - spec.Glyph.Subrect = handle.InnerSubrect; - spec.Glyph.BitmapIndex = bitmapIndex; - spec.IsBitmapUploaded = true; - - cacheRecords[character] = handle; - cache.NotifyGlyphUtilization(handle); - - sdfBitmap.Dispose(); - pendingSdfBitmaps.Remove(character); - } - else if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(character, out var handle)) + if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(character, out var handle)) { // If evicted/cleared, this will flip false and we’ll reupload next draw if (!handle.IsUploaded) @@ -158,7 +144,8 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) { - // Sync pregen for M4.1 proof-of-work (matches your preference) + + // Async pregen glyphs var bakedSizeVec = new Vector2(BakeSize, BakeSize); for (int i = 0; i < text.Length; i++) @@ -168,16 +155,9 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) if (spec.Bitmap == null) FontManager.GenerateBitmap(spec, true); + + EnsureSdfScheduled(c, spec); - if (spec.Bitmap != null && spec.Bitmap.Width != 0 && spec.Bitmap.Rows != 0 && !pendingSdfBitmaps.ContainsKey(c)) - { - var pad = ComputeTotalPad(); - var sdf = BuildSdfRgbFromCoverage(spec.Bitmap, pad, Math.Max(1, PixelRange)); - if (offsetAdjusted.Add(c)) - spec.Glyph.Offset -= new Vector2(pad, pad); - spec.Glyph.Subrect = new Rectangle(0, 0, sdf.Width, sdf.Rows); - pendingSdfBitmaps[c] = sdf; - } } } @@ -186,7 +166,10 @@ private CharacterSpecification GetOrCreateCharacterData(char character, Vector2 if (!characters.TryGetValue(character, out var spec)) { // AntiAlias: use AntiAliased so coverage bitmap is smooth - spec = new CharacterSpecification(character, FontName, bakedSize, Style, FontAntiAliasMode.Aliased); + spec = new CharacterSpecification(character, FontName, bakedSize, Style, FontAntiAliasMode.Grayscale); + spec.Glyph.Subrect = Rectangle.Empty; + spec.Glyph.BitmapIndex = 0; + spec.IsBitmapUploaded = false; characters[character] = spec; } @@ -203,45 +186,33 @@ private int ComputeTotalPad() // --- SDF generation (CPU), packed into RGB so median(R,G,B)=SDF value --- - private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(CharacterBitmap coverage, int pad, int pixelRange) + private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(byte[] src, int srcW, int srcH, int srcPitch, int pad, int pixelRange) { - int srcW = coverage.Width; - int srcH = coverage.Rows; - int w = srcW + pad * 2; int h = srcH + pad * 2; - // Build inside mask from coverage into padded image var inside = new bool[w * h]; - byte* src = (byte*)coverage.Buffer; - int srcPitch = coverage.Pitch; // in CharacterBitmap, pitch == width - for (int y = 0; y < srcH; y++) { int dstRow = (y + pad) * w + pad; - byte* srcRow = src + y * srcPitch; + int srcRow = y * srcPitch; for (int x = 0; x < srcW; x++) { - inside[dstRow + x] = srcRow[x] >= 128; + inside[dstRow + x] = src[srcRow + x] >= 128; } } - // EDT to nearest OUTSIDE (feature = outside) var distToOutsideSq = new float[w * h]; ComputeEdtSquared(w, h, inside, featureIsInside: false, distToOutsideSq); - // EDT to nearest INSIDE (feature = inside) var distToInsideSq = new float[w * h]; ComputeEdtSquared(w, h, inside, featureIsInside: true, distToInsideSq); - // Pack signed distance into RGB var bmp = new CharacterBitmapRgba(w, h); byte* dst = (byte*)bmp.Buffer; - // Stride shader uses an edge around ~0.4 (see SignedDistanceFieldFont.sdsl) - // We encode: 0.4 at boundary, +/- pixelRange maps to +/-0.5 range. float scale = 0.5f / Math.Max(1, pixelRange); for (int y = 0; y < h; y++) @@ -252,11 +223,8 @@ private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(CharacterBitma for (int x = 0; x < w; x++) { int i = baseIdx + x; - float dOut = MathF.Sqrt(distToOutsideSq[i]); float dIn = MathF.Sqrt(distToInsideSq[i]); - - // signed: + inside, - outside float signed = dOut - dIn; float encoded = Math.Clamp(0.4f + signed * scale, 0f, 1f); @@ -278,39 +246,53 @@ private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(CharacterBitma private static void ComputeEdtSquared(int w, int h, bool[] inside, bool featureIsInside, float[] outDistSq) { const float INF = 1e20f; + int maxDim = Math.Max(w, h); - // Stage 1: vertical transform - var tmp = new float[w * h]; - var f = new float[Math.Max(w, h)]; - var d = new float[Math.Max(w, h)]; - var v = new int[Math.Max(w, h)]; - var z = new float[Math.Max(w, h) + 1]; + // 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); - for (int x = 0; x < w; x++) + try { - for (int y = 0; y < h; y++) + // Stage 1: vertical transform + for (int x = 0; x < w; x++) { - bool isFeature = (inside[y * w + x] == featureIsInside); - f[y] = isFeature ? 0f : INF; - } + 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); + DistanceTransform1D(f, h, d, v, z); - for (int y = 0; y < h; y++) - tmp[y * w + x] = d[y]; - } + 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]; + // 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); + DistanceTransform1D(f, w, d, v, z); - for (int x = 0; x < w; x++) - outDistSq[row + x] = d[x]; + 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); } } @@ -351,5 +333,93 @@ private static void DistanceTransform1D(float[] f, int n, float[] d, int[] v, fl d[q] = dx * dx + f[p]; } } + + private void EnsureSdfScheduled(char c, CharacterSpecification spec) + { + // Already uploaded? nothing to do. + if (spec.IsBitmapUploaded) return; + + // Already have bitmap? If not, we can’t generate. + var bmp = spec.Bitmap; + if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; + + // Already scheduled? bail. + if (!inFlight.TryAdd(c, 0)) return; + + // Copy the grayscale/coverage bitmap to an owned array so background thread is safe. + // (Do NOT let the worker read spec.Bitmap.Buffer directly.) + var width = bmp.Width; + var rows = bmp.Rows; + var pitch = bmp.Pitch; + + var srcCopy = new byte[pitch * rows]; + unsafe + { + System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); + } + + int pad = /* your total pad, e.g. PixelRange + extra padding */ ComputeTotalPad(); + int pixelRange = Math.Max(1, PixelRange); + + _ = System.Threading.Tasks.Task.Run(async () => + { + await genBudget.WaitAsync().ConfigureAwait(false); + try + { + // Build SDF RGBA from coverage copy + var sdf = BuildSdfRgbFromCoverage(srcCopy, width, rows, pitch, pad, pixelRange); + + readyForUpload.Enqueue((c, sdf, pad)); + } + finally + { + genBudget.Release(); + inFlight.TryRemove(c, out _); + } + }); + } + + 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 (c, sdfBitmap, pad) = item; + + if (!characters.TryGetValue(c, 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); + + spec.Glyph.Subrect = handle.InnerSubrect; + spec.Glyph.BitmapIndex = bitmapIndex; + spec.IsBitmapUploaded = true; + + cacheRecords[c] = handle; + cache.NotifyGlyphUtilization(handle); + + if (offsetAdjusted.Add(c)) + spec.Glyph.Offset -= new Vector2(pad, pad); + + sdfBitmap.Dispose(); + } + } + } } From dd4f8e02f31f620acf5f1d7ec44876d2a4e5c0ee Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 30 Jan 2026 13:41:19 -0800 Subject: [PATCH 07/57] Fixed bad copypaste for SpriteFontAssetCompiler.cs --- .../SpriteFont/SpriteFontAssetCompiler.cs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs index 125dd0a7f7..1df06f49df 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs @@ -74,24 +74,6 @@ protected override void Prepare(AssetCompilerContext context, AssetItem assetIte result.BuildSteps.Add(new ImportStreamCommand { SourcePath = fontPathOnDisk, Location = fontImportLocation }); result.BuildSteps.Add(new RuntimeSignedDistanceFieldFontCommand(targetUrlInStorage, asset, assetItem.Package)); } - else if (asset.FontType is RuntimeRasterizedSpriteFontType) - { - 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.BuildSteps = new AssetBuildStep(assetItem); - result.BuildSteps.Add(new ImportStreamCommand { SourcePath = fontPathOnDisk, Location = fontImportLocation }); - result.BuildSteps.Add(new RuntimeRasterizedFontCommand(targetUrlInStorage, asset, assetItem.Package)); - } - else { var fontTypeStatic = asset.FontType as OfflineRasterizedSpriteFontType; From 0dc668c3e450badddd56da8c972535629211a648 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 30 Jan 2026 17:46:17 -0800 Subject: [PATCH 08/57] Less sloppy Async. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 85 ++++++++++++------- 1 file changed, 54 insertions(+), 31 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 8c919c5af9..e0cf1e4a5b 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,6 +1,8 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.Threading.Channels; +using System.Threading.Tasks; using Stride.Core; using Stride.Core.Mathematics; using Stride.Core.Serialization; @@ -26,6 +28,8 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont private readonly Dictionary characters = new Dictionary(); private readonly Dictionary cacheRecords = new Dictionary(); + private readonly Channel workQueue = Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); private readonly HashSet offsetAdjusted = new HashSet(); @@ -46,6 +50,18 @@ private readonly System.Collections.Concurrent.ConcurrentDictionary // 3) Limit CPU concurrency (otherwise “paragraph appears” spawns 500 tasks) private readonly System.Threading.SemaphoreSlim genBudget = new(initialCount: 2, maxCount: 2); + private struct SdfWorkItem + { + public char Character; + public byte[] SourceBuffer; + public int Width; + public int Rows; + public int Pitch; + public int Pad; + } + + + internal override FontSystem FontSystem { set @@ -334,49 +350,56 @@ private static void DistanceTransform1D(float[] f, int n, float[] d, int[] v, fl } } + private void StartWorker() + { + Task.Run(async () => + { + // One persistent loop instead of 1000s of discarded tasks + await foreach (var work in workQueue.Reader.ReadAllAsync()) + { + try + { + var sdf = BuildSdfRgbFromCoverage( + work.SourceBuffer, work.Width, work.Rows, work.Pitch, work.Pad, Math.Max(1, PixelRange)); + + readyForUpload.Enqueue((work.Character, sdf, work.Pad)); + } + finally + { + inFlight.TryRemove(work.Character, out _); + } + } + }); + } private void EnsureSdfScheduled(char c, CharacterSpecification spec) { - // Already uploaded? nothing to do. if (spec.IsBitmapUploaded) return; - // Already have bitmap? If not, we can’t generate. var bmp = spec.Bitmap; if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; - // Already scheduled? bail. + // Fast atomic check if (!inFlight.TryAdd(c, 0)) return; - // Copy the grayscale/coverage bitmap to an owned array so background thread is safe. - // (Do NOT let the worker read spec.Bitmap.Buffer directly.) - var width = bmp.Width; - var rows = bmp.Rows; - var pitch = bmp.Pitch; + // Defensive copy + var srcCopy = new byte[bmp.Pitch * bmp.Rows]; + unsafe { System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); } - var srcCopy = new byte[pitch * rows]; - unsafe + var work = new SdfWorkItem { - System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); - } - - int pad = /* your total pad, e.g. PixelRange + extra padding */ ComputeTotalPad(); - int pixelRange = Math.Max(1, PixelRange); - - _ = System.Threading.Tasks.Task.Run(async () => + Character = c, + SourceBuffer = srcCopy, + Width = bmp.Width, + Rows = bmp.Rows, + Pitch = bmp.Pitch, + Pad = ComputeTotalPad() + }; + + // TryWrite is non-blocking and allocation-free for the queue itself + if (!workQueue.Writer.TryWrite(work)) { - await genBudget.WaitAsync().ConfigureAwait(false); - try - { - // Build SDF RGBA from coverage copy - var sdf = BuildSdfRgbFromCoverage(srcCopy, width, rows, pitch, pad, pixelRange); - - readyForUpload.Enqueue((c, sdf, pad)); - } - finally - { - genBudget.Release(); - inFlight.TryRemove(c, out _); - } - }); + inFlight.TryRemove(c, out _); + } } private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) From 69e2f157bef4912eced8addd7794ef825c494d43 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sat, 31 Jan 2026 07:07:26 -0800 Subject: [PATCH 09/57] Revert "Less sloppy Async." This reverts commit 0dc668c3e450badddd56da8c972535629211a648. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 85 +++++++------------ 1 file changed, 31 insertions(+), 54 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index e0cf1e4a5b..8c919c5af9 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,8 +1,6 @@ using System; using System.Buffers; using System.Collections.Generic; -using System.Threading.Channels; -using System.Threading.Tasks; using Stride.Core; using Stride.Core.Mathematics; using Stride.Core.Serialization; @@ -28,8 +26,6 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont private readonly Dictionary characters = new Dictionary(); private readonly Dictionary cacheRecords = new Dictionary(); - private readonly Channel workQueue = Channel.CreateUnbounded( - new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); private readonly HashSet offsetAdjusted = new HashSet(); @@ -50,18 +46,6 @@ private readonly System.Collections.Concurrent.ConcurrentDictionary // 3) Limit CPU concurrency (otherwise “paragraph appears” spawns 500 tasks) private readonly System.Threading.SemaphoreSlim genBudget = new(initialCount: 2, maxCount: 2); - private struct SdfWorkItem - { - public char Character; - public byte[] SourceBuffer; - public int Width; - public int Rows; - public int Pitch; - public int Pad; - } - - - internal override FontSystem FontSystem { set @@ -350,56 +334,49 @@ private static void DistanceTransform1D(float[] f, int n, float[] d, int[] v, fl } } - private void StartWorker() - { - Task.Run(async () => - { - // One persistent loop instead of 1000s of discarded tasks - await foreach (var work in workQueue.Reader.ReadAllAsync()) - { - try - { - var sdf = BuildSdfRgbFromCoverage( - work.SourceBuffer, work.Width, work.Rows, work.Pitch, work.Pad, Math.Max(1, PixelRange)); - - readyForUpload.Enqueue((work.Character, sdf, work.Pad)); - } - finally - { - inFlight.TryRemove(work.Character, out _); - } - } - }); - } private void EnsureSdfScheduled(char c, CharacterSpecification spec) { + // Already uploaded? nothing to do. if (spec.IsBitmapUploaded) return; + // Already have bitmap? If not, we can’t generate. var bmp = spec.Bitmap; if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; - // Fast atomic check + // Already scheduled? bail. if (!inFlight.TryAdd(c, 0)) return; - // Defensive copy - var srcCopy = new byte[bmp.Pitch * bmp.Rows]; - unsafe { System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); } + // Copy the grayscale/coverage bitmap to an owned array so background thread is safe. + // (Do NOT let the worker read spec.Bitmap.Buffer directly.) + var width = bmp.Width; + var rows = bmp.Rows; + var pitch = bmp.Pitch; - var work = new SdfWorkItem - { - Character = c, - SourceBuffer = srcCopy, - Width = bmp.Width, - Rows = bmp.Rows, - Pitch = bmp.Pitch, - Pad = ComputeTotalPad() - }; - - // TryWrite is non-blocking and allocation-free for the queue itself - if (!workQueue.Writer.TryWrite(work)) + var srcCopy = new byte[pitch * rows]; + unsafe { - inFlight.TryRemove(c, out _); + System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); } + + int pad = /* your total pad, e.g. PixelRange + extra padding */ ComputeTotalPad(); + int pixelRange = Math.Max(1, PixelRange); + + _ = System.Threading.Tasks.Task.Run(async () => + { + await genBudget.WaitAsync().ConfigureAwait(false); + try + { + // Build SDF RGBA from coverage copy + var sdf = BuildSdfRgbFromCoverage(srcCopy, width, rows, pitch, pad, pixelRange); + + readyForUpload.Enqueue((c, sdf, pad)); + } + finally + { + genBudget.Release(); + inFlight.TryRemove(c, out _); + } + }); } private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) From 7ad4caaf284198d2aa0d9803bc768e3e77e52bd2 Mon Sep 17 00:00:00 2001 From: yuechen-li-dev <109704248+yuechen-li-dev@users.noreply.github.com> Date: Sun, 1 Feb 2026 04:29:03 -0800 Subject: [PATCH 10/57] Remove runtime SDF bake size plumbing --- ...untimeSignedDistanceFieldSpriteFontType.cs | 14 ++----------- .../SpriteFont/SpriteFontAssetCompiler.cs | 1 - .../Stride.Graphics/Font/FontDataFactory.cs | 2 -- .../engine/Stride.Graphics/Font/FontSystem.cs | 2 -- .../RuntimeSignedDistanceFieldSpriteFont.cs | 20 +++++++++---------- ...SignedDistanceFieldSpriteFontSerializer.cs | 2 -- 6 files changed, 11 insertions(+), 30 deletions(-) diff --git a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs index 9f07aada06..e9e769718e 100644 --- a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs +++ b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs @@ -19,20 +19,10 @@ public class RuntimeSignedDistanceFieldSpriteFontType : SpriteFontTypeBase [Display("Default Size")] public override float Size { get; set; } = 20; - /// - /// The fixed size (in pixels) used to generate MSDF glyph bitmaps at runtime. - /// The font can be rendered at other sizes by scaling in the SDF shader. - /// - [DataMember(40)] - [DefaultValue(64)] - [DataMemberRange(8, 256, 1, 8, 0)] - [Display("Bake Size")] - public int BakeSize { get; set; } = 64; - /// /// Distance field range/spread (in pixels) used during MSDF generation. /// - [DataMember(50)] + [DataMember(40)] [DefaultValue(8)] [DataMemberRange(1, 64, 1, 4, 0)] [Display("Pixel Range")] @@ -41,7 +31,7 @@ public class RuntimeSignedDistanceFieldSpriteFontType : SpriteFontTypeBase /// /// Extra padding around each glyph inside the atlas (in pixels). /// - [DataMember(60)] + [DataMember(50)] [DefaultValue(2)] [DataMemberRange(0, 16, 1, 2, 0)] [Display("Padding")] diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs index 1df06f49df..6524e9f45a 100644 --- a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs +++ b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs @@ -237,7 +237,6 @@ protected override Task DoCommandOverride(ICommandContext commandC runtimeSdfType.Size, Parameters.FontSource.GetFontName(), Parameters.FontSource.Style, - runtimeSdfType.BakeSize, runtimeSdfType.PixelRange, runtimeSdfType.Padding, useKerning: false, diff --git a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs index 48c7a1b312..9c780966c1 100644 --- a/sources/engine/Stride.Graphics/Font/FontDataFactory.cs +++ b/sources/engine/Stride.Graphics/Font/FontDataFactory.cs @@ -54,7 +54,6 @@ public SpriteFont NewRuntimeSignedDistanceField( float defaultSize, string fontName, FontStyle style, - int bakeSize, int pixelRange, int padding, bool useKerning, @@ -70,7 +69,6 @@ public SpriteFont NewRuntimeSignedDistanceField( FontName = fontName, Style = style, - BakeSize = bakeSize, PixelRange = pixelRange, Padding = padding, diff --git a/sources/engine/Stride.Graphics/Font/FontSystem.cs b/sources/engine/Stride.Graphics/Font/FontSystem.cs index ae2d9deff6..16df7cd0ec 100644 --- a/sources/engine/Stride.Graphics/Font/FontSystem.cs +++ b/sources/engine/Stride.Graphics/Font/FontSystem.cs @@ -143,7 +143,6 @@ public SpriteFont NewRuntimeSignedDistanceField( float defaultSize, string fontName, FontStyle style, - int bakeSize, int pixelRange, int padding, bool useKerning, @@ -159,7 +158,6 @@ public SpriteFont NewRuntimeSignedDistanceField( FontName = fontName, Style = style, - BakeSize = bakeSize, PixelRange = pixelRange, Padding = padding, diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 8c919c5af9..c42cff07b4 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -16,7 +16,6 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont internal string FontName; internal FontStyle Style; - internal int BakeSize = 32; internal int PixelRange = 8; internal int Padding = 2; @@ -92,16 +91,15 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve if (cache == null) throw new InvalidOperationException("RuntimeSignedDistanceFieldSpriteFont requires FontSystem.FontCacheManagerMsdf to be initialized."); - // All glyphs baked at BakeSize - var bakedSizeVec = new Vector2(BakeSize, BakeSize); + // All glyphs are generated at Size + var sizeVec = new Vector2(Size, Size); // IMPORTANT: // SDF fonts are scaled by Stride using requestedFontSize vs SpriteFont.Size. - // fixScaling should only compensate baked glyph pixel size (BakeSize) -> logical font size (Size). - var logicalSizeVec = new Vector2(Size, Size); - fixScaling = logicalSizeVec / bakedSizeVec; + // Glyphs are baked at Size, so no compensating scaling is required. + fixScaling = Vector2.One; - var spec = GetOrCreateCharacterData(character, bakedSizeVec); + var spec = GetOrCreateCharacterData(character, sizeVec); // 1) Ensure we have the coverage bitmap + correct metrics (sync) if (spec.Bitmap == null) @@ -146,12 +144,12 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) { // Async pregen glyphs - var bakedSizeVec = new Vector2(BakeSize, BakeSize); + var sizeVec = new Vector2(Size, Size); for (int i = 0; i < text.Length; i++) { var c = text[i]; - var spec = GetOrCreateCharacterData(c, bakedSizeVec); + var spec = GetOrCreateCharacterData(c, sizeVec); if (spec.Bitmap == null) FontManager.GenerateBitmap(spec, true); @@ -161,12 +159,12 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) } } - private CharacterSpecification GetOrCreateCharacterData(char character, Vector2 bakedSize) + private CharacterSpecification GetOrCreateCharacterData(char character, Vector2 size) { if (!characters.TryGetValue(character, out var spec)) { // AntiAlias: use AntiAliased so coverage bitmap is smooth - spec = new CharacterSpecification(character, FontName, bakedSize, Style, FontAntiAliasMode.Grayscale); + spec = new CharacterSpecification(character, FontName, size, Style, FontAntiAliasMode.Grayscale); spec.Glyph.Subrect = Rectangle.Empty; spec.Glyph.BitmapIndex = 0; spec.IsBitmapUploaded = false; diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs index 6cdf00e935..8f37e59f93 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFontSerializer.cs @@ -37,7 +37,6 @@ public override void Serialize(ref RuntimeSignedDistanceFieldSpriteFont font, Ar font.Style = stream.Read(); font.UseKerning = stream.Read(); - font.BakeSize = stream.Read(); font.PixelRange = stream.Read(); font.Padding = stream.Read(); @@ -50,7 +49,6 @@ public override void Serialize(ref RuntimeSignedDistanceFieldSpriteFont font, Ar stream.Write(font.Style); stream.Write(font.UseKerning); - stream.Write(font.BakeSize); stream.Write(font.PixelRange); stream.Write(font.Padding); } From d5d95761854a6632ed9a617f1d23eedceb6aec4a Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 1 Feb 2026 04:58:54 -0800 Subject: [PATCH 11/57] Adjusted default bakesize to 64 so SDF is high quality. --- .../SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs index e9e769718e..1b22c3a4e2 100644 --- a/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs +++ b/sources/engine/Stride.Assets/SpriteFont/RuntimeSignedDistanceFieldSpriteFontType.cs @@ -17,7 +17,7 @@ public class RuntimeSignedDistanceFieldSpriteFontType : SpriteFontTypeBase [DataMemberRange(MathUtil.ZeroTolerance, 2)] [DefaultValue(20)] [Display("Default Size")] - public override float Size { get; set; } = 20; + public override float Size { get; set; } = 64; /// /// Distance field range/spread (in pixels) used during MSDF generation. From 217a8fd6f63944b79e1baf8f6d12dd2bdc2155c8 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 14:29:14 -0800 Subject: [PATCH 12/57] Changed to channel based async design. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 306 +++++++++++------- 1 file changed, 195 insertions(+), 111 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index c42cff07b4..bad69758e4 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,6 +1,9 @@ using System; using System.Buffers; 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; @@ -22,28 +25,40 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont internal bool UseKerning; // Single-size runtime SDF: key is just char - private readonly Dictionary characters = new Dictionary(); - private readonly Dictionary cacheRecords - = new Dictionary(); + private readonly Dictionary characters = []; + private readonly Dictionary cacheRecords = []; - private readonly HashSet offsetAdjusted = new HashSet(); + private readonly HashSet offsetAdjusted = []; [DataMemberIgnore] - internal FontManager FontManager => FontSystem != null ? FontSystem.FontManager : null; + internal FontManager FontManager => FontSystem?.FontManager; [DataMemberIgnore] - internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem != null ? FontSystem.FontCacheManagerMsdf : null; + internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem?.FontCacheManagerMsdf; // 1) Dedup scheduling - private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight - = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight = new(); // 2) Generated SDF results waiting for GPU upload - private readonly System.Collections.Concurrent.ConcurrentQueue<(char c, CharacterBitmapRgba sdf, int pad)> readyForUpload - = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue<(char c, CharacterBitmapRgba sdf, int pad)> readyForUpload = new(); - // 3) Limit CPU concurrency (otherwise “paragraph appears” spawns 500 tasks) - private readonly System.Threading.SemaphoreSlim genBudget = new(initialCount: 2, maxCount: 2); + // --- 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( + char C, + byte[] Src, + int Width, + int Rows, + int Pitch, + int Pad, + int PixelRange); internal override FontSystem FontSystem { @@ -52,23 +67,27 @@ internal override FontSystem FontSystem 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 - float relativeLineSpacing; - float relativeBaseOffsetY; - float relativeMaxWidth; - float relativeMaxHeight; - FontManager.GetFontInfo(FontName, Style, out relativeLineSpacing, out relativeBaseOffsetY, out relativeMaxWidth, out relativeMaxHeight); + FontManager.GetFontInfo(FontName, Style, out var relativeLineSpacing, out var relativeBaseOffsetY, out var relativeMaxWidth, out var relativeMaxHeight); DefaultLineSpacing = relativeLineSpacing * Size; - BaseOffsetY = relativeBaseOffsetY * Size; + BaseOffsetY = relativeBaseOffsetY * Size; - // Use RGBA MSDF cache textures - Textures = FontCacheManagerMsdf.Textures; + // Use RGBA MSDF cache textures + Textures = FontCacheManagerMsdf.Textures; // Keep channels as-is (RGB median used by shader) swizzle = default; @@ -87,9 +106,7 @@ public override bool IsCharPresent(char c) protected override Glyph GetGlyph(CommandList commandList, char character, in Vector2 fontSize, bool uploadGpuResources, out Vector2 fixScaling) { - var cache = FontCacheManagerMsdf; - if (cache == null) - throw new InvalidOperationException("RuntimeSignedDistanceFieldSpriteFont requires FontSystem.FontCacheManagerMsdf to be initialized."); + 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); @@ -182,6 +199,161 @@ private int ComputeTotalPad() 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 = BuildSdfRgbFromCoverage(item.Src, item.Width, item.Rows, item.Pitch, item.Pad, item.PixelRange); + + // hand off to render thread for GPU upload + readyForUpload.Enqueue((item.C, sdf, item.Pad)); + } + catch + { + // If generation fails, we just allow rescheduling later. + } + finally + { + inFlight.TryRemove(item.C, out _); + } + } + } + } + catch (OperationCanceledException) + { + // normal shutdown + } + } + + private void EnsureSdfScheduled(char c, CharacterSpecification spec) + { + // Already uploaded? nothing to do. + if (spec.IsBitmapUploaded) return; + + // Already have bitmap? If not, we can’t generate. + var bmp = spec.Bitmap; + if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; + + // Ensure worker infrastructure is alive (safe even if already started) + EnsureWorkersStarted(); + + // Already scheduled? bail. + if (!inFlight.TryAdd(c, 0)) return; + + // Copy coverage bitmap to owned array so background thread is safe. + var width = bmp.Width; + var rows = bmp.Rows; + var pitch = bmp.Pitch; + + var srcCopy = new byte[pitch * rows]; + unsafe + { + System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); + } + + int pad = ComputeTotalPad(); + int pixelRange = Math.Max(1, PixelRange); + + // Render thread must NEVER block: TryWrite only. + if (!workChannel.Writer.TryWrite(new WorkItem(c, srcCopy, width, rows, pitch, pad, pixelRange))) + { + // Queue full; allow retry next frame + inFlight.TryRemove(c, out _); + } + } + + 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 (c, sdfBitmap, pad) = item; + + if (!characters.TryGetValue(c, 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); + + spec.Glyph.Subrect = handle.InnerSubrect; + spec.Glyph.BitmapIndex = bitmapIndex; + spec.IsBitmapUploaded = true; + + cacheRecords[c] = handle; + cache.NotifyGlyphUtilization(handle); + + if (offsetAdjusted.Add(c)) + spec.Glyph.Offset -= new Vector2(pad, pad); + + sdfBitmap.Dispose(); + } + } + + // --- SDF generation (CPU), 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) @@ -331,93 +503,5 @@ private static void DistanceTransform1D(float[] f, int n, float[] d, int[] v, fl d[q] = dx * dx + f[p]; } } - - private void EnsureSdfScheduled(char c, CharacterSpecification spec) - { - // Already uploaded? nothing to do. - if (spec.IsBitmapUploaded) return; - - // Already have bitmap? If not, we can’t generate. - var bmp = spec.Bitmap; - if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; - - // Already scheduled? bail. - if (!inFlight.TryAdd(c, 0)) return; - - // Copy the grayscale/coverage bitmap to an owned array so background thread is safe. - // (Do NOT let the worker read spec.Bitmap.Buffer directly.) - var width = bmp.Width; - var rows = bmp.Rows; - var pitch = bmp.Pitch; - - var srcCopy = new byte[pitch * rows]; - unsafe - { - System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); - } - - int pad = /* your total pad, e.g. PixelRange + extra padding */ ComputeTotalPad(); - int pixelRange = Math.Max(1, PixelRange); - - _ = System.Threading.Tasks.Task.Run(async () => - { - await genBudget.WaitAsync().ConfigureAwait(false); - try - { - // Build SDF RGBA from coverage copy - var sdf = BuildSdfRgbFromCoverage(srcCopy, width, rows, pitch, pad, pixelRange); - - readyForUpload.Enqueue((c, sdf, pad)); - } - finally - { - genBudget.Release(); - inFlight.TryRemove(c, out _); - } - }); - } - - 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 (c, sdfBitmap, pad) = item; - - if (!characters.TryGetValue(c, 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); - - spec.Glyph.Subrect = handle.InnerSubrect; - spec.Glyph.BitmapIndex = bitmapIndex; - spec.IsBitmapUploaded = true; - - cacheRecords[c] = handle; - cache.NotifyGlyphUtilization(handle); - - if (offsetAdjusted.Add(c)) - spec.Glyph.Offset -= new Vector2(pad, pad); - - sdfBitmap.Dispose(); - } - } - } } From e2e9f0965d21d0b10006888d833b89c7931f34d6 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 15:07:09 -0800 Subject: [PATCH 13/57] Channel based refactor for async, introduce interface for easier library integration. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9d793d1624..707548109d 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,4 @@ fastlane/report.xml fastlane/screenshots *.user project.lock.json +/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.channel.20260202-1.cs From ae2e234a083a988c74aaa2c0caaa487c529e1007 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 15:07:55 -0800 Subject: [PATCH 14/57] See above message. Wrong commit lol. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 108 +++++++++++++----- 1 file changed, 78 insertions(+), 30 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index bad69758e4..4215217fb4 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -24,18 +24,51 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont 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); + + // 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 interface IDistanceFieldGenerator + { + // Current path: coverage bitmap → packed RGBA bitmap + CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int rows, int pitch, DistanceFieldParams p); + + // Future path: outline → MSDF (placeholder for msdfgen integration) + // CharacterBitmapRgba GenerateFromOutline(GlyphOutline outline, DistanceFieldParams p); + } + + private sealed class SdfCoverageGenerator : IDistanceFieldGenerator + { + public CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int rows, int pitch, DistanceFieldParams p) + => BuildSdfRgbFromCoverage(coverage, width, rows, pitch, p.Pad, p.PixelRange, p.Encode); + } + + private readonly IDistanceFieldGenerator generator = new SdfCoverageGenerator(); + // Single-size runtime SDF: key is just char private readonly Dictionary characters = []; private readonly Dictionary cacheRecords = []; private readonly HashSet offsetAdjusted = []; - + [DataMemberIgnore] internal FontManager FontManager => FontSystem?.FontManager; [DataMemberIgnore] internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem?.FontCacheManagerMsdf; + // Async wiring // 1) Dedup scheduling private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight = new(); @@ -57,8 +90,7 @@ private readonly record struct WorkItem( int Width, int Rows, int Pitch, - int Pad, - int PixelRange); + DistanceFieldParams Params); internal override FontSystem FontSystem { @@ -139,7 +171,6 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve DrainUploads(commandList); // 3) Upload - if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(character, out var handle)) { // If evicted/cleared, this will flip false and we’ll reupload next draw @@ -170,7 +201,7 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) if (spec.Bitmap == null) FontManager.GenerateBitmap(spec, true); - + EnsureSdfScheduled(c, spec); } @@ -252,10 +283,10 @@ private async Task WorkerLoop(CancellationToken token) try { // CPU SDF build - var sdf = BuildSdfRgbFromCoverage(item.Src, item.Width, item.Rows, item.Pitch, item.Pad, item.PixelRange); + var sdf = generator.GenerateFromCoverage(item.Src, item.Width, item.Rows, item.Pitch, item.Params); // hand off to render thread for GPU upload - readyForUpload.Enqueue((item.C, sdf, item.Pad)); + readyForUpload.Enqueue((item.C, sdf, item.Params.Pad)); } catch { @@ -263,6 +294,7 @@ private async Task WorkerLoop(CancellationToken token) } finally { + ArrayPool.Shared.Return(item.Src); inFlight.TryRemove(item.C, out _); } } @@ -289,28 +321,51 @@ private void EnsureSdfScheduled(char c, CharacterSpecification spec) // Already scheduled? bail. if (!inFlight.TryAdd(c, 0)) return; - // Copy coverage bitmap to owned array so background thread is safe. + var p = GetDfParams(); + + // Copy coverage bitmap to a pooled array so background thread is safe (avoid per-glyph allocations). var width = bmp.Width; var rows = bmp.Rows; var pitch = bmp.Pitch; - var srcCopy = new byte[pitch * rows]; - unsafe + int len = pitch * rows; + var srcCopy = ArrayPool.Shared.Rent(len); + try { - System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, srcCopy.Length); - } - - int pad = ComputeTotalPad(); - int pixelRange = Math.Max(1, PixelRange); + unsafe + { + System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, len); + } - // Render thread must NEVER block: TryWrite only. - if (!workChannel.Writer.TryWrite(new WorkItem(c, srcCopy, width, rows, pitch, pad, pixelRange))) + // Render thread must NEVER block: TryWrite only. + if (!workChannel.Writer.TryWrite(new WorkItem(c, srcCopy, width, rows, pitch, p))) + { + // Queue full; allow retry next frame + ArrayPool.Shared.Return(srcCopy); + inFlight.TryRemove(c, out _); + } + } + catch { - // Queue full; allow retry next frame + ArrayPool.Shared.Return(srcCopy); inFlight.TryRemove(c, out _); + throw; } } + private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, char c, CharacterSpecification spec, FontCacheManagerMsdf.MsdfCachedGlyph handle, int bitmapIndex, int pad) + { + spec.Glyph.Subrect = handle.InnerSubrect; + spec.Glyph.BitmapIndex = bitmapIndex; + spec.IsBitmapUploaded = true; + + cacheRecords[c] = handle; + cache.NotifyGlyphUtilization(handle); + + if (offsetAdjusted.Add(c)) + spec.Glyph.Offset -= new Vector2(pad, pad); + } + private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) { var cache = FontCacheManagerMsdf; @@ -339,15 +394,7 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) var subrect = new Rectangle(); var handle = cache.UploadGlyphBitmap(commandList, spec, sdfBitmap, ref subrect, out var bitmapIndex); - spec.Glyph.Subrect = handle.InnerSubrect; - spec.Glyph.BitmapIndex = bitmapIndex; - spec.IsBitmapUploaded = true; - - cacheRecords[c] = handle; - cache.NotifyGlyphUtilization(handle); - - if (offsetAdjusted.Add(c)) - spec.Glyph.Offset -= new Vector2(pad, pad); + ApplyUploadedGlyph(cache, c, spec, handle, bitmapIndex, pad); sdfBitmap.Dispose(); } @@ -356,7 +403,7 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) // --- SDF generation (CPU), 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) + 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; @@ -383,8 +430,9 @@ private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(byte[] src, in var bmp = new CharacterBitmapRgba(w, h); byte* dst = (byte*)bmp.Buffer; - float scale = 0.5f / Math.Max(1, pixelRange); + 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; @@ -397,7 +445,7 @@ private static unsafe CharacterBitmapRgba BuildSdfRgbFromCoverage(byte[] src, in float dIn = MathF.Sqrt(distToInsideSq[i]); float signed = dOut - dIn; - float encoded = Math.Clamp(0.4f + signed * scale, 0f, 1f); + float encoded = Math.Clamp(bias + signed * scale, 0f, 1f); byte b = (byte)(encoded * 255f + 0.5f); int o = x * 4; From 914819d3f8e17dc282a6cadd029b03208a7a7fb2 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 16:06:13 -0800 Subject: [PATCH 15/57] refactor for future MSDFGeneration. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 119 +++++++++++------- 1 file changed, 73 insertions(+), 46 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 4215217fb4..54edfd16e1 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -29,6 +29,8 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont 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); @@ -39,28 +41,45 @@ private DistanceFieldParams GetDfParams() return new DistanceFieldParams(pixelRange, pad, DefaultEncode); } + private 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; + + // Placeholder container for future outline/MSDF generators. + // This keeps the scheduling/upload pipeline unchanged when we swap in msdfgen. + private sealed record OutlineInput(object OutlineData) : GlyphInput; + private interface IDistanceFieldGenerator { - // Current path: coverage bitmap → packed RGBA bitmap - CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int rows, int pitch, DistanceFieldParams p); - - // Future path: outline → MSDF (placeholder for msdfgen integration) - // CharacterBitmapRgba GenerateFromOutline(GlyphOutline outline, DistanceFieldParams p); + CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p); } private sealed class SdfCoverageGenerator : IDistanceFieldGenerator { - public CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int rows, int pitch, DistanceFieldParams p) - => BuildSdfRgbFromCoverage(coverage, width, rows, pitch, p.Pad, p.PixelRange, p.Encode); + 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), + OutlineInput => throw new NotSupportedException("Outline input is not supported by SdfCoverageGenerator."), + _ => throw new ArgumentOutOfRangeException(nameof(input)), + }; } private readonly IDistanceFieldGenerator generator = new SdfCoverageGenerator(); - // Single-size runtime SDF: key is just char - private readonly Dictionary characters = []; - private readonly Dictionary cacheRecords = []; + // Runtime SDF glyph cache key (future-proof for multiple ranges/modes) + private readonly Dictionary characters = []; + private readonly Dictionary cacheRecords = []; - private readonly HashSet offsetAdjusted = []; + private readonly HashSet offsetAdjusted = []; [DataMemberIgnore] internal FontManager FontManager => FontSystem?.FontManager; @@ -70,10 +89,10 @@ public CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int // Async wiring // 1) Dedup scheduling - private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary inFlight = new(); // 2) Generated SDF results waiting for GPU upload - private readonly System.Collections.Concurrent.ConcurrentQueue<(char c, CharacterBitmapRgba sdf, int pad)> readyForUpload = new(); + private readonly System.Collections.Concurrent.ConcurrentQueue<(GlyphKey key, CharacterBitmapRgba sdf)> readyForUpload = new(); // --- Bounded work queue + fixed worker pool --- @@ -85,11 +104,8 @@ public CharacterBitmapRgba GenerateFromCoverage(byte[] coverage, int width, int private Task[] workers; private readonly record struct WorkItem( - char C, - byte[] Src, - int Width, - int Rows, - int Pitch, + GlyphKey Key, + GlyphInput Input, DistanceFieldParams Params); internal override FontSystem FontSystem @@ -143,12 +159,17 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // 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 spec = GetOrCreateCharacterData(character, sizeVec); + + + var key = MakeKey(character, p); + var spec = GetOrCreateCharacterData(key, sizeVec); // 1) Ensure we have the coverage bitmap + correct metrics (sync) if (spec.Bitmap == null) @@ -166,18 +187,19 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve } // 2) Schedule async SDF generation (only once per char) - EnsureSdfScheduled(character, spec); + EnsureSdfScheduled(key, spec); if (commandList != null) DrainUploads(commandList); // 3) Upload - if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(character, out var handle)) + + 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.Remove(character); + cacheRecords.Remove(key); } else { @@ -193,30 +215,32 @@ 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 spec = GetOrCreateCharacterData(c, sizeVec); + var key = MakeKey(c, p); + var spec = GetOrCreateCharacterData(key, sizeVec); if (spec.Bitmap == null) FontManager.GenerateBitmap(spec, true); - EnsureSdfScheduled(c, spec); + EnsureSdfScheduled(key, spec); } } - private CharacterSpecification GetOrCreateCharacterData(char character, Vector2 size) + private CharacterSpecification GetOrCreateCharacterData(GlyphKey key, Vector2 size) { - if (!characters.TryGetValue(character, out var spec)) + if (!characters.TryGetValue(key, out var spec)) { // AntiAlias: use AntiAliased so coverage bitmap is smooth - spec = new CharacterSpecification(character, FontName, size, Style, FontAntiAliasMode.Grayscale); + spec = new CharacterSpecification(key.C, FontName, size, Style, FontAntiAliasMode.Grayscale); spec.Glyph.Subrect = Rectangle.Empty; spec.Glyph.BitmapIndex = 0; spec.IsBitmapUploaded = false; - characters[character] = spec; + characters[key] = spec; } return spec; @@ -283,10 +307,10 @@ private async Task WorkerLoop(CancellationToken token) try { // CPU SDF build - var sdf = generator.GenerateFromCoverage(item.Src, item.Width, item.Rows, item.Pitch, item.Params); + var sdf = generator.Generate(item.Input, item.Params); // hand off to render thread for GPU upload - readyForUpload.Enqueue((item.C, sdf, item.Params.Pad)); + readyForUpload.Enqueue((item.Key, sdf)); } catch { @@ -294,8 +318,10 @@ private async Task WorkerLoop(CancellationToken token) } finally { - ArrayPool.Shared.Return(item.Src); - inFlight.TryRemove(item.C, out _); + if (item.Input is CoverageInput c) + ArrayPool.Shared.Return(c.Buffer); + + inFlight.TryRemove(item.Key, out _); } } } @@ -306,7 +332,7 @@ private async Task WorkerLoop(CancellationToken token) } } - private void EnsureSdfScheduled(char c, CharacterSpecification spec) + private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) { // Already uploaded? nothing to do. if (spec.IsBitmapUploaded) return; @@ -319,10 +345,9 @@ private void EnsureSdfScheduled(char c, CharacterSpecification spec) EnsureWorkersStarted(); // Already scheduled? bail. - if (!inFlight.TryAdd(c, 0)) return; - - var p = GetDfParams(); + if (!inFlight.TryAdd(key, 0)) return; + var p = new DistanceFieldParams(key.PixelRange, key.Pad, DefaultEncode); // Copy coverage bitmap to a pooled array so background thread is safe (avoid per-glyph allocations). var width = bmp.Width; var rows = bmp.Rows; @@ -337,33 +362,35 @@ private void EnsureSdfScheduled(char c, CharacterSpecification spec) System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, len); } + var input = (GlyphInput)new CoverageInput(srcCopy, len, width, rows, pitch); + // Render thread must NEVER block: TryWrite only. - if (!workChannel.Writer.TryWrite(new WorkItem(c, srcCopy, width, rows, pitch, p))) + if (!workChannel.Writer.TryWrite(new WorkItem(key, input, p))) { // Queue full; allow retry next frame ArrayPool.Shared.Return(srcCopy); - inFlight.TryRemove(c, out _); + inFlight.TryRemove(key, out _); } } catch { ArrayPool.Shared.Return(srcCopy); - inFlight.TryRemove(c, out _); + inFlight.TryRemove(key, out _); throw; } } - private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, char c, CharacterSpecification spec, FontCacheManagerMsdf.MsdfCachedGlyph handle, int bitmapIndex, int pad) + 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[c] = handle; + cacheRecords[key] = handle; cache.NotifyGlyphUtilization(handle); - if (offsetAdjusted.Add(c)) - spec.Glyph.Offset -= new Vector2(pad, pad); + if (offsetAdjusted.Add(key)) + spec.Glyph.Offset -= new Vector2(key.Pad, key.Pad); } private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) @@ -376,9 +403,9 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) if (!readyForUpload.TryDequeue(out var item)) break; - var (c, sdfBitmap, pad) = item; + var (key, sdfBitmap) = item; - if (!characters.TryGetValue(c, out var spec) || spec == null) + if (!characters.TryGetValue(key, out var spec) || spec == null) { sdfBitmap.Dispose(); continue; @@ -394,7 +421,7 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) var subrect = new Rectangle(); var handle = cache.UploadGlyphBitmap(commandList, spec, sdfBitmap, ref subrect, out var bitmapIndex); - ApplyUploadedGlyph(cache, c, spec, handle, bitmapIndex, pad); + ApplyUploadedGlyph(cache, key, spec, handle, bitmapIndex); sdfBitmap.Dispose(); } From 048b5218f5ed434f6376a06af1210874a412ac94 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 22:21:15 -0800 Subject: [PATCH 16/57] Initial wiring. Need to fix generationPipeline. --- sources/Directory.Packages.props | 3 +- .../Stride.Graphics/Font/FontManager.Msdf.cs | 61 ++++++ .../Stride.Graphics/Font/FontManager.cs | 2 +- .../Font/RuntimeMSDF/GlyphOutlineGeometry.cs | 63 ++++++ .../RuntimeMSDF/MsdfGenerationPipeline.cs | 61 ++++++ .../Font/RuntimeMSDF/RemoraMsdfRasterizer.cs | 206 ++++++++++++++++++ .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 120 ++++++++++ .../Stride.Graphics/Stride.Graphics.csproj | 3 +- 8 files changed, 516 insertions(+), 3 deletions(-) create mode 100644 sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index d67bd5c409..c8b5969b81 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -15,6 +15,7 @@ + @@ -123,4 +124,4 @@ - + \ No newline at end of file diff --git a/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs b/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs new file mode 100644 index 0000000000..399440c650 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs @@ -0,0 +1,61 @@ +// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) +// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. + +using SharpFont; +using Stride.Core.Mathematics; +using Stride.Graphics.Font.RuntimeMsdf; + +namespace Stride.Graphics.Font +{ + internal partial class FontManager + { + /// + /// Extracts a glyph outline (vector shape) for MSDF generation. + /// + /// This is intentionally synchronous and protected by the same FreeType lock as bitmap generation. + /// If you later want more serialization/perf control, route this request through the existing + /// bitmap builder thread and return a copied . + /// + public bool TryGetGlyphOutline( + string fontFamily, + FontStyle fontStyle, + Vector2 size, + 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); + } + } + + /// + /// Convenience overload when you have a single scalar pixel size. + /// + public bool TryGetGlyphOutline( + string fontFamily, + FontStyle fontStyle, + float size, + char character, + out GlyphOutline outline, + out GlyphOutlineMetrics metrics, + LoadFlags loadFlags = LoadFlags.NoBitmap | LoadFlags.NoHinting) + { + return TryGetGlyphOutline(fontFamily, fontStyle, new Vector2(size, size), character, out outline, out metrics, loadFlags); + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index cac5ae1ab5..4fb1d14962 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -16,7 +16,7 @@ namespace Stride.Graphics.Font /// /// A font manager is in charge of loading in memory the ttf files, looking for font informations, rendering and then caching the s on the CPU . /// - internal class FontManager : IDisposable + internal partial class FontManager : IDisposable { /// /// Lock both and . 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..542a601553 --- /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 = new(); + + /// + /// 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 = new(); + 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/MsdfGenerationPipeline.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs new file mode 100644 index 0000000000..370b50e14d --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs @@ -0,0 +1,61 @@ +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); + } + + /// + /// Placeholder implementation. + /// + /// NOTE: This compiles without taking a hard dependency on Remora.MSDFGen. + /// When you're ready, create a second file that references the NuGet package + /// directly (e.g. RemoraMsdfRasterizer.cs) and plug it in through your generator seam. + /// +/* public sealed class NotImplementedMsdfRasterizer : IGlyphMsdfRasterizer + { + internal CharacterBitmapRgba RasterizeMsdf(GlyphOutline outline, DistanceFieldSettings df, MsdfEncodeSettings encode) + { + IGlyphMsdfRasterizer rasterizer = new RemoraMsdfRasterizer(); + } + }*/ +} 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..40c28faf33 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs @@ -0,0 +1,206 @@ +// 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 Stride.Core.Mathematics; +using Stride.Graphics.Font; + +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; + + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf(GlyphOutline outline, DistanceFieldSettings df, MsdfEncodeSettings encode) + { + ArgumentNullException.ThrowIfNull(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); + + 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(Vector2 v, bool flipY) + => new(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 r = ApplyEncode(c.R, encode.Bias, scaleFactor); + float g = ApplyEncode(c.G, encode.Bias, scaleFactor); + float b = ApplyEncode(c.B, 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); + } + } +} 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..4714f87e00 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -0,0 +1,120 @@ +using System; +using SharpFont; +using Stride.Core.Mathematics; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + 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); + + var bbox = ftOutline.GetBBox(); + outline.Bounds = new RectangleF( + Fixed26Dot6ToFloat(bbox.Left), + Fixed26Dot6ToFloat(bbox.Top), + Fixed26Dot6ToFloat(bbox.Right - bbox.Left), + Fixed26Dot6ToFloat(bbox.Bottom - bbox.Top)); + + return true; + } + + private static GlyphOutline DecomposeOutline(Outline ft) + { + var result = new GlyphOutline(); + GlyphContour currentContour = null; + Vector2 lastPoint = Vector2.Zero; + + // Using the constructor found in your decompiled definition + // Parameter names must match: moveTo, lineTo, conicTo, cubicTo, shift, delta + var funcs = new OutlineFuncs( + moveTo: (ref FTVector to, IntPtr user) => + { + currentContour = new GlyphContour { IsClosed = true }; + 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) + { + // Outline points in your version use Fixed16Dot16 + return new Vector2(Fixed16Dot16ToFloat(v.X), Fixed16Dot16ToFloat(v.Y)); + } + + // Helper for 26.6 (used for Metrics and BBox) + private static float Fixed26Dot6ToFloat(Fixed26Dot6 v) => v.Value / 64f; + private static float Fixed26Dot6ToFloat(int v) => v / 64f; + + // Helper for 16.16 (used for Outline Points to fix CS1503) + private static float Fixed16Dot16ToFloat(Fixed16Dot16 v) => v.Value / 65536f; + } +} diff --git a/sources/engine/Stride.Graphics/Stride.Graphics.csproj b/sources/engine/Stride.Graphics/Stride.Graphics.csproj index 62f2fe13d1..e2cf09a989 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,7 @@ + From d7d8d5bbec61fe9ba7f159fe74d4cc61922ae142 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 2 Feb 2026 23:00:48 -0800 Subject: [PATCH 17/57] Ok, it renders but only as blocks or dots. Debug time. --- .../Font/RuntimeMSDF/RemoraMsdfRasterizer.cs | 60 ++++++++++++-- .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 1 + .../RuntimeSignedDistanceFieldSpriteFont.cs | 83 +++++++++++++++++-- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs index 40c28faf33..9bf79b33fe 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs @@ -7,6 +7,7 @@ using Remora.MSDFGen.Graphics; using Stride.Core.Mathematics; using Stride.Graphics.Font; +using Color3 = Remora.MSDFGen.Graphics.Color3; namespace Stride.Graphics.Font.RuntimeMsdf { @@ -25,9 +26,20 @@ public sealed class RemoraMsdfRasterizer : IGlyphMsdfRasterizer // 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) { - ArgumentNullException.ThrowIfNull(outline); + if (outline == null) + throw new ArgumentNullException(nameof(outline)); var totalWidth = df.TotalWidth; var totalHeight = df.TotalHeight; @@ -45,7 +57,7 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf(GlyphOutline outline, Dis MSDF.EdgeColoringSimple(shape, DefaultAngleThresholdRadians); // 3) Generate float MSDF into a pixmap - var pix = new Pixmap(totalWidth, totalHeight); + 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). @@ -144,8 +156,8 @@ private static Shape BuildShape(GlyphOutline outline, bool flipY, out double min return shape; } - private static NVector2 ToRemora(Vector2 v, bool flipY) - => new(v.X, flipY ? -v.Y : v.Y); + 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) { @@ -155,7 +167,7 @@ private static void UpdateBounds(NVector2 p, ref double minX, ref double minY, r if (p.Y > maxY) maxY = p.Y; } - private static unsafe void PackPixmapToRgba8(Pixmap pix, CharacterBitmapRgba dst, MsdfEncodeSettings encode) + 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". @@ -172,9 +184,26 @@ private static unsafe void PackPixmapToRgba8(Pixmap= 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 index 4714f87e00..18411123d8 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -47,6 +47,7 @@ public static bool TryExtractGlyphOutline( var bbox = ftOutline.GetBBox(); outline.Bounds = new RectangleF( + Fixed26Dot6ToFloat(bbox.Left), Fixed26Dot6ToFloat(bbox.Top), Fixed26Dot6ToFloat(bbox.Right - bbox.Left), diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 54edfd16e1..7fbc304266 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -8,6 +8,7 @@ using Stride.Core.Mathematics; using Stride.Core.Serialization; using Stride.Core.Serialization.Contents; +using Stride.Graphics.Font.RuntimeMsdf; namespace Stride.Graphics.Font { @@ -55,7 +56,7 @@ private sealed record CoverageInput( // Placeholder container for future outline/MSDF generators. // This keeps the scheduling/upload pipeline unchanged when we swap in msdfgen. - private sealed record OutlineInput(object OutlineData) : GlyphInput; + private sealed record OutlineInput(GlyphOutline Outline, int Width, int Height) : GlyphInput; private interface IDistanceFieldGenerator { @@ -68,12 +69,47 @@ 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), - OutlineInput => throw new NotSupportedException("Outline input is not supported by SdfCoverageGenerator."), + _ => 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 : IDistanceFieldGenerator + { + private readonly SdfCoverageGenerator sdf = new(); + private readonly IGlyphMsdfRasterizer msdf; + private readonly MsdfEncodeSettings msdfEncode; + + public SdfOrMsdfGenerator(IGlyphMsdfRasterizer msdf, MsdfEncodeSettings msdfEncode) + { + this.msdf = msdf ?? throw new ArgumentNullException(nameof(msdf)); + this.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)), }; } - private readonly IDistanceFieldGenerator generator = new SdfCoverageGenerator(); + // Swap MSDF backend here without touching the runtime font pipeline. + private readonly IDistanceFieldGenerator generator = + new SdfOrMsdfGenerator(new RemoraMsdfRasterizer(), MsdfEncodeSettings.Default); // Runtime SDF glyph cache key (future-proof for multiple ranges/modes) private readonly Dictionary characters = []; @@ -337,10 +373,6 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) // Already uploaded? nothing to do. if (spec.IsBitmapUploaded) return; - // Already have bitmap? If not, we can’t generate. - var bmp = spec.Bitmap; - if (bmp == null || bmp.Width == 0 || bmp.Rows == 0) return; - // Ensure worker infrastructure is alive (safe even if already started) EnsureWorkersStarted(); @@ -348,6 +380,43 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) if (!inFlight.TryAdd(key, 0)) return; var p = new DistanceFieldParams(key.PixelRange, key.Pad, DefaultEncode); + + // Prefer outline-based MSDF generation when available. + // We still rely on the bitmap path to populate glyph metrics today, but MSDF uses the outline. + if (FontManager != null && + FontManager.TryGetGlyphOutline(FontName, Style, Size, key.C, out var outline, out _)) + { + // Prefer bitmap dimensions if available (matches current atlas/layout), otherwise fall back to outline bounds. + int w = 0, h = 0; + if (spec.Bitmap != null && spec.Bitmap.Width > 0 && spec.Bitmap.Rows > 0) + { + w = spec.Bitmap.Width; + h = spec.Bitmap.Rows; + } + else if (outline?.Bounds.Width > 0 && outline.Bounds.Height > 0) + { + w = Math.Max(1, (int)MathF.Ceiling(outline.Bounds.Width)); + h = Math.Max(1, (int)MathF.Ceiling(outline.Bounds.Height)); + } + + if (w > 0 && h > 0) + { + var input = (GlyphInput)new OutlineInput(outline, w, h); + + if (workChannel.Writer.TryWrite(new WorkItem(key, input, p))) + return; + } + // If outline path can't be scheduled (no dims / queue full), fall back to coverage below. + } + + // 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). var width = bmp.Width; var rows = bmp.Rows; From dbba408ec12207eb97804268aa4e787ade194167 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 3 Feb 2026 17:18:19 -0800 Subject: [PATCH 18/57] It works! Mostly. Need to squash some font specific bugs. --- .../Font/RuntimeMSDF/DummyTestRasterizer.cs | 133 +++++++++++ .../Font/RuntimeMSDF/GlyphOutlineGeometry.cs | 4 +- .../OutlineDiagnosticRasterizer.cs | 216 ++++++++++++++++++ .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 31 +-- 4 files changed, 369 insertions(+), 15 deletions(-) create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/DummyTestRasterizer.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs 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 index 542a601553..f717bee780 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/GlyphOutlineGeometry.cs @@ -12,7 +12,7 @@ namespace Stride.Graphics.Font.RuntimeMsdf /// public sealed class GlyphOutline { - public readonly List Contours = new(); + public readonly List Contours = []; /// /// Outline bounds in the same coordinate space as the points. @@ -35,7 +35,7 @@ public enum GlyphWinding public sealed class GlyphContour { - public readonly List Segments = new(); + public readonly List Segments = []; public bool IsClosed = true; } 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/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index 18411123d8..3bc72542c7 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -45,13 +45,20 @@ public static bool TryExtractGlyphOutline( outline = DecomposeOutline(ftOutline); + // FreeType bounding box is in 26.6 fixed point format var bbox = ftOutline.GetBBox(); - outline.Bounds = new RectangleF( + float left = Fixed26Dot6ToFloat(bbox.Left); + float bottom = Fixed26Dot6ToFloat(bbox.Bottom); + float right = Fixed26Dot6ToFloat(bbox.Right); + float top = Fixed26Dot6ToFloat(bbox.Top); - Fixed26Dot6ToFloat(bbox.Left), - Fixed26Dot6ToFloat(bbox.Top), - Fixed26Dot6ToFloat(bbox.Right - bbox.Left), - Fixed26Dot6ToFloat(bbox.Bottom - 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; } @@ -62,8 +69,7 @@ private static GlyphOutline DecomposeOutline(Outline ft) GlyphContour currentContour = null; Vector2 lastPoint = Vector2.Zero; - // Using the constructor found in your decompiled definition - // Parameter names must match: moveTo, lineTo, conicTo, cubicTo, shift, delta + // FreeType automatically closes contours, so we don't need to add closing segments var funcs = new OutlineFuncs( moveTo: (ref FTVector to, IntPtr user) => { @@ -107,15 +113,14 @@ private static GlyphOutline DecomposeOutline(Outline ft) private static Vector2 ConvertVector(FTVector v) { - // Outline points in your version use Fixed16Dot16 - return new Vector2(Fixed16Dot16ToFloat(v.X), Fixed16Dot16ToFloat(v.Y)); + // 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 (used for Metrics and BBox) + // 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; - - // Helper for 16.16 (used for Outline Points to fix CS1503) - private static float Fixed16Dot16ToFloat(Fixed16Dot16 v) => v.Value / 65536f; } } From a22eb17d26e8a9ec3d4e3ae9b4fb00df194f3354 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 3 Feb 2026 20:46:10 -0800 Subject: [PATCH 19/57] Next try with MsdfGen too. --- sources/Directory.Packages.props | 1 + .../Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs | 287 ++++++++++++++++++ .../Font/RuntimeMSDF/RemoraMsdfRasterizer.cs | 2 + .../RuntimeSignedDistanceFieldSpriteFont.cs | 2 +- .../Stride.Graphics/Stride.Graphics.csproj | 1 + 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index c8b5969b81..0f565e6aae 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -14,6 +14,7 @@ + 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..2d81fc762c --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs @@ -0,0 +1,287 @@ +// 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 Silk.NET.DXGI; +using Stride.Core.Mathematics; +using Stride.Graphics.Font; +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. + /// Handles self-intersecting contours and overlapping strokes. + /// + public sealed class MsdfGenCoreRasterizer : IGlyphMsdfRasterizer + { + CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( + GlyphOutline outline, + DistanceFieldSettings df, + MsdfEncodeSettings encode) + { + if (outline == null) + throw new ArgumentNullException(nameof(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); + + // CRITICAL: 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); + + // CRITICAL: Use overlap support and aggressive error correction for self-intersecting shapes + 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/RemoraMsdfRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs index 9bf79b33fe..720838a3a8 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs @@ -50,6 +50,8 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf(GlyphOutline outline, Dis // 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); diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 7fbc304266..ebf0749312 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -109,7 +109,7 @@ public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) // Swap MSDF backend here without touching the runtime font pipeline. private readonly IDistanceFieldGenerator generator = - new SdfOrMsdfGenerator(new RemoraMsdfRasterizer(), MsdfEncodeSettings.Default); + new SdfOrMsdfGenerator(new MsdfGenCoreRasterizer(), MsdfEncodeSettings.Default); // Runtime SDF glyph cache key (future-proof for multiple ranges/modes) private readonly Dictionary characters = []; diff --git a/sources/engine/Stride.Graphics/Stride.Graphics.csproj b/sources/engine/Stride.Graphics/Stride.Graphics.csproj index e2cf09a989..8ee10e37b9 100644 --- a/sources/engine/Stride.Graphics/Stride.Graphics.csproj +++ b/sources/engine/Stride.Graphics/Stride.Graphics.csproj @@ -43,6 +43,7 @@ + From e455a49943a7c4de25dce83ab14a456b34438f80 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 3 Feb 2026 23:03:48 -0800 Subject: [PATCH 20/57] Comment edits. --- .../Font/CharacterBitmapRgba.cs | 2 +- .../Stride.Graphics/Font/FontManager.Msdf.cs | 2 +- .../Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs | 14 +++++--------- .../RuntimeMSDF/MsdfGenerationPipeline.cs | 15 --------------- .../Font/RuntimeMSDF/RemoraMsdfRasterizer.cs | 2 -- .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 3 +++ .../RuntimeSignedDistanceFieldSpriteFont.cs | 19 +++++++++---------- 7 files changed, 19 insertions(+), 38 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs b/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs index 5efa68176e..675fb08ba0 100644 --- a/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs +++ b/sources/engine/Stride.Graphics/Font/CharacterBitmapRgba.cs @@ -9,7 +9,7 @@ namespace Stride.Graphics.Font { /// /// An RGBA bitmap representing a glyph (4 bytes per pixel). - /// Intended for runtime MSDF (stored in RGB, alpha optional). + /// Intended for runtime MSDF font (stored in RGB, alpha optional). /// internal sealed class CharacterBitmapRgba : IDisposable { diff --git a/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs b/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs index 399440c650..400e8e6813 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs @@ -13,7 +13,7 @@ internal partial class FontManager /// Extracts a glyph outline (vector shape) for MSDF generation. /// /// This is intentionally synchronous and protected by the same FreeType lock as bitmap generation. - /// If you later want more serialization/perf control, route this request through the existing + /// If serialization/perf control is needed later, route this request through the existing /// bitmap builder thread and return a copied . /// public bool TryGetGlyphOutline( diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs index 2d81fc762c..cfb59abf80 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenCoreRasterizer.cs @@ -3,9 +3,6 @@ using System; using Msdfgen; -using Silk.NET.DXGI; -using Stride.Core.Mathematics; -using Stride.Graphics.Font; using static Msdfgen.ErrorCorrectionConfig; using MsdfVector2 = Msdfgen.Vector2; @@ -13,7 +10,7 @@ namespace Stride.Graphics.Font.RuntimeMsdf { /// /// MSDFGen-Sharp (Msdfgen.Core) implementation for generating MSDF textures from font outlines. - /// Handles self-intersecting contours and overlapping strokes. + /// For fonts with self intersecting contours, it's best to preprocess it with FontForge first. /// public sealed class MsdfGenCoreRasterizer : IGlyphMsdfRasterizer { @@ -22,8 +19,7 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( DistanceFieldSettings df, MsdfEncodeSettings encode) { - if (outline == null) - throw new ArgumentNullException(nameof(outline)); + ArgumentNullException.ThrowIfNull(outline); int width = df.TotalWidth; int height = df.TotalHeight; @@ -37,14 +33,14 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( if (shape.Contours.Count == 0) return new CharacterBitmapRgba(width, height); - // CRITICAL: Normalize BEFORE orienting for self-intersecting shapes + // 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); + EdgeColoringInkTrap(shape, 3.0); // Calculate shape bounds var bounds = shape.GetBounds(); @@ -74,7 +70,7 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( // Create output bitmap (3 channels for RGB MSDF) var msdfBitmap = new Bitmap(width, height, 3); - // CRITICAL: Use overlap support and aggressive error correction for self-intersecting shapes + // Overlap support on by default. var config = new MSDFGeneratorConfig { }; diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs index 370b50e14d..63df35e070 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/MsdfGenerationPipeline.cs @@ -43,19 +43,4 @@ internal CharacterBitmapRgba RasterizeMsdf( DistanceFieldSettings df, MsdfEncodeSettings encode); } - - /// - /// Placeholder implementation. - /// - /// NOTE: This compiles without taking a hard dependency on Remora.MSDFGen. - /// When you're ready, create a second file that references the NuGet package - /// directly (e.g. RemoraMsdfRasterizer.cs) and plug it in through your generator seam. - /// -/* public sealed class NotImplementedMsdfRasterizer : IGlyphMsdfRasterizer - { - internal CharacterBitmapRgba RasterizeMsdf(GlyphOutline outline, DistanceFieldSettings df, MsdfEncodeSettings encode) - { - IGlyphMsdfRasterizer rasterizer = new RemoraMsdfRasterizer(); - } - }*/ } diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs index 720838a3a8..5a990f9eec 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/RemoraMsdfRasterizer.cs @@ -5,8 +5,6 @@ using NVector2 = System.Numerics.Vector2; using Remora.MSDFGen; using Remora.MSDFGen.Graphics; -using Stride.Core.Mathematics; -using Stride.Graphics.Font; using Color3 = Remora.MSDFGen.Graphics.Color3; namespace Stride.Graphics.Font.RuntimeMsdf diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index 3bc72542c7..8793cc616a 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -4,6 +4,9 @@ namespace Stride.Graphics.Font.RuntimeMsdf { + /// + /// Using SharpFont to extract an outline from a glyph. + /// public static class SharpFontOutlineExtractor { public static bool TryExtractGlyphOutline( diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index ebf0749312..143e025128 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -11,10 +11,13 @@ 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; @@ -54,8 +57,7 @@ private sealed record CoverageInput( int Rows, int Pitch) : GlyphInput; - // Placeholder container for future outline/MSDF generators. - // This keeps the scheduling/upload pipeline unchanged when we swap in msdfgen. + // 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 @@ -107,7 +109,7 @@ public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) }; } - // Swap MSDF backend here without touching the runtime font pipeline. + // Swap MSDF backend HERE without touching the runtime font pipeline. private readonly IDistanceFieldGenerator generator = new SdfOrMsdfGenerator(new MsdfGenCoreRasterizer(), MsdfEncodeSettings.Default); @@ -202,8 +204,6 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // Glyphs are baked at Size, so no compensating scaling is required. fixScaling = Vector2.One; - - var key = MakeKey(character, p); var spec = GetOrCreateCharacterData(key, sizeVec); @@ -228,7 +228,6 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve DrainUploads(commandList); // 3) Upload - if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(key, out var handle)) { // If evicted/cleared, this will flip false and we’ll reupload next draw @@ -284,8 +283,8 @@ private CharacterSpecification GetOrCreateCharacterData(GlyphKey key, Vector2 si private int ComputeTotalPad() { - // You generally want enough room to represent distance out to PixelRange - // plus your own explicit Padding. + // Generally, want enough room to represent distance out to PixelRange, + // plus explicit Padding. var pad = Padding + PixelRange; return Math.Max(1, pad); } @@ -497,7 +496,7 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) } - // --- SDF generation (CPU), packed into RGB so median(R,G,B)=SDF value --- + // --- 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) { From cc6556108f4a63e60b7e7a40c8e9d57cfe865252 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 3 Feb 2026 23:27:39 -0800 Subject: [PATCH 21/57] clean up gitignore of temp file. --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index 707548109d..9d793d1624 100644 --- a/.gitignore +++ b/.gitignore @@ -160,4 +160,3 @@ fastlane/report.xml fastlane/screenshots *.user project.lock.json -/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.channel.20260202-1.cs From 4d9159c2fef407cfa96169c59d3a556e83800ee4 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 4 Feb 2026 00:25:54 -0800 Subject: [PATCH 22/57] Move offset logic out of ApplyUploadedGlyph for safety. Change glyphkey cache into ConcurretDictionaries to future-proof multithreaded calls in the future. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 143e025128..24bfba1420 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading; using System.Threading.Channels; @@ -114,10 +115,8 @@ public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) new SdfOrMsdfGenerator(new MsdfGenCoreRasterizer(), MsdfEncodeSettings.Default); // Runtime SDF glyph cache key (future-proof for multiple ranges/modes) - private readonly Dictionary characters = []; - private readonly Dictionary cacheRecords = []; - - private readonly HashSet offsetAdjusted = []; + private readonly ConcurrentDictionary characters = []; + private readonly ConcurrentDictionary cacheRecords = []; [DataMemberIgnore] internal FontManager FontManager => FontSystem?.FontManager; @@ -212,6 +211,12 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve { 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) { @@ -234,7 +239,7 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve if (!handle.IsUploaded) { spec.IsBitmapUploaded = false; - cacheRecords.Remove(key); + cacheRecords.TryRemove(key, out _); ; } else { @@ -261,6 +266,12 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) 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); } @@ -457,8 +468,6 @@ private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, GlyphKey key, Charac cacheRecords[key] = handle; cache.NotifyGlyphUtilization(handle); - if (offsetAdjusted.Add(key)) - spec.Glyph.Offset -= new Vector2(key.Pad, key.Pad); } private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) From dd6b53a325ff555b491ed5467e5cb26950fdaf52 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 4 Feb 2026 00:33:37 -0800 Subject: [PATCH 23/57] Minor changes so VS would have less messages. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 24bfba1420..7b263a60ac 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -46,7 +46,7 @@ private DistanceFieldParams GetDfParams() return new DistanceFieldParams(pixelRange, pad, DefaultEncode); } - private GlyphKey MakeKey(char c, DistanceFieldParams p) => new(c, p.PixelRange, p.Pad); + 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; @@ -80,17 +80,11 @@ public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) /// Composite generator: CoverageInput -> SDF (existing path), OutlineInput -> MSDF (Remora). /// Keeps the scheduling/upload pipeline unchanged while we add MSDF support. /// - private sealed class SdfOrMsdfGenerator : IDistanceFieldGenerator + private sealed class SdfOrMsdfGenerator(IGlyphMsdfRasterizer msdf, MsdfEncodeSettings msdfEncode) : IDistanceFieldGenerator { private readonly SdfCoverageGenerator sdf = new(); - private readonly IGlyphMsdfRasterizer msdf; - private readonly MsdfEncodeSettings msdfEncode; - - public SdfOrMsdfGenerator(IGlyphMsdfRasterizer msdf, MsdfEncodeSettings msdfEncode) - { - this.msdf = msdf ?? throw new ArgumentNullException(nameof(msdf)); - this.msdfEncode = msdfEncode; - } + private readonly IGlyphMsdfRasterizer msdf = msdf ?? throw new ArgumentNullException(nameof(msdf)); + private readonly MsdfEncodeSettings msdfEncode = msdfEncode; public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) => input switch From 738280e79b2cc4ac6f0a1932e3f20e9f1f8ba314 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 11:34:43 -0800 Subject: [PATCH 24/57] Cleaned up nullable in FontSystem. --- sources/engine/Stride.Graphics/Font/FontSystem.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontSystem.cs b/sources/engine/Stride.Graphics/Font/FontSystem.cs index 16df7cd0ec..c9f40d20fd 100644 --- a/sources/engine/Stride.Graphics/Font/FontSystem.cs +++ b/sources/engine/Stride.Graphics/Font/FontSystem.cs @@ -64,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; @@ -82,7 +82,7 @@ public void Unload() // TODO possibly save generated characters bitmaps on the disk FontManager.Dispose(); FontCacheManager.Dispose(); - FontCacheManagerMsdf?.Dispose(); + FontCacheManagerMsdf.Dispose(); // Dispose create sprite fonts foreach (var allocatedSpriteFont in AllocatedSpriteFonts.ToArray()) From 92e546056969e681a3a36d91ad9d3f40ce56ba87 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 11:52:44 -0800 Subject: [PATCH 25/57] removed unsafe keyword and unneed cast for buffer copy. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 7b263a60ac..2f5c3259bf 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -13,8 +13,8 @@ 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. - /// + /// 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))] @@ -430,10 +430,7 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) var srcCopy = ArrayPool.Shared.Rent(len); try { - unsafe - { - System.Runtime.InteropServices.Marshal.Copy((IntPtr)bmp.Buffer, srcCopy, 0, len); - } + System.Runtime.InteropServices.Marshal.Copy(bmp.Buffer, srcCopy, 0, len); var input = (GlyphInput)new CoverageInput(srcCopy, len, width, rows, pitch); From 739c69e6fe212190165d5e226720a6f8174495a6 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 11:55:41 -0800 Subject: [PATCH 26/57] Spacing consistancy in pregenerated glyph method. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 2f5c3259bf..bf540c6cfc 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -246,7 +246,6 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) { - // Async pregen glyphs var sizeVec = new Vector2(Size, Size); var p = GetDfParams(); @@ -267,7 +266,6 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) } EnsureSdfScheduled(key, spec); - } } From 0bea15fa166470ff942bf7958dec2456ee40d706 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 11:58:58 -0800 Subject: [PATCH 27/57] Changed the font manager to private. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index bf540c6cfc..82d35a849e 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -113,10 +113,10 @@ public CharacterBitmapRgba Generate(GlyphInput input, DistanceFieldParams p) private readonly ConcurrentDictionary cacheRecords = []; [DataMemberIgnore] - internal FontManager FontManager => FontSystem?.FontManager; + private FontManager FontManager => FontSystem?.FontManager; [DataMemberIgnore] - internal FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem?.FontCacheManagerMsdf; + private FontCacheManagerMsdf FontCacheManagerMsdf => FontSystem?.FontCacheManagerMsdf; // Async wiring // 1) Dedup scheduling From 6a983051d0cae7935bc8b66b270b8c1f4250114d Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 12:25:20 -0800 Subject: [PATCH 28/57] Remove extra colon. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 82d35a849e..89d75d513b 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -233,7 +233,7 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve if (!handle.IsUploaded) { spec.IsBitmapUploaded = false; - cacheRecords.TryRemove(key, out _); ; + cacheRecords.TryRemove(key, out _); } else { From a457fd6beefe038ab1211c4b7a675d6ed130b1a5 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 23:05:33 -0800 Subject: [PATCH 29/57] Remerged MSDF method into FontManager. Remerged unused overload. General cleanup of FontManager. --- .../Stride.Graphics/Font/FontManager.Msdf.cs | 61 --------------- .../Stride.Graphics/Font/FontManager.cs | 76 +++++++++++++------ .../RuntimeSignedDistanceFieldSpriteFont.cs | 2 +- 3 files changed, 53 insertions(+), 86 deletions(-) delete mode 100644 sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs diff --git a/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs b/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs deleted file mode 100644 index 400e8e6813..0000000000 --- a/sources/engine/Stride.Graphics/Font/FontManager.Msdf.cs +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) -// Distributed under the MIT license. See the LICENSE.md file in the project root for more information. - -using SharpFont; -using Stride.Core.Mathematics; -using Stride.Graphics.Font.RuntimeMsdf; - -namespace Stride.Graphics.Font -{ - internal partial class FontManager - { - /// - /// 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, - 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); - } - } - - /// - /// Convenience overload when you have a single scalar pixel size. - /// - public bool TryGetGlyphOutline( - string fontFamily, - FontStyle fontStyle, - float size, - char character, - out GlyphOutline outline, - out GlyphOutlineMetrics metrics, - LoadFlags loadFlags = LoadFlags.NoBitmap | LoadFlags.NoHinting) - { - return TryGetGlyphOutline(fontFamily, fontStyle, new Vector2(size, size), character, out outline, out metrics, loadFlags); - } - } -} diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index 4fb1d14962..7e625a5390 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -10,38 +10,39 @@ using Stride.Core.IO; using Stride.Core.Mathematics; using Stride.Core.Serialization.Contents; +using Stride.Graphics.Font.RuntimeMsdf; namespace Stride.Graphics.Font { /// /// A font manager is in charge of loading in memory the ttf files, looking for font informations, rendering and then caching the s on the CPU . /// - internal partial class FontManager : IDisposable + 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/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 89d75d513b..70d4ad8a34 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -386,7 +386,7 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) // Prefer outline-based MSDF generation when available. // We still rely on the bitmap path to populate glyph metrics today, but MSDF uses the outline. if (FontManager != null && - FontManager.TryGetGlyphOutline(FontName, Style, Size, key.C, out var outline, out _)) + FontManager.TryGetGlyphOutline(FontName, Style, new Vector2(Size, Size), key.C, out var outline, out _)) { // Prefer bitmap dimensions if available (matches current atlas/layout), otherwise fall back to outline bounds. int w = 0, h = 0; From 34630722b769c18d0e5482aed6b14355a0741920 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Mon, 9 Feb 2026 23:24:29 -0800 Subject: [PATCH 30/57] Changed warning from scaffolding to indicate experimental feature. Simplified some expression for readability with pattern matching. --- .../SpriteFont/SpriteFontAssetCompiler.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs b/sources/engine/Stride.Assets/SpriteFont/SpriteFontAssetCompiler.cs index 6524e9f45a..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); @@ -76,8 +75,7 @@ protected override void Prepare(AssetCompilerContext context, AssetItem assetIte } else { - var fontTypeStatic = asset.FontType as OfflineRasterizedSpriteFontType; - if (fontTypeStatic == null) + 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 @@ -104,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); @@ -226,10 +222,7 @@ public RuntimeSignedDistanceFieldFontCommand(string url, SpriteFontAsset descrip protected override Task DoCommandOverride(ICommandContext commandContext) { - // M1 NOTE: - // This is a scaffolding step. We serialize a functional font so the pipeline works end-to-end. - // In M3/M4, this will be replaced by a real Runtime MSDF font object. - commandContext.Logger.Warning("Runtime SDF font is currently scaffolded in M1 (temporary). It will behave like a runtime raster font until the MSDF runtime generator is implemented."); + commandContext.Logger.Warning("Runtime SDF font is currently an experimental feature."); var runtimeSdfType = (RuntimeSignedDistanceFieldSpriteFontType)Parameters.FontType; From 113d16d203ee85a47bfc4fe5f9de291d35724d35 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 10 Feb 2026 00:51:57 -0800 Subject: [PATCH 31/57] Moved comment to more accurately depict pipeline for upload step. --- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 70d4ad8a34..6e41336f58 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -223,10 +223,11 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // 2) Schedule async SDF generation (only once per char) EnsureSdfScheduled(key, spec); + + // 3) Upload if (commandList != null) DrainUploads(commandList); - - // 3) Upload + if (spec.IsBitmapUploaded && cacheRecords.TryGetValue(key, out var handle)) { // If evicted/cleared, this will flip false and we’ll reupload next draw From f2930a28806c3988cd80bf9cc806e144378e6fb1 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 10 Feb 2026 01:17:47 -0800 Subject: [PATCH 32/57] Small refactor for EnsureSdfScheduled to address 0 dimension glyphs and bug around fallback logic on full channel queue. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 45 ++++++++----------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 6e41336f58..1fc768cd0d 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -384,32 +384,30 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) var p = new DistanceFieldParams(key.PixelRange, key.Pad, DefaultEncode); - // Prefer outline-based MSDF generation when available. - // We still rely on the bitmap path to populate glyph metrics today, but MSDF uses the outline. + + // 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 _)) { - // Prefer bitmap dimensions if available (matches current atlas/layout), otherwise fall back to outline bounds. - int w = 0, h = 0; - if (spec.Bitmap != null && spec.Bitmap.Width > 0 && spec.Bitmap.Rows > 0) - { - w = spec.Bitmap.Width; - h = spec.Bitmap.Rows; - } - else if (outline?.Bounds.Width > 0 && outline.Bounds.Height > 0) + // 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) immediately + if (w <= 0 || h <= 0) { - w = Math.Max(1, (int)MathF.Ceiling(outline.Bounds.Width)); - h = Math.Max(1, (int)MathF.Ceiling(outline.Bounds.Height)); + inFlight.TryRemove(key, out _); + return; } - if (w > 0 && h > 0) - { - var input = (GlyphInput)new OutlineInput(outline, w, h); + // 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; - if (workChannel.Writer.TryWrite(new WorkItem(key, input, p))) - return; - } - // If outline path can't be scheduled (no dims / queue full), fall back to coverage below. + inFlight.TryRemove(key, out _); + return; } // Fallback: bitmap/coverage-based SDF. @@ -421,17 +419,12 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) } // Copy coverage bitmap to a pooled array so background thread is safe (avoid per-glyph allocations). - var width = bmp.Width; - var rows = bmp.Rows; - var pitch = bmp.Pitch; - - int len = pitch * rows; + 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 = (GlyphInput)new CoverageInput(srcCopy, len, width, rows, pitch); + 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))) From 3176a76a927686fd363e2c0240b87313d2937dc9 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 10 Feb 2026 01:35:25 -0800 Subject: [PATCH 33/57] Removed debug message from FontCacheManagerMSDF and cleanup for readability. --- .../Font/FontCacheManagerMsdf.cs | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs index c8a501ed9f..6fb9947797 100644 --- a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -16,9 +16,9 @@ internal class FontCacheManagerMsdf : ComponentBase { private readonly FontSystem system; - private readonly List cacheTextures = new List(); - private readonly LinkedList cachedGlyphs = new LinkedList(); - private readonly GuillotinePacker packer = new GuillotinePacker(); + private readonly List cacheTextures = []; + private readonly LinkedList cachedGlyphs = new(); + private readonly GuillotinePacker packer = new(); public int AtlasPaddingPixels = 2; @@ -49,14 +49,12 @@ public void ClearCache() foreach (var glyph in cachedGlyphs) { glyph.IsUploaded = false; - if (glyph.Owner != null) - glyph.Owner.IsBitmapUploaded = false; + glyph.Owner?.IsBitmapUploaded = false; } cachedGlyphs.Clear(); packer.Clear(cacheTextures[0].ViewWidth, cacheTextures[0].ViewHeight); - System.Diagnostics.Debug.WriteLine("MSDF ClearCache()"); } /// @@ -69,10 +67,8 @@ public MsdfCachedGlyph UploadGlyphBitmap( ref Rectangle subrect, out int bitmapIndex) { - if (bitmap == null) - throw new ArgumentNullException(nameof(bitmap)); - if (commandList == null) - throw new ArgumentNullException(nameof(commandList)); + ArgumentNullException.ThrowIfNull(bitmap); + ArgumentNullException.ThrowIfNull(commandList); bitmapIndex = 0; @@ -140,8 +136,7 @@ private void RemoveLessUsedGlyphs(int frameCount = 1) while (currentNode != null && currentNode.Value.LastUsedFrame < limitFrame) { currentNode.Value.IsUploaded = false; - if (currentNode.Value.Owner != null) - currentNode.Value.Owner.IsBitmapUploaded = false; + currentNode.Value.Owner?.IsBitmapUploaded = false; packer.Free(ref currentNode.Value.OuterSubrect); var prev = currentNode.Previous; From dcfae4b56fcc0eef55621b80762c7a046166f786 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 10 Feb 2026 01:45:21 -0800 Subject: [PATCH 34/57] Move oversized glyph dimension check from FontCacheManager to Font file for efficiency. --- .../Stride.Graphics/Font/FontCacheManagerMsdf.cs | 16 ---------------- .../Font/RuntimeSignedDistanceFieldSpriteFont.cs | 7 +++++-- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs index 6fb9947797..b4abac8215 100644 --- a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -74,22 +74,6 @@ public MsdfCachedGlyph UploadGlyphBitmap( var atlasPad = AtlasPaddingPixels; - // Safety check: Is the glyph bigger than the entire atlas? - if (bitmap.Width + atlasPad * 2 > cacheTextures[0].ViewWidth || - bitmap.Rows + atlasPad * 2 > cacheTextures[0].ViewHeight) - { - throw new InvalidOperationException("Glyph is too large for the MSDF atlas settings."); - } - - 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; diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 1fc768cd0d..3d3524bac8 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -394,8 +394,11 @@ private void EnsureSdfScheduled(GlyphKey key, CharacterSpecification spec) 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) immediately - if (w <= 0 || h <= 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; From f8aadd1c566a405cda8e94fcaf264767cea42bec Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Tue, 10 Feb 2026 02:02:06 -0800 Subject: [PATCH 35/57] Re-added accidentally deleted packer logic. --- .../engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs index b4abac8215..2a3665395b 100644 --- a/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs +++ b/sources/engine/Stride.Graphics/Font/FontCacheManagerMsdf.cs @@ -74,6 +74,15 @@ public MsdfCachedGlyph UploadGlyphBitmap( 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; From 0753019fe8f0baf0acdd2d619d299f4d4e2fbf12 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Wed, 11 Feb 2026 02:02:29 -0800 Subject: [PATCH 36/57] remove unused bool isClosed definition from outline extractor. --- .../Font/RuntimeMSDF/SharpFontOutlineExtractor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index 8793cc616a..38aacd9148 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -76,7 +76,7 @@ private static GlyphOutline DecomposeOutline(Outline ft) var funcs = new OutlineFuncs( moveTo: (ref FTVector to, IntPtr user) => { - currentContour = new GlyphContour { IsClosed = true }; + currentContour = new GlyphContour { }; result.Contours.Add(currentContour); lastPoint = ConvertVector(to); return 0; From 887ebba606944d04176095e0199b76bb127cdc63 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 05:19:21 -0700 Subject: [PATCH 37/57] Minor fix to align change with current master repo. --- sources/Directory.Packages.props | 4 +++- sources/engine/Stride.Graphics/Font/FontManager.cs | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index fef8f1d7d9..bdbaa0c14b 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -76,6 +76,8 @@ + + @@ -124,4 +126,4 @@ - \ No newline at end of file + diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index 81fa4ff5cd..5d3a4e29b5 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -313,8 +313,9 @@ private void LoadFontInMemory(string fontPath) 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); + } } /// From 51485804e22b4211fadcd105ac8cda00ad8fa1ea Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 13:02:21 -0700 Subject: [PATCH 38/57] Added unit test for RT-SDF font path. Made YamlSerializer stricter. --- .../Yaml/YamlSerializerBase.cs | 10 +- ...timeSignedDistanceFieldDiagnosticsTests.cs | 116 ++++++++++++++++++ .../Stride.Assets.Tests.csproj | 1 + 3 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 sources/engine/Stride.Assets.Tests/SpriteFont/RuntimeSignedDistanceFieldDiagnosticsTests.cs diff --git a/sources/core/Stride.Core.Design/Yaml/YamlSerializerBase.cs b/sources/core/Stride.Core.Design/Yaml/YamlSerializerBase.cs index 0c1893abd5..c0c929fbdf 100644 --- a/sources/core/Stride.Core.Design/Yaml/YamlSerializerBase.cs +++ b/sources/core/Stride.Core.Design/Yaml/YamlSerializerBase.cs @@ -41,8 +41,9 @@ public virtual void ResetCache() private void AssemblyRegistered(object? sender, AssemblyRegisteredEventArgs e) { - // Process only our own assemblies - if (!e.Categories.Contains(AssemblyCommonCategories.Engine)) + // Process engine and asset assemblies, since asset YAML tag resolution depends on both. + if (!e.Categories.Contains(AssemblyCommonCategories.Engine) && + !e.Categories.Contains(AssemblyCommonCategories.Assets)) return; lock (Lock) @@ -56,8 +57,9 @@ private void AssemblyRegistered(object? sender, AssemblyRegisteredEventArgs e) private void AssemblyUnregistered(object? sender, AssemblyRegisteredEventArgs e) { - // Process only our own assemblies - if (!e.Categories.Contains(AssemblyCommonCategories.Engine)) + // Process engine and asset assemblies, since asset YAML tag resolution depends on both. + if (!e.Categories.Contains(AssemblyCommonCategories.Engine) && + !e.Categories.Contains(AssemblyCommonCategories.Assets)) return; lock (Lock) diff --git a/sources/engine/Stride.Assets.Tests/SpriteFont/RuntimeSignedDistanceFieldDiagnosticsTests.cs b/sources/engine/Stride.Assets.Tests/SpriteFont/RuntimeSignedDistanceFieldDiagnosticsTests.cs new file mode 100644 index 0000000000..2bda65f0b2 --- /dev/null +++ b/sources/engine/Stride.Assets.Tests/SpriteFont/RuntimeSignedDistanceFieldDiagnosticsTests.cs @@ -0,0 +1,116 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Stride.Assets.SpriteFont; +using Stride.Core.Assets; +using Stride.Core.Reflection; +using Stride.Core.Yaml; +using Xunit; +using Xunit.Abstractions; + +namespace Stride.Assets.Tests.SpriteFont +{ + public class RuntimeSignedDistanceFieldSpriteFontDiagnosticsTests + { + private readonly ITestOutputHelper output; + + public RuntimeSignedDistanceFieldSpriteFontDiagnosticsTests(ITestOutputHelper output) + { + this.output = output; + } + + private static string GetLogPath(string name) + { + var dir = Path.Combine(AppContext.BaseDirectory, "DiagnosticLogs"); + Directory.CreateDirectory(dir); + return Path.Combine(dir, $"{name}.txt"); + } + + private void WriteLog(string name, string content) + { + var path = GetLogPath(name); + File.WriteAllText(path, content); + output.WriteLine($"Diagnostic log written: {path}"); + } + + [Fact] + public void RuntimeSdfType_Should_Exist_And_Have_Expected_DataContract_Name() + { + var type = typeof(RuntimeSignedDistanceFieldSpriteFontType); + + Assert.Equal("RuntimeSignedDistanceFieldSpriteFontType", type.Name); + } + + [Fact] + public void AssemblyRegistry_Should_Contain_RuntimeSdfType_Assembly() + { + var allAssemblies = AssemblyRegistry.FindAll().ToList(); + var runtimeAssembly = typeof(RuntimeSignedDistanceFieldSpriteFontType).Assembly; + + Assert.Contains(runtimeAssembly, allAssemblies); + } + + [Fact] + public void AssetYamlSerializer_Should_Deserialize_RuntimeSdf_SpriteFontAsset() + { + var yaml = """ + !SpriteFont + Id: cad320b3-3f55-43a4-bb8b-46097724ba24 + SerializedVersion: {Stride: 2.0.0.0} + Tags: [] + FontSource: !FileFontProvider + Source: !file ../../../../../Downloads/NotoSansCJK-Regular.ttc + FontType: !RuntimeSignedDistanceFieldSpriteFontType + Size: 64.0 + PixelRange: 10 + Spacing: 2.0 + """; + + var serializer = new AssetYamlSerializer(); + try + { + using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(yaml)); + var result = serializer.Deserialize(stream, typeof(SpriteFontAsset)); + + var asset = Assert.IsType(result); + var fontType = Assert.IsType(asset.FontType); + + Assert.Equal(64.0f, fontType.Size); + Assert.Equal(10, fontType.PixelRange); + Assert.Equal(2.0f, asset.Spacing); + } + catch (Exception) + { + throw; + } + } + + [Fact] + public void AssetYamlSerializer_Settings_Should_Know_RuntimeSdfType_Assembly() + { + var serializer = new AssetYamlSerializer(); + var settings = serializer.GetSerializerSettings(); + var runtimeAssembly = typeof(RuntimeSignedDistanceFieldSpriteFontType).Assembly; + + Assert.NotNull(settings); + } + + [Fact] + public void SpriteFontAssetCompiler_Dispatch_Precondition_Should_See_RuntimeSdf_FontType() + { + var asset = new SpriteFontAsset + { + FontType = new RuntimeSignedDistanceFieldSpriteFontType + { + Size = 64, + PixelRange = 10, + Padding = 2 + }, + Spacing = 2.0f + }; + + Assert.True(asset.FontType is RuntimeSignedDistanceFieldSpriteFontType); + } + } +} diff --git a/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj b/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj index 6b92927dd5..c23e2e4805 100644 --- a/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj +++ b/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj @@ -16,6 +16,7 @@ GuidGenerator.cs + From 6de2b12edcb7b0547baaa244fe4975e3955e75c8 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 13:34:09 -0700 Subject: [PATCH 39/57] Step 1: Minimal initial fix for thumbnail generation of RT-SDF font by forcing warm-up before async. --- .../Thumbnails/ThumbnailFromTextureCommand.cs | 15 ++++ .../RuntimeSignedDistanceFieldSpriteFont.cs | 80 +++++++++++++++++++ .../Properties/AssemblyInfo.cs | 1 + 3 files changed, 96 insertions(+) diff --git a/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs b/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs index 0854f25965..7f7546f4bd 100644 --- a/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs +++ b/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs @@ -5,6 +5,7 @@ using Stride.Core.Mathematics; using Stride.Engine; using Stride.Graphics; +using Stride.Graphics.Font; using Stride.Rendering; using Stride.Rendering.Compositing; @@ -95,6 +96,20 @@ protected override void RenderSprites(RenderDrawContext context) // force pre-generation of the glyph Font.PreGenerateGlyphs(TitleText, new Vector2(desiredFontSize, desiredFontSize)); } + else if (Font is RuntimeSignedDistanceFieldSpriteFont runtimeSdfFont) + { + scale = 1f; + + // Get the exact size of the font rendered with the desired size + typeNameSize = Font.MeasureString(TitleText, desiredFontSize); + + // Force bounded warmup so async glyph generation/upload has a chance + // to complete before the thumbnail snapshot is taken. + runtimeSdfFont.PrepareGlyphsForThumbnail( + TitleText, + new Vector2(desiredFontSize, desiredFontSize), + context.CommandList); + } // the title text SpriteBatch.DrawString(Font, TitleText, desiredFontSize, thumbnailSize/2, FontColor, 0, typeNameSize/2, scale*Vector2.One, SpriteEffects.None, 1, TextAlignment.Center); diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 3d3524bac8..d4c594fc30 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; @@ -456,6 +457,85 @@ private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, GlyphKey key, Charac } + internal void PrepareGlyphsForThumbnail(string text, Vector2 requestedSize, CommandList commandList, int maxWaitMilliseconds = 50) + { + if (string.IsNullOrEmpty(text) || commandList == null) + return; + + var sizeVec = new Vector2(Size, Size); + var p = GetDfParams(); + + var requestedKeys = new HashSet(); + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + var key = MakeKey(c, p); + requestedKeys.Add(key); + + var spec = GetOrCreateCharacterData(key, sizeVec); + + if (spec.Bitmap == null) + { + FontManager.GenerateBitmap(spec, true); + + if (spec.Bitmap != null && spec.Glyph.XAdvance != 0) + { + spec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); + } + + if (spec.Bitmap == null || spec.Bitmap.Width == 0 || spec.Bitmap.Rows == 0 || spec.Glyph.XAdvance == 0) + { + if (c != DefaultCharacter && DefaultCharacter.HasValue) + requestedKeys.Add(MakeKey(DefaultCharacter.Value, p)); + + continue; + } + } + + EnsureSdfScheduled(key, spec); + } + + if (requestedKeys.Count == 0) + return; + + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.ElapsedMilliseconds < maxWaitMilliseconds) + { + DrainUploads(commandList, maxUploadsPerFrame: requestedKeys.Count); + + if (AreAllGlyphsUploaded(requestedKeys)) + break; + + Thread.Sleep(1); + } + + // One last drain pass in case workers completed right at the timeout boundary. + DrainUploads(commandList, maxUploadsPerFrame: requestedKeys.Count); + } + + private bool AreAllGlyphsUploaded(HashSet requestedKeys) + { + foreach (var key in requestedKeys) + { + if (!characters.TryGetValue(key, out var spec) || spec == null) + return false; + + if (!spec.IsBitmapUploaded) + return false; + + if (cacheRecords.TryGetValue(key, out var handle) && !handle.IsUploaded) + { + spec.IsBitmapUploaded = false; + cacheRecords.TryRemove(key, out _); + return false; + } + } + + return true; + } + private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) { var cache = FontCacheManagerMsdf; diff --git a/sources/engine/Stride.Graphics/Properties/AssemblyInfo.cs b/sources/engine/Stride.Graphics/Properties/AssemblyInfo.cs index 17bbd03061..17562968dc 100644 --- a/sources/engine/Stride.Graphics/Properties/AssemblyInfo.cs +++ b/sources/engine/Stride.Graphics/Properties/AssemblyInfo.cs @@ -9,6 +9,7 @@ [assembly: InternalsVisibleTo("Stride.Graphics.Serializers" + Stride.PublicKeys.Default)] [assembly: InternalsVisibleTo("Stride.Graphics.ShaderCompiler" + Stride.PublicKeys.Default)] [assembly: InternalsVisibleTo("Stride.Engine" + Stride.PublicKeys.Default)] +[assembly: InternalsVisibleTo("Stride.Editor" + Stride.PublicKeys.Default)] [assembly: InternalsVisibleTo("Stride.Rendering" + Stride.PublicKeys.Default)] [assembly: InternalsVisibleTo("Stride.Games" + Stride.PublicKeys.Default)] [assembly: InternalsVisibleTo("Stride.UI" + Stride.PublicKeys.Default)] From 350f348f47d2c5646608278b3c8474a4631600d9 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 14:19:50 -0700 Subject: [PATCH 40/57] Step 2: Diagnosed and fixed layout state mutation bug. --- ...ceFieldSpriteFontLayoutDiagnosticsTests.cs | 275 ++++++++++++++++++ .../RuntimeSignedDistanceFieldSpriteFont.cs | 10 +- 2 files changed, 281 insertions(+), 4 deletions(-) create mode 100644 sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs new file mode 100644 index 0000000000..43e85f1c25 --- /dev/null +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -0,0 +1,275 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading.Tasks; +using Stride.Core.Mathematics; +using Stride.Games; +using Stride.Graphics.Font; +using Xunit; +using Xunit.Abstractions; + +namespace Stride.Graphics.Tests +{ + public class RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests + { + private readonly ITestOutputHelper output; + + public RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests(ITestOutputHelper output) + { + this.output = output; + } + + [Fact] + public void RuntimeSdf_PreGenerateGlyphs_IsIdempotent_ForGlyphOffset() + { + using var game = new RuntimeSdfDiagnosticsGame(output, runIdempotenceTest: true, runLayoutStabilityTest: false); + game.Run(); + } + + [Fact] + public void RuntimeSdf_ThumbnailLikeWarmup_DoesNotChangeMeasureOrPlacement() + { + using var game = new RuntimeSdfDiagnosticsGame(output, runIdempotenceTest: false, runLayoutStabilityTest: true); + game.Run(); + } + + private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase + { + private readonly ITestOutputHelper output; + private readonly bool runIdempotenceTest; + private readonly bool runLayoutStabilityTest; + + private RuntimeSignedDistanceFieldSpriteFont runtimeFont = null!; + private bool completed; + + public RuntimeSdfDiagnosticsGame( + ITestOutputHelper output, + bool runIdempotenceTest, + bool runLayoutStabilityTest) + { + this.output = output; + this.runIdempotenceTest = runIdempotenceTest; + this.runLayoutStabilityTest = runLayoutStabilityTest; + } + + protected override async Task LoadContent() + { + await base.LoadContent(); + + var fontSystem = Services.GetService(); + Assert.NotNull(fontSystem); + + runtimeFont = (RuntimeSignedDistanceFieldSpriteFont)fontSystem.NewRuntimeSignedDistanceField( + defaultSize: 64, + fontName: "Arial", + style: FontStyle.Regular, + pixelRange: 8, + padding: 2, + useKerning: false, + extraSpacing: 0, + extraLineSpacing: 0, + defaultCharacter: ' '); + + Assert.NotNull(runtimeFont); + } + + protected override void Update(GameTime gameTime) + { + base.Update(gameTime); + + if (completed) + return; + + if (runIdempotenceTest) + RunIdempotenceProbe(); + + if (runLayoutStabilityTest) + RunLayoutStabilityProbe(); + + completed = true; + Exit(); + } + + private void RunIdempotenceProbe() + { + const string text = "A"; + var requestedSize = new Vector2(64, 64); + + var baseline = WarmupAndCaptureGlyphState(text, requestedSize); + output.WriteLine($"Baseline: {baseline}"); + + for (int i = 0; i < 5; i++) + { + var current = WarmupAndCaptureGlyphState(text, requestedSize); + output.WriteLine($"Iteration {i + 1}: {current}"); + + Assert.Equal(baseline.Offset.X, current.Offset.X); + Assert.Equal(baseline.Offset.Y, current.Offset.Y); + Assert.Equal(baseline.XAdvance, current.XAdvance); + } + } + + private void RunLayoutStabilityProbe() + { + const string text = "Preview text"; + var requestedSize = new Vector2(48, 48); + + var baselineMeasureDefault = runtimeFont.MeasureString(text); + var baselineMeasureRequested = runtimeFont.MeasureString(text, requestedSize.X); + var baselineGlyphStates = WarmupAndCaptureGlyphStates(text, requestedSize); + + output.WriteLine($"Baseline default measure: {baselineMeasureDefault}"); + output.WriteLine($"Baseline requested measure: {baselineMeasureRequested}"); + DumpGlyphStates("Baseline glyph states", baselineGlyphStates); + + for (int i = 0; i < 5; i++) + { + var currentMeasureDefault = runtimeFont.MeasureString(text); + var currentMeasureRequested = runtimeFont.MeasureString(text, requestedSize.X); + var currentGlyphStates = WarmupAndCaptureGlyphStates(text, requestedSize); + + output.WriteLine($"Iteration {i + 1} default measure: {currentMeasureDefault}"); + output.WriteLine($"Iteration {i + 1} requested measure: {currentMeasureRequested}"); + DumpGlyphStates($"Iteration {i + 1} glyph states", currentGlyphStates); + + Assert.Equal(baselineMeasureDefault.X, currentMeasureDefault.X); + Assert.Equal(baselineMeasureDefault.Y, currentMeasureDefault.Y); + + Assert.Equal(baselineMeasureRequested.X, currentMeasureRequested.X); + Assert.Equal(baselineMeasureRequested.Y, currentMeasureRequested.Y); + + Assert.Equal(baselineGlyphStates.Count, currentGlyphStates.Count); + + for (int g = 0; g < baselineGlyphStates.Count; g++) + { + var expected = baselineGlyphStates[g]; + var actual = currentGlyphStates[g]; + + Assert.Equal(expected.Character, actual.Character); + Assert.Equal(expected.Offset.X, actual.Offset.X); + Assert.Equal(expected.Offset.Y, actual.Offset.Y); + Assert.Equal(expected.XAdvance, actual.XAdvance); + } + } + } + + private GlyphState WarmupAndCaptureGlyphState(string text, Vector2 requestedSize) + { + runtimeFont.PreGenerateGlyphs(text, requestedSize); + + var states = WarmupAndCaptureGlyphStates(text, requestedSize); + Assert.Single(states); + return states[0]; + } + + private List WarmupAndCaptureGlyphStates(string text, Vector2 requestedSize) + { + runtimeFont.PreGenerateGlyphs(text, requestedSize); + + var result = new List(); + var characters = GetCharactersDictionary(runtimeFont); + var makeKey = GetMakeKeyMethod(); + var getDfParams = GetDfParamsMethod(); + + var dfParams = getDfParams.Invoke(runtimeFont, Array.Empty()); + Assert.NotNull(dfParams); + + foreach (var ch in text) + { + if (char.IsWhiteSpace(ch)) + continue; + + var key = makeKey.Invoke(null, new[] { (object)ch, dfParams! }); + Assert.NotNull(key); + + if (!characters.TryGetValue(key!, out var specObj)) + { + Assert.True(false, $"Character '{ch}' was not present in runtime SDF character cache after warmup."); + continue; + } + + Assert.NotNull(specObj); + + dynamic spec = specObj!; + var glyph = spec.Glyph; + + result.Add(new GlyphState( + ch, + new Vector2((float)glyph.Offset.X, (float)glyph.Offset.Y), + (float)glyph.XAdvance)); + } + + return result; + } + + private static Dictionary GetCharactersDictionary(RuntimeSignedDistanceFieldSpriteFont font) + { + var field = typeof(RuntimeSignedDistanceFieldSpriteFont).GetField( + "characters", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(field); + + var raw = field!.GetValue(font); + Assert.NotNull(raw); + + var result = new Dictionary(); + + var enumerable = raw as System.Collections.IEnumerable; + Assert.NotNull(enumerable); + + foreach (var item in enumerable!) + { + var itemType = item.GetType(); + var keyProp = itemType.GetProperty("Key"); + var valueProp = itemType.GetProperty("Value"); + + Assert.NotNull(keyProp); + Assert.NotNull(valueProp); + + var key = keyProp!.GetValue(item); + var value = valueProp!.GetValue(item); + + Assert.NotNull(key); + Assert.NotNull(value); + + result.Add(key!, value!); + } + + return result; + } + + private static MethodInfo GetMakeKeyMethod() + { + var method = typeof(RuntimeSignedDistanceFieldSpriteFont).GetMethod( + "MakeKey", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + return method!; + } + + private static MethodInfo GetDfParamsMethod() + { + var method = typeof(RuntimeSignedDistanceFieldSpriteFont).GetMethod( + "GetDfParams", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + return method!; + } + + private void DumpGlyphStates(string title, List states) + { + output.WriteLine(title); + foreach (var state in states) + { + output.WriteLine( + $" Char='{state.Character}' Offset=({state.Offset.X}, {state.Offset.Y}) XAdvance={state.XAdvance}"); + } + } + + private readonly record struct GlyphState(char Character, Vector2 Offset, float XAdvance); + } + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index d4c594fc30..55ff8c5554 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -259,12 +259,14 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) 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); + // Apply padding offset once, when glyph metrics are first materialized. + if (spec.Bitmap != null && spec.Glyph.XAdvance != 0) + { + spec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); + } } EnsureSdfScheduled(key, spec); From 9f062ef626fba13dc3f7b36957a9520324e4fd73 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 14:54:26 -0700 Subject: [PATCH 41/57] Step 3 of Diagnostic test. --- ...ceFieldSpriteFontLayoutDiagnosticsTests.cs | 333 +++++++++++++++++- 1 file changed, 332 insertions(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs index 43e85f1c25..2f4c613971 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Reflection; using System.Threading.Tasks; using Stride.Core.Mathematics; @@ -7,6 +8,7 @@ using Stride.Graphics.Font; using Xunit; using Xunit.Abstractions; +using static Stride.Graphics.SpriteFont; namespace Stride.Graphics.Tests { @@ -33,25 +35,336 @@ public void RuntimeSdf_ThumbnailLikeWarmup_DoesNotChangeMeasureOrPlacement() game.Run(); } + [Fact] + public void RuntimeSdf_MultilineMetrics_Should_Track_RuntimeRasterized_Reasonably() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceTest: false, + runLayoutStabilityTest: false, + runMultilineComparisonTest: true, + runThumbnailBoundsConsistencyTest: false); + + game.Run(); + } + + [Fact] + public void RuntimeSdf_ThumbnailMath_Should_Match_GlyphPlacementBounds() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceTest: false, + runLayoutStabilityTest: false, + runMultilineComparisonTest: false, + runThumbnailBoundsConsistencyTest: true); + + game.Run(); + } + private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase { private readonly ITestOutputHelper output; private readonly bool runIdempotenceTest; private readonly bool runLayoutStabilityTest; + private readonly bool runMultilineComparisonTest; + private readonly bool runThumbnailBoundsConsistencyTest; + + private RuntimeRasterizedSpriteFont runtimeRasterFont = null!; + private RuntimeSignedDistanceFieldSpriteFont runtimeFont = null!; private bool completed; public RuntimeSdfDiagnosticsGame( ITestOutputHelper output, bool runIdempotenceTest, - bool runLayoutStabilityTest) + bool runLayoutStabilityTest, + bool runMultilineComparisonTest, + bool runThumbnailBoundsConsistencyTest) + { + this.output = output; + this.runIdempotenceTest = runIdempotenceTest; + this.runLayoutStabilityTest = runLayoutStabilityTest; + this.runMultilineComparisonTest = runMultilineComparisonTest; + this.runThumbnailBoundsConsistencyTest = runThumbnailBoundsConsistencyTest; + } + + public RuntimeSdfDiagnosticsGame(ITestOutputHelper output, bool runIdempotenceTest, bool runLayoutStabilityTest) { this.output = output; this.runIdempotenceTest = runIdempotenceTest; this.runLayoutStabilityTest = runLayoutStabilityTest; } + private void RunMultilineComparisonProbe() + { + const string text = "Arial\n64 Regular"; + const float requestedFontSize = 46f; + + var sdfDefaultMeasure = runtimeFont.MeasureString(text); + var sdfRequestedMeasure = runtimeFont.MeasureString(text, requestedFontSize); + + var rasterDefaultMeasure = runtimeRasterFont.MeasureString(text); + var rasterRequestedMeasure = runtimeRasterFont.MeasureString(text, requestedFontSize); + + var sdfDefaultLineSpacing = runtimeFont.GetFontDefaultLineSpacing(requestedFontSize); + var sdfTotalLineSpacing = runtimeFont.GetTotalLineSpacing(requestedFontSize); + var sdfExtraLineSpacing = runtimeFont.GetExtraLineSpacing(requestedFontSize); + var sdfBaseOffsetY = GetBaseOffsetY(runtimeFont, requestedFontSize); + + var rasterDefaultLineSpacing = runtimeRasterFont.GetFontDefaultLineSpacing(requestedFontSize); + var rasterTotalLineSpacing = runtimeRasterFont.GetTotalLineSpacing(requestedFontSize); + var rasterExtraLineSpacing = runtimeRasterFont.GetExtraLineSpacing(requestedFontSize); + var rasterBaseOffsetY = GetBaseOffsetY(runtimeRasterFont, requestedFontSize); + + var sdfBounds = ComputeGlyphPlacementBounds(runtimeFont, text, requestedFontSize); + var rasterBounds = ComputeGlyphPlacementBounds(runtimeRasterFont, text, requestedFontSize); + + output.WriteLine("=== Runtime SDF multiline metrics ==="); + output.WriteLine($"Default measure: {sdfDefaultMeasure}"); + output.WriteLine($"Requested measure: {sdfRequestedMeasure}"); + output.WriteLine($"Default line spacing: {sdfDefaultLineSpacing}"); + output.WriteLine($"Total line spacing: {sdfTotalLineSpacing}"); + output.WriteLine($"Extra line spacing: {sdfExtraLineSpacing}"); + output.WriteLine($"Base offset Y: {sdfBaseOffsetY}"); + output.WriteLine($"Placement bounds: {sdfBounds}"); + + output.WriteLine("=== Runtime Raster multiline metrics ==="); + output.WriteLine($"Default measure: {rasterDefaultMeasure}"); + output.WriteLine($"Requested measure: {rasterRequestedMeasure}"); + output.WriteLine($"Default line spacing: {rasterDefaultLineSpacing}"); + output.WriteLine($"Total line spacing: {rasterTotalLineSpacing}"); + output.WriteLine($"Extra line spacing: {rasterExtraLineSpacing}"); + output.WriteLine($"Base offset Y: {rasterBaseOffsetY}"); + output.WriteLine($"Placement bounds: {rasterBounds}"); + + // Loose sanity assertions first: we are diagnosing, not enforcing pixel identity. + Assert.True(Math.Abs(sdfRequestedMeasure.X - rasterRequestedMeasure.X) < 120f, + $"Requested multiline measure X diverged too much. SDF={sdfRequestedMeasure.X}, Raster={rasterRequestedMeasure.X}"); + + Assert.True(Math.Abs(sdfRequestedMeasure.Y - rasterRequestedMeasure.Y) < 120f, + $"Requested multiline measure Y diverged too much. SDF={sdfRequestedMeasure.Y}, Raster={rasterRequestedMeasure.Y}"); + + Assert.True(Math.Abs(sdfTotalLineSpacing - rasterTotalLineSpacing) < 80f, + $"Total line spacing diverged too much. SDF={sdfTotalLineSpacing}, Raster={rasterTotalLineSpacing}"); + + Assert.True(Math.Abs(sdfBaseOffsetY - rasterBaseOffsetY) < 80f, + $"Base offset diverged too much. SDF={sdfBaseOffsetY}, Raster={rasterBaseOffsetY}"); + } + + private void RunThumbnailBoundsConsistencyProbe() + { + const string text = "Arial\n64 Regular"; + var thumbnailSize = new Vector2(256, 256); + + var sdfResult = ComputeThumbnailConsistency(runtimeFont, text, thumbnailSize, 0.95f); + var rasterResult = ComputeThumbnailConsistency(runtimeRasterFont, text, thumbnailSize, 0.95f); + + output.WriteLine("=== Runtime SDF thumbnail math ==="); + DumpThumbnailConsistency(sdfResult); + + output.WriteLine("=== Runtime Raster thumbnail math ==="); + DumpThumbnailConsistency(rasterResult); + + // First, raster should be reasonably self-consistent. + Assert.True(smallEnough(rasterResult.MeasureVsPlacementDelta.X, 80f), + $"Raster measure/placement X mismatch too large: {rasterResult.MeasureVsPlacementDelta.X}"); + Assert.True(smallEnough(rasterResult.MeasureVsPlacementDelta.Y, 80f), + $"Raster measure/placement Y mismatch too large: {rasterResult.MeasureVsPlacementDelta.Y}"); + + // Then compare whether SDF is materially worse than raster. + Assert.True( + Math.Abs(sdfResult.MeasureVsPlacementDelta.X) - Math.Abs(rasterResult.MeasureVsPlacementDelta.X) < 80f, + $"SDF X mismatch is materially worse than raster. SDF={sdfResult.MeasureVsPlacementDelta.X}, Raster={rasterResult.MeasureVsPlacementDelta.X}"); + + Assert.True( + Math.Abs(sdfResult.MeasureVsPlacementDelta.Y) - Math.Abs(rasterResult.MeasureVsPlacementDelta.Y) < 80f, + $"SDF Y mismatch is materially worse than raster. SDF={sdfResult.MeasureVsPlacementDelta.Y}, Raster={rasterResult.MeasureVsPlacementDelta.Y}"); + } + + private ThumbnailConsistencyResult ComputeThumbnailConsistency(SpriteFont font, string text, Vector2 thumbnailSize, float fontSizeScale) + { + var typeNameSize = font.MeasureString(text); + var scale = fontSizeScale * Math.Min(thumbnailSize.X / typeNameSize.X, thumbnailSize.Y / typeNameSize.Y); + var desiredFontSize = scale * font.Size; + + if (font.FontType == SpriteFontType.Dynamic || font is RuntimeSignedDistanceFieldSpriteFont) + { + scale = 1f; + typeNameSize = font.MeasureString(text, desiredFontSize); + + if (font is RuntimeSignedDistanceFieldSpriteFont sdf) + { + // Use the current thumbnail warmup path you added. + sdf.PrepareGlyphsForThumbnail(text, new Vector2(desiredFontSize, desiredFontSize), GraphicsContext.CommandList); + } + else + { + font.PreGenerateGlyphs(text, new Vector2(desiredFontSize, desiredFontSize)); + } + } + + var placementBounds = ComputeGlyphPlacementBounds(font, text, desiredFontSize); + var measuredBounds = new RectangleF(0, 0, typeNameSize.X, typeNameSize.Y); + + return new ThumbnailConsistencyResult( + desiredFontSize, + typeNameSize, + measuredBounds, + placementBounds, + new Vector2( + placementBounds.Width - measuredBounds.Width, + placementBounds.Height - measuredBounds.Height)); + } + + private RectangleF ComputeGlyphPlacementBounds(SpriteFont font, string text, float fontSize) + { + var glyphs = EnumerateGlyphPlacements(font, text, fontSize); + + bool any = false; + float minX = 0, minY = 0, maxX = 0, maxY = 0; + + foreach (var glyph in glyphs) + { + if (glyph.Width <= 0 || glyph.Height <= 0) + continue; + + if (!any) + { + minX = glyph.X; + minY = glyph.Y; + maxX = glyph.X + glyph.Width; + maxY = glyph.Y + glyph.Height; + any = true; + } + else + { + minX = Math.Min(minX, glyph.X); + minY = Math.Min(minY, glyph.Y); + maxX = Math.Max(maxX, glyph.X + glyph.Width); + maxY = Math.Max(maxY, glyph.Y + glyph.Height); + } + } + + return any ? new RectangleF(minX, minY, maxX - minX, maxY - minY) : new RectangleF(); + } + + private List EnumerateGlyphPlacements(SpriteFont font, string text, float fontSize) + { + var results = new List(); + + var glyphEnumeratorType = typeof(SpriteFont).GetNestedType("GlyphEnumerator", BindingFlags.NonPublic | BindingFlags.Public); + Assert.NotNull(glyphEnumeratorType); + + var ctor = glyphEnumeratorType!.GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + types: new[] + { + typeof(CommandList), + typeof(StringProxy), + typeof(Vector2), + typeof(bool), + typeof(int), + typeof(int), + typeof(SpriteFont), + typeof((TextAlignment, Vector2)?) + }, + modifiers: null); + + Assert.NotNull(ctor); + + var stringProxyCtor = typeof(StringProxy).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, + binder: null, + types: new[] { typeof(string) }, + modifiers: null); + + Assert.NotNull(stringProxyCtor); + + var stringProxy = stringProxyCtor!.Invoke(new object[] { text }); + + object enumerator = ctor!.Invoke(new object[] + { + GraphicsContext.CommandList, + stringProxy, + new Vector2(fontSize, fontSize), + true, + 0, + text.Length, + font, + null + }); + + var moveNext = glyphEnumeratorType.GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var currentProp = glyphEnumeratorType.GetProperty("Current", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.NotNull(moveNext); + Assert.NotNull(currentProp); + + var baseOffsetY = GetBaseOffsetY(font, fontSize); + + while ((bool)moveNext!.Invoke(enumerator, null)!) + { + var current = currentProp!.GetValue(enumerator); + Assert.NotNull(current); + + var currentType = current!.GetType(); + + var xProp = currentType.GetProperty("X", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var yProp = currentType.GetProperty("Y", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + var glyphProp = currentType.GetProperty("Glyph", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + + Assert.NotNull(xProp); + Assert.NotNull(yProp); + Assert.NotNull(glyphProp); + + var x = Convert.ToSingle(xProp!.GetValue(current)); + var y = Convert.ToSingle(yProp!.GetValue(current)); + var glyph = glyphProp!.GetValue(current); + Assert.NotNull(glyph); + + dynamic g = glyph!; + float placedX = x + (float)g.Offset.X; + float placedY = y + baseOffsetY + (float)g.Offset.Y; + float width = (float)g.Subrect.Width; + float height = (float)g.Subrect.Height; + + results.Add(new PlacedGlyph(placedX, placedY, width, height)); + } + + return results; + } + + private static float GetBaseOffsetY(SpriteFont font, float size) + { + var method = typeof(SpriteFont).GetMethod("GetBaseOffsetY", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + return Convert.ToSingle(method!.Invoke(font, new object[] { size })); + } + + private void DumpThumbnailConsistency(ThumbnailConsistencyResult result) + { + output.WriteLine($"Desired font size: {result.DesiredFontSize}"); + output.WriteLine($"Measured size: {result.MeasuredSize}"); + output.WriteLine($"Measured bounds: {result.MeasuredBounds}"); + output.WriteLine($"Placement bounds: {result.PlacementBounds}"); + output.WriteLine($"Placement - measured delta: {result.MeasureVsPlacementDelta}"); + } + + private static bool smallEnough(float value, float tolerance) + { + return Math.Abs(value) < tolerance; + } + + private readonly record struct PlacedGlyph(float X, float Y, float Width, float Height); + + private readonly record struct ThumbnailConsistencyResult( + float DesiredFontSize, + Vector2 MeasuredSize, + RectangleF MeasuredBounds, + RectangleF PlacementBounds, + Vector2 MeasureVsPlacementDelta); protected override async Task LoadContent() { await base.LoadContent(); @@ -71,6 +384,18 @@ protected override async Task LoadContent() defaultCharacter: ' '); Assert.NotNull(runtimeFont); + + runtimeRasterFont = (RuntimeRasterizedSpriteFont)fontSystem.NewDynamic( + defaultSize: 64, + fontName: "Arial", + style: FontStyle.Regular, + antiAliasMode: FontAntiAliasMode.Grayscale, + useKerning: false, + extraSpacing: 0, + extraLineSpacing: 0, + defaultCharacter: ' '); + + Assert.NotNull(runtimeRasterFont); } protected override void Update(GameTime gameTime) @@ -86,6 +411,12 @@ protected override void Update(GameTime gameTime) if (runLayoutStabilityTest) RunLayoutStabilityProbe(); + if (runMultilineComparisonTest) + RunMultilineComparisonProbe(); + + if (runThumbnailBoundsConsistencyTest) + RunThumbnailBoundsConsistencyProbe(); + completed = true; Exit(); } From 20d3d2e4e8a93376def33747b49ad35cb76b752e Mon Sep 17 00:00:00 2001 From: Yuechen Li <109704248+yuechen-li-dev@users.noreply.github.com> Date: Fri, 10 Apr 2026 15:02:59 -0700 Subject: [PATCH 42/57] Add bounded runtime SDF glyph upload convergence diagnostic --- ...ceFieldSpriteFontLayoutDiagnosticsTests.cs | 86 ++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs index 2f4c613971..bc9fdb7e49 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -61,6 +61,20 @@ public void RuntimeSdf_ThumbnailMath_Should_Match_GlyphPlacementBounds() game.Run(); } + [Fact] + public void RuntimeSdf_BoundedWarmup_Should_TransitionRequestedGlyphs_ToUploaded() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceTest: false, + runLayoutStabilityTest: false, + runMultilineComparisonTest: false, + runThumbnailBoundsConsistencyTest: false, + runBoundedWarmupUploadConvergenceTest: true); + + game.Run(); + } + private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase { private readonly ITestOutputHelper output; @@ -69,6 +83,7 @@ private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase private readonly bool runMultilineComparisonTest; private readonly bool runThumbnailBoundsConsistencyTest; + private readonly bool runBoundedWarmupUploadConvergenceTest; private RuntimeRasterizedSpriteFont runtimeRasterFont = null!; @@ -80,13 +95,15 @@ public RuntimeSdfDiagnosticsGame( bool runIdempotenceTest, bool runLayoutStabilityTest, bool runMultilineComparisonTest, - bool runThumbnailBoundsConsistencyTest) + bool runThumbnailBoundsConsistencyTest, + bool runBoundedWarmupUploadConvergenceTest = false) { this.output = output; this.runIdempotenceTest = runIdempotenceTest; this.runLayoutStabilityTest = runLayoutStabilityTest; this.runMultilineComparisonTest = runMultilineComparisonTest; this.runThumbnailBoundsConsistencyTest = runThumbnailBoundsConsistencyTest; + this.runBoundedWarmupUploadConvergenceTest = runBoundedWarmupUploadConvergenceTest; } public RuntimeSdfDiagnosticsGame(ITestOutputHelper output, bool runIdempotenceTest, bool runLayoutStabilityTest) @@ -94,6 +111,70 @@ public RuntimeSdfDiagnosticsGame(ITestOutputHelper output, bool runIdempotenceTe this.output = output; this.runIdempotenceTest = runIdempotenceTest; this.runLayoutStabilityTest = runLayoutStabilityTest; + runMultilineComparisonTest = false; + runThumbnailBoundsConsistencyTest = false; + runBoundedWarmupUploadConvergenceTest = false; + } + + private void RunBoundedWarmupUploadConvergenceProbe() + { + const string text = "Arial\n64 Regular"; + var requestedSize = new Vector2(64, 64); + const int maxIterations = 12; + + var makeKey = GetMakeKeyMethod(); + var getDfParams = GetDfParamsMethod(); + var dfParams = getDfParams.Invoke(runtimeFont, Array.Empty()); + Assert.NotNull(dfParams); + + var requestedGlyphs = text + .Where(ch => !char.IsWhiteSpace(ch)) + .Distinct() + .Select(ch => (Character: ch, Key: makeKey.Invoke(null, new[] { (object)ch, dfParams! }))) + .ToList(); + + foreach (var glyph in requestedGlyphs) + Assert.NotNull(glyph.Key); + + runtimeFont.PrepareGlyphsForThumbnail(text, requestedSize, GraphicsContext.CommandList); + + for (int iteration = 1; iteration <= maxIterations; iteration++) + { + runtimeFont.PrepareGlyphsForThumbnail(text, requestedSize, GraphicsContext.CommandList); + var measured = runtimeFont.MeasureString(text, requestedSize.X); + var placement = ComputeGlyphPlacementBounds(runtimeFont, text, requestedSize.X); + + var characters = GetCharactersDictionary(runtimeFont); + int uploadedCount = 0; + var pending = new List(); + + foreach (var (character, key) in requestedGlyphs) + { + Assert.NotNull(key); + + if (!characters.TryGetValue(key!, out var specObj)) + { + pending.Add(character); + continue; + } + + dynamic spec = specObj!; + bool isUploaded = spec.IsBitmapUploaded; + + if (isUploaded) + uploadedCount++; + else + pending.Add(character); + } + + output.WriteLine( + $"Iteration {iteration}/{maxIterations}: uploaded={uploadedCount}/{requestedGlyphs.Count}, measure={measured}, placement={placement}, pending=\"{new string(pending.ToArray())}\""); + + if (uploadedCount == requestedGlyphs.Count) + return; + } + + Assert.True(false, $"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); } private void RunMultilineComparisonProbe() @@ -417,6 +498,9 @@ protected override void Update(GameTime gameTime) if (runThumbnailBoundsConsistencyTest) RunThumbnailBoundsConsistencyProbe(); + if (runBoundedWarmupUploadConvergenceTest) + RunBoundedWarmupUploadConvergenceProbe(); + completed = true; Exit(); } From 3218db55fba8535468a53d88d6b3dbf760a2b63c Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 16:21:20 -0700 Subject: [PATCH 43/57] Fix runtime SDF font thumbnail generation in the editor. Runtime SDF now uses rasterized preview in asset thumbnails for editor stability/readability. --- .../Thumbnails/FontThumbnailCompiler.cs | 37 +++++++++++++++++-- .../Thumbnails/ThumbnailFromTextureCommand.cs | 30 +++++++-------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/sources/editor/Stride.Assets.Presentation/Thumbnails/FontThumbnailCompiler.cs b/sources/editor/Stride.Assets.Presentation/Thumbnails/FontThumbnailCompiler.cs index b6eda3ee61..dc88804683 100644 --- a/sources/editor/Stride.Assets.Presentation/Thumbnails/FontThumbnailCompiler.cs +++ b/sources/editor/Stride.Assets.Presentation/Thumbnails/FontThumbnailCompiler.cs @@ -1,14 +1,15 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System.IO; +using Stride.Assets.SpriteFont; using Stride.Core.Assets; using Stride.Core.Assets.Compiler; using Stride.Core.BuildEngine; using Stride.Core.IO; -using Stride.Assets.SpriteFont; using Stride.Editor.Resources; using Stride.Editor.Thumbnails; using Stride.Graphics; +using Stride.Graphics.Font; namespace Stride.Assets.Presentation.Thumbnails { @@ -55,12 +56,42 @@ public FontThumbnailBuildCommand(ThumbnailCompilerContext context, string url, A protected override void SetThumbnailParameters() { - Font = LoadedAsset; FontSize = 0.75f; TitleText = BuildTitleText(); - if (Font != null && Font.FontType == SpriteFontType.SDF) + bool usingRasterizedPreviewForRuntimeSdf = false; + + if (LoadedAsset is RuntimeSignedDistanceFieldSpriteFont runtimeSdfFont) + { + var fontSystem = runtimeSdfFont.FontSystem; + if (fontSystem != null) + { + Font = fontSystem.NewDynamic( + defaultSize: runtimeSdfFont.Size, + fontName: runtimeSdfFont.FontName, + style: runtimeSdfFont.Style, + antiAliasMode: FontAntiAliasMode.Grayscale, + useKerning: runtimeSdfFont.UseKerning, + extraSpacing: 0, + extraLineSpacing: 0, + defaultCharacter: runtimeSdfFont.DefaultCharacter ?? ' '); + + usingRasterizedPreviewForRuntimeSdf = true; + } + else + { + Font = LoadedAsset; + } + } + else + { + Font = LoadedAsset; + } + + if (!usingRasterizedPreviewForRuntimeSdf && Font != null && Font.FontType == SpriteFontType.SDF) EffectInstance = UIBatch.SDFSpriteFontEffect; + else + EffectInstance = null; } protected virtual string BuildTitleText() diff --git a/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs b/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs index 7f7546f4bd..3c4bb4d98d 100644 --- a/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs +++ b/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. using System; +using System.IO; using Stride.Core.Assets; using Stride.Core.Mathematics; using Stride.Engine; @@ -93,26 +94,23 @@ protected override void RenderSprites(RenderDrawContext context) // Get the exact size of the font rendered with the desired size typeNameSize = Font.MeasureString(TitleText, desiredFontSize); - // force pre-generation of the glyph + // Force pre-generation of the glyphs Font.PreGenerateGlyphs(TitleText, new Vector2(desiredFontSize, desiredFontSize)); } - else if (Font is RuntimeSignedDistanceFieldSpriteFont runtimeSdfFont) - { - scale = 1f; - - // Get the exact size of the font rendered with the desired size - typeNameSize = Font.MeasureString(TitleText, desiredFontSize); - - // Force bounded warmup so async glyph generation/upload has a chance - // to complete before the thumbnail snapshot is taken. - runtimeSdfFont.PrepareGlyphsForThumbnail( - TitleText, - new Vector2(desiredFontSize, desiredFontSize), - context.CommandList); - } // the title text - SpriteBatch.DrawString(Font, TitleText, desiredFontSize, thumbnailSize/2, FontColor, 0, typeNameSize/2, scale*Vector2.One, SpriteEffects.None, 1, TextAlignment.Center); + SpriteBatch.DrawString( + Font, + TitleText, + desiredFontSize, + thumbnailSize / 2, + FontColor, + 0, + typeNameSize / 2, + scale * Vector2.One, + SpriteEffects.None, + 1, + TextAlignment.Center); } } } From e14b84b3d869df4b617857aad178550d989e8fdd Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 17:05:03 -0700 Subject: [PATCH 44/57] Register Runtime SDF to be accessible in Game Studio. --- .../Stride.Assets.Presentation.sdpkg | 1 + .../Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 sources/editor/Stride.Assets.Presentation/Templates/Assets/Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl diff --git a/sources/editor/Stride.Assets.Presentation/Stride.Assets.Presentation.sdpkg b/sources/editor/Stride.Assets.Presentation/Stride.Assets.Presentation.sdpkg index c3a9ebc931..112fe4ec96 100644 --- a/sources/editor/Stride.Assets.Presentation/Stride.Assets.Presentation.sdpkg +++ b/sources/editor/Stride.Assets.Presentation/Stride.Assets.Presentation.sdpkg @@ -19,6 +19,7 @@ TemplateFolders: - !file Templates/Assets/BepuPhysics/HullAsset.sdtpl - !file Templates/Assets/Fonts/OfflineRasterizedSpriteFont.sdtpl - !file Templates/Assets/Fonts/RuntimeRasterizedSpriteFont.sdtpl + - !file Templates/Assets/Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl - !file Templates/Assets/Fonts/SignedDistanceFieldSpriteFont.sdtpl - !file Templates/Assets/Materials/DefaultMaterial.sdtpl - !file Templates/Assets/Materials/DiffuseMaterial.sdtpl diff --git a/sources/editor/Stride.Assets.Presentation/Templates/Assets/Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl b/sources/editor/Stride.Assets.Presentation/Templates/Assets/Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl new file mode 100644 index 0000000000..d8cb8f52d4 --- /dev/null +++ b/sources/editor/Stride.Assets.Presentation/Templates/Assets/Fonts/RuntimeSignedDistanceFieldSpriteFont.sdtpl @@ -0,0 +1,11 @@ +!TemplateAssetFactory +Id: 8D9A0F2D-2C9A-4F59-B8C1-0F4A6E6B9D11 +AssetTypeName: SpriteFontAsset +Name: "Runtime SDF" +Scope: Asset +Description: A runtime signed-distance-field sprite font asset in which glyphs are generated at runtime from the embedded font file +Group: Font +Order: -70 +Icon: ..\.sdtpl\SpriteFont.png +DefaultOutputName: SpriteFont +FactoryTypeName: RuntimeSignedDistanceFieldSpriteFontFactory From 5694d01d77066bc7cb54b28f81a708e4d361b53f Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 17:07:34 -0700 Subject: [PATCH 45/57] FontManager indentation. --- sources/engine/Stride.Graphics/Font/FontManager.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index 5d3a4e29b5..9fb9637a28 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -357,9 +357,9 @@ private void BuildBitmapThread() RenderBitmap(character, fontFace); } -DequeueRequest: + DequeueRequest: -// update the generated cached data + // update the generated cached data lock (dataStructuresLock) bitmapsToGenerate.Dequeue(); } From 7b986e1c824bab8b66309626c1e97b81991172ee Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 17:28:52 -0700 Subject: [PATCH 46/57] Split generator functionality/bitmap based SDF fallback into partial classes from the RT-SDF font file. --- ...RuntimeSignedDistanceFieldBitmapBuilder.cs | 162 ++++++++++++ .../RuntimeSignedDistanceFieldGenerator.cs | 86 +++++++ .../RuntimeSignedDistanceFieldSpriteFont.cs | 240 +----------------- 3 files changed, 252 insertions(+), 236 deletions(-) create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldBitmapBuilder.cs create mode 100644 sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldGenerator.cs diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldBitmapBuilder.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldBitmapBuilder.cs new file mode 100644 index 0000000000..95b40969a1 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldBitmapBuilder.cs @@ -0,0 +1,162 @@ +using System; +using System.Buffers; + +namespace Stride.Graphics.Font +{ + internal sealed partial class RuntimeSignedDistanceFieldSpriteFont + { + /// + /// Bitmap-based SDF generation fallback, 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; + } + /// + /// Computes squared distances to the nearest feature pixels using Felzenszwalb/Huttenlocher EDT. + /// + 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 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/RuntimeSignedDistanceFieldGenerator.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldGenerator.cs new file mode 100644 index 0000000000..907e4a34a2 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldGenerator.cs @@ -0,0 +1,86 @@ +using System; +using Stride.Core.Mathematics; +using Stride.Graphics.Font.RuntimeMsdf; + +namespace Stride.Graphics.Font +{ + internal sealed partial class RuntimeSignedDistanceFieldSpriteFont + { + // Distance field configuration + 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); + + private static readonly DistanceEncodeParams DefaultEncode = new(Bias: 0.4f, Scale: 0.5f); + + private DistanceFieldParams GetDistanceFieldParams() + { + 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: coverage today, outline/MSDF when available + private abstract record GlyphInput; + + private sealed record CoverageInput( + byte[] Buffer, + int Length, + int Width, + int Rows, + int Pitch) : GlyphInput; + + 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, OutlineInput -> MSDF. + /// Keeps the scheduling/upload pipeline unchanged while allowing generator swaps. + /// + 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 rest of the runtime font pipeline. + private readonly IDistanceFieldGenerator generator = + new SdfOrMsdfGenerator(new MsdfGenCoreRasterizer(), MsdfEncodeSettings.Default); + } +} diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 55ff8c5554..9bbaf36960 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -19,8 +19,7 @@ namespace Stride.Graphics.Font [ReferenceSerializer, DataSerializerGlobal(typeof(ReferenceSerializer), Profile = "Content")] [ContentSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontContentSerializer))] [DataSerializer(typeof(RuntimeSignedDistanceFieldSpriteFontSerializer))] - - internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont + internal sealed partial class RuntimeSignedDistanceFieldSpriteFont : SpriteFont { internal string FontName; internal FontStyle Style; @@ -30,85 +29,6 @@ internal sealed class RuntimeSignedDistanceFieldSpriteFont : SpriteFont 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 = []; @@ -191,7 +111,7 @@ protected override Glyph GetGlyph(CommandList commandList, char character, in Ve // All glyphs are generated at Size var sizeVec = new Vector2(Size, Size); - var p = GetDfParams(); + var p = GetDistanceFieldParams(); // IMPORTANT: // SDF fonts are scaled by Stride using requestedFontSize vs SpriteFont.Size. @@ -250,7 +170,7 @@ internal override void PreGenerateGlyphs(ref StringProxy text, ref Vector2 size) { // Async pregen glyphs var sizeVec = new Vector2(Size, Size); - var p = GetDfParams(); + var p = GetDistanceFieldParams(); for (int i = 0; i < text.Length; i++) { @@ -465,7 +385,7 @@ internal void PrepareGlyphsForThumbnail(string text, Vector2 requestedSize, Comm return; var sizeVec = new Vector2(Size, Size); - var p = GetDfParams(); + var p = GetDistanceFieldParams(); var requestedKeys = new HashSet(); @@ -571,157 +491,5 @@ private void DrainUploads(CommandList commandList, int maxUploadsPerFrame = 8) 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]; - } - } } } From beb3a62f0f6ced69dc6872a6acb7a4c652fcb4f4 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 17:34:32 -0700 Subject: [PATCH 47/57] Flipped font in outline diagnostic rasterizer to correct orientation. --- .../Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs index 2a151b2fbd..01cf4272ed 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/OutlineDiagnosticRasterizer.cs @@ -118,8 +118,8 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( 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); + var p0 = TransformPoint(seg.P0, minX, maxY, scale, df.Padding); + var p1 = TransformPoint(seg.P1, minX, maxY, scale, df.Padding); // Draw line segment DrawLine(buffer, pitch, totalWidth, totalHeight, p0, p1, 255, 255, 255); @@ -131,12 +131,12 @@ CharacterBitmapRgba IGlyphMsdfRasterizer.RasterizeMsdf( return bmp; } - private static Vector2 TransformPoint(Vector2 p, float minX, float minY, float scale, int padding) + private static Vector2 TransformPoint(Vector2 p, float minX, float maxY, float scale, int padding) { - // Transform: (p - min) * scale + padding + // Flip Y when mapping outline space (Y-up) to bitmap space (Y-down). return new Vector2( (p.X - minX) * scale + padding, - (p.Y - minY) * scale + padding + (maxY - p.Y) * scale + padding ); } From f3d4c00587eee6d8ad0a0e141385228db5d1f9e8 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Fri, 10 Apr 2026 18:33:29 -0700 Subject: [PATCH 48/57] Added nullablility on FontSystem.cs. --- sources/engine/Stride.Graphics/Font/FontSystem.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontSystem.cs b/sources/engine/Stride.Graphics/Font/FontSystem.cs index c9f40d20fd..c0828d421d 100644 --- a/sources/engine/Stride.Graphics/Font/FontSystem.cs +++ b/sources/engine/Stride.Graphics/Font/FontSystem.cs @@ -1,3 +1,4 @@ +#nullable enable // Copyright (c) .NET Foundation and Contributors (https://dotnetfoundation.org/ & https://stride3d.net) and Silicon Studio Corp. (https://www.siliconstudio.co.jp) // Distributed under the MIT license. See the LICENSE.md file in the project root for more information. @@ -15,11 +16,11 @@ namespace Stride.Graphics.Font /// public class FontSystem : IFontFactory { - internal int FrameCount { get; private set; } - 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 int FrameCount { get; private set; } + internal FontManager FontManager { get; private set; } = null!; + internal GraphicsDevice GraphicsDevice { get; private set; } = null!; + internal FontCacheManager FontCacheManager { get; private set; } = null!; + internal FontCacheManagerMsdf FontCacheManagerMsdf { get; private set; } = null!; internal readonly HashSet AllocatedSpriteFonts = new HashSet(); @@ -31,7 +32,7 @@ public class FontSystem : IFontFactory /// via . /// Once registered, fonts can be loaded using the method. /// - public RuntimeFontProvider RuntimeFonts { get; private set; } + public RuntimeFontProvider RuntimeFonts { get; private set; } = null!; /// /// Create a new instance of base on the provided . @@ -64,7 +65,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; From dd6dcf45d3ee41a83948fd1dc795e7b7434cb04a Mon Sep 17 00:00:00 2001 From: Yuechen Li <109704248+yuechen-li-dev@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:48:43 -0700 Subject: [PATCH 49/57] Finalize runtime SDF layout diagnostics as regression tests --- ...ceFieldSpriteFontLayoutDiagnosticsTests.cs | 558 +++--------------- 1 file changed, 98 insertions(+), 460 deletions(-) diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs index bc9fdb7e49..2ab3b489ad 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -8,7 +8,6 @@ using Stride.Graphics.Font; using Xunit; using Xunit.Abstractions; -using static Stride.Graphics.SpriteFont; namespace Stride.Graphics.Tests { @@ -22,430 +21,60 @@ public RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests(ITestOutputHel } [Fact] - public void RuntimeSdf_PreGenerateGlyphs_IsIdempotent_ForGlyphOffset() - { - using var game = new RuntimeSdfDiagnosticsGame(output, runIdempotenceTest: true, runLayoutStabilityTest: false); - game.Run(); - } - - [Fact] - public void RuntimeSdf_ThumbnailLikeWarmup_DoesNotChangeMeasureOrPlacement() - { - using var game = new RuntimeSdfDiagnosticsGame(output, runIdempotenceTest: false, runLayoutStabilityTest: true); - game.Run(); - } - - [Fact] - public void RuntimeSdf_MultilineMetrics_Should_Track_RuntimeRasterized_Reasonably() + public void RuntimeSdf_PreGenerateGlyphs_IsIdempotent_ForGlyphOffsetAndAdvance() { using var game = new RuntimeSdfDiagnosticsGame( output, - runIdempotenceTest: false, - runLayoutStabilityTest: false, - runMultilineComparisonTest: true, - runThumbnailBoundsConsistencyTest: false); - + runIdempotenceRegressionTest: true, + runThumbnailWarmupLayoutRegressionTest: false, + runBoundedWarmupUploadConvergenceRegressionTest: false); game.Run(); } [Fact] - public void RuntimeSdf_ThumbnailMath_Should_Match_GlyphPlacementBounds() + public void RuntimeSdf_ThumbnailLikeWarmup_IsLayoutStable_AcrossRepeatedCalls() { using var game = new RuntimeSdfDiagnosticsGame( output, - runIdempotenceTest: false, - runLayoutStabilityTest: false, - runMultilineComparisonTest: false, - runThumbnailBoundsConsistencyTest: true); - + runIdempotenceRegressionTest: false, + runThumbnailWarmupLayoutRegressionTest: true, + runBoundedWarmupUploadConvergenceRegressionTest: false); game.Run(); } [Fact] - public void RuntimeSdf_BoundedWarmup_Should_TransitionRequestedGlyphs_ToUploaded() + public void RuntimeSdf_BoundedWarmup_TransitionsRequestedGlyphs_ToUploaded() { using var game = new RuntimeSdfDiagnosticsGame( output, - runIdempotenceTest: false, - runLayoutStabilityTest: false, - runMultilineComparisonTest: false, - runThumbnailBoundsConsistencyTest: false, - runBoundedWarmupUploadConvergenceTest: true); - + runIdempotenceRegressionTest: false, + runThumbnailWarmupLayoutRegressionTest: false, + runBoundedWarmupUploadConvergenceRegressionTest: true); game.Run(); } private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase { private readonly ITestOutputHelper output; - private readonly bool runIdempotenceTest; - private readonly bool runLayoutStabilityTest; - - private readonly bool runMultilineComparisonTest; - private readonly bool runThumbnailBoundsConsistencyTest; - private readonly bool runBoundedWarmupUploadConvergenceTest; - - private RuntimeRasterizedSpriteFont runtimeRasterFont = null!; + private readonly bool runIdempotenceRegressionTest; + private readonly bool runThumbnailWarmupLayoutRegressionTest; + private readonly bool runBoundedWarmupUploadConvergenceRegressionTest; private RuntimeSignedDistanceFieldSpriteFont runtimeFont = null!; private bool completed; public RuntimeSdfDiagnosticsGame( ITestOutputHelper output, - bool runIdempotenceTest, - bool runLayoutStabilityTest, - bool runMultilineComparisonTest, - bool runThumbnailBoundsConsistencyTest, - bool runBoundedWarmupUploadConvergenceTest = false) + bool runIdempotenceRegressionTest, + bool runThumbnailWarmupLayoutRegressionTest, + bool runBoundedWarmupUploadConvergenceRegressionTest) { this.output = output; - this.runIdempotenceTest = runIdempotenceTest; - this.runLayoutStabilityTest = runLayoutStabilityTest; - this.runMultilineComparisonTest = runMultilineComparisonTest; - this.runThumbnailBoundsConsistencyTest = runThumbnailBoundsConsistencyTest; - this.runBoundedWarmupUploadConvergenceTest = runBoundedWarmupUploadConvergenceTest; - } - - public RuntimeSdfDiagnosticsGame(ITestOutputHelper output, bool runIdempotenceTest, bool runLayoutStabilityTest) - { - this.output = output; - this.runIdempotenceTest = runIdempotenceTest; - this.runLayoutStabilityTest = runLayoutStabilityTest; - runMultilineComparisonTest = false; - runThumbnailBoundsConsistencyTest = false; - runBoundedWarmupUploadConvergenceTest = false; - } - - private void RunBoundedWarmupUploadConvergenceProbe() - { - const string text = "Arial\n64 Regular"; - var requestedSize = new Vector2(64, 64); - const int maxIterations = 12; - - var makeKey = GetMakeKeyMethod(); - var getDfParams = GetDfParamsMethod(); - var dfParams = getDfParams.Invoke(runtimeFont, Array.Empty()); - Assert.NotNull(dfParams); - - var requestedGlyphs = text - .Where(ch => !char.IsWhiteSpace(ch)) - .Distinct() - .Select(ch => (Character: ch, Key: makeKey.Invoke(null, new[] { (object)ch, dfParams! }))) - .ToList(); - - foreach (var glyph in requestedGlyphs) - Assert.NotNull(glyph.Key); - - runtimeFont.PrepareGlyphsForThumbnail(text, requestedSize, GraphicsContext.CommandList); - - for (int iteration = 1; iteration <= maxIterations; iteration++) - { - runtimeFont.PrepareGlyphsForThumbnail(text, requestedSize, GraphicsContext.CommandList); - var measured = runtimeFont.MeasureString(text, requestedSize.X); - var placement = ComputeGlyphPlacementBounds(runtimeFont, text, requestedSize.X); - - var characters = GetCharactersDictionary(runtimeFont); - int uploadedCount = 0; - var pending = new List(); - - foreach (var (character, key) in requestedGlyphs) - { - Assert.NotNull(key); - - if (!characters.TryGetValue(key!, out var specObj)) - { - pending.Add(character); - continue; - } - - dynamic spec = specObj!; - bool isUploaded = spec.IsBitmapUploaded; - - if (isUploaded) - uploadedCount++; - else - pending.Add(character); - } - - output.WriteLine( - $"Iteration {iteration}/{maxIterations}: uploaded={uploadedCount}/{requestedGlyphs.Count}, measure={measured}, placement={placement}, pending=\"{new string(pending.ToArray())}\""); - - if (uploadedCount == requestedGlyphs.Count) - return; - } - - Assert.True(false, $"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); - } - - private void RunMultilineComparisonProbe() - { - const string text = "Arial\n64 Regular"; - const float requestedFontSize = 46f; - - var sdfDefaultMeasure = runtimeFont.MeasureString(text); - var sdfRequestedMeasure = runtimeFont.MeasureString(text, requestedFontSize); - - var rasterDefaultMeasure = runtimeRasterFont.MeasureString(text); - var rasterRequestedMeasure = runtimeRasterFont.MeasureString(text, requestedFontSize); - - var sdfDefaultLineSpacing = runtimeFont.GetFontDefaultLineSpacing(requestedFontSize); - var sdfTotalLineSpacing = runtimeFont.GetTotalLineSpacing(requestedFontSize); - var sdfExtraLineSpacing = runtimeFont.GetExtraLineSpacing(requestedFontSize); - var sdfBaseOffsetY = GetBaseOffsetY(runtimeFont, requestedFontSize); - - var rasterDefaultLineSpacing = runtimeRasterFont.GetFontDefaultLineSpacing(requestedFontSize); - var rasterTotalLineSpacing = runtimeRasterFont.GetTotalLineSpacing(requestedFontSize); - var rasterExtraLineSpacing = runtimeRasterFont.GetExtraLineSpacing(requestedFontSize); - var rasterBaseOffsetY = GetBaseOffsetY(runtimeRasterFont, requestedFontSize); - - var sdfBounds = ComputeGlyphPlacementBounds(runtimeFont, text, requestedFontSize); - var rasterBounds = ComputeGlyphPlacementBounds(runtimeRasterFont, text, requestedFontSize); - - output.WriteLine("=== Runtime SDF multiline metrics ==="); - output.WriteLine($"Default measure: {sdfDefaultMeasure}"); - output.WriteLine($"Requested measure: {sdfRequestedMeasure}"); - output.WriteLine($"Default line spacing: {sdfDefaultLineSpacing}"); - output.WriteLine($"Total line spacing: {sdfTotalLineSpacing}"); - output.WriteLine($"Extra line spacing: {sdfExtraLineSpacing}"); - output.WriteLine($"Base offset Y: {sdfBaseOffsetY}"); - output.WriteLine($"Placement bounds: {sdfBounds}"); - - output.WriteLine("=== Runtime Raster multiline metrics ==="); - output.WriteLine($"Default measure: {rasterDefaultMeasure}"); - output.WriteLine($"Requested measure: {rasterRequestedMeasure}"); - output.WriteLine($"Default line spacing: {rasterDefaultLineSpacing}"); - output.WriteLine($"Total line spacing: {rasterTotalLineSpacing}"); - output.WriteLine($"Extra line spacing: {rasterExtraLineSpacing}"); - output.WriteLine($"Base offset Y: {rasterBaseOffsetY}"); - output.WriteLine($"Placement bounds: {rasterBounds}"); - - // Loose sanity assertions first: we are diagnosing, not enforcing pixel identity. - Assert.True(Math.Abs(sdfRequestedMeasure.X - rasterRequestedMeasure.X) < 120f, - $"Requested multiline measure X diverged too much. SDF={sdfRequestedMeasure.X}, Raster={rasterRequestedMeasure.X}"); - - Assert.True(Math.Abs(sdfRequestedMeasure.Y - rasterRequestedMeasure.Y) < 120f, - $"Requested multiline measure Y diverged too much. SDF={sdfRequestedMeasure.Y}, Raster={rasterRequestedMeasure.Y}"); - - Assert.True(Math.Abs(sdfTotalLineSpacing - rasterTotalLineSpacing) < 80f, - $"Total line spacing diverged too much. SDF={sdfTotalLineSpacing}, Raster={rasterTotalLineSpacing}"); - - Assert.True(Math.Abs(sdfBaseOffsetY - rasterBaseOffsetY) < 80f, - $"Base offset diverged too much. SDF={sdfBaseOffsetY}, Raster={rasterBaseOffsetY}"); - } - - private void RunThumbnailBoundsConsistencyProbe() - { - const string text = "Arial\n64 Regular"; - var thumbnailSize = new Vector2(256, 256); - - var sdfResult = ComputeThumbnailConsistency(runtimeFont, text, thumbnailSize, 0.95f); - var rasterResult = ComputeThumbnailConsistency(runtimeRasterFont, text, thumbnailSize, 0.95f); - - output.WriteLine("=== Runtime SDF thumbnail math ==="); - DumpThumbnailConsistency(sdfResult); - - output.WriteLine("=== Runtime Raster thumbnail math ==="); - DumpThumbnailConsistency(rasterResult); - - // First, raster should be reasonably self-consistent. - Assert.True(smallEnough(rasterResult.MeasureVsPlacementDelta.X, 80f), - $"Raster measure/placement X mismatch too large: {rasterResult.MeasureVsPlacementDelta.X}"); - Assert.True(smallEnough(rasterResult.MeasureVsPlacementDelta.Y, 80f), - $"Raster measure/placement Y mismatch too large: {rasterResult.MeasureVsPlacementDelta.Y}"); - - // Then compare whether SDF is materially worse than raster. - Assert.True( - Math.Abs(sdfResult.MeasureVsPlacementDelta.X) - Math.Abs(rasterResult.MeasureVsPlacementDelta.X) < 80f, - $"SDF X mismatch is materially worse than raster. SDF={sdfResult.MeasureVsPlacementDelta.X}, Raster={rasterResult.MeasureVsPlacementDelta.X}"); - - Assert.True( - Math.Abs(sdfResult.MeasureVsPlacementDelta.Y) - Math.Abs(rasterResult.MeasureVsPlacementDelta.Y) < 80f, - $"SDF Y mismatch is materially worse than raster. SDF={sdfResult.MeasureVsPlacementDelta.Y}, Raster={rasterResult.MeasureVsPlacementDelta.Y}"); - } - - private ThumbnailConsistencyResult ComputeThumbnailConsistency(SpriteFont font, string text, Vector2 thumbnailSize, float fontSizeScale) - { - var typeNameSize = font.MeasureString(text); - var scale = fontSizeScale * Math.Min(thumbnailSize.X / typeNameSize.X, thumbnailSize.Y / typeNameSize.Y); - var desiredFontSize = scale * font.Size; - - if (font.FontType == SpriteFontType.Dynamic || font is RuntimeSignedDistanceFieldSpriteFont) - { - scale = 1f; - typeNameSize = font.MeasureString(text, desiredFontSize); - - if (font is RuntimeSignedDistanceFieldSpriteFont sdf) - { - // Use the current thumbnail warmup path you added. - sdf.PrepareGlyphsForThumbnail(text, new Vector2(desiredFontSize, desiredFontSize), GraphicsContext.CommandList); - } - else - { - font.PreGenerateGlyphs(text, new Vector2(desiredFontSize, desiredFontSize)); - } - } - - var placementBounds = ComputeGlyphPlacementBounds(font, text, desiredFontSize); - var measuredBounds = new RectangleF(0, 0, typeNameSize.X, typeNameSize.Y); - - return new ThumbnailConsistencyResult( - desiredFontSize, - typeNameSize, - measuredBounds, - placementBounds, - new Vector2( - placementBounds.Width - measuredBounds.Width, - placementBounds.Height - measuredBounds.Height)); - } - - private RectangleF ComputeGlyphPlacementBounds(SpriteFont font, string text, float fontSize) - { - var glyphs = EnumerateGlyphPlacements(font, text, fontSize); - - bool any = false; - float minX = 0, minY = 0, maxX = 0, maxY = 0; - - foreach (var glyph in glyphs) - { - if (glyph.Width <= 0 || glyph.Height <= 0) - continue; - - if (!any) - { - minX = glyph.X; - minY = glyph.Y; - maxX = glyph.X + glyph.Width; - maxY = glyph.Y + glyph.Height; - any = true; - } - else - { - minX = Math.Min(minX, glyph.X); - minY = Math.Min(minY, glyph.Y); - maxX = Math.Max(maxX, glyph.X + glyph.Width); - maxY = Math.Max(maxY, glyph.Y + glyph.Height); - } - } - - return any ? new RectangleF(minX, minY, maxX - minX, maxY - minY) : new RectangleF(); + this.runIdempotenceRegressionTest = runIdempotenceRegressionTest; + this.runThumbnailWarmupLayoutRegressionTest = runThumbnailWarmupLayoutRegressionTest; + this.runBoundedWarmupUploadConvergenceRegressionTest = runBoundedWarmupUploadConvergenceRegressionTest; } - private List EnumerateGlyphPlacements(SpriteFont font, string text, float fontSize) - { - var results = new List(); - - var glyphEnumeratorType = typeof(SpriteFont).GetNestedType("GlyphEnumerator", BindingFlags.NonPublic | BindingFlags.Public); - Assert.NotNull(glyphEnumeratorType); - - var ctor = glyphEnumeratorType!.GetConstructor( - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, - binder: null, - types: new[] - { - typeof(CommandList), - typeof(StringProxy), - typeof(Vector2), - typeof(bool), - typeof(int), - typeof(int), - typeof(SpriteFont), - typeof((TextAlignment, Vector2)?) - }, - modifiers: null); - - Assert.NotNull(ctor); - - var stringProxyCtor = typeof(StringProxy).GetConstructor( - BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, - binder: null, - types: new[] { typeof(string) }, - modifiers: null); - - Assert.NotNull(stringProxyCtor); - - var stringProxy = stringProxyCtor!.Invoke(new object[] { text }); - - object enumerator = ctor!.Invoke(new object[] - { - GraphicsContext.CommandList, - stringProxy, - new Vector2(fontSize, fontSize), - true, - 0, - text.Length, - font, - null - }); - - var moveNext = glyphEnumeratorType.GetMethod("MoveNext", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - var currentProp = glyphEnumeratorType.GetProperty("Current", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - - Assert.NotNull(moveNext); - Assert.NotNull(currentProp); - - var baseOffsetY = GetBaseOffsetY(font, fontSize); - - while ((bool)moveNext!.Invoke(enumerator, null)!) - { - var current = currentProp!.GetValue(enumerator); - Assert.NotNull(current); - - var currentType = current!.GetType(); - - var xProp = currentType.GetProperty("X", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - var yProp = currentType.GetProperty("Y", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - var glyphProp = currentType.GetProperty("Glyph", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - - Assert.NotNull(xProp); - Assert.NotNull(yProp); - Assert.NotNull(glyphProp); - - var x = Convert.ToSingle(xProp!.GetValue(current)); - var y = Convert.ToSingle(yProp!.GetValue(current)); - var glyph = glyphProp!.GetValue(current); - Assert.NotNull(glyph); - - dynamic g = glyph!; - float placedX = x + (float)g.Offset.X; - float placedY = y + baseOffsetY + (float)g.Offset.Y; - float width = (float)g.Subrect.Width; - float height = (float)g.Subrect.Height; - - results.Add(new PlacedGlyph(placedX, placedY, width, height)); - } - - return results; - } - - private static float GetBaseOffsetY(SpriteFont font, float size) - { - var method = typeof(SpriteFont).GetMethod("GetBaseOffsetY", BindingFlags.Instance | BindingFlags.NonPublic); - Assert.NotNull(method); - return Convert.ToSingle(method!.Invoke(font, new object[] { size })); - } - - private void DumpThumbnailConsistency(ThumbnailConsistencyResult result) - { - output.WriteLine($"Desired font size: {result.DesiredFontSize}"); - output.WriteLine($"Measured size: {result.MeasuredSize}"); - output.WriteLine($"Measured bounds: {result.MeasuredBounds}"); - output.WriteLine($"Placement bounds: {result.PlacementBounds}"); - output.WriteLine($"Placement - measured delta: {result.MeasureVsPlacementDelta}"); - } - - private static bool smallEnough(float value, float tolerance) - { - return Math.Abs(value) < tolerance; - } - - private readonly record struct PlacedGlyph(float X, float Y, float Width, float Height); - - private readonly record struct ThumbnailConsistencyResult( - float DesiredFontSize, - Vector2 MeasuredSize, - RectangleF MeasuredBounds, - RectangleF PlacementBounds, - Vector2 MeasureVsPlacementDelta); protected override async Task LoadContent() { await base.LoadContent(); @@ -465,18 +94,6 @@ protected override async Task LoadContent() defaultCharacter: ' '); Assert.NotNull(runtimeFont); - - runtimeRasterFont = (RuntimeRasterizedSpriteFont)fontSystem.NewDynamic( - defaultSize: 64, - fontName: "Arial", - style: FontStyle.Regular, - antiAliasMode: FontAntiAliasMode.Grayscale, - useKerning: false, - extraSpacing: 0, - extraLineSpacing: 0, - defaultCharacter: ' '); - - Assert.NotNull(runtimeRasterFont); } protected override void Update(GameTime gameTime) @@ -486,45 +103,34 @@ protected override void Update(GameTime gameTime) if (completed) return; - if (runIdempotenceTest) - RunIdempotenceProbe(); - - if (runLayoutStabilityTest) - RunLayoutStabilityProbe(); - - if (runMultilineComparisonTest) - RunMultilineComparisonProbe(); + if (runIdempotenceRegressionTest) + RunPreGenerateGlyphsIdempotenceRegression(); - if (runThumbnailBoundsConsistencyTest) - RunThumbnailBoundsConsistencyProbe(); + if (runThumbnailWarmupLayoutRegressionTest) + RunThumbnailWarmupLayoutRegression(); - if (runBoundedWarmupUploadConvergenceTest) - RunBoundedWarmupUploadConvergenceProbe(); + if (runBoundedWarmupUploadConvergenceRegressionTest) + RunBoundedWarmupUploadConvergenceRegression(); completed = true; Exit(); } - private void RunIdempotenceProbe() + private void RunPreGenerateGlyphsIdempotenceRegression() { const string text = "A"; var requestedSize = new Vector2(64, 64); var baseline = WarmupAndCaptureGlyphState(text, requestedSize); - output.WriteLine($"Baseline: {baseline}"); for (int i = 0; i < 5; i++) { var current = WarmupAndCaptureGlyphState(text, requestedSize); - output.WriteLine($"Iteration {i + 1}: {current}"); - - Assert.Equal(baseline.Offset.X, current.Offset.X); - Assert.Equal(baseline.Offset.Y, current.Offset.Y); - Assert.Equal(baseline.XAdvance, current.XAdvance); + AssertGlyphStateEqual(baseline, current, $"Iteration {i + 1}"); } } - private void RunLayoutStabilityProbe() + private void RunThumbnailWarmupLayoutRegression() { const string text = "Preview text"; var requestedSize = new Vector2(48, 48); @@ -533,45 +139,79 @@ private void RunLayoutStabilityProbe() var baselineMeasureRequested = runtimeFont.MeasureString(text, requestedSize.X); var baselineGlyphStates = WarmupAndCaptureGlyphStates(text, requestedSize); - output.WriteLine($"Baseline default measure: {baselineMeasureDefault}"); - output.WriteLine($"Baseline requested measure: {baselineMeasureRequested}"); - DumpGlyphStates("Baseline glyph states", baselineGlyphStates); - for (int i = 0; i < 5; i++) { var currentMeasureDefault = runtimeFont.MeasureString(text); var currentMeasureRequested = runtimeFont.MeasureString(text, requestedSize.X); var currentGlyphStates = WarmupAndCaptureGlyphStates(text, requestedSize); - output.WriteLine($"Iteration {i + 1} default measure: {currentMeasureDefault}"); - output.WriteLine($"Iteration {i + 1} requested measure: {currentMeasureRequested}"); - DumpGlyphStates($"Iteration {i + 1} glyph states", currentGlyphStates); + AssertVector2AlmostEqual(baselineMeasureDefault, currentMeasureDefault, $"Default measure iteration {i + 1}"); + AssertVector2AlmostEqual(baselineMeasureRequested, currentMeasureRequested, $"Requested measure iteration {i + 1}"); - Assert.Equal(baselineMeasureDefault.X, currentMeasureDefault.X); - Assert.Equal(baselineMeasureDefault.Y, currentMeasureDefault.Y); + Assert.Equal(baselineGlyphStates.Count, currentGlyphStates.Count); + for (int g = 0; g < baselineGlyphStates.Count; g++) + { + AssertGlyphStateEqual(baselineGlyphStates[g], currentGlyphStates[g], $"Glyph {g} iteration {i + 1}"); + } + } + } - Assert.Equal(baselineMeasureRequested.X, currentMeasureRequested.X); - Assert.Equal(baselineMeasureRequested.Y, currentMeasureRequested.Y); + private void RunBoundedWarmupUploadConvergenceRegression() + { + const string text = "Arial\n64 Regular"; + var requestedSize = new Vector2(64, 64); + const int maxIterations = 12; - Assert.Equal(baselineGlyphStates.Count, currentGlyphStates.Count); + var makeKey = GetMakeKeyMethod(); + var getDfParams = GetDfParamsMethod(); + var dfParams = getDfParams.Invoke(runtimeFont, Array.Empty()); + Assert.NotNull(dfParams); - for (int g = 0; g < baselineGlyphStates.Count; g++) + var requestedGlyphs = text + .Where(ch => !char.IsWhiteSpace(ch)) + .Distinct() + .Select(ch => (Character: ch, Key: makeKey.Invoke(null, new[] { (object)ch, dfParams! }))) + .ToList(); + + foreach (var glyph in requestedGlyphs) + Assert.NotNull(glyph.Key); + + for (int iteration = 1; iteration <= maxIterations; iteration++) + { + runtimeFont.PrepareGlyphsForThumbnail(text, requestedSize, GraphicsContext.CommandList); + + var characters = GetCharactersDictionary(runtimeFont); + int uploadedCount = 0; + var pending = new List(); + + foreach (var (character, key) in requestedGlyphs) { - var expected = baselineGlyphStates[g]; - var actual = currentGlyphStates[g]; + Assert.NotNull(key); + + if (!characters.TryGetValue(key!, out var specObj)) + { + pending.Add(character); + continue; + } - Assert.Equal(expected.Character, actual.Character); - Assert.Equal(expected.Offset.X, actual.Offset.X); - Assert.Equal(expected.Offset.Y, actual.Offset.Y); - Assert.Equal(expected.XAdvance, actual.XAdvance); + dynamic spec = specObj!; + if (spec.IsBitmapUploaded) + uploadedCount++; + else + pending.Add(character); } + + output.WriteLine($"Iteration {iteration}/{maxIterations}: uploaded={uploadedCount}/{requestedGlyphs.Count}, pending=\"{new string(pending.ToArray())}\""); + + if (uploadedCount == requestedGlyphs.Count) + return; } + + Assert.True(false, $"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); } private GlyphState WarmupAndCaptureGlyphState(string text, Vector2 requestedSize) { - runtimeFont.PreGenerateGlyphs(text, requestedSize); - var states = WarmupAndCaptureGlyphStates(text, requestedSize); Assert.Single(states); return states[0]; @@ -598,12 +238,7 @@ private List WarmupAndCaptureGlyphStates(string text, Vector2 reques Assert.NotNull(key); if (!characters.TryGetValue(key!, out var specObj)) - { Assert.True(false, $"Character '{ch}' was not present in runtime SDF character cache after warmup."); - continue; - } - - Assert.NotNull(specObj); dynamic spec = specObj!; var glyph = spec.Glyph; @@ -617,6 +252,19 @@ private List WarmupAndCaptureGlyphStates(string text, Vector2 reques return result; } + private static void AssertGlyphStateEqual(GlyphState expected, GlyphState actual, string context) + { + Assert.Equal(expected.Character, actual.Character); + AssertVector2AlmostEqual(expected.Offset, actual.Offset, $"{context} offset"); + Assert.InRange(Math.Abs(expected.XAdvance - actual.XAdvance), 0f, 0.0001f); + } + + private static void AssertVector2AlmostEqual(Vector2 expected, Vector2 actual, string context) + { + Assert.InRange(Math.Abs(expected.X - actual.X), 0f, 0.0001f); + Assert.InRange(Math.Abs(expected.Y - actual.Y), 0f, 0.0001f); + } + private static Dictionary GetCharactersDictionary(RuntimeSignedDistanceFieldSpriteFont font) { var field = typeof(RuntimeSignedDistanceFieldSpriteFont).GetField( @@ -674,16 +322,6 @@ private static MethodInfo GetDfParamsMethod() return method!; } - private void DumpGlyphStates(string title, List states) - { - output.WriteLine(title); - foreach (var state in states) - { - output.WriteLine( - $" Char='{state.Character}' Offset=({state.Offset.X}, {state.Offset.Y}) XAdvance={state.XAdvance}"); - } - } - private readonly record struct GlyphState(char Character, Vector2 Offset, float XAdvance); } } From f567bc20632d5313db90f50ef1360e71e1b01b4e Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 11:55:46 -0700 Subject: [PATCH 50/57] Fix accidentally repeated remora msdf package in central package management. --- sources/Directory.Packages.props | 1 - 1 file changed, 1 deletion(-) diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index 835fe2ffc2..6e855a26d9 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -73,7 +73,6 @@ - From 579573e752466ce549dfd3177d7aa8f941a8bc6f Mon Sep 17 00:00:00 2001 From: Yuechen Li <109704248+yuechen-li-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:05:53 -0700 Subject: [PATCH 51/57] Fix MSDF outline extractor to use FreeTypeNative types --- .../Stride.Graphics/Font/FontManager.cs | 4 +- .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 179 +++++++++--------- 2 files changed, 92 insertions(+), 91 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/FontManager.cs b/sources/engine/Stride.Graphics/Font/FontManager.cs index e8df63fe77..623e0408b7 100644 --- a/sources/engine/Stride.Graphics/Font/FontManager.cs +++ b/sources/engine/Stride.Graphics/Font/FontManager.cs @@ -399,14 +399,14 @@ public bool TryGetGlyphOutline( char character, out GlyphOutline outline, out GlyphOutlineMetrics metrics, - LoadFlags loadFlags = LoadFlags.NoBitmap | LoadFlags.NoHinting) + FreeTypeLoadFlags loadFlags = FreeTypeLoadFlags.NoBitmap | FreeTypeLoadFlags.NoHinting) { outline = null; metrics = default; var fontFace = GetOrCreateFontFace(fontFamily, fontStyle); - lock (freetypeLibrary) + lock (freetypeLock) { SetFontFaceSize(fontFace, size); diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index 38aacd9148..d439be5d2b 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -1,129 +1,130 @@ using System; -using SharpFont; using Stride.Core.Mathematics; namespace Stride.Graphics.Font.RuntimeMsdf { /// - /// Using SharpFont to extract an outline from a glyph. + /// Extracts glyph outlines from FreeType glyph data for runtime MSDF generation. /// - public static class SharpFontOutlineExtractor + internal static unsafe class SharpFontOutlineExtractor { + private const byte FT_CURVE_TAG_ON = 1; + private const byte FT_CURVE_TAG_CUBIC = 2; + private const byte FT_CURVE_TAG_MASK = 3; + public static bool TryExtractGlyphOutline( - Face face, + FT_FaceRec* face, uint charCode, out GlyphOutline outline, out GlyphOutlineMetrics metrics, - LoadFlags loadFlags = LoadFlags.NoBitmap) + FreeTypeLoadFlags loadFlags = FreeTypeLoadFlags.NoBitmap) { outline = null; metrics = default; - if (face == null) return false; + if (face == null) + return false; - try - { - face.LoadChar(charCode, loadFlags, LoadTarget.Normal); - } - catch (FreeTypeException) - { + var glyphIndex = FreeTypeNative.FT_Get_Char_Index(face, charCode); + if (glyphIndex == 0) return false; - } - var slot = face.Glyph; - if (slot == null) return false; + var err = FreeTypeNative.FT_Load_Glyph(face, glyphIndex, (int)loadFlags | (int)FreeTypeLoadTarget.Normal); + if (err != 0) + return false; + + var slot = face->glyph; + if (slot == null) + return false; - // Metrics are standard 26.6 fixed point - var m = slot.Metrics; + 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), + AdvanceX: Fixed26Dot6ToFloat(slot->advance.x), + BearingX: Fixed26Dot6ToFloat(m.horiBearingX), + BearingY: Fixed26Dot6ToFloat(m.horiBearingY), + 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) - ); + if (slot->outline.n_contours == 0 || slot->outline.n_points == 0) + return false; + outline = DecomposeOutline(ref slot->outline); return true; } - private static GlyphOutline DecomposeOutline(Outline ft) + private static GlyphOutline DecomposeOutline(ref FT_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) => + float minX = float.MaxValue; + float minY = float.MaxValue; + float maxX = float.MinValue; + float maxY = float.MinValue; + + var contourStart = 0; + for (var contourIndex = 0; contourIndex < ft.n_contours; contourIndex++) + { + var contourEnd = ft.contours[contourIndex]; + if (contourEnd < contourStart) + continue; + + var contour = new GlyphContour(); + result.Contours.Add(contour); + + Vector2 last = ConvertPoint(ft.points[contourStart], ref minX, ref minY, ref maxX, ref maxY); + for (var i = contourStart + 1; i <= contourEnd; i++) { - 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); + var point = ConvertPoint(ft.points[i], ref minX, ref minY, ref maxX, ref maxY); + var tag = ft.tags[i] & FT_CURVE_TAG_MASK; + + if (tag == FT_CURVE_TAG_CUBIC && i + 2 <= contourEnd) + { + var cp1 = point; + var cp2 = ConvertPoint(ft.points[i + 1], ref minX, ref minY, ref maxX, ref maxY); + var end = ConvertPoint(ft.points[i + 2], ref minX, ref minY, ref maxX, ref maxY); + contour.Segments.Add(new CubicSegment(last, cp1, cp2, end)); + last = end; + i += 2; + } + else if (tag != FT_CURVE_TAG_ON && i + 1 <= contourEnd) + { + var control = point; + var end = ConvertPoint(ft.points[i + 1], ref minX, ref minY, ref maxX, ref maxY); + contour.Segments.Add(new QuadraticSegment(last, control, end)); + last = end; + i += 1; + } + else + { + contour.Segments.Add(new LineSegment(last, point)); + last = point; + } + } + + var firstPoint = ConvertPoint(ft.points[contourStart], ref minX, ref minY, ref maxX, ref maxY); + if (last != firstPoint) + contour.Segments.Add(new LineSegment(last, firstPoint)); + + contourStart = contourEnd + 1; + } + + if (minX <= maxX && minY <= maxY) + result.Bounds = new RectangleF(minX, minY, maxX - minX, maxY - minY); return result; } - private static Vector2 ConvertVector(FTVector v) + private static Vector2 ConvertPoint(FT_Vector point, ref float minX, ref float minY, ref float maxX, ref float maxY) { - // 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); + var v = new Vector2(Fixed26Dot6ToFloat(point.x), Fixed26Dot6ToFloat(point.y)); + minX = MathF.Min(minX, v.X); + minY = MathF.Min(minY, v.Y); + maxX = MathF.Max(maxX, v.X); + maxY = MathF.Max(maxY, v.Y); + return v; } - // 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; + private static float Fixed26Dot6ToFloat(CLong value) => (int)value.Value / 64f; } } From f0d1f9ddbfa6015f0f4f86e5fa67f01c49c65241 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 12:35:08 -0700 Subject: [PATCH 52/57] Added font outline extraction P/invoke to FreeTypeNative.cs --- .../Stride.Graphics/Font/FreeTypeNative.cs | 30 +++++++++++++++++++ .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 3 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics/Font/FreeTypeNative.cs b/sources/engine/Stride.Graphics/Font/FreeTypeNative.cs index 8e90d5e4f3..2fef640bb8 100644 --- a/sources/engine/Stride.Graphics/Font/FreeTypeNative.cs +++ b/sources/engine/Stride.Graphics/Font/FreeTypeNative.cs @@ -194,6 +194,30 @@ internal unsafe struct FT_FaceRec // private fields follow (driver, memory, stream, etc.) — not needed } + // Outline extraction + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal unsafe delegate int FT_Outline_MoveToFunc(FT_Vector* to, nint user); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal unsafe delegate int FT_Outline_LineToFunc(FT_Vector* to, nint user); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal unsafe delegate int FT_Outline_ConicToFunc(FT_Vector* control, FT_Vector* to, nint user); + + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal unsafe delegate int FT_Outline_CubicToFunc(FT_Vector* control1, FT_Vector* control2, FT_Vector* to, nint user); + + [StructLayout(LayoutKind.Sequential)] + internal struct FT_Outline_Funcs + { + public nint move_to; + public nint line_to; + public nint conic_to; + public nint cubic_to; + public int shift; + public CLong delta; + } + internal static unsafe class FreeTypeNative { private const string FreetypeLib = "freetype"; @@ -221,5 +245,11 @@ internal static unsafe class FreeTypeNative [DllImport(FreetypeLib, CallingConvention = CallingConvention.Cdecl)] public static extern int FT_Render_Glyph(FT_GlyphSlotRec* slot, FreeTypeRenderMode render_mode); + + [DllImport(FreetypeLib, CallingConvention = CallingConvention.Cdecl)] + public static extern unsafe int FT_Outline_Decompose( + FT_Outline* outline, + FT_Outline_Funcs* func_interface, + nint user); } } diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index d439be5d2b..d7dd11dc46 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -1,5 +1,6 @@ using System; using Stride.Core.Mathematics; +using System.Runtime.InteropServices; namespace Stride.Graphics.Font.RuntimeMsdf { @@ -125,6 +126,6 @@ private static Vector2 ConvertPoint(FT_Vector point, ref float minX, ref float m return v; } - private static float Fixed26Dot6ToFloat(CLong value) => (int)value.Value / 64f; + private static float Fixed26Dot6ToFloat(System.Runtime.InteropServices.CLong value) => (int)value.Value / 64f; } } From 79b3cac1afeff67209096326fb9598a13aeb3dda Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 16:56:05 -0700 Subject: [PATCH 53/57] Fix outline extraction RT-SDF via Native Freetype. --- .../RuntimeMSDF/SharpFontOutlineExtractor.cs | 198 +++++++++++++----- 1 file changed, 144 insertions(+), 54 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs index d7dd11dc46..a0358c6de8 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -56,74 +56,164 @@ public static bool TryExtractGlyphOutline( private static GlyphOutline DecomposeOutline(ref FT_Outline ft) { - var result = new GlyphOutline(); + var state = new OutlineDecomposeState(); - float minX = float.MaxValue; - float minY = float.MaxValue; - float maxX = float.MinValue; - float maxY = float.MinValue; + FT_Outline_MoveToFunc moveTo = static (to, user) => + { + var handle = GCHandle.FromIntPtr(user); + var s = (OutlineDecomposeState)handle.Target!; + + var point = ConvertPoint(*to); + s.CloseCurrentContourIfNeeded(); + s.BeginContour(point); + s.Include(point); + + return 0; + }; - var contourStart = 0; - for (var contourIndex = 0; contourIndex < ft.n_contours; contourIndex++) + FT_Outline_LineToFunc lineTo = static (to, user) => { - var contourEnd = ft.contours[contourIndex]; - if (contourEnd < contourStart) - continue; + var handle = GCHandle.FromIntPtr(user); + var s = (OutlineDecomposeState)handle.Target!; - var contour = new GlyphContour(); - result.Contours.Add(contour); + var end = ConvertPoint(*to); + s.Include(end); + s.CurrentContour?.Segments.Add(new LineSegment(s.LastPoint, end)); + s.LastPoint = end; + + return 0; + }; + + FT_Outline_ConicToFunc conicTo = static (control, to, user) => + { + var handle = GCHandle.FromIntPtr(user); + var s = (OutlineDecomposeState)handle.Target!; - Vector2 last = ConvertPoint(ft.points[contourStart], ref minX, ref minY, ref maxX, ref maxY); - for (var i = contourStart + 1; i <= contourEnd; i++) + var cp = ConvertPoint(*control); + var end = ConvertPoint(*to); + + s.Include(cp); + s.Include(end); + + s.CurrentContour?.Segments.Add(new QuadraticSegment(s.LastPoint, cp, end)); + s.LastPoint = end; + + return 0; + }; + + FT_Outline_CubicToFunc cubicTo = static (control1, control2, to, user) => + { + var handle = GCHandle.FromIntPtr(user); + var s = (OutlineDecomposeState)handle.Target!; + + var cp1 = ConvertPoint(*control1); + var cp2 = ConvertPoint(*control2); + var end = ConvertPoint(*to); + + s.Include(cp1); + s.Include(cp2); + s.Include(end); + + s.CurrentContour?.Segments.Add(new CubicSegment(s.LastPoint, cp1, cp2, end)); + s.LastPoint = end; + + return 0; + }; + + var funcs = new FT_Outline_Funcs + { + move_to = Marshal.GetFunctionPointerForDelegate(moveTo), + line_to = Marshal.GetFunctionPointerForDelegate(lineTo), + conic_to = Marshal.GetFunctionPointerForDelegate(conicTo), + cubic_to = Marshal.GetFunctionPointerForDelegate(cubicTo), + shift = 0, + delta = new CLong(0) + }; + + var stateHandle = GCHandle.Alloc(state); + try + { + var user = GCHandle.ToIntPtr(stateHandle); + var localOutline = ft; + var error = FreeTypeNative.FT_Outline_Decompose(&localOutline, &funcs, user); + if (error != 0) + return new GlyphOutline(); + + state.CloseCurrentContourIfNeeded(); + + if (state.HasBounds) { - var point = ConvertPoint(ft.points[i], ref minX, ref minY, ref maxX, ref maxY); - var tag = ft.tags[i] & FT_CURVE_TAG_MASK; - - if (tag == FT_CURVE_TAG_CUBIC && i + 2 <= contourEnd) - { - var cp1 = point; - var cp2 = ConvertPoint(ft.points[i + 1], ref minX, ref minY, ref maxX, ref maxY); - var end = ConvertPoint(ft.points[i + 2], ref minX, ref minY, ref maxX, ref maxY); - contour.Segments.Add(new CubicSegment(last, cp1, cp2, end)); - last = end; - i += 2; - } - else if (tag != FT_CURVE_TAG_ON && i + 1 <= contourEnd) - { - var control = point; - var end = ConvertPoint(ft.points[i + 1], ref minX, ref minY, ref maxX, ref maxY); - contour.Segments.Add(new QuadraticSegment(last, control, end)); - last = end; - i += 1; - } - else - { - contour.Segments.Add(new LineSegment(last, point)); - last = point; - } + state.Result.Bounds = new RectangleF( + state.MinX, + state.MinY, + state.MaxX - state.MinX, + state.MaxY - state.MinY); } - var firstPoint = ConvertPoint(ft.points[contourStart], ref minX, ref minY, ref maxX, ref maxY); - if (last != firstPoint) - contour.Segments.Add(new LineSegment(last, firstPoint)); - - contourStart = contourEnd + 1; + return state.Result; } + finally + { + stateHandle.Free(); - if (minX <= maxX && minY <= maxY) - result.Bounds = new RectangleF(minX, minY, maxX - minX, maxY - minY); + GC.KeepAlive(moveTo); + GC.KeepAlive(lineTo); + GC.KeepAlive(conicTo); + GC.KeepAlive(cubicTo); + } + } - return result; + private static Vector2 ConvertPoint(FT_Vector point) + { + return new Vector2( + Fixed26Dot6ToFloat(point.x), + Fixed26Dot6ToFloat(point.y)); } - private static Vector2 ConvertPoint(FT_Vector point, ref float minX, ref float minY, ref float maxX, ref float maxY) + private sealed class OutlineDecomposeState { - var v = new Vector2(Fixed26Dot6ToFloat(point.x), Fixed26Dot6ToFloat(point.y)); - minX = MathF.Min(minX, v.X); - minY = MathF.Min(minY, v.Y); - maxX = MathF.Max(maxX, v.X); - maxY = MathF.Max(maxY, v.Y); - return v; + public GlyphOutline Result { get; } = new(); + public GlyphContour? CurrentContour { get; private set; } + + public Vector2 FirstPoint; + public Vector2 LastPoint; + + public float MinX = float.MaxValue; + public float MinY = float.MaxValue; + public float MaxX = float.MinValue; + public float MaxY = float.MinValue; + + public bool HasBounds => MinX <= MaxX && MinY <= MaxY; + + public void BeginContour(Vector2 start) + { + CurrentContour = new GlyphContour(); + Result.Contours.Add(CurrentContour); + + FirstPoint = start; + LastPoint = start; + } + + public void Include(Vector2 p) + { + MinX = MathF.Min(MinX, p.X); + MinY = MathF.Min(MinY, p.Y); + MaxX = MathF.Max(MaxX, p.X); + MaxY = MathF.Max(MaxY, p.Y); + } + + public void CloseCurrentContourIfNeeded() + { + if (CurrentContour == null) + return; + + if (LastPoint != FirstPoint) + { + CurrentContour.Segments.Add(new LineSegment(LastPoint, FirstPoint)); + } + + CurrentContour = null; + } } private static float Fixed26Dot6ToFloat(System.Runtime.InteropServices.CLong value) => (int)value.Value / 64f; From 053dfa15b744bf66e2ec52a2b09c0292dba6007f Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 17:08:48 -0700 Subject: [PATCH 54/57] Added warmup API, and preset warmup for basic latin characters. --- .../RuntimeSignedDistanceFieldSpriteFont.cs | 69 ++++++++++++++++--- 1 file changed, 61 insertions(+), 8 deletions(-) diff --git a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs index 9bbaf36960..ebb1baf46a 100644 --- a/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -46,6 +46,8 @@ internal sealed partial class RuntimeSignedDistanceFieldSpriteFont : SpriteFont // 2) Generated SDF results waiting for GPU upload private readonly System.Collections.Concurrent.ConcurrentQueue<(GlyphKey key, CharacterBitmapRgba sdf)> readyForUpload = new(); + private const string BasicLatinWarmupSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,:;!?-+*/()[]{}'\"_#"; + // --- Bounded work queue + fixed worker pool --- private const int WorkQueueCapacity = 1024; // backpressure / memory safety @@ -381,9 +383,30 @@ private void ApplyUploadedGlyph(FontCacheManagerMsdf cache, GlyphKey key, Charac internal void PrepareGlyphsForThumbnail(string text, Vector2 requestedSize, CommandList commandList, int maxWaitMilliseconds = 50) { - if (string.IsNullOrEmpty(text) || commandList == null) + WarmupGlyphSet(text, waitForUpload: true, commandList: commandList, maxWaitMilliseconds: maxWaitMilliseconds); + } + + internal void WarmupGlyphSet(string text, bool waitForUpload = false, CommandList commandList = null, int maxWaitMilliseconds = 50) + { + if (string.IsNullOrEmpty(text)) return; + var requestedKeys = QueueGlyphSet(text); + + if (requestedKeys.Count == 0) + return; + + if (waitForUpload && commandList != null) + WaitForGlyphSet(requestedKeys, commandList, maxWaitMilliseconds); + } + + internal void WarmupBasicLatin(bool waitForUpload = false, CommandList commandList = null, int maxWaitMilliseconds = 50) + { + WarmupGlyphSet(BasicLatinWarmupSet, waitForUpload, commandList, maxWaitMilliseconds); + } + + private HashSet QueueGlyphSet(string text) + { var sizeVec = new Vector2(Size, Size); var p = GetDistanceFieldParams(); @@ -392,33 +415,63 @@ internal void PrepareGlyphsForThumbnail(string text, Vector2 requestedSize, Comm for (int i = 0; i < text.Length; i++) { var c = text[i]; - var key = MakeKey(c, p); - requestedKeys.Add(key); + // Whitespace does not need atlas residency to avoid visible pop-in. + if (char.IsWhiteSpace(c)) + continue; + + var key = MakeKey(c, p); var spec = GetOrCreateCharacterData(key, sizeVec); if (spec.Bitmap == null) { FontManager.GenerateBitmap(spec, true); + // Apply padding offset once, when glyph metrics are first materialized. if (spec.Bitmap != null && spec.Glyph.XAdvance != 0) { spec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); } + } - if (spec.Bitmap == null || spec.Bitmap.Width == 0 || spec.Bitmap.Rows == 0 || spec.Glyph.XAdvance == 0) + // Missing or empty glyph: optionally seed the default fallback glyph instead. + if (spec.Bitmap == null || spec.Bitmap.Width == 0 || spec.Bitmap.Rows == 0 || spec.Glyph.XAdvance == 0) + { + if (c != DefaultCharacter && DefaultCharacter.HasValue && !char.IsWhiteSpace(DefaultCharacter.Value)) { - if (c != DefaultCharacter && DefaultCharacter.HasValue) - requestedKeys.Add(MakeKey(DefaultCharacter.Value, p)); + var fallbackKey = MakeKey(DefaultCharacter.Value, p); + var fallbackSpec = GetOrCreateCharacterData(fallbackKey, sizeVec); + + if (fallbackSpec.Bitmap == null) + { + FontManager.GenerateBitmap(fallbackSpec, true); + + if (fallbackSpec.Bitmap != null && fallbackSpec.Glyph.XAdvance != 0) + { + fallbackSpec.Glyph.Offset -= new Vector2(p.Pad, p.Pad); + } + } - continue; + if (fallbackSpec.Bitmap != null && fallbackSpec.Bitmap.Width > 0 && fallbackSpec.Bitmap.Rows > 0 && fallbackSpec.Glyph.XAdvance != 0) + { + requestedKeys.Add(fallbackKey); + EnsureSdfScheduled(fallbackKey, fallbackSpec); + } } + + continue; } + requestedKeys.Add(key); EnsureSdfScheduled(key, spec); } - if (requestedKeys.Count == 0) + return requestedKeys; + } + + private void WaitForGlyphSet(HashSet requestedKeys, CommandList commandList, int maxWaitMilliseconds) + { + if (requestedKeys == null || requestedKeys.Count == 0 || commandList == null) return; var stopwatch = Stopwatch.StartNew(); From b75e6cd4d1dfd104d526f3ff281ec21f14a64a32 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 17:21:16 -0700 Subject: [PATCH 55/57] Use assert.fail instead of assert.true(false) in new unit test. --- ...timeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs index 2ab3b489ad..1109888747 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -207,7 +207,7 @@ private void RunBoundedWarmupUploadConvergenceRegression() return; } - Assert.True(false, $"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); + Assert.Fail($"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); } private GlyphState WarmupAndCaptureGlyphState(string text, Vector2 requestedSize) @@ -238,7 +238,7 @@ private List WarmupAndCaptureGlyphStates(string text, Vector2 reques Assert.NotNull(key); if (!characters.TryGetValue(key!, out var specObj)) - Assert.True(false, $"Character '{ch}' was not present in runtime SDF character cache after warmup."); + Assert.Fail($"Character '{ch}' was not present in runtime SDF character cache after warmup."); dynamic spec = specObj!; var glyph = spec.Glyph; From 59f29f44ccc88b666ff4263931148157322c6d2d Mon Sep 17 00:00:00 2001 From: Yuechen Li <109704248+yuechen-li-dev@users.noreply.github.com> Date: Sun, 12 Apr 2026 17:26:54 -0700 Subject: [PATCH 56/57] Add MSDF rasterizer and outline extractor regression tests --- ...dfRasterizerAndExtractorRegressionTests.cs | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs new file mode 100644 index 0000000000..044d983008 --- /dev/null +++ b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Linq; +using System.Runtime.InteropServices; +using Stride.Core.Mathematics; +using Stride.Core.Serialization.Contents; +using Stride.Core.Storage; +using Stride.Engine; +using Stride.Graphics.Font; +using Stride.Graphics.Font.RuntimeMsdf; +using Xunit; + +namespace Stride.Graphics.Tests; + +public class RuntimeMsdfRasterizerAndExtractorRegressionTests +{ + [Fact] + public void OutlineDiagnosticRasterizer_Renders_AsymmetricShape_WithExpectedOrientation() + { + var outline = CreateAsymmetricLShapeOutline(); + var settings = new DistanceFieldSettings(PixelRange: 4, Padding: 2, Width: 20, Height: 20); + + using var bitmap = ((IGlyphMsdfRasterizer)new OutlineDiagnosticRasterizer()) + .RasterizeMsdf(outline, settings, MsdfEncodeSettings.Default); + + Assert.Equal(settings.TotalWidth, bitmap.Width); + Assert.Equal(settings.TotalHeight, bitmap.Rows); + + var scan = ScanBitmap(bitmap); + Assert.True(scan.NonBlackCount > 0, "Expected rasterized diagnostic outline pixels."); + + int topQuarterRows = Math.Max(1, bitmap.Rows / 4); + int bottomQuarterStart = bitmap.Rows - topQuarterRows; + int topCount = CountNonBlackPixels(bitmap, yStart: 0, yEndExclusive: topQuarterRows); + int bottomCount = CountNonBlackPixels(bitmap, yStart: bottomQuarterStart, yEndExclusive: bitmap.Rows); + + Assert.True(topCount > bottomCount, + $"Expected asymmetric shape to have more signal in top rows than bottom rows (top={topCount}, bottom={bottomCount})."); + } + + [Fact] + public void MsdfGenCoreRasterizer_RasterizeMsdf_RectangleOutline_ProducesNonEmptyBitmap() + { + var outline = CreateRectangleOutline(0, 0, 8, 6); + var settings = new DistanceFieldSettings(PixelRange: 4, Padding: 3, Width: 24, Height: 18); + + using var bitmap = ((IGlyphMsdfRasterizer)new MsdfGenCoreRasterizer()) + .RasterizeMsdf(outline, settings, MsdfEncodeSettings.Default); + + Assert.Equal(settings.TotalWidth, bitmap.Width); + Assert.Equal(settings.TotalHeight, bitmap.Rows); + + var scan = ScanBitmap(bitmap); + Assert.True(scan.NonBlackCount > 0, "Expected non-empty MSDF output."); + Assert.True(scan.MinRgb != scan.MaxRgb, "Expected non-uniform MSDF values in output bitmap."); + } + + [Fact] + public void FreeTypeOutlineExtractor_ExtractsSimpleGlyph_WithNonEmptyContoursAndBounds() + { + Game.InitializeAssetDatabase(); + + using var fontManager = new FontManager(CreateDatabaseProvider()); + + bool extracted = fontManager.TryGetGlyphOutline( + fontFamily: "Arial", + fontStyle: FontStyle.Regular, + size: new Vector2(64, 64), + character: 'A', + outline: out var outline, + metrics: out var metrics); + + Assert.True(extracted); + Assert.NotNull(outline); + Assert.NotEmpty(outline.Contours); + + int segmentCount = outline.Contours.Sum(c => c?.Segments?.Count ?? 0); + Assert.True(segmentCount > 0, "Expected at least one extracted outline segment."); + + Assert.True(outline.Bounds.Width > 0); + Assert.True(outline.Bounds.Height > 0); + + Assert.True(metrics.AdvanceX > 0); + Assert.True(metrics.Width > 0); + Assert.True(metrics.Height > 0); + Assert.True(float.IsFinite(metrics.BearingX)); + Assert.True(float.IsFinite(metrics.BearingY)); + } + + private static IDatabaseFileProviderService CreateDatabaseProvider() + { + Stride.Core.IO.VirtualFileSystem.CreateDirectory(Stride.Core.IO.VirtualFileSystem.ApplicationDatabasePath); + return new DatabaseFileProviderService(new DatabaseFileProvider(ObjectDatabase.CreateDefaultDatabase())); + } + + private static GlyphOutline CreateAsymmetricLShapeOutline() + { + var contour = new GlyphContour(); + var p0 = new Vector2(0, 0); + var p1 = new Vector2(0, 4); + var p2 = new Vector2(4, 4); + var p3 = new Vector2(4, 3); + var p4 = new Vector2(1, 3); + var p5 = new Vector2(1, 0); + + contour.Segments.Add(new LineSegment(p0, p1)); + contour.Segments.Add(new LineSegment(p1, p2)); + contour.Segments.Add(new LineSegment(p2, p3)); + contour.Segments.Add(new LineSegment(p3, p4)); + contour.Segments.Add(new LineSegment(p4, p5)); + contour.Segments.Add(new LineSegment(p5, p0)); + + var outline = new GlyphOutline + { + Bounds = new RectangleF(0, 0, 4, 4), + Winding = GlyphWinding.Clockwise, + }; + outline.Contours.Add(contour); + return outline; + } + + private static GlyphOutline CreateRectangleOutline(float x, float y, float width, float height) + { + var p0 = new Vector2(x, y); + var p1 = new Vector2(x + width, y); + var p2 = new Vector2(x + width, y + height); + var p3 = new Vector2(x, y + height); + + var contour = new GlyphContour(); + contour.Segments.Add(new LineSegment(p0, p1)); + contour.Segments.Add(new LineSegment(p1, p2)); + contour.Segments.Add(new LineSegment(p2, p3)); + contour.Segments.Add(new LineSegment(p3, p0)); + + var outline = new GlyphOutline { Bounds = new RectangleF(x, y, width, height) }; + outline.Contours.Add(contour); + return outline; + } + + private static (int NonBlackCount, int MinRgb, int MaxRgb) ScanBitmap(CharacterBitmapRgba bitmap) + { + var bytes = CopyBitmapBytes(bitmap); + int nonBlackCount = 0; + int minRgb = 255; + int maxRgb = 0; + + for (int i = 0; i < bytes.Length; i += 4) + { + int r = bytes[i]; + int g = bytes[i + 1]; + int b = bytes[i + 2]; + + if (r != 0 || g != 0 || b != 0) + nonBlackCount++; + + minRgb = Math.Min(minRgb, Math.Min(r, Math.Min(g, b))); + maxRgb = Math.Max(maxRgb, Math.Max(r, Math.Max(g, b))); + } + + return (nonBlackCount, minRgb, maxRgb); + } + + private static int CountNonBlackPixels(CharacterBitmapRgba bitmap, int yStart, int yEndExclusive) + { + var bytes = CopyBitmapBytes(bitmap); + int count = 0; + + for (int y = yStart; y < yEndExclusive; y++) + { + int rowStart = y * bitmap.Pitch; + for (int x = 0; x < bitmap.Width; x++) + { + int offset = rowStart + x * 4; + if (bytes[offset] != 0 || bytes[offset + 1] != 0 || bytes[offset + 2] != 0) + count++; + } + } + + return count; + } + + private static byte[] CopyBitmapBytes(CharacterBitmapRgba bitmap) + { + int size = checked(bitmap.Pitch * bitmap.Rows); + var bytes = new byte[size]; + if (size > 0) + Marshal.Copy(bitmap.Buffer, bytes, 0, size); + return bytes; + } +} From addf78294b9f6bd40b4c0982413b80dcaa8fd3d2 Mon Sep 17 00:00:00 2001 From: Yuechen Li Date: Sun, 12 Apr 2026 17:50:46 -0700 Subject: [PATCH 57/57] Fix newly added unit tests. --- .../RuntimeMsdfRasterizerAndExtractorRegressionTests.cs | 1 + ...untimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs index 044d983008..7602d97cc6 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs @@ -1,6 +1,7 @@ using System; using System.Linq; using System.Runtime.InteropServices; +using Stride.Core.IO; using Stride.Core.Mathematics; using Stride.Core.Serialization.Contents; using Stride.Core.Storage; diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs index 1109888747..c5595f0eab 100644 --- a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -315,7 +315,7 @@ private static MethodInfo GetMakeKeyMethod() private static MethodInfo GetDfParamsMethod() { var method = typeof(RuntimeSignedDistanceFieldSpriteFont).GetMethod( - "GetDfParams", + "GetDistanceFieldParams", BindingFlags.Instance | BindingFlags.NonPublic); Assert.NotNull(method);