注视点渲染
头显把大部分像素花在了你根本看不清细节的地方。注视点渲染就是运行时少给那些像素着色,并让你说要少给多少。
看着外围散掉
这块面板是一张离屏渲染的画面,再经过一个 shader 显示:从中心往外每一个环带,像素被吸附到更粗的格子上。拉动滑块,外圈会块状化而中间保持锐利;那几道环就是阶梯发生的位置。
演示需要 WebGL。下面的文字不依赖它,覆盖同样的内容。
头显把像素浪费在哪儿
头显给你看的那幅图,各处的有用程度并不一样。镜片在你视线正对的轴向上最锐利,越往边缘越软;而眼睛本身也只在中央一小块区域能分辨细节。再加上运行时必须把画面做反畸变去抵消镜片,这会把渲染缓冲的四角拉伸到比中间更少的物理像素上。
于是每只眼睛缓冲区的四角,都是你按全价付了钱、却几乎看不见的像素。一台要以每秒 90 帧画两个视图的头显,这部分占掉的着色预算相当可观,而用户完全感受不到。
注视点渲染就是运行时对这些区域用更低的着色率,再把结果放大回去。固定注视点只按位置套一个固定的模式;眼动追踪注视点则把锐利区移到用户真正在看的地方,效果更好,但需要眼动硬件,也带来自己的延迟要求。
怎么开
这个属性挂在 layer 上而不是 session 上,因为它描述的是**那块缓冲区怎么着色**。你可以在创建 layer 之后设一次,也可以运行时改:有些应用在菜单界面把强度调低,因为用户会去读靠近边缘的文字;而在剧烈运动时调高,因为那时候没人会注意到。
const session = await navigator.xr.requestSession('immersive-vr');
const gl = canvas.getContext('webgl2', { xrCompatible: true });
const layer = new XRWebGLLayer(session, gl);
session.updateRenderState({ baseLayer: layer });
// 0 = 不做注视点渲染,1 = 最强。中间任意取值都合法。
layer.fixedFoveation = 0.5;
// 读回来看看:运行时未必用了你给的那个数。
console.log(layer.fixedFoveation);在 three.js 里对应的是 renderer.xr.setFoveation(value),它会设到渲染器正在管理的那个 layer 上。
那个数字只是一个请求
把这个属性读回来,未必等于你写进去的值。规范把它描述成一个提示:运行时会挑自己真正支持的模式,而不同硬件支持的着色率档位数量和分界点都不一样。只有三档的设备,没法满足一个要求平滑过渡的请求。
这一点对测试很要紧。你在一台头显上设 0.5 看到明显的画质下降,在另一台上设同样的 0.5 却什么都看不出来,这两台设备都没坏。把这个值当成一个刻度盘上的位置,而那些卡位不由你控制;判断结果要靠眼睛看,不是靠你写进去的数。
这也意味着**省下了多少,没法从这个值算出来**。唯一诚实的办法是在你真正关心的那台设备上,开和关各测一次帧时间。
上面那个演示在做什么,以及它不是什么
演示先把内层场景渲染到一张离屏纹理,再通过一个片元着色器把它画出来:着色器把纹理坐标吸附到格子上。缓冲区按离中心的距离分成四个环带,每一带用同一个格子尺寸——中间逐像素采样,往外每一带把方块边长翻倍。那三道橙色环就是分界线本身,所以你在环上看到的变化,就是全部的变化。
这是对**可见后果**的模拟,不是对机制的模拟。真正的固定注视点渲染改的是 GPU 内部的着色率,而桌面浏览器不暴露那个开关。演示之所以画成块状而不是模糊,是因为降低着色率之后再放大回去看到的就是块状;画成模糊会暗示存在一个并不存在的滤波。分成离散的环带也是同一个理由,上一节说过:硬件提供的是有限的几档着色率,不是平滑斜坡。
内层场景刻意堆满了高频细节:很密的棋盘地面、管子很细的环面纽结、以及边缘那几圈小球。低频内容在很强的注视点渲染下几乎毫发无伤,那会让人误以为这个特性是免费的。
什么最先崩
注视点渲染在有些内容上几乎看不见,在另一些上一眼就能看出来。下面这些是会露馅的情况,大致按「多早失效」排列。
- 靠近视野边缘的文字
- 字形是高对比度的细笔画,恰恰是降低着色率最先毁掉的东西。一个摆在外围的菜单,在画面中央看着没问题,到了角落就不可读。
- 深色背景上的细亮几何
- 线缆、栏杆、粒子拖尾、UI 描边,在着色率下降时会严重锯齿化。即使眼睛分辨不出那根线,也能捕捉到它的闪烁。
- 高频纹理
- 网格、织物纹理、树叶这类细密重复图案,在运动中会变成闪烁的方块。做过 mip 偏置的纹理往往比锐利的那张更抗得住。
- 高光
- 小而亮的高光点会在相邻帧之间在着色样本间跳动,于是一闪一闪。这是最常被误认成光照 bug 的一种失效。
- 任何用户会转头去看的东西
- 固定注视点渲染不知道眼睛指向哪里。指望用户仔细端详的内容,不该因为「注视点渲染很微妙」就被放在外围。
固定式与眼动式
这两者常被当成一个特性来谈。它们的前提条件和失效方式都不一样。
| 固定注视点 | 眼动追踪注视点 | |
|---|---|---|
| 锐利区在哪 | 永远在缓冲区中央 | 用户看哪儿就在哪儿 |
| 需要什么硬件 | 除 GPU 外不需要 | 眼动追踪,外加低延迟上报 |
| 能做到多激进 | 有限,因为用户可能看向任何地方 | 可以高得多,因为外围是真的没被看到 |
| 主要失效方式 | 用户转头去看的那块外围细节 | 追踪丢失或滞后,会短暂糊掉正在看的地方 |
| Web API | XRWebGLLayer.fixedFoveation | 未向 Web 平台暴露 |
依据 WebXR Device API 规范与 three.js 文档核对于 2026-09。眼动追踪注视点在部分设备的原生运行时里已有,但截至此日期没有对应的 WebXR 接口。
最费时间的几个坑
- 把它当成给用户的画质选项
- 在自己的设置菜单里放一个叫「注视点渲染」的滑块,等于让用户拿他看得见的锐利度,去换一个他可能根本没觉得缺的帧率。这个值应该由你按场景自己定,并且实测。
- 在 layer 存在之前就设
- 这个属性属于 XRWebGLLayer。设在 session 上、或者在 updateRenderState 生效之前设,都会静默地什么也不做。
- 以为读回来的就是你设的
- 运行时可能量化或忽略你的请求。根据「刚写进去的值」去分支的代码,在某些设备上会走错分支。
- 在一台本来就不是 GPU 瓶颈的机器上测
- 注视点渲染省的是片元着色。如果这一帧卡在绘制调用、几何或 CPU 上,把强度调满也不会有变化,然后你会得出「它没用」的结论。
延伸阅读
- XRWebGLLayer.fixedFoveation — MDN — 这个属性、它的取值范围,以及「该值只是提示」那条说明。
- WebXR Device API 规范 — layer、渲染状态与会话生命周期的定义都在这里。
- 立体渲染 — 注视点渲染要削减的正是这个按视图循环的成本。如果「一帧画两个视图」对你还是新概念,先读它。
- WebXR Layers — 另一条避免为像素付两遍钱的路:把内容交给合成器,而不是自己画。