fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker

WorldPicker.Pick previously had no occlusion test — any entity along
the click ray within maxDistance was a candidate, including ones
behind walls. Adds the CellBspRayOccluder static helper that
Möller-Trumbore-tests the click ray against every polygon in every
currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
Both Pick overloads gate candidate selection by that wall-t (legacy
ray-sphere via world-space `t`, screen-rect via camera-space clip.W
depth — matching ScreenProjection.TryProjectSphereToScreenRect's
convention).

PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
caller can iterate without needing the private cache dictionary.
CellPhysics.BSP/PhysicsPolygons/Vertices relaxed from required to
nullable so test fixtures can construct a CellPhysics from Resolved
alone without a real DAT BSP object. GameWindow snapshots the loaded
cell physics on each Pick call and passes the occluder callback.

Closes #86.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-19 14:41:56 +02:00
parent 27d7de11d8
commit 3764867566
6 changed files with 355 additions and 6 deletions

View file

@ -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<AcDream.Core.Physics.CellPhysics>();
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)
{

View file

@ -210,6 +210,15 @@ public sealed class PhysicsDataCache
public int SetupCount => _setup.Count;
public int CellStructCount => _cellStruct.Count;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
/// EnvCell ids — used by <see cref="AcDream.Core.Selection.WorldPicker"/>
/// 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.
/// </summary>
public IReadOnlyCollection<uint> CellStructIds => (IReadOnlyCollection<uint>)_cellStruct.Keys;
/// <summary>
/// Register a pre-built <see cref="GfxObjPhysics"/> directly.
/// Intended for unit-test fixtures that construct synthetic BSP trees
@ -285,9 +294,15 @@ public sealed class SetupPhysics
/// </summary>
public sealed class CellPhysics
{
public required PhysicsBSPTree BSP { get; init; }
public required Dictionary<ushort, Polygon> PhysicsPolygons { get; init; }
public required VertexArray Vertices { get; init; }
/// <summary>
/// The physics BSP tree for this cell. Nullable so that test fixtures
/// can construct a <see cref="CellPhysics"/> from <see cref="Resolved"/>
/// alone without needing a real DAT BSP object. Production code must
/// null-check before traversal: <c>cell.BSP?.Root is not null</c>.
/// </summary>
public PhysicsBSPTree? BSP { get; init; }
public Dictionary<ushort, Polygon>? PhysicsPolygons { get; init; }
public VertexArray? Vertices { get; init; }
public Matrix4x4 WorldTransform { get; init; }
public Matrix4x4 InverseWorldTransform { get; init; }

View file

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Selection;
/// <summary>
/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
/// occlusion test. Given a ray and a set of <see cref="CellPhysics"/>
/// (currently-loaded EnvCells with resolved polygon planes), returns
/// the nearest world-space <c>t</c> along the ray that hits any cell
/// polygon — or <see cref="float.PositiveInfinity"/> if the ray clears
/// all cells.
///
/// <para>
/// Used by <see cref="WorldPicker.Pick"/> 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 <see cref="CellPhysics.InverseWorldTransform"/>
/// so the ray runs in cell-local space and the resolved-polygon
/// vertices don't need re-transformation per query.
/// </para>
///
/// <para>
/// 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 <c>Pick</c> call is well under 1 ms.
/// </para>
/// </summary>
public static class CellBspRayOccluder
{
/// <summary>
/// Returns the nearest positive <c>t</c> such that
/// <c>origin + t * direction</c> intersects a polygon in any cell.
/// Returns <see cref="float.PositiveInfinity"/> if no cell polygon
/// is intersected.
/// </summary>
/// <param name="direction">Need not be normalized; returned <c>t</c>
/// scales with direction length the same as a parametric ray.</param>
public static float NearestWallT(
Vector3 origin,
Vector3 direction,
IEnumerable<CellPhysics> 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;
}
/// <summary>
/// Möller-Trumbore ray-triangle intersection. Returns true with
/// <c>t</c> in <paramref name="t"/> if the ray hits the triangle
/// at a positive distance.
/// </summary>
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;
}
}

View file

@ -91,13 +91,20 @@ public static class WorldPicker
uint skipServerGuid,
float maxDistance = 50f,
Func<uint, float>? radiusForGuid = null,
Func<uint, float>? verticalOffsetForGuid = null)
Func<uint, float>? verticalOffsetForGuid = null,
Func<Vector3, Vector3, float>? 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<WorldEntity> candidates,
uint skipServerGuid,
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
float inflatePixels = 8f)
float inflatePixels = 8f,
Func<Vector3, Vector3, float>? 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;