ccc3e20902
Smoke: one rule by engine, no heat on trails, no trail on P1-SUN. Searchlights: two lamps a target, off with an empty sky. Halos fade before their reach and their sum is limited. Launch sites load chunks through the server thread, go off-map as a last resort, and reach 4000 blocks. Air defence: ranges x1.5, shots grow with urgency, claims released, /airdef lastchance, long shots on a big radar's tracks. Build: rust-toolchain.toml, airdef.py check, generator manifest and --check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
604 lines
24 KiB
Diff
604 lines
24 KiB
Diff
From ee79b3562feebe51959a01f1585a9e85bd53ccb5 Mon Sep 17 00:00:00 2001
|
|
From: chexit <chexit@duck.com>
|
|
Date: Wed, 28 Oct 2026 13:33:10 +0300
|
|
Subject: [PATCH 03/40] 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 <noreply@anthropic.com>
|
|
---
|
|
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<String, Option<BlockStateId>>,
|
|
+ /// 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<horizon_client::mesher::Vertex>,
|
|
) {
|
|
}
|
|
+ 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<ParticleSpawn>) {
|
|
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<pack_post::PackPost>)>,
|
|
/// The post passes of one of Horizon's own shader packs; never beside `pack_post`'s.
|
|
native: Option<native_pack::NativePack>,
|
|
+ /// AirDef fork: a mod's post pass, its parameters and the partial tick they are for.
|
|
+ mod_post: Option<mod_post::ModPost>,
|
|
+ mod_params: (Vec<f32>, 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<f32>, pixel: vec2<f32>) -> vec4<f32>`.
|
|
+//! 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<f32>,
|
|
+ view_inverse: mat4x4<f32>,
|
|
+ projection_inverse: mat4x4<f32>,
|
|
+ camera: vec4<f32>,
|
|
+ resolution: vec4<f32>,
|
|
+ time: vec4<f32>,
|
|
+ sun: vec4<f32>,
|
|
+ params: array<vec4<f32>, 256>,
|
|
+}
|
|
+@group(0) @binding(0) var<uniform> frame: ModFrame;
|
|
+@group(0) @binding(1) var scene_color: texture_2d<f32>;
|
|
+@group(0) @binding(2) var scene_depth: texture_depth_2d;
|
|
+@group(0) @binding(3) var linear_sampler: sampler;
|
|
+
|
|
+struct HorizonModPostOut {
|
|
+ @builtin(position) position: vec4<f32>,
|
|
+ @location(0) uv: vec2<f32>,
|
|
+}
|
|
+
|
|
+@vertex
|
|
+fn horizon_mod_post_vs(@builtin(vertex_index) i: u32) -> HorizonModPostOut {
|
|
+ let uv = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
|
|
+ var out: HorizonModPostOut;
|
|
+ out.position = vec4<f32>(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>) -> f32 {
|
|
+ let size = vec2<f32>(textureDimensions(scene_depth));
|
|
+ let p = vec2<i32>(clamp(uv * size, vec2<f32>(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<f32>, depth: f32) -> vec3<f32> {
|
|
+ let ndc = vec4<f32>(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<f32>(v.xyz / v.w, 0.0)).xyz;
|
|
+}
|
|
+
|
|
+/// The unit direction of the view ray through `uv`.
|
|
+fn view_dir(uv: vec2<f32>) -> vec3<f32> {
|
|
+ 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>) -> 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<f32>) -> vec3<f32> {
|
|
+ let c = frame.view_proj * vec4<f32>(world - frame.camera.xyz, 1.0);
|
|
+ let ndc = c.xyz / max(abs(c.w), 1e-5);
|
|
+ return vec3<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, c.w);
|
|
+}
|
|
+
|
|
+fn scene(uv: vec2<f32>) -> vec3<f32> {
|
|
+ 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<f32> {
|
|
+ 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<naga::Module, String> {
|
|
+ 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<Self, String> {
|
|
+ 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::<ModFrame>() 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::<ModFrame>() 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<f32>, pixel: vec2<f32>) -> vec4<f32> { return vec4<f32>(scene(uv), 1.0); }";
|
|
+ assert!(super::compile(ok).is_ok());
|
|
+ let bad = "fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32> {\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<tuple<s32, s32, s32>>;
|
|
+ /// A full-screen post pass (AirDef fork): WGSL defining
|
|
+ /// `fn post(uv: vec2<f32>, pixel: vec2<f32>) -> vec4<f32>`, 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<f32>);
|
|
}
|
|
|
|
/// 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<f32>,
|
|
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<f32>) {
|
|
+ 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)
|
|
|