会话可见性

系统菜单压在你的画面上时,用户依然看得见你画的每一帧。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 得去呼出系统菜单,而那才是值得专门测的状态。

延伸阅读