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; /// /// Draws entities using WB's (a single global /// VAO/VBO/IBO under modern rendering) with acdream's /// for bindless texture resolution and for /// translucency classification. /// /// /// Atlas-tier entities (ServerGuid == 0): mesh data comes from WB's /// via . /// Shared textures reuse each batch's WB atlas handle and layer, returning /// 64-bit resident handles stored in the per-group SSBO. /// /// /// /// Per-instance-tier entities (ServerGuid != 0): mesh data also from /// WB. Native surfaces still reuse the WB atlas; only actual indexed-palette /// and original-texture replacements resolve through owner-scoped /// composites. is currently /// unused at draw time — GameWindow's spawn path already bakes AnimPartChanges + /// GfxObjDegradeResolver (Issue #47 close-detail mesh) into MeshRefs. /// /// /// /// GL strategy (N.5 — mandatory): glMultiDrawElementsIndirect with SSBOs /// and GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters. /// All visible (entity, batch) pairs are bucketed by ; /// each group becomes one DrawElementsIndirectCommand. 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 /// so ordinary GfxObj parts and particles share /// retail's stable far-to-near stream; sealed off-screen consumers retain the /// immediate transparent MDI path. /// /// /// /// Shader: mesh_modern (bindless + gl_DrawIDARB / /// gl_BaseInstanceARB). Missing bindless/draw-parameters throws /// at startup — there is no legacy fallback. /// /// /// /// Modern rendering assumption: WB's _useModernRendering path (GL /// 4.3 + bindless) puts every mesh in a single shared VAO/VBO/IBO and uses /// FirstIndex + BaseVertex per batch. The dispatcher honors those /// offsets inside each DrawElementsIndirectCommand via /// glMultiDrawElementsIndirect. /// /// public sealed unsafe class WbDrawDispatcher : IDisposable { /// /// 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. is the sole remaining /// member; the unified retail-faithful pass (Phase U) draws every entity in /// one path. The set: parameter is retained on the Draw overloads so /// the unified pass can re-introduce partitioning later without re-threading /// the call sites. /// public enum EntitySet { /// Every entity walked, gated only by the existing /// ParentCellId ∈ visibleCellIds filter. 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 _compositeWarmupQueue = new(); private IReadOnlyList? _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; } /// /// 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. /// public void PrepareCompositeTextures( IReadOnlyList 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? currentSource, uint currentDestinationCell, int currentRadius, ulong currentGeneration, IReadOnlyList nextSource, ulong nextGeneration, uint nextDestinationCell, int nextRadius) => !ReferenceEquals(currentSource, nextSource) || currentGeneration != nextGeneration || currentDestinationCell != nextDestinationCell || currentRadius != nextRadius; private void RebuildCompositeWarmupQueue( IReadOnlyList 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); /// /// 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 . /// 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[] _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? _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? _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 _groupInputScratch = new(256); private readonly List _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 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 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 _groups = new(); private readonly List _opaqueDraws = new(); private readonly List _translucentDraws = new(); private readonly List _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 _populateScratch = new(); private readonly List _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; /// /// 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. /// private readonly Dictionary _lastIndoorProbeFrame = new(); private int _indoorProbeFrameCounter; private const int IndoorProbeRateLimitFrames = 30; /// /// Returns true at most once per /// frames per cellId. Caller must already have checked that an indoor /// probe flag is enabled. /// 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 _missRequested = new(); private readonly HashSet _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 _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 _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)); } /// /// 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. /// 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; } /// /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot /// (). Call once per frame BEFORE /// . The dispatcher uploads it to binding=4 and selects each /// object's up-to-8 lights from it () /// by the object's bounding sphere — camera-independent. Pass null/empty to /// disable per-object point lights (only ambient + sun render). /// public void SetSceneLights(IReadOnlyList? pointSnapshot) => _pointSnapshot = pointSnapshot; /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that 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. /// public void SetClipRegionSsbo(uint sharedClipRegionSsbo) => _sharedClipRegionSsbo = sharedClipRegionSsbo; /// /// Phase U.4: install the per-frame clip-slot routing for an INDOOR root. /// Call once per frame BEFORE when the camera's root cell is /// non-null; the next 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 on outdoor-root frames so the /// dispatcher reverts to the U.3 no-clip-everything behavior. /// /// cellId → CellClip slot. A cell absent from the map /// is NOT visible → its cell-static instances are culled. /// Slot for outdoor scenery / building shells while /// indoors (the OutsideView slot, or 0 for no-clip over-include). /// False ⇒ cull outdoor scenery / shells this frame /// (the OutsideView is empty). public void SetClipRouting(IReadOnlyDictionary cellIdToSlot, int outdoorSlot, bool outdoorVisible) { ArgumentNullException.ThrowIfNull(cellIdToSlot); _clipRoutingActive = true; _cellIdToSlot = cellIdToSlot; _outdoorSlot = outdoorSlot; _outdoorVisible = outdoorVisible; } /// /// 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. /// 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 _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; /// /// Phase U.4: resolve the clip slot for one entity per the slot/gate policy. /// Returns to drop the entity's instances entirely. /// /// Indoor ParentCellId: the cell's slot, or CULL when hidden. /// Outdoor ParentCellId or ParentCellId == null static scenery: the OutsideView slot /// when , else CULL. /// ServerGuid != 0 with ParentCellId == null: CULL while routing is active. /// /// Only called when _clipRoutingActive (indoor root). On the U.3 / outdoor /// path every instance is slot 0 and nothing is culled — see /// , which gates on that flag. /// /// INVARIANT: and the keys of /// MUST live in the same FULL cell-id space /// (lbMask | OtherCellId, e.g. 0xA9B40164). A bare-low-byte /// ParentCellId (e.g. 0x64) 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. /// /// /// internal static + 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 _cellIdToSlot etc. /// /// internal static int ResolveEntitySlot( uint serverGuid, uint? parentCellId, IReadOnlyDictionary 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; } /// /// Phase U.4: the call-site clip-slot decision for one entity, returning the /// (Slot, Culled) pair the per-entity loop body consumes. Wraps /// with the /// 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 ) is bypassed entirely. /// When active, a CULL sentinel maps to (0, culled=true) — the slot value /// is never emitted for a culled entity. /// internal static + pure so the whole policy (including the routing- /// inactive branch) is unit-testable — see WbDrawDispatcherClipSlotTests. /// internal static (uint Slot, bool Culled) ResolveSlotForFrame( bool clipRoutingActive, uint serverGuid, uint? parentCellId, IReadOnlyDictionary? 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; /// /// Entry for per-landblock iteration. /// Mirrors the shape yielded by GpuWorldState.LandblockEntries. /// public readonly record struct LandblockEntry( uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById); /// /// Result of — the list of (entity, meshRef index) /// pairs that passed all visibility filters, plus a diagnostic walk count. /// public struct WalkResult { public int EntitiesWalked; public int BuildingShellAnchorPass; public int BuildingShellAnchorReject; public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw; } /// /// Pure-CPU visibility filter over . /// Separated from so tests can exercise it without GL state. /// /// /// A.5 T17 Change #1: when an LB is frustum-culled AND /// 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 /// directly and look each up in /// entry.AnimatedById (typically <50 animated, up to ~10K total). /// /// /// /// A.5 T18 Change #2: per-entity AABB cull reads from the cached /// / /// (refreshed lazily if ), instead of /// recomputing Position±5 each frame. /// /// /// /// Test-friendly overload that allocates a fresh ToDraw list per call. /// Production code () uses the no-alloc overload below /// with a caller-provided scratch list. /// internal static WalkResult WalkEntities( IEnumerable landblockEntries, FrustumPlanes? frustum, uint? neverCullLandblockId, HashSet? visibleCellIds, HashSet? 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; } /// /// No-alloc overload: clears + populates the caller-provided /// list. 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 's EntitiesWalked field. /// /// /// When is non-null the method emits /// [indoor-cull] lines for cell entities rejected by the /// visibleCellIds or frustum filters, and [indoor-walk] lines for /// cell entities that pass all filters. Rate-limited by /// . Pass (the default) /// to disable all probe emission — used by the test-friendly /// overload. /// /// internal static void WalkEntitiesInto( IEnumerable landblockEntries, FrustumPlanes? frustum, uint? neverCullLandblockId, HashSet? visibleCellIds, HashSet? 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)); } } } /// /// #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. /// RetailPViewRenderer.DrawEntityBucket 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 /// 0x40YYFF00 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 0xXXYYFFFF key format /// the streaming entries and /// 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. /// internal static uint ResolveCacheLandblockHint(WorldEntity entity, uint tupleLandblockId) => entity.ParentCellId is uint pc ? ((pc & 0xFFFF0000u) | 0xFFFFu) : tupleLandblockId; /// /// #119 decisive probe: rate-limited [dump-entity] WALK-REJECT line /// for an ACDREAM_DUMP_ENTITY-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. /// 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}"); } /// /// #119 decisive probe: per-entity state dump at draw time for /// ACDREAM_DUMP_ENTITY-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). /// 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 Entities, IReadOnlyDictionary? AnimatedById)> landblockEntries, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, HashSet? visibleCellIds = null, HashSet? 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 ToEntries( IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? 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(); } } /// /// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only /// entities whose ParentCellId is in , plus /// outdoor-style entities matching the EntitySet partition. Used by /// the indoor render branch to scope rendering to the camera-buildings' /// cells. /// /// 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. /// public void Draw( ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)> landblockEntries, IReadOnlyCollection cellIds, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, HashSet? animatedEntityIds = null, EntitySet set = EntitySet.All) { // Adapt IReadOnlyCollection → HashSet for the existing path. // If the caller already passed a HashSet, avoid re-wrapping. HashSet cellIdSet = cellIds is HashSet hs ? hs : new HashSet(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 groups, bool deferTransparent, Vector3 cameraWorldPosition, List opaque, List 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 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 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? 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); } /// /// Fix B: pack into the binding=4 global light /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, /// matching mesh_modern.vert's GlobalLight). 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). /// 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)); } /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); /// 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 's slot 0. /// 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. /// /// Apply a cache hit's batches into the per-frame group dictionary by /// composing cached.RestPose * entityWorld per batch and routing /// the result through . The delegate /// abstracts over so this helper stays /// GL-free and unit-testable. /// /// /// Matrix multiplication is non-commutative: it MUST be /// RestPose * entityWorld, not the reverse. See /// for the full part-world product. /// internal static void ApplyCacheHit( EntityCacheEntry entry, Matrix4x4 entityWorld, Action 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); } } /// /// 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. /// internal static int PruneInstanceGroupsUnusedBeforeFrame( Dictionary groups, List 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; } /// /// Per-tuple flush check. If is set /// AND differs from , the previous /// entity's accumulated batches are committed to /// and is cleared. Returns the /// updated tracker tuple — pass these back into the field locals in the /// caller's loop. /// /// /// 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. /// internal static (uint? PopulateEntityId, uint PopulateLandblockId) MaybeFlushOnEntityChange( uint? populateEntityId, uint populateLandblockId, uint currentEntityId, EntityClassificationCache cache, List populateScratch, List? 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); } /// /// End-of-loop final flush. The last entity in _walkScratch has /// no next-iteration to trigger , /// so commit its accumulated batches here. No-op when no populate is /// pending (the last entity was animated, or the scratch is empty). /// /// End-of-loop only — does NOT reset the caller's tracker locals /// (intentional, since they go out of scope immediately after). /// /// internal static void FinalFlushPopulate( uint? populateEntityId, uint populateLandblockId, EntityClassificationCache cache, List populateScratch, List? selectionScratch = null) { if (populateEntityId.HasValue && populateScratch.Count > 0) { cache.Populate( populateEntityId.Value, populateLandblockId, populateScratch.ToArray(), selectionScratch?.ToArray()); populateScratch.Clear(); } selectionScratch?.Clear(); } /// /// Instance-side helper used by . Looks up or /// creates an for the given key in /// _groups and appends the per-instance world matrix. /// 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); } /// /// 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 (), so /// a static building's torches stay constant as the viewer moves. Fills /// ; unused slots are -1. On the no-lights /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. /// /// /// 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 /// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage: /// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under /// if (Render::useSunlight == 0), and the outdoor landscape stage runs /// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right /// before LScape::draw which draws buildings/scenery). So a building /// EXTERIOR shell (, /// = 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 (UpdateSunFromSky). See the divergence register /// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. /// /// // #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 _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); } /// /// Retail's useSunlight gate for per-object torch lighting, as a pure /// predicate. An object receives the static wall torches (the indoor /// minimize_object_lighting pass) ONLY when it is parented to an EnvCell /// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100. /// Outdoor objects (building shells with null , /// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures) /// are sun-lit only and return false. Mirrors /// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff /// Render::useSunlight == 0, which is true only in the indoor draw stage. /// internal static bool IndoorObjectReceivesTorches(uint? parentCellId) => parentCellId.HasValue && (parentCellId.Value & 0xFFFFu) >= 0x0100u && (parentCellId.Value & 0xFFFFu) != 0xFFFFu; // 0xFFFF = landblock marker, not an EnvCell → outdoor /// /// Fix B: append the current entity's 8-slot light set to a group's /// , parallel to its Matrices (one /// 8-int block per instance), mirroring grp.Slots.Add. /// 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? 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; } /// /// Entity-set membership test. Phase U.1 (2026-05-30): with the /// two-pipe partition deleted, the sole /// member matches every entity. Retained as a seam for the unified /// pass to re-introduce partitioning. /// private static bool EntityMatchesSet(WorldEntity entity, EntitySet set) => true; internal static bool EntityPassesVisibleCellGate( WorldEntity entity, HashSet? 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 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 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 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. /// /// Stride in bytes of DrawElementsIndirectCommand in the indirect buffer. /// 5 × uint = 20 bytes. Tests and callers reference this symbolically /// rather than hard-coding 20 so a layout change produces a compile error. /// public const int DrawCommandStride = 20; // sizeof(DrawElementsIndirectCommand): 5 × uint /// /// Public view of the per-group inputs to — used in tests. /// 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); /// /// Public mirror of the per-group uploaded to the SSBO. /// Tests verify the layout. Same field shape as the private BatchData. /// [StructLayout(LayoutKind.Sequential, Pack = 8)] public struct BatchDataPublic { public ulong TextureHandle; public uint TextureLayer; public uint Flags; } /// Result of . public readonly record struct IndirectLayoutResult( int OpaqueCount, int TransparentCount, int TransparentByteOffset); /// /// 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. /// /// /// Classification: Opaque + ClipMap → opaque pass (ClipMap uses discard, not /// blending). Everything else (AlphaBlend, Additive, InvAlpha) → transparent pass. /// public static IndirectLayoutResult BuildIndirectArrays( IReadOnlyList 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); } /// /// Public test shim for . Locks in the N.5 Decision 2 /// translucency partition: Opaque + ClipMap → opaque indirect; AlphaBlend + /// Additive + InvAlpha → transparent indirect. /// public static bool IsOpaquePublic(TranslucencyKind t) => IsOpaque(t); private static bool IsOpaque(TranslucencyKind t) => t == TranslucencyKind.Opaque || t == TranslucencyKind.ClipMap; // ──────────────────────────────────────────────────────────────────────── /// /// Thin wrapper around an instance's rate-limit dictionary + frame /// counter, passed into the static /// overload so it can emit rate-limited probe lines without access /// to instance fields. Null = probes disabled (test-friendly overload). /// internal sealed class IndoorProbeState { private readonly Dictionary _lastFrame; private readonly int _currentFrame; private const int RateLimit = IndoorProbeRateLimitFrames; internal IndoorProbeState(Dictionary lastFrame, int currentFrame) { _lastFrame = lastFrame; _currentFrame = currentFrame; } /// /// Returns true at most once per /// frames per . Side-effect: stamps the frame /// number into the dictionary on success. /// 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 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 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 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 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 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 Opacities = new(); // Retail SmartBox click lighting, parallel to Matrices. Each vec2 is // (luminosity, diffuse) and is uploaded to binding=8. public readonly List SelectionLighting = new(); /// /// 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 /// 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 float[] as its backing array doubled. /// 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(); } } }