Publishing gate

Run the WebGL scene health check

Use the checklist to score assets, camera behavior, rendering, mobile performance, loading states, and fallback content before publishing.

Run Scene Check Compare lab tools

Most picking bugs start with coordinates

Raycaster expects normalized device coordinates where x and y are between -1 and 1. Many examples compute them from `window.innerWidth` and `window.innerHeight`, which works only when the canvas fills the window. If your canvas sits inside a layout, sidebar, or article card, use the canvas bounding rectangle.

Pointer coordinates also need to be measured after CSS transforms and responsive resizing. If a click feels offset, log the canvas rectangle and the computed normalized values. The raycaster may be correct while your coordinate conversion is wrong.

Pick against a deliberate list

Raycasting the whole scene is convenient for a demo but noisy for production. Grids, helpers, invisible bounds, particles, and background meshes may all receive intersections. Keep a `pickables` array and add only objects that should respond to the pointer.

For imported GLB scenes, traverse the model and mark meshes that should be interactive with userData. That makes the decision visible in code. A product part selector, for example, can attach part ids or labels to pickable meshes while leaving decorative nodes alone.

Sort intent after intersection

Raycaster returns intersections sorted by distance. The nearest hit is often what you want, but not always. A transparent cover, highlight shell, or helper mesh may sit in front of the actual object. Filter intersections by material visibility, object userData, or layers before choosing.

Hover states should also be reversible. Store the last hovered object, clear its material state when the pointer leaves, and avoid creating new materials on every mousemove. Interaction bugs often become performance bugs when hover logic allocates objects repeatedly.

Make mobile picking forgiving

Touch input is less precise than a cursor. Small meshes can be frustrating to select on mobile. Consider larger invisible hit targets, part-level grouping, or a list-based fallback when touch selection needs to be reliable. A viewer can still use raycasting internally while presenting simpler choices in the UI.

If picking is the core of the tool, show the selected object name or id in a small status area. Clear feedback makes users trust that the scene is responding.

Use a bounded canvas before wiring picking

The examples page provides canvas-sized scenes that make pointer coordinate normalization easier to inspect.

  • Pointer math uses canvas bounds
  • The pick list is explicit
  • Mobile targets need forgiving hit areas
Open the examples

Normalize against the canvas, not the window

Window coordinates are wrong when the canvas is inset, scrolled, or smaller than the viewport.

Fragile pattern
pointer.x = event.clientX / window.innerWidth * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
Testable pattern
const rect = canvas.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

Pointer normalization examples

These values are derived directly from the normalized-device-coordinate formula.

Canvas-relative positionNDC result
Top-left(-1, 1)
Center(0, 0)
Bottom-right(1, -1)
Outside boundsIgnore before raycast

Reproduce a missed click

Record the coordinate transform and intersections before changing object geometry.

  1. Log the canvas rectangle and pointer coordinates.
  2. Confirm the normalized point falls between -1 and 1.
  3. Raycast against an explicit recursive object list.
  4. Inspect the closest intersection, object visibility, layers, and instance ID.

Canvas-relative raycast

const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const pickables = [];

canvas.addEventListener('pointerdown', (event) => {
  const rect = canvas.getBoundingClientRect();
  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const [hit] = raycaster.intersectObjects(pickables, true)
    .filter((item) => item.object.visible);

  if (hit) selectPart(hit.object);
});

Sources and further reading

Update record

  • : Rewritten around a reproducible demo, explicit test record, code comparison, and known platform limits.

Related guides