这类效果实际上是基于边缘检测(参考 )的进一步应用,边缘检测可以简单理解为检测灰度的“跳跃”程度,如果某个区域发生灰度“突变”,就会将其判定为边缘。
不过这一节我们讨论的并不是基于传统边缘检测的描边与发光效果,而是仅仅基于 Alpha 值的边缘测试以及描边和发光。
1. 直接轮廓描边
可以通过一些简单的办法直接获取描边的轮廓,举例:
[*]将原 texture 放大,再减去原 texture。
[*]将原 texture 朝八个方向移动若干像素并叠加,再减去原 texture。
这样的办法可以相对灵活地对轮廓进行各类着色操作,但缺点也很显著,一方面是性能问题,另一方面圆滑度也不一定理想。
2. 检测 Alpha 值
一种自然的想法是直接比较周围像素的 Alpha 值,对于存在周围像素透明度低于阈值的点就判定为边界点,但这样会带来如下麻烦:
[*]阈值应该取多少?
[*]对于半透明的边界点,能直接作不透明描边吗?
[*]对于描边宽度较大的情形,能保证平滑度吗?
对于前两个问题,可以强行将半透明的边界点改为不透明,由此阈值取 0 即可(by WSW);对于后一个问题,就没什么好办法了。
因此我们需要换个新的思路(想想卷积),下面我们给出一个类似边缘检测的办法:
[*]求像素点周围像素 Alpha 的平均值((1/n^2)E(n) 卷积核),记为 α
[*]内发光:采用像素点的原透明度(记为 α0),并用 α 值作为系数叠加某种发光色,即:
color.rgb += glow_color * α
[*]外发光:叠加透明度为 α 的发光色,同时使用 (1 - α0) 防止内部发光,即:
color += (1.0 - α0) * vec4(glow_color, α)
[*]描边:在某种发光模式的基础上,将 α 值用 smoothstep 等插值办法处理一下即可。
注:使用 (1/n^2)E(n) 卷积核显然不是唯一的选择,仿照高斯模糊,我们也可以尝试使用特定的加权平均等办法。
注 2:我前段时间转写的 Glow Outline 跟这种方法其实差不多,两层 for 循环实际上就是在作卷积核,感兴趣的可以去看看。 本帖最后由 dasasdhba 于 2023-2-15 11:53 编辑
风格化
在 中简要介绍了锐化和浮雕的实现,它们都是直接基于卷积实现,下面介绍几个常见的直接修改颜色的风格化效果,原理将不作详解。
1. 单色 Monochrome
使用灰度公式,令新的 RGB 都等于:
0.299*R + 0.587*G + 0.114*B
即可
2. 老照片 Old Photo
R = 0.393*R + 0.769*G + 0.189*B
G = 0.349*R + 0.686*G + 0.168*B
B = 0.272*R + 0.534*G + 0.131*B
3. 铅笔画 Pencil
基于边缘检测和单色效果的综合运用
参考源代码:https://github.com/nightyan/Shad ... aders/Pencil.shader
下作简单讲解
首先作简单边缘检测,在上述源代码中采用了卷积核:
-0.5, -1.0, 0.0
-1.0, 0.0, 1.0
0.0, 1.0, 0.5
接下来我们对 RGB 作单色处理。
不过需要注意,边缘检测实质上在计算颜色的“跳跃程度”,因此结果可能为负。之前已经提到,对此我们可以采取 clamp 方式,但容易预见的是这样我们会丢失很多细节。而如果是直接的视觉呈现,取绝对值也不失为不错的选择,因此我们接下来对负值取绝对值。
到这里就差不多了,只是铅笔画应该是轮廓为黑,背景为白,所以我们最后再反转一下即可。
图像变形
注:本节内容为原创。
下面我们简要介绍图像变形类 Shader。
需要注意,由于 Fragment Shader 的输入区域与输出区域必然相同,对于一些需要将原图像放大至区域外的情况,往往需要人为处理 Texture 使其留空。
1. 输出区域变形
我们熟知 Fragment Shader 处理图像依赖于取值范围为 ^2 的 UV 坐标系,这是一个简单的正方形区域。现假设我们需要将图像变形到一个新的区域 A,这就需要找到一映射 f:R^2→R^2,使得 f(^2)=A。
在实际应用中,我们只需要找到 f^(-1),然后根据新像的 UV,由 f^(-1) 求出原像 UV 即可。
不难看出,f 的选择往往会非常之多,且不同的选择最后得到的图像效果也往往大相径庭。而一般而言,对于任意给定的 A,并没有什么通用的好办法去处理,下面介绍两类较为局限的办法。
(1) 正交放缩法
这种办法最为简单直接,但适用场合与效果也非常局限,其原理简单来说就是对于一个点,我们直接从两个正交的方向将其压缩到 A 的边界内。下面我们简单讨论水平方向放缩,其他方向同理。
我们将 A 的边界(∂A)写为方程形式 y = g(x),若任给 y0,方程 y0 = g(x) 都有两个解 x1<x2,那么取 f 为将 U 从 直接压缩至 即可。
可以预见,尽管这种方案能够快速达到变形效果,但最后的图像效果仅仅只是拉伸变形,且对于上述提到的方程有超过两个解的情况难以处理。
(2) 坐标变换法
若 A 容易用两个参数 (x, y) 来表示,直接考虑 f^(-1) 可能会更方便,这是因为 f^(-1) 的像是简单的 1*1 正方形,因此我们作简单的坐标变换就能够得到 f^(-1)。
事实上,在 A 可由 ^2 经过线性变换得到时,这种办法将会非常有效。而对于非线性的情况,往往不容易处理,举例来说,我们知道半径为 R 的圆域有极坐标表示:
(r, θ), r∈, θ∈
假定原点在中心,它们是容易求出的:
r = distance(uv, vec2(0.5))
θ = arctan((uv.y - 0.5)/(uv.x - 0.5))
接下来我们只需要将 (r, θ) 压缩置 ^2,也就是:
f^(-1)(r, θ) = (r/R, θ/(2π))
这样我们就将图像映到了圆域,但是效果会与第一种方法大不相同,因为我们直接将极坐标用于了直角坐标;而为了得到类似第一种方法的效果,我们可以将 (r, θ) 首先映射到原像中相同原点的极坐标上,然后再将其转化为直角坐标:
(r, θ)→(r/R*√2/2, θ)
然后取:
(x, y) = vec2(0.5) + r*vec2(cosθ, sinθ)
这样可能超界,超界部分可采用第一种办法处理。
以上两种办法可以勉强用于处理任意给定的 A,然而事实上,我们需要的 A 往往具有特殊的性质(比如对于圆而言,由于其特殊的对称性,事实上任给一个点,我们都能以其到圆心的方向将其压缩至目标圆内),从而可以特殊化处理。这里我们不打算介绍太多特殊情形,具体问题具体分析往往是最好的选择。
2. 三维投影变形
三维投影变形即将二维图像折叠至三维空间,再将结果投影至二维的变形方法。区别于输出区域变形,为了表现出三维的视觉效果,我们还需要适当地调整明暗以体现光照。
其大体思路是以垂直于 UV 平面的方向再建立一坐标轴 W,不妨取屏幕向里方向为正,然后采用下列步骤:
a. 将 UV 空间折叠至 UVW 空间,这需要找到相关映射 f
b. 取新 UVW 空间的 UV 投影,这是容易的
c. 根据新 UVW 空间上的 W 值为颜色叠加明暗变化(参考 )
下面举两个 a 中相关映射 f 的简单例子:
(1) 平面旋转
将 UV 平面绕 U 轴或 V 轴旋转,以绕 U 轴为例,只需要给 VW 坐标乘上旋转矩阵(需要注意,W 初值应为 0)即可。
此外,亦可以作部分旋转(即折叠操作),比如以 U = 0.5 为转轴,旋转 U > 0.5 的部分。
(2) 圆柱面
以竖直方向的圆柱为例,固定 V,我们只需要将 UW 映射到 UW 平面的圆上。已知周长为 1,由此不难计算得半径为 R=1/(2π),不妨取圆心为 P(0.5, *, 1/(2π))。
接下来利用极坐标,将 映射至 ,考虑到圆心的位置,我们希望将 U = 0.5 映射到 π,这恰好是:θ = U*2π
(如果圆心不取在中心位置,则此处需要作平移处理)
进而容易由极坐标求出新的 UW:
(U, W) = P + R*(cosθ, sinθ)
如你所见,这个半径太小了,出于视觉效果的考虑,我们一般不将 UV 平面卷成圆柱,而是直接给定一个弧度 α 或半径 R,利用弧长 = 1 去计算极角 θ 的取值范围,从而类似处理。
3. 局部图像变形
此类变形并不改变图像输出区域,而是直接对图像作变形,扭曲等操作。这种变形效果往往是将同一类操作不均匀地作用于图像得到的,下面介绍一些例子:
(1) 不均匀模糊(参考 )
将高斯模糊应用于图像,但是越接近中心的点模糊半径越大。
(2) 不均匀缩放(输出区域变形)
将图像放大,但是越接近中心的点放大倍率越大。
(3) 不均匀旋转
将图片旋转,但是越接近中心的点旋转角度越小。(漩涡效果)
相信不难从这几个例子中看出此类方法的共性,而具体实现在此就不作过多介绍了。
本帖最后由 dasasdhba 于 2023-6-27 14:39 编辑
图像放大算法
下面我们介绍游戏常用的放大算法
再重复一遍,Fragment Shader 不能改变 Texture 本身尺寸,为了得到放大后的结果,一般采取与 Vertex Shader 协助的办法(参考 ),因为无论是人为留空还是提前临近处理都会带来不必要的麻烦。
1. HQnx
2003 年就被提出的比较老的算法,实现的不算很漂亮,甚至找不到什么原理解析,下面给出我的个人理解。
HQnx 用于像素风格图像的放大,其中横线和竖线的放大是容易的,关键在于对斜线放大的处理。
(1). 检测斜线
我们简单地考虑每个像素周围 3*3 的区域,不考虑镜像,斜线可分为两类:
a. 双边线
■□□
□■□
□□■
b. 单边线
■■■
□■■
□□■
若考虑镜像则有 6 类。
为了检测斜线,我们使用特定的 3*3 卷积核对灰度(参考 )作卷积,这需要对上述黑方格■部分的灰度求均值并与原像素灰度比较,在差距不超过预设的某个阈值时就将其判定为某种类型的斜线。
(2). 使用模板
对于每种类型的斜线,我们将其处理为预先准备好的放大模板。如上述 a 情形可以使用模板:
■■▲□□
■■■▲□
▲■■■▲
□▲■■■
□□▲■■
其中▲表示半透明混合(参考 )
然而,在具体实现上,主流的做法是采用 YUV 的方法获取权重,并利用 LUT 查表得到最后的 filter 值,可参考:https://github.com/CrossVR/hqx-shader/blob/master/glsl/hq2x.glsl
2. xBRZ
2011 年被提出的算法,基本思路是首先作边缘检测,然后判定每一个角落的像素是属于直角还是圆角,并基于这一点采取不同的插值达到较为合理的效果。
因此具体如何边缘检测和判定角落类型会比较复杂,好在这个算法比较热门,已经有比较好的具体解析可供参考:https://www.luogu.com.cn/blog/ud2/xbrz-interpolation-explained
3. Super SAI
众所周知的 MF 的 Texture 采用的放大算法,于 1999 年即被提出,这个东西其实挺冷门的,但考虑到圈内应用广泛,我将其从 C# 版本(来自 imageresizer,这玩意其实是开源的)转写为了 GLSL 版本(可在 The book of Shader 网站上测试),并附带了相关注释,可供参考(但其实我也看不懂):
#ifdef GL_ES
precision mediump float;
#endif
uniform sampler2D u_tex0;
uniform vec2 u_tex0Resolution;
uniform vec2 u_resolution;
// 用于 rgb 转 yuv
const mat3 yuv_matrix = mat3(
0.299, -0.169, 0.5,
0.587, -0.331, -0.419,
0.114, 0.5, -0.081);
const vec3 yuv_threshold = vec3(48.0/255.0, 7.0/255.0, 6.0/255.0);
const vec3 yuv_offset = vec3(0, 0.5, 0.5);
// 通过 yuv 判断两个颜色的相似度
bool like(vec4 c1, vec4 c2) {
vec3 a = yuv_matrix * c1.rgb;
vec3 b = yuv_matrix * c2.rgb;
bvec3 res = greaterThan(abs((a + yuv_offset) - (b + yuv_offset)), yuv_threshold);
return !(res.x || res.y || res.z);
}
// 一个莫名其妙的函数
// a 与 c d 相似返回 -1
// a 不与 c d 相似且 b 与 c d 相似返回 1
// 其他情况返回 0
float cond(vec4 a, vec4 b, vec4 c, vec4 d) {
bool ac = like(a, c);
bool ad = like(a, d);
bool bc = like(b, c);
bool bd = like(b, d);
float x = float(ac) + float(ad);
float y = float(bc && !ac) + float(bd && !ad);
return float(x <= 1.0) - float(y <= 1.0);
}
void main () {
vec2 uv = gl_FragCoord.xy/u_resolution.xy;
vec2 unit = 1.0/u_tex0Resolution;
// c0c1c2d3
// c3c4c5d4
// c6c7c8d5
// d0d1d2d6
// 其中 c4 在当前 UV 位置
vec4 c0 = texture2D(u_tex0, uv + vec2(-1.0, -1.0)*unit);
vec4 c1 = texture2D(u_tex0, uv + vec2( 0.0, -1.0)*unit);
vec4 c2 = texture2D(u_tex0, uv + vec2( 1.0, -1.0)*unit);
vec4 d3 = texture2D(u_tex0, uv + vec2( 2.0, -1.0)*unit);
vec4 c3 = texture2D(u_tex0, uv + vec2(-1.0,0.0)*unit);
vec4 c4 = texture2D(u_tex0, uv + vec2( 0.0,0.0)*unit);
vec4 c5 = texture2D(u_tex0, uv + vec2( 1.0,0.0)*unit);
vec4 d4 = texture2D(u_tex0, uv + vec2( 2.0,0.0)*unit);
vec4 c6 = texture2D(u_tex0, uv + vec2(-1.0,1.0)*unit);
vec4 c7 = texture2D(u_tex0, uv + vec2( 0.0,1.0)*unit);
vec4 c8 = texture2D(u_tex0, uv + vec2( 1.0,1.0)*unit);
vec4 d5 = texture2D(u_tex0, uv + vec2( 2.0,1.0)*unit);
vec4 d0 = texture2D(u_tex0, uv + vec2(-1.0,2.0)*unit);
vec4 d1 = texture2D(u_tex0, uv + vec2( 0.0,2.0)*unit);
vec4 d2 = texture2D(u_tex0, uv + vec2( 1.0,2.0)*unit);
vec4 d6 = texture2D(u_tex0, uv + vec2( 2.0,2.0)*unit);
// e00 e01
// e10 e11
// 放大后输出的四个像素的颜色
vec4 e00 = c4;
vec4 e01 = c4;
vec4 e10;
vec4 e11 = c4;
// 处理 e01 与 e11
if (like(c7, c5) && !like(c4, c8)) {
vec4 c57 = mix(c7, c5, 0.5);
e11 = c57;
e01 = c57;
} else if (like(c4, c8) && !like(c7, c5)) {
// pass
} else if (like(c4, c8) && like(c7, c5)) {
vec4 c57 = mix(c7, c5, 0.5);
vec4 c48 = mix(c4, c8, 0.5);
// 谁能告诉我它在干嘛???
float conc = 0.0;
conc += cond(c57, c48, c6, d1);
conc += cond(c57, c48, c3, c1);
conc += cond(c57, c48, d2, d5);
conc += cond(c57, c48, c2, d4);
if (conc > 0.0) {
e11 = c57;
e01 = c57;
} else if (conc == 0.0) {
e11 = mix(c48, c57, 0.5);
e01 = e11;
}
} else {
// 地狱绘图.jpg
if (like(c8, c5) && like(c8, d1) && !like(c7, d2) && !like(c8, d0)) {
e11 = mix((c8+c5+d1)/3.0, c7, 0.75);
} else if (like(c7, c4) && like(c7, d2) && !like(c7,d6), !like(c8, d1)) {
e11 = mix((c7+c4+d2)/3.0, c8, 0.75);
} else {
e11 = mix(c7, c8, 0.5);
}
if (like(c5, c8) && like(c5, c1) && !like(c5, c0) && !like(c4, c2)) {
e01 = mix((c5+c8+c1)/3.0, c4, 0.75);
} else if (like(c4, c7) && like(c4, c2) && !like(c5, c1) && !like(c4, d3)) {
e01 = mix((c4+c7+c2)/3.0, c5, 0.75);
} else {
e01 = mix(c4, c5, 0.5);
}
}
// 处理 e10
if (like(c4, c8) && like(c4, c3) && !like(c7, c5) && !like(c4, d2)) {
e10 = mix(c7, (c4+c8+c3)/3.0, 0.5);
} else if (like(c4, c6) && like(c4, c5) && !like(c7, c3) && !like(c4 ,d0)) {
e10 = mix(c7, (c4+c6+c5)/3.0, 0.5);
} else {
e10 = c7;
}
// 处理 e00
if (like(c7, c5) && like(c7, c6) && !like(c4, c8) && !like(c7, c2)) {
e00 = mix((c7+c5+c6)/3.0, c4, 0.5);
} else if (like(c7, c3) && like(c7, c8) && !like(c4, c6) && !like(c7, c0)) {
e00 = mix((c7+c3+c8)/3.0, c4, 0.5);
}
// 混和结果
vec2 pos = floor(fract(uv * u_tex0Resolution) * 2.0);
vec4 color = mix(
mix(e00, e01, pos.x),
mix(e10, e11, pos.x),
pos.y);
gl_FragColor = color;
}
注:此为源代码转写,没有优化性能,比如 YUV 转换这里很明显多算了很多次 [插曲 4]
那么图像处理类 Shader 暂时告一段落,此类 Shader 更多地与图形学内容相关,故也是一个大坑。
在此推荐一个开源的图像处理相关算法库(Java):http://www.jhlabs.com/index.html
可能会有一定的参考价值。
接下来我们将进入下一个话题:模拟(Simulation)
遗憾的是,该话题仍然没有成体系的资料,故更新速度估计会跟图像处理类一样慢(
页:
1
[2]