BACK TO ARCHIVE
houdini solaris usd procedural modeling pipeline dcc mechanics

在 Houdini 用 SOPs + Solaris 做程序化浮冰(Drift Ice):几何、细节、变体与 USD 组织

12.19.2023 ADUCG RESEARCH

最近我一直在慢慢推进一个更大的个人项目。在这个过程中,我需要制作大量浮冰来填充一片北极海洋。我觉得在制作过程中用到的一些工具和方法可能会对不少人有帮助,所以我决定写一篇文章,详细描述其中一些技术。我尽量覆盖了下图场景中使用的大多数技术:从 SOPs 中的几何生成,到 LOPs/Solaris 里的着色与组装,并配合一些简单示例与 GIF。希望你读得开心,也能学到些有用的东西!

几何生成(Geometry Generation)

使用 RBD Material Fracture 生成基础形体(Generating Base Shapes with RBD Material Fracture)

为程序化建模找到一个好的基础形体至关重要。计算机科学里有一句很经典的话:“Garbage in, garbage out(输入是垃圾,输出也只会是垃圾)”。这在程序化建模里同样成立。

在这个设置里,我在一个简单的 box 几何上使用了 RBD Material Fracture 节点来创建基础形体。这个节点通常用于刚体模拟中的破碎效果,但它对程序化建模同样非常好用。

过去要做出类似的破碎几何往往需要相当复杂的网络(尤其是这个节点里一些更高级的功能),但现在只用这一个节点就能得到很棒的结果。

在这个例子里,我只是用了该节点的 concrete preset,稍微调整了生成参数,然后在 Detail 标签页里打开了 Interior DetailEdge Detail

RBD Material Fracture 的 exploded view。

💡

RBD Material Fracture 节点虽然非常强大,但它的功能也相对“臃肿”。如果你需要一个更快的网络,有时用 Voronoi fracture 或类似方式搭一个更精简的 setup 反而更合适。

接下来,为了把几何分成冰与雪两部分,我使用了 Boolean Fracture 节点。

Boolean Fracture 也是一个 RBD 节点。它有两个输入:你要破碎的几何,以及“切割面(cutting surface)”。这里我只用了一张简单的 grid,但你也可以发挥想象力,用它生成更复杂的破碎结构。回头看,我在这里也可以用普通的 boolean 节点;不过 RBD Boolean Fracture 会自动生成一个 name 属性,这在下一步会很有用。

Boolean Fracture 的 exploded view。

使用 Attribute Noise 添加细节(Adding Details using Attribute Noise)

此时我基于 name 属性用 Split 节点把两部分(冰与雪)分开,方便分别做细节。

对两侧几何,我都会先把它们转为 SDF(surface VDB):用 VDB From Polygons 节点生成 VDB,然后再用 Convert VDB 节点转回 polygon。这是一种便宜又有效的方式,可以让面片分布更均匀。当你要添加噪声等程序化细节时,这会非常有帮助。

噪声乘 mask 的 nodegraph。我还把噪声输出拆成 X、Y、Z 三个分量,因为我希望能分别控制每个轴向。

从这一步开始,我基本就是用 Attribute Noise 节点不断叠加不同类型的噪声。对冰来说有一个比较独特的设置:我把 Element Scale 的 y 分量相对其他分量设得更大。这是为了得到你在下图里看到的那种“条带感”(它和我用作参考的冰的结构很像)。

噪声乘 mask 的 nodegraph。我还把噪声输出拆成 X、Y、Z 三个分量,因为我希望能分别控制每个轴向。

对雪来说,我想模拟一种我在参考图里观察到的效果:雪会在浮冰边缘附近堆积起来。

为了实现它,我使用了 SideFX Labs 工具集(https://www.sidefx.com/products/sidefx-labs/)里的一个很方便的节点:Measure Curvature。这个节点可以生成多种与曲率相关的属性。然后,为了让 mask 只出现在几何的上方区域,我用 VEX 根据 @N.y 做了一个限制,代码如下:

float curvature = f@convexity * chramp("Remap", @N.y);

@mask = curvature;

这段代码的核心就是:把 Measure Curvature 节点生成的 convexity 属性与一个基于法线 Y 分量的 ramp 相乘。换句话说,它确保 mask 只会作用在法线朝向 Y 轴正方向(朝上)的点上。

噪声乘 mask 的 nodegraph。我还把噪声输出拆成 X、Y、Z 三个分量,因为我希望能分别控制每个轴向。

最后,我在一个 point VOP 里基于这个 mask 添加了一些噪声。由于我们想要比较激进的塑形效果,所以 amplitude 设得很高。

噪声乘 mask 的 nodegraph。我还把噪声输出拆成 X、Y、Z 三个分量,因为我希望能分别控制每个轴向。

噪声乘 mask 的 nodegraph。我还把噪声输出拆成 X、Y、Z 三个分量,因为我希望能分别控制每个轴向。

在这一步之后,我只是继续叠加更多噪声层,subdivide 一次,然后又做了一次“VDB From Polygons → Convert VDB”的往返。这样做主要是为了清理一些由稍微极端的噪声层造成的网格瑕疵。

通过散布添加额外细节(Adding additional details using scattering)

在现有网格上散布几何,是添加复杂细节的一种非常有效的方式。这里我想模拟一点“雪块结团(snow clumping)”的感觉,于是我散布了一些我自己制作的简单雪团。我最终也把它们用于增加整体布局的复杂度。

这些小雪团的网络与上面生成冰与雪的方式非常类似:我创建一个 box,用 RBD Material Fracture 破碎,然后在一个 for each block 里对每一块加噪声并移动到原点。最后,我把它们 cache 出来,以免每次都重复计算。最关键的一点是要给每一块分配一个 name 属性;我在这里用 Connectivity 节点来完成。

雪团生成器网络。

接着,我把这套网络的输出接到 Copy to Points 节点,并基于由 Scatter 节点生成的点,把这些雪团散布到浮冰上;Scatter 的 density 则由一个 mask 来驱动。

mask 的生成方式如下(与前面分享的片段类似)。它的作用只是确保点大多落在网格顶部,即法线 Y 分量为正的区域。

f@mask = chramp("Remap", @N.y);

我不打算在这篇文章里过多展开散布细节,但我使用了 Attributes from Pieces 与 Scatter Align 的组合来做朝向、pscale,以及随机把不同 piece 分配给不同点等工作。

散布网络。

现在,把这套散布 setup 与我们的冰、雪网格合并后,就能得到类似下面的模型!

最终网格(至少其中一个版本)。

为快速变体设置 For-Each 循环(Setting up For-Each loop for fast variants)

在进入 USD/Solaris 之前的最后一步,是搭建一个 For-Each 循环,让我们能用同一套节点树生成多个变体。

具体做法是在最初的 RBD Material Fracture 之后添加一个 For-Each Named Primitive block,并把所有细节生成都放在 For-Each block 的 begin 与 end 节点之间。

网络的简化概览。

通过这种方式,你就能为每一个破碎出来的 piece 生成一个完整模型。你甚至可以把图中某些区域放进 Compile-Block 来实现多线程加速。

如果你想把每个模型居中,可以在 For-Each block 的开头加一个 transform 节点,把 translate x、y、z 设为负的 centroid。这样可以把网格中心放到原点。

Translate X: -centroid(0, D_X)
Translate Y: -centroid(0, D_Y)
Translate Z: -centroid(0, D_Z)

把每个 piece 的网格中心放到原点。

另一个很棒的做法是:为每个 piece 改变 noise offset。你可以利用 Meta Import 节点生成的 iteration detail attribute 来实现。要添加 Meta Import 节点,请在 For-Each Begin 节点里点击这个按钮:

通过 SOP Import 加载几何。

这会在你的网络里创建一个新节点,你可以引用它来访问当前迭代编号等信息。例如,你可以像下面这样把它用于 Noise Pattern Offset:

通过 SOP Import 加载几何。

detail("../foreach_begin1_metadata1/", "iteration", 0)

快速技巧:在 VEX 中做立方体 UV 投影(Cubic UV Projection)

在我们彻底离开 SOPs 之前,我想分享一段我用来为这些模型生成 UV 的脚本。我希望用程序化方式生成 UV,这样我就能制作无限多的模型变体,而不需要担心手工展开 UV。

程序化生成 UV 往往比较棘手,但我在这个项目中找到了一个很棒的 VEX 片段,感谢 Konstantin Magnus。它会生成一种 Cubic UV Projection,在我的案例里非常好用。他在这里写过一篇文章介绍它:https://procegen.konstantinmagnus.de/cubic-uv-projection-in-vex

为 Solaris 准备几何(Prepping geometry for Solaris)

从 SOPs 设置正确的 Name 属性并创建 proxy(Setting up proper Name Attributes from SOPs and creating proxies)

我知道我说过我们要结束 SOPs 了,但我们还需要做最后一件事——它与 USD 以及 LOPs/Solaris 的关系非常紧密。

如果你曾经把 Maya 的内容导入 Houdini,你可能知道:每一块几何在 Outliner/Scenegraph 中的路径会以字符串形式存储在 path 属性里。比如你在 Maya 里创建了一个名为 “box_geo” 的 object,并把它放进一个名为 “box_grp” 的 group 里,导出到 Houdini 后,它会带有一个 path 属性,值为 “/box_grp/box_geo”。

由于 USD(以及 Solaris)同样是基于层级结构的场景描述系统,因此它也有一个类似的机制:当你把几何从 SOPs 导入到 LOPs 时,name 属性里存储的值可以用来告诉 USD:应该把几何放到 scenegraph 的哪个位置。

因此,对于 For-Each 循环里的每一个几何,我添加了两个 Name 节点:一个用于全分辨率的渲染几何,一个用于 proxy 几何(下一节会讲到——别担心!)。我同样利用 For-Each metadata 节点提供的 iteration detail attribute 来确保每次迭代都有独一无二的标识符。

渲染几何的 name 属性是这样的:iceSlab_grp/iceSlab_#/render/ice_geo

proxy 几何的 name 属性是这样的:iceSlab_grp/iceSlab_#/proxy/ice_geo

通过 SOP Import 加载几何。

最后,渲染几何与 proxy 几何本质上是同一份几何,只是 proxy 版本通过一个比较激进的 polyreduce 降低了面数。

前言:USD Purposes(Preface: USD Purposes)

那么我们要用这份低分辨率几何做什么?proxy 几何会作为你在 viewport 做 layout 等工作时的预览版本。这样一来,除非你要做最终渲染,否则你不需要加载沉重的高分辨率几何——工作效率会明显提升。

这种做法在制作流程里非常常见,而 USD 甚至为这种工作方式提供了一个内置特性:USD Purposes。

如果稍微“抠字眼”一点,USD Purposes 是 UsdGeomImageable schema 上提供的一个内置属性。但你真正需要知道的是:它是 USD/Solaris 中你会经常用到的一个特性,对几乎所有常见几何类型都适用。

它是一个包含 4 种设置的小属性。从技术上讲,DCC 可以按自己的方式解释它,因为它只是 USD 提供的一个 hint,告诉应用程序如何理解这个 primitive。但一般来说,它是这样工作的:

  • default = 没有特殊 purpose,primitive 在所有模式下可见
  • render = primitive 只在最终渲染中可见
  • proxy = primitive 只在 viewport 中可见
  • guide = 为只想显示 guide 的应用提供的特殊 purpose(我个人还没用过)

💡

这一节里你会看到我不时提到 primitive。如果你还不太熟悉 USD:primitive 基本上就是 Solaris scenegraph 里你能看到的一切元素。
几何、组、材质、渲染设置……都属于不同类型的 primitive。
它们是 USD 的构建块(building blocks)。

把几何从 SOPs 加载到 LOPs,并设置 Purposes 与 Variants(Loading Geometry from SOPs into LOPs and setting up Purposes and Variants)

首先,为了把几何加载进 LOPs(Solaris),我们打开 /stage context,并添加一个 SOP Import 节点。这个节点非常直接,只需要指定一个 SOP path,就能把几何直接导入到 LOPs。为了更好地利用 name 属性,我还建议你打开 “Import Path Prefix”,并把它设为 /。

通过 SOP Import 加载几何。

完成这一步后我们会遇到一个问题:我们希望一次只使用一块浮冰,但现在它们全都同时可见。USD 有一个非常棒的功能可以解决这个问题:Variants。它在我们进入 layout 阶段时会变得更加重要。

Variants 允许我们为一个对象提供多个不同变体。比如可以是不同着色版本,也可以是完全不同的几何。在我们的例子里,我们只是想在不同浮冰变体之间切换。

我们当然可以手工完成,但因为数量很多,我更希望用 For-Each loop 自动生成它们。

由于我们是在修改已有 primitives,因此需要把 scenegraph 同时接到 For-Each end 节点与 For-Each begin 节点的第一个输入端。

接下来要创建 variants,我们需要创建一个 Add Variant 节点(在 tab 菜单里选择 Add Variant to New Primitive)。把 For-Each begin 节点接到 Add Variant 的第一个输入端,然后像下图那样,在第二个输入端接一个 Isolate 节点,并把 prim pattern 设为 `@ITERATIONVALUE`

其中

\`@ITERATIONVALUE\`

指向的是当前循环迭代中 primitive 的路径。

着色网络

在 Add Variant 节点里,你只需要把 Primitive Path 设为顶层组(因为我们希望用户在顶层组上切换 variant),并把 Variant Name Default 设为 iceSlab_`@ITERATION`。这会成为每个 variant 的名字。

💡

@ITERATION 是我们在 For-Each loop 中可以使用的一个特殊属性,它表示当前迭代编号。在这里我们同时用它来选择要 isolate 的浮冰 piece,以及为 variant 命名。

着色网络

完成上述操作后,我们希望之前放进 /render/ 路径的几何只在渲染时启用,而 /proxy/ 路径的几何只在 viewport 中启用。

因为我们已经有一个 For-Each loop,所以只需要在 Isolate 节点下方添加一个 Configure Primitive,并分别对 proxy 与 render 几何进行设置即可。如何搭建可以参考下面的截图。

我每天都会用 Configure Primitive 节点,它非常实用。对我们这个用例来说,它内置了设置 Purpose 的选项,所以我们不需要写任何手工代码。

着色网络

着色网络

着色网络

完成这些后,我们只需要用 USD ROP 把 For-Each loop 的结果写出为一个 .usd 文件,就可以进入着色、布局与渲染阶段了!

你可以用一个 Set Variant 节点,选中你配置 variants 的那个 primitive,来测试新生成的 variants。

用 Karma 进行布局与渲染(Layout & Rendering with Karma)

在 Karma 中设置材质(Setting up materials in Karma)

这一节会短一些,因为这组镜头的渲染设置与着色相对简单。

model.usd 文件通过一个 Asset Reference 节点加载(也可以用普通的 Reference 节点),材质则通过 Material Library 来添加。

我会在之后的某篇文章里介绍我整体的材质工作流;这里的雪与冰材质基本就是用 Subsurface 与 Refraction 组合出来的,并且在冰的折射里加入了一点淡蓝色。我的材质是用 Classic Shader Core shader 搭建的,并叠加了不同层级的噪声来做整体表面变化。

着色网络

在 Karma 中做布局(Layout in Karma)

这组镜头的布局过程也非常简单:我做了一个简单的相机运动,用 SOP Create 里的一个 grid 做海面,并在 displacement 中加入一些噪声,同时配上一个完全反射与折射的材质。

💡

提示:让水看起来正确的一个关键技巧,是把 IOR 设为大约 1.33 作为起点。

随后我用 Copy to Points 节点来散布浮冰(它本质上就是一个带了一些 presets 的 Instancer 节点)。如果你进入 Copy to Points 节点内部,你会看到一个 SOP context:我在其中创建了一个大 grid,并在上面散布点。Instancer 会把 primitive 实例化并散布到这些点上。

💡

记得把 Instancer 节点的 Method 设为 “Point Instancer”。这是 USD 中性能最好的散布方式,缺点是相比常规 instance 灵活性略低。但在这里它是理想选择。

用于散布的 LOPs 网络

Instancer / Copy to Points 节点内部的 SOP 网络

Karma 中的灯光(Lighting in Karma)

这个场景的灯光非常简单:只有一张 HDRI。通常我会把 HDRI 里的太阳涂掉,然后加一盏自己的方向光,但在这个案例里,HDRI 原样就很合适。

我使用的 HDRI 来自 https://polyhaven.com

在渲染设置方面,我只是把 Reflection、Refraction 和 SS Quality 略微提高,并把 Primary Samples 设到大约 48。使用 CPU Engine 在一台 M2 Ultra 的 Mac Studio 上,每帧渲染时间大概是 30 分钟。

Karma 渲染设置

这是最终镜头:

加餐:在 Karma 中基于 primvars 创建自定义 AOV(Bonus: Creating custom AOVs based on primvars in Karma)

在结束之前,我想快速讲一下:如何在 Karma 里基于 Primvars 创建 AOV。这在很多需要更灵活合成的场景中都很有用。

你可以在 Karma Render Settings 中非常容易地完成:滚动到 Image Output 标签页底部,展开 Extra Render Vars 面板。

在这里你可以为 AOV 在 “Name” 参数里指定名字,并设置 Data Type 与 Primvar 的 Source Name。也别忘了把 Source Type 设为 Primvar。

如果你不熟悉 primvars,我推荐阅读 Luca Scheller 写的优秀 USD Survival Guide 相关章节:https://lucascheller.github.io/VFX-UsdSurvivalGuide/core/elements/property.html#attributePrimvars

简单来说,它是一个特殊的 attribute 命名空间,能够确保该 attribute 会被导出到渲染中。它很适合用来添加需要被着色器与 AOV 访问的属性。

如果你读到了这里,真的非常感谢!我计划未来写更多类似类型的文章。如果你希望看到某个主题,欢迎留言。

我也很想知道:你是否把其中某些技术用到了自己的项目里。

如果你希望在我发布更多文章时收到通知,欢迎在下方订阅!

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

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

End of Article