上一篇:资源系统 | 下一篇:在UI渲染通道中绘制 | 返回目录
📚 快速导航
目录
- 简介
- 学习目标
- 多渲染通道架构
- 为什么需要多个Renderpass
- World与UI分离
- 渲染流程
- Renderpass枚举
- UI着色器实现
- UI顶点着色器
- UI片段着色器
- 2D vs 3D坐标系
- Framebuffer策略
- Backend接口扩展
- Vulkan实现
- Renderpass切换
- Shader绑定
- 全局状态更新
- Render Packet扩展
- 正交投影矩阵
- 渲染顺序与Alpha混合
- 常见问题
- 练习
📖 简介
在之前的教程中,我们只有一个渲染通道 (Renderpass),用于渲染所有几何体。但游戏引擎通常需要渲染多种类型的内容:3D 世界、2D UI、后处理效果、阴影贴图等。每种内容都有不同的渲染需求。
本教程将介绍多渲染通道架构(Multiple Renderpasses),将 3D 世界渲染和 2D UI 渲染分离到不同的 renderpass 中。通过这种分离,我们可以:
- 使用不同的着色器 (World 用透视投影,UI 用正交投影)
- 使用不同的 framebuffer (离屏渲染 vs 直接显示)
- 控制渲染顺序 (World 先渲染,UI 后渲染)
- 为未来的后处理效果做准备
🎯 学习目标
| 目标 | 描述 |
|---|---|
| 理解多Renderpass架构 | 了解为什么需要多个渲染通道 |
| 实现UI着色器 | 创建专门用于2D UI渲染的着色器 |
| 掌握正交投影 | 理解正交投影与透视投影的区别 |
| Framebuffer分层 | 理解离屏渲染和最终显示的framebuffer |
| Renderpass切换 | 实现多个renderpass之间的切换 |
多渲染通道架构
为什么需要多个Renderpass
在单一 renderpass 中混合渲染不同类型的内容会导致问题:
❌ 单一 Renderpass 的问题: ┌────────────────────────────┐ │ Single Renderpass │ │ │ │ - 3D Models (透视投影) │ │ - UI Elements (正交投影?) │ │ - Text (2D) │ │ - Particles (需要混合) │ │ │ │ 问题: │ │ 1. 无法使用不同的投影矩阵 │ │ 2. 深度测试冲突 │ │ 3. 渲染顺序难以控制 │ │ 4. 着色器逻辑复杂 │ └────────────────────────────┘使用多个 renderpass 可以解决这些问题:
✅ 多 Renderpass 架构: ┌─────────────────────────────┐ │ World Renderpass │ │ - Material Shader │ │ - 透视投影 (Perspective) │ │ - 深度测试启用 │ │ - 渲染 3D 模型 │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ UI Renderpass │ │ - UI Shader │ │ - 正交投影 (Orthographic) │ │ - 深度测试禁用 │ │ - 渲染 2D UI │ └─────────────────────────────┘ 优势: 1. 每个 renderpass 使用专门的着色器 2. 独立的投影矩阵和视图矩阵 3. 明确的渲染顺序 4. 更好的性能和可维护性World与UI分离
我们将渲染分为两个通道:
World Renderpass (世界渲染通道)
- 用途:渲染 3D 场景
- 着色器:Material Shader
- 投影:透视投影 (Perspective Projection)
- 深度测试:启用
- Framebuffer:离屏 framebuffer (world_framebuffers)
- 示例内容:3D 模型、地形、天空盒
UI Renderpass (UI 渲染通道)
- 用途:渲染 2D 用户界面
- 着色器:UI Shader
- 投影:正交投影 (Orthographic Projection)
- 深度测试:通常禁用
- Framebuffer:Swapchain framebuffer (直接显示)
- 示例内容:按钮、文本、图标、HUD
渲染流程
完整的渲染流程:
// 1. 开始帧backend.begin_frame(delta_time);// ==================== World Renderpass ====================// 2. 开始世界渲染通道backend.begin_renderpass(BUILTIN_RENDERPASS_WORLD);// 3. 更新世界全局状态 (透视投影)mat4 projection=mat4_perspective(deg_to_rad(45.0f),aspect_ratio,0.1f,1000.0f);mat4 view=camera_get_view_matrix();backend.update_global_world_state(projection,view,camera_position,ambient_color,0);// 4. 绘制世界几何体for(u32 i=0;i<world_geometry_count;++i){backend.draw_geometry(world_geometries[i]);}// 5. 结束世界渲染通道backend.end_renderpass(BUILTIN_RENDERPASS_WORLD);// ==================== UI Renderpass ====================// 6. 开始 UI 渲染通道backend.begin_renderpass(BUILTIN_RENDERPASS_UI);// 7. 更新 UI 全局状态 (正交投影)mat4 ui_projection=mat4_orthographic(0,screen_width,screen_height,0,-100.0f,100.0f);mat4 ui_view=mat4_identity();backend.update_global_ui_state(ui_projection,ui_view,0);// 8. 绘制 UI 几何体for(u32 i=0;i<ui_geometry_count;++i){backend.draw_geometry(ui_geometries[i]);}// 9. 结束 UI 渲染通道backend.end_renderpass(BUILTIN_RENDERPASS_UI);// 10. 结束帧backend.end_frame(delta_time);📋 Renderpass枚举
定义内置的 renderpass 类型:
// engine/src/renderer/renderer_types.inl/** * @brief 内置渲染通道枚举 */typedefenumbuiltin_renderpass{BUILTIN_RENDERPASS_WORLD=0x01,// 世界渲染通道BUILTIN_RENDERPASS_UI=0x02// UI 渲染通道}builtin_renderpass;使用位标志的好处:
// 可以用位运算组合 renderpassu8 renderpass_mask=BUILTIN_RENDERPASS_WORLD|BUILTIN_RENDERPASS_UI;// 检查是否包含某个 renderpassif(renderpass_mask&BUILTIN_RENDERPASS_WORLD){// 包含世界渲染通道}UI着色器实现
UI顶点着色器
UI 着色器与 Material 着色器的主要区别:
// assets/shaders/Builtin.UIShader.vert.glsl #version 450 // ========== 输入 (2D 顶点) ========== layout(location = 0) in vec2 in_position; // 2D 位置 (x, y) layout(location = 1) in vec2 in_texcoord; // 纹理坐标 // ========== 全局 UBO (正交投影) ========== layout(set = 0, binding = 0) uniform global_uniform_object { mat4 projection; // 正交投影矩阵 mat4 view; // 视图矩阵 (通常是单位矩阵) } global_ubo; // ========== Push Constants (模型矩阵) ========== layout(push_constant) uniform push_constants { mat4 model; // 64 bytes - UI 元素的变换矩阵 } u_push_constants; // ========== 输出 ========== layout(location = 1) out struct dto { vec2 tex_coord; } out_dto; void main() { // 注意:翻转 Y 纹理坐标 // 这样配合翻转的正交矩阵,使 [0,0] 在左上角而不是左下角 out_dto.tex_coord = vec2(in_texcoord.x, 1.0 - in_texcoord.y); // 计算位置:projection * view * model * position // 注意:position 是 vec2,扩展为 vec4(x, y, 0.0, 1.0) gl_Position = global_ubo.projection * global_ubo.view * u_push_constants.model * vec4(in_position, 0.0, 1.0); }关键区别:
| 特性 | Material Shader | UI Shader |
|---|---|---|
| 输入位置 | vec3 in_position(3D) | vec2 in_position(2D) |
| 投影类型 | 透视投影 | 正交投影 |
| Z 坐标 | 使用真实深度 | 固定为 0.0 |
| 纹理坐标翻转 | 不翻转 | 翻转 Y (1.0 - y) |
| 用途 | 3D 模型渲染 | 2D UI 元素渲染 |
UI片段着色器
UI 片段着色器非常简洁:
// assets/shaders/Builtin.UIShader.frag.glsl #version 450 // ========== 输出 ========== layout(location = 0) out vec4 out_colour; // ========== 材质 UBO ========== layout(set = 1, binding = 0) uniform local_uniform_object { vec4 diffuse_colour; // 颜色 (用于着色) } object_ubo; // ========== 纹理采样器 ========== layout(set = 1, binding = 1) uniform sampler2D diffuse_sampler; // ========== 输入 ========== layout(location = 1) in struct dto { vec2 tex_coord; } in_dto; void main() { // 简单的纹理采样 * 颜色调制 out_colour = object_ubo.diffuse_colour * texture(diffuse_sampler, in_dto.tex_coord); }为什么这么简单?
UI 渲染不需要复杂的光照计算:
- 没有法线
- 没有光照
- 没有阴影
- 只需要纹理 + 颜色调制
2D vs 3D坐标系
两种坐标系的对比:
3D 世界坐标系 (透视投影): Y (向上) │ │ │ └─────── X (向右) ╱ ╱ Z (向前) - 透视投影:远处物体变小 - 深度测试:正确的遮挡关系 - 视锥裁剪:near_clip ~ far_clip 2D UI 坐标系 (正交投影): (0,0) ────────► X (向右) │ │ │ ▼ Y (向下) - 正交投影:物体大小不变 - 屏幕空间坐标:[0, screen_width] x [0, screen_height] - 深度范围:通常 -100 ~ +100 (用于分层)为什么 UI 坐标系 Y 向下?
这是为了匹配屏幕坐标习惯:
传统屏幕坐标: ┌───────────────┐ (0, 0) │ │ │ UI 元素 │ │ │ └───────────────┘ (width, height) OpenGL/Vulkan 默认坐标: ┌───────────────┐ (0, height) │ │ │ │ │ │ └───────────────┘ (0, 0) 解决方案:翻转正交矩阵的 Y 轴Framebuffer策略
我们使用两套 framebuffer:
// engine/src/renderer/vulkan/vulkan_types.inltypedefstructvulkan_context{// ... 其他成员 ...vulkan_renderpass main_renderpass;// 世界渲染通道vulkan_renderpass ui_renderpass;// UI 渲染通道// World framebuffers - 离屏渲染VkFramebuffer world_framebuffers[3];// 每帧一个vulkan_swapchain swapchain;// swapchain.framebuffers[3] - 最终显示到屏幕// ...}vulkan_context;Framebuffer 使用策略:
帧渲染流程: ┌──────────────────────────────────┐ │ World Renderpass │ │ │ │ world_framebuffers[image_index] │ │ ├─ Color Attachment (离屏纹理) │ │ └─ Depth Attachment │ └───────────────┬──────────────────┘ │ │ (world 渲染结果作为纹理) │ ▼ ┌──────────────────────────────────┐ │ UI Renderpass │ │ │ │ swapchain.framebuffers[image_idx]│ │ ├─ Color Attachment (swapchain) │ │ │ └─ 包含 world 渲染结果 │ │ └─ Depth Attachment │ └───────────────┬──────────────────┘ │ ▼ Present to Screen 呈现到屏幕为什么 World 使用离屏 Framebuffer?
- 后处理准备: 离屏渲染的结果可以作为纹理输入到后处理着色器
- 合成灵活性: UI 可以在 world 渲染结果之上合成
- 分辨率独立: World 渲染可以使用不同的分辨率 (如 upscaling/downscaling)
- 未来扩展: 为阴影贴图、反射等高级效果做准备
Backend接口扩展
新增的 backend 接口:
// engine/src/renderer/renderer_types.inltypedefstructrenderer_backend{u64 frame_number;b8(*initialize)(structrenderer_backend*backend,constchar*application_name);void(*shutdown)(structrenderer_backend*backend);void(*resized)(structrenderer_backend*backend,u16 width,u16 height);b8(*begin_frame)(structrenderer_backend*backend,f32 delta_time);b8(*end_frame)(structrenderer_backend*backend,f32 delta_time);// ========== 新增:Renderpass 控制 ==========b8(*begin_renderpass)(structrenderer_backend*backend,u8 renderpass_id);b8(*end_renderpass)(structrenderer_backend*backend,u8 renderpass_id);// ========== 新增:分离的全局状态更新 ==========void(*update_global_world_state)(mat4 projection,mat4 view,vec3 view_position,vec4 ambient_colour,i32 mode);void(*update_global_ui_state)(mat4 projection,mat4 view,i32 mode);void(*draw_geometry)(geometry_render_data data);void(*create_texture)(constu8*pixels,structtexture*texture);void(*destroy_texture)(structtexture*texture);b8(*create_material)(structmaterial*material);void(*destroy_material)(structmaterial*material);b8(*create_geometry)(geometry*geometry,u32 vertex_count,constvertex_3d*vertices,u32 index_count,constu32*indices);void(*destroy_geometry)(geometry*geometry);}renderer_backend;接口变化:
| 旧接口 | 新接口 | 变化 |
|---|---|---|
update_global_state() | update_global_world_state()+update_global_ui_state() | 分离为两个函数 |
| 无 | begin_renderpass() | 新增 renderpass 控制 |
| 无 | end_renderpass() | 新增 renderpass 控制 |
Vulkan实现
Renderpass切换
实现 renderpass 的开始和结束:
// engine/src/renderer/vulkan/vulkan_backend.cb8vulkan_renderer_begin_renderpass(structrenderer_backend*backend,u8 renderpass_id){vulkan_renderpass*renderpass=0;VkFramebuffer framebuffer=0;vulkan_command_buffer*command_buffer=&context.graphics_command_buffers[context.image_index];// 1. 根据 ID 选择 renderpass 和 framebufferswitch(renderpass_id){caseBUILTIN_RENDERPASS_WORLD:renderpass=&context.main_renderpass;framebuffer=context.world_framebuffers[context.image_index];break;caseBUILTIN_RENDERPASS_UI:renderpass=&context.ui_renderpass;framebuffer=context.swapchain.framebuffers[context.image_index];break;default:KERROR("vulkan_renderer_begin_renderpass called on unrecognized renderpass id: %#02x",renderpass_id);returnfalse;}// 2. 开始 renderpass (记录 vkCmdBeginRenderPass)vulkan_renderpass_begin(command_buffer,renderpass,framebuffer);// 3. 绑定对应的着色器switch(renderpass_id){caseBUILTIN_RENDERPASS_WORLD:vulkan_material_shader_use(&context,&context.material_shader);break;caseBUILTIN_RENDERPASS_UI:vulkan_ui_shader_use(&context,&context.ui_shader);break;}returntrue;}b8vulkan_renderer_end_renderpass(structrenderer_backend*backend,u8 renderpass_id){vulkan_renderpass*renderpass=0;vulkan_command_buffer*command_buffer=&context.graphics_command_buffers[context.image_index];// 1. 根据 ID 选择 renderpassswitch(renderpass_id){caseBUILTIN_RENDERPASS_WORLD:renderpass=&context.main_renderpass;break;caseBUILTIN_RENDERPASS_UI:renderpass=&context.ui_renderpass;break;default:KERROR("vulkan_renderer_end_renderpass called on unrecognized renderpass id: %#02x",renderpass_id);returnfalse;}// 2. 结束 renderpass (记录 vkCmdEndRenderPass)vulkan_renderpass_end(command_buffer,renderpass);returntrue;}Shader绑定
每个 renderpass 使用不同的着色器:
// vulkan_material_shader_use() - 绑定 Material Shadervoidvulkan_material_shader_use(vulkan_context*context,vulkan_material_shader*shader){u32 image_index=context->image_index;// 绑定 pipeline (包含 vertex shader + fragment shader)vkCmdBindPipeline(context->graphics_command_buffers[image_index].handle,VK_PIPELINE_BIND_POINT_GRAPHICS,shader->pipeline.handle);}// vulkan_ui_shader_use() - 绑定 UI Shadervoidvulkan_ui_shader_use(vulkan_context*context,vulkan_ui_shader*shader){u32 image_index=context->image_index;// 绑定 UI pipelinevkCmdBindPipeline(context->graphics_command_buffers[image_index].handle,VK_PIPELINE_BIND_POINT_GRAPHICS,shader->pipeline.handle);}全局状态更新
分别更新 World 和 UI 的全局状态:
// engine/src/renderer/vulkan/vulkan_backend.cvoidvulkan_renderer_update_global_world_state(mat4 projection,mat4 view,vec3 view_position,vec4 ambient_colour,i32 mode){vulkan_command_buffer*command_buffer=&context.graphics_command_buffers[context.image_index];// 更新 Material Shader 的全局 UBOvulkan_material_shader_update_global_state(&context,&context.material_shader);// 绑定全局 descriptor setvulkan_material_shader_bind_globals(&context,&context.material_shader);// 设置投影和视图矩阵context.material_shader.global_ubo.projection=projection;context.material_shader.global_ubo.view=view;// TODO: 其他全局状态 (ambient_colour, view_position, etc.)}voidvulkan_renderer_update_global_ui_state(mat4 projection,mat4 view,i32 mode){vulkan_command_buffer*command_buffer=&context.graphics_command_buffers[context.image_index];// 更新 UI Shader 的全局 UBOvulkan_ui_shader_update_global_state(&context,&context.ui_shader,context.frame_delta_time);// 绑定全局 descriptor setvulkan_ui_shader_bind_globals(&context,&context.ui_shader);// 设置投影和视图矩阵context.ui_shader.global_ubo.projection=projection;context.ui_shader.global_ubo.view=view;}Render Packet扩展
Render Packet 现在包含两个几何体列表:
// engine/src/renderer/renderer_types.inltypedefstructgeometry_render_data{mat4 model;// 模型矩阵geometry*geometry;// 几何体指针}geometry_render_data;typedefstructrender_packet{f32 delta_time;// 世界几何体u32 geometry_count;geometry_render_data*geometries;// UI 几何体u32 ui_geometry_count;geometry_render_data*ui_geometries;}render_packet;使用示例:
// 应用层准备 render packetrender_packet packet;packet.delta_time=delta_time;// 世界几何体 (3D 模型)geometry_render_data world_objects[10];world_objects[0].model=mat4_translation((vec3){0,0,-5});world_objects[0].geometry=&cube_geometry;// ...packet.geometry_count=10;packet.geometries=world_objects;// UI 几何体 (2D UI)geometry_render_data ui_elements[5];ui_elements[0].model=mat4_translation((vec3){100,100,0});// 屏幕坐标ui_elements[0].geometry=&button_geometry;// ...packet.ui_geometry_count=5;packet.ui_geometries=ui_elements;// 提交渲染renderer_draw_frame(&packet);正交投影矩阵
正交投影的创建和特性:
// engine/src/math/kmath.h/** * @brief 创建正交投影矩阵 * @param left 左边界 * @param right 右边界 * @param bottom 下边界 * @param top 上边界 * @param near_clip 近裁剪面 * @param far_clip 远裁剪面 * @return 正交投影矩阵 */KINLINE mat4mat4_orthographic(f32 left,f32 right,f32 bottom,f32 top,f32 near_clip,f32 far_clip){mat4 out_matrix=mat4_identity();f32 lr=1.0f/(left-right);f32 bt=1.0f/(bottom-top);f32 nf=1.0f/(near_clip-far_clip);out_matrix.data[0]=-2.0f*lr;out_matrix.data[5]=-2.0f*bt;out_matrix.data[10]=2.0f*nf;out_matrix.data[12]=(left+right)*lr;out_matrix.data[13]=(top+bottom)*bt;out_matrix.data[14]=(far_clip+near_clip)*nf;returnout_matrix;}UI 正交投影设置:
// engine/src/renderer/renderer_frontend.c// 创建 UI 正交投影// 左上角为 (0, 0),右下角为 (width, height)state_ptr->ui_projection=mat4_orthographic(0,// left1280.0f,// right (屏幕宽度)720.0f,// bottom (屏幕高度)0,// top-100.0f,// near_clip (允许 Z 值从 -100 到 +100)100.0f// far_clip);// UI 视图矩阵通常是单位矩阵 (无相机变换)state_ptr->ui_view=mat4_identity();坐标映射:
正交投影映射: ┌─────────────────────────┐ │ 屏幕空间 (像素) │ │ (0, 0) ~ (1280, 720) │ └────────────┬────────────┘ │ mat4_orthographic() ▼ ┌─────────────────────────┐ │ NDC (归一化设备坐标) │ │ (-1, 1) ~ (1, -1) │ └─────────────────────────┘ 透视投影 vs 正交投影: ┌─────────────────────┐ ┌─────────────────────┐ │ 透视投影 │ │ 正交投影 │ │ │ │ │ │ ╱│╲ │ │ ││││││ │ │ ╱ │ ╲ │ │ ││││││ │ │ ╱ │ ╲ │ │ ││││││ │ │ ╱ │ ╲ │ │ ││││││ │ │ ╱────┼────╲ │ │ ││││││ │ │ ^ │ │ ^ │ │ 视锥体 │ │ 正交体 │ │ (远处变小) │ │ (大小不变) │ └─────────────────────┘ └─────────────────────┘渲染顺序与Alpha混合
渲染顺序很重要,特别是对于透明物体:
// 正确的渲染顺序voidrenderer_draw_frame(render_packet*packet){backend.begin_frame(delta_time);// ========== 1. World Renderpass ==========// 先渲染 3D 世界backend.begin_renderpass(BUILTIN_RENDERPASS_WORLD);backend.update_global_world_state(projection,view,...);// 1a. 渲染不透明物体 (从前到后或任意顺序)for(u32 i=0;i<opaque_count;++i){backend.draw_geometry(opaque_geometries[i]);}// 1b. 渲染透明物体 (从后到前,启用 alpha 混合)for(u32 i=0;i<transparent_count;++i){backend.draw_geometry(transparent_geometries[i]);}backend.end_renderpass(BUILTIN_RENDERPASS_WORLD);// ========== 2. UI Renderpass ==========// 后渲染 2D UI (在 world 之上)backend.begin_renderpass(BUILTIN_RENDERPASS_UI);backend.update_global_ui_state(ui_projection,ui_view,0);// UI 通常从后到前绘制 (Painter's Algorithm)for(u32 i=0;i<ui_geometry_count;++i){backend.draw_geometry(ui_geometries[i]);}backend.end_renderpass(BUILTIN_RENDERPASS_UI);backend.end_frame(delta_time);}Alpha 混合配置:
// Vulkan Pipeline 创建时配置 alpha 混合VkPipelineColorBlendAttachmentState color_blend_attachment_state;color_blend_attachment_state.blendEnable=VK_TRUE;// 启用混合// 标准 alpha 混合:// out_color = src_alpha * src_color + (1 - src_alpha) * dst_colorcolor_blend_attachment_state.srcColorBlendFactor=VK_BLEND_FACTOR_SRC_ALPHA;color_blend_attachment_state.dstColorBlendFactor=VK_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA;color_blend_attachment_state.colorBlendOp=VK_BLEND_OP_ADD;color_blend_attachment_state.srcAlphaBlendFactor=VK_BLEND_FACTOR_ONE;color_blend_attachment_state.dstAlphaBlendFactor=VK_BLEND_FACTOR_ZERO;color_blend_attachment_state.alphaBlendOp=VK_BLEND_OP_ADD;❓ 常见问题
1. 为什么 World Renderpass 使用离屏 Framebuffer?原因:
后处理效果: 离屏渲染的结果可以作为纹理输入到后处理着色器 (如 bloom、blur、tone mapping)
World → Framebuffer Texture → Post-Process Shader → UI → Screen合成灵活性: UI 可以在 world 渲染结果之上合成,而不影响 world 渲染
World Renderpass: 渲染到 world_framebuffers ↓ UI Renderpass: 在 swapchain framebuffer 上合成 world + UI分辨率缩放: World 可以使用不同分辨率渲染 (如 4K 渲染到 1080p 显示)
World: 3840x2160 → Downscale → UI: 1920x1080 → Screen未来扩展: 为多个渲染通道做准备 (阴影、反射、GBuffer 等)
当前实现:
虽然当前 tutorial 还没有实现后处理,但离屏 framebuffer 为未来功能预留了空间。
2. 正交投影和透视投影有什么本质区别?数学区别:
透视投影 (Perspective):
- 模拟人眼视觉
- 远处物体变小 (透视收缩)
- 投影矩阵包含
1/z项 - 用于 3D 场景
透视投影: 视点 ╲ ╲ 近处大 ╲ ╲ 远处小 ╲ ╲ ╲ 视锥体 投影矩阵 (简化): ┌ ┐ │ 1/tan(fov/2) 0 0 0 │ │ 0 aspect*.. 0 0 │ │ 0 0 f/(f-n) -1 │ │ 0 0 -n*f/(f-n) 0 │ └ ┘ 关键:第 3 行有 -1,产生 1/z 效果正交投影 (Orthographic):
- 平行投影
- 物体大小不变
- 无透视收缩
- 用于 2D UI、工程图纸
正交投影: │││││││ │││││││ 所有物体 │││││││ 大小相同 │││││││ │││││││ 平行投影线 投影矩阵: ┌ ┐ │ 2/(r-l) 0 0 0 │ │ 0 2/(t-b) 0 0 │ │ 0 0 2/(f-n) 0 │ │ -(r+l)/(r-l) ... ... 1 │ └ ┘ 关键:无 1/z 项,只有线性缩放视觉对比:
透视投影 (3D 游戏): ┌─────────────────┐ │ ╱────╲ │ 远处的立方体 │ │ │ │ 看起来更小 │ ╲────╱ │ │ │ │ ╱──────╲ │ 近处的立方体 │ │ │ │ 看起来更大 │ ╲──────╱ │ └─────────────────┘ 正交投影 (UI): ┌─────────────────┐ │ ╱────╲ │ 所有按钮 │ │ BTN1 │ │ 大小相同 │ ╲────╱ │ │ │ │ ╱────╲ │ 不管深度 │ │ BTN2 │ │ 如何 │ ╲────╱ │ └─────────────────┘3. 为什么 UI 纹理坐标要翻转 Y 轴?问题根源:
OpenGL/Vulkan 和屏幕坐标系统有不同的原点:
Vulkan 纹理坐标 (默认): (0,0) ────► u │ │ ▼ v (1,1) 屏幕坐标: (0,0) ────► x │ │ ▼ y (width, height) 期望的 UI 坐标: (0,0) ────► x │ │ ▼ y (width, height)解决方案:
有两种方式可以翻转:
方式 1: 翻转纹理坐标 (Kohi 使用)
// UI 顶点着色器 out_dto.tex_coord = vec2(in_texcoord.x, 1.0 - in_texcoord.y);方式 2: 翻转正交投影矩阵
// 交换 top 和 bottommat4 ui_projection=mat4_orthographic(0,// leftwidth,// rightheight,// bottom ← 本应是 top0,// top ← 本应是 bottom-100.0f,100.0f);Kohi 使用两种方式结合:
- 翻转正交矩阵 (bottom/top 交换)
- 翻转纹理坐标 (1.0 - y)
为什么要这样做?
最终效果:纹理图像正确显示,UI 元素的 (0, 0) 在左上角。
如果不翻转: ┌─────────────┐ │ ▼ │ ← 纹理上下颠倒 │ BUTTON │ │ │ └─────────────┘ 翻转后: ┌─────────────┐ │ BUTTON │ ← 纹理正确 │ ▲ │ │ │ └─────────────┘4. 如何在 UI Renderpass 中显示 World Renderpass 的渲染结果?当前实现 (Tutorial 34):
当前还没有实现 world 渲染结果的采样,两个 renderpass 是独立的。
未来实现 (后续教程):
需要将 world_framebuffers 的 color attachment 作为纹理传递给 UI renderpass:
// 1. 创建 world framebuffer 时,使其 color attachment 可采样VkImageCreateInfo image_create_info;image_create_info.usage=VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT|VK_IMAGE_USAGE_SAMPLED_BIT;// ← 允许作为纹理采样// 2. 在 UI renderpass 开始前,转换 image layoutvkCmdPipelineBarrier(command_buffer,VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,0,0,nullptr,0,nullptr,1,&image_memory_barrier);// 3. 在 UI 着色器中采样 world 渲染结果layout(set=0,binding=2)uniform sampler2D world_texture;voidmain(){vec4 world_color=texture(world_texture,screen_uv);vec4 ui_color=texture(ui_sampler,tex_coord);// 合成 world + UIout_color=mix(world_color,ui_color,ui_color.a);}使用场景:
- 后处理效果 (bloom、模糊、色调映射)
- 小地图 (将 world 渲染结果显示在 UI 角落)
- 相机监控画面
性能影响:
优点:
- Tile-based GPU 优化: 移动 GPU 可以为每个 renderpass 优化 tile memory
- Clear 操作优化: 每个 renderpass 可以高效清除 framebuffer
- Bandwidth 优化: 避免不必要的 framebuffer 读写
缺点:
- Renderpass 切换开销: 每次切换有一定的 GPU 开销 (但通常很小)
- 内存占用: 离屏 framebuffer 占用额外显存
性能测试 (典型场景):
单一 Renderpass (baseline): - Frame time: 16.6 ms (60 FPS) - GPU memory: 100 MB 多 Renderpass (world + UI): - Frame time: 16.8 ms (~60 FPS) ← 增加 ~1% - GPU memory: 120 MB ← 增加 20 MB (离屏 framebuffer) 结论:性能影响很小,收益 (代码清晰度、可扩展性) 远大于成本优化建议:
- 合并同类 Renderpass: 如果两个 renderpass 使用相同配置,考虑合并
- Lazy Transition: 只在必要时转换 image layout
- Framebuffer 复用: 多个 renderpass 可以共享 depth buffer
📝 练习
练习 1: 添加 Debug Renderpass任务:添加第三个 renderpass 用于调试可视化 (如碰撞盒、法线、网格)。
// 1. 添加新的 renderpass 枚举typedefenumbuiltin_renderpass{BUILTIN_RENDERPASS_WORLD=0x01,BUILTIN_RENDERPASS_UI=0x02,BUILTIN_RENDERPASS_DEBUG=0x04// ← 新增}builtin_renderpass;// 2. 创建 debug renderpassvulkan_renderpass debug_renderpass;vulkan_renderpass_create(&context,&debug_renderpass,...);// 3. 创建 debug shader (简单的线框着色器)vulkan_debug_shader debug_shader;vulkan_debug_shader_create(&context,&debug_shader);// 4. 在渲染流程中添加 debug renderpassvoidrenderer_draw_frame(render_packet*packet){backend.begin_frame(delta_time);// World renderpass// ...// UI renderpass// ...// Debug renderpass (绘制在 UI 之上)if(debug_mode_enabled){backend.begin_renderpass(BUILTIN_RENDERPASS_DEBUG);backend.update_global_debug_state(projection,view);// 绘制调试几何体 (碰撞盒、法线等)for(u32 i=0;i<debug_geometry_count;++i){backend.draw_geometry(debug_geometries[i]);}backend.end_renderpass(BUILTIN_RENDERPASS_DEBUG);}backend.end_frame(delta_time);}要求:
- Debug 着色器使用线框模式 (VK_POLYGON_MODE_LINE)
- 可以通过按键切换 debug 模式开关
- 绘制碰撞盒、坐标轴、法线向量
任务:添加后处理 renderpass,对 world 渲染结果应用灰度滤镜。
// 1. 创建后处理 framebuffer (全屏 quad)geometry fullscreen_quad;create_fullscreen_quad(&fullscreen_quad);// 2. 创建后处理着色器// post_process.vert.glsl#version450layout(location=0)in vec2 in_position;// 全屏四边形 [-1,1]layout(location=1)in vec2 in_texcoord;layout(location=0)out vec2 out_texcoord;voidmain(){out_texcoord=in_texcoord;gl_Position=vec4(in_position,0.0,1.0);}// post_process.frag.glsl#version450layout(location=0)in vec2 in_texcoord;layout(location=0)out vec4 out_color;layout(set=0,binding=0)uniform sampler2D scene_texture;// world 渲染结果voidmain(){vec3 color=texture(scene_texture,in_texcoord).rgb;// 灰度滤镜floatgray=dot(color,vec3(0.299,0.587,0.114));out_color=vec4(vec3(gray),1.0);}// 3. 修改渲染流程voidrenderer_draw_frame(render_packet*packet){backend.begin_frame(delta_time);// World renderpass → 渲染到离屏 texturebackend.begin_renderpass(BUILTIN_RENDERPASS_WORLD);// ... 绘制世界 ...backend.end_renderpass(BUILTIN_RENDERPASS_WORLD);// Post-process renderpass → 采样 world texture,应用滤镜backend.begin_renderpass(BUILTIN_RENDERPASS_POST_PROCESS);bind_texture(world_framebuffer_texture);draw_fullscreen_quad();backend.end_renderpass(BUILTIN_RENDERPASS_POST_PROCESS);// UI renderpass → 在后处理结果上绘制 UIbackend.begin_renderpass(BUILTIN_RENDERPASS_UI);// ... 绘制 UI ...backend.end_renderpass(BUILTIN_RENDERPASS_UI);backend.end_frame(delta_time);}练习 3: UI 深度分层任务:实现 UI 元素的深度分层,通过 Z 坐标控制绘制顺序。
// 1. 修改 UI 几何体的 Z 坐标geometry_render_data ui_elements[10];// 背景图片 (Z = 0)ui_elements[0].model=mat4_translation((vec3){0,0,0});ui_elements[0].geometry=&background;// 按钮 (Z = 10)ui_elements[1].model=mat4_translation((vec3){100,100,10});ui_elements[1].geometry=&button;// 文本 (Z = 20,最上层)ui_elements[2].model=mat4_translation((vec3){100,100,20});ui_elements[2].geometry=&text;// 2. 启用 UI renderpass 的深度测试VkPipelineDepthStencilStateCreateInfo depth_stencil;depth_stencil.depthTestEnable=VK_TRUE;// 启用深度测试depth_stencil.depthWriteEnable=VK_TRUE;// 写入深度depth_stencil.depthCompareOp=VK_COMPARE_OP_LESS;// 近的覆盖远的// 3. 调整正交投影的深度范围mat4 ui_projection=mat4_orthographic(0,width,height,0,-100.0f,// near: 允许 Z 从 -100 到 100100.0f// far);// 4. 测试深度分层// 应该看到:background → button → text 的正确遮挡关系要求:
- UI 元素可以通过 Z 坐标控制绘制顺序
- 相同 Z 值的元素,后绘制的在上面
- 支持负 Z 值 (如背景可以用 Z = -50)
恭喜!你已经掌握了多渲染通道架构!🎉
关注公众号「上手实验室」,获取更多游戏引擎开发教程!