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.setScissor 與 renderer.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 或直接把其中一支相機的 position、quaternion、zoom 等屬性複製給其他相機:
camera2.position.copy( camera1.position );
camera2.quaternion.copy( camera1.quaternion );
camera2.zoom = camera1.zoom;
camera2.updateProjectionMatrix();
程式碼範例
以下示範 4 個常見的 多 Camera 使用情境,皆以 Three.js r152+ 為基礎,並附上詳細註解。
範例 1️⃣:最簡單的雙相機切換
情境:使用鍵盤
1、2切換主視角與側視角。
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,即可切換畫面。 - 需要手動把
OrbitControls的object改成目前的相機,否則控制器仍作用於舊相機。
範例 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)。 |
| 控制器仍指向舊相機 | 切換相機後,OrbitControls、PointerLockControls 等仍綁定原本的相機物件。 |
切換相機時 重新建立 控制器或直接設定 controls.object = newCamera。 |
| RenderTarget 記憶體泄漏 | 每幀重新建立 WebGLRenderTarget。 |
一次性 建立後重複使用,或在不需要時呼叫 rt.dispose()。 |
| 多渲染器共用同一個 canvas 時畫面閃爍 | 多個渲染器同時寫入同一個 <canvas>,導致 requestAnimationFrame 時序不一致。 |
避免 同時使用多個渲染器渲染同一 canvas,或將渲染工作排程在同一個 animate 迴圈內完成。 |
小技巧
- 使用
THREE.StereoCamera:若要同時產生左右眼畫面(VR/AR),可直接使用內建的立體相機。 - 相機同步:利用
camera.clone()複製相機結構,之後只同步position、quaternion,減少重建成本。 - 自訂相機參數:
camera.fov、camera.near、camera.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 中不僅是 技術挑戰,更是 提升使用者體驗 的關鍵工具。本文從相機類型、渲染循環、視口管理、離屏渲染等核心概念出發,提供四個實務範例,並列出常見的坑與最佳實踐,最後以具體的應用情境說明其價值。
掌握:
- 正確設定
camera.aspect與renderer.setViewport。- 同步 相機參數或控制器,以免產生不一致的操作感受。
- 合理使用
WebGLRenderTarget,在需要離屏渲染或後處理時不浪費資源。
只要依照本文的步驟與技巧,你就能在專案中自如地加入 多視窗、畫中畫、分割螢幕 等功能,讓 Three.js 應用更具互動性與專業度。祝開發順利,玩得開心!