Shader Typography 很容易做成一次性的视觉片段:把文字画到纹理,铺在平面上,fragment shader 加噪声,鼠标一动就扭曲。真正困难的是让它成为网站首页的长期接口。搜索引擎能否读到标题?用户能否选择、复制和点击?字体未加载时会不会错位?高 DPR 手机会不会分配过大纹理?切页后纹理和 RAF 是否释放?WebGL context 丢失时是否整页空白?如果答案不明确,再酷的 shader 也只是遮住内容的故障点。

本文面向会写基础 Three.js 场景、准备把 GPU 字体放进真实站点的前端开发者。前置知识是 DOM、Canvas 2D、GLSL uniform 与 Astro 客户端模块。文中的 Three.js 示例按 0.185.1 核验;本站当前首页为了减小依赖体积使用原生 WebGL,并不加载 Three.js。五段精确代码已进入 examples:DOM fallback 通过静态契约测试,Three.js 运行时通过 TypeScript 类型检查,context-lost 路径还由 Vitest 执行。浏览器能力描述核验于 2026-07-16,但没有在指定设备上执行 benchmark,因此帧率、显存和性能提升结论仍是 source-reviewed

视觉资产记录:封面(1600 × 900)与文中双层架构图(1200 × 680)均为 WEB/SUN 于 2026-07-16 创作的程序化 SVG;来源/许可为本项目原创自有资产,未使用第三方图片。

第一个决定:文字是谁,效果又是谁

Three.js 的文字创建指南把 DOM + CSS 列为最容易、通常也最快的文字方案,同时列出 canvas texture、字体几何、SDF/MSDF 与 CSS2D/CSS3D 等选择。这个排序提醒我们:Three.js 不是“显示文字”的默认答案,它只在视觉变形确实需要 GPU 时介入。

生产结构应保留一份语义 DOM:

<h1 class="hero-title" data-shader-title>
  <span>SUNNY</span>
  <span>WORKS</span>
</h1>
<canvas class="hero-gpu" aria-hidden="true"></canvas>

h1 提供文档大纲、可访问名称、文本选区、字体失败回退与无 JavaScript 内容;canvas 是 aria-hidden 的视觉镜像,不接受焦点,也不重新声明一遍标题。只有增强层成功绘出首帧后,根节点才增加 data-gpu-ready,CSS 再把 DOM 文字视觉上降低透明度或裁入背景,但不能用 display:nonevisibility:hiddenaria-hidden 删除它的语义。

如果标题内部有链接,每个链接继续由 DOM 接收点击、键盘和辅助技术操作。canvas 设置 pointer-events:none,指针坐标由外层容器监听。这样 GPU 层即使失败也不会把首页变成一张无法操作的画。

Shader Typography 双层架构和降级路径

图 1(1200 × 680):DOM 永远提供真实内容;能力门禁通过后才加载纹理、材质和网格,减少动态、加载失败或上下文丢失都回到同一份 DOM。原创程序化 SVG,WEB/SUN,2026-07-16。

抽象字形被引力着色场拉伸而下方保留稳定清晰的纸面轮廓

图 2(1600 × 900):Shader 字形与 DOM 回退的编辑式视觉隐喻,不作为实现证据。OpenAI Image Gen × WEB/SUN,2026-07-16;项目内原创生成资产。

三种字形来源,没有万能方案

CanvasTexture:适合动态文案与整块排版

把文字先画进离屏 canvas,再创建 CanvasTexture,优势是可使用浏览器字体、换行和 Canvas 2D 绘制,同一平面能容纳完整词组;缺点是纹理分辨率固定,放大后会模糊,重新排版需要重绘并标记更新。

纹理尺寸不能简单等于 innerWidth * devicePixelRatio。先从 CSS 布局盒取得显示尺寸,再把 DPR 限制在经过设备测试的上限,同时尊重 renderer.capabilities.maxTextureSize。例如宽 900 CSS px、DPR 3 并不自动意味着必须创建 2700 px 纹理;效果经过扭曲和裁切后可能在更低采样下已足够。正确阈值来自截图对比、GPU 内存和帧 trace,而不是固定博客结论。

Three.js Canvas Texture 手册示例在重新绘制后设置 texture.needsUpdate = true。生产代码还要等待实际字体:await document.fonts.load(...) 后测量,否则 canvas 先用 fallback 字体生成,Web Font 到达时 DOM 与纹理轮廓会不同。字体加载失败时保留 fallback 排版即可,不要让 loader 永远遮住首页。

function makeTitleTexture(
  THREE: typeof import('three'),
  text: string,
  cssWidth: number,
  cssHeight: number,
) {
  const ratio = Math.min(window.devicePixelRatio, 2);
  const canvas = document.createElement('canvas');
  canvas.width = Math.ceil(cssWidth * ratio);
  canvas.height = Math.ceil(cssHeight * ratio);

  const context = canvas.getContext('2d');
  if (!context) throw new Error('Canvas 2D unavailable');
  context.scale(ratio, ratio);
  context.fillStyle = '#f3f0e9';
  context.font = '900 180px Archivo Variable';
  context.textBaseline = 'middle';
  context.fillText(text, 0, cssHeight / 2);

  const texture = new THREE.CanvasTexture(canvas);
  texture.colorSpace = THREE.SRGBColorSpace;
  return texture;
}

这段代码只说明数据流,不是完整适配器。实际还要处理最大纹理、换行、letter spacing、纹理过滤、颜色空间和 disposal。Canvas 2D 没有直接等价的 CSS 字距与复杂 variable font 轴控制,若精确轮廓决定品牌,应先做视觉回归,而不是假设 DOM 与 canvas 像素一致。

TextGeometry:适合真正的立体字,不适合所有标题

字体轮廓转几何后可以挤出、受光和按顶点变形,摄像机移动也能保持矢量边缘。代价是要加载字体描述,几何面数随曲线细分与字符增长,中文字符集更不适合临时打包完整轮廓。它也不会自动提供 DOM 排版、复制或语义。因此“想让字有一点波纹”不必升级成立体几何。

几何方案要缓存共享字形或生成结果,并在页面离开时 geometry.dispose()。更新文本时先释放旧 geometry;不要每帧重建字形。顶点位移可以在 shader 中完成,但拾取、包围盒和 DOM 对齐仍需明确模型。

SDF / MSDF:适合缩放与大量字形,但引入资产管线

距离场把轮廓编码进纹理,shader 在不同缩放下重建边缘,适合大量标签或需要描边、发光的场景。它不是免费清晰:图集生成、字偶距、fallback 字符、中文集合、边缘参数和许可记录都会进入构建系统。若首页只有十个固定拉丁字符,预生成小图集可控;若文章标题任意变化,就要决定动态缺字怎么办。

选择可以用一句话约束:动态整块排版优先 CanvasTexture;真正立体轮廓才用 Geometry;大量可缩放 glyph 才承担 SDF 管线。三种方案都保留 DOM 原文。

ShaderMaterial 只处理视觉,不接管状态

ShaderMaterial 文档说明它允许提供自定义 GLSL,Three.js 会传入内建属性和 uniforms,应用自己的 uniforms 需要在 JavaScript 中更新。一个可治理的字体材质至少把时间、指针、分辨率与强度分开:

const material = new THREE.ShaderMaterial({
  transparent: true,
  uniforms: {
    uMap: { value: texture },
    uTime: { value: 0 },
    uPointer: { value: new THREE.Vector2(0.5, 0.5) },
    uStrength: { value: 0 },
  },
  vertexShader,
  fragmentShader,
});

uStrength 不应由 shader 自己猜“当前是手机”。媒体能力、减少动态、可见性和交互状态属于 JavaScript 控制面;shader 接受归一化参数,只负责确定性渲染。这样静态回退可以把强度设为零并渲染一帧,视觉测试也能固定时间与指针坐标。

不要把像素坐标直接传给材质。监听容器的 pointermove,通过 getBoundingClientRect() 转成 0–1 UV,并在 ResizeObserver 回调里更新缓存矩形;RAF 只读取缓存值。高频回调中每次测量布局再写 transform,容易制造强制布局。触摸设备没有持续 hover,应提供 tap/drag 或完全静态的等价内容,不能把关键信息藏在指针附近。

时间必须使用 RAF 提供的 timestamp 或 Three.js Clock 的 delta,而不是每帧固定加 0.016。固定步长在 60Hz 看似正确,在 120Hz 会加速,在后台恢复时又可能跳跃。对于纯视觉波形,可以用绝对秒数;对于有弹簧状态的交互,限制过大的 delta 或在页面恢复时重置积分器,避免隐藏十秒后一次推进十秒。

位移发生在顶点还是像素,决定了网格与成本

顶点 shader 只能移动已有顶点。如果标题平面只有四个顶点,无论噪声公式多精细,轮廓都只能整体倾斜或拉伸;想得到局部波浪,需要细分 PlaneGeometry,或把位移放到 fragment shader 的 UV 采样。前者增加顶点数,后者增加每个覆盖像素的采样与分支。选择必须从预期形变的空间频率出发,不能只比较哪段 GLSL 更短。

fragment 方案常见做法是用噪声偏移 vUv 后采样字形 alpha。UV 超出 0–1 时要明确 clamp、repeat 还是透明,否则边缘可能出现复制条带;透明边缘还要注意纹理颜色与 alpha 的混合关系,避免浅色背景上出现黑边。若画面需要色散,可分别偏移 RGB 通道,但采样次数也随之增加。循环噪声层数、分支和全屏覆盖范围都应成为可调质量档,而不是写死在 shader。

顶点方案的细分也应随容器而不是 DPR 无限增长。DPR 影响像素密度,不代表需要同比增加几何;先为形变曲率确定网格,再为纹理清晰度确定分辨率。两条预算分开,才能知道瓶颈来自顶点、fragment 还是纹理上传。

开发时给 uniforms 固定输入,分别截取 uTime = 0、固定指针中心和极端强度的图像。这样 shader 修改能做视觉差异审查;若每次截图都由真实时间与鼠标决定,像素变化无法区分设计更新和随机状态。编译成功也不代表视觉正确,图像门禁与 WebGL 错误检查要同时存在。

能力门禁应该发生在下载 Three.js 之前

静态首屏先出现,再判断是否值得加载高级层。推荐顺序是:当前路由确实含有目标容器;用户没有请求减少动态;视口和 pointer 条件符合设计;页面可见;然后动态 import("three") 并尝试创建 renderer。任何一步失败都维持 DOM。

async function enhanceTitle(root: HTMLElement) {
  void root;
  const reduce = matchMedia('(prefers-reduced-motion: reduce)');
  if (reduce.matches || document.hidden) return () => {};

  let THREE: typeof import('three');
  try {
    THREE = await import('three');
    void THREE;
  } catch {
    return () => {};
  }

  // 创建 renderer、scene、camera、texture、material、mesh;
  // 只有首帧成功后才设置 data-gpu-ready。
  return () => disposeEverything();
}

MDN prefers-reduced-motionreduce 定义为用户希望移除或替换非必要运动。这里应直接不下载 Three.js,而不是加载完整引擎后仅把速度设为零。若 GPU 字形承担必需文字,架构已经错了;必需文字本应在 DOM。

navigator.hardwareConcurrency、设备型号或 UA 字符串不能证明 GPU 层一定顺滑。低功耗模式、驱动、浏览器黑名单、温度、同页其他 canvas 都会改变结果。能力门禁只能避免明显不合适的加载,最终仍需在目标设备矩阵实测。

Context Lost 是正常分支,不是刷新理由

显卡重置、系统资源压力或驱动事件都可能让 WebGL context 丢失。MDN webglcontextlost说明该事件在 canvas 上触发且不冒泡。应用至少要监听它,停止 RAF,移除 data-gpu-ready,让 DOM 立刻恢复可见。若确实实现资源重建,可 preventDefault() 并在 webglcontextrestored 后重新创建纹理、材质和几何;没有完整重建路径时,稳定地留在 DOM 比反复失败更可靠。

canvas.addEventListener('webglcontextlost', (event) => {
  event.preventDefault();
  running = false;
  cancelAnimationFrame(frameId);
  root.removeAttribute('data-gpu-ready');
});

不要在丢失回调里继续调用 renderer,也不要只把 canvas 隐藏而留下 RAF。回退状态应显示同样的标题与链接,用户甚至不必知道 GPU 层曾经存在。

离屏、隐藏与切页都要停

三个门分别回答不同问题:IntersectionObserver 判断标题是否接近视口;Page Visibility 判断文档是否隐藏;路由 cleanup 判断页面是否仍存在。只有三者都允许时才持续排帧。离开视口可以停止 RAF,重新进入后用当前 timestamp 恢复;切到后台要停止并在恢复时重置时间基线;路由离开则永久 dispose,不能等待浏览器垃圾回收 GPU 对象。

销毁顺序可以显式写成清单:

  1. 设置 disposed = true,阻止异步 import 或字体 Promise 完成后继续挂载。
  2. cancelAnimationFrame,断开 IntersectionObserver、ResizeObserver 和媒体查询监听。
  3. 从 scene 移除 mesh,依次调用 texture、material、geometry 的 dispose()
  4. 调用 renderer 的 dispose(),按需要释放渲染列表,并移除 canvas。
  5. 清除 data-gpu-ready,确认 DOM 状态完整。

WebGLRenderer 文档提供 dispose() 释放 renderer 相关 GPU 资源,renderer.info 可观察几何、纹理和每帧 draw call 等内部统计。renderer.info 是诊断信号,不等同真实显存;更不能看到数值没增长就断言没有所有泄漏。做 A → B → A 路由循环,结合 heap、renderer 计数和 Performance trace 才有证据。

共享 texture/material 时不能由任意页面随手 dispose。要么每页完全独占,要么资源管理器引用计数并拥有最终释放权。所有权模糊比少写一行 dispose() 更危险。

性能预算从像素、draw call 和帧工作拆开

Shader 字体是否昂贵,不能只看三角形数。全屏透明 canvas 的像素覆盖、DPR、blend、采样次数、噪声迭代、纹理上传与后处理都可能主导成本。一个平面只有两个三角形,也能因为每像素做大量循环而慢;多个字形网格可能增加 draw call,却因覆盖小而更轻。需要记录真实实现。

建议建立三组可复现证据:

  • 加载:静态 HTML 出现时间、Three.js 动态 chunk 大小、纹理或图集体积、首帧增强完成时间;慢网与缓存冷热分别记录。
  • 运行:固定指针轨迹,在目标桌面与手机记录主线程、GPU 活动、长帧与刷新率;说明设备、系统、浏览器、DPR、窗口尺寸和电源状态。
  • 生命周期:连续往返路由、隐藏/恢复、改变视口、触发 context lost 测试,确认 RAF 停止、监听归零、renderer.info 不持续增长且 DOM 始终可用。

不要只汇报平均 FPS。50 FPS 目标也必须说明显示器刷新率和采样方法;120Hz 设备的 deadline 与 60Hz 不同。本文没有执行这些测量,所以只给出方法,不给出“可达到 60 FPS”的承诺。

视觉一致性与回退不是二选一

DOM 回退无需复制 shader 的每个像素,但应保留构图、层级和品牌识别。可以用 CSS variable font、背景裁切或静态生成图维持字形内容;减少动态时保留最终稳定帧;无 WebGL 时仍显示相同的 SEED / ANCE 排版。若图片参与字形填充,图片失败时使用可读 ink 色,而不是透明文字。

视觉验收至少截图五种状态:JS 未执行、Three.js import 失败、Reduced Motion、WebGL context lost、正常增强。再分别用 320px 与 200% 缩放检查 DOM 标题不溢出,键盘访问链接,关闭 Web Font 验证 fallback。Canvas 不能成为截图通过而屏幕阅读器失败的借口。

颜色与动势也要在两个层面校验。DOM ink 必须满足普通文本所需对比度;canvas 可以使用更激进的色散,但稳定帧仍要能辨认字形。若视觉效果把字符撕裂到无法阅读,DOM 不应同时被完全透明化,可以保留细描边或可见的静态基线。作品展示的目标是证明控制力,不是让用户猜标题。

失败模式清单

失败 表面现象 结构修复
canvas 取代 h1 搜索、选区和读屏失去标题 DOM 为唯一语义源,canvas aria-hidden
顶层静态 import Three.js 手机和减少动态仍下载大包 静态首屏后通过能力门禁动态加载
每帧读取布局 指针移动伴随 layout 抖动 ResizeObserver 缓存几何,RAF 只读状态再写入
固定每帧增量 高刷加速、后台恢复跳变 使用 timestamp/delta,并处理恢复基线
只停止 RAF 不 dispose 往返路由后 GPU 资源累积 明确释放 texture/material/geometry/renderer
字体未加载就烘焙 DOM 与纹理轮廓错位 等待 Font Loading API,失败时稳定回退
context lost 后黑屏 canvas 仍遮住 DOM 立即撤销 ready 状态并停止运行时

结论

可靠的 Shader Typography 不是“把文字搬进 WebGL”,而是保持 DOM 文字完整,再为满足条件的设备叠加一层可暂停、可丢失、可销毁的视觉系统。选择 CanvasTexture、Geometry 或 SDF 前先明确字形规模和排版需求;用 uniforms 隔离视觉与状态;在下载前进行能力门禁;把 context lost、Reduced Motion、离屏和切页都当作正常生命周期。做到这些,GPU 字体才能成为作品集的炫技证据,而不是内容与性能的单点故障。

Sources