acdream/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
Erik e6a87679b7
Some checks failed
Headless portability / portable-headless (ubuntu-latest) (push) Has been cancelled
Headless portability / portable-headless (windows-latest) (push) Has been cancelled
Headless portability / linux-graphical (push) Has been cancelled
Headless portability / linux-vulkan (push) Has been cancelled
fix(render): read TransparentPartHook opacity by the real part ordinal, not 0
The user reported crystal shards hovering in the air above every Bind
Stone on Coldeve (setup 0x020010AC) that do not exist in the retail
client. The DAT truth, extracted with the new tools/SetupInspect probe:
the model authors SEVEN parts - pedestal, spinning column, inner
crystal, and four shard meshes parked in a static ring at Z=3.0 in the
placement frame and every frame of the idle cycle - and frame 0 of that
idle cycle fires four TransparentPartHooks (parts 3-6, start=end=1.0)
each loop. Retail hides the shards through those hooks; the model
simply ships with permanently-hooked-invisible parts.

acdream's hook chain was intact end to end - the static-animating
workset captures the hooks (RetailStaticAnimatingObjectScheduler ->
AnimationHookFrameQueue -> TranslucencyHookSink), and
TranslucencyFadeManager committed translucency 1.0 for parts 3-6 -
but BOTH dispatchers' bare-GfxObj branch read the fade with a
hard-coded part index 0 under a false #188-era assumption ("a bare
GfxObj entity has exactly one part"). Every live server object is a
FLATTENED multi-part entity in exactly that branch: SetupMesh.Flatten
emits one bare-GfxObj MeshRef per Setup.Parts[i], order preserved,
AnimPartChanges replacing in place - so the MeshRef ordinal IS the
retail CPartArray ordinal TransparentPartHook.PartIndex addresses.
The committed invisibility for parts 3-6 was never consulted and the
shards drew forever. Proof the ordinal was trustworthy all along:
click-selection in the same loops already publishes it as the part
identity (Slice 4 picking runs on it in production).

Fix: both the legacy classifier and the packed oracle now pass the
per-part ordinal (partIdx / packedPart.PartIndex) to the translucency
lookup. Single-part objects still read index 0; the #188 door fades
are unchanged; the Setup-expanded branch already indexed correctly.
Any other object hiding authored parts via idle-loop hooks gets its
retail appearance from the same change.

tools/SetupInspect is the new reusable DAT probe that cracked this:
dumps a Setup's parts, parent indices, GfxObj vertex bounds, placement
frames, motion-table default cycle, sampled animation frames, and all
animation hooks.

Closes task #32's code side; the connected visual gate (shards gone at
the Bind Stone, base crystals and spin retained) is the acceptance.
App Release suite 3,968 / 3 skips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 22:26:45 +02:00

3774 lines
167 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Residency;
using AcDream.App.Rendering.Scene;
using AcDream.Core.Lighting;
using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using AcDream.App.Rendering.Selection;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
/// vertex/index arena under modern rendering) with acdream's
/// <see cref="TextureCache"/> for texture resolution. Exact pass classification
/// travels with each immutable prepared mesh batch.
///
/// <para>
/// <b>Atlas-tier</b> entities (<c>ServerGuid == 0</c>): mesh data comes from WB's
/// <see cref="ObjectMeshManager"/> via <see cref="WbMeshAdapter.TryGetRenderData"/>.
/// Shared textures reuse each batch's WB atlas handle and layer, returning
/// a device texture-table slot stored in the per-group SSBO.
/// </para>
///
/// <para>
/// <b>Per-instance-tier</b> entities (<c>ServerGuid != 0</c>): mesh data also from
/// WB. Native surfaces still reuse the WB atlas; only actual indexed-palette
/// and original-texture replacements resolve through owner-scoped
/// <see cref="TextureCache"/> composites. <see cref="AnimatedEntityState"/> is currently
/// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges +
/// GfxObjDegradeResolver (Issue #47 close-detail mesh) into <c>MeshRefs</c>.
/// </para>
///
/// <para>
/// <b>Draw strategy (Campaign V — Vulkan only):</b> multi-draw-indexed-indirect
/// with SSBOs, recorded through the RHI encoder in the sibling
/// <c>WbDrawDispatcher.Rhi.cs</c> partial. All visible (entity, batch) pairs are
/// bucketed by <see cref="GroupKey"/>; each group becomes one
/// <c>DrawElementsIndirectCommand</c>. Per-frame ring allocations carry instance
/// matrices (binding 0), per-group batch metadata/texture-table slots (binding
/// 1), and the indirect draw commands. Opaque world groups remain MDI-batched.
/// Transparent world instances enter <see cref="RetailAlphaQueue"/> so ordinary
/// GfxObj parts and particles share retail's stable far-to-near stream; sealed
/// off-screen consumers retain the immediate transparent MDI path.
/// </para>
///
/// <para>
/// <b>Shader:</b> <c>mesh_modern</c>, compiled from committed SPIR-V. Missing a
/// mandatory GPU capability (the device texture table, MDI, or SSBOs) throws at
/// renderer construction — there is no legacy fallback.
/// </para>
///
/// <para>
/// <b>Modern rendering assumption:</b> WB's modern-rendering path puts every
/// mesh in a single shared vertex/index arena and uses <c>FirstIndex</c> +
/// <c>BaseVertex</c> per batch. The dispatcher honors those offsets inside each
/// <c>DrawElementsIndirectCommand</c> via multi-draw-indexed-indirect.
/// </para>
/// </summary>
public sealed partial class WbDrawDispatcher : IDisposable
{
/// <summary>
/// Which subset of entities to walk in a single Draw call.
///
/// Phase U.1 (2026-05-30): the indoor/outdoor two-pipe split (IndoorPass /
/// OutdoorScenery / BuildingShells / LiveDynamic) was deleted along with the
/// inside-out render machinery. <see cref="All"/> is the sole remaining
/// member; the unified retail-faithful pass (Phase U) draws every entity in
/// one path. The <c>set:</c> parameter is retained on the Draw overloads so
/// the unified pass can re-introduce partitioning later without re-threading
/// the call sites.
/// </summary>
public enum EntitySet
{
/// <summary>Every entity walked, gated only by the existing
/// <c>ParentCellId ∈ visibleCellIds</c> filter.</summary>
All,
}
private readonly TextureCache _textures;
private readonly WbMeshAdapter _meshAdapter;
private readonly EntitySpawnAdapter _entitySpawnAdapter;
private readonly IRetailSelectionRenderSink? _selectionSink;
private readonly IRetailSelectionLightingSource? _selectionLighting;
private readonly RetailAlphaQueue? _alphaQueue;
private readonly AlphaDrawSource _alphaSource;
private readonly RetainedScratchCapacityPolicy _alphaScratchPolicy;
private int _scratchPeakUnits;
private ICurrentRenderDispatcherObserver? _currentRenderSceneObserver;
public readonly record struct DrawStats(
EntitySet Set,
int EntitiesWalked,
int MeshRefs,
int Instances,
int Draws,
int CullRuns,
int OpaqueDraws,
int TransparentDraws,
long Triangles);
public DrawStats LastDrawStats { get; private set; }
public bool CompositeTexturesReady { get; private set; } = true;
internal int LastCompositeWarmupPendingCount { get; private set; }
// Candidate discovery only reads already-published entity facts. Keep it
// independently bounded from the materially more expensive mesh/texture
// preparation below so a large retained Far-tier world cannot consume the
// entire portal reveal window merely proving that most entities are
// outside the destination neighborhood.
internal const int MaximumCompositeWarmupScanEntitiesPerFrame = 4096;
internal const int MaximumCompositeWarmupPrepareEntitiesPerFrame = 128;
private readonly Queue<WorldEntity> _compositeWarmupQueue = new();
// Membership may change while ACE is streaming the destination object set.
// Retain exact candidate progress and schedule a follow-up pass instead of
// restarting at index zero on every generation edge.
private readonly HashSet<WorldEntity> _compositeWarmupTracked = [];
private IReadOnlyList<WorldEntity>? _compositeWarmupSource;
private ulong _compositeWarmupSourceGeneration;
private uint _compositeWarmupDestinationCell;
private int _compositeWarmupRadius;
private int _compositeWarmupScanIndex;
private bool _compositeWarmupScanComplete = true;
private enum CompositeWarmupResult : byte
{
Complete,
Pending,
UploadBudgetBlocked,
}
public void InvalidateCompositeWarmupReadiness()
{
CompositeTexturesReady = false;
LastCompositeWarmupPendingCount = 1;
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = null;
_compositeWarmupSourceGeneration = 0;
_compositeWarmupDestinationCell = 0;
_compositeWarmupRadius = 0;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = true;
}
/// <summary>
/// Resolves live-object palette/original-texture composites before the
/// world viewport becomes visible. The texture cache enforces the upload
/// budget, so repeated calls advance readiness over multiple portal-space
/// frames without one large first-world-frame upload burst.
/// </summary>
public void PrepareCompositeTextures(
IReadOnlyList<WorldEntity> entities,
ulong entityGeneration,
uint destinationCell,
int radius)
{
ArgumentNullException.ThrowIfNull(entities);
ArgumentOutOfRangeException.ThrowIfNegative(radius);
if (RequiresCompositeWarmupRebuild(
_compositeWarmupSource,
_compositeWarmupDestinationCell,
_compositeWarmupRadius,
entities,
destinationCell,
radius))
{
RebuildCompositeWarmupQueue(
entities,
entityGeneration,
destinationCell,
radius);
}
else if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
if (CompositeTexturesReady)
return;
_compositeWarmupScanIndex =
Math.Min(_compositeWarmupScanIndex, entities.Count);
int scanEnd = CompositeWarmupScanEnd(
_compositeWarmupScanIndex,
entities.Count);
for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++)
{
WorldEntity entity = entities[_compositeWarmupScanIndex];
if (IsCompositeWarmupCandidate(entity, destinationCell, radius)
&& _compositeWarmupTracked.Add(entity))
{
_compositeWarmupQueue.Enqueue(entity);
}
}
_compositeWarmupScanComplete = _compositeWarmupScanIndex == entities.Count;
if (ShouldBeginCompositeWarmupRescan(
_compositeWarmupScanComplete,
_compositeWarmupSourceGeneration,
entityGeneration))
{
BeginCompositeWarmupRescan(entities.Count, entityGeneration);
}
int candidatesThisPass = Math.Min(
_compositeWarmupQueue.Count,
MaximumCompositeWarmupPrepareEntitiesPerFrame);
for (int i = 0; i < candidatesThisPass; i++)
{
WorldEntity entity = _compositeWarmupQueue.Dequeue();
CompositeWarmupResult result = PrepareCompositeEntity(entity);
if (result != CompositeWarmupResult.Complete)
_compositeWarmupQueue.Enqueue(entity);
if (result == CompositeWarmupResult.UploadBudgetBlocked)
break;
}
LastCompositeWarmupPendingCount = _compositeWarmupQueue.Count
+ (_compositeWarmupScanComplete ? 0 : entities.Count - _compositeWarmupScanIndex);
CompositeTexturesReady = _compositeWarmupScanComplete
&& _compositeWarmupQueue.Count == 0;
if (!CompositeTexturesReady
&& AcDream.Core.Net.NetDiagnostics.ProbeReveal)
{
ProbeRevealWarmupStall();
}
}
// #260 probe: while composite warmup blocks a reveal, name the stall once
// per second. The two permanent-stall shapes — a GfxObj id that never
// resolves (silent load failure, e.g. custom-server content missing from
// the baked pak) versus an upload budget that never reopens — are
// otherwise indistinguishable from the reveal log's composites=False.
private long _probeWarmupLastEmitTs;
private void ProbeRevealWarmupStall()
{
long now = Stopwatch.GetTimestamp();
if (_probeWarmupLastEmitTs != 0
&& now - _probeWarmupLastEmitTs < Stopwatch.Frequency)
{
return;
}
_probeWarmupLastEmitTs = now;
var pendingIds = new System.Text.StringBuilder();
int listed = 0;
foreach (WorldEntity entity in _compositeWarmupQueue)
{
if (listed >= 4)
break;
ulong gfxObjId = entity.MeshRefs.Count > 0
? entity.MeshRefs[0].GfxObjId
: 0;
if (listed > 0)
pendingIds.Append(',');
pendingIds.Append($"0x{gfxObjId:X8}");
listed++;
}
Console.WriteLine(
$"[composite-warmup] STALL pending={LastCompositeWarmupPendingCount}"
+ $" queue={_compositeWarmupQueue.Count}"
+ $" scanComplete={_compositeWarmupScanComplete}"
+ $" uploadOpen={_textures.CanStartCompositeUpload}"
+ $" firstPending=[{pendingIds}]");
}
internal static bool RequiresCompositeWarmupRebuild(
IReadOnlyList<WorldEntity>? currentSource,
uint currentDestinationCell,
int currentRadius,
IReadOnlyList<WorldEntity> nextSource,
uint nextDestinationCell,
int nextRadius) =>
!ReferenceEquals(currentSource, nextSource)
|| currentDestinationCell != nextDestinationCell
|| currentRadius != nextRadius;
internal static bool ShouldBeginCompositeWarmupRescan(
bool scanComplete,
ulong scanGeneration,
ulong entityGeneration) =>
scanComplete && scanGeneration != entityGeneration;
internal static int CompositeWarmupScanEnd(
int scanIndex,
int entityCount)
{
ArgumentOutOfRangeException.ThrowIfNegative(scanIndex);
ArgumentOutOfRangeException.ThrowIfNegative(entityCount);
return Math.Min(
entityCount,
scanIndex + MaximumCompositeWarmupScanEntitiesPerFrame);
}
private void RebuildCompositeWarmupQueue(
IReadOnlyList<WorldEntity> entities,
ulong entityGeneration,
uint destinationCell,
int radius)
{
_compositeWarmupQueue.Clear();
_compositeWarmupTracked.Clear();
_compositeWarmupSource = entities;
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupDestinationCell = destinationCell;
_compositeWarmupRadius = radius;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = entities.Count == 0;
LastCompositeWarmupPendingCount = entities.Count;
CompositeTexturesReady = _compositeWarmupScanComplete;
}
private void BeginCompositeWarmupRescan(
int entityCount,
ulong entityGeneration)
{
_compositeWarmupSourceGeneration = entityGeneration;
_compositeWarmupScanIndex = 0;
_compositeWarmupScanComplete = entityCount == 0;
CompositeTexturesReady =
_compositeWarmupScanComplete && _compositeWarmupQueue.Count == 0;
}
internal static bool IsCompositeWarmupCandidate(
WorldEntity entity,
uint destinationCell,
int radius)
{
ArgumentNullException.ThrowIfNull(entity);
ArgumentOutOfRangeException.ThrowIfNegative(radius);
if (destinationCell != 0)
{
// A cell-less live object is either outside the published
// destination or still transitioning. It must not load meshes
// or hold this destination's portal readiness.
if (!TryGetEntityCell(entity, out uint entityCell)
|| !IsWithinLandblockRadius(entityCell, destinationCell, radius))
{
return false;
}
}
if (entity.PaletteOverride is not null)
return true;
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
if (entity.MeshRefs[meshIndex].SurfaceOverrides is { Count: > 0 })
return true;
}
return false;
}
private CompositeWarmupResult PrepareCompositeEntity(WorldEntity entity)
{
bool pending = false;
PaletteCompositeIdentity paletteIdentity = entity.PaletteOverride is not null
? TextureCache.GetPaletteIdentity(entity.PaletteOverride)
: default;
for (int meshIndex = 0; meshIndex < entity.MeshRefs.Count; meshIndex++)
{
MeshRef meshRef = entity.MeshRefs[meshIndex];
ObjectRenderData? renderData = _meshAdapter.TryGetRenderData(meshRef.GfxObjId);
if (renderData is null)
{
_meshAdapter.EnsureLoaded(meshRef.GfxObjId);
pending = true;
continue;
}
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
for (int partIndex = 0; partIndex < renderData.SetupParts.Count; partIndex++)
{
ulong partId = renderData.SetupParts[partIndex].GfxObjId;
ObjectRenderData? partData = _meshAdapter.TryGetRenderData(partId);
if (partData is null)
{
_meshAdapter.EnsureLoaded(partId);
pending = true;
continue;
}
if (!PrepareCompositeBatches(entity, meshRef, partData, paletteIdentity))
pending = true;
if (!_textures.CanStartCompositeUpload && pending)
return CompositeWarmupResult.UploadBudgetBlocked;
}
}
else
{
if (!PrepareCompositeBatches(entity, meshRef, renderData, paletteIdentity))
pending = true;
if (!_textures.CanStartCompositeUpload && pending)
return CompositeWarmupResult.UploadBudgetBlocked;
}
}
return pending ? CompositeWarmupResult.Pending : CompositeWarmupResult.Complete;
}
private bool PrepareCompositeBatches(
WorldEntity entity,
MeshRef meshRef,
ObjectRenderData renderData,
PaletteCompositeIdentity paletteIdentity)
{
bool complete = true;
for (int batchIndex = 0; batchIndex < renderData.Batches.Count; batchIndex++)
{
_ = ResolveTexture(
entity,
meshRef,
renderData.Batches[batchIndex],
paletteIdentity,
out bool compositePending);
if (compositePending)
complete = false;
if (compositePending && !_textures.CanStartCompositeUpload)
break;
}
return complete;
}
private static bool TryGetEntityCell(WorldEntity entity, out uint cell)
{
if (entity.ParentCellId is uint parent)
{
cell = parent;
return true;
}
if (entity.EffectCellId is uint effect)
{
cell = effect;
return true;
}
cell = 0;
return false;
}
private static bool IsWithinLandblockRadius(uint cell, uint center, int radius)
{
int x = (int)(cell >> 24);
int y = (int)((cell >> 16) & 0xFFu);
int centerX = (int)(center >> 24);
int centerY = (int)((center >> 16) & 0xFFu);
return Math.Abs(x - centerX) <= radius && Math.Abs(y - centerY) <= radius;
}
// Tier 1 cache (#53): per-entity classification results for static
// entities (those NOT in GameWindow._animatedEntities). Wired here in
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
// miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache;
// #188 — per-(entity, Setup-part) translucency ramp state (fading doors /
// secret-passage walls). ClassifyBatches reads this per part to compute
// the instance's opacity multiplier; never mutated here.
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades;
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DISABLE_TIER1_CACHE"), "1", StringComparison.Ordinal);
/// <summary>
/// A.5 T22.5: gate for GL_SAMPLE_ALPHA_TO_COVERAGE around the opaque pass.
/// Default true matches T20 behavior. Set false for Low/Medium presets that
/// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off
/// avoids the unnecessary GL state thrash and is cleaner diagnostics).
/// Can be toggled mid-session via
/// <see cref="AcDream.App.Settings.RuntimeSettingsController.ReapplyQualityPreset"/>.
/// </summary>
public bool AlphaToCoverage { get; set; } = true;
// Phase U.3: per-instance clip-slot data (binding=3 on the RHI ring). One
// uint per instance selecting its CellClip slot. In U.3 this is ALL ZEROS
// (every instance → slot 0 → no-clip), so the render is identical to
// pre-U.3. U.4 populates real slot indices.
private uint[] _clipSlotData = new uint[256];
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
// ring sections replace the single global nearest-8-to-CAMERA UBO set for
// point/spot lights — see mesh_modern.vert binding=4/5. The global-lights
// section (binding=4) holds the per-frame point-light snapshot
// (LightManager.PointSnapshot); the light-set section (binding=5) holds
// MaxLightsPerObject int indices per instance INTO it (-1 = unused), laid
// out parallel to the instance data.
private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject];
private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight
// #142: per-instance "indoor" flag (binding=6), one uint per instance, parallel
// to the instance data. 1 = object parented to an EnvCell (skip the sun in
// the shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun).
// Mechanically a clone of _clipSlotData.
private uint[] _indoorData = new uint[256];
// #188: per-instance opacity multiplier (binding=7), one float per
// instance, parallel to the instance data. 1.0 = unmodified (the dat's own
// material/texture alpha, untouched); < 1.0 multiplies the shader's
// sampled alpha for an entity mid-TransparentPartHook fade. Mechanically
// a clone of _indoorData, one binding higher.
private float[] _alphaData = new float[256];
private bool _dynamicFrameStarted;
// Campaign V slice V11: the raw-GL upload path (and its per-frame triple-
// buffered SSBO pool) is gone — the RHI arm's frame ring
// (WbDrawDispatcher.Rhi.cs) owns the equivalent ring allocations now, so
// there is no dynamic buffer set left to count. Kept for the diagnostic
// consumer (RenderFrameDiagnosticSources) that reads this alongside the
// other renderers'.
internal int DynamicBufferSetCount => 0;
// Retail SmartBox click confirmation: per-instance CMaterial luminosity /
// diffuse replacement (binding=8), parallel to the transform buffer.
private Vector2[] _selectionLightingData = new Vector2[256];
// This frame's point-light snapshot, handed in by GameWindow before Draw via
// SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1).
private IReadOnlyList<LightSource>? _pointSnapshot;
// 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[] _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
// (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in
// AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[].
private bool _currentEntityIndoor;
private Vector2 _currentEntitySelectionLighting = new(0f, 1f);
// Phase U.3: the SHARED per-cell clip-region SSBO (binding=2) id, owned by
// the GL-arm ClipFrame and handed in via SetClipRegionSsbo. Campaign V
// slice V11: the raw-GL world path that read this is gone, and the RHI arm
// binds clip regions from IWorldPassScope.Sections instead (see
// WbDrawDispatcher.Rhi.cs), so this is now write-only — kept because
// GlWorldPassSurface still calls the setter unconditionally.
private uint _sharedClipRegionSsbo;
// Phase U.4: per-frame clip-slot routing handed in via SetClipRouting before
// each Draw. When _clipRoutingActive is false (the U.3 path / outdoor root /
// no portal frame), every instance maps to slot 0 (no-clip) and no instance is
// culled — identical to U.3. When active, each instance's slot is resolved by
// ResolveEntitySlot per the U.4 policy (cell-owned entities to their cell slot;
// outdoor-owned entities to OutsideView; non-visible/unresolved indoors culled).
private bool _clipRoutingActive;
private IReadOnlyDictionary<uint, int>? _cellIdToSlot;
private int _outdoorSlot;
private bool _outdoorVisible;
// Phase U.4: the clip slot of the entity currently being classified in Draw's
// per-entity loop. Set once per entity (before ClassifyBatches / ApplyCacheHit),
// read by the two matrix-append sites (AppendInstanceToGroup + ClassifyBatches)
// so every group's Slots[] stays in lockstep with its Matrices[]. Defaults to 0
// (no-clip) on the U.3 / outdoor path.
private uint _currentEntitySlot;
// Phase U.4: true when the current entity resolved to the CULL sentinel
// (cell not visible, or outdoor stab while no outdoors is visible). Persisted
// across the entity's tuples; the per-tuple body skips all instance emission.
private bool _currentEntityCulled;
// Per-frame scratch arrays — Tasks 9-10 fully wire these.
private float[] _instanceData = new float[256 * 16]; // mat4 floats per instance
private BatchData[] _batchData = new BatchData[256];
private DrawElementsIndirectCommand[] _indirectCommands = new DrawElementsIndirectCommand[256];
private CullMode[] _drawCullModes = new CullMode[256];
private BatchDataPublic[] _batchPublicScratch = new BatchDataPublic[256];
private readonly List<IndirectGroupInput> _groupInputScratch = new(256);
private readonly List<GroupKey> _retiredGroupKeys = new();
private long _nextGroupRegistration = 1;
private long _groupFrame;
private int _opaqueDrawCount;
private int _transparentDrawCount;
private int _transparentByteOffset;
// Campaign V slice V2 (2026-07-27): std430 layout: uint TextureIndex at
// offset 0, uint Reserved (pad) at offset 4, uint TextureLayer at offset 8,
// uint Flags at offset 12. Total 16 bytes — unchanged from before V2, so
// every existing CPU writer's offsets are unchanged (see
// GpuBindingModel.GpuBatchDataStrideBytes). TextureIndex used to be a
// 64-bit ulong TextureHandle (an ARB_bindless_texture handle, uvec2 in
// GLSL); it is now a slot into the binding=9 handle table
// (mesh_modern.vert's BatchData.textureIndex / ACDREAM_TEXTURE_HANDLE),
// which is why the struct only needs 4-byte (not 8-byte) packing now.
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BatchData
{
public uint TextureIndex; // slot into the binding=9 handle table
public uint Reserved; // pad — keeps TextureLayer/Flags at offsets 8/12
public uint TextureLayer;
public uint Flags;
}
private readonly record struct DeferredAlphaInstance(
GroupKey Key,
Matrix4x4 Model,
uint ClipSlot,
InstanceLightSet Lights,
uint Indoor,
float Opacity,
Vector2 SelectionLighting);
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,
4 => L4, 5 => L5, 6 => L6, 7 => L7,
_ => throw new ArgumentOutOfRangeException(nameof(index)),
};
}
private sealed class AlphaDrawSource(WbDrawDispatcher owner) : IRetailAlphaDrawSource
{
public void PrepareAlphaDraws(ReadOnlySpan<int> tokens)
=> owner.PrepareDeferredAlphaDraws(tokens);
public void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
=> owner.DrawPreparedAlphaBatch(firstPreparedDraw, drawCount);
public void ResetAlphaSubmissions()
=> owner.ResetDeferredAlphaSubmissions();
}
// Per-frame scratch — reused across frames to avoid per-frame allocation.
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new();
private readonly List<AlphaFingerprint> _alphaFingerprintScratch = [];
private readonly List<DeferredAlphaInstance> _deferredAlpha = new(128);
private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
private Matrix4x4 _deferredAlphaViewProjection;
private int _nextInstanceSubmissionOrder;
internal long AlphaScratchBudgetBytes => _alphaScratchPolicy.BudgetBytes;
internal long RetainedAlphaScratchBytes => checked(
(long)_instanceData.Length * sizeof(float)
+ (long)_clipSlotData.Length * sizeof(uint)
+ (long)_lightSetData.Length * sizeof(int)
+ (long)_indoorData.Length * sizeof(uint)
+ (long)_alphaData.Length * sizeof(float)
+ (long)_selectionLightingData.Length * Unsafe.SizeOf<Vector2>()
+ (long)_batchData.Length * Unsafe.SizeOf<BatchData>()
+ (long)_indirectCommands.Length
* Unsafe.SizeOf<DrawElementsIndirectCommand>()
+ (long)_drawCullModes.Length * Unsafe.SizeOf<CullMode>()
+ (long)_batchPublicScratch.Length
* Unsafe.SizeOf<BatchDataPublic>()
+ (long)_deferredAlphaKinds.Length
* Unsafe.SizeOf<TranslucencyKind>()
+ (long)_deferredAlpha.Capacity
* Unsafe.SizeOf<DeferredAlphaInstance>());
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
// of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
// G2: the dispatcher consumes this acdream-owned value boundary, never
// WorldEntity. The current source translates its accepted walk into this
// retained list; G4 will replace only that producer with RenderFrameView.
private readonly List<RenderInstanceTuple> _candidateTupleScratch = new();
// Tier 1 cache (#53) — per-entity classification collector. Reused across
// frames; cleared at flush time when the per-entity loop crosses an entity
// boundary in _walkScratch (and once more at end-of-loop for the last
// entity). _walkScratch is in entity-order, so all MeshRefs of one entity
// 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.
private const float PerEntityCullRadius = 5.0f;
private RetryableResourceReleaseLedger? _disposeResources;
private bool _disposing;
private bool _disposed;
/// <summary>
/// Per-cell-entity last-log frame number for rate-limiting the
/// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
/// probes. Defaults to 30 frames at 30Hz = 1 sec.
/// </summary>
private readonly Dictionary<ulong, int> _lastIndoorProbeFrame = new();
private int _indoorProbeFrameCounter;
private const int IndoorProbeRateLimitFrames = 30;
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per cellId. Caller must already have checked that an indoor
/// probe flag is enabled.
/// </summary>
private bool ShouldEmitIndoorProbe(ulong cellId)
{
if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
|| _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
{
_lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
return true;
}
return false;
}
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen;
private int _entitiesDrawn;
private int _meshesMissing;
private int _drawsIssued;
private int _instancesIssued;
private long _lastLogTick;
// #128 self-heal: per-Draw dedup of point-of-use load re-requests
// (PrepareMeshDataAsync is idempotent while pending — the dedup just
// avoids redundant dictionary probes within one pass) + the once-per-id
// [mesh-miss] diagnostic set (never cleared; diag-gated emission).
private readonly HashSet<ulong> _missRequested = new();
private readonly HashSet<ulong> _missLogged = new();
// #119 decisive probe (2026-06-11): ACDREAM_DUMP_ENTITY one-shot entity
// dump. Keyed by entity Id; the stored signature re-emits the header line
// whenever (MeshRefs count, cache batch count, zero-translation count,
// culled) changes — e.g. the Tier-1 populate landing one frame after the
// first slow-path draw. The full per-part listing prints only on first
// sight. Inert (one Count==0 check per new entity) when the env var is
// unset. Render-thread only.
private readonly Dictionary<uint, (int MeshRefCount, int CacheBatches, int ZeroT, bool Culled)> _entityDumpSig = new();
// Rate limiter for [dump-entity] WALK-REJECT lines: a rejected entity
// re-tests every frame; emit the first rejection per entity then every
// 300th (~5 s at 60 fps). Static because WalkEntitiesInto is static;
// render-thread only like the walk itself.
private static readonly Dictionary<uint, int> _walkRejectCounts = new();
// CPU + GPU timing for [WB-DIAG] under ACDREAM_WB_DIAG=1. The GPU samples
// are written by the RHI arm's SampleRhiTimers (WbDrawDispatcher.Rhi.cs)
// from the device's own timer pool; the raw-GL query-object ring that used
// to feed them is gone with the raw-GL draw path.
private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
private readonly long[] _cpuSamples = new long[256]; // microseconds
private int _cpuSampleCursor;
private readonly long[] _gpuSamples = new long[256]; // microseconds
private int _gpuSampleCursor;
/// <summary>
/// Marks the start of a fence-protected frame. <paramref name="frameSlot"/>
/// is the shared GPU frame-ring slot every renderer in the frame receives
/// (see <see cref="RenderFrameResourceController"/>) — the RHI arm's own
/// ring allocations (<c>WbDrawDispatcher.Rhi.cs</c>) come from the current
/// <c>IGpuFrame</c> instead, so this dispatcher no longer indexes its own
/// buffer-set pool by it; the parameter is kept so every renderer's
/// <c>BeginFrame</c> call stays uniform.
/// </summary>
public void BeginFrame(int frameSlot)
{
_ = frameSlot;
if (_groupFrame == long.MaxValue)
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
ApplyScratchRetention(_scratchPeakUnits);
_scratchPeakUnits = 0;
_groupFrame++;
PruneInstanceGroupsUnusedBeforeFrame(
_groups,
_retiredGroupKeys,
_groupFrame - 1);
_dynamicFrameStarted = true;
_currentRenderSceneObserver?.BeginDispatcherFrame();
}
internal void SetCurrentRenderSceneObserver(
ICurrentRenderDispatcherObserver? observer) =>
_currentRenderSceneObserver = observer;
internal void AbortCurrentRenderSceneObserverFrame() =>
_currentRenderSceneObserver?.AbortDispatcherFrame();
/// <summary>
/// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot
/// (<see cref="LightManager.PointSnapshot"/>). Call once per frame BEFORE
/// <see cref="Draw"/>. The dispatcher uploads it to binding=4 and selects each
/// object's up-to-8 lights from it (<see cref="LightManager.SelectForObject"/>)
/// by the object's bounding sphere — camera-independent. Pass null/empty to
/// disable per-object point lights (only ambient + sun render).
/// </summary>
public void SetSceneLights(IReadOnlyList<LightSource>? pointSnapshot)
=> _pointSnapshot = pointSnapshot;
/// <summary>
/// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO
/// (binding=2) that <see cref="ClipFrame.UploadShared"/> created. Campaign
/// V slice V11: the raw-GL draw path that rebound this id is gone —
/// <c>GlWorldPassSurface</c> still calls this setter unconditionally, so it
/// is kept as a harmless store; the RHI arm binds clip regions from
/// <c>IWorldPassScope.Sections</c> instead (see
/// <c>WbDrawDispatcher.Rhi.cs</c>).
/// </summary>
public void SetClipRegionSsbo(uint sharedClipRegionSsbo)
=> _sharedClipRegionSsbo = sharedClipRegionSsbo;
/// <summary>
/// Phase U.4: install the per-frame clip-slot routing for an INDOOR root.
/// Call once per frame BEFORE <see cref="Draw"/> when the camera's root cell is
/// non-null; the next <see cref="Draw"/> resolves each instance's binding=3
/// clip slot via the U.4 policy (cell-owned entities to their cell slot,
/// outdoor-owned entities to OutsideView, non-visible/unresolved indoors culled).
/// Pair with <see cref="ClearClipRouting"/> on outdoor-root frames so the
/// dispatcher reverts to the U.3 no-clip-everything behavior.
/// </summary>
/// <param name="cellIdToSlot">cellId → CellClip slot. A cell absent from the map
/// is NOT visible → its cell-static instances are culled.</param>
/// <param name="outdoorSlot">Slot for outdoor scenery / building shells while
/// indoors (the OutsideView slot, or 0 for no-clip over-include).</param>
/// <param name="outdoorVisible">False ⇒ cull outdoor scenery / shells this frame
/// (the OutsideView is empty).</param>
public void SetClipRouting(IReadOnlyDictionary<uint, int> cellIdToSlot, int outdoorSlot, bool outdoorVisible)
{
ArgumentNullException.ThrowIfNull(cellIdToSlot);
_clipRoutingActive = true;
_cellIdToSlot = cellIdToSlot;
_outdoorSlot = outdoorSlot;
_outdoorVisible = outdoorVisible;
}
/// <summary>
/// Phase U.4: revert to U.3 behavior — every instance maps to slot 0 (no-clip),
/// nothing is culled by clip routing. Call on outdoor-root frames (camera
/// outdoors) and any frame without a portal-visibility result.
/// </summary>
public void ClearClipRouting()
{
_clipRoutingActive = false;
_cellIdToSlot = null;
_outdoorSlot = 0;
_outdoorVisible = false;
}
// §4 flap [clip-route-disp] probe state (2026-06-10, throwaway): print-on-change
// signature + monotonic sequence + reusable histogram. See RenderingDiagnostics
// .ProbeClipRouteEnabled for the full probe contract.
private string? _lastClipRouteDispSig;
private long _clipRouteDispSeq;
private readonly SortedDictionary<uint, int> _clipRouteHist = new();
// §4 flap apparatus (2026-06-10): per-slot instance histogram as staged for binding=3.
// grp.Slots is laid out 1:1 with grp.Matrices (binding=0), so this IS the slot content
// the GPU reads per instance — if outdoor instances land on the wrong slot (or vanish
// into cullEnt) when the building flood merges, this line shows it directly.
private void EmitClipRouteDispatchProbe(int culledEntities)
{
_clipRouteHist.Clear();
int total = 0;
foreach (var grp in _groups.Values)
{
var slots = grp.Slots;
for (int i = 0; i < slots.Count; i++)
{
_clipRouteHist.TryGetValue(slots[i], out int c);
_clipRouteHist[slots[i]] = c + 1;
total++;
}
}
var sb = new System.Text.StringBuilder(128);
sb.Append(System.FormattableString.Invariant(
$"outdoorSlot={_outdoorSlot} outdoorVis={(_outdoorVisible ? 'Y' : 'n')} inst={total} cullEnt={culledEntities} slots={{"));
bool first = true;
foreach (var kv in _clipRouteHist)
{
if (!first) sb.Append(',');
first = false;
sb.Append(System.FormattableString.Invariant($"{kv.Key}:{kv.Value}"));
}
sb.Append('}');
string sig = sb.ToString();
_clipRouteDispSeq++;
if (sig == _lastClipRouteDispSig)
return;
_lastClipRouteDispSig = sig;
Console.WriteLine($"[clip-route-disp] n={_clipRouteDispSeq} {sig}");
}
// Phase U.4 CULL sentinel returned by ResolveEntitySlot: the entity's instances
// are dropped entirely (not emitted into the binding=0 instance buffer NOR the
// binding=3 slot buffer), matching the existing frustum / visible-cell cull.
// Internal (not private) so the clip-slot unit tests can assert against it
// directly — see WbDrawDispatcherClipSlotTests.
internal const int ClipSlotCull = -1;
/// <summary>
/// Phase U.4: resolve the clip slot for one entity per the slot/gate policy.
/// Returns <see cref="ClipSlotCull"/> to drop the entity's instances entirely.
/// <list type="bullet">
/// <item>Indoor ParentCellId: the cell's slot, or CULL when hidden.</item>
/// <item>Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot
/// when <paramref name="outdoorVisible"/>, else CULL.</item>
/// <item>ServerGuid != 0 with ParentCellId == null: CULL while routing is active.</item>
/// </list>
/// Only called when <c>_clipRoutingActive</c> (indoor root). On the U.3 / outdoor
/// path every instance is slot 0 and nothing is culled — see
/// <see cref="ResolveSlotForFrame"/>, which gates on that flag.
/// <para>
/// INVARIANT: <paramref name="parentCellId"/> and the keys of
/// <paramref name="cellIdToSlot"/> MUST live in the same FULL cell-id space
/// (<c>lbMask | OtherCellId</c>, e.g. <c>0xA9B40164</c>). A bare-low-byte
/// ParentCellId (e.g. <c>0x64</c>) would never match a full-id key and would
/// silently CULL every indoor stab — cf. the L.2e bare-low-byte finding in
/// CLAUDE.md where player CellId was tracked without its landblock prefix.
/// </para>
/// <para>
/// <c>internal static</c> + pure (reads no instance state) so the clip-slot
/// unit tests exercise every branch without a GL context. The caller hands in
/// the routing fields it would otherwise read from <c>_cellIdToSlot</c> etc.
/// </para>
/// </summary>
internal static int ResolveEntitySlot(
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary<uint, int> cellIdToSlot,
int outdoorSlot,
bool outdoorVisible)
{
// Live-dynamic entities are not a global indoor overlay. When they
// have current cell ownership, route them through the same visible
// cell/OutsideView graph as every other object. Parentless live objects
// are unresolved indoors, so cull them while clip routing is active.
if (parentCellId is uint parentCell)
{
if (IsIndoorCellId(parentCell))
{
if (!cellIdToSlot.ContainsKey(parentCell))
return ClipSlotCull;
return cellIdToSlot[parentCell];
}
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
if (serverGuid != 0)
return ClipSlotCull;
// Outdoor scenery / building shell (no ParentCellId). Indoor root: gate to
// the OutsideView slot, or cull when nothing outdoors is visible.
return outdoorVisible ? outdoorSlot : ClipSlotCull;
}
private static bool IsIndoorCellId(uint cellId)
{
uint low = cellId & 0xFFFFu;
return low >= 0x0100u && low != 0xFFFFu;
}
/// <summary>
/// Phase U.4: the call-site clip-slot decision for one entity, returning the
/// <c>(Slot, Culled)</c> pair the per-entity loop body consumes. Wraps
/// <see cref="ResolveEntitySlot"/> with the <paramref name="clipRoutingActive"/>
/// gate: when routing is INACTIVE (outdoor root / no portal frame), every entity
/// is slot 0 and nothing is clip-culled — the bit-identical-to-U.3 property, so
/// the resolver (and <paramref name="cellIdToSlot"/>) is bypassed entirely.
/// When active, a CULL sentinel maps to <c>(0, culled=true)</c> — the slot value
/// is never emitted for a culled entity.
/// <c>internal static</c> + pure so the whole policy (including the routing-
/// inactive branch) is unit-testable — see WbDrawDispatcherClipSlotTests.
/// </summary>
internal static (uint Slot, bool Culled) ResolveSlotForFrame(
bool clipRoutingActive,
uint serverGuid,
uint? parentCellId,
IReadOnlyDictionary<uint, int>? cellIdToSlot,
int outdoorSlot,
bool outdoorVisible)
{
if (!clipRoutingActive)
return (0u, false);
int resolved = ResolveEntitySlot(serverGuid, parentCellId, cellIdToSlot!, outdoorSlot, outdoorVisible);
bool culled = resolved == ClipSlotCull;
return (culled ? 0u : (uint)resolved, culled);
}
public static Matrix4x4 ComposePartWorldMatrix(
Matrix4x4 entityWorld,
Matrix4x4 animOverride,
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
/// <summary>
/// Entry for <see cref="WalkEntities"/> per-landblock iteration.
/// Mirrors the shape yielded by <c>GpuWorldState.LandblockEntries</c>.
/// </summary>
public readonly record struct LandblockEntry(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById);
/// <summary>
/// Result of <see cref="WalkEntities"/> — the list of (entity, meshRef index)
/// pairs that passed all visibility filters, plus a diagnostic walk count.
/// </summary>
public struct WalkResult
{
public int EntitiesWalked;
public int BuildingShellAnchorPass;
public int BuildingShellAnchorReject;
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
}
/// <summary>
/// Pure-CPU visibility filter over <paramref name="landblockEntries"/>.
/// Separated from <see cref="Draw"/> so tests can exercise it without GL state.
///
/// <para>
/// A.5 T17 Change #1: when an LB is frustum-culled AND
/// <paramref name="animatedEntityIds"/> is non-empty, the OLD path walked
/// every entity in the LB just to find the few animated ones. This helper
/// fixes that: if the LB is invisible, we iterate
/// <paramref name="animatedEntityIds"/> directly and look each up in
/// <c>entry.AnimatedById</c> (typically &lt;50 animated, up to ~10K total).
/// </para>
///
/// <para>
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
/// <see cref="WorldEntity.AabbMin"/>/<see cref="WorldEntity.AabbMax"/>
/// (refreshed lazily if <see cref="WorldEntity.AabbDirty"/>), instead of
/// recomputing Position±5 each frame.
/// </para>
/// </summary>
/// <summary>
/// Test-friendly overload that allocates a fresh ToDraw list per call.
/// Production code (<see cref="Draw"/>) uses the no-alloc overload below
/// with a caller-provided scratch list.
/// </summary>
internal static WalkResult WalkEntities(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
var scratch = new List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)>();
var result = new WalkResult { ToDraw = scratch };
WalkEntitiesInto(
landblockEntries, frustum, neverCullLandblockId,
visibleCellIds, animatedEntityIds, scratch, ref result);
return result;
}
/// <summary>
/// No-alloc overload: clears + populates the caller-provided <paramref name="scratch"/>
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
///
/// <para>
/// When <paramref name="indoorProbeState"/> is non-null the method emits
/// <c>[indoor-cull]</c> lines for cell entities rejected by the
/// visibleCellIds or frustum filters, and <c>[indoor-walk]</c> lines for
/// cell entities that pass all filters. Rate-limited by
/// <see cref="IndoorProbeState"/>. Pass <see langword="null"/> (the default)
/// to disable all probe emission — used by the test-friendly
/// <see cref="WalkEntities"/> overload.
/// </para>
/// </summary>
internal static void WalkEntitiesInto(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result,
IndoorProbeState? indoorProbeState = null,
EntitySet set = EntitySet.All)
{
scratch.Clear();
result.EntitiesWalked = 0;
result.ToDraw = scratch;
foreach (var entry in landblockEntries)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible)
{
// A.5 T17 Change #1: walk only animated entities, not all entities.
// Avoids O(N_entities) scan when only O(N_animated) work is needed.
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
if (entry.AnimatedById is null) continue;
foreach (var animatedId in animatedEntityIds)
{
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set))
{
if (shellScoped) result.BuildingShellAnchorReject++;
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
continue;
}
foreach (var entity in entry.Entities)
{
if (!entity.IsDrawVisible || !entity.IsAncestorDrawVisible) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
// is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
// result reused for all probe checks below.
ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
bool isCellEntity = indoorProbeState is not null
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
if (!cellInVis)
{
if (shellScoped) result.BuildingShellAnchorReject++;
MaybeEmitWalkRejectDump(entity, "visibleCellIds-miss");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=visibleCellIds-miss " +
$"parentCell=0x{entity.ParentCellId!.Value:X8}");
}
continue;
}
if (shellScoped) result.BuildingShellAnchorPass++;
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
}
if (!aabbVisible)
{
MaybeEmitWalkRejectDump(entity, "frustum");
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
$"reason=frustum " +
$"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
$"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
}
continue;
}
// Passed all filters — emit walk probe.
if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
Console.WriteLine(
$"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
$"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
$"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"meshRef0=0x{cellProbeId:X8} " +
$"meshRefCount={entity.MeshRefs.Count} " +
$"landblockVisible=true aabbVisible=true cellInVis=true");
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
}
}
/// <summary>
/// #119 ROOT-CAUSE FIX (2026-06-11): the Tier-1 cache hint must identify the
/// entity's OWNING landblock, not the Draw call's tuple landblock.
/// <c>RetailPViewRenderer.DrawEntityBucket</c> fabricates its tuple with the
/// PLAYER's landblock id, so every bucket entity that frame shared one hint —
/// and colliding entity ids from different landblocks (the pre-fix
/// <c>0x40YYFF00</c> interior namespace discarded the landblock X byte) mapped
/// to the SAME cache key and served each other's batches: the AAB3 tower's
/// 43-part staircase drew a 1-part entity's 3 zero-RestPose batches
/// (captured live, tower-dump-launch1.log) — the session-sticky "broken
/// stairs + water barrel". Interior statics carry their owning cell; derive
/// the hint from it, canonicalized to the same <c>0xXXYYFFFF</c> key format
/// the streaming entries and <see cref="EntityClassificationCache.InvalidateLandblock"/>
/// use — which also makes owner-unload invalidation actually hit these
/// entries (bucket-hinted entries were previously orphaned forever).
/// Entities without a ParentCellId (outdoor stabs / scenery / building
/// shells via GpuWorldState entries) keep the tuple id, which IS their
/// owner on those paths.
/// </summary>
internal static uint ResolveCacheLandblockHint(WorldEntity entity, uint tupleLandblockId)
=> entity.ParentCellId is uint pc ? ((pc & 0xFFFF0000u) | 0xFFFFu) : tupleLandblockId;
internal static uint ResolveCacheLandblockHint(
in RenderInstanceCandidate entity) =>
entity.CacheLandblockId;
private static void BuildCurrentCandidateTuples(
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> source,
HashSet<uint>? animatedEntityIds,
List<RenderInstanceTuple> destination)
{
destination.Clear();
if (destination.Capacity < source.Count)
destination.Capacity = source.Count;
int index = 0;
while (index < source.Count)
{
(WorldEntity entity, _, uint tupleLandblockId) = source[index];
int end = index + 1;
while (end < source.Count
&& ReferenceEquals(source[end].Entity, entity)
&& source[end].LandblockId == tupleLandblockId)
{
end++;
}
int meshPartCount = end - index;
RenderInstanceCandidate candidate =
RenderInstanceCandidate.FromWorldEntity(
entity,
animatedEntityIds?.Contains(entity.Id) == true,
meshPartCount,
tupleLandblockId);
for (; index < end; index++)
{
int meshRefIndex = source[index].MeshRefIndex;
destination.Add(new RenderInstanceTuple(
candidate,
meshRefIndex,
entity.MeshRefs[meshRefIndex]));
}
}
}
/// <summary>
/// #119 decisive probe: rate-limited <c>[dump-entity] WALK-REJECT</c> line
/// for an <c>ACDREAM_DUMP_ENTITY</c>-targeted entity that the walk filtered
/// out (visibleCellIds gate / per-entity frustum). Absence of any DRAW dump
/// plus presence of these lines attributes "entity exists but never reaches
/// the draw loop" to the specific gate. Inert when the target set is empty.
/// </summary>
private static void MaybeEmitWalkRejectDump(WorldEntity entity, string reason)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
_walkRejectCounts.TryGetValue(entity.Id, out int n);
_walkRejectCounts[entity.Id] = n + 1;
if (n % 300 != 0) return;
Console.WriteLine(
$"[dump-entity] WALK-REJECT id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"reason={reason} parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) n={n + 1}");
}
/// <summary>
/// #119 decisive probe: per-entity state dump at draw time for
/// <c>ACDREAM_DUMP_ENTITY</c>-targeted entities. First sight prints a
/// header + every MeshRef's GfxObj id, part-transform translation, and
/// loaded flag; afterwards a compact header re-emits only when the
/// (meshRefs, cacheBatches, zeroTranslations, culled) signature changes.
/// Discriminates H-A (hydration-time MeshRef corruption: translations
/// collapsed to ~zero / missing parts) from H-B (Tier-1 cache holding a
/// partial or stale batch set) from H-C (both healthy ⇒ draw-side compose).
/// </summary>
private void MaybeEmitEntityDump(
in RenderInstanceCandidate entity,
uint landblockId,
bool culled,
IReadOnlyList<RenderInstanceTuple> tuples)
{
var targets = RenderingDiagnostics.DumpEntitySourceIds;
if (targets.Count == 0 || !targets.Contains(entity.SourceId))
return;
int zeroT = 0;
int refsCount = 0;
float tzMin = float.MaxValue, tzMax = float.MinValue;
for (int i = 0; i < tuples.Count; i++)
{
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId != entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
refsCount++;
Vector3 t = tuple.MeshRef.PartTransform.Translation;
if (t.LengthSquared() < 1e-9f) zeroT++;
if (t.Z < tzMin) tzMin = t.Z;
if (t.Z > tzMax) tzMax = t.Z;
}
int cacheBatches = -1;
int restZero = 0;
float rzMin = float.MaxValue, rzMax = float.MinValue;
if (_cache.TryGet(entity.Id, landblockId, out var cacheEntry))
{
cacheBatches = cacheEntry!.Batches.Length;
foreach (var b in cacheEntry.Batches)
{
var t = b.RestPose.Translation;
if (t.LengthSquared() < 1e-9f) restZero++;
if (t.Z < rzMin) rzMin = t.Z;
if (t.Z > rzMax) rzMax = t.Z;
}
}
var sig = (refsCount, cacheBatches, zeroT, culled);
bool first = !_entityDumpSig.TryGetValue(entity.Id, out var prev);
if (!first && prev == sig) return;
_entityDumpSig[entity.Id] = sig;
string cacheStr = cacheBatches < 0
? (_tier1CacheDisabled ? "disabled" : "miss")
: $"hit:{cacheBatches} restZero={restZero} restZ=[{rzMin:F2}..{rzMax:F2}]";
Console.WriteLine(
$"[dump-entity] DRAW{(first ? "" : "-CHANGED")} id=0x{entity.Id:X8} src=0x{entity.SourceId:X8} " +
$"lb=0x{landblockId:X8} cell=0x{entity.ParentCellId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " +
$"meshRefs={refsCount} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
if (first)
{
for (int i = 0; i < tuples.Count; i++)
{
RenderInstanceTuple tuple = tuples[i];
if (tuple.Candidate.LocalEntityId
!= entity.LocalEntityId
|| tuple.Candidate.TupleLandblockId
!= entity.TupleLandblockId)
{
continue;
}
MeshRef mr = tuple.MeshRef;
var t = mr.PartTransform.Translation;
bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null;
Console.WriteLine(
$"[dump-entity] part[{tuple.MeshRefIndex:D2}] gfx=0x{mr.GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3}) loaded={loaded}");
}
}
}
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
HashSet<uint>? animatedEntityIds = null,
EntitySet set = EntitySet.All)
{
bool diag = BeginEntityDispatch(
camera,
out Matrix4x4 vp,
out Vector3 camPos);
// ── Phase 1: clear groups, walk entities, build groups ──────────────
// 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.
_nextInstanceSubmissionOrder = 0;
foreach (InstanceGroup group in _groups.Values)
group.ClearPerInstanceData();
// Campaign V slice V11: no longer read for its own sake (the raw-GL
// VAO bind it fed is gone) — kept only because the packed-oracle
// partial (WbDrawDispatcher.PackedOracle.cs) mirrors this exact
// "first non-zero mesh id" computation and shares ExecuteClassifiedGroups'
// signature with it.
uint anyVao = 0;
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
static IEnumerable<LandblockEntry> ToEntries(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> src)
{
foreach (var e in src)
yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
}
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
//
// Pass an IndoorProbeState when any indoor probe is active so the static
// WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
// lines without needing access to instance fields. Null = probes off.
IndoorProbeState? probeState = null;
if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
{
// _currentFrame is snapped at construction time. Construct
// once per Draw() call only — a second construction within
// the same frame would stamp the dictionary with the
// (already-advanced) counter value, suppressing the second
// pass's emissions for IndoorProbeRateLimitFrames frames.
// Today Draw() is called exactly once per frame; if a
// future refactor adds a shadow / reflection / second pass,
// this assumption needs revisiting.
probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
}
var walkResult = default(WalkResult);
WalkEntitiesInto(
ToEntries(landblockEntries),
frustum,
neverCullLandblockId,
visibleCellIds,
animatedEntityIds,
_walkScratch,
ref walkResult,
probeState,
set);
_currentRenderSceneObserver?.ObserveDispatcherDraw(
set,
walkResult.EntitiesWalked,
_walkScratch);
BuildCurrentCandidateTuples(
_walkScratch,
animatedEntityIds,
_candidateTupleScratch);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
// a given entity are contiguous. We accumulate ALL of an entity's
// batches into _populateScratch, then flush exactly once per entity:
// either when the iteration crosses to a different entity, or at the
// end of the loop for the last entity. Flushing per-tuple would
// overwrite earlier MeshRefs (the cache is keyed by entity.Id), so
// multi-part Setup-backed entities would only retain their LAST
// MeshRef's batches — bug fixed in commit after 2f489a8.
uint? populateEntityId = null;
uint populateLandblockId = 0;
// §4 flap [clip-route-disp] probe (2026-06-10, throwaway): entities dropped by
// ResolveSlotForFrame's CULL sentinel this Draw. One increment per culled entity —
// cheap enough to count unconditionally; emission below is probe-gated.
int probeCulledEntities = 0;
// Tier 1 cache (#53) — fast-path one-shot tracker. The cache stores a
// FLAT list of batches across all MeshRefs of an entity, so a single
// ApplyCacheHit call already drew every batch. _walkScratch yields
// one tuple per (entity, MeshRefIndex), so without this guard a
// 3-MeshRef static entity on a frame-2 cache hit would call
// ApplyCacheHit 3 times — appending all 6 batches × 3 = 18 instances
// to _groups instead of 6. Result: severe Z-fighting + 3× perf hit
// on every multi-part static entity (buildings, statues, multi-MeshRef
// NPCs). The fast path must fire only on the FIRST tuple of each
// entity; subsequent tuples skip via this tracker.
uint? lastHitEntityId = null;
// Tier 1 cache (#53) — incomplete-entity guard. When any MeshRef of
// the current entity has _meshAdapter.TryGetRenderData return null
// (mesh still async-decoding via ObjectMeshManager.PrepareMeshDataAsync),
// we mark the entity incomplete and DROP the accumulated populate
// scratch at entity boundary instead of writing it to the cache.
// Otherwise the cache would hold a partial classification (some parts
// missing), and frame-2 cache hits would persist that partial render
// even after the missing mesh loads — every subsequent frame sees the
// cache hit and skips re-classification, so the missing parts never
// recover. User-visible symptom: the drudge statue on top of the
// Foundry (multi-part Setup entity with AnimPartChange) renders with
// some parts missing permanently. Reset on entity change.
bool currentEntityIncomplete = false;
// Per-tuple entity tracker used purely for entity-change detection.
// Updated UNCONDITIONALLY at end of every tuple (including tuples that
// skip via null renderData), so the flag-reset block below correctly
// distinguishes "new entity" from "same entity, different tuple."
// populateEntityId can't be used for this because it's only set after
// a successful slow-path classification.
uint? prevTupleEntityId = null;
foreach (RenderInstanceTuple tuple in _candidateTupleScratch)
{
RenderInstanceCandidate entity = tuple.Candidate;
int partIdx = tuple.MeshRefIndex;
uint landblockId = entity.TupleLandblockId;
if (diag) _entitiesSeen++;
// Skip subsequent tuples of an entity that already cache-hit on
// its first tuple. ApplyCacheHit drew the full flat batch list;
// re-firing here would N-multiply the instance count. Diag
// _entitiesDrawn is bumped here to preserve per-tuple parity with
// the previous counting semantics.
if (lastHitEntityId == entity.Id)
{
if (diag) _entitiesDrawn++;
continue;
}
// Reset the hit tracker on entity change so the next entity's
// first tuple re-checks the cache. (When this iteration is the
// FIRST tuple of a new entity after a cache-hit entity, we must
// not retain the previous entity's id.)
if (lastHitEntityId.HasValue && lastHitEntityId.Value != entity.Id)
{
lastHitEntityId = null;
}
// Tier 1 cache (#53) — drop the previous entity's accumulated
// populate scratch BEFORE MaybeFlushOnEntityChange runs. If the
// previous entity ended incomplete (≥1 null renderData), we MUST
// NOT cache its partial classification: clear scratch and null
// the tracker so MaybeFlushOnEntityChange sees the cleaned state
// and no-ops for this entity. Reset the incomplete flag for the
// new entity so each one gets a fresh measurement.
//
// CRITICAL: the flag reset must fire ONLY on entity change, not
// every tuple. Resetting per-tuple within the same entity would
// undo a null-renderData flag set by a previous tuple of the same
// entity → if the missing MeshRef sits in the MIDDLE of the
// entity's MeshRefs list, a later valid tuple's reset would
// re-mark the entity "complete" and let partial data populate
// the cache. Trees with [trunk valid, branches null, leaves
// valid] hit this exactly — branches never recover.
// #119 root-cause fix: cache operations key on the entity's OWNING
// landblock, never the Draw call's tuple landblock (which is the
// PLAYER's landblock on the bucket path). See ResolveCacheLandblockHint.
uint cacheLb = ResolveCacheLandblockHint(in entity);
bool isNewEntity = !prevTupleEntityId.HasValue || prevTupleEntityId.Value != entity.Id;
if (isNewEntity)
{
if (populateEntityId.HasValue && currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
currentEntityIncomplete = false;
// Phase U.4: resolve this entity's clip slot ONCE per entity
// (constant across its tuples). On the U.3 / outdoor path
// (_clipRoutingActive false) every entity is slot 0, never culled.
// The whole decision (including the routing-active gate) lives in
// the pure ResolveSlotForFrame helper so it's unit-testable.
(_currentEntitySlot, _currentEntityCulled) = ResolveSlotForFrame(
_clipRoutingActive, entity.ServerGuid, entity.ParentCell,
_cellIdToSlot, _outdoorSlot, _outdoorVisible);
if (_currentEntityCulled)
probeCulledEntities++;
// Fix B: select this entity's up-to-8 point/spot lights ONCE (the set
// is constant across the entity's parts/tuples), by the entity's
// bounding sphere — camera-INDEPENDENT (minimize_object_lighting).
ComputeEntityLightSet(entity);
_currentEntitySelectionLighting =
_selectionLighting?.TryGetLighting(
entity.ServerGuid,
entity.Id,
out var lighting) == true
? new Vector2(lighting.Luminosity, lighting.Diffuse)
: new Vector2(0f, 1f);
// #119 decisive probe: one-shot dump (+ change re-emission) for
// ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue
// so a routed-out entity still reports its state.
MaybeEmitEntityDump(
in entity,
cacheLb,
_currentEntityCulled,
_candidateTupleScratch);
// #176 seam-draw probe: any entity parented to a target cell reports
// its position + light set (a floor-coincident static/plate would be
// the z-fight's second draw; the player entity is the positive
// control). Before the culled-continue, like the dump above.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled
&& entity.ParentCell is { } seamPc
&& AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(seamPc))
MaybeEmitSeamEnt(entity);
}
prevTupleEntityId = entity.Id;
// Flush-on-entity-change: if the previous entity accumulated any
// batches AND this iteration is for a different entity, populate
// its cache entry now and reset the scratch buffer. Runs for ALL
// 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, _populateSelectionScratch);
// Phase U.4: a culled entity (cell not visible, or no outdoors visible
// for an outdoor stab) contributes NO instances. Skip after the
// boundary flush above so the previous entity still committed; the
// next entity's isNewEntity logic is unaffected (prevTupleEntityId is
// already updated). Matches the existing visible-cell / frustum cull:
// nothing enters _groups, so neither binding=0 nor binding=3 sees it.
if (_currentEntityCulled)
continue;
Matrix4x4 entityWorld = entity.RootWorld;
bool isAnimated = entity.Animated;
// Cache-hit fast path (Task 10): static entity with a populated
// cache entry skips classification entirely. Walk the cached
// (GroupKey, RestPose) flat list and append cached.RestPose *
// entityWorld to each matching group's matrices. Animated entities
// bypass the cache (collector is set null below; their entries are
// never populated in the first place).
//
// Placed AFTER the entity-change flush above so that, on a
// hit, this iteration also finishes flushing any pending
// populate state from a previous entity. Animated entities never
// enter this branch — the !isAnimated guard makes that explicit.
//
// Fires ONCE per entity: the first tuple reaches here, runs
// ApplyCacheHit, sets lastHitEntityId, and continues. Subsequent
// tuples of the same entity short-circuit at the top of the loop
// body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, cacheLb, out var cachedEntry))
{
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(cachedEntry!, entity, entityWorld);
// anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated
// anyVao yet. Look up THIS entity's first MeshRef once via
// the mesh adapter — cheap dict lookup, not a re-classify.
if (anyVao == 0)
{
MeshRef firstMeshRef = tuple.MeshRef;
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
}
if (diag) _entitiesDrawn++;
lastHitEntityId = entity.Id;
#if DEBUG
// Cross-check guard: assert the membership predicate held at hit time.
// The full re-classification cross-check (spec section 6.5) is a stretch
// goal; this simpler assert catches the prior Tier 1 bug class — a
// static entity that turns out to actually be animated would fire here.
//
// Structurally redundant with the `if (!isAnimated && ...)` branch
// condition, but serves as a TRIPWIRE: a future refactor that
// incorrectly relaxes the branch condition (e.g., removes
// `!isAnimated` from the guard) would silently allow animated
// entities into the fast path; the assert catches that immediately.
System.Diagnostics.Debug.Assert(
!isAnimated,
$"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
#endif
continue;
}
// Compute structural palette identity once per entity. Its hash
// accelerates lookup; equality still compares every range.
PaletteCompositeIdentity paletteIdentity = default;
if (entity.PaletteOverride is not null)
paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride);
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
MeshRef meshRef = tuple.MeshRef;
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
// [indoor-lookup] probe — emit once per cell entity per sec.
// Fires BEFORE the null-renderData early-continue so a miss still
// emits hit=false, distinguishing H2 (empty batches) from H6
// (dispatcher fails to traverse Setup).
ulong lookupCellId = (ulong)gfxObjId;
if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
&& RenderingDiagnostics.ProbeIndoorLookupEnabled
// Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
// (which key on the same gfxObjId). Without this, IndoorAll=1 would
// silence the lookup probe whenever the walk probe fired first.
&& ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
{
bool hit = renderData is not null;
bool isSetup = hit && renderData!.IsSetup;
int partCount = isSetup ? renderData!.SetupParts.Count : 0;
int partsHit = 0, partsMiss = 0;
if (isSetup)
{
foreach (var (partId, _) in renderData!.SetupParts)
{
if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
else partsMiss++;
}
}
bool hasEnvCellGeom = isSetup
&& renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
Console.WriteLine(
$"[indoor-lookup] cellId=0x{lookupCellId:X8} " +
$"hit={hit} isSetup={isSetup} partCount={partCount} " +
$"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
}
if (renderData is null)
{
// Tier 1 cache (#53): mesh data is still async-decoding via
// WB's ObjectMeshManager.PrepareMeshDataAsync. Flag the entity
// as incomplete so the entity-boundary check (or end-of-loop
// check) drops the accumulated populate scratch instead of
// caching a partial classification. The slow path retries on
// the next frame; once all this entity's meshes have loaded,
// the populate fires with the complete batch set.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
// #128 self-heal: a missing-but-referenced mesh re-requests
// its load HERE — the one site that touches it every frame —
// so a preparation lost to landblock churn (cancelled after
// the last registration event) can never stay lost. Deduped
// per Draw; PrepareMeshDataAsync is idempotent while pending.
if (_missRequested.Add(gfxObjId))
{
_meshAdapter.EnsureLoaded(gfxObjId);
if (diag && _missLogged.Add(gfxObjId))
Console.WriteLine($"[mesh-miss] 0x{gfxObjId:X10} re-requested at point of use");
}
continue;
}
if (anyVao == 0) anyVao = renderData.VAO;
// Cache-miss path (animated entities skip cache entirely).
// Static entities accumulate into _populateScratch across ALL
// 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)
{
// #188: setupPartIndex is the SAME index space
// TransparentPartHook.PartIndex addresses — retail's CPartArray
// numbers parts by their ordinal position in the Setup's own
// part list (SetupPartTransforms.Compute is the other verified
// consumer of this exact indexing: one rigid pose per
// Setup.Parts[i]). NOT the outer per-MeshRef loop index — a
// MeshRef is acdream's own decomposition of top-level
// attachments (weapon/shield/etc), a different concept.
for (int setupPartIndex = 0; setupPartIndex < renderData.SetupParts.Count; setupPartIndex++)
{
var (partGfxObjId, partTransform) = renderData.SetupParts[setupPartIndex];
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null)
{
// #128 self-heal + #53: a missing Setup PART must mark
// the entity incomplete (else a partial batch set
// caches permanently — the same bug class one level
// deeper) and re-request its load like the MeshRef
// path above.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
if (_missRequested.Add(partGfxObjId))
{
_meshAdapter.EnsureLoaded(partGfxObjId);
if (diag && _missLogged.Add(partGfxObjId))
Console.WriteLine($"[mesh-miss] 0x{partGfxObjId:X10} (setup part) re-requested at point of use");
}
continue;
}
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
// [indoor-xform] probe — only for the cell's synthetic
// geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
// cellGeomId convention). One line per part per sec.
// Disambiguates hypothesis H5 (transform double-apply —
// composedT lands at 2 × cellOrigin).
if ((partGfxObjId & 0x1_0000_0000UL) != 0
&& RenderingDiagnostics.ProbeIndoorXformEnabled
&& ShouldEmitIndoorProbe(partGfxObjId))
{
Console.WriteLine(
$"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
$"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
$"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
$"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
$"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
}
var restPose = partTransform * meshRef.PartTransform;
// #188 retail CPhysicsPart::Draw (0x0050d7a0) early-out: once a
// part's translucency hits EXACTLY 1.0 (fully invisible), retail
// sets draw_state|=1 and skips the whole part outright — not a
// blend to nothing. TranslucencyFadeManager.AdvanceAll guarantees
// t=1 commits the bitwise-exact value so this check is safe.
float opacityMultiplier = 1.0f;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)setupPartIndex, out float translucencyValue))
{
if (translucencyValue >= 1.0f) continue; // skip this part's draw entirely
opacityMultiplier = 1f - translucencyValue; // CMaterial::SetTranslucencySimple 0x005396f0
}
if (!ClassifyBatches(partData, model, entity, meshRef, paletteIdentity, restPose, opacityMultiplier, collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
(uint)partGfxObjId,
restPose));
drewAny = true;
}
}
else
{
// #188/#32: this MeshRef's ordinal IS the retail CPartArray part
// ordinal TransparentPartHook.PartIndex addresses. A single-part
// object trivially reads index 0; a FLATTENED multi-part live
// entity (SetupMesh.Flatten emits one bare-GfxObj MeshRef per
// Setup.Parts[i], order preserved, AnimPartChanges replace
// in place) keeps the same equality per part. The previous
// constant 0 silently ignored per-part hooks on every flattened
// entity — the Bind Stone's idle cycle hides its four authored
// shard parts (3-6) with TransparentPartHook start=end=1.0
// every loop, and they stayed visible.
float opacityMultiplier = 1.0f;
bool fullyInvisible = false;
if (_translucencyFades.TryGetCurrentValue(entity.Id, (uint)partIdx, out float translucencyValue))
{
if (translucencyValue >= 1.0f) fullyInvisible = true;
else opacityMultiplier = 1f - translucencyValue;
}
if (!fullyInvisible)
{
var model = meshRef.PartTransform * entityWorld;
if (!ClassifyBatches(renderData, model, entity, meshRef, paletteIdentity, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
currentEntityIncomplete = true;
_selectionSink?.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
partIdx,
(uint)gfxObjId,
model);
selectionCollector?.Add(new CachedSelectionPart(
partIdx,
(uint)gfxObjId,
meshRef.PartTransform));
drewAny = true;
}
}
// Track THIS entity for the next iteration's flush check. Only
// when collector is non-null (entity is static); animated entities
// leave the tracker null so we don't try to flush them.
// #119: the populate commits under the OWNER-derived hint so the
// entry is found by the same key on the next frame's TryGet and
// swept by InvalidateLandblock when the OWNING landblock unloads.
if (collector is not null)
{
populateEntityId = entity.Id;
populateLandblockId = cacheLb;
}
if (diag && drewAny) _entitiesDrawn++;
}
// Tier 1 cache (#53) — drop the accumulated populate scratch if the
// LAST entity in the loop ended incomplete (had ≥1 null renderData).
// Same reason as the entity-boundary handling above: avoid caching a
// partial classification. The slow path will retry on the next frame
// and populate correctly once all meshes have loaded.
if (currentEntityIncomplete)
{
_populateScratch.Clear();
_populateSelectionScratch.Clear();
populateEntityId = null;
}
// Final flush: the last entity in _walkScratch has no "next iteration"
// 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, _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
// culled-entity count. Routed draws only (the landscape pass under DrawInside) so the
// unrouted per-cell bucket draws don't oscillate the print-on-change signature.
// Emitted BEFORE the MeshSourceReady / totalInstances early-outs so an
// all-culled frame still reports (inst=0).
if (RenderingDiagnostics.ProbeClipRouteEnabled && _clipRoutingActive)
EmitClipRouteDispatchProbe(probeCulledEntities);
ExecuteClassifiedGroups(
vp,
camPos,
anyVao,
_groups.Values,
set,
walkResult.EntitiesWalked,
_walkScratch.Count,
diag,
observeCurrentPath: true);
}
/// <summary>
/// Whether there is a mesh source to draw from. The encoder arm has no
/// vertex array of its own — the pipeline owns one shaped by
/// <c>GpuVertexLayout.WorldMesh</c> — so this asks the shared mesh arena
/// directly, the backend-neutral question V6i-3 published as
/// <c>HasStores</c>.
/// </summary>
private bool MeshSourceReady() =>
_meshAdapter.MeshManager?.GlobalBuffer is { HasStores: true };
private bool BeginEntityDispatch(
ICamera camera,
out Matrix4x4 viewProjection,
out Vector3 cameraWorldPosition)
{
_selectionLighting?.TickLighting();
_indoorProbeFrameCounter++;
viewProjection = camera.View * camera.Projection;
_missRequested.Clear();
bool diagnosticsEnabled = string.Equals(
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
StringComparison.Ordinal);
_cpuStopwatch.Restart();
cameraWorldPosition = Vector3.Zero;
if (Matrix4x4.Invert(camera.View, out Matrix4x4 inverseView))
cameraWorldPosition = inverseView.Translation;
return diagnosticsEnabled;
}
/// <summary>
/// <paramref name="anyVao"/> is no longer read — it survives as a
/// parameter only because <c>WbDrawDispatcher.PackedOracle.cs</c> calls
/// this positionally with its own mirrored classification's
/// <c>PackedRangeClassification.AnyVao</c> and that partial is out of
/// scope for this collapse.
/// </summary>
private void ExecuteClassifiedGroups(
Matrix4x4 vp,
Vector3 camPos,
uint anyVao,
IEnumerable<InstanceGroup> groups,
EntitySet set,
int entitiesWalked,
int tupleCount,
bool diag,
bool observeCurrentPath)
{
// Nothing visible — skip the pass entirely.
if (!MeshSourceReady())
{
LastDrawStats = new DrawStats(set, entitiesWalked, tupleCount, 0, 0, 0, 0, 0, 0);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
visibleInstanceCount: 0,
immediateInstanceCount: 0,
deferTransparent: false,
camPos);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
}
// ── Phase 3: assign FirstInstance per group, lay matrices contiguously, sort opaque ──
bool deferTransparent = _alphaQueue?.IsCollecting == true;
var instanceCounts = PartitionInstanceGroups(
groups,
deferTransparent,
camPos,
_opaqueDraws,
_translucentDraws);
int totalInstances = instanceCounts.VisibleInstances;
int immediateInstances = instanceCounts.ImmediateInstances;
if (totalInstances == 0)
{
LastDrawStats = new DrawStats(set, entitiesWalked, tupleCount, 0, 0, 0, 0, 0, 0);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
visibleInstanceCount: 0,
immediateInstanceCount: 0,
deferTransparent,
camPos);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
}
_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];
// Phase U.4: size the per-instance clip-slot buffer to match the instance
// 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 < 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 < 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 < 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 < immediateInstances)
_alphaData = new float[immediateInstances + 256];
if (_selectionLightingData.Length < immediateInstances)
_selectionLightingData = new Vector2[immediateInstances + 256];
int cursor = 0;
foreach (InstanceGroup grp in _opaqueDraws)
StageImmediateGroup(grp, ref cursor);
if (!deferTransparent)
{
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
// part of an indirect command. Sorting by distance alone can turn a
// stable 1k-draw live scene into hundreds of tiny MDI runs after a
// landblock transition, which shows up as a GPU-command bottleneck
// without a triangle-count spike.
// Retail particles and ordinary object parts share CPartCell's
// CShadowPart list before delayed alpha is flushed. During the world
// alpha frame, preserve each transparent instance as an independent
// 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.
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
int immediateTransparentCount = deferTransparent ? 0 : _translucentDraws.Count;
int totalDraws = _opaqueDraws.Count + immediateTransparentCount;
TrackScratchDemand(Math.Max(totalInstances, totalDraws));
if (_batchData.Length < totalDraws)
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
if (_drawCullModes.Length < totalDraws)
_drawCullModes = new CullMode[totalDraws + 64];
if (_batchPublicScratch.Length < totalDraws)
_batchPublicScratch = new BatchDataPublic[totalDraws + 64];
_groupInputScratch.Clear();
foreach (var g in _opaqueDraws) _groupInputScratch.Add(ToInput(g));
if (!deferTransparent)
foreach (var g in _translucentDraws) _groupInputScratch.Add(ToInput(g));
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
var layout = BuildIndirectArrays(
_groupInputScratch,
_indirectCommands,
_batchPublicScratch,
_drawCullModes);
long totalTriangles = 0;
foreach (var input in _groupInputScratch)
totalTriangles += (long)(input.IndexCount / 3) * input.InstanceCount;
int cullRuns =
CountCullRuns(_drawCullModes, 0, layout.OpaqueCount) +
CountCullRuns(_drawCullModes, layout.OpaqueCount, layout.TransparentCount);
// Copy back into _batchData
for (int i = 0; i < totalDraws; i++)
{
_batchData[i] = new BatchData
{
TextureIndex = _batchPublicScratch[i].TextureIndex,
TextureLayer = _batchPublicScratch[i].TextureLayer,
Flags = _batchPublicScratch[i].Flags,
};
}
_opaqueDrawCount = layout.OpaqueCount;
_transparentDrawCount = layout.TransparentCount;
_transparentByteOffset = layout.TransparentByteOffset;
LastDrawStats = new DrawStats(
set,
entitiesWalked,
tupleCount,
totalInstances,
totalDraws,
cullRuns,
_opaqueDrawCount,
_transparentDrawCount,
totalTriangles);
ObserveClassifiedDispatcherSubmission(observeCurrentPath,
totalInstances,
immediateInstances,
deferTransparent,
camPos);
// Campaign V slice V11: every per-frame upload is a frame ring slice
// bound through the borrowed world pass, which retires the buffer-set
// pool structurally. See WbDrawDispatcher.Rhi.cs.
SubmitRhi(vp, immediateInstances, totalDraws, diag);
_cpuStopwatch.Stop();
if (diag)
{
long cpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L
/ System.Diagnostics.Stopwatch.Frequency;
_cpuSamples[_cpuSampleCursor] = cpuUs;
_cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length;
_drawsIssued += _opaqueDrawCount + _transparentDrawCount;
_instancesIssued += totalInstances;
MaybeFlushDiag();
}
}
/// <summary>
/// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only
/// entities whose ParentCellId is in <paramref name="cellIds"/>, plus
/// outdoor-style entities matching the EntitySet partition. Used by
/// the indoor render branch to scope rendering to the camera-buildings'
/// cells.
///
/// <para>Mirrors the existing visibleCellIds-based Draw but with an
/// explicit cell list (not the BFS-derived visibility set). The semantic
/// difference is at the caller: cellIds = the camera-buildings' EnvCellIds,
/// not the portal BFS result. The dispatcher's internal logic is identical
/// — it filters indoor entities by membership in the provided set.</para>
/// </summary>
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
IReadOnlyCollection<uint> cellIds,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? animatedEntityIds = null,
EntitySet set = EntitySet.All)
{
// Adapt IReadOnlyCollection<uint> → HashSet<uint> for the existing path.
// If the caller already passed a HashSet, avoid re-wrapping.
HashSet<uint> cellIdSet = cellIds is HashSet<uint> hs ? hs : new HashSet<uint>(cellIds);
Draw(camera, landblockEntries,
frustum: frustum,
neverCullLandblockId: neverCullLandblockId,
visibleCellIds: cellIdSet,
animatedEntityIds: animatedEntityIds,
set: set);
}
private void PublishCachedSelectionParts(
EntityCacheEntry cachedEntry,
in RenderInstanceCandidate entity,
Matrix4x4 entityWorld)
{
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
{
_selectionSink!.AddVisiblePart(
entity.ServerGuid,
entity.LocalEntityId,
part.PartIndex,
part.GfxObjId,
part.RestPose * entityWorld);
}
}
// Campaign V slice V4t: static again — the group already carries the
// device's table slot, so there is no per-renderer interning left to do.
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
IndexCount: g.IndexCount,
FirstIndex: g.FirstIndex,
BaseVertex: g.BaseVertex,
InstanceCount: g.InstanceCount,
FirstInstance: g.FirstInstance,
TextureIndex: g.TextureSlot.Index,
TextureLayer: g.TextureLayer,
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,
g.IndexCount,
g.TextureSlot,
g.TextureLayer,
g.Translucency,
g.CullMode);
private void ObserveCurrentDispatcherSubmission(
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
Vector3 cameraWorldPosition)
{
ICurrentRenderDispatcherObserver? observer =
_currentRenderSceneObserver;
if (observer is null)
return;
CurrentRenderDispatcherSubmission submission =
CreateDispatcherSubmission(
visibleInstanceCount,
immediateInstanceCount,
deferTransparent,
_opaqueDraws,
_translucentDraws,
cameraWorldPosition,
_alphaFingerprintScratch);
observer.ObserveDispatcherSubmission(in submission);
}
private void ObserveClassifiedDispatcherSubmission(
bool observeCurrentPath,
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
Vector3 cameraWorldPosition)
{
if (observeCurrentPath)
{
ObserveCurrentDispatcherSubmission(
visibleInstanceCount,
immediateInstanceCount,
deferTransparent,
cameraWorldPosition);
}
}
internal static CurrentRenderDispatcherSubmission
CreateDispatcherSubmission(
int visibleInstanceCount,
int immediateInstanceCount,
bool deferTransparent,
IReadOnlyList<InstanceGroup> opaque,
IReadOnlyList<InstanceGroup> transparent,
Vector3 cameraWorldPosition,
List<AlphaFingerprint> alphaScratch)
{
// The no-VAO production early-out deliberately skips group
// partitioning, so its reusable opaque/transparent lists may still
// contain the preceding draw's entries. No instances were accepted;
// those stale scratch entries are not part of this submission and
// must not leak into its diagnostic identity.
IReadOnlyList<InstanceGroup> acceptedOpaque =
visibleInstanceCount == 0
? Array.Empty<InstanceGroup>()
: opaque;
IReadOnlyList<InstanceGroup> acceptedTransparent =
visibleInstanceCount == 0
? Array.Empty<InstanceGroup>()
: transparent;
int opaqueGroupCount = acceptedOpaque.Count;
int transparentGroupCount = acceptedTransparent.Count;
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(visibleInstanceCount);
hash.Add(immediateInstanceCount);
hash.Add(opaqueGroupCount);
hash.Add(transparentGroupCount);
hash.Add(deferTransparent);
RenderSceneHash128 opaqueDigest =
BuildOpaqueSubmissionDigest(acceptedOpaque);
RenderSceneHash128 transparentDigest =
BuildTransparentSubmissionDigest(
acceptedTransparent,
cameraWorldPosition,
alphaScratch);
RenderSceneHash128 transparentSetDigest =
BuildOpaqueSubmissionDigest(acceptedTransparent);
hash.Add(opaqueDigest.Low);
hash.Add(opaqueDigest.High);
hash.Add(transparentDigest.Low);
hash.Add(transparentDigest.High);
hash.Add(transparentSetDigest.Low);
hash.Add(transparentSetDigest.High);
return new CurrentRenderDispatcherSubmission(
VisibleInstanceCount: visibleInstanceCount,
ImmediateInstanceCount: immediateInstanceCount,
OpaqueGroupCount: opaqueGroupCount,
TransparentGroupCount: transparentGroupCount,
TransparentDeferred: deferTransparent,
OpaqueDigest: opaqueDigest,
TransparentDigest: transparentDigest,
TransparentSetDigest: transparentSetDigest,
Digest: hash.Finish());
}
private static RenderSceneHash128 BuildOpaqueSubmissionDigest(
IReadOnlyList<InstanceGroup> groups)
{
// Opaque groups are a mathematical set: depth testing makes submission
// order irrelevant, and equal-distance List.Sort ties can reflect the
// persistent dictionary's historical insertion order. Preserve exact
// group and per-instance contents while combining group fingerprints
// commutatively. Transparent groups remain strictly order-sensitive.
ulong xorLow = 0;
ulong xorHigh = 0;
ulong sumLow = 0;
ulong sumHigh = 0;
for (int index = 0; index < groups.Count; index++)
{
StableRenderHash128 groupHash = StableRenderHash128.Create();
AddOpaqueSubmissionGroup(
ref groupHash,
groups[index]);
RenderSceneHash128 digest = groupHash.Finish();
xorLow ^= digest.Low;
xorHigh ^= digest.High;
sumLow = unchecked(sumLow + digest.Low);
sumHigh = unchecked(sumHigh + digest.High);
}
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(groups.Count);
hash.Add(xorLow);
hash.Add(xorHigh);
hash.Add(sumLow);
hash.Add(sumHigh);
return hash.Finish();
}
private static RenderSceneHash128 BuildTransparentSubmissionDigest(
IReadOnlyList<InstanceGroup> groups,
Vector3 cameraWorldPosition,
List<AlphaFingerprint> scratch)
{
scratch.Clear();
for (int groupIndex = 0;
groupIndex < groups.Count;
groupIndex++)
{
InstanceGroup group = groups[groupIndex];
for (int instanceIndex = 0;
instanceIndex < group.Matrices.Count;
instanceIndex++)
{
float distance =
RetailAlphaOrdering.ComputeViewerDistance(
group.LocalSortCenters[instanceIndex],
group.Matrices[instanceIndex],
cameraWorldPosition);
if (!float.IsFinite(distance) || distance <= 0f)
distance = 0f;
scratch.Add(new AlphaFingerprint(
group,
instanceIndex,
distance,
group.SubmissionOrders[instanceIndex]));
}
}
scratch.Sort(AlphaFingerprintComparer.Instance);
StableRenderHash128 hash = StableRenderHash128.Create();
hash.Add(scratch.Count);
for (int index = 0; index < scratch.Count; index++)
{
AlphaFingerprint entry = scratch[index];
GroupKey key = ToKey(entry.Group);
hash.Add(key.FirstIndex);
hash.Add(key.BaseVertex);
hash.Add(key.IndexCount);
hash.Add(key.TextureSlot.Index);
hash.Add(key.TextureLayer);
hash.Add((int)key.Translucency);
hash.Add((int)key.CullMode);
hash.Add(entry.ViewerDistance);
AddSubmissionInstance(
ref hash,
entry.Group,
entry.InstanceIndex);
}
return hash.Finish();
}
internal readonly record struct AlphaFingerprint(
InstanceGroup Group,
int InstanceIndex,
float ViewerDistance,
int SubmissionOrder);
private sealed class AlphaFingerprintComparer :
IComparer<AlphaFingerprint>
{
public static AlphaFingerprintComparer Instance { get; } =
new();
private AlphaFingerprintComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right)
{
int value = right.ViewerDistance.CompareTo(
left.ViewerDistance);
return value != 0
? value
: left.SubmissionOrder.CompareTo(
right.SubmissionOrder);
}
}
private static void AddOpaqueSubmissionGroup(
ref StableRenderHash128 hash,
InstanceGroup group)
{
GroupKey key = ToKey(group);
hash.Add(key.FirstIndex);
hash.Add(key.BaseVertex);
hash.Add(key.IndexCount);
hash.Add(key.TextureSlot.Index);
hash.Add(key.TextureLayer);
hash.Add((int)key.Translucency);
hash.Add((int)key.CullMode);
hash.Add(group.Matrices.Count);
ulong xorLow = 0;
ulong xorHigh = 0;
ulong sumLow = 0;
ulong sumHigh = 0;
for (int index = 0;
index < group.Matrices.Count;
index++)
{
StableRenderHash128 instanceHash =
StableRenderHash128.Create();
AddSubmissionInstance(
ref instanceHash,
group,
index);
RenderSceneHash128 digest = instanceHash.Finish();
xorLow ^= digest.Low;
xorHigh ^= digest.High;
sumLow = unchecked(sumLow + digest.Low);
sumHigh = unchecked(sumHigh + digest.High);
}
hash.Add(xorLow);
hash.Add(xorHigh);
hash.Add(sumLow);
hash.Add(sumHigh);
}
private static void AddSubmissionInstance(
ref StableRenderHash128 hash,
InstanceGroup group,
int index)
{
hash.Add(group.Matrices[index]);
hash.Add(group.LocalSortCenters[index]);
hash.Add(group.Slots[index]);
InstanceLightSet lights = group.LightSets[index];
for (int lightIndex = 0;
lightIndex < LightManager.MaxLightsPerObject;
lightIndex++)
{
hash.Add(lights[lightIndex]);
}
hash.Add(group.IndoorFlags[index]);
hash.Add(group.Opacities[index]);
hash.Add(group.SelectionLighting[index]);
}
private void DeferTransparentGroups(Vector3 cameraWorldPosition, Matrix4x4 viewProjection)
{
RetailAlphaQueue queue = _alphaQueue!;
if (_deferredAlpha.Count == 0)
_deferredAlphaViewProjection = viewProjection;
else if (_deferredAlphaViewProjection != viewProjection)
throw new InvalidOperationException(
"One retail alpha scope cannot combine different view-projection matrices.");
// Retail CShadowPart::insertion_sort (0x006B5130) is stable:
// equal-CYpt parts keep the order in which the cell submitted them.
// Material grouping is an acdream batching detail and must not become
// that tiebreak. Reconstruct the original draw-local instance order
// before handing entries to the queue; its stable CYpt radix then
// preserves this sequence for exact-distance ties.
_alphaFingerprintScratch.Clear();
foreach (InstanceGroup group in _translucentDraws)
{
for (int i = 0; i < group.Matrices.Count; i++)
{
Matrix4x4 model = group.Matrices[i];
Vector3 localSortCenter = group.LocalSortCenters[i];
float viewerDistance = RetailAlphaOrdering.ComputeViewerDistance(
localSortCenter,
model,
cameraWorldPosition);
if (!float.IsFinite(viewerDistance)
|| viewerDistance <= 0f)
{
viewerDistance = 0f;
}
_alphaFingerprintScratch.Add(new AlphaFingerprint(
group,
i,
viewerDistance,
group.SubmissionOrders[i]));
}
}
_alphaFingerprintScratch.Sort(
AlphaSubmissionOrderComparer.Instance);
foreach (AlphaFingerprint entry in _alphaFingerprintScratch)
{
InstanceGroup group = entry.Group;
int i = entry.InstanceIndex;
int token = _deferredAlpha.Count;
_deferredAlpha.Add(new DeferredAlphaInstance(
ToKey(group),
group.Matrices[i],
group.Slots[i],
group.LightSets[i],
group.IndoorFlags[i],
group.Opacities[i],
group.SelectionLighting[i]));
queue.Submit(
_alphaSource,
token,
entry.ViewerDistance);
}
}
private sealed class AlphaSubmissionOrderComparer :
IComparer<AlphaFingerprint>
{
public static AlphaSubmissionOrderComparer Instance { get; } =
new();
private AlphaSubmissionOrderComparer()
{
}
public int Compare(
AlphaFingerprint left,
AlphaFingerprint right) =>
left.SubmissionOrder.CompareTo(right.SubmissionOrder);
}
private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens)
{
if (tokens.Length == 0)
return;
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
if (global is null || !MeshSourceReady())
return;
int count = tokens.Length;
EnsureDeferredAlphaCapacity(count);
for (int i = 0; i < count; i++)
{
DeferredAlphaInstance entry = _deferredAlpha[tokens[i]];
WriteMatrix(_instanceData, i * 16, entry.Model);
_clipSlotData[i] = entry.ClipSlot;
_indoorData[i] = entry.Indoor;
_alphaData[i] = entry.Opacity;
_selectionLightingData[i] = entry.SelectionLighting;
int lightOffset = i * LightManager.MaxLightsPerObject;
entry.Lights.CopyTo(_lightSetData, lightOffset);
GroupKey key = entry.Key;
_batchData[i] = new BatchData
{
// Campaign V slice V2: table slot, not the raw handle.
TextureIndex = key.TextureSlot.Index,
TextureLayer = key.TextureLayer,
Flags = 0,
};
_indirectCommands[i] = new DrawElementsIndirectCommand
{
Count = (uint)key.IndexCount,
InstanceCount = 1,
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
BaseInstance = (uint)i,
};
_drawCullModes[i] = key.CullMode;
_deferredAlphaKinds[i] = key.Translucency;
}
// One upload per source per sorted alpha scope. RetailAlphaQueue later
// draws contiguous ranges from this immutable prepared payload; it must
// never overwrite these buffers for every short mesh/particle run.
//
// A ring allocation cannot outlive its frame as a ref struct, but its
// buffer, offset and size can be stored — so the payload is written
// once here and bound many times below without recopying.
PrepareRhiAlphaSections(count);
}
private void DrawPreparedAlphaBatch(int firstPreparedDraw, int drawCount)
{
if (drawCount <= 0)
return;
if (firstPreparedDraw < 0
|| firstPreparedDraw > _deferredAlpha.Count - drawCount)
throw new ArgumentOutOfRangeException(nameof(firstPreparedDraw));
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
if (global is null || !MeshSourceReady())
return;
DrawPreparedAlphaBatchRhi(global, firstPreparedDraw, drawCount);
}
private void EnsureDeferredAlphaCapacity(int count)
{
TrackScratchDemand(count);
int neededMatrixFloats = count * 16;
if (_instanceData.Length < neededMatrixFloats)
_instanceData = new float[neededMatrixFloats + 256 * 16];
if (_clipSlotData.Length < count)
_clipSlotData = new uint[count + 256];
if (_indoorData.Length < count)
_indoorData = new uint[count + 256];
if (_alphaData.Length < count)
_alphaData = new float[count + 256];
if (_selectionLightingData.Length < count)
_selectionLightingData = new Vector2[count + 256];
if (_lightSetData.Length < count * LightManager.MaxLightsPerObject)
_lightSetData = new int[(count + 256) * LightManager.MaxLightsPerObject];
if (_batchData.Length < count)
_batchData = new BatchData[count + 64];
if (_indirectCommands.Length < count)
_indirectCommands = new DrawElementsIndirectCommand[count + 64];
if (_drawCullModes.Length < count)
_drawCullModes = new CullMode[count + 64];
if (_deferredAlphaKinds.Length < count)
_deferredAlphaKinds = new TranslucencyKind[count + 64];
}
private void ResetDeferredAlphaSubmissions()
{
_deferredAlpha.Clear();
}
private void TrackScratchDemand(int units)
{
if (units > _scratchPeakUnits)
_scratchPeakUnits = units;
}
private void ApplyScratchRetention(int observedUnits)
{
int currentCapacity = Math.Max(
_instanceData.Length / 16,
Math.Max(
_lightSetData.Length / LightManager.MaxLightsPerObject,
Math.Max(
_deferredAlpha.Capacity,
Math.Max(_batchData.Length, _indirectCommands.Length))));
int bytesPerUnit = checked(
16 * sizeof(float)
+ sizeof(uint)
+ LightManager.MaxLightsPerObject * sizeof(int)
+ sizeof(uint)
+ sizeof(float)
+ Unsafe.SizeOf<Vector2>()
+ Unsafe.SizeOf<BatchData>()
+ Unsafe.SizeOf<DrawElementsIndirectCommand>()
+ Unsafe.SizeOf<CullMode>()
+ Unsafe.SizeOf<BatchDataPublic>()
+ Unsafe.SizeOf<TranslucencyKind>()
+ Unsafe.SizeOf<DeferredAlphaInstance>());
int targetCapacity = _alphaScratchPolicy.ObserveAndSelectCapacity(
currentCapacity,
observedUnits,
bytesPerUnit,
minimumCapacity: 256,
growthQuantum: 256);
if (targetCapacity >= currentCapacity)
return;
_instanceData = new float[checked(targetCapacity * 16)];
_clipSlotData = new uint[targetCapacity];
_lightSetData = new int[
checked(targetCapacity * LightManager.MaxLightsPerObject)];
_indoorData = new uint[targetCapacity];
_alphaData = new float[targetCapacity];
_selectionLightingData = new Vector2[targetCapacity];
_batchData = new BatchData[targetCapacity];
_indirectCommands = new DrawElementsIndirectCommand[targetCapacity];
_drawCullModes = new CullMode[targetCapacity];
_batchPublicScratch = new BatchDataPublic[targetCapacity];
_deferredAlphaKinds = new TranslucencyKind[targetCapacity];
_deferredAlpha.Capacity = targetCapacity;
}
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = ((int)a.CullMode).CompareTo((int)b.CullMode);
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
}
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = ((int)a.CullMode).CompareTo((int)b.CullMode);
return cull != 0 ? cull : b.SortDistance.CompareTo(a.SortDistance);
}
private static int CountCullRuns(CullMode[] modes, int startCommand, int commandCount)
{
if (commandCount <= 0) return 0;
int end = startCommand + commandCount;
int runs = 1;
var previous = modes[startCommand];
for (int i = startCommand + 1; i < end; i++)
{
var current = modes[i];
if (current == previous) continue;
runs++;
previous = current;
}
return runs;
}
private void MaybeFlushDiag()
{
long now = Environment.TickCount64;
if (now - _lastLogTick > 5000)
{
long cpuMed = MedianMicros(_cpuSamples);
long cpuP95 = Percentile95Micros(_cpuSamples);
long gpuMed = MedianMicros(_gpuSamples);
long gpuP95 = Percentile95Micros(_gpuSamples);
// A.5 T23: flag when entity dispatcher median exceeds 2.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const long BudgetUs = 2000;
string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
_lastLogTick = now;
// Don't reset the sample buffers — they're a moving window of the
// last 256 frames; clearing per 5s flush would lose recent history.
}
}
private static long MedianMicros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// Sorted ascending: zero-padding front, samples at the back. (nz - 1) / 2
// from the end keeps the offset >= 0 for all nz >= 1 — the original
// nz / 2 form indexed copy[copy.Length] (crash) on the first diag flush
// when exactly 1 sample was recorded. Same fix as GameWindow's
// TerrainDiagMedianMicros twin.
return copy[copy.Length - 1 - (nz - 1) / 2];
}
private static long Percentile95Micros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
int idx = copy.Length - 1 - (int)(nz * 0.05);
return copy[idx];
}
// ── Tier 1 cache (#53) helpers extracted for testability ─────────────────
//
// Three pure-CPU static helpers carved out of Draw's per-entity loop so
// unit tests can exercise the populate/flush algorithm + cache-hit fast
// path without needing a real GL context. Production code (Draw) calls
// these helpers; the dispatcher integration tests in
// WbDrawDispatcherBucketingTests use them to drive the same algorithm
// through deterministic inputs.
/// <summary>
/// Apply a cache hit's batches into the per-frame group dictionary by
/// composing <c>cached.RestPose * entityWorld</c> per batch and routing
/// the result through <paramref name="appendInstance"/>. The delegate
/// abstracts over <see cref="InstanceGroup"/> so this helper stays
/// GL-free and unit-testable.
/// </summary>
/// <remarks>
/// Matrix multiplication is non-commutative: it MUST be
/// <c>RestPose * entityWorld</c>, not the reverse. See
/// <see cref="ComposePartWorldMatrix"/> for the full part-world product.
/// </remarks>
internal static void ApplyCacheHit(
EntityCacheEntry entry,
Matrix4x4 entityWorld,
Action<GroupKey, Matrix4x4, Vector3> appendInstance)
{
foreach (var cached in entry.Batches)
{
appendInstance(cached.Key, cached.RestPose * entityWorld, cached.LocalSortCenter);
}
}
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
/// entity's accumulated batches are committed to <paramref name="cache"/>
/// and <paramref name="populateScratch"/> is cleared. Returns the
/// updated tracker tuple — pass these back into the field locals in the
/// caller's loop.
/// </summary>
/// <remarks>
/// This is the bug-fix structure from commit 00fa8ae (per-MeshRef
/// Populate would overwrite earlier MeshRefs because the cache is
/// keyed by entity.Id; flushing only on entity boundary preserves all
/// MeshRefs' batches). _walkScratch is in entity-order so all MeshRefs
/// of one entity arrive contiguously.
/// </remarks>
internal static (uint? PopulateEntityId, uint PopulateLandblockId)
MaybeFlushOnEntityChange(
uint? populateEntityId,
uint populateLandblockId,
uint currentEntityId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
{
if (populateScratch.Count > 0)
{
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
}
populateScratch.Clear();
selectionScratch?.Clear();
return (null, 0u);
}
return (populateEntityId, populateLandblockId);
}
/// <summary>
/// End-of-loop final flush. The last entity in <c>_walkScratch</c> has
/// no next-iteration to trigger <see cref="MaybeFlushOnEntityChange"/>,
/// so commit its accumulated batches here. No-op when no populate is
/// pending (the last entity was animated, or the scratch is empty).
/// <para>
/// End-of-loop only — does NOT reset the caller's tracker locals
/// (intentional, since they go out of scope immediately after).
/// </para>
/// </summary>
internal static void FinalFlushPopulate(
uint? populateEntityId,
uint populateLandblockId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch,
List<CachedSelectionPart>? selectionScratch = null)
{
if (populateEntityId.HasValue && populateScratch.Count > 0)
{
cache.Populate(
populateEntityId.Value,
populateLandblockId,
populateScratch.ToArray(),
selectionScratch?.ToArray());
populateScratch.Clear();
}
selectionScratch?.Clear();
}
/// <summary>
/// Instance-side helper used by <see cref="ApplyCacheHit"/>. Looks up or
/// creates an <see cref="InstanceGroup"/> for the given key in
/// <c>_groups</c> and appends the per-instance world matrix.
/// </summary>
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model, Vector3 localSortCenter)
{
InstanceGroup grp = GetOrCreateInstanceGroup(key);
AppendInstanceToGroup(grp, model, localSortCenter);
}
private InstanceGroup GetOrCreateInstanceGroup(GroupKey key)
{
if (_groups.TryGetValue(key, out InstanceGroup? group))
{
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,
TextureSlot = key.TextureSlot,
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.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
// #188: cache-hit entities are always non-animated (the Tier-1 cache
// gates on !isAnimated), and TranslucencyFadeManager only ever holds
// state for entities whose animation hooks fired — so a cached
// instance can never be mid-fade. Always unmodified opacity.
grp.Opacities.Add(1.0f);
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
}
/// <summary>
/// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result
/// reused by every part/instance of it), by the entity's world bounding
/// sphere. Camera-independent (<see cref="LightManager.SelectForObject"/>), so
/// a static building's torches stay constant as the viewer moves. Fills
/// <see cref="_currentEntityLightSet"/>; unused slots are -1. On the no-lights
/// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light.
///
/// <para>
/// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN +
/// ambient ONLY — never the static wall torches. The per-object torch step
/// (<c>minimize_object_lighting</c>, 0x0054d480) runs ONLY in the indoor stage:
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398) calls it under
/// <c>if (Render::useSunlight == 0)</c>, and the outdoor landscape stage runs
/// <c>Render::useSunlightSet(1)</c> (<c>PView::DrawCells</c> 0x005a485a, right
/// before <c>LScape::draw</c> which draws buildings/scenery). So a building
/// EXTERIOR shell (<see cref="WorldEntity.IsBuildingShell"/>,
/// <see cref="WorldEntity.ParentCellId"/> = null) and all outdoor scenery /
/// creatures get the sun, not torches. We mirror that: only objects parented to
/// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so
/// the sun path alone lights them. This is what made the Holtburg meeting-hall
/// facade wash out warm — the dat's intensity-100 wall torches (range
/// Falloff×1.3) were flooding the exterior shell that retail never torch-lights.
/// The indoor "no sun" half is already handled by the global sun kill when the
/// player is inside a cell (<c>UpdateSunFromSky</c>). See the divergence register
/// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*.
/// </para>
/// </summary>
// #176 seam-draw probe (ACDREAM_PROBE_SEAMDRAW) — throwaway apparatus. One
// [seam-ent] line per target-cell entity, re-emitted on state change: world
// position (F3 z — entities do NOT get the +0.02 shell lift), cull/slot,
// and the SelectForObject light set resolved to identities (owner-cell
// low16 + intensity). Sig dict is bounded by the handful of entities that
// ever live in the target cells.
private readonly Dictionary<ulong, string> _seamEntSigs = new();
private void MaybeEmitSeamEnt(in RenderInstanceCandidate entity)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var snap = _pointSnapshot;
var sb = new System.Text.StringBuilder(200);
sb.AppendFormat(ci,
"guid=0x{0:X8} cell=0x{1:X8} pos=({2:F2},{3:F2},{4:F3}) culled={5} slot={6} indoor={7} L=[",
entity.ServerGuid, entity.ParentCellId,
entity.Position.X, entity.Position.Y, entity.Position.Z,
_currentEntityCulled ? 1 : 0, _currentEntitySlot, _currentEntityIndoor ? 1 : 0);
bool any = false;
for (int k = 0; k < LightManager.MaxLightsPerObject; k++)
{
int idx = _currentEntityLightSet[k];
if (idx < 0) continue;
if (any) sb.Append(',');
if (snap is not null && idx < snap.Count)
sb.AppendFormat(ci, "{0:X4}:I{1:F0}", snap[idx].CellId & 0xFFFFu, snap[idx].Intensity);
else
sb.Append('?').Append(idx);
any = true;
}
sb.Append(']');
string sig = sb.ToString();
if (_seamEntSigs.TryGetValue(entity.Id, out var prev) && prev == sig) return;
_seamEntSigs[entity.Id] = sig;
Console.WriteLine($"[seam-ent] t={Environment.TickCount64} {sig}");
}
private void ComputeEntityLightSet(
in RenderInstanceCandidate entity)
{
// #142: set the indoor flag first so it's available even when the early-return
// fires below. Both the torch selection and the sun gate use the same predicate,
// so they can't disagree — one call, one truth.
_currentEntityIndoor =
IndoorObjectReceivesTorches(entity.ParentCell);
_currentEntityLightSet = InstanceLightSet.Disabled;
var snap = _pointSnapshot;
if (snap is null || snap.Count == 0) return;
// Retail useSunlight gate: outdoor objects receive no per-object torches.
if (!_currentEntityIndoor) return; // #142: reuse the cached flag (was: IndoorObjectReceivesTorches(...))
Vector3 center =
(entity.Bounds.Minimum + entity.Bounds.Maximum) * 0.5f;
float radius =
(entity.Bounds.Maximum - entity.Bounds.Minimum).Length() * 0.5f;
Array.Fill(_currentEntityLightSetScratch, -1);
LightManager.SelectForObject(snap, center, radius, _currentEntityLightSetScratch);
_currentEntityLightSet = InstanceLightSet.From(_currentEntityLightSetScratch);
}
/// <summary>
/// Retail's <c>useSunlight</c> gate for per-object torch lighting, as a pure
/// predicate. An object receives the static wall torches (the indoor
/// <c>minimize_object_lighting</c> pass) ONLY when it is parented to an EnvCell
/// — an interior cell, by the AC convention <c>(cellId &amp; 0xFFFF) &gt;= 0x0100</c>.
/// Outdoor objects (building shells with null <paramref name="parentCellId"/>,
/// outdoor scenery in a land sub-cell <c>0x0001..0x00FF</c>, outdoor creatures)
/// are sun-lit only and return false. Mirrors
/// <c>RenderDeviceD3D::DrawMeshInternal</c> (0x0059f398): torches enabled iff
/// <c>Render::useSunlight == 0</c>, which is true only in the indoor draw stage.
/// </summary>
internal static bool IndoorObjectReceivesTorches(uint? parentCellId)
=> parentCellId.HasValue
&& (parentCellId.Value & 0xFFFFu) >= 0x0100u
&& (parentCellId.Value & 0xFFFFu) != 0xFFFFu; // 0xFFFF = landblock marker, not an EnvCell → outdoor
/// <summary>
/// Fix B: append the current entity's 8-slot light set to a group's
/// <see cref="InstanceGroup.LightSets"/>, parallel to its Matrices (one
/// 8-int block per instance), mirroring <c>grp.Slots.Add</c>.
/// </summary>
private void AppendCurrentLightSet(InstanceGroup grp)
{
grp.LightSets.Add(_currentEntityLightSet);
grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block
}
private bool ClassifyBatches(
ObjectRenderData renderData,
Matrix4x4 model,
in RenderInstanceCandidate entity,
MeshRef meshRef,
PaletteCompositeIdentity paletteIdentity,
Matrix4x4 restPose,
float opacityMultiplier = 1.0f,
List<CachedBatch>? collector = null)
{
bool allTexturesReady = true;
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
var batch = renderData.Batches[batchIdx];
TranslucencyKind translucency = batch.Translucency;
// #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap
// must route through the alpha-blend pass so mesh_modern.frag's
// (blend-enabled) shader actually composites the reduced alpha —
// the no-blend opaque pass would ignore it.
if (opacityMultiplier < 1.0f && IsOpaque(translucency))
translucency = TranslucencyKind.AlphaBlend;
ResolvedTexture texture = ResolveTexture(
in entity,
meshRef,
batch,
paletteIdentity,
out bool compositePending);
if (compositePending)
allTexturesReady = false;
// Campaign V slice V4t: an unassigned slot is the "no texture yet"
// case a zero handle used to signal. It is a real sentinel
// (GpuTextureSlot.Unassigned == ACDREAM_TEXTURE_NONE), not the
// default value, so nothing here can silently resolve to slot 0.
if (!texture.Slot.IsAssigned) continue;
GpuTextureSlot texSlot = texture.Slot;
uint texLayer = texture.Layer;
var key = new GroupKey(
batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texSlot, texLayer, translucency, batch.CullMode);
InstanceGroup grp = GetOrCreateInstanceGroup(key);
grp.Matrices.Add(model);
grp.LocalSortCenters.Add(renderData.SortCenter);
grp.SubmissionOrders.Add(_nextInstanceSubmissionOrder++);
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,
texSlot,
restPose,
renderData.SortCenter,
grp,
grp.Registration));
}
return allTexturesReady;
}
private readonly record struct ResolvedTexture(GpuTextureSlot Slot, uint Layer);
private ResolvedTexture ResolveTexture(
in RenderInstanceCandidate entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.LocalEntityId,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
WorldEntity entity,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending) =>
ResolveTexture(
entity.Id,
entity.PaletteOverride,
meshRef,
batch,
paletteIdentity,
out compositePending);
private ResolvedTexture ResolveTexture(
uint localEntityId,
PaletteOverride? paletteOverride,
MeshRef meshRef,
ObjectRenderBatch batch,
PaletteCompositeIdentity paletteIdentity,
out bool compositePending)
{
compositePending = false;
uint surfaceId = batch.Key.SurfaceId;
if (surfaceId == 0 || surfaceId == 0xFFFFFFFF)
return default;
uint overrideOrigTex = 0;
bool hasOrigTexOverride = meshRef.SurfaceOverrides is not null
&& meshRef.SurfaceOverrides.TryGetValue(surfaceId, out overrideOrigTex)
&& overrideOrigTex != 0;
uint? origTexOverride = hasOrigTexOverride ? overrideOrigTex : (uint?)null;
bool sourceIsPaletteIndexed = paletteOverride is not null
&& _textures.IsPaletteIndexed(surfaceId, origTexOverride);
WbTextureResolutionKind resolution = WbTextureResolutionPolicy.Select(
hasOrigTexOverride,
paletteOverride is not null,
sourceIsPaletteIndexed);
switch (resolution)
{
case WbTextureResolutionKind.PaletteComposite:
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithPaletteOverrideBindless(
localEntityId,
surfaceId,
origTexOverride,
paletteOverride!,
paletteIdentity);
compositePending = !texture.IsResolved;
return new ResolvedTexture(texture.Slot, texture.Layer);
}
case WbTextureResolutionKind.OriginalTextureOverride:
{
BindlessTextureLocation texture =
_textures.GetOrUploadWithOrigTextureOverrideBindless(
localEntityId,
surfaceId,
overrideOrigTex);
compositePending = !texture.IsResolved;
return new ResolvedTexture(texture.Slot, texture.Layer);
}
case WbTextureResolutionKind.SharedAtlas:
return new ResolvedTexture(
batch.TextureSlot,
checked((uint)batch.TextureIndex));
default:
throw new ArgumentOutOfRangeException(nameof(resolution));
}
}
private static void WriteMatrix(float[] buf, int offset, in Matrix4x4 m)
{
buf[offset + 0] = m.M11; buf[offset + 1] = m.M12; buf[offset + 2] = m.M13; buf[offset + 3] = m.M14;
buf[offset + 4] = m.M21; buf[offset + 5] = m.M22; buf[offset + 6] = m.M23; buf[offset + 7] = m.M24;
buf[offset + 8] = m.M31; buf[offset + 9] = m.M32; buf[offset + 10] = m.M33; buf[offset + 11] = m.M34;
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
}
/// <summary>
/// Entity-set membership test. Phase U.1 (2026-05-30): with the
/// two-pipe partition deleted, the sole <see cref="EntitySet.All"/>
/// member matches every entity. Retained as a seam for the unified
/// pass to re-introduce partitioning.
/// </summary>
private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true;
internal static bool EntityPassesVisibleCellGate(
WorldEntity entity,
HashSet<uint>? visibleCellIds,
EntitySet set)
{
// No cell filter (outdoor root, or a bucket drawn unfiltered like live-dynamics / outdoor
// scenery) ⇒ every entity passes; clip-slot routing (ResolveEntitySlot) does the gating.
if (visibleCellIds is null)
return true;
// A cell-membership filter is active. An interior static passes iff its cell is visible.
if (entity.ParentCellId.HasValue)
return visibleCellIds.Contains(entity.ParentCellId.Value);
// ParentCellId == null (outdoor scenery / building shell): NOT a member of any interior cell,
// so it does NOT pass a cell-membership filter (R1: the bleed fix — was an unconditional
// `return true`). When such entities must draw (through the doorway), the caller passes
// visibleCellIds: null and relies on ResolveEntitySlot's OutsideView routing instead.
return false;
}
// Phase U.1 (2026-05-30): the shell-scoped sets (IndoorPass / BuildingShells)
// were deleted with the two-pipe machinery. EntitySet.All is never shell-scoped.
private static bool IsShellScopedSet(EntitySet set) => false;
public void Dispose()
{
if (_disposed || _disposing) return;
_disposing = true;
try
{
if (_disposeResources is null)
{
// Campaign V slice V11: the RHI arm is the only arm — its
// pipelines route their physical free through the device's
// retirement queue, so the release ledger is always empty.
var releases = new List<(string Name, Action Release)>();
DisposeRhiResources();
_disposeResources = new RetryableResourceReleaseLedger(releases);
}
ResourceReleaseAttempt attempt = _disposeResources.Advance();
if (!_disposeResources.IsComplete)
{
throw attempt.ToException(
"One or more entity renderer resources could not be released.");
}
CompleteDispose();
_disposeResources = null;
_disposed = true;
if (attempt.HasFailures)
{
throw attempt.ToException(
"Entity renderer resources released with exceptional committed outcomes.");
}
}
finally
{
_disposing = false;
}
}
private void CompleteDispose()
{
_dynamicFrameStarted = false;
}
// ── Public types + helpers for BuildIndirectArrays (Task 9) ─────────────
//
// These are public so the pure-CPU unit tests in AcDream.Core.Tests can
// exercise BuildIndirectArrays without needing a GL context.
/// <summary>
/// Stride in bytes of <c>DrawElementsIndirectCommand</c> in the indirect buffer.
/// 5 × <c>uint</c> = 20 bytes. Tests and callers reference this symbolically
/// rather than hard-coding <c>20</c> so a layout change produces a compile error.
/// </summary>
public const int DrawCommandStride = 20; // sizeof(DrawElementsIndirectCommand): 5 × uint
/// <summary>
/// Public view of the per-group inputs to <see cref="BuildIndirectArrays"/> — used in tests.
/// Campaign V slice V2: <c>TextureIndex</c> is a slot into the binding=9
/// handle table (was a raw 64-bit bindless <c>TextureHandle</c>).
/// </summary>
public readonly record struct IndirectGroupInput(
int IndexCount,
uint FirstIndex,
int BaseVertex,
int InstanceCount,
int FirstInstance,
uint TextureIndex,
uint TextureLayer,
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);
/// <summary>
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
/// Tests verify the layout. Same field shape as the private BatchData.
/// </summary>
[StructLayout(LayoutKind.Sequential, Pack = 4)]
public struct BatchDataPublic
{
public uint TextureIndex;
public uint Reserved;
public uint TextureLayer;
public uint Flags;
}
/// <summary>Result of <see cref="BuildIndirectArrays"/>.</summary>
public readonly record struct IndirectLayoutResult(
int OpaqueCount,
int TransparentCount,
int TransparentByteOffset);
/// <summary>
/// Lays out the indirect commands + parallel BatchData array contiguously:
/// opaque section first (caller sorts before calling), transparent section second.
/// Pure CPU, no GL state. Caller passes pre-sized scratch arrays.
/// </summary>
/// <remarks>
/// Classification: Opaque + ClipMap → opaque pass (ClipMap uses discard, not
/// blending). Everything else (AlphaBlend, Additive, InvAlpha) → transparent pass.
/// </remarks>
public static IndirectLayoutResult BuildIndirectArrays(
IReadOnlyList<IndirectGroupInput> groups,
DrawElementsIndirectCommand[] indirectScratch,
BatchDataPublic[] batchScratch,
CullMode[]? cullScratch = null)
{
int opaqueCount = 0;
int transparentCount = 0;
foreach (var g in groups)
{
if (IsOpaque(g.Translucency)) opaqueCount++;
else transparentCount++;
}
int oi = 0; // opaque write cursor (fills [0..opaqueCount))
int ti = opaqueCount; // transparent write cursor (fills [opaqueCount..end))
foreach (var g in groups)
{
var dec = new DrawElementsIndirectCommand
{
Count = (uint)g.IndexCount,
InstanceCount = (uint)g.InstanceCount,
FirstIndex = g.FirstIndex,
BaseVertex = g.BaseVertex,
BaseInstance = (uint)g.FirstInstance,
};
var bd = new BatchDataPublic
{
TextureIndex = g.TextureIndex,
Reserved = 0,
TextureLayer = g.TextureLayer,
Flags = 0,
};
if (IsOpaque(g.Translucency))
{
indirectScratch[oi] = dec;
batchScratch[oi] = bd;
if (cullScratch is not null) cullScratch[oi] = g.CullMode;
oi++;
}
else
{
indirectScratch[ti] = dec;
batchScratch[ti] = bd;
if (cullScratch is not null) cullScratch[ti] = g.CullMode;
ti++;
}
}
return new IndirectLayoutResult(opaqueCount, transparentCount, opaqueCount * DrawCommandStride);
}
/// <summary>
/// Public test shim for <see cref="IsOpaque"/>. Locks in the N.5 Decision 2
/// translucency partition: Opaque + ClipMap → opaque indirect; AlphaBlend +
/// Additive + InvAlpha → transparent indirect.
/// </summary>
public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t);
private static bool IsOpaque(TranslucencyKind t)
=> t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap;
// ────────────────────────────────────────────────────────────────────────
/// <summary>
/// Thin wrapper around an instance's rate-limit dictionary + frame
/// counter, passed into the static <see cref="WalkEntitiesInto"/>
/// overload so it can emit rate-limited probe lines without access
/// to instance fields. Null = probes disabled (test-friendly overload).
/// </summary>
internal sealed class IndoorProbeState
{
private readonly Dictionary<ulong, int> _lastFrame;
private readonly int _currentFrame;
private const int RateLimit = IndoorProbeRateLimitFrames;
internal IndoorProbeState(Dictionary<ulong, int> lastFrame, int currentFrame)
{
_lastFrame = lastFrame;
_currentFrame = currentFrame;
}
/// <summary>
/// Returns true at most once per <see cref="IndoorProbeRateLimitFrames"/>
/// frames per <paramref name="cellId"/>. Side-effect: stamps the frame
/// number into the dictionary on success.
/// </summary>
internal bool ShouldEmit(ulong cellId)
{
if (!_lastFrame.TryGetValue(cellId, out int last)
|| _currentFrame - last >= RateLimit)
{
_lastFrame[cellId] = _currentFrame;
return true;
}
return false;
}
}
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;
// Campaign V slice V4t: the device texture-table slot (was a raw 64-bit
// ARB_bindless_texture handle, and a uint TextureHandle in N.4).
public AcDream.App.Rendering.Gpu.GpuTextureSlot TextureSlot =
AcDream.App.Rendering.Gpu.GpuTextureSlot.Unassigned;
public uint TextureLayer; // Layer in either the pooled composite array or WB shared atlas.
public TranslucencyKind Translucency;
public CullMode CullMode;
public int FirstInstance; // offset into the shared instance VBO (in instances, not bytes)
public int InstanceCount;
public float SortDistance; // squared distance from camera to first instance, for opaque sort
public readonly List<Matrix4x4> Matrices = new();
// Retail CPhysicsPart::CYpt uses the transformed GfxObj sort center,
// not the entity origin. Parallel to Matrices so delayed-alpha
// submissions retain the exact per-part key after material grouping.
public readonly List<Vector3> LocalSortCenters = new();
// Retail CShadowPart::insertion_sort (0x006B5130) is stable for equal
// CYpt. Material groups erase authored entity/part/batch traversal
// unless that order is retained explicitly. SubmissionOrders[i] is
// the draw-local append ordinal for Matrices[i].
public readonly List<int> SubmissionOrders = new();
// Phase U.4: per-instance clip-slot index, parallel to Matrices (Slots[i]
// is the binding=2 CellClip slot for the instance whose matrix is
// Matrices[i]). At layout time the dispatcher writes Slots[i] into
// _clipSlotData at the same cursor it writes Matrices[i] into _instanceData,
// so the binding=3 instanceClipSlot[] tracks the binding=0 instance.
public readonly List<uint> Slots = new();
// 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<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
// for outdoor objects (gets the sun). Written into _indoorData at the same
// cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0.
public readonly List<uint> IndoorFlags = new();
// #188: per-instance opacity multiplier, parallel to Matrices.
// Opacities[i] is 1.0=unmodified, or <1.0 while a TransparentPartHook
// fade is in flight for the instance whose matrix is Matrices[i]. At
// layout time the dispatcher writes Opacities[i] into _alphaData at
// the same cursor, so the binding=7 instanceAlpha[] tracks binding=0.
public readonly List<float> Opacities = new();
// Retail SmartBox click lighting, parallel to Matrices. Each vec2 is
// (luminosity, diffuse) and is uploaded to binding=8.
public readonly List<Vector2> SelectionLighting = new();
/// <summary>
/// Resets every per-instance parallel list for a new frame. These lists are
/// appended in lockstep (one entry per drawn instance) during group build, so
/// they MUST all be cleared together each frame. Keeping the reset in one
/// method stops a newly-added parallel list from silently drifting out of the
/// frame lifecycle — which is exactly the #193 OOM: #188 added
/// <see cref="Opacities"/> alongside the others but left it out of the old
/// inline clear loop, so it grew one float per instance per frame forever and
/// leaked ~1 GB/min of LOH <c>float[]</c> as its backing array doubled.
/// </summary>
public void ClearPerInstanceData()
{
Matrices.Clear();
LocalSortCenters.Clear();
SubmissionOrders.Clear();
Slots.Clear();
LightSets.Clear();
IndoorFlags.Clear();
Opacities.Clear();
SelectionLighting.Clear();
}
public void ReleasePerInstanceStorage()
{
ClearPerInstanceData();
Matrices.TrimExcess();
LocalSortCenters.TrimExcess();
SubmissionOrders.TrimExcess();
Slots.TrimExcess();
LightSets.TrimExcess();
IndoorFlags.TrimExcess();
Opacities.TrimExcess();
SelectionLighting.TrimExcess();
}
}
}