Vulkan 纹理采样计算过程详解

纹理采样是图形渲染过程中的一个基础步骤,本文主要介绍 Vulkan/SPIR-V 中纹理采样相关的指令以及计算逻辑,从而可以了解最终像素点的采样值RGBA,是如何根据内存中的图片buffer数据计算而来的,对我们实现软件渲染器有一定帮助,主要的参考资料是 Vulkan Specification – Image Operations 这一章,建议和本文对照阅读,另外由于公式编辑较为耗时,文中不少公式都是截图的,还请见谅~

纹理采样相关指令

SPIR-V 的纹理采样相关指令主要可分为:

  • OpImageSample*:最常用的纹理采样指令,有多种形式,详见下面表格
  • OpImage*Gather:邻域 texel 批量获取,有 OpImageGather 和 OpImageDrefGather 两种形式,区别在于是否做深度比较;
  • OpImageFetch:获取指定的单个 texel,不采样(如 texel 之间、mipmap 之间插值计算等)
  • OpImageQuery*:查询纹理相关的一些属性,如 size、level、lod 等

以上四类指令在 GLSL 中都能找到对应的函数,详见 Texture Functions,另外 Vulkan 中以上指令都有 Sparse 版本,针对的是虚拟纹理,这里就不做过多介绍。

opcode 指定lod 齐次投影 深度比较
OpImageSampleImplicitLod
OpImageSampleExplicitLod
OpImageSampleDrefImplicitLod
OpImageSampleDrefExplicitLod
OpImageSampleProjImplicitLod
OpImageSampleProjExplicitLod
OpImageSampleProjDrefImplicitLod
OpImageSampleProjDrefExplicitLod

采样过程

在介绍采样过程前,首先需要了解纹理相关的三种坐标系统:

  • 归一化浮点坐标:范围在 [0.0, 1.0],表示为 (s,t,r,q,a)

    • s/t/r:分别为纹理图像的第1/2/3维坐标
    • q:齐次投影坐标
    • a:纹理数组 layer

    注意纹理数组不支持齐次投影,即 q 和 a 不会同时存在

  • 非归一化浮点坐标:范围在 [0.0, width/height/depth],表示为 (u,v,w,a)

    • u/v/w:分别为纹理图像的第1/2/3维坐标
    • a:纹理数组 layer
  • 整型坐标:范围在 [0, width/height/depth],表示为 (i,j,k,l,n)

    • i/j/k:分别为纹理图像的第1/2/3维坐标
    • l:纹理数组 layer
    • n:texel 采样点 index(比如开启了多采样)

然后来看纹理相关的数据结构,我们知道常见的纹理维度有1D/2D/3D(Cube)/..,然后还有纹理数组(1DArray/2DArray/..),而且单个纹理图像在开启 Mipmap 后还有多级子图,我们可以统一用这样的模型来描述:

  • image
    • layer 0
      • level 0
      • level 1
      • level m1
    • layer 1
      • level 0
      • level 1
      • level m2
    • layer n

即 image 由多个 layer 组成,每个 layer 又包含多个 level,每个 level 可以看作是一个 2D 图像,其中 layer 对应到纹理数组,level 对应 Mipmap 层级

有了上述坐标系统和数据结构的准备,接下来看具体的采样计算过程:

1. 计算齐次投影

如果是齐次投影采样,需要先将坐标值 (s,t,r) 转换为 (s/q,t/q,r/q),即都除以 q,如果有深度比较,深度值 D 也要除以 q:D = D/q

2. 定位 layer

根据归一化或非归一化坐标中的 a 计算出 layer index,确定我们需要在哪个 layer 进行采样:

layer = clamp(RNE(a), 0, layerCount - 1) + baseArrayLayer

即对 a 进行 roundEven(或向下取整),然后 clamp 到 [0, layerCnt-1],再加上 baseArrayLayer,就得到最终的 layer index

3. 计算 lod

lod(Level-of-Detail) 的计算方式如下图:

即 λ_base 可以显式地在指令中指定,否则自动根据坐标导数来计算,然后 λ_base 加上 bias,再做一些范围限定就得到最终的 λ,这里相对复杂的是根据坐标导数计算 λ_base 的过程,Vulkan 中将该过程称为 "Scale Factor Operation":

如上图,屏幕空间的一个小方块(单位长度),映射到纹理空间是一个四边形,我们计算 lod 的目的是选择合适的 Mipmap 图层,因此本质上是求缩放系数,由于 Mipmap 各 level 的大小都是 2 的幂次缩小,对缩放系数取 log2 就得到所需要的 level。以2D 为例,用 ρx、ρy 分别表示 x、y 方向上的缩放系数,即 ρx、ρy 对应右图的 r1、r2,其值分别是 x、y 方向坐标导数平方和开根号,但为了简化计算,也可以自己定义,只要满足如下条件即可:

然后是各向异性相关的计算:

ρmax = max(ρx, ρy);
ρmin = min(ρx, ρy);
η = min(ρmax/ρmin, maxAniso);

即各向异性比率 (ratio of anisotropy) η 是 x、y 方向缩放系数最大值最小值的比例,η 越大,说明 x、y 方向的缩放差异越大,η 为 1 则退化为各向同性,然后 η 还需要限制在不大于 maxAniso 的范围,最后我们计算 λ_base = log2(ρmax/η)

4. 定位 level

在计算出 λ 后,我们需要定位具体的采样 level,这里分两种情况,如果设置的 mipmap 模式是 NEAREST,则将 λ clamp 到 [baseLevel, baseLevel + levelCnt – 1] 之间,然后取整即可:

而如果 mipmap 模式是 LINEAR,就需要从相邻的两个 level 采样,然后插值

d_hi, d_lo 分别是相邻的上下两个 level index,𝛿 则为插值系数(d_l的小数部分)

5. 坐标转换 (s,t,r) -> (u,v,w)

在确定好 level 后,如果指令的坐标系统是归一化的 (s,t,r),还需要进行坐标转换为非归一化的 (u,v,w),这个步骤比较简单,各坐标分量分别乘以当前 level 对应坐标轴的大小即可,然后如果指令里带了坐标 offset,需要再加上

u = s * width + offset_i
v = t * height + offset_j
w = r * depth + offset_k

6. 坐标转换 (u,v,w) -> (i,j,k)

得到非归一化的 (u,v,w) 坐标后,此时坐标还是浮点数,而贴图内存 buffer 只能整型下标取值,所以还需要转换为整型坐标 (i,j,k),这里根据 filtering 模式分两种:

  • NEAREST

    单像素采样,(u,v,w) 只需向下取整即可,即这里 shift 为 0.0

  • LINEAR

    需要采样多个(2/4/8)像素然后插值,这里 shift 为 0.5,α, β, and γ 分别为各坐标分量的插值系数

7. Wrapping

在得到 (i,j,k) 整型坐标后,我们终于可以进行采样操作了,而 i,j,k 的坐标范围并不一定和贴图 buffer 尺寸完美对应,因此需要根据 Wrapping 模式进行变换,常见的有 repeat、clamp to edge、 mirrored repeat 等模式。

这里可能还有 Texel Replacement 过程,如 clamp to border 的 border 像素等,Vulkan 有定义多种 borderColor

8. Filtering

如前面所说,如果 filtering 模式是 LINEAR,以及 mipmap 的模式是 LINEAR,都需要进行多次采样,然后插值,插值的计算相对简单,把各个采样值用系数加权求和即可

当然这里的加权系数计算并不一定是求平均,也可能是求最大值或最小值,可由 VkSamplerReductionModeCreateInfo::reductionMode 参数指定。

9. 深度比较

最后如果有深度比较,则根据比较函数(VkSamplerCreateInfo::compareOp)比较采样值和深度值 D,通过则为 1.0,否则为 0.0。

至此,我们整个采样计算过程就完成了,而对于 Fetch 指令,主要就是少了坐标转换和 Filtering 等操作,其余步骤是一致的。

代码参考

之前在写 Shader 虚拟机 SPVM 的时候,用 C++ 实现过一版上述纹理采样过程,主要逻辑在 https://github.com/keith2018/spvm/blob/main/src/image.cpp 文件,当然目前可能实现的并不严谨(比如没考虑各向异性),后面有时间再完善一下。

参考资料

[Vulkan Specification] 16. Image Operations

[SPIR-V Specification] 3.14. Image Operands