feat(selection): port retail polygon picking and vivid marker
Replace the projected Setup-sphere rectangle and independent physics-wall ray with retail's render-coupled picker: only visible server-object parts participate, each exact drawing sphere broad-phases the camera-eye ray, and first-in-DAT-order visual polygon hits globally outrank sphere fallbacks. Replace the devtools-only procedural triangles with the retained gameplay VividTargetIndicator using retail client-enum surfaces 1..4, radar-blip colorization, Setup selection-sphere framing, and the exact eight-pixel viewport clamp. Release build succeeds with zero warnings and all 5,886 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
0f82a08f0a
commit
146a963aeb
26 changed files with 1302 additions and 1340 deletions
|
|
@ -1,114 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
32
src/AcDream.Core/Selection/RetailSelectionMesh.cs
Normal file
32
src/AcDream.Core/Selection/RetailSelectionMesh.cs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable CPU geometry used by retail's world-selection pass. One instance
|
||||
/// represents one GfxObj part: its exact drawing-BSP root sphere and its visual
|
||||
/// polygons in DAT order.
|
||||
/// </summary>
|
||||
public sealed record RetailSelectionMesh(
|
||||
Vector3 SphereCenter,
|
||||
float SphereRadius,
|
||||
IReadOnlyList<RetailSelectionPolygon> Polygons);
|
||||
|
||||
/// <summary>One visual polygon. Vertex order and one/two-sidedness are DAT-authored.</summary>
|
||||
public sealed record RetailSelectionPolygon(
|
||||
IReadOnlyList<Vector3> Vertices,
|
||||
bool SingleSided);
|
||||
|
||||
/// <summary>One part which survived the normal world-render visibility traversal.</summary>
|
||||
public readonly record struct RetailSelectionPart(
|
||||
uint ServerGuid,
|
||||
int PartIndex,
|
||||
Matrix4x4 LocalToWorld,
|
||||
RetailSelectionMesh Mesh);
|
||||
|
||||
/// <summary>Retail picker result, including which physics part supplied the hit.</summary>
|
||||
public readonly record struct RetailSelectionHit(
|
||||
uint ServerGuid,
|
||||
int PartIndex,
|
||||
double Distance,
|
||||
bool PolygonHit);
|
||||
181
src/AcDream.Core/Selection/RetailWorldPicker.cs
Normal file
181
src/AcDream.Core/Selection/RetailWorldPicker.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
using System.Numerics;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Pure port of retail's render-coupled mouse selection accumulator.
|
||||
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> broad-phases each
|
||||
/// visible part against its drawing sphere, then scans visual polygons in DAT
|
||||
/// order. Any polygon hit globally outranks every sphere-only fallback.
|
||||
/// </summary>
|
||||
public static class RetailWorldPicker
|
||||
{
|
||||
private const double RetailRayEpsilon = 0.0002;
|
||||
|
||||
public static RetailSelectionHit? Pick(
|
||||
Vector3 worldOrigin,
|
||||
Vector3 worldDirection,
|
||||
IEnumerable<RetailSelectionPart> visibleParts,
|
||||
uint skipServerGuid = 0u)
|
||||
{
|
||||
if (worldDirection.LengthSquared() < 1e-10f)
|
||||
return null;
|
||||
|
||||
RetailSelectionHit? closestSphere = null;
|
||||
RetailSelectionHit? closestPolygon = null;
|
||||
|
||||
foreach (var part in visibleParts)
|
||||
{
|
||||
if (part.ServerGuid == 0u || part.ServerGuid == skipServerGuid)
|
||||
continue;
|
||||
if (part.Mesh.SphereRadius <= 0f
|
||||
|| !Matrix4x4.Invert(part.LocalToWorld, out var worldToLocal))
|
||||
continue;
|
||||
|
||||
// Keep direction unnormalised after the affine inverse. With row-vector
|
||||
// transforms this preserves the same ray parameter t in world metres even
|
||||
// when the part carries scale (retail divides by gfxobj_scale likewise).
|
||||
Vector3 localOrigin = Vector3.Transform(worldOrigin, worldToLocal);
|
||||
Vector3 localDirection = Vector3.TransformNormal(worldDirection, worldToLocal);
|
||||
|
||||
if (!TryIntersectSphere(
|
||||
localOrigin,
|
||||
localDirection,
|
||||
part.Mesh.SphereCenter,
|
||||
part.Mesh.SphereRadius,
|
||||
out double sphereT))
|
||||
continue;
|
||||
|
||||
// Retail skips a part whose broad sphere starts beyond an already-found
|
||||
// polygon, because that part cannot improve the global polygon winner.
|
||||
if (closestPolygon is { } polygonWinner && sphereT > polygonWinner.Distance)
|
||||
continue;
|
||||
|
||||
if (closestSphere is null || sphereT < closestSphere.Value.Distance)
|
||||
closestSphere = new RetailSelectionHit(
|
||||
part.ServerGuid, part.PartIndex, sphereT, PolygonHit: false);
|
||||
|
||||
// Retail stops at the FIRST hit polygon in this part's stored flat order.
|
||||
foreach (var polygon in part.Mesh.Polygons)
|
||||
{
|
||||
if (!TryIntersectPolygon(localOrigin, localDirection, polygon, out double polygonT))
|
||||
continue;
|
||||
|
||||
if (closestPolygon is null || polygonT < closestPolygon.Value.Distance)
|
||||
closestPolygon = new RetailSelectionHit(
|
||||
part.ServerGuid, part.PartIndex, polygonT, PolygonHit: true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return closestPolygon ?? closestSphere;
|
||||
}
|
||||
|
||||
internal static bool TryIntersectSphere(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
Vector3 center,
|
||||
float radius,
|
||||
out double distance)
|
||||
{
|
||||
// CSphere::sphere_intersects_ray @ 0x005377A0. Retail intentionally
|
||||
// declines a broad-phase hit when the ray begins in or on the sphere.
|
||||
// The render view-cone normally keeps selectable objects in front of
|
||||
// the camera, so the routine does not separately reject a negative t.
|
||||
distance = 0d;
|
||||
Vector3 offset = origin - center;
|
||||
double c = Vector3.Dot(offset, offset) - (double)radius * radius;
|
||||
if (c <= 0d)
|
||||
return false;
|
||||
|
||||
double a = Vector3.Dot(direction, direction);
|
||||
if (a < RetailRayEpsilon)
|
||||
return false;
|
||||
|
||||
double b = -Vector3.Dot(offset, direction);
|
||||
double discriminant = b * b - c * a;
|
||||
if (discriminant < 0d)
|
||||
return false;
|
||||
|
||||
double root = Math.Sqrt(discriminant);
|
||||
distance = b > root ? (b - root) / a : (b + root) / a;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool TryIntersectPolygon(
|
||||
Vector3 origin,
|
||||
Vector3 direction,
|
||||
RetailSelectionPolygon polygon,
|
||||
out double distance)
|
||||
{
|
||||
distance = 0d;
|
||||
if (polygon.Vertices.Count < 3
|
||||
|| !TryPlane(polygon.Vertices, out Vector3 normal, out float planeD))
|
||||
return false;
|
||||
|
||||
double denominator = Vector3.Dot(direction, normal);
|
||||
// CPolygon::polygon_hits_ray @ 0x005395E0: raw sides_type 0 is
|
||||
// single-sided and rejects a ray travelling with the positive normal.
|
||||
if (polygon.SingleSided && denominator > 0d)
|
||||
return false;
|
||||
if (Math.Abs(denominator) < RetailRayEpsilon)
|
||||
return false;
|
||||
|
||||
distance = -(Vector3.Dot(origin, normal) + planeD) / denominator;
|
||||
if (distance < 0d)
|
||||
return false;
|
||||
|
||||
Vector3 point = origin + direction * (float)distance;
|
||||
return PointInPolygon(point, polygon.Vertices, normal);
|
||||
}
|
||||
|
||||
private static bool TryPlane(
|
||||
IReadOnlyList<Vector3> vertices,
|
||||
out Vector3 normal,
|
||||
out float planeD)
|
||||
{
|
||||
// CPolygon::make_plane @ 0x005383D0 builds a triangle fan from
|
||||
// vertex zero, sums the fan normals, normalizes once, then chooses d
|
||||
// from the average signed distance of every vertex. DatReaderWriter
|
||||
// exposes vertices rather than retail's derived Plane, so reconstruct
|
||||
// that load-time result here.
|
||||
Vector3 first = vertices[0];
|
||||
Vector3 normalSum = Vector3.Zero;
|
||||
for (int i = 1; i + 1 < vertices.Count; i++)
|
||||
normalSum += Vector3.Cross(vertices[i] - first, vertices[i + 1] - first);
|
||||
|
||||
if (normalSum.LengthSquared() > 1e-12f)
|
||||
{
|
||||
normal = Vector3.Normalize(normalSum);
|
||||
double averageDot = 0d;
|
||||
foreach (Vector3 vertex in vertices)
|
||||
averageDot += Vector3.Dot(normal, vertex);
|
||||
planeD = (float)-(averageDot / vertices.Count);
|
||||
return true;
|
||||
}
|
||||
|
||||
normal = default;
|
||||
planeD = 0f;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool PointInPolygon(
|
||||
Vector3 point,
|
||||
IReadOnlyList<Vector3> vertices,
|
||||
Vector3 normal)
|
||||
{
|
||||
// CPolygon::point_in_polygon @ 0x00538D90. Retail visual polygons are
|
||||
// convex: the point must remain on the inward side of every ordered
|
||||
// edge. Zero is accepted, so a click exactly on an edge still hits.
|
||||
Vector3 previous = vertices[^1];
|
||||
for (int i = 0; i < vertices.Count; i++)
|
||||
{
|
||||
Vector3 current = vertices[i];
|
||||
Vector3 inward = Vector3.Cross(normal, current - previous);
|
||||
if (Vector3.Dot(point - previous, inward) < 0f)
|
||||
return false;
|
||||
previous = current;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,286 +1,54 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.Core.Selection;
|
||||
|
||||
/// <summary>
|
||||
/// Mouse-to-entity picker. Pure static functions; no state, no DI.
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="BuildRay"/> turns a pixel + view/projection into a world-space ray.</item>
|
||||
/// <item><see cref="Pick"/> ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid.</item>
|
||||
/// </list>
|
||||
/// Used by <c>GameWindow.OnInputAction</c> to wire SelectLeft / SelectDblLeft / UseSelected to <c>InteractRequests.BuildUse</c>.
|
||||
/// Pure mouse-pixel to world-ray conversion shared by retail selection paths.
|
||||
/// </summary>
|
||||
public static class WorldPicker
|
||||
{
|
||||
/// <summary>
|
||||
/// Unprojects a pixel coordinate to a world-space ray using the supplied
|
||||
/// view + projection matrices (System.Numerics row-vector convention,
|
||||
/// composed as view * projection — same as the rest of acdream's camera
|
||||
/// pipeline; see GameWindow.cs:6445 FrustumPlanes.FromViewProjection).
|
||||
/// Unprojects a pixel coordinate to a world-space ray using System.Numerics'
|
||||
/// row-vector convention (<c>view * projection</c>).
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// (origin = world point on the near plane, direction = normalized
|
||||
/// world-space ray direction). Returns (Vector3.Zero, Vector3.Zero)
|
||||
/// if the view-projection composition is singular.
|
||||
/// The camera viewpoint and normalized direction, or two zero vectors
|
||||
/// when the camera transform is singular.
|
||||
/// </returns>
|
||||
public static (Vector3 Origin, Vector3 Direction) BuildRay(
|
||||
float mouseX, float mouseY,
|
||||
float viewportW, float viewportH,
|
||||
Matrix4x4 view, Matrix4x4 projection)
|
||||
float mouseX,
|
||||
float mouseY,
|
||||
float viewportW,
|
||||
float viewportH,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection)
|
||||
{
|
||||
// Pixel -> NDC. y flipped: top-left pixel maps to ndc.y = +1.
|
||||
float ndcX = (2f * mouseX) / viewportW - 1f;
|
||||
float ndcY = 1f - (2f * mouseY) / viewportH;
|
||||
|
||||
var vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out var invVp))
|
||||
Matrix4x4 vp = view * projection;
|
||||
if (!Matrix4x4.Invert(vp, out Matrix4x4 invVp)
|
||||
|| !Matrix4x4.Invert(view, out Matrix4x4 invView))
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
// Unproject near (ndc.z = -1) and far (ndc.z = +1) clip points.
|
||||
var nearClip = new Vector4(ndcX, ndcY, -1f, 1f);
|
||||
var farClip = new Vector4(ndcX, ndcY, +1f, 1f);
|
||||
var n4 = Vector4.Transform(nearClip, invVp);
|
||||
var f4 = Vector4.Transform(farClip, invVp);
|
||||
if (n4.W == 0f || f4.W == 0f)
|
||||
Vector4 nearClip = new(ndcX, ndcY, -1f, 1f);
|
||||
Vector4 farClip = new(ndcX, ndcY, 1f, 1f);
|
||||
Vector4 near = Vector4.Transform(nearClip, invVp);
|
||||
Vector4 far = Vector4.Transform(farClip, invVp);
|
||||
if (near.W == 0f || far.W == 0f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
|
||||
var nearWorld = new Vector3(n4.X, n4.Y, n4.Z) / n4.W;
|
||||
var farWorld = new Vector3(f4.X, f4.Y, f4.Z) / f4.W;
|
||||
var dir = farWorld - nearWorld;
|
||||
if (dir.LengthSquared() < 1e-10f)
|
||||
Vector3 nearWorld = new Vector3(near.X, near.Y, near.Z) / near.W;
|
||||
Vector3 farWorld = new Vector3(far.X, far.Y, far.Z) / far.W;
|
||||
Vector3 direction = farWorld - nearWorld;
|
||||
if (direction.LengthSquared() < 1e-10f)
|
||||
return (Vector3.Zero, Vector3.Zero);
|
||||
return (nearWorld, Vector3.Normalize(dir));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ray-sphere intersection against each candidate's <see cref="WorldEntity.Position"/>
|
||||
/// using a fixed 0.7 m sphere radius. Returns the <see cref="WorldEntity.ServerGuid"/>
|
||||
/// of the closest hit within <paramref name="maxDistance"/>, or null on miss.
|
||||
/// </summary>
|
||||
/// <param name="direction">
|
||||
/// World-space ray direction. <b>Must be normalized</b> — the geometric
|
||||
/// ray-sphere formula simplifies <c>a = dot(direction, direction)</c> to
|
||||
/// <c>1</c>; non-unit input produces an undocumented <c>t</c>-scale that
|
||||
/// makes <c>maxDistance</c> compare against ray-parameter units instead
|
||||
/// of world meters.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Entities with <c>ServerGuid == 0</c> (atlas-tier scenery, dat-hydrated
|
||||
/// statics) are skipped — they have no server-side identity and can't be
|
||||
/// the target of a Use packet. The player's own guid is skipped via
|
||||
/// <paramref name="skipServerGuid"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Radius history (Issue #59).</b> Started at 5 m as a forgiving default;
|
||||
/// in practice this over-picked massively — any cursor anywhere near an
|
||||
/// NPC selected the NPC instead of a nearby item, and "click empty
|
||||
/// ground to deselect" was nearly impossible. Tightened to 0.7 m on
|
||||
/// 2026-05-15 to roughly match the actual hitbox radius of humanoids +
|
||||
/// most items. A future refinement is per-itemType radius (smaller for
|
||||
/// tapers, bigger for shop chests) or priority sorting (items beat
|
||||
/// NPCs at equal hit-distance).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static uint? Pick(
|
||||
Vector3 origin, Vector3 direction,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
float maxDistance = 50f,
|
||||
Func<uint, float>? radiusForGuid = 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)
|
||||
{
|
||||
if (entity.ServerGuid == 0u) continue;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
// Per-entity radius + vertical offset (caller-supplied).
|
||||
//
|
||||
// <para>
|
||||
// <b>Vertical offset (2026-05-15).</b> WorldEntity.Position
|
||||
// is at the entity's feet (Z=ground for a humanoid). User
|
||||
// clicks usually land on chest/head (Z ≈ 1–1.8 m). With the
|
||||
// sphere centred at feet, a chest click is 1.2 m of vertical
|
||||
// distance from sphere centre — bigger than any reasonable
|
||||
// body radius — so the ray misses. Lifting the sphere
|
||||
// centre to mid-body fixes this: 0.9 m default for
|
||||
// humanoids, smaller for items, larger for tall objects.
|
||||
// </para>
|
||||
//
|
||||
// <para>
|
||||
// <b>Radius (2026-05-15).</b> Bumped default 0.7 → 1.0 m to
|
||||
// accommodate the new vertical-offset sphere placement
|
||||
// (chest-height sphere centre + 1.0 m radius covers from
|
||||
// shin to top-of-head for a 1.8 m humanoid).
|
||||
// </para>
|
||||
float r = radiusForGuid?.Invoke(entity.ServerGuid) ?? DefaultRadius;
|
||||
float r2 = r * r;
|
||||
float vz = verticalOffsetForGuid?.Invoke(entity.ServerGuid) ?? DefaultVerticalOffset;
|
||||
var sphereCenter = new Vector3(
|
||||
entity.Position.X,
|
||||
entity.Position.Y,
|
||||
entity.Position.Z + vz);
|
||||
|
||||
// Geometric ray-sphere: oc = origin - center, b = dot(oc, dir),
|
||||
// c = |oc|^2 - r^2, discriminant = b^2 - c. If discriminant < 0
|
||||
// the ray misses the sphere. Otherwise nearest intersection is
|
||||
// t = -b - sqrt(discriminant).
|
||||
var oc = origin - sphereCenter;
|
||||
float b = Vector3.Dot(oc, direction);
|
||||
float c = Vector3.Dot(oc, oc) - r2;
|
||||
float d = b * b - c;
|
||||
if (d < 0f) continue;
|
||||
|
||||
// Two intersection roots: t_near = -b - sqrt(d), t_far = -b + sqrt(d).
|
||||
// If t_near < 0 the ray origin is INSIDE the sphere; fall through
|
||||
// to t_far so the entity is still pickable at point-blank range.
|
||||
float sqrtD = MathF.Sqrt(d);
|
||||
float t = -b - sqrtD;
|
||||
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;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 2026-05-16. Screen-space rect-hit-test picker overload. Each
|
||||
/// candidate's world-space sphere (via <paramref name="sphereForEntity"/>)
|
||||
/// projects to a screen-space rectangle through
|
||||
/// <see cref="ScreenProjection.TryProjectSphereToScreenRect"/>. The
|
||||
/// rect is inflated by <paramref name="inflatePixels"/> on every side
|
||||
/// (matches the indicator's <c>TriangleSize</c> outer brackets) and
|
||||
/// hit-tested against the mouse pixel. Among rects that contain the
|
||||
/// mouse, the entity with the nearest camera-space depth wins.
|
||||
///
|
||||
/// <para>
|
||||
/// Why screen-space instead of world-space ray-sphere: the indicator
|
||||
/// draws a screen-space RECT. A world-space sphere projects to a
|
||||
/// screen CIRCLE inscribed in that rect — leaving the four rect
|
||||
/// corners as click dead zones. Per user feedback 2026-05-16, the
|
||||
/// click area must match the visible indicator extent exactly. By
|
||||
/// sharing the <see cref="ScreenProjection"/> helper with
|
||||
/// <c>TargetIndicatorPanel</c>, the click rect and the drawn rect
|
||||
/// cannot drift.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Resolver returning <c>null</c> skips the candidate (matches retail
|
||||
/// "no Setup → not pickable" behavior). Entities with
|
||||
/// <c>ServerGuid == 0</c> (atlas-tier scenery) and the player's own
|
||||
/// guid are also skipped.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Stage A of the picker port. Stage B (polygon refine via
|
||||
/// <c>CPolygon::polygon_hits_ray</c> 0x0054c889) remains deferred
|
||||
/// per issue #71 — only needed if visual testing surfaces a Stage A
|
||||
/// over-pick on entities whose visible mesh is well inside the
|
||||
/// indicator rect.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="inflatePixels">Pixel inflate on each side of the
|
||||
/// projected rect. Pass the indicator's <c>TriangleSize</c> (8 px)
|
||||
/// so the click area extends to where the visible bracket corners
|
||||
/// sit — the user perceives the inflated rect as the clickable area.</param>
|
||||
public static uint? Pick(
|
||||
float mouseX, float mouseY,
|
||||
Matrix4x4 view,
|
||||
Matrix4x4 projection,
|
||||
Vector2 viewport,
|
||||
IEnumerable<WorldEntity> candidates,
|
||||
uint skipServerGuid,
|
||||
Func<WorldEntity, (Vector3 CenterWorld, float Radius)?> sphereForEntity,
|
||||
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;
|
||||
if (entity.ServerGuid == skipServerGuid) continue;
|
||||
|
||||
var sphere = sphereForEntity(entity);
|
||||
if (sphere is null) continue;
|
||||
var (center, radius) = sphere.Value;
|
||||
if (radius <= 0f) continue;
|
||||
|
||||
if (!ScreenProjection.TryProjectSphereToScreenRect(
|
||||
center, radius, view, projection, viewport,
|
||||
out var rMin, out var rMax, out var depth))
|
||||
continue;
|
||||
|
||||
// Inflate by inflatePixels on each side — extend hit area to
|
||||
// where the indicator brackets sit.
|
||||
float minX = rMin.X - inflatePixels;
|
||||
float minY = rMin.Y - inflatePixels;
|
||||
float maxX = rMax.X + inflatePixels;
|
||||
float maxY = rMax.Y + inflatePixels;
|
||||
|
||||
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;
|
||||
bestGuid = entity.ServerGuid;
|
||||
}
|
||||
}
|
||||
return bestGuid;
|
||||
// Render::pick_ray @ 0x0054B610 stores a direction through the pixel;
|
||||
// GfxObjUnderSelectionRay @ 0x0054C740 pairs it with Render::viewpoint,
|
||||
// not the near-plane point. Inverse-view origin is that viewpoint in
|
||||
// System.Numerics' row-vector convention.
|
||||
Vector3 viewpoint = Vector3.Transform(Vector3.Zero, invView);
|
||||
return (viewpoint, Vector3.Normalize(direction));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue