From 2bdb2356c607d5fa8916742c2f6e259c2674ecd4 Mon Sep 17 00:00:00 2001 From: supersurviveur Date: Thu, 24 Sep 2026 19:45:17 +0200 Subject: [PATCH] perf: improve 3d ray marching performance --- CLAUDE.md | 7 ++++++ src/app.rs | 5 +++- src/shaders/colorize.wgsl | 53 ++++++++++++++++++++++++++------------- 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cd5c177..4017846 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,6 +204,13 @@ sample per pixel. When AA is on, `fs_refine` reads that texture and runs the reads the refined texture. PNG export (`fs_color`) still supersamples every pixel. +The 3D view (`colorize.wgsl::ray_marching`) sphere-traces the DE height +field straight from the data texture. It's cheap: rays start on the z = 0 +plane, and most hit within a few steps (about 4 on average). A min-height +mip pyramid (quadtree height-field tracing) was tried and measured about 3× +slower, because it needs about 12 costlier steps per ray. Don't reintroduce it. +Orbiting the camera only re-runs the colourise pass, never iteration. + While the user is actively panning/zooming, the app renders downscaled with AA off (`INTERACT_DOWNSCALE`) and snaps back to full resolution once input settles (`INTERACT_SETTLE`). diff --git a/src/app.rs b/src/app.rs index 662a8b3..9fa9a3e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2127,9 +2127,12 @@ impl FractalApp { if self.rendering_mode == 2 { if let Some(mt) = multi_touch { let t = mt.translation_delta; + // Orbiting only moves the camera, which the colourise pass + // handles alone, so (like mouse-drag orbiting) it doesn't count + // as interaction: that would drop to the low-res pass and + // re-iterate the fractal twice. 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 { let off = mt.center_pos - rect.center(); diff --git a/src/shaders/colorize.wgsl b/src/shaders/colorize.wgsl index 7c4f569..327e52e 100644 --- a/src/shaders/colorize.wgsl +++ b/src/shaders/colorize.wgsl @@ -68,6 +68,9 @@ fn fs_main(@builtin(position) pos: vec4) -> @location(0) vec4 { } } +// Colour of rays that miss the fractal's footprint. +const RAY_MISS: vec4 = vec4(1.0, 0.0, 0.0, 1.0); + // Per-frame constants of the raymarch, computed once per pixel in // `ray_marching` rather than on each of the up-to-100 `sdf` steps. struct MarchConsts { @@ -126,27 +129,43 @@ fn ray_marching(pos: vec4) -> vec4 { let ray_origin = world_pos.xyz; let ray_dir = u.camera_direction; - let z_intersect = ray_origin.z / ray_dir.z; - var p = ray_origin - ray_dir * z_intersect; - var i = 0u; - var dist = 0.0; + // Start where the ray crosses z = 0, the topmost possible surface (the + // camera pitch is clamped short of ±90°, so ray_dir.z > 0). + let start = ray_origin - ray_dir * (ray_origin.z / ray_dir.z); - let dist_threshold = 0.000001; - while i < 100u { - let from_origin = p - ray_origin; - if dot(from_origin, from_origin) > 9. { - break; - } - dist = sdf(p, k); + // The terrain only exists over the footprint x in [0, aspect], + // y in [0, 1]: clip the ray's xy to it up front, so rays that miss it cost + // nothing and the rest start marching at its edge. A huge finite 1/d on + // an axis the ray doesn't move along (top-down, during the 2D <-> 3D + // transition) keeps the slab maths finite. + let inv = select(1.0 / ray_dir.xy, vec2(1e30), abs(ray_dir.xy) < vec2(1e-20)); + let ta = -start.xy * inv; + let tb = (vec2(aspect_ratio, 1.0) - start.xy) * inv; + let t_leave = min(max(ta.x, tb.x), max(ta.y, tb.y)); + var t = max(max(min(ta.x, tb.x), min(ta.y, tb.y)), 0.0); + if t >= t_leave { + return RAY_MISS; + } + + // About 1/50 of a texel at typical sizes: tighter only adds steps + // without visibly moving the hit. + let dist_threshold = 0.00001; + var hit = false; + for (var i = 0u; i < 100u; i++) { + let dist = sdf(start + t * ray_dir, k); if dist < dist_threshold { + hit = true; + break; + } + t += dist; + // Past the footprint's far edge: nothing left to hit. + if t >= t_leave { break; } - p += dist * ray_dir; - i += 1u; } - - if dist < dist_threshold { - return shadow_fragment(p.xy * k.to_texel); + // Shade outside the loop, so its registers don't weigh on the march. + if !hit { + return RAY_MISS; } - return vec4(1., 0., 0., 1.); + return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel); }