PL. 151 · FRACTALS / DISTANCE FIELD / IFS FOLD
Sierpiński Tetrahedron
Sierpiński, 1915 · Hutchinson, 1981
OPEN THE LIVE PLATE ▸DEFINITION
A = ∪ₖ fₖ(A), fₖ(p) = (p + vₖ)/2, vₖ the 4 tetrahedron vertices dim_H = log 4 / log 2 = 2 exactly fold in x+y=0, x+z=0, y+z=0, then p → 2p − v₁, k times d(p) = d_simplex(p_k) · 2⁻ᵏ — every map a similarity, so 2ᵏ is exact sphere trace: t → t + d(o + t·û); n = ∇d / |∇d|
NOTES
Four points, and one rule: from anywhere at all, jump half the way toward one of them, forever. The set that survives that rule — the unique compact set carried onto itself by the four half-scale similarities toward the four vertices of a regular tetrahedron, which is what Hutchinson theorem guarantees exists and is unique — is a nested stack of tetrahedra with the middle octahedron missing at every scale, the solid counterpart of the Sierpinski triangle. Its Hausdorff dimension is log 4 over log 2, which is exactly 2: an integer, unlike almost every other fractal in this order, because four copies at half scale is precisely the packing a 2-dimensional measure asks for, no more and no less. What this plate draws is the object itself, lit, found by marching rays at it. The distance the march needs comes out of the four maps rather than out of any escape time. The three planes x+y=0, x+z=0 and y+z=0 are mirrors of the tetrahedron — each swaps two vertices and fixes the other two — so reflecting in them leaves the set alone while carrying any point into the chamber of one vertex; inside that chamber the nearest material belongs to that vertex own half-scale copy, so undoing that copy (scale 2 about the vertex) and repeating k times reduces the whole question to a distance to one small simplex, which is then divided by the 2ᵏ the undoing multiplied by. Every map on that path is a similarity, so the running scalar is not an estimate of the Lipschitz constant, it is the Lipschitz constant, and Hart guarantee applies with nothing to prove about a derivative. Measured against an independent enumeration of the actual cells, the estimate never once exceeded the true distance in twenty thousand samples, and its median is 1.000 in the bands where the march actually stops; the march itself was subdivided and tested four million times for a step that lands inside the solid, and found none. Then the exhibit, which is why the vertices sit where they do. Place them at alternating corners of a cube and each of the three axes through the midpoints of a pair of opposite edges becomes a coordinate axis, and looking down one of them the four maps project onto the four quadrant-halving maps of a square — so the whole fractal projects onto the whole closed square, not merely onto its outline. A dust with a filled square for a shadow. Both view dials read degrees and the three axes are at azimuth 0 with elevation 0, at azimuth 90, and at elevation 90, so the reader lands on the identity exactly rather than near it. What a lit render shows there needs saying precisely, because it is not the shadow: a shadow is a parallel projection and a camera is not one. Under parallel rays the square is filled and this plate own march confirms it — forty thousand rays through the square, none of them missing, at every depth tried. Through the plate lens the same rays are a fan rather than a beam, and the ones near the edge of the frame slip between tetrahedra: about a ninth of them at the default lens, a twenty-fifth at the longest, a third at the widest. So lengthen the lens and the square closes up, and the outline tells the same story from the other side — its four corners sit exactly ninety degrees apart at every lens, while their distances from the centre differ by exactly the foreshortening of the throw they are seen at. Colour is the address. Every point of this set has an itinerary in the four maps, and the surface is painted by the first two symbols of it: which of the four copies the ray landed in, and which copy within that one. It rides a rising segment of the order palette and never crosses the dark middle of it, which is the sibling raymarch plate measured finding rather than a house rule. The camera does not move, and it cannot: rendering this way is 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. 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. One retired construction is worth naming, because this plate used to be it. Until this edition the picture was a chaos game: forty thousand landings of that same halving rule, plotted as a point cloud, which converges to this very set almost surely and is how most readers will have met it. It was not wrong and it is not gone — it is the Houdini port, where a cook wants points — but a cloud has no surface, so it had no light on it, and this object has one.
PROVENANCE
- Origin
- The plane figure: W. Sierpiński, Comptes Rendus Acad. Sci. Paris 160, 1915, 302-305. The theorem that licenses building the solid version -- and that gives the four maps a unique compact set to have as their attractor, stated for complete metric spaces in general and so covering three dimensions with no extra argument: J. E. Hutchinson, "Fractals and self similarity", Indiana Univ. Math. J. 30, 1981, 713-747. That citation carries more weight on this plate than it used to. It was here for the existence of the object while the picture was a random walk; now the object IS the theorem, since the distance the march steps by is derived from the four maps themselves rather than from any dynamics, and the whole estimate is nothing but Hutchinson self-similarity read as a measurement.
- The tetrahedron itself has no first publication
- Nobody discovered the Sierpiński tetrahedron in the sense a paper can cite: it is the direct three-dimensional reading of the 1915 Sierpiński construction and the 1981 Hutchinson theorem, and it enters the scholarly record sideways, as a worked example in fractal-lattice physics rather than as an object being introduced for its own sake -- Y. Gefen, B. B. Mandelbrot and A. Aharony, "Critical Phenomena on Fractal Lattices", Physical Review Letters 45, 1980, 855-858, doi:10.1103/PhysRevLett.45.855 (Crossref-verified 25 Aug 2026: title, all three authors, journal, volume and page all match). This entry says so plainly rather than dating the object to a paper that only uses it as an example lattice.
- The src caveat, carried forward
- The DOI record Hutchinson himself is cited under, doi:10.1512/iumj.1981.30.30055, resolves on Crossref with author, journal, volume 30 and starting page 713 all correct -- and an empty title field (re-checked 25 Aug 2026: the record carries a literal empty string for the title). The title, "Fractals and self similarity", is confirmed instead from a copy the author posted at the Australian National University and from the journal listing for the article itself, both fetched directly rather than assumed. This atlas verifies what it cites rather than treating a resolving DOI as a complete one. The identifier survived the change of renderer, which is worth recording because the sibling plate identifier did not: menger cited a theorem about plane SECTIONS and had to keep a cutting plane to go on citing it, where this one cites the fixed-point theorem the distance estimate is built out of. A plate that stops being a point cloud and becomes a distance field is MORE Hutchinson, not less.
- The chaos game, retired from the picture and kept as history
- Until this edition the plate drew this set by random iteration -- start anywhere, jump half the way toward a vertex drawn uniformly at random, discard the first twenty landings and plot the rest -- which is Michael Barnsley, Fractals Everywhere, Academic Press, 1988, and is still what chaosgame (PL. 16) draws one dimension down. It converges to the attractor almost surely, so it was drawing the same set; what it could not do is carry a surface, and therefore a light. The construction is not deleted: it is what the Houdini port cooks, where a point cloud is the natural object and a shader is not, and the port colours its points by the same address symbols the shader reads out of its fold -- the last vertices jumped toward ARE the first symbols of the address, which is the one place the two constructions visibly meet. Barnsley is cited here rather than in who, because who names the people whose mathematics the picture now runs on.
- Standing
- Public domain -- a plane figure from 1915 and a fixed-point theorem from 1981, neither patentable, and a random-walk algorithm from 1988 that this plate no longer runs. The rendering method is published literature of the same standing: sphere tracing, Lambert, Blinn and the ambient obscurance model are all papers, and none of them is or ever was encumbered.
- 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. 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 -- named for the method, and explicitly NOT for the estimate, which there is the Douady-Hubbard form for a squaring map and here is a similarity-scaled distance to a simplex. 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 are the ones the sibling raymarch plates carry, resolved against the live Crossref API for the wave and matched on title, authors, container, year and pages; Blinn 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 a bound, and how tight
- It is a bound by construction and the construction was checked rather than trusted, twice, against an INDEPENDENT enumeration of the object: a recursive descent over the actual 4-to-the-k cells, each cell measured exactly as a tetrahedron, sharing no line of reasoning with the fold. First the membership test, which is the thing the estimate sign has to agree with: 200,000 uniform points at each of depths 1, 2, 3, 4, 6 and 8, 1.2 million in all, and the fold and the enumeration disagree about ZERO of them. That check earned its keep immediately, because the first version of the enumeration failed it -- it composed the four maps in the order that enumerates the same cells in a tree whose parents do not contain their children, so its bounding-sphere prune discarded the nearest cell and it called a handful of interior points exterior. The fold was right and the checker was wrong, which is the correct outcome to record: a verification is only worth what its own verification is worth. With that fixed: 5,000 exterior points at each of depths 3, 4, 5 and 6, and the ratio of the estimate to the exact distance NEVER exceeds 1 -- not one sample of twenty thousand, at any depth, with a maximum of exactly 1.000000000. Its median is 0.989 to 0.994 overall and 1.000 in both bands nearest the surface, which is where a march actually stops; the mean sits at 0.963 to 0.990 because far from the object the residual simplex is a slightly smaller thing than the attractor, and the worst single sample returns 0.58 of the true clearance. Interior points, 3,200 of them: not one returned a non-negative distance, and the magnitude never exceeded the true depth. Then the march itself, which is the unbiased test: 47,596 rays at five camera settings spanning both named views and both ends of the depth dial, 338,037 full steps, every step subdivided twelve ways and every sub-point tested for membership by the enumeration -- 4,056,444 tests, ZERO penetrating steps. The march is short because the bound is nearly the distance: the median ray finishes in 4 to 5 full steps and the 99th percentile in 19 to 31.
- The framing, which is computed rather than measured
- The camera sits at a distance derived from how wide the object reads from where it is standing, so it holds the same size in frame as the view turns and only its shape changes. That width is exact arithmetic rather than a sampled bound: every vertex is at the circumradius sqrt(3)/2 from the centre, so it stands sqrt(3/4 - (v dot a)^2) off the view axis, and the silhouette radius is the largest of the four -- four dot products, nothing sampled. It is the ATTRACTOR silhouette and not merely the hull one, because each vertex is the fixed point of its own map and so belongs to the set. The radius runs from sqrt(1/2) = 0.7071 straight down an edge-midpoint axis, through sqrt(2/3) = 0.8165 down a vertex, to sqrt(3)/2 = 0.8660 wherever a vertex stands perpendicular to the view, and the camera is placed at 1.10 of it on the frame half-height. The frame is built from the azimuth alone for its right vector, which is a unit vector perpendicular to the view at EVERY elevation: a frame crossed against a fixed world up degenerates at the poles, and this plate cannot afford that, since elevation 90 is one of the three axes the whole exhibit is about.
- The square, and what a lit render of it actually shows
- The projection identity is exact and it is checked as an identity, not sampled: dropping the coordinate along an edge-midpoint axis sends the four vertices (1,1,1)/2, (1,-1,-1)/2, (-1,1,-1)/2 and (-1,-1,1)/2 onto (1,1)/2, (1,-1)/2, (-1,1)/2 and (-1,-1)/2, the four corners of a square, compared component for component in the harness and true along all three coordinate axes; so the four spatial maps become the four quadrant-halving maps of that square, whose attractor is the filled square. The rest is what a RENDER of that fact can honestly claim, and the two differ. Under parallel rays the fill is exact and the plate own march shows it: 40,000 rays through the square along the axis, at each of depths 4, 8 and 12, and not one of them finds nothing. Tilt those parallel rays and it goes at once -- 1.43 per cent see through at half a degree, 7.26 at two degrees, 27.80 at eight. A perspective camera is a fan of tilted rays by construction, so the plate cannot show the shadow, and what it shows instead was measured across the lens dial at the default depth: 4.05 per cent of the rays through the square see through at the longest lens on the slider, 6.67 at 8 degrees, 10.83 at the default 14, 21.00 at 30 and 32.09 at the widest -- against 0 in the parallel limit the theorem is about (the figures at the plate own stopping distance rather than at a sharp surface are about a third lower, 1.80 to 27.95, because a march that stops a fraction of a pixel out closes the finest gaps). The outline says the same thing from the other side. At the axis its four corners sit at exactly 45, 135, 225 and 315 degrees about the frame centre at every lens tried, so the figure is a square in its angles; their radii are NOT equal, and that difference is the finding rather than an error -- the two near corners stand at 0.5 along the view axis and the two far ones at -0.5, so the ratio of the radii should be (throw - 0.5)/(throw + 0.5), and measured it is 0.9454 against 0.9454 at lens 5, 0.9139 against 0.9140 at lens 8, and 0.8537 against 0.8537 at the default. Past about lens 20 the near corners leave the frame and the measurement stops meaning anything. So the honest sentence, which is the one the note makes: the shadow of this set is the filled square, and the lens is how close a lit render is allowed to come to saying so.
- What one paint can afford
- One paint cannot afford a frame of this, so a paint is a batch. The grid is capped at 500 cells across, which is 318 by 256 at a card (the card is narrower than the cap, so it renders one cell per CSS pixel) and 500 by 333 at the drawer. The batch is exactly the eighth-resolution pass -- ceil(gw/8) by ceil(gh/8) cells, or 700 rays, whichever is larger -- which makes the first paint a whole coarse picture by construction rather than by arithmetic that happens to work out; the formula is the sibling plate own correction, inherited rather than rediscovered. Measured in Chrome on this machine at the default constants: a card is 81,408 rays in 64 batches, first batch 1,280 rays, warm median 1.60 ms and 95th percentile 2.10, 84 ms in total, then 0.14 ms a frame for ever because a developed picture is a blit; the drawer at 890 by 593 is 166,500 rays in 63 batches at a median of 2.40 ms, 186 ms; a 240-square thumbnail is 57,600 rays and 68 ms; the 2048-wide export runs the same 166,500 rays and 412 ms, the difference being the full-size blit rather than the march. Across the whole depth dial a batch runs 1.10 to 2.80 ms at a card and 1.80 to 4.20 at the drawer. The batch count is the constraint that decides the cap: renderStill develops an exposure over 241 draws, and a cap whose full pass needed more than that would ship a half-traced thumbnail and a half-traced export. 64 leaves it a factor of 3.8, and all six of renderStill callers were run against this entry with its draw calls counted to be sure the branch is the one that gets 241 rather than the 49 a drift plate gets.
- The two rendering paths, checked
- Both paths run the same march and they are compared rather than assumed to agree, at 20 parameter tuples -- both named views, the third coordinate axis, both ends of the depth dial, both ends of the lens, and twelve drawn over all five constants -- at a matched grid where the JS cap and the shader resolution are the same 400 by 322, 7,728,000 channels. As shipped the mean channel difference is 0.191 levels in 255, 2.15 per cent of channels differ by more than one, and 0.111 per cent of pixels disagree about whether they hit the surface. Bake the camera basis, the throw, the lens and the light direction into the shader as the same float64 numbers the JS path computes and that falls to a mean of 0.021, with 0.01 per cent of channels over one level and 0.004 per cent silhouette disagreement, the worst of the twenty tuples reading 0.041. So essentially the whole residue as shipped is the shader taking the sine and cosine of its own view angles at float32 and turning the camera by a fraction of a degree -- there is nothing left over for the march, the fold or the ramp to be blamed for, which is a stronger result than either sibling raymarch plate reports and is what an exactly self-similar distance function buys.
- What the shader costs, on the record
- A sphere trace at backing resolution is expensive and on a software renderer that is worth stating rather than discovering. Measured in the browser gate own Chrome forced onto ANGLE over SwiftShader, which is the machine with no GPU at all: a 318 by 256 card frame is 7.5 ms at the default depth, 4.6 at the bottom of the dial and 12.7 at the top, against 17.9 for menger, 26.3 for mandelbulb, 55.5 for mandelbox, 22.7 for metamer, 8.7 for mandelbrot and 1.2 for gyroid. So this is the cheapest of the four raymarched plates by a factor of two and it is cheaper at its default than mandelbrot, which is a plane escape-time field; at the drawer with device pixel ratio 2 it is 161 ms against menger 328, metamer 585 and mandelbox 1,212. (One machine and one session: the ratios travel, the absolutes do not.) The reason it is cheap is the estimate: the median ray finishes in four to five full steps because the bound is the distance rather than a fraction of it. The march budget follows from that and is not a slider: the picture is bit-identical from 48 steps at a card grid and from 96 at the drawer at device pixel ratio 2, at the worst setting tried rather than at the default, so the body carries a fixed 128 with a wide margin and a reader is not offered a dial whose whole travel does nothing. The reader who wants this plate cheap has GPU OFF, which puts it on the JS path at 1.6 ms a batch.
- Constants, measured
- Re-swept from nothing, because all five constants are new -- the plate that stood here had a point count, a tumble amplitude and a rotation speed, and a lit sphere trace has none of those. params[] is a wire format and append-only once shipped, so the three were repurposed IN PLACE and two appended: count became depth, which is the recursion cutoff the march descends to; tilt became elev, which is where the view leans, and it governed the same thing the old tumble did, only as a place rather than an amplitude; spin became az, which is where the view stands about the vertical, and it likewise governed the old turn as a place rather than a rate. lens and lightaz are appended. An old bench link therefore lands its three values on constants that mean something related but different, and reads them clamped: a point count of 32,000 clamps to depth 12, a tumble of 0.9 lands at elevation 0.9 degrees and a spin of 0.09 at azimuth 0.09 degrees -- which is a legible picture near the square view rather than a broken one, and strictly better than the silent remap that deleting entries would have caused. Nothing is locked. depth looks like the cost dial and is not one: a batch runs 1.10 ms at depth 2 and 2.80 at depth 12 at a card, because a marching ray spends its life outside the solid where the fold is a handful of reflections whatever the depth; nor is it exhausted, each step still moving 7 to 14 per cent of the frame, with the amplitude of the change falling from 66 levels to 9 as the new cells drop below what a card can sample. The other four are the camera and the light, and a camera whose throw is derived from the object own silhouette radius cannot be jittered into looking at nothing, while a key fixed relative to the eye cannot be jittered into darkness. Swept as regenerate actually jitters -- all five at once, 24 seeds at two sizes over 120 frames, 5,760 renders -- 0 throws, 0 plates below the contrast floor, pixelRange worst 104 and median 157 against the gate floor of 14. Over the whole declared box instead, 40 uniform tuples at two sizes, 9,600 renders: still 0 and 0, with a worst of 64.
- The colour, and why the trough is refused here
- The order palette is brightest near t = 0 and t = 1 and essentially black at t = 0.5, and the standing rule is that a sparse mark stays in the bright lobe while a field may cross the trough deliberately. A lit solid looks like the second case, and menger settled that it is not: what ships here is the same RISING segment of the bright lobe, 0.72 to 0.97, which stops at the lobe own peak instead of straddling it. That correction is inherited rather than re-derived, and it matters more here than there, because what the colour carries is a four-valued symbol and not a continuum: a span that straddles the peak has the same luminance at both ends and a brighter middle, so the four copies would come out as three tones. What the trap is: the first two symbols of the point address in the four maps, read off the fold as which chamber the point started in and which it started in one level down -- sixteen values on a rising ramp, so each of the four quarters of the object gets its own tone and each quarter own four sub-copies shade within it. That is the self-similarity painted onto the surface with the same recursion the geometry uses.
- Cross-reference
- chaosgame, plate 16, is the random-walk construction of this same family one dimension down, and it is where a reader who wants to watch the chaos game itself should go -- it credits Barnsley and Sierpiński, and its v = 3, r = 1/2 setting is the plane version of exactly this object. sierpinski, plate 107, carries the Sierpiński name too but is a different figure built by a different rule, the carpet, subdivided by nines with the centre removed rather than jumped toward. menger, plate 147, is the closest neighbour of all now: the same kind of picture -- an exactly self-similar solid, sphere traced with an exact distance rather than an estimated one -- from a rule about ternary digits rather than about vertices, and the two plates were built to be read side by side.
- Source
- doi:10.1512/iumj.1981.30.30055
HOUDINI · VEX
The same published mathematics as a Detail Wrangle body. Paste it into a Wrangle with Run Over set to Detail; every constant is the published value plus a tweak channel, so Create Spare Parameters gives a slider that starts where the paper does.
// FORMA — PL. 151 · SIERPIŃSKI TETRAHEDRON — Sierpiński, 1915 · Hutchinson, 1981
// A = ∪ₖ fₖ(A), fₖ(p) = (p + vₖ)/2, vₖ the 4 tetrahedron vertices
// dim_H = log 4 / log 2 = 2 exactly
// fold in x+y=0, x+z=0, y+z=0, then p → 2p − v₁, k times
// d(p) = d_simplex(p_k) · 2⁻ᵏ — every map a similarity, so 2ᵏ is exact
// sphere trace: t → t + d(o + t·û); n = ∇d / |∇d|
// Paste into a Detail Wrangle (Run Over: Detail), no inputs needed.
// Written from the published mathematics, not adapted from any code.
// Constants arrive at their published values. Press the node's Create
// Spare Parameters button and every tweak becomes a slider — starting
// at 0, the published figure, and moving in the constant's own units.
// https://forma-gen.com/#plate=tetrix
float p_depth = 5 + chf('depth_tweak'); // recursion depth · live 2 .. 12
// The plate's own colour: FORMA's FRACTALS accent as a cosine ramp,
// brightest near t = 0 and t = 1, near-black around t = 0.5.
vector forma_ramp(float t){
return set(
0.46 + 0.5 * cos(6.28318530718 * (t + 0)),
0.1389 + 0.151 * cos(6.28318530718 * (t + 0.05)),
0.1912 + 0.2078 * cos(6.28318530718 * (t + 0.1)));
}
// waived: elev, az, lens, lightaz — the page's camera and its key light; a cook has no camera to point and no lamp to place, Houdini's own viewport and lights are those, the same reasoning apollonian.vex's waived spin and schlegel.vex's waived tilt already give
//
// THE PAGE NOW RENDERS THE SOLID. What the plate draws is the attractor
// itself, lit and sphere traced, from a distance derived from the same four
// similarities this port iterates. A shader plate needs no Houdini port at
// all — menger, mandelbulb and mandelbox ship none — and this one is kept
// rather than retired because Houdini wants geometry, and because the chaos
// game is the same attractor by its other published construction: Barnsley,
// Fractals Everywhere, 1988, which converges almost surely to the fixed set
// Hutchinson's theorem gives the four maps. Two readings of one object, one
// per side of the port.
//
// The construction: start anywhere, jump half the distance toward a vertex of
// a regular tetrahedron picked at random, forever. The four vertices are the
// alternating corners of the unit cube — (1,1,1)/2, (1,-1,-1)/2, (-1,1,-1)/2,
// (-1,-1,1)/2 — the plate's own placement, chosen so that each of the three
// axes through the midpoints of a pair of opposite edges is one of Houdini's
// coordinate axes. Sight down any one of them in the viewport and the shadow
// of this cloud is a filled square; that identity is the plate's exhibit and
// it survives the port, because it is a fact about the set rather than about
// the renderer. No axis relabelling and no y negation: the three coordinate
// axes are interchangeable by the tetrahedron's own symmetry, so there is no
// preferred canvas plane or up axis to map onto.
//
// The jump ratio is exactly one half and is not a p_<k>: no other value
// produces this object. The first 20 landings are discarded, chaosgame.vex's
// own margin against the walk that has not yet reached the attractor.
// Deterministic: the vertex choice is random(counted seed), so two cooks give
// identical geometry.
//
// p_depth is the plate's recursion cutoff and it reaches the cloud as
// RESOLUTION rather than as a different object — the chaos game converges to
// the limit set and never to a level-k approximation of it, so what depth can
// honestly buy here is enough landings to resolve that level: 4^depth cells
// at level depth, sixteen points apiece. It is clamped at 7 for the cook
// (262,144 points), because the plate's own dial reaches 12 and 4^12 cells at
// sixteen points each is 268 million, which is not a port but a hang.
//
// The colour is the plate's colour, and the two constructions meet exactly
// here: the last vertex jumped toward IS the first symbol of the point's
// address, and the one before it is the second, which is what the shader
// reads out of its fold and rides along a rising segment of the order's
// bright lobe. Same address, same ramp, same picture.
float vx[] = {0.5, 0.5, -0.5, -0.5};
float vy[] = {0.5, -0.5, 0.5, -0.5};
float vz[] = {0.5, -0.5, -0.5, 0.5};
int lev = min(int(rint(p_depth)), 7);
int total = 16 * int(pow(4.0, float(lev)));
int skip = 20;
float x = 0.0, y = 0.0, z = 0.0;
int rc = 20; // the plate's own seed, counted upward
int k1 = 0, k2 = 0; // the two most recent address symbols
for (int i = 0; i < total + skip; i++){
int k = int(floor(random(rc) * 4.0)); rc++;
k2 = k1; k1 = k;
x = (x + vx[k]) * 0.5;
y = (y + vy[k]) * 0.5;
z = (z + vz[k]) * 0.5;
if (i < skip) continue;
int pt = addpoint(0, set(x, y, z));
float trap = (float(k1) + 0.25 * float(k2)) / 3.75;
setpointattrib(0, "Cd", pt, forma_ramp(0.72 + 0.25 * trap));
}
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. 151 · SIERPIŃSKI TETRAHEDRON — Sierpiński, 1915 · Hutchinson, 1981
// A = ∪ₖ fₖ(A), fₖ(p) = (p + vₖ)/2, vₖ the 4 tetrahedron vertices
// dim_H = log 4 / log 2 = 2 exactly
// fold in x+y=0, x+z=0, y+z=0, then p → 2p − v₁, k times
// d(p) = d_simplex(p_k) · 2⁻ᵏ — every map a similarity, so 2ᵏ is exact
// 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.536; // 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_depth = 5.0; // recursion depth · live 2 .. 12
const float p_elev = 12.0; // view elevation ° · live -90 .. 90
const float p_az = -12.0; // view azimuth ° · live -180 .. 180
const float p_lens = 14.0; // lens — vertical field of view ° · live 5 .. 55
const float p_lightaz = -52.0; // key light azimuth ° · live -180 .. 180
/* 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's own backing resolution and in one pass.
// Every helper is tetrix_ prefixed and nothing is declared at file scope, so
// a composition of this plate with itself still links.
//
// The distance is the IFS's own: fold into one vertex's chamber across the
// three mirrors of the tetrahedron, undo that vertex's half-scale similarity,
// repeat, and measure to the residual simplex at the accumulated scale. Every
// map is a similarity, so the running scalar is an exact Lipschitz bound.
float tetrix_de(vec3 pos, float d, out float trap){
float x = pos.x, y = pos.y, z = pos.z, sw;
// the first symbol of the point's IFS address, read before any folding
float a0 = x + y + z, b0 = x - y - z, c0 = -x + y - z, e0 = -x - y + z;
float k1 = 0.0, m0 = a0;
if (b0 > m0){ k1 = 1.0; m0 = b0; }
if (c0 > m0){ k1 = 2.0; m0 = c0; }
if (e0 > m0){ k1 = 3.0; m0 = e0; }
float k2 = 0.0, s = 1.0;
// 12 is the declared ceiling of p_depth -- mandelbrot fashion: a constant
// loop bound at the slider's own maximum, breaking on the live value.
for (int i = 0; i < 12; i++){
if (float(i) >= d) break;
if (x + y < 0.0){ sw = -y; y = -x; x = sw; }
if (x + z < 0.0){ sw = -z; z = -x; x = sw; }
if (y + z < 0.0){ sw = -z; z = -y; y = sw; }
x = 2.0 * x - 0.5; y = 2.0 * y - 0.5; z = 2.0 * z - 0.5;
s *= 2.0;
if (i == 0){
float a1 = x + y + z, b1 = x - y - z, c1 = -x + y - z, e1 = -x - y + z;
float m1 = a1;
k2 = 0.0;
if (b1 > m1){ k2 = 1.0; m1 = b1; }
if (c1 > m1){ k2 = 2.0; m1 = c1; }
if (e1 > m1){ k2 = 3.0; m1 = e1; }
}
}
trap = k1 + 0.25 * k2;
float fa = -0.5 - (x + y + z), fb = -0.5 - (x - y - z);
float fc = -0.5 - (-x + y - z), fe = -0.5 - (-x - y + z);
float face = max(max(fa, fb), max(fc, fe)) * 0.5773502691896258;
float ball = sqrt(x * x + y * y + z * z) - 0.8660254037844386;
return max(face, ball) / s;
}
float tetrix_eps(float tt, float pxa){ return max(2.0e-6, 0.25 * tt * pxa); }
float tetrix_march(vec3 ro, vec3 rd, float d, float t0, float t1, float pxa, out float trap){
float tt = t0, hit = -1.0;
trap = 0.0;
for (int i = 0; i < 128; i++){
float tr;
float dd = tetrix_de(ro + rd * tt, d, tr);
if (dd < tetrix_eps(tt, pxa)){ trap = tr; hit = tt; break; }
tt += dd;
if (tt > t1) break;
}
return hit;
}
vec3 tetrix_normal(vec3 pos, float d, float e){
float tr;
float gx = tetrix_de(pos + vec3(e, 0.0, 0.0), d, tr) - tetrix_de(pos - vec3(e, 0.0, 0.0), d, tr);
float gy = tetrix_de(pos + vec3(0.0, e, 0.0), d, tr) - tetrix_de(pos - vec3(0.0, e, 0.0), d, tr);
float gz = tetrix_de(pos + vec3(0.0, 0.0, e), d, tr) - tetrix_de(pos - vec3(0.0, 0.0, e), d, tr);
return normalize(vec3(gx, gy, gz));
}
float tetrix_shadow(vec3 pos, vec3 l, float d, float t0, float t1){
float k = 1.0, tt = t0;
for (int i = 0; i < 16; i++){
float tr;
float dd = tetrix_de(pos + l * tt, d, tr);
if (dd < 2.0e-6){ k = 0.0; break; }
k = min(k, 11.0 * dd / tt);
tt += dd;
if (tt > t1) break;
}
return clamp(k, 0.0, 1.0);
}
float tetrix_obscurance(vec3 pos, vec3 n, float d, 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 dd = 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 sv = tetrix_de(pos + dd * tt, d, tr);
if (sv < e){ hit = tt; break; }
tt += max(sv, 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 BOUND = 0.8660254037844386;
float d = floor(p_depth + 0.5);
// Two angles carry the camera. RT comes from the azimuth alone, so it is a
// unit vector perpendicular to the view at every elevation -- nothing
// degenerates at 90, where one of the three edge-midpoint axes lives.
float az = p_az * DEG, el = p_elev * DEG;
float ca = cos(az), sa = sin(az), ce = cos(el), se = sin(el);
vec3 AX = vec3(ce * sa, se, ce * ca);
vec3 RTv = vec3(ca, 0.0, -sa);
vec3 UPv = cross(AX, RTv);
// the exact silhouette radius from this direction: every vertex is at
// BOUND from the centre, so it stands sqrt(BOUND^2 - (v.AX)^2) off the axis
float m1 = abs(0.5 * (AX.x + AX.y + AX.z));
float m2 = abs(0.5 * (AX.x - AX.y - AX.z));
float m3 = abs(0.5 * (-AX.x + AX.y - AX.z));
float m4 = abs(0.5 * (-AX.x - AX.y + AX.z));
float mm = min(min(m1, m2), min(m3, m4));
float silh = sqrt(max(0.0, 0.75 - mm * mm));
float htan = tan(0.5 * p_lens * DEG);
float dist = silh * 1.1 / htan;
vec3 ro = AX * dist;
vec3 fw = -AX;
float la = p_lightaz * DEG;
vec3 kc = vec3(sin(la) * 0.7762, 0.6305, -cos(la) * 0.7762);
vec3 key = RTv * kc.x + UPv * kc.y + fw * kc.z;
vec3 fil = RTv * 0.9048 + UPv * -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 + RTv * (sx * htan * asp) + UPv * (sy * htan));
vec3 col = vec3(4.0, 6.0, 10.0) / 255.0;
float bq = dot(ro, rd);
float cq = dot(ro, ro) - 0.75;
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 && t0 < t1){
float trap;
float th2 = tetrix_march(ro, rd, d, t0, t1, pxa, trap);
if (th2 > 0.0){
vec3 pos = ro + rd * th2;
float e = tetrix_eps(th2, pxa);
vec3 n = tetrix_normal(pos, d, 1.2 * e);
float ndl = max(0.0, dot(n, key));
float sh = 0.0;
if (ndl > 0.0) sh = tetrix_shadow(pos + n * (3.0 * e), key, d, 3.0 * e, 1.7320508075688772);
float ndf = max(0.0, dot(n, fil));
float ao = tetrix_obscurance(pos, n, d, e, 0.15 * 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 / 3.75, 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. 151 · SIERPIŃSKI TETRAHEDRON — Sierpiński, 1915 · Hutchinson, 1981
// A = ∪ₖ fₖ(A), fₖ(p) = (p + vₖ)/2, vₖ the 4 tetrahedron vertices
// dim_H = log 4 / log 2 = 2 exactly
// fold in x+y=0, x+z=0, y+z=0, then p → 2p − v₁, k times
// d(p) = d_simplex(p_k) · 2⁻ᵏ — every map a similarity, so 2ᵏ is exact
// 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_tetrix : ImageComputationKernel<ePixelWise>
{
Image<eWrite> dst;
param:
float u_t; // seconds; 0 is the still frame
float p_depth; // recursion depth · live 2 .. 12
float p_elev; // view elevation ° · live -90 .. 90
float p_az; // view azimuth ° · live -180 .. 180
float p_lens; // lens — vertical field of view ° · live 5 .. 55
float p_lightaz; // key light azimuth ° · live -180 .. 180
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_depth, "p_depth", 5.0f);
defineParam(p_elev, "p_elev", 12.0f);
defineParam(p_az, "p_az", -12.0f);
defineParam(p_lens, "p_lens", 14.0f);
defineParam(p_lightaz, "p_lightaz", -52.0f);
}
void init(){
u_res = float2(float(dst.bounds.width()), float(dst.bounds.height()));
u_phase = 0.536f; // 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's own backing resolution and in one pass.
// Every helper is tetrix_ prefixed and nothing is declared at file scope, so
// a composition of this plate with itself still links.
//
// The distance is the IFS's own: fold into one vertex's chamber across the
// three mirrors of the tetrahedron, undo that vertex's half-scale similarity,
// repeat, and measure to the residual simplex at the accumulated scale. Every
// map is a similarity, so the running scalar is an exact Lipschitz bound.
float tetrix_de(float3 pos, float d, float& trap){
float x = pos.x;
float y = pos.y;
float z = pos.z;
float sw;
// the first symbol of the point's IFS address, read before any folding
float a0 = x + y + z;
float b0 = x - y - z;
float c0 = -x + y - z;
float e0 = -x - y + z;
float k1 = 0.0f;
float m0 = a0;
if (b0 > m0){ k1 = 1.0f; m0 = b0; }
if (c0 > m0){ k1 = 2.0f; m0 = c0; }
if (e0 > m0){ k1 = 3.0f; m0 = e0; }
float k2 = 0.0f;
float s = 1.0f;
// 12 is the declared ceiling of p_depth -- mandelbrot fashion: a constant
// loop bound at the slider's own maximum, breaking on the live value.
for (int i = 0; i < 12; i++){
if (float(i) >= d) break;
if (x + y < 0.0f){ sw = -y; y = -x; x = sw; }
if (x + z < 0.0f){ sw = -z; z = -x; x = sw; }
if (y + z < 0.0f){ sw = -z; z = -y; y = sw; }
x = 2.0f * x - 0.5f; y = 2.0f * y - 0.5f; z = 2.0f * z - 0.5f;
s *= 2.0f;
if (i == 0){
float a1 = x + y + z;
float b1 = x - y - z;
float c1 = -x + y - z;
float e1 = -x - y + z;
float m1 = a1;
k2 = 0.0f;
if (b1 > m1){ k2 = 1.0f; m1 = b1; }
if (c1 > m1){ k2 = 2.0f; m1 = c1; }
if (e1 > m1){ k2 = 3.0f; m1 = e1; }
}
}
trap = k1 + 0.25f * k2;
float fa = -0.5f - (x + y + z);
float fb = -0.5f - (x - y - z);
float fc = -0.5f - (-x + y - z);
float fe = -0.5f - (-x - y + z);
float face = forma_max(forma_max(fa, fb), forma_max(fc, fe)) * 0.5773502691896258f;
float ball = sqrt(x * x + y * y + z * z) - 0.8660254037844386f;
return forma_max(face, ball) / s;
}
float tetrix_eps(float tt, float pxa){ return forma_max(2.0e-6f, 0.25f * tt * pxa); }
float tetrix_march(float3 ro, float3 rd, float d, float t0, float t1, float pxa, float& trap){
float tt = t0;
float hit = -1.0f;
trap = 0.0f;
for (int i = 0; i < 128; i++){
float tr;
float dd = tetrix_de(ro + rd * tt, d, tr);
if (dd < tetrix_eps(tt, pxa)){ trap = tr; hit = tt; break; }
tt += dd;
if (tt > t1) break;
}
return hit;
}
float3 tetrix_normal(float3 pos, float d, float e){
float tr;
float gx = tetrix_de(pos + float3(e, 0.0f, 0.0f), d, tr) - tetrix_de(pos - float3(e, 0.0f, 0.0f), d, tr);
float gy = tetrix_de(pos + float3(0.0f, e, 0.0f), d, tr) - tetrix_de(pos - float3(0.0f, e, 0.0f), d, tr);
float gz = tetrix_de(pos + float3(0.0f, 0.0f, e), d, tr) - tetrix_de(pos - float3(0.0f, 0.0f, e), d, tr);
return normalize(float3(gx, gy, gz));
}
float tetrix_shadow(float3 pos, float3 l, float d, float t0, float t1){
float k = 1.0f;
float tt = t0;
for (int i = 0; i < 16; i++){
float tr;
float dd = tetrix_de(pos + l * tt, d, tr);
if (dd < 2.0e-6f){ k = 0.0f; break; }
k = forma_min(k, 11.0f * dd / tt);
tt += dd;
if (tt > t1) break;
}
return forma_clamp(k, 0.0f, 1.0f);
}
float tetrix_obscurance(float3 pos, float3 n, float d, 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 dd = 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 sv = tetrix_de(pos + dd * tt, d, tr);
if (sv < e){ hit = tt; break; }
tt += forma_max(sv, 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 BOUND = 0.8660254037844386f;
float d = floor(p_depth + 0.5f);
// Two angles carry the camera. RT comes from the azimuth alone, so it is a
// unit vector perpendicular to the view at every elevation -- nothing
// degenerates at 90, where one of the three edge-midpoint axes lives.
float az = p_az * DEG;
float el = p_elev * DEG;
float ca = cos(az);
float sa = sin(az);
float ce = cos(el);
float se = sin(el);
float3 AX = float3(ce * sa, se, ce * ca);
float3 RTv = float3(ca, 0.0f, -sa);
float3 UPv = cross(AX, RTv);
// the exact silhouette radius from this direction: every vertex is at
// BOUND from the centre, so it stands sqrt(BOUND^2 - (v.AX)^2) off the axis
float m1 = fabs(0.5f * (AX.x + AX.y + AX.z));
float m2 = fabs(0.5f * (AX.x - AX.y - AX.z));
float m3 = fabs(0.5f * (-AX.x + AX.y - AX.z));
float m4 = fabs(0.5f * (-AX.x - AX.y + AX.z));
float mm = forma_min(forma_min(m1, m2), forma_min(m3, m4));
float silh = sqrt(forma_max(0.0f, 0.75f - mm * mm));
float htan = tan(0.5f * p_lens * DEG);
float dist = silh * 1.1f / htan;
float3 ro = AX * dist;
float3 fw = -AX;
float la = p_lightaz * DEG;
float3 kc = float3(sin(la) * 0.7762f, 0.6305f, -cos(la) * 0.7762f);
float3 key = RTv * kc.x + UPv * kc.y + fw * kc.z;
float3 fil = RTv * 0.9048f + UPv * -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 + RTv * (sx * htan * asp) + UPv * (sy * htan));
float3 col = float3(4.0f, 6.0f, 10.0f) / 255.0f;
float bq = dot(ro, rd);
float cq = dot(ro, ro) - 0.75f;
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 && t0 < t1){
float trap;
float th2 = tetrix_march(ro, rd, d, t0, t1, pxa, trap);
if (th2 > 0.0f){
float3 pos = ro + rd * th2;
float e = tetrix_eps(th2, pxa);
float3 n = tetrix_normal(pos, d, 1.2f * e);
float ndl = forma_max(0.0f, dot(n, key));
float sh = 0.0f;
if (ndl > 0.0f) sh = tetrix_shadow(pos + n * (3.0f * e), key, d, 3.0f * e, 1.7320508075688772f);
float ndf = forma_max(0.0f, dot(n, fil));
float ao = tetrix_obscurance(pos, n, d, e, 0.15f * 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 / 3.75f, 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);
}
};