D3 forceCollide 碰撞力详解:radius、strength 与 iterations 的机制、参数与实战
本文围绕 D3 官方文档 docs/d3-force/collide.md 展开,系统讲解 d3-force 模块中的碰撞力 forceCollide:它如何把节点当作圆而非点来处理,三个 API(forceCollide(*radius*)、*collide*.radius、*collide*.strength、*collide*.iterations)的默认值与调用时机,以及其“软约束 + 迭代松弛”的工作原理。读完你可以直接复现仓库内置的 200 圆交互示例,并理解碰撞力在模拟 tick 循环中的位置与调优思路。
碰撞力是什么:圆形节点与软约束
碰撞力把节点视为具有给定半径的圆,而不是点,从而阻止节点相互重叠。文档给出的严格定义是:两个节点 a 与 b 会被分开,使得二者之间的距离至少为 radius(a) + radius(b)。
需要注意的关键设计:为了减少抖动(jitter),碰撞力默认是一个“软”约束(soft constraint),而不是硬性排斥——它的松紧程度由两个可配置项控制:
- strength:控制重叠修正的衰减强度,默认 1;
- iterations:控制每次施加力时的迭代次数,默认 1。
也就是说,碰撞力并不保证每一步都“完全推开”,而是通过迭代松弛逐步趋近于稳定解;这正是它与 d3-pack 这类确定性圆形打包布局的本质区别。碰撞力的典型应用场景是气泡图(bubble chart)一类的可视化,d3-force 模块文档 也把“碰撞解决”列为其核心用途之一。
API 总览
| API | 形式 | 默认值 | 作用 |
|---|---|---|---|
d3.forceCollide(*radius*) |
构造函数 | 半径默认为常量 1 | 创建一个新的圆形碰撞力 |
*collide*.radius(*radius*) |
数值或 (d, i) => number |
() => 1 |
设置(或读取)每个节点的半径访问器 |
*collide*.strength(*strength*) |
数值 | 1 | 设置(或读取)重叠修正的衰减强度,范围 [0,1] |
*collide*.iterations(*iterations*) |
整数 | 1 | 设置(或读取)每次施加力时的迭代次数 |
三个访问器方法都遵循“传入参数则设置并返回 this、不传参数则返回当前值”的读写器惯例,因此可以自由链式调用。
forceCollide(radius)
创建一个带有指定 radius 的圆形碰撞力;如果不指定 radius,则默认为常量 1(对所有节点相同)。
const collide = d3.forceCollide((d) => d.r);
半径可以直接写常量数字(所有节点同半径),也可以写成函数以按节点取值,例如上例中取节点数据上的 d.r 字段。构造函数中传入的 radius 与之后调用 *collide*.radius(*radius*) 完全等价,官方文档给出的示例正是函数形式。
collide.radius(radius)
- 传入 radius:把半径访问器设置为给定的数值或函数,立即对每个节点重新求值一次,并返回 this,可继续链式调用;
- 不传参数:返回当前的半径访问器,其默认值为:
function radius() {
return 1;
}
半径访问器会在模拟的每个节点(node)上被调用,并接收 node 本身和它的从零开始下标 index 两个参数;求值结果随后被存储(缓存)在力内部。因此每个节点的半径只在两种时刻被重新计算:
- 该力被初始化时(force.initialize,即力绑定到模拟、或模拟节点发生变化时);
- 调用
*collide*.radius()传入了新的 radius 时。
每次施加力(即每个 tick)时不会重新调用访问器。这个设计意味着:即使你的半径函数里有较昂贵的计算(查表、比例尺换算等),其成本也只发生在初始化阶段,而不是每个 tick 都付一遍。反过来它也有一个隐含约束——如果你在模拟运行中直接修改了节点上的半径源数据(例如 d.r),必须再调用一次 collide.radius(collide.radius()) 之类的操作触发重新求值,碰撞检测才会采用新值。
collide.strength(strength)
传入 strength 时,把力的强度设置为给定数值并返回 this;不传参数时返回当前强度。取值范围是 [0,1],默认值为 1。
重叠节点通过迭代松弛(iterative relaxation)来消解。具体过程是:对每个节点,先确定在下一个 tick 的预测位置 ⟨x + vx, y + vy⟩ 下预计会与之重叠的其他节点;然后修改该节点的速度,把它从每个与之重叠的节点处推开。速度的变化量会被力的 strength 所衰减(dampened),使得同一时刻发生的多重重叠可以彼此“混合”,从而收敛到一个稳定解,而不是互相打架产生抖动。
由此可以推断出 strength 的调优含义:strength 越接近 1,约束越“硬”,重叠被更坚决地推开;调小 strength(如 0.3~0.7)则让碰撞更像软弹簧,节点之间允许轻微“渗透”,视觉上更柔和,但静态布局下的最终重叠程度也会更高。
collide.iterations(iterations)
传入 iterations 时,把每次施加力时的迭代次数设置为给定数值并返回 this;不传参数时返回当前迭代次数,默认值为 1。
文档明确给出了代价与收益的权衡:
- 增大迭代次数会显著提高约束的刚性,避免节点的部分重叠(partial overlap);
- 但同时增加每次求值该力的运行时间成本,因为每一轮迭代都要重新查询邻接关系并修正速度。
实践中常见取值是 2~8 之间的整数:节点数不大、对“绝对不重叠”要求严格时可以提高 iterations;节点数很大或追求动画流畅时则保持 1~2,并通过适当降低 strength 换取观感上的平滑。
实战:官方示例的完整实现
下面这段完整可运行的示例改编自仓库文档站点中的官方演示组件 ExampleCollideForce.vue:200 个不等半径的圆在低摩擦下漂浮、相互避让,且第一个节点可以被指针“拖住”,其余节点实时让位。
import * as d3 from "d3";
const width = 688;
const height = 540;
const k = width / 200; // 半径基准,与画布宽度挂钩
const r = d3.randomUniform(k, k * 4); // 半径在 k 与 4k 之间均匀分布
const nodes = Array.from({length: 200}, () => ({r: r()}));
const svg = d3.select("svg")
.on("touchmove", (event) => event.preventDefault())
.on("pointerenter", () => simulation.alphaTarget(0.3).restart()) // 进入:重新加热
.on("pointerleave", () => simulation.alphaTarget(0)) // 离开:恢复冷却
.on("pointermove", (event) => ([nodes[0].fx, nodes[0].fy] = d3.pointer(event))); // 固定第一个节点
const circle = svg.selectAll("circle")
.data(nodes.slice(1))
.join("circle")
.attr("r", (d) => d.r);
const simulation = d3.forceSimulation(nodes)
.velocityDecay(0.1) // 低摩擦
.force("x", d3.forceX().strength(0.01)) // 极弱的向心定位
.force("y", d3.forceY().strength(0.01))
.force("collide", d3.forceCollide()
.radius((d) => d.r + 1) // 半径加 1px 间隙
.iterations(4)) // 提高刚性,避免部分重叠
.force("charge", d3.forceManyBody()
.strength((d, i) => i ? 0 : -width * 2 / 3)) // 仅节点 0 带电荷
.on("tick", ticked);
function ticked() {
circle.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y);
}
逐点拆解其中的碰撞力配置与配套技巧:
.radius((d) => d.r + 1):半径访问器取节点数据中的d.r再加 1 个像素的固定间隙。这体现了 radius 作为函数的灵活性——它可以是任意(d, i) => number表达式,间隙也可以改成比例(如d.r * 1.1)。.iterations(4):把每次施加力的迭代次数从默认 1 提到 4,换取更强的刚性,避免 200 个圆在密集区出现部分重叠。velocityDecay(0.1)(低摩擦):默认速度衰减是 0.4,这里刻意调低让节点“滑”得更久,配合弱定位力产生漂浮感。低摩擦会让软约束的混合过程更明显,也更容易暴露 jitter——这正是文档中引入 strength 作为“软”约束旋钮的动机。force("charge", d3.forceManyBody().strength((d, i) => i ? 0 : -width * 2 / 3)):多体力也支持按节点取值的强度函数,这里只有下标 0 的节点带强负电荷,其余节点电荷为 0,于是形成“一个中心 + 一群圆”的结构。- 交互重热:
pointerenter时simulation.alphaTarget(0.3).restart()把目标 alpha 抬到 0.3 并重启内部计时器,模拟进入“加热”状态;pointerleave时恢复alphaTarget(0)让其正常冷却。配合nodes[0].fx / nodes[0].fy的固定位置机制(每个 tick 末尾,fx/fy 有定义的节点其位置会被重置为该值、对应速度置零),第一个节点始终跟随指针,其余节点被碰撞力实时推开。这些机制分别见 simulation 文档 中simulation.alphaTarget、simulation.restart与simulation.nodes的说明。 - 渲染侧遵循力模拟的标准模式:只给
nodes.slice(1)做数据绑定(节点 0 不参与普通圆绑定,它由指针直接驱动),在tick事件里按d.x / d.y更新cx / cy。
机制深入:预测位置、软约束与缓存
综合 collide 文档 与 力模拟文档 的描述,可以把碰撞力在每次施加时的行为归纳为三个要点。
1. 使用预测位置做重叠判定。 碰撞力不是用当前位置 ⟨x, y⟩ 判定重叠,而是“向前看”到下一步 ⟨x + vx, y + vy⟩。simulation 文档的 Custom forces 一节 明确说明:力可以“窥探”节点的下一预测位置,这是通过迭代松弛解决几何约束所必需的——只看当前位置的碰撞判定在高速运动下会“追不上”节点的移动,从而出现穿过现象。
2. strength 是速度的阻尼系数。 对每个预测到重叠的邻居对,力会修正节点速度把二者推开,而修正量乘以 strength 后写入 vx / vy。多个重叠同时存在时,各方向的修正互相叠加并被统一衰减,这就是文档所说“simultaneous overlaps can be blended together to find a stable solution”的含义:strength < 1 时相当于在多个冲突修正之间做加权平均,越小的 strength 越“温顺”,越不容易抖动,但静态收敛后的剩余重叠也越多。
3. iterations 与运行成本的线性权衡。 iterations 控制的是每次施加力时“查询邻接 → 修正速度”这一整轮过程重复几遍。每一轮都会让布局更接近“完全不相交”的状态,因此刚性随 iterations 提升;代价是每次 tick 的碰撞力求值成本随之增长(邻接查询与速度修正都按轮数重复)。
4. 半径只算一次(初始化时缓存)。 如 radius 小节 所述,访问器在 force.initialize(nodes) 阶段对每个节点求值并缓存,每次施加力只读缓存。对照 simulation 文档 对 *force*.initialize 的说明——力可以在初始化时完成求值每个节点参数之类的工作,避免在每次施加力时重复付出——碰撞力正是这一优化原则的典型实现。
5. 碰撞力只改速度,不改位置。 从源码结构看(力通过 simulation.force("collide", ...) 绑定),碰撞力遵循 d3-force 的自定义力接口:力函数接收当前 alpha 参数,读取节点位置、改写节点速度;而位置本身由模拟在每步末尾统一按“速度更新位置”推进(每个 tick 的完整顺序是:alpha 更新 → 依次施加各力 → 速度乘以 1 - velocityDecay → 位置加上速度,见 simulation.tick 说明)。因此碰撞力的“软”特性最终体现在速度层面:它给节点注入的是逐渐衰减的修正速度,而不是瞬移,这既保证了与其他力(电荷、定位、链接)的自然协同,也是低摩擦(低 velocityDecay)配置下需要配合 strength / iterations 共同调参的原因。
参数选择与排查清单
- 想保证布局严格不重叠:提高
iterations(如 4~8),并保持strength接近 1;代价是每 tick 更贵。 - 追求动画柔和、允许轻微间隙重叠:降低
strength,iterations保持 1。 - 半径变化后布局“不生效”:确认已重新调用
collide.radius(...)(或重新初始化力),否则内部仍是旧缓存值。 - 需要离线/静态布局:按 simulation 文档 的建议,
simulation.stop()后用simulation.tick(*iterations*)手动步进若干次直至稳定,而不是依赖内部计时器。 - 需要确定性结果:d3-force 6.0 起内置力(包括碰撞相关的初始化抖动处理)是确定性的,见 CHANGES.md 的 d3-force 小节;未指定位置的新节点按黄金角螺旋(phyllotaxis)初始化,保证跨运行一致。
- 版本前提:本文对应仓库版本 d3 v7.9.0(
"d3-force": "^3.0.0"),D3 7 为纯 ES 模块、要求 Node 12+;上述 API 与默认值均以此版本为准。
更多相关入口:d3-force 模块总览、力模拟 simulation、多体力 many-body、定位力 position,以及 完整 API 索引 中对 d3.forceCollide 的条目(“create a circle collision force”)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00