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