Round B: screen-space god rays + FXAA (#12, #14)

New WGSL files:
  mask.wgsl     Sun-cone × sky-alpha mask at ¼ resolution. Marks sky
                pixels (scene_color.a == 0) that fall within a tight
                cone around the sun direction. Sun direction derived
                from scene time via the injected DAY_PERIOD /
                SUN_OFFSET constants — same source as sim::lighting,
                so visual rays align with the mechanical sun.
  shafts.wgsl   Radial blur at ¼ resolution. Projects the sun
                direction-at-infinity to screen space via view_proj,
                steps 32 samples from each pixel toward the
                sun_screen_pos accumulating mask intensity with
                exponential decay, outputs sun-tinted ray color.

shader.wgsl:
  - fs_sky now writes alpha = 0 so the mask pass can identify sky
    pixels without a separate occluder pass. Terrain / outline /
    remote-player pipelines continue writing alpha = 1.

post.wgsl (rewritten):
  - Reads scene_color + shafts.
  - Cheap edge-aware FXAA (5-tap diagonal blur, blends toward neighbor
    average where luminance gradient exceeds threshold). Catches the
    axis-aligned staircase aliasing that voxel games produce.
  - Adds shafts additively before tonemap so rays go through the
    filmic curve and don't blow out.

render/scene_target.rs:
  - create_image_bind_group (single texture + sampler) — used by
    mask pass (binds scene_color) and shafts pass (binds mask_view).
  - create_composite_bind_group (scene + shafts + sampler) — used by
    the final post pass.
  - create_quarter_res_view (¹⁄₁₆ fillrate, RENDER_ATTACHMENT +
    TEXTURE_BINDING) — used for both mask and shafts targets.

render/pipelines.rs:
  - image_bgl / composite_bgl as separate layouts.
  - fullscreen_pipeline factory replaces the post-specific one;
    takes vs/fs entry-point names so mask, shafts, and post all
    build through the same shape.

render/mod.rs:
  - Renderer grows mask_view, shafts_view, image_bgl, composite_bgl,
    mask_pipeline, shafts_pipeline, mask_bg, shafts_bg fields. The
    old single-pass post_pipeline becomes the final composite pass.
  - render() now does scene → mask → shafts → post (each in its own
    encoder block).
  - resize() recreates all three render targets and all three bind
    groups in the right order.

Tests: 63 passing (added 2 for mask/shafts source assembly). Native
+ wasm release clean.

Visual change: when you face roughly toward the sun and there's
geometry blocking the disc, you'll see warm light shafts radiating
outward. FXAA softens the worst pixel-step edges. Tonemap (from
Round A) is now the final step of the new pipeline.
This commit is contained in:
Maximus Gorog 2026-05-24 10:09:10 -06:00
parent 94585b1ab2
commit bd6b3fadb0
8 changed files with 492 additions and 44 deletions

67
src/mask.wgsl Normal file
View file

@ -0,0 +1,67 @@
// Sun-cone sky mask. Generates the input to the radial-blur shafts
// pass: white where (a) this pixel is sky (scene_color.a == 0) AND
// (b) the view direction is close to the sun direction; black
// elsewhere. Runs at ¼ resolution to save fillrate.
//
// Sun direction is derived from scene time via the injected DAY_PERIOD
// / SUN_OFFSET constants same source of truth as sim::lighting and
// the terrain shader, so the visual rays line up with the mechanical
// sun direction by construction.
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
frame: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
@group(1) @binding(0) var scene_tex: texture_2d<f32>;
@group(1) @binding(1) var scene_sampler: sampler;
fn scene_time() -> f32 { return camera.frame.x; }
fn sun_direction(t: f32) -> vec3<f32> {
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
return normalize(vec3<f32>(cos(a), sin(a), 0.25));
}
struct MaskOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_mask(@builtin(vertex_index) idx: u32) -> MaskOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = corners[idx];
var out: MaskOut;
out.clip = vec4<f32>(p, 0.0, 1.0);
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
return out;
}
@fragment
fn fs_mask(in: MaskOut) -> @location(0) vec4<f32> {
let scene = textureSample(scene_tex, scene_sampler, in.uv);
// Sky pixels carry alpha = 0; geometry carries alpha = 1.
let is_sky = 1.0 - scene.a;
// Reconstruct world-space view direction at this pixel.
let ndc_x = in.uv.x * 2.0 - 1.0;
let ndc_y = 1.0 - in.uv.y * 2.0;
let far_h = camera.inv_view_proj * vec4<f32>(ndc_x, ndc_y, 1.0, 1.0);
let world_far = far_h.xyz / far_h.w;
let view_dir = normalize(world_far - camera.eye.xyz);
let sun = sun_direction(scene_time());
// Tight cone around sun. pow exponent controls cone width.
let cone = pow(max(dot(view_dir, sun), 0.0), 8.0);
let mask = is_sky * cone;
return vec4<f32>(mask, mask, mask, 1.0);
}

View file

@ -1,13 +1,13 @@
// Post pipeline. Currently:
// 1. Sample the offscreen scene_color (linear HDR-ish)
// 2. ACES tonemap to compress highlights gracefully
// 3. Output to the surface (which sRGB-encodes automatically)
//
// Effects layered on top (god rays, FXAA) will slot in between (1) and (2)
// tonemap stays last so everything benefits from the curve.
// Post composite. Pipeline (in order):
// 1. Read scene_color (linear HDR-ish, alpha encodes sky/geometry)
// 2. Cheap edge-detect FXAA (4-tap diagonal blur)
// 3. Additively composite sun shafts on top
// 4. ACES tonemap to compress highlights into [0,1]
// 5. Output to surface (sRGB-encoded by GPU)
@group(0) @binding(0) var scene_color_tex: texture_2d<f32>;
@group(0) @binding(1) var scene_color_sampler: sampler;
@group(0) @binding(1) var shafts_tex: texture_2d<f32>;
@group(0) @binding(2) var post_sampler: sampler;
struct PostOut {
@builtin(position) clip: vec4<f32>,
@ -24,14 +24,30 @@ fn vs_post(@builtin(vertex_index) idx: u32) -> PostOut {
let p = corners[idx];
var out: PostOut;
out.clip = vec4<f32>(p, 0.0, 1.0);
// Texture origin is top-left; flip Y so screen coords map to texel coords.
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
return out;
}
// Narkowicz's ACES filmic approximation. Brightens midtones, compresses
// highlights smoothly into [0, 1]. Output is linear; the sRGB surface
// encodes for display.
// Cheap edge-aware blur a small FXAA. Samples the center and four
// diagonal neighbors, blends toward the average where the local
// luminance gradient exceeds a threshold. Works well for voxel games
// where edges are axis-aligned and high-contrast.
fn fxaa(uv: vec2<f32>, texel: vec2<f32>) -> vec3<f32> {
let c = textureSample(scene_color_tex, post_sampler, uv).rgb;
let nw = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>(-0.5, -0.5)).rgb;
let ne = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>( 0.5, -0.5)).rgb;
let sw = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>(-0.5, 0.5)).rgb;
let se = textureSample(scene_color_tex, post_sampler, uv + texel * vec2<f32>( 0.5, 0.5)).rgb;
let avg = (nw + ne + sw + se) * 0.25;
let luma_w = vec3<f32>(0.299, 0.587, 0.114);
let lc = dot(c, luma_w);
let la = dot(avg, luma_w);
let edge = clamp(abs(lc - la) * 4.0, 0.0, 1.0);
return mix(c, avg, edge);
}
// Narkowicz ACES filmic approximation. Output is linear; the sRGB
// surface encodes for display.
fn aces_tonemap(c: vec3<f32>) -> vec3<f32> {
let a = 2.51;
let b = 0.03;
@ -43,7 +59,13 @@ fn aces_tonemap(c: vec3<f32>) -> vec3<f32> {
@fragment
fn fs_post(in: PostOut) -> @location(0) vec4<f32> {
let scene = textureSample(scene_color_tex, scene_color_sampler, in.uv);
let tonemapped = aces_tonemap(scene.rgb);
let dims = vec2<f32>(textureDimensions(scene_color_tex));
let texel = vec2<f32>(1.0) / dims;
let aa = fxaa(in.uv, texel);
let shafts = textureSample(shafts_tex, post_sampler, in.uv).rgb;
// Additive composite sun shafts brighten the underlying scene.
let composed = aa + shafts;
let tonemapped = aces_tonemap(composed);
return vec4<f32>(tonemapped, 1.0);
}

View file

@ -38,7 +38,6 @@ pub struct Renderer {
pipeline: wgpu::RenderPipeline,
sky_pipeline: wgpu::RenderPipeline,
outline_pipeline: wgpu::RenderPipeline,
post_pipeline: wgpu::RenderPipeline,
outline_buffer: wgpu::Buffer,
depth_view: wgpu::TextureView,
@ -52,12 +51,21 @@ pub struct Renderer {
remote_ib: wgpu::Buffer,
remote_index_count: u32,
// ---- Post processing (Step 1: pass-through scene → surface) ----
// ---- Post processing chain: scene → mask → shafts → composite. ----
scene_color: wgpu::TextureView,
scene_color_format: wgpu::TextureFormat,
post_sampler: wgpu::Sampler,
post_bgl: wgpu::BindGroupLayout,
post_bind_group: wgpu::BindGroup,
image_bgl: wgpu::BindGroupLayout,
composite_bgl: wgpu::BindGroupLayout,
// ¼-res god-rays chain
mask_view: wgpu::TextureView,
shafts_view: wgpu::TextureView,
mask_pipeline: wgpu::RenderPipeline,
shafts_pipeline: wgpu::RenderPipeline,
post_pipeline: wgpu::RenderPipeline,
mask_bg: wgpu::BindGroup, // binds scene_color → input to mask pass
shafts_bg: wgpu::BindGroup, // binds mask_view → input to shafts pass
post_bind_group: wgpu::BindGroup, // binds scene + shafts → input to post
}
impl Renderer {
@ -252,10 +260,16 @@ impl Renderer {
mapped_at_creation: false,
});
// ---- Post pipeline + scene-color target ----
// ---- Post chain: scene-color + ¼-res mask + ¼-res shafts → composite. ----
let scene_color_format = config.format;
let scene_color =
scene_target::create_scene_color_view(&device, width, height, scene_color_format);
let mask_view = scene_target::create_quarter_res_view(
&device, width, height, scene_color_format,
);
let shafts_view = scene_target::create_quarter_res_view(
&device, width, height, scene_color_format,
);
let post_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("post sampler"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
@ -266,11 +280,53 @@ impl Renderer {
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let post_bgl = pipelines::post_bgl(&device);
let post_bind_group =
scene_target::create_post_bind_group(&device, &post_bgl, &scene_color, &post_sampler);
let post_pl = pipelines::pipeline_layout(&device, "post pl", &[&post_bgl]);
let post_pipeline = pipelines::post_pipeline(&device, &post_pl, &post_shader, config.format);
// Bind-group layouts
let image_bgl = pipelines::image_bgl(&device);
let composite_bgl = pipelines::composite_bgl(&device);
// Shaders for the new mask + shafts passes
let mask_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mask shader"),
source: wgpu::ShaderSource::Wgsl(
crate::shader_source::mask_shader_source().into(),
),
});
let shafts_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shafts shader"),
source: wgpu::ShaderSource::Wgsl(
crate::shader_source::shafts_shader_source().into(),
),
});
// Pipeline layouts
let mask_pl =
pipelines::pipeline_layout(&device, "mask pl", &[&camera_bgl, &image_bgl]);
let shafts_pl =
pipelines::pipeline_layout(&device, "shafts pl", &[&camera_bgl, &image_bgl]);
let post_pl = pipelines::pipeline_layout(&device, "post pl", &[&composite_bgl]);
// Pipelines (all full-screen-triangle, no depth)
let mask_pipeline = pipelines::fullscreen_pipeline(
&device, "mask pipeline", &mask_pl, &mask_shader, "vs_mask", "fs_mask", config.format,
);
let shafts_pipeline = pipelines::fullscreen_pipeline(
&device, "shafts pipeline", &shafts_pl, &shafts_shader, "vs_shafts", "fs_shafts", config.format,
);
let post_pipeline = pipelines::fullscreen_pipeline(
&device, "post pipeline", &post_pl, &post_shader, "vs_post", "fs_post", config.format,
);
// Bind groups
let mask_bg = scene_target::create_image_bind_group(
&device, &image_bgl, &scene_color, &post_sampler,
);
let shafts_bg = scene_target::create_image_bind_group(
&device, &image_bgl, &mask_view, &post_sampler,
);
let post_bind_group = scene_target::create_composite_bind_group(
&device, &composite_bgl, &scene_color, &shafts_view, &post_sampler,
);
Self {
surface,
@ -294,9 +350,16 @@ impl Renderer {
scene_color,
scene_color_format,
post_sampler,
post_bgl,
post_bind_group,
image_bgl,
composite_bgl,
mask_view,
shafts_view,
mask_pipeline,
shafts_pipeline,
post_pipeline,
mask_bg,
shafts_bg,
post_bind_group,
}
}
@ -384,12 +447,37 @@ impl Renderer {
height,
self.scene_color_format,
);
self.post_bind_group = scene_target::create_post_bind_group(
self.mask_view = scene_target::create_quarter_res_view(
&self.device,
&self.post_bgl,
width,
height,
self.scene_color_format,
);
self.shafts_view = scene_target::create_quarter_res_view(
&self.device,
width,
height,
self.scene_color_format,
);
self.mask_bg = scene_target::create_image_bind_group(
&self.device,
&self.image_bgl,
&self.scene_color,
&self.post_sampler,
);
self.shafts_bg = scene_target::create_image_bind_group(
&self.device,
&self.image_bgl,
&self.mask_view,
&self.post_sampler,
);
self.post_bind_group = scene_target::create_composite_bind_group(
&self.device,
&self.composite_bgl,
&self.scene_color,
&self.shafts_view,
&self.post_sampler,
);
}
pub fn rebuild_chunk(&mut self, coord: IVec3, world: &World) {
@ -508,7 +596,51 @@ impl Renderer {
}
}
// ---- Post pass: scene_color → surface (effects later). ----
// ---- 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"),

View file

@ -24,9 +24,12 @@ pub fn camera_bgl(device: &Device) -> BindGroupLayout {
})
}
pub fn post_bgl(device: &Device) -> BindGroupLayout {
/// Single-image input layout: one filterable texture + one filtering
/// sampler. Used by the mask pass (reads scene_color) and the shafts
/// pass (reads the mask). Same shape as the historical `post_bgl`.
pub fn image_bgl(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("post bgl"),
label: Some("image bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
@ -48,6 +51,42 @@ pub fn post_bgl(device: &Device) -> BindGroupLayout {
})
}
/// Composite layout used by the final post pass: scene_color +
/// shafts + shared sampler.
pub fn composite_bgl(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("composite bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
})
}
pub fn pipeline_layout(
device: &Device,
label: &str,
@ -200,26 +239,31 @@ pub fn outline_pipeline(
})
}
/// Pass-through post: full-screen triangle that samples `scene_color`
/// back to the surface. Foundation for FXAA / sun shafts / tonemap.
pub fn post_pipeline(
/// Generic full-screen-triangle pipeline. Takes the vertex + fragment
/// entry point names so the same factory builds mask, shafts, and
/// composite passes with different shaders. No depth, no culling, no
/// blending — pure post-process.
pub fn fullscreen_pipeline(
device: &Device,
label: &str,
layout: &PipelineLayout,
shader: &ShaderModule,
vs_entry: &str,
fs_entry: &str,
format: TextureFormat,
) -> RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("post pipeline"),
label: Some(label),
layout: Some(layout),
vertex: VertexState {
module: shader,
entry_point: Some("vs_post"),
entry_point: Some(vs_entry),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
module: shader,
entry_point: Some("fs_post"),
entry_point: Some(fs_entry),
targets: &[Some(color_target(format))],
compilation_options: Default::default(),
}),

View file

@ -49,19 +49,21 @@ pub fn create_scene_color_view(
tex.create_view(&wgpu::TextureViewDescriptor::default())
}
pub fn create_post_bind_group(
/// One-image bind group: a single texture + a sampler. Used by the
/// mask pass (binds scene_color) and the shafts pass (binds the mask).
pub fn create_image_bind_group(
device: &Device,
layout: &wgpu::BindGroupLayout,
scene: &TextureView,
image: &TextureView,
sampler: &Sampler,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("post bg"),
label: Some("image bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scene),
resource: wgpu::BindingResource::TextureView(image),
},
wgpu::BindGroupEntry {
binding: 1,
@ -70,3 +72,58 @@ pub fn create_post_bind_group(
],
})
}
/// Composite bind group for the final post pass: scene_color + shafts
/// + shared sampler.
pub fn create_composite_bind_group(
device: &Device,
layout: &wgpu::BindGroupLayout,
scene: &TextureView,
shafts: &TextureView,
sampler: &Sampler,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("composite bg"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(shafts),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
],
})
}
/// Quarter-resolution render target for the god-rays mask and shafts
/// passes. ¼ dimensions = ¹⁄₁₆ fillrate of full screen — cheap enough
/// to run 32-sample radial blur each frame.
pub fn create_quarter_res_view(
device: &Device,
w: u32,
h: u32,
format: TextureFormat,
) -> TextureView {
let tex = device.create_texture(&wgpu::TextureDescriptor {
label: Some("¼-res shafts target"),
size: wgpu::Extent3d {
width: (w / 4).max(1),
height: (h / 4).max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
tex.create_view(&wgpu::TextureViewDescriptor::default())
}

View file

@ -367,7 +367,9 @@ fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
let far_h = camera.inv_view_proj * vec4<f32>(in.ndc.x, in.ndc.y, 1.0, 1.0);
let world_pos = far_h.xyz / far_h.w;
let dir = normalize(world_pos - eye_world());
return vec4<f32>(sky_color(dir), 1.0);
// alpha = 0 flags this pixel as "sky" for the god-rays mask pass;
// terrain pipeline writes alpha = 1 where it overdraws.
return vec4<f32>(sky_color(dir), 0.0);
}
// ---------------- 5c. Outline ----------------

View file

@ -26,8 +26,21 @@ pub fn terrain_shader_source() -> String {
format!("{}{}", wgsl_constants_header(), include_str!("shader.wgsl"))
}
/// Post-process pipeline source. Doesn't depend on the shared
/// constants; passed through for symmetry with `terrain_shader_source`.
/// God-rays sun-cone mask shader. Reads scene_color, gates by sun
/// direction. Needs DAY_PERIOD / SUN_OFFSET injected.
pub fn mask_shader_source() -> String {
format!("{}{}", wgsl_constants_header(), include_str!("mask.wgsl"))
}
/// God-rays radial-blur pass. Reads the mask texture, accumulates
/// samples toward the sun's screen-space position. Needs the same
/// injected sun constants.
pub fn shafts_shader_source() -> String {
format!("{}{}", wgsl_constants_header(), include_str!("shafts.wgsl"))
}
/// Post-composite pipeline source: FXAA + shafts composite + tonemap.
/// Doesn't reference sun-direction math; no constants needed.
pub fn post_shader_source() -> &'static str {
include_str!("post.wgsl")
}
@ -51,4 +64,20 @@ mod tests {
assert!(src.contains("fn sun_direction"));
assert!(src.contains("fn fs_main"));
}
#[test]
fn mask_shader_source_has_constants_and_entry_points() {
let src = mask_shader_source();
assert!(src.contains("DAY_PERIOD"));
assert!(src.contains("fn vs_mask"));
assert!(src.contains("fn fs_mask"));
}
#[test]
fn shafts_shader_source_has_constants_and_entry_points() {
let src = shafts_shader_source();
assert!(src.contains("DAY_PERIOD"));
assert!(src.contains("fn vs_shafts"));
assert!(src.contains("fn fs_shafts"));
}
}

95
src/shafts.wgsl Normal file
View file

@ -0,0 +1,95 @@
// Screen-space radial blur from the sun's projected screen position.
// Reads the sun-cone sky mask, accumulates samples along the ray from
// this pixel toward the sun, and outputs sun-tinted ray intensity for
// the post composite to add onto the scene.
//
// Runs at ¼ resolution. 32 samples with exponential decay.
struct Camera {
view_proj: mat4x4<f32>,
inv_view_proj: mat4x4<f32>,
eye: vec4<f32>,
frame: vec4<f32>,
};
@group(0) @binding(0) var<uniform> camera: Camera;
@group(1) @binding(0) var mask_tex: texture_2d<f32>;
@group(1) @binding(1) var mask_sampler: sampler;
fn scene_time() -> f32 { return camera.frame.x; }
fn sun_direction(t: f32) -> vec3<f32> {
let a = (t / DAY_PERIOD + SUN_OFFSET) * 6.28318530718;
return normalize(vec3<f32>(cos(a), sin(a), 0.25));
}
fn twilight_amount(sun: vec3<f32>) -> f32 {
return smoothstep(-0.10, 0.05, sun.y) - smoothstep(0.05, 0.30, sun.y);
}
fn sun_tint(sun: vec3<f32>) -> vec3<f32> {
let twi = twilight_amount(sun);
return mix(vec3<f32>(1.00, 0.95, 0.85), vec3<f32>(1.00, 0.55, 0.30), twi);
}
struct ShaftsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs_shafts(@builtin(vertex_index) idx: u32) -> ShaftsOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = corners[idx];
var out: ShaftsOut;
out.clip = vec4<f32>(p, 0.0, 1.0);
out.uv = vec2<f32>(p.x * 0.5 + 0.5, p.y * -0.5 + 0.5);
return out;
}
const N_SAMPLES: i32 = 32;
const DECAY: f32 = 0.965;
const WEIGHT: f32 = 0.42;
const EXPOSURE: f32 = 0.30;
@fragment
fn fs_shafts(in: ShaftsOut) -> @location(0) vec4<f32> {
let sun = sun_direction(scene_time());
if (sun.y < -0.05) {
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
// Project the sun direction at infinity to screen space.
// view_proj × (sun, 0.0) yields the homogeneous coords of a point
// infinitely far along the sun direction.
let sun_clip = camera.view_proj * vec4<f32>(sun, 0.0);
if (sun_clip.w <= 0.0) {
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}
let sun_ndc = sun_clip.xyz / sun_clip.w;
let sun_uv = vec2<f32>(sun_ndc.x * 0.5 + 0.5, sun_ndc.y * -0.5 + 0.5);
// Step from this pixel toward the sun. Sample mask each step,
// accumulate with exponential illumination decay.
let delta = (sun_uv - in.uv) / f32(N_SAMPLES);
var coord = in.uv;
var illum_decay = 1.0;
var accum = 0.0;
for (var i = 0; i < N_SAMPLES; i = i + 1) {
coord = coord + delta;
let m = textureSample(mask_tex, mask_sampler, coord).r;
accum = accum + m * illum_decay * WEIGHT;
illum_decay = illum_decay * DECAY;
}
// Gate by sun altitude (no shafts at night). At low sun the rays
// are most dramatic keep intensity flat over the visible range.
let visibility = smoothstep(-0.05, 0.15, sun.y);
let intensity = accum * EXPOSURE * visibility;
return vec4<f32>(sun_tint(sun) * intensity, 1.0);
}