Unity Shader入门笔记

学习资料: https://onevcat.com/2013/07/shader-tutorial-1/ 猫都能学会的Unity3D Shader入门指南(一)(二)

学习资料:http://98jy.net/article/24Unity shader教程-第一课~第六课

Shader概述

1. Shader(着色器):实际上就是一小段给GPU执行的程序。它负责 将输入的Mesh(网格)以指定的方式和输入的贴图或者颜色等组合作用,然后输出
绘图单元可以依据这个输出来将图像绘制到屏幕上。输入的贴图或者颜色等,加上对应的Shader,以及对Shader的特定的参数设置,将这些内容(Shader及输入参数)打包存储在一起,得到的就是一个Material(材质)。之后,我们便可以将材质赋予合适的renderer(渲染器)来进行渲染(输出)了。
即:Shader是 一段规定好输入(颜色,贴图等)和输出(渲染器能够读懂的点和颜色的对应关系)的程序。 将纹理、网格信息输入,得到材质的一段程序。 而Shader开发者要做的就是根据输入,进行计算变换,产生输出而已。
Shader大体上可以分为两类,简单来说
  • 表面着色器(Surface Shader) - 为你做了大部分的工作,只需要简单的技巧即可实现很多不错的效果。类比卡片机,上手以后不太需要很多努力就能拍出不错的效果。
  • 片段着色器(Fragment Shader) - 可以做的事情更多,但是也比较难写。使用片段着色器的主要目的是可以在比较低的层级上进行更复杂(或者针对目标设备更高效)的开发。
2.着色器是运行在图形处理单元上,可以让开发人员直接操作图形硬件渲染功能
3.shader能开发出很多好的效果,UV动画、水、雾等一些特效,这些用程序开发出来比较困难,性能还不好
4.渲染流水线、模型投影、顶点着色
5.shader一般主要有:固定管线着色器、 顶点片元着色器表面着色器
固定管线着色器(慢慢会被淘汰)
顶点shader:干预模型形态的shader
像素shader:干预像素着色的shader
6.模型顶点运算的时候,可以加入顶点shader来干预顶点的位置,
顶点着色的时候,加入像素shader来干预像素的上色。

GPU编程语言

1.什么是Direct3D和OpenGL。图形接口标准,微软和Linux 安卓 IOS
2.目前面向GPU的编程语言主要有三种:
HLSL语言:通过Direct3D编写的着色器程序,只能在Direct3D里面使用
Cg语言: Nvidia和微软合作提供的语言,与C相似,Direct3D和opengl都支持
GLSL语言:支持OpenGL上编写shader的程序
3.Unity使用ShaderLab来进行着色器程序的编写,对不同的平台进行编译,重点支持Cg语言。

Shader程序的基本结构


1.首先是一些 属性定义 ,用来 指定这段代码将有哪些输入
2.接下来是一个或者多个的子着色器,在实际运行中,哪一个子着色器被使用是由运行的平台所决定的。子着色器是代码的主体,每一个子着色器中包含一个或者多个的Pass。在计算着色时,平台先选择最优先可以使用的着色器,然后依次运行其中的Pass,然后得到输出的结果。
3.最后指定一个回滚,用来处理所有Subshader都不能运行的情况(比如目标设备实在太老,所有Subshader中都有其不支持的特性)。
4.在实际进行 表面着色器的开发时,我们将直接在Subshader这个层次上写代码 ,系统将把我们的代码编译成若干个合适的Pass。

Shader Lab语法基础

1.定义一个Shader,每一个着色器程序都要有一个Shader;
Shader "name" { //name shader名字,可带层级
/ /定义的一些属性,定义在这里的会在属性查看器里面显示;可以在编辑器里面bind和修改
[Properties]
//子着色器列表,一个Shader必须至少有一个子着色器;
Subshaders: {....}
//如果子着色器显卡不支持,就会降级,即Fallback操作;
[Fallback]
}

Properties属性定义

Properties{} 中定义着色器属性,在这里定义的属性将被 作为输入 提供给所有的子着色器。每一条属性的定义的语法是这样的:
_Name("Display Name", type) = defaultValue[{options}]
  • _Name - 属性的名字,简单说就是变量名,在之后整个Shader代码中将使用这个名字来获取该属性的内容,Unity中用下划线开始_Name;
  • Display Name - 这个字符串将显示在Unity的材质编辑器中作为Shader的使用者可读的内容,在属性检查器的名字;
  • type - 这个属性的类型,可能的type所表示的内容有以下几种:
  • Color - 一种颜色,由RGBA(红绿蓝和透明度)四个量来定义,取值是0到1
  • 2D -2D纹理属性;一张2的阶数大小(256,512之类)的贴图。这张贴图将在采样后被转为对应基于模型UV的每个像素的颜色,最终被显示出来;
  • Rect - 矩形纹理属性;一个非2阶数大小的贴图;
  • Cube - 即Cube map texture(立方体纹理),简单说就是6张有联系的2D贴图的组合,主要用来做反射效果(比如天空盒和动态反射),也会被转换为对应点的采样
  • Range(min, max) - 一个介于最小值和最大值之间的浮点数,一般用来当作调整Shader某些特性的参数(比如透明度渲染的截止值可以是从0至1的值等);
  • Float - 任意一个浮点数(浮点型或小数型)
  • Vector - 一个四维数(4维向量)
  • defaultValue 定义了这个属性的默认值,通过输入一个符合格式的默认值来指定对应属性的初始值(某些效果可能需要某些特定的参数值来达到需要的效果,虽然这些值可以在之后再进行调整,但是如果默认就指定为想要的值的话就省去了一个个调整的时间,方便很多)。
  • Color - 以0~1定义的rgba颜色,比如(1,1,1,1);
  • 2D/Rect/Cube - 对于贴图来说,默认值可以为一个代表默认tint颜色的字符串,可以是空字符串或者”white”,”black”,”gray”,”bump”中的一个
  • Float,Range - 某个指定的浮点数
  • Vector - 一个4维数,写为 (x,y,z,w)
  • {option},纹理属性选项(用来描述上面的2D\3D\Rect\Cube这些纹理属性的),在写输入时我们最少要在贴图之后写一对什么都不含的空白的{},当我们需要打开特定选项时可以把其写在这对花括号内。如果需要同时打开多个选项,可以使用空白分隔。可能的选择有ObjectLinear, EyeLinear, SphereMap, CubeReflect, CubeNormal中的一个,这些都是OpenGL中TexGen的模式,具体的留到后面再说。
  • TexGenOpenGL中纹理生成模式,纹理自动生成纹理坐标的模式;顶点shader将会忽略这个选项;
LightmapMod:光照贴图模式,如果设置这个选项,纹理会被渲染器的光线贴图所影响。



Properties属性定义的范例


1._Range("range value",Range(0,1)) = 0.3; //定义一个范围
2._Color("color",Color) = (1,1,1,1); //定义一个颜色
3._FloatValue("float value",Float) = 1 //定义一个浮点
4._MainTex("Albedo",Cube) = "skybox"{TexGen CubeReflect} //定义一个立方贴图纹理属性。

SubShaer

1. SubShader { [Tags],[CommonState],Pass{} }
子着色器有标签(Tags),通用状态(CommonState),通道(Pass)列表组成,它定义了一个渲染通道列表,并可选为所有通道初始化需要的通用状态。
2.SubShader渲染的时候,将优先渲染一个被每个通道所定义的对象。
3.通道的类型:RagularPass,UsePass,GrabPass
4.在通道中定义状态同时对整个子着色器可见,那么所有的通道可以共享状态。
5.其实SubShader对应了UE4中的材质表达式。简单来说就是将图片通过处理得到另一张图片(比如用图片+UV信息 -> 纹理贴图的过程)。 SubShader其实就是定义了图片处理过程。

SubShader范例:

SubShader{
Tags{"Queue","Transparent"} //标签{"标签的类型为队列标签",“标签的值为透明”}
Pass{
Lighting Off //关闭光照
....
}
}

Tags

表面着色器可以被若干的标签(tags)所修饰,而硬件将通过判定这些标签来决定什么时候调用该着色器。比如我们的例子中SubShader的第一句
Tags { "RenderType"="Opaque" }
告诉了系统应该 在渲染非透明物体时调用 我们。Unity定义了一些列这样的渲染过程,与RenderType是Opaque相对应的显而易见的是 " RenderType" = "Transparent " ,表示 渲染含有透明效果的物体时调用
在这里Tags其实暗示了你的Shader输出的是什么,如果输出中都是非透明物体,那写在Opaque里;如果想渲染透明或者半透明的像素,那应该写在Transparent中。
另外比较有用的标签还有 "IgnoreProjector"="True" (不被 Projectors 影响), "ForceNoShadowCasting"="True" (从不产生阴影)以及 " Queue"="xxx" (指定渲染顺序队列)。
着重说一下的是Queue这个标签,如果你使用Unity做过一些透明和不透明物体的混合的话,很可能已遇到过不透明物体无法呈现在透明物体之后的情况。这种情况很可能是由于Shader的渲染顺序不正确导致的。Queue指定了物体的渲染顺序,预定义的Queue有:
  • Background - 最早被调用的渲染,用来渲染天空盒或者背景
  • Geometry - 这是默认值,用来渲染非透明物体(普通情况下,场景中的绝大多数物体应该是非透明的)
  • AlphaTest - 用来渲染经过Alpha Test的像素,单独为AlphaTest设定一个Queue是出于对效率的考虑
  • Transparent - 以从后往前的顺序渲染透明物体
  • Overlay - 用来渲染叠加的效果,是渲染的最后阶段(比如镜头光晕等特效)
这些预定义的值本质上是一组定义整数,Background = 1000, Geometry = 2000, AlphaTest = 2450, Transparent = 3000,最后Overlay = 4000。在我们实际设置Queue值时,不仅能使用上面的几个预定义值,我们也可以指定自己的Queue值,写成类似这样: "Queue"="Transparent+100" ,表示一个在Transparent之后100的Queue上进行调用。通过调整Queue值,我们可以确保某些物体一定在另一些物体之前或者之后渲染,这个技巧有时候很有用处。
/*Tags规定了混合模型 。什么是混合模型?请看下面的附图1,当然,混合模型是UE4中的概念。

1. Tags{"标签1" = “value1” "key2" = "value2"}
2.标签的类型:
Queue tag 队列标签
RenderType tag 渲染类型标签
DisableBatching tag 禁用批处理标签
ForceNoShadowCasting Tag 强制不投影标签
IgnoreProjecttor 忽略投影标签
CanUseSpriteAtlas Tag 使用精灵图集标签
PreviewType Tag 预览类型标签

LOD

LOD是Level of Detail的缩写,在这里例子里我们指定了其为200(其实这是Unity的内建Diffuse着色器的设定值)。这个数值决定了我们能用什么样的Shader。在Unity的Quality Settings中我们可以设定允许的最大LOD,当设定的LOD小于SubShader所指定的LOD时,这个SubShader将不可用。Unity内建Shader定义了一组LOD的数值,我们在实现自己的Shader的时候可以将其作为参考来设定自己的LOD数值,这样在之后调整根据设备图形性能来调整画质时可以进行比较精确的控制。 LOD表示细节呈现级别(也即是画质,即粗糙/细腻程度) 当机器很差的时候,差到其评估值小于200时,本材质无效(也就是本shader罢工)。当机器的性能不错,大于200时,本shader继续工作。
  • VertexLit及其系列 = 100
  • Decal, Reflective VertexLit = 150
  • Diffuse = 200
  • Diffuse Detail, Reflective Bumped Unlit, Reflective Bumped VertexLit = 250
  • Bumped, Specular = 300
  • Bumped Specular = 400
  • Parallax = 500
  • Parallax Specular = 600

Shader本体

最主要的部分,也就是将输入转变为输出的代码部分。
CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; struct Input { float2 uv_MainTex; }; void surf (Input IN, inout SurfaceOutput o) { //void 表示空类型,它跟int,float是同地位的,一般用在没有返回值的函数中,比如你写void main (),主函数完了不用写return 语句
/*核心的处理函数:surf,输入一张二维浮点信息,也即是上面的uv_MainTex, 输出一个o表示材质(inout像c++里面的按照引入传入,虽说没有返回,但是也有信息传出的效果)*/
half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG
逐行来看, CGPROGRAM 是一个开始标记,表明从这里开始是一段CG程序(在写Unity的Shader时用的是Cg/HLSL语言)。 ENDCG 表明CG程序到此结束。
接下来是是一个编译指令: #pragma surface surf Lambert ,它声明了我们要写一个表面Shader,并指定了光照模型。它的写法是这样的
#pragma surface surfaceFunction lightModel [optionalparams] #编译指令pragma+着色器类型(vertex\fragment\surface)+用的方法函数的名称+光照模型
  • surface - 声明的是一个表面着色器
  • surfaceFunction - 着色器代码的方法的名字
  • lightModel - 使用的光照模型。
所以在我们的例子中,我们声明了一个表面着色器,实际的代码在surf函数中(在下面能找到该函数),使用Lambert(也就是普通的diffuse)作为光照模型。
接下来一句 sampler2D _MainTex; ,sampler2D是个啥?其实在CG中, sampler2D 就是和texture所绑定的一个 数据容器接口 。等等..这个说法还是太复杂了,简单理解的话,所谓加载以后的texture(贴图)说白了不过 是一块内存存储 的,使用了RGB(也许还有A)通道,且每个通道8bits的数据。而具体地想知道像素与坐标的对应关系,以及获取这些数据,我们总不能一次一次去自己计算内存地址或者偏移,因此可以通过sampler2D来对贴图进行操作。更简单地理解,sampler2D就是GLSL中的2D贴图的类型,相应的,还有sampler1D,sampler3D,samplerCube等等格式。

解释通了sampler2D是什么之后,还需要解释下为什么在这里需要一句对 _MainTex 的声明,之前我们不是已经在 Properties 里声明过它是贴图了么。答案是我们用来实例的这个shader其实是由两个相对独立的块组成的,外层的属性声明,回滚等等是Unity可以直接使用和编译的ShaderLab;而现在我们是在 CGPROGRAM...ENDCG 这样一个代码块中,这是一段CG程序。对于这段CG程序,要想访问在 Properties 中所定义的变量的话, 必须使用和之前变量相同的名字进行声明 。于是其实 sampler2D _MainTex; 做的事情就是再次声明并链接了_MainTex,使得接下来的CG程序能够使用这个变量。
接下来是一个struct结构体。相信大家对于结构体已经很熟悉了,我们先跳过之,直接看下面的的surf函数。上面的#pragma段已经指出了我们的着色器代码的方法的名字叫做surf,那没跑儿了,就是这段代码是我们的着色器的工作核心。我们已经说过不止一次, 着色器就是给定了输入,然后给出输出进行着色的代码。 CG规定了声明为表面着色器的方法(就是我们这里的surf)的参数类型和名字,因此我们没有权利决定surf的输入输出参数的类型,只能按照规定写。这个规定就是第一个参数是一个 Input 结构,第二个参数是一个inout的 SurfaceOutput 结构。
它们分别是什么呢?Input其实是需要我们去定义的结构,这给我们提供了一个机会,可以把所需要参与计算的数据都放到这个Input结构中,传入surf函数使用;SurfaceOutput是已经定义好了里面类型输出结构,但是一开始的时候内容暂时是空白的,我们需要向里面填写输出,这样就可以完成着色了。先仔细看看INPUT吧,现在可以跳回来看上面定义的INPUT结构体了:
struct Input { float2 uv_MainTex; };
这是一个非常简单的结构体,称为Input,其中有一个float2数据类型,这是一个二维float矢量,也就是(a,b)这样的。 作为输入的结构体必须命名为Input,这个结构体中定义了一个float2的变量,表示浮点数的float后面紧跟一个数字2,这又是什么意思呢?其实没什么魔法,float和vec都可以在之后加入一个2到4的数字,来表示被打包在一起的2到4个同类型数。比如下面的这些定义:
//Define a 2d vector variablevec2 coordinate;//Define a color variablefloat4 color;//Multiply out a colorfloat3 multipliedColor = color.rgb * coordinate.x;
在访问这些值时,我们既可以只使用名称来获得整组值,也可以使用下标的方式(比如.xyzw,.rgba或它们的部分比如.x等等)来获得某个值。在这个例子里,我们声明了一个叫做 uv_MainTex 的包含两个浮点数的变量。
如果你对3D开发稍有耳闻的话,一定不会对uv这两个字母感到陌生。UV mapping的作用是 将一个2D贴图上的点按照一定规则映射到3D模型上 ,是3D渲染中最常见的一种顶点处理手段。在CG程序中,我们有这样的约定, 在一个贴图变量(在我们例子中是 _MainTex )之前加上uv两个字母 ,就代表 提取它的uv值(其实就是两个代表贴图上点的二维坐标 )。我们之后就可以在surf程序中直接通过访问uv_MainTex来取得这张 贴图当前需要计算的点的坐标值 了。
如果你坚持看到这里了,那要恭喜你,因为离最后成功读完一个Shader只有一步之遥。我们回到surf函数,它的两有参数,第一个是Input,我们已经明白了:在计算输出时Shader会多次调用surf函数,每次给入一个贴图上的点坐标,来计算输出。第二个参数是一个可写的SurfaceOutput,SurfaceOutput是预定义的输出结构,我们的surf函数的目标就是根据输入把这个输出结构填上。SurfaceOutput结构体的定义如下
struct SurfaceOutput { half3 Albedo; //像素的颜色 half3 Normal; //像素的法向值 half3 Emission; //像素的发散颜色 half Specular; //像素的镜面高光 half Gloss; //像素的发光强度 half Alpha; //像素的透明度 };
这里的half和我们常见float与double类似,都表示浮点数,只不过精度不一样。也许你很熟悉单精度浮点数(float或者single)和双精度浮点数(double),这里的half指的是半精度浮点数,精度最低,运算性能相对比高精度浮点数高一些,因此被大量使用。
在例子中,我们做的事情非常简单:
half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Albedo = c.rgb; o.Alpha = c.a;
这里用到了一个 tex2d 函数,这是CG程序中用来 在一张贴图中对一个点进行采样 的方法,返回一个float4。 tex2D就是"利用UV去取图片获得纹理贴图"的做法。 这里对_MainTex在输入点上进行了采样,并将其颜色的rbg值赋予了输出的像素颜色,将a值赋予透明度。于是,着色器就明白了应当怎样工作:即找到贴图上对应的uv点,直接使用颜色信息来进行着色。

Pass

1.SubShader包装了一个渲染方案,这些方案由一个个通道(Pass)来执行的,SubShader可以包括很多通道块,每个Pass都能使几何体渲染一次;
2.Pass基本语法:
Pass{ [Name and Tags] [RenderSetup] [Texture Setup] }
Pass块的Name引用此Pass,可以在其他着色器的Pass块中引用它,减少重复操作,Name命令必须大写。

通道类型:RegularPass渲染设置

1. Lighting光照:开启关闭定点光照 On/Off
2. Material{材质块}:材质,定义一个使用顶点光照管线的材质;
3. ColorMaterial:颜色集,定义顶点光照的时候使用顶点颜色;
4. SeparateSpecular:开关状态 开启或关闭顶点光照相关的镜面高光颜色, On/Off;
5. Color:设置顶点光照关闭时所使用的颜色
6. Fog{雾块}:设置雾参数;
7. AlphaTest:Alpha测试;
8. Ztest:深度测试模式;
9. ZWrite:深度写模式;
10.Blend:混合模式 SourceBlendMode,DestBlendMode,AlphaSourcesBlendMode,AlphaDstBlendMode;
11 .ColorMask颜色遮罩:设置颜色遮罩,颜色值可以由RGB或A或0或R,G,B,A的组合。设置为0关闭所有颜色通道渲染;
12. Offset偏移因子:设置深度偏移;

特殊通道Pass

1.UsePass:插入所有来自其他着色器的给定名字的通道;
UsePass"Shader/Name",Name为着色器通道;
UsePass"Specular/BASE"//插入specular中名为base的通道;
2. GrabPass{}:一种特殊通道类型,他会捕获物体所在位置的屏幕内容,并写入一个纹理中,这个纹理能被用于后续通道中完成一些高级图像特效,后续通道可以使用 _GrabTexture进行访问;
3. GrbaPass{"纹理名称"}捕获屏幕内容到指定纹理中,后续通道可以通过纹理名称来访问;

Fallback

1.降级:定义在所有子着色器之后,如果没有任何子着色器能运行,能尝试降级;
2. Fallback"着色器名称"
3. Fallback Off; 没有降级,并且不会打印任何警告。

Category分类

1.分类是渲染命名的逻辑组。例如着色器可以有多个子着色器,他们都需要关闭雾效果和混合
Shader "xxxx" {
Categroy{
Fog{Mode Off}
SubShader {...}
SubShader {...}
}
}
----------------------顶点片元Shader------------------

坐标空间



1.物体空间:3D物体自己的坐标空间
一般设计时几何体以中心为原点,人物以双脚中心为原点;
2.世界空间:3D物体在场景中的世界坐标,整个游戏场景的空间;
3.摄像机空间:以观察摄像机为原点的坐标系下的坐标空间;
4.投影成像:3D坐标转换到屏幕空间;

unity坐标系转换

1. transform.localToWorldMatrix 局部坐标转世界坐标矩阵
2. transform.worldToLocalMatrix 世界坐标转局部坐标矩阵
矩阵里有这些方法:
MultiplyPoint,MultiplyPoint3x4 MultiplayVector 来进行坐标变换
4.shader中左乘 _World2Object矩阵来实现世界坐标转局部坐标变换
5.shader中左乘 _Object2World矩阵来实现局部坐标转世界坐标的转换
内置矩阵 支持的矩阵(float4x4):
6. UNITY_MATRIX_MV基本变换矩阵(把每个物体移到世界里就会得到的矩阵)x摄像机矩阵
7. UNITY_MATRIX_MVP 基本变换矩阵(当前模型)x摄像机矩阵(视图)x投影矩阵(current model*view*projection matrix)
8. UNITY_MATRIX_V摄像机矩阵(当前视图矩阵)
9. UNITY_MATRIX_P投影矩阵(目前的投影矩阵)
10. UNITY_MATRIX_VP摄像机矩阵(当前视图)x投影矩阵
11. UNITY_MATRIX_T_MV (基本变换矩阵x摄像机矩阵)转置矩阵,移调模型视图矩阵
12. UNITY_MATRIX_IT_MV (基本变换矩阵x摄像机矩阵)的逆转置矩阵,模型视图矩阵的逆转
13. UNITY_MATRIX_TEXTURE0 纹理变换矩阵

GPU管道流水线

1.主要的运算在GPU上计算,CPU插入指令
2.大致流程:
顶点初始化> 顶点shader>Tellellation曲面化>几何shader>裁剪,投影>三角形遍历> 片元着色shader>输出




顶点片元着色器

1.控制灵活,但不能参与光照计算
2.在着色器中插入Cg代码段,编写在 CGPROGRAMENDCG之间
3.编译指令: #pragma控制 着色器代码编译;
#pragma vertex name 将名称为name的函数编译为顶点着色器;
#pragma fragment name 将名称为name的函数编译为片元着色器;
4.参数和返回值有语义修饰

常用语义修饰

1.POSITION:位置
2.TANGENT:切线
3.NORMAL:法线
4.TEXCOORD0:第一套纹理
5.TEXCOORD1:第二套纹理
6.TEXCOORD2:第三套纹理
7.TEXCOORD3:第四套纹理
8.COLOR:颜色

CGPROGRAM //插入Cg代码开始
#pragma vertex my_vert //把my_vert作为顶点shader的入口
//怎么样获得这个上一个工位或模块的参数呢?-->就是用语义绑定 即语义修饰bind;
//float4(用来存放position),my_vert就是入口,括号内为输入,上一个顶点工位的pos,然后语义bind到位置即:POSITION将位置传到pos;
//返回值float4 也需要语义bind流到下一个环节,所以也要加:POSITION;
//通过语义bind获得输入和输出;
float4 my_vert(float4 pos :POSITION) : POSITION {
return mul(UNITY_MATRIX_MVP, pos);//把pos变换到了投影空间
}
#pragma fragment my_frag // 把my_frag作为片元着色shader的入口
//对返回值fixed4(用来存放颜色数据)进行语义bind到COLOR作为输出
fixed4 my_frag() : COLOR {
return fixed4(1.0, 0.0, 0.0, 1.0);//返回红色
}
ENDCG //插入Cg代码结束



v2f vert ( appdata_full v )这个可以理解为: 输出值v2f 函数名vert (输入结构appdata_full 输入的变量名v)

UV纹理:简单来说A是一张图片(称A为纹理),B是一张坐标信息(称B为UV),用B来取A就是纹理贴图的精髓了,举个简单的例子: A的色彩如下: 红 黄 蓝 绿B的坐标信息如下: (0,0) (1,1) (1,1) (0,1)那么取出来得到的纹理贴图就是: 红 绿 绿 黄当这上面的点数达到很大的量级时,就很有意义了。
------------------------------------------------------------------
法线贴图 :其实是一张RGB贴图,其中红,绿,蓝三个通道分别表示由高度图转换而来的 该点的法线指向 Nx、Ny、Nz 。在其中绝大部分点的法线都指向z方向,因此图更偏向于蓝色。在shader进行处理时,我们 将光照与该点的法线值进行点积 后即可得到在该光线下应有的明暗特性,再将其应用到原图上,即可反应在一定光照环境下物体的凹凸关系了。
Shader "Custom/Normal Mapping" { Properties { _MainTex ( "Base (RGB)" , 2 D) = "white" {} _Bump ( "Bump" , 2 D) = "bump" {} // 声明并加入一个显示名称为 Bump 的贴图,用于放置法线图 } SubShader { Tags { "RenderType" = "Opaque" } LOD 200 CGPROGRAM #pragma surface surf Lambert sampler2D _MainTex; sampler2D _Bump; // 为了能够在CG程序中使用这张贴图,必须加入一个sample struct Input { float2 uv_MainTex; float2 uv_Bump; // 获取Bump的uv信息作为输入 }; void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); // 从法线图中提取法线信息,并将其赋予相应点的输出的Normal属性。 UnpackNormal 是定义在UnityCG.cginc文件中的方法,这个文件中包含了一系列常用的CG变量以及方法。
// UnpackNormal 接受一个fixed4的输入,并将其转换为所对应的法线值(fixed3)。在解包得到这个值之后,将其赋给输出的Normal,就可以参与到光线运算中完成接下来的渲染工作了。 o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump); o.Albedo = c.rgb; o.Alpha = c.a; } ENDCG } FallBack "Diffuse" }
------------------------------------------

光照模型

在我们之前的看到的Shader中(其实也就基本diffuse和normal mapping),都只使用了Lambert的光照模型(#pragma surface surf Lambert),这是一个很经典的漫反射模型, 光强与入射光的方向和反射点处表面法向夹角的余弦成正比 。关于Lambert和漫反射的一些详细的计算和推论,可以参看wiki( Lambert 漫反射 )或者其他地方的介绍。一句话的简单解释就是 一个点的反射光强是和该点的法线向量和入射光向量和强度和夹角有关系 的,其 结果 就是 这两个向量的点积 。既然已经知道了光照计算的原理,我们先来看看如何实现一个自己的光照模型吧。
在刚才的Shader上进行如下修改。
  • 首先将原来的#pragma行改为这样
#pragma surface surf CustomDiffuse
  • 然后在SubShader块中添加如下代码
inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) { float difLight = max( 0 , dot (s.Normal, lightDir)); //计算光强系数,通过点积法线值与光线来确定点的光强,通过max将系数限制在0-1的正数里。 float4 col; col.rgb = s.Albedo * _LightColor0.rgb * (difLight * atten * 2 ); col.a = s.Alpha; return col; }
  • 最后保存,回到Unity。Shader将编译,如果一切正常,你将不会看到新的shader和之前的在材质表现上有任何不同。但是事实上我们现在的shader已经与Unity内建的diffuse光照模型撇清了关系,而在使用我们自己设定的光照模型了。
首先正像我们上一篇所说, #pragma 语句在这里声明了接下来的 Shader的类型 计算调用的方法名 ,以及 指定光照模型 。在之前我们一直指定Lambert为光照模型,而现在我们将其换为了CustomDiffuse。
接下来添加的代码是计算光照的实现。 shader中对于 方法的名称 有着比较严格的约定,想要创建一个光照模型,首先要按照规则 声明一个光照计算的函数名字 ,即 Lighting<Your Chosen Name> 。对于我们的光照模型CustomDiffuse,其计算函数的名称自然就是 LightingCustomDiffuse 了。光照模型的计算是在surf方法的表面颜色之后,根据输入的光照条件来对原来的颜色在这种光照下的表现进行计算,最后输出新的颜色值给渲染单元完成在屏幕的绘制。
也许你已经猜到了,我们之前用的Lambert光照模型是不是也有一个名字叫LightingLambert的光照计算函数呢?在Unity的内建Shader中,有一个Lighting.cginc文件,里面就包含了LightingLambert的实现。也许你也注意到了,我们所实现的LightingCustomDiffuse的内容现在和Unity内建中的LightingLambert是完全一样的,这也就是使用新的shader的原来视觉上没有区别的原因,因为实现确实是完全一样的。
首先来看 输入量 SurfaceOutput s 这个就是 经过表面计算函数surf处理后的输出 ,我们讲对其上的点根据光线进行处理, fixed3 lightDir 光线的方向 fixed atten 表示 光衰减的系数 。在计算光照的代码中,我们先将 输入的s的法线值 (在Normal mapping中的话这个值已经是法线图中的对应量了)和 输入光线 进行 点积 (dot函数是CG中内置的数学函数,希望你还记得,可以 参考这里 )。 点积的结果在-1至1之间,这个值越大表示法线与光线间夹角越小,这个点也就应该越亮(法线是垂直于面的向量,所以法线和光线夹角越小表示光线近乎正面照射模型,因此比起侧面照射更亮) 之后使用 max来将这个系数结果限制在0到1之间 ,是为了避免负数情况的存在而导致最终计算的颜色变为负数,输出一团黑,一般来说这是我们不愿意看到的。

接下来我们将surf输出的颜色与光线的颜色 _LightColor0.rgb (由Unity根据场景中的光源得到的,它在Lighting.cginc中有声明)进行乘积,然后再与刚才计算的光强系数和输入的衰减系数相乘,最后得到在这个光线下的颜色输出(关于difLight * atten * 2中为什么有个乘2,这是一个历史遗留问题,主要是为了进行一些光强补偿,可以参见 这里的讨论 )。
在了解了基本实现方式之后,我们可以看看做一些修改玩玩儿。最简单的比如将这个Lambert模型改亮一些,比如换成Half Lambert模型。Half Lambert是由Valve创造的可以使物体在低光线条件下增亮的技术,最早被用于半条命(Half Life)中以避免在低光下物体的走形。简单说就是把光强系数先取一半,然后在加0.5,代码如下:
inline float4 LightingCustomDiffuse (SurfaceOutput s, fixed3 lightDir, fixed atten) { float difLight = dot (s.Normal, lightDir); float hLambert = difLight * 0 . 5 + 0 . 5 ; float4 col; col.rgb = s.Albedo * _LightColor0.rgb * (hLambert * atten * 2 ); col.a = s.Alpha; return col; }
这样一来,原来光强0的点,现在对应的值变为了0.5,而原来是1的地方现在将保持为1。也就是说模型贴图的暗部被增强变亮了,而亮部基本保持和原来一样,防止过曝。使用Half Lambert前后的效果图如下,注意最右侧石头下方的阴影处细节更加明显了,而这一切都只是视觉效果的改变,不涉及任何贴图和模型的变化。

Half Lambert下发现贴图的表现
表面贴图的追加效果
OK,对于光线和自定义光照模型的讨论暂时到此为止,因为如果展开的话这将会一个庞大的图形学和经典光学的话题了。我们回到Shader,并且一起实现一些激动人心的效果吧。比如,在你的游戏场景中有一幕是雪地场景,而你希望做一些石头上白雪皑皑的覆盖效果,应该怎么办呢?难道让你可爱的3D设计师再去出一套覆雪的贴图然后使用新的贴图?当然不,不是不能,而是不该。因为新的贴图不仅会增大项目的资源包体积,更会增大之后修改和维护的难度,想想要是有好多石头需要实现同样的覆雪效果,或者是要随着游戏时间堆积的雪逐渐变多的话,你应该怎么办?难道让设计师再把所有的石头贴图都盖上雪,然后再按照雪的厚度出5套不同的贴图么?相信我,他们会疯的。
于是,我们考虑用Shader来完成这件工作吧!先考虑下我们需要什么,积雪效果的话,我们需要积雪等级(用来表示积雪量),雪的颜色,以及积雪的方向。基本思路和实现自定义光照模型类似,通过计算原图的点在世界坐标中的法线方向与积雪方向的点积,如果大于设定的积雪等级的阈值的话则表示这个方向与积雪方向是一致的,其上是可以积雪的,显示雪的颜色,否则使用原贴图的颜色。废话不再多说,上代码,在上面的Shader的基础上,更改Properties里的内容为
Properties { _MainTex ( "Base (RGB)" , 2 D) = "white" {} _Bump ( "Bump" , 2 D) = "bump" {} _Snow ( "Snow Level" , Range( 0 , 1 ) ) = 0 _SnowColor ( "Snow Color" , Color) = ( 1 . 0 , 1 . 0 , 1 . 0 , 1 . 0 ) _SnowDirection ( "Snow Direction" , Vector) = ( 0 , 1 , 0 ) }
没有太多值得说的,唯一要提一下的是_SnowDirection设定的默认值为(0,1,0),这表示我们希望雪是垂直落下的。对应地,在CG程序中对这些变量进行声明:
sampler2D _MainTex; sampler2D _Bump; float _Snow; float4 _SnowColor; float4 _SnowDirection;
接下来改变Input的内容:
struct Input { float2 uv_MainTex; float2 uv_Bump; float3 worldNormal; INTERNAL_DATA };
相对于上面的Shader输入来说,加入了一个 float3 worldNormal; INTERNAL_DATA ,如果SurfaceOutput中设定了Normal值的话,通过worldNormal可以获取当前点在世界中的法线值。详细的解说可以参见 Unity的Shader文档 。接下来可以改变surf函数,实现积雪效果了。
void surf (Input IN, inout SurfaceOutput o) { half4 c = tex2D (_MainTex, IN.uv_MainTex); o.Normal = UnpackNormal(tex2D(_Bump, IN.uv_Bump)); if (dot(WorldNormalVector(IN, o.Normal), _SnowDirection.xyz) > lerp( 1 , - 1 ,_Snow)) { o.Albedo = _SnowColor.rgb; } else { o.Albedo = c.rgb; } o.Alpha = c.a; }
和上面相比,加入了一个if…else…的判断。首先看这个条件的不等式的左侧,我们对雪的方向和和输入点的世界法线方向进行点积。 WorldNormalVector 通过输入的点及这个点的法线值,来计算它在世界坐标中的方向;右侧的lerp函数相信只要对插值有概念的同学都不难理解:当_Snow取最小值0时,这个函数将返回1,而_Snow取最大值时,返回-1。这样我们就可以通过设定_Snow的值来控制积雪的阈值,要是积雪等级_Snow是0时,不等式左侧不可能大于右侧,因此完全没有积雪;相反要是_Snow取最大值1时,由于左侧必定大于-1,所以全模型积雪。而随着取中间值的变化,积雪的情况便会有所不同。
应用这个Shader,并且适当地调节一下积雪等级和颜色,可以得到如下右边的效果。

添加了积雪效果的Shader
更改顶点模型
到现在位置,我们还仅指是在原贴图上进行操作,不管是用法线图使模型看起来凸凹有致,还是加上积雪,所有的计算和颜色的输出都只是“障眼法”,并没有对模型有任何实质的改动。但是对于积雪效果来说,实际上积雪是附加到石头上面,而不应当简单替换掉原来的颜色。但是具体实施起来,最简单的办法还是直接替换颜色,但是我们可以稍微变更一下模型,使原来的模型在积雪的方向稍微变大一些,这样来达到一种雪是附加到石头上的效果。
我们继续修改之前的Shader,首先我们需要告诉surface shadow我们要改变模型的顶点。首先将#param行改为
#pragma surface surf CustomDiffuse vertex:vert
这告诉Shader我们想要改变模型顶点,并且我们会写一个叫做 vert 的函数来改变顶点。接下来我们再添加一个参数,在Properties中声明一个 _SnowDepth 变量,表示积雪的厚度,当然我们也需要在CG段中进行声明:
//In Properties{…} _SnowDepth ( "Snow Depth" , Range( 0 , 0 . 3 )) = 0 . 1 //In CG declare float _SnowDepth;
接下来实现vert方法,和之前积雪的运算其实比较类似,判断点积大小来决定是否需要扩大模型以及确定模型扩大的方向。在CG段中加入以下vert方法
void vert ( inout appdata_full v) { float4 sn = mul(transpose(_Object2World) , _SnowDirection); if (dot(v.normal, sn.xyz) >= lerp( 1 , - 1 , (_Snow * 2 ) / 3 )) { v.vertex.xyz += (sn.xyz + v.normal) * _SnowDepth * _Snow; } }
和surf的原理差不多,系统会输入一个当前的顶点的值,我们根据需要计算并填上新的值作为返回即可。上面第一行中使用 transpose 方法输出原矩阵的转置矩阵,在这里_Object2World是Unity ShaderLab的内建值,它表示将当前模型转换到世界坐标中的矩阵,将其与积雪方向做矩阵乘积得到积雪方向在物体的世界空间中的投影(把积雪方向转换到世界坐标中)。之后我们计算了这个世界坐标中实际的积雪方向和当前点的法线值的点积,并将结果与使用积雪等级的2/3进行比较lerp后的阈值比较。这样,当前点如果和积雪方向一致,并且积雪较为完整的话,将改变该点的模型顶点高度。
加入模型更改前后的效果对比如下图,加入模型调整的右图表现要更为丰满真实。

本节中实现的Shader可以 在这里找到完整版本 进行参考

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值