Add portal web simulator, SPI bridges, and Pico firmware updates.

Bring the five-panel hex portal online with a browser 3D/schematic preview, Pi SPI backends, and renamed multi-panel Pico UDP firmware.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 14:54:51 +12:00
parent d5cab2efdf
commit 5094c7bcee
78 changed files with 62251 additions and 832 deletions

22
web/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Portal Simulator</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<portal-app></portal-app>
<script type="importmap">
{
"imports": {
"three": "/static/vendor/three.module.js",
"three/addons/": "/static/vendor/"
}
}
</script>
<script type="module" src="/static/js/main.js"></script>
</body>
</html>

56
web/js/api.js Normal file
View File

@@ -0,0 +1,56 @@
/** @typedef {{ index: number, width: number, height: number, position: string, label?: string }} PanelConfig */
/** @typedef {{ frame: number, animation: string, panels: Array<{ index: number, width: number, height: number, rgb: number[] }> }} FramePayload */
/** @typedef {{ panels: PanelConfig[], animations: string[], defaultFps: Record<string, number>, defaultBrightness: number }} PortalConfig */
/**
* @param {string} path
* @returns {Promise<PortalConfig>}
*/
export async function fetchConfig(path = "/api/config") {
const res = await fetch(path);
if (!res.ok) throw new Error(`config ${res.status}`);
return res.json();
}
export class FrameStream {
/** @param {() => void} onFrame */
constructor(onFrame) {
this._onFrame = onFrame;
this._source = null;
this._retry = null;
/** @type {(() => void) | null} */
this.onDisconnect = null;
}
/** @param {{ animation: string, brightness: number, fps: number }} opts */
start(opts) {
this.stop();
const params = new URLSearchParams({
animation: opts.animation,
brightness: String(opts.brightness),
fps: String(opts.fps),
});
this._source = new EventSource(`/api/stream?${params}`);
this._source.onmessage = (event) => {
this._onFrame(JSON.parse(event.data));
};
this._source.onerror = () => {
this.onDisconnect?.();
this.stop();
this._retry = setTimeout(() => this.start(opts), 1500);
};
}
stop() {
if (this._retry) {
clearTimeout(this._retry);
this._retry = null;
}
if (this._source) {
this._source.close();
this._source = null;
}
}
}

View File

@@ -0,0 +1,154 @@
import { fetchConfig, FrameStream } from "../api.js";
import "./portal-controls.js";
import "./portal-viewport.js";
import "./portal-schematic.js";
const APP_STYLES = `
:host {
display: grid;
grid-template-columns: 280px 1fr;
height: 100vh;
--border: #2a3044;
--panel: #12151f;
--text: #e8ecf7;
--muted: #8b93a8;
--accent: #5b8cff;
}
main {
position: relative;
min-width: 0;
height: 100%;
}
portal-viewport {
width: 100%;
height: 100%;
}
portal-viewport[hidden] {
display: none;
}
.status {
position: absolute;
left: 1rem;
bottom: 1rem;
padding: 0.35rem 0.6rem;
background: rgba(0, 0, 0, 0.55);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.8rem;
color: var(--muted);
pointer-events: none;
font-family: system-ui, sans-serif;
}
@media (max-width: 800px) {
:host {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
portal-controls {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
`;
export class PortalApp extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
/** @type {FrameStream | null} */
this._stream = null;
/** @type {import('./portal-controls.js').PortalControls | null} */
this._controls = null;
/** @type {import('./portal-viewport.js').PortalViewport | null} */
this._viewport = null;
/** @type {import('./portal-schematic.js').PortalSchematic | null} */
this._schematic = null;
this._settings = {
animation: "rainbow",
brightness: 0.35,
fps: 25,
paused: false,
identify: false,
view3d: true,
};
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${APP_STYLES}</style>
<portal-controls></portal-controls>
<main>
<portal-viewport></portal-viewport>
<portal-schematic></portal-schematic>
<div class="status">Loading…</div>
</main>
`;
this._controls = root.querySelector("portal-controls");
this._viewport = root.querySelector("portal-viewport");
this._schematic = root.querySelector("portal-schematic");
this._status = root.querySelector(".status");
this.addEventListener("portal-settings", (e) => {
this._settings = e.detail;
this._viewport.hidden = !this._settings.view3d;
this._schematic.visible = !this._settings.view3d;
if (this._settings.paused && this._settings.identify && this._config) {
for (const panel of this._config.panels) {
this._viewport.updatePanel(panel.index, [], panel.width, panel.height, true);
this._schematic.updatePanel(panel.index, [], panel.width, panel.height, true);
}
}
this._syncStream();
});
this._init();
}
disconnectedCallback() {
this._stream?.stop();
}
async _init() {
try {
this._config = await fetchConfig();
this._controls.setConfig(this._config);
this._viewport.setPanels(this._config.panels);
this._schematic.setPanels(this._config.panels);
this._status.textContent = "Ready";
this._syncStream();
} catch (err) {
this._status.textContent = `Error: ${err.message}`;
}
}
_syncStream() {
this._stream?.stop();
if (this._settings.paused) return;
this._stream = new FrameStream((payload) => this._onFrame(payload));
this._stream.onDisconnect = () => {
this._status.textContent = "Stream disconnected — retrying…";
};
this._stream.start({
animation: this._settings.animation,
brightness: this._settings.brightness,
fps: this._settings.fps,
});
}
/** @param {import('../api.js').FramePayload} payload */
_onFrame(payload) {
const { identify } = this._settings;
for (const panel of payload.panels) {
this._viewport.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
this._schematic.updatePanel(panel.index, panel.rgb, panel.width, panel.height, identify);
}
this._status.textContent = `${payload.animation} · frame ${payload.frame}`;
}
}
customElements.define("portal-app", PortalApp);

View File

@@ -0,0 +1,185 @@
const CONTROL_STYLES = `
:host {
display: block;
border-right: 1px solid var(--border, #2a3044);
background: var(--panel, #12151f);
padding: 1rem;
overflow-y: auto;
color: var(--text, #e8ecf7);
font-family: system-ui, sans-serif;
}
h1 {
margin: 0 0 0.25rem;
font-size: 1.15rem;
}
p {
margin: 0 0 1rem;
color: var(--muted, #8b93a8);
font-size: 0.85rem;
line-height: 1.4;
}
a {
color: var(--accent, #5b8cff);
}
label {
display: block;
margin: 0.75rem 0 0.35rem;
font-size: 0.8rem;
color: var(--muted, #8b93a8);
}
select, input[type="range"], button {
width: 100%;
}
select, button {
background: #1a1f2e;
color: inherit;
border: 1px solid var(--border, #2a3044);
border-radius: 6px;
padding: 0.45rem 0.6rem;
font-size: 0.9rem;
}
button {
margin-top: 0.75rem;
cursor: pointer;
background: #243049;
}
button:hover { border-color: var(--accent, #5b8cff); }
button.active {
background: #2a3f6e;
border-color: var(--accent, #5b8cff);
}
.view-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
`;
export class PortalControls extends HTMLElement {
constructor() {
super();
/** @type {import('../api.js').PortalConfig | null} */
this._config = null;
this._animation = "rainbow";
this._brightness = 0.35;
this._fps = 25;
this._paused = false;
this._identify = false;
this._view3d = true;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${CONTROL_STYLES}</style>
<h1>Portal Simulator</h1>
<p>Hexagonal LED arch (see <a href="https://technical.kiwi/images/portal/IMG_20241029_220222.jpg" target="_blank" rel="noopener">photo</a>). Panel 2 top (Pi); 0, 1, 3, 4 walls; floor bare.</p>
<label for="animation">Animation</label>
<select id="animation"></select>
<label for="brightness">Brightness</label>
<input id="brightness" type="range" min="0.05" max="1" step="0.05" value="0.35" />
<label for="fps">FPS</label>
<input id="fps" type="range" min="5" max="40" step="1" value="25" />
<div class="view-toggle">
<button type="button" data-view="3d" class="active">3D view</button>
<button type="button" data-view="flat">Flat map</button>
</div>
<button type="button" data-action="identify">Identify panels</button>
<button type="button" data-action="pause">Pause</button>
`;
this._animationEl = root.querySelector("#animation");
this._brightnessEl = root.querySelector("#brightness");
this._fpsEl = root.querySelector("#fps");
this._animationEl.addEventListener("change", () => {
this._animation = this._animationEl.value;
if (this._config?.defaultFps[this._animation]) {
this._fps = Math.min(40, Math.round(this._config.defaultFps[this._animation]));
this._fpsEl.value = String(this._fps);
}
this._emitChange();
});
this._brightnessEl.addEventListener("input", () => {
this._brightness = Number(this._brightnessEl.value);
this._emitChange();
});
this._fpsEl.addEventListener("input", () => {
this._fps = Number(this._fpsEl.value);
this._emitChange();
});
root.querySelector('[data-view="3d"]').addEventListener("click", () => {
this._view3d = true;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-view="flat"]').addEventListener("click", () => {
this._view3d = false;
this._syncViewButtons();
this._emitChange();
});
root.querySelector('[data-action="identify"]').addEventListener("click", (e) => {
this._identify = !this._identify;
e.currentTarget.classList.toggle("active", this._identify);
this._emitChange();
});
root.querySelector('[data-action="pause"]').addEventListener("click", (e) => {
this._paused = !this._paused;
e.currentTarget.textContent = this._paused ? "Resume" : "Pause";
this._emitChange();
});
}
/** @param {import('../api.js').PortalConfig} config */
setConfig(config) {
this._config = config;
if (!this.shadowRoot) this.connectedCallback();
this._animationEl.innerHTML = "";
for (const name of config.animations) {
if (name === "solid") continue;
const opt = document.createElement("option");
opt.value = name;
opt.textContent = name;
this._animationEl.appendChild(opt);
}
this._animationEl.value = "rainbow";
this._animation = "rainbow";
this._brightness = config.defaultBrightness ?? 0.35;
this._brightnessEl.value = String(this._brightness);
this._fps = Math.min(40, Math.round(config.defaultFps.rainbow ?? 25));
this._fpsEl.value = String(this._fps);
this._emitChange();
}
_syncViewButtons() {
const root = this.shadowRoot;
root.querySelector('[data-view="3d"]').classList.toggle("active", this._view3d);
root.querySelector('[data-view="flat"]').classList.toggle("active", !this._view3d);
}
_emitChange() {
this.dispatchEvent(
new CustomEvent("portal-settings", {
bubbles: true,
composed: true,
detail: {
animation: this._animation,
brightness: this._brightness,
fps: this._fps,
paused: this._paused,
identify: this._identify,
view3d: this._view3d,
},
}),
);
}
/** @returns {import('../api.js').PortalConfig | null} */
get config() {
return this._config;
}
}
customElements.define("portal-controls", PortalControls);

View File

@@ -0,0 +1,90 @@
import { paintIdentify, paintRgb } from "../panel-paint.js";
const PANEL_STYLES = `
:host {
display: block;
background: #000;
border: 1px solid var(--border, #2a3044);
border-radius: 4px;
image-rendering: pixelated;
}
canvas {
display: block;
width: 100%;
height: auto;
}
.label {
text-align: center;
font-size: 0.75rem;
color: var(--muted, #8b93a8);
margin: 0.35rem 0;
font-family: system-ui, sans-serif;
}
`;
export class PortalPanel extends HTMLElement {
static observedAttributes = ["index", "position", "width", "height"];
constructor() {
super();
this._index = 0;
this._pixelWidth = 45;
this._pixelHeight = 9;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${PANEL_STYLES}</style>
<canvas part="canvas"></canvas>
<div class="label" part="label"></div>
`;
this._canvas = /** @type {HTMLCanvasElement} */ (root.querySelector("canvas"));
this._ctx = /** @type {CanvasRenderingContext2D} */ (this._canvas.getContext("2d"));
this._label = root.querySelector(".label");
this._syncFromAttributes();
}
attributeChangedCallback(name) {
if (!this.shadowRoot) return;
this._syncFromAttributes();
}
_syncFromAttributes() {
this._index = Number(this.getAttribute("index") ?? 0);
this._pixelWidth = Number(this.getAttribute("width") ?? 45);
this._pixelHeight = Number(this.getAttribute("height") ?? 9);
const position = this.getAttribute("position") ?? "";
if (this._label) {
this._label.textContent = `${this._index} · ${position}`;
}
if (this._canvas) {
this._canvas.width = this._pixelWidth;
this._canvas.height = this._pixelHeight;
}
}
/** @returns {HTMLCanvasElement} */
get canvas() {
if (!this._canvas) this.connectedCallback();
return this._canvas;
}
/**
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
setFrame(rgb, width, height) {
if (!this._ctx) this.connectedCallback();
paintRgb(this._ctx, this._canvas, rgb, width, height);
}
showIdentify() {
if (!this._ctx) this.connectedCallback();
paintIdentify(this._ctx, this._canvas, this._index, this._pixelWidth, this._pixelHeight);
}
}
customElements.define("portal-panel", PortalPanel);

View File

@@ -0,0 +1,169 @@
import {
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_HEX_PATH,
SCHEMATIC_PANEL_LAYOUT,
} from "../portal-layout.js";
import "./portal-panel.js";
const SCHEMATIC_STYLES = `
:host {
display: none;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
padding: 1rem;
box-sizing: border-box;
background: radial-gradient(ellipse at 50% 45%, #141820 0%, #0a0c12 70%);
}
:host([visible]) {
display: flex;
}
.hex-stage {
position: relative;
width: min(88vw, 420px);
aspect-ratio: 100 / 118;
}
.hex-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.hex-fill {
fill: rgba(0, 0, 0, 0.35);
}
.hex-stroke {
fill: none;
stroke: #3a4458;
stroke-width: 1.2;
}
.hex-floor {
fill: none;
stroke: #2a3044;
stroke-width: 0.8;
stroke-dasharray: 3 3;
}
.floor-label {
position: absolute;
left: 50%;
top: 91%;
transform: translate(-50%, -50%);
font-size: 0.65rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #5a6278;
font-family: system-ui, sans-serif;
pointer-events: none;
}
.ground {
position: absolute;
left: 5%;
right: 5%;
bottom: -2%;
height: 2px;
background: #3a4050;
}
.slot {
position: absolute;
transform: translate(-50%, -50%);
z-index: 1;
}
.slot portal-panel {
display: block;
transform-origin: center center;
box-shadow: 0 0 12px rgba(255, 60, 40, 0.15);
}
`;
export class PortalSchematic extends HTMLElement {
constructor() {
super();
/** @type {Map<number, import('./portal-panel.js').PortalPanel>} */
this._panels = new Map();
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `
<style>${SCHEMATIC_STYLES}</style>
<div class="hex-stage">
<svg class="hex-svg" viewBox="0 0 100 108" preserveAspectRatio="xMidYMid meet">
<path class="hex-fill" d="${SCHEMATIC_HEX_PATH}" />
<path class="hex-stroke" d="${SCHEMATIC_HEX_PATH}" />
<line class="hex-floor"
x1="${SCHEMATIC_FLOOR_LINE.x1}" y1="${SCHEMATIC_FLOOR_LINE.y1}"
x2="${SCHEMATIC_FLOOR_LINE.x2}" y2="${SCHEMATIC_FLOOR_LINE.y2}" />
</svg>
<div class="floor-label">floor · walk through</div>
<div class="ground"></div>
<div class="slots"></div>
</div>
`;
this._slotsEl = root.querySelector(".slots");
}
_ensureSlot(position) {
let slot = this._slotsEl.querySelector(`[data-position="${position}"]`);
if (slot) return slot;
const layout = SCHEMATIC_PANEL_LAYOUT[position];
if (!layout) return null;
slot = document.createElement("div");
slot.className = "slot";
slot.dataset.position = position;
slot.style.left = `${layout.x}%`;
slot.style.top = `${layout.y}%`;
slot.style.maxWidth = layout.maxW;
this._slotsEl.appendChild(slot);
return slot;
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
this._panels.clear();
this._slotsEl.querySelectorAll(".slot").forEach((s) => {
s.innerHTML = "";
});
for (const panel of panels) {
const slot = this._ensureSlot(panel.position);
if (!slot) continue;
const layout = SCHEMATIC_PANEL_LAYOUT[panel.position];
const el = document.createElement("portal-panel");
el.setAttribute("index", String(panel.index));
el.setAttribute("position", panel.position);
el.setAttribute("width", String(panel.width));
el.setAttribute("height", String(panel.height));
el.style.transform = `rotate(${layout.rot}deg)`;
slot.appendChild(el);
this._panels.set(panel.index, el);
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const panel = this._panels.get(index);
if (!panel) return;
if (identify) panel.showIdentify();
else panel.setFrame(rgb, width, height);
}
set visible(on) {
if (on) this.setAttribute("visible", "");
else this.removeAttribute("visible");
}
}
customElements.define("portal-schematic", PortalSchematic);

View File

@@ -0,0 +1,244 @@
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { paintIdentify, paintRgb } from "../panel-paint.js";
import { hexFrameEdges, panelPlacement, PORTAL } from "../portal-geometry.js";
const HOST_STYLES = `
:host {
display: block;
width: 100%;
height: 100%;
}
.mount {
width: 100%;
height: 100%;
}
`;
const FRAME_MAT = new THREE.MeshStandardMaterial({
color: 0x0a0a0a,
roughness: 0.92,
metalness: 0.05,
});
const BASE_MAT = new THREE.MeshStandardMaterial({
color: 0x050505,
roughness: 0.95,
metalness: 0.02,
});
export class PortalViewport extends HTMLElement {
constructor() {
super();
/** @type {Map<number, object>} */
this._panels = new Map();
this._raf = 0;
}
connectedCallback() {
if (this.shadowRoot) return;
const root = this.attachShadow({ mode: "open" });
root.innerHTML = `<style>${HOST_STYLES}</style><div class="mount"></div>`;
this._mount = root.querySelector(".mount");
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color(0x1a1a1e);
this._scene.fog = new THREE.Fog(0x1a1a1e, 8, 22);
this._camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50);
this._camera.position.set(0, 1.0, 3.8);
this._renderer = new THREE.WebGLRenderer({ antialias: true });
this._renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this._renderer.toneMapping = THREE.ReinhardToneMapping;
this._renderer.toneMappingExposure = 1.15;
this._mount.appendChild(this._renderer.domElement);
this._composer = new EffectComposer(this._renderer);
this._composer.addPass(new RenderPass(this._scene, this._camera));
this._bloom = new UnrealBloomPass(
new THREE.Vector2(1, 1),
0.85,
0.35,
0.15,
);
this._composer.addPass(this._bloom);
this._controls = new OrbitControls(this._camera, this._renderer.domElement);
this._controls.enableDamping = true;
this._controls.target.set(0, 0.95, 0);
this._controls.minDistance = 2.2;
this._controls.maxDistance = 8;
this._scene.add(new THREE.AmbientLight(0x332222, 0.25));
const key = new THREE.DirectionalLight(0xffeedd, 0.35);
key.position.set(2, 5, 4);
this._scene.add(key);
const rim = new THREE.DirectionalLight(0x445566, 0.2);
rim.position.set(-3, 2, -2);
this._scene.add(rim);
this._buildEnvironment();
this._buildFrame();
this._buildBase();
this._onResize = () => this._resize();
window.addEventListener("resize", this._onResize);
this._resize();
this._tick();
}
disconnectedCallback() {
window.removeEventListener("resize", this._onResize);
cancelAnimationFrame(this._raf);
this._renderer?.dispose();
this._composer?.dispose();
}
_buildEnvironment() {
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(14, 14),
new THREE.MeshStandardMaterial({ color: 0x2a2a30, roughness: 0.9, metalness: 0 }),
);
floor.rotation.x = -Math.PI / 2;
floor.position.y = 0;
this._scene.add(floor);
const back = new THREE.Mesh(
new THREE.PlaneGeometry(12, 6),
new THREE.MeshStandardMaterial({ color: 0x3a3a42, roughness: 0.95 }),
);
back.position.set(0, 2.5, -4);
this._scene.add(back);
}
_buildFrame() {
const { height, frameDepth } = PORTAL;
const group = new THREE.Group();
for (const edge of hexFrameEdges()) {
if (edge.isFloor) continue;
const ax = edge.outerA[0];
const az = edge.outerA[1];
const bx = edge.outerB[0];
const bz = edge.outerB[1];
const dx = bx - ax;
const dz = bz - az;
const len = Math.hypot(dx, dz);
const beam = new THREE.Mesh(
new THREE.BoxGeometry(len, height, frameDepth),
FRAME_MAT,
);
beam.position.set((ax + bx) / 2, height / 2, (az + bz) / 2);
beam.rotation.y = Math.atan2(dx, dz);
group.add(beam);
const lip = new THREE.Mesh(
new THREE.BoxGeometry(len * 0.98, frameDepth * 0.6, frameDepth * 0.35),
FRAME_MAT,
);
lip.position.set((ax + bx) / 2, height - frameDepth * 0.25, (az + bz) / 2);
lip.rotation.y = Math.atan2(dx, dz);
group.add(lip);
}
this._scene.add(group);
}
_buildBase() {
const { apothem, baseThickness, baseExtend } = PORTAL;
const w = apothem * 2 + baseExtend * 2;
const base = new THREE.Mesh(
new THREE.BoxGeometry(w, baseThickness, w * 0.85),
BASE_MAT,
);
base.position.set(0, baseThickness / 2, 0.05);
this._scene.add(base);
}
_resize() {
const w = this.clientWidth || 1;
const h = this.clientHeight || 1;
this._camera.aspect = w / h;
this._camera.updateProjectionMatrix();
this._renderer.setSize(w, h);
this._composer.setSize(w, h);
this._bloom.resolution.set(w, h);
}
_tick() {
this._raf = requestAnimationFrame(() => this._tick());
this._controls.update();
this._composer.render();
}
/** @param {import('../api.js').PanelConfig[]} panels */
setPanels(panels) {
if (!this.shadowRoot) this.connectedCallback();
for (const entry of this._panels.values()) {
this._scene.remove(entry.mesh);
entry.texture.dispose();
entry.material.dispose();
entry.geometry.dispose();
}
this._panels.clear();
for (const panel of panels) {
const place = panelPlacement(panel.position, panel.width);
const geometry = new THREE.PlaneGeometry(place.width, place.height);
const canvas = document.createElement("canvas");
canvas.width = panel.width;
canvas.height = panel.height;
const ctx = canvas.getContext("2d");
const texture = new THREE.CanvasTexture(canvas);
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.colorSpace = THREE.SRGBColorSpace;
const material = new THREE.MeshBasicMaterial({
map: texture,
side: THREE.FrontSide,
toneMapped: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(place.pos[0], place.pos[1], place.pos[2]);
mesh.rotation.set(place.rot[0], place.rot[1], place.rot[2]);
this._scene.add(mesh);
this._panels.set(panel.index, {
mesh,
geometry,
material,
canvas,
ctx,
texture,
panel,
});
}
}
/**
* @param {number} index
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
* @param {boolean} identify
*/
updatePanel(index, rgb, width, height, identify) {
const entry = this._panels.get(index);
if (!entry) return;
if (identify) {
paintIdentify(entry.ctx, entry.canvas, index, entry.panel.width, entry.panel.height);
} else {
paintRgb(entry.ctx, entry.canvas, rgb, width, height);
}
entry.texture.needsUpdate = true;
}
}
customElements.define("portal-viewport", PortalViewport);

17
web/js/dev-reload.js Normal file
View File

@@ -0,0 +1,17 @@
/** Live reload when web/ files change (dev server only). */
function connect() {
const source = new EventSource("/api/dev/reload");
source.onmessage = (event) => {
if (event.data === "reload") {
console.log("[portal] reloading…");
location.reload();
}
};
source.onerror = () => {
source.close();
setTimeout(connect, 1500);
};
}
connect();

1
web/js/main.js Normal file
View File

@@ -0,0 +1 @@
import "./components/portal-app.js";

51
web/js/panel-paint.js Normal file
View File

@@ -0,0 +1,51 @@
export const PANEL_COLORS = [
0xff5050, 0x50ff50, 0x50a0ff, 0xffc850, 0xc850ff,
];
/**
* Paint a panel frame. `rgb` is logical [R,G,B,R,G,B,…] from the Python simulator
* (same byte order as UDP panel frames). Canvas ImageData uses RGBA.
*
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number[] | Uint8Array} rgb
* @param {number} width
* @param {number} height
*/
export function paintRgb(ctx, canvas, rgb, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const image = ctx.createImageData(width, height);
const data = image.data;
for (let i = 0, p = 0; i < rgb.length; i += 3, p += 4) {
data[p] = rgb[i];
data[p + 1] = rgb[i + 1];
data[p + 2] = rgb[i + 2];
data[p + 3] = 255;
}
ctx.putImageData(image, 0, 0);
}
/**
* @param {CanvasRenderingContext2D} ctx
* @param {HTMLCanvasElement} canvas
* @param {number} panelIndex
* @param {number} width
* @param {number} height
*/
export function paintIdentify(ctx, canvas, panelIndex, width, height) {
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
const hex = PANEL_COLORS[panelIndex % PANEL_COLORS.length].toString(16).padStart(6, "0");
ctx.fillStyle = `#${hex}`;
ctx.font = `bold ${Math.floor(height * 0.75)}px monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(String(panelIndex), width / 2, height / 2);
}

168
web/js/portal-geometry.js Normal file
View File

@@ -0,0 +1,168 @@
/** Hexagonal portal geometry — matches physical flat-top hex arch. */
/** Scene scale (~2.5 m tall portal). */
export const PORTAL = {
/** Center to flat-edge (apothem) of inner opening. */
apothem: 1.05,
height: 1.85,
frameDepth: 0.14,
frameWall: 0.09,
baseThickness: 0.07,
baseExtend: 0.18,
ledInset: 0.02,
};
const CELL = 0.078;
/**
* Flat-top hex vertex ring in XZ (y = 0).
* @param {number} apothem
* @returns {[number, number][]}
*/
export function hexVerticesXZ(apothem) {
const R = (apothem * 2) / Math.sqrt(3);
const verts = [];
for (let i = 0; i < 6; i++) {
const angle = Math.PI / 6 + (i * Math.PI) / 3;
verts.push([R * Math.cos(angle), R * Math.sin(angle)]);
}
return verts;
}
/**
* @param {[number, number]} a
* @param {[number, number]} b
*/
function edgeMid(a, b) {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
/**
* Inward normal for flat-top hex edge (XZ plane).
* @param {number} edgeIndex 0..5 clockwise from lower-right sloped edge
*/
function edgeInwardNormal(verts, edgeIndex) {
const a = verts[edgeIndex];
const b = verts[(edgeIndex + 1) % 6];
const dx = b[0] - a[0];
const dz = b[1] - a[1];
const len = Math.hypot(dx, dz) || 1;
const nx = dz / len;
const nz = -dx / len;
const mx = (a[0] + b[0]) / 2;
const mz = (a[1] + b[1]) / 2;
if (mx * nx + mz * nz < 0) {
return [-nx, -nz];
}
return [nx, nz];
}
/**
* Wall panel placement on inner hex face.
* Edge mapping (clockwise from bottom-right sloped edge):
* 0 bottom-right → panel 4
* 1 bottom (floor, no LEDs)
* 2 bottom-left → panel 0
* 3 top-left → panel 1
* 4 top (panel 2 is horizontal ceiling, not this edge)
* 5 top-right → panel 3
* @param {string} position
* @param {number} pixelWidth
*/
export function panelPlacement(position, pixelWidth = 45) {
const { apothem, height, frameDepth, ledInset } = PORTAL;
const verts = hexVerticesXZ(apothem);
const h = 9 * CELL;
const w = pixelWidth * CELL;
const wallY = height * 0.42;
const topY = height - frameDepth * 0.5;
/** @type {Record<string, { edge: number, y: number, rotYExtra?: number }>} */
const wallMap = {
"bottom-right": { edge: 0, y: wallY * 0.72 },
"bottom-left": { edge: 2, y: wallY * 0.72 },
"top-left": { edge: 3, y: wallY * 1.38 },
"top-right": { edge: 5, y: wallY * 1.38 },
};
if (position === "top") {
return {
pos: [0, topY, 0],
rot: [-Math.PI / 2, 0, 0],
width: 45 * CELL,
height: h,
};
}
const spec = wallMap[position];
if (!spec) {
return { pos: [0, 0, 0], rot: [0, 0, 0], width: w, height: h };
}
const a = verts[spec.edge];
const b = verts[(spec.edge + 1) % 6];
const mid = edgeMid(a, b);
const [nx, nz] = edgeInwardNormal(verts, spec.edge);
const inset = frameDepth * 0.5 + ledInset;
const px = mid[0] + nx * inset;
const pz = mid[1] + nz * inset;
const rotY = Math.atan2(nx, nz);
return {
pos: [px, spec.y, pz],
rot: [0, rotY, 0],
width: w,
height: h,
};
}
/**
* Build dark frame beam meshes data for each hex edge.
* @returns {Array<{ from: [number,number], to: [number,number], isFloor: boolean }>}
*/
export function hexFrameEdges() {
const outer = hexVerticesXZ(PORTAL.apothem + PORTAL.frameWall);
const inner = hexVerticesXZ(PORTAL.apothem);
const edges = [];
for (let i = 0; i < 6; i++) {
edges.push({
outerA: outer[i],
outerB: outer[(i + 1) % 6],
innerA: inner[i],
innerB: inner[(i + 1) % 6],
isFloor: i === 1,
});
}
return edges;
}
export { CELL };
/** @type {Record<string, string>} */
export const SCHEMATIC_SLOTS = {
"top-left": "slot-tl",
top: "slot-top",
"top-right": "slot-tr",
"bottom-left": "slot-bl",
"bottom-right": "slot-br",
};
/** Front elevation of upright flat-top hex arch (viewBox 0 0 100 108). */
export const SCHEMATIC_HEX_PATH =
"M 20 4 L 80 4 L 96 48 L 80 96 L 20 96 L 4 48 Z";
/** Bottom opening — floor, no LEDs. */
export const SCHEMATIC_FLOOR_LINE = { x1: 20, y1: 96, x2: 80, y2: 96 };
/**
* Panel placement on standing front view (% of stage, rotation deg).
* @type {Record<string, { x: number, y: number, rot: number, maxW: string }>}
*/
export const SCHEMATIC_PANEL_LAYOUT = {
top: { x: 50, y: 7, rot: 0, maxW: "52%" },
"top-left": { x: 15, y: 30, rot: -58, maxW: "34%" },
"top-right": { x: 85, y: 30, rot: 58, maxW: "34%" },
"bottom-left": { x: 15, y: 76, rot: 58, maxW: "32%" },
"bottom-right": { x: 85, y: 76, rot: -58, maxW: "32%" },
};

9
web/js/portal-layout.js Normal file
View File

@@ -0,0 +1,9 @@
export {
panelPlacement,
SCHEMATIC_SLOTS,
SCHEMATIC_HEX_PATH,
SCHEMATIC_FLOOR_LINE,
SCHEMATIC_PANEL_LAYOUT,
PORTAL,
CELL,
} from "./portal-geometry.js";

20
web/style.css Normal file
View File

@@ -0,0 +1,20 @@
:root {
color-scheme: dark;
--bg: #0a0c12;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
}
portal-app {
display: block;
height: 100%;
}

1523
web/vendor/controls/OrbitControls.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import {
Clock,
HalfFloatType,
NoBlending,
Vector2,
WebGLRenderTarget
} from 'three';
import { CopyShader } from '../shaders/CopyShader.js';
import { ShaderPass } from './ShaderPass.js';
import { MaskPass } from './MaskPass.js';
import { ClearMaskPass } from './MaskPass.js';
class EffectComposer {
constructor( renderer, renderTarget ) {
this.renderer = renderer;
this._pixelRatio = renderer.getPixelRatio();
if ( renderTarget === undefined ) {
const size = renderer.getSize( new Vector2() );
this._width = size.width;
this._height = size.height;
renderTarget = new WebGLRenderTarget( this._width * this._pixelRatio, this._height * this._pixelRatio, { type: HalfFloatType } );
renderTarget.texture.name = 'EffectComposer.rt1';
} else {
this._width = renderTarget.width;
this._height = renderTarget.height;
}
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.renderTarget2.texture.name = 'EffectComposer.rt2';
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
this.renderToScreen = true;
this.passes = [];
this.copyPass = new ShaderPass( CopyShader );
this.copyPass.material.blending = NoBlending;
this.clock = new Clock();
}
swapBuffers() {
const tmp = this.readBuffer;
this.readBuffer = this.writeBuffer;
this.writeBuffer = tmp;
}
addPass( pass ) {
this.passes.push( pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
insertPass( pass, index ) {
this.passes.splice( index, 0, pass );
pass.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
removePass( pass ) {
const index = this.passes.indexOf( pass );
if ( index !== - 1 ) {
this.passes.splice( index, 1 );
}
}
isLastEnabledPass( passIndex ) {
for ( let i = passIndex + 1; i < this.passes.length; i ++ ) {
if ( this.passes[ i ].enabled ) {
return false;
}
}
return true;
}
render( deltaTime ) {
// deltaTime value is in seconds
if ( deltaTime === undefined ) {
deltaTime = this.clock.getDelta();
}
const currentRenderTarget = this.renderer.getRenderTarget();
let maskActive = false;
for ( let i = 0, il = this.passes.length; i < il; i ++ ) {
const pass = this.passes[ i ];
if ( pass.enabled === false ) continue;
pass.renderToScreen = ( this.renderToScreen && this.isLastEnabledPass( i ) );
pass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime, maskActive );
if ( pass.needsSwap ) {
if ( maskActive ) {
const context = this.renderer.getContext();
const stencil = this.renderer.state.buffers.stencil;
//context.stencilFunc( context.NOTEQUAL, 1, 0xffffffff );
stencil.setFunc( context.NOTEQUAL, 1, 0xffffffff );
this.copyPass.render( this.renderer, this.writeBuffer, this.readBuffer, deltaTime );
//context.stencilFunc( context.EQUAL, 1, 0xffffffff );
stencil.setFunc( context.EQUAL, 1, 0xffffffff );
}
this.swapBuffers();
}
if ( MaskPass !== undefined ) {
if ( pass instanceof MaskPass ) {
maskActive = true;
} else if ( pass instanceof ClearMaskPass ) {
maskActive = false;
}
}
}
this.renderer.setRenderTarget( currentRenderTarget );
}
reset( renderTarget ) {
if ( renderTarget === undefined ) {
const size = this.renderer.getSize( new Vector2() );
this._pixelRatio = this.renderer.getPixelRatio();
this._width = size.width;
this._height = size.height;
renderTarget = this.renderTarget1.clone();
renderTarget.setSize( this._width * this._pixelRatio, this._height * this._pixelRatio );
}
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.renderTarget1 = renderTarget;
this.renderTarget2 = renderTarget.clone();
this.writeBuffer = this.renderTarget1;
this.readBuffer = this.renderTarget2;
}
setSize( width, height ) {
this._width = width;
this._height = height;
const effectiveWidth = this._width * this._pixelRatio;
const effectiveHeight = this._height * this._pixelRatio;
this.renderTarget1.setSize( effectiveWidth, effectiveHeight );
this.renderTarget2.setSize( effectiveWidth, effectiveHeight );
for ( let i = 0; i < this.passes.length; i ++ ) {
this.passes[ i ].setSize( effectiveWidth, effectiveHeight );
}
}
setPixelRatio( pixelRatio ) {
this._pixelRatio = pixelRatio;
this.setSize( this._width, this._height );
}
dispose() {
this.renderTarget1.dispose();
this.renderTarget2.dispose();
this.copyPass.dispose();
}
}
export { EffectComposer };

104
web/vendor/postprocessing/MaskPass.js vendored Normal file
View File

@@ -0,0 +1,104 @@
import { Pass } from './Pass.js';
class MaskPass extends Pass {
constructor( scene, camera ) {
super();
this.scene = scene;
this.camera = camera;
this.clear = true;
this.needsSwap = false;
this.inverse = false;
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const context = renderer.getContext();
const state = renderer.state;
// don't update color or depth
state.buffers.color.setMask( false );
state.buffers.depth.setMask( false );
// lock buffers
state.buffers.color.setLocked( true );
state.buffers.depth.setLocked( true );
// set up stencil
let writeValue, clearValue;
if ( this.inverse ) {
writeValue = 0;
clearValue = 1;
} else {
writeValue = 1;
clearValue = 0;
}
state.buffers.stencil.setTest( true );
state.buffers.stencil.setOp( context.REPLACE, context.REPLACE, context.REPLACE );
state.buffers.stencil.setFunc( context.ALWAYS, writeValue, 0xffffffff );
state.buffers.stencil.setClear( clearValue );
state.buffers.stencil.setLocked( true );
// draw into the stencil buffer
renderer.setRenderTarget( readBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
renderer.setRenderTarget( writeBuffer );
if ( this.clear ) renderer.clear();
renderer.render( this.scene, this.camera );
// unlock color and depth buffer and make them writable for subsequent rendering/clearing
state.buffers.color.setLocked( false );
state.buffers.depth.setLocked( false );
state.buffers.color.setMask( true );
state.buffers.depth.setMask( true );
// only render where stencil is set to 1
state.buffers.stencil.setLocked( false );
state.buffers.stencil.setFunc( context.EQUAL, 1, 0xffffffff ); // draw if == 1
state.buffers.stencil.setOp( context.KEEP, context.KEEP, context.KEEP );
state.buffers.stencil.setLocked( true );
}
}
class ClearMaskPass extends Pass {
constructor() {
super();
this.needsSwap = false;
}
render( renderer /*, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
renderer.state.buffers.stencil.setLocked( false );
renderer.state.buffers.stencil.setTest( false );
}
}
export { MaskPass, ClearMaskPass };

95
web/vendor/postprocessing/Pass.js vendored Normal file
View File

@@ -0,0 +1,95 @@
import {
BufferGeometry,
Float32BufferAttribute,
OrthographicCamera,
Mesh
} from 'three';
class Pass {
constructor() {
this.isPass = true;
// if set to true, the pass is processed by the composer
this.enabled = true;
// if set to true, the pass indicates to swap read and write buffer after rendering
this.needsSwap = true;
// if set to true, the pass clears its buffer before rendering
this.clear = false;
// if set to true, the result of the pass is rendered to screen. This is set automatically by EffectComposer.
this.renderToScreen = false;
}
setSize( /* width, height */ ) {}
render( /* renderer, writeBuffer, readBuffer, deltaTime, maskActive */ ) {
console.error( 'THREE.Pass: .render() must be implemented in derived pass.' );
}
dispose() {}
}
// Helper for passes that need to fill the viewport with a single quad.
const _camera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 );
// https://github.com/mrdoob/three.js/pull/21358
class FullscreenTriangleGeometry extends BufferGeometry {
constructor() {
super();
this.setAttribute( 'position', new Float32BufferAttribute( [ - 1, 3, 0, - 1, - 1, 0, 3, - 1, 0 ], 3 ) );
this.setAttribute( 'uv', new Float32BufferAttribute( [ 0, 2, 0, 0, 2, 0 ], 2 ) );
}
}
const _geometry = new FullscreenTriangleGeometry();
class FullScreenQuad {
constructor( material ) {
this._mesh = new Mesh( _geometry, material );
}
dispose() {
this._mesh.geometry.dispose();
}
render( renderer ) {
renderer.render( this._mesh, _camera );
}
get material() {
return this._mesh.material;
}
set material( value ) {
this._mesh.material = value;
}
}
export { Pass, FullScreenQuad };

99
web/vendor/postprocessing/RenderPass.js vendored Normal file
View File

@@ -0,0 +1,99 @@
import {
Color
} from 'three';
import { Pass } from './Pass.js';
class RenderPass extends Pass {
constructor( scene, camera, overrideMaterial = null, clearColor = null, clearAlpha = null ) {
super();
this.scene = scene;
this.camera = camera;
this.overrideMaterial = overrideMaterial;
this.clearColor = clearColor;
this.clearAlpha = clearAlpha;
this.clear = true;
this.clearDepth = false;
this.needsSwap = false;
this._oldClearColor = new Color();
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
let oldClearAlpha, oldOverrideMaterial;
if ( this.overrideMaterial !== null ) {
oldOverrideMaterial = this.scene.overrideMaterial;
this.scene.overrideMaterial = this.overrideMaterial;
}
if ( this.clearColor !== null ) {
renderer.getClearColor( this._oldClearColor );
renderer.setClearColor( this.clearColor, renderer.getClearAlpha() );
}
if ( this.clearAlpha !== null ) {
oldClearAlpha = renderer.getClearAlpha();
renderer.setClearAlpha( this.clearAlpha );
}
if ( this.clearDepth == true ) {
renderer.clearDepth();
}
renderer.setRenderTarget( this.renderToScreen ? null : readBuffer );
if ( this.clear === true ) {
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
}
renderer.render( this.scene, this.camera );
// restore
if ( this.clearColor !== null ) {
renderer.setClearColor( this._oldClearColor );
}
if ( this.clearAlpha !== null ) {
renderer.setClearAlpha( oldClearAlpha );
}
if ( this.overrideMaterial !== null ) {
this.scene.overrideMaterial = oldOverrideMaterial;
}
renderer.autoClear = oldAutoClear;
}
}
export { RenderPass };

77
web/vendor/postprocessing/ShaderPass.js vendored Normal file
View File

@@ -0,0 +1,77 @@
import {
ShaderMaterial,
UniformsUtils
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
class ShaderPass extends Pass {
constructor( shader, textureID ) {
super();
this.textureID = ( textureID !== undefined ) ? textureID : 'tDiffuse';
if ( shader instanceof ShaderMaterial ) {
this.uniforms = shader.uniforms;
this.material = shader;
} else if ( shader ) {
this.uniforms = UniformsUtils.clone( shader.uniforms );
this.material = new ShaderMaterial( {
name: ( shader.name !== undefined ) ? shader.name : 'unspecified',
defines: Object.assign( {}, shader.defines ),
uniforms: this.uniforms,
vertexShader: shader.vertexShader,
fragmentShader: shader.fragmentShader
} );
}
this.fsQuad = new FullScreenQuad( this.material );
}
render( renderer, writeBuffer, readBuffer /*, deltaTime, maskActive */ ) {
if ( this.uniforms[ this.textureID ] ) {
this.uniforms[ this.textureID ].value = readBuffer.texture;
}
this.fsQuad.material = this.material;
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( writeBuffer );
// TODO: Avoid using autoClear properties, see https://github.com/mrdoob/three.js/pull/15571#issuecomment-465669600
if ( this.clear ) renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil );
this.fsQuad.render( renderer );
}
}
dispose() {
this.material.dispose();
this.fsQuad.dispose();
}
}
export { ShaderPass };

View File

@@ -0,0 +1,415 @@
import {
AdditiveBlending,
Color,
HalfFloatType,
MeshBasicMaterial,
ShaderMaterial,
UniformsUtils,
Vector2,
Vector3,
WebGLRenderTarget
} from 'three';
import { Pass, FullScreenQuad } from './Pass.js';
import { CopyShader } from '../shaders/CopyShader.js';
import { LuminosityHighPassShader } from '../shaders/LuminosityHighPassShader.js';
/**
* UnrealBloomPass is inspired by the bloom pass of Unreal Engine. It creates a
* mip map chain of bloom textures and blurs them with different radii. Because
* of the weighted combination of mips, and because larger blurs are done on
* higher mips, this effect provides good quality and performance.
*
* Reference:
* - https://docs.unrealengine.com/latest/INT/Engine/Rendering/PostProcessEffects/Bloom/
*/
class UnrealBloomPass extends Pass {
constructor( resolution, strength, radius, threshold ) {
super();
this.strength = ( strength !== undefined ) ? strength : 1;
this.radius = radius;
this.threshold = threshold;
this.resolution = ( resolution !== undefined ) ? new Vector2( resolution.x, resolution.y ) : new Vector2( 256, 256 );
// create color only once here, reuse it later inside the render function
this.clearColor = new Color( 0, 0, 0 );
// render targets
this.renderTargetsHorizontal = [];
this.renderTargetsVertical = [];
this.nMips = 5;
let resx = Math.round( this.resolution.x / 2 );
let resy = Math.round( this.resolution.y / 2 );
this.renderTargetBright = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
this.renderTargetBright.texture.name = 'UnrealBloomPass.bright';
this.renderTargetBright.texture.generateMipmaps = false;
for ( let i = 0; i < this.nMips; i ++ ) {
const renderTargetHorizontal = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetHorizontal.texture.name = 'UnrealBloomPass.h' + i;
renderTargetHorizontal.texture.generateMipmaps = false;
this.renderTargetsHorizontal.push( renderTargetHorizontal );
const renderTargetVertical = new WebGLRenderTarget( resx, resy, { type: HalfFloatType } );
renderTargetVertical.texture.name = 'UnrealBloomPass.v' + i;
renderTargetVertical.texture.generateMipmaps = false;
this.renderTargetsVertical.push( renderTargetVertical );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// luminosity high pass material
const highPassShader = LuminosityHighPassShader;
this.highPassUniforms = UniformsUtils.clone( highPassShader.uniforms );
this.highPassUniforms[ 'luminosityThreshold' ].value = threshold;
this.highPassUniforms[ 'smoothWidth' ].value = 0.01;
this.materialHighPassFilter = new ShaderMaterial( {
uniforms: this.highPassUniforms,
vertexShader: highPassShader.vertexShader,
fragmentShader: highPassShader.fragmentShader
} );
// gaussian blur materials
this.separableBlurMaterials = [];
const kernelSizeArray = [ 3, 5, 7, 9, 11 ];
resx = Math.round( this.resolution.x / 2 );
resy = Math.round( this.resolution.y / 2 );
for ( let i = 0; i < this.nMips; i ++ ) {
this.separableBlurMaterials.push( this.getSeperableBlurMaterial( kernelSizeArray[ i ] ) );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
// composite material
this.compositeMaterial = this.getCompositeMaterial( this.nMips );
this.compositeMaterial.uniforms[ 'blurTexture1' ].value = this.renderTargetsVertical[ 0 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture2' ].value = this.renderTargetsVertical[ 1 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture3' ].value = this.renderTargetsVertical[ 2 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture4' ].value = this.renderTargetsVertical[ 3 ].texture;
this.compositeMaterial.uniforms[ 'blurTexture5' ].value = this.renderTargetsVertical[ 4 ].texture;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = 0.1;
const bloomFactors = [ 1.0, 0.8, 0.6, 0.4, 0.2 ];
this.compositeMaterial.uniforms[ 'bloomFactors' ].value = bloomFactors;
this.bloomTintColors = [ new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ), new Vector3( 1, 1, 1 ) ];
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
// blend material
const copyShader = CopyShader;
this.copyUniforms = UniformsUtils.clone( copyShader.uniforms );
this.blendMaterial = new ShaderMaterial( {
uniforms: this.copyUniforms,
vertexShader: copyShader.vertexShader,
fragmentShader: copyShader.fragmentShader,
blending: AdditiveBlending,
depthTest: false,
depthWrite: false,
transparent: true
} );
this.enabled = true;
this.needsSwap = false;
this._oldClearColor = new Color();
this.oldClearAlpha = 1;
this.basic = new MeshBasicMaterial();
this.fsQuad = new FullScreenQuad( null );
}
dispose() {
for ( let i = 0; i < this.renderTargetsHorizontal.length; i ++ ) {
this.renderTargetsHorizontal[ i ].dispose();
}
for ( let i = 0; i < this.renderTargetsVertical.length; i ++ ) {
this.renderTargetsVertical[ i ].dispose();
}
this.renderTargetBright.dispose();
//
for ( let i = 0; i < this.separableBlurMaterials.length; i ++ ) {
this.separableBlurMaterials[ i ].dispose();
}
this.compositeMaterial.dispose();
this.blendMaterial.dispose();
this.basic.dispose();
//
this.fsQuad.dispose();
}
setSize( width, height ) {
let resx = Math.round( width / 2 );
let resy = Math.round( height / 2 );
this.renderTargetBright.setSize( resx, resy );
for ( let i = 0; i < this.nMips; i ++ ) {
this.renderTargetsHorizontal[ i ].setSize( resx, resy );
this.renderTargetsVertical[ i ].setSize( resx, resy );
this.separableBlurMaterials[ i ].uniforms[ 'invSize' ].value = new Vector2( 1 / resx, 1 / resy );
resx = Math.round( resx / 2 );
resy = Math.round( resy / 2 );
}
}
render( renderer, writeBuffer, readBuffer, deltaTime, maskActive ) {
renderer.getClearColor( this._oldClearColor );
this.oldClearAlpha = renderer.getClearAlpha();
const oldAutoClear = renderer.autoClear;
renderer.autoClear = false;
renderer.setClearColor( this.clearColor, 0 );
if ( maskActive ) renderer.state.buffers.stencil.setTest( false );
// Render input to screen
if ( this.renderToScreen ) {
this.fsQuad.material = this.basic;
this.basic.map = readBuffer.texture;
renderer.setRenderTarget( null );
renderer.clear();
this.fsQuad.render( renderer );
}
// 1. Extract Bright Areas
this.highPassUniforms[ 'tDiffuse' ].value = readBuffer.texture;
this.highPassUniforms[ 'luminosityThreshold' ].value = this.threshold;
this.fsQuad.material = this.materialHighPassFilter;
renderer.setRenderTarget( this.renderTargetBright );
renderer.clear();
this.fsQuad.render( renderer );
// 2. Blur All the mips progressively
let inputRenderTarget = this.renderTargetBright;
for ( let i = 0; i < this.nMips; i ++ ) {
this.fsQuad.material = this.separableBlurMaterials[ i ];
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = inputRenderTarget.texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionX;
renderer.setRenderTarget( this.renderTargetsHorizontal[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
this.separableBlurMaterials[ i ].uniforms[ 'colorTexture' ].value = this.renderTargetsHorizontal[ i ].texture;
this.separableBlurMaterials[ i ].uniforms[ 'direction' ].value = UnrealBloomPass.BlurDirectionY;
renderer.setRenderTarget( this.renderTargetsVertical[ i ] );
renderer.clear();
this.fsQuad.render( renderer );
inputRenderTarget = this.renderTargetsVertical[ i ];
}
// Composite All the mips
this.fsQuad.material = this.compositeMaterial;
this.compositeMaterial.uniforms[ 'bloomStrength' ].value = this.strength;
this.compositeMaterial.uniforms[ 'bloomRadius' ].value = this.radius;
this.compositeMaterial.uniforms[ 'bloomTintColors' ].value = this.bloomTintColors;
renderer.setRenderTarget( this.renderTargetsHorizontal[ 0 ] );
renderer.clear();
this.fsQuad.render( renderer );
// Blend it additively over the input texture
this.fsQuad.material = this.blendMaterial;
this.copyUniforms[ 'tDiffuse' ].value = this.renderTargetsHorizontal[ 0 ].texture;
if ( maskActive ) renderer.state.buffers.stencil.setTest( true );
if ( this.renderToScreen ) {
renderer.setRenderTarget( null );
this.fsQuad.render( renderer );
} else {
renderer.setRenderTarget( readBuffer );
this.fsQuad.render( renderer );
}
// Restore renderer settings
renderer.setClearColor( this._oldClearColor, this.oldClearAlpha );
renderer.autoClear = oldAutoClear;
}
getSeperableBlurMaterial( kernelRadius ) {
const coefficients = [];
for ( let i = 0; i < kernelRadius; i ++ ) {
coefficients.push( 0.39894 * Math.exp( - 0.5 * i * i / ( kernelRadius * kernelRadius ) ) / kernelRadius );
}
return new ShaderMaterial( {
defines: {
'KERNEL_RADIUS': kernelRadius
},
uniforms: {
'colorTexture': { value: null },
'invSize': { value: new Vector2( 0.5, 0.5 ) }, // inverse texture size
'direction': { value: new Vector2( 0.5, 0.5 ) },
'gaussianCoefficients': { value: coefficients } // precomputed Gaussian coefficients
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`#include <common>
varying vec2 vUv;
uniform sampler2D colorTexture;
uniform vec2 invSize;
uniform vec2 direction;
uniform float gaussianCoefficients[KERNEL_RADIUS];
void main() {
float weightSum = gaussianCoefficients[0];
vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
float x = float(i);
float w = gaussianCoefficients[i];
vec2 uvOffset = direction * invSize * x;
vec3 sample1 = texture2D( colorTexture, vUv + uvOffset ).rgb;
vec3 sample2 = texture2D( colorTexture, vUv - uvOffset ).rgb;
diffuseSum += (sample1 + sample2) * w;
weightSum += 2.0 * w;
}
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
}`
} );
}
getCompositeMaterial( nMips ) {
return new ShaderMaterial( {
defines: {
'NUM_MIPS': nMips
},
uniforms: {
'blurTexture1': { value: null },
'blurTexture2': { value: null },
'blurTexture3': { value: null },
'blurTexture4': { value: null },
'blurTexture5': { value: null },
'bloomStrength': { value: 1.0 },
'bloomFactors': { value: null },
'bloomTintColors': { value: null },
'bloomRadius': { value: 0.0 }
},
vertexShader:
`varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader:
`varying vec2 vUv;
uniform sampler2D blurTexture1;
uniform sampler2D blurTexture2;
uniform sampler2D blurTexture3;
uniform sampler2D blurTexture4;
uniform sampler2D blurTexture5;
uniform float bloomStrength;
uniform float bloomRadius;
uniform float bloomFactors[NUM_MIPS];
uniform vec3 bloomTintColors[NUM_MIPS];
float lerpBloomFactor(const in float factor) {
float mirrorFactor = 1.2 - factor;
return mix(factor, mirrorFactor, bloomRadius);
}
void main() {
gl_FragColor = bloomStrength * ( lerpBloomFactor(bloomFactors[0]) * vec4(bloomTintColors[0], 1.0) * texture2D(blurTexture1, vUv) +
lerpBloomFactor(bloomFactors[1]) * vec4(bloomTintColors[1], 1.0) * texture2D(blurTexture2, vUv) +
lerpBloomFactor(bloomFactors[2]) * vec4(bloomTintColors[2], 1.0) * texture2D(blurTexture3, vUv) +
lerpBloomFactor(bloomFactors[3]) * vec4(bloomTintColors[3], 1.0) * texture2D(blurTexture4, vUv) +
lerpBloomFactor(bloomFactors[4]) * vec4(bloomTintColors[4], 1.0) * texture2D(blurTexture5, vUv) );
}`
} );
}
}
UnrealBloomPass.BlurDirectionX = new Vector2( 1.0, 0.0 );
UnrealBloomPass.BlurDirectionY = new Vector2( 0.0, 1.0 );
export { UnrealBloomPass };

45
web/vendor/shaders/CopyShader.js vendored Normal file
View File

@@ -0,0 +1,45 @@
/**
* Full-screen textured quad shader
*/
const CopyShader = {
name: 'CopyShader',
uniforms: {
'tDiffuse': { value: null },
'opacity': { value: 1.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform float opacity;
uniform sampler2D tDiffuse;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
gl_FragColor = opacity * texel;
}`
};
export { CopyShader };

View File

@@ -0,0 +1,64 @@
import {
Color
} from 'three';
/**
* Luminosity
* http://en.wikipedia.org/wiki/Luminosity
*/
const LuminosityHighPassShader = {
name: 'LuminosityHighPassShader',
shaderID: 'luminosityHighPass',
uniforms: {
'tDiffuse': { value: null },
'luminosityThreshold': { value: 1.0 },
'smoothWidth': { value: 1.0 },
'defaultColor': { value: new Color( 0x000000 ) },
'defaultOpacity': { value: 0.0 }
},
vertexShader: /* glsl */`
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: /* glsl */`
uniform sampler2D tDiffuse;
uniform vec3 defaultColor;
uniform float defaultOpacity;
uniform float luminosityThreshold;
uniform float smoothWidth;
varying vec2 vUv;
void main() {
vec4 texel = texture2D( tDiffuse, vUv );
float v = luminance( texel.xyz );
vec4 outputColor = vec4( defaultColor.rgb, defaultOpacity );
float alpha = smoothstep( luminosityThreshold, luminosityThreshold + smoothWidth, v );
gl_FragColor = mix( outputColor, texel, alpha );
}`
};
export { LuminosityHighPassShader };

54571
web/vendor/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long