本文 AI 產出,尚未審核

Three.js 與 WebXR:AR 模式(基於 WebXR)

簡介

在行動裝置與智慧眼鏡日益普及的今天,**擴增實境(AR)**已成為前端開發者不可忽視的新領域。透過 WebXR API,開發者可以直接在瀏覽器內使用 AR 功能,無需安裝額外的 App,極大降低使用者的進入門檻。

Three.js 作為最成熟的 WebGL 3D 引擎,已內建對 WebXR 的支援,讓我們能以熟悉的場景、材質、光源等概念,快速構建出互動式 AR 體驗。本文將一步步說明如何在 Three.js 中啟用 AR 模式,並提供實作範例、常見問題與最佳實踐,幫助初學者與中階開發者快速上手。


核心概念

1. WebXR 與 AR Session

WebXR 是一套跨平台的 API,提供 VRAR 兩種 Session 類型。對於 AR,我們會使用 immersive-ar 模式,讓相機畫面成為背景,並在其上渲染 3D 物件。

// 取得 XR 支援與建立 AR Session
if (navigator.xr) {
  const isSupported = await navigator.xr.isSessionSupported('immersive-ar');
  if (isSupported) {
    const session = await navigator.xr.requestSession('immersive-ar', {
      requiredFeatures: ['hit-test', 'dom-overlay'],
      domOverlay: { root: document.body } // 允許 HTML overlay
    });
    renderer.xr.setReferenceSpaceType('local');
    renderer.xr.setSession(session);
  }
}

重點hit-test 用於偵測實體平面,dom-overlay 讓我們在 AR 畫面上疊加 UI。

2. Three.js 的 XRRenderer

Three.js 只需要把 WebGLRenderer 交給 XR 系統,即可自動處理畫面呈現與座標變換。關鍵設定如下:

const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;               // 開啟 XR 功能
document.body.appendChild(renderer.domElement);
  • alpha: true 讓背景透明,讓相機畫面可以透過 renderer.setClearColor(0x000000, 0) 變成透明。

3. Hit‑Test:將 3D 物件放在真實世界

AR 最常見的需求是「點擊螢幕,把模型放在實體平面上」。WebXR 提供的 XRHitTestSource 能把螢幕座標轉換成真實世界座標。

let hitTestSource = null;
let hitTestSourceRequested = false;

function onSessionStart(session) {
  // 每幀更新 hit‑test
  session.requestReferenceSpace('viewer').then((referenceSpace) => {
    session.requestHitTestSource({ space: referenceSpace }).then((source) => {
      hitTestSource = source;
    });
  });
}

在渲染迴圈中,我們使用 frame.getHitTestResults(hitTestSource) 取得結果,並把模型放置於 pose.transform.position

4. 互動與 UI

利用 dom-overlay,我們可以在 AR 畫面上放置 HTML 按鈕,觸發模型切換、動畫等功能。只要確保 UI 元素的 pointer-events 不會被 XR 視圖吞掉,即可正常接收點擊。

<button id="addModelBtn" style="position:fixed;bottom:20px;left:50%;transform:translateX(-50%);">
  放置模型
</button>
document.getElementById('addModelBtn').addEventListener('click', () => {
  placing = true; // 進入放置模式,下一次 hit‑test 成功時加入模型
});

5. 渲染迴圈與帧率控制

在 AR 中,渲染必須跟隨 XR Session 的 requestAnimationFrame,否則會造成畫面卡頓或姿態不同步。

function render(timestamp, frame) {
  if (frame) {
    const referenceSpace = renderer.xr.getReferenceSpace();
    const session = renderer.xr.getSession();

    // 處理 hit‑test
    if (hitTestSource) {
      const hitTestResults = frame.getHitTestResults(hitTestSource);
      if (hitTestResults.length > 0 && placing) {
        const hit = hitTestResults[0];
        const pose = hit.getPose(referenceSpace);
        // 把模型放到 hit 點
        model.position.set(pose.transform.position.x, pose.transform.position.y, pose.transform.position.z);
        scene.add(model);
        placing = false; // 完成一次放置
      }
    }
  }

  renderer.render(scene, camera);
}
renderer.setAnimationLoop(render);

程式碼範例

範例 1:最小 AR 範例(顯示立方體)

import * as THREE from 'three';
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] }));

// 建立一個簡單的立方體
const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);

// 把立方體放在相機前方 0.5m
cube.position.set(0, 0, -0.5);

renderer.setAnimationLoop(() => {
  renderer.render(scene, camera);
});

說明:使用 ARButton 快速產生「開始 AR」按鈕,適合測試與概念驗證。

範例 2:使用 Hit‑Test 放置 GLTF 模型

import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { ARButton } from 'three/examples/jsm/webxr/ARButton.js';

let hitTestSource = null, hitTestSourceRequested = false;
let reticle, model;
let placing = false;

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera();
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.xr.enabled = true;
document.body.appendChild(renderer.domElement);
document.body.appendChild(ARButton.createButton(renderer, { requiredFeatures: ['hit-test'] }));

// 讀取模型
const loader = new GLTFLoader();
loader.load('model.glb', (gltf) => {
  model = gltf.scene;
  model.scale.set(0.2, 0.2, 0.2);
});

// Reticle:顯示可放置位置
const geometry = new THREE.RingGeometry(0.07, 0.09, 32).rotateX(-Math.PI / 2);
const material = new THREE.MeshBasicMaterial({ color: 0x0fff00 });
reticle = new THREE.Mesh(geometry, material);
reticle.matrixAutoUpdate = false;
reticle.visible = false;
scene.add(reticle);

// 按鈕觸發放置模式
const btn = document.createElement('button');
btn.textContent = '放置模型';
btn.style.position = 'fixed';
btn.style.bottom = '20px';
btn.style.left = '50%';
btn.style.transform = 'translateX(-50%)';
document.body.appendChild(btn);
btn.addEventListener('click', () => (placing = true));

renderer.setAnimationLoop((timestamp, frame) => {
  if (frame) {
    const referenceSpace = renderer.xr.getReferenceSpace();
    const session = renderer.xr.getSession();

    if (!hitTestSourceRequested) {
      session.requestReferenceSpace('viewer').then((viewerSpace) => {
        session.requestHitTestSource({ space: viewerSpace }).then((source) => {
          hitTestSource = source;
        });
      });
      hitTestSourceRequested = true;
    }

    if (hitTestSource) {
      const hitTestResults = frame.getHitTestResults(hitTestSource);
      if (hitTestResults.length > 0) {
        const hit = hitTestResults[0];
        const pose = hit.getPose(referenceSpace);
        reticle.visible = true;
        reticle.matrix.fromArray(pose.transform.matrix);
        if (placing && model) {
          model.position.set(pose.transform.position.x, pose.transform.position.y, pose.transform.position.z);
          scene.add(model);
          placing = false;
        }
      } else {
        reticle.visible = false;
      }
    }
  }
  renderer.render(scene, camera);
});

說明:本範例示範 hit‑testreticle(放置指示器)以及 DOM overlay 按鈕的完整流程。

範例 3:加入光源與環境貼圖提升真實感

// 只保留核心程式碼
const envTexture = new THREE.CubeTextureLoader()
  .setPath('textures/env/')
  .load(['px.jpg','nx.jpg','py.jpg','ny.jpg','pz.jpg','nz.jpg']);
scene.environment = envTexture; // 環境光

const directional = new THREE.DirectionalLight(0xffffff, 1);
directional.position.set(0, 10, 5);
scene.add(directional);

說明:在 AR 中使用 環境貼圖(IBL)與 方向光,可以讓模型在實體光線下更自然。

範例 4:使用 XRAnchor 固定模型位置

let anchor = null;
function placeModelAtHit(pose) {
  if (anchor) anchor.delete(); // 清除舊的 Anchor
  const session = renderer.xr.getSession();
  session.addAnchor(pose, renderer.xr.getReferenceSpace()).then((a) => {
    anchor = a;
    model.position.set(0,0,0); // Anchor 自己會保持世界座標
    scene.add(model);
  });
}

說明XRAnchor 可讓模型隨裝置移動保持相對於真實世界的固定位置,適合長時間的 AR 互動。

範例 5:在 AR 中結合動畫(GLTF + Mixer)

let mixer;
loader.load('animated.glb', (gltf) => {
  model = gltf.scene;
  mixer = new THREE.AnimationMixer(model);
  gltf.animations.forEach((clip) => mixer.clipAction(clip).play());
  scene.add(model);
});

renderer.setAnimationLoop((timestamp, frame) => {
  const delta = clock.getDelta();
  if (mixer) mixer.update(delta);
  // 其餘 hit‑test、渲染同上
});

說明:將 AnimationMixer 置入渲染迴圈,即可在 AR 中播放角色動畫,提升沉浸感。


常見陷阱與最佳實踐

陷阱 可能原因 解決方案
相機畫面顯示為黑色 renderer 沒設 alpha:true 或未把 clearColor 透明化 renderer = new THREE.WebGLRenderer({alpha:true}); renderer.setClearColor(0x000000,0);
模型漂移或抖動 每幀重新建立 hitTestSource、或未使用 ReferenceSpace 一次 建立 hitTestSource,並在渲染迴圈中僅使用 frame.getHitTestResults
觸控事件失效 dom-overlay 未正確設定 root,或 CSS 把 pointer-events:none 設在父層 requestSession 時加入 {domOverlay:{root:document.body}},檢查 UI 元素的 pointer-events
手機效能不佳 使用過多高多邊形模型、未啟用 requestAnimationFrame 的 XR Loop 簡化模型、使用 LOD、在 renderer.setAnimationLoop 中只渲染必要物件
Hit‑Test 失敗 裝置不支援 hit-test、或未取得 viewer reference space 先呼叫 navigator.xr.isSessionSupported('immersive-ar'),若不支援則顯示降級訊息

最佳實踐

  1. 先檢測支援性:在 UI 顯示「開始 AR」前,先確認 navigator.xr.isSessionSupported('immersive-ar')true
  2. 使用 ARButton:它自動處理 Session 建立、權限請求與錯誤回報,減少程式碼複雜度。
  3. 限制渲染區域:AR 需要高 FPS,盡量把背景物件隱藏或使用 frustumCulled = false 只渲染在視野內的模型。
  4. 資源預載:在進入 AR 前先載入 GLTF、貼圖、環境圖,避免渲染時卡頓。
  5. 使用 XRAnchor:若模型需要長時間固定於真實世界,使用 Anchor 可降低因追蹤漂移造成的抖動。
  6. 適度使用光源:在 AR 中過多燈光會影響效能,建議使用 環境光 + 單一方向光,必要時再加 點光

實際應用場景

領域 典型應用 具體實作示例
零售 商品試看、虛擬試穿 使用 Three.js 載入產品 3D 模型,利用 hit‑test 把商品放在桌面或使用者身上
教育 互動式教材、解剖示意 透過 AR 把人體器官模型投射於教室桌面,使用者可旋轉、放大、播放動畫
製造 組裝指引、維修說明 在機械部件上投射指示箭頭與說明文字,利用 XRAnchor 固定於關鍵位置
旅遊 虛擬導覽、文化解說 把古蹟的 3D 重建模型投射於實地,結合地理定位提供導覽資訊
藝術 AR 展覽、沉浸式裝置 把互動藝術品掛在牆面或地面,使用者可以走動、改變視角,體驗作品的空間感

小技巧:在上述場景中,配合 WebXR Hand Input(手勢)或 Voice Commands,可讓體驗更自然。


總結

Three.jsWebXR 的結合,使得 瀏覽器即時 AR 成為可能。只要掌握以下幾個關鍵概念:

  1. 建立 immersive-ar Session,啟用 hit-testdom-overlay
  2. 使用 Three.js 的 XRRenderer,把 renderer.xr.enabled = true 即可。
  3. 透過 Hit‑Test 把模型放置於真實平面,並可搭配 Reticle 提供視覺回饋。
  4. 使用 XRAnchor 固定模型位置,提升長時間使用的穩定性。
  5. 注意效能:簡化模型、預載資源、適度光源,確保 60 FPS 的流暢體驗。

透過本文的範例與最佳實踐,讀者可以快速從「顯示一個立方體」到「在實體桌面上放置動畫模型」的完整流程,進而開發出符合商業需求或創意探索的 AR 應用。未來隨著瀏覽器與裝置的持續進化,WebXR 將成為跨平台 AR 的核心技術,而 Three.js 將持續提供強大的 3D 渲染能力,讓開發者的想像力無限延伸。祝開發順利,玩得開心!