316 lines
12 KiB
HTML
316 lines
12 KiB
HTML
<!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>
|