← Learn

Event Propagation in 3D: From Screen Space to Scene Records

Software 4 Senses LLC · Last updated

In a 3D scene rendered to a canvas, a click begins as a 2D pixel coordinate and has to end at a target that can genuinely handle it. The Software 4 Senses 3D Spatial Web Studio inverts that pixel through the render projection into a world ray, marches the ray on the CPU — against exact JavaScript twins of the renderer's distance functions for four shapes and conservative bounding boxes for other primitives — and publishes the hit as a message on the scene's bus, a DOM-free broker whose API also rebuilds the DOM's bubbling and stopPropagation over the Scene Document Model's record tree.

The disconnect: screen rays vs. DOM bubbling

A canvas-rendered 3D scene breaks the browser's event model because, to the browser, the entire scene is a single element. The DOM pipeline — hit-test the point, pick the deepest element, run capture and bubble along its ancestor chain — needs elements under the cursor, and a ray-marched scene offers exactly one: the canvas.

The common workaround is emulation: cast a ray, find the object, dispatch synthetic click or focus events onto proxies. Emulation reproduces the easy cases and loses the hard ones — native scrolling, caret behavior, form state, the exact semantics of bubbling and stopPropagation. The Studio does not reproduce the DOM's pipeline inside the scene. A CPU ray built from the renderer's own math finds the nearest pickable scene element under the pointer, and the hit becomes a message on the scene's own bus rather than a synthetic DOM event; scrolling inside the scene is not native scrolling — in a rail-driven scene, wheel, drag and keyboard input drive the rail. The operable glass UI controls (button, switch, checkbox, radio, slider, text field) keep native form behavior: each holds its state in a real form element placed right after the canvas, so focus, Tab order and keyboard input are the browser's own, and a press on the glass, resolved by the picker, operates that element programmatically (a click, or a written value followed by a dispatched change), so a change listener on it fires as it would on any page.

CPU picking against the renderer's own distance fields

Scene geometry is picked entirely on the CPU: the click's pixel is inverted through the exact render projection into a world ray, and that ray is sphere-traced against JavaScript twins of the same signed distance functions the WGSL renderer marches. Because the picker and the renderer share one camera model, the pick ray passes through exactly what the pixel shows; only the shape it is tested against can be approximate, as the limits below describe.

  1. Invert the projection. The canvas CSS pixel maps to a point on the z = 0 view plane; the ray runs from the eye at view-space (0, 0, 1) through it.
  2. Enter object space. The ray is carried through the record's inverse model matrix; rigid transforms preserve distance, so the object-space march is exact.
  3. Sphere-trace the SDF twin: step by the field's reported distance — at most 128 steps, hit epsilon 1e-3, maximum distance 200; a pick radius may inflate the field so thin handles stay grabbable.
  4. Arbitrate with one nearest-t comparison across every candidate — exact twins and conservative proxies enter the same comparison, never a two-tier ranking.
  5. Retarget the hit (editing only): the Studio's editing path climbs from the hit primitive to the outermost enclosing group box that exactly fits it (tolerance 2e-3 per axis), or stays on the primitive when no box fits; the scene picker reports the record the ray hit.

Exact SDF twins exist for four shapes: sphere, box, torus, and capsule. Everything else picks against a conservative bounding box in its own frame — deliberately larger than its silhouette — with glow excluded, on measurement: in the Studio's starter scene (sampled-pixel comparison, ), a lamp with glow 0.72 claimed a proxy 3.2× its real frame per axis and stole 122 sampled pixels from exact hits; glow-excluded, 3 over-reach samples and zero steals. A bound that contains the camera abstains: the march starts at t = 0, so a proxy the eye sits inside would report t = 0 and win every pixel.

Groups are never nearest-t candidates, by rule. An enclosing box's front face is always nearer than the primitive inside, so a group would win by construction and swallow every click — on the starter scene, bounding-hierarchy cull groups swallowed 30 of 41 sampled hit pixels before the rule landed (sampled-pixel survey, ). In the editing path, groups are retarget destinations, reached by climbing from the primitive the ray actually hit.

The honest limits: only those four shapes are precisely pickable, and twin-less primitives hit slightly generously. Triangle-mesh interiors carry no per-triangle candidate — the older triangle text path's thousands of facets would each cost a march — so such a mesh is reached through its root group by the editing path; the Studio's default distance-field text picks glyph by glyph against each glyph's bounding box.

One canonical pointer event

The picker knows nothing about layout: it publishes press, release, hover and unhover messages on the bus's ui channel, keyed by the id of the record hit, and drag messages, keyed by the pressed record, as the pointer moves while a press is held. For a slab — the layout system's content-bearing volume, which a rig seats in its cells — one bridge per engine turns all but the drag messages into a canonical pointer event on the rig channel, carrying the slab id, the layout cell and address it occupies, the child record hit (null when the ray hit the slab's own pick box), and the pointer position in canvas-relative CSS pixels.

Propagation is inward-out, the DOM's own arrangement: a ray pick resolves its record and walks the parent chain to the owning slab, which publishes the event; the enclosing rig subscribes to those events on the scene bus and never listens for DOM events directly.

The scene's rail listens on the same canvas but claims a pointer drag only after 8 px of movement and a vertical-majority move, so a tap passes through to the picker untouched.

Hover is opt-in by design. A hover sweep costs a ray per pointer move — a device-rate event — so automatic pick candidates are press-only: the full sweep runs on pointerdown, a human-rate event, and with no hover registrations a pointer move costs exactly one comparison regardless of scene size. Where hover is enabled, the ray is throttled to 16 ms — at most one ray per frame at 60 Hz.

Beyond the pick: DOM-style delivery on the scene bus

The picker's messages are published to the subscribers of the ui channel; they are not bubbled. Separately, the scene bus rebuilds the DOM's event semantics over the record tree, DOM-free. Every published message becomes one synchronous frozen envelope — { channel, action, payload, senderId, … } — and reactions to one message share a task, which grouped animation starts rely on. bubble() walks parent pointers from a target record toward the scene root with stopPropagation semantics and a cycle guard; an unstopped bubble is offered to an explicit whitelist of registered root actions rather than open method dispatch; broadcastSubtree() delivers the other direction, root-down through a record's subtree. A throwing subscriber never kills the delivery loop: per-handler error isolation routes the throw to a reserved error channel.

Related reading