本篇文章是对ProjectS中地形PCG模块的总结,可能需要一些前置知识,可以前往本文的 参考 部分进行相关引用

先明确下项目需求

  • 20km x 20km的世界大小
  • 大多数地貌为起伏幅度较小的平原
  • 需要分tile编辑,预览
  • 需要导出LOD高度图
  • 支持植被和物件Instance的纹理生成和手工修改
  • 需要一个工具统筹管理整个PCG管线,并实时查看每一步的结果

最终选择Houdini作为目标软件,因为Houdini作为一款相当成熟的PCG向的DCC软件,提供了相当多地形创建,编辑和工作流工具,应对我这个个人项目的简单地形PCG工作自然不成问题

项目概览

项目结构如下:

image-20230830230855423

  • terrian_pdg:地形PDG模块,组织整个地形的PCG流程
  • terrian_sop:地形PCG中用到的所有SOP集合,大部分都导出成了HDA,供PDG使用image-20230830233525023

PDG流程

我在学习和解构一个对象的时候习惯从宏观看起,大概掌握每个环节的功能,有个全局的概念,再看具体细节的时候不会有一头雾水的感觉,所以我们从PDG开始看起

image-20230830235553419

整个流程非常的直观

  • 创建地形骨架
  • 拆分地形
  • 根据地形编号查找hda文件,如果存在则进行处理,否则直接输出
  • 整合分类所有tile结果,即把经过hda处理的tile和原始tile统一通过编号分类,确保输出的为最终的结果
  • Merge所有tile
  • 导出纹理(如果需要分tile导出,则需要再次进行split后导出)

唯一需要注意的一点是通过PythonProcessor将tile信息透传到merge节点的时候,需要做一些处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# Called when this node should generate new work items from upstream items.
#
# self - A reference to the current pdg.Node instance
# item_holder - A pdg.WorkItemHolder for constructing and adding work items
# upstream_items - The list of work items in the node above, or empty list if there are no inputs
# generation_type - The type of generation, e.g. pdg.generationType.Static, Dynamic, or Regenerate
import pdg
import hou
import json

merge_tile_hda_processor_node = hou.node('/obj/terrain_pdg/topnet1/merge_tile')

for upstream_item in upstream_items:
new_item = item_holder.addWorkItem(parent=upstream_item)
output_data = upstream_item.resultData
final_tile_map = {}

for name in output_data:
final_tile_map[name.local_path] = name.local_path
final_tile_map_json = json.dumps(final_tile_map)
merge_tile_hda_processor_node.parm("hdap_tile_file_path_map").set(final_tile_map_json)
break

地形制作

整体流程

基础地形骨架

由于暗黑like游戏大部分的地貌都偏平坦,所以以比较低的噪声频率和迭代次数就能的得到一个合适的地形骨架

image-20230830231815623

即使是如此简单的地形骨架,生成也是需要花费一定事件的,而且后续可能会有一些额外的全局处理,例如一些世界级的标志地貌,所以不能每次都重新生成,这里使用switch节点来根据传递的参数决定是否需要重生成地形,如果需要重新生成,则调用file cache节点来强制触发重建地形

1
2
3
4
5
6
7
8
9
10
11
node = hou.pwd()
geo = node.geometry()

hda_node = hou.node(node.path() + "/../")
if hda_node is not None:
if hda_node.parm("regenerate_terrian").eval() == 1:
node_name = node.path() + "/../terrian_base_file_cache"
node_obj = hou.node(node_name)

if node_obj is not None:
node_obj.parm('execute').pressButton()

地形拆分

拆分单位由HDA暴露的参数决定

image-20230830233352886

地形Tile处理

可以针对每个Tile制作一个HDA用于处理这个Tile,也可以制作一个HDA支持同时处理多个Tile,这里以前者为例,名称为terrian_tile_handle_*(星号为tile编号)

image-20230830233703277

这里的示例就是对0号tile做了手工处理:

  • 通过绘制line来构筑一些山脉
  • 增强地表细节

image-20230830233831429

需要注意的是,我们需要通过bound来构造一个mask,用于限定编辑区域(最好对Bound的Mask开启Blur,效果更好一些),这样可以避免tile merge时产生过大的接缝

image-20230830234032531

地形Tile合并

处理好所有Tile之后,我们需要将Tile合并起来,全局处理一下,预览最终的世界效果

本来是使用foreach block + python节点进行处理,但实践下来发现python节点内部的逻辑只会在cook的时候执行一次,不能保证foreach循环中每次都执行

而且Houdini中的Channel表达式对嵌套的支持并不友好,我尝试使用string+map的时候就失败了,无法计算map的value

所以最终方案是自己书写Python节点处理所有Tile,最终通过tilesplice进行Volumn合并

image-20230830234307984

Python节点内容如下:

可以看到读取了tile_file_path_map channel值,这是从PDG传递到HDA的参数,包含了所有Tile数据

(吐槽一下:Houdini到19为止都还没有数组类型的参数,只能用key-value Dictionary,而key-value Dictionary在通过Python或VEX,HScript传递时总是会报出 TypeError: Cannot set a numeric parm to a non-numeric value,所以这里通过string进行参数透传,最后在Python转回key-value Dictionary)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import json

node = hou.pwd()
geo = node.geometry()

# Add code to modify contents of geo.
# Use drop down menu to select examples.

hda_node_env = node.path() + "/../"
hda_node = hou.node(hda_node_env)

geo1 = hou.Geometry()

if hda_node is not None:
tile_file_path_dict_json = hda_node.parm("tile_file_path_map").eval()
tile_file_path_dict = json.loads(tile_file_path_dict_json)
for key, value in tile_file_path_dict.items():
geotemp = hou.Geometry()
geotemp.loadFromFile(value)
geo1.merge(geotemp)
node.geometry().copy(geo1)

最终Merge结果如下:

image-20230831001220388

纹理切分导出

根据 ProjectS中的地形系统-Terrian Rendering 中提及的世界大小规划:

可以得知,对于各个LOD等级的高度纹理,假设为全地图覆盖纹理,我们需求如下:

  • LOD0,覆盖64m,320x320个 = 102400
  • LOD1,覆盖128m,160x160个 = 25600
  • LOD2,覆盖256m,80x80个 = 6400
  • LOD3,覆盖512m,40x40个 = 1600
  • LOD4,覆盖1024m,20x20个 = 400
  • LOD5,覆盖2048m,10x10个 = 100

通过HeightField Output节点即可对高度图进行导出,对于上面的LOD需求,通过HDA参数暴露出去,然后通过foreach block进行流程控制即可

假设已定义:

  • terrian_size:传入的地形大小,默认2048
  • terrian_lod_count:LOD等级数,[0, x),即此处应为6
  • sector_cover_size:sector大小
  • height_texture_resolution:高度图分辨率,默认128
  • iteration:foreach迭代index,来自terrian_lod_count

则在每次for循环中,对于HeightField Output的参数设置遵循以下算法:

  • resolution:terrian_size * 2 * 1 / pow(2, iteration),各个LOD值分别为4096,2048,1024,512,256,128,因为这是目标贴图的大分辨率,所以0.5m为分辨率的话,LOD0对应分辨率就是4096,经过tile切分后是32x32块,即最终导出分辨率依旧为128,其余分辨率同理
  • num_tiles:resolution / height_texture_resolution,各个LOD值分别为32,16,8,4,2,1

PDG中的节点为

image-20230903002930810

对于HeightField Output节点本身来说,为了达到更高的数值精度,我们使用16位的R通道深度图导出设置

image-20231029203445467

同时,我们需要把地形高度remap,为了获取最高的数值精度,我们对于每个地块(2048*2048),都映射到0-1,比如-10~100的高度,映射到0-1会比映射到0.x-0.x精度高得多

但代价就是在每个地块渲染的时候,需要指定这个地块的最高高度即1处的绝对高度值(在这个例子中就是110),才能得到正确的地形,我们可以只传递一次记录了这个信息的computebuffer(为AOS,10x10 = 100),然后在vs中取相应索引的数据即可

这个数据我们可以通过在PDG中添加一个HDA Processor,导出json得到

image-20231101233821792

由于Python节点编译特性限制,我们只能手动触发HeightField Output节点的导出操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
node = hou.pwd()
geo = node.geometry()

import os

# Add code to modify contents of geo.
# Use drop down menu to select examples.

# 获取输出节点,设置通用参数
output_node = hou.node('../heightfield_output1')
output_node.parm("out_format").set("single")

output_node.parm("specify_res").set("1")

output_node.parm("fill_red_channel").set(1)
output_node.parm("red_channel").set("height")
output_node.parm("red_out_range").set("auto")
output_node.parm("tile_output").set("1")
output_node.parm("tile_method").set("numtiles")
output_node.parm("file_naming").set("xytile")

output_node.parm("tile_overlapx").set("1")
output_node.parm("tile_overlapy").set("1")

terrian_size = hou.node("../").parm("terrian_size").eval()
iteration_count = hou.node("../").parm("terrian_lod_count").eval()
sector_cover_size = hou.node("../").parm("sector_cover_size").eval()
height_texture_resolution = hou.node("../").parm("height_texture_resolution").eval()

enable_export = hou.node("../").parm("enable_export").eval()

# 获取节点的第一个输入端口连接的节点
file_node = output_node.inputs()[0]
tile_index = os.path.basename(file_node.parm("file").eval()).split("_")[2]

for i in range(iteration_count):
output_node.parm("output").set("$HIP/texture/terrian_tile_{}_lod{}.png".format(tile_index, i))

resolution_x = terrian_size * 2 * 1 / (2 ** i)

output_node.parm("resolutionx").set(resolution_x)
output_node.parm("resolutiony").set(resolution_x)

num_of_tile = resolution_x / height_texture_resolution
output_node.parm("num_tilesx").set(num_of_tile)
output_node.parm("num_tilesy").set(num_of_tile)

if enable_export == 1:
output_node.parm('execute').pressButton()

最终导出的部分高度图如下

image-20230831001454915

地形高度信息导出

由于流式加载的原因,为了正确的对地形进行渲染和剔除,我们还需要导出每个四叉树节点对应地形高度信息,所以需要新建一个专门用于导出地形高度的HDA

我们只需要在划分正确区域范围后,再获取HeightField Split节点输出的Bound信息即可

image-20231129213350213

假设已定义:

  • terrian_size:传入的地形大小,默认2048
  • terrian_lod_count:LOD等级数,[0, x),即此处应为6
  • sector_cover_size:sector大小
  • iteration:foreach迭代index,来自terrian_lod_count

则在每次for循环中,对于HeightField Split的参数设置遵循以下算法:

  • tilecount:terrian_size / pow(2, terrian_lod_count + iteration),各个LOD值分别为32,16,8,4,2,1
  • num_tiles:[0, tilecount),作为索引更新

随后我们确认下要导出的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
namespace ET
{
public class TerrianHeightInfo
{
/// <summary>
/// tile的索引,此处的tile为2048*2048的地形,如果整个世界是20480*20480,那么这里索引就是0~99
/// </summary>
public int tileIndex;

/// <summary>
/// lod等级
/// </summary>
public int lod;

/// <summary>
/// tile内部继续细分的tile的x位置,例如一个tile被细分成32*32个tile,这里的索引就是0~31
/// </summary>
public int tilePosX;

/// <summary>
/// tile内部继续细分的tile的z位置,例如一个tile被细分成32*32个tile,这里的索引就是0~31
/// </summary>
public int tilePosZ;

/// <summary>
/// 代表的区域高度最小值
/// </summary>
public float minHeight;

/// <summary>
/// 代表的区域高度最大值
/// </summary>
public float maxHeight;
}
}

所以python脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
node = hou.pwd()
geo = node.geometry()

import os

# Add code to modify contents of geo.
# Use drop down menu to select examples.

hip_dir = hou.getenv('HIP')

# 获取输出节点,设置通用参数
terrian_split_node = hou.node('../heightfield_tilesplit1')

terrian_size = hou.node("../").parm("terrian_size").eval()
iteration_count = hou.node("../").parm("terrian_lod_count").eval()
sector_cover_size = hou.node("../").parm("sector_cover_size").eval()
enable_export = hou.node("../").parm("enable_export").eval()

for i in range(iteration_count):
tilecount_x = round(terrian_size / (2 ** (iteration_count + i)))
tilecount_y = tilecount_x;
tilecount = tilecount_x * tilecount_y;

terrian_split_node.parm("tilecountx").set(tilecount_x)
terrian_split_node.parm("tilecounty").set(tilecount_y)

for tile_index_x in range(tilecount_x):
for tile_index_y in range(tilecount_y):
terrian_split_node.parm("tilenum").set(tile_index_y * tilecount_y + tile_index_x)
terrian_split_node.cook()

max_height = terrian_split_node.geometry().boundingBox().maxvec().y()
min_height = terrian_split_node.geometry().boundingBox().minvec().y()

max_height_info = "\"lod\": {0}, \"posX\": {1}, \"posZ\": {2}, \"minHeight\": {3}, \"maxHeight\": {4} \n".format(i, tile_index_x, tile_index_y, min_height, max_height)

with open('{0}/json/terrian_height_info.txt'.format(hip_dir), 'a') as file:
# 要写入文件的字符串
string_to_write = max_height_info
# 将字符串写入文件的末尾
file.write(string_to_write)


我们注意到,相当大一部分的数据y轴最小高度为-1

1
2
3
4
5
6
7
8
9
10
11
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 0, "minHeight": -1.0, "maxHeight": 27.238351821899414 
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 1, "minHeight": -1.0, "maxHeight": 25.832469940185547
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 2, "minHeight": -1.0, "maxHeight": 24.063087463378906
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 3, "minHeight": -8.591848373413086, "maxHeight": 11.450308799743652
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 4, "minHeight": -18.10664939880371, "maxHeight": 1.0
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 5, "minHeight": -18.287702560424805, "maxHeight": 1.0
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 6, "minHeight": -15.97429370880127, "maxHeight": 1.0
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 7, "minHeight": -7.330552577972412, "maxHeight": 11.99618911743164
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 8, "minHeight": -1.0, "maxHeight": 30.167404174804688
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 9, "minHeight": -1.0, "maxHeight": 44.927547454833984
"tileIndex": 0, "lod": 0, "tilePosX": 0, "tilePosZ": 10, "minHeight": -1.0, "maxHeight": 51.94204330444336

这是lod0,x0,z0处的tile:

可以看到,虽然整个tile都在y=0上方,但ymin仍然为-1

这应该是houdini地形体素高度计算机制,对于高于0的部分,最小值为-1,低于0的部分,为正常值,留意下即可

最后,我们将节点导出HDA,并在PDG中使用,需要注意的是,由于我们无法保证PDG多线程写入文件内容的原子性,所以需要单独为导出高度节点设置一个新的Schedulers,保证其是单线程的

image-20231203233353651

地面 Mask

参考 地面渲染部分 ,我们的目标是导出一个R8格式的splat texture,每个纹素值都代表所引用的MaterialId,最多支持256种纹理混合

与高度图不同的是,SplatTexture不需要进行LOD的分离导出,只需要每个2048区域导出一份(2048x2048分辨率)即可,因为对于远处的地面渲染一般都基于预烘焙diffuse texture直接采样,所以不需要额外对SplatTexture进行LOD导出了

整体过程也比较简单:

注意需要关闭笔刷混合,否则会出现错误的纹素值,猜测是Houdini内部问题

在此方案中必须!

还注意的是Houdini中的SOP处理的Mask不论生成时的数值为多少(0.003或者300)最后都会自动ReSample到0-1的范围,即其并不具备表示index的能力,所以对于每个Layer的Mask我们都需要单独生成:

image-20240623230519461

最终通过SOP进行导出:

image-20240623230648942

最终通过rop导出一份Splat Texture资源:

不同灰度的数值代表不同的Material Id,可通过对ceiling(纹素x256)的形式还原出正确的Material Id

手动自定义工具开发思路

之所以需要这个自定义工具的理由有以下几点:

  • 特殊区域(Boss战,世界级地标)的自定义
  • 不同于既定的Mask覆盖规则

这里着重解释下第二点,想象这样一个场景,一般情况下,岩石的Mask是要被草地的Mask进行覆盖的,但是某个区域我们就是想让岩石Mask覆盖草地Mask,这时候就得直接将岩石Mask的值强制覆盖指定纹素了,有反应比较迅速的读者可能会说:“何必多此一举,直接去改草地Mask,把这里的值Clear掉,再去岩石Mask把这里的值填充上,重新导出不就好了?”,固然是可行的,Layer Mask不多的情况下确实可以这样手动处理,但如果游戏超过100多种地表材质混合,这样改起来的人力成本和错误成本就非常大了,就像回到了Unity的Splat方案一样(每次修改混合数据得修改多个Layer Mask,才能保证数据正确)

所以我们需要在Houdini/Game Engine中制作一个工具,可以让地编选择某个Material,直接使用笔刷即可覆盖指定区域的Material值,这样才是效率最高的方法

  • Houdini:基于Houdini SDK和SOP进行自定义节点开发,直接修改Splat Texture
  • GameEngine:基于编辑器拓展和Compute Shader进行修改Splat Texture,从而达到所见即所得的效果

TODO

物件Instance Mask

道路HDA

河流HDA

参考

PDG for Indie Gamedev

Houdini HeightField手册

Houdini Procedural Dependency Graph(PDG)手册