Three.js & RenderingMaterials & Lightcore4 min read
Reflection and Refraction
light splits at a surface: some bounces, the rest bends
Light hitting a surface partly reflects and partly bends through. Snell's law sets the bend, Fresnel the split; past the critical angle all of it reflects.
The solid white arrow is the incoming ray and the green dashed line is the surface normal. The yellow dashed ray is the reflection and the solid grey ray is the refraction; each is brighter and thicker the more energy (Fresnel) it carries. Grey dashed lines mark the critical angle: sweep past it and the grey ray vanishes (total internal reflection). Swap the IORs to go from a light into a dense medium.
What it is
When a ray hits the boundary between two materials, it splits. The reflected ray leaves at the same angle it arrived, mirrored about the surface normal. The refracted (transmitted) ray continues into the second material, bent toward or away from the normal depending on the two indices of refraction (IOR): air 1.0, water 1.33, glass 1.5, diamond 2.42.
Snell's law gives the bend angle. Fresnel gives the split: how much energy reflects versus transmits. Head-on, glass reflects only about 4%. At grazing angles almost everything reflects, which is why a lake mirrors the far shore but you can see the bottom at your feet. Schlick's approximation is the cheap formula every real-time renderer uses.
Going from a dense medium into a lighter one (glass → air, water → air), there is a critical angle. Beyond it Snell's law has no solution, nothing transmits, and all light reflects: total internal reflection (TIR). It is how optical fibres work, and why you see a silvery ceiling when looking up from underwater.
In shaders, GLSL's built-in reflect(I, N) and refract(I, N, eta) do the vector maths. Both expect unit vectors, with I pointing toward the surface. refract returns a zero vector on TIR. In Three.js, MeshPhysicalMaterial with transmission and ior handles this for you.
- optics
- fresnel
- snell
- ior
- glsl
- total internal reflection
Key formulas
- Reflection vector
- Snell's law
- Critical angle (n₁ > n₂)
Glass → air: 41.8°. Water → air: 48.8°.
- Schlick's Fresnel approximation
Use the angle on the less dense side; under TIR, F = 1.
Rules of thumb
- F0 for common dielectrics: water 0.02, glass and plastic 0.04, diamond 0.17. Metals are 0.5–1.0 and tinted.
- Always normalize
IandNbeforereflect/refract; GLSL does not do it for you. etainrefractis n₁/n₂: 1/1.5 ≈ 0.667 entering glass from air, 1.5 when leaving it.- If
dot(N, I) > 0the ray is exiting the object: flipNand inverteta. - Energy is conserved: transmitted = 1 − F (ignoring absorption), so reflections and refractions must not both be at full strength.
Implementation
uniform samplerCube envMap;uniform float ior; // e.g. 1.5varying vec3 vWorldPos;varying vec3 vWorldNormal;
void main() { vec3 I = normalize(vWorldPos - cameraPosition); vec3 N = normalize(vWorldNormal); float eta = 1.0 / ior; // air → glass if (dot(N, I) > 0.0) { N = -N; eta = ior; } // leaving the glass
vec3 R = reflect(I, N); vec3 T = refract(I, N, eta); // vec3(0) on TIR float f0 = pow((1.0 - ior) / (1.0 + ior), 2.0); float F = f0 + (1.0 - f0) * pow(1.0 - clamp(dot(-I, N), 0.0, 1.0), 5.0); if (dot(T, T) == 0.0) F = 1.0; // total internal reflection
vec3 col = mix(textureCube(envMap, T).rgb, textureCube(envMap, R).rgb, F); gl_FragColor = vec4(col, 1.0);}export function snell(theta1: number, n1: number, n2: number) { const s = (n1 / n2) * Math.sin(theta1) return Math.abs(s) > 1 ? null : Math.asin(s) // null = total internal reflection}
export function schlick(cosTheta: number, n1: number, n2: number) { const f0 = ((n1 - n2) / (n1 + n2)) ** 2 return f0 + (1 - f0) * (1 - cosTheta) ** 5}Common mistakes
- Passing the view direction (surface → camera) as
I.reflectandrefractwant the incoming direction (camera → surface). - Using
eta = iorwhen entering glass. It is n₁/n₂, so entering from air it is1/ior. - Ignoring the zero vector from
refractand sampling the environment with it, which gives a black or garbage pixel. - Constant reflection strength instead of Fresnel. Water and glass look flat without bright grazing-angle edges.
Prompt
Write a GLSL fragment shader for a Three.js ShaderMaterial that renders a glass-like surface from a samplerCube environment. In view space compute the unit incident vector I = normalize(fragPos − cameraPos) and the unit normal N (flip N if dot(N, I) > 0, i.e. the ray is leaving the medium, and use eta = ior instead of 1/ior). Sample R = reflect(I, N) and T = refract(I, N, eta); when refract returns the zero vector (total internal reflection) use only the reflection. Blend with Schlick: F0 = ((1 − ior)/(1 + ior))², F = F0 + (1 − F0)(1 − cosθ)⁵ with cosθ = clamp(dot(−I, N), 0, 1). Expose ior (default 1.5) as a uniform. Also write a TypeScript reflect/refract pair and a unit test that refract matches Snell's law and returns null above the critical angle.In learning paths
Related
- PBR Materialsdescribe the surface, let physics light it
- Surface Normalsthe unit arrow that says which way a surface faces
- Fragment Shadersa tiny program that runs once for every pixel
- Raycastingshoot an invisible line and ask what it touches first
- Dot and Cross Productone number for alignment, one vector for perpendicular
Further reading
Updated 2026-11-01
