在unity中写着色器(shader)
在unity中写着色器(shader)看上去可能是个艰巨的任务。这个帖子可以带你由浅入深地了解如何在unity中写着色器(shader)。
- 什么是着色器(shader)
想象unity着色器(shader)是只在GPU上运行的微缩程序。Unity中着色器(shader)的目的在于,指导显卡如何去渲染屏幕每个具体的像素。如果你具有任何水平的编程基础,你可以将其想象成具有诸多具体功能的类,我们还可以编写其中所有的代码。Unity中,2大主要“程序”就是顶点(vertex)和碎片(fragment)(也被称为像素)程序。为了了解着色器(shader),以及如何在unity中编写它们,尽可能地了解这2种程序就尤为重要。下面是一个unity着色器(shader)的例子,我们会将其拆解,并在后文中仔细地解释分析。
Shader "Simple_Diffuse" { Properties { _Color ("Main Color", Color) = (1,1,1,1) _MainTex ("Base (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #define UNITY_PASS_FORWARDBASE #include "UnityCG.cginc" #include "AutoLight.cginc" #include "Lighting.cginc" #pragma multi_compile_fwdbase_fullshadows #pragma multi_compile_fog #pragma target 3.0 float4 _Color; sampler2D _MainTex; float4 _MainTex_ST; sampler2D _NormalMap ; float4 _NormalMap_ST; struct VertexInput { float4 vertex : POSITION; //local vertex position float3 normal : NORMAL; //normal direction float4 tangent : TANGENT; //tangent direction float2 texcoord0 : TEXCOORD0; //uv coordinates float2 texcoord1 : TEXCOORD1; //lightmap uv coordinates }; struct VertexOutput { float4 pos : SV_POSITION; //screen clip space position and depth float2 uv0 : TEXCOORD0; //uv coordinates float2 uv1 : TEXCOORD1; //lightmap uv coordinates //below we create our own variables with the texcoord semantic. float4 posWorld : TEXCOORD3; //world position of the vertex float3 normalDir : TEXCOORD4; //normal direction float3 tangentDir : TEXCOORD5; //tangent direction float3 bitangentDir : TEXCOORD6; //bitangent direction LIGHTING_COORDS(7,8) //this initializes the unity lighting and shadow UNITY_FOG_COORDS(9) //this initializes the unity fog }; VertexOutput vert (VertexInput v) { VertexOutput o = (VertexOutput)0; o.uv0 = v.texcoord0; o.uv1 = v.texcoord1; o.normalDir = UnityObjectToWorldNormal(v.normal); o.tangentDir = normalize( mul( _Object2World, half4( v.tangent.xyz, 0.0 ) ).xyz ); o.bitangentDir = normalize(cross(o.normalDir, o.tangentDir) * v.tangent.w); o.posWorld = mul(_Object2World, v.vertex); o.pos = mul(UNITY_MATRIX_MVP, v.vertex); UNITY_TRANSFER_FOG(o,o.pos); TRANSFER_VERTEX_TO_FRAGMENT(o) return o; } float4 frag(VertexOutput i) : COLOR { //normal direction calculations i.normalDir = normalize(i.normalDir); float3x3 tangentTransform = float3x3( i.tangentDir, i.bitangentDir, i.normalDir); float3 normalMap = UnpackNormal(tex2D(_NormalMap,TRANSFORM_TEX(i.uv0, _NormalMap))); float3 normalDirection = normalize(mul(normalMap.rgb, tangentTransform)); //diffuse color calculations float3 mainTexColor = tex2D(_MainTex,TRANSFORM_TEX(i.uv0, _MainTex)); float3 diffuseColor = _Color.rgb * mainTexColor.rgb; //light calculations float3 lightDirection = normalize(lerp(_WorldSpaceLightPos0.xyz, _WorldSpaceLightPos0.xyz - i.posWorld.xyz,_WorldSpaceLightPos0.w)); float attenuation = LIGHT_ATTENUATION(i); float3 attenColor = attenuation * _LightColor0.rgb; float lightingModel = max(0, dot( normalDirection, lightDirection )); float4 finalDiffuse = float4(lightingModel * diffuseColor * attenColor,1); UNITY_APPLY_FOG(i.fogCoord, finalDiffuse); return finalDiffuse; } ENDCG } } FallBack "Legacy Shaders/Diffuse" } |
- 顶点程序
顶点程序只在每个发送给GPU的顶点上运行一次。顶点程序的目的在于将每个顶点的3D位置转换为其在屏幕上的2D坐标。通过将顶点转换到2D空间,我们可以准确地采样材质坐标,运动,光照和颜色。顶点着色器(shader)的输出会进入管线流程的下一阶段,而在unity着色器(shader)中多数情况下都是碎片程序。
- 碎片程序
碎片或像素程序,是为了物体在屏幕上占据的每一个像素而运行的程序。这就意味着碎片有时可以在其他碎片背后渲染,我们称之为透支(overdraw)。这一概念非常重要,之后我们会展开讲一下。
碎片程序从unity获得输入信息。进入碎片程序的数据都是来自多个顶点程序中的插入值。碎片程序利用此插入值来准确地取样材质贴图,并计算模型表面的信息。这一信息于颜色和光照相关。碎片程序的输出结果通常都是一个屏幕中渲染的单色参数,当然,可能出现更加复杂的输入和输出值。
- Unity中的着色器(shader)结构
在unity中,有特定的方式来组建你的着色器(shader)代码结构。
Shader "name" {} |
着色器(shader)命令包括含有着色器(shader)名称的字符串(string)。你可以使用正斜杠“/”来把你的着色器(shader)放在子菜单中,这样你在材质查看器(material inspector)中就能选择你的着色器(shader)。
Properties {} |
属性块包含着色器(shader)的变量(比如材质,颜色等),会作为材质的一部分保存,并且在Unity的材质查看器(material inspector)显示。属性块有非常特别的结构,并且很容易学会,看下面:
Properties { _Color ("Main Color", Color) = (1,1,1,1) _MainTex ("Base (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} } |
材质属性的结构如下:
Variable Name (Inspector Name, Variable Type) = Initial Value |
SubShader {} |
一个着色器(shader)可以包含一个或多个子着色器(subshader),主要用来根据不同的GPU性能来插入不同的着色器(shader)。
Tags {} |
标签(tag)在unity中非常重要。这里是要创建一个完整的标签(tag)列表,以及对于它们用途的详细描述,检查标签(tag)。
Pass {} |
每个子着色器(shader)都由数个渲染通道(pass)构成。每一个渲染通道(pass)都代表,着色器(shader)中所执行的顶点和碎片代码,渲染同一个物体上的材质。许多简单的着色器(shader)之使用一条渲染通道(pass),但和光线互动的着色器(shader)可能需要更多,基于unity的渲染模式而各不相同。(比如,forward和deferred等)
CGPROGRAM .. ENDCG |
这些关键词围绕着顶点和碎片程序中的Cg/HLSL代码不见。下面是上文中的代买结构在unity中的实例。
Shader "Simple_Diffuse" { Properties { _Color ("Main Color", Color) = (1,1,1,1) _MainTex ("Base (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } CGPROGRAM |
- Unity #pragma指令和包含文件
许多情况下分别具体着色器(shader)功能和需要包含的文件十分重要。要查看更多信息,可以点击Multi-Compile Directive, Include Files和Shader Targets。下面是一个#pragma指令实例,同时也有包含文件和多编译(multi-compile)指令。
Shader "Simple_Diffuse" { Properties { _Color ("Main Color", Color) = (1,1,1,1) _MainTex ("Base (RGB)", 2D) = "white" {} _NormalMap ("Normal Map", 2D) = "bump" {} } SubShader { Tags { "RenderType"="Opaque" "Queue"="Geometry" } Pass { Name "FORWARD" Tags { "LightMode"="ForwardBase" } CGPROGRAM #pragma vertex vert #pragma fragment frag #define UNITY_PASS_FORWARDBASE #include "UnityCG.cginc" #include "AutoLight.cginc" #include "Lighting.cginc" #pragma multi_compile_fwdbase_fullshadows #pragma multi_compile_fog #pragma target 3.0 |
关键点是:
#pragma vertex vert #pragma fragment frag |
这些指令极其重要。他们定义代表顶点和碎片程序的功能。下面我们会讨论一下,如何构建此着色器(shader)中,用到的顶点和碎片程序。
- 在Unity中编写顶点程序
在Unity中编写顶点程序的第一步就是熟悉unity中固有的变量。
- Unity中的固有变量/语义
Unity中定义了数个特定的变量,在你的顶点程序引用时会相当重要。看一下unity的变量,下面我会一一解释这些变量:
- POSITION - float4
此变量是顶点相对于mesh的位置。此变量上可实施操作,以定义世界空间位置,和更重要的,屏幕空间位置。
- SV_POSITION
此语义是顶点程序中更重要的一个输出变量之一。此变量定义顶点最终的“剪辑空间”(clip space)位置,这样一来GPU便能知道,屏幕上的哪个地方,以及以何种深度来渲染顶点。此变量永远都是float4,并且永远都需要通过顶点程序计算和输出。
- NORMAL – float3
此变量用来定义顶点的“法线方向”。
- TANGENT – float4
切线表示法线的一个垂直方向。这当然的不是任何随机的垂直方向,而是具体的固定方向。这通常用于计算表面上的双切线和切线空间法线。
- TEXCOORD0 – float2
此变量在unity中专门用于定义UV space 0。
- TEXCOORD1 – float2
此变量在unity中专门拥有定义UV space 1,通常用在光照贴图UV上。
- 自定义变量
为了补充unity的固有变量,你可以使用下面的语义描述来自定义变量。
- TEXCOORD#
除了TEXCOORD0和TEXCOORD1,unity允许你定义带有TEXCOORED#语义的变量。TEXCOORD语义用作表明随意高精度数据。
- COLOR#
和TEXCOORD类似,unity允许你定义带有COLOR#语义的变量。此语义用作表明低精度的0..1数据。
- 顶点程序结构
现在我们已经砍了一些变量和语义,现在来看看unity中顶点程序的结构。
struct VertexInput { float4 vertex : POSITION; //local vertex position float3 normal : NORMAL; //normal direction float4 tangent : TANGENT; //tangent direction float2 texcoord0 : TEXCOORD0; //uv coordinates float2 texcoord1 : TEXCOORD1; //lightmap uv coordinates }; |
上面的代码快中,我写了要给具体的结构,可以创建变量,并且可以在unity中填写合适的参数。这里通过适当的句法实现,unity会寻找并指派指代的参数。创建这一结构对于创建顶点程序来说十分重要。
struct VertexOutput { float4 pos : SV_POSITION; //screen clip space position and depth float2 uv0 : TEXCOORD0; //uv coordinates float2 uv1 : TEXCOORD1; //lightmap uv coordinates //below we create our own variables with the texcoord semantic. float4 posWorld : TEXCOORD3; //world position of the vertex float3 normalDir : TEXCOORD4; //normal direction float3 tangentDir : TEXCOORD5; //tangent direction float3 bitangentDir : TEXCOORD6; //bitangent direction LIGHTING_COORDS(7,8) //this initializes the unity lighting and shadow UNITY_FOG_COORDS(9) //this initializes the unity fog }; |
这就是会定义顶点程序的结构。用作创建我们后面需要引用的变量。前面的结构包含重要的信息,我们可以用来计算和设置这些参数,但前面的结构在顶点程序之后将不再被引用。
VertexOutput vert (VertexInput v) { VertexOutput o = (VertexOutput)0; o.uv0 = v.texcoord0; o.uv1 = v.texcoord1; o.normalDir = UnityObjectToWorldNormal(v.normal); o.tangentDir = normalize( mul( _Object2World, half4( v.tangent.xyz, 0.0 ) ).xyz ); o.bitangentDir = normalize(cross(o.normalDir, o.tangentDir) * v.tangent.w); o.posWorld = mul(_Object2World, v.vertex); o.pos = mul(UNITY_MATRIX_MVP, v.vertex); UNITY_TRANSFER_FOG(o,o.pos); TRANSFER_VERTEX_TO_FRAGMENT(o) return o; } |
这就是实际的顶点程序。我们来逐行的看,这样我们就能讨论每个要点,并描述其中的作用。
VertexOutput vert (VertexInput v) {
这里我们初始化程序,确保初始化时要带有unity的固有变量。
VertexOutput o = (VertexOutput)0;
这一行初始化了一个新的顶点输出结构。之后这个结构可能会在碎片程序中用到,所以之后我们会再次回到这里。
o.uv0 = v.texcoord0; o.uv1 = v.texcoord1; |
这里我们将UV坐标直接指派给我们的变量。
o.normalDir = UnityObjectToWorldNormal(v.normal);
这行代码将unity提供的法线方向(v.normal),从物体空间转化到世界空间。Unity处理这个法线参数时并没有算入物体的旋转,所以我们利用这一函数将发现转化到世界空间。
o.tangentDir = normalize( mul( _Object2World, half4( v.tangent.xyz, 0.0 ) ).xyz ); o.bitangentDir = normalize(cross(o.normalDir, o.tangentDir) * v.tangent.w); |
上面的第一行将unity提供的切线防线转译到世界空间。然后我们从法线和切线方向计算双切线方向。
o.posWorld = mul(_Object2World, v.vertex); o.pos = mul(UNITY_MATRIX_MVP, v.vertex); |
上面的代码计算顶点的世界位置,然后筛选顶点的位置。
UNITY_TRANSFER_FOG(o,o.pos); TRANSFER_VERTEX_TO_FRAGMENT(o) return o; } |
这些代码计算此顶点的雾气,光照和阴影。
合在一起,你就有了顶点程序的基础。
struct VertexInput { float4 vertex : POSITION; //local vertex position float3 normal : NORMAL; //normal direction float4 tangent : TANGENT; //tangent direction float2 texcoord0 : TEXCOORD0; //uv coordinates float2 texcoord1 : TEXCOORD1; //lightmap uv coordinates }; struct VertexOutput { float4 pos : SV_POSITION; //screen clip space position and depth float2 uv0 : TEXCOORD0; //uv coordinates float2 uv1 : TEXCOORD1; //lightmap uv coordinates //below we create our own variables with the texcoord semantic. float4 posWorld : TEXCOORD3; //world position of the vertex float3 normalDir : TEXCOORD4; //normal direction float3 tangentDir : TEXCOORD5; //tangent direction float3 bitangentDir : TEXCOORD6; //bitangent direction LIGHTING_COORDS(7,8) //this initializes the unity lighting and shadow UNITY_FOG_COORDS(9) //this initializes the unity fog }; VertexOutput vert (VertexInput v) { VertexOutput o = (VertexOutput)0; o.uv0 = v.texcoord0; o.uv1 = v.texcoord1; o.normalDir = UnityObjectToWorldNormal(v.normal); o.tangentDir = normalize( mul( _Object2World, half4( v.tangent.xyz, 0.0 ) ).xyz ); o.bitangentDir = normalize(cross(o.normalDir, o.tangentDir) * v.tangent.w); o.posWorld = mul(_Object2World, v.vertex); o.pos = mul(UNITY_MATRIX_MVP, v.vertex); UNITY_TRANSFER_FOG(o,o.pos); TRANSFER_VERTEX_TO_FRAGMENT(o) return o; } |
- 在unity中写碎片程序
在unity中写碎片程序是一项非常简单的任务。在顶点程序中执行一个输入,碎片程序就有了计算色彩输出所需的所有信息。
float4 frag(VertexOutput i) : COLOR { //normal direction calculations i.normalDir = normalize(i.normalDir); float3x3 tangentTransform = float3x3( i.tangentDir, i.bitangentDir, i.normalDir); float3 normalMap = UnpackNormal(tex2D(_NormalMap,TRANSFORM_TEX(i.uv0, _NormalMap))); float3 normalDirection = normalize(mul(normalMap.rgb, tangentTransform)); //diffuse color calculations float3 mainTexColor = tex2D(_MainTex,TRANSFORM_TEX(i.uv0, _MainTex)); float3 diffuseColor = _Color.rgb * mainTexColor.rgb; //light calculations float3 lightDirection = normalize(lerp(_WorldSpaceLightPos0.xyz, _WorldSpaceLightPos0.xyz - i.posWorld.xyz,_WorldSpaceLightPos0.w)); float attenuation = LIGHT_ATTENUATION(i); float3 attenColor = attenuation * _LightColor0.rgb; float lightingModel = max(0, dot( normalDirection, lightDirection )); float4 finalDiffuse = float4(lightingModel * diffuseColor * attenColor,1); UNITY_APPLY_FOG(i.fogCoord, finalDiffuse); return finalDiffuse; } |
这是unity中的碎片程序一个非常简单的例子。我们来一点一点地分解分析一下。
float4 frag(VertexOutput i) : COLOR {
这里我们利用顶点程序的输入将碎片程序初始化为float4。在有世界空间法线,切线和双切线的情况下,我们可以讲我们的法线转换到切线空间。这对于使用我们上面的法线贴图十分重要。
float3 normalMap = UnpackNormal(tex2D(_NormalMap,TRANSFORM_TEX(i.uv0, _NormalMap)));
这一行非常重要,所以我们尽可能的将其拆解。
UnpackNormal(tex2D)用作读取从法线贴图上读取法线方向。
Tex2D(材质变量,TRANSFORM)TEX())是来自unity的函数,允许你从材质贴图上读取一种颜色。
一起,上面的这行代码通过拆解UV上的材质法线数据,给了我们法线方向
float3 mainTexColor = tex2D(_MainTex,TRANSFORM_TEX(i.uv0, _MainTex)); float3 diffuseColor = _Color.rgb * mainTexColor.rgb; |
上面这两行十分简单。我们从主要材质上获取颜色,然后和我们的_Color属性相乘。这就允许我们通过使用材质来控制物体的颜色,同时依然是着色的颜色。
float3 lightDirection = normalize(lerp(_WorldSpaceLightPos0.xyz, _WorldSpaceLightPos0.xyz - i.posWorld.xyz,_WorldSpaceLightPos0.w)); float attenuation = LIGHT_ATTENUATION(i); float3 attenColor = attenuation * _LightColor0.rgb; |
第一行计算当前光线的方向。在后面的渲染中,碎片程序在每次光照,像素,物体中都会运行一次,为确保我们没有造成任何混乱,我们在世界空间中取样这些数据,我们就可以在像素上计算光照。这行包含在这里确定的unity变量。
第二行计算光线的衰减,使用unity自带的函数:AutoLight.cginc。衰减是光线在传输过程中造成的损失或出现的阴影。
第三行计算色彩的衰减,使用光线衰减乘以其颜色。
float lightingModel = max(0, dot( normalDirection, lightDirection )); |
这里是光线的点积和法线,也称作NdotL。在这个例子中,我们使用简单lambertain扩散光来计算像素上的光线。要想了解更多的光线模型信息,请访问光线模型。
float4 finalDiffuse = float4(lightingModel * diffuseColor * attenColor,1); UNITY_APPLY_FOG(i.fogCoord, finalDiffuse); return finalDiffuse; } |
最终扩散变量,我们利用一个非常简单的办法来计算,将光线加入到扩散色中。你可以简单地将像素色和像素上的光线参数相乘,在这个情况下就是NdotL,然后将其和衰减色相乘。这就和unity中的光照一样简单。Color * Light。
第二行用于场景雾。
第三行作为碎片程序的结束,把最终色返回给像素缓冲。
- 将着色器(shader)组合起来
在完成碎片程序猴,你还有几个步骤需要做,来完成你的着色器(shader)。特别是用下面的命令来结束:
ENDCG } |
然后结束子着色器(subshader),紧接着一个fallback,想下面这样:
} FallBack "Legacy Shaders/Diffuse" } |
这个fallback让unity知道在GPU不能运行着色器(shader)代码时该做什么。你应该在这里读一读fallback。不同的fallback有不同的插入方法,比如“Legacy Shaders/Transparent/Cutout/”fallback家族需要在自定义Transparent-Cutout 着色器(shaders)中激活阴影。
- 优化你的着色器(shader)
在unity3D中编写着色器时,随时记住着色器(shader)代码的运算消耗十分重要。通常来说,数学指令在CPU上的消耗并不太大。当运行顶点程序时,你需要记住每一帧运行的程序的绝对体积。记住这一点,在优化着色器(shader)时就有了数个重要的要点,以避免运行时出现的影响。
- 透支
透支是你在框架缓冲中浪费了像素时会出现的情况。这些像素由于被其他不透明的像素遮盖,所以成了无用像素,而且也没有混合在一起。这就意味着,虽然你只在渲染后的像素上看到最终的碎片程序,但在这下面你可能还运行着上千无用的像素。但要记住,unity会去帮你处理这些,这很关键,利用“Z-Buffer”。Z-Buffer在缓冲中测试和分类物体的像素,这样一来unity便会只渲染合适的像素。
这通常就能解决问题,但引入透明度会破坏Z-Buffer的作用,因为透明度会混合像素。透明物体越多,透支的可能性就越高。这也就是为什么优化你的着色器(shader)代码至关重要,这样一来就能让透支的消耗尽可能地低。
- 数学函数
特定的函数在着色器(shader)中消耗可能相当高。考虑到数学的复杂性,并且记住没帧计算将会运行的次数(有可能上百万次)。这里是一些你需要避免使用的函数,除非绝对必要,请避免使用。
sqrt();
cos();
sin();
Loops
for();
while();
这些函数和循环会破坏绝大多数的实时着色器(shader),除了一些非常特别的,可控的情况。
- 总结
我希望看了这篇帖子后能有一些收获,然后可以上手开始玩unity中的着色器(shader)。总地来说,指派你自己的着色器(shader)相比改造已有的着色器(shader)来说会是一个更好的解决办法。
原帖地址:
https://www.jordanstevenstechart.com/writing-shaders-in-unity
暂无关于此日志的评论。