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 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>