生成世界
在随机点上散布出来的世界看起来像噪声:树、花、石头之间没有任何形状。esengine/worldgen 按设计师的方式布置世界——连续的群落、美术组合好的成组物件、留出的空地和道路、先大后小——并且在世界被探索时才生成,在每台机器上一样,不管各部分以什么顺序被请求。
工作分三方。引擎采样环境场、计算群落权重、逐层放置成组物件、让保留区留空、给每个放置物稳定的身份,并按区块流送。游戏写规则:有哪些群落、群落里长什么、哪里留给家园或道路。美术做配方:一片树林、一组岩石、一条沿岸的芦苇带。
WorldGen 组件
Section titled “WorldGen 组件”| 字段 | 作用 |
|---|---|
rules |
布置这个世界的规则:一个 .esworld 文件,或游戏用 registerWorldRules 注册的规则标识。 |
version |
这个世界生成时使用的规则版本。存档会保留它;规则里已没有这个版本时,世界失败(状态里说明原因),而不是布置出另一个世界。 |
world、seed |
游戏给这个世界(星球、关卡)起的名字,以及它的种子。换一个种子就是同一套规则下的另一个世界。 |
paintGround |
群落是否给地形上色(默认开)。 |
surface、seaLevel |
Plane(默认)把世界铺在 Terrain 上;Planet 表示该实体同时是 Planet,世界铺在它的六个面上。seaLevel(Planet 时显示)是海面高出星球半径的高度,低于它的地面在水下。 |
规则放置的东西作为这个实体的子实体发布,围绕流送源(或摄像机):见[程序化区块](/docs/zh-cn/world/streaming/#程序化区块)。
规则是数据:群落、群落里生长的配方,以及它们所在的布置层。把它们放在一个 .esworld 文件里(编辑器里 新建 → 新建世界规则,或在内容浏览器里打开),并在 WorldGen 的 rules 里引用它。编辑器列出文件里的群落、配方和布置层,用表单编辑每一项(网格和材质像其他资源一样选取),并在下方说明引擎是否接受当前的规则。保存文件后,打开的世界会按它重新布置,编辑器里和运行中的游戏都是如此。文件就是 registerWorldRules 接受内容的 JSON,读取时会校验:引用了文件里不存在的配方或布置层、或缺少世界必需的内容时,WorldGen 失败并指出出错的位置。
规则也可以从代码注册,游戏自定义的环境场就是这样加入的(函数不是数据):
import { registerWorldRules } from 'esengine/worldgen';
registerWorldRules({ id: 'mygame:wilds', versions: [1], biomes: [ { id: 'forest', conditions: [{ field: 'moisture', min: 0.52, blend: 0.08 }, { field: 'up', min: 0.82, blend: 0.05 }], transition: 120, paint: [0.55, 0.45, 0, 0], paintStrength: 0.5, recipes: [{ recipe: 'grove', layer: 'groups', density: 0.75 }] }, // … ], recipes: [ { id: 'grove', version: 1, tags: ['tree'], anchor: { id: 'elder', mesh: 'assets/models/pine.esmesh', material: 'assets/materials/pine.esmaterial', scale: [1.4, 1.8], footprint: 10, canopy: 35 }, members: [ { id: 'pine', mesh: 'assets/models/pine.esmesh', count: [3, 6], ring: [45, 110], scale: [0.9, 1.3], footprint: 8 }, { id: 'sapling', mesh: 'assets/models/pine.esmesh', count: [2, 5], ring: [110, 170], scale: [0.35, 0.6], footprint: 4, chance: 0.8 }, ] }, ], layers: [ { id: 'groups', spacing: 260, drawDistance: 4200, chunkSize: 512 }, { id: 'detail', spacing: 90, drawDistance: 2400 }, ],});每个决定都来自环境场:在任意世界位置读到的数值。height、up(地面朝上的程度)和 slope 来自地形的地表,paint.<层名>(这一处地面有多少属于这个地形层,按层文件名取名:grass.esterrainlayer 对应 paint.grass;没有层文件时是 paint.grass、paint.dirt、paint.snow、paint.rock)、waterDepth 和 shoreDistance(干燥处离水边多远,水面上为负)来自水体;moisture、temperature 和 rock 是带种子的噪声,在世界尺度上缓慢变化(尺度在规则的 noise 里设)。游戏可以用 fields: { name: (x, z, at) => number } 加入自己的场,并从 at 读取内置场。区域可以在它的范围内设置任意场的值。
群落是环境场上的权重:每个条件是某个场的一段区间(min、max),边缘在 blend 范围内衰减,各条件相乘。几个群落同时成立时它们的权重混合——候选点按所在位置的权重选择群落,所以两个群落相接处是一条混合带而不是一条线——transition 让边缘按这么多世界单位的噪声摆动。paint(草、土、雪、岩)按群落权重和 paintStrength 把地形原本的上色拉向群落的上色。群落列出它的配方,以及每个配方所在的层和它占该层候选点的比例。
每个候选点在每个群落里都要算一遍规则,所以场只在某个条件读到它时才求值,没人读的场根本不会算。规则文件还可以在 noise 里自己加命名的噪声场({ "turf": { "scale": 1400, "octaves": 2 } }),任何条件都可以按这个名字读取。
候选点是先按权重选定一个群落,再取这个群落在当前图层里的配方:所以群落必须在它要出现的每个图层里都列出配方,这个群落所在的地方这一层有多密,就是这条配方的 density。群落在某个图层里没有配方,选中它的候选点就是空的。权重在群落重叠处把它们混合起来,不负责把一个群落变稀;变稀靠配方的 density 和图层的 spacing。
配方是一个组:一个锚点和围绕它放置的成员。每个成员说明它的网格和材质(资源路径,或已加载的句柄)、数量(count)、离锚点多远和在什么角度(ring、arc)、scale、它在地面上占的范围(footprint:树干),与它在头顶伸展的范围(canopy:树冠)分开记录;它是直立还是随地面倾斜(attach,指定了 plant 的成员也遵循它:石头顺着坡面躺放)、允许多陡(minUp)和多深的水里站立(waterDepth)、被放置的概率 chance,以及它的远景版本(far:两张十字交叉卡片显示的贴图矩形,针叶树可用帐篷形,在所在层的 lodDistance 之外绘制)。站不住的成员(没有地面、太陡、在水下、被区域排除、压在先放的东西上)会被省去,组仍然成立。
version 是每个放置物 ID 的一部分:配方放置的东西变了就把它加一。
成员可以是网格加材质,也可以是植物:规则 plants 里的一项,用来表达网格和材质表达不了的东西。植物由 parts 部件组成(树干和树冠是两个部件,各有 mesh、更省的 lod1Mesh、material、texture、tint、是否 opaque,以及对代表树冠的卡片用 soft 说明它的法线朝上偏转多少:按这个比例转向上方,让它像圆润的树冠那样受光,而不是像一块平板;castShadows 为 false 表示不投影,给小到不值得进阴影图的地表覆盖物用),超过 lodDistance 换成更省的网格,超过 drawDistance 不再绘制,far 远景版本(树的交叉卡片,烘到自己的图集上)从 drawDistance 一直画到 farDistance,另外可选:
| 字段 | 含义 |
|---|---|
tints、tintScale |
美术给的几种色调(乘在颜色上的线性系数),以及一片林子多宽(世界单位):一个地方按慢噪声取其中一种,所以林子里有偏暖和偏冷的片,不会是花点。 |
farKeep |
远景版本画多少比例的植物(大于 0 到 1,默认 1):其余的不画,保留的放大来补足覆盖。没有自己卡片的植物可以拿更省的网格作 far,并用它来稀疏化。远景版本按格子铺设,格子的宽度由所有远景版本中最短的显示距离决定(最多八个区块),所以远处的地被植物是每格几次绘制,而不是每个区块几次。 |
sink |
下沉进地面多深,世界单位(石头埋在土里的底面)。 |
unit |
一个网格单位对应多少世界单位:按米建模的植物放在厘米世界里就是 100。配方的 scale 这时是相对模型的大小。 |
植物由与手摆植物相同的机制按格子生长:区块立刻出现,之后几帧里逐步填满,每个网格只占帧预算里的几毫秒。远景版本是单独的一个图层,一直铺到它们中最远的那个,在镜头比其种类自身绘制距离更远时显示,所以图层的 drawDistance 是它的植物以本来面目绘制的距离,不是世界能看多远。建在区域型地形上的世界到地形边缘为止。
层是按顺序进行的几遍布置:先大后小。每层在间距为 spacing 的网格里每格取一个抖动后的候选点,并为它抽一个优先级。两个候选点的组会重叠时,优先级高的成立;每个候选点都避开前面各层的占地,所以蕨类不会长穿树干,石头不会嵌进巨石里。一层以 chunkSize 大小的区块发布到 drawDistance。occupies: false 让后面的层可以和它重叠(地被)。farSpacing(默认 1)让这一层植物的远景版本按更大的间距规划:远景只保留四分之一的地被设为 2,只需要算四分之一的候选点,不再先全算出来再丢掉。

地形与水面示例里的 World Gen 组件:规则资产、种子和表面。
区域(WorldZone 组件)是手工规划的一块地方:家园、道路、落点、建筑占地、保留的视线。它跟随所在实体的位置和绕竖直轴的转向。
| 字段 | 作用 |
|---|---|
kind |
它的用途,用游戏自己的词(默认 reserve)。 |
shape、radius、sizeX、sizeZ |
圆形(默认,radius 为 10),或随实体绕竖直轴旋转的矩形(sizeX × sizeZ,默认 20 × 20)。编辑器会画出它的轮廓。 |
falloff |
在轮廓外多少世界单位内逐渐消失(默认 4):被排除的东西在这里变稀,而不是停在一条线上。 |
priority |
区域重叠时,高的决定群落。 |
excludes |
它排除的东西的标签,逗号分隔:层、配方、配方或成员的标签、decor、interactive;* 表示全部(默认);留空则什么都不排除。 |
biome |
它在范围内强制的群落。 |
flatten |
把它下面的地形压平到区域中心处原地面的高度,在 falloff 内平滑过渡回去:一块可以建造的地基。代码里 flatten 可以是世界高度,或 'ground'。 |
游戏在规划时和玩家建造时添加区域:
const gens = app.getResource(WorldGenRes);gens.zones().set({ id: 'house 1', kind: 'building', shape: { type: 'rect', x, z, halfX: 40, halfZ: 30, yaw }, falloff: 12, priority: 20, excludes: ['*'] });只有区域触及的区块会重新生成,每个区块的旧版本保留到新版本替换它为止。
场景中编排的区域使用场景名称和文档实体 ID 作为身份,因此相同场景以相同种子重载后,边缘衰减的分布保持一致。插件会在场景加载前启用场景来源记录。游戏代码创建的实体使用运行时身份;需要跨重载保持区域身份时,请用 gens.zones(world).set() 并提供稳定的 id。
同名世界的生成器共享区域,并分别接收变化通知。实体消失或该世界的最后一个生成器退出时,场景区域的投影会被移除。代码添加的区域和 PlacementDiffs 保留给后续生成器使用,地形整平和群落上色则随最后一个使用者释放。需要监听变化时,使用 zones.subscribe(listener) 或 diffs.subscribe(listener),并在结束使用时调用其返回的取消函数。

一个 World Zone:一个圆,告诉生成器在其中生长哪种群落。
建筑底面与道路
Section titled “建筑底面与道路”直立建筑的成员可以设置 grounding: { size: [宽, 深], samples: 5, mode: 'highest', maxRelief: 2 }。
宽深使用模型单位,应用旋转和缩放后,对角点、边缘和内部的网格采样;网格边长可以为 3、5、7 或 9。
每个采样点都要有地面并满足 minUp、waterDepth,maxRelief 按世界单位限制最大高差。
标高可选最高点(默认)、最低点、平均值或中心点:highest、lowest、mean、center,再叠加随模型缩放的 offset。
Placement.grounding 提供高度范围、所需支撑深度和埋入深度。它是有限网格检测,不会自动削平地形;
模型地基需要覆盖允许的高差,手工场地也可以使用 WorldZone 的 flatten。
避让范围至少覆盖整块旋转缩放后的底面。scaleFootprint: true 将占地和树冠半径视为模型单位;
留空则保留原有的世界单位含义。stats 和 explain 用 relief 区分高差拒绝,世界规则编辑器中可以直接编辑这些字段。
esengine/worldgen 导出的 roadZones({ id, points, width, clearance, falloff, excludes }) 将折线路径转为矩形段和圆形接头,
转弯和端点不会留下禁植缺口;setRoad(zones, options) 替换一条路并清理旧段,removeRoad(zones, id) 释放它。
这些函数预留空间,路面网格和道路整地另行制作。2D 世界也可以使用,把 y 写在每个点的第二个坐标。
建筑层放在森林层之前,树木就会避开建筑的占地;Foliage 的建筑底面与门前通道使用 WorldZone 预留。
可交互的放置物与变化
Section titled “可交互的放置物与变化”带 interactive(如 'ore'、'tree')的成员是独立的实体,带有记录放置 ID 与类型的 PlacedObject;其余都是装饰,按区块、按种类合并成一个网格绘制。gens.objectOf(id) 找到某个放置物对应的实体。
ID 由层、网格坐标、配方和成员以及它们的版本组成(groups@1:12,-3/grove@1/pine.2)——从不是列表里的序号——所以它不随区块生成的顺序变化,之后新增层或配方时也不变。
玩家做的改变与生成的世界分开保存,在显示任何东西之前叠加上去:
gens.diffs().remove(id); // 砍倒、开采:永久消失gens.diffs().change(id, { yaw: 1.2 }); // 移动、旋转、缩放save(gens.diffs().toJSON()); // 存档保存的内容gens.diffs().load(saved); // 以及读回砍倒的树在它的区块被释放再生成后、以及存档读回后都保持砍倒。玩家建造的东西属于游戏自己:它占用的区域让生成避开那块地。
gens.plan() 对任何地方都能回答,不管是否已加载,结果与发布出来的一致——服务器、小地图或放置检查读的就是它:
placementsIn(x0, z0, x1, z1, layers?)——组锚点位于该方块内的所有放置物。biomeAt(x, z)、biomeWeights(x, z)——那里的群落,以及每个群落的权重。explain(x, z)——决定那里站着什么的一切:环境场、群落、区域,以及每层在那一格的候选点和它被拒绝的原因(noBiome、density、noGround、slope、water、zone、occupied、conflict)。stats(layer, x0, z0, x1, z1)——多少候选点成立,其余为什么没有。
.esworld 文件像材质一样被跟随:它引用的网格和材质会随引用它的场景一起打包。从代码注册的规则引用的资源场景并不引用,打包时会被剔除;在 .esengine/asset-groups.json 里把它们所在的文件夹声明为始终包含:
{ "version": "1.0", "groups": { "recipes": { "folder": "assets/models", "mode": "local", "alwaysInclude": true } } }同一套规则也能生成 2D 世界:没有地形,把规划按 x 和 y 平铺来读,围绕相机分块发布。引擎不画任何东西;放置物长什么样(精灵、预制体)、地面怎么画(瓦片层、着色器)由你在 publish 里决定。
import { registerWorldRules, createWorld2D } from 'esengine/worldgen';
registerWorldRules(rules); // 群系、配方和层,与上文相同const world = createWorld2D(app, 'island', { rules: rules.id, seed: 11, chunkSize: 512, cellSize: 32, publish(chunk) { // chunk.placements:这里立着什么(id、recipe、member、biome、x、y、scale、interactive、footprint……) // chunk.biomeGrid:地面每个格子的群系,是 chunk.biomes 的下标 const spawned = spawnTilesAndSprites(chunk); return () => despawn(spawned); // 撤掉这个区块 },});
world.diffs.remove(tree.id); // 砍倒了:只有那个区块重做,并且一直保持砍倒- 区块跟着相机走(或跟着流送源),在帧预算内由近及远生成,超过
unloadRadius就释放;它是'xy'平面上的ProceduralChunks,所以无论世界多大,同时存在的区块数都有上限。 - 先问谁都一样。 一个区块只取决于种子、规则和位置,所以邻居早做晚做结果都一致,服务器和客户端一致,放置物的 id 在多次加载间稳定。一组(锚点和它的成员)属于锚点所在的区块。
- 玩家改过的东西是
PlacementDiffs,在区块显示前盖在规划上;diffs.toJSON()和diffs.load()就是存档。 - 区域手工规划:
ZoneDef的z是世界 y,用来让一片区域保持空旷或固定为某个群系(玩家搭的营地)。 - 配方的
ring是世界单位,arc是从 +x 转向 +y 的角度。mesh、material、attach和minUp是 3D 的东西,这里不读。
- 装饰按区块合并绘制,而不是 GPU 实例化。 渲染器的实例记录每帧重写,成千上万株植物每帧要上传数兆字节;合并的静态网格每帧不上传任何东西,内存由区块驻留限制住。
- 游戏自定义的环境场是代码。 规则文件使用内置环境场和区域设置的环境场;由游戏计算的环境场要随规则从代码注册。
参见 Generated World 示例(examples/worldgen)。