Cursor Lines
This example was inspired by OGL’s Polyline example. It uses <MeshLineMaterial>
and <MeshLineGeometry>
to create a similar effect.
This effect is probably better implemented as a fragment shader but the example highlights some interesting and effective techniques for various threlte components and functions.
<script lang="ts">
import { Canvas } from '@threlte/core'
import Scene from './Scene.svelte'
</script>
<p>mouse around the canvas</p>
<Canvas>
<Scene />
</Canvas>
<style>
p {
color: white;
position: fixed;
}
</style>
<script module>
const createPoints = (count = 50) => {
const points: Vector3[] = []
for (let i = 0; i < count; i += 1) {
points.push(new Vector3())
}
return points
}
</script>
<script lang="ts">
import type { Props } from '@threlte/core'
import type { Vector3Tuple } from 'three'
import { Mesh, Vector3 } from 'three'
import { T, useTask } from '@threlte/core'
type CursorLineProps = Props<typeof Mesh, [{ getPoints(): Vector3[] }]> & {
cursorPosition: Vector3Tuple
}
let { cursorPosition, children, ...props }: CursorLineProps = $props()
const count = 50
let front = $state.raw(createPoints(count))
let back = createPoints(count)
useTask((delta) => {
back[0]?.fromArray(cursorPosition)
const alpha = 1e-6 ** delta
for (let i = 1; i < count; i += 1) {
const first = back[i - 1]
const second = back[i]
second?.lerp(first, alpha)
}
const temp = front
front = back
back = temp
})
</script>
<T.Mesh {...props}>
{@render children?.({
getPoints() {
return front
}
})}
</T.Mesh>
<script lang="ts">
import CursorLine from './CursorLine.svelte'
import type { Vector3Tuple } from 'three'
import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras'
import { Spring } from 'svelte/motion'
import { T } from '@threlte/core'
import { interactivity } from '@threlte/extras'
interactivity()
const cursorPosition = new Spring<Vector3Tuple>([0, 0, 0])
const colors = ['#0000ff', '#00ff00', '#00ffff', '#ff0000', '#ff00ff', '#ffff00']
const m = (2 * Math.PI) / colors.length
</script>
{#each colors as color, i}
{@const a = m * i}
<CursorLine
{color}
cursorPosition={cursorPosition.current}
position.x={0.5 * Math.cos(a)}
position.y={0.5 * Math.sin(a)}
>
{#snippet children({ getPoints })}
<MeshLineGeometry
points={getPoints()}
shape="taper"
/>
<MeshLineMaterial
width={10}
{color}
attenuate={false}
/>
{/snippet}
</CursorLine>
{/each}
<T.OrthographicCamera
zoom={50}
makeDefault
/>
<T.Mesh
visible={false}
onpointermove={(event) => {
cursorPosition.set(event.point.toArray())
}}
position.z={-10}
scale={100}
>
<T.PlaneGeometry />
</T.Mesh>
How Does it Work?
First we create a scene with an orthographic camera and a mesh. The mesh’s only purpose is to capture pointer move events so it needs to be big enough to fill the entire screen. The mesh is placed in front of the camera. It doesn’t really matter the exact position of the mesh because an orthographic camera has no perspective. All that really matters it is in fron of the camera and there is space inbetween the camera and the mesh - this is where the mesh lines will be drawn.
<T.OrthographicCamera
zoom={50}
makeDefault
/>
<T.Mesh
scale={100}
visible={false}
position.z={-10}
>
<T.PlaneGeometry />
</T.Mesh>
The camera is positioned at the origin and the mesh is positioned down the z-axis.
Getting the Cursor Position
To get the cursor position we use Threlte’s interactivity plugin. The event object that is passed to the onpointermove
callback has a point property which tells you where the cursor position was when the event was triggered. The cursor position is updated and sent into the <CursorLine>
component as a prop.
<script lang="ts">
const cursorPosition = new Spring<Vector3Tuple>([0, 0, 0])
interactivity()
</script>
<T.Mesh
onpointermove={(event) => {
cursorPosition = event.point.toArray()
}}
>
<!-- ... -->
</T.Mesh>
Inside the CursorLine Component
The <CursorLine>
component receives the current pointer position. To create the “trailing” effect two lists of Vector3
points are created - a back and a front. Each frame, the back set of points is updated then swapped with the front set of points. This swap causes anything that reads from the front set of points to get updated.
Making Use of $state.raw
The reason that two sets of points are used is so that $state.raw
can be used. $state.raw
is great when you have a large object such as an array and you don’t want to make the object deeply reactive. In our case we know we’re only ever going to be updating everything in the array all at once and nothing depends on updates to the individual objects in the array. In other words, we only care about when the entire array updates, specific when front
updates. The back set of points doesn’t need to be made reactive at all because nothing is listening to it.
Updating Points
Before the back and front points are swapped, the points are updated. The first point in the array is set to the current value of the spring.
back[0].fromArray(spring.current)
Then each consecutive pair of points, [first, secend]
is taken and the second point is interpolated to the first by a small amount. This update happens every frame and eventually all points are interpolated to the cursor position.
useTask((delta) => {
const alpha = 1e-6 ** delta
for (let i = 1; i < count; i += 1) {
const first = back[i - 1]
const second = back[i]
second?.lerp(first, alpha)
}
// ...
})
The value for alpha
is a little arbitrary but certain values may look may appealing than others.
For example, if alpha
is > 1, the lerp will overshoot and you’ll get very “jumpy” interpolations
that won’t ever settle. You also don’t want the value to be too small because then it won’t
interpolate quickly enough to look “smooth”. An value somewhere around .8
gives a good look and
feel.
Lastly the two point-lists are swapped which triggers anything reading front
to update.
useTask((delta) => {
//...
const temp = front
front = back
back = temp
})
In summary, we update the “back” set of points and swap it with the “front” once all points have been updated. This is very similar to a the double-buffering strategy used by many graphics software.
A “getter” for the points is available in the children
snippet so that its current value can be used.
<CursorLine>
{#snippet children({ getPoints })}
<MeshLineGeometry
points={getPoints()}
shape="taper"
/>
<MeshLineMaterial attenuate={false} />
{/snippet}
</CursorLine>