Skip to content

Commit 7f8375d

Browse files
committed
[add] instanced rendering validation & update specification/features document.
1 parent b1f0509 commit 7f8375d

File tree

6 files changed

+220
-12
lines changed

6 files changed

+220
-12
lines changed

crates/lambda-rs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ render-validation-stencil = []
7373
render-validation-pass-compat = []
7474
render-validation-device = []
7575
render-validation-encoder = []
76+
render-instancing-validation = []
7677

7778

7879
# ---------------------------- PLATFORM DEPENDENCIES ---------------------------

crates/lambda-rs/src/render/mod.rs

Lines changed: 105 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,12 +621,19 @@ impl RenderContext {
621621
{
622622
Self::apply_viewport(pass, &initial_viewport);
623623

624-
#[cfg(any(debug_assertions, feature = "render-validation-encoder",))]
624+
#[cfg(any(
625+
debug_assertions,
626+
feature = "render-validation-encoder",
627+
feature = "render-instancing-validation",
628+
))]
625629
let mut current_pipeline: Option<usize> = None;
626630

627631
#[cfg(any(debug_assertions, feature = "render-validation-encoder",))]
628632
let mut bound_index_buffer: Option<(usize, u32)> = None;
629633

634+
#[cfg(any(debug_assertions, feature = "render-instancing-validation",))]
635+
let mut bound_vertex_slots: HashSet<u32> = HashSet::new();
636+
630637
// De-duplicate advisories within this pass
631638
#[cfg(any(
632639
debug_assertions,
@@ -689,8 +696,12 @@ impl RenderContext {
689696
}
690697

691698
// Keep track of the current pipeline to ensure that draw calls
692-
// happen only after a pipeline is set.
693-
#[cfg(any(debug_assertions, feature = "render-validation-encoder",))]
699+
// happen only after a pipeline is set when validation is enabled.
700+
#[cfg(any(
701+
debug_assertions,
702+
feature = "render-validation-encoder",
703+
feature = "render-instancing-validation",
704+
))]
694705
{
695706
current_pipeline = Some(pipeline);
696707
}
@@ -792,6 +803,14 @@ impl RenderContext {
792803
));
793804
})?;
794805

806+
#[cfg(any(
807+
debug_assertions,
808+
feature = "render-instancing-validation",
809+
))]
810+
{
811+
bound_vertex_slots.insert(buffer);
812+
}
813+
795814
pass.set_vertex_buffer(buffer as u32, buffer_ref.raw());
796815
}
797816
RenderCommand::BindIndexBuffer { buffer, format } => {
@@ -863,14 +882,54 @@ impl RenderContext {
863882
vertices,
864883
instances,
865884
} => {
866-
#[cfg(any(debug_assertions, feature = "render-validation-encoder",))]
885+
#[cfg(any(
886+
debug_assertions,
887+
feature = "render-validation-encoder",
888+
feature = "render-instancing-validation",
889+
))]
867890
{
868891
if current_pipeline.is_none() {
869892
return Err(RenderError::Configuration(
870893
"Draw command encountered before any pipeline was set in this render pass"
871894
.to_string(),
872895
));
873896
}
897+
898+
#[cfg(any(
899+
debug_assertions,
900+
feature = "render-instancing-validation",
901+
))]
902+
{
903+
let pipeline_index = current_pipeline.expect(
904+
"current_pipeline must be set when validation is active",
905+
);
906+
let pipeline_ref = &self.render_pipelines[pipeline_index];
907+
908+
validation::validate_instance_bindings(
909+
pipeline_ref.pipeline().label().unwrap_or("unnamed"),
910+
pipeline_ref.per_instance_slots(),
911+
&bound_vertex_slots,
912+
)
913+
.map_err(RenderError::Configuration)?;
914+
}
915+
916+
if let Err(msg) =
917+
validation::validate_instance_range("Draw", &instances)
918+
{
919+
return Err(RenderError::Configuration(msg));
920+
}
921+
if instances.start == instances.end {
922+
#[cfg(any(
923+
debug_assertions,
924+
feature = "render-instancing-validation",
925+
))]
926+
logging::debug!(
927+
"Skipping Draw with empty instance range {}..{}",
928+
instances.start,
929+
instances.end
930+
);
931+
continue;
932+
}
874933
}
875934
pass.draw(vertices, instances);
876935
}
@@ -914,6 +973,48 @@ impl RenderContext {
914973
)));
915974
}
916975
}
976+
#[cfg(any(
977+
debug_assertions,
978+
feature = "render-validation-encoder",
979+
feature = "render-instancing-validation",
980+
))]
981+
{
982+
#[cfg(any(
983+
debug_assertions,
984+
feature = "render-instancing-validation",
985+
))]
986+
{
987+
let pipeline_index = current_pipeline.expect(
988+
"current_pipeline must be set when validation is active",
989+
);
990+
let pipeline_ref = &self.render_pipelines[pipeline_index];
991+
992+
validation::validate_instance_bindings(
993+
pipeline_ref.pipeline().label().unwrap_or("unnamed"),
994+
pipeline_ref.per_instance_slots(),
995+
&bound_vertex_slots,
996+
)
997+
.map_err(RenderError::Configuration)?;
998+
}
999+
1000+
if let Err(msg) =
1001+
validation::validate_instance_range("DrawIndexed", &instances)
1002+
{
1003+
return Err(RenderError::Configuration(msg));
1004+
}
1005+
if instances.start == instances.end {
1006+
#[cfg(any(
1007+
debug_assertions,
1008+
feature = "render-instancing-validation",
1009+
))]
1010+
logging::debug!(
1011+
"Skipping DrawIndexed with empty instance range {}..{}",
1012+
instances.start,
1013+
instances.end
1014+
);
1015+
continue;
1016+
}
1017+
}
9171018
pass.draw_indexed(indices, base_vertex, instances);
9181019
}
9191020
RenderCommand::BeginRenderPass { .. } => {

crates/lambda-rs/src/render/pipeline.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub struct RenderPipeline {
6565
color_target_count: u32,
6666
expects_depth_stencil: bool,
6767
uses_stencil: bool,
68+
per_instance_slots: Vec<bool>,
6869
}
6970

7071
impl RenderPipeline {
@@ -100,6 +101,11 @@ impl RenderPipeline {
100101
pub(super) fn uses_stencil(&self) -> bool {
101102
return self.uses_stencil;
102103
}
104+
105+
/// Per-vertex-buffer flags indicating which slots advance per instance.
106+
pub(super) fn per_instance_slots(&self) -> &Vec<bool> {
107+
return &self.per_instance_slots;
108+
}
103109
}
104110

105111
/// Public alias for platform shader stage flags used by push constants.
@@ -519,6 +525,7 @@ impl RenderPipelineBuilder {
519525

520526
// Vertex buffers and attributes
521527
let mut buffers = Vec::with_capacity(self.bindings.len());
528+
let mut per_instance_slots = Vec::with_capacity(self.bindings.len());
522529
let mut rp_builder = platform_pipeline::RenderPipelineBuilder::new()
523530
.with_label(self.label.as_deref().unwrap_or("lambda-render-pipeline"))
524531
.with_layout(&pipeline_layout)
@@ -541,6 +548,10 @@ impl RenderPipelineBuilder {
541548
attributes,
542549
);
543550
buffers.push(binding.buffer.clone());
551+
per_instance_slots.push(matches!(
552+
binding.layout.step_mode,
553+
VertexStepMode::PerInstance
554+
));
544555
}
545556

546557
if fragment_module.is_some() {
@@ -641,6 +652,7 @@ impl RenderPipelineBuilder {
641652
// Depth/stencil is enabled when `with_depth*` was called on the builder.
642653
expects_depth_stencil: self.use_depth,
643654
uses_stencil: self.stencil.is_some(),
655+
per_instance_slots,
644656
};
645657
}
646658
}

crates/lambda-rs/src/render/validation.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
//! Small helpers for limits and alignment validation used by the renderer.
22
3+
use std::{
4+
collections::HashSet,
5+
ops::Range,
6+
};
7+
38
/// Align `value` up to the nearest multiple of `align`.
49
/// If `align` is zero, returns `value` unchanged.
510
pub fn align_up(value: u64, align: u64) -> u64 {
@@ -54,6 +59,47 @@ pub fn validate_sample_count(samples: u32) -> Result<(), String> {
5459
}
5560
}
5661

62+
/// Validate that an instance range is well-formed for a draw command.
63+
///
64+
/// The `command_name` is included in any error message to make diagnostics
65+
/// easier to interpret when multiple draw commands are present.
66+
pub fn validate_instance_range(
67+
command_name: &str,
68+
instances: &Range<u32>,
69+
) -> Result<(), String> {
70+
if instances.start > instances.end {
71+
return Err(format!(
72+
"{} instance range start {} is greater than end {}",
73+
command_name, instances.start, instances.end
74+
));
75+
}
76+
return Ok(());
77+
}
78+
79+
/// Validate that all per-instance vertex buffer slots have been bound before
80+
/// issuing a draw that consumes them.
81+
///
82+
/// The `pipeline_label` identifies the pipeline in diagnostics. The
83+
/// `per_instance_slots` slice marks which vertex buffer slots advance once
84+
/// per instance, while `bound_slots` tracks the vertex buffer slots that
85+
/// have been bound in the current render pass.
86+
pub fn validate_instance_bindings(
87+
pipeline_label: &str,
88+
per_instance_slots: &[bool],
89+
bound_slots: &HashSet<u32>,
90+
) -> Result<(), String> {
91+
for (slot, is_instance) in per_instance_slots.iter().enumerate() {
92+
if *is_instance && !bound_slots.contains(&(slot as u32)) {
93+
return Err(format!(
94+
"Render pipeline '{}' requires a per-instance vertex buffer bound at slot {} but no BindVertexBuffer command bound that slot in this pass",
95+
pipeline_label,
96+
slot
97+
));
98+
}
99+
}
100+
return Ok(());
101+
}
102+
57103
#[cfg(test)]
58104
mod tests {
59105
use super::*;
@@ -89,4 +135,47 @@ mod tests {
89135
.unwrap();
90136
assert!(err.contains("not 256-byte aligned"));
91137
}
138+
139+
#[test]
140+
fn validate_instance_range_accepts_valid_ranges() {
141+
assert!(validate_instance_range("Draw", &(0..1)).is_ok());
142+
assert!(validate_instance_range("DrawIndexed", &(2..2)).is_ok());
143+
}
144+
145+
#[test]
146+
fn validate_instance_range_rejects_negative_length() {
147+
let err = validate_instance_range("Draw", &(5..1))
148+
.err()
149+
.expect("must error");
150+
assert!(err.contains("Draw instance range start 5 is greater than end 1"));
151+
}
152+
153+
#[test]
154+
fn validate_instance_bindings_accepts_bound_slots() {
155+
let per_instance_slots = vec![true, false, true];
156+
let mut bound = HashSet::new();
157+
bound.insert(0);
158+
bound.insert(2);
159+
160+
assert!(validate_instance_bindings(
161+
"test-pipeline",
162+
&per_instance_slots,
163+
&bound
164+
)
165+
.is_ok());
166+
}
167+
168+
#[test]
169+
fn validate_instance_bindings_rejects_missing_slot() {
170+
let per_instance_slots = vec![true, false, true];
171+
let mut bound = HashSet::new();
172+
bound.insert(0);
173+
174+
let err =
175+
validate_instance_bindings("instanced", &per_instance_slots, &bound)
176+
.err()
177+
.expect("must error");
178+
assert!(err.contains("instanced"));
179+
assert!(err.contains("slot 2"));
180+
}
92181
}

docs/features.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ title: "Cargo Features Overview"
33
document_id: "features-2025-11-17"
44
status: "living"
55
created: "2025-11-17T23:59:00Z"
6-
last_updated: "2025-11-17T23:59:00Z"
7-
version: "0.1.0"
6+
last_updated: "2025-11-25T00:00:00Z"
7+
version: "0.1.1"
88
engine_workspace_version: "2023.1.30"
99
wgpu_version: "26.0.1"
1010
shader_backend_default: "naga"
1111
winit_version: "0.29.10"
12-
repo_commit: "70670f8ad6bb7ac14a62e7d5847bf21cfe13f665"
12+
repo_commit: "b1f0509d245065823dff2721f97e16c0215acc4f"
1313
owners: ["lambda-sh"]
1414
reviewers: ["engine", "rendering"]
1515
tags: ["guide", "features", "validation", "cargo"]
@@ -57,6 +57,10 @@ Granular features (crate: `lambda-rs`)
5757
- `render-validation-pass-compat`: SetPipeline-time errors when color targets or depth/stencil expectations do not match the active pass.
5858
- `render-validation-device`: device/format probing advisories (if available via the platform layer).
5959
- `render-validation-encoder`: additional per-draw/encoder-time checks; highest runtime cost.
60+
- `render-instancing-validation`: instance-range and per-instance buffer binding validation for `RenderCommand::Draw` and `RenderCommand::DrawIndexed`. Behavior:
61+
- Validates that `instances.start <= instances.end` and treats `start == end` as a no-op (draw is skipped).
62+
- Ensures that all vertex buffer slots marked as per-instance on the active pipeline have been bound in the current render pass.
63+
- Adds per-draw checks proportional to the number of instanced draws and per-instance slots; SHOULD be enabled only when diagnosing instancing issues.
6064

6165
Always-on safeguards (debug and release)
6266
- Clamp depth clear values to `[0.0, 1.0]`.
@@ -76,4 +80,5 @@ Usage examples
7680
- `cargo test -p lambda-rs --features render-validation-msaa`
7781

7882
## Changelog
83+
- 0.1.1 (2025-11-25): Document `render-instancing-validation` behavior and update metadata.
7984
- 0.1.0 (2025-11-17): Initial document introducing validation features and behavior by build type.

docs/specs/instanced-rendering.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ engine_workspace_version: "2023.1.30"
99
wgpu_version: "26.0.1"
1010
shader_backend_default: "naga"
1111
winit_version: "0.29.10"
12-
repo_commit: "84c73fbac8ce660827189fda1de96e50b5c8a9d5"
12+
repo_commit: "b1f0509d245065823dff2721f97e16c0215acc4f"
1313
owners: ["lambda-sh"]
1414
reviewers: ["engine", "rendering"]
1515
tags: ["spec", "rendering", "instancing", "vertex-input"]
@@ -302,11 +302,11 @@ App Code
302302
- [x] `VertexStepMode` exposed in `lambda-rs` and `lambda-rs-platform`.
303303
- [x] `RenderPipelineBuilder` in `lambda-rs` supports explicit per-instance
304304
buffers via `with_buffer_step_mode` and `with_instance_buffer`.
305-
- [ ] Instancing validation feature flag defined in `lambda-rs`.
305+
- [x] Instancing validation feature flag defined in `lambda-rs`.
306306
- Validation and Errors
307307
- [ ] Command ordering checks cover instanced draws.
308-
- [ ] Instance range validation implemented and feature-gated.
309-
- [ ] Buffer binding diagnostics cover per-instance attributes.
308+
- [x] Instance range validation implemented and feature-gated.
309+
- [x] Buffer binding diagnostics cover per-instance attributes.
310310
- Performance
311311
- [ ] Critical instanced draw paths reasoned about or profiled.
312312
- [ ] Memory usage for instance buffers characterized for example scenes.
@@ -369,6 +369,6 @@ relevant code and tests, for example in the pull request description.
369369

370370
## Changelog
371371

372-
- 2025-11-25 (v0.1.2) — Update terminology to reference crates by name and remove per-file implementation locations from the Requirements Checklist.
372+
- 2025-11-25 (v0.1.2) — Update terminology to reference crates by name, remove per-file implementation locations from the Requirements Checklist, and mark instancing validation features as implemented in `lambda-rs`.
373373
- 2025-11-24 (v0.1.1) — Mark initial instancing layout and step mode support as implemented in the Requirements Checklist; metadata updated.
374374
- 2025-11-23 (v0.1.0) — Initial draft of instanced rendering specification.

0 commit comments

Comments
 (0)