A <canvas> or OffscreenCanvas is a valid texImage2D() source too, which turns Canvas 2D's drawing API into a texture generator for labels, price tags, charts and patterns. BookNest's sale sticker (Texturing BookNest's Cover Art) is a transparent canvas the size of a cover with a red "20% OFF" badge drawn with arc() and fillText(), uploaded with GLH.texture(gl, badge). Since it shares the cover's coordinates, the badge lands where it was drawn.
A 2D canvas stores premultiplied colors; WebGL un-premultiplies them on upload unless you set UNPACK_PREMULTIPLY_ALPHA_WEBGL, so the shader gets plain RGB and alpha to mix() by. To animate a canvas texture, redraw it, call texSubImage2D() and regenerate the mipmaps.
<!doctype html>
<style>
body { margin: 0; background: #f7f4ee; }
canvas { display: block; width: 100%; max-width: 600px; }
</style>
<canvas id="c" width="600" height="320"></canvas>
<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];
},
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;
},
};
// The cover, drawn once; the badge, a transparent canvas the size of the cover, redrawn every frame.
const cover = Object.assign(document.createElement('canvas'), { width: 256, height: 384 });
const cg = cover.getContext('2d');
cg.fillStyle = '#b5452f'; cg.fillRect(0, 0, 256, 384);
cg.fillStyle = '#e09a10'; cg.beginPath(); cg.arc(128, 150, 70, 0, Math.PI * 2); cg.fill();
cg.fillStyle = '#fff'; cg.font = 'bold 34px Georgia'; cg.textAlign = 'center';
cg.fillText('Salt and', 128, 290); cg.fillText('Saffron', 128, 330);
const badge = Object.assign(document.createElement('canvas'), { width: 256, height: 384 });
const bg = badge.getContext('2d');
function drawBadge(seconds) {
bg.clearRect(0, 0, 256, 384); // transparent outside the badge
bg.save(); bg.translate(190, 70); bg.rotate(Math.sin(seconds * 2) * 0.15);
bg.fillStyle = '#c62828'; bg.beginPath(); bg.arc(0, 0, 54, 0, Math.PI * 2); bg.fill();
bg.fillStyle = '#fff'; bg.textAlign = 'center';
bg.font = 'bold 30px Arial'; bg.fillText('20%', 0, -4);
bg.font = 'bold 15px Arial'; bg.fillText('ends in ' + (59 - Math.floor(seconds) % 60) + 's', 0, 22);
bg.restore();
}
const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
uniform mat4 uMVP; out vec2 vUV;
void main() {
vUV = vec2[6](vec2(0), vec2(1, 0), vec2(1), vec2(0), vec2(1), vec2(0, 1))[gl_VertexID];
gl_Position = uMVP * vec4((vUV - 0.5) * vec2(1.0, 1.5), 0.0, 1.0);
}`], [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec2 vUV; uniform sampler2D uCover, uBadge; out vec4 c;
void main() {
vec2 uv = vec2(vUV.x, 1.0 - vUV.y); // canvas rows run top to bottom
vec4 b = texture(uBadge, uv); // un-premultiplied on upload: plain rgb + alpha
vec3 base = texture(uCover, uv).rgb * (gl_FrontFacing ? 1.0 : 0.4);
c = vec4(mix(base, b.rgb, b.a), 1.0);
}`]]) {
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(program, s);
}
gl.linkProgram(program); gl.useProgram(program);
gl.bindVertexArray(gl.createVertexArray());
function texture(unit, source) {
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);
gl.generateMipmap(gl.TEXTURE_2D);
}
texture(0, cover);
drawBadge(0);
texture(1, badge); // unit 1 stays active for the updates below
gl.uniform1i(gl.getUniformLocation(program, 'uCover'), 0);
gl.uniform1i(gl.getUniformLocation(program, 'uBadge'), 1);
const projection = m4.perspective(40, 600 / 320, 0.1, 20);
function frame(ms) {
const t = ms / 1000, a = Math.sin(t * 0.8) * 0.9, c = Math.cos(a), s = Math.sin(a);
drawBadge(t);
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, badge); // same size: update in place
gl.generateMipmap(gl.TEXTURE_2D); // and refresh the smaller levels
gl.uniformMatrix4fv(gl.getUniformLocation(program, 'uMVP'), false,
m4.multiply(projection, [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, -2.6, 1]));
gl.clearColor(0.93, 0.91, 0.87, 1); gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLES, 0, 6);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>