把一段 VR180 视频直接放进 <video> 标签,通常会看到两幅并排、明显变形的画面。它能正常播放,却不像我们期待的那样,可以转动视角、自然地观察场景。
原因在于,这类视频保存的不是一个固定视角下的平面画面,而是一个覆盖半球的视野。播放器需要把二维图像重新映射到空间中,再通过虚拟相机截取其中一部分,才能得到正常的观看效果。
一、先分清三个概念
VR180、SBS 和立体显示经常一起出现,但它们描述的是不同的事情。
VR180 描述视野范围。 内容主要覆盖观察者前方的半球,不具备完整的后方视野。
SBS 描述双眼图像的排列方式。 左右眼画面横向拼接在同一视频帧中。它本身不决定图像采用什么投影,也不意味着内容一定是 VR180。
立体显示描述观看方式。 只有把左右眼画面分别交给观看者对应的眼睛,才能利用双眼视差产生立体感。
本文只取一只眼的图像。用户依然可以拖动视角、观察前方半球,但普通屏幕不会因此获得双眼立体效果。
另外,这种播放器只能改变观察方向,不能真正改变拍摄位置。拖动视角没有生成新的视点,也无法让用户绕到物体背后。
二、把二维图像映射到半球
图像坐标其实是角度
普通图片中的一个像素,表示平面上的一个位置。等距柱状投影中的一个像素,则可以解释为一个观察方向。
设单眼图像中的归一化坐标为:
u:从左到右,范围为[0, 1]。v:从上到下,范围为[0, 1]。
约定三维坐标系的 Y 轴朝上,相机默认朝向 −Z 方向。图像坐标对应的水平角和俯仰角为:
1 | yaw = (u - 0.5) × π |
图像横向从 −90° 延伸到 +90°,纵向从 +90° 延伸到 −90°。将角度转换成单位球面上的位置:
1 | x = sin(yaw) × cos(pitch) |
可以代入几个位置检查结果:
| 图像位置 | 对应方向 |
|---|---|
| 中心 | 正前方 (0, 0, -1) |
| 左边缘中点 | 左侧 (-1, 0, 0) |
| 右边缘中点 | 右侧 (1, 0, 0) |
| 上边缘 | 正上方 (0, 1, 0) |
这意味着我们可以创建一个半球,把视频图像贴在半球内侧,再把相机放到球心。相机拍到的局部画面,就是用户最终看到的透视图像。
SBS 视频只采样一半
前面的 u、v 描述的是单眼图像,但视频纹理包含两只眼。
对于左右等宽、左眼在左侧的 SBS 视频,左眼的横向纹理坐标为:
1 | textureU = u × 0.5 |
右眼则为:
1 | textureU = 0.5 + u × 0.5 |
在 Three.js 默认视频纹理设置下,我们可以使用下面的 UV:
1 | uvs.push(u * 0.5, 1 - v); |
这里的 1 - v 对齐了本文从上到下定义的图像坐标与纹理采样方向。如果修改了纹理的翻转设置,需要同步调整,不能重复翻转。
三、用 Three.js 搭建最小播放链路
整个渲染过程可以拆成五个部分:
1 | 视频元素 → 视频纹理 → 半球网格 → 透视相机 → Canvas |
浏览器负责视频加载和解码,Three.js 负责把解码后的画面作为纹理绘制到半球上。
创建半球网格
下面通过规则网格生成半球。每一个网格点都用前面的公式计算位置:
1 | import * as THREE from "three"; |
网格细分越多,曲面近似越精细,但顶点数量也会增加。这里的数值只是一个起点,可以使用直线、网格和高对比边缘素材检查投影效果,再决定是否需要提高细分。
创建视频纹理与场景
下面是基础渲染代码。假设页面中已经有一个 canvas,并且播放器容器具有非零尺寸:
1 | const video = document.createElement("video"); |
使用 MeshBasicMaterial,是因为视频已经包含了场景的亮度信息,不需要额外受三维光照影响。双面渲染则让位于球心的相机可以看到网格内侧。
播放操作应由用户点击触发,并处理 video.play() 返回的 Promise。浏览器可能阻止带声音的自动播放;加载成功和允许播放是两件不同的事。这里先使用持续渲染,让投影链路尽快跑通。后面再优化绘制时机。
正确处理尺寸变化
容器变化时,需要同时更新画布尺寸和相机宽高比:
1 | function resize(width: number, height: number) { |
可以通过 ResizeObserver 读取容器尺寸,并调用这个函数。注意,setSize() 会改变绘制缓冲区。按需渲染时,尺寸更新后必须安排一次重绘,否则可能暂时看到空白画布。
四、拖动改变的是相机朝向
实现拖动不需要移动半球,也不需要修改纹理。只要维护两个角度:
1 | const view = { |
然后根据它们计算观察方向:
1 | function updateCamera() { |
拖动时,将指针位移换算成角度变化。一种便于理解的起点是,让横向拖过整个容器的距离,对应一定比例的水平视野:
1 | 水平角度变化 ≈ 水平位移 / 容器宽度 × 水平 FOV |
这是一种交互近似,并不等同于严格的三维轨迹球计算。对于常见的视频预览,通常可以从这种方式开始调节手感。
建议统一使用 Pointer Events,并在拖动开始时调用 setPointerCapture(),避免指针离开画布后丢失后续事件。还需要处理 pointercancel,并根据页面滚动需求设置合适的 touch-action。
为什么不能简单限制在 ±90°?
VR180 只有前方半球。当相机接近边缘时,部分视口会看到没有内容的区域。
即使相机中心朝向还没有超过 90°,视口边缘也可能已经越界,因为相机本身具有一定的视野宽度。
在俯仰角为零时,可以用下面的关系理解水平边界:
1 | 最大水平转角 ≈ 90° - 水平 FOV / 2 |
水平 FOV 又取决于垂直 FOV 和画面宽高比:
1 | horizontalFov = |
实际同时存在水平和垂直旋转时,边界会更复杂。如果要求任何视口角落都不能越出半球,可以计算四个视口角点的观察射线,再检查它们是否仍朝向前方半球。
另一种合理选择是允许边缘露出少量黑色,以换取更自由的观察范围。关键在于明确交互目标,再设计限制。
五、让视频帧和交互共同驱动渲染
基础版本使用持续动画循环,容易理解,但它会在视频暂停、用户没有操作时继续绘制相同的画面。
要减少无意义工作,需要区分两件事:视频纹理是否发生变化,以及场景是否需要重新绘制。
| 事件 | 更新视频纹理 | 重绘场景 |
|---|---|---|
| 视频出现新帧 | 是 | 是 |
| 暂停时拖动视角 | 否 | 是 |
| 容器尺寸变化 | 否 | 是 |
| seek 后出现新画面 | 是 | 是 |
| 暂停且没有任何变化 | 否 | 否 |
浏览器提供的 requestVideoFrameCallback() 可以通知我们视频出现了新的呈现帧,比单纯根据显示器刷新率轮询更适合驱动视频画面更新。
对于不支持它的浏览器,可以在播放期间使用 requestAnimationFrame() 降级;暂停、seek 和恢复可见时,则通过相关事件安排必要的刷新。
合并同一帧内的多次更新
拖动、视频新帧和尺寸变化可能在短时间内同时发生。它们没有必要各自触发一次完整绘制。
可以先设置状态,再统一请求下一次绘制:
1 | let drawHandle: number | undefined; |
视频新帧、视角变化和尺寸变化都调用 requestDraw(),同一轮中的请求会被合并。此时应移除前面的持续动画循环:
1 | renderer.setAnimationLoop(null); |
小心视频纹理自己的更新机制
Three.js 的 VideoTexture 在支持的环境中也会使用视频帧回调。增加一个外部调度器,并不意味着纹理内部的更新机制自动消失。
可以分两步优化:
第一步,保留标准 VideoTexture,只减少场景绘制。这通常更容易维护。
第二步,如果确实需要完全控制后台或暂停时的回调,再接管视频纹理更新:只有新视频帧到达时才标记纹理需要更新,视角变化只触发重绘。
页面隐藏与异步回调
当页面隐藏时,可以停止视觉绘制;是否暂停视频或音频,则属于产品行为,需要单独决定。恢复可见后,应重新检查容器尺寸和当前视频帧,并请求一次绘制。销毁播放器时,除了取消回调,还要防止旧回调操作已失效的资源。一个常见办法是使用递增的版本号:
1 | let generation = 0; |
这只是版本检查的核心逻辑。完整实现仍需保存并取消回调句柄,并确保启动操作不会重复创建多条回调链。
六、清晰度由三种不同的分辨率决定
“把 Canvas 调小一点”只能解决一部分性能问题。VR180 播放中至少存在三个不同维度:
| 维度 | 主要影响 |
|---|---|
| 视频编码分辨率 | 解码成本、输入图像细节 |
| 视频纹理尺寸 | GPU 纹理能力与相关资源开销 |
| Canvas 绘制分辨率 | 场景绘制像素数量、输出清晰度 |
例如,一段左右等宽的 SBS 视频,即使我们只采样左眼,标准视频纹理仍然使用完整视频帧。UV 裁切不会自动让浏览器只解码半幅视频,也不会自动创建一张尺寸减半的纹理。
可以通过下面的能力值检查设备最大纹理尺寸:
1 | const maxTextureSize = renderer.capabilities.maxTextureSize; |
通过这个检查,也不代表设备一定能流畅解码该视频。编码格式、帧率、码率和硬件解码能力仍然会影响播放。
对 Canvas 而言,可以限制 DPR 或设置总像素预算。对于视频纹理尺寸或解码能力不足的情况,则应考虑切换到更低分辨率的视频源。
如果使用自适应流媒体,还需要注意:视频尺寸可能在播放过程中发生变化,检查不能只做在初始化时。
七、三个容易被忽略的浏览器问题
视频能播放,不代表能上传为 WebGL 纹理
跨域视频可能可以在普通 <video> 中播放,但不满足 Canvas/WebGL 使用的跨域要求。
设置:
1 | video.crossOrigin = "anonymous"; |
只完成了客户端部分,而且应该在设置 src 之前执行。视频服务器还需要返回适当的 CORS 响应头。
如果视频通过 HLS 加载,播放列表、分片及相关资源的跨域策略也需要正确配置。
定位问题时,可以尝试把视频当前帧绘制到一个小 Canvas,再调用 getImageData()。出现安全异常,能够帮助判断是否存在跨域读取限制;这个检查通过,也不能代替对 WebGL 能力和其他错误的检查。
“开始播放”不等于“第一帧已经可显示”
play() 成功、视频处于播放状态和画面已经成功绘制,是不同的时刻。
如果过早隐藏封面,用户可能先看到黑色画布。更稳妥的方式是保留封面,等到有效视频帧已经经过一次绘制后,再显示渲染层。
播放器状态最好区分:
1 | 资源加载中 → 视频帧可用 → 首帧已绘制 |
GPU 资源需要显式释放
移除 Canvas 不等于释放全部资源。播放器销毁时,应关闭回调和监听,并释放几何体、材质、纹理与渲染器:
1 | disposed = true; |
如果视频元素由播放器创建并独占,还应暂停视频、解除媒体源;使用 HLS 实例或 Object URL 时,也要销毁实例或撤销 URL。
注释 1 这段确实有些像内部验收清单,放在博客末尾会打断前面的技术叙述。可以把第八节和结语一起替换成下面这段,原来的“配图 5”也一并替换。
八、为什么视频分辨率很高,看起来还是不够清晰?
播放器运行起来之后,还会遇到一个直观的问题:视频明明有很高的分辨率,眼前的画面却没有想象中清晰。
原因之一是,**视频的总分辨率,并不等于当前视口能够获得的图像分辨率。**普通视频通常把整幅画面展示在播放器里,而 VR180 把大量像素分配给了整个前方半球。相机每次只观察其中一部分。
假设一段 SBS 视频的总宽度为 7680 像素,左右眼等宽,那么单眼图像宽度就是 3840 像素。这 3840 个像素覆盖了水平方向的 180°。
如果当前相机的水平视野为 90°,在朝向正前方时,可见区域横向大约覆盖:
1 | 3840 × 90° / 180° = 1920 像素 |
这个估算描述的是视口覆盖的源图像横向范围,并不意味着最终画面等同于一段 1920 像素宽的普通视频。透视投影会重新分配像素,编码质量、纹理过滤和观看方向也会影响实际清晰度。
但它解释了一个现象:一段看起来分辨率很高的 VR 视频,经过双眼拆分和局部取景后,留给当前视口的细节并没有那么多。
缩小视野,是放大已有细节
减小相机 FOV,会产生类似“拉近镜头”的效果:物体变大,画面显得更近。
但视频并没有因此提供新的细节。视口覆盖的角度越小,使用的源图像范围也越小;这些像素被放大到同样大小的 Canvas 上,更容易暴露模糊和压缩痕迹。
因此,初始 FOV 不只是一个交互参数。它同时决定了画面的空间感、物体大小,以及源图像细节被放大的程度。
提高 DPR,也有收益上限
提高 Canvas 的绘制分辨率,可以减少输出阶段的像素感,但无法恢复源视频中不存在的细节。
当视频细节已经成为瓶颈时,继续提高 DPR,可能只会增加绘制成本。反过来,即使视频本身足够清晰,过低的 Canvas 分辨率也会让输出变模糊。
调节清晰度时,可以按三个问题逐步判断:
- 当前视野内,源视频提供的细节是否足够?
- Canvas 的实际像素尺寸,是否足以呈现这些细节?
- 设备能否持续解码并绘制,而不出现明显掉帧?
这些因素共同决定最终观感。最高的视频分辨率、最大的 DPR 和最窄的 FOV,并不一定构成最好的组合。
从一张视频帧到一个可观察的空间
VR180 播放器做的事情,是把视频里的像素重新解释为空间中的方向。
半球映射决定画面是否自然,虚拟相机决定用户看到哪里,帧调度决定浏览器何时工作。理解了这三层关系,拖动、缩放、暂停和尺寸变化就都有了清楚的实现依据。
这种观看方式也有明确的边界:用户可以转头,却不能移动到新的拍摄位置;可以放大画面,却不能获得视频没有记录的细节;可以观察左右眼中的任意一幅,却只有在分别向双眼呈现时,才能得到立体视觉。
一个普通的网页窗口,就这样成为了观察拍摄现场的入口。它所呈现的范围,既由相机朝向决定,也由视频最初记录下来的信息决定。