第 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);
}

smoothstep3t² - 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