diff --git a/docs/phase-a-slice-plan.md b/docs/phase-a-slice-plan.md
index 2b9222d..9d5c29c 100644
--- a/docs/phase-a-slice-plan.md
+++ b/docs/phase-a-slice-plan.md
@@ -2312,11 +2312,11 @@ sampled coordinates.
- [x] **P2 - fidelity-neutral CPU wins.** P2a-P2f are implemented and measured. DEBUGMAP now meets its p95
frame budget with near-zero steady allocation; the SC0000 exit capture remains 65.18/71.76 ms p50/p95 in
the severe full-screen/additive band, so P2 closes and triggers P3 rather than more CPU special cases.
-- [ ] **P3 - GPU retained-renderer prototype (triggered).** Upload decoded/color-key texture variants once and mirror
+- [x] **P3 - GPU retained-renderer prototype.** Upload decoded/color-key texture variants once and mirror
ordinary retained objects into GPU-native Godot drawing while preserving handle z-order, atlas regions,
transforms, opacity/tint, and blend mode. Begin with dungeon sprites and ordinary translated textures;
compare selected frames against the software oracle behind a backend switch.
-- [ ] **P4 - GPU special paths and backend decision.** Extend the prototype through affine effects,
+- [x] **P4 - GPU special paths and backend decision.** Extend the prototype through affine effects,
additive/tint channels, created surfaces, transitions, and movie surfaces. Adopt it as the default only
after target-workload frame evidence and visual equivalence; otherwise retain documented CPU fallbacks
for unsupported paths.
@@ -2689,3 +2689,108 @@ atlas source rectangles, colorkey, tint/opacity, additive blending, and the exac
the software compositor as the pixel-parity oracle/fallback. First acceptance is the same
`SC0000@0x123de` severe band below 16.67 ms p95 without regressing DEBUGMAP presentation or movie/text
composition; only then make GPU rendering the default.
+
+### P3 retained-GPU prototype implemented; workload acceptance pending (2026-07-22)
+
+`--render-backend gpu` now mirrors the synchronized `GfxState` snapshot into a pooled Godot `Sprite2D`
+stage. Static decoded/color-key variants upload once; mutable created surfaces and movie frames update a
+surface-keyed dynamic texture, so concurrent playbacks of the same resource cannot overwrite each other.
+Each retained item preserves handle/child order, atlas source rectangle, nearest filtering, the exact sampled
+affine matrix, opacity, multiplicative tint, source/tint LERP, and source-alpha/additive canvas blending.
+Surfaceless affine fills use a shared one-pixel texture. The software compositor remains the default for
+direct launches and is selected whole-frame for legacy whole-screen transitions or the still-unobserved
+additive LERP-tint combination; diagnostics which depend on software decision strings also stay on the oracle.
+
+The performance CSV now records backend, GPU draw-item count, texture uploads, and upload CPU time while
+retaining the common object/layer/transform/candidate-pixel workload columns. A real Vulkan smoke exercised
+54 GPU recompositions without Godot errors after startup; warmed three-item frames required about
+0.02-0.10 ms of main-thread synchronization, while the first texture publications took about 2-3 ms. This
+is not the target burst and is only a plumbing measurement.
+
+The first visual comparison initially produced a false black result because the direct `SC0000` validation
+command omitted its required `--boot` SYSTEM4/INIT state. Repeating with `--boot` restored the opening event
+CG in both backends. At the settled first narration page, the GPU and software 800x450 retained-art regions
+match in geometry/content; every differing channel is at most one RGB value and the differences are confined
+to the soft lower fade, consistent with Godot floating-point versus software integer blend rounding. The
+automated `--shot` harness forces pre-page transition waits, so its abrupt fade is not cadence evidence.
+
+For this effort `run-godot.cmd` temporarily enabled both timestamped perf capture and the GPU prototype;
+that temporary launch behavior was removed at P4 closeout. **Next:** use the natural SYSTEM4 route
+at speed 1 for the SC0000 pre-CHAPTER burst and DEBUGMAP camera/unit workload. Accept P3 only if the original
+`SC0000@0x123de` severe band reaches less than 16.67 ms p95 and manual viewing finds no texture, ordering,
+movie, text, fade-cadence, or dungeon-sprite regression. Then take P4's screen/range-transition GPU path and
+backend-default decision.
+
+### P3 capture 10 - SC0000 GPU target and P4 range-transition trigger (2026-07-22)
+
+`build/perf/run-20260722-145338-127.csv` covers the natural SYSTEM4 route through the CHAPTER movie and
+following text. The user reports the original burst is “much, much smoother.” The log confirms that the GPU
+path removes the pixel-throughput wall: 199 matched `SC0000@0x123de` rows with at least 3.5 million candidate
+pixels have main-thread p50/p95/p99/max 0.068/0.117/0.119/0.124 ms and recomposition
+0.048/0.083/0.085/0.093 ms, versus the P2 software baseline's 65.15/71.74 ms p50/p95. All of those frames
+arrive at the capture's 10.0 ms presentation cadence. Across 3,048 GPU recompositions, p95/p99/max is
+3.23/4.43/13.14 ms; static reuse avoids texture uploads, while dynamic/movie publication remains bounded.
+
+The mixed hotspot still contained 67 intentional software fallbacks (2.2% of recompositions), all for one
+type-0 retained range-transition object rather than a legacy whole-screen transition. Fifty-one ordinary
+eight-layer rows cost about 17-18 ms, twelve ten-layer affine/additive rows cost about 53-55 ms, and the final
+four twelve-layer rows reached 64-66 ms. These sparse fallbacks dominate the aggregate p95 even though the
+ordinary GPU frames are far below budget, so they trigger the narrow P4 range-transition slice.
+
+### P4 type-0 retained range transition moved to GPU; recapture pending (2026-07-22)
+
+The GPU compositor now handles the exact existing software-oracle rule: range A remains in normal retained
+order, and when the transition placeholder is visited, range B is republished there with the sampled
+transition progress multiplying each source object's opacity. Texture/fill resolution, affine transforms,
+color key, tint, additive blend, dynamic-surface identity, and common perf counters use the same GPU paths as
+ordinary objects. Whole-screen host transitions and additive LERP-tint remain bounded software fallbacks.
+
+A bootstrapped SC0000 page-1 capture exercises the GPU range path and matches the saved software reference:
+the complete 800x600 frame differs only by at most one RGB value, with 46,468 changed pixels confined to the
+soft y=376..449 fade and 121 independently animated chrome pixels. The Godot build has zero warnings/errors,
+the capture log has no runtime warning/error, threaded `SELFTEST OK`, and `git diff --check` passes.
+**Next:** repeat the natural SC0000 pre-CHAPTER run once to prove `render_backend=0` disappears from this
+coordinate and the mixed severe-band p95 is below 16.67 ms, then run the DEBUGMAP camera/unit acceptance case.
+
+### P4 capture 11 - SC0000 range-transition closeout passes (2026-07-22)
+
+`build/perf/run-20260722-150316-085.csv` repeats the natural SYSTEM4-to-CHAPTER route after the GPU range
+implementation. All 3,111 recompositions use `render_backend=1`; there are zero software fallbacks anywhere
+in the run. Across the original `SC0000@0x123de` severe band (215 rows with at least 3.5 million candidate
+pixels), main-thread p50/p95/p99/max is 0.075/0.132/0.175/0.562 ms and recomposition is
+0.051/0.091/0.124/0.533 ms. Every severe frame arrives in 10.0-10.42 ms and none exceeds the 16.67 ms
+budget. The whole run's recomposition p95/p99/max is 3.10/3.57/9.03 ms; dynamic/movie upload p95 is
+0.192 ms and movie sampling p95 is 0.005 ms.
+
+This closes the original SC0000 performance defect: equivalent severe software frames were
+65.15/71.74 ms p50/p95 after P2 and are now 0.075/0.132 ms on the GPU submission path, with the user's
+manual run already reporting the burst as much smoother. The P4 range-transition fallback is accepted for
+SC0000. **Next:** run DEBUGMAP with the established pan/idle/unit-group/off-map sequence. If its sprites,
+camera, pulse effects, and frame delivery remain correct, accept P3 as the retained backend and decide the
+small remaining whole-screen fallback/default-switch cleanup.
+
+### P3 capture 12 - DEBUGMAP passes; performance effort accepted (2026-07-22)
+
+`build/perf/run-20260722-152956-766.csv` follows the established slight-pan/idle/unit-group/pan/off-map
+sequence. The user reports the result looks good visually. All 6,247 recompositions use the GPU, including
+5,496 FIELD frames; there are zero software fallbacks. FIELD averages 962 visited objects, 961 actual draw
+items, 51 time-varying objects, 434 integer layers, and 525 axis-aligned-scale layers per composition.
+
+In the matched 2.2-2.5 million candidate-pixel band, main-thread p50/p95/p99 falls from the final software
+baseline's 13.90/15.86/18.89 ms to 2.14/2.33/2.78 ms; recomposition is 2.03/2.19/2.66 ms. The >=2.5M band
+remains 2.14/2.90/5.40 ms main-thread p50/p95/p99 with a 10.99 ms max. Off-map frames with no visible draws
+fall to about 0.02 ms. The normal FIELD path allocates only a few KB of diagnostic/accounting state and the
+matched band performs no collections.
+
+One ordinary 961-item FIELD frame measured 22.04 ms without texture uploads, allocation growth, snapshot,
+or resource-resolution cost; adjacent frames immediately returned to the normal distribution. It is one
+scheduler/driver-like outlier among 5,496 FIELD recompositions, while FIELD p99 is 2.79 ms, so it does not
+represent a recurring retained-renderer bottleneck. A separate first-capacity growth row uploads seven
+textures, allocates 3.06 MB, and still completes in 10.99 ms.
+
+**Acceptance:** P3/P4 close successfully. GPU retained rendering is now the ordinary backend; the software
+compositor remains available via `--render-backend software`, owns high-volume decision diagnostics, and is
+the bounded fallback for legacy whole-screen host transitions or an unobserved additive-LERP combination.
+`run-godot.cmd` no longer forces `-PerfLog` or a renderer switch, fulfilling the temporary-launch cleanup;
+`run-godot.ps1 -PerfLog` remains available for future targeted captures. The original SC0000 burst and
+DEBUGMAP animated-unit workload are both visually accepted and comfortably within frame budget.
diff --git a/docs/platform-portability.md b/docs/platform-portability.md
index d04086c..d7aacab 100644
--- a/docs/platform-portability.md
+++ b/docs/platform-portability.md
@@ -25,6 +25,7 @@ replaced before claiming portable exports.
| Area | Current dependency | Runtime impact | Portability status / future action |
|---|---|---|---|
+| Retained graphics presentation | Backend-neutral `GfxState`; accepted default Godot `Sprite2D` GPU stage plus the retained software pixel oracle, using runtime `ImageTexture`, canvas transforms/materials, and no native graphics API | GPU backend caches static/color-key variants, updates dynamic surfaces, handles retained range transitions, and falls back whole-frame for the legacy host screen-transition path | Godot owns D3D/Vulkan/Metal/OpenGL selection; validate shader/blend/filter behavior per target rather than adding a platform renderer |
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the selected live factory over the project-owned `native/age_movie_ffmpeg` ABI; `DirectShowMovieDecoder` remains unselected pending deletion | Windows-x64 passes the complete 213-payload installed-corpus gate; other native targets and the remaining windowed CHAPTER/combat checks are pending | Run the windowed live gates, then delete DirectShow and add target-specific native builds |
| Movie integration | Each surface owns a unique playback-instance id; `MovieRuntime` owns `IMovieDecoder` from an injected factory, and the FFmpeg worker paces PTS against a monotonic clock with cancellation/failure completion | Concurrent/restarted uses of one asset have independent frame/completion/teardown state; `Main` remains annotated Windows because only the win-x64 bundle is available | Recheck the corrected combat lifecycle, then add Linux/macOS builds and remove the Windows annotation after DirectShow is deleted |
| Movie audio | FFmpeg detects the audio stream but the current ABI returns video frames only | MPEG movie audio remains intentionally silent | Extend the ABI with timestamped PCM and select an audio/presentation clock; separate feature slice |
@@ -35,9 +36,10 @@ replaced before claiming portable exports.
| Native RE tools | Frida/Ghidra helpers target the original `AGE.EXE`; supporting utilities include Windows executables and Windows command conventions | Development/research only | Keep separate from export requirements; document platform prerequisites per tool |
| Python workflow | Operating guide uses Windows `py -3.11` invocation | Developer workflow only | Add equivalent `python3` instructions if non-Windows development becomes active |
-No authored runtime code currently calls native DirectSound or Direct3D. Mentions of those APIs in
-`docs/engine-re.md` describe the original AGE implementation. The port's ordinary audio and rendering use
-Godot abstractions.
+No authored runtime code currently calls native DirectSound, Direct3D, Vulkan, Metal, or OpenGL. Mentions of
+those APIs in `docs/engine-re.md` describe the original AGE implementation. Both the software-oracle display
+and retained GPU prototype use Godot abstractions, so accepting the GPU backend does not add an OS graphics
+dependency.
## Movie backend replacement seam
diff --git a/docs/tools-reference.md b/docs/tools-reference.md
index 5f5819f..21165ed 100644
--- a/docs/tools-reference.md
+++ b/docs/tools-reference.md
@@ -225,7 +225,15 @@ split into fractional translation, axis-aligned scale, and general affine layers
allocation total is split into recomposition, retained snapshot, compositor, source-preparation,
`Image.SetData`, and Godot UI phases so a normal windowed capture can attribute remaining managed garbage
without enabling a high-volume trace. The
-writer replaces the target, buffers 120 rows between flushes, and prints its frame/recomposition counts on
+`render_backend` column is `0` for the software compositor and `1` for the opt-in retained GPU path;
+`gpu_draw_items`, `gpu_texture_uploads`, and `gpu_texture_upload_ms` distinguish cheap retained-item updates
+from new/static or changed/dynamic texture publication. Existing layer/transform/pixel workload columns are
+populated for either backend, while `raster_ms`, `set_data_ms`, and `texture_update_ms` remain zero on a native
+GPU presentation. GPU retained rendering is the normal backend; select the correctness oracle explicitly
+with `--render-backend software` or the local launcher's `-SoftwareRenderer` switch. An explicit
+`--render-backend gpu` is accepted but normally unnecessary.
+
+The writer replaces the target, buffers 120 rows between flushes, and prints its frame/recomposition counts on
normal shutdown. Use a windowed Release-equivalent run at speed 1 for performance evidence; headless runs
validate the schema only. Before a baseline, verify that no older Godot game processes remain alive; an
apparently closed window can otherwise leave a renderer consuming CPU and contaminate later runs. Do not
@@ -233,12 +241,10 @@ combine baseline captures with `--shot-sequence`, `--gfx-log`, or
`--timeline-log`, whose diagnostics add substantial or differently shaped work. Example from `age-reimpl/`:
`godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`.
-During the tracked performance-tuning effort, `run-godot.cmd` passes the launcher's `-PerfLog` switch by
-default. Every windowed batch-file launch creates `build/perf/run-yyyyMMdd-HHmmss-fff.csv` and prints the
-absolute target before Godot starts; normal shutdown flushes it and prints the captured frame/recomposition
-counts. This temporary batch-file default does not affect direct `run-godot.ps1` launches, whose `-PerfLog`
-switch remains explicit, and it does not add a log to `-SelfTest`. Remove the batch-file opt-in after the
-performance effort is accepted.
+`run-godot.ps1 -PerfLog` creates `build/perf/run-yyyyMMdd-HHmmss-fff.csv` and prints the absolute target
+before Godot starts; normal shutdown flushes it and prints the captured frame/recomposition counts. The
+performance effort's temporary `run-godot.cmd` opt-in was removed after GPU acceptance, so ordinary batch-file
+runs no longer write a log. Selftests also remain unprofiled.
**Godot debug scene launcher:** press **F4** while the natural boot is showing TITLE. TITLE's visible menu is
a live 1 ms sleep/input-poll loop rather than an ADV `wait-for-input`; the launcher identifies that exact
diff --git a/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs b/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs
index f141a97..a6365dd 100644
--- a/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs
+++ b/engine/Age.Engine.Tests/PerformanceFrameLogTests.cs
@@ -24,6 +24,9 @@ public class PerformanceFrameLogTests
log.RecordObject(timeVarying: true);
log.RecordRaster(800, 600, new Affine2D(1, 0, 0, 1, 0, 0),
800, 600, dynamic: false, BlendKind.Alpha, ticks: 10);
+ log.RecordGpuLayer(40, 20, new Affine2D(1, 0, 0, 1, 12, 13),
+ 800, 600, dynamic: true, BlendKind.Additive);
+ log.RecordGpu(drawItems: 2, textureUploads: 1, textureUploadTicks: 10);
log.RecordFillLayer();
log.RecordSkippedLayer();
log.EndRecomposite();
@@ -40,6 +43,9 @@ public class PerformanceFrameLogTests
Assert.Equal("frame", header[0]);
Assert.Equal("17", row[0]);
Assert.Equal("1", row[Array.IndexOf(header, "recomposited")]);
+ Assert.Equal("1", row[Array.IndexOf(header, "render_backend")]);
+ Assert.Equal("2", row[Array.IndexOf(header, "gpu_draw_items")]);
+ Assert.Equal("1", row[Array.IndexOf(header, "gpu_texture_uploads")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_host_request")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_retained_mutation")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_discrete_cell")]);
@@ -51,7 +57,9 @@ public class PerformanceFrameLogTests
Assert.Equal("1024", row[Array.IndexOf(header, "set_data_allocated_bytes")]);
Assert.Equal("64", row[Array.IndexOf(header, "ui_allocated_bytes")]);
Assert.Equal("1", row[Array.IndexOf(header, "time_varying_objects")]);
- Assert.Equal("480000", row[Array.IndexOf(header, "candidate_pixels")]);
+ Assert.Equal("480800", row[Array.IndexOf(header, "candidate_pixels")]);
+ Assert.Equal("1", row[Array.IndexOf(header, "dynamic_layers")]);
+ Assert.Equal("1", row[Array.IndexOf(header, "additive_layers")]);
Assert.Equal("SC0000.BIN", row[Array.IndexOf(header, "script")].Trim('"'));
Assert.Equal("10897", row[Array.IndexOf(header, "offset")]);
Assert.Equal("524", row[Array.IndexOf(header, "opcode")]);
diff --git a/godot/GpuRetainedRenderer.cs b/godot/GpuRetainedRenderer.cs
new file mode 100644
index 0000000..fd84fdc
--- /dev/null
+++ b/godot/GpuRetainedRenderer.cs
@@ -0,0 +1,251 @@
+using System;
+using System.Collections.Generic;
+using Godot;
+using Age.Engine.Model;
+using Age.Engine.Sys4;
+
+///
+/// Godot-native presentation of AGE's sampled retained objects. Static decoded/color-key variants are
+/// uploaded once; pooled Sprite2D canvas items retain their GPU resources between presentation boundaries.
+/// GfxState remains the backend-neutral source of truth and the software compositor remains the oracle.
+///
+internal sealed class GpuRetainedRenderer : IDisposable
+{
+ internal readonly record struct FrameStats(int DrawItems, int TextureUploads, long TextureUploadTicks);
+
+ private sealed class CachedTexture
+ {
+ public required ImageTexture Texture;
+ public required int Width;
+ public required int Height;
+ public byte[]? LastPixels;
+ }
+
+ private readonly Node2D _stage;
+ private readonly List _items = new(1024);
+ private readonly Dictionary<(int AssetId, long ColorKey, long DynamicKey), CachedTexture> _textures = new();
+ private readonly Dictionary<(long Tint, int Strength, BlendKind Blend), ShaderMaterial> _lerpMaterials = new();
+ private readonly CanvasItemMaterial _alphaMaterial = new() { BlendMode = CanvasItemMaterial.BlendModeEnum.Mix };
+ private readonly CanvasItemMaterial _additiveMaterial = new() { BlendMode = CanvasItemMaterial.BlendModeEnum.Add };
+ private readonly ImageTexture _whiteTexture;
+ private readonly Shader _lerpShader;
+ private int _used;
+ private int _textureUploads;
+ private long _textureUploadTicks;
+
+ public bool Visible
+ {
+ get => _stage.Visible;
+ set => _stage.Visible = value;
+ }
+
+ public GpuRetainedRenderer(Node parent)
+ {
+ _stage = new Node2D { Name = "GpuRetainedStage", Visible = false };
+ parent.AddChild(_stage);
+
+ var white = Image.CreateEmpty(1, 1, false, Image.Format.Rgba8);
+ white.SetData(1, 1, false, Image.Format.Rgba8, new byte[] { 255, 255, 255, 255 });
+ _whiteTexture = ImageTexture.CreateFromImage(white);
+
+ _lerpShader = new Shader
+ {
+ Code = """
+ shader_type canvas_item;
+ render_mode blend_mix, unshaded;
+ uniform vec3 age_tint = vec3(1.0);
+ uniform float age_strength = 0.0;
+ void fragment() {
+ vec4 source = texture(TEXTURE, UV);
+ source.rgb = mix(source.rgb, age_tint, age_strength);
+ COLOR = source * COLOR;
+ }
+ """
+ };
+ }
+
+ public void BeginFrame()
+ {
+ _used = 0;
+ _textureUploads = 0;
+ _textureUploadTicks = 0;
+ }
+
+ public bool DrawTexture(RgbaImage source, int assetId, long colorKey,
+ int srcX, int srcY, int width, int height,
+ Affine2D localToDest, long tint, int tintStrength,
+ float opacity, bool multiplyTint, bool dynamic, long dynamicKey, BlendKind blend)
+ {
+ if (width <= 0 || height <= 0 || opacity <= 0) return false;
+ int clippedWidth = Math.Min(width, source.Width - srcX);
+ int clippedHeight = Math.Min(height, source.Height - srcY);
+ if (srcX < 0 || srcY < 0 || clippedWidth <= 0 || clippedHeight <= 0) return false;
+
+ var texture = ResolveTexture(source, assetId, colorKey, dynamic, dynamicKey);
+ var item = NextItem();
+ item.Texture = texture;
+ item.RegionEnabled = true;
+ item.RegionRect = new Rect2(srcX, srcY, clippedWidth, clippedHeight);
+ item.Transform = ToGodot(localToDest);
+ item.Modulate = Modulation(tint, opacity, multiplyTint);
+ item.Material = ResolveMaterial(tint, tintStrength, multiplyTint, blend);
+ item.Visible = true;
+ return true;
+ }
+
+ public bool DrawFill(int width, int height, Affine2D localToDest, long tint, float opacity)
+ {
+ if (width <= 0 || height <= 0 || opacity <= 0) return false;
+ var item = NextItem();
+ item.Texture = _whiteTexture;
+ item.RegionEnabled = false;
+ item.Transform = ToGodot(new Affine2D(
+ localToDest.XX * width, localToDest.XY * width,
+ localToDest.YX * height, localToDest.YY * height,
+ localToDest.TX, localToDest.TY));
+ item.Modulate = Modulation(tint, opacity, multiplyTint: true);
+ item.Material = _alphaMaterial;
+ item.Visible = true;
+ return true;
+ }
+
+ public FrameStats EndFrame()
+ {
+ for (int i = _used; i < _items.Count; i++) _items[i].Visible = false;
+ return new FrameStats(_used, _textureUploads, _textureUploadTicks);
+ }
+
+ private Sprite2D NextItem()
+ {
+ if (_used == _items.Count)
+ {
+ var item = new Sprite2D
+ {
+ Centered = false,
+ RegionFilterClipEnabled = true,
+ TextureFilter = CanvasItem.TextureFilterEnum.Nearest,
+ Visible = false,
+ };
+ _stage.AddChild(item);
+ _items.Add(item);
+ }
+ var result = _items[_used];
+ // Children are created in retained handle order, which is enough to preserve AGE z-order. Keep
+ // their absolute Godot Z at the stage level so Main's later dialogue/wait controls remain above
+ // the complete AGE canvas instead of being covered by a high-numbered full-screen sprite.
+ result.ZIndex = 0;
+ _used++;
+ return result;
+ }
+
+ private Texture2D ResolveTexture(RgbaImage source, int assetId, long colorKey, bool dynamic, long dynamicKey)
+ {
+ // One movie resource may play concurrently on multiple surfaces. Static assets share one upload;
+ // dynamic surfaces use their surface/playback identity so one frame cannot overwrite another.
+ var key = (assetId, colorKey, dynamic ? dynamicKey : 0);
+ if (!_textures.TryGetValue(key, out var cached))
+ {
+ byte[] pixels = PreparePixels(source.Pixels, colorKey);
+ var image = Image.CreateFromData(source.Width, source.Height, false, Image.Format.Rgba8, pixels);
+ long started = System.Diagnostics.Stopwatch.GetTimestamp();
+ var texture = ImageTexture.CreateFromImage(image);
+ _textureUploadTicks += System.Diagnostics.Stopwatch.GetTimestamp() - started;
+ _textureUploads++;
+ cached = new CachedTexture
+ {
+ Texture = texture,
+ Width = source.Width,
+ Height = source.Height,
+ LastPixels = dynamic ? source.Pixels : null,
+ };
+ _textures.Add(key, cached);
+ }
+ else if (dynamic && (!ReferenceEquals(cached.LastPixels, source.Pixels) ||
+ cached.Width != source.Width || cached.Height != source.Height))
+ {
+ byte[] pixels = PreparePixels(source.Pixels, colorKey);
+ var image = Image.CreateFromData(source.Width, source.Height, false, Image.Format.Rgba8, pixels);
+ long started = System.Diagnostics.Stopwatch.GetTimestamp();
+ if (cached.Width == source.Width && cached.Height == source.Height)
+ cached.Texture.Update(image);
+ else
+ {
+ cached.Texture = ImageTexture.CreateFromImage(image);
+ cached.Width = source.Width;
+ cached.Height = source.Height;
+ }
+ _textureUploadTicks += System.Diagnostics.Stopwatch.GetTimestamp() - started;
+ _textureUploads++;
+ cached.LastPixels = source.Pixels;
+ }
+ return cached.Texture;
+ }
+
+ private static byte[] PreparePixels(byte[] source, long colorKey)
+ {
+ if (!BlendMath.HasColorKey(colorKey)) return source;
+ byte[] pixels = (byte[])source.Clone();
+ for (int i = 0; i < pixels.Length; i += 4)
+ if (BlendMath.ColorKeyMatches(pixels[i], pixels[i + 1], pixels[i + 2], colorKey))
+ pixels[i + 3] = 0;
+ return pixels;
+ }
+
+ private Material ResolveMaterial(long tint, int tintStrength, bool multiplyTint, BlendKind blend)
+ {
+ if (!multiplyTint && tintStrength > 0)
+ {
+ var key = (tint & 0x00ff_ffff, tintStrength, blend);
+ if (!_lerpMaterials.TryGetValue(key, out var material))
+ {
+ material = new ShaderMaterial { Shader = _lerpShader };
+ material.SetShaderParameter("age_tint", new Vector3(
+ ((tint >> 16) & 0xff) / 255f,
+ ((tint >> 8) & 0xff) / 255f,
+ (tint & 0xff) / 255f));
+ material.SetShaderParameter("age_strength", tintStrength / 255f);
+ // LERP-tint objects observed so far use source-over. If an additive LERP mode appears,
+ // keep the frame on the software oracle until it has a dedicated shader blend variant.
+ _lerpMaterials.Add(key, material);
+ }
+ return material;
+ }
+ return blend == BlendKind.Additive ? _additiveMaterial : _alphaMaterial;
+ }
+
+ private static Color Modulation(long tint, float opacity, bool multiplyTint)
+ {
+ float r = 1, g = 1, b = 1;
+ if (multiplyTint)
+ {
+ r = ((tint >> 16) & 0xff) / 255f;
+ g = ((tint >> 8) & 0xff) / 255f;
+ b = (tint & 0xff) / 255f;
+ }
+ return new Color(r, g, b, Math.Clamp(opacity, 0, 1));
+ }
+
+ private static Godot.Transform2D ToGodot(Affine2D value) => new(
+ new Vector2((float)value.XX, (float)value.XY),
+ new Vector2((float)value.YX, (float)value.YY),
+ new Vector2((float)value.TX, (float)value.TY));
+
+ public void Dispose()
+ {
+ _stage.Visible = false;
+ foreach (var item in _items)
+ {
+ item.Texture = null;
+ item.Material = null;
+ }
+ _whiteTexture.Dispose();
+ _alphaMaterial.Dispose();
+ _additiveMaterial.Dispose();
+ foreach (var material in _lerpMaterials.Values) material.Dispose();
+ foreach (var texture in _textures.Values) texture.Texture.Dispose();
+ _lerpShader.Dispose();
+ _items.Clear();
+ _textures.Clear();
+ _lerpMaterials.Clear();
+ }
+}
diff --git a/godot/Main.cs b/godot/Main.cs
index a7faced..a6d5181 100644
--- a/godot/Main.cs
+++ b/godot/Main.cs
@@ -20,6 +20,8 @@ public partial class Main : Godot.Control
private TextureRect _screenView = null!; // shows the composited screen backbuffer
private Image _screen = null!; // 800x600 immediate-mode canvas
private ImageTexture _screenTex = null!;
+ private GpuRetainedRenderer _gpuRenderer = null!;
+ private bool _useGpuBackend = true;
private ImageTexture? _ageCursorTexture;
private TextureRect _waitIndicator = null!;
private ImageTexture? _waitIndicatorSheet;
@@ -103,6 +105,7 @@ public partial class Main : Godot.Control
};
AddChild(_screenView); // added first -> draws behind the text/status labels
_screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
+ _gpuRenderer = new GpuRetainedRenderer(this);
// Native ADV wait marker: a tiny independently animated atlas region. Keeping it separate from the
// 800x600 software backbuffer avoids recompositing the entire retained scene throughout static waits.
@@ -187,6 +190,14 @@ public partial class Main : Godot.Control
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] == "--render-backend" && i + 1 < userArgs.Length)
+ {
+ if (userArgs[i + 1].Equals("gpu", System.StringComparison.OrdinalIgnoreCase))
+ _useGpuBackend = true;
+ else if (userArgs[i + 1].Equals("software", System.StringComparison.OrdinalIgnoreCase))
+ _useGpuBackend = false;
+ else GD.PushWarning($"unknown --render-backend '{userArgs[i + 1]}'; using gpu");
+ }
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);
@@ -208,6 +219,7 @@ public partial class Main : Godot.Control
if (!double.IsFinite(speed) || speed <= 0) speed = 1.0;
_clock.Speed = System.Math.Clamp(speed, 0.05, 8.0);
+ GD.Print($"[renderer] retained backend={(_useGpuBackend ? "gpu" : "software")}");
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
// Full op handling everywhere: the provider lets call-script load & run subroutines. Selftest
@@ -721,6 +733,7 @@ public partial class Main : Godot.Control
public override void _ExitTree()
{
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
+ _gpuRenderer?.Dispose();
if (_perf != null)
{
_perf.Dispose();
@@ -761,6 +774,202 @@ public partial class Main : Godot.Control
private readonly System.Collections.Generic.List _surfaceTextSnapshot = new();
private void Recomposite()
+ {
+ bool gpuSnapshotCaptured = false;
+ if (_useGpuBackend && TryRecompositeGpu(out gpuSnapshotCaptured)) return;
+ _gpuRenderer.Visible = false;
+ _screenView.Visible = true;
+ RecompositeSoftware(gpuSnapshotCaptured ? _visibleSnapshot : null);
+ }
+
+ private bool TryRecompositeGpu(out bool snapshotCaptured)
+ {
+ snapshotCaptured = false;
+ // Preserve the existing high-volume object/timeline diagnostics exactly. They are debugging tools,
+ // not performance workloads, and their software decision strings remain the canonical evidence.
+ if (_gfxLogPath != null || _timeline != null) return false;
+
+ long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
+ long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
+ if (_host.TrySnapshotScreenTransition(out _)) return false; // P4: whole-screen offscreen targets
+ _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot);
+ snapshotCaptured = true;
+ _perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
+ _perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
+
+ // Additive LERP-tint has not appeared in the target workloads and needs a dedicated additive shader
+ // variant before leaving the software oracle.
+ if (_visibleSnapshot.Any(v =>
+ v.Blend == BlendKind.Additive && !v.MultiplyTint && v.TintStrength > 0))
+ return false;
+
+ if (_perf != null)
+ {
+ var presentStep = _trace.LatestStep;
+ _perf.RecordPresentationCoordinate(presentStep?.Script ?? "",
+ presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
+ }
+ _perf?.BeginRecomposite(screenTransition: false);
+ phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
+ foreach (var label in _surfaceTextLabels) label.Visible = false;
+ _perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
+
+ int surfaceTextLabelIndex = 0;
+ _gpuRenderer.BeginFrame();
+ foreach (var v in _visibleSnapshot)
+ {
+ _perf?.RecordObject(v.TimeVarying);
+ var affine = Transform2DMath.Build(v.Transform, v.Rotation).FromLocalOrigin(v.DstX, v.DstY);
+ if (v.RangeTransform is { } rangeTransform) affine = affine.Then(rangeTransform);
+ float opacity = v.Alpha / 255f;
+ var rawObject = _vm.Gfx.TryGet(v.Handle);
+ long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
+ var texture = rawObject != null
+ ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
+ : null;
+ _perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
+ bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
+
+ if (v.SurfaceTransition is { } transition)
+ {
+ _perf?.RecordTransitionLayer();
+ DrawTransitionRangeGpu(_visibleSnapshot, transition);
+ }
+ else if (v.SurfaceResId == 0 && texture == null)
+ {
+ if (v.Blend != BlendKind.Opaque)
+ {
+ int width = v.W > 0 ? v.W : ScreenWidth;
+ int height = v.H > 0 ? v.H : ScreenHeight;
+ float fillOpacity = v.MultiplyTint
+ ? opacity
+ : opacity * v.TintStrength / 255f;
+ _perf?.RecordFillLayer();
+ if (_gpuRenderer.DrawFill(width, height, affine, v.Tint, fillOpacity))
+ _perf?.RecordGpuLayer(width, height, affine, ScreenWidth, ScreenHeight,
+ dynamic: false, BlendKind.Alpha);
+ }
+ else _perf?.RecordSkippedLayer();
+ }
+ else
+ {
+ 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();
+ else
+ {
+ var resolved = texture.Value;
+ bool drawn = _gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, v.ColorKey,
+ v.SrcX, v.SrcY, v.W, v.H, affine, v.Tint, v.TintStrength,
+ opacity, v.MultiplyTint, resolved.IsDynamic,
+ rawObject?.SourceSlot ?? v.Handle, v.Blend);
+ if (drawn)
+ _perf?.RecordGpuLayer(v.W, v.H, affine, ScreenWidth, ScreenHeight,
+ resolved.IsDynamic, v.Blend);
+ }
+ }
+
+ if (rawObject != null)
+ {
+ _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;
+ var textPos = affine.Apply(surfaceText.X - v.SrcX, surfaceText.Y - v.SrcY);
+ var label = GetSurfaceTextLabel(surfaceTextLabelIndex++);
+ label.Position = new Vector2((float)textPos.X, (float)textPos.Y);
+ label.Size = new Vector2(System.Math.Max(1, v.W - (surfaceText.X - v.SrcX)),
+ System.Math.Max(1, v.H - (surfaceText.Y - v.SrcY)));
+ label.Text = surfaceText.Text;
+ ApplyAdvTextStyle(label, surfaceText.Style);
+ label.Visible = true;
+ }
+ }
+ }
+
+ var stats = _gpuRenderer.EndFrame();
+ _perf?.RecordGpu(stats.DrawItems, stats.TextureUploads, stats.TextureUploadTicks);
+ _screenView.Visible = false;
+ _gpuRenderer.Visible = true;
+ _perf?.EndRecomposite();
+ return true;
+ }
+
+ // Native type-0 retained range transition: range A has already passed through ordinary z-order;
+ // republish range B at the transition placeholder with progress-scaled source opacity. This mirrors
+ // DrawTransitionRange's software-oracle order without allocating an offscreen CPU surface.
+ private int DrawTransitionRangeGpu(IReadOnlyList visible, SurfaceTransitionState transition)
+ {
+ int drawn = 0;
+ long end = transition.RangeBStart + transition.RangeBCount;
+ foreach (var source in visible)
+ {
+ 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)
+ {
+ _perf?.RecordSkippedLayer();
+ continue;
+ }
+ int width = source.W > 0 ? source.W : ScreenWidth;
+ int height = source.H > 0 ? source.H : ScreenHeight;
+ _perf?.RecordFillLayer();
+ if (_gpuRenderer.DrawFill(width, height, affine, source.Tint,
+ opacity * source.TintStrength / 255f))
+ {
+ _perf?.RecordGpuLayer(width, height, affine, ScreenWidth, ScreenHeight,
+ dynamic: false, BlendKind.Alpha);
+ drawn++;
+ }
+ 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;
+ }
+ var resolved = texture.Value;
+ if (_gpuRenderer.DrawTexture(resolved.Image, resolved.AssetId, source.ColorKey,
+ source.SrcX, source.SrcY, source.W, source.H, affine, source.Tint, source.TintStrength,
+ opacity, source.MultiplyTint, resolved.IsDynamic,
+ rawObject?.SourceSlot ?? source.Handle, source.Blend))
+ {
+ _perf?.RecordGpuLayer(source.W, source.H, affine, ScreenWidth, ScreenHeight,
+ resolved.IsDynamic, source.Blend);
+ drawn++;
+ }
+ }
+ return drawn;
+ }
+
+ private void RecompositeSoftware(IReadOnlyList? sampledVisible = null)
{
if (_perf != null)
{
@@ -796,11 +1005,12 @@ public partial class Main : Godot.Control
{
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
- _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); // synchronized objects + ranges
+ if (sampledVisible == null)
+ _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);
+ CompositeVisibleObjects(sampledVisible ?? _visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
}
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
diff --git a/godot/PerformanceFrameLog.cs b/godot/PerformanceFrameLog.cs
index ee55328..2ee0e92 100644
--- a/godot/PerformanceFrameLog.cs
+++ b/godot/PerformanceFrameLog.cs
@@ -36,7 +36,8 @@ public sealed class PerformanceFrameLog : IDisposable
"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," +
+ "ui_allocated_bytes,gen0,gen1,gen2,recomposited,render_backend,screen_transition," +
+ "gpu_draw_items,gpu_texture_uploads,gpu_texture_upload_ms," +
"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," +
@@ -95,6 +96,13 @@ public sealed class PerformanceFrameLog : IDisposable
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 RecordGpu(int drawItems, int textureUploads, long textureUploadTicks)
+ {
+ _current.GpuBackend = true;
+ _current.GpuDrawItems += drawItems;
+ _current.GpuTextureUploads += textureUploads;
+ _current.GpuTextureUploadTicks += textureUploadTicks;
+ }
public void BeginRecomposite(bool screenTransition)
{
@@ -131,8 +139,22 @@ public sealed class PerformanceFrameLog : IDisposable
int destinationWidth, int destinationHeight, bool dynamic,
BlendKind blend, long ticks)
{
- _current.DrawLayers++;
_current.RasterTicks += ticks;
+ RecordLayer(sourceWidth, sourceHeight, localToDest, destinationWidth, destinationHeight,
+ dynamic, blend);
+ }
+
+ public void RecordGpuLayer(int sourceWidth, int sourceHeight, Affine2D localToDest,
+ int destinationWidth, int destinationHeight, bool dynamic,
+ BlendKind blend)
+ => RecordLayer(sourceWidth, sourceHeight, localToDest, destinationWidth, destinationHeight,
+ dynamic, blend);
+
+ private void RecordLayer(int sourceWidth, int sourceHeight, Affine2D localToDest,
+ int destinationWidth, int destinationHeight, bool dynamic,
+ BlendKind blend)
+ {
+ _current.DrawLayers++;
_current.SourcePixels += Math.Max(0L, (long)sourceWidth * sourceHeight);
long candidates = EstimateCandidatePixels(localToDest, sourceWidth, sourceHeight,
destinationWidth, destinationHeight);
@@ -228,7 +250,9 @@ public sealed class PerformanceFrameLog : IDisposable
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.Recomposited ? 1 : 0); Append(b, f.GpuBackend ? 1 : 0);
+ Append(b, f.ScreenTransition ? 1 : 0);
+ Append(b, f.GpuDrawItems); Append(b, f.GpuTextureUploads); AppendTicks(b, f.GpuTextureUploadTicks);
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);
@@ -286,12 +310,13 @@ public sealed class PerformanceFrameLog : IDisposable
public string Script = "";
public string PresentScript = "";
public int PresentOffset = -1, PresentOpcode = -1;
- public bool Recomposited, ScreenTransition;
+ public bool Recomposited, GpuBackend, 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;
+ public long GpuDrawItems, GpuTextureUploads, GpuTextureUploadTicks;
}
}