前阵子做2D小游戏的战斗系统,需要一个挥剑动画。手边没有现成美术资源,就临时搭了手机支架,在绿幕前录了8秒真人挥剑视频。原本想着录完导出来、PS里抠一抠就行,结果发现真正要做的不是“剪视频”,而是把视频转成序列帧:让视频转序列帧,把每一帧从绿幕里抽出来、把背景抠掉,再拼成一张带透明通道的sprite sheet交给引擎用。整个过程如果靠PS逐帧手抠,8秒按15fps抽就是120张图,光看工作量就非常劝退。
所以我索性把所有流程都放在浏览器里做:一个video标签、一两个canvas,再写少量JavaScript,把抽帧、绿幕抠图、自动剪裁、拼合导出全部跑通。实测下来效果能用,而且整个链路没有任何桌面软件依赖,数据也不会离开本机,对独立开发者和小团队很友好。这篇就把完整思路和关键代码记录下来,主要内容包括:视频转序列帧怎么抽帧最稳,绿幕背景怎么用色度键控抠干净,最后怎么拼出一张可直接进引擎的sprite sheet。
1. 项目拆解:8秒绿幕视频的完整处理链路
1.1 视频转序列帧的典型工作场景
先说适用范围。“sprite sheet”这个词最早是游戏行业用的,把一组序列帧横向或纵向排成一张大图,运行时按坐标和索引播放。2D游戏开发里,角色动作、技能特效、受击反馈几乎都能用sprite sheet表达。Unity、Godot、Cocos 这类引擎都内置了精灵表导入器,设定好行列数和帧率就能直接播。前端方向也有一批场景,CSS的steps()逐帧动画、PixiJS的AnimatedSprite、微信小程序的帧动画组件,底层其实都是同一张sprite sheet在按帧切换。做Web动效时,用序列帧能实现很多CSS动画很难模拟的效果,而且帧率可控、表现稳定。
为什么专门挑8秒的视频?其实这是个很典型的“动作素材长度”。一个攻击动作一般2~4秒拍完,8秒足够覆盖一整个动作循环,甚至能多录一套预备动作和收招。如果按12fps抽帧,8秒就是96帧,单帧裁剪后按12×8的网格拼图,纹理尺寸大多能被WebGL和Unity接受。8秒也不会让浏览器解码和像素处理压力过大,是个人项目里很合适的一个规格。
拍的时候用了绿幕,好处是抠图逻辑非常简单稳定。如果直接在室内场景实拍,背景里有杂物、纹理、光影变化,AI抠图模型有很强泛化能力但还是可能出错。纯绿幕配合均匀打光,色度键控算法几乎不会翻车,而且运算量极小,毫秒级就能完成一帧。因此这个项目的主路径选绿幕抠图,AI模型只作为备选方案来兜底。
1.2 整体流程和方案取舍
整个链路可以拆成七个环节:
- 读取本地视频,生成Blob URL交给video标签。
- 用canvas对指定时间点抽帧,得到ImageData。
- 对每帧做色度键控,把绿色背景替换为透明通道。
- 做边缘处理和去绿边,让抠图边沿更干净。
- 计算非透明区域的边界框,按锚点对齐并裁剪。
- 把所有处理完的帧拼到一张透明大图中。
- 导出PNG,同时生成一份JSON帧元数据。
那为什么非要选纯浏览器方案?对比一下就会很清楚。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯浏览器 + JavaScript | 零安装、实时预览、可视化调参、数据不离开本地 | 帧多了以后CPU计算量大,需要自己写逻辑 |
| FFmpeg 命令行 | 批处理快、可以脚本化 | 抠图羽化这类像素级操作写filter很痛苦,调试全靠脑补 |
| Photoshop/GIMP 手动 | 单帧精修效果好、熟悉的人操作快 | 上百帧逐帧处理效率太低,要做动作批处理也不容易 |
我最后选了浏览器方案,还有一个很实际的原因:任何参数都不是写死的。比如色度键的容差、边缘羽化范围、锚点位置,在浏览器里可以做成滑块实时调整,看到哪一帧有问题马上改,比改FFmpeg命令参数、重新跑一遍要舒服太多。如果是做一个小工具给别人用,甚至可以直接封装成一个单HTML页面,让对方拖入视频就能导出sprite sheet,使用门槛极低。
2. 浏览器中实现抽帧与绿幕抠图的详细操作
2.1 抽帧的三种实现路径
浏览器里从视频取帧,现在的主流方式有三种。
第一种是根据时间点seek视频后再绘制到canvas。把video的currentTime设为目标时间,等待seeked事件触发,再调用ctx.drawImage(video, 0, 0),一帧就落到canvas上了。这种方式实现最简单,兼容性最好。缺点是在大视频上反复seek会有一些延迟,如果抽几十到上百帧,要耐心等。
第二种方式是让视频直接播放,用requestVideoFrameCallback回调拿到视频帧的时间戳,按预设节奏截取当前画面。它的好处是拿到的是播放过程中的真实帧,不会因seek产生解码延迟,抽帧节奏也比较稳。缺点是开发者要自己管理抽帧时机,而且兼容性略低于seek方式,不过现代浏览器基本都支持了。
第三种是WebCodecs API,直接用VideoDecoder解码视频数据,拿到原始VideoFrame,精度最高,还能配合Web Worker做后台处理。但WebCodecs的API相对底层,还要处理容器封装、初始化解码器配置,代码复杂度高一个量级。对于8秒这种短素材,前两种完全够用,没必要一开始就上WebCodecs。
下面是一段基于seek抽帧的核心代码:
async function seekTo(video, time) { return new Promise((resolve) => { const onSeek = () => { video.removeEventListener('seeked', onSeek); resolve(); }; video.addEventListener('seeked', onSeek); video.currentTime = time; }); } async function extractFrames(video, fps, targetWidth, targetHeight) { const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d', { willReadFrequently: true }); const total = Math.ceil(video.duration * fps); const frames = []; for (let i = 0; i < total; i++) { const t = i / fps; await seekTo(video, t); ctx.drawImage(video, 0, 0, targetWidth, targetHeight); frames.push(ctx.getImageData(0, 0, targetWidth, targetHeight)); } return frames; }这里把目标宽高作为参数传进去,其实是刻意选择的。原始视频可能是1080p,如果按原始尺寸抽帧,后面处理起来内存压力很大。对sprite sheet来说,角色能看清细节就够,通常把长边控制在512~1024之间即可。抽帧前先缩到目标尺寸,能省下大量计算。
2.2 抽几帧、多久抽一次才合理
抽帧间隔直接决定最终sprite sheet的平滑度和体积。以8秒视频为例,常见的几个抽帧档位如下:
| 抽帧率 | 总帧数 | 适用场景 |
|---|---|---|
| 8fps | 64帧 | 小尺寸Web动画、整体节奏快时够用 |
| 12fps | 96帧 | 角色动作常用于游戏内,体积和流畅度平衡 |
| 15fps | 120帧 | 动作细腻一些,适合特效或较大尺寸展示 |
| 24fps | 192帧 | 接近视频原生观感,但sprite sheet体积明显变大 |
游戏圈有个比较共识的经验:角色动作类序列帧10~12fps看起来已经比较顺滑,特效火焰、雷电这类高速元素可以抽到15~24fps。帧数越多,动画越“实”,但在引擎里调动作、改节奏的负担也更大,所以不要无脑追求高帧率。
还有一个细节:抽帧起点。录视频时手按录制键可能有一两秒空白,角色还没开始动作。直接从头按0、1/12、2/12这样抽,前面可能抽到无用帧。所以最好在代码里允许设置起始时间和结束时间,比如从0.5秒开始抽到7.5秒,这样前后留出安全余量,也能保证动作完整不过度冗余。
2.3 绿幕抠图的原理和实现
绿幕抠图在视觉上听着玄乎,其实核心就是色度键控。原理是:只要一个像素的颜色和“绿色背景”足够接近,就把它的alpha置成0,也就是变成全透明;如果颜色明显偏离绿色,就保留为前景像素。最直观的做法是在RGB通道里比较绿色通道和红蓝两个通道的差值。
function chromaKey(imageData, { background = 0.45, foreground = 0.2, despill = true } = {}) { const data = imageData.data; for (let i = 0; i < data.length; i += 4) { const r = data[i] / 255; const g = data[i + 1] / 255; const b = data[i + 2] / 255; // 绿色优势度:G比R和B高多少 const greenness = g - Math.max(r, b); if (greenness > background) { // 绿色优势非常明显,认为是纯背景 data[i + 3] = 0; } else if (greenness > foreground) { // 中间区域,按比例计算半透明,用于边缘羽化 const alpha = (greenness - foreground) / (background - foreground); data[i + 3] = Math.round(alpha * 255); } // 绿色优势小于foreground,默认保留为前景 // 去绿边:如果边缘像素还是偏绿,把多余的绿色通道拉回来 if (despill && data[i + 3] > 0 && data[i + 3] < 255) { const spill = Math.max(g - Math.max(r, b), 0) * (1 - data[i + 3] / 255); data[i + 1] = Math.round((g - spill) * 255); } } return imageData; }这个实现里有两个关键参数:background和foreground。绿色优势度大于background时当作纯背景,小于foreground时当作纯前景。中间是过渡带,alpha从这里逐渐变化,天然做了一层层羽化,边缘会柔和很多。如果阈值设得太高,前景边缘会残留一圈绿;设得太低,又会误删手指、头发、衣服上的绿色纹理。实操上我会先在视频里挑一两帧,把参数调好,再整条跑。
还需要提醒一点:这段代码基于RGB判断,速度快,但对亮度变化比较敏感。绿幕打折、打光不均匀时,阴影片区域的绿色优势度会下降,可能出现“漏抠”。更稳的写法是在YCbCr颜色空间里比较色度分量,把亮度和色度拆开处理,抗光照变化能力更强。如果素材环境光不稳定,建议优先用HSV/YCbCr方案,代码稍微多一些但效果显著改善。
2.4 绿幕与AI抠图工具的取舍
热搜里能看到很多人搜RMBG-2.0、onnxruntime、GIMP、PS哪种抠图好用,说明纯色背景之外,大家对通用人像抠图的需求也很高。这个项目里的绿幕素材用色度键控就够了,但我也把AI抠图作为备选路径做了测试。
如果素材没有绿幕,或者背景里混了和主体颜色相近的区域,RMBG-2.0这类人像分割模型就派上用场。RMBG-2.0是BRIA团队发布的背景移除模型,配合ONNX Runtime的Web版,可以在浏览器里直接推理,不用后端服务。用onnxruntime-web加载量化后的ONNX模型,输入一张图,输出Alpha通道,然后和原图合成透明图。整套流程也完全能在浏览器里跑通,只是每帧推理耗时比色度键控高得多,即便开了WebGPU后端,一般也需要几百毫秒到一两秒。
所以我对这个项目的定位很明确:能拍绿幕就优先走色度键控,算法简单、参数直观、逐帧可控;没有绿幕或主体边缘太复杂,再切AI抠图兜底。如果你的素材本身是普通背景,就想把人抠出来拼sprite sheet,参考这套管线的思路会很顺畅,只是把“色度键控”这一个环节换成RMBG-2.0或MediaPipe Selfie Segmentation。
3. 从绿幕抠像到sprite sheet:拼合前的图像处理细节
3.1 抠图后帧的对齐与边界裁剪
绿幕像素处理完之后,每一帧还带着大量透明的空余区域。如果直接把这些原始尺寸的帧拼进sprite sheet,整张图会非常大,而且角色在画面里的位置会随着动作偏移,播放时会让角色“飘来飘去”。所以拼表前必须做两个处理:自动裁剪和对齐。
自动裁剪的思路是遍历像素,找出alpha值大于某个阈值(比如8或16)的边界,得到一个非透明包围盒。然后再把包围盒往外留几像素边距,作为后续羽化和描边的空间,避免后续操作把角色边缘切掉。
对齐是很多第一次做序列帧的人会忽略的点。如果录视频时身体重心在画面里小范围移动,逐帧播放就会变成角色来回抖动。解决办法是给所有帧确定一个“锚点”——一般取角色脚底中心或包围盒底部中心,把每帧的锚点对齐到目标画布的同一个位置。这样拼出sprite sheet后,引擎在播放时角色脚底始终在同一水平线上,人物动作自然稳了很多。锚点信息同时还要写进JSON,方便引擎设置碰撞体和挂载点。
3.2 计算精灵表网格布局
帧都归一化到统一尺寸后,就可以决定拼表方案。如果所有帧尺寸都相同,最简单的布局是等宽等高的规则网格。先决定总共有多少帧数frameCount,再定列数:
const cols = Math.ceil(Math.sqrt(frameCount)); const rows = Math.ceil(frameCount / cols); const padding = 4; const sheetWidth = cols * (frameWidth + padding) + padding; const sheetHeight = rows * (frameHeight + padding) + padding;为什么用Math.sqrt估算列数?因为规则网格希望整体接近正方形,这样在引擎里分配的纹理尺寸更均衡,也更不容易超出单张纹理上限。实际使用还要考虑目标引擎或渲染器的限制,比如Unity里默认最大纹理尺寸是2048或4096,WebGL 1很多设备也就支持到4096。如果计算出的sheetWidth或sheetHeight超了,就要降帧率、缩小每帧画布,或者把一张sprite sheet拆成多张。
padding也很重要。相邻帧如果完全紧贴,某些引擎在纹理过滤或压缩后会把边缘像素串过来,出现“串色”或“黑边”。一般留2~4像素全透明padding就够了,既能防串色,也不会把纹理体积撑大多少。
3.3 具体拼合与导出
拼合本身不复杂,本质就是在目标画布上把每一帧的ImageData放到对应网格位置。
function composeSpriteSheet(frames, cols, frameW, frameH, padding) { const rows = Math.ceil(frames.length / cols); const canvas = document.createElement('canvas'); canvas.width = cols * (frameW + padding) + padding; canvas.height = rows * (frameH + padding) + padding; const ctx = canvas.getContext('2d'); frames.forEach((frameData, index) => { const col = index % cols; const row = Math.floor(index / cols); const x = padding + col * (frameW + padding); const y = padding + row * (frameH + padding); ctx.putImageData(frameData, x, y); }); canvas.toBlob((blob) => { // 保存 PNG 或交给后续处理 }, 'image/png'); }导出的时候要注意,即便最终要的是带透明通道的PNG,也不要频繁调用toDataURL,否则内存会短暂飙升。优先用canvas.toBlob,减少字符串转换的开销。如果项目里还有后端,可以把Blob直接POST上去;如果是纯本地工具,用URL.createObjectURL(blob)生成临时下载链接就好。
同时导出一份JSON帧数据,记录以下字段:帧名、网格索引、frame中的x/y/w/h,以及锚点anchor坐标。Unity里的Sprite Editor可以直接按网格切,但如果是PixiJS这类库,用JSON驱动会更方便。
4. 实操过程中遇到的坑与优化建议
4.1 视频加载、跨域和格式兼容性
浏览器处理视频和canvas有个经典的坑:canvas“被污染”。如果video源来自跨域地址,而且没有正确配置CORS,canvas执行getImageData或toDataURL时会抛出安全问题异常。自己测试时,最省事的办法是用<input type="file">读取本地视频,再通过URL.createObjectURL(blob)生成地址;Blob URL属于本地同源,canvas不会被污染。
如果是从服务器或者网络URL取视频,需要给video标签加crossOrigin="anonymous",并且服务器返回对应的Access-Control-Allow-Origin头。否则即便前端把canvas画好了,导出时照样报错。
格式兼容性也是个容易踩的坑。Chrome对H.264、VP9、AV1的支持很全面,但Safari对特定编码和容器组合偶尔会出问题。本地文件处理时,建议统一用H.264编码的MP4作为测试素材,兼容范围最广。如果是苹果生态里导出的HEVC视频,装进浏览器后很可能解不出来或播放发绿。遇到这种情况,先用FFmpeg转成标准H.264 MP4,再投入这个流程,省时又省心。
还有个和seek相关的坑:有些浏览器在canplaythrough事件之前修改currentTime会导致seeked事件迟迟不来。稳妥做法是先等loadedmetadata,再等一次canplaythrough,之后再开始抽帧循环。我实际测试时还碰到过极少数视频在seek到末尾时间附近时卡住,所以循环里最好加一个超时保护,超过一定时间就跳过当前帧或用上一帧替换。
4.2 绿幕抠图效果不干净的排查
绿幕抠图最常见的问题有三个:绿边、白边、边缘锯齿。
绿边是边缘半透明像素残留绿色,放进深色背景的sprite sheet里一眼就能看出来。解决办法是despill,然后把绿边像素往红蓝方向拉,降低绿色通道的异常值。我在代码示例里已经把despill塞进了色度键控的像素循环里,效果比事后用PS处理要省事。
白边通常来自拍摄时的绿幕过曝,或者手动羽化过度。应对策略是改变foreground和background两个阈值,让边缘判定更严格一些;也可以对alpha通道做一个“contract”操作,把边缘向内收缩1~2像素,再把边缘羽化一次,这样白边会明显减少。
边缘锯齿主要出现在快速动作或运动模糊明显的帧上。半透明像素的alpha变化如果不够平滑,拼进sprite sheet后会有毛毛糙糙的感觉。操作上可以在抽帧时稍微提高目标分辨率,再在抠图后对alpha通道做一次高斯模糊或“扩展+羽化”处理。不要小看这两步,它往往比大量调参数带来的改善更直观。
4.3 内存与性能优化
处理8秒视频,12fps抽帧就是96帧,每帧分辨率按800×600来算,一张ImageData大约不到2MB,96帧全部存在内存里差不多200MB,虽然在现代设备上不算致命,但依然容易触发GC波动。如果抽到192帧,而且解析度再高一倍,内存压力就会明显上来。
我的经验是不要把所有帧一次性放进数组。更合理的方式是边抽边处理边拼,或者控制一个“帧池”批量处理。示例代码里extractFrames返回完整帧数组,看起来直观,工程上我却更推荐用onFrame回调逐步处理:
async function extractFrames(video, fps, onFrame) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', { willReadFrequently: true }); const total = Math.ceil(video.duration * fps); for (let i = 0; i < total; i++) { await seekTo(video, i / fps); canvas.width = targetWidth; canvas.height = targetHeight; ctx.drawImage(video, 0, 0, targetWidth, targetHeight); const imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); onFrame(imageData, i, total); } }内存优化还有一招:色度键控这类像素级操作,本质上是纯CPU计算,很容易阻塞主线程,页面看起来就像卡死。如果素材量偏大,可以把像素处理丢进Web Worker里。不过Web Worker里拿不到canvas上下文,需要把ImageData转成ArrayBuffer传回主线程。对于8秒素材而言,这一步可做可不做;但如果做成服务给同事用,还是值得投入。
4.4 成品检查清单与精灵表导入设置
流程跑通后,最后检查一定不能省。我在导出sprite sheet后习惯按这个清单过一遍:
- 用支持透明背景的看图工具打开PNG,检查有没有不该透明的位置被误删。
- 在浏览器里写一段快速预览代码,把sprite sheet按帧索引循环播放,看动作是否连贯、有没有前后帧跳跃。
- 检查角色脚下位置是否恒定,如果锚点没对齐,播放时顶部的差异会比看起来更突兀。
- 看边缘有没有绿边和白色描边,特别是头发、手指、衣服下摆这些复杂边缘。
- 查看JSON里的anchor和每帧坐标是否和实际拼图一致。
如果把这些数据导入Unity,设置Sprite Mode为Multiple,打开Sprite Editor按网格切分,记得把Pixels Per Unit设成和素材分辨率匹配的值,再把锚点对准JSON里记录的anchor坐标。进入Animator后逐帧拖入Sprite,按抽帧率乘以游戏运行节奏调整播放速度即可。这里有个经验值:如果抽帧时是12fps,想让游戏里看起来正常,动画播放速度不要直接写12,因为Unity的动画采样还受项目帧率影响,经常需要微调,建议一边预览一边调Speed参数。
把流程沉淀下来,再做一次扩展
整个流程跑下来,我觉得最大收获不是省下了一点手工抠图时间,而是发现浏览器本身就是一套完整的图像处理平台。把video、canvas、toBlob、JSON数据这些原生能力串联起来,就能替代很多桌面软件才能完成的重复劳动。我后来把这个管线封装成了一个单HTML工具,把抽帧率、色度键阈值、网格列数、导出尺寸都做成了可调参数,前端实时预览,拖入文件直接出图。
如果你也要做类似的事,我的建议是先用低分辨率跑通链路,比如把长边定到480或512,确认参数没问题后再提高分辨率正式导出。这样省得后期为抠图效果反复重跑。往后如果想让工具更好用,可以继续加在线预览动画、自动计算帧尺寸,甚至把RMBG-2.0模型接进来作为绿幕失效时的兜底方案。这个项目的核心逻辑并不复杂,但有很强的复用价值,改改就能变成自己的素材处理小工具。