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
|
|
@ -12,27 +12,41 @@ namespace AcDream.App.Rendering;
|
|||
/// CPU use. A missed deadline is rebased from the current time so one slow
|
||||
/// frame cannot trigger a burst of catch-up frames.
|
||||
/// </remarks>
|
||||
internal sealed class FramePacingController
|
||||
internal sealed class FramePacingController : IDisposable
|
||||
{
|
||||
private readonly IFramePacingClock _clock;
|
||||
private readonly IFramePacingWaiter _waiter;
|
||||
private readonly bool _ownsWaiter;
|
||||
|
||||
private FramePacingPolicy _policy;
|
||||
private long _periodTicks;
|
||||
private long _nextDeadline;
|
||||
private bool _hasDeadline;
|
||||
private bool _disposed;
|
||||
|
||||
public FramePacingController()
|
||||
: this(StopwatchFramePacingClock.Instance, ThreadFramePacingWaiter.Instance)
|
||||
: this(
|
||||
StopwatchFramePacingClock.Instance,
|
||||
WindowsHighResolutionFramePacingWaiter.Create(),
|
||||
ownsWaiter: true)
|
||||
{
|
||||
}
|
||||
|
||||
internal FramePacingController(
|
||||
IFramePacingClock clock,
|
||||
IFramePacingWaiter waiter)
|
||||
: this(clock, waiter, ownsWaiter: false)
|
||||
{
|
||||
}
|
||||
|
||||
internal FramePacingController(
|
||||
IFramePacingClock clock,
|
||||
IFramePacingWaiter waiter,
|
||||
bool ownsWaiter)
|
||||
{
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_waiter = waiter ?? throw new ArgumentNullException(nameof(waiter));
|
||||
_ownsWaiter = ownsWaiter;
|
||||
if (_clock.Frequency <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(clock), "Clock frequency must be positive.");
|
||||
}
|
||||
|
|
@ -102,6 +116,16 @@ internal sealed class FramePacingController
|
|||
=> value > long.MaxValue - increment
|
||||
? long.MaxValue
|
||||
: value + increment;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
|
||||
if (_ownsWaiter && _waiter is IDisposable disposable)
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IFramePacingClock
|
||||
|
|
@ -128,28 +152,3 @@ internal sealed class StopwatchFramePacingClock : IFramePacingClock
|
|||
|
||||
public long GetTimestamp() => Stopwatch.GetTimestamp();
|
||||
}
|
||||
|
||||
internal sealed class ThreadFramePacingWaiter : IFramePacingWaiter
|
||||
{
|
||||
public static ThreadFramePacingWaiter Instance { get; } = new();
|
||||
|
||||
private ThreadFramePacingWaiter()
|
||||
{
|
||||
}
|
||||
|
||||
public void Wait(long durationTicks, long clockFrequency)
|
||||
{
|
||||
if (durationTicks <= 0 || clockFrequency <= 0)
|
||||
return;
|
||||
|
||||
double milliseconds = durationTicks * 1000d / clockFrequency;
|
||||
|
||||
// A kernel sleep is intentionally preferred over a final spin. It can
|
||||
// overshoot a presentation deadline slightly, but it keeps normal CPU
|
||||
// use low—the central reason this fallback exists.
|
||||
int sleepMilliseconds = Math.Max(
|
||||
1,
|
||||
(int)Math.Ceiling(Math.Min(milliseconds, int.MaxValue)));
|
||||
Thread.Sleep(sleepMilliseconds);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10871,7 +10871,9 @@ public sealed class GameWindow : IDisposable
|
|||
$"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
|
||||
+ $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
|
||||
+ $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})";
|
||||
if (_options.UiProbeEnabled)
|
||||
// Script automation must remain performance-neutral. Only the
|
||||
// explicit dump switch requests this allocation-heavy snapshot.
|
||||
if (_options.UiProbeDump)
|
||||
{
|
||||
(int particleSets, long particleBytes) =
|
||||
_particleRenderer?.DynamicBufferDiagnostics ?? default;
|
||||
|
|
@ -12893,7 +12895,11 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private void OnFrameRendered(double _)
|
||||
=> _framePacing.CompleteFrame();
|
||||
{
|
||||
using var pacingStage = _frameProfiler.BeginStage(
|
||||
AcDream.App.Diagnostics.FrameStage.Pacing);
|
||||
_framePacing.CompleteFrame();
|
||||
}
|
||||
|
||||
private void OnWindowMoved(Silk.NET.Maths.Vector2D<int> _)
|
||||
=> RefreshActiveMonitorFramePacing();
|
||||
|
|
@ -15022,6 +15028,9 @@ public sealed class GameWindow : IDisposable
|
|||
}
|
||||
|
||||
private void OnClosing()
|
||||
=> CompleteShutdown();
|
||||
|
||||
private void CompleteShutdown()
|
||||
{
|
||||
_shutdown ??= CreateShutdownTransaction();
|
||||
try
|
||||
|
|
@ -15036,7 +15045,6 @@ public sealed class GameWindow : IDisposable
|
|||
// context/process teardown is the final safety net.
|
||||
Console.Error.WriteLine($"[shutdown] {error}");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private ResourceShutdownTransaction CreateShutdownTransaction() => new(
|
||||
|
|
@ -15160,6 +15168,7 @@ public sealed class GameWindow : IDisposable
|
|||
new("text renderer", () => _textRenderer?.Dispose()),
|
||||
new("debug font", () => _debugFont?.Dispose()),
|
||||
new("frame profiler", _frameProfiler.Dispose),
|
||||
new("frame pacing", _framePacing.Dispose),
|
||||
]),
|
||||
new ResourceShutdownStage("frame flight owner",
|
||||
[
|
||||
|
|
@ -15202,7 +15211,17 @@ public sealed class GameWindow : IDisposable
|
|||
EndMouseLookAndRestoreCursor();
|
||||
}
|
||||
|
||||
public void Dispose() => _window?.Dispose();
|
||||
public void Dispose()
|
||||
{
|
||||
// Closing is the normal path and runs while the render context is
|
||||
// current. This direct call also covers a constructed-but-never-run
|
||||
// window and exceptions during Window.Create/Run, so owned kernel
|
||||
// handles (including the frame timer) never depend on a native window
|
||||
// event for disposal.
|
||||
CompleteShutdown();
|
||||
_window?.Dispose();
|
||||
_window = null;
|
||||
}
|
||||
|
||||
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
|
|
@ -38,8 +39,7 @@ internal interface IRetailAlphaDrawSource
|
|||
internal readonly record struct RetailAlphaSubmission(
|
||||
IRetailAlphaDrawSource Source,
|
||||
int Token,
|
||||
float ViewerDistance,
|
||||
long Sequence);
|
||||
float ViewerDistance);
|
||||
|
||||
/// <summary>
|
||||
/// 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 int[] _tokenScratch = new int[256];
|
||||
private int[] _sourceDrawOffsets = new int[4];
|
||||
private long _nextSequence;
|
||||
private RetailAlphaSubmission[] _sortScratch = new RetailAlphaSubmission[256];
|
||||
private readonly int[] _radixOffsets = new int[256];
|
||||
|
||||
public bool IsCollecting { get; private set; }
|
||||
internal int PendingCount => _submissions.Count;
|
||||
|
|
@ -74,7 +75,6 @@ internal sealed class RetailAlphaQueue
|
|||
if (_submissions.Count != 0 || _sources.Count != 0)
|
||||
throw new InvalidOperationException("Retail alpha queue retained payload outside a frame.");
|
||||
|
||||
_nextSequence = 0;
|
||||
IsCollecting = true;
|
||||
}
|
||||
|
||||
|
|
@ -89,8 +89,7 @@ internal sealed class RetailAlphaQueue
|
|||
_submissions.Add(new RetailAlphaSubmission(
|
||||
source,
|
||||
token,
|
||||
NormalizeDistance(viewerDistance),
|
||||
_nextSequence++));
|
||||
NormalizeDistance(viewerDistance)));
|
||||
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
if (ReferenceEquals(_sources[i], source))
|
||||
|
|
@ -114,7 +113,7 @@ internal sealed class RetailAlphaQueue
|
|||
if (_submissions.Count == 0)
|
||||
return;
|
||||
|
||||
_submissions.Sort(CompareRetailOrder);
|
||||
SortRetailOrder();
|
||||
EnsureTokenCapacity(_submissions.Count);
|
||||
EnsureSourceCapacity(_sources.Count);
|
||||
Array.Clear(_sourceDrawOffsets, 0, _sources.Count);
|
||||
|
|
@ -178,17 +177,61 @@ internal sealed class RetailAlphaQueue
|
|||
}
|
||||
}
|
||||
|
||||
private static int CompareRetailOrder(RetailAlphaSubmission left, RetailAlphaSubmission right)
|
||||
private void SortRetailOrder()
|
||||
{
|
||||
// CShadowPart::insertion_sort 0x006B5130 produces descending CYpt
|
||||
// (far-to-near). Sequence makes the List.Sort result stable for equal
|
||||
// distances, preserving part/surface append order like retail.
|
||||
int distance = right.ViewerDistance.CompareTo(left.ViewerDistance);
|
||||
return distance != 0 ? distance : left.Sequence.CompareTo(right.Sequence);
|
||||
int count = _submissions.Count;
|
||||
if (count <= 1)
|
||||
return;
|
||||
|
||||
EnsureSortCapacity(count);
|
||||
Span<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)
|
||||
=> 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)
|
||||
{
|
||||
|
|
@ -204,6 +247,13 @@ internal sealed class RetailAlphaQueue
|
|||
Array.Resize(ref _sourceDrawOffsets, count + 4);
|
||||
}
|
||||
|
||||
private void EnsureSortCapacity(int count)
|
||||
{
|
||||
if (_sortScratch.Length >= count)
|
||||
return;
|
||||
Array.Resize(ref _sortScratch, count + 256);
|
||||
}
|
||||
|
||||
private int FindSourceIndex(IRetailAlphaDrawSource source)
|
||||
{
|
||||
for (int i = 0; i < _sources.Count; i++)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ namespace AcDream.App.Rendering.Wb;
|
|||
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
|
||||
/// <see cref="LocalSortCenter"/> preserves the authored GfxObj key used by
|
||||
/// 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
|
||||
/// <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,
|
||||
ulong BindlessTextureHandle,
|
||||
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>
|
||||
/// 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 LandblockHint { 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>
|
||||
/// tuple. Defensive: if an entry already exists, replaces it.
|
||||
/// </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
|
||||
{
|
||||
EntityId = entityId,
|
||||
LandblockHint = landblockHint,
|
||||
Batches = batches,
|
||||
SelectionParts = selectionParts ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,29 @@ namespace AcDream.App.Rendering.Wb
|
|||
private readonly ManualResetEventSlim _preparationWorkAvailable = new(false);
|
||||
private const int MaxParallelLoads = 4;
|
||||
|
||||
internal enum PreparationWorkerWakeAction
|
||||
{
|
||||
Process,
|
||||
ResetAndWait,
|
||||
Exit,
|
||||
}
|
||||
|
||||
internal static PreparationWorkerWakeAction DecidePreparationWorkerWake(
|
||||
bool isDisposed,
|
||||
bool hasPendingRequests,
|
||||
bool stagingAtHighWater,
|
||||
bool arenaBackpressured)
|
||||
{
|
||||
// Shutdown has priority over every ordinary idle/backpressure state.
|
||||
// Dispose sets one shared manual-reset signal for all persistent
|
||||
// workers; no worker may reset that signal before its peers wake.
|
||||
if (isDisposed)
|
||||
return PreparationWorkerWakeAction.Exit;
|
||||
if (!hasPendingRequests || stagingAtHighWater || arenaBackpressured)
|
||||
return PreparationWorkerWakeAction.ResetAndWait;
|
||||
return PreparationWorkerWakeAction.Process;
|
||||
}
|
||||
|
||||
private sealed class ObjectReleaseTicket(
|
||||
ulong id,
|
||||
ObjectRenderData data,
|
||||
|
|
@ -911,14 +934,20 @@ namespace AcDream.App.Rendering.Wb
|
|||
{
|
||||
// IsDisposed re-check lets Dispose cancel and join every
|
||||
// tracked worker before the DAT mappings are released.
|
||||
if (IsDisposed
|
||||
|| _pendingRequests.Count == 0
|
||||
|| _stagedMeshData.IsAtHighWater
|
||||
|| _arenaBackpressured)
|
||||
// Exit WITHOUT resetting the shared manual-reset event:
|
||||
// Dispose sets it once to wake all four persistent workers.
|
||||
// If the first worker reset it, the remaining three slept
|
||||
// forever and graceful client shutdown deadlocked.
|
||||
PreparationWorkerWakeAction wakeAction = DecidePreparationWorkerWake(
|
||||
IsDisposed,
|
||||
_pendingRequests.Count != 0,
|
||||
_stagedMeshData.IsAtHighWater,
|
||||
_arenaBackpressured);
|
||||
if (wakeAction == PreparationWorkerWakeAction.Exit)
|
||||
return;
|
||||
if (wakeAction == PreparationWorkerWakeAction.ResetAndWait)
|
||||
{
|
||||
_preparationWorkAvailable.Reset();
|
||||
if (IsDisposed)
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -470,7 +470,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// This entity's selected point/spot light set — computed ONCE per entity at
|
||||
// the isNewEntity site (constant across the entity's parts/tuples), exactly
|
||||
// like _currentEntitySlot. -1 = unused slot.
|
||||
private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject];
|
||||
private readonly int[] _currentEntityLightSetScratch = new int[LightManager.MaxLightsPerObject];
|
||||
private InstanceLightSet _currentEntityLightSet = InstanceLightSet.Disabled;
|
||||
|
||||
// #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet,
|
||||
// parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires
|
||||
|
|
@ -517,7 +518,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
private CullMode[] _drawCullModes = new CullMode[256];
|
||||
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
|
||||
private readonly List<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 _transparentDrawCount;
|
||||
|
|
@ -541,15 +544,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
GroupKey Key,
|
||||
Matrix4x4 Model,
|
||||
uint ClipSlot,
|
||||
AlphaLightSet Lights,
|
||||
InstanceLightSet Lights,
|
||||
uint Indoor,
|
||||
float Opacity,
|
||||
Vector2 SelectionLighting);
|
||||
|
||||
private readonly record struct AlphaLightSet(
|
||||
internal readonly record struct InstanceLightSet(
|
||||
int L0, int L1, int L2, int L3,
|
||||
int L4, int L5, int L6, int L7)
|
||||
{
|
||||
public static InstanceLightSet Disabled { get; } = new(
|
||||
-1, -1, -1, -1, -1, -1, -1, -1);
|
||||
|
||||
public static InstanceLightSet From(ReadOnlySpan<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
|
||||
{
|
||||
0 => L0, 1 => L1, 2 => L2, 3 => L3,
|
||||
|
|
@ -590,6 +618,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// are contiguous — accumulate them all before flushing one Populate call.
|
||||
// Animated entities skip this scratch entirely (collector = null).
|
||||
private readonly List<CachedBatch> _populateScratch = new();
|
||||
private readonly List<CachedSelectionPart> _populateSelectionScratch = new();
|
||||
|
||||
// Per-entity-cull AABB radius. Conservative — covers most entities; large
|
||||
// outliers (long banners, tall columns) are still landblock-culled.
|
||||
|
|
@ -720,8 +749,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
|
||||
_alphaQueue = alphaQueue;
|
||||
_alphaSource = new AlphaDrawSource(this);
|
||||
_cacheHitAppender = AppendInstanceToGroup;
|
||||
|
||||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||||
}
|
||||
|
||||
|
|
@ -735,6 +762,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
{
|
||||
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
|
||||
throw new ArgumentOutOfRangeException(nameof(frameSlot));
|
||||
if (_groupFrame == long.MaxValue)
|
||||
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
|
||||
|
||||
_groupFrame++;
|
||||
PruneInstanceGroupsUnusedBeforeFrame(
|
||||
_groups,
|
||||
_retiredGroupKeys,
|
||||
_groupFrame - 1);
|
||||
_dynamicFrameSlot = frameSlot;
|
||||
_dynamicBufferSetCursor = 0;
|
||||
_dynamicFrameStarted = true;
|
||||
|
|
@ -1317,7 +1352,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
camPos = invView.Translation;
|
||||
|
||||
// ── Phase 1: clear groups, walk entities, build groups ──────────────
|
||||
foreach (var grp in _groups.Values) grp.ClearPerInstanceData();
|
||||
// Draw is invoked several times per frame (landscape slices, late
|
||||
// dynamics, paperdoll). Per-dispatch payloads reset here, while group
|
||||
// retirement happens once in BeginFrame from whole-frame liveness.
|
||||
foreach (InstanceGroup group in _groups.Values)
|
||||
group.ClearPerInstanceData();
|
||||
|
||||
var metaTable = _meshAdapter.MetadataTable;
|
||||
uint anyVao = 0;
|
||||
|
|
@ -1467,6 +1506,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (populateEntityId.HasValue && currentEntityIncomplete)
|
||||
{
|
||||
_populateScratch.Clear();
|
||||
_populateSelectionScratch.Clear();
|
||||
populateEntityId = null;
|
||||
}
|
||||
currentEntityIncomplete = false;
|
||||
|
|
@ -1513,7 +1553,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// entities (including this-entity-culled) so the PREVIOUS entity's
|
||||
// cache always flushes at the boundary.
|
||||
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange(
|
||||
populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch);
|
||||
populateEntityId, populateLandblockId, entity.Id, _cache,
|
||||
_populateScratch, _populateSelectionScratch);
|
||||
|
||||
// Phase U.4: a culled entity (cell not visible, or no outdoors visible
|
||||
// for an outdoor stab) contributes NO instances. Skip after the
|
||||
|
|
@ -1548,14 +1589,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// body via the lastHitEntityId == entity.Id check above.
|
||||
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry))
|
||||
{
|
||||
ApplyCacheHit(cachedEntry!, entityWorld, _cacheHitAppender);
|
||||
ApplyCacheHitDirect(cachedEntry!, entityWorld);
|
||||
|
||||
// The cache is populated only after every MeshRef rendered
|
||||
// successfully. Publish the same parts for retail picking now;
|
||||
// CPhysicsPart::Draw only participates after the visible draw
|
||||
// path has accepted a real part.
|
||||
if (_selectionSink is not null)
|
||||
PublishCachedSelectionParts(entity, entityWorld);
|
||||
PublishCachedSelectionParts(cachedEntry!, entity, entityWorld);
|
||||
|
||||
// anyVao recovery: when the first visible entity in the frame
|
||||
// takes the fast path, no slow-path lookup has populated
|
||||
|
|
@ -1673,6 +1714,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// their MeshRefs; the flush at next-entity-boundary (or
|
||||
// end-of-loop) commits them as a single Populate call.
|
||||
var collector = isAnimated ? null : _populateScratch;
|
||||
var selectionCollector = isAnimated ? null : _populateSelectionScratch;
|
||||
|
||||
bool drewAny = false;
|
||||
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
|
||||
|
|
@ -1748,6 +1790,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
|
||||
(uint)partGfxObjId,
|
||||
model);
|
||||
selectionCollector?.Add(new CachedSelectionPart(
|
||||
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
|
||||
(uint)partGfxObjId,
|
||||
restPose));
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1774,6 +1820,10 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
partIdx,
|
||||
(uint)gfxObjId,
|
||||
model);
|
||||
selectionCollector?.Add(new CachedSelectionPart(
|
||||
partIdx,
|
||||
(uint)gfxObjId,
|
||||
meshRef.PartTransform));
|
||||
drewAny = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1801,6 +1851,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (currentEntityIncomplete)
|
||||
{
|
||||
_populateScratch.Clear();
|
||||
_populateSelectionScratch.Clear();
|
||||
populateEntityId = null;
|
||||
}
|
||||
|
||||
|
|
@ -1808,7 +1859,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// to trigger the entity-change flush, so commit its accumulated batches
|
||||
// here. No-op when the last entity was animated (populateEntityId stays
|
||||
// null) or when no entities walked at all.
|
||||
FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch);
|
||||
FinalFlushPopulate(
|
||||
populateEntityId, populateLandblockId, _cache,
|
||||
_populateScratch, _populateSelectionScratch);
|
||||
|
||||
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): the per-slot instance
|
||||
// histogram exactly as it will be uploaded to binding=3 (grp.Slots) plus the
|
||||
|
|
@ -1829,8 +1882,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
|
||||
// ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
|
||||
int totalInstances = 0;
|
||||
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
|
||||
bool deferTransparent = _alphaQueue?.IsCollecting == true;
|
||||
var instanceCounts = PartitionInstanceGroups(
|
||||
_groups.Values,
|
||||
deferTransparent,
|
||||
camPos,
|
||||
_opaqueDraws,
|
||||
_translucentDraws);
|
||||
int totalInstances = instanceCounts.VisibleInstances;
|
||||
int immediateInstances = instanceCounts.ImmediateInstances;
|
||||
if (totalInstances == 0)
|
||||
{
|
||||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||||
|
|
@ -1839,7 +1899,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
return;
|
||||
}
|
||||
|
||||
int needed = totalInstances * 16;
|
||||
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
|
||||
if (deferTransparent)
|
||||
DeferTransparentGroups(camPos, vp);
|
||||
else
|
||||
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
|
||||
|
||||
int needed = immediateInstances * 16;
|
||||
if (_instanceData.Length < needed)
|
||||
_instanceData = new float[needed + 256 * 16];
|
||||
|
||||
|
|
@ -1847,77 +1913,38 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// count and lay it out in the SAME group order / cursor as _instanceData,
|
||||
// so instanceClipSlot[i] (binding=3) tracks Instances[i] (binding=0). On
|
||||
// the U.3 / outdoor path every Slots entry is 0 ⇒ identical to U.3.
|
||||
if (_clipSlotData.Length < totalInstances)
|
||||
_clipSlotData = new uint[totalInstances + 256];
|
||||
if (_clipSlotData.Length < immediateInstances)
|
||||
_clipSlotData = new uint[immediateInstances + 256];
|
||||
|
||||
// Fix B: per-instance light-set buffer, MaxLightsPerObject ints per
|
||||
// instance, laid out in the SAME group order / cursor as _instanceData
|
||||
// so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks
|
||||
// Instances[instanceIndex] (binding=0).
|
||||
if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject)
|
||||
_lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject];
|
||||
if (_lightSetData.Length < immediateInstances * LightManager.MaxLightsPerObject)
|
||||
_lightSetData = new int[(immediateInstances + 256) * LightManager.MaxLightsPerObject];
|
||||
|
||||
// #142: per-instance indoor flag buffer, one uint per instance, parallel to
|
||||
// _clipSlotData / _instanceData. Grown on demand like the others.
|
||||
if (_indoorData.Length < totalInstances)
|
||||
_indoorData = new uint[totalInstances + 256];
|
||||
if (_indoorData.Length < immediateInstances)
|
||||
_indoorData = new uint[immediateInstances + 256];
|
||||
|
||||
// #188: per-instance opacity buffer, one float per instance, parallel to
|
||||
// _clipSlotData / _instanceData. Grown on demand like the others.
|
||||
if (_alphaData.Length < totalInstances)
|
||||
_alphaData = new float[totalInstances + 256];
|
||||
if (_alphaData.Length < immediateInstances)
|
||||
_alphaData = new float[immediateInstances + 256];
|
||||
|
||||
if (_selectionLightingData.Length < totalInstances)
|
||||
_selectionLightingData = new Vector2[totalInstances + 256];
|
||||
|
||||
_opaqueDraws.Clear();
|
||||
_translucentDraws.Clear();
|
||||
if (_selectionLightingData.Length < immediateInstances)
|
||||
_selectionLightingData = new Vector2[immediateInstances + 256];
|
||||
|
||||
int cursor = 0;
|
||||
foreach (var grp in _groups.Values)
|
||||
foreach (InstanceGroup grp in _opaqueDraws)
|
||||
StageImmediateGroup(grp, ref cursor);
|
||||
if (!deferTransparent)
|
||||
{
|
||||
if (grp.Matrices.Count == 0) continue;
|
||||
|
||||
grp.FirstInstance = cursor;
|
||||
grp.InstanceCount = grp.Matrices.Count;
|
||||
|
||||
// Use the first instance's translation as the group's representative
|
||||
// position for front-to-back sort (perf #2). Cheap heuristic; works
|
||||
// well when instances of one group are spatially coherent
|
||||
// (typical for trees in one landblock area, NPCs at one spawn).
|
||||
var first = grp.Matrices[0];
|
||||
var grpPos = new Vector3(first.M41, first.M42, first.M43);
|
||||
grp.SortDistance = Vector3.DistanceSquared(camPos, grpPos);
|
||||
|
||||
for (int i = 0; i < grp.Matrices.Count; i++)
|
||||
{
|
||||
WriteMatrix(_instanceData, cursor * 16, grp.Matrices[i]);
|
||||
// Slots[] is parallel to Matrices[] within the group; write the
|
||||
// slot at the same cursor so binding=3 stays aligned with binding=0.
|
||||
_clipSlotData[cursor] = grp.Slots[i];
|
||||
// Fix B: LightSets[] holds 8 ints per instance, parallel to
|
||||
// Matrices[]; copy this instance's block to the same cursor so
|
||||
// binding=5 stays aligned with binding=0.
|
||||
int lsDst = cursor * LightManager.MaxLightsPerObject;
|
||||
int lsSrc = i * LightManager.MaxLightsPerObject;
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
_lightSetData[lsDst + k] = grp.LightSets[lsSrc + k];
|
||||
// #142: IndoorFlags[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=6 instanceIndoor[] tracks binding=0 instances.
|
||||
_indoorData[cursor] = grp.IndoorFlags[i];
|
||||
// #188: Opacities[] is parallel to Matrices[]; write at the same
|
||||
// cursor so binding=7 instanceAlpha[] tracks binding=0 instances.
|
||||
_alphaData[cursor] = grp.Opacities[i];
|
||||
// SmartBox CMaterial replacement, parallel to Matrices.
|
||||
_selectionLightingData[cursor] = grp.SelectionLighting[i];
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (IsOpaque(grp.Translucency))
|
||||
_opaqueDraws.Add(grp);
|
||||
else
|
||||
_translucentDraws.Add(grp);
|
||||
foreach (InstanceGroup grp in _translucentDraws)
|
||||
StageImmediateGroup(grp, ref cursor);
|
||||
}
|
||||
System.Diagnostics.Debug.Assert(cursor == immediateInstances);
|
||||
|
||||
// Front-to-back sort within each cull mode. DrawIndirectRange must
|
||||
// split MDI calls whenever CullMode changes because GL state is not
|
||||
|
|
@ -1931,13 +1958,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// submission so the shared queue can interleave it with particles.
|
||||
// Immediate mode remains for sealed off-screen consumers such as the
|
||||
// paperdoll and UI Studio render stack.
|
||||
bool deferTransparent = _alphaQueue?.IsCollecting == true;
|
||||
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
|
||||
if (deferTransparent)
|
||||
DeferTransparentGroups(camPos, vp);
|
||||
else
|
||||
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
|
||||
|
||||
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
|
||||
// fill via BuildIndirectArrays ──────────────────────────────────
|
||||
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
|
||||
|
|
@ -1998,7 +2018,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
ActivateNextDynamicBufferSet();
|
||||
fixed (float* ip = _instanceData)
|
||||
UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes,
|
||||
ip, totalInstances * 16 * sizeof(float));
|
||||
ip, immediateInstances * 16 * sizeof(float));
|
||||
|
||||
fixed (BatchData* bp = _batchData)
|
||||
UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes,
|
||||
|
|
@ -2009,33 +2029,33 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the
|
||||
// U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to
|
||||
// U.3); under indoor routing it holds the per-instance slot from
|
||||
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly totalInstances
|
||||
// entries; only [0..totalInstances) is uploaded, so any stale tail is
|
||||
// never read by the shader (BaseInstance + gl_InstanceID < totalInstances).
|
||||
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances
|
||||
// entries; only [0..immediateInstances) is uploaded, so any stale tail is
|
||||
// never read by the shader.
|
||||
fixed (uint* sp = _clipSlotData)
|
||||
UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes,
|
||||
sp, totalInstances * sizeof(uint));
|
||||
sp, immediateInstances * sizeof(uint));
|
||||
|
||||
// #142: per-instance indoor flag buffer (binding=6), one uint per instance,
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
|
||||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||||
fixed (uint* dp = _indoorData)
|
||||
UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes,
|
||||
dp, totalInstances * sizeof(uint));
|
||||
dp, immediateInstances * sizeof(uint));
|
||||
|
||||
// #188: per-instance opacity buffer (binding=7), one float per instance,
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..totalInstances)
|
||||
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
|
||||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||||
fixed (float* ap = _alphaData)
|
||||
UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes,
|
||||
ap, totalInstances * sizeof(float));
|
||||
ap, immediateInstances * sizeof(float));
|
||||
|
||||
// SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert
|
||||
// reads this only for the object path (uLightingMode=0), so EnvCell's
|
||||
// independent mode-1 renderer does not consume this binding.
|
||||
fixed (Vector2* hp = _selectionLightingData)
|
||||
UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes,
|
||||
hp, totalInstances * sizeof(float) * 2);
|
||||
hp, immediateInstances * sizeof(float) * 2);
|
||||
|
||||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||||
|
|
@ -2045,7 +2065,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
UploadGlobalLights();
|
||||
fixed (int* lp = _lightSetData)
|
||||
UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes,
|
||||
lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int));
|
||||
lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int));
|
||||
|
||||
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
|
||||
{
|
||||
|
|
@ -2234,41 +2254,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
set: set);
|
||||
}
|
||||
|
||||
private void PublishCachedSelectionParts(WorldEntity entity, Matrix4x4 entityWorld)
|
||||
private void PublishCachedSelectionParts(
|
||||
EntityCacheEntry cachedEntry,
|
||||
WorldEntity entity,
|
||||
Matrix4x4 entityWorld)
|
||||
{
|
||||
for (int outerPartIndex = 0; outerPartIndex < entity.MeshRefs.Count; outerPartIndex++)
|
||||
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
|
||||
{
|
||||
var meshRef = entity.MeshRefs[outerPartIndex];
|
||||
var renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
|
||||
if (renderData is null)
|
||||
continue;
|
||||
|
||||
if (!renderData.IsSetup || renderData.SetupParts.Count == 0)
|
||||
{
|
||||
_selectionSink!.AddVisiblePart(
|
||||
entity,
|
||||
outerPartIndex,
|
||||
meshRef.GfxObjId,
|
||||
meshRef.PartTransform * entityWorld);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (int setupPartIndex = 0;
|
||||
setupPartIndex < renderData.SetupParts.Count;
|
||||
setupPartIndex++)
|
||||
{
|
||||
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
|
||||
if (_meshAdapter.TryGetRenderData(partGfxObjId) is null)
|
||||
continue;
|
||||
_selectionSink!.AddVisiblePart(
|
||||
entity,
|
||||
unchecked((outerPartIndex << 16) | (setupPartIndex & 0xFFFF)),
|
||||
(uint)partGfxObjId,
|
||||
ComposePartWorldMatrix(
|
||||
entityWorld,
|
||||
meshRef.PartTransform,
|
||||
partTransform));
|
||||
}
|
||||
_selectionSink!.AddVisiblePart(
|
||||
entity,
|
||||
part.PartIndex,
|
||||
part.GfxObjId,
|
||||
part.RestPose * entityWorld);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2283,6 +2280,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
Translucency: g.Translucency,
|
||||
CullMode: g.CullMode);
|
||||
|
||||
internal readonly record struct InstanceLayoutCounts(
|
||||
int VisibleInstances,
|
||||
int ImmediateInstances);
|
||||
|
||||
internal static InstanceLayoutCounts PartitionInstanceGroups(
|
||||
IEnumerable<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(
|
||||
g.FirstIndex,
|
||||
g.BaseVertex,
|
||||
|
|
@ -2312,12 +2370,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
localSortCenter,
|
||||
model,
|
||||
cameraWorldPosition);
|
||||
int lightOffset = i * LightManager.MaxLightsPerObject;
|
||||
var lights = new AlphaLightSet(
|
||||
group.LightSets[lightOffset + 0], group.LightSets[lightOffset + 1],
|
||||
group.LightSets[lightOffset + 2], group.LightSets[lightOffset + 3],
|
||||
group.LightSets[lightOffset + 4], group.LightSets[lightOffset + 5],
|
||||
group.LightSets[lightOffset + 6], group.LightSets[lightOffset + 7]);
|
||||
InstanceLightSet lights = group.LightSets[i];
|
||||
|
||||
int token = _deferredAlpha.Count;
|
||||
_deferredAlpha.Add(new DeferredAlphaInstance(
|
||||
|
|
@ -2353,8 +2406,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
_alphaData[i] = entry.Opacity;
|
||||
_selectionLightingData[i] = entry.SelectionLighting;
|
||||
int lightOffset = i * LightManager.MaxLightsPerObject;
|
||||
for (int light = 0; light < LightManager.MaxLightsPerObject; light++)
|
||||
_lightSetData[lightOffset + light] = entry.Lights[light];
|
||||
entry.Lights.CopyTo(_lightSetData, lightOffset);
|
||||
|
||||
GroupKey key = entry.Key;
|
||||
_batchData[i] = new BatchData
|
||||
|
|
@ -2876,6 +2928,67 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
internal static bool TryResolveCachedGroup(
|
||||
CachedBatch cached,
|
||||
out InstanceGroup? group)
|
||||
{
|
||||
group = cached.Group;
|
||||
return group is not null
|
||||
&& cached.GroupRegistration != 0
|
||||
&& cached.GroupRegistration == group.Registration;
|
||||
}
|
||||
|
||||
private void ApplyCacheHitDirect(EntityCacheEntry entry, Matrix4x4 entityWorld)
|
||||
{
|
||||
for (int i = 0; i < entry.Batches.Length; i++)
|
||||
{
|
||||
CachedBatch cached = entry.Batches[i];
|
||||
Matrix4x4 model = cached.RestPose * entityWorld;
|
||||
if (!TryResolveCachedGroup(cached, out InstanceGroup? group))
|
||||
{
|
||||
group = GetOrCreateInstanceGroup(cached.Key);
|
||||
entry.Batches[i] = cached with
|
||||
{
|
||||
Group = group,
|
||||
GroupRegistration = group.Registration,
|
||||
};
|
||||
}
|
||||
AppendInstanceToGroup(group!, model, cached.LocalSortCenter);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Per-tuple flush check. If <paramref name="populateEntityId"/> is set
|
||||
/// AND differs from <paramref name="currentEntityId"/>, the previous
|
||||
|
|
@ -2897,15 +3010,21 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
uint populateLandblockId,
|
||||
uint currentEntityId,
|
||||
EntityClassificationCache cache,
|
||||
List<CachedBatch> populateScratch)
|
||||
List<CachedBatch> populateScratch,
|
||||
List<CachedSelectionPart>? selectionScratch = null)
|
||||
{
|
||||
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
|
||||
{
|
||||
if (populateScratch.Count > 0)
|
||||
{
|
||||
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
|
||||
cache.Populate(
|
||||
populateEntityId.Value,
|
||||
populateLandblockId,
|
||||
populateScratch.ToArray(),
|
||||
selectionScratch?.ToArray());
|
||||
}
|
||||
populateScratch.Clear();
|
||||
selectionScratch?.Clear();
|
||||
return (null, 0u);
|
||||
}
|
||||
return (populateEntityId, populateLandblockId);
|
||||
|
|
@ -2925,13 +3044,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
uint? populateEntityId,
|
||||
uint populateLandblockId,
|
||||
EntityClassificationCache cache,
|
||||
List<CachedBatch> populateScratch)
|
||||
List<CachedBatch> populateScratch,
|
||||
List<CachedSelectionPart>? selectionScratch = null)
|
||||
{
|
||||
if (populateEntityId.HasValue && populateScratch.Count > 0)
|
||||
{
|
||||
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
|
||||
cache.Populate(
|
||||
populateEntityId.Value,
|
||||
populateLandblockId,
|
||||
populateScratch.ToArray(),
|
||||
selectionScratch?.ToArray());
|
||||
populateScratch.Clear();
|
||||
}
|
||||
selectionScratch?.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -2941,20 +3066,46 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// </summary>
|
||||
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter)
|
||||
{
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
InstanceGroup grp = GetOrCreateInstanceGroup(key);
|
||||
AppendInstanceToGroup(grp, model, localSortCenter);
|
||||
}
|
||||
|
||||
private InstanceGroup GetOrCreateInstanceGroup(GroupKey key)
|
||||
{
|
||||
if (_groups.TryGetValue(key, out InstanceGroup? group))
|
||||
{
|
||||
grp = new InstanceGroup
|
||||
{
|
||||
FirstIndex = key.FirstIndex,
|
||||
BaseVertex = key.BaseVertex,
|
||||
IndexCount = key.IndexCount,
|
||||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||||
TextureLayer = key.TextureLayer,
|
||||
Translucency = key.Translucency,
|
||||
CullMode = key.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
group.LastUsedFrame = _groupFrame;
|
||||
return group;
|
||||
}
|
||||
|
||||
if (_nextGroupRegistration == long.MaxValue)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Instance-group registration space was exhausted before a safe identity could be assigned.");
|
||||
}
|
||||
|
||||
group = new InstanceGroup
|
||||
{
|
||||
FirstIndex = key.FirstIndex,
|
||||
BaseVertex = key.BaseVertex,
|
||||
IndexCount = key.IndexCount,
|
||||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||||
TextureLayer = key.TextureLayer,
|
||||
Translucency = key.Translucency,
|
||||
CullMode = key.CullMode,
|
||||
Registration = _nextGroupRegistration++,
|
||||
LastUsedFrame = _groupFrame,
|
||||
};
|
||||
_groups.Add(key, group);
|
||||
return group;
|
||||
}
|
||||
|
||||
private void AppendInstanceToGroup(
|
||||
InstanceGroup grp,
|
||||
Matrix4x4 model,
|
||||
Vector3 localSortCenter)
|
||||
{
|
||||
grp.LastUsedFrame = _groupFrame;
|
||||
grp.Matrices.Add(model);
|
||||
grp.LocalSortCenters.Add(localSortCenter);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
|
|
@ -3014,7 +3165,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
entity.Position.X, entity.Position.Y, entity.Position.Z,
|
||||
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
|
||||
bool any = false;
|
||||
for (int k = 0; k < _currentEntityLightSet.Length; k++)
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
{
|
||||
int idx = _currentEntityLightSet[k];
|
||||
if (idx < 0) continue;
|
||||
|
|
@ -3040,7 +3191,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// so they can't disagree — one call, one truth.
|
||||
_currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId);
|
||||
|
||||
Array.Fill(_currentEntityLightSet, -1);
|
||||
_currentEntityLightSet = InstanceLightSet.Disabled;
|
||||
var snap = _pointSnapshot;
|
||||
if (snap is null || snap.Count == 0) return;
|
||||
|
||||
|
|
@ -3050,7 +3201,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
if (entity.AabbDirty) entity.RefreshAabb();
|
||||
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
|
||||
float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f;
|
||||
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet);
|
||||
Array.Fill(_currentEntityLightSetScratch, -1);
|
||||
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch);
|
||||
_currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -3076,8 +3229,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
/// </summary>
|
||||
private void AppendCurrentLightSet(InstanceGroup grp)
|
||||
{
|
||||
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
|
||||
grp.LightSets.Add(_currentEntityLightSet[k]);
|
||||
grp.LightSets.Add(_currentEntityLightSet);
|
||||
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
|
||||
}
|
||||
|
||||
|
|
@ -3133,27 +3285,20 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
batch.FirstIndex, (int)batch.BaseVertex,
|
||||
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
|
||||
|
||||
if (!_groups.TryGetValue(key, out var grp))
|
||||
{
|
||||
grp = new InstanceGroup
|
||||
{
|
||||
FirstIndex = batch.FirstIndex,
|
||||
BaseVertex = (int)batch.BaseVertex,
|
||||
IndexCount = batch.IndexCount,
|
||||
BindlessTextureHandle = texHandle,
|
||||
TextureLayer = texLayer,
|
||||
Translucency = translucency,
|
||||
CullMode = batch.CullMode,
|
||||
};
|
||||
_groups[key] = grp;
|
||||
}
|
||||
InstanceGroup grp = GetOrCreateInstanceGroup(key);
|
||||
grp.Matrices.Add(model);
|
||||
grp.LocalSortCenters.Add(renderData.SortCenter);
|
||||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||||
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
|
||||
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
|
||||
collector?.Add(new CachedBatch(key, texHandle, restPose, renderData.SortCenter));
|
||||
collector?.Add(new CachedBatch(
|
||||
key,
|
||||
texHandle,
|
||||
restPose,
|
||||
renderData.SortCenter,
|
||||
grp,
|
||||
grp.Registration));
|
||||
}
|
||||
return allTexturesReady;
|
||||
}
|
||||
|
|
@ -3577,6 +3722,11 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
|
||||
internal sealed class InstanceGroup
|
||||
{
|
||||
// Nonzero only while this exact object is registered in _groups.
|
||||
// CachedBatch stores the value alongside the reference; retirement
|
||||
// zeros it before removal so stale handles cannot append off-table.
|
||||
public long Registration;
|
||||
public long LastUsedFrame;
|
||||
public uint FirstIndex;
|
||||
public int BaseVertex;
|
||||
public int IndexCount;
|
||||
|
|
@ -3601,13 +3751,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
|
||||
public readonly List<uint> Slots = new();
|
||||
|
||||
// Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per
|
||||
// instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected
|
||||
// light index block for the instance whose matrix is Matrices[i]). At
|
||||
// Fix B (A7 #3): one packed eight-index light set per instance, parallel
|
||||
// to Matrices (LightSets[i] belongs to Matrices[i]). At
|
||||
// layout time the dispatcher copies each block into _lightSetData at the
|
||||
// same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0
|
||||
// instance. -1 = unused slot.
|
||||
public readonly List<int> LightSets = new();
|
||||
public readonly List<InstanceLightSet> LightSets = new();
|
||||
|
||||
// #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is
|
||||
// 1 when the instance's entity is parented to an EnvCell (skip the sun); 0
|
||||
|
|
@ -3646,5 +3795,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
|
|||
Opacities.Clear();
|
||||
SelectionLighting.Clear();
|
||||
}
|
||||
|
||||
public void ReleasePerInstanceStorage()
|
||||
{
|
||||
ClearPerInstanceData();
|
||||
Matrices.TrimExcess();
|
||||
LocalSortCenters.TrimExcess();
|
||||
Slots.TrimExcess();
|
||||
LightSets.TrimExcess();
|
||||
IndoorFlags.TrimExcess();
|
||||
Opacities.TrimExcess();
|
||||
SelectionLighting.TrimExcess();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue