在第二节里,我们将会使用一个PSD立绘,并将它原封不动地用OpenGL画出来。
此外,这一节并不会用到上一节的程序,所以如果你没有把上一节搞定也可以接着学习。
这一节的代码理解起来没有什么难点,但是有许多繁琐的细节,如果你调不出来的话可以干脆去照抄仓库的代码。
这个章节还没有完成校订,因此可能有和谐内容。
请您收好鸡儿,文明观球。
在这个章节,你需要准备:
- 电脑
- Photoshop
- 称手的画图工具
- 基本的图形学知识
- Python3
- psd-tools
- NumPy
- OpenCV
- OpenGL
要让Vtuber好看,最根本的方法是把立绘画得好看一些。
如果你没办法画得好看,可能是色图看得少了,平时一定要多看萝莉图片!
首先,我们用称手的画图工具画一个女孩子,然后转换为PSD格式。
画出来大概是这样。
注意要把你的图层分好,大概的原则是如果你觉得这个物件是会动的,就把它分一个图层。
也可以参考我上面这个图层分法,总之图层拆得越细,动起来就越真实。
如果你不会画画,也可以找你的画师朋友帮忙。
如果你不会画画而且没有朋友,那就用手随便画几个正方形三角形之类,假装是机器人Vtuber。
我们用psd-tools来把PSD文件读进Python程序里。
我习惯用先序遍历把图层全部拆散,如果你觉得这样不合适也可以搞点visitor模式之类的。
我还顺便交换了值和图层的顺序,如果你不习惯就自己把它们删掉……
根据psd-tool的遍历顺序,先遍历到的子树对应的图层总是在后遍历到的子树的图层的下层,所以它必定是有序的。
def 提取图层(psd):
所有图层 = []
def dfs(图层, path=''):
if 图层.is_group():
for i in 图层:
dfs(i, path + 图层.name + '/')
else:
a, b, c, d = 图层.bbox
npdata = 图层.numpy()
npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy()
所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata})
for 图层 in psd:
dfs(图层)
return 所有图层
接下来,你可以试着用OpenCV把这些图层组合回来,检查一下有没有问题。
检查的方法很简单,只要按顺序写入图层,把它们都叠在对应的位置上就好了。
def 测试图层叠加(所有图层):
img = np.ones([500, 500, 4], dtype=np.float32)
for 图层数据 in 所有图层:
a, b, c, d = 图层数据['位置']
新图层 = 图层数据['npdata']
img[a:c, b:d] = 新图层
cv2.imshow('', img)
cv2.waitKey()
如果你真的这么干的话就会出现奇怪的画面,这样叠起来实际上是不行的。
这是因为最后一个通道表示的是图层的透明度,应该由它来决定前面的颜色通道如何混合。因此我们得把叠加的语句稍作修改。
你可以想象一下,如果要叠上来的图层比较透明,那它对原本的颜色的影响就比较小,反之就比较大。
实际上,我们只要以(1-alpha, alpha)
对新旧图层取一个加权平均数,就可以得到正确的结果。
alpha = 新图层[:, :, 3]
for i in range(3):
img[a:c, b:d, i] = img[a:c, b:d, i] * (1 - alpha) + 新图层[:, :, i] * alpha
看起来和Photoshop里的效果一模一样!
修改之后,我们可以确认我们成功读入了图像。
虽然我们刚才已经随便用OpenCV把它画出来了,但是为了接下来要做一些骚操作,我们还是得用OpenGL绘图。
OpenGL中的座标是四维座标(x, y, z, w)
,在这里我们将(x, y)
用作屏幕座标,z
用作深度座标。
因为这张立绘的大小是1000px*1000px
,而OpenGL的平面范围是(-1, 1)
,此外XY轴和我的设定还是相反的。
所以我们先把立绘中每个图层的位置向量乘上变换矩阵,让它们到对应的位置去。
出于易读性考虑,我就用旧版OpenGL API来绘图吧,如果你能自己把它改为新版API的话就更好了。
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE)
for 图层数据 in 所有图层:
a, b, c, d = 图层数据['位置']
p1 = np.array([a, b, 0, 1])
p2 = np.array([a, d, 0, 1])
p3 = np.array([c, d, 0, 1])
p4 = np.array([c, b, 0, 1])
model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \
matrix.translate(-1, -1, 0) @ \
matrix.rotate_ax(-math.pi/2, axis=(0, 1))
glBegin(GL_QUADS)
for p in [p1, p2, p3, p4]:
p = p @ model
glVertex4f(*p)
glEnd()
看起来很像Minecraft里的村民! 不过图中有身体和头的轮廓,我们姑且还能认出这个莉沫酱的框架是没问题的。
对了,上面的matrix
是我随手写的一个变换矩阵库,我会把它放在这个项目代码库里。
如果你的线性代数学得很好,应该可以自己把它写出来,因为它确实很简单,比如缩放矩阵matrix.scale
只是这样定义的——
def scale(x, y, z):
a = np.eye(4, dtype=np.float32)
a[0, 0] = x
a[1, 1] = y
a[2, 2] = z
return a
接下来我们要为莉沫酱框架贴上纹理。
首先,我们启用OpenGL的纹理和混合功能,然后把每个图层都绑定好对应的纹理。
glEnable(GL_TEXTURE_2D)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
for 图层数据 in 所有图层:
纹理编号 = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, 纹理编号)
纹理 = cv2.resize(图层数据['npdata'], (1024, 1024))
width, height = 纹理.shape[:2]
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glGenerateMipmap(GL_TEXTURE_2D)
图层数据['纹理编号'] = 纹理编号
然后在绘制每个图层的时候,将纹理绑定到对应的纹理编号上,这个步骤就算是完成了。
最后OpenGL窗口中的图像看起来应该像是这样——
你会发现这张图有点模糊(尤其是眉毛和眼睛),接下来我们来解决这个问题。
OpenGL要求纹理是正方形,而且边长是2的整数次幂,而我们为了图省事把所有的纹理都缩放到了512px*512px
。
由于缩放算法并不那么聪明,因此在放大后缩小的过程中没法取样回原本的点,因此看起来莉沫酱变得很模糊了。
就是上面的代码的这个地方——
纹理 = cv2.resize(图层数据['npdata'], (1024, 1024))
为了让莉沫酱变清楚,这回我们不在这里用缩放了,而是生成一个比原图层大一点的纹理,然后把原图层丢进去,这样就可以避免一次缩放。
def 生成纹理(img):
w, h = img.shape[:2]
d = 2**int(max(math.log2(w), math.log2(h))+1)
纹理 = np.zeros([d,d,4], dtype=img.dtype)
纹理[:w,:h] = img
return 纹理, (w/d, h/d)
额外返回的是座标,把它们在设置纹理座标的地方就行了,像是这样——
q, w = 纹理座标
p1 = np.array([a, b, 0, 1, 0, 0])
p2 = np.array([a, d, 0, 1, w, 0])
p3 = np.array([c, d, 0, 1, w, q])
p4 = np.array([c, b, 0, 1, 0, q])
莉沫酱真是太清楚了!
顺便说一下,新版的莉沫酱立绘不是我画的,是我的画师朋友(在收取了高昂的保护费以后)帮我画的。
上个版本我自己画的时候,画风是这样——
欸好像我画得还不错嘛2333
莉沫酱: 为什么我的欧派缩水了???
如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。
莉沫酱立绘的PSD也一起放在仓库里了,如果你画不出画的话可以用上。
不过要注意莉沫酱立绘并不在开源许可的范围内,所以不要用来做其他的事情。
最后祝各位鸡儿放假。
下一节: