Multi-Texturing

Texture Units and Multi-Texturing

A shader reads textures through numbered texture units. activeTexture(gl.TEXTURE0 + n) selects unit n, bindTexture() puts a texture on it, and each sampler2D uniform holds a unit number, set with uniform1i(). Samplers default to 0, so a shader with two samplers reads the same texture twice until you set the second:

Texture unit and size limits, required and measured
Limit WebGL 1 minimum WebGL 2 minimum GTX 1650 SwiftShader 2,546
MAX_TEXTURE_IMAGE_UNITS (fragment) 8 16 16 32
MAX_COMBINED_TEXTURE_IMAGE_UNITS 8 32 32 64
MAX_TEXTURE_SIZE 64 2048 16384 8192

Combining textures in one shader, such as a color map with a decal or a light map, is multi-texturing: Texturing BookNest's Cover Art lays a sticker over a cover with mix(base, sticker.rgb, sticker.a), and Normal Maps and PBR adds normal and roughness maps. Many same-sized images fit behind one sampler as a TEXTURE_2D_ARRAY.

A cover on unit 0, a light map on unit 1 and a sale sticker on unit 2, combined in one fragment shaderHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  .names { display: flex; max-width: 600px; text-align: center; font-family: monospace; font-size: 10px; }
  .names div { flex: 1; padding: 4px 2px; }
  p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="230"></canvas>
<div class="names"><div>unit 0: cover</div><div>unit 1: light map</div><div>unit 2: sticker (alpha)</div><div>all three</div></div>
<p id="limits"></p>
<script>
function canvas2d(w, h, draw) { const c = document.createElement('canvas'); c.width = w; c.height = h; draw(c.getContext('2d')); return c; }
const coverArt = canvas2d(256, 384, (g) => {
  g.fillStyle = '#3f7d3a'; g.fillRect(0, 0, 256, 384);
  g.fillStyle = '#f2ead8'; g.beginPath(); g.moveTo(20, 300); g.lineTo(128, 90); g.lineTo(236, 300); g.fill();
  g.fillStyle = '#3f7d3a'; g.beginPath(); g.moveTo(90, 300); g.lineTo(160, 170); g.lineTo(236, 300); g.fill();
  g.fillStyle = '#fff'; g.font = 'bold 28px Georgia'; g.textAlign = 'center';
  g.fillText('Small Steps to', 128, 40); g.fillText('Big Summits', 128, 74);
});
const lightMap = canvas2d(64, 96, (g) => {           // a baked pool of lamplight, darker at the corners
  const grad = g.createRadialGradient(22, 26, 4, 32, 48, 70);
  grad.addColorStop(0, '#fff6e0'); grad.addColorStop(1, '#3a3530');
  g.fillStyle = grad; g.fillRect(0, 0, 64, 96);
});
const sticker = canvas2d(256, 384, (g) => {          // transparent except the badge
  g.fillStyle = '#c62828'; g.beginPath(); g.arc(190, 250, 50, 0, 2 * Math.PI); g.fill();
  g.fillStyle = '#fff'; g.font = 'bold 34px Arial'; g.textAlign = 'center';
  g.fillText('20%', 190, 248); g.font = 'bold 24px Arial'; g.fillText('OFF', 190, 276);
});

const gl = document.getElementById('c').getContext('webgl2');
const program = gl.createProgram();
for (const [type, src] of [[gl.VERTEX_SHADER, `#version 300 es
layout(location = 0) in vec2 aPos; out vec2 vUV;
void main() { vUV = vec2(aPos.x, 1.0 - aPos.y); gl_Position = vec4(aPos * 2.0 - 1.0, 0.0, 1.0); }`],
  [gl.FRAGMENT_SHADER, `#version 300 es
precision mediump float; in vec2 vUV; out vec4 c;
uniform sampler2D uCover, uLight, uSticker;     // each holds a texture unit number
uniform int uShow;
void main() {
  vec3 base = texture(uCover, vUV).rgb, light = texture(uLight, vUV).rgb;
  vec4 badge = texture(uSticker, vUV);
  if (uShow == 0) c = vec4(base, 1.0);
  else if (uShow == 1) c = vec4(light, 1.0);
  else if (uShow == 2) c = vec4(mix(vec3(0.85), badge.rgb, badge.a), 1.0);
  else c = vec4(mix(base, badge.rgb, badge.a) * light * 1.2, 1.0);   // decal over the cover, then light
}`]]) {
  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.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1]), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(0);

[coverArt, lightMap, sticker].forEach((source, unit) => {
  gl.activeTexture(gl.TEXTURE0 + unit);          // select unit n...
  gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());   // ...and put a texture on it
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, source);
  gl.generateMipmap(gl.TEXTURE_2D);
});
// Samplers default to unit 0: point the other two at units 1 and 2.
gl.uniform1i(gl.getUniformLocation(program, 'uCover'), 0);
gl.uniform1i(gl.getUniformLocation(program, 'uLight'), 1);
gl.uniform1i(gl.getUniformLocation(program, 'uSticker'), 2);

gl.clearColor(0.97, 0.96, 0.93, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
for (let i = 0; i < 4; i++) {
  gl.uniform1i(gl.getUniformLocation(program, 'uShow'), i);
  gl.viewport(i * 150 + 20, 5, 110, 220);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}
document.getElementById('limits').textContent =
  `MAX_TEXTURE_IMAGE_UNITS ${gl.getParameter(gl.MAX_TEXTURE_IMAGE_UNITS)} | ` +
  `MAX_COMBINED_TEXTURE_IMAGE_UNITS ${gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)} | ` +
  `MAX_TEXTURE_SIZE ${gl.getParameter(gl.MAX_TEXTURE_SIZE)}`;
</script>