A full breakdown of the generative evolution engine. Includes info on genome structures, mutation types, and how the SVG animation page works.
evolveSVG is a generative art evolution engine that uses controlled randomization to evolve SVG patterns across generations. The entire application lives in a single HTML file (evolvesvg).
The interface is a 9-cell grid. Cell index 4 (center) always holds the current parent. The 8 surrounding cells hold independent mutations derived from that parent. Clicking any cell breeds 8 new mutations from it, placing it in the center.
Each genome is a plain JSON object fully describing one SVG pattern. It has three top-level fields: a color palette, a background, and an ordered list of layers.
// Top-level genome
{
palette: Color[] // 2–6 HSL colors
background: GradientFill // solid | linear | radial
layers: Layer[] // 1–8 composited layers
}
// Color — all values numeric
{
h: 0–360 // hue (wraps)
s: 10–100 // saturation (clamped)
l: 5–95 // lightness (clamped)
}
// Layer — core fields (all layers have these)
{
type: 'circle' | 'ellipse' | 'rect' | 'polygon' | 'path'
| 'dots_grid' | 'stripes' | 'crosshatch'
| 'checkerboard' | 'waves' | 'zigzag' | 'hexgrid'
cx, cy: 0–1 // normalized position
params: {} // type-specific (radius, size, …)
fill: Fill // solid | linear | radial gradient
stroke: Stroke
opacity: 0.05–1
blendMode: string // CSS blend mode
transform: Transform // rotation, scaleX/Y
arrangement: 'single' | 'grid' | 'radial' | 'scatter'
arrangementParams: {}
// Optional advanced effects (any combination)
displace?: // feTurbulence + feDisplacementMap
dash?: // dashed stroke pattern
blur?: // feGaussianBlur
colorShift?: // hue-rotate + saturate
mask?: // radial or linear gradient mask
morph?: // feMorphology dilate/erode
shadow?: // drop shadow
componentTransfer?:// per-channel gamma curves
diffuseLight?: // 3D diffuse lighting
convolve?: // emboss / sharpen / edge kernel
}
All mutations use Gaussian (Box-Muller) noise rather than uniform random. This produces a bell-curve distribution around the parent value. So, small tweaks are common and radical jumps are rare. The result feels smooth and aesthetically coherent.
function gauss() {
// Box-Muller transform → standard normal distribution
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
// Core nudge utility — all continuous mutations flow through this
function nudge(v, range, lo = -Infinity, hi = Infinity) {
return clamp(v + gauss() * range, lo, hi);
}
Mutates a single HSL color. Each channel is nudged independently with type-specific bounds that keep colors vivid and readable across generations.
function mutateColor(c, range) {
return {
h: (c.h + gauss() * range * 40 + 360) % 360, // wraps, ±40° * range
s: clamp(c.s + gauss() * range * 20, 10, 100), // clamped [10–100]
l: clamp(c.l + gauss() * range * 20, 5, 95), // clamped [5–95]
};
}
| Channel | Mutation Scale | Bounds | Reason |
|---|---|---|---|
| Hue | range × 40° | Wraps at 360° | Color wheel is continuous |
| Saturation | range × 20% | [10, 100] | Prevents fully grey drift |
| Lightness | range × 20% | [5, 95] | Preserves contrast headroom |
Mutates the type-specific params object of a layer. Each shape type has hand-tuned scale factors to prevent runaway drift — geometry types use small fractions of normalized coordinate space, while tiling types use larger pixel-space factors.
| Shape Type | Parameters | Scale Factor |
|---|---|---|
circle | r (radius) | 0.05× → clamped [0.01, 0.5] |
ellipse | rx, ry | 0.05× each independently |
rect | w, h, rx (corner) | 0.05× size, 0.02× corner |
polygon | r, sides | 0.05×; sides jumps ±1 at 5% chance |
path | each point x/y | 10% of current value |
dots_grid | spacing, r, jitter | 6×, 2.5×, 0.1× |
stripes | spacing, lineWidth, angle | 6×, 4×, 15° nudge |
waves / zigzag | spacing, amplitude, frequency | Multi-parameter tuning |
hexgrid / crosshatch | size, spacing, angle | Type-specific pixel-space factors |
dots_grid, stripes, etc.) use larger mutation step sizes because their parameters (like stripe spacing or dot size) are measured in pixels, which can range into the hundreds. Geometry types like circles and rectangles use a 0–1 scale, so the same small nudge would be way too subtle if applied to a pixel-space value.
The most complex tier. Each layer is mutated across three distinct categories.
These are nudged on every mutation regardless of structure rate:
cx, cy — nudged at 0.1× scale0.1×scaleX/scaleY (0.1×)// Triggered by structRate slider (default 0.3)
if (Math.random() < structRate * 0.15) l.blendMode = pick(BLEND_MODES);
if (Math.random() < structRate * 0.10) l.type = pick(TYPES);
if (Math.random() < structRate * 0.10) l.arrangement = pick(ARRANGEMENTS);
if (Math.random() < structRate * 0.10) l.fill.type = pick(['solid', 'linear', 'radial']);
Every optional effect follows the same three-way logic:
// Pattern repeated for every effect type:
if (l.displace != null) {
// Mutate existing effect
l.displace.scale = clamp(nudge(...), 1, 300);
l.displace.baseFreq = clamp(nudge(...), 0.0005, 0.13);
l.displace.octaves = clamp(Math.round(nudge(...)), 1, 8);
// ~8% chance to remove it
if (Math.random() < structRate * 0.08) delete l.displace;
} else if (flags.turbulence && Math.random() < structRate * 0.15) {
// Spawn if feature flag enabled (~4.5% at default rate)
l.displace = { scale: rand(10, 150), ... };
}
The top-level orchestrator. Always operates on a deepClone() of the parent so the original is never modified. Runs five sequential steps:
function mutateGenome(genome, opts = {}) {
const range = opts.paramRange ?? 0.5;
const structRate = opts.structureRate ?? 0.3;
const maxLayers = opts.maxLayers ?? 8;
const g = deepClone(genome); // non-destructive
// 1. Mutate every palette color
g.palette = g.palette.map(c => mutateColor(c, range));
// 2. Background gradient
if (Math.random() < range * 0.3)
g.background.angle = (g.background.angle + gauss() * 30 + 360) % 360;
if (Math.random() < structRate * 0.1)
g.background.type = pick(['solid', 'linear', 'radial']);
// 3. Mutate each layer (Tier 3)
const flags = getFeatureFlags();
g.layers = g.layers.map(l => mutateLayer(l, g.palette, range, structRate, maxLayers, flags));
// 4. Add or remove a layer
if (Math.random() < structRate * 0.6 && g.layers.length < maxLayers)
g.layers.push(randomLayer(g.palette.length));
if (Math.random() < structRate * 0.4 && g.layers.length > 1)
g.layers.splice(randInt(0, g.layers.length - 1), 1);
// 5. Add or remove a palette color
if (Math.random() < structRate * 0.1 && g.palette.length < 6)
g.palette.push(randomColor());
if (Math.random() < structRate * 0.1 && g.palette.length > 2)
g.palette.splice(randInt(0, g.palette.length - 1), 1);
return g;
}
Fills all 9 cells with independent random genomes. Each call to randomGenome() creates a palette of 3–5 colors and 1–2 random layers.
const parent = genomes[parentIdx];
newGenomes[4] = deepClone(parent); // center = unchanged parent
for (let i = 0; i < 9; i++) {
if (i === 4) continue;
newGenomes[i] = mutateGenome(parent, opts); // 8 independent mutations
}
Each of the 8 surrounding cells is an independent mutation draw from the same parent. They share no state with each other.
Identical to evolveFrom() but starts from a saved template genome. Also calls enableExperimentalFlagsForGenome() to automatically enable any feature flags the template requires.
Four sliders govern the mutation engine. Settings are persisted to localStorage via saveSliders()/loadSliders().
| Slider | Range | Default | Effect |
|---|---|---|---|
| Mutations | 1 – 20 |
4 |
How many independent mutation passes are applied to produce each child — higher values produce more varied offspring per generation |
| Param Range | 0.05 – 2.0 |
0.3 |
Multiplier applied to every nudge() call — controls how far continuous parameters can drift per generation |
| Structure Rate | 0.0 – 1.0 |
0.2 |
Scales all structural change probabilities — layer add/remove, shape type change, effect spawn/remove, arrangement change |
| Max Layers | 2 – 20 |
15 |
Hard cap on the number of layers a genome can accumulate; layer addition is skipped once this limit is reached |
Advanced SVG filter effects are gated behind feature flags. A flag controls whether an effect can be spawned on new layers during mutation. Existing effects on a layer always mutate regardless of flags — flags only govern spawn probability.
breedFromGenome(), enableExperimentalFlagsForGenome() inspects the genome and automatically enables any flags required for its effects — so templates always render correctly even if your current flag settings differ.
The Expand View re-rasterizes the current SVG pattern at progressively higher resolutions as you zoom in. This multi-tier Level of Detail system is necessary because the SVG coordinate space is fixed at 1280×720 user units. Scaling that space up to print-quality dimensions requires rendering the SVG at a higher pixel density, not stretching a smaller raster.
Three rendering modes are available. Standard mode is always on. Extreme and "Death" modes are opt-in via the Settings panel and unlock successively higher resolution tiers through a GPU tiling strategy.
A critical constraint: the SVG's viewBox dimensions are always 1280×720. They are never changed. The browser's feTurbulence filter samples noise in SVG user-unit space, so altering the viewBox would change the noise pattern itself, not just the display size. To get more pixels, the SVG width and height attributes are scaled up while the viewBox stays constant.
Each mode adds tiers to the resolution ladder. A tier is selected by the current zoom level inside the Expand View. Once rendered, each tier is cached and reused instantly when zooming back out.
| Label | Scale Factor | Output Resolution | Mode Required | Strategy |
|---|---|---|---|---|
| 720p | 1× | 1280 × 720 | Standard | Single pass |
| 4K | 3× | 3840 × 2160 | Standard | Single pass |
| 8K | 6× | 7680 × 4320 | Standard | Single pass |
| 12K | 9× | 11520 × 6480 | Standard | Single pass |
| 16K ★ | 12× | 15360 × 8640 | Extreme | 4×4 tiled composite |
| Detail of Death ☠ | 24× | 30720 × 17280 | Death | 4×4 tiled, separate canvases |
The 16K and Death tiers exceed what any GPU can hold in a single canvas allocation. They use a tiling strategy to work around this limit.
At high scale factors, a single canvas exceeds GPU memory limits and the browser silently produces a blank or corrupt result. The workaround is to divide the full image into a 4×4 grid of 16 tiles, render each tile separately at a manageable canvas size, then composite them back together.
The tricky part is SVG filters. feTurbulence computes displacement based on absolute SVG user-unit coordinates. If you simply clip the viewBox to a tile's region, filter primitives that sample outside the tile boundary (the feDisplacementMap sampling radius can reach ~75 user units at maximum scale) would be clipped to zero — producing a visible seam where tiles meet.
The solution is an overlap margin: each tile's viewBox is expanded by 80 SVG user units on every shared edge. This overlap is larger than the maximum displacement radius, so filter math across tile boundaries is identical to single-pass rendering. After rasterizing each tile, the overlap strip is cropped off via drawImage() before compositing.
// For each tile at (col, tc), (row, tr) in a 4×4 grid:
const OVERLAP = 80; // SVG user units — covers max displacement radius
// Tile's region in SVG user-unit space (without overlap)
const vx = tc * tileVW;
const vy = tr * tileVH;
// Expanded viewBox includes overlap on shared edges
const vxo = vx - (tc > 0 ? OVERLAP : 0);
const vyo = vy - (tr > 0 ? OVERLAP : 0);
const vwo = tileVW + (tc > 0 ? OVERLAP : 0) + (tc < cols-1 ? OVERLAP : 0);
const vho = tileVH + (tr > 0 ? OVERLAP : 0) + (tr < rows-1 ? OVERLAP : 0);
// Patch SVG viewBox and pixel dimensions, render to canvas
svg.setAttribute('viewBox', `${vxo} ${vyo} ${vwo} ${vho}`);
svg.setAttribute('width', rawTileW + overlapPxX);
svg.setAttribute('height', rawTileH + overlapPxY);
// Crop the overlap pixels out before compositing
ctx.drawImage(tileCanvas, cropL, cropT, rawTileW, rawTileH, destX, destY, rawTileW, rawTileH);
All 16 tiles are rendered sequentially and composited into a single offscreen canvas. The composite is displayed in the Expand View and cached for instant reuse. Export produces one PNG from this composite.
Each of the 16 tiles is kept as its own canvas element, positioned absolutely in the DOM via CSS transform. The canvases are never composited — they remain separate for streaming directly to the PNG encoder at export time.
Death mode's separate-canvas approach avoids ever allocating a 530+ megapixel composite in memory. The 16 tile canvases are instead piped sequentially into the PNG encoder.
At extreme zoom levels, some GPUs silently produce incorrect renders, usually missing some layers. An automatic detection pass runs after an Extreme-tier render, comparing it against the tier below.
The image is divided into a 16×9 analysis grid of 80×80-pixel tiles, with a 20px border cropped from each to avoid edge artifacts. The central 40×40 region of each tile is analyzed with four independent checks:
If more than 10% of analysis tiles fail any check, the tier is flagged as a GPU rendering failure.
Exporting a Death-mode render as a single PNG file poses a memory problem: a 30720×17280 image at 3 bytes per pixel is roughly 1.5 GB of raw pixel data. Loading it all into memory to encode it would crash most browsers. Instead, the exporter uses a streaming PNG encoder that writes the file scanline-by-scanline directly to disk, keeping peak memory at the size of one tile row (~100 MB).
The exporter opens a save dialog via window.showSaveFilePicker() (File System Access API) and writes directly to the chosen file. This allows sequential writes and lets the encoder seek back to fill in the IDAT chunk length once compression is complete, without holding the whole file in memory.
*-death-r{row}c{col}.png.
PNG is a relatively simple format: a fixed signature, a header chunk (IHDR), one compressed data chunk (IDAT), and an end marker (IEND). The exporter builds each part manually:
// 1. PNG signature — 8 magic bytes
write([137, 80, 78, 71, 13, 10, 26, 10]);
// 2. IHDR chunk — width, height, 8-bit RGB (color type 2)
writeChunk('IHDR', [W >> 24, W >> 16, W >> 8, W,
H >> 24, H >> 16, H >> 8, H,
8, // bit depth
2, // color type: RGB (no alpha)
0, 0, 0]); // compression, filter, interlace
// 3. IDAT chunk — length placeholder written now, filled in after compression
const idatLenPos = pos; // remember file offset
write([0, 0, 0, 0]); // placeholder — seek back later
write('IDAT');
// 4. Pipe scanlines through zlib deflate → directly to file
const cs = new CompressionStream('deflate');
const csWriter = cs.writable.getWriter();
cs.readable.pipeTo(fileStream); // compressed bytes go straight to disk
// 5. For each tile row, read pixel data from all 4 tiles in that row,
// then write scanlines by concatenating pixels horizontally
for (let tr = 0; tr < rows; tr++) {
const pixData = tiles.slice(tr*cols, tr*cols+cols)
.map(c => c.getContext('2d').getImageData(0,0,tw,th).data);
for (let y = 0; y < th; y++) {
const row = new Uint8Array(1 + W * 3); // filter byte + RGB
row[0] = 0; // PNG filter type: None
for (let tc = 0; tc < cols; tc++) {
for (let x = 0; x < tw; x++) {
// Copy R, G, B from RGBA pixel data (skip A)
row[di] = pixData[tc][si];
row[di+1] = pixData[tc][si+1];
row[di+2] = pixData[tc][si+2];
}
}
await csWriter.write(row); // scanline fed to compressor
}
}
// 6. Close compressor, seek back to fill IDAT length, write IEND
await csWriter.close();
writer.seek(idatLenPos);
write(idatByteCount); // fill in the placeholder
writeChunk('IEND', []);
Every PNG chunk requires a CRC32 checksum covering the chunk type and data. The encoder pre-computes a 256-entry CRC32 lookup table and processes each chunk's bytes through it before writing. This is a required part of the PNG specification — a PNG reader that validates checksums (most do) will reject a file with a wrong CRC.
Because a Death-mode export can take tens of seconds, a progress overlay appears showing a 4×4 grid of tile status cells. Each cell transitions through three states as processing moves through the 16 tiles:
If tiles are already cached from a previous render in the same session, the rendering phase is skipped and export jumps straight to streaming.
The Animation page (animation.html) is a parametric SVG animation tool. It reads the genome embedded in an exported evolveSVG SVG, then lets you attach SMIL <animate> and <animateTransform> elements to any numeric parameter. The result is a fully self-contained animated SVG that plays natively in any browser — no JavaScript, no CSS, no external dependencies.
Two entry points:
sessionStorage under 'evolvesvg_animate' and opens the animation page in a new tab; on load the key is read and cleared automaticallyThe SVG must contain an <evolvesvg:genome> tag inside a <metadata> block — SVGs exported from evolveSVG always carry this. Plain SVGs without genome data are rejected with an error.
<evolvesvg:animation> tag) automatically restores all keyframes. You can re-open, edit, and re-export an animation indefinitely without losing the configuration.
Once an SVG is loaded, the left sidebar builds a scrollable tree of animatable parameters, organized into collapsible sections — one per palette color and one per layer. Palette color sections are collapsed by default; layer sections are expanded.
Each parameter row shows its current value and a fill bar proportional to its position in the valid range. Drag left or right on any row to scrub the value. A 300ms-gated animation chain plays a smooth SMIL transition while you drag; after 2 seconds of inactivity the preview reverts to a static render.
A blue dot on a row indicates the parameter already has a keyframe configured.
| Parameter | Target | Range | Notes |
|---|---|---|---|
| Hue / Saturation / Lightness | Palette color | 0–360° / 0–100% / 5–95% | Animates the fill attribute between two HSLA color strings |
| Opacity | Layer (all types) | 0–1 | |
| Position X / Position Y | Layer | 0–1 (normalized) | Single and radial arrangements only — grid and scatter omitted |
| Rotation | Layer (non-tiling) | 0–360° | |
| Scale X / Scale Y | Layer (non-tiling) | 0.05–3× | |
| Radius | circle, polygon | 0.01–0.5 | Polygon animates the points attribute |
| Radius X / Radius Y | ellipse | 0.01–0.5 | |
| Width / Height | rect | 0.01–1 | |
| Fill Opacity | Layer (non-tiling, solid fill) | 0–1 | Animates fill-opacity — avoids HSLA string interpolation issues |
| Blur | Layer (if blur effect present) | 0–50 | Animates stdDeviation inside feGaussianBlur |
| Displacement | Layer (if displace effect present) | 0–300 | Animates scale inside feDisplacementMap |
| Hue Shift | Layer (if colorShift effect present) | −180–180° | Animates values inside feColorMatrix type="hueRotate" |
Tiling layer types (dots_grid, stripes, crosshatch, checkerboard, waves, zigzag, hexgrid) only expose Opacity and any filter-based parameters — position, rotation, scale, and shape dimensions are not available for tiling layers.
Each "keyframe" is a transition spec for one parameter: a from-value, a to-value, duration, repeat count, direction, and easing. Multiple parameters can be animated simultaneously — each gets its own independent keyframe entry. The keyframe list in the right panel shows all configured specs; clicking one re-opens it for editing.
| Control | Options | Notes |
|---|---|---|
| From / To | Sliders bounded by the param's valid range | Set start and end values |
| Duration | 0.1 – 60 s | |
| Repeat | Loop (indefinite), 1×, 2×, 3×, 5× | |
| Direction | Forward, Alternate | Alternate plays forward then backward; SMIL encodes this as a 3-value values="from;to;from" with keyTimes="0;0.5;1" |
| Easing | Linear, Ease, Ease In, Ease Out, Ease In-Out, Custom | Custom accepts a cubic bezier string x1 y1 x2 y2; a live canvas preview draws the curve |
The genomeToSVGAnimated() function accepts the genome and an array of keyframe specs and compiles them directly into SMIL attributes on the SVG elements. Each spec type is mapped to the appropriate SMIL element:
// Geometric attributes → <animate attributeName="…"/>
smilAnimate('r', fromPx, toPx, spec)
smilAnimate('fill', fromHSLA, toHSLA, spec)
smilAnimate('opacity', 0, 1, spec)
// Transform attributes → <animateTransform type="…"/>
smilTransform('translate', 'x,y', 'x2,y2', spec)
smilTransform('rotate', fromDeg, toDeg, spec)
smilTransform('scale', 'sx sy', 'sx2 sy2', spec)
// Filter params → <animate> embedded inside the filter primitive
smilAnimate('stdDeviation', fromBlur, toBlur, spec) // feGaussianBlur
smilAnimate('scale', fromScale, toScale, spec) // feDisplacementMap
smilAnimate('values', fromHue, toHue, spec) // feColorMatrix hueRotate
Easing is encoded as a SMIL cubic bezier spline via calcMode="spline" and keySplines. For alternate direction, the keySpline string is doubled ("ks;ks") to cover both halves. Linear easing omits calcMode entirely (it is the SMIL default).
When a layer has any transform animation (position, rotation, or scale), its group is split into three nested levels so independent animateTransform elements can coexist on the same layer without conflicting:
<!-- Outer: position -->
<g transform="translate(cx, cy)">
<animateTransform type="translate" .../>
<!-- Middle: rotation -->
<g transform="rotate(θ)">
<animateTransform type="rotate" .../>
<!-- Inner: scale + fill + opacity -->
<g transform="scale(sx, sy)" opacity="…">
<animateTransform type="scale" .../>
<animate attributeName="opacity" .../>
<shape/>
</g>
</g>
</g>
begin="indefinite". After the animated SVG is injected into the DOM, every <animate> and <animateTransform> is triggered explicitly via beginElement(). Without this, Firefox resolves begin="0s" against the document's existing SMIL timeline (which is already in the future relative to when the SVG was inserted), causing animations to complete instantly.
When exporting, both the genome and the animation specs are embedded into the SVG's <metadata> block as custom namespace tags. HTML special characters in the JSON payload are escaped before embedding and unescaped on load:
<metadata>
<evolvesvg:genome xmlns:evolvesvg="https://evolvesvg">
{ …genome JSON… }
</evolvesvg:genome>
<evolvesvg:animation xmlns:evolvesvg="https://evolvesvg">
[ …keyframe specs array… ]
</evolvesvg:animation>
</metadata>
Both tags are optional when loading: a genome-only SVG is accepted for building a new animation from scratch; an SVG with both tags restores the full editing session.
| Option | What it does |
|---|---|
| Aspect Ratio | 16:9, 4:3, 1:1, 9:16, or custom pixel dimensions (up to 4096×4096). The SVG viewBox is cropped from the 1280×720 base space to the chosen ratio — the render geometry does not change, only which region is visible. |
| GPU | Adds will-change: transform, opacity to all layer groups, hinting the browser to promote them to dedicated compositor layers for smoother playback. |
| optimizeSpeed | Adds shape-rendering, color-rendering, and image-rendering=optimizeSpeed attributes to the SVG root — trades visual quality for render speed on complex patterns. |
| Save SVG | Downloads the animated SVG with both genome and animation metadata embedded. The filename is derived from the first keyframe's label. |
| Copy SVG | Copies the full SVG string to the clipboard. |
A hidden WebM export button is available when the animation is running. It renders the animation frame-by-frame to a <canvas> at 30 fps: for each frame it calls buildFrameGenome(tSeconds) to evaluate all keyframe specs at that time offset — applying easing via a binary-search cubic bezier solver — then rasterizes the resulting static genome SVG. A MediaRecorder stream captures the canvas at 8 Mbps. The output is saved as evolvesvg-animated.webm.
MediaRecorder with VP9 codec is auto-detected and falls back to generic WebM if VP9 is unavailable.