【Three.js】day122-heat-surface

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

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 贴图(取舍)

维度

顶点颜色

纹理贴图

数据更新

快(改属性)

需重绘 Canvas/上传纹理

平滑度

取决于网格细分

取决于图片分辨率

数值精度

顶点级

像素级

适用

实时热力/状态场

静态纹理/高精度图

结论:实时热力场用顶点色;高精度静态图用贴图。本周的数据可视化全用顶点色——因为"数据实时变"是主线。


四、数据更新与性能

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 层等高切片,确认"地形起伏"更易读。


八、总结

环节

要点

顶点结构

PlaneGeometry 顶点 = (segX+1)×(segY+1),行宽 +1 是坑

顶点位移

改 position 的 z = 数据变地形

顶点颜色

vertexColors: true + color 属性 = 数据变热力

配套动作

位移后 computeVertexNormals(),漏了就没立体感

更新

只改属性 + needsUpdate + 数据到达才更新

选型

实时热力用顶点色;静态高精度用贴图

你掌握了 3D 数据可视化的万能公式:改顶点 = 形状随数据,改颜色 = 数值随数据。柱状图是"离散版"、曲面是"连续版",两者合起来覆盖了绝大多数工业数据。明天处理第三类:空间点位与密度——用粒子让"流场"活起来。


明日预告:Day 123 粒子与流场——用 Points 把空间点位/密度/流向可视化成"活"的流场,配合 Day 113 的粒子贴图经验。

评论