Light UBOs

Passing Light Data with Uniform Buffer Objects

Setting lights uniform by uniform, program by program, grows tedious. A uniform buffer object (UBO, WebGL 2) holds a block of uniforms in one buffer, bound to a numbered binding point that every program declaring the block reads. With the std140 layout the offsets follow fixed rules; the listing asks WebGL for them:

A std140 uniform block for the lights, its byte layout, and its bufferJavaScript
const program = GLH.program(gl, `#version 300 es
  void main() { gl_Position = vec4(0.0, 0.0, 0.0, 1.0); gl_PointSize = 1.0; }`,
`#version 300 es
  precision mediump float; out vec4 c;
  layout(std140) uniform Lights {
    vec3 uAmbient; vec3 uPositions[3]; vec3 uColors[3]; int uCount;
  };
  void main() { c = vec4(uAmbient + uColors[uCount - 1] * uPositions[0].y, 1.0); }`);
const block = gl.getUniformBlockIndex(program, 'Lights');
const names = ['uAmbient', 'uPositions', 'uColors', 'uCount'];
const indices = gl.getUniformIndices(program, names);
const [offsets, strides] = [gl.UNIFORM_OFFSET, gl.UNIFORM_ARRAY_STRIDE].map((pname) =>
  gl.getActiveUniforms(program, indices, pname));
const size = gl.getActiveUniformBlockParameter(program, block, gl.UNIFORM_BLOCK_DATA_SIZE);
console.log('block size', size, '| offsets', offsets.join(', '),
  '| strides', strides.join(', '));
const data = new Float32Array(32);                    // offsets / 4 = float indices
data.set([0.2, 0.2, 0.2], 0);                          // uAmbient
data.set([0, 0.5, 1], 4);                              // uPositions[0]
data.set([1, 0.8, 0.6], 16);                           // uColors[0]
new Int32Array(data.buffer)[28] = 1;                   // uCount is an int
gl.uniformBlockBinding(program, block, 0);            // block -> binding point 0
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, GLH.buffer(gl, data, gl.UNIFORM_BUFFER));
Output
block size 116 | offsets 0, 16, 64, 112 | strides 0, 16, 16, 0

std140 pads each vec3 and array element to 16 bytes, so the data goes at float indices 0, 4, 16 and 28. Drawn with this buffer bound (tried for this section), a pixel came out 178, 153, 127: 0.2 + (1, 0.8, 0.6) x 0.5. Update moving lights with bufferSubData(); UBO Layout revisits the rules.

Light data in one std140 uniform buffer, shared by two programs and updated with bufferSubDataHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  pre { max-width: 600px; margin: 4px 0 0; padding: 6px 8px; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c" width="1200" height="380"></canvas>
<pre id="out"></pre>
<script>
const m4 = {
  perspective(fovy, aspect, near, far) {
    const f = 1 / Math.tan(fovy * Math.PI / 360), d = near - far;
    return [f / aspect, 0, 0, 0, 0, f, 0, 0, 0, 0, (far + near) / d, -1, 0, 0, 2 * far * near / d, 0];
  },
  lookAt(eye, target, up = [0, 1, 0]) {
    const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
    const unit = (v) => v.map((c) => c / Math.hypot(...v));
    const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
    const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
    return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
  },
  multiply(a, b) {
    const out = new Array(16).fill(0);
    for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k];
    return out;
  },
  model: (x, y, z, sx, sy, sz) => [sx, 0, 0, 0, 0, sy, 0, 0, 0, 0, sz, 0, x, y, z, 1],
};
function cube() {
  const faces = [[[0,0,1],[1,0,0],[0,1,0]], [[0,0,-1],[-1,0,0],[0,1,0]], [[1,0,0],[0,0,-1],[0,1,0]],
                 [[-1,0,0],[0,0,1],[0,1,0]], [[0,1,0],[1,0,0],[0,0,-1]], [[0,-1,0],[1,0,0],[0,0,1]]];
  const out = [];
  for (const [n, u, v] of faces) for (const [a, b] of [[-1,-1],[1,-1],[1,1],[-1,-1],[1,1],[-1,1]])
    out.push(...[0, 1, 2].map(i => (n[i] + a * u[i] + b * v[i]) / 2), ...n);
  return new Float32Array(out);
}
const gl = document.getElementById('c').getContext('webgl2');
const vs = `#version 300 es
layout(location = 0) in vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out vec3 vPosition, vNormal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = aNormal;   // axis-aligned boxes
  gl_Position = uViewProjection * w; }`;
const block = `layout(std140) uniform Lights {
  vec3 uAmbient; vec3 uPositions[3]; vec3 uColors[3]; int uCount;
};`;
// Two different programs read the same block: a matte one and a banded "toon" one.
const shading = (body) => `#version 300 es
precision highp float; ${block}
uniform vec3 uColor; in vec3 vPosition, vNormal; out vec4 c;
void main() {
  vec3 n = normalize(vNormal), light = uAmbient;
  for (int i = 0; i < uCount; i++) {
    vec3 l = uPositions[i] - vPosition; float d = length(l);
    float k = max(dot(n, l / d), 0.0) / (1.0 + d * d); ${body}
    light += uColors[i] * k;
  }
  c = vec4(uColor * light, 1.0);
}`;
function program(fsSource) {
  const p = gl.createProgram();
  for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fsSource]]) {
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
    gl.attachShader(p, s);
  }
  gl.linkProgram(p);
  gl.uniformBlockBinding(p, gl.getUniformBlockIndex(p, 'Lights'), 0);   // block -> binding point 0
  return p;
}
const matte = program(shading(''));
const toon = program(shading('k = floor(k * 4.0 + 0.5) / 4.0;'));

// Ask WebGL for the std140 offsets rather than guessing them.
const blockIndex = gl.getUniformBlockIndex(matte, 'Lights');
const indices = gl.getUniformIndices(matte, ['uAmbient', 'uPositions', 'uColors', 'uCount']);
const offsets = gl.getActiveUniforms(matte, indices, gl.UNIFORM_OFFSET);
const strides = gl.getActiveUniforms(matte, indices, gl.UNIFORM_ARRAY_STRIDE);
const size = gl.getActiveUniformBlockParameter(matte, blockIndex, gl.UNIFORM_BLOCK_DATA_SIZE);
document.getElementById('out').textContent =
  `block size ${size} bytes | offsets ${offsets.join(', ')} | array strides ${strides.join(', ')}\n` +
  'vec3s and array elements are padded to 16 bytes: floats go at indices 0, 4, 16 and 28';

const data = new Float32Array(size / 4);
data.set([0.15, 0.15, 0.17], offsets[0] / 4);                          // uAmbient
[[2.4, 1.2, 0.6], [0.5, 1.0, 2.4]].forEach((c, i) => data.set(c, offsets[2] / 4 + i * 4));   // uColors
new Int32Array(data.buffer)[offsets[3] / 4] = 2;                       // uCount is an int
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, data, gl.DYNAMIC_DRAW);
gl.bindBufferBase(gl.UNIFORM_BUFFER, 0, ubo);                          // every program sees it

gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, cube(), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
const viewProjection = m4.multiply(m4.perspective(38, 1200 / 380, 0.1, 20), m4.lookAt([0, 1.4, 3.6], [0, 0.4, 0]));
const books = [0, 1, 2, 3, 4, 5, 6, 7].map(i => m4.model(-1.75 + i * 0.5, 0.5, 0, 0.4, 1 + (i % 3) * 0.12, 0.6));
const positions = new Float32Array(12);
function frame(ms) {
  const t = ms * 0.0008;
  positions.set([Math.sin(t) * 1.8, 1.2, 0.9], 0);
  positions.set([-Math.sin(t) * 1.8, 0.3, 0.9], 4);
  gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
  gl.bufferSubData(gl.UNIFORM_BUFFER, offsets[1], positions);          // moving lights: one small upload
  gl.clearColor(0.93, 0.91, 0.87, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  books.forEach((model, i) => {
    const p = i < 4 ? matte : toon;                                    // left half matte, right half toon
    gl.useProgram(p);
    gl.uniformMatrix4fv(gl.getUniformLocation(p, 'uViewProjection'), false, viewProjection);
    gl.uniformMatrix4fv(gl.getUniformLocation(p, 'uModel'), false, model);
    gl.uniform3f(gl.getUniformLocation(p, 'uColor'), 0.85, 0.82, 0.78);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  });
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>