光照估计
光照估计回答的是同一个问题 —— 从这里看,这个房间的光长什么样 —— 但它用三种不同的货币作答,而只用其中最便宜的那一种,正是大量 AR 内容至今看起来像贴上去的原因。
光一直在移动的房间里的两个球
那块发亮的面板是房间里唯一的强光源,它在一面暖墙和一面冷墙之间绕行。估计模式下,两个球由一个每秒重采样几次的探针照亮;切到固定,它们就换回一盏完全无视这个房间的影棚灯。
这个演示需要 WebGL,你的浏览器没有提供。下面的正文本身是完整的,不看演示也能读。
三个答案,不是一个
一次光照估计携带三样彼此独立的东西,而它们本来就是要配合使用的。球谐系数描述柔和的、带方向性的环境光 —— 二十七个浮点数,每个颜色通道九个系数,足够说清「左边更亮更暖、右边更暗更蓝」,再多就说不了了。对漫反射着色而言这个精度恰到好处,而且很便宜。
主光源方向与强度描述的是那唯一一个主导光源,也就是会投出影子的那个。球谐**在构造上**就表达不了锐利的高光,所以它被单独报出来 —— 你要拿它去摆一盏平行光。
反射立方体贴图是昂贵的那个:一张真正的周围环境图像,用于高光表面的镜面反射。它从 WebGL binding 拿,而不是从帧上拿,而且按它自己的节奏更新,不是每帧。
只用环境项的内容看起来扁平、没有落位;只用主光源的内容影子对了,但补光死板均匀。三者合起来,物体才像是站在这个房间里。
请求探针,读取估计
探针请求一次,活满整个会话。估计每帧读;而立方体贴图**根本不该每帧读** —— 要等运行时说它变了,再去取。
const session = await navigator.xr.requestSession('immersive-ar', {
optionalFeatures: ['light-estimation'],
});
// 按平台真正偏好的格式请求;请求另一个会在每次更新时多一次转换。
const lightProbe = await session.requestLightProbe({
reflectionFormat: session.preferredReflectionFormat,
});
// 立方体贴图**不是**每帧的。只在它变化时去取。
let reflectionCubeMap = null;
lightProbe.addEventListener('reflectionchange', () => {
reflectionCubeMap = glBinding.getReflectionCubeMap(lightProbe);
applyEnvironmentMap(reflectionCubeMap);
});
function onFrame(time, frame) {
const estimate = frame.getLightEstimate(lightProbe);
if (!estimate) return; // 还没就绪。沿用上一次的值。
// 27 个浮点数:9 个球谐系数 × 3 通道,位于探针空间。
// three.js 的 LightProbe 接受同样的排布。
lightProbe3js.sh.fromArray(estimate.sphericalHarmonicsCoefficients);
// 主光源。direction 是**指向光源**的,所以平行光要沿它摆位置,
// 而不是沿它设朝向。
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);
// 强度是相对值,不是勒克斯。要对着你自己的曝光去归一化,
// 不能把这些数字当成绝对量。
keyLight.intensity = Math.min(Math.max(i.x, i.y, i.z), 4);
}系数是在**探针空间**里表达的。如果你的场景建在另一个参照空间里,就必须旋转它们,而旋转球谐和旋转一个向量不是一回事 —— 去取 probeSpace 的位姿并应用它,不要默认两个空间是对齐的。
一次估计里有什么
五个名字,其中两个是绝大多数困惑的来源。
- sphericalHarmonicsCoefficients
- 长度 27 的 Float32Array:每通道九个系数,按系数逐个交错存放红、绿、蓝。它只编码低频环境光 —— 构造上就装不下锐利高光,这正是主光源要单独报出来的原因。
- primaryLightDirection
- 探针空间里**指向**主光源的单位向量。是「指向」,不是「射向」 —— 把它直接当成灯的前向量,物体会被从错误的一侧照亮,而且看着足够合理,能一路活过评审。
- primaryLightIntensity
- 一个 RGB 三元组。数值是相对的、无上界的,不是光度学单位,所以必须和你渲染器用的曝光对齐,不能原样喂进去。
- XRLightProbe.probeSpace
- 这次估计是在哪儿取的。拿它和你的参照空间求解,才知道该怎么摆放这些系数;默认它和你的世界空间一致,是「把光照方向搞错」的安静版本。
- reflectionchange
- 告诉你有新立方体贴图可用的事件。它触发得不频繁 —— 运行时并不是每帧都在重新拍这个房间 —— 在事件之外调用 getReflectionCubeMap,只是花代价拿回同一张纹理。
这个功能为什么是**故意**不精确的
光照估计是从摄像头推导出来的信号,而一个足够详细的版本会泄露大量关于用户所在位置的信息。一张全分辨率的房间光照图,已经很接近一张房间的照片。规范把这件事当作隐私问题而不是工程细节来处理,各家实现的回应就是把交回来的数据模糊化、量化,并限制频率。
这带来一个实际后果:估计**不会**因为硬件更好而变得更锐利,因为粗糙是刻意的。每通道九个系数就是答案本身,不是通往更好答案的第一次近似;一秒钟到两次的反射立方体贴图是设计,不是性能瓶颈。
它也意味着这个功能可能被拒绝,而会话其余部分照常成功;也可能被授予之后有一段时间什么都不给。把它放进 optionalFeatures 并保留一套手工搭的后备布光,不是防御性的过度设计 —— 在相当大一部分设备上,那就是常规路径。
上面的演示在做什么
房间是用**不受光材质**画的,因为在真实会话里房间是一张摄像头图像,你场景里的灯对它没有任何作用。这样一来,这个场景里的灯只作用于那两个球 —— 和真机上的分工完全一致。
一台大致处在头部高度的立方体相机,每秒几次地拍下这个房间 —— 而且只拍房间。那六个面被投影成九个球谐系数交给光照探针,这正是 sphericalHarmonicsCoefficients 背后的管线。同一张立方体纹理又被直接当作高光球的反射环境,对应 getReflectionCubeMap。
盯住哑光球,看那块面板先经过奶油色的墙、再经过蓝灰色的墙:它的暗部会染上身后那面墙的颜色。这就是环境项里带方向性的那部分,也是单一环境色做不到的事。切到固定光照,两个球会立刻不再属于这个房间。
功能可用性
三个部分是一起授予的,但未必一起交付 —— 运行时可能给了系数却没有立方体贴图。
| 平台 | light-estimation | 球谐 + 主光源 | 反射立方体贴图 |
|---|---|---|---|
| Android Chrome(ARCore) | 支持 | 支持 | 支持 |
| Meta Quest 3 / Pro 浏览器 | 部分 | 部分 | 部分 |
| visionOS Safari | 不支持 | 不支持 | 不支持 |
| iOS Safari(iPhone) | 不支持 | 不支持 | 不支持 |
| 桌面浏览器 | 不支持 | 不支持 | 不支持 |
数据核对于 2026-09。getLightEstimate 返回 null 要当成「还没好」,不是「不支持」;后者去查 session.enabledFeatures。
最费时间的那些坑
前两条产出的光照是错的,但在物体旁边没有真实参照物之前没人会发现。
- 把主光源方向弄反
- 这个向量指向光源。把它当成光行进的方向,高光会跑到物体错误的一侧 —— 读起来只是「有点怪」,而不是「明显坏了」。
- 无视 probeSpace
- 系数是相对探针表达的。如果你的场景用的是另一个参照空间,环境光就相对房间转了个角度,而且会随用户转身而漂。
- 每帧调 getReflectionCubeMap
- 它绑在 reflectionchange 事件上是有原因的。轮询它每帧多花一个纹理句柄,拿回的还是运行时已经给过你的同一份数据。
- 把强度当绝对值用
- RGB 值是相对的,而且可以大于一。直接喂给灯,在明亮房间里会把物体曝掉,在昏暗房间里又看不见。
- 只用环境项
- 球谐本身产生不了高光,也产生不了影子。只靠系数照亮的物体,不管系数多准确,看起来都是软的、没有重量的。
- 把它列为必需功能
- 把 light-estimation 放进 requiredFeatures,会让所有没实现它的设备整个会话失败 —— 换来的只是一项视觉精修。它属于 optionalFeatures,后面接一套后备布光。
延伸阅读
这份规范少见地值得整篇读完,因为隐私那一节解释了数据为什么长成这样 —— 而那正是不断让人意外的部分。
- W3C — WebXR Lighting Estimation API — XRLightProbe、XRLightEstimate,以及这些限制背后的隐私考量。
- MDN — XRLightEstimate — 系数的排布方式,以及主光源那几个值的单位。
- MDN — XRSession.requestLightProbe() — 反射格式、preferredReflectionFormat,以及探针的生命周期。
- 环境混合模式 — 你把光算对之后,显示器拿这些像素去做什么。
- 锚点 — 让这个被照亮得恰到好处的物体,留在你为它选的位置上。
- 立体渲染 — 一张反射立方体贴图的代价,最终落在帧预算的哪一段。