Mapbox 中生成带箭头的线

在地图引擎里,普通折线只需要根据路径生成线段几何,然后在片元着色器中填充颜色即可。但“带箭头的线”并不是简单地在线上放几个图标,它需要同时解决方向、重复间距、缩放稳定性、跨瓦片连续性和自定义贴图等问题。

本文以 PolylineOverlay 的箭头线实现为例,梳理从 API 到最终渲染的完整过程,并对比传统 symbol 方案和当前“线几何 + shader 重复采样”方案的差异。

传统 Symbol 方案

最直观的做法是用 symbol 图层在线上放置箭头图标。大致思路是:

  1. 根据折线生成一组点。
  2. 每隔一段距离在线上取一个采样点。
  3. 计算该点所在路段方向。
  4. 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_normala_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 中完成重复绘制。

这套方案的核心不是“把箭头画出来”,而是让箭头在缩放、跨瓦片、动态更新和自定义贴图场景下都保持稳定。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容