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





Join mailing list to get new posts right to your inbox

Source code available here

Built with

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.

Next open "UV Editing" view

Enter edit mode

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

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

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

Let's render a minecraft dirt block

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

And finally export our object

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('

').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)); }

Cool, we rendered a minecraft cube with WebGL 🎉

That's it for today, see you tomorrow 👋!





Join mailing list to get new posts right to your inbox

Source code available here

Built with