I had a scroll-driven 3D mountain scene built with Three.js. Two versions: basic terrain, then trees and water. The third version applies Studio Ghibli-style cel shading to the entire scene, with frosted glass text panels over the top.
The result is here. The approach comes from Takuya Matsuyama’s Ghibli shader implementation, which itself draws on Lightning Boy Studio’s Blender tutorial for creating Ghibli-style trees in 3D.
How the Ghibli shader works
The core idea is simple. Instead of smooth lighting, you quantise brightness into discrete bands. Each band maps to a flat colour. The result looks painted rather than rendered.
float brightness = dot(worldNormal, lightVector);
vec3 col;
if (brightness > brightnessThresholds[0])
col = colorMap[0];
else if (brightness > brightnessThresholds[1])
col = colorMap[1];
else if (brightness > brightnessThresholds[2])
col = colorMap[2];
else
col = colorMap[3];
Four colours, three thresholds. The thresholds control where the bands fall. Push them higher and you get more shadow. The colour map controls the palette: Matsuyama’s original uses teals and dark greens (#427062, #33594E, #234549, #1E363F).
The vertex shader passes normals and positions through. The fragment shader computes dot(normal, lightDirection) for each fragment and picks a colour band. There’s no smooth interpolation between bands; the hard cutoffs are what give it the cel-shaded look.
Applying cel shading to procedural terrain
The original Ghibli shader applies to a single GLB model. My terrain is a 350x350 PlaneGeometry with vertex colours computed from height and slope. I needed the cel-shading to work with those existing vertex colours rather than a fixed palette.
vec3 celShade(vec3 baseColor, float brightness) {
vec3 col;
if (brightness > 0.7)
col = baseColor * 1.3 + vec3(0.05, 0.04, 0.02);
else if (brightness > 0.4)
col = baseColor * 1.0;
else if (brightness > 0.15)
col = baseColor * 0.65 + vec3(-0.02, 0.0, 0.02);
else
col = baseColor * 0.4 + vec3(-0.03, -0.01, 0.03);
return col;
}
Same four-band structure, but each band is a multiplier on the vertex colour rather than a fixed colour. The highlight band warms slightly (+0.05, +0.04, +0.02), the shadows cool (-0.02, 0.0, +0.02). This is how Ghibli films handle shading on complex surfaces: the shadow colour shifts in hue as well as brightness.
The terrain colours themselves are richer than v2. Meadows are more saturated green, rock has warmer earth tones, snow is cream rather than pure white.
Cel-shaded trees with trunks
v2’s trees were instanced cones with MeshStandardMaterial. For v3, I replaced the geometry with randomised icosahedra for the canopy and tapered cylinders for the trunks.
const canopyGeo = new THREE.IcosahedronGeometry(1.5, 1);
canopyGeo.translate(0, 3.8, 0);
const canopyPos = canopyGeo.attributes.position.array;
for (let i = 0; i < canopyPos.length; i += 3) {
canopyPos[i] += (Math.random() - 0.5) * 0.3;
canopyPos[i + 1] += (Math.random() - 0.5) * 0.25;
canopyPos[i + 2] += (Math.random() - 0.5) * 0.3;
}
const trunkGeo = new THREE.CylinderGeometry(0.12, 0.22, 2.8, 5);
trunkGeo.translate(0, 1.4, 0);
The vertex randomisation on the canopy gives each tree an organic, blobby shape. Two separate InstancedMesh objects (canopy + trunk) share the same transform matrices, so they move as one. The canopy uses the green Ghibli palette, the trunk uses browns (#8a6a4a down to #3a2a1e).
Both materials use the same four-band Ghibli shader as the original repo, just with different colour maps. 3500 trees render fine as instanced meshes.
Edge detection outlines
Ghibli films have visible outlines around objects. I added a post-processing pass that detects edges by comparing neighbouring pixel colours:
vec4 left = texture2D(tDiffuse, vUv + vec2(-texel.x, 0.0));
vec4 right = texture2D(tDiffuse, vUv + vec2( texel.x, 0.0));
vec4 up = texture2D(tDiffuse, vUv + vec2(0.0, texel.y));
vec4 down = texture2D(tDiffuse, vUv + vec2(0.0, -texel.y));
float edgeH = length(left.rgb - right.rgb);
float edgeV = length(up.rgb - down.rgb);
float edge = sqrt(edgeH * edgeH + edgeV * edgeV);
float outline = smoothstep(0.08, 0.2, edge);
tex.rgb *= 1.0 - outline * 0.4;
This is a simplified Sobel filter. Where there’s a sharp colour change between adjacent pixels, the edge value spikes. The smoothstep controls how harsh the outline is. At 0.4 strength it reads as a soft drawn line rather than a hard black border.
The same post-processing pass also handles vignette and colour grading. A slight desaturation (mixing 12% toward greyscale) gives the painterly quality. The warm shift (red * 1.04, blue * 0.94) matches the golden-hour lighting in most Ghibli films.
Animated sky with cloud noise
The sky shader generates wispy clouds using FBM noise in the fragment shader:
if (y > 0.02) {
vec2 cloudUV = dir.xz / (y + 0.1) * 3.0 + uTime * 0.02;
float cloudNoise = fbm(cloudUV);
float clouds = smoothstep(0.4, 0.7, cloudNoise);
vec3 cloudColor = mix(
vec3(1.0, 0.98, 0.94),
vec3(1.0, 0.9, 0.8),
cloudNoise
);
col = mix(col, cloudColor, clouds * 0.35);
}
The clouds are projected onto the sky by dividing dir.xz by y, which maps them to a plane above the viewer. The uTime * 0.02 drift is slow enough that you only notice it if you watch for a while.
Frosted glass text panels
The scroll text in v1 and v2 was plain floating text with a text-shadow. For v3, I wanted frosted glass panels that let the scene bleed through with a mottled texture.
.glass-panel {
background:
url("data:image/svg+xml,...feTurbulence..."),
rgba(255, 255, 255, 0.03);
backdrop-filter: blur(12px) saturate(1.2);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
}
The mottled texture is an inline SVG using feTurbulence with fractalNoise at baseFrequency="0.65". It renders a noise pattern directly in CSS without any image files. The background opacity is intentionally low (0.03) so the panel is almost fully transparent, relying on the backdrop-filter: blur(12px) for the frosted look.
The saturate(1.2) in the backdrop filter keeps the blurred scene behind the panel from looking washed out. Without it, the Ghibli greens fade to grey behind the glass.
The panels animate in with a slide-up (translateY(30px) to 0) and fade when scrolling past. The text uses DM Serif Display for headings, which has the right weight for floating over a 3D scene without looking too heavy.
Cel-shaded water
The water plane uses a shader that quantises wave patterns into three bands instead of smooth reflections:
float wave = sin(uv.x * 3.0 + uTime * 0.8)
* sin(uv.y * 2.0 + uTime * 0.5);
vec3 col;
if (wave > 0.3)
col = uColor * 1.3 + vec3(0.1, 0.12, 0.08);
else if (wave > -0.2)
col = uColor;
else
col = uColor * 0.7;
Three colour bands based on a sine wave product. The highlight band gets a warm offset. The result looks like painted water with visible light and shadow patches that shift slowly.
Atmospheric fog in shaders
Because the terrain, trees, and water all use custom ShaderMaterial, Three.js’s built-in fog doesn’t apply automatically. Each fragment shader computes its own fog:
float dist = length(uCameraPos - vWorldPos);
float fogFactor = 1.0 - exp(-dist * uFogDensity * dist * uFogDensity);
col = mix(col, uFogColor, clamp(fogFactor, 0.0, 1.0));
The fog density is squared in the exponent, which makes it ramp up faster at distance. The uFogDensity uniform updates every frame based on camera height, same as v2. This ensures distant trees and terrain fade into the warm green fog colour rather than abruptly cutting off.
References
The Ghibli shader technique comes from Takuya Matsuyama’s repo and his video walkthrough. The shader design traces back to Lightning Boy Studio’s Blender tutorial on creating Ghibli trees in 3D. The key insight from both is that cel shading isn’t about fewer polygons or simpler geometry. It comes from quantising light into flat bands and choosing colour palettes that shift in hue between light and shadow, with brightness as a secondary axis.