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}
+
+