Skip to content

Latest commit

 

History

History
240 lines (168 loc) · 8.4 KB

2.md

File metadata and controls

240 lines (168 loc) · 8.4 KB

从零开始的自制Vtuber: 2.画图和绘图

在第二节里,我们将会使用一个PSD立绘,并将它原封不动地用OpenGL画出来。

此外,这一节并不会用到上一节的程序,所以如果你没有把上一节搞定也可以接着学习。

这一节的代码理解起来没有什么难点,但是有许多繁琐的细节,如果你调不出来的话可以干脆去照抄仓库的代码。

警告

这个章节还没有完成校订,因此可能有和谐内容。

请您收好鸡儿,文明观球。

准备

在这个章节,你需要准备:

  • 电脑
  • Photoshop
  • 称手的画图工具
  • 基本的图形学知识
  • Python3
  • psd-tools
  • NumPy
  • OpenCV
  • OpenGL

画画

要让Vtuber好看,最根本的方法是把立绘画得好看一些。
如果你没办法画得好看,可能是色图看得少了,平时一定要多看萝莉图片!

首先,我们用称手的画图工具画一个女孩子,然后转换为PSD格式。

./图/2-1.jpg

画出来大概是这样。

注意要把你的图层分好,大概的原则是如果你觉得这个物件是会动的,就把它分一个图层。
也可以参考我上面这个图层分法,总之图层拆得越细,动起来就越真实。

如果你不会画画,也可以找你的画师朋友帮忙。
如果你不会画画而且没有朋友,那就用手随便画几个正方形三角形之类,假装是机器人Vtuber。

读取PSD

我们用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

./图/2-2.jpg

看起来和Photoshop里的效果一模一样!
修改之后,我们可以确认我们成功读入了图像。

使用OpenGL绘图

虽然我们刚才已经随便用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()

./图/2-3-1.jpg

看起来很像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窗口中的图像看起来应该像是这样——

./图/2-3-2.jpg

你会发现这张图有点模糊(尤其是眉毛和眼睛),接下来我们来解决这个问题。

优化纹理

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])

./图/2-4.jpg

莉沫酱真是太清楚了!

结束之前

顺便说一下,新版的莉沫酱立绘不是我画的,是我的画师朋友(在收取了高昂的保护费以后)帮我画的。

上个版本我自己画的时候,画风是这样——

./图/2-5.webp

欸好像我画得还不错嘛2333

莉沫酱: 为什么我的欧派缩水了???

结束

如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。
莉沫酱立绘的PSD也一起放在仓库里了,如果你画不出画的话可以用上。
不过要注意莉沫酱立绘并不在开源许可的范围内,所以不要用来做其他的事情。

最后祝各位鸡儿放假。

下一节: