Wgpu图文详解(05)纹理与绑定组

前言

什么是纹理?

纹理是图形渲染中用于增强几何图形视觉效果的一种资源。它是一个二维或三维的数据数组,通常包含颜色信息,但也可以包含其他类型的数据,如法线、高度、环境光遮蔽等。纹理的主要目的是为几何图形的表面提供详细的视觉效果,使其看起来更加真实和复杂。而我们常见的图片是一个二维的像素数组,每个像素包含颜色信息(通常是 RGB 或 RGBA)。图片可以是任何内容,如照片、图标、绘画等。图片的主要用途是显示和存储视觉信息,可以用于各种应用,如网页、文档、多媒体等。

纹理和图片的差异

用途

  • 纹理:主要用于图形渲染,增强几何图形的视觉效果。
  • 图片:主要用于显示和存储视觉内容,应用范围更广泛。

数据结构

  • 纹理:可以是二维或三维的数据数组,包含多种类型的数据(如颜色、法线、高度等)。
  • 图片:通常是二维的像素数组,主要包含颜色信息。

处理方式

  • 纹理:通过图形渲染管线进行处理,涉及纹理坐标、采样器、片段着色器等。
  • 图片:通过图像处理软件或库进行处理,直接操作像素数据。

存储和传输

  • 纹理:通常存储在 GPU 内存中,优化为渲染操作。
  • 图片:可以存储在文件系统中,格式多样(如 JPEG、PNG、BMP 等),用于各种应用。

纹理与绑定组实践

在理解纹理的基本概念以后,我们基于第四章的项目内容初始化本章代码目录。要得到纹理数据,我们首先需要加载一张图片到内存中。因此,我们先放置一张样例图片到项目目录下的assets目录中(一般将资源文件放到assets目录中);同时,引入image 包,该包提供了读写图片数据的能力,以方便我们后续加载图片;最后,我们增加一个img_utils.rs的源代码文件,该文件中封装了一个名为RgbaImg的结构体来抽象图片数据。初始准备完成后,现在的项目结构如下:

初始化内容可checkout该 commit

接下来,我们会在合适的位置通过RgbaImg实例来构造出纹理对象,并将其按照之前的思路存储到WgpuCtx实例中。

构造纹理Texture

首先,我们需要在WgpuCtx结构体中增加三个字段:

  1. texture: wgpu::Texture:存储纹理Texture实例。
  2. texture_image: RgbaImg:存储图片的原始数据(宽、高、像素二进制数据)。
  3. texture_size: wgpu::Extent3d:纹理的尺寸定义。

texture: wgpu::Texture是一个纹理实例,主要是保存了GPU在渲染纹理过程中的一些上下文信息(譬如纹理的宽高深度等);

texture_image: RgbaImg主要是存储我们待会儿加载示例图片后,得到的图片的信息(宽高二进制数据等);

texture_size: wgpu::Extent3d主要是存储了纹理的宽高登信息。

在WgpuCtx结构体中定义好字段以后,我们需要完成纹理和图片的构造过程。这个过程其实和之前创建顶点缓冲区的思路类似,就是在WgpuCtxnew_async方法的合适位置调用相关的API进行构造:

首先,我们调用Rgbanew方法,传入项目下的示例图片路径,构造出RgbaImg对象,该对象存储了图片的宽高以及rgba格式的二进制数据(注意,这里的路径需要传递一个绝对路径,请读者自己将图片放置到合适位置,并在这里填写正确的绝对路径,否则运行时会找不到)。

然后,我们通过图片的宽高信息来直接构造了一个Extend3d对象。

最后,我们调用wgpu::Devicecreate_textureAPI来创建一个纹理对象,该方法参数TextureDescriptor的具体配置如下:

在这里,我们梳理一遍create_texture参数字段(label字段不在赘述):

  • size: wgpu::Extend3d:用于表达纹理的基本尺寸结构(宽、高以及深度)。在这里,我们将前面获取到的图片的宽高作为了Extend3d的宽高;对于Extend3d的depth_or_array_layers字段,理论下,纹理是以3维形式存储的,但是在本例中我们主要是为了渲染一个2D图片纹理,所以在这里我们传入1

  • mip_level_countsample_count我们暂时先不讲,因为涉及到的内容稍微有点复杂,不是一两句能讲清楚的,等到后面实践过程中再来回顾该字段,这里目前就设置为1即可。

  • dimension: wgpu::TextureDimension:表明纹理的维度,我们传入表示2D的枚举。

  • format: wgpu::TextureFormat:表明我们使用的纹理的原始数据格式。在前面我们加载图片数据后,将其转换为了u8rgba,即四个字节(u8)每个字节分别对应Red、Green、Blue、Alpha通道的数据,同时,大部分图像数据使用sRGB来存储,所以这里我们选择了对应的枚举:Rgba8UnormSrgb。关于图像格式的细节,不在本文讨论,感兴趣的读者自行拓展学习:sRGB色彩空间

  • usage: wgpu::TextureUsages:该字段用来表示我们的纹理数据将会被怎样使用,在这里我们设置两个:TEXTURE_BINDINGCOPY_DST。前者表示我们要在着色器中使用这个纹理,即在稍后的过程中我们会在着色器中通过一定的方式访问到这个纹理;后者表示我们能将数据复制到这个纹理上,即我们后续会有将数据写入到纹理上的操作。

  • view_formats也是一个比较复杂的字段,我们暂时先不关心它,传入&[]即可。

细心的读者发现了,当我们创建wgpu::Texture的时候,会首先创建该wgpu::Extent3d实例,并通过该Extent3d再来创建Texture,那么理论上,Texture实例是包含了Extend3d的,那么为什么这里需要将再额外Extend3d保存下来呢?原因在于在接下来的流程中,我们还会消费Extend3d,但是Texture没有相关的API来访问内部的Extend3d数据。

填充数据到纹理

在完成对纹理实例的创建并保存到WgpuCtx后,我们需要调用Queue队列的write_texture API 来将我们的图片数据通过队列的方式写入到纹理中。具体做法则是在我们先前在WgpuCtx中定义的draw方法中,在最后调用队列的submit方法前调用write_textureAPI:

注意,这一步我们并不是将纹理数据写入到渲染流程中,而是通过队列提供的API,将图片像素数据写入到纹理中,即完成纹理和实际二进制数据的关联。

对于write_textureAPI来说,它的参数现阶段有四个,我们依次讲解:

第一个参数我们需要传递一个ImageCopyTexture对象,该对象引用了我们刚刚创建的纹理对象(self.texture),ImageCopyTexture并不是一个纹理对象,而是一个用来描述纹理操作的配置对象,它表达这样一个意图:期望将接下来传入的图像数据以拷贝 的方式填充到纹理self.texture中。至于其中的mip_levelorigin以及aspect字段这里我们先暂时不介绍,读者按照默认即可。

第二个参数传递的是我们保存的图像像素数据(self.texture.bytes)。这里就是我们实际要填充到纹理中的图像数据。

第三个参数我们需要传入一个ImageDataLayout对象,如果读者对先前的文章比较熟悉,那么会记得在Wgpu中,常规数据需要结合XxxLayout布局才能描述一个原始的二进制数据的形态究竟是什么(比如之前的顶点缓冲区布局)。在这里也是同样一个思路,当我们传入的图片数据在没有任何其他上下文定义的情况下,GPU内部是无法理解这个二进制数据是什么结构,因此才会有ImageDataLayout。回到本例的具体配置中,ImageDataLayoutoffset字段为0,代表了我们传入的二进制数据从0开始就是图片数据;bytes_per_row字段表示了图片的每一行有多少个字节,由于我们的图片数据每一个像素由rgba四个属性组成,每个属性占用1个字节,即一个像素总共占用4个字节,对于一行来说,我们有 图片宽度n 个像素,因此这里每一行的字节数就是4 x 图片宽度n;至于rows_per_image,就不难理解了,总共有 图片高度m 行。

最后一个参数就是我们先前存储的Extend3d对象,这里就不再赘述意义了。

因此,调用了该方法后,我们就将真实的图片数据填充到纹理对象中。接下来我们就可以直接使用了吗?答案是否定的,纹理在准备完成以后,我们无法很直接的使用它。接下来我会详细说明这一块的内容。

创建采样器

纹理采样器Sampler是一个渲染管线中的组件,它负责根据顶点坐标等信息,从纹理图像中提取出具体的像素颜色值,用于对渲染的物体表面进行纹理映射。下图是一个不太严谨但易于理解的图示来解释采样器的核心工作:

左边是一个纹理图片数据,而右边则是最终要渲染的目标平面。采样器就类似于取色器一样的东西,当右边渲染平面上某一处像素位置要渲染具体的颜色的时候,采样器会通过一定的规则去左边的纹理数据中取到对应的颜色。

请务必注意,采样器基本的工作流程是依据右边的渲染目标,再从左边的纹理数据中找到对应的颜色数据,而不是从左侧的纹理数据开始,往右边渲染目标填充。这二者是有一定的区别的。

理解采样器的基本工作流程后,让我们来创建一个采样器。采样器尽管听起来像是一个工具,但在Wgpu中也算做一种资源,我们保持和之前的方式一样,首先在WgpuCtx结构体中增加一个名为sampler的字段,类型为wgpu::Sampler,用来保存我们创建的采样器;其次,在new_async的方法中合适位置调用devicecreate_samplerAPI,我们就可以创建一个采样器:

我们可以看到关于SamplerDescriptor中的三个address_mode_*字段。对于UVW坐标来说:

U和V:通常对应于二维纹理图像的水平和垂直方向的坐标。U坐标类似于二维图像中的X坐标,表示纹理图像的水平位置;V坐标类似于二维图像中的Y坐标,表示纹理图像的垂直位置。它们的取值范围一般在0到1之间,其中(0,0)通常表示纹理图像的左下角,(1,1)表示纹理图像的右上角。

W:在一些情况下,会使用到W坐标。W坐标主要用于三维纹理映射,即纹理图像本身是三维的,或者在进行一些特殊的纹理映射操作时需要额外的一个维度来控制纹理的采样。例如,在体积渲染中,纹理图像可以是一个三维的数据体,W坐标就表示在这个三维数据体中的深度方向的位置。

对于address_mode_*字段的枚举值,它的作用是指定了当采样器采样的坐标超出了纹理本身的边界时,应该如何处理。同样的,我们可以用下面不严谨的图示来描述具体的场景:

比如在上图中,位置1处于纹理图像本身上,我们直接使用对应位置位置的颜色数据;而对于位置2来说,它超出了纹理本身的尺寸,此时应该渲染什么颜色呢?这就涉及到了几种方式:

  • 方式1:任何在纹理外的纹理坐标将返回离纹理边缘最近的像素的颜色。

  • 方式2:当纹理坐标超过纹理的尺寸时,纹理将重复。

  • 方式3:类似于方式1,但图像在越过边界时将翻转。

对于Wgpu中的配置枚举来说,方式1、2、3分别对应配置:ClampToEdgeRepeatMirrorRepeat

上面3种方式对应的效果如下:

在我们的示例代码中,我们使用的是ClampToEdge

当然,SamplerDescriptor中不止上述的address_mode_*字段配置,但考虑到更多的配置涉及的知识点比较复杂,就不在本文中详细讲了。

创建绑定组与绑定组布局

本章目前为止,我们已经创建了两个资源:1)带有图像数据的纹理资源;2)采样器资源。资源创建完成以后,我们还需要通过一定的配置以便在渲染管线中访问并使用它们。具体来讲,我们需要创建一个名为绑定组(BindGroup)的对象。绑定组核心是描述了一组资源绑定关系,单个资源绑定关系其实就是代表了与某个资源的关联关系,绑定组则是这些关联关系的成组描述。让我们接下来通过实践来理解它。

创建一个绑定组总共需要两步:

  1. 创建一个绑定组布局(BindGroupLayout)对象
  2. 基于绑定组布局来创建一个绑定组对象

接下来,让我们具体分析每一步中的配置参数。首先是创建 绑定组布局

从图中代码我们可以看到,创建绑定组布局,核心就是设置BindGroupLayoutDescriptor中的entries字段,该字段是一个由BindGroupLayoutEntry对象构成的数组。对于BindGroupLayoutEntry来说,它包含了以下四个字段:

  1. binding
  2. visibility
  3. ty
  4. count

对于binding字段,它代表了某个具体的绑定 在整个绑定组中的位置插槽。要理解这句话,我们需要回过头来先理解什么绑定组布局,绑定组布局的本质是通过配置来描述一堆"绑定资源"的组成形态。我们可以将绑定组布局比喻成一个包含多个格子的盒子:

对于这样一个盒子,其中每一个小格子都会有一个"编号",这个"编号"其实就是binding字段的形象表达。有了这一层思维以后,就不难理解关于BindGroupLayoutEntry其余字段的作用了,其实就是描述了对应格子中存放的绑定资源对象的属性。

对于visibility字段,它表示了对应binding下的绑定资源在着色器中的哪个阶段可见。这个字段可以是VERTEX(即顶点着色器阶段可以见)、FRAGMENT(即片元着色器阶段可以见)等,此外,你也可以使用VERTEX | FRAGMENT来表示顶点着色器阶段和片元着色器同时可见。"可见"意味着只有在对应阶段着色器代码中运行中,我们才能够通过一定的方式来正确访问某些资源。

对于ty字段,它代表了这个插槽位置下的资源是什么类型的。比如是一个纹理资源,或是一个采样器资源。请注意,这里依然是定义,即描述这个位置将会是个什么类型的资源,而并不是实际的资源数据,因为实际的资源要等到创建绑定组对象本身的时候才会设置。

对于count字段,我们暂时不进行详细的描述,以后会提到。

在了解了BindGroupLayoutEntry每一个字段意义以后,让我们回过头来看一看创建 绑定组布局 的代码,其含义就比较容易理解了。即,我们创建了一个绑定组布局,这个绑定组布局定义了接下来即将创建的绑定组的形态:

  • 由2个绑定资源 组成,插槽位置分别是01
  • 2个绑定资源均在片元着色器阶段可见,能被使用
  • 2个绑定资源的类型分别是纹理和采样器(具体的配置这里就不详细描述了,读者直接使用代码中的配置即可)
  • 两个绑定组元素的count均为None(读者暂时不用关心意义)

理解了绑定组布局配置后,再让我们聚焦 绑定组 本身的创建。具体代码如下:

相信结合了前面绑定组布局的介绍,读者这里应该很容易的看出。首先,我们创建绑定组本身的时候,使用了刚刚创建的绑定组布局。其次,对于entries字段,我们也同样设置了2项 。其中第1项的binding0,代表了我们配置的绑定资源对应前面绑定组布局中binding0的配置定义,resource字段则设置了一个由纹理对象而来的纹理视图对象,即插槽为0位置的绑定资源关联的就是我们的一个纹理视图资源,(纹理视图是对纹理数据本身访问的一层抽象);对于第2项,其binding值为1,代表了我们配置的绑定资源对应前面绑定组布局中binding1的配置定义,其resource字段引用了创建的采样器sampler,代表我们关联了一个创建好的采样器资源。当然,我们用一张图来描述可能更加直观:

Ok,至此我们完成了一个绑定组的创建工作。绑定组本身已经包含有具体的资源数据,需要在运行时使用,因此我们将其保存至WgpuCtxbind_group字段中。并在draw方法中合适位置进行设置:

上述代码设置完成以后,其实还不够,为了能够使用到绑定组(及其关联的纹理、采样器资源)。我们还需要对一个比较久远的对象的创建过程进行调整。在很久之前,我们曾创建了渲染管线RenderPipeline这一对象:

创建管线布局

为了能够真正地使用到绑定组,我们需要创建一个名为管线布局(PipelineLayout)的对象,同时将绑定组布局设置到管线布局中,以便关联到我们创建的渲染管线中。首先,我们先通过创建好的绑定组布局对象来创建管线布局对象:

然后,我们适当的调整原来的create_pipeline的调用点,将其移动到PipelineLayout对象创建以后:

接着,我们修改create_pipeline的方法签名,让其传入一个管线布局对象,并在调用处将管线布局对象传入:

最后,我们修改create_pipeline的具体实现,在调用create_render_pipeline传递的RenderPipelineDescriptorlayout字段,设置管线布局:

这几步下来,最重要的结果是,我们将原本一个"光秃秃"的没有关联任何资源的渲染管线,修改为了关联了一个管线布局的渲染管线,而这个渲染管线关联的管线布局又包含一个绑定组布局,绑定组布局中还定义两个绑定资源:

同时,读者还需要仔细辨别和理解,在draw方法调用时(即运行时),里面使用到的是关联了实际资源数据的 绑定组 ,而不是 绑定组布局。在创建的渲染管线的时候,则都是布局(包含绑定组布局的管线布局)。

修改顶点数据

接下来,我们还需要对原有的顶点数据的部分进行修改。首先,我们修改vertex.rs文件中定义的Vertex结构体,将原本的color字段改为tex_uv(表示UV二维坐标),类型也由原来的[f32; 3]改为了[f32; 2](二维坐标);与之对应的,就是下方的VERTEX_LIST的数据也要适当调整:

这里的tex_uv值的含义考虑到篇幅原因,就不在本文中进行介绍了。感兴趣的读者可以等笔者后续的文章,或自行学习

然后,我们需要对该文件中曾编写的方法create_vertex_buffer_layout的具体实现进行调整。对于shader_location1的地方,原本每一个顶点数据的该位置是一个[f32; 3]的数据(表示一个颜色数据),由于调整为了[f32; 2](表示一个UV坐标数据),因此我们需要在做出对应的修改:

修改完成后,关于Rust中的代码我们基本上就编写完成了。接下来我们还差最后一步,修改着色器代码。

修改着色器代码

首先,对于顶点着色器的部分,由于我们传入管线的数据实际上是有调整的(Vertex结构体字段调整),因此着色器中的代码也需要完成对应调整:

关于着色器代码中的VertexInputVertexOutputFragmentInput的对应关系我就不多赘述了。

接下来,重要的一步,在着色器代码中,我们需要加入有关纹理资源以及采样器资源对象的代码,并修改片元着色器的实现逻辑:

在这里有两个要点需要解释。首先我们会看到我们新加入了两个"变量",类型分别是texture_2d<f32>sampler(这两个类型是wgsl内置的),分别代表了纹理对象和采样器资源对象;其次,它们的上面都有注解,有的读者可能已经能联想到这里的group(0)bind(0)bind(1)的含义了。对于group(0),其实就对应了我们刚刚创建的渲染管线布局中定义的仅有的索引为0的绑定组布局:

bind(0)bind(1)其实就是这个绑定组中的两个entry:

再看修改后的片元着色器实现,我们调用了wgsl语言内置的textureSample方法,第一个参数是纹理对象,第二个参数是采样器对象,第三个参数是对应的uv坐标。这段代码表达了这样一个意图:对于片元的某一位置的像素点的颜色值,我们使用采样器在纹理采样得到对应的颜色。

效果呈现

终于,我们经历"千辛万苦",终于完成了为了在Wgpu中渲染一张图片的所有准备工作。此时当我们运行本章程序,会得到如下的效果:

我们将本章最开始的一张图片渲染到了一个正六边形中。当然,我们会发现图片的渲染实际上还存在一些问题,比如六边形内部的图片是颠倒的、图片存在扭曲、部分区域分割的不正常。这一块的内容,其实涉及到了顶点、UV坐标的一些关系处理,考虑到本章篇幅原因(内容实在太多了= _ =),就不再本章中进行详解了,笔者会单独写一篇文章来详细讲解这块的内容。

写在最后

本章内容中,我们先创建了纹理以及采样器资源,并通过绑定组来完成了对纹理资源和采样器资源的关联,同时又介绍了如何将渲染管线和绑定组进行关联逻辑流程。本章内容很多,需要读者慢慢消化,但是相信读者在阅读本文内容以后,能够对Wgpu中的常常提到的布局Layout这一概念有一个更加深入的认识。

本章的代码仓库在这里:

ch05_texture_and_bind_group

后续文章的相关代码也会在该仓库中添加,所以感兴趣的读者可以点个star,谢谢你们的支持!