DEV Community

Cover image for Let's build: 3D procedural landscape with React and three.js!
sanderdebr
sanderdebr

Posted on • Edited on

Let's build: 3D procedural landscape with React and three.js!

You can do many fun things with JavaScript these days and one of them is building stuff with 3D in the browser. In this tutorial I will show you how to build a 3D landscape using React with three.js.

This is a tutorial for three.js beginners, many similar tutorials teach you e.g. only how to create a rotating box in the browser but we will take it a step further by using React and creating an actual landscape, setting up correct lighting, camera's and more!

I will assume you have basic knowledge using JavaScript ES6+, React and webpack and npm or yarn (I will be using yarn for this tutorial, recently I switched from npm).


1. Setting up the project

We will be using three.js which is a 3D JavaScript library (https://threejs.org) together with react-three-fiber (https://github.com/react-spring/react-three-fiber), which is an awesome 'reconciler' which gives us re-usable components to make our world much easier while keeping the same performance that three.js gives.

Let's start with initializing our new app with create-react-app:
$ npx create-react-app 3d-landscape

Then we will install three and three-react-fiber packages:
$ yarn add three react-three-fiber

And remove all the files inside the /src folder except index.css and index.js.

Now create the following folders and files inside /src:

src
|--components
|  |--Controls
|  |  |--index.js
|  |--Scene
|  |  |--Lights
|  |  |  |--index.js
|  |  |--Terrain
|  |  |  |--index.js
|  |  index.js
index.css
index.js

I'm using a react code snippets extension of Visual Studio code and highly recommend using it. Just type 'rafce' inside your JS file and click enter and you're react component has been set-up! Other extensions I use are eslint and prettier.

Now this tutorial is not focused on CSS so just copy my CSS inside the main index.css file in the /src folder.

@import url("https://fonts.googleapis.com/css?family=News+Cycle&display=swap");
:root {
  font-size: 20px;
}

html,
body {
  margin: 0;
  padding: 0;
  background: #070712;
  color: #606063;
  overflow: hidden;
  font-family: "News Cycle", sans-serif;
}

#root {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

canvas,
.canvas > div {
  z-index: 1;
}

.loading {
  padding: 10px;
  transform: translate3d(-50%, -50%, 0);
}

2. Setting up the canvas

Next up we will set up the canvas inside our index.js file in the src folder.

You always need to define a canvas and put everything from your three.js scene inside it. We can also declare a camera there and define the zoom level and position of it. By using Suspense, React will wait until the scene is finished loading and show an animation or loading screen to the user.

import React, { Suspense } from "react";
import ReactDOM from "react-dom";
import { Canvas, Dom } from "react-three-fiber";
import "./index.css";

function App() {
  return (
      <Canvas camera={{ zoom: 40, position: [0, 0, 500] }}>
        <Suspense
          fallback={<Dom center className="loading" children="Loading..." />}
        >
        </Suspense>
      </Canvas>
  );
}

const root = document.getElementById("root");
ReactDOM.render(<App />, root);

3. Creating the scene

Next up we will create our scene component which we act as a holder for all the components inside our scene, which are the terrain and lights.

import React from "react";
import Lights from './Lights';
import Terrain from "./Terrain";

const Scene = () => (
  <>
    <Lights />
    <Terrain />
  </>
);

export default Scene;

Then make sure to include the scene into our main index.js file and place it inside our Suspense component.


4. Adding lights

Inside our index.js file in the /lights folder we will group together:

  • 1 fake sphere light
  • 1 ambient light
  • 1 directional light
  • 2 pointlights

If you want to learn the basics of three.js first, I recommend reading some or all chapters from https://threejsfundamentals.org/

import React from "react";

export default () => {
  const FakeSphere = () => (
    <mesh>
      <sphereBufferGeometry attach="geometry" args={[0.7, 30, 30]} />
      <meshBasicMaterial attach="material" color={0xfff1ef} />
    </mesh>
  );

  return (
    <group>
      <FakeSphere />
      <ambientLight position={[0, 4, 0]} intensity={0.3} />
      <directionalLight intensity={0.5} position={[0, 0, 0]} color={0xffffff} />
      <pointLight
        intensity={1.9}
        position={[-6, 3, -6]}
        color={0xffcc77}
      />
      <pointLight
        intensity={1.9}
        position={[6, 3, 6]}
        color={0xffcc77}
        />
    </group>
  );
};

React-three-fiber gives us easy to use components which we can group together and give properties. You will still see a black screen now rendered on your canvas (make sure to comment out the terrain components which we will make later). That is because our light has nothing to shine on. You can image it would be pretty beneficial to have some guides showing us were the lights are located. Three.js actually has some light helpers for this! Let's set them up.

We need to use a useRef() to connect our light to our light-helper, react-three-fiber provides us with the useResource hook that creates a ref and re-renders the component when it becomes available next frame.

import React from "react";
import { useResource } from "react-three-fiber";

export default () => {
  const FakeSphere = () => (
    <mesh>
      <sphereBufferGeometry attach="geometry" args={[0.7, 250, 250]} />
      <meshBasicMaterial attach="material" color={0xfff1ef} />
    </mesh>
  );

  const [ref, pLight1] = useResource();
  const [ref2, pLight2] = useResource();

  return (
    <group>
      <FakeSphere />
      <ambientLight ref={ref2} position={[0, 4, 0]} intensity={0.3} />

      <directionalLight intensity={0.5} position={[0, 0, 0]} color={0xffffff} />

      <pointLight
        ref={ref}
        intensity={1}
        position={[-6, 3, -6]}
        color={0xffcc77}
      >
        {pLight1 && <pointLightHelper args={[pLight1]} />}
      </pointLight>

      <pointLight
        ref={ref2}
        intensity={1}
        position={[6, 3, 6]}
        color={0xffcc77}
      >
        {pLight2 && <pointLightHelper args={[pLight2]} />}
      </pointLight>
    </group>
  );
};

black scene

Still the lights have nothing to shine on, but we now can see were they are located!


5. Adding controls

Let's go back to our main index.js file in the src folder and set up the controls of our camera.

import Controls from "./components/Controls";
import Scene from './components/Scene';

function App() {
  return (
      <Canvas camera={{ zoom: 40, position: [0, 0, 500] }}>
        <Suspense
          fallback={<Dom center className="loading" children="Loading..." />}
        >
          <Controls />
          <Scene />
        </Suspense>
      </Canvas>
  );
}

And inside index.js in our controls folder we will add orbitControls, so the user can orbit around our landscape. Three.js offers many more controls (https://threejs.org/docs/#examples/en/controls/OrbitControls).

By using extend() we can extend the native orbitcontrols from three.js with our code.

We will need useRef() to refer and update our camera in every frame render which is defined in the useFrame() function.

OrbitControls always need two properties: the camera and the dom element to render on. We will also give our component the possibility to retrieve more props by adding {...props}.

import React, { useRef } from "react";
import { extend, useFrame, useThree } from "react-three-fiber";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

extend({ OrbitControls });

const Controls = props => {
  const ref = useRef();
  const {
    camera,
    gl: { domElement }
  } = useThree();
  useFrame(() => ref.current && ref.current.update());
  return <orbitControls ref={ref} args={[camera, domElement]} {...props} />;
};

export default Controls;

Awesome!


6. Creating the terrain

Now comes the cool part where we actually see what our light and controls are doing! Import the terrain component inside the Scene component and open up index.js inside the Terrain folder.

For now we will just render a basic plane that is rotating. We will refer to our mesh by using useRef() and increasing it's z-rotation on every frame.

Inside each mesh component you need to include two things: a material and a geometry shape. There are many different materials (https://threejsfundamentals.org/threejs/lessons/threejs-materials.html) and geometries (https://threejs.org/docs/#api/en/core/Geometry) in three.js.

Again we will provide properties to set the size and position of our geometry, as well as defining our material and it's properties.

import React, {useRef} from "react";
import { useFrame } from "react-three-fiber";

const Terrain = () => {

  const mesh = useRef();

  // Raf loop
  useFrame(() => {
    mesh.current.rotation.z += 0.01;
  });

  return (
    <mesh ref={mesh} rotation={[-Math.PI / 2, 0, 0]}>
      <planeBufferGeometry attach="geometry" args={[25, 25, 75, 75]} />
      <meshPhongMaterial
        attach="material"
        color={"hotpink"}
        specular={"hotpink"}
        shininess={3}
        flatShading
      />
    </mesh>
  );
};  

export default Terrain;

plane rotating

Now you should see a basic plane (rotate the camera a bit to see it). Cool right! We can give this plane any color or texture you want. For now we will keep it pink.

By adding -Math.PI / 2 the plane will lay horizontally instead of vertically.


7. Generating the landscape

We want to have a more interesting terrain than this basic plane, so we will procedurally render one. This means we create it algorithmically as opposed to manually. On every reload the terrain will look different.

First create a new file in the Terrain folder called perlin.js where we will include a algorithm called Perlin noise (https://en.wikipedia.org/wiki/Perlin_noise).

You can find the algorithm here, copy the contents inside our perlin.js file:
https://github.com/josephg/noisejs/blob/master/perlin.js

Then import it into our index.js file.

We will use useUpdate() from react-three-fiber to force our geometry plane to update.

Our plane consist of many vertices which we can give a random width and height to make the plane look like a landscape. This vertices array is actually inside our geometry object:

vertices_array

Inside useUpdate we will loop over each vertice and randomize each value by using the perlin noise algorithm.
I've used a randomization I found in a codepen: https://codepen.io/ptc24/pen/BpXbOW?editors=1010.

import React from "react";
import { useFrame, useUpdate } from "react-three-fiber";

import { noise } from "./perlin";

const Terrain = () => {
  const mesh = useUpdate(({ geometry }) => {
    noise.seed(Math.random());
    let pos = geometry.getAttribute("position");
    let pa = pos.array;
    const hVerts = geometry.parameters.heightSegments + 1;
    const wVerts = geometry.parameters.widthSegments + 1;
    for (let j = 0; j < hVerts; j++) {
      for (let i = 0; i < wVerts; i++) {
        const ex = 1.1;
        pa[3 * (j * wVerts + i) + 2] =
          (noise.simplex2(i / 100, j / 100) +
            noise.simplex2((i + 200) / 50, j / 50) * Math.pow(ex, 1) +
            noise.simplex2((i + 400) / 25, j / 25) * Math.pow(ex, 2) +
            noise.simplex2((i + 600) / 12.5, j / 12.5) * Math.pow(ex, 3) +
            +(noise.simplex2((i + 800) / 6.25, j / 6.25) * Math.pow(ex, 4))) /
          2;
      }
    }

    pos.needsUpdate = true;
  });

  // Raf loop
  useFrame(() => {
    mesh.current.rotation.z += 0.001;
  });

  return (
    <mesh ref={mesh} rotation={[-Math.PI / 2, 0, 0]}>
      <planeBufferGeometry attach="geometry" args={[25, 25, 75, 75]} />
      <meshPhongMaterial
        attach="material"
        color={"hotpink"}
        specular={"hotpink"}
        shininess={3}
        flatShading
      />
    </mesh>
  );
};

export default Terrain;

awesome_landscape

There it is, great job!

Now there are many other things you can do, like adding particles in the form of stars, changing the lights and controls, even adding 3D animation to the screen and adding controls to them (make your own game).

For example, you can change the material to wireframe just by adding wireframe={true} as material property:

wireframe

Or change flatShading to smoothShading:

smoothshading

That's it, have fun building awesome things in 3D!

Checkout the repo: https://github.com/sanderdebr/three-dev-tutorial

Top comments (4)

Collapse
 
3rdp profile image
3rdp

Nice tutorial! Note that wireframe={true} is a bit explicit, for boolean props you have a shorthand of just wireframe. And that will pass true for this prop.

Collapse
 
sanderdebr profile image
sanderdebr

Thanks! I actually like to sometimes explicitly note that a prop is a boolean this way, just my preference I guess.

Collapse
 
lehongquan profile image
Le Hong Quan

Nice tutorial!
Follow up and I got this result...
Just change a little bit, use useLayoutEffect instead of useUpdate since I am using different @react-three/fiber version :)

Collapse
 
arnonafriat profile image
ArnonAfriat

Great tutorial, you think you can create a codesnadbox for it? I started to and had some issues with the noise algorithm.
It would be great if you can show how to load an image as texture for the terrain or even generate the terrain using depth map.
Thanks