跳转到内容

生成世界

在随机点上散布出来的世界看起来像噪声:树、花、石头之间没有任何形状。esengine/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 组件的 Details、版本、世界、种子、涂地面和表面

地形与水面示例里的 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 的 Details、形状、半径、衰减、优先级、排除、群落和压平

一个 World Zone:一个圆,告诉生成器在其中生长哪种群落。

直立建筑的成员可以设置 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 预留。

带 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)。