Optimize static wait presentation

This commit is contained in:
gamer147
2026-07-11 13:56:30 -04:00
parent 54d11f513d
commit 3b5dbc6c2c
5 changed files with 96 additions and 6 deletions

View File

@@ -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 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 zero warnings, matching windowed capture, and user manual confirmation that the box disappears fully and
the controls retain their normal color while visible. 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 **2325 FPS / 4043 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.

View File

@@ -39,6 +39,33 @@ public class GfxAnimationTests
Assert.True(o.RotationEnabled); 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] [Fact]
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample() public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
{ {

View File

@@ -352,6 +352,21 @@ public sealed class GfxState
o.RotationChannelEnabled || o.TranslationEnabled)); o.RotationChannelEnabled || o.TranslationEnabled));
} }
/// <summary>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.</summary>
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)));
}
/// <summary>Click completion affects only type-0 foreground transitions, never ambient object channels.</summary> /// <summary>Click completion affects only type-0 foreground transitions, never ambient object channels.</summary>
public int CompleteForegroundTransitions(long nowMs) public int CompleteForegroundTransitions(long nowMs)
{ {

View File

@@ -105,6 +105,9 @@ public sealed class GodotAdvHost : IHost
{ {
Pages++; Pages++;
_main.CallDeferred("PageBreak"); _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; IsWaiting = true;
_timeline?.State("input-wait", new() { ["page"] = Pages }); _timeline?.State("input-wait", new() { ["page"] = Pages });
_gate.Wait(); _gate.Wait();
@@ -167,6 +170,9 @@ public sealed class GodotAdvHost : IHost
} }
_frameSignal.WaitOne(50); _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; IsTransitionWaiting = false;
System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1); System.Threading.Interlocked.Exchange(ref _transitionStartedAtMs, -1);
_foregroundGfx = null; _foregroundGfx = null;
@@ -185,11 +191,12 @@ public sealed class GodotAdvHost : IHost
System.Threading.Interlocked.Exchange(ref _presentRequested, 1); System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
} }
// Native retained-object writes are not front-buffer writes. The renderer publishes them only at an // Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// explicit present or while the interpreter is parked in a presentation-capable service boundary. // once, then continue only while the sampled retained scene can actually change. Text reveal is a separate
public bool ShouldRecomposite() // Godot Label; waiting/sleeping alone do not alter background pixels.
=> IsWaiting || IsTransitionWaiting || IsSleeping || IsTextRevealing || public bool ShouldRecomposite(GfxState gfx)
System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0; => System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 ||
gfx.HasActiveVisualPresentation(_clock.NowMs);
public void Stop() 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 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; long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline }); _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; IsSleeping = true;
while (_clock.NowMs < deadline) while (_clock.NowMs < deadline)
{ {

View File

@@ -222,7 +222,7 @@ public partial class Main : Godot.Control
_timeline?.SetFrame(_timelineFrame, _clock.NowMs); _timeline?.SetFrame(_timelineFrame, _clock.NowMs);
_host?.PulseFrame(); _host?.PulseFrame();
UpdateMovieFrames(); 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 Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation(); if (!_selftest && _host != null) UpdateAdvTextPresentation();
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be // --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be