threlte logo

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 lang="ts">
  import type { Props } from '@threlte/core'
  import type { Vector3Tuple } from 'three'
  import { Mesh, Vector3 } from 'three'
  import { MeshLineGeometry, MeshLineMaterial } from '@threlte/extras'
  import { Spring } from 'svelte/motion'
  import { T, useTask } from '@threlte/core'

  type CursorLineProps = Props<typeof Mesh> & {
    color: string
    cursorPosition: Vector3Tuple
    damping: number
    stiffness: number
    width: number
  }

  let { cursorPosition, color, width, stiffness, damping, ...props }: CursorLineProps = $props()

  const tween = Spring.of(() => cursorPosition, {
    damping,
    stiffness
  })

  const createPoints = (count = 50) => {
    const points: Vector3[] = []
    for (let i = 0; i < count; i += 1) {
      points.push(new Vector3())
    }
    return points
  }

  const count = 50
  let front = $state.raw(createPoints(count))
  // back doesn't need to be reactive because we're only concerned when `front` updates
  let back = createPoints(count)

  useTask((delta) => {
    back[0]?.fromArray(tween.current)
    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}>
  <MeshLineGeometry
    points={front}
    shape="taper"
  />
  <MeshLineMaterial
    {width}
    {color}
    scaleDown={0.1}
    attenuate={false}
  />
</T.Mesh>
<script lang="ts">
  import CursorLine from './CursorLine.svelte'
  import { T } from '@threlte/core'
  import type { Vector3Tuple } from 'three'
  import { interactivity } from '@threlte/extras'

  interactivity()

  let cursorPosition: Vector3Tuple = $state.raw([0, 0, 0])
  const colors = ['#fc6435', '#ff541f', '#f53c02', '#261f9a', '#1e168d']
</script>

{#each colors as color, i}
  <CursorLine
    {color}
    {cursorPosition}
    position.y={5 - i}
    stiffness={0.02 * i + 0.02}
    damping={0.25 - 0.04 * i}
    width={15 + i * 10}
  />
{/each}

<T.OrthographicCamera
  zoom={50}
  makeDefault
  position.y={10}
  oncreate={(ref) => {
    ref.lookAt(0, 0, 0)
  }}
/>

<T.Mesh
  visible={false}
  onpointermove={(event) => {
    cursorPosition = event.point.toArray()
  }}
  scale={100}
  rotation.x={-1 * 0.5 * Math.PI}
>
  <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 camera is placed above the mesh and is rotated to look down upon the mesh using the lookAt property.

<T.OrthographicCamera
  zoom={50}
  makeDefault
  position.y={10}
  oncreate={(ref) => {
    ref.lookAt(0, 0, 0)
  }}
/>

<T.Mesh
  rotation.x={-1 * 0.5 * Math.PI}
  scale={100}
  visible={false}
>
  <T.PlaneGeometry />
</T.Mesh>

The mesh is rotated so that it lies flat on the xz-plane and is made invisible.

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 positions is updated and sent into the <CursorLine> component as a prop.

<script lang="ts">
  interactivity()
  let cursorPosition: Vector3Tuple = $state.raw([0, 0, 0])
</script>

<T.Mesh
  onpointermove={(event) => {
    cursorPosition = event.point.toArray()
  }}
>
  <!-- ...  -->
</T.Mesh>

$state.raw is used over $state to store the position because we don’t care about making each entry in the tuple reactive. More on this in the next section.

Inside the CursorLine Component

The <CursorLine> component receives the current pointer position and uses a Spring to interpolate between updates. 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.