添加VR360播放功能

This commit is contained in:
truewhile
2026-09-17 00:31:45 +08:00
parent 93866d169b
commit 1153fe4191
8 changed files with 1696 additions and 10 deletions
+162
View File
@@ -0,0 +1,162 @@
import {
detectVr360Profile,
normalizeVr360Profile,
sameVr360Profile,
vr360FrameUv,
type Vr360Profile,
} from './vr360.ts'
import {
applyDirection,
applyViewOffset,
cameraForward,
cameraFromDeviceOrientation,
cameraFromYawPitch,
forwardPitch,
forwardYaw,
multiply3,
perspectiveMatrix,
rotationY,
rotationZ,
transpose3,
viewMatrixFromCamera,
} from './vr360Math.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`vr360: ${name}`)
}
function close(a: number, b: number, tolerance = 1e-6): boolean {
return Math.abs(a - b) <= tolerance
}
function closeAll(a: number[], b: number[], tolerance = 1e-6): boolean {
return a.length === b.length && a.every((value, index) => close(value, b[index], tolerance))
}
function profileOf(profile: Vr360Profile): string {
return `${profile.projection}/${profile.stereo}`
}
// ---------- 命名/画幅识别 ----------
const plain = detectVr360Profile({ title: '普通电影', path: '/media/电影/movie.mkv', width: 1920, height: 1080 })
check('普通素材不自动进入 VR', !plain.confident)
check('普通素材默认 360 单眼', profileOf(plain.profile) === 'equirect360/mono')
const pano360 = detectVr360Profile({
title: '全景演示',
path: '/media/360/equirect_demo.mp4',
width: 3840,
height: 1920,
})
check('360 关键词可识别', pano360.confident && pano360.profile.projection === 'equirect360')
check('2:1 的 360 保持单眼', pano360.profile.stereo === 'mono')
const vr360Sbs = detectVr360Profile({ path: '/media/vr/vr360-sbs-8k.mp4', width: 7680, height: 1920 })
check('4:1 画幅识别为左右并排', vr360Sbs.profile.stereo === 'sbs')
const ou360 = detectVr360Profile({ path: '/media/vr/pano360-ou.mp4', width: 3840, height: 3840 })
check('1:1 的 360 识别为上下并排', ou360.profile.stereo === 'ou')
const fisheye = detectVr360Profile({ path: '/media/vr/abc-fisheye-180.mp4', width: 1920, height: 1920 })
check('鱼眼关键词优先于其它信号', fisheye.profile.projection === 'fisheye180')
const vr180Sbs = detectVr360Profile({
path: '/media/云下载/sivr-270/sivr-270-1.mp4',
width: 3840,
height: 1920,
})
check('编号里的 vr 可识别为 VR180', vr180Sbs.confident && vr180Sbs.profile.projection === 'equirect180')
check('VR180 的 2:1 画幅为左右并排', vr180Sbs.profile.stereo === 'sbs')
const vr180Mono = detectVr360Profile({ path: '/media/vr/vr180-mono.mp4', width: 1920, height: 1920 })
check('VR180 的 1:1 画幅为单眼', vr180Mono.profile.stereo === 'mono')
const vr180Ou = detectVr360Profile({ path: '/media/vr/vr180-tb.mp4', width: 1920, height: 3840 })
check('VR180 的 1:2 画幅为上下并排', vr180Ou.profile.stereo === 'ou')
const topBottom = detectVr360Profile({ path: '/media/vr/vr360-top-bottom.mp4' })
check('top-bottom 关键词为上下并排', topBottom.profile.stereo === 'ou')
// ---------- 立体裁切与偏好归一化 ----------
check('单眼不裁切', closeAll(vr360FrameUv('mono').scale, [1, 1]))
check('左右并排取左半张', closeAll(vr360FrameUv('sbs').scale, [0.5, 1]))
check('上下并排取上半张', closeAll(vr360FrameUv('ou').scale, [1, 0.5]))
check('并排画面不做偏移', closeAll(vr360FrameUv('sbs').offset, [0, 0]))
check('未知投影回退到 360 全景', normalizeVr360Profile({ projection: 'nope', stereo: 'nope' }).projection === 'equirect360')
check('未知布局回退到单眼', normalizeVr360Profile({}).stereo === 'mono')
check(
'相同配置判定相等',
sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'sbs' }),
)
check(
'不同配置判定不等',
!sameVr360Profile({ projection: 'equirect180', stereo: 'sbs' }, { projection: 'equirect180', stereo: 'ou' }),
)
// ---------- 相机姿态与设备陀螺仪 ----------
const identityForward = cameraForward(new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]))
check('单位姿态朝向地面(设备平放)', closeAll([...identityForward], [0, 0, -1]))
const level = cameraFromYawPitch(0, 0)
check('默认视角水平看向正前方', closeAll([...cameraForward(level)], [0, 1, 0]))
const east = cameraFromYawPitch(Math.PI / 2, 0)
check('方位角 90° 转向正东', closeAll([...cameraForward(east)], [1, 0, 0], 1e-6))
const up = cameraFromYawPitch(0, Math.PI / 2)
check('俯仰角 90° 抬头看天', closeAll([...cameraForward(up)], [0, 0, 1], 1e-6))
const northEast = cameraFromYawPitch(Math.PI / 4, 0)
check('方位角 45° 落在东北方向', close(forwardYaw(cameraForward(northEast)), Math.PI / 4))
const tilted = cameraFromYawPitch(0.3, -0.4)
check('朝向角可反解出方位角', close(forwardYaw(cameraForward(tilted)), 0.3, 1e-6))
check('朝向角可反解出俯仰角', close(forwardPitch(cameraForward(tilted)), -0.4, 1e-6))
check(
'旋转矩阵转置等于反向旋转',
closeAll([...transpose3(rotationY(0.7))], [...rotationY(-0.7)], 1e-6),
)
check(
'正反旋转相乘为单位矩阵',
closeAll(
[...multiply3(rotationZ(1.1), rotationZ(-1.1))],
[1, 0, 0, 0, 1, 0, 0, 0, 1],
1e-6,
),
)
// 视图矩阵 = 相机姿态的逆(纯旋转)
const view = viewMatrixFromCamera(east)
check('视图矩阵为相机矩阵的转置', closeAll([...view], [...transpose3(east)], 1e-6))
// 设备姿态约定(与 W3C 规范一致):相机看向设备背面,
// 因此设备平放时(alpha 任意、beta = 0)视线朝下。
const flat = cameraFromDeviceOrientation(Math.PI / 3, 0, 0, 0)
check('设备平放时看向地面', closeAll([...cameraForward(flat)], [0, 0, -1], 1e-6))
// 竖持手机(beta = 90°)时视线水平
const portrait = cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0)
check('竖持手机时视线水平', close(cameraForward(portrait)[2], 0, 1e-6))
check('竖持手机时视线朝北', close(forwardYaw(cameraForward(portrait)), 0, 1e-6))
// 横屏(屏幕旋转 90°)时相机「上方向」跟随屏幕
const landscape = cameraFromDeviceOrientation(0, Math.PI / 2, 0, Math.PI / 2)
check('横屏时画面随屏幕转向', closeAll([...applyDirection(landscape, 0, 1, 0)], [1, 0, 0], 1e-6))
// 陀螺仪开启瞬间的视角对齐:叠加偏移后朝向应等于对齐前记录的拖拽视角
const aligned = applyViewOffset(cameraFromDeviceOrientation(0, Math.PI / 2, 0, 0), 0.5, 0.25)
check('叠加偏移后方位角对齐', close(forwardYaw(cameraForward(aligned)), 0.5, 1e-6))
check('叠加偏移后俯仰角对齐', close(forwardPitch(cameraForward(aligned)), 0.25, 1e-6))
// ---------- 透视投影 ----------
const projection = perspectiveMatrix(Math.PI / 2, 2, 0.1, 10)
check('透视矩阵缩放项随视场角变化', close(projection[0], 0.5))
check('透视矩阵写入 w = -1(右手系)', projection[11] === -1)
console.log('vr360.test.ts: ok')
+229
View File
@@ -0,0 +1,229 @@
// VR360(全景)播放的配置模型、命名启发式识别与本地偏好。
//
// 网页端只能从文件名/路径和画幅比例推断「这段视频是不是全景/VR」,任何信号
// 都不绝对可靠,所以这里只负责给出「大概率正确」的初始配置:识别结果不写入
// 数据库,用户随时可以在播放器菜单里改投影方式与立体布局,改动只影响本机。
export type Vr360Projection = 'equirect360' | 'equirect180' | 'fisheye180'
export type Vr360Stereo = 'mono' | 'sbs' | 'ou'
export type Vr360Profile = {
/** 画面投影方式:360 等距柱状、180 等距柱状、180 鱼眼。 */
projection: Vr360Projection
/** 立体布局:单眼、左右并排、上下并排。 */
stereo: Vr360Stereo
}
export const DEFAULT_VR360_PROFILE: Vr360Profile = {
projection: 'equirect360',
stereo: 'mono',
}
/** 视场角可调范围(度):数值越小画面越「拉近」。 */
export const VR360_MIN_FOV = 35
export const VR360_MAX_FOV = 110
export const VR360_DEFAULT_FOV = 75
export const VR360_PROJECTION_OPTIONS: Array<[Vr360Projection, string]> = [
['equirect360', '360° 全景'],
['equirect180', '180° 半景'],
['fisheye180', '180° 鱼眼'],
]
export const VR360_STEREO_OPTIONS: Array<[Vr360Stereo, string]> = [
['mono', '单眼'],
['sbs', '左右并排'],
['ou', '上下并排'],
]
export function vr360ProjectionLabel(projection: Vr360Projection): string {
return VR360_PROJECTION_OPTIONS.find(([value]) => value === projection)?.[1] ?? '360° 全景'
}
export function vr360StereoLabel(stereo: Vr360Stereo): string {
return VR360_STEREO_OPTIONS.find(([value]) => value === stereo)?.[1] ?? '单眼'
}
export function normalizeVr360Projection(value: unknown): Vr360Projection {
return value === 'equirect180' || value === 'fisheye180' ? value : 'equirect360'
}
export function normalizeVr360Stereo(value: unknown): Vr360Stereo {
return value === 'sbs' || value === 'ou' ? value : 'mono'
}
export function normalizeVr360Profile(value: unknown): Vr360Profile {
const profile = (value ?? {}) as Partial<Vr360Profile>
return {
projection: normalizeVr360Projection(profile.projection),
stereo: normalizeVr360Stereo(profile.stereo),
}
}
export function sameVr360Profile(a: Vr360Profile, b: Vr360Profile): boolean {
return a.projection === b.projection && a.stereo === b.stereo
}
// 立体素材(左右/上下并排)在普通屏幕上只取左眼画面:真正的双眼渲染需要
// WebXR 头显,网页里把两半都画出来只会得到重复画面。
export function vr360FrameUv(stereo: Vr360Stereo): {
scale: [number, number]
offset: [number, number]
} {
if (stereo === 'sbs') return { scale: [0.5, 1], offset: [0, 0] }
if (stereo === 'ou') return { scale: [1, 0.5], offset: [0, 0] }
return { scale: [1, 1], offset: [0, 0] }
}
export type Vr360DetectInput = {
title?: string
originalName?: string
path?: string
relativePath?: string
/** 视频画面宽度(媒体元数据或 ffprobe 结果)。 */
width?: number
/** 视频画面高度。 */
height?: number
}
export type Vr360Detection = {
profile: Vr360Profile
/** 文件名/路径里出现了明确的 VR 或全景关键词,可以放心自动进入 VR 模式。 */
confident: boolean
}
// 关键词分两类:明确的关键词按子串匹配("vr360" 里也含 "360"),
// 容易误伤的两字母缩写按独立词元匹配(避免 "1ou" / "sivr" 之类误判)。
const PANORAMA_SUBSTRINGS = ['360', 'equirect', 'insta360', 'pano', 'vuze', 'theta', '全景']
const FISHEYE_SUBSTRINGS = ['fisheye', '鱼眼']
const VR180_SUBSTRINGS = ['vr180', '180vr', '180°', '180度', '半景']
const SBS_SUBSTRINGS = ['side-by-side', 'sidebyside', 'side_by_side', '左右']
const OU_SUBSTRINGS = ['over-under', 'top-bottom', 'topbottom', '上下']
const VR_TOKENS = ['vr']
const SBS_TOKENS = ['sbs', 'hsbs', 'lr']
const OU_TOKENS = ['ou', 'tb', 'vou', 'tou']
function detectText(input: Vr360DetectInput): string {
return [input.path, input.relativePath, input.originalName, input.title]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLowerCase()
}
// 文件名比标题可靠:标题里可能出现「VR」之类的普通单词,文件名里的 "vr" 通常
// 是作品编号的一部分(如 sivr-270、ipvr00192)。
function detectNameText(input: Vr360DetectInput): string {
return [input.path, input.relativePath, input.originalName]
.filter((value): value is string => Boolean(value))
.join(' ')
.toLowerCase()
}
function detectTokens(text: string): Set<string> {
return new Set(text.split(/[^a-z0-9]+/).filter(Boolean))
}
function hasSubstring(text: string, needles: string[]): boolean {
return needles.some((needle) => text.includes(needle))
}
function hasToken(tokens: Set<string>, needles: string[]): boolean {
return needles.some((needle) => tokens.has(needle))
}
function aspectRatio(input: Vr360DetectInput): number | null {
const width = input.width ?? 0
const height = input.height ?? 0
if (width <= 0 || height <= 0) return null
const ratio = width / height
return Number.isFinite(ratio) && ratio > 0 ? ratio : null
}
/**
* 依据文件名/路径关键词与画幅比例推断 VR 配置。
*
* 关键词比画幅可靠:2:1 既可能是 360 单眼,也可能是 180 左右并排,所以比例只
* 在没有明确关键词时补默认值(4:1 ⇒ 左右并排;约 1:1 的 360 ⇒ 上下并排)。
*/
export function detectVr360Profile(input: Vr360DetectInput): Vr360Detection {
const text = detectText(input)
const nameText = detectNameText(input)
const tokens = detectTokens(text)
const fisheye = hasSubstring(text, FISHEYE_SUBSTRINGS)
const vr180 = hasSubstring(text, VR180_SUBSTRINGS)
const panorama = hasSubstring(text, PANORAMA_SUBSTRINGS)
const vrToken = hasToken(tokens, VR_TOKENS) || nameText.includes('vr')
const confident = fisheye || vr180 || panorama || vrToken
let projection: Vr360Projection = DEFAULT_VR360_PROFILE.projection
if (fisheye) projection = 'fisheye180'
else if (vr180) projection = 'equirect180'
// 只有 "VR" 一个信号、没有 360/全景关键词时,按更常见的 VR180 处理。
else if (vrToken && !panorama) projection = 'equirect180'
const sbs = hasSubstring(text, SBS_SUBSTRINGS) || hasToken(tokens, SBS_TOKENS)
const ou = hasSubstring(text, OU_SUBSTRINGS) || hasToken(tokens, OU_TOKENS)
let stereo: Vr360Stereo = 'mono'
if (sbs !== ou) stereo = sbs ? 'sbs' : 'ou'
const ratio = aspectRatio(input)
if (ratio !== null && stereo === 'mono') {
if (projection === 'equirect360') {
// 4:1 基本只可能是 360 左右并排(如 7680x1920);
// 约 1:1 的 360 素材多为上下并排(如 3840x3840)。
if (ratio >= 3.4) stereo = 'sbs'
else if (ratio <= 1.05) stereo = 'ou'
} else {
// VR180 单眼是 1:1,左右并排是 2:1,上下并排是 1:2。
if (ratio >= 1.8 && ratio <= 2.2) stereo = 'sbs'
else if (ratio <= 0.6) stereo = 'ou'
}
}
return { profile: { projection, stereo }, confident }
}
const VR360_PREF_KEY = 'mebox.player.vr360'
export type Vr360Preference = {
/** 是否按文件名/画幅自动进入 VR 模式;用户可以关掉,避免误判干扰正常播放。 */
autoDetect: boolean
/** 用户上次选定的投影与布局,作为下次手动进入 VR 模式时的默认值。 */
profile: Vr360Profile
}
export const DEFAULT_VR360_PREFERENCE: Vr360Preference = {
autoDetect: true,
profile: DEFAULT_VR360_PROFILE,
}
export function loadVr360Preference(): Vr360Preference {
if (typeof window === 'undefined') return DEFAULT_VR360_PREFERENCE
try {
const raw = window.localStorage.getItem(VR360_PREF_KEY)
if (!raw) return DEFAULT_VR360_PREFERENCE
const parsed = JSON.parse(raw) as Partial<Vr360Preference>
return {
autoDetect: parsed.autoDetect !== false,
profile: normalizeVr360Profile(parsed.profile),
}
} catch {
return DEFAULT_VR360_PREFERENCE
}
}
export function saveVr360Preference(preference: Vr360Preference): void {
try {
window.localStorage.setItem(
VR360_PREF_KEY,
JSON.stringify({
autoDetect: preference.autoDetect !== false,
profile: normalizeVr360Profile(preference.profile),
}),
)
} catch {
// 隐私模式下 localStorage 不可用;播放器仍可正常使用 VR 模式。
}
}
+154
View File
@@ -0,0 +1,154 @@
// VR360 播放用到的纯数学:3x3 旋转矩阵、设备姿态到相机姿态、透视投影。
//
// 坐标约定(与 W3C DeviceOrientation 规范一致,右手系):
// x = 东(设备右侧),y = 北(设备顶部),z = 天空(设备屏幕朝外)
// 相机看向相机局部坐标的 -z,设备平放时 -z 指向地面,因此设备平放时视角朝下,
// 竖持手机(beta=90°)时视角水平向前 —— 与手机陀螺仪的实际手感一致。
//
// 所有 3x3 矩阵都是 WebGL 默认的列主序(column-major)Float32Array(9),
// 可以直接交给 gl.uniformMatrix3fv,不需要转置。
export type Mat3 = Float32Array
export function degToRad(degrees: number): number {
return (degrees * Math.PI) / 180
}
export function clampNumber(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min
return Math.min(max, Math.max(min, value))
}
export function createMat3(
m00: number, m10: number, m20: number,
m01: number, m11: number, m21: number,
m02: number, m12: number, m22: number,
): Mat3 {
// WebGL 列主序内存布局:[col0, col1, col2]
return new Float32Array([m00, m10, m20, m01, m11, m21, m02, m12, m22])
}
export function identity3(): Mat3 {
return createMat3(1, 0, 0, 0, 1, 0, 0, 0, 1)
}
export function rotationX(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(1, 0, 0, 0, c, s, 0, -s, c)
}
export function rotationY(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(c, 0, -s, 0, 1, 0, s, 0, c)
}
export function rotationZ(radians: number): Mat3 {
const c = Math.cos(radians)
const s = Math.sin(radians)
return createMat3(c, s, 0, -s, c, 0, 0, 0, 1)
}
/** 先施加 b,再施加 a(即矩阵乘积 a·b)。 */
export function multiply3(a: Mat3, b: Mat3): Mat3 {
const out = new Float32Array(9)
for (let col = 0; col < 3; col++) {
for (let row = 0; row < 3; row++) {
let sum = 0
for (let k = 0; k < 3; k++) {
sum += a[k * 3 + row] * b[col * 3 + k]
}
out[col * 3 + row] = sum
}
}
return out
}
/** 旋转矩阵的逆等于转置。 */
export function transpose3(m: Mat3): Mat3 {
const out = new Float32Array(9)
for (let col = 0; col < 3; col++) {
for (let row = 0; row < 3; row++) {
out[col * 3 + row] = m[row * 3 + col]
}
}
return out
}
export function applyDirection(m: Mat3, x: number, y: number, z: number): [number, number, number] {
return [
m[0] * x + m[3] * y + m[6] * z,
m[1] * x + m[4] * y + m[7] * z,
m[2] * x + m[5] * y + m[8] * z,
]
}
/** 相机在世界坐标中的朝向(相机局部 -z)。 */
export function cameraForward(camera: Mat3): [number, number, number] {
const forward = applyDirection(camera, 0, 0, -1)
const length = Math.hypot(forward[0], forward[1], forward[2]) || 1
return [forward[0] / length, forward[1] / length, forward[2] / length]
}
/** 相机朝向的水平方位角(0 = +y 即正北,顺时针转向 +x 即正东)。 */
export function forwardYaw(forward: [number, number, number]): number {
return Math.atan2(forward[0], forward[1])
}
/** 相机朝向的俯仰角(+ 抬头,- 低头)。 */
export function forwardPitch(forward: [number, number, number]): number {
return Math.asin(clampNumber(forward[2], -1, 1))
}
/**
* 由「用户拖拽/默认视角」的方位角与俯仰角构造相机姿态。
* 默认(yaw = 0, pitch = 0)时相机水平看向 +y,画面中的地平线保持水平。
*/
export function cameraFromYawPitch(yaw: number, pitch: number): Mat3 {
return multiply3(rotationZ(-yaw), rotationX(Math.PI / 2 + pitch))
}
/**
* 由设备姿态构造相机姿态(W3C 规范:R = Rz(α)·Rx(β)·Ry(γ)),
* 再按屏幕旋转角做一次横滚校正,使画面正向与屏幕正向一致。
*/
export function cameraFromDeviceOrientation(
alpha: number,
beta: number,
gamma: number,
screenAngle: number,
): Mat3 {
const device = multiply3(multiply3(rotationZ(alpha), rotationX(beta)), rotationY(gamma))
return multiply3(device, rotationZ(-screenAngle))
}
/**
* 在设备姿态之上叠加用户拖拽产生的视角偏移:水平方向围绕世界 z 轴旋转,
* 垂直方向围绕相机自身的右轴倾斜(小角度下等效于抬头/低头)。
*/
export function applyViewOffset(camera: Mat3, yawOffset: number, pitchOffset: number): Mat3 {
return multiply3(rotationZ(-yawOffset), multiply3(camera, rotationX(pitchOffset)))
}
/** 世界坐标 → 相机坐标的视图矩阵(纯旋转,所以就是转置)。 */
export function viewMatrixFromCamera(camera: Mat3): Mat3 {
return transpose3(camera)
}
/** 标准透视投影矩阵(列主序 4x4)。 */
export function perspectiveMatrix(
fovY: number,
aspect: number,
near: number,
far: number,
): Float32Array {
const f = 1 / Math.tan(fovY / 2)
const rangeInv = 1 / (near - far)
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * rangeInv, -1,
0, 0, 2 * far * near * rangeInv, 0,
])
}
+365
View File
@@ -0,0 +1,365 @@
// 用一段 WebGL 把 <video> 贴到球面/半球面上,实现 360°/180° 全景播放。
//
// 不引入 three.js:整个渲染器只是一段着色器 + 一个网格 + 一张视频纹理,依赖
// 极小,同时也不会把播放页首屏体积撑大(该模块由播放器按需懒加载)。
//
// 视频元素始终是同一个正在播放的 <video>,这里只把它当前的帧采样到球面上,
// 因此 HLS、直连、115 云转码等所有播放方式都能直接复用。
import type { Mat3 } from './vr360Math'
import { perspectiveMatrix } from './vr360Math'
import {
VR360_DEFAULT_FOV,
VR360_MAX_FOV,
VR360_MIN_FOV,
type Vr360Projection,
} from './vr360'
export type Vr360Renderer = {
setProjection(projection: Vr360Projection): void
/** 立体素材裁切:只取左眼所在的半张画面。 */
setFrameUv(scale: [number, number], offset: [number, number]): void
setView(view: Mat3): void
setFov(fovDeg: number): void
resize(width: number, height: number): void
draw(): void
dispose(): void
}
export type Vr360RendererOptions = {
/** WebGL 上下文丢失(移动端切后台、系统回收 GPU 资源)时回调,由调用方退出 VR 模式。 */
onContextLost?: () => void
/** 视频帧无法写入纹理(跨域污染等)时回调。 */
onTextureError?: (error: unknown) => void
}
const VERTEX_SHADER = `
attribute vec3 aPosition;
attribute vec2 aUv;
uniform mat3 uView;
uniform mat4 uProjection;
uniform vec2 uUvScale;
uniform vec2 uUvOffset;
varying vec2 vUv;
void main() {
vUv = aUv * uUvScale + uUvOffset;
gl_Position = uProjection * vec4(uView * aPosition, 1.0);
}
`
const FRAGMENT_SHADER = `
precision mediump float;
uniform sampler2D uTexture;
varying vec2 vUv;
void main() {
gl_FragColor = texture2D(uTexture, vUv);
}
`
// 球面半径为 1 个单位,相机固定在球心,远近裁剪面据此选取。
const NEAR_PLANE = 0.1
const FAR_PLANE = 10
type Mesh = {
positions: Float32Array
uvs: Float32Array
indices: Uint16Array
indexCount: number
}
type GridPoint = {
position: [number, number, number]
uv: [number, number]
}
function buildGrid(
columns: number,
rows: number,
point: (col: number, row: number) => GridPoint,
): Mesh {
const positions = new Float32Array((columns + 1) * (rows + 1) * 3)
const uvs = new Float32Array((columns + 1) * (rows + 1) * 2)
for (let row = 0; row <= rows; row++) {
for (let col = 0; col <= columns; col++) {
const index = row * (columns + 1) + col
const { position, uv } = point(col, row)
positions[index * 3] = position[0]
positions[index * 3 + 1] = position[1]
positions[index * 3 + 2] = position[2]
uvs[index * 2] = uv[0]
uvs[index * 2 + 1] = uv[1]
}
}
const indices = new Uint16Array(columns * rows * 6)
let cursor = 0
for (let row = 0; row < rows; row++) {
for (let col = 0; col < columns; col++) {
const topLeft = row * (columns + 1) + col
const topRight = topLeft + 1
const bottomLeft = topLeft + columns + 1
const bottomRight = bottomLeft + 1
indices[cursor++] = topLeft
indices[cursor++] = bottomLeft
indices[cursor++] = topRight
indices[cursor++] = topRight
indices[cursor++] = bottomLeft
indices[cursor++] = bottomRight
}
}
return { positions, uvs, indices, indexCount: indices.length }
}
/**
* 等距柱状投影:水平覆盖 ±span,垂直覆盖 -90°~+90°。
* u 从 0(左端)到 1(右端),v 从 0(画面顶部)到 1(画面底部)。
*/
function buildEquirectMesh(span: number): Mesh {
const columns = 96
const rows = 48
return buildGrid(columns, rows, (col, row) => {
const lambda = -span + (2 * span * col) / columns
const phi = -Math.PI / 2 + (Math.PI * row) / rows
const cosPhi = Math.cos(phi)
return {
position: [Math.sin(lambda) * cosPhi, Math.cos(lambda) * cosPhi, Math.sin(phi)],
uv: [(lambda + span) / (2 * span), 0.5 - phi / Math.PI],
}
})
}
/**
* 180° 鱼眼(等距投影):画面中心是正前方,纹理半径 r 与偏角 θ 成正比。
* 方画幅内切圆之外的区域不参与采样,标准 VR180 鱼眼素材即如此。
*/
function buildFisheyeMesh(): Mesh {
const radial = 64
const azimuth = 96
const thetaMax = Math.PI / 2
return buildGrid(azimuth, radial, (col, row) => {
const psi = (2 * Math.PI * col) / azimuth
const theta = (thetaMax * row) / radial
const sinTheta = Math.sin(theta)
const radius = theta / thetaMax
return {
position: [sinTheta * Math.cos(psi), Math.cos(theta), sinTheta * Math.sin(psi)],
uv: [0.5 + 0.5 * radius * Math.cos(psi), 0.5 - 0.5 * radius * Math.sin(psi)],
}
})
}
function buildMesh(projection: Vr360Projection): Mesh {
if (projection === 'fisheye180') return buildFisheyeMesh()
if (projection === 'equirect180') return buildEquirectMesh(Math.PI / 2)
return buildEquirectMesh(Math.PI)
}
function compileShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
gl.deleteShader(shader)
return null
}
return shader
}
/**
* 在给定画布上创建全景渲染器;浏览器不支持 WebGL 时返回 null,调用方据此
* 退回普通播放,而不是给用户一个黑屏。
*/
export function createVr360Renderer(
canvas: HTMLCanvasElement,
video: HTMLVideoElement,
options: Vr360RendererOptions = {},
): Vr360Renderer | null {
const attributes: WebGLContextAttributes = {
alpha: false,
antialias: false,
depth: false,
stencil: false,
premultipliedAlpha: false,
preserveDrawingBuffer: false,
powerPreference: 'high-performance',
}
const gl = (canvas.getContext('webgl2', attributes) ??
canvas.getContext('webgl', attributes)) as WebGLRenderingContext | null
if (!gl) return null
const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
if (!vertexShader || !fragmentShader) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)
gl.deleteShader(vertexShader)
gl.deleteShader(fragmentShader)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
gl.deleteProgram(program)
return null
}
const positionLocation = gl.getAttribLocation(program, 'aPosition')
const uvLocation = gl.getAttribLocation(program, 'aUv')
const viewLocation = gl.getUniformLocation(program, 'uView')
const projectionLocation = gl.getUniformLocation(program, 'uProjection')
const uvScaleLocation = gl.getUniformLocation(program, 'uUvScale')
const uvOffsetLocation = gl.getUniformLocation(program, 'uUvOffset')
const textureLocation = gl.getUniformLocation(program, 'uTexture')
const positionBuffer = gl.createBuffer()
const uvBuffer = gl.createBuffer()
const indexBuffer = gl.createBuffer()
const texture = gl.createTexture()
if (!positionBuffer || !uvBuffer || !indexBuffer || !texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
// 先放一张 1x1 黑色占位图,视频帧尚未就绪时也不会出现未定义内容。
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array([0, 0, 0, 255]),
)
gl.disable(gl.CULL_FACE)
gl.disable(gl.DEPTH_TEST)
gl.disable(gl.BLEND)
gl.clearColor(0, 0, 0, 1)
let indexCount = 0
let uvScale: [number, number] = [1, 1]
let uvOffset: [number, number] = [0, 0]
let view: Mat3 = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1])
let fov = VR360_DEFAULT_FOV
let displayWidth = 1
let displayHeight = 1
let disposed = false
let contextLost = false
const uploadMesh = (mesh: Mesh) => {
indexCount = mesh.indexCount
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, mesh.positions, gl.STATIC_DRAW)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
gl.bufferData(gl.ARRAY_BUFFER, mesh.uvs, gl.STATIC_DRAW)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.indices, gl.STATIC_DRAW)
}
uploadMesh(buildMesh('equirect360'))
const handleContextLost = (event: Event) => {
// 必须 preventDefault,否则上下文不会被标记为可恢复;这里直接让上层退出 VR。
event.preventDefault()
contextLost = true
options.onContextLost?.()
}
canvas.addEventListener('webglcontextlost', handleContextLost)
return {
setProjection(projection: Vr360Projection) {
if (disposed) return
uploadMesh(buildMesh(projection))
},
setFrameUv(scale: [number, number], offset: [number, number]) {
uvScale = scale
uvOffset = offset
},
setView(next: Mat3) {
view = next
},
setFov(next: number) {
fov = Math.min(VR360_MAX_FOV, Math.max(VR360_MIN_FOV, next))
},
resize(nextWidth: number, nextHeight: number) {
if (disposed) return
// 画布位置与尺寸完全交给 CSS(inset-0 + 100%),绘制缓冲区只跟随它。
// 否则「观测到画布变大 → 设置更大的缓冲区 → 布局再次变大」会指数放大。
canvas.style.width = '100%'
canvas.style.height = '100%'
// 移动端按 DPR 渲染 4K 视频纹理代价很高,上限锁 2 倍。
const dpr = Math.min(2, Math.max(1, window.devicePixelRatio || 1))
displayWidth = Math.max(1, nextWidth)
displayHeight = Math.max(1, nextHeight)
const pixelWidth = Math.max(1, Math.round(displayWidth * dpr))
const pixelHeight = Math.max(1, Math.round(displayHeight * dpr))
if (canvas.width !== pixelWidth) canvas.width = pixelWidth
if (canvas.height !== pixelHeight) canvas.height = pixelHeight
},
draw() {
if (disposed || contextLost) return
if (canvas.width < 1 || canvas.height < 1) return
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clear(gl.COLOR_BUFFER_BIT)
if (video.readyState >= 2 && video.videoWidth > 0) {
try {
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video)
} catch (error) {
options.onTextureError?.(error)
}
}
gl.useProgram(program)
gl.uniformMatrix3fv(viewLocation, false, view)
gl.uniformMatrix4fv(
projectionLocation,
false,
perspectiveMatrix(
(fov * Math.PI) / 180,
displayWidth / displayHeight,
NEAR_PLANE,
FAR_PLANE,
),
)
gl.uniform2f(uvScaleLocation, uvScale[0], uvScale[1])
gl.uniform2f(uvOffsetLocation, uvOffset[0], uvOffset[1])
gl.uniform1i(textureLocation, 0)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuffer)
gl.enableVertexAttribArray(uvLocation)
gl.vertexAttribPointer(uvLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer)
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0)
},
dispose() {
if (disposed) return
disposed = true
canvas.removeEventListener('webglcontextlost', handleContextLost)
gl.bindBuffer(gl.ARRAY_BUFFER, null)
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, null)
gl.deleteBuffer(positionBuffer)
gl.deleteBuffer(uvBuffer)
gl.deleteBuffer(indexBuffer)
gl.deleteTexture(texture)
gl.deleteProgram(program)
// 主动释放上下文:移动端同时存活多个 WebGL 上下文会被浏览器强制回收。
gl.getExtension('WEBGL_lose_context')?.loseContext()
},
}
}