Canvas Textures

Drawing a 2D Canvas into a WebGL Texture

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.

A Canvas 2D sale badge redrawn every frame and uploaded with texSubImage2D onto a turning book coverHTMLLive
<!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>