上一篇:资源系统 | 下一篇:在UI渲染通道中绘制 | 返回目录
📚 快速导航
目录
- 简介
- 学习目标
- 多渲染通道架构
- Renderpass枚举
- UI着色器实现
- Framebuffer策略
- Backend接口扩展
- Vulkan实现
- 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 内置渲染通道枚举
*/
typedef enum builtin_renderpass {
BUILTIN_RENDERPASS_WORLD = 0x01, // 世界渲染通道
BUILTIN_RENDERPASS_UI = 0x02 // UI 渲染通道
} builtin_renderpass;
使用位标志的好处:
// 可以用位运算组合 renderpass
u8 renderpass_mask = BUILTIN_RENDERPASS_WORLD | BUILTIN_RENDERPASS_UI;
// 检查是否包含某个 renderpass
if (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.inl
typedef struct vulkan_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.inl
typedef struct renderer_backend {
u64 frame_number;
b8 (*initialize)(struct renderer_backend* backend, const char* application_name);
void (*shutdown)(struct renderer_backend* backend);
void (*resized)(struct renderer_backend* backend, u16 width, u16 height);
b8 (*begin_frame)(struct renderer_backend* backend, f32 delta_time);
b8 (*end_frame)(struct renderer_backend* backend, f32 delta_time);
// ========== 新增:Renderpass 控制 ==========
b8 (*begin_renderpass)(struct renderer_backend* backend, u8 renderpass_id);
b8 (*end_renderpass)(struct renderer_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)(const u8* pixels, struct texture* texture);
void (*destroy_texture)(struct texture* texture);
b8 (*create_material)(struct material* material);
void (*destroy_material)(struct material* material);
b8 (*create_geometry)(geometry* geometry, u32 vertex_count, const vertex_3d* vertices, u32 index_count, const u32* 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.c
b8 vulkan_renderer_begin_renderpass(struct renderer_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 和 framebuffer
switch (renderpass_id) {
case BUILTIN_RENDERPASS_WORLD:
renderpass = &context.main_renderpass;
framebuffer = context.world_framebuffers[context.image_index];
break;
case BUILTIN_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);
return false;
}
// 2. 开始 renderpass (记录 vkCmdBeginRenderPass)
vulkan_renderpass_begin(command_buffer, renderpass, framebuffer);
// 3. 绑定对应的着色器
switch (renderpass_id) {
case BUILTIN_RENDERPASS_WORLD:
vulkan_material_shader_use(&context, &context.material_shader);
break;
case BUILTIN_RENDERPASS_UI:
vulkan_ui_shader_use(&context, &context.ui_shader);
break;
}
return true;
}
b8 vulkan_renderer_end_renderpass(struct renderer_backend* backend, u8 renderpass_id) {
vulkan_renderpass* renderpass = 0;
vulkan_command_buffer* command_buffer = &context.graphics_command_buffers[context.image_index];
// 1. 根据 ID 选择 renderpass
switch (renderpass_id) {
case BUILTIN_RENDERPASS_WORLD:
renderpass = &context.main_renderpass;
break;
case BUILTIN_RENDERPASS_UI:
renderpass = &context.ui_renderpass;
break;
default:
KERROR("vulkan_renderer_end_renderpass called on unrecognized renderpass id: %#02x", renderpass_id);
return false;
}
// 2. 结束 renderpass (记录 vkCmdEndRenderPass)
vulkan_renderpass_end(command_buffer, renderpass);
return true;
}
Shader绑定
每个 renderpass 使用不同的着色器:
// vulkan_material_shader_use() - 绑定 Material Shader
void vulkan_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 Shader
void vulkan_ui_shader_use(vulkan_context* context, vulkan_ui_shader* shader) {
u32 image_index = context->image_index;
// 绑定 UI pipeline
vkCmdBindPipeline(
context->graphics_command_buffers[image_index].handle,
VK_PIPELINE_BIND_POINT_GRAPHICS,
shader->pipeline.handle
);
}
全局状态更新
分别更新 World 和 UI 的全局状态:
// engine/src/renderer/vulkan/vulkan_backend.c
void vulkan_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 的全局 UBO
vulkan_material_shader_update_global_state(&context, &context.material_shader);
// 绑定全局 descriptor set
vulkan_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.)
}
void vulkan_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 的全局 UBO
vulkan_ui_shader_update_global_state(&context, &context.ui_shader, context.frame_delta_time);
// 绑定全局 descriptor set
vulkan_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.inl
typedef struct geometry_render_data {
mat4 model; // 模型矩阵
geometry* geometry; // 几何体指针
} geometry_render_data;
typedef struct render_packet {
f32 delta_time;
// 世界几何体
u32 geometry_count;
geometry_render_data* geometries;
// UI 几何体
u32 ui_geometry_count;
geometry_render_data* ui_geometries;
} render_packet;
使用示例:
// 应用层准备 render packet
render_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 mat4 mat4_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;
return out_matrix;
}
UI 正交投影设置:
// engine/src/renderer/renderer_frontend.c
// 创建 UI 正交投影
// 左上角为 (0, 0),右下角为 (width, height)
state_ptr->ui_projection = mat4_orthographic(
0, // left
1280.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混合
渲染顺序很重要,特别是对于透明物体:
// 正确的渲染顺序
void renderer_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_color
color_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 和 bottom
mat4 ui_projection = mat4_orthographic(
0, // left
width, // right
height, // bottom ← 本应是 top
0, // 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 layout
vkCmdPipelineBarrier(
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;
void main() {
vec4 world_color = texture(world_texture, screen_uv);
vec4 ui_color = texture(ui_sampler, tex_coord);
// 合成 world + UI
out_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 枚举
typedef enum builtin_renderpass {
BUILTIN_RENDERPASS_WORLD = 0x01,
BUILTIN_RENDERPASS_UI = 0x02,
BUILTIN_RENDERPASS_DEBUG = 0x04 // ← 新增
} builtin_renderpass;
// 2. 创建 debug renderpass
vulkan_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 renderpass
void renderer_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
#version 450
layout(location = 0) in vec2 in_position; // 全屏四边形 [-1,1]
layout(location = 1) in vec2 in_texcoord;
layout(location = 0) out vec2 out_texcoord;
void main() {
out_texcoord = in_texcoord;
gl_Position = vec4(in_position, 0.0, 1.0);
}
// post_process.frag.glsl
#version 450
layout(location = 0) in vec2 in_texcoord;
layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D scene_texture; // world 渲染结果
void main() {
vec3 color = texture(scene_texture, in_texcoord).rgb;
// 灰度滤镜
float gray = dot(color, vec3(0.299, 0.587, 0.114));
out_color = vec4(vec3(gray), 1.0);
}
// 3. 修改渲染流程
void renderer_draw_frame(render_packet* packet) {
backend.begin_frame(delta_time);
// World renderpass → 渲染到离屏 texture
backend.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 → 在后处理结果上绘制 UI
backend.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 到 100
100.0f // far
);
// 4. 测试深度分层
// 应该看到:background → button → text 的正确遮挡关系
要求:
- UI 元素可以通过 Z 坐标控制绘制顺序
- 相同 Z 值的元素,后绘制的在上面
- 支持负 Z 值 (如背景可以用 Z = -50)
恭喜!你已经掌握了多渲染通道架构! 🎉
关注公众号「上手实验室」,获取更多游戏引擎开发教程!
3092

被折叠的 条评论
为什么被折叠?



