DEFINITION
boxFold: per axis, v>1 → 2−v; v<−1 → −2−v ballFold: m=|v|; m<r → v·f²/r²; m<f → v·f²/m² v → s·ballFold(boxFold(v)) + c (standard s=2, r=0.5, f=1) d = |v| / |dv/dc|, |dv/dc| → |s|·|dv/dc| + 1 sphere trace: t → t + d(o + t·û); n = ∇d / |∇d|
NOTES
Tom Lowe posted this construction to fractalforums.com on 31 January 2010, titled only Amazing fractal, and it was renamed Mandelbox by the community afterward. The rule folds three-dimensional space onto itself twice at every step, then scales and shifts. A box fold reflects any coordinate that has strayed past one or minus one: past that boundary an axis is replaced by two minus itself, or minus two minus itself on the other side, the same fold applied to x, y and z independently. A ball fold then takes anything that landed inside a small sphere of radius r and pushes it back out at a fixed rate, and anything sitting between that sphere and a second, larger one of radius f gets inverted outward through it; nothing outside the larger sphere is touched. Fold, fold, multiply by a scale s, add a constant c, and repeat from the origin forever: c belongs to the Mandelbox exactly when that orbit never runs away, the same membership test the Mandelbrot set uses in the plane, just in three real dimensions with folds standing in for squaring. What this plate draws is the solid itself, lit, found by marching rays at it rather than by cutting a plane through it. The march needs a distance, and the distance comes from carrying one extra number beside the orbit. A box fold is a reflection, so it leaves that number alone; a ball fold multiplies the point by a known factor and multiplies the number by the same one, an inversion having that single magnitude in every direction; and the scale-and-add step sends it to s times itself plus one. Because both folds settle to the identity far from the origin, the map out there is repeated multiplication by s rather than the squaring every other escape plate in this atlas shares, so the estimate is the size of the orbit divided by that running derivative with no logarithm in it, and not the Douady-Hubbard form that belongs to a squaring map. Every factor of the map being a similarity is what makes that one scalar stand for stretching in every direction at once, and therefore what turns the estimate into a lower bound on the distance to the surface rather than a guess at it. Given such a bound a ray can be advanced by exactly it without passing through anything, which is sphere tracing; where the bound falls below the width of a pixel is where the surface is; and the gradient of the same estimate, taken by differences either side of that point, is the normal to shade it by. Where the mathematics stops and the picture begins is worth saying plainly, because a lit render invites the confusion. The estimate, the march, where it stops and the normal are the plate. The light is not. Its two directions are fixed relative to the eye and one slider turns the key around, and where a lamp stands is staging rather than a fact about the set. Lambert on that normal, a halfway-vector specular, a cone traced back toward the light for the soft shadow and a hemisphere of short rays for the ambient obscurance are published models rather than invented ones, which is why they are named under Provenance and not merely used. And the optics of the page itself run after all of it and outside this function entirely, exactly as they do over every other plate here: what a lens and a sensor do to an image is never in a draw function in this atlas, and a lit surface does not become an exception to that. The camera does not move, and it cannot. Rendering this way costs some eighty times a plane cut, far more than one frame holds, so the picture develops instead: every paint traces a batch of rays into a buffer, coarsest first, the first paint laying down a whole eighth-resolution picture and later ones refining it until every cell has been traced once. A camera move would throw all of that away, so the view is two constants the reader turns rather than an animation, and turning one restarts the development, which takes about a second. The shader does the same march in one frame at full resolution and arrives at the same picture, and that it is the same picture is measured under Provenance rather than assumed. The scale decides which of two objects is on the table. Along an axis the outermost c whose orbit stays bounded is the one landing exactly on the period-two cycle of the box fold, at 2(s + 1)/(s - 1), which is six at the scale of two Lowe used; the solid reaches about seventy per cent further along its body diagonals, and the camera is placed in units of that measured reach so every listed scale frames the same way. Lowe called two more connected, against the finer architecture at three that needs a closer zoom to read, in a reply posted the same evening as the first images. Both are here, along with a handful of other measured-alive values and the whole negative-scale island, which is a quite different object: smooth faces with all of its detail crowded into the edges.
PROVENANCE
- Origin
- Tom Lowe, forum handle Tglad, "Amazing fractal", fractalforums.com, board IFS - Iterated Function Systems, subboard Amazing Box, Amazing Surf and variations. Posted January 31, 2010, 08:23:26 PM (forum-server time), carrying a same-post edit stamp of February 01, 2010, 08:20:32 PM — almost certainly one moment read across a timezone difference (a US-hosted forum local evening on the 31st is already the 1st in UTC), which is why both dates are given here rather than one being silently dropped. Re-verified resolving 25 August 2026 at https://ff1.fractalforums.org/index.php?topic=2526.0, the read-only archive with the original topic id preserved. Never link www.fractalforums.com — that live domain now serves database errors, and it is indexed, which is the trap
- The formula, transcribed from the archived post itself
- a per-axis box fold (if a coordinate exceeds 1 it becomes 2 minus itself; if it falls below -1 it becomes -2 minus itself), then a sphere fold against fixedRadius=1 and minRadius=0.5 (a point closer to the origin than minRadius is scaled up by fixedRadius squared over minRadius squared; a point between minRadius and fixedRadius is scaled by fixedRadius squared over its own squared length), then a multiply by scale and an add of the constant C. This matches the formula already carried in the wave brief term for term, checked here against the primary source directly rather than only re-cited
- Two more facts read directly off the same primary page
- Lowe states his escape termination as greater than 1024 in a same-day reply (this plate uses a smaller bailout for the frame budget, and says so under Constants below); and in the very first reply, posted the same evening, he calls a second render at scale=2 more connected against the scale=3 images the opening post led with, adding that scale=3 needs closer zooming to show its detail — both used directly in the note above and in the CHOICES list
- Naming
- The thread title is only Amazing fractal. That the community renamed it Mandelbox within weeks is carried here as researched for the wave rather than independently re-derived — the archived pages read through the fourteenth reply do not yet use the later name. No dispute over priority and no earlier construction combining a box fold with a ball fold surfaced anywhere in what was read
- Standing
- Public domain — an open forum publication with no patent ever claimed over folding operations or escape-time coloring. The rendering method below is published literature of the same standing: sphere tracing, the distance-bounded ray march for deterministic fractals, Lambert, Blinn and the ambient obscurance model are all papers, and none of them is or ever was encumbered
- src, what was checked
- doi:10.1142/11219 resolves, verified against the Crossref API directly rather than the publisher page (which returns an automated-fetch block), to Thomas Lowe (CSIRO, Australia), "Exploring Scale Symmetry", a chapter in Fractals and Dynamics in Mathematics, Science, and the Arts: Theory and Applications, World Scientific, print March 2021 / online October 2018, ISBN 9789813278547 — matching the wave brief on every field. This is a later academic treatment by the discoverer himself, a rarity this atlas tries to use where it exists; the chapter text itself was not read (no open copy located), so this entry verifies the bibliographic identity and claims nothing from inside the book beyond what the forum post already establishes
- The rendering, and every part of it named
- The march is sphere tracing: J. C. Hart, "Sphere tracing: a geometric method for the antialiased ray tracing of implicit surfaces", The Visual Computer 12(10), 1996, 527-545, doi:10.1007/s003710050084. Given a function returning a lower bound on the distance to the surface, step by exactly that bound; each evaluation defines an unbounding sphere guaranteed to contain no intersection, so no step can penetrate, and the march converges on the first hit. The soft shadow is section 5 of the same paper, not a folk trick: marching toward the light, the unbounding sphere of radius d at distance t subtends a half-angle whose sine is d/t, so the running minimum of that ratio bounds how much of the light cone is still visible. The lineage of distance-bounded marching on a deterministic fractal is J. C. Hart, D. J. Sandin and L. H. Kauffman, "Ray tracing deterministic 3-D fractals", ACM SIGGRAPH Computer Graphics 23(3), 1989, 289-296, doi:10.1145/74334.74363, which this plate does not follow in its formula (that one is the Douady-Hubbard estimate for a squaring map) but does follow in its method. Shading: Lambert, Photometria, 1760, pre-DOI and public domain, of which the whole content here is max(0, n dot l); the specular is the halfway vector of J. F. Blinn, "Models of light reflection for computer synthesized pictures", Proceedings of SIGGRAPH 77, 192-198, doi:10.1145/563858.563893 (the exponent-on-the-reflection alternative is Bui Tuong Phong, "Illumination for computer generated pictures", Communications of the ACM 18(6), 1975, 311-317, doi:10.1145/360825.360839, and is one line either way). The ambient term is S. Zhukov, A. Iones and G. Kronin, "An ambient light illumination model", Eurographics Rendering Techniques 98, Springer 1998, 45-55, doi:10.1007/978-3-7091-6453-2_5: W(P) is the mean over the hemisphere of a monotone function of the distance to the nearest occluder, zero at zero and one at infinity, and what runs here is four cosine-weighted directions each sphere traced to a radius R with the simplest member of the family they give. It is deliberately NOT the five-tap distance-field trick that circulates as ambient occlusion in demo code: that has no published derivation, and reproducing it would be reproducing a sketch. All five identifiers were resolved against the live Crossref API on 26 August 2026 and matched on title, authors, container, year and pages; Blink 1977 carries empty volume and issue fields there, which is why it is cited as the proceedings rather than in the Computer Graphics 11(2) form a secondary source would supply
- Is the estimate really a bound
- The theory says yes and the measurement says nearly: a box fold is an isometry, a ball fold is a scalar times a reflection in both branches, and by the theorem of Liouville on conformal maps in three dimensions each factor is a similarity, so the scalar can stand for the stretching in every direction. Tested rather than trusted. 59,094 rays at this plate own camera across all nine listed scales, every full step subdivided 24 ways and every sub-point tested for membership of the same 16-iteration bounded set the estimate is estimating: 676,034 steps, 16,224,816 membership tests. Zero penetrating steps at scale 2, 2.25, 2.5, 3 and -2.5; three of 45,557 at -2, both at 98 per cent of the way through their own step; and 2,631 of 36,369, or 7.2 per cent, at -1.5, where the median penetration sits 62 per cent of the way through the step. What that costs the picture was measured separately, against a march taking quarter steps with an ample budget: silhouettes differ on 0.031 to 0.382 per cent of pixels, worst at scale -2.5 rather than at -1.5, so the two effects are the step overshooting and the stopping epsilon, not one thing. Full steps ship, because a quarter step is four times the march for a third of a per cent of the frame. This also corrects a figure: the look-development prototype for this wave reported zero penetrations in 179,454 steps, and that measurement was taken at scale 2 alone at a far framing. At scale 2 this sweep also finds zero. The prototype had not tested the negative-scale island, which is the one that penetrates
- The framing, and why it is derived rather than fixed
- The window is not a fixed span and it is not the plane-slice figure either. Along an axis the outermost bounded c is the one landing on the period-two cycle of the box fold at 2(s+1)/(s-1) - six at scale two - but the solid reaches further out along its body diagonals, and a camera framed on the axis figure alone sits inside its own corners. Measured by tracing 4,000 rays inward from a far sphere in Fibonacci directions, with the same estimate the plate marches with: the silhouette radius is 1.55 to 1.72 times the axis figure at every one of the nine CHOICES scales and at all four corners of the two radius sliders, and every single direction finds a surface, so 1.72 of the axis figure bounds the solid wherever this plate can be opened. In numbers, 10.14 at scale 2 against an axis extent of 6, and 3.20 at scale -1.5 against the fold fixed point at 2. The camera then sits at 2.3 of that radius on a 45 degree field, where 2.61 would inscribe the bounding sphere exactly: the sphere is cropped a little and the solid, which does not fill its own bound, is not. Looked at against 1.5, 1.9, 2.7 and 3.2 at four azimuths and at three grid resolutions - closer is a decorated wall with no silhouette, further is a decorated cube
- What one paint can afford
- One paint cannot afford a frame of this, so a paint is a batch. A full pass at a 318 by 256 card is 32,200 rays on the 200-cell grid, and the batch is 700 rays: measured in Chrome on this machine, 45 batches at a median of 4.0 ms and a worst of 4.4 once the loop is warm, 7.3 ms for the very first batch on a cold page, 159 ms of work in total, and 0.05 ms a frame for ever afterwards because a developed picture is a blit. The drawer at 890 by 593 is 26,600 rays, 37 batches, median 3.1 ms, 101 ms in total; a 240-square thumbnail is 56 batches; the 2048-wide export is 37. That last figure is a constraint rather than an observation: renderStill develops an exposure over 241 draws, so a resolution cap whose full pass needed more batches than that would ship a half-traced thumbnail and a half-traced export, which is the failure this plate would be least likely to notice. The batch is not a scanline sweep. It is hierarchical, eighth-resolution blocks first and each level halving, and the first batch is always a whole eighth-resolution pass - 503 rays at a card, inside the 700 - so a reader moving a slider sees a coarse lit solid track it within one frame rather than a black rectangle growing a corner. Every ray a coarse level traces is a ray the finished picture keeps, because a cell is sampled at its own centre and the block it fills is a nearest-neighbour spread of that one sample. Set against the plane slice this replaced, which cost 6.18 ms of the same machine every frame it was visible for as long as it was visible, the solid is dearer for 45 frames and then about a hundred times cheaper
- Constants, measured
- Re-swept from nothing, because the constants are new, two of them mean something different and the colouring changed - the old numbers were void the moment the slice was. 24 REGENERATE seeds at two sizes over 120 frames, 5,760 renders: 0 throws, 0 plates below the contrast floor, worst pixelRange 127 and median 179 against the gate floor of 14. scale is drawn from CHOICES rather than jittered, because the live ground is two islands with a dead band between about -0.25 and 0.9 where the folds cannot outrun the contraction and every orbit in the window stays bounded; that is a fact about the map and it survived the change of renderer unaltered. minr and fixedr are left free, and the camera constants with them - a camera that always looks at the object from a distance derived from the object cannot be jittered into looking at nothing, which is why elevation and azimuth are the two least fragile constants on this plate. iter and steps are locked as pure cost dials and both ceilings are measurements: see LOCKED in taxonomy.js for the diffs. The bailout radius, 64, is smaller than the 1024 Lowe used for his own high quality renders, chosen for the frame budget rather than to match his figure, and the running derivative rather than the escape count is what carries the picture here. params[] is a wire format and append-only once shipped, so the two constants the plane slice used to tilt and drift its cutting plane were repurposed in place as the view elevation and the key light azimuth, and the view azimuth and the march budget were appended. That keeps an old bench link restoring the three constants that are still the mathematics - scale, minRadius, fixedRadius - onto the same three sliders, and it costs a param list whose order reads oddly. Removing the two dead entries instead would have silently remapped every value in every shared link
- The two rendering paths, checked
- Both paths run the same march and they are compared rather than assumed to agree, at 20 parameter tuples: the nine CHOICES scales at the published constants, plus eleven with minRadius, fixedRadius, both view angles, the light and the iteration ceiling drawn at random. At a matched grid - both paths at 200 by 161, cell for cell, 1,932,000 channels - the mean channel difference is 0.273 levels in 255, 3.15 per cent of channels differ by more than one and 0.057 per cent of pixels disagree about whether they hit the surface at all. Re-measured after the colour window moved to a rising 0.72 to 0.97 (see the shading comment above), against 0.288, 3.1 per cent and 0.07 per cent before the window moved: the same class, which is what a colour-only edit should produce, since the ramp formula is textually identical in both paths and the residue below is a camera artefact that formula cannot touch. Bake the camera basis and the light into the shader as the same float64 numbers the JS path computes and that falls to a mean of 0.164 with a maximum of one and no silhouette disagreement anywhere: so the residue is the shader taking the sine and cosine of its own view angles at float32, which turns the camera by a small fraction of a degree, and the last level is the order of two roundings the kit ramp already carries. Rounding the whole estimate to float32 in JS changes nothing, which says the difference is not in the march. Where the two genuinely differ is sampling: the JS path shades a 200-cell grid and scales it up, the shader runs at the canvas backing resolution, and at their own native samplings 16 by 16 box means agree to 2.47 levels with a worst box of 52. That comparison is also what found the one real defect in this plate. The hierarchical batch first sampled each block at the centre of the block rather than at the centre of the cell it was about to spread, so a quarter of the finished cells held a value taken half a cell away. It rendered, it developed, it looked right, and it read 4.10 levels against the shader where it now reads 0.48
- What the shader costs, on the record
- A sphere trace at backing resolution is by a wide margin the most expensive shader in this atlas, and on a software renderer that is worth stating rather than discovering. Measured in the browser gate own Chrome, which on this machine has no GPU and runs ANGLE over SwiftShader: a 318 by 256 card frame is about 40 ms against 18 for metamer, which held the record before this, 4 for mandelbrot and 8.3 for the plane slice this replaced; the drawer at device pixel ratio 2 is 1,200 ms against metamer 564. In the atlas itself, parked so the card is on screen, the grid runs at 24 frames a second against 48 parked on metamer and 31 to 35 elsewhere, and filtered down to this plate alone it is 25 against metamer 24. So the honest summary is that it lands level with the worst case the atlas already ships rather than opening a new one, and it does so on a renderer almost no reader has: a machine with any GPU at all runs this arithmetic in a small fraction of a frame. Four things were traded against that figure and each is measured: the framing (2.3 of the bounding radius rather than 1.9 is 114 ms against 168 at the same size), the obscurance (four directions by six steps rather than six by ten, indistinguishable at both sizes and a fifth of the frame), the shadow cone (16 steps rather than 24), and the march budget, which turned out not to be a lever at all - 91.2 ms at 80 steps against 90.8 at 112, because the march is bound by distance estimates and not by the tail of long rays. What could not be traded is the resolution: a fragment shader cannot march at a capped grid the way the JS path does, because every fragment pays for its own ray. The reader who wants this plate cheap has GPU OFF, which puts it on the JS path at 4 ms a batch - the first time that button has been a performance escape as well as a diagnostic, and it is one only because both paths draw the same picture
- Source
- doi:10.1142/11219
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. 149 · MANDELBOX — Tom Lowe, 2010
// boxFold: per axis, v>1 → 2−v; v<−1 → −2−v
// ballFold: m=|v|; m<r → v·f²/r²; m<f → v·f²/m²
// v → s·ballFold(boxFold(v)) + c (standard s=2, r=0.5, f=1)
// d = |v| / |dv/dc|, |dv/dc| → |s|·|dv/dc| + 1
// sphere trace: t → t + d(o + t·û); n = ∇d / |∇d|
// 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.5404; // 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_scale = 2.0; // scale — s · live -3 .. 3.5
const float p_minr = 0.5; // minRadius — r (ball fold) · live 0.15 .. 0.75
const float p_fixedr = 1.0; // fixedRadius — f (ball fold) · live 0.6 .. 1.3
const float p_elev = -9.0; // view elevation ° · live -40 .. 60
const float p_lightaz = -36.0; // key light azimuth ° · live -180 .. 180
const float p_iter = 16.0; // iteration ceiling · live 8 .. 24
const float p_az = -58.0; // view azimuth ° · live -180 .. 180
const float p_steps = 128.0; // march step budget · live 48 .. 192
/* 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;
}
// The same march, at the canvas own backing resolution and in one pass.
// Every helper is mbox_ prefixed and nothing is declared at file scope, so
// a composition of this plate with itself still links.
float mbox_de(vec3 pos, out float trap){
float it = floor(p_iter + 0.5);
float minR2 = p_minr * p_minr, fixedR2 = p_fixedr * p_fixedr;
vec3 v = vec3(0.0);
float dr = 0.0, mn = 1.0e9;
// 24 is the declared ceiling of p_iter -- mandelbrot fashion: a constant
// loop bound at the slider own maximum, breaking on the live value.
for (int n = 0; n < 24; n++){
if (float(n) >= it) break;
// clamp(v,-1,1)*2 - v is the three cases of the box fold exactly,
// componentwise: past 1 it gives 2-v, below -1 it gives -2-v, and
// between them it gives v back.
v = clamp(v, -1.0, 1.0) * 2.0 - v;
float m2 = dot(v, v);
if (m2 < minR2){ float k = fixedR2 / minR2; v *= k; dr *= k; }
else if (m2 < fixedR2){ float k = fixedR2 / m2; v *= k; dr *= k; }
v = p_scale * v + pos;
dr = abs(p_scale) * dr + 1.0;
float r2 = dot(v, v);
mn = min(mn, r2);
if (r2 > 4096.0) break;
}
trap = sqrt(mn);
return length(v) / dr;
}
float mbox_bound(float s){
return 1.72 * min(10.0, max(2.0, 2.0 * (s + 1.0) / (s - 1.0)));
}
float mbox_eps(float tt, float pxa){ return max(2.0e-6, 0.25 * tt * pxa); }
float mbox_march(vec3 ro, vec3 rd, float t0, float t1, float pxa, out float trap){
float tt = t0, hit = -1.0;
trap = 0.0;
for (int i = 0; i < 192; i++){
if (float(i) >= p_steps) break;
float tr;
float d = mbox_de(ro + rd * tt, tr);
if (d < mbox_eps(tt, pxa)){ trap = tr; hit = tt; break; }
tt += d;
if (tt > t1) break;
}
return hit;
}
vec3 mbox_normal(vec3 pos, float e){
float tr;
float gx = mbox_de(pos + vec3(e, 0.0, 0.0), tr) - mbox_de(pos - vec3(e, 0.0, 0.0), tr);
float gy = mbox_de(pos + vec3(0.0, e, 0.0), tr) - mbox_de(pos - vec3(0.0, e, 0.0), tr);
float gz = mbox_de(pos + vec3(0.0, 0.0, e), tr) - mbox_de(pos - vec3(0.0, 0.0, e), tr);
return normalize(vec3(gx, gy, gz));
}
float mbox_shadow(vec3 pos, vec3 l, float t0, float t1){
float k = 1.0, tt = t0;
for (int i = 0; i < 16; i++){
float tr;
float d = mbox_de(pos + l * tt, tr);
if (d < 2.0e-6){ k = 0.0; break; }
k = min(k, 11.0 * d / tt);
tt += d;
if (tt > t1) break;
}
return clamp(k, 0.0, 1.0);
}
float mbox_obscurance(vec3 pos, vec3 n, float e, float R){
vec3 u0 = vec3(0.0, 1.0, 0.0);
if (abs(n.y) > 0.9) u0 = vec3(1.0, 0.0, 0.0);
vec3 tg = normalize(cross(n, u0));
vec3 bt = cross(n, tg);
float sum = 0.0;
for (int i = 0; i < 4; i++){
float fi = (float(i) + 0.5) / 4.0;
float rr = sqrt(fi), ph = float(i) * 2.39996323;
vec3 d = normalize(tg * (rr * cos(ph)) + bt * (rr * sin(ph)) + n * sqrt(max(0.0, 1.0 - fi)));
float tt = 2.0 * e, hit = R;
for (int q = 0; q < 6; q++){
float tr;
float dd = mbox_de(pos + d * tt, tr);
if (dd < e){ hit = tt; break; }
tt += max(dd, e);
if (tt >= R){ hit = R; break; }
}
sum += min(1.0, hit / R);
}
return sum / 4.0;
}
vec3 plate(vec2 uv){
float DEG = 0.017453292519943295;
float htan = 0.4142135623730951; // tan of half a 45 degree field
float bound = mbox_bound(p_scale);
float dist = bound * 2.3;
float az = p_az * DEG, el = p_elev * DEG;
vec3 ro = vec3(dist * cos(el) * cos(az), dist * sin(el), dist * cos(el) * sin(az));
vec3 fw = -ro / dist;
vec3 rt = normalize(vec3(-fw.z, 0.0, fw.x));
vec3 up = cross(rt, fw);
float la = p_lightaz * DEG;
vec3 kc = vec3(sin(la) * 0.7762, 0.6305, -cos(la) * 0.7762);
vec3 key = rt * kc.x + up * kc.y + fw * kc.z;
vec3 fil = rt * 0.9048 + up * -0.2088 + fw * -0.3712;
float asp = u_res.x / u_res.y;
float pxa = 2.0 * htan / u_res.y;
float sx = uv.x * 2.0 - 1.0, sy = 1.0 - uv.y * 2.0;
vec3 rd = normalize(fw + rt * (sx * htan * asp) + up * (sy * htan));
vec3 col = vec3(4.0, 6.0, 10.0) / 255.0;
float bq = dot(ro, rd);
float cq = dot(ro, ro) - bound * bound;
float disc = bq * bq - cq;
if (disc > 0.0){
float sq = sqrt(disc);
float t0 = max(0.0, -bq - sq), t1 = -bq + sq;
if (t1 > 0.0){
float trap;
float th = mbox_march(ro, rd, t0, t1, pxa, trap);
if (th > 0.0){
vec3 pos = ro + rd * th;
float e = mbox_eps(th, pxa);
vec3 n = mbox_normal(pos, 1.2 * e);
float ndl = max(0.0, dot(n, key));
float sh = 0.0;
if (ndl > 0.0) sh = mbox_shadow(pos + n * (3.0 * e), key, 3.0 * e, bound * 2.0);
float ndf = max(0.0, dot(n, fil));
float ao = mbox_obscurance(pos, n, e, 0.05 * bound);
vec3 hv = normalize(key - rd);
float spec = pow(max(0.0, dot(n, hv)), 36.0) * ndl * sh;
vec3 al = ramp(0.72 + 0.25 * clamp((trap - 1.55) / 2.40, 0.0, 1.0));
col = al * (0.86 * ndl * sh + 0.18 * ndf)
+ al * vec3(0.4, 0.6, 1.0) * (0.25 * ao)
+ vec3(1.0) * (0.30 * spec);
}
}
}
return col;
}
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. 149 · MANDELBOX — Tom Lowe, 2010
// boxFold: per axis, v>1 → 2−v; v<−1 → −2−v
// ballFold: m=|v|; m<r → v·f²/r²; m<f → v·f²/m²
// v → s·ballFold(boxFold(v)) + c (standard s=2, r=0.5, f=1)
// d = |v| / |dv/dc|, |dv/dc| → |s|·|dv/dc| + 1
// sphere trace: t → t + d(o + t·û); n = ∇d / |∇d|
// 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_mandelbox : ImageComputationKernel<ePixelWise>
{
Image<eWrite> dst;
param:
float u_t; // seconds; 0 is the still frame
float p_scale; // scale — s · live -3 .. 3.5
float p_minr; // minRadius — r (ball fold) · live 0.15 .. 0.75
float p_fixedr; // fixedRadius — f (ball fold) · live 0.6 .. 1.3
float p_elev; // view elevation ° · live -40 .. 60
float p_lightaz; // key light azimuth ° · live -180 .. 180
float p_iter; // iteration ceiling · live 8 .. 24
float p_az; // view azimuth ° · live -180 .. 180
float p_steps; // march step budget · live 48 .. 192
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_scale, "p_scale", 2.0f);
defineParam(p_minr, "p_minr", 0.5f);
defineParam(p_fixedr, "p_fixedr", 1.0f);
defineParam(p_elev, "p_elev", -9.0f);
defineParam(p_lightaz, "p_lightaz", -36.0f);
defineParam(p_iter, "p_iter", 16.0f);
defineParam(p_az, "p_az", -58.0f);
defineParam(p_steps, "p_steps", 128.0f);
}
void init(){
u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
u_phase = 0.5404f; // 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;
}
// The same march, at the canvas own backing resolution and in one pass.
// Every helper is mbox_ prefixed and nothing is declared at file scope, so
// a composition of this plate with itself still links.
float mbox_de(float3 pos, float& trap){
float it = floor(p_iter + 0.5f);
float minR2 = p_minr * p_minr;
float fixedR2 = p_fixedr * p_fixedr;
float3 v = float3(0.0f);
float dr = 0.0f;
float mn = 1.0e9f;
// 24 is the declared ceiling of p_iter -- mandelbrot fashion: a constant
// loop bound at the slider own maximum, breaking on the live value.
for (int n = 0; n < 24; n++){
if (float(n) >= it) break;
// forma_clamp(v,-1,1)*2 - v is the three cases of the box fold exactly,
// componentwise: past 1 it gives 2-v, below -1 it gives -2-v, and
// between them it gives v back.
v = forma_clamp(v, -1.0f, 1.0f) * 2.0f - v;
float m2 = dot(v, v);
if (m2 < minR2){ float k = fixedR2 / minR2; v *= k; dr *= k; }
else if (m2 < fixedR2){ float k = fixedR2 / m2; v *= k; dr *= k; }
v = p_scale * v + pos;
dr = fabs(p_scale) * dr + 1.0f;
float r2 = dot(v, v);
mn = forma_min(mn, r2);
if (r2 > 4096.0f) break;
}
trap = sqrt(mn);
return length(v) / dr;
}
float mbox_bound(float s){
return 1.72f * forma_min(10.0f, forma_max(2.0f, 2.0f * (s + 1.0f) / (s - 1.0f)));
}
float mbox_eps(float tt, float pxa){ return forma_max(2.0e-6f, 0.25f * tt * pxa); }
float mbox_march(float3 ro, float3 rd, float t0, float t1, float pxa, float& trap){
float tt = t0;
float hit = -1.0f;
trap = 0.0f;
for (int i = 0; i < 192; i++){
if (float(i) >= p_steps) break;
float tr;
float d = mbox_de(ro + rd * tt, tr);
if (d < mbox_eps(tt, pxa)){ trap = tr; hit = tt; break; }
tt += d;
if (tt > t1) break;
}
return hit;
}
float3 mbox_normal(float3 pos, float e){
float tr;
float gx = mbox_de(pos + float3(e, 0.0f, 0.0f), tr) - mbox_de(pos - float3(e, 0.0f, 0.0f), tr);
float gy = mbox_de(pos + float3(0.0f, e, 0.0f), tr) - mbox_de(pos - float3(0.0f, e, 0.0f), tr);
float gz = mbox_de(pos + float3(0.0f, 0.0f, e), tr) - mbox_de(pos - float3(0.0f, 0.0f, e), tr);
return normalize(float3(gx, gy, gz));
}
float mbox_shadow(float3 pos, float3 l, float t0, float t1){
float k = 1.0f;
float tt = t0;
for (int i = 0; i < 16; i++){
float tr;
float d = mbox_de(pos + l * tt, tr);
if (d < 2.0e-6f){ k = 0.0f; break; }
k = forma_min(k, 11.0f * d / tt);
tt += d;
if (tt > t1) break;
}
return forma_clamp(k, 0.0f, 1.0f);
}
float mbox_obscurance(float3 pos, float3 n, float e, float R){
float3 u0 = float3(0.0f, 1.0f, 0.0f);
if (fabs(n.y) > 0.9f) u0 = float3(1.0f, 0.0f, 0.0f);
float3 tg = normalize(cross(n, u0));
float3 bt = cross(n, tg);
float sum = 0.0f;
for (int i = 0; i < 4; i++){
float fi = (float(i) + 0.5f) / 4.0f;
float rr = sqrt(fi);
float ph = float(i) * 2.39996323f;
float3 d = normalize(tg * (rr * cos(ph)) + bt * (rr * sin(ph)) + n * sqrt(forma_max(0.0f, 1.0f - fi)));
float tt = 2.0f * e;
float hit = R;
for (int q = 0; q < 6; q++){
float tr;
float dd = mbox_de(pos + d * tt, tr);
if (dd < e){ hit = tt; break; }
tt += forma_max(dd, e);
if (tt >= R){ hit = R; break; }
}
sum += forma_min(1.0f, hit / R);
}
return sum / 4.0f;
}
float3 plate(float2 uv){
float DEG = 0.017453292519943295f;
float htan = 0.4142135623730951f; // tan of half a 45 degree field
float bound = mbox_bound(p_scale);
float dist = bound * 2.3f;
float az = p_az * DEG;
float el = p_elev * DEG;
float3 ro = float3(dist * cos(el) * cos(az), dist * sin(el), dist * cos(el) * sin(az));
float3 fw = -ro / dist;
float3 rt = normalize(float3(-fw.z, 0.0f, fw.x));
float3 up = cross(rt, fw);
float la = p_lightaz * DEG;
float3 kc = float3(sin(la) * 0.7762f, 0.6305f, -cos(la) * 0.7762f);
float3 key = rt * kc.x + up * kc.y + fw * kc.z;
float3 fil = rt * 0.9048f + up * -0.2088f + fw * -0.3712f;
float asp = u_res.x / u_res.y;
float pxa = 2.0f * htan / u_res.y;
float sx = uv.x * 2.0f - 1.0f;
float sy = 1.0f - uv.y * 2.0f;
float3 rd = normalize(fw + rt * (sx * htan * asp) + up * (sy * htan));
float3 col = float3(4.0f, 6.0f, 10.0f) / 255.0f;
float bq = dot(ro, rd);
float cq = dot(ro, ro) - bound * bound;
float disc = bq * bq - cq;
if (disc > 0.0f){
float sq = sqrt(disc);
float t0 = forma_max(0.0f, -bq - sq);
float t1 = -bq + sq;
if (t1 > 0.0f){
float trap;
float th = mbox_march(ro, rd, t0, t1, pxa, trap);
if (th > 0.0f){
float3 pos = ro + rd * th;
float e = mbox_eps(th, pxa);
float3 n = mbox_normal(pos, 1.2f * e);
float ndl = forma_max(0.0f, dot(n, key));
float sh = 0.0f;
if (ndl > 0.0f) sh = mbox_shadow(pos + n * (3.0f * e), key, 3.0f * e, bound * 2.0f);
float ndf = forma_max(0.0f, dot(n, fil));
float ao = mbox_obscurance(pos, n, e, 0.05f * bound);
float3 hv = normalize(key - rd);
float spec = pow(forma_max(0.0f, dot(n, hv)), 36.0f) * ndl * sh;
float3 al = ramp(0.72f + 0.25f * forma_clamp((trap - 1.55f) / 2.40f, 0.0f, 1.0f));
col = al * (0.86f * ndl * sh + 0.18f * ndf)
+ al * float3(0.4f, 0.6f, 1.0f) * (0.25f * ao)
+ float3(1.0f) * (0.30f * spec);
}
}
}
return col;
}
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);
}
};