DEV Community

Cover image for WebGL month. Day 24. Combining terrain and skybox
Andrei Lesnitsky
Andrei Lesnitsky

Posted on

WebGL month. Day 24. Combining terrain and skybox

This is a series of blog posts related to WebGL. New post will be available every day

GitHub stars
Twitter Follow

Join mailing list to get new posts right to your inbox

Source code available here

Built with

Git Tutor Logo


Hey 👋

Welcome to WebGL month

In previous tutorials we've rendered minecraft terrain and skybox, but in different examples. How do we combine them? WebGL allows to use multiple programs, so we can combine both examples with a slight refactor.

Let's create a new entry point file minecraft.js and assume skybox.js and minecraft-terrain.js export prepare and render functions

import { prepare as prepareSkybox, render as renderSkybox } from './skybox';
import { prepare as prepareTerrain, render as renderTerrain } from './minecraft-terrain';
Enter fullscreen mode Exit fullscreen mode

Next we'll need to setup a canvas

const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');

const width = document.body.offsetWidth;
const height = document.body.offsetHeight;

canvas.width = width * devicePixelRatio;
canvas.height = height * devicePixelRatio;

canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
Enter fullscreen mode Exit fullscreen mode

Setup camera matrices

const viewMatrix = mat4.create();
const projectionMatrix = mat4.create();

mat4.lookAt(viewMatrix, [0, 0, 0], [0, 0, -1], [0, 1, 0]);

mat4.perspective(projectionMatrix, (Math.PI / 360) * 90, canvas.width / canvas.height, 0.01, 142);

gl.viewport(0, 0, canvas.width, canvas.height);

const cameraPosition = [0, 5, 0];
const cameraFocusPoint = vec3.fromValues(0, 0, 30);
const cameraFocusPointMatrix = mat4.create();

mat4.fromTranslation(cameraFocusPointMatrix, cameraFocusPoint);
Enter fullscreen mode Exit fullscreen mode

Define a render function

function render() {
    renderSkybox(gl, viewMatrix, projectionMatrix);
    renderTerrain(gl, viewMatrix, projectionMatrix);

    requestAnimationFrame(render);
}
Enter fullscreen mode Exit fullscreen mode

and execute "preparation" code

(async () => {
    await prepareSkybox(gl);
    await prepareTerrain(gl);

    render();
})();
Enter fullscreen mode Exit fullscreen mode

Now we need to implement prepare and render functions of skybox and terrain

Both functions will require access to shared state, like WebGL program, attributes and buffers, so let's create an object

const State = {};

export async function prepare(gl) {
    // initialization code goes here
}
Enter fullscreen mode Exit fullscreen mode

So what's a "preparation" step?

It's about creating program

  export async function prepare(gl) {
+     const vShader = gl.createShader(gl.VERTEX_SHADER);
+     const fShader = gl.createShader(gl.FRAGMENT_SHADER);

+     compileShader(gl, vShader, vShaderSource);
+     compileShader(gl, fShader, fShaderSource);

+     const program = gl.createProgram();
+     State.program = program;

+     gl.attachShader(program, vShader);
+     gl.attachShader(program, fShader);

+     gl.linkProgram(program);
+     gl.useProgram(program);

+     State.programInfo = setupShaderInput(gl, program, vShaderSource, fShaderSource);
  }
Enter fullscreen mode Exit fullscreen mode

Buffers

      gl.useProgram(program);

      State.programInfo = setupShaderInput(gl, program, vShaderSource, fShaderSource);

+     const cube = new Object3D(cubeObj, [0, 0, 0], [0, 0, 0]);
+     State.vertexBuffer = new GLBuffer(gl, gl.ARRAY_BUFFER, cube.vertices, gl.STATIC_DRAW);
  }
Enter fullscreen mode Exit fullscreen mode

Textures

      const cube = new Object3D(cubeObj, [0, 0, 0], [0, 0, 0]);
      State.vertexBuffer = new GLBuffer(gl, gl.ARRAY_BUFFER, cube.vertices, gl.STATIC_DRAW);

+     await Promise.all([
+         loadImage(rightTexture),
+         loadImage(leftTexture),
+         loadImage(upTexture),
+         loadImage(downTexture),
+         loadImage(backTexture),
+         loadImage(frontTexture),
+     ]).then((images) => {
+         State.texture = gl.createTexture();
+         gl.bindTexture(gl.TEXTURE_CUBE_MAP, State.texture);

+         gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+         gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+         gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+         gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

+         images.forEach((image, index) => {
+             gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X + index, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
+         });
+     });
}
Enter fullscreen mode Exit fullscreen mode

and setting up attributes

              gl.texImage2D(gl.TEXTURE_CUBE_MAP_POSITIVE_X   index, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
          });
      });
+     setupAttributes(gl);
}
Enter fullscreen mode Exit fullscreen mode

We need a separate function to setup attributes because we'll need to do this in render function as well. Attributes share the state between different programs, so we'll need to setup them properly each time we use different program

setupAttributes looks like this for skybox

function setupAttributes(gl) {
    State.vertexBuffer.bind(gl);
    gl.vertexAttribPointer(State.programInfo.attributeLocations.position, 3, gl.FLOAT, false, 0, 0);
}
Enter fullscreen mode Exit fullscreen mode

And now we need a render function which will pass view and projection matrices to uniforms and issue a draw call

export function render(gl, viewMatrix, projectionMatrix) {
    gl.useProgram(State.program);

    gl.uniformMatrix4fv(State.programInfo.uniformLocations.viewMatrix, false, viewMatrix);
    gl.uniformMatrix4fv(State.programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);

    setupAttributes(gl);

    gl.drawArrays(gl.TRIANGLES, 0, State.vertexBuffer.data.length / 3);
}
Enter fullscreen mode Exit fullscreen mode

This refactor is pretty straightforward, as it requires only moving pieces of code to necessary functions, so this steps will look the same for minecraft-terrain, with one exception

We're using ANGLE_instanced_arrays extension to render terrain, which sets up divisorAngle. As attributes share the state between programs, we'll need to "reset" those divisor angles.

function resetDivisorAngles() {
    for (let i = 0; i < 4; i++) {
        State.ext.vertexAttribDivisorANGLE(State.programInfo.attributeLocations.modelMatrix + i, 0);
    }
}
Enter fullscreen mode Exit fullscreen mode

and call this function after a draw call

export function render(gl, viewMatrix, projectionMatrix) {
    gl.useProgram(State.program);

    setupAttributes(gl);

    gl.uniformMatrix4fv(State.programInfo.uniformLocations.viewMatrix, false, viewMatrix);
    gl.uniformMatrix4fv(State.programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);

    State.ext.drawArraysInstancedANGLE(gl.TRIANGLES, 0, State.vertexBuffer.data.length / 3, 100 * 100);

    resetDivisorAngles();
}
Enter fullscreen mode Exit fullscreen mode

Does resulting code actually work?

Unfortunatelly no 😢
The issue is that we render the skybox inisde the cube which is smaller than our terrain, but we can fix it with a single change in skybox vertex shader

  attribute vec3 position;
  varying vec3 vTexCoord;

  uniform mat4 projectionMatrix;
  uniform mat4 viewMatrix;

  void main() {
      vTexCoord = position;
-     gl_Position = projectionMatrix * viewMatrix * vec4(position, 1);
+     gl_Position = projectionMatrix * viewMatrix * vec4(position, 0.01);
  }
Enter fullscreen mode Exit fullscreen mode

By changing the 4th argument, we'll scale our skybox by 100 times (the magic of homogeneous coordinates).

After this change the world looks ok, until we try to look at the farthest "edge" of our world cube. Skybox isn't rendered there 😢

This happens because of the zFar argument passed to projection matrix

  const projectionMatrix = mat4.create();

  mat4.lookAt(viewMatrix, [0, 0, 0], [0, 0, -1], [0, 1, 0]);

- mat4.perspective(projectionMatrix, (Math.PI / 360) * 90, canvas.width / canvas.height, 0.01, 100);
+ mat4.perspective(projectionMatrix, (Math.PI / 360) * 90, canvas.width / canvas.height, 0.01, 142);

  gl.viewport(0, 0, canvas.width, canvas.height);
Enter fullscreen mode Exit fullscreen mode

The distance to the farthest edge is Math.sqrt(size ** 2 + size ** 2), which is 141.4213562373095, so we can just pass 142

That's it!

Thanks for reading, see you tomorrow 👋


This is a series of blog posts related to WebGL. New post will be available every day

GitHub stars
Twitter Follow

Join mailing list to get new posts right to your inbox

Source code available here

Built with

Git Tutor Logo

Top comments (0)