Lighting math must run on linear values (sRGB), while CSS colors and image files are sRGB. Since r152 Three.js 30,815 converts for you: THREE.ColorManagement is enabled, the working space is linear sRGB, and renderer.outputColorSpace encodes back to sRGB at the end. A palette color goes in and comes back unchanged:
import * as THREE from 'three';
const blue = new THREE.Color('#1f5f8b'); // CSS colors are sRGB
const rgb = (color) => color.toArray().map((c) => +c.toFixed(3));
console.log(THREE.ColorManagement.workingColorSpace, rgb(blue), blue.getHexString());
THREE.ColorManagement.enabled = false; // the pre-r152 behavior
console.log('unmanaged', rgb(new THREE.Color('#1f5f8b')));
console.log(new THREE.Texture().colorSpace === THREE.NoColorSpace);Output
srgb-linear [ 0.014, 0.114, 0.258 ] 1f5f8b unmanaged [ 0.122, 0.373, 0.545 ] true
The last line is the trap: a new texture has NoColorSpace, so set texture.colorSpace = THREE.SRGBColorSpace on color maps you load yourself, and leave data maps (normal, roughness) alone; GLTFLoader does both (Texture Types and Color Space). Mistakes give washed-out or dark colors, never an error.
<!doctype html>
<style>
body { margin: 0; padding: 8px; background: #f5f1e8; font: 12px system-ui, sans-serif; color: #263238; }
.row { display: flex; gap: 8px; flex-wrap: wrap; }
figure { margin: 0; width: 140px; text-align: center; }
.swatch, canvas { width: 140px; height: 110px; display: block; }
</style>
<script type="importmap">
{ "imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/" } }
</script>
<div class="row" id="row">
<figure><div class="swatch" style="background:#1f5f8b"></div>CSS #1f5f8b</figure>
</div>
<p id="out"></p>
<script type="module">
import * as THREE from 'three';
const row = document.getElementById('row');
const rgb = (color) => color.toArray().map((c) => +c.toFixed(3)).join(', ');
// Render one unlit plane per panel; material and texture set by the caller
function panel(caption, material) {
const renderer = new THREE.WebGLRenderer();
renderer.setSize(140, 110);
const figure = row.appendChild(document.createElement('figure'));
figure.append(renderer.domElement, caption);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 2);
camera.position.z = 1;
scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material));
renderer.render(scene, camera);
}
// 1. Managed (the default since r152): the CSS color comes back out unchanged
const managed = new THREE.Color('#1f5f8b');
panel('managed Color', new THREE.MeshBasicMaterial({ color: managed }));
// 2. Unmanaged: the sRGB numbers are treated as linear, then encoded again: too light
THREE.ColorManagement.enabled = false;
const unmanaged = new THREE.Color('#1f5f8b');
THREE.ColorManagement.enabled = true;
panel('ColorManagement off', new THREE.MeshBasicMaterial({ color: unmanaged }));
// 3 and 4. A canvas texture painted with the same color, with and without SRGBColorSpace
function paintedTexture(colorSpace) {
const canvas = Object.assign(document.createElement('canvas'), { width: 64, height: 64 });
const g = canvas.getContext('2d');
g.fillStyle = '#1f5f8b'; g.fillRect(0, 0, 64, 64);
g.fillStyle = '#e09a10'; g.fillRect(0, 40, 64, 24);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = colorSpace;
return texture;
}
panel('texture: NoColorSpace', new THREE.MeshBasicMaterial({ map: paintedTexture(THREE.NoColorSpace) }));
panel('texture: SRGBColorSpace', new THREE.MeshBasicMaterial({ map: paintedTexture(THREE.SRGBColorSpace) }));
document.getElementById('out').textContent =
`working space ${THREE.ColorManagement.workingColorSpace}: managed linear [${rgb(managed)}], ` +
`unmanaged [${rgb(unmanaged)}]. A new Texture has NoColorSpace: ` +
`${new THREE.Texture().colorSpace === THREE.NoColorSpace}, so color maps look washed out until you set SRGBColorSpace.`;
</script>