threlte logo

Interactive shader

In this tutorial, we’ll walk you through the process of configuring a shader-based material while leveraging Threlte’s built-in interactivity plugin. Specifically, you’ll learn how to dynamically adjust material uniforms based on user interactions—such as clicking on the mesh.

<script lang="ts">
  import { Canvas } from '@threlte/core'
  import Scene from './Scene.svelte'
</script>

<div>
  <span class="absolute left-0 top-0 z-20 whitespace-nowrap pl-4">Click on the terrain mesh</span>
  <Canvas>
    <Scene />
  </Canvas>
</div>

<style>
  div {
    height: 100%;
  }
</style>
<script lang="ts">
  import { T } from '@threlte/core'
  import { OrbitControls } from '@threlte/extras'
  import { createNoise2D } from 'simplex-noise'
  import { PlaneGeometry, Vector3 } from 'three'
  import { DEG2RAD } from 'three/src/math/MathUtils.js'
  import fragmentShader from './fragment.glsl?raw'
  import vertexShader from './vertex.glsl?raw'
  import { interactivity } from '@threlte/extras'
  import { quadOut } from 'svelte/easing'
  import { tweened } from 'svelte/motion'

  // Terrain setup
  const terrainSize = 30
  const geometry = new PlaneGeometry(terrainSize, terrainSize, 100, 100)
  const noise = createNoise2D()
  const vertices = geometry.getAttribute('position').array
  for (let i = 0; i < vertices.length; i += 3) {
    const x = vertices[i]
    const y = vertices[i + 1]
    // @ts-ignore
    vertices[i + 2] = noise(x / 5, y / 5) * 2 + noise(x / 40, y / 40) * 3
  }
  geometry.computeVertexNormals()

  // Interactivity and shader variables
  interactivity()
  const pulsePosition = new Vector3()
  const pulseTimer = tweened(0, {
    easing: quadOut
  })
</script>

<T.PerspectiveCamera
  makeDefault
  position={[-70, 50, 10]}
  fov={15}
>
  <OrbitControls
    autoRotate
    target.y={1.5}
    autoRotateSpeed={0.2}
  />
</T.PerspectiveCamera>

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}
  on:click={({ point }) => {
    pulsePosition.set(point.x, point.y, point.z)
    pulseTimer.set(0, {
      duration: 0
    })
    pulseTimer.set(1, {
      duration: 2000
    })
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
    uniforms={{
      pulseTimer: {
        value: 0
      },
      pulsePosition: {
        value: pulsePosition
      }
    }}
    uniforms.pulseTimer.value={$pulseTimer}
  />
</T.Mesh>
// Credit: https://madebyevan.com/shaders/grid/

varying vec2 vUv;
varying vec3 vPosition;
uniform vec3 pulsePosition;
uniform float pulseTimer;

void main() {

  float coord = vPosition.y * 2.;
  float line = abs(fract(coord - 0.5) - 0.5) / fwidth(coord);
  float lineFill = 1.0 - min(line, 1.0);
  lineFill = pow(lineFill, 1.0 / 2.2);

  float circleGrowTimer = min(pulseTimer * 2., 1.);
  float colorFadeTimer = 1. - pulseTimer;

  float circle = 1.0 - smoothstep(0.9 * circleGrowTimer, 1. * circleGrowTimer, length(pulsePosition.xz - vPosition.xz) * 0.05);

  // bright colors
  vec3 color = vec3(vPosition.y * 1.5, vUv.x, vUv.y) * 2.5;
  vec3 coloredLines = (color * colorFadeTimer * lineFill);

  vec3 final = coloredLines = mix(coloredLines, vec3(lineFill * 0.1), 1. - circle * colorFadeTimer);

  gl_FragColor = vec4(final, 1.);

}
varying vec2 vUv;
varying vec3 vPosition;

void main() {
  vec4 modelPosition = modelMatrix * vec4(position, 1.0);

  vec4 viewPosition = viewMatrix * modelPosition;
  vec4 projectedPosition = projectionMatrix * viewPosition;

  gl_Position = projectedPosition;
  vUv = uv;
  vPosition = (modelMatrix * vec4(position, 1.0)).xyz;
}

How does it work?

We’ll start this example by utilizing the mesh terrain established in one of our other examples, Terrain with 3D noise, as our foundational starting point.

Making shader material

To integrate the shader-based material into your terrain mesh, simply nest the <T.ShaderMaterial/> component as a child element. It’s essential to supply both fragmentShader and vertexShader props, formatted as strings.

In our example, these shaders are isolated into individual files — fragment.glsl for the fragment shader and vertex.glsl for the vertex shader. We leverage Vite’s ?raw special query to import these as plain strings. Although your build tool and setup might differ, this modular approach enhances code readability. Alternatively, you could provide these shaders directly as JavaScript strings.

Scene.svelte
<script>
  import fragmentShader from './fragment.glsl?raw'
  import vertexShader from './vertex.glsl?raw'
</script>

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}

  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
  />
</T.Mesh>

Setting up terrain interactivity

In this step, we set up interactivity for the Terrain Mesh. The aim is to trigger a shader animation when the user clicks on the mesh, thereby updating its variables in real-time.

To accomplish this, we first import and initialize the interactivity plugin. This extends the functionality of your mesh by enabling on:click events, akin to the familiar HTML events in Svelte. For our animation, it’s crucial to pinpoint the exact location where the user clicks on the mesh. The event generated by the plugin conveniently provides us with a point variable to identify this location.

Scene.svelte
<script>
  import { interactivity } from '@threlte/extras'
  interactivity()
</script>

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}
  on:click={({ point }) => {
    console.log('user clicked on', point)
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
  />
</T.Mesh>

Making shader interactive

With event listeners now active on our mesh, the next step is to capture these events into variables and forward them to the shader as uniforms. Our shader will require two uniform variables: one for the click position (pulsePosition as a Vector3) and another for tracking the animation timeline (pulseTimer).

To manage the timeline, we’ll employ a Svelte store using the tweened function. Both pulsePosition and pulseTimer will be updated based on the on:click event we previously implemented.

Configuring uniforms for our ShaderMaterial is straightforward and closely aligns with standard Three.js practices. While pulsePosition can be directly passed into the uniforms and will auto-update, a current limitation in the store implementation requires us to set an initial value of 0 for pulseTimer. To update it, we’ll use a pierced property: uniforms.pulseTimer.value={$pulseTimer}.

You can learn more about ShaderMaterial from Three.js ShaderMaterial docs

Scene.svelte
<script>
  import { quadOut } from 'svelte/easing'
  import { tweened } from 'svelte/motion'

  const pulsePosition = new Vector3()
  const pulseTimer = tweened(0, {
    easing: quadOut
  })
</script>

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}
  on:click={({ point }) => {
    pulsePosition.set(point.x, point.y, point.z)
    pulseTimer.set(0, {
      duration: 0
    })
    pulseTimer.set(1, {
      duration: 2000
    })
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
    uniforms={{
      pulseTimer: {
        value: 0
      },
      pulsePosition: {
        value: pulsePosition
      }
    }}
    uniforms.pulseTimer.value={$pulseTimer}
  />
</T.Mesh>

How do the Fragment and Vertex shaders work?

While a deep dive into shader mechanics falls beyond the scope of this tutorial, let’s take a moment for a brief conceptual overview:

  1. Vertex Shader: At its core, we have a straightforward vertex shader that forwards the world position of the material to the fragment shader. This is achieved using a varying vPosition variable, along with UV coordinates transferred through a varying vUv variable.
  2. Fragment Shader and User Interaction: Armed with knowledge of the material’s world position, we can dynamically render a circle originating from the pulsePosition uniform, which is set by the user’s click event. As time progresses, the circle’s radius expands, controlled by the pulseTimer uniform.

If you want to learn more about how to write shaders, the Book of Shaders is an excellent resource to start with.