本篇文章是对ProjectS中地形PCG模块的总结,可能需要一些前置知识,可以前往本文的 参考 部分进行相关引用
先明确下项目需求
20km x 20km的世界大小
大多数地貌为起伏幅度较小的平原
需要分tile编辑,预览
需要导出LOD高度图
支持植被和物件Instance的纹理生成和手工修改
需要一个工具统筹管理整个PCG管线,并实时查看每一步的结果
最终选择Houdini作为目标软件,因为Houdini作为一款相当成熟的PCG向的DCC软件,提供了相当多地形创建,编辑和工作流工具,应对我这个个人项目的简单地形PCG工作自然不成问题
项目概览
项目结构如下:
terrian_pdg:地形PDG模块,组织整个地形的PCG流程
terrian_sop:地形PCG中用到的所有SOP集合,大部分都导出成了HDA,供PDG使用
PDG流程
我在学习和解构一个对象的时候习惯从宏观看起,大概掌握每个环节的功能,有个全局的概念,再看具体细节的时候不会有一头雾水的感觉,所以我们从PDG开始看起
整个流程非常的直观
创建地形骨架
拆分地形
根据地形编号查找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 import pdgimport houimport jsonmerge_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游戏大部分的地貌都偏平坦,所以以比较低的噪声频率和迭代次数就能的得到一个合适的地形骨架
即使是如此简单的地形骨架,生成也是需要花费一定事件的,而且后续可能会有一些额外的全局处理,例如一些世界级的标志地貌,所以不能每次都重新生成,这里使用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暴露的参数决定
地形Tile处理
可以针对每个Tile制作一个HDA用于处理这个Tile,也可以制作一个HDA支持同时处理多个Tile
,这里以前者为例,名称为terrian_tile_handle_*
(星号为tile编号)
这里的示例就是对0号tile做了手工处理:
需要注意的是,我们需要通过bound来构造一个mask,用于限定编辑区域(最好对Bound的Mask开启Blur,效果更好一些),这样可以避免tile merge时产生过大的接缝
地形Tile合并
处理好所有Tile之后,我们需要将Tile合并起来,全局处理一下,预览最终的世界效果
本来是使用foreach block + python节点进行处理,但实践下来发现python节点内部的逻辑只会在cook的时候执行一次,不能保证foreach循环中每次都执行
而且Houdini中的Channel表达式对嵌套的支持并不友好,我尝试使用string+map的时候就失败了,无法计算map的value
所以最终方案是自己书写Python节点处理所有Tile,最终通过tilesplice进行Volumn合并
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 jsonnode = hou.pwd() geo = node.geometry() 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结果如下:
纹理切分导出
根据 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中的节点为
对于HeightField Output节点本身来说,为了达到更高的数值精度,我们使用16位的R通道深度图导出设置
同时,我们需要把地形高度remap,为了获取最高的数值精度,我们对于每个地块(2048*2048
),都映射到0-1,比如-10~100的高度,映射到0-1会比映射到0.x-0.x精度高得多
但代价就是在每个地块渲染的时候,需要指定这个地块的最高高度即1处的绝对高度值(在这个例子中就是110),才能得到正确的地形,我们可以只传递一次记录了这个信息的computebuffer(为AOS,10x10 = 100),然后在vs中取相应索引的数据即可
这个数据我们可以通过在PDG中添加一个HDA Processor,导出json得到
由于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 osoutput_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()
最终导出的部分高度图如下
地形高度信息导出
由于流式加载的原因,为了正确的对地形进行渲染和剔除,我们还需要导出每个四叉树节点对应地形高度信息,所以需要新建一个专门用于导出地形高度的HDA
我们只需要在划分正确区域范围后,再获取HeightField Split节点输出的Bound信息即可
假设已定义:
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 { public int tileIndex; public int lod; public int tilePosX; public int tilePosZ; public float minHeight; 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 oship_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:
这应该是houdini地形体素高度计算机制,对于高于0的部分,最小值为-1,低于0的部分,为正常值
,留意下即可
最后,我们将节点导出HDA,并在PDG中使用,需要注意的是,由于我们无法保证PDG多线程写入文件内容的原子性,所以需要单独为导出高度节点设置一个新的Schedulers,保证其是单线程的
地面 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我们都需要单独生成:
最终通过SOP进行导出:
最终通过rop导出一份Splat Texture资源:
手动自定义工具开发思路
之所以需要这个自定义工具的理由有以下几点:
特殊区域(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)手册