diff --git a/CLAUDE.md b/CLAUDE.md index 2f7baf6..dd0ff73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/app.rs b/src/app.rs index 03a6dd4..b4636f9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -791,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) { @@ -1024,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, @@ -1050,6 +1058,7 @@ impl FractalApp { h, uniforms, reference.as_slice(), + &lights, ); let sh = Arc::clone(&shared); let png = @@ -1079,6 +1088,7 @@ impl FractalApp { h, uniforms, reference.as_slice(), + &lights, ); // Render tile by tile, awaiting each submission so the browser diff --git a/src/fractal/buddhabrot.rs b/src/fractal/buddhabrot.rs index ca3f5e9..4b3edc1 100644 --- a/src/fractal/buddhabrot.rs +++ b/src/fractal/buddhabrot.rs @@ -120,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 { diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 30b1232..91c1893 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -164,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 { @@ -211,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, + }, ], }); @@ -226,6 +246,10 @@ impl FractalRenderer { binding: 1, resource: ref_buffer.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 2, + resource: lights_buffer.as_entire_binding(), + }, ], }); @@ -292,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 { @@ -366,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 = @@ -592,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"), @@ -612,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::()) as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut light_bytes = [0u8; size_of::() * MAX_LIGHT_COUNT]; + let n = lights.len().min(MAX_LIGHT_COUNT); + light_bytes[..n * size_of::()].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, @@ -624,6 +675,10 @@ impl ExportRender { binding: 1, resource: ref_buffer.as_entire_binding(), }, + wgpu::BindGroupEntry { + binding: 2, + resource: lights_buffer.as_entire_binding(), + }, ], }); diff --git a/src/headless.rs b/src/headless.rs index 8e8f881..e839103 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -48,6 +48,7 @@ pub fn run(cli: Cli) -> Result<(), String> { height, uniforms, app.reference_points(), + app.lights(), ); eprintln!("rendering {width}×{height}…"); diff --git a/src/shaders/blit.wgsl b/src/shaders/blit.wgsl index 6288fc5..525bf4e 100644 --- a/src/shaders/blit.wgsl +++ b/src/shaders/blit.wgsl @@ -13,12 +13,7 @@ struct VsOut { @vertex fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { - var verts = array, 3>( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0), - ); - let p = verts[idx]; + let p = fullscreen_triangle_pos(idx); var out: VsOut; out.pos = vec4(p, 0.0, 1.0); // Map NDC to texture UV. v is flipped so the cache's top row (rendered at diff --git a/src/shaders/buddhabrot.wgsl b/src/shaders/buddhabrot.wgsl index 131b8b9..f39de43 100644 --- a/src/shaders/buddhabrot.wgsl +++ b/src/shaders/buddhabrot.wgsl @@ -62,17 +62,6 @@ 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; -const KIND_COMPLEX_MULTIBROT: u32 = 9u; - @group(0) @binding(0) var u: Uniforms; // Compute pass: read-write atomic histogram (3 planes of width*height, R/G/B). @group(0) @binding(1) var histogram: array>; @@ -93,10 +82,6 @@ fn rand01(seed: u32) -> f32 { return f32(hash_u32(seed)) * (1.0 / 4294967295.0); } -fn cmul(a: vec2, b: vec2) -> vec2 { - return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); -} - fn complex_pow(z: vec2, p: u32) -> vec2 { var r = vec2(1.0, 0.0); for (var i: u32 = 0u; i < p; i = i + 1u) { @@ -105,21 +90,6 @@ fn complex_pow(z: vec2, p: u32) -> vec2 { return r; } -// 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, p: vec2) -> vec2 { - let r2 = dot(z, z); - if r2 < 1e-30 { - return vec2(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(cos(ang), sin(ang)); -} - // One iteration step z_n -> z_{n+1} for the current kind. `zp` is the // previous iterate (z_{n-1}), used only by the Phoenix two-term recurrence. // Must match `FractalKind` in reference.rs (the direct, non-perturbative form @@ -251,12 +221,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { @vertex fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { - var verts = array, 3>( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0), - ); - return vec4(verts[idx], 0.0, 1.0); + return vec4(fullscreen_triangle_pos(idx), 0.0, 1.0); } @fragment diff --git a/src/shaders/colorize.wgsl b/src/shaders/colorize.wgsl index 90aaf04..e33386a 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -10,148 +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, - 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, - phoenix_p: vec2, - lambda_l: vec2, - complex_power: vec2, - de_coloring: u32, - shadow: u32, -}; - -struct Light { - azimuth: f32, - altitude: f32, - color: u32, - _pad: u32 -}; - @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var data_tex: texture_2d; @group(0) @binding(2) var lights: array; -// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette -// in mandelbrot.wgsl. -fn palette(id: u32, t: f32) -> vec3 { - if id == 4u { - return vec3(t, t, t); // grayscale - } - let a = vec3(0.5, 0.5, 0.5); - let b = vec3(0.5, 0.5, 0.5); - var c = vec3(1.0, 1.0, 1.0); - var d = vec3(0.00, 0.10, 0.20); // 0: amber / blue - if id == 1u { - d = vec3(0.00, 0.33, 0.67); // rainbow - } else if id == 2u { - d = vec3(0.30, 0.20, 0.20); // warm ember - } else if id == 3u { - c = vec3(1.0, 1.0, 0.5); - d = vec3(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 { - var verts = array, 3>( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0), - ); - return vec4(verts[idx], 0.0, 1.0); -} - -fn load(x: i32, y: i32) -> vec3 { - let dist = textureLoad(data_tex, vec2(x, y), 0).g; - return vec3(f32(x), f32(y), dist); -} - -fn compute_light(normal: vec3, light: vec3) -> vec3 { - return vec3(max(0., dot(normal, normalize(light)))); -} - -fn uncharted2tonemap(x: vec3) -> vec3 { - 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, white_point: f32) -> vec3 { - 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, k: f32, c: f32) -> vec3 { - return 1. / (1. + exp(-k * (color - c))); -} - -fn contrast(color: vec3, k: f32, c: f32) -> vec3 { - let color_c = s(color, k, c); - - return (color_c - s(vec3(0), k, c)) / (s(vec3(1), k, c) - s(vec3(0), k, c)); + return vec4(fullscreen_triangle_pos(idx), 0.0, 1.0); } @fragment fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { if u.shadow != 0u { - if textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0).b != 0. { + let x = i32(pos.x); + let y = i32(pos.y); + if textureLoad(data_tex, vec2(x, y), 0).b != 0. { return vec4(0.1, 0.1, 0.1, 1.0); } else { - let d = array, 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; - if u.shadow_palette_id == 0u { - color = compute_light(normal,vec3(.5, .5, .5)) + vec3(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(0., .5, .5)) * vec3(1., 0.5, 0.5) + compute_light(normal, vec3(0.5, 0., .5)) * vec3(0.5, 1., 1.); - - color = filmic(color, 4.2); - } else { - color = vec3(0); - var light_count = 0; - for (var i = 0u ; i < 16; i++) { - let light_color = unpack4x8unorm(lights[i].color); - if any(light_color != vec4(0)) { - light_count += 1; - } - - color += compute_light(normal, vec3( - 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(color, 1.0); + let h0 = textureLoad(data_tex, vec2(x, y), 0).g; + let h1 = textureLoad(data_tex, vec2(x + 1, y), 0).g; + let h2 = textureLoad(data_tex, vec2(x, y + 1), 0).g; + let normal = normal_from_heights(h0, h1, h2); + return vec4(shadow_color(normal), 1.0); } } else { let d = textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0); @@ -159,8 +39,7 @@ fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { 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) * sqrt(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); diff --git a/src/shaders/common.wgsl b/src/shaders/common.wgsl new file mode 100644 index 0000000..ce91242 --- /dev/null +++ b/src/shaders/common.wgsl @@ -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 { + var verts = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + return verts[idx]; +} + +// Complex multiply. +fn cmul(a: vec2, b: vec2) -> vec2 { + return vec2(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, p: vec2) -> vec2 { + let r2 = dot(z, z); + if r2 < 1e-30 { + return vec2(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(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; diff --git a/src/shaders/iterate_uniforms.wgsl b/src/shaders/iterate_uniforms.wgsl new file mode 100644 index 0000000..602a7c4 --- /dev/null +++ b/src/shaders/iterate_uniforms.wgsl @@ -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, + 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, + // 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, + // Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused + // by other kinds. + lambda_l: vec2, + // Complex exponent for the Complex Multibrot kind (z^power + c); unused + // by other kinds. + complex_power: vec2, + // 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 { + if id == 4u { + return vec3(t, t, t); // grayscale + } + let a = vec3(0.5, 0.5, 0.5); + let b = vec3(0.5, 0.5, 0.5); + var c = vec3(1.0, 1.0, 1.0); + var d = vec3(0.00, 0.10, 0.20); // 0: amber / blue + if id == 1u { + d = vec3(0.00, 0.33, 0.67); // rainbow + } else if id == 2u { + d = vec3(0.30, 0.20, 0.20); // warm ember + } else if id == 3u { + c = vec3(1.0, 1.0, 0.5); + d = vec3(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 { + 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` +// 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, light: vec3) -> vec3 { + return vec3(max(0., dot(normal, normalize(light)))); +} + +fn uncharted2tonemap(x: vec3) -> vec3 { + 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, white_point: f32) -> vec3 { + 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, k: f32, c: f32) -> vec3 { + return 1. / (1. + exp(-k * (color - c))); +} + +fn contrast(color: vec3, k: f32, c: f32) -> vec3 { + let color_c = s(color, k, c); + + return (color_c - s(vec3(0), k, c)) / (s(vec3(1), k, c) - s(vec3(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 { + let d0 = vec3(0.0, 0.0, h0); + let d1 = vec3(1.0, 0.0, h1); + let d2 = vec3(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) -> vec3 { + var color: vec3; + if u.shadow_palette_id == 0u { + color = compute_light(normal, vec3(.5, .5, .5)) + vec3(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(0., .5, .5)) * vec3(1., 0.5, 0.5) + compute_light(normal, vec3(0.5, 0., .5)) * vec3(0.5, 1., 1.); + + color = filmic(color, 4.2); + } else { + color = vec3(0); + var light_count = 0; + for (var i = 0u; i < 16; i++) { + let light_color = unpack4x8unorm(lights[i].color); + if any(light_color != vec4(0)) { + light_count += 1; + } + + color += compute_light(normal, vec3( + 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; +} diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index 1fb71d7..fcc761e 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -12,50 +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, - 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, - // 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, - // Distortion constant l for the Lambda map (l*z(1 - z_{n-1})); unused - // by other kinds. - lambda_l: vec2, - // Complex exponent for the Complex Multibrot kind (z^power + c); unused - // by other kinds. - complex_power: vec2, - // 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; -const KIND_COMPLEX_MULTIBROT: u32 = 9u; - @group(0) @binding(0) var u: Uniforms; @group(0) @binding(1) var ref_orbit: array>; +// Only read by `fs_color`'s shadow branch (custom-lights palette); the +// iteration pass (`fs_data`) never touches it. +@group(0) @binding(2) var lights: array; struct VsOut { @builtin(position) pos: vec4, @@ -65,12 +26,7 @@ struct VsOut { @vertex fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { - var verts = array, 3>( - vec2(-1.0, -1.0), - vec2(3.0, -1.0), - vec2(-1.0, 3.0), - ); - let ndc = verts[idx]; + let ndc = fullscreen_triangle_pos(idx); var out: VsOut; out.pos = vec4(ndc, 0.0, 1.0); // Flip y so +imaginary points up the screen. @@ -78,11 +34,6 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> VsOut { return out; } -// Complex multiply. -fn cmul(a: vec2, b: vec2) -> vec2 { - return vec2(a.x * b.x - a.y * b.y, a.x * b.y + a.y * b.x); -} - // Complex conjugate. fn conj(a: vec2) -> vec2 { return vec2(a.x, -a.y); @@ -94,21 +45,6 @@ fn cdiv(a: vec2, b: vec2) -> vec2 { return vec2(a.x * b.x + a.y * b.y, a.y * b.x - a.x * b.y) / d; } -// 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, p: vec2) -> vec2 { - let r2 = dot(z, z); - if r2 < 1e-30 { - return vec2(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(cos(ang), sin(ang)); -} - // |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. @@ -258,26 +194,6 @@ fn fprime(z: vec2) -> vec2 { return 2.0 * z; } -// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. -fn palette(id: u32, t: f32) -> vec3 { - if id == 4u { - return vec3(t, t, t); // grayscale - } - let a = vec3(0.5, 0.5, 0.5); - let b = vec3(0.5, 0.5, 0.5); - var c = vec3(1.0, 1.0, 1.0); - var d = vec3(0.00, 0.10, 0.20); // 0: amber / blue - if id == 1u { - d = vec3(0.00, 0.33, 0.67); // rainbow - } else if id == 2u { - d = vec3(0.30, 0.20, 0.20); // warm ember - } else if id == 3u { - c = vec3(1.0, 1.0, 0.5); - d = vec3(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 @@ -416,22 +332,15 @@ fn color_sample(s: Sample) -> vec3 { if !s.escaped { return vec3(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 { - 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, dx: vec2, dy: vec2, px: f32) -> vec3 { let aa = max(u.aa_level, 1u); let inv = 1.0 / f32(aa); var ci_sum = 0.0; @@ -453,7 +362,22 @@ fn fs_data(in: VsOut) -> @location(0) vec4 { 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(ci_avg, de_avg, interior_frac, 1.0); + return vec3(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 { + 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(aggregate_sample(base, dx, dy, px), 1.0); } // Combined iterate + colour in a single pass, for PNG export (which never needs @@ -466,6 +390,21 @@ fn fs_color(in: VsOut) -> @location(0) vec4 { 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(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(shadow_color(normal), 1.0); + } + let aa = max(u.aa_level, 1u); let inv = 1.0 / f32(aa); var acc = vec3(0.0, 0.0, 0.0); diff --git a/tests/shader_valid.rs b/tests/shader_valid.rs index 5f7104d..8d18b40 100644 --- a/tests/shader_valid.rs +++ b/tests/shader_valid.rs @@ -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"), + ), ); }