Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-pointer-ray-origin.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions src/core/compute-ray.ts
Original file line number Diff line number Diff line change
@@ -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();
}
8 changes: 2 additions & 6 deletions src/core/events.ts
Original file line number Diff line number Diff line change
@@ -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()
Expand All @@ -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);

},
};
Expand Down
40 changes: 40 additions & 0 deletions src/test/compute-ray.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});