本系列为作者学习UnityShader入门精要而作的笔记,内容将包括:
- 书本中句子照抄 + 个人批注
- 项目源码
- 一堆新手会犯的错误
- 潜在的太监断更,有始无终
总之适用于同样开始学习Shader的同学们进行有取舍的参考。
文章目录
渲染纹理
摄像机的渲染结果会输出到颜色缓冲中,并显示到我们的屏幕上。现代的GPU允许我们把整个三维场景渲染到一个中间缓冲中,即渲染目标纹理(Render Target Texture ,RRT) , 而不是传统的帧缓冲或者后备缓冲(back buffer,就是我们之前讲到的进行画面切换时,颜色缓冲重新渲染时需要使用的缓冲区)。与之相关的是多重渲染目标(Multiple Render Target ,MRT) 。这种技术指的是GPU允许我们把场景同时渲染到多个渲染目标纹理中(例如4种渲染效果,正常情况下是渲染4次,但是使用MRT可以一次就渲染4种)。
Unity为RRT定义了一种专门的纹理类型------渲染纹理(Render Texture) 。这是一种为摄像机准备的纹理贴图,使用渲染纹理通常有两种方式:
- 在Project目录下创建一个渲染纹理,然后把某个摄像机的渲染目标设置成渲染纹理,这样一来该摄像机的渲染结果就会实时更新到渲染纹理中而不会显示在屏幕上。使用这种方法,我们可以选择渲染纹理的分辨率,滤波模式等纹理属性。
- 另一种方式是在屏幕后处理时使用GrabPass命令或者OnRenderImage函数来获取当前屏幕图像,Unity会把这个屏幕图像放到一张和屏幕分辨率等同的渲染纹理中,我们可以在自定义的Shader中将它们当作普通的纹理进行处理,从而实现各种屏幕特效(牛逼啊)
镜子效果
我们想要实现如图所示的一个镜子的效果,原理就是从镜子的方向布置一个摄像机,获取它的RenderTexture并进行处理。
shader代码:
cpp
Shader "Custom/MirrowMat_Copy"
{
Properties
{
_MainTex("Main Tex",2D) = "white" {}
}
SubShader
{
Tags{"RenderType" = "Opaque" "Queue"="Geometry"}
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert(appdata_base v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
// 镜子效果需要x轴翻转
o.uv.x = 1 - o.uv.x;
return o;
}
fixed4 frag(v2f i):SV_Target
{
return tex2D(_MainTex, i.uv);
}
ENDCG
}
}
FallBack Off
}
简单获取表面纹理后映射回片元着色器即可。不过效果可以说是一坨()。由于分辨率太低导致图片像素过低,锯齿严重。而且和真正的镜子比起来效果不能说是一模一样,只能说天差地别。
玻璃效果
在Unity中,我们还可以通过Unity Shader中一种特殊的Pass来完成获取屏幕图像的目的,就是GrabPass。当我们在Shader中定义了一个GrabPass后,Unity会把当前屏幕的图像绘制在一张纹理中,以便我们在后续的Pass中访问它。我们通常会使用GrabPass来实现注入玻璃等透明材质的模拟。
与使用简单的透明度混合不同,使用GrabPass可以让我们对该物体后面的图像进行更复杂的处理,例如使用法线模拟折射想过,而不再是简单的和原屏幕颜色进行混合。
需要注意的是,使用GrabPass的时候,我们需要额外小心物体的渲染队列设置。因为GrabPass通常用于渲染透明物体,尽管代码里不包含混合指令,但我们往往仍然需要把物体的渲染队列设置成透明队列(Transparent)才能保证当渲染该物体时,所有的不透明物体已经被绘制在屏幕上(反正就是需要注意在渲染透明物体时的渲染队列顺序)
让我们用一个GrabPass来模拟一个玻璃效果。我们需要用到一张法线纹理来修改模型的法线信息,通过一个Cubemap来模拟玻璃的反射,而在模拟折射时,则使用GrabPass获取玻璃后面的屏幕图像,并模拟计算折射效果。
透明度应当是和反射率负相关的,因此我们需要以下变量:
- 材质主纹理
- 法线纹理,用于计算折射
- 立方体纹理,用于计算环境反射效果
请看代码:
cpp
Shader "Custom/GlassRefraction_Copy"
{
Properties
{
_MainTex("Main Tex",2D) = "white" {}
_BumpMap("Normal Map",2D) = "bump" {}
_Cubemap("Cube Map",Cube) = "_skybox" {}
_Distortion("Distortion",Range(0,100)) = 10
_RefractAmount("Refraction Amount",Range(0,1)) = 1
}
SubShader
{
// "RenderType"="Opaque" 为了使用着色器替换(Shader Replacement)时,该物体被正确渲染
Tags{"Queue" = "Transparent" "RenderType" ="Opaque"}
// 抓取不透明物体渲染后的缓存,并保存到_RefractionTex纹理中
GrabPass { "_RefractionTex" }
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Lighting.cginc"
#include "UnityCG.cginc"
#include "AutoLight.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _BumpMap;
float4 _BumpMap_ST;
samplerCUBE _Cubemap;
float _Distortion;
fixed _RefractAmount;
sampler2D _RefractionTex;
float4 _RefractionTex_TexelSize;
struct a2v
{
float4 vertex:POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float4 texcoord : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float4 scrPos : TEXCOORD0;
float4 uv : TEXCOORD1;
float4 TtoW0 : TEXCOORD2;
float4 TtoW1 : TEXCOORD3;
float4 TtoW2 : TEXCOORD4;
};
// 获取从切线空间转换到世界空间下的坐标,从而获得T2W转换矩阵
v2f vert(a2v v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.scrPos = ComputeGrabScreenPos(o.pos);
o.uv.xy = TRANSFORM_TEX(v.texcoord,_MainTex);
o.uv.zw = TRANSFORM_TEX(v.texcoord,_BumpMap);
float3 worldPos = o.pos;
float3 worldNormal = UnityObjectToWorldNormal(v.normal);
float3 worldTangent = mul(unity_ObjectToWorld,v.tangent);
float3 worldBinormal = cross(worldNormal,worldTangent) * v.tangent.w;
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
return o;
}
fixed4 frag(v2f i):SV_Target
{
float3 worldNormal = float3(i.TtoW0.y,i.TtoW1.y,i.TtoW2.y);
float3 worldPos = float3(i.TtoW0.w, i.TtoW1.w, i.TtoW2.w);
fixed3 worldViewDir = normalize(UnityWorldSpaceViewDir(worldPos));
fixed3 bump = UnpackNormal(tex2D(_BumpMap,i.uv.zw));
// 计算折射光的偏移 = 法线纹理 * 扭曲值 * 折射纹理(GrabPass的缓存颜色)
float2 refractionOffset = bump.xy * _Distortion * _RefractionTex_TexelSize.xy;
// 应用矩阵变换将法线变换到世界空间中
bump = normalize(half3(dot(i.TtoW0.xyz, bump), dot(i.TtoW1.xyz, bump), dot(i.TtoW2.xyz, bump)));
// 对Grab的RenderTex纹理的offset进行计算以应用折射光偏移
// 此处与书中不同,书中为refractionOffset * i.scrPos.z + i.scrPos.xy
i.scrPos.xy = refractionOffset * max(0.5,(1-i.scrPos.z)) + i.scrPos.xy;
// 我们不能直接对_RefractionTex进行采样,因为ComputeScreenPos得到的scrPos是使用o.pos计算得到的
// 因此scrPos是齐次裁剪空间下的四维向量,若需要采样则应当先应用齐次除法转换到NDC空间下
fixed3 refractionColor = tex2D(_RefractionTex, i.scrPos.xy/i.scrPos.w).rgb;
fixed3 reflectDir = reflect(-worldViewDir,bump);
fixed4 texColor = tex2D(_MainTex,i.uv.xy);
fixed3 reflectColor = texCUBE(_Cubemap,reflectDir).rgb * texColor.rgb;
fixed3 finalColor = reflectColor * (1-_RefractAmount) + refractionColor * _RefractAmount;
return fixed4(finalColor, 1);
}
ENDCG
}
}
Fallback "Diffuse"
}
设置"Queue" = "Transparent"
是为了保证渲染时其他不透明物体已经被渲染,"RenderType" = "Opaque"
是为了使用着色器替换时,该物体被正确渲染。(这通常发生在我们需要得到摄像机的深度和法线纹理时)
GrabPass
定义了一个抓取图像的Pass。我们在其中声明字符串可以决定图像被存入哪个纹理中。
o.scrPos = ComputeGrabScreenPos(o.pos);
语句计算了被抓取的屏幕图像的采样坐标,由于是使用clipPos进行计算的,因此计算结果也是处于四维的齐次裁剪空间下。
随后我们获取从切线空间到世界空间的变换矩阵,并进行一系列的向量计算
在使用scrPis前我们需要注意,我们先是对其应用了纹理偏移的计算以计算折射光在clip空间下的偏移。后续进行折射纹理采样时则是先将应用齐次除法转换到视口空间下再进行采样。
(我写的Shader使模型表面的凹凸感更充实了。出现的效果是:距离摄像机越远,玻璃的折射越不明显,因为根据公式,采样的scrPos在进行齐次除法后最终将落到屏幕空间,因此会随着摄像机的改变而改变视觉效果,而齐次空间中的z轴存储的正是深度值。因此我们可以根据深度对折射纹理进行采样)
GrabPass支持两种形式的定义:
- 直接使用GrabPass{},然后再后续的Pass中直接使用
_GrabTexture
来访问屏幕图像。但是,当场景中有多个物体都使用了这样的形式来抓取屏幕时,这种方法的性能消耗比较大。因为Unity要为每个物体都抓取一次屏幕图像。但这种方法可以让每个物体得到不同的屏幕图像,这取决于它们的渲染队列以及渲染它们时当前的屏幕缓冲中的颜色。 - 直接使用GrabPass{"TextureName"},使用该方法,Unity会在每一帧时为第一个使用名为"TextureName"的纹理的物体执行一次抓取屏幕的操作,而这个纹理同样可以被其他Pass访问。这样做的好处是:如果我们想要在多个纹理中处理抓取屏幕图像,而它们所需的屏幕图像是一样的时候,那么我们就可以使用这种方法,使得所有Pass访问的是同一张纹理图像。就不必每帧都抓取N张图像了。
渲染纹理 vs GrabPass
尽管渲染纹理和GrabPass都可以抓取屏幕图像,但实现方式略有不同。GrabPass只需在Pass中添加几行代码就可以实现屏幕纹理抓取,若是使用渲染纹理,我们还需要创建渲染纹理和一个额外的摄像机,再把摄像机的RendetTarget设置为新的渲染纹理对象,最后把渲染纹理传递给相应的Shader。
但是从效率上说,使用渲染纹理的效率往往高于GrabPass。因为我们可以自定义渲染纹理的大小,或是调整摄像机的渲染层来减少二次渲染的场景大小,或者使用其他方法来控制摄像机是否开启(灵活,纹理大小可控)
GrabPass获取的图像分辨率和显示屏幕是一致的,意味着在高分辨率的设备上会造成严重的带宽影响。且GrabPass往往需要CPU直接从back buffer中读取数据,破坏了CPU和GPU的并行性,这是比较耗时的,甚至在一些设备上不支持。
Unity5还提供了命令缓冲(Command Buffers) 来允许我们拓展Unity 的渲染流水线。此处就先略过了
程序纹理
程序纹理指的是那些由计算机生成的图形,通常用一些特定的算法来创建个性化的图案或非常真实的自然元素(例如木头,石头,没太理解)
使用程序纹理的好处是我们可以使用各种参数来控制纹理的外观,不仅仅是颜色等属性,甚至可以说是不同类型的图案属性,从而获得丰富的视觉效果。
在Unity中实现简单的程序纹理
cpp
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
[ExecuteInEditMode]
public class ProceduralTextureGenerationCopy : MonoBehaviour {
public Material material = null;
#region 材质属性
// SetProperty可以使得面板上的修改也会被属性set到
[SerializeField, SetProperty("textureWidth")]
// 纹理大小通常是2的整幂次
private int m_textureWidth = 512;
public int textureWidth {
get {
return m_textureWidth;
}
set {
m_textureWidth = value;
_UpdateMaterial();
}
}
[SerializeField, SetProperty("backgroundColor")]
private Color m_backgroundColor = Color.white;
public Color backgroundColor {
get {
return m_backgroundColor;
}
set {
m_backgroundColor = value;
_UpdateMaterial();
}
}
[SerializeField, SetProperty("circleColor")]
private Color m_circleColor = Color.yellow;
public Color circleColor {
get {
return m_circleColor;
}
set {
m_circleColor = value;
_UpdateMaterial();
}
}
[SerializeField, SetProperty("blurFactor")]
private float m_blurFactor = 2.0f;
public float blurFactor {
get {
return m_blurFactor;
}
set {
m_blurFactor = value;
_UpdateMaterial();
}
}
#endregion
private Texture2D m_generatedTexture = null;
// Use this for initialization
void Start () {
if (material == null) {
Renderer renderer = gameObject.GetComponent<Renderer>();
if (renderer == null) {
Debug.LogWarning("Cannot find a renderer.");
return;
}
material = renderer.sharedMaterial;
}
_UpdateMaterial();
}
// 该函数用于根据Set生成新纹理并应用到Shader的MainTex属性上
private void _UpdateMaterial() {
if (material != null) {
m_generatedTexture = _GenerateProceduralTexture();
material.SetTexture("_MainTex", m_generatedTexture);
}
}
// 混合颜色函数
private Color _MixColor(Color color0, Color color1, float mixFactor) {
Color mixColor = Color.white;
mixColor.r = Mathf.Lerp(color0.r, color1.r, mixFactor);
mixColor.g = Mathf.Lerp(color0.g, color1.g, mixFactor);
mixColor.b = Mathf.Lerp(color0.b, color1.b, mixFactor);
mixColor.a = Mathf.Lerp(color0.a, color1.a, mixFactor);
return mixColor;
}
private Texture2D _GenerateProceduralTexture() {
Texture2D proceduralTexture = new Texture2D(textureWidth, textureWidth);
// 定义圆点间的间距
float circleInterval = textureWidth / 4.0f;
// 定义圆的半径
float radius = textureWidth / 10.0f;
// 定义模糊系数
float edgeBlur = 1.0f / blurFactor;
// 逐像素绘制
for (int w = 0; w < textureWidth; w++) {
for (int h = 0; h < textureWidth; h++) {
// 初始化背景颜色像素
Color pixel = backgroundColor;
// 依次画圆
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
// 计算圆心坐标
Vector2 circleCenter = new Vector2(circleInterval * (i + 1), circleInterval * (j + 1));
// 计算当前像素与圆的距离 = 圆心与像素的欧氏距离 - 半径
float dist = Vector2.Distance(new Vector2(w, h), circleCenter) - radius;
// 进行边缘模糊
Color color = _MixColor(circleColor, new Color(pixel.r, pixel.g, pixel.b, 0.0f), Mathf.SmoothStep(0f, 1.0f, dist * edgeBlur));
// 与边缘颜色再进行颜色混合
pixel = _MixColor(pixel, color, color.a);
}
}
proceduralTexture.SetPixel(w, h, pixel);
}
}
proceduralTexture.Apply();
return proceduralTexture;
}
}
原理就是我们用脚本代码逐像素处理,用数学知识来手动生成了。
Unity的程序纹理
在Unity中,有一类专门使用程序纹理的材质,叫做程序材质(Procedural Materials) ,程序材质和我们之前使用的材质本质上是一样的,不同的是,它使用的纹理是程序纹理,而这些程序纹理是使用Substance Designer 生成的。
这些程序纹理的后缀为**.sbsar** 。导入到Unity之后会生成一个程序纹理资源(Procedural Material Asset)。每个程序纹理使用了不同的纹理参数,因此也会生成不同的纹理。
不过由于Unity5.6之后内置不支持生成sbsar的纹理资源,只能使用substance提供的的插件。而旧版的插件已经不支持下载,可以在2020之后的Unity版本中安装Substance 3D for Unity插件来生成纹理。