Your textures look grey and faded, your brand red renders as salmon, or the whole scene is too dark next to the same model in Blender. When Three.js colors look washed out, too dark or just wrong, the cause is almost always colour management: the colour space of a texture, the renderer output, tone mapping, or light intensity. This guide covers the fix for current Three.js (r152 and later) and explains why each setting matters.
Quick fix
The most common cause is a colour texture loaded by hand without its colour space set. Make sure your setup looks like this:
import * as THREE from 'three';
THREE.ColorManagement.enabled = true; // default since r152
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace; // default since r152
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
const loader = new THREE.TextureLoader();
const baseColor = loader.load('/textures/wood_basecolor.jpg');
baseColor.colorSpace = THREE.SRGBColorSpace; // colour maps only
const normal = loader.load('/textures/wood_normal.jpg');
// leave normal, roughness, metalness and AO maps at the default (NoColorSpace)
const material = new THREE.MeshStandardMaterial({ map: baseColor, normalMap: normal });
If you are on an older tutorial: older versions used renderer.outputEncoding = THREE.sRGBEncoding and texture.encoding = THREE.sRGBEncoding. Those properties have been removed. Use outputColorSpace and colorSpace instead.
Cause 1: colour textures without SRGBColorSpace
Image files such as JPGs and PNGs store colour in sRGB. Three.js does its lighting in a linear colour space, so it has to know which textures need converting. A texture’s colorSpace defaults to THREE.NoColorSpace, which means “use the values as they are”.
If you load a base colour map with TextureLoader and don’t set colorSpace, the sRGB values are treated as linear. They are then encoded to sRGB again on output, so they come out lighter and less saturated. That’s the classic washed-out look.
The rule:
- Set
SRGBColorSpaceon:map,emissiveMap,sheenColorMap,specularColorMap, and textures you show directly, such as video textures and background images. - Leave at the default (
NoColorSpace) on:normalMap,roughnessMap,metalnessMap,aoMap,displacementMap,alphaMapand any other data texture. Marking these as sRGB distorts their values and gives you wrong shading, for example bumpy normals or overly shiny surfaces.
GLTFLoader sets colour spaces correctly for you. In react-three-fiber, drei’s useTexture returns plain textures, so set colorSpace yourself on the colour maps.
Cause 2: the wrong tone mapping, or none at all
Tone mapping compresses bright lighting into the range a screen can display. Vanilla WebGLRenderer defaults to NoToneMapping, so highlights clip to flat white. react-three-fiber’s Canvas defaults to ACESFilmicToneMapping, so the same scene looks different in each.
| Tone mapping | Good for | Watch out for |
|---|---|---|
ACESFilmicToneMapping |
Cinematic, high-contrast scenes | Desaturates and shifts very bright, saturated colours |
AgXToneMapping |
Bright lighting where you want smooth highlight roll-off | A softer, lower-contrast look by default |
NeutralToneMapping |
Product configurators where base colours must match brand values | Less “filmic” contrast |
NoToneMapping |
Unlit UI, flat illustrations | Harsh clipping in lit scenes |
For e-commerce and configurators I default to NeutralToneMapping. It was designed (as Khronos PBR Neutral) to keep base colours close to their sRGB values under neutral lighting. That’s exactly what a client checking a fabric swatch against the product page expects. Adjust overall brightness with renderer.toneMappingExposure, not by cranking up lights.
In react-three-fiber, set it on the canvas:
import * as THREE from 'three';
import { Canvas } from '@react-three/fiber';
<Canvas gl={{ toneMapping: THREE.NeutralToneMapping, toneMappingExposure: 1 }}>
{/* scene */}
</Canvas>
// <Canvas flat> disables tone mapping entirely
For elements that should keep their exact colour, such as UI planes or logos, set material.toneMapped = false.
Cause 3: lights that no longer match after the r155 lighting change
From r155, physically correct lighting is the default (renderer.useLegacyLights was deprecated and then removed in r165). Light intensities now use physical units, and point and spot lights use decay = 2 by default, so their intensity falls off realistically with distance. A scene tuned before r155 can look much darker after an upgrade, or blown out if you over-correct.
- For
AmbientLight,HemisphereLightandDirectionalLight, multiplying the old intensity byMath.PIgives roughly the old look. PointLightandSpotLightintensities are in candela and depend on your scene’s scale. Model in metres and expect to raise these values a lot.- Don’t fix dark lighting by brightening textures. Fix the lights or the exposure.
Cause 4: no environment map
PBR materials get much of their look from reflections. Without scene.environment, metals look black or muddy and colours look flat, however many lights you add. Use a prefiltered environment:
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 1; // r163+, or material.envMapIntensity per material
An HDRI studio lightmap gives more realistic product shots. In react-three-fiber, drei’s <Environment preset="studio" /> does the PMREM step for you.
Cause 5: post-processing without an output pass
With EffectComposer, the scene renders into intermediate render targets in linear space. Tone mapping and the sRGB conversion only happen when something writes to the screen. If your pass chain ends with a RenderPass or an effect pass, the final image skips both steps and usually looks dark and harsh. Add OutputPass last:
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.4, 0.4, 0.9));
composer.addPass(new OutputPass()); // tone mapping + sRGB conversion
Older examples used ShaderPass(GammaCorrectionShader) for this. OutputPass replaces it and respects your renderer’s tone mapping settings. The postprocessing library used by @react-three/postprocessing handles this in its own effect pass, so don’t add a second conversion there.
Less obvious causes
- Custom shaders skip conversion. A
ShaderMaterialoutputs raw values. Add#include <tonemapping_fragment>and#include <colorspace_fragment>at the end of the fragment shader so it matches built-in materials.RawShaderMaterialneeds this done by hand. - Hex colours in code. With
ColorManagementenabled,new THREE.Color('#e63946')is treated as sRGB and converted to linear internally, so it matches CSS. If you turned colour management off to “fix” something, every hex value in your app is now interpreted differently. - Vertex colours. glTF vertex colours are linear. If you write raw sRGB values (say, 0–255 divided by 255) into a colour attribute, they come out washed out. Instead, set a
THREE.Colorfrom a hex or CSS string and copy itsr,gandb, which are already linear when colour management is on. - Double conversion. Setting
colorSpaceon a texture that was already converted in an image editor, or tone mapping a second time in post-processing, makes colours drift.
Step-by-step diagnosis
- Log
renderer.outputColorSpace,renderer.toneMappingandTHREE.ColorManagement.enabled. Compare them with the quick fix above. - Traverse the scene and log each material’s
map?.colorSpaceandnormalMap?.colorSpace. - Temporarily set
renderer.toneMapping = THREE.NoToneMapping. If the colours suddenly match your reference, the tone mapping operator is the issue. - Compare against the same glTF in a reference viewer such as the three.js editor or the Khronos glTF Sample Viewer.
- If you use post-processing, disable the composer and render directly. If the colours come back, check the output pass.
scene.traverse((obj) => {
if (!obj.isMesh) return;
const m = obj.material;
console.log(obj.name, {
map: m.map?.colorSpace,
emissiveMap: m.emissiveMap?.colorSpace,
normalMap: m.normalMap?.colorSpace,
roughnessMap: m.roughnessMap?.colorSpace,
});
});
Prevention
- Put renderer, tone mapping and environment setup in one shared module so every scene starts from the same baseline.
- Write a small texture-loading helper that takes a “colour” or “data” flag and sets
colorSpacefor you. - Prefer glTF over loose textures. The loader gets colour spaces right.
- Re-check lighting whenever you upgrade Three.js across a release that changes lighting or colour management.
Key takeaways
- Set
texture.colorSpace = THREE.SRGBColorSpaceon colour maps only, never on normal, roughness or other data maps. - Keep
renderer.outputColorSpace = THREE.SRGBColorSpaceand colour management enabled. - Pick tone mapping on purpose. Neutral is the safest choice for accurate product colours.
- Re-tune lights for physical intensities after r155, and always provide an environment map.
- End every
EffectComposerchain withOutputPass.
Frequently asked questions
Why are my Three.js textures washed out?
The colour texture is most likely missing texture.colorSpace = THREE.SRGBColorSpace, so its sRGB values are treated as linear and brightened again on output. Set it on colour maps such as map and emissiveMap, but not on normal or roughness maps.
What replaced sRGBEncoding in Three.js?
renderer.outputEncoding and texture.encoding were replaced by renderer.outputColorSpace and texture.colorSpace. The constant THREE.sRGBEncoding became THREE.SRGBColorSpace.
Which tone mapping should I use in Three.js?
Use NeutralToneMapping when colours must match real products or brand values, AgXToneMapping for bright scenes with smooth highlights, and ACESFilmicToneMapping for a cinematic look. Adjust brightness with toneMappingExposure.
Why did my scene get darker after updating Three.js?
Since r155, lights use physically based intensities and point and spot lights decay with distance by default. Raise your light intensities (roughly multiply ambient, hemisphere and directional lights by Math.PI) and add an environment map.
Getting colours to match a real product is one of the fiddliest parts of building a 3D configurator, and it’s work I do every week. If your renders still don’t look right, get in touch. If the model doesn’t appear at all, start with fixing a Three.js model that isn’t showing.