MeshLinePicker — GPU Picking
MeshLinePicker answers one question efficiently: "which line (or instance) is under the cursor right now?"
It renders your registered MeshLines with unique ID colors to a 1×1 offscreen render target centred on the cursor, then reads back that single pixel and decodes the ID. Because it reads what was actually rendered, it works for cases where raycast() cannot:
- Lines with
gpuPositionNode(...)(positions computed in the shader) - Lines with
positionFnhooks that change rendered geometry away from the CPU template - Thousands of instances animated per-frame on the GPU
- Hook-driven line width, bend, sway — any visual effect is picked correctly
The picker keeps a parallel picking scene alongside your regular scene, holding lightweight proxy meshes that share your lines' geometry but render with an ID-encoding material. Both scenes are driven by the same camera — the user's scene draws to the canvas as usual, while the picking scene is rendered on demand into a tiny offscreen target and read back for the hit ID.
When to use what
| Use case | raycast() | MeshLinePicker |
|---|---|---|
| Static CPU-positioned line, small count | ✅ | ✅ |
GPU-positioned line (gpuPositionNode) | ❌ | ✅ |
| Instanced lines, 100s–1000s of instances | slow | ✅ |
| Need exact 3D hit point (xyz) | ✅ | ❌ (returns ID only) |
| Per-frame animated geometry via hooks | unreliable | ✅ |
Rule of thumb: if the final rendered shape differs from what the raycaster would test against (GPU node, positionFn, per-instance transform), use the picker.
Minimal example
import { MeshLine, MeshLinePicker } from 'makio-meshline'
const line = new MeshLine()
.instances( 1000 )
.segments( 16 )
.gpuPositionNode( myGpuNode )
.color( 0xffaa00 )
scene.add( line )
const picker = new MeshLinePicker( renderer, scene, camera )
picker.add( line )
canvas.addEventListener( 'pointermove', async ( e ) => {
const rect = canvas.getBoundingClientRect()
const hit = await picker.pick( e.clientX - rect.left, e.clientY - rect.top )
if ( hit ) {
console.log( 'hovering instance', hit.instanceId, 'of', hit.line )
}
} )The returned hit is { line, instanceId } — instanceId is -1 for non-instanced lines.
How pick() works
Each call to picker.pick(x, y):
- Re-centres the frustum on the pixel at
(x, y)withcamera.setViewOffset(...)— a viewport trick that focuses the full frame into the tiny render target without touching scene state. - Renders the internal picking scene into an offscreen render target. Each registered line's proxy uses a picking material that outputs a unique ID color instead of shading.
- Reads the RT back via
readRenderTargetPixelsAsync(...)— 4 bytes, no main-loop stall. - Decodes the bytes and returns
{ line, instanceId }ornull.
ID encoding
Every fragment rendered in the picking pass writes a 4-byte color whose channels carry the hit identity:
- R (1 byte) — slot ID assigned to the line by
picker.add().0means empty (no hit). - G + B (2 bytes) — 16-bit instance index, decoded as
instanceId = (g << 8) | b. Returned as-1for non-instanced lines. - A — constant
1; the alpha channel acts purely as a hit mask so the decoder can ignore background pixels.
This fits up to 255 lines × 65 536 instances per pass.
API
new MeshLinePicker( renderer, scene, camera, options? )
renderer— yourWebGPURendererscene— the scene the lines live in (same scene used for normal rendering)camera— the camera used for normal renderingoptions.targetSize— offscreen render target size in CSS pixels, default1. Larger sizes scan a wider neighborhood around the cursor. DPR scaling is handled internally.options.hitRadius— multiplier applied to each registered line's width when drawn into the picking pass, default15. Lets thin lines be picked reliably without making them visually thicker. Set to1to pick against the exact rendered width.
picker.add( meshLine )
Registers a MeshLine. Creates a picking variant of its material (shares vertex pipeline, overrides fragment to output an ID) and attaches it as a hidden sibling on the picking layer. The transform is inherited automatically.
Up to 255 lines can be registered simultaneously.
picker.remove( meshLine )
Unregisters a MeshLine and disposes its picking material.
await picker.pick( x, y )
x,y— canvas-relative CSS pixels (i.e.event.clientX - canvas.left). DPR scaling is handled internally.- Returns
Promise<{ line, instanceId } | null>.nullmeans the cursor is over empty space.
picker.lines
Read-only snapshot of the currently registered MeshLines in registration order. Returns a fresh array each call.
picker.debugScene and picker.updateDebug()
For raw debugging, render picker.debugScene directly to inspect the ID-colored picking proxies. Call picker.updateDebug() before rendering that scene so proxy transforms, visibility, render order, instance count, resolution, DPR, and effective hit width match the live lines. Colors here are the encoded slot/instance IDs — useful for verifying the picking pipeline, but not visually meaningful. For a human-readable overlay, prefer MeshLinePickerHelper below.
picker.dispose()
Disposes the render target and all picking materials. Call on scene teardown.
MeshLinePickerHelper — visualize hit-zones
MeshLinePickerHelper is a Three.js-style helper (in the spirit of BoxHelper / SkeletonHelper) that renders the picker's wide hit-zone proxies with human-readable colors, overlaid on your scene. Each registered line / instance gets a distinct hue (golden-ratio cosine palette) so you can see exactly what the GPU picker is testing against without losing the rest of the scene.
It's a Group containing one shared-geometry proxy mesh per registered line, sized to hitRadius × lineWidth and rendered with a transparent colored material. The helper does not participate in picking — it's purely a debug visualization.
import { MeshLinePicker, MeshLinePickerHelper } from 'makio-meshline'
const picker = new MeshLinePicker( renderer, scene, camera, { hitRadius: 15 } )
picker.add( meshLine )
const helper = new MeshLinePickerHelper( picker, { opacity: 0.45 } )
scene.add( helper )
// in your render loop:
helper.update()
// toggle:
helper.visible = falsenew MeshLinePickerHelper( picker, options? )
picker— an existingMeshLinePickerwith one or more registered lines.options.opacity— overlay alpha (0..1). Default0.45.
The helper snapshots the picker's registry at construction. If you call picker.add() / picker.remove() afterwards, call helper.rebuild() to re-snapshot.
helper.update()
Sync each proxy's transform, instance count, and width from its source line. Call once per frame before rendering. Cheap (one matrix copy + a few uniform writes per registered line).
helper.setOpacity( value )
Update the overlay alpha at runtime (e.g. for a fade-in/out). Affects all proxies.
helper.rebuild()
Tear down existing proxies and rebuild from the picker's current registry. Use after picker.add() / picker.remove() to keep the helper in sync.
helper.dispose()
Dispose all proxy materials and detach them from the helper. Geometries are owned by the source lines and are not disposed — so disposing the helper is safe even while the lines are still in use.
See it live in the Laser Heist demo — pressing P cycles through raycast → picker → picker-debug, where the third state turns the helper on while picking continues to run through the GPU picker.
"Laser Heist" — raycast vs picker, side by side
The bundled Laser Heist demo renders 24 instanced GPU-positioned laser beams and cycles through three hover-test modes at runtime (click the pill at the top or press P):
- Raycast mode uses Three.js's
Raycasteragainst the instancedMeshLine— it relies on the CPU-knowninstanceStart/instanceEndattributes to test each laser as a line segment. Simple and cheap, but only works because each laser is a straight segment whose endpoints live on the CPU. - MeshLinePicker mode registers the same
MeshLinewith the GPU picker and callspicker.pick(x, y)on pointer move. The picker reads the rendered pixel under the cursor and decodes the instance ID. - Picker debug mode keeps the GPU picker active and adds a
MeshLinePickerHelperoverlay so you can see each laser's hit-zone (sized tohitRadius × lineWidth) painted with a distinct per-instance hue.
All three modes feed the same hoveredLaserId, which drives the alarm-pulse animation. Switching modes live proves the CPU and GPU strategies produce the same hit results, and the debug overlay makes the GPU picker's hit zones inspectable.
this.picker = new MeshLinePicker( renderer, scene, camera, { targetSize: 5 } )
this.picker.add( this.line )
runRaycast() {
this.raycaster.setFromCamera( _mouseNDC, camera )
const hit = this.raycaster.intersectObject( this.line )[ 0 ]
this.hoveredLaserId = hit?.instanceId ?? -1
}
async runPicker() {
const rect = renderer.domElement.getBoundingClientRect()
const hit = await this.picker.pick( mouse.x - rect.left, mouse.y - rect.top )
this.hoveredLaserId = hit?.line === this.line ? hit.instanceId : -1
}When to prefer the picker over raycast:
- The line is rendered by a
gpuPositionNode(positions are only known to the GPU) → raycast bails out entirely. - The line is deformed by a
positionFnhook that changes its actual visible shape → raycast would test the CPU template, not the rendered curve. - You have thousands of instances and only need the top-most visible hit under the cursor → picker is a single pixel read regardless of count.
When raycast is still the right tool:
- Static CPU-positioned lines, small counts, and you need the exact 3D hit point (picker only returns an ID).
- You want synchronous hit detection with zero GPU roundtrip.
See the full source at demo/src/demos/heist.js.
Performance notes
- Cost scales with scene complexity, not with picker count. Each
pick()does one render pass of the registered lines to a tiny render target, then reads back 4 bytes. - Throttle picks to pointer events (or better, to
requestAnimationFrameafter a pointer event). Picking every frame for no reason wastes GPU time. targetSize: 1is cheapest but may miss thin lines at the very edge of a pixel. Bump to3if you see flaky hits on sub-pixel-wide geometry.- Readback is async —
readRenderTargetPixelsAsyncdoes not stall the main render loop, but introduces a ~1 frame latency on the hit result. For a hover effect that's fine; for hit-precise clicks, sample onpointerdown. - The picker temporarily switches the camera to a dedicated layer, clears the scene background, and restores both afterwards. Your normal render is unaffected.
Limitations
- No xyz hit point. The picker returns the line/instance ID, not a 3D intersection point. If you need the point, use CPU
.raycast()(and stick to CPU-positioned lines). - 255 registered lines max. The slot is encoded in one byte. Easy to bump by widening the encoding, but not needed for common scenes.
- 65k instances per line max. Instance ID is encoded in 16 bits. Plenty for practical scenes.
- Lines must be on screen. Offscreen geometry won't rasterize into the picking pass and will register as a miss.
- One pixel of precision by default. No sub-pixel interpolation; if the line passes between pixels, the sample misses.