This chapter links to the course’s interactive demos and uses MathJax for its formulas. Read it through the course website (or download the file and open it in a browser); a Canvas inline preview will reach neither the demos nor the math. All chapters.

Shaders in GLSL (three.js): ShaderMaterial, Uniforms, Varyings, and the Normal Matrix

GLSL, the OpenGL Shading Language, arrived with OpenGL 2.0 in 2004; WebGL shaders are written in its OpenGL ES dialect. Khronos Group, OpenGL 2.0 specification, 2004

A shader is a small program that the graphics card runs once per vertex or once per pixel, in parallel, with no memory of the other vertices or pixels. three.js writes the shaders for its built-in materials; a ShaderMaterial lets you write your own in GLSL, the shading language of OpenGL and WebGL, while three.js still supplies the matrices, the mesh’s attributes and the colour conversion. Everything the illumination and texture chapters compute per pixel is written here, which is why the homework’s lit sphere (HW5) is a shader. This chapter covers the language as far as the course needs it and the contract between three.js and your code; it leaves out three.js’s node-based materials, compute shaders and WebGPU.

Contents
  1. Where shaders sit in the pipeline
  2. A ShaderMaterial, end to end
  3. GLSL for this course
  4. What three.js declares for you
  5. Uniforms from JavaScript
  6. Lighting in the shader, and the normal
  7. Linear light and the output colour space
  8. Textures and samplers
  9. Render state: culling, depth, blending
  10. Debugging a shader
  11. Exercises

1 · Where shaders sit in the pipeline

For each mesh, the graphics card runs the vertex shader once per vertex. It reads that vertex’s attributes and must write gl_Position, the clip-space position of the viewing chapter (\(P\,V\,M\,\mathbf{p}\), before the divide by \(w\)). The fixed-function stages then clip, divide, map to the viewport and rasterize each triangle into pixels, interpolating whatever else the vertex shader wrote, perspective-correctly (the rasterization chapter). The fragment shader runs once per covered pixel, reads the interpolated values and writes a colour; the depth test and blending decide what reaches the framebuffer.

A data-flow diagram: BufferGeometry attributes (position, normal, uv) feed the vertex shader, which runs once per vertex; uniforms you set, and three.js built-ins such as modelMatrix and viewMatrix, feed both shaders; the vertex shader writes gl_Position and varyings to the rasterizer, which interpolates the varyings into the fragment shader, run once per pixel; its gl_FragColor goes to the framebuffer after the depth test and blending.
The three kinds of input. Attributes change per vertex, varyings per pixel, uniforms per draw. A ShaderMaterial gets the built-ins declared for it; a RawShaderMaterial declares everything itself.
Definition (attribute, uniform, varying) An attribute is a per-vertex input read from the geometry’s arrays (position, normal, uv); only the vertex shader sees it. A uniform is a value set from JavaScript that is the same for every vertex and pixel of one draw (a matrix, a light position, a colour, a texture); both shaders can read it. A varying is written by the vertex shader per vertex and read by the fragment shader interpolated across the triangle; the two declarations must match in type and name.

2 · A ShaderMaterial, end to end

A ShaderMaterial is two strings of GLSL and an object of uniforms. The smallest useful one paints a mesh a single colour:

const material = new THREE.ShaderMaterial({
  uniforms: { uTint: { value: new THREE.Color(0.2, 0.6, 1.0) } },
  vertexShader: VERTEX,
  fragmentShader: FRAGMENT,
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(1, 32, 16), material));
// VERTEX: position is the attribute; the three matrices are declared by three.js
void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
// FRAGMENT: the uniform's name and type match the JavaScript key
uniform vec3 uTint;
void main() {
  gl_FragColor = vec4(uTint, 1.0);
  #include <colorspace_fragment>
}

vec4(position, 1.0) makes the point homogeneous (\(w = 1\)) and the product is the clip position. The uniform is declared in GLSL and given a value in JavaScript; three.js matches them by name each time it draws. The #include line asks three.js to paste in its output colour conversion (Section 7). Nothing else is needed: the attribute position and the matrices are declared by three.js (Section 4).

The strings are usually written in the same JavaScript file as template literals, between backquotes, often tagged with a /* glsl */ comment so an editor highlights them. three.js compiles them the first time the mesh is drawn, and recompiles only when the strings or the material’s defines change; changing a uniform’s value costs nothing.

3 · GLSL for this course

GLSL looks like C with vectors. The parts the course uses fit in one table.

featureGLSLnote
scalarsfloat, int, boolno automatic conversion: 1 is an int and float x = 1; is an error; write 1.0
vectorsvec2, vec3, vec4v.x, v.xyz, v.rgb, v.zyx (a swizzle: any components in any order); vec4(v3, 1.0) extends
matricesmat3, mat4column-major, like three.js; m * v is \(M\mathbf{v}\); m[2] is a column; mat3(m4) takes the upper-left \(3\times3\)
arithmetic+ - * /component-wise on vectors (a * b of two vec3s multiplies per component, the way colours combine); a scalar times a vector scales it
geometrydot, cross, length, normalize, reflect(i, n)reflect takes the incoming direction: \(\mathbf{i} - 2(\mathbf{n}\cdot\mathbf{i})\mathbf{n}\), so the mirror of the light direction \(\mathbf{l}\) is reflect(-l, n)
shapingmax, min, clamp, mix(a, b, t), step, smoothstep, powpow(x, y) is undefined for \(x < 0\); clamp first
matrix functionstranspose, inverseavailable because three.js compiles a ShaderMaterial as GLSL ES 3.00 (WebGL 2); costly per vertex

three.js compiles a ShaderMaterial as GLSL ES 3.00 but lets you write in the older GLSL ES 1.00 spelling: it adds #define lines so that attribute, varying, texture2D and gl_FragColor mean their 3.00 equivalents (in, out, texture, an output variable). This chapter uses the older spelling throughout, as three.js’s own shaders do.

Definition (precision) A fragment shader in GLSL ES must say how precise its floats are: highp (32-bit), mediump or lowp. A ShaderMaterial gets precision highp float; and the same for int and the sampler types prepended for it (or the best the device supports); a RawShaderMaterial gets nothing and fails to compile without the line.
Pitfall A type mismatch is a compile error, not a conversion. vec3 c = uTint * 0.5; gl_FragColor = c; fails because gl_FragColor is a vec4; the fix is gl_FragColor = vec4(c, 1.0);. Section 10 shows the error message this produces and how to find the line.

4 · What three.js declares for you

Before compiling a ShaderMaterial, three.js prepends a block of declarations to each string. The ones a course shader uses are these:

nametypemeaningin
position, normal, uvattribute vec3, vec3, vec2the geometry’s arrays, in object spacevertex
modelMatrixmat4\(M\): the mesh’s matrixWorld, object to worldvertex
viewMatrixmat4\(V\): the camera’s matrixWorldInverseboth
modelViewMatrixmat4\(V M\), object to viewvertex
projectionMatrixmat4\(P\)vertex
normalMatrixmat3the inverse transpose of the upper \(3\times3\) of \(VM\): for normals in view spacevertex
cameraPositionvec3the camera’s position in world spaceboth

So a world-space lighting shader has \(M\), \(V\), \(P\) and the eye position for free; what it lacks is a normal matrix for world space, since three.js’s normalMatrix is built from \(VM\). Section 6 supplies one.

ShaderMaterialRawShaderMaterial
declarations aboveprependednone: declare every attribute and uniform yourself
precision lineprependedwrite it
GLSL versionES 3.00, with the 1.00 spellings definedES 1.00 unless glslVersion: THREE.GLSL3 is set
#include <…> of three.js chunksavailableavailable, but the functions the chunks call are not declared
values of the built-in matricesset every drawset every draw, if you declare them by the same names

The raw form is what a WebGL program without three.js would contain, and nothing is hidden in it:

attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;
void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
precision highp float;
uniform vec3 uTint;
void main() {
  gl_FragColor = vec4(uTint, 1.0);
}

5 · Uniforms from JavaScript

Each entry of uniforms is an object with a value; three.js uploads the value to the GLSL uniform of the same name at every draw. The JavaScript type decides the GLSL type it can fill:

JavaScript valueGLSL uniform
a numberfloat (or int)
THREE.Vector2, Vector3, Vector4vec2, vec3, vec4
THREE.Colorvec3, its linear \(r, g, b\)
THREE.Matrix3, Matrix4mat3, mat4, column-major as stored
THREE.Texturesampler2D
a booleanbool
// once per frame, or whenever the value changes; no needsUpdate, no recompile
material.uniforms.uLightWorld.value.copy(lamp.position);
material.uniforms.uTime.value = t / 1000;
Pitfall Replacing the uniforms object or a uniform’s wrapper (material.uniforms.uLightWorld = lamp.position) breaks the link three.js keeps to it; change .value, or the contents of .value, instead. A uniform declared in GLSL but missing from uniforms is not an error: it keeps the value 0 (a black colour, a zero matrix that collapses every vertex to one point), and no message appears. And a uniform declared but never used is removed by the compiler, so setting it has no effect and also raises no error.

6 · Lighting in the shader, and the normal

Per-pixel lighting needs, at each pixel, the surface point, the surface normal and the light’s position in one space. World space is the natural choice: the light’s position comes from the scene, and cameraPosition is the eye. The vertex shader carries the world position and the world normal as varyings; the fragment shader builds \(\mathbf{L}\), clamps \(\mathbf{N}\cdot\mathbf{L}\) and multiplies. It is the illumination chapter’s diffuse term:

uniform mat3 uNormalWorld;      // inverse transpose of mat3(modelMatrix), set from JavaScript
varying vec3 vWorldPos;
varying vec3 vWorldNormal;
void main() {
  vec4 world   = modelMatrix * vec4(position, 1.0);   // the point, in world space
  vWorldPos    = world.xyz;
  vWorldNormal = uNormalWorld * normal;               // the normal, in world space
  gl_Position  = projectionMatrix * viewMatrix * world;
}
uniform vec3 uLightWorld;       // the light's position, world space
uniform vec3 uAlbedo;           // diffuse reflectance, linear
varying vec3 vWorldPos;
varying vec3 vWorldNormal;
void main() {
  vec3 n = normalize(vWorldNormal);                // interpolation shortens it
  vec3 l = normalize(uLightWorld - vWorldPos);     // direction to the light, per pixel
  vec3 c = uAlbedo * max(dot(n, l), 0.0);          // max(0, N·L)
  gl_FragColor = vec4(c, 1.0);
  #include <colorspace_fragment>
}
// JavaScript: the world normal matrix, refreshed before each draw
uniforms.uNormalWorld = { value: new THREE.Matrix3() };
mesh.onBeforeRender = () => material.uniforms.uNormalWorld.value.getNormalMatrix(mesh.matrixWorld);

getNormalMatrix(m) computes \(\big((M_{3\times3})^{-1}\big)^{\top}\) on the CPU, once per draw. The alternative, transpose(inverse(mat3(modelMatrix))) * normal in the vertex shader, gives the same vector and inverts the same matrix once per vertex. The normal is renormalized in the fragment shader because interpolating two unit vectors gives a shorter one in between.

A unit circle scaled 2 times in x into an ellipse. At the point (1.414, 0.707) two arrows: mat3(modelMatrix) times n gives (0.894, 0.447), leaning away from the perpendicular; normalMatrix times n gives (0.447, 0.894), perpendicular to the dashed tangent.
One normal, two transforms, after a scale of 2 in \(x\). Only the inverse transpose stays perpendicular to the surface.
Pitfall: mat3(modelMatrix) * normal Multiplying the normal by the model matrix itself is exact for rotations and translations and wrong under a non-uniform scale, the trap of the illumination chapter. Take the point \((0.707, 0.707, 0)\) on a unit circle with normal \((0.707, 0.707, 0)\), and scale the object \(2\times\) in \(x\). The point moves to \((1.414, 0.707, 0)\). mat3(modelMatrix) * normal, normalized, is \((0.894, 0.447, 0)\); the inverse transpose gives \((0.447, 0.894, 0)\). The two are \(36.9^\circ\) apart, and the first has a dot of \(-0.6\) with the surface’s tangent where the second has 0. These are the numbers of the Unity shader chapter’s finite-difference normal, which is the same mistake in another form. A sphere scaled \((2, 1, 1)\) shows it: its highlights and terminator sit where a sphere’s would, not where the ellipsoid’s should. For a uniform scale the two agree after normalizing, which is why the shortcut often goes unnoticed.
Pitfall: mixing spaces three.js’s normalMatrix takes normals to view space. Combined with a world-space light position, it gives lighting that turns with the camera: correct when the camera is at its starting pose, wrong after an orbit. Either light in view space throughout (transform the light by viewMatrix, take the eye at the origin) or in world space throughout, as above.

The specular term of the illumination chapter is a few more lines in the fragment shader: the direction to the eye from cameraPosition and the world position, the mirror direction with reflect, and the clamped cosine raised to the shininess with pow. An ambient term is a constant added before the output. Every one of them is computed per pixel from interpolated vectors, which is Phong shading in the sense of the meshes chapter; computing the colour in the vertex shader and interpolating it would be Gouraud shading.

7 · Linear light and the output colour space

Lighting adds and multiplies light, so it must run on linear values (the colour chapter); screens expect sRGB-encoded values (the transfer function). three.js keeps the two apart for you on the way in and asks you to opt in on the way out:

Either convention is consistent if its inputs are chosen for it: linear inputs with the output conversion, or values meant as screen codes with none. The error is the mix, linear inputs without the conversion, which makes every mid-tone too dark, or screen codes with the conversion, which makes them too light.

Hands-on: one pixel, with and without the conversion Save this as css551/try-shader.html, serve the folder, open http://localhost:8000/try-shader.html and read the console. It draws a plane facing the camera, lit by a light at \((0, 1.732, 1)\), so \(\mathbf{N}\cdot\mathbf{L} = 0.5\) at the origin, and reads back the centre pixel.
<!doctype html>
<script type="module">
import * as THREE from './lib/vendor/three.module.js';
const renderer = new THREE.WebGLRenderer();
renderer.setSize(64, 64);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
camera.position.z = 3;
const mat = new THREE.ShaderMaterial({
  uniforms: {
    uLightWorld: { value: new THREE.Vector3(0, 1.732, 1) },
    uAlbedo: { value: new THREE.Vector3(1, 1, 1) },
  },
  vertexShader: `
    varying vec3 vWorldPos;
    void main() {
      vec4 world = modelMatrix * vec4(position, 1.0);
      vWorldPos = world.xyz;
      gl_Position = projectionMatrix * viewMatrix * world;
    }`,
  fragmentShader: `
    uniform vec3 uLightWorld;
    uniform vec3 uAlbedo;
    varying vec3 vWorldPos;
    void main() {
      vec3 n = vec3(0.0, 0.0, 1.0);
      vec3 l = normalize(uLightWorld - vWorldPos);
      gl_FragColor = vec4(uAlbedo * max(dot(n, l), 0.0), 1.0);
      #include <colorspace_fragment>
    }`,
});
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mat));
renderer.render(scene, camera);
const gl = renderer.getContext();
const px = new Uint8Array(4);
gl.readPixels(32, 32, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, px);
console.log('centre pixel', px.join(', '));
</script>
The console prints centre pixel 188, 188, 188, 255. Delete the #include line and reload: centre pixel 129, 129, 129, 255. The pixel’s centre is half a pixel off the axis, where \(\mathbf{N}\cdot\mathbf{L} = 0.5042\): without the conversion that is written as \(0.5042 \times 255 = 128.6\), and with it the sRGB encoding of 0.5042, \(188.2\). A surface lit at \(60^\circ\) reflects half the light of one lit head-on; on the screen, half the light is code 188, not 128.

8 · Textures and samplers

A texture reaches a shader as a sampler2D uniform, and texture2D(sampler, uv) returns the filtered colour at a texture coordinate, \((0, 0)\) at the bottom-left of the image and \((1, 1)\) at the top-right. The geometry’s uv attribute is passed through as a varying:

varying vec2 vUv;
void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
uniform sampler2D uMap;
uniform mat3 uPlace;            // a 2D placement: scale, rotate, offset, as in the texture chapter
varying vec2 vUv;
void main() {
  vec2 st = (uPlace * vec3(vUv, 1.0)).xy;         // homogeneous 2D: the third component is 1
  gl_FragColor = texture2D(uMap, st);
  #include <colorspace_fragment>
}
const tex = new THREE.TextureLoader().load('checker.png');
tex.colorSpace = THREE.SRGBColorSpace;            // an image of colours: decode to linear when sampled
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;     // the default is ClampToEdgeWrapping
material.uniforms.uMap = { value: tex };

The placement matrix is the texture chapter’s \(3\times3\) homogeneous transform of the plane, applied to the coordinate before the lookup; the library’s uvMat3 builds one, and Matrix3.fromArray carries it to the uniform. The sampling options are properties of the texture, not of the shader: wrapS and wrapT (ClampToEdgeWrapping by default, RepeatWrapping, MirroredRepeatWrapping), magFilter (LinearFilter by default, or NearestFilter) and minFilter (LinearMipmapLinearFilter by default, trilinear filtering over the mipmaps of the minification section). anisotropy is 1 by default; raise it for a floor seen at a grazing angle. Data textures (normal maps, heights) keep the default colour space, since their numbers are not colours.

Pitfall Images load asynchronously: the texture is black until the image arrives, and a page that renders only on demand must render again when it does (new THREE.TextureLoader().load(url, () => draw())). And flipY is true by default, which is what makes the image’s top row land at \(v = 1\); a texture drawn from a typed array or a render target is not flipped, so the same uv shows it upside down relative to the loaded image.

9 · Render state: culling, depth, blending

What happens to a fragment after the shader is set on the material, not in GLSL. The defaults of a ShaderMaterial draw opaque front faces with the depth test and depth writes on.

propertydefaultmeaning
sideFrontSideback faces (clockwise on screen) are culled; BackSide draws only them, DoubleSide both. A double-sided surface lit from behind needs gl_FrontFacing to flip its normal
depthTesttruethe z-buffer test of the rasterization chapter: a fragment farther than what is stored is discarded
depthWritetruea surviving fragment writes its depth; turned off for transparent surfaces so they do not hide what is drawn after them
transparentfalsewhen true, blending is on and three.js draws the mesh after the opaque ones, sorted back to front by object
blendingNormalBlendingwith transparent, \(c = \alpha\,c_{\text{src}} + (1 - \alpha)\,c_{\text{dst}}\) (blending); AdditiveBlending adds, for glows and particles. Ignored while transparent is false
wireframefalsedraws the triangles’ edges as lines

A fragment shader can also refuse a pixel with discard;, which writes neither colour nor depth: a cut-out leaf from an alpha texture, or a clipping plane. Sorting is per object, not per triangle, so two interpenetrating transparent meshes, or one concave transparent mesh, can still blend in the wrong order.

10 · Debugging a shader

A shader cannot print. Two techniques replace console.log: read the compiler’s message when it fails, and paint the value you doubt as a colour when it runs.

Hands-on: reading a compile error Give the material of Section 2 this fragment shader, the mistake of Section 3:
uniform vec3 uTint;
void main() {
  vec3 c = uTint * 0.5;
  gl_FragColor = c;
}
Nothing is drawn, and the console shows (in headless Chromium, three.js r170):
THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false

Material Name:
Material Type: ShaderMaterial

Program Info Log: Fragment shader is not compiled.

FRAGMENT

ERROR: 0:60: '=' : dimension mismatch
ERROR: 0:60: 'assign' : cannot convert from 'highp 3-component vector of float' to 'out highp 4-component vector of float'

  55: }
  56:
  57: uniform vec3 uTint;
  58: void main() {
  59:   vec3 c = uTint * 0.5;
> 60:   gl_FragColor = c;
  61: }
followed by WebGL: INVALID_OPERATION: useProgram: program not valid. The line number counts the 56 lines three.js prepended, so line 60 is line 4 of your string; the listing under the message marks it with >. The out highp 4-component vector is gl_FragColor after the GLSL 3.00 #define of Section 3. Read the first error; the rest usually follow from it.

When the shader compiles but the picture is wrong, replace the output with the quantity in doubt, mapped into \([0, 1]\):

varying vec3 vWorldPos;
varying vec3 vWorldNormal;
void main() {
  vec3 n = normalize(vWorldNormal);
  gl_FragColor = vec4(0.5 * n + 0.5, 1.0);        // a unit vector, components -1..1, as a colour
}
Pitfall Remove the colorspace_fragment line while painting debug values; otherwise a normal component of 0.5 is shown as code 188 rather than 128, and the colours do not read as the numbers they stand for. The homework runner’s HW5 skeleton paints normals this way until the lighting is written.

11 · Exercises

Exercise 1 (which input) For each value, say whether it is an attribute, a uniform or a varying: the light’s position; a vertex’s texture coordinate in the vertex shader; the same coordinate in the fragment shader; the time in seconds; the world normal at a pixel.
Solution Uniform; attribute; varying; uniform; varying (computed per vertex, interpolated per pixel).
Exercise 2 (the clip position) Write gl_Position with modelMatrix, viewMatrix and projectionMatrix, and say why the order cannot be changed.
Solution projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0): \(P V M \mathbf{p}\) with column vectors applies \(M\) first. Matrix products do not commute, so another order applies the transforms in another sequence.
Exercise 3 (compile errors) Which of these lines fail to compile, and why? (a) float k = 2; (b) vec3 a = vec3(1.0); (c) vec4 c = vec4(n, 1); with n a vec3 (d) vec2 t = v.xz; with v a vec3.
Solution Only (a): an int is not converted to a float. (b) fills all three components; (c) is allowed because a constructor converts its arguments; (d) is a swizzle.
Exercise 4 (a raw material) A RawShaderMaterial uses the ShaderMaterial vertex shader of Section 2 unchanged. What does the compiler report?
Solution That projectionMatrix, modelViewMatrix and position are undeclared: a raw material gets none of the declarations of Section 4. Add the three declarations, as in the raw example.
Exercise 5 (the normal, by numbers) An object is scaled \((1, 3, 1)\). For the surface normal \((0.6, 0.8, 0)\) in object space, compute the normalized result of mat3(modelMatrix) * normal and of the inverse transpose, and the angle between them.
Solution \(M_{3\times3} = \mathrm{diag}(1, 3, 1)\): \((0.6, 2.4, 0)\), normalized \((0.243, 0.970, 0)\). The inverse transpose is \(\mathrm{diag}(1, 1/3, 1)\): \((0.6, 0.267, 0)\), normalized \((0.914, 0.406, 0)\). The angle is \(\arccos(0.243 \cdot 0.914 + 0.970 \cdot 0.406) = \arccos(0.616) = 52.0^\circ\).
Exercise 6 (world or view) A student lights with normalize(normalMatrix * normal) and a world-space uLightWorld. The sphere looks right at first and the lit side drifts as the camera orbits. Explain, and give two fixes.
Solution normalMatrix is built from \(VM\), so the normal is in view space and the light in world space; the mismatch changes with \(V\). Either use a world normal matrix (Section 6) or move the light into view space with (viewMatrix * vec4(uLightWorld, 1.0)).xyz and use the view-space position of the point.
Exercise 7 (the output conversion) A shader outputs the linear value 0.25 with the colorspace_fragment line. What code value reaches the screen (sRGB: \(1.055\,x^{1/2.4} - 0.055\) for \(x > 0.0031308\))? Without the line?
Solution \(1.055 \times 0.25^{1/2.4} - 0.055 = 0.5371\), code \(0.5371 \times 255 = 137\). Without it, \(0.25 \times 255 = 64\) (63.75 rounded).
Exercise 8 (texture placement) uPlace is a scale by 4 in both directions with RepeatWrapping. How many copies of the image cover the quad, and what changes with ClampToEdgeWrapping?
Solution \(4 \times 4 = 16\) copies: \(st\) runs from 0 to 4 and the repeat keeps the fractional part. With clamping, the image fills the lower-left quarter of each axis, \(st \in [0, 1]\), and the rest of the quad repeats the image’s edge texels as streaks.
Exercise 9 (render state) A glass sphere with transparent: true and opacity 0.4 is drawn and its back half is missing, while an opaque cube behind it shows through correctly. Which property explains the missing back half, and what are the costs of changing it?
Solution side is FrontSide, so the inner back faces are culled. DoubleSide draws them, but in one draw call, in triangle order, so some back faces blend over front faces already drawn and the result depends on the mesh’s triangle order. Drawing the sphere twice, BackSide first and FrontSide second, gives the right order for a convex shape.
Exercise 10 (a debug view) In the normals-as-colour view of Section 10, a sphere’s top is light green and its left side, facing \(-x\), is what colour? A flat floor with normal \((0, 1, 0)\)?
Solution \(0.5\,(-1, 0, 0) + 0.5 = (0, 0.5, 0.5)\), a teal. The floor is \((0.5, 1, 0.5)\), the same light green as the sphere’s top, everywhere.