BACK TO ARCHIVE
pipeline td framework usd interoperability maya houdini dcc mechanics

从 Maya 到 Houdini Solaris:基于数据驱动的场景自动化重建方案

04.26.2026 ADUCG RESEARCH

前言:美术制作中的“搬砖”痛点

在大型场景或关卡制作中,我们经常面临一个尴尬的局面:在 Maya 这种操作直观的软件里,我们凭借直觉和审美手动摆放了成百上千个建筑、植被或道具。然而,当需要将这些布局同步到 Houdini 进行高性能渲染(USD 流程)或进行后期程序化处理时,传统导出方式往往是一场灾难。

  • FBX 导出:文件巨大、实例(Instancing)丢失,导入后改一个点就要卡半天。
  • 手动重建:200 个建筑坐标,光是复制粘贴参数就能让人崩溃。
  • 无法迭代:Maya 里布局一变,Houdini 端几乎要推倒重来。

最近我看了技术美术师 JonnhyRDG 的开源工作流(ajhoudini)。它的核心思想很朴素:不要搬模型,搬“数据”
把 Maya 里“摆好的东西”提炼成一份结构化数据(位置/旋转/缩放/引用路径),在 Solaris 里用脚本按规则重建 USD 结构,这样就能做到高效、可复用、可迭代。

原文与代码:


总览:从“手工装配”到“数据驱动重建”

  1. 在 Maya 中导出场景布局为 XML
    导出每个对象/组的变换矩阵、引用关系等信息。

  2. 将 XML 解析为更易用的 JSON 字典
    清理命名空间、去掉无用信息,并把矩阵转换为 USD API 需要的格式。

  3. 在 Solaris 里按字典批量生成 Block USD(块级资产)
    每个 Block 输出一个 USD 文件,内部是引用 + 实例化的组件。

  4. 在更上层生成 City USD(城市场景)
    City 只负责引用各个 Block 并摆放,层次更清晰,迭代成本更低。

下面我按原作者的步骤做一次中文复盘,并把一些更“工程化”的要点抽出来。


1) 先把 Maya 里的装配关系讲清楚

作者的例子是一个城市 Set:由很多 Block(街区/建筑组) 组成,而一个 City 又由多个 Block 组成。

Blocks sample

City sample

层级大概像这样:

maya-hierarchy.txt
City
 |_Block01_A_01
 |_Block01_A_02
          |_building01_A_01
          |_building01_A_02

如果完全手工把这些东西在 Solaris 里重建一遍——哪怕只是把引用(reference)放对位置——工作量也会非常夸张。

Assembly graph


2) Step 1:从 Maya 导出 XML(导出“布局数据”)

作者这里用了一个基础的思路:遍历 Maya scene 里的 transform,取 worldSpace 的 matrix,写入 XML。
下面这段是原文里给的示例(我整理了格式):

maya_export_xml.py
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.")
Note

这里的重点不在于“XML”本身,而是:你导出的内容要能稳定复现装配关系。
如果你的生产里更偏向引用(nested reference)而不是纯 transform 层级,那么导出时最好把 refFile / assetId / namespace 等信息一并写入。


3) Step 2:XML → JSON 字典(让数据更“可脚本化”)

原文的第二步,是把 XML 转成更好用的字典(最终写成 JSON)。
在我看来它解决了两个关键问题:

  1. 清理命名空间与约定:把 Maya 里很“历史遗留”的命名切割掉,形成稳定 key。
  2. 矩阵格式转换:USD API 需要的矩阵格式跟 XML 里那串 16 个 float 的写法不一样。

例如 XML 里的 xform 可能是这样一行:

xform-from-xml.txt
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 更希望你给类似这样的结构:

matrix-for-usd.txt
((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 → 字典的关键代码(已整理)
xml_to_json_dict.py
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 节点:

solaris_block_builder_header.py
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 的结果:

DefinePrim

以及 reference + instanceable 对应 UI 的关系:

usd_reference_instanceable.py
new_prim.GetReferences().AddReference(assetusd)
new_prim.SetInstanceable(True)

Reference sample

Tip

做 Pipeline 时,我更倾向把“引用路径”当成数据的一部分,而不是写死在脚本里。
这样当资产发布路径变更,你只需要改 JSON 生成规则,不需要动 Solaris 脚本。


5) Step 4:在更上层生成 City USD(让装配更稳定)

原文的 Step 4 本质上是 Step 3 的“上一级版本”:
不用再深入到 asset 层级,而是只处理 Block 层级,最终得到一个 city.usd,把所有 Block 引用进来。

查看:原文 Step 4 核心脚本片段(已整理)
solaris_city_builder.py
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 传递材质信息):

Cross DCC


结语:这套方案为什么值得做

如果用一句话总结:把“场景装配”从一次性手工劳动,变成可再生的流程能力

当你需要:

  • 反复迭代关卡布局
  • 在多个软件之间同步同一套装配结果
  • 在 Solaris/USD 上做更高性能的引用与实例化

这种“数据桥梁”会非常省心。
真正麻烦的不是写脚本,而是把你们项目的命名、发布路径、引用关系这些“约定”整理成一个可靠的数据结构——一旦这步做对了,后面基本就只剩下循环。

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

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

End of Article