import * as THREE from 'three' import { HandModel, type SceneState } from './model' export interface View { rect: [number, number, number, number]; state: SceneState; bg?: string } export interface OverlayApi { W: number; H: number; t: number /** project anchor/world point of view i → logical coords (1080 x 1920 space scaled by view rect) */ anchor: (name: string, view?: number) => { x: number; y: number; z: number } world: (p: THREE.Vector3, view?: number) => { x: number; y: number; z: number } model: HandModel } export interface Frame { views: View[] overlay?: (ctx: CanvasRenderingContext2D, api: OverlayApi) => void fade?: number // 0..1 black } export const LW = 1080, LH = 1920 const BG_CACHE: Record = {} function bgTexture(kind: string) { if (BG_CACHE[kind]) return BG_CACHE[kind] const c = document.createElement('canvas'); c.width = 540; c.height = 960 const g = c.getContext('2d')! const pal: Record = { lab: ['#123257', '#081a33', '#020711'], warm: ['#3a2a3f', '#1b1426', '#07050c'], red: ['#4a1626', '#1e0b19', '#06030a'], clean: ['#1d4a6e', '#0b2441', '#030b18'], } const [a, b, d] = pal[kind] ?? pal.lab const gr = g.createRadialGradient(270, 400, 20, 270, 480, 620) gr.addColorStop(0, a); gr.addColorStop(0.5, b); gr.addColorStop(1, d) g.fillStyle = gr; g.fillRect(0, 0, 540, 960) // subtle grid g.strokeStyle = 'rgba(120,190,255,0.05)'; g.lineWidth = 1 for (let x = 0; x <= 540; x += 30) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, 960); g.stroke() } for (let y = 0; y <= 960; y += 30) { g.beginPath(); g.moveTo(0, y); g.lineTo(540, y); g.stroke() } const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace BG_CACHE[kind] = t return t } export class Engine { gl: HTMLCanvasElement renderer: THREE.WebGLRenderer model: HandModel camera = new THREE.PerspectiveCamera(35, 9 / 16, 0.05, 100) cams: THREE.PerspectiveCamera[] = [] W = 1080; H = 1920 constructor() { this.gl = document.createElement('canvas') this.renderer = new THREE.WebGLRenderer({ canvas: this.gl, antialias: true, preserveDrawingBuffer: true, alpha: false, powerPreference: 'high-performance' }) this.renderer.outputColorSpace = THREE.SRGBColorSpace this.renderer.toneMapping = THREE.ACESFilmicToneMapping this.renderer.toneMappingExposure = 1.05 this.renderer.setPixelRatio(1) this.model = new HandModel(this.renderer) this.setSize(1080, 1920) } setSize(w: number, h: number) { this.W = w; this.H = h this.renderer.setSize(w, h, false) } private setupCam(cam: THREE.PerspectiveCamera, s: SceneState, aspect: number) { cam.aspect = aspect cam.fov = s.cam.fov cam.position.set(...s.cam.pos) const target = new THREE.Vector3(...s.cam.target) const follow = (s.cam as { follow?: number }).follow ?? 0 if (follow > 0) { const d = this.model.anchors.pain.clone().sub(target).multiplyScalar(follow) cam.position.add(d); target.add(d) } cam.up.set(0, 1, 0) if (s.cam.roll) cam.up.applyAxisAngle(new THREE.Vector3(0, 0, 1), s.cam.roll) cam.lookAt(target) cam.updateProjectionMatrix() const sh = (s.cam as { shift?: [number, number] }).shift if (sh) { // place the look-at point at screen fraction (fx, fy) cam.projectionMatrix.elements[8] -= 2 * sh[0] - 1 cam.projectionMatrix.elements[9] -= 1 - 2 * sh[1] cam.projectionMatrixInverse.copy(cam.projectionMatrix).invert() } cam.updateMatrixWorld() } /** Render a frame onto a 2D context sized W x H (logical layout uses LW x LH scaled). */ render(frame: Frame, ctx: CanvasRenderingContext2D, t: number, logical: { w: number; h: number } = { w: LW, h: LH }) { const { W, H, renderer } = this renderer.setScissorTest(true) renderer.autoClear = false renderer.setClearColor('#02060d', 1) renderer.setViewport(0, 0, W, H); renderer.setScissor(0, 0, W, H); renderer.clear() while (this.cams.length < frame.views.length) this.cams.push(new THREE.PerspectiveCamera()) const anchorsPerView: Record[] = [] frame.views.forEach((v, i) => { const [rx, ry, rw, rh] = v.rect const x = Math.round(rx * W), y = Math.round(ry * H), w = Math.round(rw * W), h = Math.round(rh * H) this.model.apply(v.state) this.model.scene.background = bgTexture(v.bg ?? 'lab') const cam = this.cams[i] this.setupCam(cam, v.state, w / h) renderer.setViewport(x, H - y - h, w, h); renderer.setScissor(x, H - y - h, w, h) renderer.render(this.model.scene, cam) const snap: Record = {} for (const [k, p] of Object.entries(this.model.anchors)) snap[k] = p.clone() anchorsPerView.push(snap) }) renderer.setScissorTest(false) ctx.save() ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.clearRect(0, 0, W, H) ctx.drawImage(this.gl, 0, 0, W, H) // logical transform const sx = W / logical.w, sy = H / logical.h ctx.setTransform(sx, 0, 0, sy, 0, 0) // particles + vignette drawAtmosphere(ctx, t, logical.w, logical.h) if (frame.overlay) { const api: OverlayApi = { W: logical.w, H: logical.h, t, model: this.model, world: (p, vi = 0) => { const v = frame.views[vi]; const cam = this.cams[vi] const q = p.clone().project(cam) const [rx, ry, rw, rh] = v.rect return { x: (rx + ((q.x + 1) / 2) * rw) * logical.w, y: (ry + ((1 - q.y) / 2) * rh) * logical.h, z: q.z } }, anchor: (name, vi = 0) => api.world(anchorsPerView[vi]?.[name] ?? new THREE.Vector3(), vi), } frame.overlay(ctx, api) } if (frame.fade && frame.fade > 0) { ctx.fillStyle = `rgba(0,0,0,${Math.min(1, frame.fade)})`; ctx.fillRect(0, 0, logical.w, logical.h) } ctx.restore() } } function drawAtmosphere(ctx: CanvasRenderingContext2D, t: number, w: number, h: number) { // floating micro particles (deterministic) ctx.save() ctx.globalCompositeOperation = 'lighter' for (let i = 0; i < 40; i++) { const s1 = Math.sin(i * 91.7) * 0.5 + 0.5, s2 = Math.sin(i * 37.3 + 1) * 0.5 + 0.5, s3 = Math.sin(i * 13.1 + 2) * 0.5 + 0.5 const x = (s1 * w + Math.sin(t * 0.2 + i) * 30) % w const y = ((s2 * h - t * (8 + s3 * 18)) % h + h) % h const r = 1.2 + s3 * 2.4 ctx.fillStyle = `rgba(120,200,255,${0.05 + s3 * 0.12})` ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill() } ctx.restore() const g = ctx.createRadialGradient(w / 2, h * 0.45, h * 0.3, w / 2, h * 0.5, h * 0.78) g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.55)') ctx.fillStyle = g; ctx.fillRect(0, 0, w, h) }