Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions site/src/routes/api/events/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,10 @@ These fire on the object that was hit, or — when nothing handles them — as a

Run in three phases per pointer move — enter, then move, then leave. See [Hover events](#hover-events).

- `onMouseEnter`, `onMouseMove`, `onMouseLeave`
- `onPointerEnter`, `onPointerMove`, `onPointerLeave`

### Press and wheel events

- `onMouseDown`, `onMouseUp`
- `onPointerDown`, `onPointerUp`
- `onWheel` — registered as a passive listener

Expand All @@ -49,7 +47,7 @@ Not every handler receives every field — what you get depends on the event:
| Handler | Receives |
| --- | --- |
| `onClick`, `onContextMenu`, `onDoubleClick`, the `*Move` / `*Down` / `*Up` handlers, `onWheel` | `nativeEvent`, the intersections, and `stopPropagation` |
| `onMouseEnter`, `onMouseLeave`, `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped |
| `onPointerEnter`, `onPointerLeave` | `nativeEvent` and the intersections, but no `stopPropagation` — these can't be stopped |
| `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` | only `nativeEvent` — missed events don't raycast |

<details>
Expand Down Expand Up @@ -143,13 +141,13 @@ Without the `stopPropagation()` call the order would be front mesh → back mesh

Not every event accepts `stopPropagation()`. The split mirrors the DOM:

**Stoppable** — `onClick`, `onContextMenu`, `onDoubleClick`, `onMouseDown`, `onMouseUp`, `onMouseMove`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`.
**Stoppable** — `onClick`, `onContextMenu`, `onDoubleClick`, `onPointerDown`, `onPointerUp`, `onPointerMove`, `onWheel`.

**Non-stoppable** — these always fire for every registered handler, regardless of order:

- `onClickMissed`, `onContextMenuMissed`, `onDoubleClickMissed` — a [missed event](#missed-events) is by definition the "nothing else handled it" signal.
- `onMouseEnter`, `onPointerEnter` — enter has to reach the newly-hovered subtree.
- `onMouseLeave`, `onPointerLeave` — leave has to reach the previously-hovered subtree.
- `onPointerEnter` — enter has to reach the newly-hovered subtree.
- `onPointerLeave` — leave has to reach the previously-hovered subtree.

## Missed events

Expand Down
75 changes: 75 additions & 0 deletions site/src/routes/api/utilities/plugin.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
title: Plugins
---

# Plugins

A plugin teaches `solid-three` new **props**. It matches some set of elements and contributes methods to them; each contributed method surfaces as a typed prop, and assigning that prop calls the method. This is how features like XR controller events are layered onto the core without baking them in.

## `plugin()`

`plugin()` builds one plugin. It has three forms, differing only in which elements they match:

```tsx
import { Mesh } from "three"
import { plugin } from "solid-three"

// 1. Global — every element.
plugin(element => ({ ping: () => {} }))

// 2. Constructor-filtered — elements that are `instanceof` one of these.
plugin([Mesh], mesh => ({ shake: (intensity: number) => {} }))

// 3. Type-guard — elements that pass the guard.
plugin((element): element is Mesh => element instanceof Mesh, mesh => ({ setColor: (hex: string) => {} }))
```

The factory receives the matched element and returns an object of methods. A non-matching element contributes nothing. A method's **first parameter type becomes the prop's type** — `shake: (intensity: number) => …` makes `shake` a `number` prop.

## Registering plugins

Two ways, depending on the scope you want.

**Whole renderer — pass them to `createT`:**

```tsx
import * as THREE from "three"
import { Canvas, createT, plugin } from "solid-three"

const T = createT(THREE, [
// Every Mesh gains a `shake` prop.
plugin([THREE.Mesh], mesh => ({
shake: (intensity: number) => {
mesh.position.x += (Math.random() - 0.5) * intensity
},
})),
])

const App = () => (
<Canvas>
{/* `shake` is a typed prop now — pass a number, the method runs. */}
<T.Mesh shake={0.2}>
<T.BoxGeometry />
<T.MeshStandardMaterial />
</T.Mesh>
</Canvas>
)
```

**One element — pass them to `<Entity plugins>`:**

```tsx
import { Mesh } from "three"
import { Entity, plugin } from "solid-three"

const App = () => (
<Entity from={Mesh} plugins={[plugin([Mesh], mesh => ({ shake: (i: number) => {} }))]} shake={0.2} />
)
```

Either way the contributed prop is **type-checked**: a contributed method makes its prop available, and a prop that no plugin contributes is rejected by the type-checker. The method is invoked with the prop value; it is not assigned onto the three.js instance.

## See also

- [`T` / `createT`](/api/components/t) — the element factory plugins extend.
- [`Entity`](/api/components/entity) — per-element plugin registration.
65 changes: 37 additions & 28 deletions site/src/routes/api/utilities/raycasters.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,76 +4,85 @@ title: Raycasters

# Raycasters

`solid-three` ships custom raycasters that track the pointer for you. They all extend `THREE.Raycaster` and implement the `EventRaycaster` interface, which adds a single method:
`solid-three` ships custom raycasters that decide how a pointer becomes a ray. They all extend `THREE.Raycaster` and implement `EventRaycaster`, which adds one method:

| Method | Description |
| --- | --- |
| `update(event, context)` | Called automatically before intersection testing, to position the raycaster from the current event. |
| `cast(registry, context)` | Aim the ray for the current pointer, then return its hits against `registry` and its descendants (honoring `raycastable !== false`), nearest-first. |

The pointer system calls `cast()` on every event. How the ray is aimed is the raycaster's own business — from the camera and a cursor position for screen pointers, or from an object's world transform for an XR controller.

Screen-pointer raycasters also implement `ScreenRaycaster`, which adds `setCursor(ndc)`. The pointer system calls it with the cursor in normalized device coordinates before each `cast()`.

<details>
<summary>Exact type</summary>
<summary>Exact types</summary>

```tsx
interface EventRaycaster extends THREE.Raycaster {
update(event: PointerEvent | MouseEvent | WheelEvent, context: Context): void
cast(registry: Object3D[], context: Context): Intersection[]
}

interface ScreenRaycaster extends EventRaycaster {
setCursor(ndc: Vector2): void
}
```

</details>

`solid-three`'s [event system](/api/events/overview) calls `update()` before every intersection test — on mouse events (`click`, `mousedown`, `mouseup`, `mousemove`, `contextmenu`, `dblclick`), pointer events (`pointerdown`, `pointerup`, `pointermove`), and `wheel` — so the raycaster is positioned correctly for accurate hit detection.

## CursorRaycaster

The default raycaster; tracks the cursor position.
The default. Tracks the cursor and casts from the active camera.

```tsx
import { Canvas, CursorRaycaster } from "solid-three"

const App = () => {
const raycaster = new CursorRaycaster()
// CursorRaycaster is the default; set it explicitly if you like:
return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
// CursorRaycaster is the default; pass it explicitly only to swap or configure.
return <Canvas raycaster={new CursorRaycaster()}>{/* Your scene */}</Canvas>
}
```

## CenterRaycaster

Always casts from the center of the screen.
Ignores the cursor and always casts from the centre of the screen — useful for gaze or crosshair interaction.

```tsx
import { Canvas, CenterRaycaster } from "solid-three"

const App = () => {
const raycaster = new CenterRaycaster()
return <Canvas raycaster={raycaster}>{/* Your scene */}</Canvas>
}
const App = () => <Canvas raycaster={new CenterRaycaster()}>{/* Your scene */}</Canvas>
```

## Creating your own raycaster
## ControllerRaycaster

Extend `THREE.Raycaster` and implement `EventRaycaster`:
Casts from an `Object3D`'s world transform — origin at its world position, direction along its local −Z. This is the ray strategy for an XR controller; see [`createXR`](/api/hooks/create-xr).

```tsx
import { Raycaster, Vector2 } from "three"
import type { EventRaycaster, Context } from "solid-three"
import { ControllerRaycaster } from "solid-three"

class CustomRaycaster extends Raycaster implements EventRaycaster {
update(event: PointerEvent | MouseEvent | WheelEvent, context: Context) {
const pointer = new Vector2()
const raycaster = new ControllerRaycaster(controllerSpace)
```

## Creating your own

For a screen pointer, the simplest path is to subclass `CursorRaycaster` and reshape the cursor — `cast()` is inherited:

// Scale movement down, as an example transform
pointer.x = ((event.offsetX / context.bounds.width) * 2 - 1) * 0.5
pointer.y = (-(event.offsetY / context.bounds.height) * 2 + 1) * 0.5
```tsx
import { Vector2 } from "three"
import { Canvas, CursorRaycaster } from "solid-three"

this.setFromCamera(pointer, context.camera)
// Damp pointer movement to half speed.
class DampedRaycaster extends CursorRaycaster {
setCursor(ndc: Vector2) {
super.setCursor(new Vector2(ndc.x * 0.5, ndc.y * 0.5))
}
}

const App = () => <Canvas raycaster={new CustomRaycaster()}>{/* Your scene */}</Canvas>
const App = () => <Canvas raycaster={new DampedRaycaster()}>{/* Your scene */}</Canvas>
```

For a non-screen ray — a custom origin and direction — implement `cast()` directly: aim `this.ray` from whatever transform you like, then return `this.intersectObjects(registry, true)`. `ControllerRaycaster` is the reference implementation.

## See also

- [Events overview](/api/events/overview) — when and why `update()` is called.
- [Events overview](/api/events/overview) — how and when `cast()` is called.
- [`useThree`](/api/hooks/use-three) — `raycaster` / `setRaycaster` for swapping the active raycaster at runtime.
4 changes: 2 additions & 2 deletions site/src/routes/tour/08-tetris.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,5 +82,5 @@ this one: signals and stores describing state, JSX describing the scene,
`<T.*>` keeping the scene in sync with state. The
[API reference](/api/types) is there when you need a specific export.

If you'd like a peek at where the library is heading next, the encore is
WebGPU.
There's one more kind of power worth knowing: when a prop you want isn't a
property, you can add it yourself. Next up — plugins.
72 changes: 72 additions & 0 deletions site/src/routes/tour/09-plugins.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
title: Plugins
---

import lookAtSnippet from "../../snippets/09-look-at.tsx?raw"
import lookAtUrl from "../../snippets/09-look-at.tsx?importChunkUrl"

# Plugins

Every prop you've set so far has been just that — a *property*. `position`,
`color`, `intensity`: solid-three takes the value and assigns it to the
three.js object. That covers most of what a scene needs, but not all of it.

Some of the things you'd reach for aren't properties — they're *methods*.
three's `Object3D` has a `lookAt(target)` method that turns an object to
face a point in space. You'd love to write `<T.Mesh lookAt={somePoint} />`
and have it just work. But it won't: props get *assigned*, so solid-three
would overwrite `mesh.lookAt` with your point and the method would be gone.
Props set properties; they don't call methods.

A **plugin** closes that gap. It teaches solid-three a new prop — one that
runs a function instead of assigning a value.

## Writing the plugin

```tsx
import { plugin } from "solid-three"

const lookAt = plugin([THREE.Object3D], object => ({
lookAt: (target: THREE.Vector3) => object.lookAt(target),
}))
```

That's the whole thing. `plugin([THREE.Object3D], …)` matches every element
that's an `Object3D`, and for each one contributes a `lookAt` method. Now
when you set a `lookAt` prop, solid-three calls this method with the value
instead of assigning it — so `<T.Mesh lookAt={point} />` runs
`mesh.lookAt(point)`, exactly what we wanted.

## Registering it

`createT` takes the plugins as its second argument. The `T` it hands back
understands the props they add:

```tsx
const T = createT(THREE, [lookAt])
```

## Seeing it work

Move the pointer over the field below — every cone turns to face it.

<Demo code={lookAtSnippet} url={lookAtUrl} />

Each cone is nothing more than `<T.Mesh lookAt={target()} />`. A wide,
invisible backdrop reports the pointer's position with the `onPointerMove`
and `intersection.point` from the [pointer chapter](/tour/04-pointer-events),
and that point flows into every cone's `lookAt` prop. When the signal
changes, the prop re-applies and the plugin calls `lookAt` again — the same
reactivity that drives every other prop in the tour, now driving a method.

## There's more

`lookAt` is the simplest shape. Plugins can also match by a type-guard
instead of a class, register on a single element with `<Entity plugins={…} />`
instead of the whole renderer, and the props they contribute are fully
typed — pass a wrong one and the type-checker stops you. The
[Plugins reference](/api/utilities/plugin) covers the rest.

That's the extension seam: when a prop you want isn't a property, a plugin
makes it one. For the encore, a peek at where three.js — and this renderer —
are heading: WebGPU.
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
title: A peek at WebGPU
---

import webgpuSimple from "../../snippets/09-webgpu-simple.tsx?raw"
import webgpuSimpleUrl from "../../snippets/09-webgpu-simple.tsx?importChunkUrl"
import webgpuTsl from "../../snippets/09-webgpu-tsl.tsx?raw"
import webgpuTslUrl from "../../snippets/09-webgpu-tsl.tsx?importChunkUrl"
import webgpuTslUniform from "../../snippets/09-webgpu-tsl-uniform.tsx?raw"
import webgpuTslUniformUrl from "../../snippets/09-webgpu-tsl-uniform.tsx?importChunkUrl"
import webgpuSimple from "../../snippets/10-webgpu-simple.tsx?raw"
import webgpuSimpleUrl from "../../snippets/10-webgpu-simple.tsx?importChunkUrl"
import webgpuTsl from "../../snippets/10-webgpu-tsl.tsx?raw"
import webgpuTslUrl from "../../snippets/10-webgpu-tsl.tsx?importChunkUrl"
import webgpuTslUniform from "../../snippets/10-webgpu-tsl-uniform.tsx?raw"
import webgpuTslUniformUrl from "../../snippets/10-webgpu-tsl-uniform.tsx?importChunkUrl"

# A peek at WebGPU

Expand Down
48 changes: 48 additions & 0 deletions site/src/snippets/09-look-at.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { createSignal, For } from "solid-js"
import { Canvas, createT, plugin } from "solid-three"
import * as THREE from "three"

// A plugin: every Object3D gains a `lookAt` prop that calls three's `lookAt()`.
const lookAt = plugin([THREE.Object3D], object => ({
lookAt: (target: THREE.Vector3) => object.lookAt(target),
}))

const T = createT(THREE, [lookAt])

// One cone geometry, rotated so its tip points along +Z — a Mesh's lookAt()
// aims +Z at the target (cameras/lights aim -Z; meshes are the opposite).
const cone = new THREE.ConeGeometry(0.18, 0.7, 24)
cone.rotateX(Math.PI / 2)

// A 5×5 grid of cone positions in the XY-plane.
const positions: [number, number, number][] = []
for (let x = -2; x <= 2; x++) for (let y = -2; y <= 2; y++) positions.push([x, y, 0])

export default () => {
const [target, setTarget] = createSignal(new THREE.Vector3())

return (
<Canvas camera={{ position: [0, 0, 8] }}>
{/* An invisible backdrop catches the pointer; the cones opt out of
raycasting (`raycastable={false}`) so the ray always reaches it. */}
<T.Mesh
position={[0, 0, -0.5]}
onPointerMove={event => setTarget(event.intersection.point.clone().setZ(0))}
>
<T.PlaneGeometry args={[24, 24]} />
<T.MeshBasicMaterial visible={false} />
</T.Mesh>

<For each={positions}>
{position => (
<T.Mesh geometry={cone} position={position} raycastable={false} lookAt={target()}>
<T.MeshStandardMaterial color="cornflowerblue" />
</T.Mesh>
)}
</For>

<T.AmbientLight intensity={0.5} />
<T.DirectionalLight position={[2, 2, 4]} />
</Canvas>
)
}
Loading
Loading