阅读视图

发现新文章,点击刷新页面。

用 Three.js 和 D3 在 Vue 中打造 3D 苏州地图

前言

地理信息可视化一直是前端领域的热门话题。传统的 2D 地图已经无法满足我们对于视觉效果和交互体验的追求,而 3D 地图则可以提供更直观、更震撼的空间认知。本文将带你从零开始,在 Vue 项目中结合 Three.js 和 D3.js 的地理投影模块,将一个普通的 GeoJSON 文件转化为可交互的 3D 挤出地图,并支持旋转、缩放等操作。最终效果是一个具有立体感和边缘高亮的苏州各区地图。

本文所有代码均基于 Vue 3 + Three.js + d3-geo 实现,你可以直接复制代码运行体验。

image.png

原理:从经纬度到 3D 几何体

要将平面地图“立”起来,我们需要解决两个核心问题:

  1. 坐标转换:地理坐标(经纬度)无法直接在 Three.js 的笛卡尔坐标系中使用。我们需要使用地图投影(如墨卡托投影)将经纬度转换为平面上的 x、y 坐标。这里我们选择 D3.js 提供的 geoMercator 投影,它可以精确地将球面坐标映射到平面,并且可以通过 center 和 scale 参数将地图定位到场景中心。
  2. 三维挤出:有了平面轮廓后,我们可以利用 Three.js 的 ExtrudeGeometry 将平面形状挤出厚度,从而形成立体感。挤出的几何体可以赋予半透明的材质,使其看起来像一块块漂浮的玻璃板。同时,为了增强轮廓的清晰度,我们还可以在边缘绘制线条,让每个区域的分界更加明显。

整个流程可以概括为:
加载 GeoJSON → 解析几何类型(Polygon/MultiPolygon)→ 投影坐标 → 创建 Shape → 挤出 Mesh → 添加边缘 Line。

技术教程

1. 环境准备

首先创建一个 Vue 3 项目(如果你还没有),然后安装必要的依赖:

bash

npm install three d3-geo

注意:d3-geo 是 D3 的地理投影模块,我们只需要它,无需安装整个 D3。

2. 基础场景搭建

在 Vue 组件中,我们先初始化 Three.js 的核心组件:场景、相机、渲染器、轨道控制器。相机使用透视相机,并设置一个较远的初始位置(比如 z=300),以便后续加载的地图能够完整显示。

为了让画面更清晰,我们关闭阴影,限制像素比,并设置深色背景以减少视觉闪烁。

javascript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.set(0, 0, 300);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = false; // 关闭阴影
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.update();

3. 加载并解析 GeoJSON

GeoJSON 是一种常用的地理数据格式。我们准备了一份苏州市区的 GeoJSON 文件(可以在网上寻找或自行制作),其中包含了各区(姑苏区、虎丘区、吴中区等)的边界坐标。由于网络请求可能失败,我们添加了错误处理,并使用默认多边形作为备用。

javascript

async function loadGeoJSON() {
  try {
    const response = await fetch('/苏州市区.geojson');
    const geojson = await response.json();
    processGeoJSON(geojson);
  } catch (error) {
    console.error('加载失败,使用默认数据', error);
    const defaultGeoJSON = {
      type: "FeatureCollection",
      features: [{
        type: "Feature",
        properties: { name: "默认区域" },
        geometry: {
          type: "Polygon",
          coordinates: [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]
        }
      }]
    };
    processGeoJSON(defaultGeoJSON);
  }
}

4. 投影转换

在 processGeoJSON 中,我们需要遍历每一个 Feature,根据几何类型(Polygon 或 MultiPolygon)提取坐标环。使用 D3 的墨卡托投影将经纬度转换为平面坐标:

javascript

import { geoMercator } from 'd3-geo';

const projection = geoMercator()
  .center([120.41453, 31.342948]) // 苏州市中心经纬度
  .translate([0, 0])
  .scale(10000);

center 设置地图中心点,scale 控制缩放比例,translate 偏移设为 [0,0] 意味着投影后的坐标原点位于 (0,0),这样我们可以直接将坐标用于 Three.js。

5. 绘制挤出几何体

对于每一个坐标环(多边形轮廓),我们创建一个 THREE.Shape,然后通过 ExtrudeGeometry 挤出厚度。这里我们使用半透明的黄色材质,并开启一定的透明度,让内部结构隐约可见。

javascript

function drawExtrudeMesh(polygon, districtName) {
  const shape = new THREE.Shape();
  polygon.forEach((point, index) => {
    const [x, y] = projection(point);
    if (index === 0) shape.moveTo(x, y);
    else shape.lineTo(x, y);
  });

  const extrudeSettings = {
    depth: 10,
    bevelEnabled: false,
    steps: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({
    color: 'yellow',
    transparent: true,
    opacity: 0.5
  });
  return new THREE.Mesh(geometry, material);
}

注意:这里的 depth 控制挤出高度,可以根据视觉效果调整。

6. 添加边缘线条

为了区分不同区域并增强轮廓,我们在每个多边形边缘绘制一条线。线条的 z 坐标稍微抬高(比如设为 9),使其浮在挤出体的上方,避免被遮挡。

javascript

function lineEdge(polygon) {
  const points = polygon.map(point => {
    const [x, y] = projection(point);
    return new THREE.Vector3(x, y, 9);
  });
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({ color: 'yellow' });
  return new THREE.Line(geometry, material);
}

7. 处理 MultiPolygon

GeoJSON 中可能存在 MultiPolygon(多个多边形构成一个区域)。我们需要递归处理,将每个子多边形分别转为 Mesh 和 Line。

javascript

if (feature.geometry.type === 'MultiPolygon') {
  coordinates.forEach(coordinate => {
    coordinate.forEach(rows => {
      map.add(drawExtrudeMesh(rows, districtName));
      map.add(lineEdge(rows));
    });
  });
} else if (feature.geometry.type === 'Polygon') {
  coordinates.forEach(rows => {
    map.add(drawExtrudeMesh(rows, districtName));
    map.add(lineEdge(rows));
  });
}

将所有生成的物体添加到一个 THREE.Object3D(即 map)中,最后将这个组添加到场景。

8. 添加辅助和光照

为了让空间感更强,我们添加了坐标轴辅助线,并设置环境光(虽然 MeshBasicMaterial 不需要光照,但为了扩展性保留)。

javascript

const axes = new THREE.AxesHelper(700);
scene.add(axes);

const light = new THREE.AmbientLight(0xffffff);
scene.add(light);

9. 启动动画循环

最后,在数据加载完成后启动动画循环,不断渲染场景。

javascript

async function init() {
  await loadGeoJSON();
  animate();
}

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

init();

10. 完整代码整合

将上述所有片段整合到一个 Vue 组件的 <script setup> 中,即可得到一个完整的 3D 地图应用。记得将 GeoJSON 文件放置在 public 目录下。

效果预览与优化方向

运行项目后,你会看到一个悬浮在黑暗空间中的黄色半透明苏州地图,每个区都有清晰的边缘线条,你可以使用鼠标旋转、缩放查看各个角度。

下章优化点:

  • 为不同区域赋予不同颜色,提高辨识度。
  • 添加鼠标悬停效果,高亮当前区域并显示名称。
  • 加入底图或街道标签,丰富信息层次。
  • 使用 ShaderMaterial 实现发光边缘等特效。
  • 添加飞线效果

Three.js多视口渲染:如何在一个屏幕上同时展示三个视角

前言

客户说:“我要一个监控大屏,左边看整体,中间看特写,右边看俯视图。” 我说:“行,加钱就行。”

上次写了篇画中画,没想到反响还不错。评论区有人问:“能不能一个屏幕放三个视角?像监控室那种。”

我心想这不就是多视口渲染的升级版吗?一个画中画不够,那就来三个。

其实原理都一样:一个场景,多个相机,分区域渲染。只不过从两个变成三个,需要多处理一些布局和交互细节。

今天就用一个监控大屏的例子,把多视口渲染讲透。最终效果:左边是全局俯视,中间是自由跟随相机,右边是某个设备的特写。三个视角实时更新,互不干扰。


一、最终效果预览

先描述一下我们要实现的效果:

  • 左侧视口:固定俯视视角,看整个车间布局。
  • 中间视口:自由相机,可以拖拽旋转,观察任意角度。
  • 右侧视口:特写某个设备,相机始终盯着它,跟随移动。

三个视口共用同一个场景,但各有各的相机和控制逻辑。运行起来就像监控室里的多块屏幕。


二、核心思路

Three.js 的渲染器允许我们在同一帧里多次调用 render() 方法,只要每次渲染前用 setViewportsetScissor 设置好渲染区域就行。

关键点:

  1. 创建多个相机,分别设置位置和朝向。
  2. 在动画循环里,依次设置视口并渲染。
  3. 处理深度清除:第二个及之后的视口渲染前要清除深度缓冲区,否则画面会错乱。
  4. 如果有交互(比如控制器),需要判断鼠标落在哪个视口,激活对应的控制器。

三、代码实现

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();

这里使用了 setScissorsetScissorTest(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>

五、坑点总结

  1. 视口坐标setViewportsetScissor 用的都是左下角原点,而鼠标事件是左上角原点,转换时要注意。
  2. 深度清除:多视口渲染时,第二个及之后的视口必须调用 clearDepth(),否则旧深度会导致新画面显示不全。
  3. 控制器冲突:多个控制器同时监听同一个画布会互相干扰,必须根据鼠标位置动态启用/禁用。
  4. 性能:渲染多个视口意味着每帧多次渲染,对性能有影响。可以适当降低分辨率或关闭阴影来优化。
  5. 宽高比:每个相机要单独设置 aspect 并调用 updateProjectionMatrix

六、拓展想法

这个多视口技术还有很多玩法:

  • 给每个视口添加不同的后期效果(比如一个泛光,一个黑白)。
  • 实现分屏游戏(比如左右分屏的双人竞技)。
  • 结合 CSS 把视口放在 HTML 元素上,实现 3D 画中画嵌套 HTML。

我正准备写一篇《Three.js 后期处理进阶:给每个视口加上不同滤镜》,感兴趣的话可以关注后续。


互动

你用过 Three.js 的多视口渲染吗?实现了什么有趣的效果?欢迎评论区晒出来,让我也开开眼 😏

一个非常实用的Three.js3D模型爆破💥和切割开源插件

前言

给大家分享一个非常实用Three.js3D模型爆破切割插件,这个插件能够使前端可以直接在浏览器中,对 3D 模型进行实时且物理效果真实的 “爆破”“粉碎”“切片” 处理。

Mar-01-2026 18-55-28.gif

安装

安装也是非常的简单直接通过 npm 安装即可

但需要注意的是Three.js版本需要大于 0.158

npm install @dgreenheck/three-pinata

使用

使用也是非常的简单只要将插件提供的方法引入即可

DestructibleMesh 用于创建可切割或爆破的物体物体

FractureOptions 用于设置参数配置

import { DestructibleMesh, FractureOptions } from "@dgreenheck/three-pinata";

const outerMaterial = new THREE.MeshStandardMaterial({ color: 0x4a90e2 });
const innerMaterial = new THREE.MeshStandardMaterial({ color: 0xff6b6b });

const mesh = new DestructibleMesh(geometry, outerMaterial, innerMaterial);
scene.add(mesh);

const options = new FractureOptions({
  fractureMethod: "voronoi",
  fragmentCount: 16,
  voronoiOptions: {
    mode: "3D",
  },
});
const fragments = mesh.fracture(options);

fragments.forEach((fragment) => scene.add(fragment));
mesh.visible = false;

参数方法

该插件大概提供了7种不同的针对3D模型爆破和切割的场景方法,并且官网示例都可以直接查看演示效果

比如这个砸碎物体方法:

image.png

image.png

又或者说这个物体切片方法:

image.png

image.png

项目仓库

该项目插件是一个外国大佬开发,如果有使用Three.js开发一个小游戏的需求,或者说想丰富一下你的3D网站这个插件都会可以给你提供不错的帮助的

项目演示地址:three-pinata-demo.vercel.app/

Github: github.com/dgreenheck/…

❌