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

@@ -7,6 +7,17 @@ using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
[Flags]
public enum HostPresentationReason
{
None = 0,
HostRequest = 1,
ScreenTransition = 2,
RetainedMutation = 4,
ContinuousChannel = 8,
DiscreteSourceCell = 16,
}
[SupportedOSPlatform("windows")]
public sealed class GodotAdvHost : IHost
{
@@ -185,6 +196,16 @@ public sealed class GodotAdvHost : IHost
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
}
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
lock (_textLock)
{
snapshot.Clear();
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
}
}
public void ClearRenderedAdvTextLayout(int layoutSlot)
{
lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot);
@@ -481,7 +502,12 @@ public sealed class GodotAdvHost : IHost
}
public void InputCallbackCompleted(GfxState gfx)
=> Interlocked.Exchange(ref _presentRequested, 1);
{
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
// callback are published through GfxState's mutation generation; host-owned surface writes set
// _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even
// while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw.
}
public long InputClockMilliseconds => _clock.NowMs;
@@ -639,12 +665,22 @@ public sealed class GodotAdvHost : IHost
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// once, then continue only while the sampled retained scene can actually change. Text reveal is a separate
// Godot Label; waiting/sleeping alone do not alter background pixels.
public bool ShouldRecomposite(GfxState gfx)
public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
{
bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 ||
screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs);
var reasons = HostPresentationReason.None;
if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0)
reasons |= HostPresentationReason.HostRequest;
if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition;
GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs);
if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0)
reasons |= HostPresentationReason.RetainedMutation;
if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0)
reasons |= HostPresentationReason.ContinuousChannel;
if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0)
reasons |= HostPresentationReason.DiscreteSourceCell;
return reasons;
}
public void Stop()
@@ -760,9 +796,6 @@ public sealed class GodotAdvHost : IHost
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
// A sleep is a service boundary: make preceding retained writes visible once even when no animation
// channel is active during the hold.
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
IsSleeping = true;
while (_clock.NowMs < deadline)
{

View File

@@ -97,6 +97,12 @@ public sealed class GodotTraceSink : ITraceSink
}
}
/// <summary>Allocation-free current coordinate for once-per-frame diagnostics.</summary>
public GodotTraceStepSnapshot? LatestStep
{
get { lock (_snapshotLock) return _latestStep; }
}
private string[] CurrentCallStackLocked()
{
var stack = _scripts.ToArray();

View File

@@ -86,6 +86,8 @@ public partial class Main : Godot.Control
private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
private GodotTimelineLog? _timeline;
private int _timelineFrame;
private string? _perfLogPath; // --perf-log <csv>: low-overhead frame/compositor timings + work
private PerformanceFrameLog? _perf;
public override void _Ready()
{
@@ -184,6 +186,7 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1];
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = userArgs[i + 1];
if (userArgs[i] == "--perf-log" && i + 1 < userArgs.Length) _perfLogPath = userArgs[i + 1];
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
@@ -225,6 +228,7 @@ public partial class Main : Godot.Control
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
_trace = new GodotTraceSink(_locator, _timeline);
if (_perfLogPath != null) _perf = new PerformanceFrameLog(_perfLogPath);
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
_table = table;
@@ -322,51 +326,80 @@ public partial class Main : Godot.Control
_clock.Advance(delta);
_timelineFrame++;
_timeline?.SetFrame(_timelineFrame, _clock.NowMs);
_host?.PulseFrame();
UpdateVoicePlaybackState();
AdoptPendingMovies();
UpdateMovieFrames();
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx))
Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
var perf = _perf;
var step = perf != null ? _trace.LatestStep : null;
perf?.BeginFrame(_timelineFrame, _clock.NowMs, delta,
step?.Script ?? "<startup>", step?.Offset ?? -1, step?.Opcode ?? -1);
try
{
System.IO.Directory.CreateDirectory(_seqDir);
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
var fimg = GetViewport().GetTexture()?.GetImage();
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
_seqIdx++;
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); }
return;
}
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
{
if (++_shotSettle >= _shotSettleTarget)
long phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
_host?.PulseFrame();
perf?.RecordPulse(PerformanceFrameLog.Timestamp() - phase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
UpdateVoicePlaybackState();
AdoptPendingMovies();
UpdateMovieFrames();
perf?.RecordMovies(PerformanceFrameLog.Timestamp() - phase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
HostPresentationReason presentationReasons = !_selftest && _vm != null && _host != null
? _host.ConsumePresentationReasons(_vm.Gfx)
: HostPresentationReason.None;
bool shouldRecomposite = presentationReasons != HostPresentationReason.None;
perf?.RecordPresentationReasons((int)presentationReasons);
perf?.RecordShouldRecomposite(PerformanceFrameLog.Timestamp() - phase);
long allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (shouldRecomposite)
Recomposite(); // native publishes retained mutations only at present/service boundaries
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
{
_shotDone = true;
var img = GetViewport().GetTexture().GetImage();
img.SavePng(_shotPath);
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
ReportSubroutines();
GetTree().Quit(0);
System.IO.Directory.CreateDirectory(_seqDir);
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
var fimg = GetViewport().GetTexture()?.GetImage();
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
_seqIdx++;
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); }
return;
}
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
{
if (++_shotSettle >= _shotSettleTarget)
{
_shotDone = true;
var img = GetViewport().GetTexture().GetImage();
img.SavePng(_shotPath);
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
ReportSubroutines();
GetTree().Quit(0);
}
return;
}
if (_done && !_ended)
{
_ended = true;
DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_selftest) RunSelfTest();
}
return;
}
if (_done && !_ended)
{
_ended = true;
DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_selftest) RunSelfTest();
}
finally { perf?.EndFrame(); }
}
// _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they
@@ -688,6 +721,12 @@ public partial class Main : Godot.Control
public override void _ExitTree()
{
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
if (_perf != null)
{
_perf.Dispose();
GD.Print($"[perf-log] wrote {_perf.FrameCount} frames / {_perf.RecompositeCount} recomposites -> {_perf.Path}");
_perf = null;
}
foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose();
_pendingMovies.Clear();
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
@@ -718,30 +757,62 @@ public partial class Main : Godot.Control
// GfxState and applied here; object opacity comes only from the actual blend/color path.
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
private void Recomposite()
{
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition);
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
_perf?.BeginRecomposite(hasScreenTransition);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
System.Array.Clear(_screenPixels);
foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (_host.TrySnapshotScreenTransition(out var transition))
if (hasScreenTransition)
{
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
ref surfaceTextLabelIndex, decisions, false);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
else
{
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges
CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); // synchronized objects + ranges
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
CompositeVisibleObjects(_visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
_screenTex.Update(_screen);
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
if (decisions != null) LogGfxDecisionChanges(decisions);
_perf?.EndRecomposite();
}
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
@@ -752,6 +823,7 @@ public partial class Main : Godot.Control
int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock
{
_perf?.RecordObject(v.TimeVarying);
var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
@@ -763,16 +835,23 @@ public partial class Main : Godot.Control
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
float strength = v.TintStrength / 255f; // tint-blend / fill strength
var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
string outcome;
// These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one
// thousand retained objects per composition, so formatting them unconditionally creates
// several megabytes of short-lived garbage even in an ordinary run.
string? outcome = null;
if (v.SurfaceTransition is { } transition)
{
_perf?.RecordTransitionLayer();
int layers = DrawTransitionRange(visible, transition);
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
if (decisions != null)
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
}
else if (v.SurfaceResId == 0 && surfaceTexture == null)
{
@@ -785,37 +864,54 @@ public partial class Main : Godot.Control
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
// the tint-strength convention used by the existing effect objects.
float fillA = v.MultiplyTint ? opacity : opacity * strength;
_perf?.RecordFillLayer();
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
if (decisions != null)
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
}
else
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)";
}
else outcome = "SKIP(no-resId, opaque render-target)";
}
else
{
var texture = surfaceTexture
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
var texture = surfaceTexture;
if (texture == null && !movieSurfaceBound)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
}
else
{
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
if (decisions != null)
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
}
}
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
if (includeSurfaceText && rawObject != null)
{
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot))
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
@@ -969,25 +1065,42 @@ public partial class Main : Godot.Control
{
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform)
affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque) continue;
if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600;
_perf?.RecordFillLayer();
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
}
else
{
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
if (texture == null) continue;
if (!movieSurfaceBound && texture == null)
{
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
continue;
}
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
texture.Value.IsDynamic, source.Blend);
@@ -1039,6 +1152,8 @@ public partial class Main : Godot.Control
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
if (w <= 0 || h <= 0) return;
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight;
byte[] sourcePixels;
@@ -1077,16 +1192,24 @@ public partial class Main : Godot.Control
int sh = h;
sw = System.Math.Min(sw, sourceWidth - srcX);
sh = System.Math.Min(sh, sourceHeight - srcY);
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
if (sw <= 0 || sh <= 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
_perf?.RecordRaster(sw, sh, localToDest, ScreenWidth, ScreenHeight, dynamic, blend,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
{
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
_screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
@@ -1094,6 +1217,7 @@ public partial class Main : Godot.Control
{
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
if (ia == 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screenPixels;
int dw = ScreenWidth, dh = ScreenHeight;
@@ -1110,6 +1234,10 @@ public partial class Main : Godot.Control
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
}
_perf?.RecordFillLayer();
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
}
// Make colorkey-matching texels transparent (native colorkey is baked at surface load).

View File

@@ -94,16 +94,18 @@ internal sealed class MovieSurfaceRegistry
{
lock (_lock)
{
foreach (var binding in _byPlayback.Values
.Where(binding => binding.ResourceId == resourceId)
.OrderByDescending(binding => binding.PlaybackId))
long newestPlaybackId = long.MinValue;
MovieSurfaceFrame? newestFrame = null;
foreach (var binding in _byPlayback.Values)
{
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
frame = found;
return true;
if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId ||
!_frames.TryGetValue(binding.PlaybackId, out var found))
continue;
newestPlaybackId = binding.PlaybackId;
newestFrame = found;
}
frame = null;
return false;
frame = newestFrame;
return newestFrame != null;
}
}

View File

@@ -0,0 +1,297 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using Age.Engine.Model;
/// <summary>
/// Buffered, diagnostic-only CSV writer for real Godot frame and retained-compositor cost. The writer is
/// deliberately independent of Godot types so its schema and clipping arithmetic can be unit tested.
/// </summary>
public sealed class PerformanceFrameLog : IDisposable
{
private const int FlushIntervalFrames = 120;
private static readonly double MillisecondsPerTick = 1000.0 / Stopwatch.Frequency;
private readonly StreamWriter _writer;
private Frame _current = new();
private bool _frameOpen;
private bool _disposed;
private int _framesSinceFlush;
public long FrameCount { get; private set; }
public long RecompositeCount { get; private set; }
public string Path { get; }
public PerformanceFrameLog(string path)
{
Path = path;
var directory = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
_writer = new StreamWriter(path, append: false, Encoding.UTF8, 64 * 1024);
// Presentation coordinates are appended separately so a VM thread released by PulseFrame can be
// distinguished from the coordinate observed at _Process entry.
_writer.WriteLine(
"frame,now_ms,delta_ms,main_ms,pulse_ms,movie_ms,should_recomposite_ms,recomposite_ms," +
"clear_ms,snapshot_ms,resolve_ms,source_prep_ms,raster_ms,set_data_ms,texture_update_ms,ui_ms," +
"allocated_bytes,recompose_allocated_bytes,snapshot_allocated_bytes," +
"composite_allocated_bytes,source_prep_allocated_bytes,set_data_allocated_bytes," +
"ui_allocated_bytes,gen0,gen1,gen2,recomposited,screen_transition," +
"present_host_request,present_screen_transition,present_retained_mutation," +
"present_continuous_channel,present_discrete_cell,object_visits," +
"time_varying_objects,draw_layers,fill_layers,transition_layers,skipped_layers," +
"integer_layers,fractional_translation_layers,axis_aligned_scale_layers," +
"general_affine_layers,affine_layers,singular_layers,dynamic_layers,opaque_layers,alpha_layers," +
"additive_layers,source_pixels,candidate_pixels,full_screen_layers,script,offset,opcode," +
"present_script,present_offset,present_opcode");
}
public static long Timestamp() => Stopwatch.GetTimestamp();
public static long AllocatedBytes() => GC.GetAllocatedBytesForCurrentThread();
public void BeginFrame(int frame, long nowMs, double deltaSeconds,
string script, int offset, int opcode)
{
if (_disposed) return;
if (_frameOpen) EndFrame();
_current = new Frame
{
Number = frame,
NowMs = nowMs,
DeltaMs = deltaSeconds * 1000.0,
Script = script,
Offset = offset,
Opcode = opcode,
Started = Timestamp(),
AllocatedStart = GC.GetAllocatedBytesForCurrentThread(),
Gen0Start = GC.CollectionCount(0),
Gen1Start = GC.CollectionCount(1),
Gen2Start = GC.CollectionCount(2),
};
_frameOpen = true;
}
public void RecordPulse(long ticks) => _current.PulseTicks += ticks;
public void RecordMovies(long ticks) => _current.MovieTicks += ticks;
public void RecordShouldRecomposite(long ticks) => _current.ShouldTicks += ticks;
public void RecordPresentationReasons(int reasons)
{
_current.PresentHostRequest |= (reasons & 1) != 0;
_current.PresentScreenTransition |= (reasons & 2) != 0;
_current.PresentRetainedMutation |= (reasons & 4) != 0;
_current.PresentContinuousChannel |= (reasons & 8) != 0;
_current.PresentDiscreteCell |= (reasons & 16) != 0;
}
public void RecordUi(long ticks) => _current.UiTicks += ticks;
public void RecordClear(long ticks) => _current.ClearTicks += ticks;
public void RecordSnapshot(long ticks) => _current.SnapshotTicks += ticks;
public void RecordResolve(long ticks) => _current.ResolveTicks += ticks;
public void RecordSourcePrep(long ticks) => _current.SourcePrepTicks += ticks;
public void RecordRecomposeAllocation(long bytes) => _current.RecomposeAllocatedBytes += Math.Max(0, bytes);
public void RecordSnapshotAllocation(long bytes) => _current.SnapshotAllocatedBytes += Math.Max(0, bytes);
public void RecordCompositeAllocation(long bytes) => _current.CompositeAllocatedBytes += Math.Max(0, bytes);
public void RecordSourcePrepAllocation(long bytes) => _current.SourcePrepAllocatedBytes += Math.Max(0, bytes);
public void RecordSetDataAllocation(long bytes) => _current.SetDataAllocatedBytes += Math.Max(0, bytes);
public void RecordUiAllocation(long bytes) => _current.UiAllocatedBytes += Math.Max(0, bytes);
public void RecordSetData(long ticks) => _current.SetDataTicks += ticks;
public void RecordTextureUpdate(long ticks) => _current.TextureUpdateTicks += ticks;
public void BeginRecomposite(bool screenTransition)
{
_current.Recomposited = true;
_current.ScreenTransition |= screenTransition;
_current.RecompositeStarted = Timestamp();
}
public void RecordPresentationCoordinate(string script, int offset, int opcode)
{
_current.PresentScript = script;
_current.PresentOffset = offset;
_current.PresentOpcode = opcode;
}
public void EndRecomposite()
{
if (_current.RecompositeStarted == 0) return;
_current.RecompositeTicks += Timestamp() - _current.RecompositeStarted;
_current.RecompositeStarted = 0;
}
public void RecordObject(bool timeVarying)
{
_current.ObjectVisits++;
if (timeVarying) _current.TimeVaryingObjects++;
}
public void RecordFillLayer() => _current.FillLayers++;
public void RecordTransitionLayer() => _current.TransitionLayers++;
public void RecordSkippedLayer() => _current.SkippedLayers++;
public void RecordRaster(int sourceWidth, int sourceHeight, Affine2D localToDest,
int destinationWidth, int destinationHeight, bool dynamic,
BlendKind blend, long ticks)
{
_current.DrawLayers++;
_current.RasterTicks += ticks;
_current.SourcePixels += Math.Max(0L, (long)sourceWidth * sourceHeight);
long candidates = EstimateCandidatePixels(localToDest, sourceWidth, sourceHeight,
destinationWidth, destinationHeight);
_current.CandidatePixels += candidates;
if (candidates >= (long)destinationWidth * destinationHeight) _current.FullScreenLayers++;
if (IsIntegerTranslation(localToDest)) _current.IntegerLayers++;
else if (!localToDest.TryInverse(out _)) _current.SingularLayers++;
else
{
_current.AffineLayers++;
if (IsTranslation(localToDest)) _current.FractionalTranslationLayers++;
else if (IsAxisAligned(localToDest)) _current.AxisAlignedScaleLayers++;
else _current.GeneralAffineLayers++;
}
if (dynamic) _current.DynamicLayers++;
switch (blend)
{
case BlendKind.Opaque: _current.OpaqueLayers++; break;
case BlendKind.Additive: _current.AdditiveLayers++; break;
default: _current.AlphaLayers++; break;
}
}
public void EndFrame()
{
if (!_frameOpen || _disposed) return;
long ended = Timestamp();
_current.MainTicks = ended - _current.Started;
_current.AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - _current.AllocatedStart);
_current.Gen0 = GC.CollectionCount(0) - _current.Gen0Start;
_current.Gen1 = GC.CollectionCount(1) - _current.Gen1Start;
_current.Gen2 = GC.CollectionCount(2) - _current.Gen2Start;
Write(_current);
FrameCount++;
if (_current.Recomposited) RecompositeCount++;
_frameOpen = false;
if (++_framesSinceFlush >= FlushIntervalFrames)
{
_writer.Flush();
_framesSinceFlush = 0;
}
}
public static bool IsIntegerTranslation(Affine2D m)
=> IsTranslation(m) &&
m.TX == Math.Truncate(m.TX) && m.TY == Math.Truncate(m.TY) &&
m.TX >= int.MinValue && m.TX <= int.MaxValue &&
m.TY >= int.MinValue && m.TY <= int.MaxValue;
public static bool IsTranslation(Affine2D m)
=> m.XX == 1 && m.XY == 0 && m.YX == 0 && m.YY == 1;
public static bool IsAxisAligned(Affine2D m)
=> m.XY == 0 && m.YX == 0;
public static long EstimateCandidatePixels(Affine2D m, int width, int height,
int destinationWidth, int destinationHeight)
{
if (width <= 0 || height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) return 0;
var a = m.Apply(0, 0);
var b = m.Apply(width, 0);
var c = m.Apply(0, height);
var d = m.Apply(width, height);
double left = Math.Min(Math.Min(a.X, b.X), Math.Min(c.X, d.X));
double top = Math.Min(Math.Min(a.Y, b.Y), Math.Min(c.Y, d.Y));
double right = Math.Max(Math.Max(a.X, b.X), Math.Max(c.X, d.X));
double bottom = Math.Max(Math.Max(a.Y, b.Y), Math.Max(c.Y, d.Y));
long x0 = Math.Max(0, ClampFloor(left));
long y0 = Math.Max(0, ClampFloor(top));
long x1 = Math.Min(destinationWidth, ClampCeiling(right));
long y1 = Math.Min(destinationHeight, ClampCeiling(bottom));
return x1 <= x0 || y1 <= y0 ? 0 : checked((x1 - x0) * (y1 - y0));
}
private static long ClampFloor(double value)
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
: value >= long.MaxValue ? long.MaxValue : (long)Math.Floor(value);
private static long ClampCeiling(double value)
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
: value >= long.MaxValue ? long.MaxValue : (long)Math.Ceiling(value);
private void Write(Frame f)
{
var b = new StringBuilder(512);
Append(b, f.Number); Append(b, f.NowMs); Append(b, f.DeltaMs);
AppendTicks(b, f.MainTicks); AppendTicks(b, f.PulseTicks); AppendTicks(b, f.MovieTicks);
AppendTicks(b, f.ShouldTicks); AppendTicks(b, f.RecompositeTicks); AppendTicks(b, f.ClearTicks);
AppendTicks(b, f.SnapshotTicks); AppendTicks(b, f.ResolveTicks); AppendTicks(b, f.SourcePrepTicks);
AppendTicks(b, f.RasterTicks);
AppendTicks(b, f.SetDataTicks); AppendTicks(b, f.TextureUpdateTicks); AppendTicks(b, f.UiTicks);
Append(b, f.AllocatedBytes); Append(b, f.RecomposeAllocatedBytes);
Append(b, f.SnapshotAllocatedBytes); Append(b, f.CompositeAllocatedBytes);
Append(b, f.SourcePrepAllocatedBytes); Append(b, f.SetDataAllocatedBytes);
Append(b, f.UiAllocatedBytes); Append(b, f.Gen0); Append(b, f.Gen1); Append(b, f.Gen2);
Append(b, f.Recomposited ? 1 : 0); Append(b, f.ScreenTransition ? 1 : 0);
Append(b, f.PresentHostRequest ? 1 : 0); Append(b, f.PresentScreenTransition ? 1 : 0);
Append(b, f.PresentRetainedMutation ? 1 : 0); Append(b, f.PresentContinuousChannel ? 1 : 0);
Append(b, f.PresentDiscreteCell ? 1 : 0);
Append(b, f.ObjectVisits); Append(b, f.TimeVaryingObjects); Append(b, f.DrawLayers);
Append(b, f.FillLayers); Append(b, f.TransitionLayers); Append(b, f.SkippedLayers);
Append(b, f.IntegerLayers); Append(b, f.FractionalTranslationLayers);
Append(b, f.AxisAlignedScaleLayers); Append(b, f.GeneralAffineLayers);
Append(b, f.AffineLayers); Append(b, f.SingularLayers);
Append(b, f.DynamicLayers); Append(b, f.OpaqueLayers); Append(b, f.AlphaLayers);
Append(b, f.AdditiveLayers); Append(b, f.SourcePixels); Append(b, f.CandidatePixels);
Append(b, f.FullScreenLayers); AppendEscaped(b, f.Script); Append(b, f.Offset);
Append(b, f.Opcode); AppendEscaped(b, f.PresentScript); Append(b, f.PresentOffset);
Append(b, f.PresentOpcode, last: true);
_writer.WriteLine(b.ToString());
}
private static void AppendTicks(StringBuilder b, long ticks)
=> Append(b, ticks * MillisecondsPerTick);
private static void Append(StringBuilder b, long value, bool last = false)
{
b.Append(value.ToString(CultureInfo.InvariantCulture));
if (!last) b.Append(',');
}
private static void Append(StringBuilder b, double value)
{
b.Append(value.ToString("0.0000", CultureInfo.InvariantCulture));
b.Append(',');
}
private static void AppendEscaped(StringBuilder b, string value)
{
b.Append('"').Append(value.Replace("\"", "\"\"")).Append("\",");
}
public void Dispose()
{
if (_disposed) return;
if (_frameOpen) EndFrame();
_disposed = true;
_writer.Dispose();
}
private sealed class Frame
{
public int Number, Offset, Opcode;
public long NowMs, Started, MainTicks, PulseTicks, MovieTicks, ShouldTicks, RecompositeTicks;
public long RecompositeStarted, ClearTicks, SnapshotTicks, ResolveTicks, SourcePrepTicks, RasterTicks;
public long SetDataTicks, TextureUpdateTicks, UiTicks, AllocatedStart, AllocatedBytes;
public long RecomposeAllocatedBytes, SnapshotAllocatedBytes, CompositeAllocatedBytes;
public long SourcePrepAllocatedBytes, SetDataAllocatedBytes, UiAllocatedBytes;
public int Gen0Start, Gen1Start, Gen2Start, Gen0, Gen1, Gen2;
public double DeltaMs;
public string Script = "<unknown>";
public string PresentScript = "<none>";
public int PresentOffset = -1, PresentOpcode = -1;
public bool Recomposited, ScreenTransition;
public bool PresentHostRequest, PresentScreenTransition, PresentRetainedMutation;
public bool PresentContinuousChannel, PresentDiscreteCell;
public long ObjectVisits, TimeVaryingObjects, DrawLayers, FillLayers, TransitionLayers, SkippedLayers;
public long IntegerLayers, FractionalTranslationLayers, AxisAlignedScaleLayers;
public long GeneralAffineLayers, AffineLayers, SingularLayers, DynamicLayers;
public long OpaqueLayers, AlphaLayers, AdditiveLayers, SourcePixels, CandidatePixels, FullScreenLayers;
}
}