feat(render): Phase A8 — WbDrawDispatcher.EntitySet partition

Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to
partition the per-entity walk by ParentCellId presence. EntitySet.All
preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities;
OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds
filter is still applied on top.

Used by Task 7 to split the render frame's single Draw call into two
(indoor stencil-OFF, outdoor stencil-gated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-26 08:20:45 +02:00
parent a1c393ee14
commit dcf69a1feb
2 changed files with 170 additions and 3 deletions

View file

@ -60,6 +60,26 @@ namespace AcDream.App.Rendering.Wb;
/// </summary>
public sealed unsafe class WbDrawDispatcher : IDisposable
{
/// <summary>
/// Phase A8 — which subset of entities to walk in a single Draw call.
/// Used to split indoor entities (drawn first, stencil OFF) from outdoor
/// entities (drawn after, stencil-gated to portal silhouettes) when the
/// camera is inside an EnvCell.
/// </summary>
public enum EntitySet
{
/// <summary>Pre-A8 behavior: every entity walked, gated only by
/// the existing ParentCellId ∈ visibleCellIds filter.</summary>
All,
/// <summary>Only entities with <c>ParentCellId.HasValue</c> (indoor).
/// Existing visibleCellIds filter still applied on top.</summary>
IndoorOnly,
/// <summary>Only entities with <c>ParentCellId == null</c> (outdoor
/// stabs, scenery, live-spawned). visibleCellIds is ignored for
/// this set since outdoor entities never have a ParentCellId.</summary>
OutdoorOnly,
}
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
@ -315,7 +335,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result,
IndoorProbeState? indoorProbeState = null)
IndoorProbeState? indoorProbeState = null,
EntitySet set = EntitySet.All)
{
scratch.Clear();
result.EntitiesWalked = 0;
@ -336,6 +357,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var animatedId in animatedEntityIds)
{
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
@ -348,6 +372,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
foreach (var entity in entry.Entities)
{
// Phase A8: EntitySet partition for indoor/outdoor split passes.
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
if (entity.MeshRefs.Count == 0) continue;
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
@ -426,7 +453,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
HashSet<uint>? animatedEntityIds = null)
HashSet<uint>? animatedEntityIds = null,
EntitySet set = EntitySet.All)
{
_shader.Use();
_indoorProbeFrameCounter++;
@ -501,7 +529,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
animatedEntityIds,
_walkScratch,
ref walkResult,
probeState);
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
@ -1312,6 +1341,34 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
buf[offset + 12] = m.M41; buf[offset + 13] = m.M42; buf[offset + 14] = m.M43; buf[offset + 15] = m.M44;
}
/// <summary>
/// Phase A8 test helper: runs the EntitySet partition + visibleCellIds
/// gate against an in-memory entity list, returning the IDs that
/// survive both filters. Exists so the partition logic is unit-testable
/// without requiring a GL context or landblock-entries machinery.
/// </summary>
public static List<uint> WalkEntitiesForTest(
IReadOnlyList<AcDream.Core.World.WorldEntity> entities,
HashSet<uint>? visibleCellIds,
EntitySet set)
{
var output = new List<uint>();
foreach (var entity in entities)
{
if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue) continue;
if (set == EntitySet.OutdoorOnly && entity.ParentCellId.HasValue) continue;
if (entity.MeshRefs.Count == 0) continue;
bool cellInVis = !(entity.ParentCellId.HasValue
&& visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value));
if (!cellInVis) continue;
output.Add(entity.Id);
}
return output;
}
public void Dispose()
{
if (_disposed) return;