VR 游戏的 WebXR 基础
WebXR 将 VR 和 AR 带入浏览器。玩家无需下载任何内容,即可通过头显体验你的游戏。
1) 检查 WebXR 支持情况
js
async function checkXRSupport() {
if (!navigator.xr) {
return { vr: false, ar: false }
}
const vr = await navigator.xr.isSessionSupported('immersive-vr')
const ar = await navigator.xr.isSessionSupported('immersive-ar')
return { vr, ar }
}
// 用法
const support = await checkXRSupport()
if (support.vr) {
showVRButton()
}2) 启动 VR 会话
js
let xrSession = null
let xrRefSpace = null
async function startVR() {
try {
xrSession = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
optionalFeatures: ['hand-tracking'],
})
xrSession.addEventListener('end', onSessionEnd)
// 设置渲染
const gl = canvas.getContext('webgl2', { xrCompatible: true })
await xrSession.updateRenderState({
baseLayer: new XRWebGLLayer(xrSession, gl),
})
// 获取参考空间
xrRefSpace = await xrSession.requestReferenceSpace('local-floor')
// 启动渲染循环
xrSession.requestAnimationFrame(onXRFrame)
} catch (err) {
console.error('启动 VR 失败:', err)
}
}
function onSessionEnd() {
xrSession = null
xrRefSpace = null
}3) XR 渲染循环
js
function onXRFrame(time, frame) {
const session = frame.session
session.requestAnimationFrame(onXRFrame)
const pose = frame.getViewerPose(xrRefSpace)
if (!pose) return
const glLayer = session.renderState.baseLayer
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
// 分别为每只眼睛渲染
for (const view of pose.views) {
const viewport = glLayer.getViewport(view)
gl.viewport(viewport.x, viewport.y, viewport.width, viewport.height)
// 获取相机矩阵
const viewMatrix = view.transform.inverse.matrix
const projectionMatrix = view.projectionMatrix
// 使用这些矩阵渲染场景
renderScene(viewMatrix, projectionMatrix)
}
}4) 输入源(控制器)
js
xrSession.addEventListener('inputsourceschange', (e) => {
for (const source of e.added) {
console.log('已添加控制器:', source.handedness) // 'left' 或 'right'
}
for (const source of e.removed) {
console.log('已移除控制器:', source.handedness)
}
})
function processInput(frame) {
for (const source of xrSession.inputSources) {
if (!source.gamepad) continue
const gamepad = source.gamepad
// 扳机键(通常为索引 0)
const triggerPressed = gamepad.buttons[0]?.pressed
const triggerValue = gamepad.buttons[0]?.value || 0
// 握持键(通常为索引 1)
const gripPressed = gamepad.buttons[1]?.pressed
// 摇杆
const thumbstickX = gamepad.axes[2] || 0
const thumbstickY = gamepad.axes[3] || 0
// A/B 按钮(索引 4、5)
const buttonA = gamepad.buttons[4]?.pressed
const buttonB = gamepad.buttons[5]?.pressed
}
}不要假设每台设备都有游戏手柄
上面的代码通过 source.gamepad 读取扳机键和摇杆输入。这适用于 Meta Quest 等使用控制器的头显,但在 Apple Vision Pro(visionOS 2 Safari 默认启用 WebXR)、Samsung Galaxy XR 和其他 Android XR 设备上不起作用,因为这些设备主要使用注视加捏合或手部追踪作为输入。在这些头显上,source.gamepad 为 null,而且在用户实际做出捏合手势之前,session.inputSources 会一直为空。
对于每台设备都保证支持的交互——“主要操作”,应监听 select 事件,而不是轮询按钮。无论是控制器扳机、手部捏合,还是 Vision Pro 的注视加捏合,WebXR 都会触发 selectstart、select 和 selectend,因此只需一组处理程序即可覆盖所有输入方式:
js
xrSession.addEventListener('selectstart', (e) => {
// e.inputSource 是控制器、手部或瞬时捏合输入
// e.frame 可用于读取其 targetRaySpace 位姿
})
xrSession.addEventListener('select', (e) => {
// 主要操作已完成(扣下扳机、在目标上松开捏合手势)
})
xrSession.addEventListener('selectend', (e) => {})在使用控制器的头显上,仍可保留游戏手柄代码来处理摇杆移动和额外按钮,但应通过 if (source.gamepad) 进行判断,并将核心的“选择/抓取/激活”交互交由这些事件处理。这样,游戏在依赖手部和注视输入的设备上仍可正常运行。
5) 控制器位置
js
function getControllerPose(frame, source) {
if (!source.gripSpace) return null
const pose = frame.getPose(source.gripSpace, xrRefSpace)
if (!pose) return null
return {
position: pose.transform.position,
orientation: pose.transform.orientation,
matrix: pose.transform.matrix,
}
}
function renderControllers(frame) {
for (const source of xrSession.inputSources) {
const pose = getControllerPose(frame, source)
if (pose) {
renderControllerModel(pose, source.handedness)
}
}
}6) 用于指向操作的射线检测
js
function getControllerRay(frame, source) {
if (!source.targetRaySpace) return null
const pose = frame.getPose(source.targetRaySpace, xrRefSpace)
if (!pose) return null
const origin = pose.transform.position
const direction = {
x: -pose.transform.matrix[8],
y: -pose.transform.matrix[9],
z: -pose.transform.matrix[10],
}
return { origin, direction }
}
function checkRayIntersection(ray, objects) {
let closest = null
let closestDist = Infinity
for (const obj of objects) {
const dist = rayIntersectBox(ray, obj.boundingBox)
if (dist !== null && dist < closestDist) {
closest = obj
closestDist = dist
}
}
return closest
}7) 传送移动
js
class TeleportSystem {
constructor() {
this.targetPosition = null
this.isAiming = false
}
update(frame, inputSources) {
for (const source of inputSources) {
if (source.handedness !== 'left') continue
const gamepad = source.gamepad
const thumbstickY = gamepad?.axes[3] || 0
if (thumbstickY < -0.5) {
// 瞄准
this.isAiming = true
const ray = getControllerRay(frame, source)
this.targetPosition = this.findTeleportTarget(ray)
} else if (this.isAiming) {
// 松开——传送
if (this.targetPosition) {
player.position.x = this.targetPosition.x
player.position.z = this.targetPosition.z
}
this.isAiming = false
this.targetPosition = null
}
}
}
findTeleportTarget(ray) {
// 与地面平面求交
if (ray.direction.y >= 0) return null
const t = -ray.origin.y / ray.direction.y
if (t < 0 || t > 10) return null
return {
x: ray.origin.x + ray.direction.x * t,
y: 0,
z: ray.origin.z + ray.direction.z * t,
}
}
render() {
if (this.isAiming && this.targetPosition) {
renderTeleportMarker(this.targetPosition)
}
}
}8) 平滑移动
js
function updateSmoothLocomotion(frame, inputSources, dt) {
for (const source of inputSources) {
if (source.handedness !== 'left') continue
const gamepad = source.gamepad
if (!gamepad) continue
const moveX = gamepad.axes[2] || 0
const moveY = gamepad.axes[3] || 0
// 应用死区
if (Math.abs(moveX) < 0.1) moveX = 0
if (Math.abs(moveY) < 0.1) moveY = 0
// 获取头部朝向以确定移动方向
const pose = frame.getViewerPose(xrRefSpace)
if (!pose) continue
const headMatrix = pose.transform.matrix
const forward = { x: -headMatrix[8], z: -headMatrix[10] }
const right = { x: headMatrix[0], z: headMatrix[2] }
// 在 XZ 平面上归一化
const len = Math.sqrt(forward.x ** 2 + forward.z ** 2)
forward.x /= len
forward.z /= len
const speed = 3 * dt
player.position.x += (forward.x * -moveY + right.x * moveX) * speed
player.position.z += (forward.z * -moveY + right.z * moveX) * speed
}
}9) VR 舒适度指南
js
// 移动时使用暗角以减轻晕动症
function renderComfortVignette(movementSpeed) {
const intensity = Math.min(movementSpeed / 5, 0.5)
if (intensity < 0.1) return
// 渲染随速度增加而加深的暗色边缘
renderVignette(intensity)
}
// 分段转向
let snapTurnCooldown = 0
function handleSnapTurn(gamepad, dt) {
snapTurnCooldown -= dt
const thumbstickX = gamepad.axes[2] || 0
if (Math.abs(thumbstickX) > 0.7 && snapTurnCooldown <= 0) {
const angle = Math.sign(thumbstickX) * (Math.PI / 4) // 45 度
player.rotation += angle
snapTurnCooldown = 0.3 // 防止连续快速转向
}
}10) 完整的 WebXR 设置
js
class VRGame {
constructor(canvas) {
this.canvas = canvas
this.gl = canvas.getContext('webgl2', { xrCompatible: true })
this.session = null
this.refSpace = null
this.teleport = new TeleportSystem()
}
async checkSupport() {
if (!navigator.xr) return false
return await navigator.xr.isSessionSupported('immersive-vr')
}
async start() {
this.session = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
})
await this.session.updateRenderState({
baseLayer: new XRWebGLLayer(this.session, this.gl),
})
this.refSpace = await this.session.requestReferenceSpace('local-floor')
this.session.addEventListener('end', () => this.onEnd())
this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
}
onFrame(time, frame) {
this.session.requestAnimationFrame((t, f) => this.onFrame(t, f))
const dt = (time - this.lastTime) / 1000
this.lastTime = time
// 更新游戏逻辑
this.teleport.update(frame, this.session.inputSources)
// 渲染
const pose = frame.getViewerPose(this.refSpace)
if (!pose) return
const glLayer = this.session.renderState.baseLayer
this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, glLayer.framebuffer)
this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT)
for (const view of pose.views) {
const vp = glLayer.getViewport(view)
this.gl.viewport(vp.x, vp.y, vp.width, vp.height)
this.render(view.transform.inverse.matrix, view.projectionMatrix)
}
this.teleport.render()
}
render(viewMatrix, projectionMatrix) {
// 在这里编写你的渲染代码
}
onEnd() {
this.session = null
}
}相关内容
- WebGL 基础
- WebGPU 入门
- 游戏输入处理
- 2026 年 Web 游戏技术栈 — WebXR 在更广泛技术版图中的位置
- 在浏览器中使用 Three.js + USDC — 为 XR 场景加载 3D 资源
外部资源
- MDN:WebXR Device API — 完整 API 参考
- Immersive Web — WebXR 示例、工具和浏览器支持表
- Three.js WebXR 指南 — 使用 Three.js 创建 VR 内容