From 559b79dc9879073e0e92f2a45265a88e5bc2e765 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 11:10:04 +0200 Subject: [PATCH] fix(render): keep animated entities visible when their landblock is frustum-culled User report: other characters disappear when the camera rotates, even though they're standing within visible distance. Root cause: InstancedMeshRenderer's landblock-level frustum cull (InstancedMeshRenderer.cs:352-355) skipped the entire landblock's entity list when the landblock AABB was outside the frustum. Static scenery culling that way is fine, but ANIMATED entities (remote players, NPCs, monsters) got culled with the landblock -- they vanished as soon as the camera turned away from their block. Fix: pass an animatedEntityIds set to Draw. Inside CollectGroups the landblock-cull decision is now per-landblock boolean (not a continue), and the per-entity loop bypasses the cull when the entity id is in animatedEntityIds. Static entities still respect the landblock cull. GameWindow rebuilds the set per frame from _animatedEntities (typically <100 entities, cheap). Fast path preserved: when animatedEntityIds is null/empty AND the landblock is culled, skip the entity list entirely -- same O(visible-landblocks) cost as before. Tests stay 1439 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 19 ++++++++- .../Rendering/InstancedMeshRenderer.cs | 40 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cfefc85..73ccb1c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5023,9 +5023,26 @@ public sealed class GameWindow : IDisposable if (cameraInsideCell) _gl!.Clear(ClearBufferMask.DepthBufferBit); + // L-fix1 (2026-04-28): pass the set of animated-entity ids so + // the renderer keeps remote players / NPCs / monsters + // visible even when their landblock rotates out of the + // frustum. Without this, other characters wink in/out as + // the camera turns. The set is rebuilt per-frame from + // _animatedEntities — it's small (<100 entities typically) + // so HashSet allocation is cheap. Static scenery still + // respects landblock-level cull. + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } + _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, - visibleCellIds: visibility?.VisibleCellIds); + visibleCellIds: visibility?.VisibleCellIds, + animatedEntityIds: animatedIds); // Phase G.1 / E.3: draw all live particles after opaque // scene geometry so alpha blending composites correctly. diff --git a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs index 18a67ae..7f4ce29 100644 --- a/src/AcDream.App/Rendering/InstancedMeshRenderer.cs +++ b/src/AcDream.App/Rendering/InstancedMeshRenderer.cs @@ -152,7 +152,16 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null, - HashSet? visibleCellIds = null) + HashSet? visibleCellIds = null, + // L-fix1 (2026-04-28): set of entity ids that should bypass the + // landblock-level frustum cull. Animated entities (other + // players, NPCs, monsters) are always rendered if their + // landblock is loaded — without this they vanish whenever the + // camera rotates away from their landblock, even though + // they're within visible distance of the player. Pass null / + // empty to keep the previous "cull everything by landblock" + // behavior. + HashSet? animatedEntityIds = null) { _shader.Use(); @@ -165,7 +174,7 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable // directly — no per-draw uniform uploads needed. // ── Collect and group instances ─────────────────────────────────────── - CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds); + CollectGroups(landblockEntries, frustum, neverCullLandblockId, visibleCellIds, animatedEntityIds); // ── Build and upload the instance buffer ────────────────────────────── // Count total instances. @@ -342,16 +351,27 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, FrustumPlanes? frustum, uint? neverCullLandblockId, - HashSet? visibleCellIds) + HashSet? visibleCellIds, + HashSet? animatedEntityIds) { foreach (var grp in _groups.Values) grp.Entries.Clear(); foreach (var entry in landblockEntries) { - if (frustum is not null && - entry.LandblockId != neverCullLandblockId && - !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) + // L-fix1 (2026-04-28): the landblock cull decision is now + // PER-LANDBLOCK boolean, not a continue. We still need to + // walk the entity list because animated entities (in + // animatedEntityIds) bypass the cull and render anyway. + bool landblockVisible = frustum is null + || entry.LandblockId == neverCullLandblockId + || FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax); + + // Fast path: no animated entities globally → if landblock is + // culled, skip the whole entity list (preserves the original + // O(visible-landblocks) cost when the caller doesn't care + // about animated bypass). + if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0)) continue; foreach (var entity in entry.Entities) @@ -359,6 +379,14 @@ public sealed unsafe class InstancedMeshRenderer : IDisposable if (entity.MeshRefs.Count == 0) continue; + // L-fix1: when the landblock is frustum-culled, only + // render entities flagged as animated. This keeps + // remote players / NPCs / monsters visible even when + // their landblock rotates out of the view frustum. + bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true; + if (!landblockVisible && !isAnimated) + continue; + // Step 4: portal visibility filter. If we have a visible cell set, // skip interior entities whose parent cell isn't visible. // visibleCellIds == null means camera is outdoors → show all interiors.