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:
parent
27d7de11d8
commit
3764867566
6 changed files with 355 additions and 6 deletions
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal file
114
src/AcDream.Core/Selection/CellBspRayOccluder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue