Round C: typed camera accessors, post-pass helper, leaf translucency (#20, #23, #25)

shader.wgsl:
  - Camera.frame layout now documented slot-by-slot in the struct
    comment; named accessors scene_time() / exposure_bias() / eye_world()
    so call sites read as intent. exposure_bias plumbed (default 1.0)
    as the foundation for a future Settings.exposure slider. [#20]
  - Leaves now get sun-aware backlit translucency: leaf_translucency()
    peaks when the sun rakes across the leaf plane AND comes from
    behind the surface. Cheap stand-in for subsurface scattering —
    reads as "dappled sun through canopy" without per-pixel ray
    sampling. [#25]

render/mod.rs:
  - run_fullscreen_pass(encoder, label, target, pipeline, bgs, clear)
    helper. The three post-chain steps (mask → shafts → composite)
    are now three sequential calls instead of three copies of the
    same begin_render_pass boilerplate. Adding a new effect (bloom,
    motion blur, vignette) is one extra row. [#23]
  - upload_camera fills frame[1] = 1.0 (exposure_bias default).

render/uniform.rs:
  - CameraUniform.frame doc updated to enumerate each slot.

Tests: 63 passing. Native + wasm release clean.
This commit is contained in:
Maximus Gorog 2026-05-24 10:16:05 -06:00
parent bd6b3fadb0
commit 0dca49f475
3 changed files with 98 additions and 70 deletions

View file

@ -520,7 +520,8 @@ impl Renderer {
view_proj: vp.to_cols_array_2d(),
inv_view_proj: inv.to_cols_array_2d(),
eye: [camera.position.x, camera.position.y, camera.position.z, 1.0],
frame: [time, 0.0, 0.0, 0.0],
// [scene_time, exposure_bias, reserved, reserved]
frame: [time, 1.0, 0.0, 0.0],
};
self.queue
.write_buffer(&self.camera_buffer, 0, bytemuck::bytes_of(&uni));
@ -596,70 +597,29 @@ impl Renderer {
}
}
// ---- Mask pass: ¼-res, sun-cone × sky-alpha = god-ray seeds. ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.mask_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.mask_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.set_bind_group(1, &self.mask_bg, &[]);
pass.draw(0..3, 0..1);
}
// ---- Shafts pass: ¼-res, radial blur from sun screen-pos. ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shafts pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.shafts_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.shafts_pipeline);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
pass.set_bind_group(1, &self.shafts_bg, &[]);
pass.draw(0..3, 0..1);
}
// ---- Post pass: FXAA + shafts composite + ACES tonemap. ----
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("post pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &surface_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.post_pipeline);
pass.set_bind_group(0, &self.post_bind_group, &[]);
pass.draw(0..3, 0..1);
}
// ---- Post chain: mask → shafts → composite. Each step is a
// full-screen-triangle pass with the same shape, so the chain
// is just three calls of run_fullscreen_pass with different
// (pipeline, target, bind groups). To add a new effect (bloom,
// motion blur, vignette), insert another row here. ----
run_fullscreen_pass(
&mut encoder, "mask pass", &self.mask_view,
&self.mask_pipeline,
&[&self.camera_bind_group, &self.mask_bg],
Some(wgpu::Color::BLACK),
);
run_fullscreen_pass(
&mut encoder, "shafts pass", &self.shafts_view,
&self.shafts_pipeline,
&[&self.camera_bind_group, &self.shafts_bg],
Some(wgpu::Color::BLACK),
);
run_fullscreen_pass(
&mut encoder, "post pass", &surface_view,
&self.post_pipeline,
&[&self.post_bind_group],
None,
);
self.queue.submit(Some(encoder.finish()));
frame.present();
@ -667,6 +627,44 @@ impl Renderer {
}
}
/// Run a single full-screen-triangle render pass: clear target if
/// `clear` is `Some`, load otherwise; set pipeline; bind groups in
/// order; draw three vertices. Used for every post-chain step so the
/// chain reads as a declarative sequence of effects rather than
/// boilerplate.
fn run_fullscreen_pass(
encoder: &mut wgpu::CommandEncoder,
label: &str,
target: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
bind_groups: &[&wgpu::BindGroup],
clear: Option<wgpu::Color>,
) {
let load = match clear {
Some(c) => wgpu::LoadOp::Clear(c),
None => wgpu::LoadOp::Load,
};
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(pipeline);
for (i, bg) in bind_groups.iter().enumerate() {
pass.set_bind_group(i as u32, *bg, &[]);
}
pass.draw(0..3, 0..1);
}
// ---- Browser-only compat helpers (probe WebGPU, render init-failure overlay) ----
#[cfg(target_arch = "wasm32")]
mod wasm_compat {

View file

@ -10,9 +10,13 @@ pub struct CameraUniform {
pub view_proj: [[f32; 4]; 4],
pub inv_view_proj: [[f32; 4]; 4],
pub eye: [f32; 4],
/// `.x` = scene time in seconds (drives day/night cycle + leaf
/// sway). `.y/.z/.w` reserved. Matches WGSL `camera.frame` with
/// `scene_time()` accessor.
/// Per-frame scalars; matches WGSL `camera.frame`. Accessors in
/// the shader expose each slot by name:
///
/// .x scene_time seconds since session start
/// .y exposure_bias tonemap multiplier (default 1.0)
/// .z reserved
/// .w reserved
pub frame: [f32; 4],
}

View file

@ -16,18 +16,26 @@
// ---------------- 1. Camera ----------------
// Camera uniform layout mirrored in `render::uniform::CameraUniform`.
// The `frame` vec4 is the "per-frame scalars" slot; the accessor
// functions below name each component so callsites read as intent
// rather than `frame.x` / `frame.y` / etc.
//
// frame.x scene_time seconds since session start
// frame.y exposure_bias tonemap multiplier (default 1.0)
// frame.z reserved for future fog density / weather etc.
// frame.w reserved
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
/// .x = scene time in seconds (drives day/night cycle + leaf sway)
/// .y/.z/.w reserved
frame: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
fn scene_time() -> f32 { return camera.frame.x; }
fn exposure_bias() -> f32 { return camera.frame.y; }
fn eye_world() -> vec3<f32> { return camera.eye.xyz; }
// ---------------- 2. Sky horizon math (shared with ambient) ----------------
@ -239,6 +247,21 @@ fn leaf_jitter(world_pos: vec3<f32>) -> f32 {
return 0.88 + n * 0.18;
}
/// Approximated leaf translucency. When the sun is behind the leaf
/// (relative to the surface normal), some light transmits through to
/// the viewer's side and the leaf glows softly with sun-tinted color.
/// Cheap stand-in for full subsurface scattering; reads as "sun
/// through canopy" without per-pixel sampling.
fn leaf_translucency(normal: vec3<f32>, sun: vec3<f32>, day: f32) -> f32 {
// peak transmittance when sun rakes across the leaf plane i.e.
// when sun is roughly perpendicular to the normal (grazing).
let grazing = 1.0 - abs(dot(normal, sun));
// backlit bias: prefer light coming from BEHIND the leaf so we
// don't double-count the front-side Lambert from direct_sun_term.
let back = max(dot(-normal, sun), 0.0);
return grazing * back * 0.55 * day;
}
/// Distance fog. Returns 0 (no fog) 1 (fully obscured).
fn fog_factor(dist: f32) -> f32 {
let fog_start = 90.0;
@ -331,6 +354,9 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
if (in.leaf > 0.5) {
lit = lit * leaf_jitter(in.world_pos);
// Sun-aware translucency leaves glow when backlit.
let trans = leaf_translucency(n, sun, day);
lit = lit + in.color * sun_col * trans;
}
let to_eye = eye_world() - in.world_pos;