fix(render): Phase A8 — animated entities exempt from stencil-gated outdoor pass
Visual verification of A8 (commit 41c2e67) surfaced a showstopper:
player + NPCs disappeared when the camera entered a building. Root
cause: live server-spawned entities (animated player/NPCs/monsters)
have ParentCellId == null. The EntitySet partition classified them
as "outdoor" and stencil-gated them in the OutdoorOnly pass — so
they only rendered where stencil bit 1 was set (portal silhouettes),
producing partial-body and head-backwards artifacts at doorway
transits and full invisibility everywhere else inside.
Fix: animatedEntityIds overrides the ParentCellId-based partition.
Animated entities always belong in the IndoorOnly pass (stencil OFF),
never in OutdoorOnly. Three changes:
- WalkEntitiesInto full-walk path: compute isAnimated up front, use
it in both partition checks
- WalkEntitiesInto animated-only path: skip the entire path on
OutdoorOnly (every iterated entity is animated by definition)
- WalkEntitiesForTest: add optional animatedEntityIds parameter,
mirror the new partition logic
Two new tests cover:
- EntitySet_IndoorOnly_IncludesAnimatedEntitiesEvenWithNullParentCellId
- EntitySet_OutdoorOnly_ExcludesAnimatedEntities
Known remaining limitation: dropped items / static-but-live objects
have ParentCellId == null AND are NOT in animatedEntityIds, so they
still classify as outdoor scenery and stencil-gate. Addressing this
requires a "live entity" flag on WorldEntity — deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
41c2e67cd8
commit
a2ad5c1ac4
2 changed files with 87 additions and 10 deletions
|
|
@ -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<uint> WalkEntitiesForTest(
|
||||
IReadOnlyList<AcDream.Core.World.WorldEntity> entities,
|
||||
HashSet<uint>? visibleCellIds,
|
||||
EntitySet set)
|
||||
EntitySet set,
|
||||
HashSet<uint>? animatedEntityIds = null)
|
||||
{
|
||||
var output = new List<uint>();
|
||||
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue