Profile and optimize retained rendering

This commit is contained in:
gamer147
2026-07-22 14:19:20 -04:00
parent 4c9719c5dc
commit 4ea352e45d
17 changed files with 1619 additions and 144 deletions

View File

@@ -2309,11 +2309,10 @@ sampled coordinates.
ablations only if the phase timings do not isolate the cost. Commit the baseline percentiles and exact ablations only if the phase timings do not isolate the cost. Commit the baseline percentiles and exact
canonical runtime coordinates here. A real unit-heavy story dungeon is deferred until it is practical canonical runtime coordinates here. A real unit-heavy story dungeon is deferred until it is practical
to reach reproducibly. to reach reproducibly.
- [ ] **P2 - fidelity-neutral CPU wins.** Remove normal-path diagnostic allocation and refine the ambiguous - [x] **P2 - fidelity-neutral CPU wins.** P2a-P2f are implemented and measured. DEBUGMAP now meets its p95
presentation/affine counters, then avoid recomposition between discrete spritesheet cell changes and frame budget with near-zero steady allocation; the SC0000 exit capture remains 65.18/71.76 ms p50/p95 in
benchmark translation, affine, and safe opaque raster fast paths. Land only changes with measured wins the severe full-screen/additive band, so P2 closes and triggers P3 rather than more CPU special cases.
and focused differential raster/presentation coverage. - [ ] **P3 - GPU retained-renderer prototype (triggered).** Upload decoded/color-key texture variants once and mirror
- [ ] **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, 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; 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. compare selected frames against the software oracle behind a backend switch.
@@ -2421,19 +2420,19 @@ few layers but extreme full-screen/additive pixel work, while DEBUGMAP has hundr
many sampled through the affine path, and discrete animation that currently requests a recomposition every many sampled through the affine path, and discrete animation that currently requests a recomposition every
Godot frame. Execute the following in order, retaining the software compositor as the pixel oracle. Godot frame. Execute the following in order, retaining the software compositor as the pixel oracle.
1. **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings 1. [x] **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings
and final `z` decision strings only when `--gfx-log` or `--timeline-log` actually supplied a decisions and final `z` decision strings only when `--gfx-log` or `--timeline-log` actually supplied a decisions
dictionary. Preserve byte-for-byte diagnostic output when enabled. Re-run DEBUGMAP and require a large dictionary. Preserve byte-for-byte diagnostic output when enabled. Re-run DEBUGMAP and require a large
reduction from its approximately 3.59 MB median allocation per recomposition without worsening raster reduction from its approximately 3.59 MB median allocation per recomposition without worsening raster
time; if less than 70% disappears, use an allocation trace to identify the remaining owners before time; if less than 70% disappears, use an allocation trace to identify the remaining owners before
doing speculative collection tuning. doing speculative collection tuning.
2. **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as 2. [x] **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as
needed to distinguish VM-requested, continuous-channel, and discrete-cell presentation, and to split needed to distinguish VM-requested, continuous-channel, and discrete-cell presentation, and to split
pure fractional translation from scale/rotation/general affine work. The current log proves that the pure fractional translation from scale/rotation/general affine work. The current log proves that the
broad categories matter but cannot tell whether DEBUGMAP's roughly 526 apparent affine layers are broad categories matter but cannot tell whether DEBUGMAP's roughly 526 apparent affine layers are
camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on
that assumption alone. that assumption alone.
3. **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a 3. [x] **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a
host-frame redraw timer. One shared current/previous millisecond frame-time pair feeds every channel; host-frame redraw timer. One shared current/previous millisecond frame-time pair feeds every channel;
op `0x231` compares the source cell selected at those two samples and raises graphics dirty only when it op `0x231` compares the source cell selected at those two samples and raises graphics dirty only when it
changes. Retained mutations and genuinely continuous channels still redraw as required, while an changes. Retained mutations and genuinely continuous channels still redraw as required, while an
@@ -2444,21 +2443,28 @@ Godot frame. Execute the following in order, retaining the software compositor a
deadlines. Cover clone-before/after-first-sample, reconfiguration, differing periods, wraparound, and deadlines. Cover clone-before/after-first-sample, reconfiguration, differing periods, wraparound, and
exact boundary cases. The DEBUGMAP acceptance metric is recompositions per second and total delivered exact boundary cases. The DEBUGMAP acceptance metric is recompositions per second and total delivered
frame time; the separately reported p50/p95 cost of frames that do recompose must remain visible. frame time; the separately reported p50/p95 cost of frames that do recompose must remain visible.
4. **P2d - turn translation-like affine work into a verified fast path.** If P2b confirms pure unit-matrix 4. [x] **P2d - specialize the measured axis-aligned scale work.** P2b disproved the fractional-translation
fractional translations, implement the nearest-neighbor-equivalent translated raster path and prove it hypothesis: the new DEBUGMAP capture reports zero fractional and zero general-affine layers, with every
non-integer layer classified as axis-aligned scale. Implement the nearest-neighbor-equivalent scale path and prove it
byte-for-byte against the existing inverse-mapped oracle across positive/negative coordinates, byte-for-byte against the existing inverse-mapped oracle across positive/negative coordinates,
half-pixel boundaries, clipping, opacity/tint, and every blend mode. This is the highest-potential half-pixel boundaries, clipping, opacity/tint, and every blend mode. This is the highest-potential
DEBUGMAP per-compose CPU win because the existing translated loop is much cheaper than a general matrix DEBUGMAP per-compose CPU win because the existing translated loop is much cheaper than a general matrix
inverse and transform per destination pixel. inverse and transform per destination pixel.
5. **P2e - specialize the remaining hot pixel loops.** In descending evidence value, benchmark: 5. [x] **P2e - specialize the remaining hot pixel loops.** The measured axis-aligned scale specialization is
incremental inverse coordinates across an affine scanline; axis-aligned scale specialization; and complete. Benchmark opaque/full-opacity/unmodulated translated and scaled pixel loops next, using direct
opaque/full-opacity/unmodulated translated row copies or alpha-run copies where source transparency copies for fully opaque source texels and the existing blend arithmetic at transparent edges. General
permits. Use randomized differential raster tests against the current implementation plus the SC0000 incremental inverse coordinates are low priority because the measured FIELD workload has no general-
affine layers. Use randomized differential raster tests against the current implementation plus the SC0000
and DEBUGMAP captures. Address the dynamic color-key full-frame clone separately because it explains an and DEBUGMAP captures. Address the dynamic color-key full-frame clone separately because it explains an
allocation/source-prep spike but neither primary raster plateau. Do not spend time on snapshot, clear, allocation/source-prep spike but neither primary raster plateau. Do not spend time on snapshot, clear,
`Image.SetData`, texture upload, VM dispatch, or parallel rasterization while their measured contribution is `Image.SetData`, texture upload, VM dispatch, or parallel rasterization while their measured contribution is
small or a retained GPU renderer is the cleaner boundary. small or a retained GPU renderer is the cleaner boundary.
6. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs. 6. [x] **P2f - remove the measured per-frame collection owners.** Replace `Transform2DMath.Build`'s heap
4x4 matrices with an equivalent value-type affine-3D representation, and let the compositor reuse a
caller-owned, handle-sorted `RenderObject` snapshot buffer. Preserve the allocating snapshot overload for
callers which retain independent samples. Require exact matrix/sort differential coverage and direct
zero-allocation assertions after warm-up; confirm the real path with the allocation-phase CSV columns.
7. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs.
Report both end-to-end cadence and recomposed-frame p50/p95/p99. If either workload's required Report both end-to-end cadence and recomposed-frame p50/p95/p99. If either workload's required
recompositions remain above 16.67 ms p95, begin P3 rather than accumulating increasingly complex CPU recompositions remain above 16.67 ms p95, begin P3 rather than accumulating increasingly complex CPU
special cases. The expected P3 prototype mirrors ordinary translated/atlas dungeon objects into Godot special cases. The expected P3 prototype mirrors ordinary translated/atlas dungeon objects into Godot
@@ -2469,6 +2475,217 @@ Godot frame. Execute the following in order, retaining the software compositor a
Each landed optimization gets a before/after row in this document with capture path, canonical runtime Each landed optimization gets a before/after row in this document with capture path, canonical runtime
coordinate, p50/p95/p99, allocation, recomposition rate, and raster-work counters. Revert or leave behind a coordinate, p50/p95/p99, allocation, recomposition rate, and raster-work counters. Revert or leave behind a
disabled experiment when it does not produce a repeatable real-path improvement. The immediate disabled experiment when it does not produce a repeatable real-path improvement. The next capture validates
implementation slice is P2a followed by P2b; those make the next user capture cheaper and sufficiently P2a-P2c together and uses P2b's new categories to choose P2d without another story-progression dependency.
specific to choose the correct P2c/P2d implementation without another story-progression dependency.
### P2a-P2c implementation checkpoint (2026-07-22)
Normal runs no longer construct per-object compositor outcome or final z-decision strings unless
`--gfx-log` or `--timeline-log` requested that evidence. This removes the known DEBUGMAP diagnostic-only
allocation source while preserving the diagnostic path. The performance CSV now records five independent
presentation reasons (host request, legacy screen transition, retained mutation, continuous channel, and
discrete source-cell change) and separates fractional translation, axis-aligned scale, and general affine
layers while retaining the original aggregate affine count.
`GfxState` now publishes retained mutation generations once and samples one shared current/previous frame
time pair on every host tick. Continuously varying channels remain frame-driven. Visible op-`0x231`
spritesheets request composition only when their selected cells differ between the shared samples, and
clone-before-first-sample records seed together. VM object writes that previously escaped the model lock are
now applied through synchronized setters so publication cannot race ahead of the retained write. Opcode
`0xc8` sleep completion no longer requests presentation by itself; preceding graphics writes are covered by
the mutation generation instead.
Focused coverage includes single-publication mutation behavior, exact cell boundaries and wrap, differing
object-local periods, reconfiguration, clones made before and after the first sample, continuous-channel
behavior, and a 51-object phase-locked family producing five—not 255—source-cell publication events per
second. The complete engine suite, Godot build/selftest, and a new comparable windowed DEBUGMAP capture are
the closeout gates. Until that capture is analyzed, the allocation and recomposition improvements are
implemented expectations rather than measured before/after results.
### P2 capture 3 - DEBUGMAP after allocation/cadence changes (2026-07-22)
`build/perf/run-20260722-121910-248.csv` is the first post-P2a-P2c windowed DEBUGMAP run. Its FIELD interval
contains 1,028 frames over 53.5 seconds and 1,023 recompositions (19.12/s), so discrete-cell scheduling did
not reduce end-to-end cadence in this workload. The reason columns explain why rather than invalidating the
shared-cell implementation: 1,014 FIELD rows have a continuous-channel reason. FIELD creates one visible
op-`0x232` color pulse at `FIELD@0x9691` (handle `0xc802`, period 2,000 ms), which legitimately changes the
composed output between spritesheet boundaries. The 50 ms hover callback also produced 712 host-publication
requests, including idle callbacks; callback completion is now no longer treated as dirty, while actual
retained and host-surface mutations continue to publish themselves.
P2a was useful but missed its stated allocation gate. Recomposition allocation p50 fell from 3,593,400 to
3,172,128 bytes (-421,272, 11.7%); p95 fell from 3,629,816 to 3,178,544 bytes (-451,272, 12.4%). Raster p50
was effectively unchanged (45.43 versus 45.52 ms), as expected for an allocation-only edit. The next logger
schema splits remaining allocation among snapshot, composition, source preparation, `Image.SetData`, and UI
so the next capture supplies bounded allocation attribution rather than prompting collection tuning.
The new transform counters decisively redirect P2d. Recomposed FIELD frames average 447.7 integer layers and
506.3 axis-aligned-scale layers; fractional-translation and general-affine maxima are both zero. The busy
28-34 second interval averages about 2.35-2.39 million candidates and 54 ms main time, while the final
off-map idle interval falls to about 1.58 million and 26 ms. P2d therefore adds an axis-aligned inverse-map
path which caches the source column once per layer and computes the source row once per scanline, retaining
the existing center-sample/floor and blend arithmetic. Pooled lookup storage avoids per-layer garbage.
Five hundred deterministic randomized blit/fill cases compare byte-for-byte with the previous general
inverse-mapped oracle across positive/negative scales, fractional placement, clipping, opacity, tint,
multiplicative modulation, and alpha/additive/opaque modes. A repeat DEBUGMAP capture is still required to
measure the real-path raster win and the new allocation phases. All 345 engine tests pass and the Godot
project builds with zero warnings/errors.
An environment audit after this capture found four Godot game/console pairs still alive from July 11 and
July 21. Each game process consumed about 0.41 CPU-seconds during a two-second sample (roughly 80% of one
logical core in aggregate). They predate both P1 and this capture, so workload attribution, allocation deltas,
and the transform-category correction remain actionable, but its absolute frame-time acceptance numbers are
provisional. The eight processes were terminated with user authorization before the following capture.
### P2 capture 4 - DEBUGMAP after axis-aligned scale specialization (2026-07-22)
`build/perf/run-20260722-130024-542.csv` is the clean post-P2d windowed comparison, captured after the stale
Godot processes above were closed. The stable FIELD workload still visits about 962 objects and draws about
961 layers: approximately 433 integer translations, 526 axis-aligned scales, zero fractional translations,
and zero general affine layers. Around 900 layers are classified opaque. Camera position changes clipped
candidate pixels, but it does not materially reduce retained traversal, layer count, source pixels, or the
continuous-channel presentation cadence.
For equivalent 2.2-2.5 million candidate-pixel frames, recomposition p50/p95/p99 fell from
53.71/60.50/62.72 ms in capture 3 to 21.52/22.26/22.75 ms. Raster p50/p95 fell from 50.77/57.27 ms to
19.07/19.52 ms: a 62.4% median raster reduction with the same workload band. The final 1.5-1.9 million
candidate-pixel off-map band fell from 27.34/32.34 ms recomposition p50/p95 to 14.73/15.89 ms. Delivered
steady cadence consequently rises from about 19 recompositions/s to 44-48/s over the busy map and about
64/s off-map. P2d is a large, repeatable real-path win; busy-map p95 nevertheless remains above the
16.67 ms P2 exit target.
The new allocation phases attribute the remaining steady busy-map p50/p95 almost exactly: total
3,178,544/3,178,544 bytes, retained snapshot 961,600/961,600, compositor 2,216,664/2,216,664, source
preparation zero, `Image.SetData` zero, and UI 88/88. Camera position barely changes that total because the
snapshot and compositor collections still cover the full retained set. Allocation is now a clear GC/long-run
stability target, but raster remains the direct busy-frame budget blocker. P2e therefore starts with the
full-opacity/unmodulated hot pixel loops used by the roughly 900 opaque layers; after that measured capture,
reduce the two identified per-frame collection owners rather than tuning the GC.
### P2e unmodulated source-over specialization implemented (2026-07-22)
The translated and axis-aligned-scale raster loops now detect full object opacity, zero effective tint,
non-multiplicative color, and non-additive blending once per layer. In that common mode, alpha-zero texels
remain skipped, alpha-255 texels become exact four-byte copies, and partially transparent edge texels retain
the previous integer source-over arithmetic. The general tinted, faded, multiplicative, and additive paths
are unchanged. This targets the approximately 900 opaque FIELD layers measured in capture 4 without
assuming that their color-keyed source rectangles contain no transparent pixels.
Four hundred focused translated/scaled cases cover both opaque and alpha blend classifications with source
alpha values 0, 1, 254, and 255 against the retained pre-fast-path oracle. The complete engine suite passes
at 346 tests, the Godot build has zero warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the same
DEBUGMAP camera/idle/off-map workload. Retain P2e only if the busy 2.2-2.5 million candidate-pixel band shows
a repeatable win; then address the measured snapshot/compositor allocation owners before the P2 exit capture.
### P2 capture 5 - DEBUGMAP after unmodulated source-over specialization (2026-07-22)
`build/perf/run-20260722-132318-886.csv` is the comparable post-P2e windowed run. In the matched 2.2-2.5
million candidate-pixel band, recomposition p50/p95/p99 fell from 21.52/22.26/22.75 ms to
15.35/16.18/19.26 ms, while raster p50/p95 fell from 19.07/19.52 ms to 12.66/13.23 ms. That is a 28.7%
median recomposition reduction and a 33.6% median raster reduction; steady busy-map delivery rises from
about 44-48 to 61-66 recompositions/s. The 1.5-1.9 million candidate off-map band falls from
14.73/15.89 ms recomposition p50/p95 to 9.45/11.21 ms. P2e is retained: busy-map p95 now fits the 16.67 ms
target, although allocation/GC outliers leave p99 above it.
Allocation remains unchanged at 3,178,544 bytes p50/p95 in the busy band. Capture-wide gen-0/gen-1/gen-2
counts are 184/52/34 over 3,111 FIELD recompositions, versus 147/49/34 over only 2,274 recompositions in
capture 4; normalized collection rates therefore do not regress, but short-lived garbage remains the clear
tail-latency and long-run-stability target.
### P2f measured allocation owners removed (2026-07-22)
The compositor allocation phase was dominated by `Transform2DMath.Build`: it created about eleven
`double[16]` matrices for every rendered object. It now composes the same row-vector operations through a
twelve-double value-type affine-3D matrix and projects to `Affine2D` only at the boundary. Five hundred
randomized scale/translation/anchor/one-shot/cyclic-rotation cases match every output double bit-for-bit
against the former array implementation, and 10,000 warmed builds allocate zero bytes.
The retained snapshot phase no longer uses `Dictionary.OrderBy` or returns a newly grown list to the Godot
hot path. `GfxState` maintains a sorted handle index alongside its O(1) object dictionary, and fills a
compositor-owned reusable `List<RenderObject>` under the existing lock. The returning overload remains for
callers needing an independent snapshot. Ten warmed 1,000-object samples allocate zero bytes and preserve
ascending handle order. The complete engine suite passes at 349 tests, the Godot build has zero
warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the comparable DEBUGMAP run and verify the
snapshot/compositor allocation columns collapse without changing the capture-5 frame-time distribution;
then repeat SC0000 for the P2 exit/P3 decision.
### P2 capture 6 - DEBUGMAP after primary allocation removal (2026-07-22)
`build/perf/run-20260722-133412-146.csv` confirms P2f's primary allocation changes on the real path. In the
matched busy band, total allocation p50/p95 falls from 3,178,544/3,178,544 to 262,160/262,160 bytes
(-91.8%). Retained snapshot allocation is exactly zero; compositor allocation falls from 2,216,664 to
261,880 bytes. Capture-wide gen-0/gen-1/gen-2 collections fall from 184/52/34 to 21/8/5 despite broadly
similar duration and 2,709 FIELD recompositions. Snapshot p50/p95 falls from 0.165/0.328 ms to
0.097/0.141 ms.
Raster remains stable at 12.70/13.36 ms p50/p95 versus capture 5's 12.66/13.23 ms. End-to-end busy
recomposition improves slightly from 15.35/16.18/19.26 ms p50/p95/p99 to 15.10/15.87/16.25 ms; the much
tighter p99 is consistent with the measured GC reduction. Matched off-map recomposition improves from
9.45/11.21/13.20 ms to 9.04/10.02/11.35 ms. P2f therefore preserves the raster win while removing the
large short-lived collection owners.
The stable 261,880-byte compositor remainder scales almost exactly with FIELD's approximately 961 retained
objects. The first hypothesis was the per-object `SnapshotSurfaceText` array copy, so the compositor was
changed to reuse a caller-owned `List<SurfaceTextDraw>` under the existing text lock; the returning overload
remains available to callers needing an independent snapshot. Capture 7 below disproves that attribution.
### P2 capture 7 - residual allocation probe (2026-07-22)
`build/perf/run-20260722-134322-530.csv` is the requested short steady DEBUGMAP idle probe. Across 1,208
full FIELD recompositions, total allocation remains 259,952/261,728 bytes p50/p95, with snapshot allocation
zero and compositor allocation 259,672/261,448 bytes. The surface-text buffer therefore has no material
effect in this workload and is only a harmless general cleanup. Recomposition remains in the expected
camera-dependent range at 14.19/16.46 ms p50/p95 for an average 2.38 million candidate pixels.
The exact remaining owner is `MovieSurfaceRegistry.TryResolveResource`: every ordinary still-texture
fallback constructed a LINQ `Where` plus descending sort pipeline to look for a live movie frame, including
when the movie registry was empty. This occurred once per retained object and explains the stable roughly
270 bytes/object remainder. The registry now scans its live bindings directly while retaining the rule that
the newest published playback of a resource wins. A focused test covers matching/missing lookups and newest-
playback selection; 10,000 pairs allocate zero bytes after warm-up. All 350 engine tests, the zero-warning
Godot build, and threaded `SELFTEST OK` pass. **Next:** one final short DEBUGMAP idle probe confirms the
compositor remainder is gone, then SC0000 through the pre-CHAPTER burst supplies the P2 exit/P3 decision.
### P2 capture 8 - DEBUGMAP allocation closeout (2026-07-22)
`build/perf/run-20260722-140820-530.csv` confirms the movie-registry correction. Across 820 full steady
FIELD recompositions, total allocation is 4,416 bytes p50/p95/p99 and compositor allocation is 4,136 bytes;
snapshot, source preparation, and `Image.SetData` remain zero, while UI accounts for 88 bytes. This is a
99.86% reduction from capture 4's 3,178,544-byte steady total and a 98.3% reduction from capture 7's
259,952-byte residual. FIELD itself performs zero gen-0, gen-1, or gen-2 collections in this probe; every
capture-wide collection occurred during boot/map setup.
One FIELD row allocates 1,415,744 bytes during composition at `FIELD.BIN@0x1029` as a one-time reusable-
capacity/cache warm-up. It triggers no collection and completes in 14.78 ms, so it is neither a steady owner
nor a visible stall. At an average 2.32 million candidate pixels, recomposition p50/p95/p99 is
13.90/15.86/18.89 ms and raster p50/p95 is 11.32/13.22 ms, consistent with the post-P2e/P2f distribution.
DEBUGMAP therefore closes with busy p95 inside 16.67 ms and effectively allocation-free steady rendering.
**Next:** repeat SC0000 through the original pre-CHAPTER burst and compare against capture 1's exact
`SC0000@0x123de` plateau before deciding whether P2 exits or the full-screen/additive case triggers P3.
### P2 exit capture 9 - SC0000 pre-CHAPTER and post-movie text (2026-07-22)
`build/perf/run-20260722-141200-995.csv` repeats capture 1 through the CHAPTER movie and the following text.
Across every recomposition parked at `SC0000.BIN@0x123de`, recomposition p50/p95/p99 improves from
21.85/57.40/82.70 ms to 15.39/31.71/66.38 ms. Movie sampling itself remains small: twelve active rows have
`movie_ms` p50/p95/max 0.20/0.99/1.06 ms, so decoding/presentation is not the reported burst bottleneck.
The acceptance decision uses equivalent severe rows rather than the mixed coordinate aggregate. For frames
with at least 3.5 million candidate pixels, recomposition p50/p95/p99 falls from 76.43/88.42/90.11 ms to
65.15/71.74/72.03 ms; raster p50/p95 remains 64.11/71.15 ms. These 52 rows average 4.29 million candidate
pixels, 10.7 layers, 4.6 additive layers, 8.3 viewport-covering layers, two general-affine layers, and less
than 0.9 KB managed allocation. The worst comparable frame is 72.21 ms. P2's CPU fast paths therefore save
roughly 15-19% in this burst but leave it around 14-15 delivered frames/s, more than four times the
16.67 ms budget. No allocation or scheduler optimization can close that gap.
A separate later interval still clones about 1.92 MB per recomposition while applying a color key to a
dynamic frame. It is an independent GC issue already anticipated by the action plan, but it does not occur
in the severe additive plateau and cannot change the exit decision. Treat it as part of the GPU texture/
shader ownership work rather than delaying P3 for another software-raster special case.
**P2 exit decision:** close the fidelity-neutral CPU batch and begin P3. The prototype mirrors ordinary
retained texture/fill objects into GPU-native Godot drawing behind a backend switch, preserves handle z-order,
atlas source rectangles, colorkey, tint/opacity, additive blending, and the exact affine transform, and keeps
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.

View File

@@ -217,10 +217,19 @@ time, pulse/movie/UI time, and a compositor breakdown: recomposition, clear, ret
resolution/decode, color-key/source preparation, raster, `Image.SetData`, and `ImageTexture.Update`. Workload columns report transition resolution/decode, color-key/source preparation, raster, `Image.SetData`, and `ImageTexture.Update`. Workload columns report transition
state, retained object visits, time-varying object visits, drawn/fill/transition/skipped layers, integer/ state, retained object visits, time-varying object visits, drawn/fill/transition/skipped layers, integer/
affine/singular raster paths, dynamic/opaque/alpha/additive layers, source pixels, clipped affine bounding- affine/singular raster paths, dynamic/opaque/alpha/additive layers, source pixels, clipped affine bounding-
box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas. The box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas.
Presentation-reason columns distinguish host publication requests, legacy screen transitions, retained VM
mutations, continuously sampled channels, and discrete spritesheet cell changes. Affine work is additionally
split into fractional translation, axis-aligned scale, and general affine layers while retaining the aggregate
`affine_layers` column for comparison with the first two captures. The
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 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 normal shutdown. Use a windowed Release-equivalent run at speed 1 for performance evidence; headless runs
validate the schema only. Do not combine baseline captures with `--shot-sequence`, `--gfx-log`, or 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
combine baseline captures with `--shot-sequence`, `--gfx-log`, or
`--timeline-log`, whose diagnostics add substantial or differently shaped work. Example from `age-reimpl/`: `--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`. `godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`.

View File

@@ -29,6 +29,7 @@
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" /> <Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" /> <Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" /> <Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
<Compile Include="..\..\godot\PerformanceFrameLog.cs" Link="PerformanceFrameLog.cs" />
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" /> <Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" /> <Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" /> <Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />

View File

@@ -25,6 +25,25 @@ public class GfxAnimationTests
Assert.True(o.TranslationEnabled); Assert.True(o.TranslationEnabled);
} }
[Fact]
public void CallerOwnedVisibleSnapshot_ReusesStorageAndPreservesHandleOrder()
{
var g = new GfxState();
for (int i = 999; i >= 0; i--)
g.BindDraw(0x1000 + i, 1, 0, 0, 1, 1, i, 0);
var snapshot = new List<RenderObject>();
g.SnapshotVisibleObjects(0, snapshot); // Grow and warm the buffer outside the measured interval.
long before = GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 10; i++) g.SnapshotVisibleObjects(i, snapshot);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.Equal(0, allocated);
Assert.Equal(1000, snapshot.Count);
Assert.Equal(0x1000, snapshot[0].Handle);
Assert.Equal(0x1000 + 999, snapshot[^1].Handle);
}
[Fact] [Fact]
public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor() public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor()
{ {
@@ -94,18 +113,22 @@ public class GfxAnimationTests
var unchanged = VisibleObject(); var unchanged = VisibleObject();
Assert.False(unchanged.HasActiveVisualPresentation(1000)); Assert.False(unchanged.HasActiveVisualPresentation(1000));
Assert.False(unchanged.SnapshotVisibleObjects(1000).Single().TimeVarying);
var spritesheet = VisibleObject(); var spritesheet = VisibleObject();
spritesheet.SetSrcRect(7, 4, 1, 0, 800); spritesheet.SetSrcRect(7, 4, 1, 0, 800);
Assert.True(spritesheet.HasActiveVisualPresentation(1000)); Assert.True(spritesheet.HasActiveVisualPresentation(1000));
Assert.True(spritesheet.SnapshotVisibleObjects(1000).Single().TimeVarying);
var color = VisibleObject(); var color = VisibleObject();
color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000)); color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
Assert.True(color.HasActiveVisualPresentation(1000)); Assert.True(color.HasActiveVisualPresentation(1000));
Assert.True(color.SnapshotVisibleObjects(1000).Single().TimeVarying);
var rotation = VisibleObject(); var rotation = VisibleObject();
rotation.SetRotationCycle(7, 1000, (0, 0, 1)); rotation.SetRotationCycle(7, 1000, (0, 0, 1));
Assert.True(rotation.HasActiveVisualPresentation(1000)); Assert.True(rotation.HasActiveVisualPresentation(1000));
Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying);
} }
[Fact] [Fact]
@@ -129,6 +152,108 @@ public class GfxAnimationTests
Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H)); Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H));
} }
[Fact]
public void PresentationReasons_PublishMutationOnce_AndSpritesheetOnlyAtCellBoundaries()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(7, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(7, frameCount: 4, columns: 2, cell: 0, period: 200);
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1399));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1599));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1600));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1799));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1800));
}
[Fact]
public void PresentationReasons_CloneBeforeFirstSampleSharesPhase_AndCloneAfterKeepsIt()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 200);
Assert.True(g.CloneObject(10, 11));
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
Assert.Equal(1000, g.TryGet(10)!.SrcStart);
Assert.Equal(1000, g.TryGet(11)!.SrcStart);
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 10).SrcX,
g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 11).SrcX);
Assert.True(g.CloneObject(10, 12));
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
Assert.Equal(1000, g.TryGet(12)!.SrcStart);
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
var cells = g.SnapshotVisibleObjects(1400).Select(x => x.SrcX).Distinct().ToArray();
Assert.Single(cells);
}
[Fact]
public void PresentationReasons_UsesObjectLocalPeriods_AndReconfigurationRestartsAtSharedSample()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
g.BindDraw(11, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 100);
g.SetSrcRect(11, frameCount: 4, columns: 2, cell: 0, period: 250);
g.ConsumePresentationReasons(1000);
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1099));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1100));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1249));
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1250));
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 400);
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
Assert.Equal(1250, g.TryGet(10)!.SrcStart);
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1499));
// The second object's 250 ms boundary and the reconfigured object's 400 ms boundary coincide here.
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1650));
}
[Fact]
public void PresentationReasons_ContinuousChannelRemainsFrameDriven()
{
var g = new GfxState();
g.SetSurface(1, 5, -1);
g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0);
g.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
var first = g.ConsumePresentationReasons(1000);
Assert.True((first & GfxPresentationReason.RetainedMutation) != 0);
Assert.True((first & GfxPresentationReason.ContinuousChannel) != 0);
Assert.Equal(GfxPresentationReason.ContinuousChannel, g.ConsumePresentationReasons(1016));
}
[Fact]
public void PresentationReasons_PhaseLockedUnitFamilyPublishesFiveCellChangesPerSecond()
{
var g = new GfxState();
g.SetSurface(4, 0x37, -1);
g.BindDraw(100, 4, 0, 0, 16, 16, 0, 0);
g.SetSrcRect(100, frameCount: 4, columns: 2, cell: 0, period: 200);
for (long handle = 101; handle < 151; handle++) Assert.True(g.CloneObject(100, handle));
g.ConsumePresentationReasons(1000);
int cellChanges = 0;
for (long now = 1001; now <= 2000; now++)
if ((g.ConsumePresentationReasons(now) & GfxPresentationReason.DiscreteSourceCell) != 0)
cellChanges++;
Assert.Equal(5, cellChanges);
Assert.Single(g.Objects.Select(pair => g.TryGet(pair.Handle)!.SrcStart).Distinct());
}
[Fact] [Fact]
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample() public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
{ {

View File

@@ -60,6 +60,33 @@ public class MovieSurfaceRegistryTests
Assert.False(registry.IsKnownMovieResource(0x2af5)); Assert.False(registry.IsKnownMovieResource(0x2af5));
} }
[Fact]
public void ResourceLookup_SelectsNewestPublishedPlaybackWithoutAllocating()
{
var registry = new MovieSurfaceRegistry();
MovieSurfaceBinding older = registry.Begin(7, 0x2b42, out _);
MovieSurfaceBinding unrelated = registry.Begin(8, 0x2bad, out _);
MovieSurfaceBinding newer = registry.Begin(9, 0x2b42, out _);
Assert.True(registry.PublishFrame(older.PlaybackId, Frame(1), "OLDER.AGF", 1));
Assert.True(registry.PublishFrame(unrelated.PlaybackId, Frame(2), "OTHER.AGF", 2));
Assert.True(registry.PublishFrame(newer.PlaybackId, Frame(3), "NEWER.AGF", 3));
Assert.True(registry.TryResolveResource(0x2b42, out MovieSurfaceFrame? warm));
Assert.Equal("NEWER.AGF", warm!.Name);
Assert.False(registry.TryResolveResource(0x2af5, out _));
long before = GC.GetAllocatedBytesForCurrentThread();
bool resultsCorrect = true;
for (int i = 0; i < 10_000; i++)
{
resultsCorrect &= registry.TryResolveResource(0x2b42, out _);
resultsCorrect &= !registry.TryResolveResource(0x2af5, out _);
}
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(resultsCorrect);
Assert.Equal(0, allocated);
}
private static RgbaImage Frame(byte value) private static RgbaImage Frame(byte value)
=> new(1, 1, new[] { value, value, value, (byte)255 }); => new(1, 1, new[] { value, value, value, (byte)255 });
} }

View File

@@ -0,0 +1,97 @@
using Age.Engine.Model;
public class PerformanceFrameLogTests
{
[Fact]
public void WritesStableCsvSchemaAndFrameWorkload()
{
string path = System.IO.Path.Combine(System.IO.Path.GetTempPath(),
$"age-perf-{Guid.NewGuid():N}.csv");
try
{
using (var log = new PerformanceFrameLog(path))
{
log.BeginFrame(17, 1234, 1.0 / 60.0, "SC0000.BIN", 0x2a91, 0x20c);
log.BeginRecomposite(false);
log.RecordPresentationReasons(1 | 4 | 16);
log.RecordRecomposeAllocation(4096);
log.RecordSnapshotAllocation(512);
log.RecordCompositeAllocation(2048);
log.RecordSourcePrepAllocation(128);
log.RecordSetDataAllocation(1024);
log.RecordUiAllocation(64);
log.RecordPresentationCoordinate("SC0000.BIN", 0x2aaa, 0x21c);
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.RecordFillLayer();
log.RecordSkippedLayer();
log.EndRecomposite();
log.EndFrame();
Assert.Equal(1, log.FrameCount);
Assert.Equal(1, log.RecompositeCount);
}
string[] lines = File.ReadAllLines(path);
Assert.Equal(2, lines.Length);
string[] header = lines[0].Split(',');
string[] row = lines[1].Split(',');
Assert.Equal(header.Length, row.Length);
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, "present_host_request")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_retained_mutation")]);
Assert.Equal("1", row[Array.IndexOf(header, "present_discrete_cell")]);
Assert.Equal("0", row[Array.IndexOf(header, "present_continuous_channel")]);
Assert.Equal("4096", row[Array.IndexOf(header, "recompose_allocated_bytes")]);
Assert.Equal("512", row[Array.IndexOf(header, "snapshot_allocated_bytes")]);
Assert.Equal("2048", row[Array.IndexOf(header, "composite_allocated_bytes")]);
Assert.Equal("128", row[Array.IndexOf(header, "source_prep_allocated_bytes")]);
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("SC0000.BIN", row[Array.IndexOf(header, "script")].Trim('"'));
Assert.Equal("10897", row[Array.IndexOf(header, "offset")]);
Assert.Equal("524", row[Array.IndexOf(header, "opcode")]);
Assert.Equal("10922", row[Array.IndexOf(header, "present_offset")]);
Assert.Equal("540", row[Array.IndexOf(header, "present_opcode")]);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Theory]
[InlineData(0, 0, 10, 10, 100)]
[InlineData(-5, -5, 10, 10, 25)]
[InlineData(95, 95, 10, 10, 25)]
[InlineData(200, 200, 10, 10, 0)]
public void CandidatePixelsClipsIntegerTranslations(int x, int y, int width, int height, long expected)
{
var transform = new Affine2D(1, 0, 0, 1, x, y);
Assert.True(PerformanceFrameLog.IsIntegerTranslation(transform));
Assert.Equal(expected,
PerformanceFrameLog.EstimateCandidatePixels(transform, width, height, 100, 100));
}
[Fact]
public void CandidatePixelsUsesAffineBoundingBoxAndDetectsNonIntegerPath()
{
var scaled = new Affine2D(2, 0, 0, 3, 1.5, 2.5);
Assert.False(PerformanceFrameLog.IsIntegerTranslation(scaled));
Assert.Equal(651,
PerformanceFrameLog.EstimateCandidatePixels(scaled, 10, 10, 100, 100));
}
[Fact]
public void RasterClassificationSeparatesTranslationScaleAndGeneralAffine()
{
Assert.True(PerformanceFrameLog.IsTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5)));
Assert.False(PerformanceFrameLog.IsIntegerTranslation(new Affine2D(1, 0, 0, 1, 0.25, -0.5)));
Assert.True(PerformanceFrameLog.IsAxisAligned(new Affine2D(2, 0, 0, 3, 0, 0)));
Assert.False(PerformanceFrameLog.IsAxisAligned(new Affine2D(1, 0.25, 0, 1, 0, 0)));
}
}

View File

@@ -115,10 +115,110 @@ public class SoftwareAffineRasterizerTests
Assert.Equal(new byte[] { 43, 57, 109, 255 }, background); Assert.Equal(new byte[] { 43, 57, 109, 255 }, background);
} }
[Fact]
public void BlitRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
{
var random = new Random(0x231);
double[] scales = { -2.0, -1.25, -0.5, 0.25, 0.5, 0.75, 1.25, 2.0, 3.0 };
double[] translations = { -5.5, -2.25, -0.5, 0.0, 0.5, 1.25, 4.5, 8.0 };
BlendKind[] blends = { BlendKind.Opaque, BlendKind.Alpha, BlendKind.Additive };
for (int iteration = 0; iteration < 250; iteration++)
{
int srcW = random.Next(2, 12), srcH = random.Next(2, 10);
byte[] src = new byte[srcW * srcH * 4];
random.NextBytes(src);
int srcX = random.Next(0, srcW), srcY = random.Next(0, srcH);
int width = random.Next(1, srcW - srcX + 1), height = random.Next(1, srcH - srcY + 1);
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
var transform = new Affine2D(
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
long tint = random.Next(0x1000000);
float tintStrength = random.NextSingle();
float opacity = random.NextSingle();
bool multiplyTint = random.Next(2) != 0;
BlendKind blend = blends[random.Next(blends.Length)];
ReferenceBlit(expected, dstW, dstH, src, srcW, srcX, srcY, width, height,
transform, tint, tintStrength, opacity, multiplyTint, blend);
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
srcX, srcY, width, height, transform,
tint, tintStrength, opacity, multiplyTint, blend);
Assert.Equal(expected, actual);
}
}
[Fact]
public void FillRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
{
var random = new Random(0x22d);
double[] scales = { -2.0, -1.0, -0.5, 0.25, 0.5, 0.75, 1.5, 2.0, 3.0 };
double[] translations = { -5.5, -1.25, -0.5, 0.0, 0.5, 2.25, 7.0 };
for (int iteration = 0; iteration < 250; iteration++)
{
int width = random.Next(1, 10), height = random.Next(1, 9);
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
var transform = new Affine2D(
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
long color = random.Next(0x1000000);
float opacity = random.NextSingle();
ReferenceFill(expected, dstW, dstH, width, height, transform, color, opacity);
SoftwareAffineRasterizer.FillRgba(actual, dstW, dstH, width, height,
transform, color, opacity);
Assert.Equal(expected, actual);
}
}
[Fact]
public void BlitRgba_UnmodulatedSourceOverFastPaths_MatchGeneralAffineAcrossAlphaEdges()
{
var random = new Random(0x2e0);
Affine2D[] transforms =
{
new(1, 0, 0, 1, -2, 1),
new(1, 0, 0, 1, 3, -1),
new(0.5, 0, 0, 1.5, -1.25, 0.5),
new(-1.25, 0, 0, 0.75, 8.5, -0.5),
};
foreach (var transform in transforms)
foreach (BlendKind blend in new[] { BlendKind.Opaque, BlendKind.Alpha })
for (int iteration = 0; iteration < 50; iteration++)
{
int srcW = random.Next(3, 12), srcH = random.Next(3, 10);
byte[] src = new byte[srcW * srcH * 4];
random.NextBytes(src);
for (int pixel = 0; pixel < srcW * srcH; pixel++)
src[pixel * 4 + 3] = (byte)(pixel % 4 switch { 0 => 0, 1 => 1, 2 => 254, _ => 255 });
int dstW = random.Next(5, 16), dstH = random.Next(5, 14);
byte[] expected = new byte[dstW * dstH * 4];
random.NextBytes(expected);
byte[] actual = (byte[])expected.Clone();
ReferenceBlit(expected, dstW, dstH, src, srcW, 1, 1, srcW - 1, srcH - 1,
transform, 0x6a4c2e, 0, 1, false, blend);
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
1, 1, srcW - 1, srcH - 1, transform,
0x6a4c2e, 0, 1, false, blend);
Assert.Equal(expected, actual);
}
}
// Pre-fast-path affine algorithm retained here as an independent differential oracle. // Pre-fast-path affine algorithm retained here as an independent differential oracle.
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW, private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D transform, int srcX, int srcY, int width, int height, Affine2D transform,
long tint, float tintStrength, float opacity, bool multiplyTint) long tint, float tintStrength, float opacity, bool multiplyTint,
BlendKind blend = BlendKind.Alpha)
{ {
if (!transform.TryInverse(out var inv)) return; if (!transform.TryInverse(out var inv)) return;
ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1); ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
@@ -136,7 +236,7 @@ public class SoftwareAffineRasterizerTests
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255; int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255; int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255; int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
ReferenceBlend(dst, di, sr, sg, sb, sa); ReferenceBlend(dst, di, sr, sg, sb, sa, blend);
} }
} }
@@ -165,8 +265,17 @@ public class SoftwareAffineRasterizerTests
y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y)))); y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y))));
} }
private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a) private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a,
BlendKind blend = BlendKind.Alpha)
{ {
if (blend == BlendKind.Additive)
{
dst[i] = (byte)System.Math.Min(255, dst[i] + r * a / 255);
dst[i + 1] = (byte)System.Math.Min(255, dst[i + 1] + g * a / 255);
dst[i + 2] = (byte)System.Math.Min(255, dst[i + 2] + b * a / 255);
dst[i + 3] = (byte)System.Math.Min(255, dst[i + 3] + a);
return;
}
dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255); dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255);
dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255); dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255);
dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255); dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255);

View File

@@ -0,0 +1,95 @@
using Age.Engine.Model;
using Xunit;
public class Transform2DMathTests
{
[Fact]
public void ValueMatrixBuild_MatchesFormerArrayCompositionExactly()
{
var random = new Random(0x240);
for (int i = 0; i < 500; i++)
{
var transform = new TransformState(
Next(random, -3, 3), Next(random, -3, 3), Next(random, -3, 3),
Next(random, -500, 500), Next(random, -500, 500), Next(random, -500, 500),
Next(random, -1000, 1000), Next(random, -1000, 1000), Next(random, -1000, 1000),
Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1),
Next(random, -360, 360));
var cycle = new RotationCycleState(
random.Next(2) != 0, random.Next(1, 20000),
Next(random, -1, 1), Next(random, -1, 1), Next(random, -1, 1),
Next(random, -360, 360));
var expected = ArrayBuild(transform, cycle);
var actual = Transform2DMath.Build(transform, cycle);
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XX), BitConverter.DoubleToInt64Bits(actual.XX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.XY), BitConverter.DoubleToInt64Bits(actual.XY));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YX), BitConverter.DoubleToInt64Bits(actual.YX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.YY), BitConverter.DoubleToInt64Bits(actual.YY));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TX), BitConverter.DoubleToInt64Bits(actual.TX));
Assert.Equal(BitConverter.DoubleToInt64Bits(expected.TY), BitConverter.DoubleToInt64Bits(actual.TY));
}
}
[Fact]
public void ValueMatrixBuild_DoesNotAllocatePerObject()
{
var transform = new TransformState(1.25, 0.75, 1.1, 42, -17, 5, 400, 300, 9, 0, 0, 1, 37);
var cycle = new RotationCycleState(true, 9000, 0, 0, -1, 123);
_ = Transform2DMath.Build(transform, cycle); // JIT/warm-up outside the measured interval.
long before = GC.GetAllocatedBytesForCurrentThread();
Affine2D result = default;
for (int i = 0; i < 10_000; i++) result = Transform2DMath.Build(transform, cycle);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
GC.KeepAlive(result);
Assert.Equal(0, allocated);
}
private static double Next(Random random, double minimum, double maximum)
=> minimum + random.NextDouble() * (maximum - minimum);
// The former heap-array implementation, retained only as a differential oracle.
private static Affine2D ArrayBuild(TransformState t, RotationCycleState cycle)
{
double[] m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m[0], m[1], m[4], m[5], m[12], m[13]);
}
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 };
private static double[] Scale(double x, double y, double z)
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 };
private static double[] Translation(double x, double y, double z)
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 };
private static double[] AxisAngle(double x, double y, double z, double degrees)
{
double len = Math.Sqrt(x*x + y*y + z*z);
if (len < 1e-12 || Math.Abs(degrees) < 1e-12) return Identity();
x /= len; y /= len; z /= len;
double r = degrees * Math.PI / 180.0, c = Math.Cos(r), s = Math.Sin(r), q = 1-c;
return new double[] {
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0,
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0,
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0,
0,0,0,1
};
}
private static double[] Mul(double[] a, double[] b)
{
var output = new double[16];
for (int row = 0; row < 4; row++)
for (int column = 0; column < 4; column++)
for (int k = 0; k < 4; k++) output[row * 4 + column] += a[row * 4 + k] * b[k * 4 + column];
return output;
}
}

View File

@@ -14,6 +14,15 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
double AxisX, double AxisY, double AxisZ, double AxisX, double AxisY, double AxisZ,
double AngleDegrees = 0); double AngleDegrees = 0);
[System.Flags]
public enum GfxPresentationReason
{
None = 0,
RetainedMutation = 1,
ContinuousChannel = 2,
DiscreteSourceCell = 4,
}
/// <summary>Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order; /// <summary>Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order;
/// the compositor draws range B over it with <paramref name="Progress"/> to form the native crossfade.</summary> /// the compositor draws range B over it with <paramref name="Progress"/> to form the native crossfade.</summary>
public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot, public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot,
@@ -62,7 +71,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
bool MultiplyTint, bool MultiplyTint,
SurfaceTransitionState? SurfaceTransition = null, SurfaceTransitionState? SurfaceTransition = null,
ColorTransitionState? ColorTransition = null, ColorTransitionState? ColorTransition = null,
Affine2D? RangeTransform = null); Affine2D? RangeTransform = null,
bool TimeVarying = false);
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in /// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the /// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
@@ -142,6 +152,9 @@ public sealed class GfxState
// Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and // Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and
// returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet. // returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
private readonly Dictionary<long, GfxObject> _objects = new(); private readonly Dictionary<long, GfxObject> _objects = new();
// Native composition is handle-ascending z order. Mutations maintain this small index so snapshots do
// not rebuild/sort a dictionary-sized LINQ buffer, while hot handle lookup remains O(1).
private readonly List<long> _orderedObjectHandles = new();
private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11]; private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11];
// Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled // Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled
@@ -164,6 +177,12 @@ public sealed class GfxState
public long AnimationServiceFlags { get; private set; } public long AnimationServiceFlags { get; private set; }
public uint PreviousFrameTimeMilliseconds { get; private set; } public uint PreviousFrameTimeMilliseconds { get; private set; }
public uint CurrentFrameTimeMilliseconds { get; private set; } public uint CurrentFrameTimeMilliseconds { get; private set; }
private long _previousFrameTimeMs;
private long _currentFrameTimeMs;
private long _mutationGeneration;
private long _publishedMutationGeneration;
private void MarkRetainedMutation() => _mutationGeneration++;
/// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary> /// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary>
public IEnumerable<(long Handle, int Slot)> Objects public IEnumerable<(long Handle, int Slot)> Objects
@@ -178,8 +197,14 @@ public sealed class GfxState
// (Monitor) so the callers that already hold it are fine. // (Monitor) so the callers that already hold it are fine.
lock (_lock) lock (_lock)
{ {
if (!_objects.TryGetValue(handle, out var o)) { o = new GfxObject(); _objects[handle] = o; } if (!_objects.TryGetValue(handle, out var o))
{
o = new GfxObject();
_objects[handle] = o;
InsertOrderedHandle(handle);
}
CurrentObject = handle; CurrentObject = handle;
MarkRetainedMutation();
return o; return o;
} }
} }
@@ -189,6 +214,31 @@ public sealed class GfxState
lock (_lock) DefaultObjectSlot = slot; lock (_lock) DefaultObjectSlot = slot;
} }
public void SetObjectAnchor(long handle, (long X, long Y, long Z) anchor)
{
lock (_lock) GetOrCreate(handle).V18 = anchor;
}
public void SetObjectPosition(long handle, (long X, long Y, long Z) position)
{
lock (_lock) GetOrCreate(handle).V24 = position;
}
public void SetObjectField64(long handle, long value)
{
lock (_lock) GetOrCreate(handle).Field64 = value;
}
public void SetObjectFields68And6c(long handle, long value68, long value6c)
{
lock (_lock)
{
var o = GetOrCreate(handle);
o.Field68 = value68;
o.Field6c = value6c;
}
}
/// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its /// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its
/// anchor/pivot. This does not create or mutate an ordinary retained object.</summary> /// anchor/pivot. This does not create or mutate an ordinary retained object.</summary>
public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor) public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor)
@@ -198,6 +248,7 @@ public sealed class GfxState
_rangeTransformFirst = first; _rangeTransformFirst = first;
_rangeTransformCount = System.Math.Max(0, count); _rangeTransformCount = System.Math.Max(0, count);
_rangeTransform = new GfxObject { V18 = anchor }; _rangeTransform = new GfxObject { V18 = anchor };
MarkRetainedMutation();
} }
} }
@@ -205,13 +256,20 @@ public sealed class GfxState
public void SetRangeScaleCurrent((long X, long Y, long Z) percent) public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
{ {
lock (_lock) lock (_lock)
{
_rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); _rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
MarkRetainedMutation();
}
} }
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary> /// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation) public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
{ {
lock (_lock) _rangeTransform.TranslationCurrent = translation; lock (_lock)
{
_rangeTransform.TranslationCurrent = translation;
MarkRetainedMutation();
}
} }
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary> /// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
@@ -224,6 +282,7 @@ public sealed class GfxState
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0); _rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
_rangeTransform.ScaleEnabled = durationMs > 0; _rangeTransform.ScaleEnabled = durationMs > 0;
_rangeTransform.OneShotStartMs = -1; _rangeTransform.OneShotStartMs = -1;
MarkRetainedMutation();
} }
} }
@@ -233,6 +292,7 @@ public sealed class GfxState
lock (_lock) lock (_lock)
{ {
if (!_objects.TryGetValue(sourceHandle, out var s)) return false; if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
bool destinationIsNew = !_objects.ContainsKey(destinationHandle);
_objects[destinationHandle] = new GfxObject _objects[destinationHandle] = new GfxObject
{ {
V18 = s.V18, V24 = s.V24, V16c = s.V16c, V18 = s.V18, V24 = s.V24, V16c = s.V16c,
@@ -258,7 +318,9 @@ public sealed class GfxState
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis, RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs, RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
}; };
if (destinationIsNew) InsertOrderedHandle(destinationHandle);
CurrentObject = destinationHandle; CurrentObject = destinationHandle;
MarkRetainedMutation();
return true; return true;
} }
} }
@@ -280,7 +342,10 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot) if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
{
obj.SourceSlot = toSlot; obj.SourceSlot = toSlot;
MarkRetainedMutation();
}
} }
public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0; public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
@@ -288,7 +353,12 @@ public sealed class GfxState
{ {
lock (_lock) // re-entrant: EraseRange already holds _lock lock (_lock) // re-entrant: EraseRange already holds _lock
{ {
_objects.Remove(handle); if (_objects.Remove(handle))
{
int index = _orderedObjectHandles.BinarySearch(handle);
if (index >= 0) _orderedObjectHandles.RemoveAt(index);
MarkRetainedMutation();
}
} }
} }
@@ -312,7 +382,9 @@ public sealed class GfxState
lock (_lock) lock (_lock)
{ {
_objects.Clear(); _objects.Clear();
_orderedObjectHandles.Clear();
CurrentObject = 0; CurrentObject = 0;
MarkRetainedMutation();
} }
} }
@@ -324,6 +396,7 @@ public sealed class GfxState
lock (_lock) lock (_lock)
{ {
_objects.Clear(); _objects.Clear();
_orderedObjectHandles.Clear();
_fieldTable.Clear(); _fieldTable.Clear();
_surfaces.Clear(); _surfaces.Clear();
_createdSurfaces.Clear(); _createdSurfaces.Clear();
@@ -339,6 +412,9 @@ public sealed class GfxState
AnimationServiceFlags = 0; AnimationServiceFlags = 0;
PreviousFrameTimeMilliseconds = 0; PreviousFrameTimeMilliseconds = 0;
CurrentFrameTimeMilliseconds = 0; CurrentFrameTimeMilliseconds = 0;
_previousFrameTimeMs = 0;
_currentFrameTimeMs = 0;
MarkRetainedMutation();
} }
} }
@@ -360,6 +436,7 @@ public sealed class GfxState
_surfaces[slot] = (resId, colorKey); _surfaces[slot] = (resId, colorKey);
_createdSurfaces.Remove(slot); _createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot); _movieStopTimesMs.Remove(slot);
MarkRetainedMutation();
} }
} }
@@ -399,6 +476,7 @@ public sealed class GfxState
} }
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end) if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
CurrentRenderTargetSlot = -1; CurrentRenderTargetSlot = -1;
MarkRetainedMutation();
} }
} }
@@ -498,6 +576,7 @@ public sealed class GfxState
_surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key _surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key
_createdSurfaces.Add(slot); _createdSurfaces.Add(slot);
_movieStopTimesMs.Remove(slot); _movieStopTimesMs.Remove(slot);
MarkRetainedMutation();
} }
} }
@@ -509,6 +588,7 @@ public sealed class GfxState
_createdSurfaces.Remove(slot); _createdSurfaces.Remove(slot);
_movieStopTimesMs.Remove(slot); _movieStopTimesMs.Remove(slot);
_surfaceTransitions.Remove(slot); _surfaceTransitions.Remove(slot);
MarkRetainedMutation();
} }
} }
@@ -526,6 +606,7 @@ public sealed class GfxState
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount), RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs), DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
}; };
MarkRetainedMutation();
} }
} }
@@ -684,6 +765,7 @@ public sealed class GfxState
int completed = 0; int completed = 0;
foreach (var t in _surfaceTransitions.Values) foreach (var t in _surfaceTransitions.Values)
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; } if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
if (completed > 0) MarkRetainedMutation();
return completed; return completed;
} }
} }
@@ -883,7 +965,12 @@ public sealed class GfxState
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary> /// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
public void SetAnimClock(long durationTicks) public void SetAnimClock(long durationTicks)
{ {
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; } lock (_lock)
{
AnimClockDurationTicks = durationTicks;
AnimClockGeneration++;
MarkRetainedMutation();
}
} }
public void SetAnimationServiceFlags(long flags) public void SetAnimationServiceFlags(long flags)
@@ -895,6 +982,8 @@ public sealed class GfxState
{ {
lock (_lock) lock (_lock)
{ {
_previousFrameTimeMs = _currentFrameTimeMs;
_currentFrameTimeMs = nowMilliseconds;
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds; PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds); CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
} }
@@ -910,9 +999,65 @@ public sealed class GfxState
ForceCompleteOneShotChannels(); ForceCompleteOneShotChannels();
AnimClockDurationTicks = 0; AnimClockDurationTicks = 0;
AnimClockGeneration++; AnimClockGeneration++;
MarkRetainedMutation();
} }
} }
/// <summary>Sample the shared native frame clock and report why the retained scene needs publishing.
/// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared
/// previous/current samples select different cells. Retained VM writes are published exactly once.</summary>
public GfxPresentationReason ConsumePresentationReasons(long nowMs)
{
lock (_lock)
{
_previousFrameTimeMs = _currentFrameTimeMs;
_currentFrameTimeMs = nowMs;
PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs);
CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs);
GfxPresentationReason reasons = GfxPresentationReason.None;
if (_publishedMutationGeneration != _mutationGeneration)
{
_publishedMutationGeneration = _mutationGeneration;
reasons |= GfxPresentationReason.RetainedMutation;
}
if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
_rangeTransform.TranslationEnabled ||
_objects.Values.Any(o => o.Visible &&
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled ||
(o.ColorAnim && o.ColorPeriod > 0) ||
(o.RotationEnabled && o.RotationPeriodMs > 0))))
reasons |= GfxPresentationReason.ContinuousChannel;
foreach (var o in _objects.Values)
{
if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue;
if (o.SrcStart < 0)
{
// Prototype clones made before first publication all enter here in the same shared sample,
// reproducing FIELD's native phase lock.
o.SrcStart = nowMs;
continue;
}
if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs))
{
reasons |= GfxPresentationReason.DiscreteSourceCell;
break;
}
}
return reasons;
}
}
private static long SourceCellAt(GfxObject o, long nowMs)
{
long elapsed = System.Math.Max(0, nowMs - o.SrcStart);
return elapsed / o.SrcPeriod % o.SrcFrameCount;
}
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless /// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
/// callers and existing tests.</summary> /// callers and existing tests.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0); public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
@@ -925,10 +1070,22 @@ public sealed class GfxState
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary> /// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs) public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
{ {
var list = new List<RenderObject>();
SnapshotVisibleObjects(nowMs, list);
return list;
}
/// <summary>Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing
/// array survives across frames; callers that need an independently retained snapshot should use the
/// returning overload.</summary>
public void SnapshotVisibleObjects(long nowMs, List<RenderObject> list)
{
ArgumentNullException.ThrowIfNull(list);
lock (_lock) lock (_lock)
{ {
var list = new List<RenderObject>(); list.Clear();
Affine2D? rangeAffine = null; Affine2D? rangeAffine = null;
bool rangeTimeVarying = false;
if (_rangeTransformCount > 0) if (_rangeTransformCount > 0)
{ {
var r = _rangeTransform; var r = _rangeTransform;
@@ -944,15 +1101,16 @@ public sealed class GfxState
ref r.TranslationEnabled, nowMs); ref r.TranslationEnabled, nowMs);
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled) if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
r.OneShotStartMs = -1; r.OneShotStartMs = -1;
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
rangeAffine = Transform2DMath.Build(new TransformState( rangeAffine = Transform2DMath.Build(new TransformState(
rangeScale.X, rangeScale.Y, rangeScale.Z, rangeScale.X, rangeScale.Y, rangeScale.Z,
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z, rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
r.V18.X, r.V18.Y, r.V18.Z, r.V18.X, r.V18.Y, r.V18.Z,
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle)); rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
} }
foreach (var kv in _objects.OrderBy(k => k.Key)) foreach (long handle in _orderedObjectHandles)
{ {
var o = kv.Value; var o = _objects[handle];
if (!o.Visible) continue; if (!o.Visible) continue;
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled || bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
o.RotationChannelEnabled || o.TranslationEnabled; o.RotationChannelEnabled || o.TranslationEnabled;
@@ -1064,9 +1222,17 @@ public sealed class GfxState
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st) SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
? SampleTransition(st, nowMs) : null; ? SampleTransition(st, nowMs) : null;
Affine2D? objectRangeTransform = rangeAffine is { } ra && Affine2D? objectRangeTransform = rangeAffine is { } ra &&
kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
? ra : null; ? ra : null;
list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, bool timeVarying =
o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
o.TranslationEnabled ||
(o.SrcAnim && o.SrcPeriod > 0) ||
(o.ColorAnim && o.ColorPeriod > 0) ||
(o.RotationEnabled && o.RotationPeriodMs > 0) ||
transition is { Progress: < 1.0 } ||
(objectRangeTransform != null && rangeTimeVarying);
list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h,
(int)o.V24.X, (int)o.V24.Y, (int)o.V24.X, (int)o.V24.Y,
new TransformState(scale.X, scale.Y, scale.Z, new TransformState(scale.X, scale.Y, scale.Z,
translation.X, translation.Y, translation.Z, translation.X, translation.Y, translation.Z,
@@ -1076,12 +1242,17 @@ public sealed class GfxState
o.RotationAxis.X, o.RotationAxis.Y, o.RotationAxis.X, o.RotationAxis.Y,
o.RotationAxis.Z, cycleAngle), o.RotationAxis.Z, cycleAngle),
alpha, tint, strength, blend, multiplyTint, transition, alpha, tint, strength, blend, multiplyTint, transition,
colorTransition, objectRangeTransform)); colorTransition, objectRangeTransform, timeVarying));
} }
return list;
} }
} }
private void InsertOrderedHandle(long handle)
{
int index = _orderedObjectHandles.BinarySearch(handle);
if (index < 0) _orderedObjectHandles.Insert(~index, handle);
}
private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs) private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs)
{ {
long current = o.Color & 0xffffffff; long current = o.Color & 0xffffffff;

View File

@@ -13,14 +13,23 @@ public static class SoftwareAffineRasterizer
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255); int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
if (ia == 0) return; if (ia == 0) return;
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255); int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
bool unmodulatedSourceOver = ia == 255 && istr == 0 && !multiplyTint && blend != BlendKind.Additive;
if (TryIntegerTranslation(localToDest, out int tx, out int ty)) if (TryIntegerTranslation(localToDest, out int tx, out int ty))
{ {
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height, BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend); tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend, unmodulatedSourceOver);
return; return;
} }
if (!localToDest.TryInverse(out var inv)) return; if (!localToDest.TryInverse(out var inv)) return;
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1); Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
if (x1 <= x0 || y1 <= y0) return;
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
{
BlitAxisAligned(dst, dstW, src, srcW, srcX, srcY, width, height, inv,
x0, y0, x1, y1, tr, tg, tb, istr, ia, multiplyTint, blend,
unmodulatedSourceOver);
return;
}
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++) for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
{ {
var p = inv.Apply(x + 0.5, y + 0.5); var p = inv.Apply(x + 0.5, y + 0.5);
@@ -48,6 +57,12 @@ public static class SoftwareAffineRasterizer
} }
if (!localToDest.TryInverse(out var inv)) return; if (!localToDest.TryInverse(out var inv)) return;
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1); Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
if (x1 <= x0 || y1 <= y0) return;
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
{
FillAxisAligned(dst, dstW, width, height, inv, x0, y0, x1, y1, r, g, b, a);
return;
}
for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){ for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){
var p=inv.Apply(x+.5,y+.5); var p=inv.Apply(x+.5,y+.5);
if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a); if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a);
@@ -69,11 +84,16 @@ public static class SoftwareAffineRasterizer
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW, private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
int srcX, int srcY, int width, int height, int tx, int ty, int srcX, int srcY, int width, int height, int tx, int ty,
int tr, int tg, int tb, int istr, int ia, bool multiplyTint, int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
BlendKind blend) BlendKind blend, bool unmodulatedSourceOver)
{ {
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty); int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
int x1 = (int)System.Math.Min(dstW, (long)tx + width); int x1 = (int)System.Math.Min(dstW, (long)tx + width);
int y1 = (int)System.Math.Min(dstH, (long)ty + height); int y1 = (int)System.Math.Min(dstH, (long)ty + height);
if (unmodulatedSourceOver)
{
BlitTranslatedUnmodulated(dst, dstW, src, srcW, srcX, srcY, tx, ty, x0, y0, x1, y1);
return;
}
for (int y = y0; y < y1; y++) for (int y = y0; y < y1; y++)
{ {
int v = y - ty; int v = y - ty;
@@ -91,6 +111,20 @@ public static class SoftwareAffineRasterizer
} }
} }
private static void BlitTranslatedUnmodulated(byte[] dst, int dstW, byte[] src, int srcW,
int srcX, int srcY, int tx, int ty,
int x0, int y0, int x1, int y1)
{
for (int y = y0; y < y1; y++)
{
int sourceRow = (srcY + y - ty) * srcW + srcX - tx;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
BlendUnmodulatedSourceOver(dst, (destinationRow + x) * 4,
src, (sourceRow + x) * 4);
}
}
private static void FillTranslated(byte[] dst, int dstW, int dstH, int width, int height, private static void FillTranslated(byte[] dst, int dstW, int dstH, int width, int height,
int tx, int ty, int r, int g, int b, int a) int tx, int ty, int r, int g, int b, int a)
{ {
@@ -102,6 +136,102 @@ public static class SoftwareAffineRasterizer
Blend(dst, (y * dstW + x) * 4, r, g, b, a); Blend(dst, (y * dstW + x) * 4, r, g, b, a);
} }
// Axis-aligned scale is FIELD's dominant non-integer path. Its inverse source column is independent
// of destination Y, and its source row is independent of destination X. Cache the former once per
// layer and compute the latter once per scanline while preserving the general path's exact center-
// sample/floor arithmetic. Pooled lookup storage avoids both per-layer garbage and repeated-stackalloc
// growth when the JIT inlines this hot path; the 4096-column caller gate keeps the rented bucket small.
private static void BlitAxisAligned(byte[] dst, int dstW, byte[] src, int srcW,
int srcX, int srcY, int width, int height, Affine2D inv,
int x0, int y0, int x1, int y1,
int tr, int tg, int tb, int istr, int ia,
bool multiplyTint, BlendKind blend, bool unmodulatedSourceOver)
{
int count = x1 - x0;
int[] sourceColumns = System.Buffers.ArrayPool<int>.Shared.Rent(count);
try
{
for (int x = x0; x < x1; x++)
{
int u = (int)System.Math.Floor(inv.Apply(x + 0.5, y0 + 0.5).X);
sourceColumns[x - x0] = (uint)u < (uint)width ? u : -1;
}
for (int y = y0; y < y1; y++)
{
int v = (int)System.Math.Floor(inv.Apply(x0 + 0.5, y + 0.5).Y);
if ((uint)v >= (uint)height) continue;
int sourceRow = (srcY + v) * srcW;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
{
int u = sourceColumns[x - x0];
if (u < 0) continue;
int si = (sourceRow + srcX + u) * 4;
int di = (destinationRow + x) * 4;
if (unmodulatedSourceOver)
{
BlendUnmodulatedSourceOver(dst, di, src, si);
continue;
}
int sa = src[si + 3] * ia / 255;
if (sa == 0) continue;
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
Blend(dst, di, sr, sg, sb, sa, blend);
}
}
}
finally { System.Buffers.ArrayPool<int>.Shared.Return(sourceColumns); }
}
// Most FIELD layers have full object opacity and no tint. Preserve color-key transparency and partially
// transparent edge texels, but make the overwhelmingly common alpha-255 case a four-byte copy instead of
// performing tint and source-over multiply/divide work whose result is exactly the source texel.
private static void BlendUnmodulatedSourceOver(byte[] dst, int di, byte[] src, int si)
{
int a = src[si + 3];
if (a == 0) return;
if (a == 255)
{
dst[di] = src[si];
dst[di + 1] = src[si + 1];
dst[di + 2] = src[si + 2];
dst[di + 3] = 255;
return;
}
int inverse = 255 - a;
dst[di] = (byte)((src[si] * a + dst[di] * inverse) / 255);
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inverse) / 255);
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inverse) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + a);
}
private static void FillAxisAligned(byte[] dst, int dstW, int width, int height, Affine2D inv,
int x0, int y0, int x1, int y1,
int r, int g, int b, int a)
{
int count = x1 - x0;
bool[] includedColumns = System.Buffers.ArrayPool<bool>.Shared.Rent(count);
try
{
for (int x = x0; x < x1; x++)
{
double u = inv.Apply(x + 0.5, y0 + 0.5).X;
includedColumns[x - x0] = u >= 0 && u < width;
}
for (int y = y0; y < y1; y++)
{
double v = inv.Apply(x0 + 0.5, y + 0.5).Y;
if (v < 0 || v >= height) continue;
int destinationRow = y * dstW;
for (int x = x0; x < x1; x++)
if (includedColumns[x - x0]) Blend(dst, (destinationRow + x) * 4, r, g, b, a);
}
}
finally { System.Buffers.ArrayPool<bool>.Shared.Return(includedColumns); }
}
private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1) private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1)
{ {
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h); var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);

View File

@@ -40,46 +40,74 @@ public static class Transform2DMath
{ {
public static Affine2D Build(TransformState t, RotationCycleState cycle = default) public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
{ {
double[] m = Identity(); Matrix3D m = Identity();
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ)); m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ)); m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees)); m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ)); m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees)); if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ)); m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
return new(m[0], m[1], m[4], m[5], m[12], m[13]); return new(m.M11, m.M12, m.M21, m.M22, m.TX, m.TY);
} }
public static (double X, double Y) Apply(double x, double y, TransformState transform, public static (double X, double Y) Apply(double x, double y, TransformState transform,
RotationCycleState cycle = default) RotationCycleState cycle = default)
=> Build(transform, cycle).Apply(x, y); => Build(transform, cycle).Apply(x, y);
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; // AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only
private static double[] Scale(double x, double y, double z) // the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 }; // about eleven arrays per rendered object, or roughly 2.2 MB on every DEBUGMAP composition.
private static double[] Translation(double x, double y, double z) private readonly record struct Matrix3D(
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 }; double M11, double M12, double M13,
double M21, double M22, double M23,
double M31, double M32, double M33,
double TX, double TY, double TZ);
private static double[] AxisAngle(double x, double y, double z, double degrees) private static Matrix3D Identity() => new(
1,0,0, 0,1,0, 0,0,1, 0,0,0);
private static Matrix3D Scale(double x, double y, double z) => new(
x,0,0, 0,y,0, 0,0,z, 0,0,0);
private static Matrix3D Translation(double x, double y, double z) => new(
1,0,0, 0,1,0, 0,0,1, x,y,z);
private static Matrix3D AxisAngle(double x, double y, double z, double degrees)
{ {
double len = System.Math.Sqrt(x*x + y*y + z*z); double len = System.Math.Sqrt(x*x + y*y + z*z);
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity(); if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
x /= len; y /= len; z /= len; x /= len; y /= len; z /= len;
double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c; double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c;
return new double[] { return new(
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0, x*x*q+c, x*y*q+z*s, x*z*q-y*s,
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0, x*y*q-z*s, y*y*q+c, y*z*q+x*s,
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0, x*z*q+y*s, y*z*q-x*s, z*z*q+c,
0,0,0,1 0,0,0);
};
} }
private static double[] Mul(double[] a, double[] b) private static Matrix3D Mul(Matrix3D a, Matrix3D b) => new(
Sum4(a.M11*b.M11, a.M12*b.M21, a.M13*b.M31, 0),
Sum4(a.M11*b.M12, a.M12*b.M22, a.M13*b.M32, 0),
Sum4(a.M11*b.M13, a.M12*b.M23, a.M13*b.M33, 0),
Sum4(a.M21*b.M11, a.M22*b.M21, a.M23*b.M31, 0),
Sum4(a.M21*b.M12, a.M22*b.M22, a.M23*b.M32, 0),
Sum4(a.M21*b.M13, a.M22*b.M23, a.M23*b.M33, 0),
Sum4(a.M31*b.M11, a.M32*b.M21, a.M33*b.M31, 0),
Sum4(a.M31*b.M12, a.M32*b.M22, a.M33*b.M32, 0),
Sum4(a.M31*b.M13, a.M32*b.M23, a.M33*b.M33, 0),
Sum4(a.TX*b.M11, a.TY*b.M21, a.TZ*b.M31, b.TX),
Sum4(a.TX*b.M12, a.TY*b.M22, a.TZ*b.M32, b.TY),
Sum4(a.TX*b.M13, a.TY*b.M23, a.TZ*b.M33, b.TZ));
// Accumulate in the same order as the former 4x4 loop so boundary-sensitive nearest-neighbour
// projection retains its floating-point behavior while avoiding an intermediate array.
private static double Sum4(double a, double b, double c, double d)
{ {
var o = new double[16]; double result = 0;
for (int row=0; row<4; row++) result += a;
for (int col=0; col<4; col++) result += b;
for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col]; result += c;
return o; result += d;
return result;
} }
} }

View File

@@ -1712,16 +1712,16 @@ public sealed class VirtualMachine
Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1; Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1;
} }
case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18 case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18
Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; Gfx.SetObjectAnchor(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24 case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; Gfx.SetObjectPosition(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9 case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9
case "set-default-gfx-object-slot": case "set-default-gfx-object-slot":
Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1; Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1;
// ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ---- // ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ----
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set) case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1; Gfx.SetObjectPosition(Read(a[0]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
case "u004219E0": // pre-reference compatibility case "u004219E0": // pre-reference compatibility
case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z) case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z)
Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4]))); Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
@@ -1782,10 +1782,10 @@ public sealed class VirtualMachine
case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix
Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1; Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
case "set-gfx-field64": // 0x212 (idx)(val) case "set-gfx-field64": // 0x212 (idx)(val)
Gfx.GetOrCreate(Read(a[0])).Field64 = Read(a[1]); return pc + 1; Gfx.SetObjectField64(Read(a[0]), Read(a[1])); return pc + 1;
case "set-gfx-xy": // 0x213 (idx)(x)(y) case "set-gfx-xy": // 0x213 (idx)(x)(y)
{ {
var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1; Gfx.SetObjectFields68And6c(Read(a[0]), Read(a[1]), Read(a[2])); return pc + 1;
} }
case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1; Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;

View File

@@ -7,6 +7,17 @@ using Age.Engine.Hosting;
using Age.Engine.Model; using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
[Flags]
public enum HostPresentationReason
{
None = 0,
HostRequest = 1,
ScreenTransition = 2,
RetainedMutation = 4,
ContinuousChannel = 8,
DiscreteSourceCell = 16,
}
[SupportedOSPlatform("windows")] [SupportedOSPlatform("windows")]
public sealed class GodotAdvHost : IHost public sealed class GodotAdvHost : IHost
{ {
@@ -185,6 +196,16 @@ public sealed class GodotAdvHost : IHost
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>(); return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
} }
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
{
ArgumentNullException.ThrowIfNull(snapshot);
lock (_textLock)
{
snapshot.Clear();
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
}
}
public void ClearRenderedAdvTextLayout(int layoutSlot) public void ClearRenderedAdvTextLayout(int layoutSlot)
{ {
lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot); lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot);
@@ -481,7 +502,12 @@ public sealed class GodotAdvHost : IHost
} }
public void InputCallbackCompleted(GfxState gfx) public void InputCallbackCompleted(GfxState gfx)
=> Interlocked.Exchange(ref _presentRequested, 1); {
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
// callback are published through GfxState's mutation generation; host-owned surface writes set
// _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even
// while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw.
}
public long InputClockMilliseconds => _clock.NowMs; public long InputClockMilliseconds => _clock.NowMs;
@@ -639,12 +665,22 @@ public sealed class GodotAdvHost : IHost
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness // Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
// once, then continue only while the sampled retained scene can actually change. Text reveal is a separate // once, then continue only while the sampled retained scene can actually change. Text reveal is a separate
// Godot Label; waiting/sleeping alone do not alter background pixels. // Godot Label; waiting/sleeping alone do not alter background pixels.
public bool ShouldRecomposite(GfxState gfx) public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
{ {
bool screenTransitionActive; bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null; lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 || var reasons = HostPresentationReason.None;
screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs); if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0)
reasons |= HostPresentationReason.HostRequest;
if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition;
GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs);
if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0)
reasons |= HostPresentationReason.RetainedMutation;
if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0)
reasons |= HostPresentationReason.ContinuousChannel;
if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0)
reasons |= HostPresentationReason.DiscreteSourceCell;
return reasons;
} }
public void Stop() public void Stop()
@@ -760,9 +796,6 @@ public sealed class GodotAdvHost : IHost
long ms = NormalizeSleepMilliseconds(duration, SleepScale); long ms = NormalizeSleepMilliseconds(duration, SleepScale);
long deadline = _clock.NowMs + ms; long deadline = _clock.NowMs + ms;
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline }); _timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
// A sleep is a service boundary: make preceding retained writes visible once even when no animation
// channel is active during the hold.
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
IsSleeping = true; IsSleeping = true;
while (_clock.NowMs < deadline) while (_clock.NowMs < deadline)
{ {

View File

@@ -97,6 +97,12 @@ public sealed class GodotTraceSink : ITraceSink
} }
} }
/// <summary>Allocation-free current coordinate for once-per-frame diagnostics.</summary>
public GodotTraceStepSnapshot? LatestStep
{
get { lock (_snapshotLock) return _latestStep; }
}
private string[] CurrentCallStackLocked() private string[] CurrentCallStackLocked()
{ {
var stack = _scripts.ToArray(); var stack = _scripts.ToArray();

View File

@@ -86,6 +86,8 @@ public partial class Main : Godot.Control
private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
private GodotTimelineLog? _timeline; private GodotTimelineLog? _timeline;
private int _timelineFrame; private int _timelineFrame;
private string? _perfLogPath; // --perf-log <csv>: low-overhead frame/compositor timings + work
private PerformanceFrameLog? _perf;
public override void _Ready() public override void _Ready()
{ {
@@ -184,6 +186,7 @@ public partial class Main : Godot.Control
if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1]; if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1];
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1]; 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] == "--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] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames); 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] == "--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); if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
@@ -225,6 +228,7 @@ public partial class Main : Godot.Control
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load(); var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null }; _host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
_trace = new GodotTraceSink(_locator, _timeline); _trace = new GodotTraceSink(_locator, _timeline);
if (_perfLogPath != null) _perf = new PerformanceFrameLog(_perfLogPath);
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow // --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path). // diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
_table = table; _table = table;
@@ -322,51 +326,80 @@ public partial class Main : Godot.Control
_clock.Advance(delta); _clock.Advance(delta);
_timelineFrame++; _timelineFrame++;
_timeline?.SetFrame(_timelineFrame, _clock.NowMs); _timeline?.SetFrame(_timelineFrame, _clock.NowMs);
_host?.PulseFrame(); var perf = _perf;
UpdateVoicePlaybackState(); var step = perf != null ? _trace.LatestStep : null;
AdoptPendingMovies(); perf?.BeginFrame(_timelineFrame, _clock.NowMs, delta,
UpdateMovieFrames(); step?.Script ?? "<startup>", step?.Offset ?? -1, step?.Opcode ?? -1);
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx)) try
Recomposite(); // native publishes retained mutations only at present/service boundaries
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
{ {
System.IO.Directory.CreateDirectory(_seqDir); long phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the _host?.PulseFrame();
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped. perf?.RecordPulse(PerformanceFrameLog.Timestamp() - phase);
var fimg = GetViewport().GetTexture()?.GetImage();
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png"); phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
_seqIdx++; UpdateVoicePlaybackState();
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); } AdoptPendingMovies();
return; UpdateMovieFrames();
} perf?.RecordMovies(PerformanceFrameLog.Timestamp() - phase);
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done)) phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
{ HostPresentationReason presentationReasons = !_selftest && _vm != null && _host != null
if (++_shotSettle >= _shotSettleTarget) ? _host.ConsumePresentationReasons(_vm.Gfx)
: HostPresentationReason.None;
bool shouldRecomposite = presentationReasons != HostPresentationReason.None;
perf?.RecordPresentationReasons((int)presentationReasons);
perf?.RecordShouldRecomposite(PerformanceFrameLog.Timestamp() - phase);
long allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (shouldRecomposite)
Recomposite(); // native publishes retained mutations only at present/service boundaries
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
if (!_selftest && _host != null) UpdateAdvTextPresentation();
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
{ {
_shotDone = true; System.IO.Directory.CreateDirectory(_seqDir);
var img = GetViewport().GetTexture().GetImage(); // Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
img.SavePng(_shotPath); // real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}"); var fimg = GetViewport().GetTexture()?.GetImage();
ReportSubroutines(); fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
GetTree().Quit(0); _seqIdx++;
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); }
return;
}
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
{
if (++_shotSettle >= _shotSettleTarget)
{
_shotDone = true;
var img = GetViewport().GetTexture().GetImage();
img.SavePng(_shotPath);
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
ReportSubroutines();
GetTree().Quit(0);
}
return;
}
if (_done && !_ended)
{
_ended = true;
DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_selftest) RunSelfTest();
} }
return;
}
if (_done && !_ended)
{
_ended = true;
DumpHistogram();
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
ReportSubroutines();
ShowEnd();
if (_selftest) RunSelfTest();
} }
finally { perf?.EndFrame(); }
} }
// _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they // _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they
@@ -688,6 +721,12 @@ public partial class Main : Godot.Control
public override void _ExitTree() public override void _ExitTree()
{ {
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose(); DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
if (_perf != null)
{
_perf.Dispose();
GD.Print($"[perf-log] wrote {_perf.FrameCount} frames / {_perf.RecompositeCount} recomposites -> {_perf.Path}");
_perf = null;
}
foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose(); foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose();
_pendingMovies.Clear(); _pendingMovies.Clear();
foreach (var movie in _movies.Values) movie.Decoder.Dispose(); foreach (var movie in _movies.Values) movie.Decoder.Dispose();
@@ -718,30 +757,62 @@ public partial class Main : Godot.Control
// GfxState and applied here; object opacity comes only from the actual blend/color path. // GfxState and applied here; object opacity comes only from the actual blend/color path.
private sealed record CachedPixels(int Width, int Height, byte[] Rgba); private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new(); private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
private void Recomposite() private void Recomposite()
{ {
if (_perf != null)
{
var presentStep = _trace.LatestStep;
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
}
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition);
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
_perf?.BeginRecomposite(hasScreenTransition);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
System.Array.Clear(_screenPixels); System.Array.Clear(_screenPixels);
foreach (var label in _surfaceTextLabels) label.Visible = false; foreach (var label in _surfaceTextLabels) label.Visible = false;
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
int surfaceTextLabelIndex = 0; int surfaceTextLabelIndex = 0;
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null; System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
if (_host.TrySnapshotScreenTransition(out var transition)) if (hasScreenTransition)
{ {
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target // Native mode 4 keeps the captured source opaque and alpha-composites the complete target
// surface over it. Each offscreen target has an opaque-black clear beneath its objects. // surface over it. Each offscreen target has an opaque-black clear beneath its objects.
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false); CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress); FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
CompositeVisibleObjects(transition.Target, (float)transition.Progress, CompositeVisibleObjects(transition.Target, (float)transition.Progress,
ref surfaceTextLabelIndex, decisions, false); ref surfaceTextLabelIndex, decisions, false);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
} }
else else
{ {
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true); allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_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);
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
} }
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels); _screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
_screenTex.Update(_screen); _screenTex.Update(_screen);
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
if (decisions != null) LogGfxDecisionChanges(decisions); if (decisions != null) LogGfxDecisionChanges(decisions);
_perf?.EndRecomposite();
} }
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity, private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
@@ -752,6 +823,7 @@ public partial class Main : Godot.Control
int z = 0; int z = 0;
foreach (var v in visible) // interpolate at the retained-presentation clock foreach (var v in visible) // interpolate at the retained-presentation clock
{ {
_perf?.RecordObject(v.TimeVarying);
var t = v.Transform; var t = v.Transform;
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation); var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY); var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
@@ -763,16 +835,23 @@ public partial class Main : Godot.Control
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
float strength = v.TintStrength / 255f; // tint-blend / fill strength float strength = v.TintStrength / 255f; // tint-blend / fill strength
var rawObject = _vm.Gfx.TryGet(v.Handle); var rawObject = _vm.Gfx.TryGet(v.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var surfaceTexture = rawObject != null var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId) ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null; : null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot); bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
string outcome; // These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one
// thousand retained objects per composition, so formatting them unconditionally creates
// several megabytes of short-lived garbage even in an ordinary run.
string? outcome = null;
if (v.SurfaceTransition is { } transition) if (v.SurfaceTransition is { } transition)
{ {
_perf?.RecordTransitionLayer();
int layers = DrawTransitionRange(visible, transition); int layers = DrawTransitionRange(visible, transition);
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " + if (decisions != null)
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}"; outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
} }
else if (v.SurfaceResId == 0 && surfaceTexture == null) else if (v.SurfaceResId == 0 && surfaceTexture == null)
{ {
@@ -785,37 +864,54 @@ public partial class Main : Godot.Control
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain // One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
// the tint-strength convention used by the existing effect objects. // the tint-strength convention used by the existing effect objects.
float fillA = v.MultiplyTint ? opacity : opacity * strength; float fillA = v.MultiplyTint ? opacity : opacity * strength;
_perf?.RecordFillLayer();
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA); FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " + if (decisions != null)
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " + outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " + $"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
ColorTimeline(v.ColorTransition); $"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
ColorTimeline(v.ColorTransition);
}
else
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)";
} }
else outcome = "SKIP(no-resId, opaque render-target)";
} }
else else
{ {
var texture = surfaceTexture var texture = surfaceTexture;
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId)); if (texture == null && !movieSurfaceBound)
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)"; {
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(v.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
}
else else
{ {
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H, BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend); localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " + if (decisions != null)
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " + outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " + $"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " + $"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " + $"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" + $"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
ColorTimeline(v.ColorTransition); $"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
ColorTimeline(v.ColorTransition);
} }
} }
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}"; if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
if (includeSurfaceText && rawObject != null) if (includeSurfaceText && rawObject != null)
{ {
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot)) _host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
foreach (var surfaceText in _surfaceTextSnapshot)
{ {
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W || if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue; surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
@@ -969,25 +1065,42 @@ public partial class Main : Godot.Control
{ {
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null) if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
continue; continue;
_perf?.RecordObject(source.TimeVarying);
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY); var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
if (source.RangeTransform is { } rangeTransform) if (source.RangeTransform is { } rangeTransform)
affine = affine.Then(rangeTransform); affine = affine.Then(rangeTransform);
float opacity = source.Alpha / 255f * (float)transition.Progress; float opacity = source.Alpha / 255f * (float)transition.Progress;
var rawObject = _vm.Gfx.TryGet(source.Handle); var rawObject = _vm.Gfx.TryGet(source.Handle);
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
var texture = rawObject != null var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId) ? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null; : null;
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot); bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null) if (source.SurfaceResId == 0 && texture == null)
{ {
if (source.Blend == BlendKind.Opaque) continue; if (source.Blend == BlendKind.Opaque)
{
_perf?.RecordSkippedLayer();
continue;
}
int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600; int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600;
_perf?.RecordFillLayer();
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f); FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
} }
else else
{ {
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId); if (!movieSurfaceBound && texture == null)
if (texture == null) continue; {
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
texture = _host.ResolveResIdTexture(source.SurfaceResId);
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
}
if (texture == null)
{
_perf?.RecordSkippedLayer();
continue;
}
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f, BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint, source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
texture.Value.IsDynamic, source.Blend); texture.Value.IsDynamic, source.Blend);
@@ -1039,6 +1152,8 @@ public partial class Main : Godot.Control
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005; // right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map. // expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
var cacheKey = (assetId, colorKey); var cacheKey = (assetId, colorKey);
int sourceWidth, sourceHeight; int sourceWidth, sourceHeight;
byte[] sourcePixels; byte[] sourcePixels;
@@ -1077,16 +1192,24 @@ public partial class Main : Godot.Control
int sh = h; int sh = h;
sw = System.Math.Min(sw, sourceWidth - srcX); sw = System.Math.Min(sw, sourceWidth - srcX);
sh = System.Math.Min(sh, sourceHeight - srcY); sh = System.Math.Min(sh, sourceHeight - srcY);
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
if (sw <= 0 || sh <= 0) return; if (sw <= 0 || sh <= 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba( Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight, _screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend); srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
_perf?.RecordRaster(sw, sh, localToDest, ScreenWidth, ScreenHeight, dynamic, blend,
PerformanceFrameLog.Timestamp() - rasterStarted);
} }
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha) private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
{ {
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba( Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
_screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha); _screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha);
_perf?.RecordRaster(w, h, localToDest, ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
} }
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill. // Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
@@ -1094,6 +1217,7 @@ public partial class Main : Godot.Control
{ {
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255); int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
if (ia == 0) return; if (ia == 0) return;
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff); int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
byte[] dst = _screenPixels; byte[] dst = _screenPixels;
int dw = ScreenWidth, dh = ScreenHeight; int dw = ScreenWidth, dh = ScreenHeight;
@@ -1110,6 +1234,10 @@ public partial class Main : Godot.Control
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255); dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia); dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
} }
_perf?.RecordFillLayer();
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
PerformanceFrameLog.Timestamp() - rasterStarted);
} }
// Make colorkey-matching texels transparent (native colorkey is baked at surface load). // Make colorkey-matching texels transparent (native colorkey is baked at surface load).

View File

@@ -94,16 +94,18 @@ internal sealed class MovieSurfaceRegistry
{ {
lock (_lock) lock (_lock)
{ {
foreach (var binding in _byPlayback.Values long newestPlaybackId = long.MinValue;
.Where(binding => binding.ResourceId == resourceId) MovieSurfaceFrame? newestFrame = null;
.OrderByDescending(binding => binding.PlaybackId)) foreach (var binding in _byPlayback.Values)
{ {
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue; if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId ||
frame = found; !_frames.TryGetValue(binding.PlaybackId, out var found))
return true; continue;
newestPlaybackId = binding.PlaybackId;
newestFrame = found;
} }
frame = null; frame = newestFrame;
return false; return newestFrame != null;
} }
} }

View File

@@ -0,0 +1,297 @@
using System;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using Age.Engine.Model;
/// <summary>
/// Buffered, diagnostic-only CSV writer for real Godot frame and retained-compositor cost. The writer is
/// deliberately independent of Godot types so its schema and clipping arithmetic can be unit tested.
/// </summary>
public sealed class PerformanceFrameLog : IDisposable
{
private const int FlushIntervalFrames = 120;
private static readonly double MillisecondsPerTick = 1000.0 / Stopwatch.Frequency;
private readonly StreamWriter _writer;
private Frame _current = new();
private bool _frameOpen;
private bool _disposed;
private int _framesSinceFlush;
public long FrameCount { get; private set; }
public long RecompositeCount { get; private set; }
public string Path { get; }
public PerformanceFrameLog(string path)
{
Path = path;
var directory = System.IO.Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
_writer = new StreamWriter(path, append: false, Encoding.UTF8, 64 * 1024);
// Presentation coordinates are appended separately so a VM thread released by PulseFrame can be
// distinguished from the coordinate observed at _Process entry.
_writer.WriteLine(
"frame,now_ms,delta_ms,main_ms,pulse_ms,movie_ms,should_recomposite_ms,recomposite_ms," +
"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," +
"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," +
"integer_layers,fractional_translation_layers,axis_aligned_scale_layers," +
"general_affine_layers,affine_layers,singular_layers,dynamic_layers,opaque_layers,alpha_layers," +
"additive_layers,source_pixels,candidate_pixels,full_screen_layers,script,offset,opcode," +
"present_script,present_offset,present_opcode");
}
public static long Timestamp() => Stopwatch.GetTimestamp();
public static long AllocatedBytes() => GC.GetAllocatedBytesForCurrentThread();
public void BeginFrame(int frame, long nowMs, double deltaSeconds,
string script, int offset, int opcode)
{
if (_disposed) return;
if (_frameOpen) EndFrame();
_current = new Frame
{
Number = frame,
NowMs = nowMs,
DeltaMs = deltaSeconds * 1000.0,
Script = script,
Offset = offset,
Opcode = opcode,
Started = Timestamp(),
AllocatedStart = GC.GetAllocatedBytesForCurrentThread(),
Gen0Start = GC.CollectionCount(0),
Gen1Start = GC.CollectionCount(1),
Gen2Start = GC.CollectionCount(2),
};
_frameOpen = true;
}
public void RecordPulse(long ticks) => _current.PulseTicks += ticks;
public void RecordMovies(long ticks) => _current.MovieTicks += ticks;
public void RecordShouldRecomposite(long ticks) => _current.ShouldTicks += ticks;
public void RecordPresentationReasons(int reasons)
{
_current.PresentHostRequest |= (reasons & 1) != 0;
_current.PresentScreenTransition |= (reasons & 2) != 0;
_current.PresentRetainedMutation |= (reasons & 4) != 0;
_current.PresentContinuousChannel |= (reasons & 8) != 0;
_current.PresentDiscreteCell |= (reasons & 16) != 0;
}
public void RecordUi(long ticks) => _current.UiTicks += ticks;
public void RecordClear(long ticks) => _current.ClearTicks += ticks;
public void RecordSnapshot(long ticks) => _current.SnapshotTicks += ticks;
public void RecordResolve(long ticks) => _current.ResolveTicks += ticks;
public void RecordSourcePrep(long ticks) => _current.SourcePrepTicks += ticks;
public void RecordRecomposeAllocation(long bytes) => _current.RecomposeAllocatedBytes += Math.Max(0, bytes);
public void RecordSnapshotAllocation(long bytes) => _current.SnapshotAllocatedBytes += Math.Max(0, bytes);
public void RecordCompositeAllocation(long bytes) => _current.CompositeAllocatedBytes += Math.Max(0, bytes);
public void RecordSourcePrepAllocation(long bytes) => _current.SourcePrepAllocatedBytes += Math.Max(0, bytes);
public void RecordSetDataAllocation(long bytes) => _current.SetDataAllocatedBytes += Math.Max(0, bytes);
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 BeginRecomposite(bool screenTransition)
{
_current.Recomposited = true;
_current.ScreenTransition |= screenTransition;
_current.RecompositeStarted = Timestamp();
}
public void RecordPresentationCoordinate(string script, int offset, int opcode)
{
_current.PresentScript = script;
_current.PresentOffset = offset;
_current.PresentOpcode = opcode;
}
public void EndRecomposite()
{
if (_current.RecompositeStarted == 0) return;
_current.RecompositeTicks += Timestamp() - _current.RecompositeStarted;
_current.RecompositeStarted = 0;
}
public void RecordObject(bool timeVarying)
{
_current.ObjectVisits++;
if (timeVarying) _current.TimeVaryingObjects++;
}
public void RecordFillLayer() => _current.FillLayers++;
public void RecordTransitionLayer() => _current.TransitionLayers++;
public void RecordSkippedLayer() => _current.SkippedLayers++;
public void RecordRaster(int sourceWidth, int sourceHeight, Affine2D localToDest,
int destinationWidth, int destinationHeight, bool dynamic,
BlendKind blend, long ticks)
{
_current.DrawLayers++;
_current.RasterTicks += ticks;
_current.SourcePixels += Math.Max(0L, (long)sourceWidth * sourceHeight);
long candidates = EstimateCandidatePixels(localToDest, sourceWidth, sourceHeight,
destinationWidth, destinationHeight);
_current.CandidatePixels += candidates;
if (candidates >= (long)destinationWidth * destinationHeight) _current.FullScreenLayers++;
if (IsIntegerTranslation(localToDest)) _current.IntegerLayers++;
else if (!localToDest.TryInverse(out _)) _current.SingularLayers++;
else
{
_current.AffineLayers++;
if (IsTranslation(localToDest)) _current.FractionalTranslationLayers++;
else if (IsAxisAligned(localToDest)) _current.AxisAlignedScaleLayers++;
else _current.GeneralAffineLayers++;
}
if (dynamic) _current.DynamicLayers++;
switch (blend)
{
case BlendKind.Opaque: _current.OpaqueLayers++; break;
case BlendKind.Additive: _current.AdditiveLayers++; break;
default: _current.AlphaLayers++; break;
}
}
public void EndFrame()
{
if (!_frameOpen || _disposed) return;
long ended = Timestamp();
_current.MainTicks = ended - _current.Started;
_current.AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - _current.AllocatedStart);
_current.Gen0 = GC.CollectionCount(0) - _current.Gen0Start;
_current.Gen1 = GC.CollectionCount(1) - _current.Gen1Start;
_current.Gen2 = GC.CollectionCount(2) - _current.Gen2Start;
Write(_current);
FrameCount++;
if (_current.Recomposited) RecompositeCount++;
_frameOpen = false;
if (++_framesSinceFlush >= FlushIntervalFrames)
{
_writer.Flush();
_framesSinceFlush = 0;
}
}
public static bool IsIntegerTranslation(Affine2D m)
=> IsTranslation(m) &&
m.TX == Math.Truncate(m.TX) && m.TY == Math.Truncate(m.TY) &&
m.TX >= int.MinValue && m.TX <= int.MaxValue &&
m.TY >= int.MinValue && m.TY <= int.MaxValue;
public static bool IsTranslation(Affine2D m)
=> m.XX == 1 && m.XY == 0 && m.YX == 0 && m.YY == 1;
public static bool IsAxisAligned(Affine2D m)
=> m.XY == 0 && m.YX == 0;
public static long EstimateCandidatePixels(Affine2D m, int width, int height,
int destinationWidth, int destinationHeight)
{
if (width <= 0 || height <= 0 || destinationWidth <= 0 || destinationHeight <= 0) return 0;
var a = m.Apply(0, 0);
var b = m.Apply(width, 0);
var c = m.Apply(0, height);
var d = m.Apply(width, height);
double left = Math.Min(Math.Min(a.X, b.X), Math.Min(c.X, d.X));
double top = Math.Min(Math.Min(a.Y, b.Y), Math.Min(c.Y, d.Y));
double right = Math.Max(Math.Max(a.X, b.X), Math.Max(c.X, d.X));
double bottom = Math.Max(Math.Max(a.Y, b.Y), Math.Max(c.Y, d.Y));
long x0 = Math.Max(0, ClampFloor(left));
long y0 = Math.Max(0, ClampFloor(top));
long x1 = Math.Min(destinationWidth, ClampCeiling(right));
long y1 = Math.Min(destinationHeight, ClampCeiling(bottom));
return x1 <= x0 || y1 <= y0 ? 0 : checked((x1 - x0) * (y1 - y0));
}
private static long ClampFloor(double value)
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
: value >= long.MaxValue ? long.MaxValue : (long)Math.Floor(value);
private static long ClampCeiling(double value)
=> !double.IsFinite(value) ? 0 : value <= long.MinValue ? long.MinValue
: value >= long.MaxValue ? long.MaxValue : (long)Math.Ceiling(value);
private void Write(Frame f)
{
var b = new StringBuilder(512);
Append(b, f.Number); Append(b, f.NowMs); Append(b, f.DeltaMs);
AppendTicks(b, f.MainTicks); AppendTicks(b, f.PulseTicks); AppendTicks(b, f.MovieTicks);
AppendTicks(b, f.ShouldTicks); AppendTicks(b, f.RecompositeTicks); AppendTicks(b, f.ClearTicks);
AppendTicks(b, f.SnapshotTicks); AppendTicks(b, f.ResolveTicks); AppendTicks(b, f.SourcePrepTicks);
AppendTicks(b, f.RasterTicks);
AppendTicks(b, f.SetDataTicks); AppendTicks(b, f.TextureUpdateTicks); AppendTicks(b, f.UiTicks);
Append(b, f.AllocatedBytes); Append(b, f.RecomposeAllocatedBytes);
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.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);
Append(b, f.ObjectVisits); Append(b, f.TimeVaryingObjects); Append(b, f.DrawLayers);
Append(b, f.FillLayers); Append(b, f.TransitionLayers); Append(b, f.SkippedLayers);
Append(b, f.IntegerLayers); Append(b, f.FractionalTranslationLayers);
Append(b, f.AxisAlignedScaleLayers); Append(b, f.GeneralAffineLayers);
Append(b, f.AffineLayers); Append(b, f.SingularLayers);
Append(b, f.DynamicLayers); Append(b, f.OpaqueLayers); Append(b, f.AlphaLayers);
Append(b, f.AdditiveLayers); Append(b, f.SourcePixels); Append(b, f.CandidatePixels);
Append(b, f.FullScreenLayers); AppendEscaped(b, f.Script); Append(b, f.Offset);
Append(b, f.Opcode); AppendEscaped(b, f.PresentScript); Append(b, f.PresentOffset);
Append(b, f.PresentOpcode, last: true);
_writer.WriteLine(b.ToString());
}
private static void AppendTicks(StringBuilder b, long ticks)
=> Append(b, ticks * MillisecondsPerTick);
private static void Append(StringBuilder b, long value, bool last = false)
{
b.Append(value.ToString(CultureInfo.InvariantCulture));
if (!last) b.Append(',');
}
private static void Append(StringBuilder b, double value)
{
b.Append(value.ToString("0.0000", CultureInfo.InvariantCulture));
b.Append(',');
}
private static void AppendEscaped(StringBuilder b, string value)
{
b.Append('"').Append(value.Replace("\"", "\"\"")).Append("\",");
}
public void Dispose()
{
if (_disposed) return;
if (_frameOpen) EndFrame();
_disposed = true;
_writer.Dispose();
}
private sealed class Frame
{
public int Number, Offset, Opcode;
public long NowMs, Started, MainTicks, PulseTicks, MovieTicks, ShouldTicks, RecompositeTicks;
public long RecompositeStarted, ClearTicks, SnapshotTicks, ResolveTicks, SourcePrepTicks, RasterTicks;
public long SetDataTicks, TextureUpdateTicks, UiTicks, AllocatedStart, AllocatedBytes;
public long RecomposeAllocatedBytes, SnapshotAllocatedBytes, CompositeAllocatedBytes;
public long SourcePrepAllocatedBytes, SetDataAllocatedBytes, UiAllocatedBytes;
public int Gen0Start, Gen1Start, Gen2Start, Gen0, Gen1, Gen2;
public double DeltaMs;
public string Script = "<unknown>";
public string PresentScript = "<none>";
public int PresentOffset = -1, PresentOpcode = -1;
public bool Recomposited, 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;
}
}