0%

原文:
Sprite Shaders

Summary

在Unity中,图片的渲染和三维模型渲染非常相似。并且提供了SpriteRender的组件来实现图片渲染的功能。接下来我也会介绍这个组件、及其实现原理,然后通过我们的着色器来模拟这个组件的功能。

这个教程依赖于上一个透明着色器的教程。

Scene Setup

这里采用一个非常简单的场景来实验我们的图片着色器。首先,我将摄像机改为正交模式,并且将原先的小方块改成sprite renderer,同时将后面用到的图片格式改为sprite



Changing the Shader

在完成以上修改后,将上一章的透明材质放到SpriteRenderer组件的材质属性上,一切准备就绪!

SpriteRenderer组件会自动根据附加的图片自动生成一个网格数据,包括顶点、UV等。然后和普通的三维模型一样渲染到场景中。同时在SpriteRenderer上定义的颜色也会以顶点颜色的形式包含在网格数据中。另外还可以设置图片是否翻转。而且图片渲染和半透明渲染一样,是排在不透明渲染之后,甚至大多数渲染之后,而这个渲染队列是由SpriteRenderer自动帮我们设置的。

因为目前我们的半透明着色器还不支持翻转、和顶点颜色。所以接下来我们把这个添加上。

但是当我们翻转后,会发现图片不见了,然后再翻转,图片又出现了。这时因为从性能优化角度考虑,渲染单面比渲染双面更节约性能,这里叫做backface culling背面剔除。普通的不透明模型渲染使用背面剔除非常有效,因为其内部不可能被渲染。另外如果不使用背面剔除,在光照计算的时候会出现怪异的效果,因为参与计算的法向量只会指向正面方向,因此背面计算的结果无效。

在这里我们并不用考虑这个优化项,因为图片、半透明物体之类的并不存在哪个面朝内、哪个面朝外的问题,也不需要参与光照计算。因此我们这里直接关闭背面剔除公共,关闭的设置和之前混合模式、深度写入设置类似。

1
Cull Off

为了在最终的渲染中使用到前面说的顶点颜色数据。我们需要在半透明着色其中做一些修改。首先在输入数据结构和插值数据结构中加入一个四维向量,并标记为颜色类型。然后在顶点着色器中将输入数据结构中的顶点颜色传递给插值数据结构中的颜色值。最终在片段着色器中叠加到输出颜色上。

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
struct appdata{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
fixed4 color : COLOR;
};

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

v2f vert(appdata v){
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.color = v.color;
return o;
}

fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = tex2D(_MainTex, i.uv);
col *= _Color;
col *= i.color;
return col;
}

修改后的半透明材质就可以应用到SpriteRenderer组件上了,并且可以响应SpriteRenderer上的一些设置操作。在后面的教程中我们还可以在此基础上扩展出更加绚丽的效果。

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
Shader "Tutorial/007_Sprite"{
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
Tags{
"RenderType"="Transparent"
"Queue"="Transparent"
}

Blend SrcAlpha OneMinusSrcAlpha

ZWrite off
Cull off

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;
fixed4 color : COLOR;
};

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

v2f vert(appdata v){
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.color = v.color;
return o;
}

fixed4 frag(v2f i) : SV_TARGET{
fixed4 col = tex2D(_MainTex, i.uv);
col *= _Color;
col *= i.color;
return col;
}

ENDCG
}
}
}

SpriteRenderer组件还会提供各种网格数据来实现图标、多边形图形、动画效果。

相比于Unity内置的Sprite Shader,我们目前还没有实现的功能有instancing、像素捕捉、alpha通道扩展,因为这些功能比较复杂,而且很少使用,因此我现在不做介绍。

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

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

原文:
Basic Transparency

Summary

对于不透明物体的渲染,是直接将计算所得到的像素颜色覆盖掉原有的颜色值。而透明物体的渲染方法恰恰相反,是保留原有颜色值,然后两者通过混合后,达到一种看是半透明的效果。为了阐明关键思想,这里以最简单的不受光材质为例。

如果你还不知道如果编写着色器,这里建议你先阅读我之前的教程

为了达到正确的渲染效果,首先我们需要告诉Unity:我们打算渲染一个透明物体。因此,我们可以将SubShader块中的Tags块的渲染类型改为Transparent,以及将其中的渲染队列改为Transparent。渲染队列的设置是为了保证我们的半透明物体一定是在不透明物体之后渲染。如果不这么设置,根据上面说到的不透明渲染的方法,很可能有不透明物体直接覆盖在半透明物体区域,即便我们的半透明物体在不透明物体的前方。

1
Tags{ "RenderType"="Transparent" "Queue"="Transparent"}

接下来我们要定义混合模式,前面提到需要将半透明的像素颜色和已经渲染的像素颜色相混合,就是通过这个混合模式来决定的。混合模式的定义由两个关键字构成,第一个和半透明颜色相乘,第二个和已有的颜色相乘,然后两者相加。

当我们渲染不透明物体时,我们将第一个参数设为1,第二个参数设为0,这样新的颜色值便会完全替换掉原先的颜色值。而在透明材质中,我们通常使用新颜色的透明通道作为第一个参数,而第二个参数为1减该透明通道值,这样我们就可以调整其透明通道来实现不同程度的透明效果。

混合模式可以定义在SubShader块中,也可以定义在Pass块中,位置的不同决定其作用域的大小。但是必须定义在hlsl代码以外,因为这属于Shaderlab拓展的特性。

1
Blend SrcAlpha OneMinusSrcAlpha

你可以从下面网址找到更全面的混合模式设置介绍:https://docs.unity3d.com/Manual/SL-Blend.html

为了突出重点,精简逻辑,这里举两个小例子:

  • 当我们的片段着色器返回的透明通道值为0.5,那么根据前面设置的混合模式,将会将新的颜色值得一半和旧的颜色值得一一半进行混合。如果新颜色是白色,旧颜色是黑色,那么混合后的将是灰色;
  • 当我们的片段着色器返回的透明通道值为0.9,那么混合后,新颜色将占有90%的比例,而旧颜色只占有%10;

因此,在前面不透明着色器脚本的基础上,做以上调整,该着色器就成功变成一个半透明着色器。因为公共变量_Color参与了片段着色器中的颜色计算,所以我们在材质面板修改其透明度,将会影响最终的混合效果。如下:

另一个需要修改的地方是关闭透明着色器的深度写入功能。一般来说,当模型被渲染到画面上是,其距离摄像机的深度信息会被记录到一张深度纹理是上。然后后面渲染的模型只需要与这张深度图相比较,就可以正确判断其相互之间的遮挡关系,其中被遮挡的部分将不会渲染到画面上。但是这并不适用于半透明物体,因为即便被遮挡也会被渲染。所以我们只能从其渲染队列方面考虑,将半透明物体限定在不透明物体之后渲染,并且半透明物体之间的渲染排序是由远及近。深度值写入的设置如下,和混合模式设置一样,都可以定义在SubShaderPass块中。

1
2
Blend SrcAlpha OneMinusSrcAlpha
ZWrite Off

如果我们的纹理贴图也存在半透明通道,那么最终渲染出来的效果将是各个部位的透明程度有差异。

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
Shader "Tutorial/006_Basic_Transparency"{
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
Tags{ "RenderType"="Transparent" "Queue"="Transparent"}

Blend SrcAlpha OneMinusSrcAlpha
ZWrite off

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
}
}
}

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

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

原文:
Surface Shader Basics

Summary

在Unity中,我们可以创建一个空的着色器脚本,然后手动一行一行的去实现整个脚本。当然这种方式比较费时,毕竟着色器有一套固定的流程,因此可以将一部分代码复用,例如光照模型。如果能够直接配置一些参数就能自动生成相关代码,你一定会喜欢吧。Unity就是这么会投其所好,它实现了一种名叫表面着色器的东东,刚好能够满足咱懒人的需求。懒-是推动科技发展的第一生产力,至理名言啊!

那什么是表面作色器呢?在进入正题之前,我建议你先了解最简单的无光照的着色器,如果你不清楚,可以参考我上一个教程

Conversion to simple Surface Shader

相比于前面介绍的着色器实现方法,表面着色器的实现就显得更加简洁,原先需要处理的很多内容都可以剔除,因为Unity会自动帮我们生成相关代码。以上一个教程的着色器脚本为例,如果我们要用表面着色器来实现,那么前面提到的什么顶点着色器都可以不要了。与之相对应的宏命令也可以删除,两者之间的插值数据也可以不要了。甚至是UnityCG.cginc文件也可以不要,还有MainTex_ST等等。这些代码最终都会由Unity自动生成。一顿大刀阔斧,来看看我们的成果吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Shader "Tutorial/005_surface" {
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

sampler2D _MainTex;
fixed4 _Color;

fixed4 frag (v2f i) : SV_TARGET {
fixed4 col = tex2D(_MainTex, i.uv);
col *= _Color;
return col;
}
ENDCG
}
FallBack "Standard"
}

简洁的令人发指!等等,先别急着感叹,还有事没做完。上面的着色器脚本并不能执行,因为表面着色器有自己的一些要求。

首先,我们需要添加一个新的数据类型作为片段着色器的输入。这个数据类型将会包含所有与片段着色相关的必要数据。当然,我们这个简单的不能再简单的着色器只需要传递UV坐标。UV坐标还是二维向量。但是,这里的UV变量命名有特殊的规则。因为UV坐标是用来纹理采样的,上一章讲过UV变换,每一个纹理都有自己的缩放、偏移参数,所以必须将变换后的UV和对应的纹理相关联。这里采用命名规则来实现,首先UV变量必须以uv开头,然后后面跟随纹理变量的名称。这样,在后面自动生成代码的时候,程序就知道谁和谁配对了。

1
2
3
struct Input {
float2 uv_MainTex;//此时配对的纹理是 _MainTex
};

接下来我们要对之前的片段着色其进行修改,使其编程表面着色器。为了区分两者,先把函数名改为surf。然后表面着色器函数是没有返回值的,所以函数返回类型改为void

然后再拓展出两个参数。第一个参数正是我们刚刚定义好的Input结构,通过这个参数,表面着色器可以获取所有相关的必要参数;第二个参数是一个叫做SurfaceOutputStandard的结构体,从字面意思可以看出,这就是表面着色器的最终输出数据。当然,在函数结束之前,必须计算好、并赋值所有需要外传的参数。除了SurfaceOutputStandard结构外,Unity还定义了其他类似的结构,它们之间的区别在于适用于不同的光照模型。而这些结构中的成员变量就包含了后续光照处理所必须的数据,这些数据的具体含义将在后面介绍。

然后是删除SV_Target语义标识符,因为我们的surf函数的返回类型是void

最后是把return语句删除。然后将计算所得的数据通过SurfaceOutputStandard传递出去。

1
2
3
4
5
void surf (Input i, inout SurfaceOutputStandard o) {
fixed4 col = tex2D(_MainTex, i.uv_MainTex);
col *= _Color;
o.Albedo = col.rgb;
}

最后一步,我们需要引入光照模型,同时将写好的表面着色器函数与表面着色器相关联,这个和顶点着色器类似,都是通过#pragma来实现。因为我们这里的surf的输出结构是SurfaceOutputStandard,意味着我们使用标准的光照模型。所以在其后我们还得加上Standard来说明所用的光照模型,最后的fullforwordshadows是告诉Unity使用前向阴影。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Shader "Tutorial/005_surface" {
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

CGPROGRAM

#pragma surface surf Standard fullforwardshadows

sampler2D _MainTex;
fixed4 _Color;

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;
}
ENDCG
}
}

Standard Lighting Properties

SurfaceOutputStandard结构中包含很多材质相关的属性参数,具体如下:

  • Albedo : 表示材质表面的颜色。而材质最终呈现的颜色受光照的影响,因为材质本身只能决定对吸收哪些波段的光。
  • Normal : 表示材质表面法向向量。法向向量一般和顶点坐标一起,存储在模型数据中,这时候法向向量所在的坐标系是切向空间。什么是切向空间呢?可以简单的理解为沿着模型表面的坐标系。而法向是指垂直与模型表面的方向。法向向量一般要变换到世界坐标系再参与计算。
  • Emission :表示材质的自发光特性。一般的的模型渲染依赖于外部光源,如果关闭外部光源,那么模型表现为黑色。而具备自发光材质的模型即便没有外部光源也能显示出原本的颜色。一般场景中的灯具、或者熔岩会使用自发光属性。
  • Metallic :表示材质的金属特性。现实中,金属材料和非金属材料的光学特性不一样,例如即便黑色的金属也能在阳光下反射光芒,但是黑色的非金属就表现的黯淡无光。
  • Smoothness : 表示材质的光滑度。光滑程度主要决定漫反射、和镜面反射之间的权重分布。玻璃的光滑度非常高,所以可以用来做镜子,而一般木材非常粗糙。
  • Occlusion :表示环境遮罩特性。举个例子,平坦的桌面上,光线能够达到每个角落。而崎岖不平的背包上,那些深深的褶皱显得格外阴暗。这些因为表面相互遮挡而产生的阴影就是我们这里的环境遮罩效果。
  • Alpha : 表示材质的透明度。从字面可以很好理解,有些材质是透明的,如玻璃,有些不是,如木头。

上面提到的这些参数前三个是向量,后四个是标量。这些参数有些可以直接暴露在材质面板,方便美术编辑材质效果。

Implement a few Lighting Properties

接下来我们把emissionmetallicsmoothness三个参数为例,将其作为材质的可调参数。当然你也可以根据实际需求做调整。

首先,我们定义两个公共变量:光滑度和金属度。它们的类型我选择half。一般来说除了坐标采用float,其他的都选half类型。

1
2
half _Smoothness;
half _Metallic;

然后将这些公共变量添加到Properties块中,将其暴露在材质面板上。但是材质面板并不知道所显示的变量的类型,所以还需要在其名称后增加类型说明,如下所示:

1
2
3
4
5
6
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Smoothness ("Smoothness", float) = 0
_Metallic ("Metalness", float) = 0
}

surf函数中,我们可以直接将这些公共变量传递给SurfaceOutputStandard结构体,这样在后续的光照计算中,就可以使用这些参数了,同时我们修改材质面板上的值,便能立即改变材质的表现效果。

1
2
3
4
5
6
7
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;
}

目前为止,表面着色器已经基本完成了。但是还有些需要完善的地方,因为我们在材质面板上修改参数时,很容易设置到非法值,最终导致材质显示异常。我们可以在Properties做些小修改,将float修改为Range(0,1)就可以将这些参数限定在一个有效范围内。

1
2
3
4
5
6
Properties {
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0, 1)) = 0
_Metallic ("Metalness", Range(0, 1)) = 0
}

接下来我们添加自发光颜色。同样的我们也需要在公共变量、和Properties中加入_Emission的定义。

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

_Emission ("Emission", Color) = (0,0,0,1)

// ...

half3 _Emission;

// ...

o.Emission = _Emission;

为了避免自发光材质过度曝光,我们只能将自发光颜色限定在[0-1]之间。当然,如果我们将自发光颜色定义为HDR类型的话,就可以不用担心过曝的问题了。如果我们使用纹理来将材质各个部位的自发光特性差别化,那么可以产生很炫的效果。例如怪兽的眼睛放光的效果。

1
[HDR] _Emission ("Emission", Color) = (0,0,0,1)

Minor Improvements

最后,让我们做一些小改动,让我们的材质看起来更自然。首先,我们在着色器脚本最后面加上fallback shader,这样我们可以复用其他着色器中的代码。这里我将标准着色器作为我们的fallback shader,然后复用其中的阴影渲染部分shadow pass的代码,这样就可以让我们的材质也表现出阴影效果。我们使用fullfowardshadows参数进而获得很好的阴影效果。另外我们也可以指定当前着色器的目标平台,例如设置target为3.0。其中target的值越高,表示可以使用的特性越多,但是支持的硬件平台会越少。这里target为3.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
Shader "Tutorial/005_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)
}
SubShader {
Tags{ "RenderType"="Opaque" "Queue"="Geometry"}

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"
}

希望本章的介绍能让你有所收获!

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

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

原文:
Basic Shader

Summary

在前面三个教程中,我介绍了着色器工作的基本原理、与结构。接下来我进一步介绍如何修改其中的内容。

在此之前,我并没有介绍着色器的执行代码部分。因为作为入门介绍,我们需要从结构框架入手,而不应该拘泥于细节。在大致了解着色器的实现流程后,接下来让我们来发现更有趣的细节。

What we have so far

下面的着色器脚本,如果你觉得有前三章没有阐述到位的地方,都可以告诉我。我必将事事有会响!

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

SubShader{
//当前的标签设置表示的是:不透明渲染,和其他不透明物体处以同一渲染队列
Tags{ "RenderType"="Opaque" "Queue"="Geometry" }

Pass{
CGPROGRAM

//这里包括一些工具函数、以及内置变量
#include "UnityCG.cginc"

//定义顶点、片段着色函数
#pragma vertex vert
#pragma fragment frag

//材质所用的纹理、以及缩放偏移量
sampler2D _MainTex;
float4 _MainTex_ST;

//材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
fixed4 _Color;

//模型网格、UI等输入数据,基本代表了模型内在属性
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);
//基于图片的缩放偏移参数,对UV进行变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//基于UV坐标,进行纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//将纹理颜色和材质颜色相乘
col *= _Color;
//返回最终的像素点颜色
return col;
}

ENDCG
}
}
Fallback "VertexLit"
}

Setting up the shader stages

之前提到的顶点着色器、片段着色器,在着色器脚本中实际上就是HLSL函数。只不过,这些函数对应这渲染管线中的特定阶段。为了将这些函数关联到指定阶段,我们可以使用#pragma关键字来说明。前面经常谈到的顶点着色器、和片段着色器是非常重要的两个阶段。因为顶点着色器负责将模型数据转换到裁剪空间,然后经由栅格化处理,进入到片段着色器,最终有片段着色器计算出像素颜色,并写入渲染对象。可以说这两个着色器代表了渲染的基本流程。其中关联函数的操作如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
//定义顶点、片段着色函数
#pragma vertex vert
#pragma fragment frag

//顶点着色器函数,主要执行坐标的空间变换
v2f vert(appdata v){
//
}

//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//
}

Vertex stage

在实现顶点着色器函数之前,我满需要定义好插值数据类型,前面提到过,这类数据是在顶点着色器中计算好,然后传递给片段着色器的。

顶点着色器的主要功能就是执行空间变换,将顶点数据从模型坐标系转换到裁剪坐标系。空间转换可以借助矩阵乘法来实现。但是,很多时候我们并不需要知道乘法的具体实现,因为Unity为我们提供了很多矩阵变换相关的函数。我们可以使用宏命令来引入Unity预先编写好的工具函数。例如#include UnityCG.cginc。其中UnityObjectToClipPos便是实现模型坐标系到裁剪坐标系的工具函数。而UnityCG.cginc文件中处了定义了很多常用的工具函数外,还定义了很多宏操作。宏操作的使用方法和函数的使用类似。例如,用于UV转换的宏TRANSFORM_TEX,使用顶点UV,以及纹理变量为参数,最终得到转换后的UV坐标。

编写好的顶点着色器函数如下:

1
2
3
4
5
6
7
8
9
//顶点着色器函数,主要执行坐标的空间变换
v2f vert(appdata v){
v2f o;
//将模型各顶点坐标,转换到裁剪空间
o.position = UnityObjectToClipPos(v.vertex);
//基于图片的缩放偏移参数,对UV进行变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

Fragment stage

在片段着色器中,我们使用由顶点着色器传来的插值数据、以及公共的纹理数据等,来进一步计算每个像素点的颜色。当然,我们可以直接返回白色,如return float4(1,1,1,1);。但是更实际的情况是,我们结合前面提到的数据,通过一些简单、或复杂的计算,来得到比较自然的颜色值。

我们把使用纹理数据的过程叫做纹理采样。因为纹理数据是一整张包含无数像素点的图片,而我们的片段着色器计算的是一个单独像素的颜色。因此我们只需要纹理中的一个像素值。采样的方法也很简单,直接使用tex2D函数就可以,当然还有其他一些复杂一点的采样函数。这里我们以tex2D为例,需要两个参数,第一个是纹理变量,第二个是UV坐标。下面我们除了使用采样后的像素颜色,同时也叠加了公共变量_Color的值。

1
2
3
4
5
6
7
8
9
//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//基于UV坐标,进行纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//将纹理颜色和材质颜色相乘
col *= _Color;
//返回最终的像素点颜色
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
Shader "Tutorial/001-004_Basic_Unlit"{
//这些值将会显示在材质面板上
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
//当前的标签设置表示的是:不透明渲染,和其他不透明物体处以同一渲染队列
Tags{ "RenderType"="Opaque" "Queue"="Geometry" }

Pass{
CGPROGRAM

//这里包括一些工具函数、以及内置变量
#include "UnityCG.cginc"

//定义顶点、片段着色函数
#pragma vertex vert
#pragma fragment frag

//材质所用的纹理、以及缩放偏移量
sampler2D _MainTex;
float4 _MainTex_ST;

//材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
fixed4 _Color;

//模型网格、UI等输入数据,基本代表了模型内在属性
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);
//基于图片的缩放偏移参数,对UV进行变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//基于UV坐标,进行纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//将纹理颜色和材质颜色相乘
col *= _Color;
//返回最终的像素点颜色
return col;
}

ENDCG
}
}
Fallback "VertexLit"
}

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

原文:
Variables

Summary

在了解Shaderlab语言的基本结构,以及着色器各个阶段的功能划分,接下来,让我们来学习一下着色器中所用到的变量,以及如何在代码中使用它们。在着色器中,变量可以分为材质变量、模型网格变量、以及各个着色阶段数值传递的中间变量。

Object Data

模型数据。在介绍渲染过程的时候,我所提到的模型数据,实际上就是模型上面的网格数据。从底层角度来看,这些数据定义了模型的几何形状,决定了模型最终显示的形状。不过为了方便描述,我们直接将其归纳为模型数据、或者网格数据。通常情况下,模型数据包含模型中各个顶点的位置、以及三角面片序列。当然有些模型数据还包含的顶点法向、UV、颜色等数据。除了三角面片序列,其他的数据都是逐(个)顶点数据,也就是说顶点法向、UV、颜色、和顶点位置一一对应,具有相同的个数。因为顶点的位置数据是基于模型坐标系,所以,无论模型位置、朝向如何,都不会影响模型数据。所以对于同一种模型,我们可以使用同一个模型数据,然后通过对模型的缩放来实现一定的差异化。

在Unity着色器中, 模型数据首先是传递给顶点着色器,而模型数据通常也是以自定义数据类型表示,Unity也预先帮我们定义了一些类型,例如struct appdata。当然我们也可以按需自定义,类型的名字可以任意,只要不要和已有的重名就行。当然,因为着色器在执行的过程中有一套固定的流程,包括在各个节点使用什么样的数据。而我们定义的类型并不能传达这些信息,因此,需要在自定义类型的成员变量后面加上语义标识。如下所示,通过POSITION来表示我们的vertex是顶点坐标。

1
2
3
4
struct appdata{
float4 vertex : POSITION;//顶点坐标
float2 uv : TEXCOORD0;//UV坐标
};//别忘了加分号

关于其他定义的语义标识符,可以参考以下链接:
https://docs.unity3d.com/Manual/SL-VertexProgramInputs.html

Interpolators

插值数据。当顶点着色器将模型数据从模型空间转换到裁剪空间时,顶点着色阶段的任务就已经结束了。这时候需要将处理好的数据传递給下一个阶段,通常情况下是片段着色阶段。但是顶点着色器输出的结果是基于顶点的,但是片段着色器是基于像素点的,例如渲染一个三角形,顶点作色器只处理三个顶点,但是这个三角形投影到屏幕上就不止三个点了,一般会有更多的像素点构成。所以从顶点着色器到片段作色器,前后输出和输入参数个数不对对等,所以需要通过插值的方式来生成其他可能的像素点数据。

这里的插值过程又叫做栅格化处理,因为我们的屏幕是由一格一格的光栅构成,所以有此得名。栅格化处理是由硬件完成的,虽然这一步也属于整个渲染管线的一步,但是我们却不能对其进行修改。所以我们也需要通过语义标识符来告诉硬件,各个数据的用途。例如SV_POSITION就表示投影变换后的顶点坐标,后面也是根据它来进行插值操作,最终得到屏幕像素点。当然还有其他可选的语义标识符可以使用,例如顶点颜色、UV等,用法基本类似。

在习惯上人们通常会将插值数据命名为v2f,也就是vertex to fragment的缩写,表示是从顶点到像素片段的中间变量。具体例子如下:

1
2
3
4
5
//该数据是从顶点着色器,经过栅格化处理,传入到片段着色器中
struct v2f{
float4 position : SV_POSITION;
float2 uv : TEXCOORD0;
};

Output color

最终输出的颜色值。片段着色器主要用于计算像素点的颜色,通常计算的颜色值由4维向量表示,分别对应红、绿、蓝、透明四个通道。这里也有一个语义标识符来表示输出的颜色SV_Target

Uniform data

公共数据。因为GPU的渲染过程是一个并行过程,模型数据传入后,在顶点着色器中,顶点之间属于并列关系,同一时间有多个顶点同时执行顶点着色器的逻辑。可以想象成一个军队,每个士兵拿着自己的武器在战场上做着同样的事情。但是这些数据有一些共性,它们同属于一个模型、引用同一张纹理贴图、受同一个光照影响。但是我们不可能为每一个顶点配置一份相同的数据。因此把这些数据抽象出来,形成一个公共部分,所有顶点都可以共享这些数据。在片段着色器中也类似,每个像素也都可以对同一张纹理进行采样。公共数据有很多,除了前面提到的,还有各种空间矩阵、以及一些自定义需求引入的数据。庆幸的是,大部分公共数据Unity都已经为我们定义好了,并且在程序执行时,会对其自动赋值。只有少数我们自己定义的公共数据需要我们初始化。

定义公共数据也很简单,直接向当前着色器代码中定义变量,不过这些变量必须定义在函数体外部。如下:

1
2
3
4
5
6
//材质所用的纹理、以及缩放偏移量
sampler2D _MainTex;
float4 _MainTex_ST;

//材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
fixed4 _Color;

只要定义了这些公共变量,那么就可以在C#程序中使用Material.Set[Type]接口来对其进行赋值。很多使用我们希望直接在材质面板上设置这些量,这时候只需要将需要暴露在材质面板的变量,在Properties块中重新声明一下,格式为_Variable("材质面板上显示的名称", Type) = DefaultValue。材质面板的显示也可以自定义,功能复杂点的需要重写编辑器脚本,简单点的也可以直接在着色器脚本中实现,只需要在Properties块中的变量前增加相应的显示设置。一般的来说,Properties块中的变量和公共变量是一对一的关系,但是纹理比较特殊,因为纹理数据比较复杂,除了纹理本身的数据外,还有纹理的缩放、偏移等参数。这时候公共变量中的纹理除了要声明纹理本身外,还要声明这些缩放、偏移参数。和纹理相关的参数的命名有一个规则,必须是纹理名称加相关参数的缩写符。如这里的缩放、偏移参数的缩写符就是_STS表示缩放,T表示偏移。例如下面例子中的Properteis块就和上面的公共变量相对应。

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

Spaces?

在着色器中,我们提到位置坐标,就一定会涉及模型、世界、观察、屏幕、裁剪坐标系。这时候,我们说的坐标,必须联系使用场景,来判断当前坐标是处以哪个坐标系。抛开坐标系谈坐标就是无根之木、无水之源。

模型空间坐标系,是以模型为中心,以模型自身为参考的坐标系。(0,0,0)在模型坐标系中表示的是模型的原点。如果我们旋转模型,那么模型坐标系也会跟着旋转,换句话说,我们对模型的空间操作,实际上是对模型坐标系的空间操作。我们的模型文件中存储的顶点坐标实际上就是模型空间坐标系的。在渲染时,传入顶点着色器的顶点坐标也是模型空间坐标系上的坐标。

世界空间坐标系,是一个绝对空间坐标系,有一个固定的参考点,不会因为某个局部影响而改变。世界空间坐标系也是所有模型的空间纽带。现实中我们描述我们的位置,大概率使用的就是世界坐标系。

观察坐标系是以摄像机为参考的坐标系。裁剪坐标系是在观察坐标系的基础上,经过投影变换后的坐标系。如果我们使用的是透视投影,那么模型在摄像机上的投影将会产生近大远小的效果。屏幕坐标系是在裁剪坐标系的基础上,进一步除处理得到的,其中需要经过栅格化处理、视口变换等,这一系列操作就是方便后面的渲染。而在片段着色器上处理的便是屏幕坐标系下的数据,因此我们很多时候可以忽略掉观察坐标系、和裁剪坐标系,同时,Unity也提供了很多工具函数来处理坐标系变换。

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
Shader "Tutorial/001-004_Basic_Unlit"{
//这些值将会显示在材质面板上
Properties{
_Color ("Tint", Color) = (0, 0, 0, 1)
_MainTex ("Texture", 2D) = "white" {}
}

SubShader{
//当前的标签设置表示的是:不透明渲染,和其他不透明物体处以同一渲染队列
Tags{ "RenderType"="Opaque" "Queue"="Geometry" }

Pass{
CGPROGRAM

//这里包括一些工具函数、以及内置变量
#include "UnityCG.cginc"

//定义顶点、片段着色函数
#pragma vertex vert
#pragma fragment frag

//材质所用的纹理、以及缩放偏移量
sampler2D _MainTex;
float4 _MainTex_ST;

//材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
fixed4 _Color;

//模型网格、UI等输入数据,基本代表了模型内在属性
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);
//基于图片的缩放偏移参数,对UV进行变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//基于UV坐标,进行纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//将纹理颜色和材质颜色相乘
col *= _Color;
//返回最终的像素点颜色
return col;
}

ENDCG
}
}
Fallback "VertexLit"
}

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

原文:
HLSL

HLSL?

HLSL是Unity着色器中所使用的一种语言,可以通过HLSL来实现渲染逻辑。HLSL语言是微软设计的、面向D3D的GPU编程语言。严格的说,网络上现有的大多数Unity着色器脚本是通过CG语言编写的,CG是C for Graphics的简写,也就是为图形编辑而设计的C语言。但是CG语法和HLSL有很多共性,同时CG语言在2012年便不再维护,而我们很容易把两者混淆,不过这并不影响我们进一步的学习。理论上,Unity也支持GLSL语言,GLSL是为OpenGL设计的编程语言,也是类C的编程语言。由于HLSL的例子在网络上随处可见,同时Unity会根据平台不同,自动会将着色器脚本翻译为对应的语言,所以我们不必纠结使用哪种语言。为了方便,这里建议直接使用HLSL语言。

因为着色器编程难度较大,所以建议初学者在系统学习过基本的编程,再考虑学习着色器编程。因为着色器脚本是在GPU中运行的,而一般的编辑软件是在CPU中运行,所以在着色器编程过程中,很难进行异常分析。因此这也导致我们开发过程中有很多限制,另外,和普通的软件开发相比,我们需要从不同的角度来思考问题。如果你已经具备了基本的编程素养,知道什么是数据类型、类、函数、循环、条件语句等,也大概知道CPU和GPU的区别、串行与并行的区别,那么欢迎你阅读下面的内容。

Builting Types

首先,我们需要知道在着色器编写过程中,有哪些可以使用的内置数据类型。

Scalar Values

标量。在Unity的hlsl语言中,小数的类型有fixedhalf、以及float,整形的类型有intuint。需要指出的是,在最新的URP渲染管线中,小数的类型只支持half、和float

在移动端的GPU中,fixed的数据范围为[-2, 2],其精度为1/256。而halffloat分别是16位和32位的浮点数。在PC端的GPU中,这三类都是32位浮点数。所以在后面的内容中,你将会看到我基本上都是使用float来表示小数,当然,后面有工具可以对此进行优化。

整形,也就是我们所知的整数,其中int可以是正数也可以是负数,而uint只能是正数,这两者之间合理的选择,也能达到细微的优化作用。

另外,还支持bool类型,布尔类型数据用于表示是与否的两种状态。如果我们强行将布尔和数值进行加减乘除运算时,那么实际的布尔值的是就变成1、而否就变成0参与计算。

Vector Values

向量。向量在空间上表示的是一个方向,由各个维度的投影分量构成。上提到的标量,从广义上来说是一维向量。向量在HLSL中的表示很简单,只要在上面对应标量的后面加上维度。例如上面的fixedhalffloat的四维向量分别是fixed4half4float4。向量的使用很常见,例如记录纹理坐标、颜色、位置等信息。

当然,我们也可以访问向量中特定维度的分量。以4维向量v为例,向量数据是一个长度为4的数组,起始索引为0。也就是说v[0]实际上就是访问的第一个维度的值。另外,向量在着色器中主要用于表示空间、和颜色。所以为了方便,可以直接通过维度名称来索引,例如获取x轴的分量可以表示为v.x,获取红色通道的值可以表示为v.r。这些维度、通道的数据就是存储在前面所说的数组中,其顺序为xyzw、和rgba。换句话说,v[0]、v.x、v.r访问的是同一个值。

另外,在实际使用中,我们可能经常遇到需要从一个向量中选取部分值,来重新构成一个新的向量。因此HLSL在语言设计时就引入了这种通过维度、或通道混合的方式来实现向量重构。举个例子:

  • v.xy : 选取原向量中的前两个维度,构成一个新的二维向量;
  • v.zyx : 选取原向量中的前三个维度,并且调换顺序,构成一个新的三维向量;
  • v.xxxx: 选取原向量中的第一个维度,构成一个四维向量,新构成的四维向量的各个维度的值都等于原向量中的第一个维度的值;

Matrix Values

矩阵。如果说前面的向量是在标量的基础上,朝着一个方向扩展。那么矩阵就是朝着两个方向扩展,分别是横向r、和纵向c。因此矩阵的数据结构是一个二维数组,与标量对应的表示有fixedrxchalfrxcfloatrxc,其中的r和c表示的是行数和列数。例如float4x4half3x2bool2x4。和向量类型,矩阵也可以使用二维数组的方式进行访问,例如matrix[3][2],访问的是矩阵中第3行、第2列的元素,注意,这里的行号和列号都是从0开始的。除了二维数组的访问方式,还可以使用元素名称进行访问,例如_m32是第3行、第2列的名称。还可以使用名称混合的方式,实现向量重构。例如matrix._m03_m13_m23,是选取矩阵最后一列的前三个元素构成一个三维向量。如果我们采用二维数组的访问方式,但是只传入一个索引号,那么这个索引号表示的是行号,得到的是该行的向量。例如matrix[0]表示的是获取第0行的向量。

矩阵处理非常繁琐,庆幸的是我们很少会需要去单独处理其中的元素,因此可以直接调用现有的辅助函数来实现矩阵运算。

Textures

HLSL同时也定义了纹理类型。纹理类型比较特殊,这里不做过多讲述,只需要知道,我们可以通过tex2D(texture, coordinate)来对纹理进行采样。当然,在后面的内容我们会了解跟多,I Promise!

Math

在数学计算方面,HLSL提供基本的操作方法,例如+-*/,可以执行基本的数值计算,而<>==!=!>=<=&&||可以用于条件比较。除此之外,HLSL还集成了像absdotlerppowminatan2等常用函数,详情可参考这里

同时,HLSL也提供一些快捷操作符,例如+=*=-=/=,这些在将操作符左右两边的数据除了后,复值给左边的数据。还有++--可以用于自增、自减一个单位。

需要注意的是,标量和向量的乘法,是标量乘以向量中的每一个元素,然后生成一个同维度的向量。例如float2(2,7) * 3 == float2(6, 21)

而矩阵与向量的乘积相对复杂点,在矩阵分析中有介绍,不过直观的理解就是矩阵表示空间坐标系之间的关系,而向量表示空间坐标系中的点、或方向,两者乘积的结果表示向量从一个空间变换到另一个空间。在应用过程中,我们并不太关系该操作的具体实现。初学者可以基于这种直观理解,然后参考模仿现有的应用案例,或者直接从中复制过来,久而久之就知道怎么用了。矩阵向量的乘积使用的函数是mul,具体可以参考这里

Custom Types

除了内置类型,我们还可以添加一些自定义类型。自定义类型的语法和类C语言的结构体很像,但是需要注意的是,必须在自定义类型后面加上分号;。如下所示:

1
2
3
4
struct typeName{
float variable;
float2 otherVariable;
};

理论上来说,我们也可以使用class关键字、继承、成员函数、甚至是接口类型。但是,目前为止,我只看到使用struct的情况,所以我们依照祖传习惯就好。如果你想做第一个吃螃蟹的人,那么你可以试着将这些类C的语法应用上去,能不能正常使用,就全看天意了:->!

和向量类似,如果我们想访问自定义结构中的成员变量,同样是采用.连接符。例如,instance.variable,或者可以访问成员的成员,如,instance.otherVariable.x。

Variables

变量。在HLSL中,所有的数据都是值类型,这意味着所有操作,都是直接作用在变量上,而不是变量的引用。同时,我们也不需要使用new之类的关键字来创建变量。

当我们希望创建一个向量时,只需要把向量类型当做普通函数调用就可以。例如创建四维向量float4()float4(1,1,1,1)。在这种情况下,创建向量的所有参数的维度总和,必须等于目标向量的维度。例如创建一个四维向量,可以传入四个标量,也可以传入两个二维向量,还可以传入两个标量加一个二维向量,或者直接传入一个四维向量。如下,以上面的自定义结构为例:

1
2
3
typeName instance;
instance.variable = 3.14;
instance.otherVariable = float2(3, 1.4);

另外,变量可以声明在函数内、和函数外。如果定义在函数内,那么能在函数内可以使用,并且使用的位置必须在定义之后。如果定义在函数外面,那么所有的函数都可以使用这个变量,不受顺序的影响,但是习惯上我们会将变量统一定义在函数之前。

Functions

在HLSL中,大多是函数都是全局函数。这意味着它们不属于任何数据结构,并且我们可以在任何位置调用它们。这些函数可以传入一个或多个参数,还可以返回计算结果。如果你不希望函数返回任何值,那么你可以在前面声明为viod。如下是一个函数范例:

1
2
3
4
5
returnType functionName(argType arg1, otherArgType arg2){
//在这里实现函数功能逻辑

return returnValue;
}

当我们调用函数的时候,只需要写下函数名,以及后面跟的括号。如果函数接受传参的话,直接将所需参数以逗号分隔,依次写在括号内。这里的函数支持重载,函数和其参数共同构成了该函数的唯一标识,所以我们可以定义多个同名、但不同参的函数体。在调用时,程序会自动根据传入参数来判断所调用的函数。

Control Flow

对于大多是着色器,我们只需要一行接一行的执行相关逻辑,就可以实现我们想要的功能。但是有些复杂点的需求,我们可能需要重复执行某些命令,或者需要丢弃某些命令,这就涉及到代码执行路径选择的问题。在程序上这叫分支语句、或者叫条件语句,还有循环语句。很多人认为,使用分支语句会影响着色器的执行效率,尤其是移动端,所以应该使用step来代替分支语句。这中说法显然不对,因为在step这类函数中本身就是基于分支语句实现的。而且使用step这些函数会使我们程序在逻辑上变得复杂,不利于阅读。当然,上面关于执行效率的说法并不是完全没有依据,假如我们使用分支语句,刚好GPU会执行所有路径,然后丢弃其中一条路径的计算结果,这时候确实存着资源浪费。但是这并不能通过其他技术来避免,毕竟有些逻辑本身就存在分支,所以不要下意识的排斥使用条件语句,而应该多考虑代码的整体结构,是否美观、是否易于阅读。

if statements

如下所示的if语句,如果条件为true,那么执行上半部分的逻辑,否则,执行下半部分的逻辑。

1
2
3
4
5
if(condition){
//条件为true,执行这里
} else {
//条件为false,执行这里
}

上面的else{...}部分是可选的。花括号中的逻辑相当于一个整体。如果我们不使用花括号,那么上面的条件语句只会将其后的一行逻辑代码当成它的分支。上面的condition值可以是bool、或int,也可以是一条语句,但是这条语句的结果必须是前面两者之一。另外,!可以用于逻辑取反,假设我们希望条件为false时执行if后的分支,那么只需要在条件之前加!

Loops

除了分支语句,还有另外一种常见的控制流-循环语句。While循环是最简单的循环语句,如果条件为ture时,它将会一直循环下去,直到条件为false为止。如下:

1
2
3
while(condition){
//执行循环逻辑
}

有一点要记住,在while循环语句中,必须要有某一段可执行代码来将条件置为false,如果没有这种代码,那么该循环将会一直执行下去,这是非常严重的问题。

另一种循环叫做for循环,在for循环中可以假如控制循环次数的变量。如下所示:

1
2
3
for(beforeLoopLogic; condition; inLoopLogic){
//执行循环逻辑
}

for循环下循环次数控制方便面,显得更加简单明了。比如从0开始到最大次数:

1
2
3
for(uint index=0;index<maxValue;index++){
//执行循环逻辑
}

当然,我们也可以使while循环来实现同样的功能,但是看起来就没有for循环那么整洁了:

1
2
3
4
5
6
uint index = 0;
while(index < maxValue){
//执行循环逻辑

index++;
}

所有的循环语句中都支持breakcontinue关键字。其中break用于中断循环语句,直接跳出循环。而continue是跳过本次循环,但是会继续执行后续的循环。

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

原文:
Structure

Shader Structure

着色器编程难度较大,在学习初期阶段,我们首先需要学习它的基本结构,以便于后面灵活的修改、应用它们。

现代着色器采用的是可变渲染管线,其中顶点着色器、和片段着色器是其基本组成。除此之外,还有可选部分,几何着色器、曲面细分着色器,但是它们的应用场景比较少。顶点着色器的作用是将模型网格,通过矩阵变换,投影到屏幕(实际是投影到裁剪空间)。同时,顶点着色器的一个非常有用的操作是,执行顶点动画。当顶点坐标变换到屏幕空间后,其所构成的三角面片将被栅格化。为了保证三角面片能够正确显示,从顶点着色器到片段着色器的过程中,需要对顶点进行插值,从而得到三角面片中各个片段的位置、颜色值等。

上面简单介绍了着色器的基本流程。接下来我将阐述如何编写着色器、这些“空间坐标系”的具体含义、不同着色器之间的数据传递。但是,我相信了解其基本流程,有助于我们对着色器不同阶段之间的关系有一个直观的理解。因为,在大多数着色器语言中,基本采用了这种基本流程。即便是那些更为高级的、可以通过节点拼接的着色器语言,最终也是将其翻译为这种基本流程。

ShaderLab

在Unity中,着色器实际上就是一个以.shader结尾的文本文件。我们可以在资源面板下,依次选择Create > Shader >中的着色器模板,当然模板中的内容可能并不是我们想要的,不过没关系,下面我将介绍如何自定义着色器。为了易于上手,这里我以模板着色器Create > Shader > Unlit为参考,编写我们自己的着色器。当然这里我写的和Unlit之间最大的区别在于,我们这个是不会处理雾效,同时又增加了一个颜色属性,这样可以从整体对模型颜色进行调整。接下来我也会一步一步的讲解其实现逻辑。本教程目标是着色器小白,所以你如果有哪里不理解的地方,可以告诉我,促使我对其进行调整,以便于后续学习者能够更加顺畅。

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

SubShader{
//当前的标签设置表示的是:不透明渲染,和其他不透明物体处以同一渲染队列
Tags{ "RenderType"="Opaque" "Queue"="Geometry" }

Pass{
CGPROGRAM

//这里包括一些工具函数、以及内置变量
#include "UnityCG.cginc"

//定义顶点、片段着色函数
#pragma vertex vert
#pragma fragment frag

//材质所用的纹理、以及缩放偏移量
sampler2D _MainTex;
float4 _MainTex_ST;

//材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
fixed4 _Color;

//模型网格、UI等输入数据,基本代表了模型内在属性
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);
//基于图片的缩放偏移参数,对UV进行变换
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}

//片段作色器,主要是计算像素点的颜色
fixed4 frag(v2f i) : SV_TARGET{
//基于UV坐标,进行纹理采样
fixed4 col = tex2D(_MainTex, i.uv);
//将纹理颜色和材质颜色相乘
col *= _Color;
//返回最终的像素点颜色
return col;
}

ENDCG
}
}
Fallback "VertexLit"
}

Whats ShaderLab?

Shaderlab是Unity内置的着色器语言,其中定义了绝大多数渲染模型所需的数据。但是着色器执行渲染逻辑的部分,实际上是采用hlslglslCG这三种着色语言中的一种。这里hlsl是微软开发的底层着色器语言,glsl是英伟达开发的底层着色器语言,CG是更为高级的作色器语言。而这些执行部分在Shaderlab中占有一个独立的区域。具体一点,Shaderlab是在执行hlslglslCG的基础上,扩展了一些属于自身的语法,其中包括Properties属性块,用来关联外部输入参数。

从上图可以看到,实际ShaderLab扩展的仅占着色器很小区域。其中一部分原因是:Shaderlab不是可执行语言,而是一种抽象的描述性语言,定义了着色器有哪些输入参数、需要执行哪些渲染操作。而Unity便会识别这些描述性语言,然后将其翻译为GPU可执行的作色器语言,同时关联渲染所需的数据。对于一些简单的渲染需求,参考上图的例子,然后对CG部分进行简单的调整就行了。

Shader/SubShader/Pass

在上图中,你可能也发现了,其中有很多个{}花括号,将程序分为很多个块。下面我们来看看,这些块到底是干嘛的。

首先,最外层的Shader块,代表了整个作色器。在我们创建材质球后,需要在材质球面板上选择所需的着色器,从而得到我们所需的材质球。那些在材质面板上的着色器名称,实际上就是紧跟在Shader块后面的字符串。在这个字符串中,我们可以使用/反斜杠来对着色器进行有效的组织分类,这很像我们文件目录的组织形式。在本教程中,我将所有的着色器划分到Tutorial这个大类中,其中还会细分出一些小类。当然,这些分类可以根据需要随意改动,只要达到有效组织的目的就行。
可以看到,所谓的着色器,实际上也就是一连串描述的文本文件。需要注意的是,一个文本文件,只能定义一个着色器。但是,一个着色器却可以复用另一个着色器的功能。例如,在Shader块中,也就是最外层花括号中,我们可以定义fallback shader,当Unity将其翻译为更底层的着色器代码时,会将fallback shader中的SubShader块复制过来。

Shader块中,可以定义多个SubShader块。在模型渲染时,只会从中选择一个SubShader来执行,而具体选择哪个,依赖于实际运行的平台。可惜的是,关于如何定义SubShader的说明文档极其匮乏,根据我多年的经验,在很多情况下,一个Shader块中只定义一个SubShader能满足基本需求,减少很多不必要的麻烦。凡事皆有特例,当我们想实现阴影效果时,需要在当前SubShader块中实现相应的ShadowPass,每次都实现一遍很麻烦。因为阴影着色流程基本固定,所以Unity提供的现成的便可以使用,这时候,我们可以使用包含阴影着色逻辑的fallback shader,当渲染时,从该SubShader中未找到可以使用的ShadowPadd时,便会从fallback shader中去查找。大多数情况下,我们使用VertexLit着色器,来作为我们的fallback shader,因为VertexLit中的逻辑简单、性能消耗低、基本上能够兼容所有的显卡,也可能是大家相互Copy,从而形成VertexLit流行的假象:-)。另外,在SubShader块中,我们可以定义Subshader tags;还可以定义多个Pass,例如前面说的Shadow pass;以及属性,在SubShader块中定义的属性是由所有Pass共享的。

一个Pass包含一套完整的渲染流程,从底层着色语言的角度来看,一个Pass才是实际上的着色器,它将模型数据转换为五彩斑斓的画面。在内置渲染管线中,如果我们在SubShader块中定义了多个Pass,当该SubShader被平台选定时,其中的Pass将会被一个接着一个的执行(而最新的URP渲染管线,目前仅支持单个光照Pass)。对于具有多个PassSubShader,我们可以将其公共属性等数据定义在SubShader中,而Pass中定义一些独有的数据或逻辑。

Properties and Tags

你们可能注意到,在上图中还有两个块PropertiesTags未被提及,那我们继续吧。

在很多编程语言中有字典的概念,顾名思义,就是类似汉语字典一样,可以通过拼音、笔画等关键信息进行快速检索。这里的Tags就可以类比到字典,在Tags中可以定义多个关键字,以及关键字所对应的值,它们公共构成了着色器的配置参数。其中关键字表示了参数的类型,值表示了参数的实际设置。在SubShader中,可以通过Tags定义着色器的材质表现、渲染顺序、以及其他操作;而Pass中的Tags主要定义了光照模式。详细的说明可以参考SubShader TagsPass Tags

Properties属性块,主要用来定义材质面板中的属性显示。通过材质面板来调整材质参数具有一定的局限性,因为整个调整是以材质球为单位,也就是说,如果多个模型使用同一个材质球,那么就无法做到材质差异化。这时候需要创建多个材质球,分别对应于不同的模型。在接下来得教程中我也会详细讨论Properties的使用。

下面我们对Shaderlab的基本结构进行一个总结:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Shader "Category/Name"{
Properties{
//用于材质面板显示、与配置的属性
}
Subshader{
Tags{
//一些公共配置,涉及渲染类型、渲染顺序等设置
}

//公共设置、属性、方法可以写在Subshader中

Pass{
Tags{
//主要是光照模式的配置
}

//单个Pass的设置, 例如剔除、模板等

CGPROGRAM
//实际执行的渲染程序、以及所用到的属性参数
ENDCG
}
}
}

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
Shader "Tutorial/001-004_Basic_Unlit"{
//这些值将会显示在材质面板上
Properties{
// _Color ("Tint", Color) = (0, 0, 0, 1)
// _MainTex ("Texture", 2D) = "white" {}
}

SubShader{
//当前的标签设置表示的是:不透明渲染,和其他不透明物体处以同一渲染队列
Tags{ "RenderType"="Opaque" "Queue"="Geometry" }

Pass{
CGPROGRAM
//
// //这里包括一些工具函数、以及内置变量
// #include "UnityCG.cginc"
//
// //定义顶点、片段着色函数
// #pragma vertex vert
// #pragma fragment frag
//
// //材质所用的纹理、以及缩放偏移量
// sampler2D _MainTex;
// float4 _MainTex_ST;
//
// //材质的颜色,具体一点是:当纹理为白色图片时,材质的颜色
// fixed4 _Color;
//
// //模型网格、UI等输入数据,基本代表了模型内在属性
// 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);
// //基于图片的缩放偏移参数,对UV进行变换
// o.uv = TRANSFORM_TEX(v.uv, _MainTex);
// return o;
// }
//
// //片段作色器,主要是计算像素点的颜色
// fixed4 frag(v2f i) : SV_TARGET{
// //基于UV坐标,进行纹理采样
// fixed4 col = tex2D(_MainTex, i.uv);
// //将纹理颜色和材质颜色相乘
// col *= _Color;
// //返回最终的像素点颜色
// return col;
//
// }
ENDCG
}
}
Fallback "VertexLit"
}

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

原文:
Compute Shader

至此,我们已经学会了如何使用固定管线来渲染纹理,但是目前的显卡能做的远远不止这些。除了使用固定管线,我们还可以使用compute shader来实现。

你可能会问,为什么要用compute shader,目前的cpu其实已经很强大了,即便遇到大量的处理数据,我们也可以使用多线程来处理。是的,对于一些非图形任务,我们并不需要使用GPU。如果强行使用GPU来处理,很可能会产生各种未知错误,另外也无法使用常用的异常分析手段。优化就更麻烦了,因为需要考虑CPU与GPU之间的数据传输,而GPU并行处理也和CPU编程思路不一样。作为初学者,我们是否需要使用compute shader,首先要明白为什么要用compute shader,使用后是否能够达到比现有方法更好的效果。

如果你觉得你需要使用compute shader,那么继续读下去。在Unity中,你可以使用SystemInfo.supportsComputeShaders方法来查看你的GPU是否支持compute shader。

Basic Compute Shader

我们可以通过选中Create>Shader>Compute Shader来创建compute shader。默认创建的compute shader执行向图片中写入数据的操作。但是在本节,我将演示一个更简单的例子——写入一组坐标。

在compute shader中,我们可以向RWStructuredBuffer中写入一组数据,而StructuredBuffer是只读数据。在它们后面补充数据单元类型,类型包括vector或者struct。在本节中,我们使用float3

我们把用于计算的方法块叫做kernel。我们必须在方法块前面添加numthreads标志符,同时方法块有一个输入参数,因为方法块是针对每一个元素进行并行计算的,所以需要一个索引值来表明当前方法块所对应的元素。在这里,我们定义x轴64线程,y、z轴都是1线程。只要支持compute shader的显卡,基本可以处理这种线程设置。因为整个线程设置是一维的,所以我们处理的数据也是一维的,而在并行处理中,我们只需要考虑单个元素的处理过程。因为我们的线程设置是基于三个维度,所以我们输入参数也是三个维度,参数中的值对应相应维度的线程。因为我们这里的数据都集中在x轴上,所以我们的参数也只需要考虑x轴的索引。和普通的Shader一样,我们需要为输入参数添加标志符,方便程序识别参数的语意,这里我们参数的标志符是SV_DispatchThreadID

为了区分compute shader中的普通方法块和核方法块,我们需要使用pragma标识符,语法为#pragma kernel <functionname>。当然,在一个compute shader中可以拥有多个核函数。例如:

1
2
3
4
5
6
7
8
9
10
11
12
// 指定一个核函数,我们可以拥有多个核函数
#pragma kernel Spheres

// 输出
RWStructuredBuffer<float3> Result;


[numthreads(64, 1, 1)]
void Spheres(uint3 id : SV_DispatchThreadID)
{
// compute shader 代码
}

首先,让我们输出坐标(id, 0, 0)Result中:

1
2
3
4
5
[numthreads(64, 1, 1)]
void Spheres(uint3 id : SV_DispathcThreadID)
{
Result[id.x] = float3(id.x, 0, 0);
}

Executing Compute Shaders

和普通的Shader不同的是,compute shader并不是通过材质绑定的方式来执行,而是通过C#脚本来实现调用。

我们可以创建一个GameObject,并且在它上面创建一个C#脚本,在脚本上创建一个ComputeShader变量来引用前面创建的compute shader。同时我们创建一个整形变量,用来存储核函数的签名,这个签名是核函数在GPU中的索引ID。首先在Start函数中我们调用FindKernel(<kernelname>)来获取核函数的签名。在得到核函数签名后,我们可以使用该签名来获取该核函数的线程设置,也就是在compute shader中的[numthreads(64, 1, 1)]。我们只提取x轴的线程数,其他两个维度可以用_来表示忽略。

另外,我们在C#脚本中创建一个长度变量,用来指定compute shader中buffer的长度。知道buffer的长度,以及buffer中存储的数据类型float3,我们可以向GPU申请一块存储空间——ComputeBuffer。这个空间将会用来存储计算结果,也就是compute shader中的Result。创建ComputeBuffer需要两个参数,第一参数是元素的个数,第二个参数是元素的大小。我们的元素是float3,也就是大小为3个float。另外,我们需要在CPU中申请一块和ComputeBuffer同样大小的数组空间,以便于将计算后的结果转移到CPU,方便后续计算使用。在计算结束后,我们可以通过ComputeBuffer.Dispose方法来释放GPU申请的缓存空间。

当一切设置好后,我们可以在Update中使用compute shader。首先,我们需要将我们在GPU创建的ComputeBuffer和compute shader中的buffer相关联。在调用compute shader之前,我们还需要计算整个核函数需要执行多少遍,这个叫做线程组。因为核函数单次批处理的数量有限,所以需要分为多组,分批次处理。例如这里我们单次x轴处理量为64,而总的需要处理的数量为buffer长度,那么线程组的个数为后者处以前者。然后通过dispatch方法来启用核函数,最终计算结果通过GetData函数传回到CPU中。

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
public class BasicComputeSpheres : MonoBehaviour
{
public int SphereAmount = 17;
public ComputeShader Shader;

ComputeBuffer resultBuffer;
int kernel;
uint threadGroupSize;
Vector3[] output;

void Start()
{
// 获取核函数签名
kernel = Shader.FindKernel("Spheres");
// 获取核函数线程设置
Shader.GetKernelThreadGroupSizes(kernel, out threadGroupSize, out _, out _);

//buffer on the gpu in the ram
resultBuffer = new ComputeBuffer(SphereAmount, sizeof(float) * 3);
output = new Vector3[SphereAmount];
}

void Update()
{
//绑定数据
Shader.SetBuffer(kernel, "Result", resultBuffer);
int threadGroups = (int) ((SphereAmount + (threadGroupSize - 1)) / threadGroupSize);
Shader.Dispatch(kernel, threadGroups, 1, 1);
resultBuffer.GetData(output);
}

void OnDestroy()
{
resultBuffer.Dispose();
}
}

现在我们有了计算结果,但是并不能直观的去观察这些结果。有很多种方法可以直接在GPU中处理并显示这些结果,但是这并不是本文的重点。所以我选择使用生成一系列模型空间分布,来展示最终生成的结果。
Update中直接将计算的坐标赋值给游戏物体空间坐标。

1
2
3
4
5
6
7
8
// in start method

//spheres we use for visualisation
instances = new Transform[SphereAmount];
for (int i = 0; i < SphereAmount; i++)
{
instances[i] = Instantiate(Prefab, transform).transform;
}
1
2
3
//in update method
for (int i = 0; i < instances.Length; i++)
instances[i].localPosition = output[i];

A tiny bit more complex Compute Shader

为了达到一个更好的视觉效果,请继续阅读,别担心这里涉及到的也只是基本的hlsl语法。

在compute shader中我加入了randomness教程中关于噪声的函数,同时加入时间变量。在核函数中,我基于输入参数来构造一个长度为[0.1-1]的随机向量。然后采用叉乘的方法计算出与这些随机向量垂直的向量。然后使用时间变量的平方,加上一个比较大的奇数,得到一个关于时间的sin值和cos,最后将这两个值和两个随机向量相乘,并求和。在这个基础上乘以20,使得记过看起来更明显。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel Spheres

#include "Random.cginc"

//variables
RWStructuredBuffer<float3> Result;
uniform float Time;

[numthreads(64,1,1)]
void Spheres (uint3 id : SV_DispatchThreadID)
{
//generate 2 orthogonal vectors
float3 baseDir = normalize(rand1dTo3d(id.x) - 0.5) * (rand1dTo1d(id.x)*0.9+0.1);
float3 orthogonal = normalize(cross(baseDir, rand1dTo3d(id.x + 7.1393) - 0.5)) * (rand1dTo1d(id.x+3.7443)*0.9+0.1);
//scale the time and give it a random offset
float scaledTime = Time * 2 + rand1dTo1d(id.x) * 712.131234;
//calculate a vector based on vectors
float3 dir = baseDir * sin(scaledTime) + orthogonal * cos(scaledTime);
Result[id.x] = dir * 20;
}

当然,我们很需要在C#脚本中向compute shader中传入时间变量。

1
Shader.SetFloat("Time", Time.time);

然后使用自发光材质,以及泛光后处理技术,最后呈现出下面绚烂的效果。

源码

https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/050_Compute_Shader/BasicCompute.compute

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Each #kernel tells which function to compile; you can have many kernels
#pragma kernel Spheres

#include "Random.cginc"

//variables
RWStructuredBuffer<float3> Result;
uniform float Time;

[numthreads(64,1,1)]
void Spheres (uint3 id : SV_DispatchThreadID)
{
//generate 2 orthogonal vectors
float3 baseDir = normalize(rand1dTo3d(id.x) - 0.5) * (rand1dTo1d(id.x)*0.9+0.1);
float3 orthogonal = normalize(cross(baseDir, rand1dTo3d(id.x + 7.1393) - 0.5)) * (rand1dTo1d(id.x+3.7443)*0.9+0.1);
//scale the time and give it a random offset
float scaledTime = Time * 2 + rand1dTo1d(id.x) * 712.131234;
//calculate a vector based on vectors
float3 dir = baseDir * sin(scaledTime) + orthogonal * cos(scaledTime);
Result[id.x] = dir * 20;
}

https://github.com/ronja-tutorials/ShaderTutorials/blob/master/Assets/050_Compute_Shader/BasicComputeSpheres.cs

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
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class BasicComputeSpheres : MonoBehaviour
{
public int SphereAmount = 17;
public ComputeShader Shader;

public GameObject Prefab;

ComputeBuffer resultBuffer;
int kernel;
uint threadGroupSize;
Vector3[] output;

Transform[] instances;

void Start()
{
//program we're executing
kernel = Shader.FindKernel("Spheres");
Shader.GetKernelThreadGroupSizes(kernel, out threadGroupSize, out _, out _);

//buffer on the gpu in the ram
resultBuffer = new ComputeBuffer(SphereAmount, sizeof(float) * 3);
output = new Vector3[SphereAmount];

//spheres we use for visualisation
instances = new Transform[SphereAmount];
for (int i = 0; i < SphereAmount; i++)
{
instances[i] = Instantiate(Prefab, transform).transform;
}
}

void Update()
{
Shader.SetFloat("Time", Time.time);
Shader.SetBuffer(kernel, "Result", resultBuffer);
int threadGroups = (int) ((SphereAmount + (threadGroupSize - 1)) / threadGroupSize);
Shader.Dispatch(kernel, threadGroups, 1, 1);
resultBuffer.GetData(output);

for (int i = 0; i < instances.Length; i++)
instances[i].localPosition = output[i];
}

void OnDestroy()
{
resultBuffer.Dispose();
}
}

希望你能喜欢这个教程 :-)

Basic

getting-started-with-centos
Server World
How To Create a Sudo User on CentOS
How to Install MongoDB on CentOS 7
uninstaall mongodb from centos7
Install MongoDB Community Edition on Red Hat or CentOS

MongoDB

MongoDB Tutorial
Lessons Learned from Building a Game with MongoDB and Unity
Unity
Getting Started with the Realm SDK for Unity

Node

How To Install Node.js on a CentOS 7 server:注意,这里的源码安装方式,需要先编译,再安装,一定要确保下载的源码,比如这个地址:https://registry.npmmirror.com/-/binary/node/v16.19.1/node-v16.19.1.tar.gz
How to install GNU GCC 8 on CentOS 7
sudo yum remove –skip-broken gcc
sudo yum install –skip-broken gcc
虚拟机搭建web服务器