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); } }