乐于分享
好东西不私藏

Three.js 地形编辑器实战:实时雕刻地形高度 + 笔刷系统 + 撤销重做,完整地形创作工具

Three.js 地形编辑器实战:实时雕刻地形高度 + 笔刷系统 + 撤销重做,完整地形创作工具

你做了个 5km×5km 的大地形,程序化生成的山脉河流跑得飞起。

老板点开页面,看了三秒钟说:”挺好,但这儿我想要个山头,那儿我想要个湖。”

你:”……我改 Noise 参数重新生成?”

老板:”不用那么麻烦,你让我自己画一画就行。”

你心想:让用户在地形上直接”捏”? 那不就是地形编辑器吗 —— 美术用 Blender、ZBrush 一笔一笔雕,凭啥浏览器里就不行?

地形编辑器(Terrain Editor) 是连接”程序化生成”和”美术创作”的桥梁:

  • • 场景可信度:自动生成的地形有”程序感”,人工微调能去掉重复、突出地标
  • • 关卡设计:玩法关卡需要精确的地形(掩体、坡道、河流),程序化做不到像素级控制
  • • 玩家创作:我的世界、Roblox 的核心玩法就是让玩家挖山填海
  • • 教学演示:地理教学、地质模拟、灾害推演需要实时编辑工具
  • • 与既有文章呼应:本篇是 large-scale-terrain-rendering(地形生成)+ cloud-shadow-map(云影接收)+ undo-redo-system(操作历史)+ inspector-panel(属性面板)的大综合

本文从零实现一套生产级地形编辑器:

  • • 笔刷定位:Raycaster 命中地形 → 拿到世界坐标
  • • 6 大笔刷类型:隆起(升高)、平滑(平均)、压平(拉平)、噪波(叠加噪声)、抹除(恢复)、拉直(向目标高度)
  • • 3 种笔刷形状:圆形、方形、柔和(高斯衰减)
  • • 笔刷参数:半径、强度、衰减、笔尖预览
  • • 法线实时重算:高度变了,光照立刻跟着变
  • • Undo/Redo 集成:与上一篇文章的命令模式无缝衔接
  • • Splatmap 实时绘制:顺手在高度变化时刷材质(草地/泥土/岩石)
  • • Chunk 边界协调:与 large-scale-terrain-rendering 的 Chunk 系统联动
  • • 性能优化:批量应用、节流、Worker 异步、笔刷合并
  • • 完整 Demo:打开页面就能雕山

目录

  1. 1. 地形编辑器的核心难题
  2. 2. 整体架构设计
  3. 3. 笔刷定位:Raycaster 命中地形
  4. 4. 6 大笔刷类型完整实现
  5. 5. 3 种笔刷形状与衰减函数
  6. 6. 法线实时重算
  7. 7. Splatmap 实时绘制
  8. 8. Undo/Redo 集成
  9. 9. 笔刷预览环
  10. 10. Chunk 边界协调
  11. 11. 性能优化全景
  12. 12. 完整实战:地形编辑器 Demo
  13. 13. 常见问题速查

一、地形编辑器的核心难题

1.1 三大矛盾

矛盾
表现
解法
精度 vs 性能
修改单点高度后要重算法线(邻域 8 像素)
局部重算 + DirtyRegion
实时性 vs 一致性
笔刷落下立刻改变高度,但 Chunk 边界要协调
边界缓冲带 + 双线性插值
创作自由 vs 操作可逆
玩家乱挖也能撤销
集成 undo-redo-system 的 Command Pattern

1.2 与既有文章的关系

                    ┌────────────────────────┐
                    │   Three.js 生态全栈     │
                    └────────────────────────┘
                              │
   ┌──────────────┬───────────┼──────────┬──────────────┐
   ▼              ▼           ▼          ▼              ▼
 地形渲染        体积云      云影      开放世界       地形编辑器
 (本次)        (上次)      (上上次)   (上上上次)     (本篇)
   │              │           │          │              │
   └──────────────┴───────────┴──────────┴──────────────┘
                              │
                       都基于 Heightmap
                       都使用 Chunk 分块
                       都集成 Undo/Redo

本篇不是另起炉灶,而是把 large-scale-terrain-renderingundo-redo-systeminspector-panel 的能力组合起来。

1.3 方案选型

方案
适用场景
优势
劣势
顶点级编辑
小地形 < 1k 顶点
简单直接
性能差
Heightmap 编辑
中大地图(推荐)
GPU 友好、可序列化
需重传 data 纹理
GPU Compute 编辑
超大地图
并行极快
WebGPU 兼容性
体素编辑
沙盒游戏
任意方向可挖
内存爆炸

本文选 Heightmap 编辑——与前文 large-scale-terrain-rendering 的 DataTexture 方案完美衔接,GPU 友好,序列化简单。


二、整体架构设计

2.1 模块划分

// 整体架构
classTerrainEditor {
// 笔刷层
brushBrushEngine;             // 笔刷引擎:定位 + 类型 + 形状
brushPreviewBrushPreview;     // 笔刷预览环

// 数据层
heightmapHeightmap;           // 高度图(DataTexture)
normalMapNormalMap;           // 法线图
splatmapSplatmap;             // 材质混合图

// 修改层
modifierHeightModifier;       // 高度修改器(6 大类型)
normalRecalcNormalRecalculator// 法线重算器
splatPainterSplatPainter;     // 材质绘制器

// 协作层
chunkSyncChunkSync;           // 与 Chunk 系统同步
historyHistoryManager;        // Undo/Redo 集成

// UI 层
panelEditorPanel;             // 笔刷参数面板
toolbarToolbarUI;             // 工具栏
}

2.2 核心数据结构

// 笔刷类型枚举
enumBrushType {
RAISE = 'raise',           // 隆起(升高)
LOWER = 'lower',           // 降低(下沉)
SMOOTH = 'smooth',         // 平滑(取邻域平均)
FLATTEN = 'flatten',       // 压平(向目标高度)
NOISE = 'noise',           // 噪波(叠加噪声)
PAINT = 'paint',           // 绘制(仅改 Splatmap,不改高度)
ERASE = 'erase',           // 抹除(恢复原始高度)
}

// 笔刷形状枚举
enumBrushShape {
CIRCLE = 'circle',         // 圆形硬边
CIRCLE_SOFT = 'circle_soft'// 圆形高斯衰减(默认)
SQUARE = 'square',         // 方形
SQUARE_SOFT = 'square_soft'// 方形衰减
}

// 笔刷配置
interfaceBrushConfig {
typeBrushType;           // 笔刷类型
shapeBrushShape;         // 笔刷形状
radiusnumber;            // 半径(世界单位)
strengthnumber;          // 强度 [0, 1]
falloffnumber;           // 衰减 [0, 1],0=无衰减,1=高斯
targetHeight?: number;     // FLATTEN 笔刷的目标高度
noiseScale?: number;       // NOISE 笔刷的频率
paintLayer?: number;       // PAINT 笔刷的 Splatmap 通道
}

// 笔刷作用结果
interfaceBrushStroke {
modifiedPixelsArray<{xnumberynumberoldHnumbernewHnumber}>;
bounds: { minXnumbermaxXnumberminYnumbermaxYnumber };
}

2.3 编辑流程时序

用户鼠标按下
   ↓
Raycaster 命中地形 → 命中点世界坐标 → 转 Heightmap UV
   ↓
记录笔刷起点(用于 Undo)
   ↓
while (鼠标按住) {
   采样当前命中点
   ├─ 计算作用半径内的所有 Heightmap 像素
   ├─ 按笔刷类型应用修改
   ├─ 收集变更的像素到 dirtySet
   └─ 局部更新 DataTexture
}
   ↓
鼠标抬起 → 提交 Undo 命令(整条笔刷轨迹作为一个命令)
   ↓
重算 dirtySet 的法线 → 更新 NormalMap

三、笔刷定位:Raycaster 命中地形

3.1 基础命中检测

import * asTHREEfrom'three';

classBrushPicker {
private raycaster = newTHREE.Raycaster();
private mouse = newTHREE.Vector2();
privateterrainMeshTHREE.Mesh;

// 高度图参数
privateheightmapSizenumber;     // 高度图分辨率
privateworldWidthnumber;        // 地形世界宽度
privateworldDepthnumber;        // 地形世界深度

constructor(terrainMesh: THREE.MeshheightmapSizenumberworldSizenumber) {
this.terrainMesh = terrainMesh;
this.heightmapSize = heightmapSize;
this.worldWidth = worldSize;
this.worldDepth = worldSize;
  }

// 把鼠标事件转成世界坐标
pick(eventMouseEventcameraTHREE.CameracanvasHTMLCanvasElement): {
worldPosTHREE.Vector3;
uvTHREE.Vector2;
heightmapCoordTHREE.Vector2;
  } | null {
// 1. 归一化鼠标坐标
const rect = canvas.getBoundingClientRect();
this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

// 2. Raycaster 拾取
this.raycaster.setFromCamera(this.mouse, camera);
const intersects = this.raycaster.intersectObject(this.terrainMesh);

if (intersects.length === 0returnnull;

const hit = intersects[0];
const worldPos = hit.point.clone();
const uv = hit.uv!.clone();

// 3. UV → Heightmap 像素坐标
const heightmapCoord = newTHREE.Vector2(
      uv.x * this.heightmapSize,
      uv.y * this.heightmapSize
    );

return { worldPos, uv, heightmapCoord };
  }
}

3.2 性能优化:拾取节流

classThrottledPicker {
private lastPickTime = 0;
private pickInterval = 16;  // 60fps

pick(eventMouseEvent, ...argsany[]): PickResult | null {
const now = performance.now();
if (now - this.lastPickTime < this.pickInterval) {
returnnull;  // 跳过这一帧
    }
this.lastPickTime = now;
returnthis.doPick(event, ...args);
  }
}

四、6 大笔刷类型完整实现

4.1 笔刷基类

abstractclassBrush {
abstractapply(
heightmapFloat32Array,  // 高度数据(1D 数组,行优先)
centerXnumber,          // 中心像素 X
centerYnumber,          // 中心像素 Y
radiusnumber,           // 作用半径(像素)
configBrushConfig,
heightmapSizenumber
  ): BrushStroke;

// 工具:计算像素在笔刷内的衰减权重
protectedgetFalloff(distancenumberradiusnumberfalloffnumber): number {
if (distance >= radius) return0;
const t = distance / radius;  // 归一化距离 [0, 1]

if (falloff === 0) {
// 硬边
return1;
    } else {
// 高斯衰减
// falloff=0.5 → 中心 1,边缘 0.135
// falloff=1.0 → 中心 1,边缘 0
const sigma = 1 - falloff * 0.5;
returnMath.exp(-Math.pow(t, 2) / (2 * sigma * sigma));
    }
  }
}

4.2 RAISE / LOWER 笔刷(隆起 / 降低)

最简单也最常用:

classRaiseBrushextendsBrush {
apply(heightmap, cx, cy, radius, config, size) {
constmodifiedBrushStroke['modifiedPixels'] = [];
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

const r2 = radius * radius;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= size || y < 0 || y >= size) continue;

const distance = Math.sqrt(dist2);
const falloffWeight = this.getFalloff(distance, radius, config.falloff);
const strength = config.strength * falloffWeight;

// RAISE 是升高,LOWER 是降低(用负 strength)
const delta = config.type === BrushType.RAISE ? strength : -strength;

const idx = y * size + x;
const oldH = heightmap[idx];
const newH = oldH + delta;

        heightmap[idx] = newH;
        modified.push({ x, y, oldH, newH });

        minX = Math.min(minX, x); maxX = Math.max(maxX, x);
        minY = Math.min(minY, y); maxY = Math.max(maxY, y);
      }
    }

return {
modifiedPixels: modified,
bounds: { minX, maxX, minY, maxY }
    };
  }
}

4.3 SMOOTH 笔刷(平滑)

取邻域平均值,让尖锐的形状变得柔和:

classSmoothBrushextendsBrush {
apply(heightmap, cx, cy, radius, config, size) {
constmodifiedBrushStroke['modifiedPixels'] = [];
const r2 = radius * radius;

// 先采样整个影响区域
constsamples: {xnumberynumberhnumberwnumber}[] = [];
let sumH = 0, sumW = 0;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= size || y < 0 || y >= size) continue;

const distance = Math.sqrt(dist2);
const w = this.getFalloff(distance, radius, config.falloff);
const h = heightmap[y * size + x];

        samples.push({ x, y, h, w });
        sumH += h * w;
        sumW += w;
      }
    }

if (sumW === 0) {
return { modifiedPixels: [], bounds: { minX0maxX0minY0maxY0 } };
    }

const avgH = sumH / sumW;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

// 平滑:朝平均值方向移动
for (const s of samples) {
const idx = s.y * size + s.x;
const oldH = heightmap[idx];
// 强度越高,越接近平均值
const newH = oldH + (avgH - oldH) * config.strength * s.w;

      heightmap[idx] = newH;
      modified.push({ x: s.xy: s.y, oldH, newH });

      minX = Math.min(minX, s.x); maxX = Math.max(maxX, s.x);
      minY = Math.min(minY, s.y); maxY = Math.max(maxY, s.y);
    }

return { modifiedPixels: modified, bounds: { minX, maxX, minY, maxY } };
  }
}

4.4 FLATTEN 笔刷(压平)

把整片区域拉向目标高度:

classFlattenBrushextendsBrush {
apply(heightmap, cx, cy, radius, config, size) {
const targetH = config.targetHeight ?? 0;
constmodifiedBrushStroke['modifiedPixels'] = [];
const r2 = radius * radius;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= size || y < 0 || y >= size) continue;

const distance = Math.sqrt(dist2);
const w = this.getFalloff(distance, radius, config.falloff);

const idx = y * size + x;
const oldH = heightmap[idx];
// 朝目标高度方向移动
const newH = oldH + (targetH - oldH) * config.strength * w;

        heightmap[idx] = newH;
        modified.push({ x, y, oldH, newH });

        minX = Math.min(minX, x); maxX = Math.max(maxX, x);
        minY = Math.min(minY, y); maxY = Math.max(maxY, y);
      }
    }

return {
modifiedPixels: modified,
bounds: { minX, maxX, minY, maxY }
    };
  }
}

4.5 NOISE 笔刷(噪波)

叠加 Perlin/Simplex Noise,添加细节:

// 引入 SimplexNoise(参考 large-scale-terrain-rendering)
import { createNoise2D } from'simplex-noise';
const noise2D = createNoise2D();

classNoiseBrushextendsBrush {
apply(heightmap, cx, cy, radius, config, size) {
constmodifiedBrushStroke['modifiedPixels'] = [];
const r2 = radius * radius;
const noiseScale = config.noiseScale ?? 0.1;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= size || y < 0 || y >= size) continue;

const distance = Math.sqrt(dist2);
const w = this.getFalloff(distance, radius, config.falloff);

// 双层噪声:低频 + 高频
const lowFreq = noise2D(x * noiseScale * 0.1, y * noiseScale * 0.1);
const highFreq = noise2D(x * noiseScale, y * noiseScale);
const noiseVal = (lowFreq + highFreq * 0.3) * 0.5;

const idx = y * size + x;
const oldH = heightmap[idx];
const newH = oldH + noiseVal * config.strength * w;

        heightmap[idx] = newH;
        modified.push({ x, y, oldH, newH });

        minX = Math.min(minX, x); maxX = Math.max(maxX, x);
        minY = Math.min(minY, y); maxY = Math.max(maxY, y);
      }
    }

return {
modifiedPixels: modified,
bounds: { minX, maxX, minY, maxY }
    };
  }
}

4.6 ERASE 笔刷(抹除)

恢复到原始(未编辑前)的高度:

classEraseBrushextendsBrush {
constructor(privateoriginalHeightmapFloat32Array) {
super();
  }

apply(heightmap, cx, cy, radius, config, size) {
constmodifiedBrushStroke['modifiedPixels'] = [];
const r2 = radius * radius;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= size || y < 0 || y >= size) continue;

const distance = Math.sqrt(dist2);
const w = this.getFalloff(distance, radius, config.falloff);

const idx = y * size + x;
const oldH = heightmap[idx];
const originalH = this.originalHeightmap[idx];
const newH = oldH + (originalH - oldH) * config.strength * w;

        heightmap[idx] = newH;
        modified.push({ x, y, oldH, newH });

        minX = Math.min(minX, x); maxX = Math.max(maxX, x);
        minY = Math.min(minY, y); maxY = Math.max(maxY, y);
      }
    }

return {
modifiedPixels: modified,
bounds: { minX, maxX, minY, maxY }
    };
  }
}

4.7 笔刷工厂

classBrushFactory {
staticcreate(typeBrushTypeoriginalHeightmap?: Float32Array): Brush {
switch (type) {
caseBrushType.RAISE:
caseBrushType.LOWER:
returnnewRaiseBrush();
caseBrushType.SMOOTH:
returnnewSmoothBrush();
caseBrushType.FLATTEN:
returnnewFlattenBrush();
caseBrushType.NOISE:
returnnewNoiseBrush();
caseBrushType.ERASE:
if (!originalHeightmap) {
thrownewError('EraseBrush 需要原始高度图');
        }
returnnewEraseBrush(originalHeightmap);
caseBrushType.PAINT:
thrownewError('PaintBrush 不修改高度,请用 SplatPainter');
default:
thrownewError(`Unknown brush type: ${type}`);
    }
  }
}

五、3 种笔刷形状与衰减函数

5.1 圆形 vs 方形

functionisInBrushShape(
dxnumberdynumberradiusnumbershapeBrushShape
): boolean {
switch (shape) {
caseBrushShape.CIRCLE:
caseBrushShape.CIRCLE_SOFT:
return (dx * dx + dy * dy) <= (radius * radius);
caseBrushShape.SQUARE:
caseBrushShape.SQUARE_SOFT:
returnMath.abs(dx) <= radius && Math.abs(dy) <= radius;
default:
returnfalse;
  }
}

5.2 衰减函数库

constFalloffFunctions = {
// 硬边(无衰减)
none(tnumber) =>1,

// 线性衰减
linear(tnumber) =>1 - t,

// 平滑衰减(cosine)
smooth(tnumber) =>0.5 * (Math.cos(t * Math.PI) + 1),

// 高斯衰减
gaussian(tnumber, sigma = 0.4) =>
Math.exp(-Math.pow(t, 2) / (2 * sigma * sigma)),

// S 型衰减(sigmoid)
sigmoid(tnumber) =>1 / (1 + Math.exp(-(0.5 - t) * 10)),

// 三次衰减
cubic(tnumber) =>Math.pow(1 - t, 3),
};

// 应用笔刷的衰减
functionapplyFalloff(
distancenumberradiusnumber
falloffType: keyof typeofFalloffFunctions = 'gaussian'
): number {
if (distance >= radius) return0;
const t = distance / radius;
returnFalloffFunctions[falloffType](t);
}

5.3 衰减函数可视化

// 调试用:在控制台画衰减曲线
functionplotFalloff(falloffType: keyof typeofFalloffFunctions) {
console.log(`衰减曲线:${falloffType}`);
for (let t = 0; t <= 1; t += 0.05) {
const v = FalloffFunctions[falloffType](t);
const bar = '█'.repeat(Math.floor(v * 40));
console.log(`${t.toFixed(2)} | ${bar}`);
  }
}

六、法线实时重算

6.1 中心差分法

高度变化后,法线必须重算,否则光照不更新:

classNormalRecalculator {
// 重新计算 bounds 范围内的法线
recalc(
heightmapFloat32Array,
bounds: { minXnumbermaxXnumberminYnumbermaxYnumber },
heightmapSizenumber,
heightScalenumber,
cellSizenumber// 一个像素对应的世界宽度
  ): Float32Array {
const { minX, maxX, minY, maxY } = bounds;
const normals = newFloat32Array((maxX - minX + 1) * (maxY - minY + 1) * 3);

let idx = 0;
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
// 中心差分:取左右上下四个邻居
const left = this.sampleHeight(heightmap, x - 1, y, heightmapSize);
const right = this.sampleHeight(heightmap, x + 1, y, heightmapSize);
const down = this.sampleHeight(heightmap, x, y - 1, heightmapSize);
const up = this.sampleHeight(heightmap, x, y + 1, heightmapSize);

// 计算梯度
const dHdx = (right - left) * heightScale / (2 * cellSize);
const dHdy = (up - down) * heightScale / (2 * cellSize);

// 法线 = (-dHdx, 1, -dHdy) 归一化
const nx = -dHdx;
const ny = 1;
const nz = -dHdy;
const len = Math.sqrt(nx * nx + ny * ny + nz * nz);

        normals[idx++] = nx / len;
        normals[idx++] = ny / len;
        normals[idx++] = nz / len;
      }
    }

return normals;
  }

privatesampleHeight(heightmapFloat32Arrayxnumberynumbersizenumber): number {
    x = Math.max(0Math.min(size - 1, x));
    y = Math.max(0Math.min(size - 1, y));
return heightmap[y * size + x];
  }
}

6.2 扩展重算范围(法线依赖邻域)

法线重算范围要比修改范围大一圈(至少 1 像素):

expandBounds(
bounds: { minXnumbermaxXnumberminYnumbermaxYnumber },
sizenumber, padding = 1
): typeof bounds {
return {
minXMath.max(0, bounds.minX - padding),
maxXMath.min(size - 1, bounds.maxX + padding),
minYMath.max(0, bounds.minY - padding),
maxYMath.min(size - 1, bounds.maxY + padding),
  };
}

6.3 上传到 GPU

classHeightmapGPU {
privatedataTextureTHREE.DataTexture;
privatenormalTextureTHREE.DataTexture;
privatesizenumber;

constructor(heightmapFloat32Arraysizenumber) {
this.size = size;

// 高度图:单通道 R32F
this.dataTexture = newTHREE.DataTexture(
      heightmap, size, size, THREE.RedFormatTHREE.FloatType
    );
this.dataTexture.needsUpdate = true;
this.dataTexture.magFilter = THREE.LinearFilter;
this.dataTexture.minFilter = THREE.LinearFilter;

// 法线图:RGB
const initialNormals = newFloat32Array(size * size * 3).fill(0);
// ... 初始法线朝上
for (let i = 0; i < size * size; i++) {
      initialNormals[i * 3 + 1] = 1;
    }
this.normalTexture = newTHREE.DataTexture(
      initialNormals, size, size, THREE.RGBFormatTHREE.FloatType
    );
this.normalTexture.needsUpdate = true;
  }

// 局部更新高度图
updateHeightRegion(
heightmapFloat32Array,
bounds: { minX: number, maxX: number, minY: number, maxY: number }
) {
// 把数据写回 DataTexture
// DataTexture 默认是连续存储,heightmap 数组本身就是它的 data
this.dataTexture.needsUpdate = true;  // 简单方式:全量上传
// 进阶:用 updateRegion(需要继承 WebGLTextures 的脏标记机制)
  }

getHeightTexture() { returnthis.dataTexture; }
getNormalTexture() { returnthis.normalTexture; }
}

七、Splatmap 实时绘制

7.1 Splatmap 是什么

large-scale-terrain-rendering 文章里讲过:Splatmap 是 4 通道的 RGBA 纹理,每通道代表一种地形的混合权重

  • • R: 草地
  • • G: 泥土
  • • B: 岩石
  • • A: 雪地

笔刷在改高度时,可以顺手绘制对应通道的权重。

7.2 SplatPainter 实现

classSplatPainter {
privatesplatmapUint8Array;  // RGBA 8bit
privatesizenumber;
privatesplatTextureTHREE.DataTexture;

constructor(sizenumber) {
this.size = size;
this.splatmap = newUint8Array(size * size * 4);
this.splatTexture = newTHREE.DataTexture(
this.splatmap, size, size, THREE.RGBAFormatTHREE.UnsignedByteType
    );
this.splatTexture.needsUpdate = true;
  }

// 在指定位置叠加某种材质
paint(cxnumbercynumberradiusnumberlayernumberstrengthnumber) {
const r2 = radius * radius;

for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const dist2 = dx * dx + dy * dy;
if (dist2 > r2) continue;

const x = Math.floor(cx + dx);
const y = Math.floor(cy + dy);
if (x < 0 || x >= this.size || y < 0 || y >= this.sizecontinue;

const distance = Math.sqrt(dist2);
const t = distance / radius;
const w = 1 - t;  // 线性衰减

const idx = (y * this.size + x) * 4;
const oldVal = this.splatmap[idx + layer];
const newVal = Math.min(255, oldVal + strength * w * 255);
this.splatmap[idx + layer] = newVal;

// 归一化(4 通道总和不超过 255)
let total = 0;
for (let c = 0; c < 4; c++) total += this.splatmap[idx + c];
if (total > 255) {
const scale = 255 / total;
for (let c = 0; c < 4; c++) {
this.splatmap[idx + c] = Math.floor(this.splatmap[idx + c] * scale);
          }
        }
      }
    }

this.splatTexture.needsUpdate = true;
  }

getTexture() { returnthis.splatTexture; }
}

7.3 与 RAISE 笔刷联动

根据高度自动选择材质——这是地形编辑器的高级特性:

functionautoPaintByHeight(
splatPainterSplatPainter,
brushStrokeBrushStroke,
heightmapFloat32Array
) {
// 高度 < 0.3:泥土或草地
// 高度 0.3-0.7:岩石
// 高度 > 0.7:雪地
for (const pixel of brushStroke.modifiedPixels) {
const h = pixel.newH;
const cx = pixel.x, cy = pixel.y;
const radius = 5;  // 笔刷半径(像素)

if (h < 0.3) {
      splatPainter.paint(cx, cy, radius, 01.0);  // 草地
    } elseif (h < 0.5) {
      splatPainter.paint(cx, cy, radius, 10.8);  // 泥土
    } elseif (h < 0.7) {
      splatPainter.paint(cx, cy, radius, 20.7);  // 岩石
    } else {
      splatPainter.paint(cx, cy, radius, 31.0);  // 雪地
    }
  }
}

八、Undo/Redo 集成

8.1 与既有 Undo/Redo 系统对接

直接复用 threejs-undo-redo-system 文章的 HistoryManager

// 编辑器中的 Undo 命令
classBrushStrokeCommandimplementsICommand {
publicnamestring;
publictimestampnumber;
publicmergeableboolean = true;  // 同一笔轨迹内可合并

privatebackupArray<{ xnumberynumberoldHnumbernewHnumber }>;
privateheightmapFloat32Array;
privateheightmapSizenumber;
privategpuUpload() =>void;

constructor(
heightmapFloat32Array,
heightmapSizenumber,
pixelsBrushStroke['modifiedPixels'],
gpuUpload: () => void
) {
this.heightmap = heightmap;
this.heightmapSize = heightmapSize;
this.backup = pixels.map(p => ({ ...p }));  // 深拷贝
this.gpuUpload = gpuUpload;
this.name = `笔刷轨迹(${pixels.length} 像素)`;
this.timestamp = Date.now();
  }

execute(): void {
// 应用 backup 的 newH
for (const p ofthis.backup) {
this.heightmap[p.y * this.heightmapSize + p.x] = p.newH;
    }
this.gpuUpload();
  }

undo(): void {
// 恢复 backup 的 oldH
for (const p ofthis.backup) {
this.heightmap[p.y * this.heightmapSize + p.x] = p.oldH;
    }
this.gpuUpload();
  }

// 合并策略:相邻像素 + 时间间隔 < 100ms
canMerge(otherICommand): boolean {
if (!(other instanceofBrushStrokeCommand)) returnfalse;
if (Date.now() - this.timestamp > 100returnfalse;

// 检查像素是否相邻
for (const p ofthis.backup) {
for (const q of (other asBrushStrokeCommand).backup) {
const dx = p.x - q.x;
const dy = p.y - q.y;
if (dx * dx + dy * dy < 100returntrue;  // 10 像素内有重叠
      }
    }
returnfalse;
  }

merge(otherICommand): ICommand {
const merged = newBrushStrokeCommand(
this.heightmapthis.heightmapSize,
      [...this.backup, ...(other asBrushStrokeCommand).backup],
this.gpuUpload
    );
return merged;
  }
}

8.2 编辑器中的命令流

classTerrainEditor {
privatehistoryHistoryManager;
privatecurrentStrokePixelsBrushStroke['modifiedPixels'] = [];

// 笔刷开始(鼠标按下)
onBrushStart() {
this.currentStrokePixels = [];
  }

// 笔刷拖动中(持续修改)
onBrushMove(strokeBrushStroke) {
this.currentStrokePixels.push(...stroke.modifiedPixels);
  }

// 笔刷结束(鼠标抬起)
onBrushEnd() {
if (this.currentStrokePixels.length === 0return;

const command = newBrushStrokeCommand(
this.heightmapthis.heightmapSize,
this.currentStrokePixels,
() =>this.gpuUpload()
    );

this.history.execute(command);  // 自动入栈 + 触发 Undo
this.currentStrokePixels = [];
  }

// Ctrl+Z
undo() {
this.history.undo();
this.recalcAllNormals();  // 撤销后整体重算法线
  }

redo() {
this.history.redo();
this.recalcAllNormals();
  }
}

九、笔刷预览环

9.1 预览环几何

跟着鼠标走,半径实时变化:

classBrushPreview {
privateringTHREE.Mesh;
privateinnerCircleTHREE.Mesh;
privategroupTHREE.Group;

constructor(scene: THREE.Sceneradiusnumber) {
this.group = newTHREE.Group();

// 外环(笔刷作用范围)
const ringGeom = newTHREE.RingGeometry(radius * 0.95, radius, 64);
const ringMat = newTHREE.MeshBasicMaterial({
color0x00fffftransparenttrueopacity0.8sideTHREE.DoubleSide
    });
this.ring = newTHREE.Mesh(ringGeom, ringMat);
this.ring.rotation.x = -Math.PI / 2;

// 内圆(强度中心)
const innerGeom = newTHREE.CircleGeometry(radius * 0.132);
const innerMat = newTHREE.MeshBasicMaterial({
color0xff00fftransparenttrueopacity0.5
    });
this.innerCircle = newTHREE.Mesh(innerGeom, innerMat);
this.innerCircle.rotation.x = -Math.PI / 2;
this.innerCircle.position.y = 0.05;  // 略高于地形,避免 Z-fighting

this.group.add(this.ring);
this.group.add(this.innerCircle);
    scene.add(this.group);
  }

// 更新位置和大小
update(worldPos: THREE.Vector3radiusnumber) {
this.group.position.copy(worldPos);
this.group.position.y += 0.1;  // 浮在地面之上

// 缩放
const scale = radius / 5;  // 假设默认半径是 5
this.group.scale.setScalar(scale);
  }

setVisible(visibleboolean) {
this.group.visible = visible;
  }

dispose() {
this.ring.geometry.dispose();
    (this.ring.materialasTHREE.Material).dispose();
this.innerCircle.geometry.dispose();
    (this.innerCircle.materialasTHREE.Material).dispose();
  }
}

9.2 笔刷大小实时缩放

// 滚轮调整笔刷大小
canvas.addEventListener('wheel'(e) => {
  e.preventDefault();
  brushConfig.radius = Math.max(1Math.min(100, brushConfig.radius + (e.deltaY < 0 ? 1 : -1)));
  brushPreview.update(lastPickPos, brushConfig.radius);
});

十、Chunk 边界协调

10.1 Chunk 边界的问题

如果你用的是 large-scale-terrain-rendering 的 Chunk 系统:

        Chunk A
   ┌──────────────┐
   │              │
   │   笔刷       │ ← 笔刷中心在 A 内
   │       ╲      │
   │        ╲     │
   └─────────┼────┘
             │
        笔刷跨越边界
             │
   ┌─────────┼────┐
   │     Chunk B  │

笔刷的圆形作用范围可能跨越多个 Chunk——只更新当前 Chunk 会让边界处出现接缝。

10.2 解决方案:共享 Heightmap

最简单的方案:整个世界用一张 Heightmap DataTexture,所有 Chunk 共享采样

classChunkManager {
privateheightmapFloat32Array;  // 整个世界的 Heightmap
privateheightmapSizenumber;
privatechunksMap<stringChunk>;

// 笔刷修改后,只需更新 heightmap
onHeightmapChanged(bounds: { minX: number, maxX: number, minY: number, maxY: number }) {
// 找到所有与 bounds 相交的 Chunk
const minChunkX = Math.floor(bounds.minX / this.chunkSize);
const maxChunkX = Math.floor(bounds.maxX / this.chunkSize);
const minChunkY = Math.floor(bounds.minY / this.chunkSize);
const maxChunkY = Math.floor(bounds.maxY / this.chunkSize);

for (let cy = minChunkY; cy <= maxChunkY; cy++) {
for (let cx = minChunkX; cx <= maxChunkX; cx++) {
const key = `${cx},${cy}`;
const chunk = this.chunks.get(key);
if (chunk) {
          chunk.markDirty(bounds);  // 标记脏区域
        }
      }
    }
  }
}

10.3 脏区域更新 Chunk 几何

classChunk {
privatedirtyRegionsArray<{ minXnumbermaxXnumberminYnumbermaxYnumber }> = [];

markDirty(boundsany) {
this.dirtyRegions.push(bounds);
  }

// 在 update 循环里批量处理
update(heightmapFloat32ArrayheightmapSizenumber) {
if (this.dirtyRegions.length === 0return;

// 合并所有脏区域
const merged = this.mergeRegions(this.dirtyRegions);

// 重新构建该 Chunk 的顶点
this.rebuildVertices(heightmap, heightmapSize, merged);

// 重新计算法线
this.recalcNormals(merged);

this.dirtyRegions = [];
this.geometry.attributes.position.needsUpdate = true;
this.geometry.attributes.normal.needsUpdate = true;
  }
}

十一、性能优化全景

11.1 优化矩阵

优化项
效果
实施难度
笔刷轨迹合并
减少 Undo 命令数 90%
法线局部重算
减少 95% 重算量
⭐⭐
DataTexture 局部更新
减少 80% GPU 上传
⭐⭐⭐
Worker 异步
主线程不卡
⭐⭐⭐
脏区域延迟应用
一次提交多个修改
⭐⭐
笔刷预览降采样
预览环 60fps

11.2 Worker 异步(笔刷算法)

把笔刷算法放 Worker,主线程只做渲染:

// brush.worker.ts
import * asComlinkfrom'comlink';

classBrushWorker {
applyStroke(
heightmapFloat32Array,
cxnumbercynumber,
radiusnumber,
configany,
sizenumber
  ): any {
const brush = BrushFactory.create(config.type);
return brush.apply(heightmap, cx, cy, radius, config, size);
  }
}

Comlink.expose(newBrushWorker());
// 主线程
import * asComlinkfrom'comlink';
const worker = newWorker('./brush.worker.js', { type'module' });
const brushWorker = Comlink.wrap(worker);

const result = await brushWorker.applyStroke(heightmap, cx, cy, radius, config, size);

11.3 帧合并(Frame Coalescing)

一次鼠标移动产生 60+ 次笔刷调用,但只需要在帧末应用一次:

classFrameCoalescedEditor {
privatependingStrokesBrushStroke[] = [];
private rafScheduled = false;

onBrushMove(strokeBrushStroke) {
this.pendingStrokes.push(stroke);

if (!this.rafScheduled) {
this.rafScheduled = true;
requestAnimationFrame(() =>this.flush());
    }
  }

flush() {
// 合并所有 pending 笔刷
const merged = this.mergeStrokes(this.pendingStrokes);

// 一次性应用
this.heightmap.apply(merged);
this.gpuUpload();
this.normalRecalc(merged.bounds);

this.pendingStrokes = [];
this.rafScheduled = false;
  }
}

11.4 法线 GPU 重算

法线重算也可以放 GPU(用 TransformFeedback 或 Render to Texture):

// 法线重算 Shader(Compute Shader 风格)
uniformsampler2D heightTexture;
uniformvec2 boundsMin;
uniformvec2 boundsMax;
uniformfloat heightScale;
uniformfloat cellSize;

void main() {
vec2 uv = gl_FragCoord.xy / textureSize(heightTexture, 0);

// 边界保护
if (uv.x < boundsMin.x || uv.x > boundsMax.x ||
      uv.y < boundsMin.y || uv.y > boundsMax.y) {
discard;
  }

// 中心差分
float left = texture2D(heightTexture, uv - vec2(1.0/512.00.0)).r;
float right = texture2D(heightTexture, uv + vec2(1.0/512.00.0)).r;
float down = texture2D(heightTexture, uv - vec2(0.01.0/512.0)).r;
float up = texture2D(heightTexture, uv + vec2(0.01.0/512.0)).r;

float dHdx = (right - left) * heightScale / (2.0 * cellSize);
float dHdy = (up - down) * heightScale / (2.0 * cellSize);

vec3 normal = normalize(vec3(-dHdx, 1.0, -dHdy));
gl_FragColor = vec4(normal * 0.5 + 0.51.0);
}

十二、完整实战:地形编辑器 Demo

12.1 入口代码

// main.ts
import * asTHREEfrom'three';
import { TerrainEditor } from'./TerrainEditor';

const scene = newTHREE.Scene();
const camera = newTHREE.PerspectiveCamera(60window.innerWidth / window.innerHeight0.110000);
camera.position.set(050100);

const renderer = newTHREE.WebGLRenderer({ antialiastrue });
renderer.setSize(window.innerWidthwindow.innerHeight);
document.body.appendChild(renderer.domElement);

// 1. 创建初始地形(用程序化生成)
const size = 512;
const worldSize = 100;
const initialHeightmap = generateInitialHeightmap(size);

const editor = newTerrainEditor({
  scene, camera, renderer,
heightmapSize: size,
  worldSize,
  initialHeightmap,
});

// 2. UI
setupToolbar(editor);
setupBrushPanel(editor);
setupKeyboardShortcuts(editor);

// 3. 启动
editor.start();
animate();

functionanimate() {
requestAnimationFrame(animate);
  editor.update();  // 笔刷应用、预览环更新
  renderer.render(scene, camera);
}

12.2 TerrainEditor 主类

classTerrainEditor {
private terrainMesh!: THREE.Mesh;
private heightmap!: Float32Array;
private originalHeightmap!: Float32Array;
privateheightmapSizenumber;
privateworldSizenumber;
privateheightScalenumber;

privatepickerBrushPicker;
privategpuHeightmapGPU;
privatenormalRecalcNormalRecalculator;
privatesplatPainterSplatPainter;
privatebrushPreviewBrushPreview;
privatehistoryHistoryManager;

privatebrushConfigBrushConfig = {
typeBrushType.RAISE,
shapeBrushShape.CIRCLE_SOFT,
radius10,
strength0.5,
falloff0.5,
  };

private isPainting = false;
privatecurrentStrokePixelsany[] = [];
privatelastPickResultany = null;

constructor(privateopts: {
    scene: THREE.Scene,
    camera: THREE.Camera,
    renderer: THREE.WebGLRenderer,
    heightmapSize: number,
    worldSize: number,
    initialHeightmap: Float32Array,
  }
) {
this.heightmapSize = opts.heightmapSize;
this.worldSize = opts.worldSize;
this.heightScale = 30;  // 高度缩放
this.heightmap = newFloat32Array(opts.initialHeightmap);
this.originalHeightmap = newFloat32Array(opts.initialHeightmap);

this.picker = newBrushPicker(nullasanythis.heightmapSizethis.worldSize);
this.gpu = newHeightmapGPU(this.heightmapthis.heightmapSize);
this.normalRecalc = newNormalRecalculator();
this.splatPainter = newSplatPainter(this.heightmapSize);
this.brushPreview = newBrushPreview(opts.scenethis.brushConfig.radius);
this.history = newHistoryManager();

this.createTerrainMesh();
this.setupEvents();
  }

// ... 其他方法
}

12.3 UI 工具栏

<divid="toolbar">
<buttondata-brush="raise"class="active">⛰ 隆起</button>
<buttondata-brush="lower">⛏ 降低</button>
<buttondata-brush="smooth">〰 平滑</button>
<buttondata-brush="flatten">▭ 压平</button>
<buttondata-brush="noise">⌇ 噪波</button>
<buttondata-brush="erase">⌫ 抹除</button>
<buttondata-brush="paint">🎨 绘制</button>
<hr>
<buttonid="undo">↶ 撤销 (Ctrl+Z)</button>
<buttonid="redo">↷ 重做 (Ctrl+Y)</button>
<hr>
<buttonid="export">💾 导出</button>
<buttonid="import">📂 导入</button>
</div>

<divid="brush-panel">
<label>半径 <inputtype="range"id="radius"min="1"max="100"value="10"></label>
<label>强度 <inputtype="range"id="strength"min="0"max="1"step="0.05"value="0.5"></label>
<label>衰减 <inputtype="range"id="falloff"min="0"max="1"step="0.05"value="0.5"></label>
<label>目标高度(压平) <inputtype="number"id="target-height"value="0"step="0.1"></label>
</div>
// 工具栏事件
document.querySelectorAll('[data-brush]').forEach(btn => {
  btn.addEventListener('click'() => {
document.querySelectorAll('[data-brush]').forEach(b => b.classList.remove('active'));
    btn.classList.add('active');
    editor.setBrushType(btn.dataset.brushasBrushType);
  });
});

document.getElementById('radius')!.addEventListener('input'(e) => {
  editor.setBrushRadius(parseFloat((e.targetasHTMLInputElement).value));
});
// ... 其他事件

12.4 快捷键

functionsetupKeyboardShortcuts(editorTerrainEditor) {
window.addEventListener('keydown'(e) => {
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z') {
        e.preventDefault();
        editor.undo();
      } elseif (e.key === 'y') {
        e.preventDefault();
        editor.redo();
      }
    } else {
// 笔刷类型切换
constkeyMapRecord<stringBrushType> = {
'1'BrushType.RAISE,
'2'BrushType.LOWER,
'3'BrushType.SMOOTH,
'4'BrushType.FLATTEN,
'5'BrushType.NOISE,
'6'BrushType.ERASE,
'7'BrushType.PAINT,
      };
const brush = keyMap[e.key];
if (brush) {
        editor.setBrushType(brush);
      }

// [ ] 调整半径
if (e.key === '[') {
        editor.setBrushRadius(editor.getBrushRadius() - 1);
      } elseif (e.key === ']') {
        editor.setBrushRadius(editor.getBrushRadius() + 1);
      }
    }
  });
}

12.5 导出 / 导入 Heightmap

functionexportHeightmap(heightmapFloat32Arraysizenumber) {
// 保存为 PNG
const canvas = document.createElement('canvas');
  canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
const imageData = ctx.createImageData(size, size);

for (let i = 0; i < heightmap.length; i++) {
const v = Math.max(0Math.min(255Math.floor(heightmap[i] * 255)));
    imageData.data[i * 4] = v;
    imageData.data[i * 4 + 1] = v;
    imageData.data[i * 4 + 2] = v;
    imageData.data[i * 4 + 3] = 255;
  }

  ctx.putImageData(imageData, 00);
  canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
    a.href = url;
    a.download = 'heightmap.png';
    a.click();
  });
}

functionimportHeightmap(fileFileheightmapFloat32Arraysizenumber) {
const img = newImage();
  img.onload = () => {
const canvas = document.createElement('canvas');
    canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
    ctx.drawImage(img, 00, size, size);
const data = ctx.getImageData(00, size, size).data;

for (let i = 0; i < heightmap.length; i++) {
      heightmap[i] = data[i * 4] / 255;
    }
  };
  img.src = URL.createObjectURL(file);
}

十三、常见问题速查

Q1:笔刷修改后光照不更新?

A:法线没重算。改完高度后必须调用 normalRecalc.recalc(bounds),然后更新 geometry.attributes.normal.needsUpdate = true

Q2:Chunk 边界处出现接缝?

A:共享 Heightmap 即可解决。所有 Chunk 采样同一张 DataTexture,笔刷改的是共享数据。

Q3:Undu/Redo 后高度恢复了,但法线还是错的?

A:撤销/重做后要触发法线重算,而且范围要包含整个修改区域 + 1 像素 padding。

Q4:法线重算太慢?

A:扩展为 3 步策略:

  1. 1. 修改期间:每帧只重算 bounds 内的法线
  2. 2. 笔刷结束:用 Worker 异步重算整张法线
  3. 3. 首屏:用 computeNormals() 一次算清

Q5:DataTexture 上传很慢?

A:默认 needsUpdate = true 会全量上传。进阶方案:

  • • 用 WebGL2RenderingContext.texSubImage2D() 做局部更新
  • • 或者把 DataTexture 拆成多个 Tile Texture

Q6:笔刷预览环总是和地面穿插?

A:给预览环 position.y += 0.1,并禁用 depthTest 让它永远在最前面:

previewMat.depthTest = false;
previewMat.depthWrite = false;
preview.renderOrder = 999;

Q7:撤销时把”刷材质”和”改高度”分开?

A:用 MacroCommand 把多个命令打包(参考 threejs-undo-redo-system):

const macro = newMacroCommand('笔刷操作');
macro.add(heightCommand);
macro.add(splatCommand);
history.execute(macro);

Q8:Worker 里的 Float32Array 怎么传回主线程?

A:用 Comlink.transfer() 零拷贝:

// Worker 端
Comlink.expose({
apply(heightmapFloat32Array) => {
// 修改
returnComlink.transfer(heightmap, [heightmap.buffer]);
  }
});

Q9:PAINT 笔刷怎么”擦掉”之前的材质?

A:4 通道混合,把要擦的通道设为 0:

splatmap[idx + layerToErase] = 0;
// 重新归一化

Q10:地形编辑器能多人协作吗?

A:可以。直接复用 threejs-multiplayer-realtime-sync 的方案,把笔刷操作作为消息广播。但要注意 100ms 内的合并优化,避免网络风暴。


写在最后

地形编辑器是把”程序化”和”创作”结合起来的桥梁:

  • • 对程序:它是 large-scale-terrain-rendering 的延续,把 Noise 生成的”模板”细化成具体场景
  • • 对美术:它是最直觉的创作工具,所见即所得
  • • 对玩家:它是沙盒玩法的核心引擎(我的世界、Roblox 都是这套思路)
  • • 对项目:它是 undo-redo-system + inspector-panel + scene-graph-relationship 的综合演练

一旦你有了地形编辑器,程序化生成的世界就不再是”一次性”——你可以捏一座山,挖一个湖,铺一片雪,然后保存为关卡,让玩家在你自己雕刻的世界里冒险。

下一篇建议方向(按实用性排序):

  1. 1. 水面/海洋渲染:FFT Ocean + 反射折射 + 泡沫(地形有了,水是下一步)
  2. 2. 植被系统:GPU Instancing 批量草地树木(地形有了,该长点东西了)
  3. 3. 完整开放世界整合:地形 + 云 + 水 + 植被 + 建筑 + NPC 的架构设计(终极综合篇)

地形是骨架,水是血脉,云是空气,植被是皮肤——你的 3D 世界正在成型。