Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
497 lines
19 KiB
JavaScript
497 lines
19 KiB
JavaScript
// 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);
|
|
|
|
var canvas = document.getElementById(canvasId);
|
|
if (!canvas) {
|
|
console.warn('[fractal-gl] canvas not found:', canvasId);
|
|
return;
|
|
}
|
|
|
|
// Flat dark backdrop: clean start state, and the tone the reveal blooms over.
|
|
document.body.style.background = DARK_HEX;
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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) {
|
|
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.
|
|
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 (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) {
|
|
function compile(type, src) {
|
|
var s = gl.createShader(type);
|
|
gl.shaderSource(s, src);
|
|
gl.compileShader(s);
|
|
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
|
console.error('[fractal-gl] shader compile error:', gl.getShaderInfoLog(s));
|
|
return null;
|
|
}
|
|
return s;
|
|
}
|
|
|
|
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();
|
|
gl.attachShader(prog, vs);
|
|
gl.attachShader(prog, fs);
|
|
gl.linkProgram(prog);
|
|
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
|
|
console.error('[fractal-gl] link error:', gl.getProgramInfoLog(prog));
|
|
return initCanvas2D(canvas);
|
|
}
|
|
gl.useProgram(prog);
|
|
|
|
var buf = gl.createBuffer();
|
|
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
|
|
var aPos = gl.getAttribLocation(prog, 'a_pos');
|
|
gl.enableVertexAttribArray(aPos);
|
|
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
|
|
|
|
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);
|
|
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);
|
|
|
|
// 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;
|
|
canvas.height = window.innerHeight;
|
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
}
|
|
resize();
|
|
window.addEventListener('resize', resize);
|
|
|
|
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);
|
|
}
|
|
requestAnimationFrame(frame);
|
|
console.log('[fractal-gl] WebGL loop started');
|
|
}
|
|
|
|
// ----------------------------------------------------------------
|
|
// 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 backdrop 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];
|
|
|
|
// 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)));
|
|
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();
|
|
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.
|
|
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);
|
|
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 = DIST_URL;
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { initFractal('fractal-canvas'); });
|
|
} else {
|
|
initFractal('fractal-canvas');
|
|
}
|