一、曲面细分(Tessellation)
做了什么
给鸭子模型加了一条细分管线。细分是在渲染管线里插两个新阶段:细分控制着色器(TCS,tessellation control)和细分求值着色器(TES,tessellation evaluation),位置在顶点着色器之后。UI 上加了开关,可以在 Solid(正常绘制)和 Tess(细分绘制)之间切换。
卡住的地方
这部分我一开始连 main.tesc 都没写 ------ 因为完全没理解控制阶段要干什么,感觉无从下手,也不想抄。后来把概念理清才补上。
几个当时搞混的概念:
layout(vertices=3) out;不是 "输出 3 个顶点",而是声明一个 patch 输出几个控制点。我画的鸭子模型每个 patch 就是三角形,所以是 3。gl_TessLevelOuter/gl_TessLevelInner是 GLSL 内部变量(built-in),TCS 里给它们赋值,驱动就按这个数值决定把一个 patch 切成多少小三角形。Outer 管外环(每条边一个值),Inner 管内部。gl_TessCoord是重心坐标 (u, v, w),TES 里用它把三个控制点插值成最终顶点位置。distance()那个函数是我在 shader 里自己写的辅助函数,不是 GLSL 内建函数 ------ 距离近的边细分等级高,远的低。
踩的坑:Tess 模式模型混乱
第一次跑通,Solid 模式(ALL)鸭子正常,切到 Tess 模式整个模型是乱的,后来改完又变成完全变形。查的方向是:Tess 模式走的是另一条渲染管线,顶点输入和传递到后级阶段的数据结构必须和主管线保持一致 。main.vert 里有 PerVertex 结构被后面阶段共用,改动它会影响其他模式的绘制,所以要非常小心。把数据结构统一之后,ALL 和 Tess 两个模式都正常了。
学到的东西
细分让同一个低精度控制网格在 GPU 上按需加密,离得近的地方细分等级高、边缘更圆滑,不用 CPU 反复改顶点数据。gl_TessLevelOuter/Inner 是驱动读取的 "切几刀" 指令,控制点和重心坐标是它的输入输出。这是以后做地形、头发、动态 LOD 曲面会用到的东西。
二、顶点压缩与间接绘制(C5-07)
做了什么
两件事:把顶点数据压缩,以及用一条命令绘制多个模型(含 LOD)。
顶点压缩:32B → 20B
原来的顶点结构是 pos + normal + uv 各一个 float3/float3/float2,32 字节。压缩后:
cpp
pos Float3 12B // 位置保持精度,不能动
normal Int_2_10_10_10_REV 4B // xyz 各 10 bit 有符号整数
uv HalfFloat2 4B // 半精度
总计 20B,省了 37.5%
Int_2_10_10_10_REV 这个格式就是 32 bit 打包三个 10 bit 分量(外加 2 bit 空余),专门给法线这类 "够用就行" 的数据。法线压成 10 bit 肉眼看不出区别,UV 用半精度对 8K 纹理也完全够。要记住的是压缩顺序和解压顺序必须一致,shader 里怎么 unpack,CPU 侧写入就得怎么 pack,顺序反了数据全乱。
LOD 与多 mesh
- meshopt 提供两档简化:
meshopt_simplify正常减面,simplifySloppy更激进(只减索引不减顶点,模型绘制不会出错,只是面更大)。远处用 sloppy,近处用严格版本。 - 拆 LOD 的条件:
targetIndicesCount > 1024 && LOD < 8,够大才拆,最多 8 级。 - 多个 mesh 合进同一个顶点 / 索引缓冲,靠
indexOffset、vertexOffset定位每个 mesh 的位置;每个 mesh 自己的 LOD 用lodOffset/lodCount管理。getLODIndicesCount在偏移数组末尾多放一个值,用来标记整个索引区域的结束,从而能推算出每级 LOD 的大小 ------ 这个设计一开始没看懂。 drawIndexedIndirect:把绘制参数放进 GPU buffer,CPU 只提交一次命令,GPU 自己读参数画多个模型。这和 CPU 逐个cmdDrawIndexed的区别是:一个是一次性批量提交,一个是多次调用。
踩的坑
- compact 模式模型错乱:索引和顶点的对应关系没对上(绑错了数据源),理解了 "索引必须指向正确的顶点位置" 后才修好。
- compact 模型纹理不显示:UV 方向问题,做了翻转处理。
processLods里处理顶点流时步长写错过(/2还是/3的问题),顶点流每份数据的字段数要核对清楚。exterior.obj资源没下载到,重解压才拿到。- 主程序越来越长,拆出了 mesh.h/cpp、pipelines.h/cpp、ui.h、input.h;拆的过程中发现顶点结构定义有重复,去重了一遍。
三、计算纹理(C5-08)
做了什么
用计算着色器(compute shader)往一张纹理里写数据,做出等离子效果(plasma),UI 上加开关切换是否使用这张计算纹理。
知识点
- 计算着色器不属于渲染管线,是通用并行计算。
layout(local_size_x = 16)声明每个工作组(group)有 16 个线程。 - 计算纹理用的是 storage image:shader 里直接
imageStore往纹理写像素。 gl_GlobalInvocationID是所有线程里的全局索引,gl_LocalInvocationID是当前工作组内的索引。用学校打比方:学校是全局、班级是工作组、学生是线程;16 是组内线程数,64 是总线程数。- 等离子效果的数学核心是
fract配合时间参数,用它做颜色阈值和混合,随时间推移画面就流动起来。
踩的坑:等离子不动
一开始效果是静态的,画面完全不动。原因是时间没真正参与进计算(或者压根没传进 shader)。把时间传进去、让颜色的参数随时间变化后,波纹就流动起来了。先写后读的顺序也要注意:compute 写完纹理,渲染阶段再去采样,中间需要同步。
四、计算网格(C5-09):compute 生成波浪平面
做了什么
用计算着色器每帧生成一个波浪平面的全部顶点 ,写进顶点缓冲,再走一条独立的渲染管线画出来。UI 开关 useComputeMesh:打开就用波浪平面替代模型。这是整段里踩坑最多的一章。
数学上很简单:平面铺在 XZ 上(40×40),每个顶点 y = sin(x*0.8 + time) * 1.5,法线用导数求:dy_dx = cos(...) * 1.2,法线 normalize(vec3(-dy_dx, 1, 0))。网格 128×128,每线程写一个顶点,dispatch 的组数 = (numU*numV + 15) / 16。
踩的坑(按出现顺序)
坑 1:波浪面悬浮乱转、形状奇怪。
根因:波浪平面错误继承了模型矩阵。原来的模型数据带着缩放、绕 X 翻转、绕 Y 旋转动画(m2*m),波浪复用这套矩阵后也跟着旋转缩放。解决:给波浪建了独立的渲染管线,push constant 只推 p*v 一个矩阵,坐标完全独立。
坑 2:shader 解析失败,但没有任何错误日志。
排查很久,最后发现 LVK 桌面构建的 LLOGW 宏是空的,shader 编译错误根本不打印。用 PowerShell 对文件做字节扫描,才发现 wave.comp 里有一段中文注释是 GBK 编码,glslang 解析失败。改成 ASCII 注释后通过。教训:shader 文件编码必须干净。
坑 3:不规则多边形 + 左侧尖刺。
这是 "compute 写顶点 → 渲染读顶点" 缺了内存可见性同步。LVK 的做法是在 cmdBeginRendering 的参数里列出依赖的 buffer:{ .buffers = { rectVertexBuf } },它内部会插一道 COMPUTE → VERTEX_INPUT 的 barrier。加了一行就正常了。
坑 4:结构体还是错位(查得最久的一个)。
想用标准结构体写法:
glsl
struct VertexData {
vec3 pos;
vec3 normal;
vec2 uv;
};
layout(buffer_reference) buffer VertexBuf {
VertexData vertices[];
};
vec3 对齐 16,成员偏移是 pos@0、normal@16、uv@32,内容大小 40 字节。我一开始让 CPU 侧也用 40 字节对齐,结果图形仍然不规则。后来才搞清楚:std430 里结构体数组的元素 stride 不是 "内容大小",而是要对齐到结构体对齐(16)的倍数 ------ 所以是 48 字节,不是 40。
text
内容大小 40 → 对齐 16 → 数组 stride = 48
CPU 按 40 读,GPU 按 48 写,每顶点错 8 字节,顶点一多数据全乱
为什么 float 数组和 vec4 数组不会出这个事?因为数组元素的 stride 就是它自己的大小(float 4B、vec4 16B),没有结构体整体对齐这一层。
正确的 CPU 侧写法是显式凑到 48 字节,并且用编译期断言锁死:
cpp
struct VertexData48 {
glm::vec3 pos; // 0
float pad0; // 12
glm::vec3 normal; // 16
float pad1; // 28
glm::vec2 uv; // 32
float pad2; // 40
float pad3; // 44
};
static_assert(sizeof(VertexData48) == 48, "std430 struct stride must be 48");
顶点输入描述里 stride 用 sizeof(VertexData48),偏移用 offsetof(VertexData48, ...) 而不是手写数字,结构体以后加字段也不会错位。
坑 5:边缘锯齿的误判。
修好之后还有一轮 "锯齿" 疑虑。后来分清两种锯齿:三角形网格边缘的阶梯状轮廓(正常现象,提高网格分辨率或上 MSAA 才能改善)和数据错位导致的形状撕裂(才是 bug)。当时那个 "不规则多边形" 其实是坑 4 的错位,不是锯齿。
学到的东西
buffer_reference:shader 持有一个 GPU 地址,像指针一样解引用,类型名(地址).成员访问。可以把地址缓存成局部变量再用。- compute 写完的 buffer 要渲染读,必须显式做同步(barrier),LVK 里就是
cmdBeginRendering的buffers依赖参数。 - std430 布局完整规则:vec3 对齐 16 之外,结构体数组 stride 还要对齐到 16 的倍数------ 这是最容易漏的一层。
- 工程习惯:C++ 侧
static_assert锁布局、offsetof取偏移,改结构体不再担心静默错位。
回头看的几点
- 布局错位是 GPU 开发最阴的 bug :编译能过、不崩溃,只是画面乱。
static_assert+offsetof是便宜又有效的防线。 - compute → 渲染的同步不能省:先写后读必须 barrier,否则时序抽风。
- shader 编码:文件必须是干净的 ASCII/UTF-8,中文注释用 GBK 保存会直接让解析失败,而且日志还不一定打出来。
- 数组没有结构体对齐,结构体有:float/vec4 数组怎么写都对,一旦用结构体共享数据,stride 对齐 16 这层不能忘。
四章走下来,从 "往 shader 传几个矩阵" 到 "GPU 自己生成顶点、自己写纹理",算是摸到 Vulkan 里数据流动的核心了。