0%

原文:
Perlin Noise

Perlin Noise

泊林噪声也是非常常用的一种噪声。和上一篇值类噪声非常相似,泊林噪声也是基于噪声色块,是“梯度噪声”实现方式的一种,所以容易产生重复、光滑的效果。它们之间的区别在于本篇将上上一篇得到的噪声当做方向来处理,而值类噪声则将其当做值来处理。因为噪声部分内容复杂,建议你先从噪点、和值类噪声开始学习。

Gradient Noise in one Dimension

泊林噪声是多维梯度噪声中的一种。因为一维梯度噪声比较简单,所以我们先从它入手。

首先我们实现一维噪声生成着色器,并且把所有相关代码封装到noise函数中,方便阅读。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float gradientNoise(float value){
float previousCellNoise = rand1dTo1d(floor(value));
float nextCellNoise = rand1dTo1d(ceil(value));
float interpolator = frac(value);
interpolator = easeInOut(interpolator);
return lerp(previousCellNoise, nextCellNoise, interpolator);
}

void surf (Input i, inout SurfaceOutputStandard o) {
float value = i.worldPos.x / _CellSize;
float noise = perlinNoise(value);

float dist = abs(noise - i.worldPos.y);
float pixelHeight = fwidth(i.worldPos.y);
float lineIntensity = smoothstep(2*pixelHeight, pixelHeight, dist);
o.Albedo = lerp(1, 0, lineIntensity);
}

正如开篇提到的,泊林噪声是将初始的随机噪声当做方向来处理,然后在此基础上进行进一步平滑操作。因此我们首先要计算梯度。梯度方向可上可下,因此我们需要将噪声值从[0,1]区间变换到[-1,1]区间。

在前面值类噪声处理中,我们很容易得到每个色块的噪声值,但是现在我们记录的并不是色块的噪声值,而是该色块噪声变化的一种趋势,也就是前面说的方向,梯度方向。但是在平滑处理的插值运算中,我们仍然需要知道色块具体位置的噪声值,所以我们要根据这个梯度方向来重新构造一个噪声分布曲线。因为梯度的存在,这里构造的是倾斜的一段一段的直线。在前面的值类噪声中的线段是水平的,这也是值类和方向类噪声处理的实际差别。

1
2
3
4
5
6
7
8
float gradientNoise(float value){
float fraction = frac(value);

float previousCellInclination = rand1dTo1d(floor(value)) * 2 - 1;
float previousCellLinePoint = previousCellInclination * fraction;

return previousCellLinePoint;
}

上面的线段都是沿着色块中心点沿着一个方向伸展的,色块之间没有重叠区域,所以需要去下一个色块,沿着反方向延伸。这样,两个相邻色块之间有了重叠区域,可以做插值平滑处理。而正反方向的延伸要保持在同一条直线上。因为前面定义正向延伸的区间是0到1,所以反向延伸的区间就是-1到0。

1
2
float nextCellInclination = rand1dTo1d(ceil(value)) * 2 - 1; //下一个色块中心
float nextCellLinePoint = nextCellInclination * (fraction - 1);// -1 到 0

接下来是前后两端的插值,为了保证线条连续,我们设定距离哪个色块越近,其插值权重越大。为了保证线条光滑,我们仍然使用缓动函数来平滑。

1
2
3
4
5
6
7
8
9
10
11
12
float gradientNoise(float value){
float fraction = frac(value);
float interpolator = easeInOut(fraction);

float previousCellInclination = rand1dTo1d(floor(value)) * 2 - 1;
float previousCellLinePoint = previousCellInclination * fraction;

float nextCellInclination = rand1dTo1d(ceil(value)) * 2 - 1;
float nextCellLinePoint = nextCellInclination * (fraction - 1);

return lerp(previousCellLinePoint, nextCellLinePoint, interpolator);
}

另一个小改动是,在调用一维随机数函数时,我将其中不再使用乘积方式,而是改成加一个常数,这样当输入为0的时候,结果也不会永远是零。这个我在随机数那一篇文章应该也仅仅做了修改。

1
2
3
4
float rand1dTo1d(float3 value, float mutator = 0.546){
float random = frac(sin(value + mutator) * 143758.5453);
return random;
}

2d Perlin Noise

对于多维泊林噪声,处理起来就比较麻烦了。首先我们噪声的需要在多个维度进行插值,并且我们的噪声梯度方向也是多维的。

1
2
3
float perlinNoise(float2 value){
//...
}

而且相邻点也有两个变为四个,也就是需要在色块的四个角点上进行噪声值采样,将采样值转换为方向向量。

1
2
3
4
float2 lowerLeftDirection = rand2dTo2d(float2(floor(value.x), floor(value.y))) * 2 - 1;
float2 lowerRightDirection = rand2dTo2d(float2(ceil(value.x), floor(value.y))) * 2 - 1;
float2 upperLeftDirection = rand2dTo2d(float2(floor(value.x), ceil(value.y))) * 2 - 1;
float2 upperRightDirection = rand2dTo2d(float2(ceil(value.x), ceil(value.y))) * 2 - 1;

得到四个角点的梯度向量后,计算四个角点在当前点的贡献值。

1
2
3
4
5
6
float2 fraction = frac(value);

float lowerLeftFunctionValue = dot(lowerLeftDirection, fraction - float2(0, 0));
float lowerRightFunctionValue = dot(lowerRightDirection, fraction - float2(0, 1));
float upperLeftFunctionValue = dot(upperLeftDirection, fraction - float2(1, 0));
float upperRightFunctionValue = dot(upperRightDirection, fraction - float2(1, 1));

然后对他们进行二次插值。

1
2
3
4
5
6
7
8
float interpolatorX = easeInOut(fraction.x);
float interpolatorY = easeInOut(fraction.y);

float lowerCells = lerp(lowerLeftFunctionValue, lowerRightFunctionValue, interpolatorX);
float upperCells = lerp(upperLeftFunctionValue, upperRightFunctionValue, interpolatorX);

float noise = lerp(lowerCells, upperCells, interpolatorY);
return noise;

现在我们可以在片段函数中使用我们的泊林噪声函数了,因为生成的泊林噪声值是在-0.5到0.5之间,所以我们需要将其映射到0到1之间。

1
2
3
4
5
6
void surf (Input i, inout SurfaceOutputStandard o) {
float2 value = i.worldPos.xz / _CellSize;
float noise = perlinNoise(value) + 0.5;

o.Albedo = noise;
}

3d Perlin Noise

三维泊林噪声和上一遍的值类噪声很类似,只不过在最内层循环中,我们不能直接得到噪声值,而是需要通过梯度方向来计算噪声值。

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
float perlinNoise(float3 value){
float3 fraction = frac(value);

float interpolatorX = easeInOut(fraction.x);
float interpolatorY = easeInOut(fraction.y);
float interpolatorZ = easeInOut(fraction.z);

float cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
float3 cellDirection = rand3dTo3d(cell) * 2 - 1;
float3 compareVector = fraction - float3(x, y, z);
cellNoiseX[x] = dot(cellDirection, compareVector);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}

然后在片段着色器中,我们使用三维向量就可以生成三维泊林噪声了。

1
2
3
4
5
6
7
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos / _CellSize;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

o.Albedo = noise;
}

Special Use Case

泊林噪声看起来像行为怪异的云朵,但是可以实现一些有趣的效果。

首先,我们可以将相同值的泊林噪声点相连接,形成类似地图等高线的图案。首先我们将噪声值放大,然后取其小数部分。

1
2
3
4
5
6
7
float3 value = i.worldPos / _CellSize;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

noise = frac(noise * 6);

o.Albedo = noise;

然后我们来实现光滑的等高线。首先我们需要计算一个像素内的噪声变化情况,这里我们可以使用fwidth函数。然后使用smoothstep函数来剔除小于1的像素,因为像素是离散分布的,所以有些像素只能近似等于1,所以需要以一个像素内的变化值来做近似处理。同样的我们再剔除大于0的像素,两次剔除得到的边界相叠加。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos / _CellSize;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

noise = frac(noise * 6);

float pixelNoiseChange = fwidth(noise);

float heightLine = smoothstep(1-pixelNoiseChange, 1, noise);
heightLine += smoothstep(pixelNoiseChange, 0, noise);

o.Albedo = heightLine;
}

还有一个使用多维噪声的技巧,就是将其中一个不用的维度抽出来,加上时间变量,这样随着时间的改变,我们的噪声图也会随着变化。

1
2
3
4
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
_ScrollSpeed ("Scroll Speed", Range(0, 1)) = 1
}
1
2
3
//公共变量
float _CellSize;
float _ScrollSpeed;
1
2
3
4
float3 value = i.worldPos / _CellSize;
value.y += _Time.y * _ScrollSpeed;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

Source

1d gradient noise

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
Shader "Tutorial/026_perlin_noise/1d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator * interpolator * interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float gradientNoise(float value){
float fraction = frac(value);
float interpolator = easeInOut(fraction);

float previousCellInclination = rand1dTo1d(floor(value)) * 2 - 1;
float previousCellLinePoint = previousCellInclination * fraction;

float nextCellInclination = rand1dTo1d(ceil(value)) * 2 - 1;
float nextCellLinePoint = nextCellInclination * (fraction - 1);

return lerp(previousCellLinePoint, nextCellLinePoint, interpolator);
}

void surf (Input i, inout SurfaceOutputStandard o) {
float value = i.worldPos.x / _CellSize;
float noise = gradientNoise(value);

float dist = abs(noise - i.worldPos.y);
float pixelHeight = fwidth(i.worldPos.y);
float lineIntensity = smoothstep(2*pixelHeight, pixelHeight, dist);
o.Albedo = lerp(1, 0, lineIntensity);
}
ENDCG
}
FallBack "Standard"
}

2d perlin noise

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/026_perlin_noise/2d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;
float _Jitter;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float perlinNoise(float2 value){
//计算色块四个顶点的梯度向量
float2 lowerLeftDirection = rand2dTo2d(float2(floor(value.x), floor(value.y))) * 2 - 1;
float2 lowerRightDirection = rand2dTo2d(float2(ceil(value.x), floor(value.y))) * 2 - 1;
float2 upperLeftDirection = rand2dTo2d(float2(floor(value.x), ceil(value.y))) * 2 - 1;
float2 upperRightDirection = rand2dTo2d(float2(ceil(value.x), ceil(value.y))) * 2 - 1;

float2 fraction = frac(value);

//计算四个顶点在当前点的贡献值
float lowerLeftFunctionValue = dot(lowerLeftDirection, fraction - float2(0, 0));
float lowerRightFunctionValue = dot(lowerRightDirection, fraction - float2(1, 0));
float upperLeftFunctionValue = dot(upperLeftDirection, fraction - float2(0, 1));
float upperRightFunctionValue = dot(upperRightDirection, fraction - float2(1, 1));

float interpolatorX = easeInOut(fraction.x);
float interpolatorY = easeInOut(fraction.y);

//二次插值
float lowerCells = lerp(lowerLeftFunctionValue, lowerRightFunctionValue, interpolatorX);
float upperCells = lerp(upperLeftFunctionValue, upperRightFunctionValue, interpolatorX);

float noise = lerp(lowerCells, upperCells, interpolatorY);
return noise;
}

void surf (Input i, inout SurfaceOutputStandard o) {
float2 value = i.worldPos.xz / _CellSize;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

o.Albedo = noise;
}
ENDCG
}
FallBack "Standard"
}

3d perlin noise

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
Shader "Tutorial/026_perlin_noise/3d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;
float _Jitter;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float perlinNoise(float3 value){
float3 fraction = frac(value);

float interpolatorX = easeInOut(fraction.x);
float interpolatorY = easeInOut(fraction.y);
float interpolatorZ = easeInOut(fraction.z);

float cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
float3 cellDirection = rand3dTo3d(cell) * 2 - 1;
float3 compareVector = fraction - float3(x, y, z);
cellNoiseX[x] = dot(cellDirection, compareVector);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}

void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos / _CellSize;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

o.Albedo = noise;
}
ENDCG
}
FallBack "Standard"
}

special use tircks

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
Shader "Tutorial/026_perlin_noise/special" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
_ScrollSpeed ("Scroll Speed", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;
float _ScrollSpeed;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float perlinNoise(float3 value){
float3 fraction = frac(value);

float interpolatorX = easeInOut(fraction.x);
float interpolatorY = easeInOut(fraction.y);
float interpolatorZ = easeInOut(fraction.z);

float cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
float3 cellDirection = rand3dTo3d(cell) * 2 - 1;
float3 compareVector = fraction - float3(x, y, z);
cellNoiseX[x] = dot(cellDirection, compareVector);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}

void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos / _CellSize;
value.y += _Time.y * _ScrollSpeed;
//将噪声值映射到0-1区间
float noise = perlinNoise(value) + 0.5;

noise = frac(noise * 6);

float pixelNoiseChange = fwidth(noise);

float heightLine = smoothstep(1-pixelNoiseChange, 1, noise);
heightLine += smoothstep(pixelNoiseChange, 0, noise);

o.Albedo = heightLine;
}
ENDCG
}
FallBack "Standard"
}

我花了很长时间来理解泊林噪声的工作原理,这里做了一些总结,希望能对你理解泊林噪声有所帮助。

你可以在以下链接找到源码:

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

原文:
Partial Derivatives (fwidth)

ddxddyfwidth这三个偏导函数,我们平时很少用到,而且刚接触的时候很难理解。但是我却很喜欢它们,因为我觉得有很多适合它们直接使用的场景。所以在这里我也想向你们介绍它们。因此下面主要是针对函数进行讲解,所以并不要求你对渲染有多深的了解。但是你还需要掌握一些基本的渲染相关的知识,所以如果你完全没有基础,建议你先从着色器基础看起。

DDX and DDY

“导数“的意义是表示函数在某个点的变化情况。使用导数的概念,我们可以计算屏幕上任意点与临近点之间的变化情况。在上面三个函数中,ddx、和ddy是最简单的两个。它们分别用来计算水平、和垂直两个方向的相邻两个像素点之间的变化情况。这个计算过程并不涉及到什么复杂大函数,也没有说需要什么复杂的GPU结构支持,它就是一个简单的、像素之间的差值计算。但是,有一点你必须清楚,当我们计算某个像素点的导数时,并不是单独地、针对每一个像素都计算一遍。而是将2x2的像素块组成一个独立的处理单元,我们的片段着色器也是以像素块为单元,进行并行处理的。可以这么理解,片段着色器一次性串行的执行像素块中的四个像素的逻辑,所以一个片段着色器可以同时访问四个像素值,因此可以一次性计算出它们的偏导。其中ddx函数是计算水平方向的两个相邻像素的差值,ddy是计算垂直方向的两个相邻像素的差值。这也意味着,同一个像素块中,相邻的两个像素的ddx、或ddy相同。

这里我实现一个简单的着色器来做个测试。在片段着色器中传入了uv坐标,然后我们使用ddx来计算水平方向相邻两个像素u坐标的变化情况,然后再乘以一个缩放因子便于观察,然后将结果以颜色值得形式显示在屏幕上。

1
2
3
4
5
6
7
8
9
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//计算uv坐标中的u值在水平相邻像素之间的差异
float derivative = ddx(i.uv.x) * _Factor;
//将偏导转换为灰度值
fixed4 col = float4(derivative.xxx , 1);
col *= _Color;
return col;
}

然后我们在场景中观察其表现,我们发现模型的灰度值受uv坐标和屏幕坐标之间的关系的影响。开始我们拉进、或者放大观察,发现灰度慢慢变暗,这是因为放大后,相邻两个像素对应的UV坐标的u值差减小了。当我们旋转90度后发现其完全变黑,这是因为旋转后u方向和x方向垂直,所以相邻两个像素对应的UV坐标的u值完全相同,差值为零。

仅仅这些就已经可以实现很多功能了,例如我们可以使用深度图粗略的计算出法向纹理,然后tex2D函数会基于该法向值的变化来选择mipmap的层级。但是更多时候我们需要得到所有的变化情况,而不是某一个方向的变化情况,而fwidth的作用正在于此。

fwidth

如果你想将ddxddy两个函数结合起来使用,最直接的方法就是取各自结果的绝对值求和。下面就是我们自定义的fwidth函数。

1
2
3
float fwidth(float value){
return abs(ddx(value)) + abs(ddy(value));
}

如果我们将前面的着色器中的ddx函数用fwidth来替换,你会发现缩放的时候灰度变化和之前一样,但是旋转的时候灰度变化相对更亮,在90度时也不是全黑了。当然我们使用余弦公式来替代前面的绝对值求和,这样颜色变化的估计值会更准确一点,但是很多时候并不是使用越高级的函数,消耗更多的性能,就可以得到更好的效果。

Non-aliased step

fwidth的第一个应用场景,至少对于我来说,就是在不处理锯齿的情况下,计算出梯度值然后使用指定阈值进行区域划分。它会以不同的形式应用到火焰、水、卡通光照等场景中。进行梯度划分最简单的方法就是使用step函数–也叫阶跃函数,然后将梯度值和阈值传入step函数中,得到的结果在应用到线性插值函数lerp上,可以对不同区域进行上色。当然这里step函数会引入锯齿等边缘问题。这里我们可以实现一个逆插值函数来替代step函数,逆插值函数通过计算单个像素点上的变化情况,来实现区域划分,同时保证中间有渐变过渡带,这样就解决了锯齿问题。

首先我们使用fwidth来计算像素梯度,然后在执行逆插值之前,我们先要计算我们的边界区间,也就是我们的过渡带。下面的0.5就是我们的边界阈值,halfChange是我们的半带宽,当然边界阈值和带宽都可以根据应用场景来定义。重点是后面的逆插值函数,会将当前像素值和过度带进行比较,在过渡带区间的值将映射为0-1之间的值,也就是灰色区域,而不在过渡带区间的值将会映射为小于-1或大于1的值。然后我们使用saturate函数对其进行裁剪,就可以得到无锯齿的区域划分图案。

通过上面的描述我们可能有些属性,这不就是smoothstep函数的功能吗。是的,但是作为内置函数的smoothstep,它在过渡带区域还使用了一些平滑操作,相应的计算量会大一点。但是本身边界区域就很窄,平滑效果并不会很明显,所以我们这里并不需要多余的平滑。使用我们的逆插值函数就足够了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//片段函数
fixed4 frag(v2f i) : SV_TARGET{
//选取用于求梯度的像素值
float gradient = i.uv.x;
//计算梯度
float halfChange = fwidth(gradient) / 2;
//计算过渡带上下边界
float lowerEdge = 0.5 - halfChange;
float upperEdge = 0.5 + halfChange;
//使用你插值函数,将像素值划分为过渡带上侧、中间、下侧三个区域
float stepped = (gradient - lowerEdge) / (upperEdge - lowerEdge);
stepped = saturate(stepped);
//将划分结果转换为颜色显示
fixed4 col = float4(stepped.xxx, 1);
return col;
}

下面是我实现的三种边界效果,左边第一个是使用step实现的,边界具有明显锯齿效应。第二个是使用smoothstep实现的,进行一定的边界平滑。最后一个是通过上面的逆插值方法实现的。后面两个的抗锯齿效果几乎一般无二,所以我建议你在使用smoothstep函数前可以考虑一下是否可以使用逆插值方法,可以节省一部分性能开销。

A better step?

上面我们介绍了一种更好的边界平滑的技术,但是步骤多,写起来比较复杂。虽然这个方法也有一定的固定开销,不可能继续优化,而且99%的性能瓶颈问题都不是函数固定开销引起的。就像前面提到的tex2d,在执行的时候也会调用这些函数,但是这些函数在其中的消耗占比并不高。但是,我们可以将这个方法进行封装,这样我们在使用的时候就可以方便的调用了。

step函数有两个参数,第一个是边界值,第二个是用来比较的值,当后者小于前者时,返回0,否则,返回1。同样,我们可以将上面的方法封装成和step类似的函数,只不过除了0和1,还会返回中间过渡区域的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//我们自定义的加强版的边界划分函数
float aaStep(float compValue, float gradient){
float halfChange = fwidth(gradient) / 2;
//计算过渡带上下边界
float lowerEdge = compValue - halfChange;
float upperEdge = compValue + halfChange;
//使用你插值函数,将像素值划分为过渡带上侧、中间、下侧三个区域
float stepped = (gradient - lowerEdge) / (upperEdge - lowerEdge);
stepped = saturate(stepped);
return stepped;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
float stepped = aaStep(0.5, i.uv.x);
//显示边界
fixed4 col = float4(stepped.xxx, 1);
return col;
}

An example

通过程序实现火焰效果,是阶跃函数一个比较好的应用方向,这里我大致参考Febucci火焰着色器来实现我们的火焰效果。

这里我们随着时间不断对UV坐标进行偏移,然后用偏移后的UV对噪声图进行采样,采样结果当做是当前位置火焰的强度。另外我对将uv坐标的v值取反然后平方,这样所求得的梯度值就会沿着y方向成递减的趋势,从而使得火焰的形状下密上疏。这里我们使用的噪声图是泊林噪声,是在之前教程中实现的。然后我们将噪声图中的采样值当做是阶跃阈值,这样就得到一个火焰的基本轮廓。为了模拟更逼真的火焰分层效果,这里我们通过偏移,产生多个火焰轮廓,然后使用线性插值函数来对这些分层区域上色。

然后我们将aaStep函数中的梯度值除以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
27
28
29
30
31
32
33
34
35
36
37
38
//强化版的阶跃函数
float aaStep(float compValue, float gradient){
float change = fwidth(gradient);
//计算过渡边界
float lowerEdge = compValue - change;
float upperEdge = compValue + change;
//使用逆插值函数
float stepped = (gradient - lowerEdge) / (upperEdge - lowerEdge);
stepped = saturate(stepped);
//最终结果近似于 `smoothstep(lowerEdge, upperEdge, gradient)`
return stepped;
}

//片段着色函数
fixed4 frag(v2f i) : SV_TARGET{
//使用平方值,让火焰变得更加旺盛
float fireGradient = 1 - i.uv.y;
fireGradient = fireGradient * fireGradient;
//滑动uv,产生动画效果
float2 fireUV = TRANSFORM_TEX(i.uv, _MainTex);
fireUV.y -= _Time.y * _ScrollSpeed;
//噪声采样
float fireNoise = tex2D(_MainTex, fireUV).x;

//划分火焰区域
float outline = aaStep(fireNoise, fireGradient);
float edge1 = aaStep(fireNoise, fireGradient - _Edge1);
float edge2 = aaStep(fireNoise, fireGradient - _Edge2);

//定义火焰外层颜色
fixed4 col = _Color1 * outline;
//其他层的颜色
col = lerp(col, _Color2, edge1);
col = lerp(col, _Color3, edge2);

//输出结果
return col;
}

下面我还对比了普通阶跃函数和我们这里加强版的阶跃函数,差别看起来不大,但是如果你的游戏审美要求是像素级别的,那么这个差别还是很明显的。所以我觉得你可以从现在开始,使用这里的方法,让你的游戏画面开起来更加柔和、更加平滑,即便是在低分辨率的情况下。

Sources

  • https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/046_Partial_Derivatives/testing.shader
    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/046_Partial_Derivatives/testing"{
    //材质面板
    Properties{
    _Factor("Factor", Range(0, 100)) = 1
    }

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

    Cull Off

    Pass{
    CGPROGRAM

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

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

    float _Factor;

    //模型网格数据
    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 derivative = fwidth(i.uv.x) * _Factor;
    //可视化梯度
    fixed4 col = float4(derivative.xxx , 1);
    return col;
    }

    ENDCG
    }
    }
    }
  • https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/046_Partial_Derivatives/aa_step.shader
    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
    Shader "Tutorial/046_Partial_Derivatives/aaStep"{
    //材质面板
    Properties{

    }

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

    Cull Off

    Pass{
    CGPROGRAM

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

    //声明顶点、片段着色器
    #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);
    o.uv = v.uv;
    return o;
    }

    //强化版阶跃函数
    float aaStep(float compValue, float gradient){
    float halfChange = fwidth(gradient) / 2;
    //计算边界
    float lowerEdge = compValue - halfChange;
    float upperEdge = compValue + halfChange;
    //使用逆插值函数
    float stepped = (gradient - lowerEdge) / (upperEdge - lowerEdge);
    stepped = saturate(stepped);
    //计算结果近似于 `smoothstep(lowerEdge, upperEdge, gradient)`
    return stepped;
    }

    //片段着色器
    fixed4 frag(v2f i) : SV_TARGET{
    float stepped = aaStep(0.5, i.uv.x);
    //梯度可视化
    fixed4 col = float4(stepped.xxx, 1);
    return col;
    }



    ENDCG
    }
    }
    }
  • https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/046_Partial_Derivatives/Fire.shader
    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
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    Shader "Tutorial/046_Partial_Derivatives/fire"{
    //材质面板
    Properties{
    _MainTex ("Fire Noise", 2D) = "white" {}
    _ScrollSpeed("Animation Speed", Range(0, 2)) = 1

    _Color1 ("Color 1", Color) = (0, 0, 0, 1)
    _Color2 ("Color 2", Color) = (0, 0, 0, 1)
    _Color3 ("Color 3", Color) = (0, 0, 0, 1)

    _Edge1 ("Edge 1-2", Range(0, 1)) = 0.25
    _Edge2 ("Edge 2-3", Range(0, 1)) = 0.5
    }

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

    Cull Off
    Blend SrcAlpha OneMinusSrcAlpha
    ZWrite Off

    Pass{
    CGPROGRAM

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

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

    //火焰颜色
    fixed4 _Color1;
    fixed4 _Color2;
    fixed4 _Color3;

    float _Edge1;
    float _Edge2;

    float _ScrollSpeed;

    sampler2D _MainTex;
    float4 _MainTex_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;
    }

    //强化版阶跃函数
    float aaStep(float compValue, float gradient){
    float change = fwidth(gradient);
    //计算边界
    float lowerEdge = compValue - change;
    float upperEdge = compValue + change;
    //使用逆插值函数
    float stepped = (gradient - lowerEdge) / (upperEdge - lowerEdge);
    stepped = saturate(stepped);
    //结果近似于 `smoothstep(lowerEdge, upperEdge, gradient)`
    return stepped;
    }

    //片段着色器
    fixed4 frag(v2f i) : SV_TARGET{
    //平方使得火焰更旺盛
    float fireGradient = 1 - i.uv.y;
    fireGradient = fireGradient * fireGradient;
    //滑动uv值,产生动画效果
    float2 fireUV = TRANSFORM_TEX(i.uv, _MainTex);
    fireUV.y -= _Time.y * _ScrollSpeed;
    //噪声采样
    float fireNoise = tex2D(_MainTex, fireUV).x;

    //计算火焰轮廓
    float outline = aaStep(fireNoise, fireGradient);
    float edge1 = aaStep(fireNoise, fireGradient - _Edge1);
    float edge2 = aaStep(fireNoise, fireGradient - _Edge2);

    //外层火焰颜色
    fixed4 col = _Color1 * outline;
    //其他层火焰颜色
    col = lerp(col, _Color2, edge1);
    col = lerp(col, _Color3, edge2);

    //输出结果
    return col;
    }

    ENDCG
    }
    }
    }

相关文章

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

原文:
Value Noise

上一篇文章介绍了如何在着色器中生成随机数。这里我想对前面生成的随机噪声图进行插值,得到一个光滑、渐变的噪声图。因为我们需要事先生成好的噪声图来进行插值,所以建议你先阅读上一篇来实现噪声图。本篇讲的值噪声和泊林噪声有点区别,首先两者都是在上一篇生成的随机噪声的基础上进一步平滑得到的,区别在于,本篇将上一篇得到的噪声当做值来处理,而泊林噪声则将其当做方向来处理。

Show a Line

首先我们来实现一个简单的一维噪声可视化显示,这个是以上一篇中的噪声块为起点,然后改变这个色块的尺寸变量为标量,因为我们将处理的是一维数据。然后我们只通过世界坐标的x值来生成一维噪声。

1
2
3
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
1
float _CellSize;
1
2
3
4
void surf (Input i, inout SurfaceOutputStandard o) {
float value = floor(i.worldPos.x / _CellSize);
o.Albedo = rand1dTo1d(value);
}

这样我们可以看到我们的噪声图沿着x方向分布。接下来我们将这个分布图改为曲线表示,这样我们可以清楚地看到噪声的变化情况。首先我们将像素点的y坐标减去该像素的噪声值,然后取绝对值。

1
2
3
4
5
6
void surf (Input i, inout SurfaceOutputStandard o) {
float value = floor(i.worldPos.x / _CellSize);
float noise = rand1dTo1d(value);
float dist = abs(noise - i.worldPos.y);
o.Albedo = dist;
}

然后我们使用这个差值,并且设定一个阈值来剔除远离噪声值得点,这样我们就得到一根细细的线,不过这个阈值不好选定,线的粗细也不好控制。这里有一个更好的方法,通过计算像素值之间的梯度值,我们可以精确的到宽度为1个像素的细线。需要用到的函数就是fwidth,这个函数会自动比较相邻像素之间的值,然后返回梯度值。这里我们对世界坐标点的y求梯度,其物理含义是像素点的单位长度。所以用像素点的单位长度来作为阈值,就可以控制线的粗细了。

1
2
3
4
5
6
7
8
void surf (Input i, inout SurfaceOutputStandard o) {
float value = floor(i.worldPos.x / _CellSize);
float noise = rand1dTo1d(value);
float dist = abs(noise - i.worldPos.y);
float pixelHeight = fwidth(i.worldPos.y);
float lineIntensity = smoothstep(0, pixelHeight, dist);
o.Albedo = lineIntensity;
}

Interpolate Cells in one Dimension

为了实现色块之间插值功能,首先我们需要在片段着色器中执行两次噪声采样,分别是当前色块,和上一个色块。我们可以用floorceil两个函数,来计算上下两个色块的值。这里我们还可以直接使用坐标的小数部分作为插值位置。

1
2
3
4
float value = i.worldPos.x / _CellSize;
float previousCellNoise = rand1dTo1d(floor(value));
float nextCellNoise = rand1dTo1d(ceil(value));
float noise = lerp(previousCellNoise, nextCellNoise, frac(value));

插值后得到一根连续的曲线,不过我希望曲线更光滑一点。因此,我们可以实现简单的缓动函数,后面我们还会深入的介绍缓动函数,不过这里使用简单的几种就够了。首先我们实现名为easeIn的缓入函数,这里我们执行平方操作,这样插值的边界值还是0-1,但是在接近0的时候变化更缓慢。然后我们将缓入函数应用到我们的插值中。

1
2
3
inline float easeIn(float interpolator){
return interpolator * interpolator;
}
1
2
3
float interpolator = frac(value);
interpolator = easeIn(interpolator);
float noise = lerp(previousCellNoise, nextCellNoise, interpolator);

使用缓入函数后,我们发现色块的开头位置更加平缓。下面我们再实现一个名为EaseOut的缓出函数,使得色块结尾位置更加平缓。在实现缓出函数时,我们利用了前面的缓入函数,不过将插值翻了个个,这样就是1附近的值变化更缓慢。然后我们还要将结果翻转一遍,这样可以保证缓入缓出同时应用的时候,连接部位是光滑的。

1
2
3
float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

最后一步是将两者结合,实现缓入缓出的效果。这里我们还是使用线性插值函数,当插值接近0时,我们倾向于使用缓入效果,当插值接近1时,我们倾向于使用缓出效果。

1
2
3
4
5
float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}
1
2
3
float interpolator = frac(value);
interpolator = easeInOut(interpolator);
float noise = lerp(previousCellNoise, nextCellNoise, interpolator);

有了缓入缓出函数,我们就可以对我们的一维噪声色块进行平滑处理了。

Interpolate Cells in two Dimensions

实现两个维度的插值,我们可以选择相邻的四个色块。然后沿着x轴进行平滑处理、再沿着y轴进行平滑处理。

现在代码量比较多,所以我们单独封装一个函数来执行者两个维度的插值逻辑。这里我们使用rand2dTo1d分别计算四个色块的噪声值,然后分别计算xy轴方向的缓入缓出插值,首先沿着x方向计算两两之间的插值,然后将结果沿着y方向计算最终的插值结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float ValueNoise2d(float2 value){
float upperLeftCell = rand2dTo1d(float2(floor(value.x), ceil(value.y)));
float upperRightCell = rand2dTo1d(float2(ceil(value.x), ceil(value.y)));
float lowerLeftCell = rand2dTo1d(float2(floor(value.x), floor(value.y)));
float lowerRightCell = rand2dTo1d(float2(ceil(value.x), floor(value.y)));

float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));

float upperCells = lerp(upperLeftCell, upperRightCell, interpolatorX);
float lowerCells = lerp(lowerLeftCell, lowerRightCell, interpolatorX);

float noise = lerp(lowerCells, upperCells, interpolatorY);
return noise;
}
1
2
3
4
5
6
void surf (Input i, inout SurfaceOutputStandard o) {
float2 value = i.worldPos.xy / _CellSize;
float noise = ValueNoise2d(value);

o.Albedo = noise;
}

Interpolate Cells in Three Dimensions and Loops

三个维度的插值方法和两个维度的方法基本一致。首先需要选择八个相邻的色块,然后沿着x轴进行两两插值,其结果再沿着y轴进行两两插值,其结果再沿着z轴两两插值,得到最终的插值结果。

但是为三维插值重写上面的方法,会产生很多行代码,不利于理解和管理。所以我们使用循环语句来避免这个问题。这里每个循环迭代两次,分别计算相邻两个需要插值的量。而最内层的循环将计算x方向相邻色块的值,然后存入临时数组中,待执行完后,计算x方向的插值。这里我们在循环语句前加[unroll],表示我们的循环在编译时会被展开。因为GPU执行循环语句比较慢,而展开后便不再是循环语句了。

1
2
3
4
5
6
7
8
9
10
11
float interpolatorX = easeInOut(frac(value.x));

int y = 0, z = 0;

float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
cellNoiseX[x] = rand3dTo1d(cell);
}
float interpolatedX = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);

然后在外层再套一个循环,这个循环也会执行两次,然后将上一个循环中的插值结果存入临时数组,待结束后进行插值。这个操作和前面的二维插值效果基本一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));

int z = 0;

float cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
cellNoiseX[x] = rand3dTo1d(cell);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
float interpolatedXY = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);

最后在外层再套一个循环,和上一层循环类似,会将上一层循环执行两遍,在一个临时数组中记录上一层的插值结果。然后在循环结束后,对临时数组中的结果进行插值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
float ValueNoise3d(float3 value){
float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));
float interpolatorZ = easeInOut(frac(value.z));

float cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
cellNoiseX[x] = rand3dTo1d(cell);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}
1
2
3
4
5
6
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos.xyz / _CellSize;
float noise = ValueNoise3d(value);

o.Albedo = noise;
}

3d Output Values

上面实现了一维噪声值得平滑处理,把它改成三维噪声的平滑处理非常简单。只需要把随机函数从一维改为三维。然后将所有与噪声值相关的数据类型都改为三维。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
float3 ValueNoise3d(float3 value){
float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));
float interpolatorZ = easeInOut(frac(value.z));

float3 cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float3 cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float3 cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
cellNoiseX[x] = rand3dTo3d(cell);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float3 noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}
1
2
3
4
5
6
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos.xyz / _CellSize;
float3 noise = ValueNoise3d(value);

o.Albedo = noise;
}

Source

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
Shader "Tutorial/025_value_noise/1d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

void surf (Input i, inout SurfaceOutputStandard o) {
float value = i.worldPos.x / _CellSize;
float previousCellNoise = rand1dTo1d(floor(value));
float nextCellNoise = rand1dTo1d(ceil(value));
float interpolator = frac(value);
interpolator = easeInOut(interpolator);
float noise = lerp(previousCellNoise, nextCellNoise, interpolator);

float dist = abs(noise - i.worldPos.y);
float pixelHeight = fwidth(i.worldPos.y);
float lineIntensity = smoothstep(0, pixelHeight, dist);
o.Albedo = lineIntensity;
}
ENDCG
}
FallBack "Standard"
}
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
Shader "Tutorial/025_value_noise/2d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float ValueNoise2d(float2 value){
float upperLeftCell = rand2dTo1d(float2(floor(value.x), ceil(value.y)));
float upperRightCell = rand2dTo1d(float2(ceil(value.x), ceil(value.y)));
float lowerLeftCell = rand2dTo1d(float2(floor(value.x), floor(value.y)));
float lowerRightCell = rand2dTo1d(float2(ceil(value.x), floor(value.y)));

float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));

float upperCells = lerp(upperLeftCell, upperRightCell, interpolatorX);
float lowerCells = lerp(lowerLeftCell, lowerRightCell, interpolatorX);

float noise = lerp(lowerCells, upperCells, interpolatorY);
return noise;
}

void surf (Input i, inout SurfaceOutputStandard o) {
float2 value = i.worldPos.xy / _CellSize;
float noise = ValueNoise2d(value);

o.Albedo = noise;
}
ENDCG
}
FallBack "Standard"
}
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
Shader "Tutorial/025_value_noise/3d" {
Properties {
_CellSize ("Cell Size", Range(0, 1)) = 1
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "Random.cginc"

float _CellSize;

struct Input {
float3 worldPos;
};

float easeIn(float interpolator){
return interpolator * interpolator;
}

float easeOut(float interpolator){
return 1 - easeIn(1 - interpolator);
}

float easeInOut(float interpolator){
float easeInValue = easeIn(interpolator);
float easeOutValue = easeOut(interpolator);
return lerp(easeInValue, easeOutValue, interpolator);
}

float3 ValueNoise3d(float3 value){
float interpolatorX = easeInOut(frac(value.x));
float interpolatorY = easeInOut(frac(value.y));
float interpolatorZ = easeInOut(frac(value.z));

float3 cellNoiseZ[2];
[unroll]
for(int z=0;z<=1;z++){
float3 cellNoiseY[2];
[unroll]
for(int y=0;y<=1;y++){
float3 cellNoiseX[2];
[unroll]
for(int x=0;x<=1;x++){
float3 cell = floor(value) + float3(x, y, z);
cellNoiseX[x] = rand3dTo3d(cell);
}
cellNoiseY[y] = lerp(cellNoiseX[0], cellNoiseX[1], interpolatorX);
}
cellNoiseZ[z] = lerp(cellNoiseY[0], cellNoiseY[1], interpolatorY);
}
float3 noise = lerp(cellNoiseZ[0], cellNoiseZ[1], interpolatorZ);
return noise;
}

void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos.xyz / _CellSize;
float3 noise = ValueNoise3d(value);

o.Albedo = noise;
}
ENDCG
}
FallBack "Standard"
}

本篇主要讲了使用线性插值来实现噪声图的平滑处理,希望能对你有所帮助。

你可以在以下链接找到源码:

相关文章

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

原文:
White Noise

Summary

在很多效果中需要用到随机数来生成纹理图案、又或者其他东西。下面我们以白色噪声图为例,来展示随机数用途。后面我们还会介绍其他使用随机数生成的具有一定组织结构的图案,例如泊林噪声图、和vornoi噪声图。本文是采用表面着色器来实现的,所以建议你先阅读我关于表面着色器的介绍。

Scalar noise from 3d Input

在着色器中,我们很难将上一帧画面的数据保存到下一帧,因此我们的随机数必须依赖于着色器中可访问的参数,这样无论什么时候我们都可以得到固定的随机值。这里我们使用世界坐标来生成随机值。当然如果你想让你的噪声图动起来,可以引入时间变量。

因此我们需要在表面着色器的输入结构中加入世界坐标。另外因为我们打算通过随机数生成纹理图案,所以我们不需要纹理变量,相应的UV坐标也可以删除了。

1
2
3
struct Input {
float3 worldPos;
};

接下来我们将实现随机噪声值生成函数,这样我们可以通过该函数很方便的制造随机数。首先我们的函数接收三维坐标参数,然后返回一个0-1之间的小数。将向量转换为标量最简单的方法就是点乘,但是点乘的结果可能非常大,所以我们使用frac函数只截取其中的小数部分。

1
2
3
4
5
float rand(float3 vec){
float random = dot(vec, float3(12.9898, 78.233, 37.719));
random = frac(random);
return random;
}

如果我们在表面着色器函数中使用我们的随机函数,并以世界坐标为参数,将结果写入Albedo参数中,那么我们可以立马看到我们的随机值遍布在模型表面。

1
2
3
void surf (Input i, inout SurfaceOutputStandard o) {
o.Albedo = rand(i.worldPos);
}


上面生成的噪声图有一个问题,就是看起来并不是那么随机,我们可以看到有很多条纹图案。虽然这个”随机函数“是我随便写的,但是它执行很快,也能满足我们当前一些简单的随机需求。我们再将上面的伪随机值乘以一个非常大的值,然后截取结果的小数部分,这样可以产生非常细的条纹,几乎观察不到。

1
2
3
4
5
float rand(float3 vec){
float random = dot(vec, float3(12.9898, 78.233, 37.719));
random = frac(random * 143758.5453);
return random;
}

但是又有一个问题,就是乘以一个非常大的数,其结果很容易超出浮点数表示范围,例如我们的模型离世界原点非常远。

为了修复这个问题,我们可以在乘积之前,将点乘结果限定在非常小的范围,这里我使用三角函数。因为三角函数是在特殊的计算单元中执行,所以其性能消耗只比加减乘除高点。

1
2
3
4
5
6
7
8
9
10
//基于向量计算随机值
float rand(float3 value){
//限制向量大小
float3 smallValue = sin(value);
//计算随机值
float random = dot(smallValue, float3(12.9898, 78.233, 37.719));
//防止超出范围
random = frac(sin(random) * 143758.5453);
return random;
}

Different Input and Output

为了产生多维随机向量,我们可以沿着不同方向生成随机数,然后将结果合并成向量。但是不同方向的随机参数必须不同,这样不同方向的随机值才能不同。最简单的方法是将上面的固定向量改成变量,然后不同方向的随机值需要传入不同的向量。我们可以将上面的固定向量作为我们这个向量变量的默认参数,这样还可以以上面的方式调用。因为现在有一维、和三维随机数生成函数,所以我们需要给他们分别命名。

1
2
3
4
5
6
7
8
9
10
//生成一维随机数
float rand3dTo1d(float3 value, float3 dotDir = float3(12.9898, 78.233, 37.719)){
//限制向量大小
float3 smallValue = sin(value);
//计算随机值
float random = dot(smallValue, dotDir);
//防止超出范围
random = frac(sin(random) * 143758.5453);
return random;
}

要生成三维随机数,我们可以将上面的方法调用三次。每次得到向量的一个维度值,每个维度使用不同的方向向量。这样我们可以得到一个彩色的随机噪声图。之所以将上面的方法执行三遍,而不是另外写一个直接生成三维向量的随机方法,是因为我们想让生成的随机向量的三个维度的值相互独立。

1
2
3
4
5
6
7
8
//生成三维随机向量
float3 rand3dTo3d(float3 value){
return float3(
rand3dTo1d(value, float3(12.989, 78.233, 37.719)),
rand3dTo1d(value, float3(39.346, 11.135, 83.155)),
rand3dTo1d(value, float3(73.156, 52.235, 09.151))
);
}
1
2
3
void surf (Input i, inout SurfaceOutputStandard o) {
o.Albedo = rand3dTo3d(i.worldPos);
}

上面是使用三维向量来生成随机数,我们也可以使用二维向量,只要把前面的三维向量改成二维向量就行。还可以使用标量,不过原先的点乘操作就用不上了。我们可以一次性将这些函数都实现,然后放在一个include文件中,这样以后都不用再重写这些方法了。

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
//一维随机数

//使用三维向量计算一维随机数
float rand3dTo1d(float3 value, float3 dotDir = float3(12.9898, 78.233, 37.719)){
//限制向量大小
float3 smallValue = sin(value);
//计算随机值
float random = dot(smallValue, dotDir);
//防止超出范围
random = frac(sin(random) * 143758.5453);
return random;
}

float rand2dTo1d(float2 value, float2 dotDir = float2(12.9898, 78.233)){
float2 smallValue = sin(value);
float random = dot(smallValue, dotDir);
random = frac(sin(random) * 143758.5453);
return random;
}

float rand1dTo1d(float3 value, float mutator = 0.546){
float random = frac(sin(value + mutator) * 143758.5453);
return random;
}

//二维随机数

float2 rand3dTo2d(float3 value){
return float2(
rand3dTo1d(value, float3(12.989, 78.233, 37.719)),
rand3dTo1d(value, float3(39.346, 11.135, 83.155))
);
}

float2 rand2dTo2d(float2 value){
return float2(
rand2dTo1d(value, float2(12.989, 78.233)),
rand2dTo1d(value, float2(39.346, 11.135))
);
}

float2 rand1dTo2d(float value){
return float2(
rand2dTo1d(value, 3.9812),
rand2dTo1d(value, 7.1536)
);
}

//三维随机数

float3 rand3dTo3d(float3 value){
return float3(
rand3dTo1d(value, float3(12.989, 78.233, 37.719)),
rand3dTo1d(value, float3(39.346, 11.135, 83.155)),
rand3dTo1d(value, float3(73.156, 52.235, 09.151))
);
}

float3 rand2dTo3d(float2 value){
return float3(
rand2dTo1d(value, float2(12.989, 78.233)),
rand2dTo1d(value, float2(39.346, 11.135)),
rand2dTo1d(value, float2(73.156, 52.235))
);
}

float3 rand1dTo3d(float value){
return float3(
rand1dTo1d(value, 3.9812),
rand1dTo1d(value, 7.1536),
rand1dTo1d(value, 5.7241)
);
}

然后我们将上面的随机生成的函数都放到一个叫WhiteNoise.cginc的文件中。并且在我们的着色器中引用它。

1
#include "WhiteNoise.cginc"

为了防止我们多次误引用同一个文件,我们可以使用宏命令来规避这个问题。

1
2
3
4
5
6
#ifndef WHITE_NOISE
#define WHITE_NOISE

//我们的包含库内容

#endif

Cells

现在我们实现了通过世界坐标来生成随机颜色,这些颜色块非常小,当我们移动物体时,颜色也会快速变化。如果我们想让颜色块变大,我们可以将空间进行划分,所有处在同一块中的点使用同一个随机值。我们这里可以使用取整的方法,这样所有整数之间的小数对应的点都将使用同一随机值。

1
2
3
4
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = floor(i.worldPos);
o.Albedo = rand3dTo3d(value);
}

现在我们得到泾渭分明的色块,然后我们可以修改色块的大小。

1
2
3
Properties {
_CellSize ("Cell Size", Vector) = (1,1,1,0)
}
1
float3 _CellSize;

我们这将世界坐标除以色块尺寸。

1
2
3
4
void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = floor(i.worldPos / _CellSize);
o.Albedo = rand3dTo3d(value);
}

Source

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
Shader "Tutorial/024_white_noise/random" {
Properties {
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "WhiteNoise.cginc"

struct Input {
float3 worldPos;
};

void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = i.worldPos;
o.Albedo = rand3dTo3d(value);
}
ENDCG
}
FallBack "Standard"
}
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
Shader "Tutorial/024_white_noise/cells" {
Properties {
_CellSize ("Cell Size", Vector) = (1,1,1,0)
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

#include "WhiteNoise.cginc"

float3 _CellSize;

struct Input {
float3 worldPos;
};

void surf (Input i, inout SurfaceOutputStandard o) {
float3 value = floor(i.worldPos / _CellSize);
o.Albedo = rand3dTo3d(value);
}
ENDCG
}
FallBack "Standard"
}
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
#ifndef WHITE_NOISE
#define WHITE_NOISE

//to 1d functions

//get a scalar random value from a 3d value
float rand3dTo1d(float3 value, float3 dotDir = float3(12.9898, 78.233, 37.719)){
//make value smaller to avoid artefacts
float3 smallValue = sin(value);
//get scalar value from 3d vector
float random = dot(smallValue, dotDir);
//make value more random by making it bigger and then taking the factional part
random = frac(sin(random) * 143758.5453);
return random;
}

float rand2dTo1d(float2 value, float2 dotDir = float2(12.9898, 78.233)){
float2 smallValue = sin(value);
float random = dot(smallValue, dotDir);
random = frac(sin(random) * 143758.5453);
return random;
}

float rand1dTo1d(float3 value, float mutator = 0.546){
float random = frac(sin(value + mutator) * 143758.5453);
return random;
}

//to 2d functions

float2 rand3dTo2d(float3 value){
return float2(
rand3dTo1d(value, float3(12.989, 78.233, 37.719)),
rand3dTo1d(value, float3(39.346, 11.135, 83.155))
);
}

float2 rand2dTo2d(float2 value){
return float2(
rand2dTo1d(value, float2(12.989, 78.233)),
rand2dTo1d(value, float2(39.346, 11.135))
);
}

float2 rand1dTo2d(float value){
return float2(
rand2dTo1d(value, 3.9812),
rand2dTo1d(value, 7.1536)
);
}

//to 3d functions

float3 rand3dTo3d(float3 value){
return float3(
rand3dTo1d(value, float3(12.989, 78.233, 37.719)),
rand3dTo1d(value, float3(39.346, 11.135, 83.155)),
rand3dTo1d(value, float3(73.156, 52.235, 09.151))
);
}

float3 rand2dTo3d(float2 value){
return float3(
rand2dTo1d(value, float2(12.989, 78.233)),
rand2dTo1d(value, float2(39.346, 11.135)),
rand2dTo1d(value, float2(73.156, 52.235))
);
}

float3 rand1dTo3d(float value){
return float3(
rand1dTo1d(value, 3.9812),
rand1dTo1d(value, 7.1536),
rand1dTo1d(value, 5.7241)
);
}

#endif

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

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/024_White_Noise/WhiteNoise.cginc
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/024_White_Noise/white_noise_random.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/024_White_Noise/white_noise_cells.shader

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

原文:
Blur Postprocessing Effect (Box and Gauss)

Summary

模糊效果是一个非常有用的效果,可以用来表现角色虚弱时的视线模糊,也可以用来作为过场动画。我们是通过平均屏幕局部区域的像素值来实现画面模糊的。模糊处理可以应用在很多方面,但是最常用的就是实现后处理效果。因此你可能需要提前阅读我之前的后处理教程。

Boxblur

块模糊是最简单的一种模糊,只要才将局部方块区域进行采样平均就行。为了连续对局部区域采样,我们需要使用for循环。然后将所有采样值求和,再除以采样个数,得到局部平均值。

我们可以在前面的简单后处理脚本中做如下修改。

1
2
3
4
5
6
7
8
9
10
11
12
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//起始值
float4 col = 0;
for(float index=0;index<10;index++){
//将采样值叠加
col += tex2D(_MainTex, i.uv);
}
//求平均
col = col / 10;
return col;
}

1D Blur

因为我们上面是连续对同一个点采样求平均,所以最终结果并没有什么变化。接下来我们将改为对不同位置进行采样。然后我们需要创建一个公共变量,来控制采样块的大小,采样块的大小是一个相对值而不是实际的像素个数,这样可以保证相同的参数对不同分辨率的图片效果一样。

1
2
3
4
5
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_BlurSize("Blur Size", Range(0,0.1)) = 0
}
1
float _BlurSize;

有个模糊尺寸,我们可以在每次采样中计算不同的采样点。我们将循环次数映射为0-1之间,然后减去0.5,这样得到的采样点刚好是围绕当前点。最后我们乘以模糊尺寸,就可以动态控制模糊块的大小了。
这里我们只处理y方向的采样点。

1
2
3
4
5
6
7
//循环采样
for(float index=0;index<10;index++){
//计算y方向上不同的采样点坐标
float2 uv = i.uv + float2(0, (index/9 - 0.5) * _BlurSize);
//采样值叠加
col += tex2D(_MainTex, uv);
}

上面的采样点是沿着y方向的,所以模糊效果也是沿着y方向,但是我们希望x方向也模糊。我们可以在上面那个循环内再套一个循环,但是这并不是最好的方法。我们可以先执行y方向的模糊,然后再将模糊后的图片执行x方向的模糊。这样两次模糊后,就得到模糊块的平均值。

2D Blur

为了执行第二次模糊,我们重新实现一个Pass,我们可以将原来的代码复制过来,然后将偏移值改为沿着x方向。还有就是我们的模糊尺寸需要修改,如果不修改,模糊块的形状将和图片保持一致,这样就不能保证模糊块是一个方形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//计算图片横纵比
float invAspect = _ScreenParams.y / _ScreenParams.x;
//用于累计的颜色变量
float4 col = 0;
//循环采样
for(float index = 0; index < 10; index++){
//计算x方向的偏移,保证x、y两个方向的偏移像素个数相同
float2 uv = i.uv + float2((index/9 - 0.5) * _BlurSize * invAspect, 0);
//采样值累加
col += tex2D(_MainTex, uv);
}
//求平均
col = col / 10;
return col;
}

为了在后处理中连续使用两次Pass,我们需要对后处理脚本做一些修改。从前面的流程上来说,我们是先对原图进行纵向模糊,将结果存在一张临时纹理上,然后对这张临时纹理进行横向模糊,最终将结果写入目标图中。所以我们需要一张临时纹理,我们可以通过RenderTexture.GetTemporary函数来获取临时纹理。该函数是从纹理缓存池中获取,如果没有就会新建,然后释放该临时纹理,会将其放回纹理缓存池,以待下次使用。在执行纵向模糊时,我们要告诉Unity执行第一个Pass,因此可以向blit函数中的第四个参数传入0,表示执行第一个Pass,而目标图传入的是临时纹理。然后在执行横向模糊时,原图是临时纹理,目标图是最终的效果图,第四个参数传入1,表示执行第二个Pass

1
2
3
4
5
6
7
8
//场景渲染完后执行该函数
void OnRenderImage(RenderTexture source, RenderTexture destination){
//创建临时纹理,并执行两次模糊操作,然后释放临时纹理
var temporaryTexture = RenderTexture.GetTemporary(source.width, source.height);
Graphics.Blit(source, temporaryTexture, postprocessMaterial, 0);
Graphics.Blit(temporaryTexture, destination, postprocessMaterial, 1);
RenderTexture.ReleaseTemporary(temporaryTexture);
}

Customize Sample Amount

上面实现基本的模糊效果,但是我们可能希望控制采样个数,也就是循环次数。但是我们不能简单的将循环次数声明到公共变量,因为Unity在编译时必须确定到底采样几次,而变量是无法在编译时确定的。因为不能在循环语句中执行纹理采样,实际上前面循环在编译时就已经展开了,循环次数在编译时确定的。

那么我们可以通过宏命令来定义变量,这样在编译时就是确定的了。我们在上面两个Pass中加入一下宏定义,然后将所有与采样次数相关的全部替换掉。

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

float4 col = 0;
//循环采样,在编译时该循环会被展开
for(float index = 0; index < SAMPLES; index++){
//计算UV偏移
float2 uv = i.uv + float2(0, (index/(SAMPLES-1) - 0.5) * _BlurSize);
//采样值叠加
col += tex2D(_MainTex, uv);
}
//平均
col = col / SAMPLES;
return col;
}

这样我们就可以在代码中快速修改采样次数了。这里我们还可以进一步改进,将循环次数暴露在材质面板上,这样我们可以通过材质面板来快速修改采样次数。首先我们添加一个KeywordEnum属性,这个属性在材质面板上显示的是枚举变量,同时在着色器中定义相应的关键字。

1
2
3
4
5
6
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_BlurSize("Blur Size", Range(0,0.1)) = 0
[KeywordEnum(Low, Medium, High)] _Samples ("Sample amount", Float) = 0
}

然后在着色器代码中声明这些关键字。

1
#pragma multi_compile _SAMPLES_LOW _SAMPLES_MEDIUM _SAMPLES_HIGH

着色器会根据这些关键字编译出不同的版本,也就是所谓的变体。当我们在材质面板上选择不同的关键字时,实际上就是切换不同的变体。然后我们针对不同的变体设置不同的采样次数。这样我们就可以通过材质面板来控制采样次数了。

1
2
3
4
5
6
7
#if _SAMPLES_LOW
#define SAMPLES 10
#elif _SAMPLES_MEDIUM
#define SAMPLES 30
#else
#define SAMPLES 100
#endif

Gaussian Blur

再复杂一点,我们可以使用高斯模糊。前面将的模糊是计算局部平均值,而高斯模糊将中心点的权重增加,其权重分布符合高斯分布。

这个函数需要两个参数,一个是离中心点的距离,一个是标准差。在上一个模糊方法中我们已经计算了离中心点的距离,这里需要增加一个变量来控制标准差。另外还需要一个变量来选择使用普通的模糊还是高斯模糊。

1
2
3
4
5
6
7
8
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_BlurSize("Blur Size", Range(0,0.1)) = 0
[KeywordEnum(BoxLow, BoxMedium, BoxHigh, GaussLow, GaussHigh)] _Samples ("Sample amount", Float) = 0
[Toggle(GAUSS)] _Gauss ("Gaussian Blur", float) = 0
_StandardDeviation("Standard Deviation (Gauss only)", Range(0, 0.1)) = 0.02
}
1
#pragma shader_feature GAUSS

高斯函数中还需要的东西有圆周率和自然数,这里我一并定义了。

1
2
#define PI 3.14159265359
#define E 2.71828182846

当使用高斯函数时,我们无法确定其总的权重,所以这里引入一个新的变量sum,当我们使用高斯模糊时,我们将其初始化为0,然后在后面累计计算高斯的总权重,在使用普通模糊时,因为每个点的权重都是1,所以总权重就是点的个数。

1
2
3
4
5
#if GAUSS
float sum = 0;
#else
float sum = SAMPLES;
#endif

然后我们来修改片段着色器中的循环采样部分。首先我们计算采样点离中心点的偏移值,这个在高斯模糊中会用到。

1
2
3
4
5
6
7
8
9
10
for(float index = 0; index < SAMPLES; index++){
float offset = (index/(SAMPLES-1) - 0.5) * _BlurSize;
//计算采样点的坐标
float2 uv = i.uv + float2(0, offset);
#if !GAUSS
col += tex2D(_MainTex, uv);
#else
//计算高斯权重
#endif
}

接下来我们实现高斯模糊部分,首先我们计算标准差的平方,因为在高斯函数中用到两次了。然后根据高斯函数编写表达式。

1
2
3
//计算高斯权重
float stDevSquared = _StandardDeviation*_StandardDeviation;
float gauss = (1 / sqrt(2*PI*stDevSquared)) * pow(E, -((offset*offset)/(2*stDevSquared)));

我们将计算好的高斯权重累计到sum变量中,并且将其与纹理采样结果进行加权平均。

最后还有一个问题是标准差不能为零,所以我们加一个段保护代码,如果为零则不执行高斯模糊。

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
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
#if GAUSS
//当标准差为0时,不执行高斯模糊
if(_StandardDeviation == 0)
return tex2D(_MainTex, i.uv);
#endif
//颜色累计
float4 col = 0;
#if GAUSS
float sum = 0;
#else
float sum = SAMPLES;
#endif
//循环采样
for(float index = 0; index < SAMPLES; index++){
//计算离中心点的偏移
float offset = (index/(SAMPLES-1) - 0.5) * _BlurSize;
//计算采样点坐标
float2 uv = i.uv + float2(0, offset);
#if !GAUSS
//采样值累加
col += tex2D(_MainTex, uv);
#else
//计算高斯权重
float stDevSquared = _StandardDeviation*_StandardDeviation;
float gauss = (1 / sqrt(2*PI*stDevSquared)) * pow(E, -((offset*offset)/(2*stDevSquared)));
//计算总权重
sum += gauss;
//加权
col += tex2D(_MainTex, uv) * gauss;
#endif
}
//平均
col = col / sum;
return col;
}

上面这个模糊我想有两点可以优化,一是使用include文件,将公用代码写入其中,然后可以在多处进行引用,实现代码复用。另一个是将高斯权重计算转移到C#代码中,然后将结果传入着色器,因为在着色器中计算高斯函数非常浪费。

Source

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

//必须挂载到后处理摄像机上
public class PostprocessingBlur : MonoBehaviour {
//后处理材质
[SerializeField]
private Material postprocessMaterial;

//场景渲染完成后,执行该函数
void OnRenderImage(RenderTexture source, RenderTexture destination){
//新建临时纹理,执行模糊处理,然后回收临时纹理
var temporaryTexture = RenderTexture.GetTemporary(source.width, source.height);
Graphics.Blit(source, temporaryTexture, postprocessMaterial, 0);
Graphics.Blit(temporaryTexture, destination, postprocessMaterial, 1);
RenderTexture.ReleaseTemporary(temporaryTexture);
}
}
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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
Shader "Tutorial/023_Postprocessing_Blur"{
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_BlurSize("Blur Size", Range(0,0.5)) = 0
[KeywordEnum(Low, Medium, High)] _Samples ("Sample amount", Float) = 0
[Toggle(GAUSS)] _Gauss ("Gaussian Blur", float) = 0
[PowerSlider(3)]_StandardDeviation("Standard Deviation (Gauss only)", Range(0.00, 0.3)) = 0.02
}

SubShader{
// 双面渲染
// 禁用深度缓存
Cull Off
ZWrite Off
ZTest Always


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

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

#pragma multi_compile _SAMPLES_LOW _SAMPLES_MEDIUM _SAMPLES_HIGH
#pragma shader_feature GAUSS

//模糊处理的原图、以及模糊参数
sampler2D _MainTex;
float _BlurSize;
float _StandardDeviation;

#define PI 3.14159265359
#define E 2.71828182846

#if _SAMPLES_LOW
#define SAMPLES 10
#elif _SAMPLES_MEDIUM
#define SAMPLES 30
#else
#define SAMPLES 100
#endif

//模型网格数据
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{
#if GAUSS
//当标准差为0时,不执行模糊操作
if(_StandardDeviation == 0)
return tex2D(_MainTex, i.uv);
#endif

float4 col = 0;
#if GAUSS
float sum = 0;
#else
float sum = SAMPLES;
#endif
//循环执行采样
for(float index = 0; index < SAMPLES; index++){
//获取采样点到中心点的距离
float offset = (index/(SAMPLES-1) - 0.5) * _BlurSize;
//计算采样点的坐标
float2 uv = i.uv + float2(0, offset);
#if !GAUSS
//采样值累加
col += tex2D(_MainTex, uv);
#else
//计算高斯权重
float stDevSquared = _StandardDeviation*_StandardDeviation;
float gauss = (1 / sqrt(2*PI*stDevSquared)) * pow(E, -((offset*offset)/(2*stDevSquared)));
//总权重
sum += gauss;
//加权
col += tex2D(_MainTex, uv) * gauss;
#endif
}
//平均
col = col / sum;
return col;
}

ENDCG
}

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

#pragma multi_compile _SAMPLES_LOW _SAMPLES_MEDIUM _SAMPLES_HIGH
#pragma shader_feature GAUSS

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

//用于模糊的原图、以及模糊参数
sampler2D _MainTex;
float _BlurSize;
float _StandardDeviation;

#define PI 3.14159265359
#define E 2.71828182846

#if _SAMPLES_LOW
#define SAMPLES 10
#elif _SAMPLES_MEDIUM
#define SAMPLES 30
#else
#define SAMPLES 100
#endif

//模型网格数据
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{
#if GAUSS
//当标准差为0时,不执行模糊处理
if(_StandardDeviation == 0)
return tex2D(_MainTex, i.uv);
#endif
//计算横纵比
float invAspect = _ScreenParams.y / _ScreenParams.x;

float4 col = 0;
#if GAUSS
float sum = 0;
#else
float sum = SAMPLES;
#endif
//循环采样
for(float index = 0; index < SAMPLES; index++){
//获取横向偏移值
float offset = (index/(SAMPLES-1) - 0.5) * _BlurSize * invAspect;
//计算采样坐标
float2 uv = i.uv + float2(offset, 0);
#if !GAUSS
//采样值累加
col += tex2D(_MainTex, uv);
#else
//计算高斯权重
float stDevSquared = _StandardDeviation*_StandardDeviation;
float gauss = (1 / sqrt(2*PI*stDevSquared)) * pow(E, -((offset*offset)/(2*stDevSquared)));
//总权重
sum += gauss;
//加权
col += tex2D(_MainTex, uv) * gauss;
#endif
}
//平均
col = col / sum;
return col;
}

ENDCG
}
}
}

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

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

原文:
Stencil Buffers

Summary

深度缓存可以帮助我们对比模型之间的深度关系,确保模型之间正确遮挡。还有另一部分缓存用于模板操作,这个缓存叫做模板缓存。模板缓存就像一个印刷版,只有部分区域允许渲染到屏幕上。

Unity也有用到模板缓存来实现延迟渲染,所以如果你在执行延迟渲染时,会有一些限制。你可以阅读官方文档去了解这些具体的限制,深入了解如何使用模板。

本教程将会介绍模板缓存的基本使用,包括模板读写操作。这里也从表面着色器中的着色器脚本开始,来实现模板缓存案例。当然使用方法适用于其他着色器,包括后处理操作。

Reading form the Stencil Buffer

在使用模板的着色器中,着色器会读取模板中的值,然后以这个值按照一定条件来进行判断当前像素是否可以写入到帧缓存中,如果可以,那么再按一定条件刷新当前模板缓存值,如果不行,那么放弃后面所有操作。

所有的模板操作都是集中在一个叫做Stencil的块中。

1
2
3
4
5
6
7
8
9
10
11
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

Stencil{
//模板操作
}

//表面着色器代码

//...
}

在模板参数中最重要的是Ref,这个参数是我们模板操作的参考值。在模板写入之前,模板缓存中的默认值是0。一般在所以操作之前我都会手动初始化模板缓存为0,这样可以让代码看起来逻辑更清晰。

下一个参数叫做Comp,定义了模板比较方法,什么情况下可以通过模板,什么时候不行,其默认值为Always,表示所有都无条件通过。在本文实现的着色器中,我们使用Equal这个比较方法,这意味着只有模板值等于参考值时,才能通过模板。

1
2
3
4
Stencil{
Ref 0
Comp Equal
}

上面的模板设置并不会影响原本模型的显示,这是因为模板初始值为0,而参考值也为0,刚好所有值都通过模板。如果我们将参考值改为其他值,这时候模型会消失,因为所有模板值都未通过。

1
2
3
4
Stencil{
Ref 1
Comp Equal
}

为了方便后面调整,这里我将模板参考值暴露在材质面板上,这样我们可以通过材质面板来修改模板参考值。这里IntRange表示我们的滑块刻度是取整的。

1
2
3
4
5
6
7
8
9
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)

[IntRange] _StencilRef ("Stencil Reference Value", Range(0,255)) = 0
}

然后我们将模板操作中的参考值改为_StancilRef,这里将其放在中括号里面,表示我们这个值是属性块中的值,着色器会进行关联。这样修改之后好像我们的模型也还是只有显示我不显示两个状态,但是,使用材质面板上的滑块可以在两者之间进行快速切换。

Writing to the Stencil Buffer

在实际应用中,我们除了需要根据模板来绝对哪些需要渲染,哪些不渲染,还要有能够向模板中写入新的数值得着色器。上面实现的是读取模板的着色器,下面我们实现第二个写入模板的着色器。第二个着色器的主要功能是对模板进行操作,所以不需要写入到帧缓存。这样在第一个着色器执行的时候,就可以使用第二个着色器写入的模板值进行渲染判断。

第二个着色器我们使用最简单的着色器,因为我们只想对模板进行操作,不打算渲染其他东西。

那么对于第二个写入模板的着色器,我们将其片段着色器的返回值直接写为0,因为我们不想渲染它。然后设置混合模式为Zero One,这表示不会影响之前绘制好的像素。还有就是需要关闭深度写入功能,因为这个模型不渲染,说过写入深度的话,那么可能会遮挡后面的模型,这就会显得很诡异。最后是要保证第二个着色器比第一个着色器先执行,也就是先写后读,我们可以设置渲染队列顺序来实现。
同时我们还删除颜色变量,因为我们不需要。

1
2
3
fixed4 frag(v2f i) : SV_TARGET{
return 0;
}
1
2
Blend Zero One
ZWrite Off
1
"Queue"="Geometry-1"
1
2
3
4
//清空材质面板
Properties{

}

这样我们实现了一个完全不显示的着色器,这个着色器相比第一个着色器的优势是,它完全不受模板值得影响。因为不管模板值什么,它都不会显示出来。

然后我们将第一个着色器中的模板设置拷贝过来。然后将比较方法设置为Always,这表第二个着色器无条件通过模板。然后在加入一个Pass属性,它定义了当通过深度检测后,模板值将会如何。这里我们将它设为Replace,这表示当深度检测通过后,使用参考值替代原本的模板值。这里还有一个Fail的属性,是当检测失败后应该执行什么操作,默认是不做任何操作。

1
2
3
4
5
Stencil{
Ref [_StencilRef]
Comp Always
Pass Replace
}

现在我们可以看到,当它们的参考值相同时,第一个材质物体和第二个材质物体重叠部分可见。

实现这两个着色器的过程中,我们已经知道了模板的基本用法。如果你想了解更多,你可以参考Unity的官方文档

在不断尝试后,我遇到一个问题。当有多个模板在对同一个像素点进行读写操作时,后面的(离摄像机更远)模板可能比前一个模板更晚执行,这样可能会覆盖原先的模板值。如果你也遇到类似的问题,那么你可以通过调整它们的渲染队列来保证它们的执行顺序。Unity中,当渲染队列值大于2500时,模型是从后往前渲染的。这样做的目的是为了保证透明物体正确渲染。所以我们同样可以通过渲染队列来控制模板的顺序。在我的例子中,我使用2501来作为写模板队列,而2052最为读渲染队列,这样保证写模板在读模板之前执行。还有一点就是我们的模板队列不要超过3000,因为超过3000为半透明物体的可用渲染队列值,它们的队列值混在一起可能会出问题。

Source

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
Shader "Tutorial/022_stencil_buffer/read" {
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)

[IntRange] _StencilRef ("Stencil Reference Value", Range(0,255)) = 0
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

//模板操作
Stencil{
Ref [_StencilRef]
Comp Equal
}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;

half _Smoothness;
half _Metallic;
half3 _Emission;

struct Input {
float2 uv_MainTex;
};

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"
}
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
Shader "Tutorial/022_stencil_buffer/write"{
//材质面板
Properties{
[IntRange] _StencilRef ("Stencil Reference Value", Range(0,255)) = 0
}

SubShader{
//将队列值设在读模板之前
Tags{ "RenderType"="Opaque" "Queue"="Geometry-1"}

//模板操作
Stencil{
Ref [_StencilRef]
Comp Always
Pass Replace
}

Pass{
//不写入帧缓存、也不写入深入,只负责写入模板
Blend Zero One
ZWrite Off

CGPROGRAM
#include "UnityCG.cginc"

#pragma vertex vert
#pragma fragment frag

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{
return 0;
}

ENDCG
}
}
}

希望通过本篇教程让你了解模板的使用。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/022_Stencil_Buffer/stencil_read.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/022_Stencil_Buffer/stencil_write.shader

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

原文:
Clipping a Model with a Plane

Summary

另一个比较炫酷的效果是,根据指定区域对模型进行裁剪。

我们这篇教程需要你掌握表面着色器的基本知识,所以建议你先阅读这篇教程。

Define Plane

首先我们需要新建一个C#脚本,用来设置裁剪平面用的,然后将平面参数传递个着色器,还需要定义一个绑定该着色器的材质变量。我们使用[ExecuteAlways]标签让脚本在编辑模式下一直执行。当然,你可以根据你自身的需求考虑要不要加,毕竟就算不加,当程序运行时,该脚本也会正常执行。

我们在Update函数中新建一个类型为Plane的变量,并向其构造函数中传入平面的法向量,和平面上任意一个点的坐标。这里我们将选择脚本所在的物体的Y轴正方向为平面的法向量,脚本所在的物体的位置为平面上的任意一点。换句话说,我们这个平面就是脚本所在物体的局部坐标系的O-XZ平面。

然后我们创建一个四维向量,将平面的法向量传递给该向量的前三个元素,然后第四个元素存储平面到世界原点的距离。后面我会解释这些值得含义。

然后我们将这个四维变量传递给着色器。

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

[ExecuteAlways]
public class ClippingPlane : MonoBehaviour {
//用于裁剪的材质球
public Material mat;

//每帧都会执行一次
void Update () {
//创建一个平面
Plane plane = new Plane(transform.up, transform.position);
//平面参数
Vector4 planeRepresentation = new Vector4(plane.normal.x, plane.normal.y, plane.normal.z, plane.distance);
//传递给着色器中的_Plane变量
mat.SetVector("_Plane", planeRepresentation);
}
}

然后我们将该脚本绑定到物体上,该物体将被当做我们的裁剪工具。

Clip Plane

接下来实现我们的着色器,这里我沿用表面作色器中的着色器。

首先我们需要在着色器中添加_Plane公共变量,因为这个变量是通过脚本赋值的,所以不需要出现在Properties块中。

1
float4 _Plane;

在表面着色器中我们可以计算模型表面上的点,到过原点与我们这个自定义平面平行的平面的距离。这个距离可以通过表面上的点坐标和平面法向量的点乘来计算。如果这个带你在我们的自定义平面上,那么这个距离值将会等于平面参数中的距离值。如果这个距离值大于平面参数中的距离值,那么这个点在平面上方,小于则在其下方。

要实现这个位置判断,我们需要计算模型顶点的世界坐标。在表面着色器中,我们只需要在输入结构体中加入worldPos变量,根据命名规则,表面作色器会自动给我们计算世界坐标。如果是其他着色器,那么我们需要使用模型矩阵自行计算。然后将距离传递给自发光变量。

1
2
3
4
5
//表面着色器中的输入数据,由其自动计算
struct Input {
float2 uv_MainTex;
float3 worldPos;
};
1
2
3
4
5
6
//表面作色器函数
void surf (Input i, inout SurfaceOutputStandard o) {
//计算点到屏幕的距离
float distance = dot(i.worldPos, _Plane.xyz);
o.Emission = distance;
}

上图中平面的方向会影响亮度,但是位置却不会。这是因为我们还没有应用平面的位置参数。接下来我们将这个距离参数应用上。

1
2
3
4
5
6
7
//表面作色器函数
void surf (Input i, inout SurfaceOutputStandard o) {
//计算点到屏幕的距离
float distance = dot(i.worldPos, _Plane.xyz);
distance = distance + _Plane.w;//应用平面距离参数
o.Emission = distance;
}

这里计算出来的距离是一个矢量,具有方向性,在平面上方的大于零,平面下方的小于零,因此上图中显示的平面两侧的亮度不一样。我们可以应用这个特性来将平面某一侧的模型剔除掉。例如这里不渲染平面上方的模型,只渲染平面下方的模型。

在前面的教程中有介绍,可以在片段着色器中使用clip函数来剔除某些像素。

1
2
3
4
5
6
7
8
//表面着色器函数
void surf (Input i, inout SurfaceOutputStandard o) {
//计算点到面的距离
float distance = dot(i.worldPos, _Plane.xyz);
distance = distance + _Plane.w;
clip(-distance);//剔除平面上方的模型
o.Emission = distance;
}

现在我们可以看到上面的模型已经被剔除了,这时候我们也不用自发光的亮度来表示哪里是平面的上方,可以恢复模型原本的纹理颜色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//表面着色器
void surf (Input i, inout SurfaceOutputStandard o) {
//计算点到面的距离
float distance = dot(i.worldPos, _Plane.xyz);
distance = distance + _Plane.w;
o.Emission = distance;

//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;
o.Metallic = _Metallic;
o.Smoothness = _Smoothness;
o.Emission = _Emission;
}

Show Inside

虽然我们的模型成功的被平面裁剪为两半,但是剩下的这一部分看起来很怪异,好像缺了一部分,到处是孔洞。造成这种现象的原因是,默认情况下我们只渲染模型的正面,因为我们可以肯定模型背面,也就是其内部不会被渲染,这样起到一个优化的作用。但是,现在我们的模型被切开了,内部允许被看到,所以应该取消背面剔除功能。

要同时渲染模型的正反面,我们只需将着色器的Cull设置为Off

1
2
3
4
5
6
7
8
9
10
SubShader{
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

// 关闭剔除功能,这样模型的两个面都可以被渲染
Cull Off

//...

}

现在我们可以看到模型的内侧了,但是模型的法向量依然是指向外侧的,同时我们可能也不想看到内侧。这时候我们可以很容易的区分当前渲染的像素是内侧还是外侧。我们需要在表面着色器的输入结构体中加入一个表明像素朝向的变量。这个变量也是有表面着色器自行填充,1表示外侧,-1表示内侧。

这里我们还是用插值函数来实现内外侧的不同显示效果,所以我们需要将朝向变量映射到0-1之间。

1
2
3
4
5
6
//表面着色器的输入值
struct Input {
float2 uv_MainTex;
float3 worldPos;
float facing : VFACE;
};
1
2
float facing = i.facing * 0.5 + 0.5;
o.Emission = facing;

上图可以看到我们已经可以清楚地区分内外侧了。我们可以给内侧指定一个颜色,然后外侧按照正常显示。我们需要一个内侧颜色,并且将这个颜色应用到自发光变量上。因为我们模型内侧的法向量是错误的,所以在计算光照候的结果也将是错误的,而自发光的颜色不受光照影响。另外,我们也将朝向值应用到其他光照参数上,这样保证内侧不会进行光照计算。

1
2
3
4
5
6
7
8
9
10
//材质面板
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)

[HDR]_CutoffColor("Cutoff Color", Color) = (1,0,0,0)
}
1
float4 _CutoffColor;
1
2
3
4
5
6
7
8
9
10
//为0时表示内侧, 1表示外侧
float facing = i.facing * 0.5 + 0.5;

//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb * facing;
o.Metallic = _Metallic * facing;
o.Smoothness = _Smoothness * facing;
o.Emission = lerp(_CutoffColor, _Emission, facing);

在裁剪面上的颜色显示还是有些不正常,因为还会受到环境光的影响,但是这需要我们重写全局光才能排除其影响,本文并不打算深入这一块。

Source

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
Shader "Tutorial/021_Clipping_Plane"{
//材质面板
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)

[HDR]_CutoffColor("Cutoff Color", Color) = (1,0,0,0)
}

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

// 双面渲染
Cull Off

CGPROGRAM
//表面着色器
//表面着色器函数和光照模型
//fullforwardshadows 使用所有的阴影Pass
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;

half _Smoothness;
half _Metallic;
half3 _Emission;

float4 _Plane;

float4 _CutoffColor;

//表面着色器输入数据
struct Input {
float2 uv_MainTex;
float3 worldPos;
float facing : VFACE;
};

//表面着色器
void surf (Input i, inout SurfaceOutputStandard o) {
//计算点到面的距离
float distance = dot(i.worldPos, _Plane.xyz);
distance = distance + _Plane.w;
//移除平面上的点
clip(-distance);

float facing = i.facing * 0.5 + 0.5;

//纹理采样
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb * facing;
o.Metallic = _Metallic * facing;
o.Smoothness = _Smoothness * facing;
o.Emission = lerp(_CutoffColor, _Emission, facing);
}
ENDCG
}
FallBack "Standard" //后补着色器
}

本文的裁剪方案可以实现模型消失的效果,也可以实现简单的水在容器中的效果。希望这个教程让你有所收获。

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

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

原文:
Hull Outlines

到目前为止,我们基本上是一个着色器只会执行一次将模型绘制到屏幕上。实际上在一个着色器中是允许对一个模型绘制多次。比如说我们接下来的轮廓实现方案就需要对模型绘制多次。首先按往常一样渲染一遍模型,然后将模型顶点沿着法线方向移动一点,然后再次进行绘制,而这第二次绘制的模型会出现在上一次绘制的边缘处,也就是我们想得到的轮廓。

为了能够更好的理解本文,建议你先了解什么是表面着色器,以及无光照着色器

Outlines for Unlit Shaders

沿用之前无光照着色器脚本,我们只需要将其中的Pass复制一遍就可以。现在有两个完全相同的Pass,所以即便是绘制两遍,最终的结果也是一样的。

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
//复制出来的,用于绘制轮廓的Pass
Pass{
CGPROGRAM

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

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

//模型表面纹理
sampler2D _MainTex;
float4 _MainTex_ST;

//模型颜色
fixed4 _Color;

//模型网格数据
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 = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = tex2D(_MainTex, i.uv);
col *= _Color;
return col;
}

ENDCG
}

然后我们需要对上面这个Pass的变量进行修改,因为轮廓不需要纹理,只需要轮廓颜色、轮廓宽度,所以我们删除纹理变量,然后增加轮廓颜色、和轮廓宽度变量,并且在Properties块中添加这两个属性。

1
2
3
4
5
6
7
8
//材质面板
Properties{
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_OutlineThickness ("Outline Thickness", Range(0,.1)) = 0.03

_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}
1
2
3
4
//轮廓颜色
fixed4 _OutlineColor;
//轮廓宽度
float _OutlineThickness;

接下来是修改片段着色器,直接返回我们的轮廓颜色。

1
2
3
4
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
return _OutlineColor;
}

因为我们没有使用纹理,所以与纹理相关的UV变量也不需要,所以可以将其从那些结构体中删除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//模型网格数据
struct appdata{
float4 vertex : POSITION;
};

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

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

上图是修改后的显示效果,我们的物体最终显示为轮廓色,这是因为我们第二个Pass将第一个Pass渲染的图完全覆盖了。我们接下来处理这个问题。

为了保证我们的第二个Pass超出第一个Pass的显示范围,从而形成轮廓。我们需要将模型的顶点沿着其法向量的方向偏移。因此我们需要在模型网格数据中传入法向量,

1
2
3
4
5
//模型网格数据
struct appdata{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
1
2
3
4
5
6
7
8
9
10
11
12
//顶点着色器
v2f vert(appdata v){
v2f o;
//顶点沿着法向偏移
float3 normal = normalize(v.normal);
float3 outlineOffset = normal * _OutlineThickness;
float3 position = v.vertex + outlineOffset;
//计算裁剪坐标
o.position = UnityObjectToClipPos(position);

return o;
}

现在可以可以通过_OutlineThinckness来控制边缘的宽度,但是我们第一个Pass渲染的画面还是被遮挡了。为了修复这个问题,我们将第二个Pass改为正面剔除。这样可以保证第二个Pass渲染的画面永远在第一个Pass之后。

1
2
3
4
5
6
//第二个Pass, 用来绘制轮廓
Pass{
Cull Front

//...
}

上图就是我们得到的轮廓了。

Source

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
Shader "Tutorial/19_InvertedHull/Unlit"{
//材质面板
Properties{
_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_OutlineThickness ("Outline Thickness", Range(0,.1)) = 0.03

_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

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

//第一个Pass, 用来渲染模型本身
Pass{
CGPROGRAM

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

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

//模型纹理
sampler2D _MainTex;
float4 _MainTex_ST;

//模型颜色
fixed4 _Color;

//模型网格数据
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 = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = tex2D(_MainTex, i.uv);
col *= _Color;
return col;
}

ENDCG
}

//第二个Pass,用来绘制轮廓
Pass{
Cull front

CGPROGRAM

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

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

//轮廓颜色
fixed4 _OutlineColor;
//轮廓宽度
float _OutlineThickness;

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

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

//顶点着色器
v2f vert(appdata v){
v2f o;
//沿着法向移动顶点
float3 normal = normalize(v.normal);
float3 outlineOffset = normal * _OutlineThickness;
float3 position = v.vertex + outlineOffset;
//计算裁剪坐标
o.position = UnityObjectToClipPos(position);

return o;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
return _OutlineColor;
}

ENDCG
}
}

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

Outlines with Surface Shaders

前面是在普通的顶点、片段着色其中应用轮廓效果,在表面着色器中其实也一样。对于表面着色器,Unity会自动生成部分代码,但是不会改动我们写入的代码,因此我们可以直接将前面的第二个轮廓Pass直接复制过来,并且可以实现同样的轮廓效果。

Source

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
91
92
93
Shader "Tutorial/020_InvertedHull/Surface" {
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)

_OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
_OutlineThickness ("Outline Thickness", Range(0,1)) = 0.1
}
SubShader {
//不透明物体
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM
//表面着色器
//表面着色器函数和光照模型
//fullforwardshadows 使用所有的阴影Pass
#pragma surface surf Standard fullforwardshadows
#pragma target 3.0

sampler2D _MainTex;
fixed4 _Color;

half _Smoothness;
half _Metallic;
half3 _Emission;

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

//表面着色函数,主要计算光照模型所需的参数
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

//第二个轮廓Pass
Pass{
Cull Front

CGPROGRAM

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

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

//轮廓颜色、粗细
fixed4 _OutlineColor;
float _OutlineThickness;

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

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

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

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
return _OutlineColor;
}

ENDCG
}
}
FallBack "Standard"
}

本篇轮廓实现方案和上一篇后处理轮廓方案的区别在于,本文所有的作色器是应用到个体模型上,所以可以根据需要选择哪些模型显示轮廓,并且还可以调节轮廓的宽度,整体的表现效果也有很大的差异。如果说哪个方案好,我觉得我们应该掌握这两种轮廓方案,然后根据实际情况进行选择。

希望你能通过本篇,了解如何在一个着色器中使用多个Pass,并且知道如何利用它们来实现轮廓效果。

你可以在以下链接找到源码:
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/020_Inverted_Hull/UnlitOutlines.shader
https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/020_Inverted_Hull/SurfaceOutlines.shader

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

原文:
Postprocessing with Normal Texture

Summary

边界后处理算是我最喜欢的后处理了。通过后处理的方式实现边缘绘制有很多优势,不需要修改模型原本的材质着色器,边缘检测的效果相比也更好。

阅读本文前,需要掌握深度法向纹理相关的知识,如果你不曾了解,建议你阅读我之前的教程

Depth Outlines

这里我们还是沿续上一篇关于深度法向贴图的教程,并且使用之前的脚本。

首先我们将上一个教程中关于顶部着色的部分删掉,与之相关的变量也可以删掉,还有后处理脚本中的变换矩阵。然后我们将片段着色器中计算深度和法向之后的所有代码删除掉。

1
2
3
4
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度法向采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//深度法向解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//还原深度值
depth = depth * _ProjectionParams.z;
}

然后删除后处理脚本中的向着色器传递矩阵的命令。

1
2
3
4
5
//场景渲染完后,会调用该函数
private void OnRenderImage(RenderTexture source, RenderTexture destination){
//执行后处理
Graphics.Blit(source, destination, postprocessMaterial);
}

计算轮廓的方法是对判断定点周围进行采样,然后对比其与周围点之间的深度、法向差,差别越大,是轮廓点的概率就越大。

对周围点进行准确采样,首先我们需要知道纹理的大小,因为采样时使用的UV坐标,其范围是0-1,需要根据纹理大小计算单个像素的uv步长。而这个纹理大小的参数和之前提到的纹理缩放偏移参数类似,都是和纹理相关的,所以有一定的命名规则。这个规则就是纹理变量的名称加上_TexelSize后缀。

1
2
3
4
//深度纹理图变量
sampler2D _CameraDepthNormalsTexture;
//深度纹理图的尺寸,前两个是长宽的倒数,后两个是长宽
float4 _CameraDepthNormalsTexture_TexelSize;

然后我们针对当前采样点偏移几个像素,计算其周围采样点的值。

1
2
3
4
5
6
7
//偏移offset个像素点,对周围进行采样
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;

然后我们求这两个采样点之间的差,并显示到屏幕上。

1
2
float difference = depth - neightborDepth;
return difference;

在上图中我们提取了图片左侧的轮廓。在处理剩下的采样点之前,我们先把采样部分的代码封装成一个函数,这样我们就不需要重复写这一段共同的代码了。这个函数需要中心点的深度值、uv、以及像素偏移值。

然后我们将片段着色其中的代码拷贝到我们新创建的函数中,并且把相应部分的变量调整一下。然后在片段着色器中调用这个函数。

1
2
3
4
5
6
7
8
9
10
11
void Compare(float baseDepth, float2 uv, float2 offset){
//偏移offset个像素点,对周围进行采样
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;

return baseDepth - neighborDepth;
}
1
2
3
float depthDifference = Compare(depth, i.uv, float2(1, 0));

return depthDifference;

修改完后运行程序,会发现效果和原来的一样,但是我们可以使用Compare快速的选择其他采样点。例如这里的上下左右四个方向,然后将所有结果求和。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//中心点的深度法向采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//法向深度解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//还原深度值
depth = depth * _ProjectionParams.z;
//计算在上下左右四个方向的差值
float depthDifference = Compare(depth, i.uv, float2(1, 0));
depthDifference = depthDifference + Compare(depth, i.uv, float2(0, 1));
depthDifference = depthDifference + Compare(depth, i.uv, float2(0, -1));
depthDifference = depthDifference + Compare(depth, i.uv, float2(-1, 0));

return depthDifference;
}

Normal Outlines

使用深度图得到的轮廓已经蛮清晰了,但是我们还可以进一步使用法向数据来计算轮廓。我们同样是在Compare函数中实现法向采样,因为函数只能返回一个值,所以我们这里使用inout来返回结果。另外我们还需要向函数中传递中心点的法向,来求解其与周边的差值。

1
2
3
4
void Compare(inout float depthOutline, inout float normalOutline,  float baseDepth, float3 baseNormal, float2 uv, float2 offset)
{
//...
}

现在我们的Compare函数已经可以处理深度和法向两种计算轮廓的数据,那么我们也需要在片段着色器中做相应的改动。

1
2
3
4
5
6
7
8
9
10
11
12
13
void Compare(inout float depthOutline, inout float normalOutline, 
float baseDepth, float3 baseNormal, float2 uv, float2 offset){
//附近点采样
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;

float depthDifference = baseDepth - neighborDepth;
depthOutline = depthOutline + depthDifference;
}
1
2
3
4
5
6
7
8
9
float depthDifference = 0;
float normalDifference = 0;

Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));

return depthDifference;

上面对代码结构进行扩展,但是并没有改变实际的功能,因此输出的结果没有什么变化。现在我们继续处理临近点法向的差,求法向的差最简单且方便的做法是计算两个法向之间的点乘。法向越接近点乘越大,完全逆向时点乘为-1。这里我们希望差异越大越接近1,差异越小越接近0,所以需要做一个数值映射。ps:不过下面的法向差并不是通过点乘来实现的。

1
2
3
float3 normalDifference = baseNormal - neighborNormal;
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
normalOutline = normalOutline + normalDifference;
1
return normalDifference;

上面得到的基于法向纹理的边缘检测结果和基于深度的有所不同,我们可以将两者结合得到一个更加清晰的轮廓。

1
return depthDifference + normalDifference;

Customizable Outlines

我们基本上实现了边缘绘制的效果,但是我们还可以加一些可控参数来对最终的轮廓效果进行微调。这里我们分别针对深度、法向轮廓引入两个因子,来控制两者的强弱,还有两个偏移量,来剔除弱边缘。

1
2
3
4
5
6
7
8
//材质面板属性
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
_NormalBias ("Normal Outline Bias", Range(1,4)) = 1
_DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
_DepthBias ("Depth Outline Bias", Range(1,4)) = 1
}
1
2
3
4
5
//hlsl 中对应的公共变量
float _NormalMult;
float _NormalBias;
float _DepthMult;
float _DepthBias;

这四个变量是在片段着色器中使用的,当计算完中心点与所有方向的临近点的差值后,将其乘以上面的因子,然后将结果限制在0-1之间,最后使用指数函数,来弱化弱边缘。最终将两种边缘求和。

1
2
3
4
5
6
7
8
9
depthDifference = depthDifference * _DepthMult;
depthDifference = saturate(depthDifference);
depthDifference = pow(depthDifference, _DepthBias);

normalDifference = normalDifference * _NormalMult;
normalDifference = saturate(normalDifference);
normalDifference = pow(normalDifference, _NormalBias);

return depthDifference + normalDifference;

现在我们可以在材质面板上控制这些参数,从而对轮廓效果进行微调。

最后我们想给轮廓上色,同时将轮廓叠加到原图上。所以我们要定义一个轮廓颜色变量。

1
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
1
float4 _OutlineColor;

我们只需要在片段着色其中对原图进行采样,然后根据轮廓值,对原图和轮廓颜色进行插值,轮廓值为1的采用轮廓颜色,为0的采用原图颜色。

1
2
3
4
float outline = normalDifference + depthDifference;
float4 sourceColor = tex2D(_MainTex, i.uv);
float4 color = lerp(sourceColor, _OutlineColor, outline);
return color;

使用后处理来实现轮廓效果有一个弊端,就是整个场景画面会无差别的执行轮廓效果。你无法指定哪些物体使用轮廓线,哪些不使用。而且容易出现锯齿等不自然的表现。

虽然无差别执行轮廓效果的问题不容易解决,但是锯齿类的问题可以使用FXAATXAA来避免。

另一个需要牢记的点是,后处理轮廓只适用于哪些表面较为光滑的模型。对于那些表面细节过多的模型,其表面细节轮廓被过度绘制,导致整个画面都是线条,这可能不是你想要的结果。

Source

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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
Shader "Tutorial/019_OutlinesPostprocessed"
{
//材质面板属性
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_OutlineColor ("Outline Color", Color) = (0,0,0,1)
_NormalMult ("Normal Outline Multiplier", Range(0,4)) = 1
_NormalBias ("Normal Outline Bias", Range(1,4)) = 1
_DepthMult ("Depth Outline Multiplier", Range(0,4)) = 1
_DepthBias ("Depth Outline Bias", Range(1,4)) = 1
}

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

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

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

//用于后处理的原图
sampler2D _MainTex;
//深度法向纹理
sampler2D _CameraDepthNormalsTexture;
//深度法向纹理尺寸
float4 _CameraDepthNormalsTexture_TexelSize;

//轮廓条件参数
float4 _OutlineColor;
float _NormalMult;
float _NormalBias;
float _DepthMult;
float _DepthBias;

//模型网格数据
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;
}

void Compare(inout float depthOutline, inout float normalOutline,
float baseDepth, float3 baseNormal, float2 uv, float2 offset){
//临近点采样
float4 neighborDepthnormal = tex2D(_CameraDepthNormalsTexture,
uv + _CameraDepthNormalsTexture_TexelSize.xy * offset);
float3 neighborNormal;
float neighborDepth;
DecodeDepthNormal(neighborDepthnormal, neighborDepth, neighborNormal);
neighborDepth = neighborDepth * _ProjectionParams.z;

float depthDifference = baseDepth - neighborDepth;
depthOutline = depthOutline + depthDifference;

float3 normalDifference = baseNormal - neighborNormal;
normalDifference = normalDifference.r + normalDifference.g + normalDifference.b;
normalOutline = normalOutline + normalDifference;
}

//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度法向采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//深度法向解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//还原深度值
depth = depth * _ProjectionParams.z;

float depthDifference = 0;
float normalDifference = 0;

Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(1, 0));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, 1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(0, -1));
Compare(depthDifference, normalDifference, depth, normal, i.uv, float2(-1, 0));

depthDifference = depthDifference * _DepthMult;
depthDifference = saturate(depthDifference);
depthDifference = pow(depthDifference, _DepthBias);

normalDifference = normalDifference * _NormalMult;
normalDifference = saturate(normalDifference);
normalDifference = pow(normalDifference, _NormalBias);

float outline = normalDifference + depthDifference;
float4 sourceColor = tex2D(_MainTex, i.uv);
float4 color = lerp(sourceColor, _OutlineColor, outline);
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
using UnityEngine;
using System;

//挂载到后处理摄像机上
public class OutlinesPostprocessed : MonoBehaviour {
//后处理材质
[SerializeField]
private Material postprocessMaterial;

private Camera cam;

private void Start(){
//设置摄像机
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}

//场景渲染完后,执行该函数
private void OnRenderImage(RenderTexture source, RenderTexture destination){
//执行后处理
Graphics.Blit(source, destination, postprocessMaterial);
}
}

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

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

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

原文:
Postprocessing with Normal Texture

Summary

处理深度图外,场景的法向纹理也是后处理中可能会用到的数据,同样也是通过简单的配置摄像机就可以获得。法向纹理记录了屏幕上每个像素点所对应模型表面的法向向量。

法向纹理的操作和深度图类似,所以如果你对深度图还不了解,建议你先从上一篇教程看起,有助于你对本篇的理解。

Read Depth and Normals

本篇教程延续并使用上一篇教程的着色器脚本,然后在此基础上进行扩展。

首先我们将后处理脚本中的内容清空,在上一个教程中,这个脚本主要用来刷新波的位置。然后我们将摄像机的深度模式改为深度法向模式。这样摄像机会同时采集场景的深度和法向信息。

1
2
3
4
5
private void Start(){
//将摄像机的深度模式改为深度法向模式
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}

设置完成后,我们就可以在着色器中访问法向图了,那么接下来修改我们的着色器。

在着色器中,我们也将和波有关的代码删除,然后将_CameraDepthTexture改为_CameraDepthNormalsTexture

1
2
3
4
//编辑器上显示的属性
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
}
1
2
//深度法向纹理
sampler2D _CameraDepthNormalsTexture;

设置好这些后,我们可以在片段着色器中使用我们的深度法向图。如果将其显示在屏幕上,你会发现有趣的现象。

1
2
3
4
5
6
7
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度法向纹理采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

return depthnormal;
}

但是上面的画面并不是真正的法向纹理,我们只看到近处的红绿颜色,和远处的蓝色。这是因为_CameraDepthNormalsTexture是中存储的深度和法向数据是经过编码的,所以使用之前需要对其解码。Unity也为我们提供了相应的解码函数。该解码函数有三个参数,第一个参数是采样值,后两个参数分别是解码后的深度、和法向。

和之前的深度图不同的是,这里解码后的深度值已经是线性的了,所以我们可以直接还原深度值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度法向采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//深度法向解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//深度值还原
depth = depth * _ProjectionParams.z;

return depth;
}

继续回到我们的主题法向纹理,我们可以将其显示到屏幕上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//片段着色器
fixed4 frag(v2f i) : SV_TARGET{
//深度法向采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//深度法向解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//还原深度值
depth = depth * _ProjectionParams.z;
//显示法向
return float4(normal, 1);
}

但是当我们转动摄像机时,你会发现模型表面的法向一直在变,这是因为我们的法向纹理是基于摄像机空间生成的。所以如果我们需要额外的一步来将其转换到世界坐标系。从摄像机坐标系转换到世界坐标系很简单,但是Unity并没有提供相应的函数。因此我们需要实现,并且将转换矩阵传递给着色器。

回到后处理脚本中,我们获取到用于后处理的摄像机组件,并把它存储为脚本的成员属性,然后在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
using UnityEngine;

//挂载到后处理摄像机上
public class NormalPostprocessing : MonoBehaviour {
//后处理材质球
[SerializeField]
private Material postprocessMaterial;

private Camera cam;

private void Start(){
//设置摄像机
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}

//场景渲染完后会调用该函数
private void OnRenderImage(RenderTexture source, RenderTexture destination){
//观察坐标系到世界坐标系的变换矩阵
Matrix4x4 viewToWorld = cam.cameraToWorldMatrix;
postprocessMaterial.SetMatrix("_viewToWorld", viewToWorld);
//执行后处理
Graphics.Blit(source, destination, postprocessMaterial);
}
}

然后在着色器中使用观察坐标系到世界坐标系的变换矩阵,将法向量转换到世界坐标系。

1
2
//着色器中的变换矩阵
float4x4 _viewToWorld;
1
2
3
//将法向量转换到世界坐标系
normal = normal = mul((float3x3)_viewToWorld, normal);
return float4(normal, 1);

Color the Top

知道了世界坐标系下的法向量,我们可以实现一些简单的效果,使得模型看起来有层次感。这里我们给模型顶部上色,也就是法向朝上的区域。

因此,我们将法向量和向上的向量相比较。通过两者的点乘,可以知道两个向量之间的关系,为1使同向,为0时垂直,为-1时反向。

1
2
float up = dot(float3(0,1,0), normal);
return up;

上面的图可能还不是很明显,为了凸出向上的区域,我们可以使用step将表面区域绝对划分为向上和非向上。下面我们将这个划分阈值设置为0.5,阈值越大被认定的顶部区域越小。

1
2
3
float up = dot(float3(0,1,0), normal);
up = step(0.5, up);
return up;

接下来我们将原图和我们生成的顶部图融合,其中非顶部区域采用原图颜色,顶部区域采用白色。前面我们讲过很多次了,这种效果可以使用插值。

1
2
3
4
5
float up = dot(float3(0,1,0), normal);
up = step(0.5, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, float4(1,1,1,1), up);
return col;

最后,我们可以将其中的阈值、和顶部颜色放到材质面板上,这样我们可以更灵活的控制我们的后处理效果。

1
2
_upCutoff ("up cutoff", Range(0,1)) = 0.7
_topColor ("top color", Color) = (1,1,1,1)
1
2
3
//阈值和顶部颜色
float _upCutoff;
float4 _topColor;

然后我们将片段着色器中的阈值和顶部颜色固定值改为上面定义的变量,同时我们还可以将顶部颜色的透明通道应用上。通过调节其透明通道,可以实现顶部颜色和原图的混合效果。

1
2
3
4
5
 float up = dot(float3(0,1,0), normal);
up = step(_upCutoff, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, _topColor, up * _topColor.a);
return col;

以上展示了深度法向纹理的使用方法。当然如果你想实现雪覆盖在模型上的效果,你可以直接在模型着色器上实现,而不是通过后处理的方式。

Source

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
using UnityEngine;

//挂载到后处理摄像机上
public class NormalPostprocessing : MonoBehaviour {
//后处理材质球
[SerializeField]
private Material postprocessMaterial;

private Camera cam;

private void Start(){
//设置后摄像机
cam = GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}

//当场景渲染完后,会执行该函数
private void OnRenderImage(RenderTexture source, RenderTexture destination){
//观察坐标系到世界坐标系的变换矩阵
Matrix4x4 viewToWorld = cam.cameraToWorldMatrix;
postprocessMaterial.SetMatrix("_viewToWorld", viewToWorld);
//执行后处理
Graphics.Blit(source, destination, postprocessMaterial);
}
}
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
Shader "Tutorial/018_Normal_Postprocessing"{
//材质面板
Properties{
[HideInInspector]_MainTex ("Texture", 2D) = "white" {}
_upCutoff ("up cutoff", Range(0,1)) = 0.7
_topColor ("top color", Color) = (1,1,1,1)
}

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

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

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

//用于后处理的原图
sampler2D _MainTex;
//观察坐标系到世界坐标系的变换矩阵
float4x4 _viewToWorld;
//深度法向纹理
sampler2D _CameraDepthNormalsTexture;

//自定义的可调节参数
float _upCutoff;
float4 _topColor;


//模型网格数据
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{
//深度法向纹理采样
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);

//深度法向量解码
float3 normal;
float depth;
DecodeDepthNormal(depthnormal, depth, normal);

//还原深度值
depth = depth * _ProjectionParams.z;

normal = mul((float3x3)_viewToWorld, normal);

float up = dot(float3(0,1,0), normal);
up = step(_upCutoff, up);
float4 source = tex2D(_MainTex, i.uv);
float4 col = lerp(source, _topColor, up * _topColor.a);
return col;
}
ENDCG
}
}
}

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

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

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