d3-force-3d 构建时预计算 3D 网络布局,减少客户端渲染压力

🕒 阅读时间:4 分钟📝 字数:1237👀 阅读量:Loading...

前言

友链图谱 是一个汇聚了 1600+ 节点2200+ 连接的 3D 友链网络可视化项目。数据源是 links/*.yml 文件,渲染层基于 3d-force-graph(Three.js + d3-force-3d)。

上一篇文章中,我介绍了友链图谱从 2D 升级到 3D 的整体架构——包括 3D 力导布局的数学原理、节点渲染和高亮系统。但那时的方案是:构建时只输出数据,客户端自行跑力导仿真

这个方案有两个痛点:

痛点 表现
加载慢 每次刷新都要从 [-5,5] 随机位置重新跑动画
hover 卡顿 每次悬停触发 Graph.refresh() → 遍历 1600 节点计算颜色
构建浪费 构建 2 秒完成,但客户端要跑 5-10 秒动画

声明:本文在撰写过程中使用了 AI 工具辅助分析技术资料、整理踩坑经验及润色排版。

思路:构建时预计算位置

「既然 d3-force-3d 在客户端跑,那在构建时(Node.js)也跑一遍不就提前拿到位置了吗?」

d3-force-3d 官方文档明确支持这种做法:

simulation.tick() — "This method can be used in conjunction with simulation.stop to compute a static force layout."

于是我在 Astro API 端点 graph.json.ts 中加入了构建时预计算:

import { forceSimulation, forceLink, forceManyBody, forceCenter } from "d3-force-3d";
const sim = forceSimulation(nodes, 3) // 明确 3 维!
.force(
"link",
forceLink(links)
.id((d) => d.id)
.distance(30),
)
.force("charge", forceManyBody().strength(-60))
.force("center", forceCenter(0, 0, 0)) // 默认 strength=1
.alphaDecay(0.02)
.velocityDecay(0.3);
for (let i = 0; i < 300; i++) sim.tick();
sim.stop();

客户端改成加载预计算位置,直接冻结:

.cooldownTicks(0)
.cooldownTime(0)

然而……事情没那么简单。

踩坑 1:平面坍塌

跑出来的效果:

X: std=635 范围 [-1780, 1594]
Y: std=523 范围 [-2107, 1317]
Z: std=141 范围 [-200, 200] ← 被压扁了!
3D ratio = 0.223 ⚠️ 扁平

Z 轴被压到只有 ±200,不管怎么调参数——distance=30 还是 40charge=-60 还是 -120alphaDecay=0.02 还是 0.005——Z 永远塌在初始半径以内。

原因forceCenter(0,0,0) 的默认强度是 1.0,而 charge 只有 -60link 只有 distance=30。center 力比其他力大了两个数量级,强行把所有节点往原点拉,Z 轴首当其冲被消解。

有趣的是,客户端渲染时同样的参数看起来却是"3D 网络"——因为客户端跑到 200 tick 就停了(cooldownTicks),还没收敛到平面,停在了一个好看的暂态。但构建时要的是一劳永逸,不能依赖"没跑完"。

踩坑 2:花哨尝试一一失败

方案 结果
forceRadial(800).strength(0.05) 强制球壳,不是网络
自定义 zBias 力(随机 Z 扰动) 被 alpha 衰减消解,Z 还是塌
去掉 forceCenter,只用 forceX/forceY 中心节点子节点形成圆盘
forceCenter.strength(0.3) 0.651,不够

每次调整都离目标差一点,走了很多弯路。

最终方案:三轴等强 + 弱居中

关键洞察是:不要对抗 forceCenter,而是把它减弱到和其他力一个量级

const sim = forceSimulation(nodes, 3)
.force(
"link",
forceLink(links)
.id((d) => d.id)
.distance(40),
)
.force("charge", forceManyBody().strength(-120))
.force("center", forceCenter(0, 0, 0).strength(0.02)) // ← 关键!
.alphaDecay(0.01)
.velocityDecay(0.4);
for (let i = 0; i < 500; i++) sim.tick();
sim.stop();
参数 作用
link.distance 40 连接节点间距
charge -120 排斥力(比默认强一倍)
center.strength 0.02 极弱居中,仅防漂移
alphaDecay 0.01 慢冷却,充分收敛
velocityDecay 0.4 与 3d-force-graph 默认对齐
tick 500 完全跑完

为什么 0.02 有效?

strength=0.02 意味着每 tick 所有节点只向中心移动 2% 的距离。这刚好抵消整体漂移,但完全不足以压倒 link 和 charge 力。三种力在同一个量级上互相平衡,自然形成 3D 散布。

结果:

X: std=478 范围 [-1288, 1265]
Y: std=312 范围 [-640, 1315]
Z: std=357 范围 [-1076, 802]
3D ratio = 0.747 ✅ 良好的 3D 网络

三轴散布均衡,没有平面、没有球壳、没有圆盘,是一个真正的体积网络(volumetric network)。

客户端配置

客户端直接加载预计算位置,不再跑额外仿真:

.graphData(graphData)
.warmupTicks(0)
.cooldownTicks(0)
.cooldownTime(0)
.d3AlphaDecay(0.02)
.d3VelocityDecay(0.3);

注意 linkcharge不能禁用d3Force(null)),否则 3d-force-graph 的连线渲染会丢失。只要 cooldownTicks(0),仿真在一 tick 后立即冻结,位置不会被挪动。

效果对比

指标 客户端渲染 构建时预计算
页面加载 等 5-10 秒动画 即开即用
构建时间 2 秒 20 秒(一次编译,永久使用)
hover 卡顿 有(Graph.refresh) 无(已优化)
3D 散布 暂态,依赖 timing 稳定
布局可复现 ❌ 每次随机 ✅ 固定(只要 seed 不变)

总结

构建时预计算 3D 力导布局完全可行,关键不是"要不要用 forceCenter",而是把 forceCenter 的强度降到和其他力一个量级

center.strength 结果
1.0 平面(center 主宰)
0.0 漂移(无约束)
0.02 3D 网络 ✅(三种力平衡)

项目仓库:xingwangzhe/FriendLinks — 欢迎 star 和 PR!

友链图谱在线体验:https://links.needhelp.icu/

d3-force-3d 构建时预计算 3D 网络布局,减少客户端渲染压力

作者:xingwangzhe

本文链接:https://xingwangzhe.fun/posts/d3-force-precompute-3d/

本文采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。

留言评论