From 3cfa79c0116fd4a73e035a2aa41e86ed6956ad2d Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 13:59:43 +0300 Subject: [PATCH] HD lighting stage 2: sun and moon shadow cascades, the world relit (sun by face angle and shadow, sky and block light through the lightmap), light shafts and halos, fog over it again, a light colour grade Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity.wgsl | 11 +- crates/horizon-client/src/game.rs | 2 +- crates/horizon-client/src/game/mod_models.rs | 40 +- crates/horizon-client/src/game/mods.rs | 6 +- crates/horizon-client/src/gfx.rs | 271 +++++++- crates/horizon-client/src/gfx/hd.rs | 673 ++++++++++++++++--- crates/horizon-client/src/gfx/mod_post.rs | 64 +- crates/horizon-client/src/gfx/mod_views.rs | 9 +- crates/horizon-client/src/terrain.wgsl | 10 + 9 files changed, 957 insertions(+), 129 deletions(-) diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 01c64def9..61bc1f92a 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -56,8 +56,9 @@ struct Out { @location(11) glint_uv: vec2, // 1 drops the quad's back-facing pixels (Vertex::normal w = 1), like a culling render type. @location(12) @interpolate(flat) cull: u32, - // AirDef fork, HD lighting's G-buffer: the camera-relative position. + // AirDef fork, HD lighting's G-buffer: the camera-relative position, sky and block light. @location(13) pos: vec3, + @location(14) lm: vec2, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -121,6 +122,7 @@ fn entity_vertex(position: vec3, uv: vec2, out.cylindrical = select(max(length(position.xz), abs(position.y)), 0.0, overlay >= 2u && overlay <= 4u); out.overlay = overlay; out.pos = position; + out.lm = vec2(l.y, l.x) / 240.0; out.glint_uv = vec2(0.0); if overlay == 4u { out.spherical = length(position); } return out; @@ -269,6 +271,7 @@ fn crumbling_uv(uv: vec2, packed_origin: vec2, sprite_size: vec2, struct EntityGBufferOut { @location(0) albedo: vec4, @location(1) normal: vec4, + @location(2) light: vec4, } @fragment fn fs_entity_gbuffer(in: Out, @builtin(front_facing) front: bool) -> EntityGBufferOut { let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front); @@ -280,8 +283,14 @@ struct EntityGBufferOut { var out: EntityGBufferOut; out.albedo = vec4(color.rgb, glow); out.normal = vec4(n, 1.0); + out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0); return out; } +// AirDef fork: entities in the sun's shadow map. +@fragment fn fs_entity_shadow(in: Out) { + let a = textureSample(atlas, atlas_sampler, in.uv).a * in.color.a; + if a < 0.1 || in.overlay == 4u || in.overlay == 9u || in.overlay == 10u || in.overlay == 40u { discard; } +} // rendertype_outline.fsh: the vertex colour (the team colour; outline vertices carry no // normal, so no cardinal light) wherever the texture is not fully clear. diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 3c97bbfb6..3a11ee283 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -112,10 +112,10 @@ mod mod_browser; #[cfg(feature = "mods")] mod mod_browser_recipes; mod mod_input; +mod mod_models; #[cfg(feature = "mods")] mod mod_recipes; mod mod_render; -mod mod_models; mod mods; mod mount_menu; mod mounts; diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs index e6dbc7953..2d1b4780a 100644 --- a/crates/horizon-client/src/game/mod_models.rs +++ b/crates/horizon-client/src/game/mod_models.rs @@ -65,7 +65,11 @@ impl Game<'_> { pub(super) fn mod_hd(&self) -> Option { None } - pub(super) fn mod_lights(&self, _camera: glam::DVec3, _alpha: f64) -> Vec { + pub(super) fn mod_lights( + &self, + _camera: glam::DVec3, + _alpha: f64, + ) -> Vec { Vec::new() } } @@ -127,7 +131,12 @@ impl Game<'_> { let mut lights: HashMap<(usize, u32), (PointLight, PointLight)> = HashMap::new(); for (owner, l) in self.mods.mods.lights() { let key = (owner, l.id); - let before = self.mods.models.lights.get(&key).map_or(*l, |(_, now)| *now); + let before = self + .mods + .models + .lights + .get(&key) + .map_or(*l, |(_, now)| *now); lights.insert(key, (before, *l)); } self.mods.models.lights = lights; @@ -136,7 +145,10 @@ impl Game<'_> { self.mods.models.views = views .into_iter() .map(|v| { - let before = old.iter().find(|(_, o)| o.id == v.id).map_or(v, |(_, o)| *o); + let before = old + .iter() + .find(|(_, o)| o.id == v.id) + .map_or(v, |(_, o)| *o); (before, v) }) .collect(); @@ -145,12 +157,19 @@ impl Game<'_> { /// The HD lighting a mod asked for, if any. pub(super) fn mod_hd(&self) -> Option { let l = self.mods.mods.lighting()?; - l.hd.then_some(horizon_client::gfx::HdSettings { exposure: l.exposure, bloom: l.bloom }) + l.hd.then_some(horizon_client::gfx::HdSettings { + exposure: l.exposure, + bloom: l.bloom, + }) } /// The mods' lights between their last two settings, relative to the camera; the nearest and /// brightest first when there are too many. - pub(super) fn mod_lights(&self, camera: DVec3, alpha: f64) -> Vec { + pub(super) fn mod_lights( + &self, + camera: DVec3, + alpha: f64, + ) -> Vec { let t = alpha.clamp(0.0, 1.0); let mut out: Vec<(f32, horizon_client::gfx::HdLight)> = self .mods @@ -167,7 +186,12 @@ impl Game<'_> { } let light = horizon_client::gfx::HdLight { pos: [at.x as f32, at.y as f32, at.z as f32, radius], - color: [b.color.0, b.color.1, b.color.2, a.intensity + (b.intensity - a.intensity) * t as f32], + color: [ + b.color.0, + b.color.1, + b.color.2, + a.intensity + (b.intensity - a.intensity) * t as f32, + ], }; Some((light.color[3] / (1.0 + d * d * 0.001), light)) }) @@ -266,7 +290,9 @@ impl Game<'_> { None => (0, None, Vec::new()), }; let mut error = None; - let Some(renderer) = self.renderer.as_mut() else { return }; + let Some(renderer) = self.renderer.as_mut() else { + return; + }; if version != self.mods.models.post { self.mods.models.post = version; error = renderer.set_mod_post(source.as_deref()).err(); diff --git a/crates/horizon-client/src/game/mods.rs b/crates/horizon-client/src/game/mods.rs index ebdd18a24..a760c7763 100644 --- a/crates/horizon-client/src/game/mods.rs +++ b/crates/horizon-client/src/game/mods.rs @@ -484,7 +484,11 @@ impl Game<'_> { known_recipes, placing: self.mod_recipe_screen(), creative: self.creative, - world: self.player.is_some().then(|| self.mods.world_id.clone()).flatten(), + world: self + .player + .is_some() + .then(|| self.mods.world_id.clone()) + .flatten(), } } /// The first block along the player's view within diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index be51a0289..e351763fd 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -71,15 +71,15 @@ mod arena; mod cull; mod end_sky; mod flat; +mod hd; mod hdr; pub mod lightning; mod lod; +mod mod_post; +mod mod_views; mod native_material; mod native_pack; mod native_post; -mod hd; -mod mod_post; -mod mod_views; pub use hd::{HdLight, HdSettings}; pub use mod_views::ModView; mod native_sky; @@ -563,6 +563,10 @@ pub struct Renderer { /// AirDef fork: the HD lighting's G-buffer pipelines (terrain, entities) and its targets. gbuffer_terrain: wgpu::RenderPipeline, gbuffer_entity: wgpu::RenderPipeline, + /// The HD sun shadows: depth only, from the light, and the sections in its farther cascade. + shadow_terrain: wgpu::RenderPipeline, + shadow_entity: wgpu::RenderPipeline, + hd_shadow_visible: Vec, hd: Option, mod_params: (Vec, f32), /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). @@ -1347,9 +1351,34 @@ impl Renderer { let tri = wgpu::PrimitiveTopology::TriangleList; // AirDef fork: the HD lighting's G-buffer (terrain.wgsl's fs_gbuffer): albedo and normal. let gbuffer_targets = [ - Some(wgpu::ColorTargetState { format: hd::ALBEDO, blend: None, write_mask: wgpu::ColorWrites::ALL }), - Some(wgpu::ColorTargetState { format: hd::NORMAL, blend: None, write_mask: wgpu::ColorWrites::ALL }), + Some(wgpu::ColorTargetState { + format: hd::ALBEDO, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: hd::NORMAL, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), + Some(wgpu::ColorTargetState { + format: hd::LIGHT_LEVELS, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }), ]; + // The shadow maps: plain depth from the light, biased against acne, both faces. + let shadow_depth = Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: Default::default(), + bias: wgpu::DepthBiasState { + constant: 2, + slope_scale: 2.0, + clamp: 0.0, + }, + }); let gbuffer_depth = Some(wgpu::DepthStencilState { format: wgpu::TextureFormat::Depth32Float, depth_write_enabled: Some(true), @@ -1366,18 +1395,48 @@ impl Renderer { compilation_options: Default::default(), buffers: &terrain_buffers, }, - primitive: wgpu::PrimitiveState { cull_mode: Some(wgpu::Face::Back), ..Default::default() }, + primitive: wgpu::PrimitiveState { + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, depth_stencil: gbuffer_depth.clone(), multisample: Default::default(), fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_gbuffer"), - compilation_options: wgpu::PipelineCompilationOptions { constants: &sprite_constants, ..Default::default() }, + compilation_options: wgpu::PipelineCompilationOptions { + constants: &sprite_constants, + ..Default::default() + }, targets: &gbuffer_targets, }), multiview_mask: None, cache: None, }); + let shadow_terrain = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD shadows terrain"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_terrain"), + compilation_options: Default::default(), + buffers: &terrain_buffers, + }, + primitive: Default::default(), + depth_stencil: shadow_depth.clone(), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_shadow"), + compilation_options: wgpu::PipelineCompilationOptions { + constants: &sprite_constants, + ..Default::default() + }, + targets: &[], + }), + multiview_mask: None, + cache: None, + }); let solid = pipeline( "Terrain solid", "vs_terrain", @@ -1996,6 +2055,27 @@ impl Renderer { multiview_mask: None, cache: None, }); + let shadow_entity = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD shadows entities"), + layout: Some(&entity_layout), + vertex: wgpu::VertexState { + module: &entity_shader, + entry_point: Some("vs_entity"), + compilation_options: Default::default(), + buffers: &entity_vertex, + }, + primitive: Default::default(), + depth_stencil: shadow_depth.clone(), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &entity_shader, + entry_point: Some("fs_entity_shadow"), + compilation_options: Default::default(), + targets: &[], + }), + multiview_mask: None, + cache: None, + }); let entity_pipeline = |label, cull_mode, depth_write, compare, bias, blend, clouds| { entity_pipeline_with( label, @@ -2729,6 +2809,9 @@ impl Renderer { mod_views: None, gbuffer_terrain, gbuffer_entity, + shadow_terrain, + shadow_entity, + hd_shadow_visible: Vec::new(), hd: None, mod_params: (Vec::new(), 0.0), pass_timing: pack_timing::PassTiming::logging(), @@ -3112,14 +3195,17 @@ impl Renderer { let mut at = range.start; if let Some(views) = &self.mod_views { for v in batch.views.iter().filter(|v| { - v.at >= range.start && (v.at < range.end || (v.at == range.end && range.end == last)) + v.at >= range.start + && (v.at < range.end || (v.at == range.end && range.end == last)) }) { - self.hud.draw_range(encoder, target, &self.depth, batch, size, at..v.at); + self.hud + .draw_range(encoder, target, &self.depth, batch, size, at..v.at); views.blit(encoder, target, size, v, batch.scale); at = v.at; } } - self.hud.draw_range(encoder, target, &self.depth, batch, size, at..range.end); + self.hud + .draw_range(encoder, target, &self.depth, batch, size, at..range.end); } /// The mod cameras due this frame, each into its picture: sky, solid /// terrain and entities, through the world's own pipelines with the @@ -3215,7 +3301,9 @@ impl Renderer { pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); } for (k, v) in d.lods.iter().enumerate() { - let Some(tile) = self.lods.get(&v.key) else { continue }; + let Some(tile) = self.lods.get(&v.key) else { + continue; + }; let s = &tile.mesh; if s.solid == 0 { continue; @@ -3520,7 +3608,11 @@ impl Renderer { } Some(source) => { // Under the HD lighting it draws into the HD target, else onto the screen. - let format = if self.mod_post_hdr { hd::HDR } else { self.config.format.remove_srgb_suffix() }; + let format = if self.mod_post_hdr { + hd::HDR + } else { + self.config.format.remove_srgb_suffix() + }; self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); self.mod_post_source = Some(source.to_owned()); } @@ -3530,7 +3622,9 @@ impl Renderer { /// AirDef fork: the mod post pass's parameters for the next frames. pub fn set_mod_params(&mut self, params: &[f32], partial: f32) { self.mod_params.0.clear(); - self.mod_params.0.extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); + self.mod_params + .0 + .extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); self.mod_params.1 = partial; } pub fn drop_shader_packs(&mut self) { @@ -4049,6 +4143,41 @@ impl Renderer { }); } self.drawn_sections = visible.len() + visible_lods.len(); + // AirDef fork: the HD sun shadows draw every section in the farther cascade's box. + let hd_wanted = !frame.title + && frame.hd.is_some() + && !self.pack_post.as_ref().is_some_and(|p| p.1.is_some()) + && !self.native.as_ref().is_some_and(|n| n.post.is_some()); + let hd_scene = hd::HdScene { + view_proj, + camera, + // The world's sun: vanilla turns the sky -90° about Y, so it crosses from east to west. + sun: Vec3::new(-sun_angle.sin(), sun_angle.cos(), 0.0), + dark: 1.0 - frame.sky.sky_light_factor, + rain: if frame.weather.is_empty() { 0.0 } else { 0.7 }, + fog_color, + fog, + sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater, + }; + let (shadow_dir, shadow_strength, _) = hd::light_of(&hd_scene); + let hd_cascades = hd::cascades(camera, shadow_dir); + let mut hd_shadow_visible = std::mem::take(&mut self.hd_shadow_visible); + hd_shadow_visible.clear(); + if hd_wanted && shadow_strength > 0.0 { + let radius = + ((frame.detail_distance / 16.0) as i32).min((hd::CASCADES[1] / 16.0) as i32 + 2); + let light = Frustum { + planes: frustum(hd_cascades[1]), + }; + self.sections.visible( + camera, + radius, + &light, + |_| true, + |_, _| false, + &mut hd_shadow_visible, + ); + } // Mods' cameras: their sections round them, all of them (no occlusion), after the shadow's. let globals_size = std::mem::size_of::() as u64; let mut views_due = Vec::new(); @@ -4063,7 +4192,9 @@ impl Renderer { for v in views.choose(device, frame.mod_views, globals_size) { let (view_proj, view, aspect) = mod_views::matrices(v, camera, frame.depth_far); let own = world_projection(v.fov, aspect, frame.depth_far) * view; - let planes = Frustum { planes: frustum(own) }; + let planes = Frustum { + planes: frustum(own), + }; let mut seen = Vec::new(); // Where distant terrain covers a column, its tile draws instead, as in the world pass. self.sections.visible( @@ -4105,8 +4236,13 @@ impl Renderer { } else if frame.mod_views.is_empty() { self.mod_views = None; } - let view_sections: usize = views_due.iter().map(|d| d.visible.len() + d.lods.len()).sum(); - let instances = self.drawn_sections + shadow_visible.len() + view_sections; + let view_sections: usize = views_due + .iter() + .map(|d| d.visible.len() + d.lods.len()) + .sum(); + let instances = + self.drawn_sections + shadow_visible.len() + view_sections + hd_shadow_visible.len(); + let hd_shadow_first = (self.drawn_sections + shadow_visible.len() + view_sections) as u32; if instances as u64 > self.instance_capacity { self.instance_capacity = (instances as u64).next_power_of_two(); self.instances = self.device.create_buffer(&wgpu::BufferDescriptor { @@ -4125,14 +4261,13 @@ impl Renderer { .map(|v| section_instance(v.origin)) .chain(visible_lods.iter().map(|v| lod::instance(v.origin))) .chain(shadow_visible.iter().map(|v| section_instance(v.origin))) - .chain( - views_due.iter().flat_map(|d| { - d.visible - .iter() - .map(|v| section_instance(v.origin)) - .chain(d.lods.iter().map(|v| lod::instance(v.origin))) - }), - ), + .chain(views_due.iter().flat_map(|d| { + d.visible + .iter() + .map(|v| section_instance(v.origin)) + .chain(d.lods.iter().map(|v| lod::instance(v.origin))) + })) + .chain(hd_shadow_visible.iter().map(|v| section_instance(v.origin))), ); if !self.origins.is_empty() { self.uploads.buffer( @@ -4835,10 +4970,65 @@ impl Renderer { if hd_on && let Some(size) = self.world_post.size() { let format = self.config.format.remove_srgb_suffix(); let device = &self.device; - let hd = self.hd.get_or_insert_with(|| hd::Hd::new(device, size, format)); + let globals_size = std::mem::size_of::() as u64; + let hd = self + .hd + .get_or_insert_with(|| hd::Hd::new(device, size, format, globals_size)); hd.resize(device, size); if let Some(settings) = frame.hd { - hd.write(&self.queue, view_proj, frame.hd_lights, settings); + hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); + } + // The sun's (or moon's) shadow cascades: the world's globals swapped for the light's. + if shadow_strength > 0.0 { + self.queue + .write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals)); + for (c, cascade) in hd_cascades.iter().enumerate() { + let light = Globals { + view_proj: cascade.to_cols_array_2d(), + ..globals + }; + self.queue + .write_buffer(&hd.shadow_globals[c], 0, bytemuck::bytes_of(&light)); + encoder.copy_buffer_to_buffer( + &hd.shadow_globals[c], + 0, + &self.globals, + 0, + globals_size, + ); + let box_ = Frustum { + planes: frustum(*cascade), + }; + let mut pass = hd.shadow_pass(&mut encoder, c); + pass.set_bind_group(0, &self.group, &[]); + pass.set_pipeline(&self.shadow_terrain); + pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.set_vertex_buffer(1, self.instances.slice(..)); + let mut page = None; + for (k, v) in hd_shadow_visible.iter().enumerate() { + let s = self.sections.get(v.slot); + if s.solid == 0 { + continue; + } + let min = Vec3::from(v.origin); + if c == 0 && !box_.intersects(min, min + Vec3::splat(16.0)) { + continue; + } + if page != Some(s.page) { + pass.set_vertex_buffer(0, self.arena.buffer(s.page).slice(..)); + page = Some(s.page); + } + let instance = hd_shadow_first + k as u32; + pass.draw_indexed(0..s.solid * 6, s.start as i32, instance..instance + 1); + } + if entity_quads > 0 { + pass.set_pipeline(&self.shadow_entity); + pass.set_bind_group(0, &self.entity_group, &[]); + pass.set_vertex_buffer(0, self.entity_buffer.slice(..)); + pass.draw_indexed(0..entity_quads * 6, 0, 0..1); + } + } + encoder.copy_buffer_to_buffer(&hd.main_globals, 0, &self.globals, 0, globals_size); } let mut pass = hd.gbuffer_pass(&mut encoder); pass.set_bind_group(0, &self.group, &[]); @@ -4846,7 +5036,10 @@ impl Renderer { pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32); pass.set_vertex_buffer(1, self.instances.slice(..)); let mut page = None; - let sections = visible.iter().enumerate().map(|(k, v)| (k, self.sections.get(v.slot))); + let sections = visible + .iter() + .enumerate() + .map(|(k, v)| (k, self.sections.get(v.slot))); let tiles = visible_lods .iter() .enumerate() @@ -5397,11 +5590,22 @@ impl Renderer { { // AirDef fork: lit by the mods' lights into the HD target, the mod's post pass on // that, then bloom and the tone curve onto the screen. - hd.light(&self.device, &mut encoder, (color, depth)); + hd.light( + &self.device, + &mut encoder, + (color, depth), + (&self.lightmap_view, &self.lightmap_sampler), + ); match &mut self.mod_post { Some(post) if mod_post => { let size = self.world_post.size().unwrap_or([1, 1]); - post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); + post.write( + &self.queue, + frame, + size, + &self.mod_params.0, + self.mod_params.1, + ); post.run(&self.device, &mut encoder, (&hd.lit, depth), &hd.fx); hd.finish(&self.device, &mut encoder, &hd.fx, &view_target); } @@ -5412,7 +5616,13 @@ impl Renderer { && let Some(post) = &mut self.mod_post { let size = self.world_post.size().unwrap_or([1, 1]); - post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); + post.write( + &self.queue, + frame, + size, + &self.mod_params.0, + self.mod_params.1, + ); post.run(&self.device, &mut encoder, (color, depth), &view_target); } else if scaled.is_some() { self.world_post.finish(&mut encoder, &view_target); @@ -5635,6 +5845,7 @@ impl Renderer { self.visible = visible; self.visible_lods = visible_lods; self.shadow_visible = shadow_visible; + self.hd_shadow_visible = hd_shadow_visible; Ok(true) } } diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs index b40484e5e..c36c1795d 100644 --- a/crates/horizon-client/src/gfx/hd.rs +++ b/crates/horizon-client/src/gfx/hd.rs @@ -10,6 +10,8 @@ use glam::Mat4; /// The G-buffer's formats. pub const ALBEDO: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; pub const NORMAL: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; +/// Sky and block light per pixel. +pub const LIGHT_LEVELS: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// The lit world and the post pass's output. pub const HDR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float; /// The most lights drawn a frame. @@ -41,15 +43,54 @@ struct HdFrame { inverse_view_proj: [[f32; 4]; 4], /// width, height, 1/width, 1/height. resolution: [f32; 4], - /// lights, exposure, bloom, -. + /// lights, exposure, bloom, a frame counter. params: [f32; 4], + /// The shadow cascades: camera-relative position to the light's clip space. + cascades: [[[f32; 4]; 4]; 2], + /// xyz: towards the sun (or the moon at night); w: how much it lights (0 set or hidden). + sun: [f32; 4], + sun_color: [f32; 4], + fog_color: [f32; 4], + /// Environmental start, end; render-distance start, end. + fog: [f32; 4], + /// x: sky light kept in the shade; y: blocks' light; z: the air's density (rays, halos). + ambient: [f32; 4], + /// x: shadows on; y, z: the cascades' half sizes. + shadow: [f32; 4], } +/// What the world around looks like this frame (from the renderer's globals). +#[derive(Clone, Copy, Debug)] +pub struct HdScene { + pub view_proj: Mat4, + /// The camera in the world, for steady shadows. + pub camera: glam::DVec3, + /// Towards the sun, and the sky's darkening (0 day, 1 night). + pub sun: glam::Vec3, + pub dark: f32, + pub rain: f32, + pub fog_color: [f32; 4], + pub fog: [f32; 4], + /// The sky has a sun at all (the Overworld). + pub sky: bool, +} + +/// The cascades' half sizes (blocks) and shadow map size. +pub const CASCADES: [f32; 2] = [40.0, 176.0]; +pub const SHADOW_SIZE: u32 = 2048; + const COMMON: &str = r#" struct HdFrame { inverse_view_proj: mat4x4, resolution: vec4, params: vec4, + cascades: array, 2>, + sun: vec4, + sun_color: vec4, + fog_color: vec4, + fog: vec4, + ambient: vec4, + shadow: vec4, } struct FullOut { @builtin(position) position: vec4, @@ -77,38 +118,131 @@ struct Light { @group(0) @binding(4) var normal_tex: texture_2d; @group(0) @binding(5) var gbuffer_depth: texture_depth_2d; @group(0) @binding(6) var lights: array; +@group(0) @binding(7) var light_tex: texture_2d; +@group(0) @binding(8) var shadow_map: texture_depth_2d_array; +@group(0) @binding(9) var shadow_sampler: sampler_comparison; +@group(0) @binding(10) var lightmap: texture_2d; +@group(0) @binding(11) var linear: sampler; + +// Interleaved gradient noise: a different offset each pixel and frame, for the ray march. +fn ign(px: vec2) -> f32 { + let p = px + vec2(5.588238 * frame.params.w); + return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); +} + +// Where a camera-relative point falls in cascade c: uv and depth. +fn cascade_at(p: vec3, c: i32) -> vec3 { + let s = frame.cascades[c] * vec4(p, 1.0); + return vec3(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5, s.z); +} + +// The sun's (or moon's) light reaching p: 1 lit, 0 in shadow; soft-edged. +fn shadow(p: vec3, n: vec3) -> f32 { + let dist = length(p); + let c = select(1, 0, dist < frame.shadow.y * 0.92); + let texel = select(frame.shadow.z, frame.shadow.y, c == 0) * 2.0 / 2048.0; + let q = p + n * texel * 1.5; + let s = cascade_at(q, c); + if any(s.xy < vec2(0.0)) || any(s.xy > vec2(1.0)) || s.z > 1.0 { return 1.0; } + var lit = 0.0; + let step = 1.0 / 2048.0; + for (var y = -1; y <= 1; y++) { + for (var x = -1; x <= 1; x++) { + lit += textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy + vec2(f32(x), f32(y)) * step, c, s.z - 0.0005); + } + } + let fade = clamp((frame.shadow.z - dist) / 16.0, 0.0, 1.0); + return mix(1.0, lit / 9.0, fade); +} + +// One shadow sample, for the rays through the air. +fn lit_at(p: vec3) -> f32 { + let s = cascade_at(p, 1); + if any(s.xy < vec2(0.0)) || any(s.xy > vec2(1.0)) || s.z > 1.0 { return 1.0; } + return textureSampleCompareLevel(shadow_map, shadow_sampler, s.xy, 1, s.z - 0.001); +} + +fn linear_fog(d: f32, start: f32, end: f32) -> f32 { + if d <= start { return 0.0; } + if d >= end { return 1.0; } + return (d - start) / (end - start); +} @fragment fn fs_light(in: FullOut) -> @location(0) vec4 { let px = vec2(in.position.xy); - var c = textureLoad(scene_color, px, 0).rgb; + let scene = textureLoad(scene_color, px, 0).rgb; let d = textureLoad(scene_depth, px, 0); let g = textureLoad(gbuffer_depth, px, 0); - // Only where the G-buffer holds what the world shows (not the sky, water, particles). - if d <= 0.0 || abs(d - g) > max(d, g) * 0.002 { - return vec4(c, 1.0); - } - let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, d, 1.0); + let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0); let h = frame.inverse_view_proj * ndc; let p = h.xyz / h.w; - let n = normalize(textureLoad(normal_tex, px, 0).xyz); - let albedo = textureLoad(albedo_tex, px, 0); - var light = vec3(0.0); + let view = normalize(p); + let sun = frame.sun.xyz; + let sun_on = frame.sun.w; let count = u32(frame.params.x); + var c = scene; + let surface = d > 0.0 && abs(d - g) <= max(d, g) * 0.002; + if surface { + let n = normalize(textureLoad(normal_tex, px, 0).xyz); + let albedo = textureLoad(albedo_tex, px, 0); + let lm = textureLoad(light_tex, px, 0).xy; + // Vanilla's light (sky and blocks through the lightmap), shaded where the sun can't reach. + let base = textureSampleLevel(lightmap, linear, clamp(vec2(lm.y, lm.x) * (15.0 / 16.0) + 0.5 / 16.0, vec2(0.5 / 16.0), vec2(15.5 / 16.0)), 0.0).rgb; + let block = textureSampleLevel(lightmap, linear, vec2(lm.y * (15.0 / 16.0) + 0.5 / 16.0, 0.5 / 16.0), 0.0).rgb; + let sky_reach = smoothstep(0.35, 0.95, lm.x); + var s = 1.0; + let ndl = dot(n, sun); + if sun_on > 0.0 && ndl > 0.0 && sky_reach > 0.0 { s = shadow(p, n); } else { s = 0.0; } + // Shadowed sky light (ambient) keeps vanilla's look, blocks' light stays as is; the sun adds + // direct light by the face's angle. + let direct = frame.sun_color.rgb * sun_on * max(ndl, 0.0) * s * sky_reach; + var light = max(base * frame.ambient.x, block * frame.ambient.y) + direct; + // The mods' lights. + for (var i = 0u; i < count; i++) { + let l = lights[i]; + let to = l.pos.xyz - p; + let dist = length(to); + if dist >= l.pos.w { continue; } + let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); + let falloff = l.color.w / (dist * dist + 1.0) * window; + let k = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); + light += l.color.rgb * falloff * k; + } + c = albedo.rgb * light + albedo.rgb * albedo.a * 1.5; + // Vanilla's fog over it again. + let sph = length(p); + let cyl = max(length(p.xz), abs(p.y)); + let f = max(linear_fog(sph, frame.fog.x, frame.fog.y), linear_fog(cyl, frame.fog.z, frame.fog.w)); + c = mix(c, frame.fog_color.rgb, f * frame.fog_color.a); + } + // Light in the air: the sun's rays between the shadows, and halos round the mods' lights. + let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0); + let density = frame.ambient.z; + if sun_on > 0.0 && density > 0.0 { + let steps = 14; + let jitter = ign(in.position.xy); + var acc = 0.0; + for (var i = 0; i < steps; i++) { + let t = (f32(i) + jitter) / f32(steps) * far; + acc += lit_at(view * t); + } + // Henyey-Greenstein, forward scattering: brightest looking towards the sun. + let cos_t = dot(view, sun); + let gk = 0.6; + let phase = (1.0 - gk * gk) / pow(1.0 + gk * gk - 2.0 * gk * cos_t, 1.5) * 0.08; + c += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); + } for (var i = 0u; i < count; i++) { let l = lights[i]; - let to = l.pos.xyz - p; - let dist = length(to); - if dist >= l.pos.w { continue; } - // Inverse square, softened near the light, faded to nothing at its radius. - let window = pow(clamp(1.0 - pow(dist / l.pos.w, 4.0), 0.0, 1.0), 2.0); - let falloff = l.color.w / (dist * dist + 1.0) * window; - let ndl = max(dot(n, to / max(dist, 0.001)) * 0.85 + 0.15, 0.0); - light += l.color.rgb * falloff * ndl; + // Nearest point of the view ray to the light, and the glow along the ray in front of it. + let t0 = dot(l.pos.xyz, view); + let hh = length(l.pos.xyz - view * t0) + 0.5; + if hh > l.pos.w * 0.5 { continue; } + let a0 = atan((0.0 - t0) / hh); + let a1 = atan((far - t0) / hh); + c += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + density * 300.0); } - c += albedo.rgb * light; - // Glowing parts (flames, flares) shine past white, for the bloom. - c += albedo.rgb * albedo.a * 1.5; return vec4(c, 1.0); } "#; @@ -188,6 +322,10 @@ fn fs_tonemap(in: FullOut) -> @location(0) vec4 { // Very bright light bleaches towards white, as film does. c = mix(c, vec3(rolled), clamp((peak - 2.0) / 8.0, 0.0, 0.6)); } + // A light grade: a touch more colour, and a gentle S round the middle grey. + let luma = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c = max(mix(vec3(luma), c, 1.12), vec3(0.0)); + c = mix(c, c * c * (3.0 - 2.0 * c), 0.18); return vec4(c, 1.0); } "#; @@ -196,11 +334,20 @@ struct Target { view: wgpu::TextureView, } -fn target(device: &wgpu::Device, label: &str, size: [u32; 2], format: wgpu::TextureFormat) -> Target { +fn target( + device: &wgpu::Device, + label: &str, + size: [u32; 2], + format: wgpu::TextureFormat, +) -> Target { let view = device .create_texture(&wgpu::TextureDescriptor { label: Some(label), - size: wgpu::Extent3d { width: size[0].max(1), height: size[1].max(1), depth_or_array_layers: 1 }, + size: wgpu::Extent3d { + width: size[0].max(1), + height: size[1].max(1), + depth_or_array_layers: 1, + }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, @@ -216,6 +363,15 @@ pub struct Hd { size: [u32; 2], pub albedo: wgpu::TextureView, pub normal: wgpu::TextureView, + pub levels: wgpu::TextureView, + /// The shadow cascades: the array to sample, one view per layer to draw. + shadow_array: wgpu::TextureView, + shadow_layers: Vec, + shadow_sampler: wgpu::Sampler, + /// The globals the shadow passes draw with (the light's view), and the camera's to put back. + pub shadow_globals: [wgpu::Buffer; 2], + pub main_globals: wgpu::Buffer, + frames: u32, pub depth: wgpu::TextureView, /// The lit world, and the post pass's output. pub lit: wgpu::TextureView, @@ -258,12 +414,21 @@ fn fullscreen( device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { module, entry_point: Some("vs_full"), compilation_options: Default::default(), buffers: &[] }, + vertex: wgpu::VertexState { + module, + entry_point: Some("vs_full"), + compilation_options: Default::default(), + buffers: &[], + }, fragment: Some(wgpu::FragmentState { module, entry_point: Some(entry), compilation_options: Default::default(), - targets: &[Some(wgpu::ColorTargetState { format, blend, write_mask: wgpu::ColorWrites::ALL })], + targets: &[Some(wgpu::ColorTargetState { + format, + blend, + write_mask: wgpu::ColorWrites::ALL, + })], }), primitive: Default::default(), depth_stencil: None, @@ -286,8 +451,11 @@ fn entries(kinds: &[wgpu::BindingType]) -> Vec { .collect() } -const UNIFORM: wgpu::BindingType = - wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }; +const UNIFORM: wgpu::BindingType = wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, +}; const FLOAT: wgpu::BindingType = wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, @@ -306,7 +474,12 @@ const DEPTH: wgpu::BindingType = wgpu::BindingType::Texture { const SAMPLER: wgpu::BindingType = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering); impl Hd { - pub fn new(device: &wgpu::Device, size: [u32; 2], screen: wgpu::TextureFormat) -> Self { + pub fn new( + device: &wgpu::Device, + size: [u32; 2], + screen: wgpu::TextureFormat, + globals_size: u64, + ) -> Self { let light_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("HD lighting"), entries: &entries(&[ @@ -321,6 +494,15 @@ impl Hd { has_dynamic_offset: false, min_binding_size: None, }, + UNFILTERED, + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison), + FLOAT, + SAMPLER, ]), }); let bloom_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -335,14 +517,58 @@ impl Hd { let bloom = module(device, "HD bloom", BLOOM); let tone = module(device, "HD tone mapping", TONEMAP); let add = wgpu::BlendState { - color: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Add }, + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, alpha: wgpu::BlendComponent::REPLACE, }; - let light_pipeline = fullscreen(device, "HD lighting", &light_layout, &light, "fs_light", HDR, None); - let prefilter = fullscreen(device, "HD bloom prefilter", &bloom_layout, &bloom, "fs_prefilter", HDR, None); - let down = fullscreen(device, "HD bloom down", &bloom_layout, &bloom, "fs_down", HDR, None); - let up = fullscreen(device, "HD bloom up", &bloom_layout, &bloom, "fs_up", HDR, Some(add)); - let tone = fullscreen(device, "HD tone mapping", &tone_layout, &tone, "fs_tonemap", screen, None); + let light_pipeline = fullscreen( + device, + "HD lighting", + &light_layout, + &light, + "fs_light", + HDR, + None, + ); + let prefilter = fullscreen( + device, + "HD bloom prefilter", + &bloom_layout, + &bloom, + "fs_prefilter", + HDR, + None, + ); + let down = fullscreen( + device, + "HD bloom down", + &bloom_layout, + &bloom, + "fs_down", + HDR, + None, + ); + let up = fullscreen( + device, + "HD bloom up", + &bloom_layout, + &bloom, + "fs_up", + HDR, + Some(add), + ); + let tone = fullscreen( + device, + "HD tone mapping", + &tone_layout, + &tone, + "fs_tonemap", + screen, + None, + ); let frame = device.create_buffer(&wgpu::BufferDescriptor { label: Some("HD frame"), size: std::mem::size_of::() as u64, @@ -363,11 +589,70 @@ impl Hd { address_mode_v: wgpu::AddressMode::ClampToEdge, ..Default::default() }); + let shadow_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("HD shadow cascades"), + size: wgpu::Extent3d { + width: SHADOW_SIZE, + height: SHADOW_SIZE, + depth_or_array_layers: 2, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let shadow_array = shadow_texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let shadow_layers = (0..2) + .map(|l| { + shadow_texture.create_view(&wgpu::TextureViewDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2), + base_array_layer: l, + array_layer_count: Some(1), + ..Default::default() + }) + }) + .collect(); + let shadow_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("HD shadows"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }); + let globals = |label| { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size: globals_size, + usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + }; let mut hd = Hd { size: [0, 0], albedo: target(device, "HD albedo", [1, 1], ALBEDO).view, normal: target(device, "HD normal", [1, 1], NORMAL).view, - depth: target(device, "HD depth", [1, 1], wgpu::TextureFormat::Depth32Float).view, + levels: target(device, "HD light levels", [1, 1], LIGHT_LEVELS).view, + shadow_array, + shadow_layers, + shadow_sampler, + shadow_globals: [ + globals("HD shadow globals 0"), + globals("HD shadow globals 1"), + ], + main_globals: globals("HD camera globals"), + frames: 0, + depth: target( + device, + "HD depth", + [1, 1], + wgpu::TextureFormat::Depth32Float, + ) + .view, lit: target(device, "HD lit", [1, 1], HDR).view, fx: target(device, "HD post", [1, 1], HDR).view, bloom: Vec::new(), @@ -395,6 +680,7 @@ impl Hd { self.size = size; self.albedo = target(device, "HD albedo", size, ALBEDO).view; self.normal = target(device, "HD normal", size, NORMAL).view; + self.levels = target(device, "HD light levels", size, LIGHT_LEVELS).view; self.depth = target(device, "HD depth", size, wgpu::TextureFormat::Depth32Float).view; self.lit = target(device, "HD lit", size, HDR).view; self.fx = target(device, "HD post", size, HDR).view; @@ -406,20 +692,77 @@ impl Hd { .collect(); } - /// This frame's camera, lights and settings. - pub fn write(&mut self, queue: &wgpu::Queue, view_proj: Mat4, lights: &[HdLight], settings: HdSettings) { + /// This frame's camera, sun, fog, lights and settings. + pub fn write( + &mut self, + queue: &wgpu::Queue, + scene: &HdScene, + lights: &[HdLight], + settings: HdSettings, + ) { let n = lights.len().min(MAX_LIGHTS); if n > 0 { queue.write_buffer(&self.lights, 0, bytemuck::cast_slice(&lights[..n])); } + self.frames = self.frames.wrapping_add(1); + // The sun by day, the moon (opposite) by night, faded out round the horizon. + let (dir, strength, color) = light_of(scene); + let cascades = cascades(scene.camera, dir); + let night = scene.dark.clamp(0.0, 1.0); let frame = HdFrame { - inverse_view_proj: view_proj.inverse().to_cols_array_2d(), - resolution: [self.size[0] as f32, self.size[1] as f32, 1.0 / self.size[0] as f32, 1.0 / self.size[1] as f32], - params: [n as f32, settings.exposure, settings.bloom, 0.0], + inverse_view_proj: scene.view_proj.inverse().to_cols_array_2d(), + resolution: [ + self.size[0] as f32, + self.size[1] as f32, + 1.0 / self.size[0] as f32, + 1.0 / self.size[1] as f32, + ], + params: [ + n as f32, + settings.exposure, + settings.bloom, + (self.frames % 64) as f32, + ], + cascades: cascades.map(|m| m.to_cols_array_2d()), + sun: [dir.x, dir.y, dir.z, strength], + sun_color: [color.x, color.y, color.z, night], + fog_color: scene.fog_color, + fog: scene.fog, + // In the shade by day a surface keeps half its sky light; at night almost all. + ambient: [0.5 + 0.45 * night, 1.0, 0.0018 + scene.rain * 0.006, 0.0], + shadow: [ + if strength > 0.0 { 1.0 } else { 0.0 }, + CASCADES[0], + CASCADES[1], + 0.0, + ], }; queue.write_buffer(&self.frame, 0, bytemuck::bytes_of(&frame)); } + /// The shadow pass of cascade `c`: its depth layer cleared, nothing else. + pub fn shadow_pass<'e>( + &self, + encoder: &'e mut wgpu::CommandEncoder, + c: usize, + ) -> wgpu::RenderPass<'e> { + encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD shadow cascade"), + color_attachments: &[], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow_layers[c], + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }) + .forget_lifetime() + } + /// The G-buffer pass's attachments: albedo and normal cleared, its own depth. pub fn gbuffer_pass<'e>(&self, encoder: &'e mut wgpu::CommandEncoder) -> wgpu::RenderPass<'e> { let clear = |view| { @@ -427,23 +770,41 @@ impl Hd { view, resolve_target: None, depth_slice: None, - ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, }) }; - encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("HD G-buffer"), - color_attachments: &[clear(&self.albedo), clear(&self.normal)], - depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { - view: &self.depth, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), store: wgpu::StoreOp::Store }), - stencil_ops: None, - }), - ..Default::default() - }) - .forget_lifetime() + encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD G-buffer"), + color_attachments: &[ + clear(&self.albedo), + clear(&self.normal), + clear(&self.levels), + ], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.depth, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(0.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }) + .forget_lifetime() } - fn draw(encoder: &mut wgpu::CommandEncoder, label: &str, to: &wgpu::TextureView, load: bool, pipeline: &wgpu::RenderPipeline, group: &wgpu::BindGroup) { + fn draw( + encoder: &mut wgpu::CommandEncoder, + label: &str, + to: &wgpu::TextureView, + load: bool, + pipeline: &wgpu::RenderPipeline, + group: &wgpu::BindGroup, + ) { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some(label), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -451,7 +812,11 @@ impl Hd { resolve_target: None, depth_slice: None, ops: wgpu::Operations { - load: if load { wgpu::LoadOp::Load } else { wgpu::LoadOp::Clear(wgpu::Color::BLACK) }, + load: if load { + wgpu::LoadOp::Load + } else { + wgpu::LoadOp::Clear(wgpu::Color::BLACK) + }, store: wgpu::StoreOp::Store, }, })], @@ -463,21 +828,75 @@ impl Hd { } /// Lights the world (its colour and depth) into `lit`. - pub fn light(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: (&wgpu::TextureView, &wgpu::TextureView)) { + pub fn light( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + world: (&wgpu::TextureView, &wgpu::TextureView), + lightmap: (&wgpu::TextureView, &wgpu::Sampler), + ) { let group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("HD lighting"), layout: &self.light_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(world.0) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(world.1) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&self.albedo) }, - wgpu::BindGroupEntry { binding: 4, resource: wgpu::BindingResource::TextureView(&self.normal) }, - wgpu::BindGroupEntry { binding: 5, resource: wgpu::BindingResource::TextureView(&self.depth) }, - wgpu::BindGroupEntry { binding: 6, resource: self.lights.as_entire_binding() }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.frame.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(world.0), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(world.1), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::TextureView(&self.albedo), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: wgpu::BindingResource::TextureView(&self.normal), + }, + wgpu::BindGroupEntry { + binding: 5, + resource: wgpu::BindingResource::TextureView(&self.depth), + }, + wgpu::BindGroupEntry { + binding: 6, + resource: self.lights.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 7, + resource: wgpu::BindingResource::TextureView(&self.levels), + }, + wgpu::BindGroupEntry { + binding: 8, + resource: wgpu::BindingResource::TextureView(&self.shadow_array), + }, + wgpu::BindGroupEntry { + binding: 9, + resource: wgpu::BindingResource::Sampler(&self.shadow_sampler), + }, + wgpu::BindGroupEntry { + binding: 10, + resource: wgpu::BindingResource::TextureView(lightmap.0), + }, + wgpu::BindGroupEntry { + binding: 11, + resource: wgpu::BindingResource::Sampler(lightmap.1), + }, ], }); - Self::draw(encoder, "HD lighting", &self.lit, false, &self.light_pipeline, &group); + Self::draw( + encoder, + "HD lighting", + &self.lit, + false, + &self.light_pipeline, + &group, + ); } fn bloom_group(&self, device: &wgpu::Device, source: &wgpu::TextureView) -> wgpu::BindGroup { @@ -485,36 +904,140 @@ impl Hd { label: Some("HD bloom"), layout: &self.bloom_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(source) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.frame.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }) } /// Bloom from `source` (the lit world or the post pass's output), then the tone curve onto `screen`. - pub fn finish(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, source: &wgpu::TextureView, screen: &wgpu::TextureView) { + pub fn finish( + &self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + source: &wgpu::TextureView, + screen: &wgpu::TextureView, + ) { // Down: the prefilter into the first level, then each level from the one above. - Self::draw(encoder, "HD bloom prefilter", &self.bloom[0].view, false, &self.prefilter, &self.bloom_group(device, source)); + Self::draw( + encoder, + "HD bloom prefilter", + &self.bloom[0].view, + false, + &self.prefilter, + &self.bloom_group(device, source), + ); for k in 1..self.bloom.len() { let g = self.bloom_group(device, &self.bloom[k - 1].view); - Self::draw(encoder, "HD bloom down", &self.bloom[k].view, false, &self.down, &g); + Self::draw( + encoder, + "HD bloom down", + &self.bloom[k].view, + false, + &self.down, + &g, + ); } // Up: each level added onto the bigger one. for k in (1..self.bloom.len()).rev() { let g = self.bloom_group(device, &self.bloom[k].view); - Self::draw(encoder, "HD bloom up", &self.bloom[k - 1].view, true, &self.up, &g); + Self::draw( + encoder, + "HD bloom up", + &self.bloom[k - 1].view, + true, + &self.up, + &g, + ); } let group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("HD tone mapping"), layout: &self.tone_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.frame.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(source) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&self.bloom[0].view) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.frame.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(source), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(&self.bloom[0].view), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); - Self::draw(encoder, "HD tone mapping", screen, false, &self.tone, &group); + Self::draw( + encoder, + "HD tone mapping", + screen, + false, + &self.tone, + &group, + ); } } + +/// The light that casts the shadows: towards the sun by day, the moon by night; how strong +/// (0 below the horizon or without a sky) and its colour (warm low, white high, cold moon). +pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) { + if !scene.sky { + return (glam::Vec3::Y, 0.0, glam::Vec3::ZERO); + } + let sun = scene.sun.normalize_or(glam::Vec3::Y); + let clear = 1.0 - scene.rain * 0.85; + if sun.y > -0.02 { + let up = smooth(-0.02, 0.18, sun.y); + let warm = smooth(0.0, 0.45, sun.y); + let color = glam::Vec3::new(1.0, 0.62, 0.38).lerp(glam::Vec3::new(1.0, 0.95, 0.86), warm); + (sun, 0.6 * up * clear, color) + } else { + let moon = -sun; + let up = smooth(-0.02, 0.18, moon.y); + (moon, 0.15 * up * clear, glam::Vec3::new(0.55, 0.66, 1.0)) + } +} + +fn smooth(a: f32, b: f32, x: f32) -> f32 { + let t = ((x - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// The cascades' light view-projections (camera-relative), each square snapped to its texels in +/// the world so the shadows don't crawl as the camera moves. +pub fn cascades(camera: glam::DVec3, light: glam::Vec3) -> [Mat4; 2] { + let l = light.normalize_or(glam::Vec3::Y).as_dvec3(); + let forward = -l; + let reference = if l.y.abs() > 0.99 { + glam::DVec3::Z + } else { + glam::DVec3::Y + }; + let right = forward.cross(reference).normalize(); + let up = right.cross(forward); + CASCADES.map(|r| { + let texel = f64::from(r) * 2.0 / f64::from(SHADOW_SIZE); + let snap = |v: f64| (v / texel).floor() * texel - v; + let centre = right * snap(camera.dot(right)) + up * snap(camera.dot(up)); + let eye = centre + l * 400.0; + let view = glam::DMat4::look_at_rh(eye, centre, up); + let r = f64::from(r); + let projection = glam::DMat4::orthographic_rh(-r, r, -r, r, 1.0, 800.0); + (projection * view).as_mat4() + }) +} diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs index 497cff854..d1f54a4bb 100644 --- a/crates/horizon-client/src/gfx/mod_post.rs +++ b/crates/horizon-client/src/gfx/mod_post.rs @@ -121,7 +121,11 @@ fn compile(source: &str) -> Result { let text = e.emit_to_string(&full); match e.location(&full) { Some(l) if l.line_number as usize > prelude_lines => { - format!("line {}: {}", l.line_number as usize - prelude_lines, e.message()) + format!( + "line {}: {}", + l.line_number as usize - prelude_lines, + e.message() + ) } _ => text, } @@ -136,7 +140,11 @@ fn compile(source: &str) -> Result { } impl ModPost { - pub fn new(device: &wgpu::Device, source: &str, format: wgpu::TextureFormat) -> Result { + pub fn new( + device: &wgpu::Device, + source: &str, + format: wgpu::TextureFormat, + ) -> Result { let module = compile(source)?; let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { binding, @@ -232,11 +240,19 @@ impl ModPost { } /// This frame's camera and the mod's `params`, for a world of `size`. - pub fn write(&self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2], params: &[f32], partial: f32) { + pub fn write( + &self, + queue: &wgpu::Queue, + frame: &Frame, + size: [u32; 2], + params: &[f32], + partial: f32, + ) { let aspect = size[0] as f32 / size[1].max(1) as f32; let view = crate::camera::view_matrix(frame.yaw, frame.pitch); - let projection = - super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob * frame.spin; + let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far) + * frame.bob + * frame.spin; let a = frame.sky.sun_angle.to_radians(); let sun = [-a.sin(), a.cos(), 0.0, 0.0]; let mut p = [[0.0f32; 4]; PARAMS / 4]; @@ -248,14 +264,24 @@ impl ModPost { view_proj: m(projection * view), view_inverse: m(view.inverse()), projection_inverse: m(projection.inverse()), - camera: [frame.camera.x as f32, frame.camera.y as f32, frame.camera.z as f32, 0.0], + camera: [ + frame.camera.x as f32, + frame.camera.y as f32, + frame.camera.z as f32, + 0.0, + ], resolution: [ size[0] as f32, size[1] as f32, 1.0 / size[0].max(1) as f32, 1.0 / size[1].max(1) as f32, ], - time: [self.start.elapsed().as_secs_f32() % 3600.0, partial, sun[1], 0.0], + time: [ + self.start.elapsed().as_secs_f32() % 3600.0, + partial, + sun[1], + 0.0, + ], sun, params: p, }; @@ -279,15 +305,29 @@ impl ModPost { label: Some("Mod post"), layout: &self.layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: self.buffer.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(world.0) }, - wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(world.1) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + wgpu::BindGroupEntry { + binding: 0, + resource: self.buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(world.0), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::TextureView(world.1), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: wgpu::BindingResource::Sampler(&self.sampler), + }, ], }); self.group = Some(((world.0.clone(), world.1.clone()), group)); } - let Some((_, group)) = &self.group else { return }; + let Some((_, group)) = &self.group else { + return; + }; let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Mod post"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { diff --git a/crates/horizon-client/src/gfx/mod_views.rs b/crates/horizon-client/src/gfx/mod_views.rs index c0d886afd..aa2c5b971 100644 --- a/crates/horizon-client/src/gfx/mod_views.rs +++ b/crates/horizon-client/src/gfx/mod_views.rs @@ -194,7 +194,8 @@ impl Views { views: &'a [ModView], globals_size: u64, ) -> Vec<&'a ModView> { - self.targets.retain(|id, _| views.iter().any(|v| v.id == *id)); + self.targets + .retain(|id, _| views.iter().any(|v| v.id == *id)); for v in views { if self.targets.get(&v.id).is_none_or(|t| t.size != v.size) { let t = self.target(device, v.size, globals_size); @@ -318,5 +319,9 @@ pub(super) fn matrices(v: &ModView, eye: DVec3, far: f32) -> (Mat4, Mat4, f32) { let view = crate::camera::view_matrix(v.yaw, v.pitch); let projection = super::world_projection(v.fov, aspect, far); let offset = (eye - v.camera).as_vec3(); - (projection * view * Mat4::from_translation(offset), view, aspect) + ( + projection * view * Mat4::from_translation(offset), + view, + aspect, + ) } diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index 293729e33..bb5c70f63 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -74,6 +74,8 @@ struct TerrainOut { // AirDef fork, HD lighting's G-buffer: the camera-relative position and the unlit colour. @location(6) pos: vec3, @location(7) tint: vec4, + // Sky and block light, 0 to 1. + @location(8) lm: vec2, } // Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units // (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one @@ -132,6 +134,7 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 f32 { @@ -159,6 +162,7 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 { struct GBufferOut { @location(0) albedo: vec4, @location(1) normal: vec4, + @location(2) light: vec4, } fn face_normal(pos: vec3) -> vec3 { var n = normalize(cross(dpdx(pos), dpdy(pos))); @@ -171,8 +175,14 @@ fn face_normal(pos: vec3) -> vec3 { var out: GBufferOut; out.albedo = vec4(color.rgb, f32(in.emission) / 15.0); out.normal = vec4(face_normal(in.pos), 1.0); + out.light = vec4(clamp(in.lm, vec2(0.0), vec2(1.0)), 0.0, 1.0); return out; } +// AirDef fork: the sun's shadow map — depth only, cut-out leaves leave holes. +@fragment fn fs_shadow(in: TerrainOut) { + let a = sprite(in.uv, in.layer & 0x7fffu).a; + if a < 0.5 && (in.layer & 0x8000u) == 0u { discard; } +} @fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4 { let color = sprite(in.uv, in.layer & 0x7fffu) * in.color; if color.a < 0.01 { discard; }