Typography Vortex Skill 拆解:两遍 Canvas 如何把文字卷成漩涡

这篇文章先放原始 Skill,再逐个模块解释。原始作品(效果页 + SKILL.md 页签):ThreeUI Typography Vortex · 在线体验:LUNA Explore

原始 Skill(完整内容)

下面是 ThreeUI 效果页「Skill.md」页签展示的 add-typography-vortex 原文,保留原始顺序和内容。它由 ThreeUI 的目录数据在构建期生成,正文以「Build … from its verified authored source」固定句式把实现任务交给 Agent。后文按照这份 Skill 的模块逐个拆解。

001 | ---
002 | name: add-typography-vortex
003 | description: "Build Typography Vortex from its verified authored source using Canvas 2D, including the complete renderer, interactions, and required assets. Use when Codex needs to implement, port, or adapt this effect without requiring the ThreeUI package or reconstructing the visual from an approximation."
004 | ---
005 | 
006 | # Build Typography Vortex
007 | 
008 | ## Description
009 | 
010 | Sable’s complete rotating typography vortex with crisp prerendered rings, drifting glyphs, pointer dissolution, particle dust, and click suction — with dark and light surfaces.
011 | 
012 | Recreate the authored behavior from the verified source, not from screenshots or the abbreviated orchestration sample in this skill. The implementation may live directly in the target project and does not require `@designcodeio/threeui`.
013 | 
014 | ## Technologies
015 | 
016 | - React lifecycle host and two layered Canvas 2D surfaces
017 | - Dark/light mode surfaces with rebuilt glyph-ring bitmaps
018 | - Seeded prerendered character-ring bitmaps with adaptive pixel ratio
019 | - Pointer-relative alpha-mask dissolution and sampled glyph-particle emission
020 | - Ambient dust, click suction, visibility pausing, reduced motion, and ResizeObserver sizing
021 | - The exact embedded Fragment Mono font extracted as a local owned asset
022 | 
023 | ## Verified source material
024 | 
025 | - `ascii-page-transition-v1.html — exact right-side typography vortex`
026 | - `src/shaders/typography-vortex/typographyVortexRenderer.ts`
027 | - `src/shaders/typography-vortex/TypographyVortexCanvas.tsx`
028 | - `src/shaders/fonts/fragment-mono.woff2`
029 | 
030 | Source revision: `5a736cd3c1f6f19802f61ebb10e1701b9f7aa26e`
031 | 
032 | ## Implementation steps
033 | 
034 | 1. Open every verified source file listed above and identify the renderer, host lifecycle, styles, and assets before editing.
035 | 2. Load the exact Fragment Mono asset before measuring or prerendering character rings.
036 | 3. Build seeded rings using the authored phrase, font sizing, sparse-ring cadence, alpha, angular speed, wobble, and 1.21 radius growth.
037 | 4. Draw the rings and stray glyphs to an offscreen layer, then mask it with the authored ambient-left fade and pointer radial dissolve before compositing.
038 | 5. Sample visible layer pixels to emit matching dust and preserve both the ambient drift and pointer burst particle systems.
039 | 6. On pointer down, redirect existing particles into the complete timed suction state and retain the authored guide-circle feedback.
040 | 7. Keep adaptive DPR, resize rebuilds, offscreen pausing, visibility handling, reduced motion, and complete listener/observer/frame cleanup.
041 | 8. Give the local component a sized, overflow-controlled parent and verify desktop, mobile, reduced-motion, and context-loss behavior.
042 | 
043 | Asset handling: Copy the exact extracted Fragment Mono file and update the scoped @font-face URL only if the local asset directory changes.
044 | 
045 | ## Local component example
046 | 
047 | Import the copied local component rather than a package entrypoint:
048 | 
049 | ```tsx
050 | import { TypographyVortexCanvas } from "./effects/typography-vortex/TypographyVortexCanvas";
051 | import "./effects/typography-vortex/styles.css";
052 | 
053 | export function Scene() {
054 |   return <div className="effect-frame"><TypographyVortexCanvas speed={1} ringGrowth={1.21} /></div>;
055 | }
056 | ```
057 | 
058 | ## Core renderer pattern
059 | 
060 | This excerpt documents orchestration only. Copy the exact shader, geometry, pass, and interaction code from the verified source files.
061 | 
062 | ```tsx
063 | const textLayer = document.createElement("canvas");
064 | const text = textLayer.getContext("2d", { willReadFrequently: true });
065 | 
066 | drawPrerenderedRings(text, rings, time);
067 | applyAmbientAndPointerDissolve(text, pointer);
068 | context.drawImage(textLayer, 0, 0, width, height);
069 | updateSampledDust(context, particles, suction);
070 | ```
071 | 
072 | ## Behavior contract
073 | 
074 | - Runtime: Canvas 2D
075 | - Passes: 2 Canvas 2D passes — text layer + particle composite
076 | - Interaction: Mode, pointer dissolve, ambient dust, click suction, responsive rings, and reduced motion
077 | - Assets: Exact embedded Fragment Mono font extracted from the authored source
078 | - **renderer** (host): Canvas 2D
079 | - **mode** (optional): dark | light
080 | - **rings** (seeded): 1.21 growth
081 | - **interaction** (pointer): Dissolve + suction
082 | - **pixelRatio** (adaptive): ≤ 2
083 | - **asset** (owned): Fragment Mono
084 | 
085 | ## Verification
086 | 
087 | 1. Compare the rendered composition, animation timing, pointer behavior, and state transitions with the source implementation.
088 | 2. Exercise resize, high-DPI, mobile/coarse-pointer, reduced-motion, tab visibility, and WebGL context-loss paths where applicable.
089 | 3. Confirm every animation frame, observer, listener, geometry, buffer, texture, framebuffer, material, and renderer is released on teardown.
090 | 4. Check the browser console and confirm the effect renders at native-or-better backing resolution.
091 | 
092 | ## Guardrails
093 | 
094 | - Do not substitute a visually similar package, demo, shader, or runtime.
095 | - Do not approximate, reconstruct, or simplify the authored GLSL, render passes, geometry, interaction state, or assets.
096 | - Keep exact source and asset hashes under regression tests when the source project provides them.
097 | - Adapt only the surrounding host boundary needed by the target project; keep renderer behavior intact.
098 | 

模块 1:Metadata 与适用场景

对应原文:

---
name: add-typography-vortex
description: "Build Typography Vortex from its verified authored source using Canvas 2D, including the complete renderer, interactions, and required assets. Use when Codex needs to implement, port, or adapt this effect without requiring the ThreeUI package or reconstructing the visual from an approximation."
---

讲解:

路由元数据与其他 ThreeUI Skill 完全同构。name 稳定标识技能;description 声明两个关键约束:效果必须来自“verified authored source”,以及 不要“reconstruct the visual from an approximation”。它把需求从“做一个好看的旋转文字”收敛成“忠实移植一份已归档的 Canvas 2D 渲染器”,并要求实现不依赖 @designcodeio/threeui 包。

模块 2:Description——先描述机制,再描述资产

对应原文:

Sable’s complete rotating typography vortex with crisp prerendered rings, drifting glyphs, pointer dissolution, particle dust, and click suction — with dark and light surfaces.

Recreate the authored behavior from the verified source, not from screenshots or the abbreviated orchestration sample in this skill.

讲解:

效果描述只给了机制清单,没有给任何视觉规格:预渲染的清晰字环、漂移的散落字符、指针溶解、粒子尘埃、点击 suction、明暗两种表面。真正决定“长什么样”的是后面引用的源文件——SKILL.md 自己只保留了一段 orchestration 摘要,并且明确警告“这不是完整实现”。

这解释了为什么本文能引用同一份渲染器源码:Skill 的第 1 步就是要求 Agent 打开列出的源文件,逐行识别渲染器与生命周期。拆解这份 Skill 需要看它指向的真源。

模块 3:Technologies——为什么需要两张画布

对应原文:

- React lifecycle host and two layered Canvas 2D surfaces
- Dark/light mode surfaces with rebuilt glyph-ring bitmaps
- Seeded prerendered character-ring bitmaps with adaptive pixel ratio
- Pointer-relative alpha-mask dissolution and sampled glyph-particle emission

讲解:

技术栈可以拆成两条线:

  1. React 只负责生命周期:宿主组件持有 DOM 与画布引用,useEffect 里创建渲染器、返回销毁函数。真正的绘制在渲染器内完成,React 不碰每帧状态。
  2. 两张 Canvas:一张可见主画布 canvas,一张离屏 layer(getContext('2d', { willReadFrequently: true }))。所有字环先画到离屏层,用遮罩“挖洞”后再合成到主画布;尘埃粒子直接画在主画布上。这是后续所有 dissolve / suction 机制的结构基础。

模块 4:Verified source material——什么才算“真源”

对应原文:

- `ascii-page-transition-v1.html — exact right-side typography vortex`
- `src/shaders/typography-vortex/typographyVortexRenderer.ts`
- `src/shaders/typography-vortex/TypographyVortexCanvas.tsx`
- `src/shaders/fonts/fragment-mono.woff2`

Source revision: `5a736cd3c1f6f19802f61ebb10e1701b9f7aa26e`

讲解:

Skill 用四条引用 + 一个 commit 哈希定义真源:一份归档 HTML(涡旋最初出现在该页面右侧)、渲染器、React 包装组件、以及被提取为本地资产的 Fragment Mono 字体文件。哈希让移植方可以对照源工程验证字节级一致。

注意字体被单列:字环密度、字距、字符朝向都由字体度量参与计算,换字体 = 换几何。这解释了为什么第 2 步要求“先加载精确字体再测量”。

模块 5:Implementation steps——流水线总览

对应原文:

1. Open every verified source file listed above and identify the renderer, host lifecycle, styles, and assets before editing.
2. Load the exact Fragment Mono asset before measuring or prerendering character rings.
3. Build seeded rings using the authored phrase, font sizing, sparse-ring cadence, alpha, angular speed, wobble, and 1.21 radius growth.
4. Draw the rings and stray glyphs to an offscreen layer, then mask it with the authored ambient-left fade and pointer radial dissolve before compositing.
5. Sample visible layer pixels to emit matching dust and preserve both the ambient drift and pointer burst particle systems.
6. On pointer down, redirect existing particles into the complete timed suction state and retain the authored guide-circle feedback.
7. Keep adaptive DPR, resize rebuilds, offscreen pausing, visibility handling, reduced motion, and complete listener/observer/frame cleanup.
8. Give the local component a sized, overflow-controlled parent and verify desktop, mobile, reduced-motion, and context-loss behavior.

讲解:

八步即完整流水线:读源 → 载字体 → 建环 → 画离屏层并遮罩 → 采样生成尘埃 → 点击 suction → 生命周期 → 宿主边界。后文模块 6–12 逐一对应第 3–8 步展开。关键设计:溶解(dissolve)和尘埃都发生在“把离屏层合成到主画布”的同一时间点附近,粒子与字形共享同一坐标空间,因此采样出来的尘埃颜色天然与字形一致。

模块 6:Seeded rings——预渲染字环位图

对应原文:

Build seeded rings using the authored phrase, font sizing, sparse-ring cadence, alpha, angular speed, wobble, and 1.21 radius growth.

讲解(来自渲染器真源):

buildRings() 是整个效果的核心。用种子随机 mulberry32(7*17+3) 保证每次 reload 视觉可复现;默认 phrase 是 SABLE / SYSTEMS IN MOTION / 。环从 max(26, width*0.038) 起步,按 radius *= ringGrowth(默认 1.21)逐环外扩,直到覆盖视口对角线加余量。对每一环:

const sparse = index % 3 === 2          // 每三环一稀疏环
const fontSize = clamp(7 + radius * 0.015, 7, 17)
const alpha = clamp(0.34 + radius / maximumRadius, 0.36, 0.92) * (sparse ? 0.72 : 1)
const speed = (0.05 + 40 / (radius + 60)) * 0.35   // 内环快、外环慢

每环字符数由“字距”反推:step = fontSize * 0.62 * spacing / radius,count = floor(2π / step),字符沿圆周分布并 rotate(angle + π/2) 使字身沿切线方向。每环被预渲染成一张独立小位图(bitmap),动画帧里只对整张位图做一次 rotate + drawImage,而不是逐字重绘——这就是“crisp prerendered rings”和 120 帧率的来源。

模块 7:离屏层与 dissolve 遮罩

对应原文:

Draw the rings and stray glyphs to an offscreen layer, then mask it with the authored ambient-left fade and pointer radial dissolve before compositing.

讲解(来自渲染器真源):

环形位图和 34 个随机散落字符(strays)都画进离屏层。合成前用两个 destination-out 遮罩“挖洞”:

  • ambient-left fade:左侧一条横向渐变(宽度 clamp(width*0.22, 108, 180)),让文字向左逐渐隐入背景;
  • pointer dissolve:指针周围一个径向渐变圆。溶解强度 dissolve 不是开关而是状态:指针进入时向 1 缓动,离开时向 0 回落(1 - pow(0.78, dt/16.6)),遮罩半径随 0.42 + dissolve*0.58 扩大。因此“移近消失、移开恢复”是一个连续的 alpha 动画,而不是硬切。

模块 8:像素采样尘埃

对应原文:

Sample visible layer pixels to emit matching dust and preserve both the ambient drift and pointer burst particle systems.

讲解(来自渲染器真源):

尘埃不是随机撒点,而是从可见字形上采样真实像素:对指针附近矩形区域调 getImageData(),读取每个像素的 alpha 与 RGB,alpha 超过阈值(≥34)才在该坐标生成粒子,粒子颜色直接取像素原色。两条发射路径:

  • ambient:每 115ms 一次,从左侧淡出带的像素中随机采样少量粒子,飘向右方——营造“文字正在消散成灰烬”的氛围;
  • pointer burst:指针悬停且溶解时每 44ms 采样一圈,速度由指向外的法向 + 切向随机叠加,形成向外飞散的效果。

粒子总数有上限(720),且只发射在被溶解区域的边缘(距离指针 0.16r–1.0r),避免把未溶解的文字也“擦”成灰。

模块 9:Click suction——点击回收粒子

对应原文:

On pointer down, redirect existing particles into the complete timed suction state and retain the authored guide-circle feedback.

讲解(来自渲染器真源):

pointerdown 时把所有现存粒子标记为 sucked = true 并延长其生命,使它们统一朝向点击点收缩,而不是从零新发射一批。收缩速度随距离自适应:targetSpeed = clamp(distance * 0.078, 3.2, 28),接近中心(小于 5px)即移除。状态带时间窗(默认 920ms),结束后粒子回到 ambient 行为。

guide-circle feedback 有两条视觉反馈,区分“谁在动”:收缩期间以点击点为圆心画一个半径随进度扩大(5 + progress*22)、透明度随进度衰减的实线圆,圆心一个 2px 亮点;指针悬停溶解时,外沿另有一条随 dissolve 强度淡入的虚线圆环(dissolve > 0.02 时出现)。它们是“交互反馈”而非粒子系统的一部分。

模块 10:Behavior contract——可调旋钮与口径

对应原文:

- Runtime: Canvas 2D
- Passes: 2 Canvas 2D passes — text layer + particle composite
- Interaction: Mode, pointer dissolve, ambient dust, click suction, responsive rings, and reduced motion
- Assets: Exact embedded Fragment Mono font extracted from the authored source
- **renderer** (host): Canvas 2D
- **mode** (optional): dark | light
- **rings** (seeded): 1.21 growth
- **interaction** (pointer): Dissolve + suction
- **pixelRatio** (adaptive): ≤ 2
- **asset** (owned): Fragment Mono

讲解:

契约表把口径钉死:两遍渲染(文字层 + 粒子合成)、Canvas 2D(不是 WebGL)、DPR ≤ 2。React 组件暴露的 props 全部是这层的旋钮:mode(明暗)、phrase、speed、ringGrowth(1.21)、opacity、dissolveRadius、particleAmount、suctionDuration。渲染器只在“签名”变化时重建字环位图(mode|phrase|ringGrowth|opacity|width|height),普通调速/调透明不触发重建成千上万的 glyph。

模块 11:性能与生命周期规则

对应原文:

Keep adaptive DPR, resize rebuilds, offscreen pausing, visibility handling, reduced motion, and complete listener/observer/frame cleanup.

讲解(来自渲染器真源):

性能不是“尽量快”,而是几条硬约束:

  1. DPR 上限 2,环位图渲染分辨率单独取 max(pixelRatio, 1.5),位图清晰度与屏幕刷新解耦;
  2. resize 清空粒子并标记位图重建——几何变了必须整批重来,不是等比缩放;
  3. 动画只在 visible && !document.hidden 时继续 rAF,离开视口/切页即停;
  4. prefers-reduced-motion 下尘埃发射、指针 dissolve 遮罩与 click suction 全部停用(三条路径都以 reduced 短路),但字环旋转不受影响,仍按原速转动;
  5. 渲染器返回的销毁函数统一 cancelAnimationFrame、断开两个 Observer、移除全部监听器——没有资源泄漏面。

模块 12:LUNA 复刻与边界

对应原文:

Do not substitute a visually similar package, demo, shader, or runtime.
Do not approximate, reconstruct, or simplify the authored GLSL, render passes, geometry, interaction state, or assets.

讲解:

Guardrails 的立场是“这是移植任务,不是再创作”。而 LUNA 的 /explore/typography-vortex 是一次 Skill 引导的独立解释(skill-guided interpretation):只用 SKILL.md 描述生成了自己的版本,没有读取或导入 ThreeUI 渲染器源码,也不打包 Fragment Mono 字体,因此它不属于该 Guardrail 约束的“移植”范畴。

LUNA 实现保留了 Skill 描述中的机制:seeded 随机数、环半径按 1.21 增长、环形字符沿切线旋转、指针 dissolve、溶解区采样发射字形尘埃、点击 suction 回吸粒子、prefers-reduced-motion 与 DPR ≤ 2、离屏即暂停动画。差异点同样明确:没有把每环预渲染成独立位图(改为每帧直接绘制 glyph)、字体换成等宽系统栈、使用自己的 phrase。这些差异都不改变“两遍 Canvas + 遮罩 + 采样粒子”的机制骨架。

结论

这份 Skill 与 no-gl-grid 那种“自带完整算法文档”的 Skill 不同:它是一份指向真源的移植契约——SKILL.md 只给机制清单、行为契约和逐条 Guardrail,真正的几何在源文件与字体资产里。拆解它得到的可复用知识是:

  1. 两遍 Canvas 结构:离屏层负责文字与遮罩,主画布负责粒子合成;
  2. 预渲染位图:把几何构建(seeded 字环)与动画(旋转 drawImage)分开,是流畅度的关键;
  3. 遮罩即交互:dissolve 是 destination-out 径向渐变 + 状态缓动,不是粒子碰撞;
  4. 尘埃从字形采样:粒子颜色/位置继承可见像素,视觉上才“同源”。

回到 Explore 可以体验 LUNA 的 Skill-guided 版本;对照原始效果页和它的 SKILL.md 页签可以看到完整契约。