YASUHATI

顶点变换.
Color pixels.
shader 属性.
从顶点传数据至片元函数.
查看编译后的shader代码.

使用Unity5.6.6f2

1 默认场景

新建一个默认场景,新建一个圆球。这个默认场景本身进行了大量复杂的渲染,为了更容易的掌握Unity的渲染过程,我们先做一些简化设置,把,默认的某些花里胡哨的东西先剥离掉。

1.1 剥离天空盒

打开Window / Lighting,查看光照设置选项。弹出带有3个选项卡的面板,我们先关注Scene选项卡.

image

1-1. 默认光照设置

第一节Environment是跟环境光照相关,在这里可以设置天空盒。这个Default-Skybox当前正被用于场景的背景光、环境光、和反射光。设置为none就能关闭这些光。就手把下面的Realtime LigtingMixed Lighting也关掉,现在还用不上,后面会陆续介绍。

关闭了天空盒,环境颜色自动切换为了纯色,这个颜色默认是带着一丝蓝的黑灰色(说好的纯呢,外表很黑内心很蓝?)。而反射光会变成纯黑色。如下所示,设置后球体变暗了,背景变成了纯色。而这个背景深蓝色从哪里来的呢?

image image

1-2. 简单光照

这个深蓝色被定义在摄像机,它默认使用天空盒渲染,当天空盒失效后它会默认退回到使用纯色模式。

image1-3. 默认的摄像机设置

为了进一步简化渲染,再隐藏或删除方向光对象。这将消除场景中的直接光照,以及所有它投射的阴影。剩下纯色背景和球体的轮廓。

 1-4. 球体轮廓

2 图像渲染

分两步绘制上面的场景,一是使用相机的背景色填充图像,然后再在上面画出球体的轮廓。

Unity如何知道该画这个球体呢?我们有一个球体对象并且绑定了MeshRenderer组件,如果这个球体位于摄像机的视野内,那么它就会被渲染出来。Unity通过检测球体的边界盒是否与摄像机的视锥体相机来验证这一点。包围盒在Unity中定义为Bounds结构体Collider.bounds, Mesh.bounds and Renderer.bounds.

 2-1. 球体默认自带组件

Transform组件用于更改坐标、方向,以及网格和包围盒的尺寸。这里有对Transform层次结构的清晰描述。如果一个物体最终处于摄像机视野内,它就会被安排渲染。

最后,GPU负责渲染物体的mesh。这些具体的渲染指令在物体的material定义好的,这个material引用了一个shader-GPU程序。

 2-2. 各司其职

当前这个球体使用了Unity的默认材质,自带了一个标准 shader。我们现在把它去掉替换成自己的shader,从头开始写。


2.1 第一个Shader

通过点击Assets / Create / Shader / Unlit Shader创建并命名自己的shader,双击shader文件打开,并删除里面的内容从头写。

 2-3. 第一个shader

Shader是通过shader+关键字定义,关键字是一个字符串,在下拉界面中选择时显示的也是该关键字。它不必与文件名相同。

Shader "Unlit/MyShader"
{
}

保存文件,回到编辑器会收到警告提示none of subshaders/fallbacks are suitable因为它是空的,没有sub-shader或回调shader。尽管这个shader没有内容也有警告,我们仍能指定给material。点击Assets / Create / Material创建,然后通过下拉菜单指定。

 2-4. 给材质指定Shader

给球体指定上我们新建的Material,替换掉默认的。这时的球体会立即变成紫红色。发生这个的原因是Unity切换到了错误的shader,它故意使用这个颜色来提醒开发者这是一个错误。

2-5. 指定MyMaterial

shader warning中提到了没有sub-shader. 我们可以使用sub-shader操作shader变量进行分组, 这允许程序员为不同的编译平台提供不同的sub-shader.例如我们可以用一个sub-shader既支持pc又支持手机平台.定义一个SubShader块

Shader "Unlit/MyShader"
{
    SubShader{

    }
 }

sub-shader至少包含一个以上的pass块, pass代码块是物体实际被渲染的地方,我们先写一个pass,然后在写多个pass。为了呈现多种效果,pass数量可能会超过一个以上,而则代表着物体要被渲染多次。

Shader "Unlit/MyShader"
{
     SubShader{
        Pass{        }
    }
}

我们的球体现在应该变成了白色,因为我们使用了一个空pass渲染,这也意味着我们的Shader没有出现任何错误了。

 2-6. 空shader效果

2.2 Shader程序

现在我们要开始编写shader代码了,我们用的Unity着色器语言是HLSL和CG着色器语言的变体。所以必须指示CGPROGRAM关键字为代码的开始,同时要用ENDCG关键字做为结束。

Pass{
    CGPROGRAM

    ENDCG
}

再次打开编辑器编译后有一个警告Both vertex and fragment programs must be present没有顶点和片元程序,shader由这两个程序组成,vertex顶点程序负责处理网格的顶点数据,这包含了从对象空间到显示空间的转换。看这里。而fragment片元程序负责为位于网格的三角形内的单个像素着色。

2-7. 顶点和片元程序

我们必须通过pragma指令告诉编译器使用哪些程序

CGPROGRAM

#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram

ENDCG

编译器再次发出来错误提示,这次是因为它不能找到我们指定的程序片段,因为我们光声明没实现。首先vertex和fragment被写成方法,类似C#,尽管他们被称之为函数。先简单地创建两个同名的void方法。

CGPROGRAM

#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram

void MyVertexProgram() {

}

void MyFragmentProgram() {

}

ENDCG

这次编译后没有报错,但是球体从屏幕上消失了。


2.3 Shader汇编

Unity的shader编译器把我们的代码转换成了不同程序,这取决于目标平台。不同的平台需要不同的解决方案,例如Direct3D是服务于Windows平台,OpenGL针对MacOs,OpenGL ES针对手机平台。这里我们不是在处理单个编译器,而是多个编译器。

最终使用哪种编译器取决于目标平台,这些编译器是不完全相同的,每个平台可能得到不同的结果。在这个例子中,我们的空程序在OpenGL和Direct3D 11下能很好的工作,但在Direct3D 9就会报错。

在编辑器下点选MyFirstShader,在监视面板可以查看该shader的一些信息,以及编译错误。这也有一个Compiled code入口,该入口Compile and show code按钮和下拉菜单。如果你点击该按钮,Unity将会编译该shader并打开它,接着就可以查看生成的代码。

2-8. shader检视面板信息

2-9. 目标平台编译

我们试着先选择OpenGL Core,然后再选择D3D11,看看底层代码是怎么回事的。

OpenGL Core

Shader "Unlit/MyShader" {
SubShader {
  Pass {
 No keywords set in this variant.
 -- Vertex shader for "glcore":
 Shader Disassembly:
 #ifdef VERTEX
 #version 150
 #extension GL_ARB_explicit_attrib_location : require
 #extension GL_ARB_shader_bit_encoding : enable

void main()
 {
     return;
 }

#endif
 #ifdef FRAGMENT
 #version 150
 #extension GL_ARB_explicit_attrib_location : require
 #extension GL_ARB_shader_bit_encoding : enable

void main()
 {
     return;
 }

#endif


-- Fragment shader for "glcore":
 Shader Disassembly:
 // All GLSL source is contained within the vertex program

 }
 }
 }

提炼出两个main函数,有vertex和fragment程序

#ifdef VERTEX
void main()
{
    return;
}
#endif
#ifdef FRAGMENT
void main()
{
    return;
}
#endif

D3D11自行查看,因为编译后的代码实在是太长了,不方便贴上来。只选取了一个片段:

Pass {

No keywords set in this variant.
-- Vertex shader for "d3d11":
Shader Disassembly:
      vs_4_0
   0: ret


-- Fragment shader for "d3d11":
Shader Disassembly:
      ps_4_0
   0: ret

}


2.4 引入其他文件

编写shader代码很费劲,有时需要重复写类似的函数,为了简化书写,这里有一个类似C#程序的功能,引用其他类中的通用变量、函数等。使用#include指令就能加载一个文件。先试着加载Unity内部自带的UnityCG.cginc

CGPROGRAM

    #pragma vertex MyVertexProgram
    #pragma fragment MyFragmentProgram

    #include "UnityCG.cginc"

    void MyVertexProgram() {

    }

    void MyFragmentProgram() {

    }

ENDCG

下面是UnityCg.cginc的引用层次结构

2-10. UnityCG.cginc结构

UnityShaderVariables.cginc定义了一大堆渲染所需的着色器变量,比如转换、相机和光zhao数据。这些都是由Unity在需要时设置的。

UnityInstancing.cginc专门用于实例化支持,这是一种减少绘制调用的特定呈现技术。虽然它不直接包含文件,但它依赖于UnityShaderVariables。

HLSLSupport.cginc设置了一些无论您的目标是哪个平台都可以使用相同的代码的功能。因此,您不必担心使用特定平台的数据类型等问题。

请注意,这些文件的内容将被复制到文件中,取代include指令。这发生在预处理步骤中,该步骤执行所有预处理指令。比如#include和#pragma。


2.5 产生输出( 语义 )

为了渲染物体,shader必须要产生结果。

Vertex顶点函数必须要返回最终的顶点坐标:SV_POSITION。一个顶点有几个坐标分量?4个,因为我们使用了4x4变换矩阵。现在把函数类型从void改为float4,一个float4类型是一个由4个float类型简单组成。

float4 MyVertexProgram( ) : SV_POSITION{
    return 0;
}

Fragment片元函数返回像素的最终颜色:SV_TARGET。同理float4。

float4 MyFragmentProgram( ) : SV_TARGET{
    return 0;
}

Vertex顶点函数的输出作为Fragment片元函数的输入。输入的参数需要匹配语义!

float4 MyFragmentProgram
(
    float4 position : SV_POSITION
) : SV_TARGET {
    return 0;
}

然后看看Unity的shader汇编

//--------------D3D11-----------------
-- Vertex shader for "d3d11":
Shader Disassembly:
      vs_4_0                            //顶点着色器版本
      dcl_output_siv o0.xyzw, position  //声明o0作为输出值,带有系统值
   0: mov o0.xyzw, l(0,0,0,0)           //把(0,0,0,0)移动到o0中
   1: ret                               //返回

-- Fragment shader for "d3d11":
Shader Disassembly:
      ps_4_0
      dcl_output o0.xyzw
   0: mov o0.xyzw, l(0,0,0,0)
   1: ret

//---------------GL CORE-----------
#ifdef VERTEX
void main()
{
    gl_Position = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif

#ifdef FRAGMENT
layout(location = 0) out vec4 SV_TARGET0;
void main()
{
    SV_TARGET0 = vec4(0.0, 0.0, 0.0, 0.0);
    return;
}
#endif

2.6 顶点变换

把球给我画出来!

为了得到模型空间的顶点坐标,给Vertex顶点函数增加一条语义:POSITON。而模型空间的顶点坐标是其次坐标。先直接返回这个顶点坐标,贴汇编:

//----D3d11-------
      vs_4_0                    //版本
      dcl_input v0.xyzw            //申明v0 输入系统值
      dcl_output_siv o0.xyzw, position    //申明o0 输出系统值
   0: mov o0.xyzw, v0.xyzw            //把v0值 移动到 o0
   1: ret

//---GL CORE---
#ifdef VERTEX
in  vec4 in_POSITION0;
void main()
{
    gl_Position = in_POSITION0;
    return;
}
#endifView Code


2-11. 扭曲的球

使用MVP:model_view_projection矩阵变换顶点坐标,定义在UnityShaderVariables文件,变量名是UNITY_MATRIX_MVP。改为:

return mul(UNITY_MATRIX_MVP, position);贴汇编看看
-- Vertex shader for "d3d11":
// Stats: 8 math
Uses vertex data channel "Vertex"
//cbuffers常量数据
Constant Buffer "UnityPerDraw" (160 bytes) on slot 0 {
  Matrix4x4 unity_ObjectToWorld at 0
}
Constant Buffer "UnityPerFrame" (384 bytes) on slot 1 {
  Matrix4x4 unity_MatrixVP at 272
}

Shader Disassembly:
      vs_4_0                            //版本
      dcl_constantbuffer CB0[4], immediateIndexed    //声明常量缓冲区cbuffers,逐字索引
      dcl_constantbuffer CB1[21], immediateIndexed    //cbuffers
      dcl_input v0.xyz                    //声明输入v0
      dcl_output_siv o0.xyzw, position            //声明输入o0
      dcl_temps 2                        //声明临时寄存器2个(r0-r1)
   0: mul r0.xyzw, v0.yyyy, cb0[1].xyzw            //将v0与cb0[1]相乘传递给r0

//dest.x = cb0[0].x * v0.x + r0.x;
//dest.y = cb0[0].y * v0.x + r0.y;
//dest.z = cb0[0].z * v0.x + r0.z;
//dest.w = cb0[0].w * v0.x + r0.w;
   1: mad r0.xyzw, cb0[0].xyzw, v0.xxxx, r0.xyzw    //前两相乘结果与3相加
   2: mad r0.xyzw, cb0[2].xyzw, v0.zzzz, r0.xyzw    //同理1:

   3: add r0.xyzw, r0.xyzw, cb0[3].xyzw            //r0+cb0[3]传递给r0
   4: mul r1.xyzw, r0.yyyy, cb1[18].xyzw            //相乘
   5: mad r1.xyzw, cb1[17].xyzw, r0.xxxx, r1.xyzw    //同理1:
   6: mad r1.xyzw, cb1[19].xyzw, r0.zzzz, r1.xyzw    //同理1:
   7: mad o0.xyzw, cb1[20].xyzw, r0.wwww, r1.xyzw    //同理1:
   8: retView Code


2-12. 正确的球

3 像素颜色

先给Fragment函数返回点东西,

float4 MyFragmentProgram(float4 position : SV_POSITION) : SV_TARGET{
    return float4(1, 1, 0, 1);
}

image

3-1. 黄色球

3.1 Shader 属性: Properties

image

3-2. 结构

image

3-3. 解释

3.2 使用属性

需要在pass块内声明一个同类型的同命名变量            float4 _Tint;

float4 MyFragmentProgram(float4 position : SV_POSITION) : SV_TARGET{
    return _Tint;
}

看看片元函数的汇编

-- Fragment shader for "d3d11":
Constant Buffer "$Globals" (48 bytes) on slot 0 {
  Vector4 _Tint at 32
}

Shader Disassembly:
      ps_4_0
      dcl_constantbuffer CB0[3], immediateIndexed
      dcl_output o0.xyzw
   0: mov o0.xyzw, cb0[2].xyzw
   1: retView Code

image

3-4. 纯色

3.3 从顶点到片元

图3-4纯色球,每个像素都是同一个颜色,但是美术给的效果图是五彩斑斓的,就需要GPU光栅化三角形,取三个处理过的顶点进行插值,找到三角形内所有像素并着色

image

3-5. 插值数据传递

又3-5知:处理过的顶点数据不直接传递给Fragment片元函数,而在片元函数中访问插值本地数据,需要增加一个参数,并指定语义:TEXCOORD0.它表示贴图的UV坐标。

float4 MyVertexProgram
(
    float4 position: POSITION,
    out float3 localPosition : TEXCOORD0
) : SV_POSITION{
    localPosition = position.xyz;
    return UnityObjectToClipPos(position);
}

float4 MyFragmentProgram
(
    float4 position : SV_POSITION,
    float3 localPosition : TEXCOORD0
) : SV_TARGET
{
    return float4(localPosition, 1);
}

image

3-6. 插值本地数据作为颜色


3.4 结构体

简化!

struct Interpolators{
    float4 position : SV_POSITION;
    float3 localPosition : TEXCOORD0;
};

Interpolators MyVertexProgram (float4 position: POSITION ){
    Interpolators i;
    i.localPosition = position.xyz;
    i.position = UnityObjectToClipPos(position);
     return i;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET
{
    return float4(i.localPosition, 1);
 }

UnityObjectToClipPos是Unity5.6之后的优化:

它对应mul(UNITY_MATRIX_MVP, v.vertex),但是该函数使用了常数1作为第四个坐标而不是依赖网格数据,源码:
inline float4 UnityObjectToClipPosInstanced(in float3 pos)
{
    return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorldArray[unity_InstanceID], float4(pos, 1.0)));
}
因为通过网格提供的数始终为1,但是编译器不能知晓。所幸干脆就直接写死为1.0,优化掉运行时再去计算第四个数到底是多少这一步。

4 原文翻译

文章很好,赞助作者去吧!

分类:

技术点:

相关文章: