本文 AI 產出,尚未審核

Three.js 教學:多 Renderer 或分割畫面

簡介

在 3D 網頁應用中,常見的需求是 同時顯示多個視角分割畫面在不同的 DOM 容器裡渲染獨立的場景。例如:

  1. 車輛模擬器需要同時呈現車外視角與車內儀表板。
  2. 監控系統要把多個攝影機畫面併排顯示。
  3. 遊戲開發者想實作「分屏」對戰模式。

Three.js 天生支援多個 WebGLRenderer、多個 Scene、多個 Camera,只要妥善管理更新與渲染順序,就能輕鬆完成這類需求。本篇文章將從概念說明、實作範例、常見陷阱到最佳實踐,帶你一步步掌握 多 Renderer / 分割畫面 的技巧,適合從入門到中階的開發者使用。


核心概念

1. 為什麼要使用多個 Renderer?

  • 獨立的渲染設定:每個 Renderer 可以擁有自己的尺寸、像素比、抗鋸齒等參數,互不干擾。
  • 不同的輸出目標:Renderer 可以渲染到不同的 <canvas>,甚至渲染到 WebGLRenderTarget(離屏緩衝)再做後製。
  • 分割畫面 (Split‑Screen):只需要在同一個畫布上調整視口 (viewport) 即可,同時渲染多個相機畫面。

2. 基本流程

  1. 建立 多個 Canvas(或同一 Canvas 但使用不同 viewport)。
  2. 為每個 Canvas 建立獨立的 Renderer、Scene、Camera
  3. 在動畫迴圈 (requestAnimationFrame) 中,依序 更新物件設定視口呼叫 renderer.render(scene, camera)
  4. 如有需要,使用 renderer.setScissor 限制渲染區域,以避免不同視口之間相互覆寫。

3. Viewport 與 Scissor 的差異

  • Viewport:決定渲染結果在畫布中的 位置與大小,但渲染時仍會覆寫整個畫布。
  • Scissor:除了設定位置與大小外,還會 裁切 渲染區域,只有在 scissor 範圍內的像素會被寫入畫布。配合 renderer.setScissorTest(true) 使用,可避免畫面交叉。

程式碼範例

以下範例示範 三種常見的多 Renderer 實作,從最簡單的兩個 canvas,到同一 canvas 的分割畫面,最後展示離屏渲染再合併的技巧。每段程式碼皆附有說明註解。

範例 1️⃣:兩個獨立 Canvas 各渲染一個場景

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <title>Two Renderers Example</title>
  <style>
    body { margin:0; display:flex; }
    canvas { flex:1; }
  </style>
</head>
<body>
  <canvas id="canvasA"></canvas>
  <canvas id="canvasB"></canvas>

  <script type="module">
    import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.166/build/three.module.js';

    // ---------- Renderer A ----------
    const canvasA = document.getElementById('canvasA');
    const rendererA = new THREE.WebGLRenderer({ canvas: canvasA, antialias: true });
    rendererA.setSize(window.innerWidth / 2, window.innerHeight);
    const sceneA = new THREE.Scene();
    const cameraA = new THREE.PerspectiveCamera(45, canvasA.clientWidth / canvasA.clientHeight, 0.1, 100);
    cameraA.position.set(0, 2, 5);
    sceneA.add(new THREE.AxesHelper(2));

    const boxA = new THREE.Mesh(
      new THREE.BoxGeometry(1, 1, 1),
      new THREE.MeshStandardMaterial({ color: '#ff5555' })
    );
    sceneA.add(boxA);
    const lightA = new THREE.DirectionalLight('#ffffff', 1);
    lightA.position.set(5, 5, 5);
    sceneA.add(lightA);

    // ---------- Renderer B ----------
    const canvasB = document.getElementById('canvasB');
    const rendererB = new THREE.WebGLRenderer({ canvas: canvasB, antialias: true });
    rendererB.setSize(window.innerWidth / 2, window.innerHeight);
    const sceneB = new THREE.Scene();
    const cameraB = new THREE.PerspectiveCamera(45, canvasB.clientWidth / canvasB.clientHeight, 0.1, 100);
    cameraB.position.set(0, 2, 5);
    sceneB.add(new THREE.AxesHelper(2));

    const sphereB = new THREE.Mesh(
      new THREE.SphereGeometry(0.7, 32, 32),
      new THREE.MeshStandardMaterial({ color: '#55aaff' })
    );
    sceneB.add(sphereB);
    const lightB = new THREE.PointLight('#ffffff', 1);
    lightB.position.set(-5, 5, 5);
    sceneB.add(lightB);

    // ---------- Animation Loop ----------
    function animate() {
      requestAnimationFrame(animate);
      boxA.rotation.y += 0.01;
      sphereB.rotation.x += 0.02;

      rendererA.render(sceneA, cameraA);
      rendererB.render(sceneB, cameraB);
    }
    animate();

    // Resize handling
    window.addEventListener('resize', () => {
      const w = window.innerWidth / 2;
      const h = window.innerHeight;
      rendererA.setSize(w, h);
      rendererB.setSize(w, h);
      cameraA.aspect = w / h; cameraA.updateProjectionMatrix();
      cameraB.aspect = w / h; cameraB.updateProjectionMatrix();
    });
  </script>
</body>
</html>

重點:每個 Renderer 都有自己的 <canvas>,所以可以各自設定不同的解析度、抗鋸齒或後處理效果。


範例 2️⃣:單一 Canvas 分割成左右兩個視口(Split‑Screen)

import * as THREE from 'three';

// 建立單一 renderer,綁定到 body
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 共用同一個 scene(也可以分開)
const scene = new THREE.Scene();
scene.add(new THREE.AxesHelper(3));

// 左側相機(俯視)
const camTop = new THREE.OrthographicCamera(-5, 5, 5, -5, 0.1, 100);
camTop.position.set(0, 10, 0);
camTop.lookAt(0, 0, 0);

// 右側相機(透視)
const camPersp = new THREE.PerspectiveCamera(60, 1, 0.1, 100);
camPersp.position.set(5, 3, 5);
camPersp.lookAt(0, 0, 0);

// 加入兩個測試模型
const cube = new THREE.Mesh(
  new THREE.BoxGeometry(2, 2, 2),
  new THREE.MeshStandardMaterial({ color: '#ff8800' })
);
scene.add(cube);
const sphere = new THREE.Mesh(
  new THREE.SphereGeometry(1, 32, 32),
  new THREE.MeshStandardMaterial({ color: '#0088ff' })
);
sphere.position.set(-3, 1, 0);
scene.add(sphere);

// 簡易光源
const light = new THREE.DirectionalLight('#ffffff', 1);
light.position.set(5, 10, 5);
scene.add(light);

// ---------- 渲染函式 ----------
function render() {
  requestAnimationFrame(render);
  cube.rotation.y += 0.01;
  sphere.rotation.x += 0.015;

  // --- 左半部 (0~0.5) ---
  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, camTop);

  // --- 右半部 (0.5~1) ---
  renderer.setViewport(width / 2, 0, width / 2, height);
  renderer.setScissor(width / 2, 0, width / 2, height);
  renderer.render(scene, camPersp);
}
render();

// Resize
window.addEventListener('resize', () => {
  const w = window.innerWidth, h = window.innerHeight;
  renderer.setSize(w, h);
});

技巧:使用 renderer.setScissorTest(true) 可以保證左半部的渲染不會影響右半部,避免畫面重疊。


範例 3️⃣:離屏渲染 (RenderTarget) + 合併顯示

此範例先把兩個不同的場景渲染到 RenderTarget,再將結果貼到同一個畫布的兩個四分之一區域,適合做 鏡像、後製特效

import * as THREE from 'three';

// 建立主 renderer
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// 兩個 RenderTarget
const rtParams = { minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter, format: THREE.RGBAFormat };
const targetA = new THREE.WebGLRenderTarget(512, 512, rtParams);
const targetB = new THREE.WebGLRenderTarget(512, 512, rtParams);

// Scene A
const sceneA = new THREE.Scene();
const camA = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camA.position.set(3, 2, 4);
camA.lookAt(0, 0, 0);
sceneA.add(new THREE.AxesHelper(2));
sceneA.add(new THREE.Mesh(
  new THREE.TorusKnotGeometry(1, 0.3, 100, 16),
  new THREE.MeshStandardMaterial({ color: '#ff00ff' })
));
sceneA.add(new THREE.HemisphereLight());

// Scene B
const sceneB = new THREE.Scene();
const camB = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camB.position.set(-3, 2, -4);
camB.lookAt(0, 0, 0);
sceneB.add(new THREE.AxesHelper(2));
sceneB.add(new THREE.Mesh(
  new THREE.IcosahedronGeometry(1, 0),
  new THREE.MeshStandardMaterial({ color: '#00ffff' })
));
sceneB.add(new THREE.DirectionalLight('#ffffff', 1));

// 全螢幕四角佈局的 helper 平面
const quadGeo = new THREE.PlaneGeometry(2, 2);
const materialA = new THREE.MeshBasicMaterial({ map: targetA.texture });
const materialB = new THREE.MeshBasicMaterial({ map: targetB.texture });
const quadA = new THREE.Mesh(quadGeo, materialA);
const quadB = new THREE.Mesh(quadGeo, materialB);
quadA.position.set(-0.5, 0.5, -1);   // 左上
quadB.position.set(0.5, -0.5, -1);   // 右下
const overlayScene = new THREE.Scene();
overlayScene.add(quadA, quadB);
const overlayCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 10);

// ---------- Animation ----------
function animate() {
  requestAnimationFrame(animate);
  // 1. Render each scene to its own target
  renderer.setRenderTarget(targetA);
  renderer.render(sceneA, camA);
  renderer.setRenderTarget(targetB);
  renderer.render(sceneB, camB);
  // 2. Render overlay (two quads) to screen
  renderer.setRenderTarget(null);
  renderer.clear();
  renderer.render(overlayScene, overlayCam);
}
animate();

應用:可把 即時陰影貼圖、後製濾鏡 先渲染到離屏緩衝,再在主畫面上以不同方式拼接或混合。


常見陷阱與最佳實踐

陷阱 說明 解決方式
渲染順序錯亂 多個 Renderer 時,若先渲染後面的 Canvas,前面的 Canvas 可能被覆寫。 確保在同一幀內 依序呼叫 renderer.render,或使用 requestAnimationFrame 的同一回呼。
Viewport/Scissor 設定遺漏 未呼叫 setScissorTest(true) 時,左側視口的渲染會覆寫右側畫面。 在使用 setScissor一定要開啟 scissor test
相機長寬比不正確 視口尺寸改變後相機的 aspect 未更新,畫面會變形。 resize 事件中 更新相機的 aspect 並呼叫 updateProjectionMatrix()
效能問題 多個 Renderer 同時渲染會占用大量 GPU 記憶體,尤其使用高解析度 RenderTarget。 - 只在需要時開啟離屏渲染。
- 控制 RenderTarget 的解析度 (如 512×512)。
- 使用 renderer.setPixelRatio(window.devicePixelRatio) 只在必要時調整。
事件傳遞混亂 多個 Canvas 時,滑鼠/觸控事件會只發送到最上層的 Canvas。 為每個 Canvas 分別掛上事件監聽,或在最外層容器捕獲事件後自行分派。

最佳實踐

  1. 將共用資源(幾何、材質)抽離,避免重複載入。
  2. 使用 requestAnimationFrame 的單一回呼,在其中完成所有渲染與更新,保持時間同步。
  3. 視窗大小改變時統一調整:寫一個 onResize() 函式,集中處理所有 renderer、camera 的尺寸更新。
  4. 在開發階段啟用 renderer.debug.checkShaderErrors = true,可快速定位因多 renderer 造成的 shader 錯誤。

實際應用場景

場景 需求 實作要點
多視角車輛模擬 同時呈現外部第三人稱視角與車內儀表板 使用兩個 Canvas,左側渲染外部視角,右側渲染儀表板(可用 RenderTarget 先渲染儀表板再貼圖)。
即時監控牆 多路攝影機畫面併排顯示,需支援即時切換 單一 Canvas + 多 viewport + scissor,根據使用者選擇動態調整視口排列。
分屏對戰遊戲 兩位玩家各自控制視角,畫面同步更新 使用單一 Canvas,兩個相機分別渲染左/右半屏,並共享同一個 scene 以減少記憶體佔用。
AR/VR 雙目顯示 左右眼需分別渲染不同的影像 利用 RenderTarget 為每隻眼渲染,最後交給 WebXR API 輸出。
後製特效合成 先渲染光暈、模糊等效果,再與主畫面混合 離屏渲染到 RenderTarget,使用 THREE.ShaderPass 或自訂 shader 合併至最終畫面。

總結

  • 多 Renderer / 分割畫面 是 Three.js 強大的彈性之一,讓開發者能同時呈現多個 3D 視角或把不同的渲染結果組合在一起。
  • 主要概念在於:獨立的 Canvas、Renderer、Scene、Camera,或 同一 Canvas + Viewport + Scissor,以及 RenderTarget 的離屏渲染。
  • 實作時務必注意 渲染順序、相機比例、Scissor 設定,並以 單一 animation loop 來保證畫面同步。
  • 依照需求選擇最合適的架構:
    • 兩個 Canvas → 各自設定不同解析度、後處理。
    • 單一 Canvas 分割 → 使用 Viewport/Scissor,效能較佳。
    • 離屏渲染 → 需要後製或特效合成時的首選。

掌握上述技巧後,你就能在專案中靈活運用 多視窗與多渲染場景,打造出更具沉浸感與互動性的 Web 3D 體驗。祝你開發順利,玩得開心!