Unity3D surface shader中的基本点线绘制


请尊重原作者的工作,转载时请务必注明转载自:www.xionggf.com

老图形程式员都会对Turbo C的graphic.h头文件倍感熟悉。Turbo C的BGI图形库提供了若干绘制图形的函数例如Bar。Rectangle等等基础的绘图函数。到了Windows时代。GDI也提供了一系列的绘图函数。但万变不离其宗,绘图函数的本质,都是归结于:给定若干个坐标值,找到这些坐标值对应的着色点,涂上颜色。从计算机图形学的角度来看就是给定一片内存缓冲区,这内存可以是系统内存,也可以是显示内存。即可以离屏不可见的缓冲区,也可以是当前可视的帧缓冲区,给定的若干坐标点,就是对应于确定要处理这些缓冲区中的哪个像素,涂上颜色,就是对应于要给这些像素指定具体的颜色值。

在固定的渲染管线中,要给缓冲区的像素着色方法无非就是:首先lock住这个缓冲区,取得缓冲区的首指针。这个过程就好比如我们取得了一张上面已经绘制好纵横坐标线的白纸。利用缓冲区的高度,宽度,跨度,就通过计算便能取到指定坐标值的那个像素点。然后设置像素点的颜色值。设置完毕后,再unlock这个缓冲区。

在可编程渲染管道中。我们知道,对每一个像素着色器代码,每一个像素都会执行一次的。也就是说。我们不能像在CPU执行的代码中那样子,具体地在代码中去指定要绘制的像素点在这个图中的坐标位置。而是要首先得到本像素点在图中的对应的坐标位置,然后去判断它要不要去绘制,要不要着色。打个比方,假设我们要在屏幕的正中处,从屏幕的最左端到最右端绘制一条红色的直线。那么在像素着色器代码中,就变成了判断要执行本段着色器代码中的那个像素,它的位置是不是落在这个屏幕中间的直线上,如果是的话,就把它涂成红色。如果不是的话,就不予以处理。

既然是在U3D环境下谈点线绘制,那么就开宗明义直奔主题,首先我们需要先定义一个数据结构v2f。所谓v2f即是Vertex shader TO Fragment shader的意思。也即是说这个结构体是用来从vs向fs传递数据的。定义如下:

struct v2f 
{
    float4 pos : SV_POSITION;
    float4 srcPos : TEXCOORD0;
};

其中:pos表示经过vs和光栅化变换之后的片元(像素)坐标。而放置在TEXCOORD0中的srcPos则表示该片元在屏幕上的坐标。定义好从VS输出到FS的数据结构之后,便可以着手实现对应的顶点着色器代码。在U3D中,这些代码是用Cg语言编写的。如下:

v2f vert(appdata_base v)
{
    v2f o;  
    o.pos = mul (UNITY_MATRIX_MVP, v.vertex);  
    o.srcPos = ComputeScreenPos(o.pos);    
    o.w = o.pos.w;  
    return o;      
}

代码很直观,首先利用U3D内置的UNITY_MATRIX_MVP,即世界坐标变换矩阵,观察坐标变换矩阵,投影变换矩阵三个矩阵的连乘值,乘以传递进来的顶点坐标,将其变换到裁剪空间,并且变换后的值赋给v2f的pos分量。注意,做完投影变换之后,此时的这个pos是位于齐次裁剪空间的。并且,这一步还未做透视除法,也即是说做完投影变换之后的顶点依然是齐次坐标,而不是笛卡尔坐标。真正转化到笛卡尔坐标是在光栅化阶段的透视除法之后,

这个时候,还没有还没有最后到视口坐标,要得到这个点的视口坐标值,则需要做一个从裁剪空间到视口坐标的计算。ComputeScreenPos则是提供了这个计算操作,当然,不要误会成变换操作是在这个函数里面完成。真正的从裁剪空间到视口坐标是在渲染管道中做好了的。ComputeScreenPos方法如下:

inline float4 ComputeScreenPos (float4 pos)
{
    float4 o = pos * 0.5f;                    // step 1
    #if defined(UNITY_HALF_TEXEL_OFFSET)
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w * _ScreenParams.zw;    // step 2-a
    #else
    o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;    // step 2-b
    #endif

    #if defined(SHADER_API_FLASH)
    o.xy *= unity_NPOTScale.xy;            // step 3
    #endif

    o.zw = pos.zw;                        // step 4
    return o;
}

在代码中,float4类型的_ProjectParams和_ScreenParams都是Unity3D在Unity.cginc文件中预定义的内建变量。

_ProjectParams定义如下:
分量x是1或者是-1,如果是-1的话表示投影操作是翻转过来的

分量y是近截屏幕的Z值
分量z是远截平面的Z值
分量w是分量z的倒数

_ScreenParams的定义如下:
分量x表示视口的宽度
分量y表示视口的高度
分量z表示视口宽度的倒数,再加1
分量w表示视口高度的倒数,再加1

为了分析这个ComputeScreenPos函数,假定给函数传递进来一个顶点p,其值的四个分量分别是(px,py,pz,pw)

step 1行代码是将传进来的pos坐标取值范围缩小,乘以0.5,经过这边操作后变量o的四个分量值是

(px/2,py/2,pz/2,pw/2) //after step 1

然后先分析不考虑半个纹素偏移点的问题,即step 2-b行代码,_ProjectionParams.x的取值是1或者-1,假定用的是1,则这步操作完成之后

(px/2+pw/2,py/2+pw/2,pz/2,pw/2) //after step 2

step 3是如果在FLASH平台运行下的操作,在此不予以考虑,略过
step 4则是把变量o的zw分量复原为刚开始执行本函数时的状态,完成这步操作后,变量o的四个分量值为:

(px/2+pw/2,py/2+pw/2,pz,pw)//after step 4

如果仅仅到了这一步,大家肯定会对返回的这个值有所怀疑——这真是执行本段代码的那个顶点,经过处理后生成的对应的像素在视口坐标的坐标值吗?答案是目前还不是。还要经过在一步转换操作,这个转换操作在自定义的方法GetScreenCoordFromScreenPos中,如下

inline float2 GetScreenCoordFromScreenPos(float4 screenPos)
{
    return (screenPos.xy/screenPos.w) * _ScreenParams.xy;
}

上面的变量o依然作为参数传给GetViewportCoordFromScreenPos函数,这一步的操作,首先把o的xy分量,各自除以o的w分量,这步实质上就是光栅化阶段中的透视除法的概念,经过这一步的除法之后,原来的齐次坐标及转化到笛卡尔坐标了,并且o的xy分量值都被限定在[-1,1]的范围中。然后再把xy分量,分别乘以屏幕的高宽,这时候,得到的坐标值就是对应的视口坐标值了。转换完毕之后o的xy分量值也即是执行这段着色器代码的像素最终的视口坐标值为:

x分量:[(px/2+pw/2)/pw]*视口宽度VW = (0.5*px/pw + 0.5) * VW
y分量:[(py/2+pw/2)/pw]*视口高度VH = (0.5*py/pw + 0.5) * VH

我们知道,经过透视除法操作后,剪裁空间的顶点可通过标准化设备坐标(NDC)加以表示,在这个坐标系下xy的取值范围限定在[-1,1]范围内。也就是说。上述式子中的px/pw和py/pw的取值范围也是落在[-1,1]中,

在上面的操作中,如果当前的视口和屏幕窗口完全重合的话,视口坐标值可以等同于屏幕窗口坐标值。