317 lines
9.0 KiB
Markdown
317 lines
9.0 KiB
Markdown
# VulkanWidget 渲染问题最终解决方案
|
||
|
||
## 问题描述
|
||
|
||
VulkanWidget 中绘制的波浪线和彩色球存在严重的渲染问题:
|
||
- 图形超出 widget 范围
|
||
- 图形随时间移动,从各个角落慢慢进入
|
||
- 垂直方向严重拉伸变形(椭圆而非正圆)
|
||
- 圆心位置不固定
|
||
|
||
## 根本原因
|
||
|
||
经过深入调试,发现问题的**根本原因**是:
|
||
|
||
### Uniform Buffer 结构体内存布局不匹配
|
||
|
||
**C++ 端定义**(`src/vulkanrenderer.h`):
|
||
```cpp
|
||
struct UniformBufferObject {
|
||
float time; // offset 0
|
||
float resolution[2]; // offset 4, 8
|
||
float rotation; // offset 12
|
||
float wavePhase; // offset 16
|
||
float padding[2]; // offset 20, 24
|
||
};
|
||
```
|
||
|
||
**Shader 端定义**(`shaders/geometry.vert`,之前的错误版本):
|
||
```glsl
|
||
layout(binding = 0) uniform UniformBufferObject {
|
||
float time;
|
||
vec2 resolution; // GLSL std140 对齐可能不同!
|
||
float rotation;
|
||
float wavePhase;
|
||
} ubo;
|
||
```
|
||
|
||
**问题**:
|
||
- GLSL 的 `vec2` 在 std140 布局中的对齐方式与 C++ 的 `float[2]` 可能不同
|
||
- 导致 shader 读取的 `resolution` 值错误
|
||
- 错误的 resolution 导致坐标转换错误,产生变形和位移
|
||
|
||
## 完整解决方案
|
||
|
||
### 1. 修复 Shader UBO 布局
|
||
|
||
**文件**: `shaders/geometry.vert`
|
||
|
||
将 `vec2` 拆分为两个独立的 `float`,并明确指定 `std140` 布局:
|
||
|
||
```glsl
|
||
layout(binding = 0, std140) uniform UniformBufferObject {
|
||
float time; // offset 0
|
||
float resX; // offset 4
|
||
float resY; // offset 8
|
||
float rotation; // offset 12
|
||
float wavePhase; // offset 16
|
||
float padding1; // offset 20
|
||
float padding2; // offset 24
|
||
} ubo;
|
||
|
||
void main() {
|
||
// 使用 resX 和 resY 而不是 resolution.x 和 resolution.y
|
||
float ndcX = (inPosition.x / ubo.resX) * 2.0 - 1.0;
|
||
float ndcY = (inPosition.y / ubo.resY) * 2.0 - 1.0;
|
||
|
||
gl_Position = vec4(ndcX, ndcY, 0.0, 1.0);
|
||
fragColor = inColor;
|
||
fragTexCoord = inTexCoord;
|
||
}
|
||
```
|
||
|
||
**关键点**:
|
||
- 明确使用 `std140` 布局
|
||
- 避免使用 `vec2`,改用两个 `float`
|
||
- 确保与 C++ 结构体完全对齐
|
||
|
||
### 2. 更新所有 Uniform Buffers
|
||
|
||
**文件**: `src/vulkanrenderer.cpp` - `recordCommandBuffer()`
|
||
|
||
```cpp
|
||
// CRITICAL: 每帧更新所有 uniform buffers
|
||
// 因为有 MAX_FRAMES_IN_FLIGHT (通常是2) 个 buffers
|
||
// 必须保证所有 buffer 都有最新数据
|
||
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
|
||
updateUniformBuffer(i);
|
||
}
|
||
```
|
||
|
||
**原因**:
|
||
- Vulkan 使用多个 uniform buffers 来支持并行渲染
|
||
- 每个 descriptor set 指向不同的 buffer
|
||
- 如果只更新当前帧的 buffer,其他 buffer 会有过期数据
|
||
|
||
### 3. 启用动态视口和裁剪矩形
|
||
|
||
**文件**: `src/vulkanrenderer.cpp` - 管线创建函数
|
||
|
||
```cpp
|
||
// 在所有管线创建中添加动态状态
|
||
VkDynamicState dynamicStates[] = {
|
||
VK_DYNAMIC_STATE_VIEWPORT,
|
||
VK_DYNAMIC_STATE_SCISSOR
|
||
};
|
||
|
||
VkPipelineDynamicStateCreateInfo dynamicState = {};
|
||
dynamicState.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO;
|
||
dynamicState.dynamicStateCount = 2;
|
||
dynamicState.pDynamicStates = dynamicStates;
|
||
|
||
// 视口状态不设置静态值
|
||
viewportState.pViewports = nullptr;
|
||
viewportState.pScissors = nullptr;
|
||
|
||
// 添加到管线创建信息
|
||
pipelineInfo.pDynamicState = &dynamicState;
|
||
```
|
||
|
||
在 `recordCommandBuffer()` 中动态设置:
|
||
|
||
```cpp
|
||
VkViewport viewport = {};
|
||
viewport.x = 0.0f;
|
||
viewport.y = 0.0f;
|
||
viewport.width = static_cast<float>(m_width);
|
||
viewport.height = static_cast<float>(m_height);
|
||
viewport.minDepth = 0.0f;
|
||
viewport.maxDepth = 1.0f;
|
||
vkCmdSetViewport(commandBuffer, 0, 1, &viewport);
|
||
|
||
VkRect2D scissor = {};
|
||
scissor.offset = {0, 0};
|
||
scissor.extent = {m_width, m_height};
|
||
vkCmdSetScissor(commandBuffer, 0, 1, &scissor);
|
||
```
|
||
|
||
### 4. 初始化 Uniform Buffer
|
||
|
||
**文件**: `src/vulkanrenderer.cpp` - `initialize()`
|
||
|
||
```cpp
|
||
// 创建 uniform buffers 后立即初始化
|
||
m_ubo.time = 0.0f;
|
||
m_ubo.resolution[0] = static_cast<float>(m_width);
|
||
m_ubo.resolution[1] = static_cast<float>(m_height);
|
||
m_ubo.rotation = 0.0f;
|
||
m_ubo.wavePhase = 0.0f;
|
||
|
||
// 将初始值写入所有 uniform buffers
|
||
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
|
||
if (m_uniformBuffersMapped[i] != nullptr) {
|
||
memcpy(m_uniformBuffersMapped[i], &m_ubo, sizeof(m_ubo));
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5. 在 resize 时更新 UBO
|
||
|
||
**文件**: `src/vulkanrenderer.cpp` - `resize()`
|
||
|
||
```cpp
|
||
m_width = width;
|
||
m_height = height;
|
||
|
||
// 立即更新 UBO resolution
|
||
m_ubo.resolution[0] = static_cast<float>(m_width);
|
||
m_ubo.resolution[1] = static_cast<float>(m_height);
|
||
|
||
// 更新所有 uniform buffers
|
||
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
|
||
if (i < m_uniformBuffersMapped.size() && m_uniformBuffersMapped[i] != nullptr) {
|
||
memcpy(m_uniformBuffersMapped[i], &m_ubo, sizeof(m_ubo));
|
||
}
|
||
}
|
||
```
|
||
|
||
## 修改文件清单
|
||
|
||
### 主要修改
|
||
|
||
1. **shaders/geometry.vert**
|
||
- 使用 `std140` 布局
|
||
- 将 `vec2 resolution` 改为 `float resX, resY`
|
||
- 添加显式的 padding 字段
|
||
|
||
2. **src/vulkanrenderer.cpp**
|
||
- `initialize()`: 初始化所有 uniform buffers
|
||
- `resize()`: 更新 UBO 并写入所有 buffers
|
||
- `recordCommandBuffer()`: 每帧更新所有 buffers
|
||
- `createBackgroundPipeline()`: 添加动态状态
|
||
- `createGeometryPipeline()`: 添加动态状态
|
||
- `createLinePipeline()`: 添加动态状态
|
||
|
||
3. **src/vulkanrenderer.h**
|
||
- `drawGeometry()`: 添加 frameCount 参数
|
||
|
||
## 技术要点
|
||
|
||
### 1. GLSL std140 布局规则
|
||
|
||
- `float`: 4 字节对齐
|
||
- `vec2`: 8 字节对齐(可能与 C++ `float[2]` 不同)
|
||
- `vec3`, `vec4`: 16 字节对齐
|
||
- 数组每个元素都是 16 字节对齐
|
||
|
||
**最佳实践**:避免在 UBO 中使用 `vec2`,使用独立的 `float`。
|
||
|
||
### 2. Vulkan 多缓冲
|
||
|
||
- `MAX_FRAMES_IN_FLIGHT` 通常为 2 或 3
|
||
- 每帧可能使用不同的 uniform buffer
|
||
- **必须保证所有 buffers 数据一致**
|
||
|
||
### 3. 动态状态的优势
|
||
|
||
- 窗口大小变化时无需重建管线
|
||
- 性能开销极小
|
||
- 代码更灵活
|
||
|
||
## 验证方法
|
||
|
||
### 测试用例
|
||
|
||
创建了测试模式来验证坐标系统:
|
||
|
||
```cpp
|
||
// 在 4 个角落和中心放置测试圆圈
|
||
Position 0: screen(75.6, 42.5) -> NDC(-0.8, -0.8) // 左上
|
||
Position 1: screen(680.4, 42.5) -> NDC(0.8, -0.8) // 右上
|
||
Position 2: screen(75.6, 382.5) -> NDC(-0.8, 0.8) // 左下
|
||
Position 3: screen(680.4, 382.5) -> NDC(0.8, 0.8) // 右下
|
||
Position 4: screen(378, 212.5) -> NDC(0, 0) // 中心
|
||
```
|
||
|
||
### 预期结果
|
||
|
||
修复后应该看到:
|
||
- ✅ 8个彩色球在窗口正中心旋转
|
||
- ✅ 旋转轨道为正圆(半径80)
|
||
- ✅ 球的半径一致(15像素)
|
||
- ✅ 两条波浪线在窗口70%高度处
|
||
- ✅ 所有元素完全在窗口内
|
||
- ✅ 调整窗口大小时元素保持正确位置
|
||
- ✅ 无变形、无移动、无裁剪
|
||
|
||
## 调试经验
|
||
|
||
### 诊断步骤
|
||
|
||
1. **验证 CPU 端数据**:
|
||
- 打印 m_width, m_height
|
||
- 打印 UBO 结构体内容
|
||
- 验证 viewport 和 scissor 设置
|
||
|
||
2. **验证 GPU 端数据**:
|
||
- 使用硬编码值替代 UBO
|
||
- 如果硬编码值工作 → UBO 传输有问题
|
||
- 检查结构体对齐
|
||
|
||
3. **隔离问题**:
|
||
- 简化场景(如测试模式的5个圆圈)
|
||
- 逐步添加功能,确定引入问题的代码
|
||
|
||
### 关键发现
|
||
|
||
问题表现:
|
||
- **垂直拉伸** → resolution.y 值错误
|
||
- **随时间移动** → 不同帧使用不同的 resolution
|
||
- **从角落进入** → 中心点计算使用错误的 resolution
|
||
|
||
根本原因:
|
||
- Shader 读取的 `ubo.resolution` 与 CPU 写入的值不匹配
|
||
- 由于 `vec2` 的内存对齐问题
|
||
|
||
## 相关资源
|
||
|
||
- [Vulkan Specification - Uniform Buffer Layout](https://www.khronos.org/registry/vulkan/specs/1.3/html/vkspec.html#interfaces-resources-layout)
|
||
- [GLSL std140 Layout Rules](https://www.khronos.org/opengl/wiki/Interface_Block_(GLSL)#Memory_layout)
|
||
- [Vulkan Dynamic State](https://www.khronos.org/registry/vulkan/specs/1.3/html/vkspec.html#pipelines-dynamic-state)
|
||
|
||
## 维护建议
|
||
|
||
1. **UBO 结构体设计**:
|
||
- 避免使用 `vec2`, `vec3`
|
||
- 优先使用独立的 `float`
|
||
- 明确添加 padding 到 16 字节边界
|
||
- 在 C++ 和 GLSL 中保持相同的字段顺序和对齐
|
||
|
||
2. **调试工具**:
|
||
- 保留测试模式代码(可通过宏开关)
|
||
- 添加 UBO 内容验证函数
|
||
- 使用 RenderDoc 等工具检查 GPU 状态
|
||
|
||
3. **代码审查要点**:
|
||
- 检查所有 uniform buffer 是否都被更新
|
||
- 验证 descriptor set 绑定的 buffer index
|
||
- 确认 viewport 和 scissor 与渲染表面匹配
|
||
|
||
## 总结
|
||
|
||
这个问题的修复展示了 Vulkan 开发中的几个重要教训:
|
||
|
||
1. **内存对齐至关重要**:CPU 和 GPU 之间的数据传输必须精确匹配
|
||
2. **多缓冲需要同步**:所有 in-flight 的资源都必须保持一致
|
||
3. **动态状态很有用**:避免频繁重建管线
|
||
4. **系统化调试**:从简单场景开始,逐步定位问题
|
||
|
||
最终解决方案简洁且高效,确保了渲染的正确性和性能。
|
||
|
||
---
|
||
|
||
**修复完成日期**: 2025
|
||
**问题持续时间**: 多次迭代
|
||
**关键突破**: 使用硬编码值测试发现 UBO 传输问题
|
||
**最终原因**: GLSL vec2 对齐问题
|