Render to Texture

Rendering a Scene into a Texture

Rendering to a texture is ordinary drawing with three changes: bind the framebuffer, set the viewport to the texture's size (the viewport is not stored per framebuffer), and clear it. Binding null returns to the canvas. The drawShelf() function in Reflection Effect does all three for either target, with a camera aspect that matches the target's shape. Afterwards the color attachment is a normal texture. Never sample it while it is attached to the framebuffer being drawn into (a feedback loop: INVALID_OPERATION), and since only level 0 was drawn, keep a non-mipmap filter, as GLH.framebuffer() does, or allocate more levels and call generateMipmap() after each render.

Spinning books rendered into a 256 x 256 texture, then used as the picture on a tilted shop displayHTMLLive
<!doctype html>
<style>
  body { margin: 0; font: 11px system-ui, sans-serif; background: #f7f4ee; color: #333; }
  canvas { display: block; width: 100%; max-width: 600px; }
  p { max-width: 600px; margin: 4px 0; font-family: monospace; }
</style>
<canvas id="c" width="600" height="300"></canvas>
<p id="out"></p>
<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;
}
// Pass 1 program: a box from gl_VertexID, turned and placed by uniforms (a tiny scene, no buffers).
const books = program(`#version 300 es
uniform float uAngle, uX; uniform vec3 uColor; out vec3 vColor;
void main() {
  int face = gl_VertexID / 6, corner = gl_VertexID % 6;
  vec2 q = vec2[6](vec2(-1), vec2(1, -1), vec2(1), vec2(-1), vec2(1), vec2(-1, 1))[corner];
  vec3 n = vec3[6](vec3(0,0,1), vec3(0,0,-1), vec3(1,0,0), vec3(-1,0,0), vec3(0,1,0), vec3(0,-1,0))[face];
  vec3 u = vec3[6](vec3(1,0,0), vec3(-1,0,0), vec3(0,0,-1), vec3(0,0,1), vec3(1,0,0), vec3(1,0,0))[face];
  vec3 p = (n + q.x * u + q.y * cross(n, u)) * vec3(0.2, 0.3, 0.06);
  float c = cos(uAngle), s = sin(uAngle);
  mat3 turn = mat3(c, 0.0, -s, 0.0, 1.0, 0.0, s, 0.0, c);
  vColor = uColor * (0.4 + 0.6 * max(dot(turn * n, normalize(vec3(0.3, 0.5, 0.8))), 0.0));
  vec3 w = turn * p + vec3(uX, 0.0, 0.0);
  gl_Position = vec4(w.xy, -w.z * 0.5, 1.0);         // nearer (+z) = smaller depth
}`, `#version 300 es
precision mediump float; in vec3 vColor; out vec4 c; void main() { c = vec4(vColor, 1.0); }`);
// Pass 2 program: a display panel in perspective, textured with pass 1's result.
const panel = program(`#version 300 es
uniform float uTilt; out vec2 vUV;
void main() {
  vUV = vec2[6](vec2(0), vec2(1, 0), vec2(1), vec2(0), vec2(1), vec2(0, 1))[gl_VertexID];
  vec3 p = vec3((vUV - 0.5) * 1.6, 0.0);
  float c = cos(uTilt), s = sin(uTilt);
  p = vec3(c * p.x, p.y, -s * p.x) + vec3(0.0, 0.0, -2.4);
  gl_Position = vec4(p.x * 1.2, p.y * 2.4, 0.0, -p.z);     // a simple perspective divide by depth
}`, `#version 300 es
precision mediump float; in vec2 vUV; uniform sampler2D uPicture; out vec4 c;
void main() {
  float frame = step(0.04, min(min(vUV.x, 1.0 - vUV.x), min(vUV.y, 1.0 - vUV.y)));
  c = vec4(mix(vec3(0.2, 0.18, 0.16), texture(uPicture, vUV).rgb, frame), 1.0);
}`);

// The render target: a colour texture plus a depth renderbuffer.
const SIZE = 256;
const picture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, picture);
gl.texStorage2D(gl.TEXTURE_2D, 1, gl.RGBA8, SIZE, SIZE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);   // only level 0 is drawn: no mipmaps
const depth = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depth);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT24, SIZE, SIZE);
const fbo = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, picture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depth);
gl.bindVertexArray(gl.createVertexArray());

// A feedback loop: sampling the texture while drawing into it is refused.
gl.useProgram(panel);
gl.drawArrays(gl.TRIANGLES, 0, 6);
document.getElementById('out').textContent = `sampling the attached texture while it is the target -> ` +
  (gl.getError() === gl.INVALID_OPERATION ? 'INVALID_OPERATION' : 'no error');

const colors = [[0.12, 0.37, 0.55], [0.88, 0.6, 0.06], [0.71, 0.27, 0.18]];
function frame(ms) {
  // Pass 1: bind the framebuffer, set the viewport to the texture's size, clear, draw.
  gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
  gl.viewport(0, 0, SIZE, SIZE);
  gl.clearColor(0.97, 0.93, 0.8, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.enable(gl.DEPTH_TEST); gl.enable(gl.CULL_FACE);
  gl.useProgram(books);
  colors.forEach((color, i) => {
    gl.uniform1f(gl.getUniformLocation(books, 'uAngle'), ms * 0.001 * (i + 1) * 0.7);
    gl.uniform1f(gl.getUniformLocation(books, 'uX'), (i - 1) * 0.6);
    gl.uniform3fv(gl.getUniformLocation(books, 'uColor'), color);
    gl.drawArrays(gl.TRIANGLES, 0, 36);
  });
  // Pass 2: back to the canvas; the colour attachment is now an ordinary texture.
  gl.bindFramebuffer(gl.FRAMEBUFFER, null);
  gl.viewport(0, 0, 600, 300);
  gl.clearColor(0.93, 0.91, 0.87, 1);
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.disable(gl.CULL_FACE);
  gl.useProgram(panel);
  gl.bindTexture(gl.TEXTURE_2D, picture);
  gl.uniform1f(gl.getUniformLocation(panel, 'uTilt'), 0.5 * Math.sin(ms * 0.0005));
  gl.drawArrays(gl.TRIANGLES, 0, 6);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>