关于 webgl-components
这项技术指导技能介绍如何将着色器驱动的 WebGL 视觉效果构建为可用于生产环境的 UI 组件,重点涉及共享渲染、性能、光学效果、图像处理和回退方案。
主要功能
- 01多个实例共享一个 WebGL 上下文
- 02用单一帧循环协调渲染
- 03控制着色器与纹理开销
- 04通过折射模拟光学效果
- 05处理上下文丢失与回退
- 06缓存并解码图像纹理
适用场景
- 01在列表中渲染动态头像
- 02为界面添加环境着色器效果
- 03减少重复组件的 GPU 运算
- 04WebGL 不可用时提供回退
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ flornkm/ skills --skill webgl-components安装后在 Claude Code 中输入/webgl-components确认已加载。
附带文件scripts/gpu-probe.html
详细介绍
WebGL 组件
介绍如何将着色器驱动的视觉效果(身份头像、氛围光球、动画纹理)嵌入产品 UI。故障模式可以预见,而且几乎全都源于把小组件当成演示,而不是会在列表中渲染五十次的组件。
架构:一个上下文,多个实例
切勿为每个组件实例创建一个 WebGL 上下文。 浏览器会将单个文档中的活动上下文数量限制在约 8 到 16 个,超过后便会悄悄淘汰最早创建的上下文。头像列表很快就会触及上限。
- 保留一个模块级 WebGL 上下文在离屏渲染,并为每个组件实例提供一个开销低的 2D canvas。每一帧:先绘制到共享的 GL canvas,再用
drawImage将对应区域绘制到实例的 2D canvas。 - GL 绘图缓冲区仅在浏览器合成前有效,因此绘制和绘制到画布上必须在同一个任务中完成。切勿跨越 await。
- GL 的原点位于左下角,而 2D canvas 的原点位于左上角,因此绘制区域应从
canvas.height - size开始,而不是从0开始。 - 扩大共享 canvas,使其适配最大的实例,并且在当前会话中绝不缩小。
- 将相同的绘制分组。 如果多个实例的所有视觉输入(源、色调、尺寸、指针状态)都相同,它们绘制的像素也完全相同,因此只需绘制一次,再将结果绘制到它们各自的 canvas 上。这样一来,样式相同的列表每帧只需绘制一次,而不是每行绘制一次。
帧循环规范
- 所有实例共用一个由模块级代码管理的
requestAnimationFrame循环。不要为每个实例单独创建循环。 - 使用
IntersectionObserver控制每个实例,确保屏幕外的实例不会绘制。 - 当
document.visibilityState !== "visible"时,完全暂停循环;没有可见实例后,停止安排新的帧。 - 限制帧率。 缓慢的氛围漂移超过 30fps 不会带来任何收益,而不设上限的循环会在小组件显示期间占满一个核心;在 120Hz 显示屏上,
rAF以 120Hz 触发,开销还会翻倍。 - 合理安排耗时的一次性工作。生成源纹理时,应控制为每帧大约一个,其余的延后安排,因为页面一次引入十几种变体时,否则会在同一帧内全部生成,导致首次绘制卡顿。
开销取决于片段着色器的成本乘以面积
- 逐像素开销随小组件面积变化,而非实例数量。 应像规划热点循环一样规划片段着色器。每帧对每个像素采样模糊内核(13、25、121 次采样),正是这类小组件性能崩溃的原因。目标是每个像素采样一次纹理,并进行几次噪声计算。
- 将分层或开销高的图案生成移至预计算源纹理:在离屏环境中渲染一次,将噪声八度、条纹和颗粒感烘焙进去;之后每帧只需在扭曲后的坐标处采样。动感来自采样的位置,而不是重新计算图案。
- 如果漂移通过噪声扰动 UV,GPU 基于导数的 mip 级别选择会导致过度模糊,因为扰动后的导数看起来比实际采样密度更大。在
texture2D(..., bias)中应用负 mip 偏差,以恢复细节。这会表现为“为什么突然变得模糊、色彩也变淡了”,很容易被误诊为纹理问题。 - 不再进行模糊处理后,必须使用 mipmap。将 1024px 的源纹理缩小到 16 到 48px 的小组件中,会产生严重混叠。WebGL1 的
generateMipmap要求纹理尺寸为 2 的幂,因此生成的源纹理应保持 2 的幂尺寸,并在上传前将任意图像重采样到 2 的幂尺寸的 canvas 上。
宁用真实光学效果,也别手工绘制
玻璃、铬、虹彩和焦散是这类组件存在的常见原因,而人们通常会选择手工绘制:用高斯函数绘制高光,用手工制作的紫到红渐变绘制彩虹,用 smoothstep 环绘制边缘。手工绘制的光学效果需要针对每个角度和每种背景单独调整参数,一旦有任何东西移动,就不再逼真。推导这些效果的每像素成本大致相同,而且本身就足够可靠。
- 一个法线就能搞定一切。 对于圆盘,
N = normalize(vec3(p, sqrt(1 - r*r)))。从中得出菲涅耳项0.04 + 0.96 * pow(1 - dot(N, V), 5.0)和反射向量,只需再写两行;而 加权了菲涅耳项的env(reflect(-V, N))本身就是镜面边缘效果——掠射角会让权重升至 1,边缘只显示环境。若还要额外叠加单独的边缘项,就说明反射效果没有发挥作用。 - 环境可以是一个函数,而不必是立方体贴图。 将方向映射为颜色的函数,只需一个柔和的主光瓣、一个垂直渐变和一个地面反射,用几次
pow就能实现,不需要上传纹理或占用 GPU 内存,并且能在组件曲面上的任何位置呈现看起来正确的反射。 - 色散应该计算出来,而不是画出来。 以多个波长采样内部场景,每个波长使用各自的折射率,并按人眼赋予该波长的颜色加权。这样,内部的白色发光体就会呈现顺序正确的光谱;而玻璃越厚的边缘处,光谱自然会扩展得越宽,这是手工绘制的渐变无法做到的。三次采样(普通 RGB)只会在两侧产生色边——完整光谱还需要采样中间波长,因此应预留 8 到 16 次采样。
- 真实光学系统中有两种现象看起来像错误,实际上并非如此:
- 球体沿自身半径产生色散。 水平特征会沿长度方向分离,而且始终是白色。要让色散垂直于特征,需要加一个楔角——即在折射法线上添加倾斜;这其实是棱镜,而不是取巧。
- 直接应用楔角也会造成图像偏移,偏移量等于平均偏向角,使特征偏到组件边缘之外。用普通表面法线定位图像,计算中间波长在楔角作用下的落点,并且只为每个波长添加差值。定位和色散需要分别控制,而且通常需要不同的深度——能产生良好色差的光线深度,如果也用来定位图像,就会让图像弯成弧线。
- 真实不代表不能夸张。真实玻璃的色散弱得多,在组件的显示尺寸下难以察觉,因此值得大幅超出现实范围调整的数值只有折射率差。将其设为有名称的常量,并说明这一点,因为其他所有数值都由它决定。
- 内部效果越清晰,所需的波长就越多。 每个波长都会绘制一份内部图像,因此柔和的特征能掩盖粗略采样,而清晰的特征则会让它显出明显条纹——以 14 次采样绘制三条清晰色带,相当于绘制 42 条独立线条,而不是一个光谱。锐化内部效果与增加采样次数是同一项改动;只做前者看起来就像着色器出错。
- 将所有不随波长变化的计算移出循环。 色散会让采样沿一个轴偏移,因此由另一个轴推导的内容——波纹中心线、宽度渐变、长度衰减——在每次采样中都相同,可以只计算一次。这样,28 次采样的成本就与之前的 14 次差不多,这也是“太贵了,少采样几次”和“充分采样”之间的区别。
- 提前计算后的成本检查:一次波长采样只需一次
refract和几次exp——整个循环的成本与 13 次采样的模糊相当,而且与模糊不同,它只随面积变化。如果仍需削减成本,先减少采样次数,再降低分辨率,但每次都要重新检查是否出现条纹。
透明度:着色器能触及什么,不能触及什么
- 需要叠加在任意页面内容上的组件,必须输出它增加的内容和它遮挡的内容——即预乘的
vec4(emitted, coverage)——而不是一张最终的不透明图像。这样,中间透明区域就能透出页面,只有反光边缘会真正变得不透明。 - 任何环境光常量都暗中假设了背景。 将深色摄影棚环境反射到白色页面上的组件,会在周围形成明显的黑环,因为边缘在掠射角下看不到其他内容。根据
prefers-color-scheme调整环境光强度,并将其作为 uniform 传入。 - WebGL 无法读取自身画布后面的像素。 着色器根本无法实现对实时页面内容的真实折射。可行的方案是:在画布下方放置一个带有
backdrop-filter的 DOM 图层(blur()得到广泛支持;SVGfeDisplacementMap虽能正确弯曲图像,但兼容性不够广),或者自行将背景绘制到场景中。要明确说明你采用了哪种方案——如果组件实际上只反射程序化环境,却声称能折射页面,那么它一移到有图案的内容上就会露馅。
分辨率:根据着色器开销决定
不要照搬其他项目的设备像素比上限。合适的上限取决于片元着色器的开销。
- 开销大的着色器(多次采样模糊、每像素计算多个八度)需要较低的上限,约为 1.5,因为填充开销会随比率的平方增长。
- 开销小的着色器(只采样一次纹理)应按显示器的实际像素密度渲染。将上限设得更低,只会放大图像,让组件边缘明显变软,收益却微乎其微。
先优化着色器,才能实现更清晰的渲染。应在着色器定稿后再决定上限,而不是提前决定。
纹理缓存与 GPU 内存
- 按稳定的源键缓存纹理,让多个实例共用一次上传。
- 限制缓存大小。 每张源纹理约占一百万像素,再加上 mip 链;变体数量不受限制会让 GPU 内存持续增长。
- 根据真实信号淘汰纹理,而不只是按插入顺序:跳过当前被已挂载实例使用的键,并对其余纹理调用
gl.deleteTexture,直到缓存回到上限以内。淘汰正在屏幕上显示的纹理,只会迫使它立即重新生成。
图像源
- 使用
createImageBitmap在主线程之外解码,失败时回退到Image,并在finally中调用close()关闭位图,避免提前返回的路径泄漏已解码的像素内存。 - 纹理加载完成前显示合理的占位内容,例如纯色色调;加载完成后,重新绘制所有监听实例。
- 如果要将图像裁剪为覆盖适配,所有派生测量值(亮度探测、调色板提取)都必须基于实际显示的同一裁剪区域,否则你的保证只对用户看不到的像素成立。
稳健性
- 处理上下文丢失。 监听
webglcontextlost,调用preventDefault(),拆除循环,并将所有已挂载实例切换到 DOM 回退方案。GPU 重置和标签页压力下,上下文丢失很常见,并非罕见情况。 - 使用
failIfMajorPerformanceCaveat: true拒绝软件光栅化。GPU 被列入阻止名单时,耗时的源图处理会持续数秒并冻结标签页;此时纯色回退方案无疑是更好的组件。 - 在着色器中保证输出范围。将生成的亮度限制在中间区间,并通过保持亮度的混合方式应用色调:色相和饱和度取自色调,亮度取自源图,这样任何输入都不会生成全黑或过曝的组件。不要指望精心筛选的输入始终精心筛选。
降级回退是生产界面的一部分,而非边缘情况
failIfMajorPerformanceCaveat: true 是正确的选择,但要接受它带来的影响:所有关闭硬件加速的机器——只需在 Chrome 设置中切换一个普通选项,并非什么罕见情况——以及被列入屏蔽名单的 GPU、远程桌面会话和丢失上下文的用户,都会遇到组件拒绝渲染的情况。这些用户只能看到降级回退,而你开发时用的机器不属于其中任何一种。正因如此,损坏的降级回退才会随产品发布:有能力修复它的人从未实际渲染过它。
- 始终提供非 WebGL 降级回退,保留元素的身份特征:颜色相同、外观由同一随机种子生成,并使用纯 DOM 和 CSS 渲染。组件只是装饰,降级回退才是契约。
- 将
fallback纳入最小 API 结构。 将它作为与source并列的 prop,并在每个文档示例中都包含它——尤其是第一个可复制粘贴的代码片段,因为人和 Agent 都会从这里复制。示例集里只有专门的“降级回退演示”传入该 prop,会让其他人都学着省略它。 - 根据来源类型匹配降级回退。生成的来源 → 使用相同色调的纯色圆盘(颜色是身份标识,因此仍能看出这是同一个实体)。图片来源 → 使用同一张图片并设置
object-cover,与着色器的 cover-fit 裁剪方式一致。较大图片上的视窗 → 相对于框架绝对定位,并由裁剪区域完成裁剪。 - 裁剪降级回退的框架必须是其包含块。 只有当裁剪元素自身经过定位时,
overflow: hidden才会裁剪绝对定位的后代元素。若降级回退将超大图片相对于未定位的包装元素定位,就会完全忽略裁剪,并以原尺寸覆盖周围的 UI。请在设置了overflow: hidden的框架上添加position: relative,并将缺少此属性视为评审阻断项——这种错误会悄无声息地随产品发布,因为该分支不会在开发机器上渲染。 - 确保能在健康的 GPU 上触发该分支。 提供
forceFallbackprop,并在设计系统或文档页面中使用它,测试所支持的最极端降级回退形态(图片尺寸比组件大一个数量级,并偏离中心定位)。这样一来,裁剪回归问题就会让照片散落在文档页面上,任何人都能看到,而不是等到客户报告。如果切换到降级回退会卸载 canvas,注册 effect 必须依赖该标记,以便释放旧实例,而不是泄漏它。 - 确认文档演示确实测试了它声称测试的内容。会拦截原生元素的文档流水线(例如 MDX 的
img覆盖)可能会悄悄移除降级回退所依赖的className或style,让演示看起来正常渲染,实际却什么也没测到。
React 和 SSR 集成
- 在 React Server Components 下,组件文件需要
"use client"。 这是 RSC 边界标记,而不是 React 的普遍要求,因此请先确认你所处的环境:它适用于 Next.js App Router 和其他 RSC 配置(React Router 的 RSC 模式、Waku、Parcel 和 Vite RSC 插件);在纯 SPA、Next Pages Router、Astro 和 Remix islands 中,它则是无效指令,只会导致打包器发出模块级指令警告。在适用的环境中,省略它往往在开发环境中也能运行,但生产环境的服务端渲染会因开发和生产环境的 RSC 行为不同而失败,并显示难以理解的摘要错误。请通过实际的生产构建进行验证,而不要只用开发服务器。 - 任何服务端渲染,无论是否使用 RSC,都会在服务端导入模块。因此,模块作用域中不能使用浏览器 API(
window、document、matchMedia),只能在挂载后调用的函数中使用。这一点在 Astro、Remix、Gatsby 和 Vite SSR 构建中同样会出问题,并非 Next.js 独有。 - 将对象属性展开并传入原始值,以用作 effect 依赖。内联的
source={{...}}对象会在每次渲染时重新注册实例,因为 effect 依赖对象标识。 - 注册和清理应放在同一个 effect 中,并返回清理函数。命令式句柄(指针位置、可见性)应通过 ref 而非 state 管理,因为指针移动不能触发 React 重新渲染。
无障碍与布局
prefers-reduced-motion会渲染一帧静止画面,且不会启动循环。 保持完整的视觉表现,因为减少动态效果不等于减少外观效果。监听媒体查询的变化,并实时重新绘制。- 装饰性实例应添加
aria-hidden;有意义的实例应使用带标签的role="img"。将其设为一个默认隐藏的属性。 - 如果组件内部使用 z-index 分层,请用
isolation: isolate包裹组件。否则,其内部层叠会泄漏到页面中,使小部件浮在粘性页眉和导航上方。 - 画布和任何后备图片都应不可交互:纯视觉图层上不响应指针、不允许拖动,也不能选择文本。
标识与确定性
- 根据实体 ID 的稳定哈希值,从精心挑选的小型表格中派生每个实体的差异,例如裁剪窗口、调色板或噪声偏移。同一实体在不同会话、界面以及 WebGL 与后备方案之间的切换中,都必须呈现相同的外观。
- 使用 Knuth 乘法等强整数哈希,将变体分散到参数空间中,而不要使用连续偏移,这样相邻 ID 才会明显不同。
- 渲染路径中绝不使用
Math.random()。确定性才能让小部件成为身份标记,而不是屏幕保护程序。
不看图也能验证
- 着色器逻辑就是普通数学运算,因此将其移植到 NumPy 或 PIL,并针对统计数据编写断言:测量每个变体的平均亮度、百分位跨度和局部梯度细节指标。这样无需打开浏览器,就能发现“所有变体渲染结果都接近黑色”或“对比度崩塌”等问题。
- 通过测量而不是推测文档,确定机器会采用哪个分支。
scripts/gpu-probe.html会直接给出答案:严格上下文的结果、WebGL 是否存在、实时上下文上限,以及 blit 方向。先编辑文件顶部的CONTEXT_OPTIONS,使其与你的组件实际使用的选项一致,因为只有选项匹配时,结果才适用。启动服务并读取 JSON:
python3 -m http.server 8000 --directory scripts
- 实际测试无 GPU 路径,不要想当然。让不带 GPU 的 Chrome 打开探测页,再打开你自己的应用;使用一次性的
--user-data-dir可避免影响常规配置文件:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --disable-gpu --user-data-dir=/tmp/nogpu-profile http://localhost:8000/gpu-probe.html
预期探测页会报告 FALLBACK,然后确认应用中的每个小组件都显示其回退内容,裁剪正确,且不会在自身边框之外绘制。注意,无 GPU 的两种状态有所不同:--disable-gpu 会彻底移除 WebGL,而 GUI 开关(Settings → System → “Use graphics acceleration when available”,关闭后重启)通常会保留软件光栅器,但严格上下文仍会拒绝它们。两者都会进入回退分支,而 GUI 开关对应的正是受影响用户所处的状态。
- 浏览器检查清单:包含 50 个实例的列表以 60fps 滚动;标签页隐藏时,CPU 占用接近于零;减少动态效果时显示静止帧;强制丢失上下文后切换到回退;使用
--disable-gpu时仅显示回退内容,且不会在边框之外绘制;生产构建能够提供页面。