diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ddc5328..7ced16d 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -10,7 +10,7 @@ path = "bin/main.rs" [dependencies] axum = "0.8.7" tokio = { version = "1.48.0", features = ["full"] } -tower-http = { version = "0.6.6", features = ["fs", "cors", "limit"] } +tower-http = { version = "0.6.6", features = ["fs", "cors", "limit", "set-header"] } serde_json = "1.0.145" message-tools = { path = "../message-tools" } diff --git a/crates/server/bin/main.rs b/crates/server/bin/main.rs index a9fa515..e9be3e0 100644 --- a/crates/server/bin/main.rs +++ b/crates/server/bin/main.rs @@ -1,6 +1,6 @@ use axum::{ Json, Router, - http::{Method, StatusCode}, + http::{HeaderValue, Method, StatusCode, header}, response::Html, routing::{get, post}, }; @@ -13,7 +13,10 @@ use std::{ time::Duration, }; use tokio::io::AsyncWriteExt; -use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, services::ServeDir}; +use tower_http::{ + cors::CorsLayer, limit::RequestBodyLimitLayer, services::ServeDir, + set_header::SetResponseHeaderLayer, +}; const PUBKEY_PATH: &str = "./pubkeys/public_keys.json"; const MESSAGE_STORAGE: &str = "./messages"; @@ -76,18 +79,51 @@ async fn main() -> Result<()> { .allow_methods([Method::GET, Method::POST]) .allow_headers([axum::http::header::CONTENT_TYPE]); + // Cache static assets so navigating between pages reuses the streamed field + // instead of re-downloading it. Scoped to /routes only; HTML routes stay fresh. + let routes_static = Router::new() + .fallback_service(ServeDir::new("./routes")) + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=86400"), + )); + let app = Router::new() .route("/", get(serve_path("./routes/root/index.html")?)) - .route("/who", get(serve_path("./routes/who/index.html")?)) + .route("/about", get(serve_path("./routes/about/index.html")?)) + .route( + "/projects", + get(serve_path("./routes/projects/index.html")?), + ) + .route( + "/projects/swactor", + get(serve_path("./routes/projects/swactor/index.html")?), + ) + .route( + "/projects/airfrans-neural-cfd-surrogate", + get(serve_path( + "./routes/projects/airfrans-neural-cfd-surrogate/index.html", + )?), + ) + .route( + "/projects/cstat-yoke", + get(serve_path("./routes/projects/cstat-yoke/index.html")?), + ) .route("/api/pubkey", get(async || select_key(&PUBLIC_KEYS))) .route("/contact", get(serve_path("./routes/contact/index.html")?)) - .route("/contact/message", get(serve_path("./routes/contact/message/index.html")?)) + .route( + "/contact/message", + get(serve_path("./routes/contact/message/index.html")?), + ) .route( "/api/publish", post(publish_message).layer(RequestBodyLimitLayer::new(64 * 1024)), ) - .nest_service("/gossip-dashboard", ServeDir::new("./routes/gossip-dashboard")) - .nest_service("/routes", ServeDir::new("./routes")) + .nest_service( + "/gossip-dashboard", + ServeDir::new("./routes/gossip-dashboard"), + ) + .nest("/routes", routes_static) .layer(cors); let addr = "0.0.0.0:3000"; @@ -123,9 +159,7 @@ async fn publish_message(Json(msg): Json) -> (StatusCode, Json= MAX_PUBLISHES_PER_MINUTE { return ( StatusCode::TOO_MANY_REQUESTS, - Json(UserCreated { - tag: String::new(), - }), + Json(UserCreated { tag: String::new() }), ); } limiter.1 += 1; @@ -141,11 +175,7 @@ async fn publish_message(Json(msg): Json) -> (StatusCode, Json + + + + + About + + + + + + + +
+
+

Software engineer cum neural network wrangler.

+

Technical interests include systems engineering, distributed compute, physical simulation, and applied machine learning infrastructure.

+ +

Projects

+
    +
  • + swactor + Toolkit for orchestrating somewhat-unreliable heterogeneous GPU nodes over WAN links. +
  • +
  • + AirfRANS / neural CFD surrogate + Machine learning as simulation: neural CFD surrogates, model scaling, and solver-grounded validation. +
  • +
  • + cstat and yoke + Code as data: statistical structure, LLM coding behavior, and agent harnesses. +
  • +
+ +

If any of these interest you, please contact me. I love to talk shop.

+
+ + +
+ + + + diff --git a/routes/projects/airfrans-neural-cfd-surrogate/index.html b/routes/projects/airfrans-neural-cfd-surrogate/index.html new file mode 100644 index 0000000..bcc02d0 --- /dev/null +++ b/routes/projects/airfrans-neural-cfd-surrogate/index.html @@ -0,0 +1,42 @@ + + + + + + AirfRANS / neural CFD surrogate + + + + + + +
+
+

AirfRANS / neural CFD surrogate

+

This fake writeup pretends the goal is simple: make a neural surrogate that behaves enough like a CFD solver to be useful, but not so much like a CFD solver that it takes all afternoon to answer.

+

The model gets geometry, flow conditions, and a stern lecture from validation metrics. It returns fields, uncertainty hints, and the occasional reminder that conservation laws are not optional.

+

The interesting part is the boundary between learned approximation and solver-grounded truth: where the network is fast, where it is wrong, and how to know before a wing falls off in a slide deck.

+
+ +
+ + diff --git a/routes/projects/cstat-yoke/index.html b/routes/projects/cstat-yoke/index.html new file mode 100644 index 0000000..3acb77f --- /dev/null +++ b/routes/projects/cstat-yoke/index.html @@ -0,0 +1,36 @@ + + + + + + cstat and yoke + + + + + + +
+
+

cstat and yoke

+

cstat is the fake microscope; yoke is the fake harness. Together they treat code as data and ask what shape a codebase has before anyone starts arguing about taste.

+

The writeup claims cstat measures local structure, repetition, churn-shaped scars, and the weird fingerprints left by agents that are too confident near edge cases.

+

yoke then turns those observations into experiments: give an agent a task, constrain the harness, watch the trace, and learn whether the model fixed the problem or merely rearranged the furniture.

+
+ +
+ + diff --git a/routes/projects/index.html b/routes/projects/index.html new file mode 100644 index 0000000..60494a1 --- /dev/null +++ b/routes/projects/index.html @@ -0,0 +1,156 @@ + + + + + + Projects + + + + + + + +
+
+

Projects

+
+
+

swactor

+

Toolkit for orchestrating somewhat-unreliable heterogeneous GPU nodes over WAN links.

+
+ + + +
+

cstat and yoke

+

Code as data: statistical structure, LLM coding behavior, and agent harnesses.

+
+
+
+ + +
+ + + + diff --git a/routes/projects/swactor/index.html b/routes/projects/swactor/index.html new file mode 100644 index 0000000..dd0233e --- /dev/null +++ b/routes/projects/swactor/index.html @@ -0,0 +1,68 @@ + + + + + + swactor + + + + + + +
+
+

swactor

+

swactor is the imaginary control plane I would build for a room full of moody GPUs scattered across apartments, closets, and spare cloud instances.

+

The fake writeup version starts with a tiny actor runtime, gives each node a heartbeat, then treats dropped packets and flaky thermal envelopes as normal weather instead of exceptional tragedy.

+

The punchline: jobs migrate toward available memory, checkpoints move over boring protocols, and the scheduler learns which machines are liars before the humans do.

+
+ +
+ + diff --git a/routes/root/dist-strips.bin b/routes/root/dist-strips.bin new file mode 100644 index 0000000..73d0fd7 Binary files /dev/null and b/routes/root/dist-strips.bin differ diff --git a/routes/root/fractal-gl.js b/routes/root/fractal-gl.js index 4ad4e89..7024c26 100644 --- a/routes/root/fractal-gl.js +++ b/routes/root/fractal-gl.js @@ -1,5 +1,20 @@ -// Julia distance-isolines background. WebGL preferred, Canvas2D fallback. -// Palette: BSOD-blue, smooth cosine bands, 1024-entry LUT for finer transitions. +// Julia distance-isolines background. +// Renderer: WebGL (default) -> Canvas2D -> flat dark backdrop. +// +// The 512x512 distance field is precomputed (live per-pixel iteration is too slow). +// It ships as `dist-strips.bin`: full-resolution horizontal bands, each WebP-compressed, +// concatenated in CENTER-OUT order. We stream the file and upload each band to its texture +// sub-rectangle as its bytes arrive. +// +// The DISPLAY FOLLOWS THE STREAM: instead of drawing the whole screen over a half-loaded +// texture (which looked like a "glow"), the shader only reveals the field rows that have +// actually streamed in, blooming outward from the center over a flat dark backdrop. The +// revealed extent eases smoothly, so it reads as an intentional reveal, not jank. +// Palette: BSOD-blue, smooth cosine bands, 1024-entry LUT (Canvas2D path only). + +var FIELD_URL = '/routes/root/dist-strips.bin?v=1'; // streamed WebP bands (bump ?v on regen) +var DIST_URL = '/routes/root/dist.png'; // monolithic source (Canvas2D fallback) +var DARK_HEX = '#060a10'; // backdrop / unloaded tone function initFractal(canvasId) { console.log('[fractal-gl] initFractal:', canvasId); @@ -10,9 +25,8 @@ function initFractal(canvasId) { return; } - // Visible base color even if both render paths fail. - document.body.style.background = - 'radial-gradient(ellipse at center, #142566 0%, #0a103a 60%, #060a10 100%)'; + // Flat dark backdrop: clean start state, and the tone the reveal blooms over. + document.body.style.background = DARK_HEX; var gl = null; try { @@ -30,6 +44,91 @@ function initFractal(canvasId) { } } +// ---------------------------------------------------------------- +// Shared streamed-field loader +// Emits each band as a decoded ImageBitmap the moment its bytes land. +// cb = { onHeader(header), onStrip(bitmap, y, h), onError(err) } +// File: u32(LE) headerLen | JSON header | concatenated band WebPs. +// JSON: { fullW, fullH, bands:[{y,h,off,len}] } in stream (center-out) order. +// ---------------------------------------------------------------- +function streamFieldBands(cb) { + var TD = new TextDecoder(); + + function emitBand(bytes, b) { + // bytes is an independent copy, safe to keep past the next read. + // Disable color management so the grayscale field bytes survive intact. + createImageBitmap(new Blob([bytes], { type: 'image/webp' }), + { colorSpaceConversion: 'none', premultiplyAlpha: 'none' }) + .then(function (bmp) { + try { cb.onStrip(bmp, b.y, b.h); } + catch (e) { console.warn('[fractal-gl] band upload failed', e); } + }) + .catch(function (e) { console.warn('[fractal-gl] band decode failed', e); }); + } + + function emitAll(buf) { + var hlen = new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0, true); + var header = JSON.parse(TD.decode(buf.subarray(4, 4 + hlen))); + var ps = 4 + hlen; + cb.onHeader(header); + header.bands.forEach(function (b) { + emitBand(buf.subarray(ps + b.off, ps + b.off + b.len).slice(), b); + }); + } + + fetch(FIELD_URL).then(function (res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + if (!res.body || !res.body.getReader) { + return res.arrayBuffer().then(function (ab) { emitAll(new Uint8Array(ab)); }); + } + + var reader = res.body.getReader(); + var chunks = []; + var total = 0; + var header = null; + var ps = 0; + var next = 0; + + function assemble() { + var out = new Uint8Array(total); + var o = 0; + for (var i = 0; i < chunks.length; i++) { out.set(chunks[i], o); o += chunks[i].length; } + return out; + } + + function step() { + return reader.read().then(function (r) { + if (r.value) { chunks.push(r.value); total += r.value.length; } + var buf = assemble(); + + if (!header && total >= 4) { + var hlen = new DataView(buf.buffer, 0, 4).getUint32(0, true); + if (total >= 4 + hlen) { + header = JSON.parse(TD.decode(buf.subarray(4, 4 + hlen))); + ps = 4 + hlen; + cb.onHeader(header); + } + } + if (header) { + while (next < header.bands.length) { + var b = header.bands[next]; + var end = ps + b.off + b.len; + if (total < end) break; + emitBand(buf.subarray(ps + b.off, end).slice(), b); + next++; + } + } + if (r.done) return; + return step(); + }); + } + return step(); + }).catch(function (e) { + console.error('[fractal-gl] field stream failed', e); + if (cb.onError) cb.onError(e); + }); +} + function _hslToRgb(h, s, l) { var a = s * Math.min(l, 1 - l); function f(n) { @@ -41,7 +140,6 @@ function _hslToRgb(h, s, l) { // 1024-entry palette: 4x finer than the iteration-count space (0..255), // which kills visible quantization banding in the band peaks/troughs. -// Cosine band (no abs/pow) → smooth bands; hue pinned ~true blue. var PALETTE_SIZE = 1024; function _buildPalette32() { var p32 = new Uint32Array(PALETTE_SIZE); @@ -58,59 +156,68 @@ function _buildPalette32() { } // ---------------------------------------------------------------- -// WebGL path +// WebGL path (default) — streams bands in, reveals only what has loaded // ---------------------------------------------------------------- +var FIELD_VERT_GLSL = [ + 'attribute vec2 a_pos;', + 'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }' +].join('\n'); + +// Same field-sampling math as before, plus a feathered reveal mask gated on the +// loaded field-row extent [u_loadV0, u_loadV1]. Unloaded rows show the dark backdrop. +var FIELD_FRAG_GLSL = [ + 'precision highp float;', + 'uniform float u_time;', + 'uniform vec2 u_resolution;', + 'uniform sampler2D u_dist;', + 'uniform float u_loadV0;', + 'uniform float u_loadV1;', + 'const vec3 DARK = vec3(0.024, 0.039, 0.063);', + 'vec3 hslToRgb(float h, float s, float l) {', + ' float a = s * min(l, 1.0 - l);', + ' vec3 k = mod(vec3(0.0, 8.0, 4.0) + h * 12.0, 12.0);', + ' vec3 v = max(min(min(k - 3.0, 9.0 - k), vec3(1.0)), vec3(-1.0));', + ' return vec3(l) - a * v;', + '}', + 'vec3 calmBand(float idx) {', + ' float f = mod(idx, 256.0);', + ' float band = 0.5 + 0.5 * cos(f * 6.28318530 / 56.0);', + ' float h = 0.665 + 0.015 * sin(f * 3.14159265 / 128.0);', + ' float s = 0.80;', + ' float l = 0.18 + 0.28 * band;', + ' return hslToRgb(h, s, l);', + '}', + 'void main() {', + ' vec2 uv = gl_FragCoord.xy / u_resolution;', + ' float aspect = u_resolution.x / u_resolution.y;', + ' float t = u_time * 0.12;', + ' float maxH = 1.0 / max(aspect, 1.0);', + ' float zoomFrac = 0.65 + 0.15 * sin(t * 0.4);', + ' float halfH = maxH * zoomFrac * 0.5;', + ' float halfW = halfH * aspect;', + ' float fx = 0.5 - halfW;', + ' float fy = 0.5 - halfH;', + ' float cx = 0.5 + fx * 0.5 * cos(t * 0.55);', + ' float cy = 0.5 + fy * 0.5 * sin(t * 0.45);', + ' float sx = cx + (uv.x - 0.5) * 2.0 * halfW;', + ' float sy = cy + (uv.y - 0.5) * 2.0 * halfH;', + ' float fv = 1.0 - sy;', // field v-coordinate (texture uploaded natural orientation) + ' // Reveal only loaded field rows; nothing loaded yet => all dark.', + ' if (u_loadV1 - u_loadV0 < 0.001) { gl_FragColor = vec4(DARK, 1.0); return; }', + ' float E = 0.03;', + ' float m = smoothstep(u_loadV0 - E, u_loadV0 + E, fv) *', + ' (1.0 - smoothstep(u_loadV1 - E, u_loadV1 + E, fv));', + ' float v = texture2D(u_dist, vec2(sx, fv)).r * 255.0;', + ' float ditherMix = clamp((v - 180.0) / 60.0, 0.0, 1.0);', + ' float noise = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;', + ' v += noise * 1.2 * ditherMix;', + ' float offset = u_time * 10.8;', + ' vec3 color = calmBand(v - offset);', + ' gl_FragColor = vec4(mix(DARK, color, m), 1.0);', + '}' +].join('\n'); + function initWebGL(canvas, gl) { - var VERT_SRC = [ - 'attribute vec2 a_pos;', - 'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }' - ].join('\n'); - - var FRAG_SRC = [ - 'precision highp float;', - 'uniform float u_time;', - 'uniform vec2 u_resolution;', - 'uniform sampler2D u_dist;', - 'vec3 hslToRgb(float h, float s, float l) {', - ' float a = s * min(l, 1.0 - l);', - ' vec3 k = mod(vec3(0.0, 8.0, 4.0) + h * 12.0, 12.0);', - ' vec3 v = max(min(min(k - 3.0, 9.0 - k), vec3(1.0)), vec3(-1.0));', - ' return vec3(l) - a * v;', - '}', - 'vec3 calmBand(float idx) {', - ' float f = mod(idx, 256.0);', - ' float band = 0.5 + 0.5 * cos(f * 6.28318530 / 56.0);', - ' float h = 0.665 + 0.015 * sin(f * 3.14159265 / 128.0);', - ' float s = 0.80;', - ' float l = 0.18 + 0.28 * band;', - ' return hslToRgb(h, s, l);', - '}', - 'void main() {', - ' vec2 uv = gl_FragCoord.xy / u_resolution;', - ' float aspect = u_resolution.x / u_resolution.y;', - ' float t = u_time * 0.12;', - ' float maxH = 1.0 / max(aspect, 1.0);', - ' float zoomFrac = 0.65 + 0.15 * sin(t * 0.4);', - ' float halfH = maxH * zoomFrac * 0.5;', - ' float halfW = halfH * aspect;', - ' float fx = 0.5 - halfW;', - ' float fy = 0.5 - halfH;', - ' float cx = 0.5 + fx * 0.5 * cos(t * 0.55);', - ' float cy = 0.5 + fy * 0.5 * sin(t * 0.45);', - ' float sx = cx + (uv.x - 0.5) * 2.0 * halfW;', - ' float sy = cy + (uv.y - 0.5) * 2.0 * halfH;', - ' float v = texture2D(u_dist, vec2(sx, sy)).r * 255.0;', - ' // Dither only in the outer smooth region (v in [180, 240] ramps in;', - ' // inner fractal detail stays crisp).', - ' float ditherMix = clamp((v - 180.0) / 60.0, 0.0, 1.0);', - ' float noise = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;', - ' v += noise * 1.2 * ditherMix;', - ' float offset = u_time * 10.8;', - ' vec3 color = calmBand(v - offset);', - ' gl_FragColor = vec4(color, 1.0);', - '}' - ].join('\n'); - function compile(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); @@ -122,8 +229,8 @@ function initWebGL(canvas, gl) { return s; } - var vs = compile(gl.VERTEX_SHADER, VERT_SRC); - var fs = compile(gl.FRAGMENT_SHADER, FRAG_SRC); + var vs = compile(gl.VERTEX_SHADER, FIELD_VERT_GLSL); + var fs = compile(gl.FRAGMENT_SHADER, FIELD_FRAG_GLSL); if (!vs || !fs) return initCanvas2D(canvas); var prog = gl.createProgram(); @@ -146,6 +253,8 @@ function initWebGL(canvas, gl) { var uTime = gl.getUniformLocation(prog, 'u_time'); var uRes = gl.getUniformLocation(prog, 'u_resolution'); var uDist = gl.getUniformLocation(prog, 'u_dist'); + var uV0 = gl.getUniformLocation(prog, 'u_loadV0'); + var uV1 = gl.getUniformLocation(prog, 'u_loadV1'); var tex = gl.createTexture(); gl.activeTexture(gl.TEXTURE0); @@ -159,25 +268,69 @@ function initWebGL(canvas, gl) { gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); gl.uniform1i(uDist, 0); - var img = new Image(); - img.onload = function () { - var w = img.width, h = img.height; - console.log('[fractal-gl] PNG decoded:', w, 'x', h); - var off = document.createElement('canvas'); - off.width = w; off.height = h; - var octx = off.getContext('2d'); - octx.drawImage(img, 0, 0); - var data = octx.getImageData(0, 0, w, h).data; - var bytes = new Uint8Array(data.length); - bytes.set(data); - gl.bindTexture(gl.TEXTURE_2D, tex); - gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); - gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); - gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, - gl.RGBA, gl.UNSIGNED_BYTE, bytes); - }; - img.onerror = function (e) { console.error('[fractal-gl] dist.png load failed', e); }; - img.src = '/routes/root/dist.png'; + // Drawing the band bitmap to a 2D canvas yields a universally-supported + // TexImageSource (works on WebGL1 and WebGL2 alike). + var scratch = document.createElement('canvas'); + var sctx = scratch.getContext('2d'); + + // ---- reveal state: which field rows have streamed in ---- + var totalH = 1; + var spatial = []; // bands sorted top->bottom: {y, h} + var loadedSlots = []; // parallel booleans + var centerSlot = 0, lastSlot = 0; + var targetV0 = 0.5, targetV1 = 0.5; // loaded extent (v-fraction), grows from center + var dispV0 = 0.5, dispV1 = 0.5; // eased toward target for a smooth bloom + + // Bloom only on the first page of a tab session; on later navigations the field + // is cached, so snap straight to it instead of replaying the ~1s intro. + var revealed = false; + try { revealed = sessionStorage.getItem('fractalRevealed') === '1'; } catch (e) {} + + streamFieldBands({ + onHeader: function (header) { + var w = header.fullW, h = header.fullH; + totalH = h; + spatial = header.bands.slice().sort(function (a, b) { return a.y - b.y; }); + loadedSlots = spatial.map(function () { return false; }); + lastSlot = spatial.length - 1; + centerSlot = 0; + for (var i = 0; i < spatial.length; i++) { + if (h / 2 >= spatial[i].y && h / 2 < spatial[i].y + spatial[i].h) { centerSlot = i; break; } + } + // Allocate the full texture (black; masked until rows load). + var fill = new Uint8Array(w * h * 4); + for (var k = 3; k < fill.length; k += 4) fill[k] = 255; + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, fill); + }, + onStrip: function (bitmap, y, h) { + scratch.width = bitmap.width; + scratch.height = bitmap.height; + sctx.drawImage(bitmap, 0, 0); + gl.bindTexture(gl.TEXTURE_2D, tex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, y, gl.RGBA, gl.UNSIGNED_BYTE, scratch); + + var s = -1; + for (var i = 0; i < spatial.length; i++) { if (spatial[i].y === y) { s = i; break; } } + if (s < 0) return; + loadedSlots[s] = true; + // Grow the contiguous loaded run around the center (guards against a late + // middle band briefly revealing an unloaded gap). + if (loadedSlots[centerSlot]) { + var lo = centerSlot; while (lo - 1 >= 0 && loadedSlots[lo - 1]) lo--; + var hi = centerSlot; while (hi + 1 < spatial.length && loadedSlots[hi + 1]) hi++; + // Push the extent past the field edge once the outermost band is in, so the + // feather never darkens the true field edge in the steady state. + targetV0 = (lo === 0) ? -1.0 : spatial[lo].y / totalH; + targetV1 = (hi === lastSlot) ? 2.0 : (spatial[hi].y + spatial[hi].h) / totalH; + if (lo === 0 && hi === lastSlot) { + try { sessionStorage.setItem('fractalRevealed', '1'); } catch (e) {} + } + } + } + }); function resize() { canvas.width = window.innerWidth; @@ -190,8 +343,15 @@ function initWebGL(canvas, gl) { var startTime = performance.now(); function frame() { var t = (performance.now() - startTime) * 0.001; + // Ease the displayed extent toward the loaded extent for a smooth bloom. + // On a repeat visit (cached field) snap instead of replaying the intro. + var ease = revealed ? 1.0 : 0.12; + dispV0 += (targetV0 - dispV0) * ease; + dispV1 += (targetV1 - dispV1) * ease; gl.uniform1f(uTime, t); gl.uniform2f(uRes, canvas.width, canvas.height); + gl.uniform1f(uV0, dispV0); + gl.uniform1f(uV1, dispV1); gl.drawArrays(gl.TRIANGLES, 0, 3); requestAnimationFrame(frame); } @@ -200,15 +360,16 @@ function initWebGL(canvas, gl) { } // ---------------------------------------------------------------- -// Canvas2D fallback +// Canvas2D fallback (rare; ancient / no-GPU browsers) // - 1024-entry palette (4x finer than the byte-quantized iteration value) // - bilinear sample of source buffer (no nearest-neighbor jaggies) // - Uint32 writes (one packed write per pixel instead of four byte writes) +// Loads the monolithic dist.png and renders once decoded (fades in). // ---------------------------------------------------------------- function initCanvas2D(canvas) { var ctx2d = canvas.getContext('2d'); if (!ctx2d) { - console.error('[fractal-gl] Canvas2D also unavailable; body gradient remains'); + console.error('[fractal-gl] Canvas2D also unavailable; body backdrop remains'); return; } var palette32 = _buildPalette32(); @@ -226,6 +387,10 @@ function initCanvas2D(canvas) { var gray = new Uint8Array(SW * SH); for (var i = 0, j = 0; i < gray.length; i++, j += 4) gray[i] = data[j]; + // Fade the canvas in once the first frame is ready. + canvas.style.transition = 'opacity 400ms ease'; + canvas.style.opacity = '0'; + // Higher internal resolution = sharper. Capped to keep frame budget reasonable. function targetSize() { var h = Math.min(800, Math.max(360, Math.floor(window.innerHeight * 0.7))); @@ -244,6 +409,7 @@ function initCanvas2D(canvas) { var out32 = new Uint32Array(imgOut.data.buffer); var lastW = canvas.width, lastH = canvas.height; var startTime = performance.now(); + var faded = false; // 4x4 Bayer matrix, recentered to ±0.6 source units. Only applied where // v is in the outer-smooth band (ramp 180→240) so inner detail stays crisp. @@ -314,13 +480,14 @@ function initCanvas2D(canvas) { sy += dys; } ctx2d.putImageData(imgOut, 0, 0); + if (!faded) { faded = true; canvas.style.opacity = '1'; } requestAnimationFrame(frame); } requestAnimationFrame(frame); console.log('[fractal-gl] Canvas2D loop started; internal', canvas.width, 'x', canvas.height); }; - img.src = '/routes/root/dist.png'; + img.src = DIST_URL; } if (document.readyState === 'loading') { diff --git a/routes/root/index.html b/routes/root/index.html index 205e8a8..9bb90bd 100755 --- a/routes/root/index.html +++ b/routes/root/index.html @@ -100,6 +100,8 @@ } } + + @@ -112,23 +114,15 @@ - + + diff --git a/routes/who/index.html b/routes/who/index.html deleted file mode 100755 index 8d69975..0000000 --- a/routes/who/index.html +++ /dev/null @@ -1,122 +0,0 @@ - - - - - - Who - - - - - -
-
-

Who

-

No one in particular :)

-

I'll write more later...

-
- - -
- - - -