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
结论:柱状图天生适合 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。修法:setColorAt 后 instanceColor.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 渐变条),确认"没有坐标轴的柱状图也能读出数值量级"。
八、总结
你有了第一个"真·数据可视化"3D 组件:一根柱子的高度、颜色、位置,全部由数据说了算,而且 500 根只花一次绘制。明天把连续场数据铺成曲面——从"柱"到"面",可视化能力再进一步。
明日预告:Day 122 3D 曲面与热力图——PlaneGeometry 顶点位移成"起伏地形"、顶点颜色映射数值成热力,看温度场/压力场如何"立"起来。