From dcf69a1feb4519544511d8442362b63c1a2f9d57 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 26 May 2026 08:20:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(render):=20Phase=20A8=20=E2=80=94=20WbDraw?= =?UTF-8?q?Dispatcher.EntitySet=20partition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds EntitySet { All, IndoorOnly, OutdoorOnly } and a Draw parameter to partition the per-entity walk by ParentCellId presence. EntitySet.All preserves pre-A8 behavior; IndoorOnly drops null-ParentCellId entities; OutdoorOnly drops ParentCellId.HasValue entities. The visibleCellIds filter is still applied on top. Used by Task 7 to split the render frame's single Draw call into two (indoor stencil-OFF, outdoor stencil-gated). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 63 +++++++++- .../Wb/WbDrawDispatcherEntitySetTests.cs | 110 ++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs 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); + } +}