Light UBOs met std140's padding of vec3s and arrays. Two more rules catch people: a matrix is stored as columns, each padded to 16 bytes like an array element, and a lone float may sit in the last four bytes of a vec3, while a struct always starts on a 16-byte boundary. Asking WebGL is safer than computing by hand:
const program = GLH.program(gl, '#version 300 es\nvoid main() { gl_Position = vec4(0); }',
`#version 300 es
precision mediump float; out vec4 c; struct Lamp { vec3 position; float power; };
layout(std140) uniform Frame { mat3 uNormal; float uWeights[3]; Lamp uLamp; vec2 uSize; };
void main() {
c = vec4(uNormal[0] * uWeights[2] + uLamp.position * uLamp.power, uSize.x); }`);
const names = ['uNormal', 'uWeights', 'uLamp.position', 'uLamp.power', 'uSize'];
const offsets = gl.getActiveUniforms(program, gl.getUniformIndices(program, names),
gl.UNIFORM_OFFSET);
console.log(names.map((name, i) => `${name} ${offsets[i]}`).join(' | '));
console.log('block size', gl.getActiveUniformBlockParameter(program, 0,
gl.UNIFORM_BLOCK_DATA_SIZE), '| UNIFORM_BUFFER_OFFSET_ALIGNMENT',
gl.getParameter(gl.UNIFORM_BUFFER_OFFSET_ALIGNMENT));uNormal 0 | uWeights 48 | uLamp.position 96 | uLamp.power 108 | uSize 112 block size 120 | UNIFORM_BUFFER_OFFSET_ALIGNMENT 256
The mat3 takes 48 bytes, not 36, and three floats take 48, not 12; power shares the vec3's slot, and the reported size, 120, is not rounded up to 16. The alignment matters when one big buffer holds a block per object: bindBufferRange(UNIFORM_BUFFER, index, buffer, offset, size) needs offsets in multiples of 256 on both renderers here, and offset 64 gave INVALID_VALUE. Pad each object's slot to 256 bytes, the same rule WebGPU enforces for dynamic offsets (Dynamic Offsets).
<!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; font: 11px monospace; }
.key span { display: inline-block; width: 10px; height: 10px; margin: 0 4px 0 10px; vertical-align: -1px; }
</style>
<canvas id="c" width="600" height="300"></canvas>
<div class="key" id="key"></div>
<pre id="out"></pre>
<script>
const gl = document.getElementById('c').getContext('webgl2');
function program(vs, fs) {
const p = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, vs], [gl.FRAGMENT_SHADER, fs]]) {
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);
return p;
}
// 1. Ask WebGL where std140 puts each member (safer than computing by hand).
const layoutProgram = program('#version 300 es\nvoid main() { gl_Position = vec4(0); }', `#version 300 es
precision mediump float; out vec4 c; struct Lamp { vec3 position; float power; };
layout(std140) uniform Frame { mat3 uNormal; float uWeights[3]; Lamp uLamp; vec2 uSize; };
void main() { c = vec4(uNormal[0] * uWeights[2] + uLamp.position * uLamp.power, uSize.x); }`);
const names = ['uNormal', 'uWeights', 'uLamp.position', 'uLamp.power', 'uSize'];
const indices = gl.getUniformIndices(layoutProgram, names);
const offsets = gl.getActiveUniforms(layoutProgram, indices, gl.UNIFORM_OFFSET);
const strides = gl.getActiveUniforms(layoutProgram, indices, gl.UNIFORM_ARRAY_STRIDE);
const matrixStrides = gl.getActiveUniforms(layoutProgram, indices, gl.UNIFORM_MATRIX_STRIDE);
const size = gl.getActiveUniformBlockParameter(layoutProgram, 0, gl.UNIFORM_BLOCK_DATA_SIZE);
const alignment = gl.getParameter(gl.UNIFORM_BUFFER_OFFSET_ALIGNMENT);
// Which member owns each 4-byte slot? (-1 = padding)
const slots = new Array(Math.ceil(size / 16) * 4).fill(-1);
const claim = (member, byte, bytes) => { for (let b = byte; b < byte + bytes; b += 4) slots[b / 4] = member; };
for (let c = 0; c < 3; c++) claim(0, offsets[0] + c * matrixStrides[0], 12); // mat3: three padded columns
for (let i = 0; i < 3; i++) claim(1, offsets[1] + i * strides[1], 4); // float[3]: one per 16 bytes
claim(2, offsets[2], 12); claim(3, offsets[3], 4); claim(4, offsets[4], 8);
const colors = [[0.12, 0.37, 0.55], [0.88, 0.6, 0.06], [0.25, 0.49, 0.23], [0.16, 0.62, 0.56], [0.71, 0.27, 0.18]];
// 2. Draw the memory map: one row per 16 bytes, one cell per 4-byte slot (scissored clears).
gl.clearColor(0.97, 0.96, 0.93, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.enable(gl.SCISSOR_TEST);
const rows = slots.length / 4;
slots.forEach((member, i) => {
const x = 20 + (i % 4) * 72, y = 300 - 12 - (Math.floor(i / 4) + 1) * 20;
gl.scissor(x, y, 70, 18);
const c = member < 0 ? [0.85, 0.83, 0.79] : colors[member];
gl.clearColor(...c, 1); gl.clear(gl.COLOR_BUFFER_BIT);
});
gl.disable(gl.SCISSOR_TEST);
document.getElementById('key').innerHTML = names.map((n, i) => `<span style="background:rgb(${colors[i].map(v => v * 255)})"></span>${n} @ ${offsets[i]}`).join('') +
'<span style="background:#d9d4c9"></span>padding';
// 3. One buffer, three per-book blocks at multiples of UNIFORM_BUFFER_OFFSET_ALIGNMENT, bound with bindBufferRange.
const book = program(`#version 300 es
layout(std140) uniform Book { vec4 uColor; vec2 uPlace; };
void main() {
vec2 p = vec2(gl_VertexID & 1, gl_VertexID >> 1) * 2.0 - 1.0;
gl_Position = vec4(uPlace + p * vec2(0.09, 0.3), 0.0, 1.0);
}`, `#version 300 es
precision highp float; // must match the vertex shader for a shared block
layout(std140) uniform Book { vec4 uColor; vec2 uPlace; }; out vec4 c;
void main() { c = uColor; }`);
gl.uniformBlockBinding(book, gl.getUniformBlockIndex(book, 'Book'), 0);
const slot = Math.max(alignment, 32), data = new Float32Array(slot / 4 * 3);
[[0.12, 0.37, 0.55], [0.88, 0.6, 0.06], [0.71, 0.27, 0.18]].forEach((c, i) => {
data.set([...c, 1, 0.45 + i * 0.22, -0.3], i * slot / 4); // each book's block starts on its own slot
});
const ubo = gl.createBuffer();
gl.bindBuffer(gl.UNIFORM_BUFFER, ubo);
gl.bufferData(gl.UNIFORM_BUFFER, data, gl.STATIC_DRAW);
gl.useProgram(book);
gl.bindVertexArray(gl.createVertexArray());
for (let i = 0; i < 3; i++) {
gl.bindBufferRange(gl.UNIFORM_BUFFER, 0, ubo, i * slot, 32); // offset must be a multiple of the alignment
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
gl.bindBufferRange(gl.UNIFORM_BUFFER, 0, ubo, 64, 32); // not a multiple (unless alignment <= 64)
const misaligned = gl.getError();
document.getElementById('out').textContent =
`offsets ${names.map((n, i) => `${n} ${offsets[i]}`).join(' | ')}\n` +
`mat3 column stride ${matrixStrides[0]}, float[3] stride ${strides[1]}, block size ${size} bytes\n` +
`UNIFORM_BUFFER_OFFSET_ALIGNMENT ${alignment}: books at offsets 0, ${slot}, ${2 * slot}; offset 64 -> error ${misaligned}`;
</script>