后处理(Post-processing), 是针对原有的游戏画面进行算法加工, 达到提升画面质量或增强画面效果的技术, 可通过着色器 Shader 程序实现.
概述
变形特效是处理和增强画面效果的一类后处理技术, 经常被应用在各类相机短视频 App 特效中, 如美颜瘦身, 哈哈镜特效.
美颜相机的变形特效
本文主要从各类美颜相机中梳理了以下几种常用的变形特效:
局部扭曲 (twirl effect)
局部膨胀 (inflate effect)
任意方向挤压 (pinch effect)
其中, 扭曲可用在眼睛的局部旋转, 膨胀可以用于大眼, 挤压 / 拉伸可用于脸部塑性和瘦脸等. 如何通过着色器 Shader 实现这些变形, 是本文讨论的重点.(ps: 着急预览代码的童鞋见文末)
变形技原理
虽然变形的效果千奇百怪, 但它们往往离不开这三个要素: 变形位置, 影响范围和变形程度.
变形 Shader 实现人物尬舞
因此它在 Shader 中的实现, 就是通过构造一个变形函数, 将传入原始 uv 坐标, 变形的位置, 范围 range 和程度 strength, 经过计算后生成变形后的采样坐标, 代码如下:
- #iChannel0 "src/assets/texture/joker.png"
- vec2 deform(vec2 uv, vec2 center, float range, float strength) {
- // TODO: 变形处理
- return uv;
- }
- void mainImage(out vec4 fragColor, vec2 coord) {
- vec2 uv = coord / iResolution.xy;
- vec2 mouse = iMouse.xy / iResolution.xy;
- uv = deform(uv, mouse, .5, .5);
- vec3 color = texture(iChannel0, uv).rgb;
- fragColor.rgb = color;
- }
本文着色器代码采用 GLSL 规范, 遵循 Shader-Toy 的写法, 方便大家预览.
变形小技巧: 采样距离场变换
我们设置定点坐标 O, 任意点到点 O 距离为 dist, 以不同 dist 值为半径, 以点 O 为中心可形成无数个等距的采样圈, 它们被称为点 O 的距离场.
采样距离场
我们可以通过改变采样圈的大小, 位置, 进而改变纹理采样位置, 以实现膨胀 / 收缩, 挤压的变形效果.
vec2 deform(vec2 uv, vec2 center, float range, float strength) { float dist = distance(uv, center); vec2 direction = normalize(uv - center); dist = transform(dist, range, strength); // 改变采样圈半径 center = transform(center, dist, range, strength); // 改变采样圈中心位置 return center + dist * direction; }
这个技巧的应用先不急着说, 现在我们还是从简单的扭曲变形开始讲.
扭曲
扭曲效果类似旋涡形态, 特点是越靠近中心点旋转程度越剧烈, 我们可通过递减函数来表示离中心点距离 d 和对应旋转角度θ之间的关系.
如下图, 采用简单的一次函数θ = -A/R *d + A, 其中 A 表示扭曲中心的旋转角度, A 为正数则表示旋转方向为顺时针, 负数表示逆时针, R 表示扭曲的边界;
扭曲变形原理
如上图, 扭曲函数入参 A(中心旋转角 Angle)和 R(变形范围 Range)可以这么描述:
1)A 代表中心旋转角度, 绝对值越大, 扭曲程度更高;
2)A> 0 表示扭曲方向为顺时针, 反之 A<0 表示逆时针;
3)R 代表扭曲边界, 值越大, 影响范围越大.
扭曲动态效果
我们可以引入时间变量 time 动态改变 A 的值, 产生扭动特效, 如上图小丑扭跨效果, 具体 shader 代码如下:
#iChannel0 "src/assets/texture/joker.png" #define Range .3 #define Angle .5 #define SPEED 3. mat2 rotate(float a) // 旋转矩阵 { float s = sin(a); float c = cos(a); return mat2(c,-s,s,c); } vec2 twirl(vec2 uv, vec2 center, float range, float angle) { float d = distance(uv, center); uv -=center; // d = clamp(-angle/range * d + angle,0.,angle); // 线性方程 d = smoothstep(0., range, range-d) * angle; uv *= rotate(d); uv+=center; return uv; } void mainImage(out vec4 fragColor, vec2 coord) { vec2 uv = coord / iResolution.xy; vec2 mouse = iMouse.xy / iResolution.xy; float cTime = sin(iTime * SPEED); uv = twirl(uv, mouse, Range, Angle * cTime); vec4 color = texture(iChannel0, uv); fragColor = color; }
值得一提的是, 除了用线性方程表示扭曲关系, 还可以使用 smoothstep 方法, 相比 linear 线性函数, smoothstep 方法在扭曲边界处呈现更为平滑, 如下图.
lnear 和 smoothstep 扭曲方程效果对比
考虑到边界的平滑, 下面的变形方法也多会用 smoothstep 函数来替代线性方程.
膨胀 / 收缩
膨胀特点靠近膨胀中心的纹理被拉伸, 而靠近膨胀边界纹理被挤压, 这意味着在膨胀范围内, 以膨胀中心为距离场, 每个采样圈都应该比原先的半径更小, 并且圈间距由内到外逐渐扩大.
如下图右侧, 我们通过将等距的黑色采样圈映射到更内聚的红色采样圈, 使新采样圈之间的间距由内到外单调递增.
膨胀采样距离场变换
我们采样平滑递增函数 smoothstep 来通过采样圈半径 dist 计算出缩放值 scale:
上图的函数表明, 在靠近膨胀中心处, 采样圈缩放最明显, 缩放值最小(1 - S); 随着 dist 增大, 缩放值 scale 往 1 递增, 直至到达 R 边界范围后, scale 恒定为 1, 采样圈不再缩放.
float scale = (1.- S) + S * smoothstep(0.,1., dist / R); // 计算膨胀采样半径缩放值
于是我们得到上述采样半径缩放公式, 其中设定 Strength(0 < S < 1)代表膨胀程度.
对于膨胀距离场的变换过程, 很容易推断出, 要实现膨胀的反向效果收缩, 直接让 S 位于 [-1,0] 区间即可.
S 值对应膨胀收缩程度 Strength
如上图, 膨胀函数入参 S(变形程度 Strength)和 R(变形范围 Range)可这么描述:
1)当 S 在 [0,1] 区间时, 呈现膨胀效果, S 值越大, 膨胀的程度越高;
2)当 S 在 [-10] 区间时, 呈现收缩效果, S 值越小, 收缩程度越高;
3)R 代表变形的边界, 值越大时, 影响区域越大;
动态膨胀效果
我们可以引入时间变量 time 动态改变 Strength 的值, 模拟呼吸动画, 如上图小丑鼓肚子效果, 具体 shader 代码如下:
#iChannel0 "src/assets/texture/joker.png" #define SPEED 2. // 速度 #define RANGE .2 // 变形范围 #define Strength .5 * sin(iTime * SPEED) // 变形程度 vec2 inflate(vec2 uv, vec2 center, float range, float strength) { float dist = distance(uv , center); vec2 dir = normalize(uv - center); float scale = 1.-strength + strength * smoothstep(0., 1. ,dist / range); float newDist = dist * scale; return center + newDist * dir; } void mainImage(out vec4 fragColor, vec2 coord) { vec2 uv = coord / iResolution.xy; vec2 mouse = iMouse.xy / iResolution.xy; uv = inflate(uv, mouse, RANGE, Strength); vec3 color = texture(iChannel0, uv).rgb; fragColor.rgb = color; }
纵向 / 横向拉伸
原图 - 纵向拉伸 - 横向拉伸 - 膨胀
前面的膨胀是通过对距离场采样圈进行缩放实现的, 纵向 / 横向拉伸则是只对采样圈 x 轴或 y 轴进行缩放, 一般可用在美颜的 "长腿特效" 上.
横向拉伸距离场变换
可以发现横向拉伸距离场被变换为多个椭圆采样圈, 代码实现如下:
vec2 inflateX(vec2 uv, vec2 center, float radius, float strength) { // 前面代码跟膨胀实现一样 ... return center + vec2(newDist, dist) * dir; // 横向拉伸则 scale 只作用于想 x 轴 }
挤压
挤压一般会指明一个作用点和一个挤压方向, 它的特点是把作用点附近的纹理推到挤压终点位置.
如下图, 绿色作用点 P 作为挤压起点, 箭头为挤压向量 V, 其中向量方向指明挤压的方向, 向量长度 length(V)代表挤压的距离, 向量终点为挤压后的位置.
要实现纹理挤压, 就是让采样圈圆心往挤压向量 V 上偏移, 采样中心点应平移到点 P 的位置.
挤压采用距离场变换
随着采样圈的半径 dist 由内到外逐渐变大, 其变换后的圆心偏移量 offset 逐渐缩短, 我们可以用 - smoothstep 平滑递减函数处理采样圈半径 dist 与圈偏移量 offset 之间的关系.
公式:
offset = length(V) - length(V) * smoothstep(0, R, dist)
, 其中 R 表示挤压边界 range.
挤压动态效果
同样的, 我们引入时间变量 time 动态改变挤压向量的长度和方向, 可以实现抖动特效, 如上图小丑顶胯效果, 具体 shader 代码如下:
#iChannel0 "src/assets/texture/joker.png" #define RANGE .25 // 变形范围 #define PINCH_VECTOR vec2( sin(iTime * 10.), cos(iTime * 20.)) * .03 // 挤压向量 vec2 pinch(vec2 uv, vec2 targetPoint, vec2 vector, float range) { vec2 center = targetPoint + vector; float dist = distance(uv, targetPoint); vec2 point = targetPoint + smoothstep(0., 1., dist / range) * vector; return uv - center + point; } void mainImage(out vec4 fragColor, vec2 coord) { vec2 uv = coord / iResolution.xy; vec2 mouse = iMouse.xy / iResolution.xy; uv = pinch(uv, mouse, PINCH_VECTOR, RANGE); vec3 color = texture(iChannel0, uv).rgb; fragColor.rgb = color; }
总结
本文主要介绍三类局部变形 shader 的实现原理, 其中膨胀 / 收缩和挤压效果是通过采样距离场变换实现的, 前者变换的是采样圈大小, 后者变换的是采用圈位置.
除了上文的介绍的三种局部变形, 还有一些比较有趣的全局变形效果, 比如波浪特效(wave effect), 错位特效和镜像等, shader 实现比较容易, 就不多做介绍了.
波浪 - 错位 - 镜像
预览代码与效果
扭曲: https://www.shadertoy.com/view/slfGzN
膨胀 / 缩放: https://www.shadertoy.com/view/7lXGzN
挤压 / 拉伸: https://www.shadertoy.com/view/7tX3zN
参考资料:
glsl 基础变换: https://thebookofshaders.com/08/?lan=ch
Photoshop 挤压特效算法:
传送门
Y.one:webGL 进阶 -- 走进图形噪声
zhuanlan.zhihu.com
来源: https://zhuanlan.zhihu.com/p/374289735