你好 我想分享一下我在Unity中编写着色器的经验。 让我们从2D中的“位移/折射”着色器开始,考虑用于编写它的功能(GrabPass,PerRendererData),并注意可能出现的问题。
该信息对于那些对着色器有大致了解并尝试创建它们的人很有用,但是他们不熟悉Unity提供的功能,并且不知道该使用哪一侧。 看一下,也许我的经验可以帮助您解决。

这是我们要实现的结果。

准备工作
首先,创建一个着色器,将简单地绘制指定的精灵。 他将成为我们进一步操纵的基础。 将会添加一些内容,相反会删除一些内容。 它与标准“ Sprites-Default”不同,因为缺少一些不会影响结果的标签和操作。
用于渲染精灵的着色器代码Shader "Displacement/Displacement_Wave" { Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color" , Color) = (1,1,1,1) } SubShader { Tags { "RenderType" = "Transparent" "Queue" = "Transparent" } Cull Off Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; float4 color : COLOR; }; fixed4 _Color; sampler2D _MainTex; v2f vert (appdata v) { v2f o; o.uv = v.uv; o.color = v.color; o.vertex = UnityObjectToClipPos(v.vertex); return o; } fixed4 frag (v2f i) : SV_Target { fixed4 texColor = tex2D(_MainTex, i.uv)*i.color; return texColor; } ENDCG } } }
精灵显示背景实际上是透明的,故意变暗了。

产生的工件。

抢通
现在,我们的任务是更改屏幕上的当前图像,为此,我们需要获取图像。
GrabPass段落将帮助我们解决这一问题。 此段落将以
_GrabTexture纹理捕获屏幕图像。 纹理将仅包含在使用此着色器的对象进行渲染之前绘制的内容。
除了纹理本身,我们还需要扫描的坐标才能从中获取像素颜色。 为此,向片段着色器数据添加其他纹理坐标。 这些坐标未进行归一化(值不在0到1的范围内),并且描述了点在相机空间(投影)中的位置。
struct v2f { float4 vertex : SV_POSITION; float2 uv : float4 color : COLOR; float4 grabPos : TEXCOORD1; };
并在顶点着色器中填充它们。
o.grabPos = ComputeGrabScreenPos (o.vertex);
为了从
_GrabTexture获取颜色,如果我们使用非规范化的坐标,则可以使用以下方法
tex2Dproj(_GrabTexture, i.grabPos)
但是我们将使用不同的方法,并使用透视划分(即 将所有其他元素都分成w分量。
tex2D(_GrabTexture, i.grabPos.xy/i.grabPos.w)
w分量仅当使用透视图时,才有必要将w分量分为几部分,在正交投影中,它始终为1。实际上, w存储了指向相机的距离值。 但是它不是depth- z ,其值应在0到1的范围内。使用depth是值得单独讨论的话题,因此我们将返回到着色器。
透视划分也可以在顶点着色器中执行,并且已经准备好的数据可以传输到片段着色器。
v2f vert (appdata v) { v2f o; o.uv = v.uv; o.color = v.color; o.vertex = UnityObjectToClipPos(v.vertex); o.grabPos = ComputeScreenPos (o.vertex); o.grabPos /= o.grabPos.w; return o; }
分别添加一个片段着色器。
fixed4 frag (v2f i) : SV_Target { fixed4 = grabColor = tex2d(_GrabTexture, i.grabPos.xy); fixed4 texColor = tex2D(_MainTex, i.uv)*i.color; return grabColor; }
关闭指定的混合模式,因为 现在,我们正在片段着色器中实现混合模式。
并查看
GrabPass的结果。

似乎什么都没发生,但事实并非如此。 为了清楚起见,我们引入了一个轻微的偏移,为此,我们将变量的值添加到纹理坐标。 这样我们可以修改变量,添加一个新的
_DisplacementPower属性。
Properties { [PerRendererData] _MainTex ("Main Texture", 2D) = "white" {} _Color ("Color" , Color) = (1,1,1,1) _DisplacementPower ("Displacement Power" , Float) = 0 } SubShader { Pass { ... float _DisplacementPower; ... } }
再次,更改片段着色器。
fixed4 grabColor = tex2d(_GrabTexture, i.grabPos.xy + _DisplaccementPower)
欧普跳和结果! 图像有移位。

成功完成转换后,您可以进行更复杂的变形。 我们使用预先准备的纹理,该纹理将在指定点存储位移力。 在x轴上为偏移值的红色,在y轴上为绿色。
让我们开始吧。 添加一个新属性以存储纹理。
_DisplacementTex ("Displacement Texture", 2D) = "white" {}
和一个变量。
sampler2D _DisplacementTex;
在片段着色器中,我们从纹理获取偏移值,并将其添加到纹理坐标。
fixed4 displPos = tex2D(_DisplacementTex, i.uv)
现在,更改
_DisplacementPower参数的值,我们不仅可以移动原始图像,还可以使原始图像失真。

叠加层
现在屏幕上只有空间的扭曲,而我们一开始显示的精灵就不存在了。 我们将其放回原处。 为此,我们将使用多种颜色。 采取其他方式,例如叠加混合模式。 其公式如下:

其中S是原始图像,C是校正图像,即我们的子画面R是结果。
将此公式转移到我们的着色器。
fixed4 color = grabColor < 0.5 ? 2*grabColor*texColor : 1-2*(1-texColor)*(1-grabColor);
在着色器中使用条件运算符是一个相当令人困惑的话题。 在很大程度上取决于所使用的平台和图形API。 在某些情况下,条件语句不会影响性能。 但是,总是值得退后。 可以使用数学和可用方法替换条件运算符。 我们使用以下构造
c = step ( y, x); r = c * a + (1 - c) * b;
步进功能如果x大于或等于y,则step函数将返回1。 如果x小于y, 则为 0。
例如,如果x = 1,而y = 0.5,则c的结果将为1。
r = 1 * a + 0 * b
因为 乘以0得到0,则结果将只是a的值。
否则,如果c为0,
r = 0 * a + 1 * b
最终结果将是b 。
为
覆盖模式重写颜色。
fixed s = step(grabColor, 0.5); fixed4 color = s * (2 * grabColor * texColor) + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor));
确保考虑精灵的透明性。 为此,我们将在两种颜色之间使用线性插值。
color = lerp(grabColor, color ,texColor.a);
完整片段着色器代码。
fixed4 frag (v2f i) : SV_Target { fixed4 displPos = tex2D(_DisplacementTex, i.uv); float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color; fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset); fixed s = step(grabColor, 0.5); fixed4 color = s * (2 * grabColor * texColor) + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor)); color = lerp(grabColor, color ,texColor.a); return color; }
和我们工作的结果。

GrabPass功能
上面已经提到
GrabPass传递
{}将屏幕的内容捕获到
_GrabTexture纹理中。 同时,每次调用此段落时,纹理的内容都会更新。
通过指定将捕获屏幕内容的纹理名称,可以避免不断更新。
GrabPass{"_DisplacementGrabTexture"}
现在,仅在每帧GrabPass传递的第一次调用时更新纹理的内容。 如果有
很多使用
GrabPass {}的对象,则可以节省资源。 但是,如果两个对象重叠,则伪影将很明显,因为两个对象将使用相同的图像。
使用GrabPass {“ _ DisplacementGrabTexture”}。

使用GrabPass {}。

动画制作
现在是时候使我们的效果动起来了。 我们希望随着爆炸波的增长而平稳地减小变形力,以模拟其消失。 为此,我们需要更改材料的属性。
动画脚本 public class Wave : MonoBehaviour { private float _elapsedTime; private SpriteRenderer _renderer; public float Duration; [Space] public AnimationCurve ScaleProgress; public Vector3 ScalePower; [Space] public AnimationCurve PropertyProgress; public float PropertyPower; [Space] public AnimationCurve AlphaProgress; private void Start() { _renderer = GetComponent<SpriteRenderer>(); } private void OnEnable() { _elapsedTime = 0f; } void Update() { if (_elapsedTime < Duration) { var progress = _elapsedTime / Duration; var scale = ScaleProgress.Evaluate(progress) * ScalePower; var property = PropertyProgress.Evaluate(progress) * PropertyPower; var alpha = AlphaProgress.Evaluate(progress); transform.localScale = scale; _renderer.material.SetFloat("_DisplacementPower", property); var color = _renderer.color; color.a = alpha; _renderer.color = color; _elapsedTime += Time.deltaTime; } else { _elapsedTime = 0; } } }
动画的结果。

Perrenderer数据
请注意下面的行。
_renderer.material.SetFloat("_DisplacementPower", property);
在这里,我们不仅在更改材质的属性之一,而且还创建了源材质的副本(仅在此方法的第一次调用时)并已经对其进行处理。 这是一个可行的选择,但是如果舞台上有多个对象(例如,一千个),那么创建如此多的副本将不会带来任何好处。 有一个更好的选择-这是在着色器中使用
[PerRendererData]属性,在脚本中使用
MaterialPropertyBlock对象。
为此,将一个属性添加到着色器中的
_DisplacementPower属性。
[PerRendererData] _DisplacementPower ("Displacement Power" , Range(-.1,.1)) = 0
之后,该属性将不再显示在检查器中,因为 现在,每个对象都是单独的,它将设置值。

我们返回到脚本并对其进行更改。
private MaterialPropertyBlock _propertyBlock; private void Start() { _renderer = GetComponent<SpriteRenderer>(); _propertyBlock = new MaterialPropertyBlock(); } void Update() { ...
现在,要更改属性,我们将更新对象的
MaterialPropertyBlock而不创建
材料的副本。
关于SpriteRenderer让我们在着色器中查看这一行。
[PerRendererData] _MainTex ("Main Texture", 2D) = "white" {}
SpriteRenderer与sprites类似地
工作 。 它将使用
MaterialPropertyBlock将
_MainTex属性设置
为其值。 因此,在检查器中,不会为材质显示
_MainTex属性,在
SpriteRenderer组件中,我们指定了所需的纹理。 同时,舞台上可以有许多不同的精灵,但是只有一种材质可以用于渲染(如果您自己不更改的话)。
PerRendererData功能
您可以从几乎与渲染相关的所有组件中获取
MaterialPropertyBlock 。 例如,
SpriteRenderer ,
ParticleRenderer ,
MeshRenderer和其他
Renderer组件。 但是总是有一个例外,这是
CanvasRenderer 。 使用此方法无法获取和更改属性。 因此,如果使用UI组件编写2D游戏,则在编写着色器时会遇到此问题。
旋转角度
旋转图像时会产生不愉快的效果。 在圆波的例子中,这一点尤其明显。
右转(90度)时,波浪会产生另一个失真。

红色表示从纹理的同一点获得的矢量,但是该纹理的旋转不同。 偏移值保持不变,不考虑旋转。
为了解决这个问题,我们将使用
unity_ObjectToWorld转换
矩阵 。 这将有助于将我们的向量从局部坐标重新计算为世界坐标。
float2 offset = (displPos.xy*2 - 1) * _DisplacementPower * displPos.a; offset = mul( unity_ObjectToWorld, offset);
但是矩阵还包含对象比例尺上的数据,因此在指定变形强度时,我们必须考虑对象本身的比例尺。
_propertyBlock.SetFloat("_DisplacementPower", property/transform.localScale.x);
右波也旋转了90度,但是现在可以正确计算失真了。

夹
我们的纹理具有足够的透明像素(尤其是如果我们使用
Rect网格类型)。 着色器处理它们,在这种情况下,这没有意义。 因此,我们尝试减少不必要的计算。 我们可以使用
clip(x)方法中断透明像素的处理。 如果传递给它的参数小于零,则着色器将结束。 但是由于alpha值不能小于0,因此我们将从中减去一个小值。 也可以将其置于属性(
Cutout )中,并用于剪切图像的透明部分。 在这种情况下,我们不需要单独的参数,因此我们只使用数字
0.01 。
完整片段着色器代码。
fixed4 frag (v2f i) : SV_Target { fixed4 displPos = tex2D(_DisplacementTex, i.uv); float2 offset = (displPos.xy * 2 - 1) * _DisplacementPower * displPos.a; offset = mul( unity_ObjectToWorld,offset); fixed4 texColor = tex2D(_MainTex, i.uv + offset)*i.color; clip(texColor.a - 0.01); fixed4 grabColor = tex2D (_GrabTexture, i.grabPos.xy + offset); fixed s = step(grabColor, 0.5); fixed4 color = s * 2 * grabColor * texColor + (1 - s) * (1 - 2 * (1 - texColor) * (1 - grabColor)); color = lerp(grabColor, color ,texColor.a); return color; }
PS:着色器和脚本的源代码是
git的
链接 。 该项目还具有用于变形的小型纹理生成器。 带有基座的水晶是从资产-2D游戏套件中取出的。