Writing Custom Shaders in React Three Fiber

The author
Stephen Castle20 days ago

If you've spent any time building scenes in React Three Fiber you've probably been getting a lot of mileage out of meshStandardMaterial and its cousins. Those materials are great, but at some point you'll want to do something they just can't do. Maybe you want to make a plane ripple like water, or fade an object's color over time, or paint something with a procedural pattern that no texture file could ever capture. That's where shaders come in.

A shader is a tiny program that runs directly on the GPU for every vertex and every pixel that gets drawn. They are written in a C-like language called GLSL, and once you get past the syntax they're actually really fun to play with. In this tutorial we'll write our own custom shader in React Three Fiber to displace and color a plane of geometry. We'll start with the simplest possible shader, then build it up step by step into something that animates over time.

What is a Shader, Really?

Before we start writing code let's quickly cover what a shader actually is. Every time Three.js draws an object on the screen it runs two small programs on the GPU.

  1. The vertex shader runs once for every vertex in your geometry. Its job is to compute the final position of that vertex on the screen.
  2. The fragment shader runs once for every pixel that gets drawn. Its job is to compute the final color of that pixel.

Every standard material in Three.js is really just a built-in shader with some inputs you can tweak. When we write our own custom shader we're throwing all of that away and taking direct control. That sounds scary but it doesn't have to be. Let's start with the simplest version we can and build up from there.

Project Setup

If you already have a React Three Fiber project you can skip this part. Otherwise create a new app and add the dependencies we need.

npm create vite@latest shader-demo -- --template react
cd shader-demo
npm install three @react-three/fiber @react-three/drei

Replace the contents of src/index.css so our canvas can fill the viewport.

* {
  box-sizing: border-box;
}

html,
body,
#root {
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  background: #111;
}

And then drop the following into src/App.jsx. This gives us an empty scene with a plane sitting in front of the camera. We'll replace the material on that plane in the next step.

import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";

function Wave() {
  return (
    <mesh rotation={[-Math.PI / 3, 0, 0]}>
      <planeGeometry args={[4, 4, 64, 64]} />
      <meshBasicMaterial color="hotpink" wireframe />
    </mesh>
  );
}

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 4], fov: 60 }}>
      <OrbitControls />
      <Wave />
    </Canvas>
  );
}

You should see a hot pink wireframe plane tilted slightly toward the camera. Notice that we passed four arguments to planeGeometry instead of just two. The last two numbers are the number of subdivisions along the x and y axis. This is important for our vertex shader, because the shader can only move vertices that actually exist. The more subdivisions we have, the smoother our deformation will be later on.

Our First Shader

Now let's replace meshBasicMaterial with a custom shaderMaterial component. The shaderMaterial element is built into React Three Fiber and lets us pass our own vertex and fragment shaders as props. We'll write the shaders as plain template literal strings.

import { Canvas } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";

const vertexShader = /* glsl */ `
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = /* glsl */ `
  void main() {
    gl_FragColor = vec4(1.0, 0.4, 0.8, 1.0);
  }
`;

function Wave() {
  return (
    <mesh rotation={[-Math.PI / 3, 0, 0]}>
      <planeGeometry args={[4, 4, 64, 64]} />
      <shaderMaterial
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
      />
    </mesh>
  );
}

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 4], fov: 60 }}>
      <OrbitControls />
      <Wave />
    </Canvas>
  );
}

If everything went according to plan you should see a flat pink plane. It looks almost identical to what we had before, except now we are the ones drawing it instead of Three.js. Let's break down what's happening in those two strings.

In the vertex shader, position is the original position of each vertex in our geometry. We multiply it by modelViewMatrix to apply the position and rotation of our mesh, then by projectionMatrix to apply the camera. The result gets assigned to a special variable called gl_Position, which is how the GPU knows where to draw the vertex. Three.js gives us those matrices and that position attribute for free, which is super convenient.

In the fragment shader, gl_FragColor is the final color of the pixel. It's a vec4 with red, green, blue, and alpha channels, each in the 0 to 1 range. Right now we're hard-coding a pink color for every pixel. Pretty boring, but it's a working shader and that's something.

Passing Information from the Vertex to the Fragment Shader

A flat color is fine, but what if we want the color to vary across the surface? To do that we need a way for the vertex shader to send information to the fragment shader. In GLSL this is called a varying. You declare a varying with the same name in both shaders, set it in the vertex shader, and read it in the fragment shader. The GPU automatically interpolates the value across each triangle for us.

Every plane geometry comes with built-in uv coordinates, which range from 0 to 1 across the surface. They're perfect for this. Let's pass them through.

const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    gl_FragColor = vec4(vUv.x, vUv.y, 0.6, 1.0);
  }
`;

Now the red channel is driven by the x coordinate and the green channel by the y coordinate. You should see a smooth gradient across the plane, going from black in one corner to yellow-ish in the opposite corner. We just got the vertex shader and fragment shader talking to each other and that opens up a lot of possibilities.

Adding a Uniform to Send Data from React

So far our shader is totally isolated from the React side of things. To change anything we have to edit the GLSL string. That's not very useful. To send values from JavaScript into the shader we use something called a uniform. A uniform is just a variable that's the same for every vertex and every pixel in a single draw call, and we can update it from React.

The first uniform we'll add is time. With time as a uniform we can make our shader animate. We declare the uniform in our shader, then we pass it as an object to the uniforms prop on the material, and finally we update it on every frame with useFrame.

import { useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";

const vertexShader = /* glsl */ `
  varying vec2 vUv;

  void main() {
    vUv = uv;
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
`;

const fragmentShader = /* glsl */ `
  uniform float uTime;
  varying vec2 vUv;

  void main() {
    float pulse = sin(uTime * 2.0) * 0.5 + 0.5;
    gl_FragColor = vec4(vUv.x * pulse, vUv.y, 0.6, 1.0);
  }
`;

function Wave() {
  const materialRef = useRef();

  useFrame((state) => {
    materialRef.current.uniforms.uTime.value = state.clock.elapsedTime;
  });

  return (
    <mesh rotation={[-Math.PI / 3, 0, 0]}>
      <planeGeometry args={[4, 4, 64, 64]} />
      <shaderMaterial
        ref={materialRef}
        vertexShader={vertexShader}
        fragmentShader={fragmentShader}
        uniforms={{
          uTime: { value: 0 },
        }}
      />
    </mesh>
  );
}

A few things to point out here. The uniforms prop takes an object where each key is the name of the uniform and the value is an object with a value property. Three.js needs that wrapper object so it can mutate the value in place without rebuilding the material. That's also why we update the uniform with materialRef.current.uniforms.uTime.value = ... instead of replacing the whole uniforms object.

You should now see the red channel pulse in and out as uTime cycles through the sine wave. We're animating! Try changing the multiplier on uTime to see how it affects the speed, or try driving a different channel by the pulse value.

Displacing the Geometry in the Vertex Shader

So far we've only been coloring pixels. But the vertex shader can also move vertices around, which is where things get really cool. Let's make the plane ripple like a sheet of water. We'll do this by adding a small offset to each vertex's z position based on its x and y coordinates and the current time.

const vertexShader = /* glsl */ `
  uniform float uTime;
  varying vec2 vUv;
  varying float vElevation;

  void main() {
    vUv = uv;

    vec3 pos = position;
    float elevation = sin(pos.x * 3.0 + uTime) * 0.15
                    + cos(pos.y * 3.0 + uTime * 1.3) * 0.15;
    pos.z += elevation;
    vElevation = elevation;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
  }
`;

const fragmentShader = /* glsl */ `
  uniform float uTime;
  varying vec2 vUv;
  varying float vElevation;

  void main() {
    vec3 lowColor = vec3(0.1, 0.2, 0.6);
    vec3 highColor = vec3(0.9, 0.6, 1.0);
    float mixAmount = vElevation * 3.0 + 0.5;
    vec3 color = mix(lowColor, highColor, mixAmount);
    gl_FragColor = vec4(color, 1.0);
  }
`;

The plane now ripples in two overlapping sine waves and the color shifts smoothly between deep blue in the troughs and a soft purple at the peaks. Notice how we're passing the elevation value out of the vertex shader as a varying called vElevation, then using it in the fragment shader to choose a color. This is a really common pattern. The vertex shader figures out something interesting about a vertex and hands it down to the fragment shader to drive the look.

The mix function in GLSL is the same idea as a linear interpolation in JavaScript. It blends between two values based on a third number from 0 to 1. There are a ton of these little helper functions in GLSL like clamp, step, smoothstep, and length. Once you start learning a few of them you can build up some really interesting effects pretty fast.

Cleaning It Up with drei's shaderMaterial Helper

If you keep going down this path you're going to have a lot of shader code mixed into your scene components. There's a nicer way. The drei library ships a helper called shaderMaterial that lets you bundle your uniforms, vertex shader, and fragment shader into their own reusable material class. Then you can extend it into a JSX element and use it just like any other Three.js component.

import { useRef } from "react";
import * as THREE from "three";
import { Canvas, extend, useFrame } from "@react-three/fiber";
import { OrbitControls, shaderMaterial } from "@react-three/drei";

const WaveMaterial = shaderMaterial(
  {
    uTime: 0,
    uLowColor: new THREE.Color("#1a3399"),
    uHighColor: new THREE.Color("#e69cff"),
  },
  /* vertex */ `
    uniform float uTime;
    varying vec2 vUv;
    varying float vElevation;

    void main() {
      vUv = uv;
      vec3 pos = position;
      float elevation = sin(pos.x * 3.0 + uTime) * 0.15
                      + cos(pos.y * 3.0 + uTime * 1.3) * 0.15;
      pos.z += elevation;
      vElevation = elevation;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
    }
  `,
  /* fragment */ `
    uniform vec3 uLowColor;
    uniform vec3 uHighColor;
    varying float vElevation;

    void main() {
      float mixAmount = vElevation * 3.0 + 0.5;
      vec3 color = mix(uLowColor, uHighColor, mixAmount);
      gl_FragColor = vec4(color, 1.0);
    }
  `
);

extend({ WaveMaterial });

function Wave() {
  const materialRef = useRef();

  useFrame((state) => {
    materialRef.current.uTime = state.clock.elapsedTime;
  });

  return (
    <mesh rotation={[-Math.PI / 3, 0, 0]}>
      <planeGeometry args={[4, 4, 64, 64]} />
      <waveMaterial ref={materialRef} />
    </mesh>
  );
}

export default function App() {
  return (
    <Canvas camera={{ position: [0, 0, 4], fov: 60 }}>
      <OrbitControls />
      <Wave />
    </Canvas>
  );
}

A few things changed here. The shaderMaterial helper from drei takes our uniforms, vertex shader, and fragment shader and returns a new material class. We pass it to extend so React Three Fiber knows about the new JSX element. Notice we capitalize WaveMaterial when we define it but use <waveMaterial /> with a lowercase first letter when we use it as a JSX element. That's the same convention every Three.js component in React Three Fiber follows.

The other neat thing about the drei helper is that the uniforms get promoted to direct properties on the material instance. So instead of writing materialRef.current.uniforms.uTime.value we can just write materialRef.current.uTime. The uniforms also become props on the JSX element, so you can pass them in declaratively from the parent. Try adding uLowColor="orange" to the <waveMaterial /> and watch the colors shift live.

Where to Go From Here

You now have all the pieces you need to start writing your own shaders. Try playing with the math in the vertex shader to get different wave shapes. Change sin to cos or stack more waves on top of each other. Try driving the elevation by length(pos.xy) to make ripples emanate from the center. In the fragment shader, try replacing the mix with step to get sharp color bands instead of a smooth gradient.

A few things I want to cover in upcoming tutorials. How to use noise functions like simplex noise inside a shader to make organic-looking patterns. How to sample a texture inside a fragment shader so you can mix shader effects with art assets. And how to use post-processing shaders that run on the entire rendered image at the end of the frame. Subscribe to the Code Workshop mailing list if you want to be notified when those go up.