From 63cee5d8b2cf28d7c1b979c61d634654433f810b Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Tue, 22 Sep 2026 18:33:37 +0200 Subject: [PATCH] fix: correct 3d rendering --- src/app.rs | 278 ++++++++++++++++++------------ src/camera.rs | 59 +++---- src/cli.rs | 12 ++ src/fractal/renderer.rs | 3 + src/lights.rs | 2 +- src/main.rs | 2 +- src/shaders/colorize.wgsl | 87 +++++++--- src/shaders/iterate_uniforms.wgsl | 2 + src/shaders/mandelbrot.wgsl | 18 ++ 9 files changed, 290 insertions(+), 173 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3788a60..b4aa37e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,6 +4,8 @@ use eframe::CreationContext; use eframe::egui_wgpu; #[cfg(target_arch = "wasm32")] use eframe::egui_wgpu::wgpu; +use glam::Vec4; +use glam::Vec4Swizzles; use crate::camera::Camera; #[cfg(not(target_arch = "wasm32"))] @@ -205,6 +207,12 @@ struct AnimState { zoom: bool, /// e-folds per second; positive zooms in, negative zooms out. zoom_speed: f32, + + /// Linear 2D <-> 3D transition progress in [0, 1], advanced at a constant + /// rate; `camera_state` is its smoothstep-eased value. + camera_progress: f32, + /// Camera state in [0, 1]: 0 = top-down 2D view, 1 = full 3D camera. + camera_state: f32, } impl Default for AnimState { @@ -229,6 +237,8 @@ impl Default for AnimState { lambda_angle: 0.0, zoom: false, zoom_speed: 0.5, + camera_progress: 0., + camera_state: 0., } } } @@ -350,6 +360,8 @@ pub struct FractalApp { /// The camera used to render 3D fractals camera: Camera, + /// Screen dimension. + screen_dim: [f32; 2], } /// Significant decimal digits to show for a center at the given precision (bits). @@ -473,6 +485,7 @@ impl FractalApp { zoom_edit, zoom_edited: false, camera: Camera::new(), + screen_dim: [0., 0.], } } @@ -497,6 +510,15 @@ impl FractalApp { } self.view = Self::default_view_for(self.mode, self.kind); } + if let Some(k) = cli.rendering_kind { + use crate::cli::RenderingKindArg; + + match k { + RenderingKindArg::Classic => self.rendering_mode = 0, + RenderingKindArg::Shadow => self.rendering_mode = 1, + RenderingKindArg::Dimension3 => self.rendering_mode = 2, + } + } if let Some(jc) = cli.julia { let p: Vec<&str> = jc.split(',').collect(); if let (Some(Ok(re)), Some(Ok(im))) = ( @@ -980,11 +1002,21 @@ impl FractalApp { 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.rendering_mode > 0)) as u32, - rendering_mode: self.rendering_mode, - camera_direction: self.camera.direction().to_array(), - camera_inv_proj: self.camera.orthographic().inverse().to_cols_array(), + rendering_mode: if self.anim.camera_state > 0.0 { + 2 + } else { + self.rendering_mode + }, + camera_direction: self.camera.direction(self.anim.camera_state).to_array(), + camera_inv_proj: self + .camera + .orthographic(self.anim.camera_state) + .inverse() + .to_cols_array(), + screen_dim: self.screen_dim, _pad: [0; _], _pad2: [0; _], + _pad3: [0; _], } } @@ -1479,13 +1511,33 @@ impl FractalApp { let julia_on = self.anim.julia && self.mode == FractalMode::Julia; let phoenix_on = self.anim.phoenix && self.kind == FractalKind::Phoenix; let lambda_on = self.anim.lambda && self.kind == FractalKind::Lambda; - if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) { - return; - } // Clamp dt so a stall (tab hidden, first frame) can't jump the animation. let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1); + // Animate the 2D <-> 3D camera transition over a fixed duration with + // smoothstep easing: it lands on exactly 0 or 1 (no asymptotic tail, + // no snap), so the shader's mode switch (`camera_state > 0.0` in + // `make_uniforms`) happens only once the camera is exactly top-down. + const CAMERA_DURATION: f32 = 0.6; // seconds + let target = if self.rendering_mode == 2 { 1.0 } else { 0.0 }; + let p = self.anim.camera_progress; + if p != target { + let step = dt as f32 / CAMERA_DURATION; + self.anim.camera_progress = if target > p { + (p + step).min(target) + } else { + (p - step).max(target) + }; + ui.ctx().request_repaint(); + } + let p = self.anim.camera_progress; + self.anim.camera_state = p * p * (3.0 - 2.0 * p); + + if !(self.anim.color || self.anim.zoom || julia_on || phoenix_on || lambda_on) { + return; + } + if self.anim.color { self.color_offset = (self.color_offset + self.anim.color_speed * dt as f32).rem_euclid(1.0); @@ -2032,7 +2084,7 @@ impl FractalApp { // 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 self.rendering_mode == 3 { + if self.rendering_mode == 2 { if let Some(mt) = multi_touch { let t = mt.translation_delta; if t.x != 0.0 || t.y != 0.0 { @@ -2040,7 +2092,25 @@ impl FractalApp { interacted = true; } if mt.zoom_delta != 1.0 { - self.camera.zoom(1.0 / mt.zoom_delta); + let ndc = (mt.center_pos.to_vec2() / rect.size()) * 2.; + let camera_ndc_pos = self.camera.orthographic(self.anim.camera_state).inverse() + * Vec4::new(ndc.x, ndc.y, 0., 1.); + let view_direction = self.camera.direction(self.anim.camera_state); + + let z_move = camera_ndc_pos.z / view_direction.z; + + let ndc_pos = camera_ndc_pos.xyz() + view_direction * -z_move; + + let pos = egui::Vec2::new(ndc_pos.x / self.camera.aspect_ratio, ndc_pos.y) + * rect.size() + - rect.center().to_vec2(); + + self.view.zoom_at_pixel( + pos.x as f64, + pos.y as f64, + height_px, + 1. / (mt.zoom_delta as f64), + ); interacted = true; } ui.ctx().request_repaint(); @@ -2048,7 +2118,6 @@ impl FractalApp { 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 { @@ -2083,10 +2152,24 @@ impl FractalApp { && rect.contains(pos) { let factor = (-scroll_y as f64 * 0.0015).exp(); - if self.rendering_mode == 3 { - self.camera.zoom(factor as f32); + let off = pos - rect.center(); + if self.rendering_mode == 2 { + let ndc = (off / rect.size()) * 2.; + let camera_ndc_pos = self.camera.orthographic(self.anim.camera_state).inverse() + * Vec4::new(ndc.x, ndc.y, 0., 1.); + let view_direction = self.camera.direction(self.anim.camera_state); + + let z_move = camera_ndc_pos.z / view_direction.z; + + let ndc_pos = camera_ndc_pos.xyz() + view_direction * -z_move; + + let pos = egui::Vec2::new(ndc_pos.x / self.camera.aspect_ratio, ndc_pos.y) + * rect.size() + - rect.center().to_vec2(); + + self.view + .zoom_at_pixel(pos.x as f64, pos.y as f64, height_px, factor); } else { - let off = pos - rect.center(); self.view .zoom_at_pixel(off.x as f64, off.y as f64, height_px, factor); } @@ -2103,51 +2186,19 @@ impl FractalApp { if !ui.ctx().egui_wants_keyboard_input() { let dt = ui.input(|i| i.stable_dt as f64).clamp(0.0, 0.1); - if self.rendering_mode == 3 { - 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), - ) - }); + let not_modifier_ctrl = ui.input(|i| !i.modifiers.ctrl) || self.rendering_mode != 2; + if self.rendering_mode == 2 { + let (look_l, look_r, look_u, look_d) = ui.input(|i| { + ( + i.key_down(egui::Key::ArrowLeft) && i.modifiers.ctrl, + i.key_down(egui::Key::ArrowRight) && i.modifiers.ctrl, + i.key_down(egui::Key::ArrowUp) && i.modifiers.ctrl, + i.key_down(egui::Key::ArrowDown) && i.modifiers.ctrl, + ) + }); // 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; - } + const LOOK_SPEED: f32 = 0.5; let mut dyaw = 0.0f32; let mut dpitch = 0.0f32; @@ -2165,70 +2216,64 @@ impl FractalApp { } 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 - { + if 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; - } + let (left, right, up, down, zoom_in, zoom_out) = ui.input(|i| { + ( + i.key_down(egui::Key::ArrowLeft) && not_modifier_ctrl, + i.key_down(egui::Key::ArrowRight) && not_modifier_ctrl, + i.key_down(egui::Key::ArrowUp) && not_modifier_ctrl, + i.key_down(egui::Key::ArrowDown) && not_modifier_ctrl, + i.key_down(egui::Key::Z), + i.key_down(egui::Key::S), + ) + }); - // 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(); - } + // 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 self.rendering_mode == 2 { + let cos = self.camera.yaw.cos() as f64; + let sin = self.camera.yaw.sin() as f64; + (dx, dy) = (dx * cos + sin * dy, -dx * sin + cos * dy); + } + 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)) { @@ -2323,11 +2368,16 @@ impl FractalApp { // down while interacting (the linear blit upsamples it to the widget). let ppp = ui.ctx().pixels_per_point(); let downscale = if interacting { INTERACT_DOWNSCALE } else { 1 }; - let size_px = [ + let mut size_px = [ (((rect.width() * ppp).round() as u32) / downscale).max(1), (((rect.height() * ppp).round() as u32) / downscale).max(1), ]; + if self.rendering_mode == 2 { + size_px = [size_px[0] * 2, size_px[1] * 2]; + } + + self.screen_dim = [rect.width(), rect.height()]; self.camera.set_aspect_ratio(aspect as f32); let mut uniforms = self.make_uniforms(aspect); if interacting { diff --git a/src/camera.rs b/src/camera.rs index e1e2f63..3dcc8b0 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -9,9 +9,7 @@ pub struct Camera { pub yaw: f32, pub pitch: f32, - /// Demi-hauteur du volume visible (remplace fov_y_radians) - ortho_height: f32, - aspect_ratio: f32, + pub aspect_ratio: f32, z_near: f32, z_far: f32, } @@ -21,8 +19,7 @@ impl Camera { Self { position: Vec3::new(0., 0., -1.), yaw: 0. * PI / 180., - pitch: 0. * PI / 180., - ortho_height: 1.0, + pitch: -30. * PI / 180., aspect_ratio: 1., z_near: 0.1, z_far: 100., @@ -33,19 +30,6 @@ impl Camera { 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) { @@ -54,33 +38,34 @@ impl Camera { 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, t: f32) -> glam::Mat4 { + let yaw = self.yaw * t; + let pitch = self.pitch * t; - 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; + let zoom = 0.5 * (1. + t); + // Orbit pivot: the center of the fractal texture, which the raymarcher's + // `sdf` lays out over world x ∈ [0, aspect], y ∈ [0, 1] on the z = 0 plane. + let view = glam::Mat4::from_translation(Vec3::new(0.5 * self.aspect_ratio, 0.5, 0.)) + * glam::Mat4::from_rotation_z(-yaw) + * glam::Mat4::from_rotation_x(-pitch) + * glam::Mat4::from_translation(self.position); glam::camera::lh::proj::directx::orthographic( - -half_width, - half_width, - -half_height, - half_height, + -self.aspect_ratio / 4. / zoom, + self.aspect_ratio / 4. / zoom, + -0.25 / zoom, + 0.25 / zoom, 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) + pub fn direction(&self, t: f32) -> glam::Vec3 { + let yaw = self.yaw * t; + let pitch = self.pitch * t; + + let forward = glam::Mat3::from_rotation_z(-yaw) + * glam::Mat3::from_rotation_x(-pitch) * glam::Vec3::Z; forward.normalize() diff --git a/src/cli.rs b/src/cli.rs index 308f0ee..ee35270 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,6 +21,10 @@ pub struct Cli { #[arg(long)] pub buddhabrot: bool, + /// Rendering mode to use. + #[arg(long)] + pub rendering_kind: Option, + /// Exponent for the Multibrot kind (z -> z^power + c), clamped to [2, 8]. #[arg(long)] pub power: Option, @@ -144,6 +148,14 @@ pub enum KindArg { ComplexMultibrot, } +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum RenderingKindArg { + Classic, + Shadow, + #[value(alias = "3d")] + Dimension3, +} + impl From for FractalKind { fn from(k: KindArg) -> Self { match k { diff --git a/src/fractal/renderer.rs b/src/fractal/renderer.rs index 2fcac4a..801a6ec 100644 --- a/src/fractal/renderer.rs +++ b/src/fractal/renderer.rs @@ -101,6 +101,9 @@ pub struct Uniforms { /// 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], + /// Screen dimension + pub screen_dim: [f32; 2], + pub _pad3: [u32; 2], } /// Offscreen textures for the two-pass render, recreated whenever the widget's diff --git a/src/lights.rs b/src/lights.rs index e45d331..9751c73 100644 --- a/src/lights.rs +++ b/src/lights.rs @@ -1,7 +1,7 @@ use std::f32::consts::PI; use bytemuck::{Pod, Zeroable}; -use egui::{Button, Color32, Ui}; +use egui::{Color32, Ui}; /// Maximum number of simultaneous lights. pub const MAX_LIGHT_COUNT: usize = 16; diff --git a/src/main.rs b/src/main.rs index e536f2a..c85666b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -124,7 +124,7 @@ fn main() { Ok(_) => loading.remove(), Err(e) => { loading.set_inner_html( - "

The app has crashed. See the developer console for details.

", + &format!("

The app has crashed.
{e:?}

"), ); log::error!("failed to start eframe: {e:?}"); } diff --git a/src/shaders/colorize.wgsl b/src/shaders/colorize.wgsl index 826bafc..4644e37 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -19,16 +19,31 @@ fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4 { return vec4(fullscreen_triangle_pos(idx), 0.0, 1.0); } -fn shadow_fragment(pos: vec3) -> vec4 { +fn shadow_fragment(pos: vec2) -> vec4 { let x = i32(pos.x); let y = i32(pos.y); + let size = textureDimensions(data_tex); if textureLoad(data_tex, vec2(x, y), 0).b != 0. { return vec4(0.1, 0.1, 0.1, 1.0); } else { + // Forward differences, except on the last column/row where x+1 / y+1 + // is off the texture: fall back to a backward difference, mirrored + // (h0 + (h0 - h[-1])) so the slope keeps the sign normal_from_heights + // expects — plugging h[-1] in directly would flip the normal there. 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; + var h1: f32; + if x + 1 < i32(size.x) { + h1 = textureLoad(data_tex, vec2(x + 1, y), 0).g; + } else { + h1 = 2.0 * h0 - textureLoad(data_tex, vec2(x - 1, y), 0).g; + } + var h2: f32; + if y + 1 < i32(size.y) { + h2 = textureLoad(data_tex, vec2(x, y + 1), 0).g; + } else { + h2 = 2.0 * h0 - textureLoad(data_tex, vec2(x, y - 1), 0).g; + } let normal = normal_from_heights(h0, h1, h2); return vec4(shadow_color(normal), 1.0); } @@ -38,7 +53,7 @@ fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { if u.shadow == 2u { return ray_marching(pos); } else if u.shadow == 1u { - return shadow_fragment(pos.xyz); + return shadow_fragment(pos.xy); } else { let d = textureLoad(data_tex, vec2(i32(pos.x), i32(pos.y)), 0); let ci = d.r; @@ -54,44 +69,76 @@ fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { } fn sdf(pos: vec3) -> f32 { - let texture_pos = vec2(i32(pos.x), i32(pos.y)); + let aspect_ratio = u.screen_dim.x / u.screen_dim.y; + let size = vec2(textureDimensions(data_tex)); + var texture_pos_f32 = vec2(pos.x * size.x / aspect_ratio, pos.y * size.y); + var texture_pos = vec2(i32(texture_pos_f32.x), i32(texture_pos_f32.y)); + texture_pos.x = clamp(texture_pos.x, 0, i32(size.x) - 1); + texture_pos.y = clamp(texture_pos.y, 0, i32(size.y) - 1); + + let to_texture = max(-min(texture_pos_f32, vec2(0.)), max(texture_pos_f32 - size, vec2(0.))); + let dist_to_texture = length(to_texture) / size.y; + let px = textureLoad(data_tex, texture_pos, 0); - let de = px.g; - let z = pos.z; + let de = (px.g / size.y) * 0.5; + // Height is measured toward -z, the side the camera sits on (it looks + // along +z), so the terrain is solid on +z: interior plateau at z = 0, + // exterior sloping away from the camera as `de` grows. + let signed_z = -pos.z; + let z = max(signed_z, 0.); + var d: f32; if px.b != 0. { - return 0.; + d = z; } else { - return sqrt(z * z + (de * de)); + d = min(sqrt(z * z + de * de), signed_z + 1. - exp(-de * 5.)); } + // Outside the texture footprint, `d` is the distance from the clamped + // point q on the footprint's edge. The terrain lies over the (convex) + // footprint, so |p - x|² ≥ |q - x|² + |p - q|² for every terrain point x: + // combine in quadrature (not by adding, which overshoots). p can't be in + // the solid out here, so a negative `d` counts as 0. + if dist_to_texture > 0. { + let d_pos = max(d, 0.); + return sqrt(d_pos * d_pos + dist_to_texture * dist_to_texture); + } + return d; } 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, + (pos.x / size.x) * 2. - 1., + (pos.y / size.y) * 2. - 1., ); - var world_pos = u.camera_inv_proj * vec4(in_texture, pos.z - 10, 1.0); + var world_pos = u.camera_inv_proj * vec4(in_texture, 0., 1.0); let ray_origin = world_pos.xyz; let ray_dir = u.camera_direction; - var p = ray_origin; + let z_intersect = ray_origin.z / ray_dir.z; + var p = ray_origin - ray_dir * z_intersect; var i = 0u; var dist = 0.0; - while i < 1000u { - dist = sdf(p); - if dist < 0.0001 { + let dist_threshold = 0.000001; + while i < 100u { + if length(p - ray_origin) > 3. { break; } - p += dist * ray_dir; - i += 1u; + dist = sdf(p); + if dist < dist_threshold { + break; + } + p += dist * ray_dir; + i += 1u; } - if dist < 0.0001 { - return shadow_fragment(p); + if dist < dist_threshold { + let aspect_ratio = u.screen_dim.x / u.screen_dim.y; + let size = vec2(textureDimensions(data_tex)); + let texture_pos_f32 = vec2(p.x * size.x / aspect_ratio, p.y * size.y); + return shadow_fragment(texture_pos_f32); } return vec4(1., 0., 0., 1.); } diff --git a/src/shaders/iterate_uniforms.wgsl b/src/shaders/iterate_uniforms.wgsl index dcdab06..c0f7da0 100644 --- a/src/shaders/iterate_uniforms.wgsl +++ b/src/shaders/iterate_uniforms.wgsl @@ -38,6 +38,8 @@ struct Uniforms { // inverse of the camera's view-projection matrix, for reconstructing a // world-space ray origin per pixel in the raymarcher camera_inv_proj: mat4x4, + // Screen dimensions + screen_dim: vec2 }; // Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id. diff --git a/src/shaders/mandelbrot.wgsl b/src/shaders/mandelbrot.wgsl index fcc761e..702c7a9 100644 --- a/src/shaders/mandelbrot.wgsl +++ b/src/shaders/mandelbrot.wgsl @@ -211,6 +211,24 @@ struct Sample { fn iterate_sample(offset: vec2, px: f32) -> Sample { let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia) + // Main cardioid / period-2 bulb bypass: those points never escape, so skip + // iterating them (they'd otherwise all burn the full max_iter). `offset` is + // relative to the reference center; the absolute c is recovered from the + // orbit itself, since X_1 = X_0^2 + C_ref = C_ref. That's only f32-accurate, + // so skip the test once a pixel is smaller than that error (deep zoom), + // where it could misclassify pixels right at the boundary. + if u.kind == KIND_MANDELBROT && u.is_julia == 0u && u.ref_len > 1u && px > 1e-6 { + let c = ref_orbit[1] + offset; + let xq = c.x - 0.25; + let q = xq * xq + c.y * c.y; + let in_cardioid = q * (q + xq) <= 0.25 * c.y * c.y; + let xb = c.x + 1.0; + let in_bulb = xb * xb + c.y * c.y <= 0.0625; + if in_cardioid || in_bulb { + return Sample(0.0, 1.0, false); // interior of the set + } + } + var step_add = offset; var e = vec2(0.0, 0.0); // Orbit derivative for distance estimation. For the set plane it is d/dc