把虹膜画在 GPU 上
一次渲染循环的拆解:几何怎么排、凝视怎么阻尼、滚动怎么解构,以及为什么最后没有用 bloom。
首页那只眼睛并不是一张贴图,也不是把粒子随机撒在一个圆里。它由四种结构叠出来,全部在顶点着色器里按时间与输入重新计算,CPU 每帧只写几个 uniform。
四种结构
把粒子按角色分开,是为了让”虹膜”可被辨认,而不是一团会动的噪点。
| 结构 | 占比 | 半径 | 作用 |
|---|---|---|---|
| 虹膜条纹 | 70% | 0.30 – 0.96 | 放射状纹理,是识别为”眼睛”的关键 |
| 瞳孔环 | 18% | 0.288 – 0.314 | 一条更亮的细环,视觉焦点 |
| 外圈刻度 | 6.5% | 1.03 – 1.08 | 仪器边框,凝视时保持不动 |
| 空间尘埃 | 5.5% | 1.12 – 1.87 | 提供纵深与视差 |
刻度环之所以不动,是因为”眼睛在仪器里转动”这件事需要参照物。如果整个画面一起移动,观者只会觉得画面晃了一下,而不是”它在看你”。
凝视:阻尼而不是跟随
鼠标位置先被换算成方向向量,再做指数阻尼:
const follow = 1 - Math.exp(-dt * 5.2);
gaze.x += (target.x - gaze.x) * follow;
这里有两个容易写错的地方。
第一,不能用固定系数。gaze += (target - gaze) * 0.05 在高刷新率屏幕上会明显更快,因为每帧都乘了一次。用 1 - exp(-dt * k) 才能让不同帧率的观感一致。
第二,幅度要限制。真实眼球在眼眶里转不了太多,把 target 夹在 再乘 0.088 个世界单位,就得到”它在看你,但没有翻白眼”的幅度。
滚动解构
滚动进度由 ScrollTrigger 提供,只做一件事:把 0–1 的进度写进 uniform。
ScrollTrigger.create({
trigger: hero,
start: 'top top',
end: 'bottom top',
scrub: true,
onUpdate: (self) => {
dissolveTarget = self.progress;
},
});
着色器里把位置向”水平流”插值,并让点尺寸与透明度一起衰减:
vec3 flow = vec3(p.x * 2.35 + (aSeed - 0.5) * 0.85, p.y * 0.20, p.z);
p = mix(p, flow, smoothstep(0.0, 0.85, uProgress));
结果是粒子先被拉长成一条横向的光带,再淡出——而不是”啪”地消失。
为什么最后没有用 bloom
Codrops 的 DreamyParticles 把参数摊在面板上,其中后处理是 threshold 0.058 / strength 1.2 / directionX 1.5 / directionY 1——方向性的两遍模糊。
它对”梦幻”确实有效,但代价是一个额外的渲染目标加两遍全屏采样。而在本站里,发光感主要来自三个更便宜的东西:
- 加法混合 + 程序化的圆形点精灵(片元里
smoothstep(0.5, 0.04, d)); - 环形颜色梯度:核心暖白、中层金、外缘紫;
- canvas 背后一层 CSS 径向渐变光晕。
三者叠加已经足够,且不占显存。
显存预算
| 档位 | 粒子数 | DPR 上限 | 说明 |
|---|---|---|---|
| high | 42,000 | 2 | 桌面、显存充足 |
| mid | 20,000 | 1.5 | 移动端 / 中等设备 |
| low | 9,000 | 1.5 | 弱设备或 deviceMemory 较小 |
几何只有一份 Float32Array(位置 + 三个标量属性),没有位置纹理,也没有 ping-pong 渲染目标。这是刻意选择的:单一全屏粒子系统不需要 GPGPU,省下的是复杂度而不是性能。