From 3718e341be2d0bcc93aaf4b8ea9f67cf12219d20 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Jul 2026 06:29:30 +0200 Subject: [PATCH] fix #225: stabilize render pacing and frame CPU Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal. Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality. Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline. Co-authored-by: OpenAI Codex --- docs/ISSUES.md | 26 +- docs/plans/2026-04-11-roadmap.md | 1 + src/AcDream.App/Diagnostics/FrameProfiler.cs | 4 +- .../Rendering/FramePacingController.cs | 53 +- src/AcDream.App/Rendering/GameWindow.cs | 27 +- src/AcDream.App/Rendering/RetailAlphaQueue.cs | 78 ++- src/AcDream.App/Rendering/Wb/CachedBatch.cs | 18 +- .../Rendering/Wb/EntityClassificationCache.cs | 7 +- .../Rendering/Wb/ObjectMeshManager.cs | 41 +- .../Rendering/Wb/WbDrawDispatcher.cs | 497 ++++++++++++------ .../WindowsHighResolutionFramePacingWaiter.cs | 169 ++++++ .../FrameProfilerReportTests.cs | 10 +- .../Rendering/FramePacingControllerTests.cs | 67 +++ .../Rendering/FramePacingPolicyTests.cs | 1 + .../Rendering/RetailAlphaQueueTests.cs | 89 ++++ .../Rendering/Wb/InstanceGroupClearTests.cs | 206 +++++++- .../Wb/ObjectMeshWorkerLifecycleTests.cs | 34 ++ 17 files changed, 1103 insertions(+), 225 deletions(-) create mode 100644 src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs create mode 100644 tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshWorkerLifecycleTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 33019212..09e7ce07 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -117,6 +117,28 @@ balanced at 1,715/1,715 and composite physical residency remained below its 128 MiB ceiling. The older 32 FPS comparison run was RDP-refresh-capped and is used only for memory comparison. The lifestone/particle visual gate remains. +The 141.8 FPS result above is one destination-specific dwell, not a universal +throughput claim. A fixed dense-Caul capture with about 21,024 visible entities +later isolated a render-thread bottleneck: CPU frame time was roughly 9–12 ms +while the GPU needed only 3.8–3.9 ms. The follow-up preserves exact alpha order +and every quality/range setting while replacing comparison sorting with a +stable radix, avoiding a second immediate-buffer pack for deferred transparent +instances, packing per-instance light sets, caching static picking descriptors, +and using checked direct group handles. Historical material groups now retire +after one complete unused frame (not after one `Draw`, because landscape slices +and paperdoll share the dispatcher) and release their list capacity. That dense +sample reached roughly 5.3–6.2 ms CPU / 7.1–8.3 ms p95 and 3.2–3.6 ms GPU before +the final group-retirement review correction. + +The earlier software limiter also used coarse `Thread.Sleep`, whose nominal +one-millisecond waits measured about 15.98 ms on this host. A reusable Windows +high-resolution waitable timer now paces to the monitor deadline and is reported +as `pace` by the frame profiler. Scripted UI automation no longer enables the +allocation-heavy GPU-stream dump implicitly, and graceful shutdown now wakes +all persistent mesh workers without resetting their shared wake signal. The +post-review connected stress rerun remains pending because local ACE was not +listening; sparse-scene 239 FPS samples are not substituted for that gate. + **Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`; `src/AcDream.App/Rendering/ParticleRenderer.cs`; @@ -129,7 +151,9 @@ used only for memory comparison. The lifestone/particle visual gate remains. `src/AcDream.App/Rendering/CompositeTextureArrayCache.cs`; `src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`; `src/AcDream.Content/BoundedDatObjectCache.cs`; -`src/AcDream.Content/DecodedTextureCache.cs`. +`src/AcDream.Content/DecodedTextureCache.cs`; +`src/AcDream.App/Rendering/FramePacingController.cs`; +`src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs`. **Research:** `docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`; diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 1e25b72a..c6c2d898 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -562,6 +562,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. - **Missile/portal VFX campaign Step 9 automated hardening implemented and independently reviewed 2026-07-14; final two-client visual gate pending.** A deterministic 96-owner App fixture drives canonical missiles and effect owners through projectile updates, repeated DAT-effect scheduling, loaded↔pending↔loaded landblock churn, light/particle withdrawal and recovery, accepted deletes, same-GUID generation reuse, never-created F754 queues, and session reset. It asserts balanced logical render registration and zero residual records/bodies/projectiles, spatial buckets/rescues, shadows (including suspended registrations), effect profiles/packets, PhysicsScript FIFOs/anchors/delayed calls, particle bindings/logical IDs/render-pass owners, poses, light-controller/sink/manager owners, and stale record component references. A companion twelve-cycle gate drives exact recall motion `0x10000153` through AnimationSequencer/CallPES, Hidden, deferred remote placement, hydration, and UnHide; a second 96-owner `EntitySpawnAdapter` gate balances actual mesh-adapter reference counts without GL. The pass fixed undrained persistent rescue retention, delayed stale visibility edges, GUID-scoped teardown, create resurrection after a nested delete/reset, non-atomic resource registration, double teardown from a re-entrant session-clear callback, observer failure stranding later visibility edges, and stale outer projection transactions overwriting callback-created replacements. Teardown now removes exact projection references and generation/local-ID owners, uses per-GUID/session lifetime epochs plus per-record projection tokens, drains visibility fan-out before aggregating failures, and finishes or supersedes canonical commits before surfacing observer errors. Core remains GL/backend-free, panels remain on UI abstractions, and projectiles still draw through ordinary `WbDrawDispatcher` live-entity submission rather than a global projectile pass. AP-69 and TS-49 remain; AP-83/AP-91 now explicitly describe only a future mover that enables PerfectClip. - **Portal/resource lifetime correction 2026-07-18; connected stress gate passed, final visual gate pending.** `LiveEntityLivenessController` ports retail's 25-second leave-visibility destruction queue over canonical live records, cancels expiry for spatially resident or owned objects, and uses holtburger's conservative 384-unit envelope where ACE supplies no client-visible PVS leave event. The broader integration balances generation-scoped appearance/live/landblock/effect/UI/portal owners, bounds DAT/decoded/standalone/composite/atlas/mesh residency, performs incremental mesh-arena migration, and defers GL deletion or slot reuse through three frames of GPU fences. Render/UI scratch storage is reused with hysteretic trimming. No visual range or quality setting was reduced. The final seven-destination connected route cut peak working/private memory from 2,954.5/3,502.3 MiB to 1,493.4/1,969.5 MiB, returned Caul to 1,030.6/1,638.2 MiB, held 125–153 FPS locally through the final dwell, and produced no WER/driver reset. AP-69 is narrowed rather than retired because exact ObjCell PVS and explicit preview lifetimes remain. +- **Render-thread CPU and high-refresh pacing correction 2026-07-19; automated/review gates passed, connected rerun pending.** Software pacing now uses one reusable Windows high-resolution waitable timer instead of scheduler-quantized `Thread.Sleep`; the permanent profiler attributes that wait as `pace`. Wb keeps exact stable far-to-near alpha order while using a radix key, avoids immediate SSBO packing for deferred transparent objects, packs each eight-light set, caches immutable selection descriptors, and retires historical material groups only after one complete unused frame across every landscape/dynamics/paperdoll draw scope. A fixed dense-Caul sample improved from roughly 9–12 ms CPU to 5.3–6.2 ms without reducing view distance, particles, textures, streaming radius, or other quality. The prior 141.8 FPS result remains scene-specific; local ACE was offline for the post-review connected rerun. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation. diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs index 2ac85dcd..4f625bf3 100644 --- a/src/AcDream.App/Diagnostics/FrameProfiler.cs +++ b/src/AcDream.App/Diagnostics/FrameProfiler.cs @@ -16,6 +16,8 @@ public enum FrameStage Upload = 1, /// ImGui Render (dev overlay). ImGui = 2, + /// Software presentation deadline wait (zero under VSync or uncapped mode). + Pacing = 3, } /// @@ -187,7 +189,7 @@ public sealed class FrameProfiler : IDisposable sb.Append(" | gpu=off(wbdiag)"); sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}", alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2); - string[] names = { "upd", "upl", "imgui" }; + string[] names = { "upd", "upl", "imgui", "pace" }; for (int i = 0; i < stages.Length && i < names.Length; i++) sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}", names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0); diff --git a/src/AcDream.App/Rendering/FramePacingController.cs b/src/AcDream.App/Rendering/FramePacingController.cs index 4f5e04ba..4261bf66 100644 --- a/src/AcDream.App/Rendering/FramePacingController.cs +++ b/src/AcDream.App/Rendering/FramePacingController.cs @@ -12,27 +12,41 @@ namespace AcDream.App.Rendering; /// CPU use. A missed deadline is rebased from the current time so one slow /// frame cannot trigger a burst of catch-up frames. /// -internal sealed class FramePacingController +internal sealed class FramePacingController : IDisposable { private readonly IFramePacingClock _clock; private readonly IFramePacingWaiter _waiter; + private readonly bool _ownsWaiter; private FramePacingPolicy _policy; private long _periodTicks; private long _nextDeadline; private bool _hasDeadline; + private bool _disposed; public FramePacingController() - : this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance) + : this( + StopwatchFramePacingClock.Instance, + WindowsHighResolutionFramePacingWaiter.Create(), + ownsWaiter: true) { } internal FramePacingController( IFramePacingClock clock, IFramePacingWaiter waiter) + : this(clock, waiter, ownsWaiter: false) + { + } + + internal FramePacingController( + IFramePacingClock clock, + IFramePacingWaiter waiter, + bool ownsWaiter) { _clock = clock ?? throw new ArgumentNullException(nameof(clock)); _waiter = waiter ?? throw new ArgumentNullException(nameof(waiter)); + _ownsWaiter = ownsWaiter; if (_clock.Frequency <= 0) throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive."); } @@ -102,6 +116,16 @@ internal sealed class FramePacingController => value > long.MaxValue - increment ? long.MaxValue : value + increment; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + if (_ownsWaiter && _waiter is IDisposable disposable) + disposable.Dispose(); + } } internal interface IFramePacingClock @@ -128,28 +152,3 @@ internal sealed class StopwatchFramePacingClock : IFramePacingClock public long GetTimestamp() => Stopwatch.GetTimestamp(); } - -internal sealed class ThreadFramePacingWaiter : IFramePacingWaiter -{ - public static ThreadFramePacingWaiter Instance { get; } = new(); - - private ThreadFramePacingWaiter() - { - } - - public void Wait(long durationTicks, long clockFrequency) - { - if (durationTicks <= 0 || clockFrequency <= 0) - return; - - double milliseconds = durationTicks * 1000d / clockFrequency; - - // A kernel sleep is intentionally preferred over a final spin. It can - // overshoot a presentation deadline slightly, but it keeps normal CPU - // use low—the central reason this fallback exists. - int sleepMilliseconds = Math.Max( - 1, - (int)Math.Ceiling(Math.Min(milliseconds, int.MaxValue))); - Thread.Sleep(sleepMilliseconds); - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b9d9e40a..6db9e4b4 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -10871,7 +10871,9 @@ public sealed class GameWindow : IDisposable $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; - if (_options.UiProbeEnabled) + // Script automation must remain performance-neutral. Only the + // explicit dump switch requests this allocation-heavy snapshot. + if (_options.UiProbeDump) { (int particleSets, long particleBytes) = _particleRenderer?.DynamicBufferDiagnostics ?? default; @@ -12893,7 +12895,11 @@ public sealed class GameWindow : IDisposable } private void OnFrameRendered(double _) - => _framePacing.CompleteFrame(); + { + using var pacingStage = _frameProfiler.BeginStage( + AcDream.App.Diagnostics.FrameStage.Pacing); + _framePacing.CompleteFrame(); + } private void OnWindowMoved(Silk.NET.Maths.Vector2D _) => RefreshActiveMonitorFramePacing(); @@ -15022,6 +15028,9 @@ public sealed class GameWindow : IDisposable } private void OnClosing() + => CompleteShutdown(); + + private void CompleteShutdown() { _shutdown ??= CreateShutdownTransaction(); try @@ -15036,7 +15045,6 @@ public sealed class GameWindow : IDisposable // context/process teardown is the final safety net. Console.Error.WriteLine($"[shutdown] {error}"); } - } private ResourceShutdownTransaction CreateShutdownTransaction() => new( @@ -15160,6 +15168,7 @@ public sealed class GameWindow : IDisposable new("text renderer", () => _textRenderer?.Dispose()), new("debug font", () => _debugFont?.Dispose()), new("frame profiler", _frameProfiler.Dispose), + new("frame pacing", _framePacing.Dispose), ]), new ResourceShutdownStage("frame flight owner", [ @@ -15202,7 +15211,17 @@ public sealed class GameWindow : IDisposable EndMouseLookAndRestoreCursor(); } - public void Dispose() => _window?.Dispose(); + public void Dispose() + { + // Closing is the normal path and runs while the render context is + // current. This direct call also covers a constructed-but-never-run + // window and exceptions during Window.Create/Run, so owned kernel + // handles (including the frame timer) never depend on a native window + // event for disposal. + CompleteShutdown(); + _window?.Dispose(); + _window = null; + } // ── Phase I.6 — TurbineChat outbound helpers ────────────────── diff --git a/src/AcDream.App/Rendering/RetailAlphaQueue.cs b/src/AcDream.App/Rendering/RetailAlphaQueue.cs index b34e208f..672982d5 100644 --- a/src/AcDream.App/Rendering/RetailAlphaQueue.cs +++ b/src/AcDream.App/Rendering/RetailAlphaQueue.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using System.Runtime.InteropServices; namespace AcDream.App.Rendering; @@ -38,8 +39,7 @@ internal interface IRetailAlphaDrawSource internal readonly record struct RetailAlphaSubmission( IRetailAlphaDrawSource Source, int Token, - float ViewerDistance, - long Sequence); + float ViewerDistance); /// /// Frame-scoped port of retail's shared D3DPolyRender alpha list. @@ -62,7 +62,8 @@ internal sealed class RetailAlphaQueue private readonly List _sources = new(4); private int[] _tokenScratch = new int[256]; private int[] _sourceDrawOffsets = new int[4]; - private long _nextSequence; + private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256]; + private readonly int[] _radixOffsets = new int[256]; public bool IsCollecting { get; private set; } internal int PendingCount => _submissions.Count; @@ -74,7 +75,6 @@ internal sealed class RetailAlphaQueue if (_submissions.Count != 0 || _sources.Count != 0) throw new InvalidOperationException("Retail alpha queue retained payload outside a frame."); - _nextSequence = 0; IsCollecting = true; } @@ -89,8 +89,7 @@ internal sealed class RetailAlphaQueue _submissions.Add(new RetailAlphaSubmission( source, token, - NormalizeDistance(viewerDistance), - _nextSequence++)); + NormalizeDistance(viewerDistance))); for (int i = 0; i < _sources.Count; i++) if (ReferenceEquals(_sources[i], source)) @@ -114,7 +113,7 @@ internal sealed class RetailAlphaQueue if (_submissions.Count == 0) return; - _submissions.Sort(CompareRetailOrder); + SortRetailOrder(); EnsureTokenCapacity(_submissions.Count); EnsureSourceCapacity(_sources.Count); Array.Clear(_sourceDrawOffsets, 0, _sources.Count); @@ -178,17 +177,61 @@ internal sealed class RetailAlphaQueue } } - private static int CompareRetailOrder(RetailAlphaSubmission left, RetailAlphaSubmission right) + private void SortRetailOrder() { - // CShadowPart::insertion_sort 0x006B5130 produces descending CYpt - // (far-to-near). Sequence makes the List.Sort result stable for equal - // distances, preserving part/surface append order like retail. - int distance = right.ViewerDistance.CompareTo(left.ViewerDistance); - return distance != 0 ? distance : left.Sequence.CompareTo(right.Sequence); + int count = _submissions.Count; + if (count <= 1) + return; + + EnsureSortCapacity(count); + Span submissions = CollectionsMarshal.AsSpan(_submissions); + Span scratch = _sortScratch.AsSpan(0, count); + + // Positive IEEE-754 float bits sort in the same order as their numeric + // values. Complementing those bits turns an ascending stable radix pass + // into retail's descending CYpt order. Four stable byte passes preserve + // original submission order for equal distances, matching + // CShadowPart::insertion_sort without List.Sort's O(n log n) interface + // comparator overhead in particle-heavy views. + RadixPass(submissions, scratch, shift: 0); + RadixPass(scratch, submissions, shift: 8); + RadixPass(submissions, scratch, shift: 16); + RadixPass(scratch, submissions, shift: 24); } private static float NormalizeDistance(float value) - => float.IsFinite(value) && value >= 0f ? value : 0f; + // value > 0 also canonicalizes -0 to +0 so its IEEE sort key joins the + // same stable equal-distance run as ordinary zero. + => float.IsFinite(value) && value > 0f ? value : 0f; + + private void RadixPass( + ReadOnlySpan source, + Span destination, + int shift) + { + Array.Clear(_radixOffsets); + for (int i = 0; i < source.Length; i++) + { + uint key = ~BitConverter.SingleToUInt32Bits(source[i].ViewerDistance); + _radixOffsets[(int)((key >> shift) & 0xFF)]++; + } + + int prefix = 0; + for (int bucket = 0; bucket < _radixOffsets.Length; bucket++) + { + int bucketCount = _radixOffsets[bucket]; + _radixOffsets[bucket] = prefix; + prefix += bucketCount; + } + + for (int i = 0; i < source.Length; i++) + { + RetailAlphaSubmission submission = source[i]; + uint key = ~BitConverter.SingleToUInt32Bits(submission.ViewerDistance); + int bucket = (int)((key >> shift) & 0xFF); + destination[_radixOffsets[bucket]++] = submission; + } + } private void EnsureTokenCapacity(int count) { @@ -204,6 +247,13 @@ internal sealed class RetailAlphaQueue Array.Resize(ref _sourceDrawOffsets, count + 4); } + private void EnsureSortCapacity(int count) + { + if (_sortScratch.Length >= count) + return; + Array.Resize(ref _sortScratch, count + 256); + } + private int FindSourceIndex(IRetailAlphaDrawSource source) { for (int i = 0; i < _sources.Count; i++) diff --git a/src/AcDream.App/Rendering/Wb/CachedBatch.cs b/src/AcDream.App/Rendering/Wb/CachedBatch.cs index e270e19c..6816d380 100644 --- a/src/AcDream.App/Rendering/Wb/CachedBatch.cs +++ b/src/AcDream.App/Rendering/Wb/CachedBatch.cs @@ -10,6 +10,9 @@ namespace AcDream.App.Rendering.Wb; /// subPart.PartTransform * meshRef.PartTransform product. /// preserves the authored GfxObj key used by /// retail's delayed-alpha viewer-distance ordering on cache hits. +/// is a registration-checked fast handle into the +/// dispatcher's bounded group table; a retired or synthetic entry falls back +/// to key lookup and refreshes this array entry. /// /// Accessibility: internal because is /// internal and shows up in this struct's constructor / Deconstruct @@ -21,7 +24,19 @@ internal readonly record struct CachedBatch( GroupKey Key, ulong BindlessTextureHandle, Matrix4x4 RestPose, - Vector3 LocalSortCenter = default); + Vector3 LocalSortCenter = default, + WbDrawDispatcher.InstanceGroup? Group = null, + long GroupRegistration = 0); + +/// +/// Immutable retail-picking descriptor for one static entity part. The cache +/// stores only authored identity and rest pose; the current entity transform +/// and the selection system's live frustum/geometry verdict remain per-frame. +/// +internal readonly record struct CachedSelectionPart( + int PartIndex, + uint GfxObjId, + Matrix4x4 RestPose); /// /// One entity's cached classification. is flat across @@ -39,4 +54,5 @@ internal sealed class EntityCacheEntry public required uint EntityId { get; init; } public required uint LandblockHint { get; init; } public required CachedBatch[] Batches { get; init; } + public CachedSelectionPart[] SelectionParts { get; init; } = []; } diff --git a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs index 925458a9..a37b289f 100644 --- a/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs +++ b/src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs @@ -76,13 +76,18 @@ internal sealed class EntityClassificationCache /// (, ) /// tuple. Defensive: if an entry already exists, replaces it. /// - public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches) + public void Populate( + uint entityId, + uint landblockHint, + CachedBatch[] batches, + CachedSelectionPart[]? selectionParts = null) { _entries[(entityId, landblockHint)] = new EntityCacheEntry { EntityId = entityId, LandblockHint = landblockHint, Batches = batches, + SelectionParts = selectionParts ?? [], }; } diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index ac46c942..d1f2956e 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -354,6 +354,29 @@ namespace AcDream.App.Rendering.Wb private readonly ManualResetEventSlim _preparationWorkAvailable = new(false); private const int MaxParallelLoads = 4; + internal enum PreparationWorkerWakeAction + { + Process, + ResetAndWait, + Exit, + } + + internal static PreparationWorkerWakeAction DecidePreparationWorkerWake( + bool isDisposed, + bool hasPendingRequests, + bool stagingAtHighWater, + bool arenaBackpressured) + { + // Shutdown has priority over every ordinary idle/backpressure state. + // Dispose sets one shared manual-reset signal for all persistent + // workers; no worker may reset that signal before its peers wake. + if (isDisposed) + return PreparationWorkerWakeAction.Exit; + if (!hasPendingRequests || stagingAtHighWater || arenaBackpressured) + return PreparationWorkerWakeAction.ResetAndWait; + return PreparationWorkerWakeAction.Process; + } + private sealed class ObjectReleaseTicket( ulong id, ObjectRenderData data, @@ -911,14 +934,20 @@ namespace AcDream.App.Rendering.Wb { // IsDisposed re-check lets Dispose cancel and join every // tracked worker before the DAT mappings are released. - if (IsDisposed - || _pendingRequests.Count == 0 - || _stagedMeshData.IsAtHighWater - || _arenaBackpressured) + // Exit WITHOUT resetting the shared manual-reset event: + // Dispose sets it once to wake all four persistent workers. + // If the first worker reset it, the remaining three slept + // forever and graceful client shutdown deadlocked. + PreparationWorkerWakeAction wakeAction = DecidePreparationWorkerWake( + IsDisposed, + _pendingRequests.Count != 0, + _stagedMeshData.IsAtHighWater, + _arenaBackpressured); + if (wakeAction == PreparationWorkerWakeAction.Exit) + return; + if (wakeAction == PreparationWorkerWakeAction.ResetAndWait) { _preparationWorkAvailable.Reset(); - if (IsDisposed) - return; continue; } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 98feef5c..e258f252 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -470,7 +470,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // This entity's selected point/spot light set — computed ONCE per entity at // the isNewEntity site (constant across the entity's parts/tuples), exactly // like _currentEntitySlot. -1 = unused slot. - private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject]; + private readonly int[] _currentEntityLightSetScratch = new int[LightManager.MaxLightsPerObject]; + private InstanceLightSet _currentEntityLightSet = InstanceLightSet.Disabled; // #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet, // parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires @@ -517,7 +518,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private CullMode[] _drawCullModes = new CullMode[256]; private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256]; private readonly List _groupInputScratch = new(256); - private readonly Action _cacheHitAppender; + private readonly List _retiredGroupKeys = new(); + private long _nextGroupRegistration = 1; + private long _groupFrame; private int _opaqueDrawCount; private int _transparentDrawCount; @@ -541,15 +544,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable GroupKey Key, Matrix4x4 Model, uint ClipSlot, - AlphaLightSet Lights, + InstanceLightSet Lights, uint Indoor, float Opacity, Vector2 SelectionLighting); - private readonly record struct AlphaLightSet( + internal readonly record struct InstanceLightSet( int L0, int L1, int L2, int L3, int L4, int L5, int L6, int L7) { + public static InstanceLightSet Disabled { get; } = new( + -1, -1, -1, -1, -1, -1, -1, -1); + + public static InstanceLightSet From(ReadOnlySpan source) + { + if (source.Length < LightManager.MaxLightsPerObject) + throw new ArgumentException("A retail object-light set requires eight entries.", nameof(source)); + + return new InstanceLightSet( + source[0], source[1], source[2], source[3], + source[4], source[5], source[6], source[7]); + } + + public void CopyTo(int[] destination, int offset) + { + destination[offset + 0] = L0; + destination[offset + 1] = L1; + destination[offset + 2] = L2; + destination[offset + 3] = L3; + destination[offset + 4] = L4; + destination[offset + 5] = L5; + destination[offset + 6] = L6; + destination[offset + 7] = L7; + } + public int this[int index] => index switch { 0 => L0, 1 => L1, 2 => L2, 3 => L3, @@ -590,6 +618,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // are contiguous — accumulate them all before flushing one Populate call. // Animated entities skip this scratch entirely (collector = null). private readonly List _populateScratch = new(); + private readonly List _populateSelectionScratch = new(); // Per-entity-cull AABB radius. Conservative — covers most entities; large // outliers (long banners, tall columns) are still landblock-culled. @@ -720,8 +749,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _selectionLighting = selectionSink as IRetailSelectionLightingSource; _alphaQueue = alphaQueue; _alphaSource = new AlphaDrawSource(this); - _cacheHitAppender = AppendInstanceToGroup; - _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); } @@ -735,6 +762,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable { if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) throw new ArgumentOutOfRangeException(nameof(frameSlot)); + if (_groupFrame == long.MaxValue) + throw new InvalidOperationException("Instance-group frame identity was exhausted."); + + _groupFrame++; + PruneInstanceGroupsUnusedBeforeFrame( + _groups, + _retiredGroupKeys, + _groupFrame - 1); _dynamicFrameSlot = frameSlot; _dynamicBufferSetCursor = 0; _dynamicFrameStarted = true; @@ -1317,7 +1352,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) grp.ClearPerInstanceData(); + // Draw is invoked several times per frame (landscape slices, late + // dynamics, paperdoll). Per-dispatch payloads reset here, while group + // retirement happens once in BeginFrame from whole-frame liveness. + foreach (InstanceGroup group in _groups.Values) + group.ClearPerInstanceData(); var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1467,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (populateEntityId.HasValue && currentEntityIncomplete) { _populateScratch.Clear(); + _populateSelectionScratch.Clear(); populateEntityId = null; } currentEntityIncomplete = false; @@ -1513,7 +1553,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // entities (including this-entity-culled) so the PREVIOUS entity's // cache always flushes at the boundary. (populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange( - populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch); + populateEntityId, populateLandblockId, entity.Id, _cache, + _populateScratch, _populateSelectionScratch); // Phase U.4: a culled entity (cell not visible, or no outdoors visible // for an outdoor stab) contributes NO instances. Skip after the @@ -1548,14 +1589,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // body via the lastHitEntityId == entity.Id check above. if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry)) { - ApplyCacheHit(cachedEntry!, entityWorld, _cacheHitAppender); + ApplyCacheHitDirect(cachedEntry!, entityWorld); // The cache is populated only after every MeshRef rendered // successfully. Publish the same parts for retail picking now; // CPhysicsPart::Draw only participates after the visible draw // path has accepted a real part. if (_selectionSink is not null) - PublishCachedSelectionParts(entity, entityWorld); + PublishCachedSelectionParts(cachedEntry!, entity, entityWorld); // anyVao recovery: when the first visible entity in the frame // takes the fast path, no slow-path lookup has populated @@ -1673,6 +1714,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // their MeshRefs; the flush at next-entity-boundary (or // end-of-loop) commits them as a single Populate call. var collector = isAnimated ? null : _populateScratch; + var selectionCollector = isAnimated ? null : _populateSelectionScratch; bool drewAny = false; if (renderData.IsSetup && renderData.SetupParts.Count > 0) @@ -1748,6 +1790,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)), (uint)partGfxObjId, model); + selectionCollector?.Add(new CachedSelectionPart( + unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)), + (uint)partGfxObjId, + restPose)); drewAny = true; } } @@ -1774,6 +1820,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable partIdx, (uint)gfxObjId, model); + selectionCollector?.Add(new CachedSelectionPart( + partIdx, + (uint)gfxObjId, + meshRef.PartTransform)); drewAny = true; } } @@ -1801,6 +1851,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (currentEntityIncomplete) { _populateScratch.Clear(); + _populateSelectionScratch.Clear(); populateEntityId = null; } @@ -1808,7 +1859,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // to trigger the entity-change flush, so commit its accumulated batches // here. No-op when the last entity was animated (populateEntityId stays // null) or when no entities walked at all. - FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch); + FinalFlushPopulate( + populateEntityId, populateLandblockId, _cache, + _populateScratch, _populateSelectionScratch); // §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance // histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the @@ -1829,8 +1882,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } // ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ── - int totalInstances = 0; - foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count; + bool deferTransparent = _alphaQueue?.IsCollecting == true; + var instanceCounts = PartitionInstanceGroups( + _groups.Values, + deferTransparent, + camPos, + _opaqueDraws, + _translucentDraws); + int totalInstances = instanceCounts.VisibleInstances; + int immediateInstances = instanceCounts.ImmediateInstances; if (totalInstances == 0) { LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0); @@ -1839,7 +1899,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return; } - int needed = totalInstances * 16; + _opaqueDraws.Sort(CompareOpaqueSubmissionOrder); + if (deferTransparent) + DeferTransparentGroups(camPos, vp); + else + _translucentDraws.Sort(CompareTransparentSubmissionOrder); + + int needed = immediateInstances * 16; if (_instanceData.Length < needed) _instanceData = new float[needed + 256 * 16]; @@ -1847,77 +1913,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // count and lay it out in the SAME group order / cursor as _instanceData, // so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On // the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3. - if (_clipSlotData.Length < totalInstances) - _clipSlotData = new uint[totalInstances + 256]; + if (_clipSlotData.Length < immediateInstances) + _clipSlotData = new uint[immediateInstances + 256]; // Fix B: per-instance light-set buffer, MaxLightsPerObject ints per // instance, laid out in the SAME group order / cursor as _instanceData // so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks // Instances[instanceIndex] (binding=0). - if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) - _lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; + if (_lightSetData.Length < immediateInstances * LightManager.MaxLightsPerObject) + _lightSetData = new int[(immediateInstances + 256) * LightManager.MaxLightsPerObject]; // #142: per-instance indoor flag buffer, one uint per instance, parallel to // _clipSlotData / _instanceData. Grown on demand like the others. - if (_indoorData.Length < totalInstances) - _indoorData = new uint[totalInstances + 256]; + if (_indoorData.Length < immediateInstances) + _indoorData = new uint[immediateInstances + 256]; // #188: per-instance opacity buffer, one float per instance, parallel to // _clipSlotData / _instanceData. Grown on demand like the others. - if (_alphaData.Length < totalInstances) - _alphaData = new float[totalInstances + 256]; + if (_alphaData.Length < immediateInstances) + _alphaData = new float[immediateInstances + 256]; - if (_selectionLightingData.Length < totalInstances) - _selectionLightingData = new Vector2[totalInstances + 256]; - - _opaqueDraws.Clear(); - _translucentDraws.Clear(); + if (_selectionLightingData.Length < immediateInstances) + _selectionLightingData = new Vector2[immediateInstances + 256]; int cursor = 0; - foreach (var grp in _groups.Values) + foreach (InstanceGroup grp in _opaqueDraws) + StageImmediateGroup(grp, ref cursor); + if (!deferTransparent) { - if (grp.Matrices.Count == 0) continue; - - grp.FirstInstance = cursor; - grp.InstanceCount = grp.Matrices.Count; - - // Use the first instance's translation as the group's representative - // position for front-to-back sort (perf #2). Cheap heuristic; works - // well when instances of one group are spatially coherent - // (typical for trees in one landblock area, NPCs at one spawn). - var first = grp.Matrices[0]; - var grpPos = new Vector3(first.M41, first.M42, first.M43); - grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos); - - for (int i = 0; i < grp.Matrices.Count; i++) - { - WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]); - // Slots[] is parallel to Matrices[] within the group; write the - // slot at the same cursor so binding=3 stays aligned with binding=0. - _clipSlotData[cursor] = grp.Slots[i]; - // Fix B: LightSets[] holds 8 ints per instance, parallel to - // Matrices[]; copy this instance's block to the same cursor so - // binding=5 stays aligned with binding=0. - int lsDst = cursor * LightManager.MaxLightsPerObject; - int lsSrc = i * LightManager.MaxLightsPerObject; - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - _lightSetData[lsDst + k] = grp.LightSets[lsSrc + k]; - // #142: IndoorFlags[] is parallel to Matrices[]; write at the same - // cursor so binding=6 instanceIndoor[] tracks binding=0 instances. - _indoorData[cursor] = grp.IndoorFlags[i]; - // #188: Opacities[] is parallel to Matrices[]; write at the same - // cursor so binding=7 instanceAlpha[] tracks binding=0 instances. - _alphaData[cursor] = grp.Opacities[i]; - // SmartBox CMaterial replacement, parallel to Matrices. - _selectionLightingData[cursor] = grp.SelectionLighting[i]; - cursor++; - } - - if (IsOpaque(grp.Translucency)) - _opaqueDraws.Add(grp); - else - _translucentDraws.Add(grp); + foreach (InstanceGroup grp in _translucentDraws) + StageImmediateGroup(grp, ref cursor); } + System.Diagnostics.Debug.Assert(cursor == immediateInstances); // Front-to-back sort within each cull mode. DrawIndirectRange must // split MDI calls whenever CullMode changes because GL state is not @@ -1931,13 +1958,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // submission so the shared queue can interleave it with particles. // Immediate mode remains for sealed off-screen consumers such as the // paperdoll and UI Studio render stack. - bool deferTransparent = _alphaQueue?.IsCollecting == true; - _opaqueDraws.Sort(CompareOpaqueSubmissionOrder); - if (deferTransparent) - DeferTransparentGroups(camPos, vp); - else - _translucentDraws.Sort(CompareTransparentSubmissionOrder); - // ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent), // fill via BuildIndirectArrays ────────────────────────────────── int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count; @@ -1998,7 +2018,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable ActivateNextDynamicBufferSet(); fixed (float* ip = _instanceData) UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, - ip, totalInstances * 16 * sizeof(float)); + ip, immediateInstances * 16 * sizeof(float)); fixed (BatchData* bp = _batchData) UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, @@ -2009,33 +2029,33 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the // U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to // U.3); under indoor routing it holds the per-instance slot from - // ResolveEntitySlot. No clear here — Phase 3 wrote exactly totalInstances - // entries; only [0..totalInstances) is uploaded, so any stale tail is - // never read by the shader (BaseInstance + gl_InstanceID < totalInstances). + // ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances + // entries; only [0..immediateInstances) is uploaded, so any stale tail is + // never read by the shader. fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, - sp, totalInstances * sizeof(uint)); + sp, immediateInstances * sizeof(uint)); // #142: per-instance indoor flag buffer (binding=6), one uint per instance, - // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) + // laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances) // is uploaded — stale tail never read (same guarantee as clip-slot above). fixed (uint* dp = _indoorData) UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, - dp, totalInstances * sizeof(uint)); + dp, immediateInstances * sizeof(uint)); // #188: per-instance opacity buffer (binding=7), one float per instance, - // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) + // laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances) // is uploaded — stale tail never read (same guarantee as clip-slot above). fixed (float* ap = _alphaData) UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, - ap, totalInstances * sizeof(float)); + ap, immediateInstances * sizeof(float)); // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert // reads this only for the object path (uLightingMode=0), so EnvCell's // independent mode-1 renderer does not consume this binding. fixed (Vector2* hp = _selectionLightingData) UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, - hp, totalInstances * sizeof(float) * 2); + hp, immediateInstances * sizeof(float) * 2); // Fix B: global point-light buffer (binding=4) + per-instance light-set // buffer (binding=5). The global buffer is this frame's PointSnapshot; the @@ -2045,7 +2065,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable UploadGlobalLights(); fixed (int* lp = _lightSetData) UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, - lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); + lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int)); fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { @@ -2234,41 +2254,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable set: set); } - private void PublishCachedSelectionParts(WorldEntity entity, Matrix4x4 entityWorld) + private void PublishCachedSelectionParts( + EntityCacheEntry cachedEntry, + WorldEntity entity, + Matrix4x4 entityWorld) { - for (int outerPartIndex = 0; outerPartIndex < entity.MeshRefs.Count; outerPartIndex++) + foreach (CachedSelectionPart part in cachedEntry.SelectionParts) { - var meshRef = entity.MeshRefs[outerPartIndex]; - var renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId); - if (renderData is null) - continue; - - if (!renderData.IsSetup || renderData.SetupParts.Count == 0) - { - _selectionSink!.AddVisiblePart( - entity, - outerPartIndex, - meshRef.GfxObjId, - meshRef.PartTransform * entityWorld); - continue; - } - - for (int setupPartIndex = 0; - setupPartIndex < renderData.SetupParts.Count; - setupPartIndex++) - { - var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex]; - if (_meshAdapter.TryGetRenderData(partGfxObjId) is null) - continue; - _selectionSink!.AddVisiblePart( - entity, - unchecked((outerPartIndex << 16) | (setupPartIndex & 0xFFFF)), - (uint)partGfxObjId, - ComposePartWorldMatrix( - entityWorld, - meshRef.PartTransform, - partTransform)); - } + _selectionSink!.AddVisiblePart( + entity, + part.PartIndex, + part.GfxObjId, + part.RestPose * entityWorld); } } @@ -2283,6 +2280,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Translucency: g.Translucency, CullMode: g.CullMode); + internal readonly record struct InstanceLayoutCounts( + int VisibleInstances, + int ImmediateInstances); + + internal static InstanceLayoutCounts PartitionInstanceGroups( + IEnumerable groups, + bool deferTransparent, + Vector3 cameraWorldPosition, + List opaque, + List transparent) + { + opaque.Clear(); + transparent.Clear(); + + int visibleInstances = 0; + int immediateInstances = 0; + foreach (InstanceGroup group in groups) + { + int count = group.Matrices.Count; + if (count == 0) + continue; + + group.InstanceCount = count; + Matrix4x4 first = group.Matrices[0]; + var groupPosition = new Vector3(first.M41, first.M42, first.M43); + group.SortDistance = Vector3.DistanceSquared(cameraWorldPosition, groupPosition); + visibleInstances += count; + + if (IsOpaque(group.Translucency)) + { + opaque.Add(group); + immediateInstances += count; + } + else + { + transparent.Add(group); + if (!deferTransparent) + immediateInstances += count; + } + } + + return new InstanceLayoutCounts(visibleInstances, immediateInstances); + } + + private void StageImmediateGroup(InstanceGroup group, ref int cursor) + { + group.FirstInstance = cursor; + for (int i = 0; i < group.Matrices.Count; i++) + { + WriteMatrix(_instanceData, cursor * 16, group.Matrices[i]); + _clipSlotData[cursor] = group.Slots[i]; + group.LightSets[i].CopyTo( + _lightSetData, + cursor * LightManager.MaxLightsPerObject); + _indoorData[cursor] = group.IndoorFlags[i]; + _alphaData[cursor] = group.Opacities[i]; + _selectionLightingData[cursor] = group.SelectionLighting[i]; + cursor++; + } + } + private static GroupKey ToKey(InstanceGroup g) => new( g.FirstIndex, g.BaseVertex, @@ -2312,12 +2370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable localSortCenter, model, cameraWorldPosition); - int lightOffset = i * LightManager.MaxLightsPerObject; - var lights = new AlphaLightSet( - group.LightSets[lightOffset + 0], group.LightSets[lightOffset + 1], - group.LightSets[lightOffset + 2], group.LightSets[lightOffset + 3], - group.LightSets[lightOffset + 4], group.LightSets[lightOffset + 5], - group.LightSets[lightOffset + 6], group.LightSets[lightOffset + 7]); + InstanceLightSet lights = group.LightSets[i]; int token = _deferredAlpha.Count; _deferredAlpha.Add(new DeferredAlphaInstance( @@ -2353,8 +2406,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _alphaData[i] = entry.Opacity; _selectionLightingData[i] = entry.SelectionLighting; int lightOffset = i * LightManager.MaxLightsPerObject; - for (int light = 0; light < LightManager.MaxLightsPerObject; light++) - _lightSetData[lightOffset + light] = entry.Lights[light]; + entry.Lights.CopyTo(_lightSetData, lightOffset); GroupKey key = entry.Key; _batchData[i] = new BatchData @@ -2876,6 +2928,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } + internal static bool TryResolveCachedGroup( + CachedBatch cached, + out InstanceGroup? group) + { + group = cached.Group; + return group is not null + && cached.GroupRegistration != 0 + && cached.GroupRegistration == group.Registration; + } + + private void ApplyCacheHitDirect(EntityCacheEntry entry, Matrix4x4 entityWorld) + { + for (int i = 0; i < entry.Batches.Length; i++) + { + CachedBatch cached = entry.Batches[i]; + Matrix4x4 model = cached.RestPose * entityWorld; + if (!TryResolveCachedGroup(cached, out InstanceGroup? group)) + { + group = GetOrCreateInstanceGroup(cached.Key); + entry.Batches[i] = cached with + { + Group = group, + GroupRegistration = group.Registration, + }; + } + AppendInstanceToGroup(group!, model, cached.LocalSortCenter); + } + } + + /// + /// Retires groups that were absent for the entire preceding frame. + /// Retiring sets the registration to zero before the dictionary reference + /// is removed, invalidating every cached direct handle to that exact group + /// without invalidating unrelated live groups. The retired list storage is + /// released because stale classification entries may retain the small group + /// object until their next cache hit. + /// + internal static int PruneInstanceGroupsUnusedBeforeFrame( + Dictionary groups, + List retiredKeys, + long oldestLiveFrame) + { + retiredKeys.Clear(); + foreach ((GroupKey key, InstanceGroup group) in groups) + { + if (group.LastUsedFrame < oldestLiveFrame) + { + group.Registration = 0; + group.ReleasePerInstanceStorage(); + retiredKeys.Add(key); + } + } + + foreach (GroupKey key in retiredKeys) + groups.Remove(key); + + int retiredCount = retiredKeys.Count; + retiredKeys.Clear(); + return retiredCount; + } + /// /// Per-tuple flush check. If is set /// AND differs from , the previous @@ -2897,15 +3010,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable uint populateLandblockId, uint currentEntityId, EntityClassificationCache cache, - List populateScratch) + List populateScratch, + List? selectionScratch = null) { if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId) { if (populateScratch.Count > 0) { - cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray()); + cache.Populate( + populateEntityId.Value, + populateLandblockId, + populateScratch.ToArray(), + selectionScratch?.ToArray()); } populateScratch.Clear(); + selectionScratch?.Clear(); return (null, 0u); } return (populateEntityId, populateLandblockId); @@ -2925,13 +3044,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable uint? populateEntityId, uint populateLandblockId, EntityClassificationCache cache, - List populateScratch) + List populateScratch, + List? selectionScratch = null) { if (populateEntityId.HasValue && populateScratch.Count > 0) { - cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray()); + cache.Populate( + populateEntityId.Value, + populateLandblockId, + populateScratch.ToArray(), + selectionScratch?.ToArray()); populateScratch.Clear(); } + selectionScratch?.Clear(); } /// @@ -2941,20 +3066,46 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter) { - if (!_groups.TryGetValue(key, out var grp)) + InstanceGroup grp = GetOrCreateInstanceGroup(key); + AppendInstanceToGroup(grp, model, localSortCenter); + } + + private InstanceGroup GetOrCreateInstanceGroup(GroupKey key) + { + if (_groups.TryGetValue(key, out InstanceGroup? group)) { - grp = new InstanceGroup - { - FirstIndex = key.FirstIndex, - BaseVertex = key.BaseVertex, - IndexCount = key.IndexCount, - BindlessTextureHandle = key.BindlessTextureHandle, - TextureLayer = key.TextureLayer, - Translucency = key.Translucency, - CullMode = key.CullMode, - }; - _groups[key] = grp; + group.LastUsedFrame = _groupFrame; + return group; } + + if (_nextGroupRegistration == long.MaxValue) + { + throw new InvalidOperationException( + "Instance-group registration space was exhausted before a safe identity could be assigned."); + } + + group = new InstanceGroup + { + FirstIndex = key.FirstIndex, + BaseVertex = key.BaseVertex, + IndexCount = key.IndexCount, + BindlessTextureHandle = key.BindlessTextureHandle, + TextureLayer = key.TextureLayer, + Translucency = key.Translucency, + CullMode = key.CullMode, + Registration = _nextGroupRegistration++, + LastUsedFrame = _groupFrame, + }; + _groups.Add(key, group); + return group; + } + + private void AppendInstanceToGroup( + InstanceGroup grp, + Matrix4x4 model, + Vector3 localSortCenter) + { + grp.LastUsedFrame = _groupFrame; grp.Matrices.Add(model); grp.LocalSortCenters.Add(localSortCenter); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices @@ -3014,7 +3165,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable entity.Position.X, entity.Position.Y, entity.Position.Z, _currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0); bool any = false; - for (int k = 0; k < _currentEntityLightSet.Length; k++) + for (int k = 0; k < LightManager.MaxLightsPerObject; k++) { int idx = _currentEntityLightSet[k]; if (idx < 0) continue; @@ -3040,7 +3191,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // so they can't disagree — one call, one truth. _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); - Array.Fill(_currentEntityLightSet, -1); + _currentEntityLightSet = InstanceLightSet.Disabled; var snap = _pointSnapshot; if (snap is null || snap.Count == 0) return; @@ -3050,7 +3201,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (entity.AabbDirty) entity.RefreshAabb(); Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; - LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); + Array.Fill(_currentEntityLightSetScratch, -1); + LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch); + _currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch); } /// @@ -3076,8 +3229,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable /// private void AppendCurrentLightSet(InstanceGroup grp) { - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - grp.LightSets.Add(_currentEntityLightSet[k]); + grp.LightSets.Add(_currentEntityLightSet); grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block } @@ -3133,27 +3285,20 @@ public sealed unsafe class WbDrawDispatcher : IDisposable batch.FirstIndex, (int)batch.BaseVertex, batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode); - if (!_groups.TryGetValue(key, out var grp)) - { - grp = new InstanceGroup - { - FirstIndex = batch.FirstIndex, - BaseVertex = (int)batch.BaseVertex, - IndexCount = batch.IndexCount, - BindlessTextureHandle = texHandle, - TextureLayer = texLayer, - Translucency = translucency, - CullMode = batch.CullMode, - }; - _groups[key] = grp; - } + InstanceGroup grp = GetOrCreateInstanceGroup(key); grp.Matrices.Add(model); grp.LocalSortCenters.Add(renderData.SortCenter); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices grp.SelectionLighting.Add(_currentEntitySelectionLighting); - collector?.Add(new CachedBatch(key, texHandle, restPose, renderData.SortCenter)); + collector?.Add(new CachedBatch( + key, + texHandle, + restPose, + renderData.SortCenter, + grp, + grp.Registration)); } return allTexturesReady; } @@ -3577,6 +3722,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable internal sealed class InstanceGroup { + // Nonzero only while this exact object is registered in _groups. + // CachedBatch stores the value alongside the reference; retirement + // zeros it before removal so stale handles cannot append off-table. + public long Registration; + public long LastUsedFrame; public uint FirstIndex; public int BaseVertex; public int IndexCount; @@ -3601,13 +3751,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // so the binding=3 instanceClipSlot[] tracks the binding=0 instance. public readonly List Slots = new(); - // Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per - // instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected - // light index block for the instance whose matrix is Matrices[i]). At + // Fix B (A7 #3): one packed eight-index light set per instance, parallel + // to Matrices (LightSets[i] belongs to Matrices[i]). At // layout time the dispatcher copies each block into _lightSetData at the // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 // instance. -1 = unused slot. - public readonly List LightSets = new(); + public readonly List LightSets = new(); // #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is // 1 when the instance's entity is parented to an EnvCell (skip the sun); 0 @@ -3646,5 +3795,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable Opacities.Clear(); SelectionLighting.Clear(); } + + public void ReleasePerInstanceStorage() + { + ClearPerInstanceData(); + Matrices.TrimExcess(); + LocalSortCenters.TrimExcess(); + Slots.TrimExcess(); + LightSets.TrimExcess(); + IndoorFlags.TrimExcess(); + Opacities.TrimExcess(); + SelectionLighting.TrimExcess(); + } } } diff --git a/src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs b/src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs new file mode 100644 index 00000000..53043297 --- /dev/null +++ b/src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs @@ -0,0 +1,169 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; + +namespace AcDream.App.Rendering; + +/// +/// Low-CPU, sub-millisecond-capable deadline wait for the Windows render loop. +/// One auto-reset timer is reused for the complete window lifetime. +/// +/// +/// +/// A normal is quantized by the Windows scheduler. +/// At a 165 Hz presentation target, its variable overshoot turned a requested +/// 6.1 ms frame into a 10-16 ms frame. A high-resolution waitable timer keeps +/// the render thread blocked in the kernel without that coarse sleep quantum. +/// +/// +/// CREATE_WAITABLE_TIMER_HIGH_RESOLUTION is available on Windows 10 +/// version 1803 and newer, which is the client's supported desktop baseline. +/// Microsoft documents relative due times as negative 100-nanosecond units. +/// +/// +internal sealed partial class WindowsHighResolutionFramePacingWaiter : + IFramePacingWaiter, + IDisposable +{ + private const uint CreateWaitableTimerHighResolution = 0x00000002; + private const uint TimerModifyState = 0x00000002; + private const uint Synchronize = 0x00100000; + private const uint WaitObject0 = 0x00000000; + private const uint WaitTimeout = 0x00000102; + private const uint WaitFailed = 0xFFFFFFFF; + private const long HundredNanosecondsPerSecond = 10_000_000; + + private readonly SafeWaitHandle _timer; + private bool _disposed; + + private WindowsHighResolutionFramePacingWaiter(SafeWaitHandle timer) + => _timer = timer; + + public static WindowsHighResolutionFramePacingWaiter Create() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134)) + { + throw new PlatformNotSupportedException( + "acdream software frame pacing requires Windows 10 version 1803 or newer."); + } + + SafeWaitHandle timer = CreateWaitableTimerExW( + 0, + 0, + CreateWaitableTimerHighResolution, + TimerModifyState | Synchronize); + if (timer.IsInvalid) + { + int error = Marshal.GetLastPInvokeError(); + timer.Dispose(); + throw new Win32Exception(error, "Could not create the high-resolution frame timer."); + } + + return new WindowsHighResolutionFramePacingWaiter(timer); + } + + public void Wait(long durationTicks, long clockFrequency) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (durationTicks <= 0 || clockFrequency <= 0) + return; + + long hundredNanoseconds = ConvertTicksToHundredNanoseconds( + durationTicks, + clockFrequency); + long relativeDueTime = -hundredNanoseconds; + + if (!SetWaitableTimerEx( + _timer, + in relativeDueTime, + periodMilliseconds: 0, + completionRoutine: 0, + completionArgument: 0, + wakeContext: 0, + tolerableDelayMilliseconds: 0)) + { + throw new Win32Exception( + Marshal.GetLastPInvokeError(), + "Could not arm the high-resolution frame timer."); + } + + uint result = WaitForSingleObject( + _timer, + ComputeFailureTimeoutMilliseconds(hundredNanoseconds)); + if (result == WaitObject0) + return; + if (result == WaitTimeout) + { + throw new TimeoutException( + "The high-resolution frame timer did not signal before its safety timeout."); + } + + int waitError = result == WaitFailed + ? Marshal.GetLastPInvokeError() + : unchecked((int)result); + throw new Win32Exception(waitError, "Waiting on the high-resolution frame timer failed."); + } + + internal static long ConvertTicksToHundredNanoseconds( + long durationTicks, + long clockFrequency) + { + if (durationTicks <= 0) + throw new ArgumentOutOfRangeException(nameof(durationTicks)); + if (clockFrequency <= 0) + throw new ArgumentOutOfRangeException(nameof(clockFrequency)); + + long wholeSeconds = Math.DivRem(durationTicks, clockFrequency, out long remainder); + if (wholeSeconds >= long.MaxValue / HundredNanosecondsPerSecond) + return long.MaxValue; + + long wholeIntervals = wholeSeconds * HundredNanosecondsPerSecond; + long fractionalIntervals = (long)Math.Ceiling( + remainder * (double)HundredNanosecondsPerSecond / clockFrequency); + if (wholeIntervals > long.MaxValue - fractionalIntervals) + return long.MaxValue; + + return Math.Max(1, wholeIntervals + fractionalIntervals); + } + + private static uint ComputeFailureTimeoutMilliseconds(long hundredNanoseconds) + { + double dueMilliseconds = hundredNanoseconds / 10_000d; + double timeoutMilliseconds = Math.Ceiling(dueMilliseconds) + 1_000d; + return timeoutMilliseconds >= uint.MaxValue - 1d + ? uint.MaxValue - 1 + : Math.Max(1u, (uint)timeoutMilliseconds); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + _timer.Dispose(); + } + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial SafeWaitHandle CreateWaitableTimerExW( + nint timerAttributes, + nint timerName, + uint flags, + uint desiredAccess); + + [LibraryImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetWaitableTimerEx( + SafeWaitHandle timer, + in long dueTime, + int periodMilliseconds, + nint completionRoutine, + nint completionArgument, + nint wakeContext, + uint tolerableDelayMilliseconds); + + [LibraryImport("kernel32.dll", SetLastError = true)] + private static partial uint WaitForSingleObject( + SafeWaitHandle handle, + uint milliseconds); +} diff --git a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs b/tests/AcDream.App.Tests/FrameProfilerReportTests.cs index 1a087074..084d0446 100644 --- a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs +++ b/tests/AcDream.App.Tests/FrameProfilerReportTests.cs @@ -11,7 +11,13 @@ public class FrameProfilerReportTests var cpu = new FrameStatsBuffer(16); var gpu = new FrameStatsBuffer(16); var alloc = new FrameStatsBuffer(16); - var stages = new[] { new FrameStatsBuffer(16), new FrameStatsBuffer(16), new FrameStatsBuffer(16) }; + var stages = new[] + { + new FrameStatsBuffer(16), + new FrameStatsBuffer(16), + new FrameStatsBuffer(16), + new FrameStatsBuffer(16), + }; for (long i = 1; i <= 10; i++) { cpu.Push(i * 1000); // 1..10 ms in µs @@ -20,6 +26,7 @@ public class FrameProfilerReportTests stages[0].Push(i * 200); stages[1].Push(i * 50); stages[2].Push(i * 10); + stages[3].Push(i * 300); } string line = FrameProfiler.FormatReport( @@ -33,6 +40,7 @@ public class FrameProfilerReportTests Assert.Contains("alloc_kb p50=5.0 max=10.0", line); Assert.Contains("gc=3/1/0", line); Assert.Contains("upd p50=1.0", line); // stage 0: 200µs·5 = 1.0 ms + Assert.Contains("pace p50=1.5", line); Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture) } diff --git a/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs b/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs index 4b45d652..fded1c1b 100644 --- a/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/FramePacingControllerTests.cs @@ -91,6 +91,62 @@ public sealed class FramePacingControllerTests Assert.Equal([25L], waiter.Durations); } + [Theory] + [InlineData(1L, 10_000_000L, 1L)] + [InlineData(1L, 3L, 3_333_334L)] + [InlineData(6_000L, 1_000_000L, 60_000L)] + [InlineData(1_000L, 1_000L, 10_000_000L)] + public void High_resolution_timer_conversion_rounds_up_without_losing_time( + long durationTicks, + long frequency, + long expectedHundredNanoseconds) + { + Assert.Equal( + expectedHundredNanoseconds, + WindowsHighResolutionFramePacingWaiter.ConvertTicksToHundredNanoseconds( + durationTicks, + frequency)); + } + + [Fact] + public void High_resolution_timer_conversion_saturates_instead_of_overflowing() + { + Assert.Equal( + long.MaxValue, + WindowsHighResolutionFramePacingWaiter.ConvertTicksToHundredNanoseconds( + long.MaxValue, + clockFrequency: 1)); + } + + [Fact] + public void Windows_high_resolution_timer_arms_and_disposes_its_handle() + { + if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134)) + return; + + using var timer = WindowsHighResolutionFramePacingWaiter.Create(); + timer.Wait(1, System.Diagnostics.Stopwatch.Frequency); + + timer.Dispose(); + timer.Dispose(); + Assert.Throws(() => timer.Wait(1, 1)); + } + + [Fact] + public void Controller_disposes_owned_waiter_exactly_once() + { + var waiter = new DisposableWaiter(); + var controller = new FramePacingController( + new FakeClock(frequency: 1_000), + waiter, + ownsWaiter: true); + + controller.Dispose(); + controller.Dispose(); + + Assert.Equal(1, waiter.DisposeCount); + } + private sealed class FakeClock(long frequency) : IFramePacingClock { public long Frequency { get; } = frequency; @@ -113,4 +169,15 @@ public sealed class FramePacingControllerTests clock.Timestamp += durationTicks + OvershootTicks; } } + + private sealed class DisposableWaiter : IFramePacingWaiter, IDisposable + { + public int DisposeCount { get; private set; } + + public void Wait(long durationTicks, long clockFrequency) + { + } + + public void Dispose() => DisposeCount++; + } } diff --git a/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs b/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs index 3786e6b5..3d0ec77a 100644 --- a/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs +++ b/tests/AcDream.App.Tests/Rendering/FramePacingPolicyTests.cs @@ -20,6 +20,7 @@ public sealed class FramePacingPolicyTests [InlineData(60)] [InlineData(120)] [InlineData(144)] + [InlineData(240)] public void Normal_VSync_off_uses_active_monitor_refresh_limit(int refreshHz) { var policy = FramePacingPolicy.Resolve( diff --git a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs index de25a7ba..bad2df97 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailAlphaQueueTests.cs @@ -94,6 +94,91 @@ public sealed class RetailAlphaQueueTests Assert.Equal(new[] { 1 }, particles.BatchSizes); } + [Fact] + public void Flush_LargeInputMatchesStableDescendingRetailOrder() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + var random = new Random(0xAC2013); + float[] distances = new float[4_096]; + + queue.BeginFrame(); + for (int i = 0; i < distances.Length; i++) + { + distances[i] = i % 127 switch + { + 0 => float.NaN, + 1 => float.PositiveInfinity, + 2 => -0f, + 3 => -1f, + _ => random.Next(0, 64), + }; + queue.Submit(source, i, distances[i]); + } + queue.EndFrame(); + + int[] expected = Enumerable.Range(0, distances.Length) + .OrderByDescending(i => NormalizeForReference(distances[i])) + .ToArray(); + Assert.Equal(expected, source.PreparedTokens); + } + + [Fact] + public void Flush_ExtremeFloatKeysMatchNormalizedRetailOrder() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + float[] distances = + [ + 1f, + float.BitIncrement(1f), + float.BitDecrement(1f), + float.Epsilon, + float.MaxValue, + 0f, + -0f, + float.NaN, + float.PositiveInfinity, + float.NegativeInfinity, + -float.Epsilon, + 1f, + ]; + + queue.BeginFrame(); + for (int i = 0; i < distances.Length; i++) + queue.Submit(source, i, distances[i]); + queue.EndFrame(); + + int[] expected = Enumerable.Range(0, distances.Length) + .OrderByDescending(i => NormalizeForReference(distances[i])) + .ToArray(); + Assert.Equal(expected, source.PreparedTokens); + } + + [Fact] + public void Flush_EqualDistanceOrderStartsFreshInEachFrameScope() + { + var log = new List(); + var source = new RecordingSource("alpha", log); + var queue = new RetailAlphaQueue(); + + queue.BeginFrame(); + queue.Submit(source, 1, 12f); + queue.Submit(source, 2, 12f); + queue.EndFrame(); + + queue.BeginFrame(); + queue.Submit(source, 9, 12f); + queue.Submit(source, 8, 12f); + queue.EndFrame(); + + Assert.Equal( + ["alpha:1", "alpha:2", "alpha:9", "alpha:8"], + log); + } + [Fact] public void ComputeViewerDistance_UsesTransformedGfxSortCenter() { @@ -113,11 +198,15 @@ public sealed class RetailAlphaQueueTests Assert.Equal(2f, cypt, precision: 5); } + private static float NormalizeForReference(float value) + => float.IsFinite(value) && value > 0f ? value : 0f; + private sealed class RecordingSource(string name, List log) : IRetailAlphaDrawSource { public List BatchSizes { get; } = new(); public int ResetCount { get; private set; } public int PrepareCount { get; private set; } + public IReadOnlyList PreparedTokens => _prepared; private int[] _prepared = []; public void PrepareAlphaDraws(ReadOnlySpan tokens) diff --git a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs index 31946a75..440a8837 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/InstanceGroupClearTests.cs @@ -1,11 +1,215 @@ using System.Numerics; using AcDream.App.Rendering.Wb; +using AcDream.Core.Meshing; using Xunit; namespace AcDream.App.Tests.Rendering.Wb; public class InstanceGroupClearTests { + [Fact] + public void ClassificationCache_PreservesImmutableSelectionPartDescriptors() + { + var cache = new EntityClassificationCache(); + Matrix4x4 restPose = + Matrix4x4.CreateRotationZ(0.75f) * + Matrix4x4.CreateTranslation(1f, 2f, 3f); + CachedSelectionPart[] parts = + [ + new CachedSelectionPart(0x0001_0002, 0x01001234u, restPose), + ]; + GroupKey key = MakeKey(0xAA); + + cache.Populate( + entityId: 100, + landblockHint: 0xA9B40000u, + batches: [new CachedBatch(key, 0xAA, Matrix4x4.Identity)], + selectionParts: parts); + + Assert.True(cache.TryGet(100, 0xA9B40000u, out EntityCacheEntry? entry)); + CachedSelectionPart part = Assert.Single(entry!.SelectionParts); + Assert.Equal(parts[0], part); + + Matrix4x4 entityWorld = Matrix4x4.CreateTranslation(20f, 30f, 40f); + Assert.Equal(restPose * entityWorld, part.RestPose * entityWorld); + } + + [Fact] + public void InstanceLightSet_PacksAndCopiesAllRetailSlotsInOrder() + { + int[] source = [3, 7, 11, 15, 19, 23, 27, 31]; + var packed = WbDrawDispatcher.InstanceLightSet.From(source); + int[] destination = Enumerable.Repeat(-99, 12).ToArray(); + + packed.CopyTo(destination, 2); + + Assert.Equal(source, destination[2..10]); + Assert.Equal(-99, destination[1]); + Assert.Equal(-99, destination[10]); + } + + [Fact] + public void PartitionInstanceGroups_DeferredAlphaIsNotStagedInImmediateBuffers() + { + var opaqueGroup = MakeGroup(TranslucencyKind.Opaque, 2, new Vector3(3f, 4f, 0f)); + var alphaGroup = MakeGroup(TranslucencyKind.AlphaBlend, 3, new Vector3(0f, 0f, 10f)); + var additiveGroup = MakeGroup(TranslucencyKind.Additive, 1, new Vector3(1f, 0f, 0f)); + var emptyGroup = new WbDrawDispatcher.InstanceGroup + { + Translucency = TranslucencyKind.Opaque, + }; + var opaque = new List(); + var transparent = new List(); + + var deferred = WbDrawDispatcher.PartitionInstanceGroups( + [opaqueGroup, alphaGroup, additiveGroup, emptyGroup], + deferTransparent: true, + cameraWorldPosition: Vector3.Zero, + opaque, + transparent); + + Assert.Equal(6, deferred.VisibleInstances); + Assert.Equal(2, deferred.ImmediateInstances); + Assert.Equal([opaqueGroup], opaque); + Assert.Equal([alphaGroup, additiveGroup], transparent); + Assert.Equal(25f, opaqueGroup.SortDistance); + Assert.Equal(100f, alphaGroup.SortDistance); + + var immediate = WbDrawDispatcher.PartitionInstanceGroups( + [opaqueGroup, alphaGroup, additiveGroup], + deferTransparent: false, + cameraWorldPosition: Vector3.Zero, + opaque, + transparent); + + Assert.Equal(6, immediate.VisibleInstances); + Assert.Equal(6, immediate.ImmediateInstances); + } + + [Fact] + public void CachedGroupHandle_RequiresLiveMatchingRegistration() + { + var group = new WbDrawDispatcher.InstanceGroup { Registration = 17 }; + var key = new GroupKey( + FirstIndex: 0, + BaseVertex: 0, + IndexCount: 6, + BindlessTextureHandle: 0xAA, + TextureLayer: 0, + Translucency: TranslucencyKind.Opaque); + var cached = new CachedBatch( + key, + 0xAA, + Matrix4x4.Identity, + Group: group, + GroupRegistration: 17); + + Assert.True(WbDrawDispatcher.TryResolveCachedGroup(cached, out var resolved)); + Assert.Same(group, resolved); + group.Registration = 0; + Assert.False(WbDrawDispatcher.TryResolveCachedGroup(cached, out _)); + Assert.False(WbDrawDispatcher.TryResolveCachedGroup( + cached with { Group = null }, + out _)); + } + + [Fact] + public void FramePrune_RetiresOnlyGroupsAbsentForWholePreviousFrame() + { + GroupKey liveKey = MakeKey(0xAA); + GroupKey retiredKey = MakeKey(0xBB); + var live = new WbDrawDispatcher.InstanceGroup + { + Registration = 11, + LastUsedFrame = 9, + }; + live.Matrices.Add(Matrix4x4.Identity); + var retired = new WbDrawDispatcher.InstanceGroup + { + Registration = 12, + LastUsedFrame = 8, + }; + retired.Matrices.Capacity = 64; + var groups = new Dictionary + { + [liveKey] = live, + [retiredKey] = retired, + }; + var retiredKeys = new List(); + var staleCache = new CachedBatch( + retiredKey, + retiredKey.BindlessTextureHandle, + Matrix4x4.Identity, + Group: retired, + GroupRegistration: retired.Registration); + + int retiredCount = WbDrawDispatcher.PruneInstanceGroupsUnusedBeforeFrame( + groups, + retiredKeys, + oldestLiveFrame: 9); + + Assert.Equal(1, retiredCount); + Assert.Single(groups); + Assert.Same(live, groups[liveKey]); + Assert.Single(live.Matrices); + Assert.Equal(11, live.Registration); + Assert.Equal(0, retired.Registration); + Assert.Equal(0, retired.Matrices.Capacity); + Assert.False(WbDrawDispatcher.TryResolveCachedGroup(staleCache, out _)); + Assert.Empty(retiredKeys); + } + + [Fact] + public void FramePrune_KeepsGroupsUsedByDifferentDrawScopesInSameFrame() + { + GroupKey landscapeKey = MakeKey(0xAA); + GroupKey paperdollKey = MakeKey(0xBB); + var landscape = new WbDrawDispatcher.InstanceGroup + { + Registration = 21, + LastUsedFrame = 14, + }; + var paperdoll = new WbDrawDispatcher.InstanceGroup + { + Registration = 22, + LastUsedFrame = 14, + }; + var groups = new Dictionary + { + [landscapeKey] = landscape, + [paperdollKey] = paperdoll, + }; + + int retiredCount = WbDrawDispatcher.PruneInstanceGroupsUnusedBeforeFrame( + groups, + [], + oldestLiveFrame: 14); + + Assert.Equal(0, retiredCount); + Assert.Equal(2, groups.Count); + Assert.Equal(21, landscape.Registration); + Assert.Equal(22, paperdoll.Registration); + } + + private static GroupKey MakeKey(ulong textureHandle) => new( + FirstIndex: 0, + BaseVertex: 0, + IndexCount: 6, + BindlessTextureHandle: textureHandle, + TextureLayer: 0, + Translucency: TranslucencyKind.Opaque); + + private static WbDrawDispatcher.InstanceGroup MakeGroup( + TranslucencyKind translucency, + int instanceCount, + Vector3 firstPosition) + { + var group = new WbDrawDispatcher.InstanceGroup { Translucency = translucency }; + for (int i = 0; i < instanceCount; i++) + group.Matrices.Add(Matrix4x4.CreateTranslation(firstPosition + new Vector3(i, 0f, 0f))); + return group; + } + // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds // seven per-instance parallel lists — Matrices, LocalSortCenters, Slots, // LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep @@ -22,7 +226,7 @@ public class InstanceGroupClearTests grp.Matrices.Add(Matrix4x4.Identity); grp.LocalSortCenters.Add(Vector3.Zero); grp.Slots.Add(1u); - grp.LightSets.Add(-1); + grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled); grp.IndoorFlags.Add(0u); grp.Opacities.Add(1.0f); grp.SelectionLighting.Add(new Vector2(0f, 1f)); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshWorkerLifecycleTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshWorkerLifecycleTests.cs new file mode 100644 index 00000000..08980a6c --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Wb/ObjectMeshWorkerLifecycleTests.cs @@ -0,0 +1,34 @@ +using AcDream.App.Rendering.Wb; + +namespace AcDream.App.Tests.Rendering.Wb; + +public sealed class ObjectMeshWorkerLifecycleTests +{ + [Fact] + public void Shutdown_never_resets_the_shared_worker_wake_signal() + { + Assert.Equal( + ObjectMeshManager.PreparationWorkerWakeAction.Exit, + ObjectMeshManager.DecidePreparationWorkerWake( + isDisposed: true, + hasPendingRequests: false, + stagingAtHighWater: true, + arenaBackpressured: true)); + + Assert.Equal( + ObjectMeshManager.PreparationWorkerWakeAction.ResetAndWait, + ObjectMeshManager.DecidePreparationWorkerWake( + isDisposed: false, + hasPendingRequests: false, + stagingAtHighWater: false, + arenaBackpressured: false)); + + Assert.Equal( + ObjectMeshManager.PreparationWorkerWakeAction.Process, + ObjectMeshManager.DecidePreparationWorkerWake( + isDisposed: false, + hasPendingRequests: true, + stagingAtHighWater: false, + arenaBackpressured: false)); + } +}