feat: webgl fractal refactor, page updates

Rework routes/root/fractal-gl.js for animated WebGL fractal rendering
and refresh contact, who, root, and message page markup. Add new
root/dist.png asset.

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-05-21 15:13:36 +04:00
parent d10f539e06
commit 40f2541cfb
6 changed files with 309 additions and 99 deletions

View file

@ -20,6 +20,7 @@
} }
#fractal-canvas { #fractal-canvas {
image-rendering: auto;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -36,7 +37,6 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeInUp 800ms ease 300ms both;
} }
.glass-card { .glass-card {
@ -135,7 +135,15 @@
</nav> </nav>
</div> </div>
<script src="/routes/root/fractal-gl.js"></script> <script>
<script>initFractal('fractal-canvas');</script> requestAnimationFrame(function () {
requestAnimationFrame(function () {
var s = document.createElement('script');
s.src = '/routes/root/fractal-gl.js?v=9';
s.async = true;
document.body.appendChild(s);
});
});
</script>
</body> </body>
</html> </html>

View file

@ -23,6 +23,7 @@
} }
#fractal-canvas { #fractal-canvas {
image-rendering: auto;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -45,14 +46,12 @@
max-width: 560px; max-width: 560px;
min-height: 400px; min-height: 400px;
margin-bottom: 20px; margin-bottom: 20px;
animation: fadeInUp 800ms ease 300ms both;
} }
.back-link { .back-link {
position: relative; position: relative;
z-index: 1; z-index: 1;
margin-top: 0.5rem; margin-top: 0.5rem;
animation: fadeInUp 800ms ease 500ms both;
} }
.back-link a { .back-link a {
@ -324,7 +323,15 @@
<a href="/contact">back to contact</a> <a href="/contact">back to contact</a>
</nav> </nav>
<script src="/routes/root/fractal-gl.js"></script> <script>
<script>initFractal('fractal-canvas');</script> requestAnimationFrame(function () {
requestAnimationFrame(function () {
var s = document.createElement('script');
s.src = '/routes/root/fractal-gl.js?v=9';
s.async = true;
document.body.appendChild(s);
});
});
</script>
</body> </body>
</html> </html>

BIN
routes/root/dist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View file

@ -1,20 +1,66 @@
// WebGL Newton fractal renderer (z^3 - 1, ocean palette) // Julia distance-isolines background. WebGL preferred, Canvas2D fallback.
// Ported from crates/fractal-engine/src/{newton.rs, color.rs} // Palette: BSOD-blue, smooth cosine bands, 1024-entry LUT for finer transitions.
function initFractal(canvasId) { function initFractal(canvasId) {
var canvas = document.getElementById(canvasId); console.log('[fractal-gl] initFractal:', canvasId);
if (!canvas) return;
var gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); var canvas = document.getElementById(canvasId);
if (!gl) { if (!canvas) {
// Fallback: ocean-themed CSS gradient console.warn('[fractal-gl] canvas not found:', canvasId);
canvas.style.background =
'radial-gradient(ellipse at center, #0d3842 0%, #0c2e58 40%, #060a10 100%)';
return; return;
} }
// --- Shaders --- // Visible base color even if both render paths fail.
document.body.style.background =
'radial-gradient(ellipse at center, #142566 0%, #0a103a 60%, #060a10 100%)';
var gl = null;
try {
gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
} catch (e) {
console.warn('[fractal-gl] WebGL exception:', e);
}
if (gl) {
console.log('[fractal-gl] using WebGL path');
initWebGL(canvas, gl);
} else {
console.warn('[fractal-gl] WebGL unavailable; using Canvas2D fallback');
initCanvas2D(canvas);
}
}
function _hslToRgb(h, s, l) {
var a = s * Math.min(l, 1 - l);
function f(n) {
var k = (n + h * 12) % 12;
return Math.round((l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))) * 255);
}
return [f(0), f(8), f(4)];
}
// 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);
for (var i = 0; i < PALETTE_SIZE; i++) {
var f = i / 4; // logical index in [0, 256)
var band = 0.5 + 0.5 * Math.cos(f * 2 * Math.PI / 56);
var h = 0.665 + 0.015 * Math.sin(f * Math.PI / 128);
var s = 0.80;
var l = 0.18 + 0.28 * band;
var rgb = _hslToRgb(h, s, l);
p32[i] = (255 << 24) | (rgb[2] << 16) | (rgb[1] << 8) | rgb[0];
}
return p32;
}
// ----------------------------------------------------------------
// WebGL path
// ----------------------------------------------------------------
function initWebGL(canvas, gl) {
var VERT_SRC = [ var VERT_SRC = [
'attribute vec2 a_pos;', 'attribute vec2 a_pos;',
'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }' 'void main() { gl_Position = vec4(a_pos, 0.0, 1.0); }'
@ -24,76 +70,53 @@ function initFractal(canvasId) {
'precision highp float;', 'precision highp float;',
'uniform float u_time;', 'uniform float u_time;',
'uniform vec2 u_resolution;', '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() {', 'void main() {',
' vec2 uv = gl_FragCoord.xy / u_resolution;', ' vec2 uv = gl_FragCoord.xy / u_resolution;',
' float aspect = u_resolution.x / u_resolution.y;', ' float aspect = u_resolution.x / u_resolution.y;',
'', ' float t = u_time * 0.12;',
' // Map to complex plane (view_size=3.0, centered)', ' float maxH = 1.0 / max(aspect, 1.0);',
' float zr = (uv.x - 0.5) * 3.0 * aspect;', ' float zoomFrac = 0.65 + 0.15 * sin(t * 0.4);',
' float zi = (uv.y - 0.5) * 3.0;', ' float halfH = maxH * zoomFrac * 0.5;',
'', ' float halfW = halfH * aspect;',
' // Rotate by time (animation)', ' float fx = 0.5 - halfW;',
' float ct = cos(u_time);', ' float fy = 0.5 - halfH;',
' float st = sin(u_time);', ' float cx = 0.5 + fx * 0.5 * cos(t * 0.55);',
' float tmp = zr * ct - zi * st;', ' float cy = 0.5 + fy * 0.5 * sin(t * 0.45);',
' zi = zr * st + zi * ct;', ' float sx = cx + (uv.x - 0.5) * 2.0 * halfW;',
' zr = tmp;', ' float sy = cy + (uv.y - 0.5) * 2.0 * halfH;',
'', ' float v = texture2D(u_dist, vec2(sx, sy)).r * 255.0;',
' // Newton iteration for z^3 - 1', ' // Dither only in the outer smooth region (v in [180, 240] ramps in;',
' int rootIndex = 0;', ' // inner fractal detail stays crisp).',
' int iters = 0;', ' float ditherMix = clamp((v - 180.0) / 60.0, 0.0, 1.0);',
' for (int i = 0; i < 32; i++) {', ' float noise = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5;',
' iters = i;', ' v += noise * 1.2 * ditherMix;',
' float zr2 = zr*zr - zi*zi;', ' float offset = u_time * 10.8;',
' float zi2 = 2.0*zr*zi;', ' vec3 color = calmBand(v - offset);',
' float zr3 = zr*zr2 - zi*zi2;', ' gl_FragColor = vec4(color, 1.0);',
' float zi3 = zr*zi2 + zi*zr2;',
'',
' float d0 = (zr-1.0)*(zr-1.0) + zi*zi;',
' float d1 = (zr+0.5)*(zr+0.5) + (zi-0.866025)*(zi-0.866025);',
' float d2 = (zr+0.5)*(zr+0.5) + (zi+0.866025)*(zi+0.866025);',
'',
' if (d0 < 1e-6) { rootIndex = 0; break; }',
' if (d1 < 1e-6) { rootIndex = 1; break; }',
' if (d2 < 1e-6) { rootIndex = 2; break; }',
'',
' float nr = zr3 - 1.0;',
' float ni = zi3;',
' float dr = 3.0*zr2;',
' float di = 3.0*zi2;',
' float denom = dr*dr + di*di;',
' if (denom < 1e-12) break;',
' zr -= (nr*dr + ni*di) / denom;',
' zi -= (ni*dr - nr*di) / denom;',
' }',
'',
' // Fallback: closest root',
' float d0 = (zr-1.0)*(zr-1.0) + zi*zi;',
' float d1 = (zr+0.5)*(zr+0.5) + (zi-0.866025)*(zi-0.866025);',
' float d2 = (zr+0.5)*(zr+0.5) + (zi+0.866025)*(zi+0.866025);',
' if (d1 < d0 && d1 < d2) rootIndex = 1;',
' else if (d2 < d0) rootIndex = 2;',
'',
' // Ocean palette',
' float brightness = max(1.0 - float(iters)/32.0 * 0.7, 0.3);',
' vec3 color;',
' if (rootIndex == 0) color = vec3(0.05, 0.25, 0.55);',
' else if (rootIndex == 1) color = vec3(0.0, 0.45, 0.50);',
' else color = vec3(0.15, 0.55, 0.65);',
'',
' gl_FragColor = vec4(color * brightness, 1.0);',
'}' '}'
].join('\n'); ].join('\n');
// --- Compile helpers ---
function compile(type, src) { function compile(type, src) {
var s = gl.createShader(type); var s = gl.createShader(type);
gl.shaderSource(s, src); gl.shaderSource(s, src);
gl.compileShader(s); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.error('Shader compile error:', gl.getShaderInfoLog(s)); console.error('[fractal-gl] shader compile error:', gl.getShaderInfoLog(s));
return null; return null;
} }
return s; return s;
@ -101,52 +124,207 @@ function initFractal(canvasId) {
var vs = compile(gl.VERTEX_SHADER, VERT_SRC); var vs = compile(gl.VERTEX_SHADER, VERT_SRC);
var fs = compile(gl.FRAGMENT_SHADER, FRAG_SRC); var fs = compile(gl.FRAGMENT_SHADER, FRAG_SRC);
if (!vs || !fs) return; if (!vs || !fs) return initCanvas2D(canvas);
var prog = gl.createProgram(); var prog = gl.createProgram();
gl.attachShader(prog, vs); gl.attachShader(prog, vs);
gl.attachShader(prog, fs); gl.attachShader(prog, fs);
gl.linkProgram(prog); gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) { if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
console.error('Program link error:', gl.getProgramInfoLog(prog)); console.error('[fractal-gl] link error:', gl.getProgramInfoLog(prog));
return; return initCanvas2D(canvas);
} }
gl.useProgram(prog); gl.useProgram(prog);
// --- Full-screen triangle ---
var buf = gl.createBuffer(); var buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
var aPos = gl.getAttribLocation(prog, 'a_pos'); var aPos = gl.getAttribLocation(prog, 'a_pos');
gl.enableVertexAttribArray(aPos); gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
var uTime = gl.getUniformLocation(prog, 'u_time'); var uTime = gl.getUniformLocation(prog, 'u_time');
var uRes = gl.getUniformLocation(prog, 'u_resolution'); var uRes = gl.getUniformLocation(prog, 'u_resolution');
var uDist = gl.getUniformLocation(prog, 'u_dist');
// --- Resize --- var tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0,
gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([0, 0, 0, 255]));
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_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);
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';
function resize() { function resize() {
canvas.width = window.innerWidth; canvas.width = window.innerWidth;
canvas.height = window.innerHeight; canvas.height = window.innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height); gl.viewport(0, 0, canvas.width, canvas.height);
} }
resize(); resize();
window.addEventListener('resize', resize); window.addEventListener('resize', resize);
// --- Animation loop ---
var startTime = performance.now(); var startTime = performance.now();
var animationSpeed = 0.0001;
function frame() { function frame() {
var t = (performance.now() - startTime) * animationSpeed; var t = (performance.now() - startTime) * 0.001;
gl.uniform1f(uTime, t); gl.uniform1f(uTime, t);
gl.uniform2f(uRes, canvas.width, canvas.height); gl.uniform2f(uRes, canvas.width, canvas.height);
gl.drawArrays(gl.TRIANGLES, 0, 3); gl.drawArrays(gl.TRIANGLES, 0, 3);
requestAnimationFrame(frame); requestAnimationFrame(frame);
} }
requestAnimationFrame(frame); requestAnimationFrame(frame);
console.log('[fractal-gl] WebGL loop started');
}
// ----------------------------------------------------------------
// Canvas2D fallback
// - 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)
// ----------------------------------------------------------------
function initCanvas2D(canvas) {
var ctx2d = canvas.getContext('2d');
if (!ctx2d) {
console.error('[fractal-gl] Canvas2D also unavailable; body gradient remains');
return;
}
var palette32 = _buildPalette32();
var img = new Image();
img.onerror = function (e) { console.error('[fractal-gl] dist.png load failed (canvas2d)', e); };
img.onload = function () {
var SW = img.width, SH = img.height;
console.log('[fractal-gl] PNG decoded (canvas2d):', SW, 'x', SH);
var off = document.createElement('canvas');
off.width = SW; off.height = SH;
var octx = off.getContext('2d');
octx.drawImage(img, 0, 0);
var data = octx.getImageData(0, 0, SW, SH).data;
var gray = new Uint8Array(SW * SH);
for (var i = 0, j = 0; i < gray.length; i++, j += 4) gray[i] = data[j];
// 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)));
var aspect = window.innerWidth / window.innerHeight;
return { w: Math.max(2, Math.floor(h * aspect)), h: h };
}
function resize() {
var s = targetSize();
canvas.width = s.w; canvas.height = s.h;
}
resize();
window.addEventListener('resize', resize);
var imgOut = ctx2d.createImageData(canvas.width, canvas.height);
var out32 = new Uint32Array(imgOut.data.buffer);
var lastW = canvas.width, lastH = canvas.height;
var startTime = performance.now();
// 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.
var BAYER = new Float32Array([0,8,2,10, 12,4,14,6, 3,11,1,9, 15,7,13,5]);
for (var bi = 0; bi < 16; bi++) BAYER[bi] = (BAYER[bi] - 7.5) * 0.6 / 7.5;
function frame() {
var W = canvas.width, H = canvas.height;
if (W !== lastW || H !== lastH) {
imgOut = ctx2d.createImageData(W, H);
out32 = new Uint32Array(imgOut.data.buffer);
lastW = W; lastH = H;
}
var elapsed = (performance.now() - startTime) * 0.001;
var t = elapsed * 0.12;
var aspect = W / H;
var maxH = 1.0 / Math.max(aspect, 1.0);
var zoomFrac = 0.65 + 0.15 * Math.sin(t * 0.4);
var halfH = maxH * zoomFrac * 0.5;
var halfW = halfH * aspect;
var fxRem = 0.5 - halfW, fyRem = 0.5 - halfH;
var cx = 0.5 + fxRem * 0.5 * Math.cos(t * 0.55);
var cy = 0.5 + fyRem * 0.5 * Math.sin(t * 0.45);
var x0 = (cx - halfW) * SW;
var y0 = (cy - halfH) * SH;
var dxs = (2 * halfW) / W * SW;
var dys = (2 * halfH) / H * SH;
// offset in 1024-entry space (4x granularity) — same cycle period (~24 s) as before
var offsetHD = (elapsed * 10.8 * 4) | 0;
var SW1 = SW - 1, SH1 = SH - 1;
var idx = 0;
var sy = y0;
for (var y = 0; y < H; y++) {
var sy0i = sy | 0;
if (sy0i < 0) sy0i = 0; else if (sy0i > SH1) sy0i = SH1;
var sy1i = sy0i + 1;
if (sy1i > SH1) sy1i = SH1;
var fy = sy - (sy | 0);
var ify = 1 - fy;
var row0 = sy0i * SW;
var row1 = sy1i * SW;
var sx = x0;
for (var x = 0; x < W; x++) {
var sx0i = sx | 0;
if (sx0i < 0) sx0i = 0; else if (sx0i > SW1) sx0i = SW1;
var sx1i = sx0i + 1;
if (sx1i > SW1) sx1i = SW1;
var fx = sx - (sx | 0);
var ifx = 1 - fx;
var a = gray[row0 + sx0i];
var b = gray[row0 + sx1i];
var c = gray[row1 + sx0i];
var d = gray[row1 + sx1i];
var v = (a * ifx + b * fx) * ify + (c * ifx + d * fx) * fy;
// Selective dither: only the outer smooth region (v 180→240 ramps in).
if (v > 180) {
var mix = v >= 240 ? 1 : (v - 180) / 60;
v += BAYER[(y & 3) * 4 + (x & 3)] * 1.6 * mix;
}
// Multiply by 4 to index into 1024-entry palette, then wrap.
out32[idx++] = palette32[(((v * 4) | 0) - offsetHD) & 1023];
sx += dxs;
}
sy += dys;
}
ctx2d.putImageData(imgOut, 0, 0);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
console.log('[fractal-gl] Canvas2D loop started; internal',
canvas.width, 'x', canvas.height);
};
img.src = '/routes/root/dist.png';
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { initFractal('fractal-canvas'); });
} else {
initFractal('fractal-canvas');
} }

View file

@ -20,6 +20,7 @@
} }
#fractal-canvas { #fractal-canvas {
image-rendering: auto;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -36,7 +37,6 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeInUp 800ms ease 300ms both;
} }
.glass-card { .glass-card {
@ -119,7 +119,16 @@
</div> </div>
</div> </div>
<script src="/routes/root/fractal-gl.js"></script> <script>
<script>initFractal('fractal-canvas');</script> // Lazy-load the fractal background after first paint so the page feels instant.
requestAnimationFrame(function () {
requestAnimationFrame(function () {
var s = document.createElement('script');
s.src = '/routes/root/fractal-gl.js?v=9';
s.async = true;
document.body.appendChild(s);
});
});
</script>
</body> </body>
</html> </html>

View file

@ -20,6 +20,7 @@
} }
#fractal-canvas { #fractal-canvas {
image-rendering: auto;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
@ -36,7 +37,6 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
animation: fadeInUp 800ms ease 300ms both;
} }
.glass-card { .glass-card {
@ -108,7 +108,15 @@
</nav> </nav>
</div> </div>
<script src="/routes/root/fractal-gl.js"></script> <script>
<script>initFractal('fractal-canvas');</script> requestAnimationFrame(function () {
requestAnimationFrame(function () {
var s = document.createElement('script');
s.src = '/routes/root/fractal-gl.js?v=9';
s.async = true;
document.body.appendChild(s);
});
});
</script>
</body> </body>
</html> </html>