Three.js From Zero · Article s4-06

Subsurface Scattering — The Skin Look

← threejs-from-zeroS4 · Article 06 Season 4
Article S4-06 · Three.js From Zero

Subsurface Scattering — The Skin Look

Standard PBR assumes light hits the surface and bounces off. Skin, wax, marble, milk, jade — light goes INTO the material, scatters around, and exits from a different point. That's subsurface scattering, and without it these materials look like plastic.

The demo is a head-shaped mesh with SSS. Toggle it off and the skin becomes eerily translucent-free — obvious plastic. Slide SSS back up and the skin looks warm and alive, with that telltale red glow at the ears and thin edges when light passes through.

loading…

Why plastic-looking skin

PBR says: light reaches the surface, a fraction reflects (specular), the rest scatters back out as diffuse. "Scatters back out" is instant, at the point it entered. For optically dense materials (metal, plastic), that's accurate.

For optically translucent materials (skin, wax), light enters, bounces around inside for millimeters, exits elsewhere. A red glow on an ear when backlit. A soft glow at the edges of a candle. A marble statue that looks lit from within.

Exact solution: BSSRDF (too slow)

The physically correct model is a Bidirectional Surface Scattering Reflectance Distribution Function — light enters at one point, exits at another, with an entry-to-exit distance-dependent attenuation. Render equation triples in dimensionality. Film-production VFX uses this. Real-time can't.

Approximation 1 — Wrap lighting

Trick: pretend Lambert's max(dot(N, L), 0) has a softer edge. Instead of dropping to 0 at 90°, let it smoothly continue past:

float NoL_wrap(vec3 N, vec3 L, float w) {
  float NoL = dot(N, L);
  return (NoL + w) / (1.0 + w);   // biased + normalized
}

At w=0: standard Lambert. At w=0.5: light wraps partway around the dark side. At w=1: lit everywhere (over-scattered). Zero cost, huge warmness. Used by Valve for Half-Life 2 skin.

Approximation 2 — Pre-integrated SSS (Jimenez)

At curved surfaces, nearby surface points share light. Pre-compute a 2D lookup texture: (NoL, curvature) → scattered color. At runtime, sample by current curvature (from the derivative of world normal, abs(dFdx(N)) + abs(dFdy(N))) and NoL.

Cheaper than blur passes. Best for faces because curvature is mostly static.

Approximation 3 — Separable Screen-Space SSS (Jimenez again)

Render the scene normally. Then do two screen-space blur passes (horizontal + vertical) with a special diffusion profile that matches skin. Depth-masked so the blur stays within the skin, not bleeding onto background.

// Horizontal pass fragment shader (simplified)
vec3 color = texture2D(uColor, uv).rgb;
vec3 sum = color * weights[0];
for (int i = 1; i < 7; i++) {
  vec2 offsetUV = uv + vec2(texelSize.x * i, 0.0);
  if (abs(depth(offsetUV) - depth(uv)) < THRESHOLD) {
    sum += texture2D(uColor, offsetUV).rgb * weights[i];
  }
}
// then same in Y

Perfect for character faces. Used in most AAA games from 2012 on.

Approximation 4 — Thickness map + backlight

Bake a "thickness" map in Blender (how far light travels through the mesh at each UV). Multiply by backlight (lights from behind) to add a reddish pass-through:

vec3 backlight = lightColor * exp(-thickness * absorption);
vec3 translucency = backlight * max(dot(-N, L), 0.0);   // light from behind
color += translucency * sssColor;

Great for ears, wax candles, leaves — anywhere light visibly passes through a thin part.

In Three.js — MeshPhysicalMaterial.transmission + thickness

Three.js ships some of this natively:

const mat = new THREE.MeshPhysicalMaterial({
  color: 0xf4b08c,
  transmission: 0.3,      // how much light passes through
  thickness: 0.5,         // in meters — controls attenuation
  attenuationColor: new THREE.Color(0xe0726b),
  attenuationDistance: 0.5,
  roughness: 0.4,
});

transmission is designed for glass but works as fake SSS for thin surfaces. For character skin, combine with a custom wrap-lighting shader via onBeforeCompile.

The demo uses wrap + backlight

The simplest combo that gives visible SSS:

// Direct lighting — but with wrap
float NoL = max(dot(N, L), -uWrap);
float wrapped = (NoL + uWrap) / (1.0 + uWrap);
vec3 direct = baseColor * wrapped;

// Backlight term — light passing through thin parts
float back = max(dot(-N, L), 0.0);
float thinGlow = pow(back, 4.0) * uThickness;
vec3 translucency = uSSSColor * thinGlow;

gl_FragColor = vec4(direct + translucency * uSSS, 1.0);

Rotate the light behind the head and watch the ears glow red. That's SSS.

Beer-Lambert absorption

Light passing through a thickness t of absorbing medium:

L_out = L_in * exp(-absorption_coefficient * t)

For red scattered light: absorption is high for blue/green, low for red → you get the characteristic red glow. Tune sssColor to match: orangey for skin, yellowish for wax, greenish for jade, white for milk.

Common first-time pitfalls

  • SSS looks washed out. Running on the wrong side. Wrap lighting and backlight are separate terms — don't blend them into one lambert calculation.
  • Hot spots bloom ugly. Tone mapping isn't clamping the backlight. Bound thinGlow to [0, 1] or scale before addition.
  • Flat shading looks skin-colored but no depth. No curvature variation. Add a thickness map from Blender for per-UV variation.
  • Beard / scalp doesn't work. SSS + hair is a known problem. Hair shading is its own beast (S4-07).

Exercises

  1. Pre-integrated SSS LUT: bake a 2D texture (NoL, curvature) → color. Sample in fragment shader. Compare to pure wrap lighting.
  2. Thickness map: bake one in Blender (ray-distance-through-mesh), apply as uThickness texture instead of uniform.
  3. Screen-space separable SSS: two blur passes with depth rejection. Use a post-process composer pass.

What's next

S4-07 — Hair & fur. Shell / fin, Kajiya-Kay lighting, strand-based hair. The other thing nobody renders convincingly by default.