three.js glTF model too dark or black? Give its metal something to reflect

Why a glTF or GLB model exported from Blender draws dark or black in three.js however many lights are added, and how a coding agent finds the cause in the paprika3d plugin's look reply, measures the drawn colour before and after a change, and puts an HDRI environment in the project with asset_search.

Why more lights do not help

A glTF material is a physically based material: three.js loads it as a MeshStandardMaterial with a base colour, a roughness and a metalness. The metalness decides where its brightness comes from.

Blender's Principled BSDF exports its Metallic slider as the glTF metallicFactor, and a model textured for a game engine often has every part at metallic 1 with a metalness map to say which parts are not. In three.js a scene has nothing to reflect until scene.environment is set, so those parts draw black, and every forum answer that adds an AmbientLight or raises its intensity leaves them black. Blender's material preview and the common online glTF viewers light the model with an environment by default, which is why the same file looks right there.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. Every look reply ends with the setup faults it finds in the live scene, and one of them is this: metallic materials with no environment map. look with sample reads the colour drawn at a point, so a change can be judged by a number before it goes into the source, and asset_search with kind: "hdri" puts a CC0 sky in the project with the code that makes it the environment.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A dark toolbox

A toolbox as a Blender export brings it: a steel case and lid (metallic 1, roughness 0.35) and a red painted badge (metallic 0). The page lights it the way the forum threads do, with an ambient light and a directional key light. The GLB is small enough to inline as a data URL, so the block runs on its own.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Toolbox</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js","three/addons/":"./node_modules/three/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';

// In a project this is a file, 'models/toolbox.glb', as Blender exports it: a steel case and lid
// (metallic 1, roughness 0.35) and a red painted badge (metallic 0). Inlined so the page runs on its own.
const MODEL = 'data:model/gltf-binary;base64,Z2xURgIAAAC4BwAAFAUAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjUuMSIsInZlcnNpb24iOiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoyNCwibWF4IjpbMC41LDAuNSwwLjVdLCJtaW4iOlstMC41LC0wLjUsLTAuNV0sImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjB9LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6MjQsImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjEyfSx7InR5cGUiOiJTQ0FMQVIiLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MzYsImJ1ZmZlclZpZXciOjEsImJ5dGVPZmZzZXQiOjB9XSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZU9mZnNldCI6MCwiYnl0ZUxlbmd0aCI6NTc2LCJieXRlU3RyaWRlIjoyNCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjU3NiwiYnl0ZUxlbmd0aCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6NjQ4fV0sIm1hdGVyaWFscyI6W3sibmFtZSI6InN0ZWVsIiwicGJyTWV0YWxsaWNSb3VnaG5lc3MiOnsiYmFzZUNvbG9yRmFjdG9yIjpbMC44LDAuOCwwLjgyLDFdLCJyb3VnaG5lc3NGYWN0b3IiOjAuMzV9fSx7Im5hbWUiOiJwYWludCIsInBick1ldGFsbGljUm91Z2huZXNzIjp7ImJhc2VDb2xvckZhY3RvciI6WzAuNiwwLjA1LDAuMDMsMV0sInJvdWdobmVzc0ZhY3RvciI6MC42LCJtZXRhbGxpY0ZhY3RvciI6MH19XSwibWVzaGVzIjpbeyJuYW1lIjoiY2FzZSIsInByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjAsIk5PUk1BTCI6MX0sIm1vZGUiOjQsIm1hdGVyaWFsIjowLCJpbmRpY2VzIjoyfV19LHsibmFtZSI6ImJhZGdlIiwicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MCwiTk9STUFMIjoxfSwibW9kZSI6NCwibWF0ZXJpYWwiOjEsImluZGljZXMiOjJ9XX1dLCJub2RlcyI6W3sibmFtZSI6ImNhc2UiLCJ0cmFuc2xhdGlvbiI6WzAsMC41LDBdLCJzY2FsZSI6WzIsMSwxXSwibWVzaCI6MH0seyJuYW1lIjoibGlkIiwidHJhbnNsYXRpb24iOlswLDEuMSwwXSwic2NhbGUiOlsyLjA1LDAuMiwxLjA1XSwibWVzaCI6MH0seyJuYW1lIjoiYmFkZ2UiLCJ0cmFuc2xhdGlvbiI6WzAsMC41NSwwLjUxXSwic2NhbGUiOlswLjYsMC4zLDAuMDJdLCJtZXNoIjoxfSx7Im5hbWUiOiJ0b29sYm94IiwiY2hpbGRyZW4iOlswLDEsMl19XSwic2NlbmVzIjpbeyJuYW1lIjoiU2NlbmUiLCJub2RlcyI6WzNdfV19iAIAAEJJTgAAAAA/AAAAPwAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAPwAAAL8AAIA/AAAAAAAAAAAAAAA/AAAAvwAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAvwAAAL8AAIA/AAAAAAAAAAAAAAC/AAAAPwAAAL8AAIC/AAAAAAAAAAAAAAC/AAAAPwAAAD8AAIC/AAAAAAAAAAAAAAC/AAAAvwAAAL8AAIC/AAAAAAAAAAAAAAC/AAAAvwAAAD8AAIC/AAAAAAAAAAAAAAC/AAAAPwAAAL8AAAAAAACAPwAAAAAAAAA/AAAAPwAAAL8AAAAAAACAPwAAAAAAAAC/AAAAPwAAAD8AAAAAAACAPwAAAAAAAAA/AAAAPwAAAD8AAAAAAACAPwAAAAAAAAC/AAAAvwAAAD8AAAAAAACAvwAAAAAAAAA/AAAAvwAAAD8AAAAAAACAvwAAAAAAAAC/AAAAvwAAAL8AAAAAAACAvwAAAAAAAAA/AAAAvwAAAL8AAAAAAACAvwAAAAAAAAC/AAAAPwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAPwAAAD8AAAAAAAAAAAAAgD8AAAC/AAAAvwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAvwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAPwAAAL8AAAAAAAAAAAAAgL8AAAC/AAAAPwAAAL8AAAAAAAAAAAAAgL8AAAA/AAAAvwAAAL8AAAAAAAAAAAAAgL8AAAC/AAAAvwAAAL8AAAAAAAAAAAAAgL8AAAIAAQACAAMAAQAEAAYABQAGAAcABQAIAAoACQAKAAsACQAMAA4ADQAOAA8ADQAQABIAEQASABMAEQAUABYAFQAWABcAFQA=';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x3a3f47);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(2.2, 2, 4);
camera.lookAt(0, 0.6, 0);

// The lights the forum threads add, and keep adding, when the model is dark.
const ambient = new THREE.AmbientLight(0xffffff, 1);
ambient.name = 'ambient';
scene.add(ambient);
const key = new THREE.DirectionalLight(0xffffff, 2);
key.name = 'key';
key.position.set(3, 5, 4);
scene.add(key);

new GLTFLoader().load(MODEL, (gltf) => { gltf.scene.name = 'toolbox'; scene.add(gltf.scene); }, undefined, (err) => console.error('toolbox did not load:', err));

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, which is what the GPU: line says; the materials and the colours they draw do not depend on it. Run folder paths are shortened to <project>, and replies are cut to the lines this guide discusses, with nothing reworded.

1. Open the page and look, sampling the case, the lid and the badge.

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:46693/index.html in 0.2 s, paused at frame 0.
GPU: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). A software renderer (no GPU): frame times here say nothing of a real GPU's.
Page text outside the canvas: none

look {"sample": [[450, 450], [640, 262], [580, 445]]}

Picture: <project>/paprika3d/runs/2026-10-28T06-12-50-818Z/look-1.png
Pixels: brightness 21%, colour saturation 17%, 14% near black, 0% blown out.
Hues: grey 99% of the frame; over the objects (16.6% of it): grey 94%, red 6%.
Lighting: 1 metallic materials with no environment map (scene.environment unset): metal reflects nothing and draws black. Bake a RoomEnvironment through PMREMGenerator, or call stage() from fx.js.
On screen (3):
case 11% [379, 309, 905, 627]
lid 4.9% [367, 230, 916, 369] 2.05 × 1.05 units
badge 0.94% [513, 398, 645, 494] 0.6 × 0.3 units, across the view
Never visible: none
Colours (5 × 5 mean, in the page's pixels): (450, 450) #060606, (640, 262) #121212, (580, 445) #b72520.

The picture is a black box with a red badge. The Lighting: line names the cause and counts one material, since the case and the lid share steel. The samples put numbers on it: the case's front is #060606, close to pure black, while the painted badge, lit by the same two lights, is a plain red.

2. Read the materials. eval runs in the page:

eval {"code": "const out = [];\nscene.traverse((o) => { if (o.isMesh) out.push(`${o.name}: ${o.material.name}, metalness ${o.material.metalness}, roughness ${o.material.roughness}`); });\nreturn { environment: scene.environment, meshes: out }"}

{"environment":null,"meshes":["case: steel, metalness 1, roughness 0.35","lid: steel, metalness 1, roughness 0.35","badge: paint, metalness 0, roughness 0.6"]}

3. Try what the threads try: more light. The ambient light from 1 to 10 and the key light from 2 to 8:

eval {"code": "find('ambient').intensity = 10;\nfind('key').intensity = 8;\nreturn 'brighter lights'"}

brighter lights

look {"sample": [[450, 450], [640, 262], [580, 445]]}

Pixels: brightness 24%, colour saturation 16%, 0% near black, 0% blown out; 17% of the picture changed since the last look.
Lighting: the same line as the last look, still open.
Colours (5 × 5 mean, in the page's pixels): (450, 450) #222223, (640, 262) #424244, (580, 445) #ffb48d.

Ten times the ambient light took the steel from #060606 to #222223, still a dark grey, and burnt the red badge to #ffb48d, a pale peach. The little the case gained is the key light's highlight spread over a rough surface. This is the trade every "add more lights" answer makes: the non-metal parts wash out long before the metal looks like metal.

4. Give the metal an environment. The line's own suggestion: a RoomEnvironment, the room three.js ships for this, baked through PMREMGenerator into scene.environment, with the lights put back as they were.

eval {"code": "find('ambient').intensity = 1;\nfind('key').intensity = 2;\nconst { RoomEnvironment } = await import('three/addons/environments/RoomEnvironment.js');\nconst pmrem = new THREE.PMREMGenerator(renderer);\nscene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;\nreturn 'environment set'"}

environment set

interact {"steps": [{"wait": 1}]}

{"after":null,"changed":{}}

look {"sample": [[450, 450], [640, 262], [580, 445]]}

Pixels: brightness 31%, colour saturation 16%, 0% near black, 0% blown out.
On screen (3):
case 11% [379, 309, 905, 627]
lid 4.9% [367, 230, 916, 369] 2.05 × 1.05 units
badge 0.94% [513, 398, 645, 494] 0.6 × 0.3 units, across the view
Never visible: none
Colours (5 × 5 mean, in the page's pixels): (450, 450) #919193, (640, 262) #bbbbbd, (580, 445) #ff8f6e.

The Lighting: line is gone and the case reads #919193, a mid steel grey, with the original lights. The interact step with wait lets the page draw one frame of its own after the bake. Without it, the look pictured the canvas as it was and reported that the last render calls went into a render target: the bake inside the eval was the last thing drawn.

A RoomEnvironment is a neutral grey room. For a product that should reflect a studio, a sky or an interior, asset_search with kind: "hdri" searches the hosted library's HDRIs:

asset_search {"cwd": "<project>", "query": "photo studio softbox", "kind": "hdri", "sheet": false}

1. polyhaven-studio_small_08 · Studio Small 08 · umbrella, softbox, infinity, lamp, studio, photo · CC0-1.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/hdri/polyhaven-studio_small_08/preview.webp
2. polyhaven-wooden_studio_19 · Wooden Studio 19 · black floor, lamp, softbox, soft, white, rectangle, photo · CC0-1.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/hdri/polyhaven-wooden_studio_19/preview.webp
3. polyhaven-white_home_studio · White Home Studio · lamp, white, photo, softbox, umbrella, rectangle, infinity · CC0-1.0 · https://pub-da56e9d607b24519a815698ff4e9020f.r2.dev/hdri/polyhaven-white_home_studio/preview.webp
Pick one with { pick: "<id>" }, search again with not: [ids] to reject these, or add more: true for the next ones.

asset_search {"cwd": "<project>", "kind": "hdri", "pick": "polyhaven-studio_small_08"}

Saved assets/hdri/polyhaven-studio_small_08.hdr (Studio Small 08, CC0-1.0)
If the import map has no `three/addons/`, map it to three's `examples/jsm/` folder.

The pick replies with the loading code as well:

import { HDRLoader } from "three/addons/loaders/HDRLoader.js";

{
  const pmrem = new THREE.PMREMGenerator(renderer);
  const sky = await new HDRLoader().loadAsync("assets/hdri/polyhaven-studio_small_08.hdr");
  scene.environment = pmrem.fromEquirectangular(sky).texture;
  sky.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = sky; // the sky as backdrop; leave this line out to keep your own
  pmrem.dispose();
}

Here the page keeps its own grey backdrop, so the block goes into the source after the camera without its two scene.background lines, and the HDRLoader import goes at the top. The next look after the edit reloads the page and says what the edit closed:

look {"sample": [[450, 450], [640, 262], [580, 445]]}

Reloaded (sources changed), ran on the new code from frame 0 (ready in 0.2 s).
Picture: <project>/paprika3d/runs/2026-10-28T06-13-57-652Z/look-2.png
Pixels: brightness 30%, colour saturation 17%, 0% near black, 0% blown out; 18% of the picture changed since the last look.
Closed since the last look: Lighting: 1 metallic materials with no environment map (scene.environment unset): metal reflects nothing and draws black. Bake a RoomEnvironment through PMREMGenerator, or call stage() from fx.js.
Colours (5 × 5 mean, in the page's pixels): (450, 450) #858b8f, (640, 262) #ccccd2, (580, 445) #f9785f.

The lid now picks up the studio's bright ceiling, and the case front shades from light to dark across the softboxes' reflections.

The ambient light is no longer doing anything for the metal. With the environment in place, removing the AmbientLight leaves the steel exactly as it was and takes a little off the badge:

eval {"code": "scene.remove(find('ambient'));\nreturn scene.children.filter((o) => o.isLight).map((o) => `${o.name} ${o.intensity}`)"}

["key 2"]

look {"sample": [[450, 450], [640, 262], [580, 445]]}

Colours (5 × 5 mean, in the page's pixels): (450, 450) #858b8f, (640, 262) #ccccd2, (580, 445) #f16b56.

The environment lights the non-metal parts too, so a scene that gets one usually needs less of the light added to fight the darkness, and often none.

Other reasons a model draws dark

The metal line is the cause when the parts that are dark are the metal ones. When everything is dark, check these, which open and look also report:

Pitfalls

Without paprika3d

scene.traverse over the meshes, logging each material.metalness and scene.environment, gives the same readout as the eval above. three.js ships RoomEnvironment in three/addons/environments/ for a neutral room, and Poly Haven publishes CC0 HDRIs that HDRLoader reads. To compare with how a glTF viewer shows the file, the three.js editor and the Khronos glTF Sample Viewer both load a GLB by drag and drop and light it with an environment.