What you will build
A dark, glossy 3D object in three.js that actually renders dark. You will learn to find out why near-black text or objects come out mid-grey, test one cause at a time, and apply the two fixes that solved it on the dainer.ai hero: an environment map set on each material, and a key light moved away from the camera. You finish with a material and light setup that keeps flat faces at their true colour and puts highlights only on edges and bevels.
Before you start
- A three.js scene with
MeshStandardMaterialorMeshPhysicalMaterialand an environment map, usually fromPMREMGeneratorandRoomEnvironment. The fix below was found on three 0.185. - A way to switch settings quickly while the page runs, such as the browser console or a debug flag.
- A screenshot tool and Python with Pillow (
python3 -m pip install pillow) to read pixel colours from a screenshot. - About 30 minutes for the diagnosis, and 5 minutes for the fix once you know the cause.
How it works
With physically based materials, what you see on a surface is the sum of a few parts. The base colour reflects light diffusely. On top of that, every surface also reflects its surroundings like a very faint mirror, more sharply when it is smooth. A clearcoat adds a second glossy layer above the base. When the base colour is near black, the diffuse part is tiny, so almost everything you see is reflection. If the reflection is strong, or if a light sits where its reflection lands on the faces you look at, black turns grey.
So the job is not to make the colour darker. The job is to control the reflections.
- Reproduce the problem on one object and sample the actual pixel colour.
- Switch off one source of light at a time: the environment, the key light, the clearcoat.
- Find where the environment comes from:
scene.environmentor the material's ownenvMap. - Put the environment on the material so its strength setting works.
- Move the key light off the camera axis.
- Give flat faces and edges different materials and tune them per theme.
- Check tone mapping, then verify with screenshots in both themes.
Step 1: Reproduce it and measure it
Start with the symptom written down as numbers, not impressions. On the hero, near-black letters rendered as a soft mid-grey with a bright band across the middle, and lowering envMapIntensity changed nothing.
Take a screenshot and read a pixel from the middle of a flat face:
from PIL import Image
im = Image.open("hero-light-1440.png").convert("RGB")
print(im.getpixel((412, 260))) # a point inside a flat letter faceWrite the result next to the colour you asked for. If a face painted #161715 (RGB 22, 23, 21) samples at, say, 120, that is not a small tuning issue. Something is adding a lot of light.
Check
- You have one screenshot and one number for the problem.
- You know your three.js version:
console.log(THREE.REVISION).
Step 2: Switch off one light source at a time
Expose the scene objects in development so you can change them from the console, then remove one contributor per test. In three.js 0.185 the renderer notices a changed envMap and switches the shader on the next render by itself.
// development only
window.debug = { scene, face, side, key, renderer };
// Test A: no scene environment
debug.scene.environment = null;
// Test B: no environment on the material
debug.face.envMap = null;
// Test C: no key light
debug.key.intensity = 0;
// Test D: no clearcoat
debug.face.clearcoat = 0;Render a frame after each change (or let the loop run), take a screenshot, sample the same pixel, and restore the setting before the next test. The test that makes the biggest drop is your main cause. On the hero, the two causes turned out to be the environment strength and the key light position.
Check
- You have a short table in your notes: test, sampled value.
- Only one setting changed per test.
Step 3: Find where the environment comes from
There are two ways to give materials an environment map, and they have different strength controls.
// Way 1: one environment for all physical materials in the scene
scene.environment = envTex;
scene.environmentIntensity = 0.3;
// Way 2: an environment on each material
material.envMap = envTex;
material.envMapIntensity = 0.3;The three.js docs say Scene.environment sets the environment map for all physical materials, but cannot override a texture already assigned to a material's envMap. They also say Scene.environmentIntensity only affects the map assigned to Scene.environment.
The part that cost us time is the reverse. In three.js 0.185, the WebGL renderer only sends material.envMapIntensity to the shader inside a check for material.envMap. You can see it in src/renderers/webgl/WebGLMaterials.js:
if ( material.envMap ) {
uniforms.envMapIntensity.value = material.envMapIntensity;
}So if your environment comes only from scene.environment, lowering material.envMapIntensity has no effect. That was exactly our symptom.
Check
- Search your code for
scene.environmentandenvMap. Write down which one each material uses. - If you set
envMapIntensitybut only assignscene.environment, you have found the first cause.
Step 4: Put the environment on each material
Pick one of the two ways and use its matching control. On the hero we set the map per material, because the letter caps and the letter sides need different strengths.
const pmrem = new THREE.PMREMGenerator(renderer);
const envTex = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
// envMap is set per material: envMapIntensity is ignored for scene.environment.
const face = new THREE.MeshPhysicalMaterial({ envMap: envTex, roughness: 0.32, metalness: 0.02, clearcoat: 1, clearcoatRoughness: 0.14 });
const side = new THREE.MeshPhysicalMaterial({ envMap: envTex, roughness: 0.22, metalness: 0.35, clearcoat: 1, clearcoatRoughness: 0.08 });PMREMGenerator.fromScene(scene, sigma) bakes the room into a pre-filtered map so rough surfaces get blurred reflections. The second argument is a blur radius in radians. The docs note that MeshPhysicalMaterial works best with an environment map specified, so keep one, just at the right strength.
If you prefer one scene-wide environment, keep scene.environment and lower scene.environmentIntensity instead. Do not mix the two ways on the same material and expect both controls to apply.
Check
- Lower
face.envMapIntensityto 0.025 in the console. The flat faces get visibly darker. If nothing changes, the material still has noenvMap. - Sample the pixel again and note the new value.
Step 5: Move the key light away from the camera
A directional light near the camera lights the scene from the viewer's side. On a glossy surface, the highlight appears where the reflection of the light meets your eye. For flat faces that face the camera, a light from the camera direction puts that highlight on every face at once, which reads as a grey sheen across the whole word.
Place the key light high and to one side, and aim it at the object:
const key = new THREE.DirectionalLight(0xffffff, 1.2);
scene.add(key, key.target);
// cx, cy: centre of the word. W, H: hero size in px. fs: font size in px.
key.position.set(cx - W * 0.6, cy + H * 1.6, fs * 2.5);
key.target.position.set(cx, cy, 0);Add key.target to the scene: the three.js source notes that the target must be in the scene for a position other than the default to take effect. Now the flat faces reflect the light away from the viewer and keep their colour, while the bevels and top edges, which face up and to the side, catch the light. That is what makes the letters look solid rather than painted.
Check
- Flat faces stay close to your ink colour; bevels show clear highlights.
- Move the light around the word in the console. You can see the sheen move onto the faces whenever the light comes near the camera.
Step 6: Separate faces from edges and tune per theme
ExtrudeGeometry has two material groups: the caps (front and back faces) and the side walls including the bevel. Give it two materials, new THREE.Mesh(geo, [face, side]), and let each do one job. The caps show the colour. The sides carry the gloss.
On the hero, light mode wants solid ink and dark mode wants shiny white letters, so one paint() function sets both materials from the current theme:
const paint = () => {
const dark = document.documentElement.dataset.theme === "dark";
face.color.set(dark ? "#f4f2ec" : "#161715");
side.color.set(dark ? "#d9d6cd" : "#3a3d39");
face.envMapIntensity = dark ? 0.9 : 0.025; // light mode: near-matte ink faces
face.roughness = dark ? 0.32 : 0.55;
face.clearcoat = dark ? 1 : 0.2;
side.envMapIntensity = dark ? 1.1 : 0.9;
key.intensity = dark ? 1.2 : 0.9;
};In light mode the caps are almost matte with a weak environment and little clearcoat, so they read as ink. The sides stay brighter and slightly metallic, so the shape still reads as 3D. Call paint() again whenever the theme changes, and render a frame even if the animation is paused.
Check
- Light mode: sampled cap pixels sit near your ink value.
- Dark mode: caps are off-white and the edges still sparkle.
- Toggle the theme while motion is paused. The still frame updates.
Step 7: Check tone mapping and moving lights
The hero uses renderer.toneMapping = THREE.ACESFilmicToneMapping, toneMappingExposure = 1.0 and outputColorSpace = THREE.SRGBColorSpace. Tone mapping compresses bright values, so it can make a strong reflection look softer and wider than you expect. To see the raw result for a moment, set renderer.toneMapping = THREE.NoToneMapping and compare. Keep whichever looks right on both themes, but test lights and materials with the final tone mapping on.
Moving lights need the same care. The hero has a PointLight that sweeps across the word to make a glint travel along the bevels. In light mode it washed the ink towards silver, so its gain is set to zero in light mode and only runs in dark mode. It also sits close to the letters' plane and just above them, so it grazes the edges instead of lighting the faces head on.
Check
- Capture light and dark mode at phone and desktop sizes. Both look right with the final tone mapping.
- Watch a full animation cycle. No moment turns the flat faces grey.
Gotchas we hit
- Lowering envMapIntensity did nothing. The environment came only from
scene.environment, and three.js 0.185 only appliesmaterial.envMapIntensitywhen the material has its ownenvMap. - The key light was near the camera. Every flat face showed the light's reflection at once. Moving it high and to the side fixed most of the grey.
- Light mode needed near-matte caps. Even after both fixes, glossy caps looked lighter than the ink beside them. Light mode caps use roughness 0.55, clearcoat 0.2 and an environment strength of 0.025, while the sides keep their gloss.
- A decorative sweep light washed out light mode. It pushed the ink towards silver, so it now runs in dark mode only.
Take it further
- Make the fix permanent in code review: search for
envMapIntensityand check each material also setsenvMap. - Try a darker or lighter environment scene. The room environment is a general studio light; a custom scene with fewer bright panels gives darker reflections.
- Read Brief: turn your logo text into floating 3D letters to see this material setup in the full hero.
- Read Brief: a Rubik's cube that scrambles and solves itself in three.js, which uses the same per-material environment on glossy stickers.
- Read AI-assisted visual QA to capture both themes automatically after every lighting change.
Quick checklist
- Measure the problem with a sampled pixel value, not by eye.
- Test one light source at a time: environment, key light, clearcoat.
- Know whether each material uses
scene.environmentor its ownenvMap. - Use
material.envMapIntensityonly withmaterial.envMap; usescene.environmentIntensitywithscene.environment. - Place the key light high and to the side, and add its target to the scene.
- Use separate materials for caps and sides of extruded shapes.
- Repaint on theme change, even while paused.
- Verify light and dark captures with the final tone mapping on.