From 0bcc4b56dcbe0dfdb41d630601affa49eafba4d2 Mon Sep 17 00:00:00 2001 From: chexit Date: Thu, 29 Oct 2026 22:56:15 +0300 Subject: [PATCH] HD fixes: rain level from the world not from rain geometry (#41), TAA history on the jittered grid (#38), vanilla cloud tint without a depth heuristic (#35), air fog once over water (#37), translucent things in front of HD water (#36), cheaper sunrise fan (#39), halos in thick air capped; mods: render.set-time-override (API level 13) so replays show their recorded time of day (#57) Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/entity.wgsl | 2 + crates/horizon-client/src/environment.rs | 5 + crates/horizon-client/src/game.rs | 18 +- crates/horizon-client/src/gfx.rs | 52 ++- crates/horizon-client/src/gfx/hd.rs | 423 ++++++++++++++++------- crates/horizon-client/src/gfx/hd_sky.rs | 8 + crates/horizon-client/src/terrain.wgsl | 8 + crates/horizon-mod-sdk/wit/horizon.wit | 5 + crates/horizon-mods/src/api_level.rs | 6 +- crates/horizon-mods/src/runtime.rs | 12 + docs/api/mods-reference.md | 1 + 11 files changed, 393 insertions(+), 147 deletions(-) diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 61bc1f92a..b3fc9bc82 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -20,6 +20,8 @@ struct Globals { crumbling_sprite: vec4, // Selection outline parameters (read by terrain.wgsl). outline_params: vec4, + // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out). + hd_flags: vec4, // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). dynamic_lights: array, 16>, } diff --git a/crates/horizon-client/src/environment.rs b/crates/horizon-client/src/environment.rs index 20855b749..9b8acf13f 100644 --- a/crates/horizon-client/src/environment.rs +++ b/crates/horizon-client/src/environment.rs @@ -141,6 +141,9 @@ pub struct Sky { pub fog_end: f32, /// `visual/cloud_fog_end_distance`. pub cloud_fog_end: f32, + /// AirDef fork: the world's rain level (0..1), set by `apply_weather`. The HD sky reads this + /// rather than the presence of rain geometry near the camera. + pub rain: f32, } impl Environment { pub fn load(root: &Path) -> Result { @@ -305,6 +308,7 @@ impl Environment { .base .get("minecraft:visual/fog_end_distance") .map_or(1024.0, |v| v[0]), + rain: 0.0, cloud_fog_end: self .base .get("minecraft:visual/cloud_fog_end_distance") @@ -315,6 +319,7 @@ impl Environment { /// WeatherAttributes: rain then thunder layers lerped by their levels /// (rain counts only the part not covered by thunder). pub fn apply_weather(sky: &mut Sky, rain: f32, thunder: f32) { + sky.rain = rain.clamp(0.0, 1.0); let grey = |c: [f32; 3]| c[0] * 0.3 + c[1] * 0.59 + c[2] * 0.11; let lerp3 = |t: f32, a: [f32; 3], b: [f32; 3]| -> [f32; 3] { std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 366be39c4..fad8de187 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -8370,6 +8370,14 @@ impl Game<'_> { .collect() } + /// The time of day the player sees: a mod's override (a replay's recorded time), else the world's. + fn shown_day_time(&self) -> i64 { + self.mods + .mods + .time_override() + .map_or(self.world.day_time, |t| t as i64) + } + fn sky_darken(&self) -> i32 { if self.dim == Dim::Nether { return 11; @@ -8378,12 +8386,12 @@ impl Game<'_> { let sky = self .res .end_environment() - .sample(self.world.day_time, None, None); + .sample(self.shown_day_time(), None, None); return (15.0 - 15.0 * sky.sky_light_level).round() as i32; } let (rain, thunder) = self.world.weather.levels(1.0); // Sampled once per (time, weather): every mob asks each tick. - let key = (self.world.day_time, rain.to_bits(), thunder.to_bits()); + let key = (self.shown_day_time(), rain.to_bits(), thunder.to_bits()); if let Some((k, darken)) = self.sky_darken_memo.get() && k == key { @@ -8392,7 +8400,7 @@ impl Game<'_> { let mut sky = self .res .environment() - .sample(self.world.day_time, None, None); + .sample(self.shown_day_time(), None, None); horizon_client::environment::apply_weather(&mut sky, rain, thunder); // Level.updateSkyBrightness: darkening from the sky light level. let darken = (15.0 - 15.0 * sky.sky_light_level).round() as i32; @@ -8898,7 +8906,7 @@ impl Game<'_> { "first_quarter", "waxing_gibbous", ]; - let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize]; + let phase = phases[(self.shown_day_time() / 24000).rem_euclid(8) as usize]; body( &format!("environment/celestial/moon/{phase}"), sky.moon_angle, @@ -11608,7 +11616,7 @@ impl Game<'_> { ); // visual/water_fog_color: no dimension sets it, its default is #050533. let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]); - let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog)); + let mut sky = environment.sample(self.shown_day_time(), Some(biome_sky), Some(biome_fog)); let (rain, thunder) = if self.dim == Dim::Overworld { self.world.weather.levels(alpha as f32) } else { diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 072090e33..a8031c1f6 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -194,6 +194,9 @@ struct Globals { crumbling_sprite: [f32; 4], /// Framebuffer size, selection stroke width, perspective layering scale. outline_params: [f32; 4], + /// AirDef fork. x: 1 while the HD water draws the water (fs_translucent then leaves vanilla's + /// water out of the colour and keeps its depth). + hd_flags: [f32; 4], /// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`). dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX], } @@ -578,7 +581,7 @@ pub struct Renderer { hd_far: Option<(Mat4, glam::DVec3, Vec3, u32)>, /// HD's TAA: frames jittered so far, and the last one's unjittered view-projection and camera. taa_frame: u32, - taa_prev: Option<(Mat4, glam::DVec3)>, + taa_prev: Option<(Mat4, glam::DVec3, Mat4)>, hd: Option, mod_params: (Vec, f32), /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). @@ -1207,17 +1210,15 @@ impl Renderer { if sprite_arrays > 1 { log::info!("Horizon: {layers} block textures in {sprite_arrays} arrays"); } + // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water (and + // fs_translucent clears while the HD water draws it instead). let sprite_constants = [ ("SPRITE_ARRAYS", f64::from(sprite_arrays)), ("SPRITE_SPLIT", f64::from(split)), - ]; - // AirDef fork, HD water: the sprite layers terrain.wgsl's fs_water takes for water. - let water_constants = [ - ("SPRITE_ARRAYS", f64::from(sprite_arrays)), - ("SPRITE_SPLIT", f64::from(split)), ("WATER_STILL", f64::from(water_layers[0])), ("WATER_FLOW", f64::from(water_layers[1])), ]; + let water_constants = sprite_constants; let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("Horizon terrain bindings"), entries: &[ @@ -2823,7 +2824,7 @@ impl Renderer { mip_levels, mipmaps: mip_levels - 1, terrain_layout: bindings, - terrain_sprites: sprite_constants.map(|(_, v)| v), + terrain_sprites: [f64::from(sprite_arrays), f64::from(split)], sprite_sampler, lightmap_view, lightmap_sampler, @@ -4049,7 +4050,7 @@ impl Renderer { }; let taa_prev = std::mem::replace( &mut self.taa_prev, - taa.then_some((unjittered, frame.camera)), + taa.then_some((unjittered, frame.camera, view_proj)), ); if frame.title { self.panorama.prepare( @@ -4135,6 +4136,7 @@ impl Renderer { frame.glint_ticks, ], outline_params: outline_params(self.config.width, self.config.height), + hd_flags: [0.0; 4], dynamic_lights: { let mut lights = [[0.0; 4]; crate::dynamic_light::MAX]; for (to, from) in lights.iter_mut().zip(frame.dynamic_lights) { @@ -4260,11 +4262,14 @@ impl Renderer { taa: taa && taa_prev.is_some(), jitter_px, prev_view_proj: taa_prev.map_or(unjittered, |p| p.0), + prev_view_proj_jittered: taa_prev.map_or(view_proj, |p| p.2), camera_delta: taa_prev.map_or(Vec3::ZERO, |p| (frame.camera - p.1).as_vec3()), // 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 }, + // The world's rain level (fades in and out with the weather), not the rain geometry + // near the camera: hiding the particles or standing under a roof leaves the sky alone. + rain: frame.sky.rain.clamp(0.0, 1.0) * 0.7, fog_color, fog, sky: frame.skybox && !frame.end_sky && !frame.nether_lighting && !frame.underwater, @@ -4827,7 +4832,20 @@ impl Renderer { + shadow_quads + translucent_particle_quads + translucent_item_quads; - let cloud_quads = if let Some(input) = &frame.clouds { + // AirDef fork: vanilla's clouds (HD's volumetric ones off) take the low sun's colour in their + // own colour, so a mountain or glass behind them does not change it. + let cloud_input = frame.clouds.as_ref().map(|c| { + if !hd_wanted || !hd_scene.sky || hd::clouds_enabled() { + return crate::clouds::Input { ..*c }; + } + let [r, g, b, a] = c.color; + let [tr, tg, tb] = hd::vanilla_cloud_tint(&hd_scene); + crate::clouds::Input { + color: [r * tr, g * tg, b * tb, a], + ..*c + } + }); + let cloud_quads = if let Some(input) = &cloud_input { let (rebuilt, uniform) = crate::spans::time("clouds", || self.cloud_mesh.prepare(input)); let vertices = self.cloud_mesh.vertices.len() as u64; @@ -5154,6 +5172,15 @@ impl Renderer { if let Some(settings) = frame.hd { hd.write(&self.queue, &hd_scene, frame.hd_lights, settings); } + // vanilla's translucent water leaves the colour while the HD water draws it. + let hd_flags_at = std::mem::offset_of!(Globals, hd_flags) as u64; + let hd_flags: [f32; 4] = [if hd.water_active() { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0]; + self.uploads.buffer( + &mut encoder, + &self.globals, + hd_flags_at, + bytemuck::bytes_of(&hd_flags), + ); hd.set_timing(device, &self.queue, self.pass_timing); hd.timing_begin(device, &self.queue); // The sun's (or moon's) shadow cascades: the world's globals swapped for the light's. @@ -5184,6 +5211,11 @@ impl Renderer { } self.queue .write_buffer(&hd.main_globals, 0, bytemuck::bytes_of(&globals)); + self.queue.write_buffer( + &hd.main_globals, + hd_flags_at, + bytemuck::bytes_of(&hd_flags), + ); // Facing the light only: the nearest surface to it is always one that does. let toward = (hd_shadow_fast() && self.sides_on).then(|| shadow_dir * 1.0e5); for (c, cascade) in hd_cascades.iter().enumerate() { diff --git a/crates/horizon-client/src/gfx/hd.rs b/crates/horizon-client/src/gfx/hd.rs index 3b9daabf4..e84a618a9 100644 --- a/crates/horizon-client/src/gfx/hd.rs +++ b/crates/horizon-client/src/gfx/hd.rs @@ -133,6 +133,10 @@ pub struct HdScene { pub view_proj: Mat4, /// Last frame's view-projection without jitter, and the camera's move since (TAA). pub prev_view_proj: Mat4, + /// The same frame's matrix as it was drawn, jitter included. History kept on the jittered + /// pixel grid (`fs_taa`, scale 1) is looked up with this; `fs_taau` resolves the history on the + /// unjittered output grid and uses `prev_view_proj`. + pub prev_view_proj_jittered: Mat4, pub camera_delta: glam::Vec3, /// This frame is anti-aliased over time (the projection is jittered). pub taa: bool, @@ -403,31 +407,104 @@ fn fog_at(px: vec2, dist: f32) -> vec4 { return sum / total; } -@fragment -fn fs_light(in: FullOut) -> @location(0) vec4 { - let px = vec2(in.position.xy); - let scene = textureLoad(scene_color, px, 0).rgb; - let d = textureLoad(scene_depth, px, 0); - let g = textureLoad(gbuffer_depth, px, 0); - // Water in front of the opaque world with nothing translucent over it: here the floor is lit - // and the water composite lays the surface over it (it knows the pixel by the alpha 0.5). - var water = false; - if frame.water.x > 0.5 && g > 0.0 { - let wd = textureLoad(water_depth, px, 0); - water = wd > 0.0 && wd >= g && d - wd <= d * 0.002; - } - let dd = select(d, g, water); - let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0); - let h = frame.inverse_view_proj * ndc; - let p = h.xyz / h.w; - let view = normalize(p); +struct LightOut { + // The lit colour; alpha 0.5 marks water (the floor under it, lit without any air), 0.6 an + // object in front of the water (the scene as drawn). + @location(0) lit: vec4, + // Water: the air between the camera and the surface (scattered light, transmittance); + // in front of the water: the floor lit (rgb); else nothing. + @location(1) air: vec4, +} + +// An opaque surface lit: the ambient, the sun and the mods' lights over its albedo. +fn shade_surface(px: vec2, p: vec3, water: bool) -> vec3 { let sun = frame.sun.xyz; let sun_on = frame.sun.w; + let atmosphere = frame.sky_sun.w > 0.5; let count = u32(frame.params.x); let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; let surface_start = tiles[tile]; let surface_all = tiles[tile + 1u] == 0xffffffffu; let surface_count = select(tiles[tile + 1u], count, surface_all); + 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_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } 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; + // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on + // the mods' lights by half, not on the sun or on emission. + var occlusion = 1.0; + // (The water dims and blurs the floor under it: no occlusion there.) + if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); } + let ao_ambient = mix(1.0, occlusion, frame.ambient.w); + let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5); + // The sky's light is bluish from above and carries the horizon's colour from the sides; + // faces turned up take more of it than the ones turned down. + var sky_tint = vec3(1.0); + if atmosphere { + let reach = smoothstep(0.25, 0.9, lm.x); + let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up)); + let hue = mix(vec3(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a)); + sky_tint = mix(vec3(1.0), hue * shape, reach); + } + var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; + // The mods' lights. + for (var k = 0u; k < surface_count; k++) { + let l = lights[select(tiles[surface_start + k], k, surface_all)]; + 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 * ao_lights; + } + return albedo.rgb * light + albedo.rgb * albedo.a * 1.5; +} + +@fragment +fn fs_light(in: FullOut) -> LightOut { + let px = vec2(in.position.xy); + let scene = textureLoad(scene_color, px, 0).rgb; + let d = textureLoad(scene_depth, px, 0); + let g = textureLoad(gbuffer_depth, px, 0); + // Water over the opaque world (or over nothing): the water composite lays the surface over + // the floor, which is lit here with no air. Something translucent in front of the water + // (`front`: nearer than its surface) keeps the scene as drawn; the floor goes beside it. + var wd = 0.0; + var water_here = false; + if frame.water.x > 0.5 { + wd = textureLoad(water_depth, px, 0); + water_here = wd > 0.0 && (g <= 0.0 || wd >= g); + } + let front = water_here && g > 0.0 && d - wd > d * 0.002; + let water = water_here && !front; + let has_floor = water && g > 0.0; + let dd = select(d, g, has_floor); + let ndc = vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(dd, 0.000001), 1.0); + let h = frame.inverse_view_proj * ndc; + let p = h.xyz / h.w; + let view = normalize(p); + // Where the air ends: the water's surface over water, else the pixel itself. + var ps = p; + if water { + let hf = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(wd, 0.000001), 1.0); + ps = hf.xyz / hf.w; + } + let view_s = normalize(ps); + let sun = frame.sun.xyz; + let sun_on = frame.sun.w; + let count = u32(frame.params.x); + let tile = ((u32(px.y) >> 4u) * u32(frame.shadow.w) + (u32(px.x) >> 4u)) * 4u; let halo_start = tiles[tile + 2u]; let halo_all = tiles[tile + 3u] == 0xffffffffu; let halo_count = select(tiles[tile + 3u], count, halo_all); @@ -455,113 +532,81 @@ fn fs_light(in: FullOut) -> @location(0) vec4 { c = c * cl.a + cl.rgb; } } - // Clouds (vanilla's, drawn into the world with depth but not into the G-buffer): lit by the low - // sun's colour at dusk and dawn instead of vanilla's flat white. - if atmosphere && frame.clouds.z < 0.5 && !water && d > 0.0 && g <= 0.0 && p.y > 8.0 && dot(p, p) > 2304.0 { - let low = 1.0 - smoothstep(0.05, 0.5, frame.sky_sun.y); - c = scene * mix(vec3(1.0), mix(frame.sun_color.rgb, vec3(1.0), 0.45) * 1.1, low * 0.7 * (1.0 - frame.sky_extra.z)); - } - let surface = water || (d > 0.0 && abs(d - g) <= max(d, g) * 0.002); + // (Vanilla's clouds, when the volumetric ones are off, take the low sun's colour in their own + // pass: `vanilla_cloud_tint`, applied to their colour on the CPU.) + let surface = has_floor || (!water_here && 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_taps(p, n, select(1, 0, water)) * cloud_shadow(p); } 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; - // Contact shadows from the ambient occlusion: on the ambient and blocks' light fully, on - // the mods' lights by half, not on the sun or on emission. - var occlusion = 1.0; - // (The water dims and blurs the floor under it: no occlusion there.) - if frame.ambient.w > 0.0 && !water { occlusion = ao_at(px, length(p)); } - let ao_ambient = mix(1.0, occlusion, frame.ambient.w); - let ao_lights = mix(1.0, occlusion, frame.ambient.w * 0.5); - // The sky's light is bluish from above and carries the horizon's colour from the sides; - // faces turned up take more of it than the ones turned down. - var sky_tint = vec3(1.0); - if atmosphere { - let reach = smoothstep(0.25, 0.9, lm.x); - let up = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); - let shape = mix(0.66, 1.1, smoothstep(0.1, 1.0, up)); - let hue = mix(vec3(1.0), sky_light_tint(n), 0.4 * (1.0 - 0.5 * frame.sun_color.a)); - sky_tint = mix(vec3(1.0), hue * shape, reach); - } - var light = max(base * sky_tint * frame.ambient.x, block * frame.ambient.y) * ao_ambient + direct; - // The mods' lights. - for (var k = 0u; k < surface_count; k++) { - let l = lights[select(tiles[surface_start + k], k, surface_all)]; - 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 * ao_lights; - } - 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)); - let haze = haze_colour(view); - c = mix(c, haze, f * frame.fog_color.a); - // Aerial perspective: far things take the horizon's colour a little before the fog does. - if atmosphere && frame.fog.x > 1.0e6 { - c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5); + c = shade_surface(px, p, water); + // Vanilla's fog over it again (the water composite does it at the surface). + if !water { + 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)); + let haze = haze_colour(view); + c = mix(c, haze, f * frame.fog_color.a); + // Aerial perspective: far things take the horizon's colour a little before the fog does. + if atmosphere && frame.fog.x > 1.0e6 { + c = mix(c, haze, (1.0 - exp(-sph / 700.0)) * 0.5); + } } } - // The volumetric fog: the light the air scatters towards the camera (shafts through the trees, - // mist, haze), and how much of the pixel it hides. - let far = select(min(length(p), frame.shadow.z), frame.shadow.z, d <= 0.0); + // What lies behind a translucent thing in front of the water: the floor, lit. + var floor_lit = vec3(0.0); + if front { + let hg = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(g, 0.000001), 1.0); + floor_lit = shade_surface(px, hg.xyz / hg.w, true); + } + // The air between the camera and the pixel (the water's surface over water): the volumetric + // fog (what it hides, what it scatters), the sun's rays between the shadows, and the halos + // round the mods' lights. Over water they go to the composite, which lays them over the + // finished water; elsewhere over the pixel here. + var air_t = 1.0; + var air_s = vec3(0.0); + let far = select(min(length(ps), frame.shadow.z), frame.shadow.z, d <= 0.0); let density = frame.ambient.z; let volumetric = frame.vfog.x > 0.5 && atmosphere; if volumetric { var fog_len = 192.0; - if d > 0.0 { - var pf = p; - if water { - let hf = frame.inverse_view_proj * vec4(in.uv.x * 2.0 - 1.0, 1.0 - in.uv.y * 2.0, max(d, 0.000001), 1.0); - pf = hf.xyz / hf.w; - } - fog_len = min(length(pf), 192.0); - } + if d > 0.0 { fog_len = min(length(ps), 192.0); } let fg = fog_at(px, fog_len); - c = c * fg.a + fg.rgb; + air_t = fg.a; + air_s = fg.rgb; } - // Light in the air (without the volumetric fog): the sun's rays between the shadows; always the - // halos round the mods' lights. if !volumetric && 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); + acc += lit_at(view_s * t); } // Henyey-Greenstein, forward scattering: brightest looking towards the sun. - let cos_t = dot(view, sun); + let cos_t = dot(view_s, 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)); + air_s += frame.sun_color.rgb * sun_on * (acc / f32(steps)) * phase * (1.0 - exp(-far * density)); } for (var k = 0u; k < halo_count; k++) { let l = lights[select(tiles[halo_start + k], k, halo_all)]; // 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; + let t0 = dot(l.pos.xyz, view_s); + let hh = length(l.pos.xyz - view_s * 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); + // Thicker air glows more round a light, but only so far: the mist at dusk and many fires + // at once would otherwise wash the picture out. + air_s += l.color.rgb * l.color.w * (a1 - a0) / hh * 0.004 * (0.3 + min(density * 300.0, 0.9)); } - return vec4(c, select(1.0, 0.5, water)); + var out: LightOut; + if water { + out.lit = vec4(c, 0.5); + out.air = vec4(air_s, air_t); + } else { + out.lit = vec4(c * air_t + air_s, select(1.0, 0.6, front)); + out.air = vec4(floor_lit, 0.0); + } + return out; } "#; @@ -580,6 +625,9 @@ const REFLECT_RANGE: f32 = 96.0; @group(0) @binding(8) var lightmap: texture_2d; @group(0) @binding(9) var linear: sampler; //SKY10 +// What the lighting left beside the lit world: over water the air in front of the surface (scattered +// light, transmittance), in front of the water the lit floor. +@group(0) @binding(11) var air_tex: texture_2d; fn ign(px: vec2) -> f32 { let p = px + vec2(5.588238 * frame.params.w); @@ -761,6 +809,9 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { let px = vec2(in.position.xy); let base = textureLoad(lit_tex, px, 0); if base.a > 0.75 { return vec4(base.rgb, 1.0); } + // Alpha 0.6: something translucent stands in front of the water here (base is the scene). + let front = base.a > 0.55; + let aux = textureLoad(air_tex, px, 0); let wd = textureLoad(water_depth, px, 0); let g = textureLoad(gbuffer_depth, px, 0); let tint = textureLoad(water_tint, px, 0); @@ -792,23 +843,35 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { let volume_light = max(ambient * frame.ambient.x, block * frame.ambient.y) + frame.sun_color.rgb * sun_on * sunlit * 0.8; // Refraction: where the ray through the waves meets the world, against where the flat surface - // would send it (so still water looks as vanilla's and only the waves distort). - let floor0 = position_at(in.uv, g); - let depth0 = max(length(floor0) - dist, 0.0); - let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75); + // would send it (so still water looks as vanilla's and only the waves distort). Not where + // something translucent is in front of the water, nor onto texels that hold such a thing. + let has_floor = g > 0.0; var uv_floor = in.uv; var floor_z = g; - let moved = project(floor0 + bent * min(depth0, 24.0)); - if moved.z > 0.0 && on_screen(moved.xy) { - let z = textureLoad(gbuffer_depth, texel(moved.xy), 0); - // Only what lies behind the water: not the bank or an entity in front of it. - if z > 0.0 && z <= wd { - uv_floor = moved.xy; - floor_z = z; + if has_floor && !front { + let floor0 = position_at(in.uv, g); + let depth0 = max(length(floor0) - dist, 0.0); + let bent = refract(-v, n, 0.75) - refract(-v, geometric, 0.75); + let moved = project(floor0 + bent * min(depth0, 24.0)); + if moved.z > 0.0 && on_screen(moved.xy) { + let z = textureLoad(gbuffer_depth, texel(moved.xy), 0); + // The four texels the filter blends: none may be a pixel with a translucent thing + // in front of the water. + let marks = textureGather(3, lit_tex, linear, moved.xy); + let tainted = any((marks > vec4(0.55)) & (marks < vec4(0.75))); + // Only what lies behind the water: not the bank or an entity in front of it. + if z > 0.0 && z <= wd && !tainted { + uv_floor = moved.xy; + floor_z = z; + } } } - let thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0); - let behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb; + var thickness = 48.0; + if has_floor { + thickness = clamp(length(position_at(uv_floor, floor_z)) - dist, 0.0, 48.0); + } + var behind = textureSampleLevel(lit_tex, linear, uv_floor, 0.0).rgb; + if front { behind = aux.rgb; } // A deep, dark water: the biome's colour darkened and a little greyed, so it reads as // depth rather than as bright paint. let biome = clamp(tint.rgb, vec3(0.03), vec3(1.0)); @@ -840,6 +903,13 @@ fn fs_water(in: FullOut) -> @location(0) vec4 { if frame.sky_sun.w > 0.5 && frame.fog.x > 1.0e6 { c = mix(c, haze, (1.0 - exp(-dist / 700.0)) * 0.5); } + if front { + // The scene as drawn, with the water's change to the floor laid under the translucent thing. + c = base.rgb + (c - aux.rgb) * 0.7; + } else { + // The air between the camera and the surface: volumetric fog, rays, halos. + c = c * aux.a + aux.rgb; + } return vec4(max(c, vec3(0.0)), 1.0); } "#; @@ -1413,6 +1483,8 @@ pub struct Hd { water_normal: wgpu::TextureView, water_depth: wgpu::TextureView, lit_water: wgpu::TextureView, + /// The lighting's second target: the air over water, the floor beside translucent things. + air: wgpu::TextureView, water_layout: wgpu::BindGroupLayout, water_pipeline: wgpu::RenderPipeline, water_group: Option<((wgpu::TextureView, wgpu::Sampler), wgpu::BindGroup)>, @@ -1849,6 +1921,7 @@ impl Hd { FLOAT, SAMPLER, FLOAT, + UNFILTERED, ]), }); let sky_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { @@ -1961,15 +2034,39 @@ impl Hd { }, alpha: wgpu::BlendComponent::REPLACE, }; - let light_pipeline = fullscreen( - device, - "HD lighting", - &light_layout, - &light, - "fs_light", - HDR, - None, - ); + let light_pipeline = { + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("HD lighting"), + bind_group_layouts: &[Some(&light_layout)], + ..Default::default() + }); + let target = Some(wgpu::ColorTargetState { + format: HDR, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("HD lighting"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &light, + entry_point: Some("vs_full"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &light, + entry_point: Some("fs_light"), + compilation_options: Default::default(), + targets: &[target.clone(), target], + }), + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + multiview_mask: None, + cache: None, + }) + }; let prefilter = fullscreen( device, "HD bloom prefilter", @@ -2108,6 +2205,7 @@ impl Hd { ) .view, lit: target(device, "HD lit", [1, 1], HDR).view, + air: target(device, "HD air", [1, 1], HDR).view, fx: target(device, "HD post", [1, 1], HDR).view, water_tint: target(device, "HD water colour", [1, 1], WATER_TINT).view, water_normal: target(device, "HD water normal", [1, 1], WATER_NORMAL).view, @@ -2199,6 +2297,7 @@ impl Hd { 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.air = target(device, "HD air", size, HDR).view; self.fx = target(device, "HD post", size, HDR).view; self.water_tint = target(device, "HD water colour", size, WATER_TINT).view; self.water_normal = target(device, "HD water normal", size, WATER_NORMAL).view; @@ -2322,7 +2421,12 @@ impl Hd { 0.0 }; let frame = HdFrame { - prev_view_proj: scene.prev_view_proj.to_cols_array_2d(), + prev_view_proj: if self.upscaling() { + scene.prev_view_proj + } else { + scene.prev_view_proj_jittered + } + .to_cols_array_2d(), taa: [ scene.camera_delta.x, scene.camera_delta.y, @@ -2685,15 +2789,28 @@ impl Hd { return; }; self.water_composited = false; - Self::draw( - encoder, - "HD lighting", - &self.lit, - false, - &self.light_pipeline, - group, - stamp(&mut self.timing, "hd lighting"), - ); + let clear = |view| { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + }) + }; + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("HD lighting"), + timestamp_writes: stamp(&mut self.timing, "hd lighting"), + color_attachments: &[clear(&self.lit), clear(&self.air)], + ..Default::default() + }); + pass.set_pipeline(&self.light_pipeline); + pass.set_bind_group(0, group, &[]); + pass.draw(0..3, 0..1); + } } /// A bind group of consecutive bindings 0.., counted (`groups_last_frame`). @@ -2987,6 +3104,7 @@ impl Hd { wgpu::BindingResource::TextureView(lightmap.0), wgpu::BindingResource::Sampler(&self.sampler), wgpu::BindingResource::TextureView(&self.sky_lut), + wgpu::BindingResource::TextureView(&self.air), ], ); self.water_group = Some((key, group)); @@ -3098,6 +3216,20 @@ pub fn light_of(scene: &HdScene) -> (glam::Vec3, f32, glam::Vec3) { } } +/// The factor on vanilla's cloud colour that lights the clouds with the low sun's colour at dusk +/// and dawn instead of a flat white. It is the clouds' own colour (the cloud pass gets it), not a +/// guess from the depth buffer, so what stands behind a cloud cannot change it. +pub fn vanilla_cloud_tint(scene: &HdScene) -> [f32; 3] { + let sun = scene.sun.normalize_or(glam::Vec3::Y); + let low = 1.0 - smooth(0.05, 0.5, sun.y); + let overcast = (scene.rain / 0.7).clamp(0.0, 1.0) * 0.85; + let (_, _, color) = light_of(scene); + let warm = color.lerp(glam::Vec3::ONE, 0.45) * 1.1; + let t = low * 0.7 * (1.0 - overcast); + let tint = glam::Vec3::ONE.lerp(warm, t); + [tint.x, tint.y, tint.z] +} + /// The x of the sun's direction: positive in the morning, negative in the evening. fn sun_dir_x(scene: &HdScene) -> f32 { scene.sun.normalize_or(glam::Vec3::Y).x @@ -3201,6 +3333,37 @@ mod tests { assert_eq!(seen.len(), 16); } + #[test] + fn taa_history_lookup_follows_the_jittered_grid() { + // A static point under two frames' different Halton jitters: fs_taa finds this frame's + // point in the history by the previous frame's matrix *as drawn*, which lands on the pixel + // the previous frame wrote it to; the unjittered one is off by that frame's jitter. + let size = [1920u32, 1080]; + let unjittered = Mat4::perspective_rh(1.0, 16.0 / 9.0, 0.05, 1000.0); + let to_uv = |m: Mat4, p: glam::Vec3| { + let c = m * p.extend(1.0); + [c.x / c.w * 0.5 + 0.5, 0.5 - c.y / c.w * 0.5] + }; + let drawn = |frame: u32| { + let j = jitter(frame, size, 16); + Mat4::from_translation(glam::Vec3::new(j[0], j[1], 0.0)) * unjittered + }; + let p = glam::Vec3::new(0.0, 0.0, -20.0); + for frame in 1..16 { + let prev = drawn(frame - 1); + // The pixel (in uv) the previous frame drew the point at: what the history holds there. + let wrote = to_uv(prev, p); + let old = to_uv(unjittered, p); + let err = [ + (old[0] - wrote[0]).abs() * size[0] as f32, + (old[1] - wrote[1]).abs() * size[1] as f32, + ]; + let pj = jitter(frame - 1, size, 16); + assert!((err[0] - pj[0].abs() * size[0] as f32 * 0.5).abs() < 1e-3); + assert!((err[1] - pj[1].abs() * size[1] as f32 * 0.5).abs() < 1e-3); + } + } + #[test] fn jitter_covers_the_upscale_ratio() { assert_eq!(jitter_count(1.0), 16); diff --git a/crates/horizon-client/src/gfx/hd_sky.rs b/crates/horizon-client/src/gfx/hd_sky.rs index ec55de4c8..dbe8c8853 100644 --- a/crates/horizon-client/src/gfx/hd_sky.rs +++ b/crates/horizon-client/src/gfx/hd_sky.rs @@ -283,6 +283,14 @@ fn vanilla_sunrise_weight(d: vec3) -> f32 { let theta = radians(flip + 90.0); let c = cos(theta); let s = sin(theta); + // Above the horizon the fan reaches only the sun's side (x of the same sign as the apex's, -s) + // and rises at most alpha/3 per block of that distance (its rim stands 40*alpha high over a + // 120-block radius and the points above the horizon lie at least 120 * the share of the rim + // from the axis); anything else cannot hit it, so most of the sky skips the 16 triangles. + if d.y > 0.0 { + let side = -s * d.x; + if side <= 0.0 || d.y > alpha * (1.0 / 3.0) * side * 1.02 + 0.001 { return 0.0; } + } var best = 0.0; let apex = vanilla_rotate_x90(vec3(-100.0 * s, 100.0 * c, 0.0)); for (var i = 0; i < 16; i++) { diff --git a/crates/horizon-client/src/terrain.wgsl b/crates/horizon-client/src/terrain.wgsl index 0a2a6cdba..cb098a64a 100644 --- a/crates/horizon-client/src/terrain.wgsl +++ b/crates/horizon-client/src/terrain.wgsl @@ -22,6 +22,8 @@ struct Globals { // Atlas rect of the current destroy stage (read by entity.wgsl). crumbling_sprite: vec4, outline_params: vec4, + // AirDef fork. x: the HD water draws the water (fs_translucent leaves vanilla's out). + hd_flags: vec4, // Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs). dynamic_lights: array, 16>, } @@ -211,6 +213,12 @@ struct WaterOut { @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; } + // The HD water composite draws the water itself: vanilla's stays out of the colour (alpha 0 + // blends to nothing) but keeps its depth, which the lighting reads. + let layer = in.layer & 0x7fffu; + if globals.hd_flags.x > 0.5 && (layer == WATER_STILL || layer == WATER_FLOW) { + return vec4(0.0); + } return apply_fog(color, in.spherical, in.cylindrical); } diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 8411ac9aa..5fa5e8a27 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -694,6 +694,11 @@ interface render { /// Switches the HD lighting on or off for this mod's world, with its /// exposure and bloom; off, the world is drawn as the game draws it. set-lighting: func(lighting: lighting); + /// Shows the world at this time of day (AirDef fork): the sky, the sun + /// and moon, the sky light and the fog, as `day-time` ticks (24000 a day, + /// as `clock`). Only what the player sees changes: the world's own clock, + /// its saves and its rules do not. None shows the world's own time. + set-time-override: func(day-time: option); } /// What the game calls. Only the hooks `init` asks for are ever called. diff --git a/crates/horizon-mods/src/api_level.rs b/crates/horizon-mods/src/api_level.rs index 8cf350044..bc1ade067 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -7,13 +7,13 @@ //! version's level so the launcher can tell before it installs. /// The newest level this Horizon serves. -pub const LEVEL: u32 = 12; +pub const LEVEL: u32 = 13; /// Every function the game offers mods, as `interface.function`, with its /// level. Level 0 is the contract of Horizon 0.2.67 to 0.2.69. A new /// function takes the next level and raises [`LEVEL`] (the `api_reference` /// test fails until it is listed here). -pub const FUNCTIONS: [(&str, u32); 66] = [ +pub const FUNCTIONS: [(&str, u32); 67] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -82,6 +82,8 @@ pub const FUNCTIONS: [(&str, u32); 66] = [ ("render.query-sections", 12), ("render.sections", 12), ("render.world-changes", 12), + // AirDef fork: a replay shows its recorded time of day. + ("render.set-time-override", 13), ]; /// What the game's program carries so the launcher can read its level diff --git a/crates/horizon-mods/src/runtime.rs b/crates/horizon-mods/src/runtime.rs index 2189a99e1..fb6180f1c 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -507,6 +507,8 @@ struct State { /// `render.set-lights` and `render.set-lighting` (AirDef fork). lights: Vec, lighting: Option, + /// `render.set-time-override` (AirDef fork). + time_override: Option, input: InputState, browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. @@ -749,6 +751,7 @@ impl Mods { views: Vec::new(), lights: Vec::new(), lighting: None, + time_override: None, input: InputState::default(), browser: BrowserState::default(), data_dir, @@ -1334,6 +1337,12 @@ impl Mods { .iter() .find_map(|e| e.run.as_ref()?.store.data().lighting) } + /// The time of day the first running mod asked to show, if any. + pub fn time_override(&self) -> Option { + self.entries + .iter() + .find_map(|e| e.run.as_ref()?.store.data().time_override) + } /// The columns each running mod asked the ground height of, taken. pub fn take_ground_queries(&mut self) -> Vec<(usize, Vec<(i32, i32)>)> { self.entries @@ -2237,6 +2246,9 @@ impl render::Host for State { }; self.lighting = Some(lighting); } + fn set_time_override(&mut self, day_time: Option) { + self.time_override = day_time.map(|t| t.min(1 << 40)); + } fn query_ground(&mut self, columns: Vec<(i32, i32)>) { let room = 512usize.saturating_sub(self.ground_queries.len()); self.ground_queries.extend(columns.into_iter().take(room)); diff --git a/docs/api/mods-reference.md b/docs/api/mods-reference.md index 2afb5bf62..b58f863c2 100644 --- a/docs/api/mods-reference.md +++ b/docs/api/mods-reference.md @@ -646,6 +646,7 @@ The HD lighting (AirDef fork): the world lit per pixel by the mods' lights, in h | `set-views` | `func(views: list)` | Replaces the mod's cameras: up to 8. A `view` shape shows one; the game renders up to three a frame, in turn. | | `set-lights` | `func(lights: list)` | Replaces the mod's lights: up to 512, interpolated per id between the last two calls. | | `set-lighting` | `func(lighting: lighting)` | Switches the HD lighting on or off for this mod's world, with its exposure and bloom; off, the world is drawn as the game draws it. | +| `set-time-override` | `func(day-time: option)` | Shows the world at this time of day (AirDef fork): the sky, the sun and moon, the sky light and the fog, as `day-time` ticks (24000 a day, as `clock`). Only what the player sees changes: the world's own clock, its saves and its rules do not. None shows the world's own time. | ## `events`