LOADING

加载过慢请开启缓存 浏览器默认开启

屏幕UV的4种生成方式

梳理裁剪空间、屏幕空间与齐次屏幕坐标,并对比从 SV_POSITION、positionCS、positionOS 和 SV_VertexID 获取屏幕 UV 的方法。

一、引言

这段时间整理URP的延迟管线shader,猛然发现lightPass阶段的顶点着色器结构体有这样一个数据:uint vertexID : SV_VertexID;出于好奇就调查了一下,发现它其实是用于生成屏幕UV的,之前也写过很多次基于屏幕UV的效果了,貌似好像拿取屏幕UV的方式不止一种,这次又是一个没咋见过的方式,另外,关于这个部分Unity的函数命名以及各种图形学上的概念可能会有一些纠缠不清,所以就有了这篇文章理清一下。

二、什么是屏幕UV

我们拿取它之前首先要知道我们要拿的东西是什么。UV嘛,纹理坐标,用于告诉贴图某个像素应该被采样到那个位置,一般呢是根据模型自身来的,有点类似一个二维坐标系,给映射到了模型上。而屏幕UV呢,则不依赖于模型,它是根据屏幕的参数来定的,本文说明时以屏幕的左下角为原点,从屏幕最左边到最右边为01的X轴坐标,从屏幕最下面到最上面为01的y轴坐标。

屏幕UV的4种生成方式示意图 1

基于我们的手上有的数据,一般屏幕坐标的获取被分为四大类。

方式 数据来源 典型使用场景
裁剪空间位置 positionCS 通用
屏幕空间顶点 SV_POSITION 通用
顶点位置 positionOS 全屏 Mesh、Blit
顶点编号 SV_VertexID 程序化全屏三角形

三、统一名词和概念

想要讲清楚这件事就得先把这些统一,我在实际查阅资料的时候发现其中有些东西是混淆的。

PosCS

首先是PosCS(裁剪空间齐次顶点坐标),是模型从模型空间变换到齐次裁剪空间的结果,XY分量的取值范围是(-w,w),z值是投影变换的非线性深度分量,而w值是观察空间的线性深度,也就是PosVS的Z值。PosCS来自于MVP矩阵,Unity的函数TransformObjectToHClip变换后的结果就是它。TransformObjectToHClip内部的实现如下:

屏幕UV的4种生成方式示意图 2

其实就是连续做了M矩阵和VP矩阵。

PosNDC

想要将模型显示到屏幕上,一般要将其变换到屏幕空间,齐次裁剪空间的下一步就是NDC空间(Normalized Device Coordinate:归一化设备坐标)它来自于PosCS的透视除法,也就是PosCS/PosCS.w取值范围是(-1,1),也就是常说的标准立方体。透视除法之后的坐标是非线性的,在顶点着色器到片元着色器的过程中会执行透视插值,可能得到错误的结果,所以凡是涉及透视除法的步骤其实最好在片元着色器中完成。

PosSS

注意:这里的名字是我自己起的,并不是什么标准或者约定俗成的概念,这里说的PosSS指的是屏幕空间顶点,后面的SS指的就是ScreenSpace(屏幕空间),一般由(PosNDC * 0.5 +0.5)* 像素宽高得到,是顶点变换的终点,至此就可以知道某个顶点到底应该在屏幕中的哪个位置。需要注意的是,根据API的不同屏幕空间的坐标原点也不相同,目前计算的是以屏幕左下角为原点的空间,OpenGL就是这样的方式,而Direct3D 11/12和Vulkan则是以模型的左上角为原点,Y轴的朝向是向下的。所以我们一般不手动计算PosSS,会出现一些不同API的适配问题。

screenPos

这个名字一般是Unity约定俗成的叫法,光看名字还以为是和上文的PosSS一样的变量,实则不然。它来自于Unity的ComputeScreenPos(posCS);函数,函数直译的话是“计算屏幕位置”,而这个变量更加严谨的叫法应该是齐次屏幕坐标。先看这个函数本身的实现吧。

屏幕UV的4种生成方式示意图 3

它来源于PosCS,并且处理了不同平台的坐标原点不同的问题,然后同样将其从(-1,1)映射到了(0,1)。可以理解为它等价于(PosNDC * 0.5+0.5)* w,或者理解为没做透视除法的屏幕UV。之所以不按照我们熟悉的流程来,就是因为之前提到的顶点着色器到片元着色器的插值问题,这种方式可以把透视除法这一步延迟到片元着色器之后完成。

四、基于屏幕空间顶点(SV_POSITION)的屏幕UV

这个是最常见的获取方式之一,一般是普通的世界空间物体获取屏幕UV的方式。前文提到,我们想要的是一个以屏幕左下角为原点,以长和宽为XY轴,取值范围是01的坐标系。而模型顶点在投影到屏幕上的最终目的就与其类似,它想要变换到屏幕空间——以屏幕左下角为原点(其实根据API不同可能以左上角为原点),以长和宽为XY轴,并且取值范围为0宽或者高的坐标系。那么很简单了,我们想要拿到Screen SpaceUV就只需要用ScreenSpacePos除以屏幕的宽高即可。 我们看这样的写法:

Fragment:

float2 screenUV = i.positionCS.xy / _ScaledScreenParams.xy;

这样其实就能拿到Screen SpaceUV,这里的positionCS在顶点到片元的插值数据结构体(Varyings)会被定义成为具有SV_POSITION语义的量,拥有这个语义的量带有特殊任务,所以它其实已经并不是xy范围在(-w,-w)的齐次裁剪坐标了。在顶点着色器的时候,它还是它,在片元着色器的过程中,它会自动的经历透视除法,并将取值范围映射到屏幕长宽,所以它的XY值已经是名副其实的屏幕空间坐标顶点(ScreenSpacePos)了。所以如果你想要这样的写法的话一定一定一定要在片元着色器中写。 另外,GetNormalizedScreenSpaceUV函数内部实现方式就是这样,我们填入屏幕空间顶点,它帮我们除以分辨率。

五、裁剪空间位置(positionCS)的屏幕UV

其实在模型变换到屏幕空间的过程中就已经有屏幕UV了,也就是posNDC * 0.5+0.5,前文说过,最好别在顶点着色器算好透视除法,所以我们用screenPos。

vertexx:

 o.screenPos = ComputeScreenPos(posCS);
Fragment:

float2 screenUV = i.screenPos.xy /screenPos.w;

六、基于模型空间顶点位置(positionOS)

前面的方法都比较通用,一般普通的3D世界物体需要屏幕UV的话就可以用上面的两种办法。基于模型空间顶点位置的方式只能用于全屏效果,例如屏幕后处理,延迟渲染的光照Pass阶段。 这是一个覆盖整个屏幕的网格:

屏幕UV的4种生成方式示意图 4

我们可以将其顶点位置从(-1,1)映射到(0,1),再由顶点到片元着色器之间的插值,映射后的模型空间顶点其实就是屏幕UV了 URP的延迟渲染光照Pass就是这样的方式:

屏幕UV的4种生成方式示意图 5

七、基于顶点ID(SV_VertexID)

原先整个屏幕被一个四边形覆盖,而一个四边形是包含两个三角形的,我们当然也可以只用一个巨大的三角形覆盖住整个屏幕(由于我们构建了一个覆盖整个屏幕的巨大三角形,所以这个方式也只能用于后处理):

屏幕UV的4种生成方式示意图 6

黄色部分是屏幕,也就是说整个三角形边长一半的区域就是我们要的覆盖屏幕的UV,如果-13的位置的uv是02的话,那么正好在屏幕的部分的uv就是0~1 我们取三个顶点ID:0,1,2 然后自己定义这三个顶点ID所代表的uv(这里方便理解就用if表示):

 if (input.vertexID == 0)
    {
        output.uv = float2(0, 0);
    }
    else if (input.vertexID == 1)
    {
        output.uv = float2(2, 0);
    }
    else
    {
        output.uv = float2(0, 2);
    }

再定义对应的裁剪空间顶点:

output.positionCS = float4(output.uv * 2.0 - 1.0,0.0,1.0);

这样就分别有三个顶点:(-1,1)(3,1)(-1.3),也就是上图我们构建的样子 接下来就直接在片元着色器采样这个uv,就已经是屏幕UV了。 在实际URP中我们使用这样两个函数来构建这样的三角形:

屏幕UV的4种生成方式示意图 7

要使用这种方式构建屏幕UV,这两个函数缺一不可,不能单独使用。 他们的实现方式分别如下:

屏幕UV的4种生成方式示意图 8
屏幕UV的4种生成方式示意图 9

之前为了方便理解,我们是用if的方式构建这个UV的,实际上unity是使用运算符的方式构建,结果是一样的,if的话会产生条件分支,所以实际上应该用unity的方法。 我们看一下这行运算符:float2 uv = float2((vertexID << 1) & 2,vertexID & 2); 先看X:(vertexID << 1) & 2 vertexID << 1也就是先将vertexID 左移一位,相当于整数 * 2 & 2是只保留二进制中数值为2的一位 实际结果如下:

屏幕UV的4种生成方式示意图 10

结果其实就是我们之前指定的三个UV。