feat: add affine rotation rendering and timeline diagnostics

This commit is contained in:
gamer147
2026-07-10 17:54:33 -04:00
parent 014d128ccd
commit bb16a5496a
16 changed files with 686 additions and 150 deletions

View File

@@ -2,13 +2,17 @@ using System.Linq;
namespace Age.Engine.Model;
/// <summary>The sampled native matrix channels carried to the compositor. Op 0x21e owns scale; op 0x220 owns
/// translation. Z is retained for model fidelity even though the current 2D compositor uses X/Y only.</summary>
/// <summary>The sampled native one-shot channels carried to the compositor: op 0x21e scale, op 0x21f
/// axis-angle rotation, and op 0x220 translation. Z is retained through full 4x4 composition.</summary>
public readonly record struct TransformState(double ScaleX, double ScaleY, double ScaleZ,
double TranslateX, double TranslateY, double TranslateZ,
double AnchorX, double AnchorY, double AnchorZ);
double AnchorX, double AnchorY, double AnchorZ,
double RotationAxisX = 0, double RotationAxisY = 0,
double RotationAxisZ = 0, double RotationAngleDegrees = 0);
public readonly record struct RotationCycleState(bool Enabled, long PeriodMs, long AxisX, long AxisY, long AxisZ);
public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
double AxisX, double AxisY, double AxisZ,
double AngleDegrees = 0);
/// <summary>A renderable view of one visible gfx object — the host composites these in ascending-handle order
/// (= the engine's z-order) each frame. Built by <see cref="GfxState.SnapshotVisibleObjects"/>; the surface
@@ -60,6 +64,10 @@ public sealed class GfxState
public (double X, double Y, double Z) TranslationCurrent, TranslationTarget;
public long TranslationDelayMs, TranslationDurationMs;
public bool TranslationEnabled;
public (double X, double Y, double Z, double Angle) RotationCurrent;
public (double X, double Y, double Z, double Angle) RotationTarget;
public long RotationDelayMs, RotationDurationMs;
public bool RotationChannelEnabled;
// Shared matrix-channel start timestamp obj+0x34, seeded from frame-time ctx+0xb550.
public long MatrixStartMs = -1;
@@ -67,6 +75,7 @@ public sealed class GfxState
public long RotationPeriodMs;
public (long X, long Y, long Z) RotationAxis;
public bool RotationEnabled;
public long RotationStartMs = -1;
}
// ---- geometry/draw object store (V18/V24/draw bind, the compositor's input) ----
@@ -217,6 +226,19 @@ public sealed class GfxState
}
}
/// <summary>Op 0x21f: delayed one-shot axis-angle rotation target, sharing obj+0x34's start timestamp.</summary>
public void SetRotationChannel(long handle, long delayMs, long durationMs,
(long X, long Y, long Z) axis, long angleDegrees)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.RotationDelayMs = delayMs; o.RotationDurationMs = durationMs;
o.RotationTarget = (axis.X, axis.Y, axis.Z, angleDegrees);
o.RotationChannelEnabled = durationMs > 0; o.MatrixStartMs = -1;
}
}
/// <summary>Op 0x234: retain the cyclic rotation period and axis separately. Native interpolation uses
/// frame-time ctx+0xb550 and rotates through 360 degrees per period; affine rendering is deferred.</summary>
public void SetRotationCycle(long handle, long periodMs, (long X, long Y, long Z) axis)
@@ -225,6 +247,7 @@ public sealed class GfxState
{
var o = GetOrCreate(handle);
o.RotationPeriodMs = periodMs; o.RotationAxis = axis; o.RotationEnabled = periodMs > 0;
o.RotationStartMs = -1;
}
}
@@ -299,22 +322,35 @@ public sealed class GfxState
}
// One-shot matrix channels: hold current through delay, then linearly sample current -> target.
if ((o.ScaleEnabled || o.TranslationEnabled) && o.MatrixStartMs < 0) o.MatrixStartMs = nowMs;
if ((o.ScaleEnabled || o.RotationChannelEnabled || o.TranslationEnabled) && o.MatrixStartMs < 0)
o.MatrixStartMs = nowMs;
var scale = SampleMatrixChannel(ref o.ScaleCurrent, o.ScaleTarget, o.ScaleDelayMs,
o.ScaleDurationMs, o.MatrixStartMs, ref o.ScaleEnabled, nowMs);
var rotation = SampleRotationChannel(ref o.RotationCurrent, o.RotationTarget,
o.RotationDelayMs, o.RotationDurationMs,
o.MatrixStartMs, ref o.RotationChannelEnabled, nowMs);
var translation = SampleMatrixChannel(ref o.TranslationCurrent, o.TranslationTarget,
o.TranslationDelayMs, o.TranslationDurationMs,
o.MatrixStartMs, ref o.TranslationEnabled, nowMs);
if (!o.ScaleEnabled && !o.TranslationEnabled) o.MatrixStartMs = -1;
if (!o.ScaleEnabled && !o.RotationChannelEnabled && !o.TranslationEnabled) o.MatrixStartMs = -1;
double cycleAngle = 0;
if (o.RotationEnabled && o.RotationPeriodMs > 0)
{
if (o.RotationStartMs < 0) o.RotationStartMs = nowMs;
long elapsed = System.Math.Max(0, nowMs - o.RotationStartMs);
cycleAngle = ((elapsed % o.RotationPeriodMs) * 360) / o.RotationPeriodMs;
}
list.Add(new RenderObject(kv.Key, 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,
o.V18.X, o.V18.Y, o.V18.Z),
o.V18.X, o.V18.Y, o.V18.Z,
rotation.X, rotation.Y, rotation.Z, rotation.Angle),
new RotationCycleState(o.RotationEnabled, o.RotationPeriodMs,
o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z),
o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend));
}
return list;
@@ -341,6 +377,22 @@ public sealed class GfxState
current.Z + (target.Z - current.Z) * t);
}
private static (double X, double Y, double Z, double Angle) SampleRotationChannel(
ref (double X, double Y, double Z, double Angle) current,
(double X, double Y, double Z, double Angle) target,
long delayMs, long durationMs, long startMs, ref bool enabled, long nowMs)
{
if (!enabled || durationMs <= 0 || startMs < 0) return current;
long elapsed = nowMs - startMs - delayMs;
if (elapsed <= 0) return current;
if (elapsed >= durationMs) { current = target; enabled = false; return current; }
double t = (double)elapsed / durationMs;
return (current.X + (target.X - current.X) * t,
current.Y + (target.Y - current.Y) * t,
current.Z + (target.Z - current.Z) * t,
current.Angle + (target.Angle - current.Angle) * t);
}
/// <summary>Ping-pong interpolation weight in [0,1] toward the target: 0 at cycle start, 1 at half-period.</summary>
private static double PingPongWeight(long now, long start, long period)
{

View File

@@ -0,0 +1,54 @@
namespace Age.Engine.Model;
/// <summary>Nearest-neighbour inverse-mapped RGBA8 affine compositor used by the Godot host and pure tests.</summary>
public static class SoftwareAffineRasterizer
{
public static void BlitRgba(byte[] dst, int dstW, int dstH, byte[] src, int srcW, int srcH,
int srcX, int srcY, int width, int height, Affine2D localToDest,
long tint, float tintStrength, float opacity)
{
if (width <= 0 || height <= 0 || !localToDest.TryInverse(out var inv)) return;
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
int istr = (int)(System.Math.Clamp(tintStrength, 0f, 1f) * 255);
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
{
var p = inv.Apply(x + 0.5, y + 0.5);
int u=(int)System.Math.Floor(p.X), v=(int)System.Math.Floor(p.Y);
if ((uint)u >= (uint)width || (uint)v >= (uint)height) continue;
int si=((srcY+v)*srcW+(srcX+u))*4, di=(y*dstW+x)*4;
int sa=src[si+3]*ia/255; if(sa==0) continue;
int sr=(src[si]*(255-istr)+tr*istr)/255;
int sg=(src[si+1]*(255-istr)+tg*istr)/255;
int sb=(src[si+2]*(255-istr)+tb*istr)/255;
Blend(dst,di,sr,sg,sb,sa);
}
}
public static void FillRgba(byte[] dst, int dstW, int dstH, int width, int height,
Affine2D localToDest, long color, float opacity)
{
if (width <= 0 || height <= 0 || !localToDest.TryInverse(out var inv)) return;
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
int a=(int)(System.Math.Clamp(opacity,0f,1f)*255); if(a==0)return;
int r=(int)(color>>16&255),g=(int)(color>>8&255),b=(int)(color&255);
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);
}
}
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);
x0=System.Math.Max(0,(int)System.Math.Floor(System.Math.Min(System.Math.Min(a.X,b.X),System.Math.Min(c.X,d.X))));
y0=System.Math.Max(0,(int)System.Math.Floor(System.Math.Min(System.Math.Min(a.Y,b.Y),System.Math.Min(c.Y,d.Y))));
x1=System.Math.Min(dw,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.X,b.X),System.Math.Max(c.X,d.X))));
y1=System.Math.Min(dh,(int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y,b.Y),System.Math.Max(c.Y,d.Y))));
}
private static void Blend(byte[] d,int i,int r,int g,int b,int a){
d[i]=(byte)((r*a+d[i]*(255-a))/255);d[i+1]=(byte)((g*a+d[i+1]*(255-a))/255);
d[i+2]=(byte)((b*a+d[i+2]*(255-a))/255);d[i+3]=(byte)System.Math.Min(255,d[i+3]+a);
}
}

View File

@@ -1,11 +1,74 @@
namespace Age.Engine.Model;
/// <summary>The axis-aligned 2D reduction of AGE's row-vector object matrix. Native composition is
/// T(-anchor) * scale * middle(rotation) * translation * T(anchor). With rotation deferred, a point is
/// therefore anchor + (point-anchor)*scale + translation; Z remains a retained 3D channel, not opacity.</summary>
/// <summary>A projected row-vector affine transform: x'=x*XX+y*YX+TX, y'=x*XY+y*YY+TY.</summary>
public readonly record struct Affine2D(double XX, double XY, double YX, double YY, double TX, double TY)
{
public (double X, double Y) Apply(double x, double y)
=> (x * XX + y * YX + TX, x * XY + y * YY + TY);
public Affine2D FromLocalOrigin(double worldX, double worldY)
{
var p = Apply(worldX, worldY);
return new(XX, XY, YX, YY, p.X, p.Y);
}
public bool TryInverse(out Affine2D inverse)
{
double det = XX * YY - XY * YX;
if (System.Math.Abs(det) < 1e-12) { inverse = default; return false; }
double xx = YY / det, xy = -XY / det, yx = -YX / det, yy = XX / det;
inverse = new(xx, xy, yx, yy, -(TX * xx + TY * yx), -(TX * xy + TY * yy));
return true;
}
}
/// <summary>Exact 2D projection of AGE's row-vector retained-object matrix. Native call order is anchored
/// scale, one-shot axis-angle rotation, translation, then separately anchored cyclic rotation. The adjacent
/// anchor translations cancel, yielding T(-a)*S*R1*T*Rcycle*T(+a). Z is projected away only afterward.</summary>
public static class Transform2DMath
{
public static (double X, double Y) Apply(double x, double y, TransformState transform)
=> (transform.AnchorX + (x - transform.AnchorX) * transform.ScaleX + transform.TranslateX,
transform.AnchorY + (y - transform.AnchorY) * transform.ScaleY + transform.TranslateY);
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
{
double[] 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]);
}
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 };
private static double[] 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
};
}
private static double[] Mul(double[] a, double[] b)
{
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;
}
}