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>
  <Canvas>
    <Scene />
  </Canvas>
  <span class="absolute left-4 top-4 z-20 whitespace-nowrap text-white">Click on the terrain</span>
</div>

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

  // Terrain setup
  const terrainSize = 30
  const geometry = new PlaneGeometry(terrainSize, terrainSize, 100, 100)
  const noise = new SimplexNoise()

  const vertices = geometry.getAttribute('position')
  for (let i = 0; i < vertices.count; i += 1) {
    const x = vertices.getX(i)
    const y = vertices.getY(i)
    vertices.setZ(i, noise.noise(x / 5, y / 5) * 2 + noise.noise(x / 40, y / 40) * 3)
  }
  geometry.computeVertexNormals()

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

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

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}
  onclick={({ point }) => {
    pulsePosition.copy(point)
    pulseTimer
      .set(0, {
        duration: 0
      })
      .then(() => {
        pulseTimer.set(1, { duration: 2000 })
      })
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
    uniforms={{
      pulseTimer: {
        value: 0
      },
      pulsePosition: {
        value: 0
      }
    }}
    uniforms.pulseTimer.value={pulseTimer.current}
    uniforms.pulsePosition.value={pulsePosition}
  />
</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.

The 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. These files are imported using Vite’s ?raw special query which imports them as raw 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>

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 from @threlte/extras. This extends the functionality of meshes by enabling onclick 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}
  onclick={({ point }) => {
    console.log('user clicked on', point)
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
  />
</T.Mesh>

Shader Interactivity

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 Tween class. Both pulsePosition and pulseTimer will be updated in the onclick event callback.

Configuring uniforms for our ShaderMaterial is straightforward and closely aligns with standard Three.js practices. To update the pulseTimer and pulsePosition uniform value, we’ll use a pierced property: uniforms.pulseTimer.value={pulseTimer.current}.

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

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

<T.Mesh
  {geometry}
  rotation.x={DEG2RAD * -90}
  onclick={({ point }) => {
    pulsePosition.copy(point)
    pulseTimer
      .set(0, {
        duration: 0
      })
      .then(() => {
        pulseTimer.set(1, { duration: 2000 })
      })
  }}
>
  <T.ShaderMaterial
    {fragmentShader}
    {vertexShader}
    uniforms={{
      pulseTimer: {
        value: 0
      },
      pulsePosition: {
        value: 0
      }
    }}
    uniforms.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.