白板动画 是做什么的?
VideoScribe / Doodly 风格:一只手在画板上逐笔绘制插图和手写文字,同时伴有旁白解说。通过动画化 SVG 路径实现“路径自绘”,并让一个标记手跟随绘制尖端,然后按旁白顺序排列元素,使画板逐步填满。
何时使用
- “手绘”/涂鸦讲解视频:一只手在白板上绘制艺术图形和文字。
- 动画化草图或 Logo 的实时逐线绘制过程。
- 手写文字按照书写顺序出现,并与配音同步。
- 将静态插图转换为引导性的、逐笔揭示的动画。
核心机制:SVG 笔画绘制
通过将 stroke-dashoffset 从全长动画到 0,路径会“自行绘制”——虚线间隙缩小,从起点到终点逐步暴露笔画。
const path = document.querySelector("#stroke");
const len = path.getTotalLength();
path.style.strokeDasharray = len; // 一个与整个路径等长的虚线
path.style.strokeDashoffset = len; // 完全推出视图 = 不可见
path.getBoundingClientRect(); // 强制重排后再过渡
path.style.transition = "stroke-dashoffset 1.2s linear";
path.style.strokeDashoffset = "0"; // 从起点绘制到终点
在 <path> 上使用 pathLength="1" 进行归一化:这样 stroke-dasharray:1; stroke-dashoffset:1 可以绘制任何实际长度的路径,并且一个简单的缓动值 (0→1) 就代表了绘制进度。使用 linear(或极其平缓的缓动)会让绘制看起来像稳定的手笔;在长笔画上避免使用 ease-in-out——它会使标记手产生顿挫感。
标记手跟随尖端
此效果的成败关键在于标记手是否精确地位于正在绘制的点位上。在每一帧采样当前绘制进度下的路径点,并将手部图像放置在该位置;手部素材的笔尖必须与路径点对齐(偏移图像,使其笔尖,而非其角落,落在点上)。
const hand = document.querySelector("#hand"); // <img>/<g>,笔尖在其左上角"笔头"处
const NIB = { x: 6, y: 4 }; // 手部图像内的笔尖偏移量
function drawOn(path, hand, dur = 1200) {
const len = path.getTotalLength(), t0 = performance.now();
path.style.strokeDasharray = len;
(function frame(now) {
const k = Math.min(1, (now - t0) / dur); // 0→1 的进度
path.style.strokeDashoffset = len * (1 - k); // 暴露起点到尖端
const p = path.getPointAtLength(len * k); // SVG 坐标中的当前尖端位置
hand.setAttribute("transform", `translate(${p.x - NIB.x} ${p.y - NIB.y})`);
if (k < 1) requestAnimationFrame(frame);
})(t0);
}
getPointAtLength(len * k) 是核心:它返回的路径坐标与驱动 strokeDashoffset 的进度相同,因此笔尖始终紧贴正在增长的笔画。在不同笔画之间需要抬起手(淡出,跳转到下一个起点),以免它在空白画板上滑动。
显示顺序——画板逐步填充
按照人们绘制场景的方式,以旁白顺序排列元素:每个插图和每个手写文字块依次绘制、保持,然后下一个开始。在一个图形内部错开笔画;每个元素完成后稍作停顿再移动手。一个图形对应一段旁白节拍。不要同时绘制两样东西——眼睛(以及单只手)一次只能跟随一个尖端。
| 步骤 | 机制 |
|---|---|
| 绘制一条笔画 | stroke-dashoffset 从 len 到 0,手位于 getPointAtLength(len*k) |
| 多笔画图形 | 错开笔画;手跳到每个新笔画的起点 |
| 手写文字 | 按阅读顺序绘制每字母/单词的笔画,手跟随 |
| 保持 | 元素完成绘制后,手在画面外暂停 (0.4–1.0 秒) |
| 下一个元素 | 移动/淡入手到下一个起点;开始其绘制 |
| 擦除 / 清除 | 划除或反向绘制以清空画板,为下一部分做准备 |
将艺术作品转换为可绘制的路径
绘制动画需要单笔画、开放的、按绘制顺序排列的路径——而不是填充的复合形状。
- 创作或描摹线条艺术时使用开放笔画(Illustrator/Inkscape:轮廓线,而非填充)。填充的色块没有可动画化的“笔画”;应将填充转换为中心线笔画,或在轮廓完成后填充轮廓路径。
- 顺序很重要:
<path>元素在 SVG 中应按照手绘制的顺序出现——按照它们在文档中的顺序进行动画。 - 长连续线条看起来最自然;仅在真正需要提笔的地方断开。
- 对于纯色区域:首先绘制轮廓笔画,然后在其后方淡入/放大填充(使用
opacity或剪辑揭示),使其看起来像“之后填色”。 - 手写文字:使用笔画/手写字体,将字形转换为路径,然后按笔画顺序绘制每个字形的路径。或者模拟它:遮罩单词,从左到右扫过一个揭示层,让手位于遮罩边缘。
标记手部素材
一个拿着马克笔的手的 PNG/SVG 图像,笔尖位于已知点。测量一次笔尖偏移量(上面的 NIB)并重复使用。为整个作品保持一个手部素材以确保一致性。仅在需要左手时才镜像它(scaleX(-1))。在手下添加淡淡的阴影以增加深度;在手“离开”的保持期间去掉阴影。
擦除 / 划除过渡
在章节之间,清空画板以免造成混乱:
- 划除:对已填充的组动画化一个
clip-path(或一个白色矩形)进行划除,手部可选地“擦除”。 - 反向绘制:运行
strokeDashoffset从 0 到 len 来反向绘制,手部跟随。 - 淡出 + 重绘:将旧组淡出,同时开始下一组的绘制。
保持一种清晰的语法(例如,始终从左到右划除),这样过渡就显得有目的性。
与旁白同步节奏
绘制持续时间应跟随口语台词,而不是固定计时器。根据旁白估算,大约每秒 2.3 个词;将每个图形的总绘制时间设定为其所说明的台词长度大致相同,再加上一个短暂的保持。旁白提到的标签应在词语说出时正好完成绘制。对于强调性的台词,减慢手速;对于次要的连接性笔画,加快速度。永远不要让手在绘制的画板上闲置而旁白继续——将手移开或开始下一个元素。
输出清单
- 每个笔画都是按绘制顺序排列的单个开放路径;没有元素是突然完全出现的。
- 在整个绘制过程中,标记笔尖精确地位于绘制尖端(无漂浮,无延迟)。
- 手在不同笔画/元素之间干净地抬起;不在空白画板上滑动。
- 元素按旁白顺序依次绘制;每个元素在下一个开始前有所保持。
- 手写文字按阅读顺序绘制;填充在其轮廓之后出现。
- 擦除/划除语法一致;画板从不过度杂乱。
- 绘制时间与其所说明的 VO 台词同步。
prefers-reduced-motion:显示完成的画板(所有笔画偏移量为 0,手部隐藏),无绘制动画。
交付与验证(独立 HTML)
打包的帮助脚本 (
scripts/):scripts/seek-shot.sh anim.html 0 1.5 3冻结?t=N测试工具并在每个时刻截图;scripts/contact-sheet.sh sheet.png frame-*.png将它们平铺以便快速审阅。详见scripts/README.md。
对于一个自包含的白板场景(绘制插图、手写文字、简短的手绘节拍),交付物是一个可直接在浏览器中打开的 HTML 文件——无需构建步骤,无需渲染管道。单个文件非常适合网页动效;当一个文件就能完成工作时,不要使用打包工具。(对于带有内置 VO 的完整解说手绘视频,应将其构建为 Remotion 组合并通过 remotion still 验证——参见 explainer-video。)
输出约定:
- 一个
.html文件:内联 SVG(路径按绘制顺序排列,使用pathLength="1"或测量长度)、手部素材以数据 URI 内联,以及一个内联<script>中的绘制驱动脚本。 - 所有绘制在一个主时间线上(GSAP
tl,或一个 rAF 时钟),因此整个序列具有单个可搜索的播放头——strokeDashoffset和手部位置都由相同的进度值驱动。 - 包含下面的搜索工具,以便任何时刻都可被冻结用于截图。在
t时刻冻结显示了路径绘制的精确程度——验证依赖于此。
搜索工具——冻结精确时刻用于截图。 ?t=N 将主时间线搜索到 N 秒并暂停,以便截图能捕捉到绘制过程中的一个静止、确定的帧。
<script>
// ... 构建您的主时间线为 `tl`(在其上放置 drawOn 调用)...
const t = new URLSearchParams(location.search).get("t");
if (t !== null) { tl.pause(); tl.seek(parseFloat(t)); } // 冻结在 t 秒
// 无 ?t → 正常播放
window.__ready = true; // 为无头等待准备的就绪信号
console.log("duration", tl.duration());
</script>
验证循环——渲染→冻结→截图→检查:
- 在时间线的各个时刻打开文件——开始、绘制过程中的一帧、结束:
…/scribe.html?t=0,?t=<dur/2>,?t=<dur>。从控制台读取tl.duration()作为结束时间。选择至少一个处于笔画绘制过程中间的t——这正是手在尖端上的视觉效果被证明或打破的地方。 - 对每个冻结帧进行无头截图:
npx playwright screenshot --wait-for-timeout=500 "file://$PWD/scribe.html?t=1.2" frame-mid.png - 检查每一帧——检查保真度:在笔画中间时刻
t,标记笔尖精确地位于已绘制部分的前沿尖端上(不前,不后,不漂浮);路径仅绘制到尖端;在?t=<dur>时刻,每个笔画都干净地完成,没有间隙,没有过冲,没有剩余的虚线。检查伪影:笔画之间手在空白画板上滑动、笔尖偏移错误(尖端漂浮在线条之上)、笔画绘制顺序错误、填充在其轮廓之前出现、被裁剪/画布外的图形、字体加载前的 FOUC(Flash of Unstyled Content,样式未加载时的闪白)。 - 迭代:调整
NIB、笔画顺序、持续时间,并重新截图,直到每个冻结帧看起来都像一只手正在绘制过程中。
在完成之前:
- 在浏览器中独立打开——无控制台错误,无缺失的 CDN/资源。
- 一个主时间线;
?t=N正确冻结,并且strokeDashoffset和手部位置共享一个进度值。 - 在开始/绘制过程中间/结束时截图——整个过程中手部笔尖精确地位于尖端上,笔画在结束时干净完成,绘制顺序正确。
- 手在笔画之间抬起;不在空白画板上滑动。
prefers-reduced-motion显示完成的画板(所有笔画已绘制,手部隐藏),无绘制动画。
对于带有旁白、帧确定的导出,将相同的绘制驱动(使用 useCurrentFrame()/fps 驱动 strokeDashoffset 和手部变换)移植到 Remotion 合成中,并渲染为 MP4/GIF——参见 explainer-video 技能中关于渲染静态帧然后编码的循环。
参考文件
references/draw-on-recipes.md——可运行的绘制机制:getTotalLength/getPointAtLength手跟随路径、GSAPDrawSVGPlugin和MotionPathPlugin变体、多笔画错开、每字形手写、轮廓后填充、擦除/划除和反向绘制过渡,以及 Remotion 帧驱动移植。references/whiteboard-pipeline.md——将艺术作品转换为可绘制的单笔画 SVG、绘制顺序创作、手写/笔画字体、标记手部素材和笔尖校准、将绘制时间与 VO 台词同步,以及故事板到画板的构建序列。