github-gravity:用 Rapier2D 把 GitHub 贡献图做成 SVG 物理动画

github-gravity 是一个 GitHub Action,也可以当库用。给它一个用户名,它会把这一年的贡献格子变成刚体,丢进 2D 物理世界,和一行自定义文字撞在一起,最后导出成一条 SMIL 动画 SVG。

在线试玩:flycran.github.io/github-grav...

生成效果:raw.githubusercontent.com/flycran/git...

代码量不大,大部分是实现层面的格式转换。下面按数据流把当前 v2 的实现过一遍。真正值得单独写一节的只有一件事:原始轨迹直接导出会让 SVG 大到没法用,所以中间加了一层带时间约束的轨迹压缩。


一、用法

它就是一个普通的 Action step:

yaml 复制代码
name: Generate Contribution Gravity

on:
  workflow_dispatch:
  schedule:
    - cron: '0 0 * * *'
  push:
    branches: [main]

jobs:
  gravity:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - name: Generate Gravity SVG
        uses: flycran/github-gravity@v2
        with:
          username: flycran
          text: GitHub Gravity
          output-path: deploy/gravity.svg

      - uses: peaceiris/actions-gh-pages@v4
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./deploy
          publish_branch: output
          keep_files: false
          force_orphan: true

username 是必填项,其余参数都有默认值。生成的文件在 deploy/gravity.svg,推送到 output 分支后可以这样嵌进 README:

markdown 复制代码
![Gravity Animation](https://raw.githubusercontent.com/<用户名>/<仓库>/output/gravity.svg)

不想配 Action 的话,在线页面也能直接调参、上传字体、生成并播放,顺带会显示文件大小和耗时。页面和 Action 用的是同一份 gensvg,只是前者跑在浏览器里。


二、生成流程

整个流程可以分成四步:

markdown 复制代码
GitHub 贡献数据
      │
      ▼
① 布局:每个有贡献的格子生成一个刚体
      │
      ▼
② 模拟:Rapier2D 跑物理,文字作为静态碰撞体
      │
      ▼
③ 录制:逐步记录每个方块的位置
      │
      ▼
④ 压缩导出:轨迹压缩 → SMIL animateTransform → SVG

前两步决定画面对不对,后两步决定文件能不能用。


三、物理世界和贡献方块

贡献数据来自 gh-calendar,按周分组返回每天的日期、贡献次数和 0~4 的强度等级。强度等级直接映射到 GitHub 官方那套绿色:

ts 复制代码
export const INTENSITY_COLORS = [
  '#ebedf0', // 0
  '#9be9a8', // 1
  '#40c463', // 2
  '#30a14e', // 3
  '#216e39'  // 4
]

物理引擎是 Rapier2D,Rust 物理引擎的 WASM 构建。世界参数很简单:重力 -30,底部一张地板,左右各一堵墙。

ts 复制代码
const world = new World({ x: 0.0, y: -30 })
world.lengthUnit = 32

布局上横轴是周、纵轴是星期几。每个 count > 0 的格子生成一个动态刚体,放在世界顶部或之上,让它自己落下来:

ts 复制代码
const x = left + (interval + size) * rowIndex   // 周
const y = top + (interval + size) * dayOfWeek   // 星期几

有两个细节。

一是刚体要开 CCD。方块很小,下落速度不低,纯离散碰撞检测下,高速方块有可能在一帧内直接穿过文字。连续碰撞检测能避免这种穿模:

ts 复制代码
RigidBodyDesc.dynamic().setCcdEnabled(true).setTranslation(x, y)

二是默认用圆形而不是方形。圆形的接触计算更稳,堆叠时不容易互相卡住,也不容易穿模。想要像素风可以切到 shape: square,但物理表现会差一些。

模拟什么时候停?每步之后检查所有刚体的线速度和角速度,全部低于阈值就结束:

ts 复制代码
const VELOCITY_THRESHOLD = 1e-3
if (Math.abs(linvel.x) > VELOCITY_THRESHOLD ||
    Math.abs(linvel.y) > VELOCITY_THRESHOLD ||
    Math.abs(angvel) > VELOCITY_THRESHOLD) {
  return false
}

默认每步 8ms,动画时长就是 步数 × 8ms。


四、把文字变成碰撞体

这一段主要是格式转换,没什么技巧,但坑不少。SVG 里的文字是矢量路径,物理引擎认的却是顶点和三角形索引。管线是这样:

bash 复制代码
字体文件 (.ttf/.otf)
   │ opentype.js
   ▼
字形轮廓 Path(贝塞尔曲线)
   │ points-on-path 采样
   ▼
多边形顶点
   │ earcut 三角化
   ▼
trimesh 碰撞体

对应代码:

ts 复制代码
function svgPathToRapierTrimesh(path: string, scale = 1, tolerance = 1) {
  const subPaths = pointsOnPath(path, tolerance)
  const points = subPaths.flat()
  const flatVertices = points.map(([x, y]) => [x * scale, y * scale]).flat()
  const indices = earcut(flatVertices)

  return {
    vertices: new Float32Array(flatVertices),
    indices: new Uint32Array(indices)
  }
}

然后用 ColliderDesc.trimesh(...) 把三角网格挂到静态刚体上,文字整体按包围盒居中,纵向位置由 text-top 控制。

坐标系这里要翻两次。opentype.js 的字形坐标相对基线,向下的 y 为负;物理世界是 y 向上为正;SVG 又是 y 向下为正。所以进物理引擎前翻一次:

ts 复制代码
flippedVertices[i + 1] = -vertices[i + 1]

导出时再翻回来:

ts 复制代码
svgY = (height - y) * SCALE

三个坐标系来回转换,调错一次够查半天。我在这个项目里花在坐标系上的时间,大概和写物理模拟差不多。

默认字体用 Liberation Serif Bold,不是出于审美,而是它的轮廓闭合规整,三角化不会报错。自定义字体基本是薛定谔的兼容性:浏览器能渲染,不代表 earcut 能接受。常见的情况有三类:

  • 轮廓未闭合或自相交。浏览器渲染时会静默忽略,earcut 会直接抛错。
  • opentype.js 解析不了。比如 DejaVu 系列会卡在 GSUB 表上。
  • 字体度量不同。换字体后通常要重新调 font-size 和 text-top。

五、录制轨迹并导出 SVG

模拟跑完之后,每个方块手里都有一条完整轨迹:

ts 复制代码
update() {
  const pos = this.collider.translation()
  this.x = pos.x
  this.y = pos.y
  this.trajectory.push({ x: pos.x, y: pos.y })
}

导出时每个方块变成一个 <g>,里面是形状加一条 <animateTransform>:

xml 复制代码
<g data-date="2026-10-29">
  <circle r="6" fill="#40c463" />
  <animateTransform
    attributeName="transform"
    attributeType="XML"
    type="translate"
    values="450,450;451,448;...;453,12"
    dur="7872ms"
    fill="freeze"
  />
</g>

values 是轨迹点拼成的坐标串,dur 是总时长,fill="freeze" 让动画停在最后一帧。

到这一步功能已经完整了。但直接把原始轨迹写进去,文件体积会失控。这是整个项目里唯一需要认真处理的问题。


六、轨迹压缩

6.1 为什么需要压缩

update() 是每个物理步记一次。默认步长 8ms,方块从落到弹跳再到整体静止,整个模拟往往要跑几百到上千步。stepCount 是所有方块共用的,所以每个方块的轨迹长度都等于它。

按 1000 步、300 个有贡献的方块估算:

perl 复制代码
300 个方块 × 1000 步 = 300,000 个轨迹点
每个点导出成 "453.00,412.00;" 约 14 字节
仅 values 属性就有 ≈ 4 MB

这还没算 300 套 <g>、<circle>、<animateTransform> 的标签开销。一个"图片"膨胀到几 MB,放进 README 显然不合适:GitHub 要先下载并解析整段 XML 才能播放,弱网和移动端首屏会明显变慢,仓库和 CDN 流量也白白浪费;有些平台还会限制单文件大小。

但问题不只是文件大,而是这些点多得没有意义。物理引擎为了保证碰撞稳定,被迫把步长压得很小;动画播放却不需要这个采样率。一段匀速直线运动,两个端点就能精确还原;一段静止,也只需要首尾。轨迹里真正携带信息的,只有速度发生变化的时刻:开始下落、撞上文字、弹起、再次落下、最终停稳。

所以这里不能简单抽帧(每隔 N 步留一个点),抽帧会同时破坏轨迹形状和播放节奏。要做的是在误差可控的前提下保留关键帧,并且保证被删掉的点在播放时能准确还原。

至于"准确"怎么定义,是下一步的事。

6.2 误差怎么定义

最直接的想法是删掉"看起来在一条直线上"的点,但这样会错。看这个例子:

scss 复制代码
(0,0) → (1,1) → (3,3)

三点共线。如果只保留首尾 (0,0) 和 (3,3),播放到中间时刻的位置是 (1.5,1.5),而真实位置是 (1,1)。位置偏了,速度也从"先慢后快"变成了匀速。

所以误差不能按几何距离算,要按时间算。项目的定义是:

对每一个被丢弃的原始时刻 i,用它前后两个关键帧 s、e 按时间比例做线性插值,插值位置与真实位置的欧氏距离不超过容差 tolerance。

ts 复制代码
function deviationSq(p: Point, s: Point, e: Point, t: number): number {
  const dx = p.x - (s.x + (e.x - s.x) * t)
  const dy = p.y - (s.y + (e.y - s.y) * t)
  return dx * dx + dy * dy
}

t = (i - s) / (e - s),即这一时刻在关键帧区间里的时间占比。

这个度量有两个性质:匀速直线段的插值误差恒为 0,会被完整合并;变速段的误差立刻变大,对应时刻会自动保留为关键帧。也就是说,它保的是位置和节奏,不只是形状。碰撞、落地、停顿这些速度突变都会留下痕迹。

6.3 三种算法

在"每个被丢弃点的时刻插值偏差不超过容差"这个约束下,问题变成用最少的点覆盖整条轨迹。项目实现了三种策略。

Greedy 是默认算法。从当前关键帧出发不断尝试延长段尾,直到再加一个点就会让段内某点超限,此时把当前段尾定为下一个关键帧。单遍、不回溯。

ts 复制代码
let s = 0
while (s < n - 1) {
  let e = s + 1
  while (e < n - 1 &&
         segmentMaxDeviationSq(trajectory, s, e + 1, limitSq) <= limitSq) {
    e++
  }
  keep[e] = true
  s = e
}

DP 把问题看成最优分段:cost[i] 表示起点到第 i 个点的最少段数,枚举前一个关键帧 j 求全局最优。这就是经典的 Imai--Iri 最优分段问题,关键帧数全局最少,因此体积最小。代价是端点对乘以段检查,复杂度随轨迹长度超线性增长,长轨迹会到秒级。

RDP 是时空版的 Ramer--Douglas--Peucker:递归分裂,每次找段内偏差最大的点作为新关键帧,直到所有点都落在容差内。最快,但贪心分裂不是全局最优,关键帧数量偏多。

README 里有一组实测数据,360 条真实轨迹,每条约 1000 点,默认容差 0.5:

算法 平均关键帧 压缩率 耗时 特点
Greedy(默认) 32.1 30.7x ~0.2s 单遍贪心,距最优仅 1~3%
DP 31.3 31.5x ~2s 全局最少关键帧,体积最小
RDP 37.1 26.6x ~40ms 最快,关键帧略多

Greedy 只比全局最优多 1~3% 的关键帧,却快了一个数量级,所以默认值没什么争议。只有对体积特别敏感、又能接受几秒生成时间时,才值得换 DP。

compression-tolerance = 0 是个特殊值:此时只有插值精确命中的匀速直线段和静止段会被合并,曲线上每个点都保留。位置和节奏完全无损,压缩率相应低一些。

6.4 用 keyTimes 还原时间

这是实现上最容易漏掉的一步。

SMIL 的 values 如果不配 keyTimes,关键帧之间的时间是均匀分配的。但压缩之后,相邻关键帧可能跨 1 个原始步,也可能跨 20 个。继续按均匀时间播放,方块会在压缩得狠的区段突然加速,节奏就毁了。

办法是给每个关键帧记录它距离上一个关键帧的原始步数 span:

ts 复制代码
export interface CompressedTrajectoryPoint {
  x: number
  y: number
  span: number  // 与上一个保留点之间的原始轨迹点数
}

导出时把 span 换算成累计时间比例,写进 keyTimes:

ts 复制代码
const total = trajectory.reduce((sum, p) => sum + (p.span ?? 0), 0)
const keyTimes = [0]
let acc = 0
for (const p of trajectory.slice(1)) {
  acc += p.span ?? 0
  const prev = keyTimes[keyTimes.length - 1]
  const t = +(acc / total).toFixed(6)
  // SMIL 要求严格递增,夹取避免小数舍入把相邻值舍成相同
  keyTimes.push(Math.min(Math.max(t, prev + 1e-6), 0.999999))
}
keyTimes[keyTimes.length - 1] = 1

这样每个关键帧仍落在原本的时间点上,压缩只减少采样点,不改变速度曲线。

这里还有个不太显眼的边界:SMIL 要求 keyTimes 严格递增。轨迹很长时,时间比例保留 6 位小数可能把相邻两个值舍成相同,部分渲染器会因此忽略整条动画。上面的夹取就是为这个准备的,末帧再强制设成 1,消掉累计浮点误差。


七、参数

常用的几个:

参数 默认值 说明
username 必填 GitHub 用户名
text 同 username 作为碰撞障碍的文字
font-size 80 文字大小
shape circle circle 更稳定,square 更像素风
text-top 50 文字距世界顶部的距离
text-color / background-color black / transparent 颜色
compression-tolerance 0.5 轨迹压缩容差(SVG 像素)
compression-algorithm Greedy Greedy / DP / RDP

另有一组高级参数:width、height、size、interval、font-path。这几个改的不是输出尺寸,而是物理世界本身:

  • width 同时决定方块横向布局、文字居中位置和最终 SVG 宽度;
  • height 同时决定下落高度、文字纵向位置和最终 SVG 高度;
  • size 变大,方块更容易互相挤压,甚至穿透文字;
  • interval 和 size 共同决定方格间距,改一个就要重新核对整体布局。

改一个参数往往要连带调其他几个,这点和调 CSS 有点像。文档里反复强调"成套调整",实际用下来确实如此。


八、几个容易踩的坑

自定义字体不能随便传。 font-path 支持本地 .ttf/.otf 或 http(s) 链接,但字体本身的轮廓质量、opentype.js 的解析能力、earcut 的容忍度都会影响结果。前面列的三类问题基本覆盖了大多数失败情况。默认字体选 Liberation Serif Bold,就是为了避开这些不确定性。

压缩容差的单位是 SVG 像素,不是物理单位。 内部会先除以 SCALE(3)换算回物理坐标,让压缩函数和渲染缩放解耦。调参时不用关心这个换算,但要知道容差对应的是最终画面上的视觉误差。

按几何距离压缩会破坏速度。 共线但变速的轨迹,如果按垂距判断会被错误合并,播放时速度就不对了。必须用带时间维的插值偏差,才能同时保住位置和节奏。

SMIL 的 keyTimes 必须严格递增。 归一化后要做夹取,否则长轨迹下的小数舍入可能让整条动画在某些浏览器里失效。


九、为什么是 SVG + SMIL

选型时主要考虑三点。

体积上,一条压缩后的轨迹平均 30 来个关键帧,几百个方块加起来仍在可接受范围,比 GIF 或视频小一到两个数量级。清晰度上,SVG 是矢量,任何分辨率都清楚,深色模式也能直接改颜色。部署上,不需要 JavaScript,GitHub README、个人主页、静态博客都能直接 <img> 引用。

代价是 SMIL 只能对属性做关键帧插值,没有真正的物理回放。对"一次生成、到处播放"的场景来说,这个取舍是划算的。


十、最后

github-gravity 做的事情不复杂:拉数据、跑物理、录轨迹、导出 SVG。比较有意思的是中间那个约束------既要动起来像回事,又要文件足够小。

为此项目把轨迹压缩做成了一个带时间维的误差约束问题,给出 Greedy / DP / RDP 三种取舍。默认的 Greedy 用亚秒级时间换来接近全局最优的体积,再用 keyTimes 把被压缩掉的时间信息补回去。类似的"录制物理过程再回放"的场景,应该都能用上这套思路。

在线试玩:flycran.github.io/github-grav...

源码:github.com/flycran/git...

项目灵感来自 Orta Therox 的创意。

相关推荐
m4Rk_1 小时前
【论文阅读】Agent记忆机制(114):MemoryTax——当 Agent 能自主搜索历史,专门的记忆系统还划算吗?
论文阅读·人工智能·学习·开源·github
♡すぎ♡2 小时前
SWE 的基本条件
算法·swe·浅水模拟
miofly2 小时前
GitHub 今日推荐|elephant-goldfish-model:先审查设计草案再写代码
python·开源·github
郝学胜-神的一滴3 小时前
AI 编程智能体 17:四步搞定专属AI智能体
大数据·人工智能·python·神经网络·算法·langchain·强化学习
微尘hjx3 小时前
【StrongSort 03】多轨迹算法StrongSort改进点
人工智能·算法·yolo·视觉检测·ultralytics·strongsort·轨迹算法
梦帮科技3 小时前
QML-04-QGraph-Diff:量子非欧图扩散生成模型与 SE(3) 等变分子晶格逆向合成完整技术指南(上部分)
人工智能·算法·自动化·量子计算·傅里叶分析
七牛开发者3 小时前
TokenGate:端到端可训练的 Token 剪枝机制,让大模型 Prefill 提速 11.3 倍
算法·机器学习·剪枝·长上下文·tokengate
u1301304 小时前
GitHub 热榜项目:日榜(2026-10-28)
人工智能·github