From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001 From: chexit Date: Wed, 28 Oct 2026 13:33:10 +0300 Subject: [PATCH 03/12] Mod API (AirDef fork): full-screen post pass for mods render.set-post compiles a mod's WGSL after a prelude (camera matrices, time, 256 vec4 of params, the world's colour and depth) and runs it after the world, before the hand and the HUD; render.set-params feeds it. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/mod_models.rs | 28 ++ crates/horizon-client/src/gfx.rs | 35 +- crates/horizon-client/src/gfx/mod_post.rs | 320 +++++++++++++++++++ crates/horizon-mod-sdk/wit/horizon.wit | 13 + crates/horizon-mods/src/api_level.rs | 5 +- crates/horizon-mods/src/runtime.rs | 28 ++ 7 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 crates/horizon-client/src/gfx/mod_post.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7c342cd86..ca6a8c266 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -11527,6 +11527,7 @@ impl Game<'_> { return Ok(false); }; let alpha = self.clock.fraction(); + self.apply_mod_post(alpha as f32); // Camera.getCameraEntityPartialTicks: the view, the hand and the // player's own model stay smooth while the level is frozen. let camera_alpha = self.clock.camera_fraction(); diff --git a/crates/horizon-client/src/game/mod_models.rs b/crates/horizon-client/src/game/mod_models.rs index 3c7f9345c..be00ea4c7 100644 --- a/crates/horizon-client/src/game/mod_models.rs +++ b/crates/horizon-client/src/game/mod_models.rs @@ -36,6 +36,8 @@ pub(super) struct ModModels { poses: HashMap<(usize, u32), Pose>, /// Block states by the text mods name them, parsed once. states: HashMap>, + /// The version of the mod post pass the renderer has (0: none). + post: u64, } /// Full brightness: sky and block light 15, as the entity shader packs them. @@ -52,6 +54,7 @@ impl Game<'_> { _out: &mut Vec, ) { } + pub(super) fn apply_mod_post(&mut self, _partial: f32) {} } #[cfg(feature = "mods")] @@ -148,6 +151,31 @@ impl Game<'_> { } } + /// Before each frame: the renderer gets the mod's post pass when it changed, and its + /// parameters. A pass that doesn't compile is reported once; the last good one runs on. + pub(super) fn apply_mod_post(&mut self, partial: f32) { + let (version, source, params) = match self.mods.mods.post() { + Some((v, s, p)) => (v, Some(s.to_owned()), p.to_vec()), + None => (0, None, Vec::new()), + }; + let mut error = None; + let Some(renderer) = self.renderer.as_mut() else { return }; + if version != self.mods.models.post { + self.mods.models.post = version; + error = renderer.set_mod_post(source.as_deref()).err(); + } + if version != 0 { + renderer.set_mod_params(¶ms, partial); + } + if let Some(e) = error { + log::warn!("Horizon: mod post pass refused: {e}"); + self.system_message( + horizon_client::text::Text::literal(format!("Mod shader error: {e}")) + .with_color(horizon_client::text::RED), + ); + } + } + /// `render.spawn-particles`: made on this client at once. pub(super) fn mod_particles(&mut self, particles: Vec) { for s in particles { diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index beb8b9060..4c5005615 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -77,6 +77,7 @@ mod lod; mod native_material; mod native_pack; mod native_post; +mod mod_post; mod native_sky; mod occlusion; mod pack_atlas; @@ -543,6 +544,9 @@ pub struct Renderer { pack_post: Option<(i32, Option)>, /// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s. native: Option, + /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for. + mod_post: Option, + mod_params: (Vec, f32), /// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`). pass_timing: bool, /// The entity vertices Horizon draws itself while a shader pack draws the others. @@ -2646,6 +2650,8 @@ impl Renderer { hdr_output, pack_post: None, native: None, + mod_post: None, + mod_params: (Vec::new(), 0.0), pass_timing: pack_timing::PassTiming::logging(), pack_entity_rest: Vec::new(), cloud_bindings, @@ -3287,6 +3293,24 @@ impl Renderer { report } /// Drops the running shader pack, of either kind. + /// AirDef fork: runs a mod's post pass from now on (`None`: none). On an error the pass + /// that ran keeps running. + pub fn set_mod_post(&mut self, source: Option<&str>) -> std::result::Result<(), String> { + match source { + None => self.mod_post = None, + Some(source) => { + let format = self.config.format.remove_srgb_suffix(); + self.mod_post = Some(mod_post::ModPost::new(&self.device, source, format)?); + } + } + Ok(()) + } + /// AirDef fork: the mod post pass's parameters for the next frames. + pub fn set_mod_params(&mut self, params: &[f32], partial: f32) { + self.mod_params.0.clear(); + self.mod_params.0.extend_from_slice(¶ms[..params.len().min(mod_post::PARAMS)]); + self.mod_params.1 = partial; + } pub fn drop_shader_packs(&mut self) { self.pack_post = None; self.native = None; @@ -4456,7 +4480,9 @@ impl Renderer { .then(|| pack_timing::PassTiming::new(&self.device, &self.queue)) .flatten(); } - let pack_reads_world = pack_post || native_post; + // AirDef fork: a mod's post pass reads the world target too. + let mod_post = !frame.title && !pack_post && !native_post && self.mod_post.is_some(); + let pack_reads_world = pack_post || native_post || mod_post; // The pack's program reads the vertices this frame's draws bind. let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat; // The shadow camera's lists, drawn by the pack's shadow programs. @@ -5026,6 +5052,13 @@ impl Renderer { size, native.timing.as_mut(), ); + } else if let Some((color, depth)) = &scaled + && mod_post + && let Some(post) = &mut self.mod_post + { + let size = self.world_post.size().unwrap_or([1, 1]); + post.write(&self.queue, frame, size, &self.mod_params.0, self.mod_params.1); + post.run(&self.device, &mut encoder, (color, depth), &view_target); } else if scaled.is_some() { self.world_post.finish(&mut encoder, &view_target); } diff --git a/crates/horizon-client/src/gfx/mod_post.rs b/crates/horizon-client/src/gfx/mod_post.rs new file mode 100644 index 000000000..497cff854 --- /dev/null +++ b/crates/horizon-client/src/gfx/mod_post.rs @@ -0,0 +1,320 @@ +//! AirDef fork: a mod's full-screen post pass (`render.set-post`). The mod's WGSL is put after +//! a prelude that declares the frame (camera matrices, time, the mod's parameters) and the +//! world's colour and depth; it defines `fn post(uv: vec2, pixel: vec2) -> vec4`. +//! The pass runs after the world and before the hand and the HUD, reading the scaled world +//! target and drawing onto the screen. + +use glam::Mat4; + +use super::Frame; + +/// Floats of mod parameters (256 vec4). +pub const PARAMS: usize = 1024; + +/// The uniform the prelude's `frame` reads. +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct ModFrame { + view_proj: [[f32; 4]; 4], + view_inverse: [[f32; 4]; 4], + projection_inverse: [[f32; 4]; 4], + /// xyz: the camera; w: unused. + camera: [f32; 4], + /// width, height, 1/width, 1/height. + resolution: [f32; 4], + /// seconds (wraps hourly), the partial tick, the sun's height, unused. + time: [f32; 4], + /// The sun's direction. + sun: [f32; 4], + params: [[f32; 4]; PARAMS / 4], +} + +pub const PRELUDE: &str = r#" +struct ModFrame { + view_proj: mat4x4, + view_inverse: mat4x4, + projection_inverse: mat4x4, + camera: vec4, + resolution: vec4, + time: vec4, + sun: vec4, + params: array, 256>, +} +@group(0) @binding(0) var frame: ModFrame; +@group(0) @binding(1) var scene_color: texture_2d; +@group(0) @binding(2) var scene_depth: texture_depth_2d; +@group(0) @binding(3) var linear_sampler: sampler; + +struct HorizonModPostOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn horizon_mod_post_vs(@builtin(vertex_index) i: u32) -> HorizonModPostOut { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + var out: HorizonModPostOut; + out.position = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0); + out.uv = uv; + return out; +} + +/// The world's depth at `uv` (reversed: 1 near, 0 the sky). +fn depth_at(uv: vec2) -> f32 { + let size = vec2(textureDimensions(scene_depth)); + let p = vec2(clamp(uv * size, vec2(0.0), size - 1.0)); + return textureLoad(scene_depth, p, 0); +} + +/// The point the pixel at `uv` sees at `depth`, relative to the camera (world axes). +fn view_ray_point(uv: vec2, depth: f32) -> vec3 { + let ndc = vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0); + let v = frame.projection_inverse * ndc; + return (frame.view_inverse * vec4(v.xyz / v.w, 0.0)).xyz; +} + +/// The unit direction of the view ray through `uv`. +fn view_dir(uv: vec2) -> vec3 { + return normalize(view_ray_point(uv, 0.5)); +} + +/// How far along the view ray the world is at `uv` (a large number for the sky). +fn scene_distance(uv: vec2) -> f32 { + let d = depth_at(uv); + if d <= 0.0 { return 1.0e6; } + return length(view_ray_point(uv, d)); +} + +/// A world point on the screen: xy in uv, z its distance (negative behind the camera). +fn project(world: vec3) -> vec3 { + let c = frame.view_proj * vec4(world - frame.camera.xyz, 1.0); + let ndc = c.xyz / max(abs(c.w), 1e-5); + return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, c.w); +} + +fn scene(uv: vec2) -> vec3 { + return textureSampleLevel(scene_color, linear_sampler, uv, 0.0).rgb; +} +"#; + +const ENTRY: &str = r#" +@fragment +fn horizon_mod_post_fs(in: HorizonModPostOut) -> @location(0) vec4 { + return post(in.uv, in.position.xy); +} +"#; + +pub struct ModPost { + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + buffer: wgpu::Buffer, + sampler: wgpu::Sampler, + group: Option<((wgpu::TextureView, wgpu::TextureView), wgpu::BindGroup)>, + start: std::time::Instant, +} + +/// The mod's source with the prelude, checked by naga: errors name the mod's own lines. +fn compile(source: &str) -> Result { + let full = format!("{PRELUDE}\n{source}\n{ENTRY}"); + let module = naga::front::wgsl::parse_str(&full).map_err(|e| { + let prelude_lines = PRELUDE.lines().count() + 1; + let text = e.emit_to_string(&full); + match e.location(&full) { + Some(l) if l.line_number as usize > prelude_lines => { + format!("line {}: {}", l.line_number as usize - prelude_lines, e.message()) + } + _ => text, + } + })?; + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::default(), + ) + .validate(&module) + .map_err(|e| format!("{}", e.into_inner()))?; + Ok(module) +} + +impl ModPost { + pub fn new(device: &wgpu::Device, source: &str, format: wgpu::TextureFormat) -> Result { + let module = compile(source)?; + let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Mod post"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(size_of::() as u64), + }, + count: None, + }, + texture(1, wgpu::TextureSampleType::Float { filterable: true }), + texture(2, wgpu::TextureSampleType::Depth), + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Mod post"), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Mod post"), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Mod post"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("horizon_mod_post_vs"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("horizon_mod_post_fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + if let Some(e) = pollster::block_on(scope.pop()) { + return Err(e.to_string()); + } + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Mod post frame"), + size: size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Mod post linear"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + Ok(Self { + pipeline, + layout, + buffer, + sampler, + group: None, + start: std::time::Instant::now(), + }) + } + + /// This frame's camera and the mod's `params`, for a world of `size`. + pub fn write(&self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2], params: &[f32], partial: f32) { + let aspect = size[0] as f32 / size[1].max(1) as f32; + let view = crate::camera::view_matrix(frame.yaw, frame.pitch); + let projection = + super::world_projection(frame.fov_degrees, aspect, frame.depth_far) * frame.bob * frame.spin; + let a = frame.sky.sun_angle.to_radians(); + let sun = [-a.sin(), a.cos(), 0.0, 0.0]; + let mut p = [[0.0f32; 4]; PARAMS / 4]; + for (i, v) in params.iter().take(PARAMS).enumerate() { + p[i / 4][i % 4] = *v; + } + let m = |m: Mat4| m.to_cols_array_2d(); + let inputs = ModFrame { + view_proj: m(projection * view), + view_inverse: m(view.inverse()), + projection_inverse: m(projection.inverse()), + camera: [frame.camera.x as f32, frame.camera.y as f32, frame.camera.z as f32, 0.0], + resolution: [ + size[0] as f32, + size[1] as f32, + 1.0 / size[0].max(1) as f32, + 1.0 / size[1].max(1) as f32, + ], + time: [self.start.elapsed().as_secs_f32() % 3600.0, partial, sun[1], 0.0], + sun, + params: p, + }; + queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs)); + } + + /// Draws the pass over the world target `world` (colour, depth) onto `screen`. + pub fn run( + &mut self, + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + world: (&wgpu::TextureView, &wgpu::TextureView), + screen: &wgpu::TextureView, + ) { + if self + .group + .as_ref() + .is_none_or(|((c, d), _)| c != world.0 || d != world.1) + { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Mod post"), + layout: &self.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.buffer.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(world.0) }, + wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(world.1) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }); + self.group = Some(((world.0.clone(), world.1.clone()), group)); + } + let Some((_, group)) = &self.group else { return }; + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Mod post"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: screen, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, group, &[]); + pass.draw(0..3, 0..1); + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_shader_compiles_and_errors_name_its_line() { + let ok = "fn post(uv: vec2, pixel: vec2) -> vec4 { return vec4(scene(uv), 1.0); }"; + assert!(super::compile(ok).is_ok()); + let bad = "fn post(uv: vec2, pixel: vec2) -> vec4 {\n return nope;\n}"; + let e = super::compile(bad).unwrap_err(); + assert!(e.starts_with("line 2"), "{e}"); + } +} diff --git a/crates/horizon-mod-sdk/wit/horizon.wit b/crates/horizon-mod-sdk/wit/horizon.wit index 89d1906a9..1b167c7f7 100644 --- a/crates/horizon-mod-sdk/wit/horizon.wit +++ b/crates/horizon-mod-sdk/wit/horizon.wit @@ -487,6 +487,19 @@ interface render { /// the highest block that blocks motion or holds a fluid, as the motion /// blocking heightmap; -64 where the chunk isn't loaded. ground: func() -> list>; + /// A full-screen post pass (AirDef fork): WGSL defining + /// `fn post(uv: vec2, pixel: vec2) -> vec4`, after a prelude + /// with `frame` (camera matrices, time, `params`), the world's colour and + /// depth and helpers (`scene`, `depth-at`, `view-dir`, `scene-distance`, + /// `project`). It runs after the world, before the hand and the HUD. + /// Compile errors come back in the chat and the log; the last good pass + /// keeps running. + set-post: func(wgsl: string); + clear-post: func(); + /// The pass's `frame.params` (up to 1024 floats, as 256 vec4), kept until + /// replaced; call it once a tick (the pass gets the partial tick in + /// `frame.time.y`). + set-params: func(params: list); } /// 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 b64792eaa..68487e600 100644 --- a/crates/horizon-mods/src/api_level.rs +++ b/crates/horizon-mods/src/api_level.rs @@ -13,7 +13,7 @@ pub const LEVEL: u32 = 8; /// 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); 51] = [ +pub const FUNCTIONS: [(&str, u32); 54] = [ ("log.write", 0), ("game.get-player", 0), ("game.get-clock", 0), @@ -66,6 +66,9 @@ pub const FUNCTIONS: [(&str, u32); 51] = [ ("render.play-sound", 8), ("render.query-ground", 8), ("render.ground", 8), + ("render.set-post", 8), + ("render.clear-post", 8), + ("render.set-params", 8), ]; /// 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 317cd4f06..55c1fd13f 100644 --- a/crates/horizon-mods/src/runtime.rs +++ b/crates/horizon-mods/src/runtime.rs @@ -432,6 +432,9 @@ struct State { ground_queries: Vec<(i32, i32)>, /// The game's answers to them. ground: Vec<(i32, i32, i32)>, + /// `render.set-post`'s source, with a number that changes with it. + post: Option<(u64, String)>, + post_params: Vec, input: InputState, browser: BrowserState, /// Capability `storage`'s folder; made on first `set`. @@ -638,6 +641,8 @@ impl Mods { models: Vec::new(), ground_queries: Vec::new(), ground: Vec::new(), + post: None, + post_params: Vec::new(), input: InputState::default(), browser: BrowserState::default(), data_dir, @@ -1212,6 +1217,14 @@ impl Mods { }) .collect() } + /// The first running mod's post pass (version, WGSL) and its parameters. + pub fn post(&self) -> Option<(u64, &str, &[f32])> { + self.entries.iter().find_map(|e| { + let d = e.run.as_ref()?.store.data(); + let (v, src) = d.post.as_ref()?; + Some((*v, src.as_str(), d.post_params.as_slice())) + }) + } /// Mod `i`'s answers to its ground queries, for its next calls. pub fn answer_ground(&mut self, i: usize, ground: Vec<(i32, i32, i32)>) { if let Some(run) = self.entries.get_mut(i).and_then(|e| e.run.as_mut()) { @@ -1941,6 +1954,21 @@ impl render::Host for State { self.out.push(Output::Particles { particles }); } } + fn set_post(&mut self, wgsl: String) { + static VERSION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let v = VERSION.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.post = Some((v, clip_to(wgsl, 256 * 1024))); + } + fn clear_post(&mut self) { + self.post = None; + } + fn set_params(&mut self, params: Vec) { + self.post_params = params + .into_iter() + .take(1024) + .map(|v| if v.is_finite() { v } else { 0.0 }) + .collect(); + } 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)); -- 2.54.0 (Apple Git-157)