Lighting estimation

Lighting estimation answers one question — what does the light in this room look like from here — in three different currencies, and using only the cheapest of them is why so much AR content still looks pasted on.

Two spheres in a room whose light keeps moving

The bright panel is the only strong light in the room, and it orbits between a warm wall and a cool one. In estimated mode the spheres are lit from a probe resampled a few times a second; switch to fixed and they keep a studio light that ignores the room entirely.

This demo needs WebGL, which your browser did not provide. The explanation below covers the same material on its own.

Loading demo…

Three answers, not one

A light estimate carries three separate things, and they are meant to be used together. The spherical harmonics coefficients describe the soft, directional ambient light — twenty-seven floats, nine coefficients for each colour channel, which is enough to say "brighter and warmer from the left, dimmer and bluer from the right" and not much more. That is exactly the right amount of detail for diffuse shading, and it is cheap.

The primary light direction and intensity describe the one dominant source, the thing that casts a shadow. Spherical harmonics deliberately cannot represent a sharp highlight, so this is reported separately, and it is what you point a directional light at.

The reflection cube map is the expensive one: an actual image of the surroundings, for specular reflection on shiny surfaces. It arrives through the WebGL binding rather than from the frame, and it updates on its own schedule rather than every frame.

Content that uses only the ambient term looks flat and unplaced. Content that uses only the primary light has correct shadows and a dead, uniform fill. The combination is what makes an object look like it is standing in the room.

Requesting a probe, reading an estimate

The probe is requested once and lives for the session. The estimate is read per frame, and the cube map is not read per frame at all — it is pulled when the runtime says it has changed.

const session = await navigator.xr.requestSession('immersive-ar', {
  optionalFeatures: ['light-estimation'],
});

// Ask for the format the platform actually prefers; requesting the other
// one costs a conversion on every update.
const lightProbe = await session.requestLightProbe({
  reflectionFormat: session.preferredReflectionFormat,
});

// The cube map is NOT per-frame. Pull it only when it changes.
let reflectionCubeMap = null;
lightProbe.addEventListener('reflectionchange', () => {
  reflectionCubeMap = glBinding.getReflectionCubeMap(lightProbe);
  applyEnvironmentMap(reflectionCubeMap);
});

function onFrame(time, frame) {
  const estimate = frame.getLightEstimate(lightProbe);
  if (!estimate) return;   // Not ready yet. Keep the previous values.

  // 27 floats: 9 SH coefficients x 3 channels, in probe space.
  // three.js LightProbe takes them in the same layout.
  lightProbe3js.sh.fromArray(estimate.sphericalHarmonicsCoefficients);

  // The dominant source. direction points TOWARDS the light, so a
  // directional light is placed along it, not aimed along it.
  const d = estimate.primaryLightDirection;
  const i = estimate.primaryLightIntensity;
  keyLight.position.set(d.x, d.y, d.z).multiplyScalar(10);
  keyLight.color.setRGB(i.x, i.y, i.z);

  // Intensity is relative, not in lux. Normalise it against your own
  // exposure rather than treating the numbers as absolute.
  keyLight.intensity = Math.min(Math.max(i.x, i.y, i.z), 4);
}

The coefficients are expressed in the probe's space. If your scene is built in a different reference space you have to rotate them, and rotating spherical harmonics is not the same as rotating a vector — get the pose of probeSpace and apply it, rather than assuming the two spaces are aligned.

What is in an estimate

Five names, and two of them are the source of most of the confusion.

sphericalHarmonicsCoefficients
A Float32Array of 27: nine coefficients per channel, red then green then blue interleaved per coefficient. It encodes low-frequency ambient light only — by construction it cannot contain a sharp highlight, which is why the primary light is reported separately.
primaryLightDirection
A unit vector pointing towards the dominant light, in probe space. Towards, not away — using it directly as a light's forward vector lights the object from the wrong side, and looks plausible enough to survive a review.
primaryLightIntensity
An RGB triple. The values are relative and unbounded, not photometric units, so they have to be reconciled with whatever exposure your renderer uses instead of being fed in raw.
XRLightProbe.probeSpace
Where the estimate was taken. Resolve it against your reference space to know how to orient the coefficients; assuming it matches your world space is the quiet version of getting the lighting direction wrong.
reflectionchange
The event that says a new cube map is available. It fires infrequently — the runtime is not re-capturing the room every frame — and calling getReflectionCubeMap outside it just hands you the same texture at a cost.

Why this feature is deliberately imprecise

Lighting estimation is a camera-derived signal, and a detailed one would say a great deal about where the user is. A full-resolution image of a room's illumination is close to an image of the room. The specification treats this as a privacy concern rather than an engineering detail, and implementations respond by blurring, quantising, and rate limiting what they hand back.

That has a practical consequence. The estimate is not going to become sharper on better hardware, because the coarseness is intentional. Nine coefficients per channel is the answer, not a first approximation to a better one, and a reflection cube map arriving twice a second is the design rather than a performance limit.

It also means the feature can be refused while the rest of the session succeeds, and it can be granted and then deliver nothing for a while. Requesting it as an optional feature and keeping a hand-authored fallback lighting rig is not defensive over-engineering — it is the normal path on a large share of devices.

What the demo above is doing

The room is drawn with unlit materials, because in a real session the room is a camera image and your scene lights have no effect on it. That makes the lights in this scene act on the two spheres alone, which is the same division of labour you get on hardware.

A cube camera at roughly head height captures the room — and only the room — a few times a second. Those six faces are projected into nine spherical harmonics coefficients and handed to a light probe, which is precisely the pipeline behind sphericalHarmonicsCoefficients. The same cube texture is used directly as the reflection environment on the glossy sphere, standing in for getReflectionCubeMap.

Watch the matte sphere as the panel crosses in front of the cream wall and then the blue one: its shadowed side picks up the colour of the wall behind it. That is the directional part of the ambient term, and it is the thing a single ambient colour cannot do. Switch to fixed lighting and both spheres immediately stop belonging to the room.

Feature availability

The three parts are granted together but not always delivered together — a runtime may provide coefficients and no cube map.

Platformlight-estimationSH + primary lightReflection cube map
Android Chrome (ARCore)YesYesYes
Meta Quest 3 / Pro browserPartialPartialPartial
visionOS SafariNoNoNo
iOS Safari (iPhone)NoNoNo
Desktop browsersNoNoNo

Checked 2026-09. Treat a null return from getLightEstimate as "not yet" rather than "not supported", and check session.enabledFeatures for the latter.

Mistakes that cost the most time

The first two produce lighting that is wrong in a way nobody notices until the object is next to a real one.

Inverting the primary light direction
The vector points towards the light. Treating it as the direction light travels puts the highlight on the wrong side of the object, which reads as slightly odd rather than obviously broken.
Ignoring probeSpace
The coefficients are expressed relative to the probe. If your scene uses a different reference space, the ambient light is rotated relative to the room and the shading drifts as the user turns.
Calling getReflectionCubeMap every frame
It is bound to the reflectionchange event for a reason. Polling it costs a texture handle every frame and returns the same data the runtime already gave you.
Using intensity as an absolute
The RGB values are relative and can exceed one. Feeding them straight into a light blows out the object in a bright room and leaves it invisible in a dim one.
Using ambient only
Spherical harmonics alone cannot produce a highlight or a shadow. An object lit only by the coefficients looks soft and weightless no matter how accurate they are.
Requiring the feature
Listing light-estimation in requiredFeatures fails the whole session on every device that does not implement it, in exchange for a visual refinement. It belongs in optionalFeatures with a fallback rig behind it.

Further reading

The spec is unusually worth reading in full because the privacy section explains why the data is shaped the way it is — which is the part that keeps surprising people.