第 4 章能让 Chunk 动态加载卸载了。但加载进来的 Chunk 全是空的——没有方块。这一章把方块填进去。
1. 噪声:从一维开始理解
噪声不是随机数。随机数是「每个点的值完全独立」,噪声是「相邻点的值有关联」。
graph LR
subgraph RANDOM["随机数"]
R1["0.7"] --> R2["0.1"] --> R3["0.9"] --> R4["0.3"]
end
subgraph NOISE["噪声"]
N1["0.5"] --> N2["0.6"] --> N3["0.5"] --> N4["0.4"]
end
随机数像心电图乱跳,噪声像山丘的起伏——相邻值平滑过渡。
1.1 一维噪声
// 最简单的「值噪声」:在整数位置随机取值,中间插值
float valueNoise1D(float x) {
int xi = (int)floor(x);
float t = x - xi; // 小数部分
// 两个端点的随机值(伪随机 = 确定性)
float v0 = hash(xi);
float v1 = hash(xi + 1);
// 平滑插值
t = t * t * (3.0f - 2.0f * t); // smoothstep
return lerp(v0, v1, t);
}
smoothstep(3t² - 2t³)让过渡不是死板的直线,而是在两端减速——更自然。
1.2 二维噪声 = 地形
把一维扩展到二维:四个角随机取值,双线性插值。
float valueNoise2D(float x, float z) {
int xi = (int)floor(x), zi = (int)floor(z);
float tx = x - xi, tz = z - zi;
float v00 = hash2D(xi, zi);
float v10 = hash2D(xi + 1, zi);
float v01 = hash2D(xi, zi + 1);
float v11 = hash2D(xi + 1, zi + 1);
tx = tx * tx * (3.0f - 2.0f * tx);
tz = tz * tz * (3.0f - 2.0f * tz);
float top = lerp(v00, v10, tx);
float bottom = lerp(v01, v11, tx);
return lerp(top, bottom, tz);
}
这就是「黑白电视雪花,但相邻像素是平滑过渡的」。每个像素的值在 0~1 之间。
1.3 三维噪声 = 洞穴
再加一个 Y 维度:
float valueNoise3D(float x, float y, float z) {
int xi = (int)floor(x), yi = (int)floor(y), zi = (int)floor(z);
float tx = x - xi, ty = y - yi, tz = z - zi;
// 8 个角的随机值,三次 smoothstep,三线性插值
// ...(逻辑类似二维,体积翻倍)
}
三维噪声是生成洞穴和矿脉的基础——如果噪声值 > 0.5 放空气,< 0.5 放石头,就能造出洞穴。
2. Perlin Noise — 多尺度叠加
单层噪声太平滑,没有细节。Perlin Noise 的核心思想:多层噪声叠加,每层频率翻倍、振幅减半。
float perlinNoise(float x, float z,
int octaves = 4,
float persistence = 0.5f)
{
float total = 0.0f;
float frequency = 1.0f;
float amplitude = 1.0f;
float maxValue = 0.0f;
for (int i = 0; i < octaves; ++i) {
total += valueNoise2D(x * frequency, z * frequency) * amplitude;
maxValue += amplitude;
amplitude *= persistence; // 振幅递减
frequency *= 2.0f; // 频率翻倍
}
return total / maxValue; // 归一化到 0~1
}
Octave 1 (低频, 大振幅): ▁▁▁▂▃▅▇█▇▅▃▂▁▁▁ → 山脉轮廓
Octave 2 (中频, 中振幅): ▁▃▂▅▄▆▅▇▆▄▃▄▂▃▁ → 山丘起伏
Octave 3 (高频, 小振幅): ▄▃▅▄▄▅▃▄▅▄▃▅▄▄▃ → 岩石细节
─────────────────────────────────────────
叠加结果: ▁▁▃▄▆██▇▆▅▄▃▂▁ → 真实地形
这个效果用 Mermaid 不好表达,但你可以在代码里跑一次 perlinNoise(x, z, 4, 0.5) 把结果画成灰度图——你会看到和 Minecraft 地形生成非常相似的图案。
3. 从噪声值 → 方块类型
噪声返回 0~1。怎么变成石头/泥土/草?
BlockType TerrainGenerator::blockFromHeight(float noise,
int worldY,
int baseHeight)
{
int surfaceY = baseHeight + (int)(noise * 32.0f); // 噪声控制地形起伏
if (worldY > surfaceY)
return BlockType::Air;
if (worldY == surfaceY)
return BlockType::Grass; // 表面 = 草方块
if (worldY >= surfaceY - 4)
return BlockType::Dirt; // 4 层泥土
return BlockType::Stone; // 深处 = 石头
}
graph TD
NOISE["Perlin Noise<br/>0.0 ~ 1.0"]
HEIGHT["地表高度<br/>baseHeight + noise * 32"]
Y["遍历 Y = 0..255"]
ABOVE{"worldY > surfaceY ?"}
SURFACE{"worldY == surfaceY ?"}
DIRT{"worldY >= surfaceY - 4 ?"}
NOISE --> HEIGHT
Y --> ABOVE
ABOVE -->|"是"| AIR["空气"]
ABOVE -->|"否"| SURFACE
SURFACE -->|"是"| GRASS["草方块"]
SURFACE -->|"否"| DIRT
DIRT -->|"是"| DIRT_BLOCK["泥土"]
DIRT -->|"否"| STONE["石头"]
style AIR fill:#87ceeb,color:#333
style GRASS fill:#2ecc71,color:#fff
style DIRT_BLOCK fill:#e67e22,color:#fff
style STONE fill:#7f8c8d,color:#fff
4. 树种生成
地形有了,来点树。不是每格都放——用另一套噪声判断是否放树:
void TerrainGenerator::placeTree(Chunk &chunk, int x, int z, int surfaceY) {
// 树干的噪声独立于地形——不是每个山顶都有树
float treeNoise = valueNoise2D(x * 0.7f + 100.0f, z * 0.7f + 100.0f);
if (treeNoise < 0.6f) return; // 60% 的地表格不放树
// 树干:5 格高
for (int ty = 1; ty <= 5; ++ty) {
chunk.set(x, surfaceY + ty, z, BlockType::Wood);
}
// 树冠:3×3×3 区域,Y 在树干上方 2 格
for (int dy = 2; dy <= 5; ++dy) {
for (int dx = -2; dx <= 2; ++dx) {
for (int dz = -2; dz <= 2; ++dz) {
if (abs(dx) == 2 && abs(dz) == 2 && dy < 4) continue; // 圆角
chunk.set(x + dx, surfaceY + dy, z + dz, BlockType::Leaves);
}
}
}
}
5. 参数化面板:QSlider 实时调地形
这是体素引擎课程第一次展示 Qt 的交互能力——用滑块实时改变世界生成参数。
// TerrainPanel.h
class TerrainPanel : public QWidget {
Q_OBJECT
public:
explicit TerrainPanel(QWidget *parent = nullptr);
signals:
void paramsChanged(int octaves, double frequency, double amplitude);
private:
QSlider *m_octavesSlider;
QSlider *m_frequencySlider;
QSlider *m_amplitudeSlider;
};
// TerrainPanel.cpp
TerrainPanel::TerrainPanel(QWidget *parent) : QWidget(parent) {
auto *layout = new QVBoxLayout(this);
// 层数:2~8
layout->addWidget(new QLabel("Octaves (细节层数)"));
m_octavesSlider = new QSlider(Qt::Horizontal);
m_octavesSlider->setRange(2, 8);
m_octavesSlider->setValue(4);
layout->addWidget(m_octavesSlider);
// 频率:1.0~4.0
layout->addWidget(new QLabel("Frequency (地形起伏频率)"));
m_frequencySlider = new QSlider(Qt::Horizontal);
m_frequencySlider->setRange(10, 40); // ×0.1
m_frequencySlider->setValue(10);
layout->addWidget(m_frequencySlider);
// 振幅:10~80
layout->addWidget(new QLabel("Amplitude (地形高度)"));
m_amplitudeSlider = new QSlider(Qt::Horizontal);
m_amplitudeSlider->setRange(10, 80);
m_amplitudeSlider->setValue(32);
layout->addWidget(m_amplitudeSlider);
auto *applyBtn = new QPushButton("重新生成世界");
layout->addWidget(applyBtn);
connect(applyBtn, &QPushButton::clicked, this, [this] {
emit paramsChanged(
m_octavesSlider->value(),
m_frequencySlider->value() / 10.0,
(double)m_amplitudeSlider->value()
);
});
}
把这个面板嵌在 VoxelEditor 主窗口的侧边栏,拖动滑块、点应用——整个世界实时重建。这种「调参即见效果」的体验,是文字教程给不了的。
6. 章节收尾
graph TD
SEED["世界种子<br/>hash 函数"]
NOISE1["Perlin Noise<br/>多层叠加"]
HEIGHT2["地表高度计算<br/>baseHeight + noise * 32"]
BLOCK["方块类型判定<br/>空气/草/泥土/石头"]
EXTRA["附加生成<br/>树/花/矿脉"]
CHUNK2["Chunk 填充完毕"]
SEED --> NOISE1
NOISE1 --> HEIGHT2
HEIGHT2 --> BLOCK
BLOCK --> EXTRA
EXTRA --> CHUNK2
style SEED fill:#e74c3c,color:#fff
style NOISE1 fill:#f39c12,color:#fff
style BLOCK fill:#2ecc71,color:#fff
style CHUNK2 fill:#3498db,color:#fff
架构师复盘
地形生成我犯过一个让世界看起来很无聊的错误:
用同一个噪声同时控制地形和树。 结果:高的地方必有树,矮的地方必秃。整个地形看起来像是「每种高度固定搭配一种特征」,完全不自然。
修起来很简单——树和地形用不同的噪声种子:
float terrainNoise = perlin(x, z, seed=42);
float treeNoise = perlin(x, z, seed=137);
两个种子让两个噪声完全独立。地形高的地方可能有树也可能没有,这才像真实的自然景观。
教训: 每个独立特征给它独立的噪声层,不要复用。噪声是廉价的——Perlin Noise 的 O(n) 完全不是瓶颈。
系列文章:体素引擎从零构建 | 作者:Logic