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

不想配 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...
项目灵感来自 Orta Therox 的创意。