12_Rendering

Posted on 2026-09-03 12:28  Latencale  阅读(26)  评论(0)    收藏  举报

对于Shader的初步学习,每次做完笔记后会更新,直到结束。

Matrices 矩阵

缩放,旋转,移动并进行矩阵变换

投影矩阵

正交投影
透视投影


Shader Fundamental 着色器基础

First Shader 第一个着色器

将shader赋给material,material给object

Shader Programs着色器程序

Shader "Custom/'名称'"{ 
	SubShader{
		Pass{
			CGPROGRAM
			
			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram
			
			ENDCG
		}
	}
}
  • 着色器由Shader关键字定义
  • SubShader 子着色器
  • Pass 着色器通道 ,数量>=1
  • CGPROGRAM 着色器程序起始
  • ENDCG 结束
  • #pragma 编译指令
  • vertex 顶点着色器:把每个顶点的原始数据(位置、法线、UV)转换成后续片元着色器需要的数据,并传递给光栅化阶段进行插值,一个Pass只有一个
  • fragment 片元着色器:利用插值后的数据(法线、世界位置、UV)和纹理采样,计算出这个像素最终应该显示的颜色,一个Pass只有一个
  • MyVertexProgram 方法名(非固定)

Shader Compilation 着色器编译

在 Shader 的 Compiled code 选择编译器(如D3D、OpenGL Core等)

包含其他文件

#include "UnityCG";

生成输出

float4 MyVertexProgram (float4 position : POSITION) : SV_POSITION {
	……
}
float4 MyFragmentProgram(float4 position : SV_POSITION) : SV_TARGET{
	……
}
  • SV 代表系统值
  • POSITION 代表顶点位置
  • TARGET 代表默认的着色器目标,即帧缓冲区

顶点变换

顶点着色器中
mul(UNITY_MATRIX_MVP,position);将模型空间转化到裁剪空间
现在使用UnityObjectToClipPos(position);代替

Use shader properities 使用着色器属性

定义属性

Shader "Custom/My First Shader"{
	Properties{
	_Tint ("Tint", Color) = (1,1,1,1)
	}
}
  • Properties 着色器属性
  • `
  • _Tint在材质中可选择颜色
  • 属性的命名规则:‘_’+ 大写首字母 + 小写

访问属性

在通道中访问属性
e.g. float4 _Tint;

Pass data from vertices to fragments 将数据从顶点传递到片段

无纹理贴图情况

插值
定义结构体Interpolators 和 VertexData

struct Interpolators{
	float4 position : POSITION;
	floa2 uv : TEXCOORD0;
};
struct VertexData{
	float4 position : POSITION;
	float2 uv : TEXCOORD0;
};

然后在顶点着色器进行更改

Interpolators MyVertexProgram(VertexData v){	
	Interpolators i;
	i.position = UnityObjectToClipPos(v.position);
	//模型空间转换到裁剪空间
	i.uv = v.uv;
	return i;
}

随后再片元着色器进行传回
在没有纹理贴图时,小球有了颜色

有一张纹理贴图的状况

  • 在Properties中新增_MainTex ("Texture", 2D) = "white"{}
  • 在Pass中新增sampler2D _MainTex;
  • 在片元着色器中返回tex2D(_MainTex, i.uv);就可以了
  • tex2D(_MainTex, i.uv) * _Tint;就会改变整体的颜色

Tiling and Offset 平铺与偏移

平铺:

  • float4 _MainTex_ST;
    • ST 代表缩放与平移
    • i.uv = v.uv * _MainTex_ST + _MainTex_ST.zw 前者得到平铺,后者得到偏移
    • 等于 i.uv = TRANSFORM_TEX(v.uv, _MainTex);

纹理设置

纹理的一些选项

Wrap Mode

Wrap Mode 效果描述 典型使用场景
Repeat 纹理在 UV 坐标超出 [0,1] 范围时重复平铺 地面、墙面、大面积重复纹理
Clamp 纹理在 UV 坐标超出 [0,1] 范围时,边缘像素被拉伸 需要边缘固定颜色的纹理(如 UI)
…… …… ……

Mipmaps 和 Anisotropic Filtering

纹理更小时,可以使用Mipmaps优化视觉效果

Mipmaps 多级渐远纹理

Mipmaps原理见计算机图形学笔记
可能造成过度模糊

filter mode 分为 Bilinear双线性过滤 和 Trilinear三线性过滤,讲解见计算机图形学笔记

开启Mipmaps在Advanced/Generate Mipmaps
要在远处渐灰则开启Fadeout to Gray

Anisotropic Filtering 各向异性过滤

Edit / Project Settings / Quality

原理见计算机图形学笔记
解决过度模糊问题


Combining Texture 合并纹理

Sample multiple textures 采样多个纹理

复制My First Shader得到Textured With Detial

多重纹理采样

原来我们只采样一张纹理,多张纹理采样时要*,但是叠加细节后会变暗

float4 color = tex2D(_MainTex, i.uv) * _Tint;
color *= tex2D(_MainTex, i.uv * 10)

这里两次采样都是_MainTex主纹理,可以换成其他纹理

单独的细节纹理

对变暗效果的处理,需要使用细节纹理_DetailTex。
细节纹理:颜色以黑白灰为主色调的特殊细节纹理,取值【0 , 1】

回到着色器,我们需要在属性中添加细节纹理_DetailTex,并在通道中添加细节纹理_DetailTex和细节纹理偏移_DetailTex_ST

float4 color = tex2D(_MainTex, i.uv) * _Tint;
color *= tex2D(_DetailTex, i.uvDetail) * 2;

主纹理乘上【0,1】的细节纹理一定会变暗,因此需要再乘2。
当细节纹理的某个纹素为0.5时,最后的纹理的颜色不会变,此时细节纹理的颜色是默认的灰色,这就是细节纹理是灰度图的原因

两对UV展开

在插值Interpolators中两对UV分别取主纹理和细节纹理

淡化细节

实现在近距离或放大时提升材质的视觉效果,因此在远距离或缩小显示时,细节不应该被看到。即随着纹理显示尺寸的减小,细节逐渐淡化。
说到这,之前是不是有操作实现过?是的,Mipmaps!不知道怎么做就回头去看吧

线性色彩空间

  • 伽马色彩空间:经过伽马矫正的颜色。更符合人眼对暗色差异更敏感,指数将低值扩展,并压缩高值。
  • 线性色彩空间:线性,数学计算更正确。

注:最终呈现在显示器的图像永远是伽马图像
前面都是假设在伽马空间进行渲染,如果在线性空间进行渲染,该怎么办?
Unity提供了unity_ColorSpaceDouble,在伽马空间为2,在线性空间约4.59。
color *= [tex2D](_DetailTex, i.uvDetail) * unity_ColorSpaceDouble;
这样无论是哪个颜色空间得到的结果都一样啦~

Use a splat map 使用Splat贴图

纹理更大时,要提高纹理密度,使用纹理平铺

细节纹理存在局限,相同的细节会被应用于整个表面。
如果材质外观并不均匀,就不希望在各处使用相同的细节

现在,一个地形,有两种不同的表面,要么一,要么二。此时,可以选择灰度图代表两种表面分别的占比,实现两幅纹理的混合和过渡。

纹理部分操作

  • 关闭sRGB:代表是数据纹理(如Mask)
  • 打开 generate mipmaps
  • Wrap Mode 设置为Clamp(钳制)模式

着色器部分操作

  1. 复制My First Shader并创建一个新的Texture Splatting着色器
  2. 将色调_Tint相关内容删掉,因为是Mask遮罩,不用颜色
  3. _MainTex的类型改变为Splat Map,同理
  4. 将着色器赋给新材质,材质赋给物体
  5. 添加纹理
    1. 在属性中添加两个纹理
[NoScaleOffset] _Texture1 ("Texture 1", 2D) = "white" {}
[NoScaleOffset] _Texture2 ("Texture 2", 2D) = "white" {}

NoScaleOffset用于保留主纹理的平铺和偏移输入,简单来说就是不用分别给额外纹理输入平铺和偏移值,让其等于主纹理的平铺与偏移值
2. 在通道中添加额外纹理_Texture1,_Texture2
3. 在片元着色器中将两者采样后相加
6. 应用Splat贴图
1. 插值Interpolators中添加uvSplat,用来算某个像素应用哪个纹理
2. 在片元着色器采样
3. 计算: tex2D(_Texture1, i.uv) * splat.r + tex2D(_Texture2, i.uv) * (1 - splat.r);
因为是灰度图,所以R、G、B都可以

RGB贴图

这只融合了两张纹理,更多的纹理该怎么办。
——使用RGB贴图

原理和只有黑白色的斑点图相同,不在多说。
至于多于四张纹理的融合,可以增加Pass,还有其他方法,这里还没有学到,过。


The First Light 第一缕光

Transform normals from object to world space 将法线从对象空间转换到世界空间

法线

新复制一个shader,起名my First Lighting Shader
在VertxData和Interpolators增加法线

现在,改变视角时,法线方向也会改变,这时就需要将法线从对象空间变换到世界空间
只需要在赋值时i.normal = UnityObjectToWorldNormal(v.normal);就可以了。
然后需要重新归一化哦

diffuse reflections 漫反射

漫反射将颜色、明暗关系反射给摄像机

计算漫反射

dot(光照方向 , 法线方向)

钳制关照

当表面朝向光源时,点积计算有效;但是表面背离光源时,不应手法哦任何光照,此时点积结果会出现负数,我们不希望出现负数,所以会进行max(0,点积),然后再套个saturate钳制。

以上操作可以通过DotClamp(点积)实现,注意需要导入#include "UnityStandardBRDF.cginc"哦

平行光

我们现在先只考虑平行光,把唯一平行光当作光照方向。有
float3 lightDir = _WorldSpaceLightPos0.xyz;后者包括当前光源位置,对于方向光而言,它还表示光的传播方向

光照模式

在得到正确结果之前,我们应该选择使用哪种光照数据。
关照模式现在讨论两种,分别是前向渲染路径和延迟渲染路径。

目前使用默认的前向渲染,添加一个LightMode的tags实现

Pass{
	Tags{
		"LightMode" = "ForwardBase"
	}
	
	CGPROGRAM
	……
	ENDCG
}

灯光颜色

光源本身有颜色,需要计算后在物体表面呈现。

float3 lightDir = _WorldSpaceLightPos0.xyz;
float3 lightColor = _LightColor0.rgb;
float3 diffuse = lightColor * DotClamp(lightDir,i.normal);

反照率

物体本身也有颜色,漫反射进行反射

float3 albedo = tex2D(_MainTex, i.uv).rgb;
float3 diffuse = albedo * lightColor * DotClamp(lightDir, i.normal);

主纹理的标签也更改成Albedo

specular reflections 镜面反射

镜面反射得到的是高光
镜面反射,由中学的知识可以知道,需要表面的位置、观察者的位置和光照方向

在Interpolators中新增worldPos变量,在顶点着色器中获得其世界位置,即i.worldPos = mul(unity_ObjectToWorld,v.position);

确定视向方向,即摄像机到像素的方向。

float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
_WorldSpaceCameraPos代表摄像机的位置

反射光的方向

通过reflect()函数知道反射光的方向
float3 reflectionDir = reflect(-lightDir, i.normal);

然后, DotClamped(lightDir, i.normal);数值越大,看到的反射越多;相反的,数值越小,看到的反射越少。

光滑度

高光大小取决于材质的粗糙度,材质越光滑,高光越小。
设置【0,1】的属性来控制光滑度
_Smoothness ("Smoothness", Range(0, 1)) = 0.5

通过将点积提升到更高的幂来缩小高光。
pow(DotClamp(viewDir, reflectionDir), _Smoothness * 100);

Blinn-Phone 布林-冯光照模型

以上计算镜面反射使用的是Phone光照模型,目前更常用的是Blinn-Phone光照模型。
布林-冯光照模型使用一条介于光源方向与观察者方向之间的半向量,视觉上更接近现实。

//float3 reflectionDir = reflect(lightDir, i.normal);
float3 halfVector = normalize(lightDir + viewDir);
return pow(DotClamped(halfVector, i.normal),	_Smoothness * 100);

镜面颜色

镜面反射的颜色取决于光源颜色、材质的高光颜色/金属度。金属几乎没有反照率,镜面反射其表面颜色;而非金属反照率明显,高光较弱,为白色

对于光源颜色,直接乘上光源颜色即可
float3 specular = pow(DotClamped(halfVector, i.normal), _Smoothness * 100);

对于金属的讨论我们会在下面进行,现在对于金属,反射其表面的颜色作为情况进行讨论:

  • 属性中新建物体的色调
    _SpecularTint("Specular", Color) = (0.5,0.5,0.5);

  • 片元着色器中进行颜色的修改
    float3 specular = _SpecularTint.rgb * pow(DotClamped(halfVector, i.normal), _Smoothness * 100);

漫反射和镜面反射

将漫反射和镜面反射叠加,得到完整画面: diffuse + specular

Enforce energy conservation 遵守能量守恒

两者相加后出现一个问题:结果比光源还亮。
原因在于漫反射和镜面反射都以最大强度进行反射,使光的能量增加一倍

解决方法只需要将反照率颜色乘(1 - 高光颜色)就ok
albedo *= 1 - _SpecularTint.rgb;

但是,又出现一个问题(怎么老是有问题啊),镜面反射色为非灰色时,反照率是其反色
那么正确的方法如下:
albedo *= 1 - max(_SpecularTint.r, max(_SpecularTint.g, _Specular.b));

Utility Function 工具函数

是嘟,Unity依旧让你省力,已经有可以用于处理能量守恒的实用函数了,它就是EnergyConservationBetweenDiffuseAndSpecular,但是需要定义#include "UnityStandardUtils.cginc"

以下为改变的代码部分

//albedo *= 1 - max(_SpecularTint.r,  max(_SpecularTint.g, _Specular.b));
float oneMinusReflectivity;
albedo = EnergyConservationBetweenDiffuseAndSpecular(albedo, _SpecularTint.rgb, oneMinusReflection);

oneMinusReflectivity是高光强度的补数,用于于反照率相乘的系数。

Use a metallic workflow 使用金属工作流

上一部分提到了金属与非金属,那么这个部分就会在金属与非金属中进行转换。

通过强高光模拟金属,弱单色镜面高光模拟非金属。即高光工作流。
对于金属,把贴图的颜色数据当作高光色;对于非金属,本身没有彩色高光,因此无需单独设置高光色。成为金属工作流。

金属度

将金属度_Metallic代替高光颜色_SpecularTint
Metallic ("Metallic", Range(0,1)) = 0

其他的一切就交给功工具函数吧:

float3 specularTint;
float oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, specularTint, oneMinusReflection);

DiffuseAndSpecularFromMetallic就是根据反照率和金属度推导出高光颜色的工具函数

注:金属度属性本身应该处于伽马空间,但在线性空间渲染时,Unity不会对单个数值进行伽马矫正,所以需要告知Unity应用伽马矫正与金属度:[Gamma] Metallic ("Metallic", Range(0,1)) = 0

PBS 基于物理的着色

这些函数需要大量数学运算,教程并没有讲述细节,只简述如何进行调用。

  1. 定义#include "UntiyPBSLighting.cginc"
  2. #pragma target 3.0
  3. Blinn-Phon光照模型的东西删掉
  4. 调用UNTIY_BRDF_PBS(albedo, specularTint, oneMinusReflectivity, _Smoothness, i.normal, viewDir, 直接光, 间接光);

直接光UnityLight

UnityLight类型,包含光源颜色、方向、以及ndot值(反射项)

UntiyLight light;
light.color = lightColor;
light.dir = lightDir;
light.ndotl = DotClamped(lightDir, i.normal);

间接光UnityIndirect

UnityIndirect类型,包含漫反射颜色(环境光)和镜面反射颜色(环境的反射)。
环境光之后会接受,现在先不管,设为黑色

UnityIndirect indirectLight;
indirectLight.diffuse = 0; // 暂时忽略环境光
indirectLight.specular = 0;

最后得到由PBS计算的结果

return UNITY_BRDF_PBS(
				albedo, specularTint, 
				oneMinusReflectivity, _Smoothness, 
				i.normal, viewDir, 
				light, indirectLight
);

Multiple Lights 多光源

Include Files 包含文件

对于多盏灯光,需要更多的渲染通道。但是这些渲染通道的代码几乎相同,所以

  • 新建.cginc文本文件(作用给类似于C++的.h文件)
  • 将代码剪切到新文本文件,并在原通道引用头文件

防止重新定义

包含文件也可以包含其他文件,可能出现类似递归的情况,导致代码重复。
解决方法:对头文件使用宏定义保护。

#if !defined(MY_LIGHTING_INCLUDED)
#define MY_LIGHTING_INCLUDED
……
#endif

代码部分为内容

The Second Pass 第二个通道

新建一个方向光,当两盏光都打开时,只有主光源有作用,我们接下来做的就是把第二道及更多道光都发挥作用

ForwardBase通道之用于主方向光
新增的通道:ForwardAdd用于其他光

两个通道相混合:新旧数据分别乘前后的系数后相加
Blend One One

前向基础通道已经判断完Z-Buffer了,所以新通道可以关掉,以节省性能:ZWrite Off

性能优化工具

Draw Call Batches 绘制调用批次

启用Game视图/Status,查看批处理的数量,这个面板用来优化,在之后的学习会有很重要的作用。

Frame Debugger 帧调试器

Window / Frame Debugger 打开帧调试器
帧调试器:逐步执行Draw Call。优先绘制靠近摄像机的不透明物体。

Point Lights 点光源

添加一个点光源,同时禁用两个方向光,然后移动点光源。
灯光会出现奇怪的渲染,原因在于:基础通道没有启用方向光,被渲染处一幅黑色剪影;加性渲染使用点光源,但代码使用方向光的。(究其原因是点光源和方向光的代码原理不同)

光源函数

把光源放到一个单独的函数中,创建直接光
片元着色器的代码简化部分省略,将PBS的直接光调用CreateLight(i)。

灯光位置

对于点光源来说,_WorldSpaceLightPos0中存储的是光源的位置;而对于方向光而言,存储的是光源的方向

对于点光源,光源方向: light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);

光源衰减

对于点光源,物体于点光源越远,光的强度越弱,就会产生衰减;对于方向光,衰减不考虑。

对于点光源,衰减因子为1/d²(球的表面积是4Πr²):

float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
float attenuation = 1 / (dot(lightVec, lightVec));

然后把物体移到距离点光源无穷小处,哇塞,attenuation无穷大了,过于明亮了。改成1/1+d²更好
float attenuation = 1/ (1 + dot(lightVec, lightVec))

光照范围

在计算机中,我们必须在某个时刻停止对光源的渲染。在游戏引擎中,也是这样。
出现问题:物体突然进入/离开光照范围,物体突然变亮/暗了。
解决:确保衰减效果于光照范围同步。

原理明白,接下来开始解决吧~
嗯,Unity懂得你我,在AutoLight.cginc中有解决的方法,我们只需导入头文件后, 访问宏UNITY_LIGHT_ATTENUATION就好。
其有三个参数:存放衰减值的变量名、阴影相关(后续part有补全,目前设为0)、世界坐标位置

/*
float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
float attenuation = 1 / (dot(lightVec, lightVec));
*/
UNTIY_LIGHT_ATTENUATION(attenuation,0,i.worldPos);

Mixing Lights 混合光照

Shader Variants 着色器变体

着色器的检视器中,Compile and show code / Show ,点开文件后,可以看到每个通道的着色器变体。

对于点光源和方向光有不同的代码,添加两个变体:
#pragma multi_compile DIRECTIONAL POINT
multi_compile伪指令,定义了一个关键词列表。

使用关键词

首先区分关键词和着色器变体的区别

  • 着色器变体:关键词数量的排列组合

回到光的方向:上面说了,点光源(聚光灯)和方向光有不同的代码
这时,就需要应用关键词,如下

#if defined(POINT)|| defined(SPOT)
	light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
	light.dir = _WorldSpaceLightPos0.xyz;
#endif

Cookies 遮罩

Spotlight Cookies 聚光灯遮罩

聚光灯遮罩

遮罩的Alpha用于遮罩光线,其他通道无关紧要。

导入纹理后,选择Cookie作为类型,并设置光照类型。

平行光的遮罩

平行光也可以使用遮罩,但是遮罩图片必须是tiling平铺的。
其关键词为DIRECTION_COOKIE

点光源的遮罩

点光源的遮罩需要立方体贴图来实现
关键词为POINT_COOKIE
立方体贴图

简化编译

将multi_compile POINT DIRECTION SPOT POINT_COOKIE DIRECTION_COOKIE简化为multi_compile_fwdadd

Vertex Lights 顶点光源

逐顶点光照意味着在顶点着色器中执行光照计算,得到的颜色被插值传到片段着色器。
顶点光源只支持点光源,着色器变体为VERTEXLIGHT_ON,在基础通道中添加编译指令:
#pragam multi_compile _ VERTEXLIGHT_ON

一个顶点光源

因为要从顶点着色器传到片元着色器,所以将光照颜色存在插值里,即

 float4 pos : POSITION;
 float4 uv : TEXCOORD0; //主uv 和 细节uv
 float3 normal : TEXCOORD1; //第二套纹理
 float3 worldPos : TEXCOORD2;
#if defined(VERTEXLIGHT_ON)
    float3 vertexLightColor : TEXCOORD3;
#endif

创建函数

用单独的函数计算颜色,并且需要读取和写入插值器,所以用inout,如下:
void ComputeVertexLightColor(inout Interpolator i){}
并在顶点着色器中进行调用。

在有顶点光源时,进行赋值

#if defined(VERTEXLIGHT_ON)
	i.vertexLightColor = unity_LightColor[0].rgb;
#endif

unity_LightColor[0]是一个顶点光照颜色数组

叠加颜色

在片元着色器中,将改颜色叠加到所有其他光源上:通过将顶点光源的颜色视为间接光实现。

UnityIndirect CreateIndirectLight(Interpolators i,)
{
    UnityIndirect indirectLight;
    indirectLight.diffuse = 0;
    indirectLight.specular = 0;
    
#if defined(VERTEXTLIGHT_ON)
    indirectLight.diffuse = i.vertexLightColor;
#endif
	return indirectLight;
}

获得光源位置

Unity最多支持4个顶点光源,位置存在四个float4中,每个坐标对应一个变量,分别是unity_4LightPosX0, unity_4LightPosY0和unity_4LightPosZ0为第一个顶点光源的(x,y,z)。
float3 lightPos = float3(unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x);

计算光照向量、方向及ndotl

ndotl代表法线与光线方向的点积

float3 lightVec = lightPos - i.worldPos;
float3 lightDir = normalize(lightVec);
float ndotl = DotClamped(i.normal, lightDir);
float attenuation = 1 / (1 + dot(lightVec, lightVec) * unity_4LightAtten0.x);
i.vertexLightColor = unity_LightColor[0].rgb * ndotl * attenuation;

unity_4LightAtten0包含近似像素光源衰减的因子。

四个顶点光源

依旧Unity帮你做了——

    i.vertexLightColor = Shade4PointLights(
		unity_4LightPosX0,unity_4LightPosY0, unity_4LightPosZ0,//位置
		unity_LightColor[0].rgb, unity_LightColor[1].rgb,
		unity_LightColor[2].rgb, unity_LightColor[3].rgb,//灯光颜色
		unity_4LightAtten0, i.worldPos, i.normal//衰减系数,顶点位置,顶点法线
);

然后把上面一个顶点光源的东西删掉吧,虽然有点可惜,但理解就好。

More Lights — Spherical Harmonics更多的光源—球谐函数

所有的像素光源(1个)和顶点光源(4个)都用尽时,使用球谐函数,适用于所有三种类型的光源。

球谐函数,是一种3D空间的傅里叶级数。

教程的原理讲解我看不懂,仅将代码复制到笔记里:
创造间接光函数中

#if defined(FORWARD_BASE_PASS)
    indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
#endif

ShaderSH9是一个基于球谐函数计算光照的函数

在ForwardBase里#define FORWARD_BASE_PASS

Skybox 天空盒

Unity会使用球谐函数近似天空盒。有球谐函数的贡献,物体更接近环境光颜色。


Bumpiness--Normal Map 凹凸感--法线贴图

Bump Mapping 凹凸贴图

处于看到的效果更清晰的目的,我们将天空盒的强度调为0。

高度图

将高度数据存储在纹理中,可以据此为每个片元生成法向量。称为凹凸映射

![[Pasted image 20260908122414.png|139]]
大理石的高度贴图

下面,开始把高度贴图应用起来。

在属性中,添加_HeightMap ("Heights",2D) = "gray"{}。由于使用相同的UV,所以无需单独的缩放与偏移,所以在前面添加[NoScaleOffset]进行限制。

在通道中,进行sampler2D _HeightMap;采样.

初始化片元着色器的法线

为实现效果,新建一个函数void InitializeFragmentNormal(inout Interpolators i){},并在片元着色器的开头进行引用

有限差分

法线由切线算出,切线由导数求得,导数由极限得到。
详情请参考高等数学。

求极限

在通道中,添加属性float4 _HeightMap_TexelSize;
带_TexelSize后缀的属性存储(纹素在u方向的尺寸,纹素在v方向的尺寸,u方向像素的数量,v方向像素的数量),如(0.00390625, 0.0078125, 256, 128).

算出x方向的导数

float3 delta = float2(_HeightMap_TexelSize.x,0);
float h1 = tex2D(_HeightMap,i.uv);
float h2 = tex2D(_HeightMap,i.uv + delta);
i.normal = float3(delta.x, h2 - h1, 0);

注:此时的i.normal存储的是切线,而非法线。

效果十分明显。将高度值进行任意比例的缩放,把delta.x变成1后,效果相对更平了。

i.normal = float3(h1 - h2, 1, 0);中i.normal存储的就是法线了,光照因此而正确。

中心差分

就是将采样点同时向两个方向偏移,计算的方法一样,但是效果更正确

float3 delta = float2(_HeightMap_TexelSize.x * 0.5,0);
float h1 = tex2D(_HeightMap,i.uv - delta);
float h2 = tex2D(_HeightMap,i.uv + delta);
i.normal = float3(h1 - h2, 1, 0);

同时使用两个维度

x,y都进行计算,如下

float2 du = float2(_HeightMap_TexelSize.x * 0.5,0);
float u1 = tex2D(_HeightMap,i.uv - delta);
float u2 = tex2D(_HeightMap,i.uv + delta);

float2 dv = float2(0, _HeightMap_TexelSize.y * 0.5);
float v1 = tex2D(_HeightMap,i.uv - delta);
float v2 = tex2D(_HeightMap,i.uv + delta);
i.normal = float3(u1 - u2, 1, v1 - v2);

得到法线

Normal Mapping 法线贴图

图片类型设置为Normal map后,自动变成法线贴图

采样法线贴图

法线贴图和高度贴图有很大不同,代码不能复用。
这里采用法线贴图后,将高度贴图的相关代码删掉。
部分相同的部分直接贴代码,文字说明略。

[NoScaleOffset] _NormalMap ("Normals", 2D) = "bump" {}

sampler2D _NormalMap

void InitializeFragmentNormal(inout Interpolator i){
	i.normal = tex2D(_NormalMap, i.uv).xyz * 2 - 1;
	i.normal = i.normal.xzy;
}

DXT5nm

DXT5nm是一种用于压缩法线贴图的纹理格式
仅存储法线的x,y分量,x在A通道中,y在G通道中。
上面的代码如果是RGBA格式,则正确;但对于DXT5nm格式,需要更改:

i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));
i.normal = i.normal.xzy;

将凹凸进行缩放

地形有起伏,可能大,可能小,我们新加一个属性,用来改变地形的起伏
_BumpScale ("Bump Scale" Float) = 1
float _BumpScale;

i.normal.xy = tex2D(_NormalMap, i.uv).wy * 2 - 1;
i.normal.xy *= _BumpScale;
i.normal.z = sqrt(1 - saturate(dot(i.normal.xy, i.normal.xy)));
i.normal = i.normal.xzy;

来让Unity帮忙后:

i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv), _BumpScale);
i.normal = i.normal.xzy;

将漫反射贴图和法线贴图贴在物体上

Bump Details 细节的凹凸效果

用法线贴图实现带有细节的着色器

  • 添加细节属性
    _DetailTex ("Detail Texture", 2D) = "gray"{}
  • 在插值的UV,从float2改为float4,主纹理放xy,细节纹理放zw
  • 添加变量sampler2D DetailTex,float4 DetailTex_ST;
  • 在顶点着色器中对UV赋值
  • 把所有原来i.uv都换成i.uv.xy
  • 将细节纹理分解到漫反射贴图中albedo *= tex2D(_DetailTex, i.uv.zw) * unity_ColorSpaceDoule

细节法线

一样的,不文字说明了。

[NoScaleOffset] _DetailNormalMap ("Detail Normals", 2D) = "bump" {}
_DetailBumpScale ("Detail Bump Scale", Float) = 1
sampler2D _NormalMap, _DetailNormalMap;
float _BumpScale, _DetailBumpScale;

…

void InitializeFragmentNormal(inout Interpolators i) {
	i.normal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	i.normal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

法线混合

两个高度场相加,导数也相加,最终的法线等于两个纹理的导数相加。

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal = UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	i.normal = float3(mainNormal.xy / mainNormal.z +detailNormal.xy / detailNormal.z, 1);
	i.normal = i.normal.xzy;
	i.normal = normalize(i.normal);
}

另一种混合方法——Whiteout Blending
在陡峭的破面上有更明显的起伏
i.normal = float3(mainNormal.xy + detialNormal.xy, mainNormal.z * detailNormal.z);

方法函数

i.normal = BlendNormals(mainNormal. detailNormal);
i.normal = i.normal.xzy;

Tangent Space 切线空间

以上的代码,只能对xz平行的平面进行着色。
现在,要适用于任意形状的几何体。

已知法向n,再求切线t,二者叉乘得到副法线向量b,第四个值为1/-1,判断正反,得到TBN空间,可以随位置变化而纹理不变。

可视化切线空间

创建一个组件,可视化切线空间

using UnityEngine;

public class TangentSpaceVisualizer : MonoBehaviour
{
	void OnDrawGizmos (){
	}
}

从网格过滤器中获取网格,并用其显示切空间

MeshFilter filter = GetComponent<MeshFilter>();
if(filter){
	Mesh mesh = filter.sharedMesh;
	if(mesh){
		ShowTangentSpace(mesh);
	}
}

mesh会创建一个副本

接下来是显示切线空间的函数
先只显示法线

void ShowTangentSpace(Mesh mesh){
	Vector3[] vertices = mesh.vertices;
	Vector3[] normals = mesh.normals;
	for (int i = 0; i < vertices.Length; i++) {
			ShowTangentSpace(			transform.TransformPoint(vertices[i]),				transform.TransformDirection(normals[i]));
		}
}

void ShowTangentSpace (Vector3 vertex, Vector3 normal) {
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal);
	}

线段的长度可以进行调配

public float offset = 0.01f;
public float scale = 0.1f;

void ShowTangentSpace(Vector3 vertex, Vector3 normal){
	vertex +=normal * offset;
	Gizmos.color = Color.green;
	Gizmos.DrawLine(vertex, vertex + normal * scale);
}

经过偏移和缩放,可以改变线段的长度

切向量和副切线向量如下

void ShowTangentSpace (Mesh mesh) {
	Vector3[] vertices = mesh.vertices;
	Vector3[] normals = mesh.normals;
	Vector4[] tangents = mesh.tangents;
	for (int i = 0; i < vertices.Length; i++) {
		ShowTangentSpace(
		transform.TransformPoint(vertices[i]),			transform.TransformDirection(normals[i]),			transform.TransformDirection(tangents[i]), tangents[i].w
			);
		}
	}

	void ShowTangentSpace (Vector3 vertex, Vector3 normal, Vector3 tangent, float binormalSign) {
		vertex += normal * offset;
		Gizmos.color = Color.green;
		Gizmos.DrawLine(vertex, vertex + normal * scale);
		Gizmos.color = Color.red;
		Gizmos.DrawLine(vertex, vertex + tangent * scale);
		Vector3 binormal = Vector3.Cross(normal, tangent);
		Gizmos.color = Color.blue;
		Gizmos.DrawLine(vertex, vertex + binormal * scale);
	}

切线能直接取到,副法线叉乘就能得到

着色器中的切线空间

和上面差不多,由于要取切线,所以在VertexData结构体中要添加float4 tangent : TANGENT;
而在插值中要取到切线,所以在Interpolators添加float4 tangent : TEXCOORD2;之后的纹理依次加一

顶点着色器中,进行赋值
i.tnagent = float4(UnityObjectToWorldDir(v.tangent.xyz, v.tangent.w));
UnityObjectToWorldDir()由对象到世界的方向变换

在IntializeFragmentNormal中构造副法线,并归一化

void InitializeFragmentNormal(inout Interpolators i) {
	float3 mainNormal =
		UnpackScaleNormal(tex2D(_NormalMap, i.uv.xy), _BumpScale);
	float3 detailNormal =
		UnpackScaleNormal(tex2D(_DetailNormalMap, i.uv.zw), _DetailBumpScale);
	float3 tangentSpaceNormal = BlendNormals(mainNormal, detailNormal);
	tangentSpaceNormal = tangentSpaceNormal.xzy;

	float3 binormal = cross(i.normal, i.tangent.xyz) * (i.tangent.w * unity_WorldTransformParams.w);
	i.normal = normalize(
		tangentSpaceNormal.x * i.tangent +
		tangentSpaceNormal.y * i.normal +
		tangentSpaceNormal.z * binormal
	);
}

i.tangent.w修正模型自身的镜像
unity_WorldTransformParams.w为1/-1,修正世界空间的镜像

同步切线空间

  • 法线贴图由烘焙得到,法线由世界空间转换为切线空间
  • 游戏里渲染时,把法线贴图从切线空间转换为世界空间
    注:若烘焙法线贴图时使用的切线空间算法,和 Unity 导入模型时使用的算法,以及 Shader 中使用的算法三者不一致,那么最终显示的法线就会是错的,导致光照错误、扭曲。所以,这三者必须“同步”(Synched)。

每个顶点或片元的副法线

上面的代码并没有与Unity保持一致。
要保持一致,必须在每个顶点上计算副法线。
我们现在修改以下Shader,以支持两种方法

在Interpolators中:

#if defined(BINORMAL_PER_FRAGMENT)
	float4 tangent : TEXCOORD2;
#else
	float3 tangent : TEXCOORD2;
	float3 binormal : TEXCOORD3;
#endif

片元着色器中

#if defined(BINORMAL_PER_FRAGMENT)
	i.tangent = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
#else
	i.tangent = UnityObjectToWorldDir(v.tangent.xyz);
	i.binormal = CreateBinormal(i.normal, i.tangent, v.tangent.w);
#endif

CreateBinormal()函数的代码如下:

float3 CreateBinormal (float3 normal, float3 tangent, float binormalSign) {
return cross(normal, tangent.xyz) *
	(binormalSign * unity_WorldTransformParams.w);
}

InitializeFragmentNormal中

#if defined(BINORMAL_PER_FRAGMENT)
	float3 binormal = CreateBinormal(i.normal, i.tangent.xyz, i.tangent.w);
#else
	float3 binormal = i.binormal;
#endif

最后在Shader里,SubShader前,对其定义
因为在子着色器前,所以基础通道和叠加通道都能使用。

CGINCLUDE

#define BINORMAL_PER_FRAGMENT

ENDCG

总的来说,无论是在片元着色器进行计算(原),还是直接放在插值器中(新),两者原理相同,解释说明就作省略处理


Shadows 阴影

直接光产生阴影,所以这部分是在创建PBS方法函数中的直接光

Direction Shadows 方向光阴影

启用阴影

Edit / Project Settings / Quality / Shadows
Shadows 设置 Hard and Soft Shadows,支持 硬阴影和软阴影
Shadow Resolution 决定 阴影贴图最高清晰度上限
Shadow Projection :Stable fit 稳定适配/ Close fit 紧密适配
Shadow Distance 摄像机视距
Shadow Cascades 阴影级联 :阴影贴图的mipmap(效果类似)

将灯光设置成 Soft Shadows

实现阴影映射

Unity采用阴影贴图实现实时阴影

阴影被使用时,Draw Call 明显增多,性能开销变大。阴影相当消耗性能

原理

  • 将场景中的物体渲染到 深度纹理
    对应于裁剪空间的z坐标,深度范围[0, 1]

  • 依次渲染光源的阴影贴图
    从光源的视角进行深度信息的渲染
    但是Direction lights没有位置坐标:Unity自动调整平行光位置,使其能够看到摄像机视野的所有对象
    注:由于四层级联,所以每个光源都要渲染四次

  • 将两张存有深度信息的贴图分别从两个视角比较深度,如果两张纹理都有某个点,则被照亮;反之,被遮挡,存在阴影。

  • 像素最终接受到的光照颜色,会乘以阴影贴图的衰减因子,范围[0,1],以显示明暗

阴影质量问题

摄像机视角的深度纹理和阴影贴图的纹素并不一致:当阴影贴图的纹素>深度纹理的纹素(即阴影贴图的分辨率低于屏幕分辨率)会出现锯齿现象(尤其硬阴影)

  1. 调整阴影距离
  • 问题:近处阴影纹素过大
  • 解决:缩小阴影距离,让阴影贴图只覆盖靠近摄像机的区域。
  • 效果:近处阴影质量提升,但远处阴影丢失(超出距离的物体不再投射阴影)
  1. 使用纹理级联
  • 原理:将阴影贴图分成多个级联(Cascade),每个级联覆盖不同的距离范围。
    • 近处级联:覆盖范围小,分辨率高(阴影清晰)。
    • 远处级联:覆盖范围大,分辨率低(节省性能)。
  • 效果:在不增加阴影贴图总分辨率的前提下,让近处阴影更清晰,同时保留远处阴影。
  • 代价:需要额外渲染场景多次(4 级联 = 额外渲染 3 次)。

阴影投影模式:Stable Fit && Close Fit

模式 特点 缺点
Stable Fit(默认) 级联带基于与摄像机位置的距离划分,阴影稳定。 阴影贴图利用率略低。
Close Fit 级联带基于摄像机深度划分,阴影贴图利用率更高,画质更好。 摄像机移动时,阴影纹素会移动,产生阴影边缘漂移(Shadow Edge Swimming)。

可视化级联:Scene视图上方 Debug Draw Mode / Lighting / Shadow Cascades,Stable模式呈球形,Close模式呈矩形

Shadow Acne 阴影痤疮

阴影痤疮是指物体表面出现条纹状或斑点状的黑色阴影

原因
  1. 纹素覆盖范围与方向不匹配
  • 纹素覆盖的区域穿过表面时,部分纹素凸出表面,导致”自己给自己投影阴影“
  1. 数值精度限制
  • 光源到表面距离非常小时,深度值的微小误差导致错误的深度比较结果
解决方法:深度偏移(Depth Bias)

渲染阴影贴图时,在光源与表面距离上添加微小的偏移,将阴影“推入”表面内部,使表面在深度比较时不会错误地判定自己被遮挡。

Depth Bias 在光源空间深度上增加偏移,将阴影推入表面。
Normal Bias 沿表面法线方向偏移,减少纹素“戳出”表面的情况。

投射阴影

现在将自己的shader加上投射阴影和接收阴影的功能

阴影通道

阴影通道只关注深度值,与之前的通道代码不一样。
先把通道设置好

Pass {
		Tags {
			"LightMode" = "ShadowCaster"
		}

		CGPROGRAM

		#pragma target 3.0

		#pragma vertex MyShadowVertexProgram
		#pragma fragment MyShadowFragmentProgram

		#include "My Shadows.cginc"

		ENDCG
	}

只有光照模式设置为ShadowCaster需要注意

然后再把My Shadows.cginc头文件里的基本东西弄好

#if !defined(MY_SHADOWS_INCLUDED)
#define MY_SHADOWS_INCLUDED

#include "UnityCG.cginc"

struct VertexData {
	float4 position : POSITION;
};

float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
	return UnityObjectToClipPos(v.position);
}

half4 MyShadowFragmentProgram () : SV_TARGET {
	return 0;
}

#endif

这样已经够能进行投射阴影了

接下来就是一些可更改功能的实现了

阴影偏移

  1. 对于Bias:
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
	float4 position = UnityObjectToClipPos(v.position);
	return UnityApplyLinearShadowBias(position);
}
  1. 对于Normal Bias
struct VertexData {
	float4 position : POSITION;
	float3 normal : NORMAL;
};

float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
	float4 position = UnityClipSpaceShadowCasterPos(v.position.xyz, v.normal);
	return UnityApplyLinearShadowBias(position);
}

Receiving Shadows 接收阴影

主光源

先在基础通道进行接收。
当主方向光投射阴影时,Unity 会查找一个启用了SHADOWS_SCREEN关键字的着色器变体(必须叫SHADOWS_SCREEN):
#pragma multi_compile _ SHADOWS_SCREEN

启用阴影后UNITY_LIGHT_ATTENUATION的行为有所不同,需要进行部分修改:

	#if defined(SHADOWS_SCREEN)
		float attenuation = 1;
	#else
		UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
	#endif

采样阴影贴图

采样阴影贴图<-屏幕空间的纹理坐标。要获得屏幕空间的纹理坐标,可以首先获得裁剪空间的位置

struct Interpolators {
	…
	#if defined(SHADOWS_SCREEN)
		float4 shadowCoordinates : TEXCOORD5;
	#endif

	#if defined(VERTEXLIGHT_ON)
		float3 vertexLightColor : TEXCOORD6;
	#endif
};
…
Interpolators MyVertexProgram (VertexData v) {
	…
	#if defined(SHADOWS_SCREEN)
		i.shadowCoordinates = i.position;
	#endif

	ComputeVertexLightColor(i);
	return i;
}

通过访问阴影贴图_ShadowMapTexture,使用裁剪空间的xy坐标进行采样

UnityLight CreateLight (Interpolators i) {
	…
	#if defined(SHADOWS_SCREEN)
		float attenuation = tex2D(_ShadowMapTexture, i.shadowCoordinates.xy);
	#else
		UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
	#endif
	…
}

现在是不正确的,因为采样使用的是齐次裁剪空间坐标,而非屏幕空间坐标。现在进行坐标的转换,通过放缩和偏移。
由坐标范围[-w,w] -> [0,1]、顶点坐标(-w,-w) -> (0,0)

#if defined(SHADOWS_SCREEN)
	i.shadowCoordinates.xy = (i.position.xy + i.position.w) * 0.5 ;
	i.shadowCoordinates.zw = i.position.zw;
	#endif

再在片元着色器中,进行透视投影

UnityLight CreateLight (Interpolators i) {
	…
	#if defined(SHADOWS_SCREEN)
		float attenuation = tex2D(
			_ShadowMapTexture,
			i.shadowCoordinates.xy / i.shadowCoordinates.w
		);
	#else
		UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
	#endif

	…
}

若阴影上下颠倒:i.shadowCoordinates.xy = (float2(i.position.x, -i.position.y) + i.position.w) * 0.5;

步骤 操作 范围变化
原始裁剪空间 xy — [-w, w]
齐次屏幕坐标 — [0, w]
透视除法(在片元着色器中) xy / w [0, 1](最终屏幕 UV)

在Unity里,依旧方法函数

#if defined(SHADOWS_SCREEN)
		i.shadowCoordinates = ComputeScreenPos(i.position);
	#endif

其实插值中的shadowCoordinates也可以被宏代替:

struct Interpolators {
	…
	SHADOW_COORDS(5)
	…
};

5代表TEXCOORD5

在顶点着色器中也用宏代替曾经的赋值

Interpolators MyVertexProgram (VertexData v) {
	…
//	#if defined(SHADOWS_SCREEN)
//		i.shadowCoordinates = i.position;
//	#endif
	TRANSFER_SHADOW(i);
	…
}

最后,对于应用于片元着色器中的直接光

#if defined(SHADOWS_SCREEN)
	float attenuation = SHADOW_ATTENUATION(i);
#else
	UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif

实际上,UNITY_LIGHT_ATTENUATION已经使用了SHADOW_ATTENUATION,还记得吗,UNITY_LIGHT_ATTENUATION的第二个参数是有关阴影的,这时就该派上用场了:UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);即可

可恶,编译错误。Unity的宏对顶点名称做了假设,所以要修改属性名称

struct VertexData {
	float4 vertex : POSITION;
	…
};

struct Interpolators {
	float4 pos : SV_POSITION;
	…
};

其他使用的部分也要修改哦

多重阴影

上面只进行了主方向光的实现,要适用于所有光,需要在加法通道定义SHADOWS_SCREEN。但是!其实只需要把多编译语句加上包含阴影的语句即可#pragma multi_compile_fwdadd_fullshadows
_fullshadows生成带阴影的额外光源变体

Spotlight Shadows 聚光灯阴影

聚光灯与平行光不同的地方在于聚光灯有光源坐标,不支持阴影级联

Point Light Shadows 点光源阴影

头文件引用问题

AutoLight.cginc中的某些代码(如 UnityShadowLibrary)依赖 UnityCG.cginc 中定义的函数(如 UnityDecodeCubeShadowDepth),但 AutoLight.cginc 没有在文件内部显式地 #include "UnityCG.cginc"。
所以要在AutoLight.cginc前放#include "UnityPBSLighting.cginc"。

投射阴影

点光源使用深度立方体贴图,我们需要自力更生,把它做出来。

在渲染立方体阴影贴图时,Unity查找SHADOWS_CUBE;
渲染2D深度贴图时,查找SHADOWS_DEPTH
所以,在阴影通道进行预编译#pragma multi_compile_shadowcaster

点光源需要采用不同的方式,以渲染深度立方体贴图,如下

#if defined(SHADOWS_CUBE)
	struct Interpolators {
		float4 position : SV_POSITION;
		float3 lightVec : TEXCOORD0;
	};

	Interpolators MyShadowVertexProgram (VertexData v) {
		Interpolators i;
		i.position = UnityObjectToClipPos(v.position);
		i.lightVec =
			mul(unity_ObjectToWorld, v.position).xyz - _LightPositionRange.xyz;
		return i;
	}
	
	float4 MyShadowFragmentProgram (Interpolators i) : SV_TARGET {
		float depth = length(i.lightVec) + unity_LightShadowBias.x;//距离+偏移
		depth *= _LightPositionRange.w;//归一化
		return UnityEncodeCubeShadowDepth(depth);
	}
#else
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
		float4 position =
UnityClipSpaceShadowCasterPos(v.position.xyz, v.normal);
		return UnityApplyLinearShadowBias(position);
	}

	half4 MyShadowFragmentProgram () : SV_TARGET {
		return 0;
	}
#endif
……

Reflection 反射

实现PBS函数的间接光

Environment Mapping 环境映射

目前的材质,在金属材质,且高光滑度时,小球会像镜子一样反射所有光线,应该映出天空盒及周围物体。
但是现在只镜面反射了高光!

为了反射环境,必须加入间接光照

之前的CreateIndirectLight()函数就是间接光的相关函数
UnityIndirect就是环境光的类型,有两个参数

  • diffuse 间接漫反射光照:环境光 + 光照探针
  • specular 间接镜面反射光照:反射探针

采样天空盒

采样天空盒,即采样天空盒立方体贴图。
采样反射探针的立方体贴图,使用UNITY_SAMPLE_TEXCUBE(unity_SpecCube0,反射方向向量),
unity_SpecCube0存储探针数据(立方体贴图),unity_SpecCube0存储次探针数据(用于混合)。
返回值需要用DecodeHDR由HDR解码为RGB

#if defined(FORWARD_BASE_PASS)
	indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
	float4 envSample = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, i.normal);
	indirectLight.specular = DecodeHDR(envSample, unity_SpecCube0_HDR);
#endif

追踪反射

上面的代码在反射方向向量使用的是法线向量,实际上应该是摄像机到表面的向量经过反射得到的反射向量:

float3 reflectionDir = reflect(-viewDir, i.normal);
float4 envSample = UNITY_SAMPLE_TEXCUBE(unity_SpecCube0, reflectionDir);

由于是片元着色器的函数,而viewDir已经在片元着色器中算出来了,所以直接传过来就好。

采样几何体

采样天空盒后,把目光看向真实的场景上,我们该怎么反射这些几何体——使用反射探针
创建:GameObject / Light / Reflection Probe

在Game视图 和 Scene视图右上角的Gizmos选项,可以开启或关闭某些组件在视图中的显示
如ReflectionProbe,如果遮挡实现可以将其关闭

反射探针分为两种模式

  1. Backed需要每次烘焙才能更新立方体贴图,且只渲染静态物体,所以将所有需要反射的物体设置为Static。
  2. Realtime实时渲染模式,可以自定义多久进行一次渲染,性能成本高
  3. Custom自定义模式

Static静态也可以进行选择,现在只需要Reflection Probe Static即可

Blurred Reflections 模糊的反射

光滑的表面才能产生极其清晰的反射。表面越粗糙,其反射就越发散。哑光镜面会产生模糊的反射。接下来要实现模糊的反射

粗糙的镜面

还是要使用mipmap相关原理,UNITY_SAMPLE_TEXCUBE_LOD(unity_SpecCube0,反射方向向量,lod)是用来采样不同模糊程度的反射探针。
与原来相比多的_LOD代表细节级别,明显的与mipmap有相关。
lod的范围在[0, UNITY_SPECCUBE_LOD_STEPS]

根据粗糙度,取不同的层级。
实际上,粗糙度与mipmap的关系:r = 1.7r - 0.7r²

float roughness = 1 - _Smoothness;
roughness *= 1.7 - 0.7 * roughness;
float4 envSample = UNITY_SAMPLE_TEXCUBE_LOD(
	unity_SpecCube0, reflectionDir, roughness * UNITY_SPECCUBE_LOD_STEPS
);

原理说明白了,现在把之前的相关代码要删掉,使用Unity的方法函数(但是已过时,所以不详细说明了(我没有实力,这只是跟着教程做的笔记))

Unity_GlossyEnvironmentData envData;
envData.roughness = 1 - _Smoothness;
envData.reflUVW = reflectionDir;
indirectLight.specular = Unity_GlossyEnvironment(
	UNITY_PASS_TEXCUBE(unity_SpecCube0), unity_SpecCube0_HDR, envData
);

凹凸镜面

用法线贴图实现

金属与非金属

调整金属度实现

Box Projection 盒体投影

由于探针只有一个,而为每个需要反射的物体创建一个探针。
在物体为小球时的效果可以,但是如果换成平面,就会出现缩放和偏移上的不正确,原因在于探针与物体的边缘过于遥远,不能反射出大概真实的现象。

反射探针盒

反射探针具有一个大小和一个探针原点,二者共同定义了一个以原点为中心的立方体区域。与世界坐标轴对齐,忽略旋转和缩放
作用:决定在区域内的渲染对象使用的探针+盒体投影

在Inspector的Reflection Probe模块,顶部有Probe Scene Editting Mode字样,前者用于调整区域,后者用于移动

开启Box Projection

调整样本方向

计算盒体投影,需要初始的反射方向、采样位置、立方体贴图中的位置以及盒体的边界。

float3 BoxProjection (float3 direction, float3 position,float3 cubemapPosition, float3 boxMin, float3 boxMax) {
	return direction;
}

在CreateIndirectLight()进行调用,所以盒体投影在间接光之前
envData.reflUVW = BoxProjection(reflectionDir, i.worldPos, unity_SpecCube0_ProbePosition, unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax );

最终的目标是让摄像机看到被反射的物体,返回的是反射方向,由于物体不能反射,反射的只能是探针,所以返回的反射方向是**探针指向被反射物体,然后将显示附在物体上

探针指向被反射物体向量 = 探针指向物体向量 + 反射到盒体边缘的向量
由于盒体投影的范围是立方体,所以反射到盒体边缘
物体到盒体边缘的“时间”由x,y,z轴在其方向上的“时间”,三者取最小。
可以得到:

float3 factors = ((direction > 0 ? boxMax : boxMin) - position) / direction;
float scalar = min(min(factors.x, factors.y), factors.z);
return direction * scalar + (position - cubemapPosition);

注:反射面不能超过探针的边界,否则会出现不太正确的反射

盒体投影不仅适用于平面,对在盒体内的多个物体也和多个独立探针的效果相近

可选投影

对于探针来说,可以选择是否用盒体投影;
不使用盒体投影,就是上述独立探针的形式, unity_SpecCube0_ProbePosition < 0
使用盒体投影,则 > 0

在代码上的修改很容易

float3 BoxProjection (float3 direction, float3 position,float4 cubemapPosition, float3 boxMin, float3 boxMax) {
	if (cubemapPosition.w > 0) {
		float3 factors =
			((direction > 0 ? boxMax : boxMin) - position) / direction;
		float scalar = min(min(factors.x, factors.y), factors.z);
		direction = direction * scalar + (position - cubemapPosition);
	}
	return direction;
}

当所有片元都采用相同的探针时,使用分支可能会提效
UNITY_BRANCH插入if前。但是,在着色器中应该尽量避免使用分支,因为可能会降效。
关于分支部分,现在没有具体深入

Blending Reflection Probes 反射探针混合

实现两个盒体投影的线性混合

插值探针

之前提过,unity_SpecCube0和unity_SpecCube1是Unity提供的主探针和副探针数据。
要将两个探针进行线性混合,需要知道他们都有什么数据:

envData.reflUVW = BoxProjection(
	reflectionDir, i.worldPos,
	unity_SpecCube0_ProbePosition,
	unity_SpecCube0_BoxMin, unity_SpecCube0_BoxMax
);
float3 probe0 = Unity_GlossyEnvironment(
	UNITY_PASS_TEXCUBE(unity_SpecCube0), unity_SpecCube0_HDR, envData
);
envData.reflUVW = BoxProjection(
	reflectionDir, i.worldPos,
	unity_SpecCube1_ProbePosition,
	unity_SpecCube1_BoxMin, unity_SpecCube1_BoxMax
);
float3 probe1 = Unity_GlossyEnvironment(
	UNITY_PASS_TEXCUBE_SAMPLER(unity_SpecCube1, unity_SpecCube0), unity_SpecCube1_HDR, envData
);
indirectLight.specular = lerp(probe1, probe0, unity_SpecCube0_BoxMin.w);

仔细看的画,会发现:上下不是完全一样的。
unity_SpecCube0有自己的纹理和采样器,unity_SpecCube1只有纹理,没有采样器。
所以使用 UNTIY_PASS_TEXCUBE_SAMPLER(unity_SpecCube1, unity_SpecCube0)将第二个探针的纹理与第一个探针的采样器“绑定”在一起。

Reflection Probes调成Blend Probes模式,将两个探针范围重合,才能使两个探针混合效果生效
如果调成Blend Probes And Skybox,效果就成了一个探针和天空盒混合了。

优化

仅在开启混合,并需要混合时混合

unity_SpecCube0_BoxMin.w存储主探针的占比

#if UNITY_SPECCUBE_BLENDING
	float interpolator = unity_SpecCube0_BoxMin.w;
	UNITY_BRANCH
	if (interpolator < 0.99999) {
		float3 probe1 = Unity_GlossyEnvironment(
			UNITY_PASS_TEXCUBE_SAMPLER(unity_SpecCube1, unity_SpecCube0),
			unity_SpecCube0_HDR, envData
		);
		indirectLight.specular = lerp(probe1, probe0, interpolator);
	}
	else {
		indirectLight.specular = probe0;
	}
#else
	indirectLight.specular = probe0;
#endif

仅在开启盒体投影时,盒体投影

float3 BoxProjection(float3 direction, float3 position, float4 cubemapPosition, float3 boxMin, float3 boxMax){                 
#if UNITY_SPECCUBE_BOX_PROJECTION    
    UNITY_BRANCH //使用分支
    if (cubemapPosition.w > 0)    {
        float3 factors = ((direction > 0 ? boxMax : boxMin) - position) / direction;
        float scalar = min(min(factors.x, factors.y), factors.z); 
        direction = direction * scalar + (position - cubemapPosition); 
    }
#endif
    return direction;
}

Bouncing Reflections 反弹的反射

无限的反射,镜子照镜子……

把地面镜设置为静态,在独立探针进行烘焙,就能反射出地面。

设置多次反射
Window > Rendering > Lighting > Environment > Environment Reflection > Bounces
注意:盒体投影不能多次反射哦


GUI

User Interface 用户界面

现在为自己的着色器创建一个自定义的检视器

ShaderGUI

GUI继承于UnityEditor.ShderGUI。
起名我们的GUI为MyLightingShaderGUI

using UnityEngine;
using UnityEditor;

public class MyLightingShaderGUI : ShaderGUI {
}

同时,如果要在某个shader中使用此GUI,必须添加CustomEditor发出的指令,后包含这此GUI的名称
e.g.

Shader "Custom/Physically-Based Shading"
	……
	CustomEditor "MyLightingShaderGUI"
}

然后,要对原有的检视器进行替换,需要重写(override)ShaderGUI.OnGUI方法。
其中包含两个参数

  • MaterialEditor : 用于管理当前选中材质的检视器
  • MaterialProperty[] : 包含材质属性的数组
    注意:ShaderGUI现在只用于Inspector面板,运行时的游戏UI会使用UI Toolkit
public override void OnGUI)(
	MaterialEditor editor, MaterialProperty[] properties
){}

创建标签

默认着色器的GUI分为两部分,主帖图和次贴图。
先实现主帖图部分及其标签

public override void OnGUI (
		MaterialEditor editor, MaterialProperty[] properties
	) {
		DoMain();
	}
	
	void DoMain() {
		GUILayout.Label("Main Maps");
	}

标签要显示粗体boldLabel,更明显
GUILayout.Label("Main Maps", EditorStyles.boldLabel);

显示反照率

MaterialEditor editor;
MaterialProperty[] properties;

public override void OnGUI (
	MaterialEditor editor, MaterialProperty[] properties
) {
	this.editor = editor;
	this.properties = properties;
	DoMain();
}

相信只要是上过面向对象的课的人都会明白为什么要这样做——让其他方法能对这两个属性进行调用

反照率 的显示需要三个部分:文字、贴图、染色

先在properties找到反照率
对于数组的索引,可以按我们在着色器中定义的属性位置,也可以通过名称查找
这里使用名称查找
MaterialProperty mainTex = FindProperty("_MainTex", properties);

接下来把albedo写在GUI上:
GUIContent albedoLabel = new GUIContent(mainTex.displayName);

加上备注GUIContent albedoLabel = new GUIContent(mainTex.displayName, "Albedo(RGB)");

前面加上纹理组件editor.TexturePropertySingleLine(albedoLabel, mainTex);

后面加上染色

MaterialProperty tint = FindProperty("_Tint", properties);
editor.TexturePropertySingleLine(albedoLabel, mainTex, tint)

TexturePropertySingleLine方法有多个重写,最多可作用于三个属性
三个属性分别为:
标签内容GUIContent
纹理属性MaterialProperty
额外属性1MaterialProperty
额外属性2MaterialProperty

显示完反照率后,我们跳到底部,将主纹理的平铺和偏移值显示出来
editor.TextureScaleOffsetProperty(mainTex);

便捷方法

所有的FindProperty都要两个参数,而且第二个参数都是properties,不如重载一下

MaterialProperties FindProperty(string name){
	return FindProperty(name, properties);
}

另外,文字和备注也可以简化:

static GUIContent staticLabel = new GUIContent();	

static GUIContent MakeLabel (string text, string tooltip = null) {
	staticLabel.text = text;
	staticLabel.tooltip = tooltip;
	return staticLabel;
}

如果要写文字而不对文字进行取值,直接塞到静态函数中,代码会更加简介

static GUIContent MakeLabel (
MaterialProperty property, string tooltip = null
) {
	staticLabel.text = property.displayName;
	staticLabel.tooltip = tooltip;
	return staticLabel;
}

记得要在前面的代码进行适当的修改哦

显示法线贴图

于显示反照率不同的只有把染色换成法线贴图,而TexturePropertySingleLine有这样的重写;

void DoNormals () {
	MaterialProperty map = FindProperty("_NormalMap");
	editor.TexturePropertySingleLine(MakeLabel(map), map, map.textureValue ? FindProperty("_BumpScale") : null);
}

如果有法线贴图那就取法线该帖图,反之null

显示金属度和光滑度

主要进行的是滑块slider的应用,以及缩放

以金属度为例:

void DoMetallic () {
		MaterialProperty slider = FindProperty("_Metallic");
		EditorGUI.indentLevel += 2;
		editor.ShaderProperty(slider, MakeLabel(slider));
		EditorGUI.indentLevel -= 2;
	}

次级纹理

与主纹理完全相同,不在多述

Mixing Metal and Nonmetal金属与非金属的混合

现在只能调整整体的金属都,但是像电路板这样的材质,金属与非金属共存,就不能外显。

金属贴图

用一张灰度贴图,标出每个像素的金属度。

我觉得在着色器中新增属性,在通道中添加对应的变量,然后创建一个函数,用于从金属贴图获取片段的金属度,这个流程很明确。
代码部分也很明显,只需要注意**采样时需要使用R通道

float GetMetallic (Interpolators i) {
	return tex2D(_MetallicMap, i.uv.xy).r * _Metallic;
}

自定义GUI

我们现在的GUI不能对金属贴图进行取值,所以要调整DoMetallic():取值金属贴图 + 在有金属度贴图时,不使用滑块。

void DoMetallic () {
	MaterialProperty map = FindProperty("_MetallicMap");
	editor.TexturePropertySingleLine(
		MakeLabel(map, "Metallic (R)"), map,
		 map.textureValue ? null :FindProperty("_Metallic")
	);
}

自定义着色器关键字

不使用金属度贴图时,使用均匀值
使用金属度时,均匀度将被忽略
所以要使用着色器变体来区分是否使用了金属贴图
现在,我们的着色器有很多变体,这是由multi_compile所致,而这些指令时Unity提供的关键字。而现在,我们要定义自己的着色体关键字。
本例中,定义为_METALLIC_MAP

要向材质添加自定义关键字,要在GUI中直接访问该材质。使用MaterialEditor.target属性来获取当前材质

GUI中,定义Material target;
OnGUI中,this.target = editor.target as Material;

对于as,是C#的转换运算符:尝试把左边转成右边类型,成功就返回转换后的引用,失败就返回 null。

向着色器添加关键字是通过Material.EnableKeyword()实现;而移除关键字则依靠Material.DisableKeyword()实现

void SetKeyword (string keyword, bool state) {
	if (state) {
		target.EnableKeyword(keyword);
	}
	else {
		target.DisableKeyword(keyword);
	}
}

现在可以在DoMetallic()中自定义_METALLIC_MAP关键字了
SetKeyword("_METALLIC_MAP", map.textureValue);

Debug Keyword

在Inspector右上角调成debug模式,所有自定义关键字在Valid Keyword 里

着色器功能

为了生成着色提变体,必须在着色器中添加多编译指令。分别在基础通道和叠加通道执行#pragma multi_compile _ _METALLIC_MAP

另一种方法是定义一个着色器特性#pragma shader_feature _ _METALLIC_MAP

两者区别在于后者仅在需要时才被编译

现在,可以对GetMetallic进行调整了

float GetMetallic (Interpolators i) {
	#if defined(_METALLIC_MAP)
		return tex2D(_MetallicMap, i.uv.xy).r;
	#else
		return _Metallic;
	#endif
}

仅在需要时设置关键词

目前,每次调用OnGUI时都会设置关键词。事实上,只需要在材质被编辑时才执行次操作
需要使用EditorGUI.BeginChangeCheck()和EditorGUI.EndChngeCheck(),分别用来开始监测和结束监测
用法如下:

void DoMetallic () {
		MaterialProperty map = FindProperty("_MetallicMap");
		EditorGUI.BeginChangeCheck();
		editor.TexturePropertySingleLine(
			MakeLabel(map, "Metallic (R)"), map,
			map.textureValue ? null : FindProperty("_Metallic")
		);
		if (EditorGUI.EndChangeCheck()) {
			SetKeyword("_METALLIC_MAP", map.textureValue);
		}
	}

Smoothness Maps 平滑度贴图

与金属贴图同理,都是采样灰度图。
但是,光滑度贴图一般取Alpha通道,所以将金属度贴图与光滑度贴图二者合并到同一张纹理上是可行的。Unity就是这样做的,把两张纹理压缩成一张DXT5纹理

确定光滑度

因为两张纹理合并在一起了,所以在着色器中获取光滑度的实现与获取金属度的实现相差无几,只要把R通道换成A通道就好

float GetSmoothness (Interpolators i) {
	#if defined(_METALLIC_MAP)
		return tex2D(_MetallicMap, i.uv.xy).a * _Smoothness;
	#else
		return _Smoothness;
	#endif
}

注意看看,此时PBS宏内对金属度的取值和对光滑度的取值有没有更改为目前修改后的函数

CreateInfirectLight()中也有对平滑度的应用

将平滑度与反照率结合

只需要平滑度而不需要金属度时,可以选择将平滑度贴图与反照率贴图相结合。
这样,着色器就支持将光滑度打包到金属贴图或反照率贴图中了。

在关键词中切换

由于要对平滑度的来源进行选择,所以要在GUI中添加这样的选项

可以定义枚举来实现:

enum SmoothnessSource {
	Uniform, Albedo, Metallic
}

当选择Albedo选项时,添加_SMOOTHNESS_ALBEDO关键字
选择Metallic选项时,添加_SMOOTHNESS_METALLIC关键字
选择Custom选项时,无关键词

在标准着色器中(写自定义GUI之前的检视器),变量target的所有内容可以被全部看到,其中包括着shader引用、属性值、Keywords……
因此,可以用target,IsKeywordEnabled()用来判断是否开启了某个关键字,再重写一下,得到

bool IsKeywordEnabled (string keyword) {
	return target.IsKeywordEnabled(keyword);
	}

然后,进行平滑度来源的选择

void DoSmoothness () {
	SmoothnessSource source = SmoothnessSource.Uniform;
	if (IsKeywordEnabled("_SMOOTHNESS_ALBEDO")) {
		source = SmoothnessSource.Albedo;
	}
	else if (IsKeywordEnabled("_SMOOTHNESS_METALLIC")) {
		source = SmoothnessSource.Metallic;
	}

	…
}

再在GUI中进行显示
EditorGUILayout.EnumPopup(MakeLabel("Source"), source);

在更改选择时,需要获知新的选项是什么,因此,要进行赋值与监视

EditorGUI.BeginChangeCheck();
source = (SmoothnessSource)EditorGUILayout.EnumPopup(
	MakeLabel("Source"), source
);
if (EditorGUI.EndChangeCheck()) {
	SetKeyword("_SMOOTHNESS_ALBEDO", source == SmoothnessSource.Albedo);
	SetKeyword("_SMOOTHNESS_METALLIC", source == SmoothnessSource.Metallic);
}

支持撤销

void RecordAction(string label)
{
	editor.RegisterPropertyChangeUndo(label);
}

MaterialEditor.RegisterPropertyChangeUndo()的作用是注册一个撤销操

if (EditorGUI.EndChangeCheck())
{
    sourceProp.floatValue = (float)source;

    RecordAction("Smoothness Source");
    SetKeyword("_SMOOTHNESS_ALBEDO", source == SmoothnessSource.Albedo);
    SetKeyword("_SMOOTHNESS_METALLIC", source == SmoothnessSource.Metallic);
}

RecordAction()在积极那个更改前进行调用,创建旧状态的快照,以便撤销

平滑度变体

在基础通道和叠加通道添加平滑度相关的变体
#pragma shader_feature _ _SMOOTHNESS_ALBEDO _SMOOTHNESS_METALLI

最后要进行平滑度的取值,
Custom取1;albedo取_MainTex;metallic取_MatallicMap

float GetSmoothness(Interpolators i)
{
    float smoothness = 1;
#if defined(_SMOOTHNESS_ALBEDO)
    smoothness = tex2D(_MainTex, i.uv.xy).a;
#elif defined(_SMOOTHNESS_METALLIC) && defined(_METALLIC_MAP)  
    smoothness = tex2D(_MetallicMap, i.uv.xy).a ;
#endif
    return smoothness * _Smoothness;

}

Emissive Surface 自发光表面

着色器部分

有的材质需要部分自发光,有的需要整体自发光,所以——需要自发光变量和自发光贴图
属性、变体不再叙述

float3 GetEmission(Interpolators i)
{
#if defined(FORWARD_BASE_PASS)
    #if defined(_EMISSION_MAP)
        return tex2D(_EmissionMap, i.uv.xy) * _Emission;
    #else
        return _Emission;
    #endif
#else
    return 0;
#endif
}

只在基础通道渲染一遍,防止出现光线的错误

最终应用于物体本身,所以再PBS宏计算完成后再加

float4 color = UNITY_BRDF_PBS(……);
color.rgb += GetEmiddion(i)l
return color;

GUI部分

处理金属度的方法复制一下,再改个备注和关键字即可

HDR Emission

标准着色器使用高动态范围颜色(HDR),意味着RGB可以大于1。

MaterialEditor.TexturePropertyWithHDRColor()为需要HDR的纹理设计。
它需要两个额外参数

  • 指定HDR范围的配置选项:
    由ColorPickerHDRConfig对象进行配置,亮度[0,99],曝光[0,3]
  • 是否显示Alpha通道
static ColorPickerHDRConfig emissionConfig = new ColorPickerHDRConfig(0f, 99f, 1 / 99f, 3f);
    
void DoEmission()
{
    MaterialProperty map = FindProperty("_EmissionMap");
    EditorGUI.BeginChangeCheck();
    editor.TexturePropertyWithHDRColor(
        MakeLabel(map, "Emission (RGB)"), map, FindProperty("_Emission"),
        emissionConfig,false
    );
    if (EditorGUI.EndChangeCheck())
        SetKeyword("_EMISSION_MAP", map.textureValue);
    }
}