会话可见性
系统菜单压在你的画面上时,用户依然看得见你画的每一帧。visibilityState 就是在告诉你这件事,而你的代码必须把它当成单独的一种情况处理。
三种状态,以及中间那个最常见的两种错法
切换状态,盯住两件事:叶片还转不转(你还在渲染吗),以及小球还跟不跟着动(你还在收输入吗)。打开「常见错误」开关,就能看到两个错误分支各自长什么样。
演示需要 WebGL。三种状态和正确处理方式在下面的文字里都写了。
为什么两种状态不够用
一个不在用户眼前的页面就该停下来。这个直觉在普通网页上是对的:状态只有 visible 和 hidden,浏览器会停掉你的动画回调。但把它照搬进 XR 会制造一个很具体的 bug:用户呼出系统菜单,你的应用冻住,等他关掉菜单,看到的是一个凭空往前跳了一段的场景。
原因是头显并不会因为出现了别的东西就不再显示你的内容。运行时把菜单**合成在你的画面之上**,所以你仍然在屏幕上,仍然被期待产出帧。你**不**该做的是把用户的手当成输入,因为那双手正在操作菜单。
WebXR 给这件事单独设了一个状态。XRSession.visibilityState 是三个字符串之一,而中间那个在普通网页的生命周期里没有对应物。
三种状态
这是 XRSession.visibilityState 的取值。由运行时设定,你改不了。
- visible
- 你的内容正在显示,会话也在接收输入。这是常规情况,也是唯一适合读取输入源的状态。
- visible-blurred
- 你的内容**仍在显示**,但输入被送到别处去了,通常是合成在你画面之上的系统菜单或系统级对话框。继续渲染;不要响应输入。姿态可能仍在上报,而**响应它们正是那个 bug**。
- hidden
- 你的内容没在显示。运行时没有义务再调用你的帧回调,所以别指望用它来驱动计时,也别指望靠它发现时间过去了。
怎么读,怎么响应
事件在 session 上触发,而当前值任何时候都能从 session 对象上读到。两者之中,在帧回调里读更稳,因为它不可能过期。
session.addEventListener('visibilitychange', (event) => {
switch (event.session.visibilityState) {
case 'visible':
resumeInput();
break;
case 'visible-blurred':
// 还在屏幕上。继续画,停止监听。
suspendInput();
break;
case 'hidden':
// 帧可能不再来了。该存的先存下来。
suspendInput();
pauseSimulation();
break;
}
});
function onXRFrame(time, frame) {
session.requestAnimationFrame(onXRFrame);
// 给你的每一帧都要渲染,包括 blurred 的那些。
renderScene(frame);
if (session.visibilityState !== 'visible') return;
handleInput(frame);
}在帧回调里再判一次状态,成本为零,却能消掉一类顺序 bug —— 事件和帧的到达顺序没有保证。
两种错法,各自长什么样
把 visible-blurred 当成 hidden,这是更常见的一种。应用停掉渲染循环,合成器继续显示它收到的最后一帧,用户在菜单背后看到的是一张静止画面。关掉菜单,场景从停住的地方继续,取决于菜单开了多久,这会被感知成一次卡顿或者一次瞬移。
把它当成 visible,这是更尴尬的一种。用户伸手去按菜单按钮,而你的代码还在消费同一批姿态,于是他的化身也跟着伸手。在多人会话里,其他所有人看着他对着空气挥手;在单人里,他关掉菜单发现手上拿着的东西掉了。
上面那个演示里两个错误分支都在。打开「常见错误」开关再切到 visible-blurred:叶片停转,或者小球继续跟手,取决于你在看哪一种失效。
每种状态下到底该做什么
visible 和 visible-blurred 都要继续渲染。渲染的成本相对于另一个选项来说很便宜 —— 另一个选项是在用户最可能留意你应用响应速度的那一刻,给他看一张冻住的画面。
只在 visible 时放行输入。这包括手柄按键、手势,以及任何从姿态推导出来的东西:传送弧线、抓取、UI 悬停。但这**不**意味着要把姿态本身从场景图里拦掉 —— 一只手的模型继续更新位置、同时它的交互被抑制,看起来是对的,用起来也是对的。
把 hidden 当成暂停,而且先存盘。在这个状态下你的帧回调可能再也不会被调用。任何依赖时间推进的东西 —— 倒计时、物理步进、网络心跳 —— 都需要 requestAnimationFrame 之外的时间源,或者需要事后对账。
最费时间的几个坑
- 把它接到 document.visibilityState 上
- 页面和会话是两个东西,生命周期不同,而页面那套 API 根本没有 visible-blurred 的对应物。会话可以是 visible,而页面同时报告 hidden。
- 以为 blurred 时姿态就不来了
- 运行时在 visible-blurred 期间可能照样上报输入源姿态。那种只判断「姿态存在吗」而不判断状态的代码,会照单全收。
- 拿帧回调当时钟
- 它在 hidden 时会停,在别的状态下也不保证均匀。任何必须计时的东西都需要一个真实时间戳,外加一个对账步骤。
- 只靠摘头显来测
- 把头显摘下来通常产生的是 hidden,那是容易的那一格。要走到 visible-blurred 得去呼出系统菜单,而那才是值得专门测的状态。
延伸阅读
- XRSession.visibilityState — MDN — 三个取值,以及 visibilitychange 事件。
- WebXR Device API 规范 — 可见性状态机与它的状态迁移定义在这里。
- WebXR 会话 — 会话最初是怎么起来的,以及参考空间锚定在什么上。
- VR 手柄 — 本页让你关掉的正是这套输入模型,以及姿态每帧怎么到达。