纹理系统

在之前的 《图片和纹理》 文章中,我们已经实现了纹理绘制的相关功能,比如纹理坐标映射和纹理寻址模式。

所以本篇文章是一个“重构”主题。我们仿照 OpenGL 的设计,以 OpenGL 接口的方式重新组织纹理系统。同时因为纹理涉及到 GLSL 部分,所以也顺带“重构”一下之前的着色器设计。

本篇文章分为四节。

1. 第一节介绍 OpenGL 中和纹理相关的接口。

2. 第二节说明如何具体实现第一节中介绍的 OpenGL 接口。

3. 第三节说明如何具体实现 GLSL 中的 texture 函数。

4. 第四节介绍编写的测试用例,验证接口实现是否正确。

1. OpenGL 纹理接口

在仿照 OpenGL 之前,我们先回顾一下 OpenGL 中和纹理相关的接口。相关的接口比较多,下面按照大致的功能划分进行说明。

1.1 纹理创建和绑定

glGenTextures 函数用来生成纹理对象,和生成 VAO、VBO 类似。其函数原型为

  • void glGenTextures(GLsizei n, GLuint* textures);

其中 n 指定需要生成的纹理对象数量;textures 是存储生成的对象名称的指针。

glBindTexture 函数用来绑定一个纹理到当前活动的纹理单元。其函数原型为

  • void glBindTexture(GLenum target, GLuint texture);

其中 target 指定纹理要绑定的目标,比如 GL_TEXTURE_2D;texture 指定要绑定的纹理名称。

1.2 纹理设置

glTexParameteri 函数用来设置纹理参数,比如纹理过滤和纹理循环。其函数原型为

  • void glTexParameteri(GLenum target, GLenum pname, GLint param);

其中 target 指定要设置的纹理目标;pname 指定要设置的参数名称;param 指定参数设置的值。

纹理过滤的参数有:GL_TEXTURE_MIN_FILTER 纹理缩小的过滤方式;GL_TEXTURE_MAG_FILTER 纹理放大的过滤方式。

常用的过滤方式有:GL_NEAREST 最临近过滤;GL_LINEAR 线性过滤。

纹理环绕的参数有:GL_TEXTURE_WRAP_SGL_TEXTURE_WRAP_TGL_TEXTURE_WRAP_R,分别表示纹理在 S/T/R 方向上的环绕方式。

常见的环绕方式有:GL_REPEATGL_MIRRORED_REPEAT

1.3 纹理应用

glActiveTexture 函数用于激活指定的纹理单元,纹理单元从 GL_TEXTURE0 开始。通常和 glUniform1i 函数搭配使用,将激活的纹理单元分配给着色器中的采样器。

为了简化设计,像 glActiveTexture 这些函数后续不会实现。所以就不给出函数原型了。

1.4 Mipmap

glGenerateMipmap 用于自动生成给定纹理的所有 mipmap 级别。

1.5 纹理数据

glTexImage2D 用于指定一个二维纹理图形。其函数原型为

  • void glTexImage2D(
  •     GLenum target,
  •     GLint level,
  •     GLint internalFormat,
  •     GLsizei width,
  •     GLsizei height,
  •     GLint border,
  •     GLenum format,
  •     GLenum type,
  •     const void* data);

其中 target 指定目标纹理;level 指定纹理的级别;internalFormat 指定纹理的颜色组件格式;width 和 height 指定纹理图像的宽度和高度,单位是像素;border 现代 OpenGL 中已经不再使用,必须为 0;format 指定像素的数据格式;type 指定像素的数据类型;data 为指向实际像素数据的指针。

1.6 纹理删除

glDeleteTextures 函数用于删除指定纹理对象。

  • void glDeleteTextures(GLsizei n, const GLuint* textures);

其中 n 指定要删除的纹理数量;textures 是指向纹理对象名称的指针。

1.7 GLSL texture

在 GLSL 中,texture 函数用于从纹理对象中采样纹理数据。texture 函数有多个重载版本,我们看二维纹理版本:

  • vec4 texture(sampler2D sampler, vec2 coord);

其中 sampler 是纹理采样器,可以使用 1.3 中介绍的函数指定;coord 是纹理坐标。

函数的返回值是采样点的 RGBA 颜色数据。

2. 接口实现

在实现 glGenTextures 之前,我们先创建一个 Texture 类,用于内部管理纹理对象。如代码清单 1 所示,为了简化设计,我们的纹理对象只支持 RGBA 格式。类中记录纹理对象名称、纹理图像宽高和纹理数据。

代码清单 1 Texture 类
  1. class TTexture
  2. {
  3. public:
  4.     TTexture(uint32_t id);
  5.     ~TTexture();
  6.  
  7.     void SetTextureData(uint32_t width, uint32_t height, void* data);
  8.    
  9. private:
  10.     uint32_t m_id;
  11.     uint32_t m_size;
  12.     uint32_t m_width;
  13.     uint32_t m_height;
  14.     TRGBA* m_pTextureData;
  15. };

2.1 纹理创建和绑定

有了 Texture 类之后,我们实现 GenTextures 接口。如代码清单 2 所示,创建纹理对象时,将对象名称和 Texture 类进行关联。

代码清单 2 GenTextures
  1. void TSoftRenderer::GenTextures(uint32_t n, uint32_t* textures)
  2. {
  3.     for (uint32_t i = 0; i < n; i++)
  4.     {
  5.         uint32_t id = AllocateTextureId();
  6.         m_textureMap[id] = new TTexture(id);
  7.         textures[i] = id;
  8.     }
  9. }

纹理绑定的实现如代码清单 3 所示,我们内部新建一个 m_currentTexture 变量,用于记录当前操作的纹理对象。

因为目前只使用二维纹理,所以相较于 OpenGL 接口,后续接口里都没有 target 参数。

代码清单 3 BindTexture
  1. void TSoftRenderer::BindTexture(uint32_t texture)
  2. {
  3.     TTexture* texturePtr = NULL;
  4.  
  5.     if (texture != 0)
  6.     {
  7.         auto it = m_textureMap.find(texture);
  8.         assert(it != m_textureMap.end());
  9.  
  10.         texturePtr = it->second;
  11.     }
  12.  
  13.     m_currentTexture = texturePtr;
  14. }

2.2 纹理设置

在实现 glTexParameteri 之前,我们更新一下我们的 Texture 类。如图 4 所示,我们在 Texture 类里,记录纹理环绕模式和过滤方式。并增加纹理参数设置函数 SetParameter。

代码清单 4 Texture 类
  1. enum class TTextureParam
  2. {
  3.     WrapS,
  4.     WrapT,
  5.     Filter,
  6. };
  7.  
  8. enum class TTextureWrapMode
  9. {
  10.     MirroredRepeat,
  11.     Repeat,
  12. };
  13.  
  14. enum class TTextureFilterMode
  15. {
  16.     Nearest,
  17.     Linear,
  18. };
  19.  
  20. class TTexture
  21. {
  22. public:
  23.     void SetParameter(TTextureParam param, int value);
  24.    
  25. private:
  26.     TTextureWrapMode m_wrapS;
  27.     TTextureWrapMode m_wrapT;
  28.     TTextureFilterMode m_filter;
  29. };

这样,TexParameter 接口的实现就很简单。如代码清单 5 所示,我们只需要调用 Texture 类的 SetParameter 函数,进行参数设置即可。

代码清单 5 TexParameter
  1. void TSoftRenderer::TexParameter(TTextureParam pname, int param)
  2. {
  3.     assert(m_currentTexture != NULL);
  4.  
  5.     m_currentTexture->SetParameter(pname, param);
  6. }

2.3 纹理数据

glTexImage2D 可以使用各种不同的图像格式,所以参数非常多。而我们这边只考虑 RGBA 的格式,所以如代码清单 6 所示,实现非常简单。只需要指定图像的宽高以及数据即可。

代码清单 6 TexImage2D
  1. void TSoftRenderer::TexImage2D(int width, int height, void* data)
  2. {
  3.     assert(m_currentTexture != NULL);
  4.  
  5.     m_currentTexture->SetTextureData(width, height, data);
  6. }

2.4 纹理删除

纹理删除的实现,如代码清单 7 所示,我们通过纹理对象名称,删除并释放内部存储的 Texture 类。同时把纹理对象名称“回收”,可以后续纹理创建时再次分配。

代码清单 7 DeleteTextures
  1. void TSoftRenderer::DeleteTextures(uint32_t n, uint32_t* textures)
  2. {
  3.     for (uint32_t i = 0; i < n; i++)
  4.     {
  5.         uint32_t id = textures[i];
  6.         auto it = m_textureMap.find(id);
  7.         if (it != m_textureMap.end())
  8.         {
  9.             delete it->second;
  10.             m_textureMap.erase(it);
  11.             m_freeTextureIds.push(id);
  12.         }
  13.     }
  14. }

3. Shader

目前,我们的着色器类针对输入的数据是写死的,分为颜色和 uv。一是这样灵活性不够;二是颜色和 uv 在管线里的处理逻辑是一样的,无需额外区别。

所以,为了能更接近 GLSL 的使用,如代码清单 8 所示,对着色器的输入数据结构进行了修改。我们使用 std::variant 联合体记录变量。变量和变量名通过 map 表记录。builtin_position 成员是“模仿” gl_Position

代码清单 8 TVertexShaderOutput
  1. using TShaderVariable = std::variant<tmath::Vec2f, tmath::Vec3f, tmath::Vec4f>;
  2. struct TVertexShaderOutput
  3. {
  4.     tmath::Vec4f builtin_position;
  5.     std::unordered_map<std::string, TShaderVariable> variables;
  6.  
  7.     TVertexShaderOutput();
  8.     virtual ~TVertexShaderOutput();
  9. };

修改了输入设计之后,原本管线里的内容也要修改。修改并不复杂,主要是成员名称的修改,以及变量计算逻辑的统一处理。

我挑一处进行说明,如代码清单 9 所示,修改后,位置信息从 builtin_position 里进行获取。原本我们针对颜色和 uv 单独进行透视除法操作,现在我们只需要对使用到的变量进行统一处理即可。

代码清单 9 管线修改
  1. for (TVertexShaderOutputPrivate& vertex : clippedVertices)
  2. {
  3.     // 透视除法
  4.     vertex.invW = 1 / vertex.builtin_position.w();
  5.  
  6.     vertex.builtin_position *= vertex.invW;
  7.  
  8.     for (auto& var : vertex.variables)
  9.     {
  10.         std::visit([&](auto&& val) {
  11.             val *= vertex.invW;
  12.             }, var.second);
  13.     }
  14.  
  15.     // NDC - 屏幕
  16.     vertex.builtin_position = m_screenMatrix * vertex.builtin_position;
  17. }

std::visit 在进行编译时,各种类型是“全排列”的,即各种可能的类型情况都会“参与”所写的代码逻辑。

此处我们参与计算的数据类型需要是一致的,可以使用 if constexpr 和 std::is_same_v 等方法,在编译时进行判断,确保满足实际需要,且不会编译报错。

3.1 GLSL texture

我们还遗留一个 texture 函数没有实现。如代码清单 10 所示,想着把它也放在 context 类中,同时类中记录当前使用的 Texture 类。这样原先的片元着色器函数,就需要修改,需要增加一个 contex 输入参数。

代码清单 10 texture 设计
  1. class TShaderContext
  2. {
  3. public:
  4.     TShaderContext(const TVertexArrayObject* vao, const TTexture* texture);
  5.  
  6.     tmath::Vec4f texture(const tmath::Vec2f& uv) const;
  7. private:
  8.     const TTexture* m_texture;
  9. };
  1. class TShader
  2. {
  3. public:
  4.     virtual ~TShader();
  5.     virtual void VertexShader(const TShaderContext& context, TVertexShaderOutput& output) = 0;
  6.     virtual void FragmentShader(
  7.         const TShaderContext& context,
  8.         const TVertexShaderOutput& input,
  9.         TFragmentShaderOutput& output) = 0;
  10. };

值得一提的是,原来的片元着色器函数在管线中的调用逻辑不用“大改”,std::bind 把 context 当作“常量”传入即可。

  1. using FragmentShaderFunction = std::function<void(const TVertexShaderOutput&, TFragmentShaderOutput&)>;
  2. FragmentShaderFunction fragFunc = std::bind(&TShader::FragmentShader, m_currentShader, std::cref(context), std::placeholders::_1, std::placeholders::_2);

可以看到 std::bind 非常灵活。使用类似于 partial application 的实现,非常便捷。

如代码清单 11 所示,我们在 Texture 类中增加纹理采样的函数。所有的采样逻辑都在之前的 《图片和纹理》 文章中实现过了。这边只不过是把它们移到了 Texture 类中。

这样,texture 函数的实现就很简单,直接调用 Texture 的 Sample 函数即可。

代码清单 11 texture 实现
  1. class TTexture
  2. {
  3. public:
  4.     tmath::Vec4f Sample(const tmath::Vec2f& uv) const;
  5.  
  6. private:
  7.     float AdjustCoordinate(float coord, TTextureWrapMode wrapMode) const;
  8.     tmath::Vec2f AdjustUV(const tmath::Vec2f& uv) const;
  9.     tmath::Vec4f SampleNearest(const tmath::Vec2f& uv) const;
  10.     tmath::Vec4f SampleBilinear(const tmath::Vec2f& uv) const;
  11. };
  1. tmath::Vec4f TShaderContext::texture(const tmath::Vec2f& uv) const
  2. {
  3.     return m_texture->Sample(uv);
  4. }

最后,我们看一下“改造”后的着色器如何使用。如代码清单 12 所示,在顶点着色器中,我们先从 location 0 中获取位置输入,然后经过 mvp 变换,赋值给 “gl_Position”。接着从 location 2 中获取 uv 坐标输入,然后设置变量名称,传递给片元着色器。

在片元着色器中,通过相同的变量名称,获取到 uv 坐标的值。然后调用 texture 函数获取到对应位置的颜色值,并将其设置为片元着色器的输出。

代码清单 12 着色器使用
  1. void TPassThroughUvShader::VertexShader(const TShaderContext& context, TVertexShaderOutput& output)
  2. {
  3.     tmath::Vec3f position;
  4.     context.GetAttribute(0, position);
  5.  
  6.     output.builtin_position = projectionMatrix * viewMatrix * modelMatrix * tmath::Vec4f(position, 1.0f);
  7.  
  8.     tmath::Vec2f uv;
  9.     context.GetAttribute(2, uv);
  10.     output.variables["uv"] = uv;
  11. }
  12.  
  13. void TPassThroughUvShader::FragmentShader(
  14.     const TShaderContext& context,
  15.     const TVertexShaderOutput& input,
  16.     TFragmentShaderOutput& output)
  17. {
  18.     tmath::Vec2f uv = std::get<tmath::Vec2f>(input.variables.at("uv"));
  19.     output.color = context.texture(uv);
  20. }

4. 测试

我们写一个测试用例,来验证实现的纹理接口。如代码清单 13 所示,我们使用 GenTextures 创建一个纹理对象;使用 BindTexture 进行绑定,用于后续操作使用;我们使用之前实现的 Image 类读取图片,之后使用 TexImage2D 设置图片纹理的数据;使用 TexParameter 设置纹理的环绕和过滤参数。

代码清单 13 测试用例
  1. TImageTextureRenderTask::TImageTextureRenderTask(TBasicWindow& win)
  2. {
  3.     uint32_t textureId;
  4.     sr.GenTextures(1, &textureId);
  5.     sr.BindTexture(textureId);
  6.     TImage img("image/dog.jpg", TImage::ColorFormat::RGBA);
  7.     sr.TexImage2D(img.GetWidth(), img.GetHeight(), img.GetData());
  8.  
  9.     sr.TexParameter(TTextureParam::WrapS, (int)TTextureWrapMode::Repeat);
  10.     sr.TexParameter(TTextureParam::WrapT, (int)TTextureWrapMode::Repeat);
  11.     sr.TexParameter(TTextureParam::Filter, (int)TTextureFilterMode::Linear);
  12. }

测试用例的运行结果如视频 1 所示,正确显示了传递的 jpg 图片。

本章的完整代码见 tag/texture_interface

视频 1 图片纹理旋转