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