为什么不用 CSS 渐变
首屏背景最初的方案很简单:几个 radial-gradient 叠在一起,颜色参考调色板。
静态看效果不错,但死板——不随鼠标动,不随时间变化,和其他设计感强的网站相比少了那层"呼吸感"。
CSS 动画能让渐变动起来,但有两个硬限制:不能响应鼠标位置,
以及多层 radial-gradient 加上 animation 之后,
浏览器的重绘成本急剧上升。大面积渐变每帧重绘,在移动端很快掉帧。
WebGL shader 的思路完全不同:CPU 只负责把"当前时间"和"鼠标位置"两个数字传给 GPU, GPU 对每个像素并行执行着色器函数,每帧产出一张图。像素计算量再大,也是 GPU 的事, 主线程不参与,天生 60fps。
GradientBlinds 的视觉逻辑
把这个效果拆开来看,它由三层叠加组成:
三层通过 col = spotlight + gradient - stripe 混合。
条纹值(0–1)从渐变颜色中"减去",制造出明暗交替的百叶窗;
聚光灯在鼠标附近叠加正值,让那一片更亮。
最后画布以 mix-blend-mode: lighten 叠在黑色背景上——
着色器输出的暗区(负值被截断为 0)对应纯黑,不遮挡下方内容,只有发光部分穿透出来。
screen 会让两层颜色双向提亮,稍微过曝;
lighten 逐通道取较大值,只保留更亮的那一层——配合纯黑背景,效果完全可控,不会让文字区域的对比度受影响。
颜色带本身还加了 镜像折叠(mirrorGradient: true):
渐变从左到右跑一遍后翻折再跑一遍,让颜色平滑衔接,不出现硬边。
每条"叶片"内部也有轻微正弦波扭曲(distortAmount),让边缘不是直线,
带一点有机感。
GLSL Shader 架构拆解
着色器的 mainImage 函数按顺序做五件事:
// 1. 归一化坐标,修正宽高比 vec2 uv0 = fragCoord.xy / iResolution.xy; // [0,1] × [0,1] float aspect = iResolution.x / iResolution.y; vec2 p = uv0 * 2.0 - 1.0; p.x *= aspect; // 拉伸为正方形空间 // 2. 旋转 + 归一化回 [0,1] vec2 pr = rotate2D(p, uAngle); pr.x /= aspect; vec2 uv = pr * 0.5 + 0.5; // 3. 渐变采样(含镜像折叠) float t = uv.x; if (uMirror > 0.5) t = 1.0 - abs(1.0 - 2.0 * fract(t)); vec3 base = getGradientColor(t); // 8 色分段线性插值 // 4. 聚光灯(以鼠标为圆心) float d = length(uv0 - iMouse / iResolution.xy); float spot = (1.0 - 2.0 * pow(d / uSpotlightRadius, uSoftness)) * uOpacity; // 5. 百叶窗条纹 + 合并 float stripe = fract(uv.x * uBlindCount); vec3 col = vec3(spot) + base - vec3(stripe); col += (rand(gl_FragCoord.xy + iTime) - 0.5) * uNoise; // 抖动降噪 fragColor = vec4(col, 1.0);
几个值得注意的细节:rotate2D 在宽高比修正后旋转,旋转后再还原,
这样无论窗口比例如何,百叶窗角度始终视觉一致;
fract(uv.x * N) 生成 N 条等宽条纹,配合 mirrorGradient,
条纹两端颜色自动衔接;
最后那行 rand() 是一个基于正弦函数的伪随机数,
对 col 叠加 ±0.1 的随机抖动,消除渐变的马赫带(色阶感),
让过渡更顺滑。
让我调试一小时的 Bug:
预处理器指令必须独占一行
Shader 写完,OGL 接好,打开浏览器——黑屏。Canvas 节点在 DOM 里,
is-ready 类也加上了,但什么都没有渲染出来。
没有报错,没有警告,console.warn 也没触发,
就是一片黑。
怀疑过 mix-blend-mode 叠错层,怀疑过 isolation: isolate 把 canvas 裁掉,
甚至怀疑是 OGL 版本问题。最后把 shader 字符串单独抽出来用 gl.getShaderInfoLog() 检查,
才看到这一行编译报错:
// ❌ 错误写法:预处理器指令和代码混在同一行 #ifdef GL_ES precision mediump float; #endif // ✅ 正确写法:每条预处理器指令独占一行 #ifdef GL_ES precision mediump float; #endif
#ifdef 视为行指令——
它只处理到当前行末尾。#ifdef GL_ES 后面的 precision mediump float;
被当作条件块之外的代码,编译器在 #ifdef 尚未闭合时就遇到了
#endif,产生语法错误,整个 shader 编译失败。
OGL 对编译失败的 shader 不会抛出 JS 异常,只是静默返回一个无效的 program。
渲染调用照常执行,但什么都画不出来。try-catch 也捕获不到,
因为从 JS 的视角看,一切正常。
gl.getProgramInfoLog(program) 或
gl.getShaderInfoLog(shader) 拿到 GLSL 编译器的错误信息。
这两个 API 是排查 WebGL 黑屏问题的第一步。
一行换行,一小时调试。GLSL 编译器不宽容,也不多说话。
OGL:最小化 WebGL 封装的接入方式
原生 WebGL API 能用,但样板代码多。每次渲染都要手动管理 buffer、VAO、uniform location—— 代码量大,出错率高。Three.js 另一个极端,功能全但包体 600KB+, 为了一个 fullscreen shader 引入整个场景图系统,不值当。
OGL 定位在两者之间:只封装最低层的 WebGL 对象(Renderer、Program、
Mesh、Triangle),不带场景管理,不带物理,包体 ~30KB。
刚好够用。
接入方式用 ES module via esm.sh,不需要 npm install,直接在 HTML 里 import:
import { Renderer, Program, Mesh, Triangle } from 'https://esm.sh/ogl@1.0.11';
Triangle 是 OGL 内置的全屏三角形几何体——
用三个顶点覆盖整个 clip space(坐标范围 [-1, 1]),
比 quad(四边形)少一个顶点、少一次 draw call,
是 fullscreen shader 的标准做法。
手动 createBuffer、bindBuffer、vertexAttribPointer、enableVertexAttribArray……每个 uniform 都要 getUniformLocation,代码冗长易错
uniforms 对象直接传入 Program,OGL 自动处理 location 绑定;Renderer 负责 viewport resize;Triangle 是一行的事
鼠标跟踪:从 DOM 坐标到 WebGL 坐标
这个细节比看起来麻烦。pointermove 事件给的是 DOM 坐标系(左上角为原点,Y 轴向下),
但 WebGL fragment shader 里 gl_FragCoord 的原点在左下角,Y 轴向上。
不做转换,鼠标往上移,光斑往下跑。
// pointermove 回调 const onPointerMove = (e) => { const rect = canvas.getBoundingClientRect(); const scale = renderer.dpr || 1; mouseTarget = [ (e.clientX - rect.left) * scale, // Y 轴翻转:DOM 从上往下,WebGL 从下往上 (rect.height - (e.clientY - rect.top)) * scale, ]; };
此外还有两个性能考量:
① rAF 节流。pointermove 在高刷屏上每秒触发 120 次,
但渲染每帧最多消费一次坐标更新。用 requestAnimationFrame 节流,
确保事件处理函数在两帧之间最多执行一次,避免无效计算堆积。
let pmRaf = 0; const onPointerMove = (e) => { if (pmRaf) return; // 本帧已有待处理更新,跳过 pmRaf = requestAnimationFrame(() => { // ... 更新 mouseTarget pmRaf = 0; }); };
② 阻尼平滑。鼠标坐标直接传给 uniform,光斑会跟着鼠标像素级跳动,
视觉上很"生硬"。用指数衰减插值(mouseDampening 参数),
让光斑以当前位置为起点,逐帧趋近目标坐标:
// 每帧动画循环里 const factor = 1 - Math.exp(-dt / mouseDampening); cur[0] += (mouseTarget[0] - cur[0]) * factor; cur[1] += (mouseTarget[1] - cur[1]) * factor;
mouseDampening = 0.04 对应约 40ms 的响应时间常数,
光斑跟手但有一点延迟,像在液体里游——这个数值偏快,刻意的,
不想做成那种"老远后面追"的效果。
三重性能保障
WebGL 背景常驻渲染,稍不注意就成了页面的性能黑洞。三个机制叠起来, 保证它在各种场景下都不拖累帧率:
① IntersectionObserver 离屏暂停
当 Hero 区域滚动出视口,渲染循环继续跑但不做任何 GPU 绘制:
let running = true; new IntersectionObserver(([entry]) => { running = entry.isIntersecting; }, { threshold: 0 }).observe(container); const loop = (t) => { requestAnimationFrame(loop); if (!running) return; // 离屏直接跳过 renderer.render({ scene: mesh }); };
② DPR 上限
Retina 屏 DPR 为 2,意味着渲染像素数量 ×4。shader 的像素计算量也 ×4。 把 DPR 上限钳制在 1.5(移动端 1.0),渲染质量肉眼无差别,GPU 负载大幅下降:
const dprCap = isMobile ? 1 : 1.5; renderer = new Renderer({ dpr: Math.min(window.devicePixelRatio || 1, dprCap), });
③ 低性能设备跳过初始化
navigator.hardwareConcurrency < 4 意味着设备只有双核或单核,
通常是老旧的低端机。对这类设备直接跳过 WebGL 初始化,
CSS fallback 的静态渐变接管,体验降级但不卡顿:
if (reduceMotion || isLowCore) return null;
- ✓ IntersectionObserver 离屏暂停渲染
- ✓ DPR 钳制(桌面 1.5× / 移动 1.0×)
- ✓ 低核心设备 CSS fallback
- ✓
prefers-reduced-motion全部跳过动画 - ✓
pointermoverAF 节流