Let's Talk Three.js

3D on the web is more accessible than you might think 💡

javascriptweb developmentgame development
Let's Talk Three.js

I recently had a chat with someone around those really cool 3D product ads on websites. The last time I used three.js was professionally, around 5 months ago.

I realized that I miss it a little bit ❤️…

So I thought, why not experiment with mdx, react and three.js to render 3D scenes in a blog post?

thumbs-up-kid-meme

What is Three.js?

3D Rendering

Without going into too much depth of how 3D rendering works, there are ALOT of ‘simple’ algebraic calculations that need to happen in order for us to have pretty visuals at 60 frames per second. For example,

  • How do surfaces get lighter or darker depending on where the light sources are?
  • How do we understand how glossy or matte a surface is?

3D Projection

Remember too, we see a representation of a 3D world projected onto our 2D monitors and finally, into our eyeballs. Here’s a simple example of how this is done in C++.

cpp

void PerspectiveCamera::OnUpdate(float delta)
{
    update_projection_matrix();
    update_vectors();
    update_view_matrix();
}

Shaders

The key here, is we need to do alot of algebraic calculations in parallel. Turns out, our GPU’s are very good at doing this with what we call, shader programs.

Here’s an example of a vertex shader:

glsl

uniform mat4 projection;  // Projection matrix
uniform mat4 view;        // View matrix
uniform mat4 model;       // Model matrix

layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec2 texCoord;

out vec3 fragPosition;
out vec3 fragNormal;
out vec2 fragTexCoord;

void main() {
    // vertex position to world space
    fragPosition = vec3(model * vec4(position, 1.0));

    //  normal to world space
    fragNormal = mat3(transpose(inverse(model))) * normal;

    // texture coordinates to fragment shader
    fragTexCoord = texCoord;

    // Calculate final position in clip space
    gl_Position = projection * view * model * vec4(position, 1.0);
}

Within these programs, you will see many mathematical functions applied to vectors and matrices.

These high-level concepts really clicked when I worked with OpenGL in C++.

Three.js is a high-level javascript library that abstracts away much of the complexity of WebGL.

Let’s suppose we wanted to only use WebGL to draw a 3D spinning cube onto the screen like so

Now, yes, you can use raw webgl like an absolute chad, but do you really wanna write all of this?

  1. Defining Vertices

javascript

const positions = [
  // Front face
  -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0,
  // Back face
  -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0,
  // Top face
  -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, -1.0,
  // Bottom face
  -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,
  // Right face
  1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, 1.0, 1.0, 1.0, -1.0, 1.0,
  // Left face
  -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0, -1.0,
];

gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
  1. Loading Shaders

javascript

function loadShader(
  gl: WebGLRenderingContext,
  type: number,
  source: string
) {
  const shader = gl.createShader(type);
  if (!shader) return null;

  gl.shaderSource(shader, source);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.error(
      "An error occurred compiling the shaders: " +
        gl.getShaderInfoLog(shader)
    );
    gl.deleteShader(shader);
    return null;
  }

  return shader;
}
  1. Working with Buffers

javascript

const numComponents = 4;
const type = gl.FLOAT;
const normalize = false;
const stride = 0;
const offset = 0;
gl.bindBuffer(gl.ARRAY_BUFFER, buffers.color);
gl.vertexAttribPointer(
  programInfo.attribLocations.vertexColor,
  numComponents,
  type,
  normalize,
  stride,
  offset
);
gl.enableVertexAttribArray(programInfo.attribLocations.vertexColor);

Don’t know about you, but I would rather not write 400 lines of code to get a cube onto the screen.

Here’s the equivalent in React Three Fiber and Drei with 300 lines less code and a camera!

Demo Time 🚀

We’re going to a create a simple product configurator 🎉

Getting the model

I’m going to yoink a simple model off of sketchfab.

Setting up the model

I happen to be quite lazy, so we’re going to use gltf-react-three to generate some code for us.

Upload the model, adjust some settings and hit download project.

You can find the model in scene/src/model.ts.

Not found

src/model.ts

/*
Auto-generated by: https://github.com/pmndrs/gltfjsx
Author: assetfactory (https://sketchfab.com/assetfactory)
License: SKETCHFAB Standard (https://sketchfab.com/licenses)
Source: https://sketchfab.com/3d-models/city-bicycle-ecef0231d91645128da899bc7c07d284
Title: City Bicycle
*/

import * as THREE from "three";
import { useGLTF } from "@react-three/drei";
import { type JSX } from "react";

export function Model(props: JSX.IntrinsicElements["group"]) {
  const { nodes, materials } = useGLTF("/scene.gltf");
  return (
    <group {...props} dispose={null}>
      <group rotation={[-Math.PI / 2, 0, 0]}>
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_3 as THREE.Mesh).geometry}
          material={materials.BlackRough}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_4 as THREE.Mesh).geometry}
          material={materials.BlackSmooth}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_5 as THREE.Mesh).geometry}
          material={materials.BlackSmooth}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_6 as THREE.Mesh).geometry}
          material={materials.BlackSmooth}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_7 as THREE.Mesh).geometry}
          material={materials.GreyPlastic}
        />
        <mesh
          castShadow
          receiveShadow
          geometry={(nodes.Object_8 as THREE.Mesh).geometry}
          material={materials.MetalWhite}
        />
      </group>
    </group>
  );
}

useGLTF.preload("/scene.gltf");

Perfect, let’s import it into a simple scene

Not found

tsx

import { Canvas } from "@react-three/fiber";
import { OrbitControls, Stage } from "@react-three/drei";
import { Model } from "./bike";
import { useRef, Suspense } from "react";
import { OrbitControls as OrbitControlsImpl } from "three-stdlib";

const Configurator = () => {
  const ref = useRef<OrbitControlsImpl>(null);

  return (
    <Canvas shadows dpr={[1, 2]} camera={{ fov: 50 }} className="rounded-md">
      <Suspense fallback={null}>
        <Stage preset="upfront" intensity={1} environment="warehouse">
          <Model />
        </Stage>
      </Suspense>
      <OrbitControls ref={ref} />
    </Canvas>
  );
};

export default Configurator;

Cool! We have a bike 🥳

Changing the Color

I think it would be pretty cool if we could change the colors of some of the parts 🤔.

I’m going to leverage React’s, well, reactivity. Plus, I like Tailwind.

We’ll simply add a color prop to our model component from earlier.

Bike.tsx

interface BikeProps {
  frameColor?: string;
}

Now to add some state

Bike.tsx

const [frameMaterial, setFrameMaterial] = useState<THREE.Material | null>(null);

And make it reactive

Bike.tsx

useEffect(() => {
  if (props.frameColor) {
    const newMaterial =
      materials.MetalWhite.clone() as THREE.MeshStandardMaterial;
    newMaterial.color = new THREE.Color(props.frameColor);
    setFrameMaterial(newMaterial);
  } else {
    setFrameMaterial(materials.MetalWhite);
  }
}, [props.frameColor, materials]);

And add some UI

Not found

tsx

const ColorPicker = ({
  selectedColor,
  onSelectColor,
}: {
  selectedColor: string;
  onSelectColor: (color: string) => void;
}) => {
  const colors = [
    { hex: "#4B4B4B" },
    { hex: "#C1121F" },
    { hex: "#007BFF" },
    { hex: "#228B22" },
    { hex: "#CD7F32" },
  ];

  return (
    <div className="grid grid-cols-3 gap-5">
      {colors.map((color) => (
        <button
          key={color.name}
          onClick={() => onSelectColor(color.hex)}
          className={`w-8 h-8 rounded-full cursor-pointer ${
            selectedColor === color.hex
              ? "ring-1 ring-offset-2 ring-accent-foreground"
              : ""
          }`}
          style={{ backgroundColor: color.hex }}
        />
      ))}
    </div>
  );
};

Demo

Customize Your Frame

As you can imagine, this would be an amazing way to customize an order before buying a product.

My blog is open source so if you would like to see any of the source code, you’re free to do so.

Have an awesome day ❤️

Until next time 👋

CC BY-SA 4.0 by Joshua Macauley