首页
/ Remotion × MapTiler 地图解说动画架构完全指南:时序模型、河流电光绘制与固定地图底板

Remotion × MapTiler 地图解说动画架构完全指南:时序模型、河流电光绘制与固定地图底板

2026-09-07 14:47:14作者:胡易黎Nicole

这是一份针对 Remotion 开源仓库中 remotion-maps 技能的深度技术解析。它以「Map Explainer(地图解说)」动画为对象,讲解如何在 MapTiler Planet 矢量底图上,用 React 编写一段"河流从源头一路绘制、途经多个国家时依次点亮国境线→填充→文字标签"的叙事型地图动画。读完本文,你将掌握一套可复用的架构:基于秒的时序模型、provider 矢量直绘与自定义 GeoJSON 两种动画模式的选择、带"电光绘笔头"的河流揭示算法、逐国多段国境线安全切片,以及避免 headless 渲染抖动的"固定地图底板 + CSS 相机"方案。文章正文以 map-explainer-architecture.md 为骨架,源码佐证均取自本仓库该技能目录。

1. 整体架构一览:从一份参考文档到可运行的组件集

本技能目录位于 packages/skills/skills/remotion-maps/techniques/maptiler/,核心组件与文档如下:

需要强调的是:文档中提供的数值(时长、颜色、触发时间)是示例值,不是一套生产级样式系统。自定义几何示例由 RiverReveal.tsx + CountryLabel.tsx + tokens.ts 组成,provider 矢量设置位于 MapTilerVectorElement.ts,两种模式的取舍详见 map-data-sources.md。消费方项目应替换全部示例几何、命名、时间与视觉 token——"不要把一个源工程的调色板带进另一个制作"。

渲染入口示例(见 example-Root.tsx)是一个 12 秒 @30fps 的 1920×1080 Composition;渲染命令为 bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000

2. 逐帧渲染链路(render harness):delayRender / idle / triggerRepaint 循环

MapTiler 地图实例只在组件挂载时初始化一次(用 ref 守卫 started),因为真正常用的资源是"单例地图 + 每帧命令式更新",而非反复创建实例。初始化时传入:

const m = new maptilersdk.Map({
  container: ref.current,
  style: maptilersdk.MapStyle.BASIC,
  center: END.center,
  zoom: Math.max(START.zoom, END.zoom), // 固定 plate 以最大 zoom 渲染,见 §7
  pitch: END.pitch, bearing: END.bearing,
  interactive: false, fadeDuration: 0,
  canvasContextAttributes: {preserveDrawingBuffer: true},
});

随后在 map.on('load') 中完成三件事:清理底图杂乱(详见 references/map-geo-prep.md:移除 symbol 标签层与 /other border/i 的内层行政边界,并用 CSS 隐藏 .maplibregl-ctrl-bottom-left.maptiler-logo 等控件);添加 sources 与 layers;然后等待 map.once('idle') → continueRender(handle) 释放首帧 delayRender

RiverReveal.tsx 可以看到每一帧的完整模式:

const h = delayRender(`frame A ${frame}`);
const t = frame / fps;                    // 帧 → 秒
// …按 t 计算 reveal、切片 setData、setPaintProperty…
map.once('idle', () => continueRender(h));
map.triggerRepaint();

即:

delayRender → setData / setPaintProperty → map.once('idle', continueRender) → triggerRepaint

其中两个关键选项的语义是:

  • preserveDrawingBuffer: true:保证 Remotion 截图能抓取 WebGL canvas 内容;若为 false,绘制缓冲在合成后可能被清空,截图会得到空白地图。
  • --gl=angle:渲染 WebGL 使用 ANGLE 后端,是 headless 抓帧的推荐参数(渲染稳定性细节见 render-stability.md)。

地图就绪后,不要通过状态驱动每一帧的 paint,而是用 useCurrentFrame() 在 effect 里命令式更新;CSS 过渡、浏览器计时器、requestAnimationFrame 都不适合驱动 Remotion 逐帧动画。

另外有一个易踩的坑:构造 MapLibre/MapTiler 图层对象时,必须省略不存在的可选属性。例如写 ...(layer.filter ? {filter: layer.filter} : {}) 而非 filter: undefined——一个 undefined filter 会悄悄压制该图层,而单独创建的 halo / border 图层仍在渲染,最终表现为"国家填充缺失、深色标记光晕空转无彩色核心"。这条原则已被封装进 MapTilerVectorElement.tsaddLayer 组装逻辑中。

3. 时序模型:以"秒"为纲,节拍长度由序列推导

与"按揭示单位(reveal-units)推进"的做法不同,本文档的模型一切都以秒为坐标t = frame / fps。河流在一个时间窗口内完成绘制;每个国家在"河流抵达它"的时刻触发,随后运行一段固定长度的序列。所谓"节拍(beat)",就是"最后一个国家的完整序列刚好结束 + 收尾"所需的总时长,由序列自动推导。

核心常量(示例值):

const RIVER_START = 0.3, RIVER_END = 8.0;            // 河流在 [0.3s, 8.0s] 窗口内绘制
const BORDER_S = 2.5, FILL_S = 1.0, LABEL_S = 0.7;   // 每国三阶段序列,时长恒定
const trigger = (c) => RIVER_START + META[c].stop * (RIVER_END - RIVER_START);
// 节拍长度 = max_c( trigger(c) + BORDER_S + FILL_S + LABEL_S ) + 尾部
const reveal = interpolate(t, [RIVER_START, RIVER_END], [0,1],
  { …clamp, easing: Easing.bezier(0.645, 0.045, 0.355, 1) });

说明:

  • META[c].stop 是该国在整个河流路径上的弧长比例——即河流进入该国的位置,由 scripts/prep-geo.mjs 预先生成(见 map-geo-prep.mdstop 小节:逐点行走河流、取第一个落入该国的点)。
  • tokens.ts 中,DUR.mapExplainer = 12 * VIDEO.fps(12 秒 @30fps)即示例节拍;若最后一个国家在河流抵达后需要更多余量,就调大 durationInFrames
  • 河流揭示用的缓动是 Easing.bezier(0.645, 0.045, 0.355, 1)(近似 easeInOutSine 的缓入缓出)。

"恒定时长"是一条必须遵守的纪律:国境线的绘制进度应当由"自触发以来的本地时间"驱动(lt = t - trigger(c)),而不是由 reveal 的某个分片驱动。若把国境绘制绑定在河流 reveal 比例上,复杂或很长的国境线会在 reveal 的后段被压缩成零点几秒内闪完,视觉上完全失控。

4. 数据源二选一:provider 矢量 vs 自定义 GeoJSON

动手生产 GeoJSON 之前,先查 MapTiler Planet 是否已把该要素作为可过滤的矢量数据暴露。决策规则(完整矩阵见 map-data-sources.md):

  • MapTiler 矢量层:要素已存在于 provider 的 source-layer、其属性支持编辑级精确过滤、且 provider 几何在编辑上可接受;
  • 自定义 GeoJSON:要素缺失、是规划/历史/争议/修正/私有数据、或者需要有序几何做确定性绘制;
  • 混合模式:普通地理上下文(水系、道路)用 provider 矢量,主张性证据用自定义 GeoJSON,并保持两者视觉区分(记录来源与生效日期)。

要点:淡入淡出、颜色、宽度、模糊、圆角、fill-opacity、feature-state 高亮都可以放心在 provider 图层上逐帧动画;但不要把瓦片化的折线当作一条全局有序路径——矢量瓦片在瓦片边界处切分要素,source-to-end 的"方向性绘制"在 provider 层上不存在可靠全局顺序。方向承载语义的绘制,必须先把要素提取、校验、排序后烘焙为 GeoJSON

4.1 provider 模式:直接原地动画 MapTiler Planet 要素

封装在 MapTilerVectorElement.tsaddMapTilerVectorElement 首次调用时以 https://api.maptiler.com/tiles/v3/tiles.json?key=${apiKey} 添加 maptiler-planet vector source(默认 id 常量 SOURCE_ID),再以 source-layer + 精确 filter 添加 story 图层;setVectorElementPaint 将若干 paint 属性一次性写入图层。示例:

addMapTilerVectorElement(map, process.env.REMOTION_MAPTILER_KEY!, {
  id: "story-river",
  sourceLayer: "waterway",
  type: "line",
  filter: ["all",
    ["==", ["get", "class"], "river"],
    ["==", ["coalesce", ["get", "name_en"], ["get", "name"]], "Yarlung Tsangpo"],
  ],
  layout: {"line-cap": "round", "line-join": "round"},
  paint: {"line-color": "#E8F7FF", "line-width": 3, "line-opacity": 0},
});
// 每帧:
setVectorElementPaint(map, "story-river", {"line-opacity": reveal, "line-width": 2 + reveal * 2});

它对 line / fill / circle / symbol 四类图层都有效,无需把 provider 几何复制进项目。注意读取当前 MapTiler Planet schema 再选 waterwaywatertransportationboundarylandcoverpoi 等源层与字段(可用性与 zoom 区间随 schema 版本变化);feature-state 仅当 provider 提供稳定 ID 且跨瓦片选择确定时才使用。provider 对齐不等于正确——在把 provider 要素当作证据展示前,仍要用编辑源核对属性和几何。

5. 河流绘制 + "电光笔头":自定义折线的距离切片

在自定义模式下,河流是一根有序的 turf.lineString,预先按公里数度量(lineKm = turf.length(line))。每帧先用 reveal 计算已画长度,再做两次 turf.lineSliceAlong:一次喂给主体河流 source,一次喂给"电光笔头" source。

"电光(electricity)"的本质:白热笔头领先于绘制前沿——已画线段末端最后约 3% 长度的部分,单独放在一组高亮 + 辉光图层里,河流完整后在河口处淡出。

const riverDrawnKm = lineKm * reveal;
map.getSource("river").setData(turf.lineSliceAlong(line, 0, Math.max(0.001, riverDrawnKm)));
const headKm = lineKm * 0.03;
map.getSource("river-head").setData(turf.lineSliceAlong(line, Math.max(0, riverDrawnKm - headKm), Math.max(0.001, riverDrawnKm)));
let headFade = 0;
if (reveal > 0.002 && reveal < 0.999) headFade = 1;
else if (reveal >= 0.999) headFade = 1 - clamp01((t - RIVER_END) / 0.5); // 到达河口后 0.5s 淡出
map.setPaintProperty("river-headglow", "line-opacity", 0.85 * headFade);
map.setPaintProperty("river-head", "line-opacity", headFade);

Math.max(0.001, …) 的下限避免了长度为 0 的 GeoJSON 线段触发 setData 异常。图层自底向上(见 RiverReveal.tsxtokens.ts):

顺序 图层 id 语义 示例视觉
1 river-glow 电蓝辉光 #49C6FF,宽 11,opacity 0.32,blur 6
2 river-line 冰冷内核 #E8F7FFCOLORS.river),宽 3
3 river-headglow 笔头辉光 rgba(120,225,255,.95),宽 16,blur 9,opacity 0.85×fade
4 river-head 白热笔头 #FFFFFF,宽 4.5,opacity = fade

刻意不加深色描边(casing)——明亮的冰冷内核在任何填充色之上都能自己"读"出来,加暗描边反而污染视觉。所有线层使用 line-cap: round / line-join: round 以获得平滑的绘制笔触。

6. 逐国动画:国境绘制 → 填充绽放 → 标签升起

每个国家在河流抵达时触发,进入三段串行阶段。注意编辑取舍:国境线使用该国颜色的深色阶COUNTRY_DARK)——电光只属于河流,不放在国境上,否则国境会喧宾夺主。

const lt = t - trigger(c);                     // 自触发起的本地秒数
// 1) 恒定 BORDER_S 内完成整条 source 国境的绘制,多段安全
const bp = interpolate(clamp01(lt / BORDER_S), [0, 1], [0, 1],
  { easing: Easing.bezier(0.645, 0.045, 0.355, 1) });
map.getSource(`trail-${c}`).setData(sliceBorder(DRAW[c], 0, DRAW[c].total * bp));
// 2) 国境完成之后填充绽放(opacity 先超调再回落)
const fp = clamp01((lt - BORDER_S) / FILL_S);
const fo = interpolate(fp, [0, 0.6, 1], [0, FILL_OPACITY * 1.25, FILL_OPACITY],
  { …clamp, easing: Easing.bezier(0.3333333333333333, 1, 0.6666666666666666, 1) });
map.setPaintProperty(`fill-${c}`, "fill-opacity", fp <= 0 ? 0 : fo);
// 3) 填充之后标签升起
const lp = clamp01((lt - BORDER_S - FILL_S) / LABEL_S);

填充的"超调再回落"由输入 [0, 0.6, 1] 与输出 [0, FILL_OPACITY*1.25, FILL_OPACITY] 的插值实现:opacity 冲到目标值 1.25 倍后回落到 FILL_OPACITY(示例 0.5,见 tokens.ts),缓动为 Easing.bezier(1/3, 1, 2/3, 1)(easeOutBack),产生"颜料绽开"的弹性感。

6.1 多段国境线的核心算法 sliceBorder

sliceBorder(d, fromKm, toKm) 将一条完整、可能多段的国境,以 MultiLineString 形式揭示 [fromKm, toKm] 区间:对每段按累计长度切片,不跨 gap 造连接、不受视口裁剪。源码实现于 RiverReveal.tsx,并在架构文档中以伪码给出:

const sliceBorder = (d, fromKm, toKm) => {
  const out = [];
  for (let i = 0; i < d.segLines.length; i++) {
    const start = d.cum[i], end = start + d.segLen[i];
    const a = Math.max(fromKm, start), b = Math.min(toKm, end);
    if (b - a <= 0.0008) continue;      // 跳过过短切片,避免噪声
    out.push(turf.lineSliceAlong(d.segLines[i], a - start, b - start).geometry.coordinates);
  }
  return { type:"Feature", properties:{}, geometry:{ type:"MultiLineString", coordinates: out } };
};

前置准备在组件里一次性完成:把 META[c].border(每国完整外环,country-meta.json 中生成)映射为若干 turf.lineString 线段,逐一求 turf.lengthsegLen,再滚动累加出累计起点 cum 与总长 totalDRAW 结构)。由于切片以累计长度为标尺,国境是多段、甚至跑出画面,都不会出现"跨缺口乱连"或"视口裁剪导致只有可见段被画"的问题——数据源层面已用 turf.booleanPointInPolygon + "保留每个外部环的完整源几何"保证完整性(见 map-geo-prep.mdborder 小节:永远不要把国家或双边国境裁剪到取景 bbox,也不要丢弃屏幕外线段)。

填充、国境与河流的取色统一收口在产物的本地 token 文件(示例即 tokens.ts)——示例值只是示范,不应把源工程调色板原样带进另一个制作。

7. 标签:每帧投影的 HTML 覆盖层

标签用 React HTML 覆盖层而非地图 symbol 图层,从而获得完整排版控制(字重、字距、阴影、字体均可自定义)。CountryLabel 只是示例的"accent-rule + rise-and-fade"样式:居中锚定、上移淡入、accent 短横线从中心向两侧 scaleX 展开,并整体 pointerEvents: none;所有字体与视觉参数通过 CSS 变量(--map-label-font/-weight/-size/-tracking/-color/-shadow)给出中性回退,最终由消费方项目注入(见 CountryLabel.tsx)。

定位方式是每帧把锚点投影为屏幕像素并存入 state

const p = map.project(META[c].anchor);   // lngLat → 屏幕像素(跟随活相机)
pos[c] = { x: p.x, y: p.y, reveal: lp };
setLabels(pos);                          // 重渲染覆盖层;effect 依赖中排除 labels

实现上的关键点:

  • map.project(lngLat) 给出该经纬度在当前相机下的屏幕坐标,天然贴合"活相机"视图;
  • setLabels 每帧写入新数组以触发覆盖层重渲染,但第二个绘制 effect 的依赖数组刻意不含 labels,避免因标签状态变化造成绘制循环;
  • 在固定 plate 模式下(见 §8),坐标还要叠加 plate 变换:x: p.x * plateScale + plateX(见 RiverReveal.tsx);
  • 锚点 META[c].anchorprep-geo.mjs 用**极点(pole of inaccessibility)**算法生成——在多边形内做 46×46 网格采样、取到边界距离最大的点(并按每国 story bbox 裁剪、叠加人工 nudge)。不要用质心(centroid),质心会被拉向边缘。

覆盖层渲染在顶层 <AbsoluteFill style={{pointerEvents:'none'}}> 内,逐国家映射 CountryLabel,仅当该国 labels[c] 已就绪时挂载。

8. 相机:任何运动都用"固定地图底板",不做逐帧 jumpTo

架构文档引用 render-stability.md 并给出硬性结论:做移动 2D 运镜时,不要每帧调用 map.jumpTo()。headless 渲染中逐帧 jumpTo 会让 MapTiler 的 hillshade 甚至卫星影像发生 shimmer / jitter——这是渲染器逐帧重采样所致,与瓦片加载、网络、标签都无关,瓦片重试和缓动调整解决不了它。

正确姿势是固定地图底板(fixed map plate)

  1. 把 MapTiler 画布一次性渲染在最大所需 zoom 的超大容器里(1920×1080 下安全默认是 3840×2160,1080×1920 竖版约 2700×3840);不要盲目用 3×——1920×1080 × 3 会到 5760px,超出 Chromium 常见 4096px 的 WebGL 渲染缓冲上限,被静默降采样后 CSS 放大反而更糊;
  2. 地图相机保持静止(pitch / bearing 恒定);
  3. 每帧算出审批过的目标 center/zoom,用 CSS translate + scale 平移缩放画布;
  4. 对每个投影的 HTML 覆盖层施加完全相同的变换;
  5. GeoJSON data 与 paint 属性仍然逐帧命令式动画——只有渲染器相机被冻结

其中 map.project + 指数缩放是关键:

const projected = map.project(camera.center);              // 相机中心投影
const scale = 2 ** (camera.zoom - baseZoom);               // baseZoom = max(起止 zoom)
const plate = {
  transform: `translate(${width / 2 - projected.x * scale}px, ${height / 2 - projected.y * scale}px) scale(${scale})`,
  transformOrigin: "0 0",
};

其余运维细节:

  • 底板的渲染 zoom 取任一相机途经点的最大 zoom(含中间点与 hold 相机),保证 CSS scale 永远 ≤ 1(plate 不会被放大);
  • plate 的居中基准是相机路径地理范围的中点而非终点,以最小化所需外扩(overscan);
  • 最大的画布边控制在 4096px 以内,除非实测过更大的 MAX_RENDERBUFFER_SIZE;一条路径放不进底板就拆成两段固定 plate 加一个明确的编辑过渡,不要为了清晰度赌一张超大画布
  • 区分两类失败:重复 shimmer = 活跃渲染器仍在移动;CSS 推进中瓦片持续发虚 = 底板规格不足 / 被内部降采样 / 被放大超过 1;
  • 验收必须渲染一段短 MP4(而非只看 Studio 预览),在相机移动中检查静态地形纹理与卫星细节;底层地图细节一旦抖动就改用固定 plate,不要当作"轻微预览瑕疵"放行

9. 把架构落地到新场景:替换清单与调参旋钮

综合四篇参考文档,将这套架构复用到新的"地图解说"制作时,按下面的清单逐项替换:

  1. 数据源决策map-data-sources.md):该要素在 provider 中且可精确过滤 → 用 MapTilerVectorElement;否则用 prep-geo.mjs 烘焙 GeoJSON。
  2. Geo 预处理map-geo-prep.md):调"要哪些国家"(country 列表 + 对应 polygon GeoJSON)、"标签落在哪个区域"(ANCHOR_BBOX[country])、"标签往哪挪"(NUDGE[country],lng/lat 偏移)、"画哪条国境"(完整命名源几何,绝不用可见范围)、"何时点亮"(由 stop 派生)。
  3. 时序(本架构文档 §3):调 RIVER_START / RIVER_ENDBORDER_S / FILL_S / LABEL_S;节拍长度 = 最后一个国家的完整序列 + 尾部。
  4. tokentokens.ts):各国填充色、深色国境阶、河流电光色与透明度、FILL_OPACITY
  5. 相机render-stability.md):批准 center/zoom 路径 → 换算 plate 尺寸与 transform。
  6. 渲染验收bunx remotion render src/index.ts MapExplainer out.mp4 --gl=angle --concurrency=1 --timeout=120000,重点核验相机运动期间底图无抖动、笔头淡出干净、多段国境无跨缺口连画。

10. 本文要点速览

  • 每帧渲染 = delayRender → setData/setPaintProperty → once('idle') → continueRender → triggerRepaint,配 preserveDrawingBuffer:true--gl=angle
  • 时序全部以秒驱动,节拍由序列反推;国境/填充/标签采用自触发起的恒定时长,绝不挂在河流 reveal 的分片上;
  • "电光" = 白热笔头(末端 3% 的独立 line 切片层)在绘制中高亮、河口处 0.5s 淡出;
  • 瓦片几何无全局顺序:方向性绘制一律烘焙成有序 GeoJSON,逐段累计长度切片成 MultiLineString
  • 填充用 easeOutBack 型 opacity 超调回落在 0.25s 内产生"绽开";
  • 标签 = 每帧 map.project(anchor) 到屏幕坐标的 HTML 覆盖层,effect 依赖排除 labels 避免死循环;
  • 移动相机永不逐帧 jumpTo,而是超大固定 plate + CSS transform,投影坐标与 plate 变换同源叠加;
  • 任何示例数值与颜色都只是示范,生产环境必须换成自己的 token、字体与编辑源核对过的数据。
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.76 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
860
1.35 K
docsdocs
暂无描述
Markdown
899
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
925
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.84 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
533
601
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.03 K
525
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.37 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
395