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)
}