mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
添加VR360播放功能
This commit is contained in:
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user