790 lines
43 KiB
HTML
790 lines
43 KiB
HTML
<title>Hermes Scope Face</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&family=Saira+Condensed:wght@500;600;700&display=swap">
|
||
<style>
|
||
/* Layout: an instrument front panel. The CRT (the robot's 240x240 display) on the left, labelled controls on the right; stacks on phones. */
|
||
:root {
|
||
--panel: #d6dce2; /* cool instrument-grey front panel */
|
||
--panel-2: #c5cdd5; /* recessed control wells */
|
||
--ink: #1b2229;
|
||
--ink-2: #4d5965;
|
||
--rule: #a7b1bb;
|
||
--accent: #285a87; /* panel-print blue */
|
||
--lamp: #e8961e; /* indicator lamp amber */
|
||
--lamp-off: #8e979f;
|
||
--bezel: #23282e;
|
||
--bezel-edge: #3a4149;
|
||
--screen: #000000;
|
||
--focus: #285a87;
|
||
--font-label: "Saira Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
|
||
--font-body: "IBM Plex Sans", "Helvetica Neue", Arial, sans-serif;
|
||
--font-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root:not([data-theme="light"]) {
|
||
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
|
||
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
|
||
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
|
||
color-scheme: dark;
|
||
}
|
||
}
|
||
:root[data-theme="dark"] {
|
||
--panel: #1a2026; --panel-2: #232a31; --ink: #e2e7eb; --ink-2: #9aa5b0;
|
||
--rule: #37414b; --accent: #82b2dd; --lamp: #f0a43a; --lamp-off: #4a535c;
|
||
--bezel: #0f1215; --bezel-edge: #2c333a; --focus: #82b2dd;
|
||
color-scheme: dark;
|
||
}
|
||
|
||
body { background: var(--panel); color: var(--ink); font-family: var(--font-body); font-size: 15px; line-height: 1.5; }
|
||
.wrap { max-width: 1060px; margin: 0 auto; padding-inline: 20px; padding-block: 28px 40px; display: grid; gap: 28px; }
|
||
|
||
header { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 20px; border-bottom: 2px solid var(--ink); padding-bottom: 10px; }
|
||
h1 { margin: 0; font-family: var(--font-label); font-weight: 700; font-size: clamp(26px, 4vw, 34px); letter-spacing: .04em; text-transform: uppercase; line-height: 1.05; text-wrap: balance; }
|
||
h1 span { color: var(--accent); }
|
||
.model { font-family: var(--font-label); font-weight: 500; font-size: 14px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
|
||
|
||
.bench { display: grid; grid-template-columns: minmax(0, 520px) minmax(0, 1fr); gap: 28px; align-items: start; }
|
||
@media (max-width: 820px) { .bench { grid-template-columns: minmax(0, 1fr); } }
|
||
|
||
/* CRT */
|
||
.crt { background: var(--bezel); border-radius: 14px; padding: 20px 20px 14px; box-shadow: inset 0 0 0 1px var(--bezel-edge), 0 1px 0 var(--bezel-edge); display: grid; gap: 10px; }
|
||
.screen { position: relative; width: 100%; max-width: 480px; aspect-ratio: 1 / 1; margin: 0 auto; background: var(--screen); border-radius: 4px; overflow: hidden; box-shadow: 0 0 0 2px #000, inset 0 0 40px rgba(0,0,0,.9); }
|
||
.screen canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||
.screen.pixels canvas#face { image-rendering: pixelated; }
|
||
#grat { pointer-events: none; }
|
||
.crt-foot { display: flex; justify-content: space-between; gap: 12px; font-family: var(--font-label); font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: #8b96a1; }
|
||
.crt-foot b { font-weight: 600; color: #c3ccd4; }
|
||
|
||
/* Control panel */
|
||
.controls { display: grid; gap: 22px; min-width: 0; }
|
||
.group { display: grid; gap: 10px; }
|
||
.group-h { display: flex; align-items: center; gap: 10px; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .16em; text-transform: uppercase; color: var(--accent); }
|
||
.group-h::after { content: ""; flex: 1; height: 1px; background: var(--rule); }
|
||
|
||
.states { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 8px; }
|
||
@media (max-width: 460px) { .states { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||
.key { appearance: none; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 10px 6px 8px; display: grid; justify-items: center; gap: 6px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; box-shadow: 0 2px 0 var(--rule); transition: transform .06s, box-shadow .06s; }
|
||
.key:active { transform: translateY(2px); box-shadow: 0 0 0 var(--rule); }
|
||
.key .lamp { width: 9px; height: 9px; border-radius: 50%; background: var(--lamp-off); }
|
||
.key[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 8px var(--lamp); }
|
||
.key .num { font-family: var(--font-mono); font-size: 11px; font-weight: 400; letter-spacing: 0; color: var(--ink-2); }
|
||
.key:focus-visible, .seg button:focus-visible, input:focus-visible, .switch:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||
|
||
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; }
|
||
.switch { appearance: none; display: inline-flex; align-items: center; gap: 10px; border: 1px solid var(--rule); background: var(--panel-2); color: var(--ink); border-radius: 6px; padding: 7px 12px; cursor: pointer; font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .1em; text-transform: uppercase; }
|
||
.switch .lamp { width: 8px; height: 8px; border-radius: 50%; background: var(--lamp-off); }
|
||
.switch[aria-pressed="true"] .lamp { background: var(--lamp); box-shadow: 0 0 7px var(--lamp); }
|
||
|
||
.seg { display: inline-flex; border: 1px solid var(--rule); border-radius: 6px; overflow: hidden; background: var(--panel-2); }
|
||
.seg button { appearance: none; border: 0; border-right: 1px solid var(--rule); background: transparent; color: var(--ink-2); padding: 7px 11px; cursor: pointer; font-family: var(--font-mono); font-size: 13px; }
|
||
.seg button:last-child { border-right: 0; }
|
||
.seg button[aria-pressed="true"] { background: var(--ink); color: var(--panel); }
|
||
.seg-label { font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); min-width: 64px; }
|
||
|
||
.knobs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 22px; }
|
||
@media (max-width: 460px) { .knobs { grid-template-columns: minmax(0, 1fr); } }
|
||
.knob { display: grid; gap: 4px; min-width: 0; }
|
||
.knob label { display: flex; justify-content: space-between; gap: 8px; font-family: var(--font-label); font-weight: 600; font-size: 12px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-2); }
|
||
.knob output { font-family: var(--font-mono); font-weight: 500; font-size: 12px; letter-spacing: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||
input[type="range"] { width: 100%; accent-color: var(--accent); margin: 0; }
|
||
|
||
.readout { font-family: var(--font-mono); font-size: 12px; color: var(--ink-2); font-variant-numeric: tabular-nums; display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||
.readout b { color: var(--ink); font-weight: 500; }
|
||
|
||
/* Notes */
|
||
.notes { display: grid; gap: 12px; }
|
||
.map { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 8px 22px; margin: 0; max-width: 78ch; }
|
||
.map dt { font-family: var(--font-label); font-weight: 600; font-size: 13px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink); padding-top: 1px; }
|
||
.map dd { margin: 0; color: var(--ink-2); }
|
||
.map code, .notes code { font-family: var(--font-mono); font-size: 13px; color: var(--ink); }
|
||
.hint { color: var(--ink-2); font-size: 13px; margin: 0; }
|
||
kbd { font-family: var(--font-mono); font-size: 12px; border: 1px solid var(--rule); border-bottom-width: 2px; border-radius: 4px; padding: 0 5px; color: var(--ink); background: var(--panel-2); }
|
||
</style>
|
||
|
||
<div class="wrap">
|
||
<header>
|
||
<h1>Hermes <span>Scope</span> Face</h1>
|
||
<div class="model">Display prototype · BrainCraft 1.54″ TFT</div>
|
||
</header>
|
||
|
||
<div class="bench">
|
||
<div class="crt">
|
||
<div class="screen pixels" id="screen">
|
||
<canvas id="face" width="240" height="240" aria-label="Animated oscilloscope face"></canvas>
|
||
<canvas id="grat" width="480" height="480" aria-hidden="true"></canvas>
|
||
</div>
|
||
<div class="crt-foot"><span>240 × 240 IPS</span><span>State <b id="stateLabel">Idle</b></span></div>
|
||
</div>
|
||
|
||
<div class="controls">
|
||
<section class="group" aria-labelledby="g-face">
|
||
<div class="group-h" id="g-face">Face</div>
|
||
<div class="row">
|
||
<span class="seg-label">Shape</span>
|
||
<div class="seg" id="modeSeg" role="group" aria-label="Face shape">
|
||
<button id="mode-flat" data-mode="flat" aria-pressed="false">Flat</button>
|
||
<button id="mode-head" data-mode="head" aria-pressed="true">Head</button>
|
||
<button id="mode-lowpoly" data-mode="lowpoly" aria-pressed="false">Low-poly</button>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<span class="seg-label">Render</span>
|
||
<div class="seg" id="styleSeg" role="group" aria-label="Render style">
|
||
<button id="style-phosphor" data-style="phosphor" aria-pressed="true">Phosphor</button>
|
||
<button id="style-vector" data-style="vector" aria-pressed="false">Vector</button>
|
||
</div>
|
||
</div>
|
||
<p class="hint" id="modeNote">Wireframe head that turns with the servos.</p>
|
||
<div class="knobs" id="meshKnobs">
|
||
<div class="knob"><label for="mesh">Wireframe <output id="meshOut">45%</output></label><input id="mesh" type="range" min="0" max="100" value="45"></div>
|
||
<div class="knob"><label for="density">Mesh lines <output id="densityOut">13 × 15</output></label><input id="density" type="range" min="6" max="20" value="13"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group" aria-labelledby="g-state">
|
||
<div class="group-h" id="g-state">Agent state</div>
|
||
<div class="states" id="states">
|
||
<button class="key" id="k-idle" data-state="idle" aria-pressed="true"><span class="lamp"></span>Idle<span class="num">1</span></button>
|
||
<button class="key" id="k-listen" data-state="listening" aria-pressed="false"><span class="lamp"></span>Listen<span class="num">2</span></button>
|
||
<button class="key" id="k-think" data-state="thinking" aria-pressed="false"><span class="lamp"></span>Think<span class="num">3</span></button>
|
||
<button class="key" id="k-look" data-state="looking" aria-pressed="false"><span class="lamp"></span>Look<span class="num">4</span></button>
|
||
<button class="key" id="k-speak" data-state="speaking" aria-pressed="false"><span class="lamp"></span>Speak<span class="num">5</span></button>
|
||
</div>
|
||
<div class="row">
|
||
<button class="switch" id="seqBtn" aria-pressed="true"><span class="lamp"></span>Demo sequence</button>
|
||
<span class="hint">Keys <kbd>1</kbd>–<kbd>5</kbd> pick a state, <kbd>Space</kbd> toggles the sequence, <kbd>F</kbd> cycles shape, <kbd>V</kbd> switches render.</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group" aria-labelledby="g-crt">
|
||
<div class="group-h" id="g-crt">Phosphor</div>
|
||
<div class="row">
|
||
<div class="seg" id="phosSeg" role="group" aria-label="Phosphor type">
|
||
<button id="ph-p31" data-p="P31" aria-pressed="true">P31</button>
|
||
<button id="ph-p1" data-p="P1" aria-pressed="false">P1</button>
|
||
<button id="ph-p7" data-p="P7" aria-pressed="false">P7</button>
|
||
<button id="ph-p3" data-p="P3" aria-pressed="false">P3</button>
|
||
</div>
|
||
<span class="hint" id="phosNote">P31: bright green, the usual scope tube.</span>
|
||
</div>
|
||
<p class="hint" id="vectorNote" hidden>Vector render draws clean, even lines with no afterglow, so persistence, focus and beam noise have no effect.</p>
|
||
<div class="knobs">
|
||
<div class="knob"><label for="persist">Persistence <output id="persistOut">70 ms</output></label><input id="persist" type="range" min="12" max="300" value="70"></div>
|
||
<div class="knob"><label for="inten">Intensity <output id="intenOut">1.0×</output></label><input id="inten" type="range" min="30" max="250" value="100"></div>
|
||
<div class="knob"><label for="focus">Focus <output id="focusOut">sharp</output></label><input id="focus" type="range" min="0" max="100" value="35"></div>
|
||
<div class="knob"><label for="jitter">Beam noise <output id="jitterOut">0.3 px</output></label><input id="jitter" type="range" min="0" max="20" value="3"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group" aria-labelledby="g-head">
|
||
<div class="group-h" id="g-head">Head position</div>
|
||
<div class="knobs">
|
||
<div class="knob"><label for="pan">Pan <output id="panOut">+0°</output></label><input id="pan" type="range" min="-80" max="80" value="0"></div>
|
||
<div class="knob"><label for="tilt">Tilt <output id="tiltOut">+0°</output></label><input id="tilt" type="range" min="-45" max="45" value="0"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="group" aria-labelledby="g-disp">
|
||
<div class="group-h" id="g-disp">Display</div>
|
||
<div class="row">
|
||
<span class="seg-label">Refresh</span>
|
||
<div class="seg" id="fpsSeg" role="group" aria-label="Frame rate">
|
||
<button id="fps-60" data-fps="60" aria-pressed="false">60</button>
|
||
<button id="fps-30" data-fps="30" aria-pressed="true">30</button>
|
||
<button id="fps-20" data-fps="20" aria-pressed="false">20</button>
|
||
</div>
|
||
<button class="switch" id="pixBtn" aria-pressed="true"><span class="lamp"></span>Show pixels</button>
|
||
<button class="switch" id="gratBtn" aria-pressed="false"><span class="lamp"></span>Graticule</button>
|
||
</div>
|
||
<div class="readout"><span>Beam <b id="roPts">0</b> pts/frame</span><span>Render <b id="roFps">0</b> fps</span><span>Decay <b id="roDecay">0.00</b>/frame</span></div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="notes" aria-labelledby="g-map">
|
||
<div class="group-h" id="g-map">Where each state comes from on the robot</div>
|
||
<dl class="map">
|
||
<dt>Speak</dt><dd>The mouth is the waveform going to the robot's own speaker, read from the PipeWire output on the Pi. Here it is synthesized speech.</dd>
|
||
<dt>Listen</dt><dd>The microphone signal on the Pi, drawn dimmer. Here it is a second synthetic voice at a higher pitch.</dd>
|
||
<dt>Look</dt><dd>Triggered inside <code>look()</code> on the Pi: a shutter flash and a squint before the photo.</dd>
|
||
<dt>Think</dt><dd>Needs Hermes to say so: a turn-started or tool event sent to a small listener on the Pi. The eyes become rotating 3:2 Lissajous figures.</dd>
|
||
<dt>Shapes</dt><dd>Flat is the simplest to read across a room. Head and Low-poly trade some of that for showing direction: Head is a smooth contoured mesh, Low-poly a coarse faceted one. Phosphor render draws them as a scope would, with afterglow and beam-speed brightness; Vector render draws clean, even lines with no trails.</dd>
|
||
<dt>Head</dt><dd>In the head shapes the whole wireframe turns with the pan and tilt servos, so the face looks where the camera looks; in flat mode only the eyes shift. The head also cocks while listening and glances up while thinking.</dd>
|
||
</dl>
|
||
<p class="hint">The renderer is the same math the Pi would run: a 240 × 240 brightness buffer that decays each frame, a beam path traced at constant speed so slow parts burn brighter, and a small blur for glow. It ports to NumPy and Pillow almost line for line.</p>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
(() => {
|
||
const W = 240, H = 240, N = W * H;
|
||
const canvas = document.getElementById('face');
|
||
const ctx = canvas.getContext('2d');
|
||
const img = ctx.createImageData(W, H);
|
||
const buf = new Float32Array(N); // phosphor energy
|
||
const tmp = new Float32Array(N);
|
||
const glow = new Float32Array(N);
|
||
|
||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
||
// ---------------------------------------------------------------- settings
|
||
const PHOSPHORS = {
|
||
P31: { rgb: [0.30, 1.00, 0.45], note: 'P31: bright green, the usual scope tube.' },
|
||
P1: { rgb: [0.45, 1.00, 0.25], note: 'P1: yellower green, older and longer-glowing tubes.' },
|
||
P7: { rgb: [0.62, 0.80, 1.00], note: 'P7: blue-white flash with a long yellow afterglow; radar screens.' },
|
||
P3: { rgb: [1.00, 0.68, 0.18], note: 'P3: amber, as on old terminals.' },
|
||
};
|
||
const S = {
|
||
phos: 'P31', persistMs: 70, intensity: 1.0, focus: 0.35, jitter: 0.3,
|
||
pan: 0, tilt: 0, fps: 30, pixels: true, graticule: false, sequence: !reduceMotion,
|
||
mode: 'head', style: 'phosphor', mesh: 0.45, density: 13,
|
||
};
|
||
|
||
// ---------------------------------------------------------------- state machine
|
||
const STATES = ['idle', 'listening', 'thinking', 'looking', 'speaking'];
|
||
const LABEL = { idle: 'Idle', listening: 'Listening', thinking: 'Thinking', looking: 'Looking', speaking: 'Speaking' };
|
||
const SEQ = [['idle', 3.5], ['listening', 3.6], ['thinking', 2.4], ['looking', 1.3], ['thinking', 1.4], ['speaking', 6.5]];
|
||
let state = 'idle', stateT0 = 0, seqIdx = 0, seqT0 = 0;
|
||
let lookFlashPending = false;
|
||
|
||
function setState(s, now) {
|
||
if (s === state && s !== 'looking') return;
|
||
state = s; stateT0 = now;
|
||
if (s === 'looking') lookFlashPending = true;
|
||
document.getElementById('stateLabel').textContent = LABEL[s];
|
||
document.querySelectorAll('#states .key').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.state === s)));
|
||
}
|
||
|
||
// smoothed animation parameters
|
||
const A = { think: 0, open: 1, eyeScale: 1, mouthSpeak: 0, mouthListen: 0, smile: 1, panX: 0, tiltY: 0, yaw: 0, pitch: 0, roll: 0 };
|
||
const approach = (cur, target, rate, dt) => cur + (target - cur) * (1 - Math.exp(-rate * dt));
|
||
|
||
// blink scheduler
|
||
let nextBlink = 2.5, blinkT = -1;
|
||
|
||
// ---------------------------------------------------------------- synthetic voices
|
||
function hash(i, seed) { let x = Math.sin(i * 127.1 + seed * 311.7) * 43758.5453; return x - Math.floor(x); }
|
||
const VOWELS = [[730, 1090], [270, 2290], [300, 870], [530, 1840], [570, 840], [660, 1720]];
|
||
|
||
function voice(T, seed, baseF0, rate) {
|
||
const u = T * rate, i = Math.floor(u), frac = u - i;
|
||
const pause = hash(i, seed) < 0.16;
|
||
const amp = pause ? 0 : 0.55 + 0.45 * hash(i + 7, seed);
|
||
const env = amp * Math.pow(Math.sin(Math.PI * frac), 0.7);
|
||
const v0 = VOWELS[Math.floor(hash(i, seed + 3) * VOWELS.length)];
|
||
const v1 = VOWELS[Math.floor(hash(i + 1, seed + 3) * VOWELS.length)];
|
||
const m = frac * frac * (3 - 2 * frac);
|
||
const F1 = v0[0] + (v1[0] - v0[0]) * m, F2 = v0[1] + (v1[1] - v0[1]) * m;
|
||
const f0 = baseF0 * (1 + 0.12 * Math.sin(T * 1.7 + seed) + 0.06 * Math.sin(T * 4.3));
|
||
const w = [];
|
||
let norm = 0;
|
||
for (let k = 1; k <= 9; k++) {
|
||
const f = k * f0;
|
||
const d1 = (f - F1) / 160, d2 = (f - F2) / 260;
|
||
const a = Math.exp(-d1 * d1) + 0.65 * Math.exp(-d2 * d2) + (k === 1 ? 0.35 : 0.04);
|
||
w.push(a); norm += a;
|
||
}
|
||
const noise = frac < 0.14 && !pause ? (0.14 - frac) * 3.2 : 0;
|
||
return { env, f0, w, norm, noise };
|
||
}
|
||
// value of the voice at local time t (seconds) inside a trigger-locked window
|
||
function voiceSample(v, t, phase) {
|
||
let s = 0;
|
||
for (let k = 0; k < v.w.length; k++) s += v.w[k] * Math.sin(2 * Math.PI * (k + 1) * v.f0 * t + phase * (k + 1));
|
||
return s / v.norm;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- beam deposition
|
||
let ptsThisFrame = 0;
|
||
function deposit(x, y, e) {
|
||
if (S.jitter > 0 && S.style === 'phosphor') { x += (Math.random() - 0.5) * 2 * S.jitter; y += (Math.random() - 0.5) * 2 * S.jitter; }
|
||
const xi = Math.floor(x), yi = Math.floor(y);
|
||
if (xi < 0 || yi < 0 || xi >= W - 1 || yi >= H - 1) return;
|
||
const fx = x - xi, fy = y - yi, i = yi * W + xi;
|
||
buf[i] += e * (1 - fx) * (1 - fy);
|
||
buf[i + 1] += e * fx * (1 - fy);
|
||
buf[i + W] += e * (1 - fx) * fy;
|
||
buf[i + W + 1] += e * fx * fy;
|
||
ptsThisFrame++;
|
||
}
|
||
// trace a parametric stroke at constant beam time: n samples, energy e each
|
||
function stroke(fn, n, e) {
|
||
let px = null, py = null;
|
||
for (let j = 0; j <= n; j++) {
|
||
const p = fn(j / n);
|
||
// vector render keeps line weight even: fill any gap a fast-moving stretch leaves
|
||
if (S.style === 'vector' && px !== null) {
|
||
const gaps = Math.floor(Math.hypot(p[0] - px, p[1] - py) / 0.7);
|
||
for (let g = 1; g <= gaps; g++) { const t = g / (gaps + 1); deposit(px + (p[0] - px) * t, py + (p[1] - py) * t, e); }
|
||
}
|
||
deposit(p[0], p[1], e); px = p[0]; py = p[1];
|
||
}
|
||
}
|
||
// fast retrace between strokes: few samples, faint streak
|
||
let beamX = 120, beamY = 120;
|
||
function jumpTo(x, y, e) {
|
||
if (S.style === 'phosphor') { // a vector display blanks the beam on retrace
|
||
const n = Math.max(4, Math.round(Math.hypot(x - beamX, y - beamY) / 1.5));
|
||
for (let j = 1; j <= n; j++) deposit(beamX + (x - beamX) * j / n, beamY + (y - beamY) * j / n, e * 0.045);
|
||
}
|
||
beamX = x; beamY = y;
|
||
}
|
||
function trace(fn, n, e) { const p0 = fn(0); jumpTo(p0[0], p0[1], e); stroke(fn, n, e); const p1 = fn(1); beamX = p1[0]; beamY = p1[1]; }
|
||
|
||
// ---------------------------------------------------------------- 3D head (procedural, model units: head height 2)
|
||
const gauss = (d, s) => Math.exp(-(d / s) * (d / s));
|
||
const sstep = (a, b, x) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))); return t * t * (3 - 2 * t); };
|
||
const HR = { x: 0.74, y: 1.0, z: 0.82 };
|
||
const taper = y => 1 - 0.34 * sstep(-0.15, -0.95, y) - 0.06 * sstep(0.55, 1.0, y); // jaw and crown
|
||
function relief(x, y) { // forward displacement that sculpts the face
|
||
const ax = Math.abs(x);
|
||
return 0.22 * gauss(x, 0.075) * gauss(y + 0.10, 0.20) // nose bridge
|
||
+ 0.13 * gauss(x, 0.10) * gauss(y + 0.32, 0.08) // nose tip
|
||
- 0.12 * gauss(ax - 0.31, 0.14) * gauss(y - 0.12, 0.09) // eye sockets
|
||
+ 0.05 * gauss(y - 0.27, 0.06) * gauss(x, 0.45) // brow
|
||
+ 0.04 * gauss(ax - 0.38, 0.12) * gauss(y + 0.06, 0.12) // cheekbones
|
||
+ 0.07 * gauss(x, 0.17) * gauss(y + 0.53, 0.07) // lips
|
||
- 0.035 * gauss(x, 0.16) * gauss(y + 0.53, 0.018) // mouth line
|
||
+ 0.05 * gauss(x, 0.15) * gauss(y + 0.82, 0.08); // chin
|
||
}
|
||
function surf(th, ph) { // th: 0 at crown, ph: 0 facing the viewer
|
||
const y = HR.y * Math.cos(th), s = Math.sin(th);
|
||
const x = HR.x * taper(y) * s * Math.sin(ph);
|
||
const z = HR.z * s * Math.cos(ph) + relief(x, y) * Math.pow(Math.max(0, Math.cos(ph)), 1.5);
|
||
return [x, y, z];
|
||
}
|
||
function surfAt(x, y, lift) { // the surface point in front of (x, y)
|
||
const c = Math.max(-0.999, Math.min(0.999, y / HR.y)), s = Math.sqrt(1 - c * c);
|
||
const sp = Math.max(-1, Math.min(1, x / (HR.x * taper(y) * s))), cp = Math.sqrt(1 - sp * sp);
|
||
return [x, y, HR.z * s * cp + relief(x, y) * Math.pow(cp, 1.5) + lift];
|
||
}
|
||
// feature contours, as (x, y) control points laid onto the surface
|
||
function contour(ctrl, lift, n = 60) {
|
||
const out = [];
|
||
for (let i = 0; i <= n; i++) {
|
||
const f = i / n * (ctrl.length - 1), k = Math.min(ctrl.length - 2, Math.floor(f)), t = f - k;
|
||
// Catmull-Rom through the control points
|
||
const p0 = ctrl[Math.max(0, k - 1)], p1 = ctrl[k], p2 = ctrl[k + 1], p3 = ctrl[Math.min(ctrl.length - 1, k + 2)];
|
||
const cr = (a, b, c, d) => 0.5 * (2 * b + (-a + c) * t + (2 * a - 5 * b + 4 * c - d) * t * t + (-a + 3 * b - 3 * c + d) * t * t * t);
|
||
out.push(surfAt(cr(p0[0], p1[0], p2[0], p3[0]), cr(p0[1], p1[1], p2[1], p3[1]), lift));
|
||
}
|
||
return out;
|
||
}
|
||
const FEATURES = [
|
||
contour([[-0.05, 0.07], [-0.07, -0.14], [-0.09, -0.29], [-0.12, -0.36], [-0.06, -0.40], [0, -0.39], [0.06, -0.40], [0.12, -0.36], [0.09, -0.29], [0.07, -0.14], [0.05, 0.07]], 0.012, 90), // nose
|
||
contour([[-0.47, 0.20], [-0.38, 0.265], [-0.26, 0.28], [-0.15, 0.25]], 0.01), // right brow
|
||
contour([[0.15, 0.25], [0.26, 0.28], [0.38, 0.265], [0.47, 0.20]], 0.01), // left brow
|
||
contour([[-0.22, -0.58], [-0.1, -0.63], [0, -0.64], [0.1, -0.63], [0.22, -0.58]], 0.012), // lower lip
|
||
];
|
||
let MESH = [];
|
||
function buildMesh(rings) {
|
||
const meridians = Math.round(rings * 1.15);
|
||
const P0 = -105 * Math.PI / 180, P1 = 105 * Math.PI / 180, T0 = 10 * Math.PI / 180, T1 = 152 * Math.PI / 180;
|
||
MESH = [];
|
||
for (let i = 0; i < rings; i++) {
|
||
const th = T0 + (T1 - T0) * i / (rings - 1), line = [];
|
||
for (let j = 0; j <= 60; j++) line.push(surf(th, P0 + (P1 - P0) * j / 60));
|
||
MESH.push(i % 2 ? line : line.reverse()); // alternate direction: shorter retrace, as a vector display would
|
||
}
|
||
for (let i = 0; i < meridians; i++) {
|
||
const ph = P0 + (P1 - P0) * i / (meridians - 1), line = [];
|
||
for (let j = 0; j <= 50; j++) line.push(surf(T0 + (T1 - T0) * j / 50, ph));
|
||
MESH.push(i % 2 ? line.reverse() : line);
|
||
}
|
||
return meridians;
|
||
}
|
||
|
||
// low-poly variant: a coarse vertex grid joined by straight edges, each quad split by a
|
||
// diagonal that alternates direction so the facets mirror about the nose
|
||
let LOWPOLY = [];
|
||
function buildLowPoly(rings) {
|
||
const cols = rings + 3; // odd-ish width so one column runs down the nose
|
||
const P0 = -100 * Math.PI / 180, P1 = 100 * Math.PI / 180, T0 = 14 * Math.PI / 180, T1 = 150 * Math.PI / 180;
|
||
const V = [];
|
||
for (let i = 0; i < rings; i++) {
|
||
const th = T0 + (T1 - T0) * i / (rings - 1), row = [];
|
||
for (let j = 0; j < cols; j++) row.push(surf(th, P0 + (P1 - P0) * j / (cols - 1)));
|
||
V.push(row);
|
||
}
|
||
LOWPOLY = [];
|
||
for (let i = 0; i < rings; i++) LOWPOLY.push(i % 2 ? V[i].slice().reverse() : V[i]); // rings
|
||
for (let j = 0; j < cols; j++) { const m = V.map(r => r[j]); LOWPOLY.push(j % 2 ? m.reverse() : m); } // meridians
|
||
for (let i = 0; i < rings - 1; i++) { // zigzag diagonals
|
||
const z = [];
|
||
for (let j = 0; j < cols; j++) z.push(V[j % 2 ? i + 1 : i][j]);
|
||
LOWPOLY.push(i % 2 ? z.reverse() : z);
|
||
}
|
||
return cols;
|
||
}
|
||
|
||
const R = { cy: 1, sy: 0, cp: 1, sp: 0, cr: 1, sr: 0 };
|
||
function setRot(yaw, pitch, roll) {
|
||
R.cy = Math.cos(yaw); R.sy = Math.sin(yaw); R.cp = Math.cos(pitch); R.sp = Math.sin(pitch); R.cr = Math.cos(roll); R.sr = Math.sin(roll);
|
||
}
|
||
function rot(x, y, z) {
|
||
const x1 = x * R.cr - y * R.sr, y1 = x * R.sr + y * R.cr; // roll
|
||
const y2 = y1 * R.cp + z * R.sp, z2 = -y1 * R.sp + z * R.cp; // pitch: + looks up
|
||
return [x1 * R.cy + z2 * R.sy, y2, -x1 * R.sy + z2 * R.cy]; // yaw: + turns to viewer's right
|
||
}
|
||
function proj(p) {
|
||
const [x, y, z] = rot(p[0], p[1], p[2]);
|
||
const k = 84 / (1 - z / 5.5); // gentle perspective
|
||
// approximate surface normal (ellipsoid gradient) to tell front-facing from back-facing
|
||
const tx = HR.x * taper(p[1]);
|
||
const n = rot(p[0] / (tx * tx), p[1] / (HR.y * HR.y), p[2] / (HR.z * HR.z));
|
||
return [120 + x * k, 124 - y * k, z, n[2] / Math.hypot(n[0], n[1], n[2])];
|
||
}
|
||
// intensity depth cueing: far lines dimmer, the classic vector-display trick
|
||
const cue = z => 0.3 + 0.7 * Math.min(1, Math.max(0, (z + 0.55) / 1.45));
|
||
// floor: how much of a back-facing line still shows (wireframe see-through vs hidden)
|
||
function poly3(pts, e, step, floor) {
|
||
const q = pts.map(proj);
|
||
jumpTo(q[0][0], q[0][1], e);
|
||
for (let i = 1; i < q.length; i++) {
|
||
const a = q[i - 1], b = q[i];
|
||
const n = Math.max(1, Math.ceil(Math.hypot(b[0] - a[0], b[1] - a[1]) / step));
|
||
for (let j = 1; j <= n; j++) {
|
||
const t = j / n;
|
||
const vis = floor + (1 - floor) * sstep(-0.12, 0.22, a[3] + (b[3] - a[3]) * t);
|
||
deposit(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, e * vis * cue(a[2] + (b[2] - a[2]) * t));
|
||
}
|
||
}
|
||
const L = q[q.length - 1]; beamX = L[0]; beamY = L[1];
|
||
}
|
||
|
||
function drawHead(T, E) {
|
||
setRot(A.yaw, A.pitch, A.roll);
|
||
|
||
// the wireframe: dim, constant-speed beam; back of the head fainter
|
||
if (S.mesh > 0) {
|
||
if (S.mode === 'lowpoly') {
|
||
for (const line of LOWPOLY) poly3(line, E * 0.75 * S.mesh, 0.85, 0.25);
|
||
} else {
|
||
for (const line of MESH) poly3(line, E * 0.6 * S.mesh, 0.85, 0.3);
|
||
// contour lines that make it read as a face from the front: nose, brows, lower lip
|
||
for (const line of FEATURES) poly3(line, E * 0.95 * S.mesh, 0.7, 0.1);
|
||
}
|
||
}
|
||
|
||
// eyes on the sockets: loops that become Lissajous figures while thinking
|
||
const rx = 0.135 * A.eyeScale, ry = 0.085 * A.eyeScale * A.open;
|
||
const listenPulse = state === 'listening' ? 0.15 * voice(T, 9, 190, 4.2).env : 0;
|
||
[-1, 1].forEach((side, idx) => {
|
||
const cx = 0.31 * side, cy = 0.125, ph = T * 1.6 + idx * Math.PI * 0.5, k = A.think, pts = [];
|
||
for (let j = 0; j <= 72; j++) {
|
||
const th = j / 72 * Math.PI * 2;
|
||
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
|
||
const lx = rx * Math.sin(3 * th + ph), ly = Math.max(ry, 0.07) * Math.sin(2 * th);
|
||
pts.push(surfAt(cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k, 0.02));
|
||
}
|
||
poly3(pts, E * (1.15 + listenPulse), 0.5, 0.04);
|
||
});
|
||
|
||
// mouth along the lips: smile at rest, the live waveform when speaking or listening
|
||
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
|
||
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
|
||
const speakAmp = 0.15 * A.mouthSpeak * sv.env, listenAmp = 0.07 * A.mouthListen * lv.env;
|
||
const smile = 0.045 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
|
||
const half = 0.24 * (1 - 0.4 * A.think), pts = [];
|
||
for (let j = 0; j <= 90; j++) {
|
||
const u = j / 90, c = 2 * u - 1;
|
||
let y = -0.53 - smile * (1 - c * c) + smile * 0.4;
|
||
if (speakAmp > 0.003) y += speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 0.03 * A.mouthSpeak * Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31) : 0);
|
||
if (listenAmp > 0.003) y += listenAmp * voiceSample(lv, u * winL, 0.4);
|
||
pts.push(surfAt(c * half, y, 0.025));
|
||
}
|
||
poly3(pts, E * 0.85 * (1 - 0.3 * A.mouthListen), 0.5, 0.04);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- face geometry
|
||
function drawFace(T, dt) {
|
||
// energy per beam sample: constant energy per second whatever the refresh rate,
|
||
// and partly normalised so persistence mostly changes the trails, not the brightness
|
||
const E = 0.45 * (dt * 30) * (S.style === 'vector' ? 1 : S.intensity * Math.pow(70 / S.persistMs, 0.6));
|
||
if (S.mode !== 'flat') { drawHead(T, E); return; }
|
||
const ex = A.panX, ey = A.tiltY;
|
||
const eyes = [[82 + ex, 98 + ey], [158 + ex, 98 + ey]];
|
||
const rx = 21 * A.eyeScale, ry = 25 * A.eyeScale * A.open;
|
||
const listenPulse = state === 'listening' ? 0.12 * voice(T, 9, 190, 4.2).env : 0;
|
||
|
||
// eyes: ellipse morphing to a rotating 3:2 Lissajous figure while thinking
|
||
eyes.forEach(([cx, cy], idx) => {
|
||
const ph = T * 1.6 + idx * Math.PI * 0.5;
|
||
trace(u => {
|
||
const th = u * Math.PI * 2;
|
||
const ox = rx * Math.cos(th), oy = ry * Math.sin(th);
|
||
const lx = rx * 0.95 * Math.sin(3 * th + ph), ly = ry * 0.95 * Math.sin(2 * th);
|
||
const k = A.think;
|
||
return [cx + ox + (lx - ox) * k, cy + oy + (ly - oy) * k];
|
||
}, 260 + Math.round(160 * A.think), E * (1 + listenPulse));
|
||
});
|
||
|
||
// mouth: smile curve at rest, live waveform when speaking or listening
|
||
const mx0 = 70 + ex * 0.6, mx1 = 170 + ex * 0.6, my = 166 + ey * 0.6;
|
||
const sv = voice(T, 1, 118, 4.6), lv = voice(T, 9, 190, 4.2);
|
||
const winS = 2.4 / sv.f0, winL = 3.2 / lv.f0;
|
||
const speakAmp = 26 * A.mouthSpeak * sv.env, listenAmp = 12 * A.mouthListen * lv.env;
|
||
const smile = 7 * A.smile * (1 - A.mouthSpeak) * (1 - A.think * 0.8);
|
||
const thinkFlat = A.think;
|
||
const mouthW = (mx1 - mx0) * (1 - 0.45 * thinkFlat);
|
||
const mStart = (mx0 + mx1) / 2 - mouthW / 2;
|
||
trace(u => {
|
||
const x = mStart + u * mouthW;
|
||
const c = 2 * u - 1;
|
||
let y = my + smile * (1 - c * c) - smile * 0.4;
|
||
if (speakAmp > 0.05) y -= speakAmp * voiceSample(sv, u * winS, 0) + (sv.noise ? sv.noise * 5 * A.mouthSpeak * (Math.sin(u * 97 + T * 53) * Math.sin(u * 61 - T * 31)) : 0);
|
||
if (listenAmp > 0.05) y -= listenAmp * voiceSample(lv, u * winL, 0.4);
|
||
return [x, y];
|
||
}, 320, E * 0.65 * (0.95 - 0.3 * A.mouthListen));
|
||
jumpTo(eyes[0][0] - rx, eyes[0][1], E);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- per-frame update
|
||
function update(T, dt) {
|
||
// sequence
|
||
if (S.sequence) {
|
||
if (T - seqT0 >= SEQ[seqIdx][1]) { seqIdx = (seqIdx + 1) % SEQ.length; seqT0 = T; setState(SEQ[seqIdx][0], T); }
|
||
} else if (state === 'looking' && T - stateT0 > 1.4) {
|
||
setState('idle', T);
|
||
}
|
||
|
||
const st = state, inLook = st === 'looking', tl = T - stateT0;
|
||
A.think = approach(A.think, st === 'thinking' ? 1 : 0, 6, dt);
|
||
A.mouthSpeak = approach(A.mouthSpeak, st === 'speaking' ? 1 : 0, 10, dt);
|
||
A.mouthListen = approach(A.mouthListen, st === 'listening' ? 1 : 0, 8, dt);
|
||
A.smile = approach(A.smile, st === 'idle' ? 1 : st === 'speaking' ? 0.6 : 0.25, 4, dt);
|
||
let scaleT = st === 'listening' ? 1.08 : 1;
|
||
if (inLook) scaleT = tl < 0.18 ? 0.92 : 1.14;
|
||
A.eyeScale = approach(A.eyeScale, scaleT, 12, dt);
|
||
A.panX = approach(A.panX, -S.pan * 0.22, 7, dt); // positive pan = robot's left = viewer's right
|
||
A.tiltY = approach(A.tiltY, -S.tilt * 0.3, 7, dt);
|
||
|
||
// head pose: the servo angles, plus small gestures per state
|
||
const deg = Math.PI / 180;
|
||
let yawT = S.pan * deg, pitchT = S.tilt * deg, rollT = 0;
|
||
if (!reduceMotion) { yawT += 3.5 * deg * Math.sin(T * 0.37); pitchT += 2 * deg * Math.sin(T * 0.29); }
|
||
if (st === 'thinking') { yawT -= 11 * deg; pitchT += 9 * deg; } // glance up and aside
|
||
if (st === 'listening') { rollT = 7 * deg; pitchT -= 2 * deg; } // cock the head
|
||
if (st === 'speaking') pitchT += 3 * deg * voice(T, 1, 118, 4.6).env; // small nods on stressed syllables
|
||
if (inLook) pitchT -= 3 * deg;
|
||
A.yaw = approach(A.yaw, yawT, 5, dt);
|
||
A.pitch = approach(A.pitch, pitchT, 5, dt);
|
||
A.roll = approach(A.roll, rollT, 4, dt);
|
||
|
||
// blink (and the squint before a photo)
|
||
let openT = 1;
|
||
if (inLook && tl < 0.18) openT = 0.35;
|
||
if (st !== 'thinking' && !inLook) {
|
||
if (blinkT < 0 && T >= nextBlink) blinkT = T;
|
||
if (blinkT >= 0) {
|
||
const b = (T - blinkT) / 0.17;
|
||
if (b >= 1) { blinkT = -1; nextBlink = T + 2.2 + Math.random() * 4; }
|
||
else openT = Math.max(0.06, Math.abs(1 - 2 * b));
|
||
}
|
||
}
|
||
A.open = blinkT >= 0 ? openT : approach(A.open, openT, 18, dt);
|
||
|
||
if (lookFlashPending && tl > 0.16) { // shutter: bloom the whole tube once
|
||
if (S.style === 'phosphor') for (let i = 0; i < N; i++) buf[i] += 0.55;
|
||
else flash = 0.6; // vector render has no afterglow, so fade an overlay instead
|
||
lookFlashPending = false;
|
||
}
|
||
flash = Math.max(0, flash - dt * 2.5);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- tone mapping
|
||
function boxBlur(src, dst, r) {
|
||
// horizontal into tmp, vertical into dst
|
||
const inv = 1 / (2 * r + 1);
|
||
for (let y = 0; y < H; y++) {
|
||
let acc = 0, row = y * W;
|
||
for (let x = -r; x <= r; x++) acc += src[row + Math.min(W - 1, Math.max(0, x))];
|
||
for (let x = 0; x < W; x++) {
|
||
tmp[row + x] = acc * inv;
|
||
acc += src[row + Math.min(W - 1, x + r + 1)] - src[row + Math.max(0, x - r)];
|
||
}
|
||
}
|
||
for (let x = 0; x < W; x++) {
|
||
let acc = 0;
|
||
for (let y = -r; y <= r; y++) acc += tmp[Math.min(H - 1, Math.max(0, y)) * W + x];
|
||
for (let y = 0; y < H; y++) {
|
||
dst[y * W + x] = acc * inv;
|
||
acc += tmp[Math.min(H - 1, y + r + 1) * W + x] - tmp[Math.max(0, y - r) * W + x];
|
||
}
|
||
}
|
||
}
|
||
|
||
let flash = 0;
|
||
function render() {
|
||
if (S.style === 'vector') return renderVector();
|
||
const r = 1 + Math.round(S.focus * 5);
|
||
boxBlur(buf, glow, r);
|
||
boxBlur(glow, glow, r);
|
||
const gAmt = 0.6 + S.focus * 2.2;
|
||
const core = 1.15 - S.focus * 0.55;
|
||
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
|
||
const d = img.data;
|
||
for (let i = 0, j = 0; i < N; i++, j += 4) {
|
||
const v = buf[i] * core + glow[i] * gAmt;
|
||
const base = 1 - Math.exp(-v * 1.6);
|
||
const hot = Math.min(0.75, Math.max(0, v - 1.1) * 0.35); // overdriven spots whiten
|
||
d[j] = 255 * Math.min(1, pr * base + hot);
|
||
d[j + 1] = 255 * Math.min(1, pg * base + hot);
|
||
d[j + 2] = 255 * Math.min(1, pb * base + hot);
|
||
d[j + 3] = 255;
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
|
||
// clean vector look: this frame's lines only, saturated to an even weight, a faint halo, no trails
|
||
function renderVector() {
|
||
boxBlur(buf, glow, 1);
|
||
const [pr, pg, pb] = PHOSPHORS[S.phos].rgb;
|
||
const lift = 0.35; // pale the tint so lines read as light, not as phosphor
|
||
const cr = pr + (1 - pr) * lift, cg = pg + (1 - pg) * lift, cb = pb + (1 - pb) * lift;
|
||
const d = img.data, k = 2.6 * S.intensity;
|
||
for (let i = 0, j = 0; i < N; i++, j += 4) {
|
||
const v = Math.min(1, buf[i] * k) * 0.9 + Math.min(1, glow[i] * k) * 0.25 + flash * 0.35;
|
||
d[j] = 255 * Math.min(1, cr * v);
|
||
d[j + 1] = 255 * Math.min(1, cg * v);
|
||
d[j + 2] = 255 * Math.min(1, cb * v);
|
||
d[j + 3] = 255;
|
||
}
|
||
ctx.putImageData(img, 0, 0);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- loop
|
||
let last = performance.now(), acc = 0, frames = 0, fpsT = last;
|
||
function frame(nowMs) {
|
||
requestAnimationFrame(frame);
|
||
const step = 1000 / S.fps;
|
||
acc += nowMs - last; last = nowMs;
|
||
if (acc < step - 1) return;
|
||
const dt = Math.min(0.1, acc / 1000);
|
||
acc = acc % step;
|
||
const T = nowMs / 1000;
|
||
|
||
const decay = S.style === 'vector' ? 0 : Math.exp(-(dt * 1000) / S.persistMs);
|
||
if (decay === 0) buf.fill(0); else for (let i = 0; i < N; i++) buf[i] *= decay;
|
||
|
||
update(T, dt);
|
||
ptsThisFrame = 0;
|
||
drawFace(T, dt);
|
||
render();
|
||
|
||
frames++;
|
||
if (nowMs - fpsT > 500) {
|
||
document.getElementById('roFps').textContent = Math.round(frames * 1000 / (nowMs - fpsT));
|
||
document.getElementById('roPts').textContent = ptsThisFrame;
|
||
document.getElementById('roDecay').textContent = decay.toFixed(2);
|
||
frames = 0; fpsT = nowMs;
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- graticule overlay
|
||
function drawGraticule() {
|
||
const g = document.getElementById('grat'), c = g.getContext('2d');
|
||
c.clearRect(0, 0, g.width, g.height);
|
||
if (!S.graticule) return;
|
||
const s = g.width / 10;
|
||
c.strokeStyle = 'rgba(150, 175, 160, 0.22)'; c.lineWidth = 1;
|
||
for (let i = 1; i < 10; i++) {
|
||
c.beginPath(); c.moveTo(i * s + .5, 0); c.lineTo(i * s + .5, g.height); c.stroke();
|
||
c.beginPath(); c.moveTo(0, i * s + .5); c.lineTo(g.width, i * s + .5); c.stroke();
|
||
}
|
||
c.strokeStyle = 'rgba(150, 175, 160, 0.32)';
|
||
for (let i = 1; i < 50; i++) {
|
||
const p = i * s / 5 + .5, m = g.width / 2;
|
||
c.beginPath(); c.moveTo(p, m - 4); c.lineTo(p, m + 4); c.stroke();
|
||
c.beginPath(); c.moveTo(m - 4, p); c.lineTo(m + 4, p); c.stroke();
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- controls
|
||
const $ = id => document.getElementById(id);
|
||
function pressOnly(groupSel, btn) { document.querySelectorAll(groupSel + ' button').forEach(b => b.setAttribute('aria-pressed', String(b === btn))); }
|
||
|
||
document.querySelectorAll('#states .key').forEach(b => b.addEventListener('click', () => {
|
||
S.sequence = false; $('seqBtn').setAttribute('aria-pressed', 'false');
|
||
setState(b.dataset.state, performance.now() / 1000);
|
||
}));
|
||
function toggleSeq() {
|
||
S.sequence = !S.sequence; $('seqBtn').setAttribute('aria-pressed', String(S.sequence));
|
||
if (S.sequence) { seqIdx = 0; seqT0 = performance.now() / 1000; setState(SEQ[0][0], seqT0); }
|
||
}
|
||
$('seqBtn').addEventListener('click', toggleSeq);
|
||
|
||
document.querySelectorAll('#phosSeg button').forEach(b => b.addEventListener('click', () => {
|
||
S.phos = b.dataset.p; pressOnly('#phosSeg', b); $('phosNote').textContent = PHOSPHORS[S.phos].note;
|
||
}));
|
||
document.querySelectorAll('#fpsSeg button').forEach(b => b.addEventListener('click', () => { S.fps = +b.dataset.fps; pressOnly('#fpsSeg', b); }));
|
||
|
||
function bindRange(id, fn) { const el = $(id); const go = () => fn(+el.value); el.addEventListener('input', go); go(); }
|
||
bindRange('persist', v => { S.persistMs = v; $('persistOut').textContent = v + ' ms'; });
|
||
bindRange('inten', v => { S.intensity = v / 100; $('intenOut').textContent = (v / 100).toFixed(1) + '×'; });
|
||
bindRange('focus', v => { S.focus = v / 100; $('focusOut').textContent = v < 20 ? 'sharp' : v < 55 ? 'soft' : v < 80 ? 'bloomy' : 'smeared'; });
|
||
bindRange('jitter', v => { S.jitter = v / 10; $('jitterOut').textContent = (v / 10).toFixed(1) + ' px'; });
|
||
const sign = v => (v > 0 ? '+' : v < 0 ? '−' : '+') + Math.abs(v) + '°';
|
||
bindRange('pan', v => { S.pan = v; $('panOut').textContent = sign(v); });
|
||
bindRange('tilt', v => { S.tilt = v; $('tiltOut').textContent = sign(v); });
|
||
|
||
$('pixBtn').addEventListener('click', () => {
|
||
S.pixels = !S.pixels; $('pixBtn').setAttribute('aria-pressed', String(S.pixels));
|
||
$('screen').classList.toggle('pixels', S.pixels);
|
||
});
|
||
$('gratBtn').addEventListener('click', () => {
|
||
S.graticule = !S.graticule; $('gratBtn').setAttribute('aria-pressed', String(S.graticule)); drawGraticule();
|
||
});
|
||
|
||
const MODE_NOTE = {
|
||
flat: 'Flat face; only the eyes follow the servos.',
|
||
head: 'Smooth wireframe head that turns with the servos.',
|
||
lowpoly: 'Faceted head: a coarse mesh of straight edges, turning with the servos.',
|
||
};
|
||
const MODES = ['flat', 'head', 'lowpoly'];
|
||
let lowpolyCols = 0, meshCols = 0;
|
||
function densityLabel() {
|
||
const v = S.density;
|
||
$('densityOut').textContent = S.mode === 'lowpoly' ? Math.max(5, Math.round(v * 0.62)) + ' × ' + lowpolyCols : v + ' × ' + meshCols;
|
||
}
|
||
function setMode(m) {
|
||
S.mode = m;
|
||
pressOnly('#modeSeg', $('mode-' + m));
|
||
$('modeNote').textContent = MODE_NOTE[m];
|
||
$('meshKnobs').hidden = m === 'flat';
|
||
densityLabel();
|
||
}
|
||
function setStyle(s) {
|
||
S.style = s;
|
||
pressOnly('#styleSeg', $('style-' + s));
|
||
$('vectorNote').hidden = s !== 'vector';
|
||
buf.fill(0);
|
||
}
|
||
document.querySelectorAll('#modeSeg button').forEach(b => b.addEventListener('click', () => setMode(b.dataset.mode)));
|
||
document.querySelectorAll('#styleSeg button').forEach(b => b.addEventListener('click', () => setStyle(b.dataset.style)));
|
||
bindRange('mesh', v => { S.mesh = v / 100; $('meshOut').textContent = v + '%'; });
|
||
bindRange('density', v => {
|
||
S.density = v;
|
||
meshCols = buildMesh(v);
|
||
lowpolyCols = buildLowPoly(Math.max(5, Math.round(v * 0.62)));
|
||
densityLabel();
|
||
});
|
||
|
||
document.addEventListener('keydown', e => {
|
||
if (e.target.closest && e.target.closest('input')) return;
|
||
const n = parseInt(e.key, 10);
|
||
if (e.key === 'f' || e.key === 'F') { setMode(MODES[(MODES.indexOf(S.mode) + 1) % MODES.length]); return; }
|
||
if (e.key === 'v' || e.key === 'V') { setStyle(S.style === 'vector' ? 'phosphor' : 'vector'); return; }
|
||
if (n >= 1 && n <= 5) { $(['k-idle', 'k-listen', 'k-think', 'k-look', 'k-speak'][n - 1]).click(); }
|
||
else if (e.code === 'Space') { e.preventDefault(); toggleSeq(); }
|
||
});
|
||
|
||
if (!S.sequence) $('seqBtn').setAttribute('aria-pressed', 'false');
|
||
if (reduceMotion) { S.jitter = 0; $('jitter').value = 0; $('jitterOut').textContent = '0.0 px'; }
|
||
drawGraticule();
|
||
const t0 = performance.now() / 1000; seqT0 = t0; stateT0 = t0; nextBlink = t0 + 2.5;
|
||
requestAnimationFrame(frame);
|
||
})();
|
||
</script>
|