From 55f26f2a9c1d8d5ed682d54901c7edbfcf98fc24 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 26 May 2026 11:38:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20R2=20=E2=80=94=20W?= =?UTF-8?q?bDrawDispatcher.EntitySet=20taxonomy=20partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshapes the dormant EntitySet enum from binary IndoorOnly/OutdoorOnly to a three-way taxonomy-aware partition: IndoorPass — cell mesh + cell statics + building shells (ParentCellId.HasValue OR IsBuildingShell), live-dynamic excluded OutdoorScenery — outdoor scenery only (ParentCellId == null AND !IsBuildingShell), live-dynamic excluded LiveDynamic — ServerGuid != 0 (player, NPCs, dropped items) Centralizes the membership predicate in EntityMatchesSet to keep the three call sites (two in WalkEntitiesInto, one in WalkEntitiesForTest) DRY. R1's IsBuildingShell flag is now consumed at render time. Integration into the render frame ships in R3. Tests rebuilt from scratch — 7 cases cover the new partition truth table. Existing dispatcher tests (Tier 1 cache, etc.) continue to pass under the default EntitySet.All. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 65 ++++-- .../Wb/WbDrawDispatcherEntitySetTests.cs | 210 +++++++++++++----- 2 files changed, 205 insertions(+), 70 deletions(-) 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); } }