diff --git a/src/App.svelte b/src/App.svelte index 8c0a1a4..077964b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,23 +1,73 @@
-

Fluodditylike — scaffold ready. Build milestone 1: the WebGL2 engine spike.

+ {#if error} +

Engine failed to start: {error}

+ {:else} + + {/if}
diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts new file mode 100644 index 0000000..4a34100 --- /dev/null +++ b/src/engine/Engine.ts @@ -0,0 +1,354 @@ +// The Fluodditylike v0 substrate as a WebGL2 engine. Pure imperative TS — owns the GL +// context, the ping-pong float textures, and the rAF loop. NEVER imported by Svelte's +// reactive layer; the orchestrator pushes a plain {rule, params} snapshot in. +// +// Pipeline per step (docs/v0_normalization.md §1, world_size = 1): +// 1. particle_update : sample 2 sensors, eval the rule, integrate vel/pos -> particle tex +// 2. deposit : additive gl.POINTS gaussian splat of velocity -> deposit tex +// 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 type { Rule } from "./rule"; +import type { SubstrateParams } from "./params"; + +import fullscreenVert from "./shaders/fullscreen.vert?raw"; +import particleFrag from "./shaders/particle_update.frag?raw"; +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"; + +const SIGMA = 0.163; +const GAUSS_NORM = 1.0 / (2.0 * Math.PI * SIGMA * SIGMA); // ≈ 5.99 +// Desktop splat half-size is 0.0015 in world units (v0_normalization.md §1d). World width is +// 2, so the sprite spans (2·0.0015 / 2)·fieldRes ≈ 1.5px at 1024² — derived, not arbitrary. +const SPLAT_WORLD_HALF = 0.0015; + +export interface EngineOptions { + rule: Rule; + params: SubstrateParams; + seed?: number; + /** particle count = particleTexSize²; 512 → ~262k (spec target ~250k live). */ + particleTexSize?: number; + /** field/canvas-state resolution (spec: 1024 live). */ + fieldRes?: number; +} + +export class Engine { + private gl: GL; + private canvas: HTMLCanvasElement; + + private particleProg: Program; + private depositProg: Program; + private fieldProg: Program; + private displayProg: Program; + + private particles: PingPong; + private field: PingPong; + private deposit: Target; + private vao: WebGLVertexArrayObject; + + private pSize: number; + private fieldRes: number; + private particleCount: number; + private splatPx: number; + + private rule: Rule; + private params: SubstrateParams; + private seed: number; + + private exposure = 10.0; + private debug = false; // diagnostic display mode (red=NaN, blue=zero, log-mag otherwise) + private depositGain = 30.0; // behavioral energy knob; sweep via setDepositGain() + private steps = 0; + private running = false; + private raf = 0; + private stepsPerFrame = 1; + private warmupSteps = 80; + private resizeObserver?: ResizeObserver; + + constructor(canvas: HTMLCanvasElement, opts: EngineOptions) { + this.canvas = canvas; + this.gl = createContext(canvas); + const gl = this.gl; + + // Blending into RGBA32F (the deposit accumulation) needs EXT_float_blend. + if (!gl.getExtension("EXT_float_blend")) { + console.warn("EXT_float_blend missing — additive deposit into RGBA32F may not accumulate."); + } + + this.pSize = opts.particleTexSize ?? 512; + this.fieldRes = opts.fieldRes ?? 1024; + this.particleCount = this.pSize * this.pSize; + this.splatPx = Math.max(1, SPLAT_WORLD_HALF * this.fieldRes); + this.rule = opts.rule; + this.params = opts.params; + this.seed = opts.seed ?? 1; + + this.particleProg = new Program(gl, fullscreenVert, particleFrag, "particle_update"); + 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"); + + // Particle state: NEAREST/CLAMP, fetched by texel; never filtered or wrapped in-sampler. + const initData = this.makeInitialParticles(this.seed); + this.particles = new PingPong( + createFloatTarget(gl, this.pSize, this.pSize, { data: initData }), + createFloatTarget(gl, this.pSize, this.pSize), + ); + // Field + deposit are RGBA16F: the deposit pass blends additively into them, and 16F + // blending is core WebGL2 (32F blending would need EXT_float_blend — see glutil). + // REPEAT wrap on the field so the diffuse neighbour taps are toroidal. + this.field = new PingPong( + createFloatTarget(gl, this.fieldRes, this.fieldRes, { wrap: gl.REPEAT, half: true }), + createFloatTarget(gl, this.fieldRes, this.fieldRes, { wrap: gl.REPEAT, half: true }), + ); + this.deposit = createFloatTarget(gl, this.fieldRes, this.fieldRes, { half: true }); + + // One empty VAO, bound for the engine's lifetime: all draws are attribute-less + // (fullscreen triangle and deposit points both use gl_VertexID). + this.vao = gl.createVertexArray()!; + gl.bindVertexArray(this.vao); + + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + // Textures are zero-filled at creation (see glutil — drivers don't reliably honour the + // null-init spec); this clear is belt-and-suspenders and the reset path. + this.clearFields(); + this.resize(); + } + + // --- public API (the orchestrator seam) ------------------------------------------------- + + setRule(rule: Rule) { + this.rule = rule; + } + + setParams(params: SubstrateParams) { + this.params = params; + } + + setExposure(e: number) { + this.exposure = e; + } + + setStepsPerFrame(n: number) { + this.stepsPerFrame = Math.max(1, Math.floor(n)); + } + + /** Toggle the diagnostic display (true) vs normal HSV exposure view (false). */ + setDebug(on: boolean) { + this.debug = on; + } + + /** Scale how much energy each particle deposits into the field (drives the substrate). */ + setDepositGain(g: number) { + this.depositGain = g; + } + + /** + * Diagnostics for the console (dev only). Deliberately no readPixels: it's blocked/ + * randomized by Firefox's anti-fingerprinting, so the field can't be measured CPU-side — + * use the diagnostic display (setDebug(true)) to inspect the field instead. + */ + debugStats() { + const gl = this.gl; + return { + steps: this.steps, + exposure: this.exposure, + depositGain: this.depositGain, + glError: gl.getError(), + floatBlend: !!gl.getExtension("EXT_float_blend"), + colorBufferFloat: !!gl.getExtension("EXT_color_buffer_float"), + }; + } + + /** Re-seed and clear all state (genome/param changes restart the stochastic sim). */ + reset(seed = this.seed) { + const gl = this.gl; + this.seed = seed; + this.steps = 0; + gl.bindTexture(gl.TEXTURE_2D, this.particles.read.tex); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, this.pSize, this.pSize, gl.RGBA, gl.FLOAT, + this.makeInitialParticles(seed)); + this.clearFields(); + } + + /** Zero the field (both ping-pong halves) and deposit textures. */ + private clearFields() { + const gl = this.gl; + for (const t of [this.field.read, this.field.write, this.deposit]) { + gl.bindFramebuffer(gl.FRAMEBUFFER, t.fbo); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + } + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + + start() { + if (this.running) return; + this.running = true; + this.resize(); + // Warm up so structure is on screen immediately instead of building over a few seconds. + for (let i = 0; i < this.warmupSteps; i++) this.step(); + this.render(); + this.raf = requestAnimationFrame(this.loop); + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(this.canvas); + } + + stop() { + this.running = false; + if (this.raf) cancelAnimationFrame(this.raf); + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + } + + /** Size the drawing buffer to the canvas's CSS size × devicePixelRatio. */ + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.max(1, Math.round(this.canvas.clientWidth * dpr)); + const h = Math.max(1, Math.round(this.canvas.clientHeight * dpr)); + if (this.canvas.width !== w || this.canvas.height !== h) { + this.canvas.width = w; + this.canvas.height = h; + } + } + + dispose() { + this.stop(); + // Browser reclaims GL objects when the context is dropped; explicit deletion omitted + // for the spike. + } + + // --- frame loop ------------------------------------------------------------------------- + + private loop = () => { + if (!this.running) return; + for (let i = 0; i < this.stepsPerFrame; i++) this.step(); + this.render(); + this.raf = requestAnimationFrame(this.loop); + }; + + step() { + const gl = this.gl; + + // 1. particle_update -> particles.write + gl.bindFramebuffer(gl.FRAMEBUFFER, this.particles.write.fbo); + gl.viewport(0, 0, this.pSize, this.pSize); + this.particleProg.use(); + const pp = this.particleProg; + this.bindTex(0, this.particles.read.tex); + gl.uniform1i(pp.loc("uParticles"), 0); + this.bindTex(1, this.field.read.tex); + gl.uniform1i(pp.loc("uField"), 1); + gl.uniform4fv(pp.loc("uRule[0]"), this.rule); + gl.uniform2f(pp.loc("uFieldRes"), this.fieldRes, this.fieldRes); + const p = this.params; + gl.uniform1f(pp.loc("uSensorGain"), p.sensorGain); + gl.uniform1f(pp.loc("uSensorAngle"), p.sensorAngle); + gl.uniform1f(pp.loc("uSensorDistance"), p.sensorDistance); + gl.uniform1f(pp.loc("uGlobalForceMult"), p.globalForceMult); + gl.uniform1f(pp.loc("uDrag"), p.drag); + gl.uniform1f(pp.loc("uStrafePower"), p.strafePower); + gl.uniform1f(pp.loc("uAxialForce"), p.axialForce); + gl.uniform1f(pp.loc("uLateralForce"), p.lateralForce); + gl.drawArrays(gl.TRIANGLES, 0, 3); + this.particles.swap(); // particles.read is now the updated state + + // 2. deposit -> deposit (additive gl.POINTS) + gl.bindFramebuffer(gl.FRAMEBUFFER, this.deposit.fbo); + gl.viewport(0, 0, this.fieldRes, this.fieldRes); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + gl.enable(gl.BLEND); + gl.blendFunc(gl.ONE, gl.ONE); + this.depositProg.use(); + const dp = this.depositProg; + this.bindTex(0, this.particles.read.tex); + gl.uniform1i(dp.loc("uParticles"), 0); + gl.uniform1i(dp.loc("uTexW"), this.pSize); + gl.uniform1f(dp.loc("uSplatPx"), this.splatPx); + gl.uniform1f(dp.loc("uGaussNorm"), GAUSS_NORM); + gl.uniform1f(dp.loc("uSigma"), SIGMA); + gl.uniform1f(dp.loc("uDepositGain"), this.depositGain); + gl.drawArrays(gl.POINTS, 0, this.particleCount); + gl.disable(gl.BLEND); + + // 3. field_update -> field.write + gl.bindFramebuffer(gl.FRAMEBUFFER, this.field.write.fbo); + gl.viewport(0, 0, this.fieldRes, this.fieldRes); + this.fieldProg.use(); + const fp = this.fieldProg; + this.bindTex(0, this.field.read.tex); + gl.uniform1i(fp.loc("uField"), 0); + this.bindTex(1, this.deposit.tex); + gl.uniform1i(fp.loc("uDeposit"), 1); + gl.uniform2f(fp.loc("uFieldRes"), this.fieldRes, this.fieldRes); + gl.uniform1f(fp.loc("uPersistence"), p.trailPersistence); + gl.uniform1f(fp.loc("uDiffusion"), p.trailDiffusion); + gl.drawArrays(gl.TRIANGLES, 0, 3); + this.field.swap(); + + this.steps++; + // NOTE: CPU readback (readPixels FLOAT from a 16F buffer) is unreliable on some drivers + // (seen on Firefox: returns impossible values), so auto-calibration is disabled. Use the + // diagnostic display (setDebug(true)) or manual setExposure() instead. + } + + render() { + const gl = this.gl; + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.viewport(0, 0, this.canvas.width, this.canvas.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"), this.canvas.width, this.canvas.height); + gl.uniform1f(sp.loc("uExposure"), this.exposure); + gl.uniform1f(sp.loc("uDebug"), this.debug ? 1 : 0); + gl.drawArrays(gl.TRIANGLES, 0, 3); + } + + // --- internals -------------------------------------------------------------------------- + + private bindTex(unit: number, tex: WebGLTexture) { + const gl = this.gl; + gl.activeTexture(gl.TEXTURE0 + unit); + gl.bindTexture(gl.TEXTURE_2D, tex); + } + + /** Seeded uniform init: pos ~ U(-1,1)², vel ~ 1e-3·N(0,1) (docs/v0_normalization.md §1). */ + private makeInitialParticles(seed: number): Float32Array { + const n = this.particleCount; + const data = new Float32Array(n * 4); + const rand = mulberry32(seed); + for (let i = 0; i < n; i++) { + const o = i * 4; + data[o] = rand() * 2 - 1; + data[o + 1] = rand() * 2 - 1; + data[o + 2] = gaussian(rand) * 1e-3; + data[o + 3] = gaussian(rand) * 1e-3; + } + return data; + } + +} + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Box-Muller standard normal from a uniform [0,1) generator. */ +function gaussian(rand: () => number): number { + const u = Math.max(rand(), 1e-12); + const v = rand(); + return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); +} diff --git a/src/engine/gl/glutil.ts b/src/engine/gl/glutil.ts new file mode 100644 index 0000000..051734e --- /dev/null +++ b/src/engine/gl/glutil.ts @@ -0,0 +1,130 @@ +// Minimal WebGL2 helpers for the engine spike. No Svelte, no GL objects in reactivity. +// Kept deliberately small: a context factory, a Program wrapper that caches uniform +// locations, RGBA32F texture + FBO creation, and a ping-pong pair. + +export type GL = WebGL2RenderingContext; + +/** Create a WebGL2 context and verify the one capability the substrate needs. */ +export function createContext(canvas: HTMLCanvasElement): GL { + const gl = canvas.getContext("webgl2", { + antialias: false, + depth: false, + stencil: false, + premultipliedAlpha: false, + preserveDrawingBuffer: false, + }); + if (!gl) throw new Error("WebGL2 is not available in this browser."); + // Rendering to RGBA32F (particle + field state lives in float textures) requires this. + if (!gl.getExtension("EXT_color_buffer_float")) { + throw new Error("EXT_color_buffer_float is required (RGBA32F render targets)."); + } + return gl; +} + +/** Compile + link a program and lazily cache uniform locations by name. */ +export class Program { + readonly program: WebGLProgram; + private gl: GL; + private locs = new Map(); + + constructor(gl: GL, vertSrc: string, fragSrc: string, label = "program") { + this.gl = gl; + const vs = compileShader(gl, gl.VERTEX_SHADER, vertSrc, `${label}.vert`); + const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc, `${label}.frag`); + const prog = gl.createProgram(); + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { + const log = gl.getProgramInfoLog(prog); + throw new Error(`Failed to link ${label}: ${log}`); + } + gl.deleteShader(vs); + gl.deleteShader(fs); + this.program = prog; + } + + use() { + this.gl.useProgram(this.program); + } + + loc(name: string): WebGLUniformLocation | null { + let l = this.locs.get(name); + if (l === undefined) { + l = this.gl.getUniformLocation(this.program, name); + this.locs.set(name, l); + } + return l; + } +} + +function compileShader(gl: GL, type: number, src: string, label: string): WebGLShader { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, src); + gl.compileShader(sh); + if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) { + const log = gl.getShaderInfoLog(sh); + throw new Error(`Failed to compile ${label}: ${log}`); + } + return sh; +} + +/** An RGBA32F texture plus a framebuffer that renders into it. */ +export interface Target { + tex: WebGLTexture; + fbo: WebGLFramebuffer; + width: number; + height: number; +} + +export function createFloatTarget( + gl: GL, + width: number, + height: number, + opts: { wrap?: number; filter?: number; data?: Float32Array | null; half?: boolean } = {}, +): Target { + const wrap = opts.wrap ?? gl.CLAMP_TO_EDGE; + const filter = opts.filter ?? gl.NEAREST; + // RGBA16F (half) is core-renderable AND core-blendable in WebGL2. RGBA32F is renderable + // with EXT_color_buffer_float but only *blendable* with EXT_float_blend — so any texture + // we additively blend into (the deposit/field) must be half to avoid that dependency. + const internalFormat = opts.half ? gl.RGBA16F : gl.RGBA32F; + const type = opts.half ? gl.HALF_FLOAT : gl.FLOAT; + // Explicit zero-fill, never `null`: some drivers (seen on Firefox) leave float textures + // uninitialized, and an FBO clear didn't reliably fix it — uninitialized field texels read + // as garbage/Inf, and Inf through cos() in the rule becomes NaN that poisons everything. + // A zero typed array (0x0000 is 0.0 in both f16 and f32) guarantees a clean start. + const pixels: ArrayBufferView = + opts.data ?? (opts.half ? new Uint16Array(width * height * 4) : new Float32Array(width * height * 4)); + const tex = gl.createTexture()!; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.texImage2D(gl.TEXTURE_2D, 0, internalFormat, width, height, 0, gl.RGBA, type, pixels); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap); + + 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 RGBA32F 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; + write: Target; + constructor(read: Target, write: Target) { + this.read = read; + this.write = write; + } + swap() { + const t = this.read; + this.read = this.write; + this.write = t; + } +} diff --git a/src/engine/golden.ts b/src/engine/golden.ts new file mode 100644 index 0000000..b5bc3ff --- /dev/null +++ b/src/engine/golden.ts @@ -0,0 +1,25 @@ +// Hardcoded genome + params for the milestone-1 spike: the faithful v0 anchor +// docs/golden/Wild7-tweak3.json (a 244-float generalized rule, mutation_scale 0, wrap). +// Imported as JSON so the spike stays in sync with the golden file. + +import Wild7 from "../../docs/golden/Wild7-tweak3.json"; +import { toRule, type Rule } from "./rule"; +import type { SubstrateParams } from "./params"; + +export const WILD7_RULE: Rule = toRule(Wild7.rule); + +// The 10 v0 params, read straight from the desktop config. Per docs/v0_normalization.md §5 +// these carry the same meaning at ws=1; only the dropped √ws factors differ (a behavioral, +// not pixel-exact, anchor — see §0). +export const WILD7_PARAMS: SubstrateParams = { + sensorGain: Wild7.physics.sensor_gain, + sensorAngle: Wild7.physics.sensor_angle, + sensorDistance: Wild7.physics.sensor_distance, + globalForceMult: Wild7.physics.global_force_mult, + drag: Wild7.physics.drag, + strafePower: Wild7.physics.strafe_power, + axialForce: Wild7.physics.axial_force, + lateralForce: Wild7.physics.lateral_force, + trailPersistence: Wild7.physics.trail_persistence, + trailDiffusion: Wild7.physics.trail_diffusion, +}; diff --git a/src/engine/params.ts b/src/engine/params.ts new file mode 100644 index 0000000..2d9bd03 --- /dev/null +++ b/src/engine/params.ts @@ -0,0 +1,29 @@ +// The 10 v0 dynamics parameters (docs/v0_normalization.md §2). Plain serializable data — +// this is exactly the kind of state Svelte will own later; the engine only reads a snapshot. + +export interface SubstrateParams { + sensorGain: number; + sensorAngle: number; + sensorDistance: number; + globalForceMult: number; + drag: number; + strafePower: number; + axialForce: number; + lateralForce: number; + trailPersistence: number; + trailDiffusion: number; +} + +/** Order matters only for documentation; the engine reads by name. */ +export const V0_PARAM_KEYS: readonly (keyof SubstrateParams)[] = [ + "sensorGain", + "sensorAngle", + "sensorDistance", + "globalForceMult", + "drag", + "strafePower", + "axialForce", + "lateralForce", + "trailPersistence", + "trailDiffusion", +]; diff --git a/src/engine/rule.ts b/src/engine/rule.ts new file mode 100644 index 0000000..5637b03 --- /dev/null +++ b/src/engine/rule.ts @@ -0,0 +1,22 @@ +// The 244-float generalized plane-wave rule (docs/generalized_engine_spec.md). +// +// Flat layout (std430 GenCenter order), the same one the shader's uRule[61] expects: +// per center i (0..9), 24 floats at offset i*24: +// [ 0..15] freq[0..3] — four 4D frequency vectors, one per output channel +// [16..19] amp — per-channel amplitude +// [20..23] phase — per-channel phase +// then [240..243] dc — per-channel DC term +// As vec4[61]: center i -> indices i*6..i*6+5 = { freq0,freq1,freq2,freq3, amp, phase }; +// uRule[60] = dc. + +export const RULE_FLOATS = 244; + +/** A direct (uncompiled) genome: exactly 244 floats. */ +export type Rule = Float32Array; + +export function toRule(values: ArrayLike): Rule { + if (values.length !== RULE_FLOATS) { + throw new Error(`Rule must be ${RULE_FLOATS} floats, got ${values.length}.`); + } + return new Float32Array(values); +} diff --git a/src/engine/shaders/deposit.frag b/src/engine/shaders/deposit.frag new file mode 100644 index 0000000..565d7fc --- /dev/null +++ b/src/engine/shaders/deposit.frag @@ -0,0 +1,20 @@ +#version 300 es +precision highp float; + +// Additive Gaussian splat of the particle's velocity into the field (brush.frag). +// Blend is gl.ONE, gl.ONE; r is measured across the point sprite (gl_PointCoord). + +in vec2 vVel; +out vec4 outColor; + +uniform float uGaussNorm; // 1/(2πσ²) ≈ 5.99 +uniform float uSigma; // 0.163 +uniform float uDepositGain; // behavioral energy knob: scales field build-up (see §0) + +void main() { + vec2 d = gl_PointCoord - 0.5; + float r = length(d); + if (r > 0.5) discard; + float g = uGaussNorm * exp(-(r * r) / (2.0 * uSigma * uSigma)); + outColor = vec4(vVel, 0.01, 1.0) * g * uDepositGain; +} diff --git a/src/engine/shaders/deposit.vert b/src/engine/shaders/deposit.vert new file mode 100644 index 0000000..d3d6c87 --- /dev/null +++ b/src/engine/shaders/deposit.vert @@ -0,0 +1,18 @@ +#version 300 es +// The no-compute scatter: one gl.POINTS vertex per particle. The vertex fetches its own +// particle texel by gl_VertexID and positions a point splat at its world position. + +uniform sampler2D uParticles; // RGBA32F: pos.xy, vel.xy +uniform int uTexW; // particle texture width (for id -> texel) +uniform float uSplatPx; // point size in pixels + +out vec2 vVel; + +void main() { + int id = gl_VertexID; + ivec2 t = ivec2(id % uTexW, id / uTexW); + vec4 P = texelFetch(uParticles, t, 0); + gl_Position = vec4(P.xy, 0.0, 1.0); // pos already in clip space [-1,1] + vVel = P.zw; + gl_PointSize = uSplatPx; +} diff --git a/src/engine/shaders/display.frag b/src/engine/shaders/display.frag new file mode 100644 index 0000000..992b444 --- /dev/null +++ b/src/engine/shaders/display.frag @@ -0,0 +1,45 @@ +#version 300 es +precision highp float; + +// Field -> HSV: hue = flow direction, value = magnitude (soft-clamped by exposure). +// Flow-direction coloring (minisim style), not color-by-cohort. + +uniform sampler2D uField; +uniform vec2 uViewport; // canvas drawing-buffer size +uniform float uExposure; // value = 1 - exp(-|flow|·exposure) +uniform float uDebug; // 0 = normal HSV; 1 = diagnostic (NaN/zero/log-magnitude) + +out vec4 outColor; + +vec3 hsv2rgb(vec3 c) { + vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0); + return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y); +} + +void main() { + vec2 uv = gl_FragCoord.xy / uViewport; + vec2 flow = texture(uField, uv).xy; + float hue = atan(flow.y, flow.x) / 6.28318530718; // (-0.5, 0.5], cyclic + float mag = length(flow); + + if (uDebug > 0.5) { + // Diagnostic, GPU-side (no exposure/readback). A visible green↔magenta gradient is the + // background, so if you see the gradient the display pass IS reaching the screen and the + // field is simply dead/zero there; pure black instead means the pass isn't rendering. + vec3 bg = vec3(uv.x * 0.6, uv.y * 0.6, 0.25); + if (isinf(flow.x) || isinf(flow.y) || isnan(flow.x) || isnan(flow.y)) { + outColor = vec4(1.0, 0.0, 0.0, 1.0); // RED = NaN/Inf (field poisoned) + } else if (mag < 1e-12) { + outColor = vec4(bg, 1.0); // GRADIENT = zero field (pass works) + } else { + // log-magnitude so anything from ~1e-6 to ~10 is visible; hue still = direction + float lv = clamp((log(mag) + 14.0) / 16.0, 0.2, 1.0); + outColor = vec4(hsv2rgb(vec3(fract(hue), 0.85, lv)), 1.0); + } + return; + } + + float val = 1.0 - exp(-mag * uExposure); + vec3 rgb = hsv2rgb(vec3(fract(hue), 0.85, clamp(val, 0.0, 1.0))); + outColor = vec4(rgb, 1.0); +} diff --git a/src/engine/shaders/field_update.frag b/src/engine/shaders/field_update.frag new file mode 100644 index 0000000..7767e86 --- /dev/null +++ b/src/engine/shaders/field_update.frag @@ -0,0 +1,32 @@ +#version 300 es +precision highp float; + +// 5-tap diffuse + persist (canvas.frag / minisim._diffuse). The field texture uses REPEAT +// wrap so neighbour taps are toroidal. + +uniform sampler2D uField; // previous field +uniform sampler2D uDeposit; // this step's additive deposit +uniform vec2 uFieldRes; +uniform float uPersistence; // TRAIL_PERSISTENCE +uniform float uDiffusion; // TRAIL_DIFFUSION (slider), remapped to center weight K + +out vec4 outColor; + +void main() { + vec2 uv = gl_FragCoord.xy / uFieldRes; + vec2 px = 1.0 / uFieldRes; + + float s = clamp(uDiffusion, 0.001, 1.0); + float K = 4.0 / (pow(5.0, s * s) - 1.0); // slider -> center weight (K=1 at s=1, max blur) + + vec4 c = texture(uField, uv); + vec4 n = texture(uField, uv + vec2(0.0, px.y)); + vec4 so = texture(uField, uv - vec2(0.0, px.y)); + vec4 e = texture(uField, uv + vec2(px.x, 0.0)); + vec4 w = texture(uField, uv - vec2(px.x, 0.0)); + vec4 diffused = (c * K + n + so + e + w) / (4.0 + K); + + vec4 deposit = texture(uDeposit, uv); + float persist = clamp(uPersistence, 0.0, 0.999); + outColor = diffused * persist + (1.0 - persist) * deposit; +} diff --git a/src/engine/shaders/fullscreen.vert b/src/engine/shaders/fullscreen.vert new file mode 100644 index 0000000..d489ac5 --- /dev/null +++ b/src/engine/shaders/fullscreen.vert @@ -0,0 +1,7 @@ +#version 300 es +// Full-screen triangle from gl_VertexID — no vertex buffer needed. +// Drawn as gl.TRIANGLES, 3 vertices, with any VAO bound. +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} diff --git a/src/engine/shaders/particle_update.frag b/src/engine/shaders/particle_update.frag new file mode 100644 index 0000000..0a2e159 --- /dev/null +++ b/src/engine/shaders/particle_update.frag @@ -0,0 +1,108 @@ +#version 300 es +precision highp float; + +// One texel per particle: read (pos.xy, vel.xy), run one v0 substrate step, write the new +// state. See docs/v0_normalization.md §1 (world_size = 1, so every √ws factor is 1). + +uniform sampler2D uParticles; // RGBA32F: pos.xy, vel.xy +uniform sampler2D uField; // RGBA32F: flow.xy deposited by the trail +uniform vec2 uFieldRes; // field texture resolution, e.g. (1024, 1024) + +// The 244-float generalized plane-wave rule, packed as 61 vec4 (see rule.ts / golden.ts): +// centers[i] occupies uRule[i*6 .. i*6+5] = { freq[0..3], amp, phase }; uRule[60] = dc. +uniform vec4 uRule[61]; + +// The 10 v0 dynamics parameters. +uniform float uSensorGain; +uniform float uSensorAngle; +uniform float uSensorDistance; +uniform float uGlobalForceMult; +uniform float uDrag; +uniform float uStrafePower; +uniform float uAxialForce; +uniform float uLateralForce; + +out vec4 outColor; + +const float PI = 3.14159265358979; + +// F_k(x) = dc_k + Σ_i amp[i,k]·cos(⟨x, freq[i,k]⟩ + phase[i,k]) (4D -> 4D) +vec4 planeWave(vec4 x) { + vec4 r = uRule[60]; // dc + for (int i = 0; i < 10; i++) { + int b = i * 6; + vec4 amp = uRule[b + 4]; + vec4 phase = uRule[b + 5]; + for (int k = 0; k < 4; k++) { + r[k] += amp[k] * cos(dot(x, uRule[b + k]) + phase[k]); + } + } + return r; +} + +// Bilinear sample with wrap, matching minisim._bilinear_wrap. uv in [0,1] field space; +// fract() wraps the toroidal field, integer neighbours wrapped by modulo. +vec4 bilinearWrap(sampler2D tex, vec2 uv, vec2 res) { + vec2 st = fract(uv) * res - 0.5; + vec2 fl = floor(st); + vec2 f = st - fl; + ivec2 ires = ivec2(res); + ivec2 a = ((ivec2(fl) % ires) + ires) % ires; + ivec2 b = (((ivec2(fl) + 1) % ires) + ires) % ires; + vec4 f00 = texelFetch(tex, ivec2(a.x, a.y), 0); + vec4 f10 = texelFetch(tex, ivec2(b.x, a.y), 0); + vec4 f01 = texelFetch(tex, ivec2(a.x, b.y), 0); + vec4 f11 = texelFetch(tex, ivec2(b.x, b.y), 0); + return mix(mix(f00, f10, f.x), mix(f01, f11, f.x), f.y); +} + +void main() { + ivec2 t = ivec2(gl_FragCoord.xy); + vec4 P = texelFetch(uParticles, t, 0); + vec2 pos = P.xy; + vec2 vel = P.zw; + + // body frame (absolute_orientation = Off in v0). A stalled particle gets forward = 0 + // (like minisim): zero heading -> zero sensor projection -> zero force, so it stays put + // instead of being kicked along a fake (1,0) heading. + float speed = length(vel); + vec2 forward = speed > 1e-12 ? vel / speed : vec2(0.0); + vec2 left = vec2(forward.y, -forward.x); + + // two sensors, rotated ±angle·π, at distance 0.005·SENSOR_DISTANCE + float a = uSensorAngle * PI; + float ca = cos(a), sa = sin(a); + vec2 dirL = vec2(forward.x * ca - forward.y * sa, forward.x * sa + forward.y * ca); + vec2 dirR = vec2(forward.x * ca + forward.y * sa, -forward.x * sa + forward.y * ca); + float sd = 0.005 * uSensorDistance; + vec2 uvL = (pos + dirL * sd) * 0.5 + 0.5; + vec2 uvR = (pos + dirR * sd) * 0.5 + 0.5; + + float scl = 38.855 * uSensorGain; + vec2 sL = bilinearWrap(uField, uvL, uFieldRes).xy * scl; + vec2 sR = bilinearWrap(uField, uvR, uFieldRes).xy * scl; + + // project sensors into the body frame + vec2 L = vec2(dot(sL, forward), dot(sL, left)); + vec2 R = vec2(dot(sR, forward), dot(sR, left)); + + // symmetrized rule eval: base + y_reflect(mirror), y_reflect:(a,b)->(a,-b) + vec4 base = planeWave(vec4(L, R)); + vec4 mir = planeWave(vec4(R.x, -R.y, L.x, -L.y)); + vec2 forceB = vec2(base.x + mir.x, base.y - mir.y); + vec2 strafeB = vec2(base.z + mir.z, base.w - mir.w); + + // back to world, gated by axial/lateral + vec2 force = forward * forceB.x * uAxialForce + left * forceB.y * uLateralForce; + vec2 strafe = forward * strafeB.x * uAxialForce + left * strafeB.y * uLateralForce; + force *= uGlobalForceMult / 400.0; + strafe *= uGlobalForceMult / 20.0; + + // integrate: force is 2nd-order (into velocity), strafe is 1st-order (into position) + vel = vel * uDrag + force; + pos += vel; + pos += strafe * uStrafePower; + pos = mod(pos + 1.0, 2.0) - 1.0; // v0 boundary = wrap, into [-1, 1] + + outColor = vec4(pos, vel); +} diff --git a/src/state/inspector.svelte.ts b/src/state/inspector.svelte.ts new file mode 100644 index 0000000..02b3b1b --- /dev/null +++ b/src/state/inspector.svelte.ts @@ -0,0 +1,13 @@ +// The live-view reactive state: the 10 v0 substrate params plus the spike's display knobs. +// This is the ONLY reactive layer — plain serializable data (never GL objects), which the +// orchestrator snapshots into the imperative Engine. Seeded from the Wild7 anchor. + +import { WILD7_PARAMS } from "../engine/golden"; + +export const inspector = $state({ + params: { ...WILD7_PARAMS }, + depositGain: 30, // behavioral energy knob (see docs §0) + exposure: 200, // HSV brightness scale (no readback auto-exposure: Firefox blocks it) + debug: false, // diagnostic display (red=NaN, gradient=zero, log-magnitude) + restartNonce: 0, // bump to re-seed + clear the sim +}); diff --git a/src/ui/Controls.svelte b/src/ui/Controls.svelte new file mode 100644 index 0000000..d434ff1 --- /dev/null +++ b/src/ui/Controls.svelte @@ -0,0 +1,97 @@ + + +
+
Wild7 — v0 substrate
+ + + + {#each PARAM_SLIDERS as s (s.key)} + + {/each} + +
+ + + + +
+ + +
+
+ +