元宇宙开发指南:用Three.js + WebXR构建跨平台沉浸式应用

2026.5.19 杂七杂八 1780
33BLOG智能摘要
你正在准备一个跨平台元宇宙展厅,却被“必须同时兼容手机、PC VR和头显”的需求卡住,常规教程里那套老旧的Three.js版本根本跑不起来,结果只剩下无休止的报错和调试时间。其实,WebXR在r125之后已实现稳定API,只要锁定正确的three@0.160.0并按顺序开启renderer.xr.enabled、限制像素比、使用setAnimationLoop,整个代码库就能在浏览器、Quest和移动AR上无缝切换。
— 此摘要由33BLOG基于AI分析文章内容生成,仅供参考。

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

元宇宙开发指南:用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帧,后来做了三件事才救回来:

  1. 纹理压缩:所有贴图用KTX2格式,Three.js原生支持
  2. LOD分级:远处的物体用低模
  3. 批处理:把静态物体合并成单个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属性,纹理加载报错搞了一天。