【Three.js】day121-3d-bar-chart

作者:mario 发布时间: 2026-09-07 阅读量:4 评论数:0

Day 121 · 3D 柱状图 — 数据驱动的柱体与实例化:500 根柱子不卡

昨天的映射图纸今天落地第一张:3D 柱状图——最常用、最能体现"数据驱动渲染"的形态。核心三件事:用数据生成柱子几何体(高度/位置/颜色全部由数据算出来)、用 InstancedMesh 让 500+ 根柱子只花 1 次 draw call、以及"数据变了只更新属性不清空重建"的更新纪律。做完你会有第一个真·数据可视化 3D 组件,也是 Day 126 看板的骨架。


目录


一、从映射设计到几何体:柱子是怎么"长"出来的

1.1 一根柱子的生成参数

回顾 Day 120 的映射设计,一根柱子的所有属性都由数据算出:

// 数据 → 柱子参数的映射(Day 120 的 linearScale / valueToColor)
interface BarParams {
  x: number;      // 柱子中心 X(由类目索引算)
  z: number;      // 柱子中心 Z(由分组/列索引算)
  height: number; // 柱子高度(数值 linearScale 而来)
  color: THREE.Color; // 柱子颜色(valueToColor / 状态色)
}

1.2 网格坐标计算(避免穿模的根基)

柱子铺在网格上,中心点 = 网格索引 × 间距 + 偏移:

/**
 * 计算网格上第 (row, col) 根柱子的中心坐标
 * 间距 = 柱宽 + 柱间距;总布局居中于原点
 */
function gridPos(row: number, col: number, gapX: number, gapZ: number, rows: number, cols: number) {
  const x = (col - (cols - 1) / 2) * gapX; // 列方向居中
  const z = (row - (rows - 1) / 2) * gapZ; // 行方向居中
  return { x, z };
}

关键:柱子的几何体高度始终为 1,真实高度通过 scale.y 拉伸——这样所有柱子共用一个几何,也是实例化的前提。


二、三种写法对比:逐 Mesh vs 合并 vs InstancedMesh

写法

draw call

独立变换

独立颜色

适用

逐 Mesh(each new Mesh

500 次

≤几十根

mergeGeometries 合并(Day 118)

1 次

❌ 整体一起动

❌ 合并后难分色

静态同质

InstancedMesh

1 次

✅ 每实例矩阵

✅ 每实例颜色

海量同类

结论:柱状图天生适合 InstancedMesh——同几何、同材质,但要每根独立位置/缩放/颜色,恰好是实例化的设计目标。


三、InstancedMesh 柱状图完整实现

3.1 核心实现

// src/core/three/viz/bar-chart.ts(引擎内,纯逻辑)
import * as THREE from "three";
import { linearScale, valueToColor } from "../../data/scale";

/**
 * 数据驱动的 3D 柱状图(InstancedMesh 实现)
 * 设计要点:
 *  1. 柱几何高度恒为 1,真实高度用 instanceMatrix 的 scale.y 拉伸
 *  2. 每根柱子的位置/缩放/颜色都写入实例缓冲,一次 draw call 渲染全部
 *  3. 更新时只改实例缓冲,不销毁重建(见第四节)
 */
export class BarChart3D {
  readonly mesh: THREE.InstancedMesh;
  private readonly count: number;

  constructor(values: number[], rows: number, cols: number, options?: { maxHeight?: number }) {
    const n = values.length;
    this.count = n;
    const maxH = options?.maxHeight ?? 10;

    // 1. 单位柱几何:BoxGeometry(1, 1, 1),高 1,底在原点下方 0.5
    const geo = new THREE.BoxGeometry(1, 1, 1);
    geo.translate(0, 0.5, 0); // 让柱子底部贴地(原点在底面)

    const mat = new THREE.MeshStandardMaterial({
      color: 0xffffff,
      roughness: 0.5,
      metalness: 0.3,
    });

    this.mesh = new THREE.InstancedMesh(geo, mat, n);
    this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); // 频繁更新标记

    // 2. 逐实例写入位置/缩放/颜色
    const dummy = new THREE.Object3D();
    const color = new THREE.Color();
    const dataMin = Math.min(...values);
    const dataMax = Math.max(...values);

    for (let i = 0; i < n; i++) {
      const row = Math.floor(i / cols);
      const col = i % cols;
      const { x, z } = gridPos(row, col, 1.4, 1.4, rows, cols);

      // 高度映射:数据 → [0.1, maxH](0.1 下限让最小值"看得见")
      const h = linearScale(values[i], dataMin, dataMax, 0.1, maxH);

      dummy.position.set(x, 0, z);
      dummy.scale.set(1, h, 1);           // Y 方向拉伸成柱子高度
      dummy.updateMatrix();
      this.mesh.setMatrixAt(i, dummy.matrix);

      // 颜色映射:数值 → 冷到热
      color.copy(valueToColor(values[i], dataMin, dataMax));
      this.mesh.setColorAt(i, color);
    }
    this.mesh.instanceMatrix.needsUpdate = true;
    if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
  }

  /**
   * 更新全部柱子(数据刷新时调用)
   * 复用几何与材质,只重写实例缓冲——不销毁不重建
   */
  update(values: number[]) {
    const dataMin = Math.min(...values);
    const dataMax = Math.max(...values);
    const dummy = new THREE.Object3D();
    const color = new THREE.Color();
    for (let i = 0; i < values.length; i++) {
      const { x, z } = this.posAt(i);
      const h = linearScale(values[i], dataMin, dataMax, 0.1, 10);
      dummy.position.set(x, 0, z);
      dummy.scale.set(1, h, 1);
      dummy.updateMatrix();
      this.mesh.setMatrixAt(i, dummy.matrix);
      color.copy(valueToColor(values[i], dataMin, dataMax));
      this.mesh.setColorAt(i, color);
    }
    this.mesh.instanceMatrix.needsUpdate = true;
    if (this.mesh.instanceColor) this.mesh.instanceColor.needsUpdate = true;
  }

  private posAt(i: number) {
    // 与构造时的网格布局保持一致(此处为简化,用固定 cols 反推)
    const cols = Math.ceil(Math.sqrt(this.count));
    const row = Math.floor(i / cols);
    const col = i % cols;
    return gridPos(row, col, 1.4, 1.4, Math.ceil(this.count / cols), cols);
  }
}

3.2 使用示例

// 20 台设备能耗 → 3D 柱状
const chart = new BarChart3D(energies, 4, 5, { maxHeight: 10 });
scene.add(chart.mesh);

// 数据刷新(模拟后续实时推送)
setInterval(() => {
  const fresh = energies.map(() => Math.random() * 100);
  chart.update(fresh);   // 只更新缓冲,FPS 不掉
}, 2000);

3.3 为什么 500 根也不卡

InstancedMesh 的本质:
  1 个几何 + 1 个材质 + 1 次 draw call
  + 每实例一个 4×4 矩阵(位置/旋转/缩放)
  + 每实例一个颜色(instanceColor)
→ 500 根柱子 = 1 次提交,GPU 一次画完
对比逐 Mesh:500 次状态切换 + 500 次提交

对照表第 10 行:“手写 WebGL 要做实例化要自己管理矩阵缓冲;Three.js 一行 InstancedMesh + setMatrixAt”。记进笔记。


四、数据更新:只改属性不清空重建

4.1 为什么"重建"是性能杀手

❌ 错误做法(数据一变就重建):
  移除旧 InstancedMesh → new 几何/材质/实例 → 重新 add
  = 每 2 秒触发一次 GC、一次内存分配、一次 draw call 重建

✅ 正确做法(只改实例缓冲):
  复用几何/材质,只重写 instanceMatrix / instanceColor 的数值
  = CPU 写几百个矩阵 + GPU 重读,几乎零开销

4.2 更新的三条纪律

1. setMatrixAt / setColorAt 后必须置 needsUpdate = true
2. 构造时 instanceMatrix 用 DynamicDrawUsage(告诉 GPU 会频繁改)
3. 数值未变就不更新(脏检查,Day 118 纪律的延续)
// 脏检查:数据没变就跳过更新
let lastKey = "";
function onData(next: number[]) {
  const key = next.join(",");
  if (key === lastKey) return;   // 没变,跳过
  lastKey = key;
  chart.update(next);
}

五、可读性加工:基座/网格/图例

3D 柱状图没有坐标轴,可读性靠三件套补:

5.1 网格地面(定位参考)

// 半透明网格地面:帮眼睛判断柱子位置与高度
const grid = new THREE.GridHelper(30, 20, 0x2a4a6b, 0x16283c);
grid.position.y = -0.01;              // 略低于柱底,避免 z-fighting
scene.add(grid);

5.2 基座板(视觉锚点)

// 一块半透明基座板,柱子立在"地板"上有实感
const base = new THREE.Mesh(
  new THREE.BoxGeometry(30, 0.1, 22),
  new THREE.MeshStandardMaterial({ color: 0x0d1421, transparent: true, opacity: 0.7 })
);
base.position.y = -0.05;
scene.add(base);

5.3 图例(数值刻度自证)

图例是 HTML(Day 120 第四节),Vue 组件渲染一个渐变条 + min/max 标注,悬浮画面角落。引擎只管柱体,图例归 UI——边界纪律(Day 85)不破


六、常见坑点

坑 1:柱子位置重叠穿模

原因:网格坐标没算间距,或没居中。修法:gridPos 用"索引 × 间距 + 整体居中偏移"(第一节 1.2)。

坑 2:柱子高度不对/倒立

原因:几何没 translate,柱子以中心为原点、一半埋地下。修法:geo.translate(0, 0.5, 0) 让底贴地(第三节)。

坑 3:全部柱子一个颜色

原因:只设了 setMatrixAt 没设 setColorAt修法:setColorAtinstanceColor.needsUpdate = true(第三节)。

坑 4:数据更新后柱子不动

原因:改了矩阵没置 needsUpdate修法:更新后 instanceMatrix.needsUpdate = true(第四节)。

坑 5:最小值柱子"看不见"

原因:linearScale 输出域下限是 0,最小值柱子高度 0。修法:输出域下限设 0.1 之类的小值(第三节)。

坑 6:数据全相等 NaN

原因:max===min 除零。修法:linearScale 已防除零(Day 120),但更新路径也要用同一个函数(第三节 update)。


七、自测挑战

T1 · 静态柱状图(50 分钟)

用 20 个随机能耗数据生成 3D 柱状图(4 行 5 列),加网格地面 + 基座板。截图确认:高度/颜色随数据变化,柱底贴地,无穿模

T2 · 500 根实例化压力测试(40 分钟)

生成 500 根柱,打开 Stats 面板(Day 118)对比:

  • 逐 Mesh 写法:CALLS 是多少?

  • InstancedMesh 写法:CALLS 是多少?
    把数字记进 FPS 预算表——这是"实例化值多少钱"的硬证据。

T3 · 数据更新(40 分钟)

给图表接一个"模拟实时":每 2 秒更新一次数据,观察:

  • update()(只改缓冲):FPS 稳定

  • 用"销毁重建":FPS 抖动 / GC 卡顿
    对比后写进笔记:数据更新的正确姿势

T4 · 可读性三件套(30 分钟)

加网格 + 基座 + 图例(HTML 渐变条),确认"没有坐标轴的柱状图也能读出数值量级"。


八、总结

环节

要点

数据驱动

位置/高度/颜色全由数据映射算出(Day 120 工具)

选型

海量同类柱子 → InstancedMesh(1 次 draw call)

实现

单位柱几何 + 矩阵拉伸 + setColorAt 分色

更新

只改实例缓冲 + needsUpdate,绝不销毁重建

可读性

网格地面 + 基座 + 图例,3D 无轴必须自证刻度

证据

500 根 CALLS 对比记入预算表

你有了第一个"真·数据可视化"3D 组件:一根柱子的高度、颜色、位置,全部由数据说了算,而且 500 根只花一次绘制。明天把连续场数据铺成曲面——从"柱"到"面",可视化能力再进一步。


明日预告:Day 122 3D 曲面与热力图——PlaneGeometry 顶点位移成"起伏地形"、顶点颜色映射数值成热力,看温度场/压力场如何"立"起来。

评论