Cache weather column parameters and animate atlas UVs in shader

This commit is contained in:
rdbtCVS
2026-10-22 16:54:09 +01:00
parent 34c3c1cffe
commit e4ea9e0199
7 changed files with 810 additions and 68 deletions
@@ -11,6 +11,7 @@ fn bits(value: f32) -> String {
fn main() -> Result<(), Box<dyn std::error::Error>> {
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::<i32>().unwrap();
@@ -38,6 +39,13 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
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!(
+133
View File
@@ -43,6 +43,7 @@ struct Out {
@location(8) back_color: vec4<f32>,
// entity.vsh's lightMapColor, multiplied in after the overlay.
@location(9) light_color: vec4<f32>,
@location(10) @interpolate(flat) weather_rect: vec4<f32>,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, 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<f32> {
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<u32>, n: u32) -> vec2<u32> {
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<u32>, b: vec2<u32>) -> vec2<u32> {
let lo = a.x + b.x;
return vec2(lo, a.y + b.y + select(0u, 1u, lo < a.x));
}
fn weather_sub64(a: vec2<u32>, b: vec2<u32>) -> vec2<u32> {
return vec2(a.x - b.x, a.y - b.y - select(0u, 1u, a.x < b.x));
}
fn weather_double_plus_float(base: vec2<u32>, 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<u32>(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<u32>;
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<f32>(sign | (u32(exponent + 127) << 23u) | (mantissa & 0x7fffffu));
}
fn weather_phase(animation: vec4<f32>, bases: vec4<u32>, snow: u32, time: vec4<f32>) -> vec2<f32> {
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<f32>, rect: vec4<f32>, dimensions: vec2<u32>) -> vec2<i32> {
let size = vec2<f32>(dimensions);
let origin = vec2<i32>(round(rect.xy * size));
let extent = max(vec2<i32>(round(rect.zw * size)), vec2(1));
let local = min(vec2<i32>(floor(fract(uv) * vec2<f32>(extent))), extent - vec2(1));
return origin + local;
}
// WEATHER_MATH_END
struct WeatherTime { values: vec4<f32> }
@group(1) @binding(1) var<uniform> weather_time: WeatherTime;
@vertex fn vs_weather(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @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<f32> {
// 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);
}
+62 -60
View File
@@ -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<Vertex>) {
fn weather_geometry(
&mut self,
camera: glam::DVec3,
alpha: f64,
) -> Vec<horizon_client::weather::Vertex> {
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,
+24 -2
View File
@@ -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, &[]);
+330
View File
@@ -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::<Vertex>() 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::<Vertex>()) 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<f32>, bases: vec4<u32>, time: vec4<f32>, uv: vec2<f32>, snow: u32, pad: u32 }
@group(0) @binding(0) var<storage, read> cases: array<Case>;
@group(0) @binding(1) var<storage, read_write> results: array<vec4<u32>>;
@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {
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<vec2<u32>>(uv), vec2<u32>(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());
}
}
+251 -4
View File
@@ -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<Item = (f32, f32)> {
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 {
+2 -2
View File
@@ -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}"))