WebGL guide (part 2/2): Advanced techniques

June 2020 - WIP




Introduction

Welcome to the second part of my WebGL guide!

I had to split the guide in two parts to embed all my demos... indeed, browsers tend to lose their WebGL contexts after 16 occurrences.
Context loss can also happen when the computer goes into hibernation mode, and can be fixed by adding these two event listeners in your program:

canvas.onwebglcontextlost = e => e.preventDefault(); // make the canvas trigger onwebglcontextrestored if the context is lost
canvas.webglcontextrestored = e => { /* re-init shader, program, buffers, textures and clear color here */ }

Anyway, this second part will show a bunch of techniques built upon the basics shown in part 1, that I recommend reading first.

In this page, to make things easier to read, I will only show the relevant lines of codes involved to produce each effect. A link to a complete, editable demo will be available when necessary.

Keep in mind that most of the advanced visual effects presented here rely on the same basic principles shown in the previous parts (messing with light rays, angles, distances, sizes, reflections, projections and colors, in order to make the computer draw what we want, pixel per pixel).
This also means that you can invent your own tricks! (this guide is as complete as possible, but not exhaustive).

I'd like to thank again all the people and resources that helped me write this guide, and all the readers who came here thanks to JS newsletters, social networks and word of mouth.

Let's get started!



Table of contents




Advanced 3D rendering



Transparency and alpha blending

There are many ways to handle transparency in a WebGL program:

  • First, remember that the HTML <canvas> is transparent by default, and can absolutely stay like that if it doesn't get a CSS background or a clear color (as we saw in part 1).
  • The fragment shader can set a rgba value for each pixel it renders. By default, when lowering the "a", it will blend with the canvas's background (not its clear color):


  • In order to blend a triangle it with the clear color (or the elements behind it), you need to call two new functions:
    gl.enable(gl.BLEND); // enable alpha blending
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // specify how alpha must blend: fragment color * alpha + clear color * (1 - alpha)

    OPEN FULL DEMO

  • If you want to use a PNG image with transparency as a texture, many solutions are possible, but the most convenient one is to enable alpha premultiplication when loading the image:
    gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);

    If premultiplication is not enabled, the transparent pixels will become white and the semi-transparent ones will become fully opaque:

    OPEN FULL DEMO

  • Finally, you may want to give some transparency to entire objects. To do that, you can assign a RGBA color to each vertex, with A < 1.0.
    Then, enable WegGL's alpha blending with
    gl.enable(gl.BLEND);
    and
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    as we did earlier.
    If you're drawing an object (like a cube) with transparency, you also need to disable gl.DEPTH_TEST, so that the front faces don't block the other faces from being drawn
    If your scene contains a mix of opaque and transparent objects, you'll need to draw all your opaque objects first, then disable depth test with
    gl.disable(gl.DEPTH_TEST);
    , then draw all your objects with transparency, then re-enable depth test with
    gl.enable(gl.DEPTH_TEST);
    for the next frame.
// Set rgba colors for each face's vertices
var colors = new Float32Array([
  0.5, 0.5, 1.0, 0.3,   0.5, 0.5, 1.0, 0.3,
  0.5, 0.5, 1.0, 0.3,   0.5, 0.5, 1.0, 0.3, // front
  0.5, 1.0, 0.5, 0.3,   0.5, 1.0, 0.5, 0.3,
  0.5, 1.0, 0.5, 0.3,   0.5, 1.0, 0.5, 0.3, // right
  1.0, 0.5, 0.5, 0.3,   1.0, 0.5, 0.5, 0.3,
  1.0, 0.5, 0.5, 0.3,   1.0, 0.5, 0.5, 0.3, // up
  1.0, 1.0, 0.5, 0.3,   1.0, 1.0, 0.5, 0.3,
  1.0, 1.0, 0.5, 0.3,   1.0, 1.0, 0.5, 0.3, // left
  1.0, 1.0, 1.0, 0.3,   1.0, 1.0, 1.0, 0.3,
  1.0, 1.0, 1.0, 0.3,   1.0, 1.0, 1.0, 0.3, // down
  0.5, 1.0, 1.0, 0.3,   0.5, 1.0, 1.0, 0.3,
  0.5, 1.0, 1.0, 0.3,   0.5, 1.0, 1.0, 0.3  // back
]);

(...)

// Pass 4 values to the color attribute instead of 3
buffer(gl, colors, program, 'color', 4, gl.FLOAT);

(...)

// Set the clear color
gl.clearColor(0.0, 0.0, 0.0, 1.0);

// Enable alpha blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);

(...)

// Clear
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

// Draw opaque objects here (if any)
// (...)

// Disable depth test
gl.disable(gl.DEPTH_TEST);

// Draw semi-transparent objects here (if any)
gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0);

// Reenable depth test for next frame
gl.enable(gl.DEPTH_TEST);






Mouse interactivity

Technique 1: Select a 3D item with the mouse

Here's the simple (and a bit hacky) way:

  • When the canvas is clicked, color each object in the scene with a different flat color (for example, a different shade of red).
  • Then read the pixel color where the mouse is clicked and find the corresponding object.
  • Then, immediately repaint every object with their correct color / texture / lighting so that the hack doesn't have time to appear on screen.
This technique can be used for an object (like a cube), or a face, or a single triangle, depending on your needs!

var vshader = `
// (...)
uniform float objectNumber; // 1.0 - 255.0
void main() {
  gl_Position = mvp * pos;
  
  // (...)
  
  // On click, draw the shape with a flat color
  if(objectNumber){
    v_col = vec4(objectNumber, 0.0, 0.0, 1.0);
  }
  
  // Else, draw normally
  else {
    v_col = col;
  }
}`;

// (...)

canvas.onclick = e => {
  var x = e.clientX - canvas.offsetLeft;
  var y = e.clientY - canvas.offsetTop;
  var picked = false;
  
  // Draw each object with a different shade of red
  for(var i in myObjects){
    gl.uniform1f(objectNumber, i);
    draw(...);
  }
  
  // Read pixel at the clicked position
  var pixel = new Uint8Array(4);
  gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);

  // Do something with the red value of the pixel
  alert("object " + pixels[0] + " clicked");
  
  // Disable hack and rerender everything normally
  gl.uniform1f(objectNumber, 0);
  for(i in myObjects){
    draw(...);
  }
}

Click the cube to trigger an alert



Click a face of the cube to paint it in white

A less hacky way consists in computing the intersection between the vector starting from the mouse and the triangles in your scene to see which one is clicked (more info on 3Dkingdoms).

Technique 2: rotate a cube with the mouse (easy)

  • Detect when the mouse is clicked.
  • Update the cube's Y angle when the mouse is clicked and moves horizontally.
  • Update the cube's X angle when the mouse is clicked and moves vertically.
canvas.onmousedown = e => {
  lastX = e.clientX;
  lastY = e.clientY;
  dragging = true;
};

canvas.onmouseup = e => {
  dragging = false;
};

canvas.onmousemove = e => {
  var x = e.clientX;
  var y = e.clientY;

  if(dragging){
    
    // Set angles
    dx = (y - lastY) * 100 / canvas.height;
    dy = (x - lastX) * 100 / canvas.width;
    
    rx += dx;
    ry += dy;
    
    // Remember mouse position
    lastX = x;
    lastY = y;
    
    // Draw the modified cube
    draw();
    
    // ... set the model, mvp and inverseTransform matrices ...
    
    gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_SHORT, 0);
  }
};

Rotate the cube with the mouse


Technique 3: rotate a cube with the mouse (advanced)

In the demo above, you may have remarked that the cube rotation is wrong when its vertical angle is too big.
Indeed, When the vertical angle is, for example, 180 degrees, all the cube will be upside-down, and the horizontal rotation performed first will be inversed compared to the mouse's direction:

This problem is present in most 3D tutorials and libraries out there, and is generally "fixed" (hidden) by preventing the vertical angle to go beyond 45 degrees.
Fortunately, a real solution exists! Kevin Chapelier implemented it in his sphere demo and explained it to me:

First, every time the cube rotates, instead of rotating along the X axis then along the Y axis, make it rotate along a specifix axis, perpendicular to mouse movement:

So far, we didn't solve anything... but introducing this custom rotation axis is very important.
Indeed, it is possible to inverse the rotation that has been made by the cube so far, and apply this inverse transformation to our custom axis.
This may sound a bit strange, but it does something pretty awesome:
it virtually cancels all the previous rotations made along this axis, in order to perform the next bit of rotation as if the cube had never rotated before:

canvas.onmousedown = e => {
  lastX = e.clientX;
  lastY = e.clientY;
  dragging = true;
};

canvas.onmouseup = e => {
  dragging = false;
};

canvas.onmousemove = e => {
  var x = e.clientX;
  var y = e.clientY;
  if(dragging){
    
    // Set angles
    dx = (y - lastY) * 100 / canvas.height;
    dy = (x - lastX) * 100 / canvas.width;

    // Compute rotation axis (perpendicular to mouse movement)
    axis[0] = dx;
    axis[1] = dy;
    axis[2] = 0;

    // Inverse the rotation that has been done so far
    inverseRotation = (new DOMMatrix(modelMatrix)).invertSelf();
    
    // Apply the inverse rotation to the rotation axis
    axis = axisTransformMatrix(axis, inverseRotation);
    
    // Compute the new rotation matrix "deltaRotation" along the axis
    deltaRotation = fromRotation(axis, Math.hypot(dx, dy) / 100);

    // Compute the rotation: rotation * deltaRotation
    if(deltaRotation){
      modelMatrix.multiplySelf(deltaRotation);
    }
    
    // Remember mouse position
    lastX = x;
    lastY = y;
    
    // ... set the model, mvp and inverseTransform matrices ...
    
    gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_SHORT, 0);
  }
};

Rotate the cube with the mouse

As a result, the cube can now follow the mouse in every direction:


New functions added in matrix.js:

// Create a matrix representing a rotation around an arbitrary axis [x, y, z]
fromRotation = (axis, angle) => {

  var x = axis[0], y = axis[1], z = axis[2];
  var len = Math.hypot(x, y, z);
  var s, c, t;
  
  if (len == 0) return null;

  len = 1 / len;
  x *= len;
  y *= len;
  z *= len;

  s = Math.sin(angle);
  c = Math.cos(angle);
  t = 1 - c;

  return new Float32Array([
    x * x * t + c,      y * x * t + z * s,  z * x * t - y * s,   0,
    x * y * t - z * s,  y * y * t + c,      z * y * t + x * s,   0,
    x * z * t + y * s,  y * z * t - x * s,  z * z * t + c,       0,
    0, 0, 0, 1
  ]);
};

// Apply a matrix transformation to a custom axis
transformMat4 = (a, m) => {
  let x = a[0],
    y = a[1],
    z = a[2];
  let w = (m[3] * x + m[7] * y + m[11] * z + m[15])|| 1.0;
  
  return new Float32Array([
    (m[0] * x + m[4] * y + m[8] * z + m[12]) / w,
    (m[1] * x + m[5] * y + m[9] * z + m[13]) / w,
    (m[2] * x + m[6] * y + m[10] * z + m[14]) / w
  ]);
}




Framebuffers and renderbuffers

Until now, we've used the color and depth buffers to draw 3D content on the WebGL canvas. It is also possible to bypass this default target to perform offscreen rendering.
This can be done by creating a framebuffer object, which, in turn, writes the image data in a texture object, and the depth data in a renderbuffer object.
The texture object can be used as if it was loaded from an image file, and the renderbuffer object can be used to cast shadows, as we'll see in the following chapters.

Here's an example of 3D cube rendered as a texture and applied on a quad.
You can see that only one program and one pair of shaders is present in the code,
that's because they're generic enough to be used by both the textured cube and the textured quad, even though each of them uses different meshes, uv and mvp matrices.
However, it's totally possible to write two programs and two pairs of shaders (one for the offscreen texture, and another for the canvas) !

(...)
var OFFSCREEN_WIDTH = 256;
var OFFSCREEN_HEIGHT = 256;
var fbo = gl.createFramebuffer();
var texture = gl.createTexture();
var viewProjMatrixFBO = identity();

(...)

framebuffer.texture = texture;
depthBuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0,bgl.TEXTURE_2D, texture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
cameraMatrix = perspective({fovy: deg2rad(30), aspect: OFFSCREEN_WIDTH/OFFSCREEN_HEIGHT, near: 1, far: 100});

// Transform camera matrix
// Draw something in the framebuffer
// Draw a plane with this texture

(...)





Instanced geometry

todo



Advanced coloring / texturing




Animate, transform and wrap texturess

todo (more info here)

Mip mapping

todo

Reflexions and environment mapping

todo (more info here)

Normal / Height / Parallax / Bump mapping

todo (more info here & here)

Fresnel

todo

Matcaps / lit spheres

todo

Cel-shading / ramp textures

todo



Advanced lighting




Multiple light sources

todo (more info here)



Shadows!

Displaying shadows consists in rendering with a darker color the objects that are hidden from the light source by another object.

It's a bit tricky because it involves many concepts introduced in the previous chapters (shading, depth buffer, multiple shaders, ...)

  • First, you need to consider a virtual camera placed at the same position as the light source. Everything you can see will be in the light, everything you can not see will be in the shadow.
  • To implement that, you need a first pair of shaders that computes the distances between the light source and the scene's fragments (by computing a depth buffer from this virtual camera),
    and a second pair of shaders that uses these results to draw the scene from the "real" camera position.
  • To pass this demth buffer between the two pairs of shaders, a texture can be used. In this context, it's called a shadow map.
  • For now, the depth will be stored in the R value of the shadow map's RGBA pixels, which is a 8 bit integer.
  • All the depths between the near and far clipping planes are normalized (placed in the range [0:1]), and will be multiplied by 256 to be stored in a 8-bit value.
  • These shadow map's depth values will be compared to real-world distances, so a small offset a bit higher than 1/256 (0.005) is added to avoid rounding errors's Mach bands.
  • In this example, fragments that are not the closest to the light source (i.e. in the shadow) will be drawn with 70% of their RGB colors instead of 100%.
shadow_vshader = `
void main() {
 gl_Position = u_MvpMatrix * pos;
}`;

shadow_fshader = `
void main() {
 gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);
};

vshader = `
(...)
v_PositionFromLight = u_MvpMatrixFromLight * pos;
`;

fshader = `
uniform sampler2D u_ShadowMap;
varying vec4 v_PositionFromLight;
varying vec4 v_Color;
void main() {

  // Retrieve shadow map texel and depth encoded inside it
  vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0+0.5;
  vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy);
  float depth = rgbaDepth.r;
  
  // Render shadowed fragments 30% darker
  float visibility = (shadowCoord.z>depth+0.005)?0.7:1.0;
  gl_FragColor = vec4(v_Color.rgb*visibility,v_Color.a);
}
`;

var OFFSCREEN_WIDTH = 1024, OFFSCREEN_HEIGHT = 1024;
var LIGHT_X = 0, LIGHT_Y = 7, LIGHT_Z = 2;

(...)

var shadowProgram = createProgram(gl, SHADOW_VSHADER_SOURCE, SHADOW_FSHADER_SOURCE);
var normalProgram = createProgram(gl, VSHADER_SOURCE, FSHADER_SOURCE);

(...)

var fbo = initFramebufferObject(gl);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, fbo.texture);

(...)

var viewProjMatrixFromLight = perspective({fov: 70, ratio: OFFSCREEN_WIDTH/OFFSCREEN_HEIGHT, near: 1, far: 100);
viewProjMatrixFromLight = lookAt(viewProjMatrixFromLight, LIGHT_X, LIGHT_Y, LIGHT_Z, 0, 0, 0);

(...)

var mvpMatrixFromLight_t; // For triangle
var mvpMatrixFromLight_p; // For plane

setInterval(() => {

  // Shadow map generation
  gl.useProgram(shadowProgram);
  
  // Draw the triangle and the plane
  drawTriangle(gl, shadowProgram, triangle, currentAngle, viewProjMatrixFromLight);
  mvpMatrixFromLight_t = new DOMMatrix(g_mvpMatrix); // Used later
  drawPlane(gl, shadowProgram, plane, viewProjMatrixFromLight);
  mvpMatrixFromLight_p = new DOMMatrix(g_mvpMatrix); // Used later
  
  // Change the drawing destination to color buffer
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  
  // Regular drawing
  gl.useProgram(normalProgram);
  gl.uniform1i(normalProgram.u_ShadowMap, 0); // Pass gl.TEXTURE0
  gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatrixFromLight_t.toFloat32Array());
  drawTriangle(gl, normalProgram, triangle, currentAngle, viewProjMatrix.toFloat32Array());
  gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatrixFromLight_p.toFloat32Array());
  drawPlane(gl, normalProgram, plane, viewProjMatrix);
}, 33);
  • This 8-bit shadow only works for small scenes where the light is close to the scene's objects.
  • A solution consists in using the four RGBA components of the texture map to store the distance, instead of just R, for a 32-bit precision:

  • The rounding offset can now be lowered to 0.0015 (a bit above 1/(232))

var shadow_fshader = `
  (...)
  const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);
  const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);
  vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);
  rgbaDepth -= rgbaDepth.gbaa * bitMask;
  gl_FragColor = rgbaDepth;
`;
var fshader = `
  (...)
  float unpackDepth(const in vec4 rgbaDepth) {
    const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/(256.0 * 256.0), 1.0/(256.0 * 256.0 * 256.0));
    float depth = dot(rgbaDepth, bitShift);
    return depth;
  }
  void main() {
    vec3 shadowCoord = (v_PositionFromLight.xyz / v_PositionFromLight.w)/2.0 + 0.5;
    vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy);
    float depth = unpackDepth(rgbaDepth);
    float visibility = (shadowCoord.z > depth + 0.0015)? 0.7:1.0;
    gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);
  };
`




360° shadows

todo

Penumbra and soft shadows

todo

Baked lighting

Baked lighting was an optimization used in early 3D games, where real-time computations were very CPU-intensive.
The lights, shadings and shadows were often precomputed or drawn by artists during the game development and displayed as textures to save resources.
Famous example from Zelda Wind Waker on Gamecube:





Advanced display




Fog

  • Fog will take effect linearly between a near point (where the fog is fully transparent) and a far point (where the fog is fully opaque).
  • The vertex shader must know the position of the camera to compute a varying distance between the camera and each vertex.
  • Using this varying distance, the fragment shader knows the distance between the camera and each fragment.


    Fortunately, the native GLSL methods distance(), clamp() and mix() simplify the shader's implementation.
  • The WebGL canvas context's clear color is equal to the fog's color when it's fully opaque.
var vshader = `
// (...)
uniform vec4 camera;
varying float v_dist;
void main() {

  // (...)
  
  v_dist = distance(modelMatrix * pos, camera);
}`;

var fshader = `
// (...)
uniform vec3 fogColor;
uniform vec2 fogDist; // near / far
void main() {

  // (...)
  
  // Compute fog factor
  float fogFactor = clamp((fogDist.y - v_dist) / (fogDist.y - fogDist.x), 0.0, 1.0);
  
  // Include fog factor in the color
  gl_FragColor = vec4(mix(fogColor, vec3(col), fogFactor), 1.0);
}`;

var fogColor = [0.137, 0.231, 0.423];
gl.uniform3fv(gl.getUniformLocation(gl.program, 'fogColor'), fogColor);
gl.uniform2fv(gl.getUniformLocation(gl.program, 'fogDist'), [55, 80]);
gl.uniform4fv(gl.getUniformLocation(gl.program, 'u_Eye'), [25, 65, 35, 1.0]);

(...)

gl.clearColor(fogColor[0], fogColor[1], fogColor[2], 1.0);

To simplify, you can also use v_dist = gl_Position.w; in the vertex shader, as it is a nice approximation of the distance between the camera and the vertex.
(see the book's "fog_w" demo for more info)



Text

WebGL can't display text natively on screen.
We have to cheat a little if we want to display text in our scene...

1) Superpose a HTML div over the WebGL canvas

This approach is recommended if you want to display fixed informations on top of the scene (like a game's HUD).
However, if you want to align the text to a specific point in the scene (like a vertex), the div's position can be computed in JavaScript.
Some examples are available on Webglfundamentals.

<canvas id="canvas" width=400 height=400></canvas>
<div id="text">Hello World</div>
<style>
#text {
  font-size: 30px;
  font-family: arial;
  color: #fff;
  text-shadow: 0 0 2px 2px #000;
  position: absolute;
  top: 250px;
  left: 130px;
}
</style>

2) Place text inside the scene

In order to have text appear in the scene and be translated, rotated, scaled, overlap or be overlapped by other elements, it needs to be put into a texture, and applied on a polygon.
To do that, you can either create a texture with all the alphabet and make WebGL draw the text letter by letter, or simply draw the text on an image or a 2D canvas and use it as a texture:

<canvas id="canvas2d" width=256 height=256 hidden></canvas>
<script>
// 2D canvas context
var ctx = canvas2d.getContext("2d");

// Render text
ctx.fillStyle = "#fff";
ctx.fillRect(0, 0, 256, 256);
ctx.fillStyle = "#00F";
ctx.font = "30px Arial";
ctx.fillText("Hello WebGL", 30, 200);

// ...

// Set a 2D texture
var sampler = gl.getUniformLocation(program, 'sampler');
var texture = gl.createTexture();
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas2d);

// ...

gl.drawArrays(gl.TRIANGLE_STRIP, 0, n); // Draw the quad

</script>
(click for full demo)

NB: other approaches are available on CSS-tricks and WebGLFundamentals here and here.



Particles

todo



Multiple passes and post-processing

todo



Load external 3D models




.obj / .mtl files



  • Wavefront OBJ is a popular and simple 3D model file format, it contains text and can be exported by free tools like Blender.
  • It contains the definitions of vertices, vertex normals, texture UV coordinates for each vertex, and faces organized in objects and groups.
  • A companion MTL file is used to define the material properties of each object or group (color, opacity, texture, ...).
  • Below are described the most important features of these file formats, and all the traps and tricks I found while writing my own parser.

OBJ file content:

# List of geometric vertices (x, y, z, [w], [r], [g], [b]).
# w is optional and defaults to 1.0. r,g,b are optional and their values are between 0 and 1.
# Some 3D softwares export the vertices RGB colors after the x/y/z or x/y/z/w coordinates,
# so if a line contains 6 floats, it's x/y/z/r/g/b. If it contains 7, it's x/y/z/w/r/g/b.
# Note that this is the only way to declare colors per-vertex, since materials can only be applied to entire polygons.
# It's not guaranteed that all the lines have the same number of arguments.
# Sometimes, due to exporting errors, all the vertices are offseted (ex: the object is placed too high and every Y value is > 10).
# Depending on your needs, it may be interesting to bring the first vertex back to [0,0,0] and offset all the other vertices accordingly.
v 0.123 0.234 0.345
v 0.123 0.234 0.345 1.0
v 0.123 0.234 0.345 0.456 0.567 0.678
v 0.123 0.234 0.345 1.0 0.456 0.567 0.678
v ...

# List of texture coordinates (u, [v], [w]), between 0 and 1. v and w are optional and default to 0.
# v is usually set (without it, 2D textures wouldn't work), but w is specific to advanced rendering techniques and almost never used.
# If none of the file's objects are textured, there won't be any "vt" lines.
vt 0.500 1
vt ...

# List of normals (x, y, z). Some 3D editors don't normalize them, so make sure to always call normalize() in your shaders.
# If they're implicit (not present in the file), and if you want to light/shade your model, you'll need to compute them like this:
# for a triangle A, B, C (in counterclockwise order), the face normal is equal to:
# vn = AB . BC = [yAB*zBC – zAB*yBC, zAB*xBC – xAB*zBC, xAB*yBC – yAB*xBC]
vn 0.707 0.000 0.707
vn ...

# Polygonal faces (triangles, quads, and polygons with more than 4 sides can be mixed here).
# Allowed forms:
# - "vertex" indices
# - "vertex/texture" indices
# - "vertex/texture/normal" indices
# - "vertex//normal" indices
# Warning! Indices start at 1, not 0. The values need to be decremented to be used in a WebGL index buffer.
# Warning #2! in WebGL you can't have 3 sets of data and 3 sets of indices, so when parsing mixed indices (in the form a/b/c d/e/f g/h/i),
# you'll need to reorganize the data in either 3 non-indexed buffers, or in 3 buffers using the same indices.
# Also, to be displayed with WebGL, quads and other polygons (anything with more than 3 sides) must be converted to triangles:
# (For n-edge polygons, each triangle is of the form [0, i, i+1] for i=1..n-2. See this StackOverflow post for more info).
# The vertices order is counter-clockwise by default, which helps recomputing implicit normals properly.
f 1 2 3
f 1 2 3 4
f 3/1 4/2 5/3
f 6/4/1 3/5/3 7/6/5
f 7//1 8//2 9//3
f ...

# Faces can be gathered in objects and groups (both are optional).
# Objects can contain groups, but groups can also exist without parent object.
# It may be a good idea to always force the creation of a default object and group when parsing an OBJ file, for consistency.
# Everything that follows an object definition belongs to this object until a new object starts.
# Everything that follows a group definition belongs to this group until a new object or group starts.
# Each group generally requires a new pair of shaders to be rendered, as they generally use different colors, textures, shininess, transparency, etc)
o myObject
g myGroup1
f 1 2 3
...
g myGroup2
f 4 5 6
...

# Smooth shading can be enabled or disabled per object or group.
# When enabled, shading may be computed per fragment. When disabled, it may be computed per vertex or per face.
# It's enabled by default for every object, and inherited by default for every group.
# 1 / on, and 0 / off are equivalent.
# If the value of s changes inside a group, a new group should be created, to compute normals separately.
# 's' can also be used by some 3D softwares to define smoothing groups, with numbers > 0.
# All the groups with the same value should be smoothed together when the vertex normals are recomputed.
# It's quite rare though, so you can consider that anything different than '0' or 'off' is 'on'.
s 1
s on

s 0
s off

# Load a material file and use one or many materials defined inside it.
# Many 'mtllib' lines may exist in the same obj file (in general there's only one).
# If a material has the same name in different mtl files, the last one loaded overwrites previous occurrences.
# If the material changes within a group, a new group should be created, so WebGL can render both separately.
mtllib material.mtl
usemtl material1
# all the following elements will use material1 until another usemtl is reached.
...
 
usemtl material2
# all the following elements will use material2 until another usemtl is reached.
...

MTL file content:

# Define a material named material1.
newmtl material1

# All the following properties are optional. Most materials only have one color or one texture map.
# Whether it's called color or lighting, the implementation is the same.

# Ambient color (RGB, each channel between 0 and 1).
Ka 1.000 1.000 1.000

# Diffuse color.
Kd 1.000 1.000 1.000

# Specular color + specular exponent.
Ks 0.000 0.000 0.000     # black (off)
Ns 10.000                # shininess, between 0 and 1000

# Transparency (or its opposite, "dissolving": d = 1 - Tr). Only d is used in general, but both should be supported.
d 0.9
Tr 0.1

# Illumination model (more info on Wikipedia).
illum 0 # Color on and Ambient off
illum 1 # Color on and Ambient on
illum 2 # Specular highlight on
...     # The other models are not covered here (raytracing, etc)

# Texture map (more info on Wikipedia)
map_Ka ambient.png # ambient texture
map_Kd diffuse.png # diffuse texture, very often only this one is set, sometimes both are set but use the same file
map_Ks specular.png # specular texture
...

Obj and mtl files may contain other statements not detailed here, see OBJ specs and MTL specs

When parsed, all the v, ft, vn, f floats should be concatenated in big Float32Arrays, as it's the format in which WebGL expects to receive data buffers.

You can see my mini OBJ/MTL parser/viewer on Github. It supports all the features listed above.

(TODO: explain shaded textures)

.fbx files

todo

.gltf / .glb files

todo

Others (.ply, .prwm...)

todo



Advanced shader manipulation




Using multiple shaders

  • If your scene contains many objects rendered differently (colored, textured, alpha, etc), you may want to use many shaders instead of a single, long shader
  • You need to create a program for each shader, and when rendering a frame, switch between them using gl.useProgram(...)
var p1 = compile(gl, vshader1, fshader1);
var p2 = compile(gl, vshader2, fshader2);

(...)

gl.useProgram(p1);

// Draw things with the first shader

gl.useProgram(p2);

// Draw things with the second shader




Build shaders

todo



Interesting subjects to study next




3D physics

todo

Tessellation and decimation

todo

Interpolation, animation and rigging

todo

VR

todo

Field of view

todo

Other WebGL features

todo

Raycasting and distance fields

todo

Raytracing and Physics Based Rendering

todo

NURBS

todo

WebGPU

todo

WebGL and Gamedev

todo

Use WebGL to compute stuff

todo

Visualizing the camera

todo (more info here)