Adopt GPU retained renderer

This commit is contained in:
gamer147
2026-07-22 15:37:42 -04:00
parent 4ea352e45d
commit 197adf2e1a
7 changed files with 626 additions and 19 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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")]);

View File

@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using Godot;
using Age.Engine.Model;
using Age.Engine.Sys4;
/// <summary>
/// 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.
/// </summary>
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<Sprite2D> _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();
}
}

View File

@@ -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<SurfaceTextDraw> _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 ?? "<startup>",
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<RenderObject> 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<RenderObject>? 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;

View File

@@ -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 = "<unknown>";
public string PresentScript = "<none>";
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;
}
}