前五章建了一个能渲染、能加载、能生成地形的世界。但玩家只能看,不能碰。这一章加上「指哪打哪」的能力——鼠标指着一个方块,左键破坏它,右键放置它。

1. 从鼠标坐标到世界射线

第一步:把屏幕上的像素坐标变成世界空间里的一根射线。

graph LR
    SCREEN["屏幕坐标<br/>mouseX, mouseY"]
    NDC["归一化设备坐标<br/>x∈[-1,1], y∈[-1,1]"]
    CLIP["裁剪空间坐标<br/>(x, y, -1) 和 (x, y, 1)"]
    WORLD["世界空间射线<br/>近点 → 远点"]

    SCREEN --> NDC
    NDC --> CLIP
    CLIP --> WORLD
struct Ray {
    QVector3D origin;
    QVector3D direction;  // 单位向量
};

Ray screenToRay(int mouseX, int mouseY,
                int screenW, int screenH,
                const QMatrix4x4 &proj,
                const QMatrix4x4 &view)
{
    // 1. 屏幕 → NDC
    float ndcX = (2.0f * mouseX) / screenW - 1.0f;
    float ndcY = 1.0f - (2.0f * mouseY) / screenH;  // Y 翻转

    // 2. NDC → 裁剪空间
    QVector4D clipNear(ndcX, ndcY, -1.0f, 1.0f);
    QVector4D clipFar (ndcX, ndcY,  1.0f, 1.0f);

    // 3. 裁剪空间 → 世界空间(逆 projection × 逆 view)
    QMatrix4x4 invProjView = (proj * view).inverted();
    QVector4D worldNear = invProjView * clipNear;
    QVector4D worldFar  = invProjView * clipFar;

    // 4. 透视除法
    QVector3D nearPoint = worldNear.toVector3D() / worldNear.w();
    QVector3D farPoint  = worldFar.toVector3D() / worldFar.w();

    QVector3D dir = (farPoint - nearPoint).normalized();
    return { nearPoint, dir };
}

关键步骤:proj * view 矩阵是整条计算链路里最重的——一个 4×4 矩阵的逆。但每帧只算一次,完全没有性能问题。


2. 两阶段算法:粗筛 → 精筛

射线有了,怎么在几百万个方块里找到被击中的那一个?

graph TD
    RAY["射线的 origin + direction"]
    AABB["Phase 1: AABB 检测<br/>跳过所有不相交的 Chunk"]
    DDA["Phase 2: DDA 遍历<br/>在命中 Chunk 内<br/>逐方块推进射线"]
    HIT["命中方块坐标<br/>(x, y, z)"]

    RAY --> AABB
    AABB -->|"命中一个 Chunk"| DDA
    AABB -->|"没命中"| MISS["返回空"]
    DDA -->|"命中方块"| HIT
    DDA -->|"飞出 Chunk"| AABB

2.1 Phase 1:AABB 粗筛 Chunk

每个 Chunk 有一个轴对齐包围盒(AABB = Axis-Aligned Bounding Box)。Chunk(cx,cz) 的 AABB:

struct AABB {
    QVector3D min, max;
};

AABB Chunk::boundingBox() const {
    return {
        QVector3D(m_cx * WIDTH, 0, m_cz * DEPTH),
        QVector3D((m_cx + 1) * WIDTH, HEIGHT, (m_cz + 1) * DEPTH)
    };
}

射线与 AABB 的相交检测——这是图形学里最经典的函数之一:

bool intersects(const Ray &ray, const AABB &box,
                float &tMin, float &tMax)
{
    tMin = 0.0f;
    tMax = std::numeric_limits<float>::max();

    // 对 X, Y, Z 三个轴分别检测
    for (int i = 0; i < 3; ++i) {
        float invD = 1.0f / ray.direction[i];
        float t0 = (box.min[i] - ray.origin[i]) * invD;
        float t1 = (box.max[i] - ray.origin[i]) * invD;

        if (invD < 0.0f) std::swap(t0, t1);

        tMin = std::max(tMin, t0);
        tMax = std::min(tMax, t1);

        if (tMax <= tMin) return false;
    }
    return true;
}

遍历所有 Chunk: 射线可能穿过多个 Chunk 才命中。按 AABB 命中的 tMin 排序,从近到远逐个检查:

Chunk* findChunk(const Ray &ray, const QVector<Chunk*> &chunks,
                 float &entryT)
{
    struct Hit { Chunk *c; float t; };
    QVector<Hit> hits;

    for (auto *chunk : chunks) {
        float t0, t1;
        if (intersects(ray, chunk->boundingBox(), t0, t1)) {
            hits.push_back({chunk, t0});
        }
    }

    std::sort(hits.begin(), hits.end(),
              [](auto &a, auto &b) { return a.t < b.t; });

    for (auto &h : hits) {
        entryT = h.t;
        return h.c;
    }
    return nullptr;
}

2.2 Phase 2:DDA 精筛方块

DDA(Digital Differential Analyzer)是一种逐格子推进的算法——从射线进入 Chunk 的位置开始,每次前进到下一个格子的边界。

struct BlockHit {
    int x, y, z;        // 方块坐标
    int face;           // 击中哪个面 (0-5)
    float distance;     // 从射线原点到交点的距离
};

std::optional<BlockHit> raycastBlock(const Ray &ray,
                                      Chunk *chunk,
                                      float entryT)
{
    // 射线以 entryT 进入 Chunk,从进入点开始 DDA 遍历
    QVector3D pos = ray.origin + ray.direction * entryT;

    int x = (int)floor(pos.x()) - chunk->chunkX() * Chunk::WIDTH;
    int y = (int)floor(pos.y());
    int z = (int)floor(pos.z()) - chunk->chunkZ() * Chunk::DEPTH;

    // DDA 的核心:步长和边界距离
    int stepX = (ray.direction.x() > 0) ? 1 : -1;
    int stepY = (ray.direction.y() > 0) ? 1 : -1;
    int stepZ = (ray.direction.z() > 0) ? 1 : -1;

    float tDeltaX = fabs(1.0f / ray.direction.x());
    float tDeltaY = fabs(1.0f / ray.direction.y());
    float tDeltaZ = fabs(1.0f / ray.direction.z());

    // 到下一个边界的距离
    float tMaxX = ((ray.direction.x() > 0
                    ? (chunk->chunkX() * Chunk::WIDTH + x + 1)
                    : (chunk->chunkX() * Chunk::WIDTH + x)) - pos.x()) / ray.direction.x();
    // ... Y, Z 同理

    int maxSteps = 256;  // 安全上限
    for (int step = 0; step < maxSteps; ++step) {
        if (chunk->getBlock(x, y, z).type != BlockType::Air) {
            return BlockHit{x, y, z, lastFace, distance};
        }

        // 前进到最近的边界
        if (tMaxX < tMaxY && tMaxX < tMaxZ) {
            x += stepX;
            tMaxX += tDeltaX;
            lastFace = (stepX > 0) ? 3 : 2;  // 左或右面
        } else if (tMaxY < tMaxZ) {
            y += stepY;
            tMaxY += tDeltaY;
            lastFace = (stepY > 0) ? 1 : 0;  // 下或上面
        } else {
            z += stepZ;
            tMaxZ += tDeltaZ;
            lastFace = (stepZ > 0) ? 5 : 4;  // 前或后面
        }

        // 检查是否离开 Chunk
        if (!chunk->isInBounds(x, y, z)) return std::nullopt;
    }
    return std::nullopt;
}
graph TD
    ENTRY["射线从 AABB 边界<br/>进入 Chunk"]
    CELL1["当前格: 空气<br/>DDA 步进 →"]
    CELL2["当前格: 空气<br/>DDA 步进 →"]
    CELL3["当前格: 石头<br/>✓ 命中!"]

    ENTRY --> CELL1
    CELL1 --> CELL2
    CELL2 --> CELL3

    style ENTRY fill:#3498db,color:#fff
    style CELL1 fill:#95a5a6,color:#fff
    style CELL2 fill:#95a5a6,color:#fff
    style CELL3 fill:#e74c3c,color:#fff

DDA 为什么高效: 它不是遍历 Chunk 里所有 65536 个方块,而是沿着射线方向每一步跳到「下一个可能的方块」。在 16×256×16 的 Chunk 里,最多走 16+256+16=288 步必然出界,实际上空气区域多的话几十步就命中了。


3. 交互闭环:高亮 + 破坏 + 放置

射线拾取的结果不能只是打印日志——要接上实际的交互。

3.1 高亮被瞄准的方块

class BlockHighlighter : public IRenderable {
    BlockHit m_target;
    Mesh m_highlightMesh;  // 线框模式的小立方体

public:
    void setTarget(const BlockHit &hit) { m_target = hit; }

    void render(QOpenGLFunctions *gl,
                const QMatrix4x4 &view,
                const QMatrix4x4 &proj) override
    {
        // 线框渲染:在目标方块外面画一个半透明边框
        gl->glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
        gl->glLineWidth(2.0f);
        // ... 用 world position + u_model 偏移画线框
        gl->glPolygonMode(GL_FRONT_AND_BACK, GL_FILL);
    }
};

3.2 破坏方块

void VoxelEditor::onMousePress(QMouseEvent *event) {
    auto hit = m_world.raycast(m_camera);
    if (!hit) return;

    if (event->button() == Qt::LeftButton) {
        // 左键:破坏
        m_world.setBlock(hit->x, hit->y, hit->z, BlockType::Air);
    } else if (event->button() == Qt::RightButton) {
        // 右键:在击中的面上放置方块
        auto placePos = adjacentBlock(hit);
        m_world.setBlock(placePos.x, placePos.y, placePos.z,
                         m_selectedBlockType);
    }
}

相邻方块计算: 根据 hit.face 在法线方向偏移一格。

BlockHit adjacentBlock(const BlockHit &hit) {
    BlockHit adj = hit;
    switch (hit.face) {
        case 0: adj.y++; break; case 1: adj.y--; break;
        case 2: adj.z++; break; case 3: adj.z--; break;
        case 4: adj.x++; break; case 5: adj.x--; break;
    }
    return adj;
}

4. 章节收尾

graph TD
    MOUSE["鼠标点击<br/>onMousePress()"]
    RAY2["screenToRay()<br/>屏幕 → 世界射线"]
    CHUNK_FIND["AABB 粗筛<br/>找命中的 Chunk"]
    DDA2["DDA 精筛<br/>逐方块推进"]
    LCLICK{"左键?"}
    RCLICK{"右键?"}
    DESTROY["setBlock(x,y,z, Air)"]
    PLACE["adjacentBlock + setBlock"]

    MOUSE --> RAY2
    RAY2 --> CHUNK_FIND
    CHUNK_FIND --> DDA2
    DDA2 --> LCLICK
    LCLICK -->|"是"| DESTROY
    LCLICK -->|"否"| RCLICK
    RCLICK -->|"是"| PLACE

    style MOUSE fill:#e74c3c,color:#fff
    style DDA2 fill:#f39c12,color:#fff
    style DESTROY fill:#c0392b,color:#fff
    style PLACE fill:#2ecc71,color:#fff

这一章做完了体素引擎的「交互闭环」——玩家能看到世界、移动视点、对准方块、破坏和放置。从现在开始,你可以开着这个引擎对自己的世界动手动脚了。


架构师复盘

射线拾取我掉过一个坑:DDA 迭代次数上限设得太小。

第一版我设了 maxSteps = 64,想着「16×16 Chunk 怎么会走超过 64 步」。结果当玩家仰头看远处的方块时,射线在空气区域里斜着走,Y 方向每一步只移动 1 格高度,64 步只走了 64 格高——Chunk 有 256 格高,远远不够。

改成 maxSteps = Chunk::WIDTH + Chunk::HEIGHT + Chunk::DEPTH(= 288)之后问题解决。这个上限是 DDA 算法的数学保证——在三个维度的格子世界里,不可能走超过 width+height+depth 步。

教训: 算法分析出来的理论上限,放在代码里当硬限制是安全的。但反过来——凭直觉设的魔数——迟早会被某个你没想过的场景打穿。


系列文章:体素引擎从零构建 | 作者:Logic