背屏深渊镜

October 23

1. 背景

深渊镜是 Xiaomi 17 系列背屏上的一个表盘样式。这是一个普通表盘需求, 没有太大的宣发压力。 我希望在这个项目中尝试一些新的技术。

灵感来自于上图展示的这种常见的视觉装置。它使用两片单面反射玻璃, 通过多次反射光源制造出限延伸的视觉效果。 如果从不同角度观察, 看到的效果也会不同, 就像真的深渊一样。

背屏看起来也很像是手机上开的一扇窗。搭配传感器的话, 应该可以实现类似的视错觉。


2. 设计思路

在 3D 世界里这是个简单的摄像机模型。我做了一个动画来方便你理解。用一句话概括: 镜子中灯光保持不动, 观察者通过变化观察角度, 从不同的视角看到 "窗户" 里不同的反射效果, 而背屏正适合作为这样一扇窗户。 另外在实际体验时, 眼睛的位置应当是固定的, 通过手控制手机旋转来实现从不同视角的观测。 我们可以通过检测手机的旋转角度在屏幕上渲染不同的内容, 让用户产生后面真的有很多盏灯的视错觉。

仔细观察的话, 这和普通的渲染有下面几个显著的不同点:

  1. 透视: 特别地, 观察第一层光圈, 不难发现它需要完全匹配手机本身的透视。换句话说, 从观察者角度, 第一层光圈要像是用笔直接涂在屏幕上一样跟着屏幕旋转。
  2. 视窗大小: 背屏的物理尺寸是固定的, 旋转角度越大, 看到的 "窗户" 就越小。也就是说, 渲染的视窗大小要随着手机旋转角度动态变化。

对于不熟悉渲染的人来讲这两个不同点还是比较抽象, 俯视图能够帮助我们理解这个问题。当手机旋转的时候, 视窗 (背屏) 两端与摄像机会构成一个三角形, 称为视锥角, 张角的大小称为 FOV (Field of View)。从下面的动图可以看到, 当手机旋转的时候, 视锥角的大小也在变化。而常用的空间摄像机模型 FOV 一般是固定的。

上面的动图里只描述了单自由度旋转的情况, 真实世界里拥有3个轴的自由度, 情况会更复杂。在游戏产业里对这个问题已经有了成熟的方案, 类似的渲染方案被叫做 Portal Projection


3. 渲染

这种投影通过构造一个视图/投影矩阵, 使得无论相机(视点)如何移动, 投影平面(像平面)都固定在世界空间中, 正是我们尝试解决的问题。

3.1. Portal Projection

3.1.1. 定义成像平面

我们需要定义一个成像平面, 也就是背屏所在的平面。在世界空间中, 这个平面可以通过平面上一个点 p\mathbf{p}, 平面法线 n\mathbf{n}, 以及平面上两个单位向量 u\mathbf{u}v\mathbf{v} 来定义。 为了方便, 我们选择屏幕中心为世界坐标原点, 并选择正交的三组单位向量, 这样 p=(0,0,0)\mathbf{p} = (0, 0, 0), 以此展开后续的计算。那么我们就有:

DefExpr
p\mathbf{p}(0,0,0)(0, 0, 0)
n\mathbf{n}(0,0,1)(0, 0,-1)
u\mathbf{u}(1,0,0)(1, 0, 0)
v\mathbf{v}(0,1,0)(0, 1, 0)

3.1.2. 构造投影矩阵

我们当然也可以用传统的MVP矩阵, 创建非对称 Frustum Projection。 这里我们使用的是Lengyel, Oblique View Frustum 提到的另一种方案。 这个方案更加直观。

考虑成像平面后方任意一点 q\mathbf{q}, 用一条直线将它与视点 e\mathbf{e} 相连, 这条直线与成像平面的交点 q\mathbf{q}\prime 就是投影点。即:

q=e+n(pe)n(qe)(qe)\mathbf{q} \prime = \mathbf{e} + \frac{\mathbf{n} \cdot (\mathbf{p} - \mathbf{e})}{\mathbf{n} \cdot (\mathbf{q} - \mathbf{e})} (\mathbf{q} - \mathbf{e})

写成 vertex shader 的代码非常简洁:

// Eye position in world space uniform vec3 EyePos; // Plane point and normal uniform vec3 PlanePoint; uniform vec3 PlaneNormal; void main() { vec3 worldPos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; vec3 rayDir = normalize(worldPos - EyePos); float denom = dot(PlaneNormal, rayDir); float t = dot(PlanePoint - EyePos, PlaneNormal) / denom; vec3 projected = EyePos + rayDir * t; // Map projected.x, projected.y to [-1,1] using your plane axes and size // ... }

3.2. 效果对比


透视投影
Portal 投影

在透视投影下, 光环会'自旋转', 而不是随着屏幕旋转。

透视投影
移动鼠标以旋转


4. 交互

材质和渲染方案使用了很常规的PBR流程, 没有特别想分享的内容。我将绝大部分时间都放在了传感器数据的处理上, 让体验过程尽可能自然跟手。

4.1. 传感器数据处理

在安卓设备上可用的传感器有不少, 具体可以参阅 安卓官方文档。在本项目中用到的传感器如下:

传感器作用
陀螺仪检测设备旋转的角速度
线形加速计检测设备移动的线性加速度
重力计检测重力加速度在设备惯性系三个方向的分量
磁力计检测设备相对地磁场在三个分量上的分量

深渊镜中选择类似惯性导航系统的方案获得手机在世界坐标中的姿态信息。 可以用一个公式来表达一个超级简单的、带修正的惯性导航系统:

pk=pk1+vkΔt+corrk\mathbf{p}_{k} = \mathbf{p}_{k-1} + \mathbf{v}_{k}\cdot \Delta t + \mathbf{corr}_{k}

其中

  • pk\mathbf{p}_k 为当前的设备姿态, pk1\mathbf{p}_{k-1} 为上一帧的姿态
  • Δt\Delta t 为帧间距
  • vk\mathbf{v}_k 为当前的设备速度, 在我们的应用里代表角速度
  • corrk\mathbf{corr}_k 为基于其它观测系统的修正值, 用来控制漂移

只要给定初始设备姿态 p0\mathbf{p}_0, 我们就可以计算任意时间的设备的姿态。

这个概念太过基础, 用公式来表达有些复杂化, 但这种方式有助于让我从更高/抽象的角度去思考问题, 也会带来新的想法。如果这降低了你的阅读体验, please bear with me.

4.1.1. 初始姿态

重力计和磁力计返回的数据都是相对于世界坐标的, 非常适合用来计算设备初始姿态。

function orientate_by_mag_and_grav() { // 获取磁力计数据 const p_mag = get_magnetometer() // 获取重力计数据 const p_grav = get_gravity() let device_basis = new Basis() // 设备 y 方向指向重力加速度的反方向 device_basis.y = -p_grav.normalized() // 设备 x 方向为地磁场正北与重力加速度构成平面的法线方向 device_basis.x = device_basis.y.cross(p_mag) // 构造正交基的第三个单位向量作为设备 z 方向 device_basis.z = device_basis.x.cross(device_basis.y) }

换句话说, 如果设备此刻姿态为

[xyz]=[1 0 00 1 00 0 1]\begin{bmatrix} x\\ y\\ z \end{bmatrix} = \begin{bmatrix} 1\ 0\ 0\\ 0\ 1\ 0\\ 0\ 0\ 1 \end{bmatrix}

那么手机应该是垂直于地面, 摄像头冲着正北方。

4.1.2. 离散积分

受限于硬件, 陀螺仪返回的数据可能会有很多噪声, 在体验过程中具体表现为抖动。例如即使握持手机静止不动, 画面也会有非常轻微的旋转。在积分过程中我使用了一个Deadband滤波器来去除这种非常轻微的运动。

function deadband(gyro) { let r = new Vector3(); r.x = round(gyro.x * 10.0) / 10.0; r.y = round(gyro.y * 10.0) / 10.0; r.z = round(gyro.z * 10.0) / 10.0; return r; } // GLOBAL let BASIS_WORLD; function on_init() { // ... BASIS_WORLD = orientate_by_mag_and_grav(); // ... } function accumulate(p_gyro, dt) { // 保留小数点后1位, 消除传感器数据中微小的数据抖动 const gyro = deadband(p_gyro); let r = Basis() // 离散积分 r = r.rotated(BASIS_WORLD.x, -gyro.x * dt) r = r.rotated(BASIS_WORLD.y, -gyro.y * dt) r = r.rotated(BASIS_WORLD.z, -gyro.z * dt) BASIS_WORLD = r * BASIS_WORLD }

4.1.3. 缓动

根据过往经验, 涉及到传感器驱动渲染的项目需要还考虑下面几个问题:

  1. 传感器更新频率不固定
  2. 渲染帧间距不固定
  3. 用户动作可能会在短时间内大幅度变化 (如 Shake 或 在行驶的汽车上)

传感器的采样频率相对于真实世界的运动永远是不够高的, 这会导致数据出现跳变。如果直接用积分结果进行渲染就会出现闪烁。针对这个问题, 使用缓动函数来驱动渲染是行之有效的办法。缓动函数有两个模块, 插值和曲线。

  • 球面插值

我们试图缓动的对象是设备旋转角, 游戏工业中常用的数学工具有两个: Euler AngleQuaternion。这是个经典的话题, 已经有很多非常优秀的内容来对比两个系统的优缺点, 如 Freya3Blue1Brown 的视频, 这里不再赘述。我只引入一个特殊的场景来解释为什么在我们的应用中必须使用Quaterion。

欧拉角插值
四元数插值

基于欧拉角的插值不会按照预期路径移动

欧拉角插值
移动鼠标以旋转

Quaternion 通过在球面上寻找一条最短路径来连接起始旋转角和终点旋转角, 可以完全避免基于欧拉角插值的各种问题。

  • 弹簧曲线

这个项目没有模拟物理弹簧的必要, 因此使用一个常用的近似方法, 对目标数值 TT, 当前值 xnx_n, 前序值 xn1x_{n-1}:

xn=kT+(1k)xn1x_n = k T + (1 - k) x_{n-1}

这种近似和真正的弹簧系统的位置曲线是同形的, 因此在感知上会觉得非常自然。这里做个简单的推导以作记录, 对于弹簧系统有:

F(t)=mx¨+cx˙+k(xT)F(t) = m\ddot{x}+c\dot{x}+k(x-T)

其中 TT 为目标值, xx 为当前值, mm为质量, cc 为阻尼系数, kk为弹簧常数。考虑一个简化的系统, 令 F(t)=0F(t) = 0, m=1m = 1, x¨=0\ddot{x} = 0 有:

x˙=kc(Tx)\dot{x}=\frac{k}{c}(T-x)

而上面的近似系统中可以认为 x˙=xnxn1\dot{x} = x_n - x_{n-1}, 再进一步化成微分方程:

xn=kT+(1k)xn1xnxn1=k(Txn1)x˙=k(Txn1)\begin{aligned} x_n &= k T + (1 - k) x_{n-1}\\ x_n - x_{n-1} &= k (T - x_{n-1})\\ \dot{x} &= k (T - x_{n-1}) \end{aligned}

不难发现, 真实的弹簧模型和我们近似的模型一阶微分方程具有相同的表达式。换句话说, 两种模型都描述了一种相似的 "距离越远, 速度越快" 的运动。

4.2. 超限处理

在实际体验过程中, 用户操作幅度可能很大, 比如旋转超过360度, 甚至转两圈。在深渊镜装置中, 这相当于绕到了镜子后面。 我加入了超限机制来防止这种现象发生, 以保证体验稳定。

  • 锚点机制

初始姿态 有提到, 旋转是围绕以地磁场和重力方向定义的绝对世界坐标系进行的, 这意味着用户面朝南方时不会看到任何东西 (也就是站在了镜子后面)。 想象一下为了看时间要求用户必须面向北方? 像个邪教。

为了解决这个问题, 我们以首次进入表盘时的姿态为锚点, 建立一个相对坐标系, 围绕这个坐标系进行旋转。

// GLOBAL let BASIS_WORLD: Basis; let Q_ANCHOR: Quaternion; let Q_WORLD: Quaternion; function on_init() { // ... BASIS_WORLD = orientate_by_mag_and_grav(); Q_WORLD = Quaternion.from(BASIS_WORLD); Q_ANCHOR = Q_WORLD; //... }
  • 双弹簧

当姿态超过一定角度后, 可以移动锚点来跟随旋转, 相当于把镜子转到面向观察者的方向。

function update_anchor() { // 计算相对球面距离 const angle = Q_ANCHOR.angle_to(Q_WORLD); if (angle > MAX_ANGLE) { // 超限移动锚点 Q_ANCHOR = Q_ANCHOR.slerp(Q_WORLD, ANCHOR_MOVE_FACTOR); } } function on_update(dt) { // ... // 更新锚点 update_anchor(); // 渲染使用相对旋转 const q_diff = Q_ANCHOR.inverse() * Q_WORLD; // ... }

锚点和双弹簧机制结合使用, 可以让用户在大幅度旋转手机时依然获得稳定的体验。

Move mouse to rotate

4.3. AOD

AOD设计的主要目的是节省能耗和防止烧屏幕, 因此做以下优化:

  • 降低亮度: AOD模式下整体亮度降低到正常模式的30%
  • 简化渲染: 字体变细, 隐藏摄像头后光圈, 减少屏幕边缘光圈层数

5. 改进方向

  • 更多颜色和材质

目前只有白色, 可以使用颜色更丰富的环境和光照来提升视觉效果, 光圈也可以尝试更复杂的材质表现。

另外还有我拍的一些产品图, 欢迎 在这里查看

© 2020 - 2025 Ruiyao Luo

25/12/10 11:30

PROD

#68e6fa8

Ruiyao Luo 2025