Now Loading ...
-
3.优化VPLs采样
动机
一个典型的RSM可能包含成千上万甚至数百万个VPLs。对着色点 p,计算每一个VPL的贡献是极其耗费性能的。一个朴素的优化是只在着色点 p 周围的一个固定半径内采样VPLs。
但是,即便如此,也不是所有邻近的VPL都同样重要。有些VPL可能因为角度、遮挡或者自身亮度很低,对着色点 p 的贡献微乎其微。如果我们用均匀采样(Uniform Sampling),在采样区域内随机或均匀地选取VPLs,就会浪费大量的计算在这些贡献很小的VPL上,导致结果充满噪点(noise),或者需要极大量的样本才能获得平滑的效果。
重要性采样 (Importance Sampling)
重要性采样的核心思想是:与其均匀地采样,不如“智能”地将更多的采样机会分配给那些贡献最大的VPLs。这样,我们就可以用更少的样本数量,获得更高质量、更低噪点的结果。
一个VPL对点 p 的光照贡献有多“重要”呢?这通常取决于以下几个因素,这构成了我们的重要性度量(Importance Metric):
VPL的辐射通量(Flux):VPL本身越亮,它的贡献就越大。
几何项(G-Term):
着色点 p 的法线 n 与 VPL -> p 方向的夹角 ($cos\theta_p$)。
VPL的法线 $n_{vpl}$ 与 p -> VPL 方向的夹角 ($cos\theta_{vpl}$)。
p 与VPL之间的距离衰减 ($1/d^2$)。
BRDF:着色点 p 的表面材质属性。
综合起来,一个VPL的贡献可以近似地用下面的渲染方程的简化形式来描述:
\[L_o(p, \omega_o) = \int_{\Omega} f_r(p, \omega_i, \omega_o) \cdot L_i(p, \omega_i) \cdot \cos(\theta_i) \, d\omega_i\]
在使用VPLs时,这个积分变成了对所有VPLs的求和:
\[L_{indirect}(p) \approx \sum_{k=1}^{N} \frac{\Phi_k}{\pi} \cdot BRDF(p) \cdot \frac{\max(0, n \cdot \omega_k) \cdot \max(0, n_k \cdot -\omega_k)}{||p_k - p||^2} \cdot V(p, p_k)\]
其中:
$\Phi_k$ 是第k个VPL的通量(flux)。
$BRDF(p)$ 是点p的BRDF(代码中是 albedo / PI)。
$p_k, n_k$ 是第k个VPL的位置和法线。
$\omega_k$ 是从p指向 $p_k$ 的归一化向量。
$V(p, p_k)$ 是可见性函数(在代码中通过剔除背面和距离过近的点来简化)。
重要性采样的目标就是找到一个概率密度函数(PDF),使得采样分布与这个贡献函数尽可能相似。这里类似于光线追中中蒙题卡洛采样的思想,使用PDF确定光线。
均匀采样 vs. 重要性采样
均匀采样
这是传统的、非重要性采样的方法。
// === UNIFORM SAMPLING - Original Strategy ===
vec2 offs[32] = vec2[32](...); // 预定义的32个均匀分布的采样偏移
int N = min(samples, 32);
...
for (int i = 0; i < N; ++i) {
// 对偏移加上一点随机扰动,减少条带状瑕疵
vec2 jitter = ...;
vec2 duv = (offs[i] + jitter * 0.05) * radius / ...;
vec2 uv = clamp(baseUV + duv, 0.0, 1.0);
// 从RSM纹理中获取VPL信息
vec3 vplPos = texture(rsmPositionTex, uv).xyz;
vec3 vplNor = normalize(texture(rsmNormalTex, uv).xyz);
vec3 flux = texture(rsmFluxTex, uv).xyz;
// ... (检查VPL有效性) ...
// 计算光照贡献
vec3 wi = vplPos - p;
float dist = length(wi);
wi = normalize(wi);
float cos1 = max(dot(n, wi), 0.0);
float cos2 = max(dot(vplNor, -wi), 0.0);
float distWeight = 1.0 / (1.0 + dist * dist);
float sampleWeight = cos1 * cos2 * distWeight; // 权重
vec3 brdf = albedo / 3.14159;
bounce += brdf * flux * sampleWeight;
totalWeight += sampleWeight;
}
解释:
它使用一个固定的采样模式 offs 在着色点周围的RSM区域内进行采样。
每个样本被选中的概率是相同的。
它计算每个VPL的贡献 (brdf * flux * sampleWeight),然后累加起来。
这种方法简单直接,但效率低下。如果采样区域内大部分VPL的 flux 很小或者 sampleWeight 接近于0,那么很多采样都是无效的。
三阶段自适应重要性采样
阶段 1: 粗略分析 (Coarse Analysis Pass)
这个阶段的目标是快速找到哪个方向的VPLs最重要。
// Phase 1: Coarse Analysis Pass (8 samples)
vec2 coarseOffs[8] = vec2[8](...); // 8个方向上的粗略采样点
float maxImportance = 0.0;
vec2 bestRegion = vec2(0.0);
for (int i = 0; i < 8; ++i) {
vec2 duv = coarseOffs[i] * radius * 0.5 / ...;
vec2 uv = clamp(baseUV + duv, 0.0, 1.0);
// 获取VPL信息
vec3 vplPos = texture(rsmPositionTex, uv).xyz;
vec3 vplNor = texture(rsmNormalTex, uv).xyz;
vec3 flux = texture(rsmFluxTex, uv).xyz;
if (length(vplPos) < 0.1) continue;
// 计算重要性度量
vec3 wi = normalize(vplPos - p);
float cos1 = max(dot(n, wi), 0.0);
float cos2 = max(dot(vplNor, -wi), 0.0);
float fluxMag = length(flux);
float importance = cos1 * cos2 * fluxMag; // 核心:重要性函数
if (importance > maxImportance) {
maxImportance = importance;
bestRegion = duv; // 记录下最重要的区域的偏移方向
}
}
解释:
它只用了8个样本,在周围8个方向上进行探测。
它计算了一个重要性度量(Importance Metric):
\(\)\(\text{Importance} = \max(0, n \cdot \omega_i) \cdot \max(0, n_{vpl} \cdot -\omega_i) \cdot ||\text{Flux}||\)
\(\)这个公式忽略了距离衰减(因为这是一个方向性探测)和BRDF(假设为常数),但抓住了影响贡献度的核心要素:几何关系和VPL亮度。
循环结束后,bestRegion 变量存储了最有潜力的采样方向。
阶段 2: 集中密集采样 (Focused Dense Sampling)
在找到“黄金区域”后,这个阶段将大部分样本(20个)集中投放到该区域内部及其周围。
// Phase 2: Focused Dense Sampling (20 samples)
vec2 denseOffs[20] = vec2[20](...); // 20个在小范围内的密集偏移
...
for (int i = 0; i < 20; ++i) {
vec2 localOffset = denseOffs[i] * 0.3;
// 关键:所有采样都围绕着 bestRegion 进行
vec2 duv = (bestRegion + localOffset * radius / ...);
vec2 uv = clamp(baseUV + duv, 0.0, 1.0);
// ... 和均匀采样类似,获取VPL信息并计算贡献 ...
bounce += brdf * flux * sampleWeight;
totalWeight += sampleWeight;
}
解释:
所有的采样偏移 duv 都是基于第一阶段找到的 bestRegion 计算的。这确保了大部分计算资源都用在了刀刃上。
这种策略极大地提高了采样的效率,因为我们更有可能采样到贡献大的VPLs。
阶段 3: 覆盖采样 (Coverage Sampling)
只在最亮的区域采样可能会导致问题:如果场景中有多个次要的光源贡献区域,完全忽略它们会造成能量损失和颜色偏移。这个阶段用少量样本(4个)来覆盖更广泛的区域,以拾取那些被前两个阶段可能忽略掉的贡献。
// Phase 3: Coverage Sampling (4 samples)
vec2 coverageOffs[4] = vec2[4](...); // 4个随机分布在较大范围的偏移
...
for (int i = 0; i < 4; ++i) {
vec2 duv = coverageOffs[i] * radius / ...;
// ... 计算并累加贡献 ...
}
解释:
这4个样本被放置在采样半径内比较分散的位置,扮演着“查漏补补缺”的角色。
它确保了即使我们的“最佳区域”判断有误,或者存在多个重要区域时,渲染结果也不会出现大的瑕疵。
效果对比
不启用重要性采样:
启用重要性采样:
其实除了墙角处效果有明显区别,其他多数区域效果并没有明显提升,可能是由于当前的采样点数量(32)已经足够大,即使均匀采样也能取得不错的效果
速度对比
然而,启用重要性采样后,帧时间从12ms降低到9ms。在代码中,均匀采样和重要性采样都采样32个点,在采样点数量一致的情况下,执行速度的提升主要是由于==重要性采样的工作模式对GPU的并行处理流水线极为友好,而均匀采样的“盲目性”则会频繁地打断流水线,造成效率下降。==
线程发散 (Thread Divergence)
GPU并非一个一个地处理像素,而是将屏幕上成百上千的像素(着色器实例)打包成一个个线程组(在NVIDIA上称为Warp,通常是32个线程;在AMD上称为Wavefront)。在同一个线程组内,所有线程在同一时刻执行完全相同的指令。
现在我们来看循环内部的关键判断语句:
// 这三行是性能的关键
if (length(vplPos) < 0.1) continue; // VPL无效,跳过
if (dist < 0.05) continue; // VPL离自己太近,跳过
if (cos1 < 0.05 || cos2 < 0.05) continue; // VPL朝向不对,跳过
当一个线程组(比如32个相邻的像素)遇到if语句时,会发生什么?
理想情况 (高连贯性): 如果线程组里所有32个线程的判断结果都一样(比如都为true或都为false),那么GPU就可以无缝地、集体地执行if块内的代码或者集体跳过。这是最高效的。
糟糕情况 (线程发散): 如果线程组里部分线程结果为true,另一部分为false,就发生了“线程发散”。这时,GPU不得不同时处理两个分支。它会先执行if为true的路径,此时false的线程被临时“关闭”等待;然后再执行if为false的路径,此时true的线程被“关闭”等待。最终,整个线程组的耗时是两条路径耗时之和,效率大打折扣。
现在我们把这个原理应用到两种采样方法上:
均匀采样 (12ms,慢)
它的采样点是分散的、随机的。
对于一个线程组(32个相邻像素),它们各自随机采样的32个VPL,情况会非常混乱:
像素A的第5个样本可能是无效的 (continue)。
邻居像素B的第5个样本可能是有效的 (执行完整计算)。
邻居像素C的第5个样本可能因为朝向不对而continue。
这就导致在循环的几乎每一次迭代中,线程组内部都存在大量的线程发散。GPU的流水线被频繁地打断和等待,即使很多线程因为continue跳过了大量计算,但整个线程组仍然要为那些没有跳过的“幸运”线程付出等待的时间成本。
重要性采样 (9ms,快)
它的采样点是高度结构化和局部化的。
由于相邻像素的位置和法线通常很相似,它们在第一阶段找到的bestRegion(最佳区域)也极有可能是同一个或非常邻近的区域。
因此,当一个线程组(32个相邻像素)进入第二阶段的密集采样时,它们采样的VPL都来自RSM纹理上的一小块相似区域。
结果就是:
如果这个区域的VPL是有效的,那么线程组里几乎所有线程采到的VPL也都是有效的,大家一起执行完整的计算。
如果这个区域的VPL是无效的,那么线程组里几乎所有线程都会触发continue,大家一起跳过。
这种高度的执行连贯性最大化了GPU的并行效率。虽然它可能执行了更多次完整的循环体(因为采到的都是有效样本),但由于没有线程发散造成的流水线停顿,整体的“吞吐量”反而更高,执行速度更快。
缓存效率 (Cache Locality)
采样点的分布也会影响缓存的效率,这一思想在我们之前的一个优化模糊算法的项目中也有体现。
均匀采样的样本在RSM纹理上是随机分散的,这会导致纹理缓存(Texture Cache)命中率低。GPU需要频繁地从速度较慢的显存中去读取数据。
重要性采样的样本高度集中在bestRegion周围,这使得纹理缓存命中率极高。一旦第一个线程读取了某一块纹理数据,它就会被加载到高速缓存中,后续线程(无论是同一个像素的后续样本,还是邻居像素的样本)都能极快地从缓存中获取数据,进一步提升了性能。
-
2.RSM阴影和一次间接光照
动机
[!小球的底部没有接受到反射光。]
处理这种情况(缺少一次间接光照)最直接的方法是使用RSM。但是正如之前的博客提到的,基于光线步进的软阴影和基于Shadow Map的阴影(RSM和Shadow Map可以看作一类方法)可以被看作两种生成阴影的方法。
因此在使用RSM之前,还需要将之前的阴影生成的方法改为基于Shadow Map的方法。
软阴影算法问题
==产生的阴影总是矩形规则的==
这里询问了AI模型,他的回答是:
- 核心原因
- RSM 阴影是不规则的:它在“光空间”里真地投影几何体。我们先用一个正交光相机把场景光线步进出 position/normal/flux,再在主通道里对这些“光照纹理”做深度比较+PCF 聚合。阴影轮廓因此跟真实物体的光空间轮廓一致(还会叠加分辨率、采样核与偏置带来的不规则性)。
- 软阴影是近似的:`softShadow()` 只是沿单条光线做 SDF 步进,用 `res = min(res, k*h/t)` 这种“局部距离比值”近似可见性。你的房间由多块轴对齐平面用 `min()` 拼出来,接收面本身就是“最近表面”,沿光线的距离场变化在平面/平面交线处是分段线性的,叠加步长上限/阈值后,投到墙上的等值线就会呈现出规则的、近似矩形的边界。这不是几何投影结果,而是该近似在“由平面拼接”的场景里呈现的典型伪影。
- 进一步理解
- RSM 近似了“面积光/多 VPL”的可见性,聚合了很多光空间样本,所以会出现椭圆/弧形乃至锯齿感的真实阴影边界。
- 软阴影近似的是“点光 + 距离场安全步进”,对由 `min(平面…)` 组成的房间会把阴影边界“对齐”到这些平面的主方向,肉眼看起来就是规则矩形。
- 优化的思路
- 在阴影查询中排除接收面:对当前像素所属材质做一次“无接收面”的 `sceneSDF`(或把该平面 SDF 加小偏移/屏蔽),避免接收面主导 `h/t`。
- 多方向/多半径采样(模拟面积光,PCSS 风格):对 `rd` 周围做抖动采样而不是单射线。
- 真正按光空间做一次二次步进(从光源/光相机到接收点再做 SDF 硬测试+PCF)——本质上等价于你现在的 RSM 路线。
时间原因,我还没有做深入的分析,先保留问题,之后详细分析。
分析软阴影算法的阴影形状
RSM计算
[[Study/Games104/补充/RSM|RSM]]
效果
抱歉,您的浏览器不支持内嵌视频。
核心问题与整体思路
问题:SDF 场景通常用 Ray Marching 做直接光照与阴影,但间接光(全局光照)成本高、质量难平衡。
RSM 思路:从光源视角渲染一次场景,得到三张纹理:
Position(世界坐标)
Normal(世界法线)
Flux(经材质反射后的辐射通量,近似出射光能)
这些纹理中的每个像素可视作一个虚拟点光源(Virtual Point Light, VPL)。主渲染时,围绕当前像素在 RSM 中邻域采样若干 VPL,估算一次反弹间接光。
流水线分两步:
1) Light-pass:构建光源正交相机,Ray Marching 与材质评估,输出 Position/Normal/Flux。
2) Main-pass:投影到光相机平面,做阴影可见性(PCF/深度比较),并在 RSM 上做邻域采样累积间接光,和直接光组合。
SDF 基础与 Ray Marching 复盘
本文代码中的 SDF 以球体和房间(墙/地/顶)构成。距离场与法线估计:
距离场:给定位置 p,返回到最近表面的符号距离 $d(p)$
法线近似:用四点差分估计梯度
\(\nabla d(p) \approx \Big(
d(p+\epsilon\,k_{xyy}) - d(p+\epsilon\,k_{yyx}),
d(p+\epsilon\,k_{yxy}) - d(p+\epsilon\,k_{xxx}),
d(p+\epsilon\,k_{xxy}) - d(p+\epsilon\,k_{yyx})
\Big)\,\text{并归一化}\)
实际代码采用紧凑写法(四向量加权求和)。Ray Marching 采用安全步进策略:每步前进当前距离场值(设下限避免驻留)。
关键参数:
MAX_STEPS = 128:最多步进次数
MAX_DIST = 100.0:最大追踪距离
SURF_DIST = 0.006:命中阈值
步长下限如 max(dS, 0.003) 防止在平面/浅角处过度迭代
Part 1:从光源视角生成 RSM(三缓冲)
文件:shaders/rsm_light.frag
1. 光源正交相机与射线构建
代码(节选):
vec2 uv = fragTexCoord * 2.0 - 1.0;
vec3 ro = u.lightOrigin.xyz
+ u.lightRight.xyz * (uv.x * u.lightOrthoHalfSize.x)
+ u.lightUp.xyz * (uv.y * u.lightOrthoHalfSize.y);
vec3 rd = normalize(u.lightDir.xyz);
将屏幕 $[0,1]$ UV 映射为光相机平面上的世界位置 ro。
使用正交投影:所有像素射线方向 rd 相同(与光方向一致)。
lightRight/lightUp 为光相机基向量,lightOrthoHalfSize 控制覆盖范围(半宽/半高)。
数学上,光相机平面参数化为:
\(\mathbf{ro}(u,v)=\mathbf{o}_L + (2u-1)\,s_x\,\mathbf{r}_L + (2v-1)\,s_y\,\mathbf{u}_L\)
其中 $\mathbf{o}_L$是 lightOrigin, $\mathbf{r}_L$, $\mathbf{u}_L$ 分别是 lightRight, lightUp,$s_x,s_y$ 为半尺寸。
==ro = u.lightOrigin + 水平位移 + 垂直位移==,在3D空间中构建了一个矩形平面。这个平面的中心是 lightOrigin,朝向由 lightRight 和 lightUp 决定,大小由 lightOrthoHalfSize 决定。
注意,当lightOrthoHalfSize值太小时,发出的射线无法覆盖整个场景,RSM的第一个pass只能在场景的一部分上记录G-Buffer,因此会出现明显的光照错误。
错误的光照场景:
// 当前的策略是,在光源视锥体以外的所有区域均视作点亮
if (any(lessThan(uv, vec2(0.0))) || any(greaterThan(uv, vec2(1.0)))) {
return 1.0; // outside light frustum -> treat as lit
}
调整光源的大小正确覆盖整个场景:
2. Ray Marching 与命中属性
float d = rayMarch(ro, rd);
if (d >= MAX_DIST) { outPosition=outNormal=outFlux=vec4(0.0); return; }
vec3 pos = ro + rd * d;
vec3 nor = getNormal(pos);
float nDotL = max(dot(nor, -rd), 0.0);
未命中写 0,主渲染即可判定该像素无效 VPL。
命中后得到世界坐标 pos 与法线 nor。
法向量计算
对于传统的三角形网格模型,法线通常是预先计算好并存储在顶点数据中的。但SDF描述的是一个隐式曲面(implicit surface),它不是由顶点或三角形定义的,而是由一个函数 sceneSDF(p) 定义的。这个函数返回空间中任意点 p 到场景最近表面的距离。曲面本身就存在于所有满足 sceneSDF(p) = 0 的点上。
核心思想:法线是SDF的梯度
一个标量场(如此处的SDF)的梯度是一个向量,它指向该场值增长最快的方向。对于SDF来说,在表面上的任意一点,距离值增长最快的方向正好是垂直于表面向外的方向——法线方向。
数学公式
一个函数 $f(x, y, z)$ 的梯度,记作 $\nabla f$,其数学定义如下:
\[\nabla f(p) = \left( \frac{\partial f}{\partial x}, \frac{\partial f}{\partial y}, \frac{\partial f}{\partial z} \right)\]
其中,$p = (x, y, z)$,$\frac{\partial f}{\partial x}$ 是函数 $f$ 对 $x$ 的偏导数。这个向量的每个分量表示函数在对应坐标轴方向上的变化率。
数值近似
理论上,我们只需要计算 sceneSDF 函数在点 p 处的偏导数即可得到梯度。但在着色器代码中,sceneSDF 函数通常非常复杂,包含各种旋转、平移和 min、max 等操作。对这样的函数进行分析求导(analytically derive the partial derivatives)几乎是不可能的。
因此,我们采用一种数值近似的方法,称为有限差分 (Finite Differences)。其基本思想是:通过在目标点 p 附近的一个极小邻域内对SDF进行采样,来估算其在各个方向上的变化率。
最常用的方法是中心差分法 (Central Differences)。它估算偏导数的方法如下:
\[\frac{\partial f}{\partial x} \approx \frac{f(x+h, y, z) - f(x-h, y, z)}{2h}\]
这里,$h$ 是一个非常小的数(在代码中通常称为 epsilon 或 h)。我们通过在x轴正方向和负方向上各移动一点点,然后用两者SDF值的差来近似该方向的变化率。
将这个方法应用到所有三个坐标轴,我们就可以得到梯度的近似值:
\[\nabla \text{sceneSDF}(p) \approx \frac{1}{2h} \begin{pmatrix}
\text{sceneSDF}(p + (h, 0, 0)) - \text{sceneSDF}(p - (h, 0, 0)) \\
\text{sceneSDF}(p + (0, h, 0)) - \text{sceneSDF}(p - (0, h, 0)) \\
\text{sceneSDF}(p + (0, 0, h)) - \text{sceneSDF}(p - (0, 0, h))
\end{pmatrix}\]
这种方法需要调用6次 sceneSDF 函数,计算成本较高。
优化数值近似方法
如前文所言,使用有限差分需要计算6次场景函数。这里采用了一种常见的优化技巧:通过在空间中构造一个微小的四面体(tetrahedron)来进行采样,仅需要调用4次 sceneSDF 就能估算出梯度。
// 计算SDF在点p处的法线
// 原理:通过采样p点周围极小范围内的SDF值来估算SDF场的梯度,梯度方向即为法线方向
vec3 getNormal(vec3 p) {
const float h = 0.001;
const vec2 k = vec2(1, -1);
return normalize(k.xyy * sceneSDF(p + k.xyy * h) +
k.yyx * sceneSDF(p + k.yyx * h) +
k.yxy * sceneSDF(p + k.yxy * h) +
k.xxx * sceneSDF(p + k.xxx * h));
}
const float h = 0.001;
定义了一个极小的偏移量 h。这个值需要足够小以精确估算梯度,但又不能太小,否则可能因为浮点数精度问题导致结果错误。0.001 是一个常用的经验值。
const vec2 k = vec2(1, -1);
这是一个辅助向量,用于巧妙地生成四面体的四个顶点方向向量。通过 swizzling 操作(如 k.xyy),我们可以用它组合出 (1, -1, -1), (-1, -1, 1), (-1, 1, -1), 和 (1, 1, 1) 这些向量。
核心计算
k.xyy 对应 vec3(1, -1, -1)
k.yyx 对应 vec3(-1, -1, 1)
k.yxy 对应 vec3(-1, 1, -1)
k.xxx 对应 vec3(1, 1, 1)
整个表达式计算了一个向量:
grad.x = 1 * sceneSDF(p + h*vec3(1,-1,-1)) - 1 * sceneSDF(p + h*vec3(-1,-1,1)) - 1 * sceneSDF(p + h*vec3(-1,1,-1)) + 1 * sceneSDF(p + h*vec3(1,1,1))
虽然这个表达式看起来和我们之前推导的中心差分法不同,但它在数学上也是对梯度的有效近似。它通过组合这四个采样点的值,巧妙地估算出了SDF在x, y, z三个方向上的变化率。这种方法的优势在于减少了 sceneSDF 的调用次数(从6次降到4次)。
return normalize(...)
我们需要的是单位法向量,所以必须使用 normalize() 函数将其归一化。
3. 材质与通量(Flux)评估
vec3 albedo = getMaterialAlbedo(pos);
vec3 lightColor = u.lightColors[0].rgb * u.lightColors[0].a;
vec3 flux = albedo * lightColor * u.lightDir.w * nDotL * 2.0;
albedo 与 sceneSDF 对齐判断:哪个物体最近即取其材质色。
flux 近似为一次漫反射出射能量:
\(\Phi(\mathbf{x}) \approx \rho(\mathbf{x})\,E_L\,\max(0,\mathbf{n}\cdot\mathbf{l})\,g\)
其中 $\rho$ 为反照率,$E_L$ 为光强(含颜色/强度),$g$ 是经验增益(代码中为 2.0,用于增强间接光)。
4. 输出三缓冲(MRT)
outPosition = vec4(pos, 1.0);
outNormal = vec4(nor, 0.0);
outFlux = vec4(flux, 1.0);
Position.xyz:世界坐标,w=1 作为有效标记
Normal.xyz:世界法线
Flux.rgb:经光源调制的反射通量
Part 2:主渲染消费 RSM(阴影 + 间接光)
文件:shaders/sdf_practice.frag
Part 2 整体流程(数据与控制流)
相机射线与命中:由屏幕 uv 与相机基向量求 rd,用 Ray Marching 得到命中距离 d、交点 p=ro+rd*d 与法线 n,并根据 getMaterial(p) 取 albedo。
投影到光相机:将世界点 p 投影到光源正交平面,得到 baseUV(光空间中的中心采样坐标)。
阴影可见性:
若 u.rsmParams.w>0.5,用 RSM Position 做“类深度”PCF 比较得到 shadow∈[0,1];否则退回 SDF 软阴影。
将 shadow 经强度和最暗值混合(防止全黑)。
直接光:用 shadow 调制主光漫反射与高光累加到 finalColor。
间接光(可选):若 u.rsmParams.w>0.5 && u.rsmParams.z>0.5,在 baseUV 周围半径/样本数受 rsmParams.xy 控制的邻域上采样 RSM(三缓冲),按 Lambert BRDF 聚合 VPL 贡献并归一化,按 shadow 自适应强度加入间接光。
其他光照与反射:填充光、边缘光、环境反射等艺术项叠加。
雾与色调:按距离做雾化与微弱色调混合,输出。
简化伪代码:
hit = rayMarch(ro, rd);
if (!hit) return sky;
p = ro + rd * d; n = getNormal(p); albedo = material(p);
baseUV = projectToLight(p);
shadow = (useRSM) ? rsmShadow(p, n) : softShadow(...);
color += directLight(p, n, albedo) * shadow;
if (useRSM && useIndirect) color += rsmIndirect(p, n, albedo, baseUV);
color = addFillRimEnv(color, ...);
color = applyFogTone(color, d);
A. 阴影可见性:基于 RSM Position 的“类深度”比较
函数:rsmShadow(p, n)(节选)
vec3 rel = p - u.lightOrigin.xyz;
vec2 base = vec2(dot(rel, u.lightRight.xyz)/u.lightOrthoHalfSize.x,
dot(rel, u.lightUp.xyz)/u.lightOrthoHalfSize.y);
vec2 uv = base * 0.5 + 0.5;
vec3 Ld = normalize(u.lightDir.xyz);
float tSurface = dot(rel, Ld);
// PCF 样本中,从 rsmPositionTex 读出 VPL 的世界坐标
vec3 vplPos = texture(rsmPositionTex, uv + duv).xyz;
float tRsm = dot(vplPos - u.lightOrigin.xyz, Ld);
float bias = 0.02 + 0.10 * (1.0 - max(dot(n, Ld), 0.0));
float visible = (tRsm + bias < tSurface) ? 0.0 : 1.0;
将世界点 p 投影到光相机平面,得到 RSM 纹理坐标 uv。
以光方向 Ld 定义“深度”标量:$t=\langle x-\mathbf{o}_L,\,\mathbf{L}_d\rangle$。
用 Position 缓冲的世界坐标反推 RSM 像素的“深度” $t_{RSM}$ 与当前表面“深度” $t_{surface}$ 比较。
斜率偏置 bias 随 $1-\mathbf{n}\cdot\mathbf{L}_d$ 增大,缓解自阴影。
使用 8 点 PCF 平均可见性,半径受 u.rsmParams.x 与纹理分辨率缩放。
数学上,单样本可见性为:
\(V = \mathbb{I}\big[t_{RSM} + b < t_{surface}\big]\)
多样本 PCF 取均值:$\bar V = \frac{1}{N}\sum_i V_i$。
可见性作为直接光的阴影因子:
finalColor += (diff * albedo + spec * lightColor) * lightColor * shadow * u.lightDir.w;
其中 shadow 由 RSM/PCF 得到(或回退为 SDF 软阴影)。
如何将一个3D空间中的点 p 投影到光源视角?
这个投影过程的核心目标是:找出三维空间中的点 p 会出现在光源“摄像机”所渲染的二维纹理(即RSM纹理)的哪个位置上。
// Project point to light ortho plane
vec3 rel = p - u.lightOrigin.xyz;
vec2 base = vec2(dot(rel, u.lightRight.xyz) / max(u.lightOrthoHalfSize.x, 1e-4),
dot(rel, u.lightUp.xyz) / max(u.lightOrthoHalfSize.y, 1e-4));
vec2 uv = base * 0.5 + 0.5;
这个过程可以分解为以下四个步骤:
a. 转换到光源的局部坐标系(平移)
vec3 rel = p - u.lightOrigin.xyz;
p: 这是我们要投影的那个点的世界坐标(World Space a_position)。
u.lightOrigin.xyz: 这是光源“摄像机”在世界空间中的位置。
rel (relative a_position): 通过用点的坐标减去光源的原点坐标,我们得到了一个从光源指向点 p 的向量。这等效于将整个坐标系进行平移,使得光源位于原点(0, 0, 0)。现在,rel 就代表了点 p 在以光源为中心的新坐标系中的位置。
b. 投影到光源的视图平面(投影)
vec2 base = vec2(dot(rel, u.lightRight.xyz),
dot(rel, u.lightUp.xyz));
(为了清晰,暂时忽略后面的除法)
这一步是整个投影过程的核心。光源拥有自己的一套坐标基向量,类似于主摄像机有 cu (右), cv (上), cw (前)。这里的 u.lightRight 和 u.lightUp 就扮演了光源摄像机的“右”和“上”的角色。
u.lightRight.xyz: 一个单位向量,代表光源视图的水平方向(X轴)。
u.lightUp.xyz: 一个单位向量,代表光源视图的垂直方向(Y轴)。
dot(a, b) (点积): 当向量 b 是一个单位向量时,dot(a, b) 的几何意义是计算向量 a 在向量 b 方向上的投影长度。
所以:
dot(rel, u.lightRight.xyz) 计算出点 p (相对于光源) 在光源 “右” 方向上的距离。这就是点 p 在光源视图中的 X坐标。
dot(rel, u.lightUp.xyz) 计算出点 p (相对于光源) 在光源 “上” 方向上的距离。这就是点 p 在光源视图中的 Y坐标。
执行完这一步后,base 这个 vec2 变量就存储了点 p 在光源二维视图平面上的坐标(以世界单位计)。
c. 规范化坐标(正交投影)
vec2 base = vec2(...,
... / max(u.lightOrthoHalfSize.y, 1e-4));
这个Shader使用的是正交投影(Orthographic Projection),这意味着光源的视锥体是一个长方体,而不是像透视投影那样的角锥体。
u.lightOrthoHalfSize: 这个变量定义了光源视锥体(那个长方体)尺寸的一半。u.lightOrthoHalfSize.x 是宽度的一半,u.lightOrthoHalfSize.y 是高度的一半。
通过将上一步得到的坐标除以 lightOrthoHalfSize,我们将坐标从世界单位转换为了规范化设备坐标 (Normalized Device Coordinates, NDC)。
如果一个点正好在光源视锥体的右边缘,它的X坐标就等于 lightOrthoHalfSize.x,相除后得到 1.0。
如果一个点正好在左边缘,它的X坐标是 -lightOrthoHalfSize.x,相除后得到 -1.0。
Y坐标同理。
经过这步计算后,base 变量的 x 和 y 分量都被映射到了 [-1.0, 1.0] 的范围内。任何在这个范围之外的点都意味着它在光源的视锥体之外。
d. 转换为UV纹理坐标
vec2 uv = base * 0.5 + 0.5;
最后一步是将 [-1.0, 1.0] 的NDC坐标转换为 [0.0, 1.0] 的UV纹理坐标,以便能正确地采样RSM纹理。这是一个标准的线性变换:
当值为 -1.0 时: -1.0 * 0.5 + 0.5 = 0.0
当值为 0.0 时: 0.0 * 0.5 + 0.5 = 0.5
当值为 1.0 时: 1.0 * 0.5 + 0.5 = 1.0
计算完成后,uv 变量就包含了点 p 在光源渲染的RSM纹理上对应的二维坐标。有了这个坐标,我们就可以去 rsmPositionTex, rsmNormalTex, 和 rsmFluxTex 中采样,获取那个位置的深度、法线和光通量信息,用于阴影计算和间接光照。
整个过程可以看作是一次完整的 “世界空间 -> 光源视图空间 -> 光源裁剪空间 -> 纹理空间” 的变换,这与我们常规渲染管线中的顶点变换过程非常相似,只是这里是在片元着色器中手动完成的,并且使用的是正交投影而非透视投影。
B.RSM Shadow
RSM阴影的计算原理本质上与传统的阴影贴图 (Shadow Mapping) 技术非常相似。其核心思想是:
深度测试:从当前着色点 p 的角度,看它离光源有多远。
查询记录:查询“阴影贴图”(在这里是RSM的位置纹理),看看在同一个方向上,离光源最近的物体有多远。
比较:如果当前点 p 的距离比记录的最近距离要远,那么它就被挡住了,处于阴影中。否则,它就是可见的,被照亮。
这个Shader通过一种名为 PCF (Percentage-Closer Filtering) 的柔化技术实现了这个过程,使得阴影边缘更加柔和自然。
RSM阴影的计算流程如下:
将当前着色点 p 投影到光源的UV空间。
计算点 p 相对于光源的深度 tSurface。
在 p 对应的UV坐标周围进行多次(8次)采样。
在每次采样中:
a. 从RSM位置纹理中读取遮挡物的世界坐标 vplPos。
b. 计算遮挡物的深度 tRsm。
c. 将 tSurface 与 tRsm + bias 进行比较,判断是否可见。
将8次采样的可见性结果求平均,得到一个 0.0 (全黑) 到 1.0 (全亮) 之间的值,作为最终的阴影系数。
下面是 rsmShadow 函数的详细步骤分解:
a. 投影到光源空间
// Project point to light ortho plane
vec3 rel = p - u.lightOrigin.xyz;
vec2 base = vec2(dot(rel, u.lightRight.xyz) / max(u.lightOrthoHalfSize.x, 1e-4),
dot(rel, u.lightUp.xyz) / max(u.lightOrthoHalfSize.y, 1e-4));
vec2 uv = base * 0.5 + 0.5;
这一步我们已经知道了,它的作用是计算出当前着色点 p 在光源的RSM纹理上对应的UV坐标。
b. 计算当前点的“光源深度”
// Light forward = from light to scene
vec3 Ld = normalize(u.lightDir.xyz);
float tSurface = dot(rel, Ld);
Ld: 这是光源的“前向”向量,即光线照射的方向。
tSurface: 这是本步骤的关键。我们通过计算 rel (从光源指向点p的向量) 在光源方向 Ld 上的投影,得到了点 p 沿着光照方向,相对于光源原点的距离。你可以把它理解为点 p 在光源坐标系下的“深度值”。这个值将作为我们后续比较的基准。
c. 柔化阴影采样 (PCF)
传统的阴影贴图只采样一次,会导致阴影边缘出现锯齿。为了得到柔和的阴影,这里在 uv 坐标周围的一个小区域内进行了多次采样。
vec2 texel = 1.0 / max(u.rsmResolution.xy, vec2(1.0));
float radius = max(u.rsmParams.x, 0.5);
vec2 offs[8] = vec2[8](...); // 8个预设的采样偏移方向
float sum = 0.0;
for (int i = 0; i < 8; ++i) {
// ... 循环内部 ...
}
return sum / 8.0;
offs: 定义了8个围绕中心点的采样方向。
radius: 控制采样范围的大小,值越大,阴影边缘越模糊。
texel: 单个纹素(像素)的大小,用于将radius从像素单位转换成UV单位。
循环:代码将进行8次采样,每次都在 uv 的基础上加上一点偏移 duv。
sum / 8.0: 最后,将8次采样的结果取平均值。如果8次采样都表明点是亮的,结果是 8/8 = 1.0 (全亮);如果4次亮4次暗,结果是 4/8 = 0.5 (半透明阴影)。这就是柔和边缘的来源。
d. 循环内部的核心——深度比较
现在我们来看 for 循环内部每一轮发生了什么,这是阴影测试的核心。
// 1. 获取采样点的UV坐标
vec2 duv = offs[i] * radius * texel;
vec2 sampleUV = clamp(uv + duv, 0.0, 1.0);
// 2. 从RSM位置纹理中读取遮挡物的世界坐标
vec3 vplPos = texture(rsmPositionTex, sampleUV).xyz;
// 3. 计算遮挡物的“光源深度”
float tRsm = dot(vplPos - u.lightOrigin.xyz, Ld);
// 4. 加上偏移(Bias)后进行比较
float bias = 0.02 + 0.10 * slope; // (稍后解释)
float visible = (tRsm + bias < tSurface) ? 0.0 : 1.0; // 核心比较
// 5. 累加结果
sum += visible;
获取采样UV:计算出当前这第 i 次采样的实际UV坐标。
读取遮挡物位置:使用这个 sampleUV 从 rsmPositionTex 纹理中采样。这个纹理存储的是从光源视角看过去,场景中每个点最近的那个物体的世界坐标。我们把这个坐标称为 vplPos (虚拟点光源位置)。
计算遮挡物深度:用与步骤2完全相同的方法,计算出 vplPos 的光源深度 tRsm。tRsm 代表了在当前采样方向上,离光源最近的那个物体表面的深度。
深度比较:
tRsm + bias < tSurface: 这就是最终的判断。
如果 tSurface (当前点的深度) 大于 tRsm (遮挡物的深度),意味着当前点 p 在遮挡物的后面,因此它在阴影中。visible 被设为 0.0。
否则,当前点 p 没有被遮挡,是可见的。visible 被设为 1.0。
e. 解决“阴影粉刺” (Shadow Acne) 的偏移(Bias)
// Receiver-plane depth bias to reduce self-shadowing on curved surfaces
float slope = 1.0 - max(dot(n, Ld), 0.0);
float bias = 0.02 + 0.10 * slope;
由于浮点数精度限制和纹理采样的离散性,一个物体表面上的点在进行深度比较时,可能会因为微小的误差而判断自己被自己遮挡了,从而在被光照亮的表面上出现许多错误的黑色斑点,这种现象被称为“阴影粉刺”或“自遮挡”。
为了解决这个问题,我们在比较时给从纹理中读出的深度 tRsm 加上一个很小的正值 bias。这相当于在计算时,将遮挡物稍微往“远离”光源的方向推了一点点,从而确保物体不会错误地遮挡自己。
我们还使用了更高级的斜率缩放偏移 (Slope-Scale Bias):
dot(n, Ld) 计算表面法线 n 和光照方向 Ld 的夹角。
当表面几乎平行于光线时 (即光线以很刁钻的角度掠过表面),dot(n, Ld) 接近0,slope 接近1,bias会变得更大。这是因为这种表面的深度变化最快,最容易产生阴影粉刺,所以需要更大的偏移量。
当表面正对光源时,dot(n, Ld) 接近1,slope 接近0,bias 就很小。
B. 一次反弹间接光:在 RSM 上的邻域采样
函数:getLight(...) 的 RSM 采样分支(节选)
vec2 baseUV = base * 0.5 + 0.5; // p 投影到光相机平面的中心 uv
for (int i = 0; i < N; ++i) {
vec2 duv = (offs[i] + jitter * 0.05) * radius / u.rsmResolution.xy;
vec2 uv = clamp(baseUV + duv, 0.0, 1.0);
vec3 vplPos = texture(rsmPositionTex, uv).xyz;
vec3 vplNor = normalize(texture(rsmNormalTex, uv).xyz);
vec3 flux = texture(rsmFluxTex, uv).xyz;
if (length(vplPos) < 0.1) continue; // 无效 VPL
vec3 wi = normalize(vplPos - p);
float cos1 = max(dot(n, wi), 0.0); // 接收面余弦
float cos2 = max(dot(vplNor, -wi), 0.0); // 发光面余弦
float dist = length(vplPos - p);
float distWeight = 1.0 / (1.0 + dist * dist);
float w = cos1 * cos2 * distWeight;
vec3 brdf = albedo / 3.14159; // Lambert
bounce += brdf * flux * w;
}
finalColor += indirectStrength * bounce / totalWeight;
每个 RSM 像素视作 VPL,其通量 flux 已含一次反射。
经典近似:
\(L_i(\mathbf{x}\to\mathbf{v}) \approx \sum_{j\in\mathcal{N}} \frac{\rho(\mathbf{x})}{\pi}\,\Phi_j\,\frac{\max(0,\mathbf{n}_x\cdot\mathbf{w}_j)\max(0,\mathbf{n}_j\cdot(-\mathbf{w}_j))}{1+\|\mathbf{x}-\mathbf{x}_j\|^2}\)
其中 $\mathbf{w}_j$ 指向 VPL 的方向,分母用 $1+\mathrm{dist}^2$ 做能量衰减与数值稳定。
通过 offs[32] + jitter 做抖动与分布采样;radius 与 samples 由 u.rsmParams 控制。
indirectStrength = mix(0.8, 0.3, shadow):在阴影区给更多间接光,避免过曝。
a. 将当前着色点投影到RSM空间
为了在RSM贴图中找到附近的VPLs,我们首先需要知道当前着色点 p 对应于RSM贴图的哪个位置。这一步在之前的步骤中已经做了详细介绍,这里不做赘述。
vec3 rel = p - u.lightOrigin.xyz;
vec2 base = vec2(dot(rel, u.lightRight.xyz) / max(u.lightOrthoHalfSize.x, 1e-4),
dot(rel, u.lightUp.xyz) / max(u.lightOrthoHalfSize.y, 1e-4));
vec2 baseUV = base * 0.5 + 0.5;
u.lightOrigin, u.lightRight, u.lightUp 定义了光源摄像机的坐标系。
rel 计算出点 p 相对于光源摄像机原点的位置向量。
dot(...) 将该向量投影到光源摄像机的右方向(X轴)和上方向(Y轴)上,并除以光源视锥体的一半大小 lightOrthoHalfSize,将其归一化到 [-1, 1] 的范围。
base * 0.5 + 0.5 将 [-1, 1] 的坐标转换为 [0, 1] 的UV坐标,这个 baseUV 就是点 p 在RSM贴图上的中心采样点。
b. 在RSM中采样周围的虚拟点光源(VPLs)
我们不能只采样一个VPL,那样会导致结果充满噪点。因此,我们在 baseUV 周围的一个区域内进行多次采样,收集多个VPLs的光照贡献。
int N = min(samples, 32);
float totalWeight = 0.0;
vec3 bounce = vec3(0.0);
for (int i = 0; i < N; ++i) {
// ... 计算采样UV ...
vec2 duv = (offs[i] + jitter * 0.05) * radius / max(u.rsmResolution.xy, vec2(1.0));
vec2 uv = clamp(baseUV + duv, 0.0, 1.0);
// ... 从RSM贴图中读取VPL信息 ...
vec3 vplPos = texture(rsmPositionTex, uv).xyz;
vec3 vplNor = normalize(texture(rsmNormalTex, uv).xyz);
vec3 flux = texture(rsmFluxTex, uv).xyz;
// ... 计算该VPL的光照贡献 ...
}
代码使用了一个预定义的 offs 数组和一个随机jitter来确定采样点,形成一个漂亮的采样模式,这比纯随机采样效果更好。radius 控制采样范围的大小。
在循环中,通过 texture(...) 函数从三张RSM贴图中读取每个采样VPL的世界位置、法线和辐射通量。
c. 计算单个VPL的光照贡献(核心物理模型)
这部分是整个算法的核心,它模拟了光从VPL传播到着色点p的过程。这本质上是在求解渲染方程(Rendering Equation)的一个简化形式。
渲染方程的简化形式为:
$L_{indirect}(p) \approx \sum_{i=1}^{N} \text{BRDF}(p) \cdot \Phi_i \cdot G(p, p_i)$
其中:
$L_{indirect}(p)$ 是点 p 接收到的间接光。
$\text{BRDF}(p)$ 是点 p 表面的双向反射分布函数,描述了光线如何被反射。
$\Phi_i$ 是第 $i$ 个VPL的辐射通量(flux)。
$G(p, p_i)$ 是几何项,描述了点 p 和VPL $p_i$ 之间的几何关系(距离、角度等)。
让我们看看代码如何实现每一项:
BRDF (双向反射分布函数)
vec3 brdf = albedo / 3.14159; // Lambert BRDF
这里使用了最简单的兰伯特(Lambertian)漫反射模型。对于理想的漫反射表面,其BRDF为 $\frac{albedo}{\pi}$,其中 albedo 是表面的基础色。
几何项 $G(p, p_i)$
几何项包含了两点之间的可见性、距离衰减以及两个表面的相对角度。
vec3 wi = vplPos - p;
float dist = length(wi);
wi = normalize(wi); // 从p指向VPL的方向向量
float cos1 = max(dot(n, wi), 0.0);
float cos2 = max(dot(vplNor, -wi), 0.0);
float distWeight = 1.0 / (1.0 + dist * dist);
float sampleWeight = cos1 * cos2 * distWeight;
cos1 = max(dot(n, wi), 0.0): 这是在接收点 p 的朗伯余弦定律,即 $n_p \cdot \omega_i$。它表示从VPL射来的光线与表面p的法线之间的夹角。夹角越大,接收到的能量越少。
cos2 = max(dot(vplNor, -wi), 0.0): 这是在VPL发射点的朗伯余弦定律,即 $n_{vpl} \cdot (-\omega_i)$。它表示VPL表面法线与光线射出方向之间的夹角。
distWeight = 1.0 / (1.0 + dist * dist): 这是光的距离平方反比衰减。标准的物理公式是 $\frac{1}{dist^2}$。代码中写作 1.0 / (1.0 + dist * dist) 是一种常见的改进,可以避免当dist趋近于0时值趋于无穷大,使衰减更平滑。
所以,sampleWeight 完整地表达了上述几何项:
$G(p, p_i) = \frac{\max(0, n_p \cdot \omega_i) \cdot \max(0, n_{vpl} \cdot -\omega_i)}{1.0 + d^2}$
其中 $d$ 是 dist,$\omega_i$ 是 wi。
累加贡献
bounce += brdf * flux * sampleWeight;
totalWeight += sampleWeight;
我们将每个VPL计算出的光照贡献 brdf * flux * sampleWeight 累加到 bounce 变量中。
d. 归一化并应用
if (totalWeight > 0.0) {
float indirectStrength = mix(0.8, 0.3, shadow); // More indirect in shadows
finalColor += indirectStrength * bounce / totalWeight;
}
bounce / totalWeight: 对累加的结果进行归一化,这是一种蒙特卡洛积分的近似,可以得到更稳定的平均光照贡献。
indirectStrength = mix(0.8, 0.3, shadow): 这是一个非常巧妙的艺术处理。shadow 值越接近0(表示点p在阴影中),indirectStrength 就越高(接近0.8);反之,在被直接照亮的区域,indirectStrength 就越低。这模拟了人眼在暗处对间接光更敏感的现象,也让场景的暗部细节更丰富。
最后,将计算出的间接光照 bounce 添加到最终颜色 finalColor 中。
3. 直接光与其他补充项
直接光:经典漫反射 + 高光(简化 GGX),被 shadow 调制。
填充光、边缘光、环境反射:在 RSM 框架外的艺术项,帮助构图与层次。
雾与色调:后期做轻微混色,提升舒适度。
参数调优与性能权衡
Ray Marching:
降低 MAX_STEPS 或提高步长下限可提速但会漏检细节。
SURF_DIST 越小法线越稳定但成本增加。
阴影:
PCF 样本数(此处 8)与半径 rsmParams.x 控制过渡平滑与接缝;半径应与分辨率成比例。
斜率偏置:bias = base + k * (1 - n·L),减少自阴影条纹。
间接光:
采样数 rsmParams.y 与半径 rsmParams.x:半径太小会噪点,太大则漂白;采样数增加能抑噪但开销线性上升。
抖动 jitter + 帧间累计(若可用)能显著降噪。
能量标定:
flux 的经验增益(×2.0)应结合实际观感校准;可与 u.lightDir.w、lightColors[0].a 协同调节。
常见问题与排查
看起来一片黑/一片白:
检查 lightDir 正负与两 pass 是否一致;光相机平面方向是否与 lightRight/lightUp 正交。
lightOrthoHalfSize 是否覆盖了场景主体。
严重自阴影/条纹:
增大 bias 基础值与斜率项系数;缩小 PCF 半径尝试。
间接光溢出/漏光:
限制采样 cos1/cos2 下限(代码已用 0.05 做早停),加入距离权重与法线一致性筛选。
距离下限(如 dist < 0.05 跳过)避免自照明。
RSM 调试:
打开 debugParams.x 在主渲染中可视化 flux/normal/position 的混合视图,便于判定 RSM 是否正确渲染与对齐。
Gamma/色彩偏差:
交换链(sRGB)+ 纹理(sRGB)时避免额外 Gamma;本项目在主渲染中移除了手动 Gamma,保留轻微色调混合。
进一步优化方向
分层/瓦片化:屏幕空间分块共享 RSM 采样,提升缓存命中率。
重要性采样:按 Flux 与法线对齐度分布采样,减少无效 VPL。 ✅ 2025-09-01
-
1.预备知识和基于3D SDF的康奈尔盒子
基础知识
留个坑。
效果
抱歉,您的浏览器不支持内嵌视频。
算法步骤
阶段一:准备与射线生成 (Preparation & Ray Generation)
设置摄像机 (Camera Setup):
在3D世界中定义摄像机的位置 (ro)、观察目标 (ta) 和姿态。
基于这些信息,构建一个从“相机空间”到“世界空间”的变换矩阵,用于正确地投射射线。
生成主射线 (Primary Ray Generation):
遍历屏幕上的每一个像素。
将每个像素的2D屏幕坐标(如 (800, 600))转换为归一化的3D观察坐标(这是实现透视投影的关键)。
最终,为每个像素生成一条独一无二的、从摄像机位置 ro 出发,射入3D场景的射线方向 rd。
阶段二:场景求交 (Scene Intersection via Ray Marching)
光线步进循环 (Ray Marching Loop):
让射线从起点开始,在场景中步进。此过程通常使用一种名为球面追踪 (Sphere Tracing)(也被称为光线步进 (Ray Marching)) 的高效算法。
在每一步,调用全局的场景SDF函数 map(),计算射线当前末端位置到场景中所有物体的最短距离 d。
这个距离 d 保证了我们可以沿着射线方向安全前进 d 的距离,而不会穿过任何物体表面。
循环往复地让射线前进 d 的距离,直到 d 的值小于一个极小的阈值(例如 0.0001),这标志着射线已经命中了某个物体的表面。
记录交点信息 (Intersection Data):
一旦命中,记录下关键信息:
交点坐标 pos: ro + total_distance * rd。
物体材质ID m: 用于区分不同物体(例如,地面、球体、盒子等)。
阶段三:表面着色 (Surface Shading)
获取表面基础属性 (Acquire Surface Properties):
计算法线 nor: 通过在交点 pos 附近极小范围内多次采样SDF,估算出表面的梯度,从而得到该点的法线向量。这是所有光照计算的基础。
计算反射向量 ref: 根据视线方向 rd 和法线 nor,计算出完美的镜面反射方向,用于模拟环境反射。
确定基础材质与颜色 (Determine Base Material & Color):
根据之前记录的材质ID m,为交点赋予基础颜色(Albedo)。
这是一个分支判断点:如果是地面 (m < 1.5),则通过 checkersGradBox 函数计算程序化的棋盘格纹理;如果是其他物体,则根据ID赋予不同的纯色。
计算环境光遮蔽 (Ambient Occlusion):
在法线方向上进行数次短距离步进,检查周围的几何体密度,计算AO系数值。这个值会使角落和缝隙等难以被环境光照亮的区域变暗,极大地增强立体感。
累加多光源光照 (Accumulate Lighting from Multiple Sources):
主光源 (Key Light): 模拟太阳等强光源。其贡献主要包括漫反射(Diffuse)和高光(Specular)两部分。高光部分使用Blinn-Phong模型计算,并且整个光照贡献会乘以 calcSoftshadow 函数返回的软阴影系数。
天空光 (Sky Light): 模拟来自天空的环境光。通过检查反射向量 ref 的方向来确定光照强度,并再次调用 calcSoftshadow 函数沿着 ref 方向进行检测,实现反射遮挡,防止物体“穿透”其他物体反射天空。
补光 (Fill Light): 一个强度较弱的辅助光源,用于提亮场景的暗部,使其不至于死黑。
边缘光 (Rim Light): 根据视线和法线的夹角(菲涅尔效应的近似)在物体边缘添加一道高光,用于将物体轮廓与背景分离开。
阶段四:后期处理与输出 (Post-Processing & Final Output)
添加雾效 (Fog):
根据交点与摄像机的距离 t,将计算出的最终光照颜色与一个全局的“雾色”进行混合。距离越远,物体颜色越接近雾色,营造出深远的大气感。
最终颜色校正与输出 (Final Correction & Output):
对计算出的颜色进行伽马校正 (Gamma Correction),使其在显示器上看起来更自然。
将最终的颜色值输出到当前像素。
箱子场景算法
+-----------------------------+
| main() 函数开始 |
+-------------+---------------+
|
+---------------------v---------------------+
| 1. 设置相机,并将像素位置转换为光线方向 |
+---------------------+---------------------+
|
+-------------v-------------+
| 2. rayMarch() |
| 光线步进,寻找与物体的 |
| 交点距离 d |
+-------------+-------------+
|
+---------------v---------------+
| 光线是否击中物体 (d < MAX_DIST)? |
+---------------+---------------+
|
+---------------------+---------------------+
| 是 | 否 (未击中)
+-----------v-----------+ +-----------v-----------+
| 3. 计算交点 p 和法线 n | | 使用默认背景色 |
+-----------+-----------+ +-----------+-----------+
| |
+-----------v-----------+ |
| 4. 获取材质和基础色 | |
| (albedo) | |
+-----------+-----------+ |
| |
+-----------v-----------+ |
| 5. getLight() | |
| 计算光照、阴影、高光| |
+-----------+-----------+ |
| |
+-----------v-----------+ +-----------v-----------+
| 6. 添加雾效和后期调色 | <-------------------+
+-----------+-----------+
|
+-----------v-----------+
| 7. 输出最终像素颜色 |
+-----------------------+
初始化:设置坐标与相机
屏幕坐标转换:在 main 函数中,首先将输入的二维纹理坐标(范围 [0, 1])转换为以屏幕中心为原点的标准化坐标(范围 [-1, 1]),并根据屏幕的宽高比进行校正,防止图像拉伸。
定义虚拟相机:设置一个虚拟相机的位置(ro,光线起点)和它看向的目标点。
计算光线方向:根据相机的位置和当前像素在屏幕上的位置,计算出一条从相机出发、穿过该像素的光线方向向量(rd)。
光线步进:寻找与场景的交点
调用核心的 rayMarch 函数,沿着上一步计算出的光线方向(rd)从相机位置(ro)开始前进。
核心思想:在每一步,通过调用 sceneSDF 函数计算当前位置到场景中所有物体表面的最短距离 dS。这个距离就是本次可以安全前进的最大步长。
循环前进:不断地沿着光线方向前进 dS 的距离,直到光线与某个物体的表面足够近(小于阈值 SURF_DIST)或者超出了最大渲染距离(MAX_DIST)。
函数最终返回光线从相机出发到击中物体的总距离 d。
表面着色:计算交点颜色
如果光线成功击中物体(d < MAX_DIST),则开始计算该点的颜色。
计算交点信息:根据行进距离 d 计算出光线与场景的精确三维交点坐标 p,并调用 getNormal 函数计算该点的表面法线向量 n。
判断材质:调用 getMaterial 函数判断交点 p 属于哪个物体(球体还是墙壁)。
获取基础色(Albedo):
如果击中的是球体,则调用 getGradientColor 计算出复杂的、带有动画效果的渐变色。
如果击中的是墙壁,则赋予一个简单的、带有微小变化的蓝色。
进行光照计算:调用 getLight 函数,这是最关键的着色步骤。它综合了多种光照效果:
环境光:提供一个基础的整体亮度。
主光源:计算来自主方向光的漫反射(物体颜色)和高光(镜面反射)。
高光计算时使用Phone模型,计算反射方向。
这是一个单pass的流程(没有使用shadowMap)==,阴影的计算依赖于反射方向。==
软阴影:在计算主光源时,会从交点 p 向光源方向再次进行一次简化的光线步进(softShadow 函数),以判断该点是否处于阴影中,并计算出阴影的柔和程度。
辅助光:添加填充光(照亮暗部)和边缘光(勾勒轮廓),使光照效果更丰富。
添加雾效:根据交点与相机的距离 d,将颜色与背景色进行混合,模拟出远景模糊的雾化效果,增加场景的深度感。
后期处理与输出
在得到基础光照颜色后,进行最后的画面调整。
Gamma校正:调整颜色亮度,使其在显示器上看起来更自然。
色彩调整:为整个画面叠加一层微妙的蓝色调,以统一风格。
最终输出:将计算完成的最终颜色赋值给 outColor,作为当前像素的显示颜色。
菲涅尔效应
对于大多数电介质(非金属,如水、玻璃、塑料等),观察角度越接近于平行于表面(即掠射角),表面的反射能力就越强。这是让材质看起来更真实、更有质感的关键因素之一。
// 菲涅尔效应:视角与法线夹角越大,反射越强(常见于水面、玻璃等)
float fresnel = 1.0;
if (matId == 1) { // 只对球体应用
fresnel = pow(1.0 - max(0.0, dot(viewDir, n)), 2.0);
}
这段代码通过计算视角和法线夹角的余弦,实现了一个简单的函数:夹角越大(越接近掠射角),fresnel 的值就越接近1.0(反射越强)。
pow(1.0 - dot(V, N), power) 的形式是一种广为人知的、计算成本极低的“边缘光”或“伪菲涅尔”效果的实现方式。
PBR
从“基于物理的渲染 (PBR)”的角度来讲,上述方式不是标准的,但接近。
在现代PBR工作流中,行业标准是使用 ==Schlick 近似法 (Schlick’s Approximation) ==来模拟菲涅尔效应,其公式为:
\[F(\theta) = F_0 + (1-F_0)(1-\cos\theta)^5\]
基础反射率 ($F_0$): Schlick 模型包含一个 $F_0$ 项,代表垂直入射时的基础反射率(比如水在垂直看时约有2%的反射率)。而代码中的公式相当于假设 $F_0$=0,即垂直看时完全没有反射,这在物理上是不准确的。
幂次 (Power): Schlick 模型标准使用 5 次幂,这个数字能更好地拟合真实世界物质的反射曲线。代码中使用了 2 次幂,这会使菲涅尔效应的过渡区域更宽、更柔和,是一种艺术上的选择,而非物理上的拟合。
主光源
主光源,或称为关键光,是场景中最主要、最强的光源,它决定了物体大部分的明暗关系和阴影的朝向。在这段代码中,主光源的计算包含了三个主要部分:漫反射(Diffuse Reflection)、高光反射(Specular Reflection) 和 软阴影(Soft Shadow)。
// --- 准备工作 ---
vec3 l = normalize(-u.lightDir.xyz); // 主光源方向
vec3 r = reflect(-l, n); // 反射光方向
// ...
// 1. 主光源 (Key Light)
if (u.enableLights.x == 1) {
// --- 漫反射计算 ---
float ndotl = max(0.0, dot(n, l));
float diff = ndotl;
// --- 高光计算 (模拟GGX) ---
float rough = clamp(1.0 - u.shadowParams.w, 0.05, 0.95);
float specPower = mix(16.0, 64.0, u.shadowParams.w);
float spec = pow(max(0.0, dot(viewDir, r)), specPower) * (1.0 - rough);
// --- 阴影计算 ---
float shadow = softShadow(p + n * 0.07, l, 0.07, 6.0, 6.0 * u.shadowParams.x);
shadow = mix(0.3, 1.0, shadow * u.shadowParams.y);
// --- 最终组合 ---
vec3 lightColor = u.lightColors[0].rgb * u.lightColors[0].a;
finalColor += (diff * albedo + spec * lightColor) * lightColor * shadow * u.lightDir.w;
}
第一步:向量定义
vec3 n: 法线向量 (Normal),垂直于物体表面。
vec3 viewDir: 视角向量 (View Direction)。
vec3 l = normalize(-u.lightDir.xyz);: 光源向量 (Light Direction)。u.lightDir 定义的是光照射来的方向(例如从上到下),我们需要的是从表面指向光源的向量,所以要对其进行取反 - 并单位化 normalize。
vec3 r = reflect(-l, n);: 反射向量 (Reflection Vector)。计算的是光源向量 l 相对于法线 n 的完美镜面反射方向(用于计算Phone模型高光和软阴影)。
第二步:漫反射 (Diffuse) 计算
漫反射模拟的是光线被粗糙表面向各个方向均匀散射的效果。它决定了物体不受高光影响的基础明暗。
漫反射不考虑出射方向,==强度由入射方向和法向决定==。
float ndotl = max(0.0, dot(n, l));
float diff = ndotl;
dot(n, l): 计算法线向量 n 和光源向量 l 的点积。
当光线垂直照射到表面时 (n 和 l 方向相同),点积为 1,表面最亮。
当光线平行于表面照射时 (n 和 l 互相垂直),点积为 0,表面不受光。
当光线从表面背面照射时,点积为负数。
max(0.0, ...): 使用 max 函数确保点积结果不会是负数,因为背面的光线不应该对正面产生照明效果。
float diff = ndotl;: 将这个 ndotl 的结果作为漫反射强度 diff。这种光照模型被称为 Lambertian 反射,是最简单和常见的漫反射模型。
最终,漫反射对颜色的贡献是 diff * albedo,即漫反射强度乘以物体基础色。
第三步:高光 (Specular) 计算
高光模拟的是光滑表面(如金属、塑料)对光源的镜面反射。
==高光强度由和出射方向决定(或Blinn-Phone中使用半程向量和法向)。==
float rough = clamp(1.0 - u.shadowParams.w, 0.05, 0.95);
float specPower = mix(16.0, 64.0, u.shadowParams.w);
float spec = pow(max(0.0, dot(viewDir, r)), specPower) * (1.0 - rough);
这段代码实现了一个简化的 Blinn-Phong 高光模型,并加入了一些模拟PBR(基于物理的渲染)中金属度/粗糙度的概念。
dot(viewDir, r): 计算视角向量 viewDir 和反射向量 r 的点积。
如果视线方向 viewDir 与完美反射方向 r 完全重合,点积为 1,此时看到的高光最强。
视线与反射方向偏离得越远,点积越小,高光越弱。
max(0.0, ...): 同样是防止结果为负。
pow(..., specPower): 这是高光计算的核心。将点积结果进行 specPower (高光指数) 次幂。
specPower 的值越大,高光点越小、越锐利,模拟的表面也越光滑。
specPower 的值越小,高光点越大、越模糊,模拟的表面也越粗糙。
u.shadowParams.w: 这个uniform变量在这里被巧妙地用作金属度 (metalness) 或光滑度 (smoothness) 的控制器。
当它为 0 时, specPower 为 16 (高光模糊),当它为 1 时, specPower 为 64 (高光锐利)。mix 函数在其间进行线性插值。
* (1.0 - rough): 用一个 rough (粗糙度) 变量来进一步控制高光强度,这借鉴了PBR的思想。rough 越高,高光越弱。
最终,高光的颜色贡献是 spec * lightColor,即高光强度乘以光源颜色。
第四步:软阴影 (Soft Shadow) 计算
为了判断表面上的点 p 是否处于阴影中,代码从点 p 出发,沿着光源方向 l 进行了一次光线步进(Ray Marching)。
float shadow = softShadow(p + n * 0.07, l, 0.07, 6.0, 6.0 * u.shadowParams.x);
shadow = mix(0.3, 1.0, shadow * u.shadowParams.y);
softShadow(...): 这个函数返回一个 [0.0, 1.0] 之间的值。1.0 表示完全没有遮挡(在光下),0.0 表示完全被遮挡(在阴影中)。其内部通过多次步进检查光路上是否有物体,并根据遮挡物与当前点的距离来计算出柔和的阴影过渡,而不是硬邦邦的边缘。
p + n * 0.07: 将阴影光线的起始点沿着法线方向稍微移开一点,这是一种常见的避免“自遮挡”问题的技术。
mix(0.3, 1.0, ...): 对 softShadow 返回的原始阴影值进行调整。
shadow * u.shadowParams.y: 使用 u.shadowParams.y 来控制阴影的整体强度。
mix(...): 重新映射阴影的范围。即使在最暗的阴影处(shadow 值为0),最终的 shadow 值也是 0.3,而不是全黑的 0.0。这==模拟了现实世界中来自环境的反光==,使得阴影区域不是死黑一片,保留了细节。
软阴影计算算法
该算法基于SDF光线步进(SDF Ray Marching)的一种非常经典且高效的方法,其核心思想由图形学大神 Inigo Quilez 提出。它不像传统阴影那样只判断“是”或“否”(完全遮挡或完全无遮挡),而是计算一个0.0到1.0之间的遮挡系数,从而模拟出柔和的半影(Penumbra)区域。
softShadow 函数:
// ro: 光线起点 (物体表面上的点)
// rd: 光线方向 (从该点射向光源的方向)
// mint, maxt: 步进的最小和最大距离
// k: 一个关键参数,用于控制阴影的柔和度
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
float res = 1.0; // 结果初始化为1.0,代表完全光亮
float t = mint; // t 是当前沿着光线方向步进的距离
// 循环步进,从表面点向光源前进
for (int i = 0; i < 64; i++) {
// 计算当前点(ro + rd * t)到场景中最近物体的距离h
float h = sceneSDF(ro + rd * t);
// h极小,说明光线已经击中了某个物体,返回0.0(完全阴影)
if (h < 0.0008) return 0.0;
// --- 核心公式 ---
res = min(res, k * h / t);
// 更新步进距离t。步长是自适应的,但被clamp函数限制了范围
t += clamp(h, 0.002, 0.05);
// 如果结果已足够精确或超出了最大距离,则停止
if (res < 0.004 || t > maxt)
break;
}
// 将结果限制在[0, 1]范围内并返回
return clamp(res, 0.0, 1.0);
}
核心公式:res = min(res, k * h / t)
h: 当前光线上的点到最近遮挡物的距离。h 越小,意味着光线离遮挡物越近。
t: 光线从物体表面出发已经行进的距离。t 越小,意味着遮挡物离被着色的表面点越近。
h / t: 这个比率可以被理解为遮挡物相对于当前表面点的“视角大小”的近似。
光线到遮挡物越近,阴影越明显(很好理解),和h有正比关系。
遮挡物到着色面越近,阴影过渡更硬,受到h的影响更大,1/t可以描述这种关系。
k: 这是一个硬度/柔和度系数。它像一个放大器,用来调整 h/t 的影响范围。k 值越大,k * h / t 的结果就越大,这意味着 res 更容易保持在较高的值(更亮),从而产生更大、更模糊、更柔和的阴影。反之,k 值越小,阴影就越小、越清晰、越硬。
min(res, ...): 在整个光线步进过程中,我们取所有计算出的遮挡值的最小值。这意味着阴影的暗度是由光线路径上最危险(离遮挡物最近)的那一刻决定的。
设置步长带来的影响
最开始的渲染结果如下:
可以看到,阴影非常柔和/模糊:这是因为算法丢失了所有的细节。由于无法精确地找到物体的边缘,阴影的边界变得极不确定,只能形成一团模糊的、平均化的结果。
并且阴影的过渡部分非常不自然,这种瑕疵是严重欠采样的典型表现。算法得到的数据是粗糙且不连续的。
==当缩小步长:==
这一次阴影清晰、自然:由于采样精度足够高,算法能够准确地“感知”到遮挡物的边缘在哪里。因此,生成的阴影轮廓分明,半影的过渡也平滑且符合物理规律。
第五步:最终组合
最后,将漫反射、高光和阴影组合在一起,计算出主光源对最终颜色的总贡献。
vec3 lightColor = u.lightColors[0].rgb * u.lightColors[0].a;
finalColor += (diff * albedo + spec * lightColor) * lightColor * shadow * u.lightDir.w;
(diff * albedo + spec * lightColor): 这是光照的核心部分。将漫反射贡献(光与物体表面颜色互动)和高光贡献(光被直接反射)相加。
* lightColor: 乘以光源的颜色。
* shadow: 乘以阴影值。如果 shadow 值为 1,颜色不变;如果为 0.3,则颜色衰减为原来的30%。
* u.lightDir.w: 乘以光源的强度。
finalColor += ...: 将计算出的主光源贡献累加到最终颜色 finalColor上。
填充光 (Fill Light)?
首先,我们理解一下“填充光”在光照设计中的作用。在一个==经典的三点照明(Three-Point Lighting)系统==中,有三个主要光源:
主光源 (Key Light):最强的光,决定物体的基本形态和阴影(看上一节)。
填充光 (Fill Light):较弱的光,从主光源的另一侧照射物体,目的在于“填充”和柔化主光源制造出的浓重阴影,降低场景的对比度,让暗部的细节能够显现出来。
边缘光 (Rim Light):从物体背后打来的光,用于勾勒物体的轮廓,使其从背景中分离出来。
因此,填充光的特点是:强度较弱、通常不产生高光、并且不投射自己独立的阴影。
计算方法解析
填充光的实现特点
通过分析代码,我们可以总结出这个填充光的几个鲜明特点:
仅有漫反射:它只计算了漫反射(diffuse),完全没有计算高光(specular)。这非常符合填充光的定位——只为照亮暗部,不制造新的亮点。
不投射阴影:代码中没有为填充光调用 softShadow 函数。这也是一种常见且必要的优化,因为计算多光源的阴影成本非常高,而且通常只有主光源的阴影对场景的视觉贡献是必要的。
强度较弱且固定:它的强度被一个固定的 0.6 系数削弱,明确了其作为次级光源的地位。
边缘光
边缘光,有时也叫“背光”(Backlight),是三点照明系统中的第三个光源,如上图所示。
目的:它的主要作用不是照亮物体本身,而是勾勒出物体的轮廓。通过在物体的边缘形成一道亮边,可以将物体与深色的背景清晰地分离开来,极大地增强了场景的深度感和立体感。
位置:通常放置在物体的斜后方,正对着相机。
计算方法解析
边缘光的计算利用了一个非常巧妙且高效的技巧,它甚至不需要一个实际的光源位置。它基于视角和表面法线之间的关系来模拟这个效果。
我们来看 getLight 函数中对应的代码块:
// 3. 边缘光 (Rim Light),用于勾勒物体轮廓
if (u.enableLights.z == 1) {
float rim = 1.0 - max(0.0, dot(viewDir, n));
rim = pow(rim, 3.0);
finalColor += rim * u.lightColors[2].rgb * u.lightColors[2].a * 0.8;
}
这个计算过程可以分解为以下几个步骤:
步骤 1: 计算基础边缘强度
float rim = 1.0 - max(0.0, dot(viewDir, n));
dot(viewDir, n): 我们再次见到了这个点积运算。它计算的是视角方向 viewDir 和表面法线 n 之间夹角的余弦值。
当你的视线正对着一个表面时(例如球体的正中心),viewDir 和 n 方向几乎重合,点积结果接近 1.0。
当你的视线与表面近乎平行时(也就是你正在看物体的边缘/轮廓),viewDir 和 n 几乎互相垂直,点积结果接近 0.0。
1.0 - ...: 通过用 1.0 减去点积的结果,这个操作巧妙地将数值“反转”了:
在物体中心,1.0 - 1.0 = 0.0。边缘光强度为0。
在物体边缘,1.0 - 0.0 = 1.0。边缘光强度为1。
这行代码实现的效果是:一个物体越是靠近其视觉上的轮廓,rim 的值就越大。这正是边缘光所需要的!这个技巧与我们之前讨论的菲涅尔效应的计算几乎完全一样,它们都依赖于视角和法线的关系。
步骤 2: 调整边缘光的衰减
rim = pow(rim, 3.0);
上一步计算出的 rim 值是从边缘(1.0)到中心(0.0)线性变化的。直接使用这个值会导致边缘光范围太宽,过渡不够锐利。
pow(rim, 3.0): 通过对 rim 值进行幂运算(这里是3次方),可以收紧这个亮边的范围。因为 [0, 1] 之间的数字,其幂次越高,值就越小。例如,0.5^3 = 0.125。
这个操作使得只有 rim 值非常接近 1.0 的区域(也就是最边缘的区域)才能保持较高的亮度,而稍微离开边缘一点,亮度就会迅速衰减下去。这就形成了一道更窄、更集中的亮边,效果更佳。
步骤 3: 组合最终颜色
finalColor += rim * u.lightColors[2].rgb * u.lightColors[2].a * 0.8;
rim * ...: 将计算出的边缘光强度 rim 乘以指定的边缘光颜色 u.lightColors[2].rgb 和强度 u.lightColors[2].a。
* 0.8: 额外再乘以一个 0.8 的系数,稍微降低一点边缘光的整体亮度。
finalColor += ...: 和填充光一样,将边缘光的颜色贡献累加到最终颜色上。
边缘光的特点
虚拟光源:它不依赖于一个明确的光源方向向量(如 fillDir 或 l),而是完全通过几何关系(视角和法线)来模拟,非常高效。
依赖视角:效果是完全相对于观察者的。当你转动视角时,边缘光会一直出现在物体的轮廓上。
高度可控:通过调整 pow 函数的指数,可以非常方便地控制亮边的宽度和锐利程度。指数越高,亮边越窄。
纯粹的附加效果:和填充光一样,它没有高光,也不投射阴影,纯粹是为了增强视觉表现力而添加的颜色。
环境光计算
这里采用了一种非常简化的模型:当的眼睛看向物体表面时,==如果视线被反射向了“天空”==,就会在物体表面看到一抹来自天空的蓝色反光。这个反光在物体的边缘处以及正对着天空的表面上会最强。
步骤:
根据视线方向和法线方向计算视线反射方向。vec3 envReflect = reflect(-viewDir, n);
计算反射向量有多大程度指向天空,结合用菲涅尔效应计算最终的反射光。
```
// 1. 计算反射方向指向天空的程度
float envAmount = max(0.0, envReflect.y) * fresnel;
// 2. 添加环境光颜色
finalColor += envAmount * vec3(0.3, 0.5, 0.8) * 0.4;
[[图形学八股总结#2. 基于图像的照明 (IBL)]]
这种方式是“基于图像的照明”方法的一种简化,采用==程序化的模拟生成简化天空==(一个半球),只模拟了天空的镜面反射和菲涅尔效应。
## 思考一:shapMap和直接软阴影计算的区别
### `softShadow` (基于光线步进)
* **原理**:这是一种 “屏幕空间”方法。对于屏幕上每一个被渲染的像素点,它都会从这个点向光源方向发射一条“阴影光线”,并进行多次步进(Ray Marching)。通过在步进过程中检测离场景的最近距离,来判断这条路径上是否有遮挡物,并根据遮挡的紧密程度计算出阴影的柔和度。
* **特点**:
* **逐像素计算**:每个需要计算阴影的像素都要执行一个循环(在你的代码里是64次),计算成本非常高。
* **高质量**:可以产生==非常精确、物理正确的柔和阴影==,阴影的柔和度会根据遮挡物和接收物之间的距离自然变化。
* **无额外内存**:不需要额外的显存来存储纹理。
* **与SDF渲染原生集成**:这是在SDF光线步进渲染器中实现阴影的最自然、最直接的方法。
### Shadow Map (基于光栅化)
* **原理**:这是一种“两遍渲染(Two-Pass)”的技术。
1. **第一遍 (深度图渲染)**:将相机移动到光源的位置,并朝光源的方向渲染整个场景。但这次渲染不输出颜色,只输出每个像素的**深度信息**(即距离光源的远近),并将这些信息存储在一张纹理中,这张纹理就是**Shadow Map**。
2. **第二遍 (最终场景渲染)**:从主相机的位置正常渲染场景。对于每个像素,将其坐标转换到光源的视角下,并查询第一遍生成的Shadow Map。通过比较当前像素的深度和Shadow Map中记录的深度,就可以判断出该像素是否在阴影中。
* **特点**:
*
* **速度快**:整个过程主要依赖于硬件高度优化的光栅化管线,渲染深度图通常非常快。最终着色时,只是多了一次纹理采样,计算成本远低于光线步进。
* **硬阴影**:基础的Shadow Map只能产生边缘锐利的**硬阴影**。要实现软阴影,需要额外的技术,如 **PCF** (Percentage-Closer Filtering) 或 **VSM** (Variance Shadow Maps),这会增加一些计算成本,但通常仍比光线步进快。
* **依赖分辨率**:阴影的质量受Shadow Map纹理分辨率的限制,分辨率太低会导致阴影边缘出现锯齿(Aliasing)。
* **常见问题**:有可能会产生一些瑕疵,如“Shadow Acne”(阴影痤疮)和“Peter Panning”(物体悬浮)。
## 思考二:菲涅尔效应和边缘光
在上述介绍的光照计算方法中,菲涅尔效应系数的计算和边缘光的计算存在类似的地方:
// 边缘光
float rim = 1.0 - max(0.0, dot(viewDir, n));
// 菲涅尔
fresnel = pow(1.0 - max(0.0, dot(viewDir, n)), 2.0);
但实际上,**这种方式只是对场景的一种简化**,边缘光可以认为是一种艺术效果,是为了更好的模拟物理场景,**只是这种物理场景的模拟方式恰好和菲涅尔系数的计算方法类似**。
在该方法中,菲涅尔系数只被用于环境光的衰减,**严格意义上来说,这里并不能被称作菲涅尔系数。**
### PBR中的菲涅尔效应
在基于物理的渲染(PBR)中,菲涅尔效应是其核心原则之一,它不再是一个可选的“艺术效果”,而是**精确描述光与物质相互作用、保证能量守恒的关键物理规律**。
它的核心作用是:**根据视角,动态地决定进入材质的光线能量中有多少被镜面反射(Specular),有多少被折射并形成漫反射(Diffuse)。**
-----
#### 1\. 核心公式:Schlick近似法(前面提到过)
在PBR中,精确计算菲涅尔方程非常复杂且耗时。因此,业界广泛采用由Christophe Schlick提出的近似公式:
$$F(\theta) = F_0 + (1 - F_0) (1 - \cos\theta)^5$$
我们来分解这个公式的每一个部分:
* $F(\theta)$: **最终的菲涅尔反射率**。这是一个介于0和1之间的值(或RGB向量),代表在当前角度下,光线被镜面反射的比例。
* $F_0$: **基础反射率(Base Reflectivity)**。这是菲涅尔效应的**关键输入参数**,代表当视线**垂直于**表面时(即 $\theta = 0$)的反射率。这个值是**材质的固有属性**。
* $\\cos\\theta$: 视角与法线(或半角向量)夹角的余弦值。在PBR中,通常使用**半角向量 (h)** 和 **视角向量 (v)** 的点积来计算,即 $\cos\theta = \text{dot}(h, v)$。
* $(1 - \\cos\\theta)^5$: 这部分描述了反射率随角度变化的曲线。当视角从垂直($\cos\theta \approx 1$)变为掠射角($\\cos\\theta \\approx 0$)时,这一项的值从0迅速增长到1,使得最终的反射率 $F(\\theta)$ 趋近于1(即100%反射)。
$F\_0$ 的值取决于材质是**电介质(Dielectric,非金属)还是导体(Conductor,金属)**:
* **非金属 (Dielectrics)**:
* $F_0$ 通常是一个**很低且没有色彩的灰度值**。
* 大部分常见非金属的 $F\_0$ 值都非常接近,范围约在 **0.02 到 0.05** 之间。
* 因此,在PBR工作流中,非金属的 $F_0$ 经常被硬编码为一个**平均值 `vec3(0.04)`**。这个值是通过折射率(IOR)计算得出的:$F_0 = (\frac{IOR - 1}{IOR + 1})^2$。对于IOR为1.5的普通非金属,其$F_0$约等于0.04。
* **金属 (Metals)**:
* $F_0$ 通常是一个**很高且带有色彩的RGB值**。
* 金属会吸收所有折射光,因此它们的漫反射颜色为黑色。我们看到的金属颜色,实际上就是它们**有色的镜面反射**。
* 在PBR的金属/粗糙度(Metallic/Roughness)**工作流中,金属的 $F_0$ 值通常就是它的**反照率(Albedo)贴图提供的颜色。
在着色器代码中,我们可以这样动态计算 $F\_0$:
```glsl
vec3 F0 = vec3(0.04); // 非金属的默认F0
F0 = mix(F0, albedo.rgb, metallic); // 如果是金属(metallic=1),则用albedo颜色作为F0
2. PBR中的应用:能量守恒的“分配器”
现在我们知道了如何计算菲涅尔反射率 $F$,那么它在整个PBR光照模型中是如何使用的呢?
PBR将物体表面的光照分为两个部分:漫反射(Diffuse)和镜面反射(Specular)。渲染方程的简化形式(也称为反射方程)的BRDF(双向反射分布函数)部分可以概括为:
\[f_{r} = k_d \cdot f_{\text{diffuse}} + k_s \cdot f_{\text{specular}}\]
这里的 $k_d$ 和 $k_s$ 分别是漫反射和镜面反射所占的能量比例。为了保证能量守恒(反射出去的光不能比入射的光更多),==这两个比例之和必须小于等于1。==
菲涅尔项 $F$ 在这里就扮演了镜面反射比例 $k_s$ 的角色!
首先,我们使用Schlick近似法计算出当前角度的菲涅尔反射率 $F$。
// H: 半角向量, V: 视角向量, F0: 基础反射率
vec3 F = fresnelSchlick(max(dot(H, V), 0.0), F0);
这个 $F$ 值直接告诉我们:有多少比例的入射光能量被用于镜面反射。
\(k_s = F\)
根据能量守恒,剩下的能量则被用于折射和漫反射。所以漫反射的能量比例就是:
\(k_d = 1 - k_s = vec3(1.0) - F\)
最终,我们将这两个部分组合起来,得到总的光照贡献:
// NDF, G, F 是Cook-Torrance BRDF的三大核心部分
vec3 specular_part = NDF * G * F / (4.0 * dot(N, V) * dot(N, L) + 0.001);
// 计算漫反射能量比例 kD
vec3 kD = vec3(1.0) - F;
// 如果是金属,没有漫反射
kD *= (1.0 - metallic);
vec3 diffuse_part = kD * albedo / PI;
// 最终颜色是漫反射和镜面反射的总和
vec3 finalColor = (diffuse_part + specular_part) * lightColor * dot(N, L);
3.补充Cook-Torrence BRDF的其他项
对于PBR中的Cook-Torrance BRDF镜面反射部分,其核心思想是基于微表面理论(Microfacet Theory)。该理论假设,从宏观上看是粗糙的表面,在微观尺度上是由大量朝向各异的、平整的微小镜面(microfacet)组成的。表面的“粗糙度”(Roughness)参数,就决定了这些微小镜面的朝向混乱程度。
NDF 和 G 这两项就是用来从统计学上描述这些微表面的行为的。
1. NDF - 法线分布函数 (Normal Distribution Function)
核心作用:描述微表面的法线朝向集中度。
简单来说,NDF回答了这样一个问题:“在所有微表面中,究竟有多少比例的微表面其法线正好对齐在了某个特定方向上?”
在Cook-Torrance模型中,我们最关心的方向是半程向量 (Halfway Vector, H),即光线方向 L 和视线方向 V 的角平分线方向 (H = normalize(L + V))。因为只有当微表面的法线 m 正好等于 H 时,光线才能被完美地反射到观察者眼中。
如果表面非常光滑 (Roughness → 0):绝大多数微表面的法线都与宏观表面法线 N 一致。NDF函数会输出一个非常大(集中)的值当 H 接近 N 时,而在其他方向迅速衰减为0。这会形成一个非常小而亮的镜面高光。
如果表面非常粗糙 (Roughness → 1):微表面的法线朝向非常混乱。NDF函数在一个很宽的角度范围内都会有返回值,当 H 偏离 N 较远时,函数值衰减得也更慢。这会形成一个范围很广且更模糊的高光。
常用计算模型:Trowbridge-Reitz GGX
这是目前实时渲染中最流行和效果最自然的模型。它的公式如下:
\[NDF_{GGX}(N, H, \alpha) = \frac{\alpha^2}{\pi((N \cdot H)^2(\alpha^2 - 1) + 1)^2}\]
$N$: 宏观表面的法线。
$H$: 半程向量。
$\alpha$: 代表表面粗糙度的参数,通常由 roughness 参数计算而来:$\alpha = \text{roughness} \times \text{roughness}$。
在Shader中实现:
// NDF (Trowbridge-Reitz GGX)
float DistributionGGX(vec3 N, vec3 H, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float NdotH = max(dot(N, H), 0.0);
float NdotH2 = NdotH * NdotH;
float denom = (NdotH2 * (a2 - 1.0) + 1.0);
denom = PI * denom * denom;
return a2 / denom;
}
2. G - 几何函数 (Geometry Function)
核心作用:描述微表面的自遮挡属性。
几何函数模拟了微表面之间的相互遮挡和阴影。即使某个微表面的法线正好对齐了半程向量 H,它也可能因为以下两种原因而无法贡献光照:
遮蔽 (Masking):从观察者视线方向 V 看去,这个微表面被其他微表面挡住了。
阴影 (Shadowing):从光源方向 L 看去,这个微表面处于其他微表面投下的阴影中。
当视线或光线接近掠射角(grazing angles,即与表面近乎平行)时,这种遮挡效应会变得非常明显,导致镜面反射急剧减弱。
G 函数的取值范围是 [0, 1],0代表完全遮挡,1代表完全无遮挡。
常用计算模型:Schlick-GGX (Smith’s Method的近似)
为了高效计算,通常使用Schlick对Smith’s Method的近似模型。它将几何函数分为视线和光源两个方向的项,然后相乘:
\[G(N, V, L, k) = G_1(N, V, k) \cdot G_1(N, L, k)\]
其中 $G_1$ 的计算公式为:
\[G_1(v, k) = \frac{N \cdot v}{(N \cdot v)(1 - k) + k}\]
$v$: 代表视线向量 V 或光源向量 L。
$k$: 是一个基于粗糙度 $\alpha$ 计算的参数。对于直接光照,通常使用:$k = \frac{(\alpha + 1)^2}{8}$。
在Shader中实现:
// Geometry Function (Schlick-GGX)
float GeometrySchlickGGX(float NdotV, float roughness) {
// k for direct lighting
float r = roughness + 1.0;
float k = (r * r) / 8.0;
float num = NdotV;
float den = NdotV * (1.0 - k) + k;
return num / den;
}
// Smith's Method
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
float NdotV = max(dot(N, V), 0.0);
float NdotL = max(dot(N, L), 0.0);
float ggx_V = GeometrySchlickGGX(NdotV, roughness);
float ggx_L = GeometrySchlickGGX(NdotL, roughness);
return ggx_V * ggx_L;
}
NDF (法线分布):决定了高光的形状、大小和锐利度。粗糙度越高,高光越弥散。
G (几何遮挡):决定了高光的能量损失。在掠射角时,它会衰减高光的强度,以模拟微观层面的自遮挡,这是保证PBR能量守恒的重要一环。
箱子场景软阴影bug
观察到SDF软阴影在阴影过渡区域存在不自然的过渡区域,例如墙壁上本来的阴影和球体阴影之前的过渡。该现象可以通过减少ray march的步长解决,当ray march的步长过大时,采样率低,无法得到精确的物理近似。
-
EasyVulkan资源管理中的内存泄漏问题
近期的某项目中需要在每一帧动态创建新的资源。然而程序执行时内存占用逐渐增加,怀疑出现了内存泄漏问题。因此重新回顾了EasyVulkan的ResourceManager逻辑并进行了优化。
内存泄漏问题
之前的资源创建方式:
ShaderModuleBuilder& ResourceManager::createShaderModule() {
return *new ShaderModuleBuilder(m_device,m_context);
}
这种方式会导致严重的内存泄漏问题:
new ComputePipelineBuilder(…) 在自由存储区(堆)上创建了一个对象,并返回指向该对象的指针。
操作符解引用该指针,得到对象本身。
函数返回这个堆上对象的引用。
问题分析
ComputePipelineBuilder& builder = resourceManager.createComputePipeline();
// ... 使用 builder ...
// 更差的情况,创建了一个副本
ComputePipelineBuilder builder = resourceManager.createComputePipeline();
在这两种情况下,都丢失了 new 返回的原始指针。因为没有指针,永远无法调用 delete 来释放这块在堆上分配的内存。每次调用 createComputePipeline() 都会导致一块无法回收的内存,程序运行时间越长,消耗的内存就越多,最终可能导致程序崩溃。
结论:绝对不要返回一个由 new 在函数内部创建的对象的引用。
解决方法
ComputePipelineBuilder ResourceManager::createComputePipeline() {
// 1. 在函数内部创建一个 ComputePipelineBuilder 临时对象
// 2. 将这个临时对象作为返回值返回
return ComputePipelineBuilder(m_device, m_context);
}
这是现代 C++ 中实现工厂函数(Factory Function的正确、安全且高效的方式。
ComputePipelineBuilder(m_device, m_context) 在函数内创建了一个临时对象。
函数签名表明它将按值返回一个 ComputePipelineBuilder 对象。
返回对象的成本:
C++中的RVO机制(返回值优化 Return Value Optimization):编译器会识别出这种情况,并避免创建中间的临时对象。它会直接在调用方的内存空间(即接收返回值的那个对象的内存位置)上构造这个对象。这样一来,就完全跳过了任何拷贝或移动操作。从效果上看,几乎和返回引用一样快:
// 由于 RVO,ComputePipelineBuilder 对象会直接在 `builder` 的内存上构造
// 没有临时对象,没有拷贝,没有移动
ComputePipelineBuilder builder = resourceManager.createComputePipeline();
移动语义 (Move Semantics): 即使在少数 RVO 无法生效的情况下(例如,函数内有多个返回路径),C++11 的移动语义也会介入。如果 ComputePipelineBuilder 有移动构造函数,那么返回时会调用移动构造函数而非拷贝构造函数。移动通常非常廉价,它只是“窃取”临时对象的内部资源(如指针、句柄),而不需要深拷贝数据。
安全性与所有权
这种方式非常安全。调用者会得到一个全新的、自己拥有的对象。当这个对象离开其作用域时(例如函数结束、{} 块结束),它的析构函数会被自动调用,符合 RAII (Resource Acquisition Is Initialization) 原则。
VMA资源对象管理
任何通过 VMA Create 函数创建的资源,都必须通过与之对应的 VMA Destroy 函数来清理。
vmaCreateImage
当调用 vmaCreateImage() 时,VMA实现如下操作:
分配内存 (Allocate Memory):VMA 从它管理的内存池中找到一块合适的 VkDeviceMemory,并处理所有复杂的内存类型选择和对齐问题。这个内存块由一个 VmaAllocation 对象来代表。
创建映像 (Create Image):VMA 调用标准的 Vulkan 函数 vkCreateImage() 来创建 VkImage 句柄。
绑定内存 (Bind Memory):VMA 调用 vkBindImageMemory() 将前面分配的内存绑定到新创建的映像上。
vmaCreateImage 将这三个步骤封装成了一个原子操作,极大地简化了开发。
因此,当需要销毁这个映像时,也必须执行相反的、对应的操作:解绑内存、销毁映像、释放内存。这正是 vmaDestroyImage() 函数的作用。
vmaDestroyImage(allocator, image, allocation) 会完成:
销毁映像句柄 (内部调用 vkDestroyImage())。
释放内存块 VmaAllocation,将其归还给 VMA 的内存池,以便后续的分配可以重新使用它。
如果不使用 VMA
如果用标准 Vulkan 函数来清理:
只调用 vkDestroyImage(device, image, nullptr):
成功销毁了 VkImage 句柄本身。
但是,VMA 分配给它的那块 VkDeviceMemory (VmaAllocation) 完全没有被释放。VMA 仍然认为这块内存正在被一个(现在已经不存在的)映像使用。
结果:严重的内存泄漏。 VMA 的可用内存池会随着程序运行越来越小,最终可能导致内存耗尽。
只调用 vmaFreeMemory(allocator, allocation):
成功地将 VmaAllocation 归还给了 VMA 的内存池。
但是,VkImage 句柄 没有被销毁。
结果:严重的 Vulkan 资源泄漏。 Vulkan 驱动仍然保留着这个映像句柄的相关资源。Vulkan 的验证层(Validation Layers)会立即报错,提示有一个未被销毁的 VkImage 对象。
结论:只有 vmaDestroyImage() 能够同时、正确地清理映像句柄和它所占用的内存。
正确的生命周期管理
#include <vma/vk_mem_alloc.h>
// ... 假设已有 VmaAllocator allocator 和 VkDevice device ...
VkImage image;
VmaAllocation allocation;
// 1. 创建 Image
VkImageCreateInfo imageInfo = { ... };
VmaAllocationCreateInfo allocInfo = { };
allocInfo.usage = VMA_MEMORY_USAGE_AUTO; // 让VMA自动选择内存类型
VkResult result = vmaCreateImage(
allocator,
&imageInfo,
&allocInfo,
&image, // 输出 VkImage 句柄
&allocation, // 输出 VmaAllocation 句柄
nullptr // 可选的 VmaAllocationInfo
);
if (result == VK_SUCCESS) {
// ... 使用 image ...
}
// 2. 清理 Image (例如在程序退出或资源不再需要时)
// 必须同时传入 image 和 allocation 句柄
if (image != VK_NULL_HANDLE && allocation != VK_NULL_HANDLE) {
vmaDestroyImage(allocator, image, allocation);
}
VMA 的通用配对规则
这个原则适用于 VMA 管理的所有主要资源类型:
vmaCreateImage() -> vmaDestroyImage()
vmaCreateBuffer() -> vmaDestroyBuffer()
vmaAllocateMemory() (如果只分配内存) -> vmaFreeMemory()
vmaCreatePool() -> vmaDestroyPool()
vmaCreateAllocator() -> vmaDestroyAllocator()
始终确保资源创建和销毁调用是成对出现的,这样才能保证Vulkan 应用程序没有资源泄漏。
-
VK_EXT_debug_utils扩展的技术应用与分析
VK_EXT_debug_utils 扩展详解
Vulkan™ 是一款为高性能图形与计算设计的底层API,它为开发者提供了对硬件的显式控制能力。然而,这种控制的精细度也相应地增加了应用程序的调试复杂性。标准的Vulkan验证层(Validation Layers)能够报告API使用错误,但其输出信息有时较为抽象,可能导致开发者难以快速定位问题的具体根源。
本文旨在详细介绍 VK_EXT_debug_utils 扩展,并阐述其在提升Vulkan应用程序调试效率方面的重要作用。
Vulkan 验证层信息的局限性
在开发Vulkan应用程序时,开发者会看到类似以下的验证层输出信息:
VUID-vkCmdBeginRenderPass-initialLayout-00897(ERROR / SPEC): msgNum: -1777306431 - [AppName: EasyVulkan Application] Validation Error: [ VUID-vkCmdBeginRenderPass-initialLayout-00897 ] Object 0: handle = 0x72303f0000000052, type = VK\_OBJECT\_TYPE\_IMAGE; Object 1: handle = 0xcad092000000000d, type = VK\_OBJECT\_TYPE\_RENDER\_PASS; Object 2: handle = 0x4256c1000000005d, type = VK\_OBJECT\_TYPE\_FRAMEBUFFER; Object 3: handle = 0x2a7f70000000053, type = VK\_OBJECT\_TYPE\_IMAGE\_VIEW; | MessageID = 0x961074c1 | ...
尽管此信息准确地指出了错误类型(例如,RenderPass 的 initialLayout 与 Framebuffer 中 ImageView 的实际用途不匹配),但其中涉及的Vulkan对象仅通过其十六进制句柄(handle)来标识。在包含大量对象的复杂项目中,仅凭句柄值很难将错误与具体的代码逻辑或资源关联起来,这无疑增加了定位问题所需的时间和精力。
利用 VK_EXT_debug_utils 提升调试信息的可读性
VK_EXT_debug_utils 扩展提供了一套机制来解决上述问题。该扩展允许开发者为Vulkan对象(如 VkImage, VkBuffer, VkQueue, VkCommandBuffer)以及命令缓冲区中的特定区域附加用户定义的名称(name)和标签(tag)。
启用该扩展并为对象分配名称后,验证层的错误报告会包含这些附加信息,示例如下:
Object 0: handle = 0x72303f0000000052, name = fs-downsampled-image-pass2-0, type = VK\_OBJECT\_TYPE\_IMAGE; ...
通过 “fs-downsampled-image-pass2-0” 这样的自定义名称,开发者可以更直观地识别出产生问题的具体资源,从而显著缩短调试周期。
VK_EXT_debug_utils 的核心功能
该扩展主要提供以下几项功能:
1. 调试信使(Debug Messenger)
通过 vkCreateDebugUtilsMessengerEXT 函数可以创建一个回调机制,用于接收来自验证层或其他来源的调试消息。开发者能够自定义回调函数来处理这些消息,例如将其输出至控制台、写入日志文件,或在特定严重性级别下触发断点。
2. 对象命名(Object Naming)
vkSetDebugUtilsObjectNameEXT 函数允许为任意Vulkan对象关联一个人类可读的字符串名称。这些名称会在验证层消息和各类图形调试工具(如 RenderDoc、NVIDIA Nsight)中显示,有效提高了对象的可识别性。
3. 对象标记(Object Tagging)
通过 vkSetDebugUtilsObjectTagEXT 函数,可以为Vulkan对象附加一小块二进制数据作为标记。此功能可用于存储自定义的元数据,但与对象命名相比,其应用场景相对较少。
4. 命令缓冲区标签(Command Buffer Labels)
vkCmdBeginDebugUtilsLabelEXT 和 vkCmdEndDebugUtilsLabelEXT 函数用于在命令缓冲区中标记一个命令区域(region)。这有助于在图形调试器中对渲染帧的各个阶段进行逻辑分组和可视化,从而简化对复杂命令序列的分析。vkCmdInsertDebugUtilsLabelEXT 则用于插入单个标记点。
在项目中使用 VK_EXT_debug_utils 的标准流程
集成该扩展通常遵循以下步骤:
检查扩展支持:
在创建 VkInstance 前,查询物理设备以确认其是否支持 VK_EXT_DEBUG_UTILS_EXTENSION_NAME 扩展。
启用扩展:
在填充 VkInstanceCreateInfo 结构体时,将 VK_EXT_DEBUG_UTILS_EXTENSION_NAME 添加到 ppEnabledExtensionNames 数组中。
加载扩展函数指针:
Vulkan扩展中的函数需要通过 vkGetInstanceProcAddr (实例级函数) 或 vkGetDeviceProcAddr (设备级函数) 动态加载。
// 实例级函数
PFN_vkCreateDebugUtilsMessengerEXT pfnVkCreateDebugUtilsMessengerEXT = (PFN_vkCreateDebugUtilsMessengerEXT)vkGetInstanceProcAddr(instance, "vkCreateDebugUtilsMessengerEXT");
PFN_vkDestroyDebugUtilsMessengerEXT pfnVkDestroyDebugUtilsMessengerEXT = (PFN_vkDestroyDebugUtilsMessengerEXT)vkGetInstanceProcAddr(instance, "vkDestroyDebugUtilsMessengerEXT");
// 设备级函数
PFN_vkSetDebugUtilsObjectNameEXT pfnVkSetDebugUtilsObjectNameEXT = (PFN_vkSetDebugUtilsObjectNameEXT)vkGetDeviceProcAddr(device, "vkSetDebugUtilsObjectNameEXT");
PFN_vkCmdBeginDebugUtilsLabelEXT pfnVkCmdBeginDebugUtilsLabelEXT = (PFN_vkCmdBeginDebugUtilsLabelEXT)vkGetDeviceProcAddr(device, "vkCmdBeginDebugUtilsLabelEXT");
// ... 加载其他所需函数
通常建议将这些函数指针存储在统一的结构体或类中,以便于在程序各处调用。
创建 Debug Messenger (推荐):
定义一个符合 PFN_vkDebugUtilsMessengerCallbackEXT 签名的回调函数。
VKAPI_ATTR VkBool32 VKAPI_CALL debugCallback(
VkDebugUtilsMessageSeverityFlagBitsEXT messageSeverity,
VkDebugUtilsMessageTypeFlagsEXT messageType,
const VkDebugUtilsMessengerCallbackDataEXT* pCallbackData,
void* pUserData) {
std::cerr << "Validation layer: " << pCallbackData->pMessage << std::endl;
if (pCallbackData->objectCount > 0) {
for (uint32_t i = 0; i < pCallbackData->objectCount; ++i) {
std::cerr << " Object " << i << ": handle = " << pCallbackData->pObjects[i].objectHandle;
if (pCallbackData->pObjects[i].pObjectName) {
std::cerr << ", name = " << pCallbackData->pObjects[i].pObjectName;
}
std::cerr << ", type = " << pCallbackData->pObjects[i].objectType; // 示例: 打印对象类型
std::cerr << std::endl;
}
}
// 回调函数必须返回 VK_FALSE
return VK_FALSE;
}
填充 VkDebugUtilsMessengerCreateInfoEXT 结构体,指定感兴趣的消息严重性和类型。
VkDebugUtilsMessengerCreateInfoEXT createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT;
createInfo.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_VERBOSE_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_INFO_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT;
createInfo.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT |
VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT;
createInfo.pfnUserCallback = debugCallback;
createInfo.pUserData = nullptr; // 可选,传递用户自定义数据
调用 pfnVkCreateDebugUtilsMessengerEXT 创建信使实例,并在程序退出前使用 pfnVkDestroyDebugUtilsMessengerEXT 销毁它。
为对象命名:
在创建Vulkan对象后,使用 pfnVkSetDebugUtilsObjectNameEXT 为其指定名称。
// 假设 myImage 是一个 VkImage 句柄
// device 是 VkDevice 句柄
// pfnVkSetDebugUtilsObjectNameEXT 是已加载的函数指针
VkDebugUtilsObjectNameInfoEXT nameInfo{};
nameInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_OBJECT_NAME_INFO_EXT;
nameInfo.objectType = VK_OBJECT_TYPE_IMAGE;
nameInfo.objectHandle = (uint64_t)myImage; // 句柄必须转换为 uint64_t
nameInfo.pObjectName = "SceneAlbedoTexture";
if (pfnVkSetDebugUtilsObjectNameEXT != nullptr) {
pfnVkSetDebugUtilsObjectNameEXT(device, &nameInfo);
}
一种有效的实践是封装一个辅助函数,在创建Vulkan对象的函数中自动调用命名函数,以确保所有关键资源都被命名。
在命令缓冲区中使用标签:
// 假设 cmdBuffer 是一个 VkCommandBuffer
// pfnVkCmdBeginDebugUtilsLabelEXT 和 pfnVkCmdEndDebugUtilsLabelEXT 是已加载的函数指针
if (pfnVkCmdBeginDebugUtilsLabelEXT != nullptr) {
VkDebugUtilsLabelEXT labelInfo{};
labelInfo.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_LABEL_EXT;
labelInfo.pLabelName = "Shadow Pass";
// 颜色为可选字段,可被调试器用于可视化
labelInfo.color[0] = 0.5f;
labelInfo.color[1] = 0.5f;
labelInfo.color[2] = 0.5f;
labelInfo.color[3] = 1.0f;
pfnVkCmdBeginDebugUtilsLabelEXT(cmdBuffer, &labelInfo);
}
// ... 记录用于渲染阴影的命令 ...
if (pfnVkCmdEndDebugUtilsLabelEXT != nullptr) {
pfnVkCmdEndDebugUtilsLabelEXT(cmdBuffer);
}
EasyVulkan 框架中的封装与应用
EasyVulkan 框架在 VulkanDebug 命名空间内对 VK_EXT_debug_utils 的功能进行了封装,以简化其使用。
初始化 Debug Messenger
// 在创建 VkInstance 时启用扩展与验证层
std::vector<const char*> instanceExtensions = {VK_EXT_DEBUG_UTILS_EXTENSION_NAME};
std::vector<const char*> validationLayers = {"VK_LAYER_KHRONOS_validation"};
if (ev::VulkanDebug::checkValidationLayerSupport(validationLayers)) {
// 填充 VkDebugUtilsMessengerCreateInfoEXT 结构体
VkDebugUtilsMessengerCreateInfoEXT debugCreateInfo{};
ev::VulkanDebug::populateDebugMessengerCreateInfo(debugCreateInfo);
// 创建 Debug Messenger 实例
VkDebugUtilsMessengerEXT debugMessenger;
ev::VulkanDebug::createDebugUtilsMessengerEXT(
instance, &debugCreateInfo, nullptr, &debugMessenger);
}
上述代码封装了验证层支持检查、createInfo 结构体填充以及信使创建的逻辑,隐藏了动态加载函数指针的细节。
为Vulkan对象命名
EasyVulkan 提供了统一的接口为不同类型的Vulkan对象命名:
// 为缓冲区命名
VkBuffer vertexBuffer = ...; // 已创建的顶点缓冲区
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_BUFFER,
(uint64_t)vertexBuffer,
"MainVertexBuffer"
);
// 为图像命名
VkImage textureImage = ...; // 已创建的纹理图像
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_IMAGE,
(uint64_t)textureImage,
"DiffuseTexture"
);
// 为管线命名
VkPipeline graphicsPipeline = ...; // 已创建的图形管线
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_PIPELINE,
(uint64_t)graphicsPipeline,
"MainRenderPipeline"
);
这种方式使得在验证层报告中可以直接看到如 “MainVertexBuffer” 这样的名称,而不是十六进制句柄。
使用命令缓冲区调试标签
EasyVulkan 同样简化了在命令缓冲区中插入调试标签的过程:
// 开始一个调试区域
float shadowPassColor[4] = {0.8f, 0.0f, 0.0f, 1.0f}; // 红色
ev::VulkanDebug::beginDebugLabel(
device,
commandBuffer,
"Shadow Map Pass",
shadowPassColor
);
// ... 记录阴影贴图渲染相关命令 ...
vkCmdBeginRenderPass(...);
// ...
vkCmdEndRenderPass(...);
// 结束调试区域
ev::VulkanDebug::endDebugLabel(device, commandBuffer);
// 插入一个独立的调试标记
float markerColor[4] = {1.0f, 1.0f, 0.0f, 1.0f}; // 黄色
ev::VulkanDebug::insertDebugLabel(
device,
commandBuffer,
"Key Draw Call Marker",
markerColor
);
这些标签在 RenderDoc 等图形调试工具中会被可视化为带有颜色和名称的区域,有助于分析和理解复杂帧的结构。
应用案例:调试多通道渲染管线
在一个包含多个渲染通道的后处理管线中,可按如下方式使用EasyVulkan的调试功能:
// 为所有离屏渲染目标图像命名
for (size_t i = 0; i < offscreenImages.size(); i++) {
std::string imageName = "offscreen-rt-" + std::to_string(i);
ev::VulkanDebug::setDebugObjectName(
device,
VK_OBJECT_TYPE_IMAGE,
(uint64_t)offscreenImages[i],
imageName.c_str()
);
}
// 在命令记录期间为每个渲染阶段添加标签
float gbufferColor[4] = {0.0f, 0.5f, 0.9f, 1.0f}; // 蓝色
ev::VulkanDebug::beginDebugLabel(device, cmd, "G-Buffer Pass", gbufferColor);
// ... G-Buffer 渲染命令 ...
ev::VulkanDebug::endDebugLabel(device, cmd);
float shadowColor[4] = {0.1f, 0.1f, 0.1f, 1.0f}; // 灰色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Shadow Pass", shadowColor);
// ... 阴影渲染命令 ...
ev::VulkanDebug::endDebugLabel(device, cmd);
float lightingColor[4] = {1.0f, 0.8f, 0.0f, 1.0f}; // 金色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Lighting Pass", lightingColor);
// ... 光照计算命令 ...
ev::VulkanDebug::endDebugLabel(device, cmd);
float postFxColor[4] = {0.8f, 0.4f, 0.9f, 1.0f}; // 紫色
ev::VulkanDebug::beginDebugLabel(device, cmd, "Post-Processing", postFxColor);
// ... 后处理命令 ...
ev::VulkanDebug::endDebugLabel(device, cmd);
通过这种方式,在图形调试器中审查渲染帧时,各个渲染阶段将以不同的颜色块清晰地区分,极大地提高了分析效率。
-
-
-
色彩空间错误导致图像亮度增加
正确处理sRGB色彩空间——记一次Vulkan图像过亮问题的排查
一、问题背景与现象
1.1 项目架构
最近完成了一个基于Vulkan的混合渲染程序,核心架构包含计算管线和图形管线:
计算管线:使用 VK_FORMAT_R32G32B32A32_SFLOAT 格式的纹理作为输入/输出
图形管线:通过相同格式的纹理进行采样,在片段着色器中将其渲染到屏幕
1.2 异常现象
程序运行时,屏幕上显示的图像比原始素材显著更亮,且在明暗过渡区域出现不自然的光晕效果。
图 1:图片过亮
二、问题排查过程
2.1 初步怀疑:Gamma校正问题
观察到图像过亮后,首先怀疑Gamma校正未正确应用。以下是关键检查步骤:
(1) 检查图像加载逻辑
// 原始图像加载代码(发现问题)
int width, height, channels;
stbi_uc* pixels = stbi_load("example2048.png", &width, &height, &channels, STBI_rgb_alpha);
问题发现:STBI_rgb_alpha 将PNG的sRGB值直接读入内存,未执行sRGB→Linear转换
根本原因:图像数据以sRGB格式存储,但被当作线性值处理
(2) 验证交换链配置
// 交换链颜色空间配置(默认值)
VkColorSpaceKHR colorSpace = VK_COLOR_SPACE_SRGB_NONLINEAR_KHR;
关键发现:交换链自动执行Linear→sRGB转换,但输入的纹理数据已经是sRGB。
图 2:色彩空间转换错误
三、技术原理:为什么冗余转换导致过亮?
3.1 sRGB与Linear空间的数学关系
sRGB采用近似Gamma 2.2的非线性编码(分段函数):
\[C_{sRGB} = \begin{cases}
12.92C_{linear}, & C_{linear} \leq 0.0031308 \\
1.055C_{linear}^{1/2.4} - 0.055, & \text{otherwise}
\end{cases}\]
3.2 冗余转换的数学推导
假设原始sRGB值为 0.5(实际对应Linear值约为 0.214):
错误处理流程:
加载时未转换:C_linear(误) = 0.5
交换链自动转换:C_{final} = 1.055 × 0.5^{1/2.4} - 0.055 ≈ 0.735
正确值应为:0.5(不执行任何转换)
视觉差异:0.735比0.5亮度提升47%,导致整体画面过亮。
四、解决方案与评估
4.1 方案一:修改交换链色彩空间
// 使用PASS_THROUGH_EXT禁用自动转换
VkColorSpaceKHR colorSpace = VK_COLOR_SPACE_PASS_THROUGH_EXT;
4.2 方案二:片段着色器手动转换
// 在片段着色器中添加转换逻辑
vec3 sRGBToLinear(vec3 c) {
return mix(c/12.92, pow((c+0.055)/1.055, vec3(2.4)), step(0.04045, c));
}
void main() {
vec4 color = texture(sampler, uv);
color.rgb = sRGBToLinear(color.rgb);
outColor = vec4(color.rgb, 1.0);
}
先将颜色从sRGB空间转换到线性空间,颜色输出时,硬件再将颜色从线性空间转换到正确的sRGB空间。
4.3 方案三:直接使用 sRGB 纹理格式(推荐给采样类纹理)
当纹理来源于 LDR 图片(PNG/JPEG 等,天然是 sRGB 编码)且仅用于“采样”时,最简洁和不易出错的方式是直接使用 sRGB 纹理格式,例如 VK_FORMAT_R8G8B8A8_SRGB 或 VK_FORMAT_B8G8R8A8_SRGB。
工作机制:对 sRGB 格式的图像视图进行采样时,GPU 会在采样阶段自动执行 sRGB → Linear 解码,然后再做过滤与着色计算。
结果:着色器里拿到的就是线性空间值,无需手动 sRGBToLinear,也避免了重复 Gamma 造成的过亮。
示例代码(省略错误检查):
// 1) 创建图像时使用 SRGB 格式(仅用于采样/渲染,不作为 storage image)
VkImageCreateInfo imageInfo{};
imageInfo.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imageInfo.imageType = VK_IMAGE_TYPE_2D;
imageInfo.format = VK_FORMAT_R8G8B8A8_SRGB; // 关键:SRGB 格式
imageInfo.extent = {static_cast<uint32_t>(width), static_cast<uint32_t>(height), 1};
imageInfo.mipLevels = mipLevels;
imageInfo.arrayLayers = 1;
imageInfo.samples = VK_SAMPLE_COUNT_1_BIT;
imageInfo.tiling = VK_IMAGE_TILING_OPTIMAL;
imageInfo.usage = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT;
// 2) 视图格式保持 SRGB(采样时自动解码)
VkImageViewCreateInfo viewInfo{};
viewInfo.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewInfo.image = textureImage;
viewInfo.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewInfo.format = VK_FORMAT_R8G8B8A8_SRGB; // 关键:SRGB 视图
viewInfo.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT;
viewInfo.subresourceRange.baseMipLevel = 0;
viewInfo.subresourceRange.levelCount = mipLevels;
viewInfo.subresourceRange.baseArrayLayer = 0;
viewInfo.subresourceRange.layerCount = 1;
// 3) 着色器中无需手动转换
// vec4 color = texture(sampler2D, uv); // 这里拿到的已是线性空间
注意事项:
不要将 sRGB 格式的图像用作 storage image(大多数设备不支持 VK_FORMAT_FEATURE_STORAGE_IMAGE_BIT)。
纹理数据上传时,保持 8-bit 整数数据(如 stbi_load 的 stbi_uc*),SRGB 解码由采样器负责,不要在 CPU 端先做 Gamma。
若需要生成 mipmap,mip 也应基于 sRGB 视图生成(驱动会在采样域做正确的线性化后再过滤)。
4.4 方案选择建议(针对本文场景)
原始贴图/材质:优先使用 sRGB 纹理(方案三),着色器保持线性运算;最终输出依赖交换链色彩空间。
计算管线产物(storage image 写入):保持线性格式(UNORM 或 SFLOAT),在图形管线中以线性格式采样;不要把线性数据当 sRGB 采样。
如果整个链路中既有“sRGB 来源贴图”也有“线性计算产物”,请分别以合适格式管理,不要混用一个图像在 sRGB/UNORM 视图间交替用于不同语义的数据。
五、延伸知识:常见色彩空间与应用
Vulkan 色彩空间对应关系
色彩空间
Gamma曲线
主要应用场景
Vulkan对应枚举值
sRGB
~2.2
Web图像、消费级显示器
VK_COLOR_SPACE_SRGB_NONLINEAR_KHR
Linear
1.0
物理光照计算、HDR渲染
VK_COLOR_SPACE_PASS_THROUGH_EXT
Adobe RGB
2.2
专业摄影、印刷出版
无直接对应值
DCI-P3
2.6
数字影院、高端视频制作
VK_COLOR_SPACE_DCI_P3_NONLINEAR_EXT
Rec.2020
混合
8K/4K HDR电视
VK_COLOR_SPACE_BT2020_LINEAR_EXTVK_COLOR_SPACE_BT2020_NONLINEAR_EXT
scRGB(线性)
1.0
HDR合成、科学可视化
VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT
5.1 纹理格式基础知识(UNORM / SRGB / SFLOAT)
分类
典型格式
取值域
采样行为
可否作 storage image
常见用途
UNORM(线性)
R8G8B8A8_UNORM
0..1(量化到 8-bit)
直接按线性值采样/过滤
通常支持
线性中间结果、G-Buffer、计算管线输出
SRGB(非线性编码)
R8G8B8A8_SRGB/B8G8R8A8_SRGB
0..1(sRGB 编码)
采样时自动 sRGB→Linear
一般不支持
来自 PNG/JPEG 的 LDR 贴图、UI 纹理
浮点(线性)
R16G16B16A16_SFLOAT/R32G32B32A32_SFLOAT
宽动态范围
线性采样/高精度
通常支持
HDR、物理光照、计算精度要求高的通道
关键要点:
sRGB 只在“采样”/“颜色附件写回”路径定义了编码转换语义;
采样:sRGB → Linear 自动执行;
颜色附件写回:实现可将线性写入值转换为 sRGB 存储格式;
storage image:通常未定义/不支持。
同一物理图像若想创建不同格式的视图,必须在 VkImageCreateInfo::flags 中设置 VK_IMAGE_CREATE_MUTABLE_FORMAT_BIT,且格式需属于同一兼容类。但请谨慎:
用 sRGB 视图读取“线性数据”会被错误地当作 sRGB 再解码,导致画面偏暗。
只有当底层数据确实是 sRGB 编码时,才应使用 sRGB 视图进行采样。
5.2 实战配置与排错清单
来自磁盘的 LDR 贴图:图像/视图用 *_SRGB,采样拿到线性;不要再手动 sRGBToLinear。
中间缓冲/计算结果:使用 UNORM 或 SFLOAT,线性采样;如要节省带宽可选 R11G11B10_UFLOAT_PACK32 等。
交换链:常见为 format=*_SRGB 且 colorSpace=VK_COLOR_SPACE_SRGB_NONLINEAR_KHR,负责最终 Linear → sRGB。
校验格式能力:用 vkGetPhysicalDeviceFormatProperties 检查 FORMAT_FEATURE_SAMPLED_IMAGE_BIT、COLOR_ATTACHMENT_BIT、STORAGE_IMAGE_BIT 是否满足需求。
避免重复 Gamma:出现“偏亮/偏暗”优先检查:贴图格式、图像视图格式、是否手动 Gamma、交换链色彩空间是否与期望匹配。
六、EasyVulkan中的色彩空间
在EasyVulkan中,可以在交换链创建前指定色彩空间:
swapchainManager->setPreferredColorSpace(VK_COLOR_SPACE_SRGB_NONLINEAR_KHR);
swapchainManager->createSwapchain(800, 600);
交换链创建时,会优先创建具有指定format和color_space的交换链图像,如果不存在同时满足的情况,则会优先选择color_space,然后匹配合适的fromat。
// First try to find a format with our preferred color space and SRGB format
for (const auto& availableFormat : availableFormats) {
if (availableFormat.format == VK_FORMAT_B8G8R8A8_SRGB &&
availableFormat.colorSpace == m_preferredColorSpace) {
return availableFormat;
}
}
// If not found, try to find any format with our preferred color space
for (const auto& availableFormat : availableFormats) {
if (availableFormat.colorSpace == m_preferredColorSpace) {
return availableFormat;
}
}
-
-
Vulkan同步机制
Vulkan 通过提供多样且细粒度的同步机制,为开发者在控制渲染和计算流程时带来了极大的灵活性。Barrier、Semaphore、Fence 以及 Subpass Dependencies 各有不同的适用场景和影响:
Barrier 强调 GPU 内部流水线阶段及内存的同步,适合在单个队列内确保读写有序。
Semaphore 强调队列之间的同步,用来连接多队列的工作流。
Fence 强调 CPU 对 GPU 任务完成的可见性,用于资源回收和多帧并行调度。
Subpass Dependencies 强调在同一 Render Pass 内分阶段进行渲染时的同步,更高效地处理共享附件。
Vulkan中的同步机制详解
在传统图形API如OpenGL中,驱动程序会自动处理资源同步,开发者无需关心底层执行顺序。但这种”黑箱”机制带来了两个严重问题:性能损耗不可控和多线程扩展困难。
Vulkan 作为现代图形和计算的低层次API,其设计核心之一就是让开发者可以更细粒度地控制GPU和CPU之间的工作流程,以及不同GPU队列之间的执行顺序。而要实现稳定且高性能的渲染或计算,就必须要合理地利用好各种同步机制。本文将从几个常见的 Vulkan 同步原语(Barrier、Semaphore、Fence、Subpass Dependencies)入手,探讨它们各自的概念、适用场景、性能影响以及使用注意事项。希望通过本文,能为正在使用 Vulkan 或即将使用 Vulkan 的读者提供一些实践上的参考。
命令缓冲与队列:线性流但可乱序完成
在 Vulkan 中,所有命令都要先记录在 VkCommandBuffer 中,再提交到某个 VkQueue。在单个队列中,你提交的命令会按顺序进入 GPU 执行管线;但 GPU 可能在还没完成某个命令的写操作时,就已经开始处理后续命令的读阶段。
逻辑顺序:提交顺序一定依次排队
实际执行:可以重叠 / 并行 / 乱序完成
内存模型与缓存一致性
现代GPU采用分级缓存设计:
DDR显存 → L2缓存 → L1缓存(每个SM) → 寄存器
当计算单元写入L1缓存后,数据不会立即同步到其他缓存层级。这就是非一致性内存访问的根源。
“可用(Available)”意味着数据已经被刷出到更大层级缓存或主存;“可见(Visible)”意味着后续阶段可以读取到最新的数据——需要对读取方无效化缓存或更新缓存。
或者说:
Available:源阶段写完数据后,做缓存 Flush,让数据到了 L2 或更高层共享区域
Visible:目的阶段需要读数据时,做缓存 Invalidate,从而迫使硬件从 L2 或更高层共享区域读取数据,避免读到陈旧缓存
例如:
VkMemoryBarrier memBarrier{
.sType = VK_STRUCTURE_TYPE_MEMORY_BARRIER,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT
};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 1, &memBarrier, 0, nullptr, 0, nullptr);
这个屏障完成:
刷新所有计算阶段的写入到L2缓存(可用性)
使片段着色器能读取最新数据(可见性)
由于非一致性内存访问问题的存在,Vulkan还要求开发者显式地管理内存相关屏障,以确保数据在不同阶段之间的可见性和可用性。
因此,Vulkan的同步机制主要有两个目的:
确保数据在不同阶段之间的可见性和可用性(主要借助内存相关屏障)
确保执行顺序(主要借助pipeline屏障、semaphore、fence等)
Barrier(屏障)
Barrier的概念
Vulkan 中的 Barrier 是一种细粒度的内存和执行顺序同步机制。Barrier 在 GPU 内部起到”分割线”的作用,确保某些阶段的操作在 Barrier 之前完成,才能进行后续的阶段。例如,在进行纹理的读写转换时,需要使用 Pipeline Barrier 来保证图像布局转换或访问掩码的更改已完成,才进行下一步的采样或写入。
Barrier 有多种类型,最常见的包括:
Pipeline Barrier:用于指定源阶段(srcStageMask)到目标阶段(dstStageMask)的内存和执行依赖。
Memory Barrier:作用在整个资源上,用于指定对内存可见性的限制与保证。
Buffer Memory Barrier:只作用在特定的 Buffer 范围上。
Image Memory Barrier:只作用在特定的图像资源上,可以指定图像布局转换(image layout transition)。
Opengl中的Barrier和Vulkan对比
在Opengl中同样存在Barrier的概念,但是Vulkan中的barrier提供了更细粒度的控制。
// OpenGL隐式同步
glDispatchCompute(1024, 1, 1); // 计算着色器写入数据
glMemoryBarrier(GL_SHADER_STORAGE_BARRIER_BIT);
glDrawArrays(GL_TRIANGLES, 0, 3); // 读取计算数据
// Vulkan显式同步
vkCmdDispatch(computeCmd, 1024, 1, 1);
VkMemoryBarrier barrier{...};
vkCmdPipelineBarrier(computeCmd,
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_VERTEX_SHADER_BIT,
0, 1, &barrier);
vkCmdDraw(graphicCmd, 0, 3);
Barrier的适用场景
图像布局转换:如从 VK_IMAGE_LAYOUT_UNDEFINED 转为 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL,或者从 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 转为 VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,在开始或结束渲染通道时需要合适的图像布局。
内存可见性保证:当一个操作写入资源,另一个操作要读取该资源时,需要添加Barrier确保写入可见并完成。
不同着色阶段间的同步:例如,当顶点着色器阶段写入Buffer后,需要在片元着色器阶段进行读取,可通过Barrier来控制依赖顺序。
管线阶段分解
Vulkan将GPU工作分解为可组合的阶段:
VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT: 表示管线的起始阶段。
VK_PIPELINE_STAGE_DRAW_INDIRECT_BIT: 间接绘制命令的阶段。
VK_PIPELINE_STAGE_VERTEX_INPUT_BIT: 顶点输入操作的阶段。
VK_PIPELINE_STAGE_VERTEX_SHADER_BIT: 顶点着色器执行的阶段。
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT: 片段着色器执行的阶段。
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT: 写入颜色附件的阶段。
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT: 计算着色器执行的阶段。
VK_PIPELINE_STAGE_TRANSFER_BIT: 内存传输操作的阶段。
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT: 表示管线的结束阶段。
访问掩码
GPU 有多级缓存(L1、L2),不同阶段可能对同一块内存资源有不同的缓存策略。为了避免缓存不一致(Incoherent),Vulkan 提供了 VK_ACCESS_* 标志来精确说明某个阶段对资源的访问类型。通过source access 与 destination access 结合,可以告诉 Vulkan “我要保证前面写的数据,在后面读的时候一定可见(Visible)”。
具体包括:
VK_ACCESS_INDIRECT_COMMAND_READ_BIT: 对间接命令数据的读取访问。
VK_ACCESS_INDEX_READ_BIT: 对索引缓冲区的读取访问。
VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT: 对顶点属性的读取访问。
VK_ACCESS_UNIFORM_READ_BIT: 对统一缓冲区的读取访问。
VK_ACCESS_SHADER_READ_BIT: 对着色器存储的读取访问。
VK_ACCESS_SHADER_WRITE_BIT: 对着色器存储的写入访问。
VK_ACCESS_COLOR_ATTACHMENT_READ_BIT: 对颜色附件的读取访问。
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT: 对颜色附件的写入访问。
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT: 对深度/模板附件的读取访问。
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT: 对深度/模板附件的写入访问。
Barrier的使用
一个Barrier的定义如下:
void vkCmdPipelineBarrier(
VkCommandBuffer commandBuffer, // 记录barrier的命令缓冲区
VkPipelineStageFlags srcStageMask, // 源管线阶段掩码,指定哪些管线阶段必须在barrier之前完成
VkPipelineStageFlags dstStageMask, // 目标管线阶段掩码,指定哪些管线阶段必须等待barrier
VkDependencyFlags dependencyFlags, // 依赖标志,如VK_DEPENDENCY_BY_REGION_BIT表示区域依赖
uint32_t memoryBarrierCount, // 全局内存屏障数量
const VkMemoryBarrier* pMemoryBarriers, // 全局内存屏障数组
uint32_t bufferMemoryBarrierCount, // 缓冲内存屏障数量
const VkBufferMemoryBarrier* pBufferMemoryBarriers, // 缓冲内存屏障数组
uint32_t imageMemoryBarrierCount, // 图像内存屏障数量
const VkImageMemoryBarrier* pImageMemoryBarriers // 图像内存屏障数组
);
VkDependencyFlags主要用于控制屏障的行为,设置为0表示默认行为,同步将在整个渲染区域上全局进行,对于所有区域(所有像素),所有指定的源操作都必须在任何目标操作开始之前完成;设置为VK_DEPENDENCY_BY_REGION_BIT则允许基于区域的依赖,同步只在每个区域内进行,而不是整个渲染目标,不同区域之间可以并行处理,适合TBR架构。
如果不使用内存相关的屏障,该命令定义了一个执行屏障,即在srcStageMask和dstStageMask之间插入一个同步点,确保所有指定源操作都完成,目标操作才开始。
如果使用内存相关的屏障,则屏障会根据自身的srcAccessMask和dstAccessMask的值,在srcStageMask和dstStageMask之间插入一个同步点,确保所有指定源操作都完成,目标操作才开始。
全局内存屏障
// 全局内存屏障:影响所有内存访问
typedef struct VkMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码,指定在barrier之前必须完成的内存访问类型
VkAccessFlags dstAccessMask; // 目标访问掩码,指定必须等待barrier的内存访问类型
} VkMemoryBarrier;
全局内存屏障由 VkMemoryBarrier 结构体描述,用于同步整个 GPU 内存中的所有数据。它并不针对具体的缓冲区或图像,而是作用于全局范围内的内存访问。通过设置源和目标访问掩码(srcAccessMask 和 dstAccessMask),开发者可以确保在某个阶段完成的所有内存写操作对后续阶段的所有内存读取操作可见。
缓冲区内存屏障
// 缓冲内存屏障:针对特定缓冲区的内存访问
typedef struct VkBufferMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码
VkAccessFlags dstAccessMask; // 目标访问掩码
uint32_t srcQueueFamilyIndex; // 源队列族索引,用于队列族所有权转移
uint32_t dstQueueFamilyIndex; // 目标队列族索引,用于队列族所有权转移
VkBuffer buffer; // 受影响的缓冲区对象
VkDeviceSize offset; // 受影响区域的起始偏移
VkDeviceSize size; // 受影响区域的大小
} VkBufferMem
缓冲内存屏障由 VkBufferMemoryBarrier 结构体描述,专门用于同步对特定 VkBuffer 的内存访问。它不仅包含全局内存屏障的所有属性,还能指定屏障所作用的缓冲区、起始偏移量和数据大小。
srcQueueFamilyIndex和dstQueueFamilyIndex用于队列族所有权转移,当使用队列族所有权转移功能时,需要指定源队列族索引和目标队列族索引。如果不涉及队列族所有权转移,设置为VK_QUEUE_FAMILY_IGNORED。当计算队列族和图形队列族分离时,如果需要在图形队列中访问计算队列族的资源,需要使用队列族所有权转移(或者在资源创建时设置资源共享)。
图像内存屏障
// 图像内存屏障:针对特定图像的内存访问
typedef struct VkImageMemoryBarrier {
VkStructureType sType; // 结构体类型,必须是 VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER
const void* pNext; // 扩展信息指针,通常为nullptr
VkAccessFlags srcAccessMask; // 源访问掩码
VkAccessFlags dstAccessMask; // 目标访问掩码
VkImageLayout oldLayout; // 转换前的图像布局
VkImageLayout newLayout; // 转换后的图像布局
uint32_t srcQueueFamilyIndex; // 源队列族索引
uint32_t dstQueueFamilyIndex; // 目标队列族索引
VkImage image; // 受影响的图像对象
VkImageSubresourceRange subresourceRange; // 受影响的图像子资源范围
} VkImageMemoryBarrier;
// 子资源范围
typedef struct VkImageSubresourceRange {
VkImageAspectFlags aspectMask; // 图像方面(颜色、深度、模板等)
uint32_t baseMipLevel; // 基础mip级别
uint32_t levelCount; // mip级别数量
uint32_t baseArrayLayer; // 基础数组层
uint32_t layerCount; // 数组层数量
} VkImageSubresourceRange;
图像内存屏障由 VkImageMemoryBarrier 结构体描述,专门用于同步对特定 VkImage 的内存访问。它不仅包含全局内存屏障的所有属性,还能指定屏障所作用的图像、子资源范围。
VkImageSubresourceRange 用于指定图像的哪些部分受到影响。
aspectMask:指定图像的方面,如颜色、深度、模板等。
baseMipLevel:指定起始的mip级别。
levelCount:指定mip级别的数量。
baseArrayLayer:指定起始的数组层。
layerCount:指定数组层的数量。
除此之外,VkImageMemoryBarrier 还包含oldLayout和newLayout,用于指定图像布局的转换。
图像布局
在 Vulkan 中,图像布局(Image Layout)用于描述 GPU 内部如何组织和访问图像数据。相比 OpenGL 的隐式管理,Vulkan 要求开发者显式指定和转换图像布局,方便GPU明确用途并进行对应的性能优化。
Transient Attachments(转瞬附件),可以利用 Vulkan 的自动转换特性:
Transient Images: 创建时带有 VK_IMAGE_USAGE_TRANSIENT_ATTACHMENT_BIT,初始布局设为 VK_IMAGE_LAYOUT_UNDEFINED 表明无需保留内容。
自动转换: Vulkan 驱动会在 render pass 开始前、结束后自动插入外部依赖,完成从 UNDEFINED 到目标布局(如 COLOR_ATTACHMENT_OPTIMAL)以及结束后的转换至 PRESENT_SRC_KHR(用于交换链呈现)。
各个场景的推荐布局:
• 颜色附件: VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
• 深度/模板附件: VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL 或后续只读时使用 VK_IMAGE_LAYOUT_DEPTH_STENCIL_READ_ONLY_OPTIMAL
• 纹理采样: VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
• 传输操作: VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL / VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL
• 交换链图像: VK_IMAGE_LAYOUT_PRESENT_SRC_KHR
• 初始状态或通用用途: VK_IMAGE_LAYOUT_UNDEFINED 或 VK_IMAGE_LAYOUT_GENERAL
部分观察表明英伟达驱动在内部对 layout 的处理较为宽松,因此在英伟达显卡上可以将所有布局均设置为General。但正确区分和管理 image layout 是 Vulkan 规范的要求,并且对跨平台兼容性和未来驱动的稳定性至关重要。因此,这种理论是不正确的,不能因此在开发中忽略布局转换的管理。
Barrier的性能影响
过度使用导致性能损耗:每个 Barrier 都会在流水线上插入一个同步点,如果频繁地插入不必要的Barrier,会增加GPU的停顿并降低并行效率。
恰当使用可以避免错误和竞态:Barrier 在正确的位置使用能够让数据流安全地”串行化”,避免读写冲突。
使用注意事项
阶段掩码精确化:在指定 srcStageMask 和 dstStageMask 时,要尽量精确地指定真实会产生和需要依赖的着色阶段或管线阶段,以减少不必要的同步。
布局转换与访问掩码:Barrier 需要指定图像的布局转换和访问掩码(srcAccessMask/dstAccessMask),要确保与实际使用场景匹配。
批量Barrier:避免在不同的资源上反复调用单个Barrier,可以把多个资源的Barrier一起批量提交,减少命令开销。
Events:更灵活的同步利器
Pipeline Barrier 适用于同一个命令缓冲里强制“先做完 A,再做 B”。但如果你希望在并行中更灵活地“给 GPU 发信号”,可以用 Events:
Set Event (vkCmdSetEvent):在指定的 pipeline 阶段完成后,标记某个事件为已触发
Wait Event (vkCmdWaitEvent):在指定管线阶段等事件触发后,再继续执行
例如:
// A 和 B 两次计算
vkCmdDispatch(...);
vkCmdDispatch(...);
// 设置事件,表示 A 和 B 都完成后触发
vkCmdSetEvent(event, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
// D 是独立计算,可与 A、B 并行
vkCmdDispatch(...);
// 在绘制(C)前等待事件,确保 A、B 完成
vkCmdWaitEvents(1, &event, VK_PIPELINE_STAGE_VERTEX_SHADER_BIT, ...);
// 真正的绘制
vkCmdDraw(...);
这样便可让 D 与 A、B 并行,只有在需要结果依赖的绘制(C)时才去等待。
Semaphore(信号量)
概念
Semaphore(信号量)在 Vulkan 中主要用于队列之间的执行顺序同步。它不能被 CPU 查询,CPU无法通过 Semaphore 判断什么时候 GPU 完成了某一条指令;它更适合在 GPU 内部或不同队列之间”串起”执行顺序。例如,我们可能在一个队列上进行图像后处理,然后在另一个队列上进行呈现;在这两个队列之间需要一个 Semaphore 让呈现等待后处理完成。
Semaphore的适用场景
不同队列间依赖:渲染队列和呈现队列之间交换图像通常会用到 Semaphore 来协调。例如,vkAcquireNextImageKHR 返回的信号量会在图像可用时触发,渲染结束后再用一个信号量告知显示队列可以进行呈现。
多通道并行处理:如果使用多队列同时处理不同任务,在队列间需要明确地指定执行顺序时,使用 Semaphore 进行同步。
Semaphore的性能影响
轻量但仅限 GPU 内部:相比Fence来说,Semaphore 更轻量,因为它无需让 CPU 轮询或等待,也不需要在 CPU 端可见。通常可以让不同队列并发工作,充分利用 GPU 资源。
等待延迟:如果在一个队列上等待另一个队列信号量,会引入一定延迟,要结合管线设计和命令流来优化。
Fence(栅栏)
概念
Fence(栅栏)是另一种同步原语,与 Semaphore 不同的是,Fence 可以被 CPU 端查询。当一个命令缓冲区在 GPU 上执行完成后,Fence 会被置为已信号状态(signaled state),CPU 通过 vkWaitForFences 或 vkGetFenceStatus 等函数能够得知 GPU 的执行状态。
Fence的适用场景
GPU 任务结束的 CPU 检测:在需要 CPU 端等待 GPU 完成某些任务(比如更新一块缓冲区后,需要 CPU 端再做一些操作)时使用。
动态资源回收:如果想知道 GPU 什么时候真正使用完一块资源(例如上一帧的 Uniform Buffer),Fence 可以帮助我们在 CPU 上安全地回收或复用资源。
多缓冲策略中的帧同步:常用在多帧并行(triple buffering 或 double buffering)时,CPU需要知道当前帧是否可以安全地写入时,会查询对应的 Fence。
Fence的性能影响
CPU/GPU 同步开销:Fence 一旦被 CPU 等待(vkWaitForFences),就会造成 CPU 和 GPU 之间的同步停顿,影响并行度。
延迟增加:如果没有必要的地方使用 Fence,会导致 CPU 过度等待,引起帧率下降或延迟增大。
使用注意事项
批量等待:在需要等待多个Fence时,尽量使用批量等待,而不是一个个等待。
复用 Fence:Fence 在被 signaled 之后,可通过 vkResetFences 重置来重复使用,避免频繁创建销毁。
避免无谓等待:在管线设计中,如果可以让 CPU 继续做其他工作,就尽量不要阻塞 CPU,只有在需要保证资源一致性时再使用 Fence。
Queue Submit & 信号量等待会带来隐式内存保证:
当一个队列提交被另一个队列通过信号量(Semaphore)等待时,Vulkan 会隐式地完成所有写入的 flush,使得内存可见给后续队列。
当一个队列提交完成并且 Fence 被置位,表示 GPU -> CPU 的所有工作也可见。
Subpass Dependencies(子通道依赖)
概念
Vulkan 中的 Render Pass 可以由多个 Subpass 组成,每个 Subpass 是一个渲染阶段。Subpass Dependencies 用于指定 Subpass 之间的执行和内存依赖关系,从而在同一个 Render Pass 内实现图像的输入/输出同步。因此在同一个 Render Pass 内,Subpass Dependencies 可以减少外部 Barrier 的使用。
一般地,Subpass Dependencies 会指定:
源子通道(srcSubpass)和目标子通道(dstSubpass)
源阶段与目标阶段(srcStageMask, dstStageMask)
源访问掩码与目标访问掩码(srcAccessMask, dstAccessMask)
依赖标志(dependencyFlags)
Subpass 依赖的适用场景
多渲染阶段共享同一图像:例如,在一个 Subpass 中写入颜色附件,接下来一个 Subpass 需要使用它作为输入附件(input attachment)。
分阶段渲染:如果想在一个 Render Pass 内连续执行多个着色阶段,而这几个阶段都在同一个 GPU 队列上运行,那么 Subpass Dependencies 就是最合适的同步方式。
Subpass 依赖的性能影响
减少开销:使用 Subpass Dependencies 在同一个 Render Pass 内可以减少图像布局切换和相关命令的开销。
提高带宽利用率:Subpass Dependencies 可以帮助 Vulkan 在同一 Render Pass 内合理地利用附件。
使用注意事项
需要在创建 Render Pass 时指定:一旦 Render Pass 的依赖关系确定,就不能再动态修改。
适当规划多 Subpass 结构:过度的 Subpass 拆分会导致复杂的依赖管理,不是所有的场景都适合在一个 Render Pass 内解决。
配合 input attachment 使用:当一个 Subpass 的输出作为下一个 Subpass 的 input attachment 时,需要正确设置依赖,确保不会读写冲突。
同步方法对比
不同的同步机制往往对应着不同的使用层次和需求,下面简要对比:
同步原语
主要作用
适用场景
CPU 可见性
性能影响
Barrier
GPU 内部执行与内存同步
管线阶段、内存访问控制、布局转换
不可见
需要精确指定,过多会影响并行性
Semaphore
队列间同步
多队列交互,如图像获取与提交
不可见
轻量级,主要在GPU端
Fence
CPU 等待 GPU 完成
资源回收、GPU任务结束时需 CPU 介入
可见
CPU 阻塞可能拖慢帧率
Subpass
Render Pass 内部同步
多个渲染阶段共享附件,减少外部Barrier
不可见
在同一 Render Pass 内更高效
设计与实践建议
尽量减少无意义的Barrier
保证数据访问安全的前提下,减少不必要的 Pipeline Barrier,可以合并多个资源的Barrier或者使用更精准的阶段掩码。
巧用Subpass减少外部同步
同一 Render Pass 内的多个阶段尽量用 Subpass Dependencies 处理,可以避免过多的图像布局切换和额外的 Barrier 开销。
合理划分队列并使用Semaphore
如果 GPU 拥有异步计算队列或传输队列,适当将工作分摊在不同队列。使用 Semaphore 进行队列间同步,充分利用 GPU 并行。
Fence用于 CPU/GPU 交互
只有在需要 CPU 等待 GPU 的结果时才使用 Fence,避免无谓的阻塞。要注意等待方式(阻塞或轮询)的选择及资源回收。
监控和调试
使用 Vulkan 的验证层(Validation Layers)或 GPU 调试工具,来确认 Barrier 和同步的正确性,避免出现 GPU 死锁或数据争用。
同步示例
假设当前任务中需要四个阶段:计算任务A和B、图形渲染任务C,显示任务D,并且存在依赖关系A->B->C->D。
任务在不同队列上执行:
计算队列:任务 A 和 B
图形队列:任务 C
展示队列:任务 D
同步设计分类
同步设计主要分为两类:
队列内部顺序:同一队列中提交的命令缓冲区会按提交顺序依次执行,无需额外的同步。
跨队列同步:不同队列之间必须显式使用同步原语(主要是信号量)来保证执行顺序,同时在命令缓冲区内部也可能需要插入 pipeline barrier 以保证内存访问顺序。
设计方案详解
1. 任务 A 和 B(计算任务)
同一队列或同一命令缓冲区的情况
如果 A 和 B 都在同一计算队列中,并且可以放入同一个命令缓冲区,那么 Vulkan 隐式保证它们的顺序执行。如果 A 的输出要供 B 使用,则在 A 与 B 之间插入一个 pipeline barrier,用于:
确保 A 的写操作在 B 开始前完成
做好内存可见性和资源状态转换(例如 buffer/image 的 layout 转换)
分成两个命令缓冲区的情况
如果你希望将 A 和 B 分开提交,也可以让同一队列的提交依赖于前一次提交的结束,这时队列内部的隐式顺序即可保证(也可以用 fence 在 CPU 侧等待 A 完成后再提交 B,但一般不需要额外的 GPU 信号量)。
2. 任务 B → 任务 C(计算到图形的跨队列同步)
由于任务 B 在计算队列执行,任务 C 在图形队列执行,所以需要使用信号量来跨队列同步:
在提交任务 B 的命令缓冲区时,在 VkSubmitInfo 中指定一个信号量(例如 sem_compute2graphics),当 B 完成时,信号量会被触发
在提交任务 C 的命令缓冲区时,在 VkSubmitInfo 中设置等待 sem_compute2graphics。这样可以确保任务 C 开始之前,计算队列上任务 B 已经完全结束,并且相关数据已经正确写入
3. 任务 C → 任务 D(图形到展示的跨队列同步)
类似地,任务 C 在图形队列执行,而任务 D(例如呈现操作)在展示队列上执行,同样需要使用信号量进行跨队列同步:
在提交任务 C 时,在 VkSubmitInfo 中指定一个信号量(例如 sem_graphics2present),在任务 C 完成后该信号量被触发
在提交任务 D(通常是在呈现队列上调用 vkQueuePresentKHR 时),在 VkPresentInfoKHR 中设置等待 sem_graphics2present。这样能确保任务 D(图像展示)开始前,图形渲染任务 C 已完全完成,并且渲染结果已经准备好用于展示
4. 总体提交流程示例
假设你已经创建好两个信号量:sem_compute2graphics 和 sem_graphics2present。整个提交流程可以大致描述为:
提交任务 A 和 B 到计算队列
如果在同一命令缓冲区内:
录制命令:先执行任务 A
插入合适的 pipeline barrier(保证 A 的结果对 B 可见)
执行任务 B
在命令缓冲区末尾,通过 VkSubmitInfo 指定在 B 结束时信号 sem_compute2graphics
如果分为两个提交:
第一个提交(任务 A)直接提交
第二个提交(任务 B)可以通过队列隐式顺序保证(或使用 fence 确保 A 完成),并在提交时信号 sem_compute2graphics
提交任务 C 到图形队列
在 VkSubmitInfo 中设置等待信号量 sem_compute2graphics(对应等待阶段:等待 B 完成)
录制任务 C 的渲染命令
在任务 C 命令缓冲区结束时,指定信号 sem_graphics2present,用于通知下一阶段
提交任务 D(展示)
在展示操作时(例如 vkQueuePresentKHR 调用时),在 VkPresentInfoKHR 中设置等待信号量 sem_graphics2present,保证任务 D 执行前图形渲染任务 C 已完成
栅栏的应用
在渲染循环中,通常会有多帧同时处于 “in-flight” 状态。如果你在开始一帧之前需要确保上一帧(或同一缓冲区对应的上一个帧)的所有 GPU 操作已经完成,那么你就需要在该帧开始前检查并等待相应的栅栏。因此,每一帧一开始需要vkWaitForFences。
EasyVulkan中的同步管理
EasyVulkan通过封装 Vulkan 的同步对象,提供了一整套简单而灵活的同步管理方案。核心类 SynchronizationManager 就是这一解决方案的代表。下面我们从几个方面来介绍它的设计理念与实现思路。
统一创建与管理
SynchronizationManager 封装了创建信号量和栅栏的过程,允许开发者通过简单的接口来创建同步对象,而无需关心底层的 Vulkan 调用细节。例如:
// 创建自定义的信号量和栅栏
auto transferComplete = syncManager->createSemaphore("transferComplete");
auto cmdFence = syncManager->createFence(false, "cmdBufferFence");
这里,createSemaphore 与 createFence 接口不仅简化了对象创建,还支持为同步对象命名,这有助于调试和资源追踪。
帧同步管理
在现代图形应用中,为了实现流畅的多缓冲(如三重缓冲)渲染,通常需要为每一帧分别创建同步对象。SynchronizationManager 内置了 createFrameSynchronization 接口,可以一次性为所有并行帧创建所需的信号量和栅栏。内部会为每一帧创建:
图像可用信号量:确保交换链图像获取到位。
渲染完成信号量:通知呈现引擎渲染工作已经结束。
帧内栅栏:用于 CPU 等待 GPU 渲染完成。
// 设置三重缓冲,每帧同步对象自动创建
syncManager->createFrameSynchronization(3);
在渲染循环中,可以直接通过索引获取对应帧的同步对象:
auto imageAvailable = syncManager->getImageAvailableSemaphore(currentFrame);
auto renderFinished = syncManager->getRenderFinishedSemaphore(currentFrame);
auto inFlightFence = syncManager->getInFlightFence(currentFrame);
自动资源清理与异常处理
SynchronizationManager 在内部维护了所有同步对象的生命周期。在其析构函数中,会自动调用 cleanup 方法,确保所有 Vulkan 同步对象得到正确释放,从而避免内存泄漏和资源错误。此外,各接口均提供了异常处理机制,当遇到同步对象创建失败或参数错误时,会抛出异常,帮助开发者及时定位问题。
-
Vulkan渲染通道
引言
在Vulkan图形API的渲染管线中,渲染通道(Render Pass)是构建高效渲染流程的核心组件,它不仅描述了一次渲染操作中的渲染目标(attachments)的使用方式,还决定了多个渲染阶段(subpass)之间的执行顺序和数据依赖关系。本文将深入探讨Vulkan渲染通道的工作原理及其关键要素的实现细节。
渲染通道的本质
渲染通道(VkRenderPass)定义了渲染操作期间使用的帧缓冲附件集合及其使用方式。它通过明确指定附件的生命周期和依赖关系,允许驱动进行深层次优化。相较于传统图形API的隐式状态管理,Vulkan的显式声明机制可降低内存带宽消耗达30%以上。
渲染通道主要负责描述:
渲染目标(Attachments) 的格式、加载/存储操作、采样数等属性;
子通道(Subpasses) 中每个阶段如何使用这些渲染目标;
子通道间的依赖关系(Pipeline Dependencies),用于保证数据正确性和同步。
设计哲学
显式控制:开发者必须明确指定所有附件和子流程
执行优化:提前声明渲染流程使驱动能优化资源布局
依赖管理:精确控制子流程间的内存和执行顺序
Attachment简介与创建方法
Attachment 通常指的是帧缓冲区中的渲染目标,比如颜色缓冲、深度缓冲或模板缓冲。每个attachment都需要在创建渲染通道时进行详细的描述,主要包括以下几个方面:
格式(Format):如 VK_FORMAT_B8G8R8A8_UNORM、VK_FORMAT_D32_SFLOAT 等。
采样数(Samples):多重采样时使用的采样数。
加载/存储操作(Load/Store Operations):如在渲染开始时是清除还是保留已有数据,在渲染结束时是存储还是丢弃数据。
初始与最终布局(InitialLayout/FinalLayout):表明attachment在渲染开始前和结束后的内存布局状态,便于Vulkan内部进行布局转换。
在创建渲染通道时,需要通过一个 VkAttachmentDescription 数组来描述所有的attachment。例如:
VkAttachmentDescription colorAttachment = {};
colorAttachment.format = swapchainImageFormat; // 指定Format
colorAttachment.samples = VK_SAMPLE_COUNT_1_BIT; // 指定采样数
colorAttachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; // 指定加载操作
colorAttachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; // 指定存储操作
// For depth/stencil attachments
colorAttachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; // 指定模板加载操作
colorAttachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; // 指定模板存储操作
// End for depth/stencil attachments
colorAttachment.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; // 指定初始布局
colorAttachment.finalLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; // 指定最终布局
Subpass简介与创建方法
Subpass 是渲染通道中的一个阶段,每个subpass描述了在该阶段中如何使用和依赖attachment。
在创建渲染通道时,需要使用 VkSubpassDescription 结构体来描述每个subpass。一个subpass通常至少需要描述以下信息:
Pipeline Bind Point:通常为 VK_PIPELINE_BIND_POINT_GRAPHICS,指明当前subpass将用于图形管线。
颜色附件引用(Color Attachments):指明渲染阶段中将写入颜色数据的attachment。
输入附件引用(Input Attachments):在一个subpass中可以读取之前subpass生成的数据。
深度/模板附件引用(Depth/Stencil Attachment):如果需要使用深度或模板测试,则需要指定对应的attachment。
如下代码创建了一个简单的subpass:
VkAttachmentReference colorAttachmentRef = {};
colorAttachmentRef.attachment = 0; // 引用上面定义的第一个attachment
colorAttachmentRef.layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
VkSubpassDescription subpass = {};
subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS;
subpass.colorAttachmentCount = 1;
subpass.pColorAttachments = &colorAttachmentRef;
在这个例子中,我们创建了一个渲染阶段,该阶段会把渲染结果写入第0号attachment,并且要求该attachment处于 VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL 布局状态。
为什么需要subpass?
Vulkan采用subpass的设计模式,除了在API层面给出了更明确的渲染流程以外,还带来了以下好处:
利用On-chip memory,减少内存带宽消耗。
在移动端等功耗敏感型设备上,GPU普遍采用了TBR设计,减少对内存带宽的占用,其主要思想是在小块(tile)区域内完成大部分渲染操作,然后统一写回到内存。subpass 非常适合这种架构:在同一个 render pass 内的多个 subpass 可以在同一tile 的生命周期内连续处理(数据传输发生在On-chip memory上),不必频繁地将数据在片上和内存之间来回传输。
避免全局同步。
传统渲染流水线中,可能需要使用全局的内存屏障来确保数据一致性,而在 subpass 内部,由于数据依赖关系已被明确定义,驱动和硬件就可以局部地处理同步,减少不必要的等待。
Subpass Dependency简介与创建方法
在一个渲染通道中,多个subpass之间或subpass与外部操作之间往往存在数据依赖关系。为了确保数据的正确性和避免竞态条件,需要在渲染通道中明确声明这些依赖关系。这就是管线Dependency(Pipeline Dependency)的作用。
管线Dependency 允许开发者在subpass之间定义内存屏障和执行屏障,确保:
某个subpass的写操作完成后,下一个subpass读取数据时能够获得最新的结果;
在执行特定渲染操作前,所有前置的操作已经完成并且内存访问已经同步。
在Vulkan中,这种依赖关系通过 VkSubpassDependency 结构体进行描述。
如何使用管线依赖
假设有两个subpass:subpass0写入颜色数据,而subpass1需要读取这些数据作为输入attachment。在这种场景下,我们需要确保subpass0的写操作在subpass1开始读取之前已经完成。可以通过如下方式定义一个依赖:
VkSubpassDependency dependency = {};
dependency.srcSubpass = 0; // 依赖源subpass
dependency.dstSubpass = 1; // 依赖目标subpass
// 指定依赖的阶段与访问类型
dependency.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
dependency.dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT;
dependency.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
dependency.dstAccessMask = VK_ACCESS_INPUT_ATTACHMENT_READ_BIT;
// 对于跨subpass依赖,通常设置dependency.flags为0或VK_DEPENDENCY_BY_REGION_BIT
dependency.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT;
在上述例子中:
srcSubpass 指定了依赖的来源,即subpass0;
dstSubpass 指定了依赖的目标,即subpass1;
srcStageMask 和 dstStageMask 指明了涉及的管线阶段;
srcAccessMask 和 dstAccessMask 则描述了内存访问的类型。
此外,对于一些特殊情况(如初始状态与最终状态的同步),也可以将 srcSubpass 或 dstSubpass 设置为 VK_SUBPASS_EXTERNAL,以描述与渲染通道外部的依赖关系。
例如,定义一个计算预处理 -> 图形渲染的依赖:
VkSubpassDependency compToGraphic = {
.srcSubpass = VK_SUBPASS_EXTERNAL, // 表示计算阶段
.dstSubpass = 0, // 图形子流程索引
.srcStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
.dstStageMask = VK_PIPELINE_STAGE_VERTEX_INPUT_BIT |
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT,
.dstAccessMask = VK_ACCESS_VERTEX_ATTRIBUTE_READ_BIT |
VK_ACCESS_SHADER_READ_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
};
又或者定义一个图形渲染 -> 计算后处理的依赖:
VkSubpassDependency graphicToComp = {
.srcSubpass = 1, // 最后一个图形子流程
.dstSubpass = VK_SUBPASS_EXTERNAL, // 后续计算阶段
.srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
.dstStageMask = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
.dstAccessMask = VK_ACCESS_SHADER_READ_BIT,
.dependencyFlags = VK_DEPENDENCY_BY_REGION_BIT
};
EasyVulkan中的RenderPass
构建一个含有颜色和深度附件的 Renderpass:
// 创建一个简单的 Renderpass,其中包含颜色和深度附件
auto renderPass = renderPassBuilder
.addColorAttachment(swapchainFormat) // 添加颜色附件
.addDepthStencilAttachment(depthFormat) // 添加深度/模板附件
.beginSubpass() // 开始一个子通道
.addColorReference(0) // 子通道引用第 0 个附件作为颜色附件
.setDepthStencilReference(1) // 子通道引用第 1 个附件作为深度/模板附件
.endSubpass() // 结束子通道
.build("mainRenderPass"); // 构建 Renderpass,并命名为 "mainRenderPass"
对于复杂的渲染流程,经常需要设置多个子通道以及它们之间的依赖关系。以下示例展示了如何配置多个子通道,并在它们之间添加依赖:
// 构建一个拥有多个子通道的 Renderpass
auto renderPass = renderPassBuilder
.addColorAttachment(colorFormat) // 添加颜色附件
.addDepthStencilAttachment(depthFormat) // 添加深度/模板附件
// 第一个子通道配置:渲染到颜色附件和深度附件
.beginSubpass()
.addColorReference(0)
.setDepthStencilReference(1)
.endSubpass()
// 第二个子通道配置:使用第一个子通道的颜色输出作为输入附件,同时写入到另一个颜色附件(例如后续处理)
.beginSubpass()
.addInputReference(0)
.addColorReference(2)
.endSubpass()
// 添加子通道间依赖:确保第一个子通道的写入操作完成后,第二个子通道才能读取
.addDependency(0, 1,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_ACCESS_SHADER_READ_BIT)
.build("multiPassRender"); // 构建 Renderpass
-
Vulkan描述符集
Vulkan描述符集的简化之道:探索EasyVulkan的实现
在使用Vulkan时,需要使用描述符集来管理资源。描述符集是Vulkan中的一种资源管理机制,用于管理资源(如纹理、缓冲区等)的绑定和使用。然而,描述符集的创建和使用需要大量的代码操作,包括创建描述符池、创建layout binding、创建描述符池、创建和更新descriptorSet等。并且,增加新的资源时,也需要修改大量的代码,这无疑增加了开发者的负担。
为了简化这个过程,EasyVulkan提供了DescriptorSetBuilder类,它采用了构建器模式,大大简化了描述符集的创建和管理过程。让我们一起深入了解这个实现。
DescriptorSetBuilder的核心设计
1. 构建器模式的应用
EasyVulkan的DescriptorSetBuilder采用了构建器模式,这使得描述符集的创建过程变得更加流畅和直观。主要体现在:
DescriptorSetBuilder builder(device, context);
VkDescriptorSet descriptorSet = builder
.addBinding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1, VK_SHADER_STAGE_VERTEX_BIT)
.addBufferDescriptor(0, buffer, 0, bufferSize, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER)
.buildWithLayout("myDescriptorSet");
2. 资源绑定的简化
DescriptorSetBuilder提供了多个直观的方法来添加不同类型的资源,这些方法中封装了原本复杂的Vulkan API调用操作:
addBinding: 添加描述符布局绑定
addBufferDescriptor: 添加缓冲区描述符
addImageDescriptor: 添加图像描述符
addStorageImageDescriptor: 添加存储图像描述符
3. 自动化的资源管理
DescriptorSetBuilder还提供了自动的资源管理功能:
自动创建和管理描述符池
自动验证绑定的正确性
自动注册资源到资源管理器
自动处理错误情况
实现细节解析
1. 描述符池的创建
描述符池的创建在build方法中调用createPool方法完成。该方法的实现如下:
VkDescriptorPool DescriptorSetBuilder::createPool() const {
// 统计每种描述符类型的数量
std::unordered_map<VkDescriptorType, uint32_t> typeCount;
for (const auto &binding : m_layoutBindings) {
typeCount[binding.descriptorType] += binding.descriptorCount;
}
// 创建池大小信息
std::vector<VkDescriptorPoolSize> poolSizes;
for (const auto &[type, count] : typeCount) {
poolSizes.push_back({type, count});
}
// ... 创建描述符池
}
2. 绑定验证机制
为了确保描述符集的正确性,DescriptorSetBuilder实现了完善的验证机制:
void DescriptorSetBuilder::validateBindings() const {
// 检查是否存在绑定
if (m_layoutBindings.empty()) {
throw std::runtime_error("No descriptor set bindings specified");
}
// 检查重复绑定
std::unordered_map<uint32_t, VkDescriptorType> bindingTypes;
for (const auto &binding : m_layoutBindings) {
auto [it, inserted] = bindingTypes.insert({binding.binding, binding.descriptorType});
if (!inserted) {
throw std::runtime_error("Duplicate binding number in descriptor set layout");
}
}
// 验证写入描述符与绑定的匹配性
// ...
}
3. 资源更新机制
描述符集的更新过程也被简化:
void DescriptorSetBuilder::updateDescriptorSet(VkDescriptorSet descriptorSet) const {
std::vector<VkWriteDescriptorSet> writes = m_writes;
for (auto &write : writes) {
write.dstSet = descriptorSet;
}
vkUpdateDescriptorSets(m_device->getLogicalDevice(),
static_cast<uint32_t>(writes.size()),
writes.data(), 0, nullptr);
}
EasyVulkan中的DescriptorSet
// 创建一个包含uniform buffer和纹理的描述符集
auto descriptorSet = builder
// 添加uniform buffer绑定
.addBinding(0, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1,
VK_SHADER_STAGE_VERTEX_BIT)
// 添加纹理绑定
.addBinding(1, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1,
VK_SHADER_STAGE_FRAGMENT_BIT)
// 添加uniform buffer描述符
.addBufferDescriptor(0, uniformBuffer, 0, sizeof(UniformBufferObject),
VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER)
// 添加纹理描述符(Sampler可以通过SamplerBuilder创建)
.addImageDescriptor(1, textureImageView, textureSampler,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL,
VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER)
// 构建描述符集(name用于资源追踪)
.buildWithLayout("myMaterialDescriptorSet");
-
Vulkan命令缓冲区
命令池与命令缓冲区
在Vulkan的渲染架构中,命令池(Command Pool)和命令缓冲区(Command Buffer)构成了GPU指令管理的核心机制。
命令池 (Command Pool): 命令池是命令缓冲区的内存分配器和管理器。你可以把它想象成一个命令缓冲区的“工厂”。 每个命令池都与一个特定的队列族索引 (Queue Family Index) 关联。这意味着从该命令池分配的命令缓冲区只能提交到与该队列族索引对应的队列中。
内存分配: 命令池负责分配命令缓冲区所需的内存。Vulkan 允许驱动程序在命令池级别进行内存管理优化,例如预分配内存,从而提高命令缓冲区分配和释放的效率。
生命周期管理: 命令池管理着它所分配的命令缓冲区的生命周期。你可以重置整个命令池,一次性释放所有命令缓冲区,也可以单独重置和重新使用命令缓冲区。
命令缓冲区 (Command Buffer): 命令缓冲区是实际存储 GPU 指令的容器。它记录了一系列图形或计算操作,例如:
渲染指令: 设置渲染状态、绑定描述符集、绑定顶点缓冲区和索引缓冲区、绘制调用等。
计算指令: 分发计算着色器、绑定计算描述符集等。
传输指令: 缓冲区和图像的拷贝、填充、更新等。
同步指令: 设置事件、栅栏、管线屏障等。
可以将命令缓冲区类比为一条“指令流水线”,GPU 会按照命令缓冲区中指令的顺序逐条执行。
特性
命令池
命令缓冲区
生命周期管理
手动创建/销毁
由命令池分配/回收
线程关联性
绑定到特定队列族
继承所属命令池的队列族属性
重置行为
可批量重置所有关联命令缓冲区
支持单独或批量重置
内存管理
控制底层内存分配策略
使用预分配的内存空间
图 1:CommandPool 、CommandBuffer 、QueueFamily 、 Queue 的关系。
回顾在Vulkan初始化中,我们提到命令池创建时需要指定队列族,由该命令池创建的命令缓冲区也只能使用该队列族的队列来执行。
创建和使用
命令池创建
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = queueFamilyIndex; // 指定队列族索引 (例如图形队列族)
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT;
vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool);
标志位解析:
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT: 适用于高频更新的短期命令。提示驱动程序命令缓冲区是短暂的,可能可以进行一些优化,但实际效果取决于驱动程序。
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT: 允许单独重置命令缓冲区。强烈建议设置此标志位。它允许你单独重置命令池中分配的命令缓冲区,以便重复使用,而无需重新分配。
命令缓冲区分配
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; // 或者 VK_COMMAND_BUFFER_LEVEL_SECONDARY
allocInfo.commandBufferCount = 1; // 分配的命令缓冲区数量
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
commandPool: 指定命令缓冲区从哪个命令池分配。
level: 指定命令缓冲区的级别,可以是 VK_COMMAND_BUFFER_LEVEL_PRIMARY 或 VK_COMMAND_BUFFER_LEVEL_SECONDARY。
commandBufferCount: 指定要分配的命令缓冲区数量。可以一次性分配多个命令缓冲区。
开始和结束记录
VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; // 提示缓冲区将被提交一次并立即重置
vkBeginCommandBuffer(commandBuffer, &beginInfo);
// ... 在这里记录你的 Vulkan 指令 (例如 vkCmdBindPipeline, vkCmdDraw 等) ...
vkEndCommandBuffer(commandBuffer);
beginInfo.flags: 可以设置一些标志位来提示驱动程序命令缓冲区的用途,常用的标志位包括:
VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT: 提示缓冲区将被提交一次,然后立即重置或释放。
VK_COMMAND_BUFFER_USAGE_RENDER_PASS_CONTINUE_BIT: 指示后续渲染通道的状态将继承自这个命令缓冲区之前的渲染通道。
VK_COMMAND_BUFFER_USAGE_SIMULTANEOUS_USE_BIT: 指示命令缓冲区可以多次提交,直到被显式重置。
提交
记录完成的命令缓冲区需要提交到队列才能被 GPU 执行。
VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
if (vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE) != VK_SUCCESS) {
throw std::runtime_error("failed to submit command buffer!");
}
// 可选: 等待队列完成执行 (同步操作)
vkQueueWaitIdle(graphicsQueue);
submitInfo.pCommandBuffers: 指向要提交的命令缓冲区数组。
vkQueueSubmit: 将命令缓冲区提交到指定的队列 (graphicsQueue 在这里是图形队列)。
vkQueueWaitIdle: 等待队列中的所有命令缓冲区执行完成。通常用于同步操作,例如等待渲染完成才能进行后续操作。
释放和重置命令缓冲区
使用完命令缓冲区后,你可以选择释放或重置它。
释放命令缓冲区: 将命令缓冲区返回给命令池,可以再次分配新的命令缓冲区。
vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);
重置命令缓冲区: 清除命令缓冲区中的所有指令,使其可以重新记录。重置操作比重新分配更高效。
vkResetCommandBuffer(commandBuffer, 0); // 或者 VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT
VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT: 提示驱动程序释放命令缓冲区内部使用的资源,可以节省内存,但可能会降低性能。
销毁命令池
当不再需要命令池时,需要销毁它,释放其占用的资源。
vkDestroyCommandPool(device, commandPool, nullptr);
高级用法-二级缓冲区被主命令缓冲区调用
Vulkan 将命令缓冲区分为两种级别:
主命令缓冲区 (Primary Command Buffer, VK_COMMAND_BUFFER_LEVEL_PRIMARY): 主命令缓冲区可以提交到队列执行,并且可以调用二级命令缓冲区。它通常用于组织应用程序的主要渲染或计算流程。
二级命令缓冲区 (Secondary Command Buffer, VK_COMMAND_BUFFER_LEVEL_SECONDARY): 二级命令缓冲区不能直接提交到队列执行, 必须由主命令缓冲区调用才能被执行。二级命令缓冲区常用于:
组织复杂的渲染流程: 将渲染流程分解成多个逻辑模块,每个模块用一个二级命令缓冲区表示,提高代码可读性和可维护性。
并行命令缓冲区记录: 多个线程可以并行记录二级命令缓冲区,然后由主命令缓冲区按顺序调用,利用多核 CPU 提升命令缓冲区记录效率。
命令复用: 对于一些重复使用的命令序列,可以将其记录到二级命令缓冲区中,然后在多个主命令缓冲区中复用,减少重复记录的工作。
调用二级命令缓冲区
步骤:
创建二级命令缓冲区: 按照之前的方法,创建一个 VK_COMMAND_BUFFER_LEVEL_SECONDARY 级别的命令缓冲区。
记录二级命令缓冲区: 在二级命令缓冲区中记录你希望复用或并行记录的命令序列。
在主命令缓冲区中调用二级命令缓冲区: 在主命令缓冲区的记录过程中,使用 vkCmdExecuteCommands 命令来调用二级命令缓冲区。
// 假设 primaryCmdBuffer 是主命令缓冲区,secondaryCmdBuffer 是二级命令缓冲区
vkBeginCommandBuffer(primaryCmdBuffer, &primaryBeginInfo);
// ... 主命令缓冲区中的其他指令 ...
// 调用二级命令缓冲区
vkCmdExecuteCommands(primaryCmdBuffer, 1, &secondaryCmdBuffer);
// ... 主命令缓冲区中的其他指令 ...
vkEndCommandBuffer(primaryCmdBuffer);
二级命令缓冲区的继承 (Inheritance)
当二级命令缓冲区在渲染通道内执行时,需要设置继承信息,例如渲染通道 (Render Pass) 和帧缓冲区 (Framebuffer)。这通过 VkCommandBufferInheritanceInfo 结构体在分配二级命令缓冲区时指定。
VkCommandBufferInheritanceInfo inheritanceInfo{};
inheritanceInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_INHERITANCE_INFO;
inheritanceInfo.renderPass = renderPass; // 继承的渲染通道
inheritanceInfo.framebuffer = framebuffer; // 继承的帧缓冲区
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool;
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_SECONDARY;
allocInfo.commandBufferCount = 1;
allocInfo.pInheritanceInfo = &inheritanceInfo; // 设置继承信息
vkAllocateCommandBuffers(device, &allocInfo, &secondaryCmdBuffer);
当前 Render Pass:确保次级缓冲区的操作与主缓冲区的渲染流程兼容。
当前 Framebuffer:明确操作的目标附件(如颜色/深度附件)。
子通道(Subpass):若次级缓冲区在某个子通道内执行,需指定子通道索引。
Vulkan 会基于这些信息验证次级缓冲区的操作是否合法。如果未正确配置,可能导致验证层错误或运行时崩溃:
如果未正确配置,Vulkan 会抛出以下错误:
VUID-VkCommandBufferBeginInfo-flags-00053(Render Pass 未匹配)
VUID-vkCmdExecuteCommands-pCommandBuffers-00088(Framebuffer 不兼容)
主命令缓冲会在记录时显式的在VkRenderPassBeginInfo中指定VkRenderPass和VkFramebuffer。
// 主缓冲区记录 Render Pass
VkRenderPassBeginInfo renderPassInfo{};
renderPassInfo.renderPass = myRenderPass; // 在此处指定 Render Pass
renderPassInfo.framebuffer = myFramebuffer;
vkBeginCommandBuffer(primaryCmdBuffer, ...);
vkCmdBeginRenderPass(primaryCmdBuffer, &renderPassInfo, VK_SUBPASS_CONTENTS_INLINE);
// 调用次级缓冲区或记录绘制命令
vkCmdEndRenderPass(primaryCmdBuffer);
vkEndCommandBuffer(primaryCmdBuffer);
高级用法-条件执行模式
代码逻辑
// 1. 定义可能执行的命令缓冲区(此处为两个候选)
VkCommandBuffer conditionalBuffer = ...;
// 2. 配置条件渲染信息
VkConditionalRenderingBeginInfoEXT condInfo{};
condInfo.sType = VK_STRUCTURE_TYPE_CONDITIONAL_RENDERING_BEGIN_INFO_EXT;
condInfo.buffer = conditionBuffer; // 存储条件值的缓冲区
condInfo.offset = 0; // 条件值在缓冲区中的偏移量
// 3. 开启条件渲染范围
vkCmdBeginConditionalRenderingEXT(primaryBuffer, &condInfo);
// 4. 在条件范围内执行命令
vkCmdExecuteCommands(primaryBuffer, 1, conditionalBuffer);
// 5. 结束条件渲染范围
vkCmdEndConditionalRenderingEXT(primaryBuffer);
解析
条件值的判定规则
GPU 会从 conditionBuffer 的指定 offset 处读取一个 32位无符号整数值。
判定逻辑:
若值 ≠ 0 → 执行条件范围内的命令
若值 = 0 → 跳过所有条件范围内的命令
执行范围的作用域
条件渲染的影响范围严格限定在 vkCmdBeginConditionalRenderingEXT 和 vkCmdEndConditionalRenderingEXT 之间的命令。
嵌套支持:Vulkan 允许条件渲染的嵌套使用,内层条件可以覆盖外层条件。
次级命令缓冲区的特殊性
示例中通过 vkCmdExecuteCommands 调用的次级命令缓冲区会整体受条件值控制。
若条件不满足,次级缓冲区的所有命令将被跳过,如同未被调用。
场景
动态遮挡剔除(Occlusion Culling)
// 步骤:
// 1. 第一帧:执行遮挡查询,将结果写入 conditionBuffer
// 2. 后续帧:根据查询结果决定是否绘制物体
vkCmdBeginConditionalRenderingEXT(cmdBuffer, &condInfo);
vkCmdDrawIndexed(cmdBuffer, ...); // 仅当物体可见时执行绘制
vkCmdEndConditionalRenderingEXT(cmdBuffer);
多方案动态切换
uint32_t conditionValue = useTechniqueA ? 1 : 0;
CopyDataToBuffer(conditionBuffer, &conditionValue); // 更新条件值
vkCmdBeginConditionalRenderingEXT(cmdBuffer, &condInfo);
if (useTechniqueA) {
vkCmdExecuteCommands(cmdBuffer, 1, &techACmdBuffer);
} else {
vkCmdExecuteCommands(cmdBuffer, 1, &techBCmdBuffer);
}
vkCmdEndConditionalRenderingEXT(cmdBuffer);
GPU-Driven 渲染决策
// 通过计算着色器生成条件值
vkCmdDispatch(computeCmdBuffer, ...);
// 在渲染流程中根据计算结果决策
vkCmdBeginConditionalRenderingEXT(renderCmdBuffer, &condInfo);
vkCmdDraw(renderCmdBuffer, ...); // 由 GPU 计算的结果控制是否绘制
vkCmdEndConditionalRenderingEXT(renderCmdBuffer);
何时使用二级命令缓冲区?
1. 复杂场景分解
将复杂的渲染流程分解成多个二级命令缓冲区,例如将不同的物体或渲染阶段分别用不同的二级命令缓冲区表示,可以提高代码组织性。
2. 并行记录
如果你的应用程序有复杂的场景,命令缓冲区记录成为瓶颈,可以考虑使用多线程并行记录二级命令缓冲区,然后在一个主命令缓冲区中按顺序调用这些二级命令缓冲区。这可以有效利用多核 CPU 的性能。
3. 命令复用
对于重复使用的渲染或计算序列,将其记录到二级命令缓冲区中,在多个主命令缓冲区中复用,可以减少重复记录的工作量。
打包提交CommandBuffer
在Vulkan中,提交多个不同的command buffer到同一个队列与使用单个command buffer相比,性能差异主要受以下因素影响:
1. CPU开销
多次提交多个command buffer:
若每次提交均调用vkQueueSubmit(尤其是分散的多次调用),会增加CPU负担。驱动需要为每次提交处理验证、同步资源及命令传输,频繁的小批次提交可能导致CPU成为瓶颈。
单次提交单个command buffer:
减少vkQueueSubmit调用次数可降低CPU开销。驱动优化空间更大,可能合并内部操作,提升效率。
2. GPU执行效率
状态切换与批处理:
多个command buffer可能导致频繁的状态切换(如管线绑定、资源更新)。若这些command buffer未优化,GPU可能在执行时产生空闲。而单个command buffer可通过连续记录减少状态切换,提升吞吐量。
提交批次的影响:
GPU通常以提交批次为单位调度任务。多次提交可能分割任务,导致GPU无法充分并行;而单次提交(或一次提交多个command buffer)可能形成更大的批次,利于硬件优化。
3. 同步与依赖
显式同步需求:
多次提交常需依赖信号量或栅栏确保执行顺序,可能引入GPU等待。单次提交内部命令天然有序,减少同步需求,降低延迟。
4. 驱动与硬件的优化
驱动处理差异:
部分驱动可能优化多command buffer的合并执行(尤其在单次vkQueueSubmit提交多个时),性能接近单个command buffer。但多次分散提交可能无法享受此类优化。
硬件特性:
某些GPU架构更擅长处理大命令流,而小批次可能导致调度开销。
实践建议
优先减少提交次数:通过单次vkQueueSubmit提交多个command buffer(而非多次调用),可平衡CPU/GPU效率,接近单一大command buffer的性能。
合并录制需权衡:若多个command buffer内容固定且需重用,分开录制可能更灵活;若内容动态变化,合并录制可能减少状态切换,但需评估CPU录制开销。
场景依赖:对实时渲染等高吞吐场景,倾向于减少提交次数与状态切换;对复杂依赖或并行录制需求,可接受适度性能损失以换取灵活性。
EasyVulkan中的CommandBuffer
在EasyVulkan中,使用CommandBufferBuilder来创建和记录命令缓冲区,在注册到ResourceManager中时,会绑定对应的CommandPool。即name-> (CommandBuffer,CommandPool)
例如创建单个CommandBuffer:
// 假设 graphicsPool 已经正确创建并初始化
auto cmdBuffer = commandBufferBuilder
->setCommandPool(graphicsPool)
->setLevel(VK_COMMAND_BUFFER_LEVEL_PRIMARY)
->build("mainCommandBuffer");
创建多个CommandBuffer:
// swapchainImageCount 为交换链图像数量
auto cmdBuffers = commandBufferBuilder
->setCommandPool(graphicsPool)
->setCount(swapchainImageCount)
->buildMultiple({"frame0", "frame1", "frame2"});
创建多个二级CommandBuffer:
// 假设 threadCount 是线程数量
auto secondaryCmdBuffers = commandBufferBuilder
->setCommandPool(graphicsPool)
->setLevel(VK_COMMAND_BUFFER_LEVEL_SECONDARY)
->setCount(threadCount)
->buildMultiple();
-
VMA
引言:为何需要VMA?
Vulkan内存显式控制
在传统的图形API(如OpenGL)中,内存管理被API层完全封装,开发者无需关心底层细节。但Vulkan将内存控制权完全下放给开发者,暴露了显式的内存管理机制。这种设计带来了两个核心挑战:
多类型内存堆:现代GPU通常包含4-8种内存类型(如DEVICE_LOCAL、HOST_VISIBLE等),分布在不同的内存堆中
手动生命周期管理:开发者需要自行处理内存分配、绑定、映射和释放的全过程
一个典型的Vulkan内存分配流程需要:
vkGetBufferMemoryRequirements(...);
vkAllocateMemory(...);
vkBindBufferMemory(...);
vkMapMemory(...); // 可选
// 使用内存...
vkDestroyBuffer(...);
vkFreeMemory(...);
这种显式控制虽然提升了性能,但带来了极高的开发复杂度。根据Khronos的统计,超过60%的Vulkan内存相关BUG源于不正确的内存类型选择或生命周期管理。
Sub-allocation
考虑驱动开销:Vulkan最推荐使用sub-allocat,但是sub-allocation的内存分配原则,即尽可能减少Memory和Buffer的数量。
图 1:sub-allocation。
“The Good” ——在一大块内存里对子资源进行子分配
思路:
• 只向驱动/操作系统申请一块较大的 VkDeviceMemory,只创建一个buffer;
• 运行时将该buffer“切割”成若干子区间,每个子区间存储不同的数据。
• 这样可以显著减少真正的“分配调用次数”,也不会超出 maxMemoryAllocationCount,同时也可以减少内存绑定次数。
// 1. 创建一个“大 Buffer”以获取内存需求(包含所有用途)
VkBufferCreateInfo bigBufferCI = {};
bigBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bigBufferCI.size = totalBufferSize; // 总大小(包含 Index/Vertex/Uniform)
bigBufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT |
VK_BUFFER_USAGE_VERTEX_BUFFER_BIT |
VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
vkCreateBuffer(device, &bigBufferCI, nullptr, &bigBuffer);
VkMemoryRequirements memReqBigBuffer;
vkGetBufferMemoryRequirements(device, bigBuffer, &memReqBigBuffer);
// 2. 计算各用途的偏移和对齐
VkDeviceSize offsetIndex = 0;
VkDeviceSize offsetVertex = AlignUp(offsetIndex + indexBufferSize, memReqBigBuffer.alignment);
VkDeviceSize offsetUniform = AlignUp(offsetVertex + vertexBufferSize, memReqBigBuffer.alignment);
VkDeviceSize totalSize = AlignUp(offsetUniform + uniformBufferSize, memReqBigBuffer.alignment);
// 3. 只申请一次设备内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = totalSize;
allocInfo.memoryTypeIndex = FindMemoryType(memReqBigBuffer.memoryTypeBits, desiredProperties);
VkDeviceMemory bigMemory;
vkAllocateMemory(device, &allocInfo, nullptr, &bigMemory);
// 4. 将整个 bigMemory 绑定到“大 Buffer”
vkBindBufferMemory(device, bigBuffer, bigMemory, 0);
// 5. 将数据拷贝到 Buffer 的不同偏移处
void* mappedMemory = nullptr;
vkMapMemory(device, bigMemory, 0, VK_WHOLE_SIZE, 0, &mappedMemory);
// -- 将 Index 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetIndex, localIndexData, indexBufferSize);
// -- 将 Vertex 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetVertex, localVertexData, vertexBufferSize);
// -- 将 Uniform 数据拷贝到对应偏移
std::memcpy((uint8_t*)mappedMemory + offsetUniform, localUniformData, uniformBufferSize);
vkUnmapMemory(device, bigMemory);
// 6. 使用时指定偏移
// -- 绑定 Index Buffer
vkCmdBindIndexBuffer(cmdBuffer, bigBuffer, offsetIndex, VK_INDEX_TYPE_UINT16);
// -- 绑定 Vertex Buffer
VkDeviceSize vertexBufferOffset = offsetVertex;
vkCmdBindVertexBuffers(cmdBuffer, 0, 1, &bigBuffer, &vertexBufferOffset);
// -- 更新 DescriptorSet,指定 Uniform Buffer 的偏移和范围
VkDescriptorBufferInfo uniformBufferInfo = {};
uniformBufferInfo.buffer = bigBuffer;
uniformBufferInfo.offset = offsetUniform;
uniformBufferInfo.range = uniformBufferSize;
VkWriteDescriptorSet writeDesc = {};
writeDesc.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
writeDesc.dstSet = descriptorSet;
writeDesc.dstBinding = uniformBinding;
writeDesc.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER;
writeDesc.descriptorCount = 1;
writeDesc.pBufferInfo = &uniformBufferInfo;
vkUpdateDescriptorSets(device, 1, &writeDesc, 0, nullptr);
这种方式:
• 减少 vkAllocateMemory / vkBindBufferMemory 调用次数(只分配和绑定一次);
• 通过应用层自己维护 offset 来在同一个 Buffer 内划分出 Index/Vertex/Uniform 等数据区域;
• 大幅降低驱动层管理负担,符合 Vulkan 中鼓励的“子分配”思路,从而达到图示所说的 “The Good”。
“The Bad” ——单块显存 + 单个大 Buffer + 手动管理 offset
思路:
• 只向驱动/操作系统申请一块较大的 VkDeviceMemory;
• 运行时将这块大内存“切割”成若干子区间,每个子区间绑定到不同的 Buffer(如 Index/Vertex/Uniform)上;
• 自己管理这块内存中各个子区间的偏移与大小。
• 这样可以显著减少真正的“分配调用次数”,也不会超出 maxMemoryAllocationCount。
// 1. 分别创建需要的 Buffer 以获取各自需求(但先不真正分配内存)
// -- 例子:Index Buffer
VkBufferCreateInfo indexBufferCI = {};
indexBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
indexBufferCI.size = indexBufferSize;
indexBufferCI.usage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT;
vkCreateBuffer(device, &indexBufferCI, nullptr, &indexBuffer);
VkMemoryRequirements memReqIndex;
vkGetBufferMemoryRequirements(device, indexBuffer, &memReqIndex);
// -- 例子:Vertex Buffer
VkBufferCreateInfo vertexBufferCI = {};
vertexBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
vertexBufferCI.size = vertexBufferSize;
vertexBufferCI.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
vkCreateBuffer(device, &vertexBufferCI, nullptr, &vertexBuffer);
VkMemoryRequirements memReqVertex;
vkGetBufferMemoryRequirements(device, vertexBuffer, &memReqVertex);
// -- 例子:Uniform Buffer
VkBufferCreateInfo uniformBufferCI = {};
uniformBufferCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
uniformBufferCI.size = uniformBufferSize;
uniformBufferCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT;
vkCreateBuffer(device, &uniformBufferCI, nullptr, &uniformBuffer);
VkMemoryRequirements memReqUniform;
vkGetBufferMemoryRequirements(device, uniformBuffer, &memReqUniform);
// 2. 计算总共需要的内存大小与对齐(实际需要根据对齐做更严谨的计算)
// 比如令 offsets 为对齐后得到的各个起始偏移
VkDeviceSize offsetIndex = 0;
VkDeviceSize offsetVertex = AlignUp(offsetIndex + memReqIndex.size, memReqVertex.alignment);
VkDeviceSize offsetUniform = AlignUp(offsetVertex + memReqVertex.size, memReqUniform.alignment);
VkDeviceSize totalSize = offsetUniform + memReqUniform.size;
// 3. 只申请一次设备内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = totalSize;
allocInfo.memoryTypeIndex = FindMemoryType(
memReqIndex.memoryTypeBits & memReqVertex.memoryTypeBits & memReqUniform.memoryTypeBits,
desiredProperties // 比如 HOST_VISIBLE | HOST_COHERENT 等
);
VkDeviceMemory bigMemory;
vkAllocateMemory(device, &allocInfo, nullptr, &bigMemory);
// 4. 将同一个 bigMemory 不同的偏移绑定给不同 Buffer
vkBindBufferMemory(device, indexBuffer, bigMemory, offsetIndex);
vkBindBufferMemory(device, vertexBuffer, bigMemory, offsetVertex);
vkBindBufferMemory(device, uniformBuffer, bigMemory, offsetUniform);
这样所有的 Index/Vertex/Uniform Buffer 都共享了同一个 VkDeviceMemory,而我们只跟驱动真正打了一次“分配”的交道。
“The ?!? # Δt” ——极度碎片化或疯狂分配
思路:
• 每个小对象都单独分配,甚至更糟:同一个对象反复频繁地分配和释放;
• 导致显存碎片化、分配次数超标、或大幅度浪费显存;
典型反面案例:
• 你的场景中有非常多的微小 Buffer(例如粒子、分块地形中大量细分)却从未做子分配;
• 或者在帧间频繁地 vkFreeMemory / vkAllocateMemory,引起驱动层不断地做大开销的操作;
• 在高并发或高频率下,性能和可用内存都崩溃式下降。
数据传输
独立显卡
有专用的显存(VRAM)
数据传输过程:
CPU (Host) → PCIe总线 → GPU显存(Device Local Memory)
需要创建staging buffer作为中间缓冲
数据传输会受限于PCIe总线带宽
集成显卡
CPU和GPU共享系统内存
数据传输过程:
直接在共享内存中访问,无需跨PCIe传输
不需要staging buffer
通过VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT 标识
Lazily Allocated Memory
移动端GPU上的on-chip memory
这种内存不会立即分配物理内存
通常用于移动设备的 transient attachments(如深度缓冲区)
实际的内存分配会推迟到真正需要时
在某些架构上可能完全不会分配物理内存
这种内存在渲染时可以被保留在GPU上,显著降低带宽。
补充内容,VMA相关请跳转到“VMA的诞生”
补充:VkPhysicalDeviceMemoryProperties
typedef struct VkPhysicalDeviceMemoryProperties {
// 可用的内存类型数量
uint32_t memoryTypeCount;
// 内存类型数组,最大长度为 VK_MAX_MEMORY_TYPES (32)
VkMemoryType memoryTypes[VK_MAX_MEMORY_TYPES];
// 可用的内存堆数量
uint32_t memoryHeapCount;
// 内存堆数组,最大长度为 VK_MAX_MEMORY_HEAPS (16)
VkMemoryHeap memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;
memoryTypeCount指明该设备支持的内存类型数量。
VkMemoryType
其中 VkMemoryType 结构体定义为:
typedef struct VkMemoryType {
// 内存属性标志(VkMemoryPropertyFlags)
VkMemoryPropertyFlags propertyFlags;
// 此内存类型使用的堆的索引
uint32_t heapIndex;
} VkMemoryType;
一个VkMemoryType结构体对应GPU支持的一种内存类型,比如:
// memoryTypes[0] - 设备本地内存(VRAM)
propertyFlags = VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
heapIndex = 0 // 指向VRAM堆
// memoryTypes[1] - CPU可见的系统内存
propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
heapIndex = 1 // 指向系统内存堆
// memoryTypes[2] - CPU可见且带缓存的系统内存
propertyFlags = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT
heapIndex = 1 // 同样指向系统内存堆
VkMemoryPropertyFlags的常见值包括:
typedef enum VkMemoryPropertyFlagBits {
// 设备本地内存,通常是GPU最高效的内存类型
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT = 0x00000001,
// CPU可见内存,可以使用vkMapMemory映射
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT = 0x00000002,
// CPU写入立即可见,不需要手动flush
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT = 0x00000004,
// CPU写入被缓存,需要手动flush和invalidate
VK_MEMORY_PROPERTY_HOST_CACHED_BIT = 0x00000008,
// 用于tile-based GPU的延迟分配内存
VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT = 0x00000020,
// 受保护的内存,用于安全内容
VK_MEMORY_PROPERTY_PROTECTED_BIT = 0x00000040,
// RDMA可访问的内存
VK_MEMORY_PROPERTY_DEVICE_COHERENT_BIT_AMD = 0x00000040,
// 设备本地且RDMA可访问
VK_MEMORY_PROPERTY_DEVICE_UNCACHED_BIT_AMD = 0x00000080,
// 可以原子访问的RDMA内存
VK_MEMORY_PROPERTY_RDMA_CAPABLE_BIT_NV = 0x00000100,
} VkMemoryPropertyFlagBits;
常见的内存标识(flag常见组合):
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT //设备本地内存(GPU 专用)
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT //CPU 可见的暂存缓冲区
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT |
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT //集成显卡的共享内存
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT |
VK_MEMORY_PROPERTY_HOST_CACHED_BIT //带缓存的 CPU 访问内存
VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT |
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT //移动设备的临时附件(如深度缓冲)
VkMemoryHeap
typedef struct VkMemoryHeap {
VkDeviceSize size; // 堆的大小(字节)
VkMemoryHeapFlags flags; // 堆的属性标志
} VkMemoryHeap;
VkMemoryHeapFlags的常见值包括:
VK_MEMORY_HEAP_DEVICE_LOCAL_BIT //设备本地内存(通常是显卡的 VRAM)
VK_MEMORY_HEAP_MULTI_INSTANCE_BIT //多实例内存(在多 GPU 设置中,标记某个内存堆可以被多个物理设备同时访问)
findMemoryType
uint32_t findMemoryType(VkPhysicalDevice physicalDevice,
uint32_t typeFilter,
VkMemoryPropertyFlags properties) {
// 获取物理设备的内存属性
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);
// 遍历所有内存类型
for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
// 检查两个条件:
// 1. typeFilter 中的位是否设置 (通过位运算)
// 2. 内存类型是否具有我们需要的所有属性
if ((typeFilter & (1 << i)) &&
(memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
return i;
}
}
// 如果没找到合适的内存类型,抛出错误
throw std::runtime_error("failed to find suitable memory type!");
}
// 创建缓冲区时
VkBuffer buffer;
VkBufferCreateInfo bufferInfo = {...};
vkCreateBuffer(device, &bufferInfo, nullptr, &buffer);
// 获取缓冲区的内存需求
VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, buffer, &memRequirements);
// 分配内存
VkMemoryAllocateInfo allocInfo = {};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
// 查找合适的内存类型
allocInfo.memoryTypeIndex = findMemoryType(
physicalDevice,
memRequirements.memoryTypeBits, // typeFilter:缓冲区支持的内存类型
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT // 所需属性
);
在为缓冲区分配内存时,需要满足:
缓冲区支持的内存类型(typeFilter)
所需属性(properties)
即在VkPhysicalDeviceMemoryProperties中找到一个内存类型,它满足typeFilter和properties的要求。
VMA的诞生
Vulkan Memory Allocator(VMA)库应运而生,它通过以下核心设计解决了上述痛点:
智能内存类型选择:基于资源使用特性自动选择最佳内存类型
生命周期自动化:统一管理资源对象及其关联内存的生命周期
高级内存策略:提供内存池、碎片整理等高级功能
诊断工具集成:内置内存统计、泄漏检测等调试功能
初始化:构建内存管理基石
环境配置
使用VMA需要:
项目集成:要在项目中使用VMA,首先需要将其源代码或库文件包含进工程中,并正确链接(link)。
选择Vulkan版本:VMA需要配置Vulkan的目标版本,以便启用或禁用特定的Vulkan函数和扩展。
导入Vulkan函数:VMA自身需要调用大量Vulkan函数,这些函数需通过VmaVulkanFunctions结构体向VMA提供。可通过手动设置或者自动加载方式(如使用Vulkan loader)来实现。
启用扩展:如果需要使用诸如VK_KHR_dedicated_allocation等Vulkan扩展,则需要在创建VmaAllocator时告知VMA以便充分利用这些扩展。
配置选项:在初始化VMA时,可指定各种标志(Flags)与配置,如线程安全(是否启用互斥锁)等。
初始化流程
#include "vk_mem_alloc.h"
VmaAllocatorCreateInfo allocatorInfo = {};
allocatorInfo.vulkanApiVersion = VK_API_VERSION_1_2;
allocatorInfo.physicalDevice = physicalDevice;
allocatorInfo.device = device;
allocatorInfo.instance = instance;
VmaAllocator allocator;
vmaCreateAllocator(&allocatorInfo, &allocator);
关键配置项说明:
typedef struct VmaAllocatorCreateInfo {
VkPhysicalDevice physicalDevice;
VkDevice device;
// 启用高级特性
VmaAllocatorCreateFlags flags;
// 自定义CPU内存分配器
const VmaAllocationCallbacks* pAllocationCallbacks;
// 设备内存限制
VkDeviceSize heapSizeLimit[VK_MAX_MEMORY_HEAPS];
} VmaAllocatorCreateInfo;
推荐开启的标志位:
VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT:支持设备地址捕获
VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT:显存预算监控
AllocatorCreateInfo中的flags:
VMA_ALLOCATOR_CREATE_EXTERNALLY_SYNCHRONIZED_BIT
表示在多线程环境下,由用户负责同步
可以提高性能,但需要用户确保分配器的线程安全
如果设置此标志,用户必须在外部进行同步,确保对同一个 VmaAllocator 的调用不会并发执行
VMA_ALLOCATOR_CREATE_KHR_DEDICATED_ALLOCATION_BIT
启用 VK_KHR_dedicated_allocation 扩展功能
允许为某些特定资源分配专用内存块
适用于大型资源(如大纹理)的优化
VMA_ALLOCATOR_CREATE_KHR_BIND_MEMORY2_BIT
启用 VK_KHR_bind_memory2 扩展
提供更灵活的内存绑定选项
允许一次绑定多个内存对象
VMA_ALLOCATOR_CREATE_EXT_MEMORY_BUDGET_BIT
启用 VK_EXT_memory_budget 扩展
允许查询当前内存使用情况和预算
有助于更好地管理内存资源
VMA_ALLOCATOR_CREATE_AMD_DEVICE_COHERENT_MEMORY_BIT
启用 VK_AMD_device_coherent_memory 扩展
支持 AMD 设备一致性内存
提供更高效的内存访问
VMA_ALLOCATOR_CREATE_BUFFER_DEVICE_ADDRESS_BIT
启用缓冲区设备地址功能
支持 VK_KHR_buffer_device_address 扩展
允许在着色器中直接访问缓冲区
VMA_ALLOCATOR_CREATE_EXT_MEMORY_PRIORITY_BIT
启用 VK_EXT_memory_priority 扩展
允许设置内存分配的优先级
有助于优化内存管理策略
基础功能:从入门到精通
1. 资源生命周期管理
缓冲区创建范例:
VkBufferCreateInfo bufferInfo = { VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO };
bufferInfo.size = 1024 * 1024; // 1MB
bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_AUTO;
allocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT;
VkBuffer buffer;
VmaAllocation allocation;
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, nullptr);
VmaMemoryUsage枚举:
typedef enum VmaMemoryUsage {
VMA_MEMORY_USAGE_UNKNOWN = 0,
VMA_MEMORY_USAGE_GPU_ONLY, // 纯设备内存
VMA_MEMORY_USAGE_CPU_ONLY, // 可映射主机内存
VMA_MEMORY_USAGE_CPU_TO_GPU, // 频繁上传
VMA_MEMORY_USAGE_GPU_TO_CPU, // 回读数据
VMA_MEMORY_USAGE_AUTO = 7 // 自动决策(推荐)
} VmaMemoryUsage;
VmaAllocationCreateFlags 枚举值说明:
VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT
指示内存将被主机按顺序写入
适用于单次或连续写入的缓冲区
可能影响内存类型选择以优化顺序访问
例如每帧都需要更新的动态 uniform 数据和需要被频繁更新的顶点数据
VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT
指示内存将被主机随机读写访问
适用于需要频繁更新的动态缓冲区
会选择支持随机访问的内存类型
VMA_ALLOCATION_CREATE_HOST_ACCESS_ALLOW_TRANSFER_INSTEAD_BIT
当主机直接访问不可用时允许使用传输操作
提供内存访问的备选方案
增加分配的灵活性
VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT
强制为此分配使用独立的内存块
适用于大型资源或特殊用途
可能增加内存碎片
VMA_ALLOCATION_CREATE_NEVER_ALLOCATE_BIT
仅在现有内存块中查找空间
如果没有合适的空间则失败
用于严格控制内存分配
VMA_ALLOCATION_CREATE_MAPPED_BIT
创建时自动执行内存映射
避免手动映射/解映射操作
适用于需要持续访问的资源
VMA_ALLOCATION_CREATE_USER_DATA_COPY_STRING_BIT
为用户数据创建字符串的深拷贝
确保字符串数据的独立性和安全性
方便资源追踪和调试
VMA_ALLOCATION_CREATE_UPPER_ADDRESS_BIT
尝试在较高的 GPU 地址空间分配
可能影响某些特定硬件的性能
用于特殊的内存布局需求
内存分配策略标志
VMA_ALLOCATION_CREATE_STRATEGY_BEST_FIT_BIT
VMA_ALLOCATION_CREATE_STRATEGY_WORST_FIT_BIT
VMA_ALLOCATION_CREATE_STRATEGY_FIRST_FIT_BIT
用于控制内存分配算法的选择,影响分配效率和内存碎片
2. 内存映射与访问
安全的内存访问模式:
void* mappedData;
vmaMapMemory(allocator, allocation, &mappedData);
// 写入数据(建议使用memcpy而非直接指针操作)
memcpy(mappedData, sourceData, dataSize);
vmaUnmapMemory(allocator, allocation);
持久映射优化技巧:
allocInfo.flags |= VMA_ALLOCATION_CREATE_MAPPED_BIT;
// 创建后直接访问
VmaAllocationInfo allocInfo;
vmaGetAllocationInfo(allocator, allocation, &allocInfo);
void* persistentPtr = allocInfo.pMappedData;
1.使用VMA进行数据拷贝时无需创建和操作staging buffer,VMA会自动选择最佳内存类型,并进行数据传输。(依赖于创建buffer时正确指定usage和flags)
2.对于 Host 可见的内存,VMA 也提供 vmaFlushAllocation, vmaInvalidateAllocation 等接口,用于在需要时清理或无效化 CPU/GPU 缓存,确保数据一致性。
高级用法:突破性能瓶颈
1. 内存池(Memory Pools)
专用内存池配置:
VmaPoolCreateInfo poolInfo = {};
poolInfo.memoryTypeIndex = ...; // 指定内存类型
poolInfo.blockSize = 64 * 1024 * 1024; // 64MB块
poolInfo.minBlockCount = 1;
poolInfo.maxBlockCount = 8;
VmaPool pool;
vmaCreatePool(allocator, &poolInfo, &pool);
// 在池中分配资源
VmaAllocationCreateInfo poolAllocInfo = {};
poolAllocInfo.pool = pool; // 指定内存池
poolAllocInfo.usage = VMA_MEMORY_USAGE_AUTO; // 自动选择内存类型
poolAllocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT; // 指定内存访问模式
vmaCreateBuffer(allocator, &bufferInfo, &poolAllocInfo, &buffer, &allocation, nullptr);
2. 高级分配策略
优先设备本地内存:
allocInfo.usage = VMA_MEMORY_USAGE_AUTO_PREFER_DEVICE; //更灵活的策略,如果设备本地内存不足或不适用,会自动选择次优的内存类型
延迟内存分配:
allocInfo.flags |= VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT;
VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT 是 VMA 中一个特殊的内存分配标志,用于创建可能会”丢失”的内存分配。这是一个高级功能,主要用于内存管理优化。
这种分配可能在内存压力大时被VMA回收
需要定期检查分配是否还有效
通常配合 VMA_ALLOCATION_CREATE_CAN_MAKE_OTHER_LOST_BIT 使用
典型应用场景:
缓存数据
非关键资源
可重新生成的资源
最佳实践:
// 创建可丢失且可导致其他分配丢失的分配
VmaAllocationCreateInfo allocInfo = {};
allocInfo.flags = VMA_ALLOCATION_CREATE_CAN_BECOME_LOST_BIT |
VMA_ALLOCATION_CREATE_CAN_MAKE_OTHER_LOST_BIT;
allocInfo.priority = 0.5f; // 设置优先级
// 定期检查和维护
void maintainResources() {
for (auto& resource : resources) {
VmaAllocationInfo allocInfo;
vmaGetAllocationInfo(allocator, resource.allocation, &allocInfo);
if (allocInfo.deviceMemory == VK_NULL_HANDLE) {
// 重新创建资源
recreateResource(resource);
}
}
}
3. 内存碎片整理
碎片整理可以显著减少内存碎片,从而腾出连续的大块空间,避免频繁出现 OOM (Out Of Memory,内存耗尽)或内存分配失败的情况。当应用程序长期运行时,频繁的内存分配和释放可能导致内存碎片化,使得即使总的可用内存充足,也无法分配较大的连续内存块。
VMA 提供了一整套接口来执行碎片整理:
vmaBeginDefragmentation():初始化碎片整理上下文
vmaBeginDefragmentationPass() / vmaEndDefragmentationPass():执行碎片整理的一个或多个 Pass
vmaEndDefragmentation():结束碎片整理进程
vmaDefragment():单次执行碎片整理
注意:碎片整理期间,某些资源的内存可能会被移动,需要确保资源处于安全状态(通常在 GPU 空闲或可被重新绑定时进行)。
单次碎片整理流程:
VmaDefragmentationInfo defragInfo = {};
defragInfo.flags = VMA_DEFRAGMENTATION_FLAG_ALGORITHM_FAST;
VmaDefragmentationStats stats;
vmaDefragment(allocator, nullptr, 0, nullptr, &defragInfo, &stats);
printf("Freed %llu bytes, moved %u allocations\n",
stats.bytesFreed, stats.allocationsMoved);
更复杂的场景需要使用vmaBeginDefragmentation()和vmaEndDefragmentation(),以及vmaBeginDefragmentationPass()和vmaEndDefragmentationPass()。
4. 稀疏资源管理
稀疏纹理分配示例:
VkImageCreateInfo sparseImageInfo = { VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
sparseImageInfo.flags = VK_IMAGE_CREATE_SPARSE_BINDING_BIT;
sparseImageInfo.extent = {8192, 8192, 1}; // 8K*8K纹理
VmaAllocationCreateInfo sparseAllocInfo = {};
sparseAllocInfo.flags = VMA_ALLOCATION_CREATE_SPARSE_BINDING_BIT;
vmaCreateImage(allocator, &sparseImageInfo, &sparseAllocInfo, &image, &allocation, nullptr);
5.内存预算管理
内存预算管理主要包含两个关键功能:
查询预算信息
通过 vmaGetBudget 接口可查询各个显存堆的预算和使用情况:
VmaBudget budgets[VK_MAX_MEMORY_HEAPS];
vmaGetHeapBudgets(allocator, budgets);
// 检查第一个堆的使用情况
printf("Heap 0: Usage %llu MB / Budget %llu MB\n",
budgets[0].usage >> 20,
budgets[0].budget >> 20);
预算控制
使用 VMA_ALLOCATION_CREATE_WITHIN_BUDGET_BIT 标志可限制内存分配在预算范围内:
VmaAllocationCreateInfo allocInfo = {};
allocInfo.flags = VMA_ALLOCATION_CREATE_WITHIN_BUDGET_BIT;
// 若超出预算,vmaCreateBuffer 将返回 VK_ERROR_OUT_OF_DEVICE_MEMORY
VkResult result = vmaCreateBuffer(
allocator, &bufferInfo, &allocInfo,
&buffer, &allocation, nullptr);
6.虚拟分配器
虚拟分配器的核心思想是在不实际分配物理设备内存的情况下,模拟内存分配的行为。这对于以下场景特别有用:
内存分配策略的预演和验证
资源布局的优化
自定义内存管理系统的实现
例如:
// 模拟不同的资源分配方案
void SimulateResourceLayout() {
VmaVirtualBlock block;
vmaCreateVirtualBlock(&VmaVirtualBlockCreateInfo{
.size = 1024 * 1024 * 64 // 64MB
}, &block);
struct AllocationRecord {
VmaVirtualAllocation allocation;
VkDeviceSize offset;
VkDeviceSize size;
const char* resourceName;
};
std::vector<AllocationRecord> allocations;
// 模拟分配各种资源
auto allocateResource = [&](VkDeviceSize size, const char* name) {
VmaVirtualAllocationCreateInfo allocInfo = {};
allocInfo.size = size;
allocInfo.alignment = 256;
AllocationRecord record = {};
record.size = size;
record.resourceName = name;
if (vmaVirtualAllocate(block, &allocInfo, &record.allocation, &record.offset) == VK_SUCCESS) {
allocations.push_back(record);
return true;
}
return false;
};
// 分配各种资源
allocateResource(1024 * 1024, "Texture1");
allocateResource(512 * 1024, "Vertex Buffer");
allocateResource(256 * 1024, "Index Buffer");
// 分析内存布局
VmaStatInfo stats;
vmaCalculateVirtualBlockStats(block, &stats);
// 输出内存使用情况
for (const auto& record : allocations) {
printf("Resource: %s, Offset: %llu, Size: %llu\n",
record.resourceName, record.offset, record.size);
}
// 清理
for (const auto& record : allocations) {
vmaVirtualFree(block, record.allocation);
}
vmaDestroyVirtualBlock(block);
}
关键数据结构
VmaAllocator
VMA 的核心对象
代表一个全局或应用级别的内存分配器
VmaAllocation
代表一次内存分配
对应底层 Vulkan Device Memory 中的一块区域
VmaAllocationCreateInfo
创建分配时的配置结构
包含 VmaMemoryUsage、映射选项、独立分配等参数
VmaAllocationInfo
分配完成后返回的详细信息
包含偏移量、实际大小、映射指针等数据
VmaMemoryUsage
指定内存分配的用途
如 GPU_ONLY、CPU_ONLY 等类型
VmaPool
自定义内存池对象
用于统一管理多种内存分配
VmaPoolCreateInfo
内存池的创建参数
配置池的属性和行为
VmaBudget
内存预算管理结构
跟踪内存使用量和可用预算
VmaStatistics & VmaDetailedStatistics
内存使用统计信息
提供详细的内存分配状态
VmaVirtualAllocation & VmaVirtualBlock
虚拟内存分配相关结构
用于无物理内存的资源规划
推荐使用模式
VMA 官方文档中针对常见资源使用模式(如 GPU-only 资源、上传缓冲、回读缓冲、以及高级数据传输模式)都给出了对应的 VmaMemoryUsage 和配置建议。例如:
GPU-only 资源:
VMA_MEMORY_USAGE_GPU_ONLY
VMA_ALLOCATION_CREATE_DEDICATED_MEMORY_BIT(可选)
CPU -> GPU 上传:
VMA_MEMORY_USAGE_CPU_TO_GPU
VMA_ALLOCATION_CREATE_MAPPED_BIT(可选)
GPU -> CPU 读取:
VMA_MEMORY_USAGE_GPU_TO_CPU
先进的上传数据管理:
结合自定义内存池
使用线性分配算法提升效率
自动映射
VMA_ALLOCATION_CREATE_MAPPED_BIT 是一个在创建 VMA 内存分配时使用的标志位,它的主要功能是在分配内存的同时自动将其映射到 CPU 可访问的地址空间。这样可以省去手动调用 vmaMapMemory 的步骤。
// 不使用 VMA_ALLOCATION_CREATE_MAPPED_BIT 的传统方式
{
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
VmaAllocation allocation;
VkBuffer buffer;
// 创建buffer和分配内存
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, nullptr);
// 需要手动映射内存
void* mappedData;
vmaMapMemory(allocator, allocation, &mappedData);
// 使用映射的内存
memcpy(mappedData, sourceData, dataSize);
// 需要手动解除映射
vmaUnmapMemory(allocator, allocation);
}
// 使用 VMA_ALLOCATION_CREATE_MAPPED_BIT 的方式
{
VmaAllocationCreateInfo allocInfo = {};
allocInfo.usage = VMA_MEMORY_USAGE_CPU_TO_GPU;
allocInfo.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; // 自动映射
VmaAllocation allocation;
VkBuffer buffer;
VmaAllocationInfo allocInfo;
// 创建buffer和分配内存,同时获取分配信息
vmaCreateBuffer(allocator, &bufferInfo, &allocInfo, &buffer, &allocation, &allocInfo);
// 直接通过 allocInfo.pMappedData 访问映射的内存
memcpy(allocInfo.pMappedData, sourceData, dataSize);
// 不需要手动解除映射,会在内存释放时自动处理
}
性能优化实践
通过合理使用VMA的高级特性,在真实项目中可实现:
内存分配耗时降低70%(对比原生Vulkan接口)
显存碎片率控制在5%以下
内存泄漏检测效率提升90%
典型案例:
《赛博朋克2077》:使用VMA管理超过20GB的显存资源
Unreal Engine 5:集成VMA实现跨平台内存管理
DOOM Eternal:通过VMA内存池技术降低8%的显存占用
EasyVulkan中的VMA
在EasyVulkan中,Buffer和Image的内存分配都使用了VMA。
Buffer Builder
根据上文的问题,在创建Buffer时比较重要的信息包括:
Buffer size。
Buffer usage。
Buffer memory usage。
Buffer memory flags。
Buffer memory type index(property,optional)。
一个Buffer的创建流程可以简化为:
// Create a vertex buffer
auto vertexBuffer = bufferBuilder
->setSize(sizeof(vertices))
->setUsage(VK_BUFFER_USAGE_VERTEX_BUFFER_BIT)
->setMemoryUsage(VMA_MEMORY_USAGE_CPU_TO_GPU)
->setMemoryFlags(VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
VMA_ALLOCATION_CREATE_MAPPED_BIT)
->build("myVertexBuffer");
// Create a storage buffer used on GPU only
auto storageBuffer = bufferBuilder
->setSize(sizeof(storageData))
->setUsage(VK_BUFFER_USAGE_STORAGE_BUFFER_BIT)
->setMemoryUsage(VMA_MEMORY_USAGE_GPU_ONLY)
->build("myStorageBuffer");
VMA PDF文档|Generated by Doxygen
-
Vulkan初始化
最开始接触Vulkan时,通常会被其复杂的概念和庞大的API所吓到。无法理解window、instance、surface等概念的关系,不能区分物理设备和逻辑设备的区别。本文将介绍Vulkan的初始化过程,并解释各个概念之间的关系。最后,本文将介绍EasyVulkan项目的VulkanDevice和VulkanContext对这些概念的封装。
整体流程
创建 Window
创建 Instance
检查和启用必要的validation layers(如果在debug模式下)
设置必要的instance extensions,特别是GLFW要求的extensions
创建 Window Surface
获取物理设备
检查物理设备是否支持所需的features和extensions
检查物理设备是否适合(比如是否为独立显卡、是否支持所需的图形特性等)
获取队列族索引
创建逻辑设备
创建队列创建信息
启用必要的device extensions(比如VK_KHR_swapchain)
指定设备features
创建命令池
1.创建Window
使用 GLFW 创建窗口,这是显示 Vulkan 渲染结果的基础:
glfwInit();
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API); // 不创建 OpenGL 上下文
glfwWindowHint(GLFW_RESIZABLE, GLFW_FALSE); // 暂时禁用窗口大小调整
window = glfwCreateWindow(WIDTH, HEIGHT, "Vulkan", nullptr, nullptr);
由于历史原因,GLFW 最初是为 OpenGL 设计的窗口管理库,默认情况下,当你创建 GLFW 窗口时,它会自动创建一个 OpenGL 上下文。因此需要指定GLFW_CLIENT_API为GLFW_NO_API,只创建窗口,而不创建 OpenGL 上下文。
2.创建 Instance
Instance 是应用程序与 Vulkan 库之间的连接:
VkApplicationInfo appInfo{};
appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO;
appInfo.pApplicationName = "Vulkan App";
appInfo.applicationVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.pEngineName = "No Engine";
appInfo.engineVersion = VK_MAKE_VERSION(1, 0, 0);
appInfo.apiVersion = VK_API_VERSION_1_0;
VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
createInfo.pApplicationInfo = &appInfo;
// 获取 GLFW 需要的 extension
uint32_t glfwExtensionCount = 0;
const char** glfwExtensions = glfwGetRequiredInstanceExtensions(&glfwExtensionCount);
createInfo.enabledExtensionCount = glfwExtensionCount;
createInfo.ppEnabledExtensionNames = glfwExtensions;
vkCreateInstance(&createInfo, nullptr, &instance);
Instance 代表了一个 Vulkan 应用程序的实例,它主要负责:
告诉 Vulkan 驱动程序我们要使用哪些全局扩展(比如与窗口系统的集成)
告诉驱动程序我们的应用程序信息(名称、版本等)
设置调试回调
枚举系统中可用的物理设备(GPU)
Instance的创建过程恰恰说明了他的角色:
VkInstanceCreateInfo createInfo{};
createInfo.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO;
// 告诉 Vulkan 我们的应用程序信息
createInfo.pApplicationInfo = &appInfo;
// 告诉 Vulkan 我们需要哪些扩展
createInfo.enabledExtensionCount = glfwExtensionCount;
createInfo.ppEnabledExtensionNames = glfwExtensions;
// 告诉 Vulkan 我们需要哪些验证层(用于调试)
createInfo.enabledLayerCount = validationLayers.size();
createInfo.ppEnabledLayerNames = validationLayers.data();
可以把 Instance 想象成一个”门户”或”接待员”:
// 没有 Instance 之前,我们无法调用大多数 Vulkan 函数
// 创建 Instance 后,我们可以做这些事:
vkEnumeratePhysicalDevices(instance, ...); // 查询 GPU
vkCreateDebugUtilsMessengerEXT(instance, ...); // 设置调试
// 等等
Instance可以被理解为一个“配置中心”,我们可以通过他告诉Vulkan:
这是我的应用程序
这是我需要的功能
这是我的调试需求
3. 创建 Window Surface
Window Surface 提供了 Vulkan 与窗口系统的连接:
VkSurfaceKHR surface;
if (glfwCreateWindowSurface(instance, window, nullptr, &surface) != VK_SUCCESS) {
throw std::runtime_error("failed to create window surface!");
}
Vulkan 是与平台无关的图形 API,不直接处理窗口系统。Window Surface 是 Vulkan 和窗口系统之间的桥梁,它提供了一个可以渲染到的目标平面.
工作流程
Vulkan 渲染流程 → Swapchain → Surface → 窗口系统 → 显示到屏幕
// 1. 创建 Surface
VkSurfaceKHR surface;
glfwCreateWindowSurface(instance, window, nullptr, &surface);
// 2. Surface 用于创建 Swapchain
VkSwapchainCreateInfoKHR createInfo{};
createInfo.surface = surface; // Surface 告诉 Swapchain 渲染目标在哪里
// 3. 渲染时
vkAcquireNextImageKHR(...); // 从 Swapchain 获取下一个可用的图像
// 渲染到图像
vkQueuePresentKHR(...); // 通过 Surface 将渲染结果显示到窗口
Surface的作用
提供图像呈现能力
决定支持的图像格式
决定支持的呈现模式
处理平台差异
Windows:使用 Win32 窗口系统
Linux:使用 X11 或 Wayland
macOS:使用 Metal 层
可以把 Surface 想象成一个”画布”:
Vulkan 是画家(渲染器)
Window 是画框(显示窗口)
Surface 是画布,它把画家的作品(渲染结果)放在画框中展示
图 1:Surface 的作用。
4. 获取物理设备
选择合适的物理设备(显卡):
uint32_t deviceCount = 0;
vkEnumeratePhysicalDevices(instance, &deviceCount, nullptr);
std::vector<VkPhysicalDevice> devices(deviceCount);
vkEnumeratePhysicalDevices(instance, &deviceCount, devices.data());
// 选择第一个适合的设备
VkPhysicalDevice physicalDevice = VK_NULL_HANDLE;
for (const auto& device : devices) {
if (isDeviceSuitable(device)) {
physicalDevice = device;
break;
}
}
isDeviceSuitable 函数通常会检查以下几个关键方面来确定物理设备是否满足应用需求:
基本设备信息检查
bool isDeviceSuitable(VkPhysicalDevice device) {
// 获取设备基本属性
VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);
// 获取设备特性
VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
// 检查是否为独立显卡
bool isDiscreteGPU = deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU;
}
队列族支持检查
bool checkQueueFamilySupport(VkPhysicalDevice device) {
QueueFamilyIndices indices = findQueueFamilies(device);
// 检查是否支持所需的所有队列族
// - 图形队列族
// - 计算队列族
// - 显示队列族
return indices.isComplete();
}
设备扩展支持检查
bool checkDeviceExtensionSupport(VkPhysicalDevice device) {
// 获取设备支持的扩展
uint32_t extensionCount;
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, nullptr);
std::vector<VkExtensionProperties> availableExtensions(extensionCount);
vkEnumerateDeviceExtensionProperties(device, nullptr, &extensionCount, availableExtensions.data());
// 检查必要的扩展是否被支持
// 比如 VK_KHR_swapchain
std::set<std::string> requiredExtensions = {
VK_KHR_SWAPCHAIN_EXTENSION_NAME
};
for (const auto& extension : availableExtensions) {
requiredExtensions.erase(extension.extensionName);
}
return requiredExtensions.empty();
}
Swapchain 适配性检查
bool checkSwapChainAdequate(VkPhysicalDevice device) {
// 检查 surface 格式
uint32_t formatCount;
vkGetPhysicalDeviceSurfaceFormatsKHR(device, surface, &formatCount, nullptr);
// 检查显示模式
uint32_t presentModeCount;
vkGetPhysicalDeviceSurfacePresentModesKHR(device, surface, &presentModeCount, nullptr);
return formatCount > 0 && presentModeCount > 0;
}
内存属性检查
bool checkMemoryProperties(VkPhysicalDevice device) {
VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(device, &memProperties);
// 检查是否有足够的显存
// 检查是否支持所需的内存类型
return true; // 根据具体需求判断
}
综合评分系统(可选)
int rateDeviceSuitability(VkPhysicalDevice device) {
int score = 0;
// 基础分:独立显卡加分
VkPhysicalDeviceProperties deviceProperties;
vkGetPhysicalDeviceProperties(device, &deviceProperties);
if (deviceProperties.deviceType == VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU) {
score += 1000;
}
// 性能分:根据最大纹理大小加分
score += deviceProperties.limits.maxImageDimension2D;
// 特性分:支持几何着色器加分
VkPhysicalDeviceFeatures deviceFeatures;
vkGetPhysicalDeviceFeatures(device, &deviceFeatures);
if (deviceFeatures.geometryShader) {
score += 100;
}
return score;
}
最终的设备选择函数如下:
bool isDeviceSuitable(VkPhysicalDevice device) {
bool extensionsSupported = checkDeviceExtensionSupport(device);
bool swapChainAdequate = false;
if (extensionsSupported) {
swapChainAdequate = checkSwapChainAdequate(device);
}
return checkQueueFamilySupport(device) &&
extensionsSupported &&
swapChainAdequate &&
checkMemoryProperties(device) &&
rateDeviceSuitability(device) > minRequiredScore;
}
5. 获取队列族索引
查找支持所需操作的队列族:
struct QueueFamilyIndices {
std::optional<uint32_t> graphicsAndComputeFamily; // 图形和计算共用一个队列族
std::optional<uint32_t> presentFamily;
bool isComplete() {
return graphicsAndComputeFamily.has_value() && presentFamily.has_value();
}
};
QueueFamilyIndices findQueueFamilies(VkPhysicalDevice device) {
QueueFamilyIndices indices;
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(device, &queueFamilyCount, queueFamilies.data());
// 查找支持图形和计算的队列族
// 查找支持显示的队列族
// ... 具体实现略
return indices;
}
队列族
队列族是一组具有相同功能的队列(Queue),可以理解为是物理设备的一部分,每个队列族支持特定类型的操作,比如:
图形操作(绘制命令)
计算操作(计算着色器)
传输操作(内存复制)
显示操作(显示到屏幕)
物理设备(GPU)
├── 队列族 0(支持图形+计算+传输)
│ ├── 队列 0
│ └── 队列 1
├── 队列族 1(仅支持传输)
│ └── 队列 0
└── 队列族 2(支持显示)
└── 队列 0
可以查询每个队列族的队列数量、支持的特性,比如:
// 获取队列族属性
uint32_t queueFamilyCount = 0;
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, nullptr);
std::vector<VkQueueFamilyProperties> queueFamilies(queueFamilyCount);
vkGetPhysicalDeviceQueueFamilyProperties(physicalDevice, &queueFamilyCount, queueFamilies.data());
// 遍历每个队列族,查看其中的队列数量
for (uint32_t i = 0; i < queueFamilyCount; i++) {
const auto& queueFamily = queueFamilies[i];
// queueCount 就是该队列族中的队列数量
uint32_t numQueues = queueFamily.queueCount;
// 打印队列族信息
std::cout << "Queue Family " << i << ":\n";
std::cout << " Number of queues: " << numQueues << "\n";
std::cout << " Supports graphics: " << (queueFamily.queueFlags & VK_QUEUE_GRAPHICS_BIT ? "yes" : "no") << "\n";
std::cout << " Supports compute: " << (queueFamily.queueFlags & VK_QUEUE_COMPUTE_BIT ? "yes" : "no") << "\n";
std::cout << " Supports transfer: " << (queueFamily.queueFlags & VK_QUEUE_TRANSFER_BIT ? "yes" : "no") << "\n";
}
队列族作用
在创建逻辑设备时需要制定使用的队列族,比如:
// 创建队列信息
float queuePriority = 1.0f;
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = graphicsFamily; // 指定队列族索引
queueCreateInfo.queueCount = 1; // 使用的队列数量
queueCreateInfo.pQueuePriorities = &queuePriority; // 队列优先级
不同队列族
不同队列族的命令可以并行执行
专用队列族(如只支持传输的队列族)通常性能更好
需要在不同队列族之间同步操作时会有性能开销
相同队列族的不同队列
可以使用同一个队列族的两个队列提交命令:
// 获取同一队列族的两个队列
VkQueue queue1, queue2;
vkGetDeviceQueue(device, graphicsFamilyIndex, 0, &queue1);
vkGetDeviceQueue(device, graphicsFamilyIndex, 1, &queue2);
// 这两个队列可以并行执行命令
vkQueueSubmit(queue1, 1, &submitInfo1, fence1); // 在队列1提交命令
vkQueueSubmit(queue2, 1, &submitInfo2, fence2); // 在队列2提交命令
// 这两个提交会并行执行,不需要等待队列1完成
同一队列族的所有队列具有相同的能力(比如都支持图形操作)
每个队列都有自己独立的命令流
每个队列都可以独立提交命令缓冲区
队列之间的执行是异步的(并行执行,没有先后顺序),除非使用同步原语(使用同步原语,可以实现队列2等待队列1完成)
队列优先级和同步
// 创建队列时可以指定不同的优先级
float priorities[] = { 1.0f, 0.5f }; // 两个队列,不同优先级
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.queueCount = 2;
queueCreateInfo.pQueuePriorities = priorities;
// 如果需要队列间同步,可以使用信号量
VkSubmitInfo submitInfo{};
submitInfo.waitSemaphoreCount = 1;
submitInfo.pWaitSemaphores = &waitSemaphore; // 等待其他队列的信号量
submitInfo.signalSemaphoreCount = 1;
submitInfo.pSignalSemaphores = &signalSemaphore; // 发出完成信号
并行渲染举例
// 场景1:并行渲染多个对象
void renderScene() {
// 队列1渲染地形
vkQueueSubmit(queue1, 1, &terrainSubmitInfo, terrainFence);
// 同时,队列2渲染角色
vkQueueSubmit(queue2, 1, &characterSubmitInfo, characterFence);
// 两个渲染任务并行执行
}
// 场景2:一个队列处理主要渲染,另一个处理后期效果
void render() {
// 队列1执行主要渲染
vkQueueSubmit(queue1, 1, &mainRenderSubmitInfo, mainRenderFence);
// 设置依赖关系
waitSemaphores = mainRenderComplete;
// 队列2执行后期处理
vkQueueSubmit(queue2, 1, &postProcessSubmitInfo, postProcessFence);
}
6. 创建逻辑设备
创建队列创建信息
如前文所说,逻辑设备的创建需要指定使用的队列族。为每个唯一的队列族创建创建信息结构体:
std::vector<VkDeviceQueueCreateInfo> queueCreateInfos;
std::set<uint32_t> uniqueQueueFamilies = {
indices.graphicsAndComputeFamily.value(),
indices.presentFamily.value()
};
float queuePriority = 1.0f;
for (uint32_t queueFamily : uniqueQueueFamilies) {
VkDeviceQueueCreateInfo queueCreateInfo{};
queueCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO;
queueCreateInfo.queueFamilyIndex = queueFamily; // 指定队列族索引
queueCreateInfo.queueCount = 1; // 使用的队列数量
queueCreateInfo.pQueuePriorities = &queuePriority; // 队列优先级
queueCreateInfos.push_back(queueCreateInfo);
}
创建逻辑设备
VkDeviceCreateInfo deviceCreateInfo{};
deviceCreateInfo.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO;
deviceCreateInfo.queueCreateInfoCount = static_cast<uint32_t>(queueCreateInfos.size());
deviceCreateInfo.pQueueCreateInfos = queueCreateInfos.data(); // 指定队列创建信息
VkPhysicalDeviceFeatures deviceFeatures{};
deviceCreateInfo.pEnabledFeatures = &deviceFeatures; // 指定设备特性
VkDevice device;
if (vkCreateDevice(physicalDevice, &deviceCreateInfo, nullptr, &device) != VK_SUCCESS) {
throw std::runtime_error("failed to create logical device!");
}
如前面提到的,队列族是物理设备的一部分,逻辑设备创建时需要指定使用的队列族和队列的数量,因此可以将逻辑设备理解为建立在队列族上对物理设备的抽象。
即:
物理设备(GPU)
└── 逻辑设备(对GPU的抽象接口)
├── 队列族 0
│ ├── 队列 0
│ └── 队列 1
└── 队列族 1
└── 队列 0
上面展示了如何指定队列族和队列数量来创建逻辑设备,下面介绍如何从逻辑设备获取队列:
// 从逻辑设备获取队列
VkQueue graphicsQueue;
vkGetDeviceQueue(logicalDevice, // 逻辑设备句柄
graphicsFamilyIndex, // 队列族索引
0, // 队列索引
&graphicsQueue); // 获取到的队列
逻辑设备的功能
创建和管理各种 Vulkan 资源(缓冲区、图像等)
提供队列访问接口
启用设备特性和扩展
控制设备内存分配
例如:
// 使用逻辑设备创建资源
VkBuffer buffer;
vkCreateBuffer(logicalDevice, &bufferInfo, nullptr, &buffer);
// 使用逻辑设备分配内存
VkDeviceMemory memory;
vkAllocateMemory(logicalDevice, &allocInfo, nullptr, &memory);
不同的逻辑设备
一个应用程序可以创建多个逻辑设备
每个逻辑设备都有自己的队列和资源
不同逻辑设备间的资源不能直接共享
逻辑设备销毁时,其创建的所有资源也会被销毁
7. 创建命令池
创建用于管理命令缓冲区的命令池:
VkCommandPoolCreateInfo poolInfo{};
poolInfo.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO;
poolInfo.queueFamilyIndex = indices.graphicsAndComputeFamily.value(); // 指定队列族索引
poolInfo.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; // 允许单独重置命令缓冲区
VkCommandPool commandPool;
if (vkCreateCommandPool(device, &poolInfo, nullptr, &commandPool) != VK_SUCCESS) {
throw std::runtime_error("failed to create command pool!");
}
命令池的作用
命令池用于管理命令缓冲区的内存
每个命令池只能分配给特定的队列族使用
从同一个命令池分配的命令缓冲区只能提交到同一队列族的队列中(因为第二点指定了队列族的类型)
命令池标志
// 常用的命令池标志
VK_COMMAND_POOL_CREATE_TRANSIENT_BIT // 提示命令缓冲区会频繁重录制,可以优化内存分配
VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT // 允许单独重置命令缓冲区,而不是只能重置整个池
VK_COMMAND_POOL_CREATE_PROTECTED_BIT // 创建受保护的命令缓冲区
分配命令缓冲
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool; // 指定命令池
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; // 主要或次要命令缓冲区
allocInfo.commandBufferCount = 1; // 分配数量
VkCommandBuffer commandBuffer;
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
命令池(Command Pool)和命令缓冲区(Command Buffer)
命令池是内存池
命令缓冲区是从这个内存池分配的内存块
所有命令缓冲区共享命令池的属性(如队列族绑定)
命令池管理着所有命令缓冲区的生命周期
内存管理关系
// 命令池负责管理命令缓冲区的内存分配
VkCommandPool commandPool;
std::vector<VkCommandBuffer> commandBuffers;
// 从命令池分配命令缓冲区
VkCommandBufferAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
allocInfo.commandPool = commandPool; // 指定从哪个命令池分配
allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
allocInfo.commandBufferCount = 1; // 分配数量
vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
生命周期关系
命令池控制着其分配的所有命令缓冲区的生命周期
销毁命令池时会自动销毁其分配的所有命令缓冲区
void cleanup() {
// 不需要单独释放命令缓冲区
vkDestroyCommandPool(device, commandPool, nullptr); // 会自动释放所有命令缓冲区
}
重置关系
// 重置整个命令池(影响所有命令缓冲区)
vkResetCommandPool(device, commandPool, VK_COMMAND_POOL_RESET_RELEASE_RESOURCES_BIT);
// 如果命令池创建时指定了 VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT
// 则可以单独重置命令缓冲区
vkResetCommandBuffer(commandBuffer, VK_COMMAND_BUFFER_RESET_RELEASE_RESOURCES_BIT);
队列族关系
// 命令池绑定到特定队列族
VkCommandPoolCreateInfo poolInfo{};
poolInfo.queueFamilyIndex = graphicsQueueFamily; // 指定队列族
// 从该命令池分配的命令缓冲区只能提交到同一队列族的队列
VkSubmitInfo submitInfo{};
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;
vkQueueSubmit(graphicsQueue, 1, &submitInfo, fence); // 队列必须属于同一队列族
实例扩展和设备扩展
实例扩展(Instance Extensions):
作用范围:作用于整个 Vulkan 实例(VkInstance),影响全局功能
主要用途:
提供跨平台功能,如窗口系统集成(WSI)
添加调试和验证层支持
提供实例级别的新功能
加载时机:在创建 VkInstance 时通过 vkCreateInstance 启用
常见的实例扩展:
VK_KHR_surface
最基础的窗口系统接口扩展
定义了创建和管理平台无关的窗口表面的基础功能
几乎所有需要显示的应用都会用到
平台特定的 surface 扩展:
VK_KHR_win32_surface (Windows)
VK_KHR_xlib_surface (X11/Linux)
VK_KHR_wayland_surface (Wayland/Linux)
VK_KHR_android_surface (Android)
VK_MVK_macos_surface (macOS)
VK_EXT_debug_utils
提供调试功能
允许为 Vulkan 对象添加标签和名称
支持调试信息的回调
VK_KHR_get_physical_device_properties2
获取物理设备的额外属性信息
常用于查询新功能的支持情况
设备扩展(Device Extensions):
作用范围:作用于特定的物理设备(VkPhysicalDevice)和逻辑设备(VkDevice)
主要用途:
提供特定硬件功能支持
启用设备特定的渲染特性
添加新的设备级API功能
加载时机:在创建 VkDevice 时通过 vkCreateDevice 启用
常见的设备扩展:
VK_KHR_swapchain
最基础的显示相关扩展
用于创建和管理交换链
实现帧缓冲和显示同步
VK_KHR_maintenance1/2/3
提供各种 API 改进和补充功能
修复早期版本的一些限制和问题
VK_KHR_dynamic_rendering
简化渲染流程
无需创建 render pass 对象
更灵活的渲染配置
VK_KHR_multiview
支持单次渲染传递到多个视图
用于 VR 等立体渲染场景
VK_KHR_shader_*系列:
VK_KHR_shader_float16_int8 // 支持16位浮点和8位整数
VK_KHR_shader_non_semantic_info // 着色器附加信息
VK_KHR_shader_draw_parameters // 绘制参数访问
EasyVulkan的初始化设计
在EasyVulkan中,初始化主要由VulkanContext和VulkanDevice两个类完成。
VulkanContext
主要用于管理各种Vulkan的对象和资源。
创建 Vulkan 实例,可选择设置验证层和调试回调。
拥有对 VulkanDevice、SwapchainManager、ResourceManager、CommandPoolManager 和可选的 SynchronizationManager 的引用。
协调高级生命周期(初始化、清理)。
VulkanDevice
VulkanDevice类主要对物理设备和逻辑设备进行管理。
选择具有所需功能的物理设备,创建逻辑设备。
维护队列句柄(图形、计算、传输)。
集成 Vulkan 内存分配器(VMA)。
使用说明
借助VulkanDevice本身就是VulkanContext的成员,因此初始化可以简化为:
VulkanContext context;
context.initialize();
在VulkanContext中,我们可以借助Manager类来管理各种Vulkan的对象和资源。
例如,使用SwapchainManager:
创建和管理交换链
处理窗口调整事件
管理交换链图像和图像视图
提供图像获取和呈现功能
使用ResourceManager:
所有主要 Vulkan 资源的生成器接口(如BufferBuilder、ImageBuilder、ShaderModuleBuilder等)
自动资源跟踪和清理
基于名称的资源查找
-
图像降采样
最近的一个横向中涉及对图像进行降采样的问题,最近两周实现和对比了一些降采样的方法,在本文中进行归纳总结。
本文的主要内容包括: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