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} */ this._panels = new Map(); this._raf = 0; } connectedCallback() { if (this.shadowRoot) return; const root = this.attachShadow({ mode: "open" }); root.innerHTML = `
`; 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);