diff --git a/src/App.svelte b/src/App.svelte index 24e6e8b..f03f3fa 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,9 @@
@@ -84,9 +143,12 @@ {#if error}

Engine failed to start: {error}

{:else} - - - + + {#if view.mode === "focus"} + + + + {/if} {/if}
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index fb66ae2..9e87195 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -8,7 +8,15 @@ // 3. field_update : 5-tap diffuse(prev field) + persist, mix in deposit -> field tex // render(): field -> HSV to the canvas. -import { createContext, Program, PingPong, createFloatTarget, type GL, type Target } from "./gl/glutil"; +import { + createContext, + Program, + PingPong, + createFloatTarget, + createColorTarget, + type GL, + type Target, +} from "./gl/glutil"; import type { Rule } from "./rule"; import type { SubstrateParams } from "./params"; @@ -18,6 +26,19 @@ import depositVert from "./shaders/deposit.vert?raw"; import depositFrag from "./shaders/deposit.frag?raw"; import fieldFrag from "./shaders/field_update.frag?raw"; import displayFrag from "./shaders/display.frag?raw"; +import blitVert from "./shaders/blit.vert?raw"; +import blitFrag from "./shaders/blit.frag?raw"; + +/** A breeder-grid candidate: a compiled rule plus the substrate it runs in. */ +export interface GenSpec { + rule: Rule; + params: SubstrateParams; + depositGain: number; +} + +/** Thumbnail capture resolution (square). The field downscales into this; LINEAR-upscaled to + * the on-screen tile. 384² × ~9 RGBA8 ≈ 5 MB — cheap. */ +const THUMB_RES = 384; const SIGMA = 0.163; const GAUSS_NORM = 1.0 / (2.0 * Math.PI * SIGMA * SIGMA); // ≈ 5.99 @@ -43,12 +64,27 @@ export class Engine { private depositProg: Program; private fieldProg: Program; private displayProg: Program; + private blitProg: Program; private particles: PingPong; private field: PingPong; private deposit: Target; private vao: WebGLVertexArrayObject; + // --- breeder grid (mode === "grid") --------------------------------------------------- + // The single full-res sim above is the one resident sim; the grid is rendered by time- + // slicing it across candidates and caching each converged frame as a coloured thumbnail. + // Fidelity: thumbnails are the SAME sim at the SAME pins as the focus/single view, just + // displayed smaller — so preview == actual by construction (no separate low-res preview). + private mode: "single" | "grid" = "single"; + private specs: GenSpec[] = []; + private cols = 3; + private thumbs: Target[] = []; // cached coloured frames, one per candidate + private buildIndex = 0; // next candidate to warm up + capture (>= specs.length when done) + private hoverIndex: number | null = null; // candidate to animate live (null = all frozen) + private residentTile = -1; // which candidate the resident sim currently holds (-1 = none/stale) + private onBuildDone?: () => void; + private pSize: number; private fieldRes: number; private particleCount: number; @@ -90,6 +126,7 @@ export class Engine { this.depositProg = new Program(gl, depositVert, depositFrag, "deposit"); this.fieldProg = new Program(gl, fullscreenVert, fieldFrag, "field_update"); this.displayProg = new Program(gl, fullscreenVert, displayFrag, "display"); + this.blitProg = new Program(gl, blitVert, blitFrag, "blit"); // Particle state: NEAREST/CLAMP, fetched by texel; never filtered or wrapped in-sampler. const initData = this.makeInitialParticles(this.seed); @@ -133,6 +170,47 @@ export class Engine { this.exposure = e; } + // --- breeder grid seam ------------------------------------------------------------------ + + /** Callback fired once the whole grid has finished warming up + capturing thumbnails. */ + setOnBuildDone(fn: () => void) { + this.onBuildDone = fn; + } + + /** Switch between the full-screen single sim (focus/editor) and the thumbnail grid. Cached + * thumbnails survive the switch; the resident sim is considered stale (focus clobbers it). */ + setMode(mode: "single" | "grid") { + this.mode = mode; + this.residentTile = -1; + if (mode === "grid") this.hoverIndex = null; + } + + /** Load a new generation of candidates and (re)build their thumbnails over the next frames. */ + setGeneration(specs: GenSpec[], cols: number) { + this.specs = specs; + this.cols = Math.max(1, cols); + this.ensureThumbs(specs.length); + this.buildIndex = 0; + this.hoverIndex = null; + this.residentTile = -1; + this.mode = "grid"; + } + + /** Animate candidate `i` (null = freeze all). Switching tiles re-warms the resident sim. */ + setHover(i: number | null) { + this.hoverIndex = i !== null && i >= 0 && i < this.specs.length ? i : null; + } + + /** Grid build progress, for an "evolving…" indicator. */ + gridProgress(): { done: number; total: number } { + return { done: Math.min(this.buildIndex, this.specs.length), total: this.specs.length }; + } + + /** Run the warmup burst on the resident sim so structure is on screen immediately (focus). */ + warmup() { + for (let i = 0; i < this.warmupSteps; i++) this.step(); + } + /** Repro-relevant engine settings as plain data (for the SimPins of a saved record). */ config(): { seed: number; warmup: number; fieldRes: number; particleCount: number } { return { @@ -236,11 +314,94 @@ export class Engine { private loop = () => { if (!this.running) return; - for (let i = 0; i < this.stepsPerFrame; i++) this.step(); - this.render(); + if (this.mode === "grid") { + this.gridFrame(); + } else { + for (let i = 0; i < this.stepsPerFrame; i++) this.step(); + this.render(); + } this.raf = requestAnimationFrame(this.loop); }; + // --- grid frame: build thumbnails, animate the hovered tile, composite ------------------- + + private gridFrame() { + if (this.buildIndex < this.specs.length) { + // Warm up + capture one candidate per frame (a brief reveal, keeps the tab responsive). + this.buildTile(this.buildIndex); + this.buildIndex++; + if (this.buildIndex >= this.specs.length) this.onBuildDone?.(); + } else if (this.hoverIndex !== null) { + if (this.residentTile !== this.hoverIndex) { + this.buildTile(this.hoverIndex); // catch the resident sim up to this tile's frozen frame + } else { + this.step(); // already resident -> advance it live + this.captureThumb(this.hoverIndex); + } + } + this.renderGrid(); + } + + /** Point the resident sim at candidate `i`, warm it to convergence, capture its thumbnail. */ + private buildTile(i: number) { + const s = this.specs[i]; + this.rule = s.rule; + this.params = s.params; + this.depositGain = s.depositGain; + this.reset(this.seed); // same seed for every candidate -> a fair comparison + this.warmup(); + this.captureThumb(i); + this.residentTile = i; + } + + /** Render the resident sim's field (HSV display) into thumbnail `i`'s cached texture. */ + private captureThumb(i: number) { + const gl = this.gl; + const t = this.thumbs[i]; + gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo); + gl.viewport(0, 0, t.width, t.height); + this.displayProg.use(); + const sp = this.displayProg; + this.bindTex(0, this.field.read.tex); + gl.uniform1i(sp.loc("uField"), 0); + gl.uniform2f(sp.loc("uViewport"), t.width, t.height); // viewport fills the FBO -> uv in [0,1] + gl.uniform1f(sp.loc("uExposure"), this.exposure); + gl.uniform1f(sp.loc("uDebug"), this.debug ? 1 : 0); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + /** Composite the cached thumbnails into a grid of squares on the canvas. */ + private renderGrid() { + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(0.02, 0.02, 0.03, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + this.blitProg.use(); + const rows = Math.ceil(this.specs.length / this.cols); + const cellW = this.canvas.width / this.cols; + const cellH = this.canvas.height / rows; + const side = Math.min(cellW, cellH) * 0.94; // letterbox to a centred square (no stretch) + for (let i = 0; i < this.specs.length; i++) { + const col = i % this.cols; + const row = Math.floor(i / this.cols); + const cx = col * cellW + (cellW - side) / 2; + // GL viewport origin is bottom-left; lay rows out top-to-bottom. + const cy = this.canvas.height - (row * cellH + (cellH - side) / 2 + side); + gl.viewport(cx, cy, side, side); + this.bindTex(0, this.thumbs[i].tex); + gl.uniform1i(this.blitProg.loc("uSrc"), 0); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + } + + /** Allocate (or reuse) `count` thumbnail textures. */ + private ensureThumbs(count: number) { + while (this.thumbs.length < count) { + this.thumbs.push(createColorTarget(this.gl, THUMB_RES, THUMB_RES)); + } + } + step() { const gl = this.gl; diff --git a/src/engine/gl/glutil.ts b/src/engine/gl/glutil.ts index 051734e..ffc2a5b 100644 --- a/src/engine/gl/glutil.ts +++ b/src/engine/gl/glutil.ts @@ -114,6 +114,30 @@ export function createFloatTarget( return { tex, fbo, width, height }; } +/** + * An 8-bit RGBA texture + FBO, for caching already-coloured display output (thumbnails). + * LINEAR filtered so the field downscales to a tile smoothly; null-init is reliable for + * fixed-point textures (the zero-fill caveat only bites float textures). + */ +export function createColorTarget(gl: GL, width: number, height: number): Target { + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + const fbo = gl.createFramebuffer()!; + gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0); + if (gl.checkFramebufferStatus(gl.FRAMEBUFFER) !== gl.FRAMEBUFFER_COMPLETE) { + throw new Error("Incomplete framebuffer for RGBA8 target."); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + return { tex, fbo, width, height }; +} + /** Two interchangeable float targets for read→write ping-ponging. */ export class PingPong { read: Target; diff --git a/src/engine/shaders/blit.frag b/src/engine/shaders/blit.frag new file mode 100644 index 0000000..e075d7a --- /dev/null +++ b/src/engine/shaders/blit.frag @@ -0,0 +1,13 @@ +#version 300 es +precision highp float; + +// Copy a cached thumbnail (already HSV-coloured by the display pass) into the current viewport. +// LINEAR filtering on the source gives a smooth downscale-to-fit. + +uniform sampler2D uSrc; +in vec2 vUv; +out vec4 outColor; + +void main() { + outColor = texture(uSrc, vUv); +} diff --git a/src/engine/shaders/blit.vert b/src/engine/shaders/blit.vert new file mode 100644 index 0000000..ea00cc6 --- /dev/null +++ b/src/engine/shaders/blit.vert @@ -0,0 +1,10 @@ +#version 300 es +// Full-screen triangle that also emits a [0,1] uv varying. Unlike fullscreen.vert (which +// relies on gl_FragCoord/uViewport), the varying makes sampling correct in ANY viewport — +// so a thumbnail can be blitted into a sub-rect of the canvas without absolute-coord math. +out vec2 vUv; +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + vUv = p; // interpolates 0..1 over the visible [-1,1] square + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/evolution/spawn.ts b/src/evolution/spawn.ts new file mode 100644 index 0000000..5bd49a8 --- /dev/null +++ b/src/evolution/spawn.ts @@ -0,0 +1,53 @@ +// Population spawning — the pure-TS half of the breeder loop (no WebGL, no Svelte). Given a +// parent genome (or none), produce a generation of candidate CPPNs to fill the breeder grid. +// +// newGeneration(null) -> a diverse spread of fresh random lineages (first grid) +// spawnChildren([parent], n) -> n single-mutation children of one parent (refine a pick) +// spawnChildren([a, b], n) -> n crossover+mutate children of two parents (a cross) +// +// All genome structure lives in CPPN; this module only orchestrates the NEAT-lite operators. +// Morphology/environment are inherited unchanged elsewhere (morphology sigmas are 0 in v0), so +// only the rule-encoding CPPN varies across a generation. + +import { CPPN } from "../genome/cppn"; +import { Rng } from "../genome/rng"; +import { N_IN, N_OUT } from "../genome/compile"; + +// A minimal CPPN is near-trivial (often dead) dynamics, so seed each fresh genome with a handful +// of mutations — enough structure to render something living on first view. (Shared with the +// single-genome editor in state/genome.svelte.ts.) +export const INITIAL_MUTATIONS = 20; + +/** A fresh, deterministic genome for `seed`: minimal topology + INITIAL_MUTATIONS steps. */ +export function freshGenome(seed: number): CPPN { + const rng = new Rng(seed); + let net = CPPN.minimal(N_IN, N_OUT, rng); + for (let i = 0; i < INITIAL_MUTATIONS; i++) net = net.mutate(); + return net; +} + +/** A diverse generation: `n` independent fresh lineages from consecutive seeds off `seed0`. */ +export function randomGenomes(n: number, seed0: number): CPPN[] { + let s = seed0 >>> 0; + const out: CPPN[] = []; + for (let i = 0; i < n; i++) { + out.push(freshGenome(s)); + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; // LCG hop so seeds aren't adjacent integers + } + return out; +} + +/** + * `n` children of the current selection. One parent → single-mutation variants; two parents → + * crossover then a mutation (so siblings differ). Each call advances the parents' shared rng, so + * the n children diverge from one another. + */ +export function spawnChildren(parents: CPPN[], n: number): CPPN[] { + if (parents.length === 0) throw new Error("spawnChildren needs at least one parent"); + const out: CPPN[] = []; + for (let i = 0; i < n; i++) { + const child = parents.length >= 2 ? parents[0].crossover(parents[1]).mutate() : parents[0].mutate(); + out.push(child); + } + return out; +} diff --git a/src/state/generation.svelte.ts b/src/state/generation.svelte.ts new file mode 100644 index 0000000..73d5f41 --- /dev/null +++ b/src/state/generation.svelte.ts @@ -0,0 +1,68 @@ +// Breeder-grid generation state — the reactive half of the Picbreeder loop. Holds the current +// grid of candidate genomes and the action to advance a generation by breeding from a pick. +// +// ARCHITECTURE: like genome.svelte.ts, the live CPPN instances (and their compiled Float32Array +// rules) are NON-reactive — they live in a module holder, never in $state (CPPNs carry Maps; a +// proxied typed array would corrupt the rule). The reactive `generation` store carries only plain +// data: a `nonce` the orchestrator watches, the grid shape, and a `building` flag. The engine +// pulls the actual specs via generationSpecs() when the nonce changes. + +import type { CPPN } from "../genome/cppn"; +import { compileGenome } from "../genome/compile"; +import { randomGenomes, spawnChildren } from "../evolution/spawn"; +import type { Rule } from "../engine/rule"; +import type { GenSpec } from "../engine/Engine"; +import { activeGenome } from "./genome.svelte"; +import { substrateParams } from "./substrate.svelte"; +import { organism } from "./organism.svelte"; + +const SIZE = 9; +const COLS = 3; + +// Non-reactive: the candidate genomes and their compiled rules for the current generation. +let memberCppns: CPPN[] = []; +let memberRules: Rule[] = []; +let randomSeed = 1; // hops each time we spawn a fresh (parentless) generation + +export const generation = $state({ + nonce: 0, // bump -> orchestrator pushes generationSpecs() into the engine + size: SIZE, + cols: COLS, + building: false, // true while the engine warms up + captures thumbnails +}); + +/** Build a new generation: children of `parents`, or a diverse random spread if null. */ +export function newGeneration(parents: CPPN[] | null): void { + if (parents && parents.length) { + memberCppns = spawnChildren(parents, SIZE); + } else { + memberCppns = randomGenomes(SIZE, randomSeed); + randomSeed = (Math.imul(randomSeed, 1664525) + 1013904223) >>> 0; + } + memberRules = memberCppns.map((c) => compileGenome(c).rule); + generation.building = true; + generation.nonce++; +} + +/** Advance the loop: the currently-focused (active) genome becomes the parent of the next grid. */ +export function breedFromActive(): void { + newGeneration([activeGenome()]); +} + +/** The candidate at `i` as a serialized genome (for loading into the focus/editor view). */ +export function memberCppn(i: number) { + return memberCppns[i].toJSON(); +} + +/** The engine's view of the generation: one spec per candidate (shared substrate, varying rule). */ +export function generationSpecs(): { specs: GenSpec[]; cols: number } { + const params = substrateParams(); + const depositGain = organism.depositGain; + const specs = memberRules.map((rule) => ({ rule, params, depositGain })); + return { specs, cols: COLS }; +} + +/** Engine reports the grid finished warming up + capturing thumbnails. */ +export function markBuilt(): void { + generation.building = false; +} diff --git a/src/state/genome.svelte.ts b/src/state/genome.svelte.ts index 87b0188..05353af 100644 --- a/src/state/genome.svelte.ts +++ b/src/state/genome.svelte.ts @@ -7,21 +7,12 @@ import { CPPN, type CppnDict } from "../genome/cppn"; import { Rng } from "../genome/rng"; -import { compileGenome, N_IN, N_OUT, type CompileOptions } from "../genome/compile"; +import { compileGenome, type CompileOptions } from "../genome/compile"; +import { freshGenome } from "../evolution/spawn"; import type { Rule } from "../engine/rule"; -// A minimal CPPN compiles to near-trivial (often dead) dynamics, so seed each fresh genome -// with a handful of mutations — enough structure to render something on first view. -const INITIAL_MUTATIONS = 20; const COMPILE_OPTS: CompileOptions = {}; // defaults (see compile.ts) -function freshGenome(seed: number): CPPN { - const rng = new Rng(seed); - let net = CPPN.minimal(N_IN, N_OUT, rng); - for (let i = 0; i < INITIAL_MUTATIONS; i++) net = net.mutate(); - return net; -} - let current = freshGenome(1); let lineageSeed = 1; diff --git a/src/state/view.svelte.ts b/src/state/view.svelte.ts index af45271..e47bec0 100644 --- a/src/state/view.svelte.ts +++ b/src/state/view.svelte.ts @@ -7,4 +7,11 @@ export const VIEW_DEFAULTS = { debug: false, // diagnostic display (red=NaN, gradient=zero, log-magnitude) }; -export const view = $state({ ...VIEW_DEFAULTS, restartNonce: 0 }); +// `mode` is the top-level navigation between the breeder grid (choose) and the focus/editor view +// (inspect + tune + save one organism). `focusIndex` is the candidate being inspected. +export const view = $state({ + ...VIEW_DEFAULTS, + restartNonce: 0, + mode: "grid" as "grid" | "focus", + focusIndex: 0, +}); diff --git a/src/ui/BreederGrid.svelte b/src/ui/BreederGrid.svelte new file mode 100644 index 0000000..234b6ac --- /dev/null +++ b/src/ui/BreederGrid.svelte @@ -0,0 +1,129 @@ + + +{#if view.mode === "grid"} +
+ {#each cells as i (i)} + + {/each} +
+ +
+ {#if generation.building} + evolving… {generation.size} variants + {:else} + hover a cell to bring it to life · click to inspect & breed + {/if} +
+{:else} +
+ + + +
+{/if} + +