engine: render immediate text into surfaces

This commit is contained in:
gamer147
2026-07-30 18:47:35 -04:00
parent a58108522e
commit eec99c2c7c
9 changed files with 563 additions and 22 deletions

View File

@@ -0,0 +1,181 @@
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Text;
public class ImmediateSurfaceTextRendererTests
{
private sealed class RecordingRasterizer : IGlyphMaskRasterizer
{
public List<GlyphRasterRequest> Requests { get; } = new();
public GlyphMask Rasterize(GlyphRasterRequest request)
{
Requests.Add(request);
return new GlyphMask(
width: 1, height: 1, stride: 1,
originX: 0, originY: request.PixelHeight,
cellAdvanceX: request.Cp932Code <= 0x7f ? 2 : 4,
cellAdvanceY: 0,
cellWidth: request.Cp932Code <= 0x7f ? 2 : 4,
cellHeight: request.PixelHeight,
coverage: [16]);
}
}
[Fact]
public void ImmediateStringBuildsExactCp932RequestsAndAdvancesFromGdiMetrics()
{
var rasterizer = new RecordingRasterizer();
var renderer = new ImmediateSurfaceTextRenderer(rasterizer);
var destination = Image(16, 8);
AdvTextStyle style = Style() with
{
PrimaryFontSize = 5,
Bold = true,
FontFace = " ゴシック",
};
ImmediateSurfaceTextResult result =
renderer.Render(destination, 1, 2, "Aあ", style);
Assert.Equal(new ImmediateSurfaceTextResult(2, 7, 2), result);
Assert.Collection(rasterizer.Requests,
ascii => Assert.Equal(
(" ゴシック", 4, -2, 700, 0x41, (ushort)0x41),
RequestIdentity(ascii)),
japanese => Assert.Equal(
(" ゴシック", 4, -2, 700, 0x3042, (ushort)0x82a0),
RequestIdentity(japanese)));
Assert.Equal((255, 255, 255, 255), Pixel(destination, 1, 2));
Assert.Equal((255, 255, 255, 255), Pixel(destination, 3, 2));
}
[Fact]
public void UnsupportedCp932CharacterFailsBeforeRasterizationOrPixelMutation()
{
var rasterizer = new RecordingRasterizer();
var renderer = new ImmediateSurfaceTextRenderer(rasterizer);
var destination = Image(8, 8);
Assert.Throws<ArgumentException>(
() => renderer.Render(destination, 0, 0, "A😀", Style()));
Assert.Empty(rasterizer.Requests);
Assert.All(destination.Pixels, value => Assert.Equal(0, value));
}
[Theory]
[InlineData(24, 24)]
[InlineData(25, 24)]
[InlineData(32, 31)]
[InlineData(33, 31)]
public void NativeFontHeightMatchesDecodedLogfontRebuildRules(int requested, int expected)
=> Assert.Equal(expected, ImmediateSurfaceTextRenderer.NativePixelHeight(requested));
[Fact]
public void RasterizedTextPixelsObeyRetainedOrderAlphaTintTransformAndSourceClip()
{
var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer());
var textSurface = Image(8, 8);
renderer.Render(textSurface, 1, 1, "AA", Style());
var gfx = new GfxState();
gfx.CreateSurface(2);
gfx.BindDraw(10, 2, 1, 1, 1, 1, 0, 0);
gfx.SetCurrentScale(10, (200, 200, 100));
gfx.SetCurrentTranslation(10, (3, 2, 0));
gfx.SetObjectColorResolved(10, 128, 0x00ff00);
var occluder = new RgbaImage(1, 1, [9, 8, 7, 255]);
gfx.SetSurface(3, 0x3000, -1);
gfx.BindDraw(20, 3, 0, 0, 1, 1, 3, 2);
var destination = Image(8, 8);
IReadOnlyList<RenderObject> visible = gfx.SnapshotVisibleObjects();
int rendered = RetainedSurfaceRasterizer.CompositeRange(
destination, visible, 10, 1, _ => textSurface);
Assert.Equal(1, rendered);
Assert.Equal((0, 128, 0, 128), Pixel(destination, 3, 2));
Assert.Equal((0, 128, 0, 128), Pixel(destination, 4, 2));
Assert.Equal((0, 0, 0, 0), Pixel(destination, 5, 2));
RetainedSurfaceRasterizer.CompositeRange(
destination, visible, 20, 1, _ => occluder);
Assert.Equal((9, 8, 7, 255), Pixel(destination, 3, 2));
}
[Fact]
public void RasterizedTextPixelsInheritTheBoundObjectsAnimatedFade()
{
var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer());
var textSurface = Image(8, 8);
renderer.Render(textSurface, 1, 1, "A", Style());
var gfx = new GfxState();
gfx.CreateSurface(2);
gfx.BindDraw(10, 2, 1, 1, 1, 1, 2, 3);
gfx.SetObjectColorResolved(10, 255, 0xffffff);
gfx.SetAnimatedObjectColorResolved(
10, delayMs: 0, durationMs: 100, alpha: 0, rgb: 0xffffff);
gfx.SnapshotVisibleObjects(nowMs: 1000); // establish the native one-shot start
IReadOnlyList<RenderObject> midpoint = gfx.SnapshotVisibleObjects(nowMs: 1050);
var destination = Image(8, 8);
RetainedSurfaceRasterizer.CompositeRange(
destination, midpoint, 10, 1, _ => textSurface);
Assert.InRange(Pixel(destination, 2, 3).A, 126, 128);
Assert.Equal(0, gfx.SnapshotVisibleObjects(nowMs: 1100).Single().Alpha);
}
[Fact]
public void RasterizedTextSurvivesSurfaceCopyAndCanBeClearedAsPixels()
{
var renderer = new ImmediateSurfaceTextRenderer(new RecordingRasterizer());
var source = Image(8, 8);
renderer.Render(source, 1, 1, "A", Style());
var captured = new RgbaImage(
source.Width, source.Height, (byte[])source.Pixels.Clone());
var destination = Image(8, 8);
Assert.True(RgbaSurfaceOps.CopyRect(captured, destination, 1, 1, 1, 1, 4, 5));
Assert.Equal((255, 255, 255, 255), Pixel(destination, 4, 5));
Assert.True(RgbaSurfaceOps.FillRect(destination, 4, 5, 1, 1, 0, 0));
Assert.Equal((0, 0, 0, 0), Pixel(destination, 4, 5));
}
private static AdvTextStyle Style()
=> new(
PrimaryFontSize: 4,
RubyFontSize: 0,
Bold: false,
TextColor: 0xffffff,
EffectColor: 0,
RenderMode: 0,
EffectOffsetX: 0,
EffectOffsetY: 0,
LineSpacing: 0,
FontFace: "");
private static (
string Face, int Height, int Width, int Weight, int Scalar, ushort Cp932)
RequestIdentity(GlyphRasterRequest request)
=> (
request.FontFace, request.PixelHeight, request.RequestedWidth, request.Weight,
request.UnicodeScalar, request.Cp932Code!.Value);
private static RgbaImage Image(int width, int height)
=> new(width, height, new byte[checked(width * height * 4)]);
private static (byte R, byte G, byte B, byte A) Pixel(RgbaImage image, int x, int y)
{
int offset = checked((y * image.Width + x) * 4);
return (
image.Pixels[offset],
image.Pixels[offset + 1],
image.Pixels[offset + 2],
image.Pixels[offset + 3]);
}
}

View File

@@ -0,0 +1,103 @@
using System.Text;
using Age.Engine.Model;
using Age.Engine.Sys4;
namespace Age.Engine.Text;
public readonly record struct ImmediateSurfaceTextResult(
int GlyphCount,
int CursorX,
int CursorY);
/// <summary>
/// Converts one native CP932 string into exact glyph requests and composites it directly into an
/// AGE RGBA surface. Request construction completes before any pixels change, so an unsupported
/// character cannot leave a partially drawn string behind.
/// </summary>
public sealed class ImmediateSurfaceTextRenderer
{
public const string DefaultFontFace = " 明朝";
private static readonly Encoding Cp932 = CreateCp932();
private readonly IGlyphMaskRasterizer _rasterizer;
public ImmediateSurfaceTextRenderer(IGlyphMaskRasterizer rasterizer)
=> _rasterizer = rasterizer ?? throw new ArgumentNullException(nameof(rasterizer));
public ImmediateSurfaceTextResult Render(
RgbaImage destination,
int x,
int y,
string text,
AdvTextStyle style)
{
ArgumentNullException.ThrowIfNull(destination);
ArgumentNullException.ThrowIfNull(text);
IReadOnlyList<GlyphRasterRequest> requests = CreateRequests(text, style);
int cursorX = x;
int cursorY = y;
foreach (GlyphRasterRequest request in requests)
{
GlyphMask mask = _rasterizer.Rasterize(request);
AgeGlyphMaskCompositor.DrawGlyph(
destination, mask, cursorX, cursorY, request.PixelHeight, style);
cursorX = checked(cursorX + mask.CellAdvanceX);
cursorY = checked(cursorY + mask.CellAdvanceY);
}
return new ImmediateSurfaceTextResult(requests.Count, cursorX, cursorY);
}
public static IReadOnlyList<GlyphRasterRequest> CreateRequests(
string text,
AdvTextStyle style)
{
ArgumentNullException.ThrowIfNull(text);
int requestedHeight = style.PrimaryFontSize > 0 ? style.PrimaryFontSize : 24;
int pixelHeight = NativePixelHeight(requestedHeight);
int requestedWidth = -(pixelHeight / 2);
int weight = style.Bold ? 700 : 0;
string fontFace = string.IsNullOrWhiteSpace(style.FontFace)
? DefaultFontFace
: style.FontFace;
var requests = new List<GlyphRasterRequest>(text.Length);
foreach (Rune rune in text.EnumerateRunes())
{
byte[] encoded;
try
{
encoded = Cp932.GetBytes(rune.ToString());
}
catch (EncoderFallbackException error)
{
throw new ArgumentException(
$"U+{rune.Value:X4} is not representable in native CP932 text.", nameof(text), error);
}
if (encoded.Length is < 1 or > 2)
throw new ArgumentException(
$"U+{rune.Value:X4} encoded to unsupported CP932 length {encoded.Length}.",
nameof(text));
ushort cp932Code = encoded.Length == 1
? encoded[0]
: (ushort)((encoded[0] << 8) | encoded[1]);
requests.Add(new GlyphRasterRequest(
fontFace, pixelHeight, requestedWidth, weight,
rune.Value, cp932Code, GlyphRasterPolicy.NativeCp932Gray4));
}
return requests;
}
public static int NativePixelHeight(int requestedHeight)
{
if (requestedHeight <= 0) return 24;
if (requestedHeight is 32 or 33) return 31;
return (requestedHeight & 1) != 0 ? requestedHeight - 1 : requestedHeight;
}
private static Encoding CreateCp932()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
return Encoding.GetEncoding(
932, EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
}
}