diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 7fe8d77..b934ade 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -62,22 +62,37 @@ 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.
+ /// Used to split the indoor-cell visibility pipeline into three passes
+ /// when the camera is inside an EnvCell.
+ ///
+ /// Taxonomy reference: docs/research/2026-05-26-a8-entity-taxonomy.md.
///
public enum EntitySet
{
/// Pre-A8 behavior: every entity walked, gated only by
- /// the existing ParentCellId ∈ visibleCellIds filter.
+ /// the existing ParentCellId ∈ visibleCellIds filter.
+ /// Used when the camera is OUTSIDE any EnvCell.
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,
+
+ /// Cell mesh + cell statics (
+ /// non-null) PLUS building shell stabs (
+ /// true, regardless of ParentCellId). These render unconditionally
+ /// when the camera is inside their building — building shells ARE
+ /// the indoor walls. Live-dynamic (ServerGuid != 0) is
+ /// excluded; it flows through .
+ IndoorPass,
+
+ /// Outdoor scenery stabs (ParentCellId == null,
+ /// !IsBuildingShell) plus procedurally-generated scenery.
+ /// Drawn stencil-gated to portal silhouettes when the camera is
+ /// inside. Live-dynamic excluded.
+ OutdoorScenery,
+
+ /// Server-spawned dynamic entities (ServerGuid != 0):
+ /// player, NPCs, monsters, dropped items, animated and idle doors.
+ /// Drawn last with stencil disabled so they're depth-tested against
+ /// everything else but not stencil-clipped.
+ LiveDynamic,
}
private readonly GL _gl;
@@ -358,8 +373,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
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 (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
@@ -373,8 +387,7 @@ 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 (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
// Detect cell entity for indoor probes — first MeshRef.GfxObjId
@@ -1341,6 +1354,25 @@ 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 — entity-taxonomy-aware membership test for the three-way
+ /// EntitySet partition. See for the doctrine.
+ ///
+ private static bool EntityMatchesSet(WorldEntity entity, EntitySet set)
+ {
+ if (set == EntitySet.All) return true;
+
+ bool isLiveDynamic = entity.ServerGuid != 0;
+ if (set == EntitySet.LiveDynamic) return isLiveDynamic;
+ if (isLiveDynamic) return false; // IndoorPass/OutdoorScenery exclude live-dynamic
+
+ bool isIndoor = entity.ParentCellId.HasValue || entity.IsBuildingShell;
+ if (set == EntitySet.IndoorPass) return isIndoor;
+ if (set == EntitySet.OutdoorScenery) return !isIndoor;
+
+ throw new InvalidOperationException($"Unhandled EntitySet value: {set}");
+ }
+
///
/// Phase A8 test helper: runs the EntitySet partition + visibleCellIds
/// gate against an in-memory entity list, returning the IDs that
@@ -1355,8 +1387,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
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 (!EntityMatchesSet(entity, set)) continue;
if (entity.MeshRefs.Count == 0) continue;
bool cellInVis = !(entity.ParentCellId.HasValue
diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
index ec2d2e6..f779021 100644
--- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
+++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs
@@ -1,10 +1,19 @@
-// Phase A8 — verify the WbDrawDispatcher EntitySet partition.
+// Phase A8 — verify the WbDrawDispatcher EntitySet partition (taxonomy-aware).
//
// 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.
+//
+// EntitySet.IndoorPass — ParentCellId.HasValue OR IsBuildingShell,
+// and NOT live-dynamic (ServerGuid == 0).
+// Building shells render unconditionally indoors;
+// live-dynamic flows through LiveDynamic instead.
+// EntitySet.OutdoorScenery — ParentCellId == null AND !IsBuildingShell
+// AND not live-dynamic.
+// EntitySet.LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items,
+// idle doors after animation). Drawn last with
+// stencil disabled.
+// EntitySet.All — pre-A8 behavior (visibleCellIds gates indoor;
+// outdoor entities pass through).
using System.Collections.Generic;
using System.Numerics;
@@ -16,47 +25,63 @@ namespace AcDream.Core.Tests.Rendering.Wb;
public class WbDrawDispatcherEntitySetTests
{
- private static WorldEntity Indoor(uint id, uint cellId) => new()
+ private static WorldEntity CellEnt(uint id, uint cellId) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = cellId,
- MeshRefs = new List
- {
- new() { GfxObjId = 0x01000001u },
- },
+ MeshRefs = new List { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
- Rotation = System.Numerics.Quaternion.Identity,
+ Rotation = Quaternion.Identity,
};
- private static WorldEntity Outdoor(uint id) => new()
+ private static WorldEntity OutdoorScenery(uint id) => new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
ParentCellId = null,
- MeshRefs = new List
- {
- new() { GfxObjId = 0x01000001u },
- },
+ IsBuildingShell = false,
+ MeshRefs = new List { new() { GfxObjId = 0x01000001u } },
Position = Vector3.Zero,
- Rotation = System.Numerics.Quaternion.Identity,
+ Rotation = Quaternion.Identity,
+ };
+
+ private static WorldEntity BuildingShell(uint id) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ ParentCellId = null,
+ IsBuildingShell = true,
+ MeshRefs = new List { new() { GfxObjId = 0x01000001u } },
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
+ };
+
+ private static WorldEntity LiveDynamic(uint id, uint serverGuid) => new()
+ {
+ Id = id,
+ SourceGfxObjOrSetupId = 0x02000001u,
+ ServerGuid = serverGuid,
+ ParentCellId = null,
+ IsBuildingShell = false,
+ MeshRefs = new List { new() { GfxObjId = 0x01000001u } },
+ Position = Vector3.Zero,
+ Rotation = Quaternion.Identity,
};
[Fact]
- public void EntitySet_IndoorOnly_DropsOutdoorEntities()
+ public void IndoorPass_IncludesCellEntities()
{
var entities = new List
{
- Indoor(0x10000001, 0xA9B40143),
- Outdoor(0x10000002),
- Indoor(0x10000003, 0xA9B40144),
+ CellEnt(0x10000001, 0xA9B40143),
+ OutdoorScenery(0x10000002),
+ CellEnt(0x10000003, 0xA9B40144),
};
var visible = new HashSet { 0xA9B40143u, 0xA9B40144u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
- entities,
- visibleCellIds: visible,
- set: WbDrawDispatcher.EntitySet.IndoorOnly);
+ entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
Assert.Equal(2, result.Count);
Assert.Contains(0x10000001u, result);
@@ -65,46 +90,125 @@ public class WbDrawDispatcherEntitySetTests
}
[Fact]
- public void EntitySet_OutdoorOnly_KeepsOnlyNullParentCellId()
+ public void IndoorPass_IncludesBuildingShells_EvenWithNullParentCellId()
{
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
+ BuildingShell(0xC0000001), // cottage wall
+ OutdoorScenery(0xC0000002), // tree
+ CellEnt(0x40000001, 0xA9B40143),
};
var visible = new HashSet { 0xA9B40143u };
var result = WbDrawDispatcher.WalkEntitiesForTest(
- entities,
- visibleCellIds: visible,
- set: WbDrawDispatcher.EntitySet.All);
+ entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(0xC0000001u, result); // building shell included
+ Assert.Contains(0x40000001u, result); // cell entity included
+ Assert.DoesNotContain(0xC0000002u, result); // tree excluded
+ }
+
+ [Fact]
+ public void IndoorPass_ExcludesLiveDynamic()
+ {
+ var entities = new List
+ {
+ CellEnt(0x40000001, 0xA9B40143),
+ LiveDynamic(0x10000001, serverGuid: 0x50000123u),
+ };
+
+ var visible = new HashSet { 0xA9B40143u };
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities, visibleCellIds: visible, set: WbDrawDispatcher.EntitySet.IndoorPass);
+
+ Assert.Single(result);
+ Assert.Contains(0x40000001u, result);
+ Assert.DoesNotContain(0x10000001u, result); // live-dynamic excluded
+ }
+
+ [Fact]
+ public void OutdoorScenery_ExcludesBuildingShells()
+ {
+ var entities = new List
+ {
+ BuildingShell(0xC0000001), // cottage wall — excluded
+ OutdoorScenery(0xC0000002), // tree — included
+ CellEnt(0x40000001, 0xA9B40143), // cell — excluded
+ };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
+
+ Assert.Single(result);
+ Assert.Contains(0xC0000002u, result);
+ Assert.DoesNotContain(0xC0000001u, result);
+ Assert.DoesNotContain(0x40000001u, result);
+ }
+
+ [Fact]
+ public void OutdoorScenery_ExcludesLiveDynamic()
+ {
+ var entities = new List
+ {
+ OutdoorScenery(0xC0000001),
+ LiveDynamic(0x10000001, serverGuid: 0x50000123u),
+ };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.OutdoorScenery);
+
+ Assert.Single(result);
+ Assert.Contains(0xC0000001u, result);
+ Assert.DoesNotContain(0x10000001u, result);
+ }
+
+ [Fact]
+ public void LiveDynamic_IncludesOnlyServerSpawned()
+ {
+ var entities = new List
+ {
+ OutdoorScenery(0xC0000001),
+ BuildingShell(0xC0000002),
+ CellEnt(0x40000001, 0xA9B40143),
+ LiveDynamic(0x10000001, serverGuid: 0x50000123u),
+ LiveDynamic(0x10000002, serverGuid: 0x50000456u),
+ };
+
+ var result = WbDrawDispatcher.WalkEntitiesForTest(
+ entities, visibleCellIds: null, set: WbDrawDispatcher.EntitySet.LiveDynamic);
- // 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);
+ Assert.DoesNotContain(0xC0000001u, result);
+ Assert.DoesNotContain(0xC0000002u, result);
+ Assert.DoesNotContain(0x40000001u, result);
+ }
+
+ [Fact]
+ public void All_MatchesPreA8Behavior()
+ {
+ var entities = new List
+ {
+ CellEnt(0x40000001, 0xA9B40143),
+ OutdoorScenery(0xC0000001),
+ BuildingShell(0xC0000002),
+ LiveDynamic(0x10000001, serverGuid: 0x50000123u),
+ CellEnt(0x40000002, 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 only; outdoor entities
+ // (regardless of building/scenery/live-dynamic) pass through.
+ Assert.Equal(4, result.Count);
+ Assert.Contains(0x40000001u, result);
+ Assert.Contains(0xC0000001u, result);
+ Assert.Contains(0xC0000002u, result);
+ Assert.Contains(0x10000001u, result);
+ Assert.DoesNotContain(0x40000002u, result);
}
}