From 66669cd801887a0ca0b5265d637bbaa3a954864f Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Tue, 22 Sep 2026 09:34:03 +0200 Subject: [PATCH] feat: add 3d support --- Cargo.toml | 1 + src/app.rs | 233 +++++++++++++++++++++++------- src/camera.rs | 88 +++++++++++ src/fractal/renderer.rs | 9 ++ src/main.rs | 1 + src/shaders/colorize.wgsl | 99 ++++++++++--- src/shaders/iterate_uniforms.wgsl | 11 +- 7 files changed, 363 insertions(+), 79 deletions(-) create mode 100644 src/camera.rs diff --git a/Cargo.toml b/Cargo.toml index 5c6dfa9..1f89ea2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ bytemuck = { version = "1.25.2", features = ["derive"] } dashu-float = "0.6.0" eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] } egui = "0.36.2" +glam = "0.33.8" log = "0.4.34" png = "0.18.1" diff --git a/src/app.rs b/src/app.rs index 03ff864..e2fe143 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use eframe::egui_wgpu; #[cfg(target_arch = "wasm32")] use eframe::egui_wgpu::wgpu; +use crate::camera::Camera; #[cfg(not(target_arch = "wasm32"))] use crate::cli::Cli; use crate::fractal::{ @@ -261,6 +262,8 @@ pub struct FractalApp { de_coloring: bool, // Use shadow coloring shadow: bool, + // Use 3D raymarching rendering + dimension3: bool, /// List of enabled lights in the world lights: Vec, @@ -343,6 +346,9 @@ pub struct FractalApp { /// through the display format and drift the zoom. zoom_edit: String, zoom_edited: bool, + + /// The camera used to render 3D fractals + camera: Camera, } /// Significant decimal digits to show for a center at the given precision (bits). @@ -430,6 +436,7 @@ impl FractalApp { antialias: false, de_coloring: false, shadow: false, + dimension3: false, lights: vec![Light::default()], buddha_r_cap: 50, buddha_g_cap: 500, @@ -465,6 +472,7 @@ impl FractalApp { center_im_edit, zoom_edit, zoom_edited: false, + camera: Camera::new(), } } @@ -929,9 +937,14 @@ impl FractalApp { 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, + de_coloring: (self.de_coloring | self.shadow | self.dimension3) as u32, shadow: self.shadow as u32, + dimension3: self.dimension3 as u32, + camera_direction: self.camera.direction().to_array(), _pad: [0; _], + _pad2: [0; _], + _pad3: [0; _], + camera_inv_proj: self.camera.orthographic().inverse().to_cols_array(), } } @@ -1639,7 +1652,8 @@ impl FractalApp { .logarithmic(true), ); ui.add(egui::Slider::new(&mut self.color_offset, 0.0..=1.0).text("color offset")); - ui.checkbox(&mut self.shadow, "Shadow"); + ui.checkbox(&mut self.shadow, "Shadow rendering"); + ui.checkbox(&mut self.dimension3, "3D rendering"); if !self.shadow { egui::ComboBox::from_label("palette") .selected_text(PALETTE_NAMES[self.palette as usize]) @@ -1964,8 +1978,30 @@ impl FractalApp { // Touch: pinch to zoom (toward the gesture center) and two-finger pan. // Takes precedence over single-finger drag while two fingers are down. + // In 3D mode the same gestures orbit/dolly the raymarch camera + // instead of panning/zooming the 2D fractal view. + const ROT_SENS: f32 = 0.002; // radians per dragged pixel let multi_touch = ui.input(|i| i.multi_touch()); - if let Some(mt) = multi_touch { + if self.dimension3 { + if let Some(mt) = multi_touch { + let t = mt.translation_delta; + if t.x != 0.0 || t.y != 0.0 { + self.camera.rotate(t.x * ROT_SENS, -t.y * ROT_SENS); + interacted = true; + } + if mt.zoom_delta != 1.0 { + self.camera.zoom(1.0 / mt.zoom_delta); + interacted = true; + } + ui.ctx().request_repaint(); + } else if response.dragged() { + let d = response.drag_delta(); + if d.x != 0.0 || d.y != 0.0 { + self.camera.rotate(d.x * ROT_SENS, -d.y * ROT_SENS); + interacted = true; + } + } + } else if let Some(mt) = multi_touch { let t = mt.translation_delta; if t.x != 0.0 || t.y != 0.0 { self.view.pan_pixels(t.x as f64, t.y as f64, height_px); @@ -1989,75 +2025,165 @@ impl FractalApp { } } - // Mouse wheel / trackpad: zoom toward the cursor. + // Mouse wheel / trackpad: zoom toward the cursor (2D), or dolly the + // camera's ortho volume (3D). let (scroll_y, hover) = ui.input(|i| (i.smooth_scroll_delta.y, i.pointer.hover_pos())); if scroll_y != 0.0 && let Some(pos) = hover && rect.contains(pos) { - let off = pos - rect.center(); let factor = (-scroll_y as f64 * 0.0015).exp(); - self.view - .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); + if self.dimension3 { + self.camera.zoom(factor as f32); + } else { + let off = pos - rect.center(); + self.view + .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); + } interacted = true; ui.ctx().request_repaint(); } // 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. + // resets the view, H/I toggle the Help/Info windows. In 3D mode, + // ZQSD move the camera (forward/left/back/right), space/ctrl move it + // up/down, and the arrow keys look around instead of panning. + // 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; - } + if self.dimension3 { + let (fwd, back, strafe_l, strafe_r, up, down, look_l, look_r, look_u, look_d) = ui + .input(|i| { + ( + i.key_down(egui::Key::Z), + i.key_down(egui::Key::S), + i.key_down(egui::Key::Q), + i.key_down(egui::Key::D), + i.key_down(egui::Key::Space), + i.modifiers.ctrl || i.modifiers.command, + i.key_down(egui::Key::ArrowLeft), + i.key_down(egui::Key::ArrowRight), + i.key_down(egui::Key::ArrowUp), + i.key_down(egui::Key::ArrowDown), + ) + }); - // 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(); + // Units/sec move speed and radians/sec look speed. + const MOVE_SPEED: f32 = 0.1; + const LOOK_SPEED: f32 = 0.05; + let mut mv_fwd = 0.0f32; + let mut mv_right = 0.0f32; + let mut mv_up = 0.0f32; + if fwd { + mv_fwd += MOVE_SPEED * dt as f32; + } + if back { + mv_fwd -= MOVE_SPEED * dt as f32; + } + if strafe_r { + mv_right += MOVE_SPEED * dt as f32; + } + if strafe_l { + mv_right -= MOVE_SPEED * dt as f32; + } + if up { + mv_up += MOVE_SPEED * dt as f32; + } + if down { + mv_up -= MOVE_SPEED * dt as f32; + } + if mv_fwd != 0.0 || mv_right != 0.0 || mv_up != 0.0 { + self.camera.translate(mv_fwd, mv_right, mv_up); + interacted = true; + } + + let mut dyaw = 0.0f32; + let mut dpitch = 0.0f32; + if look_r { + dyaw += LOOK_SPEED * dt as f32; + } + if look_l { + dyaw -= LOOK_SPEED * dt as f32; + } + if look_u { + dpitch += LOOK_SPEED * dt as f32; + } + if look_d { + dpitch -= LOOK_SPEED * dt as f32; + } + if dyaw != 0.0 || dpitch != 0.0 { + self.camera.rotate(dyaw, dpitch); + interacted = true; + } + + if fwd + || back + || strafe_l + || strafe_r + || up + || down + || look_l + || look_r + || look_u + || look_d + { + ui.ctx().request_repaint(); + } + } else { + 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); + self.camera = Camera::new(); interacted = true; } if ui.input(|i| i.key_pressed(egui::Key::H)) { @@ -2152,6 +2278,7 @@ impl FractalApp { (((rect.height() * ppp).round() as u32) / downscale).max(1), ]; + self.camera.set_aspect_ratio(aspect as f32); let mut uniforms = self.make_uniforms(aspect); if interacting { uniforms.aa_level = 1; // supersampling is wasted on the low-res pass diff --git a/src/camera.rs b/src/camera.rs new file mode 100644 index 0000000..e1e2f63 --- /dev/null +++ b/src/camera.rs @@ -0,0 +1,88 @@ +use std::f32::consts::PI; + +use glam::Vec3; + +#[derive(Default, Clone)] +pub struct Camera { + pub position: glam::Vec3, + + pub yaw: f32, + pub pitch: f32, + + /// Demi-hauteur du volume visible (remplace fov_y_radians) + ortho_height: f32, + aspect_ratio: f32, + z_near: f32, + z_far: f32, +} + +impl Camera { + pub fn new() -> Self { + Self { + position: Vec3::new(0., 0., -1.), + yaw: 0. * PI / 180., + pitch: 0. * PI / 180., + ortho_height: 1.0, + aspect_ratio: 1., + z_near: 0.1, + z_far: 100., + } + } + + pub fn set_aspect_ratio(&mut self, aspect_ratio: f32) { + self.aspect_ratio = aspect_ratio; + } + + /// Camera-local right vector: perpendicular to yaw, ignoring pitch (so + /// strafing stays level regardless of where the camera is looking). + pub fn right(&self) -> glam::Vec3 { + glam::Mat3::from_rotation_y(-self.yaw) * glam::Vec3::X + } + + /// Move the camera in its own local space: `forward`/`right` follow the + /// (pitch-aware) view direction and its horizontal right vector, `up` + /// moves along the fixed world Y axis. + pub fn translate(&mut self, forward: f32, right: f32, up: f32) { + self.position += self.direction() * forward + self.right() * right + Vec3::Y * up; + } + + /// Adjust yaw/pitch by the given deltas (radians). Pitch is clamped just + /// short of straight up/down to avoid the view flipping past the pole. + pub fn rotate(&mut self, dyaw: f32, dpitch: f32) { + const PITCH_LIMIT: f32 = PI / 2.0 - 0.01; + self.yaw += dyaw; + self.pitch = (self.pitch + dpitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + + /// Scale the visible ortho volume by `factor` (<1 zooms in, >1 zooms + /// out), clamped to a sane range. + pub fn zoom(&mut self, factor: f32) { + self.ortho_height = (self.ortho_height * factor).clamp(0.001, 1000.0); + } + + pub fn orthographic(&self) -> glam::Mat4 { + let view = glam::Mat4::from_translation(self.position) + * glam::Mat4::from_rotation_y(-self.yaw) + * glam::Mat4::from_rotation_x(-self.pitch); + + let half_height = self.ortho_height; + let half_width = half_height * self.aspect_ratio; + + glam::camera::lh::proj::directx::orthographic( + -half_width, + half_width, + -half_height, + half_height, + self.z_near, + self.z_far, + ) * view.inverse() + } + + pub fn direction(&self) -> glam::Vec3 { + let forward = glam::Mat3::from_rotation_y(-self.yaw) + * glam::Mat3::from_rotation_x(-self.pitch) + * glam::Vec3::Z; + + forward.normalize() + } +} diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 91c1893..286cdd3 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -95,6 +95,15 @@ pub struct Uniforms { pub de_coloring: u32, // 0 = classic colors, 1 = shadows pub shadow: u32, + // 0 = classic colors, 1 = 3D raymarching rendering + pub dimension3: u32, + pub _pad2: [u32; 3], + // camera direction vector + pub camera_direction: [f32; 3], + pub _pad3: [u32; 1], + /// Inverse of the camera's view-projection matrix (column-major), for + /// reconstructing a world-space ray origin per pixel in the raymarcher. + pub camera_inv_proj: [f32; 16], } /// Offscreen textures for the two-pass render, recreated whenever the widget's diff --git a/src/main.rs b/src/main.rs index 5170446..e536f2a 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 camera; mod fractal; mod lights; mod view; diff --git a/src/shaders/colorize.wgsl b/src/shaders/colorize.wgsl index e33386a..562f95f 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -19,30 +19,81 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { 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 { - 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 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); - let ci = d.r; - let de = d.g; - let interior_frac = d.b; +fn shadow_fragment(pos: vec3) -> vec4 { - 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); - return vec4(col, 1.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 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); } } +@fragment +fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { + if u.dimension3 == 0u { + if u.shadow != 0u { + return shadow_fragment(pos.xyz); + } 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; + + 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); + return vec4(col, 1.0); + } + } else { + return ray_marching(pos); + } +} + +fn sdf(pos: vec3) -> f32 { + let texture_pos = vec2(i32(pos.x), i32(pos.y)); + let px = textureLoad(data_tex, texture_pos, 0); + let de = px.g; + let z = pos.z; + if px.b != 0. { + return 0.; + } else { + return sqrt(z * z + (de * de)); + } +} + +fn ray_marching(pos: vec4) -> vec4 { + let size = vec2(textureDimensions(data_tex)); + let in_texture = vec2( + pos.x / 1980. * size.x, + pos.y / 1080. * size.y, + ); + + var world_pos = u.camera_inv_proj * vec4(in_texture, pos.z - 10, 1.0); + + let ray_origin = world_pos.xyz; + let ray_dir = u.camera_direction; + + var p = ray_origin; + var i = 0u; + var dist = 0.0; + + while i < 1000u { + dist = sdf(p); + if dist < 0.0001 { + break; + } + p += dist * ray_dir; + i += 1u; + } + + if dist < 0.0001 { + return shadow_fragment(p); + } + return vec4(1., 0., 0., 1.); +} diff --git a/src/shaders/iterate_uniforms.wgsl b/src/shaders/iterate_uniforms.wgsl index 602a7c4..249d276 100644 --- a/src/shaders/iterate_uniforms.wgsl +++ b/src/shaders/iterate_uniforms.wgsl @@ -33,6 +33,13 @@ struct Uniforms { de_coloring: u32, // 0 = classic colors, 1 = shadows shadow: u32, + // Use 3D raymarching rendering + dimension3: u32, + // camera direction vector + camera_direction: vec3, + // inverse of the camera's view-projection matrix, for reconstructing a + // world-space ray origin per pixel in the raymarcher + camera_inv_proj: mat4x4, }; // Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. @@ -146,10 +153,10 @@ fn shadow_color(normal: vec3) -> vec3 { for (var i = 0u; i < 16; i++) { let light_color = unpack4x8unorm(lights[i].color); if any(light_color != vec4(0)) { - light_count += 1; + light_count += 1; } - color += compute_light(normal, vec3( + 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;