【Three.js多相机渲染】如何在同一场景里实现“画中画”效果

前言

你以为一个场景只能有一个相机?太天真了

上个月,产品经理拿着一个监控大屏的设计图来找我:

"小叶,你看这个效果------主画面看整个车间,右下角有个小窗口专门盯着那台最关键的设备,实时放大,能不能做?"

我一看,这不就是"画中画"吗?电视里看球赛的时候,主画面全场,小窗口给特写镜头,一模一样。

"能做是能做......"我脑子飞速转了一圈,"但你要知道哦,这是3D场景,不是2D视频,得渲染两次。"

产品经理眨眨眼:"那又怎样?你就说能不能做吧。"

"能。"

为了这个"能",我研究了一下午多相机渲染。今天就把研究成果分享出来,让大伙儿少走弯路。


一、多相机渲染是啥?

平时我们写Three.js,都是一个场景、一个相机、一个渲染器:

javascript 复制代码
renderer.render(scene, camera);

这叫单视角。

但Three.js允许你在同一帧里用多个相机渲染同一个场景 。每个相机可以看到不同的角度、不同的位置,然后通过设置视口(viewport),把它们渲染到屏幕的不同区域。

比如:左边一个相机看整体,右边一个相机看局部;或者主画面占满,右下角一个小窗口显示另一个视角。

这就是多相机渲染。


二、最简单的画中画实现

先来个最基础的版本:主相机看整个场景,副相机放在一个设备旁边,把画面渲染到右下角一个小方框里。

1. 创建两个相机

javascript 复制代码
// 主相机:看整体
const mainCamera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 1000);
mainCamera.position.set(10, 10, 20);
mainCamera.lookAt(0, 0, 0);

// 副相机:特写某个设备
const subCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000); // 宽高比暂时设1,后面按视口算
subCamera.position.set(2, 2, 5); // 假设设备在原点附近
subCamera.lookAt(0, 0, 0);

2. 在渲染循环里分别设置视口

javascript 复制代码
function animate() {
  requestAnimationFrame(animate);

  // 1. 渲染主相机(全屏)
  renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
  renderer.render(scene, mainCamera);

  // 2. 渲染副相机(右下角 300x200 的区域)
  const subWidth = 300;
  const subHeight = 200;
  renderer.setViewport(
    window.innerWidth - subWidth,  // x 起点
    0,                              // y 起点(从底部开始算的话,这里是0)
    subWidth,
    subHeight
  );
  renderer.render(scene, subCamera);
}

注意:setViewport 的坐标系是左下角为原点(0,0) 。所以右下角的位置是 (window.innerWidth - subWidth, 0)

3. 别忘了清除深度

两个相机渲染同一个场景,如果不做处理,第二个相机的渲染可能会被第一个相机的深度信息干扰。

解决方案:每次渲染前清除深度缓冲区,但保留颜色缓冲区(或者直接重新清除全部)。

javascript 复制代码
// 渲染主相机前,正常清除
renderer.clear();

// 渲染主相机
renderer.render(scene, mainCamera);

// 渲染副相机前,只清除深度(不清除颜色,否则主画面被清掉)
renderer.clearDepth();
renderer.render(scene, subCamera);

这样副相机的画面就能正确覆盖在主画面之上。


三、让副相机可交互

光有画面还不够,产品经理说:"小窗口能不能也支持旋转、缩放?我想仔细看看那台设备的细节。"

也就是说,副相机得绑定一套控制器。

1. 两套控制器?

直接用两个 OrbitControls 绑到同一个 canvas 上会有冲突。因为鼠标事件只有一个,你不知道用户是想操作主相机还是副相机。

解决方案:通过点击切换激活状态。点击哪个窗口,哪个相机就响应控制器。

javascript 复制代码
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

// 主相机控制器
const mainControls = new OrbitControls(mainCamera, renderer.domElement);
mainControls.enableDamping = true;

// 副相机控制器(先禁用它)
const subControls = new OrbitControls(subCamera, renderer.domElement);
subControls.enabled = false;

// 点击事件判断
renderer.domElement.addEventListener('click', (event) => {
  const mouseX = event.clientX;
  const mouseY = event.clientY;

  // 判断是否点击在副相机区域
  const subWidth = 300;
  const subHeight = 200;
  const subLeft = window.innerWidth - subWidth;
  const subBottom = 0;
  const subRight = window.innerWidth;
  const subTop = subHeight;

  if (mouseX >= subLeft && mouseX <= subRight && mouseY >= subBottom && mouseY <= subTop) {
    // 点击在副窗口,激活副相机控制器,禁用主相机控制器
    subControls.enabled = true;
    mainControls.enabled = false;
  } else {
    // 点击在主窗口,激活主相机控制器,禁用副相机控制器
    subControls.enabled = false;
    mainControls.enabled = true;
  }
});

2. 控制器的更新

在动画循环里,同时更新两个控制器:

javascript 复制代码
function animate() {
  requestAnimationFrame(animate);

  mainControls.update(); // 即使 enabled=false 也可以调用,只是没效果
  subControls.update();

  // 渲染代码同上
  // ...
}

四、进阶:小地图(俯视图)

画中画还有一个常见用法:小地图。主画面是自由视角,右下角显示一个从上往下的固定俯视图,帮助用户定位。

实现起来超级简单:

javascript 复制代码
// 创建俯视相机
const mapCamera = new THREE.OrthographicCamera(-20, 20, 20, -20, 0.1, 100);
mapCamera.position.set(0, 30, 0);
mapCamera.lookAt(0, 0, 0);
mapCamera.up.set(0, 0, 1); // 让Z轴朝上,这样俯视图更符合直觉(如果场景是Z轴朝上的话)

// 在动画循环里
renderer.setViewport(0, 0, window.innerWidth, window.innerHeight);
renderer.render(scene, mainCamera);

renderer.clearDepth();
renderer.setViewport(window.innerWidth - 200, window.innerHeight - 200, 180, 180);
renderer.render(scene, mapCamera);

注意正交相机 OrthographicCamera 的参数:left/right/bottom/top 决定了视景范围,数值越小,放大倍数越大。

为了让小地图更清晰,可以关闭一些后期效果,或者用简单的 MeshBasicMaterial 渲染一个副本,但为了简单,直接复用原场景也行。


五、坑点汇总

1. 宽高比

副相机的宽高比要跟视口的宽高比保持一致,否则画面会拉伸:

javascript 复制代码
subCamera.aspect = subWidth / subHeight;
subCamera.updateProjectionMatrix();

每次窗口大小变化时,也要重新计算。

2. 深度冲突

如果不调用 clearDepth(),第二个相机的渲染可能会因为深度测试失败而显示不全。上面已经给出解法。

3. 控制器冲突

上面用了点击切换的方法,但用户可能想同时操作?不太现实,因为鼠标只有一个。如果非要同时操作,可以考虑把副相机绑定到键盘控制,或者用不同的鼠标按钮。

4. 性能

多渲染一次,性能开销肯定翻倍。如果副相机视口很小,可以降低它的渲染分辨率或关闭阴影、后期等:

javascript 复制代码
// 在渲染副相机前,临时关掉阴影
renderer.shadowMap.enabled = false;
renderer.render(scene, subCamera);
renderer.shadowMap.enabled = true; // 恢复

或者更狠一点:副相机用更低精度的几何体(LOD),但实现起来复杂,这里不展开。

5. 清理

副相机用完记得 dispose,尤其是如果动态创建和销毁:

javascript 复制代码
subCamera = null;
// 如果有控制器,也调用 dispose
subControls.dispose();

六、实战:设备特写画中画

最后分享一下我在那个监控项目里的实际代码片段:

javascript 复制代码
// 初始化
const mainCamera = new THREE.PerspectiveCamera(45, width/height, 0.1, 1000);
const subCamera = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
subCamera.position.copy(device.position.clone().add(new THREE.Vector3(1, 1, 2)));
subCamera.lookAt(device.position);

// 动画循环
function render() {
  requestAnimationFrame(render);

  // 更新主控制器
  mainControls.update();

  // 主渲染
  renderer.setViewport(0, 0, width, height);
  renderer.clear();
  renderer.render(scene, mainCamera);

  // 副渲染(右下角 300x200)
  renderer.clearDepth();
  renderer.setViewport(width - 310, 10, 300, 200);
  renderer.render(scene, subCamera);

  // 画一个边框,突出小窗口
  // 可以用CSS,或者用另一个Sprite/Canvas画上去,这里省略
}

产品经理看了很满意,说:"就是这个效果!"

我心想:为了这个效果,我研究了半天多相机,但值了。


七、总结

多相机渲染并不复杂,核心就三步:

  1. 创建多个相机
  2. 在渲染循环里分别设置视口
  3. 处理好深度清除和宽高比

有了它,你可以实现:

  • 画中画特写
  • 小地图导航
  • 分屏对比
  • 多角度监控

下次产品经理再提类似需求,你就可以自信地说:"能,而且我能给你三个方案。"


互动

你用过Three.js的多相机渲染吗?实现了什么好玩的效果?评论区晒出来,让我抄抄作业 😏

下篇预告:【Three.js后期处理】如何让你的场景拥有电影级调色

相关推荐
答案answer3 小时前
一个非常实用的Three.js3D模型爆破💥和切割开源插件
前端·github·three.js
叶智辽1 天前
【Three.js内存管理】那些你以为释放了,其实还在占着的资源
性能优化·three.js
烛阴2 天前
Three.js 零基础入门:手把手打造交互式 3D 几何体展示系统
javascript·webgl·three.js
全栈老石3 天前
手写无限画布4 —— 从视觉图元到元数据对象
前端·javascript·canvas
全栈老石3 天前
手写一个无限画布 #3:如何在Canvas 层上建立事件体系
前端·javascript·canvas
叶智辽3 天前
【ThreeJS调试技巧】那些让 Bug 无所遁形的“脏套路”
webgl·three.js
叶智辽4 天前
【ThreeJS急诊室】一个生产事故:我把客户的工厂渲染“透明”了
webgl·three.js
全栈老石4 天前
手写一个无限画布 #2:渲染层的博弈:Canvas 还是 WebGL ?
javascript·canvas
全栈老石5 天前
手写一个无限画布 #1:坐标系的谎言
前端·canvas