引言:本文为「Cocos 中文社区第4期征稿活动」参与作品,开发者 muzzik 将和我们分享他所理解的 SDF,运用 SDF 巧妙实现阴影、描边、外发光等效果。

SDF 的全称是 Signed Distance Field(有符号距离场),用于表示空间中各点到物体表面的距离。

  • 有符号:指的是正数和负数,正数代表在物体外,负数代表在物体内。

  • 距离场:其中的 数值正是代表到物体表面的距离,0就代表物体表面,例如数值5就代表当前点在物体外,距离表面还有5的距离,负数则相反。

SDF 常被应用于字体渲染、Ray Marching、物理引擎等领域,今天我们将基于 SDF 实现一些 Shader 效果,包括形变动画、描边、外发光、阴影等。

前置知识

为了便于没有 Shader 基础的同学观看,这里简单介绍一下 Shader 的相关基础知识;同时对本文所用到的 GLSL 内置函数进行说明。

Shader 其实是一段 GLSL(OpenGL 着色语言)程序,而 WebGL 则是为了方便浏览器使用而封装的 OpenGL。组成结构:

  • 顶点着色器:模型由三角面组成,三角面由顶点组成,而顶点作色器就负责顶点的坐标控制,可以用来实现布料、水体等等。

  • 片段着色器:片段着色器负责渲染位置的颜色输出

本文用到的 GLSL 内置函数说明:

  • clamp(x, y, z):x < y 返回 y,x > z 返回 z,否则返回 x

  • mix(x, y, z):x, y 的线性混叠, x(1 - z) + y * z

  • length(x):返回一个向量的模(长度),即 sqrt(dot(x,x))

  • sign(x):x < 0 时返回 -1,x == 0 返回 0,x > 0 返回 1

形变动画

画一个圆

如果我们想要在 Shader 内用 SDF 画一个圆,应该怎么做呢?很简单,代码如下:

  • 参数 p 是当前渲染点的位置,因为是 2D 图形,所以只有 xy;

  • 参数 r 则是我们想要绘制的圆的半径。

这里的返回结果就是距离场。

举个例子:圆半径5,圆在 0,0 点(所有公式皆基于 0,0 点),渲染点在 0,3 点,这时 length ( p ) = 3,3 - 5 = -2,则我们离圆的表面有 2 的距离,负数代表渲染点在物体内。

接着我们要做的就是把它在片段着色器「画」出来:

  • output_v4:片段着色器输出的颜色

  • float dist_f:距离场

  • vec4 color_v4:物体颜色

output_v4 = mix(output_v4, color_v4, clamp(-dist_f, 0.0, 1.0));

- dist_f:负负得正,所以在物体内部 clamp 结果就是一个有效值,在物体外部就是负数(clamp 结果为0),最终结果就为原本的 output_v4,所以只有在物体内部 mix 才会生效。

注:以下我将 SDF 值通称为距离场。更多 SDF 图形公式和原理讲解附在文末,感兴趣的朋友可以继续深入了解。

平移

前面说了如何画出 SDF 图形,那么怎么让它们动起来呢?很简单,我们只需要将渲染点减去我们要移动的坐标,再将结果点传入 SDF 函数求得距离场,就得到了移动过后的距离场。

vec2 translate(vec2 render_v2_, vec2 move_v2_) {return render_v2_ - move_v2_;
}

举个例子:

float dist_f = sdf_circle(translate(render_v2_, vec2(100.0, 100.0)), 10.0);

dist_f 便是我们通过 sdf 函数求得平移 vec2(100.0, 100.0) 后的距离场。

旋转

旋转其实也很简单。学习过矩阵的同学应该知道有个旋转矩阵,我们只需要将向量 * 二维旋转矩阵,就能得到旋转后的点:

// 逆时针旋转
vec2 rotate_ccw(vec2 render_v2_, float radian_f_) {mat2 m = mat2(cos(radian_f_), sin(radian_f_), -sin(radian_f_), cos(radian_f_));return render_v2_ * m;
}// 顺时针旋转
vec2 rotate_cw(vec2 render_v2_, float radian_f_) {mat2 m = mat2(cos(radian_f_), -sin(radian_f_), sin(radian_f_), cos(radian_f_));return render_v2_ * m;
}

正常展示多个物体

注:残留像素是录屏软件的关系

如果要正常展示多个 SDF 物体,只需要返回两个距离场最小的那个就行了,一个 min 稿定:

float merge(float dist_f_, float dist2_f_) {return min(dist_f_, dist2_f_);
}

是不是很简单!通过对距离场进行操作,我们可以得到更多不同的效果,请接着往下看。

相交

效果是不是很奇怪?这个函数只会在两个物体的距离场同时 < 0 时才会返回 < 0,方法也很简单:

float intersect(float dist_f_, float dist2_f_) {// dist_f_ < 0, dist2_f_ > 0  例 dist_f_ = -2, dist2_f_ = 3,r = 3, 例 dist_f_ = -2, dist2_f_ = 1,r = 1, 则值 > 0// dist_f_ > 0, dist2_f_ < 0  例 dist_f_ = 2, dist2_f_ = -1,r = 2, 例 dist_f_ = 2, dist2_f_ = -5,r = 2, 则值 > 0 // dist_f_ > 0, dist2_f_ > 0  例 dist_f_ = 1, dist2_f_ = 2,r = 2, 例 dist_f_ = 2, dist2_f_ = 1,r = 2, 则值 > 0 // dist_f_ < 0, dist2_f_ < 0  例 dist_f_ = -2, dist2_f_ = -3,r = -2, 例 dist_f_ = -2, dist2_f_ = -1,r = -1, 则值 < 0// 所以最终结果只会在 dist_f_ 和 dist2_f_ 重合时展示return max(dist_f_, dist2_f_);
}

其实原理就是只有两个数同时 < 0 时,max 才会返回负数,所以造成了上面的效果。

融合

这个效果就比较常见了,实现方式如下:

float smooth_merge(float dist_f_, float dist2_f_, float k_f_) {// k_f_ 如果不超过 abs(dist_f_ - dist2_f_),那么都是无效值(0 或 1)float h_f = clamp(0.5 + 0.5 * (dist2_f_ - dist_f_) / k_f_, 0.0, 1.0);// 假设 k_f_ = 0, dist_f_ = 2, dist2_f_ = 1,则 h_f = 0, mix(...) = dist2_f_, k_f_ * h_f * (1.0 - h_f) = 0,结果为 dist2_f_// 假设 k_f_ = 0, dist_f_ = 1, dist2_f_ = 2,则 h_f = 1, mix(...) = dist_f_, k_f_ * h_f * (1.0 - h_f) = 0,结果为 dist_f_// 如果 k_f_  为无效值,那么返回结果将 = min(dist_f_, dist2_f_),和 merge 结果相同// 如果 k_f_ 为有效值,那么将返回比 min(dist_f_, dist2_f_) 还要小的值,k_f_  越大,结果越小return mix(dist2_f_, dist_f_, h_f) - k_f_ * h_f * (1.0 - h_f);
}

从上面可以看出来,只有 k_f_ > abs(dist_f_ - dist2_f_) 时才会对结果进行操作,如果传入的 dist_f_ 和 dist2_f_ 结果相差不大,那么就会小于 k_f_ ,从而让两个物体的中间位置返回的值更大。

抵消

两者在运动过程中的重合部分消失了,这就是抵消效果:

float merge_exclude(float dist_f_, float dist2_f_) {// 如果 dist_f_ < 0,dist2_f_ > 0  例 dist_f_ = -2  dist2_f_ = 6, r = -2, 例 dist_f_ = -2  dist2_f_ = 3, r = -2// 如果 dist_f_ > 0,dist2_f_ < 0  例 dist_f_ = 2  dist2_f_ = -6, r = -6, 例 dist_f_ = -2  dist2_f_ = 3, r = -2// 如果 dist_f_ > 0,dist2_f_ > 0  例 dist_f_ = 2  dist2_f_ = 6, r = 2, 例 dist_f_ = 5  dist2_f_ = 3, r = 3// 如果 dist_f_ < 0,dist2_f_ < 0  例 dist_f_ = -2  dist2_f_ = -3, r = 4, 例 dist_f_ = -3  dist2_f_ = -2, r = 4// 所以最终结果只会将 dist_f_ < 0 && dist2_f_ < 0 的值变成 > 0 的值return min(max(-dist_f_, dist2_f_), max(-dist2_f_, dist_f_));
}

最终的目的也就是将 dist_f_ < 0 && dist2_f_ < 0 的值变成 > 0 的值,这样就会得到在物体外部,也就是一个正数,从而实现抵消效果。

减去

“减去”的效果和字面意思一样,减去另一个物体的重合的部分,当然也不会展示减去的物体,否则就变成了抵消效果了:

float substract(float dist_f_, float dist2_f_) {// dist_f_ < 0, dist2_f_ > 0  例 dist_f_ = -2, dist2_f_ = 3,r = 3, 例 dist_f_ = -2, dist2_f_ = 1,r = 2, 则值 > 0// dist_f_ > 0, dist2_f_ < 0  例 dist_f_ = 2, dist2_f_ = -1,r = -1, 例 dist_f_ = 2, dist2_f_ = -5,r = -2, 则值 < 0 // dist_f_ > 0, dist2_f_ > 0  例 dist_f_ = 1, dist2_f_ = 2,r = 2, 例 dist_f_ = 2, dist2_f_ = 1,r = 1, 则值 > 0 // dist_f_ < 0, dist2_f_ < 0  例 dist_f_ = -2, dist2_f_ = -3,r = 4, 例 dist_f_ = -2, dist2_f_ = -1,r = 4, 则值 > 0// 所以最终结果只会展示 dist2_f_, 且 dist_f_ 和 dist2_f_ 重合时不会展示return max(-dist_f_, dist2_f_);
}

通过上面的举栗,可以看出只有 dist_f_ > 0 && dist2_f_ < 0 返回的值 < 0,而其他条件结果都 > 0。

  • dist_f_ > 0,dist2_f_ < 0 返回 < 0 代表了渲染点不在第一个物体内且在第二个物体内才展示

  • 而 dist_f_ > 0, dist2_f_ < 0 返回 > 0 就代表了渲染点同时在两个物体内,也就是抵消效果

描边

除了通过距离场实现不同的展示效果外,我们还可以利用距离场进行混合,来实现物体的描边。只需要一句代码搞定它:

  • output_v4:片段着色器输出的颜色

  • float dist_f:距离场

  • vec4 color_v4:描边颜色

  • float width_f:描边宽度

output_v4 = mix(output_v4, color_v4, abs(clamp(dist_f - width_f, 0.0, 1.0) - clamp(dist_f, 0.0, 1.0)));

从上面的代码可以看出来,dist_f 的有效值是 (0~ 1.0 + width_f),所以会在此范围内通过 clamp - clamp 返回一个负数,abs 将其转换为正数,再通过 mix 混合,就得到了物体边缘的混合颜色。

内/外发光

外发光

宝具:王之财宝! 让我来闪瞎你的眼(此函数参考原作者 AO 函数改写):

  • float dist_f:距离场

  • vec4 color_v4_:渲染点的颜色

  • vec4 input_color_v4_:外发光颜色

  • float radius_f_:外发光半径

vec4 outer_glow(float dist_f_, vec4 color_v4_, vec4 input_color_v4_, float radius_f_) {// dist_f_ > radius_f_ 结果为 0// dist_f_ < 0 结果为 1// dist_f_ > 0 && dist_f_ < radius_f_ 则 dist_f_ 越大 a_f 越小,范围 0 ~ 1float a_f = abs(clamp(dist_f_ / radius_f_, 0.0, 1.0) - 1.0);// pow:平滑 a_f// max and min:防止在物体内部渲染float b_f = min(max(0.0, dist_f_), pow(a_f, 5.0));return color_v4_ + input_color_v4_ * b_f;
}

dist_f_ 的有效值范围是( 0 ~ radius )。

  • 如果 dist_f_ > radius_f_ :

a_f = 0;

b_f = min(max(0.0, dist_f_), 0) = 0;

返回值就为 color_v4_,此时为无效值。

  • 如果 dist_f_ < 0 :

a_f = 1;

b_f = min(max(0.0, dist_f_), 1) = 0;

返回值就为 color_v4_,此时为无效值。

内发光

内发光效果的实现根据上面的外发光改写:

vec4 inner_glow(float dist_f_, vec4 color_v4_, vec4 input_color_v4_, float radius_f_) {// (dist_f_ + radius_f_) > radius_f_ 结果为1// (dist_f_ + radius_f_) < 0 结果为0// (dist_f_ + radius_f_) > 0 && (dist_f_ + radius_f_) < radius_f_ 则 dist_f_ 越大 a_f 越大,范围 0 ~ 1float a_f = clamp((dist_f_ + radius_f_) / radius_f_, 0.0, 1.0);// pow:平滑 a_f// 1.0+:在物体内渲染// max(1.0, sign(dist_f_) * -:dist_f_ < 0 时返回 -1,dist_f_ == 0 返回 0,dist_f_ > 0 返回 1,所以有效值只在物体内部float b_f = 1.0 - max(1.0, sign(dist_f_) * -(1.0 + pow(a_f, 5.0)));return color_v4_ + input_color_v4_ * b_f;
}
  • 如果 (dist_f_ + radius_f_) > radius_f_ :

a_f = 1.0;

b_f = 1.0 - max(1.0, -2.0) = 0;

返回值就为 color_v4_,此时为无效值。

  • 如果 (dist_f_ + radius_f_) < 0 :

a_f = 0.0;

b_f = 1.0 - max(1.0, 1.0) = 0;

返回值就为 color_v4_,此时为无效值。

由于 dist_f 越往物体内部越小,所以也会导致 a_f 也是也是如此,所以最后 1.0 - max。

阴影

硬阴影

注:残留像素是录屏软件的关系

什么是硬阴影?边缘没有过渡的阴影便是硬阴影。我们的 SDF 不仅可以同来生成各种图形,还可以做阴影!

硬阴影的实现原理是:从渲染点出发到光源点,依次步进安全距离(SDF 距离场,代表这个范围不会触碰到物体),如果距离场 < 0,则代表碰到了物体,返回 0,再把我们的光源的 color *= 返回值,就得到了阴影。

直接上代码:

  • vec2 render_v2_ 渲染点

  • vec2 light_v2_ 光源点

float shadow(vec2 render_v2_, vec2 light_v2_) {// 当前渲染位置到光源位置的方向向量vec2 render_to_light_dir_v2 = normalize(light_v2_ - render_v2_);// 渲染位置至光源位置距离float render_to_light_dist_f = length(render_v2_ - light_v2_);// 行走距离float travel_dist_f = 0.01;for (int k_i = 0; k_i < max_shadow_step; ++k_i) {    // 渲染点到场景的距离float dist_f = scene_dist(render_v2_ + render_to_light_dir_v2 * travel_dist_f);// 小于0表示在物体内部if (dist_f < 0.0) {return 0.0;}// abs:避免往回走// max 避免渲染点距离物理表面过近导致极小耗尽遍历次数,所以有可能会跳过物体距离小于1.0的阴影绘制travel_dist_f += max(1.0, abs(dist_f));// travel_dist_f += abs(dist_f); 精确的阴影// 渲染点的距离超过光源点if (travel_dist_f > render_to_light_dist_f) {return 1.0;}}return 0.0;}

软阴影

图1

图2

相较硬阴影,软阴影更加有真实感。目前我了解的 SDF 实现软阴影目前大概是两种,一种是 iq 大神和 games202 里面提到的公式,但是效果并不好,在靠近物体时会产生弯曲的软阴影(上图1);而本文将参考 Shadertoy 上另一位大神的代码去实现,效果非常好(上图2)。

先上代码:

float shadow(vec2 render_v2_, vec2 light_v2_, float hard_f_) {// 当前渲染位置到光源位置的方向向量vec2 render_to_light_dir_v2 = normalize(light_v2_ - render_v2_);// 渲染位置至光源位置距离float render_to_light_dist_f = length(render_v2_ - light_v2_);// 可见光的一部分,从一个半径开始(最后添加下半部分);float brightness_f = hard_f_ * render_to_light_dist_f;// 行走距离float travel_dist_f = 0.01;for (int k_i = 0; k_i < max_shadow_step; ++k_i) {    // 当前位置到场景的距离float dist_f = scene_dist(render_v2_ + render_to_light_dir_v2 * travel_dist_f);// 渲染点在物体内部if (dist_f < -hard_f_) {return 0.0;}// dist_f 不变,brightness_f 越小,在越靠近光源和物体时 brightness_f 越小brightness_f = min(brightness_f, dist_f / travel_dist_f);// max 避免渲染点距离物理表面过近导致极小耗尽遍历次数,所以有可能会跳过物体距离小于1.0的阴影绘制// abs 避免朝回走travel_dist_f += max(1.0, abs(dist_f));// 渲染点的距离超过光源点if (travel_dist_f > render_to_light_dist_f) {break;}}// brightness_f * render_to_light_dist_f 根据距离平滑, 离光源越近越小,消除波纹线// 放大阴影,hard_f 越大结果越小则阴影越大, hard_f_ / (2.0 * hard_f_) 使结果趋近于0.5,用于平滑过渡brightness_f = clamp((brightness_f * render_to_light_dist_f + hard_f_) / (2.0 * hard_f_), 0.0, 1.0);brightness_f = smoothstep(0.0, 1.0, brightness_f);return brightness_f;
}

这个实现方式的原理是:从渲染点出发到光源点,依次步进安全距离(SDF距离场,代表这个范围不会触碰到物体),如果距离场 < -hard_f_ 则返回 0,为什么是 -hard_f_ ,因为我们要用物体表面往内 hard_f_ 的距离来绘制阴影,这样软阴影就可以过渡到硬阴影的范围内,看起来更真实。

具体实现方式可以看注释,个人的理解都在注释里,大家可以多实验实验。


本文 Demo 放在 Gitee 仓库:

https://gitee.com/muzzik/mk_sdf_shadow

点击文末【阅读原文】前往论坛专贴查看完整内容,欢迎大家一起交流谈论!

参考资料

[1] 更多 SDF 图形公式

https://www.shadertoy.com/view/4dfXDn

[2] 图形公式原理讲解

https://blog.csdn.net/qq_41368247/article/details/106194092

[3] shadertoy 2D 软阴影实现

https://www.shadertoy.com/view/4dfXDn

[4] 软阴影和硬阴影

https://www.ronja-tutorials.com/post/037-2d-shadows/

往期精彩

SDF 还能这样用?Cocos Creator 基于 SDF 实现多种 Shader 特效相关推荐

  1. Cocos Creator 基于 Spine 动画的 AVATAR 换装系统优化

    很多游戏开发团队都正在使用 Spine 动画软件来制作人物 AVATAR 动画.今天,玩吧技术专家组的红孩儿将以玩吧 APP 中的游戏<噜噜喵>为例,同大家分享基于 Spine 动画的 A ...

  2. Cocos Creator基于热更新的分包方案

    cocos 的热更新是基于对比本来文件列表和远程文件列表的md5实现的,如果有多个远程资源库,就可以拿来作为分包方案.大概流程是这样的: 一 确定分包策略 首先是,策划要根据一定的策略,将动态加载的资 ...

  3. Cocos 篇:基于 Cocos Creator v1.9,开始 Hello World 。。。

    前言 身体好,才是真的好~ 从此之后,要会生活,努力活出自己想要的样子~!!! Enmmm,LZ 也是小白一枚,初入贵行,还望手下留情~ 本篇主要作用,或者说定位在于和 LZ 一样得小白,希望多多交流 ...

  4. 不写代码不建模!万字长文带你在 Cocos Creator 中零代码搭建 3D 户外场景

    点击文末[阅读原文]在线预览最终效果. 对于接触过 3D 游戏美术资源的程序来说,可能心中都出现过类似这样的独白: 这些 3D 模型是怎么用的,为什么我导入的时候老是报错? 这些花花绿绿的图片是干什么 ...

  5. Cocos Creator 性能优化:DrawCall

    Cocos Creator 性能优化:DrawCall(全面!) title: Cocos Creator 性能优化:DrawCall 前言 在游戏开发中,DrawCall 作为一个非常重要的性能指标 ...

  6. Cocos Creator性能优化---DrawCall

    前言 在游戏开发中,DrawCall 作为一个非常重要的性能指标,直接影响游戏的整体性能表现. 无论是 Cocos Creator.Unity.Unreal 还是其他游戏引擎,只要说到游戏性能优化,D ...

  7. 麒麟子Cocos Creator 3D研究笔记九:初尝Shader并实现边缘光(RimLight)

    零.先看一些图 图1:边缘光因子检查 图2:黄色,一般用于霸体效果 图3:红色,一般用于特殊技能特效 图4:白色,一般用于受击效果 图5:绿色,一般用于人物,NPC选中时高亮 看着群里的小伙伴们都很热 ...

  8. 基于Cocos Creator的水果忍者游戏

    基于cocos creater的水果忍者游戏 项目介绍 主界面 游戏界面 游戏详情界面 水果运动界面 刀片切割界面 游戏结束界面 下载方式 项目介绍 互联网技术不断革新,用户对于应用的要求在不断提高, ...

  9. 一款非常好玩的小程序游戏推荐给大家,基于cocos creator引擎开发的

    一款非常好玩的小程序游戏推荐给大家,基于cocos creator引擎开发的,排名包含微信好友排行榜,全球榜,快邀请好友,一起来玩吧.

最新文章

  1. Cracking the coding interview--Q2.2
  2. extjs 计算日期之和_财报分析之利润表的重构(2)——以医药制造行业为例
  3. java自动生成代码原理_原来这就是Java代码生成器的原理啊,太简单了
  4. 帆软报表(finereport) 复选框多值查询
  5. 判断设备是否是iphone5 及对iphone5 屏幕的适应
  6. 缺乏运动 七种病早早光临
  7. spring配置jdbc连接oracle,mysql,sqlserver
  8. JavaScript错误和异常
  9. 软件测试过程中的度量与分析
  10. (转)一个百倍股的坠落:那个曾经满大街的ESPRIT是如何衰败的
  11. 密码编码学之公钥密码学及RSA
  12. DEDECMS短信宝短信插件
  13. 全平台视频转GIF软件对比与推荐(iOS/安卓/Windows/Mac)
  14. 相似图片搜索算法介绍
  15. (二十六)Storm常见错误及处理方法
  16. java web前端邮件,JavaMail:在Web应用上完整接收、解析复杂邮件(转)
  17. Protocol Buffer 基础(Python 版)
  18. VS2010的aspx文件中的html代码的格式化整理的方法
  19. 每日辣评:快手和抖音、爱奇艺巨亏、搜狗输入法变声功能、贝佐斯
  20. 怎么视频提取音频文件?分享这3种简单实用的提取方法

热门文章

  1. js打开服务器缓存文件夹路径,浅谈微信页面入口文件被缓存解决方案
  2. 信息系统项目管理:质量管理
  3. 递归求2+2+22+222+............
  4. 第五届蓝帽杯初赛:冬奥会_is_coming
  5. 创意发明:带分频整形的单片机频率计(1Hz—20MHz)源程序,仿真与设计论文等全套资料
  6. Android学习方向
  7. OpenCV中文文档
  8. 佛山市南海技师学校计算机类,2019年佛山南海信息技术学校招生录取分数线
  9. 词频统计,中文分词FMM,BMM博客
  10. 数据挖掘与机器学习——离群点检测之孤立森林(isolate forest)