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. 地形编辑器的核心难题 -
2. 整体架构设计 -
3. 笔刷定位:Raycaster 命中地形 -
4. 6 大笔刷类型完整实现 -
5. 3 种笔刷形状与衰减函数 -
6. 法线实时重算 -
7. Splatmap 实时绘制 -
8. Undo/Redo 集成 -
9. 笔刷预览环 -
10. Chunk 边界协调 -
11. 性能优化全景 -
12. 完整实战:地形编辑器 Demo -
13. 常见问题速查
一、地形编辑器的核心难题
1.1 三大矛盾
|
|
|
|
| 精度 vs 性能 |
|
|
| 实时性 vs 一致性 |
|
|
| 创作自由 vs 操作可逆 |
|
undo-redo-system 的 Command Pattern |
1.2 与既有文章的关系
┌────────────────────────┐
│ Three.js 生态全栈 │
└────────────────────────┘
│
┌──────────────┬───────────┼──────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
地形渲染 体积云 云影 开放世界 地形编辑器
(本次) (上次) (上上次) (上上上次) (本篇)
│ │ │ │ │
└──────────────┴───────────┴──────────┴──────────────┘
│
都基于 Heightmap
都使用 Chunk 分块
都集成 Undo/Redo
本篇不是另起炉灶,而是把 large-scale-terrain-rendering、undo-redo-system、inspector-panel 的能力组合起来。
1.3 方案选型
|
|
|
|
|
| 顶点级编辑 |
|
|
|
| Heightmap 编辑 |
|
|
|
| GPU Compute 编辑 |
|
|
|
| 体素编辑 |
|
|
|
本文选 Heightmap 编辑——与前文 large-scale-terrain-rendering 的 DataTexture 方案完美衔接,GPU 友好,序列化简单。
二、整体架构设计
2.1 模块划分
// 整体架构
classTerrainEditor {
// 笔刷层
brush: BrushEngine; // 笔刷引擎:定位 + 类型 + 形状
brushPreview: BrushPreview; // 笔刷预览环
// 数据层
heightmap: Heightmap; // 高度图(DataTexture)
normalMap: NormalMap; // 法线图
splatmap: Splatmap; // 材质混合图
// 修改层
modifier: HeightModifier; // 高度修改器(6 大类型)
normalRecalc: NormalRecalculator; // 法线重算器
splatPainter: SplatPainter; // 材质绘制器
// 协作层
chunkSync: ChunkSync; // 与 Chunk 系统同步
history: HistoryManager; // Undo/Redo 集成
// UI 层
panel: EditorPanel; // 笔刷参数面板
toolbar: ToolbarUI; // 工具栏
}
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 {
type: BrushType; // 笔刷类型
shape: BrushShape; // 笔刷形状
radius: number; // 半径(世界单位)
strength: number; // 强度 [0, 1]
falloff: number; // 衰减 [0, 1],0=无衰减,1=高斯
targetHeight?: number; // FLATTEN 笔刷的目标高度
noiseScale?: number; // NOISE 笔刷的频率
paintLayer?: number; // PAINT 笔刷的 Splatmap 通道
}
// 笔刷作用结果
interfaceBrushStroke {
modifiedPixels: Array<{x: number, y: number, oldH: number, newH: number}>;
bounds: { minX: number, maxX: number, minY: number, maxY: number };
}
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();
privateterrainMesh: THREE.Mesh;
// 高度图参数
privateheightmapSize: number; // 高度图分辨率
privateworldWidth: number; // 地形世界宽度
privateworldDepth: number; // 地形世界深度
constructor(terrainMesh: THREE.Mesh, heightmapSize: number, worldSize: number) {
this.terrainMesh = terrainMesh;
this.heightmapSize = heightmapSize;
this.worldWidth = worldSize;
this.worldDepth = worldSize;
}
// 把鼠标事件转成世界坐标
pick(event: MouseEvent, camera: THREE.Camera, canvas: HTMLCanvasElement): {
worldPos: THREE.Vector3;
uv: THREE.Vector2;
heightmapCoord: THREE.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 === 0) returnnull;
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(event: MouseEvent, ...args: any[]): 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(
heightmap: Float32Array, // 高度数据(1D 数组,行优先)
centerX: number, // 中心像素 X
centerY: number, // 中心像素 Y
radius: number, // 作用半径(像素)
config: BrushConfig,
heightmapSize: number
): BrushStroke;
// 工具:计算像素在笔刷内的衰减权重
protectedgetFalloff(distance: number, radius: number, falloff: number): 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) {
constmodified: BrushStroke['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) {
constmodified: BrushStroke['modifiedPixels'] = [];
const r2 = radius * radius;
// 先采样整个影响区域
constsamples: {x: number, y: number, h: number, w: number}[] = [];
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: { minX: 0, maxX: 0, minY: 0, maxY: 0 } };
}
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.x, y: 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;
constmodified: BrushStroke['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) {
constmodified: BrushStroke['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(privateoriginalHeightmap: Float32Array) {
super();
}
apply(heightmap, cx, cy, radius, config, size) {
constmodified: BrushStroke['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(type: BrushType, originalHeightmap?: 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(
dx: number, dy: number, radius: number, shape: BrushShape
): 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: (t: number) =>1,
// 线性衰减
linear: (t: number) =>1 - t,
// 平滑衰减(cosine)
smooth: (t: number) =>0.5 * (Math.cos(t * Math.PI) + 1),
// 高斯衰减
gaussian: (t: number, sigma = 0.4) =>
Math.exp(-Math.pow(t, 2) / (2 * sigma * sigma)),
// S 型衰减(sigmoid)
sigmoid: (t: number) =>1 / (1 + Math.exp(-(0.5 - t) * 10)),
// 三次衰减
cubic: (t: number) =>Math.pow(1 - t, 3),
};
// 应用笔刷的衰减
functionapplyFalloff(
distance: number, radius: number,
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(
heightmap: Float32Array,
bounds: { minX: number, maxX: number, minY: number, maxY: number },
heightmapSize: number,
heightScale: number,
cellSize: number// 一个像素对应的世界宽度
): 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(heightmap: Float32Array, x: number, y: number, size: number): number {
x = Math.max(0, Math.min(size - 1, x));
y = Math.max(0, Math.min(size - 1, y));
return heightmap[y * size + x];
}
}
6.2 扩展重算范围(法线依赖邻域)
法线重算范围要比修改范围大一圈(至少 1 像素):
expandBounds(
bounds: { minX: number, maxX: number, minY: number, maxY: number },
size: number, padding = 1
): typeof bounds {
return {
minX: Math.max(0, bounds.minX - padding),
maxX: Math.min(size - 1, bounds.maxX + padding),
minY: Math.max(0, bounds.minY - padding),
maxY: Math.min(size - 1, bounds.maxY + padding),
};
}
6.3 上传到 GPU
classHeightmapGPU {
privatedataTexture: THREE.DataTexture;
privatenormalTexture: THREE.DataTexture;
privatesize: number;
constructor(heightmap: Float32Array, size: number) {
this.size = size;
// 高度图:单通道 R32F
this.dataTexture = newTHREE.DataTexture(
heightmap, size, size, THREE.RedFormat, THREE.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.RGBFormat, THREE.FloatType
);
this.normalTexture.needsUpdate = true;
}
// 局部更新高度图
updateHeightRegion(
heightmap: Float32Array,
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 {
privatesplatmap: Uint8Array; // RGBA 8bit
privatesize: number;
privatesplatTexture: THREE.DataTexture;
constructor(size: number) {
this.size = size;
this.splatmap = newUint8Array(size * size * 4);
this.splatTexture = newTHREE.DataTexture(
this.splatmap, size, size, THREE.RGBAFormat, THREE.UnsignedByteType
);
this.splatTexture.needsUpdate = true;
}
// 在指定位置叠加某种材质
paint(cx: number, cy: number, radius: number, layer: number, strength: number) {
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.size) continue;
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(
splatPainter: SplatPainter,
brushStroke: BrushStroke,
heightmap: Float32Array
) {
// 高度 < 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, 0, 1.0); // 草地
} elseif (h < 0.5) {
splatPainter.paint(cx, cy, radius, 1, 0.8); // 泥土
} elseif (h < 0.7) {
splatPainter.paint(cx, cy, radius, 2, 0.7); // 岩石
} else {
splatPainter.paint(cx, cy, radius, 3, 1.0); // 雪地
}
}
}
八、Undo/Redo 集成
8.1 与既有 Undo/Redo 系统对接
直接复用 threejs-undo-redo-system 文章的 HistoryManager:
// 编辑器中的 Undo 命令
classBrushStrokeCommandimplementsICommand {
publicname: string;
publictimestamp: number;
publicmergeable: boolean = true; // 同一笔轨迹内可合并
privatebackup: Array<{ x: number, y: number, oldH: number, newH: number }>;
privateheightmap: Float32Array;
privateheightmapSize: number;
privategpuUpload: () =>void;
constructor(
heightmap: Float32Array,
heightmapSize: number,
pixels: BrushStroke['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(other: ICommand): boolean {
if (!(other instanceofBrushStrokeCommand)) returnfalse;
if (Date.now() - this.timestamp > 100) returnfalse;
// 检查像素是否相邻
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 < 100) returntrue; // 10 像素内有重叠
}
}
returnfalse;
}
merge(other: ICommand): ICommand {
const merged = newBrushStrokeCommand(
this.heightmap, this.heightmapSize,
[...this.backup, ...(other asBrushStrokeCommand).backup],
this.gpuUpload
);
return merged;
}
}
8.2 编辑器中的命令流
classTerrainEditor {
privatehistory: HistoryManager;
privatecurrentStrokePixels: BrushStroke['modifiedPixels'] = [];
// 笔刷开始(鼠标按下)
onBrushStart() {
this.currentStrokePixels = [];
}
// 笔刷拖动中(持续修改)
onBrushMove(stroke: BrushStroke) {
this.currentStrokePixels.push(...stroke.modifiedPixels);
}
// 笔刷结束(鼠标抬起)
onBrushEnd() {
if (this.currentStrokePixels.length === 0) return;
const command = newBrushStrokeCommand(
this.heightmap, this.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 {
privatering: THREE.Mesh;
privateinnerCircle: THREE.Mesh;
privategroup: THREE.Group;
constructor(scene: THREE.Scene, radius: number) {
this.group = newTHREE.Group();
// 外环(笔刷作用范围)
const ringGeom = newTHREE.RingGeometry(radius * 0.95, radius, 64);
const ringMat = newTHREE.MeshBasicMaterial({
color: 0x00ffff, transparent: true, opacity: 0.8, side: THREE.DoubleSide
});
this.ring = newTHREE.Mesh(ringGeom, ringMat);
this.ring.rotation.x = -Math.PI / 2;
// 内圆(强度中心)
const innerGeom = newTHREE.CircleGeometry(radius * 0.1, 32);
const innerMat = newTHREE.MeshBasicMaterial({
color: 0xff00ff, transparent: true, opacity: 0.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.Vector3, radius: number) {
this.group.position.copy(worldPos);
this.group.position.y += 0.1; // 浮在地面之上
// 缩放
const scale = radius / 5; // 假设默认半径是 5
this.group.scale.setScalar(scale);
}
setVisible(visible: boolean) {
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(1, Math.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 {
privateheightmap: Float32Array; // 整个世界的 Heightmap
privateheightmapSize: number;
privatechunks: Map<string, Chunk>;
// 笔刷修改后,只需更新 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 {
privatedirtyRegions: Array<{ minX: number, maxX: number, minY: number, maxY: number }> = [];
markDirty(bounds: any) {
this.dirtyRegions.push(bounds);
}
// 在 update 循环里批量处理
update(heightmap: Float32Array, heightmapSize: number) {
if (this.dirtyRegions.length === 0) return;
// 合并所有脏区域
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 优化矩阵
|
|
|
|
| 笔刷轨迹合并 |
|
|
| 法线局部重算 |
|
|
| DataTexture 局部更新 |
|
|
| Worker 异步 |
|
|
| 脏区域延迟应用 |
|
|
| 笔刷预览降采样 |
|
|
11.2 Worker 异步(笔刷算法)
把笔刷算法放 Worker,主线程只做渲染:
// brush.worker.ts
import * asComlinkfrom'comlink';
classBrushWorker {
applyStroke(
heightmap: Float32Array,
cx: number, cy: number,
radius: number,
config: any,
size: number
): 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 {
privatependingStrokes: BrushStroke[] = [];
private rafScheduled = false;
onBrushMove(stroke: BrushStroke) {
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.0, 0.0)).r;
float right = texture2D(heightTexture, uv + vec2(1.0/512.0, 0.0)).r;
float down = texture2D(heightTexture, uv - vec2(0.0, 1.0/512.0)).r;
float up = texture2D(heightTexture, uv + vec2(0.0, 1.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.5, 1.0);
}
十二、完整实战:地形编辑器 Demo
12.1 入口代码
// main.ts
import * asTHREEfrom'three';
import { TerrainEditor } from'./TerrainEditor';
const scene = newTHREE.Scene();
const camera = newTHREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 10000);
camera.position.set(0, 50, 100);
const renderer = newTHREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.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;
privateheightmapSize: number;
privateworldSize: number;
privateheightScale: number;
privatepicker: BrushPicker;
privategpu: HeightmapGPU;
privatenormalRecalc: NormalRecalculator;
privatesplatPainter: SplatPainter;
privatebrushPreview: BrushPreview;
privatehistory: HistoryManager;
privatebrushConfig: BrushConfig = {
type: BrushType.RAISE,
shape: BrushShape.CIRCLE_SOFT,
radius: 10,
strength: 0.5,
falloff: 0.5,
};
private isPainting = false;
privatecurrentStrokePixels: any[] = [];
privatelastPickResult: any = 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(nullasany, this.heightmapSize, this.worldSize);
this.gpu = newHeightmapGPU(this.heightmap, this.heightmapSize);
this.normalRecalc = newNormalRecalculator();
this.splatPainter = newSplatPainter(this.heightmapSize);
this.brushPreview = newBrushPreview(opts.scene, this.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(editor: TerrainEditor) {
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 {
// 笔刷类型切换
constkeyMap: Record<string, BrushType> = {
'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(heightmap: Float32Array, size: number) {
// 保存为 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(0, Math.min(255, Math.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, 0, 0);
canvas.toBlob(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'heightmap.png';
a.click();
});
}
functionimportHeightmap(file: File, heightmap: Float32Array, size: number) {
const img = newImage();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, size, size);
const data = ctx.getImageData(0, 0, 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. 修改期间:每帧只重算 bounds 内的法线 -
2. 笔刷结束:用 Worker 异步重算整张法线 -
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: (heightmap: Float32Array) => {
// 修改
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. 水面/海洋渲染:FFT Ocean + 反射折射 + 泡沫(地形有了,水是下一步) -
2. 植被系统:GPU Instancing 批量草地树木(地形有了,该长点东西了) -
3. 完整开放世界整合:地形 + 云 + 水 + 植被 + 建筑 + NPC 的架构设计(终极综合篇)
地形是骨架,水是血脉,云是空气,植被是皮肤——你的 3D 世界正在成型。
夜雨聆风