Three.js 教學 – 多視窗與多渲染場景
主題:合併視圖呈現
簡介
在 WebGL 應用中,我們常會遇到需要同時顯示多個 3D 視角的情況,例如 左上角的主視圖、右上角的鳥瞰圖、左下角的第一人稱視角,甚至在同一畫面中混合 2D UI。若直接在同一個 renderer 內切換相機,會導致渲染效率低下、畫面閃爍,且難以維護。
合併視圖呈現(Viewports) 是 Three.js 提供的解決方案:透過設定渲染區域(viewport)與剪裁矩形(scissor),可以在同一個 WebGL canvas 中同時渲染多個獨立的場景或相機,達到「多視窗」的效果,同時保持高效能與程式碼可讀性。
本篇文章將從概念說明、實作範例、常見陷阱與最佳實踐,逐步帶你完成一個 四分割畫面 的範例,讓你能快速把握合併視圖的核心技巧,並在實務上應用於資料可視化、遊戲 UI、虛擬實境等多種場景。
核心概念
1. Viewport 與 Scissor 的差別
- Viewport:決定渲染結果寫入 canvas 的哪個矩形區域。設定方式為
renderer.setViewport( x, y, width, height )。 - Scissor:決定實際繪製時的裁切範圍,避免渲染到其他視窗之外。設定方式為
renderer.setScissor( x, y, width, height ),同時必須開啟renderer.setScissorTest( true )。
小技巧:在同一幀內切換多個 viewport 時,必須先設定
scissor,再設定viewport,否則會出現「畫面重疊」的問題。
2. 多場景 vs. 同場景
- 多場景:每個視窗使用不同的
THREE.Scene,適合需要完全獨立的渲染內容(例如左視圖顯示模型、右視圖顯示光照圖)。 - 同場景:共用同一個
THREE.Scene,只換相機。適合同一模型的不同視角或不同渲染效果(例如主視圖渲染完整光照,旁視圖只渲染線框)。
兩者皆可搭配 renderer.clearDepth() 來避免深度緩衝區相互干擾。
3. 渲染順序與深度緩衝
在渲染多個視窗時,每次渲染完畢都要呼叫 renderer.clearDepth(),讓後續的渲染可以重新寫入深度緩衝,避免前一個視窗的深度資訊阻擋後面的畫面。
程式碼範例
以下範例示範 四分割畫面:左上顯示正交投影、右上顯示透視投影、左下顯示光照貼圖、右下顯示線框模式。每個視窗都使用 不同的相機,但共用同一個場景與渲染器。
1️⃣ 基本設定(HTML + 初始化)
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<title>Three.js 合併視圖範例</title>
<style>
body { margin:0; overflow:hidden; }
canvas { display:block; }
</style>
</head>
<body>
<script src="https://cdn.jsdelivr.net/npm/three@0.162.0/build/three.min.js"></script>
<script src="app.js"></script>
</body>
</html>
// app.js
// ------------------------------------------------------------
// 1. 基本渲染器、相機、場景建立
// ------------------------------------------------------------
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// 共享的場景
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202020);
// 加入簡單的幾何體 (Box) 與光源
const geometry = new THREE.BoxGeometry(2, 2, 2);
const material = new THREE.MeshStandardMaterial({ color: 0x156289, metalness: 0.6, roughness: 0.4 });
const cube = new THREE.Mesh(geometry, material);
scene.add(cube);
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 10, 7);
scene.add(light);
說明:此段程式碼僅建立渲染器、共用場景與一個旋轉方塊,後續會根據不同 viewport 變換相機與渲染設定。
2️⃣ 四個相機的建立
// ------------------------------------------------------------
// 2. 四個相機:正交、透視、光照貼圖、線框
// ------------------------------------------------------------
const aspect = window.innerWidth / window.innerHeight;
// 正交投影(左上)
const orthoCam = new THREE.OrthographicCamera(-3, 3, 3, -3, 0.1, 100);
orthoCam.position.set(5, 5, 5);
orthoCam.lookAt(0, 0, 0);
// 透視投影(右上)
const persCam = new THREE.PerspectiveCamera(60, aspect, 0.1, 100);
persCam.position.set(5, 5, 5);
persCam.lookAt(0, 0, 0);
// 光照貼圖相機(左下)—— 用於渲染光照緩衝
const lightCam = new THREE.PerspectiveCamera(45, aspect, 0.1, 100);
lightCam.position.copy(light.position);
lightCam.lookAt(0, 0, 0);
// 線框相機(右下)—— 同透視相機,只改渲染模式
const wireCam = persCam.clone();
小提醒:正交相機的
left/right/top/bottom需要根據畫面比例手動調整,否則會出現變形。
3️⃣ 渲染迴圈與 Viewport 設定
// ------------------------------------------------------------
// 3. 渲染迴圈:分割四個視窗
// ------------------------------------------------------------
function onWindowResize() {
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', onWindowResize);
function render() {
// 讓方塊持續旋轉
cube.rotation.x += 0.01;
cube.rotation.y += 0.015;
// 取得畫布尺寸
const width = window.innerWidth;
const height = window.innerHeight;
// ---- 左上:正交投影 ----
renderer.setViewport(0, height / 2, width / 2, height / 2);
renderer.setScissor(0, height / 2, width / 2, height / 2);
renderer.setScissorTest(true);
renderer.setClearColor(0x1a1a1a);
renderer.render(scene, orthoCam);
renderer.clearDepth(); // 清除深度緩衝
// ---- 右上:透視投影 ----
renderer.setViewport(width / 2, height / 2, width / 2, height / 2);
renderer.setScissor(width / 2, height / 2, width / 2, height / 2);
renderer.setClearColor(0x2a2a2a);
renderer.render(scene, persCam);
renderer.clearDepth();
// ---- 左下:光照貼圖(僅渲染光源) ----
renderer.setViewport(0, 0, width / 2, height / 2);
renderer.setScissor(0, 0, width / 2, height / 2);
renderer.setClearColor(0x0a0a0a);
// 暫時關閉材質,只留下光源的影子
const originalOverride = scene.overrideMaterial;
scene.overrideMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff });
renderer.render(scene, lightCam);
scene.overrideMaterial = originalOverride;
renderer.clearDepth();
// ---- 右下:線框模式 ----
renderer.setViewport(width / 2, 0, width / 2, height / 2);
renderer.setScissor(width / 2, 0, width / 2, height / 2);
renderer.setClearColor(0x151515);
// 使用線框材質渲染
const wireMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00, wireframe: true });
cube.material = wireMaterial;
renderer.render(scene, wireCam);
cube.material = material; // 恢復原本材質
renderer.clearDepth();
requestAnimationFrame(render);
}
render();
重點說明
- 每一次切換視窗前,都要 設定
setScissor+setViewport,並在最後呼叫clearDepth()。- 若要在某個視窗只渲染特定物件,可使用
scene.overrideMaterial或直接在渲染前隱藏不需要的物件。- 為避免相機比例失真,建議在
onWindowResize中同步更新各相機的aspect(此範例簡化為同一比例)。
4️⃣ 進階範例:多場景與渲染目標(RenderTarget)
以下示範如何在左下視窗顯示 渲染目標(RenderTarget),即先把場景渲染到離屏緩衝,再以平面貼圖呈現在畫布上。這在製作鏡子、監視器等效果時非常有用。
// 建立離屏渲染目標
const renderTarget = new THREE.WebGLRenderTarget(512, 512, {
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat
});
// 另一個場景(只放一個球體)
const miniScene = new THREE.Scene();
const miniSphere = new THREE.Mesh(
new THREE.SphereGeometry(1, 32, 32),
new THREE.MeshStandardMaterial({ color: 0xff6600 })
);
miniScene.add(miniSphere);
miniScene.add(new THREE.AmbientLight(0x404040));
// 用同一個相機渲染
function renderMini() {
renderer.setRenderTarget(renderTarget);
renderer.render(miniScene, persCam);
renderer.setRenderTarget(null); // 回到預設畫布
}
// 把離屏貼圖掛到主場景的平面上
const screenPlane = new THREE.Mesh(
new THREE.PlaneGeometry(2, 2),
new THREE.MeshBasicMaterial({ map: renderTarget.texture })
);
screenPlane.position.set(-2, -2, 0);
scene.add(screenPlane);
// 在主渲染迴圈中呼叫 renderMini()
function render() {
// ...前面的四個視窗渲染
// 渲染離屏場景
renderMini();
// 把離屏貼圖顯示在左下視窗(與光照貼圖示例類似)
renderer.setViewport(0, 0, width / 2, height / 2);
renderer.setScissor(0, 0, width / 2, height / 2);
renderer.render(scene, persCam); // 直接渲染主場景,平面會顯示離屏貼圖
requestAnimationFrame(render);
}
render();
說明:
- 先把
miniScene渲染到renderTarget。- 再把
renderTarget.texture作為材質貼到主場景的平面上。- 這樣左下視窗就會顯示「畫中畫」的效果,適合監控視窗、鏡面反射等需求。
常見陷阱與最佳實踐
| 常見問題 | 說明 | 解決方式 |
|---|---|---|
| 畫面閃爍 / 失真 | 未正確呼叫 renderer.clearDepth() 或 setScissorTest(false)。 |
每次渲染完一個 viewport 後呼叫 clearDepth();只在需要裁切時開啟 setScissorTest(true)。 |
| 相機比例失真 | 畫布縮放後未同步更新相機的 aspect。 |
在 onWindowResize 裡同時更新 persCam.aspect = newAspect; persCam.updateProjectionMatrix();(正交相機則需手動調整 left/right/top/bottom)。 |
| 深度衝突 | 多視窗共用同一個深度緩衝,導致後面的視窗被前面的深度遮擋。 | 使用 renderer.clearDepth() 或在需要時建立 獨立的渲染目標(WebGLRenderTarget)來分離深度緩衝。 |
| 效能下降 | 每個視窗都渲染完整的場景,導致 GPU 負荷過高。 | 只渲染需要的物件(使用 scene.overrideMaterial、object.visible 或 layers),或降低離屏渲染解析度。 |
| 事件座標錯位 | 滑鼠或觸控座標相對於整個 canvas,需要自行映射到對應的 viewport。 | 在事件處理函式中先取得相對座標,再根據 viewport 計算子視窗的比例與偏移。 |
最佳實踐小結
- 先規劃視窗布局:確定每個 viewport 的位置與大小,並以常數或配置檔管理,避免硬編碼。
- 分離渲染狀態:使用
renderer.state.reset()或手動重設clearColor、clearDepth,防止上一個視窗的狀態污染。 - 層 (Layers) 的運用:Three.js 的
object.layers可以讓同一場景中不同物件只在特定相機可見,減少overrideMaterial的切換成本。 - 效能測試:使用 Chrome DevTools 的 GPU 計時與
stats.js觀測每個視窗的渲染耗時,適時調整解析度或簡化幾何。
實際應用場景
| 場景 | 需求 | 合併視圖的好處 |
|---|---|---|
| 工程建模檢視 | 同時顯示模型、剖面圖、結構分析圖 | 一畫布即可呈現多種資訊,使用者無需切換頁面 |
| 即時資料儀表板 | 3D 地圖 + 2D 圖表 + 監控視窗 | 透過 WebGLRenderTarget 把 3D 渲染結果作為貼圖嵌入 2D Canvas,降低跨框架溝通成本 |
| 多人線上遊戲 | 主視角 + 小地圖 + 觀戰視角 | 只需要一個渲染器即可同時輸出三個視角,節省記憶體與 GPU 資源 |
| 虛擬現實 (VR) 調試 | 左眼、右眼 + 除錯視窗 | 使用兩個 viewport 渲染左/右眼,額外的 viewport 顯示相機參數或深度圖,便於即時調校 |
| AR 鏡頭 + 3D 物件 | 手機相機畫面 + 3D 物件預覽 | 把相機畫面作為背景渲染在一個 viewport,另一個 viewport 渲染 3D 物件,實現「畫中畫」效果 |
總結
合併視圖(Viewport + Scissor)是 Three.js 在單一 canvas 中同時呈現多個 3D 視角的關鍵技巧。透過正確的 viewport 設定、深度緩衝清理、以及 相機與場景的靈活組合,我們可以在不增加渲染器數量的前提下,打造高效、易維護的多視窗應用。
本文從概念說明、完整範例、常見陷阱到實務場景,提供了 從入門到中階 的完整指引。只要掌握以下核心要點,你就能:
- 快速切割畫布,在同一幀內渲染多個獨立視圖。
- 使用
WebGLRenderTarget實作畫中畫、鏡面或監控畫面。 - 善用 Layers 與 OverrideMaterial,在同一場景中只渲染必要的物件,提升效能。
希望這篇文章能幫助你在專案中順利運用合併視圖,為使用者帶來更豐富、互動的 3D 體驗。祝開發愉快! 🚀