Cubic
Symmetry
Engine

════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════

How a cubic becomes a shape
AKA
How a genotype is constructed

No textures, no model files. Every vertex gets computed at load from the Form’s seed. Here’s the whole chain, and where the math stops and I start. Heads up, it’s pretty technical. Not your thing? Skip straight to the lab and break stuff.

1 · The solution

Start with x³ + ax² + bx + c. Sub in y = x + a/3, the square term drops out and you get the depressed cubic y³ + py + q:

p = b − a²/3
q = 2a³/27 − ab/3 + c

Everything hangs on D = (α−β)(β−γ)(γ−α). Swap two roots and it flips sign, so it’s not symmetric. Square it though and it is, and that’s the discriminant:

Δ = D² = −4p³ − 27q²

Take ω = (−1 + i√3)/2, the primitive cube root of unity. The Lagrange resolvent (thanks Joseph) A = α + ωβ + ω²γ has a cube you can write purely in p, q and D, and the roots fall out of that:

A³ = −27q/2 − (3i√3/2)·D

α = A/3 − p/A
β = ω²A/3 − ωp/A
γ = ωA/3 − ω²p/A

Mess with the coefficients and watch the whole chain follow: depressed form, discriminant, class, the Form itself. Every figure on this page is the real pipeline running live.

Separatedy³ − 15.33y − 10.93Δ = 11195.59Separated · 3 Real Distinct
Fracturedy³ − 15.33y − 10.93Δ = 11195.59Separated · 3 Real Distinct
Near-degeneratey³ − 15.33y − 10.93Δ = 11195.59Separated · 3 Real Distinct
the square term the depression removes
sets p, and the glyph density
sets q, and the curvature

2 · The choice that becomes the Phase

A³ has three cube roots. Swap A for ωA and α → γ → β. Same set of roots, new labels. That leftover freedom, after you’ve picked a square root and then a cube root, is the only real wiggle room in the whole solution, and it’s what the Phase trait records. Each Form commits to one branch, which turns its ω-triad a third of a turn.

Early on I tried getting phase from arg(A). Turns out it’s nearly constant for most real-coefficient samples, with floating point noise deciding the rest, so 98.6% of generations got the same value and came out identical. Oops.

SeparatedSeparated · 3 Real DistinctLattice
FracturedFractured · 1 Real 2 ComplexWeave
Near-degenerateMerged · Triple RootAperture
same roots, relabeled. the triad turns a third

3 · From solution to Form

The sign of the discriminant picks a family:

ClassMeaningForms
Separated
Δ > 0 · ~36%
three distinct real roots, bodies sit apart on the real axisGrid, Lattice, Tessellation, Tower, Strata, Labyrinth, Fold, Prism
Fractured
Δ < 0 · ~55%
one real root and a complex conjugate pair, the form breaks openKnot, Exploded, Cascade, Spiral, Shell, Orbital, Weave, Rift, Bloom, Arbor
Merged
Δ ≈ 0 · ~9%
a repeated root, bodies collapse into each otherVoid, Radial, Vessel, Aperture

Family sizes just follow how often each class actually turns up in random real cubics, and if I split them evenly the Merged forms would each get a handful of Forms while a couple of Fractured ones ate the whole collection.

The repeated-root share is the one number I set instead of measuring, because random real cubics basically never land on exactly Δ = 0 in floating point, so I inject that tier on purpose at 8% of supply, which is enough for all four Merged forms to show up in 512 and it’s still the rarest tier by a mile.

Inside a family, the coefficients drive everything:

aglobal scale and framing|a| sets how tight the camera fits
bglyph grid density80 to 200 cells a side, |b| sets how fine
ccurvaturebends, twists and opens primitives, also picks the archetype quartile
a/3world translationthe depression's own shift, relative to the Form's size
Δfracture magnitudelog-compressed, sets how hard the discriminant operator pushes things apart
arg(A)palettethe resolvent's argument picks the ANSI ramp
branchphasewhich cube root of A³ gets used, turns the ω-triad a third
rootsbody placementα, β, γ as points in ℂ, normalised to the unit disc
max |root|energyhow far out the roots sit, the scale the unit-disc normalisation throws away
min |αᵢ−αⱼ|feedbackhow close the nearest two roots are, sets how hard the render feeds back on itself

Same three solutions again. |c| bumps them between archetypes when it crosses a quartile boundary, |b| changes how fine the grid is, |a| moves the camera in or out.

SeparatedΔ = 11195.59Lattice
FracturedΔ = 11195.59Tessellation
Near-degenerateΔ = 11195.59Tower

4 · Energy and Feedback

Two traits read numbers the rest of the derivation throws away. Energy is the biggest root modulus, which matters because the roots get normalised to the unit disc before they place anything, and that used to toss the absolute scale so two solutions with the same root triangle at totally different sizes drew the same. Energy hands that number back, a Still Form keeps its bodies tight and a Violent one flings them apart, and it’s the one lever that reaches all twenty-two forms.

Feedback is the gap between the two closest roots, which Δ can’t see since it’s a product and one tiny gap hides behind two big ones. It closes a loop that’s normally open: ASCII renderers, mine included until I fixed it, only run one way, geometry in and characters out. Now last frame’s grid feeds into the luminance this frame measures, so bright cells drag a trail behind them, quantised to the same twelve glyph levels so it bands instead of blurring. You mostly see it once things move.

The sliders move the roots, not the traits, and Energy and Feedback just follow.

SeparatedΔ = 753.83Energy Charged · Feedback Echo
FracturedΔ = 100.20Energy Still · Feedback Resonant
Near-degenerateΔ = 0.21Energy Charged · Feedback Runaway
how far out the roots sit
at 0 the closest two roots meet and Δ hits zero
EnergyStill 18% · Charged 53% · Violent 30%
FeedbackNone 52% · Echo 25% · Resonant 14% · Runaway 9%

5 · The engine

This is the engine part of the name. three.js handles the 3D and that’s about it, everything after is my own pipeline. The scene renders offscreen at three by three samples per character cell, gets read back, and each cell’s luminance picks one of twelve glyphs from .:-=+*#%▒▓█ and one of five inks from the Form’s ANSI palette, with the properly saturated cells getting the accent. Then it paints the grid onto a 2D canvas character by character, batched by ink so it stays fast, and the SVG export is that same grid as plain text rows, so the vector version is the actual characters and not a trace.

Depth fog gives the ramp something to shade with or evenly lit surfaces flatten into one character, and exposure gets set per Form off a histogram of only the cells that actually have light in them, otherwise a sparse Orbital and a dense Tessellation get crushed in opposite directions.

6 · Sound

The lab can listen. Share a tab’s audio or pick a mic or line-in (BlackHole gets you system audio on a Mac) and it splits the signal into bass, mids and highs, each measured against a slowly fading peak so quiet and loud tracks both use the full range. The kick gets its own path, a steep band-pass around 60 Hz you can retune, measured against a floor that follows the level between kicks so a rolling sub doesn’t hold the form open, and the spacing between kicks gives a BPM with a flywheel that keeps pumping through breakdowns.

None of it touches the math or the genotype, it’s a live layer on top. The kick pumps the size, mids push the glyph feedback, highs and kicks add spin, hi-hats tilt it and tear the raster, all eased so it moves instead of twitching, and exports come out clean.

7 · Uniqueness

Every seed is SHA-256(master ‖ id ‖ nonce) and generation tosses anything too close to an existing Form on two gates, one comparing the math as a weighted vector and one comparing a 496-bit hash of the actual render, re-rolling the nonce until it passes. It’s all settled before the mint, minting only takes ids so nobody can sneak in their own coefficients, and the whole id → seed table is hashed into the contract with no setter, so you can check the art you saw is the art you got:

On chaincontract not deployed yet
Publishedloading…
Metadataafter the mint

8 · What the math doesn’t decide

Being straight about this. The math decides the coefficients, the depressed form, Δ and its sign, the three roots as points in ℂ, the resolvent and its branch. Across the whole supply the worst residual |p(root)| is 1.3 × 10⁻⁷, so yes, the roots actually solve their equations.

The math does not decide that Δ < 0 should look like a knot and not a tower. The 22 archetypes are a vocabulary I wrote. The equation picks from it and drives every parameter inside, but the vocabulary is a design call, not a theorem. The primitives (torus knot, icosahedron, cone) are parametric generators from three.js, built at runtime, never loaded as assets.

Sources

════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════
DNBRADIO · press play