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