一、理论基础
纹理与纹理坐标
在前面的学习中,我们已经成功在窗口中绘制出了三角形,并且我们通过顶点数据为每一个顶点设置了颜色,而三角形内的点的颜色则有硬件通过插值计算得来。但是,在更多时候,我们不会使用顶点的颜色属性,会用一张图片直接定义三角形中每一个点的颜色,这张图片就被称之为纹理。
你可以将纹理看作是一张贴在三角形上的图片。而图片贴在三角形上的方式是多样的,可以正着贴,也可以倒着贴,当然也可以斜着贴。为每一个顶点设置纹理坐标(UV或TexCoord)即可固定纹理贴在三角形上的方式。
二、具体用法
1、顶点数据
我们稍微修改上一章提供的代码。
首先,我们提供的顶点属性如下:
triangle = np.array([
# 顶点NDC坐标 纹理坐标
-0.5, -0.5, 0, 0, 0,
0.5, -0.5, 0, 1, 0,
0, 0.5, 0, 0.5, 1
], dtype=np.float32)
可以看到,我们不再提供顶点的颜色属性,转而提供顶点的纹理坐标属性。纹理坐标是一个vec2
类型的变量,故只需要两个浮点数即可。
同时,由于顶点数据发生了改变,我们原来的解释顶点数据的语句也需要发生改变:
shader.setAttrib("aPos", 3, GL_FLOAT, 20, 0)
shader.setAttrib("aTex", 2, GL_FLOAT, 20, 12)
我们不再设置aColor
变量,而设置顶点的aTex
变量,它是一个vec2
类型的变量,故其size
为2。注意,此时顶点属性的步长有原来的24字节变成了20字节(相比于原来少了一个浮点数)。
以下为完整的Python代码:
import numpy as np
from OpenGL.GL import *
from OpenGL.arrays.vbo import VBO
# 导入Window类和Shader类
from shader import Shader
from window import Window
w = Window(1920, 1080, "Test")
triangle = np.array([
-0.5, -0.5, 0, 0, 0,
0.5, -0.5, 0, 1, 0,
0, 0.5, 0, 0, 0.5, 1
], dtype=np.float32)
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
vbo = VBO(triangle, GL_STATIC_DRAW)
vbo.bind()
# 导入顶点着色器文件和片元着色器文件
shader = Shader("base.vert", "base.frag")
shader.setAttrib("aPos", 3, GL_FLOAT, 20, 0)
shader.setAttrib("aTex", 2, GL_FLOAT, 20, 12)
def render():
shader.use()
glBindVertexArray(vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
w.loop(render)
同时,着色器程序也要发生改变:
顶点着色器:
#version 330 core
in vec3 aPos;
in vec2 aTex; // 顶点的纹理坐标属性
out vec2 uv; // 将纹理坐标传给片元着色器
void main()
{
gl_Position = vec4(aPos, 1.0f);
uv = aTex; // 将纹理坐标传给片元着色器
}
由于纹理坐标是针对三角形内每一个像素的,所以我们要把纹理坐标传给片元着色器。
片元着色器:
#version 330 core
in vec2 uv; // 纹理坐标
out vec4 FragColor;
uniform sampler2D tex; // 纹理
void main()
{
FragColor = texture(tex, uv); // 从纹理中采样
}
我们接收了从顶点着色器传来的纹理坐标in vec2 uv
。我们定义了一个表示纹理的变量tex
,它的变量类型为sampler2D
,注意这个变量是一个uniform
变量,因为我们之后要通过Python代码把纹理数据传给片元着色器。
在主函数中,调用了texture(tex, uv)
函数,这个函数的含义是通过纹理坐标在纹理的相应位置进行采样,获得一个vec4
的颜色值,并将颜色值赋给这个像素以显示纹理。
这时候如果我们运行代码,只能看到一片漆黑,因为我们还没有把纹理数据传给着色器,texture
函数无法获取到正确的值。
2、将纹理数据传给GPU
本文使用的纹理图:
图源自LearnOpenGL CN。
接下来介绍在PyOpenGL中,如何将纹理数据传给GPU。
首先,我们通过Python的PIL库来读取图片:
from PIL import Image
img = Image.open("wall.jpg") # 读取文件
img = np.array(img, np.int8) # 将Image转为np.int8类型的numpy数组(PyOpenGL规定必须要这种类型)
texHeight, texWidth, _ = img.shape # 获取图片的宽高
在OpenGL中,纹理也是一个对象。通过glGenTextures(1)
函数可以创建纹理对象,通过glBindTexture(GL_TEXTURE_2D, tex)
来绑定纹理对象:
tex = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, tex)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, texWidth, texHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, img)
glGenerateMipmap(GL_TEXTURE_2D)
glTexImage2D
,这个函数用于将纹理数据传递给GPU。其参数较多,我们注意介绍:
第一个参数:纹理类型。纹理类型主要有两种。
纹理类型 | 含义 |
---|---|
GL_TEXTURE_2D | 2D纹理 |
GL_TEXTURE_CUBE_MAP | 立方体纹理 |
立方体纹理将在以后介绍。
第二个参数:指定多级渐远纹理(Mipmap)的级别。关于Mipmap,我们稍后介绍。现在只需要知道,这个参数一般填0即可。
第三个参数:把纹理存储为何种形式。主要有GL_RGB
和GL_RGBA
两种。
第四、第五个参数:纹理的宽高。
第六个参数:由于历史遗留问题,这个参数总是填0。
第七、第八个参数:纹理的格式和数据类型。
第九个参数:纹理数据。
这里有一个表,解释了内部格式(参数三)、图片格式(参数七)、数据类型(参数八)之间的关系:
内部格式 | 图片格式 | 数据类型 |
---|---|---|
GL_RGB | GL_RGB | GL_UNSIGNED_BYTE |
GL_RGBA | GL_RGBA | GL_UNSIGNED_BYTE |
GL_ALPHA | GL_ALPHA | GL_UNSIGNED_BYTE |
上表只是列出了常用的几种格式,并非 OpenGL 支持的所有格式。
从上表可以看出,参数三和参数七一般相同,参数八一般为GL_UNSIGNED_BYTE
。
glGenerateMipmap
:生成多级渐远纹理(Mipmap)。这里有一篇微软对Mipmap的介绍。简而言之,Mipmap就是对于同一张图像,当它的尺寸发生变化时,我们用不同像素的纹理来显示这个图像,这种方法可以显著的改善因图片缩小带来的失真问题,但会占用更多的内存。通过调用glGenerateMipmap
,OpenGL会自动为我们生成不同像素的纹理序列。
3、将纹理索引传给着色器
glActiveTexture(GL_TEXTURE0)
shader.setUniform("tex", 0)
对于每一个纹理,我们都需要为它分配一个索引值,以便着色器能通过索引值获取到纹理数据;另外,当同一个着色器需要多张纹理时,也需要用索引值来区分不同的纹理。glActiveTexture(GL_TEXTURE0)
为当前绑定的纹理分配了索引值0,该函数的参数为GL_TEXTURE{X}
,X可以是0~31。再通过设置uniform
变量的方法,将该索引值传递给着色器。
4、纹理相关设置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
通过glTexParameteri
,可以设置当前绑定的纹理的一些属性。该函数有三个参数,第一个参数为纹理类型,第二个参数为要修改的纹理的属性,第三个参数为该属性的值。
属性 | 含义 |
---|---|
GL_TEXTURE_WRAP_S | 纹理X轴的环绕方式 |
GL_TEXTURE_WRAP_T | 纹理Y轴的环绕方式 |
GL_TEXTURE_MAG_FILTER | 纹理放大时的插值算法 |
GL_TEXTURE_MIN_FILTER | 纹理缩小时的插值算法 |
上表中有两个名词需要解释:
首先,环绕方式,是指当纹理坐标在[0, 1]之外时的处理方式。
环绕方式 | 描述 |
---|---|
GL_REPEAT | 环绕方式的默认值。重复纹理图像。 |
GL_MIRRORED_REPEAT | 和GL_REPEAT一样,但每次重复图片是镜像放置的。 |
GL_CLAMP_TO_EDGE | 超出的部分会重复纹理坐标的边缘,产生一种边缘被拉伸的效果。 |
GL_CLAMP_TO_BORDER | 超出的坐标为用户指定的边缘颜色。 |
其次,插值方式,即当图片放大缩小时,如何处理纹理。
插值方式 | 描述 |
---|---|
GL_NEAREST | 最近邻插值 |
GL_LINEAR | 双线性插值 |
GL_LINEAR_MIPMAP_LINEAR | 三线性插值 |
这三种插值算法的具体含义这里不做介绍,有兴趣的读者可以自行查阅资料。注意查阅资料时区分双三次插值和三线性插值。
5、结果
至此,我们得到了绘制纹理的完整代码:
import numpy as np
from OpenGL.GL import *
from OpenGL.arrays.vbo import VBO
from PIL import Image
from shader import Shader
from window import Window
# 创建窗口
w = Window(1920, 1080, "Test")
# 顶点数据
triangle = np.array([
-0.5, -0.5, 0, 0, 0,
0.5, -0.5, 0, 1, 0,
0, 0.5, 0, 0, 0.5, 1
], dtype=np.float32)
# 创建VAO
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
# 创建VBO
vbo = VBO(triangle, GL_STATIC_DRAW)
vbo.bind()
# 导入顶点着色器文件和片元着色器文件
shader = Shader("base.vert", "base.frag")
# 设置顶点属性
shader.setAttrib("aPos", 3, GL_FLOAT, 20, 0)
shader.setAttrib("aTex", 2, GL_FLOAT, 20, 12)
# 读取纹理数据
img = Image.open("wall.jpg")
img = np.array(img, np.int8)
texWidth, texHeight, _ = img.shape
# 创建纹理对象
tex = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, tex)
# 将纹理数据传给GPU
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, texWidth, texHeight, 0, GL_RGB, GL_UNSIGNED_BYTE, img)
glGenerateMipmap(GL_TEXTURE_2D)
# 将纹理索引传给着色器
glActiveTexture(GL_TEXTURE0)
shader.setUniform("tex", 0)
# 配置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
# 渲染循环
def render():
shader.use()
glBindVertexArray(vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
w.loop(render)
着色器代码在上面已经给出。
最终结果图:
三、封装
下面我们将与纹理有关的操作封装成Texture类,以提高程序的复用性和可扩展性。
from OpenGL.GL import *
from PIL import Image
import numpy as np
class Texture:
def __init__(self, imgPath, idx=0, texType=GL_TEXTURE_2D,
imgType=GL_RGB, innerType=None, dataType=GL_UNSIGNED_BYTE):
if not innerType:
innerType = imgType
self.idx = idx
# 创建纹理对象
self.tex = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, self.tex)
# 读取纹理数据
img = Image.open(imgPath)
img = np.array(img, np.int8)
self.h, self.w, _ = img.shape
# 将纹理数据传给GPU
glTexImage2D(texType, 0, innerType, self.h, self.w, 0, imgType, dataType, img)
glGenerateMipmap(texType)
# 纹理设置
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
def setIndex(self, shader, name=None):
""" 设置纹理索引 """
if not name:
name = "tex" + str(self.idx)
glBindTexture(GL_TEXTURE_2D, self.tex)
glActiveTexture(GL_TEXTURE0 + self.idx)
shader.setUniform(name, self.idx)
封装后,主程序代码:
import numpy as np
from OpenGL.GL import *
from OpenGL.arrays.vbo import VBO
from shader import Shader
from texture import Texture
from window import Window
# 创建窗口
w = Window(1920, 1080, "Test")
# 顶点数据
triangle = np.array([
-0.5, -0.5, 0, 0, 0,
0.5, -0.5, 0, 1, 0,
0, 0.5, 0, 0.5, 1
], dtype=np.float32)
# VAO
vao = glGenVertexArrays(1)
glBindVertexArray(vao)
# VBO
vbo = VBO(triangle, GL_STATIC_DRAW)
vbo.bind()
# 导入顶点着色器文件和片元着色器文件
shader = Shader("base.vert", "base.frag")
shader.setAttrib("aPos", 3, GL_FLOAT, 20, 0)
shader.setAttrib("aTex", 2, GL_FLOAT, 20, 12)
# 创建并设置纹理
tex = Texture("wall.jpg")
tex.setIndex(shader, "tex")
# 渲染循环
def render():
shader.use()
glBindVertexArray(vao)
glDrawArrays(GL_TRIANGLES, 0, 3)
w.loop(render)
四、结语
本文介绍了在OpenGL中如何使用纹理。在这次以及之前的教程中,我们给出的坐标都是NDC坐标,在下一节中,我们将介绍将模型坐标转换为NDC坐标的方法。