perf: editing theme doesn't require a complete reredenring

This commit is contained in:
2026-09-15 21:14:14 +02:00
parent a5b26ce738
commit fbe7f4da13
4 changed files with 426 additions and 73 deletions
+78
View File
@@ -0,0 +1,78 @@
// Colourise pass: map the iteration pass's per-pixel escape data (from
// `mandelbrot.wgsl`'s `fs_data`) through the palette. This is the only
// color-dependent step, so changing the palette / colour scale / offset (e.g.
// colour cycling) re-runs just this cheap pass — the expensive perturbation
// iteration in the data texture is reused untouched.
//
// The data texture holds, per texel: R = ci (palette parameter), G = DE
// darkening factor, B = interior fraction (for boundary anti-aliasing). It is
// the same resolution as this pass's target, so we read it with `textureLoad`
// at the fragment's integer pixel coordinate (nearest — iteration data must not
// be linearly filtered across escape boundaries).
// Must match `Uniforms` in mandelbrot.wgsl / the Rust `Uniforms` struct.
struct Uniforms {
span: vec2<f32>,
max_iter: u32,
ref_len: u32,
color_offset: f32,
color_scale: f32,
bailout_sq: f32,
is_julia: u32,
palette_id: u32,
aa_level: u32,
kind: u32,
power: u32,
dc_offset: vec2<f32>,
phoenix_p: vec2<f32>,
de_coloring: u32,
};
@group(0) @binding(0) var<uniform> u: Uniforms;
@group(0) @binding(1) var data_tex: texture_2d<f32>;
// Smooth cyclic palettes (Inigo Quilez cosine palettes). Must match the palette
// in mandelbrot.wgsl.
fn palette(id: u32, t: f32) -> vec3<f32> {
if (id == 4u) {
return vec3<f32>(t, t, t); // grayscale
}
let a = vec3<f32>(0.5, 0.5, 0.5);
let b = vec3<f32>(0.5, 0.5, 0.5);
var c = vec3<f32>(1.0, 1.0, 1.0);
var d = vec3<f32>(0.00, 0.10, 0.20); // 0: amber / blue
if (id == 1u) {
d = vec3<f32>(0.00, 0.33, 0.67); // rainbow
} else if (id == 2u) {
d = vec3<f32>(0.30, 0.20, 0.20); // warm ember
} else if (id == 3u) {
c = vec3<f32>(1.0, 1.0, 0.5);
d = vec3<f32>(0.80, 0.90, 0.30); // lime / magenta
}
return a + b * cos(6.28318530718 * (c * t + d));
}
@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> @builtin(position) vec4<f32> {
var verts = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
return vec4<f32>(verts[idx], 0.0, 1.0);
}
@fragment
fn fs_main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let d = textureLoad(data_tex, vec2<i32>(i32(pos.x), i32(pos.y)), 0);
let ci = d.r;
let de = d.g;
let interior_frac = d.b;
let t = fract(ci * u.color_scale + u.color_offset);
var col = palette(u.palette_id, t) * 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<f32>(col, 1.0);
}
+75 -27
View File
@@ -193,12 +193,21 @@ fn palette(id: u32, t: f32) -> vec3<f32> {
return a + b * cos(6.28318530718 * (c * t + d));
}
// Perturbation iterate + color a single sample. `offset` is the per-pixel
// offset in complex units. For Mandelbrot it is the c-plane offset added every
// 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
// return black.
fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
// Escape data for one sample: `ci` is the (color-independent) palette parameter,
// `de` the distance-estimate darkening factor in [0,1], `escaped` false for the
// interior of the set. Splitting iteration from coloring lets a colour change be
// remapped cheaply (see the colourise pass) without re-iterating.
struct Sample {
ci: f32,
de: f32,
escaped: bool,
};
// Perturbation iterate a single sample. `offset` is the per-pixel offset in
// complex units. For Mandelbrot it is the c-plane offset added every 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).
fn iterate_sample(offset: vec2<f32>, px: f32) -> Sample {
let z0 = ref_orbit[0]; // reference start (0 for Mandelbrot, center for Julia)
var step_add = offset;
@@ -282,7 +291,7 @@ fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
}
if (!escaped) {
return vec3<f32>(0.0, 0.0, 0.0); // interior of the set
return Sample(0.0, 1.0, false); // interior of the set
}
let z2 = dot(z, z);
@@ -295,9 +304,8 @@ fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
// sqrt compresses the huge iteration counts of deep zooms so the palette
// varies smoothly instead of aliasing into speckle.
let ci = sqrt(max(smooth_i, 0.0));
let t = fract(ci * u.color_scale + u.color_offset);
var col = palette(u.palette_id, t);
var de = 1.0;
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
@@ -306,37 +314,77 @@ fn shade(offset: vec2<f32>, px: f32) -> vec3<f32> {
// 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);
let d = zmag * log(zmag) / dzmag;
de = clamp(d / max(px, 1e-30), 0.0, 1.0);
}
return col;
return Sample(ci, de, true);
}
@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
let base = in.centered * u.span + u.dc_offset;
// Map a sample's escape data through the palette (+ DE darkening). This is the
// only color-dependent step, so it can be redone without re-iterating. Interior
// samples are black.
fn color_sample(s: Sample) -> vec3<f32> {
if (!s.escaped) {
return vec3<f32>(0.0, 0.0, 0.0);
}
let t = fract(s.ci * u.color_scale + u.color_offset);
return palette(u.palette_id, t) * s.de;
}
// Screen-space complex-units-per-pixel. Derivatives must be evaluated in
// uniform control flow, so take them here; used to place sub-pixel AA
// samples and to convert the distance estimate into pixels.
// Iteration pass: write per-pixel escape data (color-independent) so a colour
// change is remapped by the cheap colourise pass without re-iterating.
// R = ci (palette parameter), G = DE factor, B = interior fraction (for AA).
// AA is grid-supersampled here; the interior fraction lets the colourise pass
// anti-alias the set boundary (blend toward black) after the fact.
@fragment
fn fs_data(in: VsOut) -> @location(0) vec4<f32> {
let base = in.centered * u.span + u.dc_offset;
let dx = dpdx(base);
let dy = dpdy(base);
let px = length(abs(dx) + abs(dy)); // ~ complex units per pixel (footprint)
let px = length(abs(dx) + abs(dy));
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);
let inv = 1.0 / f32(aa);
var ci_sum = 0.0;
var de_sum = 0.0;
var escaped_n = 0u;
for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) {
for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) {
// Sample centers evenly spread across the pixel, jitter in (-0.5, 0.5).
let jx = (f32(sx) + 0.5) * inv - 0.5;
let jy = (f32(sy) + 0.5) * inv - 0.5;
acc = acc + shade(base + jx * dx + jy * dy, px);
let s = iterate_sample(base + jx * dx + jy * dy, px);
if (s.escaped) {
ci_sum = ci_sum + s.ci;
de_sum = de_sum + s.de;
escaped_n = escaped_n + 1u;
}
}
}
let total = f32(aa * aa);
let ci_avg = select(0.0, ci_sum / f32(escaped_n), escaped_n > 0u);
let de_avg = select(1.0, de_sum / f32(escaped_n), escaped_n > 0u);
let interior_frac = 1.0 - f32(escaped_n) / total;
return vec4<f32>(ci_avg, de_avg, interior_frac, 1.0);
}
// Combined iterate + colour in a single pass, for PNG export (which never needs
// incremental recolouring). The interactive path uses fs_data + the colourise
// pass so colour changes skip iteration.
@fragment
fn fs_color(in: VsOut) -> @location(0) vec4<f32> {
let base = in.centered * u.span + u.dc_offset;
let dx = dpdx(base);
let dy = dpdy(base);
let px = length(abs(dx) + abs(dy));
let aa = max(u.aa_level, 1u);
let inv = 1.0 / f32(aa);
var acc = vec3<f32>(0.0, 0.0, 0.0);
for (var sy: u32 = 0u; sy < aa; sy = sy + 1u) {
for (var sx: u32 = 0u; sx < aa; sx = sx + 1u) {
let jx = (f32(sx) + 0.5) * inv - 0.5;
let jy = (f32(sy) + 0.5) * inv - 0.5;
acc = acc + color_sample(iterate_sample(base + jx * dx + jy * dy, px));
}
}
return vec4<f32>(acc / f32(aa * aa), 1.0);