forked from RimoChan/Vtuber_Tutorial
-
Notifications
You must be signed in to change notification settings - Fork 0
/
2.md
240 lines (168 loc) · 8.4 KB
/
2.md
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
228
229
230
231
232
233
234
235
236
237
238
239
240
# 从零开始的自制Vtuber: 2.画图和绘图
在第二节里,我们将会使用一个PSD立绘,并将它原封不动地用OpenGL画出来。
此外,这一节并不会用到上一节的程序,所以如果你没有把上一节搞定也可以接着学习。
这一节的代码理解起来没有什么难点,但是有许多繁琐的细节,如果你调不出来的话可以干脆去照抄仓库的代码。
## 警告
这个章节还没有完成校订,因此可能有和谐内容。
请您收好鸡儿,文明观球。
## 准备
在这个章节,你需要准备:
+ 电脑
+ Photoshop
+ 称手的画图工具
+ 基本的图形学知识
+ Python3
+ psd-tools
+ NumPy
+ OpenCV
+ OpenGL
## 画画
要让Vtuber好看,最根本的方法是把立绘画得好看一些。
如果你没办法画得好看,可能是色图看得少了,平时一定要多看萝莉图片!
首先,我们用称手的画图工具画一个女孩子,然后转换为PSD格式。
![./图/2-1.jpg](./图/2-1.jpg)
画出来大概是这样。
注意要把你的图层分好,大概的原则是如果你觉得这个物件是会动的,就把它分一个图层。
也可以参考我上面这个图层分法,总之图层拆得越细,动起来就越真实。
如果你不会画画,也可以找你的画师朋友帮忙。
如果你不会画画而且没有朋友,那就用手随便画几个正方形三角形之类,假装是机器人Vtuber。
## 读取PSD
我们用psd-tools来把PSD文件读进Python程序里。
我习惯用先序遍历把图层全部拆散,如果你觉得这样不合适也可以搞点visitor模式之类的。
我还顺便交换了值和图层的顺序,如果你不习惯就自己把它们删掉……
根据psd-tool的遍历顺序,先遍历到的子树对应的图层总是在后遍历到的子树的图层的下层,所以它必定是有序的。
```python
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把这些图层组合回来,检查一下有没有问题。
检查的方法很简单,只要按顺序写入图层,把它们都叠在对应的位置上就好了。
```python
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)`对新旧图层取一个加权平均数,就可以得到正确的结果。
```python
alpha = 新图层[:, :, 3]
for i in range(3):
img[a:c, b:d, i] = img[a:c, b:d, i] * (1 - alpha) + 新图层[:, :, i] * alpha
```
![./图/2-2.jpg](./图/2-2.jpg)
看起来和Photoshop里的效果一模一样!
修改之后,我们可以确认我们成功读入了图像。
## 使用OpenGL绘图
虽然我们刚才已经随便用OpenCV把它画出来了,但是为了接下来要做一些骚操作,我们还是得用OpenGL绘图。
OpenGL中的座标是四维座标`(x, y, z, w)`,在这里我们将`(x, y)`用作屏幕座标,`z`用作深度座标。
因为这张立绘的大小是`1000px*1000px`,而OpenGL的平面范围是`(-1, 1)`,此外XY轴和我的设定还是相反的。
所以我们先把立绘中每个图层的位置向量乘上变换矩阵,让它们到对应的位置去。
出于易读性考虑,我就用旧版OpenGL API来绘图吧,如果你能自己把它改为新版API的话就更好了。
```python
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()
```
![./图/2-3-1.jpg](./图/2-3-1.jpg)
看起来很像Minecraft里的村民!
不过图中有身体和头的轮廓,我们姑且还能认出这个莉沫酱的框架是没问题的。
对了,上面的`matrix`是我随手写的一个变换矩阵库,我会把它放在这个项目代码库里。
如果你的线性代数学得很好,应该可以自己把它写出来,因为它确实很简单,比如缩放矩阵`matrix.scale`只是这样定义的——
```python
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的纹理和混合功能,然后把每个图层都绑定好对应的纹理。
```python
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窗口中的图像看起来应该像是这样——
![./图/2-3-2.jpg](./图/2-3-2.jpg)
你会发现这张图有点模糊<sub>(尤其是眉毛和眼睛)</sub>,接下来我们来解决这个问题。
## 优化纹理
OpenGL要求纹理是正方形,而且边长是2的整数次幂,而我们为了图省事把所有的纹理都缩放到了`512px*512px`。
由于缩放算法并不那么聪明,因此在放大后缩小的过程中没法取样回原本的点,因此看起来莉沫酱变得很模糊了。
就是上面的代码的这个地方——
```python
纹理 = cv2.resize(图层数据['npdata'], (1024, 1024))
```
为了让莉沫酱变清楚,这回我们不在这里用缩放了,而是生成一个比原图层大一点的纹理,然后把原图层丢进去,这样就可以避免一次缩放。
```python
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)
```
额外返回的是座标,把它们在设置纹理座标的地方就行了,像是这样——
```python
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])
```
![./图/2-4.jpg](./图/2-4.jpg)
莉沫酱真是太清楚了!
## 结束之前
顺便说一下,新版的莉沫酱立绘不是我画的,是我的画师朋友<sub>(在收取了高昂的保护费以后)</sub>帮我画的。
上个版本我自己画的时候,画风是这样——
![./图/2-5.webp](./图/2-5.jpg)
欸好像我画得还不错嘛2333
<sub>莉沫酱: 为什么我的欧派缩水了???</sub>
## 结束
如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。
莉沫酱立绘的PSD也一起放在仓库里了,如果你画不出画的话可以用上。
<sub>不过要注意莉沫酱立绘并不在开源许可的范围内,所以不要用来做其他的事情。</sub>
最后祝各位鸡儿放假。
下一节:
+ [从零开始的自制Vtuber: 3.进入虚空](3.md)