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 预算表。
八、总结
三种形态集齐:柱(离散)、面(连续)、点(空间点位/流场)——工业数据可视化的三大支柱全部打通。明天做本周的架构核心:把数据层与渲染层解耦,让这些图表能接上"未来会来的实时数据"。
明日预告:Day 124 数据层与渲染层解耦——数据源抽象、快照映射、桥契约扩展。让今天的图表"不认业务字段,只认归一化数值",为 WS 实时接入铺好管道。