BACK TO ARCHIVE
houdini usd interoperability pipeline td fx rendering dcc mechanics

Houdini 植被高效布局与碰撞模拟:把植被自动变成 UsdSkel Agents(含 Vellum)

10.19.2025 ADUCG RESEARCH

原文:

概述

最近我一直在 Houdini 里尝试做植被相关的流程。我的一个主要目标是:找到一种方式,能够从已有的 layout 中高效提取植被实例,并通过 UsdSkel 在其之上叠加动画。

这个流程的主要使用场景是:角色穿过密集植被时,你需要把特定的植被实例抽出来做碰撞模拟。

为此,我设计了一套系统:把植被转换为 agents,并自动创建一个可用于 Vellum 模拟的近似骨骼。整体思路是:你在 layout 的 point instancer 中直接使用这些 agents,然后手工挑选需要模拟的植被实例。由于它们是 agents,你可以把骨骼提取出来作为 Vellum 的输入进行模拟,再把模拟后的 pose 重新挂回 agent,从而输出非常轻量的、基于 UsdSkel 的动画缓存。

本文会按一条完整的生产链路讲解:如何批量自动生成植被 agents(包含 proxy、shader、骨骼)并输出规范的 USD 资产;如何在 Solaris 中搭建 layout、挑选需要交互的局部实例,使用 Vellum 基于骨骼做高效碰撞模拟;以及最后如何把模拟姿态回写到 UsdSkel,并完成渲染与合成。

如果你想跟着做,可以从 gscatter.com 下载任意免费的植被资产。请确保下载 Alembic 版本。如果某个植被资产包含名为 “Building Blocks” 的 alembic,我建议在开始前删除它,否则会额外生成大量资产(每个 building block 都会变成一个资产)。

本文演示主要使用的植被资产是 Blackberry RubusPlicatus,下载链接:
https://store.gscatter.com/assets?search=blackberry&workspace=

下载前在下拉菜单里选择 Alembic(.abc)格式。

最终合成镜头。

初始 PDG 设置(Initial PDG Setup)

Tip

本文中我会把 PDG 与 TOPs 交替使用(类似 Solaris 与 LOPs 的关系):PDG 是功能名,TOPs 是使用该功能的上下文名称。

在开始进入 rig 生成之前,我先搭了一个基础的 PDG 网络,用来遍历我们下载的每个植被资产。先把这一层搭好之后,你就可以快速切换不同资产来验证自动生成的稳定性。这个网络也会被用来导出最终 USD。

如果你对 PDG/TOPs 不熟,建议先看 Houdini 文档:
https://www.sidefx.com/docs/houdini/tops/intro.html?ref=andreaskj.com

你可以去 /tasks 上下文,也可以在 /obj 创建 TOP Network。我这里选择在 /obj 创建。

在 TOP Network 中,我创建了一个 File Pattern 节点。它会按路径 pattern 扫描文件系统,并为每一个匹配文件生成一个 work item,同时生成一些有用的属性,如 filenamedirectoryextension,后面会用到。

我这里的 pattern 是:

/Users/andreaskj/Documents/Stock/3D Models/GScatter/*/*_lod0_OL.abc

它会匹配 GScatter 目录下所有子文件夹中的 lod0 alembic(即最高分辨率的模型)。如果你用的是 Megascans 或别的资产库,需要据此修改路径 pattern。

选择 File Pattern 节点,按 SHIFT+V 进行 Dirty & Cook 后,你应该能看到每个 lod0 alembic 对应一个 work item(绿色点)。中键点击一个绿色点可以查看它的属性。

TOPs 里的 File Pattern:匹配所有 lod0 alembic。

左键点任意一个 work item,将它设为当前上下文(哪一个都无所谓)。这样我们在后面进入其他上下文时,就能针对该 work item 工作;并且会出现一个下拉菜单,方便切换到其他 work item。

仍在 TOP 网络里,创建一个 LOP network。我们会在其中搭建资产生成网络,然后由 TOPs 负责批量执行。

TOPs 上下文里的 LOP network。

在 LOP network 中,我们需要搭一个 Component Builder 网络。这能让我们按 USD 标准以“干净”的方式 author 资产,并更容易做后续处理。你可以直接在 TAB 菜单里添加 Component Builder recipe,它会生成一整套结构。暂时我们不用深入改它,但等我们需要添加 shader 与 variant 时会回来继续。

Component Builder recipe。

最后,进入 Component Geometry 节点,我们就可以开始在 SOPs 里搭建 Agent Generation。

Agent Generation(生成 Agents)

我在植被动画上遇到的主要挑战之一,是需要根据输入几何生成“可用的骨骼”。这很容易变成非常耗时的工作,尤其当你要处理大量不同类型的植被时。

一些植被建模软件(例如 SpeedTree)可以输出某种形式的 skeleton 用于绑定。但在实际制作中,你经常会遇到来自 Megascans 或 GScatter 之类供应商的模型——它们通常不包含可用于变形的骨骼或曲线。因此我们需要自己生成骨骼。

本节会展示一种自动生成“可用于 agent 的骨骼”的方法。

Component Geometry 里先放一个 File SOP 并连接到 default 输出节点。它将作为 render purpose 的几何来源。

Geometry File 改成:

`@directory`/`@filename``@extension`

这些就是 TOPs 生成的属性。你可以用下方截图里的下拉菜单切换不同 work item/模型。如果你看不到下拉菜单,确认你已在 TOP 网络里按前文所述选中了一个 work item。

Component Geometry 内的 File SOP。

接着,我创建了一个名为 agent_generate 的子网络(subnet)用于组织结构,并把它接在 File SOP 下方。进入该子网络后,我们开始搭 agent generator。

Tip

如果你想复习 USD Agents(UsdSkel)的概念,可以看我另一篇文章里相关章节:
Rapid Crowd Generation in Houdini 20(Crowds in USD/Solaris)。

Skeleton Generation(骨骼生成)

要把植被变成 agent,我们需要两个组件:

  1. skeleton(骨架)
  2. 带权重的 mesh(skinned mesh)

GScatter 的 .abc 往往包含多个模型,因此我们需要对每个 primitive 分别生成一个 agent。此阶段我先把 For-Each 的 Single Pass 设为 0,方便只测试其中一个模型,确保流程没问题。

For Each Primitive:Single Pass = 0。

为了让骨骼生成正常工作,我们需要先用 Unpack 解包几何。记得开启 Convert Polygon Soup Primitives to Polygons,因为骨骼生成需要原始几何。

Unpack Geometry。

接着我用 Poly Extrude 给所有几何做了一个非常小的挤出厚度。这是为了帮助 skeleton 生成器稳定工作:它通常在“有一定厚度”的网格上表现更好。

通过 Poly Extrude 增加厚度。

现在可以生成骨骼了。我试过好几种方法,甚至想自己写生成器,但最后选择了内置的 Labs Straight Skeleton 3D。它效果出乎意料地好,只需要针对我的使用场景做一个小调整。

你可以在该节点上右键选择 Allow Editing of Contents,进入节点内部网络,在最底部你会看到一个 For-Each 网络,需要把它禁用。按我的理解,这一段原本是做某种 cleanup,但在很多骨骼上它会把整个 skeleton 删除。也许有更正确的修复方式,但我最终选择自己在后面搭一套 skeleton cleanup,以获得更可控的行为(下一节会讲)。

在 Labs Straight Skeleton 3D 内禁用 cleanup。

禁用后你应该能得到一个基于输入 mesh 的清晰骨骼。Labs Straight Skeleton 3D 中我只改了一个参数:Fuse Distance,设为 0.05(具体取决于模型尺度)。

Labs Straight Skeleton 3D 参数。

Labs Straight Skeleton 3D 后面,我接了一个 Fuse 节点,使用很小的 snap distance,确保任何重叠点都连接起来。

Fuse 参数。

之后我用一个 detail wrangle 在原点添加一个额外的 root joint,并把它连接到骨骼上距离原点最近的点。代码如下(Wrangle 运行在 Detail):

int root = addpoint(0, {0,0,0});
int closept[] = pcfind(0, "P", {0,0,0}, 1000, 1);

addprim(0, "polyline", closept[0], root);

创建 root joint 的 wrangle。

此时如果你把 Rig Doctor 接到这套骨骼上进行初始化,你会立刻看到一个关于 hierarchy cycles 的 warning。虽然在 Rig Doctor 里它只是 warning,但它会导致我们后续的 agent 生成失败,所以必须处理。下一节会讲如何清理,以及什么是 Hierarchy Traversal Cycle。

清理骨骼(Cleaning up Skeleton)

先解释为什么需要清理:如前所述,Rig Doctor 会提示 warning。看起来像“不影响”,但实际上它正是导致后续 Agent From Rig 失败的原因。

失败原因是:骨骼中出现了 “Hierarchy Traversal Cycle”。这意味着某些 joint 层级中,某个 joint 的 parent 同时又是同一层级中的 child;换句话说,层级形成了环。

下面是一个更直观的例子:joint 形成了一个循环。你能看到“茎”的 joint 最终又回到自己,导致它变成自己的子孙而形成 cycle。有时它也可能没那么明显,仅仅是 joint 方向反了,就会产生类似问题。

两个 Hierarchy Traversal Cycle 的示例。

如果你想自己定位 cycles,可以接 Rig Doctor,开启 Initialize TransformsShow Parent to Child,并降低 Joint Scale。这样你会看到 joint 之间的白色与红色箭头;红色箭头就是需要修复的 cycles。

通过 Rig Doctor 查看 Hierarchy Traversal Cycles。

修复方式有很多。我这里做了一个自定义 HDA,试图覆盖所有可能导致 cycles 的情况,在这些植被上表现不错。你可以在我的 Github 访问:
https://github.com/andreask-j/akjTools(HDA 名叫 Fix Cycles)

我把该 HDA 的内部步骤做了个拆解:它首先通过修复 vertex order 来修复“方向反了”的曲线(参考 tokeru 的 HoudiniKineFX 页面),然后利用 Rig Doctor 的部分能力检测更多 cycles 并移除它们。接着重新连接任何“漂浮”的骨骼片段,最后做一次最终检查,修复剩余的方向问题。

Fix Cycles HDA 内部结构拆解。

应用之后,我们应该得到一个可用于 agent 生成的干净 skeleton。

Rig Doctor

最后,为了初始化 skeleton,我们需要接一个 Rig Doctor。它会初始化曲线并生成一个可用的 skeleton:包含初始 transforms、orients 与命名。

为了让后续能更容易区分 joint,我还把每个 joint 的名字加上 for-loop 迭代编号作为前缀。

做法是:在 for-loop 的 Block Begin (foreach_begin) 上点击 Create Meta Import Node。它会给你一个可用于查询当前迭代信息的节点。

回到 Rig Doctor,点击右上角小齿轮选择 Add spare input,把 Meta Import 节点接到 spare input。我觉得 spare input 比在参数里写完整路径更方便(有 spare input 时可以用 -1 作为引用)。

将 Metadata 节点添加为 spare input。

然后在 Prefix 参数里填写 point_detail(-1, "iteration", 0)_,用迭代编号作为 joint name 前缀。最后开启 Initialize TransformsRe-Orient To Child

Rig Doctor 参数。

如果之前步骤正确,你现在应该得到一个干净的 skeleton,并且 Rig Doctor 不会再有 warning。

Skinning

Skinning 是这套系统里最薄弱的一环:很难找到一个能对所有输入都稳定工作的自动 skinning 方案,而且我也会在一些 skinning 节点上遇到报错。

我的做法是模拟一种“Try/Except”流程:先尝试最“精确”的 skinning 操作,如果失败就退一步用不那么精确的,若仍失败就使用最简单、但基本一定能工作的方案。这样至少能保证每个植被都有某种 skinning 结果,同时优先用更精确的方案。

所有 skinning 节点输入一致:第一个输入是原始解包几何,第二、第三输入是 rig doctor 的输出。

Skinning 操作顺序。

第一尝试:Joint Capture Biharmonic,模式设为 Exact

第一种 skinning 操作。

第二尝试:另一个 Joint Capture Biharmonic,模式设为 Adaptive

第二种 skinning 操作。

第三尝试:Joint Capture Proximity(通常一定能成功,但效果相对粗糙)。

第三种 skinning 操作。

我用 Switch 判断“是否失败”(即第二输入是否为空几何),如果失败就切到下一个。Switch 节点里用的是这段 python:

if(hou.pwd().inputs()[1].geometry()):
    return 1
else:
    return 0

用于 Switch 节点检查错误/空几何的脚本。

初始化 Agents(Initializing Agents)

到这里我们已经到了 agent 搭建的最后阶段。现在只需要:基于骨骼创建 agent,把几何作为 agent layer 加进去,并给 agent 命名以便在 LOPs 中得到干净结构。

Agent From Rig 节点。

放一个 Agent From Rig,把 Rig Doctor 的输出直接接进去,它会把 skeleton 初始化为 agent。若前面的骨骼清理失败,这里很可能会直接报错。

像之前一样,我也给 Agent From Rig 添加了 spare input,并连接 Foreach Begin Metadata,用来构造 agent 名字。

Agent Name 参数设为:

`@itemname`_agent_`detail(-1, "iteration", 0)`

并关闭 Create Locomotion Joint(这主要用于传统 crowd locomotion,对本流程不适用)。

这里出现了一个

@itemname

变量:我们会在下一步(TOPs/PDG)通过一个 python 节点来生成这个自定义变量,用于获取当前植被资产的名字。

接着把几何挂到 agent 上:用 Agent Layer。在此之前我也用 Name 节点把几何命名为 geom,这样在 LOPs 里更好控制。

为几何设置 name 属性。

Name 输出接到 Agent Layer 第二输入,把 Agent From Rig 接到第一输入。参数没什么特别的,我把 layer 也命名为 geom 保持一致。

Agent Layer:把几何加入 agent。

最后,为了在 LOPs 里得到更干净的命名,我又加了一个 Name 节点,同样把 Foreach Begin Metadata 作为 spare input,并把 Name 设为与 agent name 相同。

For-Each loop 内最后的 Name 节点。

这就是 agent generation 了!现在你可以在 For-Each End 上关闭 Single Pass,让它对当前 TOPs work item 的所有模型一起 cook。效果如下:你会得到每个 model 对应一个 agent。

从当前 work item 生成的所有 agents。

为了让 @itemname 生效,我们需要在 TOPs 里加一个 Python Script 节点来初始化自定义变量:回到 TOPs,在 File Pattern 后面接一个 Python Script,代码如下:

itemname_list = work_item.stringAttribute("filename").split("_")[0:3]
work_item.setStringAttrib("itemname", "_".join(itemname_list))
 
outputdir = work_item.stringAttribute("directory").split("/")[0:-1]
outputdir.append("usd")
work_item.setStringAttrib("outputdir", "/".join(outputdir))
 
texture_prefix = work_item.stringAttribute("filename").split("_")[0:2]
work_item.setStringAttrib("textureprefix", "_".join(texture_prefix))

在 File Pattern 后添加 Python Script。

这些变量主要是从资产命名中提取信息,用来构造纹理路径、资产名等,从而让我们能批处理整个文件夹(只要它们结构一致)。如果你不是用 GScatter,你可能需要改这段代码。

该节点也会生成一些额外属性,后面会用到。

记得选中 Python Script 节点按 SHIFT+V cook。

Tip

如果你之前在前一个节点选中了 work item,你需要在 Python Script 节点上重新选中一个 work item,否则 LOPs/SOPs 里无法读取新变量。

如果你想了解更多关于 agents 的内容,可以参考我关于 crowd 的文章。

有了 agent generator 之后,我们可以进入 Solaris 开始搭 USD 资产。目标是搭一个通用网络,用于批量处理大量植被模型。

Component Geometry(补齐 proxy)

现在回到 Component Geometry:我们之前在其中搭了 agent generator,它将作为高分辨率 render purpose 几何。但我们也需要一份 proxy。

Tip

在 USD 里,purpose 是一个特定术语:proxy、render、guide。Houdini 默认在 viewport 显示 proxy,而 Karma/渲染委托使用 render purpose。

proxy 几何应该输出到 Component Geometry 内的 proxy 输出节点。我这里通过 Alembic SOP 加载每个 alembic;File Name 参数同样使用 TOPs 变量来访问当前 work item 的文件。我这里用的是:

`@directory`/`@itemname`_lod2_OL.abc

Component Geometry:注意我加载的是 lod2 而不是 lod0。

接着需要设置一些 naming 属性,使几何在 LOPs 中有干净的命名。我创建了一个 Name 节点,把名字设为 @itemname(从 TOPs 取当前资产名)。

Name 节点设置。

此时所有 mesh 会同名,因此需要编号。我的做法与 agent_generate 里一致:用一个 prim wrangle:

s@name += "_" + itoa(@primnum);

它会给每个 primitive 的 name 追加 _ + primnum,从而与 agent naming 一致。

number_name wrangle。

最后我还进一步降了 proxy 的分辨率:先 Unpack(记得 transfer name 属性,并勾选 Convert Polygon Soup Primitives to Polygons),再用 Poly Reduce 把目标面数设到固定值。我这里用 1000,以让每个 proxy 都尽量轻。

Unpack 设置。

Poly Reduce 设置。

完成后,Component Geometry 应该能输出一个包含 proxy 几何与 agents 的 USD stage。

输出的 USD stage。

如果你想更“干净”,可以移除 agentdefinitions Scope。默认 SOP Import 会把 agent 当作 crowd 来处理,从而创建 agentdefinitions 并让实例继承它。要修正它,我们需要编辑 Component Geometry 节点内部的 base_geo 节点,把 Agents 从默认的 Create Instanced SkelRoots 改为 Create SkelRoots

在 Component Geometry 内改 agent 的处理方式。

这样你会得到更干净的 stage(没有 agentdefinitions):

清理后的 stage:注意 agentdefinitions 消失了。

Variants(变体)

下一步需要做 variants:现在所有不同植被都同时可见,但我们希望一次只显示一个,这样才能在 layout 中挑选具体植被。

我创建了一个 setup_variants 子网络并进入。

setup_variants 子网络。

在其中我搭了一个 for-each,如下图。目标是:对 /ASSET/geo/render/* scope 下的每个 primitive 创建一个 variant。它与我另一篇教程里 setup variants 的方式很类似。

setup_variants for-each loop。

foreach_end 中选择 For Each Primitive in First Input,target primitives 设为 /ASSET/geo/render/*,就能对 render scope 下每个 primitive 迭代(也就是每个 agent)。

foreach_end 设置。

接着在 variantblock_end(通过 TAB 菜单 “Add Variants to Existing Primitive” 创建)中:先禁用 Create Options block 并删除 variantblock_begin。然后把 Primitive Path 设为 /ASSET,因为我们要把 variant set 挂在顶层 prim 上,便于选择。

我把 variant 名称设为:

Variant_`@ITERATION`

其中 @ITERATION 是 for-each loop 生成的 context option,表示当前迭代编号。

Add Variant 节点设置。

最后使用 Prune 节点隔离当前 variant 需要的 prim:Method 设为 Deactivate,并添加两条 Target Rules(使它同时对 proxy 与 render 生效)。完成后,你就能在 USD stage 顶层 prim 上切换 variant。

Prune 参数。

切换 variant。

纹理 mipmapping(Mipmapping textures)

在做 shader 前,我先对纹理做一个预处理:mipmapping。通常你从网上下载的纹理不会自带 mipmaps;所谓 mipmapped,就是同一张纹理在同一个文件里存了多级不同分辨率。支持 mipmapping 的渲染器会根据相机距离与渲染分辨率自动选择合适级别的纹理,从而降低显存/内存占用并加速渲染。

很多渲染器会在渲染时自动做 mipmapping(所以你可能会在资产目录看到 .rat 文件,这是 Houdini/Karma 自动生成的),但为了减少渲染时的开销,我建议提前预处理,让纹理在渲染前就是 mipmapped。

TOPs 中用于 mipmapping 的网络。

我在 TOPs 里追加了 4 个 Generic Generator,分别处理每个纹理类型。Generic Generator 本质是执行自定义命令行;这里使用 Houdini 自带的 imaketx 来生成 mipmapped 纹理。它也是 Karma 在检测到非 mipmapped 纹理时会自动运行的工具。

这几个节点的命令基本一样,只是纹理名不同。下面是我使用的命令(适用于 GScatter;若你使用其他资产,命名可能不同)。

Generic generator:imaketx 示例(Albedo)。

imaketx -v -m ocio --format EXR "`@directory`/`@textureprefix`_Albedo_8bit_4096ppm.png" "`@directory`/`@textureprefix`_Albedo_4096ppm.exr"

Albedo

imaketx -v -m ocio --format EXR "`@directory`/`@textureprefix`_NormalOpenGL_8bit_4096ppm.png" "`@directory`/`@textureprefix`_NormalOpenGL_4096ppm.exr"

Normal

imaketx -v -m ocio --format EXR "`@directory`/`@textureprefix`_Roughness_8bit_4096ppm.png" "`@directory`/`@textureprefix`_Roughness_4096ppm.exr"

Roughness

imaketx -v -m ocio --format EXR "`@directory`/`@textureprefix`_Translucency_8bit_4096ppm.png" "`@directory`/`@textureprefix`_Translucency_4096ppm.exr"

Translucency

这条命令的关键 flags:

  • imaketx:从输入纹理生成 mipmapped 纹理
  • -v:verbose(可选,便于 debug)
  • -m:颜色管理,这里用 OCIO(取决于你的色彩管理体系;我用的是 ACES/OCIO)
  • --format:输出格式。默认是 .rat,但我建议输出 .exr(通用性更强)
  • path1:输入纹理路径
  • path2:输出纹理路径
Tip

@textureprefix 来自我们前面 TOPs 的 python script。

把 4 个节点串起来之后,选中最后一个节点(例如 maketx_translucency)按 SHIFT+V cook,就能在资产目录生成 mipmapped 的 EXR 纹理供 shader 使用。

Shaders(材质)

纹理准备好后,我们开始做 shader。此时你可以使用前面 Component Builder recipe 生成的 materiallibrary

Component Builder recipe 创建的 Material Library。

目标是:做一个通用植被材质,根据当前 TOPs work item 切换纹理。

Material Library 内,我创建了一个 plant_mtlKarma Material Builder。材质网络很简单:以 MtlX Standard Surface 为主,把纹理接到对应输入,并做少量修改。

MtlX 网络。

首先要让 MtlX Image 能加载当前资产对应的纹理:在 Filename 里使用 TOPs 变量:

MtlX Image 示例。

对应的 filenames(适用于 GScatter,假设你已在上一步转换为 EXR):

`@directory`/`@textureprefix`_Albedo_4096ppm.exr
`@directory`/`@textureprefix`_Roughness_4096ppm.exr
`@directory`/`@textureprefix`_Translucency_4096ppm.exr
`@directory`/`@textureprefix`_NormalOpenGL_4096ppm.exr

这些 MtlX Image 会接到 MtlX Standard Surface 的对应输入。

接下来我做了两处小改动:

第一:我对 normal map 做了一个处理:取其中某个分量当 bump 用,而不是直接用 normal。原因是 GScatter 的 normal map 在某些区域存在奇怪 artifact(法线方向翻转),导致 SSS 有异常。这个算是一个快速且“临时”的修复。另外注意:MtlX Bump 的 Bump scale 我用得很小(0.00065)。

Normal map 修复。

第二:我创建了 MtlX Geometry Property 节点,属性名分别为 randHuerandSatrandGain,然后把它们接到 MtlX Color Correct(输入为 albedo)。目的:我希望每个植被实例能通过随机属性在色相/饱和度/增益上略微变化,从而增加 shading 的丰富度。默认都设为 0.5,并在每个属性后接 MtlX Remap,把输出 remap 到合理范围(也就是你允许的 hue/sat/gain 最小与最大值)。

随机化 hue/sat/gain 的 Geometry Property Value 设置。

最后,为了让 SSS 工作(我用 translucency 作为 SSS color),我把 MtlX Standard Surface 的 Subsurface 设为 0.25,并开启 Thin Walled。这样能得到叶片透光的效果。

Subsurface 设置(开启 Thin Walled)。

退出 material library 后,Component Material 应该会自动把材质绑到 /ASSET prim 上,你会看到类似下面的效果:

某个植被资产的 shaded 预览。

USD Export(导出 USD)

USD 网络准备好后,我们需要把资产写盘。导出前要调整 Component Output 节点的几个设置:

Component Output 节点。

Root Prim 设为以下表达式,确保 /ASSET prim 会重命名为当前资产名:

/`@itemname`

Caching -> Name 也设为以下表达式,以便输出文件正确命名:

`@itemname`

Caching -> Location 改成下面这段,让输出落在原资产目录的同级目录(当然你也可以改成任何你希望的路径):

`@outputdir`/assets/`chs("name")`/`chs("filename")`

最后开启 Variant Layers 并把 Variant Set 设为 model,确保导出包含我们设置的 variants。

回到 TOPs,我们只需要在网络末尾加一个 ROP Fetch,指向 Component Output 内的 ROP,如下图。这样就能对每个 work item 批量执行整个 LOP 网络并写盘。

ROP Fetch:批量导出 Component Output。

完成后,选中 ROP FetchSHIFT+V,就会执行整条 TOPs 链并导出规范组织的 USD:包含 payload、纹理、variants 等。

0:00 / 0:04 最终合成镜头。

接下来进入场景布局、局部植被抽取与 Vellum 模拟,以及最终的动画回写与渲染合成。

Layout Asset Gallery(布局资产库)

Tip

本文使用的是 Houdini 20.5。Layout Asset Gallery 在 Houdini 21 中改名为 Asset Catalog,如果你找不到对应面板请留意这一点。

首先要把导出的资产加入 Layout Asset Gallery(或 Houdini 21 的 Asset Catalog)。目的:让我们能以图库形式浏览所有处理后的资产,并能在场景中可视化选择与拖拽使用。

如果你不熟悉:Layout Asset Gallery 本质是一个资产目录数据库(存于服务器或本地),在环境制作里非常实用。它能让你快速查看大量资产,并以 drag-and-drop 方式放进场景。

Layout Asset Gallery 要求资产遵循 component builder 生成的 USD 文件结构。你可以在 SideFX 文档中看到细节:
https://www.sidefx.com/docs/houdini/ref/panes/assetgallery.html#asset_dirs

加入资产的方法很多,但既然我们已经在 TOPs 里,最简单的是使用 USD Add Assets to Gallery 节点。它允许你指定一个包含所有资产的根目录(例如 component builder 输出的 /assets),并把全部资产添加到一个 Layout Asset Gallery 数据库中。

USD Add Assets to Gallery 的节点与参数。

该节点会使用当前激活的 Asset Gallery 数据库。因此在执行之前,我们先创建一个新的数据库文件。

打开 Layout Asset Gallery(或 Asset Catalog):新建一个 pane,在 Solaris 子菜单中找到对应面板。然后点击小齿轮,选择 Create New Asset Database File 创建一个空数据库。

添加 Layout Asset Gallery 面板。

现在在 TOPs 里对 USD Add Assets to Gallery 按 SHIFT+V 执行后,你的数据库应该会被填充成下图这样,包含所有植被资产与 variants:

已填充的 Layout Asset Gallery。

创建 Layout(Creating the Layout)

在进入 layout 前,我为演示录制了一段 mocap 并把它绑定到 Electra,同时做了一个简单地面用于散布植被。如果你想在自己的测试里直接复用,我提供了一个包含如下设置的 hip 文件(地面 + mocap + 相机)。

我不会在本文里展开 mocap 的处理流程,你可以自行在文件里查看。

处理后的 mocap 与地面。

请注意:在继续之前,我把地面、角色、相机三者导出到一个名为 main.usd 的 USD 文件(见该 hip 的 /obj 中 export LOP 网络)。

在 LOPs 中做植被 Layout(Vegetation layout in LOPs)

现在我们开始最终镜头的植被 layout。我在 /obj 中新建了一个 LOP network 叫 layout。在真实制作中,你可能会分成多个 hip 文件;这里为了演示,我在一个场景里用多个 LOP networks 分离工作。

新的 Layout LOP Network。

首先,通过 sublayer 加载你在上一节导出的 main.usd(角色、相机、地面)。随后接一个 Layer Break:因为我们希望最终只导出 layout 本身,不要把 main.usd 的内容一起写到输出里。

Tip

如果你不熟 Layer Break:它本质是一个标记,告诉 Houdini 在导出最终 USD 时不包含其下方的内容。用于制作你希望后续叠加(composite)的 USD layers 很有用。

通过 sublayer 加载地面、角色与相机。

当 viewport 里 instancers 很多时会变重。为加速,我建议使用 “Populate Mask”:取消 Populate all primitives in viewport,这样你可以选择性加载场景部分。

或者你也可以禁用

Load all payloads in viewport

这样默认只显示 bounding boxes,直到你显式启用加载。

Populate mask。

创建模拟用的 mask(Creating mask for simulation)

Tip

本文中我用 “Agent” 来指代我们绑定好的植被资产。在 USD 中更准确可能叫 UsdSkel,但我觉得容易与 Skeleton primitive 混淆,所以这里统一称 Agent。

第一步是选出 point instancer 中哪些实例需要模拟。原因是:我们需要从 point instancer 中把对应的 agents 抽取出来,才能为它们叠加独立动画。

如果你想做全局动画(例如风吹树摆),也可以在 prototype 中预先创建不同动画版本的 Agent,然后在 instancer 层面选择;但本流程的目标是“只模拟必要的少量实例”。

为此,我先用 Modify Point Instances 在 point instancer 上添加一个 mask:标记出我们要模拟的植被实例。注意:这个节点在交互上可能偏慢。

Modify Point Instances 参数。

Modify Point Instances 中,我把想要处理的 point instances 加进去。记得在每个 prim path 后加 [*] 来选择该 instancer 下所有 instances。我还添加了一个名为 characterMask 的 property,确保 mask 能被导出。它的类型为 int,因为我们只需要 0/1。

Modify Point Instances 内部的 SOP context,我用了 USD Character Import 从 LOPs 把角色动画导入 SOPs。LOP Path 与 Primitive Path 取决于你自己的 scene 结构。

USD Character Import 参数(1/2)。

USD Character Import 参数(2/2)。

接着把 USD Character Import 的 3 个输出直接接到 Joint Deform,把动画应用到 skinned mesh。

然后我接了一个 Trail(设为 Connect as Mesh,Trail length = 100),再把它接到一个 Time Shift(设到 1100,也就是我们的 end frame)。这样会得到一个“轨迹网格”,用于查看角色在整个镜头范围内会经过哪里。若想更快,可增大 Trail Increment

角色动画:Trail + Timeshift 的结果。

最后我接了一个 Peak 把它稍微膨胀,再用 VDB From Polygons 做一个体积“blob”。然后把它作为 Group Create 的第二输入,用来创建一个 bounding volume,基于它来选择会与角色发生接触的植被实例。

Peak + VDB From Polygons。

我把结果接到 Group CreateKeep In Bounding Regions - Bounding Volume),从而得到一个点组:包含镜头中角色穿过区域内的植被实例。

characterMask。

最后用 Group Promote 把 group 提升为 point attribute(勾选 Output as Integer Attribute),这样在 LOPs 中更容易使用。

用 Group Promote 把 Group 输出成整型属性。

回到 LOPs,你现在应该能看到 characterMask 属性了。

characterMask。

抽取植被用于模拟(Extracting vegetation for simulation)

现在我们要把需要模拟的植被从 point instancer 中抽出来。核心目标是:把这些被标记的 agents 从 point instancer 中提取出来,用它们的骨骼做模拟,然后把动画叠回去。

最简单的方法是用 LOPs 的 Extract Instances。在里面我们可以用表达式,仅抽取 characterMask==1 的 instances:

/world/environment/instancers/fern_instancer[{i@characterMask==1}]

Extract Instances 参数。

抽取 instances 后的 viewport 效果。

同时我们要给抽取出的 instances 指定输出 prim path。我这里放在 /world/environment/simPlants

抽取之后,还需要把这些 instances 从原始 point instancer 中删除,以避免重复几何。做法是再放一个 Modify Point Instances,并在 Point Instances 参数里使用与 Extract Instances 相同的 prim pattern 表达式。

用于删除抽取 instances 的 Modify Point Instances 参数。

Modify Using (Global) 设为 Uniform Values,关闭 Transform,并把 Prune -> Method 设为 Delete,即可删除选中的 instances。

最后,因为我们需要为每个 agent 修改它的 SkelAnimation primitive(用于承载动画),所以必须把抽取的 prims 取消实例化(un-instance)。

Tip

USD 中的 instanced primitives 不允许你修改其子 prim。这就是为什么必须 uninstance。

做法是用 Configure Primitive,目标设为抽取出来的 prims,把 Instanceable 设为 Not Instanceable。你会看到 viewport 中 prim 的颜色变为黄/橙色。

Configure Primitive 参数。

取消实例化后的 extracted instances。

我在最后加了一个 null IN_STAGE,方便后面在 SOPs 里引用(如上图)。

把植被 Agents 导入 SOPs(Importing Vegetation Agents into SOPs)

现在我们要把植被 agents 拉到 SOPs 里,这样才能用 Vellum 对它们的 skeleton 做模拟。

在 LOP network 里创建一个空的 SOP Network

接着把 agents 从 LOPs 抽到 SOPs。这一步不算优雅,因为 USD Character Import 一次只能导入一个 character,所以我需要用 For-Each loop。

先放一个 LOP ImportLOP Path 指向 IN_STAGEPrimitives 里填入抽取出来的植被路径(可用 pattern,注意 * 通配符)。我还把 Purpose 设为 renderTime Sample 设为 Static,并把 Display As 设为 Point Cloud 来减轻负担。

LOP Import 设置。

然后接一个 For Each Primitive 并把 LOP Import 接进去。

在 loop 内放一个 USD Character Import:它能把 LOPs 的 UsdSkel 数据导出为 SOPs 可用的数据(骨骼、skin 甚至动画)。

这里我把 LOP Path 设为与 LOP Import 相同,Purpose 设为 render,并把 SkelRoot Primitive Path 设为:

`prims("../foreach_begin1", 0, "path")`

它会读取 For-Each 当前 primitive 的 path,从而导入当前“角色”(也就是植被 agent)。

USD Character Import 设置。

我还把 USD Character Import

Timing

tab 设为

By Frame

并把

Frame

锁定到 1001,因为这里我们不需要从 LOPs 读取动画。

为了更方便使用,我用 Character PackUSD Character Import 的结果打包。这样每个 character 会得到 3 个 packed primitives:skin、deformed pose、rest pose(对应 3 个输入)。

Character Pack。

在接入 For Each End 之前,我加了一个 Wrangle:目的是从 LOPs 里提取一些信息,尤其是一个唯一标识每个植被的 agentname。它从 path 属性提取,因此 Wrangle 的第二输入连接 For Each Begin(因为我需要读取 LOP Import 生成的 prim attributes)。

代码如下:

string path[] = re_split("/", prim(1, "path", 0));

s@agentname = path[-4];

我用

re_split

把 path 切成列表,再提取 agentname。

抽取后的一个 character 示例:包含 rest skin/pose 与 layout 中的 pose/position。

Vellum Simulation(Vellum 模拟)

终于开始用 Vellum 模拟植被 skeleton。我先声明:我并不是 Vellum 专家,模拟还有很多可以改进的地方,但希望这能提供一个工作流概览。

首先我们要用 Character Unpack(在 For-Each loop 之后)把植被“characters”解包,取出它们的“animated pose”(也就是植被 skeleton 在 layout 中的位置),并基于它做模拟。效果类似下图:

植被的 animated poses。

在继续之前,我也搭了碰撞体:地面几何与 mocap 角色。

加载角色:再放一个 USD Character Import,按下图设置导入角色。

USD Character Import。

并在 Timing tab 做一点调整以匹配正确的帧范围:

USD Character Import(Timing)。

然后把 USD Character Import 的结果接到 Joint Deform,得到变形后的 skinned mesh:

Joint Deform。

我还给角色做了一点 preroll(在 1001 前让她“掉进”植被里),以避免她一开始就与植被相交:

简单 preroll 动画。

接着加载地面:这部分更简单。我用 LOP Import 指向地面 prim,用 Unpack USD To Polygons 解包,并加了 Poly Reduce 以显著降低地面面数(原 mesh 很重)。

导入地面并 poly reduce。

最后我把角色与地面 merge,并用 File Cache 缓存碰撞体:

合并并缓存碰撞体。

回到 skeleton:我们需要先创建一个 pin group,用于在 Vellum 中把植被根部固定在地面上,防止被撞飞。

我加载地面(用碰撞体的 full res 地面,从 Unpack USD to Polygons 直接抓),挤出 0.02。然后把它接到 Group Create 的第二输入,Group Name 设为 pin,类型为 points,Bounding Type 设为 Bounding Object。这样会创建一个 pin group:包含“在地面内部”的点。挤出一点是为了覆盖更多点,因为不是所有植被都深插地面。

创建 pin group。

接下来设置 Vellum constraints:我从 Vellum Configure Hair preset 起步,因为这个 constraint 类型会更新 joint orientations,有利于后续用骨骼驱动变形。

调参需要试错,并且强依赖几何分辨率与尺度。我会把我用过的设置截图放出来,但请注意你需要根据自己的输入调整。

最关键参数是:Define Pieces 设为 From Attribute,并把 Pin Points 设为 pin(我们刚创建的 pin group)。

Vellum Constraint 参数(1/2)。

Vellum Constraint 参数(2/2)。

之后把 Vellum Constraint 的输出接到 Vellum Solver:同时把第一、第二输出分别接到 Vellum Solver 的第一、第二输入;把碰撞体接到第三输入。

网络中的 Vellum Solver。

Solver 参数方面我改得不多:Solver tab 的 Substeps 设为 24(根据 skeleton 可能需要更多),并关闭 Self Collisions(我的镜头中不需要)。

Vellum Solver:Solver tab。

Forces tab 中我把 Gravity 设为 0,避免植被自然下垂。你也可以通过更精细的 constraint 设置来避免下垂,但我这里用这个方式最省事。

Vellum Solver:Forces tab。

最后把 Start Frame 设为 900,让植被在 1001 开始前有时间 settle。可能不需要 100 帧那么长,但在我这里很稳定。

接下来你会注意到一个问题:Vellum Solver 的输出点数发生了变化,因为 Vellum 会拆分 skeleton 的连接以进行求解。幸运的是,它会通过 weldbranchweld 属性记录哪些点需要焊回去。

因此在 Vellum Solver 后面接一个 Vellum Post-Process,勾选 Apply Welds,即可把点焊回去,恢复原 skeleton 的几何拓扑,同时保留 Vellum 模拟的运动。

Vellum Post-Process。

Vellum Post-Process 后我接了 File Cache 并缓存模拟:

缓存后的模拟。

把 Vellum 模拟挂回 Agents(Attaching Vellum Simulation to Agents)

模拟缓存好后,我们要把动画 skeleton 挂回植被 Agents,并把动画传回 LOPs。

整体 SOP 侧流程:对每个植被 agent,生成 agent(骨骼+几何),把模拟后的 skeleton pose 应用到该 agent,并写好 usdpathattribute 让 Solaris 知道应该覆盖到 USD 的哪里(且只覆盖 SkelAnimation)。

首先在 SOPs 中:我们需要一个 For-Each loop,因为要为每个植被实例应用不同动画。

创建 For-Each Named Primitive,接到缓存的模拟上,把 Piece Attribute 设为 agentname。这样 loop 会遍历 layout 中每个植被实例(我们之前创建了唯一 agentname)。

For-Each End 参数。

我们还需要 2 个 Block BeginFetch Input),用来分别获取每个植被的 rest geometry 与 capture pose。我在场景中对它们命名并连接到前面 Character Unpack 的对应输出。并把它们的 fetch 路径指向 foreach_end4(你的节点名可能不同)。如果 fetch 成功,你会看到这些 Block Begin 节点出现橙色边框。

Fetch Input 的 Block Begin 节点。

现在这些 fetch 输入会拿到所有 rest geo / capture pose,但我们需要在每次迭代中只保留当前 agent 的部分。为此,先在原始 For-Each Begin 上创建 Meta Import NodeCreate Meta Import Node)。

创建 Meta Import Node。

然后用两个 Blast 节点(两者参数一致)分别隔离 rest geo 与 capture pose。给每个 Blast 添加 spare input,并把 foreach_begin_metadata 拖到 Spare Input 0。将 Blast 设为 Delete Non Selected,Group 设为:

@agentname=`details(-1, "value")`

这样就能根据当前迭代的 agentname,隔离对应数据。

在 For Each 中隔离 rest geo 与 capture pose。

现在把这些数据转换成 agents:使用 Agent From Rig,把 Agent Name 设为读取输入几何的 agentname 属性,从而与 layout 里保持一致:

`prims(0, 0, "agentname")`

Agent From Rig 参数。

我还把 Point GroupsPoint Attributes 设为 *,确保传递所有组与属性,并关闭 Locomotion Joint

Houdini 21.0.440 的 Agent From Rig 有一个 bug:只要有东西连到它就可能崩溃。我已向 SideFX 提交 bug。写本文时它在 21.0.500 daily build 中已正常,请尽量使用更新的 daily build。

接着把几何挂到 agent:用 Agent Layer,参数如下。layer 名字我用 geom。第一输入是 agent(Agent From Rig),第二是 rest geo,第三是 capture pose(skeleton)。

Agent Layer 参数。

最后应用模拟后的 skeleton:用 Agent Pose from Rig。第一输入接 Agent Layer 输出,第二输入接当前迭代的 animated skeleton(foreach_begin)。这样就会把模拟动画应用到 agent,你能在 viewport 中看到植被运动。

Agent Pose from Rig 参数。

到这里你已经可以把结果接到 foreach_end 并用 SOP Import 导入 Solaris,得到一个可用的“植被 crowd”。但为了维持 USD 的层级与继承,我们希望把它输出成 SkelAnimation 并叠加到已有 SkelRoots 上。这样导出的数据非常轻量,远比烘焙变形几何小。

为了做到这一点,我们需要更新 agents 的 path 属性。用一个运行在 Primitives 的 wrangle:第一输入接 Agent Pose from Rig 输出,第二输入接原始导入的 animated skeleton(foreach_begin)。代码如下:

string usdanimpath = prim(1, "usdanimpath", 0);

s@path = re_replace("/animation", "", usdanimpath);

它从原始 skeleton 上读 usdanimpath,并去掉 /animation,使 path 指向 SkelRoot。这样 SOP Import 才能把动画覆盖到正确 prim。

Primitive Wrangle。

完成后把 wrangle 输出接到 foreach_end,你应该能看到整套 layout 以 agents 方式显示。我在末尾加了一个 null OUT_AGENTS 方便引用。

最终植被 crowd。

在 Solaris 中导入 Agents(Importing Agents in Solaris)

现在把 agents 导回 Solaris,以把新动画叠加回现有 UsdSkel Agents。

IN_STAGE 之后放一个 SOP Import,主要修改如下:

  • SOP Path 指向我们刚创建的 OUT_AGENTS null
  • Other Primitives 设为 Overlay(我们只想覆盖已有 prim,而不是创建新的)
  • Agents 设为 Create SkelAnimations(只导入动画数据,叠加到已有 UsdSkel)
  • Path Attributes 设为 path(读取我们在 SOPs 写的 path)

我还把 Sublayer Style 设为 COPY SOP Layer Into New Active Layer:这属于一个小的 housekeeping,让这些修改进入一个新的 Active Layer。实际 production 中我强烈建议把“动画覆盖层”单独输出为一个轻量 USD,这样你每次只需要更新这一个 USD(除非新增模拟植被实例)。

SOP Import 参数。

接上后,你应该能看到每个植被下的 SkelAnimation prim 出现 time-sampled 的 rotations/scales/translations。并且当你开始渲染时(我们只给 render purpose prim 做动画),植被会随动画运动。

SkelAnimation:应用了 Vellum 动画。

Karma 渲染:带动画的 UsdSkel Agents。

现在你可以加一个 USD ROP 导出:得到一个干净的 USD layer,可在 lighting scene 中通过 Sublayer 叠加。注意:因为我们前面用了 layer break,这个导出只包含我们新的 simulated vegetation layer。

USD ROP 参数。

作为一个额外说明:如果我们单独导出这个 SOP Import 的输出,你会看到它只包含新增的动画覆盖——不包含原始 prim 的其他数据。因此它非常轻量,适合频繁迭代模拟。

SOP Import 的输出 stage:只包含新增动画覆盖。

灯光与渲染(Lighting & Rendering)

导出后我们进入 lighting。由于 shading 已经在资产内部,我们基本只需要加灯光即可完成镜头。前面流程的准备工作让 lighting scene 非常简洁,因此本节也会相对短。

我在一个新的 LOP network(叫 render)中加载 main.usd(包含 layout+动画)。真实制作中这通常会是单独的 lighting hip。

main.usd Sublayer。

紧接着再 sublayer 载入 fx.usd(包含我们对 instancers 的 override:删除抽取实例,以及对植被 agents 的动画覆盖)。

fx.usd Sublayer。

灯光部分非常简单:一个 dome light,使用来自 https://polyhaven.com 的晴天 HDRI。

我还在 SOP Create 里做了一个 gobo mesh,让太阳光能透过“洞”投射阴影,营造密林环境的感觉。我大致是用一个 grid,经由 Attribute Noise + Blast 做出孔洞。

Viewport 中的进行中渲染。

Motion blur 方面基本不需要额外配置,因为植被是 agents:它们与角色 agents 一样,会自动获得 deformation motion blur。

最后我在 Nuke 里做了一点 grading 得到最终色调,但这部分留到未来文章再讲。

最终合成镜头。

结语(Conclusion)

到这里这篇“巨型教程”就结束了。感谢所有读到这里的人!如果你用这套流程做出了任何东西,我很期待看到你的成果。你可以在评论区分享,或通过邮件/LinkedIn 联系作者。

如果你觉得有帮助,也欢迎订阅作者的 newsletter,后续文章会直接发到你的邮箱。

本文采用 Creative Commons BY-NC-ND 4.0 协议进行授权。

BY-NC-ND: 署名-非商业性使用-禁止演绎

End of Article