Compare commits

...
6 Commits
Author SHA1 Message Date
surv 067c588704 refactor: add common shaders helpers + fix shadow export 2026-09-20 13:13:42 +02:00
surv a527a9f812 feat: improve colors when using distance estimate 2026-09-20 13:13:42 +02:00
surv 2110d038a1 feat: improve UI/UX 2026-09-20 13:13:42 +02:00
surv 95b85fdfae feat: Add complex multibrot fractal 2026-09-20 13:13:42 +02:00
surv e954524e99 feat: minor visual fix for PNG export 2026-09-20 13:13:42 +02:00
survandClaude Sonnet 5 b078a3f97a feat: add keyboard shortcuts for pan/zoom/iterations/AA
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:13:42 +02:00
16 changed files with 808 additions and 305 deletions
+18 -5
View File
@@ -66,6 +66,18 @@ pixel is a handful of `f32` complex multiplies.
`compute_reference`/`compute_set_reference`: iterate the chosen formula at
high precision on the CPU, emitting `Z_n` as `f32` pairs — that's the
reference orbit the GPU perturbs from.
- `src/shaders/*.wgsl` — none of these are standalone WGSL modules; WGSL has
no `#include`, so each is compiled by concatenating plain-text fragments
with `concat!`/`include_str!` at the `create_shader_module` call site (see
`renderer.rs`, `buddhabrot.rs`, and `tests/shader_valid.rs`, which must
concatenate the same pieces to validate what actually gets built).
`common.wgsl` (fullscreen-triangle vertex helper, `cmul`/`cpow`, `KIND_*`
constants) is prepended to every shader. `iterate_uniforms.wgsl` (the
perturbation-pipeline `Uniforms` struct + `palette()`) is additionally
prepended to `mandelbrot.wgsl` and `colorize.wgsl`, which share that layout.
Because there's no namespacing, a definition must live in exactly one file
among those concatenated together for a given shader — don't redefine a
`common.wgsl`/`iterate_uniforms.wgsl` symbol locally.
- `src/shaders/mandelbrot.wgsl` — the perturbation fragment shader.
`advance_delta(z, e)` is the per-kind delta step (`z` = reference point,
`e` = current delta); the caller adds `step_add` (= `dc`) afterward — this
@@ -75,8 +87,8 @@ pixel is a handful of `f32` complex multiplies.
reference data since the orbit point alone wouldn't be enough to recover an
exact delta). `fprime(z)` is the derivative used for distance-estimation
(DE) shading; exact for holomorphic kinds, an approximation (`~2Z`) for the
abs-based ones. A `KIND_*` constant here must match the matching
`FractalKind` variant's discriminant exactly.
abs-based ones. A `KIND_*` constant (from `common.wgsl`) must match the
matching `FractalKind` variant's discriminant exactly.
- `src/fractal/renderer.rs` — `FractalRenderer` (wgpu pipelines, uniform +
storage buffers, bind groups), `Uniforms` (repr(C) layout that must match
the WGSL `Uniforms` struct field-for-field, including padding), and
@@ -105,9 +117,10 @@ pixel is a handful of `f32` complex multiplies.
### Adding a new `FractalKind`
Touches, in order: `reference.rs` (enum variant + CPU iteration formula, and a
test comparing against a naive `f64` iteration), `mandelbrot.wgsl` (matching
`KIND_*` const + `advance_delta`/`fprime` arms), `buddhabrot.wgsl` (matching
arm in `advance()`, if the kind makes sense as a Buddhabrot), `renderer.rs`
test comparing against a naive `f64` iteration), `common.wgsl` (matching
`KIND_*` const), `mandelbrot.wgsl` (matching `advance_delta`/`fprime` arms),
`buddhabrot.wgsl` (matching arm in `advance()`, if the kind makes sense as a
Buddhabrot), `renderer.rs`
`Uniforms` (only if the kind needs a new per-kind constant, e.g. Phoenix's
`phoenix_p`), `share.rs` (encode/decode string tag), `app.rs` (`KINDS` label,
`JULIA_PRESETS`/`SET_PRESETS` slot, `default_view_for` entry, and optionally a
+219 -39
View File
@@ -43,6 +43,7 @@ const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"];
pub enum FractalMode {
Mandelbrot,
Julia,
Buddhabrot,
}
/// Selectable fractal formulas, with UI labels.
@@ -56,6 +57,7 @@ const KINDS: &[(FractalKind, &str)] = &[
(FractalKind::Buffalo, "Buffalo"),
(FractalKind::Phoenix, "Phoenix"),
(FractalKind::Lambda, "Lambda"),
(FractalKind::ComplexMultibrot, "Complex Multibrot"),
];
/// UI label for a fractal kind.
@@ -69,8 +71,8 @@ fn kind_label(kind: FractalKind) -> &'static str {
/// The iteration formula for a kind, in human-readable notation (mirrors the
/// doc comments on `FractalKind`'s variants). `power` is only used by
/// Multibrot.
fn kind_formula(kind: FractalKind, power: u32) -> String {
/// Multibrot; `complex_power` only by Complex Multibrot.
fn kind_formula(kind: FractalKind, power: u32, complex_power: (f64, f64)) -> String {
match kind {
FractalKind::Mandelbrot => "z = z² + c".to_string(),
FractalKind::BurningShip => "z = (|Re(z)| + i|Im(z)|)² + c".to_string(),
@@ -81,13 +83,16 @@ fn kind_formula(kind: FractalKind, power: u32) -> String {
FractalKind::Buffalo => "z = |Re(z²)| − i|Im(z²)| + c".to_string(),
FractalKind::Phoenix => "z = z² + c + p·z_prev".to_string(),
FractalKind::Lambda => "z = λ·z(1 − z)".to_string(),
FractalKind::ComplexMultibrot => {
format!("z = z^({:.3}{:+.3}i) + c", complex_power.0, complex_power.1)
}
}
}
type JuliaPreset = (&'static str, f64, f64, u32, Option<(f64, f64)>);
/// Nice-looking Julia constants offered as presets.
const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [
const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::ComplexMultibrot as usize + 1] = [
&[
("dendrite", -0.8, 0.156, 400, None),
("rabbit", -0.123, 0.745, 400, None),
@@ -106,6 +111,7 @@ const JULIA_PRESETS: [&[JuliaPreset]; FractalKind::Lambda as usize + 1] = [
("archipelago 2", -0.556, 0.253, 500, Some((-0.415, -0.267))),
],
&[],
&[],
];
type SetPreset = (
@@ -120,7 +126,7 @@ type SetPreset = (
/// Curated beautiful locations offered as one-click presets.
/// Each is `(name, center_re, center_im, half_height, iterations)`; the centers
/// are decimals parsed at full precision so deep places stay sharp.
const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [
const SET_PRESETS: [&[SetPreset]; FractalKind::ComplexMultibrot as usize + 1] = [
&[
(
"Seahorse Valley",
@@ -171,6 +177,7 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [
Some((-0.9, -0.49)),
)],
&[],
&[],
];
/// Parameters a reference orbit was (or will be) computed for. Used to decide
@@ -186,6 +193,7 @@ struct RequestKey {
iter: u32,
kind: FractalKind,
power: u32,
complex_power: (f64, f64),
}
/// Shared state for an in-progress PNG export. The worker (a background thread
@@ -273,6 +281,8 @@ pub struct FractalApp {
kind: FractalKind,
/// Exponent for the Multibrot kind.
power: u32,
/// Complex exponent for the Complex Multibrot kind (`z^power + c`).
complex_power: (f64, f64),
julia_c: (f64, f64),
/// Distortion constant `p` for the Phoenix kind (`z^2 + c + p·z_{n-1}`).
phoenix_p: (f64, f64),
@@ -298,10 +308,6 @@ pub struct FractalApp {
/// List of enabled lights in the world
lights: Vec<Light>,
/// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of
/// the ordinary escape-time set. Plain f32 view — no deep zoom, no
/// perturbation/reference-orbit machinery (see `fractal::buddhabrot`).
buddhabrot: bool,
/// Nested escape-iteration caps for the R/G/B histogram channels
/// (Nebulabrot coloring); kept ordered r <= g <= b by the UI.
buddha_r_cap: u32,
@@ -427,6 +433,8 @@ impl FractalApp {
}
}
cc.egui_ctx.set_zoom_factor(1.1);
// Debug/testing hooks, driven by CLI flags.
#[cfg(not(target_arch = "wasm32"))]
app.apply_cli(Cli::parse());
@@ -452,6 +460,7 @@ impl FractalApp {
mode: FractalMode::Mandelbrot,
kind: FractalKind::Mandelbrot,
power: 3,
complex_power: (2.0, 0.5),
julia_c: (-0.8, 0.156),
phoenix_p: (-0.5, 0.0),
lambda_l: (-0.5, 0.0),
@@ -465,7 +474,6 @@ impl FractalApp {
de_coloring: false,
shadow: false,
lights: vec![Light::default()],
buddhabrot: false,
buddha_r_cap: 50,
buddha_g_cap: 500,
buddha_b_cap: 2000,
@@ -513,6 +521,15 @@ impl FractalApp {
if let Some(p) = cli.power {
self.power = p.clamp(2, 8);
}
if let Some(cp) = &cli.complex_power {
let p: Vec<&str> = cp.split(',').collect();
if let (Some(Ok(re)), Some(Ok(im))) = (
p.first().map(|s| s.trim().parse::<f64>()),
p.get(1).map(|s| s.trim().parse::<f64>()),
) {
self.complex_power = (re, im);
}
}
self.view = Self::default_view_for(self.mode, self.kind);
}
if let Some(jc) = cli.julia {
@@ -538,7 +555,7 @@ impl FractalApp {
self.de_coloring = true;
}
if cli.buddhabrot {
self.buddhabrot = true;
self.mode = FractalMode::Buddhabrot;
}
if let Some(p) = cli.buddha_palette {
self.buddha_palette = p.min(BUDDHA_PALETTE_NAMES.len() as u32 - 1);
@@ -618,6 +635,7 @@ impl FractalApp {
julia_c: self.julia_c,
phoenix_p: self.phoenix_p,
lambda_l: self.lambda_l,
complex_power: self.complex_power,
color_scale: self.color_scale,
color_offset: self.color_offset,
palette: self.palette,
@@ -637,6 +655,7 @@ impl FractalApp {
self.julia_c = s.julia_c;
self.phoenix_p = s.phoenix_p;
self.lambda_l = s.lambda_l;
self.complex_power = s.complex_power;
self.color_scale = s.color_scale;
self.color_offset = s.color_offset;
self.palette = (s.palette as usize).min(PALETTE_NAMES.len() - 1) as u32;
@@ -688,6 +707,7 @@ impl FractalApp {
FractalKind::Buffalo => (-0.5, -0.5, 1.5),
FractalKind::Phoenix => (0.0, 0.0, 1.6),
FractalKind::Lambda => (0.0, 0.0, 1.6),
FractalKind::ComplexMultibrot => (0.0, 0.0, 1.5),
};
ViewState::with_center(big_from_f64(cr, 53), big_from_f64(ci, 53), hh)
}
@@ -704,6 +724,7 @@ impl FractalApp {
iter: self.max_iterations,
kind: self.kind,
power: self.power,
complex_power: self.complex_power,
}
}
@@ -729,6 +750,7 @@ impl FractalApp {
|| key.iter != self.max_iterations
|| key.kind != self.kind
|| key.power != self.power
|| key.complex_power != self.complex_power
{
return true;
}
@@ -769,6 +791,13 @@ impl FractalApp {
&self.reference
}
/// The configured shadow-style lights, for headless export's `ExportRender`
/// (which has no `FractalCallback` to source them from).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn lights(&self) -> &[Light] {
&self.lights
}
/// Recompute the reference orbit when needed. Native: dispatch to a worker
/// thread and pick up completed results. Web: compute inline.
fn ensure_reference(&mut self) {
@@ -797,6 +826,7 @@ impl FractalApp {
power: key.power,
phoenix_p: key.phoenix_p,
lambda_l: key.lambda_l,
complex_power: key.complex_power,
});
self.pending = true;
}
@@ -816,6 +846,7 @@ impl FractalApp {
key.power,
key.phoenix_p,
key.lambda_l,
key.complex_power,
)
} else {
compute_set_reference(
@@ -827,6 +858,7 @@ impl FractalApp {
key.power,
key.phoenix_p,
key.lambda_l,
key.complex_power,
)
};
self.apply_reference(
@@ -881,6 +913,7 @@ impl FractalApp {
key.power,
key.phoenix_p,
key.lambda_l,
key.complex_power,
)
} else {
compute_set_reference(
@@ -892,6 +925,7 @@ impl FractalApp {
key.power,
key.phoenix_p,
key.lambda_l,
key.complex_power,
)
};
self.apply_reference(
@@ -921,6 +955,7 @@ impl FractalApp {
dc_offset: self.dc_offset(),
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32],
de_coloring: (self.de_coloring | self.shadow) as u32,
shadow: self.shadow as u32,
_pad: [0; _],
@@ -941,6 +976,7 @@ impl FractalApp {
aspect: aspect as f32,
phoenix_p: [self.phoenix_p.0 as f32, self.phoenix_p.1 as f32],
lambda_l: [self.lambda_l.0 as f32, self.lambda_l.1 as f32],
complex_power: [self.complex_power.0 as f32, self.complex_power.1 as f32],
bailout_sq: BAILOUT_SQ,
kind: self.kind as u32,
power: self.power,
@@ -954,7 +990,7 @@ impl FractalApp {
height: 0, // set by the callback from size_px
total_samples: 0.0, // tracked by the renderer across frames
palette: self.buddha_palette,
_pad: [0; 3],
_pad0: 0,
}
}
@@ -965,7 +1001,7 @@ impl FractalApp {
if self.export.is_some() {
return; // one export at a time
}
if self.buddhabrot {
if self.mode == FractalMode::Buddhabrot {
self.status = Some("PNG export isn't available in Buddhabrot mode yet".into());
return;
}
@@ -995,6 +1031,7 @@ impl FractalApp {
renderer.export_handles()
};
let reference = Arc::clone(&self.reference);
let lights = self.lights.clone();
let shared = Arc::new(Mutex::new(ExportShared {
fraction: 0.0,
@@ -1021,6 +1058,7 @@ impl FractalApp {
h,
uniforms,
reference.as_slice(),
&lights,
);
let sh = Arc::clone(&shared);
let png =
@@ -1050,6 +1088,7 @@ impl FractalApp {
h,
uniforms,
reference.as_slice(),
&lights,
);
// Render tile by tile, awaiting each submission so the browser
@@ -1215,22 +1254,22 @@ impl FractalApp {
.strong()
.heading(),
);
let mode_label = if self.buddhabrot {
"Buddhabrot mode — orbit density (random c, z₀ = 0)"
} else {
match self.mode {
FractalMode::Mandelbrot => {
"Mandelbrot mode — parameter space (c varies per pixel, z₀ = 0)"
}
FractalMode::Julia => {
"Julia mode — dynamical plane for a fixed c (z₀ varies per pixel)"
}
let mode_label = match self.mode {
FractalMode::Mandelbrot => {
"Mandelbrot mode — parameter space (c varies per pixel, z₀ = 0)"
}
FractalMode::Julia => {
"Julia mode — dynamical plane for a fixed c (z₀ varies per pixel)"
}
FractalMode::Buddhabrot => "Buddhabrot mode — orbit density (random c, z₀ = 0)",
};
ui.label(mode_label);
ui.separator();
ui.label(format!("formula: {}", kind_formula(self.kind, self.power)));
ui.label(format!(
"formula: {}",
kind_formula(self.kind, self.power, self.complex_power)
));
if self.mode == FractalMode::Julia {
ui.label(format!("c = {:.6} {:+.6}i", self.julia_c.0, self.julia_c.1));
}
@@ -1246,6 +1285,12 @@ impl FractalApp {
self.lambda_l.0, self.lambda_l.1
));
}
if self.kind == FractalKind::ComplexMultibrot {
ui.label(format!(
"power = {:.6} {:+.6}i",
self.complex_power.0, self.complex_power.1
));
}
ui.separator();
ui.label(self.kind.description());
@@ -1253,8 +1298,8 @@ impl FractalApp {
self.info_open = open;
}
/// Help window: what the app does, plus a reference for mouse/touch
/// controls (there are no in-app keyboard shortcuts today).
/// Help window: what the app does, plus a reference for mouse/touch and
/// keyboard controls.
fn help_window(&mut self, ctx: &egui::Context) {
let mut open = self.help_open;
egui::Window::new("Help")
@@ -1270,7 +1315,8 @@ impl FractalApp {
ui.label(
"A deep-zoom fractal explorer. It renders the Mandelbrot set \
and several related fractals (Burning Ship, Tricorn, \
Multibrot, Celtic, Perpendicular, Buffalo, Phoenix, Lambda).",
Multibrot, Complex Multibrot, Celtic, Perpendicular, Buffalo, \
Phoenix, Lambda).",
);
ui.add_space(4.0);
ui.label(
@@ -1305,6 +1351,32 @@ impl FractalApp {
ui.separator();
ui.heading("Keyboard");
egui::Grid::new("help_keyboard_grid")
.num_columns(2)
.spacing([12.0, 6.0])
.show(ui, |ui| {
ui.label("Arrow keys");
ui.label("Pan the view");
ui.end_row();
ui.label("Z / S");
ui.label("Zoom in / out toward the center");
ui.end_row();
ui.label("+ / -");
ui.label("Increase / decrease iterations");
ui.end_row();
ui.label("R");
ui.label("Reset to the default view");
ui.end_row();
ui.label("H");
ui.label("Toggle this Help window");
ui.end_row();
ui.label("I");
ui.label("Toggle the Info window");
ui.end_row();
ui.label("A");
ui.label("Toggle antialiasing (2×2)");
ui.end_row();
});
ui.separator();
ui.heading("Tips");
@@ -1439,6 +1511,7 @@ impl FractalApp {
fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer");
ui.separator();
ui.add_space(4.);
// Fractal formula. Switching kinds jumps to a sensible default view,
// since interesting regions differ between fractals.
@@ -1485,20 +1558,42 @@ impl FractalApp {
ui.label("i");
});
}
if self.kind == FractalKind::ComplexMultibrot {
ui.horizontal(|ui| {
ui.label("power =");
ui.add(
egui::DragValue::new(&mut self.complex_power.0)
.speed(0.01)
.range(-8.0..=8.0),
);
ui.add(
egui::DragValue::new(&mut self.complex_power.1)
.speed(0.01)
.range(-8.0..=8.0),
);
ui.label("i");
});
}
if self.kind != prev_kind {
self.view = Self::default_view_for(self.mode, self.kind);
}
ui.checkbox(&mut self.buddhabrot, "Buddhabrot")
.on_hover_text(
"Monte-Carlo density of escaping orbits instead of the ordinary \
ui.horizontal(|ui| {
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
ui.radio_value(&mut self.mode, FractalMode::Buddhabrot, "Buddhabrot")
.on_hover_text(
"Monte-Carlo density of escaping orbits instead of the ordinary \
escape-time set. Plain f32 view (no deep zoom); the image \
progressively sharpens while the view stays still.",
);
);
});
if self.buddhabrot {
if self.mode == FractalMode::Buddhabrot {
self.buddhabrot_ui(ui);
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
if ui.button("Reset view").clicked() {
self.view = Self::default_view_for(self.mode, self.kind);
}
@@ -1507,11 +1602,6 @@ impl FractalApp {
return;
}
ui.horizontal(|ui| {
ui.radio_value(&mut self.mode, FractalMode::Mandelbrot, "Set");
ui.radio_value(&mut self.mode, FractalMode::Julia, "Julia");
});
if self.mode == FractalMode::Julia && self.kind != FractalKind::Lambda {
ui.horizontal(|ui| {
ui.label("c =");
@@ -1529,6 +1619,7 @@ impl FractalApp {
});
if !JULIA_PRESETS[self.kind as usize].is_empty() {
ui.label("places:");
ui.horizontal_wrapped(|ui| {
for &(name, re, im, iterations, phoenix) in JULIA_PRESETS[self.kind as usize] {
if ui.small_button(name).clicked() {
@@ -1559,7 +1650,9 @@ impl FractalApp {
});
}
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
ui.checkbox(&mut self.auto_iterations, "Auto iterations")
.on_hover_text("Scale the iteration count with zoom depth so deep zooms stay sharp.");
if self.auto_iterations {
@@ -1613,7 +1706,9 @@ impl FractalApp {
});
});
}
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
if !self.shadow {
@@ -1704,7 +1799,9 @@ impl FractalApp {
}
});
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
// Editable center coordinates. Shown at full precision; parsed
// losslessly on commit (Enter or focus loss). While a field is focused
// we leave the user's text alone; otherwise we refresh it from the live
@@ -1780,7 +1877,9 @@ impl FractalApp {
ui.colored_label(egui::Color32::LIGHT_YELLOW, "computing reference…");
}
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
let exporting = self.export.is_some();
ui.horizontal(|ui| {
if ui.button("Copy link").clicked() {
@@ -1800,10 +1899,11 @@ impl FractalApp {
ui.add(
egui::DragValue::new(&mut self.export_scale)
.range(1.0..=16.0)
.speed(0.5),
.speed(0.25)
.custom_formatter(|x, _| format!("x{:.1}", x)),
);
ui.label(format!(
"→ {}×{}",
"= {}×{}",
(self.last_size_px.x * self.export_scale) as u32,
(self.last_size_px.y * self.export_scale) as u32,
));
@@ -1822,7 +1922,9 @@ impl FractalApp {
ui.small(status);
}
ui.add_space(4.);
ui.separator();
ui.add_space(4.);
if ui.button("Reset view").clicked() {
self.view = Self::default_view_for(self.mode, self.kind);
}
@@ -1932,7 +2034,85 @@ impl FractalApp {
ui.ctx().request_repaint();
}
if self.buddhabrot {
// Keyboard: arrows pan, z/s zoom in/out, +/- adjust iterations, R
// resets the view, H/I toggle the Help/Info windows. Skipped while a
// text field (e.g. the center/zoom edit boxes) has focus.
if !ui.ctx().egui_wants_keyboard_input() {
let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1);
let (left, right, up, down, zoom_in, zoom_out) = ui.input(|i| {
(
i.key_down(egui::Key::ArrowLeft),
i.key_down(egui::Key::ArrowRight),
i.key_down(egui::Key::ArrowUp),
i.key_down(egui::Key::ArrowDown),
i.key_down(egui::Key::Z),
i.key_down(egui::Key::S),
)
});
// Pixels/sec pan speed — matches a brisk mouse drag regardless of
// frame rate. See `pan_pixels`'s screen-space (+x right, +y down)
// convention: Right/Down pan the *camera* right/down, which is
// the opposite delta sign from a drag that would show the same
// content (a drag grabs the canvas; these keys move the camera).
const PAN_SPEED_PX: f64 = 700.0;
let mut dx = 0.0;
let mut dy = 0.0;
if left {
dx += PAN_SPEED_PX * dt;
}
if right {
dx -= PAN_SPEED_PX * dt;
}
if down {
dy -= PAN_SPEED_PX * dt;
}
if up {
dy += PAN_SPEED_PX * dt;
}
if dx != 0.0 || dy != 0.0 {
self.view.pan_pixels(dx, dy, height_px);
interacted = true;
}
// e-folds/sec, same scale as the auto-zoom animation.
const ZOOM_SPEED: f64 = 1.0;
if zoom_in != zoom_out {
let rate = if zoom_in { ZOOM_SPEED } else { -ZOOM_SPEED };
let factor = (-rate * dt).exp();
self.view.zoom_at_pixel(0.0, 0.0, height_px, factor);
interacted = true;
}
if left || right || up || down || zoom_in || zoom_out {
ui.ctx().request_repaint();
}
if ui.input(|i| i.key_pressed(egui::Key::R)) {
self.view = Self::default_view_for(self.mode, self.kind);
interacted = true;
}
if ui.input(|i| i.key_pressed(egui::Key::H)) {
self.help_open = !self.help_open;
}
if ui.input(|i| i.key_pressed(egui::Key::I)) {
self.info_open = !self.info_open;
}
if ui.input(|i| i.key_pressed(egui::Key::A)) {
self.antialias = !self.antialias;
}
if ui.input(|i| i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals)) {
self.auto_iterations = false;
self.max_iterations = ((self.max_iterations as f64 * 1.25).round() as u32)
.clamp(32, MAX_REF_POINTS as u32 - 1);
}
if ui.input(|i| i.key_pressed(egui::Key::Minus)) {
self.auto_iterations = false;
self.max_iterations = ((self.max_iterations as f64 / 1.25).round() as u32)
.clamp(32, MAX_REF_POINTS as u32 - 1);
}
}
if self.mode == FractalMode::Buddhabrot {
// No reference orbit / perturbation machinery: iterate directly in
// f32 from the live view. Progressive accumulation means this
// needs its own continuous repaint, separate from the escape-time
+7
View File
@@ -17,6 +17,10 @@ pub struct Cli {
#[arg(long)]
pub power: Option<u32>,
/// Complex exponent for the Complex Multibrot kind (z -> z^power + c).
#[arg(long, value_name = "RE,IM")]
pub complex_power: Option<String>,
/// Start in Julia mode with this seed constant.
#[arg(long, value_name = "RE,IM")]
pub julia: Option<String>,
@@ -79,6 +83,8 @@ pub enum KindArg {
Buffalo,
Phoenix,
Lambda,
#[value(alias = "cmulti")]
ComplexMultibrot,
}
impl From<KindArg> for FractalKind {
@@ -93,6 +99,7 @@ impl From<KindArg> for FractalKind {
KindArg::Buffalo => FractalKind::Buffalo,
KindArg::Phoenix => FractalKind::Phoenix,
KindArg::Lambda => FractalKind::Lambda,
KindArg::ComplexMultibrot => FractalKind::ComplexMultibrot,
}
}
}
+14 -2
View File
@@ -46,7 +46,11 @@ pub struct BuddhabrotUniforms {
/// (yellow core, blue halo), 2 = grayscale. Display-only, like `exposure`
/// — excluded from `ContentKey` so changing it doesn't reset accumulation.
pub palette: u32,
pub _pad: [u32; 3],
/// Padding so `complex_power` (a vec2, 8-byte aligned in the shader)
/// starts on an 8-byte boundary.
pub _pad0: u32,
/// Complex exponent for the Complex Multibrot kind; ignored by other kinds.
pub complex_power: [f32; 2],
}
/// The subset of `BuddhabrotUniforms` that determines the *content* of the
@@ -62,6 +66,7 @@ struct ContentKey {
bailout_sq: f32,
kind: u32,
power: u32,
complex_power: [f32; 2],
r_cap: u32,
g_cap: u32,
b_cap: u32,
@@ -78,6 +83,7 @@ impl From<&BuddhabrotUniforms> for ContentKey {
bailout_sq: u.bailout_sq,
kind: u.kind,
power: u.power,
complex_power: u.complex_power,
r_cap: u.r_cap,
g_cap: u.g_cap,
b_cap: u.b_cap,
@@ -114,7 +120,13 @@ impl BuddhabrotRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("buddhabrot"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/buddhabrot.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/buddhabrot.wgsl"),
)
.into(),
),
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
+115 -3
View File
@@ -35,6 +35,9 @@ pub enum FractalKind {
Phoenix = 7,
/// `z -> lambda·z(1 - z)` (logistic map).
Lambda = 8,
/// `z -> z^power + c`, where `power` is a complex constant (the
/// `complex_power` argument), via the principal branch `z^p = exp(p·ln z)`.
ComplexMultibrot = 9,
}
impl FractalKind {
@@ -53,6 +56,7 @@ impl FractalKind {
FractalKind::Buffalo => "",
FractalKind::Phoenix => "",
FractalKind::Lambda => "",
FractalKind::ComplexMultibrot => "Like Multibrot, but the exponent itself is a complex number instead of a plain integer, via z^p = exp(p·ln z).",
}
}
}
@@ -77,6 +81,7 @@ pub fn compute_reference(
power: u32,
phoenix_p: (f64, f64),
lambda_l: (f64, f64),
complex_power: (f64, f64),
) -> Vec<[f32; 2]> {
let cr = c_re.clone().with_precision(precision).value();
let ci = c_im.clone().with_precision(precision).value();
@@ -92,6 +97,9 @@ pub fn compute_reference(
// Lambda distortion constant `l` (a small fixed complex number).
let lr = big_from_f64(lambda_l.0, precision);
let li = big_from_f64(lambda_l.1, precision);
// Complex Multibrot exponent (a fixed complex number).
let cpow_re = big_from_f64(complex_power.0, precision);
let cpow_im = big_from_f64(complex_power.1, precision);
let mut points: Vec<[f32; 2]> = Vec::with_capacity(max_iter as usize + 1);
@@ -162,6 +170,10 @@ pub fn compute_reference(
let lzi = &lr * &zi + &li * &zr;
(&lzr * &re2 - &lzi * &im2, re2 * lzi + lzr * im2)
}
FractalKind::ComplexMultibrot => {
let (pr, pi) = complex_pow_complex(&zr, &zi, &cpow_re, &cpow_im, precision);
(pr + &cr, pi + &ci)
}
};
// Shift the previous iterate (only the Phoenix arm reads it).
@@ -198,6 +210,32 @@ fn complex_pow(zr: &Big, zi: &Big, power: u32, precision: usize) -> (Big, Big) {
(rr, ri)
}
/// `true` if `x` is (numerically) zero. The f64 check is exact for a true
/// zero; only matters here to special-case `ln(0)`.
fn is_big_zero(x: &Big) -> bool {
x.to_f64().value() == 0.0
}
/// `(zr + i zi)^(pr + i pi)` for a complex exponent, via the principal branch
/// `z^p = exp(p·ln z)` where `ln z = ln|z| + i·arg(z)`. Used by
/// `ComplexMultibrot`; must be kept in sync with the shader's `cpow`.
/// `z = 0` is special-cased to `0` (the formula's `ln(0)` would otherwise
/// panic; this is the correct limit for the `Re(p) > 0` region the UI
/// exposes).
fn complex_pow_complex(zr: &Big, zi: &Big, pr: &Big, pi: &Big, precision: usize) -> (Big, Big) {
if is_big_zero(zr) && is_big_zero(zi) {
return (big_zero(precision), big_zero(precision));
}
let r2 = &zr.sqr() + &zi.sqr();
let ln_r = r2.ln() >> 1; // 0.5 * ln(r2) = ln(sqrt(r2)); exact halving.
let theta = zi.atan2(zr);
let exp_re = (pr * &ln_r - pi * &theta).with_precision(precision).value();
let exp_im = (pr * &theta + pi * &ln_r).with_precision(precision).value();
let mag = exp_re.exp();
let (sin_a, cos_a) = exp_im.sin_cos();
(&mag * &cos_a, &mag * &sin_a)
}
/// Convenience: parameter-plane ("Mandelbrot-set") reference (`z0 = 0`,
/// `c = center`) for any `kind`.
#[allow(clippy::too_many_arguments)]
@@ -210,10 +248,21 @@ pub fn compute_set_reference(
power: u32,
phoenix_p: (f64, f64),
lambda_l: (f64, f64),
complex_power: (f64, f64),
) -> Vec<[f32; 2]> {
let zero = big_zero(precision);
compute_reference(
&zero, &zero, center_re, center_im, max_iter, precision, kind, power, phoenix_p, lambda_l,
&zero,
&zero,
center_re,
center_im,
max_iter,
precision,
kind,
power,
phoenix_p,
lambda_l,
complex_power,
)
}
@@ -236,6 +285,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
// Independent naive f64 orbit.
@@ -275,6 +325,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
assert_eq!(points.len(), 501, "interior orbit should not escape");
}
@@ -293,6 +344,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (-1.75_f64, -0.03_f64);
@@ -322,6 +374,7 @@ mod tests {
3,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (0.3_f64, 0.2_f64);
@@ -357,6 +410,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (mut zr, mut zi) = (0.15_f64, -0.1_f64);
@@ -386,6 +440,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (-0.6_f64, 0.4_f64);
@@ -416,6 +471,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (-0.7_f64, -0.2_f64);
@@ -446,6 +502,7 @@ mod tests {
2,
(0.0, 0.0),
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (-1.2_f64, -0.35_f64);
@@ -468,8 +525,17 @@ mod tests {
let cr = Big::try_from(0.5667_f64).unwrap();
let ci = Big::try_from(0.0_f64).unwrap();
let p = (-0.5_f64, 0.0_f64);
let points =
compute_set_reference(&cr, &ci, 60, 200, FractalKind::Phoenix, 2, p, (0.0, 0.0));
let points = compute_set_reference(
&cr,
&ci,
60,
200,
FractalKind::Phoenix,
2,
p,
(0.0, 0.0),
(0.0, 0.0),
);
let (c_re, c_im) = (0.5667_f64, 0.0_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
@@ -489,4 +555,50 @@ mod tests {
zi = nzi;
}
}
/// Complex Multibrot (power 2.5 + 0.3i) reference matches a naive f64
/// iteration of `z^p = exp(p·ln z)`.
#[test]
fn complex_multibrot_reference_matches_naive_f64() {
let cr = Big::try_from(0.1_f64).unwrap();
let ci = Big::try_from(-0.2_f64).unwrap();
let power = (2.5_f64, 0.3_f64);
let points = compute_set_reference(
&cr,
&ci,
60,
200,
FractalKind::ComplexMultibrot,
2,
(0.0, 0.0),
(0.0, 0.0),
power,
);
// Naive f64 complex power via z^p = exp(p * ln z), ln z = ln|z| + i*arg(z).
fn naive_cpow(zr: f64, zi: f64, pr: f64, pi: f64) -> (f64, f64) {
if zr == 0.0 && zi == 0.0 {
return (0.0, 0.0);
}
let ln_r = 0.5 * (zr * zr + zi * zi).ln();
let theta = zi.atan2(zr);
let exp_re = pr * ln_r - pi * theta;
let exp_im = pr * theta + pi * ln_r;
let mag = exp_re.exp();
(mag * exp_im.cos(), mag * exp_im.sin())
}
let (c_re, c_im) = (0.1_f64, -0.2_f64);
let (mut zr, mut zi) = (0.0_f64, 0.0_f64);
for point in &points {
let tol = 1e-4 * (1.0 + zr.abs().max(zi.abs()));
assert!((point[0] as f64 - zr).abs() < tol, "re: {point:?} vs {zr}");
assert!((point[1] as f64 - zi).abs() < tol, "im: {point:?} vs {zi}");
let (pr, pi) = naive_cpow(zr, zi, power.0, power.1);
let nzr = pr + c_re;
let nzi = pi + c_im;
zr = nzr;
zi = nzi;
}
}
}
+62 -3
View File
@@ -37,6 +37,7 @@ fn geom_differs(a: &Uniforms, b: &Uniforms) -> bool {
|| a.aa_level != b.aa_level
|| a.kind != b.kind
|| a.power != b.power
|| a.complex_power != b.complex_power
|| a.dc_offset != b.dc_offset
|| a.phoenix_p != b.phoenix_p
|| a.de_coloring != b.de_coloring
@@ -87,6 +88,9 @@ pub struct Uniforms {
/// Distortion constant `l` for the Lambda map (`l·z(1 - z)`);
/// ignored by other kinds.
pub lambda_l: [f32; 2],
/// Complex exponent for the Complex Multibrot kind (`z^power + c`);
/// ignored by other kinds.
pub complex_power: [f32; 2],
/// 0 = escape-time coloring, 1 = distance-estimation shading.
pub de_coloring: u32,
// 0 = classic colors, 1 = shadows
@@ -160,7 +164,14 @@ impl FractalRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mandelbrot"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/mandelbrot.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/mandelbrot.wgsl"),
)
.into(),
),
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
@@ -207,6 +218,19 @@ impl FractalRenderer {
},
count: None,
},
// Only read by the export pipeline's shadow branch (`fs_color`
// with the custom-lights palette); the iterate pipeline
// (`fs_data`) ignores it, but both pipelines share this layout.
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
@@ -222,6 +246,10 @@ impl FractalRenderer {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: lights_buffer.as_entire_binding(),
},
],
});
@@ -288,7 +316,14 @@ impl FractalRenderer {
// Colourise pass: data texture + colour uniforms → colour texture.
let colorize_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("colorize"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/colorize.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/iterate_uniforms.wgsl"),
include_str!("../shaders/colorize.wgsl"),
)
.into(),
),
});
let colorize_bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -362,7 +397,13 @@ impl FractalRenderer {
// Blit pipeline: samples the cache texture onto egui's surface.
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("blit"),
source: wgpu::ShaderSource::Wgsl(include_str!("../shaders/blit.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(
concat!(
include_str!("../shaders/common.wgsl"),
include_str!("../shaders/blit.wgsl"),
)
.into(),
),
});
let blit_bind_group_layout =
@@ -588,6 +629,7 @@ impl ExportRender {
height: u32,
uniforms: Uniforms,
reference: &[[f32; 2]],
lights: &[Light],
) -> Self {
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export uniforms"),
@@ -608,6 +650,19 @@ impl ExportRender {
queue.write_buffer(&ref_buffer, 0, bytemuck::cast_slice(&reference[..count]));
}
// Only read by the shadow branch's custom-lights palette; harmless
// (zeroed) for every other coloring mode.
let lights_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("export lights"),
size: (MAX_LIGHT_COUNT * std::mem::size_of::<Light>()) as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut light_bytes = [0u8; size_of::<Light>() * MAX_LIGHT_COUNT];
let n = lights.len().min(MAX_LIGHT_COUNT);
light_bytes[..n * size_of::<Light>()].copy_from_slice(bytemuck::cast_slice(&lights[..n]));
queue.write_buffer(&lights_buffer, 0, &light_bytes);
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("export bind group"),
layout: bind_group_layout,
@@ -620,6 +675,10 @@ impl ExportRender {
binding: 1,
resource: ref_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: lights_buffer.as_entire_binding(),
},
],
});
+16 -2
View File
@@ -25,6 +25,8 @@ pub struct ShareState {
pub phoenix_p: (f64, f64),
/// Distortion constant for the Lambda kind (ignored by others).
pub lambda_l: (f64, f64),
/// Complex exponent for the Complex Multibrot kind (ignored by others).
pub complex_power: (f64, f64),
pub color_scale: f32,
pub color_offset: f32,
/// Palette index (`palette_id` in the shader).
@@ -49,6 +51,7 @@ impl ShareState {
FractalKind::Buffalo => "buffalo",
FractalKind::Phoenix => "phoenix",
FractalKind::Lambda => "lambda",
FractalKind::ComplexMultibrot => "cmulti",
}
));
s.push_str(&format!("&pw={}", self.power));
@@ -60,8 +63,12 @@ impl ShareState {
s.push_str(&format!("&px={}&py={}", self.phoenix_p.0, self.phoenix_p.1));
s.push_str(&format!("&lx={}&ly={}", self.lambda_l.0, self.lambda_l.1));
s.push_str(&format!(
"&cs={}&co={}&pal={}",
self.color_scale, self.color_offset, self.palette
"&cpr={}&cpi={}",
self.complex_power.0, self.complex_power.1
));
s.push_str(&format!(
"&cs={}&co={}&pal={}&spal={}",
self.color_scale, self.color_offset, self.palette, self.shadow_palette
));
s
}
@@ -89,6 +96,7 @@ impl ShareState {
"buffalo" => FractalKind::Buffalo,
"phoenix" => FractalKind::Phoenix,
"lambda" => FractalKind::Lambda,
"cmulti" => FractalKind::ComplexMultibrot,
_ => FractalKind::Mandelbrot,
})
.unwrap_or(FractalKind::Mandelbrot),
@@ -109,6 +117,10 @@ impl ShareState {
map.get("lx").and_then(|s| s.parse().ok()).unwrap_or(-0.5),
map.get("ly").and_then(|s| s.parse().ok()).unwrap_or(0.0),
),
complex_power: (
map.get("cpr").and_then(|s| s.parse().ok()).unwrap_or(2.0),
map.get("cpi").and_then(|s| s.parse().ok()).unwrap_or(0.0),
),
color_scale: map.get("cs").and_then(|s| s.parse().ok()).unwrap_or(0.02),
color_offset: map.get("co").and_then(|s| s.parse().ok()).unwrap_or(0.0),
palette: map.get("pal").and_then(|s| s.parse().ok()).unwrap_or(0),
@@ -134,6 +146,7 @@ mod tests {
julia_c: (-0.123, 0.745),
phoenix_p: (-0.5, 0.1),
lambda_l: (-0.5, 0.0),
complex_power: (2.5, 0.3),
color_scale: 0.02,
color_offset: 0.25,
palette: 3,
@@ -149,6 +162,7 @@ mod tests {
assert_eq!(d.iterations, s.iterations);
assert_eq!(d.julia_c, s.julia_c);
assert_eq!(d.phoenix_p, s.phoenix_p);
assert_eq!(d.complex_power, s.complex_power);
assert_eq!(d.palette, s.palette);
assert_eq!(d.shadow_palette, s.shadow_palette);
}
+1
View File
@@ -48,6 +48,7 @@ pub fn run(cli: Cli) -> Result<(), String> {
height,
uniforms,
app.reference_points(),
app.lights(),
);
eprintln!("rendering {width}×{height}…");
+1 -6
View File
@@ -13,12 +13,7 @@ struct VsOut {
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let p = verts[idx];
let p = fullscreen_triangle_pos(idx);
var out: VsOut;
out.pos = vec4<f32>(p, 0.0, 1.0);
// Map NDC to texture UV. v is flipped so the cache's top row (rendered at
+11 -27
View File
@@ -47,30 +47,21 @@ struct Uniforms {
// Tonemap colour style: 0 = classic (R/G/B = raw caps), 1 = nebula
// (yellow core, blue halo), 2 = grayscale.
palette: u32,
// Padding to a 16-byte multiple. NOT vec3<u32> — that type aligns to 16
// bytes in WGSL (unlike Rust's `[u32; 3]`, which aligns to 4), which
// silently added 32 bytes instead of 16 and mismatched the Rust struct's
// size (a wgpu validation error at dispatch time: "size 96 where the
// shader expects 112").
// Padding so `complex_power` (a vec2, 8-byte aligned) starts on an
// 8-byte boundary. NOT vec3<u32> — that type aligns to 16 bytes in WGSL
// (unlike Rust's `[u32; 3]`, which aligns to 4), which silently added 32
// bytes instead of 16 and mismatched the Rust struct's size (a wgpu
// validation error at dispatch time: "size 96 where the shader expects
// 112").
_pad0: u32,
_pad1: u32,
_pad2: u32,
// Complex exponent for the Complex Multibrot kind; unused by other kinds.
complex_power: vec2<f32>,
};
const PALETTE_NEBULA: u32 = 0u;
const PALETTE_YELLOW: u32 = 1u;
const PALETTE_GRAYSCALE: u32 = 2u;
const KIND_MANDELBROT: u32 = 0u;
const KIND_BURNING_SHIP: u32 = 1u;
const KIND_TRICORN: u32 = 2u;
const KIND_MULTIBROT: u32 = 3u;
const KIND_CELTIC: u32 = 4u;
const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u;
@group(0) @binding(0) var<uniform> u: Uniforms;
// Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B).
@group(0) @binding(1) var<storage, read_write> histogram: array<atomic<u32>>;
@@ -91,10 +82,6 @@ fn rand01(seed: u32) -> f32 {
return f32(hash_u32(seed)) * (1.0 / 4294967295.0);
}
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
fn complex_pow(z: vec2<f32>, p: u32) -> vec2<f32> {
var r = vec2<f32>(1.0, 0.0);
for (var i: u32 = 0u; i < p; i = i + 1u) {
@@ -126,6 +113,8 @@ fn advance(z: vec2<f32>, zp: vec2<f32>, c: vec2<f32>) -> vec2<f32> {
} else if u.kind == KIND_LAMBDA {
// l * z * (1 - z); c is unused (see file doc comment above).
return cmul(u.lambda_l, cmul(z, vec2<f32>(1.0 - z.x, -z.y)));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return cpow(z, u.complex_power) + c;
}
return vec2<f32>(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y) + c; // Mandelbrot
}
@@ -232,12 +221,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3<u32>) {
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return vec4<f32>(verts[idx], 0.0, 1.0);
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
}
@fragment
+10 -130
View File
@@ -10,147 +10,28 @@
// at the fragment's integer pixel coordinate (nearest — iteration data must not
// be linearly filtered across escape boundaries).
// Must match `Uniforms` in mandelbrot.wgsl / the Rust `Uniforms` struct.
struct Uniforms {
span: vec2<f32>,
max_iter: u32,
ref_len: u32,
color_offset: f32,
color_scale: f32,
bailout_sq: f32,
is_julia: u32,
palette_id: u32,
shadow_palette_id: u32,
aa_level: u32,
kind: u32,
power: u32,
dc_offset: vec2<f32>,
phoenix_p: vec2<f32>,
lambda_l: vec2<f32>,
de_coloring: u32,
shadow: u32,
};
struct Light {
azimuth: f32,
altitude: f32,
color: u32,
_pad: u32
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var data_tex: texture_2d<f32>;
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette
// in mandelbrot.wgsl.
fn palette(id: u32, t: f32) -> vec3<f32> {
if id == 4u {
return vec3<f32>(t, t, t); // grayscale
}
let a = vec3<f32>(0.5, 0.5, 0.5);
let b = vec3<f32>(0.5, 0.5, 0.5);
var c = vec3<f32>(1.0, 1.0, 1.0);
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
if id == 1u {
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
} else if id == 2u {
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
} else if id == 3u {
c = vec3<f32>(1.0, 1.0, 0.5);
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
}
return a + b * cos(6.28318530718 * (c * t + d));
}
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return vec4<f32>(verts[idx], 0.0, 1.0);
}
fn load(x: i32, y: i32) -> vec3<f32> {
let dist = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
return vec3<f32>(f32(x), f32(y), dist);
}
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
return vec3<f32>(max(0., dot(normal, normalize(light))));
}
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
let A = 0.15; // Shoulder strength
let B = 0.50; // Linear strength
let C = 0.10; // Linear angle
let D = 0.20; // Toe strength
let E = 0.02; // Toe numerator / shoarder angle/etc.
let F = 0.30; // Toe denominator
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
let exposure_bias = 2.0;
let curr = uncharted2tonemap(color * exposure_bias);
// Valeur blanche maximale de référence
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
return curr * white_scale;
}
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
return 1. / (1. + exp(-k * (color - c)));
}
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
let color_c = s(color, k, c);
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
return vec4<f32>(fullscreen_triangle_pos(idx), 0.0, 1.0);
}
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
if u.shadow != 0u {
if textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0).b != 0. {
let x = i32(pos.x);
let y = i32(pos.y);
if textureLoad(data_tex, vec2<i32>(x, y), 0).b != 0. {
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
} else {
let d = array<vec3<f32>, 3>(load(i32(pos.x), i32(pos.y)), load(i32(pos.x + 1), i32(pos.y)), load(i32(pos.x), i32(pos.y + 1)));
let normal = normalize(cross(d[1] - d[0], d[2] - d[0]));
var color: vec3<f32>;
if u.shadow_palette_id == 0u {
color = compute_light(normal,vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
color = filmic(color, 2.5);
color = contrast(color, 4., 0.67);
} else if u.shadow_palette_id == 1u {
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
color = filmic(color, 4.2);
} else {
color = vec3<f32>(0);
var light_count = 0;
for (var i = 0u ; i < 16; i++) {
let light_color = unpack4x8unorm(lights[i].color);
if any(light_color != vec4<f32>(0)) {
light_count += 1;
}
color += compute_light(normal, vec3<f32>(
cos(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
}
color = filmic(color, 1. + f32(light_count));
}
return vec4<f32>(color, 1.0);
let h0 = textureLoad(data_tex, vec2<i32>(x, y), 0).g;
let h1 = textureLoad(data_tex, vec2<i32>(x + 1, y), 0).g;
let h2 = textureLoad(data_tex, vec2<i32>(x, y + 1), 0).g;
let normal = normal_from_heights(h0, h1, h2);
return vec4<f32>(shadow_color(normal), 1.0);
}
} else {
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
@@ -158,8 +39,7 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let de = d.g;
let interior_frac = d.b;
let t = fract(ci * u.color_scale + u.color_offset);
var col = palette(u.palette_id, t) * de;
var col = classic_color(ci, de);
// Anti-alias the set boundary: fade toward black by the fraction of the
// pixel's sub-samples that landed in the interior.
col = col * (1.0 - interior_frac);
+51
View File
@@ -0,0 +1,51 @@
// Shared helpers, concatenated into every shader at build time via
// `concat!`/`include_str!` (see renderer.rs / buddhabrot.rs). Keep this file
// free of anything that differs between pipelines (e.g. a `Uniforms` struct —
// mandelbrot/colorize and buddhabrot each have their own shape) since every
// shader gets the whole thing spliced in.
// Fullscreen triangle vertex position: one triangle that covers the whole
// viewport (cheaper than a quad's two), shared by every full-screen vertex
// shader in this project.
fn fullscreen_triangle_pos(idx: u32) -> vec2<f32> {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return verts[idx];
}
// Complex multiply.
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
// z^p for a complex exponent p, via the principal branch z^p = exp(p * ln z),
// ln z = ln|z| + i*arg(z). z = 0 maps to 0 (the correct limit for the
// Re(p) > 0 region the UI exposes; ln(0) would otherwise be -inf).
fn cpow(z: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
let r2 = dot(z, z);
if r2 < 1e-30 {
return vec2<f32>(0.0, 0.0);
}
let ln_r = 0.5 * log(r2);
let theta = atan2(z.y, z.x);
let mag = exp(p.x * ln_r - p.y * theta);
let ang = p.x * theta + p.y * ln_r;
return mag * vec2<f32>(cos(ang), sin(ang));
}
// Iteration formula selector, shared by the perturbation (mandelbrot.wgsl)
// and direct (buddhabrot.wgsl) iteration paths. Must match `FractalKind` in
// reference.rs.
const KIND_MANDELBROT: u32 = 0u;
const KIND_BURNING_SHIP: u32 = 1u;
const KIND_TRICORN: u32 = 2u;
const KIND_MULTIBROT: u32 = 3u;
const KIND_CELTIC: u32 = 4u;
const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u;
const KIND_COMPLEX_MULTIBROT: u32 = 9u;
+161
View File
@@ -0,0 +1,161 @@
// Shared by mandelbrot.wgsl (writes the per-pixel data texture) and
// colorize.wgsl (reads it): the iteration pass and the colour remap pass
// must agree on both the uniform layout and the palette function.
// Must match the Rust `Uniforms` struct in renderer.rs field-for-field,
// including padding.
struct Uniforms {
span: vec2<f32>,
max_iter: u32,
ref_len: u32,
color_offset: f32,
color_scale: f32,
bailout_sq: f32,
is_julia: u32,
palette_id: u32,
shadow_palette_id: u32,
aa_level: u32,
// Iteration formula (see the KIND_* constants in common.wgsl).
kind: u32,
// Exponent for the Multibrot kind.
power: u32,
dc_offset: vec2<f32>,
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
phoenix_p: vec2<f32>,
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
// by other kinds.
lambda_l: vec2<f32>,
// Complex exponent for the Complex Multibrot kind (z^power + c); unused
// by other kinds.
complex_power: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32,
// 0 = classic colors, 1 = shadows
shadow: u32,
};
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
fn palette(id: u32, t: f32) -> vec3<f32> {
if id == 4u {
return vec3<f32>(t, t, t); // grayscale
}
let a = vec3<f32>(0.5, 0.5, 0.5);
let b = vec3<f32>(0.5, 0.5, 0.5);
var c = vec3<f32>(1.0, 1.0, 1.0);
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
if id == 1u {
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
} else if id == 2u {
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
} else if id == 3u {
c = vec3<f32>(1.0, 1.0, 0.5);
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
}
return a + b * cos(6.28318530718 * (c * t + d));
}
// Classic (non-shadow) escape colouring: palette lookup at the smoothed
// iteration count `ci`, darkened by the distance-estimate factor `de`
// (sqrt-compressed so the darkening falls off more gently near the
// boundary). Shared by the colourise pass's classic branch (colorize.wgsl,
// applied to an already-averaged data texel) and the PNG-export pass
// (mandelbrot.wgsl's `fs_color`, applied per sub-sample pre-AA) — the two
// places a fully escaped point is turned into a final pixel colour.
fn classic_color(ci: f32, de: f32) -> vec3<f32> {
let t = fract(ci * u.color_scale + u.color_offset);
return palette(u.palette_id, t) * sqrt(de);
}
// A single directional/point light, set by the UI's light list. `color`'s
// alpha channel doubles as intensity (see `shadow_color`'s use of
// `light_color.a`). Each shader that binds a `lights: array<Light, 16>`
// uniform (colorize.wgsl, mandelbrot.wgsl's export shadow path) uses this
// same layout.
struct Light {
azimuth: f32,
altitude: f32,
color: u32,
_pad: u32,
};
fn compute_light(normal: vec3<f32>, light: vec3<f32>) -> vec3<f32> {
return vec3<f32>(max(0., dot(normal, normalize(light))));
}
fn uncharted2tonemap(x: vec3<f32>) -> vec3<f32> {
let A = 0.15; // Shoulder strength
let B = 0.50; // Linear strength
let C = 0.10; // Linear angle
let D = 0.20; // Toe strength
let E = 0.02; // Toe numerator / shoarder angle/etc.
let F = 0.30; // Toe denominator
return ((x * (A * x + C * B) + D * E) / (x * (A * x + B) + D * F)) - E / F;
}
fn filmic(color: vec3<f32>, white_point: f32) -> vec3<f32> {
let exposure_bias = 2.0;
let curr = uncharted2tonemap(color * exposure_bias);
// Valeur blanche maximale de référence
let white_scale = vec3(1.0) / uncharted2tonemap(vec3(white_point));
return curr * white_scale;
}
fn s(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
return 1. / (1. + exp(-k * (color - c)));
}
fn contrast(color: vec3<f32>, k: f32, c: f32) -> vec3<f32> {
let color_c = s(color, k, c);
return (color_c - s(vec3<f32>(0), k, c)) / (s(vec3<f32>(1), k, c) - s(vec3<f32>(0), k, c));
}
// Surface normal from three height samples (`h0` at the pixel, `h1` one pixel
// to the right, `h2` one pixel down), treating DE as a height field. Only the
// differences matter, so callers don't need to pass pixel coordinates — a
// texture-backed caller (colorize.wgsl) and a live-sampled caller
// (mandelbrot.wgsl's export shadow path) can share this.
fn normal_from_heights(h0: f32, h1: f32, h2: f32) -> vec3<f32> {
let d0 = vec3<f32>(0.0, 0.0, h0);
let d1 = vec3<f32>(1.0, 0.0, h1);
let d2 = vec3<f32>(0.0, 1.0, h2);
return normalize(cross(d1 - d0, d2 - d0));
}
// Shade a DE-derived surface normal per `u.shadow_palette_id`: 0 = grayscale
// key light, 1 = red/blue two-tone, 2 = the user's custom `lights` list.
// Shared by the interactive shadow pass (colorize.wgsl) and the PNG-export
// shadow path (mandelbrot.wgsl's `fs_color`), which must render identically.
fn shadow_color(normal: vec3<f32>) -> vec3<f32> {
var color: vec3<f32>;
if u.shadow_palette_id == 0u {
color = compute_light(normal, vec3<f32>(.5, .5, .5)) + vec3<f32>(0.58, 0.85, 1.) * 0.2;
color = filmic(color, 2.5);
color = contrast(color, 4., 0.67);
} else if u.shadow_palette_id == 1u {
color = compute_light(normal, vec3<f32>(0., .5, .5)) * vec3<f32>(1., 0.5, 0.5) + compute_light(normal, vec3<f32>(0.5, 0., .5)) * vec3<f32>(0.5, 1., 1.);
color = filmic(color, 4.2);
} else {
color = vec3<f32>(0);
var light_count = 0;
for (var i = 0u; i < 16; i++) {
let light_color = unpack4x8unorm(lights[i].color);
if any(light_color != vec4<f32>(0)) {
light_count += 1;
}
color += compute_light(normal, vec3<f32>(
cos(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].azimuth) * cos(lights[i].altitude),
sin(lights[i].altitude))) * light_color.xyz * light_color.a;
}
color = filmic(color, 1. + f32(light_count));
}
return color;
}
+94 -84
View File
@@ -12,46 +12,11 @@
// the reference index to 0 and carry the full value as the new delta (valid
// because X_0 = 0).
struct Uniforms {
span: vec2<f32>,
max_iter: u32,
ref_len: u32,
color_offset: f32,
color_scale: f32,
bailout_sq: f32,
is_julia: u32,
palette_id: u32,
shadow_palette_id: u32,
aa_level: u32,
// Iteration formula (see the KIND_* constants below).
kind: u32,
// Exponent for the Multibrot kind.
power: u32,
dc_offset: vec2<f32>,
// Distortion constant p for the Phoenix map (z^2 + c + p*z_{n-1}); unused
// by other kinds. Placed by dc_offset so both vec2s stay 8-byte aligned.
phoenix_p: vec2<f32>,
// Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused
// by other kinds.
lambda_l: vec2<f32>,
// 0 = escape-time coloring, 1 = distance-estimation shading.
de_coloring: u32,
// 0 = classic colors, 1 = shadows
shadow: u32,
};
const KIND_MANDELBROT: u32 = 0u;
const KIND_BURNING_SHIP: u32 = 1u;
const KIND_TRICORN: u32 = 2u;
const KIND_MULTIBROT: u32 = 3u;
const KIND_CELTIC: u32 = 4u;
const KIND_PERPENDICULAR: u32 = 5u;
const KIND_BUFFALO: u32 = 6u;
const KIND_PHOENIX: u32 = 7u;
const KIND_LAMBDA: u32 = 8u;
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var<storage, read> ref_orbit: array<vec2<f32>>;
// Only read by `fs_color`'s shadow branch (custom-lights palette); the
// iteration pass (`fs_data`) never touches it.
@group(0) @binding(2) var<uniform> lights: array<Light, 16>;
struct VsOut {
@builtin(position) pos: vec4<f32>,
@@ -61,12 +26,7 @@ struct VsOut {
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let ndc = verts[idx];
let ndc = fullscreen_triangle_pos(idx);
var out: VsOut;
out.pos = vec4<f32>(ndc, 0.0, 1.0);
// Flip y so +imaginary points up the screen.
@@ -74,16 +34,17 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut {
return out;
}
// Complex multiply.
fn cmul(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x);
}
// Complex conjugate.
fn conj(a: vec2<f32>) -> vec2<f32> {
return vec2<f32>(a.x, -a.y);
}
// Complex division a / b.
fn cdiv(a: vec2<f32>, b: vec2<f32>) -> vec2<f32> {
let d = dot(b, b);
return vec2<f32>(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d;
}
// |c + d| - |c|, evaluated exactly (no catastrophic cancellation even when the
// sum crosses zero). This is what makes the Burning Ship delta correct through
// the sign flips that happen all along the axes, where the ship's detail lives.
@@ -123,6 +84,47 @@ fn multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: u32) -> vec2<f32> {
return acc;
}
// Number of terms kept in `complex_multibrot_delta`'s series. Truncation, not
// exactness: unlike `multibrot_delta` (a finite binomial sum for an integer
// power), a complex power has no finite expansion, so this converges rather
// than terminates. Fine as long as perturbation's usual invariant (|e| << |z|,
// kept true by rebasing) holds, since each extra term is O(w^k) smaller.
const COMPLEX_MULTIBROT_TERMS: u32 = 16u;
// Perturbation delta for z -> z^p with a complex p: (Z+e)^p - Z^p.
//
// When |e| << |Z| (the common case: it's the whole reason perturbation
// works), forming Z+e directly would round e away in f32, so instead expand
// = Z^p * ((1+w)^p - 1), w = e/Z, as a Taylor series in w: (1+w)^p - 1 =
// sum_{k=1}^N C(p,k) w^k, with the complex binomial coefficient built up
// incrementally: C(p,k) = C(p,k-1) * (p-(k-1)) / k. Unlike `multibrot_delta`
// (a finite binomial sum for an integer power), this only *converges* — and
// only for |w| < 1 — rather than terminating exactly.
//
// Right after a rebase (or near a reference point close to zero, where w is
// singular), e is *not* small relative to Z — that's normal perturbation
// dynamics, not a deep-zoom edge case — and the series above would diverge.
// But forming Z+e directly is numerically safe exactly there (e isn't many
// orders of magnitude smaller than Z), so fall back to a plain subtraction.
fn complex_multibrot_delta(z: vec2<f32>, e: vec2<f32>, p: vec2<f32>) -> vec2<f32> {
// |w|^2 = |e|^2 / |Z|^2; inf or nan (Z ~ 0, or both ~ 0) correctly fails
// the `< 0.25` test below and falls through to the direct branch.
let w2 = dot(e, e) / dot(z, z);
if w2 < 0.25 {
let w = cdiv(e, z);
var wk = vec2<f32>(1.0, 0.0); // w^0
var coef = vec2<f32>(1.0, 0.0); // C(p,0)
var acc = vec2<f32>(0.0, 0.0);
for (var k: u32 = 1u; k <= COMPLEX_MULTIBROT_TERMS; k = k + 1u) {
coef = cdiv(cmul(coef, p - vec2<f32>(f32(k - 1u), 0.0)), vec2<f32>(f32(k), 0.0));
wk = cmul(wk, w);
acc = acc + cmul(coef, wk);
}
return cmul(cpow(z, p), acc);
}
return cpow(z + e, p) - cpow(z, p);
}
// One perturbation step of the current fractal's delta: e -> f(Z+e) - f(Z),
// where `z` is the reference orbit value X_m. `step_add` (dc) is added by the
// caller. Must match `FractalKind` on the CPU side.
@@ -163,6 +165,8 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
// Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e).
let one_minus_2z_minus_e = vec2<f32>(1.0 - 2.0 * z.x - e.x, -2.0 * z.y - e.y);
return cmul(u.lambda_l, cmul(e, one_minus_2z_minus_e));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
return complex_multibrot_delta(z, e, u.complex_power);
}
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part)
}
@@ -183,30 +187,13 @@ fn fprime(z: vec2<f32>) -> vec2<f32> {
} else if u.kind == KIND_LAMBDA {
// Lambda: f'(z) = λ·(1-2z).
return cmul(u.lambda_l, vec2<f32>(1.0 - 2.0 * z.x, -2.0 * z.y));
} else if u.kind == KIND_COMPLEX_MULTIBROT {
// f'(z) = p * z^(p-1).
return cmul(u.complex_power, cpow(z, u.complex_power - vec2<f32>(1.0, 0.0)));
}
return 2.0 * z;
}
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
fn palette(id: u32, t: f32) -> vec3<f32> {
if id == 4u {
return vec3<f32>(t, t, t); // grayscale
}
let a = vec3<f32>(0.5, 0.5, 0.5);
let b = vec3<f32>(0.5, 0.5, 0.5);
var c = vec3<f32>(1.0, 1.0, 1.0);
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
if id == 1u {
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
} else if id == 2u {
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
} else if id == 3u {
c = vec3<f32>(1.0, 1.0, 0.5);
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
}
return a + b * cos(6.28318530718 * (c * t + d));
}
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
// interior of the set. Splitting iteration from coloring lets a colour change be
@@ -345,22 +332,15 @@ fn color_sample(s: Sample) -> vec3<f32> {
if !s.escaped {
return vec3<f32>(0.0, 0.0, 0.0);
}
let t = fract(s.ci * u.color_scale + u.color_offset);
return palette(u.palette_id, t) * s.de;
return classic_color(s.ci, s.de);
}
// Iteration pass: write per-pixel escape data (color-independent) so a colour
// change is remapped by the cheap colourise pass without re-iterating.
// R = ci (palette parameter), G = DE factor, B = interior fraction (for AA).
// AA is grid-supersampled here; the interior fraction lets the colourise pass
// anti-alias the set boundary (blend toward black) after the fact.
@fragment
fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
let base = in.centered * u.span + u.dc_offset;
let dx = dpdx(base);
let dy = dpdy(base);
let px = length(abs(dx) + abs(dy));
// Supersampled escape data at one point: average (ci, DE factor) over the
// AA grid's escaped sub-samples, plus the fraction that landed in the
// interior. Shared by `fs_data` (writes it straight to the data texture) and
// `fs_color`'s shadow branch (used both at the pixel and at its two
// neighbours, to build a DE height field without a texture round-trip).
fn aggregate_sample(base: vec2<f32>, dx: vec2<f32>, dy: vec2<f32>, px: f32) -> vec3<f32> {
let aa = max(u.aa_level, 1u);
let inv = 1.0 / f32(aa);
var ci_sum = 0.0;
@@ -382,7 +362,22 @@ fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
let ci_avg = select(0.0, ci_sum / f32(escaped_n), escaped_n > 0u);
let de_avg = select(1.0, de_sum / f32(escaped_n), escaped_n > 0u);
let interior_frac = 1.0 - f32(escaped_n) / total;
return vec4<f32>(ci_avg, de_avg, interior_frac, 1.0);
return vec3<f32>(ci_avg, de_avg, interior_frac);
}
// Iteration pass: write per-pixel escape data (color-independent) so a colour
// change is remapped by the cheap colourise pass without re-iterating.
// R = ci (palette parameter), G = DE factor, B = interior fraction (for AA).
// AA is grid-supersampled here; the interior fraction lets the colourise pass
// anti-alias the set boundary (blend toward black) after the fact.
@fragment
fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
let base = in.centered * u.span + u.dc_offset;
let dx = dpdx(base);
let dy = dpdy(base);
let px = length(abs(dx) + abs(dy));
return vec4<f32>(aggregate_sample(base, dx, dy, px), 1.0);
}
// Combined iterate + colour in a single pass, for PNG export (which never needs
@@ -395,6 +390,21 @@ fn fs_color(in: VsOut) -> @location(0) vec4<f32> {
let dy = dpdy(base);
let px = length(abs(dx) + abs(dy));
if u.shadow != 0u {
// No data texture to sample neighbours from (this pass never runs
// one), so build the same DE height field colorize.wgsl reads from
// the texture by aggregating live, at the pixel and its two
// neighbours a `dx`/`dy` step away.
let here = aggregate_sample(base, dx, dy, px);
if here.z != 0.0 {
return vec4<f32>(0.1, 0.1, 0.1, 1.0);
}
let right = aggregate_sample(base + dx, dx, dy, px);
let down = aggregate_sample(base + dy, dx, dy, px);
let normal = normal_from_heights(here.y, right.y, down.y);
return vec4<f32>(shadow_color(normal), 1.0);
}
let aa = max(u.aa_level, 1u);
let inv = 1.0 / f32(aa);
var acc = vec3<f32>(0.0, 0.0, 0.0);
+4
View File
@@ -26,6 +26,8 @@ pub struct RefRequest {
pub phoenix_p: (f64, f64),
/// Distortion constant for the Lambda map (ignored by other kinds).
pub lambda_l: (f64, f64),
/// Complex exponent for the Complex Multibrot kind (ignored by other kinds).
pub complex_power: (f64, f64),
}
pub struct RefResult {
@@ -107,6 +109,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.power,
req.phoenix_p,
req.lambda_l,
req.complex_power,
)
} else {
compute_set_reference(
@@ -118,6 +121,7 @@ fn compute(req: &RefRequest) -> Vec<[f32; 2]> {
req.power,
req.phoenix_p,
req.lambda_l,
req.complex_power,
)
}
}
+24 -4
View File
@@ -21,24 +21,44 @@ fn validate(name: &str, src: &str) {
fn mandelbrot_shader_is_valid() {
validate(
"mandelbrot.wgsl",
include_str!("../src/shaders/mandelbrot.wgsl"),
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/iterate_uniforms.wgsl"),
include_str!("../src/shaders/mandelbrot.wgsl"),
),
);
}
#[test]
fn colorize_shader_is_valid() {
validate("colorize.wgsl", include_str!("../src/shaders/colorize.wgsl"));
validate(
"colorize.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/iterate_uniforms.wgsl"),
include_str!("../src/shaders/colorize.wgsl"),
),
);
}
#[test]
fn blit_shader_is_valid() {
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl"));
validate(
"blit.wgsl",
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/blit.wgsl"),
),
);
}
#[test]
fn buddhabrot_shader_is_valid() {
validate(
"buddhabrot.wgsl",
include_str!("../src/shaders/buddhabrot.wgsl"),
concat!(
include_str!("../src/shaders/common.wgsl"),
include_str!("../src/shaders/buddhabrot.wgsl"),
),
);
}