From a3b389603dba4c37487c64e9e9b94e52f5acc671 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Apr 2026 21:29:54 +0200 Subject: [PATCH] fix(app): multi-point Z sampling + never-cull player landblock 1. Slope clipping: replaced single foot-forward Z sample with 4-point sampling (forward, back, left, right at 0.7 units). Takes the max Z across all samples so both uphill and downhill slopes keep feet above the terrain mesh surface. Removed the +0.1 Z bias entirely. 2. Player culling: replaced per-entity scan (alwaysVisibleEntityId) with per-landblock skip (neverCullLandblockId). The player's current landblock is computed from _playerController.Position and passed to the renderer. Simpler, faster, and more reliable. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Input/PlayerMovementController.cs | 36 ++++++++++++------- src/AcDream.App/Rendering/GameWindow.cs | 11 +++++- .../Rendering/StaticMeshRenderer.cs | 31 ++++------------ .../Input/PlayerMovementControllerTests.cs | 4 +-- 4 files changed, 42 insertions(+), 40 deletions(-) diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index faa0b7f..243156e 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -206,22 +206,32 @@ public sealed class PlayerMovementController } } - // Foot-forward Z sampling: on slopes the visual terrain at the - // character's feet (slightly forward) can be higher than at the center. - // Sample a point ~1 unit ahead in the walk direction and use the MAX - // of center and foot Z. Only when grounded and moving. - if (!IsAirborne && (dx != 0f || dy != 0f)) + // Multi-point Z sampling: on slopes the visual terrain mesh can be + // higher or lower than the center-point physics sample. Sample 4 + // points around the character (forward, back, left, right at ~0.7 + // units — roughly foot distance) and use the MAX Z. This prevents + // feet clipping on both uphill and downhill slopes. + if (!IsAirborne) { - float footX = result.Position.X + forwardX * 1.0f; - float footY = result.Position.Y + forwardY * 1.0f; - var footResult = _physics.Resolve( - new Vector3(footX, footY, newZ), CellId, - Vector3.Zero, StepUpHeight); - if (footResult.IsOnGround && footResult.Position.Z > newZ) - newZ = footResult.Position.Z; + const float sampleDist = 0.7f; + ReadOnlySpan<(float ox, float oy)> offsets = stackalloc (float, float)[] + { + ( forwardX * sampleDist, forwardY * sampleDist), // forward + (-forwardX * sampleDist, -forwardY * sampleDist), // back + ( forwardY * sampleDist, -forwardX * sampleDist), // right + (-forwardY * sampleDist, forwardX * sampleDist), // left + }; + foreach (var (ox, oy) in offsets) + { + var sampleResult = _physics.Resolve( + new Vector3(result.Position.X + ox, result.Position.Y + oy, newZ), + CellId, Vector3.Zero, StepUpHeight); + if (sampleResult.IsOnGround && sampleResult.Position.Z > newZ) + newZ = sampleResult.Position.Z; + } } _prevGroundZ = newZ; - Position = new Vector3(result.Position.X, result.Position.Y, newZ + 0.1f); + Position = new Vector3(result.Position.X, result.Position.Y, newZ); CellId = result.CellId; // 4. Determine current motion commands. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8bc256f..cb1e218 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1757,8 +1757,17 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); _terrain?.Draw(camera, frustum); + // Never cull the landblock the player is currently on. + uint? playerLb = null; + if (_playerMode && _playerController is not null) + { + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + } _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, - alwaysVisibleEntityId: _playerMode ? _playerServerGuid : null); + neverCullLandblockId: playerLb); // Count visible vs total for the perf overlay. foreach (var entry in _worldState.LandblockEntries) diff --git a/src/AcDream.App/Rendering/StaticMeshRenderer.cs b/src/AcDream.App/Rendering/StaticMeshRenderer.cs index 635e796..f201338 100644 --- a/src/AcDream.App/Rendering/StaticMeshRenderer.cs +++ b/src/AcDream.App/Rendering/StaticMeshRenderer.cs @@ -79,7 +79,7 @@ public sealed unsafe class StaticMeshRenderer : IDisposable public void Draw(ICamera camera, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList Entities)> landblockEntries, FrustumPlanes? frustum = null, - uint? alwaysVisibleEntityId = null) + uint? neverCullLandblockId = null) { _shader.Use(); _shader.SetMatrix4("uView", camera.View); @@ -90,21 +90,11 @@ public sealed unsafe class StaticMeshRenderer : IDisposable // alpha-discard path in the fragment shader (uTranslucencyKind == 1). foreach (var entry in landblockEntries) { - // Per-landblock frustum cull: one AABB test skips all entities in - // this landblock if it is fully outside the view frustum. - // Exception: never cull a landblock containing the player entity. + // Per-landblock frustum cull. Never cull the player's landblock. if (frustum is not null && + entry.LandblockId != neverCullLandblockId && !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) - { - // Check if this landblock contains the always-visible entity. - bool hasAlwaysVisible = false; - if (alwaysVisibleEntityId is not null) - { - foreach (var e in entry.Entities) - if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; } - } - if (!hasAlwaysVisible) continue; - } + continue; foreach (var entity in entry.Entities) { @@ -179,18 +169,11 @@ public sealed unsafe class StaticMeshRenderer : IDisposable foreach (var entry in landblockEntries) { - // Same per-landblock frustum cull + always-visible exception for pass 2. + // Same per-landblock frustum cull for pass 2. if (frustum is not null && + entry.LandblockId != neverCullLandblockId && !FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax)) - { - bool hasAlwaysVisible = false; - if (alwaysVisibleEntityId is not null) - { - foreach (var e in entry.Entities) - if (e.Id == alwaysVisibleEntityId.Value) { hasAlwaysVisible = true; break; } - } - if (!hasAlwaysVisible) continue; - } + continue; foreach (var entity in entry.Entities) { diff --git a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs index fed7c26..b090996 100644 --- a/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs @@ -137,7 +137,7 @@ public class PlayerMovementControllerTests Assert.False(controller.IsAirborne, "Should have landed"); // +0.15 Z bias keeps feet above terrain surface (prevents z-fighting). - Assert.Equal(50.1f, controller.Position.Z, precision: 1); + Assert.Equal(50f, controller.Position.Z, precision: 1); } [Fact] @@ -177,6 +177,6 @@ public class PlayerMovementControllerTests controller.Update(0.05f, new MovementInput(Forward: true)); Assert.False(controller.IsAirborne, "Player should have landed"); - Assert.Equal(20.1f, controller.Position.Z, precision: 1); + Assert.Equal(20f, controller.Position.Z, precision: 1); } }