在现代 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: number
geometry: THREE.BufferGeometry
color: THREE.Color
}
const SceneItem = ({
geometry,
color,
}: {
geometry: THREE.BufferGeometry
color: THREE.Color
}) => {
const meshRef = useRef<THREE.Mesh>(null)
useFrame(() => {
if (meshRef.current) {
meshRef.current.rotation.y += 0.01
}
})
return (
<mesh ref={meshRef} geometry={geometry}>
<meshStandardMaterial
color={color}
roughness={0.5}
metalness={0}
flatShading
/>
</mesh>
)
}
const SceneWithControls = ({ sceneData }: { sceneData: SceneData }) => {
return (
<Canvas
camera={{ 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: number
geometry: THREE.BufferGeometry
color: 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) => (
<div
key={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对应每个场景的DOM controls.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 功能集成