0%

原文:
Postprocessing with the Depth Texture

在上一篇教程中,我介绍了简单后处理效果的实现过程。但是在实际应用中,我们经常需要使用深度图来实现一些更高级的后处理效果。深度图是从摄像机视角采集的记录场景深度信息的纹理图。

在理解如何借助深度图来实现复杂的后处理之前,建议你先阅读上一篇关于简单后处理效果的介绍。

Read Depth

这里我们沿用上一篇中实现的最简单的后处理脚本,然后在此基础上进行修改。

首先我们需要对后处理脚本进行扩展,保证后处理摄像机生成深度图,供这里的后处理使用。

1
2
3
4
private void Start(){
Camera cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.Depth;
}

上面对后处理脚本修改完成后,接下来我们要对后处理着色器进行修改。

为了在着色器中访问深度图,我们首先需要定义一个名叫_CameraDepthTexture的纹理,这个名字是Unity内置的。深度图的采样和其他纹理一样,我们可以将采样结果渲染到屏幕上,看看深度图到底长啥样。因为深度图只有一个值有效,所以在纹理中深度值是存储在R通道,我们可以直接进行访问。

1
2
//深度图
sampler2D _CameraDepthTexture;
1
2
3
4
5
6
7
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度采样
float depth = tex2D(_CameraDepthTexture, i.uv).r;

return depth;
}

这一切都准备好后,启动游戏,不过这时候屏幕上显示的很可能是一片黑。这是因为深度值得存储位数有限,为了扩大深度值得记录范围,同时保证近景的深度精度,所以采用非线性编码,其中距离摄像机越近的区域深度值得精度越高,反之越低。当你将摄像机靠近物体时,你可能观察到更亮的颜色,这表明这个区域理摄像机很近。如果你将摄像机不断靠近,画面依然很黑,这时候你可以尝试将摄像机的近平面调大一点。

前面的深度编码是考虑到存储的限制,而我们使用深度值之前必须对其进行解码。庆幸的是,Unity为我们提供了解码函数,解码后的深度值是线性的,范围在0-1之间,0表示在摄像机位置,1表示在远平面上。如果解码后的深度图显示除了天空盒区域是白色,其他地方基本是黑色,你可以将远平面调小,这样可以观察到更多的模型。

1
2
3
4
5
6
7
8
9
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度采样
float depth = tex2D(_CameraDepthTexture, i.uv).r;
//深度解码,解码后的深度值是线性的,范围0-1, 0为摄像机位置,1为远平面上
depth = Linear01Depth(depth);

return depth;
}

接下来的一步是基于摄像机参数,还原真实的深度值。这里有个_ProjectionParams是记录摄像机的投影参数,其中z值是远平面的大小。

1
2
3
4
5
6
7
8
9
10
11
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度采样
float depth = tex2D(_CameraDepthTexture, i.uv).r;
//深度值解码
depth = Linear01Depth(depth);
//还原深度值,得到点到摄像机的真实距离
depth = depth * _ProjectionParams.z;

return depth;
}

因为场景中绝大多数的模型到摄像机的距离都大于一个单位,所以还原后的深度图显示在屏幕上将会是纯白色,但是这个深度值是与远平面无关的真实深度值,是点到摄像机的距离。

Generate Wave

加下来我将基于这些信息来实现一种波效果,一种不断从玩家开始,向远处传播的效果。同时我们可以自定义某个时刻波距玩家的距离、以及波的拖尾长度、波的颜色。所以首先我们需要在着色器脚本中添加这些变量。这里我们使用Header属性标签来加粗标题,当然这只具有显示功能,不会影响着色器的实际使用。

1
2
3
4
5
6
7
8
//在编辑器上显示的属性
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
[Header(Wave)]
_WaveDistance ("Distance from player", float) = 10
_WaveTrail ("Length of the trail", Range(0,5)) = 1
_WaveColor ("Color", Color) = (1,0,0,1)
}
1
2
3
4
//HLSL 内定义的变量
float _WaveDistance;
float _WaveTrail;
float4 _WaveColor;

我们这个波的一头是突然截断、另一头是渐变的拖尾效果。我们首先实现这个突然截断的头部效果。在前面的教程中谈到过step这个函数可以实现跳变的效果。

1
2
3
4
 //计算波的头部
float waveFront = step(depth, _WaveDistance);

return waveFront;

然后我们再使用smoothstep函数来实现尾部渐变效果,这个函数和step函数类似,只不过它有三个参数。如果第三个参数小于第一个参,那么返回0,如果大于第二个参数,那么返回1,其他情况返回一个0-1的值。

1
2
float waveTrail = smoothstep(_WaveDistance - _WaveTrail, _WaveDistance, depth);
return waveTrail;

你可能注意到上面两个波效果刚好相反,这正是我们想要的效果。因为我们将这两个波值相乘后,只有中间很窄的区域会为1,其他位置都将是0。

1
2
3
4
5
6
//计算前后波
float waveFront = step(depth, _WaveDistance);
float waveTrail = smoothstep(_WaveDistance - _WaveTrail, _WaveDistance, depth);
float wave = waveFront * waveTrail;

return wave;

现在我们得到了想要的波,打算将其应用到最终的显示画面上。首先需要采集原始画面,然后和我们的波进行线性插值,插值的时候可以把我们的波颜色也应用上。

1
2
3
4
//和原图混合
fixed4 col = lerp(source, _WaveColor, wave);

return col;

上面的效果可以发现一些瑕疵,就是当波移动到远平面时,会突然高亮。虽然我们的天空盒就是在远平面处,但是我还是不想出现这种瑕疵。

要解决这个问题,我通过判断深度值是否达到远平面,如果达到,那么直接返回原始图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度采样
float depth = tex2D(_CameraDepthTexture, i.uv).r;
//深度解码
depth = Linear01Depth(depth);
//还原深度值
depth = depth * _ProjectionParams.z;

//原图采样
fixed4 source = tex2D(_MainTex, i.uv);
//当达到远平面时,直接返回原图
if(depth >= _ProjectionParams.z)
return source;

//计算波
float waveFront = step(depth, _WaveDistance);
float waveTrail = smoothstep(_WaveDistance - _WaveTrail, _WaveDistance, depth);
float wave = waveFront * waveTrail;

//波和原图混合
fixed4 col = lerp(source, _WaveColor, wave);

return col;
}

最后,我想扩展后处理脚本来实现自动设置波位置,并且让它缓慢远离摄像机。我想控制波速以及是否启用波后处理效果。所以我必须记住当前波的位置。下面是我添加的新变量。

1
2
3
4
5
6
[SerializeField]
private Material postprocessMaterial;
[SerializeField]
private float waveSpeed;
[SerializeField]
private bool waveActive;

然后我在后处理脚本中的Update函数中不断刷新波的位置。关闭波效将会重置波的位置,开启波效,波都会冲初始位置开始,慢慢的原理摄像机。

1
2
3
4
5
6
7
8
private void Update(){
//启用时会不断移动波,关闭时会重置波的位置
if(waveActive){
waveDistance = waveDistance + waveSpeed * Time.deltaTime;
} else {
waveDistance = 0;
}
}

然后我在OnRenderImage函数中的后处理操作执行之前,将波距离参数传递给着色器,保证每次渲染都是在正确的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
Shader "Tutorial/017_Depth_Postprocessing"{
//显示在编辑器上
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
[Header(Wave)]
_WaveDistance ("Distance from player", float) = 10
_WaveTrail ("Length of the trail", Range(0,5)) = 1
_WaveColor ("Color", Color) = (1,0,0,1)
}

SubShader{
// 关闭剔除
// 禁用深度缓存功能
Cull Off
ZWrite Off
ZTest Always

Pass{
CGPROGRAM
//引入内置函数和变量
#include "UnityCG.cginc"

//声明顶点、片段着色器
#pragma vertex vert
#pragma fragment frag

//用于后处理的原图
sampler2D _MainTex;

//深度图
sampler2D _CameraDepthTexture;

//波参数
float _WaveDistance;
float _WaveTrail;
float4 _WaveColor;


//模型网格数据
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间插值数据
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//计算裁剪坐标
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

//片段着色器,后处理操作主要是在这里执行
fixed4 frag(v2f i) : SV_TARGET{
//深度采样
float depth = tex2D(_CameraDepthTexture, i.uv).r;
//深度解码
depth = Linear01Depth(depth);
//深度还原
depth = depth * _ProjectionParams.z;

//原图采样
fixed4 source = tex2D(_MainTex, i.uv);
//当达到远平面时,直接返回原图
if(depth >= _ProjectionParams.z)
return source;

//计算波
float waveFront = step(depth, _WaveDistance);
float waveTrail = smoothstep(_WaveDistance - _WaveTrail, _WaveDistance, depth);
float wave = waveFront * waveTrail;

//原图与波混合
fixed4 col = lerp(source, _WaveColor, wave);

return col;
}
ENDCG
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using UnityEngine;

//该脚本需要和摄像机绑定在同一物体
public class DepthPostprocessing : MonoBehaviour {
//用于后处理的材质
[SerializeField]
private Material postprocessMaterial;
[SerializeField]
private float waveSpeed;
[SerializeField]
private bool waveActive;

private float waveDistance;

private void Start(){
//设置当前摄像机为深度采集模式
Camera cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.Depth;
}

private void Update(){
//启用时会不断移动波,关闭时会重置波的位置
if(waveActive){
waveDistance = waveDistance + waveSpeed * Time.deltaTime;
} else {
waveDistance = 0;
}
}

//当当前绑定的摄像机渲染完一帧画面后,会调用该函数
private void OnRenderImage(RenderTexture source, RenderTexture destination){
//同步当前波距到着色器
postprocessMaterial.SetFloat("_WaveDistance", waveDistance);
//将原图按照材质着色器脚本逻辑,写入到结果图
Graphics.Blit(source, destination, postprocessMaterial);
}
}

希望我的教程能够对你有所帮助。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/017_DepthPostprocessing/DepthPostprocessing.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/017_DepthPostprocessing/DepthPostprocessing.cs

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Postprocessing Basics

Summary

目前为止,我将所有我实现的着色器应用到了模型上,并将其渲染到屏幕上。着色器还有一个常用的用途就是处理图片、以及我们刚渲染好的上一帧画面。我们对前面渲染好的画面进行处理的操作就叫做后处理。

后处理所使用的着色器在语法和结构上和之间介绍的着色器一样。所以我建议你先了解前面关于着色器的基础教程

Postprocessing Shader

作为后处理的入门教程,这里我将展示如何实现简单的颜色取反效果。

因为整个脚本和其他着色器类似,所以我将直接使用着色器基础教程中的着色器脚本,并在此基础上进行修改。

当然,即便是最基础的着色器也有一些后处理用不到的变量,我们可以将它删除。例如这里的材质颜色、渲染标签Tags、纹理参数。

然后我们还需要添加一些东西,使得我们的着色器更适用于后处理。例如在后处理中所有的属性变量都是通过脚本赋值的,所以在属性块中可以加入属性隐藏标签,另外后处理操作不应该对影响场景深度图,所以应该禁用深度写入等功能。

基于上述修改,最终的着色器如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Shader "Tutorial/016_Postprocessing"{
//材质面板,这里所有属性都将隐藏
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
// 不需要背面剔除
// 禁用深度缓存
Cull Off
ZWrite Off
ZTest Always

Pass{
CGPROGRAM
//引入内置函数和变量
#include "UnityCG.cginc"

//声明顶点、片段着色器
#pragma vertex vert
#pragma fragment frag

//将要进行后处理的图片
sampler2D _MainTex;

//模型纹理数据,后处理会自动生成一个矩阵网格
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间插值数据
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//变换到裁剪空间,后处理的渲染过程和普通着色器类似
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

//片段着色器,后处理主要是在这里执行
fixed4 frag(v2f i) : SV_TARGET{
//原图片采样
fixed4 col = tex2D(_MainTex, i.uv);
return col;
}

ENDCG
}
}
}

Postprocessing C# Script

上面我们已经准备好了用于后处理的着色器,接下来我们要实现C#脚本,来控制后处理过程。摄像机在执行后处理的时候会用到这个脚本。

新建的脚本是一个脚本组件,只有一个函数OnRenderImage。这个函数有Unity在特定时间调用的。其中传递两个参数,一是后处理原图,一是后处理结果图。将一张图中的数据复制到另一张图,可以使用Blit函数。

1
2
3
4
5
6
7
8
9
10
11
using UnityEngine;

//该脚本需要和摄像机绑定在同一物体
public class Postprocessing : MonoBehaviour {

//当当前绑定的摄像机渲染完一帧画面后,会调用该函数
void OnRenderImage(RenderTexture source, RenderTexture destination){
//将原图写入到结果图
Graphics.Blit(source, destination);
}
}

到目前为止,整个后处理逻辑并不会产生什么特别的效果,因为从原图到结果图没有执行任何操作。我们可以再传第三个材质参数,那么在结果图写入前会执行该材质中的着色器脚本。所以这里我们给这个后处理脚本增加一个材质属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;

//该脚本需要和摄像机绑定在同一物体
public class Postprocessing : MonoBehaviour {
//用于后处理的材质
[SerializeField]
private Material postprocessMaterial;

//当当前绑定的摄像机渲染完一帧画面后,会调用该函数
void OnRenderImage(RenderTexture source, RenderTexture destination){
//将原图按照材质着色器脚本逻辑,写入到结果图
Graphics.Blit(source, destination, postprocessMaterial);
}
}

当前面准备好后,我们创建需要的材质球,然后将该材质球和我们的后处理着色器绑定。

然后将我们的后处理脚本绑定到我们的摄像机物体上,并且将前面的材质球赋值给这个后处理组件。

Negative Colors Effect

做好这一切后,运行程序发现好像没啥特别的变化。要实现颜色取反的效果,我们还需要重新回到我们的后处理着色器中,在片段着色器函数中执行颜色取反的操作。

1
2
3
4
5
6
7
8
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//原图采样
fixed4 col = tex2D(_MainTex, i.uv);
//颜色取反
col = 1 - col;
return col;
}

虽然颜色取反并不是我们常用的效果,但是它揭示了后处理的一般流程,为我们打开一个全新的后处理世界。在后面我会陆陆续续介绍其他常用的后处理效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Shader "Tutorial/016_Postprocessing"{
//材质面板,这里所有属性都将隐藏
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
// 不需要背面剔除
// 禁用深度缓存
Cull Off
ZWrite Off
ZTest Always

Pass{
CGPROGRAM
//引入内置函数和变量
#include "UnityCG.cginc"

//声明顶点、片段着色器
#pragma vertex vert
#pragma fragment frag

//将要进行后处理的图片
sampler2D _MainTex;

//模型纹理数据,后处理会自动生成一个矩阵网格
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间插值数据
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//变换到裁剪空间,后处理的渲染过程和普通着色器类似
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

//片段着色器,后处理主要是在这里执行
fixed4 frag(v2f i) : SV_TARGET{
//原图片采样
fixed4 col = tex2D(_MainTex, i.uv);
//颜色取反
col = 1 - col;
return col;
}

ENDCG
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
using UnityEngine;

//该脚本需要和摄像机绑定在同一物体
public class Postprocessing : MonoBehaviour {
//用于后处理的材质
[SerializeField]
private Material postprocessMaterial;

//当当前绑定的摄像机渲染完一帧画面后,会调用该函数
void OnRenderImage(RenderTexture source, RenderTexture destination){
//将原图按照材质着色器脚本逻辑,写入到结果图
Graphics.Blit(source, destination, postprocessMaterial);
}
}

希望本篇教程能够让你了解如何使用简单的后处理效果、能够独立实现一些后处理效果。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/016_Postprocessing/Postprocessing.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/016_Postprocessing/Postprocessing.cs

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Vertex Displacement

目前为止,我们使用到最多的就是裁剪坐标系和世界坐标系,其实在顶点着色器中,我们能做的远远不止这些。接下来我将介绍如果将三角函数应用到模型上,从而实现模型抖动效果。

本篇的例子是采用表面着色器,如果你对表面着色器还不了解的话,建议你先从这篇教程看起。当然本篇介绍的思路可以用于到其他着色器上。

一般我们对顶点坐标的操作都是在顶点着色器中,而我们的表面着色器中似乎并没有顶点着色器函数,实际上表面着色器最终会被翻译为顶点、片段着色器,只不过这些都是由Unity来完成。而在表面着色器中其实还有一个和顶点着色器同名的函数,也是用来处理顶点数据的,只不过定义的时候是和表面着色器一起定义的。

1
2
3
4
5
//表面着色器
//表面着色器函数和标准光照模型
//fullforwardshadows 使用所有的阴影Pass
//vertex:vert 用来处理顶点变换
#pragma surface surf Standard fullforwardshadows vertex:vert

然后我们需要去实现这个顶点处理函数。在无光照的着色器中,我们是在顶点着色器函数中处理裁剪变换。而在表面着色器中,这里的顶点处理函数并不需要处理裁剪变换,因为那些基础部分都由Unity自动生成。这我们只需要处理顶点坐标,然后将处理后的结果传给下一步由Unity自动生成的代码处理。

可以这么说,这里的顶点处理函数是在普通的顶点着色器函数之前执行的,所以顶点处理函数的输入参数也是模型网格数据。这里可以使用Unity提供给我们的appadata_full,也可以自定义。

和表面着色器函数一样,这里的顶点处理函数也不返回任何值,而是通过inout来向外部传递结果。

因为在表面着色器中,所有必要的顶点变换都是由Unity自动生成的,所以定义一个空的顶点处理函数并不会影响原先的表面着色器。

1
2
3
void vert(inout appdata_full data){

}

比较简单的顶点处理就是给所有的顶点乘以一个缩放因子,这样我们就可以控制模型变大变小。

1
2
3
void vert(inout appdata_full data){
data.vertex.xyz *= 2;
}

虽然模型变大了,但是整个显示却变得不正常了。这里的阴影还是基于原来的为改变的模型顶点来计算的。这是因为表面着色器并不会根据需求自动生成阴影Pass,而依然是复制已有的阴影Pass。为了解决这个问题,我们可以定义addshadow关键字,这样错误的阴影就会消失了。

1
2
//addshadows 是告诉表面着色器,基于顶点处理函数,重现创建一个阴影Pass
#pragma surface surf Standard fullforwardshadows vertex:vert addshadow

仅仅是缩放模型显得单调了,接下来我们可以实现更有趣的效果。通过计算顶点坐标的x值得三角函数,来改变器y值,从而产生一种波动的效果。

1
2
3
void vert(inout appdata_full data){
data.vertex.y += sin(data.vertex.x);
}

上面的结果表明当前使用的三角函数波形较大、频率低,因此我们增加两个控制波形的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//...

_Amplitude ("Wave Size", Range(0,1)) = 0.4
_Frequency ("Wave Freqency", Range(1, 8)) = 2

//...

float _Amplitude;
float _Frequency;

//...

void vert(inout appdata_full data){
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency) * _Amplitude;
data.vertex = modifiedPos;

//...


现在我们可以很好地控制我们的模型波形了,但是在顶点处理函数中只处理了顶点坐标,而没有同时处理法向量,因此法向量相对应模型表面来说实际上是不匹配的。

这里最简单且最灵活的计算自定义模型表面法向量的方法是,通过采集模型表面上的点来重新计算法向量。

理论上来说,我们可以采集变形后的局部区域的任意点来计算切平面,进而计算法向量。但是我们需要充分利用已有数据来解析这个切平面。首先对于切向空间我们需要有所了解,在切向空间中,法向量叫normal,切向向量叫tangent,还有一个叫不出名字的bitangent,这三个向量相互垂直,构成切向空间的三个轴。如下图所示,蓝色是法向量,红色是切向量,黄色是bitangent。其中变形前的切向量和法向量都可以从模型网格数据中获取。所以变形前的bitangent可以通过前两者的叉乘来计算。

在知道变形前的切向向量和bitangent就表示我们知道变形前的切平面,那么计算变形后的切平面我们同样可以先计算变形后的切向量和bitangent。因为这两个向量是沿着模型表面一同变形的,所以可以使用前面的波形函数计算两个向量变形后的方向,然后再通过叉乘来计算变形后的法向量。

1
2
3
4
5
6
7
//求解变形后的切向方向临近点的坐标
float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency) * _Amplitude;
//求解变形后的bitangent方向临近点的坐标
float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency) * _Amplitude;

上面求解了两个临近点变形后的位置,加上前面计算好的顶点变形后的位置,我们就可以得到变性后的切向平面,然后求切平面的法向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void vert(inout appdata_full data){
//求解变形后的顶点坐标
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency) * _Amplitude;
//求解变形后的切向方向临近点的坐标
float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency) * _Amplitude;
//求解变形后的bitangent方向临近点的坐标
float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency) * _Amplitude;
//求解变形后的切平面
float3 modifiedTangent = posPlusTangent - modifiedPos;
float3 modifiedBitangent = posPlusBitangent - modifiedPos;
//求解变形后切平面的法向量,也就是模型变形后的法向量
float3 modifiedNormal = cross(modifiedTangent, modifiedBitangent);
data.normal = normalize(modifiedNormal);
data.vertex = modifiedPos;
}

最后我希望我们的波形抖动随着时间变化而变化。前面我们只采用了模型顶点坐标的x值作为波形函数的参数,从而得到变形后的坐标,在此基础上引入时间变量是非常简单的。

Unity向着色器中传递的时间变量是一个四维向量,其中第一个元素的值是时间处以20,第二是是时间,第三个是时间成以2,第四个是时间乘以三,这里的时间都是以秒为单位。这里我们选择第二个参数时间来控制波形,另外我们还需要控制波形动画速度的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
_AnimationSpeed ("Animation Speed", Range(0,5)) = 1

//...

float _AnimationSpeed;

//...

void vert(inout appdata_full data){
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 modifiedTangent = posPlusTangent - modifiedPos;
float3 modifiedBitangent = posPlusBitangent - modifiedPos;

float3 modifiedNormal = cross(modifiedTangent, modifiedBitangent);
data.normal = normalize(modifiedNormal);
data.vertex = modifiedPos;
}

上面计算临近点的时候我们是使用0.01个偏移来是变形更加平滑。这个值越小,其变形变越明显,越大,整个形变越光滑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
Shader "Tutorial/015_vertex_manipulation" {
//材质面板
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0, 1)) = 0
_Metallic ("Metalness", Range(0, 1)) = 0
[HDR] _Emission ("Emission", color) = (0,0,0)

_Amplitude ("Wave Size", Range(0,1)) = 0.4
_Frequency ("Wave Freqency", Range(1, 8)) = 2
_AnimationSpeed ("Animation Speed", Range(0,5)) = 1
}
SubShader {
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

//表面着色器
//表面着色器函数和标准光照模型
//fullforwardshadows 使用所有的阴影Pass
//vertex:vert 用来处理顶点变换
//addshadows 是告诉表面着色器,基于顶点处理函数,重现创建一个阴影Pass
#pragma surface surf Standard fullforwardshadows vertex:vert addshadow
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;

half _Smoothness;
half _Metallic;
half3 _Emission;

float _Amplitude;
float _Frequency;
float _AnimationSpeed;

//表面着色器的输入数据
struct Input {
float2 uv_MainTex;
};

void vert(inout appdata_full data){
float4 modifiedPos = data.vertex;
modifiedPos.y += sin(data.vertex.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 posPlusTangent = data.vertex + data.tangent * 0.01;
posPlusTangent.y += sin(posPlusTangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 bitangent = cross(data.normal, data.tangent);
float3 posPlusBitangent = data.vertex + bitangent * 0.01;
posPlusBitangent.y += sin(posPlusBitangent.x * _Frequency + _Time.y * _AnimationSpeed) * _Amplitude;

float3 modifiedTangent = posPlusTangent - modifiedPos;
float3 modifiedBitangent = posPlusBitangent - modifiedPos;

float3 modifiedNormal = cross(modifiedTangent, modifiedBitangent);
data.normal = normalize(modifiedNormal);
data.vertex = modifiedPos;
}

//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {
//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;
//设置标准光照参数
o.Metallic = _Metallic;
o.Smoothness = _Smoothness;
o.Emission = _Emission;
}
ENDCG
}
FallBack "Standard"
}

希望本篇能启发你对模型顶点处理的思考,然后创造出美轮美奂的效果。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/015_VertexManipulation/vertexmanipulation.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Polygon Clipping

Summary

目前为止,我们接触的所有模型渲染都是基于多边形的。有人可能好奇,可不可以通过一系列顶点来实现对多边形的裁剪操作,这也是本文的重点。我将介绍如何在单Pass中的片段着色器函数中实现这个。当然关于裁剪还有其他的实现方案,例如通过将顶点构成的裁剪区域渲染到模板上,然后基于模板进行裁剪操作,但是本文不打算讨论它。

本文主要从技术角度来介绍器基本思路,并不会涉及到复杂的图形效果,所以这里我们以无光照的着色器来实现。关于无光照的着色器介绍可以参考这里

Draw Line

首先我们需要顶点的世界坐标,就像前面关于二维平面映射中介绍的一样,将传入的模型顶点坐标乘以模型矩阵,然后将结果传递给片段着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//中间插值结构体
struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//计算裁剪坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldPos = worldPos.xyz;
return o;
}

然后在片段着色器中,我们需要计算点与线的关系。因为我们后面要使用点来构造线,而两点确定一条线正是我们这里用到的一条基本规律。

为了确定点线之间的关系,我们需要借助两个向量,一是从直线上任意一点到该点的向量,二是直线的法向量。单纯的谈直线的法向量并没有太大意义,因为法向具有方向性,在二维空间有两个,在三维空间有无数个。但是这里我们想判定点是在线的左侧还是右侧,所以我们这里将法向量定义为垂直与直线,并指向直线左侧的向量。

当我们得到这两个向量后,这两个向量的点乘就可以用来判断点与线的关系。如果结果为正,说明在左侧,如果为负,说明在右侧,如果为零,说明在直线上。

那么在着色器脚本中,我们首先定义直线上的两个点,然后计算上图中的三个向量。首先我们计算直线的方向,我们将第一个点减去第二个点所得到的方向向量作为直线的方向,然后将该向量旋转90度。接下来我们将直线外的那个点减去直线内的任意一个点。

然后我们将直线的法向量和目标点的向量做点乘,并将结果显示在屏幕上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float2 linePoint1 = float2(-1, 0);
float2 linePoint2 = float2(1, 1);

//计算这三个向量
float2 lineDirection = linePoint2 - linePoint1;
float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
float2 toPos = i.worldPos.xy - linePoint1;

//计算点与线的关系
float side = dot(toPos, lineNormal);
//以0为分界线,大于零取1,否则取0
//side = step(0, side);

return side;

上图中出现一条灰色的过渡带。但是这并不是我们所想要的。因为所有小于零的区域显示为纯黑,0到1之间的显示为灰色区域,大于1的显示为百色。因此我们可以使用step函数来讲中间灰度区域去掉。

1
2
3
4
5
//以0为分界线,大于零取1,否则取0
float side = dot(toPos, lineNormal);
side = step(0, side);

return side;

当我们再向其中加入一个点、两条线,就可以构成三角形了。因此我们将上面的判断逻辑抽成一个函数,方便复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//在左边返回1,否则返回0
float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2){
//计算三个向量
float2 lineDirection = linePoint2 - linePoint1;
float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
float2 toPos = pos - linePoint1;

//以0为分界线,大于零取1,否则取0
float side = dot(toPos, lineNormal);
side = step(0, side);
return side;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
float2 linePoint1 = float2(-1, 0);
float2 linePoint2 = float2(1, 1);

side = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);

return side;
}

Draw a Polygon of multiple lines

前面已经抽取点与线的位置判断函数,这样我们可以对多边形的每一条边界线进行判定。然后将所有的判定结果结合起来,判定点与多边形的位置关系。例如我们可以定义当点在所有线的左侧时,为true,反之为false。或者我们可以定义在所有线的右侧时,为false,反之为true。这里我们定义的三角形为顺时针,这意味着线的左侧为三角形的外侧,我们可以将所有线条的判定结果求和,当全为左侧时,为零,否则大于零。

1
2
3
4
5
6
7
8
9
10
11
12
//片段作色器
fixed4 frag(v2f i) : SV_TARGET{
float2 linePoint1 = float2(-1, 0);
float2 linePoint2 = float2(1, 1);
float2 linePoint3 = float2(1, -1);

float outsideTriangle = isLeftOfLine(i.worldPos.xy, linePoint1, linePoint2);
outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint2, linePoint3);
outsideTriangle = outsideTriangle + isLeftOfLine(i.worldPos.xy, linePoint3, linePoint1);

return outsideTriangle;
}

上面我们成功通过边界裁剪来实现多边形效果。现在我想通过材质面板来编辑这个裁剪区域。我们需要引入两个变量,一个是裁剪顶点列表,一个是顶点的个数。顶点列表记录的是我们裁剪区域的顶点位置。第二个是记录顶点的个数,因为着色器中不支持动态数组,所以我们需要定义一个固定数组,然后通过该变量来控制实际参与计算的顶点个数。

1
2
3
//顶点数组和个数
uniform float2 _corners[1000];
uniform uint _cornerCount;

Filling the Corner Array

材质面板并不支持数组显示。所以我们需要创建一个脚本组件来管理这个数组。这里我给新创建的脚本添加两个属性,并且将该脚本定义为编辑模式可执行,这样我们就不需要每次都启动程序了。另外,下面的脚本要求必须和渲染的裁剪多边形绑定,这样我们可以访问其中的材质。

1
2
3
4
5
6
7
8
9
10
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Renderer))]
public class PolygonController : MonoBehaviour {


}

下面是我们想脚本中加入两个变量,一个是裁剪着色器所需要的顶点数组,一个是绑定该着色器的材质。材质属性是私有的,因为我们是直接从当前绑定物体上获取的。顶点数组也是私有的,因为我们也不需要在外部访问,但是我们需要在组件面板上编辑它,所以需要SerializeField来标记它。

1
2
3
4
[SerializeField]
private Vector2[] corners;

private Material _mat;

然后我们实现一个函数将这些顶点数据传递给着色器。首先我们需要获取到当前物体的材质,这里我们使用sharedmaterial属性来获取材质,如果使用material属性的话,获取的将是该材质的副本。

然后我们再创建一个长度为1000的4维向量数组。之所以是4维而不是二维,因为Unity只支持向着色其中传递4为向量数组。而长度为1000是因为着色器中定义的是固定长度的数组。当然这里定义1000是假设我们会用到的顶点个数最大为1000,具有一定的随意性,你也可以根据实际需要调整。

当我们将二维向量赋值给四维向量时,为赋值的部分将会以0填充。

在准备好四维向量数组后,我们将其、以及实际的数组长度传递给着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void UpdateMaterial(){
//获取当前模型的材质
if(_mat == null)
_mat = GetComponent<Renderer>().sharedMaterial;

//填充顶点位置数组
Vector4[] vec4Corners = new Vector4[1000];
for(int i=0;i<corners.Length;i++){
vec4Corners[i] = corners[i];
}

//传递给着色器
_mat.SetVectorArray("_corners", vec4Corners);
_mat.SetInt("_cornerCount", corners.Length);
}

下一步是在Unity事件函数中调用上面的函数。这里我们选择StartOnValidate两个函数,前者在游戏启动时会调用一次,后者在每次修改组件属性面板上的值时会调用一次。

1
2
3
4
5
6
7
void Start(){
UpdateMaterial();
}

void OnValidate(){
UpdateMaterial();
}

脚本编写完成后,将其以组件的形式赋给我们的裁剪物体。然后在组件属性面板上可以编辑需要的裁剪顶点。

下面我们回到着色器脚本,然后在着色器中使用传入的顶点数组。

然后我们使用for循环来依次遍历这个顶点数组。因为在hlsl中,数组的起点是0,所以我们循环的起点也是0。循环的终止条件是超过实际的顶点个数。这里我们使用for循环来遍历。其实还可以将for循环展开,相比而言在显卡中的效率更高,但是我们需要提前知道循环次数才可以展开。这里我们的循环次数是可变的,所以只能使用for循环。

在循环中,我们计算所有的边界与点之间的关系,然后求和。而边界是由本次遍历的顶点和下次遍历的顶点构成的。但是这里有一个问题,就是最后的一个顶点的下一个顶点应该是第一个点的,所以这里我们可以使用求余来将索引值切回到起始点。

1
2
3
4
5
6
7
8
9
10
11
12
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{

float outsideTriangle = 0;

[loop]
for(uint index;index<_cornerCount;index++){
outsideTriangle += isLeftOfLine(i.worldPos.xy, _corners[index], _corners[(index+1) % _cornerCount]);
}

return outsideTriangle;
}

最终我们得到由六个点构成的裁剪区域。

Clip and Color the Polygon

前面的步骤实际上是一个区域标记的过程,在区域内外都会进行渲染,只不过使用不同的颜色而已。有人可能想问,如何只渲染其中一部分,这样就可以向其中添加其他模型一同渲染。在hlsl中有一个放弃渲染的函数叫做clip。如果向clip传入的值小于0,那么将不会执行颜色写入的操作,否则的话正常渲染。

前面我们已经计算出区域标记,不过要么是0要么是1,都不小于0,因此我们还需要做一些转换才能将其中一部分渲染剔除。

1
2
clip(-outsideTriangle);
return outsideTriangle;


这种裁剪方式有很大的缺陷,就是只能用来渲染凸多边形。ps:其实我觉得凹多边形也不影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
Shader "Tutorial/014_Polygon"
{
//属性面板
Properties{
_Color ("Color", Color) = (0, 0, 0, 1)
}

SubShader{
//渲染不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

//引入内置函数和变量
#include "UnityCG.cginc"

//定义顶点和片段着色器
#pragma vertex vert
#pragma fragment frag

fixed4 _Color;

//用于裁剪的顶点数组
uniform float2 _corners[1000];
uniform uint _cornerCount;

//模型网格数据
struct appdata{
float4 vertex : POSITION;
};

//中间插值数据
struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//计算裁剪坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldPos = worldPos.xyz;
return o;
}

//在左边返回1,否则返回0
float isLeftOfLine(float2 pos, float2 linePoint1, float2 linePoint2){
//计算所需的三个向量
float2 lineDirection = linePoint2 - linePoint1;
float2 lineNormal = float2(-lineDirection.y, lineDirection.x);
float2 toPos = pos - linePoint1;

//以0为分界线,大于零取1,否则取0
float side = dot(toPos, lineNormal);
side = step(0, side);
return side;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{

float outsideTriangle = 0;

[loop]
for(uint index;index<_cornerCount;index++){
outsideTriangle += isLeftOfLine(i.worldPos.xy, _corners[index], _corners[(index+1) % _cornerCount]);
}

clip(-outsideTriangle);
return _Color;
}

ENDCG
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[ExecuteInEditMode]
[RequireComponent(typeof(Renderer))]
public class PolygonController : MonoBehaviour {
[SerializeField]
private Vector2[] corners;

private Material _mat;

void Start(){
UpdateMaterial();
}

void OnValidate(){
UpdateMaterial();
}

void UpdateMaterial(){
//获取当前模型所用的材质
if(_mat == null)
_mat = GetComponent<Renderer>().sharedMaterial;

//填充顶点数组
Vector4[] vec4Corners = new Vector4[1000];
for(int i=0;i<corners.Length;i++){
vec4Corners[i] = corners[i];
}

//传递给着色器
_mat.SetVectorArray("_corners", vec4Corners);
_mat.SetInt("_cornerCount", corners.Length);
}

}

希望你能从这边文章中了解到如何处理点、线等多边形问题。也希望我所介绍到刚好是你想知道的!

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/014_Polygon/Polygon.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/014_Polygon/PolygonController.cs

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Custom Lighting

Summary

表面着色器真的非常方便,特别是在处理光照时,表面着色器可以使用PBR模型快速实现物理光照效果。但是有时候我们可能想实现其他一些光照效果,例如卡通风格的。这时候我们可以使用自定义光照函数来满足我们的需求。

本篇主要介绍表面着色器特有的一些功能。但是光照处理的基本原理可以应用到其他着色器中。只不过Unity会为表面着色器生成一个基本的可复用的光照框架,如果我们使用其他着色器,那么必须手动补全这部分代码,但是这些并不是本文的重点,所以不做赘述。

如果你是一个初学者,建议你从第一章开始看。

Use Custom Lighting Function

首先我们需要将光照模型设置为我们自定义的光照函数。

1
2
3
4
//表面着色器
//表面着色函数以及我们的自定义光照函数
//fullforwardshadows 应用所有的阴影Pass
#pragma surface surf Custom fullforwardShadows

然后我们添加我们的自定义光照函数。光照函数的名字结构是LightingX,其中X是上面定义的光照函数,这里是Custom。在下面的自定义光照函数中使用的SurfaceOutput结构体,实际上是由surf表面着色器函数返回的数据,两者最终的数据结构必须一致。另外还有光照方向、以及光照衰减度,衰减度在后面会介绍。

1
2
3
4
//自定义光照函数,针对每个光源都会处理一遍
float4 LightingCustom(SurfaceOutput s, float3 lightDir, float atten){
return 0;
}

SurfaceOutputSurfaceOutputSstandard都是Unity预定义的数据结构,前者是针对非物理渲染的,后者是提供了物理渲染的基本参数。当然你也可以把他们当做是一个参考模板,然后实现自己的数据结构。使用的方法是,先在表面着色器函数中对该结构赋值,然后在自定义光照函数中使用。因为SurfaceOutput不包含光滑度、以及金属度的属性,所以在表面着色器删除对这两者的赋值。

//表面着色器
void surf (Input i, inout SurfaceOutput o) {
//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;

//o.Emission = _Emission;

}

现在我们有了自己的光照函数,但是该函数目前返回的是0,所以模型渲染后看不到光照效果。

按理说光照为零的话,整个模型应该是纯黑色,但是我们现在却能看清模型的基本轮廓。这是因为,光照函数处理的是直接光源,但是在模型渲染的时候除了直接光源,还有间接光源作用。其中环境光就是一种间接光源,Unity会自动将天空盒的颜色来当成环境光的一部分,所以我们在环境光的作用下还能看清物体。如果你在Unity编辑器的场景窗口上方选择关闭环境光的按钮,那么整个模型最终就会变成纯黑色,并且其最终显示都完全受我们的自定义光照函数控制。当然这里我保留Unity编辑器的默认设置。

Implement Lighting Ramp

下面我们来实现简单的自定义光照函数。第一步我们先求解表面法向和入射光线方向向量的点乘,这两个参数正好在自定义光照函数的参数列表中。

1
2
3
4
5
6
//自定义光照函数
float4 LightingCustom(SurfaceOutput s, float3 lightDir, float atten){
//法向和光照入射方向的点乘,可以用来表示光照密度
float towardsLight = dot(s.Normal, lightDir);
return towardsLight;
}

下面我们实现的光照函数非常简单,涵盖了一般通用结构。我们使用光线入射向量来最为纹理纹理采样的参数,并将采样结果当成光源在该点的亮度。

因此我们必须将点乘结果的取值范围从[-1, 1]映射为[0, 1],因为后者是UV的取值范围。

然后我们创建一个叫ramp的纹理变量。按照前面说的采样方法进行采样。然后在材质面板上设置纹理为半黑半白的图片。

1
2
3
4
5
6
7
8
9
10
11
12
//材质面板
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
[HDR] _Emission ("Emission", color) = (0,0,0)

_Ramp ("Toon Ramp", 2D) = "white" {}
}

//...

sampler2D _Ramp;

下面是我们所用的ramp纹理。

1
2
3
4
5
6
7
8
9
10
11
12
//自定义光照函数
float4 LightingCustom(SurfaceOutput s, float3 lightDir, float atten){
//入射光强
float towardsLight = dot(s.Normal, lightDir);
//数值区间映射
towardsLight = towardsLight * 0.5 + 0.5;

//纹理采样
float3 lightIntensity = tex2D(_Ramp, towardsLight).rgb;

return float4(lightIntensity, 1);
}

在上图中,模型背光面也能看清模型表面纹理。这还是因为环境光的影响。

为了让效果看起来更好,我们将模型颜色和光照密度相乘、同时应用衰减因子,最终的到一个具有颜色、且强度随距离变化的光源。模型最终的表现也和光源的颜色、和距离相关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//自定义光照
float4 LightingCustom(SurfaceOutput s, float3 lightDir, float atten){
//法向和光入射方向点乘
float towardsLight = dot(s.Normal, lightDir);
//数值范围映射
towardsLight = towardsLight * 0.5 + 0.5;

//光强采样
float3 lightIntensity = tex2D(_Ramp, towardsLight).rgb;

//最终的颜色
float4 col;
//应用光照
col.rgb = lightIntensity * s.Albedo * atten * _LightColor0.rgb;
//赋值透明通道
col.a = s.Alpha;

return col;
}

上面已经介绍完了一个完整的自定义光照着色器的实现。我们可以使用不同的ramp纹理来得到完全不同的渲染风格。比如下面,我们采样冷暖色条纹,实现的卡通效果。图片源于这里

在着色器我们还有一个emission没有用到,但是模型最终的显示中却受自发光这个变量的影响。

上面这种卡通风格的着色器非常有用,在很多地方可以灵活运用。

自定义光照函数非常强大,但是只能用在前向渲染中。即便是你把渲染路径改为延迟渲染,最终渲染管线依然会把这部分的渲染转移到前向渲染路径中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
Shader "Tutorial/013_CustomSurfaceLighting" {
//材质面板
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
[HDR] _Emission ("Emission", color) = (0,0,0)

_Ramp ("Toon Ramp", 2D) = "white" {}
}
SubShader {
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

//表面着色器
//自定义表面着色器函数、自定义光照函数
//fullforwardshadows 使用所有的阴影Pass
#pragma surface surf Custom fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;
half3 _Emission;

sampler2D _Ramp;

//自定义光照函数
float4 LightingCustom(SurfaceOutput s, float3 lightDir, float atten){
//点乘
float towardsLight = dot(s.Normal, lightDir);
//数值范围映射
towardsLight = towardsLight * 0.5 + 0.5;

//采样
float3 lightIntensity = tex2D(_Ramp, towardsLight).rgb;

//最终的颜色
float4 col;
//计算光照
col.rgb = lightIntensity * s.Albedo * atten * _LightColor0.rgb;
//使用透明通道
col.a = s.Alpha;

return col;
}

//输入结构体
struct Input {
float2 uv_MainTex;
};

//表边着色器函数
void surf (Input i, inout SurfaceOutput o) {
//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;

//o.Emission = _Emission;
}
ENDCG
}
FallBack "Standard"
}

希望本篇能帮助你理解表面着色器和自定义光照。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/013_CustomSurfaceLighting/CustomLighting.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Fresnel

Summary

菲涅尔效果是渲染中常用的效果。使用菲涅尔效果我们可以很方便的对模型的边缘进行增亮、加黑等边缘效果,加强场景的深度感。

本篇将采用表面着色器的实现方法,所以如果你对表面着色器还不了解的话,建议你先看看这篇文章。当然你也可以将菲涅尔效果应用到其他类型的着色器,来增强模型平滑度、或突出重点。

Highlighting one Side of the Model

我们通过修改表面着色器来实现菲涅尔效果。菲涅尔效果是根据法向来计算效果的密度、或厚度。因为要在片段着色器中使用到世界坐标系中的法向量,所以我们首先在顶点着色器中计算好世界坐标系中的顶点法向,然后通过中间插值结构传递给片段着色器。当然,我们的输入结构体和中间插值结构体都需要定义法向量。

三维平面映射我们介绍了如何计算顶点的世界法向量。

1
2
3
4
5
6
//输入的模型网格数据,其中采用宏定义来定义部分数据
struct Input {
float2 uv_MainTex;
float3 worldNormal;
INTERNAL_DATA
};

我们可以通过计算相邻向量之间的点乘,从而了解模型表明的平滑度、或者梯度。单位向量之间的点乘越大,说明它们方向越一致。

首先,我将所有法向量与一个固定向量做点乘,可以让我们更容易理解点乘的意义。然后将点乘的结果传递给emission变量,将点乘结果通过自发光的形式表现出来。

1
2
3
4
5
6
7
8
9
10
//表面着色器函数,主要用了计算光照模型的输入参数,然后由光照模型进行最中的颜色计算
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//两个向量之间的点乘
float fresnel = dot(i.worldNormal, float3(0, 1, 0));
//应用菲涅尔效果
o.Emission = _Emission + fresnel;
}

上图我们可以看到,越朝上的地方越亮、越朝下越暗。为了避免自发光出现负数,这里将菲涅尔值限定在[0,1]之间。这里有两个函数staturateclamp都可以实现范围限定的功能,但是staturate在GPU上执行速度更快。下面是限定后的效果。

1
2
3
4
5
6
7
8
9
10
11
12
//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//计算菲涅尔值
float fresnel = dot(i.worldNormal, float3(0, 1, 0));
//限定为 0 - 1 之间
fresnel = saturate(fresnel);
//应用菲尼尔效果
o.Emission = _Emission + fresnel;
}

Highlighting the outer Parts

接下来我们使用视角方向来计算菲涅尔值。视角放向可以直接定义输入结构体中,然后在表面着色器函数中就可以访问。

如果是在无光照着色器中实现菲涅尔效果,那么视角方向可以通过摄像机的位置和顶点世界坐标来计算。其中摄像机坐标存储在_WorldSpaceCameraPos这个内置变量中,由Unity来赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//表面着色器的输入结构体
struct Input {
float2 uv_MainTex;
float3 worldNormal;
float3 viewDir;
INTERNAL_DATA
};

//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//金属度与光滑度
o.Metallic = _Metallic;
o.Smoothness = _Smoothness;

//菲尼尔值
float fresnel = dot(i.worldNormal, i.viewDir);
//范围限定在 0 - 1
fresnel = saturate(fresnel);
//应用菲涅尔值
o.Emission = _Emission + fresnel;
}

总体来说实现了菲涅尔效果,但是整个材质是靠近中心区域更亮,而不是边缘更亮。如果我们想让边缘更亮,我们可以将1减去菲涅尔值。

1
2
3
4
5
6
7
8
9
10
11
12
//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//菲涅尔值
float fresnel = dot(i.worldNormal, i.viewDir);
//效果反转
fresnel = saturate(1 - fresnel);
//应用菲涅尔效果
o.Emission = _Emission + fresnel;
}

Add Fresnel Color and Intensity

最后,我想增加一些自定义属性,比如菲涅尔颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//...

_FresnelColor ("Fresnel Color", Color) = (1,1,1,1)

//...

float3 _FresnelColor;

//...

//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//菲涅尔值
float fresnel = dot(i.worldNormal, i.viewDir);
//效果反转
fresnel = saturate(1 - fresnel);
//使用菲涅尔自定义颜色
float3 fresnelColor = fresnel * _FresnelColor;
//应用菲涅尔效果
o.Emission = _Emission + fresnelColor;
}

//...

然后再增加一个控制菲涅尔强度的属性。这里我们使用指数函数来调节菲涅尔强度。

因为指数函数计算消耗非常大,所以如果有其他方法能实现相同的效果,尽量不要使用指数函数。当然,指数函数用法简单、便捷,在设计效果的时候可以直接使用指数函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//...

[PowerSlider(4)] _FresnelExponent ("Fresnel Exponent", Range(0.25, 4)) = 1

//...

float _FresnelExponent;

//...

//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {

//...

//菲涅尔值
float fresnel = dot(i.worldNormal, i.viewDir);
//效果反转
fresnel = saturate(1 - fresnel);
//使用菲涅尔自定义颜色
float3 fresnelColor = fresnel * _FresnelColor;
//强度调节
fresnel = pow(fresnel, _FresnelExponent);
//应用菲涅尔效果
o.Emission = _Emission + fresnelColor;
}

菲涅尔效果除了用来突出轮廓外,还可以用来实现各种渐变效果,这里不展开,有兴趣可以自行尝试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
Shader "Tutorial/012_Fresnel" {
//材质面板
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0, 1)) = 0
_Metallic ("Metalness", Range(0, 1)) = 0
[HDR] _Emission ("Emission", color) = (0,0,0)

_FresnelColor ("Fresnel Color", Color) = (1,1,1,1)
[PowerSlider(4)] _FresnelExponent ("Fresnel Exponent", Range(0.25, 4)) = 1
}
SubShader {
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

//这是表面着色器,unity会自动生成部分代码
//surf 是被定义为表面着色器函数
//fullforwardshadows 告诉Unity将所有阴影Pass都复制过来
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;

half _Smoothness;
half _Metallic;
half3 _Emission;

float3 _FresnelColor;
float _FresnelExponent;

//表面着色器的输入结构
struct Input {
float2 uv_MainTex;
float3 worldNormal;
float3 viewDir;
INTERNAL_DATA
};

//表面着色器函数,主要用来计算光照模型所需的参数
void surf (Input i, inout SurfaceOutputStandard o) {
//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;

//光滑度、和金属度
o.Metallic = _Metallic;
o.Smoothness = _Smoothness;

//菲涅尔值
float fresnel = dot(i.worldNormal, i.viewDir);
//效果翻转
fresnel = saturate(1 - fresnel);
//控制菲涅尔强度
fresnel = pow(fresnel, _FresnelExponent);
//应用菲涅尔颜色
float3 fresnelColor = fresnel * _FresnelColor;
//应用菲涅尔
o.Emission = _Emission + fresnelColor;
}
ENDCG
}
FallBack "Standard"
}

希望本篇能让你对菲涅尔效果有所了解。

你可以在以下链接找到源码:https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/012_Fresnel/Fresnel.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Checkerboard Pattern

Summary

我觉得使用着色器来生成图片比较有意思。下面我以棋盘格为例,像你们展示如何通过程序生成模型纹理的。

Stripes

参考前面关于二维平面映射的教程,这里我们也是基于模型顶点的世界坐标来生成UV,这样的话,移动模型的过程中,其表面纹理在前后帧的渲染图可以无缝衔接。如果你希望生成的纹理跟随模型一起运动,那么可以选择基于模型坐标系进行计算。

首先,我们在顶点着色器中通过坐标变换,计算得到顶点的世界坐标,然后通过中间插值数据结构将计算结果传递到片段着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
}

v2f vert(appdata v){
v2f o;
//计算裁剪坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界坐标
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}

然后在片段着色器中,我们首先考虑一个维度上的棋盘格效果,也就是黑白相间的条纹效果。方法很简单,就是选取世界坐标中的一个轴向的值,然后取整,两个整数之间的数取整后的结果是一样的,也就是说取整后的值代表了两个整数之间的区域,正是我们这个里的条纹效果。

然后我们要区分条纹的奇偶顺序,因为两个相邻的条纹刚好可以用两个相邻的整数来表示,所以只需要求其整数的奇偶性。求一个整数的奇偶性,可以通过对2的求余操作来实现。然后通过奇偶性来判断其颜色。

1
2
3
4
5
6
7
8
9
fixed4 frag(v2f i) : SV_TARGET{
//选择x轴的值取整
float chessboard = floor(i.worldPos.x);
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;
return chessboard;
}

Checkerboard in 2d and 3d

接下来,我们处理两个轴向的棋盘格。按照前面的操作,另外在选一个轴向进行计算,这时候分别知道两个轴的奇偶性,然后两个奇偶值相加,再求一遍奇偶性,得到最终格子的颜色。实际上可以进一步优化,可以直接在取整之后就求和,然后后面的奇偶求解可以合并。

1
2
3
4
5
6
7
8
9
fixed4 frag(v2f i) : SV_TARGET{
//合并两个轴向
float chessboard = floor(i.worldPos.x) + floor(i.worldPos.y);
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;
return chessboard;
}


我们还可以进一步扩展到三个轴。

1
2
3
4
5
6
7
8
9
fixed4 frag(v2f i) : SV_TARGET{
//合并三个轴向
float chessboard = floor(i.worldPos.x) + floor(i.worldPos.y) + floor(i.worldPos.z);
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;
return chessboard;
}

Scaling

下面我们再给棋盘格增加一个缩放功能。我们需要在材质面板上引入缩放变量,方便后续调参。然后在片段着色器中,先将世界坐标处以这个缩放参数,然后在执行上面的操作。这样我们在材质面板上减小缩放变量时,棋盘格的大小也会变小。

除此之外,这里还有一个细微的改变,就是我们不再是对各个轴向分开取整,而是直接采样向量的方法,同时对所有轴向进行求整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//...

//材质面板属性
Properties{
_Scale ("Pattern Size", Range(0,10)) = 1
}

//...

float _Scale;

//...

fixed4 frag(v2f i) : SV_TARGET{
//使用向量方法对各个轴向同时取整
float3 adjustedWorldPos = floor(i.worldPos / _Scale);
//各个轴向求和
float chessboard = adjustedWorldPos.x + adjustedWorldPos.y + adjustedWorldPos.z;
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;
return chessboard;
}

//...

Customizable Colors

最后,我们还可以增加两个变量来控制棋盘格的颜色。在片段着色器的最后我使用线性插值函数来实现两种颜色的二选一操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
//...

//材质面板
Properties{
_Scale ("Pattern Size", Range(0,10)) = 1
_EvenColor("Color 1", Color) = (0,0,0,1)
_OddColor("Color 2", Color) = (1,1,1,1)
}

//...

float4 _EvenColor;
float4 _OddColor;

//...

fixed4 frag(v2f i) : SV_TARGET{
//同时取整
float3 adjustedWorldPos = floor(i.worldPos / _Scale);
//三个维度求和
float chessboard = adjustedWorldPos.x + adjustedWorldPos.y + adjustedWorldPos.z;
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;

//二选一操作
float4 color = lerp(_EvenColor, _OddColor, chessboard);
return color;
}

//...

下面是最终的棋盘格生成着色器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
Shader "Tutorial/011_Chessboard"
{
//材质面板
Properties{
_Scale ("Pattern Size", Range(0,10)) = 1
_EvenColor("Color 1", Color) = (0,0,0,1)
_OddColor("Color 2", Color) = (1,1,1,1)
}

SubShader{
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}


Pass{
CGPROGRAM
#include "UnityCG.cginc"

#pragma vertex vert
#pragma fragment frag

float _Scale;

float4 _EvenColor;
float4 _OddColor;

struct appdata{
float4 vertex : POSITION;
};

struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
};

v2f vert(appdata v){
v2f o;
//计算裁剪坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界坐标
o.worldPos = mul(unity_ObjectToWorld, v.vertex);
return o;
}

fixed4 frag(v2f i) : SV_TARGET{
//同时取整
float3 adjustedWorldPos = floor(i.worldPos / _Scale);
//求和
float chessboard = adjustedWorldPos.x + adjustedWorldPos.y + adjustedWorldPos.z;
//计算余数的一半
chessboard = frac(chessboard * 0.5);
//计算余数
chessboard *= 2;

//二选一插值
float4 color = lerp(_EvenColor, _OddColor, chessboard);
return color;
}

ENDCG
}
}
FallBack "Standard" //后补着色器
}

希望本篇对你有所帮助,能够让你知道如何通过程序实现模型纹理图。

你可以在以下链接找到源码:https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/011_ChessBoard/Chessboard.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Triplanar Mapping

Summary

在前面我们介绍过二维平面映射的实现方法,这里我们来讲讲三维平面的映射方法。
纳尼?平面本身是二维的叫二维平面还可以理解,你这来个三维平面,是欺负我读书少,想糊弄我???
稍安勿躁!首先专业名字本身依据其专业用途、含义来取的,很容易和我们习惯相冲突,比如数学领域各种眼花缭乱的术语。这里我们的三维平面更多的值得是三维空间上的平面,可以有三个维度的取值。之前提到的二维平面映射,是只从一个方向进行投影,换句话说,我们只用沿着其投影方向进行渲染,才能看到我们的纹理贴合在模型表面,如果换个角度,你可能就看不到了,即便看到了也可能是模糊不清的。而三维平面映射,是从分别从三个维度进行投影映射,然后将得到的三个纹理颜色进行混合,这样无论我们采用怎样刁钻的角度,也挑不出啥毛病。

当然,本文也是在之前的二维平面映射的基础上扩展的,在了解其原理后,你也可以使用表面着色器重写一遍。

Calcualte Projection Planes

首先,为了得到三个不同方向的UV坐标,我们需要改变UV坐标的生成方式。在二维平面映射中,我们是在顶点着色器中进行UV变换。这里我们直接将顶点的世界坐标传递到片段着色器中,然后在片段着色器中进行uv变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
};

v2f vert(appdata v){
v2f o;
//计算裁剪空间坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldPos = worldPos.xyz;
return o;
}

接下来我们对三个方向投影所对应的uv坐标进行UV变换。在这里我把世界坐标的y轴对应uv坐标的v,这样渲染出来的纹理就是正的。当然,你也可以随意尝试多种对应关系,看看会有什么不一样的效果。

1
2
3
4
5
6
fixed4 frag(v2f i) : SV_TARGET{
//分别计算三个投影方向的uv变换
float2 uv_front = TRANSFORM_TEX(i.worldPos.xy, _MainTex);
float2 uv_side = TRANSFORM_TEX(i.worldPos.zy, _MainTex);
float2 uv_top = TRANSFORM_TEX(i.worldPos.xz, _MainTex);
}

然后使用变换后的uv值进行纹理采样,并将三个不同的采样值进行平均。当然你也可以直接求和,不过最终结果会显得非常亮。

1
2
3
4
5
6
7
8
9
10
11
//分别对三个方向进行纹理采样
fixed4 col_front = tex2D(_MainTex, uv_front);
fixed4 col_side = tex2D(_MainTex, uv_side);
fixed4 col_top = tex2D(_MainTex, uv_top);

//求平均值
fixed4 col = (col_front + col_side + col_top) / 3;

//叠加材质颜色
col *= _Color;
return col;

Normals

到目前为止,你会发现整个材质表现的非常怪异,各种重影迭起,这是因为我们只是单纯的对三个方向的采样值进行平均。为了消除这种重影,我们可以根据不同的朝向,侧重显示对应朝向的采样值。表面朝向有个专业点的名称:法向向量。在我们的网格数据中就包含法向数据。因为一些特殊考虑,网格数据中的法向和顶点是一一对应的。

所以,我们首先要做的是在我们的输入结构体中加入法向变量,然后在顶点着色其中将其变换到世界坐标系,并且通过插值数据传入到片段着色器中参与后续的计算。这里之所以要变换到世界坐标系,是因为我们的纹理映射是基于世界坐标系的。换句话说,我们在进行计算时,应该保证空间数据的空间一致性。

其中将法向从模型坐标系变换到世界坐标系有些特殊。一般的顶点在两个坐标系之间转换是直接乘以模型矩阵,但是法向是乘以模型矩阵转置的逆矩阵。当然其中的矩阵推导比较复杂,我们记住这个结论就行。如果你好奇心很强,那我这里可以先定性地给你分析一下为什么不能直接乘以模型矩阵。前面说过,法向是垂直与表面的向量,假设我们将模型沿着x轴正方向拉伸,这时候表面相对于y轴会变得越来越陡,如果我们也对法向做同样的拉伸,你会发现法向也变得越来越陡,这时候法向和表面不再是垂直关系。我们这里描述的拉伸实际上就是一个空间变换的操作,因此法向和顶点不能使用同样的空间变换,否则将会打破两者的垂直关系。而模型矩阵转置的逆矩阵正是一种相反的操作,可以始终保持两者的垂直关系。当然我们还需要将该矩阵裁剪为3X3的矩阵,因为4x4矩阵还包含了平移变换,而我们的法向量最为方向是没有位置的概念的,所以需要剔除掉矩阵中的平移部分。

事实上,在实际的代码中,我们可能并不会直接使用模型矩阵转置的逆矩阵,而是会采用一些小巧的方法,尽可能的减少计算量。例如世界到模型的变换矩阵刚好等于模型矩阵的逆矩阵,同时向量与矩阵乘积的顺序调转,刚好可以替代转置操作。所以实际上我们可以用法向量左乘世界到模型的变换矩阵来计算在世界坐标系下的法向量。是不是很绕、很晕?那没办法,给你一张图自己去捂捂!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct appdata{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 normal : NORMAL;
};

v2f vert(appdata v){
v2f o;
//计算裁剪空间下的坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界空间下的坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldPos = worldPos.xyz;
//计算世界空间下的法向量
float3 worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);//再给你点提提示,向量在左叫左乘,后面是从世界到模型空间的变换矩阵
o.normal = normalize(worldNormal);
return o;
}

在学习渲染的过程中,记住可视化是我们的看家本领,所以很多时候都可以通过渲染后的表现效果来分析我们的计算过程。这里可以将法向量进行可视化,很简单就是直接将法向量当成颜色返回。所谓的高大上的可视化到咱这还不算一行代码的事,实在不行就多写两行!

1
2
3
fixed4 frag(v2f i) : SV_TARGET{
return fixed4(i.normal.xyz, 1);
}

在得到世界空间下的法向后,我们还需要对法向取绝对值才能应用到后面的权重分配部分。因为法向作为方向向量其取值是在[-1,1]之间,这也是为什么前面的法向可视化中,朝着负轴向的表面颜色是黑色。

1
2
float3 weights = i.normal;
weights = abs(weights);

法向的各个轴向值得大小表明了法向与各个轴向的重合程度。所以我们将权重的各个轴向值分别乘以前面三个投影方向的采样值,例如xy投影平面的投影方向是z轴,所以将其采样值乘以z轴的权重值,依次类推。

这里我们不需要做平均,因为我们并不是简单的将三个采样值进行相加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//在世界坐标系下的法向量当做权重值
float3 weights = i.normal;
//取其绝对值
weights = abs(weights);

//乘以权重值
col_front *= weights.z;
col_side *= weights.x;
col_top *= weights.y;

//求和
fixed4 col = col_front + col_side + col_top;

//叠加材质的基本颜色
col *= _Color;
return col;

上图可以看到整个模型看你来更加凝实了,少了很多眼花缭乱的重影。但是还有一个问题,前面的例子中有一个求平均的过程,但是为甚么要求平均呢,因为求平均可以保证最终混合结果不会过亮。但是我们这里使用法向权重值之和会大概率会大于1,最终导致显示过亮。所以我么可以先除以权重和。

1
2
//保证权重之和为 1
weights = weights / (weights.x + weights.y + weights.z);

现在看起来和纹理原本的亮度差不多。

最后一步是尽可能的提高各个投影方向纹理的权重差异。因为上图的显示效果还是有很大一部分相互叠加。这是因为即便某个投影方向有权重优势,但这种优势并不是碾压式的,不能占有绝对比重。为了使强者越强、弱者越弱,指数函数是一个很好地选择。我们先定义一个表示指数的参数。然后在权重之前,对其各个分量执行指数操作。然后在材质面板上调节这个指数参数,观察显示变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//...

_Sharpness("Blend Sharpness", Range(1, 64)) = 1

//...

float _Sharpness;

//...

//指数操作,强者越强,弱者越弱
weights = pow(weights, _sharpness)

//...

上面的三维平面映射效果还有些问题,表面45度的地方存在明显的过渡痕迹,不过这种痕迹的出现是由于纹理上下左右边界不衔接导致的。另外三维平面映射的性能消耗要更大,因为这里执行了三次纹理采样。

我们可以将三维平面映射应用在表面着色器上,例如对albedo进行三维平面映射,或者对specular等纹理。但是法向纹理需要额外的操作才行,因为我们在三维平面映射的过程中使用的法向向量,这里不做深入研究了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
Shader "Tutorial/010_Triplanar_Mapping"{
//材质面板显示的属性
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Sharpness ("Blend sharpness", Range(1, 64)) = 1
}

SubShader{
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

#include "UnityCG.cginc"

#pragma vertex vert
#pragma fragment frag

//纹理数据
sampler2D _MainTex;
float4 _MainTex_ST;

fixed4 _Color;
float _Sharpness;

struct appdata{
float4 vertex : POSITION;
float3 normal : NORMAL;
};

struct v2f{
float4 position : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 normal : NORMAL;
};

v2f vert(appdata v){
v2f o;
//计算裁剪空间坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界空间坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
o.worldPos = worldPos.xyz;
//计算世界空间法向量
float3 worldNormal = mul(v.normal, (float3x3)unity_WorldToObject);
o.normal = normalize(worldNormal);
return o;
}

fixed4 frag(v2f i) : SV_TARGET{
//分别计算三个方向的uv变换
float2 uv_front = TRANSFORM_TEX(i.worldPos.xy, _MainTex);
float2 uv_side = TRANSFORM_TEX(i.worldPos.zy, _MainTex);
float2 uv_top = TRANSFORM_TEX(i.worldPos.xz, _MainTex);

//分别执行三个方向的纹理采样
fixed4 col_front = tex2D(_MainTex, uv_front);
fixed4 col_side = tex2D(_MainTex, uv_side);
fixed4 col_top = tex2D(_MainTex, uv_top);

//将法向量当成权重
float3 weights = i.normal;
//绝对值
weights = abs(weights);
//求权重指数
weights = pow(weights, _Sharpness);
//权重归一
weights = weights / (weights.x + weights.y + weights.z);

//权重应用
col_front *= weights.z;
col_side *= weights.x;
col_top *= weights.y;

//求和
fixed4 col = col_front + col_side + col_top;

//应用基本颜色
col *= _Color;

return col;
}

ENDCG
}
}
FallBack "Standard" //当当前着色器不支持时,选择后补着色器中的功能
}

希望本文能够帮助你理解什么是三维平面映射。

你可以在以下链接找到源码:https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/010_Triplanar_Mapping/triplanar_mapping.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Color Interpolation

Summary

很多时候,我们的模型需要使用多张纹理,并且同时应用这些纹理,例如地形材质。这时候我们需要通过一些插值的方法将这些纹理采集的颜色进行有效融合。

本教程是在上一个图片着色器的基础上实现的。但是你也可以根据其基本思路,以表面着色器的形式重写其功能。

Interpolate Colors

首先,我们颜色混合着色器的第一个版本仅仅处理两个纯色之间的混合。因为这样我们就不需要考虑什么纹理、uv之类的。我们只需在加一个颜色变量、以及一个用于混合的参数,这个参数将决定两个颜色的混合权重,这里我们把它设为Range类,这样方便在材质面板上调节参数,同时确保参数的有效性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//...

//材质面板上显示的属性
Properties{
_Color ("Color", Color) = (0, 0, 0, 1) //the base color
_SecondaryColor ("Secondary Color", Color) = (1,1,1,1) //the color to blend to
_Blend ("Blend Value", Range(0,1)) = 0 //0 is the first color, 1 the second
}

//...

//混合参数,或权重
float _Blend;

//用于混合的两个颜色
fixed4 _Color;
fixed4 _SecondaryColor;
``

虽然我们没有使用纹理的颜色,但是也可以保留顶点着色器中关于UV变换的操作,下一个版本还会用到它。而作为第一个版本,我么只修改片段着色器就可以,直接根据混合参数,将第二种颜色叠加到原先的颜色上。
```c++
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = _Color + _SecondaryColor * _Blend;
return col;
}

!()[https://www.ronja-tutorials.com/assets/images/posts/009/BlendColorsAdd.gif]

现在我们可以看到混合后颜色的变化了,但是我们始终无法将其颜色完全过度到第二种颜色。这是因为混合参数只改变第二种颜色混入的颜色,而第一种颜色依然存在。

为了实现两种颜色之间的过渡渐变效果,我们需要保证两种颜色的权重和为1。

1
2
3
4
5
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = _Color * (1 - _Blend) + _SecondaryColor * _Blend;
return col;
}

这种混合操作叫做线性插值,Unity内置的lerp函数就是实现这个线性插值功能。

1
2
3
4
5
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = lerp(_Color, _SecondaryColor, _Blend);
return col;
}

最终两个颜色间的混合着色器源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Shader "Tutorial/009_Color_Blending/Plain"{
//材质面板上显示的属性
Properties{
_Color ("Color", Color) = (0, 0, 0, 1) //基础颜色
_SecondaryColor ("Secondary Color", Color) = (1,1,1,1) //用于混合的颜色
_Blend ("Blend Value", Range(0,1)) = 0 //混合权重,0 表示只显示基础颜色, 1 表示只显示混合颜色
}

SubShader{
//渲染不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

//引入内置变量和函数
#include "UnityCG.cginc"

//定义着色器函数
#pragma vertex vert
#pragma fragment frag

//混合参数
float _Blend;

//两个用于混合的颜色
fixed4 _Color;
fixed4 _SecondaryColor;

//输入的网格数据
struct appdata{
float4 vertex : POSITION;
};

//中间插值的变量,由顶点着色器到片段着色器
struct v2f{
float4 position : SV_POSITION;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//计算裁剪空间下的坐标
o.position = UnityObjectToClipPos(v.vertex);
return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = lerp(_Color, _SecondaryColor, _Blend);
return col;
}

ENDCG
}
}
}

Interpolate Textures

我们颜色混合着色器的第二个版本将考虑混合两张纹理贴图的颜色。首先我们删掉前面两个颜色变量,改成两个纹理变量。因为涉及到纹理,所以需要uv坐标来进行纹理采样。之前的纹理采样都执行了uv变换操作,实际上这一步并不是必须的,如果我们不打算缩放纹理的话,这一步就可以省略掉,与之相关的纹理参数也可以省掉。但是我们这里有两张纹理,每张纹理都打算使用各自的缩放参数,这时候可以在顶点着色器中执行uv变换,然后在传给片段着色器,也可以直接在片段着色器中进行uv变换。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
//...

//材质面板上显示的属性
Properties{
_MainTex ("Texture", 2D) = "white" {} //基础纹理颜色
_SecondaryTex ("Secondary Texture", 2D) = "black" {} //用于混合的纹理颜色
_Blend ("Blend Value", Range(0,1)) = 0 //混合权重,0 表示只显示基础颜色, 1 表示只显示混合颜色
}

//...

//定义着色器函数
#pragma vertex vert
#pragma fragment frag

//输入的网格数据
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间插值的变量,由顶点着色器到片段着色器
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//计算裁剪空间下的坐标
o.position = UnityObjectToClipPos(v.vertex);
return o;
}

//...

这里我们是在片段着色其中进行uv变换的。并且使用各自变换后的uv进行纹理采样。在得到采样颜色后,我们就可以和上一个版本一样进行线性插值了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//分别进行uv变换
float2 main_uv = TRANSFORM_TEX(i.uv, _MainTex);
float2 secondary_uv = TRANSFORM_TEX(i.uv, _SecondaryTex);

//分别进行纹理采样
fixed4 main_color = tex2D(_MainTex, main_uv);
fixed4 secondary_color = tex2D(_SecondaryTex, secondary_uv);

//最终执行线性插值
fixed4 col = lerp(main_color, secondary_color, _Blend);
return col;
}

下面是我们这个图片混合着色器的完整脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
Shader "Tutorial/009_Color_Blending/Texture"{
//材质面板属性显示
Properties{
_MainTex ("Texture", 2D) = "white" {} //第一张图
_SecondaryTex ("Secondary Texture", 2D) = "black" {} //第二张图
_Blend ("Blend Value", Range(0,1)) = 0 //0 表示只显示第一张图, 1 只显示第二张图
}

SubShader{
//不透明材质
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

//引入内置变量和函数
#include "UnityCG.cginc"

//定义着色器函数
#pragma vertex vert
#pragma fragment frag

//混合参数
float _Blend;

//两张图
sampler2D _MainTex;
float4 _MainTex_ST;

sampler2D _SecondaryTex;
float4 _SecondaryTex_ST;

//输入的网格数据
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间的插值数据
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//变换到裁剪空间
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//各自进行uv变换
float2 main_uv = TRANSFORM_TEX(i.uv, _MainTex);
float2 secondary_uv = TRANSFORM_TEX(i.uv, _SecondaryTex);

//各自进行纹理采样
fixed4 main_color = tex2D(_MainTex, main_uv);
fixed4 secondary_color = tex2D(_SecondaryTex, secondary_uv);

//线性插值
fixed4 col = lerp(main_color, secondary_color, _Blend);
return col;
}

ENDCG
}
}
}

Interpolation based on a Texture

前面连个的混合参数都是一个统一的变量,这样模型表面每个区域的混合权重都一样。为了达到不同权重的混合效果,最后这个版本使用纹理来作为我们的混合参数。

首先我们是将原先的混合变量用一个纹理变量替代。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//...

//材质面板属性
Properties{
_MainTex ("Texture", 2D) = "white" {} //第一张图
_SecondaryTex ("Secondary Texture", 2D) = "black" {} //第二张图
_BlendTex ("Blend Texture", 2D) = "grey" //混合权重图
}

//...

//混合权重
sampler2D _BlendTex;
float4 _BlendTex_ST;

//用于混合的图
sampler2D _MainTex;
float4 _MainTex_ST;

sampler2D _SecondaryTex;
float4 _SecondaryTex_ST;

//...

同样的,我们也对权重图进行uv变换,然后再进行采样。但是纹理采样的结果还是颜色,是一个向量,而我们的插值权重是一个标量,这时候我们可以选择其中一个合适的通道值来作为我们的插值权重。和前面一样,最后我们用这个权重值进行颜色插值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//分别进行uv变换
float2 main_uv = TRANSFORM_TEX(i.uv, _MainTex);
float2 secondary_uv = TRANSFORM_TEX(i.uv, _SecondaryTex);
float2 blend_uv = TRANSFORM_TEX(i.uv, _BlendTex);

//分别进行纹理采样
fixed4 main_color = tex2D(_MainTex, main_uv);
fixed4 secondary_color = tex2D(_SecondaryTex, secondary_uv);
fixed4 blend_color = tex2D(_BlendTex, blend_uv);

//选其中红色通道作为混合权重
fixed blend_value = blend_color.r;

//最终的颜色插值
fixed4 col = lerp(main_color, secondary_color, blend_value);
return col;
}

好了,下面是完整的着色器脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
Shader "Tutorial/009_Color_Blending/TextureBasedBlending"{
//材质面板上的属性
Properties{
_MainTex ("Texture", 2D) = "white" {} //第一张图
_SecondaryTex ("Secondary Texture", 2D) = "black" {} //第二张图
_BlendTex ("Blend Texture", 2D) = "grey" //权重图
}

SubShader{
//渲染不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

//引入内置变量和函数
#include "UnityCG.cginc"

//定义着色器函数
#pragma vertex vert
#pragma fragment frag

//混合权重图
sampler2D _BlendTex;
float4 _BlendTex_ST;

//用于混合的两张纹理
sampler2D _MainTex;
float4 _MainTex_ST;

sampler2D _SecondaryTex;
float4 _SecondaryTex_ST;

//输入的模型数据
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};

//中间的插值数据
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

//顶点着色器
v2f vert(appdata v){
v2f o;
//变换到裁剪空间
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//分别进行uv变换
float2 main_uv = TRANSFORM_TEX(i.uv, _MainTex);
float2 secondary_uv = TRANSFORM_TEX(i.uv, _SecondaryTex);
float2 blend_uv = TRANSFORM_TEX(i.uv, _BlendTex);

//分别进行纹理采样
fixed4 main_color = tex2D(_MainTex, main_uv);
fixed4 secondary_color = tex2D(_SecondaryTex, secondary_uv);
fixed4 blend_color = tex2D(_BlendTex, blend_uv);

//选取一个通道作为混合权重
fixed blend_value = blend_color.r;

//最终的颜色插值
fixed4 col = lerp(main_color, secondary_color, blend_value);
return col;
}

ENDCG
}
}
}

希望本文能让你了解着色器中颜色的基本使用、以及插值的实际应用。

所有的源码都在以下链接可以找到:

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!

原文:
Planar Mapping

Summary

有时候我们的网格数据中并没有UV坐标,或者说器UV坐标并不适用于将要使用的纹理,或者我们想让纹理和模型表面根据某一规则对齐。或者还有其他什么原因,我们需要动态生成UV坐标。那么接下来的教程,我们将以最简单的方法,二维平面映射,来创建我们的UV坐标。

本教程是在上一个图片着色器的基础上实现的。但是你也可以根据其基本思路,以表面着色器的形式重写其功能。

Basics

首先我们将输入结构体中的uv变量删除掉,因为我们打算通过脚本生成。

1
2
3
struct appdata{
float vertex : POSITION;
};

因为片段着色器中的输入参数是由顶点着色器中的输出参数插值而得到的,因此我们选择在顶点着色器中计算新的uv值。首先我们将其顶点UV设置为顶点在模型坐标系下的xz的值。这样足以让纹理出现在模型表面了,并且其效果看起来就好像是图片从上往下投影到模型表面一样。

1
2
3
4
5
6
v2f vert(appdata v){
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.uv = v.vertex.xz;
return o;
}

Adjustable Tiling

前面并没有考虑图片的缩放,或者我们可能希望图片显示不跟随模型一起旋转。

图片缩放的问题可以通过TRANSFORM_TEX宏来执行UV变换,这样最终用于纹理采样的UV可以跟随纹理缩放而相应改变。

1
2
3
4
5
6
v2f vert(appdata v){
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.vertex.xz, _MainTex);
return o;
}

Texture Coordinates based on World Position

为了消除模型位置、和旋转对UV坐标的影响,我们需要将顶点坐标转换到世界坐标系,前面的例子是使用模型坐标系中的顶点坐标来生成UV坐标的。计算世界坐标的方法很简单,只需要将顶点坐标乘以模型空间矩阵。在我们得到世界坐标后,使用其世界坐标来生成UV坐标。

1
2
3
4
5
6
7
8
9
10
v2f vert(appdata v){
v2f o;
//计算裁剪空间下的顶点坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界空间下的顶点坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
//应用纹理缩放,执行UV变换
o.uv = TRANSFORM_TEX(worldPos.xz, _MainTex);
return o;
}

从上面我们也可以看到基于世界坐标的二维平面映射也不完美,因为我们必须使用可重复的图片,否则无法覆盖整个空间区域。而且最终用渲染出来的纹理也会因为观察角度不同而发生扭曲。但是我们可以使用更牛的技术来改进,例如后面将会介绍的,三维平面映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
Shader "Tutorial/008_Planar_Mapping"{
//材质面板上显示的属性
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
//渲染不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Pass{
CGPROGRAM

#include "UnityCG.cginc"

#pragma vertex vert
#pragma fragment frag

//定义公共变量
sampler2D _MainTex;
float4 _MainTex_ST;

fixed4 _Color;

struct appdata{
float4 vertex : POSITION;
};

struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

v2f vert(appdata v){
v2f o;
//计算裁剪空间下的坐标
o.position = UnityObjectToClipPos(v.vertex);
//计算世界空间下的坐标
float4 worldPos = mul(unity_ObjectToWorld, v.vertex);
//执行UV变换
o.uv = TRANSFORM_TEX(worldPos.xz, _MainTex);
return o;
}

fixed4 frag(v2f i) : SV_TARGET{
//纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//多个颜色叠加
col *= _Color;
return col;
}

ENDCG
}
}
FallBack "Standard" //当当前着色器不支持时,选择后补着色器中的功能
}

你可以在以下链接找到源码:https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/008_Planar_Mapping/planar_mapping.shader

希望你能喜欢这个教程哦!如果你想支持我,可以关注我的推特,或者通过ko-fi、或patreon给两小钱。总之,各位大爷,走过路过不要错过,有钱的捧个钱场,没钱的捧个人场:-)!!!