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>
114 lines
4.2 KiB
C#
114 lines
4.2 KiB
C#
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;
|
||
}
|
||
}
|