Bevy 渲染系统 Bindless 实现与交互逻辑
在现代游戏引擎与图形渲染中,Bindless 技术是提升大量资源管理效率、减少渲染状态切换的关键。Bevy 作为一款基于 Rust 的现代化游戏引擎,其 Bindless 实现既有对跨平台兼容性的考量,也包含独特的资源管理逻辑。本文将完全基于 Bevy 相关源码文档,从执行流程、Bindless 实现、宏机制到 Shader 交互,一步步拆解 Bevy 渲染系统的核心逻辑。
一、Bevy 渲染系统的核心执行流程
要理解 Bindless 技术在 Bevy 中的作用,首先需要明确渲染代码的核心执行路径 —— 从 executor 启动到具体系统任务执行,每一步都为后续资源绑定奠定基础。
1.1 执行入口:MultiThreadedExecutor 的 run 方法
Bevy 渲染代码的执行起点是 MultiThreadedExecutor(继承自 SystemExecutor)的 run 方法。该方法会触发上下文的 tick 循环,是所有渲染任务的 “总开关”。
1.2 循环触发:context.tick_executor 与锁竞争
在 run 方法内部,会调用 context.tick_executor 方法,该方法的核心是重复执行 guard.tick(self, conditions)。由于是多线程上下文,每次执行前必须通过 try_lock 尝试获取锁 —— 只有成功获取锁,才能进入下一轮 tick 逻辑,避免多线程资源竞争。
1.3 Tick 核心逻辑:三大关键步骤
tick 方法内部包含三个核心操作,共同完成渲染前的资源准备与任务调度:
- finish_system_and_handle_dependents:处理已完成系统的依赖关系,确保后续任务的执行顺序正确;
- rebuild_active_access:重建当前活跃的资源访问关系,更新资源的读写状态;
- spawn_system_tasks:启动系统任务,这是 Bindless 资源绑定的关键触发点 —— 该方法会调用 spawn_system_task,异步执行 bevy_pbr::material::specialize_material_meshes。
1.4 资源收集与 Pipeline 准备
在 specialize_material_meshes 异步任务中,会首先获取可见实体的关键资源:
- material_instance(材质实例)
- mesh_instance(网格实例)
- lightmap(光照图)
- render_visibility_ranges(渲染可见范围)
通过这些资源,进一步提取出 mesh(网格)、material(材质)、material_bind_group(材质绑定组)。随后,根据 mesh 的布局获取 pipeline_id—— 而获取 pipeline_id 必须执行 specialize 方法:
- specialize 会加载顶点着色器、片元着色器与描述符;
- 若启用 Bindless 模式,会在 Shader 宏中自动添加 BINDLESS 标识,为后续 Bindless 资源访问铺路。
二、Bevy 中 Bindless 渲染的实现机制
Bevy 的 Bindless 支持并非 “完全动态”,而是基于 AsBindGroup 派生宏的 “批量绑定” 方案,核心围绕属性解析、双重代码生成、资源跟踪三大模块展开。
2.1 核心入口:AsBindGroup 派生宏
Bevy 的 Bindless 实现集中在 as_bind_group.rs 文件中,通过 #[derive(AsBindGroup)] 宏为结构体(如材质)自动生成绑定逻辑。该宏支持多个辅助属性,用于定义资源绑定规则:
#[proc_macro_derive(
AsBindGroup,
attributes(
uniform, storage_texture, texture, sampler,
bind_group_data, storage, bindless, data
)
)]
其中 bindless 属性是开启 Bindless 模式的关键,其他属性(如 uniform、texture)用于定义具体资源的绑定类型。
2.2 Bindless 实现的四大核心环节
1. Bindless 属性解析
宏会优先解析结构体级别的 #[bindless(limit(N))] 属性,确定 Bindless 资源的数量限制:
- 支持显式限制(如 limit(4),指定每组最大资源数);
- 支持自动限制(使用 AUTO_BINDLESS_SLAB_RESOURCE_LIMIT,由引擎动态分配)。
解析逻辑核心代码如下:
} else if attr_ident == BINDLESS_ATTRIBUTE_NAME {
attr_bindless_count = Some(BindlessSlabResourceLimitAttr::Auto);
if let Meta::List(_) = attr.meta {
attr.parse_nested_meta(|submeta| {
if submeta.path.is_ident(&LIMIT_MODIFIER_NAME) {
let content;
parenthesized!(content in submeta.input);
let lit: LitInt = content.parse()?;
attr_bindless_count = Some(BindlessSlabResourceLimitAttr::Limit(lit));
return Ok(());
}// ...
2. 双重代码生成:Bindless 与传统布局兼容
为确保跨平台兼容性,宏会生成两套绑定布局:
- Bindless 布局:当硬件支持且启用 Bindless 时使用,基于资源数组与索引表;
- 传统布局:当 Bindless 不支持或被禁用时回退,使用独立的绑定组。
引擎会在运行时根据设备功能(如是否支持 Descriptor Indexing)自动选择布局。
3. 资源类型跟踪
通过 BindlessResourceType 枚举跟踪所有使用的资源类型(纹理、采样器、缓冲区等),同时维护:
- BindlessIndexTableDescriptor:描述索引表的结构,用于查找资源在数组中的位置;
- 绑定数组描述符:为每种资源类型创建大型数组(如纹理数组、采样器数组)。
4. 条件缓冲区处理
由于 WebGPU 暂不支持真正的 “Bindless Uniform”,Bevy 在 Bindless 模式下会将 Uniform 缓冲区提升为存储缓冲区,并根据模式选择绑定类型:
let uniform_binding_type_declarations = match attr_bindless_count {
Some(_) => {
quote! {
let (#uniform_binding_type, #uniform_buffer_usages) =
if Self::bindless_supported(render_device) && !force_no_bindless {
(
BufferBindingType::Storage { read_only: true },
BufferUsages::STORAGE,
)
} else {
(
BufferBindingType::Uniform,
BufferUsages::UNIFORM,
)
};
}
}// ...
2.3 Bindless 核心机制:资源数组 + 索引表
Bevy Bindless 的本质是 “用大型资源数组替代独立绑定组,用索引表实现动态访问”,具体逻辑如下:
1. 资源数组:集中管理同类型资源
不再为每个材质创建独立绑定,而是将所有同类型资源(如 2D 纹理、采样器)存储在大型数组中,示例 Shader 代码:
// Bindless 模式:大型资源数组
@group(2) @binding(5) var bindless_textures_2d: binding_array<texture_2d<f32>>;
@group(2) @binding(1) var bindless_samplers_filtering: binding_array<sampler>;
2. 索引表:定位资源位置
通过索引表(bindless_index_table)存储资源在数组中的索引,Shader 中通过索引动态访问资源:
// 索引表:存储资源在数组中的位置@group(2) @binding(0) var<storage> bindless_index_table: array<MaterialIndexEntry>;
// 动态访问:通过索引获取资源
let material_index = bindless_index_table[slot].material_index;
let texture = bindless_textures_2d[material_index];
3. Rust 代码示例:Bindless 材质定义
以下是文档中典型的 Bindless 材质定义,清晰展示了 bindless 属性与资源绑定的关联:
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
#[uniform(0, BindlessMaterialUniform, binding_array(10))]
// Uniform 存储在绑定数组10中#[bindless(limit(4))]
// 每组最多4个材质实例共享绑定组
struct BindlessMaterial {
color: LinearRgba,
#[texture(1)] // 纹理绑定到索引1
#[sampler(2)] // 采样器绑定到索引2
color_texture: Option<Handle<Image>>,
}
三、澄清 Bevy Bindless 的认知:并非完全动态的 Bindless
很多开发者会误以为 Bevy 的 Bindless 是 “完全动态、可随意增删资源” 的实现,但实际上它是一种 “准 Bindless” 方案,需明确以下关键区别。
3.1 为什么不是 “完全 Bindless”?
真正的 Bindless 应具备四大特性:
- 支持通过句柄直接访问任意 GPU 资源;
- 无需预先将资源分组到绑定组;
- 运行时可动态添加 / 删除资源;
- 基于 Descriptor Heaps(DirectX)或类似机制管理资源。
而 Bevy 的实现存在三个限制:
- 资源仍需绑定:资源必须预先加入大型数组,无法完全脱离绑定;
- 动态性有限:数组大小预定义,无法随意增删内部数据;
- 依赖批次分组:limit(N) 限制了每组资源数量,并非全局动态管理。
3.2 关键误解:limit(4) 的真实含义
文档中明确指出,#[bindless(limit(4))] 并非 “每组最多 4 个材质实例”,而是每个绑定组(bind group)中最多包含 4 个材质实例:
- 当材质实例数≤4 时,共享同一个绑定组;
- 当第 5 个实例加入时,自动创建新的绑定组;
- 目的是平衡性能(减少绑定组数量)与兼容性(适配硬件数组大小限制)。
3.3 Bevy 选择 “准 Bindless” 的原因
- WebGPU 兼容性:WebGPU 目前的 API 不支持完全动态的 Descriptor Heaps;
- 跨平台支持:需适配不同硬件(PC、移动端、Web)的资源管理限制;
- 性能平衡:在 “灵活性” 与 “渲染效率” 间找到折中,避免过度动态导致的性能损耗;
- 渐进式改进:为未来 WebGPU 支持完整 Bindless 打下基础。
四、实现真正 Bindless 渲染的方案(修改 Bevy 与 WGPU)
若要在 Bevy 中实现 “完全动态” 的 Bindless,需从底层 API 支持、资源管理、渲染系统重构三方面入手,甚至修改 WGPU 核心逻辑。
底层 API 支持:依赖现代图形接口特性
真正的 Bindless 需依赖以下 API 特性:
- DirectX 12:使用 Descriptor Heaps 管理资源句柄;
- Vulkan:启用 Descriptor Indexing 扩展;
- Metal:通过 Argument Buffers 实现动态资源访问。
五、derive_as_bind_group 宏的完整工作流程
derive_as_bind_group 是 Bevy 资源绑定的 “核心引擎”,它通过过程宏自动生成 AsBindGroup 实现,将 Rust 结构体转换为 GPU 可识别的绑定资源。以下是其完整流程。
5.1 步骤 1:初始设置与模块加载
宏首先从 Bevy 清单中加载依赖模块,确保生成代码时能正确引用渲染、图像、资产相关接口:
let manifest = BevyManifest::shared();
let render_path = manifest.get_path("bevy_render");
let image_path = manifest.get_path("bevy_image");
let asset_path = manifest.get_path("bevy_asset");
let ecs_path = manifest.get_path("bevy_ecs");
5.2 步骤 2:结构体属性处理
解析结构体级别的属性,确定绑定组的全局配置:
- #[bind_group_data(Type)]:指定绑定组所需的配置数据类型(如材质密钥);
- #[bindless(limit(N))]:启用 Bindless 模式并设置资源数量限制;
- 其他属性:如 #[uniform(0, Type)] 定义全局 Uniform 缓冲区。
处理逻辑核心代码:
for attr in &ast.attrs {
if let Some(attr_ident) = attr.path().get_ident() {
if attr_ident == BIND_GROUP_DATA_ATTRIBUTE_NAME {
// 处理 bind_group_data 属性
} else if attr_ident == BINDLESS_ATTRIBUTE_NAME {
// 处理 bindless 属性
}
}}
5.3 步骤 3:字段分析与绑定分配
遍历结构体的每个字段,根据字段属性(uniform、texture、sampler 等)确定资源类型与绑定索引:
- #[uniform(N)]:字段作为 Uniform 缓冲区,绑定到索引 N;
- #[texture(N)]:字段作为纹理,绑定到索引 N;
- #[sampler(N)]:字段作为采样器,绑定到索引 N;
- #[storage(N)]:字段作为存储缓冲区,绑定到索引 N。
同时维护 binding_states 向量,跟踪绑定索引的占用状态,防止冲突:
enum BindingState<'a> {
Free, // 未占用
Occupied { binding_type: BindingType, ident: &'a Ident }, // 已占用
OccupiedConvertedUniform, // 转换后的 Uniform
OccupiedMergeableUniform { uniform_fields: Vec<&'a syn::Field> }, // 可合并的 Uniform}
5.4 步骤 4:生成不同类型的绑定代码
根据资源类型,生成对应的 GPU 资源绑定代码,以下是三种核心类型的生成逻辑:
1. Uniform 缓冲区
为 #[uniform] 属性生成缓冲区创建代码,将 Rust 数据序列化为 GPU 可读取的格式:
binding_impls.push(quote! {{
let mut buffer = #render_path::render_resource::encase::UniformBuffer::new(Vec::new());
buffer.write(&self.#field_name).unwrap(); // 序列化字段数据
(
#binding_index, // 绑定索引
#render_path::render_resource::OwnedBindingResource::Buffer(
render_device.create_buffer_with_data(
&#render_path::render_resource::BufferInitDescriptor {
label: None,
usage: #uniform_buffer_usages, // 缓冲区用途
contents: buffer.as_ref(), // 序列化后的数据
},
)
)
)}});
2. 纹理资源
为 #[texture] 属性生成纹理视图获取代码,处理资源句柄与 fallback 逻辑:
binding_impls.insert(0, quote! {
(
#binding_index,
#render_path::render_resource::OwnedBindingResource::TextureView(
#render_path::render_resource::#dimension, // 纹理维度(如 D2)
{
let handle: Option<&#asset_path::Handle<#image_path::Image>> = (&self.#field_name).into();
if let Some(handle) = handle {
// 从资源管理器获取纹理视图
images.get(handle)
.ok_or_else(|| AsBindGroupError::RetryNextUpdate)?
.texture_view.clone()
} else {
// 使用 fallback 纹理
#fallback_image.texture_view.clone()
}
}
)
)});
3. 采样器资源
为 #[sampler] 属性生成采样器获取代码,验证采样器类型并处理 fallback:
binding_impls.insert(0, quote! {
(
#binding_index,
#render_path::render_resource::OwnedBindingResource::Sampler(
{
let handle: Option<&#asset_path::Handle<#image_path::Image>> = (&self.#field_name).into();
if let Some(handle) = handle {
let image = images.get(handle)
.ok_or_else(|| AsBindGroupError::RetryNextUpdate)?;
image.sampler.clone() // 获取图像关联的采样器
} else {
#fallback_image.sampler.clone() // fallback 采样器
}
}
)
)});
5.5 步骤 5:生成绑定组布局条目
绑定组布局条目(BindGroupLayoutEntry)是 WGPU 识别资源类型的关键,宏会根据资源类型生成对应的布局描述:
non_bindless_binding_layouts.push(quote!{
#bind_group_layout_entries.push(
#render_path::render_resource::BindGroupLayoutEntry {
binding: #binding_index, // 绑定索引
visibility: #visibility, // 可见的 Shader 阶段(如 FRAGMENT)
ty: #render_path::render_resource::BindingType::Buffer {
ty: #uniform_binding_type, // 缓冲区类型(Uniform/Storage)
has_dynamic_offset: false, // 是否支持动态偏移
min_binding_size: Some(<#field_ty as ShaderType>::min_size()), // 最小缓冲区大小
},
count: #actual_bindless_slot_count, // Bindless 模式下的数组大小
}
);});
5.6 步骤 6:Bindless 模式的特殊处理
若启用 #[bindless],宏会额外生成以下逻辑:
- 跟踪 Bindless 资源类型(纹理、采样器等);
- 生成绑定数组的布局条目(而非单个绑定);
- 创建索引表描述符,用于资源定位:
match #actual_bindless_slot_count {
Some(bindless_slot_count) => {
let bindless_index_table_range = #bindless_index_table_range;
#bind_group_layout_entries.extend(
#render_path::render_resource::create_bindless_bind_group_layout_entries(
bindless_index_table_range.end.0 - bindless_index_table_range.start.0,
bindless_slot_count.into(),
#bindless_index_table_binding_number,
).into_iter()
);
#(#bindless_binding_layouts)*;
}
None => {
#(#non_bindless_binding_layouts)*;
}};
六、AsBindGroup 与 Shader、Draw Call 的交互细节
AsBindGroup 生成的代码并非孤立存在,而是与 Shader 定义、渲染管线、Draw Call 紧密协作,共同完成 “Rust 资源 → GPU 渲染” 的闭环。
6.1 第一步:Shader 中的绑定组定义
Shader(以 WGSL 为例)需预先定义与 Rust 材质对应的绑定组结构,确保索引与类型匹配。以下是传统模式与 Bindless 模式的对比:
传统绑定模式
// 材质 Uniform 缓冲区(group 1, binding 0)@group(1) @binding(0) var<uniform> material: MaterialUniform;// 纹理(group 1, binding 1)@group(1) @binding(1) var color_texture: texture_2d<f32>;// 采样器(group 1, binding 2)@group(1) @binding(2) var color_sampler: sampler;
Bindless 绑定模式
// 索引表(存储资源在数组中的位置)@group(1) @binding(0) var<storage> material_indices: array<MaterialIndex>;// 纹理数组(group 1, binding 1)@group(1) @binding(1) var bindless_textures: binding_array<texture_2d<f32>>;// 采样器数组(group 1, binding 2)@group(1) @binding(2) var bindless_samplers: binding_array<sampler>;
// 动态访问资源@fragmentfn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
let index = material_indices[instance_index]; // 从实例索引获取资源位置
let texture = bindless_textures[index.texture_index];
let sampler = bindless_samplers[index.sampler_index];
return textureSample(texture, sampler, in.uv);}
6.2 第二步:Rust 中的材质定义与宏生成代码
以传统模式为例,Rust 材质定义需与 Shader 绑定组一一对应:
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
#[bind_group_data(MyMaterialKey)] // 绑定组配置数据
#[uniform(0, MyMaterialUniform)] // 全局 Uniform(group 1, binding 0)
struct MyMaterial {
#[uniform(1)] // 字段级 Uniform(group 1, binding 1)
color: Color,
#[texture(2)] // 纹理(group 1, binding 2)
#[sampler(3)] // 采样器(group 1, binding 3)
base_texture: Handle<Image>,
}
宏会为该结构体生成 AsBindGroup 实现,核心包含两个方法:
- unprepared_bind_group:收集 GPU 资源(缓冲区、纹理视图、采样器),返回未准备好的绑定组;
- bind_group_layout_entries:返回绑定组布局条目,定义资源类型与索引。
6.3 第三步:渲染管线中的绑定组关联
渲染管线(RenderPipeline)需关联绑定组布局,确保管线与 Shader、材质绑定组兼容:
// 创建绑定组布局(从材质的 AsBindGroup 实现获取)
let material_bind_group_layout = MyMaterial::bind_group_layout(&render_device, false);
// 创建渲染管线布局,关联绑定组布局
let pipeline_layout = render_device.create_pipeline_layout(
&PipelineLayoutDescriptor {
label: Some("my_material_pipeline_layout"),
bind_group_layouts: &[
&mesh_bind_group_layout, // group 0:网格数据
&material_bind_group_layout, // group 1:材质数据(与 Shader 对应)
],
push_constant_ranges: &[],
});
6.4 第四步:Draw Call 中的绑定与绘制
在渲染阶段,每个 Draw Call 都会执行以下步骤,完成资源绑定与绘制:
1. 缓存或创建绑定组
为避免重复创建绑定组,引擎会使用缓存(material_bind_group_cache)存储已创建的绑定组:
let material_bind_group = match material_bind_group_cache.get(&material_key) {
Some(bind_group) => bind_group.clone(), // 从缓存获取
None => {
// 从材质生成未准备的绑定组
let unprepared_bind_group = material.unprepared_bind_group(
&material_bind_group_layout,
&render_device,
&mut system_params,
false,
)?;
// 创建 WGPU 绑定组
let bind_group = render_device.create_bind_group(&BindGroupDescriptor {
label: Some("my_material_bind_group"),
layout: &material_bind_group_layout,
entries: &[
// Uniform 缓冲区条目(binding 0)
BindGroupEntry {
binding: 0,
resource: BindingResource::Buffer(BufferBinding {
buffer: &uniform_buffer,
offset: 0,
size: None,
}),
},
// 纹理条目(binding 2)
BindGroupEntry {
binding: 2,
resource: BindingResource::TextureView(&texture_view),
},
// 采样器条目(binding 3)
BindGroupEntry {
binding: 3,
resource: BindingResource::Sampler(&sampler),
},
],
});
material_bind_group_cache.insert(material_key, bind_group.clone());
bind_group
}};
2. 绑定资源并执行绘制
在 RenderPass 中,将绑定组绑定到对应的 Shader 组,然后执行绘制命令:
// 绑定 group 0(网格数据)
render_pass.set_bind_group(0, &mesh_bind_group, &[]);// 绑定 group 1(材质数据,与 Shader 的 @group(1) 对应)
render_pass.set_bind_group(1, &material_bind_group, &[]);// 设置渲染管线
render_pass.set_pipeline(&render_pipeline);// 执行绘制(索引绘制)
render_pass.draw_indexed(0..index_count, 0, 0..1);
七、总结
Bevy 的渲染系统围绕 “兼容性” 与 “性能” 设计,其 Bindless 实现虽非完全动态,却通过 “资源数组 + 索引表” 的方案,在 WebGPU 限制下实现了高效的资源管理。核心逻辑可概括为:
- 执行流程:从 MultiThreadedExecutor 的 run 方法启动,通过 tick 循环调度任务,最终触发 specialize_material_meshes 收集资源;
- Bindless 实现:基于 AsBindGroup 宏,通过属性解析、双重代码生成、资源跟踪,实现 “批量绑定”;
- 宏机制:derive_as_bind_group 自动生成资源绑定代码,处理 Uniform、纹理、采样器等不同资源类型;
- 交互逻辑:与 Shader 绑定组定义一一对应,通过渲染管线关联,在 Draw Call 中完成资源绑定与绘制。