feat(renderer): uniform bind groups infrastructure (Étape 3 + 4.1/4.2/4.4)

Étape 3 (infrastructure uniforms) + le câblage minimal d'Étape 4 pour garder
les exemples exécutables (wgpu requiert que tous les bind groups du layout
pipeline soient posés au draw) :

- resources/uniform.rs : types bytemuck Pod FrameUniforms (192 B) et
  ObjectUniform (64 B), alignés 16 octets sans padding; offsets vérifiés par un
  test unitaire contre le contrat du shader. glam feature bytemuck activé.
- pipeline_cache: create_uniform_bind_group_layouts() expose les 2 layouts
  (frame @0 Vertex|Fragment + object @1 Vertex); build_pipeline les attache à
  TOUT pipeline (un seul layout pour tous, décision actée).
- Renderer: alloue le buffer frame partagé + BindGroup(0) (défaut identité,
  mode lit) et un object identité partagé pour le chemin bas-niveau; cache
  RefCell<HashMap<label,(buffer,bindgroup)>> par entité, model réécrit chaque
  frame depuis transform.to_matrix(); draw_entity pose groupes 0+1.

4.3 (caméra active + aspect) non implémenté: simple/manual restent exécutables
car basic ignore ces uniforms. Documentation DRAFT mise à jour.

Validation: check workspace+examples 0 warning, doc 0 warning, test (Pod+wgsl)
OK, fmt propre.
This commit is contained in:
Jérôme Bousquié
2026-09-16 16:56:05 +02:00
parent 26a3cda6f6
commit c1e07b42b4
8 changed files with 311 additions and 39 deletions
+135 -11
View File
@@ -20,12 +20,20 @@
use crate::core::Context;
use crate::core::Frame;
use crate::resources::{Material, Mesh};
use crate::math::Transform;
use crate::pipeline::create_uniform_bind_group_layouts;
use crate::resources::uniform::{FRAME_UNIFORMS_SIZE, OBJECT_UNIFORM_SIZE};
use crate::resources::{FrameUniforms, Material, Mesh, ObjectUniform};
use crate::scene::Scene;
use std::cell::RefCell;
use std::collections::HashMap;
/// The Executor layer of the architecture. Holds shared references to Device and Queue from Context,
/// plus the surface texture format. Executes WGPU rendering commands by binding Materials and Meshes
/// into RenderPasses during each frame. Does not own raw hardware resources (they are Arc-cloned from Context).
///
/// Since Étape 3 every pipeline declares the two uniform bind groups (frame @0 + object @1), the
/// Renderer owns the matching GPU buffers and `BindGroup`s and binds them around every draw call.
pub struct Renderer {
/// GPU command submission queue — holds an Arc clone from Context; shared with other Context users.
queue: wgpu::Queue,
@@ -33,19 +41,78 @@ pub struct Renderer {
device: wgpu::Device,
/// Surface texture output format — stored here so it can be passed to PipelineCache on Material creation.
format: wgpu::TextureFormat,
/// Bind group layout for the per-object uniforms (group 1) — must match every pipeline layout.
object_layout: wgpu::BindGroupLayout,
/// Shared per-frame uniform buffer + bind group (camera + lights). Written each frame (`render_scene`).
frame_bind_group: wgpu::BindGroup,
/// Shared per-object bind group (identity model) used by the low-level `render` path.
shared_object_bind_group: wgpu::BindGroup,
/// Per-entity object uniform buffers + bind groups, lazily created on first encounter and keyed by
/// entity label. Needed because `render_scene(&self, &Scene)` is immutable; the model matrix is
/// rewritten each frame for every entity.
object_cache: RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>,
}
impl Renderer {
/// Creates a Renderer by cloning Device and Queue Arc references from the Context, plus capturing the surface format.
/// Creates a Renderer by cloning Device and Queue Arc references from the Context, plus capturing
/// the surface format, and allocates the shared frame + object uniform buffers and their bind groups.
/// Inputs: context (borrowed reference to Context providing GPU resource handles), format (surface texture format).
/// Returns a new Renderer instance sharing the same underlying GPU resources as Context.
/// Called once at application startup during scene setup. The Renderer shares these resources via Arc;
/// Context retains ownership and can continue using them after this call.
pub fn new(context: &Context, format: wgpu::TextureFormat) -> Self {
let queue: wgpu::Queue = context.queue.clone();
let device: wgpu::Device = context.device.clone();
let [frame_layout, object_layout] = create_uniform_bind_group_layouts(&device);
// Shared frame uniforms: identity camera + white directional light, lit mode by default.
// Values become meaningful once an active camera is wired (Étape 4.3); for now the default
// is a coherent scene when a shader actually reads them, and irrelevant to shaders that don't.
let default_frame = FrameUniforms::default();
let frame_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("frame uniform buffer"),
size: FRAME_UNIFORMS_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&frame_buffer, 0, bytemuck::bytes_of(&default_frame));
let frame_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("frame bind group"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame_buffer.as_entire_binding(),
}],
});
// Shared per-object bind group (identity model) for the low-level `render` path.
let object_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("shared object uniform buffer"),
size: OBJECT_UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let identity_object = ObjectUniform {
model: glam::Mat4::IDENTITY,
};
queue.write_buffer(&object_buffer, 0, bytemuck::bytes_of(&identity_object));
let shared_object_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("shared object bind group"),
layout: &object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: object_buffer.as_entire_binding(),
}],
});
Self {
queue: context.queue.clone(),
device: context.device.clone(),
queue,
device,
format,
object_layout,
frame_bind_group,
shared_object_bind_group,
object_cache: RefCell::new(HashMap::new()),
}
}
@@ -81,7 +148,13 @@ impl Renderer {
..Default::default()
});
draw_entity(&mut render_pass, mesh, material);
draw_entity(
&mut render_pass,
mesh,
material,
&self.frame_bind_group,
&self.shared_object_bind_group,
);
}
self.queue.submit(std::iter::once(encoder.finish()));
}
@@ -113,8 +186,15 @@ impl Renderer {
..Default::default()
});
for (_label, mesh, material, _transform) in scene.iter_entities() {
draw_entity(&mut render_pass, mesh, material);
for (label, mesh, material, transform) in scene.iter_entities() {
let object_bind_group = self.object_bind_group_for(label, transform);
draw_entity(
&mut render_pass,
mesh,
material,
&self.frame_bind_group,
&object_bind_group,
);
}
}
self.queue.submit(std::iter::once(encoder.finish()));
@@ -138,18 +218,62 @@ impl Renderer {
pub fn format(&self) -> wgpu::TextureFormat {
self.format
}
/// Returns the per-entity object bind group for `label`, creating its uniform buffer on first
/// encounter and rewriting the model matrix each call. Since `render_scene(&self)` is immutable,
/// the lazily-populated cache is interior-mutable (`RefCell`). Étape 4.2.
/// Inputs: label (entity identifier used as cache key), transform (world placement to upload).
/// Returns an owned (cheaply Arc-cloned) reference handle to the object bind group (group 1).
fn object_bind_group_for(&self, label: &str, transform: &Transform) -> wgpu::BindGroup {
let mut cache = self.object_cache.borrow_mut();
let (buffer, bind_group) = cache.entry(label.to_string()).or_insert_with(|| {
let buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("object uniform buffer"),
size: OBJECT_UNIFORM_SIZE,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("object bind group"),
layout: &self.object_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, bind_group)
});
// Rewrite the model matrix every frame so entity transforms can update (e.g. rotation).
let object_uniforms = ObjectUniform {
model: transform.to_matrix(),
};
self.queue
.write_buffer(buffer, 0, bytemuck::bytes_of(&object_uniforms));
bind_group.clone()
}
}
/// Binds a Material pipeline and Mesh buffers into an active render pass and issues the draw call.
/// Shared by `Renderer::render` and `Renderer::render_scene` to avoid duplicated draw logic.
/// Binds a Material pipeline, the two uniform bind groups, and Mesh buffers into an active render
/// pass and issues the draw call. Shared by `Renderer::render` and `Renderer::render_scene`.
/// The frame (group 0) and object (group 1) bind groups are **required** by every pipeline layout
/// (Étape 3 : un seul layout pour tous) — they must be bound even if the shader does not read them.
/// Draws indexed geometry when an index buffer exists, otherwise falls back to a non-indexed draw.
/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline to bind).
fn draw_entity(pass: &mut wgpu::RenderPass<'_>, mesh: &Mesh, material: &Material) {
/// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline to bind),
/// frame_bind_group (shared per-frame uniforms), object_bind_group (per-entity/identity model).
fn draw_entity(
pass: &mut wgpu::RenderPass<'_>,
mesh: &Mesh,
material: &Material,
frame_bind_group: &wgpu::BindGroup,
object_bind_group: &wgpu::BindGroup,
) {
if mesh.num_vertices == 0 {
// No vertices — nothing to render.
return;
}
pass.set_pipeline(&material.pipeline);
pass.set_bind_group(0, frame_bind_group, &[]);
pass.set_bind_group(1, object_bind_group, &[]);
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);