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:
parent
47d7086a74
commit
3718e341be
17 changed files with 1103 additions and 225 deletions
|
|
@ -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 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`;
|
**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`;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- **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.
|
- **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.
|
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 ──────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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++)
|
||||||
|
|
|
||||||
|
|
@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue