一、概述
纹理是驱动程序控制的内存中存储的图像数据。在虚拟地球、游戏以及许多图形应用中,纹理占用的内存往往超过顶点数据。纹理用于渲染高分辨率图像,这在虚拟地球仪中已成为标配。不过,纹理中的纹素并非一定要表示像素,纹理还可用于存储地形高度等其他数据。
我们的渲染器提供了几个用于操作 2D 纹理的类,包括纹理本身、用于与纹理传输数据的像素缓冲区、描述过滤和环绕模式的采样器,以及用于为渲染分配纹理和采样器的纹理单元。
二、创建纹理
要创建纹理,客户端代码必须首先创建一个纹理描述Texture2DDescription
,如图6.1。
class Texture2DDescription {
public:
explicit Texture2DDescription(int width,
int height,
TextureFormat format,
bool generateMipmaps = false) noexcept
: width_(width), height_(height), format_(format), generateMipmaps_(generateMipmaps) {}
int width() const { return width_; }
int height() const { return height_; }
TextureFormat format() const { return format_; }
bool shouldGenerateMipmaps() const { return generateMipmaps_; }
bool colorRenderable() const noexcept;
bool depthRenderable() const noexcept;
bool depthStencilRenderable() const noexcept;
int approximateSizeInBytes() const noexcept;
bool operator<=>(const Texture2DDescription&) const noexcept = default;
private:
int width_;
int height_;
TextureFormat format_;
bool generateMipmaps_;
};
纹理描述定义了纹理的宽度、高度、内部格式,以及是否使用多级渐远纹理(mipmapping)。它还包含三个基于格式的派生属性:ColorRenderable
(可渲染颜色)、DepthRenderable
(可渲染深度)和 DepthStencilRenderable
(可渲染深度模板)。这些属性描述了具有该描述的纹理在使用帧缓冲区时可附加到哪些位置。例如,若 ColorRenderable
为 false
,则该纹理无法附加到帧缓冲区的颜色附件上。
创建好 Texture2DDescription 后,将其传递给 Device::createTexture2D,以创建一个 Texture2D 类型的实际纹理:
// 创建纹理描述
Texture2DDescription description( 256, 256, TextureFormat::RedGreenBlueAlpha8 );
// 创建纹理对象
Texture2D texture = Device::createTexture2D(description);
接下来显然要做的是为纹理提供数据。这需要通过像素缓冲区来实现,如图 6.2 所示。
像素缓冲区分为两种类型:WritePixelBuffer
(写入像素缓冲区)用于将数据从系统内存传输到纹理,ReadPixelBuffer
(读取像素缓冲区)用于将数据从纹理传输到系统内存。也就是说,写入像素缓冲区向纹理写入数据,读取像素缓冲区从纹理读取数据。像素缓冲区的使用方式与顶点缓冲区非常相似。它们是无类型的,即仅包含原始字节,但提供了带泛型参数 T 的 CopyFromSystemMemory
和 CopyToSystemMemory
重载方法,因此客户端代码无需进行类型转换。
像素缓冲区与顶点缓冲区极为相似,无论是写入还是读取像素缓冲区,其接口都与前面章节的顶点缓冲区代码设计几乎完全一致。主要区别在于,像素缓冲区还支持与图像数据之间的直接交互,而非仅支持与原始数组的操作。在使用 stb_image
库的项目中,这些操作通常表现为与unsigned char*
类型的图像数据进行互传,例如使用CopyFromImageData
和CopyToImageData
这类方法。
这里用 C++ 结合开源图像库 stb_image 实现一个 Bitmap 图像封装类:
#pragma once
#include <stb/stb_image.h>
#include <cstdint>
#include <stdexcept>
namespace Scene {
enum class ImageRowOrder { BottomToTop, TopToBottom };
class Bitmap {
public:
Bitmap(const char* path) {
data_ = stbi_load(path, &width_, &height_, &channels_, 0);
if (!data_) {
throw std::runtime_error(std::string("Failed to load image: ") + stbi_failure_reason());
}
rowOrder_ = ImageRowOrder::TopToBottom; // stb_image默认从上到下
}
Bitmap(Bitmap&& other) noexcept
: width_(other.width_), height_(other.height_),
channels_(other.channels_), rowOrder_(other.rowOrder_),
data_(other.data_) {
other.data_ = nullptr;
}
Bitmap(const Bitmap&) = delete;
Bitmap& operator=(const Bitmap&) = delete;
~Bitmap() { stbi_image_free(data_); }
int width() const { return width_; }
int height() const { return height_; }
int channels() const { return channels_; }
ImageRowOrder rowOrder() const { return rowOrder_; }
const uint8_t* data() const { return data_; }
private:
int width_{0};
int height_{0};
int channels_{0};
ImageRowOrder rowOrder_;
uint8_t* data_{nullptr};
};
namespace BitmapAlgorithms {
inline ImageRowOrder rowOrder(const Bitmap& bitmap) {
return bitmap.rowOrder();
}
inline size_t SizeOfPixelsInBytes(const Bitmap& bitmap) {
const int stride = (bitmap.width() * bitmap.channels() + 3) & ~3; // 4字节对齐
return stride * bitmap.height();
}
} // namespace BitmapAlgorithms
} // namespace Scene
有人可能会认为,顶点缓冲区和像素缓冲区应该使用同一个抽象类,或者至少写入和读取像素缓冲区应该使用同一个类。但我们没有这样做,因为更倾向于通过独立的类来提供强类型保障。例如,像 Texture2D.CopyFromBuffer
这样接收写入像素缓冲区的方法,就只能传入写入像素缓冲区 —— 这种检查在编译时即可完成。如果两种像素缓冲区类型使用同一个类,那么这种检查就必须在运行时进行,这会让方法更难使用,效率也更低。虽然我们并不太在意额外一个 if 语句的开销,但我们很注重设计出 “易于正确使用、难以错误使用” 的方法。
鉴于像素缓冲区的工作方式与顶点缓冲区极为相似,客户端代码向像素缓冲区复制数据的写法也大同小异就不足为奇了。示例向一个像素缓冲区复制了两个红、绿、蓝、阿尔法(RGBA)像素:
std::array<BlittableRGBA, 2> pixels = {
BlittableRGBA(Color::Red),
BlittableRGBA(Color::Green)
};
WritePixelBuffer pixelBuffer = Device::CreateWritePixelBuffer(
PixelBufferHint::Stream,
sizeof(BlittableRGBA) * pixels.size()
);
pixelBuffer.CopyFromSystemMemory(pixels.data(), pixels.size());
客户端代码也可以使用 CopyFromBitmap
方法将数据从 Bitmap
复制到像素缓冲区:
Bitmap bitmap(filename);
WritePixelBuffer pixelBuffer = Device::CreateWritePixelBuffer(
PixelBufferHint::Stream,
BitmapAlgorithms::SizeOfPixelsInBytes(bitmap)
);
pixelBuffer.CopyFromBitmap(bitmap);
结合下面的代码清单中展示的 Texture2D
接口可知,应使用 CopyFromBuffer
方法将数据从像素缓冲区复制到纹理。
enum class ImageFormat {
DepthComponent,
Red,
RedGreenBlue,
RedGreenBlueAlpha,
// ...
};
enum class ImageDatatype {
UnsignedByte,
UnsignedInt,
Float,
// ...
};
class Texture2D {
public:
Texture2D(const Texture2D&) = delete;
Texture2D& operator=(const Texture2D&) = delete;
Texture2D(Texture2D&&) noexcept = default;
Texture2D& operator=(Texture2D&&) noexcept = default;
virtual ~Texture2D() = default;
virtual void copyFromBuffer(WritePixelBuffer* pixelBuffer,
ImageFormat format,
ImageDatatype dataType,
int rowAlignment = 4) = 0;
virtual void copyFromBuffer(WritePixelBuffer* pixelBuffer,
int xOffset,
int yOffset,
int width,
int height,
ImageFormat format,
ImageDatatype dataType,
int rowAlignment = 4) = 0;
virtual ReadPixelBuffer* copyToBuffer(ImageFormat format, ImageDatatype dataType);
virtual ReadPixelBuffer* copyToBuffer(ImageFormat format, ImageDatatype dataType, int rowAlignment) = 0;
virtual const Texture2DDescription& description() const noexcept = 0;
[[nodiscard]] virtual bool save(const std::string& filename) = 0;
protected:
Texture2D() = default;
void saveColor(const std::string& filename) const noexcept;
void saveDepth(const std::string& filename) const noexcept;
void saveRed(const std::string& filename) const noexcept;
void saveFloat(const std::string& filename, ImageFormat imageFormat) const noexcept;
};
该方法的两个参数用于解析像素缓冲区中存储的原始字节,这与 VertexBufferAttribute
解析顶点缓冲区中原始字节的方式类似。这需要指定像素缓冲区中数据的格式(例如 RGBA)和数据类型(例如无符号字节)。系统会自动进行必要的转换,将该格式和数据类型转换为创建纹理时在描述中指定的内部纹理格式。若要复制前面创建的、包含 BlittableRGBA
数组的整个像素缓冲区,可使用以下代码:
texture.copyFromBuffer(writePixelBuffer, ImageFormat.RedGreenBlueAlpha, ImageDatatype.UnsignedByte);
copyFromBuffer
的其他重载允许客户端代码仅修改纹理的部分区域,并可指定行对齐方式。
Texture2D
还具有一个 description
属性,用于返回创建纹理时使用的描述信息。该对象是不可变的——纹理一旦创建,其分辨率、格式和多级渐远纹理(mipmapping)特性就无法更改。Texture2D
还包含一个save
方法,用于将纹理保存到磁盘,这在调试时非常有用。
为简化纹理创建流程,Device::createTexture2D
提供了一个接收Bitmap参数的重载版本。通过该重载,可仅用一行代码从磁盘文件创建纹理,例如:
Texture2D* texture = Device::createTexture2D(new Bitmap(filename),
TextureFormat::RedGreenBlue8,
generateMipmaps);
与接收 Mesh
参数的createVertexArray
重载类似,该重载更注重易用性而非灵活性,本书的许多示例中都会用到它。
纹理矩形。除了在着色器中使用归一化纹理坐标访问的常规 2D 纹理(例如,(0, width)
和 (0, height)
分别映射到归一化范围 (0, 1)
)之外,我们的渲染器还支持2D纹理矩形——它使用非归一化纹理坐标访问,坐标范围为 (0, width)
和 (0, height)
。这种纹理寻址方式能简化某些算法的实现,例如高度场光线投射算法。纹理矩形仍使用 Texture2D
类型,但需通过Device::createTexture2DRectangle
创建。需要注意的是,纹理矩形不支持多级渐远纹理(mipmapping
),且采样器也不支持任何形式的重复环绕模式。
三、采样器(Samplers)
当纹理用于渲染时,客户端代码还必须指定采样参数。这包括缩小和放大时应使用的过滤类型、如何处理超出[0, 1]范围的纹理坐标环绕方式,以及各向异性过滤的程度。过滤方式会影响渲染质量和性能,尤其是各向异性过滤,它对提升虚拟地球仪中地形纹理的水平视角视觉质量非常有效。纹理环绕有多种用途,包括在地形着色中平铺细节纹理。
与 Direct3D
和新版OpenGL一样,我们的渲染器将纹理与采样器分离。纹理由 Texture2D
表示,采样器由 TextureSampler
表示,如图 6.3 所示:
enum class TextureMagnificationFilter {
Nearest,
Linear,
// ... Mipmapping filters
};
enum class TextureMinificationFilter {
Nearest,
Linear
};
enum class TextureWrap {
Clamp,
Repeat,
MirroredRepeat
};
class TextureSampler {
public:
TextureSampler(TextureMinificationFilter minificationFilter,
TextureMagnificationFilter magnificationFilter,
TextureWrap wrapS,
TextureWrap wrapT,
float maximumAnistropy)
: minificationFilter_(minificationFilter)
, magnificationFilter_(magnificationFilter)
, wrapS_(wrapS)
, wrapT_(wrapT)
, maximumAnistropy_(maximumAnistropy) {}
~TextureSampler();
TextureMinificationFilter minificationFilter() const { return minificationFilter_; }
TextureMagnificationFilter magnificationFilter() const { return magnificationFilter_; }
TextureWrap wrapS() const { return wrapS_; }
TextureWrap wrapT() const { return wrapT_; }
float maximumAnisotropic() const { return maximumAnistropy_; }
private:
TextureMinificationFilter minificationFilter_;
TextureMagnificationFilter magnificationFilter_;
TextureWrap wrapS_;
TextureWrap wrapT_;
float maximumAnistropy_;
};
客户端代码可通过Device::createTexture2DSampler
显式创建采样器:
TextureSampler sampler = Device::createTexture2DSampler(
TextureMinificationFilter.Linear,
TextureMagnificationFilter.Linear,
TextureWrap.Repeat,
TextureWrap.Repeat);
设备中还包含一组通用采样器,因此上述对CreateTexture2DSampler的调用可简化为:
TextureSampler
sampler = Device.Samplers.LinearRepeat;
后一种方法的优势在于无需创建额外的渲染器对象,从而避免生成新的GL对象。
想一想:
Device.Samplers属性包含四个预定义采样器:NearestClamp、LinearClamp、NearestRepeat和LinearRepeat。是否有必要实现一个类似着色器缓存的采样器缓存?如果是,请设计并实现它;如果否,请说明理由。
提示:
除非应用存在大量高频、多样化的自定义采样器需求,否则无需实现采样器缓存 —— 预定义采样器 + 按需创建自定义采样器的方式已足够高效。
四、使用纹理进行渲染
给定一个2D纹理(Texture2D)和一个纹理采样器(TextureSampler),告知上下文(Context)要使用它们进行渲染是一件很简单的事。实际上,我们甚至可以告知上下文,在同一次绘制调用中使用多个纹理(每个纹理可能搭配不同的采样器)。着色器从多个纹理读取数据的能力称为多纹理技术(multitexturing)。这种技术应用广泛,例如通过多纹理为地球受阳光照射的一侧应用白天纹理,另一侧应用夜晚纹理;除了从多个纹理读取数据外,着色器还可以多次从同一个纹理读取数据。
纹理单元的数量(即一次可使用的唯一纹理/采样器组合的最大数量)由Device.NumberOfTextureUnits
定义。上下文(Context)包含一组纹理单元(TextureUnits),如图 6.4 所示。每个纹理单元通过0到Device.NumberOfTextureUnits - 1
之间的索引访问。
在调用Context.draw
之前,客户端代码会为所需的每个纹理单元分配纹理和采样器。例如,若使用白天和夜晚纹理,客户端代码可能如下所示:
context.textureUnits[0].texture = dayTexture;
context.textureUnits[0].textureSampler = Device.textureSamplers.LinearClamp;
context.textureUnits[1].texture = nightTexture;
context.textureUnits[1].textureSampler = Device.textureSamplers.LinearClamp;
context.draw(/*...*/);
GLSL着色器可以定义两个sampler2D
类型的uniform变量,通过渲染器的自动uniform访问纹理单元:
uniform sampler2D og_texture0; // 白天纹理——纹理单元0
uniform sampler2D og_texture1; // 夜晚纹理——纹理单元1
或者,也可以使用自定义名称的uniform变量,并在客户端代码中通过UniformT<int>
显式将其设置为对应的纹理单元。
问题:
为什么纹理单元集合是上下文(Context)的一部分,而不是绘制状态(draw state)的一部分?这让哪些使用场景更简单?有什么缺点?
试一试:
在离线状态下计算mipmap通常很有用,因为此时可以投入更多时间进行高质量过滤,而不是在运行时通过glGenerateMipmap
等API调用生成。修改Texture2D
及相关类以支持预计算的mipmap。
试一试:
我们的渲染器仅支持2D纹理,因为信不信由你,撰写本书所有示例代码只需要2D纹理这一种纹理类型!但还有许多其他有用的纹理类型:1D纹理、3D纹理、立方体贴图、压缩纹理和纹理数组。为这些类型添加渲染器支持是对现有设计的直接扩展。动手试试吧。
五、GL渲染器实现
纹理的OpenGL实现在多个类中展开。WritePixelBufferGL3x
和ReadPixelBufferGL3x
分别实现了写像素缓冲区和读像素缓冲区。这些类采用了与顶点缓冲区相同的GL缓冲区对象模式:在构造函数中使用glBufferData
分配内存,并通过glBufferSubData
实现copyFromSystemMemory
方法。实际上,像素缓冲区和顶点缓冲区的实现在底层共享代码。一个显著区别是:写像素缓冲区使用GL_PIXEL_UNPACK_BUFFER
作为目标,而读像素缓冲区使用GL_PIXEL_PACK_BUFFER
。
Texture2DGL3x
是实际2D纹理的实现类。其构造函数创建GL纹理并为其分配内存:首先调用glGenTexture
生成纹理ID,接着调用glBindBuffer
(目标为GL_PIXEL_UNPACK_BUFFER
,缓冲区ID为0)以确保没有绑定任何可能为纹理提供数据的缓冲区对象。然后,使用glActiveTexture
激活最后一个纹理单元(例如,若Device.NumberOfTextureUnits
为32,则激活纹理单元31)。这一看似反直觉的步骤是必要的,因为我们需要明确哪个纹理单元会受到影响,并在客户端代码希望在同一纹理单元上使用不同纹理进行渲染时,能在下一次绘制调用前处理这种情况。配置好GL状态后,调用glTexImage2D
(数据参数为null)最终为纹理分配内存。
copyFromBuffer
方法的实现与构造函数有相似之处:首先调用glBindBuffer
绑定包含待复制图像数据的写像素缓冲区(GL术语中的解包像素缓冲区对象),接着调用glPixelStore
设置行对齐方式,然后使用glTexSubImage2D
传输数据。若纹理需要生成mipmap,则最后调用glGenerateMipmap
为纹理生成mipmap。
TextureSamplerGL3x
是纹理采样器的实现类。由于该类是不可变的,且GL为采样器对象提供了简洁的API,因此TextureSamplerGL3x
是我们渲染器中实现最简洁的类之一。构造函数中使用glGenSamplers
创建采样器的GL ID,并在对象销毁时使用glDeleteSamplers
删除该ID。构造函数多次调用glSamplerParameter
定义采样器的过滤和环绕参数。
最后,纹理单元在TextureUnitsGL3x
和TextureUnitGL3x
中实现。纹理单元负责绑定纹理和采样器以供渲染使用。这里采用了与统一变量(uniforms)和顶点数组类似的延迟处理方式:当客户端代码为纹理单元分配纹理或采样器时,不会立即调用glActiveTexture
激活纹理单元,也不会立即绑定纹理或采样器的OpenGL对象,而是将该纹理单元标记为“脏”。当调用Context.draw
时,系统会遍历所有脏纹理单元并进行清理:调用glActiveTexture
激活纹理单元,分别调用glBindTexture
和glBindSampler
绑定纹理和采样器。对于在Texture2D.copyFromBuffer
中可能被修改的最后一个纹理单元,系统将其作为特殊情况处理——若其纹理和采样器不为null,则显式绑定它们。
参考:
- Cozi, Patrick; Ring, Kevin. 3D Engine Design for Virtual Globes. CRC Press, 2011.