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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 06:29:30 +02:00
parent 47d7086a74
commit 3718e341be
17 changed files with 1103 additions and 225 deletions

View file

@ -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 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. 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 912 ms
while the GPU needed only 3.83.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.36.2 ms CPU / 7.18.3 ms p95 and 3.23.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`; **Files:** `src/AcDream.App/Rendering/RetailAlphaQueue.cs`;
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`;
`src/AcDream.App/Rendering/ParticleRenderer.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/CompositeTextureArrayCache.cs`;
`src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`; `src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs`;
`src/AcDream.Content/BoundedDatObjectCache.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:** **Research:**
`docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`; `docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md`;

View file

@ -562,6 +562,7 @@ behavior. Estimated 1726 days focused work, 35 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. - **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 125153 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. - **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 125153 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 912 ms CPU to 5.36.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. **Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.

View file

@ -16,6 +16,8 @@ public enum FrameStage
Upload = 1, Upload = 1,
/// <summary>ImGui Render (dev overlay).</summary> /// <summary>ImGui Render (dev overlay).</summary>
ImGui = 2, ImGui = 2,
/// <summary>Software presentation deadline wait (zero under VSync or uncapped mode).</summary>
Pacing = 3,
} }
/// <summary> /// <summary>
@ -187,7 +189,7 @@ public sealed class FrameProfiler : IDisposable
sb.Append(" | gpu=off(wbdiag)"); sb.Append(" | gpu=off(wbdiag)");
sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}", 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); 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++) for (int i = 0; i < stages.Length && i < names.Length; i++)
sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}", 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); names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0);

View file

@ -12,27 +12,41 @@ namespace AcDream.App.Rendering;
/// CPU use. A missed deadline is rebased from the current time so one slow /// CPU use. A missed deadline is rebased from the current time so one slow
/// frame cannot trigger a burst of catch-up frames. /// frame cannot trigger a burst of catch-up frames.
/// </remarks> /// </remarks>
internal sealed class FramePacingController internal sealed class FramePacingController : IDisposable
{ {
private readonly IFramePacingClock _clock; private readonly IFramePacingClock _clock;
private readonly IFramePacingWaiter _waiter; private readonly IFramePacingWaiter _waiter;
private readonly bool _ownsWaiter;
private FramePacingPolicy _policy; private FramePacingPolicy _policy;
private long _periodTicks; private long _periodTicks;
private long _nextDeadline; private long _nextDeadline;
private bool _hasDeadline; private bool _hasDeadline;
private bool _disposed;
public FramePacingController() public FramePacingController()
: this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance) : this(
StopwatchFramePacingClock.Instance,
WindowsHighResolutionFramePacingWaiter.Create(),
ownsWaiter: true)
{ {
} }
internal FramePacingController( internal FramePacingController(
IFramePacingClock clock, IFramePacingClock clock,
IFramePacingWaiter waiter) IFramePacingWaiter waiter)
: this(clock, waiter, ownsWaiter: false)
{
}
internal FramePacingController(
IFramePacingClock clock,
IFramePacingWaiter waiter,
bool ownsWaiter)
{ {
_clock = clock ?? throw new ArgumentNullException(nameof(clock)); _clock = clock ?? throw new ArgumentNullException(nameof(clock));
_waiter = waiter ?? throw new ArgumentNullException(nameof(waiter)); _waiter = waiter ?? throw new ArgumentNullException(nameof(waiter));
_ownsWaiter = ownsWaiter;
if (_clock.Frequency <= 0) if (_clock.Frequency <= 0)
throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive."); throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive.");
} }
@ -102,6 +116,16 @@ internal sealed class FramePacingController
=> value > long.MaxValue - increment => value > long.MaxValue - increment
? long.MaxValue ? long.MaxValue
: value + increment; : value + increment;
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
if (_ownsWaiter && _waiter is IDisposable disposable)
disposable.Dispose();
}
} }
internal interface IFramePacingClock internal interface IFramePacingClock
@ -128,28 +152,3 @@ internal sealed class StopwatchFramePacingClock : IFramePacingClock
public long GetTimestamp() => Stopwatch.GetTimestamp(); 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);
}
}

View file

@ -10871,7 +10871,9 @@ public sealed class GameWindow : IDisposable
$"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
+ $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | " + $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
+ $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})"; + $"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) = (int particleSets, long particleBytes) =
_particleRenderer?.DynamicBufferDiagnostics ?? default; _particleRenderer?.DynamicBufferDiagnostics ?? default;
@ -12893,7 +12895,11 @@ public sealed class GameWindow : IDisposable
} }
private void OnFrameRendered(double _) 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<int> _) private void OnWindowMoved(Silk.NET.Maths.Vector2D<int> _)
=> RefreshActiveMonitorFramePacing(); => RefreshActiveMonitorFramePacing();
@ -15022,6 +15028,9 @@ public sealed class GameWindow : IDisposable
} }
private void OnClosing() private void OnClosing()
=> CompleteShutdown();
private void CompleteShutdown()
{ {
_shutdown ??= CreateShutdownTransaction(); _shutdown ??= CreateShutdownTransaction();
try try
@ -15036,7 +15045,6 @@ public sealed class GameWindow : IDisposable
// context/process teardown is the final safety net. // context/process teardown is the final safety net.
Console.Error.WriteLine($"[shutdown] {error}"); Console.Error.WriteLine($"[shutdown] {error}");
} }
} }
private ResourceShutdownTransaction CreateShutdownTransaction() => new( private ResourceShutdownTransaction CreateShutdownTransaction() => new(
@ -15160,6 +15168,7 @@ public sealed class GameWindow : IDisposable
new("text renderer", () => _textRenderer?.Dispose()), new("text renderer", () => _textRenderer?.Dispose()),
new("debug font", () => _debugFont?.Dispose()), new("debug font", () => _debugFont?.Dispose()),
new("frame profiler", _frameProfiler.Dispose), new("frame profiler", _frameProfiler.Dispose),
new("frame pacing", _framePacing.Dispose),
]), ]),
new ResourceShutdownStage("frame flight owner", new ResourceShutdownStage("frame flight owner",
[ [
@ -15202,7 +15211,17 @@ public sealed class GameWindow : IDisposable
EndMouseLookAndRestoreCursor(); 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 ────────────────── // ── Phase I.6 — TurbineChat outbound helpers ──────────────────

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Numerics; using System.Numerics;
using System.Runtime.InteropServices;
namespace AcDream.App.Rendering; namespace AcDream.App.Rendering;
@ -38,8 +39,7 @@ internal interface IRetailAlphaDrawSource
internal readonly record struct RetailAlphaSubmission( internal readonly record struct RetailAlphaSubmission(
IRetailAlphaDrawSource Source, IRetailAlphaDrawSource Source,
int Token, int Token,
float ViewerDistance, float ViewerDistance);
long Sequence);
/// <summary> /// <summary>
/// Frame-scoped port of retail's shared <c>D3DPolyRender</c> alpha list. /// Frame-scoped port of retail's shared <c>D3DPolyRender</c> alpha list.
@ -62,7 +62,8 @@ internal sealed class RetailAlphaQueue
private readonly List<IRetailAlphaDrawSource> _sources = new(4); private readonly List<IRetailAlphaDrawSource> _sources = new(4);
private int[] _tokenScratch = new int[256]; private int[] _tokenScratch = new int[256];
private int[] _sourceDrawOffsets = new int[4]; 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; } public bool IsCollecting { get; private set; }
internal int PendingCount => _submissions.Count; internal int PendingCount => _submissions.Count;
@ -74,7 +75,6 @@ internal sealed class RetailAlphaQueue
if (_submissions.Count != 0 || _sources.Count != 0) if (_submissions.Count != 0 || _sources.Count != 0)
throw new InvalidOperationException("Retail alpha queue retained payload outside a frame."); throw new InvalidOperationException("Retail alpha queue retained payload outside a frame.");
_nextSequence = 0;
IsCollecting = true; IsCollecting = true;
} }
@ -89,8 +89,7 @@ internal sealed class RetailAlphaQueue
_submissions.Add(new RetailAlphaSubmission( _submissions.Add(new RetailAlphaSubmission(
source, source,
token, token,
NormalizeDistance(viewerDistance), NormalizeDistance(viewerDistance)));
_nextSequence++));
for (int i = 0; i < _sources.Count; i++) for (int i = 0; i < _sources.Count; i++)
if (ReferenceEquals(_sources[i], source)) if (ReferenceEquals(_sources[i], source))
@ -114,7 +113,7 @@ internal sealed class RetailAlphaQueue
if (_submissions.Count == 0) if (_submissions.Count == 0)
return; return;
_submissions.Sort(CompareRetailOrder); SortRetailOrder();
EnsureTokenCapacity(_submissions.Count); EnsureTokenCapacity(_submissions.Count);
EnsureSourceCapacity(_sources.Count); EnsureSourceCapacity(_sources.Count);
Array.Clear(_sourceDrawOffsets, 0, _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 int count = _submissions.Count;
// (far-to-near). Sequence makes the List.Sort result stable for equal if (count <= 1)
// distances, preserving part/surface append order like retail. return;
int distance = right.ViewerDistance.CompareTo(left.ViewerDistance);
return distance != 0 ? distance : left.Sequence.CompareTo(right.Sequence); EnsureSortCapacity(count);
Span<RetailAlphaSubmission> submissions = CollectionsMarshal.AsSpan(_submissions);
Span<RetailAlphaSubmission> 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) 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<RetailAlphaSubmission> source,
Span<RetailAlphaSubmission> 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) private void EnsureTokenCapacity(int count)
{ {
@ -204,6 +247,13 @@ internal sealed class RetailAlphaQueue
Array.Resize(ref _sourceDrawOffsets, count + 4); 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) private int FindSourceIndex(IRetailAlphaDrawSource source)
{ {
for (int i = 0; i < _sources.Count; i++) for (int i = 0; i < _sources.Count; i++)

View file

@ -10,6 +10,9 @@ namespace AcDream.App.Rendering.Wb;
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product. /// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
/// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by /// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by
/// retail's delayed-alpha viewer-distance ordering on cache hits. /// retail's delayed-alpha viewer-distance ordering on cache hits.
/// <see cref="Group"/> 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: <c>internal</c> because <see cref="GroupKey"/> is /// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c> /// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
@ -21,7 +24,19 @@ internal readonly record struct CachedBatch(
GroupKey Key, GroupKey Key,
ulong BindlessTextureHandle, ulong BindlessTextureHandle,
Matrix4x4 RestPose, Matrix4x4 RestPose,
Vector3 LocalSortCenter = default); Vector3 LocalSortCenter = default,
WbDrawDispatcher.InstanceGroup? Group = null,
long GroupRegistration = 0);
/// <summary>
/// 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.
/// </summary>
internal readonly record struct CachedSelectionPart(
int PartIndex,
uint GfxObjId,
Matrix4x4 RestPose);
/// <summary> /// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across /// One entity's cached classification. <see cref="Batches"/> is flat across
@ -39,4 +54,5 @@ internal sealed class EntityCacheEntry
public required uint EntityId { get; init; } public required uint EntityId { get; init; }
public required uint LandblockHint { get; init; } public required uint LandblockHint { get; init; }
public required CachedBatch[] Batches { get; init; } public required CachedBatch[] Batches { get; init; }
public CachedSelectionPart[] SelectionParts { get; init; } = [];
} }

View file

@ -76,13 +76,18 @@ internal sealed class EntityClassificationCache
/// <c>(<paramref name="entityId"/>, <paramref name="landblockHint"/>)</c> /// <c>(<paramref name="entityId"/>, <paramref name="landblockHint"/>)</c>
/// tuple. Defensive: if an entry already exists, replaces it. /// tuple. Defensive: if an entry already exists, replaces it.
/// </summary> /// </summary>
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 _entries[(entityId, landblockHint)] = new EntityCacheEntry
{ {
EntityId = entityId, EntityId = entityId,
LandblockHint = landblockHint, LandblockHint = landblockHint,
Batches = batches, Batches = batches,
SelectionParts = selectionParts ?? [],
}; };
} }

View file

@ -354,6 +354,29 @@ namespace AcDream.App.Rendering.Wb
private readonly ManualResetEventSlim _preparationWorkAvailable = new(false); private readonly ManualResetEventSlim _preparationWorkAvailable = new(false);
private const int MaxParallelLoads = 4; 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( private sealed class ObjectReleaseTicket(
ulong id, ulong id,
ObjectRenderData data, ObjectRenderData data,
@ -911,14 +934,20 @@ namespace AcDream.App.Rendering.Wb
{ {
// IsDisposed re-check lets Dispose cancel and join every // IsDisposed re-check lets Dispose cancel and join every
// tracked worker before the DAT mappings are released. // tracked worker before the DAT mappings are released.
if (IsDisposed // Exit WITHOUT resetting the shared manual-reset event:
|| _pendingRequests.Count == 0 // Dispose sets it once to wake all four persistent workers.
|| _stagedMeshData.IsAtHighWater // If the first worker reset it, the remaining three slept
|| _arenaBackpressured) // 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(); _preparationWorkAvailable.Reset();
if (IsDisposed)
return;
continue; continue;
} }

View file

@ -470,7 +470,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// This entity's selected point/spot light set — computed ONCE per entity at // This entity's selected point/spot light set — computed ONCE per entity at
// the isNewEntity site (constant across the entity's parts/tuples), exactly // the isNewEntity site (constant across the entity's parts/tuples), exactly
// like _currentEntitySlot. -1 = unused slot. // 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, // #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet,
// parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires // parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires
@ -517,7 +518,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private CullMode[] _drawCullModes = new CullMode[256]; private CullMode[] _drawCullModes = new CullMode[256];
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256]; private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
private readonly List<IndirectGroupInput> _groupInputScratch = new(256); private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
private readonly Action<GroupKey, Matrix4x4, Vector3> _cacheHitAppender; private readonly List<GroupKey> _retiredGroupKeys = new();
private long _nextGroupRegistration = 1;
private long _groupFrame;
private int _opaqueDrawCount; private int _opaqueDrawCount;
private int _transparentDrawCount; private int _transparentDrawCount;
@ -541,15 +544,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
GroupKey Key, GroupKey Key,
Matrix4x4 Model, Matrix4x4 Model,
uint ClipSlot, uint ClipSlot,
AlphaLightSet Lights, InstanceLightSet Lights,
uint Indoor, uint Indoor,
float Opacity, float Opacity,
Vector2 SelectionLighting); Vector2 SelectionLighting);
private readonly record struct AlphaLightSet( internal readonly record struct InstanceLightSet(
int L0, int L1, int L2, int L3, int L0, int L1, int L2, int L3,
int L4, int L5, int L6, int L7) 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<int> 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 public int this[int index] => index switch
{ {
0 => L0, 1 => L1, 2 => L2, 3 => L3, 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. // are contiguous — accumulate them all before flushing one Populate call.
// Animated entities skip this scratch entirely (collector = null). // Animated entities skip this scratch entirely (collector = null).
private readonly List<CachedBatch> _populateScratch = new(); private readonly List<CachedBatch> _populateScratch = new();
private readonly List<CachedSelectionPart> _populateSelectionScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large // Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled. // outliers (long banners, tall columns) are still landblock-culled.
@ -720,8 +749,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_selectionLighting = selectionSink as IRetailSelectionLightingSource; _selectionLighting = selectionSink as IRetailSelectionLightingSource;
_alphaQueue = alphaQueue; _alphaQueue = alphaQueue;
_alphaSource = new AlphaDrawSource(this); _alphaSource = new AlphaDrawSource(this);
_cacheHitAppender = AppendInstanceToGroup;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless)); _bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
} }
@ -735,6 +762,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{ {
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length) if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
throw new ArgumentOutOfRangeException(nameof(frameSlot)); 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; _dynamicFrameSlot = frameSlot;
_dynamicBufferSetCursor = 0; _dynamicBufferSetCursor = 0;
_dynamicFrameStarted = true; _dynamicFrameStarted = true;
@ -1317,7 +1352,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
camPos = invView.Translation; camPos = invView.Translation;
// ── Phase 1: clear groups, walk entities, build groups ────────────── // ── 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; var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0; uint anyVao = 0;
@ -1467,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (populateEntityId.HasValue && currentEntityIncomplete) if (populateEntityId.HasValue && currentEntityIncomplete)
{ {
_populateScratch.Clear(); _populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null; populateEntityId = null;
} }
currentEntityIncomplete = false; currentEntityIncomplete = false;
@ -1513,7 +1553,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// entities (including this-entity-culled) so the PREVIOUS entity's // entities (including this-entity-culled) so the PREVIOUS entity's
// cache always flushes at the boundary. // cache always flushes at the boundary.
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange( (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 // Phase U.4: a culled entity (cell not visible, or no outdoors visible
// for an outdoor stab) contributes NO instances. Skip after the // 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. // body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry)) 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 // The cache is populated only after every MeshRef rendered
// successfully. Publish the same parts for retail picking now; // successfully. Publish the same parts for retail picking now;
// CPhysicsPart::Draw only participates after the visible draw // CPhysicsPart::Draw only participates after the visible draw
// path has accepted a real part. // path has accepted a real part.
if (_selectionSink is not null) if (_selectionSink is not null)
PublishCachedSelectionParts(entity, entityWorld); PublishCachedSelectionParts(cachedEntry!, entity, entityWorld);
// anyVao recovery: when the first visible entity in the frame // anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated // 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 // their MeshRefs; the flush at next-entity-boundary (or
// end-of-loop) commits them as a single Populate call. // end-of-loop) commits them as a single Populate call.
var collector = isAnimated ? null : _populateScratch; var collector = isAnimated ? null : _populateScratch;
var selectionCollector = isAnimated ? null : _populateSelectionScratch;
bool drewAny = false; bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0) if (renderData.IsSetup && renderData.SetupParts.Count > 0)
@ -1748,6 +1790,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)), unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId, (uint)partGfxObjId,
model); model);
selectionCollector?.Add(new CachedSelectionPart(
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
restPose));
drewAny = true; drewAny = true;
} }
} }
@ -1774,6 +1820,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
partIdx, partIdx,
(uint)gfxObjId, (uint)gfxObjId,
model); model);
selectionCollector?.Add(new CachedSelectionPart(
partIdx,
(uint)gfxObjId,
meshRef.PartTransform));
drewAny = true; drewAny = true;
} }
} }
@ -1801,6 +1851,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (currentEntityIncomplete) if (currentEntityIncomplete)
{ {
_populateScratch.Clear(); _populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null; populateEntityId = null;
} }
@ -1808,7 +1859,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// to trigger the entity-change flush, so commit its accumulated batches // to trigger the entity-change flush, so commit its accumulated batches
// here. No-op when the last entity was animated (populateEntityId stays // here. No-op when the last entity was animated (populateEntityId stays
// null) or when no entities walked at all. // 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 // §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 // 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 ── // ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
int totalInstances = 0; bool deferTransparent = _alphaQueue?.IsCollecting == true;
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count; var instanceCounts = PartitionInstanceGroups(
_groups.Values,
deferTransparent,
camPos,
_opaqueDraws,
_translucentDraws);
int totalInstances = instanceCounts.VisibleInstances;
int immediateInstances = instanceCounts.ImmediateInstances;
if (totalInstances == 0) if (totalInstances == 0)
{ {
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 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; 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) if (_instanceData.Length < needed)
_instanceData = new float[needed + 256 * 16]; _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, // count and lay it out in the SAME group order / cursor as _instanceData,
// so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On // 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. // the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3.
if (_clipSlotData.Length < totalInstances) if (_clipSlotData.Length < immediateInstances)
_clipSlotData = new uint[totalInstances + 256]; _clipSlotData = new uint[immediateInstances + 256];
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per // Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
// instance, laid out in the SAME group order / cursor as _instanceData // instance, laid out in the SAME group order / cursor as _instanceData
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks // so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
// Instances[instanceIndex] (binding=0). // Instances[instanceIndex] (binding=0).
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) if (_lightSetData.Length < immediateInstances * LightManager.MaxLightsPerObject)
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; _lightSetData = new int[(immediateInstances + 256) * LightManager.MaxLightsPerObject];
// #142: per-instance indoor flag buffer, one uint per instance, parallel to // #142: per-instance indoor flag buffer, one uint per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others. // _clipSlotData / _instanceData. Grown on demand like the others.
if (_indoorData.Length < totalInstances) if (_indoorData.Length < immediateInstances)
_indoorData = new uint[totalInstances + 256]; _indoorData = new uint[immediateInstances + 256];
// #188: per-instance opacity buffer, one float per instance, parallel to // #188: per-instance opacity buffer, one float per instance, parallel to
// _clipSlotData / _instanceData. Grown on demand like the others. // _clipSlotData / _instanceData. Grown on demand like the others.
if (_alphaData.Length < totalInstances) if (_alphaData.Length < immediateInstances)
_alphaData = new float[totalInstances + 256]; _alphaData = new float[immediateInstances + 256];
if (_selectionLightingData.Length < totalInstances) if (_selectionLightingData.Length < immediateInstances)
_selectionLightingData = new Vector2[totalInstances + 256]; _selectionLightingData = new Vector2[immediateInstances + 256];
_opaqueDraws.Clear();
_translucentDraws.Clear();
int cursor = 0; 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; foreach (InstanceGroup grp in _translucentDraws)
StageImmediateGroup(grp, ref cursor);
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);
} }
System.Diagnostics.Debug.Assert(cursor == immediateInstances);
// Front-to-back sort within each cull mode. DrawIndirectRange must // Front-to-back sort within each cull mode. DrawIndirectRange must
// split MDI calls whenever CullMode changes because GL state is not // 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. // submission so the shared queue can interleave it with particles.
// Immediate mode remains for sealed off-screen consumers such as the // Immediate mode remains for sealed off-screen consumers such as the
// paperdoll and UI Studio render stack. // 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), // ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ────────────────────────────────── // fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count; int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
@ -1998,7 +2018,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ActivateNextDynamicBufferSet(); ActivateNextDynamicBufferSet();
fixed (float* ip = _instanceData) fixed (float* ip = _instanceData)
UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes, UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes,
ip, totalInstances * 16 * sizeof(float)); ip, immediateInstances * 16 * sizeof(float));
fixed (BatchData* bp = _batchData) fixed (BatchData* bp = _batchData)
UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes, UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes,
@ -2009,33 +2029,33 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the // instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the
// U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to // 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 // U.3); under indoor routing it holds the per-instance slot from
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly totalInstances // ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances
// entries; only [0..totalInstances) is uploaded, so any stale tail is // entries; only [0..immediateInstances) is uploaded, so any stale tail is
// never read by the shader (BaseInstance + gl_InstanceID < totalInstances). // never read by the shader.
fixed (uint* sp = _clipSlotData) fixed (uint* sp = _clipSlotData)
UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes, 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, // #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). // is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (uint* dp = _indoorData) fixed (uint* dp = _indoorData)
UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes, UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes,
dp, totalInstances * sizeof(uint)); dp, immediateInstances * sizeof(uint));
// #188: per-instance opacity buffer (binding=7), one float per instance, // #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). // is uploaded — stale tail never read (same guarantee as clip-slot above).
fixed (float* ap = _alphaData) fixed (float* ap = _alphaData)
UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes, UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes,
ap, totalInstances * sizeof(float)); ap, immediateInstances * sizeof(float));
// SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert // SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert
// reads this only for the object path (uLightingMode=0), so EnvCell's // reads this only for the object path (uLightingMode=0), so EnvCell's
// independent mode-1 renderer does not consume this binding. // independent mode-1 renderer does not consume this binding.
fixed (Vector2* hp = _selectionLightingData) fixed (Vector2* hp = _selectionLightingData)
UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes, 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 // Fix B: global point-light buffer (binding=4) + per-instance light-set
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the // buffer (binding=5). The global buffer is this frame's PointSnapshot; the
@ -2045,7 +2065,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
UploadGlobalLights(); UploadGlobalLights();
fixed (int* lp = _lightSetData) fixed (int* lp = _lightSetData)
UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes, UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes,
lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int));
fixed (DrawElementsIndirectCommand* cp = _indirectCommands) fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
{ {
@ -2234,41 +2254,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
set: set); 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]; _selectionSink!.AddVisiblePart(
var renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId); entity,
if (renderData is null) part.PartIndex,
continue; part.GfxObjId,
part.RestPose * entityWorld);
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));
}
} }
} }
@ -2283,6 +2280,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Translucency: g.Translucency, Translucency: g.Translucency,
CullMode: g.CullMode); CullMode: g.CullMode);
internal readonly record struct InstanceLayoutCounts(
int VisibleInstances,
int ImmediateInstances);
internal static InstanceLayoutCounts PartitionInstanceGroups(
IEnumerable<InstanceGroup> groups,
bool deferTransparent,
Vector3 cameraWorldPosition,
List<InstanceGroup> opaque,
List<InstanceGroup> 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( private static GroupKey ToKey(InstanceGroup g) => new(
g.FirstIndex, g.FirstIndex,
g.BaseVertex, g.BaseVertex,
@ -2312,12 +2370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
localSortCenter, localSortCenter,
model, model,
cameraWorldPosition); cameraWorldPosition);
int lightOffset = i * LightManager.MaxLightsPerObject; InstanceLightSet lights = group.LightSets[i];
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]);
int token = _deferredAlpha.Count; int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance( _deferredAlpha.Add(new DeferredAlphaInstance(
@ -2353,8 +2406,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_alphaData[i] = entry.Opacity; _alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting; _selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject; int lightOffset = i * LightManager.MaxLightsPerObject;
for (int light = 0; light < LightManager.MaxLightsPerObject; light++) entry.Lights.CopyTo(_lightSetData, lightOffset);
_lightSetData[lightOffset + light] = entry.Lights[light];
GroupKey key = entry.Key; GroupKey key = entry.Key;
_batchData[i] = new BatchData _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);
}
}
/// <summary>
/// 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.
/// </summary>
internal static int PruneInstanceGroupsUnusedBeforeFrame(
Dictionary<GroupKey, InstanceGroup> groups,
List<GroupKey> 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;
}
/// <summary> /// <summary>
/// Per-tuple flush check. If <paramref name="populateEntityId"/> is set /// Per-tuple flush check. If <paramref name="populateEntityId"/> is set
/// AND differs from <paramref name="currentEntityId"/>, the previous /// AND differs from <paramref name="currentEntityId"/>, the previous
@ -2897,15 +3010,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
uint populateLandblockId, uint populateLandblockId,
uint currentEntityId, uint currentEntityId,
EntityClassificationCache cache, EntityClassificationCache cache,
List<CachedBatch> populateScratch) List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{ {
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId) if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
{ {
if (populateScratch.Count > 0) if (populateScratch.Count > 0)
{ {
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray()); cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
} }
populateScratch.Clear(); populateScratch.Clear();
selectionScratch?.Clear();
return (null, 0u); return (null, 0u);
} }
return (populateEntityId, populateLandblockId); return (populateEntityId, populateLandblockId);
@ -2925,13 +3044,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
uint? populateEntityId, uint? populateEntityId,
uint populateLandblockId, uint populateLandblockId,
EntityClassificationCache cache, EntityClassificationCache cache,
List<CachedBatch> populateScratch) List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{ {
if (populateEntityId.HasValue && populateScratch.Count > 0) if (populateEntityId.HasValue && populateScratch.Count > 0)
{ {
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray()); cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
populateScratch.Clear(); populateScratch.Clear();
} }
selectionScratch?.Clear();
} }
/// <summary> /// <summary>
@ -2941,20 +3066,46 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// </summary> /// </summary>
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter) 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 group.LastUsedFrame = _groupFrame;
{ return group;
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
IndexCount = key.IndexCount,
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
};
_groups[key] = grp;
} }
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.Matrices.Add(model);
grp.LocalSortCenters.Add(localSortCenter); grp.LocalSortCenters.Add(localSortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices 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, entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0); _currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false; bool any = false;
for (int k = 0; k < _currentEntityLightSet.Length; k++) for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
{ {
int idx = _currentEntityLightSet[k]; int idx = _currentEntityLightSet[k];
if (idx < 0) continue; if (idx < 0) continue;
@ -3040,7 +3191,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// so they can't disagree — one call, one truth. // so they can't disagree — one call, one truth.
_currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId);
Array.Fill(_currentEntityLightSet, -1); _currentEntityLightSet = InstanceLightSet.Disabled;
var snap = _pointSnapshot; var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return; if (snap is null || snap.Count == 0) return;
@ -3050,7 +3201,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (entity.AabbDirty) entity.RefreshAabb(); if (entity.AabbDirty) entity.RefreshAabb();
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
float radius = (entity.AabbMax - entity.AabbMin).Length() * 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);
} }
/// <summary> /// <summary>
@ -3076,8 +3229,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// </summary> /// </summary>
private void AppendCurrentLightSet(InstanceGroup grp) private void AppendCurrentLightSet(InstanceGroup grp)
{ {
for (int k = 0; k < LightManager.MaxLightsPerObject; k++) grp.LightSets.Add(_currentEntityLightSet);
grp.LightSets.Add(_currentEntityLightSet[k]);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block 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.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode); batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
if (!_groups.TryGetValue(key, out var grp)) InstanceGroup grp = GetOrCreateInstanceGroup(key);
{
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;
}
grp.Matrices.Add(model); grp.Matrices.Add(model);
grp.LocalSortCenters.Add(renderData.SortCenter); grp.LocalSortCenters.Add(renderData.SortCenter);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
grp.SelectionLighting.Add(_currentEntitySelectionLighting); 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; return allTexturesReady;
} }
@ -3577,6 +3722,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
internal sealed class InstanceGroup 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 uint FirstIndex;
public int BaseVertex; public int BaseVertex;
public int IndexCount; public int IndexCount;
@ -3601,13 +3751,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance. // so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new(); public readonly List<uint> Slots = new();
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per // Fix B (A7 #3): one packed eight-index light set per instance, parallel
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected // to Matrices (LightSets[i] belongs to Matrices[i]). At
// light index block for the instance whose matrix is Matrices[i]). At
// layout time the dispatcher copies each block into _lightSetData at the // layout time the dispatcher copies each block into _lightSetData at the
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
// instance. -1 = unused slot. // instance. -1 = unused slot.
public readonly List<int> LightSets = new(); public readonly List<InstanceLightSet> LightSets = new();
// #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is // #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 // 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(); Opacities.Clear();
SelectionLighting.Clear(); SelectionLighting.Clear();
} }
public void ReleasePerInstanceStorage()
{
ClearPerInstanceData();
Matrices.TrimExcess();
LocalSortCenters.TrimExcess();
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}
} }
} }

View file

@ -0,0 +1,169 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace AcDream.App.Rendering;
/// <summary>
/// Low-CPU, sub-millisecond-capable deadline wait for the Windows render loop.
/// One auto-reset timer is reused for the complete window lifetime.
/// </summary>
/// <remarks>
/// <para>
/// A normal <see cref="Thread.Sleep(int)"/> 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.
/// </para>
/// <para>
/// <c>CREATE_WAITABLE_TIMER_HIGH_RESOLUTION</c> 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.
/// </para>
/// </remarks>
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);
}

View file

@ -11,7 +11,13 @@ public class FrameProfilerReportTests
var cpu = new FrameStatsBuffer(16); var cpu = new FrameStatsBuffer(16);
var gpu = new FrameStatsBuffer(16); var gpu = new FrameStatsBuffer(16);
var alloc = 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++) for (long i = 1; i <= 10; i++)
{ {
cpu.Push(i * 1000); // 1..10 ms in µs cpu.Push(i * 1000); // 1..10 ms in µs
@ -20,6 +26,7 @@ public class FrameProfilerReportTests
stages[0].Push(i * 200); stages[0].Push(i * 200);
stages[1].Push(i * 50); stages[1].Push(i * 50);
stages[2].Push(i * 10); stages[2].Push(i * 10);
stages[3].Push(i * 300);
} }
string line = FrameProfiler.FormatReport( string line = FrameProfiler.FormatReport(
@ -33,6 +40,7 @@ public class FrameProfilerReportTests
Assert.Contains("alloc_kb p50=5.0 max=10.0", line); Assert.Contains("alloc_kb p50=5.0 max=10.0", line);
Assert.Contains("gc=3/1/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("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) Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture)
} }

View file

@ -91,6 +91,62 @@ public sealed class FramePacingControllerTests
Assert.Equal([25L], waiter.Durations); 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<ObjectDisposedException>(() => 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 private sealed class FakeClock(long frequency) : IFramePacingClock
{ {
public long Frequency { get; } = frequency; public long Frequency { get; } = frequency;
@ -113,4 +169,15 @@ public sealed class FramePacingControllerTests
clock.Timestamp += durationTicks + OvershootTicks; 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++;
}
} }

View file

@ -20,6 +20,7 @@ public sealed class FramePacingPolicyTests
[InlineData(60)] [InlineData(60)]
[InlineData(120)] [InlineData(120)]
[InlineData(144)] [InlineData(144)]
[InlineData(240)]
public void Normal_VSync_off_uses_active_monitor_refresh_limit(int refreshHz) public void Normal_VSync_off_uses_active_monitor_refresh_limit(int refreshHz)
{ {
var policy = FramePacingPolicy.Resolve( var policy = FramePacingPolicy.Resolve(

View file

@ -94,6 +94,91 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(new[] { 1 }, particles.BatchSizes); Assert.Equal(new[] { 1 }, particles.BatchSizes);
} }
[Fact]
public void Flush_LargeInputMatchesStableDescendingRetailOrder()
{
var log = new List<string>();
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<string>();
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<string>();
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] [Fact]
public void ComputeViewerDistance_UsesTransformedGfxSortCenter() public void ComputeViewerDistance_UsesTransformedGfxSortCenter()
{ {
@ -113,11 +198,15 @@ public sealed class RetailAlphaQueueTests
Assert.Equal(2f, cypt, precision: 5); 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<string> log) : IRetailAlphaDrawSource private sealed class RecordingSource(string name, List<string> log) : IRetailAlphaDrawSource
{ {
public List<int> BatchSizes { get; } = new(); public List<int> BatchSizes { get; } = new();
public int ResetCount { get; private set; } public int ResetCount { get; private set; }
public int PrepareCount { get; private set; } public int PrepareCount { get; private set; }
public IReadOnlyList<int> PreparedTokens => _prepared;
private int[] _prepared = []; private int[] _prepared = [];
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens) public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)

View file

@ -1,11 +1,215 @@
using System.Numerics; using System.Numerics;
using AcDream.App.Rendering.Wb; using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using Xunit; using Xunit;
namespace AcDream.App.Tests.Rendering.Wb; namespace AcDream.App.Tests.Rendering.Wb;
public class InstanceGroupClearTests 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<WbDrawDispatcher.InstanceGroup>();
var transparent = new List<WbDrawDispatcher.InstanceGroup>();
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<GroupKey, WbDrawDispatcher.InstanceGroup>
{
[liveKey] = live,
[retiredKey] = retired,
};
var retiredKeys = new List<GroupKey>();
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<GroupKey, WbDrawDispatcher.InstanceGroup>
{
[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 // #193 (regression from #188, 2026-07-09): WbDrawDispatcher's InstanceGroup holds
// seven per-instance parallel lists — Matrices, LocalSortCenters, Slots, // seven per-instance parallel lists — Matrices, LocalSortCenters, Slots,
// LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep // LightSets, IndoorFlags, Opacities, SelectionLighting — appended in lockstep
@ -22,7 +226,7 @@ public class InstanceGroupClearTests
grp.Matrices.Add(Matrix4x4.Identity); grp.Matrices.Add(Matrix4x4.Identity);
grp.LocalSortCenters.Add(Vector3.Zero); grp.LocalSortCenters.Add(Vector3.Zero);
grp.Slots.Add(1u); grp.Slots.Add(1u);
grp.LightSets.Add(-1); grp.LightSets.Add(WbDrawDispatcher.InstanceLightSet.Disabled);
grp.IndoorFlags.Add(0u); grp.IndoorFlags.Add(0u);
grp.Opacities.Add(1.0f); grp.Opacities.Add(1.0f);
grp.SelectionLighting.Add(new Vector2(0f, 1f)); grp.SelectionLighting.Add(new Vector2(0f, 1f));

View file

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