等距视角(Isometric)游戏开发教程:从菱形瓦片到遮挡排序

等距视角(Isometric)是游戏史上生命力最强的视觉方案之一。从《模拟城市》《帝国时代》到《暗黑破坏神》,再到千禧年前后一大批国产 2D 网游,这种"斜 45 度俯瞰"的画面撑起了整整一个时代。我早年参与过几款这类 2.5D 网游客户端的开发,后来也研究过现代引擎里的同类系统,发现这道题的两代答案恰好构成一条非常好的学习路径:同一个问题,在"只有软件光栅"和"有现代引擎"两种约束下,会演化出两套截然不同但同样精巧的架构。

这篇教程就把这条路径完整走一遍:从坐标系起步,到地图渲染,再到这类游戏真正的技术核心——遮挡排序——最后收在寻路和性能工程上。文中的代码都是示意性的 C++ 伪代码,重在表达结构而非可直接编译。

〇、先厘清概念:它其实不是"真"等距

学术定义的 isometric projection(等轴测投影)要求三个坐标轴两两夹角 120°,俯角约 35.26°,菱形瓦片的宽高比为 √3:1 ≈ 1.73:1。而游戏里实际用的几乎都是 2:1 dimetric(二轴测):菱形宽是高的两倍,等效俯角 atan(0.5) ≈ 26.57°。

为什么游戏业集体选择 2:1?因为像素。2:1 的斜边在像素网格上正好是"横 2 点、纵 1 点"的完美阶梯,线条干净无锯齿;而 √3:1 是无理数比例,画出来全是毛边。所以业内说的"Isometric 游戏",默认就是 2:1 dimetric,本文也沿用这个惯例。

这类游戏的核心矛盾只有一句话:2D 渲染管线没有深度缓冲(Z-buffer),屏幕上谁挡住谁,完全由绘制顺序决定。 先画的会被后画的盖住——这就是"画家算法"(Painter's Algorithm)。于是"每个对象第几个画"就成了整套引擎的题眼,本文一半的篇幅都在围绕它展开。

一、坐标系:一切的起点

等距游戏里至少存在三套坐标,新手最容易在这里迷路:

逻辑格到屏幕像素的变换,是所有教程的第一页:

// 一个逻辑格投影到屏幕上是一个 64×32 的菱形(2:1)
const int TILE_W = 64;   // 菱形宽
const int TILE_H = 32;   // 菱形高

void WorldToScreen(int x, int y, int& sx, int& sy) {
    sx = (x - y) * (TILE_W / 2);   // (x - y) * 32
    sy = (x + y) * (TILE_H / 2);   // (x + y) * 16
}

void ScreenToWorld(int sx, int sy, int& x, int& y) {
    x = (sx / 32 + sy / 16) / 2;   // 整数运算,落地时向下取整
    y = (sy / 16 - sx / 32) / 2;
}

几何直觉很简单:世界 X 轴在屏幕上指向右下,世界 Y 轴指向左下,两个方向各走一步,屏幕 Y 就下移一格菱形的高度。于是屏幕上出现了一个天然秩序——screenY 越大,位置越靠屏幕下方,离观察者越近。请记住这句话,它是后面所有排序算法的地基。

等距网格与坐标变换:世界 X 轴指向屏幕右下,世界 Y 轴指向左下;一个逻辑格投影为 64×32 的菱形

鼠标拾取是反方向的问题:玩家点了一个像素,要知道点的是哪一格。先用 ScreenToWorld 拿到候选格,再做一次点-菱形测试修正误差——因为菱形的四个角区域属于相邻格:

// 判断点 (px, py) 在菱形 (cx, cy) 内的哪个区域
// 利用 2:1 斜率:|dx| / 32 + |dy| / 16 <= 1 即在菱形内
bool PointInDiamond(int px, int py, int cx, int cy) {
    int dx = abs(px - cx), dy = abs(py - cy);
    return dx * TILE_H + dy * TILE_W <= TILE_W * TILE_H / 2;
}

一个可以颠覆认知的观察

上面这套变换公式,教科书都会讲。但商业端游里有个更激进的做法:投影根本不在运行期做。 整张地图在美术管线里就用 3D 软件按等距视角烘焙成一整张 2D 位图,运行期只做一件事——把这张位图按相机原点偏移 blit 上去:

void Map::Draw() {
    // m_Bitmap 是离线烘焙好的整张地图;m_CamX/m_CamY 是相机原点
    Blit(-m_CamX, -m_CamY, m_Bitmap);
}

这个做法把"3D 渲染"问题降维成了"2D 像素合成"问题:运行期没有任何投影数学,位图坐标 = 屏幕坐标,所有系统(掩码、寻路、对象排序)共享同一套整数像素坐标,互相查询零转换。代价是内存和美术管线的复杂度,换来的是运行期极致的简单和稳定。这个"离线扛复杂度、在线扛性能"的思路会贯穿全文。

二、画地图:从逐瓦片到整图烘焙

入门级的等距地图是双重循环逐瓦片绘制,用到一个关键技巧——错位(staggered)摆放

for (int row = 0; row < rows; ++row) {
    for (int col = 0; col < cols; ++col) {
        // 奇数行横向错开半格,纵向每行下移 1/4 格
        int sx = col * (TILE_W / 2) + (row % 2) * (TILE_W / 2);
        int sy = row * (TILE_H / 2);
        DrawSprite(tileSprite[map[row][col]], sx, sy);
    }
}

逐瓦片方案灵活(可以运行时换瓦片、做编辑器),但商业 MMO 大多选择另一条路:美术在 3D 软件里搭好整个场景,用一台严格对齐的相机把它烘焙成位图。这台相机的参数是整条管线的灵魂:

参数 取值 作用
投影方式 正交(Orthographic) 消灭透视变形,近处远处一样大
方位角 45° 正对着一个格角看
仰角 atan(0.5) ≈ 26.57° 强制 2:1 dimetric 比例
像素比例 1 世界单位 = 1 像素 烘出来的位图运行期零缩放

同一场景通常烘两份产物:主位图(好看的画面)和遮挡掩码(每个抬高物——墙、房屋、树——的 alpha 剪影 + 它在位图里的外接矩形)。掩码的外接矩形角点要吸附到格网上,方便运行期按格索引。这份掩码数据是第五章的主角,先记住它的存在。

运行期因此朴素得近乎无聊:相机滚动只是改 (camX, camY) 两个整数,绘制是一次整图 blit。所有真正的复杂度都被推到了离线管线里。

三、画家算法与 Y 排序:80% 的场景一招就够

地图画完,轮到动态对象登场:玩家、NPC、掉落物品、魔法特效。它们没有深度缓冲可用,唯一武器是绘制顺序——从远到近画,近的盖住远的

得益于第一节的那个观察(screenY 大 = 近),最简单的排序就是按脚底点的 Y 排:

struct Obj {
    int x, y;          // 脚底点(位图像素坐标)
    Sprite* sprite;    // 绘制时以脚底点为锚,向上画
};

// 每帧:按脚底 Y 升序排序,依次绘制
std::sort(objs.begin(), objs.end(),
          [](Obj* a, Obj* b) { return a->y < b->y; });
for (Obj* o : objs) DrawSprite(o->sprite, o->x, o->y);

如果对象带逻辑格坐标,等价写法是 depth = x + y(正是 screenY = (x+y)×16 的单调性)。很多教程到这里就结束了,而它在实践中也确实能盖住大约八成的情况。但作为开发者必须清楚它在哪三种情况下会穿帮:

Y 排序失效的三个经典场景:同 Y 紧贴站立、高个体的树冠、垂直堆叠

  1. 同 Y 不同 X:两个角色紧贴站在同一排,脚底 Y 几乎相等,Y 排序分不出先后,谁后画纯看运气,帧间可能来回闪;
  2. 高个体:一棵树的脚底比角色远(Y 更小),但高高的树冠在屏幕上越过角色的位置。按脚底 Y 排序,树先画、角色后画,角色的头会盖住树冠——视觉上角色像是从树冠"前面"挤过去的;
  3. 垂直堆叠:同一个格子上叠三只箱子,Y 完全一样,必须有 Z 维度参与。

剩下的章节,就是两代工程师对这三个问题的不同回答。

四、先把业务规则从几何里摘出来

进入几何精判之前,老道的工程师会先问一个问题:是不是所有遮挡关系都该由几何决定?

答案是否定的。网游里有一类铁律是业务定的:尸体必须躺在所有活人脚下;掉落物品要压在尸体上、但又被活人踩住;魔法特效永远飘在最上面。这类规则和几何无关,让几何排序去"碰巧"满足它们是危险的。

解法朴素而有效:桶预分类。绘制收集阶段按业务类型分桶,桶与桶之间是绝对的画家顺序,几何排序只在桶内发生:

class DrawQueue {
    List m_DieList;   // 尸体(躺平的)
    List m_ItmList;   // 地面物品
    List m_ObjList;   // 活物:NPC / 玩家 / 特效

    void Collect(Obj* o) {
        if (o->state == DEAD && o->frame >= FALL_FRAMES) {
            InsertSorted(m_DieList, o);      // 死透了:平躺的尸体
            return;
        }
        if (o->type == ITEM) {
            InsertSorted(m_ItmList, o);
            return;
        }
        InsertSorted(m_ObjList, o);          // 其余都是"站着的"
    }

    void Flush() {                           // 提交:严格按 尸体→物品→活物
        DrawList(m_DieList);
        DrawList(m_ItmList);
        DrawList(m_ObjList);
    }
};

注意第一个判断里的 frame >= FALL_FRAMES:角色死亡动画的倒下过程(前几帧)还是"站着的",要和活人一起参与几何排序;只有播完、彻底躺平的那一帧起,才沉入尸体桶。一行代码把"濒死"和"尸体"两种视觉实体分开了——这种细节是调试无数个"尸体盖住活人"的 bug 换来的。

桶内排序用什么算法?答案可能出乎意料:插入排序。理由是一连串具体的工程事实,而不是教科书复杂度:

void InsertSorted(List& list, Obj* o) {
    for (Node* p = list.head; p; p = p->next) {
        if (Compare(p->obj, o) >= 0) {   // 找到第一个"不比 o 远"的位置
            list.InsertBefore(p, o);
            return;
        }
    }
    list.AddTail(o);
}

整个收集-提交过程不维护任何跨帧状态:每帧重新分桶、重新排序、画完即弃。在对象数量受视口约束的前提下,这种"无状态"换来的是绝对的健壮——不会出现增量排序里常见的"某帧忘了更新导致永久错乱"。

五、近距离仲裁:静态掩码方案(端游时代的答案)

现在处理硬骨头:同一桶里两个紧贴的对象,Y 排序失灵时怎么办?

两段式比较:远的走捷径,近的才精算

第一个洞察是绝大多数对象对根本不需要精判。回忆第一节:一个逻辑格在屏幕上是 64×32 的菱形。如果两个对象的脚底 Y 差超过 64(垂直跨两个以上菱形)、或 X 差超过 128,它们的菱形凸包在屏幕上根本不可能重叠——此时简单 Y 排序必然正确:

int Compare(Obj* a, Obj* b) {
    int dx = a->x - b->x;
    int dy = a->y - b->y;

    // 快路径:离得够远,Y 说了算
    if (dy > 64 || dy < -64 || dx > 128 || dx < -128)
        return dy;

    // 慢路径:近邻冲突,请"裁判"仲裁(下文)
    for (Mask* m : QueryMasksNear(a->x, a->y - BODY_OFFSET)) {
        int c1 = m->quad.Classify(a->x, a->y);   // a 相对掩码:前/内/后
        int c2 = m->quad.Classify(b->x, b->y);   // b 相对掩码:前/内/后
        if (c1 > c2 && dy >= 0) return -1;       // a 在掩码之后且屏幕更低 → a 实际更远
        if (c1 < c2 && dy <= 0) return +1;       // a 在掩码之前且屏幕更高 → a 实际更近
    }
    return dy;   // 没有掩码命中,退回 Y 排序
}

这个结构保证了:98% 的比较走 O(1) 快路径,只有极少数近邻冲突才进入掩码仲裁。几何精确性和排序性能兼得。

裁判从哪来:预烘焙的菱形包围框

仲裁需要一个"第三方参照物",它就是第二章烘焙管线产出的静态掩码。每个抬高物(墙、房屋、大树)在烘焙期生成两份数据:逐像素的 alpha 剪影,以及一个 2:1 菱形包围框——由外接矩形的对角线反算而来:

struct Quad {
    Point v[4];   // 固定顺序:上、左、下、右

    // 只给外接矩形的对角线 (x1,y1)-(x2,y2),强制反算出 2:1 菱形
    void Make(int x1, int y1, int x2, int y2) {
        int W = x2 - x1, H = y2 - y1;
        int a = (2 * H - W) / 2;      // 左右顶点相对上下对角线的水平外扩
        int b = (2 * H - W) / 4;      // 垂直外扩,保持 2:1 斜率
        v[0] = {x1, y1};              // 上
        v[1] = {x1 - a, y1 + b};      // 左
        v[2] = {x2, y2};              // 下
        v[3] = {x2 + a, y2 - b};      // 右
    }
};

为什么用菱形而不是矩形做包围?因为等距世界里"前/后"的分界线本身就是斜的——一个 2:1 菱形的四条边恰好对齐世界坐标里的两条轴线方向,用它做"前/内/后"三区划分,几何含义最干净:

点-菱形前后判定:包围盒粗判加速,四条边叉积符号精判,返回 前(0)/内(1)/后(2)

// 返回 0=前方(屏幕下方,会遮挡别人)1=内部 2=后方(屏幕上方)
int Quad::Classify(int px, int py) {
    if (py < v[0].y) return 2;              // 包围盒粗判:上面
    if (py > v[2].y) return 0;              // 下面
    if (px < v[1].x || px > v[3].x) {       // 左右两侧:看斜边再定
        // …… 同样用叉积符号判断
    }
    // 包围盒内:对四条边各做一次叉积符号测试
    // 叉积 > 0 表示点在这条边的"外侧后方/前方"
    if (Cross(v[0], v[1], px, py) > 0) return 2;   // 上边外侧 → 后
    if (Cross(v[1], v[2], px, py) > 0) return 0;   // 左边外侧 → 前
    if (Cross(v[2], v[3], px, py) > 0) return 0;   // 下边外侧 → 前
    if (Cross(v[3], v[0], px, py) > 0) return 2;   // 右边外侧 → 后
    return 1;                                       // 内部
}

于是仲裁逻辑水到渠成:两个近邻对象 A、B 谁脚底 Y 差不多,就看它们相对同一堵墙的菱形谁更"后"。Compare 里那个 (c1, c2, dy) 三元组判断,本质是在问:当掩码夹在两者之间时,谁该被它挡住?

两个工程细节值得单独说:

  1. 掩码按格建桶索引。掩码外接矩形吸附到 32×32 格网后,给地图建一个 桶[格W × 格H] 数组,每格挂着覆盖它的掩码列表。查询时 x/32, y/32 一次定位,O(1) 拿到候选,每格通常只有 0–2 个掩码;
  2. 查询点要上移一个身位。角色的脚底在地上,但墙体遮挡发生在身体高度那一带。所以查询掩码时不用脚底 Y,而是用 y - 身高偏移(比如 50 像素 ≈ 一格 + 半个身高),把查询点提到角色躯干的位置——那才是"身后那排格子",遮挡关系最敏感的地方。这个偏移量硬编码是最常见的做法,代价是巨兽或幼儿体型会错位,讲究的做法是把身高存进对象数据。

收尾:像素级遮挡修正

排序解决的是"谁先画",但还有一类残留问题:角色站在墙后,排序再正确,角色的整张 sprite 还是会压在墙面像素上(因为墙是烘焙进地图位图的,角色是后画的)。做法是画完角色后,用同一份掩码做一次修正:算出角色 sprite 覆盖的格子范围,把那些"在角色前方"的掩码像素重新 blit 一遍,把穿帮的下半身抹掉。

遮挡修正:排序保证大关系,掩码像素把角色下半身重新盖住

注意这里的美感:排序仲裁用的是菱形包围框,像素修正用的也是它——同一个几何对象,先在对象级做粗判定,再在像素级做精修正,两处结论天然一致,不会出现"排序说在墙后、修正却往墙前抹"的自相矛盾。

最后一块拼图是视口剔除前置:对象进桶之前先用 脚底点 ± 安全余量(约 100 像素,照顾"脚底出屏但上半身还在屏内"的高个子)过滤掉离屏对象。注意剔除只发生在进桶前,桶内绝不再剔——因为遮挡判定要求 A 知道 B 的存在,哪怕 B 自己不会被画出来。

六、现代引擎的另一条路:依赖图与拓扑排序

时间快进到现代引擎时代。约束变了:对象不再是"一张 sprite + 一个脚底点",而是带完整三维包围盒(AABB)的实体;渲染管线支持按 Z 值自动排序;CPU 也快了几个数量级。同样的排序问题,演化出一套截然不同的解法:把"谁挡谁"建模成有向依赖图,把"画在哪一层"建模成显式深度分配。

第一步:两个物体谁画前面——穿透深度与主导轴

判定的输入是两组世界坐标 AABB(最小角点 + 尺寸)。思路分两层:

  1. 需要排序吗? 两个 AABB 在 X/Y 平面有投影交叠、且 Z 向高度也有交叠,才可能产生遮挡;
  2. 谁在前? 计算三个轴上的"穿透深度"——沿哪个轴把两者错开所需的距离最短,那个轴就是主导轴(它们是"最不可能分离"的方向);在主导轴上,侵入对方更多的那一方画在前面。

AABB 主导轴判定:沿 X 错开最省事,X 就是主导轴

// 返回 true 表示 "b 应画在 a 前面"
bool Depends(Vec3 aMin, Vec3 aSize, Vec3 bMin, Vec3 bSize) {
    Vec3 aMax = aMin + aSize, bMax = bMin + bSize;

    // 高度不交叠(一个在另一个正上方且足够高)→ 无遮挡关系
    bool overlapZ = (bMax.z > aMin.z) && (aMax.z > bMin.z);
    bool overlapXY = (aMax.x > bMin.x) && (aMax.y > bMin.y);
    if (!overlapZ || !overlapXY) return false;

    // 三个轴上的互相侵入量
    Vec3 dA = {aMax.x - bMin.x, aMax.y - bMin.y, bMax.z - aMin.z};
    Vec3 dB = {bMax.x - aMin.x, bMax.y - aMin.y, aMax.z - bMin.z};

    // 沿各轴错开所需的最小距离;越小越"难舍难分"
    Vec3 dP = aSize + bSize - Abs(dB - dA);

    if (dP.x <= dP.y && dP.x <= dP.z) return dA.x > dB.x;  // X 主导
    if (dP.y <= dP.x && dP.y <= dP.z) return dA.y > dB.y;  // Y 主导
    return dA.z > dB.z;                                    // Z 主导(垂直堆叠)
}

注意这个判定天然解决了第三章的三个失效场景:垂直堆叠由 Z 轴主导轴处理;高个体因为 AABB 有真实高度而自动正确;同 Y 紧贴的对象由 X 或 Y 主导轴分胜负。一次判定,O(1)。

第二步:从两两关系到全局顺序——拓扑排序

每对重叠对象产生一条依赖边("A 依赖于 B" = B 先画),全场景的遮挡关系就构成一张有向图。把它变成线性绘制顺序,用深度优先遍历:

float Place(Obj* o, float startDepth) {
    if (o->placed) return startDepth;    // 防重入,顺带破环
    o->placed = true;

    for (Obj* dep : o->depends)          // 先递归安置所有"比我远"的
        startDepth = Place(dep, startDepth);

    o->z = startDepth;                   // 给 o 分配独占的深度层
    return startDepth + o->zExtent + STEP; // 下一个对象从"我的顶"开始
}

三个设计点:

第三步:别做无用功——空间索引与脏标记

两两判定是 O(N²),一个屏幕几百个对象就不能接受。配套的两件套:

两代方案对比

维度 端游方案(第五章) 现代方案(第六章)
对象模型 脚底点两个整数 完整 3D AABB
参照物 预烘焙静态掩码(菱形) 对象两两互判
全局顺序 桶 + 插入排序 依赖图 + DFS 拓扑排序
深度表达 隐式(绘制顺序本身) 显式(独占 Z 区间)
静态开销 每帧全量重排 脏标记,静态零开销
邻居发现 32×32 格桶索引 四叉树
擅长 海量同类型 sprite,软光栅 2D/3D 混合、垂直堆叠、动态场景
软肋 动态对象互遮挡靠掩码兜底 旋转物体的屏幕 AABB 会低估

没有优劣,只有约束不同:一个要把每一次 CPU 运算都省下来喂给软光栅 blit,一个可以大方地建图、遍历、分配浮点深度。看懂约束如何塑造架构,比记住任何一个算法都重要。

七、移动与寻路:别用菱形格

一个反直觉的结论:等距游戏的寻路网格不是菱形,是矩形子格。

菱形是视觉概念,不是网格概念——它没有干净的"邻居"定义(边相邻?点相邻?),写起寻路来处处别扭。实践中的做法是把每个 64×32 菱形再细分成 2×2 = 4 个 16×8 的矩形子格,寻路在这张普通矩形网格上做标准 8 邻居扩展:

// 子格粒度:宽 = 格宽/2,高 = 格高/4
const int SUB_W = 16, SUB_H = 8;

// 8 邻 BFS / A*:就是最普通的矩形网格寻路
const int DIRS[8][2] = {
    {-1,0},{1,0},{0,-1},{0,1},      // 四邻
    {-1,-1},{1,1},{1,-1},{-1,-1}    // 四对角
};

菱形与寻路子格:视觉上是 64×32 菱形,逻辑上是 16×8 矩形网格

16×8 这个 2:1 比例不是随便选的:它让子格在屏幕上恰好贴合等距空间的视觉形状(每个菱形里正好走 4 个位置),同时保持矩形网格的所有便利——数组下标直接映射、8 邻居扩展是轴对齐偏移、对角移动代价好算。

还有一个值得抄的数据设计:障碍图和寻路距离场共享同一块内存。两者粒度完全相同(都是 16×8 子格、同样尺寸的数组),寻路时直接在障碍图上做 BFS 写距离、回溯时读出来。零冗余,一致性自动成立——永远不会出现"障碍更新了但寻路缓存忘了刷"。

八、进阶话题速览

高度与地形起伏。 给每格加 z 坐标后,绘制位置向上偏移 z × 层间距,深度公式相应变成 depth = x + y + z。老方案里这体现为掩码的"身高偏移"和多层地图位图;新方案里 Z 本来就是 AABB 的一个轴,直接免费。

性能清单。 按投入产出排序:① 视口剔除永远前置,这是数量级级的优化;② 排序数据结构选链表 + 插入排序,利用帧间相干性;③ 空间索引(格桶或四叉树)把邻居发现降到 O(N·K);④ 脏标记让静态场景零开销;⑤ 对象池消灭逐帧分配。

陷阱清单(都是真 bug 的坟头):

结语

等距视角游戏的技术栈,本质上是在回答一个问题:如何用确定性的 2D 手段,模拟出 3D 空间的遮挡秩序。

这条教程走下来的路径是:坐标系确立"Y 即深度"的地基 → 预烘焙把投影赶出运行期 → 画家算法 + Y 排序解决大多数问题 → 桶分类把业务规则从几何中剥离 → 静态掩码仲裁近距离冲突 → 现代方案用依赖图和拓扑排序把问题一般化 → 寻路回归矩形网格。

今天写等距游戏,你可能直接用引擎的 YSort 或 sortingOrder 就解决了 80% 的需求。但当角色穿模、物体闪烁、排序错乱的那一天到来时,希望这篇教程能让你知道:脚下的每一寸土地,都是两代工程师踩着实实在在的坑填平的。