diff --git a/src/render/mod.rs b/src/render/mod.rs index e1c9d34..0cfbff8 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -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, +) { + 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 { diff --git a/src/render/uniform.rs b/src/render/uniform.rs index 7ea9aef..7c00324 100644 --- a/src/render/uniform.rs +++ b/src/render/uniform.rs @@ -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], } diff --git a/src/shader.wgsl b/src/shader.wgsl index d1dfa07..18ebac3 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -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, inv_view_proj: mat4x4, eye: vec4, - /// .x = scene time in seconds (drives day/night cycle + leaf sway) - /// .y/.z/.w reserved frame: vec4, }; @group(0) @binding(0) var camera: Camera; fn scene_time() -> f32 { return camera.frame.x; } +fn exposure_bias() -> f32 { return camera.frame.y; } fn eye_world() -> vec3 { return camera.eye.xyz; } // ---------------- 2. Sky horizon math (shared with ambient) ---------------- @@ -239,6 +247,21 @@ fn leaf_jitter(world_pos: vec3) -> 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, sun: vec3, 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 { 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;