feat: distance-estimation shading for crisp deep-zoom filaments
This commit is contained in:
@@ -34,3 +34,6 @@ opt-level = 1
|
|||||||
|
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
naga = { version = "30", features = ["wgsl-in"] }
|
||||||
|
|||||||
+15
-1
@@ -133,6 +133,9 @@ pub struct FractalApp {
|
|||||||
palette: u32,
|
palette: u32,
|
||||||
/// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work).
|
/// Supersample each pixel 2×2 for smoother edges (costs ~4× fragment work).
|
||||||
antialias: bool,
|
antialias: bool,
|
||||||
|
/// Distance-estimation shading: darkens toward the set boundary using the
|
||||||
|
/// orbit derivative, giving crisp filaments at deep zoom instead of speckle.
|
||||||
|
de_coloring: bool,
|
||||||
|
|
||||||
/// Reference orbit (`Z_n` as f32 pairs) for the current view.
|
/// Reference orbit (`Z_n` as f32 pairs) for the current view.
|
||||||
reference: Arc<Vec<[f32; 2]>>,
|
reference: Arc<Vec<[f32; 2]>>,
|
||||||
@@ -231,6 +234,7 @@ impl FractalApp {
|
|||||||
color_offset: 0.0,
|
color_offset: 0.0,
|
||||||
palette: 0,
|
palette: 0,
|
||||||
antialias: false,
|
antialias: false,
|
||||||
|
de_coloring: false,
|
||||||
reference: Arc::new(Vec::new()),
|
reference: Arc::new(Vec::new()),
|
||||||
generation: 0,
|
generation: 0,
|
||||||
ref_center_re,
|
ref_center_re,
|
||||||
@@ -296,6 +300,9 @@ impl FractalApp {
|
|||||||
if let Ok(spec) = std::env::var("MANDEL_VIEW") {
|
if let Ok(spec) = std::env::var("MANDEL_VIEW") {
|
||||||
app.apply_view_spec(&spec);
|
app.apply_view_spec(&spec);
|
||||||
}
|
}
|
||||||
|
if std::env::var("MANDEL_DE").is_ok() {
|
||||||
|
app.de_coloring = true;
|
||||||
|
}
|
||||||
if std::env::var("MANDEL_EXPORT").is_ok() {
|
if std::env::var("MANDEL_EXPORT").is_ok() {
|
||||||
app.export_requested = true;
|
app.export_requested = true;
|
||||||
}
|
}
|
||||||
@@ -573,7 +580,8 @@ impl FractalApp {
|
|||||||
kind: self.kind.shader_id(),
|
kind: self.kind.shader_id(),
|
||||||
power: self.power,
|
power: self.power,
|
||||||
dc_offset: self.dc_offset(),
|
dc_offset: self.dc_offset(),
|
||||||
_pad: [0, 0],
|
de_coloring: self.de_coloring as u32,
|
||||||
|
_pad: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,6 +865,12 @@ impl FractalApp {
|
|||||||
});
|
});
|
||||||
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
|
ui.checkbox(&mut self.antialias, "Antialiasing (2×2)")
|
||||||
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
|
.on_hover_text("Supersample each pixel for smoother edges (~4× slower).");
|
||||||
|
ui.checkbox(&mut self.de_coloring, "Distance shading")
|
||||||
|
.on_hover_text(
|
||||||
|
"Shade by distance to the set boundary (from the orbit derivative) \
|
||||||
|
for crisp filaments at deep zoom. Exact for Mandelbrot/Multibrot, \
|
||||||
|
approximate for Burning Ship/Tricorn.",
|
||||||
|
);
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
// Editable center coordinates. Shown at full precision; parsed
|
// Editable center coordinates. Shown at full precision; parsed
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ pub struct Uniforms {
|
|||||||
/// or reused reference (computed at a slightly different center) still maps
|
/// or reused reference (computed at a slightly different center) still maps
|
||||||
/// correctly. Added to every pixel's per-pixel offset.
|
/// correctly. Added to every pixel's per-pixel offset.
|
||||||
pub dc_offset: [f32; 2],
|
pub dc_offset: [f32; 2],
|
||||||
|
/// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||||
|
pub de_coloring: u32,
|
||||||
/// Padding to a 16-byte multiple (uniform buffer requirement).
|
/// Padding to a 16-byte multiple (uniform buffer requirement).
|
||||||
pub _pad: [u32; 2],
|
pub _pad: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Offscreen texture the fractal is rendered into, plus the bind group used to
|
/// Offscreen texture the fractal is rendered into, plus the bind group used to
|
||||||
|
|||||||
+60
-11
@@ -27,6 +27,8 @@ struct Uniforms {
|
|||||||
// Exponent for the Multibrot kind.
|
// Exponent for the Multibrot kind.
|
||||||
power: u32,
|
power: u32,
|
||||||
dc_offset: vec2<f32>,
|
dc_offset: vec2<f32>,
|
||||||
|
// 0 = escape-time coloring, 1 = distance-estimation shading.
|
||||||
|
de_coloring: u32,
|
||||||
};
|
};
|
||||||
|
|
||||||
const KIND_MANDELBROT: u32 = 0u;
|
const KIND_MANDELBROT: u32 = 0u;
|
||||||
@@ -130,6 +132,23 @@ fn advance_delta(z: vec2<f32>, e: vec2<f32>) -> vec2<f32> {
|
|||||||
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot
|
return 2.0 * cmul(z, e) + cmul(e, e); // Mandelbrot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derivative f'(Z) of the iteration map at the full value Z, used to propagate
|
||||||
|
// the orbit derivative for distance-estimation shading. Exact for the
|
||||||
|
// holomorphic kinds (z^2 -> 2Z, z^p -> p Z^{p-1}); for the non-holomorphic
|
||||||
|
// Burning Ship / Tricorn we use |f'| ~ |2Z|, which keeps the DE magnitude close
|
||||||
|
// enough to de-speckle filaments.
|
||||||
|
fn fprime(z: vec2<f32>) -> vec2<f32> {
|
||||||
|
if (u.kind == KIND_MULTIBROT) {
|
||||||
|
let p = clamp(u.power, 2u, 8u);
|
||||||
|
var zk = vec2<f32>(1.0, 0.0); // Z^0
|
||||||
|
for (var k: u32 = 1u; k < p; k = k + 1u) {
|
||||||
|
zk = cmul(zk, z); // -> Z^{p-1}
|
||||||
|
}
|
||||||
|
return f32(p) * zk;
|
||||||
|
}
|
||||||
|
return 2.0 * z;
|
||||||
|
}
|
||||||
|
|
||||||
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
// Smooth cyclic palettes (Inigo Quilez cosine palettes), selected by id.
|
||||||
fn palette(id: u32, t: f32) -> vec3<f32> {
|
fn palette(id: u32, t: f32) -> vec3<f32> {
|
||||||
if (id == 4u) {
|
if (id == 4u) {
|
||||||
@@ -155,14 +174,20 @@ fn palette(id: u32, t: f32) -> vec3<f32> {
|
|||||||
// step (delta starts at 0); for Julia it is the z-plane offset that seeds the
|
// step (delta starts at 0); for Julia it is the z-plane offset that seeds the
|
||||||
// initial delta (c is fixed, so nothing is added per step). Interior pixels
|
// initial delta (c is fixed, so nothing is added per step). Interior pixels
|
||||||
// return black.
|
// return black.
|
||||||
fn shade(offset: vec2<f32>) -> vec3<f32> {
|
fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
|
||||||
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
|
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
|
||||||
|
|
||||||
var step_add = offset;
|
var step_add = offset;
|
||||||
var e = vec2<f32>(0.0, 0.0);
|
var e = vec2<f32>(0.0, 0.0);
|
||||||
|
// Orbit derivative for distance estimation. For the set plane it is d/dc
|
||||||
|
// (starts at 0, gains +1 each step); for Julia it is d/dz0 (starts at 1).
|
||||||
|
var dz = vec2<f32>(0.0, 0.0);
|
||||||
|
var dz_seed = vec2<f32>(1.0, 0.0);
|
||||||
if (u.is_julia != 0u) {
|
if (u.is_julia != 0u) {
|
||||||
step_add = vec2<f32>(0.0, 0.0);
|
step_add = vec2<f32>(0.0, 0.0);
|
||||||
e = offset;
|
e = offset;
|
||||||
|
dz = vec2<f32>(1.0, 0.0);
|
||||||
|
dz_seed = vec2<f32>(0.0, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
var m: u32 = 0u; // reference index; invariant: y_n = X[m] + e
|
var m: u32 = 0u; // reference index; invariant: y_n = X[m] + e
|
||||||
@@ -183,6 +208,12 @@ fn shade(offset: vec2<f32>) -> vec3<f32> {
|
|||||||
break; // interior
|
break; // interior
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate the derivative of the full orbit (unaffected by rebasing,
|
||||||
|
// which only re-expresses the same value). Only when DE is enabled.
|
||||||
|
if (u.de_coloring != 0u) {
|
||||||
|
dz = cmul(fprime(z), dz) + dz_seed;
|
||||||
|
}
|
||||||
|
|
||||||
// Advance the delta by this fractal's formula (+ dc for the set plane).
|
// Advance the delta by this fractal's formula (+ dc for the set plane).
|
||||||
e = advance_delta(xm, e) + step_add;
|
e = advance_delta(xm, e) + step_add;
|
||||||
m = m + 1u;
|
m = m + 1u;
|
||||||
@@ -209,8 +240,10 @@ fn shade(offset: vec2<f32>) -> vec3<f32> {
|
|||||||
return vec3<f32>(0.0, 0.0, 0.0); // interior of the set
|
return vec3<f32>(0.0, 0.0, 0.0); // interior of the set
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let z2 = dot(z, z);
|
||||||
|
|
||||||
// Continuous (smooth) iteration count.
|
// Continuous (smooth) iteration count.
|
||||||
let log_zn = 0.5 * log(max(dot(z, z), 1.0));
|
let log_zn = 0.5 * log(max(z2, 1.0));
|
||||||
let nu = log2(log_zn / log(2.0));
|
let nu = log2(log_zn / log(2.0));
|
||||||
let smooth_i = f32(n) + 1.0 - nu;
|
let smooth_i = f32(n) + 1.0 - nu;
|
||||||
|
|
||||||
@@ -218,22 +251,38 @@ fn shade(offset: vec2<f32>) -> vec3<f32> {
|
|||||||
// varies smoothly instead of aliasing into speckle.
|
// varies smoothly instead of aliasing into speckle.
|
||||||
let ci = sqrt(max(smooth_i, 0.0));
|
let ci = sqrt(max(smooth_i, 0.0));
|
||||||
let t = fract(ci * u.color_scale + u.color_offset);
|
let t = fract(ci * u.color_scale + u.color_offset);
|
||||||
return palette(u.palette_id, t);
|
var col = palette(u.palette_id, t);
|
||||||
|
|
||||||
|
if (u.de_coloring != 0u) {
|
||||||
|
// Exterior distance estimate (complex-plane units): |z|·ln|z| / |dz|.
|
||||||
|
// Divided by the pixel footprint it becomes a distance in pixels; we
|
||||||
|
// darken toward the boundary (< ~1 px away) so filaments stay crisp
|
||||||
|
// instead of aliasing into speckle. If |dz| overflowed, de -> 0 and the
|
||||||
|
// boundary simply reads as dark, which is the correct limit.
|
||||||
|
let zmag = sqrt(max(z2, 1.0));
|
||||||
|
let dzmag = sqrt(max(dot(dz, dz), 1e-20));
|
||||||
|
let de = zmag * log(zmag) / dzmag;
|
||||||
|
let de_px = de / max(px, 1e-30);
|
||||||
|
col = col * clamp(de_px, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
return col;
|
||||||
}
|
}
|
||||||
|
|
||||||
@fragment
|
@fragment
|
||||||
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
||||||
let base = in.centered * u.span + u.dc_offset;
|
let base = in.centered * u.span + u.dc_offset;
|
||||||
|
|
||||||
let aa = max(u.aa_level, 1u);
|
// Screen-space complex-units-per-pixel. Derivatives must be evaluated in
|
||||||
if (aa <= 1u) {
|
// uniform control flow, so take them here; used to place sub-pixel AA
|
||||||
return vec4<f32>(shade(base), 1.0);
|
// samples and to convert the distance estimate into pixels.
|
||||||
}
|
|
||||||
|
|
||||||
// Screen-space complex-units-per-pixel, used to place sub-pixel samples.
|
|
||||||
// Derivatives must be evaluated in uniform control flow, so take them here.
|
|
||||||
let dx = dpdx(base);
|
let dx = dpdx(base);
|
||||||
let dy = dpdy(base);
|
let dy = dpdy(base);
|
||||||
|
let px = length(abs(dx) + abs(dy)); // ~ complex units per pixel (footprint)
|
||||||
|
|
||||||
|
let aa = max(u.aa_level, 1u);
|
||||||
|
if (aa <= 1u) {
|
||||||
|
return vec4<f32>(shade(base, px), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
var acc = vec3<f32>(0.0, 0.0, 0.0);
|
var acc = vec3<f32>(0.0, 0.0, 0.0);
|
||||||
let inv = 1.0 / f32(aa);
|
let inv = 1.0 / f32(aa);
|
||||||
@@ -242,7 +291,7 @@ fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
|
|||||||
// Sample centers evenly spread across the pixel, jitter in (-0.5, 0.5).
|
// Sample centers evenly spread across the pixel, jitter in (-0.5, 0.5).
|
||||||
let jx = (f32(sx) + 0.5) * inv - 0.5;
|
let jx = (f32(sx) + 0.5) * inv - 0.5;
|
||||||
let jy = (f32(sy) + 0.5) * inv - 0.5;
|
let jy = (f32(sy) + 0.5) * inv - 0.5;
|
||||||
acc = acc + shade(base + jx * dx + jy * dy);
|
acc = acc + shade(base + jx * dx + jy * dy, px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return vec4<f32>(acc / f32(aa * aa), 1.0);
|
return vec4<f32>(acc / f32(aa * aa), 1.0);
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
//! Static validation of the WGSL shaders. `cargo build` does not type-check
|
||||||
|
//! WGSL (that happens at pipeline creation), so this parses and validates each
|
||||||
|
//! shader with the same `naga` version wgpu uses — catching shader errors
|
||||||
|
//! without needing a GPU or a display.
|
||||||
|
|
||||||
|
fn validate(name: &str, src: &str) {
|
||||||
|
let module = match naga::front::wgsl::parse_str(src) {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(e) => panic!("{name}: WGSL parse error:\n{}", e.emit_to_string(src)),
|
||||||
|
};
|
||||||
|
let mut validator = naga::valid::Validator::new(
|
||||||
|
naga::valid::ValidationFlags::all(),
|
||||||
|
naga::valid::Capabilities::all(),
|
||||||
|
);
|
||||||
|
if let Err(e) = validator.validate(&module) {
|
||||||
|
panic!("{name}: WGSL validation error:\n{}", e.emit_to_string(src));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mandelbrot_shader_is_valid() {
|
||||||
|
validate(
|
||||||
|
"mandelbrot.wgsl",
|
||||||
|
include_str!("../src/shaders/mandelbrot.wgsl"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blit_shader_is_valid() {
|
||||||
|
validate("blit.wgsl", include_str!("../src/shaders/blit.wgsl"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user