Files
mangavalkandClaude Opus 5.5 a18f081e0a Colorduino plasma demo, matrix test and browser simulator
- ColorduinoPlasma sketch and Colorduino library v1.2.6 (lincomatic @ fff7bd3)
- ColorduinoMatrixTest hardware test sketch
- web/index.html: bit-accurate port of the plasma with large resolutions
- prebuilt 16 MHz firmware (with and without Optiboot)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 22:28:12 +01:00

317 lines
12 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>