Skip to the plate
FORMA PUBLIC DOMAIN GENERATIVE ATLAS / ED. 0.28
Plate 150, Tetrabrot: a still of the escape time / bicomplex slice plate as the atlas renders it, in the fractals accent.

PL. 150  ·  FRACTALS / ESCAPE TIME / BICOMPLEX SLICE

Tetrabrot

Dominic Rochon, 2000

OPEN THE LIVE PLATE ▸

DEFINITION

ηₙ₊₁ = ηₙ² + c  in ℂ₂,   η₀ = 0
c = p + q·i₁ + y·i₂ = (p + (q−y)i₁)·γ + (p + (q+y)i₁)·γ̄
c ∈ T  ⟺  p + (q−y)i₁ ∈ M  and  p + (q+y)i₁ ∈ M
γ = (1+j₁)/2,  γ̄ = (1−j₁)/2,  j₁ = i₁i₂,  j₁² = +1

NOTES

The bicomplex numbers are the complex numbers built a second time. A bicomplex number is z1 + z2·i2, where z1 and z2 are ordinary complex numbers in i1 and i2 is a second square root of minus one that commutes with the first. Their product i1·i2 is written j1, and it squares to PLUS one, which is what makes this four-real-dimensional algebra behave nothing like the quaternions: it is commutative, it has zero divisors, and it carries a pair of idempotent elements, gamma = (1 + j1)/2 and its conjugate (1 - j1)/2, each equal to its own square and multiplying together to zero. Every bicomplex number splits along that pair as (z1 - z2·i1)·gamma + (z1 + z2·i1)·gamma-bar, and written that way, multiplication runs term by term. So squaring and adding never mixes the two halves. Iterate eta to eta squared plus c from zero, exactly as the Mandelbrot set is built, and a bicomplex orbit is two ordinary complex orbits running side by side: it stays bounded precisely when both of them do. Rochon published that generalisation in 2000 and named the three-dimensional shadow of it the Tetrabrot, the points p + q·i1 + y·i2 with the fourth coordinate held at zero. Read the split at such a point and the two halves come out as p + (q - y)i1 and p + (q + y)i1 — one complex number carried down and up the imaginary axis by y. So a horizontal cross-section of this solid at height y is the intersection of two copies of the ordinary Mandelbrot set, one translated down by y and one translated up by y, and drawing it needs no bicomplex arithmetic at all. That is Lemma 3 of the 2000 paper, and it is what this plate runs: two plain Mandelbrot escape tests per pixel, at q - y and at q + y, coloured by whichever of the two leaves the disc of radius two first. Leaving first is the honest criterion rather than a shortcut, because the bicomplex set sits inside the product of two such discs and departs it the moment either half does. The smoothing is the one mandelbrot (PL. 13) and julia (PL. 14) already share, with the larger of the two component moduli standing in for the single complex one. At y = 0 the two halves are the same number, so the plate does not merely resemble the Mandelbrot set — it computes it, by the identical recursion, and that was checked at the orbit and at the pixel rather than asserted. What moves is the height of the cut. It starts at zero, where the picture is the Mandelbrot set, and climbs: the cusp of the cardioid opens and rounds off, the period-two bulb comes away as a separate island, the whole figure squares up into a lens with four wings, and everything shrinks. The family is symmetric, the section at minus y being the same set as the section at plus y, since the Mandelbrot set is symmetric about the real axis and the two translations merely exchange. And it ends. The section is empty once the height passes m, the greatest imaginary part any point of the Mandelbrot set has, because beyond that the two translated copies no longer overlap anywhere. Measured here rather than quoted, by exterior distance certification and then by solving for the pre-periodic point itself: m = 1.1227570636, reached at c = -0.2071078671 + 1.1227570636i, and as the height climbs to it the whole section collapses onto the single real point -0.2071078671. Why bicomplex and not quaternion is the interesting part, and this atlas holds both. Bedding and Briggs showed in 1995 that iterating q squared plus c over the quaternions with c complex adds nothing: every orbit stays inside a complex plane, so the quaternion Julia sets of that family are the plane ones spun about an axis and the Mandelbrot set of it is a solid of revolution. PL. 138 draws a quaternion Julia set with its constant pushed off the complex plane for that exact reason, since that is where the picture becomes genuinely four-dimensional. Bicomplex space is the other escape route, and it is the one with theorems: the four-dimensional set is proved connected, it satisfies a bicomplex version of the Fatou-Julia theorem, and the tower above it is charted. The tricomplex Mandelbrot set one level up has exactly eight distinct three-dimensional principal slices, of which this is the first, and two of the other seven are Platonic solids — the Airbrot is a regular octahedron and the Firebrot a regular tetrahedron, both assembled out of the hyperbolic Mandelbrot set, which is a filled square. A cube turns up as well, in a second family of three-dimensional slices of the same object. And adding further complex units buys no new shape: every principal three-dimensional slice of a multicomplex Mandelbrot set is a tricomplex one up to an affine map. One thing about this solid is still open, and it is a good question rather than a gap: the four-dimensional bicomplex set is proved connected in the same 2000 paper, and yet the three-dimensional slice drawn here is conjectured NOT to be. A piece of it near p = -1.39 looks detached, and the conjecture is reduced there to two small regions of the ordinary Mandelbrot set being empty and two named points of it being occupied — which is checkable, and was checked.

PROVENANCE

Origin
Dominic Rochon, "A generalized Mandelbrot set for bicomplex numbers", Fractals 8(4), 2000, 355-368, doi:10.1142/S0218348X0000041X. Verified against the live Crossref record on 25 August 2026 on every field this plate states: title, sole author, container (Fractals), volume 08, issue 04, pages 355-368, year 2000, type journal-article. The name Tetrabrot is from this paper, its Definition 4, not a later coinage. The algebra is older: bicomplex numbers are the n = 2 case of the multicomplex numbers Corrado Segre introduced in 1892, and James Cockle had reached an isomorphic system, the tessarines, from 1848.
Read, not only cited
Both load-bearing papers were read first-hand. The 2000 paper was read from the copy the author posts at 3dfractals.com, whose bibliographic details match the Crossref record field for field; its Lemma 3, the proof of that lemma, Theorem 2, Theorem 6 and Conjecture 1 are taken from the paper rather than from any secondary account. Vallieres and Rochon 2022 (Mathematics 10(3), article 482, doi:10.3390/math10030482, open access) was read the same way and is the more approachable of the two: it restates Lemma 3 as its Proposition 2, credited back to the 2000 paper, and carries the octahedron, the tetrahedron and the cube. This is said plainly because the atlas distinguishes what was read from what was verified bibliographically -- the four supporting papers named below had only the second kind of check.
The characterization this plate draws
Lemma 3 of Rochon 2000, in its own notation: T = union over y in [-m, m] of {[(M1 - y i1) intersect (M1 + y i1)] + y i2}, where m := sup{q in R : there exists p in R with p + q i1 in M1}. Vallieres and Rochon 2022 give it as Proposition 2. The mechanism is the idempotent representation -- identity (2) of the 2022 paper, w = (z1 - z2 i1) gamma + (z1 + z2 i1) gamma-bar -- in which bicomplex multiplication is term by term, so the iteration decouples into two complex ones and boundedness of the pair is boundedness of each. Nothing was taken from any renderer, repository or sketch: the implementation is that lemma written out as two escape loops.
Checked, not just plotted
Six things, all run against the shipped draw() and the shipped shader body rather than a stand-in. First, the decoupling itself as an identity: one full bicomplex step, coded independently on the pair of complex coordinates from the product rule, against one step of each decoupled complex orbit -- 20,000 random states, 80,000 components, maximum relative difference 3.55e-15, which is machine epsilon. Second, the same along orbits: 4,000 random triples, agreement to 3.44e-12 over the first twelve steps, and the bounded-or-not verdict that decides the picture agreeing on 4,000 of 4,000 at 300 steps. Third, the y = 0 identity at orbit level, against an independently written plain z squared plus c iteration over 20,000 random points of the plate window: escape counts and interior verdicts identical on all 20,000, final squared modulus identical to the last bit. Fourth, the same identity at image level: the shipped draw() at y = 0 diffed against a standalone Mandelbrot renderer built fresh from PL. 13 own escape and smoothing formulas at the identical window, giving 0 differing channels of 58,650 at 890 by 600, 0 of 65,280 at 300 by 225 and 0 of 69,870 at 1240 by 1000. Fifth, the symmetry the note states, rendered at plus and minus y for five heights, pixel-identical each time over 293,250 channels. Sixth, the two rendering paths: a statement-for-statement transliteration of the shader body against the JS path over 5,865,000 channels across 20 parameter tuples and 5 heights, zero differing -- both sides in float64 here, since node cannot run GLSL, so the float32 slack the shader itself carries is outside what this check can claim. The value of m in the note was measured twice by methods that do not sample a grid, because the top of the Mandelbrot set is a filament of zero area and a grid climb finds whichever tip it starts under: an exterior-distance certification stable to twelve figures while its tolerance fell five orders of magnitude, and Newton on the pre-periodic condition z_{k+p} = z_k, which converges at pre-period 2 and period 13 to -0.2071078670939677 + 1.1227570636325974i. The published northernmost point of the Mandelbrot set agrees with that on every digit measured, and was consulted only afterwards. The collapse of the section to that one real part as the height reaches m follows from m itself, and the certification confirms it: on that row the walk finds exactly one place where the clearance collapses, against fifteen at height 1.10 and sixty at 0.7186.
The connectedness question, and what was checked of it
The bicomplex set M2 is proved connected in Theorem 2 of the 2000 paper. Conjecture 1 of the same paper says the Tetrabrot, its three-dimensional slice, is NOT -- a piece near p = -1.39 appears detached in the divergence-layer renders. Theorem 6 turns that into a conditional statement about the ordinary Mandelbrot set: if two small regions, spanning real parts -1.3939 to -1.3893 and imaginary parts 0.0683 to 0.0848 and 0.1259 to 0.1304, are disjoint from the Mandelbrot set, and the two points -1.391816306 + 0.129472959i and -1.392873019 + 0.077172405i are inside it, then the Tetrabrot is unconnected. Both halves were checked here. The disjointness was certified rather than sampled: each region was covered by disks the Douady-Hubbard distance estimator and the Koebe one-quarter theorem prove contain no point of the set, and all six pieces came out covered with minimum clearances between 2.5e-4 and 1.5e-3 against grid half-diagonals between 9.6e-6 and 1.6e-5, so the disks genuinely overlap rather than leaving gaps. The two points stay bounded through twenty million iterations, which is evidence rather than proof, since membership of the Mandelbrot set is only semi-decidable in the escaping direction. Nothing in the later papers in this chain records the conjecture as settled, so the note says conjectured.
The family, and the one it is not
The supporting results the note leans on, each verified against Crossref on 25 August 2026 on title, authors, venue, volume and year, and none of them read in full: the tricomplex Mandelbrot set has exactly eight distinct principal three-dimensional slices (Parise and Rochon, Nonlinear Dynamics 82, 2015, 157-171, doi:10.1007/s11071-015-2146-6); the Airbrot is a regular octahedron (Garant-Pelletier and Rochon, Fractals 17(3), 2009, 241-255, doi:10.1142/S0218348X09004326); three complex units are enough, since every principal three-dimensional slice of a multicomplex Multibrot set is equivalent to a tricomplex one up to an affine map (Brouillette and Rochon, Advances in Applied Clifford Algebras 29(3), 2019, article 39, doi:10.1007/s00006-019-0956-1). The Firebrot being a regular tetrahedron of edge root two over two, and the cube appearing among the idempotent slices, are Theorem 3 and Section 4 of the 2022 paper, which was read. The hyperbolic Mandelbrot set being a filled square was first reported by Senn (American Journal of Physics 58(10), 1990, 1018, doi:10.1119/1.16288) and proved by Metzler (American Journal of Physics 62(9), 1994, 813-814, doi:10.1119/1.17465), who gives it as the set of (a, b) with |a + 7/8| + |b| at most 9/8. The one it is not: this is not the quaternion Mandelbrot set. Bedding and Briggs, "Iteration of quaternion maps", International Journal of Bifurcation and Chaos 5(3), 1995, 877-881, doi:10.1142/S0218127495000661, established that q squared plus c with c complex keeps every orbit inside a complex plane, so that family is plane dynamics rotated and holds nothing new -- which is why quaternionjulia (PL. 138) drives its own constant off the complex plane instead, and why the genuinely new three-dimensional Mandelbrot set lives in the commutative algebra rather than the non-commutative one.
Standing
Public domain -- an algebra published in 1892 and an iteration published in 2000, implemented here from the stated mathematics. No patent has ever applied to bicomplex arithmetic or to escape-time colouring.
Constants
y is the height of the horizontal cut, and it is driven back and forth by the runner rather than by the draw, so the drawer marks it swept and moving it does nothing; its declared range runs to 1.1228 because that is m, the height at which the section vanishes. cx, cy and span are the window and are free for regenerate, which was measured across 2,400 renders of jittered tuples with none of them blank. span stops at 1.2 rather than lower because below that the frame at height zero is more than seventy per cent solid interior, which is a zoom into black; PL. 13 is the plate for diving. iter is locked as a pure cost dial, exactly as on mandelbrot and julia, and it is deliberately not scaled with the window the way PL. 13 scales its own, because the zoom here belongs to the reader rather than to a cycle.
Source
doi:10.1142/S0218348X0000041X

TOUCHDESIGNER · GLSL

The same shader this plate runs, reframed for a GLSL TOP. Pasted bare it renders the published constants as a still frame; wire absTime.seconds into u_t on the Vectors page to animate it.

// FORMA — PL. 150 · TETRABROT — Dominic Rochon, 2000
//   ηₙ₊₁ = ηₙ² + c  in ℂ₂,   η₀ = 0
//   c = p + q·i₁ + y·i₂ = (p + (q−y)i₁)·γ + (p + (q+y)i₁)·γ̄
//   c ∈ T  ⟺  p + (q−y)i₁ ∈ M  and  p + (q+y)i₁ ∈ M
//   γ = (1+j₁)/2,  γ̄ = (1−j₁)/2,  j₁ = i₁i₂,  j₁² = +1
// TouchDesigner port — paste into a GLSL TOP's pixel shader. Set the
// resolution on the TOP's Common page. As pasted it renders the published
// constants as a still frame; to animate, add a uniform named u_t on the
// GLSL TOP's Vectors 1 page with the expression absTime.seconds.
// Constants are consts — edit to tweak; comments give the measured range.
// Written from the published mathematics, not adapted from any code.

#define u_res (uTDOutputInfo.res.zw)
uniform float u_t;               // absTime.seconds on the Vectors page; unset = still

const float u_phase = 0.3511;    // this plate's own grid phase, 0..1
// FORMA's FRACTALS accent as cosine-gradient coefficients
const vec3 u_pal_a = vec3(0.46, 0.1389, 0.1912);
const vec3 u_pal_b = vec3(0.5, 0.151, 0.2078);
const vec3 u_pal_c = vec3(1, 1, 1);
const vec3 u_pal_d = vec3(0, 0.05, 0.1);

const float p_y    = 0.3;         // section height y · live 0 .. 1.1228
const float p_cx   = -0.66;       // centre — real · live -2 .. 0.4
const float p_cy   = 0.0;         // centre — imaginary · live -0.6 .. 0.6
const float p_span = 3.2;         // window width · live 1.2 .. 4.2
const float p_iter = 180.0;       // iteration ceiling · live 60 .. 320

/* The order's ramp — the same cosine formulation the JS kit uses, so a
   plate keeps its classification colour in either language. */
vec3 ramp(float t){
  return clamp(u_pal_a + u_pal_b * cos(6.28318530718 * (u_pal_c * t + u_pal_d)), 0.0, 1.0);
}

/* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
float cycle(float t, float period){ return fract(t / period + u_phase); }
float pingpong(float t, float period){
  float u = cycle(t, period);
  return u < 0.5 ? u * 2.0 : 2.0 - u * 2.0;
}


vec3 plate(vec2 uv){
  /* The same section, at the shader's own resolution. Nothing here is
     bicomplex: the idempotent split turns c = cr + ci*i1 + y*i2 into the
     pair cr + (ci - y)i1 and cr + (ci + y)i1, and the iteration runs on
     each of them separately. Rochon 2000, Lemma 3. */
  float it = floor(p_iter + 0.5);
  float span = p_span, y = p_y;
  float cr = p_cx + (uv.x - 0.5) * span;
  float ci = p_cy + (uv.y - 0.5) * span * (u_res.y / u_res.x);

  float ya = ci - y, yb = ci + y;
  float ar = 0.0, ai = 0.0, br = 0.0, bi = 0.0, n = 0.0;
  /* GLSL wants a constant bound, so the loop runs to the specimen's own
     ceiling of 320 and breaks on the live iteration count — mandelbrot's
     idiom, and p_iter's declared maximum. It also breaks the moment either
     half leaves the disc of radius two, which is when the bicomplex point
     leaves the product of the two discs. */
  for (int i = 0; i < 320; i++){
    if (float(i) >= it || ar * ar + ai * ai >= 4.0 || br * br + bi * bi >= 4.0) break;
    float at = ar * ar - ai * ai + cr;
    ai = 2.0 * ar * ai + ya; ar = at;
    float bt = br * br - bi * bi + cr;
    bi = 2.0 * br * bi + yb; br = bt;
    n += 1.0;
  }
  if (n >= it) return vec3(4.0, 6.0, 10.0) / 255.0;

  /* Continuous dwell and the log remap the whole escape family shares,
     with the bidisc modulus in place of the complex one. */
  float r = max(ar * ar + ai * ai, br * br + bi * bi);
  float nu = n + 1.0 - log2(log(r) / 2.0);
  float g = log(1.0 + max(0.0, nu)) / log(1.0 + it);
  return ramp(0.44 + 0.62 * g);
}

out vec4 fragColor;
void main(){
  // FORMA's uv runs y-down, matching its canvas; TD's vUV runs up
  vec2 uv = vec2(vUV.s, 1.0 - vUV.t);
  fragColor = TDOutputSwizzle(vec4(plate(uv), 1.0));
}

NUKE · BLINKSCRIPT

The same shader this plate runs, transpiled to a BlinkScript kernel. Paste it into a BlinkScript node's Kernel Source and press Recompile; every constant arrives as a knob at its published value, and u_t animates with the expression frame/24. Compiled and rendered in Nuke 17.1, then compared against this plate on the page.

// FORMA — PL. 150 · TETRABROT — Dominic Rochon, 2000
//   ηₙ₊₁ = ηₙ² + c  in ℂ₂,   η₀ = 0
//   c = p + q·i₁ + y·i₂ = (p + (q−y)i₁)·γ + (p + (q+y)i₁)·γ̄
//   c ∈ T  ⟺  p + (q−y)i₁ ∈ M  and  p + (q+y)i₁ ∈ M
//   γ = (1+j₁)/2,  γ̄ = (1−j₁)/2,  j₁ = i₁i₂,  j₁² = +1
// Nuke port — a BlinkScript kernel. Paste into a BlinkScript node's Kernel
// Source and press Recompile. Every constant arrives as a knob at its published
// value (the comment gives the measured range); u_t is a knob too — animate it
// with the expression frame/24 or leave it at 0 for the still frame. Written
// from the published mathematics, not adapted from any code.
// Transpiled from the shader this plate runs on the page (GLSL ES 3.00):
// vec → float2/3/4, swizzles expanded, GLSL builtins Blink lacks written out
// as forma_ functions, float literals suffixed. Compiled and rendered in a
// real Nuke (17.1v1) and compared against this plate on the page: 34 of 34.
//
// plate() and its helpers are written to a single exit — the loop that runs
// once. That is not a style: Blink 17.1 drops a conditional early return from
// a called function while Vectorize is on, which is the node default, with no
// warning and no error. Written this way it paints correctly as pasted.

kernel Forma_tetrabrot : ImageComputationKernel<ePixelWise>
{
  Image<eWrite> dst;

param:
  float u_t;             // seconds; 0 is the still frame
  float p_y;    // section height y · live 0 .. 1.1228
  float p_cx;   // centre — real · live -2 .. 0.4
  float p_cy;   // centre — imaginary · live -0.6 .. 0.6
  float p_span; // window width · live 1.2 .. 4.2
  float p_iter; // iteration ceiling · live 60 .. 320

local:
  float2 u_res;
  float u_phase;
  float3 u_pal_a, u_pal_b, u_pal_c, u_pal_d;

  void define(){
    defineParam(u_t, "u_t", 0.0f);
    defineParam(p_y, "p_y", 0.3f);
    defineParam(p_cx, "p_cx", -0.66f);
    defineParam(p_cy, "p_cy", 0.0f);
    defineParam(p_span, "p_span", 3.2f);
    defineParam(p_iter, "p_iter", 180.0f);
  }

  void init(){
    u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
    u_phase = 0.3511f;    // this plate's own grid phase, 0..1
    // FORMA's FRACTALS accent as cosine-gradient coefficients
    u_pal_a = float3(0.46f, 0.1389f, 0.1912f);
    u_pal_b = float3(0.5f, 0.151f, 0.2078f);
    u_pal_c = float3(1.0f, 1.0f, 1.0f);
    u_pal_d = float3(0.0f, 0.05f, 0.1f);
  }

  /* GLSL builtins Blink lacks, written as templates rather than overload sets.
     Blink's operators return expression templates (Swizzle<float,N>), so a call
     passing an expression cannot resolve against an overload set on float2
     against float3 — measured in Nuke 17.1: a float2 expression is ambiguous
     between the two, while scalar-against-vector resolves. A template deduces
     the expression's own type, so the ambiguity cannot arise. */
  template <class T> T forma_fract(T v){ return v - floor(v); }
  template <class T, class S> T forma_mod(T x, S y){ return x - y * floor(x / y); }
  /* Blink's own min/max/clamp take no scalar bound against a vector, which GLSL
     does; v * 0.0f + b is that bound at the vector's own width, and collapses to
     b when v is a scalar, so one template serves both. */
  template <class T, class S> T forma_min(T a, S b){ return min(a, a * 0.0f + b); }
  template <class T, class S> T forma_max(T a, S b){ return max(a, a * 0.0f + b); }
  template <class T, class S> T forma_clamp(T v, S lo, S hi){ return clamp(v, v * 0.0f + lo, v * 0.0f + hi); }
  int forma_min(int a, int b){ return min(a, b); }
  int forma_max(int a, int b){ return max(a, b); }
  /* GLSL step(edge, x) is 1 where x >= edge; floor(sign(x - e) * 0.5 + 1) is
     that exactly, equality included, out of builtins Blink does have. */
  template <class T, class S> T forma_step(S e, T x){ return floor(sign(x - e) * 0.5f + 1.0f); }
  template <class T, class S> T forma_smoothstep(S a, S b, T x){
    T t = forma_clamp((x - a) / (b - a), 0.0f, 1.0f);
    return t * t * (3.0f - 2.0f * t);
  }
  template <class T> float forma_distance(T a, T b){ return length(a - b); }
  float forma_tanh(float x){ float e = exp(2.0f * x); return (e - 1.0f) / (e + 1.0f); }
  float forma_radians(float d){ return d * 0.01745329252f; }
  // the page's hash2 is exact uint32; Blink has int, so the shifts are made
  // logical by masking and the read-back is lifted into 0 .. 2^32
  /* A uint read back as a float. Blink has no unsigned type, so a value past
     2^31 arrives as a negative int and float() of it is negative. Measured on
     gabor, whose own generator then returned uniforms in [-0.5, 0.5) and drew
     a different picture — it compiled, it rendered, and only comparing it with

  /* The order's ramp — the same cosine formulation the JS kit uses, so a
     plate keeps its classification colour in either language. */
  float3 ramp(float t){
    return forma_clamp(u_pal_a + u_pal_b * cos(6.28318530718f * (u_pal_c * t + u_pal_d)), 0.0f, 1.0f);
  }

  /* Sawtooth and triangle on this plate's phase, mirroring the JS kit. */
  float cycle(float t, float period){ return forma_fract(t / period + u_phase); }
  float pingpong(float t, float period){
    float u = cycle(t, period);
    return u < 0.5f ? u * 2.0f : 2.0f - u * 2.0f;
  }


  float3 plate(float2 uv){
    float3 forma_r = float3(0.0f, 0.0f, 0.0f);
    for (int forma_once = 0; forma_once < 1; forma_once++){
      /* The same section, at the shader's own resolution. Nothing here is
         bicomplex: the idempotent split turns c = cr + ci*i1 + y*i2 into the
         pair cr + (ci - y)i1 and cr + (ci + y)i1, and the iteration runs on
         each of them separately. Rochon 2000, Lemma 3.f */
      float it = floor(p_iter + 0.5f);
      float span = p_span;
      float y = p_y;
      float cr = p_cx + (uv.x - 0.5f) * span;
      float ci = p_cy + (uv.y - 0.5f) * span * (u_res.y / u_res.x);

      float ya = ci - y;
      float yb = ci + y;
      float ar = 0.0f;
      float ai = 0.0f;
      float br = 0.0f;
      float bi = 0.0f;
      float n = 0.0f;
      /* GLSL wants a constant bound, so the loop runs to the specimen's own
         ceiling of 320 and breaks on the live iteration count — mandelbrot's
         idiom, and p_iter's declared maximum. It also breaks the moment either
         half leaves the disc of radius two, which is when the bicomplex point
         leaves the product of the two discs. */
      for (int i = 0; i < 320; i++){
        if (float(i) >= it || ar * ar + ai * ai >= 4.0f || br * br + bi * bi >= 4.0f) break;
        float at = ar * ar - ai * ai + cr;
        ai = 2.0f * ar * ai + ya; ar = at;
        float bt = br * br - bi * bi + cr;
        bi = 2.0f * br * bi + yb; br = bt;
        n += 1.0f;
      }
      if (n >= it) { forma_r = float3(4.0f, 6.0f, 10.0f) / 255.0f; break; }

      /* Continuous dwell and the log remap the whole escape family shares,
         with the bidisc modulus in place of the complex one. */
      float r = forma_max(ar * ar + ai * ai, br * br + bi * bi);
      float nu = n + 1.0f - log2(log(r) / 2.0f);
      float g = log(1.0f + forma_max(0.0f, nu)) / log(1.0f + it);
      { forma_r = ramp(0.44f + 0.62f * g); break; }
    }
    return forma_r;
  }

  void process(int2 pos){
    // FORMA's uv runs y-down like its canvas; Nuke's rows run up
    float2 uv = float2((float(pos.x) + 0.5f) / u_res.x, 1.0f - (float(pos.y) + 0.5f) / u_res.y);
    float3 c = plate(uv);
    dst() = float4(c.x, c.y, c.z, 1.0f);
  }
};