{media ? (
@@ -602,6 +632,18 @@ export function PlayerVideoStage({
)
})}
+ {vr360 ? (
+
setVrReady(true)}
+ onError={(message) => onVr360Error?.(message)}
+ onProfileChange={onVr360ProfileChange}
+ />
+ ) : null}
{danmakuPanel}
{playlistPanel}
@@ -701,6 +746,14 @@ export function PlayerVideoStage({
) : (
加载中…
)}
+ {vr360 && media && !vrReady ? (
+
+ ) : null}
{playerError ? (
{playerError}
diff --git a/web/src/utils/vr360.test.ts b/web/src/utils/vr360.test.ts
new file mode 100644
index 0000000..dd5e6a5
--- /dev/null
+++ b/web/src/utils/vr360.test.ts
@@ -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')
diff --git a/web/src/utils/vr360.ts b/web/src/utils/vr360.ts
new file mode 100644
index 0000000..787e09f
--- /dev/null
+++ b/web/src/utils/vr360.ts
@@ -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
+ 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 {
+ 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, 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
+ 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 模式。
+ }
+}
diff --git a/web/src/utils/vr360Math.ts b/web/src/utils/vr360Math.ts
new file mode 100644
index 0000000..c53630b
--- /dev/null
+++ b/web/src/utils/vr360Math.ts
@@ -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,
+ ])
+}
diff --git a/web/src/utils/vr360Renderer.ts b/web/src/utils/vr360Renderer.ts
new file mode 100644
index 0000000..79d5a11
--- /dev/null
+++ b/web/src/utils/vr360Renderer.ts
@@ -0,0 +1,365 @@
+// 用一段 WebGL 把