从曲面细分到计算着色器:我的 Vulkan 实验记录

一、曲面细分(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 取偏移,改结构体不再担心静默错位。

回头看的几点

  1. 布局错位是 GPU 开发最阴的 bug :编译能过、不崩溃,只是画面乱。static_assert + offsetof 是便宜又有效的防线。
  2. compute → 渲染的同步不能省:先写后读必须 barrier,否则时序抽风。
  3. shader 编码:文件必须是干净的 ASCII/UTF-8,中文注释用 GBK 保存会直接让解析失败,而且日志还不一定打出来。
  4. 数组没有结构体对齐,结构体有:float/vec4 数组怎么写都对,一旦用结构体共享数据,stride 对齐 16 这层不能忘。

四章走下来,从 "往 shader 传几个矩阵" 到 "GPU 自己生成顶点、自己写纹理",算是摸到 Vulkan 里数据流动的核心了。

相关推荐
刻BITTER2 天前
LocalLow 乱码文件排查 Intel INSC着色器缓存文件
缓存·哈希算法·着色器
茉莉玫瑰花茶14 天前
OpenGL [ GLSL 着色器基础 ]
着色器
李小白杂货铺16 天前
“正在编译着色器“ 耗时原理解析
着色器·技术原理·技术解析·原理解析·编译着色器·正在编译着色器·耗时原理
爱上网的小舟1 个月前
着色器缓存大小设置多少合适?N卡A卡缓存路径与清理方法
缓存·着色器
灵境(虚幻知音)1 个月前
WebGL 不支持>1px的线绘制,那 Cesium 的带宽度的线是怎么画出来的?
webgl·cesium·着色器
破竹152 个月前
OpenGL ES调试汇总
图形渲染·着色器
小小数媒成员2 个月前
合并阶段和计算着色器【图文解释】
着色器
小小数媒成员2 个月前
顶点,几何,片元着色器+曲面细分阶段【图文解析】
着色器
SNAKEpc121382 个月前
OpenGL(十六)- 着色器统一值
c语言·c++·线性代数·算法·矩阵·图形渲染·着色器