💡 本系列文章收录于个人专栏 ShaderMyHead,欢迎订阅。
💡 完整源码可在 Cocos Creator 官方商店获取。
一、描边原理
描边原理和边缘查找的实现类似。
材质上镂空处(Alpha=0)的某个点,要判断它是否需要绘制描边色值,可以向四周做多个方向的发射采样(发射距离为描边的尺寸),查找最临近的实心(Alpha>0.5)像素:
若未找到实心像素,就在片元着色器中直接返回材质原色值,否则则返回描边色值。
二、描边的实现
2.1 基础代码
根据上述方案,我们先书写一个着色器基础模板:
CCEffect %{
techniques:
- name: glow
passes:
- vert: vs:vert
frag: fs:frag
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
depthStencilState:
depthTest: false
depthWrite: false
rasterizerState:
cullMode: none
properties:
glowColor: { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
}%
CCProgram vs %{
#include "../../resources/chunk/normal-vert.chunk"
}%
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
uniform UBO {
vec4 glowColor; // 发光颜色
float glowSpread; // 发光最大半径(UV)
};
vec4 frag () {
vec4 texColor = texture(cc_spriteTexture, uv);
float centerAlpha = texColor.a;
// 不透明像素不需要处理,直接返回材质原色值
if (centerAlpha > 0.5) return texColor;
// TODO: 搜索最近实心像素,计算要返回的色值
}
}%
接着只需要把片元着色器里最终的逻辑(第 44 行)逐步实现即可。
留意我们定义了 glowColor 和 glowSpread 两个参数,它们分别表示描边的色值,以及描边的尺寸。
其中描边的尺寸(glowSpread)的数值是基于 UV 坐标体系的,这里定为 [0.0, 0.2],意味着描边最粗可达材质宽高的 20%。
2.2 核心代码
在片元着色器中,如果设定当前像素会往周围的 12 个方向发射向量,则代码实现为:
// ---------- 搜索最近实心像素 ----------
const int DIR_COUNT = 12; // 当前像素向四周发射向量的数量
for (int dir = 0; dir < DIR_COUNT; dir++) {
float angle = float(dir) / float(DIR_COUNT) * 6.2831853; // 当前向量角度,其中 6.2831853 表示 2π
// TODO - 查找当前方向最临近的实心像素
}
在第 5 行我们通过 dir / DIR_COUNT * 2π 的计算来获取当前向量的角度,可用于后续计算。
接着我们给每个发散的方向设定发射步数(例如 8 步),让其一步步去探测实心像素:
其代码实现为:
const int DIR_COUNT = 12;
const int STEP_COUNT = 8; // 每个方向探测的步数
float stepSize = glowSpread / float(STEP_COUNT); // 探测的每一步的步长
for (int dir = 0; dir < DIR_COUNT; dir++) {
float angle = 6.2831853 * float(dir) / float(DIR_COUNT);
vec2 dirVec = vec2(cos(angle), sin(angle)); // 当前向量
for (int s = 1; s <= STEP_COUNT; s++) {
float dist = float(s) * stepSize; // 从中心到当前采样点的总距离(基于 uv 坐标体系)
vec2 offset = dirVec * dist; // 从中心到当前采样点的偏移量
// TODO - 探测实心像素
}
}
其中第 7 行使用的就是极坐标转直角坐标的经典方式 —— 给定一个角度 θ,则单位圆上的方向向量是 x = cos(θ),y = sin(θ)。
进而就能通过 dirVec * dist 获取当前方向的偏移。
另外留意我们的 glowSpread 是基于 UV 坐标体系的,故上方代码中 dist 和 offset 得到的值也会基于 UV 坐标体系,因此我们可以通过 texture(cc_spriteTexture, uv + offset) 来获取当前步的偏移量所对应的像素:
const int DIR_COUNT = 12;
const int STEP_COUNT = 8;
float stepSize = glowSpread / float(STEP_COUNT);
float minDist = glowSpread; // 探测到实心像素的最短距离,初始化为描边尺寸(即最大距离)
for (int dir = 0; dir < DIR_COUNT; dir++) {
float angle = 6.2831853 * float(dir) / float(DIR_COUNT);
vec2 dirVec = vec2(cos(angle), sin(angle));
for (int s = 1; s <= STEP_COUNT; s++) {
float dist = float(s) * stepSize;
vec2 offset = dirVec * dist;
float a = texture(cc_spriteTexture, uv + offset).a; // 当前探测到的像素
if (a > 0.5) { // 探测到了实心像素
minDist = min(minDist, dist);
break;
}
}
}
通过「多方向发散」和「分步探测」两个嵌套循环,我们最终获得了当前像素最临近的实心像素的最短距离 dist(若没探测到实心像素,则 dist 等于初始化的描边尺寸),因此可以在片元着色器的最后返回对应的色值:
vec4 color = texColor;
if (minDist < glowSpread) {
color = glowColor;
}
return color;
执行效果如下:
2.2 描边精细化
可以看到当 glowSpread 的数值越大时,材质上比较精细复杂的轮廓描边会出现毛糙的问题:
这是因为我们设定的 DIR_COUNT 和 STEP_COUNT 数值都相对较小,如果需要被描边的材质存在较复杂的轮廓,可以相应调大它们的数值(特别是 DIR_COUNT):
const int DIR_COUNT = 80;
const int STEP_COUNT = 10;
修改后的描边效果:
💡 虽然「多方向发散」和「分步探测」的循环遍历是发生在 GPU 侧的,但遍历的次数太多依旧会影响性能,因此推荐将
DIR_COUNT和STEP_COUNT数值调整为自己能接受的最小数值。
三、发光的实现
在上一节我们实现了实心描边的效果,接着可以通过修改描边着色强度来实现发光的效果。
实现的方式很简单,通过 GLSL 内置的 smoothstep 方法返回一个 [0.0, 1.0] 区间的平滑值,来作为发光强度的因子即可:
float glowFactor = smoothstep(glowSpread, 0.0, minDist);
着色器中的应用如下:
vec4 color = texColor;
#if USE_GLOW_DECAY
// 发光效果
float glowFactor = smoothstep(glowSpread, 0.0, minDist); // 发光强度的因子
color = glowColor * glowFactor;
#else
// 实心描边
if (minDist < glowSpread) {
color = glowColor;
}
#endif
return color;
我们使用了一个自定义宏 USE_GLOW_DECAY 来决定是否启用衰减发光的效果:
这里存在一个问题 —— 当 glowColor 数值较高时,发光的衰减会程序出一种果冻分层效应:
这是由于 miniDist 不够精准导致的,我们把 STEP_COUNT 从 10 加大到 20 来获取更精准的 miniDist,修改后的发光衰减变得更加平滑:
此时完整的着色器代码如下:
/** glow-simple.effect **/
CCEffect %{
techniques:
- name: glow
passes:
- vert: vs:vert
frag: fs:frag
blendState:
targets:
- blend: true
blendSrc: src_alpha
blendDst: one_minus_src_alpha
depthStencilState:
depthTest: false
depthWrite: false
rasterizerState:
cullMode: none
properties:
glowColor: { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
}%
CCProgram vs %{
#include "../../resources/chunk/normal-vert.chunk"
}%
CCProgram fs %{
precision highp float;
#include <sprite-texture>
in vec2 uv;
uniform UBO {
vec4 glowColor; // 发光颜色
float glowSpread; // 发光最大半径(基于 UV 坐标体系)
};
vec4 frag () {
vec4 texColor = texture(cc_spriteTexture, uv);
float centerAlpha = texColor.a;
// 不透明像素不处理外发光
if (centerAlpha > 0.5) return texColor;
// ---------- 搜索最近实心像素 ----------
const int DIR_COUNT = 80; // 当前像素向四周发射向量的数量,值越大越精准
const int STEP_COUNT = 20; // 每个方向探测的步数,值越大越精准
float stepSize = glowSpread / float(STEP_COUNT); // 探测的每一步的步长
float minDist = glowSpread; // 探测到实心像素的最短距离,初始化为最大距离
for (int dir = 0; dir < DIR_COUNT; dir++) {
float angle = 6.2831853 * float(dir) / float(DIR_COUNT); // 当前向量角度,其中 6.2831853 等于 2π
vec2 dirVec = vec2(cos(angle), sin(angle)); // 当前向量
for (int s = 1; s <= STEP_COUNT; s++) {
float dist = float(s) * stepSize; // 当前步的长度(基于 UV 坐标体系)
vec2 offset = dirVec * dist; // 当前步的偏移量
float a = texture(cc_spriteTexture, uv + offset).a; // 当前步的偏移量所对应的像素
if (a > 0.5) { // 探测到了实心像素
minDist = min(minDist, dist);
break;
}
}
}
vec4 color = texColor;
#if USE_GLOW_DECAY
// 发光效果
float glowFactor = smoothstep(glowSpread, 0.0, minDist); // 发光强度的因子
color = glowColor * glowFactor;
#else
// 实心描边
if (minDist < glowSpread) {
color = glowColor;
}
#endif
return color;
}
}%
四、潜在问题
4.1 描边粗细不均衡的处理
描边的尺寸(glowSpread)的数值是基于 UV 坐标体系的,若材质是一个正方形那没什么问题,但如果材质是一个长方形(特别是宽高差值较大的长方形),会导致描边在 U 轴和 V 轴方向上的的粗细不均:
解决方案是新增一个材质宽高比的参数 textureAspect:
properties:
glowColor: { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
textureAspect: { value: [1.0, 1.0], editor: { type: vector } } #新增参数
并通过外部的组件脚本动态赋值:
material.setProperty('textureAspect', new Vec2(texture.width / texture.height, 1.0));
在片元着色器中只需要让偏移量除以该宽高比参数即可:
// vec2 offset = dirVec * dist;
vec2 offset = dirVec * dist / textureAspect;
4.2 描边被材质边缘截断的处理
由于我们的着色器效果是在每张图片(材质)原有的像素上去着色的,因此描边或发光的效果无法超出材质外部的区域,即着色器的效果会被材质边缘截断:
要解决该问题,可以增大原生图片的镂空区域(来容纳更粗的描边),但这样会导致图片的体积变大,也为原生图片的处理增加了心智负担。
我们可以使用上一章所掌握的 Render Texture 来解决此问题。
我们创建一个等于画布尺寸 1280 * 720 的 Render Texture,以及一个绑定该 Render Texture 文件的摄像头(留意 Ortho Height 要设置为画布的高度的一半),接着修改 Effect 文件代码:
- CCEffect 新增
renderTexture材质参数
# CCEffect
properties:
glowColor: { value: [1.0, 0.8, 0.3, 1.0], editor: { type: color } }
glowSpread: { value: 0.04, editor: { range: [0.0, 0.2, 0.005] } }
textureAspect: { value: [1.78, 1.0], editor: { type: vector } } # 宽高比修改为 1280 / 720 = 1.78
renderTexture: { value: grey } # 新增 renderTexture 材质参数
- 顶点着色器改动
precision highp float;
#include <cc-global>
#include <common/common-define> // 引入 CC_HANDLE_RT_SAMPLE_FLIP 内置函数
// 略...
vec4 vert() {
// 略...
// 解决不同平台和渲染管线中 RenderTexture 的坐标系差异,可处理 RenderTexture 采样时的 UV 坐标翻转问题
uv = CC_HANDLE_RT_SAMPLE_FLIP(uv);
return pos;
}
- 片元着色器改动
precision highp float;
#include <sprite-texture>
in vec2 uv;
uniform sampler2D renderTexture; // 新增材质变量
// 略...
vec4 frag () {
vec4 texColor = texture(renderTexture, uv); // 将 cc_spriteTexture 更换为 renderTexture
// 略...
float a = texture(renderTexture, uv + offset).a; // 将 cc_spriteTexture 更换为 renderTexture
// 略...
}
接着在着色器材质的属性检查器面板,把 Render Texture 文件赋予 renderTexture 材质参数:
最后新增一个覆盖整个画布的 SpriteFrame 节点,并应用材质即可:
此时描边或发光效果已能超出每张图片原本的区域,不再被图片边缘截断:
💡 该方案还能解决 Spine 动画描边异常的问题。因为 Spine 通常由多个互相独立的材质叠加而成,仅把材质应用到单个 Spine 上时,每个材质部件都会被着色器绘制上描边,导致效果错乱:
使用 Render Texture 方案可以规避此问题: