引言
WebXR 的价值在于零安装分发:一个链接就能让用户进入 AR 或 VR,不需要应用商店审核、不需要下载几百兆。这对营销展示、电商预览、教育课件这类「低频、轻量、要传播」的场景几乎是唯一合理的选择。
但 WebXR 的性能天花板也低得多。浏览器要管 JS 执行、GC、图层合成、页面布局,留给三维渲染的预算被压缩得很紧。在手机上跑 WebXR AR,能稳定 30 fps 已属不易,而一体机浏览器的 WebXR 虽然能到 72 或 90 fps,但可用内存与纹理带宽都比原生应用紧张。
工程上真正要解决的问题是:能力探测要准(否则黑屏)、参考空间要理解(否则内容飘)、渲染循环要贴合 XRFrame(否则掉帧)、降级路径要完整(不支持 WebXR 时给什么)。本文按这条主线展开,代码以原生 WebXR API 与 Three.js 两条线对照。整体定位见 空间计算与 XR 技术全景 。
目录
- WebXR 是什么
- 能力探测与设备支持
- 会话模式与生命周期
- 参考空间与坐标系
- 输入源与手柄
- 渲染循环与 XRFrame
- Three.js 集成
- 图层与立体渲染
- 命中检测与锚点
- 性能优化
- 部署与兼容
- 局限与替代方案
- 权衡取舍
- 常见坑清单
- 小结
1. WebXR 是什么
WebXR Device API 是 W3C 标准,取代了早期的 WebVR。它提供三件事:
- 设备发现:
navigator.xr.isSessionSupported()探测能力。 - 会话管理:
requestSession()进入 immersive-vr 或 immersive-ar。 - 帧循环:
session.requestAnimationFrame()给出带位姿的XRFrame。
// 最小可用流程
if (navigator.xr && await navigator.xr.isSessionSupported('immersive-vr')) {
const session = await navigator.xr.requestSession('immersive-vr', {
requiredFeatures: ['local-floor'],
optionalFeatures: ['hand-tracking', 'layers']
});
session.requestAnimationFrame(onXRFrame);
}
与原生 XR 相比,WebXR 的抽象层级更接近 OpenXR:同样是「会话 + 参考空间 + 帧循环 + 图层」四件套,理解了 OpenXR 的模型,WebXR 上手很快。
2. 能力探测与设备支持
探测必须分层,因为「浏览器支持 WebXR」不等于「这台设备支持你要的模式」:
async function probe() {
if (!('xr' in navigator)) return { supported: false, reason: 'no-xr' };
const out = {};
out.vr = await navigator.xr.isSessionSupported('immersive-vr');
out.ar = await navigator.xr.isSessionSupported('immersive-ar');
// 注意:isSessionSupported 在非安全上下文会直接 reject
return out;
}
支持现状(截至 2026 年):
| 平台 | immersive-vr | immersive-ar | 备注 |
|---|---|---|---|
| Quest 浏览器 | 支持 | 部分支持 | 需 HTTPS |
| Chrome Android | 部分 | 支持(ARCore) | 需 ARCore 服务 |
| Safari iOS | 不支持 | 不支持 | 无 WebXR |
| Vision Pro Safari | 支持 | 支持 | 需用户授权 |
| 桌面 Chrome | 需头显/模拟器 | 需 WebXR API 模拟器 | 开发用 |
iOS Safari 不支持 WebXR 是最大的现实约束:想做 iOS 上的网页 AR,只能用 Quick Look(USDZ)或第三方库降级方案。
3. 会话模式与生命周期
三种模式:
| 模式 | 说明 | 典型用途 |
|---|---|---|
| immersive-vr | 全屏沉浸,独占显示 | VR 游戏、漫游 |
| immersive-ar | 透视叠加 | 网页 AR 展示 |
| inline | 页面内嵌,无独占 | 预览、降级 |
生命周期与会话事件:
session.addEventListener('end', () => { /* 清理资源 */ });
session.addEventListener('inputsourceschange', onInputsChange);
session.addEventListener('visibilitychange', () => {
// 用户摘下头显或切标签页
});
// 结束会话必须显式调用,否则浏览器可能保留资源
await session.end();
工程要点:
- 进入前必须有用户手势:
requestSession必须在点击等用户手势的调用栈里,否则被浏览器拒绝。 - 退出要清理:监听
end事件释放渲染目标与纹理,否则反复进出会内存泄漏。 - visibilitychange 要处理:头显摘下时暂停渲染,省电且避免状态错乱。
3.1 特性声明的两种语义
requiredFeatures:不满足则 requestSession 直接抛错
local-floor / bounded-floor / hit-test / anchors
optionalFeatures:不满足则静默忽略,应用需自行探测
hand-tracking / layers / dom-overlay / plane-detection
经验规则:核心玩法依赖的能力放 required,增强体验的能力放 optional。把 layers 放进 required 会导致在不支持的浏览器上完全无法进入,而它其实只影响清晰度。
4. 参考空间与坐标系
参考空间(Reference Space)决定「位姿是相对什么说的」,是 WebXR 最容易理解错的部分:
| 参考空间 | 原点 | Y 轴 | 适用 |
|---|---|---|---|
| viewer | 用户眼睛 | 无所谓 | 仅需头部相对运动 |
| local | 会话开始时头显位置 | 无重力对齐 | VR 站立 |
| local-floor | 地面投影 | 垂直向上,地面 y=0 | VR 站立/房间 |
| bounded-floor | 地面投影 | 同上,含边界 | 房间尺度 |
| unbounded | 起点 | 重力对齐 | 大空间漫游 |
const refSpace = await session.requestReferenceSpace('local-floor');
// 每帧:取左右眼视图矩阵与投影矩阵
const viewerPose = frame.getViewerPose(refSpace);
for (const view of viewerPose.views) {
// view.transform.matrix → 视图矩阵(列主序)
// view.projectionMatrix → 投影矩阵
// view.eye → 'left' | 'right' | 'none'
}
最常见的错误是用了 local 却按 local-floor 理解,导致用户看到的虚拟地面与自己脚下差 1.6 米。VR 内容应优先用 local-floor 或 bounded-floor。
5. 输入源与手柄
输入源通过 session.inputSources 获取,每个源包含手部/手柄、目标射线空间与游戏手柄映射:
function onInputsChange(e) {
for (const src of session.inputSources) {
console.log(src.handedness, src.targetRayMode, src.profiles);
// targetRayMode: 'tracked-pointer' | 'gaze' | 'screen'
}
}
// 每帧读取手柄位姿
const src = session.inputSources[0];
const pose = frame.getPose(src.targetRaySpace, refSpace);
if (pose) {
const m = pose.transform.matrix; // 手柄在世界中的位姿
}
// 按键:XRSession 的 select / squeeze 事件
session.addEventListener('select', () => { /* 扳机 */ });
session.addEventListener('squeeze', () => { /* 侧握 */ });
关键点:
select是语义事件,可能是扳机、捏合或屏幕点击,不要硬编码为「扳机」。targetRayMode为gaze表示注视输入(无手柄的 AR),交互要改用注视加停留。- 手部追踪需要
optionalFeatures: ['hand-tracking'],且只在支持设备上可用。
6. 渲染循环与 XRFrame
绝不能继续用 window.requestAnimationFrame,必须用 session.requestAnimationFrame,因为只有它提供带预测位姿的 XRFrame:
function onXRFrame(time, frame) {
session.requestAnimationFrame(onXRFrame); // 先注册下一帧
const pose = frame.getViewerPose(refSpace);
if (!pose) return; // 位姿不可用,跳过本帧
const glLayer = session.renderState.baseLayer;
gl.bindFramebuffer(gl.FRAMEBUFFER, glLayer.framebuffer);
for (const view of pose.views) {
const vp = glLayer.getViewport(view);
gl.viewport(vp.x, vp.y, vp.width, vp.height);
renderScene(view.projectionMatrix, view.transform.inverse.matrix);
}
}
要点:
baseLayer的 framebuffer 由浏览器提供,应用只负责往里画。frame.getViewerPose可能返回 null,必须判空,否则崩溃。- 每帧必须调用一次
requestAnimationFrame,否则循环停止。
7. Three.js 集成
Three.js 通过 WebGLRenderer.xr 封装了上述细节:
import * as THREE from 'three';
import { ARButton } from 'three/addons/webxr/ARButton.js';
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.xr.enabled = true;
renderer.xr.setReferenceSpaceType('local-floor');
document.body.appendChild(ARButton.createButton(renderer, {
requiredFeatures: ['hit-test'],
optionalFeatures: ['dom-overlay'],
domOverlay: { root: document.getElementById('overlay') }
}));
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
renderer.setAnimationLoop((t, frame) => {
// frame 是 XRFrame,可直接做命中检测
renderer.render(scene, camera);
});
要点:
- 必须用
renderer.setAnimationLoop,它会自动切换到 XR 帧循环。 camera不需要手动更新,Three.js 从XRFrame取位姿写入相机。renderer.xr.getController(i)拿到控制器对象,用于射线与模型挂载。
7.1 控制器射线
const controller = renderer.xr.getController(0);
controller.addEventListener('selectstart', onSelect);
scene.add(controller);
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -1)
]),
new THREE.LineBasicMaterial({ color: 0xffffff })
);
controller.add(line); // 射线跟随控制器
8. 图层与立体渲染
WebXR 支持多层图层,可显著提升清晰度:
| 图层类型 | 说明 | 用途 |
|---|---|---|
| XRWebGLLayer | 基础投影层 | 常规三维渲染 |
| XRQuadLayer | 平面四边形 | 高清 UI、视频 |
| XRCylinderLayer | 柱面 | 环绕 UI |
| XREquirectLayer | 全景 | 360 视频 |
| XRProjectionLayer | 多视图投影 | 立体渲染 |
Quad Layer 是 WebXR 里最实用的优化:把 UI 从三维场景里抽出来,用独立的高分辨率四边形渲染,既不占用三维渲染分辨率,也不受立体渲染影响。这在 VR 里能让文字清晰度提升数倍。
const quadLayer = new XRQuadLayer(session, {
space: refSpace,
viewPixelWidth: 1024, viewPixelHeight: 1024,
isStatic: true
});
// 需要在 requestSession 时声明 optionalFeatures: ['layers']
注意图层支持是可选特性,必须探测后再用,不支持时退回到三维内贴图。
9. 命中检测与锚点
immersive-ar 的命中检测等价于 ARCore/ARKit 的射线检测:
const viewerSpace = await session.requestReferenceSpace('viewer');
const hitTestSource = await session.requestHitTestSource({
space: viewerSpace
});
function onXRFrame(t, frame) {
const hits = frame.getHitTestResults(hitTestSource);
if (hits.length) {
const pose = hits[0].getPose(refSpace);
reticle.visible = true;
reticle.matrix.fromArray(pose.transform.matrix);
} else {
reticle.visible = false;
}
}
要点:
- 必须声明
requiredFeatures: ['hit-test'],否则requestHitTestSource抛错。 - 命中检测是相对
viewer空间的射线,即从眼睛往前打,与手机屏幕点击不同。 - 锚点用
XRAnchor,通过frame.createAnchor(pose, refSpace)创建,跨会话持久化能力弱于原生。
10. 性能优化
浏览器三维的性能约束比原生严,主要瓶颈与对策:
| 瓶颈 | 现象 | 对策 |
|---|---|---|
| DrawCall 过多 | CPU 卡顿 | 合批、实例化、合并几何 |
| 纹理过大 | 首次加载慢、显存爆 | 压缩纹理、降分辨率 |
| 后处理链 | 每帧多次全屏采样 | 移动端关掉或简化 |
| JS 主线程 | 动画与逻辑卡顿 | 用 Web Worker 分担 |
| GC 抖动 | 周期性掉帧 | 复用对象,避免每帧 new |
| 阴影贴图 | GPU 占用高 | 关闭实时阴影,用假阴影 |
// 复用对象,避免每帧分配(GC 是掉帧的隐形杀手)
const _v = new THREE.Vector3();
const _q = new THREE.Quaternion();
function updatePositions() {
_v.set(x, y, z); // 复用,不 new
mesh.position.copy(_v);
}
进一步的算力卸载可参考 WebAssembly 浏览器内 AI 推理 ,把密集计算移出主线程是浏览器侧最有效的优化之一。
11. 部署与兼容
WebXR 的部署约束比普通网页多:
- 必须 HTTPS:
isSessionSupported在非安全上下文直接 reject,localhost 例外。 - 必须用户手势进入:不能自动进入沉浸模式。
- 权限提示:进入 AR 会请求相机权限,需在 UI 上提前说明。
- 降级路径:不支持 WebXR 时退回到鼠标拖拽的 360 预览或内嵌视频。
- 首屏加载:三维资源体积大,必须做按需加载与进度提示。
降级决策树:
支持 immersive-ar → AR 模式(相机 + 命中检测)
支持 immersive-vr → VR 模式(手柄 + 传送)
仅支持 inline → 页面内 3D 预览(鼠标拖拽)
完全不支持 → 视频或图片展示
12. 局限与替代方案
WebXR 的能力边界要提前认清:
- 无持久化世界地图:不能像 ARKit 那样保存世界地图重定位。
- 无遮挡:WebXR 的 AR 没有深度 API,虚拟物体无法被真实物体遮挡。
- 无高频追踪:位姿精度与延迟都弱于原生。
- iOS 无支持:这是最大的市场缺口。
- 内存上限低:移动浏览器可用内存常低于 500 MB。
替代与互补方案:
| 需求 | 方案 |
|---|---|
| iOS 上的网页 AR | Quick Look(USDZ)或 8th Wall 类商业方案 |
| 高保真展示 | 原生 App 或视频 |
| 轻量 360 预览 | Three.js + 鼠标拖拽 |
| 需要持久化 | 原生 AR + 云锚点 |
13. 权衡取舍
- WebXR 与原生 App:前者免安装、迭代快,后者性能与能力全,按分发需求选。
- local 与 local-floor:前者简单但地面高度不对,VR 应用一律用后者。
- 三维内 UI 与 Quad Layer:后者清晰度高但支持不全,需做能力探测与降级。
- 实时阴影与假阴影:移动浏览器上实时阴影几乎不可用,优先贴图阴影。
- 手部追踪与控制器:手部追踪免设备但精度低,浏览器上稳定性更差。
- 高分辨率与帧率:移动浏览器上二者不可兼得,展示类优先保帧率。
14. 常见坑清单
- 用 window.requestAnimationFrame:拿不到 XRFrame,位姿无法更新,画面静止。
- 忘记判空 getViewerPose:位姿不可用时返回 null,直接崩溃。
- 在非用户手势里 requestSession:浏览器直接拒绝,表现为「点击没反应」。
- 参考空间用错:用 local 当 local-floor,用户站在地下或悬空。
- 不监听 end 事件:反复进出会话导致显存泄漏,最终崩溃。
- 把 select 当扳机:在 AR 里 select 可能是屏幕点击,交互逻辑要按语义写。
- 不声明 requiredFeatures:用 hit-test 却不声明,运行时抛错。
- 每帧 new 对象:GC 抖动导致周期性掉帧,必须复用。
- 依赖 iOS Safari:不支持 WebXR,必须有降级方案。
- 忽略 HTTPS:本地能跑,部署后 isSessionSupported 直接 reject。
15. 小结
WebXR 的工程主线是:分层探测能力 → 用用户手势进入会话 → 选对参考空间 → 在 XRFrame 里渲染 → 用图层提升清晰度 → 用命中检测做放置 → 准备完整的降级路径。它最大的优势是分发,最大的代价是性能与能力受限。
判断一个项目该不该用 WebXR,只需问:用户是否愿意为了这次体验安装一个 App。如果答案是否定的(营销、展示、课件),WebXR 是对的选择;如果用户会长期使用(游戏、工具),原生仍是唯一选项。
若要在网页里做更重的计算(如姿态估计、图像处理),下一步读 WebAssembly 浏览器内 AI 推理 ;若要进入头显做完整交互体系,读 Unity XR Interaction Toolkit 交互体系 。
延伸阅读
- 空间计算与 XR 技术全景 — XR 全景与选型
- ARCore 与 ARKit 平面检测与锚点 — 原生侧的命中检测与锚点
- WebAssembly 浏览器内 AI 推理 — 浏览器侧算力卸载
- 一体机 XR 性能优化实战 — 头显浏览器的性能约束
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。