Files

316 lines
12 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Colorduino Plasma</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0b0b0d; color: #ddd; font: 14px/1.4 system-ui, sans-serif;
display: flex; flex-direction: column; height: 100vh; }
header { display: flex; flex-wrap: wrap; gap: 14px 18px; align-items: end; padding: 10px 14px;
background: #16161a; border-bottom: 1px solid #2a2a30; }
label { display: flex; flex-direction: column; gap: 3px; font-size: 12px; color: #9a9aa5; }
label.inline { flex-direction: row; align-items: center; gap: 6px; color: #ddd; font-size: 13px; }
input[type=number] { width: 78px; }
input, select, button { font: inherit; background: #222228; color: #eee; border: 1px solid #3a3a44;
border-radius: 6px; padding: 4px 8px; }
button { cursor: pointer; }
button:hover { background: #2d2d35; }
.group { display: flex; gap: 6px; align-items: end; }
#stage { flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; padding: 12px; }
canvas { image-rendering: pixelated; }
footer { padding: 6px 14px; font-size: 12px; color: #777; background: #16161a; border-top: 1px solid #2a2a30;
display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.val { color: #ccc; font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<header>
<div class="group">
<label>Preset
<select id="preset">
<option value="8,8,1">8×8 Colorduino</option>
<option value="8,8,32">8×8 area, 32× detail</option>
<option value="32,32,1">32×32</option>
<option value="64,64,1">64×64</option>
<option value="128,72,1">128×72</option>
<option value="256,256,1">256×256 field</option>
<option value="64,36,8">64×36, 8× detail</option>
<option value="">Custom</option>
</select>
</label>
</div>
<label>Width (px)<input id="w" type="number" min="1" max="1024" value="8"></label>
<label>Height (px)<input id="h" type="number" min="1" max="1024" value="8"></label>
<label>Detail
<select id="detail">
<option>1</option><option>2</option><option>4</option><option>8</option>
<option>16</option><option>32</option><option>64</option>
</select>
</label>
<label>Speed (frames/s) <span class="val" id="fpsv"></span>
<input id="fps" type="range" min="1" max="60" value="12">
</label>
<label>Colour
<select id="cmode">
<option value="led">LED light (gamma table → screen)</option>
<option value="raw">Raw PWM values</option>
<option value="nogamma">Without gamma table</option>
</select>
</label>
<label>Rotate
<select id="rot"><option>0</option><option>90</option><option>180</option><option>270</option></select>
</label>
<label class="inline"><input id="led" type="checkbox" checked> LED dots</label>
<label class="inline"><input id="outline" type="checkbox"> Mark 8×8 area</label>
<div class="group">
<button id="play">Pause</button>
<button id="step">Step</button>
<button id="reset">Reset</button>
<button id="fs">Fullscreen</button>
</div>
</header>
<div id="stage"><canvas id="c"></canvas></div>
<footer>
<span>paletteShift <span class="val" id="shift"></span> · samples <span class="val" id="samples"></span> · render <span class="val" id="ms"></span></span>
<span>Space = pause · → = step · F = fullscreen</span>
</footer>
<script>
"use strict";
/* Port of ColorduinoPlasma (lincomatic Colorduino library), maths matched to
the ATmega328 build: 32-bit float everywhere (Math.fround), float->unsigned
char conversion wraps negatives (avr-libc __fixunssfsi), gamma8 table. */
const F = Math.fround;
const PlasmaScaling = F(10.0);
const START_SHIFT = 128000;
const gamma8 = [
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,
1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,4,4,4,4,4,5,5,5,
5,6,6,6,6,7,7,7,7,8,8,8,9,9,9,10,10,10,11,11,11,12,12,13,13,13,14,14,15,15,16,16,
17,17,18,18,19,19,20,20,21,21,22,22,23,24,24,25,25,26,27,27,28,29,29,30,31,32,32,33,34,35,35,36,
37,38,39,39,40,41,42,43,44,45,46,47,48,49,50,50,51,52,54,55,56,57,58,59,60,61,62,63,64,66,67,68,
69,70,72,73,74,75,77,78,79,81,82,83,85,86,87,89,90,92,93,95,96,98,99,101,102,104,105,107,109,110,112,114,
115,117,119,120,122,124,126,127,129,131,133,135,137,138,140,142,144,146,148,150,152,154,156,158,160,162,164,167,169,171,173,175,
177,180,182,184,186,189,191,193,196,198,200,203,205,208,210,213,215,218,220,223,225,228,231,233,236,239,241,244,247,249,252,255
];
/* HSVtoRGB from the sketch, s = v = 255. Returns pre-gamma 0..255 values. */
function hsvToRgb(hByte) {
let h = F(hByte / 256.0);
const s = F(255 / 256.0), v = F(255 / 256.0);
h = F(h * 6.0);
const i = Math.floor(h);
const f = F(h - i);
const p = F(v * F(1.0 - s));
const q = F(v * F(1.0 - F(s * f)));
const t = F(v * F(1.0 - F(s * F(1.0 - f))));
let r, g, b;
switch (i) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q; break;
default: r = g = b = 0;
}
return [Math.trunc(F(r * 255.0)), Math.trunc(F(g * 255.0)), Math.trunc(F(b * 255.0))];
}
function linToSrgb(l) {
const s = l <= 0.0031308 ? 12.92 * l : 1.055 * Math.pow(l, 1 / 2.4) - 0.055;
return Math.round(Math.min(1, Math.max(0, s)) * 255);
}
/* The colour depends only on the 8-bit hue, so build a 256-entry palette. */
const palettes = {};
for (const mode of ["led", "raw", "nogamma"]) {
const pal = new Uint32Array(256);
for (let hb = 0; hb < 256; hb++) {
let [r, g, b] = hsvToRgb(hb);
if (mode !== "nogamma") { r = gamma8[r]; g = gamma8[g]; b = gamma8[b]; }
if (mode === "led") { // LED light output is linear in PWM duty
r = linToSrgb(r / 255); g = linToSrgb(g / 255); b = linToSrgb(b / 255);
}
pal[hb] = (255 << 24) | (b << 16) | (g << 8) | r; // little-endian RGBA
}
palettes[mode] = pal;
}
/* ---------- state & caches ---------- */
const $ = id => document.getElementById(id);
const cv = $("c"), ctx = cv.getContext("2d");
const src = document.createElement("canvas"), sctx = src.getContext("2d");
let W = 8, H = 8, D = 1, SW = 8, SH = 8; // logical size, detail, sample grid
let xs, ys, sDist, img, buf32; // per-config caches
let shift = START_SHIFT, playing = true, lastT = 0, acc = 0;
let mask = null, cell = 1, rotation = 0;
function setup() {
W = clampInt($("w").value, 1, 1024);
H = clampInt($("h").value, 1, 1024);
D = clampInt($("detail").value, 1, 64);
// keep the sample count sane
while (W * D * H * D > 2_000_000 && D > 1) D >>= 1;
$("detail").value = String(D);
SW = W * D; SH = H * D;
xs = new Float32Array(SW); ys = new Float32Array(SH);
for (let i = 0; i < SW; i++) xs[i] = F(i / D);
for (let j = 0; j < SH; j++) ys[j] = F(j / D);
// static part: sin(dist(x,y,64,64)/10) and sin(dist(x,y,192,100)/10)
sDist = new Float32Array(SW * SH * 2);
for (let j = 0, k = 0; j < SH; j++) {
const y = ys[j];
for (let i = 0; i < SW; i++, k += 2) {
const x = xs[i];
sDist[k] = F(Math.sin(F(dist(x, y, 64.0, 64.0) / PlasmaScaling)));
sDist[k + 1] = F(Math.sin(F(dist(x, y, 192.0, 100.0) / PlasmaScaling)));
}
}
src.width = SW; src.height = SH;
img = sctx.createImageData(SW, SH);
buf32 = new Uint32Array(img.data.buffer);
$("samples").textContent = `${SW}×${SH}`;
layout();
}
function dist(a, b, c, d) {
const dx = F(c - a), dy = F(d - b);
return F(Math.sqrt(F(F(dx * dx) + F(dy * dy))));
}
function clampInt(v, lo, hi) { v = parseInt(v, 10); return isNaN(v) ? lo : Math.max(lo, Math.min(hi, v)); }
/* ---------- plasma_morph() ---------- */
function computeFrame() {
const pal = palettes[$("cmode").value];
const s1 = new Float32Array(SW), s3 = new Float32Array(SH);
for (let i = 0; i < SW; i++) s1[i] = F(Math.sin(F(F(xs[i] + shift) / PlasmaScaling)));
const sh7 = F(shift / 7.0);
for (let j = 0; j < SH; j++) s3[j] = F(Math.sin(F(F(ys[j] + sh7) / PlasmaScaling)));
for (let j = 0, k = 0, p = 0; j < SH; j++) {
const c = s3[j];
for (let i = 0; i < SW; i++, k += 2, p++) {
const value = F(F(F(s1[i] + sDist[k]) + c) + sDist[k + 1]);
buf32[p] = pal[Math.trunc(F(value * 128)) & 0xff];
}
}
sctx.putImageData(img, 0, 0);
}
/* ---------- display ---------- */
function layout() {
rotation = parseInt($("rot").value, 10);
const rotated = rotation === 90 || rotation === 270;
const lw = rotated ? H : W, lh = rotated ? W : H;
const st = $("stage");
const availW = st.clientWidth - 24, availH = st.clientHeight - 24;
const dpr = window.devicePixelRatio || 1;
// cell = device pixels per logical pixel
cell = Math.max(1, Math.floor(Math.min(availW / lw, availH / lh) * dpr));
if (D > 1) cell = Math.max(cell, D); // detail needs room
cv.width = lw * cell; cv.height = lh * cell;
cv.style.width = (cv.width / dpr) + "px";
cv.style.height = (cv.height / dpr) + "px";
buildMask(lw, lh);
draw();
}
/* black sheet with round holes, overlaid when "LED dots" is on */
function buildMask(lw, lh) {
mask = null;
if (!$("led").checked || cell < 4) return;
mask = document.createElement("canvas");
mask.width = cv.width; mask.height = cv.height;
const m = mask.getContext("2d");
m.fillStyle = "#0b0b0d";
m.fillRect(0, 0, mask.width, mask.height);
m.globalCompositeOperation = "destination-out";
const r = cell * 0.40;
const tile = document.createElement("canvas");
tile.width = tile.height = cell;
const t = tile.getContext("2d");
t.beginPath(); t.arc(cell / 2, cell / 2, r, 0, Math.PI * 2); t.fill();
const pat = m.createPattern(tile, "repeat");
m.fillStyle = pat;
m.fillRect(0, 0, mask.width, mask.height);
}
function draw() {
if (!img) return;
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.fillStyle = "#000"; ctx.fillRect(0, 0, cv.width, cv.height);
ctx.translate(cv.width / 2, cv.height / 2);
ctx.rotate(rotation * Math.PI / 180);
const dw = W * cell, dh = H * cell;
ctx.drawImage(src, -dw / 2, -dh / 2, dw, dh);
if ($("outline").checked && (W > 8 || H > 8)) {
ctx.strokeStyle = "#fff"; ctx.lineWidth = Math.max(1, cell / 8);
ctx.setLineDash([cell / 2, cell / 3]);
ctx.strokeRect(-dw / 2, -dh / 2, Math.min(8, W) * cell, Math.min(8, H) * cell);
}
ctx.restore();
if (mask) ctx.drawImage(mask, 0, 0);
$("shift").textContent = shift;
}
function frame() {
const t0 = performance.now();
computeFrame();
draw();
$("ms").textContent = (performance.now() - t0).toFixed(1) + " ms";
shift++; // paletteShift++ after drawing, as in the sketch
}
function tick(t) {
const dt = Math.min(250, t - lastT); lastT = t;
if (playing) {
acc += dt;
const period = 1000 / parseInt($("fps").value, 10);
if (acc >= period) { acc %= period; frame(); }
}
requestAnimationFrame(tick);
}
/* ---------- UI ---------- */
function applyPreset() {
const v = $("preset").value;
if (!v) return;
const [w, h, d] = v.split(",");
$("w").value = w; $("h").value = h; $("detail").value = d;
$("led").checked = (d === "1"); // dots only make sense one sample per LED
setup(); frame();
}
$("preset").onchange = applyPreset;
for (const id of ["w", "h", "detail"]) $(id).onchange = () => { $("preset").value = ""; setup(); frame(); };
$("cmode").onchange = () => { shift--; frame(); };
$("rot").onchange = layout;
$("led").onchange = layout;
$("outline").onchange = draw;
$("fps").oninput = () => $("fpsv").textContent = $("fps").value;
$("play").onclick = () => { playing = !playing; $("play").textContent = playing ? "Pause" : "Play"; };
$("step").onclick = () => { playing = false; $("play").textContent = "Play"; frame(); };
$("reset").onclick = () => { shift = START_SHIFT; frame(); };
$("fs").onclick = () => document.fullscreenElement ? document.exitFullscreen() : document.documentElement.requestFullscreen();
window.addEventListener("resize", layout);
document.addEventListener("fullscreenchange", () => setTimeout(layout, 50));
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT" || e.target.tagName === "SELECT") return;
if (e.code === "Space") { e.preventDefault(); $("play").click(); }
else if (e.code === "ArrowRight") $("step").click();
else if (e.key === "f" || e.key === "F") $("fs").click();
});
$("fpsv").textContent = $("fps").value;
setup();
frame();
requestAnimationFrame(t => { lastT = t; tick(t); });
</script>
</body>
</html>