DEV Community

loading...

WebGL month. Day 20. Rendering a minecraft dirt cube

lesnitsky profile image Andrei Lesnitsky ・8 min read

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

Today we're going to explore how to add textures to 3d objects.

First we'll need a new entry point

πŸ“„ index.html

      </head>
      <body>
          <canvas></canvas>
-         <script src="./dist/3d.js"></script>
+         <script src="./dist/3d-textured.js"></script>
      </body>
  </html>

πŸ“„ src/3d-textured.js

console.log('Hello textures');

πŸ“„ webpack.config.js

          texture: './src/texture.js',
          'rotating-square': './src/rotating-square.js',
          '3d': './src/3d.js',
+         '3d-textured': './src/3d-textured.js',
      },

      output: {

Now let's create simple shaders to render a 3d object with solid color. Learn more in this tutorial

πŸ“„ src/shaders/3d-textured.f.glsl

precision mediump float;

void main() {
    gl_FragColor = vec4(1, 0, 0, 1);
}

πŸ“„ src/shaders/3d-textured.v.glsl

attribute vec3 position;

uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;

void main() {
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}

We'll need a canvas, webgl context and make canvas fullscreen

πŸ“„ src/3d-textured.js

- console.log('Hello textures');
+ 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`;

Create and compile shaders. Learn more here

πŸ“„ src/3d-textured.js

+ import vShaderSource from './shaders/3d-textured.v.glsl';
+ import fShaderSource from './shaders/3d-textured.f.glsl';
+ import { compileShader } from './gl-helpers';
+ 
  const canvas = document.querySelector('canvas');
  const gl = canvas.getContext('webgl');


  canvas.style.width = `${width}px`;
  canvas.style.height = `${height}px`;
+ 
+ const vShader = gl.createShader(gl.VERTEX_SHADER);
+ const fShader = gl.createShader(gl.FRAGMENT_SHADER);
+ 
+ compileShader(gl, vShader, vShaderSource);
+ compileShader(gl, fShader, fShaderSource);

Create, link and use webgl program

πŸ“„ src/3d-textured.js


  compileShader(gl, vShader, vShaderSource);
  compileShader(gl, fShader, fShaderSource);
+ 
+ const program = gl.createProgram();
+ 
+ gl.attachShader(program, vShader);
+ gl.attachShader(program, fShader);
+ 
+ gl.linkProgram(program);
+ gl.useProgram(program);

Enable depth test since we're rendering 3d. Learn more here

πŸ“„ src/3d-textured.js


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

Setup shader input. Learn more here

πŸ“„ src/3d-textured.js

  import vShaderSource from './shaders/3d-textured.v.glsl';
  import fShaderSource from './shaders/3d-textured.f.glsl';
- import { compileShader } from './gl-helpers';
+ import { compileShader, setupShaderInput } from './gl-helpers';

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

  gl.enable(gl.DEPTH_TEST);
+ 
+ const programInfo = setupShaderInput(gl, program, vShaderSource, fShaderSource);

Now let's go to Blender and create a cube, but make sure to check "Generate UVs" so that blender can map cube vertices to a plain image.

Blender 1

Next open "UV Editing" view

Blender 2

Enter edit mode

Blender 3

Unwrapped cube looks good already, so we can export UV layout

Blender 4

Now if we open exported image in some editor we'll see something like this

Photoshop 1

Cool, now we can actually fill our texture with some content

Let's render a minecraft dirt block

Photoshop 2

Next we need to export our object from blender, but don't forget to triangulate it first

Blender 5

And finally export our object

Blender 6

Now let's import our cube and create an object. Learn here about this helper class

πŸ“„ src/3d-textured.js

  import vShaderSource from './shaders/3d-textured.v.glsl';
  import fShaderSource from './shaders/3d-textured.f.glsl';
  import { compileShader, setupShaderInput } from './gl-helpers';
+ import cubeObj from '../assets/objects/textured-cube.obj';
+ import { Object3D } from './Object3D';

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

  const programInfo = setupShaderInput(gl, program, vShaderSource, fShaderSource);
+ 
+ const cube = new Object3D(cubeObj, [0, 0, 0], [1, 0, 0]);

If we'll look into object source, we'll see lines like below

vt 0.625000 0.000000
vt 0.375000 0.250000
vt 0.375000 0.000000
vt 0.625000 0.250000
vt 0.375000 0.500000

These are texture coordinates which are referenced by faces in the 2nd "property"

f 2/1/1 3/2/1 1/3/1

# vertexIndex / textureCoordinateIndex / normalIndex

so we need to update our parser to support texture coordinates

πŸ“„ src/gl-helpers.js

  export function parseObj(objSource) {
      const _vertices = [];
      const _normals = [];
+     const _texCoords = [];
+ 
      const vertexIndices = [];
      const normalIndices = [];
+     const texCoordIndices = [];

      objSource.split('\n').forEach(line => {
          if (line.startsWith('v ')) {
              _normals.push(parseVec(line, 'vn '));
          }

+         if (line.startsWith('vt ')) {
+             _texCoords.push(parseVec(line, 'vt '));
+         }
+ 
          if (line.startsWith('f ')) {
              const parsedFace = parseFace(line);

              vertexIndices.push(...parsedFace.map(face => face[0] - 1));
+             texCoordIndices.push(...parsedFace.map(face => face[1] - 1));
              normalIndices.push(...parsedFace.map(face => face[2] - 1));
          }
      });

      const vertices = [];
      const normals = [];
+     const texCoords = [];

      for (let i = 0; i < vertexIndices.length; i++) {
          const vertexIndex = vertexIndices[i];
          const normalIndex = normalIndices[i];
+         const texCoordIndex = texCoordIndices[i];

          const vertex = _vertices[vertexIndex];
          const normal = _normals[normalIndex];
+         const texCoord = _texCoords[texCoordIndex];

          vertices.push(...vertex);
          normals.push(...normal);
+ 
+         if (texCoord) {
+             texCoords.push(...texCoord);
+         }
      }

      return { 
          vertices: new Float32Array(vertices), 
-         normals: new Float32Array(normals), 
+         normals: new Float32Array(normals),
+         texCoords: new Float32Array(texCoords), 
      };
  }

and add this property to Object3D

πŸ“„ src/Object3D.js


  export class Object3D {
      constructor(source, position, color) {
-         const { vertices, normals } = parseObj(source);
+         const { vertices, normals, texCoords } = parseObj(source);

          this.vertices = vertices;
          this.normals = normals;
          this.position = position;
+         this.texCoords = texCoords;

          this.modelMatrix = mat4.create();
          mat4.fromTranslation(this.modelMatrix, position);

Now we need to define gl buffers. Learn more about this helper class here

πŸ“„ src/3d-textured.js

  import { compileShader, setupShaderInput } from './gl-helpers';
  import cubeObj from '../assets/objects/textured-cube.obj';
  import { Object3D } from './Object3D';
+ import { GLBuffer } from './GLBuffer';

  const canvas = document.querySelector('canvas');
  const gl = canvas.getContext('webgl');
  const programInfo = setupShaderInput(gl, program, vShaderSource, fShaderSource);

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

We also need to define an attribute to pass tex coords to the vertex shader

πŸ“„ src/shaders/3d-textured.v.glsl

  attribute vec3 position;
+ attribute vec2 texCoord;

  uniform mat4 modelMatrix;
  uniform mat4 viewMatrix;

and varying to pass texture coordinate to the fragment shader. Learn more here

πŸ“„ src/shaders/3d-textured.f.glsl

  precision mediump float;

+ varying vec2 vTexCoord;
+ 
  void main() {
      gl_FragColor = vec4(1, 0, 0, 1);
  }

πŸ“„ src/shaders/3d-textured.v.glsl

  uniform mat4 viewMatrix;
  uniform mat4 projectionMatrix;

+ varying vec2 vTexCoord;
+ 
  void main() {
      gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
+ 
+     vTexCoord = texCoord;
  }

Let's setup attributes

πŸ“„ src/3d-textured.js


  const vertexBuffer = new GLBuffer(gl, gl.ARRAY_BUFFER, cube.vertices, gl.STATIC_DRAW);
  const texCoordsBuffer = new GLBuffer(gl, gl.ARRAY_BUFFER, cube.texCoords, gl.STATIC_DRAW);
+ 
+ vertexBuffer.bind(gl);
+ gl.vertexAttribPointer(programInfo.attributeLocations.position, 3, gl.FLOAT, false, 0, 0);
+ 
+ texCoordsBuffer.bind(gl);
+ gl.vertexAttribPointer(programInfo.attributeLocations.texCoord, 2, gl.FLOAT, false, 0, 0);

Create and setup view and projection matrix. Learn more here

πŸ“„ src/3d-textured.js

+ import { mat4 } from 'gl-matrix';
+ 
  import vShaderSource from './shaders/3d-textured.v.glsl';
  import fShaderSource from './shaders/3d-textured.f.glsl';
  import { compileShader, setupShaderInput } from './gl-helpers';

  texCoordsBuffer.bind(gl);
  gl.vertexAttribPointer(programInfo.attributeLocations.texCoord, 2, gl.FLOAT, false, 0, 0);
+ 
+ const viewMatrix = mat4.create();
+ const projectionMatrix = mat4.create();
+ 
+ mat4.lookAt(
+     viewMatrix,
+     [0, 0, -7],
+     [0, 0, 0],
+     [0, 1, 0],
+ );
+ 
+ mat4.perspective(
+     projectionMatrix,
+     Math.PI / 360 * 90,
+     canvas.width / canvas.height,
+     0.01,
+     100,
+ );

Pass view and projection matrices to shader via uniforms

πŸ“„ src/3d-textured.js

      0.01,
      100,
  );
+ 
+ gl.uniformMatrix4fv(programInfo.uniformLocations.viewMatrix, false, viewMatrix);
+ gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);

Setup viewport

πŸ“„ src/3d-textured.js


  gl.uniformMatrix4fv(programInfo.uniformLocations.viewMatrix, false, viewMatrix);
  gl.uniformMatrix4fv(programInfo.uniformLocations.projectionMatrix, false, projectionMatrix);
+ 
+ gl.viewport(0, 0, canvas.width, canvas.height);

and finally render our cube

πŸ“„ src/3d-textured.js

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

  gl.viewport(0, 0, canvas.width, canvas.height);
+ 
+ function frame() {
+     mat4.rotateY(cube.modelMatrix, cube.modelMatrix, Math.PI / 180);
+ 
+     gl.uniformMatrix4fv(programInfo.uniformLocations.modelMatrix, false, cube.modelMatrix);
+     gl.uniformMatrix4fv(programInfo.uniformLocations.normalMatrix, false, cube.normalMatrix);
+ 
+     gl.drawArrays(gl.TRIANGLES, 0, vertexBuffer.data.length / 3);
+ 
+     requestAnimationFrame(frame);
+ }
+ 
+ frame();

but before rendering the cube we need to load our texture image. Learn more about loadImage helper here

πŸ“„ src/3d-textured.js


  import vShaderSource from './shaders/3d-textured.v.glsl';
  import fShaderSource from './shaders/3d-textured.f.glsl';
- import { compileShader, setupShaderInput } from './gl-helpers';
+ import { compileShader, setupShaderInput, loadImage } from './gl-helpers';
  import cubeObj from '../assets/objects/textured-cube.obj';
  import { Object3D } from './Object3D';
  import { GLBuffer } from './GLBuffer';
+ import textureSource from '../assets/images/cube-texture.png';

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

- frame();
+ loadImage(textureSource).then((image) => {
+     frame();
+ });

πŸ“„ webpack.config.js

              },

              {
-                 test: /\.jpg$/,
+                 test: /\.(jpg|png)$/,
                  use: 'url-loader',
              },
          ],

and create webgl texture. Learn more here

πŸ“„ src/3d-textured.js


  import vShaderSource from './shaders/3d-textured.v.glsl';
  import fShaderSource from './shaders/3d-textured.f.glsl';
- import { compileShader, setupShaderInput, loadImage } from './gl-helpers';
+ import { compileShader, setupShaderInput, loadImage, createTexture, setImage } from './gl-helpers';
  import cubeObj from '../assets/objects/textured-cube.obj';
  import { Object3D } from './Object3D';
  import { GLBuffer } from './GLBuffer';
  }

  loadImage(textureSource).then((image) => {
+     const texture = createTexture(gl);
+     setImage(gl, texture, image);
+ 
      frame();
  });

and read fragment colors from texture

πŸ“„ src/shaders/3d-textured.f.glsl

  precision mediump float;
+ uniform sampler2D texture;

  varying vec2 vTexCoord;

  void main() {
-     gl_FragColor = vec4(1, 0, 0, 1);
+     gl_FragColor = texture2D(texture, vTexCoord);
  }

Let's move camera a bit to top to see the "grass" side

πŸ“„ src/3d-textured.js


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

Something is wrong, top part is partially white, but why?

Turns out that image is flipped when read by GPU, so we need to flip it back

Turns out that image is flipped when read by GPU, so we need to flip it back

πŸ“„ src/shaders/3d-textured.f.glsl

  varying vec2 vTexCoord;

  void main() {
-     gl_FragColor = texture2D(texture, vTexCoord);
+     gl_FragColor = texture2D(texture, vTexCoord * vec2(1, -1) + vec2(0, 1));
  }

Minecraft cube

Cool, we rendered a minecraft cube with WebGL πŸŽ‰

That's it for today, see you tomorrow πŸ‘‹!


GitHub stars
Twitter Follow

Join mailing list to get new posts right to your inbox

Source code available here

Built with

Git Tutor Logo

Discussion (0)

pic
Editor guide