【Three.js】day123-particle-flowfield

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

Day 123 · 粒子与流场 — 用 Points 让"空间数据"活起来

柱状图管"离散类目"、曲面管"连续场",今天补上第三类:空间点位与密度/流向。工业场景里最常见的需求是"这一片区域里,某指标的空间分布"(厂区空气质量、管道流速、人员/设备密度)——数据是一堆带坐标的点。Three.js 处理这类数据的终极武器是 Points:把每个数据点变成一个粒子,几千上万个点也只需一次 draw call。核心三件事:数据点位 → Points 几何、用"颜色/大小"表达密度或流速、让粒子动起来做"流场"。做完三种形态(柱/面/点)集齐,Day 126 看板就有全部弹药了。


目录


一、Points 的本质:一张"顶点清单"就是全部

1.1 Points vs 柱状/曲面

柱状图(Day 121) = 离散实体(有体积、要光照)
曲面(Day 122)   = 连续表面(有拓扑、要法线)
Points(今天)    = 一堆"点"(无体积、无拓扑、只管位置)

Points 是最轻的渲染形态:没有三角形、没有索引、没有法线,只有一张位置清单。GPU 把每个位置画成一个点精灵(sprite)。

1.2 一个点的全部信息

每粒子三组属性:
  position   → (x, y, z) 空间坐标(必须)
  color      → (r, g, b) 颜色(可选,表达数值)
  size       → 点的大小(可选,表达数值/密度)

Points 的优势:一张缓冲、一次 draw call,几千点轻松跑
(这正是 Day 113 粒子系统的"数据可视化版"——同一个机制)

对照表第 11 行:“手写 WebGL 做粒子要自己管理点精灵/大小衰减;Three.js 一个 Points + PointsMaterial 搞定”。记进笔记。


二、数据点位粒子:散点/密度可视化

2.1 核心实现(数据点位 → Points)

// src/core/three/viz/scatter-points.ts(引擎内,纯逻辑)
import * as THREE from "three";

/**
 * 数据点位粒子:把带坐标的数据点渲染成点云
 * 数据格式:[x, y, z, value] 数组——坐标 + 数值
 * 设计要点:
 *  1. 坐标直接用数据(可能需 linearScale 归一化到场景尺寸)
 *  2. 数值映射成颜色(valueToColor)与/或大小
 */
export class ScatterPoints {
  readonly points: THREE.Points;
  private readonly positions: Float32Array;
  private readonly count: number;

  constructor(
    data: Array<[number, number, number, number]>,   // [x, y, z, value]
    options?: { size?: number; maxSize?: number }
  ) {
    this.count = data.length;
    this.positions = new Float32Array(this.count * 3);

    const colorArr = new Float32Array(this.count * 3);
    const sizeArr = new Float32Array(this.count);       // PointsMaterial.sizeAttenuation 用每粒子大小需 ShaderMaterial,这里先全用统一 size
    const color = new THREE.Color();
    const vals = data.map((d) => d[3]);
    const min = Math.min(...vals);
    const max = Math.max(...vals);

    data.forEach(([x, y, z, v], i) => {
      this.positions[i * 3] = x;
      this.positions[i * 3 + 1] = y;
      this.positions[i * 3 + 2] = z;
      color.copy(valueToColor(v, min, max));
      colorArr[i * 3] = color.r;
      colorArr[i * 3 + 1] = color.g;
      colorArr[i * 3 + 2] = color.b;
    });

    const geo = new THREE.BufferGeometry();
    geo.setAttribute("position", new THREE.BufferAttribute(this.positions, 3));
    geo.setAttribute("color", new THREE.BufferAttribute(colorArr, 3));

    const mat = new THREE.PointsMaterial({
      size: options?.size ?? 0.15,
      vertexColors: true,               // 颜色来自每粒子 color
      transparent: true,
      depthWrite: false,
    });

    this.points = new THREE.Points(geo, mat);
  }

  /**
   * 更新粒子(数据刷新):只改属性缓冲,不重建
   */
  update(data: Array<[number, number, number, number]>) {
    // 更新 position / color(与构造逻辑一致)
    // ...
    const geo = this.points.geometry;
    (geo.attributes.position as THREE.BufferAttribute).needsUpdate = true;
    (geo.attributes.color as THREE.BufferAttribute).needsUpdate = true;
  }
}

2.2 使用示例

// 厂区空气质量采样点(x, z, y≈0, 浓度值)
const samples: Array<[number, number, number, number]> = generateGridSamples();
const cloud = new ScatterPoints(samples, { size: 0.2 });
scene.add(cloud.points);

2.3 每粒子不同大小(进阶)

PointsMaterial 的大小是全局的。要"每个粒子不同大小"(大点=高值),需自定义着色器(Day 116 能力回流):

// 顶点着色器(节选):attribute float aSize 驱动每粒子大小
attribute float aSize;
attribute vec3 aColor;
varying vec3 vColor;
void main() {
  vColor = aColor;
  vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
  gl_PointSize = aSize * (300.0 / -mvPosition.z);  // 距离衰减:远小近大
  gl_Position = projectionMatrix * mvPosition;
}

优先顺序:先会"统一大小 + 颜色表达数值"(够用 80% 场景),需要"大小也表达数值"再上着色器——别一开始就上 ShaderMaterial。


三、粒子大小与颜色:表达"密度/数值"

3.1 密度可视化的三种手法

手法 1:颜色映射(最常用)
  浓度高 → 偏红,低 → 偏蓝(valueToColor)

手法 2:粒子大小映射(进阶)
  高值 → 大点,低值 → 小点(需 aSize 着色器)

手法 3:粒子密度(用数量表达)
  区域点越密 → 数值越高(数据本身是采样点,不做映射)

3.2 颜色与大小同时用要注意

✘ 颜色 + 大小同时映射不同变量 → 观众分不清哪个是主
✓ 二选一为主(颜色),另一个只做弱辅助或不用
"一个可视化表达一个主信息" —— 可读性优先(Day 120 红线)

四、流场:让粒子沿轨迹动起来

4.1 流场的本质

流场数据 = 空间上每个点有一个方向 + 速度(矢量场)。可视化方式:

方式 A:流线(静态)
  沿流线放置一串粒子点,连成轨迹 → 看"形状"
方式 B:动态粒子(今天重点)
  粒子沿速度方向持续移动,形成"流动感" → 看"活的方向"

4.2 动态流场实现(粒子沿速度移动)

// src/core/three/viz/flow-field.ts(引擎内)
import * as THREE from "three";

/**
 * 动态流场:粒子沿矢量场移动,形成流动感
 * 矢量场 = (x, y) → (vx, vy) 的采样函数(工业里来自 CFD 数据)
 * 设计要点:
 *  1. 粒子初始在场景内随机撒点
 *  2. 每帧按"所在位置的矢量"更新粒子位置(欧拉积分)
 *  3. 粒子跑出边界就"重生"回场边(循环流动)
 */
export class FlowField {
  readonly points: THREE.Points;
  private readonly positions: Float32Array;
  private readonly n: number;
  private readonly bounds: [number, number];

  constructor(n: number, bounds: [number, number], getVelocity: (x: number, y: number) => [number, number]) {
    this.n = n;
    this.bounds = bounds;
    this.positions = new Float32Array(n * 3);

    // 随机撒点
    for (let i = 0; i < n; i++) {
      this.positions[i * 3] = Math.random() * bounds[1] - bounds[0] / 2;
      this.positions[i * 3 + 1] = Math.random() * bounds[1] - bounds[0] / 2;
      this.positions[i * 3 + 2] = 0.5;
    }

    const geo = new THREE.BufferGeometry();
    geo.setAttribute("position", new THREE.BufferAttribute(this.positions, 3));
    const mat = new THREE.PointsMaterial({
      size: 0.12,
      color: 0x00c6ff,
      transparent: true,
      opacity: 0.8,
      depthWrite: false,
    });
    this.points = new THREE.Points(geo, mat);
    this.getVelocity = getVelocity;
  }

  /** 每帧调用:按矢量场推进粒子 */
  update(delta: number) {
    const arr = this.positions;
    const [w, h] = this.bounds;
    for (let i = 0; i < this.n; i++) {
      const x = arr[i * 3];
      const y = arr[i * 3 + 1];
      const [vx, vy] = this.getVelocity(x, y);
      arr[i * 3] = x + vx * delta;      // 欧拉积分:位置 += 速度 × 时间
      arr[i * 3 + 1] = y + vy * delta;
      // 出界重生
      if (Math.abs(arr[i * 3]) > w / 2 || Math.abs(arr[i * 3 + 1]) > h / 2) {
        arr[i * 3] = Math.random() * w - w / 2;
        arr[i * 3 + 1] = Math.random() * h - h / 2;
      }
    }
    (this.points.geometry.attributes.position as THREE.BufferAttribute).needsUpdate = true;
  }

  private getVelocity: (x: number, y: number) => [number, number];
}

4.3 一个工业味矢量场示例

// 管道内流速场(示意):x 越大流速越快,且带一点漩涡
const getVel = (x: number, y: number): [number, number] => {
  const speed = 0.5 + (x + 5) / 10;   // 越靠右越快
  return [speed, Math.sin(x * 0.5) * 0.2]; // 轻微波动
};
const field = new FlowField(2000, [10, 6], getVel);
scene.add(field.points);

// 渲染循环里调用(数据更新段,Day 124 将归位)
field.update(delta);

4.4 时间驱动纪律(Day 118 回流)

粒子移动必须用 delta 时间驱动update(delta)),不能每帧固定步进——否则掉帧时流场"变慢",帧率高时"变快",动态效果失真。这也是 Day 118"动效纪律"在数据可视化上的延续。


五、性能与预算(延续 Day 118)

5.1 粒子数量预算

2000 粒子(流场)     → 很轻,随便跑
10000 粒子(大密度场)→ 需要 PointsMaterial + 不加透明混合
50000+                → 可能掉帧,考虑降粒数 / 加着色器优化

5.2 预算纪律

✓ 流场粒子用统一大小 + 颜色表达(省着色器开销)
✓ 更新纪律:位置只在需要时更新(流场是每帧,散点是数据到达时)
✓ 出界重生用"随机换位"而非"归零",避免视觉聚集
✗ 别在 PointsMaterial 上加 UnrealBloom(粒子小、点多,辉光浪费)

把粒子数/帧率记入 Day 118 预算表:流场每帧 2000 点更新 + 渲染的增量毫秒数,让数据说话。


六、常见坑点

坑 1:粒子太小看不见 / 太大糊成一片

原因:PointsMaterial.size 与场景尺度不匹配。修法:按场景尺寸调 size(如 0.1~0.3),或加 sizeAttenuation 距离衰减

坑 2:粒子全是白色,颜色没生效

原因:材质没开 vertexColors: true 或没设 color 属性。修法:vertexColors: true + 设每粒子 color(第二节)。

坑 3:粒子半透明叠加"糊" / 背景穿帮

原因:透明混合 + depthWrite 没配合。修法:transparent: true + depthWrite: false(第二节)。

坑 4:流场粒子全部堆到一点/不动

原因:矢量场函数返回 (0,0) 或积分更新没调用。修法:检查 getVelocity 有方向 + 每帧调 update(delta)(第四节)。

坑 5:粒子"闪烁"(z-fighting 感)

原因:多个 Points 叠加或与平面同高度。修法:错开微小 y 偏移 / depthWrite 策略调整

坑 6:掉帧(粒子太多或每帧重建)

原因:数量超预算或每次重建属性。修法:降粒数 + 只改属性缓冲 + 记入预算表(第五节)。


七、自测挑战

T1 · 数据散点云(40 分钟)

用 1000 个随机采样点(带数值)做 ScatterPoints截图确认:颜色随数值冷暖变化、无白点无糊点

T2 · 密度三种手法(50 分钟)

同一批数据分别用"颜色映射"“大小映射(着色器 aSize)”"粒子密度"表达数值。对比三者的可读性与成本,写进笔记

T3 · 动态流场(60 分钟)

实现 FlowField,用 2000 粒子演示管道流速场。截图/录屏确认:粒子沿速度方向流动、出界重生、颜色/大小表达流速。重点验证"时间驱动"——锁帧 30 与 60 时流速观感一致。

T4 · 预算记录(20 分钟)

把"流场 2000 粒子每帧更新"的增量毫秒数记入 Day 118 预算表。


八、总结

环节

要点

Points 本质

一张位置清单,最轻的渲染形态,一次 draw call

散点/密度

数据点位 → position + color(vertexColors)

表达数值

颜色为主、大小为辅;一个图一个主信息

流场

粒子沿矢量场移动(欧拉积分 + 出界重生)

动态纪律

时间驱动 + 只改属性 + 数量入预算表

进阶

每粒子大小需 aSize 着色器(Day 116 能力)

三种形态集齐:柱(离散)、面(连续)、点(空间点位/流场)——工业数据可视化的三大支柱全部打通。明天做本周的架构核心:把数据层与渲染层解耦,让这些图表能接上"未来会来的实时数据"。


明日预告:Day 124 数据层与渲染层解耦——数据源抽象、快照映射、桥契约扩展。让今天的图表"不认业务字段,只认归一化数值",为 WS 实时接入铺好管道。

评论