diff --git a/crates/horizon-client/examples/weather_fixture.rs b/crates/horizon-client/examples/weather_fixture.rs index 15aab6bdb..3cf6c31be 100644 --- a/crates/horizon-client/examples/weather_fixture.rs +++ b/crates/horizon-client/examples/weather_fixture.rs @@ -11,6 +11,7 @@ fn bits(value: f32) -> String { fn main() -> Result<(), Box> { let args: Vec<_> = std::env::args().collect(); let mut output = String::new(); + let mut parameters = weather::ParameterCache::default(); for (case, line) in fs::read_to_string(&args[1])?.lines().enumerate() { let p: Vec<&str> = line.split_whitespace().collect(); let int = |i: usize| p[i].parse::().unwrap(); @@ -38,6 +39,13 @@ fn main() -> Result<(), Box> { bits(column.v_offset), column.light_coords )?; + assert_eq!( + parameters + .get(x, z) + .column(kind != 0, ticks, x, bottom, top, z, light, partial), + column, + "cached parameters differ from original random source at case {case}" + ); let max_alpha = if kind == 0 { 1.0 } else { 0.8 }; for v in weather::column_quad(&column, camera, max_alpha, radius, intensity) { write!( diff --git a/crates/horizon-client/src/entity.wgsl b/crates/horizon-client/src/entity.wgsl index 2c6af26f9..99211ec5f 100644 --- a/crates/horizon-client/src/entity.wgsl +++ b/crates/horizon-client/src/entity.wgsl @@ -43,6 +43,7 @@ struct Out { @location(8) back_color: vec4, // entity.vsh's lightMapColor, multiplied in after the overlay. @location(9) light_color: vec4, + @location(10) @interpolate(flat) weather_rect: vec4, } // Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed. fn entity_light(normal: vec3, nether: bool) -> f32 { @@ -209,3 +210,135 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 { @fragment fn fs_water_mask() -> @location(0) vec4 { return vec4(0.0); } + +// WEATHER_MATH_BEGIN +// Unsigned 64-bit helpers (low word first). Snow adds a random double to a +// float drift before casting to float. Retaining the 53-bit base avoids early +// rounding, including ties, without requiring optional GPU float64 support. +fn weather_shr_jam(v: vec2, n: u32) -> vec2 { + if n == 0u { return v; } + if n < 32u { + return vec2((v.x >> n) | (v.y << (32u - n)) | select(0u, 1u, (v.x << (32u - n)) != 0u), v.y >> n); + } + if n == 32u { return vec2(v.y | select(0u, 1u, v.x != 0u), 0u); } + if n < 64u { + return vec2((v.y >> (n - 32u)) | select(0u, 1u, v.x != 0u || (v.y << (64u - n)) != 0u), 0u); + } + return vec2(select(0u, 1u, any(v != vec2(0u))), 0u); +} +fn weather_add64(a: vec2, b: vec2) -> vec2 { + let lo = a.x + b.x; + return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x)); +} +fn weather_sub64(a: vec2, b: vec2) -> vec2 { + return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x)); +} +fn weather_double_plus_float(base: vec2, delta: f32) -> f32 { + // The random base is nonnegative and either zero or >= 2^-53. + if all(base == vec2(0u)) { return select(delta, 0.0, delta == 0.0); } + let db = bitcast(delta); + let mag = db & 0x7fffffffu; + let be = i32((base.y >> 20u) & 2047u) - 1023; + var de = i32(mag >> 23u) - 127; + var dm = (mag & 0x7fffffu) | 0x800000u; + if mag < 0x800000u { + dm = mag; + if dm == 0u { de = -200; } + else { + let shift = countLeadingZeros(dm) - 8u; + dm <<= shift; + de = -126 - i32(shift); + } + } + let e = max(be, de); + // Leading significand bit at 61, with room for addition and guard bits. + var a = vec2(base.x << 9u, ((base.y & 0xfffffu) | 0x100000u) << 9u | (base.x >> 23u)); + var b = vec2(0u, dm << 6u); + a = weather_shr_jam(a, u32(e - be)); + b = weather_shr_jam(b, u32(e - de)); + var sign = 0u; + var value: vec2; + if (db >> 31u) == 0u { value = weather_add64(a, b); } + else if a.y > b.y || (a.y == b.y && a.x >= b.x) { value = weather_sub64(a, b); } + else { value = weather_sub64(b, a); sign = 0x80000000u; } + if all(value == vec2(0u)) { return 0.0; } + let leading = select(31u - countLeadingZeros(value.x), 63u - countLeadingZeros(value.y), value.y != 0u); + var exponent = e + i32(leading) - 61; + // Normalize the leading bit to 62. After jamming, bit 38 is the guard + // bit and bits 0..37 distinguish a tie from a value above the midpoint. + let shift = 62u - leading; + if shift >= 32u { value = vec2(0u, value.x << (shift - 32u)); } + else if shift != 0u { value = vec2(value.x << shift, (value.y << shift) | (value.x >> (32u - shift))); } + // Vanilla first rounds the addition to double, then casts to float. + // Round at bit 10 (53 retained bits) before the 24-bit rounding below; + // otherwise a double-rounding midpoint can select the wrong float. + let double_guard = (value.x & 512u) != 0u; + let double_sticky = (value.x & 511u) != 0u; + let double_odd = (value.x & 1024u) != 0u; + value.x &= 0xfffffc00u; + if double_guard && (double_sticky || double_odd) { + value = weather_add64(value, vec2(1024u, 0u)); + } + if (value.y & 0x80000000u) != 0u { + value = weather_shr_jam(value, 1u); + exponent += 1; + } + var mantissa = value.y >> 7u; + let guard = (value.y & 64u) != 0u; + let sticky = (value.y & 63u) != 0u || value.x != 0u; + if guard && (sticky || (mantissa & 1u) != 0u) { mantissa += 1u; } + if mantissa >= 0x1000000u { mantissa >>= 1u; exponent += 1; } + return bitcast(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu)); +} +fn weather_phase(animation: vec4, bases: vec4, snow: u32, time: vec4) -> vec2 { + if snow == 0u { + let ticks = time.x + animation.y; + let t = ticks + time.z; + let step = -t / 32.0; + let offset = step * animation.x; + return vec2(0.0, offset % 32.0); + } + let t = time.x + time.z; + let u_time = t * 0.01; + let du = u_time * animation.z; + let v_time = t * animation.w; + let dv = v_time * 0.001; + let u = weather_double_plus_float(bases.xy, du); + let v = weather_double_plus_float(bases.zw, dv); + let fall_time = time.y + time.z; + let fall = -fall_time / 512.0; + return vec2(u, fall + v); +} +// Atlas sampler is nearest with no mipmaps. Clamp the integer texel inside +// the sprite even when a fractional coordinate rounds up at its far edge. +fn weather_texel(uv: vec2, rect: vec4, dimensions: vec2) -> vec2 { + let size = vec2(dimensions); + let origin = vec2(round(rect.xy * size)); + let extent = max(vec2(round(rect.zw * size)), vec2(1)); + let local = min(vec2(floor(fract(uv) * vec2(extent))), extent - vec2(1)); + return origin + local; +} +// WEATHER_MATH_END + +struct WeatherTime { values: vec4 } +@group(1) @binding(1) var weather_time: WeatherTime; +@vertex fn vs_weather(@location(0) position: vec3, @location(1) uv: vec2, + @location(2) rect: vec4, @location(3) animation: vec4, + @location(4) bases: vec4, @location(5) color: vec4, + @location(6) light: vec4, @location(7) snow: u32) -> Out { + let phase = weather_phase(animation, bases, snow, weather_time.values); + var out = entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0)); + out.weather_rect = rect; + return out; +} +@fragment fn fs_weather(in: Out) -> @location(0) vec4 { + // Repeat within this sprite, never across the atlas. The atlas uses nearest + // filtering and no mipmaps, so fractional UVs have no seam derivatives. + let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas)); + var color = textureLoad(atlas, texel, 0) * in.color; + if color.a < 0.1 { discard; } + color *= in.light_color; + let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y), + linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)); + return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a); +} diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index f706e9348..5c8d64d18 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1110,6 +1110,7 @@ struct Game<'a> { /// `FogRenderer`'s evolving state. fog: horizon_client::fog::FogState, screen_fx: horizon_client::screen_effects::ScreenEffects, + weather_parameters: horizon_client::weather::ParameterCache, panorama_rotation: horizon_client::gfx::panorama::Rotation, world: World, streaming: Streaming, @@ -8942,89 +8943,84 @@ impl Game<'_> { } } /// WeatherEffectRenderer: rain and snow columns within the weather radius. - fn weather_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec) { + fn weather_geometry( + &mut self, + camera: glam::DVec3, + alpha: f64, + ) -> Vec { use horizon_client::weather; let (intensity, _) = self.world.weather.levels(alpha as f32); if intensity <= 0.0 { - return; + return Vec::new(); } let atlas = &self.gui.atlas; let (Some(rain_tex), Some(snow_tex)) = ( atlas.sprites.get("environment/rain").copied(), atlas.sprites.get("environment/snow").copied(), ) else { - return; + return Vec::new(); }; let (cx, cy, cz) = ( camera.x.floor() as i32, camera.y.floor() as i32, camera.z.floor() as i32, ); - let radius = self.settings.weather_radius; - let ticks = self.world.game_time; - let partial = alpha as f32; - let eye = [camera.x, camera.y, camera.z]; - // Vanilla draws every rain column before the snow ones. - let mut columns = (Vec::new(), Vec::new()); - for z in cz - radius..=cz + radius { - for x in cx - radius..=cx + radius { + // The 32x32 direction table supports at most a 15-block radius. + let radius = self.settings.weather_radius.clamp(0, 15); + let eye = camera.to_array(); + let mut rain = Vec::new(); + let mut snow_vertices = Vec::new(); + // Saturation keeps extreme camera coordinates from overflowing the loop. + for z in cz.saturating_sub(radius)..=cz.saturating_add(radius) { + for x in cx.saturating_sub(radius)..=cx.saturating_add(radius) { let top = self.rain_height(x, z); - let y0 = (cy - radius).max(top); - let y1 = (cy + radius).max(top); + let y0 = cy.saturating_sub(radius).max(top); + let y1 = cy.saturating_add(radius).max(top); if y1 == y0 { continue; } let Some(snow) = self.precipitation(x, cy, z) else { continue; }; - let mut random = weather::column_random(x, z); + let parameters = self.weather_parameters.get(x, z); let light = self.world.light(x, top.max(cy), z); let coords = i32::from(light & 15) << 4 | i32::from(light >> 4) << 20; - if snow { - columns.1.push(weather::snow_column( - &mut random, - ticks, - x, - y0, - y1, - z, - coords, - partial, - )); + // Only light brightening is needed here; UV phases stay zero. + let lc = if snow { + let (block, sky) = (coords >> 4 & 15, coords >> 20 & 15); + ((block * 3 + 15) / 4) << 4 | ((sky * 3 + 15) / 4) << 20 } else { - columns.0.push(weather::rain_column( - &mut random, - ticks, - x, - y0, - y1, - z, - coords, - partial, - )); + coords + }; + let column = weather::Column { + x, + z, + bottom_y: y0, + top_y: y1, + u_offset: 0.0, + v_offset: 0.0, + light_coords: lc, + }; + let r = if snow { snow_tex } else { rain_tex }; + let vertices = weather::vertices( + &column, + parameters, + snow, + eye, + radius, + intensity, + [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0], + ); + if snow { + snow_vertices.extend(vertices); + } else { + rain.extend(vertices); } } } - let rain = columns.0.iter().map(|c| (c, 1.0, rain_tex)); - let snow = columns.1.iter().map(|c| (c, 0.8, snow_tex)); - for (column, max_alpha, r) in rain.chain(snow) { - let quad = weather::column_quad(column, eye, max_alpha, radius, intensity); - let color = quad[0].color; - let lc = quad[0].light_coords; - let light = ((lc >> 20 & 15) * 16) as u32 | (((lc >> 4 & 15) * 16) as u32) << 8; - weather::tile(&quad, |pos, uvs| { - for (p, [u, v]) in pos.into_iter().zip(uvs) { - out.push(Vertex { - normal: [0; 4], - pos: p, - uv: [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v], - layer: 0, - color, - light, - }); - } - }); - } + // Vanilla draws every rain column before the snow ones. + rain.extend(snow_vertices); + rain } /// Player.startSleepInBed: night or thunder, no monsters within 8 blocks; /// the bed becomes the respawn point either way. @@ -9830,11 +9826,12 @@ impl Game<'_> { if !title { self.end_geometry(camera, &mut entities); } - if (!title || live) && self.dim == Dim::Overworld && self.settings.horizon.weather { - horizon_client::spans::time("weather", || { - self.weather_geometry(camera, alpha, &mut entities) - }); - } + let weather = + if (!title || live) && self.dim == Dim::Overworld && self.settings.horizon.weather { + horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha)) + } else { + Vec::new() + }; let border = (!title) .then(|| self.border_draw(camera.to_array(), alpha as f32)) .flatten(); @@ -9845,6 +9842,8 @@ impl Game<'_> { self.gather_dynamic_lights(camera, alpha); } let frame = Frame { + weather: &weather, + weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), shader: self.shader_inputs(camera, alpha as f32), nether_lighting: self.dim == Dim::Nether, camera, @@ -11438,6 +11437,7 @@ impl<'a> Game<'a> { gui, fog: Default::default(), screen_fx: Default::default(), + weather_parameters: Default::default(), panorama_rotation: Default::default(), world, streaming, @@ -12349,6 +12349,8 @@ fn loading_renderer( /// A title-shaped frame: black clear and panorama, only the HUD is drawn. fn loading_frame(hud: &HudBatch) -> Frame<'_> { Frame { + weather: &[], + weather_time: horizon_client::weather::Time::new(0, 0.0), camera: glam::DVec3::new(0.5, 100.0, 0.5), yaw: 0.0, pitch: 10.0, diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 14c040c5c..41576bd5f 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -33,6 +33,7 @@ fn entity_shader() -> String { } mod entity_batch; +mod weather; use entity_batch::EntityBatch; pub use entity_batch::WATER_MASK_OVERLAY; @@ -189,6 +190,8 @@ pub struct Frame<'a> { pub nether_lighting: bool, /// Camera-relative entity geometry textured from the shared atlas. pub entities: &'a [Vertex], + pub weather: &'a [crate::weather::Vertex], + pub weather_time: crate::weather::Time, /// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off. pub dynamic_lights: &'a [[f32; 4]], /// Cloud mesh inputs; geometry and GPU storage are retained by the renderer. @@ -324,6 +327,7 @@ pub struct Renderer { crosshair_group: wgpu::BindGroup, crosshair_globals: wgpu::Buffer, entity_pipeline: wgpu::RenderPipeline, + weather: weather::Weather, crumbling_pipeline: wgpu::RenderPipeline, /// `RenderPipelines.WATER_MASK`: depth writes only, no colour. water_mask_pipeline: wgpu::RenderPipeline, @@ -1445,6 +1449,12 @@ impl Renderer { ), ], }); + let weather = weather::Weather::new( + &device, + config.format.remove_srgb_suffix(), + &entity_bindings, + &entity_shader, + ); let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("Atlas"), ..Default::default() @@ -2101,6 +2111,7 @@ impl Renderer { crosshair_group, crosshair_globals, entity_pipeline, + weather, crumbling_pipeline, water_mask_pipeline, cloud_pipeline, @@ -3229,6 +3240,13 @@ impl Renderer { bytemuck::cast_slice(&all), ); } + self.weather.prepare( + &self.device, + &mut encoder, + &mut self.uploads, + frame.weather, + frame.weather_time, + ); let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize); if outline_edges > 0 { let lines = outline_vertices(&frame.outline[..outline_edges], camera); @@ -3246,6 +3264,7 @@ impl Renderer { frame.hand, frame.see_through, ); + let weather_quads = frame.weather.len() as u32 / 4; let world_quads = entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads; let cloud_quads = if let Some(input) = &frame.clouds { @@ -3318,8 +3337,9 @@ impl Renderer { bytemuck::cast_slice(&frame.celestial[..celestial_vertices]), ); } - let max_quads = - (world_quads + additive_quads + hand_quads + see_through_quads).max(cloud_quads); + let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads) + .max(cloud_quads) + .max(weather_quads); if max_quads > self.index_quads { self.index_quads = max_quads.next_power_of_two(); self.indices = Self::quad_indices(&self.device, self.index_quads); @@ -3602,6 +3622,8 @@ impl Renderer { pass.draw_indexed(0..entity_quads * 6, 0, 0..1); pass.set_bind_group(0, &self.group, &[]); } + self.weather.draw(&mut pass, &self.entity_group); + pass.set_bind_group(0, &self.group, &[]); if crumbling_quads > 0 { pass.set_pipeline(&self.crumbling_pipeline); pass.set_bind_group(0, &self.entity_group, &[]); diff --git a/crates/horizon-client/src/gfx/weather.rs b/crates/horizon-client/src/gfx/weather.rs new file mode 100644 index 000000000..15886b8f2 --- /dev/null +++ b/crates/horizon-client/src/gfx/weather.rs @@ -0,0 +1,330 @@ +//! Weather's unsplit quads, using the entity atlas/lightmap resources. +use crate::weather::{Time, Vertex}; + +pub(super) struct Weather { + pipeline: wgpu::RenderPipeline, + group: wgpu::BindGroup, + uniform: wgpu::Buffer, + vertices: wgpu::Buffer, + capacity: usize, + count: u32, +} +impl Weather { + pub fn new( + device: &wgpu::Device, + format: wgpu::TextureFormat, + entities: &wgpu::BindGroupLayout, + shader: &wgpu::ShaderModule, + ) -> Self { + let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Weather time"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Weather layout"), + bind_group_layouts: &[Some(entities), Some(&bindings)], + ..Default::default() + }); + let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4, + 3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32]; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Weather"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: shader, + entry_point: Some("vs_weather"), + compilation_options: Default::default(), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &attributes, + })], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::GreaterEqual), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: shader, + entry_point: Some("fs_weather"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Weather time"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Weather time"), + layout: &bindings, + entries: &[wgpu::BindGroupEntry { + binding: 1, + resource: uniform.as_entire_binding(), + }], + }); + Self { + pipeline, + group, + uniform, + vertices: Self::buffer(device, 4), + capacity: 4, + count: 0, + } + } + fn buffer(device: &wgpu::Device, capacity: usize) -> wgpu::Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Weather vertices"), + size: (capacity * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + pub fn prepare( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + uploads: &mut super::upload::Uploads, + vertices: &[Vertex], + time: Time, + ) { + self.count = vertices.len() as u32; + if vertices.is_empty() { + return; + } + if vertices.len() > self.capacity { + self.capacity = vertices.len().next_power_of_two(); + self.vertices = Self::buffer(device, self.capacity); + } + uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices)); + uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time)); + } + pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) { + if self.count == 0 { + return; + } + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, entities, &[]); + pass.set_bind_group(1, &self.group, &[]); + pass.set_vertex_buffer(0, self.vertices.slice(..)); + // The renderer binds the shared triangle-list quad indices. + pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1); + } +} + +#[cfg(test)] +mod tests { + use crate::weather::{Parameters, Time}; + use wgpu::util::DeviceExt; + + #[repr(C)] + #[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] + struct Case { + animation: [f32; 4], + bases: [u32; 4], + time: [f32; 4], + uv: [f32; 2], + snow: u32, + _pad: u32, + } + + /// Execute the production WGSL math, rather than a Rust translation of it. + /// Requires a GPU; run explicitly on renderer changes, not on headless CI. + #[test] + #[ignore = "requires a Vulkan/Metal/DX12 adapter"] + fn weather_shader_uvs_match_legacy_bits() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = + pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .unwrap(); + eprintln!("weather parity adapter: {:?}", adapter.get_info()); + let (device, queue) = + pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).unwrap(); + let mut cases = Vec::new(); + let mut expected = Vec::new(); + for i in 0..4096i32 { + let x = i.wrapping_mul(45_238_971); + let z = i.wrapping_mul(-418_711).wrapping_add(i32::MIN); + let p = Parameters::new(x, z); + let bases = p.snow_base.map(f64::to_bits); + for ticks in [-1, 0, 511, 512, 131_071, 131_072, i64::MAX] { + let partial = [0.0, 0.5, 0.999, f32::from_bits(0x3f7fffff)][i as usize % 4]; + for snow in [false, true] { + let column = p.column(snow, ticks, x, -64, 320, z, 0, partial); + let uv = [1.0, if i & 1 == 0 { -16.0 } else { 80.0 }]; + cases.push(Case { + animation: [ + p.rain_speed, + p.rain_tick_offset, + p.snow_drift[0], + p.snow_drift[1], + ], + bases: [ + bases[0] as u32, + (bases[0] >> 32) as u32, + bases[1] as u32, + (bases[1] >> 32) as u32, + ], + time: Time::new(ticks, partial).values, + uv, + snow: u32::from(snow), + _pad: 0, + }); + expected.push([ + (uv[0] + column.u_offset).to_bits(), + (uv[1] + column.v_offset).to_bits(), + ]); + } + } + } + // Force halfway rounding, cancellation, zero, tiny bases and either + // sign of drift. Gaussian seeds alone rarely land on exact midpoints. + for base in [ + 0.0, + 2.0f64.powi(-53), + 0.5, + 0.5 + 2.0f64.powi(-25), + 0.5 + 2.0f64.powi(-25) + 2.0f64.powi(-53), + 0.5 + 2.0f64.powi(-14) + 2.0f64.powi(-53), + 1.0 - 2.0f64.powi(-53), + ] { + for delta in [0.0f32, -0.0, -0.5, 0.5, 1024.0, -1024.0, 2.0f32.powi(-40)] { + // time=100, u scale=.01 -> delta exactly, v is unused. + let bits = base.to_bits(); + cases.push(Case { + animation: [3.0, 0.0, delta, 0.0], + bases: [bits as u32, (bits >> 32) as u32, 0, 0], + time: [100.0, 0.0, 0.0, 0.0], + uv: [0.0, 0.0], + snow: 1, + _pad: 0, + }); + expected.push([((base + f64::from(delta)) as f32).to_bits(), 0]); + } + } + let expected: Vec<[u32; 4]> = expected + .into_iter() + .map(|bits| { + let uv = bits.map(f32::from_bits); + let texel = [ + 256 + ((uv[0] - uv[0].floor()) * 16.0).floor() as u32, + 512 + ((uv[1] - uv[1].floor()) * 32.0).floor() as u32, + ]; + [bits[0], bits[1], texel[0], texel[1]] + }) + .collect(); + let math = include_str!("../entity.wgsl") + .split("// WEATHER_MATH_BEGIN") + .nth(1) + .unwrap() + .split("// WEATHER_MATH_END") + .next() + .unwrap(); + let source = format!( + "{math}\n{}", + r#" +struct Case { animation: vec4, bases: vec4, time: vec4, uv: vec2, snow: u32, pad: u32 } +@group(0) @binding(0) var cases: array; +@group(0) @binding(1) var results: array>; +@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3) { + if id.x >= arrayLength(&cases) { return; } + let c = cases[id.x]; + let uv = c.uv + weather_phase(c.animation, c.bases, c.snow, c.time); + let texel = weather_texel(uv, vec4(0.25, 0.5, 0.015625, 0.03125), vec2(1024u)); + results[id.x] = vec4(bitcast>(uv), vec2(texel)); +}"# + ); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Weather parity"), + source: wgpu::ShaderSource::Wgsl(source.into()), + }); + let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some("Weather parity"), + layout: None, + module: &shader, + entry_point: Some("main"), + compilation_options: Default::default(), + cache: None, + }); + let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: bytemuck::cast_slice(&cases), + usage: wgpu::BufferUsages::STORAGE, + }); + let size = (expected.len() * 16) as u64; + let output = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: input.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: output.as_entire_binding(), + }, + ], + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_compute_pass(&Default::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &group, &[]); + pass.dispatch_workgroups((cases.len() as u32).div_ceil(64), 1, 1); + } + encoder.copy_buffer_to_buffer(&output, 0, &readback, 0, size); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback.map_async(wgpu::MapMode::Read, .., move |r| tx.send(r).unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + let mapped = readback.get_mapped_range(..).unwrap(); + let actual: &[[u32; 4]] = bytemuck::cast_slice(&mapped); + for (i, (actual, expected)) in actual.iter().zip(&expected).enumerate() { + assert_eq!( + actual, expected, + "case {i}, snow={}, time={:?}, animation={:?}", + cases[i].snow, cases[i].time, cases[i].animation + ); + } + eprintln!("{} GPU weather UV cases matched bit for bit", cases.len()); + } +} diff --git a/crates/horizon-client/src/weather.rs b/crates/horizon-client/src/weather.rs index 3eb39e14c..d687604cb 100644 --- a/crates/horizon-client/src/weather.rs +++ b/crates/horizon-client/src/weather.rs @@ -3,8 +3,9 @@ //! Vanilla draws each column as one camera-facing quad whose texture //! coordinates run past 0..1 (`v` advances a quarter of the texture per block, //! so `rain.png` repeats every 4 blocks) and relies on a repeating sampler. -//! Horizon samples the sprite from its atlas, so [`tile`] splits a quad at -//! every whole texture repeat into pieces that stay inside the sprite. +//! The live renderer repeats UVs inside the atlas sprite in its shader, using +//! cached coordinate-only [`Parameters`]. The reference [`tile`] path splits +//! at whole repeats and remains available for CPU callers and parity probes. use horizon_core::random::{LegacyRandom, RandomSource}; /// `WeatherEffectRenderer.ColumnInstance`. @@ -47,6 +48,176 @@ pub fn column_random(x: i32, z: i32) -> LegacyRandom { LegacyRandom::new(i64::from(column_seed(x, z))) } +/// Coordinate-only random values. Keep the snow bases as doubles: rounding them +/// before adding the float drift changes vanilla's UVs at rounding boundaries. +#[derive(Clone, Copy, Debug)] +pub struct Parameters { + pub rain_speed: f32, + pub rain_tick_offset: f32, + pub snow_base: [f64; 2], + pub snow_drift: [f32; 2], +} +impl Parameters { + pub fn new(x: i32, z: i32) -> Self { + let rain_speed = 3.0 + column_random(x, z).next_float(); + let mut random = column_random(x, z); + let u = random.next_double(); + let du = random.next_gaussian() as f32; + let v = random.next_double(); + let dv = random.next_gaussian() as f32; + Self { + rain_speed, + rain_tick_offset: (x + .wrapping_mul(x) + .wrapping_mul(3121) + .wrapping_add(x.wrapping_mul(45_238_971)) + .wrapping_add(z.wrapping_mul(z).wrapping_mul(418_711)) + .wrapping_add(z.wrapping_mul(13761)) + & 255) as f32, + snow_base: [u, v], + snow_drift: [du, dv], + } + } + + /// CPU reference for fixtures and GPU differential tests. The public random + /// source helpers below retain their original draw/advancement semantics. + #[allow(clippy::too_many_arguments)] + pub fn column( + self, + snow: bool, + ticks: i64, + x: i32, + bottom_y: i32, + top_y: i32, + z: i32, + light_coords: i32, + partial: f32, + ) -> Column { + let time = (ticks & 131_071) as f32 + partial; + let (u_offset, v_offset, light_coords) = if snow { + let u = (self.snow_base[0] + f64::from(time * 0.01 * self.snow_drift[0])) as f32; + let v = (self.snow_base[1] + f64::from(time * self.snow_drift[1] * 0.001)) as f32; + let fall = -((ticks & 511) as f32 + partial) / 512.0; + let (block, sky) = (light_coords >> 4 & 15, light_coords >> 20 & 15); + ( + u, + fall + v, + ((block * 3 + 15) / 4) << 4 | ((sky * 3 + 15) / 4) << 20, + ) + } else { + let offset = -(((ticks & 131_071) as f32 + self.rain_tick_offset) + partial) / 32.0 + * self.rain_speed; + (0.0, offset % 32.0, light_coords) + }; + Column { + x, + z, + bottom_y, + top_y, + u_offset, + v_offset, + light_coords, + } + } +} + +/// Direct-mapped 32x32 cache: at most 1024 coordinate keys, including after +/// teleports or radius changes. Full keys disambiguate collisions and wrapping. +/// Nothing world-dependent is cached, so dimension/biome/pack changes need no +/// invalidation; the caller still samples heights, precipitation and light. +pub struct ParameterCache { + entries: Box<[Option<((i32, i32), Parameters)>]>, +} +impl Default for ParameterCache { + fn default() -> Self { + Self { + entries: vec![None; 32 * 32].into_boxed_slice(), + } + } +} +impl ParameterCache { + pub fn get(&mut self, x: i32, z: i32) -> Parameters { + let index = ((z as u32 & 31) * 32 + (x as u32 & 31)) as usize; + let entry = &mut self.entries[index]; + if let Some((key, parameters)) = entry + && *key == (x, z) + { + return *parameters; + } + let parameters = Parameters::new(x, z); + *entry = Some(((x, z), parameters)); + parameters + } +} + +/// One unsplit camera-relative weather vertex. UV animation and atlas-local +/// repetition happen in the weather shader, rather than rebuilding tiled quads. +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Vertex { + pub pos: [f32; 3], + pub uv: [f32; 2], + pub rect: [f32; 4], + pub animation: [f32; 4], + pub bases: [u32; 4], + pub color: u32, + pub light: u32, + pub snow: u32, +} + +/// Current ticks are masked on the CPU before conversion to float, including +/// negative/large tick values. Do not use the renderer's unbounded game_time. +#[repr(C)] +#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct Time { + pub values: [f32; 4], +} +impl Time { + pub fn new(ticks: i64, partial: f32) -> Self { + Self { + values: [(ticks & 131_071) as f32, (ticks & 511) as f32, partial, 0.0], + } + } +} + +/// Emit a single quad, retaining column_quad's position, fade, UV rounding and +/// light semantics. The zero phase leaves the shader to animate both UV axes. +pub fn vertices( + column: &Column, + parameters: Parameters, + snow: bool, + camera: [f64; 3], + radius: i32, + intensity: f32, + rect: [f32; 4], +) -> [Vertex; 4] { + let quad = column_quad( + column, + camera, + if snow { 0.8 } else { 1.0 }, + radius, + intensity, + ); + let lc = column.light_coords; + let light = ((lc >> 20 & 15) * 16) as u32 | (((lc >> 4 & 15) * 16) as u32) << 8; + let [u, v] = parameters.snow_base.map(f64::to_bits); + quad.map(|q| Vertex { + pos: q.pos, + uv: q.uv, + rect, + animation: [ + parameters.rain_speed, + parameters.rain_tick_offset, + parameters.snow_drift[0], + parameters.snow_drift[1], + ], + bases: [u as u32, (u >> 32) as u32, v as u32, (v >> 32) as u32], + color: q.color, + light, + snow: u32::from(snow), + }) +} + /// `createRainColumnInstance`. #[allow(clippy::too_many_arguments)] pub fn rain_column( @@ -135,8 +306,8 @@ pub fn column_quad( let t = (distance_sq / radius_sq).min(1.0); let alpha = (max_alpha + t * (0.5 - max_alpha)) * intensity; let color = ((alpha * 255.0).floor() as i32 as u32) << 24 | 0xFF_FFFF; - let index = - (column.z - camera[2].floor() as i32 + 16) * 32 + column.x - camera[0].floor() as i32 + 16; + let index = (column.z - camera[2].floor() as i32 + 16) * 32 + + (column.x - camera[0].floor() as i32 + 16); let (size_x, size_z) = column_size(index as usize); let (half_x, half_z) = (size_x / 2.0, size_z / 2.0); let (x0, x1) = (relative_x - half_x, relative_x + half_x); @@ -219,6 +390,82 @@ fn pieces(a: f32, b: f32) -> impl Iterator { mod tests { use super::*; + #[test] + fn cached_parameters_match_original_random_draws() { + let mut cache = ParameterCache::default(); + for i in 0..2048i32 { + let x = i.wrapping_mul(45_238_971); + let z = i.wrapping_mul(-418_711).wrapping_add(i32::MIN); + for ticks in [i64::MIN, -1, 0, 511, 512, 131_071, 131_072, i64::MAX] { + for partial in [0.0, 0.5, 0.999] { + for snow in [false, true] { + let mut random = column_random(x, z); + let reference = if snow { + snow_column(&mut random, ticks, x, -64, 320, z, 0xf000f0, partial) + } else { + rain_column(&mut random, ticks, x, -64, 320, z, 0xf000f0, partial) + }; + assert_eq!( + cache + .get(x, z) + .column(snow, ticks, x, -64, 320, z, 0xf000f0, partial), + reference + ); + } + } + } + } + } + + #[test] + fn parameter_cache_reuses_full_keys_and_stays_bounded() { + let mut cache = ParameterCache::default(); + let expected = cache.get(-1, i32::MAX); + // A changed entry proves that a hit does not regenerate random values. + let index = 31 * 32 + 31; + cache.entries[index].as_mut().unwrap().1.rain_speed = 123.0; + assert_eq!(cache.get(-1, i32::MAX).rain_speed, 123.0); + // Colliding positions must replace the entry, rather than reuse it. + cache.get(31, -1); + assert_eq!(cache.get(-1, i32::MAX).rain_speed, expected.rain_speed); + for i in 0..100_000i32 { + cache.get(i.wrapping_mul(3121), i.wrapping_mul(418_711)); + } + assert_eq!(cache.entries.len(), 1024); + assert!(cache.entries.iter().flatten().count() <= 1024); + } + + #[test] + fn unsplit_vertices_keep_live_geometry_light_and_atlas_inputs() { + let x = i32::MAX - 5; + let z = i32::MIN + 5; + let parameters = Parameters::new(x, z); + let camera = [f64::from(x) - 2.25, 80.75, f64::from(z) + 0.25]; + for (bottom_y, top_y, light_coords, radius, intensity, rect) in [ + (64, 90, 0, 5, 0.25, [0.1, 0.2, 0.03, 0.04]), + (85, 95, 0xf000f0, 10, 1.0, [0.5, 0.5, 0.1, 0.2]), + ] { + let column = Column { + x, + z, + bottom_y, + top_y, + light_coords, + u_offset: 0.0, + v_offset: 0.0, + }; + let quad = column_quad(&column, camera, 1.0, radius, intensity); + let vertices = vertices(&column, parameters, false, camera, radius, intensity, rect); + for (a, b) in vertices.iter().zip(quad) { + assert_eq!(a.pos, b.pos); + assert_eq!(a.uv, b.uv); + assert_eq!(a.color, b.color); + assert_eq!(a.rect, rect); + assert_eq!(a.light, if light_coords == 0 { 0 } else { 0xf0f0 }); + } + } + } + #[test] fn rain_repeats_every_four_blocks() { let column = Column { diff --git a/tools/test_weather_render_parity.py b/tools/test_weather_render_parity.py index af713ec80..cf52156e0 100644 --- a/tools/test_weather_render_parity.py +++ b/tools/test_weather_render_parity.py @@ -12,8 +12,8 @@ def make_input(build): rng = random.Random(71) cases = [] for kind in (0, 1): - for ticks in (0, 1, 511, 512, 131071, 131072, 24000 * 37 + 5, 9_000_000_123): - for x, z in ((0, 0), (1, 0), (0, -1), (3, 7), (-10, 10), (10, -10), (-4, -9), (1_000_003, -2_000_011)): + for ticks in (-9223372036854775808, -1, 0, 1, 511, 512, 131071, 131072, 24000 * 37 + 5, 9_000_000_123, 9223372036854775807): + for x, z in ((0, 0), (1, 0), (0, -1), (3, 7), (-10, 10), (10, -10), (-4, -9), (1_000_003, -2_000_011), (30_000_000, -30_000_000), (2_147_483_631, -2_147_483_632)): for bottom, top in ((60, 80), (64, 65), (-64, -44), (300, 310)): light = (rng.randrange(16) << 4) | (rng.randrange(16) << 20) partial = rng.choice(("0.0", "0.5", "0.999", f"{rng.random():.6f}"))