Closed objects show only front faces, but thin geometry, such as an open book's page, a bookmark or a leaf, is a single layer seen from both sides. With culling off (Face Culling) its back face is drawn, yet its normal still points out of the front, so the lighting treats the visible side as facing away and paints it black. Flip the normal for back faces with the built-in gl_FrontFacing:
vec3 normal = normalize(vNormal);
if (!gl_FrontFacing) normal = -normal; // shading the back of a one-sided surfaceA quick test drew one clockwise (back-facing) triangle with a normal of +z, lit from -z: one-sided it read back black (0, 0, 0), two-sided white (255, 255, 255). A small translucency term, max(dot(-normal, toLight), 0.0) * 0.3, suggests light through paper. Do not flip on closed meshes, where a visible back face is a modeling error.
<!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; }
.names div { flex: 1; padding: 4px 2px; }
</style>
<canvas id="c" width="1200" height="400"></canvas>
<div class="names"><div>one-sided: normal always from the front</div><div>two-sided: if (!gl_FrontFacing) normal = -normal</div></div>
<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];
},
lookAt(eye, target, up = [0, 1, 0]) {
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
const unit = (v) => v.map((c) => c / Math.hypot(...v));
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const z = unit(eye.map((c, i) => c - target[i])), x = unit(cross(up, z)), y = cross(z, x);
return [x[0], y[0], z[0], 0, x[1], y[1], z[1], 0, x[2], y[2], z[2], 0, -dot(x, eye), -dot(y, eye), -dot(z, eye), 1];
},
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;
},
rotationY(a) { const c = Math.cos(a), s = Math.sin(a); return [c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1]; },
};
// A single-layer curved page: x runs from the spine, it bulges in z. Position + normal per vertex.
const page = [];
const point = (s, t) => [s, t * 1.3 - 0.65, Math.sin(s * Math.PI) * 0.18];
const normalAt = (s) => { const dz = Math.cos(s * Math.PI) * 0.18 * Math.PI, l = Math.hypot(dz, 1); return [-dz / l, 0, 1 / l]; };
const N = 24;
for (let i = 0; i < N; i++) for (const [a, b] of [[0, 0], [1, 0], [1, 1], [0, 0], [1, 1], [0, 1]]) {
const s = (i + a) / N;
page.push(...point(s, b), ...normalAt(s)); // counterclockwise seen from +z
}
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 vec3 aPosition; layout(location = 1) in vec3 aNormal;
uniform mat4 uViewProjection, uModel; out vec3 vPosition, vNormal;
void main() { vec4 w = uModel * vec4(aPosition, 1.0); vPosition = w.xyz; vNormal = mat3(uModel) * aNormal;
gl_Position = uViewProjection * w; }`], [gl.FRAGMENT_SHADER, `#version 300 es
precision highp float; in vec3 vPosition, vNormal; uniform bool uTwoSided; uniform vec3 uLight; out vec4 c;
void main() {
vec3 normal = normalize(vNormal);
if (uTwoSided && !gl_FrontFacing) normal = -normal; // shade the side you can see
vec3 toLight = normalize(uLight - vPosition);
float diffuse = max(dot(normal, toLight), 0.0);
float through = uTwoSided ? max(dot(-normal, toLight), 0.0) * 0.3 : 0.0; // light through paper
vec3 paper = vec3(0.96, 0.93, 0.85);
c = vec4(paper * (0.05 + 0.9 * diffuse + through), 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);
const u = (n) => gl.getUniformLocation(program, n);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(page), gl.STATIC_DRAW);
gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 24, 0); gl.enableVertexAttribArray(0);
gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 24, 12); gl.enableVertexAttribArray(1);
gl.enable(gl.DEPTH_TEST);
gl.disable(gl.CULL_FACE); // thin geometry: both sides are drawn
gl.enable(gl.SCISSOR_TEST);
gl.uniform3f(u('uLight'), 1.2, 1.5, 2.5);
gl.uniformMatrix4fv(u('uViewProjection'), false, m4.multiply(m4.perspective(40, 600 / 400, 0.1, 20), m4.lookAt([0.4, 0.6, 2.6], [0, 0, 0])));
function frame(ms) {
const turn = -Math.PI * (0.5 - 0.5 * Math.cos(ms * 0.0009)); // the page turns over and back
gl.uniformMatrix4fv(u('uModel'), false, m4.rotationY(turn));
for (let pane = 0; pane < 2; pane++) {
gl.viewport(pane * 600, 0, 600, 400); gl.scissor(pane * 600 + 3, 0, 594, 400);
gl.clearColor(0.78, 0.74, 0.68, 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.uniform1i(u('uTwoSided'), pane);
gl.drawArrays(gl.TRIANGLES, 0, page.length / 6);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>