在現代 Web 開發中,3D 可視化需求日益增長,特別是在 React 生態系統中實現多 3D 場景的展示與交互。本文通過對比兩種實現方案,探討 React 中構建多 3D 場景的最佳實踐,分析它們的技術特點、性能表現和適用場景。
方案一:React Three Fiber 組件化方案
采用 @react-three/fiber(RTF)框架,將每個 3D 場景封裝為獨立的
<Canvas>
組件,充分利用 React 的組件化思想管理場景元素。方案二:原生 Three.js 多視口方案
直接使用 Three.js 原生 API,通過單一 Canvas 元素手動管理多個視口。所有場景共享同一個渲染器,利用視口裁剪技術實現多場景并行渲染。
此方案特別適合需要呈現大量 3D 元素且對性能要求較高的場景,如產品展示墻或 3D 數據可視化。通過精細的視口管理和資源優化,可在單個 Canvas 中高效渲染數十至上百個獨立 3D 場景。
?在Three.js開發中,多場景渲染是一個常見挑戰。以商業網站為例,當需要展示多個3D模型時,開發者往往會為每個模型創建獨立的Canvas元素和Renderer實例。也就是本文說的方案一。
然而,這種做法會帶來兩個顯著問題:
WebGL上下文數量限制 瀏覽器通常對WebGL上下文數量設置了8個的上限,超出限制時,系統會自動釋放最早創建的上下文。
WebGL資源無法共享 不同WebGL上下文之間不能共用資源。例如,當兩個Canvas都需要加載相同的10MB模型和20MB紋理時,這些資源會被重復加載兩次。這不僅導致初始化、著色器編譯等操作重復執行,而且隨著Canvas數量增加,性能問題會愈發嚴重。
另一種解決方案是在整個背景中使用單一Canvas填充視口,并通過其他元素來模擬"虛擬畫布"(virtual canvas)。具體實現方式是:僅在主Canvas中加載一個Renderer,同時為每個virtual canvas創建獨立的場景(Scene)。我們只需確保每個virtual canvas的位置準確,THREE.js就能將它們正確渲染到屏幕對應位置。
這種方法僅使用一個Canvas和一個WebGL上下文,既解決了資源共享問題,又避免了WebGL上下文數量限制的風險。?
?方案一:每個場景擁有獨立的Canvas組件(@react-three/fiber)
import { useRef, useEffect, useState } from 'react'
import { Canvas, useFrame } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import * as THREE from 'three'
import { Flex } from 'antd'
import './index.less'
type SceneData = {id: numbergeometry: THREE.BufferGeometrycolor: THREE.Color
}const SceneItem = ({geometry,color,
}: {geometry: THREE.BufferGeometrycolor: THREE.Color
}) => {const meshRef = useRef<THREE.Mesh>(null)useFrame(() => {if (meshRef.current) {meshRef.current.rotation.y += 0.01}})return (<mesh ref={meshRef} geometry={geometry}><meshStandardMaterialcolor={color}roughness={0.5}metalness={0}flatShading/></mesh>)
}const SceneWithControls = ({ sceneData }: { sceneData: SceneData }) => {return (<Canvascamera={{ position: [0, 0, 2], fov: 50 }}gl={{ antialias: true }}style={{ width: '300px', height: '300px' }}><SceneItem geometry={sceneData.geometry} color={sceneData.color} /><hemisphereLight intensity={3} color={0xaaaaaa} groundColor={0x444444} /><directionalLight position={[1, 1, 1]} intensity={1.5} color={0xffffff} /><OrbitControls minDistance={2} maxDistance={5} /></Canvas>)
}const MultipleElementsDemo = () => {const [scenes, setScenes] = useState<SceneData[]>([])useEffect(() => {if (scenes.length === 0) {const geometries = [new THREE.BoxGeometry(1, 1, 1),new THREE.SphereGeometry(0.5, 12, 8),new THREE.DodecahedronGeometry(0.5),new THREE.CylinderGeometry(0.5, 0.5, 1, 12),]const newScenes = Array.from({ length: 10 }, (_, i) => ({id: i + 1,geometry: geometries[Math.floor(Math.random() * geometries.length)],color: new THREE.Color().setHSL(Math.random(), 1, 0.75),}))setScenes(newScenes)}}, [scenes])return (<div className="multi-scene" ><Flex gap={20} wrap >{scenes.map((scene) => (<div key={scene.id} className="list-item"><SceneWithControls sceneData={scene} /><div>Scene {scene.id}</div></div>))}</Flex></div>)
}export default MultipleElementsDemo
代碼解析
多Canvas架構:
為每個3D場景創建一個獨立的
<Canvas>
組件(共10個)每個Canvas擁有自己獨立的WebGL上下文、場景圖和渲染循環
場景數據結構:
type SceneData = {id: numbergeometry: THREE.BufferGeometrycolor: THREE.Color
}
存儲幾何體和顏色等差異化的場景數據
組件結構:
SceneWithControls
:包裝單個場景的完整環境(燈光、控制器等)
SceneItem
:處理單個3D對象的渲染和動畫
動態場景生成:
Array.from({ length: 40 }, (_, i) => ({id: i + 1,geometry: geometries[Math.floor(Math.random() * geometries.length)],color: new THREE.Color().setHSL(Math.random(), 1, 0.75)
}))
獨立動畫控制:
useFrame(() => {meshRef.current.rotation.y += 0.01 // 每個場景獨立動畫
})
性能問題:
內存消耗:10個+獨立WebGL上下文占用大量內存
渲染開銷:同時維護10個渲染循環(即使場景不可見)
GPU資源:重復創建相似資源(如幾何體、材質)
方案二:基于原生 Three.js 的多視口實現
import { useRef, useEffect, useState } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import './index.less';// 場景項類型定義
type SceneItem = {id: number;geometry: THREE.BufferGeometry;color: THREE.Color;position: [number, number];
};// 場景數據初始化
const initializeSceneData = () => {const geometries = [new THREE.BoxGeometry(1, 1, 1),new THREE.SphereGeometry(0.5, 12, 8),new THREE.DodecahedronGeometry(0.5),new THREE.CylinderGeometry(0.5, 0.5, 1, 12),];return Array.from({ length: 40 }, (_, i) => {const row = Math.floor(i / 5);const col = i % 5;return {id: i + 1,geometry: geometries[Math.floor(Math.random() * geometries.length)],color: new THREE.Color().setHSL(Math.random(), 1, 0.75),position: [col * 370 + 40, row * 370 + 40] as [number, number],};});
};// 創建單個場景
const createScene = (scene: SceneItem, material: THREE.MeshStandardMaterial) => {const sceneObj = new THREE.Scene();// 創建幾何體網格const mesh = new THREE.Mesh(scene.geometry, material.clone());mesh.material.color = scene.color;sceneObj.add(mesh);// 添加光照sceneObj.add(new THREE.HemisphereLight(0xaaaaaa, 0x444444, 3));const light = new THREE.DirectionalLight(0xffffff, 1.5);light.position.set(1, 1, 1);sceneObj.add(light);return sceneObj;
};// 初始化所有場景
const initializeScenes = (scenes: SceneItem[]) => {const material = new THREE.MeshStandardMaterial({roughness: 0.5,metalness: 0,flatShading: true,});return scenes.map(scene => createScene(scene, material));
};// 更新Canvas位置
const updateCanvasPosition = (container: HTMLDivElement, canvas: HTMLCanvasElement) => {const containerRect = container.getBoundingClientRect();canvas.style.transform = `translateY(${window.scrollY}px)`;canvas.style.top = `${containerRect.top}px`;
};// 清理資源
const cleanupResources = (animationId: number,renderer: THREE.WebGLRenderer,sceneControls: OrbitControls[],sceneObjects: THREE.Scene[]
) => {window.cancelAnimationFrame(animationId);renderer.dispose();// 清理控制器sceneControls.forEach(ctrl => ctrl.dispose());// 清理幾何體和材質sceneObjects.forEach(scene => {scene.traverse(obj => {if (obj instanceof THREE.Mesh) {obj.geometry.dispose();if (Array.isArray(obj.material)) {obj.material.forEach(m => m.dispose());} else {obj.material.dispose();}}});});
};const MultiViewportDemo = () => {const canvasRef = useRef<HTMLCanvasElement>(null);const containerRef = useRef<HTMLDivElement>(null);const scenesRef = useRef<HTMLDivElement>(null);const [scenes, setScenes] = useState<SceneItem[]>([]);const rendererRef = useRef<THREE.WebGLRenderer | null>(null);const animationRef = useRef<number>(0);const sceneCamerasRef = useRef<THREE.PerspectiveCamera[]>([]);const sceneControlsRef = useRef<OrbitControls[]>([]);const sceneObjectsRef = useRef<THREE.Scene[]>([]);// 初始化場景數據useEffect(() => {const newScenes = initializeSceneData();setScenes(newScenes);}, []);// 初始化Three.js和滾動處理useEffect(() => {if (!scenes.length || !canvasRef.current || !scenesRef.current || !containerRef.current) return;const canvas = canvasRef.current;const container = containerRef.current;// 初始化渲染器const renderer = new THREE.WebGLRenderer({ canvas,antialias: true,});rendererRef.current = renderer;// 初始化所有場景sceneObjectsRef.current = initializeScenes(scenes);// 渲染函數const render = () => {if (!rendererRef.current) return;const renderer = rendererRef.current;const width = window.innerWidth;const height = window.innerHeight;// 更新渲染器尺寸if (canvas.width !== width || canvas.height !== height) {renderer.setSize(width, height, false);}// 初始清除renderer.setClearColor(0xffffff);renderer.setScissorTest(false);renderer.clear();// 設置公共渲染狀態renderer.setClearColor(0xe0e0e0);renderer.setScissorTest(true);// 渲染每個場景scenes.forEach((scene, i) => {const element = scenesRef.current?.children[i] as HTMLElement;if (!element) return;const rect = element.getBoundingClientRect();// 檢查是否在可視區域內if (rect.bottom < 0 ||rect.top > window.innerHeight ||rect.right < 0 ||rect.left > window.innerWidth) return;// 計算視口參數const viewportWidth = rect.right - rect.left;const viewportHeight = rect.bottom - rect.top;const left = rect.left;const bottom = height - rect.bottom;// 初始化相機(延遲初始化)if (!sceneCamerasRef.current[i]) {const camera = new THREE.PerspectiveCamera(50, viewportWidth / viewportHeight, 1, 10);camera.position.z = 2;sceneCamerasRef.current[i] = camera;// 初始化控制器const controls = new OrbitControls(camera, element);controls.minDistance = 2;controls.maxDistance = 5;sceneControlsRef.current[i] = controls;}// 更新相機比例const camera = sceneCamerasRef.current[i];camera.aspect = viewportWidth / viewportHeight;camera.updateProjectionMatrix();// 更新控制器sceneControlsRef.current[i].update();// 更新場景動畫const mesh = sceneObjectsRef.current[i].children[0] as THREE.Mesh;mesh.rotation.y += 0.01;// 設置視口并渲染renderer.setViewport(left, bottom, viewportWidth, viewportHeight);renderer.setScissor(left, bottom, viewportWidth, viewportHeight);renderer.render(sceneObjectsRef.current[i], camera);});animationRef.current = requestAnimationFrame(render);};// 開始渲染循環render();updateCanvasPosition(container, canvas);// 事件監聽器const handleScroll = () => {updateCanvasPosition(container, canvas);};const handleResize = () => {renderer.setSize(window.innerWidth, window.innerHeight, false);updateCanvasPosition(container, canvas);};window.addEventListener('scroll', handleScroll);window.addEventListener('resize', handleResize);// 清理函數return () => {cleanupResources(animationRef.current,renderer,sceneControlsRef.current,sceneObjectsRef.current);window.removeEventListener('scroll', handleScroll);window.removeEventListener('resize', handleResize);};}, [scenes]);return (<div className="multi-scene-container" ref={containerRef}>{/* Three.js畫布 */}<canvas ref={canvasRef} id="three-canvas"style={{position: 'fixed',left: 0,width: '100%',height: '100%',zIndex: 0,pointerEvents: 'none', // 允許穿透到下方元素}}/>{/* 視口定位元素 */}<div ref={scenesRef}style={{ position: 'relative',zIndex: 1,width: '100%',}}>{scenes.map((scene) => (<divkey={scene.id}className="scene-viewport"style={{position: 'absolute',left: `${scene.position[0]}px`,top: `${scene.position[1]}px`,width: '300px',height: '300px',pointerEvents: 'auto', // 恢復交互}}><div className="scene-label">Scene {scene.id}</div></div>))}</div>{/* 撐開容器高度 */}<div style={{ height: `${Math.ceil(scenes.length / 5) * 370}px`,width: '100%'}} /></div>);
};export default MultiViewportDemo;
核心實現原理
單Canvas多視口架構:
// 單個Canvas承載所有渲染 <canvas ref={canvasRef} style={{ position: 'fixed' }} />
視口分割技術:
// 為每個場景設置獨立的渲染視口 renderer.setViewport(left, bottom, width, height); renderer.setScissor(left, bottom, width, height); renderer.render(scene, camera);
場景管理結構:
const sceneObjectsRef = useRef<THREE.Scene[]>([]); // 存儲所有場景 const sceneCamerasRef = useRef<THREE.PerspectiveCamera[]>([]); // 各場景相機
關鍵技術實現
場景初始化:
const initializeScenes = (scenes: SceneItem[]) => {const material = new THREE.MeshStandardMaterial({...});return scenes.map(scene => createScene(scene, material)); };
智能渲染優化:
// 只渲染可視區域內的場景 if (rect.bottom < 0 || rect.top > window.innerHeight) return;
資源復用機制:
// 共享基礎材質 const material = new THREE.MeshStandardMaterial({...}); // 各場景使用材質副本 mesh.material = material.clone();
性能優化策略
按需渲染:
通過
getBoundingClientRect()
檢測視口可見性不可見場景跳過渲染
內存管理:
// 組件卸載時清理資源 const cleanupResources = () => {renderer.dispose();scene.traverse(obj => obj.geometry.dispose()); };
滾動優化:
// 同步Canvas位置與頁面滾動 canvas.style.transform = `translateY(${window.scrollY}px)`;
對比傳統多Canvas方案的優劣
特性 | 單Canvas多視口方案 | 多Canvas方案 |
---|---|---|
內存占用 | 低(共享上下文) | 高(多個上下文) |
GPU資源利用率 | 高 | 低 |
渲染性能 | 優(批量處理) | 一般 |
開發復雜度 | 較高 | 較低 |
最大場景支持數 | 高(100+) | 低(通常<20) |
實現難點及解決方案
視口同步問題:
難點:確保DOM元素位置與3D視口精確匹配
方案:使用
getBoundingClientRect()
動態計算
交互沖突:
難點:多個OrbitControls的事件處理
方案:為每個控制器綁定獨立DOM元素
const controls = new OrbitControls(camera, element);// element對應每個場景的DOMcontrols.minDistance = 2;controls.maxDistance = 5;sceneControlsRef.current[i] = controls;
性能瓶頸:
難點:大量場景的渲染壓力
方案:實施可見性檢測和資源復用
完整工作流程
初始化階段:
創建所有3D場景和材質
設置共享渲染器
渲染循環:
樣式文件
.multi-scene {overflow-y: auto;height: 100%;.list-item {display: inline-block;margin: 1em;padding: 1em;box-shadow: 1px 2px 4px 0px rgba(0, 0, 0, 0.25);background-color: white;}
}
* ::marker {display: none;content: '';
}
.multi-scene-container {position: relative;width: 100%;height: 100%;overflow-y: auto;overflow-x: hidden;
}.scene-viewport {box-shadow: 1px 2px 4px 0px rgba(0, 0, 0, 0.25);background: rgba(255, 255, 255, 0.1); /* 半透明背景 */pointer-events: all; /* 確保能接收交互事件 */
}.scene-label {color: #888;font-family: sans-serif;font-size: 1rem;padding: 0.5em;text-align: center;user-select: none; /* 防止文字被選中 */
}#three-canvas {display: block;outline: none; /* 移除焦點邊框 */
}
核心技術對比
1. 實現理念差異
維度 | React Three Fiber 方案 | 原生 Three.js 方案 |
---|---|---|
編程范式 | 聲明式編程,符合 React 思維 | 命令式編程,手動控制渲染流程 |
抽象層級 | 高抽象,隱藏 Three.js 底層細節 | 低抽象,直接操作 Three.js API |
代碼風格 | 組件化,JSX 語法描述 3D 場景 | 函數式,手動管理 3D 對象生命周期 |
React Three Fiber 將 3D 場景描述為 React 組件樹,例如用
<mesh>
標簽表示網格,<hemisphereLight>
表示光源,這種方式對 React 開發者更友好;而原生方案則需要手動創建THREE.Scene
、THREE.Mesh
等對象,更接近 Three.js 的原生開發模式。?
?渲染架構對比
方案一:每個場景擁有獨立的Canvas組件(@react-three/fiber):
采用 "多 Canvas" 架構,每個場景對應獨立的
<Canvas>
組件,擁有自己的渲染器、相機和渲染循環。這種架構的優勢是隔離性好,單個場景的崩潰不會影響其他場景,但資源開銷較大。
- 使用 React Three Fiber(RTF)的聲明式 API,每個場景是獨立的
<Canvas>
組件。- 每個場景有自己的渲染器、相機和控制器,相互隔離。
- 利用 RTF 的
useFrame
鉤子實現動畫循環。
方案二:基于原生 Three.js 的多視口實現
采用 "單 Canvas 多視口" 架構,所有場景共享一個渲染器,通過
setViewport()
和setScissor()
方法在不同區域渲染不同場景。這種架構資源利用率更高,渲染性能更優,但需要手動處理場景隔離。
- 使用單個全局 Canvas,通過
setViewport
和setScissor
手動管理多個視口。- 所有場景共享同一個渲染器,手動控制每個場景的渲染位置。
- 使用
requestAnimationFrame
手動實現動畫循環。
性能表現對比
在場景數量較少時(如 10 個以內),兩種方案性能差異不明顯;但當場景數量增加到 40 個時,差異開始顯現:
方案一40個模型
可以看到方案1的前24個模型場景沒有渲染出來,從25個模型到后面才渲染出來
?方案二40個模型
方案2輕輕松松
- 內存占用:React Three Fiber 方案由于多個渲染器并存,內存占用約為原生方案的 3-4 倍
- 幀率表現:原生方案在 40 個場景時仍能保持 60fps,而 React Three Fiber 方案可能降至 30-40fps
- 渲染效率:原生方案通過視口裁剪只渲染可見區域,而 React Three Fiber 會渲染所有 Canvas,包括不可見區域
原生方案的性能優勢源于:
- 共享渲染上下文,減少 GPU 資源切換
- 集中式渲染循環,避免多個 requestAnimationFrame 沖突
- 手動控制渲染時機,可實現按需渲染
?開發效率與維護性
React Three Fiber 方案:
- 開發效率高,組件化復用性好
- 與 React 生態融合自然,可直接使用 hooks 管理動畫和交互
- 學習曲線平緩,React 開發者可快速上手
原生 Three.js 方案:
- 需手動處理大量底層邏輯(如視口計算、資源清理)
- 代碼量更大,需要更多 Three.js 專業知識
- 維護成本高,需手動協調多個場景的渲染狀態
適用場景分析
適合使用 React Three Fiber 的場景
- 中小型 3D 應用:場景數量較少(<20 個),對性能要求不極致
- 快速原型開發:需要快速搭建可交互的 3D 演示
- React 深度集成:需要與 React 狀態管理(如 Redux)、表單系統深度集成
- 團隊技術棧:團隊以 React 開發者為主,Three.js 經驗有限
- 復雜交互場景:需要利用 React 生態的 UI 組件(如菜單、表單)與 3D 場景結合
適合使用原生 Three.js 的場景
- 大型 3D 應用:場景數量多(>20 個),對性能要求高
- 資源受限環境:需要在低配置設備上運行
- 精細控制需求:需要自定義渲染管線、著色器或高級優化
- 視口復雜布局:需要實現不規則排列、動態大小的 3D 視口
- Three.js 專業團隊:團隊擁有豐富的 Three.js 經驗
總結與最佳實踐建議
兩種方案各有優劣,沒有絕對的好壞,選擇時應根據實際需求權衡:
優先選擇 React Three Fiber:
- 項目周期短,需要快速交付
- 場景數量少,交互不復雜
- 團隊以 React 開發者為主
考慮原生 Three.js:
- 場景數量多(>20 個)
- 對性能和資源占用有嚴格要求
- 需要深度定制 Three.js 渲染流程
混合策略:
- 核心復雜場景使用原生 Three.js 保證性能
- 周邊簡單場景使用 React Three Fiber 加速開發
- 通過
react-three-fiber
的extend
?API 實現原生 Three.js 功能集成