前言:美术制作中的“搬砖”痛点
在大型场景或关卡制作中,我们经常面临一个尴尬的局面:在 Maya 这种操作直观的软件里,我们凭借直觉和审美手动摆放了成百上千个建筑、植被或道具。然而,当需要将这些布局同步到 Houdini 进行高性能渲染(USD 流程)或进行后期程序化处理时,传统导出方式往往是一场灾难。
- FBX 导出:文件巨大、实例(Instancing)丢失,导入后改一个点就要卡半天。
- 手动重建:200 个建筑坐标,光是复制粘贴参数就能让人崩溃。
- 无法迭代:Maya 里布局一变,Houdini 端几乎要推倒重来。
最近我看了技术美术师 JonnhyRDG 的开源工作流(ajhoudini)。它的核心思想很朴素:不要搬模型,搬“数据”。
把 Maya 里“摆好的东西”提炼成一份结构化数据(位置/旋转/缩放/引用路径),在 Solaris 里用脚本按规则重建 USD 结构,这样就能做到高效、可复用、可迭代。
原文与代码:
总览:从“手工装配”到“数据驱动重建”
-
在 Maya 中导出场景布局为 XML
导出每个对象/组的变换矩阵、引用关系等信息。 -
将 XML 解析为更易用的 JSON 字典
清理命名空间、去掉无用信息,并把矩阵转换为 USD API 需要的格式。 -
在 Solaris 里按字典批量生成 Block USD(块级资产)
每个 Block 输出一个 USD 文件,内部是引用 + 实例化的组件。 -
在更上层生成 City USD(城市场景)
City 只负责引用各个 Block 并摆放,层次更清晰,迭代成本更低。
下面我按原作者的步骤做一次中文复盘,并把一些更“工程化”的要点抽出来。
1) 先把 Maya 里的装配关系讲清楚
作者的例子是一个城市 Set:由很多 Block(街区/建筑组) 组成,而一个 City 又由多个 Block 组成。


层级大概像这样:
City
|_Block01_A_01
|_Block01_A_02
|_building01_A_01
|_building01_A_02如果完全手工把这些东西在 Solaris 里重建一遍——哪怕只是把引用(reference)放对位置——工作量也会非常夸张。

2) Step 1:从 Maya 导出 XML(导出“布局数据”)
作者这里用了一个基础的思路:遍历 Maya scene 里的 transform,取 worldSpace 的 matrix,写入 XML。
下面这段是原文里给的示例(我整理了格式):
import xml.etree.ElementTree as ET
import maya.cmds as cmds
root = ET.Element("Scene")
objects = cmds.ls(type="transform")
for obj in objects:
matrix = cmds.xform(obj, q=True, matrix=True, ws=True)
obj_element = ET.SubElement(root, "Object")
obj_element.set("name", obj)
matrix_element = ET.SubElement(obj_element, "Matrix")
matrix_element.text = " ".join(str(v) for v in matrix)
tree = ET.ElementTree(root)
tree.write("output.xml")
print("XML exported successfully.")这里的重点不在于“XML”本身,而是:你导出的内容要能稳定复现装配关系。
如果你的生产里更偏向引用(nested reference)而不是纯 transform 层级,那么导出时最好把 refFile / assetId / namespace 等信息一并写入。
3) Step 2:XML → JSON 字典(让数据更“可脚本化”)
原文的第二步,是把 XML 转成更好用的字典(最终写成 JSON)。
在我看来它解决了两个关键问题:
- 清理命名空间与约定:把 Maya 里很“历史遗留”的命名切割掉,形成稳定 key。
- 矩阵格式转换:USD API 需要的矩阵格式跟 XML 里那串 16 个 float 的写法不一样。
例如 XML 里的 xform 可能是这样一行:
1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 1.0而 USD API 更希望你给类似这样的结构:
((1.0,0.0,0.0,0.0),(0.0,1.0,0.0,0.0),(0.0,0.0,1.0,0.0),(0.0,0.0,0.0,1.0))查看:原文中 XML → 字典的关键代码(已整理)
import xml.etree.ElementTree as ET
import json
xmlblock = ET.parse("P:/AndreJukebox/assets/sets/city/publish/xml/block_builder.xml")
xmlcity = ET.parse("P:/AndreJukebox/assets/sets/city/publish/xml/city_builder.xml")
blocksdict = {}
assetdict = {}
def assetListFromXML(xml):
root = xml.getroot()
for instanceList in root:
for instance in instanceList:
for childInstance in instance[2]:
groupIteration = f'{(int(childInstance.attrib[\"name\"].split(\":\")[0].rsplit(\"_\",1)[1])):04d}'
if xml == \"xmlblock\":
group = f'{childInstance.attrib[\"name\"].split(\":\")[0].rsplit(\"_\",1)[0]}_{groupIteration}'
else:
group = f'{childInstance.attrib[\"name\"].split(\":\")[0].rsplit(\"_\",1)[0]}'
groupXform = childInstance[1].attrib[\"value\"]
groupasset = f'{childInstance.attrib[\"name\"].split(\":\")[0].rsplit(\"_\",1)[0]}'
groupusd = f'P:/AndreJukebox/assets/sets/{groupasset}/publish/usd/{groupasset}.usd'
tr = groupXform.split(\" \")
tdict = {}
iter = 0
for i in tr:
iter += 1
tdict[iter] = i
group_matrix = (
f'( ({tdict[1]},{tdict[2]},{tdict[3]},{tdict[4]}),'
f'({tdict[5]},{tdict[6]},{tdict[7]},{tdict[8]}),'
f'({tdict[9]},{tdict[10]},{tdict[11]},{tdict[12]}),'
f'({tdict[13]},{tdict[14]},{tdict[15]},{tdict[16]}) )'
)
for assetGroups in childInstance[2]:
assetdict = {}
for assetUnit in assetGroups[2]:
assetname = assetUnit.attrib[\"name\"].split(\":\")
assetclean = assetname[2].rsplit(\"_\",1)[0]
assetpath = assetUnit.attrib[\"refFile\"]
usdassetpath = assetpath.replace(\".abc\", \".usd\").replace('publish/cache','publish/usd')
assetInstance = f'{assetclean}_{int(assetname[1].rsplit(\"_\",1)[1]):04d}'
xform = assetUnit[1].attrib[\"value\"]
tr = xform.split(\" \")
tdict = {}
iter = 0
for i in tr:
iter += 1
tdict[iter] = i
asset_matrix = (
f'( ({tdict[1]},{tdict[2]},{tdict[3]},{tdict[4]}),'
f'({tdict[5]},{tdict[6]},{tdict[7]},{tdict[8]}),'
f'({tdict[9]},{tdict[10]},{tdict[11]},{tdict[12]}),'
f'({tdict[13]},{tdict[14]},{tdict[15]},{tdict[16]}) )'
)
assetdict[assetInstance] = {\"xform\":asset_matrix, \"abcpath\":assetpath, \"usdpath\":usdassetpath}
blocksdict[group] = {\"xform\":group_matrix, \"usdpath\":groupusd, \"assets\":assetdict}
# 注意:原文里用 xml == xmlblock 这样的判断有点“草稿味”,实际你可能会把它写得更清晰。
assetListFromXML(xml=xmlblock)
assetListFromXML(xml=xmlcity)作者最后得到的是一个更干净的结构化数据:
key 是 Block/Asset 的稳定名字,value 里包含 xform、usdpath 等信息。这个形态一旦确立,后面所有“重建工作”都可以变成纯循环。
4) Step 3:在 Solaris 里批量生成 Block USD
这一步的目标是:每个 Block 生成一个 USD 文件,里面把每个 building(或道具)引用进来,并设置为 instanceable。
先引入 USD API,并拿到当前 Solaris 节点:
from pxr import Usd, UsdGeom, Gf
import hou
import json
node = hou.pwd()核心逻辑可以概括为:
- 为每个 Block 创建一个 Stage(
Usd.Stage.CreateNew()) - 在 Stage 里创建 prim(
DefinePrim(..., "Xform")) - 给 prim 添加 reference,并
SetInstanceable(True) - 写盘保存为
.usd
作者在文中用了一张图解释 DefinePrim 的结果:
以及 reference + instanceable 对应 UI 的关系:
new_prim.GetReferences().AddReference(assetusd)
new_prim.SetInstanceable(True)做 Pipeline 时,我更倾向把“引用路径”当成数据的一部分,而不是写死在脚本里。
这样当资产发布路径变更,你只需要改 JSON 生成规则,不需要动 Solaris 脚本。
5) Step 4:在更上层生成 City USD(让装配更稳定)
原文的 Step 4 本质上是 Step 3 的“上一级版本”:
不用再深入到 asset 层级,而是只处理 Block 层级,最终得到一个 city.usd,把所有 Block 引用进来。
查看:原文 Step 4 核心脚本片段(已整理)
from pxr import Usd, UsdGeom, Gf
import hou
import json
node = hou.pwd()
class blockbuild:
def __init__(self):
self.dictread()
def dictread(self):
self.citydict = open("P:/AndreJukebox/assets/sets/city/publish/xml/city_builder.json")
self.cityread = json.load(self.citydict)
def blockslist(self):
block_stage = "P:/AndreJukebox/assets/sets/city/publish/usd/city.usd"
self.stage = Usd.Stage.CreateNew(block_stage)
city_prim = self.stage.DefinePrim("/city", "Xform")
city_prim.SetInstanceable(True)
city_model = Usd.ModelAPI(city_prim)
city_model.SetKind("assembly")
for keys in self.cityread:
self.createRefs(blocks=keys, refsnum=len(self.cityread[keys]))
self.stage.GetRootLayer().Save()
def createRefs(self, blocks, refsnum):
# 这里会读取每个 block 的 xform / usdpath
# 并在 /city 下创建子 prim,AddReference + 设置变换
pass到这里,你就拥有了一个很清晰的装配层次:
- Block USD:组件层(building reference / instance)
- City USD:装配层(Block reference / instance)
这对后续做 Lookdev、渲染、甚至做程序化扩展(比如替换某一类建筑、按规则生成变体)都更友好。
6) 跨 DCC 的价值:让“共享”变成默认选项
作者最后展示了同一套场景可以在不同 DCC 之间共享的结果(Lookdev 在 Katana 完成,短期仍在用 lookfiles,长期希望直接用 USD 传递材质信息):

结语:这套方案为什么值得做
如果用一句话总结:把“场景装配”从一次性手工劳动,变成可再生的流程能力。
当你需要:
- 反复迭代关卡布局
- 在多个软件之间同步同一套装配结果
- 在 Solaris/USD 上做更高性能的引用与实例化
这种“数据桥梁”会非常省心。
真正麻烦的不是写脚本,而是把你们项目的命名、发布路径、引用关系这些“约定”整理成一个可靠的数据结构——一旦这步做对了,后面基本就只剩下循环。