Threejs,地图标签绘制,碰撞检测逻辑

124 阅读11分钟

image.png

地图标签重叠优化:从"一团乱麻"到"井然有序"

本文基于一个真实的 Three.js 地图可视化项目,讲解当屏幕上同时出现几十个标签时,如何通过碰撞检测 + 扇区螺旋搜索,让标签自动找到不重叠的位置。


一、问题场景:标签打架了

想象你在看一张地图,上面分布着 20 多个风电场和光伏电站。每个场站都有一个图标(小圆点),旁边还要挂一个标签显示场站名称。

如果直接把标签堆在图标旁边,大概率会变成这样:

  • 标签 A 盖住标签 B
  • 标签 C 盖住图标 D
  • 缩放地图后,标签位置全乱了,越叠越厚

核心矛盾:标签要离图标近(让人知道是谁的),但又不能互相重叠(要看得清)。


二、整体思路:四步走策略

我们的解决思路可以概括为四步:

1. 统一坐标系(把 3D 世界坐标转成屏幕像素坐标)
2. 初始化布局(先给每个标签一个"默认座位")
3. 碰撞检测 + 避让(有人坐错了,就换个位置)
4. 画连线(用折线把标签和图标连起来)

下面逐个拆解。


三、第一步:统一坐标系

Three.js 里的物体活在世界坐标系(xyz),而标签是 HTML DOM 元素,活在屏幕坐标系(px)。要做碰撞检测,必须先让它们在同一个坐标系里对话。

实现:世界坐标 → 屏幕坐标

function worldToScreen(
    worldPos: THREE.Vector3,
    camera: THREE.Camera,
    width: number,
    height: number
): { x: number; y: number } {
    const vec = worldPos.clone().project(camera);
    return {
        x: ((vec.x + 1) / 2) * width,   // [-1,1] → [0, width]
        y: ((-vec.y + 1) / 2) * height, // [-1,1] → [0, height],Y 轴翻转
    };
}

通俗解释:Three.js 的相机把 3D 世界"拍扁"成了一张照片。project() 就是这个拍照过程,拍完后物体的位置在 -11 之间,我们再把它映射成屏幕上的像素坐标。

为什么 Y 轴要翻转?因为 Three.js 的屏幕坐标系原点在左下角,而 DOM 的原点在左上角。


四、第二步:初始化布局——先占个座

每个标签都有两个关键属性:

  • 图标矩形(iconRect):场站图标在屏幕上的占位
  • 标签矩形(labelRect):标签在屏幕上的占位

默认座位规则

我们先让所有标签坐在图标的正右侧,垂直居中:

function initLayoutNodes(points, config) {
    const offX = config.iconSize / 2 + config.visualGap; // 图标右边缘 + 10px 间距
    const offY = -config.labelHeight / 2;                // 垂直居中

    return points.map((p) => {
        const iconRect = {
            x: p.screen.x - config.iconSize / 2,
            y: p.screen.y - config.iconSize / 2,
            w: config.iconSize,
            h: config.iconSize,
        };
        const labelRect = {
            x: p.screen.x + offX,  // 默认在右侧
            y: p.screen.y + offY,
            w: config.labelWidth,
            h: config.labelHeight,
        };
        return { ...p, iconRect, labelRect, labelOffX: offX, labelOffY: offY };
    });
}

通俗解释:就像电影院找座位,我们先给每个人都发一张"右侧邻座"的票。如果这个位置没人坐,那就坐下了;如果有人,再换位置。


五、第三步:碰撞检测——怎么知道位置被占了?

AABB 碰撞检测

判断两个矩形是否重叠,游戏开发里最常用的就是 AABB(Axis-Aligned Bounding Box,轴对齐包围盒)。

原理非常简单:

两个矩形不重叠的条件(满足任意一条即可):
- 矩形 A 的右边 < 矩形 B 的左边
- 矩形 B 的右边 < 矩形 A 的左边
- 矩形 A 的底边 < 矩形 B 的顶边
- 矩形 B 的底边 < 矩形 A 的顶边

如果上面四条都不满足,那就是重叠了。

代码实现:

interface Rect {
    x: number; // 左上角 X
    y: number; // 左上角 Y
    w: number; // 宽度
    h: number; // 高度
}

function isColliding(r1: Rect, r2: Rect): boolean {
    return !(
        r1.x + r1.w < r2.x ||  // A 在 B 左边
        r2.x + r2.w < r1.x ||  // B 在 A 左边
        r1.y + r1.h < r2.y ||  // A 在 B 上边
        r2.y + r2.h < r1.y     // B 在 A 上边
    );
}

通俗解释:想象两个快递盒子放在桌上。如果 A 盒子的右边缘还没碰到 B 盒子的左边缘,那它们肯定不撞。只要四个"不撞条件"都不满足,那就是撞上了。

检测谁?

一个标签不能撞上两类东西:

  1. 所有图标(不管这个图标有没有被处理过)
  2. 已经放好的标签(按顺序处理,已放置的标签位置已经确定)
function checkNodeCollision(node, placed, allNodes): boolean {
    // 1. 撞上图标?
    for (const n of allNodes) {
        if (isColliding(node.labelRect, n.iconRect)) return true;
    }
    // 2. 撞上已放置的标签?
    for (const other of placed) {
        if (isColliding(node.labelRect, other.labelRect)) return true;
    }
    return false;
}

注意:我们检测的是所有图标,而不仅仅是"已处理"的图标。因为图标本身不会动,即使还没轮到它,它的位置也是固定的。


六、第四步:扇区螺旋搜索——撞了怎么办?

如果默认位置(正右侧)撞了,就需要找新位置

核心策略:分左右两组

我们先算一下所有图标的屏幕 X 坐标范围,取中位数,把场站分成两组:

  • 左组(屏幕偏左的场站):标签默认放图标左侧
  • 右组(屏幕偏右的场站):标签默认放图标右侧

这样从全局看,标签会均匀分布在图标群的两侧,而不是全部挤在一边。

let minX = Infinity, maxX = -Infinity;
for (const node of nodes) {
    minX = Math.min(minX, node.screen.x);
    maxX = Math.max(maxX, node.screen.x);
}
const cx = (minX + maxX) / 2; // 中位数分界线

const left: LayoutNode[] = [];
const right: LayoutNode[] = [];
for (const node of nodes) {
    if (node.screen.x >= cx) right.push(node);
    else left.push(node);
}

排序:从上到下依次落座

为了避免"下面的人把上面的路堵死",我们按 Y 坐标从小到大排序(从上到下):

left.sort((a, b) => a.screen.y - b.screen.y);
right.sort((a, b) => a.screen.y - b.screen.y);

扇区螺旋搜索

当默认位置发生碰撞时,我们不在 360° 乱找,而是限制在一个合理的扇区内搜索:

  • 左组:在图标左上方到左下方的扇区搜索(角度 135° ~ 225°
  • 右组:在图标右上方到右下方的扇区搜索(角度 -45° ~ 45°

搜索方式是螺旋式外扩:先在内圈尝试各个角度,不行就扩大半径到下一圈继续试。

function searchInSector(
    node, placed, allNodes,
    rangeStart, rangeEnd,   // 角度范围(弧度)
    baseDist,               // 起始半径
    distStep,               // 每圈半径增加量
    angleStep,              // 每步角度增量
    vCenter                 // 垂直居中偏移
): void {
    let ring = 0;
    while (true) {
        const radius = baseDist + ring * distStep; // 当前圈半径
        const steps = Math.ceil((rangeEnd - rangeStart) / angleStep);
        
        for (let i = 0; i <= steps; i++) {
            const angle = rangeStart + i * angleStep;
            // 根据极坐标算出新位置的偏移量
            node.labelOffX = Math.cos(angle) * radius;
            node.labelOffY = Math.sin(angle) * radius + vCenter;
            // 更新标签矩形位置
            node.labelRect.x = node.screen.x + node.labelOffX;
            node.labelRect.y = node.screen.y + node.labelOffY;
            // 不碰撞就坐下!
            if (!checkNodeCollision(node, placed, allNodes)) return;
        }
        ring++; // 扩大一圈继续找
    }
}

通俗解释:想象你在一个圆形广场上找空位。默认座位被占了,你就以图标为中心,先在内圈(半径小)沿着左半弧(或右半弧)走一圈看看有没有空位;没有就退到外层圈继续找,直到找到为止。

完整布局流程

function resolveLayout(nodes, config) {
    // ... 分左右组、排序 ...

    const placed: LayoutNode[] = [];
    
    // 左组:默认放左侧,撞了就在左扇区搜索
    for (const node of left) {
        node.labelOffX = -baseDistLeft;  // 左侧
        node.labelOffY = -halfLabelH;    // 垂直居中
        node.labelRect.x = node.screen.x + node.labelOffX;
        node.labelRect.y = node.screen.y + node.labelOffY;
        
        if (checkNodeCollision(node, placed, nodes)) {
            searchInSector(node, placed, nodes, 
                135 * Math.PI / 180, 225 * Math.PI / 180, // 左扇区
                baseDistLeft, distStep, angleStep, vCenter);
        }
        placed.push(node); // 落座成功,加入已放置列表
    }

    // 右组:默认放右侧,撞了就在右扇区搜索
    for (const node of right) {
        node.labelOffX = baseDistRight;   // 右侧
        node.labelOffY = -halfLabelH;
        node.labelRect.x = node.screen.x + node.labelOffX;
        node.labelRect.y = node.screen.y + node.labelOffY;
        
        if (checkNodeCollision(node, placed, nodes)) {
            searchInSector(node, placed, nodes,
                -45 * Math.PI / 180, 45 * Math.PI / 180,  // 右扇区
                baseDistRight, distStep, angleStep, vCenter);
        }
        placed.push(node);
    }
}

这里 placed 数组很关键:它记录了"已经安排好位置"的标签。后处理的标签只会检测与先处理标签的碰撞,而不会反过来影响前者——这保证了布局的确定性


七、第五步:画连线——标签属于谁?

标签被扇区搜索推远后,用户可能不知道它属于哪个图标。所以我们用 SVG 画一条折线,把图标中心和标签中心连起来。

连线策略:水平优先 or 垂直优先?

根据标签相对图标的位置,选择不同的折线风格:

  • 水平方向差异大(标签在很左或很右):先水平走一段,再拐向标签
  • 垂直方向差异大(标签在上或下):先垂直走一段,再拐向标签
function createLabelDom(name, type, offX, offY, showLeader, ...) {
    // ... 标签 DOM 创建 ...

    if (showLeader) {
        const labelCx = offX + labelW / 2;  // 标签中心 X(相对图标中心)
        const labelCy = offY + labelH / 2;  // 标签中心 Y(相对图标中心)

        // 计算 SVG 画布大小
        const svgLeft = Math.min(0, labelCx);
        const svgTop = Math.min(0, labelCy);
        const svgWidth = Math.max(1, Math.max(0, labelCx) - svgLeft);
        const svgHeight = Math.max(1, Math.max(0, labelCy) - svgTop);

        // 创建 SVG
        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        // ... 设置宽高、位置 ...

        const startX = -svgLeft;        // 图标中心在 SVG 内坐标
        const startY = -svgTop;
        const endX = labelCx - svgLeft; // 标签中心在 SVG 内坐标
        const endY = labelCy - svgTop;

        const path = document.createElementNS(svgNS, 'path');

        // 水平差异更大:先水平走,再拐过去
        if (Math.abs(endX - startX) > Math.abs(endY - startY) * 1.2) {
            const midX1 = startX + (endX - startX) * 0.2;
            const midX2 = startX + (endX - startX) * 0.8;
            path.setAttribute('d',
                `M ${startX} ${startY} L ${midX1} ${startY} L ${midX2} ${endY} L ${endX} ${endY}`);
        } else {
            // 垂直差异更大:先垂直走,再拐过去
            const midY1 = startY + (endY - startY) * 0.2;
            const midY2 = startY + (endY - startY) * 0.8;
            path.setAttribute('d',
                `M ${startX} ${startY} L ${startX} ${midY1} L ${endX} ${midY2} L ${endX} ${endY}`);
        }

        path.setAttribute('stroke', '#357739'); // 深绿色
        path.setAttribute('stroke-width', '2.5');
        svg.appendChild(path);
    }
}

通俗解释:标签被推远后,我们需要一根"绳子"把它和图标拴在一起。如果标签在左边很远,绳子就先横着走一段再拐过去;如果标签在上方,绳子就先竖着走再拐。这样看起来更自然,不会斜着乱穿。


八、第六步:生命周期管理——别重复渲染

地图可以缩放、平移,每次相机变化,屏幕坐标都会变,标签布局也需要重新计算。但如果直接重新渲染,会出现两个问题:

  1. 旧标签没删掉,新标签叠上去,越叠越厚
  2. 事件监听器重复绑定,缩放一次触发 N 次重绘

清理旧标签

const oldGroup = tp.scene.getObjectByName('auto-labels');
if (oldGroup) {
    oldGroup.traverse((child: any) => {
        if (child.element && child.element.parentNode) {
            child.element.parentNode.removeChild(child.element); // 从 DOM 移除
        }
    });
    tp.scene.remove(oldGroup); // 从 Three.js 场景移除
}

解绑旧事件 + 防抖

// 解绑上一次的事件
const prev = tp.__labelListeners;
if (prev) {
    tp.mitter.off('resize', prev.onResize);
    tp.container.removeEventListener('wheel', prev.reRenderOnChange);
    tp.container.removeEventListener('pointerup', prev.reRenderOnChange);
    tp.controls.removeEventListener('change', prev.reRenderOnChange);
}

// 防抖:相机变化很频繁,150ms 内只重绘一次
const reRenderOnChange = () => {
    if (timers.changeTimer) clearTimeout(timers.changeTimer);
    timers.changeTimer = window.setTimeout(() => {
        // 清旧 → 重新布局渲染
        doLayoutAndRender(tp);
    }, 150);
};

通俗解释:每次相机动了,不要立刻重绘(那样会卡),而是等用户停手 150ms 后再重绘。同时记得把上一次的"监听耳朵"摘掉,否则耳朵越长越多,最后动一下相机就会触发几十次重绘。


九、完整流程图

开始
  │
  ▼
世界坐标 ──→ 屏幕坐标
  │
  ▼
初始化标签矩形(默认在图标右侧)
  │
  ▼
按屏幕 X 坐标分左组 / 右组
  │
  ▼
每组按 Y 坐标从上到下排序
  │
  ▼
依次落座:
  ├─ 默认位置(左组左侧,右组右侧)
  ├─ 检测碰撞(撞图标?撞已落座标签?)
  ├─ 碰撞 → 扇区螺旋搜索新位置
  └─ 不碰撞 → 落座成功,加入已放置列表
  │
  ▼
为每个标签创建 DOM + SVG 连线
  │
  ▼
加入 Three.js 场景
  │
  ▼
绑定事件(resize / wheel / 相机变化)
  │
  ▼
触发重绘时:清旧 → 重新执行上述流程

十、效果对比

优化前优化后
标签全部堆在图标右侧,互相重叠标签按左右分组分布,碰撞后自动扇区避让
不知道标签属于哪个图标SVG 折线清晰连接标签与图标
缩放后标签越叠越厚旧标签完全清除,重新布局

十一、关键设计决策

1. 为什么用 AABB 而不是圆形碰撞?

标签和图标都是矩形(DOM 元素),用矩形碰撞检测更精确。圆形碰撞虽然计算更快,但会漏掉矩形角落的重叠。

2. 为什么分左右两组,而不是 360° 乱搜?

如果允许标签出现在任意方向,最终效果会很乱——有的标签在上、有的在下、有的交叉。限制在水平方向的扇区内,视觉上更整齐,也减少了搜索空间。

3. 为什么按 Y 坐标排序?

从上到下依次落座,可以让上方的标签优先占据好位置,下方的标签自然往空隙里填。如果反过来从下到上,上面的标签会被迫推到更远的地方。

4. 为什么只检测"已放置"标签,不检测"未放置"标签?

未放置的标签位置还没确定,检测它们没有意义。而且按顺序处理天然避免了循环依赖。


十二、可改进的方向

  1. 力导向布局:当节点很多时,可以用物理引擎(斥力/引力)替代螺旋搜索,效果更自然。
  2. 标签聚合:当缩放级别很小时,把附近的标签聚合成一个数字气泡(如"+5")。
  3. 优先级排序:重要的场站先落座,不重要的场站可以被隐藏或推到更远的位置。
  4. 缓存布局:相机小幅度平移时,可以直接平移标签 DOM,不需要重新做碰撞检测。

总结

标签重叠优化的本质是一个**"找座位"**问题:

  1. 把所有人放到同一个坐标系里(世界坐标 → 屏幕坐标)
  2. 给每个人一个默认座位(初始化布局)
  3. 检查座位是否被占(AABB 碰撞检测)
  4. 被占了就在附近扇区一圈圈找(螺旋搜索)
  5. 用绳子把人和座位连起来(SVG 折线)
  6. 场景变化时,清空重来,记得摘旧耳朵(生命周期管理)

这套方案不需要引入庞大的物理引擎,几百行代码就能实现一个确定性强、可预测、性能可控的标签自动布局系统。