Zero-GPU hit testing with mouseHit. Ten waypoint nodes around a soft loop — each one tested every frame against the cursor by projecting its origin to screen space and comparing against a configurable radius. The hit zone is drawn explicitly with bullsEye, which takes the same size and shape parameters as mouseHit — the gizmo is the hit zone, no guessing where the click registers. Use the panel to tune the radius and switch between circular and square hit shapes.


The per-object test

const params = { size: 80, shape: p5.Tree.CIRCLE }

push()
translate(node.position)
const hit = mouseHit(params)
fill(hit ? 'white' : node.color)
sphere(node.size)
pop()

mouseHit(opts) is shorthand for pointerHit(mouseX, mouseY, opts). Inside the call it reads the current model matrix off the stack, projects the model origin ([0, 0, 0]) to screen space via the same mapLocation machinery used elsewhere in the series, and compares the cursor pixel against a half-size radius. Depth-corrected: the pixel-radius is divided by pixelRatio at the model origin, so the hit zone holds steady on screen as the camera moves closer or farther.

The whole test is one matrix-vector multiply plus a 2D distance compare per call. No FBO, no readPixels, no GPU sync — just CPU math.


The hit zone is the gizmo

const params = { size: 80, shape: p5.Tree.CIRCLE }

push()
translate(node.position)
const hit = mouseHit(params)
// ... fill, sphere ...
bullsEye(params)        // ← same params object
pop()

bullsEye draws a screen-space ring (or rounded square) plus a centred crosshair at the current model origin. It takes the same size and shape keys mouseHit reads, so passing one object to both means the gizmo is the hit zone — no mismatch between visual feedback and click target. cross is the simpler companion: just a crosshair at the model origin, sized by size, no shape outline.

Both gizmos use beginHUD / endHUD internally (covered in camera interpolation), so they always render as a flat overlay regardless of camera angle. Stroke and weight come from the ambient drawing state — drive them per-object to communicate hit state:

stroke(hit ? '#ffd166' : '#5cd0ff')   // amber on hit, blue otherwise
bullsEye(params)

Tuning size, shape, and a cached PV

OptionDefaultDescription
size50Hit zone diameter (world units, depth-corrected).
shapep5.Tree.CIRCLECIRCLE (distance ≤ r/2) or SQUARE (Chebyshev ≤ r/2).
mat4PVcomputedPre-multiplied projection × view; skips a matrix mul.
mat4Modelcurrent modelOverride the model matrix (skip push + translate).

mouseHit projects through P · V · M every call. For N objects with a stable camera, the P · V part is identical for every test — pre-compute once and pass it in:

const pv = new Float32Array(16)

function draw() {
  // ... orbitControl, scene setup ...
  mat4PV(pv)                                  // build P·V once

  for (const n of nodes) {
    push()
    translate(n.position)
    const hit = mouseHit({ size: 80, mat4PV: pv })
    // ...
    pop()
  }
}

For ten nodes the saving is invisible. For a few hundred — or for objects polled per pointer-event in a tight loop — it’s the difference between the test costing real frame time and being free.


Where it falls short

Proximity testing answers “is the cursor near this object’s projected centre?” — not “is the cursor on this object’s geometry.” That distinction is fine for graph nodes, drag handles, and waypoint markers; it’s a problem when the geometry is large or irregular (a long thin box, a torus, a complex mesh) because the hit zone can extend well beyond the visible shape, or fall short at the corners.

It also doesn’t disambiguate overlap. If two nodes’ projected origins both fall within the cursor’s radius, both mouseHit calls return true; the caller has to decide which one wins by depth, draw order, or some other rule.

For pixel-perfect picking on arbitrary geometry, switch to GPU color-ID picking: one extra draw pass, depth-resolved hits, and the answer is whichever pixel the user actually sees. The two coexist naturally — colour-ID for the scene’s geometry, proximity for handles overlaid on top.


References

  1. p5.tree README — CPU proximity pickingmouseHit, pointerHit, cross, bullsEye
  2. GPU color-ID picking — sibling strategy: pixel-perfect, depth-resolved, scales to large scenes
  3. WebGL Fundamentals — Picking — overview of the picking-technique family this strategy sits inside
  4. Three.js Raycaster — CPU ray–mesh intersection: more accurate than proximity, more expensive than colour-ID
  5. Camera interpolationcreatePanel, beginHUD, and the surrounding p5.tree vocabulary used here
  6. Chebyshev distance — the metric shape: SQUARE falls back to: max(|dx|, |dy|) ≤ r