Metal - 8.深入剖析纹理贴图
在前面的章节中,我们学习了如何利用片段函数和着色器为模型添加颜色和细节。除了纯粹的数学计算之外,另一种更常用且强大的方式是使用图像纹理(image textures)。本章将深入探讨纹理贴图的核心概念,以及如何在 Metal 中高效地使用它们。
一、 纹理与 UV 映射(Textures and UV Maps)
纹理(Textures)是赋予 3D 模型表面细节和外观的 2D 图像。
1. UV 坐标的作用
要将 2D 图像正确地应用到 3D 模型的表面,就需要 UV 坐标 (UV Coordinates),也被称为纹理坐标。
- UV 映射: UV 坐标的作用是将模型的每个顶点映射到 2D 纹理上的一个特定位置。
- 坐标范围: 每个顶点都含有一个二维坐标,用于将该点放置在 2D 纹理平面上。该平面的左上角通常被定义为 (0,1)(0, 1)(0,1),右下角是 (1,0)(1, 0)(1,0)。
- 建模流程: 在 3D 建模过程中,需要通过标记**接缝(seams)来展平(flatten)**模型(Unwrap a Mesh),从而创建一个匹配模型表面的 2D 贴图。艺术家可以在这个展平的模型贴图上绘制或应用纹理。
- 文件存储: 当将 UV 映射的模型导出为
.obj
文件时,Blender 等工具会将 UV 坐标添加到文件内容中。
二、 启动应用与纹理加载
Texture StarterApp
本章的启动应用渲染了一个低多边形房屋模型,当前使用半球光照进行着色。我们的目标是将片段函数中硬编码的天空和大地颜色替换为从纹理中读取的颜色。
项目的 Mesh.swift
和 Submesh.swift
文件已进行重构,将 Model I/O 和 MetalKit 的网格缓冲区提取到自定义的缓冲区和子网格组中,这种抽象模式为引擎提供了更高的灵活性。
要将纹理集成到渲染中,需要遵循以下三个步骤:
- 集中加载和存储图像纹理。
- 在绘制模型前将加载的纹理传递给片段函数。
- 更改片段函数以从纹理中读取相应的像素。
1. 集中加载纹理
由于一个模型通常有多个子网格,并且子网格可能引用多个纹理,为了避免重复加载,需要创建一个中心化的 TextureController
类来存储纹理字典。
MTKTextureLoader
: 使用 MetalKit 提供的MTKTextureLoader
创建纹理加载器。- 纹理原点: 在加载纹理时,需要更改纹理的 原点选项(origin option),确保纹理以左下角为原点加载。
- 材质引用: Model I/O 会加载模型的所有材质信息。在
.mtl
文件中,Kd
值持有漫反射材质颜色,而map_Kd [文件名]
则提供了漫反射颜色贴图的文件名。在 Metal 中,**基本颜色(Base color)**与漫反射(diffuse)含义相同。 Submesh.Textures
: 每个子网格都包含一个Textures
结构体,用于持有其材质特性,例如baseColor
纹理。Model I/O 可以查找材质属性(MDLMaterialSemantic.baseColor
),提取文件名并加载纹理。
private extension Submesh.Textures {init(material: MDLMaterial?) {func property(with semantic: MDLMaterialSemantic)-> MTLTexture? {guard let property = material?.property(with: semantic),property.type == .string,let filename = property.stringValue,let texture =TextureController.texture(filename: filename)else { return nil }return texture}baseColor = property(with: MDLMaterialSemantic.baseColor)}
}
2. 将纹理传递给片段函数
纹理、缓冲区和采样器状态都存储在 参数表(argument tables) 中,并通过索引号访问。
- 索引枚举: 我们使用枚举
TextureIndices
来跟踪纹理缓冲区的索引号,例如BaseColor = 0
。 - 设置纹理: 在渲染模型的
render(encoder:uniforms:params:)
方法中,使用encoder.setFragmentTexture(submesh.textures.baseColor, index: BaseColor.index)
将纹理传递给片段函数。 - 硬件限制: 在 iOS 上,参数表至少可以容纳 31 个缓冲区和纹理,以及 16 个采样器状态;而在 macOS 上,纹理数量增加到 128 个。
encoder.setFragmentTexture(submesh.textures.baseColor,index: BaseColor.index)
3. 更新片段函数
在 Metal 着色器语言(MSL)中,通过将纹理类型作为参数添加到 fragment_main
函数签名中,并使用 [[texture(n)]]
限定符指定索引,来访问纹理。
- Texel 与 Sampler: 在纹理空间中,采样的单位是 纹素(texels)。当采样点不精确地落在某个像素上时,需要使用 采样器(sampler) 来决定如何处理纹素。使用
constexpr sampler textureSampler;
定义一个默认采样器。 - 采样操作: 使用
baseColorTexture.sample(textureSampler, in.uv).rgb
来读取纹理颜色,其中in.uv
是从顶点函数接收的插值 UV 坐标。
fragment float4 fragment_main(constant Params ¶ms [[buffer(ParamsBuffer)]],VertexOut in [[stage_in]])
{constexpr sampler textureSampler;float3 baseColor = baseColorTexture.sample(textureSampler,in.uv).rgb;return float4(baseColor, 1);
}
三、 sRGB 颜色空间(sRGB Color Space)
纹理图像通常存储在 sRGB 颜色空间(sRGB Color Space)中,这是一个非线性空间(non-linear space)。
- 线性空间冲突: 在计算机图形学中,着色和光照计算通常在线性颜色空间中进行。如果直接将 sRGB 纹理(非线性)读入线性空间进行计算(例如将颜色乘以 0.5 来调暗),会导致颜色差异与预期不符。
- 颜色失真: sRGB 空间中的中灰色在线性空间中会被读取为深灰色,导致渲染结果比预期暗。
- 伽马近似: 要解决此问题,一种近似方法是应用 伽马 2.2 的逆(inverse of gamma 2.2) 进行转换。
- MetalKit 解决方案: 更简单的方法是在加载纹理时,通过设置
MTKTextureLoader.Option: .SRGB: false
来指示加载器不要将纹理视为 sRGB 格式。这将强制纹理以 线性颜色像素格式(如bgra8Unorm
)加载。
四、 捕获 GPU 工作负载(Capture GPU Workload)
GPU 工作负载捕获工具(Capture GPU Workload tool) 是一个强大的调试工具,可以检查 GPU 上所有 Metal 缓冲区、纹理和渲染通道的状态。
- 使用目的: 检查纹理在 GPU 上的实际格式(例如
BGRA8Unorm
或BGRA8Unorm_sRGB
),验证渲染命令编码器发出的命令序列(如setFragmentBytes
和setRenderPipelineState
)。 - 资源检查: 通过调试器,可以查看 片段资源(Fragment Resources)(例如纹理所在的槽位)以及 附件(Attachments)(例如
CAMetalLayer Drawable
和MTKView Depth
,即深度缓冲区)。
五、 采样器与纹理过滤(Samplers)
采样器(Samplers)定义了片段函数如何读取和处理纹理中的纹素。
1. 过滤(Filtering)
采样器参数控制纹理在放大或缩小时的外观:
filter::linear
: 指示采样器平滑纹理(Smooth the texture),通常用于消除明显的像素化边缘。filter::nearest
: 保持纹理的像素化,适用于某些复古风格的游戏。
2. 寻址模式与平铺(Addressing Mode and Tiling)
address::repeat
: 设置寻址模式。当 UV 坐标超出 $$ 的标准范围时,纹理会重复平铺。- 平铺实现: 通过在片段函数中将
in.uv
坐标乘以一个平铺因子(例如 16)并配合address::repeat
模式,可以使纹理在物体表面重复出现多次。为了动态控制平铺,需要将平铺因子作为Params
结构体的一部分,通过setFragmentBytes
传递到着色器。
3. Moiré 伪影与采样状态
- 摩尔纹(Moiré):当纹理采样不足(undersampling)时(即采样的纹素多于屏幕像素),会在远处产生一种干扰图案,称为摩尔纹。
- MTLSamplerState: 采样器并非必须在着色器中定义。开发者可以创建
MTLSamplerState
对象,将其与模型一起存储,并通过[[sampler(n)]]
属性发送给片段函数,从而对采样状态进行集中管理。
六、 Mipmaps 与各向异性(Mipmaps and Anisotropy)
当模型在 3D 场景中远离摄影机时,屏幕上的像素数量会少于纹理的纹素数量,导致欠采样和 Moiré 伪影。
1. Mipmaps 的作用
- 定义: Mipmaps 是纹理的多级渐远纹理(Multiple levels of detail),即纹理的预先计算好的、越来越小的副本。
- 工作原理: GPU 会根据片段的深度信息,选择分辨率最接近显示尺寸的 mipmap 级别进行采样,从而减少采样错误和消除伪影。
- 生成与使用: 可以在
MTKTextureLoader
的加载选项中设置.generateMipmaps: NSNumber(value: true)
来自动生成 mipmaps。同时,在采样器配置中,必须将mip_filter
设置为.linear
或.nearest
,才能启用 mipmap 采样功能。
2. 各向异性过滤(Anisotropy)
- 问题: 当纹理以斜角(oblique angle)投射时(例如观察地平面),即使使用了 mipmaps,也可能出现模糊和“泥泞”(muddy)的现象,这是各向异性引起的。
- 解决方案: 在采样器构造中添加
max_anisotropy(8)
(最大可设 16)来启用各向异性过滤。Metal 会从纹素中抽取多个样本来构造片段,尤其改善了斜角视图的纹理质量。为了更高的灵活性,如果并非所有模型都需要高各向异性采样,也可以将MTLSamplerState
存储在Model
中,而非在着色器中定义。
Anisotropy(各向异性)主要应用于各向异性过滤(Anisotropic Filtering, AF)以解决纹理模糊问题,以及应用于着色/反射以模拟特殊材质。它们的原理各有侧重。
各向异性过滤的原理,简单来说就是:它不再假设屏幕上的一个像素对应到纹理上的区域是正方形(或圆形),而是正确地将其视为一个被拉伸的长条形(各向异性)区域,并沿着这个长条形进行更密集的采样。
传统问题:各向同性过滤 (Isotropic Filtering) 的局限
传统的纹理过滤方法,如三线性过滤(Trilinear Filtering),依赖于 Mipmap 技术。
- Mipmap 预先创建了纹理的低分辨率版本。
- 当 3D 物体远离摄像机时,系统会切换到更低一级的 Mipmap 来避免细节过多导致的摩尔纹(aliasing)和过多的采样开销。
- 三线性过滤会在两个相邻 Mipmap 级别之间进行插值,以获得更平滑的过渡。
然而,Mipmap 假设屏幕像素投影到纹理空间是一个正方形或圆形区域。当表面非常倾斜(例如,一条笔直延伸到远方的公路)时:
- 像素投影到纹理上是一个长条形区域(在垂直于视线方向上被拉伸了)。
- Mipmap 机制为了避免高频失真,会错误地使用一个过低分辨率的 Mipmap 级别。
- 结果是:在长条形短轴方向上获得了足够的细节,但在长轴方向上细节被过度平滑,导致纹理模糊。
各向异性过滤 (AF) 的解决方案
AF 通过以下方式解决这个问题:
- 识别各向异性: GPU/驱动程序会计算屏幕像素投影到纹理上的区域的拉伸比例(各向异性比)。
- 不均匀采样: 它不再仅仅从一个 Mipmap 级别上进行四次采样(三线性),而是沿着这个长条形区域的长轴方向(即高细节方向)从更高分辨率的 Mipmap 级别进行多次采样(采样次数即 2x,4x,8x,16x2\text{x}, 4\text{x}, 8\text{x}, 16\text{x}2x,4x,8x,16x 的数字)。
- 组合结果: 将这些沿着长轴方向的采样值进行组合(例如平均),得到最终的像素颜色。
简而言之,AF 确保了在最需要细节的方向(与视线夹角大的方向)上使用足够高的纹理分辨率,从而在保持抗锯齿效果的同时,避免了纹理模糊,使远处的倾斜表面纹理细节保持锐利。
七、 资源目录与纹理压缩(The Asset Catalog and Compression)
随着项目复杂度的提高,集中管理和优化纹理变得至关重要。
1. 资源目录(The Asset Catalog)
资源目录(Asset Catalog)可以存储所有资源,包括纹理。
- 纹理集(Texture Set): 纹理集不同于普通图像,它们供 GPU 使用,并允许开发者设置特定的属性。
- 自定义与优化: 资源目录允许开发者完全控制 mipmap 级别,并且可以根据不同的设备和颜色色域(如 sRGB 或 P3)提供不同的纹理版本。
Interpretation: Data
: 通过在资源目录中将纹理的Interpretation
属性设置为Data
,可以确保当 sRGB 纹理加载到非 sRGB 缓冲区时,着色器将颜色数据视为线性数据进行处理。
2. 纹理压缩(Texture Compression)
纹理压缩是节省 CPU 和 GPU 内存的关键优化手段。
- ASTC 格式: Apple 推荐使用 ASTC(Adaptive Scalable Texture Compression)作为高质量压缩格式。在 iOS 上,资源目录会自动选择 ASTC 格式。
- 最佳实践: 应该为可缩小的纹理生成 mipmaps,并压缩大尺寸纹理以适应内存带宽需求。对于 A12 GPU 或更新的设备,甚至支持对无法预先压缩的渲染目标进行无损纹理压缩,以提高运行时效率。
八、 Metal 与 OpenGL 纹理概念对比
在纹理处理上,Metal 和 OpenGL 在核心概念(UV、Mipmaps、Filtering)上一致,但在 API 级别和状态管理上存在显著差异。
概念 | Metal (《Metal Tutorials》) | OpenGL (《Learn OpenGL》) | 核心差异 |
---|---|---|---|
加载/存储 | 依赖 MTKTextureLoader 或 Asset Catalog,将图像加载到 MTLTexture 对象中。 | 需要使用 外部库,例如 stb_image ,加载图像数据。然后手动通过 glTexImage2D 将数据填充到 GPU 内存。 | Metal 的集成度高,加载更自动化。 |
纹理绑定 | 使用 encoder.setFragmentTexture(texture, index: index) 将 MTLTexture 绑定到片段函数的 纹理槽位(Texture Slot)([[texture(n)]] )。 | 依赖 纹理单元(Texture Units)(GL_TEXTURE0 到 GL_TEXTURE15 )。必须先使用 glActiveTexture 激活单元,然后将 GLSL 中的 sampler2D uniform 变量与单元索引匹配 (glUniform1i )。 | OpenGL 使用纹理单元,Metal 使用参数表索引。 |
纹理参数 | 过滤、寻址、各向异性等参数在 MSL 中使用 constexpr sampler 定义,或使用 MTLSamplerState 对象定义。 | 参数通过重复调用 glTexParameteri 函数设置,指定纹理目标、轴(S/T/R)和具体模式(如 GL_LINEAR , GL_REPEAT )。 | |
Mipmaps 生成 | 通过设置 MTKTextureLoader 选项 .generateMipmaps: true 实现自动生成。 | 使用 glGenerateMipmap(GL_TEXTURE_2D) 函数手动触发生成。 | |
数据读取 | 使用 texture.sample(sampler, uv) 或 texture.read(coords) (使用像素坐标而非归一化坐标时)。 | 使用 GLSL 内置函数 texture(sampler, TexCoords) 。 | |
材质贴图 | Metal 通过 Model I/O 解析 .mtl 文件中的 map_Kd 等属性,并将它们加载为 MTLTexture 。 | OpenGL 同样使用纹理来实现漫反射贴图(Diffuse Map)、镜面贴图(Specular Map) 和法线贴图(Normal Map),通过 sampler2D 类型在 GLSL 中访问。 | |
透明度 | Metal 通过 opacity 贴图实现透明度定义。 | OpenGL 通过加载 RGBA 格式的纹理 并启用**混合(Blending)**功能(例如 GL_BLEND )来渲染半透明纹理。 |