Profile and optimize retained rendering
This commit is contained in:
@@ -2309,11 +2309,10 @@ sampled coordinates.
|
||||
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
|
||||
to reach reproducibly.
|
||||
- [ ] **P2 - fidelity-neutral CPU wins.** Remove normal-path diagnostic allocation and refine the ambiguous
|
||||
presentation/affine counters, then avoid recomposition between discrete spritesheet cell changes and
|
||||
benchmark translation, affine, and safe opaque raster fast paths. Land only changes with measured wins
|
||||
and focused differential raster/presentation coverage.
|
||||
- [ ] **P3 - GPU retained-renderer prototype.** Upload decoded/color-key texture variants once and mirror
|
||||
- [x] **P2 - fidelity-neutral CPU wins.** P2a-P2f are implemented and measured. DEBUGMAP now meets its p95
|
||||
frame budget with near-zero steady allocation; the SC0000 exit capture remains 65.18/71.76 ms p50/p95 in
|
||||
the severe full-screen/additive band, so P2 closes and triggers P3 rather than more CPU special cases.
|
||||
- [ ] **P3 - GPU retained-renderer prototype (triggered).** Upload decoded/color-key texture variants once and mirror
|
||||
ordinary retained objects into GPU-native Godot drawing while preserving handle z-order, atlas regions,
|
||||
transforms, opacity/tint, and blend mode. Begin with dungeon sprites and ordinary translated textures;
|
||||
compare selected frames against the software oracle behind a backend switch.
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
time; if less than 70% disappears, use an allocation trace to identify the remaining owners before
|
||||
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
|
||||
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
|
||||
camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on
|
||||
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;
|
||||
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
|
||||
@@ -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
|
||||
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.
|
||||
4. **P2d - turn translation-like affine work into a verified fast path.** If P2b confirms pure unit-matrix
|
||||
fractional translations, implement the nearest-neighbor-equivalent translated raster path and prove it
|
||||
4. [x] **P2d - specialize the measured axis-aligned scale work.** P2b disproved the fractional-translation
|
||||
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,
|
||||
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
|
||||
inverse and transform per destination pixel.
|
||||
5. **P2e - specialize the remaining hot pixel loops.** In descending evidence value, benchmark:
|
||||
incremental inverse coordinates across an affine scanline; axis-aligned scale specialization; and
|
||||
opaque/full-opacity/unmodulated translated row copies or alpha-run copies where source transparency
|
||||
permits. Use randomized differential raster tests against the current implementation plus the SC0000
|
||||
5. [x] **P2e - specialize the remaining hot pixel loops.** The measured axis-aligned scale specialization is
|
||||
complete. Benchmark opaque/full-opacity/unmodulated translated and scaled pixel loops next, using direct
|
||||
copies for fully opaque source texels and the existing blend arithmetic at transparent edges. General
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
implementation slice is P2a followed by P2b; those make the next user capture cheaper and sufficiently
|
||||
specific to choose the correct P2c/P2d implementation without another story-progression dependency.
|
||||
disabled experiment when it does not produce a repeatable real-path improvement. The next capture validates
|
||||
P2a-P2c together and uses P2b's new categories to choose P2d 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.
|
||||
|
||||
@@ -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
|
||||
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-
|
||||
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
|
||||
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/`:
|
||||
`godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`.
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
|
||||
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.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\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
|
||||
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
||||
|
||||
@@ -25,6 +25,25 @@ public class GfxAnimationTests
|
||||
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]
|
||||
public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor()
|
||||
{
|
||||
@@ -94,18 +113,22 @@ public class GfxAnimationTests
|
||||
|
||||
var unchanged = VisibleObject();
|
||||
Assert.False(unchanged.HasActiveVisualPresentation(1000));
|
||||
Assert.False(unchanged.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||
|
||||
var spritesheet = VisibleObject();
|
||||
spritesheet.SetSrcRect(7, 4, 1, 0, 800);
|
||||
Assert.True(spritesheet.HasActiveVisualPresentation(1000));
|
||||
Assert.True(spritesheet.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||
|
||||
var color = VisibleObject();
|
||||
color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
|
||||
Assert.True(color.HasActiveVisualPresentation(1000));
|
||||
Assert.True(color.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||
|
||||
var rotation = VisibleObject();
|
||||
rotation.SetRotationCycle(7, 1000, (0, 0, 1));
|
||||
Assert.True(rotation.HasActiveVisualPresentation(1000));
|
||||
Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -129,6 +152,108 @@ public class GfxAnimationTests
|
||||
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]
|
||||
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
|
||||
{
|
||||
|
||||
@@ -60,6 +60,33 @@ public class MovieSurfaceRegistryTests
|
||||
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)
|
||||
=> new(1, 1, new[] { value, value, value, (byte)255 });
|
||||
}
|
||||
|
||||
97
engine/Age.Engine.Tests/PerformanceFrameLogTests.cs
Normal file
97
engine/Age.Engine.Tests/PerformanceFrameLogTests.cs
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -115,10 +115,110 @@ public class SoftwareAffineRasterizerTests
|
||||
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.
|
||||
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
|
||||
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;
|
||||
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 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;
|
||||
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))));
|
||||
}
|
||||
|
||||
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 + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255);
|
||||
dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255);
|
||||
|
||||
95
engine/Age.Engine.Tests/Transform2DMathTests.cs
Normal file
95
engine/Age.Engine.Tests/Transform2DMathTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@ public readonly record struct RotationCycleState(bool Enabled, long PeriodMs,
|
||||
double AxisX, double AxisY, double AxisZ,
|
||||
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;
|
||||
/// 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,
|
||||
@@ -62,7 +71,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
|
||||
bool MultiplyTint,
|
||||
SurfaceTransitionState? SurfaceTransition = 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
|
||||
/// 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
|
||||
// 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();
|
||||
// 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];
|
||||
|
||||
// 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 uint PreviousFrameTimeMilliseconds { 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>
|
||||
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.
|
||||
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;
|
||||
MarkRetainedMutation();
|
||||
return o;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +214,31 @@ public sealed class GfxState
|
||||
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
|
||||
/// 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)
|
||||
@@ -198,6 +248,7 @@ public sealed class GfxState
|
||||
_rangeTransformFirst = first;
|
||||
_rangeTransformCount = System.Math.Max(0, count);
|
||||
_rangeTransform = new GfxObject { V18 = anchor };
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,13 +256,20 @@ public sealed class GfxState
|
||||
public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_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>
|
||||
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>
|
||||
@@ -224,6 +282,7 @@ public sealed class GfxState
|
||||
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
|
||||
_rangeTransform.ScaleEnabled = durationMs > 0;
|
||||
_rangeTransform.OneShotStartMs = -1;
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +292,7 @@ public sealed class GfxState
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
|
||||
bool destinationIsNew = !_objects.ContainsKey(destinationHandle);
|
||||
_objects[destinationHandle] = new GfxObject
|
||||
{
|
||||
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
|
||||
@@ -258,7 +318,9 @@ public sealed class GfxState
|
||||
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
|
||||
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
|
||||
};
|
||||
if (destinationIsNew) InsertOrderedHandle(destinationHandle);
|
||||
CurrentObject = destinationHandle;
|
||||
MarkRetainedMutation();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -280,7 +342,10 @@ public sealed class GfxState
|
||||
{
|
||||
lock (_lock)
|
||||
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
|
||||
{
|
||||
obj.SourceSlot = toSlot;
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
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
|
||||
{
|
||||
_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)
|
||||
{
|
||||
_objects.Clear();
|
||||
_orderedObjectHandles.Clear();
|
||||
CurrentObject = 0;
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +396,7 @@ public sealed class GfxState
|
||||
lock (_lock)
|
||||
{
|
||||
_objects.Clear();
|
||||
_orderedObjectHandles.Clear();
|
||||
_fieldTable.Clear();
|
||||
_surfaces.Clear();
|
||||
_createdSurfaces.Clear();
|
||||
@@ -339,6 +412,9 @@ public sealed class GfxState
|
||||
AnimationServiceFlags = 0;
|
||||
PreviousFrameTimeMilliseconds = 0;
|
||||
CurrentFrameTimeMilliseconds = 0;
|
||||
_previousFrameTimeMs = 0;
|
||||
_currentFrameTimeMs = 0;
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +436,7 @@ public sealed class GfxState
|
||||
_surfaces[slot] = (resId, colorKey);
|
||||
_createdSurfaces.Remove(slot);
|
||||
_movieStopTimesMs.Remove(slot);
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +476,7 @@ public sealed class GfxState
|
||||
}
|
||||
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
|
||||
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
|
||||
_createdSurfaces.Add(slot);
|
||||
_movieStopTimesMs.Remove(slot);
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +588,7 @@ public sealed class GfxState
|
||||
_createdSurfaces.Remove(slot);
|
||||
_movieStopTimesMs.Remove(slot);
|
||||
_surfaceTransitions.Remove(slot);
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -526,6 +606,7 @@ public sealed class GfxState
|
||||
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
|
||||
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
|
||||
};
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -684,6 +765,7 @@ public sealed class GfxState
|
||||
int completed = 0;
|
||||
foreach (var t in _surfaceTransitions.Values)
|
||||
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
|
||||
if (completed > 0) MarkRetainedMutation();
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
@@ -883,7 +965,12 @@ public sealed class GfxState
|
||||
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
|
||||
public void SetAnimClock(long durationTicks)
|
||||
{
|
||||
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
|
||||
lock (_lock)
|
||||
{
|
||||
AnimClockDurationTicks = durationTicks;
|
||||
AnimClockGeneration++;
|
||||
MarkRetainedMutation();
|
||||
}
|
||||
}
|
||||
|
||||
public void SetAnimationServiceFlags(long flags)
|
||||
@@ -895,6 +982,8 @@ public sealed class GfxState
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_previousFrameTimeMs = _currentFrameTimeMs;
|
||||
_currentFrameTimeMs = nowMilliseconds;
|
||||
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
|
||||
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
|
||||
}
|
||||
@@ -910,9 +999,65 @@ public sealed class GfxState
|
||||
ForceCompleteOneShotChannels();
|
||||
AnimClockDurationTicks = 0;
|
||||
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
|
||||
/// callers and existing tests.</summary>
|
||||
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>
|
||||
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)
|
||||
{
|
||||
var list = new List<RenderObject>();
|
||||
list.Clear();
|
||||
Affine2D? rangeAffine = null;
|
||||
bool rangeTimeVarying = false;
|
||||
if (_rangeTransformCount > 0)
|
||||
{
|
||||
var r = _rangeTransform;
|
||||
@@ -944,15 +1101,16 @@ public sealed class GfxState
|
||||
ref r.TranslationEnabled, nowMs);
|
||||
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
|
||||
r.OneShotStartMs = -1;
|
||||
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
||||
rangeAffine = Transform2DMath.Build(new TransformState(
|
||||
rangeScale.X, rangeScale.Y, rangeScale.Z,
|
||||
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
|
||||
r.V18.X, r.V18.Y, r.V18.Z,
|
||||
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;
|
||||
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
|
||||
o.RotationChannelEnabled || o.TranslationEnabled;
|
||||
@@ -1064,9 +1222,17 @@ public sealed class GfxState
|
||||
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
|
||||
? SampleTransition(st, nowMs) : null;
|
||||
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
|
||||
kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount
|
||||
handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
|
||||
? 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,
|
||||
new TransformState(scale.X, scale.Y, scale.Z,
|
||||
translation.X, translation.Y, translation.Z,
|
||||
@@ -1076,12 +1242,17 @@ public sealed class GfxState
|
||||
o.RotationAxis.X, o.RotationAxis.Y,
|
||||
o.RotationAxis.Z, cycleAngle),
|
||||
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)
|
||||
{
|
||||
long current = o.Color & 0xffffffff;
|
||||
|
||||
@@ -13,14 +13,23 @@ public static class SoftwareAffineRasterizer
|
||||
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
|
||||
if (ia == 0) return;
|
||||
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))
|
||||
{
|
||||
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;
|
||||
}
|
||||
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);
|
||||
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++)
|
||||
{
|
||||
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;
|
||||
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++){
|
||||
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);
|
||||
@@ -69,11 +84,16 @@ public static class SoftwareAffineRasterizer
|
||||
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 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 x1 = (int)System.Math.Min(dstW, (long)tx + width);
|
||||
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++)
|
||||
{
|
||||
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,
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);
|
||||
|
||||
@@ -40,46 +40,74 @@ public static class Transform2DMath
|
||||
{
|
||||
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, 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]);
|
||||
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,
|
||||
RotationCycleState cycle = default)
|
||||
=> 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 };
|
||||
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 };
|
||||
// AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only
|
||||
// the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated
|
||||
// about eleven arrays per rendered object, or roughly 2.2 MB on every DEBUGMAP composition.
|
||||
private readonly record struct Matrix3D(
|
||||
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);
|
||||
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
|
||||
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;
|
||||
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
|
||||
};
|
||||
return new(
|
||||
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,
|
||||
x*z*q+y*s, y*z*q-x*s, z*z*q+c,
|
||||
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];
|
||||
for (int row=0; row<4; row++)
|
||||
for (int col=0; col<4; col++)
|
||||
for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col];
|
||||
return o;
|
||||
double result = 0;
|
||||
result += a;
|
||||
result += b;
|
||||
result += c;
|
||||
result += d;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
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
|
||||
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 "set-default-gfx-object-slot":
|
||||
Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1;
|
||||
|
||||
// ---- 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)
|
||||
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 "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])));
|
||||
@@ -1782,10 +1782,10 @@ public sealed class VirtualMachine
|
||||
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;
|
||||
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)
|
||||
{
|
||||
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
|
||||
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;
|
||||
|
||||
@@ -7,6 +7,17 @@ using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
|
||||
[Flags]
|
||||
public enum HostPresentationReason
|
||||
{
|
||||
None = 0,
|
||||
HostRequest = 1,
|
||||
ScreenTransition = 2,
|
||||
RetainedMutation = 4,
|
||||
ContinuousChannel = 8,
|
||||
DiscreteSourceCell = 16,
|
||||
}
|
||||
|
||||
[SupportedOSPlatform("windows")]
|
||||
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>();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot);
|
||||
@@ -481,7 +502,12 @@ public sealed class GodotAdvHost : IHost
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -639,12 +665,22 @@ public sealed class GodotAdvHost : IHost
|
||||
// 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
|
||||
// Godot Label; waiting/sleeping alone do not alter background pixels.
|
||||
public bool ShouldRecomposite(GfxState gfx)
|
||||
public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
|
||||
{
|
||||
bool screenTransitionActive;
|
||||
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
|
||||
return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 ||
|
||||
screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs);
|
||||
var reasons = HostPresentationReason.None;
|
||||
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()
|
||||
@@ -760,9 +796,6 @@ public sealed class GodotAdvHost : IHost
|
||||
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
|
||||
long deadline = _clock.NowMs + ms;
|
||||
_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;
|
||||
while (_clock.NowMs < deadline)
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
var stack = _scripts.ToArray();
|
||||
|
||||
262
godot/Main.cs
262
godot/Main.cs
@@ -86,6 +86,8 @@ public partial class Main : Godot.Control
|
||||
private string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
|
||||
private GodotTimelineLog? _timeline;
|
||||
private int _timelineFrame;
|
||||
private string? _perfLogPath; // --perf-log <csv>: low-overhead frame/compositor timings + work
|
||||
private PerformanceFrameLog? _perf;
|
||||
|
||||
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] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
|
||||
if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = userArgs[i + 1];
|
||||
if (userArgs[i] == "--perf-log" && i + 1 < userArgs.Length) _perfLogPath = userArgs[i + 1];
|
||||
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
|
||||
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
|
||||
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
||||
@@ -225,6 +228,7 @@ public partial class Main : Godot.Control
|
||||
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
|
||||
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
|
||||
_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
|
||||
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
|
||||
_table = table;
|
||||
@@ -322,51 +326,80 @@ public partial class Main : Godot.Control
|
||||
_clock.Advance(delta);
|
||||
_timelineFrame++;
|
||||
_timeline?.SetFrame(_timelineFrame, _clock.NowMs);
|
||||
_host?.PulseFrame();
|
||||
UpdateVoicePlaybackState();
|
||||
AdoptPendingMovies();
|
||||
UpdateMovieFrames();
|
||||
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx))
|
||||
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)
|
||||
var perf = _perf;
|
||||
var step = perf != null ? _trace.LatestStep : null;
|
||||
perf?.BeginFrame(_timelineFrame, _clock.NowMs, delta,
|
||||
step?.Script ?? "<startup>", step?.Offset ?? -1, step?.Opcode ?? -1);
|
||||
try
|
||||
{
|
||||
System.IO.Directory.CreateDirectory(_seqDir);
|
||||
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
|
||||
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
|
||||
var fimg = GetViewport().GetTexture()?.GetImage();
|
||||
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
|
||||
_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)
|
||||
long phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
_host?.PulseFrame();
|
||||
perf?.RecordPulse(PerformanceFrameLog.Timestamp() - phase);
|
||||
|
||||
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
UpdateVoicePlaybackState();
|
||||
AdoptPendingMovies();
|
||||
UpdateMovieFrames();
|
||||
perf?.RecordMovies(PerformanceFrameLog.Timestamp() - phase);
|
||||
|
||||
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
HostPresentationReason presentationReasons = !_selftest && _vm != null && _host != null
|
||||
? _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;
|
||||
var img = GetViewport().GetTexture().GetImage();
|
||||
img.SavePng(_shotPath);
|
||||
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
|
||||
ReportSubroutines();
|
||||
GetTree().Quit(0);
|
||||
System.IO.Directory.CreateDirectory(_seqDir);
|
||||
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
|
||||
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
|
||||
var fimg = GetViewport().GetTexture()?.GetImage();
|
||||
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
|
||||
_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
|
||||
@@ -688,6 +721,12 @@ public partial class Main : Godot.Control
|
||||
public override void _ExitTree()
|
||||
{
|
||||
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();
|
||||
_pendingMovies.Clear();
|
||||
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.
|
||||
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.List<RenderObject> _visibleSnapshot = new(1024);
|
||||
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
|
||||
|
||||
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);
|
||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
||||
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
||||
int surfaceTextLabelIndex = 0;
|
||||
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
|
||||
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
|
||||
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
|
||||
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
|
||||
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
|
||||
ref surfaceTextLabelIndex, decisions, false);
|
||||
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
}
|
||||
else
|
||||
{
|
||||
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges
|
||||
CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true);
|
||||
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
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);
|
||||
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
|
||||
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
_screenTex.Update(_screen);
|
||||
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
|
||||
if (decisions != null) LogGfxDecisionChanges(decisions);
|
||||
_perf?.EndRecomposite();
|
||||
}
|
||||
|
||||
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
|
||||
@@ -752,6 +823,7 @@ public partial class Main : Godot.Control
|
||||
int z = 0;
|
||||
foreach (var v in visible) // interpolate at the retained-presentation clock
|
||||
{
|
||||
_perf?.RecordObject(v.TimeVarying);
|
||||
var t = v.Transform;
|
||||
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
|
||||
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 strength = v.TintStrength / 255f; // tint-blend / fill strength
|
||||
var rawObject = _vm.Gfx.TryGet(v.Handle);
|
||||
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
var surfaceTexture = rawObject != null
|
||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
||||
: null;
|
||||
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||
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)
|
||||
{
|
||||
_perf?.RecordTransitionLayer();
|
||||
int layers = DrawTransitionRange(visible, transition);
|
||||
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
|
||||
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
|
||||
if (decisions != null)
|
||||
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)
|
||||
{
|
||||
@@ -785,37 +864,54 @@ public partial class Main : Godot.Control
|
||||
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
|
||||
// the tint-strength convention used by the existing effect objects.
|
||||
float fillA = v.MultiplyTint ? opacity : opacity * strength;
|
||||
_perf?.RecordFillLayer();
|
||||
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
|
||||
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
|
||||
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
|
||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
||||
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
|
||||
ColorTimeline(v.ColorTransition);
|
||||
if (decisions != null)
|
||||
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
|
||||
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
|
||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
||||
$"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
|
||||
{
|
||||
var texture = surfaceTexture
|
||||
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
|
||||
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
||||
var texture = surfaceTexture;
|
||||
if (texture == null && !movieSurfaceBound)
|
||||
{
|
||||
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
texture = _host.ResolveResIdTexture(v.SurfaceResId);
|
||||
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||
}
|
||||
if (texture == null)
|
||||
{
|
||||
_perf?.RecordSkippedLayer();
|
||||
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
|
||||
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
|
||||
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
|
||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
||||
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
|
||||
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
|
||||
ColorTimeline(v.ColorTransition);
|
||||
if (decisions != null)
|
||||
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
|
||||
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
|
||||
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
|
||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
||||
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
|
||||
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
|
||||
ColorTimeline(v.ColorTransition);
|
||||
}
|
||||
}
|
||||
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
|
||||
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 ||
|
||||
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)
|
||||
continue;
|
||||
_perf?.RecordObject(source.TimeVarying);
|
||||
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
|
||||
if (source.RangeTransform is { } rangeTransform)
|
||||
affine = affine.Then(rangeTransform);
|
||||
float opacity = source.Alpha / 255f * (float)transition.Progress;
|
||||
var rawObject = _vm.Gfx.TryGet(source.Handle);
|
||||
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
var texture = rawObject != null
|
||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
||||
: null;
|
||||
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||
if (source.SurfaceResId == 0 && texture == null)
|
||||
{
|
||||
if (source.Blend == BlendKind.Opaque) continue;
|
||||
if (source.Blend == BlendKind.Opaque)
|
||||
{
|
||||
_perf?.RecordSkippedLayer();
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
||||
if (texture == null) continue;
|
||||
if (!movieSurfaceBound && texture == null)
|
||||
{
|
||||
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
texture = _host.ResolveResIdTexture(source.SurfaceResId);
|
||||
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||
}
|
||||
if (texture == null)
|
||||
{
|
||||
_perf?.RecordSkippedLayer();
|
||||
continue;
|
||||
}
|
||||
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,
|
||||
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;
|
||||
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
|
||||
if (w <= 0 || h <= 0) return;
|
||||
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||
var cacheKey = (assetId, colorKey);
|
||||
int sourceWidth, sourceHeight;
|
||||
byte[] sourcePixels;
|
||||
@@ -1077,16 +1192,24 @@ public partial class Main : Godot.Control
|
||||
int sh = h;
|
||||
sw = System.Math.Min(sw, sourceWidth - srcX);
|
||||
sh = System.Math.Min(sh, sourceHeight - srcY);
|
||||
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
|
||||
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
|
||||
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
|
||||
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)
|
||||
{
|
||||
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
|
||||
_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.
|
||||
@@ -1094,6 +1217,7 @@ public partial class Main : Godot.Control
|
||||
{
|
||||
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
|
||||
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);
|
||||
byte[] dst = _screenPixels;
|
||||
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 + 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).
|
||||
|
||||
@@ -94,16 +94,18 @@ internal sealed class MovieSurfaceRegistry
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var binding in _byPlayback.Values
|
||||
.Where(binding => binding.ResourceId == resourceId)
|
||||
.OrderByDescending(binding => binding.PlaybackId))
|
||||
long newestPlaybackId = long.MinValue;
|
||||
MovieSurfaceFrame? newestFrame = null;
|
||||
foreach (var binding in _byPlayback.Values)
|
||||
{
|
||||
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
|
||||
frame = found;
|
||||
return true;
|
||||
if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId ||
|
||||
!_frames.TryGetValue(binding.PlaybackId, out var found))
|
||||
continue;
|
||||
newestPlaybackId = binding.PlaybackId;
|
||||
newestFrame = found;
|
||||
}
|
||||
frame = null;
|
||||
return false;
|
||||
frame = newestFrame;
|
||||
return newestFrame != null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
297
godot/PerformanceFrameLog.cs
Normal file
297
godot/PerformanceFrameLog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user