webgl-components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

WebGL Components

WebGL 组件

Lessons for embedding shader-driven visuals (identity avatars, ambient orbs, animated textures) into product UI. The failure modes are predictable, and nearly all of them come from treating the widget like a demo instead of like a component that renders fifty times in a list.
将基于着色器的视觉效果(身份头像、环境球体、动画纹理)嵌入产品UI的实践经验。这类组件的失效模式是可预测的,几乎所有问题都源于将其当作演示项目而非可在列表中渲染五十次的组件来处理。

Architecture: one context, many instances

架构:单上下文,多实例

Never create one WebGL context per component instance. Browsers cap a document at roughly 8 to 16 live contexts, then silently evict the oldest. A list of avatars hits that cap immediately.
  • Keep one module-level WebGL context rendering offscreen, and give each component instance a cheap 2D canvas. Each frame: draw into the shared GL canvas, then blit the region into the instance's 2D canvas with
    drawImage
    .
  • The GL drawing buffer is only valid until the browser composites, so draw and blit within the same task. Never across an await.
  • GL's origin is bottom-left and the 2D canvas' is top-left, so blit from
    canvas.height - size
    , not from
    0
    .
  • Grow the shared canvas to fit the largest instance and never shrink it mid-session.
  • Group identical draws. Instances agreeing on every visual input (source, tint, size, pointer state) paint identical pixels, so draw once and blit that result to all of their canvases. A list of same-styled items then costs one draw per frame instead of one per row.
切勿为每个组件实例创建一个WebGL context。 浏览器会将文档的活跃上下文数量限制在约8到16个,之后会静默回收最早的上下文。一个头像列表会立即触及这个上限。
  • 保留一个模块级别的WebGL context进行离屏渲染,为每个组件实例分配一个轻量化的2D canvas。每一帧:先绘制到共享的GL画布,再通过
    drawImage
    将对应区域复制到实例的2D画布中。
  • GL绘图缓冲区仅在浏览器合成前有效,因此绘制和复制操作必须在同一个任务内完成,绝不能跨await执行。
  • GL的原点在左下角,而2D canvas的原点在左上角,因此复制时应从
    canvas.height - size
    开始,而非
    0
  • 共享画布需扩容至能容纳最大的实例,且在会话期间绝不缩小。
  • 合并相同的绘制操作。 所有视觉输入(源、色调、尺寸、指针状态)一致的实例会绘制出完全相同的像素,因此只需绘制一次,再将结果复制到所有对应画布中。这样一来,同样式的列表项每帧只需绘制一次,而非每行一次。

Frame loop discipline

帧循环规范

  • One
    requestAnimationFrame
    loop for all instances, owned at module level. No per-instance loops.
  • Gate every instance on an
    IntersectionObserver
    so offscreen instances never draw.
  • Pause the loop entirely when
    document.visibilityState !== "visible"
    , and stop scheduling once nothing visible remains.
  • Cap the frame rate. Slow ambient drift gains nothing above 30fps, and an uncapped loop pins a core for as long as the widgets are on screen, twice over on a 120Hz display where rAF fires at 120.
  • Ration expensive one-time work. Generating source textures should be budgeted to roughly one per frame with the remainder rescheduled, because a page that introduces a dozen variants at once will otherwise generate them all in a single frame and stall first paint.
  • 为所有实例维护一个模块级别的
    requestAnimationFrame
    循环,禁止为单个实例创建独立循环。
  • 为每个实例添加
    IntersectionObserver
    监听,确保离屏实例不会进行绘制。
  • document.visibilityState !== "visible"
    时,完全暂停循环;当所有可见实例都消失后,停止调度循环。
  • 限制帧率。 缓慢的环境动画在30fps以上不会带来任何提升,无限制的循环会在组件显示期间持续占用一个CPU核心,在120Hz显示器上(rAF以120次/秒触发)会加倍消耗资源。
  • 合理分配昂贵的一次性任务。生成源纹理应控制在每帧约一个,剩余任务重新调度,否则页面一次性加载十多种变体时,会在单帧内完成所有生成操作,导致首次绘制卡顿。

Cost lives in the fragment shader times area

性能开销取决于片元着色器与面积的乘积

  • Per-pixel cost scales with widget area, not instance count. Budget the fragment shader like a hot loop. A blur kernel sampled per pixel per frame (13, 25, 121 taps) is where these widgets die. Aim for one texture tap plus a couple of noise evaluations per pixel.
  • Move layered or expensive pattern generation into a precomputed source texture: render it once offscreen with the noise octaves, streaks, and grain baked in, then have the per-frame pass merely sample it at a warped position. Motion comes from where you sample, not from recomputing the pattern.
  • If the drift perturbs UVs with noise, the GPU's derivative-based mip selection over-blurs, because perturbed derivatives look larger than the real sampling density. Apply a negative mip bias in
    texture2D(..., bias)
    to pull the detail back. This presents as "why is it suddenly blurry and washed out" and is easy to misdiagnose as a texture problem.
  • Mipmaps are mandatory once nothing blurs. A 1024px source minified into a 16 to 48px widget aliases badly. WebGL1 requires power-of-two textures for
    generateMipmap
    , so keep generated sources power-of-two and resample arbitrary images onto a power-of-two canvas before upload.
  • 每像素开销随组件面积而非实例数量增长。 要像对待热点循环一样规划片元着色器的开销。每帧每像素采样的模糊内核(13、25、121次采样)是这类组件性能瓶颈的常见来源。目标是每像素仅一次纹理采样加几次噪声计算
  • 将分层或复杂的图案生成转移到预计算的源纹理中:在离屏渲染一次,将噪声八度、条纹、颗粒等效果烘焙进去,之后每帧只需在扭曲位置采样该纹理即可。动画效果来自采样位置的变化,而非重新计算图案。
  • 如果通过噪声扰动UV坐标,GPU基于导数的MIP选择会过度模糊,因为扰动后的导数看起来比实际采样密度更大。在
    texture2D(..., bias)
    中设置负MIP偏移来恢复细节。这种问题常表现为“为什么突然变得模糊褪色”,容易被误判为纹理问题。
  • 一旦需要避免模糊,MIP贴图是必需的。将1024px的源纹理缩小到16至48px的组件时,会出现严重的锯齿。WebGL1要求纹理尺寸为2的幂次才能使用
    generateMipmap
    ,因此生成的源纹理需保持2的幂次,并且在上传前将任意尺寸的图像重新采样到2的幂次画布上

Prefer real optics to painted ones

优先使用真实光学效果而非手绘模拟

Glass, chrome, iridescence and caustics are the usual reasons these widgets exist, and the instinct is to paint them: a gaussian for the highlight, a hand-authored violet-to-red ramp for the rainbow, a
smoothstep
ring for the edge. Painted optics need a new hand-tuned term for every angle and every background, and they stop being convincing the moment anything moves. Deriving them costs about the same per pixel and holds up on its own.
  • One normal buys everything. On a disc,
    N = normalize(vec3(p, sqrt(1 - r*r)))
    . From it, Fresnel
    0.04 + 0.96 * pow(1 - dot(N, V), 5.0)
    and a reflection vector are two more lines, and a Fresnel-weighted
    env(reflect(-V, N))
    already is the specular edge
    — grazing angles drive the weight to 1 and the rim shows nothing but environment. A separate rim term added on top of this is a sign the reflection is not doing its job.
  • The environment can be a function, not a cubemap. A direction-to-colour function with one soft key lobe, a vertical gradient and a floor bounce is a handful of
    pow
    s, needs no texture upload or GPU memory, and gives correct-looking reflections everywhere the widget curves.
  • Dispersion should be produced, not drawn. Sample the interior at several wavelengths, each with its own index of refraction, and weight each by the colour the eye assigns it. A white emitter inside then comes out as a spectrum in the right order, and the spread automatically widens toward the rim where the glass is thick, which no hand-authored ramp does. Three taps (plain RGB) only fringe the two edges — a full spectrum needs the middle sampled too, so budget 8 to 16.
  • Two things about a real optical setup that read as bugs but are not:
    • A sphere disperses along its own radius. A horizontal feature separates lengthwise and stays stubbornly white. Getting the spread perpendicular to the feature needs a wedge — a tilt added to the refracting normal — which is a prism, not a hack.
    • Applying that wedge directly also displaces the image by its mean deviation, throwing the feature off the edge of the widget. Position the image with the plain surface normal, compute the wedge's landing for a mid wavelength, and add only the difference per wavelength. Position and separation want separate controls, and usually separate depths — the ray depth that gives a good chromatic spread will bend the image into an arc if you also position with it.
  • Physical does not mean unexaggerated. Real glass disperses far too little to see at widget scale, so the index spread is the one number worth pushing well past reality. Keep it as a named constant and say so, since every other number then follows from it.
  • The sharper the interior, the more wavelengths it needs. Each wavelength draws its own copy of whatever is inside, so a soft feature hides a coarse sampling and a crisp one turns it into visible stripes — with three sharp ribbons at 14 taps you are drawing 42 separate lines, not a spectrum. Sharpening the interior and raising the tap count are the same change; doing only the first looks like a shader bug.
  • Hoist everything that does not vary per wavelength out of the loop. Dispersion displaces the sample along one axis, so anything derived from the other one — wave centre lines, width tapers, length falloffs — is identical on every tap and can be computed once. That is what makes 28 taps cost about what 14 did, and it is the difference between "too expensive, use fewer" and "sample it properly".
  • Cost check after hoisting: a wavelength tap is one
    refract
    and a few
    exp
    s — the whole loop is comparable to a 13-tap blur, and unlike the blur it scales with nothing but area. If it still needs trimming, drop taps before dropping resolution, but re-check for striping each time.
玻璃、铬、虹彩和焦散是这类组件常见的应用场景,人们本能地会选择手绘模拟:用高斯函数做高光,用手动编写的紫到红渐变模拟彩虹,用
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次采样。
  • 真实光学系统中有两个看似bug但实际正常的现象:
    • 球体沿自身半径方向色散。 水平特征会沿长度方向分离,并保持明显的白色。要让色散垂直于特征,需要添加一个楔形——即对折射法线进行倾斜,这是棱镜的原理,而非hack。
    • 直接应用楔形会使图像平均偏移,导致特征移出组件边缘。使用平面表面法线定位图像,计算中间波长在楔形下的落点,然后仅为每个波长添加差值。定位和分离需要独立的控制参数,通常也需要独立的光线深度——能产生良好色散的光线深度如果同时用于定位,会使图像弯曲成弧形。
  • 基于物理并不意味着不能夸张。真实玻璃的色散在组件尺寸下几乎不可见,因此折射率差值是唯一值得大幅超出真实值的参数。将其设为命名常量并注明,因为其他所有参数都由此推导而来。
  • 内部细节越清晰,需要的波长采样次数越多。 每个波长都会绘制内部内容的副本,因此柔和的特征可以掩盖粗糙的采样,而清晰的特征会使其变成可见的条纹——如果用14次采样绘制三条清晰的色带,相当于绘制42条独立的线条,而非光谱。 sharpen内部细节和增加采样次数是等价的操作;只做前者会看起来像是着色器bug。
  • 将所有不随波长变化的内容移出循环。 色散仅沿一个轴偏移采样,因此从另一个轴推导的所有内容——波中心线、宽度渐变、长度衰减——在所有采样中都是相同的,可以只计算一次。这使得28次采样的开销与14次相近,也是“开销太大,减少采样”和“正确采样”的区别所在。
  • 移出循环后的开销检查:一次波长采样包含一次
    refract
    和几次
    exp
    运算——整个循环的开销相当于13次采样的模糊操作,且与面积无关,不像模糊操作那样随其他因素缩放。如果仍需优化,先减少采样次数而非降低分辨率,但每次减少后都要重新检查是否出现条纹。

Transparency: what the shader can and cannot reach

透明度:着色器能及与不能及的边界

  • A widget that should sit over arbitrary page content must output what it adds plus what it blocks — premultiplied
    vec4(emitted, coverage)
    — rather than a finished opaque image. The clear middle then lets the page through while only the reflective rim goes properly opaque.
  • Any environment constant secretly assumes a background. A dark studio reflected onto a widget sitting on a white page draws a hard black ring around it, because at grazing angles the edge shows nothing else. Drive the ambient level from
    prefers-color-scheme
    and pass it as a uniform.
  • WebGL cannot read the pixels behind its own canvas. True refraction of live page content is not available from the shader at all. The honest options are a DOM layer under the canvas carrying a
    backdrop-filter
    (
    blur()
    is universally supported; an SVG
    feDisplacementMap
    bends it properly but is not portable), or drawing the backdrop into the scene yourself. Say which one you did — a widget described as refracting the page when it is only reflecting a procedural environment will be found out the moment it moves over something patterned.
  • 需要覆盖任意页面内容的组件必须输出其“添加的内容”加上“遮挡的内容”——即预乘的
    vec4(emitted, coverage)
    ——而非最终的不透明图像。这样,透明的中间区域可以让页面内容透过,而仅反射边缘会完全不透明。
  • 任何环境常量都默认假设了背景。 如果将暗室环境反射到白色页面上的组件,会在组件周围绘制出明显的黑环,因为掠射角下边缘仅显示环境。应从
    prefers-color-scheme
    获取环境亮度,并将其作为uniform传入。
  • WebGL无法读取自身画布后方的像素。 着色器完全无法实现对实时页面内容的真实折射。可行的方案是在画布下方添加一个带有
    backdrop-filter
    的DOM层(
    blur()
    已被普遍支持;SVG的
    feDisplacementMap
    可以正确弯曲内容,但兼容性较差),或者自行将背景绘制到场景中。需明确说明采用的方案——如果组件声称能折射页面内容,但实际上仅反射程序化环境,那么当它移动到有图案的内容上时就会露馅。

Resolution: let the shader cost decide

分辨率:由着色器开销决定

Do not copy a device-pixel-ratio cap from another project. The right cap is a function of how expensive your fragment shader is.
  • An expensive shader (multi-tap blurs, many octaves per pixel) needs an aggressive cap, around 1.5, because fill cost scales with the square of the ratio.
  • A cheap shader (one texture tap) should render at the display's real density. Capping below it is just an upscale that visibly softens the widget's edge, and it buys almost nothing back.
Optimizing the shader first is what earns the sharper rendering. Decide the cap after the shader is final, not before.
不要直接照搬其他项目的设备像素比上限。合适的上限是着色器开销的函数。
  • 开销大的着色器(多采样模糊、每像素多次八度运算)需要严格的上限,约为1.5,因为填充开销与像素比的平方成正比。
  • 开销小的着色器(仅一次纹理采样)应按显示器的真实密度渲染。低于该值的上限只会导致组件边缘模糊,几乎无法节省资源。
优先优化着色器才能获得更清晰的渲染效果。应在着色器最终确定后再决定分辨率上限,而非提前设定。

Texture cache and GPU memory

纹理缓存与GPU内存

  • Cache textures by a stable source key so many instances share one upload.
  • Bound the cache. Each source texture is roughly a megapixel plus its mip chain, so an unbounded variant count grows GPU memory forever.
  • Evict on a real signal, not just insertion order: skip any key currently in use by a mounted instance, delete the rest with
    gl.deleteTexture
    until back under the cap. Evicting a texture that is on screen just forces an immediate regeneration.
  • 通过稳定的源键缓存纹理,使多个实例共享一次上传。
  • 限制缓存大小。 每个源纹理约占1百万像素加上其MIP链,因此无限制的变体数量会持续占用GPU内存。
  • 根据真实信号进行回收:跳过当前被挂载实例使用的键,调用
    gl.deleteTexture
    删除其他键对应的纹理,直到缓存大小低于上限。回收正在显示的纹理会立即触发重新生成。

Image sources

图像源

  • Decode off the main thread with
    createImageBitmap
    , falling back to
    Image
    , and
    close()
    the bitmap in a
    finally
    so early-return paths cannot leak decoded pixel memory.
  • Show a plausible placeholder such as a solid tint until the texture arrives, then repaint the listeners when it lands.
  • If you crop the image to cover-fit, every derived measurement (luminance probes, palette extraction) must measure the same cropped region you display, otherwise your guarantees hold only for pixels nobody sees.
  • 使用
    createImageBitmap
    在主线程外解码图像, fallback到
    Image
    ,并在
    finally
    中调用
    close()
    关闭位图,避免提前返回路径泄漏解码后的像素内存。
  • 在纹理加载完成前显示合理的占位符(如纯色块),加载完成后通知监听器重绘。
  • 如果对图像进行裁剪以适应组件,所有派生测量(亮度探测、调色板提取)必须针对显示的同一裁剪区域,否则你的保证仅对无人可见的像素有效。

Robustness

鲁棒性

  • Handle context loss. Listen for
    webglcontextlost
    , call
    preventDefault()
    , tear down the loop, and flip every mounted instance to its DOM fallback. Context loss is routine under GPU resets and tab pressure, not exotic.
  • Refuse software rasterization with
    failIfMajorPerformanceCaveat: true
    . Where the GPU is blocklisted, a heavy source pass takes seconds and freezes the tab, and the flat fallback is strictly the better widget there.
  • Guarantee output bounds in the shader itself. Clamp generated luminance to a mid band and apply tints with a luminosity-preserving blend, taking hue and saturation from the tint and luminosity from the source, so no input can produce an all-black or blown-out widget. Do not rely on curated inputs staying curated.
  • 处理上下文丢失。 监听
    webglcontextlost
    事件,调用
    preventDefault()
    ,终止循环,并将所有挂载的实例切换到DOM fallback。上下文丢失在GPU重置和标签页资源紧张时是常见现象,并非极端情况。
  • 拒绝软件光栅化,设置
    failIfMajorPerformanceCaveat: true
    。在GPU被列入黑名单的环境中,繁重的源渲染会耗时数秒并冻结标签页,此时简单的fallback组件反而更好。
  • 在着色器中保证输出范围。将生成的亮度限制在中间区间,使用保留亮度的混合模式应用色调(从色调中获取色相和饱和度,从源中获取亮度),确保任何输入都不会导致组件全黑或过曝。不要依赖输入始终经过筛选。

The fallback is a production surface, not an edge case

Fallback是生产环境的必要部分,而非边缘情况

failIfMajorPerformanceCaveat: true
is the right call, but accept what it implies: the widget refuses to render on every machine with hardware acceleration switched off — a plain Chrome settings toggle, not an exotic state — plus blocklisted GPUs, remote-desktop sessions, and lost contexts. Those users see only the fallback, and none of them are the machine you develop on, which is precisely how a broken fallback ships: nobody who could fix it ever renders it.
  • Always ship a non-WebGL fallback that preserves the element's identity: same color, same seed-derived look, rendered with plain DOM and CSS. The widget is decoration, the fallback is the contract.
  • Make
    fallback
    part of the minimum API shape.
    Take it as a prop next to
    source
    , and include it in every documented example — above all the first copy-paste snippet, because that is the one humans and agents lift. An example gallery where only a dedicated "fallback demo" passes one teaches everyone else to omit it.
  • Match the fallback to the source kind. Generated source → a flat disc in the same tint (the color is the identity signal, so it still reads as the right entity). Image source → the same image with
    object-cover
    , which matches the shader's cover-fit crop. A window onto a larger image → position it absolutely against the frame and let the clip do the cropping.
  • The frame that clips the fallback must be its containing block.
    overflow: hidden
    only clips an absolutely positioned descendant when the clipping element is itself positioned. A fallback that positions an oversized image against a static wrapper ignores the clip entirely and paints across the surrounding UI at full size. Put
    position: relative
    on the overflow-hidden frame, and treat its absence as a review blocker — this exact bug ships invisibly because the branch never renders on a dev machine.
  • Make the branch reachable on a healthy GPU. Ship a
    forceFallback
    prop and use it in the design-system or docs page with the hardest fallback shape supported (an image an order of magnitude larger than the widget, positioned off-center). A clipping regression then splatters a photo across the docs page where anyone sees it, instead of waiting for a customer report. If flipping to the fallback unmounts the canvas, the registration effect must depend on that flag so the stale instance is released, not leaked.
  • Verify the docs demo actually exercises what it claims. Docs pipelines that intercept intrinsic elements (an MDX
    img
    override, for instance) can silently strip the
    className
    or
    style
    a fallback depends on, leaving a demo that renders plausibly while testing nothing.
设置
failIfMajorPerformanceCaveat: true
是正确的选择,但要接受其隐含的结果:组件会在所有硬件加速关闭的机器上拒绝渲染——这只是Chrome的一个普通设置选项,而非极端状态——还包括被列入黑名单的GPU、远程桌面会话和上下文丢失的情况。这些用户只能看到fallback,而开发人员的机器通常不会触发这种状态,这正是有问题的fallback会被发布的原因:没有能修复它的人会看到它。
  • 始终提供非WebGL的fallback,保留元素的标识:相同的颜色、相同的种子衍生外观,使用纯DOM和CSS渲染。组件是装饰,fallback是契约。
  • fallback
    纳入最小API结构。
    将其作为与
    source
    并列的prop,并在所有文档示例中包含——尤其是第一个可复制粘贴的代码片段,因为这是人们和AI会直接使用的部分。如果只有专门的“fallback演示”示例才传入fallback,会导致所有人都忽略它。
  • 根据源类型匹配fallback。生成源→相同色调的纯色圆盘(颜色是标识信号,因此仍能代表正确的实体)。图像源→使用
    object-cover
    的相同图像,与着色器的裁剪方式匹配。显示大图像局部的组件→绝对定位图像到框架内,通过裁剪实现局部显示。
  • 裁剪fallback的框架必须是其包含块。 只有当裁剪元素本身是定位元素时,
    overflow: hidden
    才会裁剪绝对定位的后代元素。如果fallback将超大图像定位到静态容器上,会完全忽略裁剪,以全尺寸覆盖周围UI。在设置了
    overflow: hidden
    的框架上添加
    position: relative
    ,并将缺失该属性视为审查阻塞项——这个bug会被悄悄发布,因为开发机器永远不会触发该分支。
  • 在健康GPU上也能触发fallback分支。 添加
    forceFallback
    prop,并在设计系统或文档页面中使用最难的fallback形状(比如比组件大一个数量级的图像,偏移中心定位)。这样,裁剪回归问题会直接在文档页面上显示出来,所有人都能看到,而不是等待客户反馈。如果切换到fallback时会卸载画布,注册逻辑必须依赖该标志,以释放旧实例而非泄漏。
  • 验证文档演示是否真正测试了其声称的功能。文档流水线可能会拦截内置元素(比如MDX的
    img
    覆盖),悄悄移除fallback依赖的
    className
    style
    ,导致演示看起来正常但实际未测试任何内容。

React and SSR integration

React与SSR集成

  • Under React Server Components, the component file needs
    "use client"
    .
    This is an RSC boundary marker rather than a React-wide requirement, so check which world you are in: it applies in the Next.js App Router and the other RSC setups (React Router's RSC mode, Waku, the Parcel and Vite RSC plugins), and is an inert directive in a plain SPA, the Next Pages Router, or Astro and Remix islands, where the only effect is a bundler warning about module-level directives. Where it does apply, omitting it often works in dev while the production server render fails with an opaque digest error, because dev and prod RSC behavior differ. Verify with a real production build, not the dev server.
  • Any server rendering, RSC or not, imports the module on the server. So no browser APIs (
    window
    ,
    document
    ,
    matchMedia
    ) at module scope, only inside functions called after mount. This one bites in Astro, Remix, Gatsby and a Vite SSR build just as hard as in Next.
  • Spread object props into primitives for effect dependencies. An inline
    source={{...}}
    object re-registers the instance on every render when the effect depends on object identity.
  • Registration and teardown belong in one effect returning a cleanup. The imperative handle (pointer position, visibility) goes through refs rather than state, because pointer moves must not re-render React.
  • 在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一样常见。
  • 将对象props展开为基本类型作为effect依赖。如果effect依赖对象标识,内联的
    source={{...}}
    对象会在每次渲染时重新注册实例。
  • 注册和销毁逻辑应放在同一个effect中,并返回清理函数。 imperative句柄(指针位置、可见性)应通过refs传递而非state,因为指针移动不应触发React重新渲染。

Accessibility and layout

可访问性与布局

  • prefers-reduced-motion
    renders one still frame and never starts the loop.
    Keep full visual fidelity, since reduced motion is not reduced appearance. Listen for the media-query change and repaint live.
  • Decorative instances get
    aria-hidden
    , meaningful ones get
    role="img"
    with a label. Make it a prop that defaults to hidden.
  • Wrap the component in
    isolation: isolate
    if it layers internally with z-index. Otherwise its internal stacking leaks into the page and the widget floats above sticky headers and navigation.
  • Make canvases and any backing images non-interactive: no pointer events on purely visual layers, no drag, no text selection.
  • prefers-reduced-motion
    时,渲染一帧静态图像,永不启动循环。
    保持完整的视觉保真度,因为减少动画并非减少外观。监听媒体查询变化并实时重绘。
  • 装饰性实例添加
    aria-hidden
    ,有意义的实例添加
    role="img"
    并附带标签。将其设为默认隐藏的prop。
  • 如果组件内部使用z-index分层,将其包裹在
    isolation: isolate
    中。否则,内部的堆叠会泄漏到页面中,导致组件浮在粘性页眉和导航上方。
  • 将画布和任何背景图像设为非交互式:纯视觉层不响应指针事件,禁止拖拽和文本选择。

Identity and determinism

标识与确定性

  • Derive per-entity variety from a stable hash of the entity id into a small curated table of crop windows, palettes, or noise offsets. The same entity must look identical across sessions, surfaces, and the WebGL-to-fallback boundary.
  • Spread variants across the parameter space with a strong integer hash such as Knuth multiplicative, not sequential offsets, so neighbouring ids look clearly different.
  • Never use
    Math.random()
    in the render path. Determinism is what makes the widget an identity mark rather than a screensaver.
  • 实体ID的稳定哈希值派生出每个实体的变体,映射到一个小型的裁剪窗口、调色板或噪声偏移表中。同一实体在不同会话、不同平台以及WebGL与fallback之间必须外观一致。
  • 使用强整数哈希(如Knuth乘法哈希)而非顺序偏移来分散变体,使相邻ID的外观明显不同。
  • 渲染路径中绝不能使用
    Math.random()
    。确定性是组件成为标识而非屏保的关键。

Verifying without eyeballs

无需视觉验证的测试方法

  • Shader logic is plain math, so port it to NumPy or PIL and assert on statistics: mean luminance, percentile spread, and local-gradient detail metrics per variant. This catches "all variants render near-black" or "contrast collapsed" without opening a browser.
  • Settle which branch a machine takes by measuring, not by reasoning from documentation.
    scripts/gpu-probe.html
    answers it directly: the strict-context result, whether WebGL exists at all, the live context cap, and the blit orientation. Edit
    CONTEXT_OPTIONS
    at the top of the file to match your component's real options first, because the answer only transfers if the options match. Serve it and read the JSON:
bash
python3 -m http.server 8000 --directory scripts
  • Test the no-GPU path for real, not by assumption. Point a GPU-less Chrome at the probe, then at your own app; a throwaway
    --user-data-dir
    keeps it out of the normal profile:
bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --disable-gpu --user-data-dir=/tmp/nogpu-profile http://localhost:8000/gpu-probe.html
Expect the probe to report
FALLBACK
, then confirm every widget in your app shows its fallback, correctly clipped and painting nothing outside its frame. Note the two no-GPU states differ:
--disable-gpu
removes WebGL entirely, while the GUI toggle (Settings → System → "Use graphics acceleration when available", off, then restart) usually leaves a software rasterizer that the strict context refuses anyway. Both land on the fallback branch, and the GUI toggle is the exact state the affected users are in.
  • The browser checklist: a list of fifty instances scrolls at 60fps, tab-hidden CPU sits near zero, reduced motion shows a still frame, forced context loss flips to the fallback,
    --disable-gpu
    shows only fallbacks with nothing painting outside its frame, and a production build serves the page.
  • 着色器逻辑是纯数学,因此将其移植到NumPy或PIL中并断言统计数据:每个变体的平均亮度、百分位分布和局部梯度细节指标。这样无需打开浏览器就能发现“所有变体都接近黑色”或“对比度崩溃”等问题。
  • 通过测量确定机器会进入哪个分支,而非仅凭文档推断。
    scripts/gpu-probe.html
    可以直接给出答案:严格上下文的结果、WebGL是否存在、活跃上下文上限以及复制方向。首先编辑文件顶部的
    CONTEXT_OPTIONS
    使其与组件的真实选项匹配,因为只有选项匹配时结果才有效。启动服务并读取JSON:
bash
python3 -m http.server 8000 --directory scripts
  • 真实测试无GPU路径,而非假设。 用无GPU的Chrome访问探针,再访问你的应用;使用临时的
    --user-data-dir
    避免影响正常配置文件:
bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --disable-gpu --user-data-dir=/tmp/nogpu-profile http://localhost:8000/gpu-probe.html
预期探针会报告
FALLBACK
,然后确认应用中的所有组件都显示fallback,且正确裁剪、不会绘制到框架外。注意两种无GPU状态的区别:
--disable-gpu
完全移除WebGL,而GUI设置(设置→系统→“可用时使用图形加速”,关闭后重启)通常会保留软件光栅化,但严格上下文会拒绝它。两种状态都会进入fallback分支,且GUI设置正是受影响用户所处的状态。
  • 浏览器检查清单:五十个实例的列表能以60fps滚动,标签页隐藏时CPU占用接近零,减少动画模式显示静态图像,强制上下文丢失会切换到fallback,
    --disable-gpu
    仅显示fallback且无内容绘制到框架外,生产构建能正常提供页面。