diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 13a660c..59226ad 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9131,6 +9131,17 @@ public sealed class GameWindow : IDisposable var camera = _cameraController.Active; var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y); + // Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently- + // cached EnvCell physics so the picker can occlude entities behind + // walls. Snapshot is per-pick (one click), iteration is bounded + // by the streaming radius (~80 cells at radius 4). + var loadedCellPhysics = new List(); + foreach (var cellId in _physicsDataCache.CellStructIds) + { + var cp = _physicsDataCache.GetCellStruct(cellId); + if (cp is not null) loadedCellPhysics.Add(cp); + } + var picked = AcDream.Core.Selection.WorldPicker.Pick( mouseX: _lastMouseX, mouseY: _lastMouseY, view: camera.View, projection: camera.Projection, @@ -9153,7 +9164,11 @@ public sealed class GameWindow : IDisposable // Match the indicator's TriangleSize (8 px) so the click area // extends out to the bracket corners — what the user perceives // as "selectable extent." - inflatePixels: 8f); + inflatePixels: 8f, + cellOccluder: loadedCellPhysics.Count > 0 + ? (origin, direction) => + AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics) + : null); if (picked is uint guid) { diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index 771f208..ca5c81e 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -210,6 +210,15 @@ public sealed class PhysicsDataCache public int SetupCount => _setup.Count; public int CellStructCount => _cellStruct.Count; + /// + /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached + /// EnvCell ids — used by + /// to enumerate occluder candidates without exposing the underlying + /// dictionary. Returns the live key-set; callers should snapshot the + /// collection if they need stability across frames. + /// + public IReadOnlyCollection CellStructIds => (IReadOnlyCollection)_cellStruct.Keys; + /// /// Register a pre-built directly. /// Intended for unit-test fixtures that construct synthetic BSP trees @@ -285,9 +294,15 @@ public sealed class SetupPhysics /// public sealed class CellPhysics { - public required PhysicsBSPTree BSP { get; init; } - public required Dictionary PhysicsPolygons { get; init; } - public required VertexArray Vertices { get; init; } + /// + /// The physics BSP tree for this cell. Nullable so that test fixtures + /// can construct a from + /// alone without needing a real DAT BSP object. Production code must + /// null-check before traversal: cell.BSP?.Root is not null. + /// + public PhysicsBSPTree? BSP { get; init; } + public Dictionary? PhysicsPolygons { get; init; } + public VertexArray? Vertices { get; init; } public Matrix4x4 WorldTransform { get; init; } public Matrix4x4 InverseWorldTransform { get; init; } diff --git a/src/AcDream.Core/Selection/CellBspRayOccluder.cs b/src/AcDream.Core/Selection/CellBspRayOccluder.cs new file mode 100644 index 0000000..49d5283 --- /dev/null +++ b/src/AcDream.Core/Selection/CellBspRayOccluder.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using AcDream.Core.Physics; + +namespace AcDream.Core.Selection; + +/// +/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon +/// occlusion test. Given a ray and a set of +/// (currently-loaded EnvCells with resolved polygon planes), returns +/// the nearest world-space t along the ray that hits any cell +/// polygon — or if the ray clears +/// all cells. +/// +/// +/// Used by to filter entities that sit +/// behind a wall from the camera's POV (issue #86). Möller-Trumbore +/// ray-triangle intersection; one test per triangle. Cells are +/// transformed via their +/// so the ray runs in cell-local space and the resolved-polygon +/// vertices don't need re-transformation per query. +/// +/// +/// +/// No BSP traversal — iterates every polygon in every cell. Cell count +/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys +/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on +/// modern hardware; one Pick call is well under 1 ms. +/// +/// +public static class CellBspRayOccluder +{ + /// + /// Returns the nearest positive t such that + /// origin + t * direction intersects a polygon in any cell. + /// Returns if no cell polygon + /// is intersected. + /// + /// Need not be normalized; returned t + /// scales with direction length the same as a parametric ray. + public static float NearestWallT( + Vector3 origin, + Vector3 direction, + IEnumerable loadedCells) + { + if (loadedCells is null) return float.PositiveInfinity; + + float bestT = float.PositiveInfinity; + foreach (var cell in loadedCells) + { + if (cell?.Resolved is null) continue; + + // Bring the ray into cell-local space ONCE per cell. + var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform); + var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform); + + foreach (var (_, poly) in cell.Resolved) + { + // Triangulate the (possibly polygonal) face into a fan. + int n = poly.NumPoints; + if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n) + continue; + + for (int i = 1; i < n - 1; i++) + { + if (TryRayTriangle( + localOrigin, localDirection, + poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1], + out var t) + && t < bestT) + { + bestT = t; + } + } + } + } + return bestT; + } + + /// + /// Möller-Trumbore ray-triangle intersection. Returns true with + /// t in if the ray hits the triangle + /// at a positive distance. + /// + private static bool TryRayTriangle( + Vector3 origin, Vector3 direction, + Vector3 v0, Vector3 v1, Vector3 v2, + out float t) + { + const float Epsilon = 1e-7f; + + var edge1 = v1 - v0; + var edge2 = v2 - v0; + var pvec = Vector3.Cross(direction, edge2); + float det = Vector3.Dot(edge1, pvec); + + // No two-sided handling here — picker should be permissive so + // a wall blocks regardless of which side the camera is on. + if (det > -Epsilon && det < Epsilon) { t = 0f; return false; } + float invDet = 1f / det; + + var tvec = origin - v0; + float u = Vector3.Dot(tvec, pvec) * invDet; + if (u < 0f || u > 1f) { t = 0f; return false; } + + var qvec = Vector3.Cross(tvec, edge1); + float v = Vector3.Dot(direction, qvec) * invDet; + if (v < 0f || u + v > 1f) { t = 0f; return false; } + + t = Vector3.Dot(edge2, qvec) * invDet; + return t > Epsilon; + } +} diff --git a/src/AcDream.Core/Selection/WorldPicker.cs b/src/AcDream.Core/Selection/WorldPicker.cs index f95a93f..2b6fc67 100644 --- a/src/AcDream.Core/Selection/WorldPicker.cs +++ b/src/AcDream.Core/Selection/WorldPicker.cs @@ -91,13 +91,20 @@ public static class WorldPicker uint skipServerGuid, float maxDistance = 50f, Func? radiusForGuid = null, - Func? verticalOffsetForGuid = null) + Func? verticalOffsetForGuid = null, + Func? cellOccluder = null) { const float DefaultRadius = 1.0f; const float DefaultVerticalOffset = 0.9f; if (direction.LengthSquared() < 1e-10f) return null; + // Indoor walking Phase 1 #86 (2026-05-19): if the caller provides + // a cell-BSP occluder, query the nearest wall hit along the ray + // ONCE; entities whose ray-t exceeds the wall-t sit behind a wall + // and are skipped. + float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity; + uint? bestGuid = null; float bestT = float.PositiveInfinity; foreach (var entity in candidates) @@ -150,6 +157,7 @@ public static class WorldPicker if (t < 0f) t = -b + sqrtD; // origin inside sphere -> use far exit if (t < 0f) continue; // both roots negative -> sphere entirely behind ray if (t >= maxDistance) continue; + if (t >= wallT) continue; // wall is between camera and entity (#86) if (t < bestT) { bestT = t; @@ -207,11 +215,39 @@ public static class WorldPicker IEnumerable candidates, uint skipServerGuid, Func sphereForEntity, - float inflatePixels = 8f) + float inflatePixels = 8f, + Func? cellOccluder = null) { uint? bestGuid = null; float bestDepth = float.PositiveInfinity; + // Indoor walking Phase 1 #86 (2026-05-19): cell-BSP occlusion. + // Build the click ray, query the nearest wall along it, convert + // to the same camera-space depth metric (clip.W) that + // ScreenProjection.TryProjectSphereToScreenRect returns per + // candidate. Candidates with depth > wallDepth sit behind a wall. + float wallDepth = float.PositiveInfinity; + if (cellOccluder is not null) + { + var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection); + if (rayDir.LengthSquared() > 0f) + { + float wallT = cellOccluder(rayOrigin, rayDir); + if (!float.IsPositiveInfinity(wallT)) + { + var wallPoint = rayOrigin + rayDir * wallT; + // ScreenProjection uses clip.W as its depth metric — + // "camera-space depth" in the row-vector convention is + // the W component of the homogeneous clip-space vector, + // which equals the eye-space Z distance to the point. + var viewProj = view * projection; + var clip = Vector4.Transform(new Vector4(wallPoint, 1f), viewProj); + if (clip.W > 0f) + wallDepth = clip.W; + } + } + } + foreach (var entity in candidates) { if (entity.ServerGuid == 0u) continue; @@ -237,6 +273,8 @@ public static class WorldPicker if (mouseX < minX || mouseX > maxX) continue; if (mouseY < minY || mouseY > maxY) continue; + if (depth > wallDepth) continue; // wall is between camera and entity (#86) + if (depth < bestDepth) { bestDepth = depth; diff --git a/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs b/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs new file mode 100644 index 0000000..3846663 --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using DatReaderWriter.Enums; +using Xunit; + +namespace AcDream.Core.Tests.Selection; + +public class CellBspRayOccluderTests +{ + // Build a CellPhysics with a single triangular poly at world-Y=10. + // Triangle vertices in local space, world transform = identity. + // Uses the Resolved-only constructor path (BSP = null is allowed after Phase 1 relaxation). + private static CellPhysics MakeWallCell() + { + var verts = new[] + { + new Vector3(-5, 10, 0), + new Vector3( 5, 10, 0), + new Vector3( 0, 10, 5), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f), + NumPoints = 3, + SidesType = CullMode.None, + }; + return new CellPhysics + { + BSP = null, // Occluder doesn't use BSP — direct poly iteration. + Resolved = new() { [0] = poly }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + } + + [Fact] + public void NearestWallT_RayHitsTriangle_ReturnsHitDistance() + { + var cell = MakeWallCell(); + var origin = new Vector3(0, 0, 1); + var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10 + float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell }); + Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}"); + } + + [Fact] + public void NearestWallT_RayMisses_ReturnsPositiveInfinity() + { + var cell = MakeWallCell(); + var origin = new Vector3(0, 0, 1); + var direction = -Vector3.UnitY; // travels AWAY from the wall + float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell }); + Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}"); + } + + [Fact] + public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity() + { + var origin = Vector3.Zero; + var direction = Vector3.UnitY; + float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty()); + Assert.True(float.IsPositiveInfinity(t)); + } + + [Fact] + public void NearestWallT_TwoCells_ReturnsNearer() + { + var nearCell = MakeWallCell(); // wall at Y=10 + var farCell = MakeWallCell(); + // Move farCell's transform to push it to Y=20. + farCell = new CellPhysics + { + BSP = null, + Resolved = nearCell.Resolved, + WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0), + InverseWorldTransform = Matrix4x4.CreateTranslation(0, -10, 0), + }; + + var origin = new Vector3(0, 0, 1); + var direction = Vector3.UnitY; + float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell }); + Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}"); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs new file mode 100644 index 0000000..af80446 --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs @@ -0,0 +1,81 @@ +using System.Numerics; +using AcDream.Core.Physics; +using AcDream.Core.Selection; +using AcDream.Core.World; +using DatReaderWriter.Enums; +using Xunit; + +namespace AcDream.Core.Tests.Selection; + +public class WorldPickerCellOcclusionTests +{ + private static CellPhysics MakeWallAtY10() + { + // A quad wall at Y=10 spanning X=-5..5, Z=-5..5 (local space = world space + // because WorldTransform = Identity). The occluder triangulates it as a fan: + // tri0 = [0,1,2], tri1 = [0,2,3]. A ray travelling +Y from Y=0 hits it at t≈10. + var verts = new[] + { + new Vector3(-5, 10, -5), + new Vector3( 5, 10, -5), + new Vector3( 5, 10, 5), + new Vector3(-5, 10, 5), + }; + var poly = new ResolvedPolygon + { + Vertices = verts, + Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f), + NumPoints = 4, + SidesType = CullMode.None, + }; + return new CellPhysics + { + BSP = null, + Resolved = new() { [0] = poly }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + }; + } + + private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new() + { + Id = guid, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0, + Position = pos, + Rotation = Quaternion.Identity, + MeshRefs = System.Array.Empty(), + }; + + [Fact] + public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp() + { + var wall = MakeWallAtY10(); + var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10 + + var result = WorldPicker.Pick( + origin: Vector3.Zero, + direction: Vector3.UnitY, + candidates: new[] { entity }, + skipServerGuid: 0u, + cellOccluder: (origin, direction) => + CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall })); + + Assert.Null(result); + } + + [Fact] + public void Pick_RaySphere_NoWall_HitsEntity() + { + var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); + + var result = WorldPicker.Pick( + origin: Vector3.Zero, + direction: Vector3.UnitY, + candidates: new[] { entity }, + skipServerGuid: 0u, + cellOccluder: null); // null occluder = no occlusion + + Assert.Equal(0xABCDu, result); + } +}