Technical-ish Reference

How evolveSVG works

A full breakdown of the generative evolution engine. Includes info on genome structures, mutation types, and how the SVG animation page works.

Contents

Architecture Overview

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.

Genome Structure

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: 0360        // hue (wraps)
  s: 10100       // saturation (clamped)
  l: 595        // 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:           01         // normalized position
  params:           {}          // type-specific (radius, size, …)
  fill:             Fill        // solid | linear | radial gradient
  stroke:           Stroke
  opacity:          0.051
  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
}

Randomization Foundation

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);
}
Why Gaussian?
Uniform randomization would make a large mutation equally likely as a tiny one. Gaussian noise concentrates changes near zero, so most mutations are small refinements, which are exactly what you want when steering a design toward something you like.

Color Mutation — mutateColor()

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]
    };
}
ChannelMutation ScaleBoundsReason
Huerange × 40°Wraps at 360°Color wheel is continuous
Saturationrange × 20%[10, 100]Prevents fully grey drift
Lightnessrange × 20%[5, 95]Preserves contrast headroom

Parameter Mutation — mutateParams()

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 TypeParametersScale Factor
circler (radius)0.05× → clamped [0.01, 0.5]
ellipserx, ry0.05× each independently
rectw, h, rx (corner)0.05× size, 0.02× corner
polygonr, sides0.05×; sides jumps ±1 at 5% chance
patheach point x/y10% of current value
dots_gridspacing, r, jitter6×, 2.5×, 0.1×
stripesspacing, lineWidth, angle6×, 4×, 15° nudge
waves / zigzagspacing, amplitude, frequencyMulti-parameter tuning
hexgrid / crosshatchsize, spacing, angleType-specific pixel-space factors
Design Note
Tiling types (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.

Layer Mutation — mutateLayer()

The most complex tier. Each layer is mutated across three distinct categories.

A — Continuous Parameters (always applied)

These are nudged on every mutation regardless of structure rate:

  • → Position cx, cy — nudged at 0.1× scale
  • → Fill alpha — nudged at 0.1×
  • → Stroke width and alpha
  • → Opacity
  • → Transform: rotation (Gaussian ±15°), scaleX/scaleY (0.1×)
B — Structural Changes (probabilistic)
// 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']);
C — Advanced Effects (spawn / mutate / remove)

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), ... };
}
Spawn vs. Remove
At the default structRate of 0.3, each effect has a ~4.5% chance to spawn and a ~2.4% chance to be removed per mutation. Existing effects always mutate; Only their parameters change, never their presence unless the remove roll hits.

Genome Mutation — mutateGenome()

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;
}
Structural probability at default rate (structRate = 0.3)
  • Add a layer0.3 × 0.6
  • Remove a layer0.3 × 0.4
  • Change blend mode0.3 × 0.15
  • Change shape type0.3 × 0.10
  • Spawn advanced effect0.3 × 0.15
  • Remove advanced effect0.3 × 0.08
  • Add palette color0.3 × 0.10

Evolution Flow

restart() — fresh grid

Fills all 9 cells with independent random genomes. Each call to randomGenome() creates a palette of 3–5 colors and 1–2 random layers.

evolveFrom(parentIdx) — user clicks a cell
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.

breedFromGenome(template) — load a template

Identical to evolveFrom() but starts from a saved template genome. Also calls enableExperimentalFlagsForGenome() to automatically enable any feature flags the template requires.

Mutation Controls

Four sliders govern the mutation engine. Settings are persisted to localStorage via saveSliders()/loadSliders().

SliderRangeDefaultEffect
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
Tip
Low Param Range + low Structure Rate → fine detail refinement.
High Structure Rate → exploratory, wild divergence from the parent.

Feature Flag System

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.

turbulence
feTurbulence + feDisplacementMap warping
Standard
dash
Dashed stroke patterns with gap control
Standard
blur
feGaussianBlur softening
Standard
colorMatrix
Hue rotation + saturation shift
Standard
mask
Radial or linear gradient mask
Standard
morph
feMorphology dilate / erode
Experimental
shadow
Drop shadow with offset and blur
Experimental
componentTransfer
Per-channel gamma curves
Experimental
diffuseLight
3D diffuse point lighting
Experimental
convolve
Convolution kernels (emboss, sharpen, edge)
Experimental
Template Loading
When a template genome is loaded via 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.

High-Resolution LOD Rendering

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.

The Fixed SVG Coordinate Space

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.

Tier System

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.

LabelScale FactorOutput ResolutionMode RequiredStrategy
720p1280 × 720StandardSingle pass
4K3840 × 2160StandardSingle pass
8K7680 × 4320StandardSingle pass
12K11520 × 6480StandardSingle pass
16K ★12×15360 × 8640Extreme4×4 tiled composite
Detail of Death ☠24×30720 × 17280Death4×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.

Tiling Strategy (Extreme & Death Modes)

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);
Why this works
The browser clips SVG filter buffers to the tile's viewBox. By making the viewBox larger than the inner region, all filter sampling that would cross a tile boundary happens within the oversized buffer, producing pixel-identical results to a single full-resolution pass.
Extreme vs. Death: What's Different
16K ★
Extreme Mode

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.

☠ Death
Death Mode

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.

Issue Detection (deprecated)

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:

  • 1. Luminance variance drop — detects missing texture or displacement layers
  • 2. Per-channel mean delta > 15 — detects missing tone or color grading layers
  • 3. Saturation-amplified color variance drop — detects missing subtle hue-shift layers
  • 4. Structured difference variance — detects missing textured detail at any scale

If more than 10% of analysis tiles fail any check, the tier is flagged as a GPU rendering failure.

Currently Disabled
The CPU rendering fallback notification is suppressed. Detection runs in full but does not surface a warning or offer a fallback to the user. This was a solution before I setup tiling. I kept the code in evolveSVG just in case the tiling wasn't good enough. I should probably remove these checks for the sake of performance.

Tiled PNG Export

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).

File System Access API

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.

Fallback
If the File System Access API is unavailable (non-Chromium browsers, certain contexts), the exporter falls back to downloading all 16 tiles as separate PNG files named *-death-r{row}c{col}.png.
PNG Encoding Pipeline

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', []);
Memory efficiency
At any given moment, only one tile row's pixel data is in memory — roughly 4 × (tile width × tile height × 4 bytes). For a 4×4 tile layout of a 30720-pixel-wide image, each tile is ~7680 pixels wide, so one tile row is about 500 MB of pixel data. Scanlines are fed immediately to the compressor and written to disk; there is never a full-image buffer in memory.
CRC32 Checksums

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.

Progress UI

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:

  • Rendering — the tile's SVG is being rasterized
  • Streaming — the tile's pixel data is being fed to the PNG compressor
  • Done — the tile's scanlines have been written to disk

If tiles are already cached from a previous render in the same session, the rendering phase is skipped and export jumps straight to streaming.

SMIL Animation System

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.

SVG Loading

Two entry points:

  • Paste mode — paste SVG code directly into the textarea and click Load SVG (or Cmd/Ctrl+Enter)
  • Auto-load — the main evolveSVG page writes the chosen SVG to sessionStorage under 'evolvesvg_animate' and opens the animation page in a new tab; on load the key is read and cleared automatically

The 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.

Roundtrip
Pasting a previously exported animated SVG (one that also contains an <evolvesvg:animation> tag) automatically restores all keyframes. You can re-open, edit, and re-export an animation indefinitely without losing the configuration.
Parameter Tree

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.

Animatable Parameters
ParameterTargetRangeNotes
Hue / Saturation / LightnessPalette color0–360° / 0–100% / 5–95%Animates the fill attribute between two HSLA color strings
OpacityLayer (all types)0–1
Position X / Position YLayer0–1 (normalized)Single and radial arrangements only — grid and scatter omitted
RotationLayer (non-tiling)0–360°
Scale X / Scale YLayer (non-tiling)0.05–3×
Radiuscircle, polygon0.01–0.5Polygon animates the points attribute
Radius X / Radius Yellipse0.01–0.5
Width / Heightrect0.01–1
Fill OpacityLayer (non-tiling, solid fill)0–1Animates fill-opacity — avoids HSLA string interpolation issues
BlurLayer (if blur effect present)0–50Animates stdDeviation inside feGaussianBlur
DisplacementLayer (if displace effect present)0–300Animates scale inside feDisplacementMap
Hue ShiftLayer (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.

Keyframe System

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.

ControlOptionsNotes
From / ToSliders bounded by the param's valid rangeSet start and end values
Duration0.1 – 60 s
RepeatLoop (indefinite), 1×, 2×, 3×, 5×
DirectionForward, AlternateAlternate plays forward then backward; SMIL encodes this as a 3-value values="from;to;from" with keyTimes="0;0.5;1"
EasingLinear, Ease, Ease In, Ease Out, Ease In-Out, CustomCustom accepts a cubic bezier string x1 y1 x2 y2; a live canvas preview draws the curve
SMIL Build Architecture

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).

Three-Group Transform Structure

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>
Firefox / beginElement()
All SMIL elements use 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.
Metadata Persistence

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.

Output Options
OptionWhat it does
Aspect Ratio16: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.
GPUAdds will-change: transform, opacity to all layer groups, hinting the browser to promote them to dedicated compositor layers for smoother playback.
optimizeSpeedAdds shape-rendering, color-rendering, and image-rendering=optimizeSpeed attributes to the SVG root — trades visual quality for render speed on complex patterns.
Save SVGDownloads the animated SVG with both genome and animation metadata embedded. The filename is derived from the first keyframe's label.
Copy SVGCopies the full SVG string to the clipboard.
WebM Export

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.

Browser Support
SMIL playback and WebM export both work best in Chrome. Non-Chromium browsers show a warning banner on load. MediaRecorder with VP9 codec is auto-detected and falls back to generic WebM if VP9 is unavailable.