engine: add deterministic glyph mask core
This commit is contained in:
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal file
135
engine/Age.Engine/Text/AgeGlyphMaskCompositor.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
namespace Age.Engine.Text;
|
||||
|
||||
/// <summary>AGE's deterministic 32-bit glyph-mask compositor, independent of mask provenance.</summary>
|
||||
public static class AgeGlyphMaskCompositor
|
||||
{
|
||||
public static void DrawGlyph(
|
||||
RgbaImage destination,
|
||||
GlyphMask mask,
|
||||
int cellX,
|
||||
int cellY,
|
||||
int topToBaseline,
|
||||
AdvTextStyle style)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(mask);
|
||||
ValidateDestination(destination);
|
||||
int baselineY = checked(cellY + topToBaseline);
|
||||
|
||||
if (style.RenderMode == 1)
|
||||
DrawMask(destination, mask,
|
||||
checked(cellX + style.EffectOffsetX),
|
||||
checked(baselineY + style.EffectOffsetY),
|
||||
style.EffectColor);
|
||||
else if (style.RenderMode == 3)
|
||||
foreach ((int x, int y) in GetMode3OutlineOffsets(
|
||||
style.EffectOffsetX, style.EffectOffsetY))
|
||||
DrawMask(destination, mask, checked(cellX + x), checked(baselineY + y),
|
||||
style.EffectColor);
|
||||
|
||||
DrawMask(destination, mask, cellX, baselineY, style.TextColor);
|
||||
if (style.RenderMode == 2)
|
||||
DrawMask(destination, mask, cellX, baselineY, style.TextColor, coverageShift: 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draw at a GDI-style baseline. mask.OriginX moves right from the pen and mask.OriginY moves
|
||||
/// upward from the baseline. coverageShift=2 reproduces AGE's mode-2 quarter-coverage pass.
|
||||
/// </summary>
|
||||
public static bool DrawMask(
|
||||
RgbaImage destination,
|
||||
GlyphMask mask,
|
||||
int baselineX,
|
||||
int baselineY,
|
||||
long rgb,
|
||||
int coverageShift = 0)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(destination);
|
||||
ArgumentNullException.ThrowIfNull(mask);
|
||||
ValidateDestination(destination);
|
||||
if (coverageShift is < 0 or > 4)
|
||||
throw new ArgumentOutOfRangeException(nameof(coverageShift));
|
||||
|
||||
int red = (int)((rgb >> 16) & 0xff);
|
||||
int green = (int)((rgb >> 8) & 0xff);
|
||||
int blue = (int)(rgb & 0xff);
|
||||
int left = checked(baselineX + mask.OriginX);
|
||||
int top = checked(baselineY - mask.OriginY);
|
||||
ReadOnlySpan<byte> coverage = mask.Coverage.Span;
|
||||
bool changed = false;
|
||||
|
||||
for (int row = 0; row < mask.Height; row++)
|
||||
{
|
||||
int y = checked(top + row);
|
||||
if ((uint)y >= (uint)destination.Height) continue;
|
||||
int sourceRow = row * mask.Stride;
|
||||
for (int column = 0; column < mask.Width; column++)
|
||||
{
|
||||
int x = checked(left + column);
|
||||
if ((uint)x >= (uint)destination.Width) continue;
|
||||
int level = coverage[sourceRow + column] >> coverageShift;
|
||||
if (level == 0) continue;
|
||||
int alpha = level * 255 / 16;
|
||||
BlendTextPixel(destination.Pixels,
|
||||
checked((y * destination.Width + x) * 4),
|
||||
red, green, blue, alpha);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<(int X, int Y)> GetMode3OutlineOffsets(int radiusX, int radiusY)
|
||||
{
|
||||
double radius = Math.Sqrt((double)radiusX * radiusX + (double)radiusY * radiusY);
|
||||
if (radius == 0) return Array.Empty<(int, int)>();
|
||||
int sampleCount = checked((int)Math.Ceiling(radius * 8.0));
|
||||
var result = new (int X, int Y)[sampleCount];
|
||||
double step = 360.0 / (radius * 8.0);
|
||||
for (int index = 0; index < result.Length; index++)
|
||||
{
|
||||
double radians = index * step * Math.PI / 180.0;
|
||||
result[index] = (
|
||||
RoundNearest(Math.Cos(radians) * radiusX),
|
||||
RoundNearest(Math.Sin(radians) * radiusY));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int RoundNearest(double value)
|
||||
=> checked((int)(value < 0 ? value - 0.5 : value + 0.5));
|
||||
|
||||
private static void BlendTextPixel(
|
||||
byte[] destination, int offset, int red, int green, int blue, int alpha)
|
||||
{
|
||||
int destinationAlpha = destination[offset + 3];
|
||||
if (alpha == 255 || destinationAlpha == 0)
|
||||
{
|
||||
destination[offset] = (byte)red;
|
||||
destination[offset + 1] = (byte)green;
|
||||
destination[offset + 2] = (byte)blue;
|
||||
destination[offset + 3] = (byte)alpha;
|
||||
return;
|
||||
}
|
||||
|
||||
int inverse = 255 - alpha;
|
||||
destination[offset] =
|
||||
(byte)((destination[offset] * inverse + red * alpha) / 255);
|
||||
destination[offset + 1] =
|
||||
(byte)((destination[offset + 1] * inverse + green * alpha) / 255);
|
||||
destination[offset + 2] =
|
||||
(byte)((destination[offset + 2] * inverse + blue * alpha) / 255);
|
||||
destination[offset + 3] = (byte)Math.Max(destinationAlpha, alpha);
|
||||
}
|
||||
|
||||
private static void ValidateDestination(RgbaImage destination)
|
||||
{
|
||||
if (destination.Width <= 0 || destination.Height <= 0
|
||||
|| destination.Pixels.Length != checked(destination.Width * destination.Height * 4))
|
||||
throw new ArgumentException("Destination must be a non-empty tightly packed RGBA image.",
|
||||
nameof(destination));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user