From 3361933ce6395e297eb5ae35856e9b498f2e225a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 27 May 2026 11:18:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20RR5=20=E2=80=94=20?= =?UTF-8?q?WbDrawDispatcher=20Draw(cellIds:)=20overload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new public overload accepting an explicit IReadOnlyCollection cellIds (the camera-buildings' EnvCellIds) instead of a BFS-derived visibility set. Used by RR7's IndoorPass to scope indoor rendering to the camera-buildings' cells, not the full portal BFS (which causes Issues A+C). Pure-data test helper WalkEntitiesForTestByCellIds added alongside the production overload, mirroring the WalkEntitiesForTest pattern. The overload internally delegates to the existing visibleCellIds path — the dispatcher's semantic stays the same; only the caller's intent differs (explicit cell list vs visibility-derived). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 63 +++++++++++++ .../WbDrawDispatcherCellIdsOverloadTests.cs | 90 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index b934ade..10a14f4 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -1089,6 +1089,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } } + /// + /// Phase A8 RR5 (2026-05-26): per-building draw overload. Walks only + /// entities whose ParentCellId is in , plus + /// outdoor-style entities matching the EntitySet partition. Used by + /// the indoor render branch to scope rendering to the camera-buildings' + /// cells. + /// + /// Mirrors the existing visibleCellIds-based Draw but with an + /// explicit cell list (not the BFS-derived visibility set). The semantic + /// difference is at the caller: cellIds = the camera-buildings' EnvCellIds, + /// not the portal BFS result. The dispatcher's internal logic is identical + /// — it filters indoor entities by membership in the provided set. + /// + public void Draw( + ICamera camera, + IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, + IReadOnlyList Entities, + IReadOnlyDictionary? AnimatedById)> landblockEntries, + IReadOnlyCollection cellIds, + FrustumPlanes? frustum = null, + uint? neverCullLandblockId = null, + HashSet? animatedEntityIds = null, + EntitySet set = EntitySet.All) + { + // Adapt IReadOnlyCollection → HashSet for the existing path. + // If the caller already passed a HashSet, avoid re-wrapping. + HashSet cellIdSet = cellIds is HashSet hs ? hs : new HashSet(cellIds); + Draw(camera, landblockEntries, + frustum: frustum, + neverCullLandblockId: neverCullLandblockId, + visibleCellIds: cellIdSet, + animatedEntityIds: animatedEntityIds, + set: set); + } + private static IndirectGroupInput ToInput(InstanceGroup g) => new( IndexCount: g.IndexCount, FirstIndex: g.FirstIndex, @@ -1400,6 +1435,34 @@ public sealed unsafe class WbDrawDispatcher : IDisposable return output; } + /// + /// Phase A8 RR5 (2026-05-26): pure-data walk for the explicit cellIds + /// overload. Used by RR7's IndoorPass to render only the camera-buildings' + /// cells (instead of the visibility-derived set). + /// + /// Indoor entities (ParentCellId set) gated by membership in + /// . Building shells (IsBuildingShell) pass + /// unconditionally when == IndoorPass. Outdoor + /// scenery is excluded by the EntitySet partition (no cell-list gate + /// needed — EntityMatchesSet handles it). + /// + public static List WalkEntitiesForTestByCellIds( + IEnumerable entities, + IReadOnlyCollection cellIds, + EntitySet set) + { + var result = new List(); + foreach (var entity in entities) + { + if (!EntityMatchesSet(entity, set)) continue; + if (entity.MeshRefs.Count == 0) continue; + if (entity.ParentCellId.HasValue && !cellIds.Contains(entity.ParentCellId.Value)) + continue; + result.Add(entity.Id); + } + return result; + } + public void Dispose() { if (_disposed) return; diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs new file mode 100644 index 0000000..5b52f57 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherCellIdsOverloadTests.cs @@ -0,0 +1,90 @@ +// Phase A8 RR5 — verify WbDrawDispatcher.WalkEntitiesForTestByCellIds, +// the pure-data companion to the new Draw(cellIds:) production overload. +// +// Semantics: indoor entities (ParentCellId.HasValue) are gated by explicit +// membership in cellIds. Building shells (IsBuildingShell) always pass. +// Outdoor scenery (no ParentCellId, not a shell) is excluded by EntitySet.IndoorPass. + +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 WbDrawDispatcherCellIdsOverloadTests +{ + private static WorldEntity CellEnt(uint id, uint cellId) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000001u, + ParentCellId = cellId, + MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + private static WorldEntity OutdoorScenery(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x01000001u, + ParentCellId = null, + IsBuildingShell = false, + MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + private static WorldEntity BuildingShell(uint id) => new() + { + Id = id, + SourceGfxObjOrSetupId = 0x02000001u, + ParentCellId = null, + IsBuildingShell = true, + MeshRefs = new List { new(0x01000001u, Matrix4x4.Identity) }, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + }; + + [Fact] + public void WalkEntitiesByCellIds_IncludesOnlyEntitiesInListedCells() + { + var entities = new List + { + CellEnt(0x40000001u, 0xA9B40150u), // in listed cells + CellEnt(0x40000002u, 0xA9B40151u), // in listed cells + CellEnt(0x40000003u, 0xA9B40999u), // OUT — not in list + BuildingShell(0xC0000001u), // always included (IsBuildingShell) + OutdoorScenery(0xC0000002u), // OUT — not a shell, not in cell list + }; + var cellIds = new HashSet { 0xA9B40150u, 0xA9B40151u }; + + var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( + entities, cellIds, set: WbDrawDispatcher.EntitySet.IndoorPass); + + Assert.Equal(3, result.Count); + Assert.Contains(0x40000001u, result); + Assert.Contains(0x40000002u, result); + Assert.Contains(0xC0000001u, result); + Assert.DoesNotContain(0x40000003u, result); + Assert.DoesNotContain(0xC0000002u, result); + } + + [Fact] + public void WalkEntitiesByCellIds_EmptyCellList_StillIncludesBuildingShells() + { + var entities = new List + { + CellEnt(0x40000001u, 0xA9B40150u), + BuildingShell(0xC0000001u), + }; + + var result = WbDrawDispatcher.WalkEntitiesForTestByCellIds( + entities, new HashSet(), set: WbDrawDispatcher.EntitySet.IndoorPass); + + // Cell entity dropped (no cells in list); building shell still passes. + Assert.Single(result); + Assert.Contains(0xC0000001u, result); + } +}