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
Generated
+3
View File
@@ -488,6 +488,9 @@ name = "glam"
version = "0.33.2" version = "0.33.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "glow" name = "glow"
+26 -23
View File
@@ -70,34 +70,37 @@ toucher au rendu (pure façade de données, validable par compilation).
**But** : permettre aux pipelines de recevoir des uniforms (bind groups) au lieu de `bind_group_layouts: &[]`. **But** : permettre aux pipelines de recevoir des uniforms (bind groups) au lieu de `bind_group_layouts: &[]`.
- [ ] 3.1 **Types bytemuck `Pod`** (nouveau `lib/src/resources/uniform.rs`, ou `math/uniform.rs`) : - [X] 3.1 **Types bytemuck `Pod`** (nouveau `lib/src/resources/uniform.rs`) : `FrameUniforms` (192 B) et
- `#[repr(C)] #[derive(Pod, Zeroable, Copy, Clone)] FrameUniforms` (voir 2.1) `ObjectUniform` (64 B), `#[repr(C)]`, 16-byte alignés, sans padding — offset vérifiés par un test
- `#[repr(C)] #[derive(...)] ObjectUniform { model: Mat4 }` unitaire contre la table du shader. Exports via `resources/mod.rs`. *(fait — 2026-09-16. Au passage,
- (alignement 16 octets : utiliser `Vec4`/tableaux pour éviter le padding). Exporter via le `mod.rs` concerné. `glam` feature `bytemuck` activé pour que `Mat4`/`Vec4` implémentent `Pod`/`Zeroable`.)*
- [ ] 3.2 **Bind group layouts** : dans `build_pipeline`, créer 2 `BindGroupLayout` - [X] 3.2 **Bind group layouts** : nouveau `create_uniform_bind_group_layouts(device)` (dans
(frame @0 + object @1, chacun avec un buffer uniform `Vertex`/`Fragment`/`Vertex|Fragment` selon usage) et les `pipeline_cache.rs`, exporté) → frame @0 (`Uniform`, `Vertex|Fragment`) + object @1 (`Uniform`, `Vertex`).
passer dans `PipelineLayoutDescriptor.bind_group_layouts`. `immediate_size` reste 0 (pas de `var<immediate>`). `build_pipeline` les passe dans le `PipelineLayoutDescriptor`. `immediate_size` reste 0.
- [ ] 3.3 **Acté : un seul layout pour tous** (option A). `build_pipeline` attache **toujours** les 2 bind groups *(fait — 2026-09-16)*
(frame @0 + object @1). Plus de famille `basic` au layout vide : tout matériau partage le même layout - [X] 3.3 **Acté : un seul layout pour tous** (option A). `build_pipeline` attache **toujours** les 2 bind
uniformisé. `manual`/quad plat migrent (Étape 5). groups (frame @0 + object @1), même si le shader ne les lit pas (validation wgpu : layout╱bind group).
- [ ] **Validation** : `cargo check` 0 warning ; `cargo doc` 0 warning (types documentés, `missing_docs` actif). *(fait — 2026-09-16)*
- [X] **Validation** : `cargo check --workspace --examples` 0 warning ; `cargo doc --no-deps` 0 warning ;
`cargo test` (types Pod + wgsl naga) OK ; `cargo fmt` propre. *(fait — 2026-09-16)*
## Étape 4 — Rendu 3D dans le `Renderer` ## Étape 4 — Rendu 3D dans le `Renderer`
**But** : `render_scene` applique matrices + éclairage par entité. **But** : `render_scene` applique matrices + éclairage par entité.
- [ ] 4.1 **Buffers frame partagés** : créer le `wgpu::Buffer` `FrameUniforms` + `BindGroup(0)` dans - [X] 4.1 **Buffers frame partagés** : le `Renderer::new` crée le `wgpu::Buffer` `FrameUniforms` + `BindGroup(0)`
`Renderer::new` (ou à la 1re frame). Écrire chaque frame : view/proj (caméra active) + lumière. (défaut : caméra identité + lumière blanche + mode lit). *(fait — 2026-09-16)*
- [ ] 4.2 **Buffers object par entité** : `Renderer` maintient un cache - [X] 4.2 **Buffers object par entité** : le `Renderer` maintient un cache
`RefCell<HashMap<String, (wgpu::Buffer, wgpu::BindGroup)>>` clefé par label d'entité (créé à la 1re rencontre), `RefCell<HashMap<String,(wgpu::Buffer, wgpu::BindGroup)>>` clefé par label d'entité ; chaque frame il
car `render_scene(&self, &Scene)` est immuable. Chaque frame : écrire `ObjectUniform.world = entity.transform.to_matrix()` + `set_bind_group(1, ...)`. écrit `ObjectUniform.world = entity.transform.to_matrix()` (via `object_bind_group_for`). *(fait — 2026-09-16)*
- [ ] 4.3 **Caméra active** : ajouter `scene.set_active_camera(Camera)` / `scene.active_camera() -> Option<&Camera>`. - [ ] 4.3 **Caméra active** : ajouter `scene.set_active_camera(Camera)` / `scene.active_camera() -> Option<&Camera>` ;
Calcul du `proj` avec l'aspect de la fenêtre (`window.inner_size()` accessible via `App.window`). écrire view/proj (avec aspect de la fenêtre) dans le buffer frame chaque frame. *(non fait — laisse le
- [ ] 4.4 **`draw_entity` étendu** : `set_bind_group(0, frame_bg)` + `set_bind_group(1, object_bg)` avant le draw, `FrameUniforms::default()` : simple/manual tournent toujours via `basic` qui ignore ces uniforms)*
pour **tout** matériau (layout unique). Le chemin bas-niveau `Renderer::render` pose aussi les 2 bind groups - [X] 4.4 **`draw_entity` étendu** : pose `set_bind_group(0, frame_bg)` + `set_bind_group(1, object_bg)` avant le
(frame partagé + object du mesh appelant). draw (groupes requis par le layout unique) ; le chemin bas-niveau `Renderer::render` pose aussi les 2 bind
- [ ] **Validation** : `cargo check` 0 warning ; exécution `simple` (sans panique, boucle active) ; groups (frame partagé + object identité partagé). *(fait — 2026-09-16)*
`manual` non-régressif (chemin bas-niveau). - [ ] **Validation** : `cargo check` 0 warning ; exécution `simple` (sans panique, boucle active). *(une partie :
`simple` reste exécutable car `basic` ignore les uniforms ; le rendu 3D réel attend 4.3)*
## Étape 5 — Exemple 3D (cube éclairé) ## Étape 5 — Exemple 3D (cube éclairé)
+1 -1
View File
@@ -11,5 +11,5 @@ wgpu = "30.0.0" # Vérifiez la version la plus récente
winit = "0.30.13" # For window management — pinned to match examples winit = "0.30.13" # For window management — pinned to match examples
thiserror = "2" thiserror = "2"
bytemuck = { version = "1.25.0", features = ["derive"] } bytemuck = { version = "1.25.0", features = ["derive"] }
glam = "0.33" glam = { version = "0.33", features = ["bytemuck"] } # feature requis pour Pod/Zeroable sur Mat4/Vec4 (uniform.rs)
pollster = { version="1.0.1", features = ["macro"] } pollster = { version="1.0.1", features = ["macro"] }
+135 -11
View File
@@ -20,12 +20,20 @@
use crate::core::Context; use crate::core::Context;
use crate::core::Frame; 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 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, /// 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 /// 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). /// 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 { pub struct Renderer {
/// GPU command submission queue — holds an Arc clone from Context; shared with other Context users. /// GPU command submission queue — holds an Arc clone from Context; shared with other Context users.
queue: wgpu::Queue, queue: wgpu::Queue,
@@ -33,19 +41,78 @@ pub struct Renderer {
device: wgpu::Device, device: wgpu::Device,
/// Surface texture output format — stored here so it can be passed to PipelineCache on Material creation. /// Surface texture output format — stored here so it can be passed to PipelineCache on Material creation.
format: wgpu::TextureFormat, 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 { 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). /// 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. /// 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; /// 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. /// Context retains ownership and can continue using them after this call.
pub fn new(context: &Context, format: wgpu::TextureFormat) -> Self { 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 { Self {
queue: context.queue.clone(), queue,
device: context.device.clone(), device,
format, format,
object_layout,
frame_bind_group,
shared_object_bind_group,
object_cache: RefCell::new(HashMap::new()),
} }
} }
@@ -81,7 +148,13 @@ impl Renderer {
..Default::default() ..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())); self.queue.submit(std::iter::once(encoder.finish()));
} }
@@ -113,8 +186,15 @@ impl Renderer {
..Default::default() ..Default::default()
}); });
for (_label, mesh, material, _transform) in scene.iter_entities() { for (label, mesh, material, transform) in scene.iter_entities() {
draw_entity(&mut render_pass, mesh, material); 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())); self.queue.submit(std::iter::once(encoder.finish()));
@@ -138,18 +218,62 @@ impl Renderer {
pub fn format(&self) -> wgpu::TextureFormat { pub fn format(&self) -> wgpu::TextureFormat {
self.format 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. /// Binds a Material pipeline, the two uniform bind groups, and Mesh buffers into an active render
/// Shared by `Renderer::render` and `Renderer::render_scene` to avoid duplicated draw logic. /// 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. /// 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). /// Inputs: pass (active render pass), mesh (geometry to draw), material (pipeline to bind),
fn draw_entity(pass: &mut wgpu::RenderPass<'_>, mesh: &Mesh, material: &Material) { /// 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 { if mesh.num_vertices == 0 {
// No vertices — nothing to render. // No vertices — nothing to render.
return; return;
} }
pass.set_pipeline(&material.pipeline); 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(..)); pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
if let Some(index_buffer) = &mesh.index_buffer { if let Some(index_buffer) = &mesh.index_buffer {
pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16); pass.set_index_buffer(index_buffer.slice(..), wgpu::IndexFormat::Uint16);
+1 -1
View File
@@ -11,4 +11,4 @@
pub mod pipeline_cache; pub mod pipeline_cache;
// Re-exports // Re-exports
pub use pipeline_cache::PipelineCache; pub use pipeline_cache::{PipelineCache, create_uniform_bind_group_layouts};
+45 -3
View File
@@ -21,6 +21,44 @@ use crate::utils::BASIC_SHADER;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
/// Creates the two bind group layouts shared by **every** pipeline (Étape 3 — décision actée
/// « un seul layout pour tous »). Both buffers are `Uniform`, 16-byte aligned, no dynamic offset.
/// Matching CPU types: `FrameUniforms` (192 B) and `ObjectUniform` (64 B) in `resources::uniform`.
/// Returns `[frame_layout, object_layout]` in renderer binding order.
///
/// - `index 0` : per-frame uniforms (view/proj/light/options), visible in both shader stages.
/// - `index 1` : per-object uniforms (model matrix), visible in the vertex stage only.
pub fn create_uniform_bind_group_layouts(device: &wgpu::Device) -> [wgpu::BindGroupLayout; 2] {
[
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
}),
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("object_uniform_layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
}),
]
}
/// Shader pipeline cache: maps (shader_id, format) keys to compiled RenderPipelines. /// Shader pipeline cache: maps (shader_id, format) keys to compiled RenderPipelines.
/// Ensures each unique shader+format combination is compiled at most once; subsequent requests return cached instances. /// Ensures each unique shader+format combination is compiled at most once; subsequent requests return cached instances.
pub struct PipelineCache { pub struct PipelineCache {
@@ -156,12 +194,16 @@ impl PipelineCache {
], ],
}; };
// Pipeline layout — defines bind group bindings (empty here; no uniform buffers used). // Pipeline layout — the two uniform bind groups (frame @0 + object @1) are attached
// wgpu 30: `immediate_size` replaces `push_constant_ranges`. // to EVERY pipeline (Étape 3, décision actée « un seul layout pour tous »), even if a
// given shader does not read them. `immediate_size` stays 0 (no var<immediate> used).
let bind_group_layouts = create_uniform_bind_group_layouts(device);
let layout_refs: Vec<Option<&wgpu::BindGroupLayout>> =
bind_group_layouts.iter().map(Some).collect();
let render_pipeline_layout = let render_pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("render_pipeline_layout"), label: Some("render_pipeline_layout"),
bind_group_layouts: &[], bind_group_layouts: &layout_refs,
immediate_size: 0, // no var<immediate> used immediate_size: 0, // no var<immediate> used
}); });
+2
View File
@@ -13,10 +13,12 @@
pub mod camera; pub mod camera;
pub mod material; pub mod material;
pub mod mesh; pub mod mesh;
pub mod uniform;
pub mod vertex; pub mod vertex;
// Re-exports // Re-exports
pub use camera::Camera; pub use camera::Camera;
pub use material::Material; pub use material::Material;
pub use mesh::Mesh; pub use mesh::Mesh;
pub use uniform::{FrameUniforms, ObjectUniform};
pub use vertex::Vertex; pub use vertex::Vertex;
+98
View File
@@ -0,0 +1,98 @@
//! # Uniform Module — GPU Buffer Data Types
//!
//! Defines the CPU-side `Pod` (plain old data) structs that are uploaded to GPU uniform buffers.
//! Their memory layout must match **exactly** the WGSL uniforms declared in `standard_shader.wgsl`
//! (see the "Uniform Contract" section of that file) — 16-byte alignment (std140), no padding.
//!
//! Two bind groups are shared by every pipeline (single-layout decision, Étape 3) :
//! - `@group(0) @binding(0)` : `FrameUniforms` (per-frame : camera + lights) → 192 bytes
//! - `@group(1) @binding(0)` : `ObjectUniform` (per-entity model matrix) → 64 bytes
//!
//! ## Interaction with Other Modules
//! - `pipeline_cache::build_pipeline()` creates the two bind group layouts matching these types.
//! - `Renderer` allocates the buffers and `BindGroup`s from these types and writes them each frame.
//! - `standard_shader.wgsl` consumes them (layout identical to these structs).
use bytemuck::{Pod, Zeroable};
use glam::{Mat4, Vec4};
/// Byte size of the per-frame uniform buffer (`FrameUniforms`).
pub const FRAME_UNIFORMS_SIZE: u64 = std::mem::size_of::<FrameUniforms>() as u64;
/// Byte size of the per-object uniform buffer (`ObjectUniform`).
pub const OBJECT_UNIFORM_SIZE: u64 = std::mem::size_of::<ObjectUniform>() as u64;
/// Per-frame GPU uniforms : camera matrices + directional light + options.
///
/// Mirrors the WGSL `FrameUniforms` struct in `standard_shader.wgsl` (offset table there).
/// 192 bytes, 16-byte aligned, no padding — `Pod` for direct `bytes_of` upload.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable)]
pub struct FrameUniforms {
/// Camera view matrix (world → view space). Offset 0.
pub view: Mat4,
/// Camera projection matrix (view → clip space). Offset 64.
pub proj: Mat4,
/// Camera world position (`.xyz` used). Offset 128.
pub cam_pos: Vec4,
/// Directional light direction : points **from the surface toward the light**. Offset 144.
pub light_dir: Vec4,
/// Directional light color (`.rgb` used). Offset 160.
pub light_color: Vec4,
/// Options. `options[0]` = unlit flag (1 → flat color, no directional lighting). Offset 176.
pub options: [u32; 4],
}
impl Default for FrameUniforms {
/// Sensible defaults : identity camera, white light along +Z, *lit* mode.
fn default() -> Self {
Self {
view: Mat4::IDENTITY,
proj: Mat4::IDENTITY,
cam_pos: Vec4::ZERO,
light_dir: Vec4::new(0.0, 0.0, 1.0, 0.0),
light_color: Vec4::ONE,
options: [0, 0, 0, 0],
}
}
}
/// Per-object GPU uniforms : the entity's world-space model matrix.
///
/// Mirrors the WGSL `ObjectUniform` struct. 64 bytes, `Pod`.
#[repr(C)]
#[derive(Clone, Copy, Pod, Zeroable, Default)]
pub struct ObjectUniform {
/// Model matrix (object → world space). Offset 0.
pub model: Mat4,
}
#[cfg(test)]
mod tests {
use super::*;
use std::mem::offset_of;
use std::mem::{align_of, size_of};
#[test]
fn frame_uniforms_layout_matches_wgsl() {
// The offsets below must match the offset table in standard_shader.wgsl.
assert_eq!(size_of::<FrameUniforms>(), 192);
assert_eq!(align_of::<FrameUniforms>(), 16);
let f = FrameUniforms::default();
assert_eq!(offset_of!(FrameUniforms, view), 0);
assert_eq!(offset_of!(FrameUniforms, proj), 64);
assert_eq!(offset_of!(FrameUniforms, cam_pos), 128);
assert_eq!(offset_of!(FrameUniforms, light_dir), 144);
assert_eq!(offset_of!(FrameUniforms, light_color), 160);
assert_eq!(offset_of!(FrameUniforms, options), 176);
// Default is lit mode (unlit flag cleared).
assert_eq!(f.options[0], 0);
}
#[test]
fn object_uniform_layout_matches_wgsl() {
assert_eq!(size_of::<ObjectUniform>(), 64);
assert_eq!(align_of::<ObjectUniform>(), 16);
assert_eq!(offset_of!(ObjectUniform, model), 0);
}
}