从零搭建你的第一个元宇宙空间:Three.js + WebXR 实战笔记

去年年底,我接了一个跨平台元宇宙展厅的项目,甲方要求同时支持手机、PC VR和VR头显。当时第一反应是“又要搞原生又要搞Web,工期得翻倍”。后来调研发现,WebXR + Three.js的组合远比想象中成熟——用同一套代码库,居然能同时跑在浏览器、Oculus Quest和手机AR里。这篇文章就是我从踩坑到落地的完整记录。
环境搭建:别让依赖版本坑了你
先说我踩的第一个坑:Three.js 的 WebXR 支持在 r125 之后才稳定,但很多人还在用老教程里的 r112。所以第一步,明确版本。
# 初始化项目
npm init -y
# 安装核心依赖,注意锁定版本
npm install three@0.160.0 @types/three --save
# 如果要用 VR 手柄交互
npm install three-mesh-bvh three-stdlib --save
然后建一个最简的 HTML 骨架。这里有个细节:WebXR 需要 crossorigin="anonymous" 属性,否则纹理加载会报跨域错误。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>我的元宇宙空间</title>
<style>
body { margin: 0; overflow: hidden; }
</style>
</head>
<body>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module" src="main.js"></script>
</body>
</html>
踩坑提示:如果你用本地文件(file://)打开,WebXR 会报错。必须用 HTTPS 或者 localhost 启动,推荐用 npx serve -s . --ssl-cert 生成临时证书。
初始化场景:渲染器配置决定成败
Three.js 的 WebXR 模式需要手动开启,并且渲染器的像素比要限制,否则 Quest 2 上会卡成幻灯片。
// main.js
import * as THREE from 'three';
import { XRButton } from 'three/addons/webxr/XRButton.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
// 相机:透视相机,视野70度适合VR
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
// 渲染器:关键配置
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 限制像素比
renderer.xr.enabled = true; // 开启WebXR
document.body.appendChild(renderer.domElement);
// 添加环境光,避免VR里太暗
const pmremGenerator = new THREE.PMREMGenerator(renderer);
scene.environment = pmremGenerator.fromScene(new RoomEnvironment()).texture;
// 添加地面和网格辅助
const gridHelper = new THREE.GridHelper(20, 20, 0x8888ff, 0x444466);
gridHelper.position.y = -0.5;
scene.add(gridHelper);
// 创建XR按钮
document.body.appendChild(XRButton.createButton(renderer));
实战经验:renderer.xr.enabled 必须在任何其他操作之前设置,包括添加灯光。否则在部分浏览器上会闪退。另外,不要忘记调用 renderer.setAnimationLoop 而不是 requestAnimationFrame——后者在VR模式下会失效。
交互系统:让用户“摸到”虚拟物体
纯展示的元宇宙没意义,必须能交互。我用的是 Three.js 的 XRControllerModelFactory 来加载手柄模型,再结合射线检测实现抓取。
import { XRControllerModelFactory } from 'three/addons/webxr/XRControllerModelFactory.js';
// 创建控制器
const controllerModelFactory = new XRControllerModelFactory();
const controllerGrip1 = renderer.xr.getControllerGrip(0);
controllerGrip1.add(controllerModelFactory.createControllerModel(controllerGrip1));
scene.add(controllerGrip1);
const controllerGrip2 = renderer.xr.getControllerGrip(1);
controllerGrip2.add(controllerModelFactory.createControllerModel(controllerGrip2));
scene.add(controllerGrip2);
// 射线检测
const raycaster = new THREE.Raycaster();
const tempMatrix = new THREE.Matrix4();
function handleController(controller) {
controller.addEventListener('selectstart', () => {
// 获取手柄位置和方向
tempMatrix.identity().extractRotation(controller.matrixWorld);
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tempMatrix);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const object = intersects[0].object;
// 触发自定义事件
object.dispatchEvent({ type: 'grab', controller });
}
});
}
renderer.xr.addEventListener('sessionstart', () => {
const controller1 = renderer.xr.getController(0);
const controller2 = renderer.xr.getController(1);
handleController(controller1);
handleController(controller2);
});
踩坑提示:控制器的事件监听必须在 session 开始后注册,否则 selectstart 事件永远不会触发。另外,射线检测时别忘了考虑物体的包围盒,否则抓取精度会很差。
跨平台适配:手机、PC、VR一键切换
这是整个项目最头疼的部分。WebXR 的 session 模式有三种:inline(手机浏览器)、immersive-vr(VR头显)、immersive-ar(AR眼镜)。我的做法是动态检测设备能力。
async function startExperience() {
if (navigator.xr) {
// 检测是否支持VR
const isVRSupported = await navigator.xr.isSessionSupported('immersive-vr');
// 检测是否支持AR
const isARSupported = await navigator.xr.isSessionSupported('immersive-ar');
if (isVRSupported || isARSupported) {
// 显示VR/AR按钮
document.getElementById('xr-button').style.display = 'block';
} else {
// 降级到普通3D模式
console.log('当前设备不支持XR,使用普通3D模式');
animate();
}
} else {
alert('浏览器不支持WebXR,请使用Chrome或Edge');
}
}
// 不同平台的不同UI适配
function handleResize() {
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
if (isMobile && !renderer.xr.isPresenting) {
// 手机模式:调整UI大小和触控
document.querySelector('.ui-panel').style.fontSize = '24px';
}
}
window.addEventListener('resize', handleResize);
实战经验:千万不要依赖 userAgent 做唯一判断,有些平板既像手机又像电脑。我后来加了 navigator.maxTouchPoints 辅助判断。另外,在VR模式下要禁用所有鼠标事件,否则会出现双重交互。
性能优化:让Quest 2跑满72帧
最开始我的场景在Quest 2上只有30帧,后来做了三件事才救回来:
- 纹理压缩:所有贴图用KTX2格式,Three.js原生支持
- LOD分级:远处的物体用低模
- 批处理:把静态物体合并成单个Geometry
import * as THREE from 'three';
import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
// KTX2纹理加载器
const ktx2Loader = new KTX2Loader()
.setTranscoderPath('https://unpkg.com/three@0.160.0/examples/jsm/libs/basis/')
.detectSupport(renderer);
// 加载压缩纹理
ktx2Loader.load('textures/brick.ktx2', (texture) => {
const material = new THREE.MeshStandardMaterial({ map: texture });
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
});
// LOD示例
const lod = new THREE.LOD();
const highPoly = createDetailedModel();
const lowPoly = createSimplifiedModel();
lod.addLevel(highPoly, 0);
lod.addLevel(lowPoly, 10);
scene.add(lod);
踩坑提示:KTX2的transcoder文件必须托管在HTTPS服务器上,而且路径不能有中文。我因为用中文文件夹名折腾了两小时。另外,LOD的切换距离要根据实际场景调整,我习惯先在编辑器里测好再写代码。
发布与测试:别忘了这个关键步骤
本地测试没问题后,部署时要注意:WebXR 强制要求 HTTPS,而且 Service Worker 要配置正确。我用的是 Vite 构建,配置如下:
# vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
server: {
https: true, // 本地也开启HTTPS
host: '0.0.0.0' // 允许局域网访问
},
build: {
target: 'esnext', // 保留importmap
rollupOptions: {
output: {
manualChunks: {
three: ['three']
}
}
}
}
});
最后用 npx vite build 打包,上传到支持HTTPS的服务器(Netlify、Vercel都行)。用手机扫码打开时,记得在浏览器地址栏输入 chrome://flags 开启WebXR支持(部分安卓浏览器默认关闭)。
这套方案最终成功交付,客户在Oculus Quest 2、iPhone Safari和Windows Chrome上都能流畅运行。虽然中间踩了不少坑,但看到用户真的在虚拟展厅里走来走去,那种成就感是纯平面开发给不了的。如果你也在做类似项目,记住:先跑通最简单的VR场景,再逐步加功能——这是我用血泪换来的经验。


之前公司那个webxr项目,没加crossorigin属性,纹理加载报错搞了一天。