From f0b3bd9aa2d523879323971da6666e9c9e64ca0a Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 13 May 2026 17:41:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(B.4b):=20WorldPicker.BuildRay=20=E2=80=94?= =?UTF-8?q?=20mouse-to-world=20ray=20unprojection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New AcDream.Core.Selection.WorldPicker static helper. BuildRay unprojects pixel (mouseX, mouseY) through a view+projection matrix pair into a world-space (origin, direction) ray. Used by GameWindow.OnInputAction to drive entity picking on click. Pure math, no state, no DI. Composes view*projection (System.Numerics row-vector convention, matching the rest of acdream's camera path — see GameWindow.cs:6445 FrustumPlanes.FromViewProjection). 2 xUnit tests cover center-of-viewport (forward ray) and right-of-center (positive-X deflection). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Selection/WorldPicker.cs | 55 +++++++++++++++++++ .../Selection/WorldPickerTests.cs | 54 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 src/AcDream.Core/Selection/WorldPicker.cs create mode 100644 tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs diff --git a/src/AcDream.Core/Selection/WorldPicker.cs b/src/AcDream.Core/Selection/WorldPicker.cs new file mode 100644 index 0000000..ae3f0cb --- /dev/null +++ b/src/AcDream.Core/Selection/WorldPicker.cs @@ -0,0 +1,55 @@ +using System.Numerics; +using AcDream.Core.World; + +namespace AcDream.Core.Selection; + +/// +/// Mouse-to-entity picker. Pure static functions; no state, no DI. +/// +/// turns a pixel + view/projection into a world-space ray. +/// ray-sphere intersects against entity candidates and returns the nearest hit's ServerGuid. +/// +/// Used by GameWindow.OnInputAction to wire SelectLeft / SelectDblLeft / UseSelected to InteractRequests.BuildUse. +/// +public static class WorldPicker +{ + /// + /// 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). + /// + /// + /// (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. + /// + public static (Vector3 Origin, Vector3 Direction) BuildRay( + 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)) + 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) + 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) + return (Vector3.Zero, Vector3.Zero); + return (nearWorld, Vector3.Normalize(dir)); + } +} diff --git a/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs b/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs new file mode 100644 index 0000000..5f55161 --- /dev/null +++ b/tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs @@ -0,0 +1,54 @@ +using System; +using System.Numerics; +using AcDream.Core.Selection; +using Xunit; + +namespace AcDream.Core.Tests.Selection; + +public class WorldPickerTests +{ + private const float Epsilon = 0.01f; + + private static (Matrix4x4 View, Matrix4x4 Projection) MakeIdentityCamera() + { + var view = Matrix4x4.Identity; + var proj = Matrix4x4.CreatePerspectiveFieldOfView( + fieldOfView: MathF.PI / 3f, + aspectRatio: 16f / 9f, + nearPlaneDistance: 0.1f, + farPlaneDistance: 100f); + return (view, proj); + } + + [Fact] + public void BuildRay_CenterOfViewport_ReturnsForwardRay() + { + var (view, proj) = MakeIdentityCamera(); + const float vpW = 1920f, vpH = 1080f; + + var (_, direction) = WorldPicker.BuildRay( + mouseX: vpW / 2f, mouseY: vpH / 2f, + viewportW: vpW, viewportH: vpH, + view, proj); + + // Right-handed perspective + identity view -> camera looks down -Z. + // Center pixel ray = (0, 0, -1) within float epsilon. + Assert.True(MathF.Abs(direction.X) < Epsilon, $"direction.X = {direction.X}"); + Assert.True(MathF.Abs(direction.Y) < Epsilon, $"direction.Y = {direction.Y}"); + Assert.True(direction.Z < -0.99f, $"direction.Z = {direction.Z}"); + } + + [Fact] + public void BuildRay_OffsetMouseRight_DeflectsRayPositiveX() + { + var (view, proj) = MakeIdentityCamera(); + const float vpW = 1920f, vpH = 1080f; + + var (_, direction) = WorldPicker.BuildRay( + mouseX: vpW * 0.75f, mouseY: vpH / 2f, + viewportW: vpW, viewportH: vpH, + view, proj); + + Assert.True(direction.X > 0.1f, $"direction.X = {direction.X} (expected > 0.1)"); + } +}