
我把 5 个 Blender 模型变成了 12,000 颗粒
事情是这样的。
前几天我在网上看到一个 WebGL 网站,黑色背景里漂着一大片蓝色、橙色和暖白色粒子。鼠标经过以后,粒子会像液体一样被推开。继续滚动,画面里的形态、位置和标题又一起变了。
我盯着它看了挺久。
不是因为我没见过粒子效果。正好相反,我以前在三个项目里都做过类似的东西。有 SVG 图标之间的点阵变形,也有球体、云和服务器之间的三维切换,还有一个 3 万多颗粒的交互球。
但这次我突然发现,自己以前做的更像一个效果。
那个网站做的是一个场景。
区别很小,也很大。
效果只要会动就行。场景还要考虑模型是什么、文字在说什么、用户怎么操作、滚动以后为什么要进入下一屏。
所以我把以前零散的方案重新拆了一遍,用 Codex 配合 Three.js,从头做了一套新的粒子变形 Demo。
最后的结果是,5 个 Blender 模型,共用 12,000 颗粒。可以自动变形,可以用滚轮切换章节,可以按住鼠标旋转模型,鼠标靠近时还会推开周围的点。

5 个 Blender 模型在同一组粒子中连续变形
这是我最后保留的蓝、橙和暖白版本。它不是把几个模型套上同一个滤镜,而是让同一组粒子在五套表面坐标之间重新排列。

最终蓝、橙与暖白三色粒子效果
这篇不只展示成品。
我把最关键的实现思路和踩过的坑都拆开讲一下。你不需要先学会写复杂 Shader,但最好知道一点 JavaScript 和 Three.js。代码我会尽量只留下真正决定效果的部分。
先别急着写代码,模型选错了后面全白干
我最开始想用一整个数据中心场景。
楼、道路、机柜、灯带都有,看着很丰富。可一旦只留下粒子,问题马上就出来了。大量小结构叠在一起,轮廓会变成一块密密麻麻的噪点。你根本看不出它是园区,还是一团被打散的海绵。
后来我翻出以前买过的一套 Blender 图标,里面已经导出了 GLB 文件。
这次使用的 Blender 图标
我从里面挑了 AI 核心、芯片、上传云、数据容器和算力节点。
选它们不是因为名字听着高级,而是因为剪影差异足够大。
圆形变方形,方形变成软轮廓,再从云变成圆柱和层叠方盒。即使去掉材质和灯光,只看黑色剪影,你也能认出来它们不是同一个东西。
这是第一条可以直接拿走的经验。
做粒子变形,优先选轮廓清楚、内部镂空适中、形态差异大的模型。面数高不等于效果好,细节太多反而更容易糊。

AI 核心进入粒子场后的轮廓
不要直接使用模型顶点,先把表面重新采样
模型准备好以后,我撞上的第一个硬问题,是它们的顶点完全对不上。
芯片边缘可能用了大量顶点,云的曲面又是另一套密度,数据容器还有很多藏在内部的小结构。它们不但顶点数量不同,排列顺序也没有任何关系。
如果直接让模型 A 的第 1 个顶点飞向模型 B 的第 1 个顶点,确实会动。
但不好看。
有些地方密得发白,有些地方像破了洞,还有一批粒子会突然穿过整个画面。
我最后没有继续纠结原始顶点,而是把每个模型都看成一张由三角面拼成的表面。
程序先遍历模型里的 Mesh,计算每个三角面的面积。面积越大的面,分到的粒子越多。然后在选中的三角面里,用重心坐标随机取点。
核心逻辑可以缩成下面这样。
const area = ab.subVectors(b, a)
.cross(ac.subVectors(c, a))
.length() * 0.5
const r1 = Math.sqrt(seed(i + 91))
const r2 = seed(i + 173)
const u = 1 - r1
const v = r1 * (1 - r2)
const w = r1 * r2
point.x = a.x * u + b.x * v + c.x * w
point.y = a.y * u + b.y * v + c.y * w
point.z = a.z * u + b.z * v + c.z * w这里的重点不是背公式。
重点是,不管原模型有 3000 个顶点还是 3 万个顶点,经过这一步以后,每个形态都会重新得到长度完全一样的 12,000 个坐标。
从这一刻开始,粒子不再关心模型原来是怎么建的。
它只关心下一次应该去哪里。

同一组粒子重新组成 AI 芯片
普通插值能用,但会像橡皮泥融化
坐标数量统一以后,最简单的变形就是线性插值。
position.x = lerp(from.x, to.x, progress)
position.y = lerp(from.y, to.y, progress)
position.z = lerp(from.z, to.z, progress)技术上没有任何问题。
视觉上差一点意思。
模型会从旧形态直接挤向新形态,像一块正在融化的橡皮泥。你能看懂变化,但不会觉得它真的散开又重新长了回来。

普通线性变化与散开重组的对比
我给运动中间加了一次散射。
粒子先离开原来的表面,变化到中点时散射达到最大,然后再被新的轮廓吸回去。每颗粒子根据自己的随机数获得不同方向,路径不会整齐得像军训方阵。
const scatter = Math.sin(progress * Math.PI)
position.x = lerp(fromX, toX, ease)
+ Math.cos(angle) * scatter * random
position.y = lerp(fromY, toY, ease)
+ Math.sin(angle) * scatter * random这里有个很容易忽略的细节。
散射不是越大越好。
太小,还是融化。太大,模型会彻底消失很久,用户只看到满屏噪点。我最后保留了旧轮廓逐渐失去控制,再让新轮廓慢慢被看见的节奏。
真正让粒子效果显得贵的,往往不是粒子数量。
是时间。
什么时候散,散多远,什么时候回来。
颜色不要随机撒,要用三维噪声形成色团
这个地方我也走了弯路。
最初我给每颗粒子随机分配荧光绿、蓝、红和白。颜色确实多了,可模型也被打碎了。每个点都在抢注意力,看起来像一把彩色砂糖撒在黑纸上。
后来我又做了一版完整彩虹渐变。
还是不对。
真正好看的多色粒子不是平均,也不是规整的彩虹带。颜色应该像光落在不同空间里,有连续的区域,也有被另一种颜色侵入的小块。
所以最终版本只保留蓝色、橙金和暖白三组颜色,再根据粒子的三维位置计算两层噪声,把颜色混成不规则色团。
float n1 = .5
+ .25 * sin(dot(p, vec3(1.7, 2.3, 1.2)))
+ .25 * sin(dot(p, vec3(-2.8, 1.1, 2.1)) + 1.7);
vec3 blue = vec3(.18, .56, .94);
vec3 orange = vec3(.96, .53, .10);
vec3 cream = vec3(.90, .87, .79);
vec3 color = mix(blue, orange, smoothstep(.45, .64, n1));
color = mix(color, cream, smoothstep(.57, .78, n2) * .78);我还把材质从过强的 Additive Blending 改成了 Normal Blending。
Additive 很容易制造第一眼的炫,但粒子一密就会叠成白块。Normal 没那么炸,模型体积和颜色层次反而更清楚。
如果你的粒子效果总有一种廉价屏保感,可以先检查两件事。
颜色是不是每颗随机,发光是不是开得太满。
鼠标不是装饰,它应该真的改变粒子
一开始鼠标移动只会让整个模型轻轻偏转。
这个效果太弱了。用户知道画面跟自己有关,却感觉不到自己真的碰到了它。
我后来从摄像机发出一条射线,让它和模型所在的平面相交,再把交点转换到粒子模型自己的局部坐标。
每个粒子在 Vertex Shader 里计算自己与鼠标交点的距离。靠得越近,排斥力越强,同时加一点旋转和 Z 轴抬升。
vec3 p = position;
vec2 delta = p.xy - uPointer.xy;
float distance = max(length(delta), .001);
float influence = smoothstep(1.35, .06, distance);
vec2 direction = delta / distance;
p.xy += direction * influence * .34;
p.z += influence * .3;最后的手感不是鼠标下面突然挖出一个死洞,而是周围的粒子先躲开,再带着一点旋流从边缘滑过去。
为了让用户知道这里能操作,我又加了一个跟随指针的细线圆环。进入粒子区域时出现,拖拽时进入另一种状态。
这类交互提示很小,但没有它,很多人根本不会碰你的 Demo。
拖拽旋转和滚轮切换,不能抢同一个操作
做到这里以后,我又补了三维旋转。
最直接的方式是接入 OrbitControls。可它默认会占用滚轮缩放,而我的滚轮已经用来切换模型和章节了。
两个交互抢同一个输入,体验马上乱掉。
所以我没有套完整的轨道控制器,而是只监听 Pointer Events。按住鼠标以后记录移动距离,把横向位移变成 Y 轴旋转,把纵向位移变成 X 轴旋转。松手以后保留一点速度,再逐帧衰减。
rotationTargetY += deltaX * 0.0065
rotationTargetX = clamp(
rotationTargetX + deltaY * 0.0055,
-1.15,
1.15
)
rotationVelocityY *= 0.94
rotationVelocityX *= 0.94上下方向一定要限位。
不然用户拖两下,模型就会翻到一个完全认不出来的角度。自由不等于失控。
手机端也不需要再写一套 Touch 逻辑。Pointer Events 本身就能覆盖鼠标、触控笔和单指触摸,只需要给 Canvas 设置 `touch-action: none`。
滚轮切换的不只是模型,而是一整个章节
这是我最后补上、也是最接近完整网站的一步。
之前滚一下,只是右侧模型从 AI 核心变成芯片。左侧标题不动,模型位置也不动。它更像一个 3D 轮播组件。
我后来给每种形态都配了一组场景信息。
{
label: 'AI CHIP',
title: ['把一枚芯片拆成', '12,000 个光点。'],
intro: '同一组粒子离开上一种结构,再寻找新的位置。',
x: 1.72,
y: 0.28,
scale: 0.94
}滚轮达到阈值以后,程序同时做四件事。
粒子开始变形,模型移动到新的构图位置,标题和介绍淡出后更新,右上角编号进入下一章。
模型的位置和缩放不是直接跳过去,而是在动画循环里逐帧接近目标值。
cloud.position.x += (targetX - cloud.position.x) * .045
cloud.position.y += (targetY - cloud.position.y) * .045
const nextScale = cloud.scale.x
+ (targetScale - cloud.scale.x) * .045
cloud.scale.setScalar(nextScale)滚轮还要做阈值和冷却。
Mac 触控板一次滑动会连续触发很多 Wheel Event。如果每个事件都切一屏,手指轻轻一划,5 个模型直接播完了。。。
我最后累积滚动量,超过阈值才切换,并增加 620ms 冷却。变形过程中再次滚动时,新操作可以接管当前动画,不需要傻等上一段播完。
这一步完成以后,Demo 才从一个会动的模型,变成了一个可以往下讲故事的网页。
桌面 12,000 颗,手机别硬撑
桌面版最终保留 12,000 颗粒,设备像素比最多取 1.75。
移动端我直接降到 6000 颗,像素比上限 1.25。
少一半会不会立刻变糙?
实际看下来不会。手机屏幕更小,观看距离也更远,6000 颗已经能把主要轮廓交代清楚。继续堆点数,增加的是 GPU 填充压力、发热和耗电,肉眼得到的提升很有限。
同一台 Apple Silicon Mac、同一个自动化 Chrome 环境里,旧版桌面 300 帧测试平均帧间隔为 11.72ms,P95 为 25.4ms。移动尺寸模拟平均为 12.93ms,P95 为 19.9ms。
这里我不把它写成真机 60 帧。
浏览器自动化只能说明同一环境下两个质量档位都能持续运行,不能代替手机的温度、功耗和长时间稳定性测试。
如果你准备把这套效果放进正式官网,还应该继续做模型延迟加载、Three.js 代码拆包、页面切后台暂停渲染、WebGL 丢失后的静态回退,以及 `prefers-reduced-motion` 支持。
这些东西不会让录屏更炸。
但它们决定这个效果能不能真的上线。

移动端降低粒子数量后的实际画面
最后,把这套方法缩成一句话
回头看,这个实验真正困难的地方并不是写出一个粒子 Shader。
Codex 可以帮我搭工程、解析 GLB、写采样函数、改交互,也能自动打开浏览器录屏和做构建检查。它把反馈速度拉得非常快,这一点确实很爽。
可模型选哪一个,5 种形态按什么顺序出现,粒子应该散多远,颜色为什么不能随机,滚动以后标题要说什么,这些决定仍然要有人来做。
代码解决的是它能不能运行。
设计解决的是用户能不能看懂,以及他愿不愿意继续看下去。
如果你也想复现,别一上来追求 10 万颗粒,也别先抄一个复杂的 GPGPU 工程。
找两个轮廓差异足够大的 GLB 模型,把表面采样、坐标统一和一次顺滑变形先跑通。然后再一点点加颜色、鼠标、旋转和章节。
一开始做出来可能真的很像屏保。
没关系,我前几版也一样。
粒子并不知道自己最后要变成芯片、云还是数据核心。
它们只知道下一次应该去哪里。
而我们要做的,就是把那个方向选清楚。
如果你想看下一篇,我可以继续把这套 Demo 里的完整工程结构、录屏方式和移动端适配单独拆出来。
