Basics
Handling Events
Events are a way to listen for changes in the state of the application.
Listening to events is as easy as adding a regular Svelte event listener to <T>
components:
<T.Mesh on:click={onClick}>
. Threlte supports wheel, click and pointer events through the plugin
interactivity
from '@threlte/extras'
as well as the create
event and arbitrary three.js
object events on objects that extend THREE.EventDispatcher
(like OrbitControls
). Click,
pointer and wheel events contain the native browser event as well as the raycast event data (object,
point, distance and other data).
Create Event
The create
event is triggered when the underlying three.js object is created in a <T>
component.
<T.PerspectiveCamera
makeDefault
on:create={({ ref, cleanup }) => {
ref.lookAt(0, 0, 0)
// provide a cleanup function that is called
// when the component is destroyed or `ref`
// changes because different `args` are passed.
cleanup(() => {
console.log('cleanup')
})
}}
/>
Object Events
Some three.js objects dispatch events such as the OrbitControls
.
To listen for these events, use a regular Svelte event listener.
The following example uses the change
event from OrbitControls
to invalidate the renderer when the camera is moved.
<script>
import { T, extend, useThrelte } from '@threlte/core'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
extend({ OrbitControls })
const { renderer, invalidate } = useThrelte()
</script>
<T.PerspectiveCamera
makeDefault
let:ref
>
<T.OrbitControls
args={[ref, renderer.domElement]}
on:change={invalidate}
/>
</T.PerspectiveCamera>
Click, Pointer and Wheel Events
<script lang="ts">
import { Canvas, extend } from '@threlte/core'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import Scene from './Scene.svelte'
extend({
OrbitControls
})
</script>
<div class="relative h-full w-full">
<Canvas>
<Scene />
</Canvas>
<div
class=" absolute left-0 top-0 h-full w-full [&>*]:pointer-events-none"
id="int-target"
>
<div class="relative left-6 top-6">
<div class="text-orange text-sm font-bold uppercase">Custom Event Target</div>
<div class="text-orange text-3xl font-bold">Event Handling on Foreground Element</div>
</div>
</div>
</div>
<script lang="ts">
import { forwardEventHandlers, T } from '@threlte/core'
import { useCursor } from '@threlte/extras'
import { spring } from 'svelte/motion'
let color = 'white'
const scale = spring(1)
const component = forwardEventHandlers()
const { onPointerEnter, onPointerLeave } = useCursor()
</script>
<T.Group
scale={$scale}
{...$$restProps}
bind:this={$component}
>
<T.Mesh
position.y={0.5}
on:pointerenter={onPointerEnter}
on:pointerleave={onPointerLeave}
on:pointerenter={() => {
$scale = 2
color = '#FE3D00'
}}
on:pointerleave={() => {
$scale = 1
color = 'white'
}}
>
<T.BoxGeometry />
<T.MeshStandardMaterial {color} />
</T.Mesh>
</T.Group>
<script lang="ts">
import { forwardEventHandlers, T, useThrelte } from '@threlte/core'
const { invalidate } = useThrelte()
const el = document.getElementById('int-target')
const component = forwardEventHandlers()
</script>
<T.OrthographicCamera
zoom={80}
position={[10, 10, 10]}
makeDefault
let:ref
>
<T.OrbitControls
bind:this={$component}
args={[ref, el]}
on:change={invalidate}
enableZoom={false}
/>
</T.OrthographicCamera>
<script lang="ts">
import { T } from '@threlte/core'
import { Grid, interactivity } from '@threlte/extras'
import { spring } from 'svelte/motion'
import Box from './Box.svelte'
import Camera from './Camera.svelte'
const { target } = interactivity()
target.set(document.getElementById('int-target') ?? undefined)
const pos = spring({ x: 0, z: 0 })
const setRandomPos = () => {
pos.set({
x: Math.random() * 10 - 5,
z: Math.random() * 10 - 5
})
}
</script>
<Camera />
<T.AmbientLight intensity={0.4} />
<T.DirectionalLight position={[1, 2, 5]} />
<Box
on:click={setRandomPos}
position.x={$pos.x}
position.z={$pos.z}
/>
<Grid
position.y={-0.001}
cellColor="#ffffff"
sectionColor="#ffffff"
sectionThickness={0}
fadeDistance={25}
cellSize={2}
/>
To add click, pointer and wheel events to your Threlte app, import the plugin interactivity
from
'@threlte/extras'
and call it in your main scene component.
<script>
import { interactivity } from '@threlte/extras'
interactivity()
</script>
All child components now receive events.
<script>
import { interactivity } from '@threlte/extras'
interactivity()
</script>
<T.Mesh
on:click={() => {
console.log('clicked')
}}
>
<T.BoxGeometry />
<T.MeshStandardMaterial color="red" />
</T.Mesh>
Available Events
The following interaction events are available:
<T.Mesh
on:click={(e) => console.log('click')}
on:contextmenu={(e) => console.log('context menu')}
on:dblclick={(e) => console.log('double click')}
on:wheel={(e) => console.log('wheel')}
on:pointerup={(e) => console.log('up')}
on:pointerdown={(e) => console.log('down')}
on:pointerover={(e) => console.log('over')}
on:pointerout={(e) => console.log('out')}
on:pointerenter={(e) => console.log('enter')}
on:pointerleave={(e) => console.log('leave')}
on:pointermove={(e) => console.log('move')}
on:pointermissed={() => console.log('missed')}
/>
Event Data
All interaction events contain the following data:
type Event = THREE.Intersection & {
intersections: THREE.Intersection[] // The first intersection of each intersected object
object: THREE.Object3D // The object that was actually hit
eventObject: THREE.Object3D // The object that registered the event
camera: THREE.Camera // The camera used for raycasting
delta: THREE.Vector2 // Distance between mouse down and mouse up event in pixels
nativeEvent: MouseEvent | PointerEvent | WheelEvent // The native browser event
pointer: Vector2 // The pointer position in normalized device coordinates
ray: THREE.Ray // The ray used for raycasting
stopPropagation: () => void // Function to stop propagation of the event
stopped: Boolean // Whether the event propagation has been stopped
}
Event Propagation
Propagation works a bit differently to the DOM because objects can occlude each other in 3D. The intersections array in the event data includes all objects intersecting the ray, not just the nearest. Only the first intersection with each object is included. The event is first delivered to the object nearest the camera, and then bubbles up through its ancestors like in the DOM. After that, it is delivered to the next nearest object, and then its ancestors, and so on. This means objects are transparent to pointer events by default, even if the object handles the event.
event.stopPropagation()
doesn’t just stop this event from bubbling up, it also stops it from
being delivered to farther objects (objects behind this one). All other objects, nearer or farther,
no longer count as being hit while the pointer is over this object. If they were previously
delivered pointerover events, they will immediately be delivered pointerout events. If you want
an object to block pointer events from objects behind it, it needs to have an event handler as follows:
<T.Mesh on:click={(e) => e.stopPropagation()} />
even if you don’t want this object to respond to the pointer event. If you do want to handle the
event as well as using stopPropagation()
, remember that the pointerout
events will happen during
the stopPropagation()
call. You probably want your other event handling to happen after this.
Interactivity Event Target
If no event target is specified, all event handlers listen to events on the domElement
of the
renderer
(which is the canvas element by default). You can specify a different target by passing
a target
prop to the interactivity
plugin.
<script>
import { interactivity } from '@threlte/extras'
interactivity({
target: document
})
</script>
It’s also possible to change the target at runtime by updating the store target
returned from the
interactivity
plugin.
<script>
import { interactivity } from '@threlte/extras'
const { target } = interactivity()
$: target.set(document)
</script>
Interactivity Event Compute
In the event that your event target is not the same size as the canvas, you can pass a compute
function
to the interactivity
plugin. This function receives the DOM event and the interactivity state and should
set the pointer
property of the state to the pointer position in normalized device coordinates as well
as set the raycaster up for raycasting.
<script>
import { interactivity } from '@threlte/extras'
import { useThrelte } from '@threlte/core'
const { camera } = useThrelte()
interactivity({
compute: (event, state) => {
// Update the pointer
state.pointer.update((p) => {
p.x = (event.clientX / window.innerWidth) * 2 - 1
p.y = -(event.clientY / window.innerHeight) * 2 + 1
})
// Update the raycaster
state.raycaster.setFromCamera(state.pointer.current, $camera)
}
})
</script>
Interactivity Event Filtering
You can filter and sort events by passing a filter
to the interactivity
plugin. The function receives all hits and the interactivity state and should return the hits that should be delivered to the event handlers in the order they should be delivered.
<script>
import { interactivity } from '@threlte/extras'
interactivity({
filter: (hits, state) => {
// Only return the first hit
return hits.slice(0, 1)
}
})
</script>
Interactivity State
To access the interactivity state, you can use the useInteractivity
hook in any child component of the component that implements the interactivity
plugin as follows:
<script>
import { useInteractivity } from '@threlte/extras'
const { pointer, pointerOverTarget } = useInteractivity()
$: console.log($pointer, $pointerOverTarget)
</script>
where this is the type of the interactivity state:
export type State = {
enabled: CurrentWritable<boolean>
target: CurrentWritable<HTMLElement | undefined>
pointer: CurrentWritable<Vector2>
pointerOverTarget: CurrentWritable<boolean>
lastEvent: MouseEvent | WheelEvent | PointerEvent | undefined
raycaster: Raycaster
initialClick: [x: number, y: number]
initialHits: THREE.Object3D[]
hovered: Map<string, IntersectionEvent<MouseEvent | WheelEvent | PointerEvent>>
interactiveObjects: THREE.Object3D[]
compute: ComputeFunction
filter?: FilterFunction
}
CurrentWritable
is a custom Threlte store.
It’s a regular writable store that also has a current
property which is the current value of the
store. It’s useful for accessing the value of a store in a non-reactive context, such as in loops.