HyperFrames 场景转场实战:用 @hyperframes/shader-transitions 在 GSAP 时间线上接入 GPU 着色器过渡
【免费下载链接】hyperframesWrite HTML. Render video. Built for agents.项目地址: https://gitcode.com/GitHub_Trending/hy/hyperframes
HyperFrames 遵循"Write HTML. Render video."的理念:页面即时间线,场景(scene)即 DOM。@hyperframes/shader-transitions正是为这种架构补齐最后一环的独立子包——它用 WebGL 片元着色器(fragment shader)在相邻场景之间渲染 GPU 加速转场,并通过捕获场景动画采样帧与 GSAP 时间线结合驱动。阅读本文后,你将掌握在 HyperFrames 组合(composition)中安装、配置与扩展着色器转场,理解"预捕获 + 着色器合成"的浏览器预览管线、引擎确定性渲染管线的差异,以及降级与缓存等工程细节。
本文档与源码位于仓库 packages/shader-transitions(当前版本 0.8.29,见 package.json)。
一、包定位与核心思想
@hyperframes/shader-transitions解决的问题很具体:在一个由多个 HTML 场景(如intro、demo、outro)组成的 HyperFrames 视频中,相邻场景之间需要一个持续的、在动的转场,而不是硬切。
包的核心思路可以概括为三步,入口实现见 hyper-shader.ts:
- 预捕获(pre-capture):为每个转场在其开始前的时刻捕获"出境场景"的动画采样帧,同时捕获"入境场景"从转场起点继续推进的采样帧;
- 合成(composite):播放进入转场窗口时,把两套缓存帧作为纹理(texture)上传到 WebGL,交给片元着色器按
u_progress混合输出; - 时间线(timeline):
init()返回一个 GSAP 时间线。转场期间场景动画依然持续向前推进,但播放循环中不再有 DOM 捕获开销;转场结束后由原本的场景动画无缝接管。
由此带来的关键收益是:转场过程是真正"在动"的(captured animation keeps advancing),且 WebGL 渲染发生在 GPU 上;如果浏览器没有 WebGL,包会自动回退为普通时间线播放(不做着色器合成)。源码中对应回退逻辑会打印[HyperShader] WebGL unavailable — shader transitions disabled.并直接返回注册好的时间线(hyper-shader.ts 的init()内)。
二、安装与三种加载方式
推荐通过 npm 安装:
npm install @hyperframes/shader-transitions或者通过 CDN 以<script>标签直接加载 IIFE 产物:
<script src="https://cdn.jsdelivr.net/npm/@hyperframes/shader-transitions/dist/index.global.js"></script>该包设计上"自包含、可独立分发":源码注释明确指出它作为独立 CDN bundle 发布,不依赖@hyperframes/engine(相关说明见 hyper-shader.ts)。其唯一运行时依赖是html2canvas(^1.4.1,见 package.json),并在构建时通过noExternal: ["html2canvas"]打进了产物。
产物由 tsup.config.ts 生成,三种格式及适用场景如下表:
| 格式 | 文件 | 适用场景 | 全局变量 |
|---|---|---|---|
| ESM | dist/index.js | 打包器(Vite、webpack 等) | — |
| CJS | dist/index.cjs | Node.js /require() | — |
| IIFE | dist/index.global.js | <script>标签、CDN | HyperShader |
所有格式均包含 source map,并随包发布 TypeScript 类型声明(tsup开启了dts: true)。IIFE 场景下请注意源码注释提到的一点:当通过<script>手工加载 bundle 后,从 vanilla JS 传入显式的空字符串shader: ""不会被当作"省略",而会走到着色器注册表并抛出明确的 unknown shader 错误,这是刻意的严格行为(见 registry.ts 的getFragSource())。
三、核心 API:init(config): GsapTimeline
所有能力都收敛到init()一个函数。基本用法:
import { init } from "@hyperframes/shader-transitions"; const tl = init({ bgColor: "#0a0a0a", // 场景捕获时的兜底背景色 accentColor: "#ff6b2b", // 着色器辉光效果强调色 scenes: ["scene-1", "scene-2", "scene-3"], transitions: [ { time: 3, shader: "domain-warp", duration: 0.8 }, { time: 8, shader: "light-leak", duration: 0.7 }, ], });3.1 配置项全表
| 选项 | 类型 | 必填 | 说明 |
|---|---|---|---|
bgColor | string | 是 | 场景捕获时的兜底背景色(hex)。应使用组合的 body/canvas 背景色——每个场景通过 CSS 自行设置各自的background-color |
accentColor | string | 否 | 着色器辉光效果的强调色(hex) |
scenes | string[] | 是 | 各场景元素的 ID,按顺序排列 |
transitions | TransitionConfig[] | 是 | 转场定义数组,见下文 |
timeline | GsapTimeline | 否 | 已有的 GSAP 时间线,把转场叠加到它上面 |
compositionId | string | 否 | 覆盖data-composition-id,用于时间线注册 |
previewCaptureFps | number | 否 | 浏览器预览模式每秒钟为每个转场预捕获的采样帧数。默认30;渲染模式则改为确定性的逐帧合成,不使用此值 |
3.2 底层行为印证
对照 hyper-shader.ts 的init()实现,可以确认以下细节:
- 组合画布尺寸:优先读取根元素(带
data-composition-id的元素)上的data-width/data-height属性;缺失或非法时回退到1920 × 1080(常量DEFAULT_WIDTH/DEFAULT_HEIGHT定义于 webgl.ts)。compositionId的解析顺序是config.compositionId→ 根元素data-composition-id→"main"。 - 强调色三档化:单个
accentColor会在内部被推导成三档 RGB 用于片元着色器 uniformu_accent、u_accent_dark(约乘 0.35)与u_accent_bright(约1.5x+0.2后截断到 1)。未提供时使用默认橙色三档[1, 0.6, 0.2]/[0.4, 0.15, 0]/[1, 0.85, 0.5]。 - 预览采样速率:
previewCaptureFps默认 30,并在取值上被钳制到1 ~ 60之间;非法值(NaN/非正数)回退到默认值。 - WebGL 画布:包会在组合根元素(或
body)下创建一个id="gl-canvas"的透明覆盖 canvas,样式为position:absolute; top:0; left:0; z-index:100; pointer-events:none,尺寸与组合一致;WebGL 上下文开启preserveDrawingBuffer以便后续读取。 - 着色器程序:所有用到的着色器会按名称去重编译并缓存到
programsMap;单个着色器编译失败只打印[HyperShader] Failed to compile ...,不影响其他转场。 - 着色器间插值:由于捕获帧是离散的,两个相邻采样帧之间由包内置的一个
mix(texture2D(u_a), texture2D(u_b), u_mix)混合程序(blend program)做线性插值,配合纹理交错机制保证预览中的转场依然连续。
3.3 组合到已有时间线
如果你的页面已经有自己的 GSAP 时间线(例如已写好每个场景的进入/退出动画),可以把时间线传入,转场会被"叠"到上面而不是另起炉灶:
import { init } from "@hyperframes/shader-transitions"; import { gsap } from "gsap"; const tl = gsap.timeline({ paused: true }); // ... add your scene animations ... init({ bgColor: "#000", scenes: ["intro", "demo", "outro"], transitions: [ { time: 5, shader: "cinematic-zoom" }, // duration/ease 走默认值 { time: 12, shader: "glitch", duration: 0.5 }, ], timeline: tl, });传入timeline时,包不会把返回值重新注册到全局window.__timelines[compositionId],注册只发生在"由包自建时间线"的情况下(见 hyper-shader.ts 的registerTimeline())。
四、TransitionConfig与SHADER_NAMES
每个转场由TransitionConfig描述:
| 选项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
time | number | — | 转场开始时间(秒) |
shader | ShaderName | — | 上表中的着色器名。注意源码类型中它是可选的:省略(undefined)时该转场退化为 CSS 交叉淡入淡出,不依赖 WebGL |
duration | number | 0.7 | 转场持续时长(秒) |
ease | string | "power2.inOut" | GSAP 缓动函数 |
默认值0.7与"power2.inOut"在两个模式(浏览器预览、引擎确定性渲染)和元数据写入中共用同一组常量(DEFAULT_DURATION/DEFAULT_EASE),保证"预览里怎么动、引擎 seek 时怎么动、producer 读取元数据时按什么参数合成"三者完全一致。
SHADER_NAMES导出全部着色器名字符串数组,可用于参数校验或构建下拉 UI:
import { SHADER_NAMES } from "@hyperframes/shader-transitions"; // ["domain-warp", "ridged-burn", "whip-pan", ...]它在源码中由注册表对象的键推导而来:ShaderName = keyof typeof shaders,查找未知名称会抛出[HyperShader] Unknown shader: "xxx". Available: ...(见 registry.ts)。
4.1 可用着色器一览
| 着色器 | 描述 |
|---|---|
domain-warp | 基于噪声的有机扭曲,带发光边缘 |
ridged-burn | 脊状(ridged)噪声灼烧,带火花与热辉光 |
whip-pan | 水平运动模糊,模拟快速甩镜 |
sdf-iris | 圆形光圈擦除(iris wipe),带发光环边 |
ripple-waves | 从中心辐射的同心涟漪扭曲 |
gravitational-lens | 引力透镜式扭曲,带色差 |
cinematic-zoom | 径向缩放模糊,带色边 |
chromatic-split | 从中心向外的 RGB 通道分离 |
glitch | 数字故障,块状位移 + 扫描线 |
swirl-vortex | 基于噪声扭曲的螺旋旋转 |
thermal-distortion | 从画面底部升腾的热浪扰动 |
flash-through-white | 闪白后揭示下一场景 |
cross-warp-morph | 噪声驱动的双场景形变混合 |
light-leak | 暖色电影漏光 + 镜头光晕 |
4.2 共享着色器基础设施
所有片元着色器并非凭空独立,而是拼接自公共头部,理解这一点有助于二次开发:
- 顶点着色器把
a_pos(-1..1的四边形)映射为v_uv并做 Y 轴翻转,适配 WebGL 坐标系(见 common.ts); - 每个片元着色器头部(
H常量)统一声明了这些 uniform:u_from/u_to(出境/入境两张纹理)、u_progress(0→1 进度)、u_resolution(分辨率)、u_accent/u_accent_dark/u_accent_bright(三档强调色); - 需要噪声的着色器会拼入
NQ常量——基于 hash 的 value noise + 五次平滑插值 + 旋转各向异性的 5 层 FBM。
也就是说,"纹理对(u_from、u_to)+ 一个进度标量"是所有转场的通用数据契约;gl_FragColor的写法是"按进度/噪声混合两帧,再叠加强调色效果"。均匀值由 webgl.ts 的renderShader()统一写入(采样器绑定 TEXTURE0/TEXTURE1,进度、分辨率、三档颜色逐一uniform*),程序与 uniform 位置都做了缓存以省去重复查询。
五、运行架构:预捕获 → 着色器合成 → GSAP 时间线
浏览器预览模式下,一次转场周期的数据流如下(对应init()后半段逻辑,见 hyper-shader.ts):
- 初始化时按
scenes.length === transitions.length + 1校验(例如 3 个场景配 2 个转场),违反会直接抛错;随后逐个检查场景 ID 存在于 DOM 中且带.sceneclass,两类问题会分别抛出清晰错误(scene ids not found in DOM: .../elements found but missing .scene class: ...)。 - 为每个转场预编译对应的 GLSL 程序。
- 转场前开始预捕获:对出境场景与入境场景按
previewCaptureFps采样动画帧。捕获结果先以 PNG Blob 形式写入 IndexedDB(见第六节),播放前按需把 Blob 解码成ImageBitmap/Image再上传为 WebGL 纹理。 - 播放进入转场窗口时,
u_progress随时间线推进(映射到duration与ease),着色器每帧对两张纹理做混合,结果直接绘制到覆盖在页面上的gl-canvas。 - 播放经过转场窗口后,隐藏 GL 画布,露出持续推进中的入境场景 DOM,画面无缝衔接。
整个过程里,init()返回的GsapTimeline才是组合时间线的"真相来源"——无论转场是着色器合成还是 CSS 交叉淡入淡出,都可以被暂停、seek、play,与 HyperFrames 的既有播放体系兼容。
5.1 WebGL 不可用与 CSS 交叉淡入淡出降级
两条独立的降级路径需要分清:
- 无 WebGL 环境:
createContext()返回空,init()打印警告并直接返回普通时间线,转场退化为硬切,场景动画完全正常。 - 某项转场未指定
shader(shader === undefined):该转场以 CSS opacity 交叉淡入淡出执行,不需要 WebGL。在引擎渲染模式下,这样的条目会被安排成真实的 opacity tween(详见第七节),保证单帧截图里就包含正确的混合结果。
六、场景捕获管线:从 html2canvas 到原生 HTML-in-Canvas
"把 DOM 场景变成纹理"是整套方案最脆弱也最关键的部分。包支持两条捕获路径,策略代码见 capture.ts。
- 原生 HTML-in-Canvas(首选):当浏览器暴露 Chrome 实验性的 CanvasDrawElement API 时,使用
layoutSubtreecanvas +drawElementImage()直接绘制 DOM。实现细节包括:把场景克隆进一个position:fixed; z-index:-9999; opacity:0的 layoutsubtree canvas,等待两个requestAnimationFrame让浏览器完成布局/绘制,用bgColor填充底色,再drawElementImage读出画面并复制到结果 canvas。该路径失败时自动回退到 html2canvas。 - html2canvas 回退:
html2canvas抓取时为避免 Safari 的画布污染(SecurityError: The operation is insecure),固定开启useCORS: true与allowTaint: true。这里有一个值得注意的工程取舍:tainted canvas 无法被gl.texImage2D上传(WebGL 规范强制 SecurityError),所以allowTaint的实际作用是把"失败点"从 html2canvas 内部挪到更可控的纹理上传处,由调用方统一兜底。此外还提供foreignObjectRendering尝试开关(失败自动回退到常规渲染)、onclone中把带 transform 的box-shadow抽取成独立 shim 元素(因为变换会破坏阴影栅格化)、强制克隆场景可见等处理。
用isHtmlInCanvasCaptureSupported()可以自行做特性检测(源码判定"存在layoutSubtree属性 + 2D 上下文具备drawElementImage函数"),对应测试见 capture.test.ts(验证了非浏览器环境返回 false、能力齐备返回 true、缺drawElementImage返回 false 三种情况)。
另外,捕获对零尺寸 pattern 有个 Safari 相关防御补丁:重写CanvasRenderingContext2D.prototype.createPattern,当传入 0×0 的 canvas 时返回null而不是让浏览器抛错(initCapture())。
6.1 浏览器预览快照的 IndexedDB 缓存
每次刷新页面都重新捕获几十上百帧显然不划算,因此浏览器预览的快照会被持久化:
- 数据库名为
hyper-shader-preview-cache,object store 名为frames,schema 版本v1(常量见 hyper-shader.ts)。 - 缓存键由composition ID、场景 DOM/样式签名、转场时序、捕获 FPS、缩放与画布尺寸综合推导。DOM/样式签名不是简单 hash:它基于文档内所有
<style>文本、<link rel=stylesheet>及脚本特征的stableHash,场景自身的签名还会在计算前剔除播放过程中被运行时改写的opacity/visibility/pointer-events等内联样式,从而让缓存身份追踪"作者写的内容"而非"上次预览的播放头状态"。 - 刷新后若键匹配,快照直接加载为 WebGL 纹理,不再重捕。
- 运行中编辑场景或样式表时,只会把相邻转场的缓存标记为 dirty,重捕推迟到真正播放到那个转场时才发生,保证编辑器操作期间交互不卡顿。
- 缓存总量上限为 1200 条(
MAX_SNAPSHOT_CACHE_ENTRIES),写入时按updatedAt淘汰最旧条目并清理当前 composition 的失效键。
6.2 预捕获阶段的加载反馈
首次播放前的快照准备可能需要一段时间,包内置了一套全屏加载反馈:覆盖层包含品牌图形、进度短语与逐 transition / 逐 frame 的进度数字,短语按进度切换("Preparing scene transitions"、"Sampling outgoing scene motion" 等)。该覆盖层带data-hyperframes-ignore、data-no-capture、data-no-pick等标记,确保它不会污染捕获与拾取逻辑。
播放器接管 vs 内置加载 UI:当页面由<hyperframes-player>承载时,浏览器预览的捕获缩放与转场预加载 UI 的所有权归属播放器(对应属性shader-capture-scale、shader-loading),而不是组合代码;非播放器的直接预览则保留内置的全保真加载兜底。实现上,捕获缩放系数读取全局变量__HF_SHADER_CAPTURE_SCALE或查询参数__hf_shader_capture_scale(解析后钳制在0.25 ~ 1,默认 1),加载模式读取__HF_SHADER_LOADING或__hf_shader_loading,取值player/true→ 播放器接管、none/false/off→ 关闭、其余 →internal内置覆盖层。
七、引擎渲染模式:确定性逐帧输出
浏览器里人眼看 30fps 预捕获足够,但视频渲染(引擎)要求每一帧都精确确定。init()会探测window.__HF_VIRTUAL_TIME__标记(引擎在渲染模式注入的虚拟时间 shim,见 hyper-shader.ts),一旦检测到就切换到initEngineMode(),完全跳过所有 GL / canvas / html2canvas 分支,只构建一条确定性的"透明度翻转"时间线:
- 非首场景初始全部
opacity: 0(用tl.set(..., 0)挂进时间线开头,保证逆向 seek 也能恢复正确初态)。 - 对着色器转场:转场窗口内from/to 两场景都保持
opacity: 1,出境场景在time + duration时刻降到 0——这样引擎的 Node 端分层合成器能分别独立捕获两场景再自行混合。 - 对 CSS 交叉淡入淡出:安排真实的 opacity tween(
fromTo),保证单帧页面截图本身已包含正确的混合结果。 - 使用
tl.set()(零时长 tween)而不是tl.call(),因为tl.call只在运动方向上触发,引擎 warmup 会正向 seek 到各转场起点、随后又反向 seek 回 t=0,回调态会卡住而set可随反向 seek 正确还原。
引擎读取合成的依据是init()同步写入window.__hf.transitions的元数据数组(每项含time、duration、shader、ease、fromScene、toScene,缺省 duration/ease 时同样使用 0.7 /power2.inOut)。该结构刻意在包内本地重声明(不 import engine 的类型)以保持 CDN 独立,并与 engine 的HfTransitionMeta保持同步(注释中明确说明)。
7.1 可选的页面端合成器(engine-mode page compositing)
当 producer 以EngineConfig.enablePageSideCompositing: true启动并注入window.__HF_PAGE_SIDE_COMPOSITING__哨兵时,引擎模式还会安装一个页面端 WebGL 合成器(installPageSideCompositor(),导出见 index.ts,实现见 engineModePageComposite.ts),让"单次整页截图"也能得到与预览路径一致的原生保真捕获。它采用两阶段协议:
- Phase 1(seek 包装):包装
window.__hf.seek。进入转场窗口时,把 FROM/TO 场景克隆进两个常驻的 layoutsubtree staging canvas,并设window.__hf_page_composite_pending。 - Paint force(引擎侧):引擎检测到 pending 标记后触发一次微型的
Page.captureScreenshot,强制浏览器合成器把 staging canvas 的克隆绘制出来。 - Phase 2(resolve):引擎调用
window.__hf_page_composite_resolve,用drawElementImage从已绘制克隆读出画面、上传纹理、跑着色器并显示 GL 覆盖层,最后清理 staging。
克隆时会把各自getBoundingClientRect()实测到的盒模型left/top/width/height固定到克隆上——这是为了规避"仅靠inset:0定位的场景克隆进 layout subtree 后坍缩成 0×0"的已知问题;同时强制克隆可见、解码 contenteditable="false">【免费下载链接】hyperframesWrite HTML. Render video. Built for agents.项目地址: https://gitcode.com/GitHub_Trending/hy/hyperframes
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考