← 回首页
Day 2 · WebGL · GLSL

GradientBlinds:
一个 GLSL Shader 的解剖

首页背景那片彩色百叶窗不是 CSS 渐变,是一段跑在 GPU 上的 GLSL 着色器。 记录它的数学逻辑、OGL 的接入方式、鼠标跟踪的坐标转换,以及一个让我盯着黑屏调试一小时的预处理器 Bug。

2026-05-06 WebGL · OGL · GLSL L2 交互档位
目录
  1. 为什么不用 CSS 渐变
  2. GradientBlinds 的视觉逻辑
  3. GLSL Shader 架构拆解
  4. 让我调试一小时的 Bug:预处理器指令必须独占一行
  5. OGL:最小化 WebGL 封装的接入方式
  6. 鼠标跟踪:从 DOM 坐标到 WebGL 坐标
  7. 三重性能保障
背景

为什么不用 CSS 渐变

首屏背景最初的方案很简单:几个 radial-gradient 叠在一起,颜色参考调色板。 静态看效果不错,但死板——不随鼠标动,不随时间变化,和其他设计感强的网站相比少了那层"呼吸感"。

CSS 动画能让渐变动起来,但有两个硬限制:不能响应鼠标位置, 以及多层 radial-gradient 加上 animation 之后, 浏览器的重绘成本急剧上升。大面积渐变每帧重绘,在移动端很快掉帧。

WebGL shader 的思路完全不同:CPU 只负责把"当前时间"和"鼠标位置"两个数字传给 GPU, GPU 对每个像素并行执行着色器函数,每帧产出一张图。像素计算量再大,也是 GPU 的事, 主线程不参与,天生 60fps

GPU 并行渲染
鼠标实时响应
主线程零压力
mix-blend-mode: lighten
视觉逻辑

GradientBlinds 的视觉逻辑

把这个效果拆开来看,它由三层叠加组成:

① 旋转渐变色带 — 把画面旋转一个角度,沿 X 轴展开多色渐变
② 百叶窗条纹 — 在旋转空间里切出周期性明暗条纹
③ 鼠标聚光灯 — 以鼠标为圆心,向外衰减的圆形高亮

三层通过 col = spotlight + gradient - stripe 混合。 条纹值(0–1)从渐变颜色中"减去",制造出明暗交替的百叶窗; 聚光灯在鼠标附近叠加正值,让那一片更亮。 最后画布以 mix-blend-mode: lighten 叠在黑色背景上—— 着色器输出的暗区(负值被截断为 0)对应纯黑,不遮挡下方内容,只有发光部分穿透出来。

为什么是 lighten 而不是 screen?
screen 会让两层颜色双向提亮,稍微过曝; lighten 逐通道取较大值,只保留更亮的那一层——配合纯黑背景,效果完全可控,不会让文字区域的对比度受影响。

颜色带本身还加了 镜像折叠mirrorGradient: true): 渐变从左到右跑一遍后翻折再跑一遍,让颜色平滑衔接,不出现硬边。 每条"叶片"内部也有轻微正弦波扭曲(distortAmount),让边缘不是直线, 带一点有机感。

Shader 架构

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

让我调试一小时的 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
根本原因:GLSL 预处理器(和 C 预处理器行为一致)把 #ifdef 视为行指令—— 它只处理到当前行末尾。#ifdef GL_ES 后面的 precision mediump float; 被当作条件块之外的代码,编译器在 #ifdef 尚未闭合时就遇到了 #endif,产生语法错误,整个 shader 编译失败。

OGL 对编译失败的 shader 不会抛出 JS 异常,只是静默返回一个无效的 program。 渲染调用照常执行,但什么都画不出来。try-catch 也捕获不到, 因为从 JS 的视角看,一切正常。

调试技巧:OGL 在创建 Program 时如果 shader 编译失败, 可以用 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 对象(RendererProgramMeshTriangle),不带场景管理,不带物理,包体 ~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 的标准做法。

原生 WebGL

手动 createBuffer、bindBuffer、vertexAttribPointer、enableVertexAttribArray……每个 uniform 都要 getUniformLocation,代码冗长易错

OGL

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;
实测结果:MacBook Pro M3、iPhone 15 Pro、中端 Android 均稳定 60fps。 Hero 区域滚出屏幕后 GPU 占用立即归零。