diff --git a/src/app.rs b/src/app.rs index 3f73244..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, @@ -30,7 +31,7 @@ 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"]; +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"]; @@ -273,6 +274,9 @@ pub struct FractalApp { // 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 /// perturbation/reference-orbit machinery (see `fractal::buddhabrot`). @@ -410,6 +414,7 @@ impl FractalApp { antialias: false, de_coloring: false, shadow: false, + lights: vec![Light::default()], buddhabrot: false, buddha_r_cap: 50, buddha_g_cap: 500, @@ -1384,6 +1389,25 @@ impl FractalApp { } }); } + 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)."); if !self.shadow { @@ -1397,14 +1421,16 @@ impl FractalApp { } 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") @@ -1779,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 b96ed9c..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; @@ -132,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`. @@ -174,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: &[ @@ -305,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 = @@ -413,6 +433,7 @@ impl FractalRenderer { bind_group_layout, uniform_buffer, ref_buffer, + lights_buffer, bind_group, target_format, uploaded_generation: u64::MAX, @@ -481,6 +502,10 @@ impl FractalRenderer { binding: 1, resource: wgpu::BindingResource::TextureView(&data_view), }, + wgpu::BindGroupEntry { + binding: 2, + resource: self.lights_buffer.as_entire_binding(), + }, ], }); @@ -785,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. @@ -831,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 @@ -843,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/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 d18b216..eaaceb2 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -31,8 +31,16 @@ struct Uniforms { 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. @@ -71,11 +79,10 @@ fn load(x: i32, y: i32) -> vec3 { } fn compute_light(normal: vec3, light: vec3) -> vec3 { - return vec3((dot(normal, normalize(light)) + 0.2) / 1.2); + return vec3(max(0., dot(normal, normalize(light)))); } fn uncharted2tonemap(x: vec3) -> vec3 { -// Paramètres de la courbe de Hable let A = 0.15; // Shoulder strength let B = 0.50; // Linear strength let C = 0.10; // Linear angle @@ -84,20 +91,32 @@ fn uncharted2tonemap(x: vec3) -> vec3 { 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) -> vec3 { +} + +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(4.2)); + 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 { if u.shadow != 0u { if textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0).b != 0. { - return vec4(0., 0.11, 0.54, 1.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))); @@ -105,11 +124,30 @@ fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { var color: vec3; if u.shadow_palette_id == 0u { - color = compute_light(normal,vec3(.5, .5, .5)); - } else { + 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); + 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); diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index 224ee21..01a2547 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -161,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) @@ -182,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; }