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
|
ablations only if the phase timings do not isolate the cost. Commit the baseline percentiles and exact
|
||||||
canonical runtime coordinates here. A real unit-heavy story dungeon is deferred until it is practical
|
canonical runtime coordinates here. A real unit-heavy story dungeon is deferred until it is practical
|
||||||
to reach reproducibly.
|
to reach reproducibly.
|
||||||
- [ ] **P2 - fidelity-neutral CPU wins.** Remove normal-path diagnostic allocation and refine the ambiguous
|
- [x] **P2 - fidelity-neutral CPU wins.** P2a-P2f are implemented and measured. DEBUGMAP now meets its p95
|
||||||
presentation/affine counters, then avoid recomposition between discrete spritesheet cell changes and
|
frame budget with near-zero steady allocation; the SC0000 exit capture remains 65.18/71.76 ms p50/p95 in
|
||||||
benchmark translation, affine, and safe opaque raster fast paths. Land only changes with measured wins
|
the severe full-screen/additive band, so P2 closes and triggers P3 rather than more CPU special cases.
|
||||||
and focused differential raster/presentation coverage.
|
- [ ] **P3 - GPU retained-renderer prototype (triggered).** Upload decoded/color-key texture variants once and mirror
|
||||||
- [ ] **P3 - GPU retained-renderer prototype.** Upload decoded/color-key texture variants once and mirror
|
|
||||||
ordinary retained objects into GPU-native Godot drawing while preserving handle z-order, atlas regions,
|
ordinary retained objects into GPU-native Godot drawing while preserving handle z-order, atlas regions,
|
||||||
transforms, opacity/tint, and blend mode. Begin with dungeon sprites and ordinary translated textures;
|
transforms, opacity/tint, and blend mode. Begin with dungeon sprites and ordinary translated textures;
|
||||||
compare selected frames against the software oracle behind a backend switch.
|
compare selected frames against the software oracle behind a backend switch.
|
||||||
@@ -2421,19 +2420,19 @@ few layers but extreme full-screen/additive pixel work, while DEBUGMAP has hundr
|
|||||||
many sampled through the affine path, and discrete animation that currently requests a recomposition every
|
many sampled through the affine path, and discrete animation that currently requests a recomposition every
|
||||||
Godot frame. Execute the following in order, retaining the software compositor as the pixel oracle.
|
Godot frame. Execute the following in order, retaining the software compositor as the pixel oracle.
|
||||||
|
|
||||||
1. **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings
|
1. [x] **P2a - remove diagnostic-only allocation from normal rendering.** Construct per-object outcome strings
|
||||||
and final `z` decision strings only when `--gfx-log` or `--timeline-log` actually supplied a decisions
|
and final `z` decision strings only when `--gfx-log` or `--timeline-log` actually supplied a decisions
|
||||||
dictionary. Preserve byte-for-byte diagnostic output when enabled. Re-run DEBUGMAP and require a large
|
dictionary. Preserve byte-for-byte diagnostic output when enabled. Re-run DEBUGMAP and require a large
|
||||||
reduction from its approximately 3.59 MB median allocation per recomposition without worsening raster
|
reduction from its approximately 3.59 MB median allocation per recomposition without worsening raster
|
||||||
time; if less than 70% disappears, use an allocation trace to identify the remaining owners before
|
time; if less than 70% disappears, use an allocation trace to identify the remaining owners before
|
||||||
doing speculative collection tuning.
|
doing speculative collection tuning.
|
||||||
2. **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as
|
2. [x] **P2b - measure the two ambiguous dirty/raster categories.** Extend the low-overhead counters only as
|
||||||
needed to distinguish VM-requested, continuous-channel, and discrete-cell presentation, and to split
|
needed to distinguish VM-requested, continuous-channel, and discrete-cell presentation, and to split
|
||||||
pure fractional translation from scale/rotation/general affine work. The current log proves that the
|
pure fractional translation from scale/rotation/general affine work. The current log proves that the
|
||||||
broad categories matter but cannot tell whether DEBUGMAP's roughly 526 apparent affine layers are
|
broad categories matter but cannot tell whether DEBUGMAP's roughly 526 apparent affine layers are
|
||||||
camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on
|
camera-induced fractional translations or genuine scale/rotation. Do not change sampling semantics on
|
||||||
that assumption alone.
|
that assumption alone.
|
||||||
3. **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a
|
3. [x] **P2c - reproduce the native shared dirty/cell cadence.** Native does not give each visible sprite a
|
||||||
host-frame redraw timer. One shared current/previous millisecond frame-time pair feeds every channel;
|
host-frame redraw timer. One shared current/previous millisecond frame-time pair feeds every channel;
|
||||||
op `0x231` compares the source cell selected at those two samples and raises graphics dirty only when it
|
op `0x231` compares the source cell selected at those two samples and raises graphics dirty only when it
|
||||||
changes. Retained mutations and genuinely continuous channels still redraw as required, while an
|
changes. Retained mutations and genuinely continuous channels still redraw as required, while an
|
||||||
@@ -2444,21 +2443,28 @@ Godot frame. Execute the following in order, retaining the software compositor a
|
|||||||
deadlines. Cover clone-before/after-first-sample, reconfiguration, differing periods, wraparound, and
|
deadlines. Cover clone-before/after-first-sample, reconfiguration, differing periods, wraparound, and
|
||||||
exact boundary cases. The DEBUGMAP acceptance metric is recompositions per second and total delivered
|
exact boundary cases. The DEBUGMAP acceptance metric is recompositions per second and total delivered
|
||||||
frame time; the separately reported p50/p95 cost of frames that do recompose must remain visible.
|
frame time; the separately reported p50/p95 cost of frames that do recompose must remain visible.
|
||||||
4. **P2d - turn translation-like affine work into a verified fast path.** If P2b confirms pure unit-matrix
|
4. [x] **P2d - specialize the measured axis-aligned scale work.** P2b disproved the fractional-translation
|
||||||
fractional translations, implement the nearest-neighbor-equivalent translated raster path and prove it
|
hypothesis: the new DEBUGMAP capture reports zero fractional and zero general-affine layers, with every
|
||||||
|
non-integer layer classified as axis-aligned scale. Implement the nearest-neighbor-equivalent scale path and prove it
|
||||||
byte-for-byte against the existing inverse-mapped oracle across positive/negative coordinates,
|
byte-for-byte against the existing inverse-mapped oracle across positive/negative coordinates,
|
||||||
half-pixel boundaries, clipping, opacity/tint, and every blend mode. This is the highest-potential
|
half-pixel boundaries, clipping, opacity/tint, and every blend mode. This is the highest-potential
|
||||||
DEBUGMAP per-compose CPU win because the existing translated loop is much cheaper than a general matrix
|
DEBUGMAP per-compose CPU win because the existing translated loop is much cheaper than a general matrix
|
||||||
inverse and transform per destination pixel.
|
inverse and transform per destination pixel.
|
||||||
5. **P2e - specialize the remaining hot pixel loops.** In descending evidence value, benchmark:
|
5. [x] **P2e - specialize the remaining hot pixel loops.** The measured axis-aligned scale specialization is
|
||||||
incremental inverse coordinates across an affine scanline; axis-aligned scale specialization; and
|
complete. Benchmark opaque/full-opacity/unmodulated translated and scaled pixel loops next, using direct
|
||||||
opaque/full-opacity/unmodulated translated row copies or alpha-run copies where source transparency
|
copies for fully opaque source texels and the existing blend arithmetic at transparent edges. General
|
||||||
permits. Use randomized differential raster tests against the current implementation plus the SC0000
|
incremental inverse coordinates are low priority because the measured FIELD workload has no general-
|
||||||
|
affine layers. Use randomized differential raster tests against the current implementation plus the SC0000
|
||||||
and DEBUGMAP captures. Address the dynamic color-key full-frame clone separately because it explains an
|
and DEBUGMAP captures. Address the dynamic color-key full-frame clone separately because it explains an
|
||||||
allocation/source-prep spike but neither primary raster plateau. Do not spend time on snapshot, clear,
|
allocation/source-prep spike but neither primary raster plateau. Do not spend time on snapshot, clear,
|
||||||
`Image.SetData`, texture upload, VM dispatch, or parallel rasterization while their measured contribution is
|
`Image.SetData`, texture upload, VM dispatch, or parallel rasterization while their measured contribution is
|
||||||
small or a retained GPU renderer is the cleaner boundary.
|
small or a retained GPU renderer is the cleaner boundary.
|
||||||
6. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs.
|
6. [x] **P2f - remove the measured per-frame collection owners.** Replace `Transform2DMath.Build`'s heap
|
||||||
|
4x4 matrices with an equivalent value-type affine-3D representation, and let the compositor reuse a
|
||||||
|
caller-owned, handle-sorted `RenderObject` snapshot buffer. Preserve the allocating snapshot overload for
|
||||||
|
callers which retain independent samples. Require exact matrix/sort differential coverage and direct
|
||||||
|
zero-allocation assertions after warm-up; confirm the real path with the allocation-phase CSV columns.
|
||||||
|
7. **P2 exit gate and P3 trigger.** After the safe CPU batch, repeat equivalent SC0000 and DEBUGMAP runs.
|
||||||
Report both end-to-end cadence and recomposed-frame p50/p95/p99. If either workload's required
|
Report both end-to-end cadence and recomposed-frame p50/p95/p99. If either workload's required
|
||||||
recompositions remain above 16.67 ms p95, begin P3 rather than accumulating increasingly complex CPU
|
recompositions remain above 16.67 ms p95, begin P3 rather than accumulating increasingly complex CPU
|
||||||
special cases. The expected P3 prototype mirrors ordinary translated/atlas dungeon objects into Godot
|
special cases. The expected P3 prototype mirrors ordinary translated/atlas dungeon objects into Godot
|
||||||
@@ -2469,6 +2475,217 @@ Godot frame. Execute the following in order, retaining the software compositor a
|
|||||||
|
|
||||||
Each landed optimization gets a before/after row in this document with capture path, canonical runtime
|
Each landed optimization gets a before/after row in this document with capture path, canonical runtime
|
||||||
coordinate, p50/p95/p99, allocation, recomposition rate, and raster-work counters. Revert or leave behind a
|
coordinate, p50/p95/p99, allocation, recomposition rate, and raster-work counters. Revert or leave behind a
|
||||||
disabled experiment when it does not produce a repeatable real-path improvement. The immediate
|
disabled experiment when it does not produce a repeatable real-path improvement. The next capture validates
|
||||||
implementation slice is P2a followed by P2b; those make the next user capture cheaper and sufficiently
|
P2a-P2c together and uses P2b's new categories to choose P2d without another story-progression dependency.
|
||||||
specific to choose the correct P2c/P2d implementation without another story-progression dependency.
|
|
||||||
|
### P2a-P2c implementation checkpoint (2026-07-22)
|
||||||
|
|
||||||
|
Normal runs no longer construct per-object compositor outcome or final z-decision strings unless
|
||||||
|
`--gfx-log` or `--timeline-log` requested that evidence. This removes the known DEBUGMAP diagnostic-only
|
||||||
|
allocation source while preserving the diagnostic path. The performance CSV now records five independent
|
||||||
|
presentation reasons (host request, legacy screen transition, retained mutation, continuous channel, and
|
||||||
|
discrete source-cell change) and separates fractional translation, axis-aligned scale, and general affine
|
||||||
|
layers while retaining the original aggregate affine count.
|
||||||
|
|
||||||
|
`GfxState` now publishes retained mutation generations once and samples one shared current/previous frame
|
||||||
|
time pair on every host tick. Continuously varying channels remain frame-driven. Visible op-`0x231`
|
||||||
|
spritesheets request composition only when their selected cells differ between the shared samples, and
|
||||||
|
clone-before-first-sample records seed together. VM object writes that previously escaped the model lock are
|
||||||
|
now applied through synchronized setters so publication cannot race ahead of the retained write. Opcode
|
||||||
|
`0xc8` sleep completion no longer requests presentation by itself; preceding graphics writes are covered by
|
||||||
|
the mutation generation instead.
|
||||||
|
|
||||||
|
Focused coverage includes single-publication mutation behavior, exact cell boundaries and wrap, differing
|
||||||
|
object-local periods, reconfiguration, clones made before and after the first sample, continuous-channel
|
||||||
|
behavior, and a 51-object phase-locked family producing five—not 255—source-cell publication events per
|
||||||
|
second. The complete engine suite, Godot build/selftest, and a new comparable windowed DEBUGMAP capture are
|
||||||
|
the closeout gates. Until that capture is analyzed, the allocation and recomposition improvements are
|
||||||
|
implemented expectations rather than measured before/after results.
|
||||||
|
|
||||||
|
### P2 capture 3 - DEBUGMAP after allocation/cadence changes (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-121910-248.csv` is the first post-P2a-P2c windowed DEBUGMAP run. Its FIELD interval
|
||||||
|
contains 1,028 frames over 53.5 seconds and 1,023 recompositions (19.12/s), so discrete-cell scheduling did
|
||||||
|
not reduce end-to-end cadence in this workload. The reason columns explain why rather than invalidating the
|
||||||
|
shared-cell implementation: 1,014 FIELD rows have a continuous-channel reason. FIELD creates one visible
|
||||||
|
op-`0x232` color pulse at `FIELD@0x9691` (handle `0xc802`, period 2,000 ms), which legitimately changes the
|
||||||
|
composed output between spritesheet boundaries. The 50 ms hover callback also produced 712 host-publication
|
||||||
|
requests, including idle callbacks; callback completion is now no longer treated as dirty, while actual
|
||||||
|
retained and host-surface mutations continue to publish themselves.
|
||||||
|
|
||||||
|
P2a was useful but missed its stated allocation gate. Recomposition allocation p50 fell from 3,593,400 to
|
||||||
|
3,172,128 bytes (-421,272, 11.7%); p95 fell from 3,629,816 to 3,178,544 bytes (-451,272, 12.4%). Raster p50
|
||||||
|
was effectively unchanged (45.43 versus 45.52 ms), as expected for an allocation-only edit. The next logger
|
||||||
|
schema splits remaining allocation among snapshot, composition, source preparation, `Image.SetData`, and UI
|
||||||
|
so the next capture supplies bounded allocation attribution rather than prompting collection tuning.
|
||||||
|
|
||||||
|
The new transform counters decisively redirect P2d. Recomposed FIELD frames average 447.7 integer layers and
|
||||||
|
506.3 axis-aligned-scale layers; fractional-translation and general-affine maxima are both zero. The busy
|
||||||
|
28-34 second interval averages about 2.35-2.39 million candidates and 54 ms main time, while the final
|
||||||
|
off-map idle interval falls to about 1.58 million and 26 ms. P2d therefore adds an axis-aligned inverse-map
|
||||||
|
path which caches the source column once per layer and computes the source row once per scanline, retaining
|
||||||
|
the existing center-sample/floor and blend arithmetic. Pooled lookup storage avoids per-layer garbage.
|
||||||
|
Five hundred deterministic randomized blit/fill cases compare byte-for-byte with the previous general
|
||||||
|
inverse-mapped oracle across positive/negative scales, fractional placement, clipping, opacity, tint,
|
||||||
|
multiplicative modulation, and alpha/additive/opaque modes. A repeat DEBUGMAP capture is still required to
|
||||||
|
measure the real-path raster win and the new allocation phases. All 345 engine tests pass and the Godot
|
||||||
|
project builds with zero warnings/errors.
|
||||||
|
|
||||||
|
An environment audit after this capture found four Godot game/console pairs still alive from July 11 and
|
||||||
|
July 21. Each game process consumed about 0.41 CPU-seconds during a two-second sample (roughly 80% of one
|
||||||
|
logical core in aggregate). They predate both P1 and this capture, so workload attribution, allocation deltas,
|
||||||
|
and the transform-category correction remain actionable, but its absolute frame-time acceptance numbers are
|
||||||
|
provisional. The eight processes were terminated with user authorization before the following capture.
|
||||||
|
|
||||||
|
### P2 capture 4 - DEBUGMAP after axis-aligned scale specialization (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-130024-542.csv` is the clean post-P2d windowed comparison, captured after the stale
|
||||||
|
Godot processes above were closed. The stable FIELD workload still visits about 962 objects and draws about
|
||||||
|
961 layers: approximately 433 integer translations, 526 axis-aligned scales, zero fractional translations,
|
||||||
|
and zero general affine layers. Around 900 layers are classified opaque. Camera position changes clipped
|
||||||
|
candidate pixels, but it does not materially reduce retained traversal, layer count, source pixels, or the
|
||||||
|
continuous-channel presentation cadence.
|
||||||
|
|
||||||
|
For equivalent 2.2-2.5 million candidate-pixel frames, recomposition p50/p95/p99 fell from
|
||||||
|
53.71/60.50/62.72 ms in capture 3 to 21.52/22.26/22.75 ms. Raster p50/p95 fell from 50.77/57.27 ms to
|
||||||
|
19.07/19.52 ms: a 62.4% median raster reduction with the same workload band. The final 1.5-1.9 million
|
||||||
|
candidate-pixel off-map band fell from 27.34/32.34 ms recomposition p50/p95 to 14.73/15.89 ms. Delivered
|
||||||
|
steady cadence consequently rises from about 19 recompositions/s to 44-48/s over the busy map and about
|
||||||
|
64/s off-map. P2d is a large, repeatable real-path win; busy-map p95 nevertheless remains above the
|
||||||
|
16.67 ms P2 exit target.
|
||||||
|
|
||||||
|
The new allocation phases attribute the remaining steady busy-map p50/p95 almost exactly: total
|
||||||
|
3,178,544/3,178,544 bytes, retained snapshot 961,600/961,600, compositor 2,216,664/2,216,664, source
|
||||||
|
preparation zero, `Image.SetData` zero, and UI 88/88. Camera position barely changes that total because the
|
||||||
|
snapshot and compositor collections still cover the full retained set. Allocation is now a clear GC/long-run
|
||||||
|
stability target, but raster remains the direct busy-frame budget blocker. P2e therefore starts with the
|
||||||
|
full-opacity/unmodulated hot pixel loops used by the roughly 900 opaque layers; after that measured capture,
|
||||||
|
reduce the two identified per-frame collection owners rather than tuning the GC.
|
||||||
|
|
||||||
|
### P2e unmodulated source-over specialization implemented (2026-07-22)
|
||||||
|
|
||||||
|
The translated and axis-aligned-scale raster loops now detect full object opacity, zero effective tint,
|
||||||
|
non-multiplicative color, and non-additive blending once per layer. In that common mode, alpha-zero texels
|
||||||
|
remain skipped, alpha-255 texels become exact four-byte copies, and partially transparent edge texels retain
|
||||||
|
the previous integer source-over arithmetic. The general tinted, faded, multiplicative, and additive paths
|
||||||
|
are unchanged. This targets the approximately 900 opaque FIELD layers measured in capture 4 without
|
||||||
|
assuming that their color-keyed source rectangles contain no transparent pixels.
|
||||||
|
|
||||||
|
Four hundred focused translated/scaled cases cover both opaque and alpha blend classifications with source
|
||||||
|
alpha values 0, 1, 254, and 255 against the retained pre-fast-path oracle. The complete engine suite passes
|
||||||
|
at 346 tests, the Godot build has zero warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the same
|
||||||
|
DEBUGMAP camera/idle/off-map workload. Retain P2e only if the busy 2.2-2.5 million candidate-pixel band shows
|
||||||
|
a repeatable win; then address the measured snapshot/compositor allocation owners before the P2 exit capture.
|
||||||
|
|
||||||
|
### P2 capture 5 - DEBUGMAP after unmodulated source-over specialization (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-132318-886.csv` is the comparable post-P2e windowed run. In the matched 2.2-2.5
|
||||||
|
million candidate-pixel band, recomposition p50/p95/p99 fell from 21.52/22.26/22.75 ms to
|
||||||
|
15.35/16.18/19.26 ms, while raster p50/p95 fell from 19.07/19.52 ms to 12.66/13.23 ms. That is a 28.7%
|
||||||
|
median recomposition reduction and a 33.6% median raster reduction; steady busy-map delivery rises from
|
||||||
|
about 44-48 to 61-66 recompositions/s. The 1.5-1.9 million candidate off-map band falls from
|
||||||
|
14.73/15.89 ms recomposition p50/p95 to 9.45/11.21 ms. P2e is retained: busy-map p95 now fits the 16.67 ms
|
||||||
|
target, although allocation/GC outliers leave p99 above it.
|
||||||
|
|
||||||
|
Allocation remains unchanged at 3,178,544 bytes p50/p95 in the busy band. Capture-wide gen-0/gen-1/gen-2
|
||||||
|
counts are 184/52/34 over 3,111 FIELD recompositions, versus 147/49/34 over only 2,274 recompositions in
|
||||||
|
capture 4; normalized collection rates therefore do not regress, but short-lived garbage remains the clear
|
||||||
|
tail-latency and long-run-stability target.
|
||||||
|
|
||||||
|
### P2f measured allocation owners removed (2026-07-22)
|
||||||
|
|
||||||
|
The compositor allocation phase was dominated by `Transform2DMath.Build`: it created about eleven
|
||||||
|
`double[16]` matrices for every rendered object. It now composes the same row-vector operations through a
|
||||||
|
twelve-double value-type affine-3D matrix and projects to `Affine2D` only at the boundary. Five hundred
|
||||||
|
randomized scale/translation/anchor/one-shot/cyclic-rotation cases match every output double bit-for-bit
|
||||||
|
against the former array implementation, and 10,000 warmed builds allocate zero bytes.
|
||||||
|
|
||||||
|
The retained snapshot phase no longer uses `Dictionary.OrderBy` or returns a newly grown list to the Godot
|
||||||
|
hot path. `GfxState` maintains a sorted handle index alongside its O(1) object dictionary, and fills a
|
||||||
|
compositor-owned reusable `List<RenderObject>` under the existing lock. The returning overload remains for
|
||||||
|
callers needing an independent snapshot. Ten warmed 1,000-object samples allocate zero bytes and preserve
|
||||||
|
ascending handle order. The complete engine suite passes at 349 tests, the Godot build has zero
|
||||||
|
warnings/errors, and threaded `SELFTEST OK`. **Next:** repeat the comparable DEBUGMAP run and verify the
|
||||||
|
snapshot/compositor allocation columns collapse without changing the capture-5 frame-time distribution;
|
||||||
|
then repeat SC0000 for the P2 exit/P3 decision.
|
||||||
|
|
||||||
|
### P2 capture 6 - DEBUGMAP after primary allocation removal (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-133412-146.csv` confirms P2f's primary allocation changes on the real path. In the
|
||||||
|
matched busy band, total allocation p50/p95 falls from 3,178,544/3,178,544 to 262,160/262,160 bytes
|
||||||
|
(-91.8%). Retained snapshot allocation is exactly zero; compositor allocation falls from 2,216,664 to
|
||||||
|
261,880 bytes. Capture-wide gen-0/gen-1/gen-2 collections fall from 184/52/34 to 21/8/5 despite broadly
|
||||||
|
similar duration and 2,709 FIELD recompositions. Snapshot p50/p95 falls from 0.165/0.328 ms to
|
||||||
|
0.097/0.141 ms.
|
||||||
|
|
||||||
|
Raster remains stable at 12.70/13.36 ms p50/p95 versus capture 5's 12.66/13.23 ms. End-to-end busy
|
||||||
|
recomposition improves slightly from 15.35/16.18/19.26 ms p50/p95/p99 to 15.10/15.87/16.25 ms; the much
|
||||||
|
tighter p99 is consistent with the measured GC reduction. Matched off-map recomposition improves from
|
||||||
|
9.45/11.21/13.20 ms to 9.04/10.02/11.35 ms. P2f therefore preserves the raster win while removing the
|
||||||
|
large short-lived collection owners.
|
||||||
|
|
||||||
|
The stable 261,880-byte compositor remainder scales almost exactly with FIELD's approximately 961 retained
|
||||||
|
objects. The first hypothesis was the per-object `SnapshotSurfaceText` array copy, so the compositor was
|
||||||
|
changed to reuse a caller-owned `List<SurfaceTextDraw>` under the existing text lock; the returning overload
|
||||||
|
remains available to callers needing an independent snapshot. Capture 7 below disproves that attribution.
|
||||||
|
|
||||||
|
### P2 capture 7 - residual allocation probe (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-134322-530.csv` is the requested short steady DEBUGMAP idle probe. Across 1,208
|
||||||
|
full FIELD recompositions, total allocation remains 259,952/261,728 bytes p50/p95, with snapshot allocation
|
||||||
|
zero and compositor allocation 259,672/261,448 bytes. The surface-text buffer therefore has no material
|
||||||
|
effect in this workload and is only a harmless general cleanup. Recomposition remains in the expected
|
||||||
|
camera-dependent range at 14.19/16.46 ms p50/p95 for an average 2.38 million candidate pixels.
|
||||||
|
|
||||||
|
The exact remaining owner is `MovieSurfaceRegistry.TryResolveResource`: every ordinary still-texture
|
||||||
|
fallback constructed a LINQ `Where` plus descending sort pipeline to look for a live movie frame, including
|
||||||
|
when the movie registry was empty. This occurred once per retained object and explains the stable roughly
|
||||||
|
270 bytes/object remainder. The registry now scans its live bindings directly while retaining the rule that
|
||||||
|
the newest published playback of a resource wins. A focused test covers matching/missing lookups and newest-
|
||||||
|
playback selection; 10,000 pairs allocate zero bytes after warm-up. All 350 engine tests, the zero-warning
|
||||||
|
Godot build, and threaded `SELFTEST OK` pass. **Next:** one final short DEBUGMAP idle probe confirms the
|
||||||
|
compositor remainder is gone, then SC0000 through the pre-CHAPTER burst supplies the P2 exit/P3 decision.
|
||||||
|
|
||||||
|
### P2 capture 8 - DEBUGMAP allocation closeout (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-140820-530.csv` confirms the movie-registry correction. Across 820 full steady
|
||||||
|
FIELD recompositions, total allocation is 4,416 bytes p50/p95/p99 and compositor allocation is 4,136 bytes;
|
||||||
|
snapshot, source preparation, and `Image.SetData` remain zero, while UI accounts for 88 bytes. This is a
|
||||||
|
99.86% reduction from capture 4's 3,178,544-byte steady total and a 98.3% reduction from capture 7's
|
||||||
|
259,952-byte residual. FIELD itself performs zero gen-0, gen-1, or gen-2 collections in this probe; every
|
||||||
|
capture-wide collection occurred during boot/map setup.
|
||||||
|
|
||||||
|
One FIELD row allocates 1,415,744 bytes during composition at `FIELD.BIN@0x1029` as a one-time reusable-
|
||||||
|
capacity/cache warm-up. It triggers no collection and completes in 14.78 ms, so it is neither a steady owner
|
||||||
|
nor a visible stall. At an average 2.32 million candidate pixels, recomposition p50/p95/p99 is
|
||||||
|
13.90/15.86/18.89 ms and raster p50/p95 is 11.32/13.22 ms, consistent with the post-P2e/P2f distribution.
|
||||||
|
DEBUGMAP therefore closes with busy p95 inside 16.67 ms and effectively allocation-free steady rendering.
|
||||||
|
**Next:** repeat SC0000 through the original pre-CHAPTER burst and compare against capture 1's exact
|
||||||
|
`SC0000@0x123de` plateau before deciding whether P2 exits or the full-screen/additive case triggers P3.
|
||||||
|
|
||||||
|
### P2 exit capture 9 - SC0000 pre-CHAPTER and post-movie text (2026-07-22)
|
||||||
|
|
||||||
|
`build/perf/run-20260722-141200-995.csv` repeats capture 1 through the CHAPTER movie and the following text.
|
||||||
|
Across every recomposition parked at `SC0000.BIN@0x123de`, recomposition p50/p95/p99 improves from
|
||||||
|
21.85/57.40/82.70 ms to 15.39/31.71/66.38 ms. Movie sampling itself remains small: twelve active rows have
|
||||||
|
`movie_ms` p50/p95/max 0.20/0.99/1.06 ms, so decoding/presentation is not the reported burst bottleneck.
|
||||||
|
|
||||||
|
The acceptance decision uses equivalent severe rows rather than the mixed coordinate aggregate. For frames
|
||||||
|
with at least 3.5 million candidate pixels, recomposition p50/p95/p99 falls from 76.43/88.42/90.11 ms to
|
||||||
|
65.15/71.74/72.03 ms; raster p50/p95 remains 64.11/71.15 ms. These 52 rows average 4.29 million candidate
|
||||||
|
pixels, 10.7 layers, 4.6 additive layers, 8.3 viewport-covering layers, two general-affine layers, and less
|
||||||
|
than 0.9 KB managed allocation. The worst comparable frame is 72.21 ms. P2's CPU fast paths therefore save
|
||||||
|
roughly 15-19% in this burst but leave it around 14-15 delivered frames/s, more than four times the
|
||||||
|
16.67 ms budget. No allocation or scheduler optimization can close that gap.
|
||||||
|
|
||||||
|
A separate later interval still clones about 1.92 MB per recomposition while applying a color key to a
|
||||||
|
dynamic frame. It is an independent GC issue already anticipated by the action plan, but it does not occur
|
||||||
|
in the severe additive plateau and cannot change the exit decision. Treat it as part of the GPU texture/
|
||||||
|
shader ownership work rather than delaying P3 for another software-raster special case.
|
||||||
|
|
||||||
|
**P2 exit decision:** close the fidelity-neutral CPU batch and begin P3. The prototype mirrors ordinary
|
||||||
|
retained texture/fill objects into GPU-native Godot drawing behind a backend switch, preserves handle z-order,
|
||||||
|
atlas source rectangles, colorkey, tint/opacity, additive blending, and the exact affine transform, and keeps
|
||||||
|
the software compositor as the pixel-parity oracle/fallback. First acceptance is the same
|
||||||
|
`SC0000@0x123de` severe band below 16.67 ms p95 without regressing DEBUGMAP presentation or movie/text
|
||||||
|
composition; only then make GPU rendering the default.
|
||||||
|
|||||||
@@ -217,10 +217,19 @@ time, pulse/movie/UI time, and a compositor breakdown: recomposition, clear, ret
|
|||||||
resolution/decode, color-key/source preparation, raster, `Image.SetData`, and `ImageTexture.Update`. Workload columns report transition
|
resolution/decode, color-key/source preparation, raster, `Image.SetData`, and `ImageTexture.Update`. Workload columns report transition
|
||||||
state, retained object visits, time-varying object visits, drawn/fill/transition/skipped layers, integer/
|
state, retained object visits, time-varying object visits, drawn/fill/transition/skipped layers, integer/
|
||||||
affine/singular raster paths, dynamic/opaque/alpha/additive layers, source pixels, clipped affine bounding-
|
affine/singular raster paths, dynamic/opaque/alpha/additive layers, source pixels, clipped affine bounding-
|
||||||
box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas. The
|
box candidate pixels, full-screen layers, main-thread managed allocations, and GC collection deltas.
|
||||||
|
Presentation-reason columns distinguish host publication requests, legacy screen transitions, retained VM
|
||||||
|
mutations, continuously sampled channels, and discrete spritesheet cell changes. Affine work is additionally
|
||||||
|
split into fractional translation, axis-aligned scale, and general affine layers while retaining the aggregate
|
||||||
|
`affine_layers` column for comparison with the first two captures. The
|
||||||
|
allocation total is split into recomposition, retained snapshot, compositor, source-preparation,
|
||||||
|
`Image.SetData`, and Godot UI phases so a normal windowed capture can attribute remaining managed garbage
|
||||||
|
without enabling a high-volume trace. The
|
||||||
writer replaces the target, buffers 120 rows between flushes, and prints its frame/recomposition counts on
|
writer replaces the target, buffers 120 rows between flushes, and prints its frame/recomposition counts on
|
||||||
normal shutdown. Use a windowed Release-equivalent run at speed 1 for performance evidence; headless runs
|
normal shutdown. Use a windowed Release-equivalent run at speed 1 for performance evidence; headless runs
|
||||||
validate the schema only. Do not combine baseline captures with `--shot-sequence`, `--gfx-log`, or
|
validate the schema only. Before a baseline, verify that no older Godot game processes remain alive; an
|
||||||
|
apparently closed window can otherwise leave a renderer consuming CPU and contaminate later runs. Do not
|
||||||
|
combine baseline captures with `--shot-sequence`, `--gfx-log`, or
|
||||||
`--timeline-log`, whose diagnostics add substantial or differently shaped work. Example from `age-reimpl/`:
|
`--timeline-log`, whose diagnostics add substantial or differently shaped work. Example from `age-reimpl/`:
|
||||||
`godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`.
|
`godot --path godot -- --scene SC0000 --boot --perf-log ../build/perf/sc0000.csv`.
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
|
<Compile Include="..\..\godot\PageLocatorState.cs" Link="PageLocatorState.cs" />
|
||||||
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
|
<Compile Include="..\..\godot\GodotTimelineLog.cs" Link="GodotTimelineLog.cs" />
|
||||||
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
|
<Compile Include="..\..\godot\GodotTraceSink.cs" Link="GodotTraceSink.cs" />
|
||||||
|
<Compile Include="..\..\godot\PerformanceFrameLog.cs" Link="PerformanceFrameLog.cs" />
|
||||||
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
|
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
|
||||||
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
|
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
|
||||||
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
||||||
|
|||||||
@@ -25,6 +25,25 @@ public class GfxAnimationTests
|
|||||||
Assert.True(o.TranslationEnabled);
|
Assert.True(o.TranslationEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CallerOwnedVisibleSnapshot_ReusesStorageAndPreservesHandleOrder()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
for (int i = 999; i >= 0; i--)
|
||||||
|
g.BindDraw(0x1000 + i, 1, 0, 0, 1, 1, i, 0);
|
||||||
|
var snapshot = new List<RenderObject>();
|
||||||
|
g.SnapshotVisibleObjects(0, snapshot); // Grow and warm the buffer outside the measured interval.
|
||||||
|
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
for (int i = 0; i < 10; i++) g.SnapshotVisibleObjects(i, snapshot);
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
|
||||||
|
Assert.Equal(0, allocated);
|
||||||
|
Assert.Equal(1000, snapshot.Count);
|
||||||
|
Assert.Equal(0x1000, snapshot[0].Handle);
|
||||||
|
Assert.Equal(0x1000 + 999, snapshot[^1].Handle);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor()
|
public void CurrentScaleSetter_ExpandsGlowAroundItsAnchor()
|
||||||
{
|
{
|
||||||
@@ -94,18 +113,22 @@ public class GfxAnimationTests
|
|||||||
|
|
||||||
var unchanged = VisibleObject();
|
var unchanged = VisibleObject();
|
||||||
Assert.False(unchanged.HasActiveVisualPresentation(1000));
|
Assert.False(unchanged.HasActiveVisualPresentation(1000));
|
||||||
|
Assert.False(unchanged.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||||
|
|
||||||
var spritesheet = VisibleObject();
|
var spritesheet = VisibleObject();
|
||||||
spritesheet.SetSrcRect(7, 4, 1, 0, 800);
|
spritesheet.SetSrcRect(7, 4, 1, 0, 800);
|
||||||
Assert.True(spritesheet.HasActiveVisualPresentation(1000));
|
Assert.True(spritesheet.HasActiveVisualPresentation(1000));
|
||||||
|
Assert.True(spritesheet.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||||
|
|
||||||
var color = VisibleObject();
|
var color = VisibleObject();
|
||||||
color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
|
color.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
|
||||||
Assert.True(color.HasActiveVisualPresentation(1000));
|
Assert.True(color.HasActiveVisualPresentation(1000));
|
||||||
|
Assert.True(color.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||||
|
|
||||||
var rotation = VisibleObject();
|
var rotation = VisibleObject();
|
||||||
rotation.SetRotationCycle(7, 1000, (0, 0, 1));
|
rotation.SetRotationCycle(7, 1000, (0, 0, 1));
|
||||||
Assert.True(rotation.HasActiveVisualPresentation(1000));
|
Assert.True(rotation.HasActiveVisualPresentation(1000));
|
||||||
|
Assert.True(rotation.SnapshotVisibleObjects(1000).Single().TimeVarying);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -129,6 +152,108 @@ public class GfxAnimationTests
|
|||||||
Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H));
|
Assert.Equal((0, 0, 200, 200), (wrapped.SrcX, wrapped.SrcY, wrapped.W, wrapped.H));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationReasons_PublishMutationOnce_AndSpritesheetOnlyAtCellBoundaries()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
g.SetSurface(4, 0x37, -1);
|
||||||
|
g.BindDraw(7, 4, 0, 0, 16, 16, 0, 0);
|
||||||
|
g.SetSrcRect(7, frameCount: 4, columns: 2, cell: 0, period: 200);
|
||||||
|
|
||||||
|
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1399));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1599));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1600));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1799));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1800));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationReasons_CloneBeforeFirstSampleSharesPhase_AndCloneAfterKeepsIt()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
g.SetSurface(4, 0x37, -1);
|
||||||
|
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
|
||||||
|
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 200);
|
||||||
|
Assert.True(g.CloneObject(10, 11));
|
||||||
|
|
||||||
|
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1000));
|
||||||
|
Assert.Equal(1000, g.TryGet(10)!.SrcStart);
|
||||||
|
Assert.Equal(1000, g.TryGet(11)!.SrcStart);
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
|
||||||
|
Assert.Equal(g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 10).SrcX,
|
||||||
|
g.SnapshotVisibleObjects(1200).Single(x => x.Handle == 11).SrcX);
|
||||||
|
|
||||||
|
Assert.True(g.CloneObject(10, 12));
|
||||||
|
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
|
||||||
|
Assert.Equal(1000, g.TryGet(12)!.SrcStart);
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1400));
|
||||||
|
var cells = g.SnapshotVisibleObjects(1400).Select(x => x.SrcX).Distinct().ToArray();
|
||||||
|
Assert.Single(cells);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationReasons_UsesObjectLocalPeriods_AndReconfigurationRestartsAtSharedSample()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
g.SetSurface(4, 0x37, -1);
|
||||||
|
g.BindDraw(10, 4, 0, 0, 16, 16, 0, 0);
|
||||||
|
g.BindDraw(11, 4, 0, 0, 16, 16, 0, 0);
|
||||||
|
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 100);
|
||||||
|
g.SetSrcRect(11, frameCount: 4, columns: 2, cell: 0, period: 250);
|
||||||
|
g.ConsumePresentationReasons(1000);
|
||||||
|
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1099));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1100));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1199));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1200));
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1249));
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1250));
|
||||||
|
|
||||||
|
g.SetSrcRect(10, frameCount: 4, columns: 2, cell: 0, period: 400);
|
||||||
|
Assert.Equal(GfxPresentationReason.RetainedMutation, g.ConsumePresentationReasons(1250));
|
||||||
|
Assert.Equal(1250, g.TryGet(10)!.SrcStart);
|
||||||
|
Assert.Equal(GfxPresentationReason.None, g.ConsumePresentationReasons(1499));
|
||||||
|
// The second object's 250 ms boundary and the reconfigured object's 400 ms boundary coincide here.
|
||||||
|
Assert.Equal(GfxPresentationReason.DiscreteSourceCell, g.ConsumePresentationReasons(1650));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationReasons_ContinuousChannelRemainsFrameDriven()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
g.SetSurface(1, 5, -1);
|
||||||
|
g.BindDraw(7, 1, 0, 0, 64, 64, 0, 0);
|
||||||
|
g.SetColorAnim(7, 1000, GfxState.PackColor(0x80, 0xff0000));
|
||||||
|
|
||||||
|
var first = g.ConsumePresentationReasons(1000);
|
||||||
|
Assert.True((first & GfxPresentationReason.RetainedMutation) != 0);
|
||||||
|
Assert.True((first & GfxPresentationReason.ContinuousChannel) != 0);
|
||||||
|
Assert.Equal(GfxPresentationReason.ContinuousChannel, g.ConsumePresentationReasons(1016));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PresentationReasons_PhaseLockedUnitFamilyPublishesFiveCellChangesPerSecond()
|
||||||
|
{
|
||||||
|
var g = new GfxState();
|
||||||
|
g.SetSurface(4, 0x37, -1);
|
||||||
|
g.BindDraw(100, 4, 0, 0, 16, 16, 0, 0);
|
||||||
|
g.SetSrcRect(100, frameCount: 4, columns: 2, cell: 0, period: 200);
|
||||||
|
for (long handle = 101; handle < 151; handle++) Assert.True(g.CloneObject(100, handle));
|
||||||
|
g.ConsumePresentationReasons(1000);
|
||||||
|
|
||||||
|
int cellChanges = 0;
|
||||||
|
for (long now = 1001; now <= 2000; now++)
|
||||||
|
if ((g.ConsumePresentationReasons(now) & GfxPresentationReason.DiscreteSourceCell) != 0)
|
||||||
|
cellChanges++;
|
||||||
|
|
||||||
|
Assert.Equal(5, cellChanges);
|
||||||
|
Assert.Single(g.Objects.Select(pair => g.TryGet(pair.Handle)!.SrcStart).Distinct());
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
|
public void OneShotRotation_SharesMatrixClockAndMatchesNativeSample()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,6 +60,33 @@ public class MovieSurfaceRegistryTests
|
|||||||
Assert.False(registry.IsKnownMovieResource(0x2af5));
|
Assert.False(registry.IsKnownMovieResource(0x2af5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResourceLookup_SelectsNewestPublishedPlaybackWithoutAllocating()
|
||||||
|
{
|
||||||
|
var registry = new MovieSurfaceRegistry();
|
||||||
|
MovieSurfaceBinding older = registry.Begin(7, 0x2b42, out _);
|
||||||
|
MovieSurfaceBinding unrelated = registry.Begin(8, 0x2bad, out _);
|
||||||
|
MovieSurfaceBinding newer = registry.Begin(9, 0x2b42, out _);
|
||||||
|
Assert.True(registry.PublishFrame(older.PlaybackId, Frame(1), "OLDER.AGF", 1));
|
||||||
|
Assert.True(registry.PublishFrame(unrelated.PlaybackId, Frame(2), "OTHER.AGF", 2));
|
||||||
|
Assert.True(registry.PublishFrame(newer.PlaybackId, Frame(3), "NEWER.AGF", 3));
|
||||||
|
Assert.True(registry.TryResolveResource(0x2b42, out MovieSurfaceFrame? warm));
|
||||||
|
Assert.Equal("NEWER.AGF", warm!.Name);
|
||||||
|
Assert.False(registry.TryResolveResource(0x2af5, out _));
|
||||||
|
|
||||||
|
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
bool resultsCorrect = true;
|
||||||
|
for (int i = 0; i < 10_000; i++)
|
||||||
|
{
|
||||||
|
resultsCorrect &= registry.TryResolveResource(0x2b42, out _);
|
||||||
|
resultsCorrect &= !registry.TryResolveResource(0x2af5, out _);
|
||||||
|
}
|
||||||
|
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||||
|
|
||||||
|
Assert.True(resultsCorrect);
|
||||||
|
Assert.Equal(0, allocated);
|
||||||
|
}
|
||||||
|
|
||||||
private static RgbaImage Frame(byte value)
|
private static RgbaImage Frame(byte value)
|
||||||
=> new(1, 1, new[] { value, value, value, (byte)255 });
|
=> new(1, 1, new[] { value, value, value, (byte)255 });
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
Assert.Equal(new byte[] { 43, 57, 109, 255 }, background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlitRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
|
||||||
|
{
|
||||||
|
var random = new Random(0x231);
|
||||||
|
double[] scales = { -2.0, -1.25, -0.5, 0.25, 0.5, 0.75, 1.25, 2.0, 3.0 };
|
||||||
|
double[] translations = { -5.5, -2.25, -0.5, 0.0, 0.5, 1.25, 4.5, 8.0 };
|
||||||
|
BlendKind[] blends = { BlendKind.Opaque, BlendKind.Alpha, BlendKind.Additive };
|
||||||
|
for (int iteration = 0; iteration < 250; iteration++)
|
||||||
|
{
|
||||||
|
int srcW = random.Next(2, 12), srcH = random.Next(2, 10);
|
||||||
|
byte[] src = new byte[srcW * srcH * 4];
|
||||||
|
random.NextBytes(src);
|
||||||
|
int srcX = random.Next(0, srcW), srcY = random.Next(0, srcH);
|
||||||
|
int width = random.Next(1, srcW - srcX + 1), height = random.Next(1, srcH - srcY + 1);
|
||||||
|
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
|
||||||
|
byte[] expected = new byte[dstW * dstH * 4];
|
||||||
|
random.NextBytes(expected);
|
||||||
|
byte[] actual = (byte[])expected.Clone();
|
||||||
|
var transform = new Affine2D(
|
||||||
|
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
|
||||||
|
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
|
||||||
|
long tint = random.Next(0x1000000);
|
||||||
|
float tintStrength = random.NextSingle();
|
||||||
|
float opacity = random.NextSingle();
|
||||||
|
bool multiplyTint = random.Next(2) != 0;
|
||||||
|
BlendKind blend = blends[random.Next(blends.Length)];
|
||||||
|
|
||||||
|
ReferenceBlit(expected, dstW, dstH, src, srcW, srcX, srcY, width, height,
|
||||||
|
transform, tint, tintStrength, opacity, multiplyTint, blend);
|
||||||
|
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
|
||||||
|
srcX, srcY, width, height, transform,
|
||||||
|
tint, tintStrength, opacity, multiplyTint, blend);
|
||||||
|
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FillRgba_AxisAlignedScaleFastPath_MatchesGeneralAffineAcrossRandomizedCases()
|
||||||
|
{
|
||||||
|
var random = new Random(0x22d);
|
||||||
|
double[] scales = { -2.0, -1.0, -0.5, 0.25, 0.5, 0.75, 1.5, 2.0, 3.0 };
|
||||||
|
double[] translations = { -5.5, -1.25, -0.5, 0.0, 0.5, 2.25, 7.0 };
|
||||||
|
for (int iteration = 0; iteration < 250; iteration++)
|
||||||
|
{
|
||||||
|
int width = random.Next(1, 10), height = random.Next(1, 9);
|
||||||
|
int dstW = random.Next(3, 15), dstH = random.Next(3, 13);
|
||||||
|
byte[] expected = new byte[dstW * dstH * 4];
|
||||||
|
random.NextBytes(expected);
|
||||||
|
byte[] actual = (byte[])expected.Clone();
|
||||||
|
var transform = new Affine2D(
|
||||||
|
scales[random.Next(scales.Length)], 0, 0, scales[random.Next(scales.Length)],
|
||||||
|
translations[random.Next(translations.Length)], translations[random.Next(translations.Length)]);
|
||||||
|
long color = random.Next(0x1000000);
|
||||||
|
float opacity = random.NextSingle();
|
||||||
|
|
||||||
|
ReferenceFill(expected, dstW, dstH, width, height, transform, color, opacity);
|
||||||
|
SoftwareAffineRasterizer.FillRgba(actual, dstW, dstH, width, height,
|
||||||
|
transform, color, opacity);
|
||||||
|
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BlitRgba_UnmodulatedSourceOverFastPaths_MatchGeneralAffineAcrossAlphaEdges()
|
||||||
|
{
|
||||||
|
var random = new Random(0x2e0);
|
||||||
|
Affine2D[] transforms =
|
||||||
|
{
|
||||||
|
new(1, 0, 0, 1, -2, 1),
|
||||||
|
new(1, 0, 0, 1, 3, -1),
|
||||||
|
new(0.5, 0, 0, 1.5, -1.25, 0.5),
|
||||||
|
new(-1.25, 0, 0, 0.75, 8.5, -0.5),
|
||||||
|
};
|
||||||
|
foreach (var transform in transforms)
|
||||||
|
foreach (BlendKind blend in new[] { BlendKind.Opaque, BlendKind.Alpha })
|
||||||
|
for (int iteration = 0; iteration < 50; iteration++)
|
||||||
|
{
|
||||||
|
int srcW = random.Next(3, 12), srcH = random.Next(3, 10);
|
||||||
|
byte[] src = new byte[srcW * srcH * 4];
|
||||||
|
random.NextBytes(src);
|
||||||
|
for (int pixel = 0; pixel < srcW * srcH; pixel++)
|
||||||
|
src[pixel * 4 + 3] = (byte)(pixel % 4 switch { 0 => 0, 1 => 1, 2 => 254, _ => 255 });
|
||||||
|
int dstW = random.Next(5, 16), dstH = random.Next(5, 14);
|
||||||
|
byte[] expected = new byte[dstW * dstH * 4];
|
||||||
|
random.NextBytes(expected);
|
||||||
|
byte[] actual = (byte[])expected.Clone();
|
||||||
|
|
||||||
|
ReferenceBlit(expected, dstW, dstH, src, srcW, 1, 1, srcW - 1, srcH - 1,
|
||||||
|
transform, 0x6a4c2e, 0, 1, false, blend);
|
||||||
|
SoftwareAffineRasterizer.BlitRgba(actual, dstW, dstH, src, srcW, srcH,
|
||||||
|
1, 1, srcW - 1, srcH - 1, transform,
|
||||||
|
0x6a4c2e, 0, 1, false, blend);
|
||||||
|
|
||||||
|
Assert.Equal(expected, actual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pre-fast-path affine algorithm retained here as an independent differential oracle.
|
// Pre-fast-path affine algorithm retained here as an independent differential oracle.
|
||||||
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
|
private static void ReferenceBlit(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
|
||||||
int srcX, int srcY, int width, int height, Affine2D transform,
|
int srcX, int srcY, int width, int height, Affine2D transform,
|
||||||
long tint, float tintStrength, float opacity, bool multiplyTint)
|
long tint, float tintStrength, float opacity, bool multiplyTint,
|
||||||
|
BlendKind blend = BlendKind.Alpha)
|
||||||
{
|
{
|
||||||
if (!transform.TryInverse(out var inv)) return;
|
if (!transform.TryInverse(out var inv)) return;
|
||||||
ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
|
ReferenceBounds(transform, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
|
||||||
@@ -136,7 +236,7 @@ public class SoftwareAffineRasterizerTests
|
|||||||
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
|
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
|
||||||
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
|
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
|
||||||
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
|
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
|
||||||
ReferenceBlend(dst, di, sr, sg, sb, sa);
|
ReferenceBlend(dst, di, sr, sg, sb, sa, blend);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +265,17 @@ public class SoftwareAffineRasterizerTests
|
|||||||
y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y))));
|
y1 = System.Math.Min(dh, (int)System.Math.Ceiling(System.Math.Max(System.Math.Max(a.Y, b.Y), System.Math.Max(c.Y, d.Y))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a)
|
private static void ReferenceBlend(byte[] dst, int i, int r, int g, int b, int a,
|
||||||
|
BlendKind blend = BlendKind.Alpha)
|
||||||
{
|
{
|
||||||
|
if (blend == BlendKind.Additive)
|
||||||
|
{
|
||||||
|
dst[i] = (byte)System.Math.Min(255, dst[i] + r * a / 255);
|
||||||
|
dst[i + 1] = (byte)System.Math.Min(255, dst[i + 1] + g * a / 255);
|
||||||
|
dst[i + 2] = (byte)System.Math.Min(255, dst[i + 2] + b * a / 255);
|
||||||
|
dst[i + 3] = (byte)System.Math.Min(255, dst[i + 3] + a);
|
||||||
|
return;
|
||||||
|
}
|
||||||
dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255);
|
dst[i] = (byte)((r * a + dst[i] * (255 - a)) / 255);
|
||||||
dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255);
|
dst[i + 1] = (byte)((g * a + dst[i + 1] * (255 - a)) / 255);
|
||||||
dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255);
|
dst[i + 2] = (byte)((b * a + dst[i + 2] * (255 - a)) / 255);
|
||||||
|
|||||||
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 AxisX, double AxisY, double AxisZ,
|
||||||
double AngleDegrees = 0);
|
double AngleDegrees = 0);
|
||||||
|
|
||||||
|
[System.Flags]
|
||||||
|
public enum GfxPresentationReason
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
RetainedMutation = 1,
|
||||||
|
ContinuousChannel = 2,
|
||||||
|
DiscreteSourceCell = 4,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order;
|
/// <summary>Sampled op-0x223 type-0 surface transition. Range A is already present in normal z-order;
|
||||||
/// the compositor draws range B over it with <paramref name="Progress"/> to form the native crossfade.</summary>
|
/// the compositor draws range B over it with <paramref name="Progress"/> to form the native crossfade.</summary>
|
||||||
public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot,
|
public readonly record struct SurfaceTransitionState(long CommandKey, int TargetSlot,
|
||||||
@@ -62,7 +71,8 @@ public readonly record struct RenderObject(long Handle, long SurfaceResId, long
|
|||||||
bool MultiplyTint,
|
bool MultiplyTint,
|
||||||
SurfaceTransitionState? SurfaceTransition = null,
|
SurfaceTransitionState? SurfaceTransition = null,
|
||||||
ColorTransitionState? ColorTransition = null,
|
ColorTransitionState? ColorTransition = null,
|
||||||
Affine2D? RangeTransform = null);
|
Affine2D? RangeTransform = null,
|
||||||
|
bool TimeVarying = false);
|
||||||
|
|
||||||
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
|
/// <summary>Host-agnostic model of the AGE native gfx command-buffer (reversed in
|
||||||
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
|
/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the
|
||||||
@@ -142,6 +152,9 @@ public sealed class GfxState
|
|||||||
// Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and
|
// Populated lazily by the geometry SET ops and draw-texture. Op 0x215 queries this same native map and
|
||||||
// returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
|
// returns the object's live source slot (obj+4), or -1 when the handle has not been drawn/bound yet.
|
||||||
private readonly Dictionary<long, GfxObject> _objects = new();
|
private readonly Dictionary<long, GfxObject> _objects = new();
|
||||||
|
// Native composition is handle-ascending z order. Mutations maintain this small index so snapshots do
|
||||||
|
// not rebuild/sort a dictionary-sized LINQ buffer, while hot handle lookup remains O(1).
|
||||||
|
private readonly List<long> _orderedObjectHandles = new();
|
||||||
private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11];
|
private readonly NumericGlyphStyle[] _numericGlyphStyles = new NumericGlyphStyle[11];
|
||||||
|
|
||||||
// Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled
|
// Ops 0x229-0x22e address one embedded gfx-object record outside the ordinary object map. Its sampled
|
||||||
@@ -164,6 +177,12 @@ public sealed class GfxState
|
|||||||
public long AnimationServiceFlags { get; private set; }
|
public long AnimationServiceFlags { get; private set; }
|
||||||
public uint PreviousFrameTimeMilliseconds { get; private set; }
|
public uint PreviousFrameTimeMilliseconds { get; private set; }
|
||||||
public uint CurrentFrameTimeMilliseconds { get; private set; }
|
public uint CurrentFrameTimeMilliseconds { get; private set; }
|
||||||
|
private long _previousFrameTimeMs;
|
||||||
|
private long _currentFrameTimeMs;
|
||||||
|
private long _mutationGeneration;
|
||||||
|
private long _publishedMutationGeneration;
|
||||||
|
|
||||||
|
private void MarkRetainedMutation() => _mutationGeneration++;
|
||||||
|
|
||||||
/// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary>
|
/// <summary>Live geometry objects and the surface slot they draw from — for the CLI gfx oracle.</summary>
|
||||||
public IEnumerable<(long Handle, int Slot)> Objects
|
public IEnumerable<(long Handle, int Slot)> Objects
|
||||||
@@ -178,8 +197,14 @@ public sealed class GfxState
|
|||||||
// (Monitor) so the callers that already hold it are fine.
|
// (Monitor) so the callers that already hold it are fine.
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (!_objects.TryGetValue(handle, out var o)) { o = new GfxObject(); _objects[handle] = o; }
|
if (!_objects.TryGetValue(handle, out var o))
|
||||||
|
{
|
||||||
|
o = new GfxObject();
|
||||||
|
_objects[handle] = o;
|
||||||
|
InsertOrderedHandle(handle);
|
||||||
|
}
|
||||||
CurrentObject = handle;
|
CurrentObject = handle;
|
||||||
|
MarkRetainedMutation();
|
||||||
return o;
|
return o;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,6 +214,31 @@ public sealed class GfxState
|
|||||||
lock (_lock) DefaultObjectSlot = slot;
|
lock (_lock) DefaultObjectSlot = slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetObjectAnchor(long handle, (long X, long Y, long Z) anchor)
|
||||||
|
{
|
||||||
|
lock (_lock) GetOrCreate(handle).V18 = anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetObjectPosition(long handle, (long X, long Y, long Z) position)
|
||||||
|
{
|
||||||
|
lock (_lock) GetOrCreate(handle).V24 = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetObjectField64(long handle, long value)
|
||||||
|
{
|
||||||
|
lock (_lock) GetOrCreate(handle).Field64 = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetObjectFields68And6c(long handle, long value68, long value6c)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var o = GetOrCreate(handle);
|
||||||
|
o.Field68 = value68;
|
||||||
|
o.Field6c = value6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its
|
/// <summary>Op 0x229: reset the embedded range transform, select [first, first+count), and set its
|
||||||
/// anchor/pivot. This does not create or mutate an ordinary retained object.</summary>
|
/// anchor/pivot. This does not create or mutate an ordinary retained object.</summary>
|
||||||
public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor)
|
public void SetRangeTransform(long first, long count, (long X, long Y, long Z) anchor)
|
||||||
@@ -198,6 +248,7 @@ public sealed class GfxState
|
|||||||
_rangeTransformFirst = first;
|
_rangeTransformFirst = first;
|
||||||
_rangeTransformCount = System.Math.Max(0, count);
|
_rangeTransformCount = System.Math.Max(0, count);
|
||||||
_rangeTransform = new GfxObject { V18 = anchor };
|
_rangeTransform = new GfxObject { V18 = anchor };
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,13 +256,20 @@ public sealed class GfxState
|
|||||||
public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
|
public void SetRangeScaleCurrent((long X, long Y, long Z) percent)
|
||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
|
{
|
||||||
_rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
|
_rangeTransform.ScaleCurrent = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
|
/// <summary>Op 0x22c: immediately replace the embedded range transform's current translation.</summary>
|
||||||
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
|
public void SetRangeTranslationCurrent((long X, long Y, long Z) translation)
|
||||||
{
|
{
|
||||||
lock (_lock) _rangeTransform.TranslationCurrent = translation;
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_rangeTransform.TranslationCurrent = translation;
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
|
/// <summary>Op 0x22d: arm the range transform's delayed one-shot scale target.</summary>
|
||||||
@@ -224,6 +282,7 @@ public sealed class GfxState
|
|||||||
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
|
_rangeTransform.ScaleTarget = (percent.X / 100.0, percent.Y / 100.0, percent.Z / 100.0);
|
||||||
_rangeTransform.ScaleEnabled = durationMs > 0;
|
_rangeTransform.ScaleEnabled = durationMs > 0;
|
||||||
_rangeTransform.OneShotStartMs = -1;
|
_rangeTransform.OneShotStartMs = -1;
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +292,7 @@ public sealed class GfxState
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
|
if (!_objects.TryGetValue(sourceHandle, out var s)) return false;
|
||||||
|
bool destinationIsNew = !_objects.ContainsKey(destinationHandle);
|
||||||
_objects[destinationHandle] = new GfxObject
|
_objects[destinationHandle] = new GfxObject
|
||||||
{
|
{
|
||||||
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
|
V18 = s.V18, V24 = s.V24, V16c = s.V16c,
|
||||||
@@ -258,7 +318,9 @@ public sealed class GfxState
|
|||||||
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
|
RotationPeriodMs = s.RotationPeriodMs, RotationAxis = s.RotationAxis,
|
||||||
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
|
RotationEnabled = s.RotationEnabled, RotationStartMs = s.RotationStartMs,
|
||||||
};
|
};
|
||||||
|
if (destinationIsNew) InsertOrderedHandle(destinationHandle);
|
||||||
CurrentObject = destinationHandle;
|
CurrentObject = destinationHandle;
|
||||||
|
MarkRetainedMutation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -280,7 +342,10 @@ public sealed class GfxState
|
|||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
|
if (_objects.TryGetValue(handle, out var obj) && obj.SourceSlot == fromSlot)
|
||||||
|
{
|
||||||
obj.SourceSlot = toSlot;
|
obj.SourceSlot = toSlot;
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
|
public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0;
|
||||||
|
|
||||||
@@ -288,7 +353,12 @@ public sealed class GfxState
|
|||||||
{
|
{
|
||||||
lock (_lock) // re-entrant: EraseRange already holds _lock
|
lock (_lock) // re-entrant: EraseRange already holds _lock
|
||||||
{
|
{
|
||||||
_objects.Remove(handle);
|
if (_objects.Remove(handle))
|
||||||
|
{
|
||||||
|
int index = _orderedObjectHandles.BinarySearch(handle);
|
||||||
|
if (index >= 0) _orderedObjectHandles.RemoveAt(index);
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +382,9 @@ public sealed class GfxState
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_objects.Clear();
|
_objects.Clear();
|
||||||
|
_orderedObjectHandles.Clear();
|
||||||
CurrentObject = 0;
|
CurrentObject = 0;
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,6 +396,7 @@ public sealed class GfxState
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_objects.Clear();
|
_objects.Clear();
|
||||||
|
_orderedObjectHandles.Clear();
|
||||||
_fieldTable.Clear();
|
_fieldTable.Clear();
|
||||||
_surfaces.Clear();
|
_surfaces.Clear();
|
||||||
_createdSurfaces.Clear();
|
_createdSurfaces.Clear();
|
||||||
@@ -339,6 +412,9 @@ public sealed class GfxState
|
|||||||
AnimationServiceFlags = 0;
|
AnimationServiceFlags = 0;
|
||||||
PreviousFrameTimeMilliseconds = 0;
|
PreviousFrameTimeMilliseconds = 0;
|
||||||
CurrentFrameTimeMilliseconds = 0;
|
CurrentFrameTimeMilliseconds = 0;
|
||||||
|
_previousFrameTimeMs = 0;
|
||||||
|
_currentFrameTimeMs = 0;
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,6 +436,7 @@ public sealed class GfxState
|
|||||||
_surfaces[slot] = (resId, colorKey);
|
_surfaces[slot] = (resId, colorKey);
|
||||||
_createdSurfaces.Remove(slot);
|
_createdSurfaces.Remove(slot);
|
||||||
_movieStopTimesMs.Remove(slot);
|
_movieStopTimesMs.Remove(slot);
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +476,7 @@ public sealed class GfxState
|
|||||||
}
|
}
|
||||||
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
|
if (CurrentRenderTargetSlot >= firstSlot && CurrentRenderTargetSlot < end)
|
||||||
CurrentRenderTargetSlot = -1;
|
CurrentRenderTargetSlot = -1;
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,6 +576,7 @@ public sealed class GfxState
|
|||||||
_surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key
|
_surfaces[slot] = (0, -1); // create-texture: real mutable pixels, no asset id or color key
|
||||||
_createdSurfaces.Add(slot);
|
_createdSurfaces.Add(slot);
|
||||||
_movieStopTimesMs.Remove(slot);
|
_movieStopTimesMs.Remove(slot);
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,6 +588,7 @@ public sealed class GfxState
|
|||||||
_createdSurfaces.Remove(slot);
|
_createdSurfaces.Remove(slot);
|
||||||
_movieStopTimesMs.Remove(slot);
|
_movieStopTimesMs.Remove(slot);
|
||||||
_surfaceTransitions.Remove(slot);
|
_surfaceTransitions.Remove(slot);
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +606,7 @@ public sealed class GfxState
|
|||||||
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
|
RangeBStart = rangeBStart, RangeBCount = System.Math.Max(0, rangeBCount),
|
||||||
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
|
DelayMs = System.Math.Max(0, delayMs), DurationMs = System.Math.Max(0, durationMs),
|
||||||
};
|
};
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,6 +765,7 @@ public sealed class GfxState
|
|||||||
int completed = 0;
|
int completed = 0;
|
||||||
foreach (var t in _surfaceTransitions.Values)
|
foreach (var t in _surfaceTransitions.Values)
|
||||||
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
|
if (!t.Forced && TransitionProgress(t, nowMs) < 1.0) { t.Forced = true; completed++; }
|
||||||
|
if (completed > 0) MarkRetainedMutation();
|
||||||
return completed;
|
return completed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -883,7 +965,12 @@ public sealed class GfxState
|
|||||||
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
|
/// <summary>Op 0x238: set its separate global animation-service duration and reset marker.</summary>
|
||||||
public void SetAnimClock(long durationTicks)
|
public void SetAnimClock(long durationTicks)
|
||||||
{
|
{
|
||||||
lock (_lock) { AnimClockDurationTicks = durationTicks; AnimClockGeneration++; }
|
lock (_lock)
|
||||||
|
{
|
||||||
|
AnimClockDurationTicks = durationTicks;
|
||||||
|
AnimClockGeneration++;
|
||||||
|
MarkRetainedMutation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetAnimationServiceFlags(long flags)
|
public void SetAnimationServiceFlags(long flags)
|
||||||
@@ -895,6 +982,8 @@ public sealed class GfxState
|
|||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
|
_previousFrameTimeMs = _currentFrameTimeMs;
|
||||||
|
_currentFrameTimeMs = nowMilliseconds;
|
||||||
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
|
PreviousFrameTimeMilliseconds = CurrentFrameTimeMilliseconds;
|
||||||
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
|
CurrentFrameTimeMilliseconds = unchecked((uint)nowMilliseconds);
|
||||||
}
|
}
|
||||||
@@ -910,9 +999,65 @@ public sealed class GfxState
|
|||||||
ForceCompleteOneShotChannels();
|
ForceCompleteOneShotChannels();
|
||||||
AnimClockDurationTicks = 0;
|
AnimClockDurationTicks = 0;
|
||||||
AnimClockGeneration++;
|
AnimClockGeneration++;
|
||||||
|
MarkRetainedMutation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sample the shared native frame clock and report why the retained scene needs publishing.
|
||||||
|
/// Continuous channels remain frame-driven; op-0x231 spritesheets become dirty only when the shared
|
||||||
|
/// previous/current samples select different cells. Retained VM writes are published exactly once.</summary>
|
||||||
|
public GfxPresentationReason ConsumePresentationReasons(long nowMs)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_previousFrameTimeMs = _currentFrameTimeMs;
|
||||||
|
_currentFrameTimeMs = nowMs;
|
||||||
|
PreviousFrameTimeMilliseconds = unchecked((uint)_previousFrameTimeMs);
|
||||||
|
CurrentFrameTimeMilliseconds = unchecked((uint)_currentFrameTimeMs);
|
||||||
|
|
||||||
|
GfxPresentationReason reasons = GfxPresentationReason.None;
|
||||||
|
if (_publishedMutationGeneration != _mutationGeneration)
|
||||||
|
{
|
||||||
|
_publishedMutationGeneration = _mutationGeneration;
|
||||||
|
reasons |= GfxPresentationReason.RetainedMutation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_surfaceTransitions.Values.Any(t => TransitionProgress(t, nowMs) < 1.0) ||
|
||||||
|
_rangeTransform.ScaleEnabled || _rangeTransform.RotationChannelEnabled ||
|
||||||
|
_rangeTransform.TranslationEnabled ||
|
||||||
|
_objects.Values.Any(o => o.Visible &&
|
||||||
|
(o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
||||||
|
o.TranslationEnabled ||
|
||||||
|
(o.ColorAnim && o.ColorPeriod > 0) ||
|
||||||
|
(o.RotationEnabled && o.RotationPeriodMs > 0))))
|
||||||
|
reasons |= GfxPresentationReason.ContinuousChannel;
|
||||||
|
|
||||||
|
foreach (var o in _objects.Values)
|
||||||
|
{
|
||||||
|
if (!o.Visible || !o.SrcAnim || o.SrcPeriod <= 0 || o.SrcFrameCount < 1) continue;
|
||||||
|
if (o.SrcStart < 0)
|
||||||
|
{
|
||||||
|
// Prototype clones made before first publication all enter here in the same shared sample,
|
||||||
|
// reproducing FIELD's native phase lock.
|
||||||
|
o.SrcStart = nowMs;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (SourceCellAt(o, _previousFrameTimeMs) != SourceCellAt(o, _currentFrameTimeMs))
|
||||||
|
{
|
||||||
|
reasons |= GfxPresentationReason.DiscreteSourceCell;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reasons;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long SourceCellAt(GfxObject o, long nowMs)
|
||||||
|
{
|
||||||
|
long elapsed = System.Math.Max(0, nowMs - o.SrcStart);
|
||||||
|
return elapsed / o.SrcPeriod % o.SrcFrameCount;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
/// <summary>Back-compat: snapshot with no animation clock (nowMs = 0) — deterministic, for headless
|
||||||
/// callers and existing tests.</summary>
|
/// callers and existing tests.</summary>
|
||||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);
|
||||||
@@ -925,10 +1070,22 @@ public sealed class GfxState
|
|||||||
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
|
/// alpha/tint. Channel Start fields seed to nowMs on first sight.</summary>
|
||||||
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
|
public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
|
||||||
{
|
{
|
||||||
|
var list = new List<RenderObject>();
|
||||||
|
SnapshotVisibleObjects(nowMs, list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fill a caller-owned snapshot buffer. The Godot compositor reuses one list so its backing
|
||||||
|
/// array survives across frames; callers that need an independently retained snapshot should use the
|
||||||
|
/// returning overload.</summary>
|
||||||
|
public void SnapshotVisibleObjects(long nowMs, List<RenderObject> list)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(list);
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var list = new List<RenderObject>();
|
list.Clear();
|
||||||
Affine2D? rangeAffine = null;
|
Affine2D? rangeAffine = null;
|
||||||
|
bool rangeTimeVarying = false;
|
||||||
if (_rangeTransformCount > 0)
|
if (_rangeTransformCount > 0)
|
||||||
{
|
{
|
||||||
var r = _rangeTransform;
|
var r = _rangeTransform;
|
||||||
@@ -944,15 +1101,16 @@ public sealed class GfxState
|
|||||||
ref r.TranslationEnabled, nowMs);
|
ref r.TranslationEnabled, nowMs);
|
||||||
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
|
if (!r.ScaleEnabled && !r.RotationChannelEnabled && !r.TranslationEnabled)
|
||||||
r.OneShotStartMs = -1;
|
r.OneShotStartMs = -1;
|
||||||
|
rangeTimeVarying = r.ScaleEnabled || r.RotationChannelEnabled || r.TranslationEnabled;
|
||||||
rangeAffine = Transform2DMath.Build(new TransformState(
|
rangeAffine = Transform2DMath.Build(new TransformState(
|
||||||
rangeScale.X, rangeScale.Y, rangeScale.Z,
|
rangeScale.X, rangeScale.Y, rangeScale.Z,
|
||||||
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
|
rangeTranslation.X, rangeTranslation.Y, rangeTranslation.Z,
|
||||||
r.V18.X, r.V18.Y, r.V18.Z,
|
r.V18.X, r.V18.Y, r.V18.Z,
|
||||||
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
|
rangeRotation.X, rangeRotation.Y, rangeRotation.Z, rangeRotation.Angle));
|
||||||
}
|
}
|
||||||
foreach (var kv in _objects.OrderBy(k => k.Key))
|
foreach (long handle in _orderedObjectHandles)
|
||||||
{
|
{
|
||||||
var o = kv.Value;
|
var o = _objects[handle];
|
||||||
if (!o.Visible) continue;
|
if (!o.Visible) continue;
|
||||||
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
|
bool hadOneShot = o.OneShotColorEnabled || o.ScaleEnabled ||
|
||||||
o.RotationChannelEnabled || o.TranslationEnabled;
|
o.RotationChannelEnabled || o.TranslationEnabled;
|
||||||
@@ -1064,9 +1222,17 @@ public sealed class GfxState
|
|||||||
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
|
SurfaceTransitionState? transition = _surfaceTransitions.TryGetValue(o.SourceSlot, out var st)
|
||||||
? SampleTransition(st, nowMs) : null;
|
? SampleTransition(st, nowMs) : null;
|
||||||
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
|
Affine2D? objectRangeTransform = rangeAffine is { } ra &&
|
||||||
kv.Key >= _rangeTransformFirst && kv.Key - _rangeTransformFirst < _rangeTransformCount
|
handle >= _rangeTransformFirst && handle - _rangeTransformFirst < _rangeTransformCount
|
||||||
? ra : null;
|
? ra : null;
|
||||||
list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h,
|
bool timeVarying =
|
||||||
|
o.OneShotColorEnabled || o.ScaleEnabled || o.RotationChannelEnabled ||
|
||||||
|
o.TranslationEnabled ||
|
||||||
|
(o.SrcAnim && o.SrcPeriod > 0) ||
|
||||||
|
(o.ColorAnim && o.ColorPeriod > 0) ||
|
||||||
|
(o.RotationEnabled && o.RotationPeriodMs > 0) ||
|
||||||
|
transition is { Progress: < 1.0 } ||
|
||||||
|
(objectRangeTransform != null && rangeTimeVarying);
|
||||||
|
list.Add(new RenderObject(handle, resId, ck, srcX, srcY, w, h,
|
||||||
(int)o.V24.X, (int)o.V24.Y,
|
(int)o.V24.X, (int)o.V24.Y,
|
||||||
new TransformState(scale.X, scale.Y, scale.Z,
|
new TransformState(scale.X, scale.Y, scale.Z,
|
||||||
translation.X, translation.Y, translation.Z,
|
translation.X, translation.Y, translation.Z,
|
||||||
@@ -1076,12 +1242,17 @@ public sealed class GfxState
|
|||||||
o.RotationAxis.X, o.RotationAxis.Y,
|
o.RotationAxis.X, o.RotationAxis.Y,
|
||||||
o.RotationAxis.Z, cycleAngle),
|
o.RotationAxis.Z, cycleAngle),
|
||||||
alpha, tint, strength, blend, multiplyTint, transition,
|
alpha, tint, strength, blend, multiplyTint, transition,
|
||||||
colorTransition, objectRangeTransform));
|
colorTransition, objectRangeTransform, timeVarying));
|
||||||
}
|
}
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void InsertOrderedHandle(long handle)
|
||||||
|
{
|
||||||
|
int index = _orderedObjectHandles.BinarySearch(handle);
|
||||||
|
if (index < 0) _orderedObjectHandles.Insert(~index, handle);
|
||||||
|
}
|
||||||
|
|
||||||
private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs)
|
private static (long Packed, ColorTransitionState State) SampleOneShotColor(GfxObject o, long nowMs)
|
||||||
{
|
{
|
||||||
long current = o.Color & 0xffffffff;
|
long current = o.Color & 0xffffffff;
|
||||||
|
|||||||
@@ -13,14 +13,23 @@ public static class SoftwareAffineRasterizer
|
|||||||
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
|
int ia = (int)(System.Math.Clamp(opacity, 0f, 1f) * 255);
|
||||||
if (ia == 0) return;
|
if (ia == 0) return;
|
||||||
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
|
int tr=(int)(tint>>16&255), tg=(int)(tint>>8&255), tb=(int)(tint&255);
|
||||||
|
bool unmodulatedSourceOver = ia == 255 && istr == 0 && !multiplyTint && blend != BlendKind.Additive;
|
||||||
if (TryIntegerTranslation(localToDest, out int tx, out int ty))
|
if (TryIntegerTranslation(localToDest, out int tx, out int ty))
|
||||||
{
|
{
|
||||||
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
|
BlitTranslated(dst, dstW, dstH, src, srcW, srcX, srcY, width, height,
|
||||||
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend);
|
tx, ty, tr, tg, tb, istr, ia, multiplyTint, blend, unmodulatedSourceOver);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!localToDest.TryInverse(out var inv)) return;
|
if (!localToDest.TryInverse(out var inv)) return;
|
||||||
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
|
Bounds(localToDest, width, height, dstW, dstH, out int x0, out int y0, out int x1, out int y1);
|
||||||
|
if (x1 <= x0 || y1 <= y0) return;
|
||||||
|
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
|
||||||
|
{
|
||||||
|
BlitAxisAligned(dst, dstW, src, srcW, srcX, srcY, width, height, inv,
|
||||||
|
x0, y0, x1, y1, tr, tg, tb, istr, ia, multiplyTint, blend,
|
||||||
|
unmodulatedSourceOver);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
|
for (int y=y0; y<y1; y++) for (int x=x0; x<x1; x++)
|
||||||
{
|
{
|
||||||
var p = inv.Apply(x + 0.5, y + 0.5);
|
var p = inv.Apply(x + 0.5, y + 0.5);
|
||||||
@@ -48,6 +57,12 @@ public static class SoftwareAffineRasterizer
|
|||||||
}
|
}
|
||||||
if (!localToDest.TryInverse(out var inv)) return;
|
if (!localToDest.TryInverse(out var inv)) return;
|
||||||
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
|
Bounds(localToDest,width,height,dstW,dstH,out int x0,out int y0,out int x1,out int y1);
|
||||||
|
if (x1 <= x0 || y1 <= y0) return;
|
||||||
|
if (localToDest.XY == 0 && localToDest.YX == 0 && x1 - x0 <= 4096)
|
||||||
|
{
|
||||||
|
FillAxisAligned(dst, dstW, width, height, inv, x0, y0, x1, y1, r, g, b, a);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){
|
for(int y=y0;y<y1;y++)for(int x=x0;x<x1;x++){
|
||||||
var p=inv.Apply(x+.5,y+.5);
|
var p=inv.Apply(x+.5,y+.5);
|
||||||
if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a);
|
if(p.X>=0&&p.X<width&&p.Y>=0&&p.Y<height) Blend(dst,(y*dstW+x)*4,r,g,b,a);
|
||||||
@@ -69,11 +84,16 @@ public static class SoftwareAffineRasterizer
|
|||||||
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
|
private static void BlitTranslated(byte[] dst, int dstW, int dstH, byte[] src, int srcW,
|
||||||
int srcX, int srcY, int width, int height, int tx, int ty,
|
int srcX, int srcY, int width, int height, int tx, int ty,
|
||||||
int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
|
int tr, int tg, int tb, int istr, int ia, bool multiplyTint,
|
||||||
BlendKind blend)
|
BlendKind blend, bool unmodulatedSourceOver)
|
||||||
{
|
{
|
||||||
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
|
int x0 = System.Math.Max(0, tx), y0 = System.Math.Max(0, ty);
|
||||||
int x1 = (int)System.Math.Min(dstW, (long)tx + width);
|
int x1 = (int)System.Math.Min(dstW, (long)tx + width);
|
||||||
int y1 = (int)System.Math.Min(dstH, (long)ty + height);
|
int y1 = (int)System.Math.Min(dstH, (long)ty + height);
|
||||||
|
if (unmodulatedSourceOver)
|
||||||
|
{
|
||||||
|
BlitTranslatedUnmodulated(dst, dstW, src, srcW, srcX, srcY, tx, ty, x0, y0, x1, y1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (int y = y0; y < y1; y++)
|
for (int y = y0; y < y1; y++)
|
||||||
{
|
{
|
||||||
int v = y - ty;
|
int v = y - ty;
|
||||||
@@ -91,6 +111,20 @@ public static class SoftwareAffineRasterizer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void BlitTranslatedUnmodulated(byte[] dst, int dstW, byte[] src, int srcW,
|
||||||
|
int srcX, int srcY, int tx, int ty,
|
||||||
|
int x0, int y0, int x1, int y1)
|
||||||
|
{
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
{
|
||||||
|
int sourceRow = (srcY + y - ty) * srcW + srcX - tx;
|
||||||
|
int destinationRow = y * dstW;
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
BlendUnmodulatedSourceOver(dst, (destinationRow + x) * 4,
|
||||||
|
src, (sourceRow + x) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void FillTranslated(byte[] dst, int dstW, int dstH, int width, int height,
|
private static void FillTranslated(byte[] dst, int dstW, int dstH, int width, int height,
|
||||||
int tx, int ty, int r, int g, int b, int a)
|
int tx, int ty, int r, int g, int b, int a)
|
||||||
{
|
{
|
||||||
@@ -102,6 +136,102 @@ public static class SoftwareAffineRasterizer
|
|||||||
Blend(dst, (y * dstW + x) * 4, r, g, b, a);
|
Blend(dst, (y * dstW + x) * 4, r, g, b, a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Axis-aligned scale is FIELD's dominant non-integer path. Its inverse source column is independent
|
||||||
|
// of destination Y, and its source row is independent of destination X. Cache the former once per
|
||||||
|
// layer and compute the latter once per scanline while preserving the general path's exact center-
|
||||||
|
// sample/floor arithmetic. Pooled lookup storage avoids both per-layer garbage and repeated-stackalloc
|
||||||
|
// growth when the JIT inlines this hot path; the 4096-column caller gate keeps the rented bucket small.
|
||||||
|
private static void BlitAxisAligned(byte[] dst, int dstW, byte[] src, int srcW,
|
||||||
|
int srcX, int srcY, int width, int height, Affine2D inv,
|
||||||
|
int x0, int y0, int x1, int y1,
|
||||||
|
int tr, int tg, int tb, int istr, int ia,
|
||||||
|
bool multiplyTint, BlendKind blend, bool unmodulatedSourceOver)
|
||||||
|
{
|
||||||
|
int count = x1 - x0;
|
||||||
|
int[] sourceColumns = System.Buffers.ArrayPool<int>.Shared.Rent(count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
{
|
||||||
|
int u = (int)System.Math.Floor(inv.Apply(x + 0.5, y0 + 0.5).X);
|
||||||
|
sourceColumns[x - x0] = (uint)u < (uint)width ? u : -1;
|
||||||
|
}
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
{
|
||||||
|
int v = (int)System.Math.Floor(inv.Apply(x0 + 0.5, y + 0.5).Y);
|
||||||
|
if ((uint)v >= (uint)height) continue;
|
||||||
|
int sourceRow = (srcY + v) * srcW;
|
||||||
|
int destinationRow = y * dstW;
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
{
|
||||||
|
int u = sourceColumns[x - x0];
|
||||||
|
if (u < 0) continue;
|
||||||
|
int si = (sourceRow + srcX + u) * 4;
|
||||||
|
int di = (destinationRow + x) * 4;
|
||||||
|
if (unmodulatedSourceOver)
|
||||||
|
{
|
||||||
|
BlendUnmodulatedSourceOver(dst, di, src, si);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int sa = src[si + 3] * ia / 255;
|
||||||
|
if (sa == 0) continue;
|
||||||
|
int sr = multiplyTint ? src[si] * tr / 255 : (src[si] * (255 - istr) + tr * istr) / 255;
|
||||||
|
int sg = multiplyTint ? src[si + 1] * tg / 255 : (src[si + 1] * (255 - istr) + tg * istr) / 255;
|
||||||
|
int sb = multiplyTint ? src[si + 2] * tb / 255 : (src[si + 2] * (255 - istr) + tb * istr) / 255;
|
||||||
|
Blend(dst, di, sr, sg, sb, sa, blend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { System.Buffers.ArrayPool<int>.Shared.Return(sourceColumns); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most FIELD layers have full object opacity and no tint. Preserve color-key transparency and partially
|
||||||
|
// transparent edge texels, but make the overwhelmingly common alpha-255 case a four-byte copy instead of
|
||||||
|
// performing tint and source-over multiply/divide work whose result is exactly the source texel.
|
||||||
|
private static void BlendUnmodulatedSourceOver(byte[] dst, int di, byte[] src, int si)
|
||||||
|
{
|
||||||
|
int a = src[si + 3];
|
||||||
|
if (a == 0) return;
|
||||||
|
if (a == 255)
|
||||||
|
{
|
||||||
|
dst[di] = src[si];
|
||||||
|
dst[di + 1] = src[si + 1];
|
||||||
|
dst[di + 2] = src[si + 2];
|
||||||
|
dst[di + 3] = 255;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int inverse = 255 - a;
|
||||||
|
dst[di] = (byte)((src[si] * a + dst[di] * inverse) / 255);
|
||||||
|
dst[di + 1] = (byte)((src[si + 1] * a + dst[di + 1] * inverse) / 255);
|
||||||
|
dst[di + 2] = (byte)((src[si + 2] * a + dst[di + 2] * inverse) / 255);
|
||||||
|
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + a);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void FillAxisAligned(byte[] dst, int dstW, int width, int height, Affine2D inv,
|
||||||
|
int x0, int y0, int x1, int y1,
|
||||||
|
int r, int g, int b, int a)
|
||||||
|
{
|
||||||
|
int count = x1 - x0;
|
||||||
|
bool[] includedColumns = System.Buffers.ArrayPool<bool>.Shared.Rent(count);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
{
|
||||||
|
double u = inv.Apply(x + 0.5, y0 + 0.5).X;
|
||||||
|
includedColumns[x - x0] = u >= 0 && u < width;
|
||||||
|
}
|
||||||
|
for (int y = y0; y < y1; y++)
|
||||||
|
{
|
||||||
|
double v = inv.Apply(x0 + 0.5, y + 0.5).Y;
|
||||||
|
if (v < 0 || v >= height) continue;
|
||||||
|
int destinationRow = y * dstW;
|
||||||
|
for (int x = x0; x < x1; x++)
|
||||||
|
if (includedColumns[x - x0]) Blend(dst, (destinationRow + x) * 4, r, g, b, a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { System.Buffers.ArrayPool<bool>.Shared.Return(includedColumns); }
|
||||||
|
}
|
||||||
|
|
||||||
private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1)
|
private static void Bounds(Affine2D m,int w,int h,int dw,int dh,out int x0,out int y0,out int x1,out int y1)
|
||||||
{
|
{
|
||||||
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);
|
var a=m.Apply(0,0);var b=m.Apply(w,0);var c=m.Apply(0,h);var d=m.Apply(w,h);
|
||||||
|
|||||||
@@ -40,46 +40,74 @@ public static class Transform2DMath
|
|||||||
{
|
{
|
||||||
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
|
public static Affine2D Build(TransformState t, RotationCycleState cycle = default)
|
||||||
{
|
{
|
||||||
double[] m = Identity();
|
Matrix3D m = Identity();
|
||||||
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
|
m = Mul(m, Translation(-t.AnchorX, -t.AnchorY, -t.AnchorZ));
|
||||||
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
|
m = Mul(m, Scale(t.ScaleX, t.ScaleY, t.ScaleZ));
|
||||||
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
|
m = Mul(m, AxisAngle(t.RotationAxisX, t.RotationAxisY, t.RotationAxisZ, t.RotationAngleDegrees));
|
||||||
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
|
m = Mul(m, Translation(t.TranslateX, t.TranslateY, t.TranslateZ));
|
||||||
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
|
if (cycle.Enabled) m = Mul(m, AxisAngle(cycle.AxisX, cycle.AxisY, cycle.AxisZ, cycle.AngleDegrees));
|
||||||
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
|
m = Mul(m, Translation(t.AnchorX, t.AnchorY, t.AnchorZ));
|
||||||
return new(m[0], m[1], m[4], m[5], m[12], m[13]);
|
return new(m.M11, m.M12, m.M21, m.M22, m.TX, m.TY);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static (double X, double Y) Apply(double x, double y, TransformState transform,
|
public static (double X, double Y) Apply(double x, double y, TransformState transform,
|
||||||
RotationCycleState cycle = default)
|
RotationCycleState cycle = default)
|
||||||
=> Build(transform, cycle).Apply(x, y);
|
=> Build(transform, cycle).Apply(x, y);
|
||||||
|
|
||||||
private static double[] Identity() => new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 };
|
// AGE composes affine 4x4 row-vector matrices, whose last column is always (0,0,0,1). Carry only
|
||||||
private static double[] Scale(double x, double y, double z)
|
// the 3x3 linear part and translation row as a value type: the old double[16] implementation allocated
|
||||||
=> new double[] { x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1 };
|
// about eleven arrays per rendered object, or roughly 2.2 MB on every DEBUGMAP composition.
|
||||||
private static double[] Translation(double x, double y, double z)
|
private readonly record struct Matrix3D(
|
||||||
=> new double[] { 1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1 };
|
double M11, double M12, double M13,
|
||||||
|
double M21, double M22, double M23,
|
||||||
|
double M31, double M32, double M33,
|
||||||
|
double TX, double TY, double TZ);
|
||||||
|
|
||||||
private static double[] AxisAngle(double x, double y, double z, double degrees)
|
private static Matrix3D Identity() => new(
|
||||||
|
1,0,0, 0,1,0, 0,0,1, 0,0,0);
|
||||||
|
|
||||||
|
private static Matrix3D Scale(double x, double y, double z) => new(
|
||||||
|
x,0,0, 0,y,0, 0,0,z, 0,0,0);
|
||||||
|
|
||||||
|
private static Matrix3D Translation(double x, double y, double z) => new(
|
||||||
|
1,0,0, 0,1,0, 0,0,1, x,y,z);
|
||||||
|
|
||||||
|
private static Matrix3D AxisAngle(double x, double y, double z, double degrees)
|
||||||
{
|
{
|
||||||
double len = System.Math.Sqrt(x*x + y*y + z*z);
|
double len = System.Math.Sqrt(x*x + y*y + z*z);
|
||||||
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
|
if (len < 1e-12 || System.Math.Abs(degrees) < 1e-12) return Identity();
|
||||||
x /= len; y /= len; z /= len;
|
x /= len; y /= len; z /= len;
|
||||||
double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c;
|
double r = degrees * System.Math.PI / 180.0, c = System.Math.Cos(r), s = System.Math.Sin(r), q = 1-c;
|
||||||
return new double[] {
|
return new(
|
||||||
x*x*q+c, x*y*q+z*s, x*z*q-y*s, 0,
|
x*x*q+c, x*y*q+z*s, x*z*q-y*s,
|
||||||
x*y*q-z*s, y*y*q+c, y*z*q+x*s, 0,
|
x*y*q-z*s, y*y*q+c, y*z*q+x*s,
|
||||||
x*z*q+y*s, y*z*q-x*s, z*z*q+c, 0,
|
x*z*q+y*s, y*z*q-x*s, z*z*q+c,
|
||||||
0,0,0,1
|
0,0,0);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double[] Mul(double[] a, double[] b)
|
private static Matrix3D Mul(Matrix3D a, Matrix3D b) => new(
|
||||||
|
Sum4(a.M11*b.M11, a.M12*b.M21, a.M13*b.M31, 0),
|
||||||
|
Sum4(a.M11*b.M12, a.M12*b.M22, a.M13*b.M32, 0),
|
||||||
|
Sum4(a.M11*b.M13, a.M12*b.M23, a.M13*b.M33, 0),
|
||||||
|
Sum4(a.M21*b.M11, a.M22*b.M21, a.M23*b.M31, 0),
|
||||||
|
Sum4(a.M21*b.M12, a.M22*b.M22, a.M23*b.M32, 0),
|
||||||
|
Sum4(a.M21*b.M13, a.M22*b.M23, a.M23*b.M33, 0),
|
||||||
|
Sum4(a.M31*b.M11, a.M32*b.M21, a.M33*b.M31, 0),
|
||||||
|
Sum4(a.M31*b.M12, a.M32*b.M22, a.M33*b.M32, 0),
|
||||||
|
Sum4(a.M31*b.M13, a.M32*b.M23, a.M33*b.M33, 0),
|
||||||
|
Sum4(a.TX*b.M11, a.TY*b.M21, a.TZ*b.M31, b.TX),
|
||||||
|
Sum4(a.TX*b.M12, a.TY*b.M22, a.TZ*b.M32, b.TY),
|
||||||
|
Sum4(a.TX*b.M13, a.TY*b.M23, a.TZ*b.M33, b.TZ));
|
||||||
|
|
||||||
|
// Accumulate in the same order as the former 4x4 loop so boundary-sensitive nearest-neighbour
|
||||||
|
// projection retains its floating-point behavior while avoiding an intermediate array.
|
||||||
|
private static double Sum4(double a, double b, double c, double d)
|
||||||
{
|
{
|
||||||
var o = new double[16];
|
double result = 0;
|
||||||
for (int row=0; row<4; row++)
|
result += a;
|
||||||
for (int col=0; col<4; col++)
|
result += b;
|
||||||
for (int k=0; k<4; k++) o[row*4+col] += a[row*4+k] * b[k*4+col];
|
result += c;
|
||||||
return o;
|
result += d;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1712,16 +1712,16 @@ public sealed class VirtualMachine
|
|||||||
Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1;
|
Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1;
|
||||||
}
|
}
|
||||||
case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18
|
case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18
|
||||||
Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
|
Gfx.SetObjectAnchor(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
|
||||||
case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24
|
case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24
|
||||||
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1;
|
Gfx.SetObjectPosition(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
|
||||||
case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9
|
case "u0041AF00": // 0x80: default object slot substituted by native op 0x1d9
|
||||||
case "set-default-gfx-object-slot":
|
case "set-default-gfx-object-slot":
|
||||||
Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1;
|
Gfx.SetDefaultObjectSlot((int)Read(a[0])); return pc + 1;
|
||||||
|
|
||||||
// ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ----
|
// ---- SC0000 anim/transform/spritesheet cluster (docs/engine-re.md §"SC0000 anim ... cluster") ----
|
||||||
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
|
case "u00421DD0": // 0x22f set-position: (handle)(op2)(x)(y)(z) -> base position (direct set)
|
||||||
Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[2]), Read(a[3]), Read(a[4])); return pc + 1;
|
Gfx.SetObjectPosition(Read(a[0]), (Read(a[2]), Read(a[3]), Read(a[4]))); return pc + 1;
|
||||||
case "u004219E0": // pre-reference compatibility
|
case "u004219E0": // pre-reference compatibility
|
||||||
case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z)
|
case "set-gfx-range-transform": // 0x229 (first)(count)(anchor x/y/z)
|
||||||
Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
|
Gfx.SetRangeTransform(Read(a[0]), Read(a[1]), (Read(a[2]), Read(a[3]), Read(a[4])));
|
||||||
@@ -1782,10 +1782,10 @@ public sealed class VirtualMachine
|
|||||||
case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix
|
case "gfx-set-scale-current": // 0x1fd (handle)(sx%)(sy%)(sz%) -> current scale matrix
|
||||||
Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
|
Gfx.SetCurrentScale(Read(a[0]), (Read(a[1]), Read(a[2]), Read(a[3]))); return pc + 1;
|
||||||
case "set-gfx-field64": // 0x212 (idx)(val)
|
case "set-gfx-field64": // 0x212 (idx)(val)
|
||||||
Gfx.GetOrCreate(Read(a[0])).Field64 = Read(a[1]); return pc + 1;
|
Gfx.SetObjectField64(Read(a[0]), Read(a[1])); return pc + 1;
|
||||||
case "set-gfx-xy": // 0x213 (idx)(x)(y)
|
case "set-gfx-xy": // 0x213 (idx)(x)(y)
|
||||||
{
|
{
|
||||||
var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1;
|
Gfx.SetObjectFields68And6c(Read(a[0]), Read(a[1]), Read(a[2])); return pc + 1;
|
||||||
}
|
}
|
||||||
case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range
|
case "gfx-elem-erase": // 0x1f7 (handle)(count) — erase retained-object range
|
||||||
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;
|
Gfx.EraseRange(Read(a[0]), Read(a[1])); return pc + 1;
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ using Age.Engine.Hosting;
|
|||||||
using Age.Engine.Model;
|
using Age.Engine.Model;
|
||||||
using Age.Engine.Sys4;
|
using Age.Engine.Sys4;
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
public enum HostPresentationReason
|
||||||
|
{
|
||||||
|
None = 0,
|
||||||
|
HostRequest = 1,
|
||||||
|
ScreenTransition = 2,
|
||||||
|
RetainedMutation = 4,
|
||||||
|
ContinuousChannel = 8,
|
||||||
|
DiscreteSourceCell = 16,
|
||||||
|
}
|
||||||
|
|
||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
public sealed class GodotAdvHost : IHost
|
public sealed class GodotAdvHost : IHost
|
||||||
{
|
{
|
||||||
@@ -185,6 +196,16 @@ public sealed class GodotAdvHost : IHost
|
|||||||
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
|
return _surfaceText.TryGetValue(surfaceSlot, out var draws) ? draws.ToArray() : Array.Empty<SurfaceTextDraw>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SnapshotSurfaceText(int surfaceSlot, List<SurfaceTextDraw> snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
lock (_textLock)
|
||||||
|
{
|
||||||
|
snapshot.Clear();
|
||||||
|
if (_surfaceText.TryGetValue(surfaceSlot, out var draws)) snapshot.AddRange(draws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void ClearRenderedAdvTextLayout(int layoutSlot)
|
public void ClearRenderedAdvTextLayout(int layoutSlot)
|
||||||
{
|
{
|
||||||
lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot);
|
lock (_textLock) _historyText.Remove(layoutSlot == 0 ? _currentAdvLayout : layoutSlot);
|
||||||
@@ -481,7 +502,12 @@ public sealed class GodotAdvHost : IHost
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void InputCallbackCompleted(GfxState gfx)
|
public void InputCallbackCompleted(GfxState gfx)
|
||||||
=> Interlocked.Exchange(ref _presentRequested, 1);
|
{
|
||||||
|
// Callback completion itself is not native graphics dirtiness. Any retained writes made by the
|
||||||
|
// callback are published through GfxState's mutation generation; host-owned surface writes set
|
||||||
|
// _presentRequested at their actual mutation sites. FIELD services a 50 ms hover callback even
|
||||||
|
// while the pointer is idle, so an unconditional request here recreates its sleep-poll overdraw.
|
||||||
|
}
|
||||||
|
|
||||||
public long InputClockMilliseconds => _clock.NowMs;
|
public long InputClockMilliseconds => _clock.NowMs;
|
||||||
|
|
||||||
@@ -639,12 +665,22 @@ public sealed class GodotAdvHost : IHost
|
|||||||
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
|
// Native retained-object writes are not front-buffer writes. Publish explicit/service-boundary dirtiness
|
||||||
// once, then continue only while the sampled retained scene can actually change. Text reveal is a separate
|
// once, then continue only while the sampled retained scene can actually change. Text reveal is a separate
|
||||||
// Godot Label; waiting/sleeping alone do not alter background pixels.
|
// Godot Label; waiting/sleeping alone do not alter background pixels.
|
||||||
public bool ShouldRecomposite(GfxState gfx)
|
public HostPresentationReason ConsumePresentationReasons(GfxState gfx)
|
||||||
{
|
{
|
||||||
bool screenTransitionActive;
|
bool screenTransitionActive;
|
||||||
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
|
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
|
||||||
return System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0 ||
|
var reasons = HostPresentationReason.None;
|
||||||
screenTransitionActive || gfx.HasActiveVisualPresentation(_clock.NowMs);
|
if (System.Threading.Interlocked.Exchange(ref _presentRequested, 0) != 0)
|
||||||
|
reasons |= HostPresentationReason.HostRequest;
|
||||||
|
if (screenTransitionActive) reasons |= HostPresentationReason.ScreenTransition;
|
||||||
|
GfxPresentationReason gfxReasons = gfx.ConsumePresentationReasons(_clock.NowMs);
|
||||||
|
if ((gfxReasons & GfxPresentationReason.RetainedMutation) != 0)
|
||||||
|
reasons |= HostPresentationReason.RetainedMutation;
|
||||||
|
if ((gfxReasons & GfxPresentationReason.ContinuousChannel) != 0)
|
||||||
|
reasons |= HostPresentationReason.ContinuousChannel;
|
||||||
|
if ((gfxReasons & GfxPresentationReason.DiscreteSourceCell) != 0)
|
||||||
|
reasons |= HostPresentationReason.DiscreteSourceCell;
|
||||||
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Stop()
|
public void Stop()
|
||||||
@@ -760,9 +796,6 @@ public sealed class GodotAdvHost : IHost
|
|||||||
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
|
long ms = NormalizeSleepMilliseconds(duration, SleepScale);
|
||||||
long deadline = _clock.NowMs + ms;
|
long deadline = _clock.NowMs + ms;
|
||||||
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
|
_timeline?.State("sleep", new() { ["duration_ms"] = ms, ["deadline_ms"] = deadline });
|
||||||
// A sleep is a service boundary: make preceding retained writes visible once even when no animation
|
|
||||||
// channel is active during the hold.
|
|
||||||
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
|
|
||||||
IsSleeping = true;
|
IsSleeping = true;
|
||||||
while (_clock.NowMs < deadline)
|
while (_clock.NowMs < deadline)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -97,6 +97,12 @@ public sealed class GodotTraceSink : ITraceSink
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Allocation-free current coordinate for once-per-frame diagnostics.</summary>
|
||||||
|
public GodotTraceStepSnapshot? LatestStep
|
||||||
|
{
|
||||||
|
get { lock (_snapshotLock) return _latestStep; }
|
||||||
|
}
|
||||||
|
|
||||||
private string[] CurrentCallStackLocked()
|
private string[] CurrentCallStackLocked()
|
||||||
{
|
{
|
||||||
var stack = _scripts.ToArray();
|
var stack = _scripts.ToArray();
|
||||||
|
|||||||
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 string? _timelineLogPath; // --timeline-log <jsonl>: synchronized VM/host/compositor evidence
|
||||||
private GodotTimelineLog? _timeline;
|
private GodotTimelineLog? _timeline;
|
||||||
private int _timelineFrame;
|
private int _timelineFrame;
|
||||||
|
private string? _perfLogPath; // --perf-log <csv>: low-overhead frame/compositor timings + work
|
||||||
|
private PerformanceFrameLog? _perf;
|
||||||
|
|
||||||
public override void _Ready()
|
public override void _Ready()
|
||||||
{
|
{
|
||||||
@@ -184,6 +186,7 @@ public partial class Main : Godot.Control
|
|||||||
if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1];
|
if (userArgs[i] == "--shot-sequence" && i + 1 < userArgs.Length) _seqDir = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
|
if (userArgs[i] == "--gfx-log" && i + 1 < userArgs.Length) _gfxLogPath = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = userArgs[i + 1];
|
if (userArgs[i] == "--timeline-log" && i + 1 < userArgs.Length) _timelineLogPath = userArgs[i + 1];
|
||||||
|
if (userArgs[i] == "--perf-log" && i + 1 < userArgs.Length) _perfLogPath = userArgs[i + 1];
|
||||||
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
|
if (userArgs[i] == "--frames" && i + 1 < userArgs.Length) int.TryParse(userArgs[i + 1], out _seqFrames);
|
||||||
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
|
if (userArgs[i] == "--sleep-scale" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out sleepScale);
|
||||||
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
if (userArgs[i] == "--speed" && i + 1 < userArgs.Length) double.TryParse(userArgs[i + 1], out speed);
|
||||||
@@ -225,6 +228,7 @@ public partial class Main : Godot.Control
|
|||||||
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
|
var resources = scripts != null ? new ResourceMap(scripts.Catalog) : ResourceMap.Load();
|
||||||
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
|
_host = new GodotAdvHost(this, resources, scene, _clock, _locator, _timeline) { SleepScale = sleepScale, TraceOps = _gfxLogPath != null };
|
||||||
_trace = new GodotTraceSink(_locator, _timeline);
|
_trace = new GodotTraceSink(_locator, _timeline);
|
||||||
|
if (_perfLogPath != null) _perf = new PerformanceFrameLog(_perfLogPath);
|
||||||
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
|
// --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow
|
||||||
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
|
// diverges — wait-for-input is a no-op there — so this is the only way to profile the live path).
|
||||||
_table = table;
|
_table = table;
|
||||||
@@ -322,51 +326,80 @@ public partial class Main : Godot.Control
|
|||||||
_clock.Advance(delta);
|
_clock.Advance(delta);
|
||||||
_timelineFrame++;
|
_timelineFrame++;
|
||||||
_timeline?.SetFrame(_timelineFrame, _clock.NowMs);
|
_timeline?.SetFrame(_timelineFrame, _clock.NowMs);
|
||||||
_host?.PulseFrame();
|
var perf = _perf;
|
||||||
UpdateVoicePlaybackState();
|
var step = perf != null ? _trace.LatestStep : null;
|
||||||
AdoptPendingMovies();
|
perf?.BeginFrame(_timelineFrame, _clock.NowMs, delta,
|
||||||
UpdateMovieFrames();
|
step?.Script ?? "<startup>", step?.Offset ?? -1, step?.Opcode ?? -1);
|
||||||
if (!_selftest && _vm != null && _host != null && _host.ShouldRecomposite(_vm.Gfx))
|
try
|
||||||
Recomposite(); // native publishes retained mutations only at present/service boundaries
|
|
||||||
if (!_selftest && _host != null) UpdateAdvTextPresentation();
|
|
||||||
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
|
|
||||||
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
|
|
||||||
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
|
|
||||||
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
|
|
||||||
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
|
||||||
{
|
{
|
||||||
System.IO.Directory.CreateDirectory(_seqDir);
|
long phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
|
_host?.PulseFrame();
|
||||||
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
|
perf?.RecordPulse(PerformanceFrameLog.Timestamp() - phase);
|
||||||
var fimg = GetViewport().GetTexture()?.GetImage();
|
|
||||||
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
|
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
_seqIdx++;
|
UpdateVoicePlaybackState();
|
||||||
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); }
|
AdoptPendingMovies();
|
||||||
return;
|
UpdateMovieFrames();
|
||||||
}
|
perf?.RecordMovies(PerformanceFrameLog.Timestamp() - phase);
|
||||||
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
|
|
||||||
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
|
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
{
|
HostPresentationReason presentationReasons = !_selftest && _vm != null && _host != null
|
||||||
if (++_shotSettle >= _shotSettleTarget)
|
? _host.ConsumePresentationReasons(_vm.Gfx)
|
||||||
|
: HostPresentationReason.None;
|
||||||
|
bool shouldRecomposite = presentationReasons != HostPresentationReason.None;
|
||||||
|
perf?.RecordPresentationReasons((int)presentationReasons);
|
||||||
|
perf?.RecordShouldRecomposite(PerformanceFrameLog.Timestamp() - phase);
|
||||||
|
long allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
|
if (shouldRecomposite)
|
||||||
|
Recomposite(); // native publishes retained mutations only at present/service boundaries
|
||||||
|
perf?.RecordRecomposeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
|
|
||||||
|
phase = perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
allocationPhase = perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
|
if (!_selftest && _host != null) UpdateAdvTextPresentation();
|
||||||
|
if (!_selftest && _host != null) UpdateAdvWaitIndicatorPresentation();
|
||||||
|
if (!_selftest && _host != null) UpdateHistoryTextPresentation();
|
||||||
|
perf?.RecordUiAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
|
perf?.RecordUi(PerformanceFrameLog.Timestamp() - phase);
|
||||||
|
|
||||||
|
// --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be
|
||||||
|
// verified as distinct frames, not just the final state. Captures after Recomposite; quits when full.
|
||||||
|
if (_seqDir != null && _seqIdx < _seqFrames && !_done)
|
||||||
{
|
{
|
||||||
_shotDone = true;
|
System.IO.Directory.CreateDirectory(_seqDir);
|
||||||
var img = GetViewport().GetTexture().GetImage();
|
// Headless has no rendered viewport texture (GetImage() is null). Still advance/count/quit so the
|
||||||
img.SavePng(_shotPath);
|
// real-run trace-histogram can profile the live path without a display; only the PNG grab is skipped.
|
||||||
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
|
var fimg = GetViewport().GetTexture()?.GetImage();
|
||||||
ReportSubroutines();
|
fimg?.SavePng($"{_seqDir}/frame_{_seqIdx:0000}.png");
|
||||||
GetTree().Quit(0);
|
_seqIdx++;
|
||||||
|
if (_seqIdx >= _seqFrames) { GD.Print($"SEQ saved {_seqIdx} frames -> {_seqDir}"); GetTree().Quit(0); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// --shot: once the target page is composed and parked at wait-for-input, settle a few frames then grab it.
|
||||||
|
if (_shotPath != null && !_shotDone && _host != null && (_host.Pages >= _shotPage && _host.IsWaiting || _done))
|
||||||
|
{
|
||||||
|
if (++_shotSettle >= _shotSettleTarget)
|
||||||
|
{
|
||||||
|
_shotDone = true;
|
||||||
|
var img = GetViewport().GetTexture().GetImage();
|
||||||
|
img.SavePng(_shotPath);
|
||||||
|
GD.Print($"SHOT saved page {_pageCount} -> {_shotPath}");
|
||||||
|
ReportSubroutines();
|
||||||
|
GetTree().Quit(0);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_done && !_ended)
|
||||||
|
{
|
||||||
|
_ended = true;
|
||||||
|
DumpHistogram();
|
||||||
|
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
|
||||||
|
ReportSubroutines();
|
||||||
|
ShowEnd();
|
||||||
|
if (_selftest) RunSelfTest();
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_done && !_ended)
|
|
||||||
{
|
|
||||||
_ended = true;
|
|
||||||
DumpHistogram();
|
|
||||||
GD.Print($"[vm] ended: {_vm!.HaltReason ?? "unknown"} after {_vm.Steps} steps");
|
|
||||||
ReportSubroutines();
|
|
||||||
ShowEnd();
|
|
||||||
if (_selftest) RunSelfTest();
|
|
||||||
}
|
}
|
||||||
|
finally { perf?.EndFrame(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they
|
// _Input (not _UnhandledInput): the root Control consumes mouse clicks as GUI input before they
|
||||||
@@ -688,6 +721,12 @@ public partial class Main : Godot.Control
|
|||||||
public override void _ExitTree()
|
public override void _ExitTree()
|
||||||
{
|
{
|
||||||
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
|
DumpHistogram(); _host?.Stop(); _timeline?.Dispose(); _locator?.Dispose();
|
||||||
|
if (_perf != null)
|
||||||
|
{
|
||||||
|
_perf.Dispose();
|
||||||
|
GD.Print($"[perf-log] wrote {_perf.FrameCount} frames / {_perf.RecompositeCount} recomposites -> {_perf.Path}");
|
||||||
|
_perf = null;
|
||||||
|
}
|
||||||
foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose();
|
foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose();
|
||||||
_pendingMovies.Clear();
|
_pendingMovies.Clear();
|
||||||
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
|
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
|
||||||
@@ -718,30 +757,62 @@ public partial class Main : Godot.Control
|
|||||||
// GfxState and applied here; object opacity comes only from the actual blend/color path.
|
// GfxState and applied here; object opacity comes only from the actual blend/color path.
|
||||||
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
|
private sealed record CachedPixels(int Width, int Height, byte[] Rgba);
|
||||||
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
|
private readonly System.Collections.Generic.Dictionary<(int AssetId, long Key), CachedPixels> _pixelCache = new();
|
||||||
|
private readonly System.Collections.Generic.List<RenderObject> _visibleSnapshot = new(1024);
|
||||||
|
private readonly System.Collections.Generic.List<SurfaceTextDraw> _surfaceTextSnapshot = new();
|
||||||
|
|
||||||
private void Recomposite()
|
private void Recomposite()
|
||||||
{
|
{
|
||||||
|
if (_perf != null)
|
||||||
|
{
|
||||||
|
var presentStep = _trace.LatestStep;
|
||||||
|
_perf.RecordPresentationCoordinate(presentStep?.Script ?? "<startup>",
|
||||||
|
presentStep?.Offset ?? -1, presentStep?.Opcode ?? -1);
|
||||||
|
}
|
||||||
|
long phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
long allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
|
bool hasScreenTransition = _host.TrySnapshotScreenTransition(out var transition);
|
||||||
|
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
|
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
|
||||||
|
_perf?.BeginRecomposite(hasScreenTransition);
|
||||||
|
|
||||||
|
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
System.Array.Clear(_screenPixels);
|
System.Array.Clear(_screenPixels);
|
||||||
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
foreach (var label in _surfaceTextLabels) label.Visible = false;
|
||||||
|
_perf?.RecordClear(PerformanceFrameLog.Timestamp() - phase);
|
||||||
int surfaceTextLabelIndex = 0;
|
int surfaceTextLabelIndex = 0;
|
||||||
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
System.Collections.Generic.Dictionary<long, string>? decisions = _gfxLogPath != null || _timeline != null ? new() : null;
|
||||||
if (_host.TrySnapshotScreenTransition(out var transition))
|
if (hasScreenTransition)
|
||||||
{
|
{
|
||||||
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
|
// Native mode 4 keeps the captured source opaque and alpha-composites the complete target
|
||||||
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
|
// surface over it. Each offscreen target has an opaque-black clear beneath its objects.
|
||||||
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
|
CompositeVisibleObjects(transition.Source, 1f, ref surfaceTextLabelIndex, decisions, false);
|
||||||
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
|
FillQuad(0, 0, ScreenWidth, ScreenHeight, 0, (float)transition.Progress);
|
||||||
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
|
CompositeVisibleObjects(transition.Target, (float)transition.Progress,
|
||||||
ref surfaceTextLabelIndex, decisions, false);
|
ref surfaceTextLabelIndex, decisions, false);
|
||||||
|
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var visible = _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs); // synchronized objects + ranges
|
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
CompositeVisibleObjects(visible, 1f, ref surfaceTextLabelIndex, decisions, true);
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
|
_vm.Gfx.SnapshotVisibleObjects(_clock.NowMs, _visibleSnapshot); // synchronized objects + ranges
|
||||||
|
_perf?.RecordSnapshotAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
|
_perf?.RecordSnapshot(PerformanceFrameLog.Timestamp() - phase);
|
||||||
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
|
CompositeVisibleObjects(_visibleSnapshot, 1f, ref surfaceTextLabelIndex, decisions, true);
|
||||||
|
_perf?.RecordCompositeAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
}
|
}
|
||||||
|
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
allocationPhase = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
|
_screen.SetData(ScreenWidth, ScreenHeight, false, Image.Format.Rgba8, _screenPixels);
|
||||||
|
_perf?.RecordSetDataAllocation(PerformanceFrameLog.AllocatedBytes() - allocationPhase);
|
||||||
|
_perf?.RecordSetData(PerformanceFrameLog.Timestamp() - phase);
|
||||||
|
phase = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
_screenTex.Update(_screen);
|
_screenTex.Update(_screen);
|
||||||
|
_perf?.RecordTextureUpdate(PerformanceFrameLog.Timestamp() - phase);
|
||||||
if (decisions != null) LogGfxDecisionChanges(decisions);
|
if (decisions != null) LogGfxDecisionChanges(decisions);
|
||||||
|
_perf?.EndRecomposite();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
|
private void CompositeVisibleObjects(IReadOnlyList<RenderObject> visible, float globalOpacity,
|
||||||
@@ -752,6 +823,7 @@ public partial class Main : Godot.Control
|
|||||||
int z = 0;
|
int z = 0;
|
||||||
foreach (var v in visible) // interpolate at the retained-presentation clock
|
foreach (var v in visible) // interpolate at the retained-presentation clock
|
||||||
{
|
{
|
||||||
|
_perf?.RecordObject(v.TimeVarying);
|
||||||
var t = v.Transform;
|
var t = v.Transform;
|
||||||
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
|
var affine = Age.Engine.Model.Transform2DMath.Build(t, v.Rotation);
|
||||||
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
|
var localToDest = affine.FromLocalOrigin(v.DstX, v.DstY);
|
||||||
@@ -763,16 +835,23 @@ public partial class Main : Godot.Control
|
|||||||
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
|
float opacity = v.Alpha / 255f * globalOpacity; // transform Z is never opacity
|
||||||
float strength = v.TintStrength / 255f; // tint-blend / fill strength
|
float strength = v.TintStrength / 255f; // tint-blend / fill strength
|
||||||
var rawObject = _vm.Gfx.TryGet(v.Handle);
|
var rawObject = _vm.Gfx.TryGet(v.Handle);
|
||||||
|
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
var surfaceTexture = rawObject != null
|
var surfaceTexture = rawObject != null
|
||||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
|
||||||
: null;
|
: null;
|
||||||
|
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||||
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||||
string outcome;
|
// These strings exist only for --gfx-log/timeline diagnostics. DEBUGMAP visits roughly one
|
||||||
|
// thousand retained objects per composition, so formatting them unconditionally creates
|
||||||
|
// several megabytes of short-lived garbage even in an ordinary run.
|
||||||
|
string? outcome = null;
|
||||||
if (v.SurfaceTransition is { } transition)
|
if (v.SurfaceTransition is { } transition)
|
||||||
{
|
{
|
||||||
|
_perf?.RecordTransitionLayer();
|
||||||
int layers = DrawTransitionRange(visible, transition);
|
int layers = DrawTransitionRange(visible, transition);
|
||||||
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
|
if (decisions != null)
|
||||||
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
|
outcome = $"TRANSITION slot={transition.TargetSlot} key=0x{transition.CommandKey:x} " +
|
||||||
|
$"progress={transition.Progress:0.000} forced={transition.Forced} layers={layers}";
|
||||||
}
|
}
|
||||||
else if (v.SurfaceResId == 0 && surfaceTexture == null)
|
else if (v.SurfaceResId == 0 && surfaceTexture == null)
|
||||||
{
|
{
|
||||||
@@ -785,37 +864,54 @@ public partial class Main : Godot.Control
|
|||||||
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
|
// One-shot/mode-1 packed color supplies opacity directly. Static mode-0 fills retain
|
||||||
// the tint-strength convention used by the existing effect objects.
|
// the tint-strength convention used by the existing effect objects.
|
||||||
float fillA = v.MultiplyTint ? opacity : opacity * strength;
|
float fillA = v.MultiplyTint ? opacity : opacity * strength;
|
||||||
|
_perf?.RecordFillLayer();
|
||||||
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
|
FillAffineQuad(baseW, baseH, localToDest, v.Tint, fillA);
|
||||||
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
|
if (decisions != null)
|
||||||
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
|
outcome = $"FILL tint=0x{v.Tint:x6} a={fillA:0.00} {baseW}x{baseH}@({dstX},{dstY}) " +
|
||||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
$"base=({v.DstX},{v.DstY}) anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) " +
|
||||||
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
|
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) " +
|
||||||
ColorTimeline(v.ColorTransition);
|
$"trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) rot={v.Rotation.AngleDegrees:0.0}" +
|
||||||
|
ColorTimeline(v.ColorTransition);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_perf?.RecordSkippedLayer();
|
||||||
|
if (decisions != null) outcome = "SKIP(no-resId, opaque render-target)";
|
||||||
}
|
}
|
||||||
else outcome = "SKIP(no-resId, opaque render-target)";
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var texture = surfaceTexture
|
var texture = surfaceTexture;
|
||||||
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
|
if (texture == null && !movieSurfaceBound)
|
||||||
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
{
|
||||||
|
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
texture = _host.ResolveResIdTexture(v.SurfaceResId);
|
||||||
|
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||||
|
}
|
||||||
|
if (texture == null)
|
||||||
|
{
|
||||||
|
_perf?.RecordSkippedLayer();
|
||||||
|
if (decisions != null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
|
BlitLayer(texture.Value.Image, texture.Value.AssetId, v.ColorKey, v.Tint, strength, v.SrcX, v.SrcY, v.W, v.H,
|
||||||
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
|
localToDest, opacity, v.MultiplyTint, texture.Value.IsDynamic, v.Blend);
|
||||||
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
|
if (decisions != null)
|
||||||
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
|
outcome = $"slot={rawObject?.SourceSlot} DRAWN resId=0x{v.SurfaceResId:x} {texture.Value.Name} " +
|
||||||
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
|
$"src=({v.SrcX},{v.SrcY} {v.W}x{v.H}) base=({v.DstX},{v.DstY}) " +
|
||||||
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
$"anchor=({t.AnchorX:0.0},{t.AnchorY:0.0}) dst=({dstX},{dstY}) " +
|
||||||
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
|
$"scale=({t.ScaleX:0.00},{t.ScaleY:0.00}) trans=({t.TranslateX:0.0},{t.TranslateY:0.0}) " +
|
||||||
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
|
$"rot=({t.RotationAngleDegrees:0.0}+{v.Rotation.AngleDegrees:0.0}) " +
|
||||||
ColorTimeline(v.ColorTransition);
|
$"mode={rawObject?.StaticColorMode} op={opacity:0.00} tintStr={strength:0.00}" +
|
||||||
|
ColorTimeline(v.ColorTransition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
|
if (decisions != null) decisions[v.Handle] = $"z{z} {outcome}";
|
||||||
if (includeSurfaceText && rawObject != null)
|
if (includeSurfaceText && rawObject != null)
|
||||||
{
|
{
|
||||||
foreach (var surfaceText in _host.SnapshotSurfaceText(rawObject.SourceSlot))
|
_host.SnapshotSurfaceText(rawObject.SourceSlot, _surfaceTextSnapshot);
|
||||||
|
foreach (var surfaceText in _surfaceTextSnapshot)
|
||||||
{
|
{
|
||||||
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
|
if (surfaceText.X < v.SrcX || surfaceText.X >= v.SrcX + v.W ||
|
||||||
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
|
surfaceText.Y < v.SrcY || surfaceText.Y >= v.SrcY + v.H) continue;
|
||||||
@@ -969,25 +1065,42 @@ public partial class Main : Godot.Control
|
|||||||
{
|
{
|
||||||
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
|
if (source.Handle < transition.RangeBStart || source.Handle >= end || source.SurfaceTransition != null)
|
||||||
continue;
|
continue;
|
||||||
|
_perf?.RecordObject(source.TimeVarying);
|
||||||
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
|
var affine = Transform2DMath.Build(source.Transform, source.Rotation).FromLocalOrigin(source.DstX, source.DstY);
|
||||||
if (source.RangeTransform is { } rangeTransform)
|
if (source.RangeTransform is { } rangeTransform)
|
||||||
affine = affine.Then(rangeTransform);
|
affine = affine.Then(rangeTransform);
|
||||||
float opacity = source.Alpha / 255f * (float)transition.Progress;
|
float opacity = source.Alpha / 255f * (float)transition.Progress;
|
||||||
var rawObject = _vm.Gfx.TryGet(source.Handle);
|
var rawObject = _vm.Gfx.TryGet(source.Handle);
|
||||||
|
long resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
var texture = rawObject != null
|
var texture = rawObject != null
|
||||||
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
|
||||||
: null;
|
: null;
|
||||||
|
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||||
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
|
||||||
if (source.SurfaceResId == 0 && texture == null)
|
if (source.SurfaceResId == 0 && texture == null)
|
||||||
{
|
{
|
||||||
if (source.Blend == BlendKind.Opaque) continue;
|
if (source.Blend == BlendKind.Opaque)
|
||||||
|
{
|
||||||
|
_perf?.RecordSkippedLayer();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600;
|
int w = source.W > 0 ? source.W : 800, h = source.H > 0 ? source.H : 600;
|
||||||
|
_perf?.RecordFillLayer();
|
||||||
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
|
FillAffineQuad(w, h, affine, source.Tint, opacity * source.TintStrength / 255f);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
|
if (!movieSurfaceBound && texture == null)
|
||||||
if (texture == null) continue;
|
{
|
||||||
|
resolveStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
texture = _host.ResolveResIdTexture(source.SurfaceResId);
|
||||||
|
_perf?.RecordResolve(PerformanceFrameLog.Timestamp() - resolveStarted);
|
||||||
|
}
|
||||||
|
if (texture == null)
|
||||||
|
{
|
||||||
|
_perf?.RecordSkippedLayer();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
|
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
|
||||||
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
|
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
|
||||||
texture.Value.IsDynamic, source.Blend);
|
texture.Value.IsDynamic, source.Blend);
|
||||||
@@ -1039,6 +1152,8 @@ public partial class Main : Godot.Control
|
|||||||
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
|
// right<=left or bottom<=top. FIELD deliberately creates zero-area prototype objects from SO005;
|
||||||
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
|
// expanding those dimensions to the full texture leaks the entire spritesheet onto the map.
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
|
long sourcePrepStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
|
long sourcePrepAllocated = _perf != null ? PerformanceFrameLog.AllocatedBytes() : 0;
|
||||||
var cacheKey = (assetId, colorKey);
|
var cacheKey = (assetId, colorKey);
|
||||||
int sourceWidth, sourceHeight;
|
int sourceWidth, sourceHeight;
|
||||||
byte[] sourcePixels;
|
byte[] sourcePixels;
|
||||||
@@ -1077,16 +1192,24 @@ public partial class Main : Godot.Control
|
|||||||
int sh = h;
|
int sh = h;
|
||||||
sw = System.Math.Min(sw, sourceWidth - srcX);
|
sw = System.Math.Min(sw, sourceWidth - srcX);
|
||||||
sh = System.Math.Min(sh, sourceHeight - srcY);
|
sh = System.Math.Min(sh, sourceHeight - srcY);
|
||||||
|
_perf?.RecordSourcePrep(PerformanceFrameLog.Timestamp() - sourcePrepStarted);
|
||||||
|
_perf?.RecordSourcePrepAllocation(PerformanceFrameLog.AllocatedBytes() - sourcePrepAllocated);
|
||||||
if (sw <= 0 || sh <= 0) return;
|
if (sw <= 0 || sh <= 0) return;
|
||||||
|
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
|
Age.Engine.Model.SoftwareAffineRasterizer.BlitRgba(
|
||||||
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
|
_screenPixels, ScreenWidth, ScreenHeight, sourcePixels, sourceWidth, sourceHeight,
|
||||||
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
|
srcX, srcY, sw, sh, localToDest, tint, tintStrength, alpha, multiplyTint, blend);
|
||||||
|
_perf?.RecordRaster(sw, sh, localToDest, ScreenWidth, ScreenHeight, dynamic, blend,
|
||||||
|
PerformanceFrameLog.Timestamp() - rasterStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
|
private void FillAffineQuad(int w, int h, Age.Engine.Model.Affine2D localToDest, long tint, float alpha)
|
||||||
{
|
{
|
||||||
|
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
|
Age.Engine.Model.SoftwareAffineRasterizer.FillRgba(
|
||||||
_screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha);
|
_screenPixels, ScreenWidth, ScreenHeight, w, h, localToDest, tint, alpha);
|
||||||
|
_perf?.RecordRaster(w, h, localToDest, ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
|
||||||
|
PerformanceFrameLog.Timestamp() - rasterStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
|
// Alpha-blend a solid tint (0xRRGGBB) rectangle over the screen — the surfaceless fade/flash fill.
|
||||||
@@ -1094,6 +1217,7 @@ public partial class Main : Godot.Control
|
|||||||
{
|
{
|
||||||
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
|
int ia = (int)(System.Math.Clamp(alpha, 0f, 1f) * 255);
|
||||||
if (ia == 0) return;
|
if (ia == 0) return;
|
||||||
|
long rasterStarted = _perf != null ? PerformanceFrameLog.Timestamp() : 0;
|
||||||
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
|
int tr = (int)((tint >> 16) & 0xff), tg = (int)((tint >> 8) & 0xff), tb = (int)(tint & 0xff);
|
||||||
byte[] dst = _screenPixels;
|
byte[] dst = _screenPixels;
|
||||||
int dw = ScreenWidth, dh = ScreenHeight;
|
int dw = ScreenWidth, dh = ScreenHeight;
|
||||||
@@ -1110,6 +1234,10 @@ public partial class Main : Godot.Control
|
|||||||
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
|
dst[di + 2] = (byte)((tb * ia + dst[di + 2] * (255 - ia)) / 255);
|
||||||
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
|
dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + ia);
|
||||||
}
|
}
|
||||||
|
_perf?.RecordFillLayer();
|
||||||
|
_perf?.RecordRaster(w, h, new Affine2D(1, 0, 0, 1, dstX, dstY),
|
||||||
|
ScreenWidth, ScreenHeight, false, BlendKind.Alpha,
|
||||||
|
PerformanceFrameLog.Timestamp() - rasterStarted);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make colorkey-matching texels transparent (native colorkey is baked at surface load).
|
// Make colorkey-matching texels transparent (native colorkey is baked at surface load).
|
||||||
|
|||||||
@@ -94,16 +94,18 @@ internal sealed class MovieSurfaceRegistry
|
|||||||
{
|
{
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
foreach (var binding in _byPlayback.Values
|
long newestPlaybackId = long.MinValue;
|
||||||
.Where(binding => binding.ResourceId == resourceId)
|
MovieSurfaceFrame? newestFrame = null;
|
||||||
.OrderByDescending(binding => binding.PlaybackId))
|
foreach (var binding in _byPlayback.Values)
|
||||||
{
|
{
|
||||||
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
|
if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId ||
|
||||||
frame = found;
|
!_frames.TryGetValue(binding.PlaybackId, out var found))
|
||||||
return true;
|
continue;
|
||||||
|
newestPlaybackId = binding.PlaybackId;
|
||||||
|
newestFrame = found;
|
||||||
}
|
}
|
||||||
frame = null;
|
frame = newestFrame;
|
||||||
return false;
|
return newestFrame != null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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