庄懂技美课的shader复现1

第二课shader复现

第二课的shader.

SF有些实现好怪- -

翡翠效果

image-20210726234650553

已经是我的极限了,美术牛

实现流程大概是这样:

首先进行基本的Lambert构建

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
Shader "ZhuangDong/Lec2/DoubleSpecTamaMaterial"
{
Properties
{
_Diffuse("Diffuse Color", Color) = (1,1,1,1)
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct v2f
{
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};

fixed4 _Diffuse;

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(normalize(v.normal));
o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
float diffuse = 0.5 * dot(i.worldNormal, lightDir) + 0.5;
return fixed4(diffuse,diffuse,diffuse, 1.0);
}
ENDCG
}
}
}

然后根据half lambert的计算结果对RampTex进行采样。RampTex是随便拉出来的,长这个样子:

image-20210726235527648

采样代码:

1
fixed3 rampColor = tex2D(_RampTex, float2(1-diffuse, .5)).rgb;

接下来考虑高光的遮罩制作。先对光照方向进行偏移:

1
2
float3 lightOffset1 = normalize(float3(lightDir.x + _OffsetValue1, lightDir.y, lightDir.z));
float3 lightOffset2 = normalize(float3(lightDir.x, lightDir.y + _OffsetValue2, lightDir.z));

这样得到两个偏移以后的光照,进而在此基础上进行lambert

1
2
float diff1 = max(0, dot(i.worldNormal, lightOffset1));
float diff2 = max(0, dot(i.worldNormal, lightOffset2));

对它进行一定的采样。我们遮罩的要求是如果$diff>r$,$r$是某个域值,就为高光点,否则不是高光点。因此,使用step函数,其中step(a,x)=a>x?1:0

1
2
float spec1 = step(_SpecularThreshold, diff1);
float spec2 = step(_SpecularThreshold, diff2);

得到两个高光之后,对二者取max,得到两个高光点

1
float spec = max(spec1, spec2);

此时的遮罩如下

image-20210726235417728

接下来要实现当spec是1的时候显示高光,否则显示原来的纹理颜色。这个操作可以用spec对原有颜色和现在的颜色进行lerp:

1
fixed3 targetColor = lerp(rampColor, whiteColor, spec);
image-20210727000045011

现在已经初具形状了,但仍然感觉不够晶莹剔透。所以我们引入fresnel term:
$$
fr = (1-\mathrm{v}\cdot\mathrm{n})^r
$$
当$\mathrm{v,n}$接近的时候,这个值接近0;否则接近1.也就是在边缘上,这个值接近1,中间接近0.那么根据这个值对原始颜色和白色进行插值,就可以得到一个发光的外表面。

完整代码如下:

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
Shader "ZhuangDong/Lec2/DoubleSpecTamaMaterial"
{
Properties
{
_Diffuse("Diffuse Color", Color) = (1,1,1,1)
_RampTex("Ramp Texture", 2D) = "white" {}
_OffsetValue1("Offset Value 1", float) = .01
_OffsetValue2("Offset Value 2", float) = .5
_SpecularThreshold("Specular Threshold", float) = .99
_FresnelPow("Fresnel Pow", float) = 3
}
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "Lighting.cginc"

struct v2f
{
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD0;
float3 worldNormal : TEXCOORD1;
float2 uv : TEXCOORD2;
};

sampler2D _RampTex;
float4 _RampTex_ST;
float _OffsetValue1;
float _OffsetValue2;
float _SpecularThreshold;
fixed4 _Diffuse;
float _FresnelPow;

v2f vert (appdata_base v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(normalize(v.normal));
o.uv = TRANSFORM_TEX(v.texcoord, _RampTex);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
float3 lightOffset1 = normalize(float3(lightDir.x + _OffsetValue1, lightDir.y, lightDir.z));
float3 lightOffset2 = normalize(float3(lightDir.x, lightDir.y + _OffsetValue2, lightDir.z));
float diff1 = max(0, dot(i.worldNormal, lightOffset1));
float diff2 = max(0, dot(i.worldNormal, lightOffset2));
float spec1 = step(_SpecularThreshold, diff1);
float spec2 = step(_SpecularThreshold, diff2);
float spec = max(spec1, spec2);
float3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz;
float diffuse = 0.5 * dot(i.worldNormal, lightDir) + 0.5;
fixed3 rampColor = tex2D(_RampTex, float2(1-diffuse, .5)).rgb;
fixed3 whiteColor = fixed3(1, 1, 1);
fixed3 targetColor = lerp(rampColor, whiteColor, spec);
float3 viewDir = normalize(UnityWorldSpaceViewDir(i.worldPos));
fixed fresnel = pow(1-dot(viewDir, i.worldNormal), _FresnelPow);
fixed3 col = lerp(targetColor, whiteColor, fresnel);
return fixed4(targetColor, 1.0);
}
ENDCG
}
}
}

带子效果

image-20210727225117259

这玩意的复现之路障碍重重…

首先的拦路虎就是,如何获取屏幕空间坐标?

有一个方法叫ComputeScreenPos,它大概长这个样子:

1
2
3
4
5
6
7
inline float4 ComputeScreenPos(float4 pos) {
float4 o = ComputeNonStereoScreenPos(pos);
#if defined(UNITY_SINGLE_PASS_STEREO)
o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
return o;
}

显然得不到什么有用信息。这个Single Pass Stereo Rendering是一个VR的参数,具体可以见这里。进一步考察ComputeNonStereoScreenPos这个函数:

1
2
3
4
5
6
inline float4 ComputeNonStereoScreenPos(float4 pos) {
float4 o = pos * 0.5f;
o.xy = float2(o.x, o.y*_ProjectionParams.x) + o.w;
o.zw = pos.zw;
return o;
}

首先要说明的是,这里传入的参数pos是经过MVP和NDC之后的坐标pos。

我们先抛开这里的_ProjectionParams不谈。由于
$$
srcX = \frac{p_x/p_w + 1}{2} \cdot W
$$
这是因为$p_x \in [-p_w, p_w]$。而我们要求的纹理
$$
\frac{srcX}{W} = \frac{p_x/p_w + 1}{2}
$$
这正好是对应UV的$x$值。考虑下面的函数
$$
x = 0.5p_x + 0.5 p_w
$$
那么这个$x$实际上对应了UV的$x$乘上$p_w$。

_ProjectionParams控制了透视投影的一些参量:

  • $x$代表是否使用翻转投影矩阵,如果是则为$-1.0$,否则为$1.0$
  • $y$为近平面,$z$为远平面,$w$为远平面的倒数

总而言之,这个函数的返回值是$[0, w]$的一个数。但我们往往需要一个$[0,1]$的数,否则无法采样。实际上,Unity提供了一个函数tex2Dproj,这个函数是在投影的基础上进行采样,也就是

1
2
3
tex2D(_Tex, float2(screenPos.xy / screenPos.w));
// 等价于
tex2Dproj(_Tex, screenPos);

如果直接这样采样,得到的结果大概是这样的:

image-20210727234334298

先放一个代码:

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
Shader "ZhuangDong/Lec2/ScreenBatch"
{
Properties
{
_BatchTex ("Texture", 2D) = "white" {}
_TextureScale("Texture Scale", float) = 1000
}
SubShader
{

Pass
{

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

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

struct v2f
{
float4 screenPos : TEXCOORD1;
float4 vertex : SV_POSITION;
};

sampler2D _BatchTex;
float4 _BatchTex_ST;
float _TextureScale;
sampler2D _RampTex;
float4 _RampTex_ST;

v2f vert (appdata v) {
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);
return o;
}

fixed4 frag (v2f i) : SV_Target {
i.screenPos.xy = i.screenPos.xy * 10;
return tex2Dproj(_BatchTex, i.screenPos);
}
ENDCG
}
}
}

但这么做的问题在于,放大缩小之后,纹理大小并不发生改变,而这并不是我们想看到的。我们希望放大纹理也放大,缩小纹理也缩小,那这可以通过乘上一个深度来实现。所以下一个问题就是,深度怎么求?好在,Unity有宏COMPUTE_EYEDEPTH来完成这项工作。它的代码也非常简单:

1
#define COMPUTE_EYEDEPTH(o) o = -UnityObjectToViewPos( v.vertex ).z

之所以我们还要乘上一个负号,是因为观察空间是右手系。所以总的来说,求深度的方法是这样:

1
2
3
o.screenPos = ComputeScreenPos(o.vertex);
COMPUTE_EYEDEPTH(o.screenPos.z);
float dep = max(0, o.screenPos.z - _ProjectionParams.g);

这里再插一个热知识。我们看Unity在UnityObjectToViewPos的实现是这样的:

1
2
3
inline float3 UnityObjectToViewPos( in float3 pos ) {
return mul(UNITY_MATRIX_V, mul(unity_ObjectToWorld, float4(pos, 1.0))).xyz;
}

为什么我们不直接使用UNITY_MATRIX_MV去乘呢?我们看看UNITY_MATRIX_MV的定义:

1
2
#define UNITY_MATRIX_MV     unity_MatrixMV
static float4x4 unity_MatrixMV = mul(unity_MatrixV, unity_ObjectToWorld);

当我们直接乘的时候,是这样的:

1
mul(mul(unity_MatrixV, unity_ObjectToWorld), float4(pos, 1.0));

它的计算次数是$4\times 4 \times 4 + 4 \times 4 \times 1 = 80$

而先做后两个的乘法,运算次数是$4 \times 4 \times 1 + 4 \times 4 \times 1 = 32$

省了一半之多!

但如果我们直接去乘,会出现非常严重的畸变:

image-20210728001006453

可是明明庄懂那Shader Forge跑出来就很正常!为了搞清楚这个问题,我特意安装了Unity 5.6。看一下它的Shader,长这样:

1
2
float2 sceneUVs = (i.screenPos.xy / i.screenPos.w);
float2 texTrans = partZ * (sceneUVs * 2 - 1).rg * _TextureScale;

这里它首先从[0,pw]映射到了[0,1],接下来多了一步操作,从[0,1]映射到了[-1,1]。原来屏幕空间实际上呈现这么一张图的样子:

image-20210728001713668

现在我们变成了这样:

image-20210728001739171

这样有什么好处呢?畸变的产生实际上是因为在原来比较大的$x,y$乘上深度之后就变得很大导致的,而想要杜绝这一点,我们就需要把$x,y$控制在一个比较小的范围内。进行remap操作之后,原来在屏幕就会产生的畸变程度现在在屏幕边缘产生,位于中间的深度有比较好的效果,并且越是在画面中间,畸变程度就越低。

image-20210728002209487

这里我们就不用tex2Dproj了,直接使用tex2D进行采样:

1
fixed4 textureColor = 1 - tex2D(_BatchTex, TRANSFORM_TEX(texTrans, _BatchTex));

这个问题解决了,实际上最麻烦的地方就解决了。剩下的部分就比较轻松愉快了。

首先还是先算Lambert:

1
2
3
float3 lightDir = UnityWorldSpaceLightDir(i.worldPos);
float3 worldNormal = normalize(i.worldNormal);
float diffuse = dot(lightDir, worldNormal);

然后我们根据textureColor.rdiffuse进行step操作。这样,当diffuse < 0的时候,texColor恒为黑色。然后我们还需要高光,这个时候可以根据某个阈值比如0.9diffuse进行step操作,保留高光位点。最后,把两个step进行max,就完成了亮处高光、暗处黑色、其他地方条纹的着色。

1
2
3
float diffuseStep = step(textureColor.r , diffuse);
float whiteDiffuseStep = step(.9, diffuse);
float mixStep = max(diffuseStep, whiteDiffuseStep);

由于此时的颜色只有两种,我们可以使用一个双色的RampTex进行采样。

1
fixed4 color = tex2D(_RampTex, float2(mixStep, .5));

我们还需要一个描边效果,可以用一个经典Pass来完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Pass {
Cull Front

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

float _Outline;
fixed4 _OutlineColor;

float4 vert(appdata_base i) : SV_POSITION {
return UnityObjectToClipPos(float4(i.vertex.xyz+i.normal*_Outline, 1));
}

fixed4 frag() : SV_TARGET {
return _OutlineColor;
}

ENDCG
}

最后我们放出完整的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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
Shader "ZhuangDong/Lec2/ScreenBatch"
{
Properties
{
_BatchTex ("Texture", 2D) = "white" {}
_TextureScale("Texture Scale", float) = 1000
_RampTex ("Texture", 2D) = "white" {}
_Outline ("Outline", Range(0, .1)) = .1
_OutlineColor ("Outline Color", color) = (0,0,0,0)
}
SubShader
{

Pass {
Cull Front

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

float _Outline;
fixed4 _OutlineColor;

float4 vert(appdata_base i) : SV_POSITION {
return UnityObjectToClipPos(float4(i.vertex.xyz+i.normal*_Outline, 1));
}

fixed4 frag() : SV_TARGET {
return _OutlineColor;
}

ENDCG

}

Pass
{

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

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

struct v2f
{
float4 screenPos : TEXCOORD1;
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD2;
float3 worldNormal : TEXCOORD3;
float dep : TEXCOORD4;
};

sampler2D _BatchTex;
float4 _BatchTex_ST;
float _TextureScale;
sampler2D _RampTex;
float4 _RampTex_ST;

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);
COMPUTE_EYEDEPTH(o.screenPos.z);
float dep = max(0, o.screenPos.z - _ProjectionParams.g);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
o.dep = dep;
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = UnityWorldSpaceLightDir(i.worldPos);
float3 worldNormal = normalize(i.worldNormal);
float diffuse = dot(lightDir, worldNormal);
float partZ = max(0, i.screenPos.z - _ProjectionParams.g);
float2 sceneUVs = (i.screenPos.xy / i.screenPos.w);
float2 texTrans = partZ * (sceneUVs * 2 - 1).rg * _TextureScale;
fixed4 textureColor = 1 - tex2D(_BatchTex, TRANSFORM_TEX(texTrans, _BatchTex));
// texColor > diffuse 0 otherwise 1
// diffuse < 0, texColor < diffuse 成立,黑色
float diffuseStep = step(textureColor.r , diffuse);
float whiteDiffuseStep = step(.9, diffuse);
float mixStep = max(diffuseStep, whiteDiffuseStep);
fixed4 color = tex2D(_RampTex, float2(mixStep, .5));
return color;
}
ENDCG
}
}
}

halftone

image-20210728004324722

前两个复现出来都差强人意,这个应该还算比较正常(

和上一个shader有很多类似的地方,首先我们定义_DotSize是点的密度。为了保证点是个圆形,比起上个shader加一步骤:

1
2
3
float2 sceneUVs = (i.screenPos.xy / i.screenPos.w);
float2 texTrans = (sceneUVs * 2 - 1).rg * _DotSize;
texTrans.y = texTrans.y * _ScreenParams.y / _ScreenParams.x;

接下来,我们取texTrans的小数部分。

image-20210728004804440

可以看到这么一个格,这个格左下角是$(0,0)$,右上角是$(1,1)$。为了找到圆心,我们Remap到$(-.5, .5)$:

1
2
float2 texRemap = texFrag - .5;
// pic:(texRemap, .0, 1.0)
image-20210728004915558

此时,texRemap的模就是距离圆心的距离:

1
2
float dist = length(texRemap);
// pic:(dist, dist, dist, 1.0)
image-20210728005106344

接下来进行lambert。不同的是,我们先remap一下:

1
2
3
4
float3 lightDir = UnityWorldSpaceLightDir(i.worldPos);
float3 worldNormal = normalize(i.worldNormal);
float diffuse = max(0, dot(lightDir, worldNormal));
float diffuseRemap = (1 - diffuse) * 2.5 - .5;

diffuse被remap到了[-0.5,2]的范围内。这么做是为了进行下一步:

1
float distPow = pow(dist, diffuseRemap);

这么做的目的我们分类讨论:

  • 比较暗的地方,diffuse小,1-diffuse大,映射到$[1,2]$的区间。此时,会让原来比较暗的点更暗,比较亮的点也比较暗。
  • 比较亮的地方,diffuse大,1-diffuse小,映射到$[-.5,0]$的区间。此时,会让原来比较暗的点更亮,比较亮的点也更亮。
  • 中间的地方,映射到$[0,1]$的区间,变化不是很剧烈。

这个时候做出来是这样:

image-20210728005634392

为了让它更锐利,可以进行一下round操作:

1
float roundPow = round(distPow);

最后再加个阴影,就完整了。

完整的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
Shader "ZhuangDong/Lec2/ScreenDot"
{
Properties
{
_DotSize ("Dot Size", float) = 10
_RampTex ("Texture", 2D) = "white" {}
_Outline ("Outline", Range(.01, .05)) = .1
_OutlineColor ("Outline Color", color) = (0,0,0,0)
}
SubShader
{

Pass {
Cull Front

CGPROGRAM

#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"

float _Outline;
fixed4 _OutlineColor;

float4 vert(appdata_base i) : SV_POSITION {
return UnityObjectToClipPos(float4(i.vertex.xyz+i.normal*_Outline, 1));
}

fixed4 frag() : SV_TARGET {
return _OutlineColor;
}

ENDCG

}

Pass
{

CGPROGRAM
#pragma vertex vert
#pragma fragment frag

#include "UnityCG.cginc"
#include "AutoLight.cginc"

struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
SHADOW_COORDS(0)
};

struct v2f
{
float4 screenPos : TEXCOORD1;
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD2;
float3 worldNormal : TEXCOORD3;
};

float _DotSize;
sampler2D _RampTex;
float4 _RampTex_ST;

v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.screenPos = ComputeScreenPos(o.vertex);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
o.worldNormal = UnityObjectToWorldNormal(v.normal);
TRANSFER_SHADOW(o);
return o;
}

fixed4 frag (v2f i) : SV_Target
{
float3 lightDir = UnityWorldSpaceLightDir(i.worldPos);
float3 worldNormal = normalize(i.worldNormal);
float diffuse = max(0, dot(lightDir, worldNormal));
float diffuseRemap = (1 - diffuse) * 2.5 - .5;
float2 sceneUVs = (i.screenPos.xy / i.screenPos.w);
float2 texTrans = (sceneUVs * 2 - 1).rg * _DotSize;
texTrans.y = texTrans.y * _ScreenParams.y / _ScreenParams.x;
float2 texFrag = frac(texTrans);
float2 texRemap = texFrag - .5;
float dist = length(texRemap);
float distPow = pow(dist, diffuseRemap);
float roundPow = round(distPow);
UNITY_LIGHT_ATTENUATION(atten, i, i.worldPos);
return fixed4(roundPow, roundPow, roundPow, 1.0);
}
ENDCG
}
}
}

Author

LittleRewriter

Posted on

2021-07-28

Updated on

2021-07-28

Licensed under

Comments