Diagnosing three.js Color Management: Washed-Out, Dark, or Inconsistent Canvas Output
A diagnostic guide for three.js WebGLRenderer color management: isolate output color space, texture color space, tone mapping, and post-processing issues that make canvas colors look wrong.
30 Jul 2026, 21:10 UTC

Recognizable condition
You render a three.js scene and the canvas does not match your design comps or an older build. Mid-gray albedo looks washed out, an unlit flat color appears too dark, or colors shift only after adding post-processing. Sometimes a canvas screenshot looks correct on screen but the captured image is blank or wrong. These symptoms usually point to a mismatch between texture color spaces, the renderer's output color space, tone mapping, or an incomplete EffectComposer chain.
Cause and diagnostic table
| Condition | Likely cause | Diagnostic signal |
|---|---|---|
| Albedo textures look washed out or too bright | Color texture .colorSpace is not SRGBColorSpace | Log map.colorSpace for the albedo map; it should be SRGBColorSpace |
| Unlit swatch or flat color looks dark or flat | renderer.outputColorSpace is not SRGBColorSpace, or ColorManagement.enabled is false | Log renderer.outputColorSpace and THREE.ColorManagement.enabled |
| Raw render is correct, but post-processed output is wrong | Missing OutputPass, wrong composer render target type/color space, or tone mapping applied twice | Render with and without EffectComposer; inspect the pass array |
| Global hue or brightness shift | renderer.toneMapping or toneMappingExposure changed | Compare NoToneMapping against the current setting |
Ordered checks
- Reproduce with a known swatch. Create a full-viewport quad or small plane with
MeshBasicMaterialand color#808080. This isolates color management from lighting and texture complexity. - Log renderer state. In the browser console (no special permissions beyond devtools access), after renderer creation, run:
Expected:console.log(renderer.outputColorSpace); console.log(THREE.ColorManagement.enabled); console.log(renderer.toneMapping, renderer.toneMappingExposure);outputColorSpaceis"srgb"(orSRGBColorSpace),ColorManagement.enabledistrue. If not, note the values. - Log texture color spaces. For one albedo map and one normal map:
Expected: albedo isconsole.log(albedoMap.colorSpace, normalMap.colorSpace);SRGBColorSpace; normal isNoColorSpace. - Toggle output color space. Temporarily set
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;and re-render. The swatch should visibly change. If it does not, the control path is broken or overridden. - Render with and without post-processing. Capture the canvas immediately after render in the same frame using
renderer.domElement.toDataURL()orreadRenderTargetPixels. Compare the two images. Risk: withoutpreserveDrawingBuffer: true,toDataURL()may return a blank image unless called synchronously afterrender(). - Check the composer chain. If using
EffectComposer, logcomposer.passesand verify the final pass isOutputPass. Also log the composer's render target texturecolorSpaceandtype. - Cross-browser check. If possible, open the same page in a second browser or GPU. Note whether the symptom persists.
Fixes tied to findings
Canvas output is dark or flat
If renderer.outputColorSpace is not SRGBColorSpace, set it explicitly:
renderer.outputColorSpace = THREE.SRGBColorSpace;
Keep THREE.ColorManagement.enabled = true. Disabling it to match an old screenshot changes texture decoding and lighting math and is not a fix.
Albedo textures look wrong
For color maps (albedo, emissive, UI textures), set:
texture.colorSpace = THREE.SRGBColorSpace;
For data maps (normal, roughness, metalness, AO, height), leave NoColorSpace (the default for non-color data). Applying sRGB conversion to a normal map corrupts the vectors.
Post-processed output differs from raw render
When using EffectComposer, use a HalfFloatType render target where supported and add OutputPass as the final pass:
const composer = new EffectComposer(renderer);
composer.renderTarget1.texture.type = THREE.HalfFloatType;
composer.renderTarget2.texture.type = THREE.HalfFloatType;
composer.addPass(renderPass);
composer.addPass(new OutputPass());
Do not also add a manual tone-mapping ShaderPass. OutputPass applies tone mapping and output color space conversion once. Verify that intermediate render targets do not force an unexpected color space. If HalfFloatType is unsupported on the target platform, fall back to the default type and re-test.
Global hue or brightness shift
Set renderer.toneMapping deliberately (for example, THREE.ACESFilmicToneMapping) and adjust renderer.toneMappingExposure rather than changing texture color spaces. Compare against THREE.NoToneMapping to confirm the source of the shift.
Escalation criteria
Escalate to a minimal reproduction or three.js issue tracker when:
- The swatch is correct without post-processing but wrong only through
EffectComposeror a customShaderMaterial. - Canvas capture and
readRenderTargetPixelsdisagree. - The issue is isolated to one GPU, driver, or browser.
- WebGL2, float/HalfFloat render targets, or canvas capture fail.
- Custom shaders sample sRGB textures manually without converting to linear space.
When escalating, include a minimal repro, the installed three.js version/build, renderer.capabilities, GPU/browser details, and the logged values from the checks above.
Limitations and verification
This guide assumes a standard WebGLRenderer with MeshBasicMaterial or MeshStandardMaterial and built-in materials. It does not cover node materials, XR sessions, or offscreen workers. Color-management defaults changed across three.js releases, so confirm behavior against your installed build rather than assuming r150-era defaults.
To verify a fix, render the #808080 swatch with MeshBasicMaterial after setting renderer.outputColorSpace = THREE.SRGBColorSpace. Capture the canvas in the same frame using preserveDrawingBuffer: true or by calling toDataURL() immediately after render(). The captured mid-gray should match the expected sRGB value. Toggle outputColorSpace between SRGBColorSpace and LinearSRGBColorSpace; the swatch should change, confirming the control path is active.