Day 122 · 3D 曲面与热力图 — 顶点位移成"地形":温度场立起来
柱状图表达"离散类目",今天处理连续场(温度场/压力场/高度场):把一块平面网格的每个顶点,用数据抬高成起伏的"地形",再用顶点颜色把数值涂上去——这就是 3D 热力曲面。核心三件事:
PlaneGeometry的顶点结构(为什么它叫 30×20 却要 31×21 个点)、用数据改position属性生成起伏、用vertexColors把数值映射成连续色带。做完你会看懂"一切表面可视化都是改顶点 + 改颜色"这条 3D 数据的万能公式。
目录
一、平面网格的顶点结构:一张"布"是怎么编的
1.1 PlaneGeometry 的真实顶点数
new THREE.PlaneGeometry(width, height, segX, segY) 的顶点数是 (segX+1) × (segY+1),不是 segX × segY:
segX=3, segY=2 → 4 × 3 = 12 个顶点
(segments 是"段数",顶点 = 段数 + 1)
每顶点在 XY 平面:
左上 (-w/2, +h/2) → 右下 (+w/2, -h/2) 逐格排列
属性数组按"行优先"顺序存
这个"+1"是第 1 个经典坑:遍历数据时要按 (segY+1) 算行宽,否则数据错位、曲面扭曲。
1.2 顶点与索引
PlaneGeometry 内部:
position 属性 → 每个顶点的 (x, y, z) 坐标
index 属性 → 每两个三角形引用哪 3 个顶点(构成网格)
uv 属性 → 每顶点的贴图坐标(0~1)
今天只改 position 的 z(位移)和加 color 属性——索引、uv 不动。这就是"改顶点"的全部。
二、顶点位移:把数据变成"地形"
2.1 核心实现
// src/core/three/viz/heat-surface.ts(引擎内,纯逻辑)
import * as THREE from "three";
import { linearScale, valueToColor } from "../../data/scale";
/**
* 数据驱动的 3D 热力曲面
* 原理:PlaneGeometry 每个顶点的 z 用数据抬高(顶点位移),
* 再用顶点颜色把数值涂上去(vertexColors)。
* 设计要点:
* 1. 构造时先建"平坦网格",之后 update 只改 position 的 z 与 color
* 2. 网格细分越高,曲面越平滑,顶点越多(性能权衡)
*/
export class HeatSurface {
readonly mesh: THREE.Mesh;
private readonly segX: number;
private readonly segY: number;
private readonly dataMin: number;
private readonly dataMax: number;
private readonly maxHeight: number;
constructor(data: number[][], options?: { segX?: number; segY?: number; size?: number; maxHeight?: number }) {
this.segX = options?.segX ?? 30; // X 向段数
this.segY = options?.segY ?? 20; // Y 向段数
this.dataMin = Math.min(...data.flat());
this.dataMax = Math.max(...data.flat());
this.maxHeight = options?.maxHeight ?? 5;
// 1. 网格:顶点数 (segX+1)*(segY+1)
const size = options?.size ?? 30;
const geo = new THREE.PlaneGeometry(size, size, this.segX, this.segY);
// 默认 PlaneGeometry 面朝 +Z,旋转成水平(面朝上)——也可用 rotateX(-PI/2)
geo.rotateX(-Math.PI / 2);
// 2. 材质开启顶点颜色
const mat = new THREE.MeshStandardMaterial({
vertexColors: true, // 关键:颜色来自顶点而非 material.color
roughness: 0.6,
metalness: 0.1,
side: THREE.DoubleSide, // 曲面起伏时背面也可能被看到
});
this.mesh = new THREE.Mesh(geo, mat);
this.update(data);
}
/**
* 更新曲面:把数据写进顶点位置与顶点颜色
* 只改 BufferAttribute 的内容 + needsUpdate,不销毁重建
*/
update(data: number[][]) {
const geo = this.mesh.geometry as THREE.PlaneGeometry;
const pos = geo.attributes.position as THREE.BufferAttribute;
const count = pos.count;
// 初始化/复用顶点颜色属性(颜色数 = 顶点数)
let colors = geo.attributes.color as THREE.BufferAttribute | undefined;
if (!colors) {
colors = new THREE.BufferAttribute(new Float32Array(count * 3), 3);
geo.setAttribute("color", colors);
}
const color = new THREE.Color();
for (let i = 0; i < count; i++) {
// 顶点的原始 (x, y) 在水平面上(旋转后 x, z 是平面坐标)
const ix = i % (this.segX + 1); // 当前顶点在网格的列
const iy = Math.floor(i / (this.segX + 1)); // 当前顶点所在行
// 采样数据:data 是 (segY+1)×(segX+1) 的二维数组
const v = data[iy]?.[ix] ?? this.dataMin;
const h = linearScale(v, this.dataMin, this.dataMax, 0.1, this.maxHeight);
// 抬高 z(因为 rotateX 后,原 y 轴变成了世界 z)
pos.setZ(i, h);
color.copy(valueToColor(v, this.dataMin, this.dataMax));
colors.setXYZ(i, color.r, color.g, color.b);
}
pos.needsUpdate = true;
colors.needsUpdate = true;
geo.computeVertexNormals(); // 位移后必须重算法线,否则光照是"平的"
}
}
2.2 使用示例
// 30×20 的网格温度采样(字段是 (segY+1)×(segX+1) = 21×31)
const temps: number[][] = sampleGrid(21, 31); // 伪代码:采样温度
const surface = new HeatSurface(temps, { segX: 30, segY: 20, size: 30, maxHeight: 5 });
scene.add(surface.mesh);
// 模拟实时刷新
setInterval(() => surface.update(sampleGrid(21, 31)), 2000);
2.3 为什么必须 computeVertexNormals
顶点位移后,平面法线还是"朝上"的 → 光照全平,起伏"看不清"?
其实更糟:法线不对会让明暗完全错误。
修法:更新顶点后调用 geo.computeVertexNormals() 重算法线。
这是"改顶点后必做"的配套动作,漏了就是曲面"没有立体感"。
三、顶点颜色:热力是"涂"上去的
3.1 vertexColors 的本质
材质加 vertexColors: true 后:
每个顶点带一个颜色(color 属性)
三角形内部颜色由三个顶点"插值"而来
→ 连续色带 → 平滑过渡,无"断层"
对比贴图(Day 107):
贴图是"图片涂表面",顶点色是"数值涂顶点"
数据实时变 → 改顶点色比换贴图更快更省
3.2 顶点色 vs 贴图(取舍)
结论:实时热力场用顶点色;高精度静态图用贴图。本周的数据可视化全用顶点色——因为"数据实时变"是主线。
四、数据更新与性能
4.1 更新纪律(延续 Day 121)
1. 只改 position 的 z 与 color 属性,不重建几何
2. 改完 pos.needsUpdate / colors.needsUpdate = true
3. 改完 computeVertexNormals()(位移影响光照)
4. 脏检查:数据没变就跳过
4.2 细分与性能的权衡
段数 30×20 → 顶点 31×21 = 651 个 → 很轻
段数 200×200 → 顶点 201×201 = 40401 个 → 每帧位移+重算法线会重
工业建议:网格细分 ≈ 数据采样分辨率即可,别盲目加细
数据只有 30×20 采样点,网格 200 段是白花钱
性能预算纪律(Day 118 延续):曲面属于"中开销"——顶点位移 + 法线重算在 CPU 侧,改成"数据到达才更新"而非"每帧更新"即可守住预算。
五、等高线与辅助增强(选做)
5.1 等高线(理解地形的好助手)
等高线 = 高度相同的点连成线。3D 里可用"多层半透明切片"近似:
// 在曲面下方每隔一定高度放一块半透明平面,形成"等高切片"观感
function buildContourSlices(heightRange: [number, number], levels: number) {
const slices: THREE.Mesh[] = [];
for (let i = 0; i < levels; i++) {
const h = heightRange[0] + (i / (levels - 1)) * (heightRange[1] - heightRange[0]);
const slice = new THREE.Mesh(
new THREE.PlaneGeometry(30, 30),
new THREE.MeshBasicMaterial({ color: 0x00c6ff, transparent: true, opacity: 0.08, depthWrite: false })
);
slice.rotation.x = -Math.PI / 2;
slice.position.y = h;
slices.push(slice);
}
return slices;
}
5.2 底部投影(数值锚点)
在曲面正下方地面投影一份"扁平热力"(把 z 压到 0 的复制体),形成"地形 + 地图"对照——帮眼睛把起伏对应到平面坐标。选做,学有余力再实现。
六、常见坑点
坑 1:数据错位,曲面扭成麻花
原因:遍历顶点时行宽用错(用了 segX 而不是 segX+1)。修法:ix = i % (segX+1),iy = floor(i / (segX+1))(第二节)。
坑 2:曲面是"平的",没有起伏立体感
原因:顶点位移后没 computeVertexNormals()。修法:update 末尾重算法线(第二节 2.3)。
坑 3:全是白色/颜色不对
原因:材质没开 vertexColors: true,或没设 color 属性。修法:vertexColors: true + setAttribute("color", ...)(第二、三节)。
坑 4:背面看不见/穿帮
原因:曲面起伏后部分面朝下,默认 FrontSide 剔除。修法:side: THREE.DoubleSide(第二节)。
坑 5:数据采样点数与网格顶点数不匹配
原因:数据是 30×20 采样,网格顶点是 31×21,索引错位。修法:数据按 (segY+1)×(segX+1) 生成,或采样时对齐(第一节)。
坑 6:实时更新掉帧
原因:每帧都 update 或细分过高。修法:数据到达才更新 + 细分匹配采样分辨率(第四节)。
七、自测挑战
T1 · 静态热力曲面(50 分钟)
用 21×31 的随机温度场生成热力曲面(segX=30, segY=20)。截图确认:起伏高度、颜色冷暖都随数据变化,无麻花无平脸。
T2 · 实时刷新(40 分钟)
每 2 秒更新温度场,观察:
曲面平滑变形、颜色连续过渡(无断层)
打开 Stats 面板:FPS 稳定、无 GC 尖刺
对比"重建几何"的写法,把数字记入预算表。
T3 · 顶点色 vs 贴图对比(40 分钟)
同一温度场分别用"顶点色"和"Canvas 画的热力贴图"实现,对比更新流畅度与画质。写进笔记:什么场景用哪个。
T4 · 等高线切片(选做,30 分钟)
加 5 层等高切片,确认"地形起伏"更易读。
八、总结
你掌握了 3D 数据可视化的万能公式:改顶点 = 形状随数据,改颜色 = 数值随数据。柱状图是"离散版"、曲面是"连续版",两者合起来覆盖了绝大多数工业数据。明天处理第三类:空间点位与密度——用粒子让"流场"活起来。
明日预告:Day 123 粒子与流场——用 Points 把空间点位/密度/流向可视化成"活"的流场,配合 Day 113 的粒子贴图经验。