前言
客户说:“我要一个监控大屏,左边看整体,中间看特写,右边看俯视图。” 我说:“行,加钱就行。”
上次写了篇画中画,没想到反响还不错。评论区有人问:“能不能一个屏幕放三个视角?像监控室那种。”
我心想这不就是多视口渲染的升级版吗?一个画中画不够,那就来三个。
其实原理都一样:一个场景,多个相机,分区域渲染。只不过从两个变成三个,需要多处理一些布局和交互细节。
今天就用一个监控大屏的例子,把多视口渲染讲透。最终效果:左边是全局俯视,中间是自由跟随相机,右边是某个设备的特写。三个视角实时更新,互不干扰。
一、最终效果预览
先描述一下我们要实现的效果:
- 左侧视口:固定俯视视角,看整个车间布局。
- 中间视口:自由相机,可以拖拽旋转,观察任意角度。
- 右侧视口:特写某个设备,相机始终盯着它,跟随移动。
三个视口共用同一个场景,但各有各的相机和控制逻辑。运行起来就像监控室里的多块屏幕。
二、核心思路
Three.js 的渲染器允许我们在同一帧里多次调用 render() 方法,只要每次渲染前用 setViewport 或 setScissor 设置好渲染区域就行。
关键点:
- 创建多个相机,分别设置位置和朝向。
- 在动画循环里,依次设置视口并渲染。
- 处理深度清除:第二个及之后的视口渲染前要清除深度缓冲区,否则画面会错乱。
- 如果有交互(比如控制器),需要判断鼠标落在哪个视口,激活对应的控制器。
三、代码实现
1. 基础设置
先搭好场景、光照和几个简单的物体(用立方体和球体模拟车间设备)。
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
// 添加一些物体
const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
scene.add(gridHelper);
const cube = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshStandardMaterial({ color: 0xff8844 })
);
cube.position.set(2, 1, 2);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);
const sphere = new THREE.Mesh(
new THREE.SphereGeometry(1.5, 32, 16),
new THREE.MeshStandardMaterial({ color: 0x44aaff })
);
sphere.position.set(-2, 1.5, -1);
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);
const cylinder = new THREE.Mesh(
new THREE.CylinderGeometry(1, 1, 3, 32),
new THREE.MeshStandardMaterial({ color: 0x88cc44 })
);
cylinder.position.set(0, 1.5, -3);
cylinder.castShadow = true;
cylinder.receiveShadow = true;
scene.add(cylinder);
// 灯光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);
2. 创建三个相机
每个相机负责一个视角。
// 相机1:俯视固定
const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraTop.position.set(0, 15, 0);
cameraTop.lookAt(0, 0, 0);
// 相机2:自由视角
const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraFree.position.set(5, 5, 10);
cameraFree.lookAt(0, 2, 0);
// 相机3:特写立方体
const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraCloseup.position.set(4, 3, 4);
cameraCloseup.lookAt(cube.position); // 盯着立方体
注意:宽高比我们还没设置,等渲染时根据视口大小动态更新。
3. 设置控制器
自由视角的相机需要控制器,其他两个不需要(或者也可以加,但本例中俯视和特写是固定的)。
const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
controlsFree.enableDamping = true;
controlsFree.target.set(0, 2, 0);
但控制器会监听整个画布的鼠标事件,我们需要判断鼠标当前在哪个视口,只有落在自由视口时才让 controlsFree 生效。后面会处理。
4. 定义视口布局
假设屏幕宽度为 window.innerWidth,高度为 window.innerHeight。我们分成三等份,每个视口占三分之一宽度,高度占满。
const viewports = [
{ left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
{ left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
{ left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
];
5. 处理鼠标事件,激活对应控制器
我们需要知道鼠标当前落在哪个视口,然后决定哪个控制器应该启用。其他控制器的 enabled 设为 false。
function onMouseClick(event) {
const mouseX = event.clientX;
const mouseY = event.clientY;
// 遍历视口,判断鼠标是否在内部
let activeIndex = -1;
viewports.forEach((vp, index) => {
if (mouseX >= vp.left && mouseX <= vp.left + vp.width &&
mouseY >= window.innerHeight - vp.bottom - vp.height && mouseY <= window.innerHeight - vp.bottom) {
activeIndex = index;
}
});
// 根据 activeIndex 启用/禁用控制器
// 这里我们只有自由相机需要控制器,其他两个不需要
if (activeIndex === 1) {
controlsFree.enabled = true;
} else {
controlsFree.enabled = false;
}
}
renderer.domElement.addEventListener('click', onMouseClick);
注意坐标转换:屏幕坐标系原点在左上角,而 setViewport 用的是左下角原点,所以判断时需要转换。上面代码中的 mouseY 判断已转换。
6. 动画循环:多视口渲染
这是核心。每帧先更新控制器(如果启用),然后依次渲染每个视口。
function animate() {
requestAnimationFrame(animate);
// 更新自由相机的控制器
controlsFree.update();
// 让特写相机始终盯着立方体(如果立方体在动)
cameraCloseup.lookAt(cube.position);
// 为每个视口设置视口并渲染
viewports.forEach((vp) => {
// 设置视口
renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
// 设置剪裁区域(可选,避免渲染到其他区域)
renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
renderer.setScissorTest(true);
// 更新相机的宽高比
const aspect = vp.width / vp.height;
vp.camera.aspect = aspect;
vp.camera.updateProjectionMatrix();
// 如果是第一个视口,清除颜色和深度;后面的只清除深度
if (vp === viewports[0]) {
renderer.clear();
} else {
renderer.clearDepth();
}
// 渲染当前相机
renderer.render(scene, vp.camera);
});
// 渲染完成后关闭剪裁测试(可选)
renderer.setScissorTest(false);
}
animate();
这里使用了 setScissor 和 setScissorTest(true) 来确保每个相机的渲染只在自己区域内,防止绘制到其他区域。同时用 clearDepth 避免深度冲突。
7. 窗口大小变化时更新布局
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
viewports[0].width = window.innerWidth / 3;
viewports[0].height = window.innerHeight;
viewports[1].left = window.innerWidth / 3;
viewports[1].width = window.innerWidth / 3;
viewports[1].height = window.innerHeight;
viewports[2].left = 2 * window.innerWidth / 3;
viewports[2].width = window.innerWidth / 3;
viewports[2].height = window.innerHeight;
});
四、完整代码
把上面的代码片段组合起来,就是一个完整的多视口示例。为了方便你直接运行,我整理成一个完整的 HTML 文件,并加了一点动画让立方体旋转,让效果更生动。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Three.js 多视口渲染:三屏监控</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Microsoft YaHei'; }
#info {
position: absolute; top: 20px; left: 20px;
background: rgba(0,0,0,0.7); color: white;
padding: 8px 16px; border-radius: 20px;
z-index: 100; pointer-events: none;
}
.label {
position: absolute; bottom: 20px;
background: rgba(0,0,0,0.5); color: white;
padding: 4px 12px; border-radius: 12px;
font-size: 14px; pointer-events: none;
z-index: 200;
}
#label-left { left: calc(16.67% - 50px); }
#label-center { left: 50%; transform: translateX(-50%); }
#label-right { right: calc(16.67% - 60px); }
</style>
</head>
<body>
<div id="info">🎥 三视口监控:俯视 | 自由 | 特写</div>
<div class="label" id="label-left">📐 俯视固定</div>
<div class="label" id="label-center">🎮 自由视角 (点击激活)</div>
<div class="label" id="label-right">🔍 设备特写</div>
<!-- 引入 Three.js 核心库和 OrbitControls -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.128.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.128.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- 初始化场景 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111122);
// 网格地面
const gridHelper = new THREE.GridHelper(20, 20, 0x4db8ff, 0x2266aa);
scene.add(gridHelper);
// 添加一些物体
const cubeGeo = new THREE.BoxGeometry(2, 2, 2);
const cubeMat = new THREE.MeshStandardMaterial({ color: 0xff8844, emissive: 0x221100 });
const cube = new THREE.Mesh(cubeGeo, cubeMat);
cube.position.set(2, 1, 2);
cube.castShadow = true;
cube.receiveShadow = true;
scene.add(cube);
const sphereGeo = new THREE.SphereGeometry(1.5, 32, 16);
const sphereMat = new THREE.MeshStandardMaterial({ color: 0x44aaff, emissive: 0x001122 });
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
sphere.position.set(-2, 1.5, -1);
sphere.castShadow = true;
sphere.receiveShadow = true;
scene.add(sphere);
const cylinderGeo = new THREE.CylinderGeometry(1, 1, 3, 32);
const cylinderMat = new THREE.MeshStandardMaterial({ color: 0x88cc44, emissive: 0x112200 });
const cylinder = new THREE.Mesh(cylinderGeo, cylinderMat);
cylinder.position.set(0, 1.5, -3);
cylinder.castShadow = true;
cylinder.receiveShadow = true;
scene.add(cylinder);
// 添加一个移动的小球作为动态元素
const ballGeo = new THREE.SphereGeometry(0.5, 16);
const ballMat = new THREE.MeshStandardMaterial({ color: 0xffaa33 });
const ball = new THREE.Mesh(ballGeo, ballMat);
ball.castShadow = true;
scene.add(ball);
// 灯光
const ambientLight = new THREE.AmbientLight(0x404060);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.position.set(5, 10, 7);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 1024;
dirLight.shadow.mapSize.height = 1024;
scene.add(dirLight);
const fillLight = new THREE.PointLight(0x4466aa, 0.5);
fillLight.position.set(-3, 2, 4);
scene.add(fillLight);
// --- 渲染器 ---
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// --- 三个相机 ---
// 1. 俯视相机
const cameraTop = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraTop.position.set(0, 15, 0);
cameraTop.lookAt(0, 0, 0);
// 2. 自由相机
const cameraFree = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraFree.position.set(5, 5, 10);
cameraFree.lookAt(0, 2, 0);
// 3. 特写相机
const cameraCloseup = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
cameraCloseup.position.set(4, 3, 4);
cameraCloseup.lookAt(cube.position);
// --- 控制器(只给自由相机)---
const controlsFree = new OrbitControls(cameraFree, renderer.domElement);
controlsFree.enableDamping = true;
controlsFree.target.set(0, 2, 0);
// --- 视口定义 ---
const viewports = [
{ left: 0, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraTop },
{ left: window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraFree },
{ left: 2 * window.innerWidth / 3, bottom: 0, width: window.innerWidth / 3, height: window.innerHeight, camera: cameraCloseup }
];
// --- 鼠标点击激活对应控制器 ---
function onMouseClick(event) {
const mouseX = event.clientX;
const mouseY = event.clientY;
let activeIndex = -1;
for (let i = 0; i < viewports.length; i++) {
const vp = viewports[i];
// 转换鼠标坐标(左下原点)
const vpLeft = vp.left;
const vpRight = vp.left + vp.width;
const vpBottom = vp.bottom;
const vpTop = vp.bottom + vp.height;
if (mouseX >= vpLeft && mouseX <= vpRight &&
mouseY >= window.innerHeight - vpTop && mouseY <= window.innerHeight - vpBottom) {
activeIndex = i;
break;
}
}
// 自由相机索引为1,其他相机没有控制器
controlsFree.enabled = (activeIndex === 1);
}
renderer.domElement.addEventListener('click', onMouseClick);
// --- 窗口大小自适应 ---
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
viewports[0].width = window.innerWidth / 3;
viewports[0].height = window.innerHeight;
viewports[1].left = window.innerWidth / 3;
viewports[1].width = window.innerWidth / 3;
viewports[1].height = window.innerHeight;
viewports[2].left = 2 * window.innerWidth / 3;
viewports[2].width = window.innerWidth / 3;
viewports[2].height = window.innerHeight;
});
// --- 动画变量 ---
let time = 0;
// --- 动画循环 ---
function animate() {
requestAnimationFrame(animate);
// 让小球围绕中心旋转
time += 0.01;
ball.position.x = Math.sin(time) * 3;
ball.position.z = Math.cos(time) * 3;
ball.position.y = 0.5 + Math.sin(time * 2) * 0.5;
// 让立方体旋转
cube.rotation.y += 0.01;
// 更新自由相机的控制器
controlsFree.update();
// 让特写相机始终盯着立方体
cameraCloseup.lookAt(cube.position);
// 依次渲染每个视口
viewports.forEach((vp, index) => {
// 设置视口
renderer.setViewport(vp.left, vp.bottom, vp.width, vp.height);
renderer.setScissor(vp.left, vp.bottom, vp.width, vp.height);
renderer.setScissorTest(true);
// 更新相机宽高比
vp.camera.aspect = vp.width / vp.height;
vp.camera.updateProjectionMatrix();
// 第一个视口清除颜色和深度,后续只清除深度
if (index === 0) {
renderer.clear();
} else {
renderer.clearDepth();
}
renderer.render(scene, vp.camera);
});
renderer.setScissorTest(false);
}
animate();
</script>
</body>
</html>
五、坑点总结
- 视口坐标:
setViewport和setScissor用的都是左下角原点,而鼠标事件是左上角原点,转换时要注意。 - 深度清除:多视口渲染时,第二个及之后的视口必须调用
clearDepth(),否则旧深度会导致新画面显示不全。 - 控制器冲突:多个控制器同时监听同一个画布会互相干扰,必须根据鼠标位置动态启用/禁用。
- 性能:渲染多个视口意味着每帧多次渲染,对性能有影响。可以适当降低分辨率或关闭阴影来优化。
- 宽高比:每个相机要单独设置
aspect并调用updateProjectionMatrix。
六、拓展想法
这个多视口技术还有很多玩法:
- 给每个视口添加不同的后期效果(比如一个泛光,一个黑白)。
- 实现分屏游戏(比如左右分屏的双人竞技)。
- 结合 CSS 把视口放在 HTML 元素上,实现 3D 画中画嵌套 HTML。
我正准备写一篇《Three.js 后期处理进阶:给每个视口加上不同滤镜》,感兴趣的话可以关注后续。
互动
你用过 Three.js 的多视口渲染吗?实现了什么有趣的效果?欢迎评论区晒出来,让我也开开眼 😏