在 PARTICLE FIELD 04 这个实验场里我把发射器从点源改成喷射粒子数量一路涨到 12,480。一开始帧率稳稳 60等我把涡旋强度拉到 0.78、再拖几下发射器画面就从边缘开始掉帧了——粒子密集的地方卡得最厉害拖动发射器的手势跟着发飘。右下角帧率 60 fps的字样没变但肉眼已经能看出粒子在跳格子。问题出在哪我第一反应是渲染慢。把每个粒子的 Circle 换成纯色矩形、关掉描边帧率没回来。直到我在 step 函数里埋了个计数器才发现每帧要跑上亿次距离判断。这个数怎么来的12,480 个粒子两两配对就是 12,480 × 12,480 ÷ 2约 7,790 万对再乘上取平方根和乘加单帧轻松破亿。渲染反而没占多少是邻域检索把 CPU 吃满了。12,480 个粒子第一版是全量两两配对第一版的 step 长得非常直白step(dt:number):void{for(leti0;ithis.particles.length;i){constathis.particles[i]for(letji1;jthis.particles.length;j){constbthis.particles[j]constdxb.x-a.xconstdyb.y-a.yconstd2dx*dxdy*dyif(d20d2this.h2){// h2 32² 1024constdMath.sqrt(d2)constfthis.pressureForce(d)// 按距离算排斥a.vxf*(dx/d)a.vyf*(dy/d)b.vx-f*(dx/d)b.vy-f*(dy/d)}}}}这段逻辑没问题两个粒子距离小于 32px 就产生相互作用。坑在复杂度上——它是 O(n²)n 从几百涨到一万二以后每帧的运算量直接爆掉。我一开始还不信在 for 循环外面包了个计时12,480 个粒子跑一次 step 要 46ms。60fps 的帧预算是 16.7ms一个 step 吃掉将近三帧画面不卡才怪。渲染层背不了这个锅。Circle 在 ArkUI 里是原生组件一万多个粒子只要不重排布局GPU 画得动。慢的是这段 O(n²) 的 CPU 计算。46ms 花在哪先计时再动手46ms 不能靠感觉。step 外面包了计时连跑 100 帧取中位数避开首帧干扰。顺手挂了个常驻帧统计interfaceFrameStat{stepMs:number// 邻域检索与受力rebuildMs:number// 网格重建pairs:number// 实际距离判断次数fps:number}classFrameProfiler{privatestat:FrameStat{stepMs:0,rebuildMs:0,pairs:0,fps:60}privatelastLogAt:number0report(now:number):void{if(now-this.lastLogAt1000)return// 每秒最多一行this.lastLogAtnowconsole.info([Fluid] step${this.stat.stepMs.toFixed(1)}ms rebuild${this.stat.rebuildMs.toFixed(1)}ms pairs${this.stat.pairs}fps${this.stat.fps})}}第一版的日志长这样step46.2ms rebuild0.0ms pairs77880000 fps15。pairs 就是两两配对总数12,480 选 2 约 7,790 万。换成网格后同一行变成step2.1ms rebuild0.4ms pairs4700000 fps60——热点在哪一行不用猜。这行日志还揪出一个没想到的地方rebuild 第一版永远是 0因为压根没有网格加分帧重建后涨到 6~8ms才逼出后面 taskpool 那一串折腾。换掉两两配对让粒子只问附近的邻居想清楚以后方向就变了不抠循环细节直接换检索结构。粒子之间的相互作用有个物理前提——距离超过 32pxh就不算数。这意味着我根本不用问所有粒子两两之间什么关系只要问我附近 32px 以内有谁。空间哈希网格就是干这个的。把画布按 32px 切成格子每个粒子记到它所在的格子里。查询时只看自己的格子加上周围 8 个其余格子里的粒子距离必然超过 h直接跳过。先给数据建模。粒子和场分开粒子管自己的状态场管集合和检索exportinterfaceParticle{id:numberx:numbery:numbervx:numbervy:numberkind:number// 0 青 #70F4CD / 1 蓝 #7A83FF / 2 粉 #FF83C5phase:number// 涡旋相位让同一圈粒子错开}exportclassFluidField{privateparticles:Particle[][]privategrid:Mapnumber,number[]newMap()privatereadonlycellSize:number32privatereadonlyh2:number1024// 32²相互作用半径平方privategeneration:number0// 每次清空/重建自增作废旧任务constructor(){for(leti0;i12480;i){this.particles.push({id:i,x:90(i*73)%540,y:50(i*97)%360,vx:0,vy:0,kind:i%3,phase:(i*0.618)%1})}}}粒子颜色沿用界面里的三个色板值 #70F4CD、#7A83FF、#FF83C5按i % 3轮着分。phase存黄金分割小数旋涡模式下用来让同一圈粒子错开相位不至于挤成一条线。格子的键值用整数把二维坐标压成一维。场景 640×480格子 32px横向最多 20 个格子key 编码成cx * 4096 cyprivatekeyOf(cx:number,cy:number):number{returncx*4096cy}privaterebuildGrid():void{this.grid.clear()for(leti0;ithis.particles.length;i){constpthis.particles[i]constcxMath.floor(p.x/this.cellSize)constcyMath.floor(p.y/this.cellSize)constkeythis.keyOf(cx,cy)constbucketthis.grid.get(key)if(bucketundefined){this.grid.set(key,[i])}else{bucket.push(i)}}}有个细节容易被忽略格子 32px相互作用半径也是 32px一个粒子要看的邻居不光是自己的格子还有周边 8 个。因为粒子可能落在格子边缘离它 30px 远的粒子已经跨到隔壁格子去了。邻域查询要把 3×3 的格子都扫一遍再用距离二次校验。格子和半径对齐以后查邻居就是顺着同一套坐标走一遍。privateneighborsOf(p:Particle):number[]{constout:number[][]constcxMath.floor(p.x/this.cellSize)constcyMath.floor(p.y/this.cellSize)for(letdx-1;dx1;dx){for(letdy-1;dy1;dy){constbucketthis.grid.get(this.keyOf(cxdx,cydy))if(bucketundefined)continuefor(constjofbucket){constqthis.particles[j]if(q.idp.id)continueconstddxq.x-p.xconstddyq.y-p.yif(ddx*ddxddy*ddythis.h2){out.push(j)}}}}returnout}continue跳过空桶是必要的——3×3 九个格子里靠近边界或粒子稀的地方一大半是空的。这个分支是性能的关键真正做了距离判断的只有自己格子周围的几十个粒子全场 12,480 个根本轮不到逐个问。改完我重新计时一次 step 从 46ms 掉到 2.1ms稳稳进了 16.7ms 的帧预算。场里的粒子数、涡旋强度这些面板值不用动画面回来了。数值上也说得通1.5 亿次两两距离判断换成网格以后每帧只剩 470 万次左右砍掉 30 倍。界面这一侧的截图就是当时的状态右侧面板的粒子数量 12,480涡旋强度 0.78粘度 0.24还是那组数据把重建拆进每一帧别再让主线程卡住网格索引本身也有成本。12,480 个粒子每帧都要重新入桶rebuildGrid一次要跑 1.2 万次Math.floor加 Map 插入低端机上就是 6~8ms。它和 step 一样跑在主线程两者叠加又会顶到帧预算。我第一版想把它丢进 taskpool 后台线程。跑起来才发现kit.ArkTS的 taskpool 任务返回值只认可序列化的类型Mapnumber, number[]不能直接传回来要么序列化成数组反而更慢要么把网格常驻在 worker 里维护同步又成了新问题。折腾半天干脆没用多线程改成主线程分帧重建——把 12,480 个粒子的入桶拆成每帧 2048 个中间让出事件循环asyncrebuildGridChunked(gen:number):Promiseboolean{constCHUNK2048this.grid.clear()for(letstart0;startthis.particles.length;startCHUNK){if(this.generation!gen){returnfalse// 用户中途清空本次重建作废}constendMath.min(startCHUNK,this.particles.length)for(letistart;iend;i){constpthis.particles[i]constcxMath.floor(p.x/this.cellSize)constcyMath.floor(p.y/this.cellSize)constkeythis.keyOf(cx,cy)constbucketthis.grid.get(key)if(bucketundefined){this.grid.set(key,[i])}else{bucket.push(i)}}awaitnewPromisevoid((resolve)setTimeout(resolve,0))}returntrue}那个generation检查就是失败分支。为什么需要它用户点一下清除按钮this.particles瞬间清空但上一轮rebuildGridChunked可能才跑到第 6000 个粒子await让出以后它还会继续往下跑把已经删掉的粒子重新塞回网格。所以清空时自增generation旧任务每批开头对一下发现编号变了就return false不再写任何东西。setTimeout(resolve, 0)的代价是每批多等一个宏任务2048 个一批12,480 个要拆 7 批多出来的延迟在 1ms 以内肉眼无感但主线程的输入事件能插进来了拖动发射器不再发飘。工程结构不复杂逻辑集中在特征层页面只负责摆组件FluidParticles/ ├─ entry/src/main/ets/ │ ├─ pages/Index.ets # 侧栏、画布与场参数面板 │ ├─ feature/FluidField.ets # 粒子集合、网格索引与邻域查询 │ └─ entryability/EntryAbility.ets # 应用入口 ├─ entry/src/main/resources/ # 颜色与文字资源 └─ build-profile.json5h 换成 48网格要不要重切网格能跑快全靠 cellSize 等于相互作用半径 h。现在都是 32查 3×3 就够h 改成 48 而格子还是 32隔壁格子的粒子只差 16px 就能撞上3×3 扫不到力就漏了。h 要变网格必须跟着重切exportfunctioncheckGridConfig(cellSize:number,h:number):boolean{// cellSize 小于 h 时3×3 邻域覆盖不全会漏检跨格邻居returncellSizeh}另一个边界是画布本身。粒子被涡旋甩出 640×480 后格子坐标算出负值keyOf没做偏移就会和别的格子撞键。入桶之前先把位置夹回画布越界的按反弹处理速度乘 0.5 折回来。钳制放在 step 之后、rebuild 之前顺序不能反先钳后重建网格里存的全是合法坐标反过来的话负坐标已经进了 Map下一帧的邻域查询全带着脏键。边界说明这套空间网格只保证画布内的邻域检索正确粒子被推出 640×480 的边界后格子坐标会算出负值keyOf里没做偏移就会撞键。实际应用里我加了边界钳制粒子位置先夹回 [0, 640] × [0, 480] 再入桶越界粒子按反弹处理不进检索。另外网格的 cellSize 必须等于相互作用半径 h如果以后改了粘度让 h 变成 48网格还得跟着重切否则边界格子里的邻居会漏检。这段代码纯粹是本地流体实验的数据结构思路不涉及真实流体力学求解器别拿它去做工程仿真。