这片文章记录如何使用片原元着色器绘制一个最简单的笑脸。
首先看看最终绘制的效果图如下
1. 思路
先整体绘制一个大的灰色圆形作为脸部,然后脸的内部将眼睛和嘴巴部分绘制成黄色即可。
- 眼睛的绘制:选取某个点为圆心,其它与这个点距离小于半径点片元都绘制成为黄色
float len1 = length(i.uv - _LeftEyeCenter.xy);
float len2 = length(i.uv - _RightEyeCenter.xy);
// 绘制眼睛
if (len1 < _EyeRadio || len2 < _EyeRadio) {
resultColor = yellow;
}
- 嘴巴的绘制:选取两个原点和半径都不同都圆,当位置在两个圆之间的片元,都是嘴巴部分,绘制为黄色
float len4 = length(i.uv - _MouthInCenter.xy);
float len5 = length(i.uv - _MouthOutCenter.xy);
else if (len4 < _MouthInSize && len5 > _MouthOutSize) {
resultColor = yellow;
}
- 脸部绘制:整个大圆中,除了眼睛和嘴巴,其它片元绘制为灰色即可
2. 第一版完整代码
Shader "Unlit/Face"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_EyeRadio ("Eye Size", float) = 0.15
_MouthOutCenter ("MouthOuterCenter", Vector) =(0.5, 0.8, 0, 0)
_MouthInCenter ("MouthInCenter", Vector) = (0.5, 0.5, 0, 0)
_MouthOutSize ("MouthOutSize", float) = 0.5
_MouthInSize ("MouthInSize", float) = 0.3
_LeftEyeCenter ("LeftEyeCenter", Vector) = (0.3, 0.7, 0, 0)
_RightEyeCenter ("RightEyeCenter", Vector) = (0.7, 0.7, 0, 0)
}
SubShader
{
Tags { "RenderType"="Opaque" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
// make fog work
#pragma multi_compile_fog
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
UNITY_FOG_COORDS(1)
float4 vertex : SV_POSITION;
};
sampler2D _MainTex;
float4 _MainTex_ST;
// 眼睛中心
float4 _LeftEyeCenter;
float4 _RightEyeCenter;
// 眼睛半径
float _EyeRadio;
// 嘴巴外圈半径
float _MouthOutSize;
// 嘴巴内圈半径
float _MouthInSize;
// 嘴巴外圈圆心
float4 _MouthOutCenter;
// 嘴巴内圈圆心
float4 _MouthInCenter;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 yellow = fixed4(1, 1, 0.0, 1);
fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
fixed4 white = fixed4(1, 1, 1, 0);
fixed4 red = fixed4(0.6, 0, 0, 1);
fixed4 resultColor;
float len3 = length(i.uv - 0.5);
float len4 = length(i.uv - _MouthInCenter.xy);
float len5 = length(i.uv - _MouthOutCenter.xy);
float len1 = length(i.uv - _LeftEyeCenter.xy);
float len2 = length(i.uv - _RightEyeCenter.xy);
// 绘制眼睛
if (len1 < _EyeRadio || len2 < _EyeRadio) {
resultColor = yellow;
}
// 绘制嘴巴
else if (len4 < _MouthInSize && len5 > _MouthOutSize) {
resultColor = yellow;
}
// 绘制圆脸
else if (len3 < 0.5) {
resultColor = gray;
}
else {
resultColor = white;
}
return resultColor;
}
ENDCG
}
}
}
3. 代码优化
上面贴出来的代码成功绘制了笑脸,但代码里用到了很多if
,在编写着色器代码(包括 Unity ShaderLab,CG、HLSL)时,一个无脑遵循的原则是
尽量不要使用
if
for
等条件判断和循环的语法
原因大概是因为 GPU 和 CPU 的结构差异,GPU 中并没有那么多的控制寄存器,导致GPU在进行分支控制计算时需要以lockstep
的方式进行,这会导致线程束
的等待,耗费 GPU 周期从而导致性能下降,可以具体参考这篇文章从GPU硬件架构看渲染流水线,尽管现代的 GPU 针对架构进行了优化,if
和for
的消耗已经没有那么大了,但我们还是希望尽可能进行一些优化。那么我们需要考虑如何把上述代码中的 if
优化掉。
优化
if
最常用的方法是配合使用step
和clamp
内置方法。
clamp
不用说了,在Unity Shader 极简实践3——step,lerp 和 smoothstep 应用这篇文章中有介绍关于内置方法 step 的用法,简单来说就是
step (a, x)
{
if (x < a)
{
return 0;
}
else
{
return 1;
}
}
那么如下的这个 if
语句
if (len3 < 0.5) {
resultColor = gray;
}
便可以使用 step
进行这样的优化
resultColor = step(len3, 0.5) * gray;
使用这样的方法,可以将上述的片元着色器优化为如下代码
fixed4 frag(v2f i) : SV_Target
{
fixed4 yellow = fixed4(1, 1, 0.0, 1);
fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
fixed4 white = fixed4(1, 1, 1, 0);
float len3 = length(i.uv - 0.5);
float len4 = length(i.uv - _MouthInCenter.xy);
float len5 = length(i.uv - _MouthOutCenter.xy);
float len1 = length(i.uv - _LeftEyeCenter.xy);
float len2 = length(i.uv - _RightEyeCenter.xy);
// eye 返回1 则是眼睛范围
float v = step(len1, _EyeRadio) + step(len2, _EyeRadio);
float eye = clamp(v, 0, 1);
// mouth 返回1 则是嘴巴范围
float mouth = step(2, step(len4, _MouthInSize) + step(_MouthOutSize, len5));
float eyeOrMouth = clamp(eye + mouth, 0, 1);
// face 返回1 是脸的范围,包括嘴巴和眼睛
float face = step(len3, 0.5);
// 非脸的范围返回 white
// 脸的范围内若是嘴巴或眼睛返回 yellow,否则返回 gray
return face * (eyeOrMouth * yellow + (1 - eyeOrMouth) * gray) + (1 - face) * white;
}
很明显,上述的return
语句可以使用 lerp
内置方法来进行如下的优化
return lerp(lerp(white, gray, face), yellow, eyeOrMouth);
4. 代码进一步优化
在这一部分我们将之前的片元着色器代码做一下抽象,考虑到绘制的眼睛、嘴巴都是基于绘制圆形来完成,我们抽取一个绘制圆形的方法
/*
* uv 片元的 uv
* center 圆心
* radio 半径
*/
float circle(float2 uv, float2 center, float radio) {
float len = length(uv - center);
return step(len, radio);
}
使用这个方法,在center
为圆心,radio
为半径的圆内部的片元,将返回1,否则返回0,那么使用这个返回值和圆的颜色相乘就绘制了对应的圆,如果将片元着色器修改为
fixed4 frag(v2f i) : SV_TARGET
{
fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
return circle(i.uv, _LeftEyeCenter, _EyeRadio) * yellow;
}
绘制左眼的圆形,得到的效果图如下
提取了
circle
方法后,我们可以考虑针对它的返回值进行计算,在圆中的片元返回1,那么可以考虑两个圆相加的结果是
- 当两个圆相交时,相交部分的片元返回2
- 分别只在其中一个圆内的片元返回1
- 不在任何圆中的片元返回0
将片元着色器修改为
// 不相交的圆相加
fixed4 frag(v2f i) : SV_TARGET
{
fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio) + circle(i.uv, _RightEyeCenter, _EyeRadio);
return eye * yellow;
}
绘制结果如下图
这里之所以把
yellow
这个颜色设置为一般的黄色,是需要更好展示圆形相交时的情况,我们将片元着色器做如下修改,将两个圆的半径增加,使得他们有部分相交
// 相交的圆相加
fixed4 frag(v2f i) : SV_TARGET {
fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio * 3) + circle(i.uv, _RightEyeCenter, _EyeRadio * 3);
return eye * yellow;
}
可以看看绘制的结果:
我们可以看到,在两个圆相交的部分,黄色增强了,这是因为
circle
这个方法在相交部分的片元返回值为2,如果我们不想要增强颜色,相交部分也使用同样的颜色时,使用clamp
来进行就可以了,将上述代码做简单修改:
return clamp(eye, 0, 1) * yellow;
修改后的绘制结果是这样的
我们再考虑一下两个
circle
相减会是什么结果,考虑 a - b
- 返回 -1 和 返回 0 是一样的结果
- a - b相当于先绘制a,再把b所占部分扣掉
如果把片段着色器修改为如下代码:
// 不相交的圆相减
fixed4 frag(v2f i) : SV_TARGET
{
fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
float v = circle(i.uv, _LeftEyeCenter, _EyeRadio) - circle(i.uv, _RightEyeCenter, _EyeRadio);
return v * yellow;
}
绘制的结果是:
修改片元着色器代码如下,调整半径,使得两个圆相交
// 相交的圆相减
fixed4 frag(v2f i) : SV_TARGET {
fixed4 yellow = fixed4(0.5, 0.5, 0, 1);
float eye = circle(i.uv, _LeftEyeCenter, _EyeRadio * 3) - circle(i.uv, _RightEyeCenter, _EyeRadio * 3);
return eye * yellow;
}
得到的绘制结果是:
那么,在抽象了一个圆的绘制方法
circle
以及考虑了circle
的加减运算结果后,可以将上述的笑脸绘制方法修改为如下代码:
// 抽象 circle 来简化代码
fixed4 frag(v2f i) : SV_TARGET
{
fixed4 yellow = fixed4(1, 1, 0.0, 1);
fixed4 gray = fixed4(0.75, 0.75, 0.75, 1);
fixed4 white = fixed4(1, 1, 1, 0);
float eye = clamp(circle(i.uv, _LeftEyeCenter, _EyeRadio) + circle(i.uv, _RightEyeCenter, _EyeRadio), 0, 1);
float mouth = clamp(circle(i.uv, _MouthInCenter, _MouthInSize) - circle(i.uv, _MouthOutCenter, _MouthOutSize), 0, 1);
float face = clamp(circle(i.uv, float2(0.5, 0.5), 0.5), 0, 1);
return lerp(white, lerp(gray, yellow, (eye + mouth)), face);
}
得到的绘制结果如下,可以看到,跟优化前代码的绘制效果一摸一样
关于最后一行代码
return lerp(white, lerp(gray, yellow, (eye + mouth)), face);
可以这么来理解:因为我们使用step
和clamp
确保了 eye
、mouth
和 face
的取值都是 0 和 1,lerp
针对1和0来处理其实就是if-else
的效果了:
- 针对外层
lerp
,若片元不在脸face
的范围,返回颜色white
,否则返回第二层lerp
结果; - 第二层
lerp
针对face
的片元,若在眼睛eye
或嘴巴mouth
的返回,返回颜色yellow
,否则返回颜色gray
; - 这里使用针对 0 和 1 的
lerp
来替代if-else
附:在测试时材质上的参数设置如下图,可以通过调整这些参数得到不同的笑脸效果