Profile and optimize retained rendering

This commit is contained in:
gamer147
2026-07-22 14:19:20 -04:00
parent 4c9719c5dc
commit 4ea352e45d
17 changed files with 1619 additions and 144 deletions

View File

@@ -14,6 +14,15 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
double AxisX, double AxisY, double AxisZ,
double AngleDegrees = 0);
[System.Flags]
public enum GfxPresentationReason
{
None = 0,
RetainedMutation = 1,
ContinuousChannel = 2,
DiscreteSourceCell = 4,
}
/// <summary>Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order;
/// the compositor draws range B over it with <paramref name="Progress"/> to form the native crossfade.</summary>
public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot,
@@ -62,7 +71,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
bool MultiplyTint,
SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null,
Affine2D? RangeTransform = null);
Affine2D? RangeTransform = null,
bool TimeVarying = false);
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
@@ -142,6 +152,9 @@ public sealed class GfxState
// Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and
// returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
private readonly Dictionary<long, GfxObject> _objects = new();
// Native composition is handle-ascending z order. Mutations maintain this small index so snapshots do
// not rebuild/sort a dictionary-sized LINQ buffer, while hot handle lookup remains O(1).
private readonly List<long> _orderedObjectHandles = new();
private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11];
// Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled
@@ -164,6 +177,12 @@ public sealed class GfxState
public long AnimationServiceFlags { get; private set; }
public uint PreviousFrameTimeMilliseconds { get; private set; }
public uint CurrentFrameTimeMilliseconds { get; private set; }
private long _previousFrameTimeMs;
private long _currentFrameTimeMs;
private long _mutationGeneration;
private long _publishedMutationGeneration;
private void MarkRetainedMutation() => _mutationGeneration++;
/// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary>
public IEnumerable<(long Handle, int Slot)> Objects
@@ -178,8 +197,14 @@ public sealed class GfxState
// (Monitor) so the callers that already hold it are fine.
lock (_lock)
{
if (!_objects.TryGetValue(handle, out var o)) { o = new GfxObject(); _objects[handle] = o; }
if (!_objects.TryGetValue(handle, out var o))
{
o = new GfxObject();
_objects[handle] = o;
InsertOrderedHandle(handle);
}
CurrentObject = handle;
MarkRetainedMutation();
return o;
}
}
@@ -189,6 +214,31 @@ public sealed class GfxState
lock (_lock) DefaultObjectSlot = slot;
}
public void SetObjectAnchor(long handle, (long X, long Y, long Z) anchor)
{
lock (_lock) GetOrCreate(handle).V18 = anchor;
}
public void SetObjectPosition(long handle, (long X, long Y, long Z) position)
{
lock (_lock) GetOrCreate(handle).V24 = position;
}
public void SetObjectField64(long handle, long value)
{
lock (_lock) GetOrCreate(handle).Field64 = value;
}
public void SetObjectFields68And6c(long handle, long value68, long value6c)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.Field68 = value68;
o.Field6c = value6c;
}
}
/// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its
/// anchor/pivot. This does not create or mutate an ordinary retained object.</summary>
public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor)
@@ -198,6 +248,7 @@ public sealed class GfxState
_rangeTransformFirst = first;
_rangeTransformCount = System.Math.Max(0, count);
_rangeTransform = new GfxObject { V18 = anchor };
MarkRetainedMutation();
}
}
@@ -205,13 +256,20 @@ public sealed class GfxState
public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
{
lock (_lock)
{
_rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
MarkRetainedMutation();
}
}
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
{
lock (_lock) _rangeTransform.TranslationCurrent = translation;
lock (_lock)
{
_rangeTransform.TranslationCurrent = translation;
MarkRetainedMutation();
}
}
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
@@ -224,6 +282,7 @@ public sealed class GfxState
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
_rangeTransform.ScaleEnabled = durationMs > 0;
_rangeTransform.OneShotStartMs = -1;
MarkRetainedMutation();
}
}
@@ -233,6 +292,7 @@ public sealed class GfxState
lock (_lock)
{
if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
bool destinationIsNew = !_objects.ContainsKey(destinationHandle);
_objects[destinationHandle] = new GfxObject
{
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
@@ -258,7 +318,9 @@ public sealed class GfxState
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
};
if (destinationIsNew) InsertOrderedHandle(destinationHandle);
CurrentObject = destinationHandle;
MarkRetainedMutation();
return true;
}
}
@@ -280,7 +342,10 @@ public sealed class GfxState
{
lock (_lock)
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
{
obj.SourceSlot = toSlot;
MarkRetainedMutation();
}
}
public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
@@ -288,7 +353,12 @@ public sealed class GfxState
{
lock (_lock) // re-entrant: EraseRange already holds _lock
{
_objects.Remove(handle);
if (_objects.Remove(handle))
{
int index = _orderedObjectHandles.BinarySearch(handle);
if (index >= 0) _orderedObjectHandles.RemoveAt(index);
MarkRetainedMutation();
}
}
}
@@ -312,7 +382,9 @@ public sealed class GfxState
lock (_lock)
{
_objects.Clear();
_orderedObjectHandles.Clear();
CurrentObject = 0;
MarkRetainedMutation();
}
}
@@ -324,6 +396,7 @@ public sealed class GfxState
lock (_lock)
{
_objects.Clear();
_orderedObjectHandles.Clear();
_fieldTable.Clear();
_surfaces.Clear();
_createdSurfaces.Clear();
@@ -339,6 +412,9 @@ public sealed class GfxState
AnimationServiceFlags = 0;
PreviousFrameTimeMilliseconds = 0;
CurrentFrameTimeMilliseconds = 0;
_previousFrameTimeMs = 0;
_currentFrameTimeMs = 0;
MarkRetainedMutation();
}
}
@@ -360,6 +436,7 @@ public sealed class GfxState
_surfaces[slot] = (resId, colorKey);
_createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot);
MarkRetainedMutation();
}
}
@@ -399,6 +476,7 @@ public sealed class GfxState
}
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
CurrentRenderTargetSlot = -1;
MarkRetainedMutation();
}
}
@@ -498,6 +576,7 @@ public sealed class GfxState
_surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key
_createdSurfaces.Add(slot);
_movieStopTimesMs.Remove(slot);
MarkRetainedMutation();
}
}
@@ -509,6 +588,7 @@ public sealed class GfxState
_createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot);
MarkRetainedMutation();
}
}
@@ -526,6 +606,7 @@ public sealed class GfxState
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
};
MarkRetainedMutation();
}
}
@@ -684,6 +765,7 @@ public sealed class GfxState
int completed = 0;
foreach (var t in _surfaceTransitions.Values)
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
if (completed > 0) MarkRetainedMutation();
return completed;
}
}
@@ -883,7 +965,12 @@ public sealed class GfxState
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
public void SetAnimClock(long durationTicks)
{
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
lock (_lock)
{
AnimClockDurationTicks = durationTicks;
AnimClockGeneration++;
MarkRetainedMutation();
}
}
public void SetAnimationServiceFlags(long flags)
@@ -895,6 +982,8 @@ public sealed class GfxState
{
lock (_lock)
{
_previousFrameTimeMs = _currentFrameTimeMs;
_currentFrameTimeMs = nowMilliseconds;
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
}
@@ -910,9 +999,65 @@ public sealed class GfxState
ForceCompleteOneShotChannels();
AnimClockDurationTicks = 0;
AnimClockGeneration++;
MarkRetainedMutation();
}
}
/// <summary>Sample the shared native frame clock and report why the retained scene needs publishing.
/// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared
/// previous/current samples select different cells. Retained VM writes are published exactly once.</summary>
public GfxPresentationReason ConsumePresentationReasons(long nowMs)
{
lock (_lock)
{
_previousFrameTimeMs = _currentFrameTimeMs;
_currentFrameTimeMs = nowMs;
PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs);
CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs);
GfxPresentationReason reasons = GfxPresentationReason.None;
if (_publishedMutationGeneration != _mutationGeneration)
{
_publishedMutationGeneration = _mutationGeneration;
reasons |= GfxPresentationReason.RetainedMutation;
}
if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible &&
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled ||
(o.ColorAnim && o.ColorPeriod > 0) ||
(o.RotationEnabled && o.RotationPeriodMs > 0))))
reasons |= GfxPresentationReason.ContinuousChannel;
foreach (var o in _objects.Values)
{
if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue;
if (o.SrcStart < 0)
{
// Prototype clones made before first publication all enter here in the same shared sample,
// reproducing FIELD's native phase lock.
o.SrcStart = nowMs;
continue;
}
if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs))
{
reasons |= GfxPresentationReason.DiscreteSourceCell;
break;
}
}
return reasons;
}
}
private static long SourceCellAt(GfxObject o, long nowMs)
{
long elapsed = System.Math.Max(0, nowMs - o.SrcStart);
return elapsed / o.SrcPeriod % o.SrcFrameCount;
}
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
/// callers and existing tests.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
@@ -925,10 +1070,22 @@ public sealed class GfxState
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
{
var list = new List<RenderObject>();
SnapshotVisibleObjects(nowMs, list);
return list;
}
/// <summary>Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing
/// array survives across frames; callers that need an independently retained snapshot should use the
/// returning overload.</summary>
public void SnapshotVisibleObjects(long nowMs, List<RenderObject> list)
{
ArgumentNullException.ThrowIfNull(list);
lock (_lock)
{
var list = new List<RenderObject>();
list.Clear();
Affine2D? rangeAffine = null;
bool rangeTimeVarying = false;
if (_rangeTransformCount > 0)
{
var r = _rangeTransform;
@@ -944,15 +1101,16 @@ public sealed class GfxState
ref r.TranslationEnabled, nowMs);
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
r.OneShotStartMs = -1;
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
rangeAffine = Transform2DMath.Build(new TransformState(
rangeScale.X, rangeScale.Y, rangeScale.Z,
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
r.V18.X, r.V18.Y, r.V18.Z,
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
}
foreach (var kv in _objects.OrderBy(k => k.Key))
foreach (long handle in _orderedObjectHandles)
{
var o = kv.Value;
var o = _objects[handle];
if (!o.Visible) continue;
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
o.RotationChannelEnabled || o.TranslationEnabled;
@@ -1064,9 +1222,17 @@ public sealed class GfxState
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
? SampleTransition(st, nowMs) : null;
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount
handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
? ra : null;
list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
bool timeVarying =
o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled ||
(o.SrcAnim && o.SrcPeriod > 0) ||
(o.ColorAnim && o.ColorPeriod > 0) ||
(o.RotationEnabled && o.RotationPeriodMs > 0) ||
transition is { Progress: < 1.0 } ||
(objectRangeTransform != null && rangeTimeVarying);
list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y,
new TransformState(scale.X, scale.Y, scale.Z,
translation.X, translation.Y, translation.Z,
@@ -1076,12 +1242,17 @@ public sealed class GfxState
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, multiplyTint, transition,
colorTransition, objectRangeTransform));
colorTransition, objectRangeTransform, timeVarying));
}
return list;
}
}
private void InsertOrderedHandle(long handle)
{
int index = _orderedObjectHandles.BinarySearch(handle);
if (index < 0) _orderedObjectHandles.Insert(~index, handle);
}
private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs)
{
long current = o.Color & 0xffffffff;

View File

@@ -13,14 +13,23 @@ public static class SoftwareAffineRasterizer
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
if (ia == 0) return;
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
bool unmodulatedSourceOver = ia == 255 && istr == 0 && !multiplyTint && blend != BlendKind.Additive;
if (TryIntegerTranslation(localToDest, out int tx, out int ty))
{
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend);
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend, unmodulatedSourceOver);
return;
}
if (!localToDest.TryInverse(out var inv)) return;
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
if (x1 <= x0 || y1 <= y0) return;
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
{
BlitAxisAligned(dst, dstW, src, srcW, srcX, srcY, width, height, inv,
x0, y0, x1, y1, tr, tg, tb, istr, ia, multiplyTint, blend,
unmodulatedSourceOver);
return;
}
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
{
var p = inv.Apply(x + 0.5, y + 0.5);
@@ -48,6 +57,12 @@ public static class SoftwareAffineRasterizer
}
if (!localToDest.TryInverse(out var inv)) return;
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
if (x1 <= x0 || y1 <= y0) return;
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
{
FillAxisAligned(dst, dstW, width, height, inv, x0, y0, x1, y1, r, g, b, a);
return;
}
for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){
var p=inv.Apply(x+.5,y+.5);
if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a);
@@ -69,11 +84,16 @@ public static class SoftwareAffineRasterizer
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, int tx, int ty,
int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
BlendKind blend)
BlendKind blend, bool unmodulatedSourceOver)
{
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
int x1 = (int)System.Math.Min(dstW, (long)tx + width);
int y1 = (int)System.Math.Min(dstH, (long)ty + height);
if (unmodulatedSourceOver)
{
BlitTranslatedUnmodulated(dst, dstW, src, srcW, srcX, srcY, tx, ty, x0, y0, x1, y1);
return;
}
for (int y = y0; y < y1; y++)
{
int v = y - ty;
@@ -91,6 +111,20 @@ public static class SoftwareAffineRasterizer
}
}
private static void BlitTranslatedUnmodulated(byte[] dst, int dstW, byte[] src, int srcW,
int srcX, int srcY, int tx, int ty,
int x0, int y0, int x1, int y1)
{
for (int y = y0; y < y1; y++)
{
int sourceRow = (srcY + y - ty) * srcW + srcX - tx;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
BlendUnmodulatedSourceOver(dst, (destinationRow + x) * 4,
src, (sourceRow + x) * 4);
}
}
private static void FillTranslated(byte[] dst, int dstW, int dstH, int width, int height,
int tx, int ty, int r, int g, int b, int a)
{
@@ -102,6 +136,102 @@ public static class SoftwareAffineRasterizer
Blend(dst, (y * dstW + x) * 4, r, g, b, a);
}
// Axis-aligned scale is FIELD's dominant non-integer path. Its inverse source column is independent
// of destination Y, and its source row is independent of destination X. Cache the former once per
// layer and compute the latter once per scanline while preserving the general path's exact center-
// sample/floor arithmetic. Pooled lookup storage avoids both per-layer garbage and repeated-stackalloc
// growth when the JIT inlines this hot path; the 4096-column caller gate keeps the rented bucket small.
private static void BlitAxisAligned(byte[] dst, int dstW, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D inv,
int x0, int y0, int x1, int y1,
int tr, int tg, int tb, int istr, int ia,
bool multiplyTint, BlendKind blend, bool unmodulatedSourceOver)
{
int count = x1 - x0;
int[] sourceColumns = System.Buffers.ArrayPool<int>.Shared.Rent(count);
try
{
for (int x = x0; x < x1; x++)
{
int u = (int)System.Math.Floor(inv.Apply(x + 0.5, y0 + 0.5).X);
sourceColumns[x - x0] = (uint)u < (uint)width ? u : -1;
}
for (int y = y0; y < y1; y++)
{
int v = (int)System.Math.Floor(inv.Apply(x0 + 0.5, y + 0.5).Y);
if ((uint)v >= (uint)height) continue;
int sourceRow = (srcY + v) * srcW;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
{
int u = sourceColumns[x - x0];
if (u < 0) continue;
int si = (sourceRow + srcX + u) * 4;
int di = (destinationRow + x) * 4;
if (unmodulatedSourceOver)
{
BlendUnmodulatedSourceOver(dst, di, src, si);
continue;
}
int sa = src[si + 3] * ia / 255;
if (sa == 0) continue;
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
Blend(dst, di, sr, sg, sb, sa, blend);
}
}
}
finally { System.Buffers.ArrayPool<int>.Shared.Return(sourceColumns); }
}
// Most FIELD layers have full object opacity and no tint. Preserve color-key transparency and partially
// transparent edge texels, but make the overwhelmingly common alpha-255 case a four-byte copy instead of
// performing tint and source-over multiply/divide work whose result is exactly the source texel.
private static void BlendUnmodulatedSourceOver(byte[] dst, int di, byte[] src, int si)
{
int a = src[si + 3];
if (a == 0) return;
if (a == 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
return;
}
int inverse = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inverse) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inverse) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inverse) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + a);
}
private static void FillAxisAligned(byte[] dst, int dstW, int width, int height, Affine2D inv,
int x0, int y0, int x1, int y1,
int r, int g, int b, int a)
{
int count = x1 - x0;
bool[] includedColumns = System.Buffers.ArrayPool<bool>.Shared.Rent(count);
try
{
for (int x = x0; x < x1; x++)
{
double u = inv.Apply(x + 0.5, y0 + 0.5).X;
includedColumns[x - x0] = u >= 0 && u < width;
}
for (int y = y0; y < y1; y++)
{
double v = inv.Apply(x0 + 0.5, y + 0.5).Y;
if (v < 0 || v >= height) continue;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
if (includedColumns[x - x0]) Blend(dst, (destinationRow + x) * 4, r, g, b, a);
}
}
finally { System.Buffers.ArrayPool<bool>.Shared.Return(includedColumns); }
}
private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1)
{
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);

View File

@@ -40,46 +40,74 @@ public static class Transform2DMath
{
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
{
double[] m = Identity();
Matrix3D m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m[0], m[1], m[4], m[5], m[12], m[13]);
return new(m.M11, m.M12, m.M21, m.M22, m.TX, m.TY);
}
public static (double X, double Y) Apply(double x, double y, TransformState transform,
RotationCycleState cycle = default)
=> Build(transform, cycle).Apply(x, y);
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 };
private static double[] Scale(double x, double y, double z)
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 };
private static double[] Translation(double x, double y, double z)
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 };
// AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only
// the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated
// about eleven arrays per rendered object, or roughly 2.2 MB on every DEBUGMAP composition.
private readonly record struct Matrix3D(
double M11, double M12, double M13,
double M21, double M22, double M23,
double M31, double M32, double M33,
double TX, double TY, double TZ);
private static double[] AxisAngle(double x, double y, double z, double degrees)
private static Matrix3D Identity() => new(
1,0,0, 0,1,0, 0,0,1, 0,0,0);
private static Matrix3D Scale(double x, double y, double z) => new(
x,0,0, 0,y,0, 0,0,z, 0,0,0);
private static Matrix3D Translation(double x, double y, double z) => new(
1,0,0, 0,1,0, 0,0,1, x,y,z);
private static Matrix3D AxisAngle(double x, double y, double z, double degrees)
{
double len = System.Math.Sqrt(x*x + y*y + z*z);
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
x /= len; y /= len; z /= len;
double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c;
return new double[] {
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0,
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0,
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0,
0,0,0,1
};
return new(
x*x*q+c, x*y*q+z*s, x*z*q-y*s,
x*y*q-z*s, y*y*q+c, y*z*q+x*s,
x*z*q+y*s, y*z*q-x*s, z*z*q+c,
0,0,0);
}
private static double[] Mul(double[] a, double[] b)
private static Matrix3D Mul(Matrix3D a, Matrix3D b) => new(
Sum4(a.M11*b.M11, a.M12*b.M21, a.M13*b.M31, 0),
Sum4(a.M11*b.M12, a.M12*b.M22, a.M13*b.M32, 0),
Sum4(a.M11*b.M13, a.M12*b.M23, a.M13*b.M33, 0),
Sum4(a.M21*b.M11, a.M22*b.M21, a.M23*b.M31, 0),
Sum4(a.M21*b.M12, a.M22*b.M22, a.M23*b.M32, 0),
Sum4(a.M21*b.M13, a.M22*b.M23, a.M23*b.M33, 0),
Sum4(a.M31*b.M11, a.M32*b.M21, a.M33*b.M31, 0),
Sum4(a.M31*b.M12, a.M32*b.M22, a.M33*b.M32, 0),
Sum4(a.M31*b.M13, a.M32*b.M23, a.M33*b.M33, 0),
Sum4(a.TX*b.M11, a.TY*b.M21, a.TZ*b.M31, b.TX),
Sum4(a.TX*b.M12, a.TY*b.M22, a.TZ*b.M32, b.TY),
Sum4(a.TX*b.M13, a.TY*b.M23, a.TZ*b.M33, b.TZ));
// Accumulate in the same order as the former 4x4 loop so boundary-sensitive nearest-neighbour
// projection retains its floating-point behavior while avoiding an intermediate array.
private static double Sum4(double a, double b, double c, double d)
{
var o = new double[16];
for (int row=0; row<4; row++)
for (int col=0; col<4; col++)
for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col];
return o;
double result = 0;
result += a;
result += b;
result += c;
result += d;
return result;
}
}

View File

@@ -1712,16 +1712,16 @@ public sealed class VirtualMachine
Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1;
}
case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18
Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
Gfx.SetObjectAnchor(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
Gfx.SetObjectPosition(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9
case "set-default-gfx-object-slot":
Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1;
// ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ----
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1;
Gfx.SetObjectPosition(Read(a[0]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
case "u004219E0": // pre-reference compatibility
case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z)
Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
@@ -1782,10 +1782,10 @@ public sealed class VirtualMachine
case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix
Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "set-gfx-field64": // 0x212 (idx)(val)
Gfx.GetOrCreate(Read(a[0])).Field64 = Read(a[1]); return pc + 1;
Gfx.SetObjectField64(Read(a[0]), Read(a[1])); return pc + 1;
case "set-gfx-xy": // 0x213 (idx)(x)(y)
{
var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1;
Gfx.SetObjectFields68And6c(Read(a[0]), Read(a[1]), Read(a[2])); return pc + 1;
}
case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;