把一段 VR180 视频直接放进 <video> 标签,通常会看到两幅并排、明显变形的画面。它能正常播放,却不像我们期待的那样,可以转动视角、自然地观察场景。

原因在于,这类视频保存的不是一个固定视角下的平面画面,而是一个覆盖半球的视野。播放器需要把二维图像重新映射到空间中,再通过虚拟相机截取其中一部分,才能得到正常的观看效果。

一、先分清三个概念

VR180、SBS 和立体显示经常一起出现,但它们描述的是不同的事情。

VR180 描述视野范围。 内容主要覆盖观察者前方的半球,不具备完整的后方视野。

SBS 描述双眼图像的排列方式。 左右眼画面横向拼接在同一视频帧中。它本身不决定图像采用什么投影,也不意味着内容一定是 VR180。

立体显示描述观看方式。 只有把左右眼画面分别交给观看者对应的眼睛,才能利用双眼视差产生立体感。

本文只取一只眼的图像。用户依然可以拖动视角、观察前方半球,但普通屏幕不会因此获得双眼立体效果。

另外,这种播放器只能改变观察方向,不能真正改变拍摄位置。拖动视角没有生成新的视点,也无法让用户绕到物体背后。

二、把二维图像映射到半球

图像坐标其实是角度

普通图片中的一个像素,表示平面上的一个位置。等距柱状投影中的一个像素,则可以解释为一个观察方向。

设单眼图像中的归一化坐标为:

  • u:从左到右,范围为 [0, 1]。
  • v:从上到下,范围为 [0, 1]。

约定三维坐标系的 Y 轴朝上,相机默认朝向 −Z 方向。图像坐标对应的水平角和俯仰角为:

1
2
yaw   = (u - 0.5) × π
pitch = (0.5 - v) × π

图像横向从 −90° 延伸到 +90°,纵向从 +90° 延伸到 −90°。将角度转换成单位球面上的位置:

1
2
3
x = sin(yaw) × cos(pitch)
y = sin(pitch)
z = -cos(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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import * as THREE from "three";

function createHemisphere(columns = 64, rows = 48) {
const positions: number[] = [];
const uvs: number[] = [];
const indices: number[] = [];

for (let row = 0; row <= rows; row++) {
const v = row / rows;
const pitch = (0.5 - v) * Math.PI;

for (let column = 0; column <= columns; column++) {
const u = column / columns;
const yaw = (u - 0.5) * Math.PI;

positions.push(
Math.sin(yaw) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(yaw) * Math.cos(pitch)
);

// 只采样 SBS 视频的左眼画面。
uvs.push(u * 0.5, 1 - v);

if (row < rows && column < columns) {
const a = row * (columns + 1) + column;
const b = a + columns + 1;

indices.push(a, b, a + 1);
indices.push(b, b + 1, a + 1);
}
}
}

const geometry = new THREE.BufferGeometry();

geometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(positions, 3)
);
geometry.setAttribute(
"uv",
new THREE.Float32BufferAttribute(uvs, 2)
);
geometry.setIndex(indices);

return geometry;
}

网格细分越多,曲面近似越精细,但顶点数量也会增加。这里的数值只是一个起点,可以使用直线、网格和高对比边缘素材检查投影效果,再决定是否需要提高细分。

创建视频纹理与场景

下面是基础渲染代码。假设页面中已经有一个 canvas,并且播放器容器具有非零尺寸:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
const video = document.createElement("video");

video.crossOrigin = "anonymous";
video.playsInline = true;
video.preload = "metadata";
video.src = "/videos/sample-vr180.mp4";

const renderer = new THREE.WebGLRenderer({
canvas,
antialias: false,
});

renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setClearColor(0x000000);

const scene = new THREE.Scene();

const camera = new THREE.PerspectiveCamera(70, 1, 0.1, 10);
camera.position.set(0, 0, 0);

const texture = new THREE.VideoTexture(video);

// 本例假设使用常见的 SDR 视频素材。
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;

const geometry = createHemisphere();

const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.DoubleSide,
toneMapped: false,
});

scene.add(new THREE.Mesh(geometry, material));

renderer.setAnimationLoop(() => {
if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
renderer.render(scene, camera);
}
});

使用 MeshBasicMaterial,是因为视频已经包含了场景的亮度信息,不需要额外受三维光照影响。双面渲染则让位于球心的相机可以看到网格内侧。

播放操作应由用户点击触发,并处理 video.play() 返回的 Promise。浏览器可能阻止带声音的自动播放;加载成功和允许播放是两件不同的事。这里先使用持续渲染,让投影链路尽快跑通。后面再优化绘制时机。

正确处理尺寸变化

容器变化时,需要同时更新画布尺寸和相机宽高比:

1
2
3
4
5
6
7
8
9
function resize(width: number, height: number) {
if (width <= 0 || height <= 0) return;

renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
renderer.setSize(width, height, false);

camera.aspect = width / height;
camera.updateProjectionMatrix();
}

可以通过 ResizeObserver 读取容器尺寸,并调用这个函数。注意,setSize() 会改变绘制缓冲区。按需渲染时,尺寸更新后必须安排一次重绘,否则可能暂时看到空白画布。

四、拖动改变的是相机朝向

实现拖动不需要移动半球,也不需要修改纹理。只要维护两个角度:

1
2
3
4
const view = {
yaw: 0,
pitch: 0,
};

然后根据它们计算观察方向:

1
2
3
4
5
6
7
8
9
10
function updateCamera() {
const yaw = THREE.MathUtils.degToRad(view.yaw);
const pitch = THREE.MathUtils.degToRad(view.pitch);

camera.lookAt(
Math.sin(yaw) * Math.cos(pitch),
Math.sin(pitch),
-Math.cos(yaw) * Math.cos(pitch)
);
}

拖动时,将指针位移换算成角度变化。一种便于理解的起点是,让横向拖过整个容器的距离,对应一定比例的水平视野:

1
水平角度变化 ≈ 水平位移 / 容器宽度 × 水平 FOV

这是一种交互近似,并不等同于严格的三维轨迹球计算。对于常见的视频预览,通常可以从这种方式开始调节手感。

建议统一使用 Pointer Events,并在拖动开始时调用 setPointerCapture(),避免指针离开画布后丢失后续事件。还需要处理 pointercancel,并根据页面滚动需求设置合适的 touch-action。

为什么不能简单限制在 ±90°?

VR180 只有前方半球。当相机接近边缘时,部分视口会看到没有内容的区域。

即使相机中心朝向还没有超过 90°,视口边缘也可能已经越界,因为相机本身具有一定的视野宽度。

在俯仰角为零时,可以用下面的关系理解水平边界:

1
最大水平转角 ≈ 90° - 水平 FOV / 2

水平 FOV 又取决于垂直 FOV 和画面宽高比:

1
2
horizontalFov =
2 × atan(tan(verticalFov / 2) × aspect)

实际同时存在水平和垂直旋转时,边界会更复杂。如果要求任何视口角落都不能越出半球,可以计算四个视口角点的观察射线,再检查它们是否仍朝向前方半球。

另一种合理选择是允许边缘露出少量黑色,以换取更自由的观察范围。关键在于明确交互目标,再设计限制。

五、让视频帧和交互共同驱动渲染

基础版本使用持续动画循环,容易理解,但它会在视频暂停、用户没有操作时继续绘制相同的画面。

要减少无意义工作,需要区分两件事:视频纹理是否发生变化,以及场景是否需要重新绘制。

事件 更新视频纹理 重绘场景
视频出现新帧 是 是
暂停时拖动视角 否 是
容器尺寸变化 否 是
seek 后出现新画面 是 是
暂停且没有任何变化 否 否

浏览器提供的 requestVideoFrameCallback() 可以通知我们视频出现了新的呈现帧,比单纯根据显示器刷新率轮询更适合驱动视频画面更新。

对于不支持它的浏览器,可以在播放期间使用 requestAnimationFrame() 降级;暂停、seek 和恢复可见时,则通过相关事件安排必要的刷新。

合并同一帧内的多次更新

拖动、视频新帧和尺寸变化可能在短时间内同时发生。它们没有必要各自触发一次完整绘制。

可以先设置状态,再统一请求下一次绘制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let drawHandle: number | undefined;
let disposed = false;

function requestDraw() {
if (disposed || document.hidden || drawHandle !== undefined) return;

drawHandle = requestAnimationFrame(() => {
drawHandle = undefined;

if (disposed || document.hidden) return;
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) return;

renderer.render(scene, camera);
});
}

视频新帧、视角变化和尺寸变化都调用 requestDraw(),同一轮中的请求会被合并。此时应移除前面的持续动画循环:

1
renderer.setAnimationLoop(null);

小心视频纹理自己的更新机制

Three.js 的 VideoTexture 在支持的环境中也会使用视频帧回调。增加一个外部调度器,并不意味着纹理内部的更新机制自动消失。

可以分两步优化:

第一步,保留标准 VideoTexture,只减少场景绘制。这通常更容易维护。

第二步,如果确实需要完全控制后台或暂停时的回调,再接管视频纹理更新:只有新视频帧到达时才标记纹理需要更新,视角变化只触发重绘。

页面隐藏与异步回调

当页面隐藏时,可以停止视觉绘制;是否暂停视频或音频,则属于产品行为,需要单独决定。恢复可见后,应重新检查容器尺寸和当前视频帧,并请求一次绘制。销毁播放器时,除了取消回调,还要防止旧回调操作已失效的资源。一个常见办法是使用递增的版本号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let generation = 0;

function invalidateCallbacks() {
generation++;
}

function scheduleVideoFrame() {
const currentGeneration = generation;

video.requestVideoFrameCallback(() => {
if (disposed || currentGeneration !== generation) return;

requestDraw();
scheduleVideoFrame();
});
}

这只是版本检查的核心逻辑。完整实现仍需保存并取消回调句柄,并确保启动操作不会重复创建多条回调链。

六、清晰度由三种不同的分辨率决定

“把 Canvas 调小一点”只能解决一部分性能问题。VR180 播放中至少存在三个不同维度:

维度 主要影响
视频编码分辨率 解码成本、输入图像细节
视频纹理尺寸 GPU 纹理能力与相关资源开销
Canvas 绘制分辨率 场景绘制像素数量、输出清晰度

例如,一段左右等宽的 SBS 视频,即使我们只采样左眼,标准视频纹理仍然使用完整视频帧。UV 裁切不会自动让浏览器只解码半幅视频,也不会自动创建一张尺寸减半的纹理。

可以通过下面的能力值检查设备最大纹理尺寸:

1
2
3
4
5
const maxTextureSize = renderer.capabilities.maxTextureSize;

if (Math.max(video.videoWidth, video.videoHeight) > maxTextureSize) {
// 选择更低分辨率的视频源,或提示当前设备无法支持。
}

通过这个检查,也不代表设备一定能流畅解码该视频。编码格式、帧率、码率和硬件解码能力仍然会影响播放。

对 Canvas 而言,可以限制 DPR 或设置总像素预算。对于视频纹理尺寸或解码能力不足的情况,则应考虑切换到更低分辨率的视频源。

如果使用自适应流媒体,还需要注意:视频尺寸可能在播放过程中发生变化,检查不能只做在初始化时。

七、三个容易被忽略的浏览器问题

视频能播放,不代表能上传为 WebGL 纹理

跨域视频可能可以在普通 <video> 中播放,但不满足 Canvas/WebGL 使用的跨域要求。

设置:

1
video.crossOrigin = "anonymous";

只完成了客户端部分,而且应该在设置 src 之前执行。视频服务器还需要返回适当的 CORS 响应头。

如果视频通过 HLS 加载,播放列表、分片及相关资源的跨域策略也需要正确配置。

定位问题时,可以尝试把视频当前帧绘制到一个小 Canvas,再调用 getImageData()。出现安全异常,能够帮助判断是否存在跨域读取限制;这个检查通过,也不能代替对 WebGL 能力和其他错误的检查。

“开始播放”不等于“第一帧已经可显示”

play() 成功、视频处于播放状态和画面已经成功绘制,是不同的时刻。

如果过早隐藏封面,用户可能先看到黑色画布。更稳妥的方式是保留封面,等到有效视频帧已经经过一次绘制后,再显示渲染层。

播放器状态最好区分:

1
资源加载中 → 视频帧可用 → 首帧已绘制

GPU 资源需要显式释放

移除 Canvas 不等于释放全部资源。播放器销毁时,应关闭回调和监听,并释放几何体、材质、纹理与渲染器:

1
2
3
4
5
6
7
8
9
10
11
12
disposed = true;

if (drawHandle !== undefined) {
cancelAnimationFrame(drawHandle);
}

renderer.setAnimationLoop(null);

geometry.dispose();
material.dispose();
texture.dispose();
renderer.dispose();

如果视频元素由播放器创建并独占,还应暂停视频、解除媒体源;使用 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 播放器做的事情,是把视频里的像素重新解释为空间中的方向。

半球映射决定画面是否自然,虚拟相机决定用户看到哪里,帧调度决定浏览器何时工作。理解了这三层关系,拖动、缩放、暂停和尺寸变化就都有了清楚的实现依据。

这种观看方式也有明确的边界:用户可以转头,却不能移动到新的拍摄位置;可以放大画面,却不能获得视频没有记录的细节;可以观察左右眼中的任意一幅,却只有在分别向双眼呈现时,才能得到立体视觉。

一个普通的网页窗口,就这样成为了观察拍摄现场的入口。它所呈现的范围,既由相机朝向决定,也由视频最初记录下来的信息决定。

站内搜索

没有找到内容!