在地图引擎里,普通折线只需要根据路径生成线段几何,然后在片元着色器中填充颜色即可。但“带箭头的线”并不是简单地在线上放几个图标,它需要同时解决方向、重复间距、缩放稳定性、跨瓦片连续性和自定义贴图等问题。
本文以 PolylineOverlay 的箭头线实现为例,梳理从 API 到最终渲染的完整过程,并对比传统 symbol 方案和当前“线几何 + shader 重复采样”方案的差异。
传统 Symbol 方案
最直观的做法是用 symbol 图层在线上放置箭头图标。大致思路是:
- 根据折线生成一组点。
- 每隔一段距离在线上取一个采样点。
- 计算该点所在路段方向。
- 用
symbol或 marker 把箭头图标放到采样点上。
这种方案实现简单,适合箭头数量少、对连续性要求不高的场景。但如果希望箭头成为“线样式的一部分”,它会暴露很多问题。
Symbol 方案的缺点
箭头和线不是同一套几何。
线由 line layer 绘制,箭头由 symbol layer 绘制。两者在渲染管线、排序、碰撞、缩放策略上都不一样,容易出现线已经显示但箭头延迟、丢失或被碰撞系统隐藏的问题。
跨瓦片连续性不好处理。
如果每个瓦片单独生成 symbol,箭头间距会在瓦片边界重新计算,容易出现边界处两个箭头挤在一起,或者一个箭头被裁掉一半。
缩放时容易跳动。
symbol 的放置通常依赖屏幕空间或瓦片内采样。缩放过程中,采样点和碰撞结果可能变化,箭头会出现跳动、闪烁或“重新排布”的感觉。
箭头数量控制不自然。
如果想让箭头随 zoom 自动增减,需要在 CPU 侧重新采样路径,或者为不同 zoom 准备不同密度的数据,维护成本较高。
样式更新成本更高。
线宽、箭头间距、箭头贴图、箭头颜色如果是两个图层分别管理,就需要同步多套状态。动态更新时更容易出现线和箭头状态不一致。
性能开销更分散。
大量箭头 symbol 意味着大量独立图标实例。它们要经过 symbol placement、排序、可能的碰撞检测和图标 atlas 管理。对一条长线来说,这比在线 shader 中重复采样更重。
当前方案概览
当前实现没有使用独立 symbol 图层,而是把箭头作为 polyline-overlay 图层内部能力处理。
整体流程是:
PolylineOverlay API
↓
polyline-overlay layer paint properties
↓
worker bucket 生成普通线几何和箭头几何
↓
主线 shader 绘制线体
↓
箭头 shader 按路径距离重复采样箭头贴图
↓
得到带方向箭头的折线
Worker 中生成箭头几何
普通折线已经有自己的 line vertex buffer。但为了生成稳定的箭头,仅靠普通线几何不够,因此新增了一套箭头专用几何。
箭头顶点包含这些属性:
a_pos // 当前顶点在线段上的位置
a_normal // 线宽方向的法线
a_distance // 当前点沿路径走过的距离
a_dir // 当前线段方向
每个线段会被展开成一个 quad:
start left -------- end left
| |
| |
start right -------- end right
也就是 4 个顶点、2 个三角形。顶点里的 a_distance 用来决定箭头在路径上的重复位置,a_normal 和 a_dir 用来判断当前片元在线宽横向上的位置。
为了避免普通折线无端增加内存和构建成本,当前只有图层存在 line-dir-image 时才生成箭头几何。普通线不会额外创建 arrow vertex/index buffer。
长线段拆分
如果一条线段特别长,只用一个 quad 会导致插值跨度过大,箭头纹理容易变形,路径距离也不够稳定。
所以实现中会把长线段拆成多个小段:
const splitDistance = 2048 * overscaling;
每个小段单独生成 arrow quad。这样可以让 a_distance 和横向插值更稳定,箭头重复效果也更接近真实路径。
跨瓦片距离连续
瓦片边界是箭头线最容易出问题的地方。
如果每个 tile 都从 distance = 0 开始计算,边界处会出现:
- 前一个 tile 末尾箭头被裁掉一半;
- 后一个 tile 又马上重新开始一个箭头;
- 两个箭头间距异常接近。
为了解决这个问题,箭头距离需要结合 mapbox_clip_start / mapbox_clip_end 推回整条线上的全局距离偏移:
const clippedFeatureRatio = lineClipEnd - lineClipStart;
const fullFeatureDistance = tileLocalDistance / clippedFeatureRatio;
const arrowDistanceOffset = lineClipStart * fullFeatureDistance;
之后 tile 内部的距离从这个 offset 开始累加。这样不同瓦片虽然各自生成几何,但它们共享同一条全局距离轴,箭头重复周期就能跨瓦片连续。
Vertex Shader
箭头 vertex shader 的核心输出是:
out highp float v_distance;
out highp float v_flag;
其中:
float cosValue = dot(a_normal, a_dir);
float sinValue = cross(vec3(normalize(a_normal), 0.0), vec3(a_dir, 0.0)).z;
sinValue = step(0.0, sinValue);
v_distance = a_distance * u_ratio + (cosValue * width * 0.5);
v_flag = sinValue;
v_distance 表示当前片元沿路径方向的距离,用来计算箭头重复位置。v_flag 表示当前片元在线宽横向上的位置,用来映射箭头贴图的横向坐标。
最终顶点位置根据线宽和法线外扩:
gl_Position = projectTile(
a_pos + u_translation + lineOffset + a_normal * width / u_ratio * 0.5
);
Fragment Shader
片元着色器根据 v_distance 判断当前片元是否落在箭头图案范围内。
先根据贴图大小和线宽计算箭头显示尺寸:
vec2 display_size = (pattern_bottom_right - pattern_top_left) / pixel_ratio;
float icon_height = max(display_size.y, 1.0);
vec2 icon_size = display_size * vec2(max(u_dir_width, 1.0) / icon_height, 1.0);
再计算重复距离:
float repeat_distance = icon_size.x * 4.0 * u_dir_gap * u_meter_per_pixel_ratio;
float pattern_distance = mod(v_distance, repeat_distance);
如果当前片元不在箭头贴图范围内,就直接丢弃:
if (pattern_distance <= 0.0 || pattern_distance >= icon_size.x) {
discard;
}
然后映射到贴图坐标:
float line_progress = 1.0 - clamp(pattern_distance / icon_size.x, 0.0, 1.0);
vec2 texture_progress = vec2(v_flag, line_progress);
默认箭头使用 dirColor 染色:
if (u_custom_img_flag < 0.5) {
if (arrow_color.a <= 0.0) {
discard;
}
arrow_color = vec4(u_dir_color.rgb, u_dir_color.a);
}
自定义贴图则保留图片自己的颜色。
缩放稳定性
如果重复距离只使用当前 zoom 的像素比例,缩放地图时箭头会明显沿线滑动。
为了解决这个问题,shader 使用一个比例值:
u_meter_per_pixel_ratio = optimalTileUnitsPerPixel / currentTileUnitsPerPixel;
重复距离中乘上这个比例:
repeat_distance = icon_size.x * 4.0 * u_dir_gap * u_meter_per_pixel_ratio;
这样可以减少缩放过程中箭头相位漂移,让箭头视觉上更稳定。
Draw 阶段
渲染时分成两个 pass:
普通线 pass
↓
箭头 pass
先绘制线体:
program.draw(...bucket.layoutVertexBuffer...);
如果 line-dir-opacity > 0 且存在 line-dir-image,再绘制箭头:
arrowProgram.draw(
...bucket.arrowLayoutVertexBuffer,
bucket.arrowIndexBuffer,
bucket.arrowSegments
);
这样箭头 shader 可以拥有独立逻辑,同时又不需要额外创建一个用户可见的 symbol layer。
动态更新
箭头间距可以动态修改:
polyline.dirImgGap = 0.5;
内部会更新 paint property:
this._updatePaintProperty(this._lineLayerId, "line-dir-gap", gap);
showDir 也可以动态开启:
polyline.showDir = true;
这里有一个细节:如果创建时没有开启箭头,worker bucket 里可能没有箭头几何。因此首次启用箭头时,需要刷新 source,让 worker 重新构建带箭头几何的 bucket。
方案对比
| 维度 | Symbol 方案 | 线几何 + Shader 方案 |
|---|---|---|
| 箭头和线的关系 | 分属不同图层 | 同一个 polyline-overlay 图层内部处理 |
| 跨瓦片连续性 | 需要额外处理,容易断裂或重复 | 通过全局距离 offset 保持连续 |
| 缩放稳定性 | 容易跳动或重新排布 | 使用距离比例稳定重复相位 |
| 箭头数量控制 | 需要 CPU 侧采样或重建数据 | shader 根据距离自动重复 |
| 自定义贴图 | 可以支持,但要管理 symbol icon | 直接作为 line-dir-image 采样 |
| 动态间距 | 通常需要重新计算 symbol 点 | 更新 line-dir-gap 即可 |
| 渲染一致性 | 受 symbol placement、碰撞等影响 | 箭头作为线样式的一部分绘制 |
| 性能模型 | 大量独立 symbol 实例 | 一套几何 + shader 重复采样 |
当前方案的优势
当前方案最大的优势是:箭头不再是独立标注,而是线本身的一种渲染效果。
这带来了几个直接收益:
- 线和箭头生命周期一致。
- 样式更新集中在一个 overlay 内。
- 箭头间距由 shader 控制,动态调整成本低。
- 跨瓦片可以通过路径距离保持连续。
- 缩放时重复相位更稳定。
- 自定义贴图和默认染色都可以统一走同一套渲染管线。
-
普通线不会生成箭头几何,避免不必要的 buffer 成本。
202606161111236.gif
总结
带箭头的线看起来只是一个样式增强,但真正稳定的实现需要贯穿 API、worker 几何、image atlas、shader 和 draw pass。
相比传统 symbol 方案,当前“线几何 + shader 重复采样”的方式更适合把箭头作为线样式的一部分来实现。它不依赖独立 symbol placement,也不需要 CPU 侧反复生成箭头点,而是利用路径距离和贴图采样在 GPU 中完成重复绘制。
这套方案的核心不是“把箭头画出来”,而是让箭头在缩放、跨瓦片、动态更新和自定义贴图场景下都保持稳定。
