feat(render): Phase A8 — indoor visibility + streaming fixes batch

Lands the working A8 indoor-rendering and streaming fixes accumulated this
session. User has verified these visually to some degree (e.g. lifestone /
translucent meshes confirmed fine under the FrontFace flip; bridge / wall /
collision regressions confirmed fixed after travel); not every path has been
exhaustively gated. The cellar-flap defect remains OPEN and will be solved
the retail-faithful way via a dedicated brainstorm (see handoff docs).

Rendering core (reviewed, high confidence):
- EnvCellRenderer SSBO stride fix: upload packed Matrix4x4[] (64B) instead of
  the 80B CPU InstanceData struct the shader never expected — fixes the
  transform/texture "explosion" for any draw with >1 instance (cells that
  dedupe to a shared cellGeomId). Real root cause.
- WB-style global FrontFace(CW) + per-batch CullMode carried through the MDI
  layout (GroupKey + BuildIndirectArrays + DrawIndirectRange split into
  same-cull runs with absolute uDrawIDOffset per run).
- EntitySet partitioning (IndoorPass / OutdoorScenery / LiveDynamic) +
  WorldEntity.BuildingShellAnchorCellId so building shells scope to their
  dat-derived building cell instead of rendering everywhere.
- RenderOutsideInAcdream (look into buildings from outside) +
  CollectVisiblePortalBuildings frustum cull of portal bounds.
- Sky-when-inside-building + per-cell audit probe + GL-state probe.

Streaming / perf (test-covered; not independently code-reviewed this session):
- Near/far priority queues so near work wins over far; PromoteToNear carries
  full landblock + mesh data; LandblockEntriesWithoutAnimatedIndex avoids
  rebuilding the animated-lookup dict in the hot draw path. Fixes the
  bridge-not-appearing / missing-walls / broken-collision-after-travel
  regressions and improves post-transition FPS.

Tooling + docs:
- tools/A8CellAudit: offline dat cell/portal/building dumper (portals +
  buildings modes) — reproduces the cellar-flap investigation with no launch.
- docs/research cellar-flap root-cause + option-2 handoff (the didInsideStencil
  double-duty finding + the WB-recursive design decision + brainstorm prompt),
  entity-taxonomy, replan, issue-78 visibility investigation.

Diagnostics retained on purpose: ACDREAM_A8_DIAG_* gates, portal_stencil.vert
provisional pos.w clamp, and the probe families are kept (env-var gated, zero
cost when off) because the pending option-2 cellar-flap brainstorm needs them.
Strip in the option-2 ship commit.

Indoor branch stays behind ACDREAM_A8_INDOOR_BRANCH=1 (default off = pre-A8
visual). Build green; App tests + Core (streaming/dispatcher/loader) tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-29 10:14:50 +02:00
parent e415bb3863
commit 5dc4140c11
38 changed files with 3965 additions and 277 deletions

View file

@ -6,10 +6,9 @@ namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Phase A8 (2026-05-26): a logical building — one or more EnvCells linked
/// via the dat-level <c>LandBlockInfo.Buildings</c> entry. Building shells (cottage
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) render unconditionally
/// when the camera is inside this building's cells. The exit portal polygons
/// are stencil-marked so outdoor visibility leaks through portal silhouettes
/// only.
/// walls, inn walls — <c>IsBuildingShell=true</c> entities) are scoped to this
/// building's cells via their dat-derived anchor. The exit portal polygons are
/// stencil-marked so outdoor visibility leaks through portal silhouettes only.
///
/// <para>Step 5 (cross-building visibility via 3-stencil-bit pipeline) uses
/// the occlusion-query state to skip rendering when the building's portals
@ -39,6 +38,18 @@ public sealed class Building
/// polygon vertices via <see cref="AcDream.App.Rendering.LoadedCell.WorldTransform"/>.</summary>
public required IReadOnlyList<Vector3[]> ExitPortalPolygons { get; init; }
/// <summary>True when <see cref="PortalBounds"/> contains at least one
/// exit-portal vertex. Mirrors WB's <c>BuildingPortalGPU.VertexCount &gt; 0</c>
/// filter before a building participates in outside-in / Step 5 stencil
/// visibility.</summary>
public bool HasPortalBounds { get; init; }
/// <summary>World-space AABB of all exit portal polygons. WB's
/// <c>PortalRenderManager.GetVisibleBuildingPortals</c> frustum-culls this
/// box with near-plane ignored before adding the building to the portal
/// visibility list.</summary>
public WbBoundingBox PortalBounds { get; init; }
// -------------------------------------------------------------------------
// Step 5 occlusion-query state (mutable, per-frame, RR9 scope).
// -------------------------------------------------------------------------

View file

@ -117,6 +117,19 @@ public static class BuildingLoader
}
}
bool hasPortalBounds = false;
var portalMin = new Vector3(float.MaxValue);
var portalMax = new Vector3(float.MinValue);
foreach (var poly in exitPortalPolys)
{
foreach (var v in poly)
{
hasPortalBounds = true;
portalMin = Vector3.Min(portalMin, v);
portalMax = Vector3.Max(portalMax, v);
}
}
// WB PortalService.cs:89: skip buildings with no interior cells.
if (envCellIds.Count == 0) continue;
@ -125,6 +138,10 @@ public static class BuildingLoader
BuildingId = nextId++,
EnvCellIds = envCellIds,
ExitPortalPolygons = exitPortalPolys,
HasPortalBounds = hasPortalBounds,
PortalBounds = hasPortalBounds
? new WbBoundingBox(portalMin, portalMax)
: default,
};
reg.Add(building);

View file

@ -23,7 +23,6 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using DatReaderWriter.Enums;
@ -70,6 +69,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
private int _modernInstanceCapacity = 1024;
private uint _modernBatchBuffer;
private int _modernBatchCapacity = 1024;
// mesh_modern.vert's SSBO InstanceData is only mat4 transform. The CPU
// InstanceData below also carries CellId/Flags for filtering, so upload a
// packed transform array instead of the 80-byte CPU struct.
private Matrix4x4[] _gpuInstanceTransforms = Array.Empty<Matrix4x4>();
// Reusable scratch arrays — avoid per-frame allocation.
// WB BaseObjectRenderManager.cs:58-59: private DrawElementsIndirectCommand[] _commands = Array.Empty<...>()
@ -193,7 +196,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.GenBuffers(1, out _modernInstanceBuffer);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
// Per-batch data SSBO (binding=1)
_gl.GenBuffers(1, out _modernBatchBuffer);
@ -464,11 +467,30 @@ public sealed unsafe class EnvCellRenderer : IDisposable
/// Call once per frame, before <see cref="Render"/>.
/// Source: WB EnvCellRenderManager.cs:247-373 (verbatim).
/// </summary>
public void PrepareRenderBatches(Matrix4x4 viewProjection, Vector3 cameraPosition, HashSet<uint>? filter = null)
public void PrepareRenderBatches(
Matrix4x4 viewProjection,
Vector3 cameraPosition,
HashSet<uint>? filter = null,
int? centerLbX = null,
int? centerLbY = null,
int? renderRadius = null)
{
// WB EnvCellRenderManager.cs:249-250:
if (!_initialized || cameraPosition.Z > 4000) return;
if (filter is { Count: 0 })
{
lock (_renderLock)
{
_poolIndex = 0;
_activeSnapshot = new EnvCellVisibilitySnapshot();
_activeSnapshotGlobalGroups = new Dictionary<ulong, List<InstanceData>>();
_activeSnapshotGlobalGfxObjIds = new List<ulong>();
NeedsPrepare = false;
}
return;
}
// WB EnvCellRenderManager.cs:251-253:
lock (_renderLock) { _poolIndex = 0; }
@ -479,8 +501,19 @@ public sealed unsafe class EnvCellRenderer : IDisposable
// Filter loaded landblocks by GpuReady + Instances non-empty.
var landblocks = new List<EnvCellLandblock>();
foreach (var lb in _landblocks.Values)
{
if (centerLbX.HasValue && centerLbY.HasValue && renderRadius.HasValue)
{
if (Math.Abs(lb.GridX - centerLbX.Value) > renderRadius.Value ||
Math.Abs(lb.GridY - centerLbY.Value) > renderRadius.Value)
{
continue;
}
}
if (lb.GpuReady && lb.Instances.Count > 0)
landblocks.Add(lb);
}
if (landblocks.Count == 0) return;
// WB EnvCellRenderManager.cs:265-267:
@ -815,32 +848,13 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindVertexArray(0);
_currentVao = 0;
// Phase A8 (2026-05-28 visual-gate-#4 follow-up): NO cull-restore
// at exit. The Landblock→None override can leave cull DISABLED
// if the last batch was Landblock — and that's intentional: the
// subsequent `dispatcher.Draw(IndoorPass)` call in
// RenderInsideOutAcdream's Step 3 wants cull-off too, because
// AC's cottage-shell GfxObj parts (the wooden floor planks +
// wall slabs that the player walks on / through) have winding
// that gets back-face-culled by the dispatcher's default
// FrontFace=CCW. Letting cull stay off through IndoorPass
// renders both shell and cell mesh double-sided, so floors are
// visible from above (and inverted-front-facing wall slabs are
// visible from inside the room). Step 4's
// `gl.Enable(EnableCap.CullFace)` (line ~10768) + the cleanup
// block's enable (line ~10870) re-establish cull-back before
// LiveDynamic chars / NPCs / doors render — so those still
// look solid (no see-through head). The static `_currentVao`
// is reset because the next Render call's batch loop needs to
// re-issue BindVertexArray regardless; `_currentCullMode` is
// intentionally left at None so the cache matches actual GL
// state until the next Render call's per-batch SetCullMode
// either confirms or re-sets it.
//
// The retail-faithful long-term move is matching WB's
// glFrontFace(CW) globally (GameScene.cs:843) so cull-back
// selects the correct side for AC's polygon winding without
// double-sided rendering — deferred until a wider audit.
// No cull restore at exit, matching WB's manager pattern: the
// last SetCullMode call reflects actual GL state, and the next
// Render call invalidates `_currentCullMode` before issuing its
// own per-batch state. The Landblock->None override below can
// intentionally leave cull disabled for the following IndoorPass,
// preserving the shipped Gate #5 baseline while deeper evidence is
// gathered.
// Update frame stats for probe emission at the call site.
_lastFrameStats.CellsRendered = filter?.Count ?? snapshot.BatchedByCell.Count;
@ -932,7 +946,7 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_modernInstanceCapacity = Math.Max(_modernInstanceCapacity * 2, uniqueInstanceCount);
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(_modernInstanceCapacity * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
(nuint)(_modernInstanceCapacity * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
}
// WB BaseObjectRenderManager.cs:761-762: grow scratch arrays.
@ -977,12 +991,15 @@ public sealed unsafe class EnvCellRenderer : IDisposable
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernInstanceBuffer);
_gl.BufferData(GLEnum.ShaderStorageBuffer,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), null, GLEnum.DynamicDraw);
var instancesSpan = CollectionsMarshal.AsSpan(allInstances);
fixed (InstanceData* ptr = instancesSpan)
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), null, GLEnum.DynamicDraw);
if (_gpuInstanceTransforms.Length < uniqueInstanceCount)
Array.Resize(ref _gpuInstanceTransforms, Math.Max(_gpuInstanceTransforms.Length * 2, uniqueInstanceCount));
for (int i = 0; i < uniqueInstanceCount; i++)
_gpuInstanceTransforms[i] = allInstances[i].Transform;
fixed (Matrix4x4* ptr = _gpuInstanceTransforms)
{
_gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0,
(nuint)(uniqueInstanceCount * sizeof(InstanceData)), ptr);
(nuint)(uniqueInstanceCount * sizeof(Matrix4x4)), ptr);
}
_gl.BindBuffer(GLEnum.ShaderStorageBuffer, _modernBatchBuffer);
@ -1014,23 +1031,10 @@ public sealed unsafe class EnvCellRenderer : IDisposable
foreach (var group in batchesByCullMode)
{
var cullMode = (CullMode)(group.Key % 4);
// Phase A8 fix (2026-05-28 visual-gate-#3 evidence): override
// CullMode.Landblock to None for cell-mesh batches. WB sets
// glFrontFace(CW) globally (GameScene.cs:843) so its CullMode
// mapping (Landblock→Back) culls the correct side; we set
// glFrontFace(CCW) in WbDrawDispatcher (line 1056) so the
// mapping would cull the OPPOSITE side, hiding cell floors.
// Cell-mesh polys with CullMode.Landblock represent the floor +
// walls + ceiling of a single room — they face different
// directions but share one CullMode value, so a single cull
// setting can't be correct for all of them. The retail-faithful
// approach is double-sided rendering for cell polys (cull off),
// matching what the cull-disable A/B diagnostic empirically
// confirmed (floor visible with cull off in visual-gate-#3).
// CullMode.Landblock is only ever assigned in this codebase by
// PrepareCellStructMeshData (cell polys) — terrain has its own
// renderer that doesn't go through this code path — so this
// override is scoped exactly right.
// Phase A8 visual-gate evidence: cell meshes use CullMode.Landblock
// uniformly, but the room surfaces need to be visible from inside
// under acdream's current global winding state. Render cell polys
// double-sided while the architectural cause is isolated.
if (cullMode == CullMode.Landblock) cullMode = CullMode.None;
if (_currentCullMode != cullMode)
{

View file

@ -1,4 +1,5 @@
using AcDream.Core.Meshing;
using DatReaderWriter.Enums;
namespace AcDream.App.Rendering.Wb;
@ -17,4 +18,5 @@ internal readonly record struct GroupKey(
int IndexCount,
ulong BindlessTextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);

View file

@ -25,9 +25,12 @@ namespace AcDream.App.Rendering.Wb;
/// </para>
///
/// <para>
/// Idempotency: a duplicate load for the same landblock is a no-op on
/// ref-counting (the snapshot is already present). Defensive guard against
/// streaming-controller bugs.
/// Idempotency: repeated notifications for the same landblock only register
/// newly-seen ids. This matters for two-tier streaming: a far-tier terrain
/// load first snapshots an empty entity set, then a later Far-to-Near promotion
/// supplies the actual stabs/buildings. Treating the second notification as a
/// blanket no-op leaves the world-state entity list populated while the WB
/// mesh cache never pins the promoted GfxObj ids.
/// </para>
///
/// <para>
@ -53,18 +56,15 @@ public sealed class LandblockSpawnAdapter
}
/// <summary>
/// Called when a landblock finishes streaming in.
/// Registers a ref-count increment with WB for each unique atlas-tier
/// GfxObj id in the landblock. Duplicate loads for the same landblock id
/// are silently ignored.
/// Called when a landblock finishes streaming in or receives promoted
/// atlas-tier entities. Registers a ref-count increment with WB for each
/// unique atlas-tier GfxObj id that has not already been registered for
/// this landblock.
/// </summary>
public void OnLandblockLoaded(LoadedLandblock landblock)
{
System.ArgumentNullException.ThrowIfNull(landblock);
// Idempotency: already-loaded landblock is a no-op.
if (_idsByLandblock.ContainsKey(landblock.LandblockId)) return;
var unique = new HashSet<ulong>();
foreach (var entity in landblock.Entities)
{
@ -76,8 +76,18 @@ public sealed class LandblockSpawnAdapter
unique.Add((ulong)meshRef.GfxObjId);
}
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
if (!_idsByLandblock.TryGetValue(landblock.LandblockId, out var registered))
{
_idsByLandblock[landblock.LandblockId] = unique;
foreach (var id in unique) _adapter.IncrementRefCount(id);
return;
}
foreach (var id in unique)
{
if (registered.Add(id))
_adapter.IncrementRefCount(id);
}
}
/// <summary>

View file

@ -6,6 +6,7 @@ using AcDream.Core.Meshing;
using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using DatReaderWriter.Enums;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering.Wb;
@ -76,18 +77,22 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// <summary>Cell mesh + cell statics (<see cref="WorldEntity.ParentCellId"/>
/// non-null) PLUS building shell stabs (<see cref="WorldEntity.IsBuildingShell"/>
/// true, regardless of ParentCellId). These render unconditionally
/// when the camera is inside their building — building shells ARE
/// the indoor walls. Live-dynamic (<c>ServerGuid != 0</c>) is
/// excluded; it flows through <see cref="LiveDynamic"/>.</summary>
/// true) whose <see cref="WorldEntity.BuildingShellAnchorCellId"/>
/// belongs to the active building cell set. Live-dynamic
/// (<c>ServerGuid != 0</c>) is excluded; it flows through
/// <see cref="LiveDynamic"/>.</summary>
IndoorPass,
/// <summary>Outdoor scenery stabs (<c>ParentCellId == null</c>,
/// <c>!IsBuildingShell</c>) plus procedurally-generated scenery.
/// Drawn stencil-gated to portal silhouettes when the camera is
/// inside. Live-dynamic excluded.</summary>
/// <summary>Outdoor/top-level stabs (<c>ParentCellId == null</c>),
/// including building shells. Drawn stencil-gated to portal
/// silhouettes when the camera is inside. Live-dynamic excluded.</summary>
OutdoorScenery,
/// <summary>Top-level building shell stabs only, optionally scoped by
/// <see cref="WorldEntity.BuildingShellAnchorCellId"/>. Used for
/// portal depth repair without walking the full outdoor scenery set.</summary>
BuildingShells,
/// <summary>Server-spawned dynamic entities (<c>ServerGuid != 0</c>):
/// player, NPCs, monsters, dropped items, animated and idle doors.
/// Drawn last with stencil disabled so they're depth-tested against
@ -103,6 +108,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
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; }
// 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
@ -132,6 +150,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
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 int _opaqueDrawCount;
private int _transparentDrawCount;
@ -283,6 +302,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public struct WalkResult
{
public int EntitiesWalked;
public int BuildingShellAnchorPass;
public int BuildingShellAnchorReject;
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
}
@ -375,8 +396,15 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) 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));
@ -397,11 +425,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool isCellEntity = indoorProbeState is not null
&& RenderingDiagnostics.IsEnvCellId(cellProbeId);
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
bool shellScoped = IsShellScopedSet(set)
&& entity.IsBuildingShell
&& visibleCellIds is not null;
bool cellInVis = EntityPassesVisibleCellGate(entity, visibleCellIds, set);
if (!cellInVis)
{
if (shellScoped) result.BuildingShellAnchorReject++;
if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
&& indoorProbeState!.ShouldEmit(cellProbeId))
{
@ -412,6 +442,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
}
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.
@ -545,6 +576,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
probeState,
set);
if (set == EntitySet.IndoorPass && RenderingDiagnostics.ProbeVisibilityEnabled)
{
Console.WriteLine(
$"[indoor-shells] anchorPass={walkResult.BuildingShellAnchorPass} " +
$"anchorReject={walkResult.BuildingShellAnchorReject} walked={walkResult.EntitiesWalked}");
}
// 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
@ -855,6 +893,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// 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;
@ -865,6 +904,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var grp in _groups.Values) totalInstances += grp.Matrices.Count;
if (totalInstances == 0)
{
LastDrawStats = new DrawStats(set, walkResult.EntitiesWalked, _walkScratch.Count, 0, 0, 0, 0, 0, 0);
_cpuStopwatch.Stop();
if (diag) MaybeFlushDiag();
return;
@ -905,11 +945,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_translucentDraws.Add(grp);
}
// Front-to-back sort for opaque pass: nearer groups draw first so the
// depth test rejects fragments hidden behind them, reducing fragment
// shader cost from overdraw on dense scenes (Holtburg courtyard,
// Foundry interior).
_opaqueDraws.Sort(static (a, b) => a.SortDistance.CompareTo(b.SortDistance));
// 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.
_opaqueDraws.Sort(CompareOpaqueSubmissionOrder);
_translucentDraws.Sort(CompareTransparentSubmissionOrder);
// ── Phase 4: build IndirectGroupInput list (opaque sorted, then translucent),
// fill via BuildIndirectArrays ──────────────────────────────────
@ -918,6 +961,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_batchData = new BatchData[totalDraws + 64];
if (_indirectCommands.Length < totalDraws)
_indirectCommands = new DrawElementsIndirectCommand[totalDraws + 64];
if (_drawCullModes.Length < totalDraws)
_drawCullModes = new CullMode[totalDraws + 64];
var groupInputs = new List<IndirectGroupInput>(totalDraws);
foreach (var g in _opaqueDraws) groupInputs.Add(ToInput(g));
@ -926,7 +971,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// Cast _batchData (private BatchData) to public-mirror BatchDataPublic for BuildIndirectArrays.
// Layout is asserted at test time (BatchDataPublic_LayoutMatchesPrivateBatchData test).
var batchPublic = new BatchDataPublic[totalDraws];
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic);
var layout = BuildIndirectArrays(groupInputs, _indirectCommands, batchPublic, _drawCullModes);
long totalTriangles = 0;
foreach (var input in groupInputs)
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++)
@ -941,6 +992,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_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 three buffers ───────────────────────────────────
fixed (float* ip = _instanceData)
@ -1007,12 +1068,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque[gpuQuerySlot]);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)0,
(uint)_opaqueDrawCount,
(uint)DrawCommandStride);
DrawIndirectRange(0, _opaqueDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
}
@ -1030,38 +1086,13 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// flickers to whatever opaque batch sorted first that frame. See
// uDrawIDOffset comment in mesh_modern.vert.
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
// back-face cull setup. The legacy StaticMeshRenderer had this
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
// WbDrawDispatcher never inherited the cull-face state.
//
// Closed-shell translucent meshes — lifestone crystal, glow gems,
// any convex blended mesh — NEED back-face culling in the
// translucent pass. Without it, back faces composite OVER front
// faces in arbitrary iteration order, because DepthMask(false)
// means nothing records depth within the translucent set. The
// result is the user-visible "one face missing, see into the
// hollow interior" + frame-to-frame color flicker as rotation
// shifts the triangle order.
//
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
// the correct state. Matches WorldBuilder's per-batch CullMode
// handling. Neg-side polygons (rare on translucent AC content)
// use reversed winding and get culled here, matching the opaque
// pass and the original Phase 9.2 fix's known limitation.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
// 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]);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles,
DrawElementsType.UnsignedShort,
(void*)_transparentByteOffset,
(uint)_transparentDrawCount,
(uint)DrawCommandStride);
DrawIndirectRange(_opaqueDrawCount, _transparentDrawCount);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
_gl.DepthMask(true);
_gl.Disable(EnableCap.Blend);
@ -1132,7 +1163,91 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
FirstInstance: g.FirstInstance,
TextureHandle: g.BindlessTextureHandle,
TextureLayer: g.TextureLayer,
Translucency: g.Translucency);
Translucency: g.Translucency,
CullMode: g.CullMode);
private static int CompareOpaqueSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = a.CullMode.CompareTo(b.CullMode);
return cull != 0 ? cull : a.SortDistance.CompareTo(b.SortDistance);
}
private static int CompareTransparentSubmissionOrder(InstanceGroup a, InstanceGroup b)
{
int cull = a.CullMode.CompareTo(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 unsafe void UploadSsbo(uint ssbo, uint binding, void* data, int byteCount)
{
@ -1293,6 +1408,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
CullMode = key.CullMode,
};
_groups[key] = grp;
}
@ -1335,7 +1451,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var key = new GroupKey(
batch.IBO, batch.FirstIndex, (int)batch.BaseVertex,
batch.IndexCount, texHandle, texLayer, translucency);
batch.IndexCount, texHandle, texLayer, translucency, batch.CullMode);
if (!_groups.TryGetValue(key, out var grp))
{
@ -1348,6 +1464,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
BindlessTextureHandle = texHandle,
TextureLayer = texLayer,
Translucency = translucency,
CullMode = batch.CullMode,
};
_groups[key] = grp;
}
@ -1403,7 +1520,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
if (set == EntitySet.IndoorPass) return isIndoor;
if (set == EntitySet.OutdoorScenery) return !isIndoor;
if (set == EntitySet.OutdoorScenery) return !entity.ParentCellId.HasValue;
if (set == EntitySet.BuildingShells) return entity.IsBuildingShell;
throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
}
@ -1425,10 +1543,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
if (!cellInVis) continue;
if (!EntityPassesVisibleCellGate(entity, visibleCellIds, set)) continue;
output.Add(entity.Id);
}
@ -1441,8 +1556,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// cells (instead of the visibility-derived set).
///
/// <para>Indoor entities (ParentCellId set) gated by membership in
/// <paramref name="cellIds"/>. Building shells (IsBuildingShell) pass
/// unconditionally when <paramref name="set"/> == IndoorPass. Outdoor
/// <paramref name="cellIds"/>. Building shells are gated by
/// BuildingShellAnchorCellId membership in the same cell set. Outdoor
/// scenery is excluded by the EntitySet partition (no cell-list gate
/// needed — EntityMatchesSet handles it).</para>
/// </summary>
@ -1458,11 +1573,40 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value))
continue;
if (IsShellScopedSet(set) && entity.IsBuildingShell)
{
if (entity.BuildingShellAnchorCellId is not uint anchorCellId ||
!cellIds.Contains(anchorCellId))
continue;
}
result.Add(entity.Id);
}
return result;
}
private static bool EntityPassesVisibleCellGate(
WorldEntity entity,
HashSet<uint>? visibleCellIds,
EntitySet set)
{
if (visibleCellIds is null)
return true;
if (entity.ParentCellId.HasValue)
return visibleCellIds.Contains(entity.ParentCellId.Value);
if (IsShellScopedSet(set) && entity.IsBuildingShell)
{
return entity.BuildingShellAnchorCellId is uint anchorCellId
&& visibleCellIds.Contains(anchorCellId);
}
return true;
}
private static bool IsShellScopedSet(EntitySet set) =>
set == EntitySet.IndoorPass || set == EntitySet.BuildingShells;
public void Dispose()
{
if (_disposed) return;
@ -1503,7 +1647,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
int FirstInstance,
ulong TextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);
TranslucencyKind Translucency,
CullMode CullMode = CullMode.CounterClockwise);
/// <summary>
/// Public mirror of the per-group <see cref="BatchData"/> uploaded to the SSBO.
@ -1535,7 +1680,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public static IndirectLayoutResult BuildIndirectArrays(
IReadOnlyList<IndirectGroupInput> groups,
DrawElementsIndirectCommand[] indirectScratch,
BatchDataPublic[] batchScratch)
BatchDataPublic[] batchScratch,
CullMode[]? cullScratch = null)
{
int opaqueCount = 0;
int transparentCount = 0;
@ -1570,12 +1716,14 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
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++;
}
}
@ -1639,6 +1787,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
public ulong BindlessTextureHandle; // 64-bit (was uint TextureHandle in N.4)
public uint TextureLayer; // 0 for per-instance composites; non-zero when WB atlas is adopted in N.6+
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