普通视图

发现新文章,点击刷新页面。
昨天以前智伤帝

Maya 拍屏方案汇总

作者 智伤帝
2022年11月28日 21:30

前言

  最近接到了一个需求,又是熟悉的 拍屏工具。
  其实老早之前我就有写过类似的需求,只是表现形式各不相同。
  这里打算将不同的拍屏方案汇总到一起,这样大家可以挑选一个合适的情景的方式完成这个任务。

拍屏方案汇总

Maya Python Publish 检查功能开发

  最早在华强实习的时候,就写过将 Arnold 渲染的界面合成并打开 RV 进行预览。
  背后主要用 renderWindowEditor 命令导出。


https://github.com/FXTD-ODYSSEY/MayaViewportCapture

  后来进入腾讯前,我写了 Maya Viewport Capture 工具。
  那个时候写的比较粗糙,我通过 UI 可以定义几个相机的位置,然后规定进行拍屏。
  当时研究用 Maya 或者 Qt 的 API 将 Viewport 的画面截取下来。
  背后主要用 Maya API M3dViewreadColorBuffer
  Qt 部分其实也是在拿到 Maya 的 MImage 之后转成 QImage 而已。


Maya Python 模型拍屏合并工具

  后来正式工作之后,发现前辈用的是 ogsRender 命令将 Maya Hardware 2.0 输出来。
  相较于 renderWindowEditor 命令不需要打开渲染窗口。


playblast

  实现拍屏有太多的方案,当然最为基础的方法就是使用 playblast 命令。
  建议安装上 QuickTime 这样可以极大压缩 Maya 拍屏的文件大小,同时提升 Maya 拍屏的质量。
  playblast 命令既可以直接生成视频也可以拍屏序列帧。

拍屏需求汇总

  上面提供四种拍屏方案,最常用的时 playblast 方案,因为可以直接输出视频。
  如果是图片序列还需要借助 ffmpeg 等命令行工具将图片序列合成为视频。

  拍屏的需求千变万化,但是有一些点其实大差不差。

  1. 拍屏信息
  2. 镜头角度

  比较常见的信息有 时间,影片的归属名字(比如动画的某一段),影片负责人 等等。
  添加这些信息可以用 headsUpMessage 将相关信息叠加到 Viewport 上。
  但是 headsUpMessage 非常难用,而且字体大小等各种非常不方便自定义。
  要解决这个问题可以用 插件,它通过 OpenMaya API 扩展了 headsUpMessage 的功能。
  作者是 zurbrigg ,只可惜它之前免费的工具现在变成付费了。
  劲爆羊工具盒 里面有拍屏王,它就是通过 ZShotmask VP2 插件,将各种信息贴到屏幕上。
  具体可以在 劲爆羊工具盒 里面找到脚本 resource\tools\MSTools\MST_DATA\plug-ins\zshotmask.py
  当然它是一个 Maya Python 插件,注册之后提供了一个节点,只要设置节点的属性就可以了。


  这个方式可以结合 playblast 解决大部分拍屏的问题。
  但有些情况并不能很好解决,比如我遇到的问题就是,每一帧都要重新矫正一下镜头的位置。
  而且这个矫正还不能单纯使用约束,需要每一帧单独进行计算。
  所以我只能改用 ogsRender 的方式,在后台进行拍屏。

Maya ogsRender 输出序列帧

  使用 ogsRender 输出序列帧只能输出到默认工程 images 文件夹的路径。
  因此要控制 ogsRender 输出的位置只能通过修改工程位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import contextlib
@contextlib.contextmanager
def change_workspace_images(folder):
"""Change maya project images folder temporarily.

Args:
folder (str): Image folder path.
"""
workspace_settings = pm.workspace(q=1, fr=1)
image_index = workspace_settings.index("images")
original_image_folder = workspace_settings[image_index + 1]
pm.workspace(fr=["images", folder])
yield
pm.workspace(fr=["images", original_image_folder])

  我写了一个函数,可以修改输出位置,在修改回去。
  这样我可以输出到任意路径。

Python ThreadPool 多线程后处理

  上面拍屏生成的图片,可以放到 imagemagick 进行图片后处理。
  imagemagick 是 maya 自带的命令行图形处理库。
  在 Maya 2022 之前叫做 imconvert.exe, 2022 之后叫做 magick.exe

  之前也研究过通过 imagemagick 处理图片,真的是拳打 Pillow 脚踢 QImage

ImageMagick 图像处理介绍

  imagemagick 用 C 和 C++ 编写的,非常小巧,而且运行速度很快~
  这里我没有使用 ZShotmask VP2 直接拍屏输出我要的信息,因为有些信息想要通过 imagemagick 叠加到图片上。
  于是我想到可以利用 Pool 线程池的方式多线程后台调用命令行。

  其中 from multiprocessing.dummy import Pool 可以导入 Python 隐藏的线程池。
  这个用起来比起使用 threading 库要简单方便很多。
注: from multiprocessing import Pool 导入进程池, Maya 不太支持这个。
  下面来个实例演示一下多线程调度后处理函数的好处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from multiprocessing.dummy import Pool
from functools import partial
from functools import wraps
import time

def log_time(func=None, msg="elapsed time:"):
if not func:
return partial(log_time, msg=msg)

@wraps(func)
def wrapper(*args, **kwargs):
curr = time.time()
res = func(*args, **kwargs)
print("[{0}]".format(func.__name__),msg, time.time() - curr)
return res

return wrapper

def post_process(index):
time.sleep(0.1)
print("test", index)


@log_time
def multi_thread():
pool = Pool()

results = []
for index in range(10):
time.sleep(0.1)
results.append(pool.apply_async(partial(post_process, index)))

[result.wait() for result in results]
pool.close()
print("done")

@log_time
def sequence_run():
for index in range(10):
time.sleep(0.1)
post_process(index)
print("done")

if __name__ == "__main__":
multi_thread()
sequence_run()

  执行上面的代码

1
2
[sequence_run] elapsed time: 2.201172351837158
[multi_thread] elapsed time: 1.2311382293701172

  最后会得到用线程池的方式可以比直接执行快1倍。
  而且这个代码是 py2 兼容的。
  通过这个方式可以在 Maya 拍屏的时候用多线程调用 imagemagick 来对生成的图像进行处理。
  这样用户几乎感受不到图像后处理的时间。

总结

  以上就是 Maya 各种拍屏方案汇总,使用序列帧的自由度比较高,但是需要 ffmpeg 和 imagemagick 等依赖进行处理。
  简单的需求可以直接用 playblast 加上 ZShotmask VP2 完成。

C++ 道法器术

作者 智伤帝
2022年11月26日 15:59

前言

https://www.bilibili.com/video/BV1pu411y7n1
https://www.bilibili.com/video/BV1RV4y1x7qH

  上面两个链接是 李老师 的直播视屏。
  虽然 李老师 在卖课。
  但他免费的直播对我 C++ 小白来说,非常有用,让我对 C++ 语言有了一个大局观的认识。
  这样才能更好地定位到自己学习的情况。
  下面是对他 PPT 内容的一些总结汇总。

C++ 道法器术

  • C++ 5个术
      1. 类型系统
      1. 编译映射
      1. 内存管理
      1. 设计范式
      1. 习语与规范
  • 设计范式
      1. 面向过程
      1. 面向对象
      1. 泛型编程
      1. 函数式编程
      1. 模板化编程
  • 时空人
    • 时间分析 – 发生在什么时候
    • 空间分析 – 变量/对象放在哪里
    • 人物分析 – 代码哪来的,如何耦合
  • 模块一 C++ 类型系统与设施
    • 类型基础
      • 存储: 堆 栈 全局区
      • 值语义与引用语义
      • 指针与引用
      • 初始化与生命周期
    • 其他类型
      • 数组序列: vector array 与 C数组
      • 字符串处理: string string_view与char*
      • 枚举类 联合 位域
      • 数据成员
      • 函数成员
      • 静态与实例成员
      • 操作符重载
    • 类型扩展
      • auto 与自动类型推断
      • const
      • volatile
      • 结构化绑定
    • 编译与构建
      • C++ 编译机制
      • 模块 (C++ 20)
      • GCC/Clang/MSVC
  • 模块二 C++ 面向对象编程
    • C++ 对象模型
      • 对象内存模型
      • 对象成员与指针成员
      • 对象布局 对齐 和尺寸
    • 三法则与五法则
      • 构造函数 / 析构函数
      • 拷贝构造函数 / 赋值操作符
      • 移动拷贝构造函数 / 移动赋值函数
      • 默认定义与删除规则
    • 继承: 类型抽象
      • 基类与子类
      • 成员的继承
      • 抽象类
      • 共有 私有 受保护继承
      • 多继承与虚继承
    • 多态: 运行时绑定
      • 虚函数
      • 虚函数表
      • 虚析构函数
      • 运行时绑定
      • dynamic_cast
    • 面向对象设计
      • 实现继承与接口继承
      • 组合与继承
      • 编译时 VS 运行时绑定
      • 设计模式: Template Strategy Observer
  • 模块三 内存管理: 原理 优化技巧与避免踩坑
    • RAII: 内存与资源管理
      • 内存与资源
      • 资源获取即初始化 (RAII)
      • C++ Java Go Rust 内存管理对比
    • 智能指针
      • unique_ptr
      • shared_ptr
      • weak_ptr
    • 移动语义
      • 右值与左值
      • 移动构造与移动赋值
      • 移动与拷贝
      • 临时对象与返回值优化(RVO)
      • std::move 操作
      • std::forward 操作
    • new 与 delete 扩展
      • 全局 new 与 delete
      • new 与 delete 操作符
      • placement new
      • nothrow new
  • 模板机制
    • 参数化类型
      • 类模板
      • 类型参数与值参数
      • 模板参数推到
      • 参数的隐式绑定
    • 参数化操作
      • 函数模板
      • 函数对象
      • lambda 表达式
      • 函数式编程
    • 实用类型
      • pair 与 tuple
      • variant optional any
      • bitset
    • 模板扩展
      • 模板编译模型
      • 类型别名
      • 模板特化
      • 可变参数模板
      • constexpr 编译时计算
      • SFINAE \ enable_if \ Tag Dispatching \ if constexpr
      • 模板元编程
  • 模块五 泛型编程与 STL
    • 容器
      • 容器概述
      • STL 中的常用容器
      • 容器及操作性能考虑
      • 容器最佳实践
    • 算法
      • STL 算法概览
      • 不同算法的性能考虑
      • 编写泛型算法
      • 适配器
    • 迭代器
      • 迭代器概念
      • STL 中的迭代器
      • Ranges 与 for
    • 概念 (Concept)
      • 类型约束与接口规约
      • 概念定义
      • STL 常用概念
  • 设计原则 Design Principle
    • 正交设计四原则
      • 消除重复性
      • 分离关注点
      • 减少不必要地依赖
      • 向稳定的方向依赖
    • 整洁代码三原则
      • KISS 原则 (简单以理解)
      • DRY 原则 (不要重复自己)
      • 迪米特原则 (最小依赖)
    • SOLID 五大设计原则
      • 单一职责原则 (SRP)
      • 开闭原则 (OCP)
      • 里氏替换原则(LSP)
      • 接口隔离原则(ISP)
      • 依赖倒置原则(DIP)
    • 面向对象三原则
      • 封装责任 隔离变化
      • 优化使用对象组合 而不是类继承
      • 针对接口编程 而不是针对实现编程
  • 设计习语 Design Idiom
    • RAII 资源获取即初始化
    • Scope Guard 范围守卫
    • Copy & Swap 拷贝后转换
    • SOO 小对象优化
    • Local Buffer 本地缓存
    • Copy-On-Write (COW) 变更时拷贝
    • EBCO 空基类优化
    • Virtual Constructor 虚构造器
    • Pimpl 指向实现的指针
    • NVI (Non-Virtual Interface) 非虚接口
    • CRTP 奇异递归模板模式
    • Mixin 混入类
    • Policy Design 策略设计
    • Type Traits 类型萃取
    • Lambda 重载
    • Tag Dispatcher 标签分发
    • Type Erasure 类型擦除
    • SFINAE 替换失败不是错误
    • Named Template Arguments / Method Chain 命名模板参数 / 方法链
  • 从管理变化的角度理解设计模式
    • 晚期扩展
      • Template Method
      • Builder
    • 策略对象
      • Strategy
      • Observer / Event
    • 对象创建
      • Factory Method
      • Abstract Factory
      • Prototype
    • 单一职责
      • Decorator
      • Bridge
    • 行为变化
      • Command
      • Visitor
    • 接口隔离
      • Adapter
      • Proxy
      • Facade
      • Mediator
    • 对象性能
      • Singleton
      • Flyweight
    • 数据结构
      • Composite
      • Iterator
      • Chain of Responsible
    • 状态变化
      • State
      • Memento
    • 领域规则
      • Interpreter

接口设计

语言构造习语与模式
封装 - 接口隔离Pimpl
多态基类 - 接口合约NVI
泛型隐式接口Template Method
Type TraitsFactory
Tag DispatchingAdapter
SFINAEProxy
概念 – 泛型显示接口Facade
Composite
Iterator

继承设计

语言构造习语与模式
继承EBCO
多继承CRTP
虚继承Bridge
实现继承Mixin
接口继承Decorator
变参继承

内存设计

语言构造习语与模式
对象生命周期RAII
值语义/引用语义Scope Guard
对象内存布局SOO
智能指针 {unique_ptr shared_ptr weak_ptr}Local Buffer
移动语义Copy-On-Write
Singleton
Flyweight

回调设计

语言构造习语与模式
函数指针Policy Design
多态对象 (策略 命令)Strategy
函数对象 (仿函数)Observer
函数适配器 (bine mem_fn)Command
Lambda 表达式Lambda Overload
std::function (多态回调对象)Visitor
std::invoke (多态调用)
std::invocable (回调概念)

C++ 基础入门

作者 智伤帝
2022年11月13日 11:39

前言

  随着学习的深入,C++ 的学习越来越迫在眉睫。
  虽然我在学习 Maya API 以及 Unreal 过程中已经写过不少的 C++ 代码。
  但以前写 C++ 都是用 Python 的经验迁移过去使用的,很多 C++ 的特性都不懂,很多库也不怎么会用。
  所以正因为如此,才希望自己可以深入学习好 C++

课程推荐

C++ MasterClass

在 youtube 上找到了一个非常棒的教程
Youtube地址(不完整): https://www.youtube.com/watch?v=8jLOx1hD3_o
udemy 完整版地址: https://www.udemy.com/course/the-modern-cpp-20-masterclass/
B站
https://www.bilibili.com/video/BV1Hr4y1H7wB
https://www.bilibili.com/video/BV1JY4y1Y7uZ
https://www.bilibili.com/video/BV1iA4y1X76r
https://www.bilibili.com/video/BV1A34y1e7KS
https://www.bilibili.com/video/BV1434y1e7N4

Github地址: https://github.com/rutura/The-C-20-Masterclass-Source-Code

  教程足足有 30 小时长,而且还是 udemy 教程的阉割版本,不过里面有第一章会教导如何使用 MSVC gcc clang 三种 C++ 编译器构建环境。
  我 fork 了他的仓库加上我自己的 VSCode 配置 仓库地址: https://github.com/FXTD-ODYSSEY/The-C-20-Masterclass-Source-Code

  默认 tasks 是配置了三中不同编译的选项,如果注释掉两个的话,那就可以直接在 VScode 实现 ctrl+shift+b 实现编译并运行。
  教程里面主要 IDE 环境是使用 VScode 搭建的,可能会有人困惑,why not VS。
  我很久以前开发 Maya C++ 就是使用 VS 进行开发的,说实话,IDE 隐藏了太多细节,一旦出错,反而是无头苍蝇,无从查起。 知乎回答
  当然也同其他回答说得也对,用什么工具都无所谓,关键是懂得 C++ 的整个编译流程。

The Cherno C++

https://www.youtube.com/watch?v=18c3MTX0PK0&list=PLlrATfBNZ98dudnM48yfGUldqGD0S4FFb
https://www.bilibili.com/video/BV1gk4y1r7UH

  游戏开发大佬推出的一系列编程课程。

parallel 101

  后来非常偶然地,我翻到一个大佬 (小彭老师) 的课程

https://github.com/parallel101/course
https://www.bilibili.com/video/BV1fa411r7zp

  这个课程用直播和录播的形式详细介绍了从 cmake 到 C++ 的使用。
  而且老师年轻有为,能力很强,经验丰富。

原子之声

C++现代实用教程(一):基础主线(VSCODE) gitlab地址
C++现代实用教程(二):面向对象基础 gitlab地址
C++现代实用教程(三):面向对象之友元与继承 gitlab地址
C++现代实用教程(四):面向对象核心多态 gitlab地址
C++现代实用教程:智能指针 gitlab地址
C++现代实用教程: Namespace命名空间 gitlab地址

  这位老师也很赞~
  但是还没仔细看…

C++ 道法器术

https://www.bilibili.com/video/BV1pu411y7n1
https://www.bilibili.com/video/BV1RV4y1x7qH

  C++ 是一门很复杂的语言,像我是从 Python 开始进阶编程的。
  当我将 Python 很多用法摸透之后,进入到 Python 底层,发现 C++ 还很多底层的内容等待我去学习(:з」∠)
  那上面的视频,比较系统地总结了 C++ 从入门到进阶的各个不同阶段地内容,学习 C++ 有很清晰的整体图谱。
  当然视频里面其实是介绍作者推出的课程的~

个人剖析文章 01_C++ 道法器术.md

搭建运行环境

  C++ 语言和 Python 运行方式有相当大的不同,

参考: https://smartkeyerror.com/Python-Virtual-Machine

  编译 C++ 需要有 C++ 编译器来生成汇编代码(二进制机器码) ,不同的编译器有不同的优化策略,所以版本和编译器平台都会对生成的汇编有很大影响。
  教程提供了 https://en.cppreference.com/w/cpp/compiler_support 这个网站。
  可以看到不同平台编译器对各种 CPP 规范的支持情况,如果用了老版本就不能使用新版本的 C++ 写法
  目前 C++ 也在不断演进,从古老的 C++98 到现在 C++11 C++14 C++17 C++20 以及后续即将推出的 C++23 C++26
  目前主流编译器的最新版本都支持到 C++17 了。

编译器下载配置

  市面上最主流的 C++ 编译器有 MSVC gcc clang ,其中 MSVC 是 windows 平台的,另外两个是可跨平台开发。
  windows 下如何安装环境呢? 推荐使用 choco 进行安装

1
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

  用管理员权限打开 powershell 然后输入上面的命令进行安装。

1
2
3
4
5
6
7
8
9
::安装 MSVC
choco install visualstudio2019buildtools --yes
choco install vcredist140 --yes

::安装 gcc
choco install mingw --yes

::安装 clang
choco install llvm --yes

  执行上面的命令可以安装相对应的环境到系统中。
  需要注意的是 MSVC 需要打开 VS installer 配置 windows SDK C:\Program Files (x86)\Microsoft Visual Studio\Installer\setup.exe

image

image

  然后选择下载 Windows 10 SDK 再到右下角点击修改。
  这样才能将 MSVC 编译器安装到电脑上。


  使用 MSVC 进行编译,需要调用 C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\Common7\Tools\VsDevCmd.bat 脚本启动环境。
  激活环境之后可以使用 cl.exe 来接链编译 C++ 代码。
  而其他编译器默认安装完之后 choco 添加到 PATH 路径下了。

1
2
C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\Common7\Tools\VsDevCmd.bat
cl /Zi /std:c++20 /EHsc /Fe: main.exe main.cpp
1
g++ -g -std=c++20 main.exe main.cpp
1
clang -g -std=c++20 main.exe main.cpp

  使用上面的命令就可以实现 C++20 标准代码的编译。
  如果你使用 Visual Studio 之类的 IDE,那背后其实也是调用编译器对 C++ 代码编译生成二进制机器码文件。

VScode 环境配置

  有了上述的环境之后,只要运行命令就可以执行代码了。
  开发工具比较推荐使用 VScode
  个人体验了 VS 感觉过于笨重,而且隐藏了很多编译的细节,导致很多环节出错了不知道从何查起。
  所以我推荐使用 VScode 编辑器作为入门,了解了基础再使用复杂的 IDE 才能事半功倍。


  安装 VScode 之后,可以安装微软官方提供的 C++ 扩展
  实际上 VScode 官方是比较推荐用 tasks.json 配置来管理编译 用 launch.json 来管理启动的。
  但是这些配置对小白来说还是稍显复杂。
  这里我推荐安装 Code Runner 插件

image

  去到对应的代码就有启动图标,在右上角,点击一下默认会调用 gcc 编译并执行。

image

  如果想要修改默认的执行命令,可以去修改 code runner 的配置

image

  默认会有不同语言对应执行的命令,我们这里可以把 Cpp 执行的命令改成我们想要的样子即可。
  比如我们想要改成 clang 编译也或者 MSVC 编译也是完全可以的。
  MSVC 比较麻烦,需要先跑 VsDevCmd.bat 激活环境才能使用 cl 命令。

  另外输入源可以改成通配符识别 *.cpp ,这样多个文件只要都在一个目录里面都会一同编译,方便我们初学跑程序。

1
"cpp": "cd $dir && g++ -g -std=c++20 *.cpp -o $fileNameWithoutExt && $dir$fileNameWithoutExt",

  另外在线网站 https://godbolt.org/ 内置了很多不用语言的编译器
  可以在线编写代码去验证,也能很方便地查看编译出来的汇编语言。
  没有本地环境的时候也可以用这个工具来跑代码进行验证。

C++ 入门

  学习一门语言,是骡子是马总得遛 一遛才知道代码是否有问题。
  所以只是看教程,脑内编译代码是不行的。

  这里我用 C++ 入门会以 The-C-20-Masterclass-Source-Code
  只要按照我上面的配置,就可以愉快地跑这个仓库任意路径的代码,并编译出可执行文件了~

  如果你已经有编程基础,比如学过其他的编程语言,那么我更推荐直接看代码执行来学习,遇到不懂的部分再翻视频。
  这样比起纯看看视频会更快上手。

  另外为了方便能够查阅 cppreference.com cplusplus.com 等官方文档,可以快速跑里面的案例 Demo
  我用 Python 做了个简单的爬虫,将不同的资料汇总到一起 REPO

FBX 二进制数据解析

作者 智伤帝
2022年11月6日 16:38

前言

  最近遇到了一个比较难搞的需求,好不容易解决了,在这里记录一下。
  需求是这样的,公司有大佬在 motionbuilder 写了插件,利用 mobu API 做了一个自定义的节点并在里面通过 FBXStore API 存入了自定义数据。
  我需要将这些操作通过 Python FBXSDK 来完成这些数据的写入。
  主要原因是 motionbuilder 的稳定性不可靠,如果可以利用纯外部调用 FBXSDK 的形式解决问题,就不需要依赖 mobu 了。

  用 FBXSDK 来还原自定义节点操作都好说。
  主要蛋疼的地方在于需要解决 FBXStore API 调用背后怎么转换成二进制的问题。

motion builder C++ 插件编译

  在 motion builder 的安装路径有 OpenRealitySDK 文件夹,里面的 samples 有很多开发 mobu 的参考代码。
  其中比较具有代表性的脚本就是 OpenRealitySDK\samples\devices\devicecamera\ordevicecamera_device.cxx
  这个脚本就定义怎么将自定义数据存入 FBX 当中,并且利用 FbxRetrieve 方法将功能读取回来。

  我们可以把这个东西编译出来作为我们这次测试的内容。

  默认 motionbuilder 的 samples 里面提供了 sln 工程,可以直接用 VS 打开。

image

  打开之后需要将平台工具集升级到最新的 VS 版支持的工具集,默认是 2012 工具集太过古老了。
  改完之后本想着愉快地编译,然而这样会报错。

image

  这个问题只能归结为新的平台工具集已经去掉了支持,但是头文件依旧引入相应的文件,解决也很简单,将报错的那一行注释即可。

image

  编译完成会默认去到 bin\x64\plugins 的目录,这样只要重启 motion builder 就能加载到这个 dll 了。

image

  这样将这个图标拖拽到场景就可以创建一个 device.
  将这场景以 ascii 的格式保存。
  检查保存的 FBX 文件,可以看到 FBXStore 的写入逻辑,会将信息写入到节点的 MoBuAttrBlindData 属性上

image

  存储出来可以看到相应的信息。
  这里官方的插件将信息转成了 KString 所以里面的信息也是以 FBX ASCII 的形式存在。
  但如果将 FBX 存成 Binary 模式,然后再用 Python FBXSDK 来转存成 ASCII 的话,这些 FBXStore 的数据会转成 base64 的二进制数据。

image

  如果用 base64 解码,可以看到里面存储的二进制数据。

1
2
3
4
5
6
7
8
import base64

in_data = r"cBsAAAABAAAABQAAAAhDb21tVHlwZUkQAAAANAAAAAEAAAAFAAAAB1ZlcnNpb25JUQMAAGIAAAADAAAAGwAAAAZTZXJpYWxJAQAAAEkAlgAAUwwAAABTY2VuZQABTW9kZWycAAAABAAAACQAAAAJU2ltdWxhdG9yRAAAAAAAAPA/RAAAAAAAAPA/RAAAAAAAAAAARAAAAAAAAAAAwwAAAAIAAAATAAAAB05ldHdvcmtTCQAAADEyNy4wLjAuMUm5CwAA9gAAAAIAAAAaAAAADFNoYXJlZE1lbW9yeVMKAAAASE1DX1NITV9WMVMGAAAAMDAwMDAwFAEAAAEAAAAJAAAACFNldHRpbmdzRAAAAAAAAAAAMgEAAAEAAAAFAAAADFNhbXBsaW5nTW9kZUkAAAAAVAEAAAEAAAAFAAAAEEluc3RydW1lbnRBY3RpdmVJAQAAAG0BAAABAAAABQAAAAdWZXJzaW9uSVEDAACOAQAAAQAAAAUAAAAPTGVuc1RhYmxlTG9hZGVkSQAAAADbAQAABgAAAC4AAAASTWFudWFsTW9kZVNldHRpbmdzSQAAAABEBzDzdETpTEBEAAAAAACARkBJAAAAAEQAAAAAAAAAAEQAAAAAAAAAAP4BAAACAAAACgAAAAxJbnZlcnRWYWx1ZXNJAAAAAEkAAAAAHwIAAAEAAAAJAAAAC0FzcGVjdFJhdGlvRFVVVVVVVfU/QwIAAAEAAAAJAAAADlpvb21NdWx0aXBsaWVyRAAAAAAAAPA/aAIAAAEAAAAJAAAAD05vZGFsTXVsdGlwbGllckQAAAAAAABZQIgCAAABAAAABQAAAA5BbmdsZUluRGVncmVlc0kAAAAAywIAAAQAAAAkAAAAEkVuY29kZXJDYWxpYnJhdGlvbkQAAADgzxJjQUQAAADgzxJjwUQAAADgzxJjQUQAAADgzxJjwRsDAAAGAAAANgAAAA1TdHVkaW9PZmZzZXRzRAAAAAAAAAAARAAAAAAAAAAARAAAAAAAAAAARAAAAAAAAAAARAAAAAAAAAAARAAAAAAAAAAANAMAAAEAAAAFAAAAB1ZlcnNpb25JUQMAAE8DAAABAAAABQAAAAlTeW5jRGVsYXlJBAAAAAAAAAAAAAAAAAAAAAA="

output_path = r"G:\_TEMP\2022-11-1\test_device.bin"

with open(output_path,'wb')as wf:
wf.write(base64.b64decode(in_data))

image

  VScode 安装 Hex Editor 可以查看二进制数据。
  而我这边需要想办法用 Python 写入二进制数据,从而摆脱 motion builder 的依赖。

FBX 二进制

FBX 数据格式
RenderDoc Python 开发 FBX 导出工具

  之前写 renderdoc 导出 FBX 插件的时候,使用的时 FBX ASCII 格式,通过将数据写入到 FBX ASCII 对应的位置。FBX 就可以被读取到。
  当时踩了的坑也可以从中窥探到 FBX 存储的结构。

Python 二进制处理
Maya 输出顶点动画到引擎

  通过上面的文章,可以了解到 Python 写入二进制数据可以依赖内置的 struct 包。
  写入数据需要了解 C++ 的数据类型的长度,按照长度和数据的写入顺序就可以用 Python 还原二进制数据。


image

  通过 C++ 源码可以知道写入了这些数据

image

  通过源码和二进制的对比,可以窥探到其中意思规则
  比如利用 C++ 可以知道 Version 数据写入的时 0x0351 的数据

1
2
3
>>> import struct
>>> struct.pack("i",0x0351)
b'Q\x03\x00\x00'

  使用 python 将 0x0351 转换为整形会返回 Q\x03\x00\x00

image

  正好和 二进制 数据是对应的,中间的 I 则表示是 Int 整形数据。
  这个规律我经过我对二进制不少数据的解读总结出来的。但还有一些数据的含义是未知的。

  后来在网上搜索了一下这个二进制规则,发现 Blender 官方提供了 FBX 二进制的解读。 链接
  这个文章有非常完整的 FBX 二进制规则。
  通过这个规则可以解读出整个 FBX 二进制数据的存储方式。

  比如开头的 CommmType 前面有14位数据,除去开头第一个 0x70 数据,后面的数据分别对应 EndOffset NumProperties PropertyListLen NameLen
  完全和 Node Record Format 对应。

  理解了数据的存储方式之后,就可以很顺利用 Python 写入同样的二进制数据。

FBXSDK 写入问题

  只是我处理的时候发现 Python FBXSDK 无法直接写入 blob 二进制数据。
  原因是 FbxProperty.Set 不接受 bytes 数据。

image

  这个部分是用 C++ 模板实现的,可能这个功能并没有映射给 Python FBXSDK,导致功能缺失。(也只能说这个功能少用得很)
  为了保证数据的长度,我的处理方式是用 FbxString 写入相同长度的 字符串桩 ,比如一堆 * 的字符串。
  保存出去的 FBX 二进制文件再度用 Python 读取,然后将 字符串桩 替换为真实的 二进制 数据。

总结

  这次深度挖掘了 FBX 二进制格式,对 FBX 的文件处理更加得心应手😄~

Maya 顶点色单通道笔刷

作者 智伤帝
2022年8月16日 16:11

前言

  上次我们讨论了怎么在 Maya 实现各种笔刷的姿势 Maya CurveBrush 笔刷开发
  趁着最近比较有空,我又捡起了之前想要开发顶点颜色单通道笔刷,
  仓库早在 1 年前就创建了,但是并没有好好开发出来。

https://github.com/FXTD-ODYSSEY/Maya-VertexColorPainter

  关于单通道顶点色笔刷,其实是之前项目组给我提的需求,Maya 官方提供的 Paint Vertex Color Tool 挺好的

image

  就是绘制的时候顶点色是混合在一起的。无法实现分通道绘制。
  网上也可以找到有不少帖子抱怨 Maya 竟然没有实现这个功能的。

https://polycount.com/discussion/191918/single-channel-vertex-painting-in-maya-2018
https://www.reddit.com/r/Maya/comments/87znt2/paint_on_separate_channels_in_vertex_painting/

  我当时做了一些研究,后来因为太忙了,就将需求转交给其他同事负责了。
  那个同事解决了需求,只是解决方案比较复杂,需要用 OpenMaya 写一个节点,再加自定义笔刷实现。

  经过我上次笔刷的折腾,我在想能否扩展原本 Maya Paint Vertex Color Tool 的功能

  上面就是我最终实现的效果,在 Maya 的原生 UI 上进行修改,提供了额外的 UI 配置来进行单通道绘制。

笔刷选型

  Maya CurveBrush 笔刷开发 我这篇文章已经覆盖了写笔刷的各种姿势。
  用 Maya 开放的 MPxContext 写笔刷实最为自由的,但是很多功能都没有。
  使用 Maya 内置的 artisan 笔刷,则已经实现了好多功能。

  1. 自带镜像
  2. 笔刷可以自定义笔刷图章实现渐变
  3. 内置序列化功能

artisan painting 扩展官方文档

  所以如果不是复杂的笔刷,能用 artisan 就用 artisan 去实现。
  只可惜 Maya 没有暴露 artisan 笔刷的 C++ 接口,所以如果用 C++ 开发就只能重新实现一遍 artisan 的功能,比较麻烦。
  当然绘制顶点色我直接使用 artAttrPaintVertexCtx 即可。

实现原理

单通道 color set 拆分

  利用 Maya 提供的 ColorSet 功能,将模型的主顶点色分拆成四个通道的 ColorSet
  我这里就分别命名为 VertexColorR VertexColorG VertexColorB VertexColorA
  绘制的时候根据选择 UI 的选择激活相应的 ColorSet

  这一步可以用 artAttrPaintVertexCtxtoolOnProctoolOffProc 定义激活和关闭的回调。
  激活 context 的时候创建 ColorSet 拆分,退出 Context 的时候删除冗余的 ColorSet
  这个地方的 toolOnProc toolOffProc 同样只接受 mel 函数,用 Python 解决的方案参考 Maya CurveBrush 笔刷开发 这篇文章。

颜色分解

  那么上面拆分 ColorSet 的时候就需要将对应的 MainColorSet 的颜色按通道赋值给对应单通道的 ColorSet.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import pyeml.core as pm
from maya import OpenMaya
PAINT_CTX = "artAttrColorPerVertexContext"
color_set_representation = {
"R": "RGB",
"G": "RGB",
"B": "RGB",
"A": "A",
}
def get_color_sets(node):
color_sets = pm.polyColorSet(node, q=1, allColorSets=1)
return color_sets or pm.polyColorSet(node, create=1)

def filter_color(color, index, source_color=None):
if index > 3:
return color
is_color = isinstance(source_color, OpenMaya.MColor)
color_list = list(source_color) if is_color else [0, 0, 0, 1]
color_list[index] = color[index]
return OpenMaya.MColor(*color_list)

# NOTES(timmyliang): 获取当前正在绘制的节点
for node in set(pm.artAttrPaintVertexCtx(PAINT_CTX, q=1, pna=1).split()):
node = pm.PyNode(node)
node.displayColors.set(1)
color_sets = get_color_sets(node)
main_color_set = color_sets[0]

mesh = node.__apimfn__()

# NOTES(timmyliang): 获取主 color set 顶点色
color_array = OpenMaya.MColorArray()
mesh.getVertexColors(color_array, main_color_set)

# NOTES(timmyliang): 获取顶点序号数组
vtx_array = OpenMaya.MIntArray()
for array_index in range(color_array.length()):
vtx_array.append(array_index)

final_colors = OpenMaya.MColorArray()
for channel_index, color_channel in enumerate(cls.CHANNELS):
# NOTES(timmyliang): 如果通道 color set 不存在则创建
color_set = "VertexColor{0}".format(color_channel)
if color_set not in color_sets:
rpt = color_set_representation.get(color_channel)
pm.polyColorSet(node, create=1, rpt=rpt, colorSet=color_set)

mesh.setCurrentColorSetName(color_set)
final_colors.clear()
for array_index in range(color_array.length()):
full_color = color_array[array_index]
color = filter_color(full_color, index=channel_index)
final_colors.append(color)
# NOTES(timmyliang): 批量设置顶点色
mesh.setVertexColors(final_colors, vtx_array)
mesh.setCurrentColorSetName(main_color_set)

  这里利用 pymel 提供的 __apimfn__ 直接获取 MFnMesh 对象
  利用 setVertexColors API 批量设置顶点色,性能比起单点设置要好很多。

单通道 单颜色 绘制

  下一步就是要实现绘制将颜色锁在对应通道上。
  比如我在 UI 上设置为值绘制 R 通道的状态,绘制选择的颜色是 白色 [255,255,255],点击 Viewport 的时候会将颜色过滤成 红色 [255,0,0] ,这样勾选 R 的时候就只会刷出 红色 没有其他颜色。
  这里可以监听 Viewport 的 press 和 release 触发,当点击 viewport 的时候根据 UI 勾选的通道过滤 Ctx 颜色配置。

1
2
3
4
5
6
7
8
9
10
# NOTES(timmyliang): 获取 UI 的颜色和透明值
rgb = pm.colorSliderGrp("colorPerVertexColor", q=1, rgb=1)
alpha = pm.floatSliderGrp("colorPerVertexAlpha", q=1, value=1)
rgb.append(alpha)
# NOTES(timmyliang): 组装颜色,过滤掉相应的通道。
# 获取 ui 的选项
sel = pm.radioButtonGrp(SINGLE_CONTROL, q=1, sl=1)
# 过滤颜色
color = filter_color(rgb, index=sel)
pm.artAttrPaintVertexCtx(PAINT_CTX, e=1, cl4=tuple(color))

  release 的时候恢复之前的 顶点色 颜色配置。

release 通道颜色同步

  最后还需要实现将绘制完的通道同步到其他的 color set 上的功能。
  因此 release 触发的时候要判断当前绘制的模式,如果绘制 rgb 就将颜色分解到对应的单通道上。
  相反如果是单通道绘制就要将颜色反馈到 rgb 的主 color set 上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
def apply_color_channel(cls):
index = pm.radioButtonGrp(cls.SINGLE_CONTROL, q=1, sl=1)
mode = cls.OPTION_ITEMS[index + 1]
is_rgb = mode == "RGB"
for node in cls.get_paint_nodes():
dag_path = node.__apimdagpath__()
mesh = OpenMaya.MFnMesh(dag_path)
color_sets = cls.get_color_sets(node)
main_color_set = color_sets[0]

current_color_set = mesh.currentColorSetName()

main_colors = OpenMaya.MColorArray()
mesh.getVertexColors(main_colors, main_color_set)
vtx_array = cls.vertex_color_data[node.fullPathName()]
final_colors = OpenMaya.MColorArray()

# NOTES(timmyliang): 如果当前绘制为非单通道
if is_rgb:
# NOTES(timmyliang): 将当前的主颜色 拆分到各个通道上
for channel_index, color_channel in enumerate(cls.CHANNELS):
final_colors.clear()
color_set = "VertexColor{0}".format(color_channel)
mesh.setCurrentColorSetName(color_set)
for vtx_index in vtx_array:
main_color = main_colors[vtx_index]
color = cls.filter_color(main_color, channel_index)
final_colors.append(color)
mesh.setVertexColors(final_colors, vtx_array)
else:
mode_index = cls.OPTION_ITEMS.index(mode) - 2
channel_colors = OpenMaya.MColorArray()
fix_colors = OpenMaya.MColorArray()
color_set = "VertexColor{0}".format(mode)
mesh.getVertexColors(channel_colors, color_set)
# NOTES(timmyliang): 获取单通道的颜色 回馈到主颜色上
for vtx_index in vtx_array:
channel_color = channel_colors[vtx_index]
main_color = main_colors[vtx_index]
color = cls.filter_color(channel_color, mode_index, main_color)
final_colors.append(color)
fix_color = cls.filter_color(channel_color, mode_index)
fix_colors.append(fix_color)

mesh.setVertexColors(fix_colors, vtx_array)
mesh.setCurrentColorSetName(main_color_set)
mesh.setVertexColors(final_colors, vtx_array)

mesh.setCurrentColorSetName(current_color_set)

  通过上面的方式就可以每次绘制完之后同步顶点色到对应的 color set 上。

Maya UI 修改 & 扩展

image

  Maya 有个非常好的设计是 UI 使用过 mel 脚本组装的,这样不需要编译就可以改动 UI,而且这部分的 mel 脚本都是开源的。
  可以很清楚地知道 Maya 是如何组装出相应工具的界面。

C:\Program Files\Autodesk\Maya2018\scripts\others\artAttrColorPerVertexProperties.mel

  Maya 的颜色笔刷是通过上面路径的 mel 脚本实现的。
  这样可以找到对应 UI 的名字

image

  如上图所示,可以找到 artAttrColorChannelChoices 的名字。
  然后用 cmds 命令可以对这些 UI 进行二次修改。

1
2
from maya import cmds
cmds.radioButtonGrp('artAttrColorChannelChoices',e=1,gbc=[255,0,0])

image

  比如执行上面的代码可以修改相应 UI 的背景颜色。


  上面已经展示了如何修改原生的 UI
  这些操作需要学习 Mel 的 UI 构建方式,会有点复杂。

  不过 Mel 的 example 都有案例,比如这里的UI 使用了 columnLayout
  那我可以去到 columnLayout 的文档运行案例代码进行学习。

image

  将代码放到代码编辑器执行。

image

  查了一下 columnLayout 的 API ,发现它竟然没有 insert 功能。
  于是我找了一下 Mel Tips大全的网站 MEL How-To (上古网站,但对学习Mel很有帮助)

image

  可以找到一个 链接 如何实现UI的置顶插入。

image

  方案一使用 frameLayout 比较繁琐
  方案二则是使用一个新的 Layout 然后将旧 Layout 的 UI 删除掉。
  这个方法删除 UI 对我想要的效果并不适用。

  不过倒是启发了我,我想到了可以利用 childArray 可以拿到 Layout 下所有的 Control 名字。
  然后对每个 Control 修改 parent 到新的 Layout 上。

使用 cmds 嵌入 UI

1
2
3
4
5
6
7
8
9
10
from maya import cmds
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
print(parent)
# ToolSettings|MainToolSettingsLayout|tabLayout1|artAttrColorPerVertex|artCommonOperationFrame|columnLayout1061|columnLayout1065
window = cmds.window()
column_layout = cmds.columnLayout()
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)

cmds.showWindow(window)

  上面的想法写成代码如上所示

image

  直接实现了 UI 的乾坤大挪移
  只是显示上有些不一样,主要原因是 mel 构建 UI 的时候使用 setUITemplate

1
2
3
4
5
6
7
8
9
10
11
from maya import cmds
window = cmds.window()
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)

column_layout = cmds.columnLayout()
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)

cmds.setUITemplate(popTemplate=1)
cmds.showWindow(window)

image

  加上了 OptionsTemplate 之后 UI 的显示就保持一致了
  所以在 parent control 的过程中加入自己的 UI ,就可以实现对应位置的嵌入效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
from maya import cmds
window = cmds.window()
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)

column_layout = cmds.columnLayout()
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)
if control == "artAttrColorChannelChoices":
cmds.button(label="click me")

cmds.setUITemplate(popTemplate=1)
cmds.showWindow(window)

image

  比如上面的效果,如此就可以在相应的位置嵌入任意的 UI

  最后是怎么将 UI 嵌入到原本的位置,关键就是使用 setParent 命令

1
2
3
4
5
6
7
8
9
10
11
12
from maya import cmds
parent = cmds.radioButtonGrp('artAttrColorChannelChoices',q=1,parent=1)
cmds.setParent(parent)
cmds.setUITemplate("OptionsTemplate", pushTemplate=1)
column_layout = cmds.columnLayout()

for control in cmds.layout(parent,q=1,childArray=1):
cmds.control(control, e=1, p=column_layout)
if control == "artAttrColorChannelChoices":
cmds.button(label="click me")

cmds.setUITemplate(popTemplate=1)

image

  如此就可以了, setParent 会将当前 UI 创建设置到之前的 Layout 下。

使用 Qt 嵌入 UI

  既然 cmds 可以实现 UI 嵌入,那能否利用 Qt API 来实现这个效果呢?

  我也想过将 Layout 转成 Qt Object 的方式进行调用。
  但这个方式获取到的是 QLayout 无法使用 insertWidget 插入,倒是可以使用 addWidget

1
2
3
4
5
6
7
8
9
10
import pymel.core as pm
from Qt import QtWidgets
widget = pm.uitypes.toQtObject("artAttrColorChannelChoices")
parent = widget.parent()
print(parent.objectName())
# columnLayout1065 objectName 和 mel 的 controlName 是一样的
layout = parent.layout()
print(layout)
# <PySide2.QtWidgets.QLayout object at 0x0000014DB0917648>
layout.addWidget(QtWidgets.QPushButton("asd"))

image

  利用上面的方式就可以在 Layout 的最末端添加一个按钮。
  上面使用了 toQtObject 的 pymel API
  背后调用 OpenMayaUI 库通过 objectName 查找到对应的 Qt 组件,然后 wrapInstance 将组件转换为 QObject 类型。
  用 pymel 的方式比较方便。

  要实现 insert 的效果可以利用 takeAt API 将 widget 提取出来再放回去。

1
2
3
4
5
6
7
8
9
10
import pymel.core as pm
from Qt import QtWidgets
widget = pm.uitypes.toQtObject("artAttrColorChannelChoices")
parent = widget.parent()
layout = parent.layout()
index = layout.indexOf(widget)
widget_list = [layout.takeAt(0).widget() for _ in range(layout.count())]
widget_list.insert(index,QtWidgets.QPushButton("click me"))
for widget in widget_list:
layout.addWidget(widget)

image

  如上所示,也完全实现了 cmds 库一样的效果,使用 Qt API 就比 cmds 要灵活很多。
  可以嵌入 Designer 生成的 Widget 等等。

  这里只是展望了一下,我的实现还是基于 cmds 的方式。

总结

  我的工具已经做成了 Maya 插件,启用按照插件的方式加载即可。

Maya CurveBrush 笔刷开发

作者 智伤帝
2022年8月9日 15:01

前言

  最近因为比较特殊的原因,工作上突然闲下来了,于是我就去研究了我们组做的 Maya 毛发工具。
  学习一下 Maya 做笔刷有哪些坑。

  这次我的主要目的是模仿 XGen 的毛发笔刷效果,通过最小案例的实现,探讨不同的实现方案。

官方文档: XGen Interactive Grooming

  上面的视频就是 XGen 实现的笔刷效果,对于毛发制作非常丝滑好用。
  只可惜这个笔刷不能对曲线直接生效。

  上面是我用 C++ 写的曲线笔刷,下面我也会来探讨如何用 Python OpenMaya 结合 Qt 开发笔刷的流程。
  具体代码已经开源到 https://github.com/FXTD-ODYSSEY/Maya-CurveBrush
  C++ 插件提供了 2020 - 2023 支持
  Python 插件有 om1_curve_brush.pyom2_curve_brush.py

Maya C++ 笔刷开发流程

Maya C++ MPxContext

  什么是 Maya Context ? 官方文档说明
  Maya Context 就是一个开放的接口,可以用于自定义 鼠标 在 Viewport 上执行的逻辑,实现 绘制 修改选择物体 等操作。

MPxContext 官方文档

  上面的链接是一个 Maya Devkit 里面的案例 devkit\plug-ins\marqueeTool\marqueeTool.cpp
  Maya CMake 构建 C++ 插件编译环境 我的这篇文章有提到如何将 devkit 的源码编译生成 mll

maya2020 - marqueeTool.mll

  这里提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.marqueeToolContext()
cmds.setToolTo(ctx)

  加载mll 插件后,可以使用上面的代码激活 Context

  上面实现的效果和默认的 框选物体是一样的。
  只是框的颜色变成了自定义的 黄色。


  实现这个 context 需要继承实现两个类,一个是 MPxContext 另一个是 MPxContextCommand
  MPxContext 类定义了鼠标拖拽 移动 等逻辑的虚函数,MPxContextCommand 则是用来读取 MPxContext 数据的 Mel 命令。
  通过 MPxContextCommand 就可以用 Mel 命令来修改 MPxContext 的变量(比如笔刷大小之类的)

MPxContextCommand 官方文档

Maya C++ MPxToolCommand

MPxToolCommand 官方文档

  上面提到的方案 Context 进行处理的时候是没有 Undo 功能的。
  因此 Maya C++ 提供了 MPxToolCommand 这样将需要 undo 的逻辑放到 Command 当中实现,就可以 undo redo 操作了。
  上面文档的案例来自于 devkit\plug-ins\helixTool\helixTool.cpp

image
maya2020 - helixTool.mll

  这里照样提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.helixToolContext()
cmds.setToolTo(ctx)

  加载mll 插件后,可以使用上面的代码激活 Context

image

  需要注意这个插件只能在旧的 Viewport 生效 (我测了好久才明白过来)

  这个工具可以在 Maya Viewport 拖拽一个 圆柱预览 ,这个圆柱最后生成 螺旋线。
  通过 MPxToolCommand 的方式就可以让生成的 螺旋线 支持undo。


  为何这个工具不能在 viewport2.0 下使用

image

  从上面的 API 列表可以看到 doDrag doPress 好几个 API 都有两个实现。
  一个是只传入 event 的,这个方法只在 老 Viewport 下调用。
  Viewport2.0 调用的是传入 MUIDrawManager 的方法。
  helixTool 没有实现 MUIDrawManager 的方法,所以 Viewport2.0 下不起作用。

Tool Contexts 官方文档

  官方文档被打散到 Viewport2.0 的目录下了,具体的说法可以参照上面

笔刷工具的 UI

Tool property sheets 官方文档

<>Properties.mel 实现左侧的可修改界面
<>Values.mel 获取笔刷数值 (更新到界面上)

  Context 激活之后,双击可以看到工具界面

image

  这个界面就是遵循上面两个 mel 的方法来实现的。


image

  可以继续参考 helixTool 的源码目录,它提供了 helixProperties.melhelixValues.mel 脚本
  那么上面的命名 <> 是怎么决定的,为啥用 helixProperties 而不是 helixToolProperties

image

  其实这是 getClassName 决定的。
  mel脚本并不是重点,双击 Context 调用的是 helixProperties helixValues 两个 mel 方法,如果找不到才会找同名脚本。

用 Python 生成 Mel Proc

  如果要编写自定义的 UI,一定要用 mel 才能编写吗?
  能否用 Python 解决问题呢?

Python function as a MEL procedure 官方文档

image

  如果嫌弃使用 mel 确实可以参考上面的链接用 Python 创建的 Mel Proc

C:\Program Files\Autodesk\Maya2020\Python\Lib\site-packages\maya\mel\melutils.py
具体的代码实现可以通过上面的路径找到。

image

  我尝试了一下,默认 returnCmd 是 False 会打开文件窗口生成出 mel 脚本。
  可以设置 returnCmd=True 这样就返回 mel 代码了。
  后面可以用 mel.eval 来执行返回的代码

  就是传入的Python function 如果不在 Python 模块之下会弹出警告


py2melProc 文档

  pymel 库也提供了 py2mel 的方法
  使用这个方法会比 Maya 内置的处理好一些
  实现的原理基本一致,都是通过 Python 构建出 Mel 代码,
  Mel 代码本质就是用 python 关键字执行 Python 代码 (一会 Python 一会 Mel 的似乎挺绕的(:з」∠))

  pymel 还提供了 mel2pyStr 的方法可以直接将 mel 代码转成 Python 的版本。
  这样就可以避免 python 和 mel 混写。

1
2
3
4
5
6
from pymel.tools import mel2py
path = r"C:\Program Files\Autodesk\Maya2018\scripts\others\customtoolPaint.mel"
with open(path,'r') as rf:
content = rf.read()
py_str = mel2py.mel2pyStr(content, pymelNamespace="pm")
print(py_str)

  比如上面就可以将一些内置的 mel 案例转换成 python 版本。
  pymelNamespace 可以给所有的调用加上相应的前缀。

  利用上面的方法就可以将 helixTool 的 mel 脚本转为 Python 实现

image

  转换完成之后需要注意 function 调用,要将 pm.mel 去掉
  因为之前 proc 编程 Python function 用 pm.mel.helixSetCallbacks 是调用不了的。

image

  另外一些变量名 mel 里面可能命名为了 set ,如果这些是 Python 的关键字或者内置命名需要注意。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import pymel.core as pm
from pymel.tools import py2mel

def helixProperties():
pm.setUITemplate("DefaultTemplate", pushTemplate=1)
parent = str(pm.toolPropertyWindow(q=1, location=1))
pm.setParent(parent)
pm.columnLayout("helix")
pm.tabLayout("helixTabs", childResizable=True)
pm.columnLayout("helixTab")
pm.frameLayout("helixFrame", cll=True, l="Helix Options", cl=False)
pm.columnLayout("helixOptions")
pm.separator(style="none")
pm.intSliderGrp(
"numCVs", field=1, minValue=20, maxValue=100, value=1, label="Number of CVs"
)
pm.checkBoxGrp("upsideDownGrp", numberOfCheckBoxes=1, l1=" ", label="Upside Down")
pm.setParent("..")
# helixOptions
pm.setParent("..")
# helixFrame
pm.setParent("..")
# helixTab
pm.setParent("..")
# helixTabs
pm.setParent("..")
# helix
# Name the tabs; -tl does not allow tab labelling upon creation
pm.tabLayout("helixTabs", tl=("helixTab", "Tool Defaults"), e=1)
pm.setUITemplate(popTemplate=1)
helixSetCallbacks(parent)

def helixSetCallbacks(parent):
pm.setParent(parent)
pm.checkBoxGrp(
"upsideDownGrp",
e=1,
on1=lambda *args: pm.helixToolContext(pm.currentCtx(), upsideDown=True, e=1),
of1=lambda *args: pm.helixToolContext(pm.currentCtx(), upsideDown=False, e=1),
)
pm.intSliderGrp(
"numCVs",
e=1,
cc=lambda *args: pm.helixToolContext(pm.currentCtx(), numCVs=args[0], e=1),
)

def helixValues(toolName):
parent=(str(pm.toolPropertyWindow(q=1, location=1)) + "|helix|helixTabs|helixTab")
pm.setParent(parent)
icon="helixTool.xpm"
help=""
pm.mel.toolPropertySetCommon(toolName, icon, help)
pm.frameLayout('helixFrame', en=True, e=1, cl=False)
helixOptionValues(toolName)
pm.mel.toolPropertySelect('helix')

def helixOptionValues(toolName):

cv_num = 0
cv_num=int(pm.mel.eval("helixToolContext -q -numCVs " + toolName))
pm.intSliderGrp('numCVs', e=1, value=cv_num)
cv_num=int(pm.mel.eval("helixToolContext -q -upsideDown " + toolName))
if cv_num:
pm.checkBoxGrp('upsideDownGrp', e=1, value1=1)
else:
pm.checkBoxGrp('upsideDownGrp', e=1, value1=0)

py2mel.py2melProc(helixProperties, procName="helixProperties")
py2mel.py2melProc(helixValues, procName="helixValues")

  经过一些修改之后,可以实现用 Python 的方式来编写 Mel Proc。
  只是还是需要熟悉一下 mel UI 构建的语法。

Maya C++ CurveBrush

  通过上面一番探讨之后,我们理清楚了做一个笔刷需要什么。

image

  所以我在 C++ 代码层面拆分三个头文件,分别对应 MPxContext MPxContextCommand MPxContextToolCommand 的实现。
  如何开发也可以参考 helixTool 的代码。

image

  注册插件的时候需要同时注册 MPxContextCommandMPxContextToolCommand
  这样 Maya 就知道这两个命令是关联在一起的, MPxContext 里面调用 newToolCommand 方法就可以获取到 MPxContextToolCommand

笔刷属性调整

  我先要让笔刷按住 B 键的时候可以实现 大小 调整。
  默认 Maya API 没有提供键盘事件的监听。
  于是查找官方的案例,找到了 devkit\plug-ins\grabUVMain.cpp

maya2020 - grabUV.mll

  这里提供 Maya2020 windows 版本的 mll 插件
  Maya 加载 mll 插件

1
2
3
import maya.cmds as cmds
ctx = cmds.grabUVContext()
cmds.setToolTo(ctx)

  这个插件可以按住 B 键调整笔刷的大小。
  原理是利用 Qt 的 eventFilter 监听全局的键盘响应,所以编译的 include 路径需要有 Qt 的头文件,默认的 include 路径只有 Qt 头文件压缩包,需要解压缩来索引。

  所以我也是用同样的方式监听是否有按 B 键。
  左键拖拽调整笔刷大小,中键拖拽调整笔刷强度。

曲线衰变颜色

image

  笔刷覆盖的范围呈现颜色,这个是用 Viewport2.0MUIDrawManager 实现的。

image
MUIDrawManager

  MUIDrawManager 提供了 mesh 的 API 进行曲线模型等的绘制。
  最重要的第一点是可以传入颜色数组,根据每个点自定义颜色,其他的 line API 无法实现这个功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
MStatus curveBrushContext::doPtrMoved(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
short x, y;
event.getPosition(x, y);
mBrushCenterScreenPoint = MPoint(x, y);
auto radius = mBrushConfig.size();

drawMgr.beginDrawable();
if (bFalloffMode)
{
for (unsigned int index = 0; index < objDagPathArray.length(); ++index)
{
MPointArray pointArray;
MColorArray colorArray;
MFnNurbsCurve curveFn(objDagPathArray[index]);
unsigned int segmentCount = 100;
for (unsigned int pointIndex = 0; pointIndex < segmentCount; ++pointIndex)
{
MPoint point;
auto param = curveFn.findParamFromLength(curveFn.length() * pointIndex / segmentCount);
curveFn.getPointAtParam(param, point, MSpace::kWorld);
pointArray.append(point);

// NOTE(timmyliang): draw falloff
short x_pos, y_pos;
view.worldToView(point, x_pos, y_pos);
MPoint screenPoint(x_pos, y_pos);
auto distance = (mBrushCenterScreenPoint - screenPoint).length();
auto field = 1 - distance / radius;
// NOTE(timmyliang): transparent
colorArray.append(distance > radius ? MColor(0.f) : MColor(field, field, field));
}

drawMgr.setLineWidth(12.0f);
drawMgr.mesh(MHWRender::MUIDrawManager::kLineStrip, pointArray, NULL, &colorArray);
}
}

drawMgr.setColor(MColor(1.f, 1.f, 1.f));
drawMgr.setLineWidth(2.0f);
drawMgr.circle2d(mBrushCenterScreenPoint, radius);

drawMgr.endDrawable();
return MS::kSuccess;
}

  那么问题就变成怎么获取顶点上色了,如果曲线的顶点数量很少就很难有好的显示效果。

  因此这里使用 findParamFromLength getPointAtParam 的方式重新采样曲线的顶点。
  对采样的顶点再判断一下是否在笔刷的圆圈范围内,范围外的附上透明的颜色,范围内的根据距离附上黑白色。

曲线 CV 移动

  首先要获取 drag 偏移的向量。
  通过 doPress 方法可以获取到点击的时候的向量偏移。
  再通过 doDrag 获取拖拽的时候鼠标的位置。
  两个位置坐标就可以得到偏移的向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
MStatus curveBrushContext::doPress(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
view = M3dView::active3dView();
event.getPosition(startPosX, startPosY);
fStartBrushSize = mBrushConfig.size();
fStartBrushStrength = mBrushConfig.strength();

return MS::kSuccess;
}

MStatus curveBrushContext::doDrag(MEvent &event, MHWRender::MUIDrawManager &drawMgr, const MHWRender::MFrameContext &context)
{
view.refresh(false, true);
short currentPosX, currentPosY;
event.getPosition(currentPosX, currentPosY);
auto currentPos = MPoint(currentPosX, currentPosY);

MPoint start(startPosX, startPosY);
MVector delta = MVector(currentPos - start);

drawMgr.beginDrawable();
drawMgr.setColor(MColor(1.f, 1.f, 1.f));
drawMgr.setLineWidth(2.0f);
// NOTE(timmyliang): hold down `B` key
if (eDragMode == kBrushSize)
{
float deltaValue;
char info[64];
// NOTES(timmyliang): left mouse for size
if (event.mouseButton() == MEvent::kLeftMouse)
{
deltaValue = delta.x > 0 ? delta.length() : -delta.length();
mBrushConfig.setSize(fStartBrushSize + deltaValue);
sprintf(info, "Brush Size: %.2f", mBrushConfig.size());
drawMgr.text2d(currentPos, info);
}
// NOTES(timmyliang): middle mouse for strength
else if (event.mouseButton() == MEvent::kMiddleMouse)
{
deltaValue = delta.y > 0 ? delta.length() : -delta.length();
mBrushConfig.setStrength(fStartBrushStrength + deltaValue);
sprintf(info, "Brush Strength: %.2f", mBrushConfig.strength());
drawMgr.text2d(currentPos, info);
}
drawMgr.line2d(start, MPoint(startPosX, startPosY + mBrushConfig.strength() * 2));
}
else
{
MPoint startNearPos, startFarPos, currNearPos, currFarPos;
view.viewToWorld(currentPosX, currentPosY, currNearPos, currFarPos);
view.viewToWorld(startPosX, startPosY, startFarPos, startFarPos);
// NOTE(timmyliang): use tool command for undo
curveBrushTool *cmd = (curveBrushTool *)newToolCommand();
cmd->setStrength(mBrushConfig.strength());
cmd->setRadius(mBrushConfig.size());
cmd->setMoveVector((currFarPos - startFarPos).normal());
cmd->setStartPoint(start);
cmd->setDagPathArray(objDagPathArray);
cmd->redoIt();
cmd->finalize();
}

drawMgr.circle2d(start, mBrushConfig.size());
drawMgr.endDrawable();
return MS::kSuccess;
}

  doDrag 还会判断是否按住 B 键,按住的话就调整笔刷的大小。
  反之则调用 newToolCommand 执行 CV 移动的逻辑


  ToolCommand 会获取曲线上 CV 点的位置,将空间坐标转为屏幕坐标。
  这样可以判断这些 CV 点是否在笔刷范围内。
  如果在范围的 CV 点根据笔刷提供的方向进行偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
MStatus curveBrushTool::redoIt()
{

MVector offsetVector = moveVector * 0.002 * strength;
M3dView view = M3dView::active3dView();

// NOTE(timmyliang): move curves cv in radius
short x_pos, y_pos;
for (unsigned int index = 0; index < dagPathArray.length(); ++index)
{
MFnNurbsCurve curveFn(dagPathArray[index]);
std::map<int, MVector> offsetMap;
for (MItCurveCV cvIter(dagPathArray[index]); !cvIter.isDone(); cvIter.next())
{
MPoint pos = cvIter.position(MSpace::kWorld);
int cvIndex = cvIter.index();
curvePointMap[index][cvIndex] = pos;
view.worldToView(pos, x_pos, y_pos);
if ((startPoint - MPoint(x_pos, y_pos)).length() < radius)
{
offsetMap[cvIndex] = pos + offsetVector;
}
}
for (const auto &it : offsetMap)
{
curveFn.setCV(it.first, it.second, MSpace::kWorld);
}
curveFn.updateCurve();
}
return MStatus::kSuccess;
}

  通过 MItCurveCV 遍历曲线上所有的 CV 点。
  利用 setCV 方法可以实现顶点的偏移
  C++ 这边我发现不能在 MItCurveCV 的遍历过程中调用 setCV ,它会导致遍历中断。
  但是用 MItCurveCV 提供的 setCVPosition 无法实现位置的刷新。
  最后只好将 CV序号 和 位置通过 Map 保存起来,通过 setCV API 去偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MStatus curveBrushTool::undoIt()
{
// NOTE(timmyliang): reset point position
for (const auto &kv : curvePointMap)
{
MFnNurbsCurve curveFn(dagPathArray[kv.first]);
for (const auto &it : kv.second)
{
int cvIndex = it.first;
MPoint pos = it.second;
curveFn.setCV(cvIndex, pos, MSpace::kWorld);
}
}

return MStatus::kSuccess;
}

  通过 curvePointMap 变量保存了上一次所有 CV 点的位置,undo 只要遍历这个字典去重置 CV 位置即可。

OpenMaya 2.0 笔刷开发

  既然 C++ 可以开发出如上看到的笔刷,理论上也可以通过 Python OpenMaya 库进行笔刷开发。
  但是我发现 OpenMaya 1.0 不支持 Viewport 2.0 的 API,比如上面关键的 MUIDrawManager
  在 OpenMaya1.0 下是不不存在的。

1
2
3
4
5
from maya import OpenMayaRender
OpenMayaRender.MUIDrawManager
# Error: AttributeError: file <maya console> line 2: 'module' object has no attribute 'MUIDrawManager' #
from maya.api import OpenMayaRender
OpenMayaRender.MUIDrawManager

  可以看到 OpenMaya 2.0 才有 MUIDrawManager

https://matiascodesal.com/blog/maya-python-api-20-it-ready-yet/

  以前 18 年的时候还看到有人了文章介绍 OpenMaya 2.0 到底是否可以已经完善了。
  OpenMaya 2.0 与 OpenMaya 1.0 相比还缺了挺多的 C++ 类的。
  而且 OpenMaya2.0 的案例都有一些代码错误,比如 plug-ins\python\api2\py2LassoTool.py (已经是 2023 的最新版本了)
  这实在是令人失望,脚本的第 224 行有明显 true 使用不当,并且 MItCurveCV 这个类 OpenMaya 2.0 不支持的。
  我启用这个脚本框选 CV 点直接给我报错(:з」∠)
  也因为 OpenMaya 2.0 各种不完善, 👨‍💻mottosso 大佬才会做自己的 Pyd wrapper 封装 C++ API cmdc ,只是目前的进度还需要更多人加入支持开发。


  那是用 OpenMaya 2.0 能否完成我上面的 C++ 曲线笔刷的复刻呢?
  我查了一下,发现 Maya 2020 之后添加了 MPxToolCommand 命令,似乎可以实现和 C++ 一样的 undo 命令。
  然而我的实测却让我非常失望。

https://github.com/FXTD-ODYSSEY/Maya-CurveBrush/blob/main/plug-ins/om2_curve_brush.py

MPxContextCommand 缺失 syntax parser 方法

  基于 OpenMaya 2.0 版本的插件我已经写完了,只是被它的不完整气得不轻。
  首先 MPxContextCommand 缺失了 syntax parser 方法
  即便提供了 doQueryFlags doEditFlags 的 API 但是没法和 C++ 一样进行调用,但是 OpenMaya 1.0 提供了 _syntax _parser 方法给 Python 调用。

registerContextCommand 不支持 MPxToolCommand 注册

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def initializePlugin(plugin):
pluginFn = om.MFnPlugin(plugin)
try:
pluginFn.registerContextCommand(CONTEXT_NAME, CurveBrushContextCmd.creator)
# TODO(timmyliang): not support MPxToolCommand registered
# pluginFn.registerContextCommand(
# CONTEXT_NAME,
# CurveBrushContextCmd.creator,
# CONTEXT_TOOL_NAME,
# CurveBrushTool.creator,
# CurveBrushTool.newSyntax,
# )
except:
sys.stderr.write("Failed to register command: %s\n" % CONTEXT_NAME)
raise

  OpenMaya 2.0 终于在 Maya 2020 提供了 MPxToolCommand 的接口。
  但是 MPxToolCommand 需要通过 registerContextCommand 来注册进去。
  但是它目前不支持 5 个参数的调用,导致 MPxToolCommand 无法注册。

1
# Error: TypeError: file F:/repo/CMakeMaya/modules/Maya-CurveBrush/plug-ins/om2_curve_brush.py line 448: function takes exactly 2 arguments (5 given) # 

  注册的时候会提示 registerContextCommand 只接受两个参数。

MPxToolCommand doFinalize 无法传入参数

1
2
3
4
5
6
7
8
9
10
class CurveBrushTool(omui.MPxToolCommand):
def finalize(self):
command = om.MArgList()
command.addArg(self.commandString)
for flag, config in self.flags_data.items():
long_flag = config.get("long")
command.addArg(flag)
command.addArg(getattr(self, long_flag[1:]))
# TODO(timmyliang): not accept the command argument
# return self.doFinalize(command)

  虽然 registerContextCommand 无法注册 MPxToolCommand 导致 newToolCommand 没有正常的返回。
  但我可以单独实例化 MPxToolCommand 从而实现 undo
  可是还是不行,而且这个坑爹的情况明显是官方的问题。
  doFinalize 明明可以接受一个 MArgList 类型的参数,但是这个 Python 函数却不接受任何参数(:з」∠)

OpenMaya 2.0 展示

  虽然 2.0 有上述的诸多问题,笔刷的基础功能还是可以实现的。
  只是 undo 功能解决不了,倒是可以将曲线的 tweak 操作转移到另一个 Command 上从而实现 undo 的。
  不过我这里就点到为止,主要踩了 OpenMaya 2.0 的坑,对它好感度降低了不少(:з」∠)

Python Qt Overlay 实现自定义绘制

  上面提到了 OpenMaya 1.0 缺失了 MUIDrawManager 所以无法在 Viewport 2.0 下进行图像绘制。

image

  C++ 文档也注明了带 MUIDrawManager 是无法在 Python 下使用的。
  我也测试了不传入 MUIDrawManager 的几个方法,他们只能在 Legacy Viewport 下响应触发。

  那还有什么方法不用 C++ 也可以实现 Python 的绘制呢?
  这就可以参考一个非常棒的 Maya Python 工具 spore

  spore 也实现了自己的笔刷工具,并且对低版本 Maya 兼容。
  它的做法不是通过 Maya API 实现,而是利用 Qt 的 API 进行绘制。
  首先对 Maya 的 Viewport 叠加一层透明的 QWidget 层,通过 paintEvent 的实现,绘制自定义图形叠加到 Viewport 上。

  实现效果如上图,基本和 Maya API 的绘制效果很接近。

Overlay 组件实现

  组件叠加的方案我之前的文章也有过 Unreal Python 路径定位启动器
  核心思路就是取消 Widget 的边框,忽略输入影响,透明化背景并且永远保持在最前面。

1
2
3
4
5
6
7
8
9
10
11
12
class CanvasOverlay(QtWidgets.QWidget):
def __init__(self, context):
# type: (CurveBrushContext) -> None
super(CanvasOverlay, self).__init__()
self.setWindowFlags(
QtCore.Qt.FramelessWindowHint
| QtCore.Qt.SplashScreen
| QtCore.Qt.WindowStaysOnTopHint
| QtCore.Qt.WindowTransparentForInput
)
self.setAttribute(QtCore.Qt.WA_TranslucentBackground)
self.setAttribute(QtCore.Qt.WA_NoSystemBackground)

  这样就是一个无边框透明的窗口,如果不加上颜色用户是无感知的。

spore 参考

多个 Viewport 叠加支持


注: 这里的 Overlay 加上了大色块方便观察。

>   我添加了多个 Viewport 的 Overlay 支持,spore 默认是只对笔刷激活时的 Viewport 进行 Overlay 操作。
>   如果切换到多视图或者单独的 Viewport 窗口就会让 Overlay 显示不正常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class AppFilter(QtCore.QObject):
def __init__(self, canvas):
# type: (CurveBrushContext) -> None
super(AppFilter, self).__init__()
self.canvas = canvas

def eventFilter(self, receiver, event):
if event.type() == QtCore.QEvent.MouseButtonPress:
widget = QtWidgets.QApplication.widgetAt(QtGui.QCursor.pos())
panel = isinstance(widget, QtCore.QObject) and widget.parent()
name = panel and panel.objectName()
if name:
is_model_editor = cmds.objectTypeUI(name, i="modelEditor")
self.canvas.setVisible(is_model_editor)
if is_model_editor:
QtCore.QTimer.singleShot(0, self.canvas.setup_active_viewport)
return super(AppFilter, self).eventFilter(receiver, event)

class CurveBrushContext(OpenMayaMPx.MPxContext):
# 省略 ...
def toolOnSetup(self):
self.canvas = CanvasOverlay(self)
# NOTES(timmyliang): 获取 QApplication 进行监听
app = QtWidgets.QApplication.instance()
app_filter = AppFilter(self.canvas)
app.installEventFilter(app_filter)


>   我这里的做法是利用 toolOnSetup API ,激活笔刷的时候监听 Maya QApplication 全局的点击事件
>   如果点击的 Widget 是 modelEditor 就将 overlay 同步过去。
>   Qt 的 objectName 就是 Maya 的 UI control Name ,所以从 objectName() 获取的 API 可以直接用 objectTypeUI 判断类型
>   利用这个方法任何 Viewport 点击都可以直接 resize Overlay 上去。

>   本来不想搞得那么复杂的,但是 Maya 原生的监听方案不起作用 stackoverflow
>   stackoverflow 的回答是使用 timer 定时触发,也不是很理想,所以还是借助 Qt API 监听鼠标按键的方法最好。

### 监听 Viewport 事件

>   正如上面所说的 doDrag doPress 等一系列 API 在 Viewport 2.0 下是失效的。
>   通过 Qt 的 installEventFilter 可以实现对 Viewport 的事件监听。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import shiboken2
import maya.OpenMaya as om
import maya.OpenMayaUI as omui

from PySide2.QtWidgets import QWidget
from PySide2.QtCore import QObject

def active_view():
""" return the active 3d view """
return omui.M3dView.active3dView()

def active_view_wdg():
""" return the active 3d view wrapped in a QWidget """
view = active_view()
active_view_widget = shiboken2.wrapInstance(long(view.widget()), QWidget)
return active_view_widget


spore 参考

>   通过 OpenMaya 1.0 的 API 可以获取当前激活的 Viewport QWidget
>   拦截这个 Viewport QWidget 的事件可以实现鼠标点击拖拽等等的响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

class MouseFilter(QtCore.QObject):
wheel = QtCore.Signal(QtCore.QEvent)
moved = QtCore.Signal(QtCore.QEvent)
clicked = QtCore.Signal(QtCore.QEvent)
dragged = QtCore.Signal(QtCore.QEvent)
released = QtCore.Signal(QtCore.QEvent)
entered = QtCore.Signal()
leaved = QtCore.Signal()

def __init__(self, *args, **kwargs):
super(MouseFilter, self).__init__(*args, **kwargs)
self.is_clicked = False

def eventFilter(self, receiver, event):
event_type = event.type()
if event_type == QtCore.QEvent.MouseMove:
self.moved.emit(event)
if self.is_clicked:
self.dragged.emit(event)
elif (
event_type == QtCore.QEvent.MouseButtonPress
or event_type == QtCore.QEvent.MouseButtonDblClick
):
self.is_clicked = True
self.clicked.emit(event)
elif event_type == QtCore.QEvent.MouseButtonRelease:
self.is_clicked = False
self.released.emit(event)
elif event_type == QtCore.QEvent.Enter:
self.entered.emit()
elif event_type == QtCore.QEvent.Leave:
self.leaved.emit()
elif event_type == QtCore.QEvent.Wheel:
self.wheel.emit(event)

return super(MouseFilter, self).eventFilter(receiver, event)

viewport = active_view_wdg()
mouse_filter = MouseFilter()
viewport.installEventFilter(mouse_filter)


>   通过上面的方式就可以拦截 viewport 的 event 通过 MouseFilter 的信号槽做相应的触发。

### 绘制实现

image

>   参考上图可以看到,Qt API 基本上和 Maya API 绘制的效果差不多。
>   Maya API 的 MUIDrawManager 提供了 mesh API 来绘制复杂图形。
>   Qt API 并没有类似的方法,不过 Qt 也有 QGradient
>   通过 QLinearGradient 可以实现上面的效果。
>   同样地需要对曲线进行二次采样,提高分段数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def paintEvent(self, event):
self.draw_shape(self.create_brush_cricle(), QtCore.Qt.white, 2)
if self.is_press_B:
self.draw_shape(self.create_brush_line(), QtCore.Qt.white, 2)
self.draw_text(self._message_info)
for curve, data in self.color_data.items():
self.draw_shape(data.get("points"), data.get("colors"), 10)

return super(CanvasOverlay, self).paintEvent(event)

def create_brush_cricle(self, count=60):
shape = []
radius = self.radius
pt = self.start_pos if self.is_press_B else self.current_pos
for index in range(count + 1):
theta = math.radians(360 * index / count)
pos_x = pt.x() + radius * math.cos(theta)
pos_y = pt.y() + radius * math.sin(theta)
shape.append(QtCore.QPointF(pos_x, pos_y))
return shape

def create_brush_line(self):
shape = []
start_pt = self.start_pos if self.is_press_B else self.current_pos
shape.append(start_pt)
shape.append(QtCore.QPoint(start_pt.x(), start_pt.y() - self.strength))
return shape

def draw_shape(self, line_shapes, colors, width=1):
if len(line_shapes) < 2:
return
colors = colors or QtCore.Qt.white
painter = QtGui.QPainter(self)

painter.setRenderHint(painter.Antialiasing)
painter.begin(self)

if (
isinstance(colors, Iterable)
and not isinstance(colors, six.string_types)
and len(colors) == len(line_shapes)
):
# NOTES(timmyliang): paint falloff
for index, point in enumerate(line_shapes[:-1]):
start_point = point
end_point = line_shapes[index + 1]
grandient_color = QtGui.QLinearGradient(start_point, end_point)
start_color = colors[index]
end_color = colors[index + 1]
grandient_color.setColorAt(0, start_color)
grandient_color.setColorAt(1, end_color)
pen = QtGui.QPen(grandient_color, width)
pen.setCapStyle(QtCore.Qt.RoundCap)
pen.setJoinStyle(QtCore.Qt.RoundJoin)
painter.setPen(pen)
painter.drawLine(start_point, end_point)
else:
path = QtGui.QPainterPath()
path.moveTo(line_shapes[0])
[path.lineTo(point) for point in line_shapes]
color = QtGui.QColor(colors)
pen = QtGui.QPen(color, width)
painter.setPen(pen)
painter.drawPath(path)

painter.end()

def draw_text(self, text, pos=None, color=QtCore.Qt.white, width=1):
if not text:
return
painter = QtGui.QPainter(self)
pen = QtGui.QPen(color, width)
painter.setPen(pen)
pos = pos or self.current_pos + QtCore.QPoint(10, 0)
painter.drawText(pos, text)
painter.end()


>   上面是绘制用到的 一些 API
>   核心就是 draw_shape 里面如果传入了多个 color ,获取color每个顶点画一条渐变的线
>   多条线组合成圆形,由此有了衰变颜色的圆形曲线。

>   其他的绘制比如 绘制文字,Qt 有 drawText API
>   绘制圆圈可以利用 sin cos 数学函数来生成圆形的顶点进行绘制。


### 踩坑注意

>   QtCore.QPointOpenMaya.MPoint 两者的 Y 轴坐标起始不一样,所以通过 M3dView 将世界坐标转换为屏幕坐标的时候需要额外的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def world_to_view(position, invertY=True):
"""
convert the given 3d position to 2d viewport coordinates
"""
view = OpenMayaUI.M3dView.active3dView()
arg_x = OpenMaya.MScriptUtil(0)
arg_y = OpenMaya.MScriptUtil(0)

arg_x_ptr = arg_x.asShortPtr()
arg_y_ptr = arg_y.asShortPtr()
view.worldToView(position, arg_x_ptr, arg_y_ptr)
x_pos = arg_x.getShort(arg_x_ptr)
y_pos = arg_y.getShort(arg_y_ptr)

if invertY:
y_pos = view.portHeight() - y_pos

return (x_pos, y_pos)


spore 参考

## 基于 draggerContext 笔刷

Curve paint and tweak tool


  最后在 highend3d 里面也找到了一个直接 tweak CV 点的方案。
  这个方案采用 draggerContext 实现
  draggerContext 的案例就可以实现在 viewport 拖拽的时候实现回调。
  highend3d 的 ysd 曲线工具集还结合软选择的范围作为笔刷移动的范围参数,这是非常聪明的做法。
  也可以通过这个方式实现拖拽生成一条曲线。
  结合 OpenMaya API 可以做更多的事情,比如散布物体等等,用这个的方案比起 从零构建一个 MPxContext 要简单许多。

  绘制功能还是无法通过 draggerContext 解决,不过可以用上面 Qt Overlay 方案来解决。

ysv 工具优化

  从 highend3d 下载的 ysv 曲线工具可以用,但是有几个问题

  1. 直接调用 PySide 导致不兼容
  2. 没有做 Python3 兼容
  3. 部分代码在新版本代码下运行有 BUG

PySide 兼容

  将 PySide 的导入转成 Qt.py 的导入
  Qt.py 库的引入则是采用 submodule 的方式放到 scripts 目录下。

Python3 兼容

  Python3 兼容使用 Python内置的 lib2to3 库进行转换 参考链接

1
mayapy -m lib2to3 -w F:\repo\Maya-CurveBrush\scripts\ysv\ysvView.py

  通过这个方式就可以自动将所有的 print 括号加上等操作。省去繁琐的人工操作。
  我当时是写了一个脚本,批量执行,执行完之后调用 black 和 isort 风格化代码。

  生成完之后会将之前的文件加上 .bak 后缀,如果没有问题就可以把 bak 删除。

BUG 修复

  原代码获取当前摄像机是通过下面的方式

1
2
from pymel.core import *
modelEditor(getPanel(wf=1), e=1, nurbsCurves=1)

  但是如果当前 focus 的 panel 不是 modelEditor 就遭殃了。

1
2
3
4
5
from pymel.core import *
for mp in getPanel(type="modelPanel"):
if modelEditor(mp, q=1, av=1):
modelEditor(mp, e=1, nurbsCurves=1)
break

  所以我把代码改成上面的效果。


1
2
3
4
5
6
7
for crv in self.inViewCurves:
for cv in [crv.cv[0], crv.cv[-1]]:
cv = str(cv) # fix add here
setAttr(cv + ".xv", lock=1)
setAttr(cv + ".yv", lock=1)
setAttr(cv + ".zv", lock=1)

  用 pymel 获取 cv 点之后,直接将 NurbsCurveCV 与字符串相加
  但是 NurbsCurveCV 有自己的相加逻辑,所以这里需要加上字符串转换可以修复 BUG。

artisan 笔刷

  Maya 根据贴图在模型表面散列物体 以前也写过散列物体的文章,不过实现方式是非笔刷的。
  利用 artisan 就可以实现笔刷的方式散布物体了。

官方文档: Overview of MEL script painting

  官方提到有 spherePaint geometryPaint emitterPaint 几个案例。
  具体的代码可以在 mel 脚本库里面找到 eg: C:\Program Files\Autodesk\Maya2018\scripts\others\spherePaint.mel
  从最简单的 spherePaint 介绍

image

  在 Modify 页面下找到 Paint Scripts Tool 工具
  打开工具属性面板,在 Setup 标签页的 Tool setup cmd 输入 spherePaint 就可以激活笔刷

  激活工具之后就可以模型上刷 Sphere


  只可惜 artisan 笔刷它不响应 NurbsCurve ,只支持 mesh。
  所以无法实现上面探讨的 曲线笔刷 的功能。
  artisan 方案更适合颜色绘制或者是物体散布。

总结

  以上就是我发现的 Maya 笔刷的多种使用姿势。
  后面有机会可以再探讨一下 artisan 笔刷的关于顶点色编辑相关的内容。

Unreal C++ 工具开发最小实践

作者 智伤帝
2022年7月15日 16:00

前言

  Unreal 的学习浩瀚且博杂,有时候一个最小 Demo 就是很好的学习起点。
  想起我以前翻阅 UE 的源码一大堆的文件,看得我是无比头疼。
  偶然间发现 CSDN YakSue 写了好多篇 Unreal 工具开发的 介绍。
  虽然没有配上 Github 链接,但是源码都在文章里面体现了。
  对于工具开发的不同模块都大有裨益。
  于是我将这些内容整合到一起,并且详细讲解其中实现的核心点。

Custom Asset

https://yaksue.blog.csdn.net/article/details/107646900
https://github.com/FXTD-ODYSSEY/Unreal-Playground/tree/main/Plugins/Yaksue/TestAssetEditorPlg

  创建一个自定义的 Asset 需要有三个类

  1. Asset (UObject)
  2. AssetFactory (UFactory)
  3. AssetTypeActions (FAssetTypeActions_Base)

  Asset 描述对象本身的数据
  AssetFactory 描述如何创建对象
  AssetTypeActions 返回对象显示的信息

  AssetTypeActions 包含方法 GetName GetTypeColor GetSupportedClass GetCategories 用来描述对应的信息。
  GetCategories 会分配 Asset 所属的位置。

  这个方式默认打开的窗口是 Details Panel.
  如果想要自定义打开的窗口需要添加 FAssetEditorToolkit
  AssetTypeActions 添加 OpenAssetEditor 方法将 Toolkit 生成并初始化。

1
2
3
4
5
6
7
FAssetEditorToolkit
GetToolkitFName
GetBaseToolkitName
GetWorldCentricTabPrefix
GetWorldCentricTabColorScale
Initialize
RegisterTabSpawners

  RegisterTabSpawners 通过这个方法注册生产 Tab 的 ID
  后续通过 Initialize 方法调用 AddTab 将 Register 的 Tab 生成。
  最后通过 FAssetEditorToolkit::InitAssetEditor 完成 Toolkit 的初始化


  如果不想将 Asset 放到 EAssetTypeCategories::Misc 的分类中。
  也可以构建一个新的标签附上去。
  只是需要将 factory 相关的 GetMenuCategories 放入去掉。
  我之前没有去掉,一直很疑惑为啥自定义菜单没有生效。

1
2
3
4
5
6
7
8
9
10
11
FYaksueTestAssetTypeActions::FYaksueTestAssetTypeActions()
{
// NOTE: 注册新的分类
IAssetTools &AssetTools = FModuleManager::LoadModuleChecked<FAssetToolsModule>("AssetTools").Get();
AssetCategory = AssetTools.RegisterAdvancedAssetCategory(FName(TEXT("Custom Assets")), LOCTEXT("CustomAssetCategory", "Custom Assets"));
}

uint32 FYaksueTestAssetTypeActions::GetCategories()
{
return AssetCategory;
}

  构造函数注册新的分类,头文件需要添加上定义 FYaksueTestAssetTypeActions(); EAssetTypeCategories::Type AssetCategory;

Custom Filter

https://yaksue.blog.csdn.net/article/details/120929455
https://github.com/FXTD-ODYSSEY/Unreal-Playground/tree/main/Plugins/Yaksue/TestCustomFilter

  继承 UContentBrowserFrontEndFilterExtension 可以通过 override AddFrontEndFilterExtensions 方法扩展 filter。
  生成一个 FFrontendFilter 子类,然后通过 AddFrontEndFilterExtensions 将过滤对象添加到过滤列表里面。
  FFrontendFilter 最核心的方法就是 PassesFilter 它会将每个 item 传到这个函数返回 bool 来决定是否显示。

Slate

https://yaksue.blog.csdn.net/article/details/110084013

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// Put your tab content here!
SNew(SOverlay)
+ SOverlay::Slot()//底层
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(0.3f)//占30%
[
SNew(SButton)1
]
+ SHorizontalBox::Slot().FillWidth(0.7f)//占70%
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().FillHeight(0.5f)//占50%
[
SNew(SButton)
]
+ SVerticalBox::Slot().FillHeight(0.5f)//占50%
[
SNew(SButton)
]
]
]
+ SOverlay::Slot()//顶层
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(1.0f)//占满剩余空间
+ SHorizontalBox::Slot().AutoWidth()
[
SNew(SVerticalBox)
+ SVerticalBox::Slot().FillHeight(1.0f)//占满剩余空间
+ SVerticalBox::Slot().AutoHeight()
[
SNew(SBox)
.HeightOverride(128)
.WidthOverride(128)
[
SNew(SButton)
]
]
+ SVerticalBox::Slot().FillHeight(1.0f)//占满剩余空间
]
+ SHorizontalBox::Slot().FillWidth(1.0f)//占满剩余空间
]

  使用 Unreal Slate 构建窗口,通过代码的属性结构来描述 UI 的构成和配置。

alt

DockTab Layout

https://yaksue.blog.csdn.net/article/details/109321869

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65

void FTestLayoutWindowModule::StartupModule()
{
// This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module

FTestLayoutWindowStyle::Initialize();
FTestLayoutWindowStyle::ReloadTextures();

FTestLayoutWindowCommands::Register();

PluginCommands = MakeShareable(new FUICommandList);

PluginCommands->MapAction(
FTestLayoutWindowCommands::Get().OpenLayoutWindow,
FExecuteAction::CreateRaw(this, &FTestLayoutWindowModule::PluginButtonClicked),
FCanExecuteAction());

FLevelEditorModule& LevelEditorModule = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor");

{
TSharedPtr<FExtender> MenuExtender = MakeShareable(new FExtender());
MenuExtender->AddMenuExtension("WindowLayout", EExtensionHook::After, PluginCommands, FMenuExtensionDelegate::CreateRaw(this, &FTestLayoutWindowModule::AddMenuExtension));

LevelEditorModule.GetMenuExtensibilityManager()->AddExtender(MenuExtender);
}

{
TSharedPtr<FExtender> ToolbarExtender = MakeShareable(new FExtender);
ToolbarExtender->AddToolBarExtension("Settings", EExtensionHook::After, PluginCommands, FToolBarExtensionDelegate::CreateRaw(this, &FTestLayoutWindowModule::AddToolbarExtension));

LevelEditorModule.GetToolBarExtensibilityManager()->AddExtender(ToolbarExtender);
}

FGlobalTabmanager::Get()->RegisterNomadTabSpawner(TestLayoutWindowTabName, FOnSpawnTab::CreateRaw(this, &FTestLayoutWindowModule::OnSpawnPluginTab))
.SetDisplayName(LOCTEXT("FTestLayoutWindowTabTitle", "TestLayoutWindow"))
.SetMenuType(ETabSpawnerMenuType::Hidden);

// ! InnerTab的内容:
FGlobalTabmanager::Get()->RegisterNomadTabSpawner(InnerTabName, FOnSpawnTab::CreateLambda([](const FSpawnTabArgs& SpawnTabArgs)
{
return
SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(STextBlock)
.Text(FText::FromString("InnerTab"))
];
}))
.SetDisplayName(LOCTEXT("InnerTab", "InnerTab"))
.SetMenuType(ETabSpawnerMenuType::Hidden);

// ! InnerTab2的内容:
FGlobalTabmanager::Get()->RegisterNomadTabSpawner(InnerTabName2, FOnSpawnTab::CreateLambda([](const FSpawnTabArgs& SpawnTabArgs)
{
return
SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(STextBlock)
.Text(FText::FromString("InnerTab2"))
];
}))
.SetDisplayName(LOCTEXT("InnerTab2", "InnerTab2"))
.SetMenuType(ETabSpawnerMenuType::Hidden);
}

  核心处理是在插件加载的时候 StartupModule 调用 RegisterNomadTabSpawner 注册 Tab

1
2
3
4
void FTestLayoutWindowModule::PluginButtonClicked()
{
FGlobalTabmanager::Get()->InvokeTab(TestLayoutWindowTabName);
}

  点击 GUI 会触发 Tab 生成,调用 OnSpawnPluginTab 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
TSharedRef<SDockTab> FTestLayoutWindowModule::OnSpawnPluginTab(const FSpawnTabArgs& SpawnTabArgs)
{
//原来的分页:
const TSharedRef<SDockTab> NomadTab = SNew(SDockTab)
.TabRole(ETabRole::NomadTab);

//创建TabManager
if (!TabManager.IsValid())
{
TabManager = FGlobalTabmanager::Get()->NewTabManager(NomadTab);
}

//创建布局:
if (!TabManagerLayout.IsValid())
{
TabManagerLayout = FTabManager::NewLayout("TestLayoutWindow")
->AddArea
(
FTabManager::NewPrimaryArea()
->SetOrientation(Orient_Vertical)
->Split
(
FTabManager::NewStack()
->SetSizeCoefficient(.4f)
->AddTab(InnerTabName, ETabState::OpenedTab)
)
->Split
(
FTabManager::NewStack()
->SetSizeCoefficient(.4f)
->AddTab(InnerTabName2, ETabState::OpenedTab)
)
);

}

//从布局中恢复得到控件
TSharedRef<SWidget> TabContents = TabManager->RestoreFrom(TabManagerLayout.ToSharedRef(), TSharedPtr<SWindow>()).ToSharedRef();

//设置内容控件
NomadTab->SetContent(
TabContents
);

return NomadTab;
}

  这里将之前注册的 Tab 唤起。

Viewport

https://yaksue.blog.csdn.net/article/details/109258860

  引入默认的 SEditorViewport
  然后 override 方法 MakeEditorViewportClient

1
2
3
4
5
TSharedRef<FEditorViewportClient> STestLevelEditorViewport::MakeEditorViewportClient()
{
TSharedPtr<FEditorViewportClient> EditorViewportClient = MakeShareable(new FEditorViewportClient(nullptr));
return EditorViewportClient.ToSharedRef();
}

  然后Slate 代码直接使用 SNew(STestLevelEditorViewport) 初始化界面即可。
  不过这个方式沿用了 Viewport ,如何构建一个自定义 Viewport 呢?


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
TSharedRef<FEditorViewportClient> STestEditorViewport::MakeEditorViewportClient()
{
PreviewScene = MakeShareable(new FPreviewScene());

//向预览场景中加一个测试模型
{
//读取模型
UStaticMesh* SM = LoadObject<UStaticMesh>(NULL, TEXT("StaticMesh'/Engine/EngineMeshes/Cube.Cube'"), NULL, LOAD_None, NULL);
//创建组件
UStaticMeshComponent* SMC = NewObject<UStaticMeshComponent>();
SMC->SetStaticMesh(SM);
//向预览场景中增加组件
PreviewScene->AddComponent(SMC, FTransform::Identity);
}

TSharedPtr<FEditorViewportClient> EditorViewportClient = MakeShareable(new FEditorViewportClient(nullptr, PreviewScene.Get()));

return EditorViewportClient.ToSharedRef();
}

  新建一个自定义的 FPreviewScene ,可以将物体实例化添加到场景当中。
  将 PreviewScene 传入到 FEditorViewportClient 中,这样 Viewport 就显示独立的场景。

1
2
3
4
5
TSharedPtr<SWidget> STestEditorViewport::MakeViewportToolbar()
{
return SNew(SCommonEditorViewportToolbarBase, SharedThis(this));
}

  使用上面的代码可以构建出默认 Viewport 的 Toolbar。

GraphEditor

https://yaksue.blog.csdn.net/article/details/107945507
https://yaksue.blog.csdn.net/article/details/108020797
https://yaksue.blog.csdn.net/article/details/108227439
https://yaksue.blog.csdn.net/article/details/109347063

EditorMode

Maya C++ pyd 模块开发

作者 智伤帝
2022年7月14日 10:52

前言

作者: 👨‍💻sonictk

https://github.com/sonictk/maya_python_c_extension

  这篇文章也是参考 sonictk 大佬的提供的 pyd 开发文章。
  文章也提到之前的 hot reload 方案已经解决了很多 C++ 开发困难的问题。
  然而还是有很多情况需要开发一个 python 的 C++ 模块实现 Maya C++ API 的 调用。
  这个情况有点像是 Unreal 暴露 C++ API 到 Python 一样。

Maya 编译 c 相关 Python 库 & pyd 编译

  之前我也写过关于 Maya pyd 编译的文章,但是这个文章是用 Cython 自动生成 C 代码编译实现的,这次是手写 pyd。

什么是 pyd

  pyd 本质上也是一个 dll 文件,就像 Maya 插件的 mll 一样。
  只是 pyd 规定了一些暴露规则,从而让 python 解释器可以读取。
  这也是 Python 称之为胶水语言的一大特点,它可以无缝和 C++ 编译的模块进行交互。
  因此很多 C++ 的包 比如 Qt 等可以暴露接口到 Python 实现调用。

pyd hello world 案例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <Python.h>
#include <maya/MGlobal.h>
#include <stdio.h>

static const char MAYA_PYTHON_C_EXT_DOCSTRING[] = "An example Python C extension that makes use of Maya functionality.";
static const char HELLO_WORLD_MAYA_DOCSTRING[] = "Says hello world!";

// NOTE(timmyliang): 调用 MGlobal API 打印 Python 传递的字符串
static PyObject *pyHelloWorldMaya(PyObject *module, PyObject *args)
{
const char *inputString;
if (!PyArg_ParseTuple(args, "s", &inputString))
{
return NULL;
}

PyGILState_STATE pyGILState = PyGILState_Ensure();

MGlobal::displayInfo(inputString);

PyObject *result = Py_BuildValue("s", inputString);

PyGILState_Release(pyGILState);

return result;
}

// NOTE(timmyliang): 定义模块的函数列表
static PyMethodDef mayaPythonCExtMethods[] = {
{"hello_world_maya", pyHelloWorldMaya, METH_VARARGS, HELLO_WORLD_MAYA_DOCSTRING},
{NULL, NULL, 0, NULL} // NOTE: (sonictk) Sentinel value for Python
};


// NOTE(timmyliang): python2 初始化函数规范 init<module_name>
#if PY_MAJOR_VERSION == 2
extern "C" PyMODINIT_FUNC initpy_hello()
{
return Py_InitModule3("py_hello",
mayaPythonCExtMethods,
MAYA_PYTHON_C_EXT_DOCSTRING);
}
// NOTE(timmyliang): python3 初始化函数规范 PyInit_<module_name>
#elif PY_MAJOR_VERSION == 3
extern "C" PyMODINIT_FUNC PyInit_py_hello()
{
static PyModuleDef hello_module = {
PyModuleDef_HEAD_INIT,
"py_hello", // Module name to use with Python import statements
MAYA_PYTHON_C_EXT_DOCSTRING, // Module description
0,
mayaPythonCExtMethods // Structure that defines the methods of the module
};
return PyModule_Create(&hello_module);
}
#endif

  上面的代码就是一个小案例,将 C++ 编译成 pyd 给 python 调用。
  并且这里引用了 Maya 的 API ,因此只能使用 Maya 的 Python Interpreter (mayapy.exe) 进行加载。
  如果使用其他 Python 导入这个模块会出现如下的错误

1
2
3
4
Traceback (most recent call last):
File "d:/Obsidian/Personal/2_Area/📝Blog/CG/Maya/C++/test_load.py", line 5, in <module>
import py_hello
ImportError: DLL load failed while importing py_hello: 找不到指定的程序。

  pyd 的 C++ 代码包含三个部分

  1. python 定义的函数
  2. 函数列表定义 (需要传入上面的 C++ 编写的 Python 函数)
  3. 模块定义 (传入上面的 函数列表)

  最后生成模块部分,Python2 和 Python3 暴露的 API 不一致,可以用宏来区分。

  编译这个 cpp 需要加上 Maya include 目录的头文件,以及链接 Maya lib 的静态库文件。
  另外编译 pyd 需要特别注意的是,它也需要想 mll 一样暴露出初始化的函数。
  在 python2 下是 init<module_name> 开头,在 python3 下是 PyInit_<module_name> 开头。
  在 cpp 里面配置编译环境是个相当让人头疼的问题。
  我在自己的 CMakeMaya 库里面已经配置好了编译用的环境,
  具体的使用方法可以看 readme 或者参考我的文章 Maya CMake 构建 C++ 插件编译环境

  在我提供的环境下执行 doit c -p pyd -v 2020 即可编译出 pyd 到 plug-ins\Release\maya2022\pyd\py_hello.pyd
  需要注意 pyd 在不同的平台不同Maya版本都需要单独编译。这里我提供了编译好给 Windows64 Maya2020 的 pyd

image

导入 pyd 引入 Maya C++ 节点

  在相应的版本执行就可以看到如期触发了 maya API 的方法。
  也可以用这个方式注册 Maya 的节点和 Mel 命令,具体可以看 pyDeformer 的代码。
  只是由于没有 initializePlugin 拿不到传进来的 MObject 实例化 MFnPlugin
  我测试的 py_deformer 用了 MFnPlugin::findPlug 拿到内置插件 matrixNodes 提供的 MObject 来注册节点。
  答案是可以实现的,而且新加入的节点也会显示在 matrixNodes 上。

image

  这种骚操作不建议使用,而且也不知道会不会有什么 BUG 导致 Maya 崩溃。
  另外没有办法触发 uninitializePlugin 来注销这个节点的注册。

pyd mll 缝合怪

  基于上面的测试我发现还可以生成出既是 Maya 插件又是 Python 模块的 缝合怪文件。
  因为 C++ 只要编译的时候 export 出对应的方法就可以加载。

  只是 Python 加载二进制包要求文件后缀为 pyd ,Maya 加载二进制插件要求文件命名为 mll 才可以。
  解决这个问题,可以用软连接或者拆分成两个文件来实现,经过测试是可以的,具体可以看 pyCommand测试代码

使用 mll 嵌入 python 模块

  上面主要实现按照 python 的规范加载包的操作,sonitck 的文章还提供了一个方案,加载 mll 获取到 python 包的方式。
  做法也不复杂,就是在 initializePlugin 的时候加上加上 C++ 的模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#include <Python.h>
#include <maya/MFnPlugin.h>
#include <maya/MGlobal.h>

const char *kAUTHOR = "TimmyLiang";
const char *kVERSION = "1.0.0";
const char *kREQUIRED_API_VERSION = "Any";

static const char HELLO_WORLD_MAYA_DOCSTRING[] = "Says hello world!";
static const char MAYA_PYTHON_C_EXT_DOCSTRING[] = "An example Python C extension that makes use of Maya functionality.";

PyObject *module = NULL;

static PyObject *pyHelloWorldMaya(PyObject *module, PyObject *args)
{
const char *inputString;
if (!PyArg_ParseTuple(args, "s", &inputString)) {
return NULL;
}
PyGILState_STATE pyGILState = PyGILState_Ensure();
MGlobal::displayInfo(inputString);
PyObject *result = Py_BuildValue("s", inputString);
PyGILState_Release(pyGILState);
return result;
}

static PyMethodDef mayaPythonCExtMethods[] = {
{"hello_world_maya", pyHelloWorldMaya, METH_VARARGS, HELLO_WORLD_MAYA_DOCSTRING},
{NULL, NULL, 0, NULL}
};

MStatus initializePlugin(MObject obj)
{
MFnPlugin plugin(obj, kAUTHOR, kVERSION, kREQUIRED_API_VERSION);
if (!Py_IsInitialized())
Py_Initialize();

if (Py_IsInitialized())
{
PyGILState_STATE pyGILState = PyGILState_Ensure();

// NOTE(TimmyLiang): python2 直接初始化模块就不会变成 built-in 模块
#if PY_MAJOR_VERSION == 2
module = Py_InitModule3("mll_py",
mayaPythonCExtMethods,
MAYA_PYTHON_C_EXT_DOCSTRING);
// NOTE(TimmyLiang): python3 用官方的方式添加模块不行,可能是因为 Py_Initialize 已经执行了
#elif PY_MAJOR_VERSION == 3
// NOTE(TimmyLiang): 参考 https://github.com/LinuxCNC/linuxcnc/issues/825 将模块加到 sys.modules 里面
static PyModuleDef hello_module = {
PyModuleDef_HEAD_INIT,
"mll_py", // Module name to use with Python import statements
MAYA_PYTHON_C_EXT_DOCSTRING, // Module description
0,
mayaPythonCExtMethods // Structure that defines the methods of the module
};

module = PyModule_Create(&hello_module);
PyObject *sys_modules = PyImport_GetModuleDict();
PyDict_SetItemString(sys_modules, "mll_py", module);
#endif
MGlobal::displayInfo("Registered Python bindings!");
if (module == NULL)
{
return MStatus::kFailure;
}
// NOTE(timmyliang): 增加引用计数(确保不会 gc)
Py_INCREF(module);
PyGILState_Release(pyGILState);
}
return MStatus::kSuccess;
}

MStatus uninitializePlugin(MObject obj)
{
MStatus status;
// NOTE(timmyliang): 减少引用计数
Py_DECREF(module);
return status;
}

  上面的代码兼容 python2 python3 版本。
  python2 直接用默认的 Py_InitModule 方法就可以添加,如果在 Python 打印模块会提示 <module 'mll_py' (built-in)>
  但是 python3 下面不行,后来查找了 Github 的 issue 通过将模块添加到 sys.modules 下面解决问题。
  只是模块打印就是普通的模块。
  那为什么将模块放到 sys.modules 就可以了,这 Python 的 import 机制有关。 Python - Import 机制

  这个方式可以将一些 C++ 的 API 暴露给 Python,只是这个操作需要更多的说明。
  否则没人知道这个 mll 居然添加一个 Python 模块。

pybind11 自动绑定

  通过上面一顿操作,也可以深刻体会到如果跨版本兼容 C++ 需要做很多宏的判断,相当繁琐。
  包括 Python2 和 Python3 暴露的方法名不一样,需要在 CMake 上进行判断。
  使用 pybind11 进行转换相对方便许多

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <Python.h>
#include <maya/MGlobal.h>
#include <stdio.h>
#include <pybind11/pybind11.h>

// https://zhuanlan.zhihu.com/p/80884925
void displayInfo(char *inputString)
{
MGlobal::displayInfo(inputString);
return;
}

PYBIND11_MODULE( pybind11cpp, m ){
m.doc() = "pybind11 example";
m.def("display_info", &displayInfo, "Maya Display Info" ,pybind11::arg("inputString") = "hello world!");
}

  pybind11 会自动将 Python 的参数进行转换
  这样只要将纯粹的 C++ 函数放入到 PYBIND11_MODULE
  并且 pybind11 的 2.9 版本支持 python2 python3 的 pyd 编译。
  只要在 cmake 里面配置 /export 对应的方法即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
find_package(Pybind11 REQUIRED) 

project(pybind11cpp) #project name


file(GLOB SRCS "pybind11/*.cpp" "pybind11/*.h")

include_directories(${MAYA_INCLUDE_DIR} ${MAYA_PYTHON_INCLUDE_DIR} ${PYBIND11_INCLUDE_DIR})

link_directories(${MAYA_LIBRARY_DIR}) #specifies a directory where a linker should search for libraries

add_library(${PROJECT_NAME} SHARED ${SRCS}) #Add a dynamic library to the project using the specified source files

# pybind11_add_module(${PROJECT_NAME} ${SRCS})

target_link_libraries(${PROJECT_NAME} ${MAYA_LIBRARIES}) #specifies list of libraries to use when linking the terget and its dependents

if(${MAYA_VERSION} GREATER 2020)
set(PYBIND_LINK_FLAGS "/export:PyInit_pybind11cpp")
else()
set(PYBIND_LINK_FLAGS "/export:initpybind11cpp")
endif()

set_target_properties(${PROJECT_NAME} PROPERTIES
LINK_FLAGS ${PYBIND_LINK_FLAGS}
SUFFIX ".pyd"
)

  pybind11 可以使用 pybind11_add_module 来生成 pyd
  但是它是自动查找 Python 环境,指定 Maya 的 Python 需要额外的配置。
  所以我就不用这个,自己来配置好了。

  通过上面的方式可以大大简化 C++ 的编写。

总结

  以上就是 pyd 编译的各种折腾结果。
  社区里面值得说道的有 cmdc 基于 pybind11 编译的二次封装 C++ API 库。

  Python 调用 C++ 还有利用 ctypes 库访问 dll 的方式
  后续也可以实验一下在 Python 中从 dll 里面调用 function 实现 参考:https://github.com/Autodesk/animx

Unreal C++ VScode 配置

作者 智伤帝
2022年7月12日 14:47

前言

  这次尝试在 VScode 进行引擎编译。
  网上一查发现,官方其实有做支持的,具体可以参考这篇文章 链接
  这篇文章传播甚广,可以参照和这个方式配置 VScode 编译。

https://www.youtube.com/watch?v=fydvKedIxKk
https://github.com/boocs/ue4-tellisense-fixes

C++ 编译过程

深度参考学习这边文章 https://ericlemes.com/2018/11/21/compiling-c-code/
鉴于本人的 C++ 水平一般,建议阅读原文

编译步骤

  C++ 编译可能会用到下面的文件。

  • .cpp 文件编译成 .obj
  • 生成静态库 .lib
  • 生成动态库 .dll
  • 生成可执行文件 executable

VS 工具链

  .sln 全称是 solution 解决方案,是 VS 的项目配置文件。 (整合了 .vcxproj .csproj)
  他可以同时配置多个项目,最后通过 MSBuild 来构建
  sln 包含了项目的各种头文件依赖,库引用等描述,执行顺序,通过这个 IDE 就知道怎么编译你的项目。

  Xcode 的情况也是类似的。
  其中比较特别的时 CMake ,通过 CMakeLists.txt 文件可以根据不同平台生成工程配置文件。

第一步 编译

输入:

  • Defines
  • Include 文件夹路径Include directories
  • 预编译头文件 (如果有用到的话)
  • 源代码

输出:

  • .obj 文件

  MSBuild 使用 CL.exe 进行 C++ 编译。 可能的路径 C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Tools\MSVC\14.29.30133\bin\Hostx64\x64\cl.exe
  需要安装 VS 或者用 choco 来安装

  编译的时候会根据 宏定义(比如 #ifdef)动态 改变编译行为
  通过这个方式可以在不同的平台编译出不同的行为。
  C++ 最终编译成对应平台的二进制,这个设计和 Java C# 都不同。

  头文件最终会拼接到 C++ 里面进行编译,所以需要加上 #pragma once 或者 #if 来避免多次定义。
  预编译头则可以生成 .pch 文件实现头文件复用。

第二步 链接

输入:

  • 一些源码生成 .obj 文件
  • 一些源码生成 .lib 文件
  • 第三方的 lib 和 obj 文件

输出:

  • .dll 或者 .exe

  这一步会将生成的中间文件合并成 dll 或者 exe
  这个过程会完成很多优化的步骤,把不运行的部分清理掉。
  最后会将一些平台的 lib 引入确保它在平台上可以运行,比如 wincrt (Windows C Runtime library) 等等
  并且 lib 也有很多种类,有 release 版本和 debug 版本等等。

Unreal Build Tool

https://ericlemes.com/2018/11/23/understanding-unreal-build-tool/

CS 配置文件说明

https://www.bilibili.com/read/cv15297017/

  Unreal 使用自己开发的 UnrealBuildTool 来编译自己的 C++ 代码
  与 💾CMake 类似的,UnrealBuildTool 会引用你需要在相应的模块添加 .build.cs 的代码文件来描述仓库链接的东西。
  .build.cs 之上配套了 Private Public 文件夹分别放置暴露和不暴露的代码。
  .target.cs 则可以用来定义输出的类型,有 Game Editor Client Server 几种类型。

生成工程文件

  当我们对 uproject 文件右键生成 project 的时候背后执行就是 UnrealBuildTool

image

1
C:/EpicGames/git/UnrealEngine-4.27/Engine/Binaries/DotNET/UnrealBuildTool.exe  -projectfiles -project="D:/EpicGames/test_plugin/test_plugin.uproject" -game -engine -progress -log="D:\EpicGames\test_plugin/Saved/Logs/UnrealVersionSelector-2022.07.12-15.50.08.log"

  UnrealBuildTool 会根据 .build.cs.target.cs 里面配置模块路径生成 sln 工程文件。

编译 C++

image

1
D:/EpicGames/UE_4.27/Engine/Binaries/DotNET/UnrealBuildTool.exe Development Win64 -Project="D:/EpicGames/Unreal_Playground/Unreal_Playground.uproject" -TargetType=Editor -Progress -NoEngineChanges -NoHotReloadFromIDE

  这个 Build.bat 背后还是调用 UnrealBuildTool.exe 通过它来编译 C++
  上面生成工程时候 .build.cs.target.cs 只是收集了路径。
  现在会再次读取这两个文件来获取一些编译用的属性。
  然根据配置解决各个模块的依赖关系。

  最后会运行 UnrealHeaderTool 将 UObject 的一些特性注入到 UObject 的 cpp 文件当中。
  这也说明了为什么需要引入 .generated.h 的头文件。
  准备好了所有代码之后再调用相应的编译工具去构建 C++。

VScode 编译配置

  了解了 C++ 编译和 Unreal 全家桶的编译逻辑之后。
  我们终于可以回归到本篇文章的正题。

http://jollymonsterstudio.com/2018/11/02/unreal-c-with-visual-studio-code/

  按照这里提供的文章就可以用 Unreal 官方的方式配置好 .vscode 目录的编译配置。
  后续只要 Ctrl + shift + B 就可以触发编译。
  编译背后的逻辑就在上面解释了。

  相应的我也可以用 python 脚本来触发编译。
  sln 工程并不是必须的,不过 VS 有 VA 查找代码比较快。

Maya C++ mll hot reload 研究

作者 智伤帝
2022年7月8日 10:41

前言

作者: 👨‍💻sonictk

https://sonictk.github.io/maya_hot_reload_example_public/

  详细的说明 & 教程在上面的链接。

  Maya 用写 C++ 开发会比较痛苦,一方面是编译问题总是让人烦躁,另一方面加载了 mll 会导致占用,测试起来很不方便。
  所以我之前推崇用 Python OpenMaya 做原型设计再转 C++
  当然 sonictk 也提到 Fabric Engine 和 Maya Bifrost 使用的时 LLVM IR 的方案来实现 JIT 编译。
  具体可以参考另一个项目 giordi91/babycpp

LLVM 热加载

  babycpp 基于 LLVM 的解决方案我编译没有通过,代码报类型错误,因此也没有测试成功。
  不过也了解了 LLVM 是怎么实现热更新的,运行逻辑和 Python 有点像,但是从本质上不一样。

  传统的编译器需要有 前端 优化器 后端组成,一般前端是语言,通过 tokenize 和 AST 等方案将语言解析然后通过优化器生成后端的二进制文件。
  LLVM 推出了 LLVM IR 中间语言,这样不管前端用什么语言开发,只要有对应的解析工具生成出 LLVM IR ,j就可以利用 LLVM IR 的优化生成 二进制机器语言高效运行。
  babycpp 项目就基于 LLVM IR 的机制开发了一个自己的简化版 C++ 语言,通过 LLVM IR JIT 编译动态改变运行逻辑。

  我目前个人理解来看,LLVM IR 模式和 Python 模式还是不一样的,Python 是调用自己编译好的模块来运行的,而 LLVM IR 是直接运行时(JIT)生成机器语言,JIT模式的运行效率有时候比 C++ 的静态编译还要高,因为 JIT 可以根据运行过程推断程序下一步的执行来优化非必要的运行逻辑,所以 LLVM IR 的性能要比 Python 好得多。其实我后面了解了一下 numba 提速 Python 的原理就是利用 LLVM 标准实现的。

  不过也正如 sonictk 的文章所提到的,这个方案只能调用暴露的东西,无法对内存的细节进行处理。

基于 dll 加载

https://github.com/FXTD-ODYSSEY/CMakeMaya/tree/master/projects/sonictk/hot_reload

  如果使用作者提供的 github 仓库的代码编译会有问题,作者的 thirdparty 仓库编译不通过。
  所以我后面是根据作者文章的代码稍微调整组装到一起实现的。

  详细讲解之前,我先用最简单的话说明这个 hotreload 方案。

  1. 编译一个变形器的 mll 插件 和 带逻辑的 dll 文件
  2. mll 加载之后会调用 dll 的function进行计算
  3. 修改逻辑之后重新编译 dll
  4. mll 会重新健在最新的 dll 实现热更新。

实现思路

https://sonictk.github.io/maya_hot_reload_example_public/getting_started/

  这篇文章非常好,不仅仅讲解了作者 hot reload 的思路,还附带了 windows lib dll 之间的运行逻辑等知识。

目录结构

image

  代码结构上需要将插件分成两个部分,一个是调用 logic 生成 dll
  另一个是 deformer 的代码生成 mll
  具体编译配置通过 cmake 配置两个 project 实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.
├── logic
│ ├── logic.cpp dll 代码逻辑
│ └── logic.h
├── maya_deformer
│ ├── deformer_platform.cpp 调用 <windows.h> API 加载 dll
│ ├── deformer_platform.h
│ ├── deformer.cpp Maya 变形器 deform 调用 deform_platform 提供的方法
│ ├── deformer.h
│ ├── plugin_main.cpp Maya mll 插件初始化函数
│ └── plugin_main.h
├── scripts
│ └── test_deformer.py 测试插件是否修改
├── CMakeLists.txt
└── readme.md

dll 加载方案

image

  上面三个函数调用了 window API 提供的 LoadLibrary FreeLibrary GetProcAddress 加载 dll

image

  然后将分装到 loadDeformerLogicDLLunloadDeformerLogicDLL 方法里面。
  deformer 在触发计算的时候调用加载 dll。

image

  这样每次触发节点运算的时候会自动按照 dll 的路径进行加载。

  问题是怎么在 C++ 动态获取到当前 dll 的路径呢?

image

  在插件加载的时候通过 plugin.loadPath 可以拿到当前 mll 加载的路径。
  只要在同一个路径找 logic.dll 路径即可。

遇到的坑

编译 dll 占用问题

  需要注意的是,mll 被 Maya 加载会产生占用,mll 去加载 dll 也会造成占用。
  只有执行 unloadDeformerLogicDLL 才会解除 dll 的占用
  但是占用会造成编译失败。

  于是我用 CMake 的 API 将旧的 logic.dll 改名叫 logic_old.dll
  windows 下被占用的文件还是可以改名的。
  然后执行编译生成新的 logic.dll
  这时候需要手动触发 Maya 节点的更新,这样就会按照原来的路径加载新的 dll。

  CMake 怎么判断 dll 是否占用,我也没有找到合适方法,于是我想到直接删除这个 dll 在判断 dll 是否存在的方法。

extern 问题

1
2
static MString kPluginLogicLibraryPath;
static DeformerLogicLibrary kLogicLibrary;

  源码这两个变量用的是 static 静态变量。
  但是不知道为什么在其他 cpp 文件里面调动得到的是不同的 内存 地址。

https://blog.csdn.net/sksukai/article/details/105612235

1
2
extern MString kPluginLogicLibraryPath;
extern DeformerLogicLibrary kLogicLibrary;

  后续是改成 extern
  然后在 plugin_main.cpp 里面初始化变量解决问题。

总结

  这个方法切实解决了 节点热加载的问题,不需要 unloadPlugin 清空场景之类的操作,测试起来方便了许多。

Maya CMake 构建 C++ 插件编译环境

作者 智伤帝
2022年7月1日 14:21

前言

  过去构建 Maya C++ 插件是按照 Autodesk 官方提供的流程,在 VS 里面配置项目工程。 参考链接
  通过配置 devkit 的 pluginwizard 来构建项目。
  但是使用 VS 配置 Maya 依赖的头文件和 lib 其实挺不方便的。

image

image

  依赖和修改都在不同选项里面,配置起来要搞半天。
  而且这个工程配置只能兼容 Windows ,如果我们要在 Linux 环境下编译,整个流程又完全不一样了。
  其实解决这种问题,有专门的工具去做。
  这就是 CMake
  通过 cmake 配置可以生成不同平台的工程文件,不需要打开 IDE 就可以调用 compiler 编译结果。

https://github.com/volodinroman/CMakeMaya

  这个仓库是别人配置好的基于 CMake 构建 Maya 插件的仓库。

Doit 自动构建环境

  但是构建编译环境还是挺麻烦的,一方面需要下载 VS 和 CMake
  另外还要配置好 Maya 提供的 SDK

https://github.com/FXTD-ODYSSEY/CMakeMaya

  我这个仓库提供了懒人包环境,只需要配置有 Python 环境和poetry 库。
  在仓库的目录,执行 poetry installpoetry shell 就可以进入开发虚拟环境。(注: 需要管理员权限)
  poetry 会自动安装配置好的依赖,包括 doit 框架
  执行 doit init 会调用 choco 安装 VS 的依赖,以及 CMake
  这个过程需要等待一段时间。

  执行完之后 VS Build Tool 就添加到系统了。
  但还是找不到 C++ compiler ,需要手动打开 installer 下载 C++ CMake 开发包。

image

  使用 doit SDK -v 2020 会下载 Maya 官方的 devkit 到仓库的 SDK 目录。
  准备好环境之后,还需要安装好 maya 2020
  如此就是完备的编译环境,只需要用 doit c 执行 cmake 编译命令来编译 C++ 插件。

1
doit c -p weightDriver -v 2020

  使用 -p 可以指定编译的项目,-v 可以指定编译的 Maya 版本,默认不指定会编译全部项目的 2020 版本
  -p 支持完整的projects 相对路径或者最终目录指定

1
2
doit c -p IngoClemens/weightDriver
doit c -p weightDriver

  执行 doit 的时候会用 python 识别将末端目录变成完整的相对目录

image


  下面是完整执行编译的流程

image

  doit 背后执行的是 拼接输入 执行 cmake 命令

1
cmake -Wno-dev -G "Visual Studio 16 2019" -DMAYA_VERSION={version} -DMAYA_PROJECT={project}. -B build

  DMAYA_VERSION 指定 Maya 版本号
  DMAYA_PROJECT 指定 Maya 项目,多个项目可以用 ; 分割。
  这个命令会读取根目录的 CMakeLists.txt 根据 VS2019 的配置生成 sln 文件到 Build 目录。
  windows 下如果需要 Debug 也可以用 VS 打开 sln 去配置 Debug 工具。

1
cmake --build build --config Release

  后面会执行 build 命令根据配置编译输出到指定目录。

中文乱码坑

💡Vscode terminal 中文乱码

  Window Terminal 默认不支持 MSBuild 的字符输出。

image

  需要在 terminal 上执行 chcp 65001 切换字符集。

添加新工程

  如果需要添加自己的 mll 需要自己填充 CMakeLists.txt 配置
  使用 doit new 可以快速生成 插件 编译模板

cmake 配置说明

projects 下每个项目目录都有对应的 CMakeLists.txt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 设置输出目录
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE}/maya${MAYA_VERSION})

# 设置项目名称 (一般编译的文件名取项目名)
project({{cookiecutter.project_name}})

# 添加编译的文件
file(GLOB SRCS "*.cpp" "*.h")

# 添加头文件依赖
include_directories(${MAYA_INCLUDE_DIR})
# 添加 lib 库目录
link_directories(${MAYA_LIBRARY_DIR})
# 链接源码
add_library(${PROJECT_NAME} SHARED ${SRCS})
# 链接 lib
target_link_libraries(${PROJECT_NAME} ${MAYA_LIBRARIES})

# mll 输出配置
MAYA_PLUGIN(${PROJECT_NAME})

  大部分的结构如上图,默认模板如上。
  我加上了注释说明。

  MAYA_PLUGIN方法将 mll 的 initializePlugin uninitializePlugin 两个方法暴露出来(Maya 加载用),并且将 dll 的后缀改为 mll。

用 CMake 编译 Devkit 的案例代码

  上面提到的 CMake 是基于 https://github.com/volodinroman/CMakeMaya 的方案搭建的。
  cmake 文件基本上是自己编写,可以控制每一处的细节。

  其实 Maya 的 Devkit 也提供了一套 CMake 的方案。
  每个插件都保留了 CMakeLists.txt 用于编译。
  如何顺利编译 Maya C++ 的案例插件是一个好问题。
  我过去看 Maya 的文档但是因为不会折腾这个编译(编译出错不知道怎么解决) ,导致无法深入学习 C++ 插件。
  只能拿 Devkit 提供的 Python 文件进行学习。
  通过上面的折腾与学习,自己也算是对 CMake 有了基础的入门,终于有能力搞定这个问题了~

https://help.autodesk.com/view/MAYAUL/2020/ENU/?guid=__developer_Maya_SDK_MERGED_A_First_Plugin_HelloWorld_html

  上面的链接是官方文档提供的一个 Maya 插件最简案例。
  相应的代码在 devkit\plug-ins\helloCmd 找到

image

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
cmake_minimum_required(VERSION 2.8)

# include the project setting file
include($ENV{DEVKIT_LOCATION}/cmake/pluginEntry.cmake)

# specify project name
set(PROJECT_NAME helloCmd)

# set SOURCE_FILES
set(SOURCE_FILES
helloCmd.cpp

)

# set linking libraries
set(LIBRARIES
OpenMaya
Foundation
)

# Build plugin
build_plugin()

  构建插件的 cmake 代码如上,核心部分是 $ENV{DEVKIT_LOCATION} 通过环境变量获取 Devkit 的路径
  所以执行 CMake 之前可以配置一下环境变量。

1
2
3
set DEVKIT_LOCATION=F:\maya_devkit\devkitBase
cmake -G "Visual Studio 16 2019" . -B build
cmake --build build --config Release

image

  如此操作,就可以编译出 mll 了。(前提是要配置好 VS 的环境)

总结

  这个环境我通过 虚拟机 测试过,在 win10 环境是没有问题。
  通过 cmake 配置可以快速构建好 C++ 编译环境,比起以前折腾 VS 来方便太多了。
  利用 choco 来安装依赖也解决了各种缺库导致起不来的问题。
  通过这个人懒人包可以极大降低 Maya 写 C++ 的难度。

2022-7-8 补充说明

  最近利用 submodule 添加了很多社区的 C++ 库。
  clone 仓库之后需要用执行 git submodule update --init 来拉取 submodule

  一些注意事项请参阅 readme 文档

Unreal Python 导出 MetaHuman 控制器关键帧

作者 智伤帝
2022年6月24日 16:57

前言

  MetaHuman 已经在数字人领域里面相当成熟的解决方案。
  并且 UE 官方开发了源码工程。
  目前 github 上有不少人演示自己套用 MetaHuman 动画的效果。
  于是我自己也尝试着想将它 UE 里面的控制器动画导出来。
  然而却发现行不通。

image.png

  它的控制器关键帧是在 sequencer 里面。
  最初是尝试将 sequencer 的资源全部导出成 FBX。
  然而控制器的关键帧并没有跟随导入到 FBX 当中。

  于是我想到可以用 unreal python 读取关键帧数据导出 json
   Maya 再读取数据设置关键帧到控制器上。

unreal python 导出关键帧

  有思路之后就好办。
  之前我也写过脚本来获取 sequencer 关键帧的。
  需要注意如果想要使用 unreal python 的 API 需要开启相应的 C++ 插件。

image.png

  否则 python 会获取不到相应的 API 报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# Import built-in modules
from collections import defaultdict
import json
import os

# Import local modules
import unreal

DIR = os.path.dirname(os.path.abspath(__file__))

def unreal_progress(tasks, label="进度", total=None):
total = total if total else len(tasks)
with unreal.ScopedSlowTask(total, label) as task:
task.make_dialog(True)
for i, item in enumerate(tasks):
if task.should_cancel():
break
task.enter_progress_frame(1, "%s %s/%s" % (label, i, total))
yield item


def main():
# NOTE: 读取 sequence
sequence = unreal.load_asset('/Game/Sequencer/MetaHumanSample_Sequence.MetaHumanSample_Sequence')
# NOTE: 收集 sequence 里面所有的 binding
binding_dict = defaultdict(list)
for binding in sequence.get_bindings():
binding_dict[binding.get_name()].append(binding)

# NOTE: 遍历命名为 Face 的 binding
for binding in unreal_progress(binding_dict.get("Face", []), "导出 Face 数据"):
# NOTE: 获取关键帧 channel 数据
keys_dict = {}
for track in binding.get_tracks():
for section in track.get_sections():
for channel in unreal_progress(section.get_channels(), "导出关键帧"):
if not channel.get_num_keys():
continue
keys = []
for key in channel.get_keys():
frame_time = key.get_time()
frame = frame_time.frame_number.value + frame_time.sub_frame
keys.append({"frame": frame, "value": key.get_value()})

keys_dict[channel.get_name()] = keys

# NOTE: 导出 json
name = binding.get_parent().get_name()
export_path = os.path.join(DIR, "{0}.json".format(name))
with open(export_path, "w") as wf:
json.dump(keys_dict, wf, indent=4)

  上面的脚本会定位 MetaHuman 的 sequence 资源,然后导出关键帧的信息为 json

  导出会在脚本目录输出两个 json 文件。
  Maya 可以解析这个这两个 json 将关键帧设置到 控制器上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# Import built-in modules
import json
import os
import traceback

# Import third-party modules
import pymel.core as pm

DIR = os.path.dirname(os.path.abspath(__file__))


def progress(seq, status="", title=""):
pm.progressWindow(status=status, title=title, progress=0.0, isInterruptable=True)
total = len(seq)
for i, item in enumerate(seq):
try:
if pm.progressWindow(query=True, isCancelled=True):
break
pm.progressWindow(e=True, progress=float(i) / total * 100)
yield item # with body executes here
except:
traceback.print_exc()
pm.progressWindow(ep=1)
pm.progressWindow(ep=1)


def main():

# NOTE: 读取数据
with open(os.path.join(DIR, "BP_metahuman_001.json"), "r") as rf:
data = json.load(rf)

attr_map = {"location": "t", "rotation": "r"}
status = "Import Keyframe to metahuman controller"

# NOTE: undo 支持
pm.undoInfo(ock=1)
for channel, frame_list in progress(data.items(), status=status):
# NOTE: 解析 channel_name
has_attr = channel.count(".")

if not has_attr:
# NOTE: 处理 `CTRL_C_eye_parallelLook_4311` 格式
ctrl_name = channel.rsplit("_", 1)[0]
attr = "ty"
else:
parts = iter(channel.split("."))
ctrl_name = next(parts, "")
param = next(parts, "")
axis = next(parts, "")
if not axis:
# NOTE: 处理 `CTRL_C_teethD.Y_4330` 格式
attr = "t"
axis = param
else:
# NOTE: 处理 `CTRL_L_eyeAim.Rotation.Y_4387` 格式
attr = attr_map.get(param.lower())
attr += axis.split("_")[0].lower()

# NOTE: 解析出控制器属性设置关键帧
attribute = pm.PyNode(".".join([ctrl_name, attr]))
for frame_data in frame_list:
frame = frame_data.get("frame")
value = frame_data.get("value")
attribute.setKey(t=frame, v=value)

pm.undoInfo(cck=1)

  加载 unreal 导出的数据。

总结

  其实整个流程不复杂,有思路就很好处理。

Python 代码规范

作者 智伤帝
2022年5月9日 11:16

Python 编程规范系列大纲

  1. Python 代码规范
    1. flake8 代码检查工具
    2. wemake python style
  2. Python poetry 包管理
  3. Python 工具配置
    1. commitizen
    2. isort
    3. black
    4. pylint
    5. falkehell
    6. pre-commit
    7. tox & nox 测试环境管理
  4. Python mkdocs 文档构建
  5. Python pytest 单元测试
  6. Python cookiecutter 项目模板生成工具
  7. Python Github 开源项目维护流程
    1. Github Action
    2. pull request
    3. git rebase 说明

前言

  过去在项目组开发,需要快速迭代,通常都是面向美术编程,这个需要快准狠地解决问题,至于代码怎么写其实是没有任何要求的。
  但是这对于长线维护来说简直是灾难,当这种代码越来越多之后,就会变成一堆没人敢碰的屎山代码。
  那怎么才能写出可以长期维护的代码呢?
  下面我会开一个 Python 编程规范 系列,整理出一整套 Python 的编程规范,以及配套的工具。
  这个过程是这小半年来的一次总结,这里诚挚地感谢我的同事 龙浩 ,它教会了很多~

Python 代码规范

  1. 谷歌规范 styleguide | Style guides for Google-originated open-source projects
  2. Maya Python 开发规范: Python Scripting for Maya Artists | Chad Vernon
  3. TA 101: theodox/ta_101: a coding standards doc for technical artists (github.com)

  其中我结合自身理解,翻译了 Maya Python 开发规范TA 101,大家可以自行参阅。

文件头部统一写法

  我们的代码优先采用 Python3 写法,因此根据谷歌规范,所有的代码文件需要加上下列规范
  __future__ 模块用来兼容 Python3 写法
  coding:utf-8 兼容 utf-8 编码

1
2
3
4
5
6
7
8
# -*- coding: utf-8 -*-
"""
module docstring
"""

from __future__ import division
from __future__ import print_function
from __future__ import absolute_import

  from __future__ import division 可以引入整数相除可以得到小数 (Python2的默认环境是得到整数)
  from __future__ import print_function 可以让 print 关键字变为 Python3 的 print 方法,可以在 lambda 里面使用 print
  **from __future__ import absolute_import 引入绝对导入

  division 会导致 OpenMaya 一些类型的运算符失效

PowerPoint プレゼンテーション (square-enix.com)

  原因是引入 division 之后 除法 除法 __truediv__ 而不再是 __div__ 了

enter image description here

backport 兼容库

six - py2 & py3 兼容

Github: benjaminp/six: Python 2 and 3 compatibility library (github.com)

  six 库提供了统一的 API 解决了 Py2 Py3 不统一的问题。
  比如加入 metaclass

1
2
3
4
#Python2
import abc
class TestClass(object):
__metaclass__ = abc.ABCMeta
1
2
3
4
#Python3
import abc
class TestClass(metaclass=abc.ABCMeta):
pass
1
2
3
4
5
# py2 & py3 兼容
import abc
import six
class TestClass(six.with_metaclass(abc.ABCMeta, object)):
pass

future 模块

Quick-start guide — Python-Future documentation

1
2
3
from future.standard_library import install_aliases
install_aliases()
import queue

  可以支持大部分名字迁移的库,比如 Python2 里面用 Queue 在 Python3 下用 queue
  使用 future 库就可以用 Python3 写法在 Python2 下运行。

Qt.py Qt库兼容

Github : mottosso/Qt.py: Minimal Python 2 & 3 shim around all Qt bindings - PySide, PySide2, PyQt4 and PyQt5. (github.com)

Qt.py 的作者是 流程TD 因此影视行业多采用这个,Qt.py 是运行时 Resolve Qt 的库,因此不太兼容 pyinstaller 打包之类依赖静态分析的库,要解决这个问题可以用 qtpy 库 (qtpy 是多文件 Qt.py 是单文件)


Qt 的 Python Binding 因为一些历史瓜葛,导致拆分出了两个可用的库
PyQt & PySide
PyQt 商用付费,PySide 商用免费

两者都是 Qt C++ 封装暴露到 Python 库,使用上大部分的代码都是能够兼容的。
需要注意的部分差异有信号槽区别

1
2
3
4
5
from PyQt5 import QtCore
signal = QtCore.pyqtSignal()

from PySide2 import QtCore
signal = QtCore.Signal()


enter image description here

MayaC++ Qt 版本PyQtPySide
2014+ 信息源Qt4PyQt4PySide
2017+ 信息源Qt5PyQt5PySide2
未支持Qt6PyQt6PySide6

Dealing with Maya 2017 and PySide2 · Fredrik Averpil

需要注意 PySide 升级到 PySide2 的 API 由以前的两个模块拆分成了三个 (QtCore QtWidgets QtGui)
官方推荐下列的代码解决问题 信息源

1
2
3
4
5
6
7
8
9
10
11
try:
from PySide2.QtCore import *
from PySide2.QtGui import *
from PySide2.QtWidgets import *
from PySide2 import __version__
from shiboken2 import wrapInstance
except ImportError:
from PySide.QtCore import *
from PySide.QtGui import *
from PySide import __version__
from shiboken import wrapInstance

但是上面的代码需要 * 导入,并不符合我们的代码规范
使用 Qt.py 就可以轻松解决问题

1
2
3
4
from Qt import QtCore
from Qt import QtGui
from Qt import QtWidgets
from Qt.QtCompat import wrapInstance

老版本的 Maya 使用 PySide 也会被映射到 PySide2 的调用规范上。

black 代码格式化

VScode 配置 black 工具
enter image description here
使用的 Python 必须 pip install black

Pylint 代码提示

VScode 配置 Pylint 工具
enter image description here

该选项默认是启用的
只要启用 Python 安装过 Pylint (pip install pylint)

docstring 规范

docstring有四种通用的标注规范

  • Epytext
  • reST
  • Google
  • Numpy

四种规范的样式

sphinx.ext.napoleon 支持将 Google 和 Numpy 转换成 reST

建议做到所有的函数都进行 docstirng 注释

VScode 自动生成 docstring

enter image description here
Python Docstring Generator - Visual Studio Marketplace
安装 VScode 插件

enter image description here
设定可以修改 Docstring 的生成格式。
默认生成快捷键为 ctrl+shift+2

代码 review

仓库设置必须经过 review 才能合并
review 代码可以大家共同成长,统一代码规范。

tox

THM中的tox命令行大全 - 腾讯iWiki (woa.com)

龙浩提供的 thm 仓库会提供 open-dev-shell.cmd 脚本,需要本机安装 thm
启动可以进入 thm 的命令行开发环境,配备了多个中心化部署的工具

enter image description here

利用上面的 tox -a 可以查看龙浩提供的 tox 配置命令。
使用 tox -e pkg-py 可以初始化当前仓库,生成 package.py 和 setup.py 等一系列配置文件。
可以使用 tox -e ide-code 使用中心化的 vscode 打开当前仓库

enter image description here

默认 package.py 已经配置好 docs 和 单元测试 等诸多环境。
环境变量利用 rez 的规则添加到 commands 函数里面。


日常上传代码前使用 tox -e preflight
会运行 pre-commit,blackisort 标准化所有的代码,也能提前发现一些文件错误。

后续使用 git add <文件> 命令将要提交的文件添加到 git 记录里。
commit 步骤 tox -e commit添加提交信息。(这样提交信息有统一规范)

enter image description here
参考链接


发布前可以使用 tox -e build-test <版本号> 测试是否可以正常发布 thm packages
如果 build-test 通过可以使用 tox -e build <版本号> 来发布到 thm 中心化云端上

单元测试

Unit Testing in Maya | Chad Vernon
Unit Testing in Maya (Part 2) | Chad Vernon

可以使用 龙浩 配置好的 tox 进行单元测试

Qt 单元测试

pytest-qt — pytest-qt documentation

sphinx 文档生成

使用 Sphinx 撰写技术文档并生成 PDF 总结 - 简书 (jianshu.com)

使用 Markdown 编写 Sphinx 文档

使用 龙浩 的 _build_docs 命令可以自动生成文档。

Poetry 依赖管理

Poetry | PYTHON 打包和依赖管理变得简单 (qq.com)
基于 龙浩 提供的 thm(rez) 流程,不是十分需要 poetry。

Sentry 错误追踪

Sentry | 应用程序监控和错误跟踪 (qq.com)

typing 静态类型检测

python/mypy: Optional static typing for Python 3 and 2 (PEP 484) (github.com)

Python - Import 机制

作者 智伤帝
2022年4月15日 09:23

前言

  你是否也会为 reload Python 的模块干到烦恼。
  需要在不同的脚本加上 reload 导入的模块确保可以看到代码的更新。
  Python 是怎么缓存 import 的模块的。

TLDR;

  我后来了解了 Python 的加载机制之后弄了一个函数,只要将我们开发的包命名加上,就可以实现整个开发包 reload 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def module_cleanup(module_name):
"""Cleanup module_name in sys.modules cache.

Args:
module_name (str): Module Name
"""
if module_name in sys.builtin_module_names:
return
packages = [mod for mod in sys.modules if mod.startswith("%s." % module_name)]
for package in packages + [module_name]:
module = sys.modules.get(package)
if module is not None:
del sys.modules[package] # noqa:WPS420

# NOTES(timmyliang): 这个操作等同于对 test_module 下所有的 module 进行 reload
module_cleanup("test_module")

  如果我们的 test_module 下有众多脚本就不需要逐个去添加 reload 了。
  万一不小心把 reload 发布出去了也会稍微降低脚本运行的性能。

Python Import

https://docs.python.org/3/reference/import.html

  上面是 Python 的官方文档讲述 Python的 import 的时候背后的运行机理,也可以切换成中文进行阅读。
  这里我将上面的文章结合自己的实践总结一番。

  Python import 模块可以用关键字 import 或者 importlib.import_module()
备注: 关键字调用无法放到 lambda 函数里面,这也是为什么 Python2 下默认 print 无法放入 lambda 里面, python3 print 不再是关键字可以放入 lambda
  使用 import 关键字其实背后执行的是 __import__() 内置方法。
  import 触发之后会从 sys.modules 查找缓存,找不到就从 sys.path 里面匹配模块 (这个过程也会触发 meta_path 等触发自定义的 import 行为)
  找到匹配的模块就会创建模块 否则 raise ModuleNotFoundError
  生成的模块会放入到 sys.modules 进行缓存。

import 执行操作(不考虑自定义 import 情况)

  1. sys.modules 查找模块缓存
  2. sys.path 匹配脚本 生成模块 放入 sys.modules 缓存

sys.modules

  由于 sys.modules 的缓存机制,Python 下次导入就从已经加载的缓存中获取模块,导致模块用的还是旧的代码逻辑。
  相应的也可以修改 sys.modules 的字典实现骚操作

1
2
3
4
5
import sys
sys.modules['a'] = 1

import a
print(a) # 打印 1

  当然这种骚操作不推荐使用就是了。
  另外还有一些危险的操作,比如 del sys.modules["builtins"] 会让 Python 变得不正常(:з」∠)

1
2
3
4
5
del sys.modules["builtins"]  
map
# Traceback (most recent call last):
# File "<stdin>", line 1, in <module>
# RuntimeError: lost builtins module

  基于这个原理,如果将缓存清理了,下次 Python import 就会重新加载这个模块,实现 reload 的效果。
  我最初也是在 mGear 的代码里面学习它们的 reload 方法学习到的。

image

  它背后实现的代码就是 del sys.modules["mgear"] 等相关的模块

https://docs.python.org/3/reference/import.html#the-module-cache

  根据官方文档的说明,如果一个大模块下有很多子模块,都是单独键值缓存的。
  所以要 reload 所有的子模块需要编译键值将匹配的都删除掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def module_cleanup(module_name):
"""Cleanup module_name in sys.modules cache.

Args:
module_name (str): Module Name
"""
if module_name in sys.builtin_module_names:
return
packages = [mod for mod in sys.modules if mod.startswith("%s." % module_name)]
for package in packages + [module_name]:
module = sys.modules.get(package)
if module is not None:
del sys.modules[package] # noqa:WPS420

# NOTES(timmyliang): 这个操作等同于对 test_module 下所有的 module 进行 reload
module_cleanup("test_module")

  这个就是我整理的遍历所有匹配的模块进行缓存删除的函数,sys.builtin_module_names 通过规避对内置模块的清理。
  这样源代码不需要添加 reload ,我们只在开发用的调试脚本添加这个函数执行 reload 即可。
  另外有一个小小注意点,用这个删除缓存的方式 reload 会将之前的 module 删除生成新的 module 对象,但是如果用 reload 的话是沿用之前的 module 对象。
  目前我实践上还没遇到过因为这个导致出现问题的情况。

packages 命名空间包

https://packaging.python.org/en/latest/guides/packaging-namespace-packages/

  按照上面链接提供的目录结构

1
2
3
4
5
6
7
8
9
10
11
12
mynamespace-subpackage-a/
setup.py
mynamespace/
subpackage_a/
__init__.py

mynamespace-subpackage-b/
setup.py
mynamespace/
subpackage_b/
__init__.py
module_b.py

  然后就可以 from mynamespace import subpackage_b from mynamespace import subpackage_a
  用同一个 mynamespace 包导入两个不同路径的模块。

image

  但是上面的链接也提到 命名空间包并不适用所有的情况,反而是用前缀包会更好。

模块遍历查找

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pkgutil
import xml
for finder,name,ispkg in pkgutil.walk_packages(xml.__path__,xml.__name__+'.'):
print(finder,name,ispkg)

# 输出如下
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.dom True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.NodeFilter False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.domreg False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.expatbuilder False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.minicompat False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.minidom False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.pulldom False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\dom') xml.dom.xmlbuilder False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.etree True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementInclude False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementPath False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.ElementTree False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\etree') xml.etree.cElementTree False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.parsers True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\parsers') xml.parsers.expat False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml') xml.sax True
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax._exceptions False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.expatreader False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.handler False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.saxutils False
# FileFinder('C:\\tools\\Anaconda3\\lib\\xml\\sax') xml.sax.xmlreader False

  通过 pkgutil.walk_packages 可以遍历一个模块所有的子模块。
  from setuptools import find_packages 也可以实现类似的功能
  但是 find_packages 面对命名空间模块不好使,但是 walk_packages 好使。(原因是 find_packages 通过 os.walk 去查找路径的)
  也可以通过这个方式将对应模块的缓存进行删除~

判断模块是否存在

1
2
3
4
5
6
def importable(module_name)
try:
__import__(module_name)
return True
except ImportError:
return False

  过去判断一模块是否可以 import 通常使用异常进行处理。
  其实 pkgutil.find_loader 也可以返回模块是否可以 import

1
2
3
4
import pkgutil
loader = pkgutil.find_loader("maya")
has_maya = loader and loader.load_module("maya")
print(has_maya) # 如果存在返回 maya 库,不存在返回 None

  上面的方式就不需要用 exception 进行处理。(find_loader 的源码已经有 exception 的逻辑)

  如果模块可以导入会返回对应的 loader,使用 load_module 可以进行加载。
注: py2 的 load_module 必须要传参。

自定义 import 行为

  除了 sys.path 通过系统路径查找 python 包进行加载之外。
  Python 还有 sys.meta_path 存储一系列 Finder 类 (Py3还需要 Loader 类) 来自定义 import 逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import sys
import types

class CustomFinder(object):
def __init__(self):
self.submodule_search_locations = []
self.has_location = False
self.origin = None

def create_module(self, spec):
return self.load_module(spec.name)

def exec_module(self, module):
"""Execute the given module in its own namespace
This method is required to be present by importlib.abc.Loader,
but since we know our module object is already fully-formed,
this method merely no-ops.
"""

def find_spec(self, fullname,*args):
self.name = fullname
self.loader = self
return self.find_module()

# NOTES(timmyliang): compat with Python2
def find_module(self,*args):
return self

def load_module(self, fullname):
module = sys.modules.get(fullname)
if module:
return module

new_module = types.ModuleType(fullname)
sys.modules[fullname] = new_module
new_module.__name__ = fullname
new_module.__loader__ = self
return new_module

if __name__ == "__main__":
sys.meta_path.append(CustomFinder())

import myapp
print(myapp)
# Py3: <module 'myapp' (<__main__.CustomFinder object at 0x000002A2A2904808>)>
# Py2: <module 'myapp' (built-in)>

  上面的代码实现了 py2 py3 的 Finder 兼容。
  可以实现加载任意名称的模块都能成功返回而不会引发 ImportError
  当然这种操作如果用到项目里面,肯定会被人打死 😄

  在 Py2 环境下 Finder 需要实现 find_moduleload_module 方法
  Py3 环境可以参考下面的链接。

https://stackoverflow.com/a/58275573/13452951

  需要有 Finder 需要实现 find_spec 返回 ModuleSpec 类,这个类需要有 Loader 进行加载逻辑

  官方提供的 zipimport.zipimporter 在 Py2 下是 Finder ,在 Py3 下是 Loader。
  可以从下面官方文档的类方法中看出来。

https://docs.python.org/2.7/library/zipimport.html?highlight=zip#module-zipimport
https://docs.python.org/3.10/library/zipimport.html?highlight=zip#module-zipimport

  通过需改 import 机制,可以实现很多黑科技,但是推荐使用侵入性较小的使用方式。
  这个机制可以让某个模块虚空导入而不报错,这不符合正常使用 Python 的逻辑,可能会让团队其他人很懵逼的。
  如果某个 BUG 是因为这个机制导致的,其他人又不熟悉这块的话,那这问题查半天也不一定有结果 😢

  这种黑科技的方式无法支持 mypy 类型检测和回溯,倒是可以做一些代码桩来实现提示,但不是很推荐。

总结

  本次深入浅出地学习了 Python Import 的各种底层逻辑。
  以后有机会的话也想好好学习一下 CPython 的底层实现。

Python doit 库

作者 智伤帝
2022年3月28日 15:50

前言

  代码开发的过程中可能遇到一些情况想要通过 代码 来自动执行命令行生成一些东西的情况。
  如果不使用框架进行管理,这些代码脚本就很零碎地散落在各个地方。
  因此就找到这个框架可以很方便管理多个任务,实现

Github 地址
官方说明文档

doit 的基本用法

  在 doit 执行命令的地方添加一个 dodo.py 的脚本
  doit 会去读取 dodo.py 里面命名开头为 task_ 的方法作为执行的命令。

1
2
3
4
5
6
7
8
9
10
11
def task_hello():
"""hello"""

def python_hello(targets):
with open(targets[0], "a") as output:
output.write("Python says Hello World!!!\n")

return {
'actions': [python_hello],
'targets': ["hello.txt"],
}

  比如添加上面的方法到 dodo.py 里面
  执行 doit list 可以罗列出当前的可执行的命令

1
2
3
4
F:\thm_git\adam_pose_editor>doit list
hello hello
F:\thm_git\adam_pose_editor>doit hello
. hello

  执行 doit hello 就会在 dodo.py 缩在目录下输出一个 hello.txt 的文件。
  这个就是 doit 的基本用法。

dodo.py 配置

https://pydoit.org/configuration.html

  可以使用 doit -f xxx/dodo.py 配置 dodo.py 的路径
  也可以使用 pyproject.toml 进行配置

1
2
[tool.doit]
dodoFile = "scripts/dodo.py"

task 配置

  dodo.py 的 task 支持导入
  只要是 task_ 前缀的方法就会自动识别。
  也可以给函数添加 create_doit_tasks 属性,这样就可以自动生成了。 文档链接

  利用这些机制,我搞了一个装饰器可以给 task 添加一个短名的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def add_short_name(short_name):
"""Doit for short decorator.

Args:
short_name (str): short alias name.

Returns:
callable: decoartor function.
"""

def decorator(func):
globals()["task_{0}".format(short_name)] = func # noqa: WPS421
return func

return decorator

@add_short_name("pf")
def task_preflight():
"""Run pre commit for all files.

Returns:
dict: doit config.
"""
command = ["poetry", "run", "pre-commit", "run", "-a"]
return {"actions": [command], "verbosity": 2}

  这样运行 doit 会识别到两个 task ,可以分别通过 doit pf 或者 doit preflight 触发指令

1
2
3
>doit list 
pf Run pre commit for all files.
preflight Run pre commit for all files.

  但是默认排序是按命名来的,如果命令很多就会混在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>doit list 
b Run black format all python files.
black Run black format all python files.
d Run mkdocs serve.
dd Run mike to deploy docs.
docs Run mkdocs serve.
docs_deploy Run mike to deploy docs.
f Run `black` `isort`.
format Run `black` `isort`.
i Run isort format all python files.
isort Run isort format all python files.
l Run flakehell lint for all python files.
lint Run flakehell lint for all python files.
m Run mike serve.
mike Run mike serve.
pf Run pre commit for all files.
preflight Run pre commit for all files.
pt Run pytest.
pytest Run pytest.

  可以使用 doit list –sort=definition 的方式让排序变成创建顺序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
>doit list --sort=definition
f Run `black` `isort`.
format Run `black` `isort`.
pf Run pre commit for all files.
preflight Run pre commit for all files.
b Run black format all python files.
black Run black format all python files.
i Run isort format all python files.
isort Run isort format all python files.
l Run flakehell lint for all python files.
lint Run flakehell lint for all python files.
pt Run pytest.
pytest Run pytest.
d Run mkdocs serve.
docs Run mkdocs serve.
m Run mike serve.
mike Run mike serve.
dd Run mike to deploy docs.
docs_deploy Run mike to deploy docs.

  但是每次使用都要加一个参数配置,那是相当的麻烦。
  我们可以利用 DOIT_CONFIG 进行配置 文档链接

1
2
3
DOIT_CONFIG = {
"sort": "definition",
}

task group

  可以使用 task_dep 的方式执行多个定义好的 task 文档链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import glob
DIR = os.path.dirname(__file__)
PY_FILES = glob.glob(os.path.join(DIR, "**/*.py"), recursive=True)
@add_short_name("f")
def task_format():
"""Run `black` `isort`.

Returns:
dict: doit config.
"""
return {"actions": None, "task_dep": ["black", "isort"]}

@add_short_name("b")
def task_black():
"""Run black format all python files.

Returns:
dict: doit config.
"""
command = ["poetry", "run", "black"] + PY_FILES
return {"actions": [command], "verbosity": 2}


@add_short_name("i")
def task_isort():
"""Run isort format all python files.

Returns:
dict: doit config.
"""
command = ["poetry", "run", "isort"] + PY_FILES
return {"actions": [command], "verbosity": 2}

  通过上面的配置就可以快速给所有的 python 脚本运行 black 和 isort

task 传参

文档链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
def gen_api(api):
"""Generate API docs.

Args:
api (bool): flag to generate docs

Returns:
str: running command
"""
# NOTES(timmyliang): remove reference api
rmtree(os.path.join(DIR, "docs", "reference"), ignore_errors=True)
script_path = os.path.join(DIR, "docs", "gen_api_nav.py")
api_command = " ".join(["poetry", "run", "python", script_path])
serve_command = " ".join(["poetry", "run", "mkdocs", "serve"])
return f"{api_command} & {serve_command}" if api else serve_command


@add_short_name("d")
def task_docs():
"""Run mkdocs serve.

Returns:
dict: doit config.
"""
return {
"actions": [CmdAction(gen_api)],
"params": [
{
"name": "api",
"short": "a",
"type": bool,
"default": False,
"inverse": "flagoff",
"help": "generate api docs",
},
],
"verbosity": 2,
}

  通过 params 定义传入的参数,就可以控制 mkdocs 是否自动生成 api 的 markdown 脚本。

总结

  目前我使用上面的写法已经很够用了,其实它还有很多其他的配置可以用来做 C 编译。
  还可以定义 task 依赖 和 文件依赖,确保 task 的执行顺序。
  整体而言,doit 是个非常简单而是用的框架,配置 tox 等工具可谓是锦上添花。

Python dependencies 库

作者 智伤帝
2022年3月28日 09:50

前言

  在 Java Spring Boot 等等的后端领域,会大量使用依赖注入的方式来简化复杂的设计模式。
  实现参数的自动化注入。
  这些设计方式在 Python 的世界里使用不多,因为 Python 语言足够灵活。
  倘若需要开发复杂的框架,使用 依赖注入 框架可以简化很多代码。

Github 地址
官方说明文档

依赖注入解决的问题

参考文章

  在日常开发中,我们的方法调用可能会越来越深。

1
2
3
4
5
6
7
8
9
10

def create_robot(robot_name):
create_robot_hand()

def create_robot_hand():
create_robot_finger()

def create_robot_finger():
print("create_robot_finger")

  上面是一个简单的机器人创建调用函数。
  调用方式会伴随则系统的复杂程度逐层深入。
  到了 create_robot_finger 深度的时候,可能会需要在上层传入参数控制 finger 的数量

1
2
3
4
5
6
7
8
9

def create_robot(robot_name,finger_num=10):
create_robot_hand(finger_num=finger_num)

def create_robot_hand(finger_num=10):
create_robot_finger(finger_num=finger_num)

def create_robot_finger(finger_num=10):
print("create_robot_finger finder_number:{0}".format(finger_num))

  这需要将参数补充到 调用链条 的每一个函数当中。
  如果只是上面的 三层 调用深度,那可能手动修改维护还不是什么问题。
  但倘若调用深度很深,那这个代码修改量就会非常庞大。
  不利于代码的扩展和维护。


  在 Python 的世界里,解决这个问题的方法有很多。

  1. 导入 配置 模块,外部获取参数配置
  2. 面向对象 注入依赖,从实例化中获取参数配置

方案一 导入模块

1
2
3
4
5
6
7
8
"""settings.py"""

from __future__ import division
from __future__ import print_function
from __future__ import absolute_import

ROBOT_FINGER_NUM = 10

1
2
3
4
5
6
7
8
9
10
11
import settings

def create_robot(robot_name):
create_robot_hand()

def create_robot_hand():
create_robot_finger()

def create_robot_finger():
print("create_robot_finger finder_number:{0}".format(settings.ROBOT_FINGER_NUM))

  通过模块的方式将参数转移到外部,进行配置。
  这个做法可以解决参数传递的问题。

  缺点就是参数管理会比较麻烦,通常是将全局配置的参数都放到一个文件方便集中管理。
  但是这样会导致不同的逻辑调用的参数都会塞到一个文件里面,并不是十分整洁。

方案二 注入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from dependencies import Injector
import attr


@attr.s
class Robot(object):
finger_num = attr.ib(default=10)

def create_robot(self,robot_name):
self.create_robot_hand()

def create_robot_hand(self):
self.create_robot_finger()

def create_robot_finger(self):
print("create_robot_finger finder_number:{0}".format(self.finger_num))


class Container(Injector):
finger_num = 10
robot = Robot
Container.robot.create_robot("robot name")
# 打印 create_robot_finger finder_number:10

# `dependencies` 的实现等价于下面的代码
robot = Robot(finger_num=10)
robot.create_robot("robot name")

  使用 dependencies 库实现依赖注入,自动将容器内的数据填充到 类的实例化过程中。
  通过类的属性实现参数传递。

dependencies 介绍

  通过上面的案例可以看到。
  dependencies 可以自动实例化类,填充类初始化需要的参数。
  但它的功能还远不止这么简单。
  它还可以实现多个类实例化的自动填充,只要参数变量名命名配置好即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from dependencies import Injector
import attr

@attr.s
class Robot(object):
servo = attr.ib()
controller = attr.ib()
settings = attr.ib()
di_environment = attr.ib()

def run(self):
print("controller di_environment",self.controller.di_environment)
print("self di_environment",self.di_environment)
print("settings threshold",self.settings.threshold)
print("servo threshold",self.servo.threshold)

@attr.s
class Servo(object):
threshold = attr.ib()

@attr.s
class Controller(object):
di_environment = attr.ib()

@attr.s
class Settings(object):
threshold = attr.ib()

class Container(Injector):
threshold = 1
di_environment = "production"

robot = Robot
servo = Servo
settings = Settings
controller = Controller

Container.robot.run()
# 打印:
# controller di_environment production
# self di_environment production
# settings threshold 1
# servo threshold 1

  通过 dependencies 可以根据属性命名自动填充多个类的参数数据。
  container 的逻辑等价于下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
threshold = 1
di_environment = "production"
servo = Servo(threshold)
settings = Settings(threshold)
controller = Controller(di_environment)
robot = Robot(servo,controller,settings,di_environment)
robot.run()
# 打印:
# controller di_environment production
# self di_environment production
# settings threshold 1
# servo threshold 1

  但是 dependencies 库根据参数的命名自动实例化对象,参数的调整变得简单可控。

dependencies 实现 caller 方法

参考文章

  利用 依赖注入 可以分离 依赖 和 业务 逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import attr
from dependencies import Injector


class Editor(object):
def install_language(self,lang):
print("install language:{0}".format(lang))

editor = Editor()

@attr.s(frozen=True, slots=True)
class ChangeLanguage(object):
editor = attr.ib()

def __call__(self,lang):
self.editor.install_language(lang)

class Container(Injector):
editor = editor
change_language = ChangeLanguage

Container.change_language("en_US")
# 打印: install language:en_US

  利用 dependencies 可以构建出 caller 对象。
  caller 虽然用类构建,但是调用方式和方法一致,可以方法需要用到的依赖用类实例化的方式进行注入。
  实现依赖和传参的分离。

总结

  依赖注入可以很好解决函数调用过深的问题,让代码结构更加清晰。

Python blinker 库

作者 智伤帝
2022年2月28日 20:47

前言

  Qt 内置了非常棒的 信号槽的函数。
  可以让 UI 进行异步调用。
  但是有些时候,并不想依赖 Qt 框架同时又能实现信号槽的功能。
  这里可以使用 blinker 库来完成。

Github 地址
官方说明文档

blinker 基本用法

1
2
3
4
5
from blinker import signal,Signal

initialized = signal('initialized')
initialized is signal('initialized')
sig = Signal()

  可以使用匿名信号槽,也可以使用带名称的信号槽。


1
2
3
4
5
6
7
8
9
from blinker import signal
send_data = signal('send-data')
@send_data.connect
def receive_data(sender, **kw):
print("Caught signal from %r, data %r" % (sender, kw))
return 'received!'
result = send_data.send('anonymous', abc=123)
print(result) # 打印 [(<function receive_data at 0x000002A3328D4DC8>, 'received!')]
# 打印 Caught signal from 'anonymous', data {'abc': 123}

  可以用装饰器的方式连接信号槽
  触发信号槽使用 send 方法
  并且信号槽执行完可以拿到函数触发的返回值。


1
2
3
4
5
6
7
8
9
from blinker import signal

dice_roll = signal('dice_roll')
@dice_roll.connect_via(1)
@dice_roll.connect_via(3)
@dice_roll.connect_via(5)
def odd_subscriber(sender):
print("Observed dice roll %r." % sender)
result = dice_roll.send(3)

  另外一个特点就是可以根据触发的参数去触发相应注册的函数。
  Qt 因为要使用 C++,这种注册方式会非常麻烦。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from blinker import signal

initialized = signal("initialized")


@initialized.connect
def initialize_call1():
print("initialize_call1")


@initialized.connect
def initialize_call2():
print("initialize_call2")


@initialized.connect
def initialize_call3():
print("initialize_call3")


for key, weakref in initialized.receivers.items():
func = weakref()
func()

# 打印:
# initialize_call1
# initialize_call2
# initialize_call3

  通过信号槽的 receivers 方法可以获取到注册到信号槽的所有函数。

总结

  这个库可以摆脱 Qt 的依赖实现函数的异步调用。
  如果是 Qt 的环境建议还是使用 Qt 内置的 信号槽,这样可以支持 Qt 的多线程等处理。
  但如果是 Python 环境下想要摆脱 Qt 的依赖,则推荐 blinker 来完成信号触发。
  blinker 还有个好处是可以获取到注册的函数列表,而 Qt 基于 C++ 的并没有提供这个功能,只能通过 Meta 对象来判断这个信号槽是否有函数连接。 参考实现

❌
❌