diff --git a/.changeset/fix-pointer-ray-origin.md b/.changeset/fix-pointer-ray-origin.md new file mode 100644 index 0000000..826a9d5 --- /dev/null +++ b/.changeset/fix-pointer-ray-origin.md @@ -0,0 +1,5 @@ +--- +"react-three-map": patch +--- + +Fix hover/raycast inaccuracy by deriving the pointer-event ray origin from the cursor position. The ray origin was being unprojected from a fixed NDC point that ignored the cursor, producing a skewed ray whose error grew towards the edges of the map. diff --git a/src/core/compute-ray.ts b/src/core/compute-ray.ts new file mode 100644 index 0000000..063e719 --- /dev/null +++ b/src/core/compute-ray.ts @@ -0,0 +1,19 @@ +import { Matrix4, Ray } from "three"; + +/** + * Builds a raycaster ray from the pointer position by unprojecting NDC points + * through the inverted projection*view matrix. + * + * Both the origin (near plane) and direction (towards the far plane) are derived + * from the pointer, so the resulting ray is the geometrically correct line + * through the camera eye for any cursor position — the same near→far scheme + * three.js uses in `Raycaster.setFromCamera`. + */ +export function computeRay(ray: Ray, pointerX: number, pointerY: number, projViewInv: Matrix4) { + ray.origin.set(pointerX, pointerY, -1).applyMatrix4(projViewInv); + ray.direction + .set(pointerX, pointerY, 1) + .applyMatrix4(projViewInv) + .sub(ray.origin) + .normalize(); +} diff --git a/src/core/events.ts b/src/core/events.ts index 3eae86e..b7a7aab 100644 --- a/src/core/events.ts +++ b/src/core/events.ts @@ -1,5 +1,6 @@ import { Canvas, events as fiberEvents } from "@react-three/fiber"; import { Matrix4 } from "three"; +import { computeRay } from "./compute-ray"; /** projection * view matrix inverted */ const projViewInv = new Matrix4() @@ -22,12 +23,7 @@ export const events: Events = (store) => { if (state.camera.userData.projByViewInv) projViewInv.fromArray(state.camera.userData.projByViewInv); state.raycaster.camera = state.camera; - state.raycaster.ray.origin.setScalar(0).applyMatrix4(projViewInv); - state.raycaster.ray.direction - .set(state.pointer.x, state.pointer.y, 1) - .applyMatrix4(projViewInv) - .sub(state.raycaster.ray.origin) - .normalize(); + computeRay(state.raycaster.ray, state.pointer.x, state.pointer.y, projViewInv); }, }; diff --git a/src/test/compute-ray.test.ts b/src/test/compute-ray.test.ts new file mode 100644 index 0000000..2bfc28d --- /dev/null +++ b/src/test/compute-ray.test.ts @@ -0,0 +1,40 @@ +import { Matrix4, PerspectiveCamera, Ray, Vector3 } from "three"; +import { expect, it } from "vitest"; +import { computeRay } from "../core/compute-ray"; + +/** + * For a perspective camera every pointer ray must pass through the camera eye. + * `computeRay` satisfies this for any cursor position by deriving the origin + * from the pointer; the previous fixed origin (`setScalar(0)`) only did so at + * the dead centre of the view, which is the hover/raycast offset bug. + */ +it("computeRay builds a ray that passes through the perspective camera eye", () => { + const camera = new PerspectiveCamera(60, 1.5, 0.1, 100); + camera.position.set(3, 4, 5); + camera.lookAt(0, 0, 0); + camera.updateMatrixWorld(true); + camera.updateProjectionMatrix(); + + // projection * view, inverted — the matrix events.ts unprojects through + const projViewInv = new Matrix4() + .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) + .invert(); + + const ray = new Ray(); + // an off-centre pointer, where the old fixed origin was wrong + computeRay(ray, 0.6, -0.3, projViewInv); + + // direction is unit length + expect(ray.direction.length()).toBeCloseTo(1, 6); + + // eye is collinear with the ray: (eye - origin) is parallel to direction + const eye = camera.position.clone(); + const toEye = eye.clone().sub(ray.origin).normalize(); + expect(Math.abs(toEye.dot(ray.direction))).toBeCloseTo(1, 5); + + // regression guard: the old origin (unprojected NDC centre) is NOT on the + // ray, so it produced a skewed ray that missed the eye. + const oldOrigin = new Vector3().setScalar(0).applyMatrix4(projViewInv); + const toEyeOld = eye.clone().sub(oldOrigin).normalize(); + expect(Math.abs(toEyeOld.dot(ray.direction))).toBeLessThan(0.999); +});