Skip to content

这是一个 SVGA 在移动端 Web 上的播放器,它的目标是 更轻量更高效

性能优化

渲染引擎运行在 WebWorker

目标: 将主要的渲染逻辑迁移到 WebWorker 中运行,以减轻主线程的负担,提高复杂动画播放时的 UI 响应性和流畅性。核心思路是利用 OffscreenCanvas 将Canvas的控制权转移给 Worker。

技术方案概要:

  1. render worker实现:
    • 完整渲染逻辑移植:将 render.ts 的全部功能移植到 Render Worker,包括:
      • 位图绘制(sprites + ImageBitmap)
      • 矢量图形绘制(shapes: bezier/ellipse/rect)
      • 动态元素支持(dynamicElements + replaceElements)
      • 遮罩路径(maskPath)和变换(transform)
    • 消息协议实现:
      • init:初始化 OffscreenCanvas、videoData、位图缓存、动态元素
      • play/pause/stop/resume:动画的操作协议,将这些动作收敛至worker里,主线程只做协议派发
      • resize:调整画布尺寸
      • updateDynamic:更新动态元素和替换元素
      • destroy:清理资源并关闭 Worker
      • setConfig: 同setConfig方法的功能差不多,主要用来设置帧缓存
    • 错误处理和回退机制:
      • 检测 OffscreenCanvas 和 transferControlToOffscreen 支持
      • Worker 错误自动回退到主线程渲染
      • 代码注入失败时的优雅降级
    • 主线程集成:
      • 帧渲染同步(Animator → Worker)
      • 动态元素更新 API(updateDynamicElements)
      • 资源清理和内存管理

预期效果: 通过将渲染任务移至 WebWorker,可以显著降低主线程的负载,尤其是在播放复杂的 SVGA 动画时,从而改善用户界面的响应速度,减少掉帧,提升整体用户体验。然而,这也带来了额外的复杂性和通信开销,需要在具体实现中仔细权衡。

使用 WebAssembly (WASM) 替代 WebWorker 进行 SVGA 解析

目标: 利用 WebAssembly 的高性能特性来替代当前基于 JavaScript 的 WebWorker 进行 SVGA 文件核心解析任务(特别是 Protobuf 解码部分),以期提高解析速度,缩短动画加载时间。

背景: 当前 SVGA 解析器在 WebWorker 中运行,主要包含以下步骤:文件获取、Zlib 解压缩、Protobuf 解码(使用 protobufjs)以及图像数据预处理。其中,Protobuf 解码对于复杂的 SVGA 文件可能是 CPU 密集型操作。

技术方案概要 (Rust + prost + wasm-bindgen):

  1. SVGA .proto 文件定义:

    • 关键前提: 需要一份准确的 SVGA 格式的 .proto (Protocol Buffers schema) 文件。目前项目中的 svga-proto.ts 是 TypeScript 定义,可以作为参考来创建或验证 .proto 文件。
    • 详情可以查阅:SVGA-Format
  2. Rust 解析库:

    • svga_parser_wasm
    • 依赖:
      • prost: 用于处理 Protobuf 编译和运行时。
      • prost-build: 在 build.rs 中用于根据 .proto 文件生成 Rust 代码。
      • wasm-bindgen: 用于生成 Rust 和 JavaScript 之间的桥接代码。
      • miniz_oxide: 用于处理 Zlib 解压缩。
    • 代码生成: 在 build.rs 中配置 prost-build,使其在编译时根据 .proto 文件生成对应的 Rust 结构体和解析逻辑。
    • 核心解析函数:
      • 创建一个或多个 pub Rust 函数,并使用 #[wasm_bindgen] 宏使其可以被 JavaScript 调用。
      • 该函数应接收原始的 SVGA 文件数据(作为一个字节切片 &[u8])作为输入。
      • 函数内部使用 prost 生成的代码将字节切片解码为 Rust 结构体。
      • 将解析得到的 Rust 结构体转换为 JavaScript 对象。wasm-bindgen 可以直接转换许多简单结构体;对于复杂嵌套结构或需要精确匹配 VideoEntity 的场景,可能需要手动映射或使用 serdeserde-wasm-bindgen
      • Rust 函数返回这个 JavaScript 对象。错误(如解析失败)应被捕获并转换为 JavaScript Error 对象抛出。
  3. WASM 模块构建与集成:

    • 使用 wasm-pack build --target web (或 bundler/nodejs) 来编译 Rust crate。这将生成一个 .wasm 文件和相应的 JavaScript 胶水代码。
    • 运行环境: 为了避免阻塞主线程,WASM 解析模块本身仍应在一个 WebWorker (下称 "WASM Worker") 中加载和执行。即,用 "WASM in Worker" 替换 "JS in Worker" 的核心解析部分。
    • WASM Worker:
      • 负责加载 .wasm 文件和其 JS 胶水代码。
      • 接收主线程传来的原始 SVGA 文件数据(ArrayBuffer)。
      • 调用 WASM 导出的解析函数。
      • 将 WASM 返回的 JS 对象发送回主线程。
    • 主线程 (Parser 类):
      • 修改 Parser 类,使其创建一个 WASM Worker 而不是当前的 JS Worker (如果 isDisableWebWorkerfalse)。
      • 将获取到的 SVGA 文件 ArrayBuffer 发送给 WASM Worker。
      • 接收 Worker 返回的已解析的 JS 对象 (类似 VideoEntity)。
  4. Zlib 解压缩和图像处理:

    • Zlib 解压缩:
      • 在 Rust 代码中使用 miniz_oxide 等库进行解压缩
    • 图像数据处理 (ImageBitmap):
      • WASM 解析模块返回的应是包含图像元数据(如文件名、base64 编码的图像数据)的结构化对象。
      • ImageBitmap 的创建(从 base64 或其他图像源)涉及浏览器 API,应在数据从 WASM Worker 返回到主线程后,在主线程的 Parser 或 Player 的 mount 阶段进行,与当前逻辑类似。或者,可以在 WASM Worker 的 JS 侧完成,如果 createImageBitmap 在 Worker 中可用且高效。
  5. 与现有 Parser 的对比:

    • 优点:
      • Protobuf 解码速度可能大幅提升,特别是对于大型或结构复杂的 SVGA 文件。
      • 可能减少内存抖动,因为 WASM 操作的是线性内存。
    • 缺点:
      • 构建复杂性: 引入 Rust 和 wasm-pack 到项目构建流程。
      • .proto 文件依赖: 强依赖于准确的 .proto 文件。
      • 初始加载: WASM 文件(目前优化大概在80KB)需要额外加载。
      • 胶水代码: JS 与 WASM 之间的数据转换和函数调用会产生一些开销,但通常远小于纯 JS 解析的开销。

预期效果: 对于解析性能敏感的应用,使用 WebAssembly 进行核心 Protobuf 解码有望带来显著的速度提升。这将直接改善用户首次加载和解析 SVGA 动画的体验。然而,需要仔细评估其对项目构建流程、代码复杂性和最终包大小的综合影响。

注意项

浏览器兼容性:

  • wasm解析支持 chromium >= 95, 覆盖公司app 95%以上的用户
    • 当前用了reference types,提升wasm和js的通信性能,低版本不支持
  • 当前做了兼容逻辑, 低版本退回js解析,最低支持chromium >= 70

TODO

当前优化遗留的坑填补

TypeScript类型补全

  • 将代码里的any类型尽可能消除,前期实现阶段懒得写ts,后续优化.

实现WASM管理器

  • 可能是wasm manage 又或者是 global worker fetch wasm,主要解决现在每次new Parser执行initWasm时加载wasm模块

WebGL 渲染初步设计(位图优先策略)

目标: WebGL 做位图复合渲染,逐步替代部分 2D 绘制,降低 CPU 与主线程压力,提高多实例场景下的稳定帧率。

阶段划分

  • 阶段 1(位图 GPU 复合):
    • drawImage(bitmap, ...) 的路径切换为 WebGL 纹理绘制(一个四边形+变换矩阵+采样)。
    • 纹理管理:复用、按需释放,避免重复上传;可选图集(同尺寸合并)。
    • 蒙版与透明叠加:使用混合或模板缓解开销。
  • 阶段 2(矢量形状栅格缓存):
    • 对不频繁变化的路径/描边,先离屏 2D 光栅化成纹理,后续帧复用纹理绘制。
    • 仍保留 2D 作为兜底路径(复杂 clipPath 等)。
  • 阶段 3(矢量 GPU 化,可选):
    • 形状三角化与描边剖分,使用 WebGL 绘制路径(工程量较大,收益需验证)。

API/集成

  • 与 Render Worker 集成:WebGL 上下文在 Worker 内创建(OffscreenCanvas.getContext('webgl'))。
  • 与现有管线兼容:维持 VideoEntitybitmapsCache 作为输入;优先让位图走 WebGL,矢量与文本暂留 2D。
  • 回退:若 WebGL 不可用,自动回退到 2D。

内存与性能策略

  • 纹理池:LRU 或固定上限,按需回收;避免峰值纹理内存过高。
  • 批处理:同纹理或同状态合并 draw call;减少绑定与切换。
  • 预热:在空闲时预创建 GL 资源与着色器,降低首帧抖动。

风险与回退

  • 部分 WebView 的 WebGL 稳定性与驱动差异;需监控异常,动态回退 2D。
  • 体积:首次引入 WebGL 管线保持最小实现,仅覆盖位图复合路径。

目前进度

  • 特定分支实现了
  • 目前可能是实现逻辑有问题,功能对齐,但是在基准机的性能比未实现webgl前要差,比官方库要好

差异

  • 不支持播放 SVGA 1.x 格式
  • 不支持声音播放

使用

简单使用

html
<canvas id="canvas"></canvas>
js
// 引入wasm,临时方案,后续增加作为Parser的配置项引入,或者不传,会有兜底链接
window.SVGA_WASM_URL = './svga_wasm_parser_bg.wasm'

import { Parser, Player } from 'svga'

const parser = new Parser()
const svga = await parser.load('xx.svga')

const player = new Player(document.getElementById('canvas'))
await player.mount(svga)

player.onStart = () => console.log('onStart')
player.onResume = () => console.log('onResume')
player.onPause = () => console.log('onPause')
player.onStop = () => console.log('onStop')
player.onProcess = () => console.log('onProcess', player.progress)
player.onEnd = () => console.log('onEnd')

// 开始播放动画
player.start()

// 暂停播放动画
// player.pause()

// 继续播放动画
// player.resume()

// 停止播放动画
// player.stop()

// 清空动画
// player.clear()

// 销毁
// parser.destroy()
// player.destroy()

ParserConfigOptions

ts
new Parser({
  // 是否取消使用 WebWorker,默认值 false
  isDisableWebWorker: false,

  // 是否取消使用 ImageBitmap 垫片,默认值 false
  isDisableImageBitmapShim: false
})

PlayerConfigOptions

ts
const enum PLAYER_FILL_MODE {
  // 播放完成后停在首帧
  FORWARDS = 'forwards',
  // 播放完成后停在尾帧
  BACKWARDS = 'backwards'
}

const enum PLAYER_PLAY_MODE {
  // 顺序播放
  FORWARDS = 'forwards',
  // 倒序播放
  FALLBACKS = 'fallbacks'
}

new Player({
  // 播放动画的 Canvas 元素
  container?: HTMLCanvasElement

  // 循环次数,默认值 0(无限循环)
  loop?: number | boolean

  // 最后停留的目标模式,默认值 forwards
  // 类似于 https://developer.mozilla.org/en-US/docs/Web/CSS/animation-fill-mode
  fillMode?: PLAYER_FILL_MODE

  // 播放模式,默认值 forwards
  playMode?: PLAYER_PLAY_MODE

  // 开始播放的帧数,默认值 0
  startFrame?: number

  // 结束播放的帧数,默认值 0
  endFrame?: number

  // 循环播放开始的帧数,可设置每次循环从中间开始。默认值 0,每次播放到 endFrame 后,跳转到此帧开始循环,若此值小于 startFrame 则不生效
  // 类似于 https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode/loopStart
  loopStartFrame?: number

  // 是否开启缓存已播放过的帧数据,默认值 false
  // 开启后对已绘制的帧进行缓存,提升重复播放动画性能
  isCacheFrames?: boolean

  // 是否开启动画容器视窗检测,默认值 false
  // 开启后利用 Intersection Observer API 检测动画容器是否处于视窗内,若处于视窗外,停止描绘渲染帧避免造成资源消耗
  // https://developer.mozilla.org/zh-CN/docs/Web/API/Intersection_Observer_API
  isUseIntersectionObserver?: boolean

  // 是否使用避免执行延迟,默认值 false
  // 开启后使用 `WebWorker` 确保动画按时执行(避免个别情况下浏览器延迟或停止执行动画任务)
  // https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API#Policies_in_place_to_aid_background_page_performance
  isOpenNoExecutionDelay?: boolean
})

替换元素 / 插入动态元素

可通过修改解析后的数据元,从而实现修改元素、插入动态元素功能

js
const svga = await parser.load('xx.svga')

// 替换元素
const image = new Image()
image.src = 'https://xxx.com/xxx.png'
svga.replaceElements['key'] = image

// 动态元素
const text = 'hello gg'
const fontCanvas = document.getElementById('font')
const fontContext = fontCanvas.getContext('2d')
fontCanvas.height = 30
fontContext.font = '30px Arial'
fontContext.textAlign = 'center'
fontContext.textBaseline = 'middle'
fontContext.fillStyle = '#000'
fontContext.fillText(text, fontCanvas.clientWidth / 2, fontCanvas.clientHeight / 2)
svga.dynamicElements['key'] = fontCanvas

await player.mount(svga)

DB

利用 IndexedDB 进行持久化缓存已下载并解析的数据元,可避免重复消耗资源对相同 SVGA 下载和解析

js
import { DB } from 'svga'

try {
  const url = 'xx.svga'
  const db = new DB()
  let svga = await db.find(url)
  if (!svga) {
    // Parser 需要配置取消使用 ImageBitmap 特性,ImageBitmap 数据无法直接存储到 DB 内
    const parser = new Parser({ isDisableImageBitmapShim: true })
    svga = await parser.load(url)
    await db.insert(url, svga)
  }
  await player.mount(svga)
} catch (error) {
  console.error(error)
}

TypeScript 声明 SVGA 文件

ts
// global.d.ts
declare module '*.svga'

Webpack SVGA

SVGA 文件可用 url-loader 配置 Webpack 进行打包构建,例如:

js
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /\.svga$/i,
        use: 'url-loader'
      }
    ]
  }
}

// js
import { Parser } from 'svga'
import xx from './xx.svga'
const parser = new Parser()
const svga = await parser.load(xx)

Vite SVGA

SVGA 文件可通过配置 Vite 作为 静态资源 打包构建,例如:

js
// vite.config.ts
export default defineConfig({
  assetsInclude: [
    'svga'
  ]
})

// js
import { Parser } from 'svga'
import xx from './xx.svga?url'
const parser = new Parser()
const svga = await parser.load(xx)

环境要求

Node.js v16.x

sh
# 安装依赖
yarn install

# 开发 & 测试
yarn test

# 构建
yarn build