Replace scheduler-quantized software sleeps with a reusable Windows high-resolution deadline timer, expose pacing in the frame profiler, and make shutdown wake every persistent mesh worker without losing the shared signal. Preserve retail alpha order while using a stable radix, skip duplicate deferred-alpha SSBO packing, pack light sets, cache static selection descriptors, and retire historical material groups at the whole-frame boundary. The fixed dense-Caul sample improved from roughly 9-12 ms CPU to 5.3-6.2 ms without reducing visual quality. Release build succeeds with zero warnings and all 6,300 tests pass with five intentional skips. Three independent retail, architecture, and adversarial reviews are clean; the post-review connected route remains pending because local ACE is offline. Co-authored-by: OpenAI Codex <codex@openai.com>
3811 lines
174 KiB
C#
3811 lines
174 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
using System.Runtime.InteropServices;
|
||
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;
|
||
using Silk.NET.OpenGL;
|
||
|
||
namespace AcDream.App.Rendering.Wb;
|
||
|
||
/// <summary>
|
||
/// Draws entities using WB's <see cref="ObjectRenderData"/> (a single global
|
||
/// VAO/VBO/IBO under modern rendering) with acdream's <see cref="TextureCache"/>
|
||
/// for bindless texture resolution and <see cref="AcSurfaceMetadataTable"/> for
|
||
/// translucency classification.
|
||
///
|
||
/// <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
|
||
/// 64-bit resident handles 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>GL strategy (N.5 — mandatory):</b> <c>glMultiDrawElementsIndirect</c> with SSBOs
|
||
/// and <c>GL_ARB_bindless_texture</c> + <c>GL_ARB_shader_draw_parameters</c>.
|
||
/// All visible (entity, batch) pairs are bucketed by <see cref="GroupKey"/>;
|
||
/// each group becomes one <c>DrawElementsIndirectCommand</c>. Three GPU buffers
|
||
/// are uploaded per frame: instance matrices (SSBO binding 0), per-group batch
|
||
/// metadata/texture handles (SSBO 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> (bindless + <c>gl_DrawIDARB</c> /
|
||
/// <c>gl_BaseInstanceARB</c>). Missing bindless/draw-parameters throws
|
||
/// <see cref="NotSupportedException"/> at startup — there is no legacy fallback.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Modern rendering assumption:</b> WB's <c>_useModernRendering</c> path (GL
|
||
/// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses
|
||
/// <c>FirstIndex</c> + <c>BaseVertex</c> per batch. The dispatcher honors those
|
||
/// offsets inside each <c>DrawElementsIndirectCommand</c> via
|
||
/// <c>glMultiDrawElementsIndirect</c>.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed unsafe 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 GL _gl;
|
||
private readonly Shader _shader;
|
||
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 BindlessSupport _bindless;
|
||
|
||
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; }
|
||
internal const int MaximumCompositeWarmupEntitiesPerFrame = 128;
|
||
private readonly Queue<WorldEntity> _compositeWarmupQueue = new();
|
||
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();
|
||
_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,
|
||
_compositeWarmupSourceGeneration,
|
||
entities,
|
||
entityGeneration,
|
||
destinationCell,
|
||
radius))
|
||
{
|
||
RebuildCompositeWarmupQueue(
|
||
entities,
|
||
entityGeneration,
|
||
destinationCell,
|
||
radius);
|
||
}
|
||
if (CompositeTexturesReady)
|
||
return;
|
||
|
||
int scanEnd = Math.Min(
|
||
entities.Count,
|
||
_compositeWarmupScanIndex + MaximumCompositeWarmupEntitiesPerFrame);
|
||
for (; _compositeWarmupScanIndex < scanEnd; _compositeWarmupScanIndex++)
|
||
{
|
||
WorldEntity entity = entities[_compositeWarmupScanIndex];
|
||
if (IsCompositeWarmupCandidate(entity, destinationCell, radius))
|
||
_compositeWarmupQueue.Enqueue(entity);
|
||
}
|
||
_compositeWarmupScanComplete = _compositeWarmupScanIndex == entities.Count;
|
||
|
||
int candidatesThisPass = Math.Min(
|
||
_compositeWarmupQueue.Count,
|
||
MaximumCompositeWarmupEntitiesPerFrame);
|
||
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;
|
||
}
|
||
|
||
internal static bool RequiresCompositeWarmupRebuild(
|
||
IReadOnlyList<WorldEntity>? currentSource,
|
||
uint currentDestinationCell,
|
||
int currentRadius,
|
||
ulong currentGeneration,
|
||
IReadOnlyList<WorldEntity> nextSource,
|
||
ulong nextGeneration,
|
||
uint nextDestinationCell,
|
||
int nextRadius) =>
|
||
!ReferenceEquals(currentSource, nextSource)
|
||
|| currentGeneration != nextGeneration
|
||
|| currentDestinationCell != nextDestinationCell
|
||
|| currentRadius != nextRadius;
|
||
|
||
private void RebuildCompositeWarmupQueue(
|
||
IReadOnlyList<WorldEntity> entities,
|
||
ulong entityGeneration,
|
||
uint destinationCell,
|
||
int radius)
|
||
{
|
||
_compositeWarmupQueue.Clear();
|
||
_compositeWarmupSource = entities;
|
||
_compositeWarmupSourceGeneration = entityGeneration;
|
||
_compositeWarmupDestinationCell = destinationCell;
|
||
_compositeWarmupRadius = radius;
|
||
_compositeWarmupScanIndex = 0;
|
||
_compositeWarmupScanComplete = entities.Count == 0;
|
||
LastCompositeWarmupPendingCount = entities.Count;
|
||
CompositeTexturesReady = _compositeWarmupScanComplete;
|
||
}
|
||
|
||
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="GameWindow.ReapplyQualityPreset"/>.
|
||
/// </summary>
|
||
public bool AlphaToCoverage { get; set; } = true;
|
||
|
||
// SSBO buffer ids
|
||
private uint _instanceSsbo;
|
||
private uint _batchSsbo;
|
||
private uint _indirectBuffer;
|
||
private int _instanceSsboCapacityBytes;
|
||
private int _batchSsboCapacityBytes;
|
||
private int _indirectBufferCapacityBytes;
|
||
|
||
// Phase U.3: per-instance clip-slot SSBO (binding=3), parallel to
|
||
// _instanceSsbo. 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 _clipSlotSsbo;
|
||
private int _clipSlotSsboCapacityBytes;
|
||
private uint[] _clipSlotData = new uint[256];
|
||
|
||
// Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two
|
||
// SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot
|
||
// lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4)
|
||
// holds the per-frame point-light snapshot (LightManager.PointSnapshot);
|
||
// _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per
|
||
// instance INTO it (-1 = unused), laid out parallel to _instanceSsbo.
|
||
private uint _globalLightsSsbo;
|
||
private uint _instLightSetSsbo;
|
||
private int _globalLightsSsboCapacityBytes;
|
||
private int _instLightSetSsboCapacityBytes;
|
||
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 _instanceSsbo. 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 / _clipSlotSsbo.
|
||
private uint _instIndoorSsbo;
|
||
private int _instIndoorSsboCapacityBytes;
|
||
private uint[] _indoorData = new uint[256];
|
||
|
||
// #188: per-instance opacity multiplier (binding=7), one float per
|
||
// instance, parallel to _instanceSsbo. 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 / _instIndoorSsbo, one binding higher.
|
||
private uint _instAlphaSsbo;
|
||
private int _instAlphaSsboCapacityBytes;
|
||
private float[] _alphaData = new float[256];
|
||
// Retail SmartBox click confirmation: per-instance CMaterial luminosity /
|
||
// diffuse replacement (binding=8), parallel to the transform buffer.
|
||
private uint _instSelectionLightingSsbo;
|
||
private int _instSelectionLightingSsboCapacityBytes;
|
||
|
||
private sealed class DynamicBufferSet
|
||
{
|
||
public uint InstanceSsbo;
|
||
public uint BatchSsbo;
|
||
public uint IndirectBuffer;
|
||
public uint ClipSlotSsbo;
|
||
public uint GlobalLightsSsbo;
|
||
public uint InstanceLightSetSsbo;
|
||
public uint InstanceIndoorSsbo;
|
||
public uint InstanceAlphaSsbo;
|
||
public uint InstanceSelectionLightingSsbo;
|
||
public int InstanceCapacityBytes;
|
||
public int BatchCapacityBytes;
|
||
public int IndirectCapacityBytes;
|
||
public int ClipSlotCapacityBytes;
|
||
public int GlobalLightsCapacityBytes;
|
||
public int InstanceLightSetCapacityBytes;
|
||
public int InstanceIndoorCapacityBytes;
|
||
public int InstanceAlphaCapacityBytes;
|
||
public int InstanceSelectionLightingCapacityBytes;
|
||
}
|
||
|
||
private readonly List<DynamicBufferSet>[] _dynamicBufferSetsByFrame =
|
||
[[], [], []];
|
||
private int _dynamicFrameSlot;
|
||
private int _dynamicBufferSetCursor;
|
||
private bool _dynamicFrameStarted;
|
||
private DynamicBufferSet? _activeDynamicBufferSet;
|
||
|
||
internal int DynamicBufferSetCount =>
|
||
_dynamicBufferSetsByFrame.Sum(frameSets => frameSets.Count);
|
||
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), owned by the
|
||
// GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0
|
||
// (not yet wired), we bind our OWN fallback no-clip region buffer below so the
|
||
// shader never reads an unbound SSBO. The fallback holds exactly slot 0
|
||
// (count 0 = pass-all), matching ClipFrame.NoClip's slot 0.
|
||
private uint _sharedClipRegionSsbo;
|
||
private uint _fallbackClipRegionSsbo;
|
||
|
||
// 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;
|
||
|
||
// std430 layout: ulong TextureHandle (uvec2) at offset 0, uint TextureLayer
|
||
// at offset 8, uint Flags at offset 12. Total 16 bytes.
|
||
// Pack=8 (not 4) because std430's uvec2 requires 8-byte alignment — Pack=4
|
||
// works today by accident (TextureHandle is the first field, so offset 0 is
|
||
// always 8-byte aligned), but adding a 4-byte field before TextureHandle
|
||
// without bumping Pack would silently misalign the GPU struct.
|
||
[StructLayout(LayoutKind.Sequential, Pack = 8)]
|
||
private struct BatchData
|
||
{
|
||
public ulong TextureHandle; // bindless handle (uvec2 in GLSL)
|
||
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._deferredAlpha.Clear();
|
||
}
|
||
|
||
// 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<DeferredAlphaInstance> _deferredAlpha = new(128);
|
||
private TranslucencyKind[] _deferredAlphaKinds = new TranslucencyKind[128];
|
||
private Matrix4x4 _deferredAlphaViewProjection;
|
||
// 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();
|
||
|
||
// 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.
|
||
private readonly System.Diagnostics.Stopwatch _cpuStopwatch = new();
|
||
private readonly long[] _cpuSamples = new long[256]; // microseconds
|
||
private int _cpuSampleCursor;
|
||
// GPU timing uses a ring of 3 query-pair slots so the read of frame N-3's
|
||
// result lands when the GPU has finished (~50ms after issue on a typical
|
||
// 60fps frame). Ring of 3 is the vendor-neutral choice: NVIDIA drivers with
|
||
// triple-buffering+vsync can queue ~3 frames ahead, AMD typically 1-2,
|
||
// Intel iGPUs vary. ResultAvailable is the safety guard if the GPU is
|
||
// still working when we try to read.
|
||
private const int GpuQueryRingDepth = 3;
|
||
private readonly uint[] _gpuQueryOpaque = new uint[GpuQueryRingDepth];
|
||
private readonly uint[] _gpuQueryTransparent = new uint[GpuQueryRingDepth];
|
||
// #125: a glGenQueries name does not become a QUERY OBJECT until its first
|
||
// glBeginQuery — GetQueryObject on a never-begun name is GL_INVALID_OPERATION.
|
||
// The N.6 ring assumed ONE Draw per frame with both passes always non-empty;
|
||
// the pview pipeline issues MANY small Draws per frame (landscape slices,
|
||
// per-cell buckets, dynamics), where zero-draw passes routinely skip
|
||
// BeginQuery. Under ACDREAM_WB_DIAG=1 the slot read then queued an
|
||
// InvalidOperation EVERY frame — silently, until WB's diligent texture-path
|
||
// glGetError checks ate the stale errors and treated their own successful
|
||
// uploads as failures ([wb-error] + sticky drop) and ProcessDirtyUpdates'
|
||
// check threw (process death; tower-wbdiag3.log). Track which slots were
|
||
// actually begun and only read those.
|
||
private readonly bool[] _gpuQueryOpaqueBegun = new bool[GpuQueryRingDepth];
|
||
private readonly bool[] _gpuQueryTransparentBegun = new bool[GpuQueryRingDepth];
|
||
private int _gpuQueryFrameIndex;
|
||
private readonly long[] _gpuSamples = new long[256]; // microseconds
|
||
private int _gpuSampleCursor;
|
||
private bool _gpuQueriesInitialized;
|
||
|
||
// Constructor accessibility is internal because EntityClassificationCache
|
||
// is internal — a public ctor with an internal-typed parameter would be
|
||
// an inconsistent-accessibility error. The dispatcher is constructed
|
||
// exclusively from GameWindow (same assembly), so internal is fine.
|
||
internal WbDrawDispatcher(
|
||
GL gl,
|
||
Shader shader,
|
||
TextureCache textures,
|
||
WbMeshAdapter meshAdapter,
|
||
EntitySpawnAdapter entitySpawnAdapter,
|
||
BindlessSupport bindless,
|
||
EntityClassificationCache classificationCache,
|
||
AcDream.Core.Rendering.TranslucencyFadeManager translucencyFades,
|
||
IRetailSelectionRenderSink? selectionSink = null,
|
||
RetailAlphaQueue? alphaQueue = null)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(gl);
|
||
ArgumentNullException.ThrowIfNull(shader);
|
||
ArgumentNullException.ThrowIfNull(textures);
|
||
ArgumentNullException.ThrowIfNull(meshAdapter);
|
||
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
|
||
ArgumentNullException.ThrowIfNull(classificationCache);
|
||
ArgumentNullException.ThrowIfNull(translucencyFades);
|
||
|
||
_gl = gl;
|
||
_shader = shader;
|
||
_textures = textures;
|
||
_meshAdapter = meshAdapter;
|
||
_entitySpawnAdapter = entitySpawnAdapter;
|
||
_cache = classificationCache;
|
||
_translucencyFades = translucencyFades;
|
||
_selectionSink = selectionSink;
|
||
_selectionLighting = selectionSink as IRetailSelectionLightingSource;
|
||
_alphaQueue = alphaQueue;
|
||
_alphaSource = new AlphaDrawSource(this);
|
||
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Selects the fence-protected frame slot and resets its draw-call cursor.
|
||
/// Every Draw/alpha preparation in one frame receives a distinct buffer
|
||
/// set, so later per-cell submissions cannot overwrite an earlier draw's
|
||
/// still-pending SSBO and indirect-command data.
|
||
/// </summary>
|
||
public void BeginFrame(int frameSlot)
|
||
{
|
||
if ((uint)frameSlot >= (uint)_dynamicBufferSetsByFrame.Length)
|
||
throw new ArgumentOutOfRangeException(nameof(frameSlot));
|
||
if (_groupFrame == long.MaxValue)
|
||
throw new InvalidOperationException("Instance-group frame identity was exhausted.");
|
||
|
||
_groupFrame++;
|
||
PruneInstanceGroupsUnusedBeforeFrame(
|
||
_groups,
|
||
_retiredGroupKeys,
|
||
_groupFrame - 1);
|
||
_dynamicFrameSlot = frameSlot;
|
||
_dynamicBufferSetCursor = 0;
|
||
_dynamicFrameStarted = true;
|
||
_activeDynamicBufferSet = null;
|
||
}
|
||
|
||
/// <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. The
|
||
/// dispatcher re-binds it to binding=2 immediately before each MDI so a
|
||
/// consumer that touched binding=2 in between can't leave it pointing
|
||
/// elsewhere. Pass 0 to fall back to the internal no-clip region buffer.
|
||
/// </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 <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;
|
||
|
||
/// <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(WorldEntity entity, uint landblockId, bool culled)
|
||
{
|
||
var targets = RenderingDiagnostics.DumpEntitySourceIds;
|
||
if (targets.Count == 0 || !targets.Contains(entity.SourceGfxObjOrSetupId)) return;
|
||
|
||
var refs = entity.MeshRefs;
|
||
int zeroT = 0;
|
||
float tzMin = float.MaxValue, tzMax = float.MinValue;
|
||
for (int i = 0; i < refs.Count; i++)
|
||
{
|
||
var t = refs[i].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 = (refs.Count, 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.SourceGfxObjOrSetupId:X8} " +
|
||
$"lb=0x{landblockId:X8} cell=0x{(entity.ParentCellId ?? 0u):X8} " +
|
||
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) scale={entity.Scale:F2} " +
|
||
$"meshRefs={refs.Count} tZero={zeroT} tZ=[{tzMin:F2}..{tzMax:F2}] cache={cacheStr} culled={culled}");
|
||
|
||
if (first)
|
||
{
|
||
for (int i = 0; i < refs.Count; i++)
|
||
{
|
||
var mr = refs[i];
|
||
var t = mr.PartTransform.Translation;
|
||
bool loaded = _meshAdapter.TryGetRenderData(mr.GfxObjId) is not null;
|
||
Console.WriteLine(
|
||
$"[dump-entity] part[{i: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)
|
||
{
|
||
_shader.Use();
|
||
_selectionLighting?.TickLighting();
|
||
_indoorProbeFrameCounter++;
|
||
var vp = camera.View * camera.Projection;
|
||
_shader.SetMatrix4("uViewProjection", vp);
|
||
// A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set
|
||
// explicitly (shared GL uniform; EnvCellRenderer sets it to 1).
|
||
_shader.SetInt("uLightingMode", 0);
|
||
// #176 stripe-hunt isolation (ACDREAM_LIGHT_DEBUG) — throwaway diagnostic.
|
||
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
|
||
|
||
// #128 self-heal: fresh re-request dedup per Draw pass.
|
||
_missRequested.Clear();
|
||
|
||
bool diag = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal);
|
||
|
||
if (diag && !_gpuQueriesInitialized)
|
||
{
|
||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||
{
|
||
_gpuQueryOpaque[i] = _gl.GenQuery();
|
||
_gpuQueryTransparent[i] = _gl.GenQuery();
|
||
}
|
||
_gpuQueriesInitialized = true;
|
||
}
|
||
|
||
// Always run the CPU stopwatch — cheap; only logged under diag.
|
||
_cpuStopwatch.Restart();
|
||
|
||
// Camera world-space position for front-to-back sort (perf #2). The view
|
||
// matrix is the inverse of the camera's world transform, so the world
|
||
// translation lives in the inverse's translation row.
|
||
Vector3 camPos = Vector3.Zero;
|
||
if (Matrix4x4.Invert(camera.View, out var invView))
|
||
camPos = invView.Translation;
|
||
|
||
// ── 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.
|
||
foreach (InstanceGroup group in _groups.Values)
|
||
group.ClearPerInstanceData();
|
||
|
||
var metaTable = _meshAdapter.MetadataTable;
|
||
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);
|
||
|
||
// 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 (var (entity, partIdx, landblockId) in _walkScratch)
|
||
{
|
||
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(entity, landblockId);
|
||
|
||
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.ParentCellId,
|
||
_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, 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(entity, cacheLb, _currentEntityCulled);
|
||
|
||
// #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.ParentCellId 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;
|
||
|
||
var entityWorld =
|
||
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||
Matrix4x4.CreateTranslation(entity.Position);
|
||
|
||
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
|
||
|
||
// 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)
|
||
{
|
||
var firstMeshRef = entity.MeshRefs[partIdx];
|
||
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.
|
||
var meshRef = entity.MeshRefs[partIdx];
|
||
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, partGfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose, opacityMultiplier, collector))
|
||
currentEntityIncomplete = true;
|
||
_selectionSink?.AddVisiblePart(
|
||
entity,
|
||
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
|
||
(uint)partGfxObjId,
|
||
model);
|
||
selectionCollector?.Add(new CachedSelectionPart(
|
||
unchecked((partIdx << 16) | (setupPartIndex & 0xFFFF)),
|
||
(uint)partGfxObjId,
|
||
restPose));
|
||
drewAny = true;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// #188: a bare (non-Setup) GfxObj entity has exactly one part —
|
||
// retail's CPartArray for such an object is a single-entry array,
|
||
// so TransparentPartHook.PartIndex for it is always 0.
|
||
float opacityMultiplier = 1.0f;
|
||
bool fullyInvisible = false;
|
||
if (_translucencyFades.TryGetCurrentValue(entity.Id, 0u, out float translucencyValue))
|
||
{
|
||
if (translucencyValue >= 1.0f) fullyInvisible = true;
|
||
else opacityMultiplier = 1f - translucencyValue;
|
||
}
|
||
|
||
if (!fullyInvisible)
|
||
{
|
||
var model = meshRef.PartTransform * entityWorld;
|
||
if (!ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, paletteIdentity, metaTable, restPose: meshRef.PartTransform, opacityMultiplier: opacityMultiplier, collector: collector))
|
||
currentEntityIncomplete = true;
|
||
_selectionSink?.AddVisiblePart(
|
||
entity,
|
||
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 anyVao / totalInstances early-outs so an all-culled frame still
|
||
// reports (inst=0).
|
||
if (RenderingDiagnostics.ProbeClipRouteEnabled && _clipRoutingActive)
|
||
EmitClipRouteDispatchProbe(probeCulledEntities);
|
||
|
||
// Nothing visible — skip the GL pass entirely.
|
||
if (anyVao == 0)
|
||
{
|
||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||
_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.Values,
|
||
deferTransparent,
|
||
camPos,
|
||
_opaqueDraws,
|
||
_translucentDraws);
|
||
int totalInstances = instanceCounts.VisibleInstances;
|
||
int immediateInstances = instanceCounts.ImmediateInstances;
|
||
if (totalInstances == 0)
|
||
{
|
||
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
|
||
_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;
|
||
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
|
||
{
|
||
TextureHandle = _batchPublicScratch[i].TextureHandle,
|
||
TextureLayer = _batchPublicScratch[i].TextureLayer,
|
||
Flags = _batchPublicScratch[i].Flags,
|
||
};
|
||
}
|
||
_opaqueDrawCount = layout.OpaqueCount;
|
||
_transparentDrawCount = layout.TransparentCount;
|
||
_transparentByteOffset = layout.TransparentByteOffset;
|
||
LastDrawStats = new DrawStats(
|
||
set,
|
||
walkResult.EntitiesWalked,
|
||
_walkScratch.Count,
|
||
totalInstances,
|
||
totalDraws,
|
||
cullRuns,
|
||
_opaqueDrawCount,
|
||
_transparentDrawCount,
|
||
totalTriangles);
|
||
|
||
// ── Phase 5: upload four buffers ────────────────────────────────────
|
||
ActivateNextDynamicBufferSet();
|
||
fixed (float* ip = _instanceData)
|
||
UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes,
|
||
ip, immediateInstances * 16 * sizeof(float));
|
||
|
||
fixed (BatchData* bp = _batchData)
|
||
UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes,
|
||
bp, totalDraws * sizeof(BatchData));
|
||
|
||
// Phase U.4: per-instance clip-slot buffer (binding=3), one uint per
|
||
// instance, laid out parallel to _instanceData in Phase 3's group loop so
|
||
// instanceClipSlot[instanceIndex] tracks Instances[instanceIndex]. On the
|
||
// U.3 / outdoor path every entry is 0 ⇒ slot 0 ⇒ no-clip (identical to
|
||
// U.3); under indoor routing it holds the per-instance slot from
|
||
// ResolveEntitySlot. No clear here — Phase 3 wrote exactly immediateInstances
|
||
// entries; only [0..immediateInstances) is uploaded, so any stale tail is
|
||
// never read by the shader.
|
||
fixed (uint* sp = _clipSlotData)
|
||
UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes,
|
||
sp, immediateInstances * sizeof(uint));
|
||
|
||
// #142: per-instance indoor flag buffer (binding=6), one uint per instance,
|
||
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
|
||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||
fixed (uint* dp = _indoorData)
|
||
UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes,
|
||
dp, immediateInstances * sizeof(uint));
|
||
|
||
// #188: per-instance opacity buffer (binding=7), one float per instance,
|
||
// laid out parallel to _instanceData in Phase 3. Only [0..immediateInstances)
|
||
// is uploaded — stale tail never read (same guarantee as clip-slot above).
|
||
fixed (float* ap = _alphaData)
|
||
UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes,
|
||
ap, immediateInstances * sizeof(float));
|
||
|
||
// SmartBox click lighting: x=luminosity, y=diffuse. mesh_modern.vert
|
||
// reads this only for the object path (uLightingMode=0), so EnvCell's
|
||
// independent mode-1 renderer does not consume this binding.
|
||
fixed (Vector2* hp = _selectionLightingData)
|
||
UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes,
|
||
hp, immediateInstances * sizeof(float) * 2);
|
||
|
||
// Fix B: global point-light buffer (binding=4) + per-instance light-set
|
||
// buffer (binding=5). The global buffer is this frame's PointSnapshot; the
|
||
// per-instance buffer holds 8 int indices into it per instance, laid out
|
||
// parallel to _instanceData in Phase 3. Both bound with ≥1 element so the
|
||
// shader never reads an unbound SSBO on a no-lights frame.
|
||
UploadGlobalLights();
|
||
fixed (int* lp = _lightSetData)
|
||
UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes,
|
||
lp, immediateInstances * LightManager.MaxLightsPerObject * sizeof(int));
|
||
|
||
fixed (DrawElementsIndirectCommand* cp = _indirectCommands)
|
||
{
|
||
UploadDynamicBuffer(
|
||
BufferTargetARB.DrawIndirectBuffer,
|
||
_indirectBuffer,
|
||
ref _indirectBufferCapacityBytes,
|
||
cp,
|
||
totalDraws * sizeof(DrawElementsIndirectCommand));
|
||
}
|
||
|
||
PersistActiveDynamicBufferCapacities();
|
||
|
||
// Phase U.3: bind the SHARED per-cell clip-region SSBO (binding=2). The
|
||
// GameWindow-level ClipFrame already uploaded + bound it this frame; we
|
||
// re-bind defensively in case another consumer touched binding=2 since.
|
||
// When no shared id is set (0), bind our own no-clip fallback so the
|
||
// shader never reads an unbound SSBO at binding=2.
|
||
BindClipRegionBinding2();
|
||
|
||
// ── Phase 6: bind global VAO once ───────────────────────────────────
|
||
_gl.BindVertexArray(anyVao);
|
||
|
||
if (string.Equals(Environment.GetEnvironmentVariable("ACDREAM_NO_CULL"), "1", StringComparison.Ordinal))
|
||
_gl.Disable(EnableCap.CullFace);
|
||
|
||
// GPU timing: compute this frame's ring slot. We read frame N-3's
|
||
// result (the oldest data in the ring) before overwriting it with
|
||
// frame N's queries. Hoisted to function scope so both the opaque
|
||
// and transparent passes below can reference gpuQuerySlot. See spec
|
||
// §3 Q1/Q2 + §4 in
|
||
// docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
|
||
int gpuQuerySlot = _gpuQueryFrameIndex % GpuQueryRingDepth;
|
||
// diag is part of the gate so the read/issue/increment trio stays
|
||
// symmetric — without it, toggling ACDREAM_WB_DIAG mid-session would
|
||
// freeze the frame counter (gated by diag below) while the read kept
|
||
// re-reading the same slot, producing duplicate stale samples.
|
||
if (diag && _gpuQueriesInitialized && _gpuQueryFrameIndex >= GpuQueryRingDepth)
|
||
{
|
||
// #125: only read slots whose query objects were actually BEGUN (a
|
||
// zero-draw pass skips BeginQuery; reading a never-begun name is
|
||
// GL_INVALID_OPERATION). A pass that never ran contributes 0 ns.
|
||
ulong opaqueNs = 0, transNs = 0;
|
||
bool anyRead = false, allAvailable = true;
|
||
if (_gpuQueryOpaqueBegun[gpuQuerySlot])
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availO);
|
||
if (availO != 0)
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryOpaque[gpuQuerySlot], QueryObjectParameterName.Result, out opaqueNs);
|
||
anyRead = true;
|
||
}
|
||
else allAvailable = false;
|
||
}
|
||
if (_gpuQueryTransparentBegun[gpuQuerySlot])
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.ResultAvailable, out int availT);
|
||
if (availT != 0)
|
||
{
|
||
_gl.GetQueryObject(_gpuQueryTransparent[gpuQuerySlot], QueryObjectParameterName.Result, out transNs);
|
||
anyRead = true;
|
||
}
|
||
else allAvailable = false;
|
||
}
|
||
// If a begun query isn't available yet the sample is dropped
|
||
// silently. MedianMicros computes over the non-zero subset, so
|
||
// dropped samples don't poison the median.
|
||
if (anyRead && allAvailable)
|
||
{
|
||
long gpuUs = (long)((opaqueNs + transNs) / 1000UL);
|
||
_gpuSamples[_gpuSampleCursor] = gpuUs;
|
||
_gpuSampleCursor = (_gpuSampleCursor + 1) % _gpuSamples.Length;
|
||
}
|
||
}
|
||
|
||
// ── Phase 7: opaque pass ─────────────────────────────────────────────
|
||
if (_opaqueDrawCount > 0)
|
||
{
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.DepthMask(true);
|
||
// A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask
|
||
// from the alpha written by mesh_modern.frag so foliage edges are
|
||
// smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches.
|
||
// A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets
|
||
// (no MSAA) skip the unnecessary GL state change.
|
||
if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage);
|
||
_shader.SetInt("uRenderPass", 0);
|
||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): opaque section of
|
||
// Batches[] starts at index 0. See uDrawIDOffset comment in
|
||
// mesh_modern.vert for why this is needed.
|
||
_shader.SetInt("uDrawIDOffset", 0);
|
||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||
if (diag && _gpuQueriesInitialized)
|
||
{
|
||
_gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
|
||
_gpuQueryOpaqueBegun[gpuQuerySlot] = true; // #125
|
||
}
|
||
DrawIndirectRange(0, _opaqueDrawCount);
|
||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
|
||
}
|
||
|
||
// ── Phase 8: transparent pass ────────────────────────────────────────
|
||
if (_transparentDrawCount > 0)
|
||
{
|
||
_gl.Enable(EnableCap.Blend);
|
||
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
|
||
_gl.DepthMask(false);
|
||
// Phase Post-A.5 (ISSUE #52, 2026-05-10): transparent section of
|
||
// Batches[] starts at index _opaqueDrawCount. Without this offset,
|
||
// each transparent draw reads BatchData[0..transparentCount) — the
|
||
// OPAQUE section — and the lifestone crystal's apparent texture
|
||
// flickers to whatever opaque batch sorted first that frame. See
|
||
// uDrawIDOffset comment in mesh_modern.vert.
|
||
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
|
||
// Closed-shell translucent meshes still need culling, but the
|
||
// cull side must come from each dat batch just like the opaque
|
||
// section. BuildIndirectArrays preserves CullMode in _drawCullModes.
|
||
_gl.FrontFace(FrontFaceDirection.CW);
|
||
_shader.SetInt("uRenderPass", 1);
|
||
if (diag && _gpuQueriesInitialized)
|
||
{
|
||
_gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent[gpuQuerySlot]);
|
||
_gpuQueryTransparentBegun[gpuQuerySlot] = true; // #125
|
||
}
|
||
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
|
||
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
|
||
_gl.DepthMask(true);
|
||
_gl.Disable(EnableCap.Blend);
|
||
}
|
||
|
||
_gl.Disable(EnableCap.CullFace);
|
||
_gl.BindVertexArray(0);
|
||
|
||
_cpuStopwatch.Stop();
|
||
|
||
if (diag)
|
||
{
|
||
long cpuUs = _cpuStopwatch.ElapsedTicks * 1_000_000L / System.Diagnostics.Stopwatch.Frequency;
|
||
_cpuSamples[_cpuSampleCursor] = cpuUs;
|
||
_cpuSampleCursor = (_cpuSampleCursor + 1) % _cpuSamples.Length;
|
||
|
||
// GPU sample read happens BEFORE issuing the next frame's queries
|
||
// (see step 1.3 above). Increment the frame counter here so the
|
||
// next call computes a fresh slot.
|
||
if (_gpuQueriesInitialized) _gpuQueryFrameIndex++;
|
||
|
||
_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,
|
||
WorldEntity entity,
|
||
Matrix4x4 entityWorld)
|
||
{
|
||
foreach (CachedSelectionPart part in cachedEntry.SelectionParts)
|
||
{
|
||
_selectionSink!.AddVisiblePart(
|
||
entity,
|
||
part.PartIndex,
|
||
part.GfxObjId,
|
||
part.RestPose * entityWorld);
|
||
}
|
||
}
|
||
|
||
private static IndirectGroupInput ToInput(InstanceGroup g) => new(
|
||
IndexCount: g.IndexCount,
|
||
FirstIndex: g.FirstIndex,
|
||
BaseVertex: g.BaseVertex,
|
||
InstanceCount: g.InstanceCount,
|
||
FirstInstance: g.FirstInstance,
|
||
TextureHandle: g.BindlessTextureHandle,
|
||
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.BindlessTextureHandle,
|
||
g.TextureLayer,
|
||
g.Translucency,
|
||
g.CullMode);
|
||
|
||
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.");
|
||
|
||
foreach (InstanceGroup group in _translucentDraws)
|
||
{
|
||
GroupKey key = ToKey(group);
|
||
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);
|
||
InstanceLightSet lights = group.LightSets[i];
|
||
|
||
int token = _deferredAlpha.Count;
|
||
_deferredAlpha.Add(new DeferredAlphaInstance(
|
||
key,
|
||
model,
|
||
group.Slots[i],
|
||
lights,
|
||
group.IndoorFlags[i],
|
||
group.Opacities[i],
|
||
group.SelectionLighting[i]));
|
||
queue.Submit(_alphaSource, token, viewerDistance);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void PrepareDeferredAlphaDraws(ReadOnlySpan<int> tokens)
|
||
{
|
||
if (tokens.Length == 0)
|
||
return;
|
||
|
||
GlobalMeshBuffer? global = _meshAdapter.MeshManager?.GlobalBuffer;
|
||
if (global is null || global.VAO == 0)
|
||
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
|
||
{
|
||
TextureHandle = key.BindlessTextureHandle,
|
||
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.
|
||
ActivateNextDynamicBufferSet();
|
||
UploadDeferredAlphaBuffers(count);
|
||
PersistActiveDynamicBufferCapacities();
|
||
}
|
||
|
||
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 || global.VAO == 0)
|
||
return;
|
||
|
||
_shader.Use();
|
||
_shader.SetMatrix4("uViewProjection", _deferredAlphaViewProjection);
|
||
_shader.SetInt("uLightingMode", 0);
|
||
_shader.SetInt("uLightDebug", AcDream.Core.Rendering.RenderingDiagnostics.LightDebugMode);
|
||
_shader.SetInt("uRenderPass", 1);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 0, _instanceSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 1, _batchSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 3, _clipSlotSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 4, _globalLightsSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 5, _instLightSetSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 6, _instIndoorSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 7, _instAlphaSsbo);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, 8, _instSelectionLightingSsbo);
|
||
BindClipRegionBinding2();
|
||
_gl.BindVertexArray(global.VAO);
|
||
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
|
||
_gl.Enable(EnableCap.DepthTest);
|
||
_gl.Enable(EnableCap.Blend);
|
||
_gl.DepthMask(false);
|
||
_gl.FrontFace(FrontFaceDirection.CW);
|
||
|
||
int runStart = firstPreparedDraw;
|
||
int preparedEnd = firstPreparedDraw + drawCount;
|
||
while (runStart < preparedEnd)
|
||
{
|
||
TranslucencyKind blend = _deferredAlphaKinds[runStart];
|
||
int runEnd = runStart + 1;
|
||
while (runEnd < preparedEnd && _deferredAlphaKinds[runEnd] == blend)
|
||
runEnd++;
|
||
|
||
ApplyRetailBlend(blend);
|
||
DrawIndirectRange(runStart, runEnd - runStart);
|
||
runStart = runEnd;
|
||
}
|
||
|
||
_gl.DepthMask(true);
|
||
_gl.Disable(EnableCap.Blend);
|
||
_gl.Disable(EnableCap.CullFace);
|
||
_gl.BindVertexArray(0);
|
||
}
|
||
|
||
private void EnsureDeferredAlphaCapacity(int 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 UploadDeferredAlphaBuffers(int count)
|
||
{
|
||
fixed (float* p = _instanceData)
|
||
UploadSsbo(_instanceSsbo, 0, ref _instanceSsboCapacityBytes,
|
||
p, count * 16 * sizeof(float));
|
||
fixed (BatchData* p = _batchData)
|
||
UploadSsbo(_batchSsbo, 1, ref _batchSsboCapacityBytes,
|
||
p, count * sizeof(BatchData));
|
||
fixed (uint* p = _clipSlotData)
|
||
UploadSsbo(_clipSlotSsbo, 3, ref _clipSlotSsboCapacityBytes,
|
||
p, count * sizeof(uint));
|
||
fixed (int* p = _lightSetData)
|
||
UploadSsbo(_instLightSetSsbo, 5, ref _instLightSetSsboCapacityBytes,
|
||
p, count * LightManager.MaxLightsPerObject * sizeof(int));
|
||
fixed (uint* p = _indoorData)
|
||
UploadSsbo(_instIndoorSsbo, 6, ref _instIndoorSsboCapacityBytes,
|
||
p, count * sizeof(uint));
|
||
fixed (float* p = _alphaData)
|
||
UploadSsbo(_instAlphaSsbo, 7, ref _instAlphaSsboCapacityBytes,
|
||
p, count * sizeof(float));
|
||
fixed (Vector2* p = _selectionLightingData)
|
||
UploadSsbo(_instSelectionLightingSsbo, 8, ref _instSelectionLightingSsboCapacityBytes,
|
||
p, count * sizeof(float) * 2);
|
||
UploadGlobalLights();
|
||
|
||
fixed (DrawElementsIndirectCommand* p = _indirectCommands)
|
||
{
|
||
UploadDynamicBuffer(
|
||
BufferTargetARB.DrawIndirectBuffer,
|
||
_indirectBuffer,
|
||
ref _indirectBufferCapacityBytes,
|
||
p,
|
||
count * sizeof(DrawElementsIndirectCommand));
|
||
}
|
||
}
|
||
|
||
private void ApplyRetailBlend(TranslucencyKind blend)
|
||
{
|
||
_gl.BlendFunc(
|
||
blend == TranslucencyKind.InvAlpha
|
||
? BlendingFactor.OneMinusSrcAlpha
|
||
: BlendingFactor.SrcAlpha,
|
||
blend switch
|
||
{
|
||
TranslucencyKind.Additive => BlendingFactor.One,
|
||
TranslucencyKind.InvAlpha => BlendingFactor.SrcAlpha,
|
||
_ => BlendingFactor.OneMinusSrcAlpha,
|
||
});
|
||
}
|
||
|
||
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 unsafe void DrawIndirectRange(int startCommand, int commandCount)
|
||
{
|
||
int end = startCommand + commandCount;
|
||
int command = startCommand;
|
||
while (command < end)
|
||
{
|
||
var cullMode = _drawCullModes[command];
|
||
ApplyCullMode(cullMode);
|
||
|
||
int runCount = 1;
|
||
while (command + runCount < end && _drawCullModes[command + runCount] == cullMode)
|
||
runCount++;
|
||
|
||
// Each glMultiDrawElementsIndirect call restarts gl_DrawID at 0.
|
||
// Because this method splits one logical opaque/transparent pass
|
||
// into CullMode runs, the shader must receive the absolute command
|
||
// index for this run or it will read BatchData[0] again and bind
|
||
// the wrong texture for later runs.
|
||
_shader.SetInt("uDrawIDOffset", command);
|
||
_gl.MultiDrawElementsIndirect(
|
||
PrimitiveType.Triangles,
|
||
DrawElementsType.UnsignedShort,
|
||
(void*)(command * DrawCommandStride),
|
||
(uint)runCount,
|
||
(uint)DrawCommandStride);
|
||
|
||
command += runCount;
|
||
}
|
||
}
|
||
|
||
private void ApplyCullMode(CullMode mode)
|
||
{
|
||
// WB BaseObjectRenderManager.cs:850-866 applies CullMode per MDI group.
|
||
// WB GameScene.cs:843 sets FrontFace(CW) globally; SetCullMode then
|
||
// only chooses front/back culling. Keep the same convention here so
|
||
// splitting MDI commands by CullMode cannot resurrect stale CCW state.
|
||
_gl.FrontFace(FrontFaceDirection.CW);
|
||
switch (mode)
|
||
{
|
||
case CullMode.None:
|
||
_gl.Disable(EnableCap.CullFace);
|
||
break;
|
||
case CullMode.Clockwise:
|
||
_gl.Enable(EnableCap.CullFace);
|
||
_gl.CullFace(TriangleFace.Front);
|
||
break;
|
||
case CullMode.CounterClockwise:
|
||
case CullMode.Landblock:
|
||
_gl.Enable(EnableCap.CullFace);
|
||
_gl.CullFace(TriangleFace.Back);
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void ActivateNextDynamicBufferSet()
|
||
{
|
||
if (!_dynamicFrameStarted)
|
||
throw new InvalidOperationException("BeginFrame must be called before drawing world entities.");
|
||
|
||
List<DynamicBufferSet> slotSets = _dynamicBufferSetsByFrame[_dynamicFrameSlot];
|
||
if (_dynamicBufferSetCursor == slotSets.Count)
|
||
slotSets.Add(CreateDynamicBufferSet());
|
||
|
||
DynamicBufferSet set = slotSets[_dynamicBufferSetCursor++];
|
||
_activeDynamicBufferSet = set;
|
||
_instanceSsbo = set.InstanceSsbo;
|
||
_batchSsbo = set.BatchSsbo;
|
||
_indirectBuffer = set.IndirectBuffer;
|
||
_clipSlotSsbo = set.ClipSlotSsbo;
|
||
_globalLightsSsbo = set.GlobalLightsSsbo;
|
||
_instLightSetSsbo = set.InstanceLightSetSsbo;
|
||
_instIndoorSsbo = set.InstanceIndoorSsbo;
|
||
_instAlphaSsbo = set.InstanceAlphaSsbo;
|
||
_instSelectionLightingSsbo = set.InstanceSelectionLightingSsbo;
|
||
_instanceSsboCapacityBytes = set.InstanceCapacityBytes;
|
||
_batchSsboCapacityBytes = set.BatchCapacityBytes;
|
||
_indirectBufferCapacityBytes = set.IndirectCapacityBytes;
|
||
_clipSlotSsboCapacityBytes = set.ClipSlotCapacityBytes;
|
||
_globalLightsSsboCapacityBytes = set.GlobalLightsCapacityBytes;
|
||
_instLightSetSsboCapacityBytes = set.InstanceLightSetCapacityBytes;
|
||
_instIndoorSsboCapacityBytes = set.InstanceIndoorCapacityBytes;
|
||
_instAlphaSsboCapacityBytes = set.InstanceAlphaCapacityBytes;
|
||
_instSelectionLightingSsboCapacityBytes = set.InstanceSelectionLightingCapacityBytes;
|
||
}
|
||
|
||
private DynamicBufferSet CreateDynamicBufferSet()
|
||
{
|
||
var set = new DynamicBufferSet();
|
||
try
|
||
{
|
||
set.InstanceSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity instance SSBO");
|
||
set.BatchSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity batch SSBO");
|
||
set.IndirectBuffer = TrackedGlResource.CreateBuffer(_gl, "creating entity indirect buffer");
|
||
set.ClipSlotSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity clip-slot SSBO");
|
||
set.GlobalLightsSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity global-light SSBO");
|
||
set.InstanceLightSetSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity light-set SSBO");
|
||
set.InstanceIndoorSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity indoor SSBO");
|
||
set.InstanceAlphaSsbo = TrackedGlResource.CreateBuffer(_gl, "creating entity alpha SSBO");
|
||
set.InstanceSelectionLightingSsbo = TrackedGlResource.CreateBuffer(
|
||
_gl,
|
||
"creating entity selection-lighting SSBO");
|
||
return set;
|
||
}
|
||
catch (Exception creationFailure)
|
||
{
|
||
try { DeleteDynamicBufferSet(set); }
|
||
catch (Exception cleanupFailure)
|
||
{
|
||
throw new AggregateException(
|
||
"Entity dynamic-buffer creation and rollback failed.",
|
||
creationFailure,
|
||
cleanupFailure);
|
||
}
|
||
throw;
|
||
}
|
||
}
|
||
|
||
private void DeleteDynamicBufferSet(DynamicBufferSet set)
|
||
{
|
||
List<Exception>? failures = null;
|
||
void Attempt(uint buffer, int bytes, string name)
|
||
{
|
||
try { TrackedGlResource.DeleteBuffer(_gl, buffer, bytes, $"deleting {name}"); }
|
||
catch (Exception ex) { (failures ??= []).Add(ex); }
|
||
}
|
||
|
||
Attempt(set.InstanceSsbo, set.InstanceCapacityBytes, "entity instance SSBO");
|
||
Attempt(set.BatchSsbo, set.BatchCapacityBytes, "entity batch SSBO");
|
||
Attempt(set.IndirectBuffer, set.IndirectCapacityBytes, "entity indirect buffer");
|
||
Attempt(set.ClipSlotSsbo, set.ClipSlotCapacityBytes, "entity clip-slot SSBO");
|
||
Attempt(set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes, "entity global-light SSBO");
|
||
Attempt(set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes, "entity light-set SSBO");
|
||
Attempt(set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes, "entity indoor SSBO");
|
||
Attempt(set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes, "entity alpha SSBO");
|
||
Attempt(
|
||
set.InstanceSelectionLightingSsbo,
|
||
set.InstanceSelectionLightingCapacityBytes,
|
||
"entity selection-lighting SSBO");
|
||
|
||
if (failures is not null)
|
||
throw new AggregateException("One or more entity dynamic buffers failed to delete.", failures);
|
||
}
|
||
|
||
private void PersistActiveDynamicBufferCapacities()
|
||
{
|
||
DynamicBufferSet set = _activeDynamicBufferSet
|
||
?? throw new InvalidOperationException("No dynamic entity buffer set is active.");
|
||
set.InstanceCapacityBytes = _instanceSsboCapacityBytes;
|
||
set.BatchCapacityBytes = _batchSsboCapacityBytes;
|
||
set.IndirectCapacityBytes = _indirectBufferCapacityBytes;
|
||
set.ClipSlotCapacityBytes = _clipSlotSsboCapacityBytes;
|
||
set.GlobalLightsCapacityBytes = _globalLightsSsboCapacityBytes;
|
||
set.InstanceLightSetCapacityBytes = _instLightSetSsboCapacityBytes;
|
||
set.InstanceIndoorCapacityBytes = _instIndoorSsboCapacityBytes;
|
||
set.InstanceAlphaCapacityBytes = _instAlphaSsboCapacityBytes;
|
||
set.InstanceSelectionLightingCapacityBytes = _instSelectionLightingSsboCapacityBytes;
|
||
}
|
||
|
||
private unsafe void UploadSsbo(
|
||
uint ssbo,
|
||
uint binding,
|
||
ref int capacityBytes,
|
||
void* data,
|
||
int byteCount)
|
||
{
|
||
UploadDynamicBuffer(
|
||
BufferTargetARB.ShaderStorageBuffer,
|
||
ssbo,
|
||
ref capacityBytes,
|
||
data,
|
||
byteCount);
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo);
|
||
}
|
||
|
||
private unsafe void UploadDynamicBuffer(
|
||
BufferTargetARB target,
|
||
uint buffer,
|
||
ref int capacityBytes,
|
||
void* data,
|
||
int byteCount)
|
||
{
|
||
if (byteCount < 0)
|
||
throw new ArgumentOutOfRangeException(nameof(byteCount));
|
||
|
||
_gl.BindBuffer(target, buffer);
|
||
// A render bucket can legitimately contain zero batches (for example the outdoor dynamic
|
||
// bucket immediately after auto-entry). Keep the buffer bound for the corresponding SSBO
|
||
// binding, but there is no active prefix to allocate or upload and no draw can read it.
|
||
if (byteCount == 0)
|
||
return;
|
||
|
||
if (capacityBytes < byteCount)
|
||
{
|
||
int grownCapacity = DynamicBufferCapacity.Grow(capacityBytes, byteCount);
|
||
TrackedGlResource.AllocateBufferStorage(
|
||
_gl,
|
||
(GLEnum)target,
|
||
buffer,
|
||
capacityBytes,
|
||
grownCapacity,
|
||
GLEnum.DynamicDraw,
|
||
$"growing entity dynamic buffer {buffer} to {grownCapacity} bytes");
|
||
capacityBytes = grownCapacity;
|
||
}
|
||
|
||
_gl.BufferSubData(target, 0, (nuint)byteCount, data);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Fix B: pack <see cref="_pointSnapshot"/> into the binding=4 global light
|
||
/// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes,
|
||
/// matching mesh_modern.vert's <c>GlobalLight</c>). Always uploads ≥1 element
|
||
/// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is
|
||
/// a zeroed dummy that no instance set references (all sets are -1).
|
||
/// </summary>
|
||
private unsafe void UploadGlobalLights()
|
||
{
|
||
int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData);
|
||
int count = n > 0 ? n : 1; // never zero-size
|
||
// Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats.
|
||
fixed (float* gp = _globalLightData)
|
||
UploadSsbo(_globalLightsSsbo, 4, ref _globalLightsSsboCapacityBytes, gp,
|
||
count * GlobalLightPacker.FloatsPerLight * sizeof(float));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the
|
||
/// shared <see cref="ClipFrame"/> buffer (set via <see cref="SetClipRegionSsbo"/>);
|
||
/// otherwise lazily creates + binds a one-slot no-clip fallback so the shader
|
||
/// never reads an unbound SSBO. The fallback's single slot has count 0
|
||
/// (pass-all), matching <see cref="ClipFrame.NoClip"/>'s slot 0.
|
||
/// </summary>
|
||
private unsafe void BindClipRegionBinding2()
|
||
{
|
||
if (_sharedClipRegionSsbo != 0)
|
||
{
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer,
|
||
ClipFrame.MeshClipSsboBinding, _sharedClipRegionSsbo);
|
||
return;
|
||
}
|
||
|
||
if (_fallbackClipRegionSsbo == 0)
|
||
{
|
||
_fallbackClipRegionSsbo = _gl.GenBuffer();
|
||
// One CellClip slot, all zeros: count 0 ⇒ shader passes every plane.
|
||
var zero = stackalloc byte[ClipFrame.CellClipStrideBytes];
|
||
for (int i = 0; i < ClipFrame.CellClipStrideBytes; i++) zero[i] = 0;
|
||
_gl.BindBuffer(BufferTargetARB.ShaderStorageBuffer, _fallbackClipRegionSsbo);
|
||
_gl.BufferData(BufferTargetARB.ShaderStorageBuffer,
|
||
(nuint)ClipFrame.CellClipStrideBytes, zero, BufferUsageARB.DynamicDraw);
|
||
}
|
||
_gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer,
|
||
ClipFrame.MeshClipSsboBinding, _fallbackClipRegionSsbo);
|
||
}
|
||
|
||
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,
|
||
BindlessTextureHandle = key.BindlessTextureHandle,
|
||
TextureLayer = key.TextureLayer,
|
||
Translucency = key.Translucency,
|
||
CullMode = key.CullMode,
|
||
Registration = _nextGroupRegistration++,
|
||
LastUsedFrame = _groupFrame,
|
||
};
|
||
_groups.Add(key, group);
|
||
return group;
|
||
}
|
||
|
||
private void AppendInstanceToGroup(
|
||
InstanceGroup grp,
|
||
Matrix4x4 model,
|
||
Vector3 localSortCenter)
|
||
{
|
||
grp.LastUsedFrame = _groupFrame;
|
||
grp.Matrices.Add(model);
|
||
grp.LocalSortCenters.Add(localSortCenter);
|
||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||
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(WorldEntity 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 ?? 0u,
|
||
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(WorldEntity 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.ParentCellId);
|
||
|
||
_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(...))
|
||
|
||
if (entity.AabbDirty) entity.RefreshAabb();
|
||
Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f;
|
||
float radius = (entity.AabbMax - entity.AabbMin).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 & 0xFFFF) >= 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,
|
||
ulong gfxObjId,
|
||
Matrix4x4 model,
|
||
WorldEntity entity,
|
||
MeshRef meshRef,
|
||
PaletteCompositeIdentity paletteIdentity,
|
||
AcSurfaceMetadataTable metaTable,
|
||
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;
|
||
if (metaTable.TryLookup(gfxObjId, batchIdx, out var meta))
|
||
{
|
||
translucency = meta.Translucency;
|
||
}
|
||
else
|
||
{
|
||
translucency = batch.IsAdditive ? TranslucencyKind.Additive
|
||
: batch.IsTransparent ? TranslucencyKind.AlphaBlend
|
||
: TranslucencyKind.Opaque;
|
||
}
|
||
|
||
// #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(
|
||
entity,
|
||
meshRef,
|
||
batch,
|
||
paletteIdentity,
|
||
out bool compositePending);
|
||
if (compositePending)
|
||
allTexturesReady = false;
|
||
if (texture.Handle == 0) continue;
|
||
ulong texHandle = texture.Handle;
|
||
uint texLayer = texture.Layer;
|
||
|
||
var key = new GroupKey(
|
||
batch.FirstIndex, (int)batch.BaseVertex,
|
||
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
|
||
|
||
InstanceGroup grp = GetOrCreateInstanceGroup(key);
|
||
grp.Matrices.Add(model);
|
||
grp.LocalSortCenters.Add(renderData.SortCenter);
|
||
grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices
|
||
AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices
|
||
grp.Opacities.Add(opacityMultiplier); // #188 — parallel to Matrices
|
||
grp.SelectionLighting.Add(_currentEntitySelectionLighting);
|
||
collector?.Add(new CachedBatch(
|
||
key,
|
||
texHandle,
|
||
restPose,
|
||
renderData.SortCenter,
|
||
grp,
|
||
grp.Registration));
|
||
}
|
||
return allTexturesReady;
|
||
}
|
||
|
||
private readonly record struct ResolvedTexture(ulong Handle, uint Layer);
|
||
|
||
private ResolvedTexture ResolveTexture(
|
||
WorldEntity entity,
|
||
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 = entity.PaletteOverride is not null
|
||
&& _textures.IsPaletteIndexed(surfaceId, origTexOverride);
|
||
WbTextureResolutionKind resolution = WbTextureResolutionPolicy.Select(
|
||
hasOrigTexOverride,
|
||
entity.PaletteOverride is not null,
|
||
sourceIsPaletteIndexed);
|
||
|
||
switch (resolution)
|
||
{
|
||
case WbTextureResolutionKind.PaletteComposite:
|
||
{
|
||
BindlessTextureLocation texture =
|
||
_textures.GetOrUploadWithPaletteOverrideBindless(
|
||
entity.Id,
|
||
surfaceId,
|
||
origTexOverride,
|
||
entity.PaletteOverride!,
|
||
paletteIdentity);
|
||
compositePending = texture.Handle == 0;
|
||
return new ResolvedTexture(texture.Handle, texture.Layer);
|
||
}
|
||
|
||
case WbTextureResolutionKind.OriginalTextureOverride:
|
||
{
|
||
BindlessTextureLocation texture =
|
||
_textures.GetOrUploadWithOrigTextureOverrideBindless(
|
||
entity.Id,
|
||
surfaceId,
|
||
overrideOrigTex);
|
||
compositePending = texture.Handle == 0;
|
||
return new ResolvedTexture(texture.Handle, texture.Layer);
|
||
}
|
||
|
||
case WbTextureResolutionKind.SharedAtlas:
|
||
return new ResolvedTexture(
|
||
batch.BindlessTextureHandle,
|
||
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)
|
||
{
|
||
var releases = new List<(string Name, Action Release)>();
|
||
BuildDisposeReleases(releases);
|
||
_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 BuildDisposeReleases(List<(string Name, Action Release)> releases)
|
||
{
|
||
for (int frame = 0; frame < _dynamicBufferSetsByFrame.Length; frame++)
|
||
{
|
||
List<DynamicBufferSet> frameSets = _dynamicBufferSetsByFrame[frame];
|
||
for (int index = 0; index < frameSets.Count; index++)
|
||
AddDynamicBufferSetReleases(releases, frameSets[index], frame, index);
|
||
}
|
||
|
||
AddRawGlRelease(
|
||
releases,
|
||
_fallbackClipRegionSsbo,
|
||
"fallback-clip-region",
|
||
"deleting entity fallback clip SSBO",
|
||
_gl.DeleteBuffer);
|
||
|
||
if (!_gpuQueriesInitialized)
|
||
return;
|
||
for (int i = 0; i < GpuQueryRingDepth; i++)
|
||
{
|
||
AddRawGlRelease(
|
||
releases,
|
||
_gpuQueryOpaque[i],
|
||
$"opaque-query-{i}",
|
||
"deleting entity opaque timing query",
|
||
_gl.DeleteQuery);
|
||
AddRawGlRelease(
|
||
releases,
|
||
_gpuQueryTransparent[i],
|
||
$"transparent-query-{i}",
|
||
"deleting entity transparent timing query",
|
||
_gl.DeleteQuery);
|
||
}
|
||
}
|
||
|
||
private void AddDynamicBufferSetReleases(
|
||
List<(string Name, Action Release)> releases,
|
||
DynamicBufferSet set,
|
||
int frame,
|
||
int index)
|
||
{
|
||
AddTrackedBufferRelease(releases, set.InstanceSsbo, set.InstanceCapacityBytes,
|
||
$"dynamic-{frame}-{index}-instances", "deleting entity instance SSBO");
|
||
AddTrackedBufferRelease(releases, set.BatchSsbo, set.BatchCapacityBytes,
|
||
$"dynamic-{frame}-{index}-batches", "deleting entity batch SSBO");
|
||
AddTrackedBufferRelease(releases, set.IndirectBuffer, set.IndirectCapacityBytes,
|
||
$"dynamic-{frame}-{index}-indirect", "deleting entity indirect buffer");
|
||
AddTrackedBufferRelease(releases, set.ClipSlotSsbo, set.ClipSlotCapacityBytes,
|
||
$"dynamic-{frame}-{index}-clip-slots", "deleting entity clip-slot SSBO");
|
||
AddTrackedBufferRelease(releases, set.GlobalLightsSsbo, set.GlobalLightsCapacityBytes,
|
||
$"dynamic-{frame}-{index}-global-lights", "deleting entity global-light SSBO");
|
||
AddTrackedBufferRelease(releases, set.InstanceLightSetSsbo, set.InstanceLightSetCapacityBytes,
|
||
$"dynamic-{frame}-{index}-light-sets", "deleting entity light-set SSBO");
|
||
AddTrackedBufferRelease(releases, set.InstanceIndoorSsbo, set.InstanceIndoorCapacityBytes,
|
||
$"dynamic-{frame}-{index}-indoor", "deleting entity indoor SSBO");
|
||
AddTrackedBufferRelease(releases, set.InstanceAlphaSsbo, set.InstanceAlphaCapacityBytes,
|
||
$"dynamic-{frame}-{index}-alpha", "deleting entity alpha SSBO");
|
||
AddTrackedBufferRelease(
|
||
releases,
|
||
set.InstanceSelectionLightingSsbo,
|
||
set.InstanceSelectionLightingCapacityBytes,
|
||
$"dynamic-{frame}-{index}-selection-lighting",
|
||
"deleting entity selection-lighting SSBO");
|
||
}
|
||
|
||
private void AddTrackedBufferRelease(
|
||
List<(string Name, Action Release)> releases,
|
||
uint buffer,
|
||
long capacityBytes,
|
||
string name,
|
||
string context)
|
||
{
|
||
if (buffer == 0)
|
||
return;
|
||
RetryableGpuResourceRelease release =
|
||
TrackedGlResource.CreateRetryableBufferDeletion(
|
||
_gl,
|
||
buffer,
|
||
capacityBytes,
|
||
context);
|
||
releases.Add((name, release.Run));
|
||
}
|
||
|
||
private void AddRawGlRelease(
|
||
List<(string Name, Action Release)> releases,
|
||
uint resource,
|
||
string name,
|
||
string context,
|
||
Action<uint> delete)
|
||
{
|
||
if (resource == 0)
|
||
return;
|
||
var release = new RetryableGpuResourceRelease(
|
||
() => GLHelpers.ThrowOnResourceError(_gl, $"{context} (precondition)"),
|
||
() =>
|
||
{
|
||
delete(resource);
|
||
GLHelpers.ThrowOnResourceError(_gl, context);
|
||
});
|
||
releases.Add((name, release.Run));
|
||
}
|
||
|
||
private void CompleteDispose()
|
||
{
|
||
foreach (List<DynamicBufferSet> frameSets in _dynamicBufferSetsByFrame)
|
||
frameSets.Clear();
|
||
_activeDynamicBufferSet = null;
|
||
_dynamicFrameStarted = false;
|
||
_instanceSsbo = 0;
|
||
_batchSsbo = 0;
|
||
_indirectBuffer = 0;
|
||
_clipSlotSsbo = 0;
|
||
_globalLightsSsbo = 0;
|
||
_instLightSetSsbo = 0;
|
||
_instIndoorSsbo = 0;
|
||
_instAlphaSsbo = 0;
|
||
_instSelectionLightingSsbo = 0;
|
||
_fallbackClipRegionSsbo = 0;
|
||
Array.Clear(_gpuQueryOpaque);
|
||
Array.Clear(_gpuQueryTransparent);
|
||
_gpuQueriesInitialized = 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.
|
||
/// </summary>
|
||
public readonly record struct IndirectGroupInput(
|
||
int IndexCount,
|
||
uint FirstIndex,
|
||
int BaseVertex,
|
||
int InstanceCount,
|
||
int FirstInstance,
|
||
ulong TextureHandle,
|
||
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 = 8)]
|
||
public struct BatchDataPublic
|
||
{
|
||
public ulong TextureHandle;
|
||
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
|
||
{
|
||
TextureHandle = g.TextureHandle,
|
||
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;
|
||
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
|
||
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();
|
||
|
||
// 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();
|
||
Slots.Clear();
|
||
LightSets.Clear();
|
||
IndoorFlags.Clear();
|
||
Opacities.Clear();
|
||
SelectionLighting.Clear();
|
||
}
|
||
|
||
public void ReleasePerInstanceStorage()
|
||
{
|
||
ClearPerInstanceData();
|
||
Matrices.TrimExcess();
|
||
LocalSortCenters.TrimExcess();
|
||
Slots.TrimExcess();
|
||
LightSets.TrimExcess();
|
||
IndoorFlags.TrimExcess();
|
||
Opacities.TrimExcess();
|
||
SelectionLighting.TrimExcess();
|
||
}
|
||
}
|
||
}
|