diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 7fe8d77..86654d1 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -357,9 +357,12 @@ 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; + // Phase A8 fix: every entity in this loop IS animated (we're iterating + // animatedEntityIds). Animated entities (player, NPCs, monsters) are live + // server-spawned objects that have ParentCellId == null but must ALWAYS + // render in the indoor pass — never stencil-gated by OutdoorOnly. + // Otherwise the character disappears when the camera enters a building. + if (set == EntitySet.OutdoorOnly) continue; if (entity.MeshRefs.Count == 0) continue; if (entity.ParentCellId.HasValue && visibleCellIds is not null && !visibleCellIds.Contains(entity.ParentCellId.Value)) continue; @@ -372,9 +375,13 @@ 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; + // Phase A8 fix: animated entities (player, NPCs, monsters) are live + // server-spawned objects with ParentCellId == null. They must ALWAYS render + // in the indoor pass — never stencil-gated by OutdoorOnly — or the character + // disappears when the camera enters a building. + bool isAnimated = animatedEntityIds is not null && animatedEntityIds.Contains(entity.Id); + if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue && !isAnimated) continue; + if (set == EntitySet.OutdoorOnly && (entity.ParentCellId.HasValue || isAnimated)) continue; if (entity.MeshRefs.Count == 0) continue; // Detect cell entity for indoor probes — first MeshRef.GfxObjId @@ -403,7 +410,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Per-entity AABB frustum cull (perf #3). Animated entities bypass — // they're tracked at landblock level + need per-frame work regardless. // A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty. - bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + // Note: isAnimated already computed above for the EntitySet partition. bool aabbVisible = true; if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId) { @@ -1350,13 +1357,18 @@ public sealed unsafe class WbDrawDispatcher : IDisposable public static List WalkEntitiesForTest( IReadOnlyList entities, HashSet? visibleCellIds, - EntitySet set) + EntitySet set, + HashSet? animatedEntityIds = null) { 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; + // Phase A8 fix: animated entities (player, NPCs, monsters) are live + // server-spawned objects with ParentCellId == null. They must ALWAYS render + // in the indoor pass — never stencil-gated by OutdoorOnly. + bool isAnimated = animatedEntityIds is not null && animatedEntityIds.Contains(entity.Id); + if (set == EntitySet.IndoorOnly && !entity.ParentCellId.HasValue && !isAnimated) continue; + if (set == EntitySet.OutdoorOnly && (entity.ParentCellId.HasValue || isAnimated)) 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..67126d5 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/WbDrawDispatcherEntitySetTests.cs @@ -107,4 +107,69 @@ public class WbDrawDispatcherEntitySetTests Assert.Contains(0x10000002u, result); Assert.DoesNotContain(0x10000003u, result); } + + // --------------------------------------------------------------------- + // Phase A8 fix (post-visual-verification): animated entities (player, + // NPCs, monsters) are live server-spawned objects with ParentCellId == null. + // Without these tests, they would be classified as outdoor scenery and + // stencil-gated by the OutdoorOnly pass — causing the character to + // disappear when the camera enters a building. Fix: animatedEntityIds + // overrides the ParentCellId-based partition. Animated entities always + // belong in the IndoorOnly pass, never in OutdoorOnly. + // --------------------------------------------------------------------- + + [Fact] + public void EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId() + { + var entities = new List + { + Indoor(0x10000001, 0xA9B40143), + Outdoor(0x10000002), // static outdoor scenery + Outdoor(0x40000005), // animated (player/NPC) + }; + + var visible = new HashSet { 0xA9B40143u }; + var animated = new HashSet { 0x40000005u }; + + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, + visibleCellIds: visible, + set: WbDrawDispatcher.EntitySet.IndoorOnly, + animatedEntityIds: animated); + + // Indoor entity passes via ParentCellId.HasValue. + // Outdoor scenery (0x10000002) fails — not animated, no ParentCellId. + // Animated entity (0x40000005) passes via animatedEntityIds override. + Assert.Equal(2, result.Count); + Assert.Contains(0x10000001u, result); + Assert.Contains(0x40000005u, result); + Assert.DoesNotContain(0x10000002u, result); + } + + [Fact] + public void EntitySet_OutdoorOnly_ExcludesAnimatedEntities() + { + var entities = new List + { + Outdoor(0x10000002), // static outdoor scenery + Outdoor(0x40000005), // animated (player/NPC) + Outdoor(0x40000006), // animated (NPC) + }; + + var animated = new HashSet { 0x40000005u, 0x40000006u }; + + var result = WbDrawDispatcher.WalkEntitiesForTest( + entities, + visibleCellIds: null, + set: WbDrawDispatcher.EntitySet.OutdoorOnly, + animatedEntityIds: animated); + + // Only static outdoor scenery passes the OutdoorOnly partition. + // Animated entities are explicitly excluded so they don't get + // stencil-gated (they're drawn in the IndoorOnly pass instead). + Assert.Single(result); + Assert.Contains(0x10000002u, result); + Assert.DoesNotContain(0x40000005u, result); + Assert.DoesNotContain(0x40000006u, result); + } }