← 返回首页

Perlin Noise 完整循序渐进教程

🎯 面向纯初学者,适配 Cocos Creator + TypeScript。
学习路径:感性认识 → 必备数学 → 原理拆解 → 手算例子 → 手写完整代码 → FBM分形 → 实战生成地形。

前置:会基础TS语法(函数、数组、循环);数学只需要初中水平。
周期建议:7天,每天1~1.5小时。

0. 什么是Perlin噪声(初学者必读)

历史小知识:Perlin噪声由 Ken Perlin 在1983年发明,用于电影《电子世界争霸战》,后来拿到奥斯卡技术成就奖。它属于相干噪声(Coherent Noise)。

普通随机 Math.random() 的问题

Perlin噪声能干什么?

生成平滑连续的随机数:位置挨在一起,数值就接近;距离越远,变化越大。

重要区分(很多初学者搞混)

⚠️ 关键点:Perlin噪声输出不是0~1!原版2D输出大约 ‑0.707 ~ +0.707,不是严格‑1到1,所以我们需要手动归一映射到[0,1]方便做地图高度判断。

🎨 实时噪声预览(可拖动滑块调参)

改变种子 = 换一套完全不同地形
越大细节越碎;越小地块越大
叠加多少层噪声,增加细节
高层细节的强弱,一般0.5
每层频率放大倍数,一般2.0
鼠标位置噪声值:-

阶段0:必备前置数学(第0天,30分钟)

本节目标:掌握3个Perlin噪声赖以生存的工具:向量点积、线性插值Lerp、Fade五次平滑函数。这一节所有内容,后面写噪声代码会原封不动用上,不要跳过。

0.1 向量与向量点积(最重要)

二维向量:\(\vec{v} = (x,y)\),代表一个方向+长度。Perlin噪声的网格顶点,存的就是这种向量,我们叫梯度向量。

向量点积公式: \[ \vec{a}\cdot\vec{b}=a_x \cdot b_x + a_y \cdot b_y \]

⚠️ 点积结果是一个数字(标量),不是向量!

通俗理解:点积可以衡量两个向量方向的相似度:
如果梯度向量朝向采样点,点积结果为正数;背离采样点,点积为负数。

几何含义:\(\vec{a}\) 在 \(\vec{b}\) 方向上的投影长度,再乘以 \(\vec{b}\) 的长度。
如果两个向量都是单位向量(长度=1):

✅ 手算示例:
\(\vec{a}=(2,3),\quad \vec{b}=(4,5)\)
\(a\cdot b = 2\times4 + 3\times5 = 8+15=23\)

噪声内例子:梯度向量 \(\vec{g}=(1,1)\),偏移向量 \(\vec{d}=(0.2,0.3)\)
点积 = \(1\times0.2 + 1\times0.3 = 0.5\)

随堂练习1:
\(\vec{g}=(-1,0),\quad \vec{d}=(0.7,0.2)\),求点积?

参考答案 \((-1)*0.7 + 0*0.2 = -0.7\)
易错提醒:不要和向量加法混淆,加法是 \((a_x+b_x, a_y+b_y)\);点积是乘积求和,输出数字!

0.2 线性插值 Lerp

\[ lerp(a,b,t) = a + t(b-a), \quad t\in[0,1] \]
function lerp(a: number, b: number, t: number): number {
    return a + t * (b - a);
}

示例:lerp(2, 10, 0.5) = 2 + 0.5*(8) = 6

问题:直接lerp是直线插值。如果我们直接用原始的fx、fy当作t插值,在网格边界处导数会突变,画面会出现生硬的格子接缝,这就是为什么我们需要fade。

随堂练习2:lerp(5, 20, 0.3) = ?

参考答案 5 + 0.3*(15) = 9.5

0.3 Fade 五次平滑函数(原版Perlin固定公式)

\[ fade(t)=6t^5 -15t^4 +10t^3 \] 等价代码写法: \[ fade(t)=t^3(t(6t-15)+10) \]

定义域 t ∈ [0,1]

性质:

通俗理解:把直线t,磨成S型平滑曲线。让插值在格子边界变化速度慢慢降到0,消除方块网格接缝。

function fade(t: number): number {
    return t * t * t * (t * (t * 6 - 15) + 10);
}

示例:fade(0)=0,fade(1)=1,fade(0.5)=0.5

随堂练习3(理解题):为什么Perlin不用原始fx直接lerp,而要先过fade?

参考答案 原始fx线性lerp在网格边缘斜率不连续,会出现方块网格痕迹;fade把插值系数变成平滑曲线,消除棱角,噪声过渡自然。

阶段0 验收自测

  1. 随便给两个二维向量,可以口算点积
  2. 会写lerp,知道t的范围是0~1
  3. 记住fade函数的代码,知道它的作用:平滑插值,消除接缝

阶段1:2D原版Perlin噪声完整原理(第1天,1h)

核心一句话回顾:Perlin噪声 = 在网格整点上预先绑定固定随机梯度向量;采样点求到四个角的偏移向量,分别点积梯度向量;再用fade平滑后的系数做双重lerp插值,得到平滑随机值。

1.1 通俗理解两个核心概念

1.2 输入与网格划分

输入:任意浮点数坐标 \((x,y)\),例如 \((2.3, 1.6)\)

第一步:取出这个点落在网格格子的左下角整数坐标 \[ ix = \lfloor x \rfloor,\quad iy = \lfloor y \rfloor \] \(\lfloor \rfloor\) = 向下取整(Math.floor)

例子:x=2.3,ix=2;y=1.6,iy=1
这个格子四个顶点:

采样点P相对四个顶点的偏移:
fx = x - ix = 0.3
fy = y - iy = 0.6

⚠️ 高频坑:B点偏移:fx - 1 = -0.7;D点偏移:fx - 1 = -0.7,fy - 1 = -0.4;很多新手直接写fx、fy,噪声直接坏掉!

1.3 原版固定梯度表

原版Perlin的梯度表(8个固定方向向量,不使用完全随机向量,避免方向性偏差)

[ [1,1], [-1,1], [1,-1], [-1,-1],
  [1,0], [-1,0], [0,1], [0,-1] ]

1.4 完整5步流程(建议背诵)

输入 P(x,y)

  1. 取网格左下角:ix = floor(x), iy = floor(y);fx=x‑ix,fy=y‑iy
  2. 四个网格顶点,通过perm数组分别取出各自的梯度向量 g00,g10,g01,g11
  3. 计算采样点到四个角的偏移向量,分别和对应梯度做点积,得到四个标量:d00, d10, d01, d11
  4. 计算平滑插值系数 u=fade(fx), v=fade(fy)
  5. 两次lerp插值:先水平插值,再垂直插值,输出噪声结果
x1 = lerp(d00, d10, u)
x2 = lerp(d01, d11, u)
result = lerp(x1, x2, v)
📄 伪代码版本(不看TS也可以读懂逻辑)
// 伪代码,仅逻辑演示,不可直接运行
function perlin(x, y):
    ix = floor(x)
    iy = floor(y)
    fx = x - ix
    fy = y - iy
    // 取四个角梯度向量
    g00 = get_gradient(ix, iy)
    g10 = get_gradient(ix+1, iy)
    g01 = get_gradient(ix, iy+1)
    g11 = get_gradient(ix+1, iy+1)
    // 偏移向量点积梯度
    d00 = dot(g00, (fx  , fy  ))
    d10 = dot(g10, (fx-1, fy  ))
    d01 = dot(g01, (fx  , fy-1))
    d11 = dot(g11, (fx-1, fy-1))
    u = fade(fx)
    v = fade(fy)
    x1 = lerp(d00, d10, u)
    x2 = lerp(d01, d11, u)
    return lerp(x1, x2, v)

1.5 完整手算示例

我们取采样点 \(P(2.3, 1.6)\),假设四个顶点梯度: \(g_{00}=(1,1),\;g_{10}=(-1,0),\;g_{01}=(0,1),\;g_{11}=(-1,-1)\)

  1. \(ix=2,iy=1,\;fx=0.3,\;fy=0.6\)
  2. 计算4组点积:
    • \(d_{00}=0.3\times 1 + 0.6\times 1 = 0.9\)
    • \(d_{10}=(0.3-1)\times(-1)+0.6\times 0 = 0.7\)
    • \(d_{01}=0.3\times 0 + (0.6)\times 1 = 0.6\)
    • \(d_{11}=(0.3-1)\times(-1)+(0.6-1)\times(-1) = 0.7 + 0.4 = 1.1\)
  3. 求平滑系数: \(u = fade(0.3),\;v=fade(0.6)\) \[ fade(0.3)=0.3^3(0.3(6\times0.3-15)+10)=0.027\times(0.3\times(-13.2)+10)=0.027\times 6.04 = 0.16308 \] \[ fade(0.6)=0.6^3(0.6(6\times0.6-15)+10)=0.216\times(0.6\times(-11.4)+10)=0.216\times 3.16=0.68256 \]
  4. 第一层插值: \(x_1 = lerp(0.9, 0.7, 0.16308) = 0.9 + 0.16308\times(-0.2) = 0.867384\) \(x_2 = lerp(0.6, 1.1, 0.16308) = 0.6 + 0.16308\times(0.5) = 0.68154\)
  5. 第二层插值: \(res = lerp(0.867384, 0.68154, 0.68256) \approx 0.730\)

得到该点噪声值约 0.73。注意:这个是手工假设梯度的演示;真实perm查表不会出现这么大数值,仅用来演算流程。

手算思考题 如果 fx=0,那么 u=fade(0)=0,此时 x1 = d00,为什么?

阶段2:手写最简2D Perlin Noise(第2天,1.5h)

目标:从零敲,不复制别人封装好的库,看懂每一行。下面是可直接在Cocos Creator TS运行的原版实现,带注释。

export class Perlin2D {
    private perm: number[];
    private grad: [number, number][] = [
        [1,1],[-1,1],[1,-1],[-1,-1],
        [1,0],[-1,0],[0,1],[0,-1]
    ];
    constructor(seed:number = 0) {
        // 生成0~255置换数组
        this.perm = Array.from({length:256}, (_,i)=>i);
        // 简单随机打乱
        const rng = (n:number)=>{
            const x = Math.sin(seed + n) * 10000;
            return x - Math.floor(x);
        }
        for(let i=255; i>0; i--){
            const j = Math.floor(rng(i)*(i+1));
            [this.perm[i], this.perm[j]] = [this.perm[j], this.perm[i]];
        }
        // 复制一份到后面,变成512,防止取模越界
        this.perm.push(...this.perm);
    }
    private fade(t:number):number {
        return t*t*t*(t*(t*6 - 15)+10);
    }
    private lerp(a:number,b:number,t:number):number {
        return a + t*(b-a);
    }
    // 根据ix,iy拿到梯度向量
    private getGrad(ix:number, iy:number):[number,number]{
        const idx = this.perm[ this.perm[ix] + iy ] % 8;
        return this.grad[idx];
    }
    // 核心噪声函数,输入浮点数x,y,输出大约 [-0.707,0.707]
    noise(x:number, y:number):number {
        const ix = Math.floor(x);
        const iy = Math.floor(y);
        const fx = x - ix;
        const fy = y - iy;
        const [gx00, gy00] = this.getGrad(ix, iy);
        const [gx10, gy10] = this.getGrad(ix+1, iy);
        const [gx01, gy01] = this.getGrad(ix, iy+1);
        const [gx11, gy11] = this.getGrad(ix+1, iy+1);
        // 偏移向量点积梯度
        const d00 = fx * gx00 + fy * gy00;
        const d10 = (fx-1)*gx10 + fy * gy10;
        const d01 = fx * gx01 + (fy-1)*gy01;
        const d11 = (fx-1)*gx11 + (fy-1)*gy11;
        const u = this.fade(fx);
        const v = this.fade(fy);
        const x1 = this.lerp(d00, d10, u);
        const x2 = this.lerp(d01, d11, u);
        return this.lerp(x1, x2, v);
    }
    // 辅助:归一到 [0,1],地图最常用
    noise01(x:number,y:number):number{
        const n = this.noise(x,y);
        return (n + 0.707) / (0.707*2);
    }
}

测试代码:

const p = new Perlin2D(12345);
console.log(p.noise01(1.2,3.4));
// 同一个坐标输出固定值,这就是噪声!

阶段2任务:

  1. 自己手打一遍,不要复制粘贴
  2. 逐行对照5步原理,标记每一行对应哪一步
  3. 修改seed,观察噪声变化

阶段3:分形噪声(FBM / 分形布朗运动,最重要!第3天,1h)

原生单层Perlin太柔和,像光滑塑料,游戏地形几乎全是FBM。

通俗理解FBM:多层噪声叠加。低频层做大山大河大轮廓;高频小层叠加岩石、小坑洼细节。

FBM = 多层Perlin叠加:

\[ fbm(x,y)=\sum_{i=0}^{octave-1} amplitude * noise(freq*x, freq*y) \] 每一轮:freq *= lacunarity; amp *= gain

加到Perlin2D类里面:

fbm(x:number,y:number, octaves:number=4, lacunarity=2, gain=0.5):number{
    let total = 0;
    let amplitude = 1;
    let frequency = 1;
    let maxValue = 0; // 用来归一化总和
    for(let i=0;i

调参体验:

阶段3验收:会调octave/lacunarity/gain,理解“低频做大轮廓,高频加小细节”。

阶段4:用Perlin生成网格地图(第4天,1.5h,Cocos实战)

逻辑:

  1. 遍历地图每一个格子 mx, my
  2. 传入噪声坐标:nx = mx * scale; ny = my * scale;scale是重中之重!scale越大地形越细碎;scale越小地块越大块
  3. 拿到fbm01的值 0~1
  4. 阈值判断地块类型
// mapW, mapH 地图宽高,scale噪声缩放
function generateMap(perlin:Perlin2D, mapW:number, mapH:number, scale:number=0.08){
    const tileMap:number[][] = [];
    for(let y=0;y

拿到tileMap二维数组后,循环实例化瓦片精灵渲染地图。

附加:岛屿生成(径向衰减,完整代码示例)

岛屿原理:计算每个点距离地图中心点距离,距离越远,把高度压低,边缘强制变成海水。
function generateIsland(perlin:Perlin2D, mapW:number, mapH:number, scale=0.08){
    const tileMap:number[][] = [];
    const cx = mapW*0.5;
    const cy = mapH*0.5;
    const maxRadius = Math.min(mapW,mapH)*0.45;
    for(let y=0;y

阶段5:进阶变种(第5天,1h,按需学)

湍流 Turbulence

取绝对值 Math.abs(noise()),山谷会变成峡谷沟壑;适合岩浆、崎岖山脉。

turbulence(x,y,oct=4){
    let sum=0,amp=1,freq=1;
    for(let i=0;i

域扭曲 Domain Warping

用噪声去偏移噪声采样坐标,做出扭曲蜿蜒河流、扭曲岛屿,割草游戏常用。

// 伪代码示意
ox = noise(x,y) * warpStrength;
oy = noise(x+100,y+100) * warpStrength;
h = fbm(x+ox, y+oy);

阶段6:对比 Simplex Noise + FastNoiseLite(第6天,40分钟)

原版Perlin在高维会有网格伪影、对角方向不均匀。Simplex(单纯形噪声)是Perlin后来改进版本,改用三角形网格,各向同性更好,3D/4D计算更快。

但是:2D场景差别不大。

✅学习建议:
学习阶段手写原版Perlin噪声理解底层原理;做正式游戏项目直接使用 FastNoiseLite,经过高度优化,bug少、速度快,不用自己维护噪声实现。

📋 初学者调试排错手册(高频BUG)

  • 现象:图像全是雪花噪点,没有大块地形
    原因:scale太大,采样频率过高。
    修复:把scale调小,例如0.05‑0.15区间。
  • 现象:画面出现整齐方块网格痕迹
    原因1:忘记写fade函数,直接原始fx/fy做lerp;
    原因2:点积的时候没有写‑1(fx‑1、fy‑1);
    修复:检查d10、d01、d11的偏移向量。
  • 现象:整张图几乎全黑或者全白,没有灰度变化
    原因:忘记归一化,直接使用noise()返回的‑0.707~0.707数值做0‑255绘图。
    修复:调用 noise01 / fbm01,映射到0‑1。
  • 现象:每次运行地形一模一样,完全不变
    原因:new Perlin2D(固定seed);修改seed参数可以换一套地形。
  • 现象:FBM叠加之后数值越叠越大,范围失控
    原因:忘记maxValue做归一化;
    修复:FBM代码中必须除以maxValue总和。
  • 现象:地形完全不动,修改参数画面毫无变化
    原因:画布渲染函数没有重新执行render()。

阶段7:复盘+最终考核(第7天)

高频坑清单汇总

  • 忘记scale,噪声采样太密 → 全是雪花噪点
  • 点积偏移向量忘记写‑1,噪声出现方块断层
  • 忘记归一化,正负乱飞,高度阈值判断失效
  • seed固定地形固定;每次new实例才会更换地形
  • FBM不加maxValue归一,输出数值范围爆炸

最终考核(全部做到代表真正掌握)

  1. 不看笔记,独立手写2D Perlin noise核心函数。
  2. 口头解释:梯度向量、点积、fade、lerp、perm置换数组各自作用。
  3. 手写FBM,能通过调整octave/gain/lacunarity改变地形样貌。
  4. 实现普通地形地图 + 径向衰减岛屿。

配套练习清单(按顺序做)

  1. 调用基础noise,打印多组相邻坐标数值,观察平滑连续变化。
  2. 绘制灰度噪声图,观察单层Perlin长什么样。
  3. 实现FBM,对比octave=1、octave=4视觉差异。
  4. 生成高度瓦片地图:水‑沙滩‑草地‑树林‑雪山。
  5. 实现湍流地形,观察峡谷沟壑效果。
  6. 实现域扭曲,做出蜿蜒扭曲的河流效果。
  7. 径向衰减,生成一座圆形岛屿。
拓展路线:学会Perlin之后,可以继续学习 WFC、BSP地牢、元胞洞穴、Voronoi沃罗诺伊图,组合实现更丰富的程序生成地图。