跳转至

lookdev自动化贴图

【开源】Maya 自动化贴图管线与 Arnold 材质网络智能重命名工具 (PySide2)

📖 引言

在大中型影视与游戏 LookDev(外观开发)项目生产中,资产命名规范化与贴图格式/色彩空间统一是保障渲染管线稳定的基础。本项目针对高精资产制作中材质网络层级深、节点量大、多象限贴图手动处理效率低等问题,基于 Python (PySide2) 及 Maya 节点引擎开发了自动化管理工具。

以下为该工具的核心功能及技术实现说明:


🛠️ 核心功能与技术实现

1. 基于递归遍历(DFS)重命名材质网络

由于一个复杂的 Arnold 材质往往包含校色(aiColorCorrect)、混合(aiLayerShader)、凹凸(bump2D/aiBump2D)等大量中间节点,常规的单级遍历无法完全覆盖。

本工具实现了深度优先搜索递归算法(DFS),以 aiImage 节点为起点,沿下游连接方向自动向后追溯,直至捕获到最终的材质球(aiStandardSurface)及着色组(shadingEngine)。

  • 全网络覆盖:确保网络中所有辅助工具节点及 SG 节点全部被识别,无一遗漏。
  • 资产重命名规范:工具根据用户输入的“资产名”与“部件名”,自动为整条链接上的所有节点加上规范的前缀与后缀,直接剔除管线中的混乱命名。

2. 多象限(UDIM)序列识别与后台转码

在处理多象限材质时,手动修改文件名或转换格式极易出错。

  • UDIM 标签解析:工具通过解析 <UDIM><udim> 占位符,结合 Python 的 glob 模块对资产目录下对应的多象限文件进行物理模糊匹配。
  • CLI 后台无缝转码:工具通过 subprocess 静默调用外部图像处理引擎 ImageMagickmagick.exe),在不卡死 Maya 主线程的前提下,一键将多象限的 .exr.tif 等格式自动转码并重命名输出为生产标准的 .png 或指定格式。

3. 色彩空间(Color Space)自动化管理

严格遵循 ACES 或 Linear 流程规范,防止因人工误操作导致渲染色彩错误。

  • 通道自动分流:工具利用字符串关键字匹配逻辑,自动扫描贴图通道。凡带有 basecolorspeccoloremissivesubsurfacecolor 等色彩通道特性的节点,一键锁定为 Utility - sRGB - Texture
  • 非色彩通道锁定:对于 normalroughnessmetallicdisplacement 等数据通道,自动锁定为 Utility - Raw
  • 规避系统干扰:在切换色彩空间的同时,工具会自动将节点的 ignoreColorSpaceFileRules 属性置为 1(启用),强制规避 Maya 原生色彩空间规则对已指定节点的二次干扰。

📈 管线应用价值

  • 规范化落地:一键解决 LookDev 阶段因资产文件交接、外包反馈带来的命名混乱问题,确保资产进入下游灯光渲染管线时的合规性。
  • 效率提升:将原本需要逐个节点修改色彩空间、逐张贴图手动转码的繁琐机械劳动,缩短至数秒内的一键批处理。

🛠️ 完整源码

# -*- coding: utf-8 -*-
"""
================================================================================
工具名称:  贴图与 Arnold 材质管线工具
作者: 方元 (Fangyuan)
功能描述: 自动完成 Arnold 贴图格式转换、色彩空间智能管理、多象限UDIM处理
          以及全材质节点网络的递归智能重命名。
================================================================================
"""

from PySide2.QtCore import Qt, QUrl
from PySide2.QtGui import QDesktopServices, QPixmap, QPalette, QBrush
from PySide2.QtWidgets import QPushButton, QLabel, QWidget, QHBoxLayout, QVBoxLayout, QMainWindow, QLineEdit, QLayout
from shiboken2 import wrapInstance

import maya.OpenMayaUI as omui
import maya.cmds as cmds
import subprocess
import logging
import shutil
import os
import glob

# 配置日志
logging.basicConfig(level=logging.INFO, format='[companyTool] %(levelname)s: %(message)s')

def maya_main_window():
    """ 返回Maya主窗口作为Qt对象 """
    main_window_ptr = omui.MQtUtil.mainWindow()
    return wrapInstance(int(main_window_ptr), QWidget)

def add_h_lay(layout, stretch=False, *items):
    """ 快捷创建水平布局并添加部件 """
    h_lay = QHBoxLayout()
    for item in items:
        if isinstance(item, QLayout):
            h_lay.addLayout(item)
        elif isinstance(item, QWidget):
            h_lay.addWidget(item)
    if stretch:
        h_lay.addStretch(1)
    if layout:
        layout.addLayout(h_lay)
    return h_lay


class AdjustTexture(QMainWindow):
    # 外部公共路径配置(建议根据自身机房环境修改)
    IMAGEMAGICK_PATH = r"\\nas\pipeline\_AntPipe\Maya\antMaya\Model\Tool\NEZHA_2\ImageMagick7.1.1\magick.exe"
    QSS_PATH = r"N:/work/TD/code/Standalone/antBrower/styleSheet/main.qss"
    BG_IMAGE_PATH = r"N:/work/TD/code/icon/jinguzhou.png"

    def __init__(self, parent=maya_main_window()):
        super(AdjustTexture, self).__init__(parent)

        # 加载样式表
        if os.path.exists(self.QSS_PATH):
            with open(self.QSS_PATH, "r") as f:
                self.setStyleSheet(f.read())

        self.setWindowTitle("lookdev贴图/材质工具")
        self.resize(550, 240)
        self.setWindowFlags(self.windowFlags() ^ Qt.WindowMinMaxButtonsHint)

        # 加载背景图
        if os.path.exists(self.BG_IMAGE_PATH):
            self.setAutoFillBackground(True)
            palette = self.palette()
            pixmap = QPixmap(self.BG_IMAGE_PATH)
            palette.setBrush(QPalette.Window, QBrush(pixmap))
            self.setPalette(palette)

        self.widget_init()
        self.layout_init()
        self.adjust_ui()
        self.connect_init()

    def widget_init(self):
        # 资产信息输入
        self.asset_lbl = QLabel('资产名:')
        self.obj_lbl = QLabel('部件名:')
        self.asset_ldt = QLineEdit()
        self.obj_ldt = QLineEdit()

        # 通道材质按钮
        self.basecolor_btn = QPushButton('BaseColor')
        self.height_btn = QPushButton('Height')
        self.metallic_btn = QPushButton('Metallic')
        self.normal_btn = QPushButton('Normal')
        self.roughness_btn = QPushButton('Roughness')
        self.emissive_btn = QPushButton('Emissive')
        self.specular_btn = QPushButton('Specular')
        self.speccolor_btn = QPushButton('SpecColor')
        self.opacity_btn = QPushButton('Opacity')
        self.subsurfacecolor_btn = QPushButton('SubsurfaceColor')
        self.subsurface_weight_btn = QPushButton('SubsurfaceWeight')
        self.Displacement_btn = QPushButton('Displacement (必须使用32位exr)')
        self.View_btn = QPushButton('View')
        self.Mask_btn = QPushButton('Mask')

        # 功能批处理按钮
        self.rename_btn = QPushButton('修改所有的材质节点命名')
        self.renameSpaceColor_btn = QPushButton('设置所有节点色彩空间')
        self.setSrgb_btn = QPushButton('强制锁定通道: Utility - sRGB - Texture')
        self.setRaw_btn = QPushButton('强制锁定通道: Utility - Raw')

    def layout_init(self):
        self.v_lay = QVBoxLayout()
        add_h_lay(self.v_lay, False, self.asset_lbl, self.asset_ldt, self.obj_lbl, self.obj_ldt)
        add_h_lay(self.v_lay, False, self.basecolor_btn, self.metallic_btn, self.roughness_btn, self.normal_btn, self.height_btn)
        add_h_lay(self.v_lay, False, self.specular_btn, self.speccolor_btn, self.emissive_btn, self.opacity_btn)
        add_h_lay(self.v_lay, False, self.subsurfacecolor_btn, self.subsurface_weight_btn)
        add_h_lay(self.v_lay, False, self.Displacement_btn, self.View_btn, self.Mask_btn)
        add_h_lay(self.v_lay, False, self.rename_btn, self.renameSpaceColor_btn)
        add_h_lay(self.v_lay, False, self.setSrgb_btn, self.setRaw_btn)

        self.main_widget = QWidget()
        self.main_widget.setLayout(self.v_lay)
        self.setCentralWidget(self.main_widget)

    def adjust_ui(self):
        self.asset_ldt.setText(self.get_file_name())

    def connect_init(self):
        self.basecolor_btn.clicked.connect(lambda: self.btn_cmd('BaseColor'))
        self.metallic_btn.clicked.connect(lambda: self.btn_cmd('Metallic'))
        self.roughness_btn.clicked.connect(lambda: self.btn_cmd('Roughness'))
        self.normal_btn.clicked.connect(lambda: self.btn_cmd('Normal'))
        self.height_btn.clicked.connect(lambda: self.btn_cmd('Height'))
        self.specular_btn.clicked.connect(lambda: self.btn_cmd('Specular'))
        self.speccolor_btn.clicked.connect(lambda: self.btn_cmd('SpecColor'))
        self.emissive_btn.clicked.connect(lambda: self.btn_cmd('Emissive'))
        self.opacity_btn.clicked.connect(lambda: self.btn_cmd('Opacity'))
        self.subsurfacecolor_btn.clicked.connect(lambda: self.btn_cmd('SubsurfaceColor'))
        self.subsurface_weight_btn.clicked.connect(lambda: self.btn_cmd('SubsurfaceWeight'))
        self.Displacement_btn.clicked.connect(lambda: self.btn_Disp('Displacement'))
        self.View_btn.clicked.connect(lambda: self.btn_View('View'))
        self.Mask_btn.clicked.connect(lambda: self.btn_cmd('Mask'))
        self.rename_btn.clicked.connect(self.btn_renameAll)
        self.renameSpaceColor_btn.clicked.connect(self.btn_renameSpaceColorAll)
        self.setSrgb_btn.clicked.connect(self.btn_Setsrgb)
        self.setRaw_btn.clicked.connect(self.btn_SetRaw)

    def btn_Setsrgb(self):
        try:
            sel = cmds.ls(sl=True)
            if sel:
                cmds.setAttr(sel[0] + '.colorSpace', 'Utility - sRGB - Texture', typ='string')
                cmds.setAttr(sel[0] + '.ignoreColorSpaceFileRules', 1)
        except Exception as e:
            logging.error(f"锁定sRGB失败: {e}")

    def btn_SetRaw(self):
        try:
            sel = cmds.ls(sl=True)
            if sel:
                cmds.setAttr(sel[0] + '.colorSpace', 'Utility - Raw', typ='string')
                cmds.setAttr(sel[0] + '.ignoreColorSpaceFileRules', 1)
        except Exception as e:
            logging.error(f"锁定Raw失败: {e}")

    def btn_renameSpaceColorAll(self):
        for node in cmds.ls(type='aiImage'):
            node_lower = node.lower()
            # 智能判定色彩空间分类标准
            srgb_keywords = ['basecolor', 'speccolor', 'emissive', 'subsurfacecolor', 'view']
            is_srgb = any(key in node_lower for key in srgb_keywords)

            target_space = 'Utility - sRGB - Texture' if is_srgb else 'Utility - Raw'
            try:
                cmds.setAttr(node + '.colorSpace', target_space, typ='string')
                cmds.setAttr(node + '.ignoreColorSpaceFileRules', 1)
            except Exception as e:
                logging.warning(f"无法设置节点 {node} 的色彩空间: {e}")

    def get_file_name(self):
        file_name = cmds.file(query=True, sceneName=True)
        if file_name:
            return os.path.basename(file_name).split('.')[0].replace('HL', '')
        return ""

    def get_asset_obj_text(self):
        if self.asset_ldt.text() and self.obj_ldt.text():
            return f"{self.asset_ldt.text()}_{self.obj_ldt.text()}"
        return None

    def _get_all_connected_nodes_recursive(self, current_node, visited=None):
        if visited is None:
            visited = set()
        if current_node in visited:
            return []

        visited.add(current_node)
        connected_nodes = cmds.listConnections(current_node, source=False, destination=True) or []
        all_connected_nodes = []

        for node in connected_nodes:
            if node not in visited and cmds.nodeType(node) != 'aiImage':
                all_connected_nodes.append(node)
                all_connected_nodes.extend(self._get_all_connected_nodes_recursive(node, visited))
        return all_connected_nodes

    def renameOther(self):
        sel = cmds.ls(sl=True, typ='aiImage')
        if not sel:
            return
        start_node = sel[0]
        all_connected_nodes = self._get_all_connected_nodes_recursive(start_node)
        prefix = self.get_asset_obj_text()

        if all_connected_nodes and prefix:
            for bt_node in set(all_connected_nodes):
                nt = cmds.nodeType(bt_node)
                suffix_map = {'aiStandardSurface': 'shader', 'aiLayerShader': 'aiLayerShader', 'shadingEngine': 'SG'}
                suffix = suffix_map.get(nt, nt)
                try:
                    cmds.rename(bt_node, f"{prefix}_{suffix}")
                except Exception as e:
                    logging.warning(f"重命名节点 {bt_node} 失败: {e}")

    def reNameImage(self):
        sel = cmds.ls(sl=True, typ='aiImage')
        if not sel:
            return
        start_node = sel[0]
        filename_attr = cmds.getAttr(start_node + '.filename')
        if filename_attr:
            new_name = os.path.basename(filename_attr).split('.')[0]
            try:
                cmds.rename(start_node, f"{new_name}_aiImage")
            except Exception as e:
                logging.warning(f"重命名aiImage节点失败: {e}")

    def btn_renameAll(self):
        # 1. 优先重命名所有贴图节点
        for wi in cmds.ls(type='aiImage'):
            filepath = cmds.getAttr(f"{wi}.filename")
            if filepath:
                a_image = os.path.basename(filepath).split('.')[0]
                try:
                    cmds.rename(wi, f"{a_image}_aiImage")
                except:
                    pass

        # 2. 递归遍历重命名关联网络
        for ii in cmds.ls(type='aiImage'):
            filepath = cmds.getAttr(f"{ii}.filename")
            if not filepath:
                continue
            base_filename = os.path.basename(filepath).split('.')[0]
            other_node_prefix = '_'.join(base_filename.split('_')[:-1])

            all_connected_nodes = self._get_all_connected_nodes_recursive(ii)
            if all_connected_nodes:
                for bt_node in set(all_connected_nodes):
                    nt = cmds.nodeType(bt_node)
                    suffix_map = {'aiStandardSurface': 'shader', 'aiLayerShader': 'aiLayerShader', 'shadingEngine': 'SG'}
                    suffix = suffix_map.get(nt, nt)
                    try:
                        cmds.rename(bt_node, f"{other_node_prefix}_{suffix}")
                    except:
                        pass

    def btn_Disp(self, image_type):
        sel = cmds.ls(sl=True, typ='aiImage')
        if not sel:
            cmds.confirmDialog(title='提示', message='请先选择置换对应的贴图节点!')
            return

        fileimage_node = sel[0]
        prefix = self.get_asset_obj_text()
        if not prefix:
            return

        asset_name = f"{prefix}_{image_type}"
        old_image_path = cmds.getAttr(fileimage_node + '.filename')
        if not old_image_path:
            return

        dir_name = os.path.dirname(old_image_path)
        ext = os.path.basename(old_image_path).split('.')[-1]

        if '<udim>' not in old_image_path.lower():
            new_image_path = f"{dir_name}/{asset_name}.{ext}"
            if not os.path.exists(new_image_path):
                shutil.copy(old_image_path, new_image_path)
            cmds.setAttr(fileimage_node + '.filename', new_image_path, typ='string')
        else:
            # 处理多象限置换
            base_path_without_ext = old_image_path.lower().split('.<udim>')[0]
            ud_exrs = [i.replace('\\', '/') for i in glob.glob(f"{base_path_without_ext}.*.exr")]
            for old_ud_exr in ud_exrs:
                udim_num = os.path.basename(old_ud_exr).split('.')[-2]
                new_image_path_udim = f"{dir_name}/{asset_name}.{udim_num}.exr"
                if not os.path.exists(new_image_path_udim):
                    shutil.copy(old_ud_exr, new_image_path_udim)
            cmds.setAttr(fileimage_node + '.filename', f"{dir_name}/{asset_name}.<UDIM>.exr", type='string')

        self.renameOther()

    def btn_View(self, image_type):
        sel = cmds.ls(sl=True, typ='aiImage')
        if not sel:
            cmds.confirmDialog(title='提示', message='请先选择View对应的贴图节点!')
            return

        fileimage_node = sel[0]
        prefix = self.get_asset_obj_text()
        if not prefix:
            return

        asset_name = f"{prefix}_{image_type}"
        old_image_path = cmds.getAttr(fileimage_node + '.filename')
        if not old_image_path:
            return

        ext = os.path.basename(old_image_path).split('.')[-1]
        new_image_path = f"{os.path.dirname(old_image_path)}/{asset_name}.{ext}"

        if not os.path.exists(new_image_path):
            shutil.copy(old_image_path, new_image_path)
        cmds.setAttr(fileimage_node + '.filename', new_image_path, typ='string')
        self.renameOther()

    def btn_cmd(self, image_type):
        sel = cmds.ls(sl=True, typ='aiImage')
        prefix = self.get_asset_obj_text()
        if not sel or not prefix:
            cmds.confirmDialog(title='提示', message='请确保选中了aiImage节点并且输入了“部件名”')
            return

        aiimage_node = sel[0]
        asset_name = f"{prefix}_{image_type}"
        old_image_path = cmds.getAttr(aiimage_node + '.filename')
        if not old_image_path:
            return

        dir_name = os.path.dirname(old_image_path)

        if '<udim>' not in old_image_path.lower():
            new_image_path = f"{dir_name}/{asset_name}.png"
            if not os.path.exists(new_image_path):
                command = [self.IMAGEMAGICK_PATH, "convert", old_image_path, new_image_path]
                subprocess.call(command)
            cmds.setAttr(aiimage_node + '.filename', new_image_path, typ='string')
        else:
            # 批量多象限转码
            base_search_path = old_image_path.lower().split('.<udim>')[0]
            udim_files = [i.replace('\\', '/') for i in glob.glob(f"{base_search_path}.*")]
            for old_file in udim_files:
                if old_file.endswith('.tx'):
                    continue
                udim_num = os.path.basename(old_file).split('.')[-2]
                new_image_path_udim = f"{dir_name}/{asset_name}.{udim_num}.png"

                if not os.path.exists(new_image_path_udim):
                    command = [self.IMAGEMAGICK_PATH, "convert", old_file, new_image_path_udim]
                    subprocess.call(command)
            cmds.setAttr(aiimage_node + '.filename', f"{dir_name}/{asset_name}.<UDIM>.png", typ='string')

        self.renameOther()
        self.reNameImage()


def run():
    global adjust_texture_window
    try:
        adjust_texture_window.close()
        adjust_texture_window.deleteLater()
    except:
        pass

    adjust_texture_window = AdjustTexture()
    adjust_texture_window.show()