diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index f62790d..7fe8d77 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -60,6 +60,26 @@ namespace AcDream.App.Rendering.Wb;
///
public sealed unsafe class WbDrawDispatcher : IDisposable
{
+ ///
+ /// 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.
+ ///
+ public enum EntitySet
+ {
+ /// Pre-A8 behavior: every entity walked, gated only by
+ /// the existing ParentCellId ∈ visibleCellIds filter.
+ All,
+ /// Only entities with ParentCellId.HasValue (indoor).
+ /// Existing visibleCellIds filter still applied on top.
+ IndoorOnly,
+ /// Only entities with ParentCellId == null (outdoor
+ /// stabs, scenery, live-spawned). visibleCellIds is ignored for
+ /// this set since outdoor entities never have a ParentCellId.
+ OutdoorOnly,
+ }
+
private readonly GL _gl;
private readonly Shader _shader;
private readonly TextureCache _textures;
@@ -315,7 +335,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet? 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? visibleCellIds = null,
- HashSet? animatedEntityIds = null)
+ HashSet? 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;
}
+ ///
+ /// 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.
+ ///
+ public static List WalkEntitiesForTest(
+ IReadOnlyList entities,
+ HashSet? visibleCellIds,
+ EntitySet set)
+ {
+ var output = new List();
+ 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;
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
new file mode 100644
index 0000000..ec2d2e6
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
@@ -0,0 +1,110 @@
+// Phase A8 — verify the WbDrawDispatcher EntitySet partition.
+//
+// The pure-data WalkEntitiesForTest helper iterates a flat entity list and
+// returns the IDs that survive the EntitySet filter + visibleCellIds gate.
+// EntitySet.IndoorOnly should include only entities with ParentCellId,
+// EntitySet.OutdoorOnly only entities with null ParentCellId, and
+// EntitySet.All (the default) should match the pre-A8 behavior.
+
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.Core.World;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering.Wb;
+
+public class WbDrawDispatcherEntitySetTests
+{
+ private static WorldEntity Indoor(uint id, uint cellId) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ ParentCellId = cellId,
+ MeshRefs = new List
+ {
+ new() { GfxObjId = 0x01000001u },
+ },
+ Position = Vector3.Zero,
+ Rotation = System.Numerics.Quaternion.Identity,
+ };
+
+ private static WorldEntity Outdoor(uint id) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x01000001u,
+ ParentCellId = null,
+ MeshRefs = new List
+ {
+ new() { GfxObjId = 0x01000001u },
+ },
+ Position = Vector3.Zero,
+ Rotation = System.Numerics.Quaternion.Identity,
+ };
+
+ [Fact]
+ public void EntitySet_IndoorOnly_DropsOutdoorEntities()
+ {
+ var entities = new List
+ {
+ Indoor(0x10000001, 0xA9B40143),
+ Outdoor(0x10000002),
+ Indoor(0x10000003, 0xA9B40144),
+ };
+
+ var visible = new HashSet { 0xA9B40143u, 0xA9B40144u };
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities,
+ visibleCellIds: visible,
+ set: WbDrawDispatcher.EntitySet.IndoorOnly);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(0x10000001u, result);
+ Assert.Contains(0x10000003u, result);
+ Assert.DoesNotContain(0x10000002u, result);
+ }
+
+ [Fact]
+ public void EntitySet_OutdoorOnly_KeepsOnlyNullParentCellId()
+ {
+ var entities = new List
+ {
+ Indoor(0x10000001, 0xA9B40143),
+ Outdoor(0x10000002),
+ Outdoor(0x10000003),
+ };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities,
+ visibleCellIds: null,
+ set: WbDrawDispatcher.EntitySet.OutdoorOnly);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(0x10000002u, result);
+ Assert.Contains(0x10000003u, result);
+ Assert.DoesNotContain(0x10000001u, result);
+ }
+
+ [Fact]
+ public void EntitySet_All_MatchesPreA8Behavior()
+ {
+ var entities = new List
+ {
+ Indoor(0x10000001, 0xA9B40143),
+ Outdoor(0x10000002),
+ Indoor(0x10000003, 0xA9B40999), // not in visibleCellIds
+ };
+
+ var visible = new HashSet { 0xA9B40143u };
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities,
+ visibleCellIds: visible,
+ set: WbDrawDispatcher.EntitySet.All);
+
+ // Pre-A8: visibleCellIds gates indoor entities, outdoor entities pass.
+ Assert.Equal(2, result.Count);
+ Assert.Contains(0x10000001u, result);
+ Assert.Contains(0x10000002u, result);
+ Assert.DoesNotContain(0x10000003u, result);
+ }
+}