Loading
0303 / 09屏幕上,粒子生命是一团彩色光点。
引擎内部,它是一系列承诺。
无论显示器刷新率是多少,相同的模拟时间都应该产生相同的物理结果。相同的种子与矩阵应该重现相同的开场。靠近右边缘的粒子,应该能正确地与左边缘附近的粒子相互作用。两千个粒子不应该在每一步都要求四百万次昂贵的距离检查。后台标签页重新出现时,也不应该试图一次补算所有错过的时间。
这些承诺本身不会制造涌现。它们制造的是一个稳定舞台,让涌现结果能够具有意义。
否则,一个漂亮图案可能只是帧率缺陷、随机数事故、边界伪影,或者特定机器上的计时差异。引擎的工作,就是排除这些替代解释。
应用先把浏览器帧间隔限制在 100 ms,再由粒子系统最多保留四个 1/60 秒物理步。丢弃的时间不会进入模拟时钟。
浏览器动画帧驱动一条 SimulationLoop。每次 tick 中,循环完成四项彼此分离的任务:
真实经过时间
↓
固定物理步骤 → 实时分析 → 渲染当前状态 → 发布界面指标ParticleSystem 拥有位置、速度、类型、力矩阵与物理步骤。Analysis 读取状态并计算团簇、速度和稳定性。渲染器只接收粒子缓冲区的一小段视图。React 拥有控件和表现层,却不会伸进内部的力循环。
这些边界让物理脱离 React 接受测试,也让渲染失败而不抹掉世界。
这种分离很实用。同一套物理可以在测试与 Discover 搜索中无画布、无 React 地运行。渲染器可以从 WebGPU 切换到 WebGL2,而轨迹不变。界面通过一个很小的公开 API 暂停或加载预设,而不是直接修改粒子数组。
架构规则: 模拟状态只有一个所有者。其他部分要么观察它,要么请求它执行明确操作。
浏览器不承诺每秒六十帧。60 Hz 显示器、144 Hz 显示器、繁忙的 CPU、调整窗口大小与后台标签页都会提供不同的帧间隔。
如果引擎把每一帧的真实时长直接放进力方程,不同机器就不会运行同一项实验。长帧会进行更大的积分跳跃,短帧会更温和,甚至定性图案都可能分叉。
粒子生命会累积真实时间,然后用固定步骤消费它:
h = 1 / 60 秒在每秒显示 120 帧时,一些渲染帧不会执行物理步骤,只绘制当前状态。遇到较慢的一帧,累加器可能在渲染前运行多个固定步骤。方程看到的 h 始终相同。
速度控制遵守同一契约。2× 不会把 h 加倍,而是向累加器提供两倍模拟时间。因此,2× 下的六十帧与 1× 下的一百二十帧执行相同的物理步骤。测试证明两条轨迹逐个浮点值完全一致。
这里有一个刻意设置的上限:每次 tick 最多前进四个固定步骤。如果标签页沉睡十秒,引擎会丢弃多余时间,而不是进入试图追赶的“死亡螺旋”。负载过高时,模拟会优雅地变慢;时间显示也只统计真正执行的步骤。它报告的是模拟事实,而不是墙上时钟的猜测。
每次重置都会散布粒子位置并分配类型。如果使用环境中的 Math.random(),共享矩阵只能描述规则,却无法描述规则开始运行的世界。
引擎使用确定性的 Mulberry32 生成器。一个 32 位种子会产生相同的数字序列,而重置方法会按固定顺序消费它们:
粒子 0:x、y、type
粒子 1:x、y、type
粒子 2:x、y、type
...调用顺序本身就是数据格式的一部分。改变它会在种子不变的情况下,悄悄破坏所有现有共享开场。
确定性把多项产品功能变成同一个工程能力:
当前分享契约精确重现的是开场,而不是任意后续时刻的位置与速度。链接表达“开始同一项实验”,而不是“传送到我刚才看到的那一帧”。
硬边界会增加一条与交互矩阵无关的规则。粒子可能在墙边堆积、反弹或形成虚假的边缘密度,让靠近边界的结构难以解释。
粒子生命使用环形世界:从右侧离开会从左侧进入,从底部离开会从顶部进入。从拓扑上看,矩形像一个圆环表面,尽管它仍被平铺在屏幕上。
世界首尾相连。物理引擎使用最小镜像距离,所以位于相对边缘的粒子仍可能互为近邻。
仅仅环绕位置还不够。力的距离也必须选择更短的环绕路线。对于水平距离 dx,引擎使用最小镜像约定:
let dx = neighborX - particleX;
if (dx > width / 2) dx -= width;
else if (dx < -width / 2) dx += width;垂直方向同样处理。位于 x = 2 与 x = width - 3 的两个粒子,跨越接缝只相距五像素,而不是几乎整个画布宽度。
这样,局部物理在世界每个位置都保持一致。可见矩形仍有接缝,但模拟没有特权中心,也没有真实边缘。
朴素引擎可以让每个粒子检查其他所有粒子。对于 N = 2,000,有向力循环接近:
N × (N - 1) = 2,000 × 1,999 = 每步 3,998,000 次检查每个模拟秒执行六十步,而其中绝大多数检查都被浪费了,因为超过 R = 100 px 后力为零。
空间哈希把环形世界划分为均匀网格。每个单元至少与作用半径一样宽。粒子按单元插入扁平链表。寻找可能的邻居时,一个粒子只扫描自身单元与周围八个单元,然后再执行精确半径判断。
对于 2,000 个粒子,全粒子对每一步接近四百万次有向检查。
每个网格单元至少与作用半径一样宽。因此,当前单元及其八个邻居包含所有可能发生相互作用的候选粒子。
典型工作量从“与整个世界比较”变成“与局部人口比较”。在预期分布下,网格重建与查询随粒子数量表现得接近线性。但这不是普遍复杂度保证:如果所有粒子都挤进一个单元,候选集合仍可能退化为二次规模。
实现细节只有在出错时才会显现:
ceil 的旧网格曾在接缝处制造过窄单元,让真实邻居落到 3×3 搜索之外;每一种行为都有回归测试。没有正确性测试的性能优化,只会让模拟更快、却更不可信。
最直观的表示方式是粒子对象数组。最终系统使用的却是数组结构:
x = Float32Array
y = Float32Array
vx = Float32Array
vy = Float32Array
type = Uint8Array力循环读取连续数值存储,不需要创建临时对象。渲染器可以把相同的位置与类型数据直接送入 GPU 缓冲区。数组只在明确的重置或生成操作中增长,不会在每帧热路径中增长。
空间哈希采用同样原则:单元头与 next 指针都是 Int32Array。重建过程会清空并复用已有存储;邻居结果写入可复用的临时缓冲区。轨迹历史和连接线数据也会复用固定缓冲区。
这里的“零分配”指稳定运行的热路径,而不是整个应用。用户操作可以分配内存。目标是避免垃圾回收变成影响帧节奏的一股隐形力量。
力修改速度后,每个粒子都遵循相同顺序:限制速度、应用摩擦、用固定时间步积分位置、把新位置环绕回世界。
改变顺序就会改变模型。先摩擦再限速,在剧烈矩阵下会产生不同结果。在模拟中,算术“重构”常常只是披着代码风格外衣的物理变更。
因此测试覆盖的是不变量,而不是具体代码行:±0.9 的剧烈矩阵下速度仍然有限且为正常数值;不规则帧间隔产生相同轨迹;调整尺寸后每个位置都回到新世界范围内。
通过站点导航离开时,循环必须取消动画帧、断开尺寸观察器,并销毁 GPU 或 WebGL 资源。如果渲染器在卸载后才完成初始化,它会在完成时立刻被销毁。
GPU 设备丢失还有一个特殊约束:已经配置为 WebGPU 的画布不能直接变成 WebGL2 画布。恢复过程会挂载一个全新画布,并把现有 SimulationLoop 重新绑定过去。粒子、速度、经过时间与矩阵都保留下来,只有渲染器改变。
正因为这条边界,渲染失败不会抹掉实验。引擎拥有世界,画布只是世界的一种视图。
打开模拟器,点击分享,再在第二个标签页打开复制的链接。两个开场应该一致:相同规则、相同数量、相同种子。之后它们的帧调度可能分叉,但它们从同一项实验开始。
下一篇将沿着另一个方向追踪状态:从类型化数组进入 WebGPU 缓冲区、泛光纹理、轨迹、连接线,以及在首选渲染器不可用时保持世界可见的 WebGL2 后备方案。