关于 webxr-dev
webxr-dev 是面向使用 Three.js 构建和调试 WebXR 体验的开发指南,适用于实现 VR 与 AR 的开发者。内容涵盖会话模式、渲染、交互、移动方式和 VR 界面,并结合 Meta Quest 头显项目经验。
主要功能
- 01选择并配置 VR 或 AR 会话
- 02设置原生 XR 帧缓冲分辨率
- 03处理着色器、立体渲染和后期处理
- 04构建相机支架与移动方式
- 05创建世界空间 VR 界面板
适用场景
- 01为 Three.js 应用添加 VR 或 AR
- 02排查 WebXR 渲染模糊问题
- 03实现控制器移动与传送
- 04调整 VR 中的视觉效果
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ danielcanton/ webxr-dev-skill --skill webxr-dev安装后在 Claude Code 中输入/webxr-dev确认已加载。
附带文件LICENSEREADME.md
详细介绍
WebXR 开发指南
使用 Three.js 构建 WebXR(VR/AR)体验的实用指南,内容基于在 Meta Quest 头显上发布 VR 应用积累的宝贵经验。
何时使用此技能
在以下情况下使用 /webxr-dev:
- 为 Three.js 应用添加 VR/AR 支持
- 调试 WebXR 渲染问题
- 实现 VR 交互系统(面板、传送、控制器、手部追踪)
- 为现有 VR 应用添加透视模式/AR
关键规则:immersive-vr 与 immersive-ar
这是 WebXR 应用中最重要的决策。
immersive-vr(默认值 — 使用此模式)
- 帧缓冲区是不透明的。合成器会按原样显示渲染结果。
scene.background、setClearColor()可正常工作。- Three.js 会正确渲染背景、雾效和天空盒。
- 标准 VR 行为——一切都能正常工作。
immersive-ar(透视模式 — 踩坑重灾区)
- 帧缓冲区的 alpha 值控制摄像头画面的混合:alpha=0 → 完全显示透视画面,alpha=1 → 完全显示渲染画面。
- Three.js 会有意在 AR 会话中跳过
scene.background渲染。 setClearColor(color, 1)可能会被 Three.js XR 路径覆盖。- 每个场景中的每个像素都必须显式输出 alpha=1.0,才能遮挡摄像头画面。
- 使用
gl_FragColor = vec4(color, 1.0)的着色器没有问题,但 MeshBasicMaterial/MeshStandard 可能不行。 - 无法在会话中途切换 VR 和 AR——会话模式在请求时就已固定。
规则
Start with immersive-vr. Only switch to immersive-ar when you have:
1. A specific passthrough use case
2. The ability to test on-device
3. A plan for managing alpha in every material and shader
切勿仅因为设备支持 immersive-ar 就将其设为默认模式。Quest 3 同时支持两种模式——除非透视模式是主要体验,否则始终优先选择 immersive-vr。
会话设置
最简可用设置
const xr = navigator.xr;
const supported = await xr.isSessionSupported("immersive-vr");
if (!supported) return;
const sessionInit: XRSessionInit = {
optionalFeatures: [
'local-floor', // Floor-level reference space
'bounded-floor', // Room-scale boundary
'hand-tracking', // Quest hand tracking
'layers', // XRProjectionLayer + composition layers
'hit-test', // Ray-vs-real-world hit testing (AR)
],
};
const session = await xr.requestSession("immersive-vr", sessionInit);
renderer.xr.setReferenceSpaceType("local-floor");
renderer.xr.setSession(session);
有效的功能描述符
optionalFeatures / requiredFeatures 中仅识别以下字符串:
anchors、bounded-floor、depth-sensing、dom-overlay、hand-tracking、hit-test、layers、light-estimation、local、local-floor、secondary-views、unbounded、viewer。
无法识别的字符串会被静默忽略——不会导致错误,但也不会产生任何作用。
原生帧缓冲区分辨率(防止像素化)
**重要:**默认 WebXR 帧缓冲区的分辨率较低。必须创建一个使用原生缩放系数的 XRWebGLLayer,以避免渲染出现像素化:
const onSessionStarted = (session: XRSession) => {
session.addEventListener("end", onSessionEnded);
renderer.xr.setReferenceSpaceType("local-floor");
// Set high-res framebuffer — without this, everything looks pixelated
const gl = renderer.getContext();
const glLayer = new XRWebGLLayer(session, gl, {
framebufferScaleFactor: XRWebGLLayer.getNativeFramebufferScaleFactor(session),
antialias: true,
});
session.updateRenderState({ baseLayer: glLayer });
renderer.xr.setSession(session);
};
否则,Quest 的渲染分辨率会低于原生分辨率。仅此一项改动就能解决大多数“画面模糊/像素化”的问题。
后续添加透视模式(如有需要)
透视模式需要 immersive-ar,而非 immersive-vr。不存在 'passthrough' 这个可选功能——该字符串不属于 WebXR 规范。
启用透视模式:
// Request an AR session — the device composites your render over the camera feed
const session = await xr.requestSession("immersive-ar", {
optionalFeatures: ['local-floor', 'hand-tracking'],
});
// session.environmentBlendMode will be "alpha-blend" on Quest
在 immersive-ar 中,帧缓冲区的 alpha 值控制摄像头画面的混合:alpha=0 显示完整透视画面,alpha=1 显示渲染画面。无法在会话中途切换 VR 和 AR——模式在请求时就已固定。
VR 着色器注意事项
反转球体天空盒模式
对于自定义着色器天空盒(光线步进、程序化星空等),请使用带有 DoubleSide 的标准球体:
const sphereGeo = new THREE.IcosahedronGeometry(50, 5);
// Do NOT call sphereGeo.scale(-1, 1, 1) — see "Geometry Inversion Trap" below
const material = new THREE.ShaderMaterial({
vertexShader, fragmentShader,
side: THREE.DoubleSide, // Guarantees visibility from inside — no culling surprises
depthWrite: false,
});
const vrSphere = new THREE.Mesh(sphereGeo, material);
vrSphere.frustumCulled = false;
几何体反转陷阱
切勿将 sphereGeo.scale(-1, 1, 1) 与 side: THREE.BackSide 组合使用。
scale(-1,1,1)会翻转顶点绕序和法线(此时法线朝内)BackSide会渲染法线方向的反面- 法线朝内时,
BackSide渲染的是外侧——位于球体内部的摄像头什么也看不到 - 这是双重反转,会导致球体不可见
在球体内部从摄像头视角渲染的安全选项:
- **
DoubleSide(推荐):**渲染两面,消除所有剔除歧义。着色器会根据光线方向决定绘制内容——双面渲染不会造成任何视觉成本。 - **标准几何体 +
BackSide:**理论上可行(法线朝外,BackSide渲染内侧面),但不同几何体类型的顶点绕序可能不一致。 - **反转几何体 +
FrontSide:**同样可行,但也容易出问题。
使用 DoubleSide,不必再为此担心。
VR 相机位置
在 VR 中,相机位于用户的实际头部位置(距地面约 1.6 m)。这会影响:
- 光线步进着色器:如果 BH/物体位于原点,相机可能会位于其内部或离它过近。
- 解决方法:将物体放置在用户注视的位置附近(视线高度,前方数米处)。
- 示例:对于 rs=0.2 的黑洞,将其放置在 (0, 1.6, -5)——视线高度,前方 5 m。在史瓦西半径的 25 倍距离处,透镜效应清晰可见,又不会过度占据视野。
VR 中的物体尺寸
在桌面端看起来合适的物体,在 VR 中往往会显得巨大,因为你能真实地感知空间。**务必缩小 VR 中物体的实际尺寸。**示例:质量为 1.5(rs=1.5 m)的黑洞在桌面端效果不错,但在 VR 中会变成房间大小的球体。将质量缩小至 0.2,获得适中且震撼的效果。反复测试——没有什么能替代通过头显感知尺寸。
双目视差
VR 顶点着色器必须传递每只眼睛的相机位置,才能实现正确的立体效果:
varying vec3 vCameraPos;
void main() {
vCameraPos = cameraPosition; // Three.js sets this per-eye in VR
// ...
}
不要将相机位置硬编码——每只眼睛的渲染位置偏移都不同。
VR 中的后期处理
postprocessing 库不支持 WebXR
postprocessing npm 包(EffectComposer、BloomEffect 等)完全不具备 XR/立体渲染支持,无法渲染到 XR 帧缓冲区。
在渲染循环中,VR 模式必须绕过 composer:
if (renderer.xr.isPresenting) {
renderer.render(scene, camera); // Direct render — no composer
} else {
composer.render(delta); // Desktop gets postprocessing
}
**结果:**所有屏幕空间效果(辉光、失真、色调映射、色差)在 VR 中都完全不可见。
兼容 VR 的替代方案
用 3D 场景物体替代屏幕空间后期处理:
| 桌面端效果 | VR 替代方案 |
|---|---|
| 辉光 | 自发光材质、使用鲜艳颜色的 MeshBasicMaterial、加法混合 |
| 屏幕失真 | 扩散的冲击波环形网格(RingGeometry + DoubleSide + 渐隐) |
| 色彩分级 | 直接调整材质颜色 |
| 光晕 | 更大、更亮且带透明度的 MeshBasicMaterial 球体 |
示例——并合碰撞效果:
// Glow sphere: scale up + color shift at peak
this.glowMaterial.opacity = glowIntensity * 0.9;
this.mergerGlow.scale.setScalar(1 + glowIntensity * 5);
// Purple → white shift
this.glowMaterial.color.setRGB(
0.39 + glowIntensity * 0.61,
0.40 + glowIntensity * 0.60,
0.95 + glowIntensity * 0.05
);
// Expanding shockwave ring
const shockProgress = Math.max(0, (playbackTime - mergerNorm) * 6);
if (shockProgress > 0 && shockProgress < 1) {
shockwaveMaterial.opacity = (1 - shockProgress) * 0.7;
shockwaveRing.scale.setScalar(1 + shockProgress * 15);
} else {
shockwaveMaterial.opacity = 0;
}
相机 Rig 架构
Rig 模式
所有可移动物体(相机、控制器、瞬移目标)都位于同一个 cameraRig 组之下:
scene
└── cameraRig (THREE.Group)
├── camera (PerspectiveCamera)
├── controller1
├── controller2
└── teleportTarget
移动 Rig 会让所有物体一起移动。XR 系统会根据头显追踪更新相机的局部变换;Rig 则提供世界偏移。
关键:世界空间与局部空间坐标
**cameraRig 的任何子级都使用 Rig 局部空间中的位置。**对世界空间物体(地面、场景物体)执行射线检测时,必须先转换命中点,再将其赋给 Rig 子级:
// WRONG — world hit assigned as local position → offset by rig transform
teleportTarget.position.copy(worldHitPoint);
// CORRECT — convert to rig-local space first
teleportTarget.position.copy(worldHitPoint);
cameraRig.worldToLocal(teleportTarget.position);
适用于:
- 瞬移目标圆环/瞄准标记
- 作为 Rig 子级的任何预览标记或指示器
- 根据射线检测结果定位的任何物体
如果不使用 worldToLocal(),标记就会偏离实际射线交点——Rig 离原点越远,偏移越大。
移动方式
Thumbstick 映射(Quest 控制器)
Left controller:
axes[2] = strafe (left/right)
axes[3] = forward/back
buttons[1] = grip (hold for fly mode)
buttons[3] = thumbstick press (menu toggle)
buttons[4] = X button
Right controller:
axes[2] = horizontal turn (left/right)
axes[3] = vertical fly (up/down)
buttons[1] = grip
buttons[3] = thumbstick press
锁定地面模式与飞行模式
if (leftGrip) {
// Fly mode: full 3D movement following head direction
// forward/right keep Y component from camera quaternion
} else {
// Ground mode: project to XZ plane
forward.y = 0; forward.normalize();
right.y = 0; right.normalize();
}
右摇杆:水平转向 + 垂直飞行
// Horizontal: rotate the rig (yaw)
if (Math.abs(rx) > DEAD_ZONE) {
cameraRig.rotateY(-rx * TURN_SPEED * dt);
}
// Vertical: move up/down (NOT pitch rotation — see below)
if (Math.abs(ry) > DEAD_ZONE) {
cameraRig.position.y += -ry * MOVE_SPEED * 0.5 * dt;
}
为什么不在 Rig 上进行俯仰旋转
**不要使用 cameraRig.rotateX() 进行垂直视角转动。**XR 头显通过头部追踪控制相机朝向。在 X 轴上旋转 Rig 会与头显自身的俯仰控制冲突——该旋转虽然会应用,但会立即与头显姿态组合,导致令人迷失方向或看不出效果。
- 偏航(rotateY)有效,因为它会在世界空间中整体旋转 Rig,与用户原地转身的动作一致。
- 俯仰(rotateX)无效,因为头显已经通过实际头部运动处理垂直视角转动。
- 改用垂直飞行(position.y)——用户上下移动,并用头部环顾四周。
传送
- 控制器向地面平面发射射线
- 松开扳机时,将相机架移动到命中点
- 地面传送时,始终设置
cameraRig.position.y = 0 - **请记住:**传送目标是相机架的子级——对命中点使用
worldToLocal()
移动速度
在大型场景中,默认的 2 m/s 会让人感觉迟缓。探索时,5 m/s 是不错的起始值。垂直飞行速度设为一半(2.5 m/s)会感觉更自然。
VR UI 面板
模式
使用在世界空间中悬浮的纹理平面网格。将 UI 渲染到画布上,并将其作为纹理应用。
class VRPanel {
mesh: THREE.Mesh; // Plane with CanvasTexture
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
// Position in front of camera
positionInFront(camera: THREE.Camera, distance: number) {
const forward = new THREE.Vector3(0, 0, -1).applyQuaternion(camera.quaternion);
this.mesh.position.copy(camera.position).addScaledVector(forward, distance);
this.mesh.lookAt(camera.position);
}
}
交互
- 从控制器/手部向面板网格发射射线
- 将交点的 UV 坐标转换为画布坐标
- 对按钮区域进行命中测试
- 视觉反馈:在交点处显示瞄准点、改变射线颜色、显示悬停状态
提示
- 初始时隐藏面板,通过菜单按钮(按下摇杆或 X 按钮)切换显示
- 按钮要足够大(至少 0.15 m 高),方便瞄准
- 使用高对比度颜色(面板会以 VR 分辨率渲染)
- 渲染顺序很重要——设置
renderOrder,确保面板始终可见
测试与调试
没有头显就无法测试 VR
这一点不可妥协。在桌面浏览器中测试无法告诉你以下内容:
- 立体渲染是否正确
- 控制器/手部交互的体验
- VR 帧率(72-120 fps)下的性能
- AR 模式下的 Alpha 混合行为
- 空间音频和触觉反馈
远程调试设置
- 通过 Meta 手机应用启用开发者模式(设备 > 头显设置 > 开发者模式)
- 通过 USB 将 Quest 连接到 PC,并在头显中批准连接对话框
- 在桌面版 Chrome 中打开
chrome://inspect/#devices - Quest 浏览器标签页会显示出来——点击检查以打开完整的 DevTools
- **无线方式:**运行
adb tcpip 5555,然后运行adb connect <quest-ip>:5555
**注意:**开发者菜单的位置会随 Quest 固件版本变化。请尝试:
- 设置 > 系统 > 开发者
- 设置 > 开发者
- 设置 > 高级 > 开发者
如果没有开发者选项,你需要先在 developer.meta.com 注册(免费),通过手机应用启用该选项,然后重启 Quest。
Quest 浏览器与 PWA
- 始终先在 Quest 浏览器中测试(不要先用 PWA)
- PWA 会积极缓存内容;过期构建版本容易造成混淆
- 使用无痕标签页或强制刷新,确保加载最新代码
- 如果已安装 PWA,它可能运行的是完全不同的版本
迭代流程
- 修改
- 部署(或使用本地网络 URL)
- 在 Quest 浏览器中强制刷新
- 测试特定功能
- 通过远程调试检查控制台
不要在不进行设备端测试的情况下盲目迭代。与其盲目修复四轮,不如一次性设置好远程调试。
常见问题
1. 需要 immersive-vr,却请求了 immersive-ar
请参阅上文的关键规则。这一个错误就会引发一连串渲染问题。
2. 对象位于原点,而相机位于 (0, 1.6, 0)
在 VR 中,相机位于头部高度。位于原点的对象就在你的脚边。将对象放在视线高度,体验最佳。
3. 在 AR 中,scene.background 不起作用
Three.js 会有意跳过 AR 会话中的背景渲染。你必须自行填充背景(不透明的天空球体、输出 alpha=1 的着色器)。
4. 几何体反转 + BackSide = 不可见
scale(-1,1,1) + BackSide 会对面剔除产生双重反转。改用 DoubleSide。请参阅上文的“几何体反转陷阱”。
5. VR 中的透明材质
带有 transparent: true 的材质在 AR 和 VR 中的渲染方式不同。在 VR 中,透明度只影响视觉效果。在 AR 中,低 Alpha 值会显示透视摄像头画面。
6. 忘记设置 frustumCulled = false
大型天空盒球体可能会被 VR 摄像机的视锥体剔除。务必对天空盒网格设置 frustumCulled = false。
7. 切换模式时未释放几何体/材质
如果替换几何体(例如,大球体 ↔ 小球体),务必对旧几何体调用 .dispose(),以防止 GPU 内存泄漏。
8. 默认帧缓冲区分辨率过低
如果没有使用 getNativeFramebufferScaleFactor() 显式创建 XRWebGLLayer,Quest 渲染时会使用低于原生分辨率的分辨率,画面会显得像素化。请始终设置原生缩放系数。
9. VR 中看不到后期处理效果
postprocessing 库不支持 WebXR。当 renderer.xr.isPresenting 时,辉光、失真和色调映射都会被跳过。请改用 3D 场景对象。
10. 传送目标/瞄准环与控制器射线错位
如果传送目标或瞄准环是 cameraRig 的子对象,则必须通过 cameraRig.worldToLocal() 将世界空间的命中点转换为局部空间。否则,标记会因 Rig 的位置而偏移。
11. 右摇杆无法控制俯仰旋转
cameraRig.rotateX() 会与头显追踪冲突。请改用垂直位置移动(向上/向下飞行)。只有在 Rig 上使用偏航旋转(rotateY)才可靠。
架构建议
在应用层决定会话模式
不要让各个场景自行选择会话模式。应由 XR 管理器请求会话,并让场景适应所获得的会话模式。
// Good: XR manager decides
const mode = "immersive-vr"; // App-level decision
// Bad: Scene tries to change session type
if (userWantsPassthrough) requestSession("immersive-ar"); // Can't switch mid-session
VR 场景接口
interface Scene {
supportsXR?: boolean;
init(ctx: SceneContext): Promise<void>;
update(dt: number, elapsed: number): void;
dispose(): void;
}
场景应当:
- 在更新循环中检测
renderer.xr.isPresenting - 在桌面和 VR 渲染模式之间切换
- 向 XR 管理器注册/注销 VR 面板
- 在 dispose 中清理 VR 状态
将透视功能作为未来功能
如果以后需要透视功能,请按以下方式实现:
- 添加一个单独的“进入 AR”按钮(与“进入 VR”按钮区分)
- 使用
immersive-ar,并制定经过设备实测的完整 Alpha 管理策略 - 每个材质和着色器都必须输出正确的 Alpha 值(alpha=0 → 透视,alpha=1 → 不透明渲染)
无法在 immersive-vr 会话中启用透视功能。切勿在正常运行的 VR 应用中直接添加透视功能,除非经过专门的设备实测。