前言

  最近特效那边给我提了需求,需要在 Maya 输出 unreal 的顶点动画。
  顶点动画输出工具其实 unreal 官方也提供了一个 链接
  但是这个顶点动画输出工具是针对 3dsMax 的,需要做一个 Maya 版本。

  特效那边需要给角色做一个跟随的路径动画面片,然后将面片的动画以上述的纹理图片的形式 在 unreal 中用 shader 驱动动画。
  过去整个流程也一直是在 Max 里面完成的,最近需要配合 Maya 的动画进行处理,所以如果有 Maya 相应的工具会方便很多。
  我研究了一下 Max 的导出流程,还原 Max 的面片生成处理其实不难。
  最大的问题反而是 Max 导出流程中微不足道的点,那就是 exr 数据导出的问题。
  在 Max 里面提供了 exr 导出的 API ,而且 Max 的坐标轴和 unreal 一样,不需要额外的处理。
  但是 Maya 的 exr 导出 API 藏得非常深,而且数据处理不好弄。

OpenMaya 输出 exr

  OpenMaya 的图像 API 有两个,分别是 MImage 和 MTexture
  MImage 有 setPixels 命令但是支持 0 - 255 区间的数值,输出格式不支持 exr 等纹理。

  这方面的研究我之前在做 MayaViewportCapture 工具的时候有研究过。
  Maya MImage 可以通过 setPixels 赋值像素数据,也可以对像素数据进行 0 - 255 的修改。

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
# coding:utf-8
from __future__ import unicode_literals,division,print_function

__author__ = 'timmyliang'
__email__ = '820472580@qq.com'
__date__ = '2020-05-24 10:35:32'

"""
MImage 无法存储浮点数(8bit) | 存储格式也有限制 不支持 exr
"""

import maya.api.OpenMaya as om
import maya.api.OpenMayaUI as omui
import maya.api.OpenMayaRender as omr

# NOTE 如果裁剪区域超过原图范围则限制到原图的边界上
width = 400
height = 400

# NOTE https://groups.google.com/forum/#!topic/python_inside_maya/Q9NuAd6Av20
pixels = bytearray(width*height*4)
for w in range(width):
for h in range(height):
pos = (w+h*width)*4
# NOTE 这里加数字代表当前像素下 RGBA 四个通道的值
pixels[pos+0] = 255
pixels[pos+1] = 255
pixels[pos+2] = 255
pixels[pos+3] = 255

# NOTE 返回裁剪的 Image
img = om.MImage()
img.setPixels(pixels, width, height)

DIR = os.path.dirname(__file__)
img.writeToFile(os.path.join(DIR,"MImage.png"), 'png')

  MTexture 格式可以输出 exr 格式,这个和 MImage 不一样,用来定义 Maya 的纹理贴图。
  这个对象无法像 MImage 一样进行实例化,需要借助 MTextureManager 来生成 MTexture 实例。
  而且 OpenMaya1.0 的 API 已经不支持,只可以使用 2.0 的 API 进行获取。

  通过查C++文档的案例可以知道 MTextureManager 也需要通过 MRenderer 获取,无法直接实例化(属于单例模式?!)

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
# coding:utf-8
from __future__ import unicode_literals,division,print_function

__author__ = 'timmyliang'
__email__ = '820472580@qq.com'
__date__ = '2020-05-24 10:35:32'

"""
MTexture 不支持 OpenMaya 1.0
MTexture 无法实例化

MTexture 比起 MImage 可以输出 exr 格式
"""

import os
import ctypes
import struct

import maya.api.OpenMaya as om
import maya.api.OpenMayaUI as omui
import maya.api.OpenMayaRender as omr

manager = omr.MRenderer.getTextureManager()
print (dir(manager))
path_list = manager.imagePaths()

# NOTE ----------------------------------------------------------------
# NOTE 读取本地图片
# NOTE ----------------------------------------------------------------
DIR = os.path.dirname(__file__)
file_name = os.path.join(DIR,"test7.exr")

texture = manager.acquireTexture(file_name)
# print ("bytesPerPixel",texture.bytesPerPixel())

pixel,rowPitch,total = texture.rawData()
ptr = ctypes.cast(pixel.__long__(), ctypes.POINTER(ctypes.c_char))
pixels = ctypes.string_at(ptr, total)
height = int(total/rowPitch)
width = int(rowPitch/4)
# texture.freeRawData(pixel)
# print(height,total,width)


pixels = range(width*height*4)
for w in range(width):
for h in range(height):
pos = (w+h*width)*4
# NOTE 这里加数字代表当前像素下 RGBA 四个通道的值
pixels[pos+0] = 255
pixels[pos+1] = 1
pixels[pos+2] = 1
pixels[pos+3] = 255

img = om.MImage()
img.setPixels(pixels, width, height)
img.writeToFile(os.path.join(DIR,"MImage.png"), 'png')

# NOTE 无法执行 update 修改像素数据 _(:з」∠)_
texture.update(pixels,False,rowPitch)
manager.saveTexture(texture,os.path.join(DIR,"MTexture.exr"))

  MTexture 没有研究出如何通过 Python 修改像素数据,我用上面的代码可以修改 MImage 的像素却修改不了 MTexture 的像素(输出的图片为黑色)

OpenEXR 库输出

  鉴于上面的 MTexture 无法操作的原因,我只好退而求其次,用外部的 Python 库来进行修改。
  反正可以通过 PyInstaller 打包然后用 Maya 的 Python 外调 exe 来实现 exr 数据输出。
  网上查了一下 Python 有 OpenEXR 的库。

  第一步安装就出现了问题,安装提示缺少了头文件,windows 平台安装失败。
  网上搜了一下,提到 Linux 系统安装 libopenexr-dev 的系统库之后才可以正常安装。
  救救 windows 平台吧 o(╥﹏╥)o

  后来再 stack overflow 上找到了相关问题的回答 链接
  评论下面推荐使用 https://www.lfd.uci.edu/~gohlke/pythonlibs/ 这个文章下载 build 的二进制 wheel 进行安装
  然而坑爹的情况出现了。
  我使用 QQ浏览器 点击链接下载居然跳转到 404 目录,我以为是网站出问题了。
  继续找其他网站的 build 版本,无果,之后网上搜了一下这个网站 404 的问题,没想到居然是 浏览器 的锅。
  使用 Chrome 浏览器就可以正常下载了(:з」∠)


  好不容易才终于可以正常安装 OpenEXR 这个库了,尝试过在 Maya 安装,果然转完之后无法执行 dll 。
  所以转而去到外部的 Python 进行安装。


  后续流程就是 Maya 记录特效物体每一帧的每一个顶点的位置,然后输出 json 文件。
  后续利用 OpenEXR 这个库读取 json 数据 生成 EXR 图片。
  当时没有考虑到 Maya 的轴向和 Unreal 不一样,结果输出的 图片 信息看起来不对,放进引擎更加不对ε=(´ο`*)))

Python 输出 exr

  因为上述的原因,这个 Max 插件导出转成 Maya 导出一直卡主。
  后来我搜了一下有没有现成的 Maya 插件,没想到还真让我搜到了 Maya 版本的插件。
  就在 Unreal 的官方论坛里面 链接
  这个插件还在不断更新中,就在今天写文章之际还更新了一版。

  既然上面输出 Exr 碰到那么多问题,这个插件到底是怎么做到 Exr 的输出呢?
  当我翻阅它的代码时候,我惊呆了,居然使用了 纯 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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# coding:utf-8
from __future__ import division,print_function

__author__ = 'timmyliang'
__email__ = '820472580@qq.com'
__date__ = '2020-05-25 21:12:22'

"""
https://www.openexr.com/documentation/openexrfilelayout.pdf
https://www.openexr.com/documentation/TechnicalIntroduction.pdf
根据 OpenEXR 规范通过 Python struct 输出二进制
"""

# Written by user "Styler" on Tech-Artists

import os
import struct
import binascii

from math import copysign, frexp, isinf, isnan, trunc

NEGATIVE_INFINITY = b'\x00\xfc'
POSITIVE_INFINITY = b'\x00\x7c'
POSITIVE_ZERO = b'\x00\x00'
NEGATIVE_ZERO = b'\x00\x80'
# exp=2**5-1 and significand non-zero
EXAMPLE_NAN = struct.pack('<H', (0b11111 << 10) | 1)

def dump_to_exr_rgb16uncomp(height, width, data, filename):
"""
:param height: Height of image
:param width: Width of image
:param data: A sequence (list/tuple) of float point values in format [r, g, b, r, g, b ...]
:param filename: filename for output
"""
def make_chlist_value(name, typ, linear=0, samx=1, samy=1):
# NOTE 定义好了 chlist 输出格式 https://www.openexr.com/documentation/openexrfilelayout.pdf#page=14
return ''.join([
# NOTE https://www.openexr.com/documentation/TechnicalIntroduction.pdf#page=7
# NOTE 包含 名称 和 类型
name, '\x00',
struct.pack('I', typ),
struct.pack('B', linear),
'\x00\x00\x00', # reserved
# NOTE RGBA 图片 为 1
struct.pack('I', samx),
struct.pack('I', samy)])

def make_attr(name, typ, size, value):
# NOTE Attribute Layout https://www.openexr.com/documentation/openexrfilelayout.pdf#page=8
return ''.join([
name, '\x00',
typ, '\x00',
struct.pack('I', size),
value])

def binary16(f):
# NOTE IEEE 754-2008 https://stackoverflow.com/questions/31464022/
# NOTE https://gist.github.com/zed/59a413ae2ed4141d2037
"""Convert Python float to IEEE 754-2008 (binary16) format.
https://en.wikipedia.org/wiki/Half-precision_floating-point_format
"""
if isnan(f):
return EXAMPLE_NAN

sign = copysign(1, f) < 0
if isinf(f):
return NEGATIVE_INFINITY if sign else POSITIVE_INFINITY

# 1bit 10bits 5bits
# f = (-1)**sign * (1 + f16 / 2**10) * 2**(e16 - 15)
# f = (m * 2) * 2**(e - 1)
m, e = frexp(f)
assert not (isnan(m) or isinf(m))
if e == 0 and m == 0: # zero
return NEGATIVE_ZERO if sign else POSITIVE_ZERO

f16 = trunc((2 * abs(m) - 1) * 2**10) # XXX round toward zero
assert 0 <= f16 < 2**10
e16 = e + 14
if e16 <= 0: # subnormal
# f = (-1)**sign * fraction / 2**10 * 2**(-14)
f16 = int(2**14 * 2**10 * abs(f) + .5) # XXX round
e16 = 0
elif e16 >= 0b11111: # infinite
return NEGATIVE_INFINITY if sign else POSITIVE_INFINITY
else:
# normalized value
assert 0b00001 <= e16 < 0b11111, (f, sign, e16, f16)
"""
http://blogs.perl.org/users/rurban/2012/09/reading-binary-floating-point-numbers-numbers-part2.html
sign 1 bit 15
exp 5 bits 14-10 bias 15
frac 10 bits 9-0
(-1)**sign * (1 + fraction / 2**10) * 2**(exp - 15)
+-+-----[1]+----------[0]+ # little endian
|S| exp | fraction |
+-+--------+-------------+
|1|<---5-->|<---10bits-->|
<--------16 bits--------->
"""
return (sign << 15) | (e16 << 10) | f16
# return struct.pack('<H', (sign << 15) | (e16 << 10) | f16)

def pack_half(value):
# NOTE 将传入的 浮点数 转换为 half 类型
# NOTE 参考文章 https://akaedu.github.io/book/ch14s04.html
F16_EXPONENT_BITS = 0x1F
F16_EXPONENT_SHIFT = 10
F16_EXPONENT_BIAS = 15
F16_MANTISSA_BITS = 0x3ff
F16_MANTISSA_SHIFT = (23 - F16_EXPONENT_SHIFT)
F16_MAX_EXPONENT = (F16_EXPONENT_BITS << F16_EXPONENT_SHIFT)

a = struct.pack('>f', value)
b = binascii.hexlify(a)

f32 = int(b, 16)
f16 = 0
sign = (f32 >> 16) & 0x8000
exponent = ((f32 >> 23) & 0xff) - 127
mantissa = f32 & 0x007fffff

if exponent == 128:
f16 = sign | F16_MAX_EXPONENT
if mantissa:
f16 |= (mantissa & F16_MANTISSA_BITS)
elif exponent > 15:
f16 = sign | F16_MAX_EXPONENT
elif exponent > -15:
exponent += F16_EXPONENT_BIAS
mantissa >>= F16_MANTISSA_SHIFT
f16 = sign | exponent << F16_EXPONENT_SHIFT | mantissa
else:
f16 = sign

return f16

depth = 3
# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=6 第 5 - 6 页有描述
# NOTE \x76\x2f\x31\x01 固定数据 10 进制为 20000630 | 用来区分 OpenEXR 格式
# NOTE \x02\x00\x00\x00 这四个数据为 Version Field | \x02 表示 2.0 版本
fdata = ['\x76\x2f\x31\x01\x02\x00\x00\x00'] # magic and version

if width*height*depth != len(data):
raise ValueError('Data length does not fit with image size')

# build header
# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=8
# NOTE 定义属性通道描述
channels = '%s\x00' % ''.join([make_chlist_value(name, typ) for name, typ in (('R', 1), ('G', 1), ('B', 1))])
fdata.append(make_attr('channels', 'chlist', 18*depth+1, channels))

# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=15
fdata.append(make_attr('compression', 'compression', 1, '\x00')) # 0 - uncompressed

# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=14
# NOTE box2i 包含 4 个 int | int 占用 4 个字节 | 前面 '\x00'*8 表示 0 , 0
windata = ''.join(['\x00'*8, struct.pack('i', width-1), struct.pack('i', height-1)])
fdata.append(make_attr('dataWindow', 'box2i', 16, windata))
fdata.append(make_attr('displayWindow', 'box2i', 16, windata))

# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=15
fdata.append(make_attr('lineOrder', 'lineOrder', 1, '\x00')) # inc y
# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=8
fdata.append(make_attr('pixelAspectRatio', 'float', 4, struct.pack('f', 1.0)))
fdata.append(make_attr('screenWindowCenter', 'v2f', 8, struct.pack('ff', 0, 0)))
fdata.append(make_attr('screenWindowWidth', 'float', 4, struct.pack('f', 1)))
fdata.append('\x00') # end of the header


# NOTE https://www.openexr.com/documentation/openexrfilelayout.pdf#page=10
# NOTE 计算 offset Tables | offset Tables 用来读取像素的映射表
# NOTE offsetChunk 使用 unsigned long 类型 占 8 个字节
# NOTE 添加了 height 个 offsetChunk 数据(所以 + height*8)
# calc lines offset
offtab_size = sum([len(x) for x in fdata]) + height*8
# NOTE 每个单元格像素包含 RGB 三个通道 depth | 2 代表 half 类型的大小(float 为 4 字节, half 为 2 字节)
# NOTE 前面加 8 是因为 每行开始需要有 起始结束的描述数据 | 两个 int 类型共占 8 个字节
# NOTE https://www.openexr.com/documentation/TechnicalIntroduction.pdf#page=13
line_size = 8 + width*2*depth
# fill offsets table
for i in xrange(height):
fdata.append(struct.pack('Q', offtab_size + i*line_size))

data_size = struct.pack('I', width*2*depth)

# NOTE Scan Lines
# add data by scanlines
for j in xrange(height):
# NOTE 起始和结束的行描述数据
line = [struct.pack('i', j), data_size]
n = j*width*depth

for i in xrange(depth):
# NOTE 读取传入的像素数据 转换为 half 类型
chdata = [binary16(data[n+x+i]) for x in xrange(0, width*depth, depth)]
# NOTE 使用 short 类型的长度代指 half 占用的空间
line.append(struct.pack('%sH' % len(chdata), *chdata))
fdata.append(''.join(line))

# write to file
dirname = os.path.dirname(filename)
if not os.path.exists(dirname):
os.makedirs(dirname)

with open(filename, 'wb') as f:
f.write(''.join(fdata))


if __name__ == "__main__":

# NOTE 测试输出 400 * 400 蓝色的 exr 图片
width = 10
height = 10
pixels = range(width*height*3)
for w in range(width):
for h in range(height):
pos = (w+h*width)*3
# NOTE RGB 的顺序是反过来的
pixels[pos+2] = 0.0
pixels[pos+1] = 0.0
pixels[pos+0] = 255.0

DIR = os.path.dirname(__file__)
file_name = os.path.join(DIR,"test.exr")
dump_to_exr_rgb16uncomp(width,height,pixels,file_name)

github地址

  这个文件如何输出 exr 我进行了进一步的研究,代码的注释都在上面。
  主要通过 struct 模块进行 二进制 的操作,根据 OpenExr 提供的操作文档给文件添加相应二进制信息。
  其中比较厉害的操作是通过 python 创建 half 类型的二进制数据,需要通过 bitwise 位运算实现,目前还没有完全弄懂原理。
  在 Stack Overflow 上找到了不同打包 half 类型的方案 链接
  其中 binary16 的代码虽然写法不一样,但是可以得到上面 pack_half 函数的效果,原理同样还没有完全搞清楚。
  根据 https://akaedu.github.io/book/ch14s04.html 上面对浮点数的描述。
  half 类型的浮点数应该是通过科学计数法记录数据的,因此计算和转换的时候会有误差
  有时间再进一步研究 浮点数 转换, Exr 的输出原理基本就是这样。

顶点动画输出

  搞了这么久我们貌似将主题给搞偏了,虽然 输出 EXR 格式花了大部分的研究时间,其实我们的根本目标是输出 Maya 的顶点动画。
  还有很重要的一步没有做,如何将 Maya 的模型动画输出来。
  其实 Unreal 论坛的脚本也有很详细的操作。
  主要利用了 Maya 的 snapShot 命令生成时间内的模型 快照。
  不过我拿几个月前的老版本处理会有 Bug ,直接输出在运动路径的模型的快照是固定不动的。
  后面我将 snapShot 的操作改为逐帧处理即可,就是输出效率大大降低。

  有了快照信息就有了模型再运动时间内的顶点位置数据,然后将这些数据打包整理到 EXR 的 RGB 通道上就完成了顶点动画信息的转移。
  最后只需要输出动画模型的 FBX 即可。
  这种处理方式也是有问题的,只支持一些小模型的动画,如果模型点数太多超过 8K 贴图,就无法正常输出了。(我没有测试过,是 Unreal 论坛的老哥自己说的)

总结

  本来想搞 顶点动画 的,没想到却花了大量时间研究 EXR 的 binary 数据输出了。
  最后有没有搞清楚 half 类型是如何输出的,倒是发现纯 Python 也可以输出类似 EXR 这样复杂的数据。
  Python 的功能果然很强大,这次又开阔了我的眼界。