本文 AI 產出,尚未審核

Three.js 多視窗與多渲染場景

主題:多 Camera 使用


簡介

在 3D 網頁應用中,同時顯示多個視角是提升使用者體驗的常見需求。無論是遊戲的分割螢幕、建築模型的全景與細部同時觀察,或是資料視覺化需要同時呈現不同投影方式,都離不開 多 Camera 的概念。

Three.js 作為最受歡迎的 WebGL 框架,提供了靈活且效能良好的相機系統。掌握 如何在同一個渲染循環中管理多支相機、如何將畫面分割到不同的 HTML 容器或畫布,是進階開發者必備的技能。本文將從核心概念講起,並透過多個實作範例,帶你一步步在 Three.js 中建立、切換與同步多 Camera,最後整理常見的坑與最佳實踐,讓你能在專案中快速套用。


核心概念

1. 相機的基本類型

Three.js 內建兩種最常用的相機:

類型 說明 典型應用
PerspectiveCamera 具透視效果,模擬人眼視角。 遊戲、模擬、一般 3D 場景
OrthographicCamera 平行投影,無透視失真。 2D UI、等距視圖、工程圖

重點:即使是同一個場景,不同相機的投影矩陣(Projection Matrix)也會完全不同,切換相機時必須重新更新渲染器的視口(viewport)設定。

2. 渲染循環與相機切換

在 Three.js 中,渲染流程通常是:

function animate() {
    requestAnimationFrame( animate );
    // 更新場景、物件、控制器...
    renderer.render( scene, activeCamera );
}
  • activeCamera 可以是任何 Camera 物件。
  • 若要 同時渲染多支相機,只需要在同一次 animate 內呼叫多次 renderer.render,每次設定不同的視口(setViewport)或渲染目標(RenderTarget)。

3. 視口(Viewport)與畫布分割

renderer.setViewport( x, y, width, height ) 用來指定渲染結果在畫布中的位置與大小。配合 renderer.setScissorrenderer.setScissorTest( true ),可以 只在畫布的特定區域繪製,達到分割螢幕的效果。

4. 多 Render Target(RenderTarget)

若想把每支相機的畫面輸出到 不同的 <canvas>,或是後續做影像處理(如後期特效),可以使用 WebGLRenderTarget

const rt = new THREE.WebGLRenderTarget( width, height );
renderer.setRenderTarget( rt );
renderer.render( scene, camera );
renderer.setRenderTarget( null ); // 回到預設畫布

5. 同步相機參數

多支相機常常需要 保持相同的視角變化(例如同步縮放或旋轉),這時可以使用 THREE.CameraHelper 或直接把其中一支相機的 positionquaternionzoom 等屬性複製給其他相機:

camera2.position.copy( camera1.position );
camera2.quaternion.copy( camera1.quaternion );
camera2.zoom = camera1.zoom;
camera2.updateProjectionMatrix();

程式碼範例

以下示範 4 個常見的 多 Camera 使用情境,皆以 Three.js r152+ 為基礎,並附上詳細註解。

範例 1️⃣:最簡單的雙相機切換

情境:使用鍵盤 12 切換主視角與側視角。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const canvas = document.querySelector('#myCanvas');
const renderer = new THREE.WebGLRenderer({ canvas });
renderer.setSize( window.innerWidth, window.innerHeight );

const scene = new THREE.Scene();
scene.background = new THREE.Color( 0x202020 );

// 建立兩支相機
const camFront = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 100 );
camFront.position.set( 5, 5, 5 );
camFront.lookAt( 0, 0, 0 );

const camSide = new THREE.PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 0.1, 100 );
camSide.position.set( -5, 5, 0 );
camSide.lookAt( 0, 0, 0 );

// 控制器(只綁定到目前的 activeCamera)
let activeCamera = camFront;
const controls = new OrbitControls( activeCamera, canvas );

window.addEventListener( 'keydown', (e) => {
    if ( e.key === '1' ) {
        activeCamera = camFront;
    } else if ( e.key === '2' ) {
        activeCamera = camSide;
    }
    controls.object = activeCamera; // 更新控制器目標
    controls.update();
});

function animate() {
    requestAnimationFrame( animate );
    renderer.render( scene, activeCamera );
}
animate();

說明

  • 只在 render 時傳入 activeCamera,即可切換畫面。
  • 需要手動把 OrbitControlsobject 改成目前的相機,否則控制器仍作用於舊相機。

範例 2️⃣:分割螢幕(左/右)同時顯示兩支相機

情境:開發多人對戰或雙視角檢視時,常見左/右畫面各自呈現不同視角。

// 基本設定同上,只保留一支 scene、兩支相機
const camA = new THREE.PerspectiveCamera( 70, 1, 0.1, 100 );
camA.position.set( 5, 3, 5 );
camA.lookAt( 0, 0, 0 );

const camB = new THREE.PerspectiveCamera( 70, 1, 0.1, 100 );
camB.position.set( -5, 3, 5 );
camB.lookAt( 0, 0, 0 );

function renderSplitScreen() {
    const width = window.innerWidth;
    const height = window.innerHeight;

    // 左半部
    renderer.setViewport( 0, 0, width / 2, height );
    renderer.setScissor( 0, 0, width / 2, height );
    renderer.setScissorTest( true );
    renderer.render( scene, camA );

    // 右半部
    renderer.setViewport( width / 2, 0, width / 2, height );
    renderer.setScissor( width / 2, 0, width / 2, height );
    renderer.render( scene, camB );
}

function animate() {
    requestAnimationFrame( animate );
    renderSplitScreen();
}
animate();

要點

  • setScissorTest(true) 必須配合 setScissor,才能避免左半部渲染影響右半部。
  • 兩支相機的 aspect ratio 必須與各自的視口比例相符(上例皆為 1),否則畫面會被拉伸。

範例 3️⃣:畫中畫(Picture‑in‑Picture)

情境:在主視角之外,提供一個小視窗顯示「相機鏡頭」或「第三人稱」視角。

const mainCam = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 100 );
mainCam.position.set( 0, 2, 6 );

const pipCam = new THREE.PerspectiveCamera( 75, 1, 0.1, 100 );
pipCam.position.set( 0, 5, 0 );
pipCam.lookAt( 0, 0, 0 );

function renderPIP() {
    const w = window.innerWidth;
    const h = window.innerHeight;

    // 主畫面
    renderer.setViewport( 0, 0, w, h );
    renderer.setScissor( 0, 0, w, h );
    renderer.setScissorTest( true );
    renderer.render( scene, mainCam );

    // 右上角小視窗 (150x150)
    const pipSize = 150;
    renderer.setViewport( w - pipSize - 10, h - pipSize - 10, pipSize, pipSize );
    renderer.setScissor( w - pipSize - 10, h - pipSize - 10, pipSize, pipSize );
    renderer.render( scene, pipCam );
}
animate = () => {
    requestAnimationFrame( animate );
    renderPIP();
};
animate();

技巧

  • 小視窗的 aspect 需要根據 pipSize 計算,或直接固定為 1(正方形)。
  • 若要在小視窗中顯示 不同的場景(例如 UI、2D 圖表),只需要在 renderPIP 前切換 scene 變數即可。

範例 4️⃣:使用 RenderTarget 輸出至多個 <canvas>

情境:將相機畫面作為 即時影片來源,或在後續做特效(如景深、模糊)。

<!-- 兩個 canvas,分別顯示渲染結果 -->
<canvas id="canvasA"></canvas>
<canvas id="canvasB"></canvas>
// 初始化兩個渲染器(共用同一個 WebGL context)
const gl = document.createElement('canvas').getContext('webgl2');
const rendererA = new THREE.WebGLRenderer({ canvas: document.querySelector('#canvasA'), context: gl });
const rendererB = new THREE.WebGLRenderer({ canvas: document.querySelector('#canvasB'), context: gl });

rendererA.setSize( 400, 300 );
rendererB.setSize( 400, 300 );

const rt = new THREE.WebGLRenderTarget( 400, 300, {
    format: THREE.RGBAFormat,
    depthBuffer: true,
});

// 相機設定
const cam = new THREE.PerspectiveCamera( 60, 4/3, 0.1, 100 );
cam.position.set( 3, 3, 3 );
cam.lookAt( 0, 0, 0 );

function renderToTarget() {
    // 1. 先渲染到 RenderTarget
    rendererA.setRenderTarget( rt );
    rendererA.render( scene, cam );
    rendererA.setRenderTarget( null ); // 回到預設畫布

    // 2. 把 RenderTarget 的紋理繪製到第二個 canvas
    rendererB.clear();
    rendererB.render( scene, cam ); // 直接渲染(或使用後處理 Pass)
}
function animate() {
    requestAnimationFrame( animate );
    renderToTarget();
}
animate();

說明

  • WebGLRenderTarget 允許 離屏渲染,渲染出的影像會存放在 rt.texture 中。
  • 可把 rt.texture 作為 ShaderMaterial 的貼圖,進一步實作自訂後處理。
  • 多個 WebGLRenderer 共享同一個 WebGL context,減少 GPU 記憶體開銷。

常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方式 / 最佳實踐
相機的 aspect 與視口不一致 視口寬高改變(如視窗調整)卻忘記更新 camera.aspect window.onresize同時更新 camera.aspect 並呼叫 camera.updateProjectionMatrix()
渲染順序錯誤導致畫面被覆蓋 多次 renderer.render 時忘記設定 setScissorTest,或視口重疊。 每一次渲染前 設定 setViewport + setScissor,渲染完畢後若不再使用可 setScissorTest(false)
控制器仍指向舊相機 切換相機後,OrbitControlsPointerLockControls 等仍綁定原本的相機物件。 切換相機時 重新建立 控制器或直接設定 controls.object = newCamera
RenderTarget 記憶體泄漏 每幀重新建立 WebGLRenderTarget 一次性 建立後重複使用,或在不需要時呼叫 rt.dispose()
多渲染器共用同一個 canvas 時畫面閃爍 多個渲染器同時寫入同一個 <canvas>,導致 requestAnimationFrame 時序不一致。 避免 同時使用多個渲染器渲染同一 canvas,或將渲染工作排程在同一個 animate 迴圈內完成。

小技巧

  1. 使用 THREE.StereoCamera:若要同時產生左右眼畫面(VR/AR),可直接使用內建的立體相機。
  2. 相機同步:利用 camera.clone() 複製相機結構,之後只同步 positionquaternion,減少重建成本。
  3. 自訂相機參數camera.fovcamera.nearcamera.far 調整時,務必在變更後呼叫 camera.updateProjectionMatrix(),否則投影矩陣不會更新。

實際應用場景

場景 為何需要多 Camera 可能的實作方式
建築可視化 同時查看外觀、剖面與室內細節 主相機呈現全景,側相機作為剖面圖,使用分割螢幕或畫中畫呈現。
多人競技遊戲 每位玩家都有自己的視角 為每位玩家建立一支 PerspectiveCamera,在單一渲染循環中依序渲染至不同視口。
資料儀表板 同時顯示 3D 圖形與 2D 統計圖 3D 場景使用 PerspectiveCamera,統計圖使用 OrthographicCamera,分別渲染至不同 <canvas>
AR/VR 立體渲染 左右眼必須各自渲染一次 使用 THREE.StereoCamera 或自行建立左右眼相機,渲染至兩個 RenderTarget 再合併。
即時影片串流 把 3D 畫面作為直播來源 把主相機渲染至 WebGLRenderTarget,再把 rt.texture 交給 MediaRecorder 或 WebRTC 進行串流。

總結

多 Camera 在 Three.js 中不僅是 技術挑戰,更是 提升使用者體驗 的關鍵工具。本文從相機類型、渲染循環、視口管理、離屏渲染等核心概念出發,提供四個實務範例,並列出常見的坑與最佳實踐,最後以具體的應用情境說明其價值。

掌握

  1. 正確設定 camera.aspectrenderer.setViewport
  2. 同步 相機參數或控制器,以免產生不一致的操作感受。
  3. 合理使用 WebGLRenderTarget,在需要離屏渲染或後處理時不浪費資源。

只要依照本文的步驟與技巧,你就能在專案中自如地加入 多視窗、畫中畫、分割螢幕 等功能,讓 Three.js 應用更具互動性與專業度。祝開發順利,玩得開心!