Spaces:
Running
Running
File size: 6,790 Bytes
213d393 d61897b 213d393 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 | 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<string, THREE.Texture> = {}
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<string, [string, string, string]> = {
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<string, THREE.Vector3>[] = []
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<string, THREE.Vector3> = {}
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)
}
|