解答UnityShader学习过程中的一些疑惑(持续更新中)

一、坐标系相关

shader中会有几种空间:
模型空间:有建模决定的,一般情况下以物体自己为中心原点,单位是unity的坐标单位
世界空间:就是unity的世界坐标
观察空间(视图空间):以相机为中心的坐标系,依然是以unity坐标为单位。其实这个过程仅仅是相当于将坐标系原点转移至摄像机上,摄像机的前面z为正
裁剪空间:是一个4d空间,有x,y,z,w分量。此时还没有做透视除法,这时x,y,z的坐标范围,是在(-w,-w,-w)到(w,w,w)之间,这是一个立方体,由于不同顶点的w值会不一样,所以相当于每个顶点都会产生一个立方体,并且边长不一致。不在这个范围内的坐标会被裁剪。远处的物体的w值更大,靠近摄像机的w值更小.
NDC空间(归一化设备空间):这个是一个三维空间,有x,y,z分量。在对裁剪空间的x,y,z分别除以w,得到一个[-1,1]之间的坐标范围,这个归一化坐标就是NDC坐标

为什么要有这个w值呢?我们需要实现透视效果,即近大远小,实现的逻辑是将x,y,z分别除以w,在得到NDC空间
因为靠近相机的坐标w值更小,除以w后更大,坐标之间的差距越大,绘制出来的图像看起来越大;
远离摄像机的坐标w值更大,除以w后更小,坐标之间的差距越小,也就是汇聚于一点,这就使得物体看起来近大远小

Unity中的MVP矩阵转换顶点是这样的:ClipPos=P×V×M×pos,我们前面知道,坐标转换都是先将模型空间转换至世界空间(M),再转换至视图控件(V),最后才是裁剪空间(P)。而Unity这里计算却没有加括号,是因为矩阵是满足结合律的,M1×(M2×M3)=(M1×M2)×M3。
由于矩阵与向量的计算都是矩阵放在乘号左边,向量在右边(即列向量),所以为了方便直观的理解,矩阵都是从右往左乘的
顺便说一个平移缩放缩放的矩阵:pos’=T×R×S×pos,可以看到这里是先缩放、再旋转、再平移的。当然可以前面3个矩阵先按左往右顺序相乘,最后乘坐标。只要乘法时左右矩阵不要颠倒就行

二、坐标系在shader中的变化

在顶点着色器vert中,使用的是模型空间,在传入片元着色器之前,必须要先将这个模型坐标转换成裁剪空间下的坐标
这个转换过程是必须的,那么还要自己写转换,而不是shader自动做转换呢?因为我们可能会实现一些自定义效果,比如水面的波纹等效果

总结下坐标空间的通常用法如下:
模型空间:进入顶点着色器时传入的顶点坐标
世界空间:光照的坐标、光照的法线
裁剪空间:进入片元着色器时传入的坐标

三、unity shader语法

1.Shader代码结构

写shader时,先要有

Shader "NewMyShader"
{
}

上面这一段,表示的是声明一个shader文件,然后

Shader "NewMyShader"
{
	Properties
	{
		_Color("Color", Color) = (1, 1, 1, 1)
	}
}

上面这一段,表示我要声明一个可以在unity中编辑的变量,名字叫"Color",他的类型是Color,他在shader中的名字是_Color
之后

Shader "NewMyShader"
{
	Properties
	{
		_Color("Color", Color) = (1, 1, 1, 1)
	}
	SubShader
	{
		Pass
		{
			
		}
	}
}

上面这一段,SubShader表示对某个显卡的支持,可以写多个SubShader,显卡会选择一个能用的执行
Pass表示一次渲染流程,一个SubShader可以有多个Pass,他们会进行渲染叠加

Shader "NewMyShader"
{
	Properties
	{
		_Color("Color", Color) = (1, 1, 1, 1)
	}
	SubShader
	{
		Pass
		{
			CGPROGRAM
			#pragma vertex vert
			#pragma fragment frag
			ENDCG
		}
	}
}

上面这一段,CGPROGRAM和ENDCG是一对的,表示使用HLSL,HLSL是Direct3D的语言(unity默认使用Direct3D),openGL使用的语言是GLSL
#pragma表示预编译指令,这里他的意思是告诉GPU说使用vert函数作为顶点着色器,frag函数作为片元着色器,并非定义一个函数。这个预编译指令是必不可少的!如果没有则会找不到怎么渲染,导致渲染错误出现粉色
注意:#pragma和函数定义,是写在CGPROGAM里的

2.语法

strcut appdata
{
	float4 pos : POSITION;
	float2 uv : TEXTOORD0;
}
float4 vert(appdata data) : POSITION
{
	return UnityObjectToClipPos(data.pos);
}

float4 frag(v2f v) : SV_Target
{
	return float4(0, 0, 1, 1);
}

这一段是vert函数将顶点坐标转为裁剪坐标,其中vert的参数,可以自己定义需要哪些参数,参数写少了也不会报错。这里我要的是appdata类型的,他是一个struct
变量后面的 : POSITION表示的是语义,说明这个pos表示的是一个坐标
vert后面的 :POSITION也是,因为这里只返回了float4,所以需要给float4加上语义,如果返回一个struct,则不用添加语义
我们都知道顶点着色器的核心职责就是将模型坐标转换为裁剪坐标,所以这里vert只有一行代码就是转为裁剪坐标然后返回给frag
frag的返回值是一个渲染目标,一般都是float4,一个颜色

shader中一些常用的全局变量和函数
UNITY_LIGHTMODEL_AMBIENT 环境光,可以直接用不需要引入
_LightColor0 光照颜色,需要引入#include “Lighting.cginc”
_WorldSpaceLightPos0 光照方向 可以直接用
_WorldSpaceCameraPos 相机坐标 可以直接用
UnityObjectToWorldNormal(normal) 将顶点的法线从对象空间转为世界空间

3.vert函数

vert是顶点着色器,他的职责就是要将顶点的模型坐标转为裁剪坐标。
vert函数可能会被调用多次,比如说cube虽然只有8个顶点,但是他有6个面,每个面4个顶点,所以最多是6*4=24次调用。因为虽然一个顶点会被3个面共享,但是这3个面的法线等参数都不一样,所以可能会被调用。不过不同的设备会进行优化,所以可能不会有24次顶点着色器的调用

4.frag函数

frag函数的传入参数,是由vert返回的,他的职责就是纹理采样,并且计算像素颜色。但是最终的渲染操作不是由frag执行的,而是后面的逐像素操作。
frag函数的返回值,一般都是一个渲染目标SV_Target,就是一个color
疑问:为什么经过frag函数计算后的颜色,呈现出来的效果是渐变颜色?因为在片元着色器的前一阶段:三角形遍历中,会将片元的深度做插值运算,这会导致最终的颜色是渐变的

5.shader内置数学函数

①step(num, x)
功能:当x<num时,返回0;x>=num时,返回1
用法:常用来做比较
②smoothstep(num1, num2, x)
功能:当x<=num1时,返回0;当x>=num2时,返回1,其他情况返回0~1的过渡
用法:平滑过渡,还可以生成t值
③lerp(num1,num2,t)
功能:传入一个0-1的t值,返回在num1和num2之间的插值
用法:过渡
④mix(float4 num1, float4 num2, t)
功能:传入一个0-1的t值,返回在num1和num2之间的插值(注意这个可以float4类型)
用法:颜色过渡

四、各种语义对应什么

POSITION:模型坐标
SV_POSITION:裁剪空间下的裁剪坐标
SV_TARGET:渲染目标

五、Shader计算过程

1.float之间的乘法,遵循分量相乘,比如float4 f = (1,1,1,1) float4 f2 = (2,2,2,2) 结果是(12, 12, 12, 12)。不同阶之间不可计算,float4*float3会报错!

2.shader的unity_WorldToObject和unity_ObjectToWorld互为逆矩阵,他们相乘是单位矩阵

3.mul函数 这个函数有很多坑!
首先要知道,任何情况下mul函数的返回值都是一个列向量
mul函数他的官方提示是:float4 mul(float4x4 M, float4 v),也就是说正常情况下是矩阵在第一个参数,向量放在第二个参数。但是我们老是看到有一种写法是向量在左,矩阵在右,但是他们的结果其实确是完全相等的!这与数学中的矩阵相乘规则都不符!
其实mul函数不是单纯的数学中的两个矩阵相乘,而是做了很多处理。
当矩阵在左,向量在右时,是标准的数学的矩阵相乘,即向量为列向量;
而矩阵在右,向量在左时,出现问题了,如果是列向量,那么向量与矩阵根本没法相乘。所以这时向量被自动转为了行向量,但是这时再与矩阵相乘,就没法变为列向量了,只是行向量。所以这时mul函数做了处理,将右矩阵自动转为转置矩阵,此时再进行计算,结果才是相等的。
结论:两者计算结果通常都是一致的,但是我们都是将矩阵放在左边,向量放在右边减少不必要的计算

4.做纹理采样时,他的计算是所有光照计算完成后再乘上纹理颜色,比如

float3 color = ((diffColor + heightLightColor) * lightColor + ambient) * texColor; //计算的所有光照与纹理相乘

六、渲染相关

0.渲染流水线的顺序
我们都知道一个物体的渲染是按照顺序依次执行的。但是其实不同物体之间的渲染是并行执行的。
也就是说可能出现物体A还在执行顶点着色器,而物体B已经执行到了片元着色器。物体B并不会等物体A执行完后再一起执行下一步,而是直接往下执行的。

1.shader的环境光照:shader中的环境光照UNITY_LIGHTMODEL_AMBIENT.rgb,并非物体多次反射后的光照,而是一个全局的固定颜色的光照,任何角度都是这个光照,主要是为了保证性能

2.关于颜色的相乘和相加:
颜色相乘表示混合、过滤,并不会改变光照强度,比如红色×蓝色=紫色,绿×红=黄
颜色相加表示叠加,会改变光照强度,越叠加越接近白色(1,1,1,1)
同个颜色相乘表示提高对比度,让暗的地方更暗,亮的地方更亮。比如:0.20.2=0.04(更暗了),0.90.9=0.81(相对较亮)。所以我们经常会看见高光时pow(saturate(dot(-ref, viewDir)), _qiangdu),这个_qiangdu就是相当于对比度强度,会让光线更集中。原先的高亮是一大片区域,增加_qiangdu的参数,就可以让光线集中到一个点上。

3.法线纹理(法线贴图)

  1. 在一些情况下,我们需要存储一个模型的全部法线信息以便纹理采样,那么我们可以将这些法线信息存储到一张图片中,这张图片就是模型的法线纹理。里面的像素的rgb信息,就是法线的映射。
  2. 但是我们知道,法线方向的范围是[-1, 1],而rgb的范围是[0, 1],我们需要建立一种映射关系,可以使用公式:pixel=(normal+1)/2 这样就可以将法线映射到rgb上了,反过来,通过rgb,也就可以得到法线。
  3. 法线贴图有两种,一种是在模型空间下的法线纹理,一种是在切线空间下的法线纹理。我们先说什么是切线空间,切线空间就是以模型顶点为原点,法线方向就是z轴方向,x是切线方向,y是垂直于法线和切线的,如图1
    在这里插入图片描述
    [图1]
  4. 我们再接着说两个空间有什么区别,在模型空间下的法线纹理,他们都是同一个空间,所以法线方向会有各种各样的坐标,比如(0, 0.6, 1),他映射到法线纹理,rgb变成了(0.5, 0.8, 1),是浅蓝色。所以这样会导致法线纹理是五颜六色的,如图2
    在这里插入图片描述
    [图2]
  5. 我们再看切线空间下的法线纹理:
    原点是模型顶点
    z轴是模型的原始法线方向
    x轴是原始法线的切线方向
    y轴是x轴和z轴做叉乘得到的
    他在切线空间下的坐标,表示的是这个法线在顶点的原始法线的偏移方向。也就是说他是一个相对位置。越接近z轴,说明这个法线与原始法线越靠近,如果是(0,0,z),则是完全重合
    假如法线在切线空间下的位置是(0.2, 0.1, 0.8),那么转换成rgb就是(0.6, 0.5, 0.9),对应颜色是蓝紫色。因为z轴方向是顶点的原始法线方向,而rgb中(0,0,1)是纯蓝色,所以切线空间下的法线纹理始终是偏蓝色,只不过蓝色的深浅不一样,如图3
    在这里插入图片描述
    [图3]
  6. 问:我们知道法线纹理的uv坐标对应的就是模型顶点的坐标,但是uv是二维坐标,而顶点是三维坐标,他们是怎么对应上的?
    答:可以通过uv展开的方法来将三维坐标映射到二维坐标上,在vert函数中会一起传入顶点对应的uv坐标。
    问:模型空间下的法线贴图与切线空间下的法线贴图有什么最大区别?
    答:我们知道切线空间是以顶点的原始法线方向建立的,在这个坐标系中的向量,不跟任何模型关联,只是一个相对与原始的位置,所以这一张贴图可以用在任何模型上;而模型空间下的法线贴图,是一个绝对位置,每一个颜色对应的都是这个模型的固定法线,所以只能应用在这个模型上,切换模型则会出现光照错误。
  7. 阴影的生成
    阴影的生成简单分为2步:将顶点坐标转换至光源空间中,生成深度图->对比深度图,根据对比结果生成阴影
    生成深度图:
     我们简单的理解为假如光源没法直接射到物体的某个点上,那么这个点就是被遮挡了。于是我们将视野变成从光源的位置看,视野方向就是光线的方向。
     当然这个过程并不是真的移动相机,而是通过一串计算,将物体的顶点从模型空间转换至到光源空间下。这个过程最终生成的就是一张从光源方向看,每个片元距离光源的一张深度图。
    生成阴影:
     既然我们已经得到了深度图,那么就可以将这个片元与深度图中对应位置的片元作比较,如果该片元深度大于深度图的值,那么就说明该片元被遮挡,否则就是没被遮挡。
    整个阴影计算的过程中,其实是没有场景相机的事的,场景相机不参与阴影的计算,所以无论相机怎么移动,那个要用来比较的片元是不会变的

4.物体渲染顺序
先说结论:Unity的渲染顺序为不透明的物体按照从近到远渲染;透明物体按照从远到近渲染。
如果不透明的物体按照从远到近渲染,那么远处的一定先渲染,这样会导致近的物体覆盖掉它,但是还要再渲染一次,会有性能消耗
对于透明物体,首先是要先关闭深度写入也就是ZWrite off,这样可以保证后面的物体一定可以渲染。但是这样是有问题的,假如多个透明物体前后重叠时,会导致后面的半透明物体挡住前面的半透明物体。所以半透明的物体需要按照从远到近渲染

假如不关闭深度写入可不可以呢?
由于深度测试发生在逐片元操作时,也就是渲染的前一步,深度测试通过后才会进行Blend混合。
那么深度测试时片元是不认识自己和深度缓存的数据是不是透明物体的,测试时发现深度缓存已经被写入了,并且深度值小于这个不透明物体,那么不透明物体就会认为自己还是被挡住,最后不渲染。如果关闭透明物体的深度写入,这时不透明物体的深度测试就可以通过了。

5.半透明物体的坑
设置半透明物体时,出现过一个问题,就是这个半透明物体背后是不透明物体时,半透明物体会莫名其妙的闪烁!一会显示一会消失不见。经过代码一段一段排查后,发现写在Pass中的渲染设置有问题

Pass
{
	//Queue、RenderType不能写在Pass下面!!
    Tags
    {
        "Queue" = "Transparent"
        "RenderType" = "Transparent"
        "IgnoreProjector" = "True"
        "LightMode" = "ForwardBase"
    }
}

将这段Tag的Queue、RenderType、IgnoreProjector写在SubShader下面就可以了


SubShader
{
    // 设置渲染标签
    Tags
     {
         "Queue" = "Transparent"
         "RenderType" = "Transparent"
         "IgnoreProjector" = "True"
     }

     Pass
     {
         Tags
         {
             "LightMode" = "ForwardBase"
         }
      }
}

这样就不会出现半透明物体闪烁的情况了,但是目前还不知道是为什么。

6.渲染队列
渲染队列中,数值小的先渲染,大的后渲染 所以很多代码像下面的这段写法,-1就是为了让他优先渲染,防止队列重叠时出现渲染错误,会导致物体来回闪烁消失

Tags { "Queue" = "Geometry-1" "RenderType" = "Opaque" }

七、数学计算

1.投影

  shader计算中经常会需要计算投影,投影可以基于点乘
  a·b=|a||b|cos(θ) 假如此时a是单位向量,那么就变成了
  a·b=|b|cos(θ) 这就是b在a方向上的投影长度,可得b在a上的投影向量就是a(a·b)
  注意:只有在a是单位向量的情况下才能算是投影,否则只是投影长度乘了a倍

  a·b和b·a数值是完全一致的,任意调换位置不会影响长度,但是他们表达的意思其实不一样
  a·b表示:a在b方向上的投影×|b|;b·a表示:b在a方向上的投影×|a|

  如果a和b都不是单位向量怎么办?假如是a在b上的投影,那么可以将b转为单位向量
  dot(a,normalize(b)),这样就可以得出a在b方向上的投影长度
  简单的技巧:想投影到哪个向量上,就把哪个向量归一化

2. 矩阵计算
矩阵相乘的条件:左矩阵的列数=右矩阵的行数
相乘后的矩阵:行数=左矩阵行数,列数=右矩阵列数

其实也就是说:
列向量计算时,只能放在右边,乘完还是列向量
行向量计算时,只能放在左边,乘完还是行向量

3.如何简单理解矩阵
我们可以将一个矩阵理解为空间变换,这个矩阵本身表示的就是变换,并且这个变换是对于坐标系的变换。举个例子
现在有一个二维坐标系是标准坐标系,即x轴为(1,0),y轴为(0,1)。这时出现了一个矩阵M在这里插入图片描述这个矩阵M可以表示成原先的标准坐标系,变成了x方向为(1,-1),y方向为(1,1)的新坐标系,如图4
在这里插入图片描述
[图4]
经过了这个矩阵M变换后,相当于把原先的整个坐标系顺时针旋转了45度,得到了一个新的坐标系,后续的向量操作都在这个新的坐标系基础上进行计算的,我们可以带入一个数据看看:
原先的标准坐标系下,有一个向量v(1,1),它是x和y的对角线,这时经过矩阵M的变换后,坐标系被顺时针旋转了45度,同时向量v也被旋转了45度变成了(2,0),因为矩阵变换的是空间,在这个空间下的所有向量都会变换。这个过程就是向量通过矩阵变换得到了新的向量。
上面的向量的变换中有一个细节注意到没有?
原先的向量(1,1)模长是根号2,而经过矩阵变换后为(2,0),模长却变成了2,向量v被拉长了根号2倍!
其实在空间变换的时候,坐标轴就已经变换了长度,原先的y轴(0,1),变成了(1,1) 这个过程y轴被拉长了根号2倍,所以才使得空间中的所有向量都被拉长了根号2倍。

八、易踩坑点

  1. shader中涉及到的方向,基本都是默认单位向量,很多书和文章中都不会明确告知,但是他是默认的,比如法线方向、光照方向、视角方向,因为这些都涉及到公式计算,但是某些手动算出来的方向,是要看情况的,比如a-b,得到的向量得看情况要不要归一化
  2. Unity的WorldSpaceLightPos0,这个平行光的方向是从顶点指向光源的方向。注意不要搞反了! shader中的reflect(i, n),这里的i应该传入入射方向,也就是光源指向顶点的方向,而不是直接传光照方向,一般用法都是
    //反射方向
    fixed3 reflectDir = normalize(reflect(-worldLightDir, worldNormal));
    注意是-worldLightDir,如果哪次计算出来发现光照反了,那就是这里传错了

九、C#与Shader

  1. C#中material和sharedMaterial的区别
    每次调用material时,会创建一个新的mat实例,不会影响原先材质的设置,这样是为了防止误修改源mat文件
    sharedMaterial会修改原始mat文件,修改他会影响所有引用了原始mat文件的物体
    当有3个物体,都挂了一个材质球叫做mat
    这时修改某一个物体的material.color,只有这个物体会被修改颜色
    修改某个物体的sharedMaterial.color,所有物体都会被修改颜色
    一般情况下都是用material,这样可以保护源文件,少数情况下才用sharedMaterial
    重点注意:使用material时,必须要手动销毁material,即使这个物体已经被Destroy掉了,也不会自动销毁新实例化出来的material!!
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值