← All articles

Use a lightmap in Three.js

Updated on . Tested with three.js r186, R3F 9 and drei 10.

Sometimes a three.js scene looks flat. There are no soft shadows, no light bounces off the walls and the corners are as bright as the rest of the room. A lightmap fixes this. It is a texture with the scene's light baked into it, so the scene looks lit by real light and still runs smoothly on a phone.

The same room corner twice. Without a lightmap the floor is flat and dark. With the lightmap, sunlight from the window falls across the floor and the orange ball casts a soft shadow

To add a lightmap to your scene, do the following.

  1. Set the texture as material.lightMap.
  2. Set texture.channel = 1 so it uses the mesh's second UV set, uv1.
  3. Set material.needsUpdate = true.
  4. If the lightmap was baked as the brightness of a white surface, set lightMapIntensity to Math.PI.
  5. Switch off the lamps whose light is already in the lightmap, or the scene is lit twice.

Add a lightmap to a mesh

Set the texture as the material's lightMap, point it at the second UV set and tell three.js the material changed.

function applyLightmap(mesh: THREE.Mesh, lightMap: THREE.Texture) {
  const material = mesh.material as THREE.MeshStandardMaterial;
  lightMap.channel = 1; // read the second UV set, uv1
  material.lightMap = lightMap;
  material.lightMapIntensity = Math.PI;
  material.needsUpdate = true; // three.js only notices a new map after this
}

async function loadLightmap(url: string): Promise<THREE.Texture> {
  const lightMap = await new THREE.TextureLoader().loadAsync(url);
  lightMap.flipY = false; // for a model from GLTFLoader, whose UVs start at the top left
  lightMap.colorSpace = THREE.SRGBColorSpace; // a PNG or JPEG; leave an EXR or HDR as it loads
  lightMap.generateMipmaps = false;
  lightMap.minFilter = THREE.LinearFilter;
  return lightMap;
}

A lightmap usually covers several meshes at once, each in its own part of the image. Load it once and give it to every mesh that was baked into it.

const lightMap = await loadLightmap('/lightmaps/scene.png');
const bakedMeshes = [floor, backWall, sideWall, windowWall, ball, box];

for (const mesh of bakedMeshes) {
  applyLightmap(mesh, lightMap);
}

The lightmap contains only light and shadow. Three.js multiplies it by the material's color and map, so your textures stay as sharp as they were while the light comes from the bake. It works on MeshStandardMaterial, MeshPhysicalMaterial, MeshLambertMaterial, MeshPhongMaterial and MeshBasicMaterial, which has no other lighting at all.

Mipmaps are off because a lightmap is pieces of many meshes packed side by side, and a mipmap averages neighboring pieces into each other.

The lightmap lands in the wrong place

A lightmap needs a UV set where each face of the mesh has its own area of the image. Your color texture's UVs usually do not work for this, because they often reuse one patch of texture on many faces. A model made for baking has a second UV set for the lightmap. GLTFLoader loads glTF's TEXCOORD_1 as the attribute uv1, and setting channel = 1 tells the lightmap to use it.

When two faces share an area, they get the same light. BoxGeometry gives each of its six faces the whole square from 0 to 1, so a bake that uses those UVs writes all six faces onto the same pixels. Here the side of the plinth that faces away from the window caught the same band of sunlight as the side that faces it.

After a bake that used the box's own UVs, the floor and the ball are right, but both visible sides of the plinth show the same diagonal band of sunlight, including the side the window cannot reach

The other way to get it wrong is upside down. glTF puts UV (0, 0) at the top left of an image and three.js at the bottom left, which is why GLTFLoader sets flipY = false on every texture it loads. A lightmap you load yourself for a glTF model needs the same. Leave flipY at its default of true and each mesh reads pieces of the others' lightmaps.

The same lightmap with flipY left at true. The window light on the floor turns into a blurred ring, pale window shapes appear on the back wall, and the ball is striped with pieces of other lightmaps

Code that sets uv2 does nothing

Older answers copy the UVs into an attribute named uv2, because before r151 the lightmap always read uv2. Since r151 it reads whichever set texture.channel names, so 0 means uv, 1 means uv1 and 2 means uv2. The default is 0, so that old code now draws the lightmap with your color UVs and no error. Name the attribute uv1 and set channel = 1. The change is in the r151 migration notes.

The lightmap looks too dark

Three.js treats a lightmap texel as the light falling on the surface, and divides it by π on the way to the screen, as it does for every diffuse light. Many bakes store how bright a white surface looks under that light instead, which is already divided by π. three.js's own ProgressiveLightMap does this, and so does a 3D tool's diffuse lighting pass baked with color turned off. Shown at the default lightMapIntensity of 1, such a lightmap comes out about a stop and a half dark.

The same lightmap at lightMapIntensity 1. The window light is in the same place, but it is a dull tan instead of bright sunlight

lightMapIntensity = Math.PI puts it back, which is what applyLightmap above does. If your baker already stores the light falling on the surface, keep it at 1.

The scene is too bright and the shadows fade

Three.js adds the lightmap to every other light on the surface. If the lamp you baked is still in the scene, its light arrives twice, once from the lightmap and once live. The live half lights the shadows the lightmap drew.

The lightmap with the scene's lamp still on. The whole floor and the walls are bright, and the window light is much fainter against the rest of the floor

Switch off every lamp whose light is in the lightmap.

lamp.visible = false;

An environment map adds diffuse light of its own as well as reflections. If the sky or HDRI was part of the bake, its diffuse light arrives twice too. scene.environmentIntensity turns down both halves together, so lower it until the shadows read again.

Nothing changes when you set lightMap

Three.js builds a shader for each material the first time it draws it, and a lightmap needs a different shader. Setting material.lightMap on a material that is already on screen does nothing until you set material.needsUpdate = true.

A material that gets its lightmap before its first frame does not need it.

Metal parts ignore the lightmap

A lightmap only lights the diffuse part of a material, and a metal has none, because three.js sets its diffuse color to color × (1 − metalness). A floor with metalness: 1 loses its baked light and shows only its reflection of the environment.

This is an open three.js issue. For a brushed metal part that has to show a baked shadow, lower metalness below 1 so some diffuse light is left for the lightmap to darken.

Bake a lightmap in the browser

You do not need a 3D tool to try this. Three.js ships ProgressiveLightMap, which bakes the lamps you give it into one lightmap for several meshes, directly on the GPU.

import { ProgressiveLightMap } from 'three/addons/misc/ProgressiveLightMap.js';

// A lamp only for the bake, where the scene's lamp is.
const bakeLamp = new THREE.DirectionalLight('#ffe0b8', 6);
bakeLamp.castShadow = true;
bakeLamp.shadow.mapSize.set(1024, 1024);

const bakedMeshes = [floor, backWall, sideWall, windowWall, ball, box];
const baker = new ProgressiveLightMap(renderer, 1024);
baker.addObjectsToLightMap([...bakedMeshes, bakeLamp]);

// Move the lamp a little each pass. The average of all the passes is a soft shadow.
for (let pass = 0; pass < 300; pass++) {
  bakeLamp.position.set(5, 3, -0.8).add(new THREE.Vector3().randomDirection().multiplyScalar(0.15));
  baker.update(camera, 100);
}

const lightMap = baker.progressiveLightMap2.texture;

for (const mesh of bakedMeshes) {
  applyLightmap(mesh, lightMap);
}

Each pass draws one shadow map from a slightly different lamp position and blends 1/100 of it into the lightmap (the 100 passed to update), which starts out black. The light fades in and the edges of the shadows soften as the passes add up.

After n passes the lightmap has reached 1 − 0.99ⁿ of its full brightness, which is 63% at 100 passes and 95% at 300. The right half of the picture at the top of this page is the result of those 300 passes. renderer.shadowMap.enabled must be true, because each pass is an ordinary shadow map.

ProgressiveLightMap makes uv1 for you. It copies each mesh's first UV set, uv, into a square of its own in the lightmap. So uv must already give every face its own area of the image, and it must stay between 0 and 1.

  • If two faces use the same area of uv, they get the same light. That is what went wrong with the box above.
  • If uv goes past 1, the mesh spills into the next mesh's square. A ShapeGeometry, like the wall with the window here, does this, because its UVs are the outline's own coordinates. Scale them to 0 to 1 before the bake.

Only meshes in the bake cast shadows into the lightmap. That is why the wall with the window is in the list. It blocks the sun everywhere except through the window.

For a box, you can lay the six faces side by side yourself before the bake.

// Lay the six faces of a BoxGeometry side by side, three across and two down,
// with a small gap round each, so no two faces share an area of the lightmap.
function layOutFaces(geometry: THREE.BoxGeometry) {
  const uv = geometry.getAttribute('uv');
  for (let i = 0; i < uv.count; i++) {
    const face = Math.floor(i / 4);
    const u = uv.getX(i) * 0.9 + 0.05 + (face % 3);
    const v = uv.getY(i) * 0.9 + 0.05 + Math.floor(face / 3);
    uv.setXY(i, u / 3, v / 2);
  }
  uv.needsUpdate = true;
}

For a model, unwrap a second UV set in your 3D tool instead.

In React Three Fiber

Set the texture up in useTexture's callback, and give the lightmap to the model's meshes in a layout effect.

export function BakedModel({ url, lightMapUrl }: { url: string; lightMapUrl: string }) {
  const { scene } = useGLTF(url);
  const lightMap = useTexture(lightMapUrl, (texture) => {
    texture.channel = 1;
    texture.flipY = false;
    texture.colorSpace = THREE.SRGBColorSpace;
  });

  useLayoutEffect(() => {
    scene.traverse((object) => {
      if (!(object instanceof THREE.Mesh)) return;
      object.material.lightMap = lightMap;
      object.material.lightMapIntensity = Math.PI;
      object.material.needsUpdate = true;
    });
  }, [scene, lightMap]);

  return <primitive object={scene} />;
}

useGLTF and useTexture come from @react-three/drei. The model's meshes need their own TEXCOORD_1 for this, as above.

To bake in R3F, drei has <AccumulativeShadows>, which builds up soft shadows pass by pass like ProgressiveLightMap. It bakes only the shadows on one floor plane, so the walls and the objects keep their live lighting.

<AccumulativeShadows frames={300} temporal scale={10}>
  <RandomizedLight amount={8} radius={0.15} position={[5, 3, -0.8]} />
</AccumulativeShadows>

The canvas needs shadows, and each mesh that should cast a shadow needs castShadow.

How to make the lighting look even better

The Three.js viewport and the same scene rendered, side by side: a corner of a room with a light blue wall, a white plinth and an orange ball, lit by sunlight through a window on the right
A comparison of the built-in ProgressiveLightMap and a Bakery3 bake

ProgressiveLightMap bakes the direct light of the lamps you hand it. It does not bake the sunlight that bounces off the floor and the walls into the rest of the room, or the darkening where the ball meets the floor. Away from the window, the room has only the environment map's even light, which is why it stays so dark around the sunlight.

Bakery3 bakes your scene with a path tracer, the way an offline renderer lights it, and puts the lightmaps back on your meshes. It makes the second UV set, sets up the textures and picks the intensity, so none of the steps above are left to you.

import { createBakeSession } from '@oeave/bakery3/bake';

const job = await bakery3.bake();
const { bundle } = await job.result();

const session = createBakeSession({ scene });
await session.apply(session.addGeneration(bundle));

The session keeps your lamps on for highlights and takes the diffuse light from the lightmap alone, so the scene is not lit twice. If you switched a lamp off in the viewport because its light is in the lightmaps, mark it renderOnly so the bake still uses it.

// The lamp's light is in the lightmaps, so the viewport leaves it off. A bake still traces it.
lamp.visible = false;
lamp.userData.bakery3 = { renderOnly: true };

Here is a whole room baked this way, which you can walk around in your browser.

Baked GIBaked GI

Both frames are the real-time viewport. The second is the same scene after a traced bake was applied as lightmaps. Still interactive, still your meshes, and revertible in one call.

up to 512² lightmaps·standard quality·6 object(s) lit·2% of seams in view·3.3 MB to interact

Connecting your scene takes a few lines.

Common questions

Does a lightmap in three.js need a second UV set?

Not always. Since r151 a three.js lightMap reads the UV set its texture's channel names, and the default 0 is the mesh's first set, uv. Most models still need a second set, uv1 with channel = 1, because their color UVs reuse one patch of texture on many faces, and every face needs its own area of the lightmap.

Does a glTF file carry its lightmap into three.js?

No. Core glTF 2.0 has no lightmap slot, and three.js's GLTFLoader never sets material.lightMap, so the baked lightmap travels as a separate image. GLTFLoader does load the model's TEXCOORD_1 as uv1, so load the image with flipY = false, set channel = 1 and give it to each mesh as its lightMap.

Do I need lights in the scene when I use a lightmap in three.js?

No. three.js adds a lightMap texel to the light that reaches the surface, so a mesh with a lightmap looks lit with no lamps in the scene at all. A lamp whose light is already in the lightmap lights the surface a second time and washes out the baked shadows, so switch it off.

How do I bake a lightmap in three.js without a 3D tool?

Use ProgressiveLightMap from three.js's addons, which bakes the shadows and direct light of the lamps you give it into one lightmap on the GPU, pass by pass. It works with WebGLRenderer, and ProgressiveLightMapGPU is the version for WebGPURenderer. It does not bake light bouncing between surfaces, so shaded areas stay as dark as the environment leaves them.

Does a three.js lightmap update when an object moves?

No. A lightMap is a fixed image read through the mesh's UVs, so the light and shadows stay where they were baked. A ball that rolls away leaves its baked shadow on the floor and takes its own baked shading with it. A lightmap suits the parts of a scene that stay still, and a moving part needs live lights or a new bake.