Creating a Refractive Material with Chromatic Aberration in Three.js


This guide shows how to build a physically-inspired refractive material for transparent objects in Three.js, with chromatic dispersion, Fresnel reflections, and thickness-aware refraction.
We’ll use multiple render passes (front/back faces + background capture), screen-space sampling, and a compact three-wavelength spectral model (via Cauchy IOR approximation) that’s fast enough for the web.
What you’ll build
- A custom shader material that:
- Refracts the scene behind the object (no cube maps required).
- Splits white light into R/G/B using wavelength-dependent IOR.
- Responds to viewing angle with Schlick Fresnel.
- Accounts for object thickness (using front/back depth).
- A robust render pipeline that works on desktop and mobile, with adaptive quality.
Stack: Three.js (or React Three Fiber), WebGL2 (or WebGPU), optional R3F useFBO.
1) Optical model (short & practical)
We approximate dispersion with three wavelengths (R≈700 nm, G≈546 nm, B≈435 nm). For each λ we compute refractive index n(λ) with Cauchy:
n(λ) = A + B/λ² + C/λ⁴ (λ in micrometers; for glass often A≈1.5046, B≈0.00420, C≈0)
Fresnel reflectance uses Schlick:
F(θ) = F0 + (1 − F0) · (1 − cosθ)^5, F0 = ((n − 1) / (n + 1))^2
We compute thickness from the difference between back-face and front-face depths in view space and scale the refraction offset by this thickness, giving realistic parallax inside the volume.

2) Render pipeline overview
- Background pass — render the full scene without the refractive mesh → backgroundRT (color).
- Back-face depth — render BackSide of the mesh to backDepthRT (depth only).
- Front-face depth — render FrontSide of the mesh to frontDepthRT (depth only).
- Final — draw the mesh with the custom refractive fragment shader that samples:
- backgroundRT for screen-space environment,
- frontDepthRT & backDepthRT to compute thickness,
- and mixes refraction + Fresnel reflection.
Why back & front? Their depth difference gives you local thickness even for complex shapes.

3) FBO setup (React Three Fiber example)
import * as THREE from 'three';
import { useThree, useFrame } from '@react-three/fiber';
import { useFBO } from '@react-three/drei';
import { useMemo, useRef } from 'react';
function useRefractFBOs() {
const { size, viewport } = useThree();
const samples = 0; // 0–2 on mobile, 2–4 desktop
const backgroundRT = useFBO({ samples, depthBuffer: true, stencilBuffer: false });
const frontDepthRT = useFBO({ samples: 0, depthBuffer: true, stencilBuffer: false });
const backDepthRT = useFBO({ samples: 0, depthBuffer: true, stencilBuffer: false });
const prev = useRef({ w: 0, h: 0 });
useFrame(({ viewport, size }) => {
const dpr = viewport.dpr;
const w = Math.floor(size.width * dpr);
const h = Math.floor(size.height * dpr);
if (w !== prev.current.w || h !== prev.current.h) {
backgroundRT.setSize(w, h);
frontDepthRT.setSize(w, h);
backDepthRT.setSize(w, h);
prev.current = { w, h };
}
});
return { backgroundRT, frontDepthRT, backDepthRT };
}
4) Vertex shader
We pass world normal and view direction. Keep it linear-space.
// refract.vert
varying vec3 vWorldNormal;
varying vec3 vViewDir;
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vWorldNormal = normalize(mat3(modelMatrix) * normal);
vViewDir = normalize(cameraPosition - worldPos.xyz);
gl_Position = projectionMatrix * viewMatrix * worldPos;
}
5) Fragment shader (three-wavelength dispersion, Schlick, thickness)
This version:
- Computes nR, nG, nB via Cauchy.
- Uses front/back depth to derive thickness (in view space).
- Refracts three rays, samples background, and mixes with Schlick Fresnel reflection.
- Includes tone mapping / color space at the end.
// refract.frag
precision highp float;
varying vec3 vWorldNormal;
varying vec3 vViewDir;
uniform sampler2D uBackground; // scene color
uniform sampler2D uFrontDepth; // front-face depth
uniform sampler2D uBackDepth; // back-face depth
uniform vec2 uResolution; // in pixels
// Cauchy params for glass-like medium (tune per material)
uniform float uA; // ~1.5046
uniform float uB; // ~0.0042
uniform float uC; // ~0.0
// Dispersion & scale
uniform float uChromaticScale; // dispersion multiplier (0–1)
uniform float uRefractScale; // base refraction intensity
// Fresnel boost (for artistic control)
uniform float uFresnelBoost; // 0–1, multiplies F
// Helper: linearize depth (view-space)
float linearizeDepth(float zNDC) {
// Assumes perspective; if you have near/far, pass them as uniforms and do proper inversion.
// For screen-space depth texture in [0..1], remap if needed. Here we expect already-linear values.
return zNDC;
}
// Schlick Fresnel for RGB
vec3 fresnelSchlick(float cosTheta, vec3 F0) {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
// Cauchy IOR at wavelength (micrometers)
float iorAt(float lambdaUm) {
return uA + uB/(lambdaUm*lambdaUm) + uC/(lambdaUm*lambdaUm*lambdaUm*lambdaUm);
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
vec3 N = normalize(vWorldNormal);
vec3 V = normalize(vViewDir);
// 1) Thickness from depth difference
float dFront = texture2D(uFrontDepth, uv).r;
float dBack = texture2D(uBackDepth, uv).r;
float zF = linearizeDepth(dFront);
float zB = linearizeDepth(dBack);
float thickness = max(0.0, zB - zF);
// 2) Spectral IOR (R,G,B)
float nR = iorAt(0.700); // 700 nm
float nG = iorAt(0.546); // 546 nm
float nB = iorAt(0.435); // 435 nm
// Base F0 from IOR per channel
vec3 nRGB = vec3(nR, nG, nB);
vec3 F0 = pow((nRGB - 1.0) / (nRGB + 1.0), 2.0);
// 3) Refract directions (air->material; assuming air n≈1)
// For screen-space sampling, use XY of refracted ray as an offset guide
vec3 Rdir = refract(-V, N, 1.0 / nR);
vec3 Gdir = refract(-V, N, 1.0 / nG);
vec3 Bdir = refract(-V, N, 1.0 / nB);
// 4) Offset magnitude: base + thickness-scaled dispersion
float baseOff = uRefractScale;
float disp = uChromaticScale * thickness;
// UVs per channel (clamp softly)
vec2 uvR = uv + Rdir.xy * (baseOff + disp * 0.6);
vec2 uvG = uv + Gdir.xy * (baseOff + disp * 1.0);
vec2 uvB = uv + Bdir.xy * (baseOff + disp * 1.4);
uvR = clamp(uvR, vec2(0.001), vec2(0.999));
uvG = clamp(uvG, vec2(0.001), vec2(0.999));
uvB = clamp(uvB, vec2(0.001), vec2(0.999));
// 5) Sample background (consider using textureLod for big offsets if available)
float r = texture2D(uBackground, uvR).r;
float g = texture2D(uBackground, uvG).g;
float b = texture2D(uBackground, uvB).b;
vec3 refractedRGB = vec3(r, g, b);
// 6) Fresnel mix
float cosTheta = abs(dot(V, N));
vec3 F = fresnelSchlick(cosTheta, F0) * uFresnelBoost;
// Approximate reflection from screen env by reflecting view vector (SSR-lite)
vec3 R = reflect(-V, N);
vec2 uvRef = clamp(uv + R.xy * (baseOff * 0.65), vec2(0.001), vec2(0.999));
vec3 reflected = texture2D(uBackground, uvRef).rgb;
vec3 color = mix(refractedRGB, reflected, F);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
Notes
- If you target WebGL2+, prefer textureLod(uBackground, uv, lod) when the offset is large to avoid aliasing.
- For WebGPU/RT, switch to WGSL equivalents—the math is the same.
6) Material & uniforms
import { ShaderMaterial } from 'three';
import { useMemo } from 'react';
function useRefractMaterial() {
return useMemo(() => {
const mat = new ShaderMaterial({
vertexShader: refractVertSource,
fragmentShader: refractFragSource,
transparent: false,
depthWrite: true,
depthTest: true,
});
// Defaults (tune per asset)
mat.uniforms = {
uBackground: { value: null },
uFrontDepth: { value: null },
uBackDepth: { value: null },
uResolution: { value: new THREE.Vector2(1, 1) },
// Cauchy glass
uA: { value: 1.5046 },
uB: { value: 0.0042 },
uC: { value: 0.0 },
uChromaticScale: { value: 0.5 }, // reduce on mobile
uRefractScale: { value: 0.12 },
uFresnelBoost: { value: 1.0 },
} as any;
return mat;
}, []);
}
7) Render loop (React Three Fiber)
function RefractiveMesh() {
const meshRef = useRef<THREE.Mesh>(null);
const bgRef = useRef<THREE.Object3D>(null);
const { gl, scene, camera, size, viewport } = useThree();
const { backgroundRT, frontDepthRT, backDepthRT } = useRefractFBOs();
const mat = useRefractMaterial();
useFrame(() => {
if (!meshRef.current) return;
// Update resolution uniform
const dpr = viewport.dpr;
mat.uniforms.uResolution.value.set(size.width * dpr, size.height * dpr);
const prevMeshVisible = meshRef.current.visible;
// 1) Background pass (hide mesh)
meshRef.current.visible = false;
gl.setRenderTarget(backgroundRT);
gl.clearColor();
gl.render(scene, camera);
// 2) Back-face depth
meshRef.current.visible = true;
meshRef.current.material.side = THREE.BackSide;
gl.setRenderTarget(backDepthRT);
gl.clear(true, true, true);
gl.render(scene, camera);
// 3) Front-face depth
meshRef.current.material.side = THREE.FrontSide;
gl.setRenderTarget(frontDepthRT);
gl.clear(true, true, true);
gl.render(scene, camera);
// 4) Final composite
mat.uniforms.uBackground.value = backgroundRT.texture;
mat.uniforms.uFrontDepth.value = frontDepthRT.depthTexture ?? frontDepthRT.texture;
mat.uniforms.uBackDepth.value = backDepthRT.depthTexture ?? backDepthRT.texture;
gl.setRenderTarget(null);
meshRef.current.material = mat;
// Restore state if needed
meshRef.current.visible = prevMeshVisible;
});
return (
<mesh ref={meshRef}>
<sphereGeometry args={[1, 128, 128]} />
{/* material assigned in frame */}
</mesh>
);
}
If your FBOs don’t expose depthTexture, enable depthTexture: true (or sample depth from the color attachment of a depth-only pass, depending on your setup).
8) Performance tips
- Adaptive quality: Lower uChromaticScale and FBO MSAA on mobile. Introduce dynamic resolution: if frame time > 20 ms, downscale FBOs by 0.85; if < 12 ms, upscale toward 1.0.
- LOD for big offsets: For large refraction offsets use textureLod to pick a higher mip level and avoid shimmering.
- Temporal smoothing: A simple TAA (jitter + history blend) reduces rainbow noise on thin edges.
- Avoid self-sampling: Never read from the RT you are currently rendering into.
- State hygiene: After offscreen passes, restore viewport, clear color, depth test, and cull state to avoid “phantom” artifacts.

9) Troubleshooting
- Color fringes “leak” at edges: Clamp UVs, blend with a soft vignette near borders, reduce uChromaticScale.
- Looks “rubbery” (too much smear): Ensure thickness actually drives the offset; verify depth pass order and linearization.
- Flat/metallic reflections: Replace the single light specular with screen-space reflection sampling (shown) or environment IBL (PMREM cubemap).
- Banding: Switch background RT to RGBA16F (if supported) and keep the pipeline in linear color until the final color-space conversion.
10) Parameter cheatsheet (starting points)
- Glass (clear): A=1.5046, B=0.0042, C=0.0, refractScale=0.10–0.15, chromaticScale=0.35–0.6
- Acrylic: A≈1.49, B≈0.005, C≈0, refractScale a touch lower, chromaticScale ≈0.3
- Crystal (stronger dispersion): increase B, but cap chromaticScale on mobile
11) What you get
- Realistic refraction that respects thickness and view angle.
- Plausible chromatic aberration with only three spectral samples.
- A screen-space solution (no HDRI required), which keeps authoring simple.
- A pipeline that works on desktop and mobile with a few toggles.
Closing notes
This approach balances physical plausibility and runtime budget for the web. If you later need higher fidelity, swap the screen-space background for a prefiltered environment map (IBL) and blend it with SSR for best of both worlds.
For WebGPU, port the shaders to WGSL—the math and flow are identical, and you’ll gain performance headroom for higher dispersion or TAA.

Andrey Malyshev
Founder of Lb project