Optimize static wait presentation
This commit is contained in:
@@ -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 **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.
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user