Now Loading ...
-
Liquid Glass
Overview
1. 坐标系统设置
main 函数的前几行代码主要是为了建立一个以鼠标为中心的坐标系。
vec2 uv = fragTexCoord;
// 适配 Vulkan 的坐标系
vec2 mouse = ubo.iMouse.xy;
mouse.y = ubo.iResolution.y - mouse.y; // Shadertoy/OpenGL的原点在左下角,Vulkan在左上角,这里进行翻转
if (length(mouse) < 1.0) { // 如果鼠标坐标为(0,0)(通常是初始值),则将效果置于屏幕中心
mouse = ubo.iResolution.xy * 0.5;
}
// 将uv坐标的原点移动到鼠标位置,并进行归一化和放大
// m2 的范围大致在 [-1.0, 1.0] 附近,中心点为 (0,0)
vec2 m2 = (uv - mouse / ubo.iResolution.xy) * 2.0;
这段代码的核心是计算出 m2。它代表了当前像素相对于鼠标位置的向量。如果一个像素正好在鼠标上,它的 m2 值就是 (0, 0)。
2. 形状定义 (超椭圆)
通过一个数学公式定义了玻璃的形状。
// pow(|x|^p + |y|^p, 1/p) = r 是超椭圆公式
// 这里 p = 8.0,创建了一个带有圆角的矩形(Squircle)
float roundedBox = pow(abs(m2.x * ubo.iResolution.x / ubo.iResolution.y), 8.0) + pow(abs(m2.y), 8.0);
这是一个超椭圆(Superellipse)的变体公式:
\(|x/a|^p + |y/b|^p = 1\)
这里的 p 值是 8.0。当 $p > 2$ 时,图形会趋向于一个矩形,但边角是圆滑的。$p$ 越大,形状越接近完美的矩形。
ubo.iResolution.x / ubo.iResolution.y 是为了修正屏幕的宽高比,确保即使屏幕不是正方形,我们的“玻璃”看起来也是规则的,而不是被拉伸的。
roundedBox 的值在形状的中心最小,向边缘逐渐变大。在形状的轮廓线上,这个值约等于 1.0。
3. 区域分层 (Layers)
代码利用 roundedBox 的值,通过 clamp 函数计算出了三个不同的遮罩(mask),用于定义玻璃的不同部分:主体、内边框和外高光。
// rb1: 玻璃的主体区域,是一个实心遮罩
float rb1 = clamp((1.0 - roundedBox * 10000.0) * 8.0, 0.0, 1.0);
// rb2: 玻璃的内边框。通过两个 clamp 相减,得到一个细窄的环形区域
float rb2 = clamp((0.95 - roundedBox * 9500.0) * 16.0, 0.0, 1.0) - clamp(pow(0.9 - roundedBox * 9500.0, 1.0) * 16.0, 0.0, 1.0);
// rb3: 用于计算光照的另一个稍宽的环形区域
float rb3 = (clamp((1.5 - roundedBox * 11000.0) * 2.0, 0.0, 1.0) - clamp(pow(1.0 - roundedBox * 11000.0, 1.0) * 2.0, 0.0, 1.0));
这里的技巧是利用一个快速下降的函数 (C - roundedBox * K) 和 clamp 来精确地“切割”出不同的区域。rb1 是最内部的实体区域,rb2 和 rb3 则是通过相减制造出的窄环,用于后续的描边和高光。
4. 效果实现 (The Effect)
这是着色器的核心部分,只有在像素位于玻璃区域内时(transition > 0.0)才会执行。
// 使用 smoothstep 让 rb1 和 rb2 的组合边缘变得平滑,用于抗锯齿
float transition = smoothstep(0.0, 1.0, rb1 + rb2);
vec4 color;
if (transition > 0.0) {
// 1. 透镜效果 (Lensing/Refraction)
vec2 lens = ((uv - 0.5) * 1.0 * (1.0 - roundedBox * 5000.0) + 0.5);
// 2. 背景模糊 (Blur)
vec4 accum = vec4(0.0);
float total = 0.0;
for (int xi = -4; xi <= 4; ++xi) {
for (int yi = -4; yi <= 4; ++yi) {
// ...
accum += texture(texSampler, offset + lens);
// ...
}
}
color = accum / total; // Box Blur 算法
// 3. 光照和高光 (Lighting & Highlight)
// 根据 m2.y (像素与中心的垂直距离) 计算一个从上到下的渐变
float gradient = clamp((clamp(m2.y, 0.0, 0.2) + 0.1) / 2.0, 0.0, 1.0) + clamp((clamp(-m2.y, -1000.0, 0.2) * rb3 + 0.1) / 2.0, 0.0, 1.0);
// 将模糊后的颜色、主体高光(rb1*gradient)和边框高光(rb2*0.3)叠加
vec4 lighting = clamp(color + vec4(rb1) * gradient + vec4(rb2) * 0.3, 0.0, 1.0);
// 4. 最终混合 (Final Mix)
// 使用 transition 遮罩,将原始背景和处理后的玻璃效果平滑地混合在一起
color = mix(texture(texSampler, uv), lighting, transition);
} else {
// 如果不在区域内,直接显示原始背景
color = texture(texSampler, uv);
}
outColor = color;
透镜效果:lens 变量重新计算了纹理采样坐标。它根据 roundedBox 的值(即离中心的距离)对原始的 uv 坐标进行扭曲。这会产生一种中央放大、边缘压缩的视觉效果,模拟光线通过凸透镜的折射。
背景模糊:代码使用一个 9x9 的**盒状模糊(Box Blur)**算法。它对扭曲后的 lens 坐标周围的像素进行多次采样并取平均值,使得透过玻璃看到的背景变得模糊。
光照和高光:gradient 的计算模拟了从上方来的光源。它在玻璃的上半部分产生一个亮边,同时用 rb3 在下半部分也添加一些光照效果。然后,lighting 变量将这个光照效果叠加在模糊后的背景上。rb1 用于给主体区域上色,rb2 用于给内边框加上一个常量的亮边。
最终混合:最后,mix 函数根据 transition 的值(0.0 到 1.0)将原始背景颜色和我们精心制作的 lighting 颜色进行混合。这保证了玻璃的边缘是平滑过渡的,没有锯齿。
超椭圆
float roundedBox = pow(abs(m2.x * ubo.iResolution.x / ubo.iResolution.y), 8.0) + pow(abs(m2.y), 8.0);
这行代码的本质是利用**超椭圆(Superellipse)**的数学公式来程序化地定义一个带圆角的矩形(通常称为“圆角矩”或 Squircle)。
1. 基础数学公式:超椭圆
标准的超椭圆方程为:
\[\left| \frac{x}{a} \right|^p + \left| \frac{y}{b} \right|^p = 1\]
$(x, y)$ 是坐标点。
$a$ 和 $b$ 分别是椭圆在 x 轴和 y 轴上的半长轴/半短轴(半径)。
$p$ 是一个大于 0 的实数,它决定了形状的“方正”程度。
我们来看看 $p$ 值的不同效果:
当 $p = 2$ 时,方程变为 $(\frac{x}{a})^2 + (\frac{y}{b})^2 = 1$,这是一个标准的椭圆。
当 $p = 1$ 时,方程变为 $
\frac{x}{a}
+
\frac{y}{b}
= 1$,这是一个菱形。
当 $p \to \infty$ 时,形状会无限趋近于一个矩形。
当 $p > 2$ 时,形状介于椭圆和矩形之间,呈现出带有平滑圆角的矩形外观。$p$ 值越大,形状越方正,边角越锐利。
在这个着色器中,选择 p = 8.0 就是为了创造一个非常接近矩形,但又具有平滑圆角的视觉效果,这正是 Apple UI 设计中常见的风格。
2. 代码分步解析
现在我们把公式和代码对应起来:
float roundedBox = pow(abs(m2.x * ...), 8.0) + pow(abs(m2.y), 8.0);
这行代码实际上计算的是超椭圆公式的左半部分:$
x’
^p +
y’
^p$。
m2:
如前所述,m2 是以鼠标为中心的坐标系。所以 m2.x 和 m2.y 就对应了公式中的 $x$ 和 $y$。
abs(...):
对应公式中的绝对值符号 $|…|$,确保无论像素在中心的哪个象限,计算方式都一样,使得图形是中心对称的。
ubo.iResolution.x / ubo.iResolution.y:
这是宽高比校正(Aspect Ratio Correction)。
为什么需要? m2 坐标是从屏幕的 uv 坐标(通常范围是 0 到 1)计算来的。如果屏幕分辨率是 1920x1080,那么它不是一个正方形。在这样的非正方形空间里,水平方向上移动 0.1 单位和垂直方向上移动 0.1 单位所跨越的实际像素数是不同的。如果不进行校正,我们的“圆角矩”在屏幕上看起来会被压扁或拉伸。
如何工作? 通过将 x 坐标 m2.x 乘以 宽度 / 高度,我们人为地“拉伸”了 x 轴,使得在这个新的坐标空间里,x 和 y 方向的单位长度所代表的像素距离变得相等。这样就保证了我们定义的形状不会变形。这相当于在超椭圆公式中设置了不同的 $a$ 和 $b$ 来抵消屏幕的非正方性。
pow(..., 8.0):
这对应了公式中的指数 $p$。如上所述,8.0 这个值创造了一个非常“方”的圆角矩形。
3. roundedBox 值的含义
最终计算出的 roundedBox 是一个浮点数,它的值代表了当前像素离这个超椭圆中心的“数学距离”。
注意,这里并不是由二次关系定义的欧氏距离,而是一个和欧氏距离正相关的距离关系。数值变化如下图所示:
在形状中心 (m2 为 (0,0)):roundedBox 的值为 0。
在形状的边界上:根据公式,roundedBox 的值约等于 1.0。
在形状内部:roundedBox 的值在 0.0 和 1.0 之间。
在形状外部:roundedBox 的值会大于 1.0。
这个值不是一个简单的“是”或“否”(0 或 1),而是一个从中心向外平滑增长的连续值。这就像一个距离场(Distance Field)。正因为它是连续的,后续的代码才能利用它来计算出平滑的边缘过渡、边框和光照效果。例如,可以通过检查 roundedBox 是否在 0.95 到 1.0 之间来精确地绘制边框。
区域分层
核心目标
这部分代码的目标是,基于前一步计算出的、从中心 (0.0) 到边缘 (~1.0) 平滑变化的 roundedBox 值,“雕刻”出三个不同的遮罩(Mask)。每个遮罩都是一个 0.0 到 1.0 之间的浮点数,它们分别定义了:
rb1: 玻璃的主体实心区域。
rb2: 玻璃内部的一圈高光边框。
rb3: 用于光照计算的另一圈光晕区域。
关键工具:clamp() 函数
理解这部分代码的关键是理解 clamp(value, min, max) 函数和一种特定的用法模式。
clamp(value, min, max): 这个函数会把 value 限制在 [min, max] 区间内。如果 value 小于 min,结果就是 min;如果大于 max,结果就是 max。
模式: clamp((C - x) * K, 0.0, 1.0)
C - x: 这是一个临界点判断。当 x < C 时,结果为正;当 x > C 时,结果为负。
* K: K 是一个很大的数,它充当锐化因子。它会让 (C - x) 的结果在 x 跨越 C 点时急剧地从一个大的正数变为一个大的负数,从而创造出非常清晰、陡峭的边缘。
clamp(..., 0.0, 1.0): 将这个急剧变化的结果“拍扁”到 [0, 1] 区间,最终形成一个边缘清晰的遮罩。
逐层解析
1. rb1: 主体填充层 (The Core Fill Layer)
float rb1 = clamp((1.0 - roundedBox * 10000.0) * 8.0, 0.0, 1.0);
逻辑: 1.0 - roundedBox * 10000.0
这里的 10000.0 是一个很大的缩放系数,用于微调边缘的位置。我们主要关注 1.0 这个临界值。
当 roundedBox 非常小(靠近中心)时,roundedBox * 10000.0 远小于 1.0,1.0 - ... 是一个正数。
当 roundedBox 增大到某个值,使得 roundedBox * 10000.0 接近 1.0 时,1.0 - ... 的结果会迅速从正数变为 0,然后变为负数。
锐化: 乘以 8.0 让这个过渡更加陡峭。
clamp: 将最终结果限制在 [0, 1]。
结果: rb1 在形状的绝大部分内部区域都为 1.0(纯白),在非常靠近边缘的地方会快速平滑地过渡到 0.0(纯黑)。它定义了玻璃的主体部分。
2. rb2: 内边框层 (The Inner Border Layer)
float rb2 = clamp((0.95 - roundedBox * 9500.0) * 16.0, 0.0, 1.0) - clamp(pow(0.9 - roundedBox * 9500.0, 1.0) * 16.0, 0.0, 1.0);
这里的技巧是用一个大图形减去一个小图形,从而得到一个环。
第一部分 (大图形): clamp((0.95 - roundedBox * 9500.0) * 16.0, 0.0, 1.0)
这会创建一个实心遮罩,其边界由 roundedBox * 9500.0 ≈ 0.95 定义。
第二部分 (小图形): clamp(pow(0.9 - roundedBox * 9500.0, 1.0) * 16.0, 0.0, 1.0)
这会创建另一个实心遮罩,其边界由 roundedBox * 9500.0 ≈ 0.9 定义。这个图形比第一个要小一些。(pow(..., 1.0) 不改变值,可以忽略)。
减法: 大图形 - 小图形
在两个图形都为 1.0 的重叠区域(中心区域),结果是 1.0 - 1.0 = 0.0。
在两个图形都为 0.0 的外部区域,结果是 0.0 - 0.0 = 0.0。
只有在大图形为 1.0 而小图形为 0.0 的那一圈窄窄的环形区域,结果才是 1.0 - 0.0 = 1.0。
结果: rb2 是一个非常细的环形遮罩,正好位于 rb1 区域的内边缘,完美地用于创建边框高光。
3. rb3: 外光晕层 (The Outer Glow/Lighting Layer)
float rb3 = (clamp((1.5 - roundedBox * 11000.0) * 2.0, 0.0, 1.0) - clamp(pow(1.0 - roundedBox * 11000.0, 1.0) * 2.0, 0.0, 1.0));
逻辑: rb3 使用了和 rb2 完全相同的“大图形减小图形”的技巧来创建一个环形。
区别: 它使用的临界值是 1.5 和 1.0,并且缩放系数是 11000.0。这意味着它定义的环形区域与 rb2 的位置和宽度都不同,这个遮罩将被用于后续光照计算中的另一个特定效果(例如底部的高光)。
3. 可视化分层区域
图(0,2)将rb1~rb3放在了同一张图中,对比位置关系。
注意,rb3和rb1+rb2定义的区域部分重叠,在白线外的区域是非重叠区域。
效果实现
前置步骤:平滑过渡遮罩
在进入核心效果计算之前,代码首先准备了一个高质量的遮罩:
float transition = smoothstep(0.0, 1.0, rb1 + rb2);
目标: 创建一个边缘平滑、用于抗锯齿的最终遮罩。
代码分析:
rb1 + rb2: 将主体填充层 (rb1) 和内边框层 (rb2) 合并成一个单一的遮罩。
smoothstep(edge0, edge1, x): 这是一个非常重要的 GLSL 内置函数。
数学原理: smoothstep 函数接受一个输入 x,并在两个阈值 edge0 和 edge1 之间进行平滑的 S 型曲线插值。其标准化的数学表达式(当 edge0=0, edge1=1 时)为:
\(S(x) = 3x^2 - 2x^3, \quad x \in [0, 1]\)
相比于线性的 clamp,smoothstep 的导数在起点和终点都为 0,这使得过渡的开始和结束都非常缓和,视觉上看起来比线性过渡更加自然、平滑。在这里,它将 rb1+rb2 产生的硬边缘变成柔和的渐变,是实现高质量抗锯齿的关键。
1. 透镜/折射效果 (Lensing Effect)
这一步通过扭曲纹理坐标,模拟光线穿过凸透镜时发生的折射现象,产生放大镜的效果。
vec2 lens = ((uv - 0.5) * 1.0 * (1.0 - roundedBox * 5000.0) + 0.5);
目标: 计算出经过透镜扭曲后,当前像素应该去采样背景纹理的哪个新坐标。
代码与数学分步解析: 让我们将这个坐标变换拆解开来,假设原始坐标为 $\vec{uv}$:
uv - 0.5: 坐标中心化。
数学: $\vec{v}_{centered} = \vec{uv} - (0.5, 0.5)$
解释: 将坐标原点从左上角 (0,0) 移动到屏幕中心 (0.5, 0.5)。这样一来,后续的缩放操作就会以屏幕中心为基准点,而不是左上角。
* (1.0 - roundedBox * 5000.0): 应用一个基于距离的缩放因子。
数学: 设缩放因子 $S = 1.0 - roundedBox \times 5000.0$。那么新的向量是 $\vec{v}{scaled} = \vec{v}{centered} \times S$。
解释:
在玻璃的正中心,roundedBox = 0,所以缩放因子 $S = 1.0$。坐标没有任何变化。
从中心向边缘移动,roundedBox 值变大,导致 roundedBox * 5000.0 也变大,因此缩放因子 $S$ 小于 1.0。
一个向量乘以一个小于 1 的标量,会使其长度变短。这意味着,我们正在用一个更小的向量去采样背景。
视觉效果: 从一个更靠近中心的点采样纹理,并把它显示在当前离中心更远的位置上,这就产生了**放大(Magnification)**效果。想象一下,你把背景图的一小块区域拉伸,覆盖到了屏幕上更大的一块区域。
备注:* (1.0 - roundedBox * 5000.0)对应的大于0的区域相比于rb1+rb2定义的区域更大一些,因此不存在S<0的情况。
+ 0.5: 坐标原点移回。
数学: $\vec{uv}{lens} = \vec{v}{scaled} + (0.5, 0.5)$
解释: 将坐标系从以中心为原点移回到以左上角为原点,得到最终可以用于 texture() 函数的采样坐标 lens。
2. 背景模糊 (Background Blur)
这一步模拟了当焦点在玻璃上时,背景会失焦而变得模糊的景深效果。这里使用的是一个低质量的box模糊,效果可以进一步优化。
vec4 accum = vec4(0.0);
float total = 0.0;
for (int xi = -4; xi <= 4; ++xi) {
for (int yi = -4; yi <= 4; ++yi) {
vec2 offset = vec2(float(xi), float(yi)) * 0.5 / ubo.iResolution.xy;
accum += texture(texSampler, offset + lens);
total += 1.0;
}
}
color = accum / total;
目标: 计算当前像素透过玻璃看到的模糊背景色。
代码与数学原理: 这里实现的是一种名为**盒状模糊(Box Blur)**的卷积算法。
卷积(Convolution): 图像处理中的一个基本操作,一个像素的最终颜色由它和它邻域内其他像素的颜色加权平均得到。
数学: 盒状模糊的公式为:
\(C_{out} = \frac{1}{N} \sum_{i=1}^{N} C_{i}\)
其中 $C_{i}$ 是采样点颜色, $N$ 是总采样数。
实现:
for 循环创建了一个 9x9 的采样网格(从 -4 到 4,共 9 个点)。总采样数 $N = 9 \times 9 = 81$。
offset: 计算每个邻域采样点相对于中心点的偏移量。
vec2(float(xi), float(yi)): 这是像素空间的偏移,例如 (-4, -4), (-4, -3) …
/ ubo.iResolution.xy: 将像素空间的偏移转换为纹理坐标(UV)空间的偏移。这是因为UV坐标是从 0 到 1 覆盖整个纹理的,一个像素的UV宽度是 1.0 / width。
* 0.5: 这是一个调整因子,它缩小了模糊的半径,使得模糊不会过于发散,效果更细腻。
accum += texture(texSampler, offset + lens): 在经过透镜扭曲后的坐标 lens 上,再叠加上邻域偏移 offset 进行采样,并将颜色累加到 accum 中。
color = accum / total: 将累加的总颜色值除以总采样数,得到平均颜色,即模糊后的颜色。
—–
补充:一个很直接的加速方法是预制模糊图像,在shader中实现模糊效果时直接从模糊图像上采样。
3. 光照与高光 (Lighting & Highlights)
这是赋予玻璃质感和立体感的关键步骤,它并非基于物理的精确光照模型。
float gradient = clamp((clamp(m2.y, 0.0, 0.2) + 0.1) / 2.0, 0.0, 1.0) + clamp((clamp(-m2.y, -1000.0, 0.2) * rb3 + 0.1) / 2.0, 0.0, 1.0);
vec4 lighting = clamp(color + vec4(rb1) * gradient + vec4(rb2) * 0.3, 0.0, 1.0);
目标: 在模糊背景的基础上,叠加模拟的顶部高光和边缘高光。
gradient 计算解析:
第一部分 (底部微光): clamp((clamp(m2.y, 0.0, 0.2) + 0.1) / 2.0, 0.0, 1.0)
m2.y 是像素相对于鼠标中心的垂直坐标(负值为上,正值为下)。
clamp(m2.y, 0.0, 0.2) 会在玻璃下半部分靠近中心的一条带状区域产生一个从 0 到 0.2 的梯度值。
第二部分 (顶部主高光): clamp((clamp(-m2.y, -1000.0, 0.2) * rb3 + 0.1) / 2.0, 0.0, 1.0)
-m2.y 将坐标翻转,所以 clamp(-m2.y, ...) 作用于玻璃的上半部分。
* rb3: 这个高光被我们之前计算的环形遮罩 rb3 所调制,意味着高光只会出现在 rb3 所定义的那个光晕区域内,形状更可控。
gradient 最终是这两部分的叠加,形成了一个非对称的、主要集中在顶部的光照效果。
lighting 最终颜色合成:
数学: 这是一个简单的加法混合模型:$C_{final} = C_{blur} + C_{highlight1} + C_{highlight2}$
color: 模糊后的背景色作为基础层。
+ vec4(rb1) * gradient: 将计算出的 gradient 高光应用在玻璃的主体 rb1 区域。
+ vec4(rb2) * 0.3: 在内边框 rb2 区域,直接加上一个固定的亮度值 0.3,形成一道清晰锐利的边缘高光。
clamp(..., 0.0, 1.0): 最后进行一次 clamp,防止颜色因叠加而超出 1.0(过曝)。
4. 最终混合 (Final Compositing)
这里和ShaderToy中的实现保持一致,但是实际上的抗锯齿效果不明显。
color = mix(texture(texSampler, uv), lighting, transition);
目标: 根据抗锯齿遮罩,将效果无缝地绘制到屏幕上。
mix 函数与数学原理: mix(A, B, x) 执行的是线性插值(Linear Interpolation)。
数学: $Result = A \times (1 - x) + B \times x$
A 是 texture(texSampler, uv),即原始未处理的背景。
B 是 lighting,即我们完成的玻璃效果。
x 是 transition,我们带有平滑边缘的遮罩。
解释:
当像素在玻璃外部,transition = 0,公式变为 $A \times (1 - 0) + B \times 0 = A$,显示原始背景。
当像素在玻璃内部,transition = 1,公式变为 $A \times (1 - 1) + B \times 1 = B$,显示玻璃效果。
在玻璃的柔和边缘,transition 在 0 和 1 之间,公式会按比例混合原始背景和玻璃效果,从而创造出完美的抗锯齿边缘。
未使用混合:
使用混合:
-
-
图像降采样
最近的一个横向中涉及对图像进行降采样的问题,最近两周实现和对比了一些降采样的方法,在本文中进行归纳总结。
本文的主要内容包括:1.介绍几种常见的降采样方法。2.对比不同方法的性能。3.基于计算着色器实现区域平均。
定义
图像降采样(Image Downsampling)是指通过减少图像的像素数量来降低图像分辨率的过程。具体来说,它是将高分辨率的原始图像转换为较低分辨率的图像,同时尽可能保持图像的视觉质量和关键信息。
图 1:区域均值降采样。
常见的图像降采样方法包括:
最近邻插值 选择最接近的像素值
双线性插值 使用周围4个像素的加权平均
双三次插值 使用周围16个像素的加权平均
区域平均 计算采样区域内所有像素的平均值
问题目标
将图像的分辨率降低到 $\frac{W}{2^n} \times \frac{H}{2^n}$。
在保证图片质量的前提下,尽可能提高计算速度。
使用C++、OpenGL或Vulkan实现。
下采样方法
为了平衡计算速度和图片质量,本文主要研究双线性插值或区域平均。
双线性插值
图形API中的双线性插值
在OpenGL或Vulkan等图形API中,双线性插值被广泛的支持,例如在OpenGL中,可以使用glTexImage2D函数来创建一个纹理,并指定GL_LINEAR作为纹理过滤器,从而在片段着色器中使用双线性插值对该纹理进行采样。除了GL_LINEAR外,还包括:
GL_NEAREST:最近邻插值
GL_NEAREST_MIPMAP_LINEAR:根据bias参数选择两个mipmap层,mipmap层内部进行最近邻插值,mipmap层之间使用线性插值。
GL_NEAREST_MIPMAP_NEAREST:选择最近的mipmap层,在单个mipmap中最近邻插值。
GL_LINEAR_MIPMAP_NEAREST:选择最近的mipmap层,在单个mipmap中双线性插值。
GL_LINEAR_MIPMAP_LINEAR:根据bias参数选择两个mipmap层,mipmap层内部进行双线性插值,mipmap层之间使用线性插值。
在Vulkan中,将图像绑定到描述符集时,可以为该图像创建采样器,可以为采样器指定类似于前文OpenGL提供的采样参数,具体包括:
VK_FILTER_NEAREST:最近邻插值
VK_FILTER_LINEAR:双线性插值
如果开启mipmap,则可以指定:
VK_SAMPLER_MIPMAP_MODE_NEAREST:对mipmap进行最近邻插值
VK_SAMPLER_MIPMAP_MODE_LINEAR:对mipmap进行双线性插值
双线性插值理论
图 2:双线性插值。
双线性插值需要使用最近的四个像素进行插值,计算公式如下:
\(I(x, y) = (1 - dx) * (1 - dy) * I(0, 0) + dx * (1 - dy) * I(1, 0) + (1 - dx) * dy * I(0, 1) + dx * dy * I(1, 1)\)
其中,$I(x, y)$是插值后的像素值,$I(0, 0)$、$I(1, 0)$、$I(0, 1)$、$I(1, 1)$是最近的四个像素值,$dx$和$dy$是插值点相对于最近四个像素点的偏移量。
将双线性插值应用于降采样时,如果将分辨率降低为原始分辨率的$\frac{1}{2}$,那么等价于对四个像素进行区域平均,即:
\(I(0.5,0.5) = (1-0.5) * (1-0.5) * I(0, 0) + 0.5 * (1-0.5) * I(1, 0) + (1-0.5) * 0.5 * I(0, 1) + 0.5 * 0.5 * I(1, 1) \\
I(0.5,0.5) = \frac{I(0, 0) + I(1, 0) + I(0, 1) + I(1, 1)}{4} \phantom{* (1-0.5) * I(0, 0) + 0.5 * (1-0.5) * I(1, 0) + (1-0.5) * 0.5 * I(0, 1) + 0.5 * 0.5 * I(1, 1)}\)
图 3:图像分辨率降低一半时,双线性插值等价于区域平均。
然而,当降采样比率较大时($\frac{width_{original}}{width_{downsampled}} > 2$),双线性插值会”遗漏”一些像素,从而带来图像质量的显著损失。如下图所示,蓝色的像素是原始图像中的像素,深色的像素是降采样后对应回原图的区域,橙色圆形是该区域的中心,对橙色圆形进行双线性插值时参与的只有蓝色圆形所示的点,其他像素被”遗漏”。
图 4:降采样样比率较大时,双线性插值会"遗漏"一些像素。
因此,为了保证降采样图像的质量,逐级降采样是更好的选择。所谓逐级降采样,是指将图像先降采样到$\frac{1}{2}$,再降采样到$\frac{1}{4}$,再降采样到$\frac{1}{8}$,以此类推。
图 5:逐级降采样。
这种逐级降采样的方式非常适合在图形管线中实现。在OpenGL中,我们可以通过两种方式来实现:
使用glGenerateMipmap函数自动生成mipmap序列,这是最简单直接的方法
将原始图像作为输入纹理,通过多次渲染并利用双线性插值采样到更小的目标图像上,逐步完成降采样过程
与OpenGL不同,Vulkan没有提供类似glGenerateMipmap的便捷函数。在Vulkan中,我们需要通过重复调用vkCmdBlitImage命令来手动生成每一级mipmap。虽然这种方式需要更多的代码,但也给了开发者更大的灵活性和控制权。
下文将对比这三种方式的时间和优缺点。
区域平均理论
区域平均是一种简单直观且计算高效的图像降采样方法。它通过以下步骤实现图像的降采样处理:
根据目标图像尺寸,将原始图像划分为多个大小相等、互不重叠的矩形区域
对每个矩形区域内的所有像素值进行算术平均计算
将计算得到的平均值赋给降采样后图像中对应位置的像素
这种方法的一大优势在于其灵活性: 它可以通过单次计算过程将图像直接降采样到任意目标尺寸,而不需要多次迭代。这种特性使其在某些场景下具有明显的性能优势。
实现和对比
glGenerateMipmap
直接调用函数即可。
逐级降采样
思路:输入纹理A,将纹理B作为帧缓冲的颜色附件,纹理B的分辨率是纹理A的$\frac{1}{2}$。
准备阶段:
width, height
For mipmapLevel = 0 to mipmapLevelMax:
Create Texture[mipmapLevel] with width ,height
Create Framebuffer[mipmapLevel] with Texture[mipmapLevel]
width, height = width / 2, height / 2
渲染阶段:
For mipmapLevel = 0 to mipmapLevelMax:
Bind Framebuffer[mipmapLevel]
Bind Texture[mipmapLevel-1]
Render
Vulkan Mipmap生成
重复调用vkCmdBlitImage命令,将mipmapLevel-1的图像blit到mipmapLevel。
时间统计方法
CPU时间
CPU时间是指CPU侧执行代码的时间(包含CPU侧的处理,指令提交到GPU,GPU执行,GPU返回结果的时间),即资源分配和GPU执行的时间。
1.使用std::chrono::high_resolution_clock::now()和std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()来统计时间。
2.使用Nvidia Nsight Compute来统计CPU时间。
GPU时间
GPU时间是指GPU执行该指令提交到GPU的任务的时间。
1.使用Querypool。
2.使用Nvidia Nsight Compute的GPU Trace Profiler来统计GPU时间。
结果
输入2048*2048的图像。
第一帧:
方法
CPU时间(ms)
GPU时间(ms)
glGenerateMipmap
10.8
2.2
逐级降采样
9.8
1.4
Vulkan Mipmap
1.9
1.5
注:
逐级降采样中,CPU时间是指创建帧缓冲和纹理的时间。
VUlkan Mipmap生成的CPU时间只包含blit指令产生的CPU时间(统计该Command提交到返回到时间),不包含纹理图像创建的时间(VUlkan在创建纹理图像时需要指定Mipmap level,并为之分配内存)。
图 6:Vulkan降采样指令返回时间。
第二帧:
方法
CPU时间(ms)
GPU时间(ms)
glGenerateMipmap
2.4
2.1
逐级降采样
9.3
1.5
Vulkan Mipmap
1.8
1.5
从上述结果中可以看出,第一次调用glGenerateMipmap时,CPU时间较长,而Vulkan Mipmap的CPU时间较短,这可能是因为glGenerateMipmap在第一次调用时需要进行一些初始化工作,而Vulkan Mipmap在第一次调用时已经完成了纹理图像的创建。
就GPU侧的速度而言,逐级降采样和Vulkan Mipmap的速度相近,二者都快于glGenerateMipmap。
glGenerateMipmap函数对我们而言就像是一个黑盒子,第二帧的时间显著减少,是否意味着glGenerateMipmap函数在第二次调用时实际没有执行任何操作?为了解答该问题,我使用Nsight Compute的GPU Trace Profiler来查看glGenerateMipmap执行时GPU的占用情况。
图 7:第二帧时glGenerateMipmap执行时GPU的占用情况。
图 8:第二帧时不调用glGenerateMipmap时GPU的占用情况。
从图中可以看出,glGenerateMipmap执行时和不调用该函数相比,GPU的占用率明显更高,因此第二帧时glGenerateMipmap的调用时只有CPU侧的部分资源分配任务被跳过,GPU侧的任务没有明显变化。
区域平均
上述借助双线性插值的方法本质上都是一个“逐级”的过程,这其中驱动层面上会产生额外的开销。并且,数据在GPU的主存和片上内存之间来回传输,存在IO开销。
区域平均则是一个“单次”的过程,它通过一次计算过程将图像直接降采样到任意目标尺寸,而不需要多次迭代。
然而,区域平均无法像双线性插值利用硬件特性,需要我们自己实现。
一种最直接的方法是在片段着色器中读取NxN的像素,然后计算平均值。然而,数据的读取和累加操作是在单个片段着色器中顺序执行的,计算效率低下。
图 9:区域平均的计算过程。
使用Compute Shader
为了提高利用并行性,我们可以使用Compute Shader来进行区域平均。
算法实现:
1.每个线程读取32个像素。
2.在线程内部计算列方向上的求和,根据降采样比率决定累加的数据数量。
3.将中间结果写入恭喜那个内存中。
4.LandID<output_size*output_size的像素读取m个共享内存中的数据。
5.计算行方向上的累加并写入输出图像中。
每个warp中包含32个线程,因此一个Warp处理32*32的区域,如下图所示:
图 10:Warp处理32*32的区域,每个线程读取32个数据。
为了简化过程,我们以4*4的区域为例(假设一个warp中只包含4个线程,每个线程读取4个数据),进行讲解。下采样比率为2,即输出图像的分辨率为输入图像的$\frac{1}{2}$ 。
读取数据
线程根据所在的workgroupID,WarpID,以及线程ID,计算出该线程需要读取的像素的坐标,然后读取这些像素的值到寄存器中。
计算列方向上的求和
列方向上的输出维度为2,因此每$\frac{4}{2}$个像素进行一次累加。
写入共享内存
将中间结果写入共享内存中。
读取共享内存
LandID<2*2的线程读取共享内存中的结果到寄存器。
计算行方向上的累加
计算行方向上的累加并写入输出图像中。
累加过程如图所示:
图 11:累加流程。
实验结果
输入2048*2048的图像,降采样到64*64。
方法
CPU时间(ms)
GPU时间(ms)
glGenerateMipmap
2.4
2.1
逐级降采样
9.3
1.5
Vulkan Mipmap
1.8
1.5
区域平均(CS)
0.9
0.9
-
How to compute integral image on GPU?
Related Work
GPU Gems 3 (2005). Chapter: “Summed-Area Tables on the GPU” by Dave Johnson (NVIDIA).
https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-8-summed-area-variance-shadow-maps
Parallel Prefix Sums
https://developer.nvidia.com/gpugems/gpugems3/part-vi-gpu-computing/chapter-39-parallel-prefix-sum-scan-cuda
Scan primitives for GPU computing
GPU-efficient recursive filtering and summed-area tables
https://dl.acm.org/doi/10.1145/2070781.2024210
技术重点:
并行前缀和(Scan):大多数GPU实现将积分图像计算分解为两个主要的并行前缀和传递,一个是水平方向的,另一个是垂直方向的。
内存合并与冲突:高效的GPU积分图像算法注意内存布局,确保线程访问连续的内存段以减少延迟并提高吞吐量。
工作负载划分(Tile):大型图像被划分为独立且并行计算的小块。在每个小块上计算出部分总和后,额外进行一次合并操作以得到全局积分图像。
层次化方法:层次化求和策略通过将求和问题拆解成多个阶段来降低处理大型图像时所需处理复杂度。
使用共享内存与寄存器:高性能实现将中间总和保存在在快速GPU共享内存中,从而减少全局内存流量。
Summed-Area Variance Shadow Maps(GPU gem3)
https://developer.nvidia.com/gpugems/gpugems3/part-ii-light-and-shadows/chapter-8-summed-area-variance-shadow-maps
该章节指出SAT的计算本质上是两个维度的前缀和过程。Hensley et al. 2005.提出了一种更并行的方法。
本章节主要关注积分图像计算时的精度问题,Hensley et al. 2005有很多关于精度问题的解决方案, Donnelly and Lauritzen 2006也给出了一种解决方案。
Parallel Prefix Sum (Scan) with CUDA(GPU Gem3)
Naive Method
CPU的前缀和计算的复杂度为O(n),GPU算法的复杂度如果不超过该复杂度,我们称之为efficient work。
最原始的GPU版本就是一个不高效的实现,该算法需要两个Buffer来保证正确性。同时,该算法还假设处理器单元的数量和数组大小一致。
Work-Efficient Method
使用平衡树来优化复杂度。
分成两个过程,Red1uce(归约过程,也被称作上扫过程);和下扫过程。
同一个位置上的操作在共享内存中完成。
Reduce
该过程结束后,根结点上保存了整个数组的和。
down-sweep
在上扫描阶段,我们已经获得了总和,但前缀和还未完全计算出来。下扫描阶段通过逆向操作,逐步填充前缀和数组。
上扫描结束后,位置0,1,3,7的值是前缀和数组中会出现的值(为inclusive的,但通常需要计算exclusive的)。例如,25的右子树14需要11的值来更新子节点。
因此,下扫描过程可以理解为一个传递到左子树、累加右子树的过程。
初始化:设置根节点为0.
遍历步长数组[4,2,1]
设置左子树为根节点的值,右子树为左子树和右子树的和(左子树使用设置后的值)。
该过程一个线程操作两个元素,需要在同一个work group中完成。
使用上扫阶段的结果:
树结构:
25
/ \
11 14
/ \ / \
4 7 5 9
/ \ / \ / \ / \
3 1 7 0 4 1 6 3
初始化根节点为 0:
0
/ \
11 14
/ \ / \
4 7 5 9
/ \ / \ / \ / \
3 1 7 0 4 1 6 3
传递并更新:
根节点 0 分配给左子树 left = 0 和右子树 right = 11(左子树的部分和)。
左子树 11 分配给其左子树 left = 0 和右子树 right = 4。
右子树 14 分配给其左子树 left = 11 和右子树 right = 5。
继续递归,最终得到前缀和数组。
最终前缀和数组 S = [0, 3, 4, 11, 11, 15, 16, 22]。
Fast Summed‐Area Table Generation and its Applications[Hensley et al. 2005]
提到论文:Simple Blurry Reflections with Environment Maps 似乎是使用miomap近似模糊的lerp blur。
方法
使用图形管线实现,整个过程分解为两个phase,每个phase包括log(n)个pass。
使用两张纹理图像,互相作为输入输出。
积分图像的精度问题
累加时机器误差的积累。
使用积分图像时通常是两个接近的值相减,尤其是两个值很大时,误差会更加明显。
积分图像的最大值大小为$w \times h \times 255$
Using Signed-Offset Pixel Representation
将像素值进行偏移,从[0,1]偏移到[-0.5,0.5],这样可以使得:
数值不总是单调的。
有两种方式实现这一点:
所有像素值-0.5.
所有像素值-平均值。
Using Origin-Centered Image Representation
以图片的中心点作为原点,相当于计算四张更小的积分图像,从而避免了极大值的出现。
但是使用时增加了额外的计算量。
Scan Primitives for GPU Computing 2007
GPU并行模型
符合流水线结构的算法天然适合GPU实现。即,每个kernel处理单独的输入,产生单独的输出。
某些问题,例如前缀求和问题,需要输入数据的全局知识。
相关工作
原始方法
horn 2005
[[#Fast Summed‐Area Table Generation and its Applications[Hensley et al. 2005]]]
这些方法的复杂度为O(nlogn),是non-work-efficient的。
reduce and down-sweep
提出:Blelloch in 1990[Vector Models for Data-Parallel Computing]
GPU实现:Sengupta(本文作者) et al. and Greß et al. in 2006
CUDA实现:本文
方法
本文方法的主要贡献是引入了分段扫描的概念.
分段扫描Segmented Scan,是一种将输入序列分成多个互不影响的字序列并进行后续操作的过程。
使用flag数组标记不同的分段,例如:
Data: [a, b, c, d, e]
Flags: [1, 0, 1, 0, 0]
该算法用于解决输入向量超过线程块大小的情况。
在归约过程和下扫描之间插入部分和的计算。
(影响下扫描的初始化)
Efficient Integral Image Computation on the GPU 2010
使用work-efficient的前缀和计算,利用了分段扫描解决大数组问题。积分图像的计算转化为:前缀和-转置-前缀和过程。
使用tanspose操作的优点在于,可以使用同一个kernel完成两次扫描操作。
GPU-Efficient Recursive Filtering and Summed-Area Tables 2011
Efficient Algorithms for the Summed Area Tables Primitive on GPUs 2018
Related work
Scan-scan algorithms:
Compute prefix sums directly
Limited by memory access patterns
Scan-transpose-scan algorithms:
Use matrix transposition between steps
Rely heavily on scratchpad memory
Have expensive matrix transpose operations
Face memory bandwidth limitations
该方法来自于Efficient Integral Image Computation on the GPU。本文指出,该方法需要对global memory的聚合访问和昂贵的transpose操作。
[!聚合访问]
对全局内容的访问较慢,只有每一个线程以相同stride的方式访问全局内存,才能实现峰值吞吐:
Thread 0: accesses address N
Thread 1: accesses address N+1
Thread 2: accesses address N+2
…and so on
[!bank conflict]
现代GPU的共享内容被分成一系列的bank。当同一个warp中的不同线程访问同一个bank时,会发生冲突。
例如,一个warp中存在12个线程,每个线程都需要访问共享内存对应行的32个数据,即第i个线程访问sharedMem[i][0…31]由于bank的存在,每个线程访问数据j时都对应到同一个bank中,因此存在冲突。
本文为了避免这种冲突,定义数组大小为[32][33]
本文方法的动机
SAT 计算的瓶颈在于数据移动。以聚合模式高效访问全局内存对算法性能至关重要。此外,设计减少为了减少数据移动,我们采用了寄存器缓存方法。问题在于如何在有效使用寄存器的同时避免争用。
线程间通信寄存器的技术(即shuffle指令)只能在单个 warp(CUDA 中一起执行的一组线程)内工作。因此,我们调整了算法,在warp级别进行扫描,同时避免warp内部通信。
最直接的SAT计算
Prefix Sum
Basic method
Kogge-Stone
http://lumetta.web.engr.illinois.edu/408-S20/slide-copies/ece408-lecture16-S20.pdf
Step1得到长度小于等于2的累加和
Step2得到长度小于等于4的累加和
Step3得到长度小于等于8的累加和
LF-scan
warp shuffle
In theory, the LFscan achieves the highest computing efficiency with logN stages and $\frac{NlogN}{2}$
addition operations as Fig. 2c shows,
‘reduce (up-sweep) and down-sweep’
https://developer.nvidia.com/gpugems/gpugems3/part-vi-gpu-computing/chapter-39-parallel-prefix-sum-scan-cuda
问题
算法流程决定加法运算的次数和算法阶段数。
数据存储、传递的方式(GPU显存访问模式)也会显著影响算法性能。
本文贡献
使用寄存器存储中间数据。
提出BRLT
方法
Caching Data Using Register Files
SAT的计算问题是内存限制的。
每个线程都先使用寄存器缓存数据。
并且warp中线程的通信泗洪shuffle操作完成。
GPU聚合访问
用于降低对Global Memory的访问成本。具体做法是,在同一个warp中的所有线程访问临近的内存位置。GPU可以将这些操作打包成一次数据传输,从而降低延迟、提升带宽。
data向16 bits or 32 bits对齐。
globalID = (x + width * y + width * height * z),对内存位置的访问和GlobalID一致(尤其是2D图像,y连续访问不是聚合访问)。
Google
Ch
问题回顾
计算前缀和在两个方向上进行。
但是:
两个方向需要使用不同的kernel函数(不同的着色器程序,或者引入不同的分支)。
列方向处理的数据不连续,无法实现聚合访问。
因此,SAT计算更常见的做法是Scan-transpose-scan。
接下来需要考虑矩阵转置是否是聚合操作。
矩阵转置
warp level
element level
在每个warp内部:
Method:直接做法
写入操作不是聚合操作。
本文提出了BRLT,在Shared memory中对warp中的数据进行转置。
BRLT
Block-Register-Local-Transpose Method
这是一种将数据从寄存器复制到共享内容再复制回寄存器的矩阵transpose方法。
这种方法的仅使用共享内存作为缓冲区,transpose操作是在寄存器-共享内存之间完成的;传统算法的操作是在共享内存中完成,并且需要从主存中加载数据。
Bank conflict
google
GPU共享内存以bank的形式被组织。N卡通常32banks,bank中数据单元大小为4或8。当一个warp中的不同线程访问到同一个bank时,会发生conflict,导致IO操作无法并行。
例如,bank number=4.该任务是一个矩阵转置任务,Clock0时,四个线程写入第一列:
此时发生bank conflict。
但是如果修改共享内存大小为4*5:
部分和计算
将warp计算出的部分和存储在共享内存中。
在共享内存中计算数据的前缀和。
将数据从共享内存加载回对应的warp。
方法一:ScanRow-BRLT
Efficient Integral Image Computation on the GPU 2010中方法需要将行扫描的结果存储在全局内存中,transpose操作也在全局内存中完成。
本文中的方法:
将输入2D图像的一块直接加载到block中,
然后执行行扫描-BRLT-写回主存。因此转置操作在共享内存中完成。
如上图所示,每个block负责32行的数据,每个warp处理32*32的块。
每个线程读取32个数据,线程之间使用LF-scan(利用shuffle操作)。
warp之间的通信:
在一个线程中串行操作,复杂度O(n^2):
思考
warp是被调度的基本单元,但并不意味着不同warp之间有先后顺序,也不意味着warp被完整调度。
为什么转置以后再写回主存,直接rigister-shared memory-rigister的模式完成两次算法IO更少。
如何处理更大的图像。
方法二:BRLT-ScanRow
与方法1类似,但是先转置再执行scan操作。重复两次。但是论文指出,该方法使用串行扫描算法,并且这种方法效率更高。
warp之间使用共享内存通信,但是该方法的每个线程只需要一个位置的preSum,复杂度降低为O(n)
方法三:Register-based ScanRowColumn Method
每个block处理32行的数据,每个warp处理一行的数据。
例如warp0,扫描0-31号元素,使用shuffle操作传递到warp0的第二次扫描的第一个线程上。
-
Vulkan Compute Shader
Reference
P.A. Minerva’s Vulkan Tutorial on Compute Shaders
vulkan tutorial
AMD opengpu
GPU Program Blog
Example for Compute Shader
Intro
核心组成:GPU由数千个处理核心组成,这些核心专门用于执行由许多核心同时处理的单独任务。GPU通过并行执行相同的指令序列(描述特定任务)来实现高速处理大量数据。
核心组织:这些核心被组织成多处理器(multiprocessors),能够在多个线程上并行执行相同的指令。这种硬件模型被称为SIMT架构(Single-Instruction, Multiple-Thread)。
多处理器内存:每个多处理器包括以下几种类型的片上内存:
32位寄存器,分配给核心。
共享内存,由所有核心共享。
只读常量缓存,加速从设备内存的只读区域读取。
只读纹理缓存,加速从纹理内存空间读取。
线程组和执行:多处理器创建、管理、调度并执行称为warp(或wavefront)的32或64个并行线程组。每个warp在同一程序地址开始,但具有自己的指令地址计数器和寄存器状态,因此可以独立分支和执行。
线程块和warp调度:多处理器接收一个或多个线程块进行执行时,将它们划分为warps,并由warp调度器管理和调度。线程块到warp的划分是一致的,每个warp包含连续递增的线程ID。
执行上下文和调度:每个warp的执行上下文在其整个生命周期内都保持在芯片上,因此从一个执行上下文切换到另一个没有成本。每个指令发出时,warp调度器可以选择一个准备好执行下一指令的warp,并向这些线程发出指令。
线程块(Thread Blocks)
定义:线程块是一组在GPU上同时执行的线程。它是由程序员定义的,用于组织和执行并行任务。
大小和形状:线程块的大小(即包含的线程数)和形状(如1D、2D或3D)可以根据特定的计算任务进行调整。
资源共享:线程块内的线程可以共享一定量的快速访问内存(称为共享内存),并且可以进行同步操作。
Warp(线程束)
定义:warp是线程块中的一小部分线程,这些线程在GPU上以单一的指令流同时执行相同的操作。在NVIDIA的GPU中,一个warp通常包含32个线程。
硬件调度单位:warp是GPU硬件调度和执行的基本单位。GPU的warp调度器负责管理这些warp的执行。
线程块与Warp之间的关系
线程块划分为Warp:当线程块被提交到GPU执行时,它被划分为多个warp。这个划分是自动进行的,基于warp的大小(如32个线程)。
连续线程ID:每个warp包含具有连续线程ID的线程。例如,在32线程的warp中,第一个warp包含线程ID 0-31,第二个warp包含线程ID 32-63,依此类推。
并行执行:线程块内的所有warp可以在GPU上并行执行,但每个warp内的线程同时执行相同的指令。
执行效率:合理地组织线程块和warp对于实现高效的GPU并行计算至关重要。线程块的大小应该是warp大小的整数倍,以最大化GPU核心的利用率并减少空闲线程。
SIMD vs SIMT
GPU并不提供对SIMD的支持,因此考虑并行应该是SIMT,多个线程执行相同的指令。
GPU可以理解为一系列Multiprocessor的集合,如上图。Multiprocessor内部的processor可以共享一部分数据。
因此线程组的数量应该和multiprocessor数量对应(线程组可以多于multiprocessor数量)。
而warp是一组线程,是最小的调度单位。warp内的线程并行执行,但不同的warp之间是否并行取决于资源的分配(实际上这是由于某些操作会被挂起等待,转而执行其他的warp)。
CS
Thread Blocks:一个三维逻辑结构
Coding
Check maxComputeWorkGroupInvocations and maxComputeWorkGroupSize in VkPhysicalDeviceLimits
查询线程组数量和线程组大小限制
线程组的数量由API制定,线程组的大小在GLSL代码中被指定
Sync
共享内存
// Array allocated in shared memory to be shared by all invocations in a work group.
// Check VkPhysicalDeviceLimits::maxComputeSharedMemorySize for the maximum
// total storage size, in bytes, available for variables shared by all invocations in a work group.
shared vec4 gCache_0[256];
在一个线程组中的线程的执行很难确定先后顺序,如果多个线程访问到相同的数据,可能会造成线程冲突。
void main()
{
ivec2 textureLocation = ivec2(gl_GlobalInvocationID.xy);
// Read texel from input image at mipmap level 0 and
// save the result in the shared memory
gCache_0[gl_LocalInvocationID.x] = imageLoad(InputTex, textureLocation);
// Wait for all invocations in the work group
barrier();
// OK!
vec4 left = gCache_0[gl_LocalInvocationID.x - 1];
vec4 right = gCache_0[gl_LocalInvocationID.x + 1];
// ...
}
使用屏障保证数据写入完成。
barrier()函数的作用
同步工作组内的着色器调用:在计算着色器中,barrier()函数确保在一个工作组(workgroup)内的所有着色器调用都到达这个屏障点之前,任何一个着色器调用都不会继续执行超过这个点的代码。
在曲面细分控制着色器中的应用:barrier()函数也可以在曲面细分控制着色器中使用,以同步单个输入补丁的所有着色器调用。
控制流的一致性
控制流必须是一致的:在计算着色器中使用barrier()时,控制流必须是一致的。这意味着如果任何一个着色器调用进入了一个条件语句,那么所有的调用都必须进入它。
barrier()与内存同步
控制流和共享变量的同步:barrier()函数影响控制流,并同步对共享变量的内存访问(以及曲面细分控制输出变量)。
其他内存访问:对于非共享变量的内存访问,barrier()函数并不能保证一个着色器调用在barrier()之前写入的值可以被其他调用在barrier()之后安全地读取。
使用内存屏障函数
确保内存访问的顺序:为了确保一个着色器调用写入的值可以被其他调用安全地读取,需要同时使用barrier()和特定的内存屏障函数。
内存屏障函数的作用:内存屏障函数用于对可被其他着色器调用访问的内存中的变量进行读写操作的排序。这些函数在被调用时,会等待调用者之前执行的所有读写操作的完成,然后再返回。
不同类型的内存屏障函数
特定类型变量的内存屏障:如memoryBarrierAtomicCounter()、memoryBarrierBuffer()、memoryBarrierImage()和memoryBarrierShared()等函数,分别用于等待对原子计数器、缓冲区、图像和共享变量的访问的完成。
全局内存屏障:memoryBarrier()和groupMemoryBarrier()函数用于等待对所有上述变量类型的访问的完成。
着色器类型的可用性:memoryBarrierShared()和groupMemoryBarrier()只在计算着色器中可用,而其他函数在所有着色器类型中都可用。
例子:粒子系统
使用GPU更新顶点位置,避免了受到总线带宽的限制。
SSBO
需要一个能读能写的buffer,SSBO可以满足这一点。
flag为:
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT | VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
Buffer type
. VK_BUFFER_USAGE_STORAGE_BUFFER_BIT
• Description: This flag indicates that the buffer can be used as a storage buffer. Storage buffers are used to store large blocks of data that shaders (especially compute shaders) can read from and write to.
• Common Use Case:
• It is commonly used in compute shaders or fragment shaders where you need random-access read and write operations.
• It’s suitable for large datasets that may change frequently, like results of computations or intermediate data.
• Access in Shaders:
• In GLSL (Vulkan’s shading language), a buffer marked with VK_BUFFER_USAGE_STORAGE_BUFFER_BIT is typically accessed using the layout(std430) storage qualifier, like this:
• Both read and write operations are possible in shaders.
VK_BUFFER_USAGE_TRANSFER_SRC_BIT
• Description: This flag indicates that the buffer can be used as a source for data transfer operations. Specifically, this buffer can be used in a memory transfer operation, where data from this buffer will be copied to another buffer or image (e.g., via vkCmdCopyBuffer or vkCmdCopyBufferToImage).
• Common Use Case:
• When you want to copy data from one buffer to another buffer or image, the source buffer should be created with this flag.
• It’s useful when you’re doing staging operations: you might upload data to a buffer that’s visible to the CPU (with VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT), then transfer it to a GPU-only buffer with VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT.
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT用于顶点着色器,VK_BUFFER_USAGE_STORAGE_BUFFER_BIT用于计算着色器的写入和读取。
VK_BUFFER_USAGE_TRANSFER_DST_BIT表明该buffer是数据复制的目的buffer。例如从staging buffer到该buffer。
VK_BUFFER_USAGE_TRANSFER_SRC_BIT表明该buffer是数据复制的起源。例如staging buffer。
Storage Image
借助SI可以完成对图片的操作,例如后处理、生成mip-maps等。
信号量和栅栏
计算cmd需要栅栏避免冗余提交(确保之前的
指令已经被执行再提交,cpu和gpu之间),计算管线和图形管线之间需要信号量,保证图形管线开始时计算任务已经完成(gpu内部)。
[!note]
解释了为什么信号量以数组形式给出:
VkSemaphore waitSemaphores[] = { computeFinishedSemaphores[currentFrame], imageAvailableSemaphores[currentFrame] };
延伸:
Asynchronous compute
[[TBDR]]
Touch background to close