mcpField
W
webgl-components

webgl-components

github.com/flornkm设计与 UI115163 次安装10 周前更新
代码仓库

关于 webgl-components

这项技术指导技能介绍如何将着色器驱动的 WebGL 视觉效果构建为可用于生产环境的 UI 组件,重点涉及共享渲染、性能、光学效果、图像处理和回退方案。

主要功能

  1. 01多个实例共享一个 WebGL 上下文
  2. 02用单一帧循环协调渲染
  3. 03控制着色器与纹理开销
  4. 04通过折射模拟光学效果
  5. 05处理上下文丢失与回退
  6. 06缓存并解码图像纹理

适用场景

  1. 01在列表中渲染动态头像
  2. 02为界面添加环境着色器效果
  3. 03减少重复组件的 GPU 运算
  4. 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() 得到广泛支持;SVG feDisplacementMap 虽能正确弯曲图像,但兼容性不够广),或者自行将背景绘制到场景中。要明确说明你采用了哪种方案——如果组件实际上只反射程序化环境,却声称能折射页面,那么它一移到有图案的内容上就会露馅。

分辨率:根据着色器开销决定

不要照搬其他项目的设备像素比上限。合适的上限取决于片元着色器的开销。

  • 开销大的着色器(多次采样模糊、每像素计算多个八度)需要较低的上限,约为 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 上触发该分支。 提供 forceFallback prop,并在设计系统或文档页面中使用它,测试所支持的最极端降级回退形态(图片尺寸比组件大一个数量级,并偏离中心定位)。这样一来,裁剪回归问题就会让照片散落在文档页面上,任何人都能看到,而不是等到客户报告。如果切换到降级回退会卸载 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 时仅显示回退内容,且不会在边框之外绘制;生产构建能够提供页面。

评论

支持 Markdown