| title | document_id | status | created | last_updated | version | engine_workspace_version | wgpu_version | shader_backend_default | winit_version | repo_commit | owners | reviewers | tags | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
Reflective Floor: Stencil‑Masked Planar Reflections |
reflective-room-tutorial-2025-11-17 |
draft |
2025-11-17T00:00:00Z |
2026-02-07T00:00:00Z |
0.4.5 |
2023.1.30 |
28.0.0 |
naga |
0.29.10 |
544444652b4dc3639f8b3e297e56c302183a7a0b |
|
|
|
This tutorial builds a reflective floor using the stencil buffer with an optional depth test and 4× multi‑sample anti‑aliasing (MSAA). The scene renders in four phases: a floor mask into stencil, a mirrored cube clipped by the mask, a translucent lit floor surface, and a normal cube above the plane. The camera looks down at a moderate angle so the reflection is clearly visible.
Reference implementation: demos/render/src/bin/reflective_room.rs.
- Overview
- Goals
- Prerequisites
- Requirements and Constraints
- Data Flow
- Implementation Steps
- Step 1 — Runtime and Component Skeleton
- Step 2 — Shaders and Immediates
- Step 3 — Meshes: Cube and Floor
- Step 4 — Render Passes: Mask and Color
- Step 5 — Pipeline: Floor Mask (Stencil Write)
- Step 6 — Pipeline: Reflected Cube (Stencil Test)
- Step 7 — Pipeline: Floor Visual (Tinted)
- Step 8 — Pipeline: Normal Cube
- Step 9 — Per‑Frame Transforms and Reflection
- Step 10 — Record Commands and Draw Order
- Step 11 — Input, MSAA/Depth/Stencil Toggles, and Resize
- Validation
- Notes
- Conclusion
- Putting It Together
- Exercises
- Changelog
- Use the stencil buffer to restrict rendering to the floor area and show a mirrored reflection of a cube.
- Support depth testing and 4× MSAA to improve geometric correctness and edge quality.
- Drive transforms via immediates for model‑view‑projection (MVP) and model matrices.
- Provide runtime toggles for MSAA, stencil, and depth testing, plus camera pitch and visibility helpers.
- Build the workspace:
cargo build --workspace. - Run the minimal demo to verify setup:
cargo run -p lambda-demos-minimal --bin minimal.
- A pipeline that uses stencil state MUST render into a pass with a depth‑stencil attachment. Use
DepthFormat::Depth24PlusStencil8. - The mask pass MUST disable depth writes and write stencil with
Replaceso the floor area becomes1. - The reflected cube pipeline MUST test stencil
Equalagainst reference1and SHOULD set stencil write mask to0x00. - Mirroring across the floor plane flips face winding. Culling MUST be disabled for the reflected draw or the front‑face definition MUST be adjusted. This example culls front faces for the reflected cube.
- Immediate data size MUST match the shader declaration. Two
mat4values are sent (128 bytes total).
Note: In wgpu v28, push constants were renamed to "immediates" and require the
Features::IMMEDIATESfeature. The GLSL syntax remainspush_constant.
- Matrix order MUST match the shader’s expectation. The example transposes matrices before upload to match GLSL column‑major multiplication.
- The render pass and pipelines MUST use the same sample count when MSAA is enabled.
- Acronyms: graphics processing unit (GPU), central processing unit (CPU), multi‑sample anti‑aliasing (MSAA), model‑view‑projection (MVP).
CPU (meshes, elapsed time, toggles)
│
├─ Build/attach render passes (mask, color) with MSAA
├─ Build pipelines (mask → reflected → floor → normal)
▼
Pass 1: Depth/Stencil‑only (no color) — write stencil where floor covers
│ stencil = 1 inside floor, 0 elsewhere; depth write off
▼
Pass 2: Color (with depth/stencil) — draw reflected cube with stencil test == 1
│ culling front faces; depth compare configurable
▼
Pass 3: Color — draw tinted floor (alpha) to show reflection
▼
Pass 4: Color — draw normal cube above the floor
Define a Component that owns shaders, meshes, render passes, pipelines, window size, elapsed time, and user‑toggleable settings for MSAA, stencil, and depth testing.
use lambda::{
component::Component,
runtimes::{application::ComponentResult, ApplicationRuntimeBuilder},
};
pub struct ReflectiveRoomExample {
shader_vs: lambda::render::shader::Shader,
shader_fs_lit: lambda::render::shader::Shader,
shader_fs_floor: lambda::render::shader::Shader,
cube_mesh: Option<lambda::render::mesh::Mesh>,
floor_mesh: Option<lambda::render::mesh::Mesh>,
pass_id_mask: Option<lambda::render::ResourceId>,
pass_id_color: Option<lambda::render::ResourceId>,
pipe_floor_mask: Option<lambda::render::ResourceId>,
pipe_reflected: Option<lambda::render::ResourceId>,
pipe_floor_visual: Option<lambda::render::ResourceId>,
pipe_normal: Option<lambda::render::ResourceId>,
width: u32,
height: u32,
elapsed: f32,
msaa_samples: u32,
stencil_enabled: bool,
depth_test_enabled: bool,
needs_rebuild: bool,
}
impl Default for ReflectiveRoomExample { /* create shaders; set defaults */ }
impl Component<ComponentResult, String> for ReflectiveRoomExample { /* lifecycle */ }Narrative: The component stores GPU handles and toggles. When settings change, mark needs_rebuild = true and rebuild pipelines/passes on the next frame.
Use one vertex shader and two fragment shaders. The vertex shader expects immediates with two mat4 values: the MVP and the model matrix, used to transform positions and rotate normals to world space. The floor fragment shader is lit and translucent so the reflection reads beneath it.
Note: In wgpu v28, push constants were renamed to "immediates" and gated behind
Features::IMMEDIATES. The GLSL syntax remainslayout(push_constant).
// Vertex (GLSL 450)
layout (location = 0) in vec3 vertex_position;
layout (location = 1) in vec3 vertex_normal;
layout (location = 0) out vec3 v_world_normal;
layout ( push_constant ) uniform Push { mat4 mvp; mat4 model; } pc;
void main() {
gl_Position = pc.mvp * vec4(vertex_position, 1.0);
// Transform normals by the model matrix; sufficient for rigid + mirror.
v_world_normal = mat3(pc.model) * vertex_normal;
}// Fragment (lit)
layout (location = 0) in vec3 v_world_normal;
layout (location = 0) out vec4 fragment_color;
void main() {
vec3 N = normalize(v_world_normal);
vec3 L = normalize(vec3(0.4, 0.7, 1.0));
float diff = max(dot(N, L), 0.0);
vec3 base = vec3(0.2, 0.6, 0.9);
fragment_color = vec4(base * (0.25 + 0.75 * diff), 1.0);
}// Fragment (floor: lit + translucent)
layout (location = 0) in vec3 v_world_normal;
layout (location = 0) out vec4 fragment_color;
void main() {
vec3 N = normalize(v_world_normal);
vec3 L = normalize(vec3(0.4, 0.7, 1.0));
float diff = max(dot(N, L), 0.0);
vec3 base = vec3(0.10, 0.10, 0.11);
vec3 color = base * (0.35 + 0.65 * diff);
fragment_color = vec4(color, 0.15);
}In Rust, pack immediate data as 32‑bit words and transpose matrices before upload.
#[repr(C)]
pub struct ImmediateData { pub mvp: [[f32; 4]; 4], pub model: [[f32; 4]; 4] }
pub fn immediate_data_to_words(data: &ImmediateData) -> &[u32] {
unsafe {
let size = std::mem::size_of::<ImmediateData>() / std::mem::size_of::<u32>();
let ptr = data as *const ImmediateData as *const u32;
return std::slice::from_raw_parts(ptr, size);
}
}Build a unit cube (36 vertices) with per‑face normals and a large XZ floor quad at y = 0. Provide matching vertex attributes for position and normal at locations 0 and 1.
Reference: demos/render/src/bin/reflective_room.rs:740 and demos/render/src/bin/reflective_room.rs:807.
Create a depth/stencil‑only pass for the floor mask and a color pass for the scene. Use the same sample count on both.
use lambda::render::render_pass::RenderPassBuilder;
let pass_mask = RenderPassBuilder::new()
.with_label("reflective-room-pass-mask")
.with_depth_clear(1.0)
.with_stencil_clear(0)
.with_multi_sample(msaa_samples)
.without_color() // no color target
.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
);
let pass_color = RenderPassBuilder::new()
.with_label("reflective-room-pass-color")
.with_multi_sample(msaa_samples)
.with_depth_clear(1.0) // or .with_depth_load() when depth test is off
.with_stencil_load() // preserve mask from pass 1
.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
);Rationale: pipelines that use stencil require a depth‑stencil attachment, even if depth testing is disabled.
Draw the floor geometry to write stencil = 1 where the floor covers. Do not write to color. Disable depth writes and set depth compare to Always.
use lambda::render::pipeline::{RenderPipelineBuilder, CompareFunction, StencilState, StencilFaceState, StencilOperation};
let pipe_floor_mask = RenderPipelineBuilder::new()
.with_label("floor-mask")
.with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8)
.with_depth_write(false)
.with_depth_compare(CompareFunction::Always)
.with_immediate_data(std::mem::size_of::<ImmediateData>() as u32)
.with_buffer(floor_vertex_buffer, floor_attributes)
.with_stencil(StencilState {
front: StencilFaceState { compare: CompareFunction::Always, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Replace },
back: StencilFaceState { compare: CompareFunction::Always, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Replace },
read_mask: 0xFF, write_mask: 0xFF,
})
.with_multi_sample(msaa_samples)
.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
&pass_mask,
&shader_vs,
None,
);Render the mirrored cube only where the floor mask is present. Mirroring flips the winding, so cull front faces for the reflected draw. Use depth_compare = Always and disable depth writes so the reflection remains visible; the stencil confines it to the floor.
let mut builder = RenderPipelineBuilder::new()
.with_label("reflected-cube")
.with_culling(lambda::render::pipeline::CullingMode::Front)
.with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8)
.with_immediate_data(std::mem::size_of::<ImmediateData>() as u32)
.with_buffer(cube_vertex_buffer, cube_attributes)
.with_stencil(StencilState {
front: StencilFaceState { compare: CompareFunction::Equal, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Keep },
back: StencilFaceState { compare: CompareFunction::Equal, fail_op: StencilOperation::Keep, depth_fail_op: StencilOperation::Keep, pass_op: StencilOperation::Keep },
read_mask: 0xFF, write_mask: 0x00,
})
.with_multi_sample(msaa_samples)
.with_depth_write(false)
.with_depth_compare(CompareFunction::Always);
let pipe_reflected = builder.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
&pass_color,
&shader_vs,
Some(&shader_fs_lit),
);Draw the floor surface with a translucent tint so the reflection remains visible beneath.
let mut floor_vis = RenderPipelineBuilder::new()
.with_label("floor-visual")
.with_blend(lambda::render::pipeline::BlendMode::AlphaBlending)
.with_immediate_data(std::mem::size_of::<ImmediateData>() as u32)
.with_buffer(floor_vertex_buffer, floor_attributes)
.with_multi_sample(msaa_samples);
if depth_test_enabled || stencil_enabled {
floor_vis = floor_vis
.with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8)
.with_depth_write(false)
.with_depth_compare(if depth_test_enabled { CompareFunction::LessEqual } else { CompareFunction::Always });
}
let pipe_floor_visual = floor_vis.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
&pass_color,
&shader_vs,
Some(&shader_fs_floor),
);Draw the unreflected cube above the floor using the lit fragment shader. Enable back‑face culling and depth testing when requested.
let mut normal = RenderPipelineBuilder::new()
.with_label("cube-normal")
.with_immediate_data(std::mem::size_of::<ImmediateData>() as u32)
.with_buffer(cube_vertex_buffer, cube_attributes)
.with_multi_sample(msaa_samples);
if depth_test_enabled || stencil_enabled {
normal = normal
.with_depth_format(lambda::render::texture::DepthFormat::Depth24PlusStencil8)
.with_depth_write(depth_test_enabled)
.with_depth_compare(if depth_test_enabled { CompareFunction::Less } else { CompareFunction::Always });
}
let pipe_normal = normal.build(
ctx.gpu(),
ctx.surface_format(),
ctx.depth_format(),
&pass_color,
&shader_vs,
Some(&shader_fs_lit),
);Compute camera, model rotation, and the mirror transform across the floor plane. The camera pitches downward and translates to a higher vantage point. Build the mirror using the plane‑reflection matrix R = I − 2 n n^T for a plane through the origin with unit normal n (for a flat floor, n = (0,1,0)).
use lambda::render::scene_math::{compute_perspective_projection, compute_view_matrix, SimpleCamera};
let camera = SimpleCamera { position: [0.0, 3.0, 4.0], field_of_view_in_turns: 0.24, near_clipping_plane: 0.1, far_clipping_plane: 100.0 };
// View = R_x(-pitch) * T(-position)
let pitch_turns = 0.10; // ~36 degrees downward
let rot_x = lambda::math::matrix::rotate_matrix(lambda::math::matrix::identity_matrix(4,4), [1.0,0.0,0.0], -pitch_turns)
.expect("rotation axis must be a unit axis vector");
let view = rot_x.multiply(&compute_view_matrix(camera.position));
let projection = compute_perspective_projection(camera.field_of_view_in_turns, width.max(1), height.max(1), camera.near_clipping_plane, camera.far_clipping_plane);
let angle_y = 0.12 * elapsed;
let mut model = lambda::math::matrix::identity_matrix(4, 4);
model = lambda::math::matrix::rotate_matrix(model, [0.0, 1.0, 0.0], angle_y)
.expect("rotation axis must be a unit axis vector");
model = model.multiply(&lambda::math::matrix::translation_matrix([0.0, 0.5, 0.0]));
let mvp = projection.multiply(&view).multiply(&model);
let n = [0.0f32, 1.0, 0.0];
let (nx, ny, nz) = (n[0], n[1], n[2]);
let mirror = [
[1.0 - 2.0*nx*nx, -2.0*nx*ny, -2.0*nx*nz, 0.0],
[-2.0*ny*nx, 1.0 - 2.0*ny*ny, -2.0*ny*nz, 0.0],
[-2.0*nz*nx, -2.0*nz*ny, 1.0 - 2.0*nz*nz, 0.0],
[0.0, 0.0, 0.0, 1.0],
];
let model_reflect = mirror.multiply(&model);
let mvp_reflect = projection.multiply(&view).multiply(&model_reflect);Record commands in the following order. Set viewport and scissor to the window dimensions.
use lambda::render::command::RenderCommand;
let mut cmds: Vec<RenderCommand> = Vec::new();
// Pass 1: floor stencil mask
cmds.push(RenderCommand::BeginRenderPass { render_pass: pass_id_mask, viewport });
cmds.push(RenderCommand::SetPipeline { pipeline: pipe_floor_mask });
cmds.push(RenderCommand::SetStencilReference { reference: 1 });
cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_floor_mask, buffer: 0 });
cmds.push(RenderCommand::Immediates { pipeline: pipe_floor_mask, offset: 0, bytes: Vec::from(immediate_data_to_words(&ImmediateData { mvp: mvp_floor.transpose(), model: model_floor.transpose() })) });
cmds.push(RenderCommand::Draw { vertices: 0..floor_vertex_count, instances: 0..1 });
cmds.push(RenderCommand::EndRenderPass);
// Pass 2: reflected cube (stencil test == 1)
cmds.push(RenderCommand::BeginRenderPass { render_pass: pass_id_color, viewport });
cmds.push(RenderCommand::SetPipeline { pipeline: pipe_reflected });
cmds.push(RenderCommand::SetStencilReference { reference: 1 });
cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_reflected, buffer: 0 });
cmds.push(RenderCommand::Immediates { pipeline: pipe_reflected, offset: 0, bytes: Vec::from(immediate_data_to_words(&ImmediateData { mvp: mvp_reflect.transpose(), model: model_reflect.transpose() })) });
cmds.push(RenderCommand::Draw { vertices: 0..cube_vertex_count, instances: 0..1 });
// Pass 3: floor visual (tinted)
cmds.push(RenderCommand::SetPipeline { pipeline: pipe_floor_visual });
cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_floor_visual, buffer: 0 });
cmds.push(RenderCommand::Immediates { pipeline: pipe_floor_visual, offset: 0, bytes: Vec::from(immediate_data_to_words(&ImmediateData { mvp: mvp_floor.transpose(), model: model_floor.transpose() })) });
cmds.push(RenderCommand::Draw { vertices: 0..floor_vertex_count, instances: 0..1 });
// Pass 4: normal cube
cmds.push(RenderCommand::SetPipeline { pipeline: pipe_normal });
cmds.push(RenderCommand::BindVertexBuffer { pipeline: pipe_normal, buffer: 0 });
cmds.push(RenderCommand::Immediates { pipeline: pipe_normal, offset: 0, bytes: Vec::from(immediate_data_to_words(&ImmediateData { mvp: mvp.transpose(), model: model.transpose() })) });
cmds.push(RenderCommand::Draw { vertices: 0..cube_vertex_count, instances: 0..1 });
cmds.push(RenderCommand::EndRenderPass);Support runtime toggles to observe the impact of each setting:
Mtoggles MSAA between1×and4×. Rebuild passes and pipelines when it changes.Stoggles the stencil reflection. When disabled, the example skips the mask and reflected draw.Dtoggles depth testing. When disabled, set depth compare toAlwaysand disable depth writes on pipelines.Ftoggles the floor overlay (mirror mode). When enabled, the reflection shows without the translucent floor surface.IandKadjust the camera pitch up/down in small steps.- On window resize, update stored
widthandheightand use them when computing the viewport and projection matrix.
Reference: demos/render/src/bin/reflective_room.rs:164.
Implement resize and toggles using event_mask() and on_*_event handlers.
use lambda::events::{EventMask, Key, VirtualKey, WindowEvent};
// Inside `impl Component<ComponentResult, String> for ReflectiveRoomExample`.
fn event_mask(&self) -> EventMask {
return EventMask::WINDOW | EventMask::KEYBOARD;
}
fn on_window_event(&mut self, event: &WindowEvent) -> Result<(), String> {
if let WindowEvent::Resize { width, height } = event {
self.width = *width;
self.height = *height;
}
return Ok(());
}
fn on_keyboard_event(&mut self, event: &Key) -> Result<(), String> {
match event {
Key::Pressed {
scan_code: _,
virtual_key: Some(VirtualKey::KeyM),
} => {
self.msaa_samples = if self.msaa_samples > 1 { 1 } else { 4 };
self.needs_rebuild = true;
}
Key::Pressed {
scan_code: _,
virtual_key: Some(VirtualKey::KeyS),
} => {
self.stencil_enabled = !self.stencil_enabled;
self.needs_rebuild = true;
}
Key::Pressed {
scan_code: _,
virtual_key: Some(VirtualKey::KeyD),
} => {
self.depth_test_enabled = !self.depth_test_enabled;
self.needs_rebuild = true;
}
_ => {}
}
return Ok(());
}When a toggle flips, set needs_rebuild = true so pipelines and passes are
rebuilt on the next frame with the updated MSAA/depth/stencil settings.
- Build and run:
cargo run -p lambda-demos-render --bin reflective_room. - Expected behavior:
- A cube rotates above a reflective floor. The reflection appears only inside the floor area and shows correct mirroring.
- Press
Sto toggle the reflection (stencil). The reflected cube disappears when stencil is off. - Press
Fto hide/show the floor overlay to see a clean mirror. - Press
I/Kto adjust camera pitch; ensure the reflection remains visible at moderate angles. - Press
Dto toggle depth testing. With depth off, the reflection still clips to the floor via stencil. - Press
Mto toggle MSAA. With4×MSAA, edges appear smoother.
- Pipelines that use stencil MUST target a pass with a depth‑stencil attachment; otherwise, pipeline creation or draws will fail.
- Mirroring across a plane flips winding. Either disable culling or adjust front‑face winding for the reflected draw; do not leave back‑face culling enabled with mirrored geometry.
- This implementation culls front faces for the reflected pipeline to account for mirrored winding; the normal cube uses back‑face culling.
- The mask pass SHOULD clear stencil to
0and write1where the floor renders. UseReplaceand a write mask of0xFF. - The reflected draw SHOULD use
read_mask = 0xFF,write_mask = 0x00, andreference = 1to preserve the mask. - When depth testing is disabled, set
depth_compare = Alwaysanddepth_write = falseto avoid unintended depth interactions. - The pass and all pipelines in the pass MUST use the same MSAA sample count.
- Transpose matrices before uploading when GLSL expects column‑major multiplication.
- Metal (MSL) portability: avoid calling
inverse()in shaders for normal transforms; compute the normal matrix on the CPU if needed. The example usesmat3(model)for rigid + mirror transforms.
The reflective floor combines a simple stencil mask with an optional depth test and MSAA to produce a convincing planar reflection. The draw order and precise stencil state are critical: write the mask first, draw the mirrored geometry with a strict stencil test, render a translucent floor, and then render normal scene geometry.
- Full reference:
demos/render/src/bin/reflective_room.rs.
- Replace the cube with a sphere and observe differences in mirrored normals.
- Move the floor plane to
y = kand update the mirror transform accordingly. - Add a blend mode change on the floor to experiment with different reflection intensities.
- Switch stencil reference values to render multiple reflective regions on the floor.
- Re‑enable back‑face culling for the reflected draw and adjust front‑face winding to match the mirrored transform.
- Add a checkerboard texture to the floor and render the reflection beneath it using the same mask.
- Extend the example to toggle a mirrored XZ room (two planes) using different reference values.
- 2026-02-05, 0.4.5: Update demo commands and reference paths for
demos/. - 2026-01-16, 0.4.3: Normalize event handler terminology.
- 2026-01-16, 0.4.2: Add
event_mask()andon_*_eventhandler examples. - 2026-01-07, 0.4.1: Remove stage usage from immediates API examples.
- 2026-01-05, 0.4.0: Update for wgpu v28; rename push constants to immediates; update struct references to
ImmediateData. - 2025-12-15, 0.3.0: Update builder API calls to use
ctx.gpu()and addsurface_format/depth_formatparameters toRenderPassBuilderandRenderPipelineBuilder. - 2025-11-21, 0.2.2: Align tutorial with removal of the unmasked reflection debug toggle in the example and update metadata to the current engine workspace commit.
- 0.2.0 (2025‑11‑19): Updated for camera pitch, front‑face culling on reflection, lit translucent floor, unmasked reflection debug toggle, floor overlay toggle, and Metal portability note.
- 0.1.0 (2025‑11‑17): Initial draft aligned with
demos/render/src/bin/reflective_room.rs, including stencil mask pass, reflected pipeline, and MSAA/depth toggles.