Skip to content

feat(events)!: DOM-agnostic Pointer dispatch + XR controller pointers#64

Closed
bigmistqke wants to merge 7 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-xr-pointers
Closed

feat(events)!: DOM-agnostic Pointer dispatch + XR controller pointers#64
bigmistqke wants to merge 7 commits into
solidjs-community:next-cleanupfrom
bigmistqke:next-xr-pointers

Conversation

@bigmistqke

Copy link
Copy Markdown
Contributor

Decouples solid-three's pointer-event dispatch from the DOM behind a Pointer abstraction, opens it to multiple simultaneous pointers via pointerId, and adds an API to wire XR controllers as pointers. Event-driven, and behavior-preserving for the mouse path.

Why

The event system was DOM-triggered (each registry listened on context.canvas) and single-pointer (one shared hoveredSet, one context.raycaster). That blocked XR — controllers emit selectstart/selectend, never DOM events — and couldn't track multi-touch or multiple controllers. Dispatch now lives on a per-pointer object fed by pluggable sources, so a mouse, two fingers, and two controllers can all dispatch independently through the same code, while the mouse path stays byte-identical.

What changed

  • Pointer (src/pointers.ts) — per-pointer, DOM-agnostic dispatch + hover/press state. The enter/leave ancestor diff, bubbling + stopPropagation, click + -Missed, and down/up/wheel logic are ported from the old per-kind registries; each pointer keeps its own hover set.
  • EventRaycaster.cast + ScreenRaycaster + ControllerRaycaster (src/raycasters.tsx) — the ray strategy is a property of a pointer. cast(registry, context) aims the ray and intersects; ScreenRaycaster adds setCursor (camera + NDC); ControllerRaycaster casts from an Object3D's matrixWorld.
  • DOMPointerManager (src/pointer-managers.ts) — the built-in screen source: owns the canvas listeners, a Pointer per native pointerId (multi-touch), and a primary Pointer for click/dblclick/contextmenu/wheel (which are MouseEvents with no pointerId).
  • XRPointerManager + createXR wiring — each controller (renderer.xr.getController(i)) becomes a Pointer with a ControllerRaycaster; selectstart/selectend drive onPointerDown/onPointerUp and synthesize onClick. Created on sessionstart, torn down on sessionend/disconnect, and guarded so it only engages when the renderer exposes getController.
  • context.eventRegistry — one refcounted registry of handler-bearing objects that every pointer raycasts, replacing the three per-kind registries.

Breaking

  • onMouse* removed (onMouseDown/onMouseUp/onMouseMove/onMouseEnter/onMouseLeave). Fully redundant with onPointer* (pointer events fire for mouse input and are a superset) and absent from react-three-fiber. Migration: use the onPointer* equivalents.
  • EventRaycaster.update(event, context) removed in favour of cast + setCursor (affects custom raycasters).
  • The mouse path keeps native DOM click/dblclick/contextmenu; only XR controllers synthesize click from select-down/up. This is a deliberate hybrid — r3f-core is native-click-only, @react-three/xr synthesizes uniformly; we keep the mouse byte-identical and synthesize only where there is no DOM click.

Deferred (not in this PR)

Per-frame driving, and XR controller sweep-hover (enter/leave as a controller ray moves while held still) — controllers emit no move events, so that needs a per-frame tick. This PR is event-driven: point-and-click/select works now; controller hover-while-moving comes with the per-frame phase.

Test plan

  • pnpm test — 175 passed, 2 todo (16 files). Behavior-preserving: every pre-existing test passes unchanged except the removed onMouse* blocks (and two api-coverage tests migrated off the removed .update()). New tests cover Pointer dispatch, EventRaycaster.cast, multi-touch (independent hover per pointerId), and XR controller select → onPointerDown/onPointerUp/onClick.
  • pnpm lint:types — clean.
  • pnpm lint:code — clean.

…ointer* family)

Ports the hover/missable/default dispatch from the per-kind registries into one
per-pointer Pointer class with its own hover state, fed by a manager's raycaster.
Adds context.eventRegistry (the single registry the Pointer raycasts; populated
in a later task). Additive + unwired — existing suite unchanged (175/2 → +5).
…ster

cast(registry, context) aims the ray and intersects (ported from the old raycast);
ScreenRaycaster adds setCursor; ControllerRaycaster casts from a space's matrixWorld.
Legacy update() kept (now optional) so the pre-Pointer engine still runs. Additive.
…d + primary)

Owns the canvas listeners, a Pointer per native pointerId (multi-touch), and a
primary Pointer for the family-agnostic click/dblclick/contextmenu/wheel gestures.
Additive — not yet wired (the clean cut replaces createEvents next).
…op onMouse*

createEvents now registers handler-bearing objects into the single
context.eventRegistry and connects the DOMPointerManager (per-pointerId Pointers
+ a primary Pointer for click/wheel). Deletes the three per-kind registries, the
old raycast, eventNameMap, createRegistry, and the duplicated createThreeEvent.

BREAKING: onMouse* handlers removed (redundant with onPointer*; absent from r3f).
Their test blocks are removed; every other test passes unchanged (185 → 172),
confirming the new dispatch is behavior-identical for the mouse path.
The pre-Pointer engine was the only caller; cast() replaces it. Drops update()
from EventRaycaster + Cursor/CenterRaycaster and the unused RayEvent type; migrates
the two api-coverage tests that probed update() to the setCursor/cast API.
…ck (event-driven)

Each XR controller (renderer.xr.getController(i)) becomes a Pointer with a
ControllerRaycaster bound to its targetRay space; selectstart/selectend drive
down/up and synthesize click. createXR wires it on sessionstart, tears down on
sessionend/disconnect — guarded so the renderer must expose getController and the
connected value be a full Context, leaving createXR's existing fakes unaffected.
Sweep-hover (needs per-frame) is deferred.
@pkg-pr-new

pkg-pr-new Bot commented Jun 3, 2026

Copy link
Copy Markdown

commit: 3ff10f7

@bigmistqke

Copy link
Copy Markdown
Contributor Author

API implications: what this looks like for consumers

The headline is that there is no XR-specific event code to write. Once a session starts, each controller (up to 2) becomes its own Pointer aiming a ray from its matrixWorld, and pulling the trigger maps onto the same handlers already used for the mouse:

  • selectstartonPointerDown
  • selectendonPointerUp, then a synthesized onClick (controllers have no DOM click)

So a <T.Mesh> that responds to the mouse responds to a controller for free. Wiring is the existing createXR flow — XRPointerManager attaches itself on sessionstart and tears down on sessionend. There is no pointers prop and nothing to pass in.

import { Canvas, createXR, createT, type S3 } from "solid-three"
import * as THREE from "three"
import { createSignal, Show } from "solid-js"

const T = createT(THREE)

function Button(props: { position: [number, number, number]; label: string }) {
  const [active, setActive] = createSignal(false)

  // The same handlers you'd write for the mouse. In a session, the XR
  // controller ray drives them: point at this mesh, pull the trigger, and
  // onPointerDown → onPointerUp → onClick fire — no XR-specific wiring.
  return (
    <T.Mesh
      position={props.position}
      scale={active() ? 1.2 : 1}
      onPointerDown={(event: S3.ThreeEvent<Event>) => {
        event.stopPropagation() // don't bubble to meshes behind this one
        setActive(true)
      }}
      onPointerUp={() => setActive(false)}
      onClick={(event) => {
        // event.point / event.distance describe where the controller ray hit.
        console.log(`${props.label} selected at`, event.point)
      }}
    >
      <T.BoxGeometry args={[0.3, 0.15, 0.05]} />
      <T.MeshStandardMaterial color={active() ? "orange" : "white"} />
    </T.Mesh>
  )
}

function Scene() {
  return (
    <>
      <T.AmbientLight />
      <T.DirectionalLight position={[2, 4, 3]} />
      {/* A floating two-button panel a controller can point at and "click". */}
      <Button position={[-0.4, 1.4, -1]} label="left" />
      <Button position={[0.4, 1.4, -1]} label="right" />
    </>
  )
}

export function App() {
  const xr = createXR()

  return (
    <>
      {/* DOM button, outside the Canvas — its click is the user gesture that
          starts the session. Don't await anything before enter(). */}
      <button onClick={() => xr.enter("immersive-vr")}>Enter VR</button>
      <Show when={xr.isPresenting()}>
        <button onClick={() => xr.exit()}>Exit VR</button>
      </Show>

      {/* ref={xr.connect} hands createXR the renderer + per-frame render.
          On sessionstart, XRPointerManager wires both controllers as pointers
          automatically; on sessionend it tears them down. No pointers prop. */}
      <Canvas ref={xr.connect} camera={{ position: [0, 1.6, 0] }}>
        <Scene />
      </Canvas>
    </>
  )
}

Works now vs. deferred

  • Works (event-driven): point-and-select. Trigger press/release on a controller pointed at a handler-bearing mesh dispatches onPointerDown / onPointerUp / onClick, bubbling with stopPropagation like the mouse. Two controllers dispatch independently.
  • Deferred (not in this PR): controller sweep-hoveronPointerEnter / onPointerLeave as a ray moves across objects while held still. Controllers emit no move events, so continuous hover needs a per-frame tick (see the PR's "Deferred" section). The mouse hover path is unchanged.

@bigmistqke

Copy link
Copy Markdown
Contributor Author

Closing in favor of #65 — same work, rebuilt as one coherent history.

#64 added the source-agnostic pointer system but also baked an XRPointerManager into core, which a later branch then removed to re-introduce XR as a plugin. The replacement branch (next-pluggable-xr) drops that add-then-remove zigzag so XR enters exactly once — as the solid-three/xr plugin — on top of the new plugin system. It also strips a harness artifact (.claude/scheduled_tasks.lock) that slipped into this branch's history.

Continued in #65.

@bigmistqke bigmistqke closed this Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant