perf: improve 3d ray marching performance

This commit is contained in:
2026-09-24 19:45:17 +02:00
parent 5206a22bdd
commit 2bdb2356c6
3 changed files with 47 additions and 18 deletions
+7
View File
@@ -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`).
+4 -1
View File
@@ -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();
+37 -18
View File
@@ -68,6 +68,9 @@ fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
}
}
// Colour of rays that miss the fractal's footprint.
const RAY_MISS: vec4<f32> = vec4<f32>(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<f32>) -> vec4<f32> {
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);
if dist < dist_threshold {
break;
}
p += dist * ray_dir;
i += 1u;
// 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<f32>(1e30), abs(ray_dir.xy) < vec2<f32>(1e-20));
let ta = -start.xy * inv;
let tb = (vec2<f32>(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 {
return shadow_fragment(p.xy * k.to_texel);
hit = true;
break;
}
return vec4<f32>(1., 0., 0., 1.);
t += dist;
// Past the footprint's far edge: nothing left to hit.
if t >= t_leave {
break;
}
}
// Shade outside the loop, so its registers don't weigh on the march.
if !hit {
return RAY_MISS;
}
return shadow_fragment((start.xy + t * ray_dir.xy) * k.to_texel);
}