The Sleepers emerged as a WebGL venture I crafted for Bruno Simon’s Three.js contest.
Working on a creative challenge like this often means juggling time pressures and personal passion projects without financial backing. It’s a scenario that mandates a sharp focus on efficient strategies.
This case study presents a variety of straightforward WebGL techniques designed for swift implementation with a focus on performance. Rather than striving for complex technical solutions, I aimed for maximum visual impact through simplicity.
Let’s examine some of the methods employed in this creation.
Initial Transition: Swirling Effects
For the swirling transition, a black-and-white texture is critical, like the one displayed below:

This texture acts as a uniform in a post-processing shader that manages the gradual shift from dark grayscale to full color within the scene.
The red channel functions as a threshold, where each pixel transitions based on whether the progress uniform (uProgress) surpasses the corresponding threshold from the texture.
Here’s a simplified shader snippet that illustrates the method:
uniform float uProgress;
uniform sampler2D uTransitionTexture;
vec3 greyscale(vec3 color, float str) {
float g = dot(color, vec3(0.1));
return mix(color, vec3(g), str);
}
void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) {
vec3 greyScaledColor = greyscale(inputColor.rgb, 1.);
greyScaledColor = mix(greyScaledColor, vec3(0.1, 0.1, .9), pow(uProgress, 5.));
vec4 textureColor = texture2D(uTransitionTexture, vUv);
float mixer = step(textureColor.r, uProgress);
outputColor = vec4(mix(greyScaledColor, inputColor.rgb, mixer), 1.);
}
As uProgress progresses from 0 to 1, different areas of the screen reveal themselves in sequence according to the texture values. Initially, the darker sections come into view, followed by brighter regions, resembling a mesmerizing swirling onset. This unassuming technique can generate vivid and fluid transitions while maintaining a straightforward shader logic. 👌
Simulated Fog
This fog effect isn’t genuinely volumetric; rather, it’s a clever color manipulation applied across the scene materials.
Step 1: Vertical Gradation of Fog
First, we need to modify the shader (employing onBeforeCompile) to prepare it for future material applications.
Within the onBeforeCompile, we utilize the world position of each fragment to dictate its color. Above a designated Y-axis threshold, colors remain unchanged; below it, fragments take on the fog's color.
const fogOnBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
varying vec3 vWorldPosition;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
`)
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`
uniform float fogPositionY;
uniform float fogSmoothness;
varying vec3 vWorldPosition;
void main() {
`)
shader.fragmentShader = shader.fragmentShader.replace(
'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
`
vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
float verticalMixer = smoothstep(vWorldPosition.y - fogSmoothness, vWorldPosition.y + fogSmoothness, fogPositionY);
float mixer = clamp(verticalMixer, 0., 1.);
vec3 fogColor = vec3(1.);
outgoingLight = mix(outgoingLight, fogColor, mixer);
`);
shader.uniforms.fogPositionY = { value: fogSettings.height };
shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
}
This shader adjustment can be applied to materials throughout the scene (assuming they’re instances of MeshStandardMaterial; modifications may be needed for other types).
gltf.scene.traverse(child => {
if (child.isMesh) {
child.material.onBeforeCompile = (shader) => {
fogOnBeforeCompile(shader);
};
}
});
Following this process should yield an initial result that looks something like this:
In this instance, I enveloped the scene within a spherical shape, applying the same technique to create a horizon fog effect; it’s fog colored below a certain height in the world, with transparency above that level.
Step 2: Animated Fog Using Noise
To infuse the fog with vitality, incorporating noise is essential. However, calculating noise can be resource-heavy, especially with multiple materials in play. The alternative lies in utilizing a seamless noise texture:

Various approaches exist to introduce noise into the visual presentation of fog. Personally, I employed domain warping to craft an engaging fog surface and utilized the distance from the worldPosition to manipulate visual depth.
For simplicity, here’s a fundamental shader example that encapsulates this concept:
const fogOnBeforeCompile = (shader) => {
shader.vertexShader = shader.vertexShader.replace(
'void main() {',
`
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz;
vUv = uv;
`)
shader.fragmentShader = shader.fragmentShader.replace(
'void main() {',
`
uniform float fogPositionY;
uniform float fogSmoothness;
uniform sampler2D noiseTexture;
uniform float uTime;
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
`)
shader.fragmentShader = shader.fragmentShader.replace(
'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
`
vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
vec4 noiseColor = texture2D(noiseTexture, vec2(vWorldPosition.x * noiseFreq + uTime, vWorldPosition.z * noiseFreq + uTime));
float noise = noiseColor.r;
float verticalMixer = smoothstep(
vWorldPosition.y - fogSmoothness,
vWorldPosition.y + fogSmoothness,
fogPositionY + noise);
float mixer = clamp(verticalMixer, 0., 1.);
vec3 fogColor = vec3(1.);
outgoingLight = mix(outgoingLight, fogColor, mixer);
`);
shader.uniforms.fogPositionY = { value: fogSettings.height };
shader.uniforms.fogSmoothness = { value: fogSettings.smoothness };
shader.uniforms.uTime = 0;
shader.uniforms.noiseTexture = fogSettings.noiseTexture;
}
For an interactive glimpse, check out the demo below:
Creating Mesh Outlines

This technique, often known by various names such as shell outline or backface outline, is rooted in Blender practices:
Step 1: Establish an Outline Material
For a black outline appearance, a dedicated black material is necessary. Previously, using an RGB node translated into a MeshBasicMaterial was feasible in Three.js, but this is no longer accessible post-Blender version 5.0.0. Instead, we will use the Principled BSDF node, which Three.js will convert into a MeshStandardMaterial; this is acceptable since we can easily reassign it to a MeshBasicMaterial via JavaScript.
It's advisable to position the outline material in the last slot for simplicity in the next steps.
We also need to enable backface culling, which ensures that the backside of the material doesn’t render.
Step 2: Adding the Solidify Modifier
Next, we will insert a Solidify modifier with the following configurations: a negative thickness, flipped normals, and the appropriate material offset for the outline material. This can be a fixed number for simplicity, ensuring the modifier can be easily applied across various objects with different material slot counts.
When exporting to glTF, ensure you select the options to "apply modifiers" and "export materials".
Creating an Infinite City Effect
No surprises here; I utilized a single repeatable chunk within a dynamic grid system to sustain a 3x3 layout around the camera. By cloning tiles and repositioning them dynamically as the camera moves, the illusion of an infinite cityscape emerges, all while minimizing memory consumption.

Lighting Techniques
I find it essential to highlight my consistent use of the Three.js LightKit for lighting management across nearly all my projects.
This handy tool allows users to explore and test a wide array of HDRs, utilizing the entire Polyhaven library with ease. It also provides capabilities to experiment with various toneMapping and exposure settings.
Upon finalizing your favorite lighting setup, you can export it as a JSON file to conveniently load it as your default configuration.
This tool is compatible with both WebGL and WebGPU projects, as well as other implementations in vanilla JavaScript and React.
For a live demo, feel free to visit: Three.js LightKit.
In Conclusion
I hope this breakdown provided useful insights and sparked some inspiration for your own projects! 🫡