From 3b5dbc6c2caedad5b13f57d58ddb11dcfe9fccd1 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 11 Jul 2026 13:56:30 -0400 Subject: [PATCH] Optimize static wait presentation --- docs/phase-a-slice-plan.md | 38 ++++++++++++++++++++ engine/Age.Engine.Tests/GfxAnimationTests.cs | 27 ++++++++++++++ engine/Age.Engine/Model/GfxState.cs | 15 ++++++++ godot/GodotAdvHost.cs | 20 ++++++++--- godot/Main.cs | 2 +- 5 files changed, 96 insertions(+), 6 deletions(-) diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md index f76388d..1d04399 100644 --- a/docs/phase-a-slice-plan.md +++ b/docs/phase-a-slice-plan.md @@ -1256,3 +1256,41 @@ and makes a committed alpha-zero ADV crop transparent. Focused model/raster test endpoint, identity modulation, and zero-opacity output. Validation: engine **134/134**, Godot build with zero warnings, matching windowed capture, and user manual confirmation that the box disappears fully and the controls retain their normal color while visible. + +### Godot window jitter / compositor performance investigation (2026-07-11) + +The visible window jitter is main-thread compositor pressure, not `FrameClock.Speed`, coroutine pacing, +or GPU throughput. A bounded windowed run on the Vulkan backend (RTX 4080 SUPER, `--max-fps 60 +--print-fps`) sustained only **23–25 FPS / 40–43 ms per frame** while SC0000 approached its first ADV +input wait. The retained frontend currently makes `ShouldRecomposite()` true throughout input waits, +explicit sleeps, foreground waits, and text reveal. Each recomposite clears the 800x600 canvas, then for +every visible layer calls `Image.GetData()`, runs the C# per-pixel inverse-affine rasterizer, calls +`Image.SetData()`, and finally uploads the full canvas with `ImageTexture.Update()`. An unchanged input-wait +screen therefore consumes the same expensive path every rendered frame; dragging the OS window stutters +because this work runs on Godot's main thread. + +Continuous presentation itself is partly load-bearing: one-shot transitions, movies, and ambient cyclic +channels must continue sampling `FrameClock` while active. The *unconditional* redraw implied by +`IsWaiting`/`IsTextRevealing` is not. The existing gfx-change log showed real one-shot color changes through +render frame 121, then no retained-object changes, while the separate steady-state FPS probe remained near +23 FPS. A safe correction should preserve time-based presentation but distinguish dirty/static waits from +active visual channels, and should avoid copying the full Godot `Image` out and back once per layer. Likely +implementation boundaries are: (1) explicit compositor dirty generation plus an active-animation/movie +query, and (2) one CPU backbuffer acquisition/update per recomposite or migration of ordinary layers to +Godot/GPU-native drawing. Merely changing `--speed`, sleep/coroutine behavior, or the 60 FPS cap will not +remove the underlying frame cost. + +**Quick win 1 implemented (2026-07-11).** `GfxState.HasActiveVisualPresentation(nowMs)` now reports +only retained pixels that can change without another VM mutation: finite surface/one-shot channels plus +visible spritesheet, color, and cyclic-rotation channels with positive periods. Godot consumes explicit +presentation dirtiness once and otherwise recomposites only while that query is true. Entering input wait +or `sleep` requests one publish so preceding retained writes remain visible; the wait/sleep state itself and +ADV text reveal no longer rebuild the background. Movie frames retain their existing per-sample dirty +publication. This preserves the clock, VM suspension model, active transitions, ambient animation, and +movie lifecycle. + +Validation: the focused static-vs-ambient query regression brings the engine suite to **135/135**; Godot +builds with zero warnings and threaded `SELFTEST OK`. A matching hidden-window Vulkan run capped at 60 FPS, +using `--speed 8` only to reach the static state quickly, held **60 FPS / 16.66 ms per frame** for all eight +reported samples, versus the pre-change 23-25 FPS. Buffer batching/source-pixel caching and rasterizer fast +paths remain independent follow-ups. diff --git a/engine/Age.Engine.Tests/GfxAnimationTests.cs b/engine/Age.Engine.Tests/GfxAnimationTests.cs index 888d581..ff252e5 100644 --- a/engine/Age.Engine.Tests/GfxAnimationTests.cs +++ b/engine/Age.Engine.Tests/GfxAnimationTests.cs @@ -39,6 +39,33 @@ public class GfxAnimationTests Assert.True(o.RotationEnabled); } + [Fact] + public void ActiveVisualPresentation_ExcludesStaticWaits_ButIncludesAmbientChannels() + { + static GfxState VisibleObject() + { + var state = new GfxState(); + state.SetSurface(1, 5, -1); + state.BindDraw(7, 1, 0, 0, 64, 64, 0, 0); + return state; + } + + var unchanged = VisibleObject(); + Assert.False(unchanged.HasActiveVisualPresentation(1000)); + + var spritesheet = VisibleObject(); + spritesheet.SetSrcRect(7, 4, 1, 0, 800); + Assert.True(spritesheet.HasActiveVisualPresentation(1000)); + + var color = VisibleObject(); + color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000)); + Assert.True(color.HasActiveVisualPresentation(1000)); + + var rotation = VisibleObject(); + rotation.SetRotationCycle(7, 1000, (0, 0, 1)); + Assert.True(rotation.HasActiveVisualPresentation(1000)); + } + [Fact] public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample() { diff --git a/engine/Age.Engine/Model/GfxState.cs b/engine/Age.Engine/Model/GfxState.cs index 5565630..d7a6678 100644 --- a/engine/Age.Engine/Model/GfxState.cs +++ b/engine/Age.Engine/Model/GfxState.cs @@ -352,6 +352,21 @@ public sealed class GfxState o.RotationChannelEnabled || o.TranslationEnabled)); } + /// Whether sampling the retained scene at a later frame can change its pixels without another + /// VM mutation. Includes finite presentation work plus the ambient channels that may remain active while + /// the interpreter is parked at an input wait. Static waits themselves are deliberately not animation. + public bool HasActiveVisualPresentation(long nowMs) + { + lock (_lock) + return _surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) || + _objects.Values.Any(o => o.Visible && + (o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled || + o.TranslationEnabled || + (o.SrcAnim && o.SrcPeriod > 0) || + (o.ColorAnim && o.ColorPeriod > 0) || + (o.RotationEnabled && o.RotationPeriodMs > 0))); + } + /// Click completion affects only type-0 foreground transitions, never ambient object channels. public int CompleteForegroundTransitions(long nowMs) { diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index 9d1ad3f..707bacd 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -105,6 +105,9 @@ public sealed class GodotAdvHost : IHost { Pages++; _main.CallDeferred("PageBreak"); + // Publish retained mutations accumulated before the wait once. A static input wait is not itself a + // reason to rebuild the 800x600 background every frame; ambient channels are queried separately. + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); IsWaiting = true; _timeline?.State("input-wait", new() { ["page"] = Pages }); _gate.Wait(); @@ -167,6 +170,9 @@ public sealed class GodotAdvHost : IHost } _frameSignal.WaitOne(50); } + // The active query becomes false at the exact transition/movie endpoint. Publish that terminal sample + // once so the last visible frame cannot remain fractionally incomplete. + System.Threading.Interlocked.Exchange(ref _presentRequested, 1); IsTransitionWaiting = false; System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1); _foregroundGfx = null; @@ -185,11 +191,12 @@ public sealed class GodotAdvHost : IHost System.Threading.Interlocked.Exchange(ref _presentRequested, 1); } - // Native retained-object writes are not front-buffer writes. The renderer publishes them only at an - // explicit present or while the interpreter is parked in a presentation-capable service boundary. - public bool ShouldRecomposite() - => IsWaiting || IsTransitionWaiting || IsSleeping || IsTextRevealing || - System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0; + // 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) + => System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 || + gfx.HasActiveVisualPresentation(_clock.NowMs); public void Stop() { @@ -218,6 +225,9 @@ public sealed class GodotAdvHost : IHost long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window 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) { diff --git a/godot/Main.cs b/godot/Main.cs index 0a087fe..a170fb6 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -222,7 +222,7 @@ public partial class Main : Godot.Control _timeline?.SetFrame(_timelineFrame, _clock.NowMs); _host?.PulseFrame(); UpdateMovieFrames(); - if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite()) + 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(); // --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be