把虹膜画在 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——方向性的两遍模糊。

它对”梦幻”确实有效,但代价是一个额外的渲染目标加两遍全屏采样。而在本站里,发光感主要来自三个更便宜的东西:

  1. 加法混合 + 程序化的圆形点精灵(片元里 smoothstep(0.5, 0.04, d));
  2. 环形颜色梯度:核心暖白、中层金、外缘紫;
  3. canvas 背后一层 CSS 径向渐变光晕。

三者叠加已经足够,且不占显存。

显存预算

档位粒子数DPR 上限说明
high42,0002桌面、显存充足
mid20,0001.5移动端 / 中等设备
low9,0001.5弱设备或 deviceMemory 较小

几何只有一份 Float32Array(位置 + 三个标量属性),没有位置纹理,也没有 ping-pong 渲染目标。这是刻意选择的:单一全屏粒子系统不需要 GPGPU,省下的是复杂度而不是性能。