第09章 - 标注布局与符号渲染

标注是渲染管线里唯一”有全局状态”的部分。多边形的绘制彼此独立,而两段文字只要在屏幕上相交,就必须有一方让位——这个”谁让谁”的决定依赖整个视口的当前状态,所以 symbol 图层被拆成了两个阶段:布局(layout/placement) 与 绘制(render)。第一阶段算位置与可见性,第二阶段才生成顶点。

这一章按这两个阶段拆解,涉及的文件主要在这几处:

src/mln/text/            标注文本:shaping、glyph、bidi、collision、placement
src/mln/layout/          符号实例与特征(SymbolInstance、SymbolFeature)
src/mln/renderer/buckets/symbol_bucket.cpp   符号 bucket
src/mln/renderer/layers/render_symbol_layer.cpp  渲染层
src/mln/shaders/symbol_*.glsl                 绘制 shader

命名空间是 mln(旧版本 mbgl)。

9.1 一条标注的完整生命周期

从 GeoJSON 到屏幕像素,一条标注要经过六步:

① 解析       text-field / icon-image 表达式求值
② shaping    文本 → 字形序列(HarfBuzz + BiDi)
③ 布局      symbol-spacing / text-max-width 决定锚点与换行
④ 碰撞      CollisionIndex 判定可见性,跨 tile 用 CrossTileSymbolIndex 去重
⑤ bucket    写入顶点缓冲(四边形 + 纹理坐标)
⑥ 绘制      symbol_sdf / symbol_icon shader

第 ② 步到第 ④ 步是 symbol 图层独有的,其他图层从 ③ 直接跳到 ⑤。

9.2 SymbolLayoutProperties:布局属性一览

src/mln/style/layers/symbol_layer_properties.hpp 里,symbol 图层的布局属性是一份长列表,按前缀分两组:

class SymbolLayoutProperties : public Properties<
    IconAllowOverlap, IconAnchor, IconIgnorePlacement, IconImage, IconKeepUpright,
    IconOffset, IconOptional, IconPadding, IconPitchAlignment, IconRotate,
    IconRotationAlignment, IconSize, IconTextFit, IconTextFitPadding,
    SymbolAvoidEdges, SymbolPlacement, SymbolScreenSpace, SymbolSortKey,
    SymbolSpacing, SymbolZOrder,
    TextAllowOverlap, TextAnchor, TextField, TextFont, TextIgnorePlacement,
    TextJustify, TextKeepUpright, TextLetterSpacing, TextLineHeight, TextMaxAngle,
    TextMaxWidth, TextOffset, TextOptional, TextPadding, TextPitchAlignment,
    TextRadialOffset, TextRotate, TextRotationAlignment, TextSize, TextTransform,
    TextVariableAnchor, TextVariableAnchorOffset, TextWritingMode
> {};

真正影响冲突检测结果的只有少数几个:

属性 影响 常见误用
symbol-placement point / line / line-center 路线标签用 point,结果标注挤成一团
symbol-spacing 沿线放置的间距(像素) 默认 250,密路网里要降
text-allow-overlap true 时跳过碰撞检测 开了就不会自动避让,全部叠在一起
text-ignore-placement true 时不参与碰撞,不挤占别人 与上一个不同:它仍会显示,但不占位
text-optional true 时允许”只放 icon 不放文字” 与 icon-optional 搭配可做”空间不够就只显示图标”
symbol-sort-key 碰撞优先级,值小的先占位 不设时按数据顺序,结果不稳定
text-variable-anchor 允许在多个方向间切换以避让 需要配合 text-radial-offset

text-allow-overlap 与 text-ignore-placement 的区别值得单独记:前者是”我不参与碰撞”,后者是”我参与碰撞但不让别人受影响”。业务上做”重要标注强制显示、次要标注自动避让”时,正确组合是重要标注开 text-ignore-placement,而不是开 text-allow-overlap。

9.3 shaping:文本到字形

src/mln/text/ 下的流程是:BiDi 先按 Unicode 双向算法切分文本方向,harfbuzz 做字形整形(连字、字距),glyph_manager 负责按 fontstack + range 请求 PBF 字形数据。

GlyphManager 的接口围绕”请求-回调”:

class GlyphRequestor {
public:
    virtual void onGlyphsAvailable(GlyphMap, HBShapeRequests) = 0;
};

class GlyphManager {
public:
    // 请求某个 fontstack 的某个 range
    void getGlyphs(GlyphRequestor&, const FontStack&, const GlyphRange&);
};

字形按 256 个码位一段(GlyphRange)打包在 PBF 里。所以样式里的 glyphs URL 必须写成带 {range} 的模板:

{
  "glyphs": "https://example.com/fonts/{fontstack}/{range}.pbf"
}

三个高频错误:

  1. URL 少了 {range}:每个 range 请求打到同一个文件,只能显示部分字符;
  2. fontstack 里写了不存在的字体:引擎会对缺失 glyph 发请求,服务端 404,最终这些字显示成空白或豆腐块;
  3. 中文字体切片不全:常用汉字分布在上百个 range 里,只部署了一部分,表现为”部分地名正常、部分空白”。

9.4 sprite:图标与图片

icon-image 指向的是 sprite 里的图片,不是独立 URL。sprite 由样式里的 sprite 字段声明:

{
  "sprite": "https://example.com/sprites/basic-v1"
}

引擎会请求 basic-v1.json(索引)与 basic-v1.png(图集)。运行时新增的图片(比如 map.addImage())由 ImageManager 管理,接口在 src/mln/renderer/image_manager.hpp:

class ImageManager : public std::enable_shared_from_this<ImageManager> {
public:
    void reduceMemoryUse();
    void reduceMemoryUseIfCacheSizeExceedsLimit();
    // onStyleImageMissing 回调见 ImageManagerObserver
};

onStyleImageMissing 是样式引用了一张不存在的图片时触发的扩展点。用途是做动态图标:样式里写 icon-image: "poi-{class}",运行时按需从网络取图并 addImage()。注意图片尺寸会影响布局——图集里的图片以固定像素尺寸参与碰撞计算,运行时加一张 512×512 的图会让对应标注的碰撞盒突然变大。

9.5 碰撞检测:CollisionIndex

src/mln/text/collision_index.cpp 是判定”这条标注能不能显示”的地方。核心数据结构是一个网格索引 GridIndex<IndexedSubfeature>,把已放置的标注按格子记录,新的候选标注只查询重叠格子。

9.5.1 viewportPadding:为什么屏幕外的标注也要算

collision_index.cpp 开头有三个常量:

const float viewportPaddingDefault = 100;
const float viewportPaddingForStaticTiles = 1024;

inline float findViewportPadding(const TransformState& transformState, MapMode mapMode) {
    if (mapMode == MapMode::Tile) return viewportPaddingForStaticTiles;
    return (transformState.getPitch() != 0.0f) ? viewportPaddingDefault * 2 : viewportPaddingDefault;
}

三种情况:普通交互模式 100px,有俯仰角时 200px,静态出图模式 1024px。碰撞网格的尺寸是视口加上两侧 padding:

collisionGrid(transformState.getSize().width + 2 * viewportPadding,
              transformState.getSize().height + 2 * viewportPadding, ...)

为什么要多算一圈?因为地图在平移时,屏幕外的标注会进入视野。如果只对视口内做碰撞,平移过程中新进入的标监会突然和已有标注重叠——视觉上就是”拖动时文字不停地闪、换位置”。padding 让这个切换发生在屏幕外。

俯仰角翻倍是因为俯视时屏幕空间被压缩,同样像素距离对应的地理范围更大,需要更大的缓冲。

9.5.2 放置与插入

CollisionIndex 提供两个关键方法:

std::pair<bool, bool> placeFeature(const CollisionFeature& feature,
                                   Point<float> shift, const mat4& posMatrix,
                                   const mat4& labelPlaneMatrix, float textPixelRatio,
                                   const PlacedSymbol& symbol, float scale, float fontSize,
                                   bool allowOverlap, bool pitchWithMap, bool collisionDebug,
                                   const std::optional<CollisionBoundaries>& avoidEdges,
                                   const std::optional<std::function<bool(const RefIndexedSubfeature&)>>& collisionGroupPredicate,
                                   std::vector<ProjectedCollisionBox>& /*out*/);

void insertFeature(const CollisionFeature& feature,
                   const std::vector<ProjectedCollisionBox>&, bool ignorePlacement,
                   uint32_t bucketInstanceId, uint16_t collisionGroupId);

返回的 std::pair<bool, bool> 两个值分别是”文字是否放置”与”图标是否放置”——这就是”空间不够时只显示图标”的实现位置。placeFeature 只做判定不改状态;判定通过后由 insertFeature 把碰撞盒写进网格,后续候选才会被它挡住。

allowOverlap 来自 text-allow-overlap;ignorePlacement 来自 text-ignore-placement。两个参数分开传,所以三档行为都能表达:正常(false/false)、强制显示且占位(false/true)、强制显示且不占位(true/true)。

9.5.3 跨瓦片:CrossTileSymbolIndex

同一条标注在相邻两块瓦片里各出现一次(跨边界的线标注会被两块瓦片都裁到)。如果不去重,接缝处会出现两个同名标注叠在一起。

src/mln/text/cross_tile_symbol_index.hpp 解决这个问题。核心是给每个符号实例分配一个稳定的 crossTileID:

class TileLayerIndex {
public:
    TileLayerIndex(OverscaledTileID coord, std::vector<SymbolInstance>&,
                   uint32_t bucketInstanceId, std::string bucketLeaderId);
    Point<int64_t> getScaledCoordinates(const SymbolInstance&, const OverscaledTileID&) const;
    void findMatches(SymbolBucket&, const OverscaledTileID&, std::set<uint32_t>&) const;
    std::map<std::u16string, std::vector<IndexedSymbolInstance>> indexedSymbolInstances;
};

匹配逻辑是:新瓦片的符号按”文本 + 缩放后的坐标”去已索引的相邻瓦片里找同样的实例,找到就复用同一个 crossTileID。这样碰撞检测把它们当成同一个符号,不会重复判定,也不会因瓦片边界而突然消失。

在 RenderOrchestrator 里,这一步发生在符号布局之前:

auto result = crossTileSymbolIndex.addLayer(layer, longitude);
symbolBucketsAdded = symbolBucketsAdded || (result & CrossTileSymbolIndex::AddLayerResult::BucketsAdded);

注意 addLayer 的第二个参数是经度,因为世界副本不同意味着坐标不同。

9.6 SymbolBucket:布局结果的容器

src/mln/renderer/buckets/symbol_bucket.hpp 里,SymbolBucket 继承自 Bucket,负责把布局结果转成顶点数据。它的两个关键方法是:

void place(Placement&, const BucketPlacementData&, std::set<uint32_t>&);
void update(const Placement&, bool updateOpacities, const TransformState&,
            const RenderTile&, std::set<uint32_t>&);

place() 在布局阶段被调用,做碰撞检测并写入 PlacedSymbol;update() 在绘制阶段被调用,把位置与透明度写进动态顶点缓冲。

几个硬编码常量在同一个头文件里:

const uint16_t MAX_GLYPH_ICON_SIZE = 255;
const uint16_t SIZE_PACK_FACTOR = 128;
const uint16_t MAX_PACKED_SIZE = MAX_GLYPH_ICON_SIZE * SIZE_PACK_FACTOR;  // 32640

符号尺寸在顶点属性里用 16 位打包存储,能表示的最大值是 255 × 128 = 32640。所以 icon-size 或 text-size 极大时会被截断——这是”把 text-size 设成 500 后标注显示异常”的真实原因。

尺寸绑定有三个实现,按表达式的复杂度自动选择:

实现 适用场景
ConstantSymbolSizeBinder text-size 是常量:最快,一次算完
SourceFunctionSymbolSizeBinder 依赖 feature 属性(data-driven)
CompositeFunctionSymbolSizeBinder 同时依赖 zoom 与 feature 属性

9.7 RenderSymbolLayer:绘制阶段

src/mln/renderer/layers/render_symbol_layer.hpp 里定义了几个 shader group:

gfx::ShaderGroupPtr symbolIconGroup;
gfx::ShaderGroupPtr symbolSDFGroup;
gfx::ShaderGroupPtr symbolTextAndIconGroup;

gfx::ShaderGroupPtr collisionBoxGroup;
gfx::ShaderGroupPtr collisionCircleGroup;

前三个对应 shaders/symbol_icon.*、shaders/symbol_sdf.*、shaders/symbol_text_and_icon.*;后两个只在打开碰撞调试时用,画的是碰撞盒与碰撞圆。

两个默认尺寸写在类成员里:

float iconSize = 1.0f;
float textSize = 16.0f;

这两个值不是样式默认值(样式里 icon-size 默认 1、text-size 默认 16,恰好一致),而是”求值失败时的兜底值”。

9.7.1 淡入淡出

符号出现与消失不是瞬时的,src/mln/text/placement.hpp 里有一组状态类:

class OpacityState {
public:
    OpacityState(bool placed, bool skipFade);
    OpacityState(const OpacityState& prevState, float increment, bool placed);
    bool isHidden() const;
    float opacity;
    bool placed;
};

class JointOpacityState {
public:
    JointOpacityState(bool placedText, bool placedIcon, bool skipFade);
    ...
    OpacityState icon;
    OpacityState text;
};

skipFade 的注释解释了它的用途:符号在视口外但落在 viewportPadding 内时,可以跳过淡入动画——因为它还没真正进入视野,等它进来时应该直接完全不透明。这避免了”标注在边缘拖进来时慢慢浮现”的观感问题。

9.8 图标与文字的 paint 属性

绘制阶段用到的 paint 属性分两组(symbol_bucket.hpp 里的定义):

class IconPaintProperties : public Properties<IconOpacity, IconColor,
    IconHaloColor, IconHaloWidth, IconHaloBlur,
    IconTranslate, IconTranslateAnchor> {};

class TextPaintProperties : public Properties<TextOpacity, TextColor,
    TextHaloColor, TextHaloWidth, TextHaloBlur,
    TextTranslate, TextTranslateAnchor> {};
属性 作用 实践要点
icon-opacity / text-opacity 整体透明度 与淡入淡出是乘算关系,设 0.5 后最大可见度就是半透明
icon-color / text-color 颜色 对 SDF 图标与文字生效;普通 PNG 图标无效
text-halo-color 描边色 让文字在任何底图上可读的关键属性
text-halo-width 描边宽度 SDF 实现,实际宽度约为设定值的 1/4,通常设 1–2
text-halo-blur 描边模糊 0 是硬边,1 以上开始柔和
icon-halo-* 图标描边 只对 SDF 图标有效

text-halo-width 的实际显示宽度偏窄是设计使然:shader 里描边用的是 SDF 距离场,宽度按纹理像素换算。经验值是先设 1.5,再按底图复杂度微调。

9.9 排错清单

现象 根因 定位方法
文字全部空白 glyphs URL 缺 {range} 或字体文件缺失 看 Network 里 .pbf 请求是否 404
部分汉字空白 字形切片不全,只有部分 range 部署 补全 range,或用本地字体 localFontFamily
图标显示成方块/空白 sprite 未加载完,或缺 icon-image 引用的名字 等 sprite 加载完成;检查 sprite 的 json 索引
标注在拖动时不停闪 viewportPadding 相关:视口外未预留缓冲 检查是否有自定义 RenderSymbolLayer 改了这个值
标注在 tile 边界重复出现 CrossTileSymbolIndex 未生效(bucketLeaderId 不一致) 确认多个图层共享同一 layout group
text-size 设大后异常 打包上限 MAX_PACKED_SIZE = 32640 降到 255 以下的尺寸,或用图层缩放实现
设置了 text-allow-overlap 但仍被挤掉 实际要的是 text-ignore-placement 的语义 两者含义不同,见 9.2
空间不足时图标消失 icon-optional 默认 false 设 icon-optional: true 允许只放文字;反之配 text-optional
标注在俯仰时突然重排 findViewportPadding 从 100 变 200 预期行为,可接受;不能配置

9.10 生产建议

限制要素数量,而不是调参数。 每个候选都要投影并求碰撞盒,几千个候选在移动端就会掉帧;能聚合就聚合,能分级就分级。

用 symbol-sort-key 而不是依赖数据顺序。 不设时碰撞优先级取决于瓦片内要素的遍历顺序,不同缩放级别可能不同,表现为”某些标注时有时无”。显式设一个整数排序键,行为就稳定了。

中文字体优先用 localFontFamily。 中文场景下网络字形请求有几百个 range;Renderer 构造函数的 localFontFamily 参数(include/mln/renderer/renderer.hpp)可以让本地字体完全跳过网络请求。

halo 是移动端可读性的关键。 只给 text-color 不加 halo,在卫星影像或复杂路网上基本看不清。text-halo-color 取底图反色、宽度 1–2,是成本最低的可读性提升。

9.11 本章小结

要点 事实(main 分支)
两阶段 布局(placement/collision)与绘制(bucket→shader)分离
属性 布局属性在 SymbolLayoutProperties(含 symbol-* / text-* / icon-*)
文本整形 BiDi + HarfBuzz + GlyphManager;glyph URL 必须含 {range}
碰撞 CollisionIndex;viewportPadding 默认 100,有 pitch 200,Tile 模式 1024
跨瓦片 CrossTileSymbolIndex 分配稳定 crossTileId,避免接缝重复
尺寸上限 MAX_GLYPH_ICON_SIZE = 255、MAX_PACKED_SIZE = 32640
关键开关 text-allow-overlap(不参与碰撞)≠ text-ignore-placement(参与但不占位)
淡入 OpacityState / JointOpacityState,skipFade 用于视口外符号
Shader symbol_sdf(文字/文字描边)、symbol_icon、symbol_text_and_icon