阅读视图

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

学习Three.js--柱状图

学习Three.js--柱状图

前置核心说明

开发目标

基于Three.js实现ECharts标准风格的3D柱状图,还原ECharts的视觉特征(配色、标签样式、坐标轴风格),同时具备3D场景的交互性与真实光影效果,核心能力包括:

  1. 视觉风格还原:精准复刻ECharts柱状图的配色、坐标轴样式、标签样式(轴刻度、轴名称、柱顶数值),兼顾3D立体感与2D可视化的简洁性;
  2. 3D场景构建:通过几何体、材质、光照系统打造真实的3D视觉效果,阴影系统增强立体感,避免3D场景的平面化;
  3. 2D标签渲染:借助CSS2D渲染器实现灵活的文字标签,解决Three.js原生文字渲染样式受限的问题,贴合ECharts的标签风格;
  4. 流畅交互体验:通过轨道控制器实现360°拖拽旋转、滚轮缩放,且启用阻尼效果提升交互顺滑度;
  5. 响应式适配:适配不同屏幕尺寸,保证柱状图在PC/平板等设备上无拉伸变形;
  6. 循环动画驱动:通过帧动画循环维持交互阻尼与场景渲染的流畅性。

c01cf879-a892-4478-b422-08456d7cbcf8.png

核心技术栈(关键知识点)

技术点 作用
THREE.Scene/Camera/Renderer 搭建3D场景基础框架,定义视角、渲染尺寸、抗锯齿等核心属性,是所有3D效果的载体
THREE.OrbitControls 实现3D场景的轨道交互(拖拽旋转、滚轮缩放),启用阻尼让交互更自然,适配3D柱状图的查看需求
CSS2DRenderer/CSS2DObject 将HTML/CSS创建的文字标签(轴刻度、柱顶数值)绑定到3D空间坐标,实现灵活的样式控制,还原ECharts标签风格
THREE.MeshStandardMaterial/LineBasicMaterial 分别实现几何体(柱子、地面)的物理材质(支持光影)和线条(坐标轴)的基础材质,保证视觉质感
THREE.BoxGeometry/CircleGeometry/BufferGeometry 构建柱子(立方体)、地面(圆形)、坐标轴(线条)的几何形态,是3D物体的形状基础
THREE.DirectionalLight/AmbientLight/PointLight 组合环境光、方向光、点光源,打造分层级的光照效果,增强3D柱子的立体感与真实感
THREE.ShadowMap(阴影系统) 开启软阴影并配置阴影参数,让柱子投射自然阴影到地面,提升场景的真实度
响应式窗口监听(resize事件) 动态更新相机比例与渲染器尺寸,保证柱状图在不同屏幕下无拉伸变形
requestAnimationFrame 驱动动画循环,维持轨道控制器阻尼更新与场景渲染的流畅性(60帧/秒)

核心开发流程

graph TD
    A["初始化场景/相机/渲染器/轨道控制器"] --> B["初始化CSS2D渲染器(标签渲染)"]
    B --> C["构建光照系统(环境光+主光源+辅助光)"]
    C --> D["定义图表数据与视觉配置参数"]
    D --> E["构建地面与网格辅助线(视觉锚点)"]
    E --> F["构建坐标轴系统(X/Y轴+刻度+名称标签)"]
    F --> G["构建柱状图主体(几何体+材质+柱顶数值标签)"]
    G --> H["添加辅助线/装饰元素(Z轴短线、原点装饰)"]
    H --> I["绑定窗口resize事件(响应式适配)"]
    I --> J["启动动画循环(更新控制器+渲染场景)"]

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器)

搭建Three.js 3D场景的核心框架,为柱状图提供基础的展示载体与交互能力,兼顾视角合理性与渲染清晰度。

1.1 核心代码(对应原代码1)
// 初始化场景、相机、渲染器
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf5f7fa); // 柔和灰白背景,贴合ECharts容器

const camera = new THREE.PerspectiveCamera(
    45, // 更小视角,减少畸变,更接近2D感
    window.innerWidth / window.innerHeight,
    0.1,
    1000
);
camera.position.set(9, 7, 14);
camera.lookAt(4, 3, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;        // 开启阴影,更真实
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 轨道控制器,带阻尼
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.autoRotate = false;
controls.enableZoom = true;
controls.target.set(4, 3, 0);
controls.minDistance = 8;
controls.maxDistance = 30;
1.2 关键说明
  • 相机参数优化
    • 视角45°:相比默认的75°,更小的视角减少3D畸变,让柱状图更接近ECharts的2D视觉风格,同时保留3D纵深;
    • 位置(9,7,14)+注视点(4,3,0):采用“斜上方俯视”视角,既可以清晰看到柱子的高度与X轴分类,又能体现3D立体感,避免视角过平/过陡导致的视觉失衡。
  • 渲染器核心配置
    • antialias: true:开启抗锯齿,让柱子边缘、坐标轴线条更细腻,避免“锯齿边”;
    • shadowMap.type = PCFSoftShadowMap:启用软阴影,让柱子投射的阴影边缘更柔和,贴合真实物理效果,避免硬阴影的生硬感;
    • setPixelRatio:适配Retina屏幕,保证高清渲染,标签文字与柱子细节无模糊。
  • 轨道控制器优化
    • 阻尼系数0.06:拖拽旋转场景后,控制器会自然减速,交互更顺滑;
    • 缩放范围8~30:限制最小/最大缩放距离,避免缩放过近导致柱子遮挡、过远导致细节丢失;
    • autoRotate: false:关闭自动旋转,让用户自主控制查看视角,更贴合数据可视化的使用场景。

步骤2:CSS2D渲染器初始化(标签渲染核心)

初始化CSS2D渲染器,为坐标轴标签、柱顶数值标签提供渲染载体,解决Three.js原生文字渲染样式不灵活的问题。

2.1 核心代码(对应原代码2)
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.left = '0px';
labelRenderer.domElement.style.pointerEvents = 'none'; // 不阻挡点击
document.body.appendChild(labelRenderer.domElement);
2.2 关键说明
  • CSS2D渲染器的核心价值:将HTML元素(<div>标签)映射到3D空间坐标,既能利用CSS灵活控制文字样式(颜色、字体、背景、圆角等),又能跟随3D场景的旋转/缩放同步更新位置,完美还原ECharts的标签风格。
  • pointerEvents: none:关闭标签的鼠标事件响应,避免标签遮挡轨道控制器的交互(如拖拽旋转时点击到标签无反应)。

步骤3:光照系统构建(光影质感核心)

组合环境光、方向光、点光源,打造分层级的光照效果,增强柱子的立体感,同时避免光线过亮/过暗导致的视觉失衡。

3.1 核心代码(对应原代码3)
// 环境光提供基础照明
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);

// 主光源 - 产生阴影
const mainLight = new THREE.DirectionalLight(0xfff5e6, 1.0);
mainLight.position.set(8, 12, 8);
mainLight.castShadow = true;
mainLight.receiveShadow = true;
mainLight.shadow.mapSize.width = 1024;
mainLight.shadow.mapSize.height = 1024;
mainLight.shadow.camera.near = 0.5;
mainLight.shadow.camera.far = 30;
mainLight.shadow.camera.left = -15;
mainLight.shadow.camera.right = 15;
mainLight.shadow.camera.top = 15;
mainLight.shadow.camera.bottom = -15;
scene.add(mainLight);

// 辅助背光,增加柱子暗部细节
const backLight = new THREE.DirectionalLight(0xe0f0ff, 0.5);
backLight.position.set(-5, 0, 10);
scene.add(backLight);

// 补充侧光
const fillLight = new THREE.PointLight(0xccddff, 0.3);
fillLight.position.set(2, 5, 12);
scene.add(fillLight);
3.2 关键说明
  • 光照分层逻辑
    • 环境光(强度0.6):提供基础照明,避免场景暗部全黑,保证所有元素的基础可见性;
    • 主方向光(强度1.0):模拟主光源(如自然光),同时开启阴影,是柱子立体感的核心;
    • 背光(强度0.5):补充柱子暗部细节,避免暗部“死黑”,提升光影层次感;
    • 点光源(强度0.3):柔和填充侧光,进一步优化光影过渡,让柱子的材质质感更真实。
  • 阴影参数优化
    • shadow.mapSize = 1024x1024:设置阴影贴图分辨率,数值越高阴影越清晰(但性能开销越大),1024是“清晰度+性能”的平衡值;
    • 阴影相机范围(-15~15):覆盖整个柱状图场景,保证所有柱子的阴影都能被正确渲染。

步骤4:图表数据与配置参数定义

定义柱状图的业务数据与视觉配置参数,实现“数据与样式解耦”,便于后续调整视觉风格。

4.1 核心代码(对应原代码4)
// 图表数据(ECharts 标准:一月到六月,数值清晰)
const chartData = [
    { label: '一月', value: 2 },
    { label: '二月', value: 3 },
    { label: '三月', value: 4 },
    { label: '四月', value: 5 },
    { label: '五月', value: 6 },
    { label: '六月', value: 7 }
];

// 配置参数 —— 完全符合柱状图审美
const config = {
    columnWidth: 0.9,          // 柱子宽一些,更饱满
    columnDepth: 0.9,          // Z轴厚度
    yMax: 10,                 // Y轴最大值固定为10,留白更ECharts(原数据最大7,顶部留空间显示数值)
    xStep: 2.1,              // 柱间距适中
    yStep: 2,                // Y轴刻度步长
    axisColor: 0x5d6d7e,     // 轴体深蓝灰,专业感
    axisLineWidth: 2.2,      // 轴线条加粗
    columnColors: [0x5470c6, 0x91cc75, 0xfac858, 0xee6666, 0x73c0de, 0x3ba272], // ECharts 经典配色
    columnXOffset: 1.8,      // 柱子起点与Y轴留白,避免拥挤(ECharts风格)
    groundOpacity: 0.15,      // 地面极浅,仅作视觉参考
    shadowOpacity: 0.3
};
4.2 关键说明
  • 数据结构设计chartData采用“标签+数值”的对象数组,贴合ECharts的数据格式,便于后续对接真实业务数据;
  • 配置参数的ECharts适配
    • yMax: 10:预留顶部空间(原数据最大7),避免柱顶数值标签与柱子顶部重叠,符合ECharts的留白审美;
    • columnColors:使用ECharts经典配色数组,按柱子循环使用,保证视觉风格一致;
    • xStep: 2.1/columnXOffset: 1.8:控制柱子间距与X轴偏移,避免柱子与Y轴拥挤,贴合ECharts的轴边距风格。

步骤5:地面与网格辅助线构建

构建地面几何体与网格辅助线,为3D场景提供视觉锚点,增强坐标感,同时接收柱子的阴影。

5.1 核心代码(对应原代码5)
// 地面(接收阴影,视觉锚点)
const groundGeometry = new THREE.CircleGeometry(30, 32); // 圆形地面更柔和
const groundMaterial = new THREE.MeshStandardMaterial({
    color: 0xe9ecef,
    roughness: 0.8,
    metalness: 0.1,
    transparent: true,
    opacity: config.groundOpacity,
    side: THREE.DoubleSide
});
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.1;
ground.receiveShadow = true;
scene.add(ground);

// 添加一个极细的网格辅助线(可选,增强坐标感,但不抢眼)
const gridHelper = new THREE.GridHelper(26, 20, 0x9aa6b2, 0xcbd5e0);
gridHelper.position.y = -0.09;
scene.add(gridHelper);
5.2 关键说明
  • 地面设计优化
    • 圆形地面(CircleGeometry):相比方形地面更柔和,避免直角的生硬感;
    • 透明度0.15:地面仅作视觉锚点,不抢夺柱状图的视觉焦点;
    • receiveShadow: true:开启阴影接收,让柱子的阴影投射到地面,增强3D真实感。
  • 网格辅助线
    • 位置y=-0.09:略高于地面,避免与地面重叠导致的视觉混乱;
    • 浅灰色调:增强坐标感的同时,不干扰主视觉,符合数据可视化“辅助元素不抢戏”的原则。

步骤6:坐标轴系统构建(X/Y轴+刻度+标签)

构建符合ECharts风格的X/Y坐标轴,包括轴线、刻度线、分类/数值标签、轴名称,是数据可视化的核心骨架。

6.1 核心代码(对应原代码6,以X轴为例)
// 坐标轴材质
const axisMaterial = new THREE.LineBasicMaterial({ 
    color: config.axisColor,
    linewidth: config.axisLineWidth
});

// ===== X轴 =====
const xAxisStart = -0.5;
const xAxisEnd = (chartData.length - 1) * config.xStep + config.columnXOffset + 1.2;
const xAxisPoints = [new THREE.Vector3(xAxisStart, 0, 0), new THREE.Vector3(xAxisEnd, 0, 0)];
const xAxisGeo = new THREE.BufferGeometry().setFromPoints(xAxisPoints);
const xAxisLine = new THREE.Line(xAxisGeo, axisMaterial);
scene.add(xAxisLine);

// X轴刻度与分类标签
chartData.forEach((item, i) => {
    const xPos = i * config.xStep + config.columnXOffset;
    
    // 刻度线(向下,长度0.6,清晰可见)
    const tickPoints = [
        new THREE.Vector3(xPos, 0, 0),
        new THREE.Vector3(xPos, -0.6, 0)
    ];
    const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
    const tick = new THREE.Line(tickGeo, axisMaterial);
    scene.add(tick);

    // 分类标签 (CSS2D) —— ECharts风格:居中,字体适中
    const labelDiv = document.createElement('div');
    labelDiv.className = 'axis-tick-label';
    labelDiv.textContent = item.label;
    labelDiv.style.transform = 'translate(-50%, 0)';
    labelDiv.style.fontWeight = '500';
    labelDiv.style.color = '#2e4053';
    const labelObj = new CSS2DObject(labelDiv);
    labelObj.position.set(xPos, -1.1, 0);
    scene.add(labelObj);
});

// X轴名称 “月份” (ECharts标准:置于轴末端附近,加粗)
const xNameDiv = document.createElement('div');
xNameDiv.className = 'axis-name-label';
xNameDiv.textContent = '月份';
xNameDiv.style.transform = 'translate(-50%, 0)';
const xNameLabel = new CSS2DObject(xNameDiv);
xNameLabel.position.set(xAxisEnd - 0.3, -1.8, 0);
scene.add(xNameLabel);
6.2 关键说明
  • 轴线长度自适应xAxisEnd通过chartData.length动态计算,保证X轴长度适配数据条数,避免硬编码导致的适配问题;
  • 刻度线与标签对齐
    • 刻度线长度0.6:清晰可见且不突兀,符合ECharts的刻度线尺寸;
    • 标签位置y=-1.1:位于刻度线下方,避免与轴线/刻度线重叠;
    • transform: translate(-50%, 0):让标签水平居中对齐刻度线,保证视觉整齐;
  • 轴名称样式:采用加粗、深色、末端放置的样式,完全复刻ECharts的轴名称风格,增强数据可读性。

步骤7:柱状图主体构建(几何体+材质+标签)

构建3D柱子几何体,配置贴合ECharts风格的材质,同时添加柱顶数值标签,是数据可视化的核心展示层。

7.1 核心代码(对应原代码7)
chartData.forEach((item, i) => {
    const xPos = i * config.xStep + config.columnXOffset;
    const height = item.value;     // 实际数值
    const color = config.columnColors[i % config.columnColors.length];

    // 柱子材质:轻微光泽,略带透明感但清晰
    const columnMaterial = new THREE.MeshStandardMaterial({
        color: color,
        roughness: 0.45,
        metalness: 0.2,
        emissive: new THREE.Color(color).multiplyScalar(0.1),
        emissiveIntensity: 0.2,
        transparent: true,
        opacity: 0.95
    });

    const columnGeo = new THREE.BoxGeometry(
        config.columnWidth,
        height,
        config.columnDepth
    );
    const column = new THREE.Mesh(columnGeo, columnMaterial);
    column.castShadow = true;
    column.receiveShadow = true;
    column.position.set(xPos, height / 2, 0);
    scene.add(column);

    // 柱顶数值标签(核心要求:每一个柱子顶部显示相应的值,ECharts 风格)
    const valueDiv = document.createElement('div');
    valueDiv.className = 'bar-value-label';
    valueDiv.textContent = item.value;   // 简洁显示数值
    // 仿ECharts: 白色底框+红褐色字,小圆角
    valueDiv.style.background = 'rgba(255, 255, 255, 0.8)';
    valueDiv.style.border = '1px solid ' + new THREE.Color(color).getStyle();
    valueDiv.style.color = new THREE.Color(color).multiplyScalar(0.6).getStyle();
    
    const valueLabel = new CSS2DObject(valueDiv);
    // 标签置于柱子顶部上方0.8处,醒目不重叠
    valueLabel.position.set(xPos, height + 0.8, 0);
    scene.add(valueLabel);

    // 柱顶圆点装饰
    const topDotGeo = new THREE.SphereGeometry(0.1);
    const topDotMat = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.2 });
    const topDot = new THREE.Mesh(topDotGeo, topDotMat);
    topDot.position.set(xPos, height, 0);
    topDot.castShadow = false;
    scene.add(topDot);
});
7.2 关键说明
  • 柱子位置计算position.set(xPos, height / 2, 0),Three.js的立方体几何体锚点在中心,因此Y轴位置需设置为height/2,保证柱子底部对齐X轴(y=0);
  • 材质风格优化
    • roughness: 0.45/metalness: 0.2:轻微的金属光泽,让柱子有质感但不刺眼;
    • emissive:轻微自发光,增强柱子的视觉层次感,贴合ECharts的高亮风格;
    • 透明度0.95:略带透明感,避免柱子过于厚重,符合现代数据可视化的轻盈风格;
  • 柱顶标签优化
    • 位置height + 0.8:位于柱子顶部上方,避免与柱子重叠;
    • 背景半透+圆角:复刻ECharts的数值标签样式,增强可读性;
    • 颜色适配:标签边框/文字颜色与柱子颜色联动,保证视觉统一性。

步骤8:辅助线与装饰元素构建

添加Z轴短线、背面辅助线等装饰元素,增强3D场景的方位感,同时不干扰主视觉。

8.1 核心代码(对应原代码8)
// Z轴短线示意(虽然2D柱状图,但3D空间给出方位)
const zAxisPoints = [new THREE.Vector3(0, 0, -2), new THREE.Vector3(0, 0, 2)];
const zAxisGeo = new THREE.BufferGeometry().setFromPoints(zAxisPoints);
const zAxisLine = new THREE.Line(zAxisGeo, new THREE.LineBasicMaterial({ color: 0xb0bec5, linewidth: 1 }));
scene.add(zAxisLine);

// 背面辅助线,增加空间感
const helperExtent = 14;
const axisHelperMaterial = new THREE.LineBasicMaterial({ color: 0xd0d8e0, linewidth: 1 });
const backLine1 = [new THREE.Vector3(-1, 0, -1.5), new THREE.Vector3(xAxisEnd, 0, -1.5)];
const backLineGeo1 = new THREE.BufferGeometry().setFromPoints(backLine1);
const backLineObj1 = new THREE.Line(backLineGeo1, axisHelperMaterial);
scene.add(backLineObj1);
8.2 关键说明
  • Z轴短线:提示3D场景的纵深方向,让用户感知柱子的厚度(Z轴),避免误以为是纯2D图表;
  • 背面辅助线:浅灰色细线条,增强场景的空间层次感,同时不抢夺柱状图的视觉焦点。

步骤9:响应式窗口适配

监听窗口尺寸变化,动态更新相机比例、渲染器尺寸,保证柱状图在不同屏幕下无拉伸变形。

9.1 核心代码(对应原代码9)
window.addEventListener('resize', onWindowResize, false);
function onWindowResize() {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
    labelRenderer.setSize(window.innerWidth, window.innerHeight);
}
9.2 关键说明
  • camera.updateProjectionMatrix():窗口尺寸变化后,相机的宽高比会修改,必须调用该方法更新投影矩阵,否则柱状图会出现拉伸变形;
  • 同步更新labelRenderer尺寸:保证CSS2D标签与3D场景同步适配,避免标签位置偏移。

步骤10:动画循环驱动

通过requestAnimationFrame驱动动画循环,维持轨道控制器阻尼更新与场景渲染的流畅性。

10.1 核心代码(对应原代码10)
function animate() {
    requestAnimationFrame(animate);
    controls.update(); // 启用阻尼后需要每帧更新
    renderer.render(scene, camera);
    labelRenderer.render(scene, camera);
}
animate();
10.2 关键说明
  • controls.update():轨道控制器启用阻尼后,必须在每帧动画中调用该方法,否则阻尼效果失效;
  • 双渲染器调用:同时渲染3D场景(renderer)和CSS2D标签(labelRenderer),保证标签与3D场景同步显示。

核心技术深度解析

1. CSS2D标签渲染的核心逻辑

CSS2D标签是还原ECharts风格的关键,其核心渲染逻辑如下:

graph LR
    A["创建HTML div标签(设置样式/文字)"] --> B["封装为CSS2DObject(绑定3D坐标)"]
    B --> C["添加到Three.js场景"] --> D["动画循环中调用labelRenderer.render"]
    D --> E["标签随3D场景旋转/缩放同步更新位置"]
  • 核心优势:相比Three.js原生的TextGeometry,CSS2D标签支持任意CSS样式(背景、圆角、文字阴影、毛玻璃等),完全复刻ECharts的标签风格,且渲染性能更高。

2. 光影系统的层次感实现

柱状图的3D立体感核心来自分层光照设计,其逻辑如下:

graph LR
    A["环境光(基础照明,0.6强度)"] --> B["主方向光(核心光影+阴影,1.0强度)"]
    B --> C["背光(补充暗部细节,0.5强度)"] --> D["点光源(柔和填充侧光,0.3强度)"]
    D --> E["柱子材质(roughness/metalness/emissive)"] --> F["自然的3D光影质感"]
  • 核心亮点:主光源负责阴影和主要光照,背光和点光源补充细节,避免暗部死黑,让柱子的材质质感更真实。

3. ECharts风格视觉还原的核心

代码通过三大维度精准复刻ECharts风格:

  1. 配色维度:使用ECharts经典配色数组,轴体采用深蓝灰(专业感),标签采用分级配色(刻度浅灰、轴名深灰、数值醒目色);
  2. 布局维度:Y轴预留顶部空间、柱子与Y轴留白、刻度线长度统一、标签位置对齐,完全贴合ECharts的布局审美;
  3. 样式维度:标签的文字阴影、半透背景、圆角,柱子的轻微光泽/透明感,轴线条加粗,均复刻ECharts的视觉细节。

核心参数速查表(快速调整视觉/交互效果)

参数分类 参数名 当前取值 核心作用 修改建议
场景配置 相机视角 camera.fov 45 控制3D场景的视角,越小畸变越少 改为30:更接近2D视觉;改为60:3D纵深感更强
场景配置 相机位置 camera.position (9,7,14) 控制观察视角(斜上方俯视) 改为(0,10,15):正面视角;改为(15,5,10):侧方视角
视觉配置 柱子尺寸 columnWidth/columnDepth 0.9/0.9 控制柱子的宽度/厚度 改为0.7/0.7:柱子更纤细;改为1.2/1.2:柱子更粗壮
视觉配置 柱间距 xStep 2.1 控制X轴上柱子的间距 改为1.5:柱子更密集;改为3.0:柱子更稀疏
视觉配置 Y轴最大值 yMax 10 控制Y轴高度(预留标签空间) 改为8:Y轴更紧凑;改为15:Y轴更宽松
视觉配置 轴线条宽度 axisLineWidth 2.2 控制坐标轴线的粗细 改为1.5:轴线更细;改为3.0:轴线更粗
数据配置 图表数据 chartData 6组(一月-六月) 柱状图的业务数据 新增/删除数组元素:适配更多/更少分类;修改value:调整柱子高度
光照配置 主光源强度 mainLight.intensity 1.0 控制主光源的亮度 改为0.7:光线更柔和;改为1.5:光线更明亮
交互配置 控制器阻尼 dampingFactor 0.06 控制拖拽旋转的顺滑度 改为0.03:阻尼更弱(旋转更快);改为0.1:阻尼更强(旋转更慢)
交互配置 缩放范围 minDistance/maxDistance 8/30 控制滚轮缩放的最小/最大距离 改为5/20:缩放范围更小;改为10/40:缩放范围更大

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <title>Three.js 柱状图 · ECharts 标准风格</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
            font-family: "Microsoft YaHei", sans-serif;
        }
        canvas {
            display: block;
        }
        /* 坐标轴标签:清爽灰色,无干扰 */
        .axis-tick-label {
            color: #4a4a4a;
            font-size: 13px;
            font-weight: normal;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 2px rgba(255,255,255,0.8);
        }
        /* 轴名称标签 (ECharts 风格:加粗,深色) */
        .axis-name-label {
            color: #2c3e50;
            font-size: 16px;
            font-weight: 600;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 3px rgba(255,255,255,0.9);
        }
        /* 柱顶数值标签:醒目红褐色,类似ECharts 强调 */
        .bar-value-label {
            color: #c0392b;
            font-size: 14px;
            font-weight: 600;
            white-space: nowrap;
            pointer-events: none;
            text-shadow: 0 0 5px rgba(255,255,255,0.8);
            background: rgba(255, 255, 255, 0.6);
            padding: 2px 6px;
            border-radius: 10px;
            border: 1px solid rgba(192, 57, 43, 0.3);
            backdrop-filter: blur(2px);
        }
        /* 简单辅助:去掉滚动条,干净视图 */
    </style>
</head>
<body>
    <script type="module">
        import * as THREE from 'https://esm.sh/three@0.174.0';
        import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';
        import { CSS2DRenderer, CSS2DObject } from 'https://esm.sh/three@0.174.0/examples/jsm/renderers/CSS2DRenderer.js';

        // ---------- 1. 初始化场景、相机、渲染器(增强抗锯齿与性能)----------
        const scene = new THREE.Scene();
        scene.background = new THREE.Color(0xf5f7fa); // 柔和灰白背景,类似ECharts容器

        const camera = new THREE.PerspectiveCamera(
            45, // 更小视角,减少畸变,更接近2D感
            window.innerWidth / window.innerHeight,
            0.1,
            1000
        );
        camera.position.set(9, 7, 14);
        camera.lookAt(4, 3, 0);

        const renderer = new THREE.WebGLRenderer({ antialias: true });
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.shadowMap.enabled = true;        // 开启阴影,更真实
        renderer.shadowMap.type = THREE.PCFSoftShadowMap;
        renderer.setPixelRatio(window.devicePixelRatio);
        document.body.appendChild(renderer.domElement);

        // 轨道控制器,带阻尼
        const controls = new OrbitControls(camera, renderer.domElement);
        controls.enableDamping = true;
        controls.dampingFactor = 0.06;
        controls.autoRotate = false;
        controls.enableZoom = true;
        controls.target.set(4, 3, 0);
        controls.minDistance = 8;
        controls.maxDistance = 30;

        // ---------- 2. CSS2渲染器(用于所有文字标签:轴刻度、轴名称、柱顶数值)----------
        const labelRenderer = new CSS2DRenderer();
        labelRenderer.setSize(window.innerWidth, window.innerHeight);
        labelRenderer.domElement.style.position = 'absolute';
        labelRenderer.domElement.style.top = '0px';
        labelRenderer.domElement.style.left = '0px';
        labelRenderer.domElement.style.pointerEvents = 'none'; // 不阻挡点击
        document.body.appendChild(labelRenderer.domElement);

        // ---------- 3. 灯光系统(呈现立体感,但不刺眼)----------
        // 环境光提供基础照明
        const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
        scene.add(ambientLight);

        // 主光源 - 产生阴影
        const mainLight = new THREE.DirectionalLight(0xfff5e6, 1.0);
        mainLight.position.set(8, 12, 8);
        mainLight.castShadow = true;
        mainLight.receiveShadow = true;
        mainLight.shadow.mapSize.width = 1024;
        mainLight.shadow.mapSize.height = 1024;
        mainLight.shadow.camera.near = 0.5;
        mainLight.shadow.camera.far = 30;
        mainLight.shadow.camera.left = -15;
        mainLight.shadow.camera.right = 15;
        mainLight.shadow.camera.top = 15;
        mainLight.shadow.camera.bottom = -15;
        scene.add(mainLight);

        // 辅助背光,增加柱子暗部细节
        const backLight = new THREE.DirectionalLight(0xe0f0ff, 0.5);
        backLight.position.set(-5, 0, 10);
        scene.add(backLight);

        // 补充侧光
        const fillLight = new THREE.PointLight(0xccddff, 0.3);
        fillLight.position.set(2, 5, 12);
        scene.add(fillLight);

        // ---------- 4. 图表数据(ECharts 标准:一月到六月,数值清晰)----------
        const chartData = [
            { label: '一月', value: 2 },
            { label: '二月', value: 3 },
            { label: '三月', value: 4 },
            { label: '四月', value: 5 },
            { label: '五月', value: 6 },
            { label: '六月', value: 7 }
        ];

        // 配置参数 —— 完全符合柱状图审美
        const config = {
            columnWidth: 0.9,          // 柱子宽一些,更饱满
            columnDepth: 0.9,          // Z轴厚度
            yMax: 10,                 // Y轴最大值固定为10,留白更ECharts(原数据最大7,顶部留空间显示数值)
            xStep: 2.1,              // 柱间距适中
            yStep: 2,                // Y轴刻度步长
            axisColor: 0x5d6d7e,     // 轴体深蓝灰,专业感
            axisLineWidth: 2.2,      // 轴线条加粗
            columnColors: [0x5470c6, 0x91cc75, 0xfac858, 0xee6666, 0x73c0de, 0x3ba272], // ECharts 经典配色
            columnXOffset: 1.8,      // 柱子起点与Y轴留白,避免拥挤(ECharts风格)
            groundOpacity: 0.15,      // 地面极浅,仅作视觉参考
            shadowOpacity: 0.3
        };

        // ---------- 5. 地面(接收阴影,视觉锚点)----------
        const groundGeometry = new THREE.CircleGeometry(30, 32); // 圆形地面更柔和
        const groundMaterial = new THREE.MeshStandardMaterial({
            color: 0xe9ecef,
            roughness: 0.8,
            metalness: 0.1,
            transparent: true,
            opacity: config.groundOpacity,
            side: THREE.DoubleSide
        });
        const ground = new THREE.Mesh(groundGeometry, groundMaterial);
        ground.rotation.x = -Math.PI / 2;
        ground.position.y = -0.1;
        ground.receiveShadow = true;
        scene.add(ground);

        // 添加一个极细的网格辅助线(可选,增强坐标感,但不抢眼)
        const gridHelper = new THREE.GridHelper(26, 20, 0x9aa6b2, 0xcbd5e0);
        gridHelper.position.y = -0.09;
        scene.add(gridHelper);

        // ---------- 6. 坐标轴系统(完全参照ECharts:轴线明显,刻度清晰,轴标签明确)----------
        const axisMaterial = new THREE.LineBasicMaterial({ 
            color: config.axisColor,
            linewidth: config.axisLineWidth
        });

        // ===== X轴 =====
        const xAxisStart = -0.5;
        const xAxisEnd = (chartData.length - 1) * config.xStep + config.columnXOffset + 1.2;
        const xAxisPoints = [new THREE.Vector3(xAxisStart, 0, 0), new THREE.Vector3(xAxisEnd, 0, 0)];
        const xAxisGeo = new THREE.BufferGeometry().setFromPoints(xAxisPoints);
        const xAxisLine = new THREE.Line(xAxisGeo, axisMaterial);
        scene.add(xAxisLine);

        // X轴刻度与分类标签
        chartData.forEach((item, i) => {
            const xPos = i * config.xStep + config.columnXOffset;
            
            // 刻度线(向下,长度0.6,清晰可见)
            const tickPoints = [
                new THREE.Vector3(xPos, 0, 0),
                new THREE.Vector3(xPos, -0.6, 0)
            ];
            const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
            const tick = new THREE.Line(tickGeo, axisMaterial);
            scene.add(tick);

            // 分类标签 (CSS2D) —— ECharts风格:居中,字体适中
            const labelDiv = document.createElement('div');
            labelDiv.className = 'axis-tick-label';
            labelDiv.textContent = item.label;
            labelDiv.style.transform = 'translate(-50%, 0)';
            labelDiv.style.fontWeight = '500';
            labelDiv.style.color = '#2e4053';
            const labelObj = new CSS2DObject(labelDiv);
            labelObj.position.set(xPos, -1.1, 0);
            scene.add(labelObj);
        });

        // X轴名称 “月份” (ECharts标准:置于轴末端附近,加粗)
        const xNameDiv = document.createElement('div');
        xNameDiv.className = 'axis-name-label';
        xNameDiv.textContent = '月份';
        xNameDiv.style.transform = 'translate(-50%, 0)';
        const xNameLabel = new CSS2DObject(xNameDiv);
        xNameLabel.position.set(xAxisEnd - 0.3, -1.8, 0);
        scene.add(xNameLabel);

        // ===== Y轴 =====
        const yAxisStart = 0;
        const yAxisEnd = config.yMax;
        const yAxisPoints = [new THREE.Vector3(0, yAxisStart, 0), new THREE.Vector3(0, yAxisEnd, 0)];
        const yAxisGeo = new THREE.BufferGeometry().setFromPoints(yAxisPoints);
        const yAxisLine = new THREE.Line(yAxisGeo, axisMaterial);
        scene.add(yAxisLine);

        // Y轴刻度和数值标签(0到10,步长2,完全展示)
        for (let val = 0; val <= config.yMax; val += config.yStep) {
            // 刻度线(向左,长度0.6)
            const tickPoints = [
                new THREE.Vector3(0, val, 0),
                new THREE.Vector3(-0.6, val, 0)
            ];
            const tickGeo = new THREE.BufferGeometry().setFromPoints(tickPoints);
            const tick = new THREE.Line(tickGeo, axisMaterial);
            scene.add(tick);

            // 数值标签
            const labelDiv = document.createElement('div');
            labelDiv.className = 'axis-tick-label';
            labelDiv.textContent = val;
            labelDiv.style.transform = 'translate(0, -50%)';
            labelDiv.style.fontWeight = '500';
            const labelObj = new CSS2DObject(labelDiv);
            labelObj.position.set(-1.2, val, 0);
            scene.add(labelObj);
        }

        // Y轴名称 “数值” (ECharts风格:垂直居中,加粗)
        const yNameDiv = document.createElement('div');
        yNameDiv.className = 'axis-name-label';
        yNameDiv.textContent = '数值';
        yNameDiv.style.transform = 'translate(0, -50%)';
        const yNameLabel = new CSS2DObject(yNameDiv);
        yNameLabel.position.set(-2.4, config.yMax / 2, 0);
        scene.add(yNameLabel);

        // ===== 原点装饰(加强视觉)=====
        const originDotGeo = new THREE.SphereGeometry(0.08);
        const originDotMat = new THREE.MeshStandardMaterial({ color: 0x2c3e50, emissive: 0x1a2630, emissiveIntensity: 0.2 });
        const originDot = new THREE.Mesh(originDotGeo, originDotMat);
        originDot.position.set(0, 0, 0);
        scene.add(originDot);

        // ---------- 7. 柱状图主体(每个柱子带阴影、柱顶数值标签,严格按ECharts标准)----------
        chartData.forEach((item, i) => {
            const xPos = i * config.xStep + config.columnXOffset;
            const height = item.value;     // 实际数值
            const color = config.columnColors[i % config.columnColors.length];

            // 柱子材质:轻微光泽,略带透明感但清晰
            const columnMaterial = new THREE.MeshStandardMaterial({
                color: color,
                roughness: 0.45,
                metalness: 0.2,
                emissive: new THREE.Color(color).multiplyScalar(0.1),
                emissiveIntensity: 0.2,
                transparent: true,
                opacity: 0.95
            });

            const columnGeo = new THREE.BoxGeometry(
                config.columnWidth,
                height,
                config.columnDepth
            );
            const column = new THREE.Mesh(columnGeo, columnMaterial);
            column.castShadow = true;
            column.receiveShadow = true;
            column.position.set(xPos, height / 2, 0);
            scene.add(column);

            // ----- 柱顶数值标签(核心要求:每一个柱子顶部显示相应的值,ECharts 风格)-----
            const valueDiv = document.createElement('div');
            valueDiv.className = 'bar-value-label';
            valueDiv.textContent = item.value;   // 简洁显示数值
            // 仿ECharts: 白色底框+红褐色字,小圆角
            valueDiv.style.background = 'rgba(255, 255, 255, 0.8)';
            valueDiv.style.border = '1px solid ' + new THREE.Color(color).getStyle();
            valueDiv.style.color = new THREE.Color(color).multiplyScalar(0.6).getStyle();
            
            const valueLabel = new CSS2DObject(valueDiv);
            // 标签置于柱子顶部上方0.8处,醒目不重叠
            valueLabel.position.set(xPos, height + 0.8, 0);
            scene.add(valueLabel);

            // ----- 额外加一个微小的柱顶圆点,提升细节 (ECharts有时会有标记) -----
            const topDotGeo = new THREE.SphereGeometry(0.1);
            const topDotMat = new THREE.MeshStandardMaterial({ color: color, emissive: color, emissiveIntensity: 0.2 });
            const topDot = new THREE.Mesh(topDotGeo, topDotMat);
            topDot.position.set(xPos, height, 0);
            topDot.castShadow = false;
            scene.add(topDot);
        });

        // ---------- 8. 添加一个轻量的Z轴短线示意(虽然2D柱状图,但3D空间给出方位)----------
        const zAxisPoints = [new THREE.Vector3(0, 0, -2), new THREE.Vector3(0, 0, 2)];
        const zAxisGeo = new THREE.BufferGeometry().setFromPoints(zAxisPoints);
        const zAxisLine = new THREE.Line(zAxisGeo, new THREE.LineBasicMaterial({ color: 0xb0bec5, linewidth: 1 }));
        scene.add(zAxisLine);

        // 添加微弱的辅助环境线框,不干扰主视觉
        const helperExtent = 14;
        const axisHelperMaterial = new THREE.LineBasicMaterial({ color: 0xd0d8e0, linewidth: 1 });

        // 简单在背面加两根平行线,增加空间感(可选)
        const backLine1 = [new THREE.Vector3(-1, 0, -1.5), new THREE.Vector3(xAxisEnd, 0, -1.5)];
        const backLineGeo1 = new THREE.BufferGeometry().setFromPoints(backLine1);
        const backLineObj1 = new THREE.Line(backLineGeo1, axisHelperMaterial);
        scene.add(backLineObj1);

        // ---------- 9. 响应式窗口----------
        window.addEventListener('resize', onWindowResize, false);
        function onWindowResize() {
            camera.aspect = window.innerWidth / window.innerHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(window.innerWidth, window.innerHeight);
            labelRenderer.setSize(window.innerWidth, window.innerHeight);
        }

        // ---------- 10. 动画循环----------
        function animate() {
            requestAnimationFrame(animate);
            controls.update(); // 启用阻尼后需要每帧更新
            renderer.render(scene, camera);
            labelRenderer.render(scene, camera);
        }
        animate();

        // 控制台提示
        console.log('ECharts 风格柱状图已加载,坐标轴标签、柱顶数值完整展示');
    </script>
</body>
</html>

总结与扩展建议

核心总结

  1. 视觉还原核心:通过CSS2D标签、ECharts经典配色、轴样式/布局优化,精准复刻ECharts柱状图的视觉风格,同时保留3D立体感;
  2. 光影构建核心:分层光照(环境光+主方向光+背光+点光源)+软阴影系统,打造真实的3D光影质感,避免场景平面化;
  3. 交互适配核心:轨道控制器阻尼优化+响应式窗口适配,保证3D交互的顺滑性与多设备的兼容性;
  4. 性能核心:通过合理的几何体/材质配置、阴影分辨率平衡,保证在普通设备上60帧流畅渲染。

扩展建议

  1. 动态数据更新
    • 新增updateData函数,修改chartData后重新构建柱子几何体,实现数据动态刷新(如实时监控数据);
    • 为柱子添加高度过渡动画,让数据更新时柱子平滑升降,提升视觉体验。
  2. 多系列柱状图
    • 在Z轴方向扩展,为每个分类添加多个柱子(如每月的“销量”“利润”),实现多系列对比;
    • 新增图例组件(CSS2D标签),标注不同系列的颜色与含义,贴合ECharts多系列图表风格。
  3. 交互增强
    • 添加tooltip交互:监听鼠标点击/悬浮事件,显示柱子的详细信息(如“一月:2(同比增长10%)”);
    • 柱子高亮效果:鼠标悬浮时修改柱子材质(如提高emissiveIntensity),增强交互反馈。
  4. 视觉主题切换
    • 新增主题配置(如浅色/深色主题),修改scene.backgroundaxisColorcolumnColors等参数,实现一键切换;
    • 适配ECharts的官方主题(如dark、macarons),提升视觉多样性。
  5. 性能优化
    • 使用InstancedMesh替代普通Mesh,批量渲染相同样式的柱子,减少DrawCall,支持更多分类数据;
    • 视锥体裁剪:剔除屏幕外的柱子/标签,减少渲染开销,适配大量数据场景。

用一个粒子效果告别蛇年迎来马年~

我们即将迎来马年,随手整了一个粒子切换效果,在这里分享给大家,本期功能实现主要是运用了Three.JS!

cover2

1.加载模型

这种物体的形状很难通过纯数学公式推导出来,所以我是在sketchfab上找的两个模型

20260208174832

20260208174916

这两个模型都是.glb类型的,在Three.JS中我们可以通过GLTFLoaderDRACOLoader很轻松的加载这种类型的模型文件!

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const gltf = await gltfLoader.loadAsync(path);
const model = gltf.scene;

关于DRACOLoader

简单来说,DRACOLoaderThree.js 中专门用来解压经过 Draco 压缩过的 3D 模型的“解压器”。

如果你在开发 WebGL 项目时发现模型文件(通常是 .gltf 或 .glb)太大,导致加载缓慢,你通常会使用 Google 开发的 Draco 算法 对模型进行压缩。而 DRACOLoader 就是为了让浏览器能读懂这些压缩数据而存在的。

const dracoLoader = new DRACOLoader()
dracoLoader.setDecoderPath('/draco/')

const gltfLoader = new GLTFLoader()
gltfLoader.setDRACOLoader(dracoLoader)

const modelFiles = [
    {path: '/snake_model.glb', scale: 8, position: {x: 0, y: 0, z: 0}},
    {path: '/horse.glb', scale: 18, position: {x: 0, y: -14, z: 0}}
];


for (const modelConfig of modelFiles) {
    try {
        const gltf = await gltfLoader.loadAsync(modelConfig.path);
        const model = gltf.scene;
        model.scale.set(modelConfig.scale, modelConfig.scale, modelConfig.scale);
        model.position.set(modelConfig.position.x, modelConfig.position.y, modelConfig.position.z);
        model.updateMatrixWorld(true);
        scene.add(model);

        console.log(`Loaded: ${modelConfig.path}`);
    } catch (error) {
        console.error(`Failed to load ${modelConfig.path}:`, error);
    }
}

20260209091146

2.模型粒子化

现在我们的两个模型已经成功加载,我们的模型粒子化的思路是拿到模型的顶点数据然后使用new THREE.Points来展示,所以我们先隐藏我们的模型文件

for (const modelConfig of modelFiles) {
    try {
        ...
        ...
      - scene.add(model);
      + model.visible = false;
    
    } catch (error) {
        
    }
}

2.1 MeshSurfaceSampler

MeshSurfaceSampler 是 Three.js 扩展库(three/examples/jsm/math/MeshSurfaceSampler.js)中的一个实用类。它通过加权随机算法,根据模型表面的几何面积分布,在三角形网格上提取随机点的坐标、法线以及颜色。

通俗的来说我们的模型是由许多个三角形组成的,MeshSurfaceSampler通过算法会判断三角形面积,如果更大的三角形则权重更多被分配的点也就更多!

举个栗子🌰

import { MeshSurfaceSampler } from 'three/examples/jsm/math/MeshSurfaceSampler.js';

// 1. 创建采样器
const sampler = new MeshSurfaceSampler(yourLoadedMesh)
    .setWeightAttribute('color') // 可选:如果有颜色属性,可以按颜色密度采样
    .build();

// 2. 采样循环
const tempPosition = new THREE.Vector3();
const tempNormal = new THREE.Vector3();

for (let i = 0; i < particleCount; i++) {
    sampler.sample(tempPosition, tempNormal);
    
    // 将采样到的位置存入数组或属性中
    positions.push(tempPosition.x, tempPosition.y, tempPosition.z);
}

2.2 合并Mash

从上面的例子我们能看到MeshSurfaceSampler接收的是一个单一的Mesh,但是我们的模型可能会包含多个Mesh,比如本次案例中的都是有两个Mesh,所以在使用MeshSurfaceSampler前我们需要把多个Mesh合并成一个!

BufferGeometryUtils.mergeGeometries 是 Three.js 扩展库 BufferGeometryUtils 中的一个静态方法。它的主要作用是将一组 BufferGeometry 合并成一个单一的几何体。

function getMergedMeshFromScene(scene) {
    const geometries = [];

    scene.updateMatrixWorld(true);

    scene.traverse((child) => {
        if (child.isMesh) {
            const clonedGeom = child.geometry.clone();
            clonedGeom.applyMatrix4(child.matrixWorld);
            for (const key in clonedGeom.attributes) {
                if (key !== 'position') clonedGeom.deleteAttribute(key);
            }
            geometries.push(clonedGeom);
        }
    });

    // 合并所有几何体
    const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
    return new THREE.Mesh(mergedGeometry);
}

2.3 展示粒子


function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;
    }

    return {positions};
}


 const modelData = generatePositionsFromModel(getMergedMeshFromScene(model), particleCount);
 modelDataArray.push(modelData);

现在我们已经有了模型的顶点坐标只需要使用THREE.Points配合THREE.PointsMaterial

function makeParticles(modelData) {

    const {positions} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    
    const material = new THREE.PointsMaterial({
        color: 0xffffff,      
        size: 0.5,             
        sizeAttenuation: true, 
        transparent: true,
        opacity: 0.8
    });

    return new THREE.Points(geometry, material);
}

const particles = makeParticles(modelDataArray[0]);
scene.add(particles);

20260209093343

我们的粒子小蛇就展示出来了,只不过现在这个粒子还很粗糙,我们会在后面优化~

3.粒子切换

现在我们的粒子已经成功展示!根据前面两步我们能知道粒子的展示就是根据模型的顶点来计算的,所以从一个模型切换到另一个模型就是单纯的顶点切换!

function beginMorph(index) {
    isTrans = true;
    prog = 0;

    const fromPts = new Float32Array(particles.geometry.attributes.position.array);
    const modelData = generatePositionsFromModel(getMergedMeshFromScene(rawModel[index]), particleCount);
    const toPts = new Float32Array(modelData.positions);

    particles.userData = {from: fromPts, to: toPts};
}

通过beginMorph我们把当前的粒子状态和目标状态存入到userData中,然后在tick中进行动画处理

const morphSpeed = .03;

const tick = () => {
    window.requestAnimationFrame(tick)
    controls.update()

    if (isTrans) {
        prog += morphSpeed;
        // 使用平滑的缓动函数
        const eased = prog >= 1 ? 1 : 1 - Math.pow(1 - prog, 3);

        const { from, to } = particles.userData;
        const particleArr = particles.geometry.attributes.position.array;

        for (let i = 0; i < particleArr.length; i++) {
            particleArr[i] = from[i] + (to[i] - from[i]) * eased;
        }
        // 通知 GPU 更新
        particles.geometry.attributes.position.needsUpdate = true;
        if (prog >= 1) isTrans = false;
    }


    renderer.render(scene, camera);
}

change

此时我们基础的粒子切换效果就已经实现啦!

4.粒子优化

此时我们的粒子效果还是存在几个问题的!

  • 大小固定/粒子是正方形
  • 没有颜色
  • 效果单调

要解决上面几个问题我们还使用THREE.PointsMaterial就有点不够看了,接下来我们使用THREE.ShaderMaterial搭配自定义着色器来优化效果!

4.1 大小随机化/粒子改为圆形

我们想让粒子的大小产生一个随机变化就要考虑通过顶点着色器中gl_PointSize来随机改变粒子大小!粒子改为圆形就要在片元着色器中修改gl_FragColor!

function generatePositionsFromModel(mesh, totalCount = particleCount) {
    const positions = new Float32Array(totalCount * 3);
    const sizes = new Float32Array(totalCount);
    const rnd = new Float32Array(totalCount * 3);

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        sizes[i] = .7 + Math.random() * 1.1;
        sampler.sample(tempPosition);
        tempPosition.applyMatrix4(mesh.matrixWorld);
        const i3 = i * 3;
        positions[i3] = tempPosition.x;
        positions[i3 + 1] = tempPosition.y;
        positions[i3 + 2] = tempPosition.z;

        rnd[i3] = Math.random() * 10;
        rnd[i3 + 1] = Math.random() * Math.PI * 2;
        rnd[i3 + 2] = .5 + .5 * Math.random();
    }

    return {positions, sizes, rnd};
}

首先修改generatePositionsFromModel方法,针对每个顶点坐标产生一组随机数范围在.7 ~ .77

function makeParticles(modelData) {

    const {positions, sizes, rnd} = modelData;

    const geometry = new THREE.BufferGeometry();

    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
    geometry.setAttribute("random", new THREE.BufferAttribute(rnd, 3));

    const material = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}, hueSpeed: {value: 0.12}},
        vertexShader: ..., 
        fragmentShader: ...,
        transparent: true, 
        depthWrite: false, 
        vertexColors: true, 
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geometry, material);
}
uniform float time;
attribute float size;
attribute vec3 random;
varying vec3 vCol;
varying float vR;
void main(){
    vec3 p=position;
    vec4 mv=modelViewMatrix*vec4(p,1.);
    float pulse=.9+.1*sin(time*1.15+random.y);
    gl_PointSize=size*pulse*(350./-mv.z);
    gl_Position=projectionMatrix*mv;
}
uniform float time;
void main() {
    float d = length(gl_PointCoord - vec2(0.5));
    float alpha = 1.0 - smoothstep(0.4, 0.5, d);
    if (alpha < 0.01) discard;
    gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
}

20260209100331

此时的粒子就大小改为随机并且是圆形粒子了~

4.2 粒子添加颜色

粒子添加颜色和上一步的粒子大小类似都需要针对每一个顶点生成一个随机的颜色

const palette = [0xff3c78, 0xff8c00, 0xfff200, 0x00cfff, 0xb400ff, 0xffffff, 0xff4040].map(c => new THREE.Color(c));

    const tempPosition = new THREE.Vector3();

    const sampler = new MeshSurfaceSampler(mesh).build();
    for (let i = 0; i < totalCount; i++) {
        ...
        ...

        const base = palette[Math.random() * palette.length | 0], hsl = {h: 0, s: 0, l: 0};
        base.getHSL(hsl);
        hsl.h += (Math.random() - .5) * .05;
        hsl.s = Math.min(1, Math.max(.7, hsl.s + (Math.random() - .5) * .3));
        hsl.l = Math.min(.9, Math.max(.5, hsl.l + (Math.random() - .5) * .4));

        const c = new THREE.Color().setHSL(hsl.h, hsl.s, hsl.l);
        colors[i3] = c.r;
        colors[i3 + 1] = c.g;
        colors[i3 + 2] = c.b;

        ...
    }

修改片元着色器

uniform float time;
uniform float hueSpeed;
varying vec3 vCol;
varying float vR;

vec3 hueShift(vec3 c, float h) {
    const vec3 k = vec3(0.57735);
    float cosA = cos(h);
    float sinA = sin(h);
    return c * cosA + cross(k, c) * sinA + k * dot(k, c) * (1.0 - cosA);
}

void main() {
    vec2 uv = gl_PointCoord - 0.5;
    float d = length(uv);

    float core = smoothstep(0.05, 0.0, d);
    float angle = atan(uv.y, uv.x);
    float flare = pow(max(0.0, sin(angle * 6.0 + time * 2.0 * vR)), 4.0);
    flare *= smoothstep(0.5, 0.0, d);
    float glow = smoothstep(0.4, 0.1, d);

    float alpha = core * 1.0 + flare * 0.5 + glow * 0.2;

    vec3 color = hueShift(vCol, time * hueSpeed);
    vec3 finalColor = mix(color, vec3(1.0, 0.95, 0.9), core);
    finalColor = mix(finalColor, color, flare * 0.5 + glow * 0.5);

    if (alpha < 0.01) discard;

    gl_FragColor = vec4(finalColor, alpha);
}

20260209100747

4.3 设置亮度差

现在我们的粒子看着还是略显单调!我们可以给粒子做局部提亮!


function createSparkles() {

    const geo = new THREE.BufferGeometry();
    const pos = new Float32Array(particleSparkCount * 3);
    const size = new Float32Array(particleSparkCount);
    const rnd = new Float32Array(particleSparkCount * 3);

    for (let i = 0; i < particleSparkCount; i++) {
        size[i] = 0.5 + Math.random() * 0.8;
        rnd[i * 3] = Math.random() * 10;
        rnd[i * 3 + 1] = Math.random() * Math.PI * 2;
        rnd[i * 3 + 2] = 0.5 + 0.5 * Math.random();
    }
    geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
    geo.setAttribute('size', new THREE.BufferAttribute(size, 1));
    geo.setAttribute('random', new THREE.BufferAttribute(rnd, 3));

    const mat = new THREE.ShaderMaterial({
        uniforms: {time: {value: 0}},
        vertexShader: `
            uniform float time;
            attribute float size;
            attribute vec3 random;
            void main() {
                vec3 p = position;
                float t = time * 0.25 * random.z;
                float ax = t + random.y, ay = t * 0.75 + random.x;
                float amp = (0.6 + sin(random.x + t * 0.6) * 0.3) * random.z;
                p.x += sin(ax + p.y * 0.06 + random.x * 0.1) * amp;
                p.y += cos(ay + p.z * 0.06 + random.y * 0.1) * amp;
                p.z += sin(ax * 0.85 + p.x * 0.06 + random.z * 0.1) * amp;
                vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
                gl_PointSize = size * (300.0 / -mvPosition.z);
                gl_Position = projectionMatrix * mvPosition;
            }`,
        fragmentShader: `
            uniform float time;
            void main() {
                float d = length(gl_PointCoord - vec2(0.5));
                float alpha = 1.0 - smoothstep(0.4, 0.5, d);
                if (alpha < 0.01) discard;
                gl_FragColor = vec4(1.0, 1.0, 1.0, alpha);
            }`,
        transparent: true,
        depthWrite: false,
        blending: THREE.AdditiveBlending
    });

    return new THREE.Points(geo, mat);
}

const particlesSpark = createSparkles(modelDataArray[0])
scene.add(particlesSpark);


const targetPositions = modelDataArray[0].positions;

const particleArr = particles.geometry.attributes.position.array;
const sparkleArr = particlesSpark.geometry.attributes.position.array;

for (let j = 0; j < particleCount; j++) {
    const idx = j * 3;

    // 直接从 targetPositions 拷贝三个连续的数值 (x, y, z)
    particleArr[idx] = targetPositions[idx];
    particleArr[idx + 1] = targetPositions[idx + 1];
    particleArr[idx + 2] = targetPositions[idx + 2];

    // 同步更新闪烁粒子
    if (j < particleSparkCount) {
        sparkleArr[idx] = targetPositions[idx];
        sparkleArr[idx + 1] = targetPositions[idx + 1];
        sparkleArr[idx + 2] = targetPositions[idx + 2];
    }
}

// 必须通知 GPU 更新
particles.geometry.attributes.position.needsUpdate = true;
particlesSpark.geometry.attributes.position.needsUpdate = true;

20260209102034转存失败,建议直接上传图片文件

我们又添加了一个createSparkles然后粒子位置和最开始的模型粒子位置一致,只不过颜色我们设置成白色!但是到这还没结束!我们的提亮魔法还要依靠THREE的后期处理能力!

EffectComposerThree.js 的 后期处理(Post-processing)管理器。它负责管理一个“通道(Pass)”队列。它不再直接将场景渲染到画布上,而是渲染到一个或多个缓冲帧中,经过各种视觉特效处理后,再呈现给用户。

const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
composer.addPass(new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), .45, .5, .85));
const after = new AfterimagePass();
after.uniforms.damp.value = .92;
composer.addPass(after);
composer.addPass(new OutputPass());
  • UnrealBloomPass 是用来做荧光、发光的效果
  • AfterimagePass 是用来做拖尾影效果

结束语

希望所有人 2026 事事如意!

参考代码

Three.js & GLSL Particle Metamorphosis

【ThreeJS实战】从86MB到4MB:复杂模型加载优化黑魔法

前言:正当我沉浸在将draw call从52000优化到1的喜悦中无法自拔时,产品经理这时候又杀过来了:"客户说模型加载要30秒,还没进去就关页面了,你优化一下?"我打开Network面板一看,卧槽,86MB的GLB文件!这谁顶得住啊...

如果你也遇到过这种情况:精心打磨的3D场景,本地运行丝滑流畅,一上线用户骂娘——"破网站卡死了"、"怎么还在转圈"、"手机直接闪退"。别急着怪用户网速慢,先看看你的模型是不是太胖了

我这有个复杂模型,几何体+贴图一共86MB,在4G网络下加载需要30秒(Chrome模拟Slow 4G(3mb/s)一直加载...)。今天咱们不讲Blender操作模型(之前用Blender是因为没招,现在有更狠的),直接用命令行黑魔法把它压到4MB!!,加载时间从30秒干到1.5秒

以下是优化前的绝望现场整整加载了30多秒...

image.png

一、优化思路

既然知道了加载为什么那么慢的原因,那我们就可以开始想想该怎么优化了

我目前的思路就是用gltf-transform 先把模型体积压下来,要不然渲染的时候再流畅,客户等到第二十秒的时候关闭浏览器,也没有意义了。。

二、DRACOLoader

ThreeJS DRACOLoader直接无缝解压缩被压缩的模型

安装压缩模型工具(不用Blender,命令行搞定)

# 安装gltf-transform(一行命令搞定Draco压缩+WebP+KTX2)
npm install -g @gltf-transform/cli

至于我为什么选择gltf-transform而不是gltf-pipeline,以下是它们的对比:

特性 gltf-pipeline gltf-transform
Draco压缩 ✅ 支持 ✅ 支持(更快)
WebP纹理 ❌ 不支持 ✅ 支持(关键!)
KTX2/Basis ❌ 不支持 ✅ 支持
安装体积 大(依赖多) 小(WASM核心)
推荐度 ⭐⭐⭐ ⭐⭐⭐⭐⭐

压缩你的GLB(80MB → 4MB)

gltf-transform optimize input.glb output.glb \
  --compress draco \
  --texture-compress webp \
  --texture-size 2048

以下是我压缩之后的体积:

image.png

可以看到,模型的体积得到了巨大的缩减,从原来的86mb到现在的4mb左右!

参数说明

参数 说明 建议值
--texture-compress webp 贴图转WebP格式 必加,体积减半
--texture-compress ktx2 贴图转KTX2(GPU直读) 如果目标设备支持,比WebP更好
--texture-size 2048 限制最大贴图尺寸 必加,4096→2048省4倍显存
--compress draco 启用Draco几何压缩 必加,默认就是sequential模式
--compress-method sequential Draco编码模式 sequential(默认,小体积)或 edgeloop(快解码)
--compress-level 10 Draco压缩级别 0-10,10压最狠但解压慢,建议7-10
--flatten 打平节点层级 如果模型层级太深,加这个减少DrawCall(但会丢失动画)

以下是优化之后的加载时间,就问你快不快!

image.png

Three.js加载代码(关键!)

/**
 * 优化后的 GLB 加载步骤(Draco / gltf-transform)
 *
 * 依赖:Three.js、GLTFLoader、DRACOLoader
 * 解码器:把 three 的 examples/jsm/libs/draco/gltf/ 放到站点 /draco/ 下,或使用 CDN 路径
 */

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

// ————— 步骤 1:创建 Draco 解码器并指定路径 —————
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
// 或用 CDN(与项目 three 版本一致):'https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/gltf/'

// ————— 步骤 2:把 DRACOLoader 挂到 GLTFLoader 上 —————
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

// ————— 步骤 3:正常 load,普通 GLB 与 Draco 压缩的 GLB 都能加载 —————
loader.load(
  'https://your-cdn.com/model-optimized.glb',
  (gltf) => {
    scene.add(gltf.scene);
  },
  undefined,
  (err) => console.error(err),
);

// Promise 写法(可选):
export function loadOptimizedGLB(url) {
  return new Promise((resolve, reject) => {
    loader.load(url, resolve, undefined, reject);
  });
}
// 使用方式:const gltf = await loadOptimizedGLB(url);

注意setDecoderPath 指向的是 Draco 的 WASM 解码文件,需要从 Three.js 的 examples/jsm/libs/draco/ 目录复制到你的 public 文件夹,或者用 CDN(上面示例用的是从threejs复制的本地解码文件)。

image.png

image.png

避坑指南

  1. 别重复压缩:Draco是有损压缩,压一次损失一点精度,别压两遍!先备份原文件。
  2. WebP兼容性:虽然现代浏览器都支持WebP,但如果你要兼容IE11(虽然不应该),只能用PNG/JPG。
  3. KTX2谨慎用:KTX2(Basis Universal)压缩率最高,但需要 GPU 支持,老旧手机可能解码失败,建议 WebP 更稳妥。
  4. 量化精度:如果你发现压缩后的模型出现裂缝(顶点没对齐),把 --quantization-position 从 10 调到 14。

还有一件事:Draco是有损压缩,但视觉上几乎看不出差别(工业模型顶点精度够高),解压是在Web Worker里进行的,不会卡主线程。

三、又到了喜闻乐见的前后对比(刺激!)

指标 原始模型 Draco压缩
文件体积 86MB 4MB
4G加载时间 30秒 1.5秒

可以看到加载时间跨度很大,从30秒到1.5秒,足足提升了20倍,客户本来都要睡着了,但现在客户眨了一下眼睛,就发现眼前屏幕里的世界都不一样了~

总结

优化路径:86MB(原始)→ 4MB(Draco+WebP)→ 1.5秒加载完成

核心认知

  • gltf-transform:一站式解决几何体+贴图压缩,不用Blender,一行命令搞定
  • Draco:解决"下载慢"(几何体从18MB压到2MB)
  • WebP:解决"贴图肥"(68MB压到2MB,兼容性最好)

没用到的手段(进阶可选)

  • KTX2:比WebP体积更小且GPU直读,但需要设备支持,老旧手机可能解码失败
  • 分块加载:如果4MB还是大,可以拆成"外壳1MB+细节3MB",首屏秒开

不用Blender,全程命令行+代码搞定,这才是工程师的浪漫。

下篇预告:【ThreeJS实战】GPU还是100%?LOD策略:让远处模型自动"减肥"

互动:你用gltf-transform压了多少倍?我20倍算不算狠?评论区报出你的原始体积vs优化后体积,看看谁是真正的"压王"😏

Three.js 透视相机完全指南:从入门到精通

什么是透视相机?

生活中的类比

想象你拿着一部手机摄像头对着一个房间:

  • 📱 手机摄像头 = Three.js 中的相机
  • 🎬 摄像头能看到的范围 = 视锥体(Frustum)
  • 📐 摄像头的视角宽度 = 视野角度(FOV)

透视相机就像真实的摄像头一样,离物体越近,看到的范围越小;离物体越远,看到的范围越大。这就是"透视"的含义。

代码示例

import * as THREE from 'three';

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
    40,      // 视野角度(FOV)
    2,       // 宽高比(aspect ratio)(一般3D游戏中的屏幕适配都是在调整这个值)
    0.1,     // 近裁剪面(near)
    1000     // 远裁剪面(far)
);

camera.position.z = 120; // 相机位置

四个关键参数详解

参数 1️⃣:视野角度(FOV - Field of View)

定义:相机能看到的垂直视角范围,单位是度数(°)。

直观理解

  • 🔭 FOV = 10° → 像用望远镜看,视角很窄,看到的东西很小但很清晰
  • 📷 FOV = 40° → 像用普通手机摄像头,视角适中
  • 🐟 FOV = 90° → 像用鱼眼镜头,视角很宽,看到很多东西但会变形
// 小视角 - 看得清楚但范围小
const camera1 = new THREE.PerspectiveCamera(20, 2, 0.1, 1000);

// 中等视角 - 平衡
const camera2 = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 大视角 - 看得多但容易变形
const camera3 = new THREE.PerspectiveCamera(75, 2, 0.1, 1000);

实际应用

  • 🎮 第一人称游戏:FOV 通常 60-90°
  • 🏢 建筑可视化:FOV 通常 40-50°
  • 🎥 电影效果:FOV 通常 24-35°

参数 2️⃣:宽高比(Aspect Ratio)

定义:相机画面的宽度与高度的比例

计算方式

const aspect = window.innerWidth / window.innerHeight;
// 例如:1920 / 1080 = 1.777...

为什么重要

  • ✅ 如果 aspect 与实际画布比例一致,画面不会被拉伸
  • ❌ 如果不一致,圆形会变成椭圆,正方形会变成矩形
// 假设窗口是 1920×1080
const aspect = 1920 / 1080; // ≈ 1.78

const camera = new THREE.PerspectiveCamera(40, aspect, 0.1, 1000);

响应式设计

function onWindowResize() {
    const width = window.innerWidth;
    const height = window.innerHeight;
    
    camera.aspect = width / height;
    camera.updateProjectionMatrix(); // 重要!更新投影矩阵
    
    renderer.setSize(width, height);
}

window.addEventListener('resize', onWindowResize);

参数 3️⃣:近裁剪面(Near)

定义:相机能看到的最近距离。比这个距离更近的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染相机背后的物体
  • 🔍 避免穿模:防止相机进入物体内部时看到内部结构
// near = 0.1 意味着距离相机 0.1 单位以内的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 0.05,它会被裁剪掉
const cube = new THREE.Mesh(geometry, material);
cube.position.z = 0.05; // ❌ 看不到

参数 4️⃣:远裁剪面(Far)

定义:相机能看到的最远距离。比这个距离更远的物体不会被显示。

为什么需要它

  • 🎯 性能优化:不渲染太远的物体
  • 🔍 深度精度:提高深度缓冲的精度
// far = 1000 意味着距离相机 1000 单位以外的物体看不到
const camera = new THREE.PerspectiveCamera(40, 2, 0.1, 1000);

// 如果物体在 z = 1500,它会被裁剪掉
const star = new THREE.Mesh(geometry, material);
star.position.z = 1500; // ❌ 看不到

视锥体的可视范围计算

什么是视锥体?

视锥体是一个四棱锥形的空间,只有在这个空间内的物体才能被看到。

计算可视范围的公式

在距离相机 distance 处,可视范围的大小为:

垂直高度 = 2 × tan(FOV/2) × distance
水平宽度 = 垂直高度 × aspect

实际计算示例

假设我们有这样的相机配置:

const fov = 40;           // 视野角度
const aspect = 2;         // 宽高比(2:1)
const distance = 120;     // 相机距离(z = 120)

// 计算垂直可视高度
const vFOV = fov * Math.PI / 180; // 转换为弧度
const height = 2 * Math.tan(vFOV / 2) * distance;
// height = 2 × tan(20°) × 120
// height = 2 × 0.364 × 120
// height ≈ 87.2

// 计算水平可视宽度
const width = height * aspect;
// width = 87.2 × 2
// width ≈ 174.4

结论:在 z=120 处,相机能看到的范围是:

  • 📏 宽度:174.4 单位
  • 📏 高度:87.2 单位

坐标范围的确定

场景布局示例

假设我们要在场景中放置一个 5×4 的网格(5列 4行),每个物体之间间距为 15 单位:

const spread = 15; // 间距

// 物体位置计算
for (let x = -2; x <= 2; x++) {
    for (let y = -1; y <= 2; y++) {
        const obj = createObject();
        obj.position.x = x * spread;  // x: -30, -15, 0, 15, 30
        obj.position.y = y * spread;  // y: -15, 0, 15, 30
        scene.add(obj);
    }
}

对象分布范围

  • X 轴:-30 到 30(宽度 60)
  • Y 轴:-15 到 30(高度 45)

检查是否超出可视范围

// 相机可视范围
const visibleWidth = 174.4;
const visibleHeight = 87.2;

// 对象范围
const objectWidth = 60;
const objectHeight = 45;

// 检查
console.log(`宽度是否超出: ${objectWidth > visibleWidth}`); // false ✅
console.log(`高度是否超出: ${objectHeight > visibleHeight}`); // false ✅

如果超出范围怎么办?

问题:当 spread=20 时,对象范围变为 80×60,超出了可视范围。

解决方案

方案 1:增加相机距离

// 原来
camera.position.z = 120;

// 改为
camera.position.z = 160; // 距离越远,看到的范围越大

方案 2:增加视野角度

// 原来
const fov = 40;

// 改为
const fov = 60; // 视角越大,看到的范围越大

方案 3:减小间距

// 原来
const spread = 20;

// 改为
const spread = 15; // 物体靠得更近

方案 4:使用正交相机

如果你不需要透视效果,可以使用正交相机(OrthographicCamera),它的可视范围不会随距离变化:

const camera = new THREE.OrthographicCamera(
    -100,  // left
    100,   // right
    50,    // top
    -50,   // bottom
    0.1,   // near
    1000   // far
);

实战代码

完整的响应式相机设置

import * as THREE from 'three';

class ResponsiveCamera {
    constructor() {
        this.fov = 40;
        this.near = 0.1;
        this.far = 1000;
        this.distance = 120;
        
        this.updateCamera();
    }
    
    updateCamera() {
        const aspect = window.innerWidth / window.innerHeight;
        
        this.camera = new THREE.PerspectiveCamera(
            this.fov,
            aspect,
            this.near,
            this.far
        );
        
        this.camera.position.z = this.distance;
    }
    
    // 计算指定深度处的可视范围
    getVisibleRange(depth = null) {
        const vFOV = (this.fov * Math.PI) / 180;
        // 如果没有指定深度,使用相机的默认距离
        const distance = depth !== null ? depth : this.distance;
        const height = 2 * Math.tan(vFOV / 2) * distance;
        const width = height * (window.innerWidth / window.innerHeight);

        return { width, height };
    }
    
    // 检查物体是否在可视范围内
    isObjectVisible(obj) {
        const pos = obj.position;
        
        // 计算物体相对于相机的距离(沿着相机的视线方向)
        const distanceFromCamera = this.camera.position.z - pos.z;
        
        // 计算物体所在深度的可视范围
        const range = this.getVisibleRange(distanceFromCamera);
        console.log('物体距离相机:', distanceFromCamera, '该深度的可视范围:', range);

        return (
            Math.abs(pos.x) <= range.width / 2 &&
            Math.abs(pos.y) <= range.height / 2 &&
            distanceFromCamera >= this.near &&
            distanceFromCamera <= this.far
        );
    }
    
    // 窗口大小改变时更新
    onWindowResize() {
        this.updateCamera();
        this.camera.updateProjectionMatrix();
    }
}

// 使用示例
// 使用示例
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x222222);

// 4. 创建渲染器:将3D场景渲染到网页上
const renderer = new THREE.WebGLRenderer({ antialias: true }); // antialias开启抗锯齿
renderer.setSize(window.innerWidth, window.innerHeight); // 设置渲染尺寸
// 将渲染器的画布添加到网页中
document.body.appendChild(renderer.domElement);

const camera = new ResponsiveCamera();
window.addEventListener('resize', () => camera.onWindowResize());

// 添加光源
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);

const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 50, 50);
scene.add(directionalLight);

// 检查物体是否可见
const cube = new THREE.Mesh(new THREE.BoxGeometry(8, 8, 8), createMaterial());
cube.position.set(20, 20, -10);
scene.add(cube);

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

requestAnimationFrame(animate);

console.log(camera.isObjectVisible(cube)); // true or false

小结

  1. FOV 和 distance 决定可视范围

    • FOV 越大,看到的范围越大
    • distance 越大,看到的范围越大
  2. aspect 必须与画布比例一致

    • 否则画面会被拉伸变形
  3. near 和 far 定义了深度范围

    • 在这个范围外的物体看不到

📂 核心代码与完整示例:    my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

THREE.js 摄像机

前置代码

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    html,
    body {
      margin: 0;
      height: 100%;
    }

    #c {
      width: 100%;
      height: 100%;
      display: block;
    }

    .split {
      position: absolute;
      left: 0;
      top: 0;
      width: 100%;
      height: 100%;
      display: flex;
    }

    .split>div {
      width: 100%;
      height: 100%;
    }
  </style>
</head>

<body>

  <canvas id="c">

  </canvas>
  <div class="split">
    <div id="view1"></div>
    <div id="view2"></div>
  </div>
  <script type="importmap">
    {
        "imports": {
            "three": "https://esm.sh/three@0.174.0/build/three.module.js",
            "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.174.0/examples/jsm/"
        }   
    }
    </script>
  <script type="module" src="./index.js"></script>
</body>

</html>

透视摄像机PerspectiveCamera

PerspectiveCamera 通过四个属性来定义一个视锥, near定义视锥前端, far定义远端, fov是视野, 通过计算正确的高度来从摄像机的位置获取指定的以near为单位的视野, 定义的是视锥的前端和远端的高度 aspect间接地定义了视锥前端和远端的宽度, 实际上视锥的宽度是通过高度乘以 aspect 来得到的

下面这个例子我们使用 three 的剪函数, 把视图分成两部分, 主视图正常渲染, 辅视图用来观察 cameraHelper 的渲染

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({ antialias: true, canvas });

  // #region 左视图的相机
  const fov = 45;
  const aspect = 2; // the canvas default
  const near = 0.1;
  const far = 100;
  const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
  camera.position.set(0, 10, 20);

  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(0, 5, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 5, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.aspect = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();

image.png


正交摄像机OrthographicCamera

与透视摄像机不同的是, 它需要设置left right top bottom nearfar 指定一个长方形, 使得视野是平行的而不是透视的

使用 zoom 属性可以缩放世界 -> 屏幕的映射比例, 不改变实际尺寸

< 1 看到更多 > 1 看到更少

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { GUI } from "three/addons/libs/lil-gui.module.min.js";

function main() {
  const canvas = document.querySelector("#c");
  const view1Elem = document.querySelector("#view1");
  const view2Elem = document.querySelector("#view2");

  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    canvas,
    logarithmicDepthBuffer: true,
  });

  // #region 左视图的相机
  const size = 1;
  const near = 5;
  const far = 50;
  const camera = new THREE.OrthographicCamera(-size, size, size, -size, near, far);
  camera.zoom = 0.2;
  camera.position.set(0, 20, 0);
  // camera.lookAt(0, 0, 0);
  const cameraHelper = new THREE.CameraHelper(camera);

  const controls = new OrbitControls(camera, view1Elem);
  controls.target.set(2, 0, 0);
  controls.update();

  // #endregion

  // #region 右视图的相机
  const camera2 = new THREE.PerspectiveCamera(
    60, // fov
    2, // aspect
    0.1, // near
    500, // far
  );
  camera2.position.set(40, 10, 30);
  camera2.lookAt(0, 10, 0);

  const controls2 = new OrbitControls(camera2, view2Elem);
  controls2.target.set(0, 5, 0);
  controls2.update();

  // #endregion

  /**
   * 设置裁剪区域和视口, 返回宽高比
   * @param {HTMLElement} elem
   * @returns
   */
  function setScissorForElement(elem) {
    // 获取 canvas 与元素的边界矩形
    const canvasRect = canvas.getBoundingClientRect();
    const elemRect = elem.getBoundingClientRect();

    // 相对位置计算元素在 canvas 内的左右上下边界
    const right = Math.min(elemRect.right, canvasRect.right) - canvasRect.left;
    const left = Math.max(0, elemRect.left - canvasRect.left);
    const bottom = Math.min(elemRect.bottom, canvasRect.bottom) - canvasRect.top;
    const top = Math.max(0, elemRect.top - canvasRect.top);

    const width = Math.min(canvasRect.width, right - left);
    const height = Math.min(canvasRect.height, bottom - top);

    // 设置裁剪
    const positiveYUpBottom = canvasRect.height - bottom;

    // 对 renderer 设置裁剪区域和视口
    renderer.setScissor(left, positiveYUpBottom, width, height);
    renderer.setViewport(left, positiveYUpBottom, width, height);

    return width / height;
  }

  // gui 使用,限制对象中属性的最大值最小值
  class MinMaxGUIHelper {
    constructor(obj, minProp, maxProp, minDif) {
      this.obj = obj;
      this.minProp = minProp;
      this.maxProp = maxProp;
      this.minDif = minDif;
    }
    get min() {
      return this.obj[this.minProp];
    }
    set min(v) {
      this.obj[this.minProp] = v;
      this.obj[this.maxProp] = Math.max(this.obj[this.maxProp], v + this.minDif);
    }
    get max() {
      return this.obj[this.maxProp];
    }
    set max(v) {
      this.obj[this.maxProp] = v;
      this.min = this.min; // this will call the min setter
    }
  }

  // #region 添加相机属性的gui界面
  const gui = new GUI();
  // gui.add(camera, "fov", 1, 180);
  const minMaxGUIHelper = new MinMaxGUIHelper(camera, "near", "far", 0.1);
  gui.add(minMaxGUIHelper, "min", 0.1, 50, 0.1).name("near");
  gui.add(minMaxGUIHelper, "max", 0.1, 50, 0.1).name("far");
  gui.add(camera, "zoom", 0.01, 1).name("zoom").listen(); // 调整相机展现多少单位大小

  // #endregion

  const scene = new THREE.Scene();
  scene.background = new THREE.Color("black");
  scene.add(cameraHelper);

  {
    const planeSize = 40;

    const loader = new THREE.TextureLoader();
    const texture = loader.load("https://threejs.org/manual/examples/resources/images/checker.png");
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    //texture.colorSpace = THREE.SRGBColorSpace;
    const repeats = planeSize / 2;
    texture.repeat.set(repeats, repeats);

    const planeGeo = new THREE.PlaneGeometry(planeSize, planeSize);
    const planeMat = new THREE.MeshPhongMaterial({
      map: texture,
      side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(planeGeo, planeMat);
    mesh.rotation.x = Math.PI * -0.5;
    scene.add(mesh);
  }

  {
    const cubeSize = 4;
    const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize);
    const cubeMat = new THREE.MeshPhongMaterial({ color: "#8AC" });
    const mesh = new THREE.Mesh(cubeGeo, cubeMat);
    mesh.position.set(cubeSize + 1, cubeSize / 2, 0);
    scene.add(mesh);
  }

  {
    const sphereRadius = 3;
    const sphereWidthDivisions = 32;
    const sphereHeightDivisions = 16;
    const sphereGeo = new THREE.SphereGeometry(
      sphereRadius,
      sphereWidthDivisions,
      sphereHeightDivisions,
    );
    const sphereMat = new THREE.MeshPhongMaterial({ color: "#CA8" });
    const mesh = new THREE.Mesh(sphereGeo, sphereMat);
    mesh.position.set(-sphereRadius - 1, sphereRadius + 2, 0);
    scene.add(mesh);
  }

  {
    const color = 0xffffff;
    const intensity = 3;
    const light = new THREE.DirectionalLight(color, intensity);
    light.position.set(0, 10, 0);
    light.target.position.set(-5, 0, 0);
    scene.add(light);
    scene.add(light.target);
  }

  function resizeRendererToDisplaySize(renderer) {
    const canvas = renderer.domElement;
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const needResize = canvas.width !== width || canvas.height !== height;
    if (needResize) {
      renderer.setSize(width, height, false);
    }

    return needResize;
  }

  function render() {
    resizeRendererToDisplaySize(renderer);

    // 启用剪刀函数
    renderer.setScissorTest(true);

    // #region 视图1 渲染
    const aspect1 = setScissorForElement(view1Elem);
    camera.left = -aspect1;
    camera.right = aspect1;
    camera.updateProjectionMatrix();
    // 不在视图 1中渲染 helper
    cameraHelper.visible = false;
    cameraHelper.update();
    renderer.render(scene, camera);
    // #endregion

    // #region 视图2 渲染
    const aspect2 = setScissorForElement(view2Elem);
    camera2.aspect = aspect2;
    camera2.updateProjectionMatrix();
    // 在第二台摄像机中绘制cameraHelper
    cameraHelper.visible = true;
    // 单独给视图 2 设置个背景色
    scene.background.set(0x000040);
    renderer.render(scene, camera2);

    // #endregion

    requestAnimationFrame(render);
  }

  requestAnimationFrame(render);
}

main();


image.png


❌