fix #225: stabilize render pacing and frame CPU

Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal.

Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality.

Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-19 06:29:30 +02:00
parent 47d7086a74
commit 3718e341be
17 changed files with 1103 additions and 225 deletions

View file

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

View file

@ -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 ──────────────────

View file

@ -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++)

View file

@ -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; } = [];
}

View file

@ -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 ?? [],
};
}

View file

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

View file

@ -470,7 +470,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// This entity's selected point/spot light set — computed ONCE per entity at
// 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();
}
}
}

View file

@ -0,0 +1,169 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace AcDream.App.Rendering;
/// <summary>
/// Low-CPU, sub-millisecond-capable deadline wait for the Windows render loop.
/// One auto-reset timer is reused for the complete window lifetime.
/// </summary>
/// <remarks>
/// <para>
/// A normal <see cref="Thread.Sleep(int)"/> is quantized by the Windows scheduler.
/// At a 165 Hz presentation target, its variable overshoot turned a requested
/// 6.1 ms frame into a 10-16 ms frame. A high-resolution waitable timer keeps
/// the render thread blocked in the kernel without that coarse sleep quantum.
/// </para>
/// <para>
/// <c>CREATE_WAITABLE_TIMER_HIGH_RESOLUTION</c> is available on Windows 10
/// version 1803 and newer, which is the client's supported desktop baseline.
/// Microsoft documents relative due times as negative 100-nanosecond units.
/// </para>
/// </remarks>
internal sealed partial class WindowsHighResolutionFramePacingWaiter :
IFramePacingWaiter,
IDisposable
{
private const uint CreateWaitableTimerHighResolution = 0x00000002;
private const uint TimerModifyState = 0x00000002;
private const uint Synchronize = 0x00100000;
private const uint WaitObject0 = 0x00000000;
private const uint WaitTimeout = 0x00000102;
private const uint WaitFailed = 0xFFFFFFFF;
private const long HundredNanosecondsPerSecond = 10_000_000;
private readonly SafeWaitHandle _timer;
private bool _disposed;
private WindowsHighResolutionFramePacingWaiter(SafeWaitHandle timer)
=> _timer = timer;
public static WindowsHighResolutionFramePacingWaiter Create()
{
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17134))
{
throw new PlatformNotSupportedException(
"acdream software frame pacing requires Windows 10 version 1803 or newer.");
}
SafeWaitHandle timer = CreateWaitableTimerExW(
0,
0,
CreateWaitableTimerHighResolution,
TimerModifyState | Synchronize);
if (timer.IsInvalid)
{
int error = Marshal.GetLastPInvokeError();
timer.Dispose();
throw new Win32Exception(error, "Could not create the high-resolution frame timer.");
}
return new WindowsHighResolutionFramePacingWaiter(timer);
}
public void Wait(long durationTicks, long clockFrequency)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (durationTicks <= 0 || clockFrequency <= 0)
return;
long hundredNanoseconds = ConvertTicksToHundredNanoseconds(
durationTicks,
clockFrequency);
long relativeDueTime = -hundredNanoseconds;
if (!SetWaitableTimerEx(
_timer,
in relativeDueTime,
periodMilliseconds: 0,
completionRoutine: 0,
completionArgument: 0,
wakeContext: 0,
tolerableDelayMilliseconds: 0))
{
throw new Win32Exception(
Marshal.GetLastPInvokeError(),
"Could not arm the high-resolution frame timer.");
}
uint result = WaitForSingleObject(
_timer,
ComputeFailureTimeoutMilliseconds(hundredNanoseconds));
if (result == WaitObject0)
return;
if (result == WaitTimeout)
{
throw new TimeoutException(
"The high-resolution frame timer did not signal before its safety timeout.");
}
int waitError = result == WaitFailed
? Marshal.GetLastPInvokeError()
: unchecked((int)result);
throw new Win32Exception(waitError, "Waiting on the high-resolution frame timer failed.");
}
internal static long ConvertTicksToHundredNanoseconds(
long durationTicks,
long clockFrequency)
{
if (durationTicks <= 0)
throw new ArgumentOutOfRangeException(nameof(durationTicks));
if (clockFrequency <= 0)
throw new ArgumentOutOfRangeException(nameof(clockFrequency));
long wholeSeconds = Math.DivRem(durationTicks, clockFrequency, out long remainder);
if (wholeSeconds >= long.MaxValue / HundredNanosecondsPerSecond)
return long.MaxValue;
long wholeIntervals = wholeSeconds * HundredNanosecondsPerSecond;
long fractionalIntervals = (long)Math.Ceiling(
remainder * (double)HundredNanosecondsPerSecond / clockFrequency);
if (wholeIntervals > long.MaxValue - fractionalIntervals)
return long.MaxValue;
return Math.Max(1, wholeIntervals + fractionalIntervals);
}
private static uint ComputeFailureTimeoutMilliseconds(long hundredNanoseconds)
{
double dueMilliseconds = hundredNanoseconds / 10_000d;
double timeoutMilliseconds = Math.Ceiling(dueMilliseconds) + 1_000d;
return timeoutMilliseconds >= uint.MaxValue - 1d
? uint.MaxValue - 1
: Math.Max(1u, (uint)timeoutMilliseconds);
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_timer.Dispose();
}
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial SafeWaitHandle CreateWaitableTimerExW(
nint timerAttributes,
nint timerName,
uint flags,
uint desiredAccess);
[LibraryImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool SetWaitableTimerEx(
SafeWaitHandle timer,
in long dueTime,
int periodMilliseconds,
nint completionRoutine,
nint completionArgument,
nint wakeContext,
uint tolerableDelayMilliseconds);
[LibraryImport("kernel32.dll", SetLastError = true)]
private static partial uint WaitForSingleObject(
SafeWaitHandle handle,
uint milliseconds);
}