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:
22
web/index.html
Normal file
22
web/index.html
Normal 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
56
web/js/api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
web/js/components/portal-app.js
Normal file
154
web/js/components/portal-app.js
Normal 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);
|
||||
185
web/js/components/portal-controls.js
Normal file
185
web/js/components/portal-controls.js
Normal 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);
|
||||
90
web/js/components/portal-panel.js
Normal file
90
web/js/components/portal-panel.js
Normal 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);
|
||||
169
web/js/components/portal-schematic.js
Normal file
169
web/js/components/portal-schematic.js
Normal 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);
|
||||
244
web/js/components/portal-viewport.js
Normal file
244
web/js/components/portal-viewport.js
Normal 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
17
web/js/dev-reload.js
Normal 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
1
web/js/main.js
Normal file
@@ -0,0 +1 @@
|
||||
import "./components/portal-app.js";
|
||||
51
web/js/panel-paint.js
Normal file
51
web/js/panel-paint.js
Normal 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
168
web/js/portal-geometry.js
Normal 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
9
web/js/portal-layout.js
Normal 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
20
web/style.css
Normal 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
1523
web/vendor/controls/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
231
web/vendor/postprocessing/EffectComposer.js
vendored
Normal file
231
web/vendor/postprocessing/EffectComposer.js
vendored
Normal 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
104
web/vendor/postprocessing/MaskPass.js
vendored
Normal 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
95
web/vendor/postprocessing/Pass.js
vendored
Normal 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
99
web/vendor/postprocessing/RenderPass.js
vendored
Normal 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
77
web/vendor/postprocessing/ShaderPass.js
vendored
Normal 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 };
|
||||
415
web/vendor/postprocessing/UnrealBloomPass.js
vendored
Normal file
415
web/vendor/postprocessing/UnrealBloomPass.js
vendored
Normal 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
45
web/vendor/shaders/CopyShader.js
vendored
Normal 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 };
|
||||
64
web/vendor/shaders/LuminosityHighPassShader.js
vendored
Normal file
64
web/vendor/shaders/LuminosityHighPassShader.js
vendored
Normal 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
54571
web/vendor/three.module.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user