diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 375816d1..97c45dbc 100644 --- a/docs/ISSUES.md +++ b/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 diff --git a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs index 035ffa9f..75e9518c 100644 --- a/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs +++ b/src/AcDream.App/Rendering/DirectionalSunShadowRenderer.cs @@ -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 = diff --git a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs index 1cadfb34..95f7ee1d 100644 --- a/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs +++ b/src/AcDream.App/Rendering/Packs/AtmosphericPostProcessGraph.cs @@ -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; diff --git a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs index e3e77ed8..39077c3d 100644 --- a/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs +++ b/src/AcDream.App/Rendering/Scene/DirectionalShadowCasterFrame.cs @@ -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>())); - public void Build(in RenderSceneQuery query) + /// + /// #429 owner-approved pipelining: with + /// 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. + /// + 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) + /// + /// 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 () only advances inside + /// . + /// + 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); } + /// + /// #429 residual-stall fix: same packed-key index sort as + /// DirectionalShadowPreparedDraws.SortSourceDraws. The caster + /// comparer orders by the 64-bit traversal SortKey.Value 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. + /// + 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 + { + 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 { diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index 350c73ec..44a497e2 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -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 _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() + (long)_batches.Length * Unsafe.SizeOf() - + (long)_runs.Length * Unsafe.SizeOf()); + + (long)_runs.Length * Unsafe.SizeOf() + + (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>())); /// /// 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); } + /// + /// 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. + /// + private readonly struct GroupOrderComparer( + ulong[] keyHi, + ulong[] keyLo, + int[] firstDraw) : IComparer + { + 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 - { - 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. /// + /// + /// #429: the version the shadow pipelining policy observes — the same + /// counter keys its topology + /// gate on. + /// + internal long DirectionalShadowAvailabilityVersion => + _meshAdapter.MeshManager?.RenderDataAvailabilityVersion ?? 0L; + internal DirectionalShadowPreparedDraws PrepareDirectionalShadowDraws( - DirectionalShadowCasterFrame casters) + DirectionalShadowCasterFrame casters, + bool allowTopologyRebuild = true) { ArgumentNullException.ThrowIfNull(casters); ReadOnlySpan 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++) diff --git a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs index 681c7ac4..9dd73a00 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/DirectionalShadowPreparedDrawTests.cs @@ -598,4 +598,87 @@ public sealed class DirectionalShadowPreparedDrawTests MemoryMarshal.CreateReadOnlySpan(ref actual, 1)); Assert.True(expectedBits.SequenceEqual(actualBits)); } + + /// + /// #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 + /// enum.CompareTo(enum), which binds to + /// Enum.CompareTo(object) 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. + /// + [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)); + } }