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:
Erik 2026-07-17 21:32:51 +02:00
parent 0f82a08f0a
commit 146a963aeb
26 changed files with 1302 additions and 1340 deletions

View file

@ -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 ≈ 11.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));
}
}