React+threejs两种3D多场景渲染方案

发布于:2025-07-25 ⋅ 阅读:(17) ⋅ 点赞:(0)

在现代 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实例。也就是本文说的方案一。

然而,这种做法会带来两个显著问题:

  1. WebGL上下文数量限制 浏览器通常对WebGL上下文数量设置了8个的上限,超出限制时,系统会自动释放最早创建的上下文。

  2. 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;

核心实现原理

  1. 单Canvas多视口架构

    // 单个Canvas承载所有渲染
    <canvas ref={canvasRef} style={{ position: 'fixed' }} />
  2. 视口分割技术

    // 为每个场景设置独立的渲染视口
    renderer.setViewport(left, bottom, width, height);
    renderer.setScissor(left, bottom, width, height);
    renderer.render(scene, camera);
  3. 场景管理结构

    const sceneObjectsRef = useRef<THREE.Scene[]>([]); // 存储所有场景
    const sceneCamerasRef = useRef<THREE.PerspectiveCamera[]>([]); // 各场景相机

关键技术实现

  1. 场景初始化

    const initializeScenes = (scenes: SceneItem[]) => {
      const material = new THREE.MeshStandardMaterial({...});
      return scenes.map(scene => createScene(scene, material));
    };
  2. 智能渲染优化

    // 只渲染可视区域内的场景
    if (rect.bottom < 0 || rect.top > window.innerHeight) return;
  3. 资源复用机制

    // 共享基础材质
    const material = new THREE.MeshStandardMaterial({...});
    // 各场景使用材质副本
    mesh.material = material.clone();

性能优化策略

  1. 按需渲染

    • 通过getBoundingClientRect()检测视口可见性

    • 不可见场景跳过渲染

  2. 内存管理

    // 组件卸载时清理资源
    const cleanupResources = () => {
      renderer.dispose();
      scene.traverse(obj => obj.geometry.dispose());
    };
  3. 滚动优化

    // 同步Canvas位置与页面滚动
    canvas.style.transform = `translateY(${window.scrollY}px)`;

对比传统多Canvas方案的优劣

特性 单Canvas多视口方案 多Canvas方案
内存占用 低(共享上下文) 高(多个上下文)
GPU资源利用率
渲染性能 优(批量处理) 一般
开发复杂度 较高 较低
最大场景支持数 高(100+) 低(通常<20)

实现难点及解决方案

  1. 视口同步问题

    • 难点:确保DOM元素位置与3D视口精确匹配

    • 方案:使用getBoundingClientRect()动态计算

  2. 交互冲突

    • 难点:多个OrbitControls的事件处理

    • 方案:为每个控制器绑定独立DOM元素

              const controls = new OrbitControls(camera, element);// element对应每个场景的DOM
              controls.minDistance = 2;
              controls.maxDistance = 5;
              sceneControlsRef.current[i] = controls;
  3. 性能瓶颈

    • 难点:大量场景的渲染压力

    • 方案:实施可见性检测和资源复用

完整工作流程

  1. 初始化阶段

    • 创建所有3D场景和材质

    • 设置共享渲染器

  2. 渲染循环

样式文件

.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.SceneTHREE.Mesh等对象,更接近 Three.js 的原生开发模式。 

 渲染架构对比

方案一:每个场景拥有独立的Canvas组件(@react-three/fiber)

采用 "多 Canvas" 架构,每个场景对应独立的<Canvas>组件,拥有自己的渲染器、相机和渲染循环。这种架构的优势是隔离性好,单个场景的崩溃不会影响其他场景,但资源开销较大。

  • 使用 React Three Fiber(RTF)的声明式 API,每个场景是独立的<Canvas>组件。
  • 每个场景有自己的渲染器、相机和控制器,相互隔离。
  • 利用 RTF 的useFrame钩子实现动画循环。

方案二:基于原生 Three.js 的多视口实现

采用 "单 Canvas 多视口" 架构,所有场景共享一个渲染器,通过setViewport()setScissor()方法在不同区域渲染不同场景。这种架构资源利用率更高,渲染性能更优,但需要手动处理场景隔离。

  • 使用单个全局 Canvas,通过setViewportsetScissor手动管理多个视口。
  • 所有场景共享同一个渲染器,手动控制每个场景的渲染位置。
  • 使用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 的场景

  1. 中小型 3D 应用:场景数量较少(<20 个),对性能要求不极致
  2. 快速原型开发:需要快速搭建可交互的 3D 演示
  3. React 深度集成:需要与 React 状态管理(如 Redux)、表单系统深度集成
  4. 团队技术栈:团队以 React 开发者为主,Three.js 经验有限
  5. 复杂交互场景:需要利用 React 生态的 UI 组件(如菜单、表单)与 3D 场景结合

适合使用原生 Three.js 的场景

  1. 大型 3D 应用:场景数量多(>20 个),对性能要求高
  2. 资源受限环境:需要在低配置设备上运行
  3. 精细控制需求:需要自定义渲染管线、着色器或高级优化
  4. 视口复杂布局:需要实现不规则排列、动态大小的 3D 视口
  5. Three.js 专业团队:团队拥有丰富的 Three.js 经验

总结与最佳实践建议

两种方案各有优劣,没有绝对的好坏,选择时应根据实际需求权衡:

  1. 优先选择 React Three Fiber

    • 项目周期短,需要快速交付
    • 场景数量少,交互不复杂
    • 团队以 React 开发者为主
  2. 考虑原生 Three.js

    • 场景数量多(>20 个)
    • 对性能和资源占用有严格要求
    • 需要深度定制 Three.js 渲染流程
  3. 混合策略

    • 核心复杂场景使用原生 Three.js 保证性能
    • 周边简单场景使用 React Three Fiber 加速开发
    • 通过react-three-fiberextend API 实现原生 Three.js 功能集成


网站公告

今日签到

点亮在社区的每一天
去签到