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));
+ }
+}