Files
sigil/src/viz/shader.rs
T
2026-05-19 19:56:25 +02:00

192 lines
7.3 KiB
Rust

//! Reusable raw-wgpu raymarch/post pipeline base.
//!
//! [`ShaderPipeline<U>`] is the self-contained fullscreen-fragment pipeline
//! that `breakcore` pioneered, hoisted so any future GPU visualiser can drive
//! a `.wgsl` shader without re-deriving the wgpu boilerplate. It owns:
//!
//! - WGSL module + fullscreen-triangle render pipeline (no vertex buffers;
//! entry points are the fixed convention `vs_main` / `fs_main`),
//! - a single uniform buffer sized to `U`,
//! - dual `RES²` textures ping-ponged for frame feedback (the shader samples
//! the previous frame at `@binding(1)`, writes the other),
//! - per-frame UBO upload + the bind-group swap.
//!
//! A new GPU visualiser supplies only its UBO type, its `.wgsl`, and the
//! per-frame fill of `U`; the section/geometry/colour logic stays in Rust.
//!
//! **Guardrails (non-negotiable, inherited from breakcore).** This base does
//! *not* abstract away the two things that can hang the GPU: the shader's own
//! hard march-step cap and any `array<vec4, N>` ↔ Rust UBO-length coupling
//! still live in the shader + the caller's `U` layout, and naga only
//! validates the WGSL at pipeline-create on a real device. Keep the cap in
//! the `.wgsl` and keep `U`'s layout in lock-step with the shader's UBO.
//!
//! Determinism: pure GPU plumbing — no `Rng`, no clock, no integration. Given
//! the same `U` bytes it produces the same frame, so a visualiser built on it
//! stays `--render` bit-reproducible exactly as before (the state lives in the
//! caller, advanced once per frame as ever).
use std::marker::PhantomData;
use nannou::wgpu;
/// Reinterpret a `Copy`, packing-free POD (`[f32; N]`, `#[repr(C)]` plain
/// struct) as its raw bytes for `write_buffer`. The crate already relies on
/// exactly this invariant for its one UBO, so this needs no `bytemuck` dep —
/// but `U` MUST stay padding-free and match the shader's UBO layout.
fn as_bytes<U: Copy>(u: &U) -> &[u8] {
unsafe {
std::slice::from_raw_parts((u as *const U).cast::<u8>(), std::mem::size_of::<U>())
}
}
/// Fullscreen fragment pipeline with ping-pong feedback. `U` is the uniform
/// block (one `write_buffer` per frame); see the module guardrails note.
pub struct ShaderPipeline<U> {
pipeline: wgpu::RenderPipeline,
ubo: wgpu::Buffer,
tex: [wgpu::Texture; 2],
view: [wgpu::TextureViewHandle; 2],
bind: [wgpu::BindGroup; 2], // bind[w]: writes view[w], samples view[1-w]
cur: usize, // index last written / presented
primed: bool,
_pd: PhantomData<U>,
}
impl<U: Copy> ShaderPipeline<U> {
/// Build the pipeline for `wgsl` at `res²` in `format`. Bindings, in WGSL
/// `@binding` order, are: `0` uniform `U`, `1` previous-frame texture,
/// `2` sampler. Entry points are `vs_main` / `fs_main`.
pub fn new(
device: &wgpu::Device,
wgsl: &str,
res: u32,
format: wgpu::TextureFormat,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("shaderpipeline-shader"),
source: wgpu::ShaderSource::Wgsl(wgsl.into()),
});
let mk = || {
wgpu::TextureBuilder::new()
.size([res, res])
.format(format)
.usage(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
)
.build(device)
};
let tex = [mk(), mk()];
let view = [
tex[0].create_view(&wgpu::TextureViewDescriptor::default()),
tex[1].create_view(&wgpu::TextureViewDescriptor::default()),
];
// Bindings (order = WGSL @binding 0/1/2): uniform, prev texture, sampler.
let bgl = wgpu::BindGroupLayoutBuilder::new()
.uniform_buffer(wgpu::ShaderStages::FRAGMENT, false)
.texture_from(wgpu::ShaderStages::FRAGMENT, &tex[0])
.sampler(wgpu::ShaderStages::FRAGMENT, true)
.build(device);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("shaderpipeline-pl"),
bind_group_layouts: &[&bgl],
push_constant_ranges: &[],
});
// Fullscreen triangle: no vertex buffers; default triangle-list.
let pipeline = wgpu::RenderPipelineBuilder::from_layout(&layout, &shader)
.vertex_entry_point("vs_main")
.fragment_shader(&shader)
.fragment_entry_point("fs_main")
.color_format(format)
.build(device);
let ubo = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("shaderpipeline-ubo"),
size: std::mem::size_of::<U>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sampler = wgpu::SamplerBuilder::new()
.address_mode(wgpu::AddressMode::ClampToEdge)
.mag_filter(wgpu::FilterMode::Linear)
.min_filter(wgpu::FilterMode::Linear)
.mipmap_filter(wgpu::FilterMode::Nearest)
.build(device);
let mk_bind = |w: usize| {
wgpu::BindGroupBuilder::new()
.binding(ubo.as_entire_binding())
.texture_view(&view[1 - w])
.sampler(&sampler)
.build(device, &bgl)
};
let bind = [mk_bind(0), mk_bind(1)];
ShaderPipeline {
pipeline,
ubo,
tex,
view,
bind,
cur: 0,
primed: false,
_pd: PhantomData,
}
}
/// Upload `ubo`, raymarch one frame into the unused ping-pong slot
/// (sampling the previous), and return the just-written texture.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
ubo: &U,
) -> &wgpu::Texture {
let w = 1 - self.cur; // write target; sample the last-written (self.cur)
queue.write_buffer(&self.ubo, 0, as_bytes(ubo));
let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("shaderpipeline-enc"),
});
{
let mut rp = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("shaderpipeline-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.view[w],
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: None,
});
rp.set_pipeline(&self.pipeline);
rp.set_bind_group(0, &self.bind[w], &[]);
rp.draw(0..3, 0..1);
}
queue.submit(Some(enc.finish()));
self.cur = w;
self.primed = true;
&self.tex[self.cur]
}
/// The most recently written texture (for present/readback).
pub fn current(&self) -> &wgpu::Texture {
&self.tex[self.cur]
}
/// `true` once at least one frame has been rendered (the feedback sample
/// is only valid after the first frame).
pub fn primed(&self) -> bool {
self.primed
}
}