diff --git a/Cargo.toml b/Cargo.toml index 65d3e9a..ae9329b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,8 @@ web-sys = { version = "0.3.105", features = ["Window", "Location", "Url", "UrlSe # Release: optimize hard (fractal math is hot). [profile.release] opt-level = 3 -codegen-units = 1 +# codegen-units = 1 +debug = true # Dev: keep our own crate debuggable, but optimize dependencies (dashu, wgpu, # egui) so the explorer is actually interactive during development. diff --git a/src/app.rs b/src/app.rs index 1f79111..686e223 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ use crate::fractal::{ }; #[cfg(target_arch = "wasm32")] use crate::fractal::{compute_reference, compute_set_reference}; +use crate::lights::Light; use crate::view::{ Big, DEFAULT_HALF_HEIGHT, ViewState, big_from_decimal_str, big_from_f64, big_to_decimal_str, precision_for, @@ -29,6 +30,8 @@ const INTERACT_DOWNSCALE: u32 = 2; const INTERACT_SETTLE: f64 = 0.12; /// Palette names; index maps to `palette_id` in the shader. const PALETTE_NAMES: &[&str] = &["Amber", "Rainbow", "Ember", "Lime", "Grayscale"]; +/// Shadow palette names; index maps to `palette_id` in the shader. +const SHADOW_PALETTE_NAMES: &[&str] = &["Grayscale", "Red & Blue", "Custom lights"]; /// Buddhabrot tonemap style names; index maps to `BuddhabrotUniforms::palette`. const BUDDHA_PALETTE_NAMES: &[&str] = &["Nebula", "Yellow", "Grayscale"]; @@ -121,7 +124,7 @@ const SET_PRESETS: [&[SetPreset]; FractalKind::Lambda as usize + 1] = [ "-0.7436438870371587", "0.1318259042053", 8.0e-8, - 10000, + 2000, None, ), ], @@ -262,11 +265,17 @@ pub struct FractalApp { color_scale: f32, color_offset: f32, palette: u32, + shadow_palette: u32, /// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work). antialias: bool, /// Distance-estimation shading: darkens toward the set boundary using the /// orbit derivative, giving crisp filaments at deep zoom instead of speckle. de_coloring: bool, + // Use shadow coloring + shadow: bool, + + /// List of enabled lights in the world + lights: Vec, /// Render as a Buddhabrot (Monte-Carlo orbit-density histogram) instead of /// the ordinary escape-time set. Plain f32 view — no deep zoom, no @@ -401,8 +410,11 @@ impl FractalApp { color_scale: 0.15, color_offset: 0.0, palette: 0, + shadow_palette: 0, antialias: false, de_coloring: false, + shadow: false, + lights: vec![Light::default()], buddhabrot: false, buddha_r_cap: 50, buddha_g_cap: 500, @@ -576,6 +588,7 @@ impl FractalApp { color_scale: self.color_scale, color_offset: self.color_offset, palette: self.palette, + shadow_palette: self.shadow_palette, } } @@ -594,6 +607,8 @@ impl FractalApp { 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; + self.shadow_palette = + (s.shadow_palette as usize).min(SHADOW_PALETTE_NAMES.len() - 1) as u32; // The link carries an explicit iteration count; honor it rather than // letting the auto-scaler immediately overwrite it. self.auto_iterations = false; @@ -802,14 +817,16 @@ impl FractalApp { bailout_sq: BAILOUT_SQ, is_julia: matches!(self.mode, FractalMode::Julia) as u32, palette_id: self.palette, + shadow_palette_id: self.shadow_palette, aa_level: if self.antialias { 2 } else { 1 }, kind: self.kind as u32, power: self.power, 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], - de_coloring: self.de_coloring as u32, - _pad: [0], + de_coloring: (self.de_coloring | self.shadow) as u32, + shadow: self.shadow as u32, + _pad: [0; _], } } @@ -833,11 +850,11 @@ impl FractalApp { r_cap: self.buddha_r_cap, g_cap: self.buddha_g_cap, b_cap: self.buddha_b_cap, - seed: 0, // set by the callback's own dispatch counter + seed: 0, // set by the callback's own dispatch counter samples_this_dispatch: 0, // set by the callback exposure: self.buddha_exposure, - width: 0, // set by the callback from size_px - height: 0, // set by the callback from size_px + width: 0, // set by the callback from size_px + 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], @@ -1354,32 +1371,66 @@ impl FractalApp { .logarithmic(true), ); ui.add(egui::Slider::new(&mut self.color_offset, 0.0..=1.0).text("color offset")); - egui::ComboBox::from_label("palette") - .selected_text(PALETTE_NAMES[self.palette as usize]) - .show_ui(ui, |ui| { - for (i, name) in PALETTE_NAMES.iter().enumerate() { - ui.selectable_value(&mut self.palette, i as u32, *name); + ui.checkbox(&mut self.shadow, "Shadow"); + if !self.shadow { + egui::ComboBox::from_label("palette") + .selected_text(PALETTE_NAMES[self.palette as usize]) + .show_ui(ui, |ui| { + for (i, name) in PALETTE_NAMES.iter().enumerate() { + ui.selectable_value(&mut self.palette, i as u32, *name); + } + }); + } else { + egui::ComboBox::from_label("palette") + .selected_text(SHADOW_PALETTE_NAMES[self.shadow_palette as usize]) + .show_ui(ui, |ui| { + for (i, name) in SHADOW_PALETTE_NAMES.iter().enumerate() { + ui.selectable_value(&mut self.shadow_palette, i as u32, *name); + } + }); + } + if self.shadow && self.shadow_palette as usize == SHADOW_PALETTE_NAMES.len() - 1 { + ui.horizontal(|ui| { + ui.label("lights:"); + if ui.button("+").clicked() { + self.lights.push(Light::default()); } }); + egui::Grid::new("lights") + .striped(true) + .num_columns(1) + .show(ui, |ui| { + self.lights.retain_mut(|light| { + let delete = !light.widget(ui); + ui.end_row(); + delete + }); + }); + } + ui.separator(); ui.checkbox(&mut self.antialias, "Antialiasing (2×2)") .on_hover_text("Supersample each pixel for smoother edges (~4× slower)."); - ui.checkbox(&mut self.de_coloring, "Distance shading") - .on_hover_text( - "Shade by distance to the set boundary (from the orbit derivative) \ + if !self.shadow { + ui.checkbox(&mut self.de_coloring, "Distance shading") + .on_hover_text( + "Shade by distance to the set boundary (from the orbit derivative) \ for crisp filaments at deep zoom. Exact for the holomorphic kinds \ (Mandelbrot/Multibrot/Phoenix), approximate for the abs-based kinds \ (Burning Ship/Tricorn/Celtic/Perpendicular/Buffalo).", - ); + ); + } ui.collapsing("Animation", |ui| { - ui.checkbox(&mut self.anim.color, "Cycle colours") - .on_hover_text("Scroll the palette offset over time."); - if self.anim.color { - ui.add( - egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0) - .text("cycles/s") - .logarithmic(true), - ); + if !self.shadow { + ui.checkbox(&mut self.anim.color, "Cycle colours") + .on_hover_text("Scroll the palette offset over time."); + if self.anim.color { + ui.add( + egui::Slider::new(&mut self.anim.color_speed, 0.01..=2.0) + .text("cycles/s") + .logarithmic(true), + ); + } } ui.checkbox(&mut self.anim.zoom, "Auto-zoom") @@ -1754,6 +1805,7 @@ impl FractalApp { rect, FractalCallback { uniforms, + lights: self.lights.clone(), reference: Arc::clone(&self.reference), generation: self.generation, size_px, diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 33bb037..0d37bf4 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -13,6 +13,8 @@ use std::sync::Arc; use eframe::egui_wgpu::{self, wgpu}; +use crate::lights::{Light, MAX_LIGHT_COUNT}; + /// Maximum reference-orbit length (points) the storage buffer can hold. Also /// bounds the iteration count. 128k points * 8 bytes = 1 MiB. pub const MAX_REF_POINTS: usize = 1 << 17; @@ -46,6 +48,8 @@ fn color_differs(a: &Uniforms, b: &Uniforms) -> bool { a.color_offset != b.color_offset || a.color_scale != b.color_scale || a.palette_id != b.palette_id + || a.shadow_palette_id != b.shadow_palette_id + || a.shadow != b.shadow } /// GPU-side view + coloring parameters. Layout must match `Uniforms` in the @@ -64,12 +68,14 @@ pub struct Uniforms { /// 0 = Mandelbrot, 1 = Julia. pub is_julia: u32, pub palette_id: u32, + pub shadow_palette_id: u32, /// Supersampling factor per axis: 1 = off, 2 = 2×2 (4 samples). pub aa_level: u32, /// Iteration formula (`FractalKind::shader_id`). pub kind: u32, /// Exponent for the Multibrot kind. pub power: u32, + pub _pad: [u32; 1], /// Complex offset of the view center from the reference center, so a stale /// or reused reference (computed at a slightly different center) still maps /// correctly. Added to every pixel's per-pixel offset. @@ -83,8 +89,8 @@ pub struct Uniforms { pub lambda_l: [f32; 2], /// 0 = escape-time coloring, 1 = distance-estimation shading. pub de_coloring: u32, - /// Padding to a 16-byte multiple (uniform buffer requirement). - pub _pad: [u32; 1], + // 0 = classic colors, 1 = shadows + pub shadow: u32, } /// Offscreen textures for the two-pass render, recreated whenever the widget's @@ -128,6 +134,7 @@ pub struct FractalRenderer { bind_group_layout: wgpu::BindGroupLayout, uniform_buffer: wgpu::Buffer, ref_buffer: wgpu::Buffer, + lights_buffer: wgpu::Buffer, bind_group: wgpu::BindGroup, target_format: wgpu::TextureFormat, /// Generation of the reference orbit currently uploaded to `ref_buffer`. @@ -170,6 +177,13 @@ impl FractalRenderer { mapped_at_creation: false, }); + let lights_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("lights parameters"), + size: (MAX_LIGHT_COUNT * std::mem::size_of::()) as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("fractal bind group layout"), entries: &[ @@ -301,6 +315,16 @@ impl FractalRenderer { }, count: None, }, + 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, + }, ], }); let colorize_pipeline_layout = @@ -409,6 +433,7 @@ impl FractalRenderer { bind_group_layout, uniform_buffer, ref_buffer, + lights_buffer, bind_group, target_format, uploaded_generation: u64::MAX, @@ -477,6 +502,10 @@ impl FractalRenderer { binding: 1, resource: wgpu::BindingResource::TextureView(&data_view), }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.lights_buffer.as_entire_binding(), + }, ], }); @@ -781,6 +810,7 @@ pub fn encode_png_with_progress( /// colourise pass (see `prepare`). pub struct FractalCallback { pub uniforms: Uniforms, + pub lights: Vec, pub reference: Arc>, pub generation: u64, /// Widget size in physical pixels — the cache texture resolution. @@ -827,7 +857,8 @@ impl egui_wgpu::CallbackTrait for FractalCallback { let color_dirty = iter_dirty || renderer.colored.as_ref().is_none_or(|c| { c.width != width || c.height != height || color_differs(&c.uniforms, &self.uniforms) - }); + }) + || true; if !color_dirty { return Vec::new(); // cache still valid; paint() just blits it @@ -839,6 +870,10 @@ impl egui_wgpu::CallbackTrait for FractalCallback { 0, bytemuck::bytes_of(&self.uniforms), ); + let mut bytes = [0; size_of::() * MAX_LIGHT_COUNT]; + bytes[..self.lights.len() * size_of::()] + .copy_from_slice(bytemuck::cast_slice(&self.lights)); + queue.write_buffer(&renderer.lights_buffer, 0, &bytes); if let Some(cache) = &renderer.cache { if iter_dirty { diff --git a/src/fractal/share.rs b/src/fractal/share.rs index 4e06d9d..4550b29 100644 --- a/src/fractal/share.rs +++ b/src/fractal/share.rs @@ -29,6 +29,8 @@ pub struct ShareState { pub color_offset: f32, /// Palette index (`palette_id` in the shader). pub palette: u32, + /// Shadow palette index (`shadow_palette_id` in the shader). + pub shadow_palette: u32, } impl ShareState { @@ -110,6 +112,7 @@ impl ShareState { 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), + shadow_palette: map.get("spal").and_then(|s| s.parse().ok()).unwrap_or(0), }) } } @@ -134,6 +137,7 @@ mod tests { color_scale: 0.02, color_offset: 0.25, palette: 3, + shadow_palette: 1, }; let d = ShareState::decode(&s.encode()).unwrap(); assert_eq!(d.julia, s.julia); @@ -146,6 +150,7 @@ mod tests { assert_eq!(d.julia_c, s.julia_c); assert_eq!(d.phoenix_p, s.phoenix_p); assert_eq!(d.palette, s.palette); + assert_eq!(d.shadow_palette, s.shadow_palette); } #[test] diff --git a/src/lights.rs b/src/lights.rs new file mode 100644 index 0000000..aefe241 --- /dev/null +++ b/src/lights.rs @@ -0,0 +1,55 @@ +use std::f32::consts::PI; + +use bytemuck::{Pod, Zeroable}; +use egui::{Color32, Ui}; + +/// Maximum number of simultaneous lights. +pub const MAX_LIGHT_COUNT: usize = 16; + +#[derive(Clone, Copy, PartialEq, Zeroable, Pod)] +#[repr(C)] +pub struct Light { + pub azimuth: f32, + pub altitude: f32, + pub color: Color32, + pub _pad: u32, +} + +impl Default for Light { + fn default() -> Self { + Self { + azimuth: PI / 4., + altitude: PI / 4., + color: Color32::WHITE, + _pad: 0, + } + } +} + +impl Light { + pub fn widget(&mut self, ui: &mut Ui) -> bool { + let formater = |v, _| format!("{}°", ((v as f32 * 180. / PI) as u32)); + ui.horizontal(|ui| { + let del = ui.button("-").clicked(); + ui.label("color:"); + ui.color_edit_button_srgba(&mut self.color); + ui.label("θ:"); + ui.add( + egui::DragValue::new(&mut self.azimuth) + .range(0.0..=PI * 2.) + .custom_formatter(formater) + .speed(0.02), + ); + ui.label("φ:"); + ui.add( + egui::DragValue::new(&mut self.altitude) + .range(0.0..=PI / 2.) + .custom_formatter(formater) + .speed(0.02), + ); + + del + }) + .inner + } +} diff --git a/src/main.rs b/src/main.rs index ba6f722..a9268da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ // and calls the wasm `main`, which boots eframe onto the page's . mod app; +mod lights; mod fractal; mod view; diff --git a/src/shaders/colorize.wgsl b/src/shaders/colorize.wgsl index 299f1f8..eaaceb2 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -20,32 +20,43 @@ struct Uniforms { 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, 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) { + 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) { + if id == 1u { d = vec3(0.00, 0.33, 0.67); // rainbow - } else if (id == 2u) { + } else if id == 2u { d = vec3(0.30, 0.20, 0.20); // warm ember - } else if (id == 3u) { + } else if id == 3u { c = vec3(1.0, 1.0, 0.5); d = vec3(0.80, 0.90, 0.30); // lime / magenta } @@ -62,17 +73,96 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { 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)); +} + @fragment fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { - let d = textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0); - let ci = d.r; - let de = d.g; - let interior_frac = d.b; + if u.shadow != 0u { + if textureLoad(data_tex, vec2(i32(pos.x), i32(pos.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 t = fract(ci * u.color_scale + u.color_offset); - var col = palette(u.palette_id, t) * 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); - return vec4(col, 1.0); + 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); + } + } else { + let d = textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0); + let ci = d.r; + 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; + // 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); + return vec4(col, 1.0); + } } diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index 4e99935..01a2547 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -21,6 +21,7 @@ struct Uniforms { 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, @@ -35,6 +36,8 @@ struct Uniforms { lambda_l: vec2, // 0 = escape-time coloring, 1 = distance-estimation shading. de_coloring: u32, + // 0 = classic colors, 1 = shadows + shadow: u32, }; const KIND_MANDELBROT: u32 = 0u; @@ -158,7 +161,7 @@ fn advance_delta(z: vec2, e: vec2) -> vec2 { return vec2(sq.x, -2.0 * (z.x * da + e.x * abs_yf)); } else if u.kind == KIND_LAMBDA { // Lambda map: z^{n+1} = λ·z·(1-z). Delta: e = λ·e·(1-2z-e). - let one_minus_2z_minus_e = vec2(1.0 - 2.0*z.x - e.x, -2.0*z.y - e.y); + let one_minus_2z_minus_e = vec2(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)); } return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot (and Phoenix square part) @@ -179,7 +182,7 @@ fn fprime(z: vec2) -> vec2 { return f32(p) * zk; } else if u.kind == KIND_LAMBDA { // Lambda: f'(z) = λ·(1-2z). - return cmul(u.lambda_l, vec2(1.0 - 2.0*z.x, -2.0*z.y)); + return cmul(u.lambda_l, vec2(1.0 - 2.0 * z.x, -2.0 * z.y)); } return 2.0 * z; } @@ -326,7 +329,11 @@ fn iterate_sample(offset: vec2, px: f32) -> Sample { let zmag = sqrt(max(z2, 1.0)); let dzmag = sqrt(max(dot(dz, dz), 1e-20)); let d = zmag * log(zmag) / dzmag; - de = clamp(d / max(px, 1e-30), 0.0, 1.0); + var max_de = 1.; + if u.shadow != 0u { + max_de = 1000.; + } + de = clamp(d / max(px, 1e-30), 0.0, max_de); } return Sample(ci, de, true); }