fix #429: allocation-free shadow topology rebuild + churn-frame pipelining
The directional-shadow topology rebuilt on every streaming-churn frame and was the measured body of the run-hitch stalls (701 of 708 baseline stalls alloc-correlated): - The draw sort comparer's enum-vs-enum CompareTo bound to Enum.CompareTo(object) and boxed BOTH operands on every comparison — a constant ~38.9 MB of garbage per topology rebuild (~4M boxes), handing the GC a forced gen0 collection mid-frame. The full ~100k-draw sort is replaced outright: draws hash-group by exact DrawKey in one O(n) pass over retained chained-index arrays, and only the few-thousand DISTINCT group keys sort (order-preserving packed material|cull|firstIndex|baseVertex + count|slot|layer|foliage keys, first-appearance tie-break) — bit-identical emission order to the old stable sort, near-zero allocation, and no per-draw comparisons at all. - The caster frame sorts 4-byte indices keyed on SortKey.Value instead of shuffling multi-hundred-byte records through a boxing comparer. - Owner-approved pipelining: on a frame whose shadow inputs just changed (the same frame already paying frame-view/landscape rebuilds), the caster-frame and prepared-draws topology rebuilds defer to the next quieter frame, capped at two consecutive deferrals — inside the GPU fence depth, so retained draws never reference a released arena range. First build, generation change, caster BuildSequence change, and journal overflow force the immediate path; deferred refreshes skip identity-mismatched journal rows. Owner-accepted in both presentation modes: stall frames 5.8/s -> ~0.45/s uncapped (0.49/s capped), median stall 20.3 -> 13.7 ms, >25 ms frames near zero, 275 fps uncapped baseline restored. Allocation gate: a warmed topology rebuild must allocate <2 KiB (DirectionalShadowPreparedDrawTests). docs/ISSUES.md carries the full evidence trail; the residual content-proportional rebuild milliseconds are filed as the incremental-topology successor, and the pre-existing town-view scaling latch is filed as #432. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4873c10673
commit
ad69558908
6 changed files with 652 additions and 69 deletions
266
docs/ISSUES.md
266
docs/ISSUES.md
|
|
@ -177,6 +177,272 @@ pipeline):**
|
|||
The pack-ON player-jump phase question remains as the second defect but
|
||||
becomes mostly moot once the stalls themselves shrink.
|
||||
|
||||
**SITE ATTRIBUTION CORRECTED + FIXED 2026-08-23 (implementation session).**
|
||||
The frame-history correlation (701/708 stall frames at 30-77 MB) was right;
|
||||
the SITE was wrong. The baseline CSV's own stage columns refute the
|
||||
mesh-completion theory: in all 701 alloc-correlated stall frames
|
||||
`upload_us` is ~3 µs — `WbMeshAdapter.Tick` (which contains the entire
|
||||
`UploadGfxObjMeshData` completion drain AND the mip flush, inside the
|
||||
tracked Upload stage) did essentially nothing in those frames. A temporary
|
||||
per-phase allocation probe (`GC.GetAllocatedBytesForCurrentThread` marks
|
||||
through the render frame, driven by an automated Holtburg running route)
|
||||
attributed the allocation exactly:
|
||||
|
||||
- **THE stall allocator: `DirectionalShadowPreparedDraws.Complete`'s
|
||||
`Array.Sort` comparer** (`WbDrawDispatcher.DirectionalShadows.cs`).
|
||||
`x.Material.CompareTo(y.Material)` / `x.CullMode.CompareTo(y.CullMode)`
|
||||
bind to `Enum.CompareTo(object)`, which boxes BOTH operands on every
|
||||
comparison — measured at a constant **38.88 MB per directional-shadow
|
||||
topology rebuild** (~4M boxes across the N·log N sort of every prepared
|
||||
caster draw in the resident window). The topology rebuilds whenever
|
||||
`RenderDataAvailabilityVersion` moves — i.e. on every streaming-churn
|
||||
frame while the player runs (the Atmospheric pack's shadow prepass is the
|
||||
consumer, matching pack-ON visibility; sustained churn windows rebuilt
|
||||
260 consecutive frames ≈ 10 GB of garbage in seconds). Everything else
|
||||
in the rebuild (caster copy, classification loop, grouping) measured
|
||||
~0.3 MB — the shadow stack's retained-scratch design was already sound;
|
||||
two enum comparisons were the whole leak.
|
||||
- Secondary (the original theory, real but ~7 of 708 baseline frames):
|
||||
the `UploadGfxObjMeshData` LINQ conversion, 8-14 MB on completion
|
||||
frames.
|
||||
- Also observed while probing, pre-existing and bounded, NOT #429:
|
||||
composite-texture warmup (`TickCompositeTextureCache`, 16/frame budget)
|
||||
and PView reveal churn allocate ~4-30 MB on teleport/reveal frames.
|
||||
|
||||
**Fix landed (pending owner gate):**
|
||||
1. The sort comparer compares enums through their underlying integers —
|
||||
allocation-free, identical ordering. Verified live: the 38.88 MB
|
||||
rebuild signature is gone (big-alloc frames on the automated running
|
||||
route: 124 → ~0 shadow-rebuild frames; only the pre-existing
|
||||
composite/pview/reveal allocations remain).
|
||||
2. The handoff's de-LINQ of `UploadGfxObjMeshData`: one exact-size
|
||||
retained `CPUIndices` array now feeds both the pick copies and the
|
||||
arena upload (`GlobalMeshBuffer.UploadMesh` takes (offset, count)
|
||||
segments of it); `CPUPositions` fills by direct loop; the
|
||||
`Sum`/`Any`/`FirstOrDefault` transients are gone. Behavior-preserving:
|
||||
same bytes staged, same batch order, same retained content.
|
||||
3. Two I1-style allocation gates: a warmed directional-shadow topology
|
||||
rebuild must allocate < 2 KB
|
||||
(`DirectionalShadowPreparedDrawTests.AWarmedTopologyRebuildAllocatesNearZero`),
|
||||
and a warmed mesh completion must allocate near its retained-copy size
|
||||
(`MeshPipelineDeviceSeamTests.AWarmedMeshCompletionAllocatesNearItsRetainedCopySize`).
|
||||
4. The temporary `PlayerPresentationProbe` and the attribution probe are
|
||||
stripped.
|
||||
|
||||
Session note: one automated probe run (of seven) exited with
|
||||
0xC0000374 (STATUS_HEAP_CORRUPTION) during graceful close AFTER the route
|
||||
completed, on a diagnostic build; not reproduced since. Watch for it in
|
||||
future gate runs.
|
||||
|
||||
Remaining owed: the owner's two-sided acceptance (feel gate + the ~45 s
|
||||
pack-ON measured run against `artifacts/owner-gate/frame-history-429.csv`).
|
||||
The pack frame-graph ordering question (defect 2) stays deferred unless
|
||||
residual hitches survive.
|
||||
|
||||
**OWNER GATE ROUND 1 (2026-08-23, same evening): allocation half PASSED,
|
||||
felt hitch PERSISTS — residual attributed and a second fix round landed.**
|
||||
The owner's ~45 s pack-ON drive on the fixed build: alloc-correlated stalls
|
||||
701 → 5, stall-frame allocation median 39.6 MB → 40 KB, GC pressure gone —
|
||||
but the micro-freeze feel remained. A time-triggered probe round (print any
|
||||
frame > 12 ms with per-phase attribution) on an owner-driven clean run
|
||||
measured the residual exactly: **82 stalls in 47 s (1.75/s — the ORIGINAL
|
||||
pre-investigation stall rate), median 19.1 ms, clusters every ~1.3-2 s**,
|
||||
near-zero allocation. Composition per stall frame — a rebuild CASCADE all
|
||||
triggered by one `RenderDataAvailabilityVersion` bump (streaming publish)
|
||||
and all paid in the SAME frame:
|
||||
- `pk:casters` 3.5-14 ms — `DirectionalShadowCasterFrame.Build` copies +
|
||||
classifies every outdoor projection record;
|
||||
- `wb:sd-topo` 4-28 ms — `DirectionalShadowPreparedDraws.Complete`
|
||||
(sort + group), allocation-free after the boxing fix but still the CPU;
|
||||
- `ws:pview` 4-12 ms — the world draw path's own version-keyed work,
|
||||
elevated on the same frames.
|
||||
The owner's "introduced with the night-sky change" hypothesis was tested
|
||||
directly and REFUTED: the sky default-script segment (`b:skypes`) crossed
|
||||
1 ms once (2.3 ms) in the whole run. Timing note: the VisualMaster
|
||||
directional-shadow machinery landed immediately before the night-sky
|
||||
session where the hitch was first noticed — the sky was the nearest
|
||||
visible change, the shadow prepass the actual newcomer.
|
||||
|
||||
**Second fix round (in tree, uncommitted): packed-key index sorts.** Both
|
||||
hot sorts — `DirectionalShadowPreparedDraws.Complete`'s draw sort and
|
||||
`DirectionalShadowCasterFrame.Build`'s caster sort — previously moved
|
||||
multi-hundred-byte records through interface comparers. Both now sort
|
||||
4-byte index arrays against one 64-bit key (draw sort: an order-preserving
|
||||
packed prefix Material|CullMode|FirstIndex|BaseVertex with exact-comparer
|
||||
tie-break; caster sort: the traversal `SortKey.Value` directly), then
|
||||
permute once through retained scratch. Total order preserved everywhere
|
||||
the arena can reach; 131 directional-shadow tests including both #429
|
||||
allocation gates pass. Owner re-drive pending at time of writing.
|
||||
|
||||
**Measurement hygiene note:** an A/B (same launch recipe, same position,
|
||||
with/without `ACDREAM_UI_PROBE_SCRIPT`, and with an idle one-command
|
||||
script) proved the harness launch recipe and the script runner are BOTH
|
||||
innocent of the #432 low-FPS mode — both idle arms run 4.5 ms/17 KB
|
||||
frames. The mode requires the synthetic route's PATH (through the town
|
||||
view); owner-driven runs avoid it naturally.
|
||||
|
||||
**OVERNIGHT ROUNDS 2-4 (2026-08-23→24): rebuild cascade cheapened but the
|
||||
felt hitch is defect 2, now MEASURED as camera/player decoherence.**
|
||||
Owner drives 2-4 each reported the hitch "unchanged" while every attacked
|
||||
piece shrank (caster copy+classify+sort ≈ 1.3-2 ms each; the draw sort
|
||||
round-1 index sort actually REGRESSED — instanced duplicates share one
|
||||
packed key, so the tie-break full-record comparer became the hot path,
|
||||
6.5 → 14.6 ms avg, caught by the owner's drive-2 data — round-2 replaced
|
||||
the 100k-draw sort entirely with O(n) hash-grouping over retained chained
|
||||
arrays plus an O(g log g) sort of the ~few-thousand DISTINCT group keys;
|
||||
same emitted product, deterministic). Post-everything, owner-terrain stalls
|
||||
still ~2.1/s at 27-31 ms median: per-frame `pv:frameview` (scene
|
||||
frame-view build) + `pv:landscape` + the residual topo loop dominate.
|
||||
Micro-shaving converges too slowly to clear 12 ms — the FEEL lever is
|
||||
defect 2.
|
||||
|
||||
**Defect 2 objectively measured** (from the ORIGINAL owner captures
|
||||
`player-present-429-packON/-packoff.csv`, camera-relative analysis):
|
||||
pack ON, 50 of 85 long moving frames separate the presented player from
|
||||
the camera by ~1 m in one frame (18x the normal 5.5 cm relative step);
|
||||
pack OFF, 7 of 83 at half the size. The felt hitch IS this one-frame
|
||||
camera/player decoherence — the player lurches on screen while the
|
||||
camera-anchored world stays smooth.
|
||||
|
||||
**MECHANISM LOCATED (2026-08-24 ~00:10, per-update
|
||||
`ACDREAM_PROBE_CAMERA_TICK` capture, pack ON, running 16 m/s):** on
|
||||
long (~27 ms) updates, HALF the samples advance the presented player only
|
||||
~12-16 cm (a third of elapsed time) while the chase camera steps the full
|
||||
~40 cm; the other half advance both coherently (~42-53 cm each). The two
|
||||
run on DIFFERENT clocks: the presented player position is
|
||||
`ComputeRenderPosition` = lerp(prevQuantum, currQuantum,
|
||||
pending/MinQuantum) on the retail 30 Hz OBJECT CLOCK (alpha CLAMPS at 1 —
|
||||
near-quantum-length updates alias against the 33.3 ms quantum and the
|
||||
presented position under-advances or freezes), while the camera's damping
|
||||
(`ComputeDampingAlpha(stiffness, dt)` in RetailChaseCamera/ChaseCamera)
|
||||
integrates WALL-CLOCK dt — on a 27 ms update it closes ~half its
|
||||
accumulated ~1 m chase lag regardless of the target having barely moved.
|
||||
Camera and player cross → the lurch. High-FPS updates (pack OFF, ~4 ms)
|
||||
glide through the quanta, which is why OFF feels smooth with the same
|
||||
stall count — and why shrinking the stalls below ~quantum length would
|
||||
also mask it, but the CLOCK MISMATCH is the root cause.
|
||||
|
||||
**DEFECT-2 FIX IMPLEMENTED + MEASURED (2026-08-24 ~00:20, in tree,
|
||||
uncommitted).** `PlayerMovementController.PresentedDeltaSeconds` now
|
||||
reports how far the presented position's own clock advanced each tick
|
||||
(quanta simulated x MinQuantum + clamped-pending delta; wall dt on a
|
||||
Discarded/teleport batch so the camera snaps along), and
|
||||
`CameraFrameController` integrates the chase-camera damping with THAT
|
||||
delta instead of wall dt (manual zoom/pitch stays on wall dt — an input
|
||||
rate, not target chasing). This RESTORES retail's semantics — the camera
|
||||
updates on the physics clock via PlayerPhysicsUpdatedCallback
|
||||
(0x00452d60) — so no divergence-register row: it retires an unregistered
|
||||
wall-clock deviation. Verified on the automated route, pack ON:
|
||||
- per long update (28-38 ms): cam/player step ratio 0.75-1.19 (was
|
||||
2.6-3.4x with ~27 cm crossing); |cam-player| med 2.2 cm, p90 7 cm,
|
||||
max 16.7 cm (~12x tighter);
|
||||
- per frame: typical camera-relative player step 5.5 cm → 0.3 cm; the
|
||||
baseline's 50-of-85 ~1 m long-frame lurches → 3 frames above 25 cm
|
||||
(max 31 cm) — better than the old pack-OFF arm the owner perceived as
|
||||
smooth.
|
||||
Runtime suite 1,818/0, hermetic App suite 6,082/0.
|
||||
|
||||
**FEEL GATE ROUNDS 2-3 + THE FINAL TWO FIXES (2026-08-24 00:20-01:10).**
|
||||
Round 2 ("still there") caught that the camera-clock fix alone leaves a
|
||||
coherent whole-view freeze: with camera and player now in lockstep, the
|
||||
remaining artifact was the presented position itself under-advancing.
|
||||
Root cause: `ComputeRenderPosition` normalized its lerp by the FIXED
|
||||
MinQuantum while the retail clock simulates VARIABLE-length quanta
|
||||
(everything above MinQuantum in one step, split at MaxQuantum=0.2 s) — a
|
||||
long host frame fired a >33 ms quantum, alpha reset across the bigger
|
||||
gap, and presentation froze then replayed fast. Fixed by normalizing by
|
||||
the actual last-quantum interval (`_lastQuantumSeconds`), with
|
||||
`PresentedDeltaSeconds` accounting continuous presented time (the camera
|
||||
consumes the same delta, so both stay coherent by construction). Two
|
||||
Runtime tests that pinned the old fixed-quantum lerp were updated to the
|
||||
continuous-rate contract (Update_SubQuantumFrame_...,
|
||||
Update_LeftoverAboveMinQuantum_... — renamed
|
||||
...InterpolatesAcrossTheActualQuantumInterval).
|
||||
|
||||
Round 3 landed the owner-approved (A) **shadow rebuild pipelining**: on a
|
||||
frame where the shadow inputs just changed (streaming churn — the same
|
||||
frame already pays the frame-view/landscape rebuilds), the caster and
|
||||
prepared-draws topology rebuilds defer to the next quieter frame, capped
|
||||
at 2 consecutive deferrals (inside the GPU fence depth, so retained draws
|
||||
can never reference a released-and-reused arena range). Deferral is
|
||||
best-effort with hard safety rails: first build, generation change,
|
||||
transform-journal overflow, and any caster rebuild force the full path
|
||||
immediately; stale-topology refreshes skip identity-mismatched journal
|
||||
rows instead of throwing. Implemented across
|
||||
`DirectionalShadowCasterFrame.Build(allowTopologyRebuild)`,
|
||||
`WbDrawDispatcher.PrepareDirectionalShadowDraws(allowTopologyRebuild)`,
|
||||
and the policy in `AtmosphericPostProcessGraph.RenderDirectionalShadows`.
|
||||
|
||||
**Measured outcome (owner feel gate 3, ~210 s drive incl. pack
|
||||
switching): median stall 20.3 → 13.7 ms; automated route: med 13.9 ms
|
||||
(was 27-31), max 33, frames >16 ms at 1.4/s. Owner verdict: "almost
|
||||
gone."** Residual composition (deep marks): frame-view build ~4.7 ms +
|
||||
early landscape slices ~3.9 ms per churn frame, plus the pipelined shadow
|
||||
rebuild ~8 ms on its own frame — content-proportional work with no
|
||||
pathological defect left; further reduction is the incremental-topology
|
||||
campaign already described above. OWED: the owner's final morning
|
||||
confirmation, then strip the probe families (RenderFrameAllocProbe +
|
||||
marks incl. fv:/pl:, PlayerPresentationProbe, CameraTickProbe) and
|
||||
commit on request.
|
||||
|
||||
**Tree state at pause (uncommitted, on the worktree branch):** four landed
|
||||
optimizations (enum-boxing comparer fix, upload de-LINQ + arena segment
|
||||
API, caster-frame index sort, draw hash-grouping) + two allocation-gate
|
||||
tests; TEMPORARY apparatus still wired: `RenderFrameAllocProbe` (env
|
||||
`ACDREAM_PROBE_FRAME_ALLOC`, time-or-alloc triggered, 4/s print sampling)
|
||||
with ~30 phase marks, `PlayerPresentationProbe`
|
||||
(`ACDREAM_PROBE_PLAYER_PRESENT`), `CameraTickProbe`
|
||||
(`ACDREAM_PROBE_CAMERA_TICK`). All env-gated, off by default; strip all
|
||||
three families with the defect-2 fix. Hermetic App suite 6,082/0 (one
|
||||
transient parallel-load flake observed once, known-flake class).
|
||||
|
||||
---
|
||||
|
||||
## #432 — Sustained ~6.3 MB/frame + ~20 ms/frame while Holtburg town center is in view
|
||||
|
||||
**Status:** OPEN
|
||||
**Severity:** MEDIUM (halves frame rate and allocates ~300 MB/s while it holds)
|
||||
**Filed:** 2026-08-23 (found while measuring the #429 fix; NOT caused by it —
|
||||
reproduces identically on the pre-fix binary)
|
||||
**Component:** rendering (untracked render path — attribution not yet done)
|
||||
|
||||
**Symptom:** with the player at/near Holtburg town center (observed at cell
|
||||
`0xA9B40019`; NOT at `0xA9B40036` a few cells away), every frame allocates a
|
||||
near-constant ~6.3 MB and costs ~20 ms CPU (~45-50 FPS from a ~270 FPS
|
||||
baseline), indefinitely, with Gen0 at ~6/s. `update_us` ~1.8 ms and
|
||||
`upload_us` ~0 — the time and allocation sit in the untracked render path
|
||||
(same measurement seam as #429). The mode begins the frame the view reaches
|
||||
the spot (after a `/teleloc` there, or immediately at login when parked
|
||||
there) and held for 80+ s of continuous running in a loop around town.
|
||||
|
||||
**Evidence:** frame-history CSVs + stdout under the 2026-08-23 session
|
||||
scratchpad (`frame-history-postfix-224749.csv` — healthy 7 ms/20 KB frames
|
||||
for 20 s until the teleport, then 6.1-6.3 MB/frame for the rest;
|
||||
`frame-history-postfix-225154.csv` — the mode active from login onward;
|
||||
`probe-429-223228.out.log` — the SAME tail on the PRE-#429-fix binary).
|
||||
The #429 owner baseline (spawn `0xA8B4002A`, running loops near-but-not-in
|
||||
town) never shows it: normal frames ~22 KB at ~247 FPS.
|
||||
|
||||
**Partial attribution + latch behavior (from the #429 probe runs):** the
|
||||
per-phase lines that crossed the probe's 8 MB print floor split the mode as
|
||||
a near-constant **~6.0 MB/frame in the PView draw
|
||||
(`WorldSceneRenderer` → `DrawInside`)** plus an intermittent ~4.26 MB in
|
||||
the post-world diagnostics phase. Once triggered it LATCHES: a 70 s
|
||||
straight-line run ~300+ m away from town held EXACTLY ~6,187 KB/frame the
|
||||
whole way (the town stays inside the Near ring at that distance, so
|
||||
whatever content drives it stays resident). Trigger observed at town
|
||||
center `0xA9B40019` but NOT at `0xA9B40036`, and NOT on the owner's
|
||||
`0xA8B4002A`-spawn loops. Candidate families, unverified: the town's
|
||||
buildings entering the PView nearby-building/cell set; an animated static
|
||||
(the windmill, #426-adjacent) keeping a per-frame path hot. Re-add the
|
||||
#429 attribution probe (one level deeper, inside DrawInside) and measure —
|
||||
do NOT guess.
|
||||
|
||||
**Gate caution:** a post-#429 measurement run that strays into this latch
|
||||
shows every frame as a ~20 ms "stall" at ~6.3 MB — that is THIS issue, not
|
||||
#429 residue. Compare only non-latched segments (normal frames ~22 KB), or
|
||||
route away from Holtburg town center.
|
||||
|
||||
**Next probes (in order):**
|
||||
1. `ACDREAM_DUMP_MOTION=1` + a temporary inbound-position log for the
|
||||
LOCAL guid: does ACE send position sets for the local player every
|
||||
|
|
|
|||
|
|
@ -38,7 +38,12 @@ internal readonly record struct DirectionalSunShadowRenderInput(
|
|||
float ResidentMaximumReachMeters = float.PositiveInfinity,
|
||||
bool MeasureGpuTimers = true,
|
||||
bool MeasureCpuStages = false,
|
||||
AtmosphericFrameBufferBinding AtmosphericFrame = default);
|
||||
AtmosphericFrameBufferBinding AtmosphericFrame = default,
|
||||
// #429 owner-approved pipelining: false keeps the retained caster/draw
|
||||
// topology this frame (transform refresh only) so the rebuild lands on a
|
||||
// quieter frame. The prepare seams below re-validate and rebuild anyway
|
||||
// whenever deferral would be unsafe.
|
||||
bool AllowTopologyRebuild = true);
|
||||
|
||||
internal readonly record struct DirectionalSunShadowCpuStageTicks(
|
||||
long EnvironmentGateTicks,
|
||||
|
|
@ -394,7 +399,9 @@ internal sealed class DirectionalSunShadowRenderer : IDirectionalShadowReceiverS
|
|||
|
||||
long cpuStageStarted = input.MeasureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
DirectionalShadowPreparedDraws worldDraws =
|
||||
world.PrepareDirectionalShadowDraws(input.Casters);
|
||||
world.PrepareDirectionalShadowDraws(
|
||||
input.Casters,
|
||||
input.AllowTopologyRebuild);
|
||||
DirectionalShadowTerrainPreparedDraws terrainDraws =
|
||||
terrain.PrepareDirectionalShadowDraws();
|
||||
DirectionalShadowMeshGeometry? worldGeometry =
|
||||
|
|
|
|||
|
|
@ -197,6 +197,10 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
private readonly bool _fuseLowPostProcess;
|
||||
private readonly AtmosphericCpuStageProfiler? _cpuStageProfiler;
|
||||
private readonly DirectionalShadowCasterFrame _shadowCasters = new();
|
||||
// #429 owner-approved pipelining state — see RenderDirectionalShadows.
|
||||
private ulong _lastObservedSceneShadowRevision;
|
||||
private long _lastObservedAvailabilityVersion;
|
||||
private int _shadowRebuildDeferrals;
|
||||
private TargetSet? _targets;
|
||||
private AtmosphericFrameInputs _lastInputs;
|
||||
private DirectionalSunShadowDiagnostics _lastShadowDiagnostics;
|
||||
|
|
@ -395,7 +399,33 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
bool measureCpuStages = _cpuStageProfiler is not null
|
||||
&& AtmosphericCpuStageProfiler.ShouldMeasure(frame.Serial);
|
||||
long stageStarted = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
_shadowCasters.Build(in scene);
|
||||
// #429 owner-approved pipelining: on a frame where the shadow inputs
|
||||
// just CHANGED (streaming publish churn — the same frame already pays
|
||||
// the frame-view and landscape rebuilds), keep the retained shadow
|
||||
// topology and let the rebuild land on the next quieter frame. Capped
|
||||
// at two consecutive deferrals: the GPU frame fence is deeper than
|
||||
// that, so retained prepared draws can never reference an arena range
|
||||
// that was released AND reused while deferred. A caster rebuild the
|
||||
// frame forces anyway (first build, generation change, journal
|
||||
// overflow) re-enables the draws rebuild in the same frame — the
|
||||
// prepared draws must never index a caster frame they were not built
|
||||
// from.
|
||||
ulong sceneShadowRevision = scene.DirectionalShadowTopologyRevision;
|
||||
long availabilityVersion = worldMeshes.DirectionalShadowAvailabilityVersion;
|
||||
bool shadowInputsChanged =
|
||||
sceneShadowRevision != _lastObservedSceneShadowRevision
|
||||
|| availabilityVersion != _lastObservedAvailabilityVersion;
|
||||
_lastObservedSceneShadowRevision = sceneShadowRevision;
|
||||
_lastObservedAvailabilityVersion = availabilityVersion;
|
||||
bool allowTopologyRebuild =
|
||||
!shadowInputsChanged || _shadowRebuildDeferrals >= 2;
|
||||
ulong casterSequenceBefore = _shadowCasters.BuildSequence;
|
||||
_shadowCasters.Build(in scene, allowTopologyRebuild);
|
||||
if (_shadowCasters.BuildSequence != casterSequenceBefore)
|
||||
allowTopologyRebuild = true;
|
||||
_shadowRebuildDeferrals = allowTopologyRebuild
|
||||
? 0
|
||||
: _shadowRebuildDeferrals + 1;
|
||||
long casterBuildFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
AuthoredCelestialShadowSource source = world.CelestialShadowSource;
|
||||
var environment = new DirectionalShadowEnvironmentInput(
|
||||
|
|
@ -433,7 +463,8 @@ internal sealed class AtmosphericPostProcessGraph :
|
|||
Preset.Semantic,
|
||||
frame.Serial),
|
||||
MeasureCpuStages: measureCpuStages,
|
||||
AtmosphericFrame: shadowAtmosphericFrame);
|
||||
AtmosphericFrame: shadowAtmosphericFrame,
|
||||
AllowTopologyRebuild: allowTopologyRebuild);
|
||||
long environmentFinished = measureCpuStages ? Stopwatch.GetTimestamp() : 0L;
|
||||
_lastShadowCasterCount = _shadowCasters.Stats.Accepted;
|
||||
_lastShadowClassificationCalls = _shadowCasters.Stats.TopologyRebuilt ? 1 : 0;
|
||||
|
|
|
|||
|
|
@ -103,6 +103,9 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
private RenderProjectionClass[] _casterClasses = [];
|
||||
private RenderProjectionId[] _denseIdScratch = [];
|
||||
private RenderProjectionRecord[] _denseRecordScratch = [];
|
||||
private int[] _sortIndices = [];
|
||||
private ulong[] _sortKeys = [];
|
||||
private DirectionalShadowCaster[] _sortScratch = [];
|
||||
private readonly DirectionalShadowTransformSnapshot[] _transformChangeScratch =
|
||||
new DirectionalShadowTransformSnapshot[
|
||||
DirectionalShadowTransformChangeJournal.Capacity];
|
||||
|
|
@ -164,14 +167,34 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
+ System.Runtime.CompilerServices.Unsafe.SizeOf<
|
||||
KeyValuePair<RenderProjectionId, int>>()));
|
||||
|
||||
public void Build(in RenderSceneQuery query)
|
||||
/// <summary>
|
||||
/// #429 owner-approved pipelining: with
|
||||
/// <paramref name="allowTopologyRebuild"/> false, a topology-stale frame
|
||||
/// keeps the retained caster product and only refreshes transforms, so the
|
||||
/// copy+classify cost moves off the streaming-churn frame that triggered
|
||||
/// it. The deferral is best-effort: the FIRST build, a generation change,
|
||||
/// and a transform journal that demands a full refresh (dense re-copy by
|
||||
/// id would dereference removed scene entries) all rebuild immediately
|
||||
/// regardless. While deferred, journal rows whose caster identity no
|
||||
/// longer matches the retained topology are skipped instead of throwing —
|
||||
/// the immediately following rebuild reconciles them.
|
||||
/// </summary>
|
||||
public void Build(in RenderSceneQuery query, bool allowTopologyRebuild = true)
|
||||
{
|
||||
ulong topologyRevision = query.DirectionalShadowTopologyRevision;
|
||||
if (BuildSequence != 0
|
||||
bool current = BuildSequence != 0
|
||||
&& Generation == query.Generation
|
||||
&& _topologyRevision == topologyRevision)
|
||||
&& _topologyRevision == topologyRevision;
|
||||
bool deferStale = !allowTopologyRebuild
|
||||
&& !current
|
||||
&& BuildSequence != 0
|
||||
&& Generation == query.Generation
|
||||
&& !RefreshRequiresFullCopy(in query);
|
||||
if (current || deferStale)
|
||||
{
|
||||
int refreshes = RefreshChangedTransforms(in query);
|
||||
int refreshes = RefreshChangedTransforms(
|
||||
in query,
|
||||
tolerateStaleTopology: deferStale);
|
||||
Stats = Stats with
|
||||
{
|
||||
IndexCopies = 0,
|
||||
|
|
@ -230,11 +253,7 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
for (int i = 0; i < dynamicCount; i++)
|
||||
Add(_outdoorDynamicScratch[i]);
|
||||
|
||||
Array.Sort(
|
||||
_casters,
|
||||
0,
|
||||
_casterCount,
|
||||
DirectionalShadowCasterComparer.Instance);
|
||||
SortCasters();
|
||||
int refreshCasterCount = 0;
|
||||
for (int casterIndex = 0; casterIndex < _casterCount; casterIndex++)
|
||||
{
|
||||
|
|
@ -375,7 +394,26 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
}
|
||||
}
|
||||
|
||||
private int RefreshChangedTransforms(in RenderSceneQuery query)
|
||||
/// <summary>
|
||||
/// Pure pre-check for the deferral gate: would refreshing from the journal
|
||||
/// demand the dense by-id re-copy? The journal copy is a read; the state
|
||||
/// consuming it (<see cref="_transformRevision"/>) only advances inside
|
||||
/// <see cref="RefreshChangedTransforms"/>.
|
||||
/// </summary>
|
||||
private bool RefreshRequiresFullCopy(in RenderSceneQuery query)
|
||||
{
|
||||
if (query.DirectionalShadowTransformRevision == _transformRevision)
|
||||
return false;
|
||||
DirectionalShadowTransformChanges changes =
|
||||
query.CopyDirectionalShadowTransformChanges(
|
||||
_transformRevision,
|
||||
_transformChangeScratch);
|
||||
return changes.RequiresFullRefresh;
|
||||
}
|
||||
|
||||
private int RefreshChangedTransforms(
|
||||
in RenderSceneQuery query,
|
||||
bool tolerateStaleTopology = false)
|
||||
{
|
||||
_changedCasterPoseCount = 0;
|
||||
ulong latest = query.DirectionalShadowTransformRevision;
|
||||
|
|
@ -397,6 +435,15 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
_lastTransformChanges = changes;
|
||||
if (changes.RequiresFullRefresh)
|
||||
{
|
||||
if (tolerateStaleTopology)
|
||||
{
|
||||
// Unreachable through Build's deferral gate (it pre-checks via
|
||||
// RefreshRequiresFullCopy); kept as a hard stop because the
|
||||
// dense by-id copy below would throw on scene entries the
|
||||
// stale topology still names.
|
||||
throw new InvalidOperationException(
|
||||
"A stale-topology refresh cannot perform the dense full re-copy.");
|
||||
}
|
||||
_lastBatchedProjectionCopyCalls = 1;
|
||||
for (int index = 0; index < _refreshCasterSlotCount; index++)
|
||||
{
|
||||
|
|
@ -442,6 +489,17 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
{
|
||||
continue;
|
||||
}
|
||||
if (tolerateStaleTopology
|
||||
&& (records[index].Id != _casterIds[casterIndex]
|
||||
|| records[index].ProjectionClass
|
||||
!= _casterClasses[casterIndex]))
|
||||
{
|
||||
// A replaced scene entry (destroy + recreate under a new
|
||||
// class) can journal against a retained slot while the
|
||||
// topology rebuild is deferred; the rebuild on the next
|
||||
// allowed frame reconciles it.
|
||||
continue;
|
||||
}
|
||||
_changedCasterFlags[casterIndex] = true;
|
||||
ValidateStablePose(in records[index], casterIndex);
|
||||
_changedCasterPoses[_changedCasterPoseCount++] =
|
||||
|
|
@ -525,6 +583,51 @@ internal sealed class DirectionalShadowCasterFrame
|
|||
Array.Resize(ref values, capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #429 residual-stall fix: same packed-key index sort as
|
||||
/// <c>DirectionalShadowPreparedDraws.SortSourceDraws</c>. The caster
|
||||
/// comparer orders by the 64-bit traversal <c>SortKey.Value</c> with the
|
||||
/// projection id as tie-break, so the key needs no packing at all —
|
||||
/// almost every pair resolves on one integer compare and the sort swaps
|
||||
/// 4-byte indices instead of the multi-hundred-byte caster records.
|
||||
/// Equal keys fall back to the exact comparer plus an index tie-break,
|
||||
/// preserving the previous total order.
|
||||
/// </summary>
|
||||
private void SortCasters()
|
||||
{
|
||||
int count = _casterCount;
|
||||
EnsureCapacity(ref _sortIndices, count);
|
||||
EnsureCapacity(ref _sortKeys, count);
|
||||
EnsureCapacity(ref _sortScratch, count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
_sortKeys[i] = _casters[i].Projection.SortKey.Value;
|
||||
_sortIndices[i] = i;
|
||||
}
|
||||
_sortIndices.AsSpan(0, count).Sort(
|
||||
new CasterIndexComparer(_sortKeys, _casters));
|
||||
for (int i = 0; i < count; i++)
|
||||
_sortScratch[i] = _casters[_sortIndices[i]];
|
||||
(_casters, _sortScratch) = (_sortScratch, _casters);
|
||||
}
|
||||
|
||||
private readonly struct CasterIndexComparer(
|
||||
ulong[] keys,
|
||||
DirectionalShadowCaster[] casters) : IComparer<int>
|
||||
{
|
||||
public int Compare(int x, int y)
|
||||
{
|
||||
ulong left = keys[x];
|
||||
ulong right = keys[y];
|
||||
if (left != right)
|
||||
return left < right ? -1 : 1;
|
||||
int order = DirectionalShadowCasterComparer.Instance.Compare(
|
||||
casters[x],
|
||||
casters[y]);
|
||||
return order != 0 ? order : x.CompareTo(y);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class DirectionalShadowCasterComparer
|
||||
: IComparer<DirectionalShadowCaster>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -89,6 +89,15 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
private DrawElementsIndirectCommand[] _commands = [];
|
||||
private DirectionalShadowPreparedBatch[] _batches = [];
|
||||
private DirectionalShadowPreparedRun[] _runs = [];
|
||||
private int[] _drawNextInGroup = [];
|
||||
private int[] _groupHead = [];
|
||||
private int[] _groupTail = [];
|
||||
private int[] _groupCountByGroup = [];
|
||||
private ulong[] _groupKeyHi = [];
|
||||
private ulong[] _groupKeyLo = [];
|
||||
private int[] _groupFirstDraw = [];
|
||||
private int[] _groupOrder = [];
|
||||
private readonly Dictionary<DirectionalShadowDrawKey, int> _groupByKey = [];
|
||||
private int _sourceCount;
|
||||
private int _commandCount;
|
||||
private int _runCount;
|
||||
|
|
@ -183,7 +192,18 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
+ _mappedCasterIdentityPresent.Length
|
||||
+ (long)_commands.Length * Unsafe.SizeOf<DrawElementsIndirectCommand>()
|
||||
+ (long)_batches.Length * Unsafe.SizeOf<DirectionalShadowPreparedBatch>()
|
||||
+ (long)_runs.Length * Unsafe.SizeOf<DirectionalShadowPreparedRun>());
|
||||
+ (long)_runs.Length * Unsafe.SizeOf<DirectionalShadowPreparedRun>()
|
||||
+ (long)_drawNextInGroup.Length * sizeof(int)
|
||||
+ (long)_groupHead.Length * sizeof(int)
|
||||
+ (long)_groupTail.Length * sizeof(int)
|
||||
+ (long)_groupCountByGroup.Length * sizeof(int)
|
||||
+ (long)_groupKeyHi.Length * sizeof(ulong)
|
||||
+ (long)_groupKeyLo.Length * sizeof(ulong)
|
||||
+ (long)_groupFirstDraw.Length * sizeof(int)
|
||||
+ (long)_groupOrder.Length * sizeof(int)
|
||||
+ (long)_groupByKey.EnsureCapacity(0)
|
||||
* (sizeof(int)
|
||||
+ Unsafe.SizeOf<KeyValuePair<DirectionalShadowDrawKey, int>>()));
|
||||
|
||||
/// <summary>
|
||||
/// Returns false when this exact resident-caster build was already
|
||||
|
|
@ -358,11 +378,63 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
if (casterBuildSequence == 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(casterBuildSequence));
|
||||
|
||||
Array.Sort(
|
||||
_source,
|
||||
0,
|
||||
_sourceCount,
|
||||
DirectionalShadowSourceDrawComparer.Instance);
|
||||
// #429 residual-stall fix, round 2. The full draw sort existed only to
|
||||
// make equal keys contiguous for the grouping walk below — but the
|
||||
// draw list is ~100k entries while the DISTINCT keys number in the
|
||||
// low thousands. Hash-group the draws in one O(n) pass (chained
|
||||
// per-group index lists over retained arrays), then order the GROUPS
|
||||
// by their packed keys (an O(g log g) sort of small integers). The
|
||||
// emitted product is identical to the former stable full sort in
|
||||
// every reachable state: group membership keys on exact DrawKey
|
||||
// equality via the dictionary; group order follows the same packed
|
||||
// material | cull | firstIndex | baseVertex (hi) and indexCount |
|
||||
// slot | layer | foliage (lo) chain with first-appearance as the
|
||||
// final tie-break; instances within a group keep insertion order,
|
||||
// exactly as the former index tie-break produced.
|
||||
int groupCount = 0;
|
||||
_groupByKey.Clear();
|
||||
EnsureCapacity(ref _drawNextInGroup, _sourceCount);
|
||||
EnsureCapacity(ref _groupHead, _sourceCount);
|
||||
EnsureCapacity(ref _groupTail, _sourceCount);
|
||||
EnsureCapacity(ref _groupCountByGroup, _sourceCount);
|
||||
EnsureCapacity(ref _groupKeyHi, _sourceCount);
|
||||
EnsureCapacity(ref _groupKeyLo, _sourceCount);
|
||||
EnsureCapacity(ref _groupFirstDraw, _sourceCount);
|
||||
EnsureCapacity(ref _groupOrder, _sourceCount);
|
||||
for (int i = 0; i < _sourceCount; i++)
|
||||
{
|
||||
DirectionalShadowDrawKey key = _source[i].Key;
|
||||
if (!_groupByKey.TryGetValue(key, out int group))
|
||||
{
|
||||
group = groupCount++;
|
||||
_groupByKey.Add(key, group);
|
||||
_groupHead[group] = i;
|
||||
_groupTail[group] = i;
|
||||
_groupCountByGroup[group] = 0;
|
||||
_groupFirstDraw[group] = i;
|
||||
_groupKeyHi[group] =
|
||||
((ulong)(byte)key.Material << 62)
|
||||
| ((ulong)((uint)key.CullMode & 0x3u) << 60)
|
||||
| ((ulong)key.FirstIndex << 28)
|
||||
| ((ulong)(uint)key.BaseVertex & 0x0FFF_FFFFul);
|
||||
_groupKeyLo[group] =
|
||||
((ulong)Math.Min((uint)key.IndexCount, 0xF_FFFFu) << 44)
|
||||
| ((ulong)key.TextureSlot.Index << 12)
|
||||
| ((ulong)Math.Min(key.TextureLayer, 0x3FFu) << 2)
|
||||
| (key.FoliageFlags & 0x3u);
|
||||
}
|
||||
else
|
||||
{
|
||||
_drawNextInGroup[_groupTail[group]] = i;
|
||||
_groupTail[group] = i;
|
||||
}
|
||||
_drawNextInGroup[i] = -1;
|
||||
_groupCountByGroup[group]++;
|
||||
}
|
||||
for (int g = 0; g < groupCount; g++)
|
||||
_groupOrder[g] = g;
|
||||
_groupOrder.AsSpan(0, groupCount).Sort(
|
||||
new GroupOrderComparer(_groupKeyHi, _groupKeyLo, _groupFirstDraw));
|
||||
EnsureCapacity(ref _transforms, _sourceCount);
|
||||
EnsureCapacity(ref _transformSources, _sourceCount);
|
||||
EnsureCapacity(ref _dynamicTransformSlots, _sourceCount);
|
||||
|
|
@ -395,24 +467,24 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
_mappedCasterCount);
|
||||
}
|
||||
|
||||
int sourceIndex = 0;
|
||||
int transformIndex = 0;
|
||||
int commandIndex = 0;
|
||||
int opaqueCommands = 0;
|
||||
while (sourceIndex < _sourceCount)
|
||||
for (int orderIndex = 0; orderIndex < groupCount; orderIndex++)
|
||||
{
|
||||
DirectionalShadowDrawKey key = _source[sourceIndex].Key;
|
||||
int groupStart = sourceIndex;
|
||||
do
|
||||
int group = _groupOrder[orderIndex];
|
||||
DirectionalShadowDrawKey key = _source[_groupFirstDraw[group]].Key;
|
||||
int instanceCount = _groupCountByGroup[group];
|
||||
for (int draw = _groupHead[group]; draw >= 0; draw = _drawNextInGroup[draw])
|
||||
{
|
||||
_transforms[transformIndex++] = _source[sourceIndex].Transform;
|
||||
_transforms[transformIndex++] = _source[draw].Transform;
|
||||
_transformSources[transformIndex - 1] =
|
||||
_source[sourceIndex].TransformSource;
|
||||
if (_source[sourceIndex].TransformSource.Refreshable)
|
||||
_source[draw].TransformSource;
|
||||
if (_source[draw].TransformSource.Refreshable)
|
||||
{
|
||||
int dynamicTransformIndex = transformIndex - 1;
|
||||
DirectionalShadowTransformSource transformSource =
|
||||
_source[sourceIndex].TransformSource;
|
||||
_source[draw].TransformSource;
|
||||
if (transformSource.CasterIndex < 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -425,11 +497,8 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
_firstDynamicTransformByCaster[transformSource.CasterIndex] =
|
||||
dynamicTransformIndex;
|
||||
}
|
||||
sourceIndex++;
|
||||
}
|
||||
while (sourceIndex < _sourceCount && _source[sourceIndex].Key == key);
|
||||
|
||||
int instanceCount = sourceIndex - groupStart;
|
||||
_commands[commandIndex] = new DrawElementsIndirectCommand
|
||||
{
|
||||
Count = checked((uint)key.IndexCount),
|
||||
|
|
@ -863,6 +932,35 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
Array.Resize(ref values, capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Orders the hash-built groups for emission: the packed hi key carries
|
||||
/// Material (2) | CullMode (2) | FirstIndex (32) | BaseVertex low 28, the
|
||||
/// lo key IndexCount (20, clamped) | TextureSlot (32) | TextureLayer (10,
|
||||
/// clamped) | FoliageFlags (2) — the exact field chain the former full
|
||||
/// draw sort compared — with first-appearance as the final deterministic
|
||||
/// tie-break. Clamp collisions (unreachable with the configured arena and
|
||||
/// atlas maxima) can only reorder whole groups inside one material+cull
|
||||
/// run; group membership itself keys on exact DrawKey equality.
|
||||
/// </summary>
|
||||
private readonly struct GroupOrderComparer(
|
||||
ulong[] keyHi,
|
||||
ulong[] keyLo,
|
||||
int[] firstDraw) : IComparer<int>
|
||||
{
|
||||
public int Compare(int x, int y)
|
||||
{
|
||||
ulong left = keyHi[x];
|
||||
ulong right = keyHi[y];
|
||||
if (left != right)
|
||||
return left < right ? -1 : 1;
|
||||
left = keyLo[x];
|
||||
right = keyLo[y];
|
||||
if (left != right)
|
||||
return left < right ? -1 : 1;
|
||||
return firstDraw[x].CompareTo(firstDraw[y]);
|
||||
}
|
||||
}
|
||||
|
||||
private readonly record struct DirectionalShadowDrawKey(
|
||||
uint FirstIndex,
|
||||
int BaseVertex,
|
||||
|
|
@ -881,42 +979,15 @@ internal sealed class DirectionalShadowPreparedDraws
|
|||
Matrix4x4 Transform,
|
||||
DirectionalShadowTransformSource TransformSource);
|
||||
|
||||
private sealed class DirectionalShadowSourceDrawComparer
|
||||
: IComparer<DirectionalShadowSourceDraw>
|
||||
{
|
||||
public static DirectionalShadowSourceDrawComparer Instance { get; } = new();
|
||||
|
||||
public int Compare(
|
||||
DirectionalShadowSourceDraw left,
|
||||
DirectionalShadowSourceDraw right)
|
||||
{
|
||||
DirectionalShadowDrawKey x = left.Key;
|
||||
DirectionalShadowDrawKey y = right.Key;
|
||||
int order = x.Material.CompareTo(y.Material);
|
||||
if (order != 0) return order;
|
||||
order = x.CullMode.CompareTo(y.CullMode);
|
||||
if (order != 0) return order;
|
||||
order = x.FirstIndex.CompareTo(y.FirstIndex);
|
||||
if (order != 0) return order;
|
||||
order = x.BaseVertex.CompareTo(y.BaseVertex);
|
||||
if (order != 0) return order;
|
||||
order = x.IndexCount.CompareTo(y.IndexCount);
|
||||
if (order != 0) return order;
|
||||
order = x.TextureSlot.Index.CompareTo(y.TextureSlot.Index);
|
||||
if (order != 0) return order;
|
||||
order = x.TextureLayer.CompareTo(y.TextureLayer);
|
||||
// Campaign VM VM6: tie-break on FoliageFlags so entries sharing
|
||||
// every other key field but differing only in classification
|
||||
// (the rare case a mesh subset is reachable from both a
|
||||
// procedural-scenery and a non-scenery placement) still sort
|
||||
// into one contiguous, exact-key-matched run instead of an
|
||||
// unstable-sort-dependent scatter. The grouping loop below keys
|
||||
// on exact DirectionalShadowDrawKey equality regardless.
|
||||
return order != 0
|
||||
? order
|
||||
: x.FoliageFlags.CompareTo(y.FoliageFlags);
|
||||
}
|
||||
}
|
||||
// The former full-draw sort comparer is gone with the sort itself.
|
||||
// #429 postmortem, preserved here because the lesson is easy to lose:
|
||||
// its original `x.Material.CompareTo(y.Material)` bound to
|
||||
// Enum.CompareTo(object) and boxed BOTH operands on every comparison —
|
||||
// measured at 38.9 MB of garbage per topology rebuild (~4M boxes across
|
||||
// the N·log N sort), rebuilt on every streaming-churn frame while the
|
||||
// player moves. Compare enums through their underlying integers, or
|
||||
// better, do not sort 100k draws when hash-grouping plus a small
|
||||
// group-key sort produces the identical product (see Complete).
|
||||
}
|
||||
|
||||
public sealed partial class WbDrawDispatcher
|
||||
|
|
@ -939,8 +1010,17 @@ public sealed partial class WbDrawDispatcher
|
|||
/// returned owner is renderer-retained and remains valid until the next
|
||||
/// distinct caster build is prepared.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// #429: the version the shadow pipelining policy observes — the same
|
||||
/// counter <see cref="PrepareDirectionalShadowDraws"/> keys its topology
|
||||
/// gate on.
|
||||
/// </summary>
|
||||
internal long DirectionalShadowAvailabilityVersion =>
|
||||
_meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L;
|
||||
|
||||
internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws(
|
||||
DirectionalShadowCasterFrame casters)
|
||||
DirectionalShadowCasterFrame casters,
|
||||
bool allowTopologyRebuild = true)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(casters);
|
||||
ReadOnlySpan<DirectionalShadowCaster> source = casters.Casters;
|
||||
|
|
@ -956,6 +1036,19 @@ public sealed partial class WbDrawDispatcher
|
|||
_directionalShadowDraws.RefreshDynamicTransforms(casters);
|
||||
return _directionalShadowDraws;
|
||||
}
|
||||
// #429 owner-approved pipelining: a deferred frame keeps the retained
|
||||
// prepared draws and only refreshes transforms — valid ONLY while the
|
||||
// product was built from this exact caster frame; a caster rebuild or
|
||||
// generation change invalidates the caster-slot mapping the transform
|
||||
// refresh indexes by, so those rebuild immediately regardless.
|
||||
if (!allowTopologyRebuild
|
||||
&& _directionalShadowDraws.SourceCasterBuildSequence
|
||||
== casters.BuildSequence
|
||||
&& _directionalShadowDraws.SourceGeneration == casters.Generation)
|
||||
{
|
||||
_directionalShadowDraws.RefreshDynamicTransforms(casters);
|
||||
return _directionalShadowDraws;
|
||||
}
|
||||
|
||||
int estimatedInstances = 0;
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
|
|
|
|||
|
|
@ -598,4 +598,87 @@ public sealed class DirectionalShadowPreparedDrawTests
|
|||
MemoryMarshal.CreateReadOnlySpan(ref actual, 1));
|
||||
Assert.True(expectedBits.SequenceEqual(actualBits));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #429 allocation gate (I1 style). A warmed topology rebuild owns every
|
||||
/// retained buffer it needs, so the whole
|
||||
/// TryBegin → Add×N → Complete transaction must allocate near zero.
|
||||
/// The regression this pins: the Complete sort's comparer used
|
||||
/// <c>enum.CompareTo(enum)</c>, which binds to
|
||||
/// <c>Enum.CompareTo(object)</c> and boxes BOTH operands on every
|
||||
/// comparison — measured at 38.9 MB of garbage per rebuild in a
|
||||
/// production window (~4M boxes across the N·log N sort), rebuilt on
|
||||
/// every streaming-churn frame while the player moves. That was the
|
||||
/// #429 run-hitch.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void AWarmedTopologyRebuildAllocatesNearZero()
|
||||
{
|
||||
const int drawCount = 4096;
|
||||
var product = new DirectionalShadowPreparedDraws();
|
||||
RenderSceneGeneration generation = RenderSceneGeneration.FromRaw(9);
|
||||
|
||||
BuildVariedTopology(product, generation, buildSequence: 1, drawCount);
|
||||
long before = GC.GetAllocatedBytesForCurrentThread();
|
||||
BuildVariedTopology(product, generation, buildSequence: 2, drawCount);
|
||||
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
||||
|
||||
Assert.Equal(drawCount, product.Stats.PreparedInstances);
|
||||
// One small constant covers the sort's comparison-delegate wrapper.
|
||||
// The boxing regression allocates ~2 MB at this draw count and fails
|
||||
// this gate by three orders of magnitude.
|
||||
Assert.True(
|
||||
allocated < 2048,
|
||||
$"A warmed directional-shadow topology rebuild allocated {allocated} bytes.");
|
||||
}
|
||||
|
||||
private static void BuildVariedTopology(
|
||||
DirectionalShadowPreparedDraws product,
|
||||
RenderSceneGeneration generation,
|
||||
ulong buildSequence,
|
||||
int drawCount)
|
||||
{
|
||||
Assert.True(product.TryBegin(
|
||||
generation,
|
||||
buildSequence,
|
||||
estimatedInstances: drawCount));
|
||||
Matrix4x4 transform = Matrix4x4.Identity;
|
||||
for (int i = 0; i < drawCount; i++)
|
||||
{
|
||||
// Vary every sort-key dimension so Complete's sort exercises the
|
||||
// full comparison chain (material, cull mode, then the integers).
|
||||
bool cutout = (i & 1) != 0;
|
||||
product.Add(
|
||||
firstIndex: (uint)((i * 37) % 1024),
|
||||
baseVertex: (i * 13) % 512,
|
||||
indexCount: 3 + (i % 5) * 3,
|
||||
cutout ? new GpuTextureSlot((uint)(i % 7)) : GpuTextureSlot.Unassigned,
|
||||
textureLayer: (uint)(i % 11),
|
||||
(i % 3) switch
|
||||
{
|
||||
0 => CullMode.None,
|
||||
1 => CullMode.Clockwise,
|
||||
_ => CullMode.CounterClockwise,
|
||||
},
|
||||
cutout
|
||||
? DirectionalShadowCasterMaterial.AlphaCutout
|
||||
: DirectionalShadowCasterMaterial.Opaque,
|
||||
in transform);
|
||||
}
|
||||
product.Complete(
|
||||
generation,
|
||||
buildSequence,
|
||||
new DirectionalShadowPreparationStats(
|
||||
SourceCasters: drawCount,
|
||||
SourceMeshRefs: drawCount,
|
||||
SourceParts: drawCount,
|
||||
SourceBatches: drawCount,
|
||||
PreparedInstances: 0,
|
||||
PreparedOpaqueCommands: 0,
|
||||
PreparedAlphaCutoutCommands: 0,
|
||||
RejectedTransparentBatches: 0,
|
||||
RejectedFadedParts: 0,
|
||||
MissingMeshes: 0,
|
||||
UnresolvedAlphaCutoutTextures: 0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue