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.