Under the Hood
Threejs

Materials, lighting, and textures: how a surface gets its color

Every visual difference between a Material in Three.js — flat, lit, physically-based, textured, bump-mapped — comes down to what math runs inside the same fragment-shader slot from lesson 5, so this closing lesson treats a Material as a fragment-shader generator and follows one pixel's color from surface normal and light direction through texture sampling to the number that finally lands in the framebuffer.

Materials, lighting, and textures: how a surface gets its color

Every lesson in this module has been building toward one question: when a pixel appears on screen, what decided its color? The pipeline lesson said the answer lives in the fragment shader — the small program that runs once per covered pixel and outputs a single color. Everything since then has been getting data to that program: vertex attributes through buffers, transforms through matrices, hierarchy through the scene graph. This lesson is about what actually happens once the fragment shader has that data in hand, because a Three.js Material is, mechanically, nothing more than a generator for that program. MeshBasicMaterial, MeshLambertMaterial, MeshPhongMaterial, MeshStandardMaterial are not four different rendering systems — they are four different fragment shaders occupying the exact same slot in the exact same pipeline, each doing progressively more accurate math before writing out a color.

A Material is a fragment-shader generator

When you write new THREE.MeshStandardMaterial({ color: 0x8899aa, roughness: 0.5 }), Three.js does not store a color and a number and stop there. It uses those properties to assemble GLSL source code — a real fragment shader, and typically a vertex shader alongside it — compiles it, links it into a program, and that program is what actually runs per fragment when a mesh using this material gets drawn. Swap the material on a mesh from MeshBasicMaterial to MeshStandardMaterial and nothing about the geometry changes; what changes is which compiled program runs during that mesh's draw call.

The four common mesh materials differ only in how much of the fragment shader's math is devoted to reacting to light:

  • MeshBasicMaterial — flat, unlit color. Its fragment shader ignores every Light in the scene entirely; it outputs the material's color (optionally modulated by a texture) regardless of where anything is pointed. This is the cheapest possible fragment shader in this family, and it's why basic materials are the right choice for things that shouldn't look lit — a sky sphere, a UI element rendered into the 3D world, debug wireframes.
  • MeshLambertMaterial — adds diffuse lighting: how much of each light reaches a surface depends on the angle between the surface and the light, computed per vertex and interpolated (cheap, but can miss small highlights).
  • MeshPhongMaterial — computes lighting per fragment instead of per vertex, and adds specular highlights, the bright glossy spot that depends on where the camera is, not just where the light is.
  • MeshStandardMaterial — physically based rendering (PBR): instead of hand-tuned shininess knobs, it's parameterized by roughness (how microscopically scattered the surface is) and metalness (whether reflections behave like a metal or a dielectric), plugged into a lighting model that more closely approximates how real light actually behaves.

Every one of these compiles down to a program that gets bound and run exactly the way the state-machine lesson described: gl.useProgram, then a draw call that executes it once per fragment. The material system is a shader generator sitting on top of the same raw mechanism the whole module has been describing.

The lighting computation at its core: a dot product

Strip away every material's specific bells and whistles and the actual arithmetic that makes a surface look lit is small enough to write out in full. A fragment needs two directions: which way the surface faces at this point (its normal, interpolated across the triangle by the rasterizer, exactly as the pipeline lesson described) and which way the light is coming from. The brightness of straight, head-on diffuse light is the dot product of those two directions:

// Simplified fragment-shader diffuse lighting.
// normal and lightDir are both unit vectors.
float diffuse = max(0.0, dot(normal, lightDir));
vec3 color = baseColor * diffuse + ambientColor;

dot(normal, lightDir) is largest (1.0) when the surface faces directly into the light and falls toward zero as the surface turns away from it — geometrically, it's measuring how much the light's rays are "spread out" by hitting the surface at an angle rather than head-on. The max(0.0, ...) clamps out the case where the surface faces away from the light entirely (a negative dot product), which would otherwise subtract light instead of adding it. ambientColor is a flat baseline added on top so surfaces facing away from every light aren't pure black — a rough stand-in for all the indirect bounced light a real scene has and this simple model doesn't simulate. Phong and Standard materials add a second term for specular highlights, which depends additionally on the direction toward the camera — the highlight moves as you orbit around a shiny surface precisely because that term reacts to viewer position, not just light position.

The crucial thing to hold onto is that this whole computation — dot product, clamp, specular term, texture lookup, sum — runs per fragment, independently, for every one of the potentially millions of covered pixels in a frame, in parallel, exactly the way the pipeline lesson's closing section described the GPU running the same small program across thousands of cores at once. Nothing about lighting is special-cased outside that model; it's just more arithmetic added to the same per-fragment program.

Textures: sampling an image inside the fragment shader

Flat or lit color is one input to a fragment's final color; the other common one is a texture — an image sampled at that fragment's position on the surface. Lesson 3 mentioned that a vertex can carry a UV attribute alongside position and normal — two numbers per vertex specifying where on a 2D image that vertex corresponds to. Rasterization interpolates UVs across a triangle exactly the way it interpolates normals, so every fragment gets its own smoothly-varying (u, v) coordinate, and the fragment shader uses it to look the color up:

uniform sampler2D map; // the texture, already uploaded to GPU memory
varying vec2 vUv;      // this fragment's interpolated UV

void main() {
  vec4 texColor = texture2D(map, vUv);
  gl_FragColor = texColor * vec4(diffuse, 1.0); // modulated by the lighting above
}

The image data itself lives in GPU memory as a texture object — uploaded once, not re-sent per frame — and texture2D is a hardware-accelerated lookup, fast enough to call for every one of millions of fragments a frame. One detail worth knowing: textures are typically stored with mipmaps, a stack of progressively half-sized, pre-shrunk copies of the same image. When a textured surface is far from the camera and covers only a handful of pixels, sampling the full-resolution image would alias and shimmer as the camera moves — sampling from an appropriately small mipmap instead produces a stable, correctly-averaged color. Three.js generates mipmaps for you by default; the fragment shader (or the texture-sampling hardware, really) picks which level to read based on how much screen space the texture is actually covering.

Normal maps push this same sampling trick one step further: instead of sampling a texture for color, you sample it for a perturbed normal — a texture whose RGB channels encode a slightly different surface direction at every texel. The fragment shader reads that perturbed normal instead of (or blended with) the interpolated geometric one, and feeds that into the same dot(normal, lightDir) computation from before. The result is a surface that looks like it has bumps, scratches, or fine relief — brick mortar lines, skin pores, fabric weave — with the lighting math correctly reacting to detail that was never actually built as geometry. It's a fake: the triangle is still flat. But because the lighting equation only ever consumed a normal, not the geometry itself, substituting a more detailed normal is enough to convincingly fool it.

The unifying point

Look back at the four materials from the top of this lesson with all of that in view: MeshBasicMaterial skips the lighting section of the fragment shader entirely. MeshLambertMaterial adds the diffuse dot product. MeshPhongMaterial adds a specular term and moves the lighting math from per-vertex to per-fragment. MeshStandardMaterial replaces the hand-tuned specular shape with a PBR model driven by roughness and metalness. None of these are different pipelines, different draw mechanisms, or different stages — they are the same fragment-shader slot, occupied by four programs of increasing physical accuracy, each still just computing one color for one fragment and writing it out.

const material = new THREE.MeshStandardMaterial({
  map: brickColorTexture,       // sampled per-fragment for base color
  normalMap: brickNormalTexture, // sampled per-fragment for a perturbed normal
  roughness: 0.8,
  metalness: 0.0,
});
const light = new THREE.DirectionalLight(0xffffff, 1.0);
scene.add(light, new THREE.Mesh(geometry, material));

That's a real, complete lit-and-textured surface, and every one of its ingredients maps onto something this lesson (or an earlier one) named explicitly: map feeds the texture sample, normalMap feeds the perturbed normal into the diffuse dot product, roughness/metalness reshape the specular term, and the DirectionalLight supplies lightDir. Nothing here is a new mechanism — it's the fragment shader's inputs, assembled by the material system instead of hand-written GLSL.

Coda: where WebGL's pipeline is headed

Everything this module has described — the fixed pipeline, the bind-then-draw state machine, the model/view/projection matrices, the two shader stages, the scene graph, the draw-call cost model, the fragment-shader materials above — is built on WebGL, an API whose bones (as lesson 2 pointed out) trace back to decades-old fixed-function graphics hardware. WebGPU is the emerging successor, already shipping in modern browsers, and it changes the API shape far more than it changes the underlying concepts. Instead of one big global state machine you mutate call by call, WebGPU has you build explicit, immutable pipeline objects upfront — bundles of shader programs and fixed-function state, validated once and reused, rather than reassembled from whatever happens to be currently bound. It also exposes compute shaders — general-purpose parallel programs that aren't tied to drawing triangles at all — and is built from the ground up for multithreaded command submission, which speaks directly to the draw-call lesson's CPU-bound bottleneck: if dispatching draw commands can be spread across multiple threads instead of serialized on one, the exact ceiling that lesson described gets pushed back. Three.js already ships an experimental WebGPURenderer, and it targets the same Scene, Mesh, Material, and Camera objects this whole module used — because underneath the new API, it's still vertices in, pixels out, through a pipeline that looks remarkably like the one lesson 1 opened with.

Synthesis: one pipeline, eight lessons

Zoom out and the whole module is one continuous mechanism, described from eight different angles: buffers put your vertex data into GPU memory; matrices decide where those vertices land in clip space; two shaders are the only programmable points in an otherwise fixed sequence; the scene graph and render loop are what generate a fresh model matrix and re-trigger that sequence every frame; draw calls are the actual unit of cost the CPU pays to trigger it; and materials, lighting, and textures are what the second of those two shaders computes once it's running. None of these are separate systems bolted together — they are one pipeline, and every Three.js class you'll ever touch exists to fill in exactly one part of it.

Go deeper

  • Three.js manual — Materials A visual side-by-side of Basic/Lambert/Phong/Standard/Physical materials under the same lighting, making the 'same slot, more accurate math' point directly.
  • LearnOpenGL — Basic Lighting Derives the ambient/diffuse/specular model from first principles with the same dot-product diffuse term this lesson walked through, plus diagrams of the vectors involved.
  • WebGPU specification The authoritative source on WebGPU's explicit pipeline objects and compute shaders referenced in this lesson's coda, for anyone continuing past WebGL.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. In what sense is a Three.js Material 'really' a fragment-shader generator rather than a data container?
  2. Order MeshBasicMaterial, MeshLambertMaterial, MeshPhongMaterial, and MeshStandardMaterial by how much lighting math their fragment shader performs, and say what each one adds over the last.
  3. Write the core diffuse lighting formula in terms of a dot product, and explain what it means physically when that dot product is near 0 versus near 1.
  4. Why does specular highlight position change as the camera orbits a shiny object, when diffuse lighting doesn't?
  5. What does a UV coordinate let a fragment shader do, and why must UVs be interpolated per-fragment the same way normals are?
  6. What problem do mipmaps solve, and why does the fragment shader need a pre-shrunk copy rather than always sampling the full-resolution texture?
  7. Explain how a normal map fakes surface detail without adding geometry — specifically, what value does it change, and what equation consumes that changed value?