植被
一片草地是几万根草叶,一片森林是几千棵树,没有哪个引擎会一个实体一个实体地画它们。Foliage 组件把它们按格子长出来:区域被切成方块,每个方块是一个包含全部植物的网格,所以一片草地是几百次绘制,剔除可以整块丢掉,每个格子又按它的距离该有的细节来构建。
给某个实体加一个 Foliage 组件。区域以它为中心,它的子实体就是这些格子。格子是程序化区块:围绕流送源(没有流送源的游戏里是视点,编辑器场景视图里也是视点)生长到 drawDistance,由近及远,超出后连同网格一起释放,所以大片区域只保留看着它的东西附近的格子。字段、地面或水变化时重新生长,每个旧格子保留到新格子替换它为止。没有任何东西被烘焙。
| 字段 | 作用 |
|---|---|
mesh |
要散布的植物。为空则长草,草叶在这里生成。 |
material |
用 Foliage 着色器的材质,它负责风(见下)。为空则植物静止,用自己的颜色绘制。 |
sizeX、sizeZ、cellSize |
区域,以及它被切成的格子的边长。 |
count、seed |
每格的植物数,以及种子:换一个种子就在别处长出同样的植物。 |
minScale、maxScale |
每棵植物的大小在这两者之间。 |
minUp、heightMin、heightMax |
植物在哪里生长:一个地方最少要朝上多少(调低能让植物长上斜坡),以及一段世界高度范围。 |
waterDepth |
植物能站在水下多深:0(默认)让它们只长在干地上,芦苇则站在浅水里。 |
bladeHeight、bladeWidth |
只对草:缩放为 1 时一根草叶的大小。 |
lodDistance、drawDistance |
超过前者,格子用它的远景版本绘制;超过后者就不画。 |
cardU0、cardV0、cardU1、cardV1 |
网格的远景版本是两张交叉的卡片,显示它纹理的这个矩形(cardTent 为 1 时卡片裁成帐篷形,即针叶树的轮廓,而不是矩形)。 |
如果场景里有 Terrain,植物就站在它上面,避开太陡的地方,并等它构建完;没有地形时,区域是实体所在高度的平地。
预算与禁植区域
Section titled “预算与禁植区域”count 是每块植物数量。700 × 700 的区域使用 35 单位的区块、每块 750 株,在距离和地形筛选前最多有 400 块、30 万株。
密度约为 count / cellSize²,需要和区块数、LOD、绘制距离、阴影一起设置。增大区块可以减少提交,但裁剪会更粗。
短草可设置 castShadows: false,仍然接受光照和阴影。旧场景默认保持投影。
存在 WorldGen 时,Foliage 会读取它的禁植区域。zoneWorld 选择世界名称,留空使用第一个世界;zoneTags 为逗号分隔的额外标签,
默认已包含 foliage 和 grass(使用网格时为 tree);zonePadding 是额外的世界单位留白。
用 WorldZone 方框预留建筑地基、台阶和门前通道,用 esengine/worldgen 的 roadZones 或 setRoad 预留连续道路。
区域移动或删除后草地会重新生成。没有 WorldGen 的场景保持原有行为。
MCP 的 audit_vegetation 会给出配置上限和当前绘制统计。先用 step 完成预热,再测量;gpuDraws 包含阴影等全部 pass,
drawCalls 是场景列表的绘制数。后台窗口的空闲时间和 1 FPS 不能代表前台游戏帧率。大型森林优先使用支持共享网格和逐实例 LOD 的
WorldRules.plants 或 PlacedFoliage。
Foliage 着色器(builtin:foliage)按顶点颜色里画好的权重弯曲每个顶点(红色是它在植物上多高,所以根不动、尖端动;绿色是植物自己的相位,所以一片草地不会整体一起摆),再加一阵在世界上传播的阵风和叠在上面的颤动。参数在材质里:u_windStrength、u_windSpeed、u_windDirection(度)、u_gustScale(越小阵风越长)和 u_flutter。植物是双面的,并且让光透过(u_translucency);带 alpha 通道的叶子纹理(u_albedo、u_useTexture、u_alphaCutoff)能从一张卡片里抠出叶子的轮廓。
你自己的着色器也能读到同样的权重:顶点钩子(#pragma vertexoffset)里,顶点颜色是 es_vertexColor。
每个格子构建两次。近处,草是每棵植物一根弯曲、渐细的草叶;远处,是三分之一的草叶,更宽而且更平。网格近处是完整的拷贝,远处是每棵植物两张交叉的卡片。引擎自己的细节层级按格子覆盖多少屏幕在它们之间选择,超过 drawDistance 就把格子丢掉。
Foliage 按规则撒植物。美术搭好的场景里,每一棵都在美术放的位置,常常有几万棵,做成实体就是每棵一个变换、一个渲染器、一次绘制。PlacedFoliage 把它们当作数据存在一个实体里:一张 kinds 种类表和一份 records 记录。
| 字段 | 作用 |
|---|---|
sizeX、sizeZ、cellSize |
区域(以实体为中心)和切成的格子边长。 |
kinds |
每种植物由什么组成:parts 部件(mesh 网格、更省的 lod1Mesh、material 材质、texture 贴图、tint 色调、是否 opaque);超过 lodDistance 换成更省的网格,超过 drawDistance 不再绘制;以及远景版本 far 部件,从 drawDistance 一直画到 farDistance。 |
records |
每棵植物的位置:每棵六个数,种类序号、x、y、z(在实体空间里)、绕竖直轴的转角(弧度)、缩放。 |
镜头(或流式加载源)周围的格子会被生长出来,离开距离就释放。格子会立刻出现,之后的几帧里逐步填满:每个种类的每个部件占帧预算里很小一块,由近到远。每个部件是一个实体,画的是该部件的网格(所有格子共用这一份),摆在每棵植物所在的位置:MeshInstances 组件存着这些位置(每棵 64 字节),引擎逐棵按视野剔除,超过 lodDistance 的用更省的网格,超过 drawDistance 的不画。几何体不论多少棵都只有一份,网格和材质相同的绘制合成一次。网格保留美术做的所有通道(法线贴图、第二套 uv、顶点色),不会被覆盖,所以植物用普通材质绘制,而不是带风的材质。
远景版本就是层级细节(HLOD):树的交叉卡片烘到自己的图集上,和植物本身一样是一组摆放位置,从 drawDistance 起画到离镜头 farDistance。它便宜到整个区域可以一次全部长出。没有远景版本的种类,超过 drawDistance 就不画。
没有自己卡片的种类可以拿更省的网格当远景版本,并设置 farKeep:远景版本画多少比例的植物(大于 0 到 1)。每 n 棵保留一棵,n 取最接近 1 / farKeep 的整数,保留下来的放大到原来的 n 的平方根倍(最多两倍),覆盖的地面面积不变。这样远处的地被只花近处一个比例的代价,其余由地面自己的纹理承担。
植物是直立、等比缩放的,记录里没有倾斜。kinds 里的路径是项目路径,列出来是为了让包带上它们。
森林不可能全是实体,也不需要:能被碰到的只有附近的东西。给角色(或其他植物应该感知的东西)加一个 FoliageContact。它做两件事,分别对应植物的两种绘制方式。
| 字段 | 作用 |
|---|---|
radius |
植物从实体倒开的距离,世界单位。 |
strength |
力度:1 表示完全倒开。 |
recovery |
它走后草重新立起来的秒数。 |
草会分开。 Foliage 的一个格子是一个网格,所以弯曲在顶点里算:Foliage 着色器接收四个位置(u_contact0 到 u_contact3),按草叶高度把每片叶子推开,高出或低于接触点越多推得越少。这四个是离流式来源最近的接触点,然后是它们刚离开的位置,这些位置会在 recovery 内收拢,所以角色身后留下的是一条慢慢合上的分痕。u_contactBend 是叶子分开的距离占接触范围的比例。超过四个时,多余的接触点不会分开任何草,玩家看到的是远处生物周围的草不动。除了加这个组件,不需要打开别的东西。
放置的植物靠近才激活。 PlacedFoliage 或 WorldRules.plants 里的种类用 interaction 开启。它仍然是 MeshInstances 里的一个实例,但接触点在 activationDistance 内时,每棵植物还会多一个实体 PlantProxy,接触点离开后实体消失。画面全程不换手,所以不会跳变。这个实体就是射线或碰撞找到的东西,游戏自己的组件也可以挂上去。
interaction 字段 |
作用 |
|---|---|
activationDistance |
接触点多近时植物变成实体,世界单位。0 表示始终是数据。 |
radius、height |
代表它的胶囊体,立在根部,单位是网格单位(乘以缩放)。任一为 0 则没有。 |
bend |
从接触点倒开的最大角度,弧度。这是对实例矩阵的错切,带弹簧,所以任何材质都能弯,放开时会晃一下。 |
health |
damage 要打掉多少才砍倒。0 表示不能这样砍倒。 |
fallSeconds、fallAngle、lieSeconds |
倒下用时(默认 1.8)、倒下角度(默认 85 度)、躺多久后沉入地下(默认 30)。 |
felledKind、harvestedKind |
砍倒后留下的种类(树桩)、采完后变成的种类(光秃的灌木):kinds 的下标,或 WorldRules.plants 里的名字。不写则什么都不留。 |
yields |
harvest 能取多少次。 |
regrowSeconds |
砍倒或采完后多少秒重新长出,从小长大。0 表示不再长。 |
通过 esengine/foliage 的资源访问:
import { FoliageContact, PlantInteractionRes } from 'esengine/foliage';
const plants = app.getResource(PlantInteractionRes);
// 这一斧够得着的植物,由近到远。const [tree] = plants.near(player.x, player.z, 2);if (tree) plants.damage(tree, 25, { x: player.x, z: player.z }); // 血量归零时朝远离这一击的方向倒下
// 射线打到的植物。const hit = physics.raycast(eye, forward);const plant = hit && plants.of(hit.entity);if (plant) inventory.berries += plants.harvest(plant, 1);
plants.on((event) => { if (event.type === 'landed') spawnDust(event.plant); });砍倒的植物以带自身网格的实体倒下,是绕根部转动的运动学刚体,躺着时会挡住路过的东西;它的实例立刻消失,远景版本也一样。
植物的结局会保留。 状态按植物的 key 记录,不按格子,所以砍倒的树在区块卸载再生成后依然是倒的。plants.saveState() 把这些都导出成普通数据,包括再生用的时钟;plants.loadState(state) 还原,在格子生成前后调用都可以。世界的植物用规划里的位置标识。PlacedFoliage 的植物用 records 里的下标加 stateKey(或实体名)标识,所以除了重排 records,存档都有效;两片植被要起不同的名字。
植被和地形一样是可选模块:场景里有 Foliage 或 PlacedFoliage 组件,或脚本导入了 esengine/foliage,包里才会带上它。场景里有 Terrain 时植被立在地形上;平地上不需要地形模块。
- 只有位置。 区域被当作不旋转、不缩放;地形的位置和均匀缩放会被跟随,旋转不会。
- 只有放置的植物能砍。 散布的
Foliage会为接触点分开,但一个格子是一个网格,没有单棵植物可以碰撞、砍伐或采集;要砍的森林请用PlacedFoliage或WorldRules.plants。 - 倒下靠规则,不靠求解器。 它绕根部转到
fallAngle,不会贴合斜坡或滚动,所以陡坡上树干可能悬空或插进地里。刚体是运动学的:它推开落点的东西,但没有东西能推它。 - 碰撞需要 3D 物理模块。 代理的胶囊体在模块加载后才有,场景里有
CharacterController3D或刚体时会自动加载;没有的话植物仍能弯曲和砍倒,只是没有碰撞。形状只有胶囊体,宽树冠或分叉不会贴合。 - 倒下中的植物不进存档。 正在倒的树存为已砍倒,读档后直接消失。
- 网格的远景版本是卡片,不是 impostor。 它显示你指定的那个纹理矩形,从各个方向看都一样;纹理里没有这样一张图的树,卡片上就是它的纹理。
- 格子在每帧预算内生长,由近及远,所以远处会在一小会儿内填满。