diff --git a/sources/Directory.Packages.props b/sources/Directory.Packages.props index 5e717b5e60..0933277fb1 100644 --- a/sources/Directory.Packages.props +++ b/sources/Directory.Packages.props @@ -16,6 +16,7 @@ + @@ -71,6 +72,7 @@ + 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/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 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 0854f25965..3c4bb4d98d 100644 --- a/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs +++ b/sources/editor/Stride.Editor/Thumbnails/ThumbnailFromTextureCommand.cs @@ -1,10 +1,12 @@ // 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; using Stride.Graphics; +using Stride.Graphics.Font; using Stride.Rendering; using Stride.Rendering.Compositing; @@ -92,12 +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)); } // 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); } } } 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 f594f90130..b86e58c1ec 100644 --- a/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj +++ b/sources/engine/Stride.Assets.Tests/Stride.Assets.Tests.csproj @@ -15,6 +15,7 @@ GuidGenerator.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..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 d7d9b6eade..cad1d9e2da 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 @@ -89,15 +103,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); @@ -119,7 +131,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); @@ -191,9 +203,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); @@ -202,6 +214,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.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs new file mode 100644 index 0000000000..7602d97cc6 --- /dev/null +++ b/sources/engine/Stride.Graphics.Tests/RuntimeMsdfRasterizerAndExtractorRegressionTests.cs @@ -0,0 +1,190 @@ +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; +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; + } +} diff --git a/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs new file mode 100644 index 0000000000..c5595f0eab --- /dev/null +++ b/sources/engine/Stride.Graphics.Tests/RuntimeSignedDistanceFieldSpriteFontLayoutDiagnosticsTests.cs @@ -0,0 +1,328 @@ +using System; +using System.Collections.Generic; +using System.Linq; +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_ForGlyphOffsetAndAdvance() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceRegressionTest: true, + runThumbnailWarmupLayoutRegressionTest: false, + runBoundedWarmupUploadConvergenceRegressionTest: false); + game.Run(); + } + + [Fact] + public void RuntimeSdf_ThumbnailLikeWarmup_IsLayoutStable_AcrossRepeatedCalls() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceRegressionTest: false, + runThumbnailWarmupLayoutRegressionTest: true, + runBoundedWarmupUploadConvergenceRegressionTest: false); + game.Run(); + } + + [Fact] + public void RuntimeSdf_BoundedWarmup_TransitionsRequestedGlyphs_ToUploaded() + { + using var game = new RuntimeSdfDiagnosticsGame( + output, + runIdempotenceRegressionTest: false, + runThumbnailWarmupLayoutRegressionTest: false, + runBoundedWarmupUploadConvergenceRegressionTest: true); + game.Run(); + } + + private sealed class RuntimeSdfDiagnosticsGame : GraphicTestGameBase + { + private readonly ITestOutputHelper output; + private readonly bool runIdempotenceRegressionTest; + private readonly bool runThumbnailWarmupLayoutRegressionTest; + private readonly bool runBoundedWarmupUploadConvergenceRegressionTest; + + private RuntimeSignedDistanceFieldSpriteFont runtimeFont = null!; + private bool completed; + + public RuntimeSdfDiagnosticsGame( + ITestOutputHelper output, + bool runIdempotenceRegressionTest, + bool runThumbnailWarmupLayoutRegressionTest, + bool runBoundedWarmupUploadConvergenceRegressionTest) + { + this.output = output; + this.runIdempotenceRegressionTest = runIdempotenceRegressionTest; + this.runThumbnailWarmupLayoutRegressionTest = runThumbnailWarmupLayoutRegressionTest; + this.runBoundedWarmupUploadConvergenceRegressionTest = runBoundedWarmupUploadConvergenceRegressionTest; + } + + 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 (runIdempotenceRegressionTest) + RunPreGenerateGlyphsIdempotenceRegression(); + + if (runThumbnailWarmupLayoutRegressionTest) + RunThumbnailWarmupLayoutRegression(); + + if (runBoundedWarmupUploadConvergenceRegressionTest) + RunBoundedWarmupUploadConvergenceRegression(); + + completed = true; + Exit(); + } + + private void RunPreGenerateGlyphsIdempotenceRegression() + { + const string text = "A"; + var requestedSize = new Vector2(64, 64); + + var baseline = WarmupAndCaptureGlyphState(text, requestedSize); + + for (int i = 0; i < 5; i++) + { + var current = WarmupAndCaptureGlyphState(text, requestedSize); + AssertGlyphStateEqual(baseline, current, $"Iteration {i + 1}"); + } + } + + private void RunThumbnailWarmupLayoutRegression() + { + 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); + + for (int i = 0; i < 5; i++) + { + var currentMeasureDefault = runtimeFont.MeasureString(text); + var currentMeasureRequested = runtimeFont.MeasureString(text, requestedSize.X); + var currentGlyphStates = WarmupAndCaptureGlyphStates(text, requestedSize); + + AssertVector2AlmostEqual(baselineMeasureDefault, currentMeasureDefault, $"Default measure iteration {i + 1}"); + AssertVector2AlmostEqual(baselineMeasureRequested, currentMeasureRequested, $"Requested measure iteration {i + 1}"); + + Assert.Equal(baselineGlyphStates.Count, currentGlyphStates.Count); + for (int g = 0; g < baselineGlyphStates.Count; g++) + { + AssertGlyphStateEqual(baselineGlyphStates[g], currentGlyphStates[g], $"Glyph {g} iteration {i + 1}"); + } + } + } + + private void RunBoundedWarmupUploadConvergenceRegression() + { + 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); + + 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) + { + Assert.NotNull(key); + + if (!characters.TryGetValue(key!, out var specObj)) + { + pending.Add(character); + continue; + } + + 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.Fail($"Not all requested glyphs transitioned to uploaded within {maxIterations} iterations."); + } + + private GlyphState WarmupAndCaptureGlyphState(string text, Vector2 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.Fail($"Character '{ch}' was not present in runtime SDF character cache after warmup."); + + 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 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( + "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( + "GetDistanceFieldParams", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.NotNull(method); + return method!; + } + + private readonly record struct GlyphState(char Character, Vector2 Offset, float XAdvance); + } + } +} 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 ce83e71f09..623e0408b7 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,7 +22,7 @@ internal unsafe 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. @@ -33,17 +34,17 @@ internal unsafe class FontManager : IDisposable /// /// 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 @@ -267,11 +268,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(); @@ -379,7 +377,7 @@ private void BuildBitmapThread() RenderBitmap(character, fontFace); } - DequeueRequest: + DequeueRequest: // update the generated cached data lock (dataStructuresLock) @@ -387,5 +385,38 @@ private void BuildBitmapThread() } } } + + /// + /// 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, + FreeTypeLoadFlags loadFlags = FreeTypeLoadFlags.NoBitmap | FreeTypeLoadFlags.NoHinting) + { + outline = null; + metrics = default; + + var fontFace = GetOrCreateFontFace(fontFamily, fontStyle); + + lock (freetypeLock) + { + 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..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,10 +16,12 @@ 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 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(); /// @@ -29,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 . @@ -50,6 +53,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 +83,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 +139,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/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/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..01cf4272ed --- /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, 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); + } + } + } + + Debug.WriteLine("Outline rendered successfully"); + return bmp; + } + + private static Vector2 TransformPoint(Vector2 p, float minX, float maxY, float scale, int padding) + { + // Flip Y when mapping outline space (Y-up) to bitmap space (Y-down). + return new Vector2( + (p.X - minX) * scale + padding, + (maxY - p.Y) * 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..a0358c6de8 --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeMSDF/SharpFontOutlineExtractor.cs @@ -0,0 +1,221 @@ +using System; +using Stride.Core.Mathematics; +using System.Runtime.InteropServices; + +namespace Stride.Graphics.Font.RuntimeMsdf +{ + /// + /// Extracts glyph outlines from FreeType glyph data for runtime MSDF generation. + /// + 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( + FT_FaceRec* face, + uint charCode, + out GlyphOutline outline, + out GlyphOutlineMetrics metrics, + FreeTypeLoadFlags loadFlags = FreeTypeLoadFlags.NoBitmap) + { + outline = null; + metrics = default; + + if (face == null) + return false; + + var glyphIndex = FreeTypeNative.FT_Get_Char_Index(face, charCode); + if (glyphIndex == 0) + 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; + + var m = slot->metrics; + metrics = new GlyphOutlineMetrics( + AdvanceX: Fixed26Dot6ToFloat(slot->advance.x), + BearingX: Fixed26Dot6ToFloat(m.horiBearingX), + BearingY: Fixed26Dot6ToFloat(m.horiBearingY), + Width: Fixed26Dot6ToFloat(m.width), + Height: Fixed26Dot6ToFloat(m.height), + Baseline: 0f); + + if (slot->outline.n_contours == 0 || slot->outline.n_points == 0) + return false; + + outline = DecomposeOutline(ref slot->outline); + return true; + } + + private static GlyphOutline DecomposeOutline(ref FT_Outline ft) + { + var state = new OutlineDecomposeState(); + + 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; + }; + + FT_Outline_LineToFunc lineTo = static (to, user) => + { + var handle = GCHandle.FromIntPtr(user); + var s = (OutlineDecomposeState)handle.Target!; + + 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!; + + 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) + { + state.Result.Bounds = new RectangleF( + state.MinX, + state.MinY, + state.MaxX - state.MinX, + state.MaxY - state.MinY); + } + + return state.Result; + } + finally + { + stateHandle.Free(); + + GC.KeepAlive(moveTo); + GC.KeepAlive(lineTo); + GC.KeepAlive(conicTo); + GC.KeepAlive(cubicTo); + } + } + + private static Vector2 ConvertPoint(FT_Vector point) + { + return new Vector2( + Fixed26Dot6ToFloat(point.x), + Fixed26Dot6ToFloat(point.y)); + } + + private sealed class OutlineDecomposeState + { + 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; + } +} 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 new file mode 100644 index 0000000000..ebb1baf46a --- /dev/null +++ b/sources/engine/Stride.Graphics/Font/RuntimeSignedDistanceFieldSpriteFont.cs @@ -0,0 +1,548 @@ +using System; +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; +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 partial class RuntimeSignedDistanceFieldSpriteFont : SpriteFont + { + internal string FontName; + internal FontStyle Style; + + internal int PixelRange = 8; + internal int Padding = 2; + + internal bool UseKerning; + + // 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(); + + private const string BasicLatinWarmupSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,:;!?-+*/()[]{}'\"_#"; + + // --- 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 = GetDistanceFieldParams(); + + // 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 = GetDistanceFieldParams(); + + 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 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); + } + } + + 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); + + } + + internal void PrepareGlyphsForThumbnail(string text, Vector2 requestedSize, CommandList commandList, int maxWaitMilliseconds = 50) + { + 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(); + + var requestedKeys = new HashSet(); + + for (int i = 0; i < text.Length; i++) + { + var c = text[i]; + + // 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); + } + } + + // 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)) + { + 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); + } + } + + 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); + } + + return requestedKeys; + } + + private void WaitForGlyphSet(HashSet requestedKeys, CommandList commandList, int maxWaitMilliseconds) + { + if (requestedKeys == null || requestedKeys.Count == 0 || commandList == null) + 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; + 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(); + } + } + } +} 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/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)] diff --git a/sources/engine/Stride.Graphics/Stride.Graphics.csproj b/sources/engine/Stride.Graphics/Stride.Graphics.csproj index faa62b0cb0..3fbf6dc3df 100644 --- a/sources/engine/Stride.Graphics/Stride.Graphics.csproj +++ b/sources/engine/Stride.Graphics/Stride.Graphics.csproj @@ -1,4 +1,4 @@ - + true @@ -38,6 +38,8 @@ + +