engine: render immediate text into surfaces
This commit is contained in:
@@ -5,6 +5,7 @@ using System.Threading;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Text;
|
||||
|
||||
[Flags]
|
||||
public enum HostPresentationReason
|
||||
@@ -65,6 +66,11 @@ public sealed class GodotAdvHost : IHost
|
||||
private volatile bool _stopping;
|
||||
private readonly object _textLock = new();
|
||||
private readonly Dictionary<int, List<SurfaceTextDraw>> _surfaceText = new();
|
||||
private readonly CachedGlyphMaskRasterizer? _surfaceTextMaskCache;
|
||||
private readonly ImmediateSurfaceTextRenderer? _surfaceTextPixelRenderer;
|
||||
private readonly GlyphRasterizerBackendInfo? _surfaceTextBackendInfo;
|
||||
private string _surfaceTextFallbackReason;
|
||||
private bool _surfaceTextFallbackWarningReported;
|
||||
private readonly Dictionary<int, AdvTextHistoryRenderBatch> _historyText = new();
|
||||
private sealed class LiveTextState
|
||||
{
|
||||
@@ -119,7 +125,9 @@ public sealed class GodotAdvHost : IHost
|
||||
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock,
|
||||
PageLocatorState locator, Sys4LogicalCanvas logicalCanvas,
|
||||
GodotTimelineLog? timeline = null,
|
||||
bool synchronizeExplicitPresentation = true)
|
||||
bool synchronizeExplicitPresentation = true,
|
||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null,
|
||||
string? surfaceTextFallbackReason = null)
|
||||
{
|
||||
_main = main; _res = res; _rootScene = scene; _clock = clock;
|
||||
_locator = locator; _timeline = timeline;
|
||||
@@ -127,9 +135,33 @@ public sealed class GodotAdvHost : IHost
|
||||
_screenHeight = logicalCanvas.Height;
|
||||
_slotDims[0] = (_screenWidth, _screenHeight);
|
||||
_synchronizeExplicitPresentation = synchronizeExplicitPresentation;
|
||||
_surfaceTextBackendInfo =
|
||||
(surfaceTextRasterizer as IIdentifiedGlyphMaskRasterizer)?.BackendInfo;
|
||||
if (surfaceTextRasterizer != null)
|
||||
{
|
||||
_surfaceTextMaskCache = new CachedGlyphMaskRasterizer(
|
||||
surfaceTextRasterizer, capacity: 2048);
|
||||
_surfaceTextPixelRenderer =
|
||||
new ImmediateSurfaceTextRenderer(_surfaceTextMaskCache);
|
||||
}
|
||||
_surfaceTextFallbackReason = surfaceTextFallbackReason
|
||||
?? (surfaceTextRasterizer == null
|
||||
? "No glyph-mask rasterizer was selected."
|
||||
: "");
|
||||
}
|
||||
|
||||
public Sys4LogicalCanvas LogicalCanvas => new(_screenWidth, _screenHeight);
|
||||
public bool UsesSurfaceTextPixels => _surfaceTextPixelRenderer != null;
|
||||
public GlyphRasterizerBackendInfo? SurfaceTextBackendInfo => _surfaceTextBackendInfo;
|
||||
public string SurfaceTextFallbackReason => _surfaceTextFallbackReason;
|
||||
public (int Count, int Capacity, long Hits, long Misses) SurfaceTextMaskCacheStats
|
||||
=> _surfaceTextMaskCache == null
|
||||
? (0, 0, 0, 0)
|
||||
: (
|
||||
_surfaceTextMaskCache.Count,
|
||||
_surfaceTextMaskCache.Capacity,
|
||||
_surfaceTextMaskCache.Hits,
|
||||
_surfaceTextMaskCache.Misses);
|
||||
|
||||
public void ReportWarning(string message) => System.Console.Error.WriteLine(message);
|
||||
|
||||
@@ -314,6 +346,27 @@ public sealed class GodotAdvHost : IHost
|
||||
|
||||
public void DrawStringToSurface(int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||
{
|
||||
if (_surfaceTextPixelRenderer != null
|
||||
&& TryDrawStringPixels(surfaceSlot, x, y, text, style))
|
||||
{
|
||||
lock (_textLock)
|
||||
if (_surfaceText.TryGetValue(surfaceSlot, out var fallbackDraws))
|
||||
{
|
||||
fallbackDraws.RemoveAll(draw => draw.X == x && draw.Y == y);
|
||||
if (fallbackDraws.Count == 0) _surfaceText.Remove(surfaceSlot);
|
||||
}
|
||||
_timeline?.Event("draw-string", new()
|
||||
{
|
||||
["surface"] = surfaceSlot,
|
||||
["x"] = x,
|
||||
["y"] = y,
|
||||
["text"] = text,
|
||||
["presentation"] = "rgba-glyph-mask",
|
||||
["backend"] = _surfaceTextBackendInfo?.Id ?? "unidentified",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_textLock)
|
||||
{
|
||||
if (!_surfaceText.TryGetValue(surfaceSlot, out var draws))
|
||||
@@ -321,7 +374,63 @@ public sealed class GodotAdvHost : IHost
|
||||
draws.RemoveAll(draw => draw.X == x && draw.Y == y);
|
||||
draws.Add(new SurfaceTextDraw(x, y, text, style));
|
||||
}
|
||||
_timeline?.Event("draw-string", new() { ["surface"] = surfaceSlot, ["x"] = x, ["y"] = y, ["text"] = text });
|
||||
_timeline?.Event("draw-string", new()
|
||||
{
|
||||
["surface"] = surfaceSlot,
|
||||
["x"] = x,
|
||||
["y"] = y,
|
||||
["text"] = text,
|
||||
["presentation"] = "label-fallback",
|
||||
["reason"] = _surfaceTextFallbackReason,
|
||||
});
|
||||
}
|
||||
|
||||
private bool TryDrawStringPixels(
|
||||
int surfaceSlot, int x, int y, string text, AdvTextStyle style)
|
||||
{
|
||||
RgbaImage? destination = ResolveSurfacePixels(surfaceSlot);
|
||||
if (destination == null
|
||||
&& _slotDims.TryGetValue(surfaceSlot, out var dimensions)
|
||||
&& dimensions.W > 0
|
||||
&& dimensions.H > 0)
|
||||
destination = new RgbaImage(
|
||||
dimensions.W, dimensions.H,
|
||||
new byte[checked(dimensions.W * dimensions.H * 4)]);
|
||||
if (destination == null || destination.Width <= 0 || destination.Height <= 0)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"Surface {surfaceSlot} has no rasterizable pixel allocation.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var updated = new RgbaImage(
|
||||
destination.Width, destination.Height, (byte[])destination.Pixels.Clone());
|
||||
try
|
||||
{
|
||||
_surfaceTextPixelRenderer!.Render(updated, x, y, text, style);
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is ArgumentException
|
||||
or InvalidOperationException
|
||||
or PlatformNotSupportedException
|
||||
or System.ComponentModel.Win32Exception)
|
||||
{
|
||||
ReportSurfaceTextFallback(
|
||||
$"{error.GetType().Name}: {error.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_imageLock) _surfaceImages[surfaceSlot] = updated;
|
||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ReportSurfaceTextFallback(string reason)
|
||||
{
|
||||
_surfaceTextFallbackReason = reason;
|
||||
if (_surfaceTextFallbackWarningReported) return;
|
||||
_surfaceTextFallbackWarningReported = true;
|
||||
ReportWarning($"surface text fell back to Label metadata: {reason}");
|
||||
}
|
||||
|
||||
public (string Text, int X, int Y, int VisibleGlyphs, bool Revealing) SnapshotAdvText()
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
<EnableDynamicLoading>true</EnableDynamicLoading>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<DefineConstants>$(DefineConstants);AGE_WINDOWS_GDI</DefineConstants>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\engine\Age.Engine\Age.Engine.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="$([MSBuild]::IsOSPlatform('Windows'))">
|
||||
<ProjectReference Include="..\engine\Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="Exists('..\build\native\win-x64\age_movie_ffmpeg.dll')">
|
||||
<None Include="..\build\native\win-x64\*.dll"
|
||||
Link="%(Filename)%(Extension)"
|
||||
|
||||
120
godot/Main.cs
120
godot/Main.cs
@@ -11,6 +11,10 @@ using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Persistence;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Text;
|
||||
#if AGE_WINDOWS_GDI
|
||||
using Age.Engine.Text.Windows;
|
||||
#endif
|
||||
using Age.Engine.Vm;
|
||||
using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
|
||||
|
||||
@@ -69,6 +73,9 @@ public partial class Main : Godot.Control
|
||||
private Sys4RegIniStore? _sys4RegIniStore;
|
||||
private VirtualMachine _vm = null!;
|
||||
private GodotAdvHost _host = null!;
|
||||
#if AGE_WINDOWS_GDI
|
||||
private WindowsGdiGlyphMaskRasterizer? _exactGlyphRasterizer;
|
||||
#endif
|
||||
private FullwidthTextEditorDialog? _fullwidthTextEditor;
|
||||
private Sys4ScriptProvider? _scripts;
|
||||
private DebugSceneLauncher? _debugSceneLauncher;
|
||||
@@ -374,9 +381,44 @@ public partial class Main : Godot.Control
|
||||
var resources = scripts != null
|
||||
? new ResourceMap(scripts.Catalog, trackedAssetStore)
|
||||
: new ResourceMap(catalog, _assetStore);
|
||||
IGlyphMaskRasterizer? surfaceTextRasterizer = null;
|
||||
string surfaceTextFallbackReason;
|
||||
#if AGE_WINDOWS_GDI
|
||||
if (WindowsGdiGlyphMaskRasterizer.TryGetAvailability(out string availability))
|
||||
{
|
||||
try
|
||||
{
|
||||
_exactGlyphRasterizer = new WindowsGdiGlyphMaskRasterizer();
|
||||
surfaceTextRasterizer = _exactGlyphRasterizer;
|
||||
surfaceTextFallbackReason = "";
|
||||
GD.Print(
|
||||
$"[text] immediate surface strings use {_exactGlyphRasterizer.BackendInfo.Id}: " +
|
||||
_exactGlyphRasterizer.BackendInfo.Detail);
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
surfaceTextFallbackReason =
|
||||
$"Exact Windows GDI glyph backend failed to initialize: {error.Message}";
|
||||
GD.PushWarning($"[text] {surfaceTextFallbackReason}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
surfaceTextFallbackReason = availability;
|
||||
GD.PushWarning(
|
||||
$"[text] immediate surface strings retain the Label fallback: {availability}");
|
||||
}
|
||||
#else
|
||||
surfaceTextFallbackReason =
|
||||
"The exact Windows GDI glyph adapter is not part of this platform build.";
|
||||
GD.PushWarning(
|
||||
$"[text] immediate surface strings retain the Label fallback: {surfaceTextFallbackReason}");
|
||||
#endif
|
||||
_host = new GodotAdvHost(
|
||||
this, resources, scene, _clock, _locator, logicalCanvas, _timeline,
|
||||
synchronizeExplicitPresentation: !_selftest)
|
||||
synchronizeExplicitPresentation: !_selftest,
|
||||
surfaceTextRasterizer: surfaceTextRasterizer,
|
||||
surfaceTextFallbackReason: surfaceTextFallbackReason)
|
||||
{
|
||||
SleepScale = sleepScale,
|
||||
TraceOps = _gfxLogPath != null,
|
||||
@@ -1013,6 +1055,10 @@ public partial class Main : Godot.Control
|
||||
|
||||
DumpHistogram(); _timeline?.Dispose(); _locator?.Dispose();
|
||||
_gpuRenderer?.Dispose();
|
||||
#if AGE_WINDOWS_GDI
|
||||
_exactGlyphRasterizer?.Dispose();
|
||||
_exactGlyphRasterizer = null;
|
||||
#endif
|
||||
if (_perf != null)
|
||||
{
|
||||
_perf.Dispose();
|
||||
@@ -2426,6 +2472,75 @@ public partial class Main : Godot.Control
|
||||
&& textEffectSmoke.GetThemeConstant("line_spacing")
|
||||
== CalibratedLineSpacing(
|
||||
8, 24, calibratedBold.GetHeight(24));
|
||||
bool immediateSurfaceTextOk;
|
||||
string immediateSurfaceTextMode;
|
||||
_host.CreateTexture(997, 32, 24);
|
||||
var immediateStyle = AdvTextStyle.Default with
|
||||
{
|
||||
PrimaryFontSize = 16,
|
||||
TextColor = 0xffffff,
|
||||
FontFace = ImmediateSurfaceTextRenderer.DefaultFontFace,
|
||||
};
|
||||
_host.DrawStringToSurface(997, 1, 1, "A", immediateStyle);
|
||||
if (_host.UsesSurfaceTextPixels)
|
||||
{
|
||||
RgbaImage? pixels = _host.CaptureSurfacePixels(997);
|
||||
GlyphRasterizerBackendInfo? backend = _host.SurfaceTextBackendInfo;
|
||||
var cache = _host.SurfaceTextMaskCacheStats;
|
||||
bool pixelsPresent = pixels != null
|
||||
&& pixels.Pixels.Where((_, index) => index % 4 == 3)
|
||||
.Any(alpha => alpha != 0);
|
||||
bool gpuAccepted = false;
|
||||
if (pixels != null)
|
||||
{
|
||||
_gpuRenderer.BeginFrame(false);
|
||||
gpuAccepted = _gpuRenderer.DrawTexture(
|
||||
pixels, int.MinValue + 997, -1,
|
||||
0, 0, pixels.Width, pixels.Height,
|
||||
new Affine2D(1, 0, 0, 1, 2, 3),
|
||||
0xff8080, 255, 0.5f, true,
|
||||
dynamic: true, dynamicKey: 997, BlendKind.Alpha);
|
||||
GpuRetainedRenderer.FrameStats stats = _gpuRenderer.EndFrame();
|
||||
gpuAccepted &= stats.DrawItems == 1 && stats.TextureUploads == 1;
|
||||
}
|
||||
|
||||
_host.CreateTexture(996, 32, 24);
|
||||
_host.CopySurfaceRect(new SurfaceRectCopy(
|
||||
997, 996, 0, 0, 32, 24, 0, 0));
|
||||
RgbaImage? copied = _host.CaptureSurfacePixels(996);
|
||||
bool copiedPixels = pixels != null
|
||||
&& copied != null
|
||||
&& pixels.Pixels.SequenceEqual(copied.Pixels);
|
||||
_host.FillSurfaceRect(new SurfaceRectFill(
|
||||
996, 0, 0, 32, 24, 0, 0));
|
||||
RgbaImage? cleared = _host.CaptureSurfacePixels(996);
|
||||
bool clearedPixels = cleared != null && cleared.Pixels.All(value => value == 0);
|
||||
immediateSurfaceTextOk =
|
||||
pixelsPresent
|
||||
&& _host.SnapshotSurfaceText(997).Count == 0
|
||||
&& backend is
|
||||
{
|
||||
Id: "windows-gdi-gray4",
|
||||
Policy: GlyphRasterPolicy.NativeCp932Gray4,
|
||||
NativePixelExact: true,
|
||||
}
|
||||
&& cache.Count is > 0 and <= 2048
|
||||
&& cache.Capacity == 2048
|
||||
&& gpuAccepted
|
||||
&& copiedPixels
|
||||
&& clearedPixels;
|
||||
immediateSurfaceTextMode = "exact-rgba";
|
||||
}
|
||||
else
|
||||
{
|
||||
immediateSurfaceTextOk =
|
||||
_host.CaptureSurfacePixels(997)?.Pixels.All(value => value == 0) == true
|
||||
&& _host.SnapshotSurfaceText(997).Count == 1
|
||||
&& !string.IsNullOrWhiteSpace(_host.SurfaceTextFallbackReason);
|
||||
immediateSurfaceTextMode = "label-fallback";
|
||||
}
|
||||
_host.ReleaseSurface(996);
|
||||
_host.ReleaseSurface(997);
|
||||
Window rootWindow = GetTree().Root;
|
||||
bool logicalCanvasOk = _host.LogicalCanvas == new Sys4LogicalCanvas(_screenWidth, _screenHeight)
|
||||
&& _screen.GetWidth() == _screenWidth
|
||||
@@ -2464,6 +2579,7 @@ public partial class Main : Godot.Control
|
||||
&& firstRiffBoundaryOk
|
||||
&& bgmReplacementCancelsFade && bgmOneShotModeOk && bgmLoopModeOk
|
||||
&& bgmStopReleaseOk && textEffectModesOk && fontCalibrationOk
|
||||
&& immediateSurfaceTextOk
|
||||
&& logicalCanvasOk && backbufferPreservationOk;
|
||||
if (ok) GD.Print($"SELFTEST OK: threaded host matches headless ({actual.Count} lines, full handling); " +
|
||||
$"debug launcher catalog/UI smoke ({debugEntries.Count} packed scripts); " +
|
||||
@@ -2471,6 +2587,7 @@ public partial class Main : Godot.Control
|
||||
$"first-riff-boundary=ok; " +
|
||||
$"bgm-fade-replacement=ok; bgm-start-modes-stop=ok; " +
|
||||
$"text-effect-modes=ok; font-calibration=ok; " +
|
||||
$"immediate-surface-text={immediateSurfaceTextMode}; " +
|
||||
$"backbuffer-preservation=ok; " +
|
||||
$"logical-canvas={_screenWidth}x{_screenHeight}; " +
|
||||
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");
|
||||
@@ -2482,6 +2599,7 @@ public partial class Main : Godot.Control
|
||||
$"bgm-one-shot={bgmOneShotModeOk}; bgm-loop={bgmLoopModeOk}; " +
|
||||
$"bgm-stop-release={bgmStopReleaseOk}; " +
|
||||
$"text-effect-modes={textEffectModesOk}; font-calibration={fontCalibrationOk}; " +
|
||||
$"immediate-surface-text={immediateSurfaceTextOk}({immediateSurfaceTextMode}); " +
|
||||
$"backbuffer-preservation={backbufferPreservationOk}; " +
|
||||
$"logical-canvas={logicalCanvasOk}({_screenWidth}x{_screenHeight}); " +
|
||||
$"window-request={_windowOptions.Width}x{_windowOptions.Height}");
|
||||
|
||||
Reference in New Issue
Block a user