OpenTK中文教程——1.5纹理

252 阅读14分钟

纹理

我们了解到,为了给对象添加更多细节,可以为每个顶点使用颜色来创建一些有趣的图像。然而,为了获得相当程度的真实感,我们必须拥有许多顶点,这样我们就可以指定大量颜色。这会占用相当多的额外开销,因为每个模型需要更多的顶点,而每个顶点还需要一个颜色属性。

艺术家和程序员通常更喜欢使用纹理。纹理是一种二维图像(存在一维和三维纹理,但它们并不常见),用于向对象添加细节;想象一下,纹理就像一张纸上有一幅漂亮的砖块图像(例如),将其整齐地折叠在你的3D房屋上,使它看起来像你的房子有石质外墙。由于我们可以在单个图像中插入大量细节,因此我们可以在不增加额外顶点的情况下,让物体看起来非常细致。

除了图像之外,纹理还可以用于存储大量数据以发送给着色器,但我们留待其他话题讨论。

下面您将看到一张砖墙纹理图像,它被映射到上一个教程中的三角形。

image.png

为了将纹理映射到三角形上,我们需要告诉三角形的每个顶点它对应纹理的哪一部分。因此,每个顶点都应该有一个与其关联的纹理坐标,该坐标指定了要从纹理图像中采样的部分。片段插值然后为其他片段完成剩余的工作。

纹理坐标的范围在 x 轴和 y 轴上都是从 0 到 1(记住我们使用的是 2D 纹理图像)。使用纹理坐标检索纹理颜色称为采样。纹理坐标的起点为纹理图像左下角的 (0,0),终点为纹理图像右上角的 (1,1)。下图显示了我们如何将纹理坐标映射到三角形上:

image.png

我们为三角形指定了3个纹理坐标点。我们希望三角形的左下角与纹理的左下角相对应,因此我们为三角形的左下顶点使用(0,0)纹理坐标。同样地,对于右下角,我们使用(1,0)纹理坐标。三角形的顶部应该与纹理图像的顶部中心相对应,因此我们将其纹理坐标设为(0.5,1.0)。我们只需要向顶点着色器传递3个纹理坐标,然后顶点着色器再将这些坐标传递给片段着色器,后者会为每个片段插值所有的纹理坐标。

最终的纹理坐标看起来就像这样:

float[] texCoords = {
    0.0f, 0.0f,  // lower-left corner  
    1.0f, 0.0f,  // lower-right corner
    0.5f, 1.0f   // top-center corner
};

纹理采样有一个宽松的解释,可以以许多不同的方式完成。因此,我们的任务是告诉OpenGL应该如何采样其纹理。

纹理包裹

纹理坐标通常从(0,0)到(1,1),但如果指定超出此范围的坐标会怎样?OpenGL 的默认行为是重复纹理图像(我们基本上忽略浮点纹理坐标的整数部分),但 OpenGL 提供了更多选项:

  • 重复【GL_REPEAT】:纹理的默认行为。重复纹理图像。
  • 镜像重复【GL_MIRRORED_REPEAT】:与 GL_REPEAT 相同,但在每次重复时镜像图像。
  • 边缘夹紧【GL_CLAMP_TO_EDGE】:将坐标夹紧在 0 和 1 之间。结果是较高的坐标被夹紧到边缘,导致出现拉伸的边缘图案。
  • 边界夹紧【GL_CLAMP_TO_BORDER】:范围外的坐标现在被赋予用户指定的边界颜色。

当使用超出默认范围的纹理坐标时,每个选项都会产生不同的视觉输出。让我们看看这些在样本纹理图像上的表现:

image.png

【万恶的水印】从左到右依次为:GL_REPEAT、GL_MIRRORED_REPEAT、GL_CLAMP_TO_EDGE、GL_CLAMP_TO_BORDER

上述每个选项都可以通过坐标轴(s、t(如果使用3D纹理还包括p),相当于x、y、z)使用GL.TexParameter函数设置:

GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);

注意:必须将枚举转换为int,GL.TexParameter才能接受它。

第一个参数指定纹理目标;我们正在处理2D纹理,所以纹理目标是TextureTarget.Texture2D。第二个参数要求我们告诉要设置哪个选项以及针对哪个纹理轴。我们希望配置WRAP选项,并为其指定S轴和T轴。最后一个参数要求我们传入所需的纹理包裹模式,在这种情况下,OpenGL将在当前激活的纹理上设置其纹理包裹选项为TextureWrapMode.Repeat。

如果我们选择TextureWrapMode.ClampToBorder选项,还应该指定一个边界颜色。这是通过glTexParameter函数的fv等价物完成的,其中选项为TextureParameterName.TextureBorderColor,我们传入一个包含边界颜色值的浮点数组:

float[] borderColor = { 1.0f, 1.0f, 0.0f, 1.0f };
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureBorderColor, borderColor); 

纹理过滤

纹理坐标不依赖于分辨率,可以是任何浮点值,因此OpenGL必须确定将纹理坐标映射到哪个纹理像素(也称为texel)。当对象非常大而纹理分辨率较低时,这一点尤为重要。你可能已经猜到,OpenGL对此也有纹理过滤选项。有几个可用的选项,但目前我们将讨论最重要的两个选项:最近邻和线性插值。

最近邻(也称为最近邻过滤)是OpenGL的默认纹理过滤方法。当设置为最近邻时,OpenGL会选择中心最接近纹理坐标的像素。下面你可以看到4个像素,其中十字表示精确的纹理坐标。左上角的texel的中心最接近纹理坐标,因此被选作采样颜色:

image.png

线性(也称为(双)线性过滤)从纹理坐标的邻近texel中取一个插值,近似出这些texel之间的颜色。纹理坐标与texel中心的距离越小,该texel的颜色对采样颜色的贡献就越大。下面我们可以看到返回了邻近像素的混合颜色:

image.png

但是这种纹理过滤方法的视觉效果如何呢?让我们看看在大对象上使用低分辨率纹理时这些方法是如何工作的(因此纹理会被放大,单个Texel会变得明显):

image.png

最近点采样(Nearest)会产生带有明显像素块的图案,我们可以清楚地看到构成纹理的像素,而线性插值(Linear)则产生更平滑的图案,单个像素不那么明显。线性插值产生的输出更为真实,但有些开发者更喜欢复古、像素化的风格,因此会选择最近点采样选项。

纹理过滤可以为放大和缩小操作设置(在放大或缩小比例时),例如,当纹理被缩小时可以使用最近邻过滤,而放大时使用线性过滤。因此,我们必须通过GL.TexParameter为这两种情况指定过滤方法。代码看起来应该类似于设置环绕方式:

GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);

Mipmaps

想象一下,如果我们有一个装有数千个物体的大房间,每个物体都附有纹理。远处的物体会有与近处物体相同的高分辨率纹理。由于这些物体很远,可能只会产生几个片段,OpenGL 在从高分辨率纹理中检索片段的正确颜色值时会遇到困难,因为它必须为跨越纹理大片区域的片段选择一个纹理颜色。这将在小物体上产生可见的伪影,更不用说在小物体上使用高分辨率纹理会造成内存浪费。

为了解决这个问题,OpenGL 使用了一个称为 mipmap 的概念,这基本上是一组纹理图像,其中每个后续的纹理比前一个纹理小一倍。mipmap 背后的想法应该很容易理解:在观众与物体之间的距离超过某个阈值后,OpenGL 将使用最适合该距离的不同的 mipmap 纹理。因为物体离得很远,所以较低的分辨率不会被用户察觉。此外,mipmap 还有一个额外的好处,那就是它们对性能也有好处。让我们仔细看看带有 mipmap 的纹理是什么样子:

image.png

为每个纹理图像创建一个包含多级渐远纹理的集合手动操作起来非常繁琐,但幸运的是,我们可以在创建纹理后通过调用 GL.GenerateMipmap(GenerateMipmapTarget.Texture2D) 让 OpenGL 为我们完成所有工作。在后续的纹理教程中,你会看到这个函数的使用。

在渲染过程中切换mipmaps级别时,OpenGL可能会显示一些伪影,例如两个mipmap层之间的锐利边缘。就像普通的纹理过滤一样,也可以使用最近邻和线性过滤来在mipmap级别之间进行过滤。为了指定mipmap级别之间的过滤方法,我们可以用以下四个选项之一替换原始的过滤方法:

  • NearestMipmapNearest:选择最接近像素大小的mipmap,并使用最近邻插值进行纹理采样。
  • LinearMipmapNearest:选择最近的mipmap级别,并使用线性插值进行采样。
  • NearestMipmapLinear:在线性插值两个最接近像素大小的mipmap之间进行采样,使用最近邻插值。
  • LinearMipmapLinear:在线性插值两个最接近的mipmap之间进行采样,并通过线性插值采样纹理。

就像纹理过滤一样,我们可以使用 GL.TexParameter 将过滤方法设置为上述 4 种方法之一:

GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);

一个常见的错误是将其中一个mipmap过滤选项设置为放大滤波器。这没有任何效果,因为mipmaps主要用于纹理缩小的情况:纹理放大不使用mipmaps,给它一个mipmap过滤选项会产生OpenGL GL_INVALID_ENUM错误代码。

加载和创建纹理

要实际使用纹理,我们首先需要将它们加载到我们的应用程序中。纹理图像可以存储在几十种文件格式中,每种格式都有自己的结构和数据顺序,那么我们如何将这些图像加载到我们的应用程序中呢?一种解决方案是选择一个我们想要使用的文件格式,比如说.PNG,并编写我们自己的图像加载器,将图像格式转换成一个大的字节数组。虽然编写自己的图像加载器并不非常困难,但仍然很麻烦,而且如果你想支持更多的文件格式怎么办?那你还需要为每种你想要支持的格式编写一个图像加载器。

另一个解决方案,可能是一个很好的方案,是使用一个支持多种流行格式并为我们完成所有繁重工作的图像加载库。像这样的库

对于OpenTK4:

stb_image.h 和 StbImageSharp

stb_image.h 是一个在 C 语言中广泛使用的库,用于加载多种常见格式(如 png、jpeg、gif 等)的图像。该项目有一个 C# 版本的移植,可以在 NuGet 上以 StbImageSharp 的形式获取。

对于接下来关于纹理的部分,我们将使用一个木箱的图像。 在你的项目中创建一个新文件,Texture.cs。在顶部放置以下using语句:

using System;
using OpenTK.Graphics.OpenGL4;
using StbImageSharp;

创建一个名为 Texture 的类,并添加一个名为 Handle 的 int 属性。构造函数应接受一个参数:图像文件的路径。

在构造函数中,编写以下行:Handle = GL.GenTexture();。这将为我们生成一个空白纹理。

接下来,在代码中添加 Use 函数,包含以下行:GL.BindTexture(TextureTarget.Texture2D, Handle);。在生成纹理后立即在构造函数中调用该函数。

// stb_image 从左上角像素加载,而 OpenGL 从左下角加载,这会导致纹理在垂直方向上被翻转。
// 这将纠正这一点,使纹理显示正常。
StbImage.stbi_set_flip_vertically_on_load(1);

// 加载图像。
ImageResult image = ImageResult.FromStream(File.OpenRead(path), ColorComponents.RedGreenBlueAlpha);

现在这个完成了,是时候上传我们的纹理了。

GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, image.Data);

对于OpenTK3:

ImageSharp

ImageSharp 是由 SixLabors 开发的一款非常流行的图像加载库,能够加载大多数流行的文件格式,并且易于集成到您的项目中。您可以从 Nuget 添加 ImageSharp 到您的项目。

在接下来的纹理部分,我们将使用一张木箱的图片。

在您的项目中创建一个新文件,Texture.cs。在顶部添加以下 using 语句:

using System;
using System.Collections.Generic;
using OpenTK.Graphics.OpenGL4;

using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;

创建一个名为 Texture 的类,并添加一个名为 Handle 的 int 类型属性。构造函数应接受一个参数:图像文件的路径。

在构造函数中,编写以下代码行:Handle = GL.GenTexture(); 这将为我们生成一个空白纹理。

接下来,在代码中添加 Use 函数,包含以下代码行:GL.BindTexture(TextureTarget.Texture2D, Handle); 在生成纹理后立即在构造函数中调用该函数。

接下来,我们将使用 ImageSharp 加载图像,并将这些像素发送到 OpenGL。

// 加载图像。
Image<Rgba32> image = Image.Load<Rgba32>(path);

// ImageSharp从左上角像素加载,而OpenGL从左下角加载,这导致纹理被垂直翻转。
// 这将修正这一点,使纹理显示正常。
image.Mutate(x => x.Flip(FlipMode.Vertical));

// 使用ImageSharp中的CopyPixelDataTo函数将图像中的所有字节复制到一个数组中,我们可以将这个数组提供给OpenGL。
var pixels = new byte[4 * image.Width * image.Height];
image.CopyPixelDataTo(pixels);

现在我们有了一个表示像素的字节数组,我们必须设置包裹和过滤模式。目前,就选择线性(Linear)和重复(Repeat)。

现在这些都完成了,是时候上传我们的纹理了。

GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba, image.Width, image.Height, 0, PixelFormat.Rgba, PixelType.UnsignedByte, pixels);

TexImage2D 的参数如下:

  • 要生成的纹理类型。可以生成 1D、2D 和 3D 纹理,但通常只需要 2D。
  • 细节层次。如果设置为非 0 值,可以将默认的 mipmap 设置为低于最大级别的层次。我们不希望这样,所以将其保持为 0。
  • OpenGL 用于在 GPU 上存储像素的格式。几乎总是希望这是 RGBA。
  • 图像的宽度。
  • 图像的高度。
  • 图像的边框。这必须始终为 0;它是来自 OpenGL 古老版本的遗留参数。
  • 字节的格式。ImageSharp 总是将其图像放置在 Rgba 中,因此只需使用该格式。
  • 像素的类型。在这种情况下为无符号字节。
  • 要转换为纹理的像素数组。

图像现在已生成!

可选地,我们可以生成mipmaps。这在这里不是必需的,但为了参考,在TexImage2D之后添加GL.GenerateMipmap(GenerateMipmapTarget.Texture2D)这一行。这就是你需要做的全部!

应用纹理

现在我们的纹理已经创建好了,我们需要修改着色器和顶点以使用该纹理。

首先,将顶点数组替换为以下内容:

float[] vertices =
{
    //Position          Texture coordinates
     0.5f,  0.5f, 0.0f, 1.0f, 1.0f, // top right
     0.5f, -0.5f, 0.0f, 1.0f, 0.0f, // bottom right
    -0.5f, -0.5f, 0.0f, 0.0f, 0.0f, // bottom left
    -0.5f,  0.5f, 0.0f, 0.0f, 1.0f  // top left
};

回想我们之前讨论的纹理坐标以及它们的工作原理。我们将它们添加到每个顶点。

接下来,我们必须修改顶点属性位置以将纹理坐标发送给着色器。

将你的 VertexAttribPointer 调用替换为:

GL.VertexAttribPointer(vertexLocation, 3, VertexAttribPointerType.Float, false, 5 * sizeof(float), 0);

几乎完全相同,只是步长从 3 * sizeof(float) 改为 5 * sizeof(float),以适应新的纹理坐标。

在此之下,添加以下行:

int texCoordLocation = shader.GetAttribLocation("aTexCoord");
GL.EnableVertexAttribArray(texCoordLocation);
GL.VertexAttribPointer(texCoordLocation, 2, VertexAttribPointerType.Float, false, 5 * sizeof(float), 3 * sizeof(float));

再次,几乎与上次调用完全相同,只是数据包数量为2个而不是3个,并且初始偏移量为3 * sizeof(float)。

现在,我们需要修改我们的着色器。首先是顶点着色器。新代码是:

#version 330 core

layout(location = 0) in vec3 aPosition;

layout(location = 1) in vec2 aTexCoord;

out vec2 texCoord;

void main(void)
{
    texCoord = aTexCoord;

    gl_Position = vec4(aPosition, 1.0);
}

我们添加了另一个输入变量,aTexCoord,这将是纹理坐标。我们将它直接转发到输出变量texCoord,不做任何修改,以便片段着色器可以使用它。说到片段着色器,接下来就是它了:

#version 330

out vec4 outputColor;

in vec2 texCoord;

uniform sampler2D texture0;

void main()
{
    outputColor = texture(texture0, texCoord);
}

我们看到一种全新的变量类型,sampler2D。简单来说,这就是着色器中纹理的表示。

一次可以绑定多达16种不同的纹理(具体数量可能更多,取决于你的硬件,但OpenGL至少要求16种)。在下一个示例中,我将向您展示如何同时使用多种纹理。不过,现在我们不需要做其他事情。

如果您一切操作正确,运行代码时您应该看到以下内容:

image.png

恭喜你绘制了第一个纹理!下次,我将演示一次绘制多个纹理。