shader
A ray-traced black hole in a <Canvas>, with sliders wired to its uniform.
Three pieces meet here, and each stays on its own side of the fence. The pipeline is built once at startup by Options::setup, which eframe hands a CreationContext with a wgpu render state in it; the resources go into callback_resources under a type of their own, so they can be found again by type and cannot clash with another app's. The <Canvas> element knows none of that: it asks the layout for a rect and calls paint with it, and what goes in there is one line pushing an egui_wgpu::Callback onto the painter. Everything in shader.wgsl is invisible to the Rust above it.
State is the point. speed, mass, bloom, tilt and paused are use_state like anywhere else; the values are copied into the callback struct, written to a uniform buffer, and read by the WGSL. Move a slider and a number in a hook changes, and the picture changes — nothing in between has to be told. mass is the one to try first: it is the Schwarzschild radius the shader traces photons around.
In a browser this runs on WebGPU where the browser has it, and falls back to WebGL where it does not; both come from eframe's defaults, with nothing to configure. Web and native has the setup hole in context.
Run it yourself
cargo run -p shader
trunk serve --config examples/shader/Trunk.tomlThe source is examples/shader/src/lib.rs; the pipeline is in src/gpu.rs and the shader in src/shader.wgsl.
use egui_reactor::prelude::*;
use egui_reactor_elements::prelude::*;
pub mod gpu;
/// The shader canvas, four sliders and a pause box.
#[component]
pub fn App(cx: &mut Cx) {
let mut speed = use_state(cx, || 1.0f32);
let mut mass = use_state(cx, || 1.0f32);
let mut bloom = use_state(cx, || 1.5f32);
// Degrees, because that is what a slider labelled "tilt" should show; the
// shader gets radians.
let mut tilt = use_state(cx, || 22.0f32);
let mut paused = use_state(cx, || false);
let mut mouse = use_state(cx, || egui::Vec2::ZERO);
// egui's own clock, in seconds since the app started. It stops moving when
// no repaint is asked for, which is what makes `pause` work.
let time = cx.ui().input(|i| i.time) as f32;
// A shader animates, so it needs a frame a frame; asking here rather than
// in `paint` keeps the request out of the drawing code.
if !*paused {
cx.ctx().request_repaint();
}
// Read the states once. The handlers below take them `&mut`, and `paint`
// is a separate closure that would otherwise hold a borrow across them.
let speed_value = *speed;
let mass_value = *mass;
let bloom_value = *bloom;
let tilt_value = tilt.to_radians();
let mouse_value = *mouse;
let points_to_pixels = cx.ctx().pixels_per_point();
rsx! {
<View direction="column" gap={8} p={12} grow={1.0}>
<Text size={22.0} strong>"shader"</Text>
<Text>"Drag the canvas to orbit the camera."</Text>
// `h={0}` with `grow={1}` is the flexbox idiom for "take what is
// left and nothing more". A `<Canvas>` reports the whole window as
// the size it could fill, and this column's height is decided by
// its content, so `grow` on its own would size the column to the
// canvas *plus* the controls and push the row below off the
// bottom. Zero height and a share of the free space instead.
<Canvas
grow={1.0}
h={0.0}
sense={egui::Sense::drag()}
on_drag={|delta: egui::Vec2| *mouse += delta}
paint={move |ui: &mut egui::Ui, rect: egui::Rect| {
// The shader works in pixels, and a rect is in points.
let resolution = rect.size() * points_to_pixels;
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
gpu::ShaderCallback {
time: time * speed_value,
mass: mass_value,
bloom: bloom_value,
tilt: tilt_value,
resolution,
mouse: mouse_value * points_to_pixels,
},
));
}}
/>
<View direction="row" gap={12} align="center" wrap>
<Slider bind={mass.bind()} range={0.1..=2.0} label="mass"/>
<Slider bind={bloom.bind()} range={0.0..=4.0} label="bloom"/>
<Slider bind={tilt.bind()} range={-60.0..=60.0} label="tilt"/>
<Slider bind={speed.bind()} range={0.0..=4.0} label="speed"/>
<Checkbox bind={paused.bind()} label="pause"/>
</View>
</View>
}
}