LUMEN: generative shader studio with 9 looping art modes and PNG/video/GIF export
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+166
@@ -0,0 +1,166 @@
|
||||
/* WebGL2 engine: compiles the uber shader, owns the render loop,
|
||||
exposes deterministic renderAt(phase) for exporters. */
|
||||
|
||||
var Engine = (function () {
|
||||
var canvas, gl, program, uniforms = {};
|
||||
var playing = true;
|
||||
var loopT = 0; // seconds into current loop
|
||||
var lastTick = 0;
|
||||
var fps = 60, fpsAcc = 0, fpsN = 0, fpsCb = null;
|
||||
var getParams = null; // injected: () => P
|
||||
|
||||
var UNIFORM_NAMES = [
|
||||
"u_res", "u_phase", "u_seed", "u_mode",
|
||||
"u_c1", "u_c2", "u_c3", "u_c4", "u_bg",
|
||||
"u_hue", "u_sat", "u_exposure", "u_contrast",
|
||||
"u_scale", "u_complex", "u_warp", "u_flow", "u_stretch",
|
||||
"u_light", "u_gloss", "u_lightAngle", "u_irid", "u_glow",
|
||||
"u_grain", "u_cell", "u_lines", "u_ca", "u_vig", "u_soft",
|
||||
"u_travel"
|
||||
];
|
||||
|
||||
function compile(type, src) {
|
||||
var sh = gl.createShader(type);
|
||||
gl.shaderSource(sh, src);
|
||||
gl.compileShader(sh);
|
||||
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
|
||||
throw new Error("Shader compile error:\n" + gl.getShaderInfoLog(sh));
|
||||
}
|
||||
return sh;
|
||||
}
|
||||
|
||||
function init(canvasEl, paramsGetter) {
|
||||
canvas = canvasEl;
|
||||
getParams = paramsGetter;
|
||||
gl = canvas.getContext("webgl2", {
|
||||
antialias: false,
|
||||
preserveDrawingBuffer: true,
|
||||
powerPreference: "high-performance"
|
||||
});
|
||||
if (!gl) throw new Error("WebGL2 not available");
|
||||
|
||||
program = gl.createProgram();
|
||||
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT_SRC));
|
||||
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG_SRC));
|
||||
gl.linkProgram(program);
|
||||
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error("Program link error:\n" + gl.getProgramInfoLog(program));
|
||||
}
|
||||
gl.useProgram(program);
|
||||
|
||||
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);
|
||||
gl.enableVertexAttribArray(0);
|
||||
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
UNIFORM_NAMES.forEach(function (n) {
|
||||
uniforms[n] = gl.getUniformLocation(program, n);
|
||||
});
|
||||
|
||||
lastTick = performance.now();
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function setSize(w, h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
gl.viewport(0, 0, w, h);
|
||||
}
|
||||
|
||||
function pushUniforms(P, phase) {
|
||||
gl.uniform2f(uniforms.u_res, canvas.width, canvas.height);
|
||||
gl.uniform1f(uniforms.u_phase, phase);
|
||||
gl.uniform1f(uniforms.u_seed, P.seed);
|
||||
gl.uniform1i(uniforms.u_mode, P.mode);
|
||||
|
||||
gl.uniform3fv(uniforms.u_c1, hexToRgb01(P.c1));
|
||||
gl.uniform3fv(uniforms.u_c2, hexToRgb01(P.c2));
|
||||
gl.uniform3fv(uniforms.u_c3, hexToRgb01(P.c3));
|
||||
gl.uniform3fv(uniforms.u_c4, hexToRgb01(P.c4));
|
||||
gl.uniform3fv(uniforms.u_bg, hexToRgb01(P.bg));
|
||||
|
||||
gl.uniform1f(uniforms.u_hue, P.hue);
|
||||
gl.uniform1f(uniforms.u_sat, P.sat);
|
||||
gl.uniform1f(uniforms.u_exposure, P.exposure);
|
||||
gl.uniform1f(uniforms.u_contrast, P.contrast);
|
||||
|
||||
gl.uniform1f(uniforms.u_scale, P.scale);
|
||||
gl.uniform1f(uniforms.u_complex, P.complex);
|
||||
gl.uniform1f(uniforms.u_warp, P.warp);
|
||||
gl.uniform1f(uniforms.u_flow, P.flow);
|
||||
gl.uniform1f(uniforms.u_stretch, P.stretch);
|
||||
|
||||
gl.uniform1f(uniforms.u_light, P.light);
|
||||
gl.uniform1f(uniforms.u_gloss, P.gloss);
|
||||
gl.uniform1f(uniforms.u_lightAngle, P.lightAngle);
|
||||
gl.uniform1f(uniforms.u_irid, P.irid);
|
||||
gl.uniform1f(uniforms.u_glow, P.glow);
|
||||
|
||||
gl.uniform1f(uniforms.u_grain, P.grain);
|
||||
gl.uniform1f(uniforms.u_cell, P.cell);
|
||||
gl.uniform1f(uniforms.u_lines, P.lines);
|
||||
gl.uniform1f(uniforms.u_ca, P.ca);
|
||||
gl.uniform1f(uniforms.u_vig, P.vig);
|
||||
gl.uniform1f(uniforms.u_soft, P.soft);
|
||||
|
||||
gl.uniform1f(uniforms.u_travel, P.travel);
|
||||
}
|
||||
|
||||
function renderAt(phase) {
|
||||
var P = getParams();
|
||||
pushUniforms(P, phase);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
||||
}
|
||||
|
||||
function currentPhase() {
|
||||
var P = getParams();
|
||||
return (loopT / P.loop) % 1;
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
var dt = Math.min((now - lastTick) / 1000, 0.1);
|
||||
lastTick = now;
|
||||
|
||||
if (playing) {
|
||||
var P = getParams();
|
||||
loopT = (loopT + dt) % P.loop;
|
||||
}
|
||||
renderAt(currentPhase());
|
||||
|
||||
fpsAcc += dt; fpsN++;
|
||||
if (fpsAcc >= 0.5) {
|
||||
fps = Math.round(fpsN / fpsAcc);
|
||||
fpsAcc = 0; fpsN = 0;
|
||||
if (fpsCb) fpsCb(fps);
|
||||
}
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
function readPixels() {
|
||||
var w = canvas.width, h = canvas.height;
|
||||
var buf = new Uint8Array(w * h * 4);
|
||||
gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, buf);
|
||||
/* flip vertically: GL origin is bottom-left */
|
||||
var flipped = new Uint8Array(w * h * 4);
|
||||
var row = w * 4;
|
||||
for (var y = 0; y < h; y++) {
|
||||
flipped.set(buf.subarray(y * row, (y + 1) * row), (h - 1 - y) * row);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
return {
|
||||
init: init,
|
||||
setSize: setSize,
|
||||
renderAt: renderAt,
|
||||
readPixels: readPixels,
|
||||
currentPhase: currentPhase,
|
||||
resetTime: function () { loopT = 0; },
|
||||
setPlaying: function (v) { playing = v; lastTick = performance.now(); },
|
||||
isPlaying: function () { return playing; },
|
||||
onFps: function (cb) { fpsCb = cb; },
|
||||
canvas: function () { return canvas; },
|
||||
size: function () { return [canvas.width, canvas.height]; }
|
||||
};
|
||||
})();
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/* Export pipeline: PNG stills, WebM/MP4 video, looping GIF. */
|
||||
|
||||
var Exporter = (function () {
|
||||
var cancelled = false;
|
||||
var busy = false;
|
||||
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
function showOverlay(title) {
|
||||
cancelled = false;
|
||||
$("overlay-title").textContent = title;
|
||||
$("overlay-detail").textContent = "preparing";
|
||||
$("overlay-bar").style.width = "0%";
|
||||
$("overlay").hidden = false;
|
||||
}
|
||||
function setProgress(frac, detail) {
|
||||
$("overlay-bar").style.width = Math.round(frac * 100) + "%";
|
||||
if (detail) $("overlay-detail").textContent = detail;
|
||||
}
|
||||
function hideOverlay() { $("overlay").hidden = true; }
|
||||
|
||||
function download(blob, name) {
|
||||
var a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
|
||||
}
|
||||
|
||||
function stamp(P, ext) {
|
||||
var mode = MODES[P.mode].key;
|
||||
return "lumen-" + mode + "-" + String(Math.round(P.seed)).padStart(4, "0") + "." + ext;
|
||||
}
|
||||
|
||||
function evenRound(v) { return 2 * Math.round(v / 2); }
|
||||
|
||||
/* ---------- PNG ---------- */
|
||||
function exportPNG(P, aspect) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
var prev = Engine.size();
|
||||
var h = parseInt(P.imgRes, 10);
|
||||
var w = evenRound(h * aspect);
|
||||
Engine.setSize(w, h);
|
||||
Engine.renderAt(Engine.currentPhase());
|
||||
Engine.canvas().toBlob(function (blob) {
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.renderAt(Engine.currentPhase());
|
||||
busy = false;
|
||||
if (blob) {
|
||||
download(blob, stamp(P, "png"));
|
||||
UI.toast("Saved " + w + "\u00d7" + h + " PNG");
|
||||
}
|
||||
}, "image/png");
|
||||
}
|
||||
|
||||
/* ---------- Video (realtime capture) ---------- */
|
||||
function pickVideoMime() {
|
||||
var candidates = [
|
||||
"video/webm;codecs=vp9",
|
||||
"video/webm;codecs=vp8",
|
||||
"video/webm",
|
||||
"video/mp4"
|
||||
];
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
if (window.MediaRecorder && MediaRecorder.isTypeSupported(candidates[i])) return candidates[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function exportVideo(P, aspect) {
|
||||
if (busy) return;
|
||||
var mime = pickVideoMime();
|
||||
if (!mime) { UI.toast("Video recording not supported in this browser"); return; }
|
||||
busy = true;
|
||||
|
||||
var prev = Engine.size();
|
||||
var h = parseInt(P.vidRes, 10);
|
||||
var w = evenRound(h * aspect);
|
||||
Engine.setSize(w, h);
|
||||
|
||||
var wasPlaying = Engine.isPlaying();
|
||||
Engine.setPlaying(true);
|
||||
Engine.resetTime();
|
||||
|
||||
var durMs = P.loop * P.vidLoops * 1000;
|
||||
var ext = mime.indexOf("mp4") >= 0 ? "mp4" : "webm";
|
||||
showOverlay("Recording video");
|
||||
|
||||
var stream = Engine.canvas().captureStream(60);
|
||||
var rec = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: 18000000 });
|
||||
var parts = [];
|
||||
rec.ondataavailable = function (e) { if (e.data.size) parts.push(e.data); };
|
||||
rec.onstop = function () {
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
hideOverlay();
|
||||
busy = false;
|
||||
if (!cancelled) {
|
||||
download(new Blob(parts, { type: mime }), stamp(P, ext));
|
||||
UI.toast("Saved " + (durMs / 1000).toFixed(1) + "s " + ext.toUpperCase() + " (" + w + "\u00d7" + h + ")");
|
||||
}
|
||||
};
|
||||
rec.start(200);
|
||||
|
||||
var t0 = performance.now();
|
||||
(function poll() {
|
||||
var el = performance.now() - t0;
|
||||
if (cancelled) { rec.stop(); return; }
|
||||
setProgress(Math.min(el / durMs, 1), (el / 1000).toFixed(1) + "s / " + (durMs / 1000).toFixed(1) + "s \u00b7 " + w + "\u00d7" + h);
|
||||
if (el >= durMs) { rec.stop(); return; }
|
||||
requestAnimationFrame(poll);
|
||||
})();
|
||||
}
|
||||
|
||||
/* ---------- GIF (offline, deterministic, perfect loop) ---------- */
|
||||
async function exportGIF(P, aspect) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
|
||||
var prev = Engine.size();
|
||||
var wasPlaying = Engine.isPlaying();
|
||||
Engine.setPlaying(false);
|
||||
|
||||
var w = parseInt(P.gifW, 10);
|
||||
var h = evenRound(w / aspect);
|
||||
var fps = parseInt(P.gifFps, 10);
|
||||
var nFrames = Math.max(2, Math.round(P.loop * fps));
|
||||
|
||||
showOverlay("Rendering GIF");
|
||||
Engine.setSize(w, h);
|
||||
|
||||
var frames = [];
|
||||
for (var f = 0; f < nFrames; f++) {
|
||||
if (cancelled) break;
|
||||
Engine.renderAt(f / nFrames);
|
||||
frames.push(Engine.readPixels());
|
||||
setProgress(0.4 * (f + 1) / nFrames, "capturing " + (f + 1) + "/" + nFrames);
|
||||
if (f % 4 === 3) await wait(0);
|
||||
}
|
||||
|
||||
Engine.setSize(prev[0], prev[1]);
|
||||
Engine.setPlaying(wasPlaying);
|
||||
|
||||
if (cancelled) { hideOverlay(); busy = false; return; }
|
||||
|
||||
var data = await GIFEnc.encode({
|
||||
frames: frames, width: w, height: h, fps: fps,
|
||||
dither: P.gifDither,
|
||||
onProgress: function (frac, detail) { setProgress(0.4 + 0.6 * frac, "encoding \u00b7 " + detail); },
|
||||
isCancelled: function () { return cancelled; }
|
||||
});
|
||||
|
||||
hideOverlay();
|
||||
busy = false;
|
||||
if (data && !cancelled) {
|
||||
download(new Blob([data], { type: "image/gif" }), stamp(P, "gif"));
|
||||
UI.toast("Saved " + nFrames + "-frame looping GIF (" + w + "\u00d7" + h + ")");
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
$("overlay-cancel").addEventListener("click", function () { cancelled = true; });
|
||||
});
|
||||
|
||||
return {
|
||||
exportPNG: exportPNG,
|
||||
exportVideo: exportVideo,
|
||||
exportGIF: exportGIF,
|
||||
isBusy: function () { return busy; }
|
||||
};
|
||||
})();
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
/* Dependency-free animated GIF encoder.
|
||||
- global 256-color palette via median cut over sampled frame pixels
|
||||
- optional 4x4 Bayer ordered dithering (kills gradient banding)
|
||||
- standard GIF LZW (jsgif-compatible code-size handling)
|
||||
- NETSCAPE2.0 extension for infinite looping */
|
||||
|
||||
var GIFEnc = (function () {
|
||||
|
||||
/* ---------- byte writer ---------- */
|
||||
function ByteWriter() {
|
||||
this.chunks = [];
|
||||
this.cur = new Uint8Array(1 << 16);
|
||||
this.len = 0;
|
||||
}
|
||||
ByteWriter.prototype.byte = function (b) {
|
||||
if (this.len === this.cur.length) { this.chunks.push(this.cur); this.cur = new Uint8Array(1 << 16); this.len = 0; }
|
||||
this.cur[this.len++] = b & 0xff;
|
||||
};
|
||||
ByteWriter.prototype.bytes = function (arr) {
|
||||
for (var i = 0; i < arr.length; i++) this.byte(arr[i]);
|
||||
};
|
||||
ByteWriter.prototype.short = function (v) { this.byte(v & 0xff); this.byte((v >> 8) & 0xff); };
|
||||
ByteWriter.prototype.string = function (s) { for (var i = 0; i < s.length; i++) this.byte(s.charCodeAt(i)); };
|
||||
ByteWriter.prototype.result = function () {
|
||||
var total = this.chunks.length * (1 << 16) + this.len;
|
||||
var out = new Uint8Array(total);
|
||||
var o = 0;
|
||||
for (var i = 0; i < this.chunks.length; i++) { out.set(this.chunks[i], o); o += this.chunks[i].length; }
|
||||
out.set(this.cur.subarray(0, this.len), o);
|
||||
return out;
|
||||
};
|
||||
|
||||
/* ---------- median cut quantization ---------- */
|
||||
function buildPalette(samples, maxColors) {
|
||||
// samples: array of [r,g,b]
|
||||
var boxes = [samples];
|
||||
while (boxes.length < maxColors) {
|
||||
var bi = -1, bRange = -1, bCh = 0;
|
||||
for (var i = 0; i < boxes.length; i++) {
|
||||
var box = boxes[i];
|
||||
if (box.length < 2) continue;
|
||||
var mins = [255, 255, 255], maxs = [0, 0, 0];
|
||||
for (var j = 0; j < box.length; j++) {
|
||||
for (var c = 0; c < 3; c++) {
|
||||
if (box[j][c] < mins[c]) mins[c] = box[j][c];
|
||||
if (box[j][c] > maxs[c]) maxs[c] = box[j][c];
|
||||
}
|
||||
}
|
||||
for (var c2 = 0; c2 < 3; c2++) {
|
||||
var r = maxs[c2] - mins[c2];
|
||||
if (r > bRange) { bRange = r; bi = i; bCh = c2; }
|
||||
}
|
||||
}
|
||||
if (bi < 0 || bRange === 0) break;
|
||||
var target = boxes[bi];
|
||||
target.sort(function (a, b) { return a[bCh] - b[bCh]; });
|
||||
var mid = target.length >> 1;
|
||||
boxes.splice(bi, 1, target.slice(0, mid), target.slice(mid));
|
||||
}
|
||||
var palette = [];
|
||||
for (var k = 0; k < boxes.length; k++) {
|
||||
var bx = boxes[k];
|
||||
if (!bx.length) continue;
|
||||
var rs = 0, gs = 0, bs = 0;
|
||||
for (var m = 0; m < bx.length; m++) { rs += bx[m][0]; gs += bx[m][1]; bs += bx[m][2]; }
|
||||
palette.push([Math.round(rs / bx.length), Math.round(gs / bx.length), Math.round(bs / bx.length)]);
|
||||
}
|
||||
while (palette.length < maxColors) palette.push([0, 0, 0]);
|
||||
return palette;
|
||||
}
|
||||
|
||||
function makeNearest(palette) {
|
||||
var cache = new Int16Array(32768).fill(-1);
|
||||
return function (r, g, b) {
|
||||
var key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3);
|
||||
var hit = cache[key];
|
||||
if (hit >= 0) return hit;
|
||||
var best = 0, bd = 1e9;
|
||||
for (var i = 0; i < palette.length; i++) {
|
||||
var dr = palette[i][0] - r, dg = palette[i][1] - g, db = palette[i][2] - b;
|
||||
var d = dr * dr + dg * dg + db * db;
|
||||
if (d < bd) { bd = d; best = i; }
|
||||
}
|
||||
cache[key] = best;
|
||||
return best;
|
||||
};
|
||||
}
|
||||
|
||||
var BAYER = [
|
||||
0, 8, 2, 10,
|
||||
12, 4, 14, 6,
|
||||
3, 11, 1, 9,
|
||||
15, 7, 13, 5
|
||||
];
|
||||
|
||||
/* ---------- LZW (GIF variant) ---------- */
|
||||
function lzwEncode(minCodeSize, indices, out) {
|
||||
var clearCode = 1 << minCodeSize;
|
||||
var eoiCode = clearCode + 1;
|
||||
var nextCode = eoiCode + 1;
|
||||
var curBits = minCodeSize + 1;
|
||||
var maxCode = (1 << curBits) - 1;
|
||||
var dict = new Map();
|
||||
|
||||
var bitAcc = 0, bitCnt = 0;
|
||||
var sub = new Uint8Array(255), subLen = 0;
|
||||
|
||||
function flushSub() {
|
||||
if (subLen > 0) { out.byte(subLen); for (var i = 0; i < subLen; i++) out.byte(sub[i]); subLen = 0; }
|
||||
}
|
||||
function pushByte(b) {
|
||||
sub[subLen++] = b;
|
||||
if (subLen === 255) flushSub();
|
||||
}
|
||||
function emit(code) {
|
||||
bitAcc |= code << bitCnt;
|
||||
bitCnt += curBits;
|
||||
while (bitCnt >= 8) { pushByte(bitAcc & 0xff); bitAcc >>= 8; bitCnt -= 8; }
|
||||
if (nextCode > maxCode && curBits < 12) {
|
||||
curBits++;
|
||||
maxCode = (1 << curBits) - 1;
|
||||
}
|
||||
}
|
||||
|
||||
out.byte(minCodeSize);
|
||||
emit(clearCode);
|
||||
|
||||
var prev = indices[0];
|
||||
for (var i = 1; i < indices.length; i++) {
|
||||
var k = indices[i];
|
||||
var key = (prev << 8) | k;
|
||||
var hit = dict.get(key);
|
||||
if (hit !== undefined) { prev = hit; continue; }
|
||||
emit(prev);
|
||||
if (nextCode < 4096) {
|
||||
dict.set(key, nextCode++);
|
||||
} else {
|
||||
emit(clearCode);
|
||||
dict.clear();
|
||||
nextCode = eoiCode + 1;
|
||||
curBits = minCodeSize + 1;
|
||||
maxCode = (1 << curBits) - 1;
|
||||
}
|
||||
prev = k;
|
||||
}
|
||||
emit(prev);
|
||||
emit(eoiCode);
|
||||
if (bitCnt > 0) pushByte(bitAcc & 0xff);
|
||||
flushSub();
|
||||
out.byte(0); /* block terminator */
|
||||
}
|
||||
|
||||
/* ---------- main encode ---------- */
|
||||
/* frames: array of Uint8Array RGBA (top-down), all width*height*4 */
|
||||
async function encode(opts) {
|
||||
var frames = opts.frames, w = opts.width, h = opts.height;
|
||||
var fps = opts.fps || 25;
|
||||
var dither = opts.dither !== false;
|
||||
var onProgress = opts.onProgress || function () {};
|
||||
var isCancelled = opts.isCancelled || function () { return false; };
|
||||
|
||||
/* sample pixels across all frames for the global palette */
|
||||
var samples = [];
|
||||
var targetSamples = 42000;
|
||||
var totalPx = frames.length * w * h;
|
||||
var stride = Math.max(1, Math.floor(totalPx / targetSamples));
|
||||
for (var f = 0; f < frames.length; f++) {
|
||||
var d = frames[f];
|
||||
for (var p = (f * 7919) % stride; p < w * h; p += stride) {
|
||||
samples.push([d[p * 4], d[p * 4 + 1], d[p * 4 + 2]]);
|
||||
}
|
||||
}
|
||||
onProgress(0.02, "building palette");
|
||||
await microtask();
|
||||
var palette = buildPalette(samples, 256);
|
||||
var nearest = makeNearest(palette);
|
||||
|
||||
var out = new ByteWriter();
|
||||
out.string("GIF89a");
|
||||
out.short(w); out.short(h);
|
||||
out.byte(0xF7); /* global table, 256 colors, 8-bit res */
|
||||
out.byte(0); /* bg index */
|
||||
out.byte(0); /* aspect */
|
||||
for (var c = 0; c < 256; c++) {
|
||||
var col = palette[c] || [0, 0, 0];
|
||||
out.byte(col[0]); out.byte(col[1]); out.byte(col[2]);
|
||||
}
|
||||
|
||||
/* NETSCAPE loop forever */
|
||||
out.byte(0x21); out.byte(0xFF); out.byte(11);
|
||||
out.string("NETSCAPE2.0");
|
||||
out.byte(3); out.byte(1); out.short(0); out.byte(0);
|
||||
|
||||
var delay = Math.max(2, Math.round(100 / fps));
|
||||
var indices = new Uint8Array(w * h);
|
||||
|
||||
for (var fi = 0; fi < frames.length; fi++) {
|
||||
if (isCancelled()) return null;
|
||||
var data = frames[fi];
|
||||
var di = 0;
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
var o = (y * w + x) * 4;
|
||||
var r = data[o], g = data[o + 1], b = data[o + 2];
|
||||
if (dither) {
|
||||
var dth = (BAYER[(y & 3) * 4 + (x & 3)] / 16 - 0.5) * 14;
|
||||
r = Math.max(0, Math.min(255, r + dth));
|
||||
g = Math.max(0, Math.min(255, g + dth));
|
||||
b = Math.max(0, Math.min(255, b + dth));
|
||||
}
|
||||
indices[di++] = nearest(r | 0, g | 0, b | 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* graphic control extension */
|
||||
out.byte(0x21); out.byte(0xF9); out.byte(4);
|
||||
out.byte(0x04); /* disposal: do not dispose */
|
||||
out.short(delay);
|
||||
out.byte(0); out.byte(0);
|
||||
|
||||
/* image descriptor */
|
||||
out.byte(0x2C);
|
||||
out.short(0); out.short(0); out.short(w); out.short(h);
|
||||
out.byte(0); /* no local table */
|
||||
|
||||
lzwEncode(8, indices, out);
|
||||
|
||||
onProgress(0.05 + 0.95 * (fi + 1) / frames.length, "frame " + (fi + 1) + "/" + frames.length);
|
||||
await microtask();
|
||||
}
|
||||
|
||||
out.byte(0x3B); /* trailer */
|
||||
return out.result();
|
||||
}
|
||||
|
||||
function microtask() {
|
||||
return new Promise(function (res) { setTimeout(res, 0); });
|
||||
}
|
||||
|
||||
return { encode: encode };
|
||||
})();
|
||||
+323
@@ -0,0 +1,323 @@
|
||||
/* App state, parameter schema, randomizer recipes, and wiring. */
|
||||
|
||||
/* ---------------- modes ---------------- */
|
||||
|
||||
var MODES = [
|
||||
{ id: 0, key: "chrome", name: "Chrome", full: "Liquid Chrome",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M1 12 C5 4 9 15 13 9 C17 3 21 13 25 7" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15 C5 9 10 17 14 12 C18 8 22 15 25 11" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.45"/></svg>' },
|
||||
{ id: 1, key: "silk", name: "Silk", full: "Silk Ribbons",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M1 13 C8 11 12 3 25 4" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1 15.5 C8 13.5 12 5.5 25 6.5" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.65"/><path d="M1 18 C8 16 12 8 25 9" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.35"/></svg>' },
|
||||
{ id: 2, key: "bloom", name: "Bloom", full: "Soft Bloom",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="9" cy="8" r="5.5" fill="currentColor" opacity="0.35"/><circle cx="17" cy="11" r="4" fill="currentColor" opacity="0.6"/></svg>' },
|
||||
{ id: 3, key: "aura", name: "Aura", full: "Aura Rings",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="13" cy="9" r="7" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.35"/><circle cx="13" cy="9" r="4.2" fill="none" stroke="currentColor" stroke-width="1.4" opacity="0.7"/><circle cx="13" cy="9" r="1.6" fill="currentColor"/></svg>' },
|
||||
{ id: 4, key: "rays", name: "Rays", full: "Light Rays",
|
||||
icon: '<svg viewBox="0 0 26 18"><path d="M13 1 L7 17 M13 1 L13 17 M13 1 L19 17 M13 1 L2 13 M13 1 L24 13" fill="none" stroke="currentColor" stroke-width="1.3" opacity="0.8"/></svg>' },
|
||||
{ id: 5, key: "halftone", name: "Halftone", full: "Halftone",
|
||||
icon: '<svg viewBox="0 0 26 18"><circle cx="4" cy="5" r="2.4" fill="currentColor"/><circle cx="11" cy="5" r="1.8" fill="currentColor"/><circle cx="18" cy="5" r="1.2" fill="currentColor"/><circle cx="24" cy="5" r="0.7" fill="currentColor"/><circle cx="4" cy="12" r="1.6" fill="currentColor"/><circle cx="11" cy="12" r="2.2" fill="currentColor"/><circle cx="18" cy="12" r="1.5" fill="currentColor"/><circle cx="24" cy="12" r="0.9" fill="currentColor"/></svg>' },
|
||||
{ id: 6, key: "glyphs", name: "Glyphs", full: "Data Glyphs",
|
||||
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="2" height="3"/><rect x="7" y="2" width="2" height="3" opacity="0.5"/><rect x="12" y="2" width="2" height="3"/><rect x="17" y="2" width="2" height="3" opacity="0.3"/><rect x="22" y="2" width="2" height="3" opacity="0.7"/><rect x="2" y="8" width="2" height="3" opacity="0.4"/><rect x="7" y="8" width="2" height="3"/><rect x="12" y="8" width="2" height="3" opacity="0.6"/><rect x="17" y="8" width="2" height="3"/><rect x="22" y="8" width="2" height="3" opacity="0.4"/><rect x="2" y="14" width="2" height="3" opacity="0.7"/><rect x="7" y="14" width="2" height="3" opacity="0.3"/><rect x="12" y="14" width="2" height="3" opacity="0.8"/><rect x="17" y="14" width="2" height="3" opacity="0.5"/><rect x="22" y="14" width="2" height="3"/></g></svg>' },
|
||||
{ id: 7, key: "reeded", name: "Reeded", full: "Reeded Glass",
|
||||
icon: '<svg viewBox="0 0 26 18"><g stroke="currentColor" stroke-width="1.8" fill="none"><path d="M3 1 V17" opacity="0.9"/><path d="M8 1 V17" opacity="0.5"/><path d="M13 1 V17" opacity="0.9"/><path d="M18 1 V17" opacity="0.5"/><path d="M23 1 V17" opacity="0.9"/></g></svg>' },
|
||||
{ id: 8, key: "mosaic", name: "Mosaic", full: "Pixel Bloom",
|
||||
icon: '<svg viewBox="0 0 26 18"><g fill="currentColor"><rect x="2" y="2" width="6" height="6" opacity="0.9"/><rect x="9" y="2" width="6" height="6" opacity="0.4"/><rect x="16" y="2" width="6" height="6" opacity="0.7"/><rect x="2" y="9" width="6" height="6" opacity="0.3"/><rect x="9" y="9" width="6" height="6" opacity="0.8"/><rect x="16" y="9" width="6" height="6" opacity="0.5"/></g></svg>' }
|
||||
];
|
||||
|
||||
var ASPECTS = { "16:9": 16 / 9, "3:2": 1.5, "1:1": 1, "4:5": 0.8, "21:9": 21 / 9 };
|
||||
|
||||
/* ---------------- state ---------------- */
|
||||
|
||||
var P = {
|
||||
mode: 0, seed: Math.floor(Math.random() * 10000),
|
||||
c1: "#e0220a", c2: "#ff5a1f", c3: "#1f8cff", c4: "#bfe7ff", bg: "#050507",
|
||||
hue: 0, sat: 1.2, exposure: 1.0, contrast: 1.15,
|
||||
scale: 1.7, complex: 3.8, warp: 1.1, flow: 0.5, stretch: 0.45,
|
||||
light: 1.6, gloss: 64, lightAngle: 130, irid: 0.55, glow: 0.35,
|
||||
grain: 0.05, cell: 90, lines: 56, ca: 0.25, vig: 0.35, soft: 0.95,
|
||||
travel: 0.7, loop: 5,
|
||||
lockStyle: false,
|
||||
imgRes: "2160", vidRes: "1080", vidLoops: 2,
|
||||
gifW: "640", gifFps: 25, gifDither: true,
|
||||
aspect: "16:9"
|
||||
};
|
||||
|
||||
/* randomizer ranges; missing keys fall back to DEF_RANGE */
|
||||
var DEF_RANGE = {
|
||||
scale: [0.8, 1.8], complex: [3, 6], warp: [0.3, 1.5], flow: [0, 1], stretch: [-0.3, 0.5],
|
||||
light: [0.6, 1.6], gloss: [16, 80], lightAngle: [0, 360], irid: [0, 0.8], glow: [0, 0.5],
|
||||
contrast: [0.95, 1.2], grain: [0, 0.12], cell: [40, 140], lines: [20, 100], ca: [0, 0.4],
|
||||
vig: [0, 0.4], soft: [0.6, 1.4], sat: [0.95, 1.35], exposure: [0.95, 1.1], hue: [0, 0],
|
||||
travel: [0.3, 1.0], loop: [4, 8]
|
||||
};
|
||||
|
||||
var RECIPES = {
|
||||
chrome: { tone: ["dark"], scale: [1.2, 2.4], complex: [2.8, 5], warp: [0.7, 1.9], flow: [0.2, 1.0],
|
||||
stretch: [0.25, 0.7], light: [1.2, 2.0], gloss: [36, 100], irid: [0.2, 0.85], glow: [0.15, 0.55],
|
||||
contrast: [1.0, 1.3], grain: [0.02, 0.1], ca: [0, 0.45], vig: [0.15, 0.5], sat: [1.0, 1.4],
|
||||
travel: [0.4, 1.0], loop: [4, 8] },
|
||||
silk: { tone: ["dark"], scale: [1.2, 2.2], warp: [0.3, 1.0], lines: [40, 90], gloss: [24, 80],
|
||||
light: [1.0, 1.9], stretch: [0, 0.4], irid: [0.2, 0.7], glow: [0.2, 0.6], grain: [0.02, 0.08],
|
||||
vig: [0.1, 0.4], sat: [1.0, 1.45], contrast: [1.0, 1.25], travel: [0.3, 0.8], loop: [4, 9] },
|
||||
bloom: { tone: ["light", "light", "dark"], scale: [0.8, 1.6], warp: [0.3, 1.4], soft: [0.8, 1.5],
|
||||
light: [0, 0.6], glow: [0, 0.3], grain: [0, 0.07], ca: [0, 0.25], vig: [0, 0.25],
|
||||
contrast: [0.9, 1.05], sat: [0.9, 1.3], travel: [0.5, 1.2], loop: [5, 10] },
|
||||
aura: { tone: ["light", "light", "dark"], scale: [0.9, 1.6], warp: [0.2, 1.0], soft: [0.9, 1.6],
|
||||
grain: [0, 0.06], contrast: [0.9, 1.05], sat: [0.85, 1.15], vig: [0, 0.2], glow: [0, 0.35],
|
||||
ca: [0, 0.08], travel: [0.3, 0.9], loop: [5, 10] },
|
||||
rays: { tone: ["light", "light", "dark"], warp: [0.3, 1.2], lines: [20, 90], grain: [0, 0.1],
|
||||
glow: [0.1, 0.5], contrast: [0.95, 1.2], sat: [1.0, 1.4], vig: [0, 0.3], travel: [0.2, 0.7],
|
||||
loop: [5, 10], scale: [0.9, 1.5] },
|
||||
halftone: { tone: ["light", "light", "dark"], cell: [60, 150], scale: [0.9, 1.8], warp: [0.4, 1.4],
|
||||
grain: [0, 0.05], contrast: [0.95, 1.15], sat: [0.95, 1.3], ca: [0, 0.2], vig: [0, 0.2],
|
||||
travel: [0.4, 1.0], loop: [4, 9] },
|
||||
glyphs: { tone: ["dark"], cell: [70, 150], scale: [0.8, 1.6], warp: [0.5, 1.5], grain: [0.02, 0.12],
|
||||
glow: [0.3, 0.8], contrast: [1.0, 1.3], sat: [1.0, 1.5], ca: [0, 0.5], vig: [0.2, 0.6],
|
||||
travel: [0.4, 1.0], loop: [3, 7] },
|
||||
reeded: { tone: ["light", "dark"], lines: [36, 90], warp: [0.6, 1.6], scale: [0.8, 1.5],
|
||||
grain: [0, 0.07], light: [0.4, 1.2], contrast: [0.95, 1.2], sat: [1.0, 1.35], vig: [0, 0.18],
|
||||
travel: [0.5, 1.1], loop: [5, 10] },
|
||||
mosaic: { tone: ["light", "dark"], cell: [40, 120], warp: [0.3, 1.2], scale: [0.8, 1.5],
|
||||
grain: [0, 0.04], contrast: [0.95, 1.15], sat: [0.95, 1.3], vig: [0, 0.15],
|
||||
travel: [0.5, 1.2], loop: [5, 10], soft: [0.8, 1.4] }
|
||||
};
|
||||
|
||||
var FORM_KEYS = ["scale", "complex", "warp", "flow", "stretch"];
|
||||
var LIGHT_KEYS = ["light", "gloss", "lightAngle", "irid", "glow", "contrast"];
|
||||
var TEXTURE_KEYS = ["grain", "cell", "lines", "ca", "vig", "soft"];
|
||||
var MOTION_KEYS = ["loop", "travel"];
|
||||
var GRADE_KEYS = ["sat", "exposure"];
|
||||
|
||||
/* ---------------- randomizer ---------------- */
|
||||
|
||||
function rnd() { return Math.random(); }
|
||||
function randIn(range) { return range[0] + rnd() * (range[1] - range[0]); }
|
||||
function rangeFor(key) {
|
||||
var rec = RECIPES[MODES[P.mode].key] || {};
|
||||
return rec[key] || DEF_RANGE[key];
|
||||
}
|
||||
|
||||
var activePreset = 0;
|
||||
var setPresetActive = function () {};
|
||||
|
||||
function applyPalette(pal, presetIdx) {
|
||||
P.c1 = pal.colors[0]; P.c2 = pal.colors[1];
|
||||
P.c3 = pal.colors[2]; P.c4 = pal.colors[3];
|
||||
P.bg = pal.bg;
|
||||
activePreset = presetIdx;
|
||||
setPresetActive(presetIdx);
|
||||
}
|
||||
|
||||
function randomizePalette() {
|
||||
var rec = RECIPES[MODES[P.mode].key] || { tone: ["dark", "light"] };
|
||||
var tone = rec.tone[Math.floor(rnd() * rec.tone.length)];
|
||||
if (rnd() < 0.25) {
|
||||
applyPalette(generateRandomPalette(rnd, tone), -1);
|
||||
} else {
|
||||
var pool = [];
|
||||
PALETTES.forEach(function (p, i) { if (p.tone === tone) pool.push(i); });
|
||||
var idx = pool[Math.floor(rnd() * pool.length)];
|
||||
applyPalette(PALETTES[idx], idx);
|
||||
}
|
||||
P.hue = 0;
|
||||
GRADE_KEYS.forEach(function (k) { P[k] = randIn(rangeFor(k)); });
|
||||
}
|
||||
|
||||
function randomizeKeys(keys) {
|
||||
keys.forEach(function (k) {
|
||||
var v = randIn(rangeFor(k));
|
||||
if (k === "gloss" || k === "cell" || k === "lines" || k === "lightAngle") v = Math.round(v);
|
||||
if (k === "loop") v = Math.round(v * 2) / 2;
|
||||
P[k] = v;
|
||||
});
|
||||
}
|
||||
|
||||
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
|
||||
|
||||
function randomizeAll() {
|
||||
if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
|
||||
randomizePalette();
|
||||
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
|
||||
newSeed();
|
||||
refreshAll();
|
||||
}
|
||||
|
||||
/* ---------------- UI wiring ---------------- */
|
||||
|
||||
var refreshers = [];
|
||||
function reg(fn) { refreshers.push(fn); return fn; }
|
||||
function refreshAll() {
|
||||
refreshers.forEach(function (f) { f(); });
|
||||
updateMeta();
|
||||
}
|
||||
|
||||
function get(k) { return function () { return P[k]; }; }
|
||||
function set(k) { return function (v) { P[k] = v; updateMeta(); }; }
|
||||
|
||||
function fmt2(v) { return (+v).toFixed(2); }
|
||||
function fmt1(v) { return (+v).toFixed(1); }
|
||||
function fmtInt(v) { return String(Math.round(v)); }
|
||||
function fmtDeg(v) { return Math.round(v) + "\u00b0"; }
|
||||
function fmtSec(v) { return (+v).toFixed(1) + "s"; }
|
||||
|
||||
function buildRail() {
|
||||
var rail = document.getElementById("rail");
|
||||
|
||||
/* STYLE */
|
||||
var sStyle = UI.section(rail, "Style", function () {
|
||||
P.mode = Math.floor(rnd() * MODES.length);
|
||||
refreshAll();
|
||||
});
|
||||
reg(UI.modeGrid(sStyle, MODES, get("mode"), function (v) { P.mode = v; updateMeta(); }));
|
||||
reg(UI.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
|
||||
|
||||
/* COLOR */
|
||||
var sColor = UI.section(rail, "Color", function () { randomizePalette(); refreshAll(); });
|
||||
setPresetActive = UI.presetChips(sColor, PALETTES, function (i) {
|
||||
applyPalette(PALETTES[i], i);
|
||||
refreshAll();
|
||||
});
|
||||
setPresetActive(activePreset);
|
||||
reg(UI.colorSwatches(sColor,
|
||||
[{ key: "c1", label: "A" }, { key: "c2", label: "B" }, { key: "c3", label: "C" }, { key: "c4", label: "D" },
|
||||
{ gap: true }, { key: "bg", label: "BG" }],
|
||||
function (k) { return P[k]; },
|
||||
function (k, v) { P[k] = v; activePreset = -1; setPresetActive(-1); }));
|
||||
reg(UI.slider(sColor, { label: "Hue shift", min: -180, max: 180, step: 1, fmt: fmtDeg, get: get("hue"), set: set("hue") }));
|
||||
reg(UI.slider(sColor, { label: "Saturation", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("sat"), set: set("sat") }));
|
||||
reg(UI.slider(sColor, { label: "Exposure", min: 0.5, max: 1.6, step: 0.01, fmt: fmt2, get: get("exposure"), set: set("exposure") }));
|
||||
|
||||
/* FORM */
|
||||
var sForm = UI.section(rail, "Form", function () { randomizeKeys(FORM_KEYS); newSeed(); refreshAll(); });
|
||||
reg(UI.seedRow(sForm, { get: get("seed"), set: function (v) { P.seed = v; updateMeta(); refreshAll(); }, onDice: function () { newSeed(); refreshAll(); } }));
|
||||
reg(UI.slider(sForm, { label: "Zoom", min: 0.5, max: 3, step: 0.01, fmt: fmt2, get: get("scale"), set: set("scale") }));
|
||||
reg(UI.slider(sForm, { label: "Detail", min: 1, max: 8, step: 0.1, fmt: fmt1, get: get("complex"), set: set("complex") }));
|
||||
reg(UI.slider(sForm, { label: "Warp", min: 0, max: 2.5, step: 0.01, fmt: fmt2, get: get("warp"), set: set("warp") }));
|
||||
reg(UI.slider(sForm, { label: "Turbulence", min: 0, max: 2, step: 0.01, fmt: fmt2, get: get("flow"), set: set("flow") }));
|
||||
reg(UI.slider(sForm, { label: "Stretch", min: -1, max: 1, step: 0.01, fmt: fmt2, get: get("stretch"), set: set("stretch") }));
|
||||
|
||||
/* LIGHTING */
|
||||
var sLight = UI.section(rail, "Lighting", function () { randomizeKeys(LIGHT_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sLight, { label: "Intensity", min: 0, max: 2.2, step: 0.01, fmt: fmt2, get: get("light"), set: set("light") }));
|
||||
reg(UI.slider(sLight, { label: "Gloss", min: 4, max: 120, step: 1, fmt: fmtInt, get: get("gloss"), set: set("gloss") }));
|
||||
reg(UI.slider(sLight, { label: "Angle", min: 0, max: 360, step: 1, fmt: fmtDeg, get: get("lightAngle"), set: set("lightAngle") }));
|
||||
reg(UI.slider(sLight, { label: "Iridescence", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("irid"), set: set("irid") }));
|
||||
reg(UI.slider(sLight, { label: "Glow", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("glow"), set: set("glow") }));
|
||||
reg(UI.slider(sLight, { label: "Contrast", min: 0.6, max: 1.6, step: 0.01, fmt: fmt2, get: get("contrast"), set: set("contrast") }));
|
||||
|
||||
/* TEXTURE */
|
||||
var sTex = UI.section(rail, "Texture", function () { randomizeKeys(TEXTURE_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sTex, { label: "Grain", min: 0, max: 0.4, step: 0.005, fmt: fmt2, get: get("grain"), set: set("grain") }));
|
||||
reg(UI.slider(sTex, { label: "Density", min: 14, max: 180, step: 1, fmt: fmtInt, get: get("cell"), set: set("cell") }));
|
||||
reg(UI.slider(sTex, { label: "Ridges", min: 8, max: 160, step: 1, fmt: fmtInt, get: get("lines"), set: set("lines") }));
|
||||
reg(UI.slider(sTex, { label: "Aberration", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("ca"), set: set("ca") }));
|
||||
reg(UI.slider(sTex, { label: "Vignette", min: 0, max: 1, step: 0.01, fmt: fmt2, get: get("vig"), set: set("vig") }));
|
||||
reg(UI.slider(sTex, { label: "Softness", min: 0.3, max: 1.6, step: 0.01, fmt: fmt2, get: get("soft"), set: set("soft") }));
|
||||
|
||||
/* MOTION */
|
||||
var sMotion = UI.section(rail, "Motion", function () { randomizeKeys(MOTION_KEYS); refreshAll(); });
|
||||
reg(UI.slider(sMotion, { label: "Loop length", min: 2, max: 12, step: 0.5, fmt: fmtSec, get: get("loop"), set: set("loop") }));
|
||||
reg(UI.slider(sMotion, { label: "Travel", min: 0, max: 1.5, step: 0.01, fmt: fmt2, get: get("travel"), set: set("travel") }));
|
||||
|
||||
/* EXPORT */
|
||||
var sExp = UI.section(rail, "Export", null);
|
||||
reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") }));
|
||||
reg(UI.selectRow(sExp, { label: "Video loops", options: [["1", "1 loop"], ["2", "2 loops"], ["3", "3 loops"], ["4", "4 loops"]], get: function () { return String(P.vidLoops); }, set: function (v) { P.vidLoops = parseInt(v, 10); } }));
|
||||
reg(UI.selectRow(sExp, { label: "GIF width", options: [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]], get: get("gifW"), set: set("gifW") }));
|
||||
reg(UI.selectRow(sExp, { label: "GIF fps", options: [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]], get: function () { return String(P.gifFps); }, set: function (v) { P.gifFps = parseInt(v, 10); } }));
|
||||
reg(UI.toggleRow(sExp, { label: "GIF dithering", get: get("gifDither"), set: set("gifDither") }));
|
||||
|
||||
var grid = UI.el("div", "export-grid", sExp);
|
||||
UI.exportButton(grid, "Save image", "PNG",
|
||||
'<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.5" cy="5.5" r="1.5" fill="currentColor"/><path d="M2 12 L6 8 L9 11 L11.5 8.5 L14 11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
|
||||
UI.exportButton(grid, "Record video", "WEBM",
|
||||
'<svg viewBox="0 0 16 16"><rect x="1.5" y="3.5" width="9" height="9" rx="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 7 L14.5 4.5 V11.5 L10.5 9" fill="currentColor"/></svg>',
|
||||
function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
|
||||
UI.exportButton(grid, "Render loop GIF", "GIF",
|
||||
'<svg viewBox="0 0 16 16"><path d="M13.5 8 a5.5 5.5 0 1 1 -1.6 -3.9" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M13.8 1.6 V4.4 H11" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>',
|
||||
function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
|
||||
}
|
||||
|
||||
/* ---------------- stage / meta ---------------- */
|
||||
|
||||
function updateMeta() {
|
||||
document.getElementById("meta-mode").textContent = MODES[P.mode].full;
|
||||
document.getElementById("meta-seed").textContent = "seed " + String(Math.round(P.seed)).padStart(4, "0");
|
||||
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s loop";
|
||||
var s = Engine.size();
|
||||
document.getElementById("meta-res").textContent = s[0] + "\u00d7" + s[1];
|
||||
}
|
||||
|
||||
function fitCanvas() {
|
||||
var frame = document.getElementById("canvas-frame");
|
||||
var availW = frame.clientWidth - 80;
|
||||
var availH = frame.clientHeight - 52;
|
||||
if (availW <= 0 || availH <= 0) return;
|
||||
var ar = ASPECTS[P.aspect];
|
||||
var w = availW, h = w / ar;
|
||||
if (h > availH) { h = availH; w = h * ar; }
|
||||
var canvas = Engine.canvas();
|
||||
canvas.style.width = Math.round(w) + "px";
|
||||
canvas.style.height = Math.round(h) + "px";
|
||||
var dpr = Math.min(window.devicePixelRatio || 1, 1.35);
|
||||
Engine.setSize(2 * Math.round(w * dpr / 2), 2 * Math.round(h * dpr / 2));
|
||||
updateMeta();
|
||||
}
|
||||
|
||||
function setPlayingUI(v) {
|
||||
Engine.setPlaying(v);
|
||||
document.getElementById("icon-pause").style.display = v ? "" : "none";
|
||||
document.getElementById("icon-play").style.display = v ? "none" : "";
|
||||
}
|
||||
|
||||
/* ---------------- boot ---------------- */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var canvas = document.getElementById("view");
|
||||
try {
|
||||
Engine.init(canvas, function () { return P; });
|
||||
} catch (e) {
|
||||
document.querySelector(".canvas-frame").innerHTML =
|
||||
'<div style="color:#9b9ba4;font-size:13px;max-width:380px;text-align:center;line-height:1.6">' +
|
||||
"WebGL2 is required. Please use a recent Chrome, Edge or Firefox.<br><span style=\"color:#5e5e68;font-size:11px\">" +
|
||||
String(e.message).split("\n")[0] + "</span></div>";
|
||||
return;
|
||||
}
|
||||
|
||||
buildRail();
|
||||
|
||||
/* aspect segmented control */
|
||||
var segRefresh = UI.segmented(
|
||||
document.getElementById("aspect-seg"),
|
||||
Object.keys(ASPECTS).map(function (k) { return [k, k]; }),
|
||||
get("aspect"),
|
||||
function (v) { P.aspect = v; fitCanvas(); }
|
||||
);
|
||||
reg(segRefresh);
|
||||
|
||||
fitCanvas();
|
||||
new ResizeObserver(fitCanvas).observe(document.getElementById("canvas-frame"));
|
||||
|
||||
Engine.onFps(function (fps) {
|
||||
document.getElementById("meta-fps").textContent = fps + " fps";
|
||||
});
|
||||
|
||||
document.getElementById("btn-random").addEventListener("click", randomizeAll);
|
||||
document.getElementById("btn-play").addEventListener("click", function () {
|
||||
setPlayingUI(!Engine.isPlaying());
|
||||
});
|
||||
document.getElementById("btn-export-png").addEventListener("click", function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
|
||||
document.getElementById("btn-export-video").addEventListener("click", function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
|
||||
document.getElementById("btn-export-gif").addEventListener("click", function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
var tag = (e.target.tagName || "").toLowerCase();
|
||||
if (tag === "input" || tag === "select" || tag === "textarea") return;
|
||||
if (e.code === "Space") { e.preventDefault(); setPlayingUI(!Engine.isPlaying()); }
|
||||
else if (e.key === "r" || e.key === "R") { randomizeAll(); }
|
||||
else if (e.key === "s" || e.key === "S") { Exporter.exportPNG(P, ASPECTS[P.aspect]); }
|
||||
});
|
||||
|
||||
updateMeta();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/* Curated palettes derived from the reference moods.
|
||||
tone: 'dark' | 'light' decides which modes prefer them. */
|
||||
|
||||
var PALETTES = [
|
||||
{ name: "Inferno Chrome", tone: "dark",
|
||||
bg: "#050507", colors: ["#e0220a", "#ff5a1f", "#1f8cff", "#bfe7ff"] },
|
||||
{ name: "Neon Silk", tone: "dark",
|
||||
bg: "#040406", colors: ["#19e3e3", "#ff2d78", "#ff7a1a", "#7a2dff"] },
|
||||
{ name: "Ultraviolet", tone: "dark",
|
||||
bg: "#06040c", colors: ["#2440ff", "#8a2bff", "#e22bd0", "#ff5470"] },
|
||||
{ name: "Ember", tone: "dark",
|
||||
bg: "#070403", colors: ["#ff6a00", "#ffb347", "#a81c00", "#3d0c02"] },
|
||||
{ name: "Deep Signal", tone: "dark",
|
||||
bg: "#030608", colors: ["#0e3a5c", "#2e7fb8", "#9fd4e8", "#16222e"] },
|
||||
{ name: "Red Telemetry", tone: "dark",
|
||||
bg: "#0a0202", colors: ["#ff2414", "#c81204", "#ff7a5c", "#5c0a02"] },
|
||||
{ name: "Ghost Mono", tone: "dark",
|
||||
bg: "#030304", colors: ["#f2f2f4", "#9a9aa6", "#3c3c46", "#c8c8d2"] },
|
||||
{ name: "Acid Garden", tone: "dark",
|
||||
bg: "#04070a", colors: ["#b8ff2e", "#1fd9a4", "#0a7a5c", "#eaffd0"] },
|
||||
{ name: "Blush", tone: "light",
|
||||
bg: "#fbf6f2", colors: ["#d4607a", "#f0b890", "#fde8d8", "#b8434f"] },
|
||||
{ name: "Prism Pastel", tone: "light",
|
||||
bg: "#f4f1fa", colors: ["#ffb340", "#2b3bd4", "#ff4f9a", "#9a8cff"] },
|
||||
{ name: "Sky Aura", tone: "light",
|
||||
bg: "#e8edfb", colors: ["#2451e8", "#6fa3f5", "#f0a8c8", "#fdfdff"] },
|
||||
{ name: "Halo", tone: "light",
|
||||
bg: "#f4f6ff", colors: ["#4a30e0", "#7a8cff", "#e89ab8", "#c2d4ff"] },
|
||||
{ name: "Solar Flare", tone: "light",
|
||||
bg: "#fefcf8", colors: ["#e8401c", "#ff8a2a", "#ffc04a", "#a82408"] },
|
||||
{ name: "Glacier", tone: "light",
|
||||
bg: "#f8fafc", colors: ["#1a56d6", "#4a9af0", "#a8d4ff", "#0a2a6e"] },
|
||||
{ name: "Tangerine Glass", tone: "dark",
|
||||
bg: "#1c1e22", colors: ["#ff7a14", "#e8e4dc", "#3a3e46", "#ffb066"] },
|
||||
{ name: "Velvet Dusk", tone: "dark",
|
||||
bg: "#08050c", colors: ["#ff3d2e", "#ff8c5a", "#5a1eb8", "#1a0a3c"] }
|
||||
];
|
||||
|
||||
/* Generates a harmonised random palette: pick base hue + scheme. */
|
||||
function generateRandomPalette(rand, tone) {
|
||||
var h = rand() * 360;
|
||||
var schemes = [
|
||||
[0, 22, 48, 180], // analogous + complement pop
|
||||
[0, 150, 180, 210], // split complementary
|
||||
[0, 120, 240, 60], // triad + bridge
|
||||
[0, 30, -30, 200], // warm cluster + cold accent
|
||||
[0, 8, 16, 24] // tight monochrome drift
|
||||
];
|
||||
var sch = schemes[Math.floor(rand() * schemes.length)];
|
||||
var dark = tone === "dark";
|
||||
var colors = sch.map(function (off, i) {
|
||||
var hue = ((h + off) % 360 + 360) % 360;
|
||||
var s = dark ? 0.75 + rand() * 0.25 : 0.55 + rand() * 0.35;
|
||||
var l = dark
|
||||
? (i === 0 ? 0.52 : 0.28 + rand() * 0.45)
|
||||
: (i === 0 ? 0.5 : 0.45 + rand() * 0.4);
|
||||
return hslToHex(hue, s, l);
|
||||
});
|
||||
var bg = dark
|
||||
? hslToHex(h + (rand() - 0.5) * 60, 0.3 + rand() * 0.4, 0.015 + rand() * 0.035)
|
||||
: hslToHex(h + (rand() - 0.5) * 60, 0.25 + rand() * 0.35, 0.93 + rand() * 0.05);
|
||||
return { name: "Generated", tone: tone, bg: bg, colors: colors };
|
||||
}
|
||||
|
||||
function hslToHex(h, s, l) {
|
||||
h = ((h % 360) + 360) % 360;
|
||||
var c = (1 - Math.abs(2 * l - 1)) * s;
|
||||
var x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
var m = l - c / 2;
|
||||
var r = 0, g = 0, b = 0;
|
||||
if (h < 60) { r = c; g = x; }
|
||||
else if (h < 120) { r = x; g = c; }
|
||||
else if (h < 180) { g = c; b = x; }
|
||||
else if (h < 240) { g = x; b = c; }
|
||||
else if (h < 300) { r = x; b = c; }
|
||||
else { r = c; b = x; }
|
||||
return "#" + [r, g, b].map(function (v) {
|
||||
return Math.round((v + m) * 255).toString(16).padStart(2, "0");
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function hexToRgb01(hex) {
|
||||
var v = parseInt(hex.slice(1), 16);
|
||||
return [((v >> 16) & 255) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255];
|
||||
}
|
||||
+453
@@ -0,0 +1,453 @@
|
||||
/* GLSL sources. One uber fragment shader, mode switched by uniform.
|
||||
All motion is driven by a phase in [0,1) sampled on a circle in noise
|
||||
space, so every animation is a mathematically perfect loop. */
|
||||
|
||||
var VERT_SRC = "#version 300 es\n" +
|
||||
"layout(location=0) in vec2 a_pos;\n" +
|
||||
"void main(){ gl_Position = vec4(a_pos, 0.0, 1.0); }\n";
|
||||
|
||||
var FRAG_SRC = `#version 300 es
|
||||
precision highp float;
|
||||
precision highp int;
|
||||
|
||||
uniform vec2 u_res;
|
||||
uniform float u_phase; // 0..1 loop phase
|
||||
uniform float u_seed;
|
||||
uniform int u_mode;
|
||||
|
||||
uniform vec3 u_c1, u_c2, u_c3, u_c4, u_bg;
|
||||
uniform float u_hue, u_sat, u_exposure, u_contrast;
|
||||
|
||||
uniform float u_scale; // zoom: higher = larger forms
|
||||
uniform float u_complex; // fbm octaves 1..8
|
||||
uniform float u_warp;
|
||||
uniform float u_flow; // extra turbulence
|
||||
uniform float u_stretch; // -1..1 anisotropy
|
||||
|
||||
uniform float u_light, u_gloss, u_lightAngle, u_irid, u_glow;
|
||||
|
||||
uniform float u_grain, u_cell, u_lines, u_ca, u_vig, u_soft;
|
||||
uniform float u_travel;
|
||||
|
||||
out vec4 fragColor;
|
||||
|
||||
#define TAU 6.28318530718
|
||||
#define PI 3.14159265359
|
||||
|
||||
/* ---------------- noise ---------------- */
|
||||
|
||||
/* fract-first hashes stay precise for large inputs (big seeds, far cells) */
|
||||
float hash11(float n){
|
||||
n = fract(n * 0.1031);
|
||||
n *= n + 33.33;
|
||||
n *= n + n;
|
||||
return fract(n);
|
||||
}
|
||||
float hash21(vec2 p){
|
||||
vec3 p3 = fract(vec3(p.xyx) * 0.1031);
|
||||
p3 += dot(p3, p3.yzx + 33.33);
|
||||
return fract((p3.x + p3.y) * p3.z);
|
||||
}
|
||||
vec2 hash22(vec2 p){
|
||||
float n = hash21(p);
|
||||
return vec2(n, hash21(p+n+17.13));
|
||||
}
|
||||
|
||||
float vnoise(vec2 p){
|
||||
vec2 i = floor(p), f = fract(p);
|
||||
vec2 u = f*f*(3.0-2.0*f);
|
||||
float a = hash21(i);
|
||||
float b = hash21(i+vec2(1,0));
|
||||
float c = hash21(i+vec2(0,1));
|
||||
float d = hash21(i+vec2(1,1));
|
||||
return mix(mix(a,b,u.x), mix(c,d,u.x), u.y);
|
||||
}
|
||||
|
||||
mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); }
|
||||
|
||||
float fbm(vec2 p){
|
||||
float v = 0.0, a = 0.5, tot = 0.0;
|
||||
mat2 R = rot(0.62);
|
||||
for (int i = 0; i < 8; i++){
|
||||
float w = clamp(u_complex - float(i), 0.0, 1.0);
|
||||
if (w <= 0.0) break;
|
||||
v += a*w*vnoise(p);
|
||||
tot += a*w;
|
||||
a *= 0.55;
|
||||
p = R*p*2.03 + 11.7;
|
||||
}
|
||||
return v/max(tot, 1e-4);
|
||||
}
|
||||
|
||||
/* loop offset: orbit in noise space -> perfect loop */
|
||||
vec2 LT(){ return vec2(cos(TAU*u_phase), sin(TAU*u_phase)) * u_travel; }
|
||||
vec2 SO(){ return vec2(hash11(u_seed*0.137 + 0.731)*61.7, hash11(u_seed*0.213 + 7.0)*47.3); }
|
||||
|
||||
/* ---------------- color ---------------- */
|
||||
|
||||
vec3 palette(float t){
|
||||
t = clamp(t, 0.0, 1.0);
|
||||
float x = t*3.0;
|
||||
vec3 c = mix(u_c1, u_c2, smoothstep(0.0,1.0,x));
|
||||
c = mix(c, u_c3, smoothstep(1.0,2.0,x));
|
||||
c = mix(c, u_c4, smoothstep(2.0,3.0,x));
|
||||
return c;
|
||||
}
|
||||
vec3 paletteCyc(float t){
|
||||
t = fract(t);
|
||||
float x = t*4.0;
|
||||
vec3 c = mix(u_c1, u_c2, smoothstep(0.0,1.0,x));
|
||||
c = mix(c, u_c3, smoothstep(1.0,2.0,x));
|
||||
c = mix(c, u_c4, smoothstep(2.0,3.0,x));
|
||||
c = mix(c, u_c1, smoothstep(3.0,4.0,x));
|
||||
return c;
|
||||
}
|
||||
|
||||
vec3 hueRotate(vec3 c, float deg){
|
||||
float a = deg*PI/180.0;
|
||||
float cs = cos(a), sn = sin(a);
|
||||
mat3 m = mat3(
|
||||
0.299+0.701*cs+0.168*sn, 0.587-0.587*cs+0.330*sn, 0.114-0.114*cs-0.497*sn,
|
||||
0.299-0.299*cs-0.328*sn, 0.587+0.413*cs+0.035*sn, 0.114-0.114*cs+0.292*sn,
|
||||
0.299-0.300*cs+1.250*sn, 0.587-0.588*cs-1.050*sn, 0.114+0.886*cs-0.203*sn);
|
||||
return c*m;
|
||||
}
|
||||
|
||||
/* ---------------- shared coords ---------------- */
|
||||
|
||||
vec2 toP(vec2 uv){
|
||||
float asp = u_res.x/u_res.y;
|
||||
vec2 p = (uv - 0.5) * vec2(asp, 1.0) * (3.0/max(u_scale, 0.15));
|
||||
p.x *= mix(1.0, 0.38, clamp(u_stretch, 0.0, 1.0));
|
||||
p.y *= mix(1.0, 0.38, clamp(-u_stretch, 0.0, 1.0));
|
||||
return p;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 0 — LIQUID CHROME
|
||||
========================================================= */
|
||||
|
||||
float chromeH(vec2 p, vec2 w){
|
||||
vec2 so = SO(), lt = LT();
|
||||
return fbm((p + w)*0.85 + so*0.5 + u_flow*0.6*lt);
|
||||
}
|
||||
|
||||
vec3 sceneChrome(vec2 uv){
|
||||
vec2 p = toP(uv);
|
||||
p.x *= 0.48;
|
||||
vec2 so = SO(), lt = LT();
|
||||
|
||||
/* slow warp field, computed once and reused for all height taps */
|
||||
vec2 w = u_warp*0.9*vec2(
|
||||
fbm(p*0.5 + so + lt) - 0.5,
|
||||
fbm(p*0.5 + so + 7.31 - lt) - 0.5) * 2.4;
|
||||
|
||||
float e = 0.06;
|
||||
float h = chromeH(p, w);
|
||||
float hx = chromeH(p + vec2(e,0.0), w);
|
||||
float hy = chromeH(p + vec2(0.0,e), w);
|
||||
float relief = 3.4 + u_warp*1.6;
|
||||
vec3 n = normalize(vec3(-(hx-h)/e*relief, -(hy-h)/e*relief, 1.0));
|
||||
|
||||
float la = u_lightAngle*PI/180.0;
|
||||
vec3 L = normalize(vec3(cos(la), sin(la), 0.55));
|
||||
float diff = max(dot(n, L), 0.0);
|
||||
vec3 Hv = normalize(L + vec3(0.0,0.0,1.0));
|
||||
float spec = pow(max(dot(n, Hv), 0.0), u_gloss);
|
||||
float spec2 = pow(max(dot(n, normalize(vec3(-L.xy, 0.9))), 0.0), u_gloss*0.45);
|
||||
float fres = pow(1.0 - max(n.z, 0.0), 2.4);
|
||||
|
||||
vec3 alb = palette(clamp(h*1.1 + u_irid*n.x*0.7, 0.0, 1.0));
|
||||
vec3 alb2 = palette(clamp(0.55 - n.x*0.7 + h*0.25, 0.0, 1.0));
|
||||
|
||||
/* dark metal base; nearly all color arrives via the lights */
|
||||
vec3 col = u_bg*(0.55 + 0.45*diff);
|
||||
col += alb * pow(diff, 2.4) * 0.30;
|
||||
col += alb * spec * u_light * 3.0;
|
||||
col += alb2 * spec2 * u_light * 1.35;
|
||||
col += palette(clamp(fres*0.85 + u_irid*n.y*0.4, 0.0, 1.0)) * fres * u_light * 0.55;
|
||||
col += vec3(1.0) * pow(spec, 3.0) * u_light * 0.5;
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 1 — SILK RIBBONS
|
||||
========================================================= */
|
||||
|
||||
vec3 sceneSilk(vec2 uv){
|
||||
vec2 p = toP(uv);
|
||||
vec2 so = SO(), lt = LT();
|
||||
p = rot(-0.30 + 0.6*(hash11(u_seed*0.31+3.0)-0.5)) * p;
|
||||
|
||||
/* one smooth guide curve: low-frequency two-octave noise */
|
||||
vec2 wq = p*vec2(0.42, 0.50) + so + lt*0.55;
|
||||
float wave = vnoise(wq)*0.70 + vnoise(wq*2.13 + 5.0)*0.30;
|
||||
|
||||
float freq = u_lines*0.16;
|
||||
float tt = p.y*freq + (wave-0.5)*(4.5 + u_warp*3.5) + p.x*0.30;
|
||||
|
||||
float ft = fract(tt)-0.5; /* signed position across strand */
|
||||
float band = abs(ft)*2.0;
|
||||
float prof = sqrt(max(1.0-band*band, 0.0));
|
||||
|
||||
/* true cylinder normal across the strand */
|
||||
vec3 n = normalize(vec3(0.35*(wave-0.5), ft*2.0, max(prof, 0.05)));
|
||||
|
||||
float la = u_lightAngle*PI/180.0;
|
||||
vec3 L = normalize(vec3(cos(la), sin(la), 0.62));
|
||||
float diff = max(dot(n, L), 0.0);
|
||||
float spec = pow(max(dot(n, normalize(L+vec3(0,0,1))), 0.0), u_gloss);
|
||||
|
||||
float id = hash11(floor(tt)*7.77 + hash11(u_seed*0.171)*43.0);
|
||||
vec3 alb = paletteCyc(id*0.97 + wave*0.22 + u_irid*0.25*n.y);
|
||||
|
||||
vec3 col = alb*(0.05 + 0.95*pow(diff, 1.7));
|
||||
col += alb * spec * u_light * 1.9;
|
||||
col += vec3(1.0) * pow(spec, 2.5) * u_light * 0.6;
|
||||
col *= 0.45 + 0.55*prof; /* crevice shadow between strands */
|
||||
|
||||
float env = smoothstep(1.8, 0.55, abs(p.y*0.7 + (wave-0.5)*3.4));
|
||||
return mix(u_bg, col, env);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 2 — SOFT BLOOM
|
||||
========================================================= */
|
||||
|
||||
vec3 blobField(vec2 p, float warpAmt){
|
||||
vec2 so = SO();
|
||||
p += warpAmt*0.55*vec2(fbm(p*0.8+so)-0.5, fbm(p*0.8-so)-0.5)*2.0;
|
||||
vec3 col = u_bg;
|
||||
for (int i = 0; i < 5; i++){
|
||||
float fi = float(i);
|
||||
vec2 hc = hash22(vec2(fi*3.17, u_seed*0.731 + fi));
|
||||
vec2 base = (hc - 0.5)*vec2(2.2, 1.6);
|
||||
float orbR = 0.18 + 0.4*hash11(u_seed*0.117 + fi*9.1);
|
||||
float ph = u_phase + hash11(fi + u_seed*0.291);
|
||||
float dir = hash11(fi*5.0 + u_seed*0.49) > 0.5 ? 1.0 : -1.0;
|
||||
vec2 pos = base + orbR*u_travel*vec2(cos(TAU*ph*dir), sin(TAU*ph*dir));
|
||||
float rad = (0.45 + 0.6*hash11(fi*2.3 + u_seed*0.371 + 4.0)) * u_soft;
|
||||
float d = length(p - pos);
|
||||
float g = exp(-(d*d)/(rad*rad));
|
||||
/* spread blob hues across the whole palette so c1..c4 all show up */
|
||||
vec3 bc = palette(fract(fi*0.249 + hash11(fi + u_seed*0.523)*0.18));
|
||||
col = mix(col, bc, g*0.92);
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
vec3 sceneBloom(vec2 uv){
|
||||
return blobField(toP(uv), u_warp);
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 3 — AURA RINGS
|
||||
========================================================= */
|
||||
|
||||
vec3 sceneAura(vec2 uv){
|
||||
vec2 p = toP(uv);
|
||||
vec2 so = SO();
|
||||
vec2 c = (hash22(vec2(u_seed*0.37, 8.8)) - 0.5)*vec2(0.5, 0.6);
|
||||
vec2 d2 = p - c;
|
||||
float ang = atan(d2.y, d2.x);
|
||||
float d = length(d2);
|
||||
d += (0.06 + 0.08*u_warp)*fbm(vec2(ang*1.2, d*1.4) + so + LT()*0.5)
|
||||
* smoothstep(0.0, 0.3, d) - 0.05; /* fade wobble near center */
|
||||
d += 0.045*u_travel*sin(TAU*u_phase);
|
||||
|
||||
float t = pow(max(d*0.66, 0.0), mix(1.55, 0.8, clamp(u_soft*0.65, 0.0, 1.0)));
|
||||
|
||||
/* gentle radial ramp: palette through the middle, bg at both poles */
|
||||
vec3 col = palette(smoothstep(0.04, 0.96, t));
|
||||
col = mix(col, u_bg, smoothstep(0.68, 1.18, t));
|
||||
col = mix(col, mix(u_bg, vec3(1.0), 0.5), smoothstep(0.26, 0.0, t)*0.45);
|
||||
/* one soft luminous ring accent */
|
||||
float ring = exp(-pow((t - 0.46)*4.6, 2.0));
|
||||
col = mix(col, col*1.18 + 0.06, ring*0.5);
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 4 — LIGHT RAYS
|
||||
========================================================= */
|
||||
|
||||
vec3 sceneRays(vec2 uv){
|
||||
vec2 p = toP(uv);
|
||||
vec2 so = SO();
|
||||
vec2 O = vec2((hash11(u_seed+1.7)-0.5)*0.8, 1.9);
|
||||
vec2 dir = p - O;
|
||||
float ang = atan(dir.x, -dir.y);
|
||||
float r = length(dir);
|
||||
|
||||
float beams = fbm(vec2(ang*(2.0 + u_lines*0.12), 0.0) + so + LT()*0.5);
|
||||
beams = pow(clamp(beams*1.25, 0.0, 1.0), 2.0 + u_warp*2.0);
|
||||
|
||||
float fall = smoothstep(3.4, 0.7, r);
|
||||
float glowB = beams*fall;
|
||||
|
||||
vec3 col = u_bg;
|
||||
vec3 beamCol = palette(clamp(0.85 - glowB*0.9, 0.0, 1.0));
|
||||
col = mix(col, beamCol, clamp(glowB*1.7, 0.0, 1.0));
|
||||
col = mix(col, palette(0.92), smoothstep(1.2, 3.2, r)*0.85);
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 5 — HALFTONE
|
||||
========================================================= */
|
||||
|
||||
vec3 sceneHalftone(vec2 uv){
|
||||
float asp = u_res.x/u_res.y;
|
||||
vec2 so = SO(), lt = LT();
|
||||
vec2 guv = uv*vec2(asp,1.0)*u_cell*0.55;
|
||||
vec2 gp = floor(guv);
|
||||
vec2 gf = fract(guv)-0.5;
|
||||
vec2 cuv = (gp+0.5)/(u_cell*0.55)/vec2(asp,1.0);
|
||||
vec2 cp = toP(cuv);
|
||||
|
||||
vec2 q = cp + u_warp*0.9*vec2(fbm(cp*0.7+so+lt)-0.5, fbm(cp*0.7-so-lt)-0.5)*2.0;
|
||||
float f = fbm(q + so);
|
||||
f = smoothstep(0.30, 0.80, f); /* large breathing-room voids */
|
||||
|
||||
float radius = sqrt(f)*0.62;
|
||||
float dotm = smoothstep(radius, radius-0.12, length(gf));
|
||||
float hueF = fbm(q*0.55 + so + 31.7);
|
||||
vec3 ink = palette(clamp(hueF*1.5 - 0.22, 0.0, 1.0));
|
||||
return mix(u_bg, ink, dotm*(0.30 + 0.70*f));
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 6 — DATA GLYPHS
|
||||
========================================================= */
|
||||
|
||||
const int GLYPHS[8] = int[8](31599, 11415, 29330, 31727, 1488, 448, 128, 9362);
|
||||
|
||||
vec3 sceneGlyphs(vec2 uv){
|
||||
float asp = u_res.x/u_res.y;
|
||||
vec2 so = SO(), lt = LT();
|
||||
vec2 guv = uv*vec2(asp,1.0)*vec2(u_cell*0.5, u_cell*0.5/1.55);
|
||||
vec2 gp = floor(guv);
|
||||
vec2 gf = fract(guv);
|
||||
vec2 cuv = (gp+0.5)/vec2(u_cell*0.5, u_cell*0.5/1.55)/vec2(asp,1.0);
|
||||
vec2 cp = toP(cuv);
|
||||
|
||||
float b = fbm(cp*0.8 + so + lt);
|
||||
b = pow(clamp(b*1.65 - 0.30, 0.0, 1.0), 2.3); /* deep dark voids */
|
||||
/* columnar shimmer, quantized so it still loops */
|
||||
float step8 = floor(u_phase*8.0);
|
||||
b *= 0.55 + 0.9*hash21(vec2(gp.x*1.31, step8));
|
||||
b += 0.018; /* faint base field so the grid breathes */
|
||||
|
||||
float swap = hash21(gp + vec2(floor(u_phase*8.0)*13.0, u_seed));
|
||||
int gi = int(floor(swap*7.999));
|
||||
int glyph = GLYPHS[gi];
|
||||
|
||||
vec2 cell = gf;
|
||||
cell = (cell - 0.5)/0.74 + 0.5; /* padding */
|
||||
vec3 col = u_bg;
|
||||
if (cell.x > 0.0 && cell.x < 1.0 && cell.y > 0.0 && cell.y < 1.0){
|
||||
int px = int(floor(cell.x*3.0));
|
||||
int py = int(floor((1.0-cell.y)*5.0));
|
||||
int bit = (glyph >> ((4-py)*3 + (2-px))) & 1;
|
||||
vec3 ink = palette(clamp(b*1.3, 0.0, 1.0));
|
||||
col += ink * float(bit) * b * 2.2;
|
||||
}
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 7 — REEDED GLASS
|
||||
========================================================= */
|
||||
|
||||
/* bold smooth color regions for the glass to refract:
|
||||
a seeded diagonal palette sweep + noise, grounded by bg in the lows */
|
||||
vec3 boldField(vec2 p){
|
||||
vec2 so = SO();
|
||||
float f1 = fbm(p*0.40 + so + LT()*0.7);
|
||||
float ang = TAU*hash11(u_seed*0.071 + 2.0);
|
||||
float diag = 0.5 + 0.30*(cos(ang)*p.x + sin(ang)*p.y);
|
||||
vec3 col = palette(clamp(diag + (f1-0.5)*1.5, 0.0, 1.0));
|
||||
col = mix(col, u_bg, smoothstep(0.60, 0.18, f1)*0.85);
|
||||
return col;
|
||||
}
|
||||
|
||||
vec3 sceneReeded(vec2 uv){
|
||||
float nx = uv.x * u_lines*0.55;
|
||||
float ci = floor(nx);
|
||||
float lx = fract(nx)-0.5;
|
||||
|
||||
float srcX = (ci + 0.5 + lx*0.20 + sin(lx*PI)*0.34*u_warp) / (u_lines*0.55);
|
||||
vec2 suv = vec2(srcX, uv.y);
|
||||
vec3 col = boldField(toP(suv)*0.8);
|
||||
|
||||
float shade = 0.86 + 0.26*cos(lx*PI);
|
||||
float edge = smoothstep(0.5, 0.465, abs(lx));
|
||||
col *= mix(0.62, shade, edge);
|
||||
col += vec3(1.0)*pow(max(cos(lx*PI), 0.0), 30.0)*0.12*u_light;
|
||||
return col;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MODE 8 — PIXEL BLOOM
|
||||
========================================================= */
|
||||
|
||||
vec3 sceneMosaic(vec2 uv){
|
||||
float asp = u_res.x/u_res.y;
|
||||
float cells = max(u_cell*0.22, 3.0);
|
||||
vec2 g = vec2(cells*asp, cells);
|
||||
vec2 q = (floor(uv*g)+0.5)/g;
|
||||
vec3 col = blobField(toP(q), u_warp*0.5);
|
||||
float h = hash21(floor(uv*g)+u_seed);
|
||||
col *= 0.97 + 0.05*h;
|
||||
return col;
|
||||
}
|
||||
|
||||
/* ---------------- dispatch + post ---------------- */
|
||||
|
||||
vec3 scene(vec2 uv){
|
||||
if (u_mode == 0) return sceneChrome(uv);
|
||||
if (u_mode == 1) return sceneSilk(uv);
|
||||
if (u_mode == 2) return sceneBloom(uv);
|
||||
if (u_mode == 3) return sceneAura(uv);
|
||||
if (u_mode == 4) return sceneRays(uv);
|
||||
if (u_mode == 5) return sceneHalftone(uv);
|
||||
if (u_mode == 6) return sceneGlyphs(uv);
|
||||
if (u_mode == 7) return sceneReeded(uv);
|
||||
return sceneMosaic(uv);
|
||||
}
|
||||
|
||||
void main(){
|
||||
vec2 uv = gl_FragCoord.xy/u_res;
|
||||
vec3 col;
|
||||
|
||||
if (u_ca > 0.004){
|
||||
vec2 off = (uv-0.5)*u_ca*0.016;
|
||||
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
|
||||
} else {
|
||||
col = scene(uv);
|
||||
}
|
||||
|
||||
/* glow: soft luminance knee */
|
||||
float lum = dot(col, vec3(0.299,0.587,0.114));
|
||||
col += u_glow * col * lum * 0.85;
|
||||
|
||||
/* grade */
|
||||
if (abs(u_hue) > 0.5) col = hueRotate(col, u_hue);
|
||||
float l2 = dot(col, vec3(0.299,0.587,0.114));
|
||||
col = mix(vec3(l2), col, u_sat);
|
||||
col *= u_exposure;
|
||||
col = (col - 0.5)*u_contrast + 0.5;
|
||||
|
||||
/* vignette */
|
||||
float asp = u_res.x/u_res.y;
|
||||
vec2 vc = (uv-0.5)*vec2(asp,1.0);
|
||||
col *= 1.0 - u_vig*smoothstep(0.35, 1.05, length(vc));
|
||||
|
||||
/* film grain — quantized steps keep the loop perfect */
|
||||
float gstep = floor(u_phase*24.0);
|
||||
float gr = hash21(gl_FragCoord.xy*0.71 + vec2(gstep*3.1, gstep*7.7));
|
||||
col += (gr-0.5)*u_grain*0.55;
|
||||
|
||||
fragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,217 @@
|
||||
/* Small builder library for the control rail. main.js wires it to state. */
|
||||
|
||||
var UI = (function () {
|
||||
|
||||
var DICE_SVG = '<svg viewBox="0 0 16 16"><rect x="1.5" y="1.5" width="13" height="13" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="5.4" cy="5.4" r="1.25" fill="currentColor"/><circle cx="10.6" cy="10.6" r="1.25" fill="currentColor"/><circle cx="10.6" cy="5.4" r="1.25" fill="currentColor"/><circle cx="5.4" cy="10.6" r="1.25" fill="currentColor"/></svg>';
|
||||
|
||||
function el(tag, cls, parent) {
|
||||
var e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (parent) parent.appendChild(e);
|
||||
return e;
|
||||
}
|
||||
|
||||
function section(rail, title, onDice) {
|
||||
var sec = el("div", "rail-section", rail);
|
||||
var head = el("div", "section-head", sec);
|
||||
var t = el("div", "section-title", head);
|
||||
t.textContent = title;
|
||||
if (onDice) {
|
||||
var d = el("button", "section-dice", head);
|
||||
d.innerHTML = DICE_SVG;
|
||||
d.title = "Randomize " + title.toLowerCase();
|
||||
d.addEventListener("click", onDice);
|
||||
}
|
||||
return sec;
|
||||
}
|
||||
|
||||
function fmtDefault(v) { return (+v).toFixed(2); }
|
||||
|
||||
function slider(parent, opts) {
|
||||
var wrap = el("div", "ctl", parent);
|
||||
var head = el("div", "ctl-head", wrap);
|
||||
var lab = el("span", "ctl-label", head);
|
||||
lab.textContent = opts.label;
|
||||
var val = el("span", "ctl-value", head);
|
||||
var input = el("input", null, wrap);
|
||||
input.type = "range";
|
||||
input.min = opts.min; input.max = opts.max; input.step = opts.step;
|
||||
var fmt = opts.fmt || fmtDefault;
|
||||
|
||||
function paint(v) {
|
||||
var pct = ((v - opts.min) / (opts.max - opts.min)) * 100;
|
||||
input.style.setProperty("--fill", pct + "%");
|
||||
val.textContent = fmt(v);
|
||||
}
|
||||
input.addEventListener("input", function () {
|
||||
var v = parseFloat(input.value);
|
||||
paint(v);
|
||||
opts.set(v);
|
||||
});
|
||||
function refresh() {
|
||||
var v = opts.get();
|
||||
input.value = v;
|
||||
paint(v);
|
||||
}
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function selectRow(parent, opts) {
|
||||
var row = el("div", "field-row", parent);
|
||||
var lab = el("span", "ctl-label", row);
|
||||
lab.textContent = opts.label;
|
||||
var sel = el("select", null, row);
|
||||
opts.options.forEach(function (o) {
|
||||
var op = el("option", null, sel);
|
||||
op.value = o[0]; op.textContent = o[1];
|
||||
});
|
||||
sel.addEventListener("change", function () { opts.set(sel.value); });
|
||||
function refresh() { sel.value = opts.get(); }
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function toggleRow(parent, opts) {
|
||||
var row = el("div", "toggle-row", parent);
|
||||
var lab = el("span", "ctl-label", row);
|
||||
lab.textContent = opts.label;
|
||||
var tg = el("button", "toggle", row);
|
||||
tg.setAttribute("role", "switch");
|
||||
tg.addEventListener("click", function () { opts.set(!opts.get()); refresh(); });
|
||||
function refresh() { tg.classList.toggle("on", !!opts.get()); }
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function colorSwatches(parent, defs, get, set) {
|
||||
var row = el("div", "swatch-row", parent);
|
||||
var refreshers = [];
|
||||
defs.forEach(function (d, i) {
|
||||
if (d.gap) { el("div", "swatch-gap", row); return; }
|
||||
var wrap = el("div", "swatch-wrap", row);
|
||||
var sw = el("div", "swatch", wrap);
|
||||
var input = el("input", null, sw);
|
||||
input.type = "color";
|
||||
input.addEventListener("input", function () {
|
||||
sw.style.background = input.value;
|
||||
set(d.key, input.value);
|
||||
});
|
||||
var lab = el("div", "swatch-label", wrap);
|
||||
lab.textContent = d.label;
|
||||
refreshers.push(function () {
|
||||
var v = get(d.key);
|
||||
input.value = v;
|
||||
sw.style.background = v;
|
||||
});
|
||||
});
|
||||
function refresh() { refreshers.forEach(function (r) { r(); }); }
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function presetChips(parent, palettes, onPick) {
|
||||
var row = el("div", "preset-row", parent);
|
||||
var chips = [];
|
||||
palettes.forEach(function (pal, i) {
|
||||
var chip = el("button", "preset-chip", row);
|
||||
chip.title = pal.name;
|
||||
pal.colors.forEach(function (c) {
|
||||
var b = el("i", null, chip);
|
||||
b.style.background = c;
|
||||
});
|
||||
chip.addEventListener("click", function () { onPick(i); });
|
||||
chips.push(chip);
|
||||
});
|
||||
return function setActive(idx) {
|
||||
chips.forEach(function (c, i) { c.classList.toggle("active", i === idx); });
|
||||
};
|
||||
}
|
||||
|
||||
function modeGrid(parent, modes, get, set) {
|
||||
var grid = el("div", "mode-grid", parent);
|
||||
var cards = [];
|
||||
modes.forEach(function (m) {
|
||||
var card = el("button", "mode-card", grid);
|
||||
card.innerHTML = m.icon + "<span>" + m.name + "</span>";
|
||||
card.title = m.full;
|
||||
card.addEventListener("click", function () { set(m.id); refresh(); });
|
||||
cards.push(card);
|
||||
});
|
||||
function refresh() {
|
||||
var cur = get();
|
||||
cards.forEach(function (c, i) { c.classList.toggle("active", modes[i].id === cur); });
|
||||
}
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function lockRow(parent, opts) {
|
||||
var lab = el("label", "style-lock", parent);
|
||||
var input = el("input", null, lab);
|
||||
input.type = "checkbox";
|
||||
var box = el("span", "lock-box", lab);
|
||||
box.innerHTML = '<svg viewBox="0 0 10 10"><path d="M1.5 5.5 L4 8 L8.5 2.5" fill="none" stroke="#0a0a0c" stroke-width="2"/></svg>';
|
||||
var txt = el("span", null, lab);
|
||||
txt.textContent = opts.label;
|
||||
input.addEventListener("change", function () { opts.set(input.checked); });
|
||||
function refresh() { input.checked = !!opts.get(); }
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function seedRow(parent, opts) {
|
||||
var row = el("div", "seed-row ctl", parent);
|
||||
var input = el("input", "num-input mono", row);
|
||||
input.type = "number"; input.min = 0; input.max = 9999;
|
||||
input.addEventListener("change", function () {
|
||||
opts.set(Math.max(0, Math.min(9999, parseInt(input.value, 10) || 0)));
|
||||
});
|
||||
var btn = el("button", "mini-btn", row);
|
||||
btn.innerHTML = DICE_SVG + "New seed";
|
||||
btn.addEventListener("click", opts.onDice);
|
||||
function refresh() { input.value = Math.round(opts.get()); }
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
function exportButton(parent, label, ext, svg, onClick) {
|
||||
var b = el("button", "export-btn", parent);
|
||||
b.innerHTML = svg + "<span>" + label + '</span><span class="ext">' + ext + "</span>";
|
||||
b.addEventListener("click", onClick);
|
||||
return b;
|
||||
}
|
||||
|
||||
function segmented(container, options, get, set) {
|
||||
options.forEach(function (o) {
|
||||
var b = el("button", null, container);
|
||||
b.textContent = o[1];
|
||||
b.dataset.v = o[0];
|
||||
b.addEventListener("click", function () { set(o[0]); refresh(); });
|
||||
});
|
||||
function refresh() {
|
||||
var cur = String(get());
|
||||
Array.prototype.forEach.call(container.children, function (b) {
|
||||
b.classList.toggle("active", b.dataset.v === cur);
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
return refresh;
|
||||
}
|
||||
|
||||
var toastTimer = null;
|
||||
function toast(msg) {
|
||||
var t = document.getElementById("toast");
|
||||
t.textContent = msg;
|
||||
t.hidden = false;
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { t.hidden = true; }, 2600);
|
||||
}
|
||||
|
||||
return {
|
||||
el: el, section: section, slider: slider, selectRow: selectRow,
|
||||
toggleRow: toggleRow, colorSwatches: colorSwatches, presetChips: presetChips,
|
||||
modeGrid: modeGrid, lockRow: lockRow, seedRow: seedRow,
|
||||
exportButton: exportButton, segmented: segmented, toast: toast
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user