Three.js From Zero · Article s4-06
Subsurface Scattering — The Skin Look
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.
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
- Pre-integrated SSS LUT: bake a 2D texture (NoL, curvature) → color. Sample in fragment shader. Compare to pure wrap lighting.
- Thickness map: bake one in Blender (ray-distance-through-mesh), apply as
uThicknesstexture instead of uniform. - 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.