Back to Shader Toys

Flame Nebula

The flame family renders a fractal nebula: an iterated function system seeded from the map name plays the chaos game on the CPU, accumulating a density-and-color histogram that log-density tone mapping and a palette gradient turn into filamentary cloud. The attractor's transforms are part of the seed identity; the knobs grade its rendering. Like the other families, the result is a transparent static viewport the map places as an ordinary parallax layer.

Preparing renderer…

Waiting to render

Seed

Composition

Tone

Palette

Map frontmatter configuration (editable)

Flame nebula source

Renderer and tone-mapping kernel

/** @fileoverview Implements the flame (IFS fractal) nebula renderer and registry contract. */

// Fractal-flame nebula in the manner of Draves' algorithm: a seeded iterated // function system's chaos game accumulates a density-and-color histogram, // log-density tone mapping compresses it, and a palette gradient colorizes the // result. This family renders on the CPU through Canvas 2D: at bake and toy // scale a few tens of millions of samples take seconds, need no WebGL context, // and are deterministic per seed. The transforms and framing are resolved with // the settings (settings.mjs owns generation and degenerate-seed rejection); // render() only runs the game.

import { mulberry32 } from '../../../../model/random.mjs'; import { FLAME_NEBULA, applyFinalTransformInto, applyFlameTransformInto, resolveFlameNebulaSettings, serializeFlameNebulaSettings } from './settings.mjs';

export { FLAME_NEBULA, resolveFlameNebulaSettings };

/**

  • The CPU kernel, exposed where GLSL families expose fragment source. The toy
  • displays this listing; there is intentionally no shader in this family. */ export const FLAME_NEBULA_KERNEL_SOURCE = // CPU chaos-game kernel (Canvas 2D; no GLSL in this family). // // Per seeded transform t: affine (rotation, anisotropic contraction, // translation), then a variation blended against the affine result so bounded // variations cannot print their bounds: // sinusoidal: sin(p) // spherical: p / |p|^2 // swirl: rotate xy by |p|^2 (blend-capped: phase rings) // bubble: 4p / (|p|^2 + 4) // horseshoe: ((x-y)(x+y), 2xy) / |p| // fisheye: 2p / (|p| + 1) // An optional seeded final transform (never fed back into the orbit) warps // the splatted point for compositional variety. // // Orbit loop (48 decorrelated orbits, ~20 warmup steps unplotted): // u = rng(); t = pick(transforms, u) // p = blend(affine(t, p), variation(t, p)) // c = (c + t.color) / 2 // color coordinate // splat bilinearly: histogram and colorSum share four weighted cells // // Density estimation: the histogram is box-blurred and blended back by local // count (sparse cells take the smoothed value, dense cores stay sharp), the // cheap form of a flame renderer's adaptive kernel. // // Tone map: // norm = log1p(count) / log1p(percentile 99.7 of nonzero counts) // bright = norm ^ (1 / gamma) // rgb = LUT(colorSum / count) * bright + white * bright^6 * hotCores // rgb = vibrance(rgb, 1.15) // alpha = bright * edge envelope // straight alpha over the stage // // Bloom: two additive blurred self-draws (radii 3 and 12 display pixels), // scaled by the bloom knob; layer opacity applies on the final composite.;

function buildLut(settings) { const deep = settings.colorA.map(channel => channel * 0.22); const stops = [ [0, [0, 0, 0]], [settings.lutStops[0], deep], [settings.lutStops[1], settings.colorA], [settings.lutStops[2], settings.colorB], [1, settings.highlight] ]; const lut = new Float32Array(256 * 3); for (let i = 0; i < 256; i += 1) { const t = i / 255; let a = stops[0]; let b = stops[stops.length - 1]; for (let s = 0; s < stops.length - 1; s += 1) { if (t >= stops[s][0] && t <= stops[s + 1][0]) { a = stops[s]; b = stops[s + 1]; break; } } const f = (t - a[0]) / Math.max(1e-6, b[0] - a[0]); for (let channel = 0; channel < 3; channel += 1) { lut[i * 3 + channel] = a[1][channel] + (b[1][channel] - a[1][channel]) * f; } } return lut; }

// Separable running-sum box blur over a single-channel float field. function boxBlur(field, width, height, radius) { const out = new Float32Array(field.length); const temp = new Float32Array(field.length); const span = radius * 2 + 1; for (let y = 0; y < height; y += 1) { const row = y * width; let sum = 0; for (let x = -radius; x <= radius; x += 1) { sum += field[row + Math.min(width - 1, Math.max(0, x))]; } for (let x = 0; x < width; x += 1) { temp[row + x] = sum / span; const add = row + Math.min(width - 1, x + radius + 1); const drop = row + Math.max(0, x - radius); sum += field[add] - field[drop]; } } for (let x = 0; x < width; x += 1) { let sum = 0; for (let y = -radius; y <= radius; y += 1) { sum += temp[Math.min(height - 1, Math.max(0, y)) * width + x]; } for (let y = 0; y < height; y += 1) { out[y * width + x] = sum / span; const add = Math.min(height - 1, y + radius + 1) * width + x; const drop = Math.max(0, y - radius) * width + x; sum += temp[add] - temp[drop]; } } return out; }

function browserCanvas() { return document.createElement('canvas'); }

/**

  • Renders resolved flame-nebula settings to a transparent static raster.
  • @param {object} [settings=FLAME_NEBULA] Renderer-ready settings.
  • @param {object} [renderOptions={}] Canvas factory override.
  • @returns {HTMLCanvasElement} Detached 2D raster. */ export function createFlameNebulaSheet(settings = FLAME_NEBULA, renderOptions = {}) { const { canvasFactory = browserCanvas } = renderOptions; const [viewportWidth, viewportHeight] = settings.viewport; const width = Math.max(2, Math.round(viewportWidth * settings.renderScale)); const height = Math.max(2, Math.round(viewportHeight * settings.renderScale));

const count = new Float32Array(width * height); const colorSum = new Float32Array(width * height); const random = mulberry32(settings.orbitSeed); const effectiveSamples = Math.max(200000, Math.round(settings.samples * settings.renderScale * settings.renderScale)); const ORBITS = 48; const perOrbit = Math.floor(effectiveSamples / ORBITS); // Fit the measured attractor spans to the sheet being drawn, so authored // and live-adjusted sheet sizes reframe instead of cropping. const scale = 0.82 * Math.min( viewportWidth / settings.frame.spanX, viewportHeight / settings.frame.spanY ) * settings.zoom * settings.renderScale; const cosR = Math.cos(settings.rotation); const sinR = Math.sin(settings.rotation); const halfW = width * 0.5; const halfH = height * 0.5; const { transforms, finalTransform, frame } = settings; const scratch = new Float64Array(3); const finalScratch = new Float64Array(3);

for (let orbit = 0; orbit < ORBITS; orbit += 1) { let x = random() * 2 - 1; let y = random() * 2 - 1; let z = random() * 2 - 1; let c = random(); for (let i = 0; i < perOrbit; i += 1) { const u = random(); let transform = transforms[transforms.length - 1]; for (let t = 0; t < transforms.length; t += 1) { if (u <= transforms[t].cdf) { transform = transforms[t]; break; } } applyFlameTransformInto(transform, x, y, z, scratch); x = scratch[0]; y = scratch[1]; z = scratch[2]; c = (c + transform.color) * 0.5; if (i < 20) continue;

let fx = x, fy = y; if (finalTransform) { applyFinalTransformInto(finalTransform, x, y, z, finalScratch); fx = finalScratch[0]; fy = finalScratch[1]; } // Rotation about the origin, matching the framing measurement. const rx = fx * cosR - fy * sinR; const ry = fx * sinR + fy * cosR; const sx = (rx - frame.cx) * scale + halfW; const sy = (ry - frame.cy) * scale + halfH; if (!(sx >= 0 && sx < width - 1 && sy >= 0 && sy < height - 1)) continue; // Bilinear splat: the sample shares four cells, which antialiases the // filaments that nearest-cell splatting turns to speckle. const x0 = sx | 0; const y0 = sy | 0; const tx = sx - x0; const ty = sy - y0; const i00 = y0 * width + x0; const w00 = (1 - tx) * (1 - ty); const w10 = tx * (1 - ty); const w01 = (1 - tx) * ty; const w11 = tx * ty; count[i00] += w00; colorSum[i00] += c * w00; count[i00 + 1] += w10; colorSum[i00 + 1] += c * w10; count[i00 + width] += w01; colorSum[i00 + width] += c * w01; count[i00 + width + 1] += w11; colorSum[i00 + width + 1] += c * w11; } }

// Cheap density estimation: sparse cells take the blurred histogram, dense // cores keep their sharp counts. const blurRadius = Math.max(1, Math.round(2 * settings.renderScale)); const smoothCount = boxBlur(count, width, height, blurRadius); const smoothColor = boxBlur(colorSum, width, height, blurRadius); const DENSITY_SHARP = 10; for (let i = 0; i < count.length; i += 1) { const weight = Math.min(1, count[i] / DENSITY_SHARP); smoothCount[i] += (count[i] - smoothCount[i]) * weight; smoothColor[i] += (colorSum[i] - smoothColor[i]) * weight; }

// Normalize against a high percentile of nonzero cells so a hyperdense core // saturates gracefully instead of owning the whole tonal range. const nonzero = []; for (let i = 0; i < smoothCount.length; i += 7) if (smoothCount[i] > 0.001) nonzero.push(smoothCount[i]); nonzero.sort((a, b) => a - b); const percentile = nonzero.length ? nonzero[Math.min(nonzero.length - 1, Math.floor(nonzero.length * 0.997))] : 1; const logMax = Math.log1p(percentile); const lut = buildLut(settings);

const base = canvasFactory(); base.width = width; base.height = height; const baseContext = base.getContext('2d'); if (!baseContext) throw new Error('The flame renderer requires a 2D canvas context.'); const image = baseContext.createImageData(width, height); const invGamma = 1 / settings.gamma; for (let py = 0; py < height; py += 1) { const edgeY = Math.abs(py - halfH) / halfH; for (let px = 0; px < width; px += 1) { const i = py * width + px; const n = smoothCount[i]; if (n <= 0.001) continue; let bright = Math.min(1, Math.log1p(n) / logMax); bright = Math.pow(bright, invGamma); const lutIndex = Math.min(255, Math.max(0, Math.round(smoothColor[i] / n * 255))); const hot = Math.pow(bright, 6) * settings.hotCores; let r = lut[lutIndex * 3] * bright + hot; let g = lut[lutIndex * 3 + 1] * bright + hot; let b = lut[lutIndex * 3 + 2] * bright + hot; // Astronomical processing pushes saturation; match it, gently. const luma = r * 0.299 + g * 0.587 + b * 0.114; r = luma + (r - luma) * 1.15; g = luma + (g - luma) * 1.15; b = luma + (b - luma) * 1.15; // The raster fades before its rectangular boundary, like every family. const edgeX = Math.abs(px - halfW) / halfW; const edge = Math.max(edgeX, edgeY); const envelope = edge <= 0.72 ? 1 : edge >= 0.99 ? 0 : 1 - (edge - 0.72) / 0.27; image.data[i * 4] = Math.min(255, Math.round(Math.max(0, r) * 255)); image.data[i * 4 + 1] = Math.min(255, Math.round(Math.max(0, g) * 255)); image.data[i * 4 + 2] = Math.min(255, Math.round(Math.max(0, b) * 255)); image.data[i * 4 + 3] = Math.min(255, Math.round(bright * envelope * 255)); } } baseContext.putImageData(image, 0, 0);

const raster = canvasFactory(); raster.width = width; raster.height = height; const context = raster.getContext('2d'); if (!context) throw new Error('The flame renderer requires a 2D canvas context.'); context.globalAlpha = settings.opacity; context.drawImage(base, 0, 0); if (settings.bloom > 0 && 'filter' in context) { context.globalCompositeOperation = 'lighter'; const blurSmall = Math.max(1, 3 * settings.renderScale); const blurLarge = Math.max(2, 12 * settings.renderScale); context.filter = blur(${blurSmall}px); context.globalAlpha = settings.opacity * 0.38 * settings.bloom; context.drawImage(base, 0, 0); context.filter = blur(${blurLarge}px); context.globalAlpha = settings.opacity * 0.26 * settings.bloom; context.drawImage(base, 0, 0); context.filter = 'none'; context.globalCompositeOperation = 'source-over'; } context.globalAlpha = 1; return raster; }

/**

  • Extracts raster positioning fields from resolved flame-nebula settings.
  • @param {object} settings Resolved renderer settings.
  • @returns {object} Nebula presentation contract. */ export function resolveFlameNebulaPresentation(settings) { return { viewport:settings.viewport, displayScale:settings.displayScale, origin:settings.origin, screenAnchor:settings.screenAnchor, startOffset:settings.startOffset, parallax:settings.parallax }; }

/** Registry entry implementing the nebula renderer contract for flame. / export const flameNebulaRenderer = Object.freeze({ type:'flame', label:'Flame nebula', fragmentShader:FLAME_NEBULA_KERNEL_SOURCE, defaultSettings:FLAME_NEBULA, resolveSettings:resolveFlameNebulaSettings, resolvePresentation:resolveFlameNebulaPresentation, serializeSettings:serializeFlameNebulaSettings, render:createFlameNebulaSheet });

Seeded transforms and settings

/* @fileoverview Generates, normalizes, and serializes flame-nebula renderer settings. */

import { mulberry32 } from '../../../../model/random.mjs'; import { hashNebulaSeed } from '../../seed.mjs'; import { resolveNebulaCanvasSize } from '../../authored-canvas-size.mjs';

/** Variations available to seeded transforms (3D-adapted fractal-flame classics). */ export const FLAME_VARIATIONS = Object.freeze([ 'linear', 'sinusoidal', 'spherical', 'swirl', 'bubble', 'horseshoe', 'fisheye' ]);

const VARIATION_CODES = Object.freeze(Object.fromEntries( FLAME_VARIATIONS.map((name, code) => [name, code]) ));

// Palette archetypes shared in spirit with the emission family: a gas line, a // second species, and a near-white core, traced from reference photography. const PALETTES = Object.freeze([ Object.freeze({ gas:Object.freeze([0.82, 0.3, 0.38]), rim:Object.freeze([0.5, 0.64, 0.7]), core:Object.freeze([1.0, 0.94, 0.83]) }), Object.freeze({ gas:Object.freeze([0.2, 0.56, 0.52]), rim:Object.freeze([0.86, 0.63, 0.32]), core:Object.freeze([1.0, 0.97, 0.88]) }), Object.freeze({ gas:Object.freeze([0.74, 0.18, 0.22]), rim:Object.freeze([0.4, 0.53, 0.84]), core:Object.freeze([1.0, 0.91, 0.86]) }), Object.freeze({ gas:Object.freeze([0.47, 0.4, 0.76]), rim:Object.freeze([0.76, 0.47, 0.3]), core:Object.freeze([0.99, 0.96, 1.0]) }) ]);

const DEFAULT_TRANSFORMS = Object.freeze([ Object.freeze({ rot:Object.freeze([1, 0, 0, 0, 1, 0, 0, 0, 1]), aniso:Object.freeze([0.62, 0.55, 0.58]), translate:Object.freeze([0.4, 0.1, -0.2]), variation:'linear', code:0, blend:1, color:0, cdf:0.5 }), Object.freeze({ rot:Object.freeze([0, -1, 0, 1, 0, 0, 0, 0, 1]), aniso:Object.freeze([0.6, 0.66, 0.5]), translate:Object.freeze([-0.45, -0.2, 0.25]), variation:'spherical', code:2, blend:0.5, color:1, cdf:1 }) ]);

/** Baseline flame-nebula settings and complete renderer contract shape. */ export const FLAME_NEBULA = Object.freeze({ viewport:Object.freeze([2048, 1152]), displayScale:2, opacity:0.72, origin:Object.freeze([1024, 576]), screenAnchor:Object.freeze([0.5, 0.5]), startOffset:Object.freeze([0, 0]), parallax:0.055, renderScale:1, rotation:0, colorA:Object.freeze([0.82, 0.3, 0.38]), colorB:Object.freeze([0.5, 0.64, 0.7]), highlight:Object.freeze([1.0, 0.94, 0.83]), zoom:1, gamma:2.4, hotCores:0.35, bloom:0.6, samples:32000000, transforms:DEFAULT_TRANSFORMS, finalTransform:null, frame:Object.freeze({ spanX:5.598, spanY:3.149, cx:0, cy:0 }), lutStops:Object.freeze([0.22, 0.5, 0.78]), orbitSeed:1 });

const NUMBER_OVERRIDES = Object.freeze(['opacity', 'zoom', 'gamma', 'hotCores', 'bloom']); const COLOR_OVERRIDES = Object.freeze(['colorA', 'colorB', 'highlight']);

function validColor(value) { return Array.isArray(value) && value.length === 3 && value.every(Number.isFinite); }

/**

  • Applies one variation to a 3D point, writing into a caller-owned array so
  • the chaos game's inner loop allocates nothing.
  • @param {number} code Variation code (index into FLAME_VARIATIONS).
  • @param {number} x Point x. @param {number} y Point y. @param {number} z Point z.
  • @param {Float64Array|number[]} out Three-slot output. */ export function applyFlameVariationInto(code, x, y, z, out) { const r2 = x * x + y * y + z * z; switch (code) { case 1: // sinusoidal out[0] = Math.sin(x); out[1] = Math.sin(y); out[2] = Math.sin(z); return; case 2: { // spherical const k = 1 / Math.max(r2, 1e-6); out[0] = x * k; out[1] = y * k; out[2] = z * k; return; } case 3: { // swirl const s = Math.sin(r2), c = Math.cos(r2); out[0] = x * s - y * c; out[1] = x * c + y * s; out[2] = z; return; } case 4: { // bubble const k = 4 / (r2 + 4); out[0] = x * k; out[1] = y * k; out[2] = z * k; return; } case 5: { // horseshoe const r = Math.sqrt(r2) + 1e-6; out[0] = (x - y) * (x + y) / r; out[1] = 2 * x * y / r; out[2] = z; return; } case 6: { // fisheye (eyefish form) const k = 2 / (Math.sqrt(r2) + 1); out[0] = x * k; out[1] = y * k; out[2] = z * k; return; } default: out[0] = x; out[1] = y; out[2] = z; } }

/** Array-returning convenience wrapper around applyFlameVariationInto. */ export function applyFlameVariation(kind, x, y, z) { const out = [0, 0, 0]; applyFlameVariationInto(VARIATION_CODES[kind] ?? 0, x, y, z, out); return out; }

/**

  • Applies one full seeded transform (affine, then blended variation), writing
  • into a caller-owned array. This is the chaos game's hot path. */ export function applyFlameTransformInto(transform, x, y, z, out) { const rot = transform.rot; const ax = (rot[0] * x + rot[1] * y + rot[2] * z) * transform.aniso[0] + transform.translate[0]; const ay = (rot[3] * x + rot[4] * y + rot[5] * z) * transform.aniso[1] + transform.translate[1]; const az = (rot[6] * x + rot[7] * y + rot[8] * z) * transform.aniso[2] + transform.translate[2]; if (transform.blend >= 1) { out[0] = ax; out[1] = ay; out[2] = az; return; } applyFlameVariationInto(transform.code, ax, ay, az, out); const blend = transform.blend; out[0] = ax + (out[0] - ax) * blend; out[1] = ay + (out[1] - ay) * blend; out[2] = az + (out[2] - az) * blend; }

/** Array-returning convenience wrapper around applyFlameTransformInto. */ export function applyFlameTransform(transform, x, y, z) { const out = [0, 0, 0]; applyFlameTransformInto(transform, x, y, z, out); return out; }

/**

  • Applies the optional non-iterated final transform (classic flame "final
  • xform"): a mild blended variation on the splatted point only, never fed
  • back into the orbit. */ export function applyFinalTransformInto(finalTransform, x, y, z, out) { if (!finalTransform) { out[0] = x; out[1] = y; out[2] = z; return; } applyFlameVariationInto(finalTransform.code, x, y, z, out); const blend = finalTransform.blend; out[0] = x + (out[0] - x) * blend; out[1] = y + (out[1] - y) * blend; out[2] = z + (out[2] - z) * blend; }

function rotationMatrix(random) { const a = random() * Math.PI * 2, b = random() * Math.PI * 2, c = random() * Math.PI * 2; const ca = Math.cos(a), sa = Math.sin(a), cb = Math.cos(b), sb = Math.sin(b); const cc = Math.cos(c), sc = Math.sin(c); return [ ca * cb, ca * sb * sc - sa * cc, ca * sb * cc + sa * sc, sa * cb, sa * sb * sc + ca * cc, sa * sb * cc - ca * sc, -sb, cb * sc, cb * cc ]; }

function makeTransforms(random) { const count = 3 + Math.floor(random() * 3); const transforms = []; for (let index = 0; index < count; index += 1) { // The contraction floor sets structure character: strong contraction // beads the attractor into clump chains, gentle contraction keeps large // connected billows. const scale = 0.52 + random() * 0.26; const pick = random(); const variation = pick < 0.36 ? 'linear' : pick < 0.52 ? 'sinusoidal' : pick < 0.7 ? 'spherical' : pick < 0.8 ? 'swirl' : pick < 0.88 ? 'bubble' : pick < 0.94 ? 'horseshoe' : 'fisheye'; // Bounded variations print their bounds when applied pure, and swirl's // phase rings survive strong blends, so each class gets its own cap. const blend = variation === 'linear' ? 1 : variation === 'swirl' ? 0.3 + random() * 0.25 : variation === 'horseshoe' ? 0.4 + random() * 0.35 : 0.35 + random() * 0.4; transforms.push({ rot:rotationMatrix(random), aniso:[scale * (0.7 + random() * 0.5), scale * (0.7 + random() * 0.5), scale * (0.7 + random() * 0.5)], translate:[(random() - 0.5) * 1.05, (random() - 0.5) * 1.05, (random() - 0.5) * 1.05], variation, code:VARIATION_CODES[variation], blend, color:count > 1 ? index / (count - 1) : 0.5, weight:0.5 + random() }); } const total = transforms.reduce((sum, transform) => sum + transform.weight, 0); let acc = 0; transforms.forEach(transform => { acc += transform.weight / total; transform.cdf = acc; delete transform.weight; }); return transforms; }

function makeFinalTransform(random) { if (random() < 0.45) return null; const pick = random(); const variation = pick < 0.35 ? 'spherical' : pick < 0.6 ? 'sinusoidal' : pick < 0.8 ? 'swirl' : 'bubble'; return { variation, code:VARIATION_CODES[variation], blend:0.15 + random() * 0.2 }; }

function pickTransform(transforms, u) { for (let index = 0; index < transforms.length; index += 1) { if (u <= transforms[index].cdf) return transforms[index]; } return transforms[transforms.length - 1]; }

// A short test orbit both rejects degenerate attractors (collapsed, needle, // escaped) and measures the bounding box the renderer frames against. Points // pass through the final transform and the presentation rotation before // measurement so the frame bounds what the renderer actually draws. function testAttractor(transforms, finalTransform, random, rotation) { const cosR = Math.cos(rotation); const sinR = Math.sin(rotation); const scratch = [0, 0, 0]; const finalScratch = [0, 0, 0]; const points = []; for (let n = 0; n < 120; n += 1) { let x = random() * 2 - 1, y = random() * 2 - 1, z = random() * 2 - 1; let escaped = false; for (let i = 0; i < 45; i += 1) { applyFlameTransformInto(pickTransform(transforms, random()), x, y, z, scratch); x = scratch[0]; y = scratch[1]; z = scratch[2]; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { escaped = true; break; } } if (escaped) return null; applyFinalTransformInto(finalTransform, x, y, z, finalScratch); const fx = finalScratch[0], fy = finalScratch[1]; if (Math.abs(fx) < 8 && Math.abs(fy) < 8) { points.push([fx * cosR - fy * sinR, fx * sinR + fy * cosR]); } } if (points.length < 90) return null; const mean = [0, 1].map(axis => points.reduce((sum, p) => sum + p[axis], 0) / points.length); const deviation = [0, 1].map(axis => Math.sqrt(points.reduce((sum, p) => sum + (p[axis] - mean[axis]) ** 2, 0) / points.length)); if (deviation[0] < 0.05 || deviation[1] < 0.05) return null; if (deviation[0] / deviation[1] > 14 || deviation[1] / deviation[0] > 14) return null; const xs = points.map(p => p[0]).sort((a, b) => a - b); const ys = points.map(p => p[1]).sort((a, b) => a - b); const lo = Math.floor(points.length * 0.02); const hi = Math.ceil(points.length * 0.98) - 1; return { bbox:[xs[lo], ys[lo], xs[hi], ys[hi]] }; }

/**

  • Generates deterministic settings for a map and overlays validated authored fields.
  • The attractor itself (transforms, final transform, and framing) is seeded
  • identity, resolved here with a deterministic reject-and-retry loop so every
  • map name yields a non-degenerate nebula.
  • @param {string} mapName Stable map or toy seed.
  • @param {object} [authored={}] Partial public settings.
  • @returns {object} Complete renderer-ready settings. */ export function resolveFlameNebulaSettings(mapName, authored = {}) { const baseHash = hashNebulaSeed(mapName); const source = authored && typeof authored === 'object' && !Array.isArray(authored) ? authored : {}; // The rotation participates in framing, so it resolves (seeded or authored, // in degrees) ahead of the attempt loop. The frame stores the measured // attractor spans; the renderer fits them to whatever sheet size it draws. const rotation = typeof source.rotation === 'number' && Number.isFinite(source.rotation) ? source.rotation * Math.PI / 180 : mulberry32((baseHash ^ 0x51ab7e3d) >>> 0)() * Math.PI * 2 - Math.PI; const viewport = resolveNebulaCanvasSize(FLAME_NEBULA.viewport, source); let transforms = FLAME_NEBULA.transforms; let finalTransform = FLAME_NEBULA.finalTransform; let frame = { ...FLAME_NEBULA.frame }; let paletteRandom = mulberry32(baseHash); for (let attempt = 0; attempt < 60; attempt += 1) { const random = mulberry32((baseHash + attempt * 7919) >>> 0); const candidate = makeTransforms(random); const candidateFinal = makeFinalTransform(random); const test = testAttractor(candidate, candidateFinal, random, rotation); if (test) { transforms = candidate; finalTransform = candidateFinal; paletteRandom = random; const [x0, y0, x1, y1] = test.bbox; frame = { spanX:Math.max(0.2, x1 - x0), spanY:Math.max(0.2, y1 - y0), cx:(x0 + x1) / 2, cy:(y0 + y1) / 2 }; break; } }

const palette = PALETTES[Math.floor(paletteRandom() * PALETTES.length)]; const generated = { ...FLAME_NEBULA, viewport:[...viewport], origin:[viewport[0] / 2, viewport[1] / 2], screenAnchor:[...FLAME_NEBULA.screenAnchor], rotation, opacity:0.66 + paletteRandom() * 0.2, colorA:[...palette.gas], colorB:[...palette.rim], highlight:[...palette.core], zoom:0.85 + paletteRandom() * 0.3, gamma:2.2 + paletteRandom() * 0.5, hotCores:0.25 + paletteRandom() * 0.25, bloom:0.45 + paletteRandom() * 0.35, transforms, finalTransform, frame, lutStops:[ 0.18 + paletteRandom() * 0.08, 0.45 + paletteRandom() * 0.1, 0.74 + paletteRandom() * 0.08 ], orbitSeed:(baseHash ^ 0x9e3779b9) >>> 0, startOffset:[(paletteRandom() - 0.5) * 800, (paletteRandom() - 0.5) * 480] };

const settings = { ...generated }; NUMBER_OVERRIDES.forEach(key => { if (typeof source[key] === 'number' && Number.isFinite(source[key])) settings[key] = source[key]; }); COLOR_OVERRIDES.forEach(key => { if (validColor(source[key])) settings[key] = source[key].map(Number); }); if (Number.isInteger(source.samples)) { settings.samples = Math.max(8000000, Math.min(48000000, source.samples)); } return settings; }

/**

  • Selects and rounds the public authored subset of resolved settings.
  • @param {object} settings Complete renderer settings.
  • @returns {object} JSON/YAML-ready public settings. */ export function serializeFlameNebulaSettings(settings) { const rounded = value => Number(value.toFixed(3)); return { width:Math.round(settings.viewport[0]), height:Math.round(settings.viewport[1]), rotation:rounded(settings.rotation * 180 / Math.PI), opacity:rounded(settings.opacity), colorA:settings.colorA.map(rounded), colorB:settings.colorB.map(rounded), highlight:settings.highlight.map(rounded), zoom:rounded(settings.zoom), gamma:rounded(settings.gamma), hotCores:rounded(settings.hotCores), bloom:rounded(settings.bloom), samples:settings.samples }; }

Fractal-flame algorithm after Scott Draves; original CPU implementation, no GLSL in this family. The attractor itself is seeded from the map name; the knobs grade its rendering.