File size: 22,700 Bytes
9759528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
// CyberMax 3D stage: the shared Three.js (r170, ./vendor, MIT) look for CyberMax games, built from Soaklark 3D's
// renderer patterns. Toon materials with a rim light, HDR scene target with MSAA, two-level bloom, ACES tone map,
// colour grading, vignette, hit flash, chromatic kick on impacts, camera shake (trauma), a GPU-light particle pool,
// gradient sky domes and adaptive resolution. Each game builds its own scene with it and keeps its rules in core.js;
// the game's 2D canvas renderer stays as the no-WebGL2 fallback. Copied into each 3D game by scripts/games/sync_kit.py.
// (c) 2026 CyberMax. All rights reserved.
import * as THREE from 'three';

export { THREE };
export const col = (c) => new THREE.Color(c);
export const clamp01 = (t) => Math.max(0, Math.min(1, t));
export const lerp = (a, b, t) => a + (b - a) * t;
export const ease = (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
export const damp = (a, b, k, dt) => b + (a - b) * Math.exp(-k * dt);

/** True when this browser can run the 3D stage (WebGL2). `?2d=1` forces the 2D renderer. */
export function canRun3d() {
  try {
    if (/[?&]2d=1/.test(location.search)) return false;
    const c = document.createElement('canvas');
    return !!c.getContext('webgl2');
  } catch { return false; }
}

const NOISE = `
float h21(vec2 p){ p = fract(p*vec2(123.34, 456.21)); p += dot(p, p+45.32); return fract(p.x*p.y); }
float vnoise(vec2 p){ vec2 i=floor(p); vec2 f=fract(p); f=f*f*(3.-2.*f);
  return mix(mix(h21(i),h21(i+vec2(1,0)),f.x), mix(h21(i+vec2(0,1)),h21(i+vec2(1,1)),f.x), f.y); }`;
const FSQ_V = 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0., 1.); }';
const BRIGHT_F = `uniform sampler2D t; uniform float uTh; uniform vec2 uPx; varying vec2 vUv;
void main(){ vec3 c = vec3(0.);
  c += texture2D(t, vUv + uPx*vec2(-1.,-1.)).rgb; c += texture2D(t, vUv + uPx*vec2(1.,-1.)).rgb;
  c += texture2D(t, vUv + uPx*vec2(-1.,1.)).rgb; c += texture2D(t, vUv + uPx*vec2(1.,1.)).rgb; c *= 0.25;
  float l = max(c.r, max(c.g, c.b)); float k = smoothstep(uTh, uTh + 0.8, l); gl_FragColor = vec4(min(c * k, vec3(24.)), 1.); }`;
const BLUR_F = `uniform sampler2D t; uniform vec2 uDir; varying vec2 vUv;
void main(){ vec3 c = texture2D(t, vUv).rgb * 0.227027;
  c += texture2D(t, vUv + uDir*1.3846).rgb * 0.3162162; c += texture2D(t, vUv - uDir*1.3846).rgb * 0.3162162;
  c += texture2D(t, vUv + uDir*3.2308).rgb * 0.0702703; c += texture2D(t, vUv - uDir*3.2308).rgb * 0.0702703;
  gl_FragColor = vec4(c, 1.); }`;
const COMP_F = `uniform sampler2D tScene, tB1, tB2; uniform float uBloom, uExp, uSat, uCon, uVig, uTime, uFlash, uLdr, uAb, uGrain;
uniform vec3 uLift, uGain, uFlashCol; varying vec2 vUv; ${NOISE}
vec3 aces(vec3 x){ return clamp((x*(2.51*x+0.03))/(x*(2.43*x+0.59)+0.14), 0., 1.); }
void main(){
  vec2 uv = vUv; vec2 d = uv - 0.5;
  vec3 c;
  if (uAb > 0.0005) { vec2 o = d * uAb; c = vec3(texture2D(tScene, uv + o).r, texture2D(tScene, uv).g, texture2D(tScene, uv - o).b); }
  else c = texture2D(tScene, uv).rgb;
  c += (texture2D(tB1, uv).rgb * 0.75 + texture2D(tB2, uv).rgb * 1.0) * uBloom;
  c *= uExp;
  c = mix(aces(c), clamp(c, 0., 1.), uLdr);
  c = pow(c, vec3(1./2.2));
  float l = dot(c, vec3(0.299, 0.587, 0.114)); c = mix(vec3(l), c, uSat);
  c = (c - 0.5) * uCon + 0.5; c = c * uGain + uLift * (1. - c);
  c *= 1. - uVig * dot(d, d) * 1.7;
  c += (h21(uv * 913.7 + fract(uTime * 7.31)) - 0.5) * uGrain;
  c = mix(c, uFlashCol, uFlash);
  gl_FragColor = vec4(clamp(c, 0., 1.), 1.);
}`;
const SKY_V = 'varying vec3 vP; void main(){ vP = normalize(position); vec4 p = projectionMatrix * modelViewMatrix * vec4(position, 1.); gl_Position = p.xyww; }';
const SKY_F = `uniform vec3 uTop, uHor, uLow, uGlowCol; uniform vec3 uGlowDir; uniform float uStars, uTime, uGlow, uCurve; varying vec3 vP; ${NOISE}
void main(){ vec3 p = normalize(vP); float y = p.y;
  vec3 c = y > 0. ? mix(uHor, uTop, pow(y, uCurve)) : mix(uHor, uLow, pow(-y, uCurve * 0.85));
  float s = max(dot(p, normalize(uGlowDir)), 0.); c += uGlowCol * (pow(s, 8.) * 0.6 + pow(s, 90.) * 2.5) * uGlow;
  if (uStars > 0.) { vec2 g = vec2(atan(p.z, p.x) * 60., p.y * 80.); vec2 id = floor(g); float r = h21(id);
    float st = step(0.975, r) * smoothstep(0.45, 0., length(fract(g) - 0.5)); c += vec3(st) * (0.55 + 0.45 * sin(uTime * 2. + r * 40.)) * uStars * smoothstep(-0.1, 0.3, y) * 2.2; }
  gl_FragColor = vec4(c, 1.); }`;
const PART_V = `attribute vec3 aCol; attribute float aSize; attribute float aA; varying vec3 vC; varying float vA; uniform float uScale;
void main(){ vC = aCol; vA = aA; vec4 mv = modelViewMatrix * vec4(position, 1.); gl_Position = projectionMatrix * mv; gl_PointSize = aSize * uScale / max(-mv.z, 0.1); }`;
const PART_F = `varying vec3 vC; varying float vA; void main(){ vec2 q = gl_PointCoord - 0.5; float d = length(q); if (d > 0.5) discard;
  float k = smoothstep(0.5, 0.0, d); gl_FragColor = vec4(vC * (0.6 + 1.6 * k * k), vA * k); }`;

/** A pool of glowing particles (sparks, dust, confetti), updated on the CPU and drawn as one additive point cloud. */
class Particles {
  constructor(n = 1600) {
    this.n = n; this.i = 0; this.live = new Float32Array(n * 10); // x y z vx vy vz life max size drag
    this.cr = new Float32Array(n * 3);
    const g = new THREE.BufferGeometry();
    this.pos = new THREE.Float32BufferAttribute(new Float32Array(n * 3), 3); this.pos.setUsage(THREE.DynamicDrawUsage);
    this.colA = new THREE.Float32BufferAttribute(new Float32Array(n * 3), 3); this.colA.setUsage(THREE.DynamicDrawUsage);
    this.size = new THREE.Float32BufferAttribute(new Float32Array(n), 1); this.size.setUsage(THREE.DynamicDrawUsage);
    this.al = new THREE.Float32BufferAttribute(new Float32Array(n), 1); this.al.setUsage(THREE.DynamicDrawUsage);
    g.setAttribute('position', this.pos); g.setAttribute('aCol', this.colA); g.setAttribute('aSize', this.size); g.setAttribute('aA', this.al);
    g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 1e6);
    this.U = { uScale: { value: 300 } };
    this.mesh = new THREE.Points(g, new THREE.ShaderMaterial({ vertexShader: PART_V, fragmentShader: PART_F, uniforms: this.U, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }));
    this.mesh.frustumCulled = false; this.mesh.renderOrder = 10;
    this.grav = 0;
  }
  /** Burst `n` particles at (x,y,z). colors: CSS colours; speed in world units/s; life in s; up: extra upward kick. */
  burst(x, y, z, { n = 30, colors = ['#FFFFFF'], speed = 30, life = 0.8, size = 3, drag = 2.2, up = 0, spread = 1, hdr = 2.2, dir = null } = {}) {
    const cs = colors.map((c) => col(c).multiplyScalar(hdr));
    for (let k = 0; k < n; k++) {
      const i = this.i; this.i = (this.i + 1) % this.n; const o = i * 10;
      let vx = Math.random() * 2 - 1, vy = Math.random() * 2 - 1, vz = Math.random() * 2 - 1; const l = Math.hypot(vx, vy, vz) || 1;
      const s = speed * (0.35 + Math.random() * 0.65) * spread;
      vx = vx / l * s; vy = vy / l * s + up; vz = vz / l * s;
      if (dir) { vx += dir[0]; vy += dir[1]; vz += dir[2]; }
      const L = life * (0.6 + Math.random() * 0.6);
      this.live.set([x, y, z, vx, vy, vz, L, L, size * (0.6 + Math.random() * 0.8), drag], o);
      const c = cs[k % cs.length]; this.cr[i * 3] = c.r; this.cr[i * 3 + 1] = c.g; this.cr[i * 3 + 2] = c.b;
    }
  }
  update(dt) {
    const P = this.pos.array, C = this.colA.array, S = this.size.array, A = this.al.array, L = this.live;
    for (let i = 0; i < this.n; i++) {
      const o = i * 10;
      if (L[o + 6] <= 0) { A[i] = 0; S[i] = 0; continue; }
      L[o + 6] -= dt; const f = Math.exp(-L[o + 9] * dt);
      L[o + 3] *= f; L[o + 4] = L[o + 4] * f - this.grav * dt; L[o + 5] *= f;
      L[o] += L[o + 3] * dt; L[o + 1] += L[o + 4] * dt; L[o + 2] += L[o + 5] * dt;
      const t = Math.max(0, L[o + 6] / L[o + 7]);
      P[i * 3] = L[o]; P[i * 3 + 1] = L[o + 1]; P[i * 3 + 2] = L[o + 2];
      C[i * 3] = this.cr[i * 3]; C[i * 3 + 1] = this.cr[i * 3 + 1]; C[i * 3 + 2] = this.cr[i * 3 + 2];
      S[i] = L[o + 8] * (0.4 + 0.6 * t); A[i] = Math.min(1, t * 1.6);
    }
    this.pos.needsUpdate = this.colA.needsUpdate = this.size.needsUpdate = this.al.needsUpdate = true;
  }
  clear() { this.live.fill(0); }
}

// A tube bent along an arc in the vertex shader, so one geometry serves every blade/zone/progress length.
export function arcGeo(seg = 72, around = 8, flat = false) {
  const pos = [], idx = [];
  for (let i = 0; i <= seg; i++) for (let j = 0; j <= around; j++) {
    const v = (j / around) * Math.PI * 2;
    pos.push(i / seg, flat ? (j / around) * 2 - 1 : Math.cos(v), flat ? 0 : Math.sin(v));
  }
  for (let i = 0; i < seg; i++) for (let j = 0; j < around; j++) {
    const a = i * (around + 1) + j, b = a + around + 1;
    idx.push(a, b, a + 1, b, b + 1, a + 1);
  }
  const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); g.setIndex(idx);
  g.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 20);
  return g;
}
export const TUBE = arcGeo(72, 8), RIBBON = arcGeo(72, 1, true);
/** Unlit HDR material for TUBE/RIBBON (always transparent: arc materials share one program, and an opaque one would
 * force alpha 1 on every other one): setArc(mesh, startAngle, length, radius, thickness, y) bends it (XZ plane). */
export function arcMat(color, k = 2.5, { opacity = 1, additive = false, dash = 0 } = {}) {
  const U = { uA0: { value: 0 }, uLen: { value: 1 }, uR: { value: 1 }, uTube: { value: 0.05 }, uY: { value: 0 }, uDash: { value: dash }, uTime: { value: 0 } };
  const m = new THREE.MeshBasicMaterial({ color: col(color).multiplyScalar(k), transparent: true, opacity, depthWrite: !(additive || opacity < 1), blending: additive ? THREE.AdditiveBlending : THREE.NormalBlending, side: THREE.DoubleSide });
  m.onBeforeCompile = (s) => {
    Object.assign(s.uniforms, U);
    s.vertexShader = s.vertexShader.replace('#include <common>', '#include <common>\nuniform float uA0, uLen, uR, uTube, uY; varying float vS;')
      .replace('#include <begin_vertex>', 'float th = uA0 + position.x * uLen; float rr = uR + position.y * uTube; vec3 transformed = vec3(cos(th) * rr, uY + position.z * uTube, sin(th) * rr); vS = position.x * uLen;');
    s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform float uDash, uTime; varying float vS;')
      .replace('#include <opaque_fragment>', 'if (uDash > 0. && fract(vS * uDash - uTime * 2.) > 0.5) discard;\n#include <opaque_fragment>');
  };
  m.customProgramCacheKey = () => `arc${dash > 0 ? 'd' : ''}`;
  m.forceSinglePass = true;
  m.U = U; return m;
}
export function setArc(m, a0, len, r, tube, y = 0) { const U = m.material.U; U.uA0.value = a0; U.uLen.value = len; U.uR.value = r; U.uTube.value = tube; U.uY.value = y; }


export class Stage {
  /**
   * canvas: the WebGL canvas. opts: { tier: 'high'|'low', record, bloom, threshold, exposure, sat, con, vig, lift, gain, grain, fov }
   */
  constructor(canvas, opts = {}) {
    this.canvas = canvas;
    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, depth: true, stencil: false, powerPreference: 'high-performance', preserveDrawingBuffer: !!opts.record });
    if (!gl) throw new Error('webgl2');
    this.r = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false });
    this.r.outputColorSpace = THREE.LinearSRGBColorSpace; this.r.toneMapping = THREE.NoToneMapping;
    this.r.shadowMap.enabled = !!opts.shadows; this.r.shadowMap.type = THREE.PCFSoftShadowMap;
    this.r.setPixelRatio(1);
    const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent) && !opts.record;
    this.tier = opts.tier || (mobile ? 'low' : 'high');
    this.hdr = this.r.extensions.has('EXT_color_buffer_float') || this.r.extensions.has('EXT_color_buffer_half_float');
    this.q = { scale: 1, msaa: this.tier === 'high' ? 4 : 2, maxPr: this.tier === 'high' ? 2 : 1.75, bloom: true };
    this.look = { bloom: 0.8, threshold: 0.9, exposure: 1, sat: 1.12, con: 1.05, vig: 0.55, lift: [0.01, 0.0, 0.03], gain: [1, 1, 1], grain: 0.025, ...opts };
    this.scene = new THREE.Scene();
    this.cam = new THREE.PerspectiveCamera(opts.fov || 50, 1, 0.1, 4000);
    this.camBase = new THREE.Vector3(); this.camLook = new THREE.Vector3();
    this.gradient = (() => { const d = new Uint8Array([110, 110, 110, 255, 170, 170, 170, 255, 225, 225, 225, 255, 255, 255, 255, 255]); const t = new THREE.DataTexture(d, 4, 1, THREE.RGBAFormat); t.minFilter = t.magFilter = THREE.NearestFilter; t.needsUpdate = true; return t; })();
    this.rimU = { value: col(opts.rim || '#8FA8FF').multiplyScalar(0.5) };
    this.t = 0; this.trauma = 0; this.flashA = 0; this.flashCol = col('#FFFFFF'); this.kick = 0;
    this.parts = new Particles(opts.particles || 1600); this.scene.add(this.parts.mesh);
    this.post();
    this.ft = 16; this.slow = 0; this.fast = 0; this.frames = 0; this.strikes = 0; this.tooSlow = false;
    this.auto = !opts.fixedQuality && !opts.record && !/[?&](fixedq|3d)=1/.test(location.search);
  }

  // ---------- materials
  /** Cel-shaded material with a rim light (Soaklark look). */
  toon(opts = {}, rim = 1) {
    const m = new THREE.MeshToonMaterial({ gradientMap: this.gradient, ...opts });
    const R = this.rimU;
    m.onBeforeCompile = (s) => {
      s.uniforms.uRim = R;
      s.fragmentShader = s.fragmentShader.replace('#include <common>', '#include <common>\nuniform vec3 uRim;')
        .replace('#include <opaque_fragment>', `float rimF = 1.0 - max(dot(normalize(vViewPosition), normal), 0.0);\noutgoingLight += uRim * ${rim.toFixed(2)} * smoothstep(0.6, 0.95, rimF);\n#include <opaque_fragment>`);
    };
    return m;
  }
  /** Unlit HDR colour: anything brighter than ~1 blooms. */
  glow(color, k = 2.5, o = {}) { return new THREE.MeshBasicMaterial({ color: col(color).multiplyScalar(k), toneMapped: false, ...o }); }
  /** Back-face outline shell for a mesh (inverted hull). */
  outline(mesh, t = 0.06, color = '#0A0B14') {
    const m = new THREE.Mesh(mesh.geometry, new THREE.MeshBasicMaterial({ color, side: THREE.BackSide }));
    m.scale.setScalar(1 + t); mesh.add(m); return m;
  }
  /** Gradient sky dome (top / horizon / below), optional glow (sun or moon) and twinkling stars. */
  sky({ top = '#0A0B2A', hor = '#3A1E6E', low = '#05060F', glow = '#FF8A5C', glowDir = [0.3, 0.25, -1], glowK = 1, stars = 0, curve = 0.6 } = {}) {
    if (this.skyMesh) { this.scene.remove(this.skyMesh); this.skyMesh.material.dispose(); }
    const U = { uTop: { value: col(top) }, uHor: { value: col(hor) }, uLow: { value: col(low) }, uGlowCol: { value: col(glow) }, uGlowDir: { value: new THREE.Vector3(...glowDir) }, uStars: { value: stars }, uTime: { value: 0 }, uGlow: { value: glowK }, uCurve: { value: curve } };
    const m = new THREE.Mesh(new THREE.SphereGeometry(1000, 32, 16), new THREE.ShaderMaterial({ vertexShader: SKY_V, fragmentShader: SKY_F, uniforms: U, side: THREE.BackSide, depthWrite: false }));
    m.frustumCulled = false; m.renderOrder = -10; this.skyU = U; this.skyMesh = m; this.scene.add(m);
    return m;
  }

  // ---------- post-processing
  post() {
    const type = this.hdr ? THREE.HalfFloatType : THREE.UnsignedByteType;
    this.rtS = new THREE.WebGLRenderTarget(4, 4, { type, samples: this.q.msaa, depthBuffer: true });
    this.rtB = [0, 1, 2, 3].map(() => new THREE.WebGLRenderTarget(4, 4, { type, depthBuffer: false }));
    const tri = new THREE.BufferGeometry();
    tri.setAttribute('position', new THREE.Float32BufferAttribute([-1, -1, 0, 3, -1, 0, -1, 3, 0], 3));
    tri.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 2, 0, 0, 2], 2));
    const mk = (f, u) => new THREE.ShaderMaterial({ vertexShader: FSQ_V, fragmentShader: f, uniforms: u, depthTest: false, depthWrite: false });
    this.mBright = mk(BRIGHT_F, { t: { value: null }, uTh: { value: 1 }, uPx: { value: new THREE.Vector2() } });
    this.mBlur = mk(BLUR_F, { t: { value: null }, uDir: { value: new THREE.Vector2() } });
    this.mComp = mk(COMP_F, { tScene: { value: null }, tB1: { value: null }, tB2: { value: null }, uBloom: { value: 0.8 }, uExp: { value: 1 }, uSat: { value: 1.1 }, uCon: { value: 1.05 },
      uVig: { value: 0.55 }, uTime: { value: 0 }, uFlash: { value: 0 }, uLdr: { value: this.hdr ? 0 : 1 }, uAb: { value: 0 }, uGrain: { value: 0.02 },
      uLift: { value: new THREE.Vector3() }, uGain: { value: new THREE.Vector3(1, 1, 1) }, uFlashCol: { value: new THREE.Color() } });
    this.fsq = new THREE.Mesh(tri, this.mComp); this.fsq.frustumCulled = false;
    this.fsScene = new THREE.Scene(); this.fsScene.add(this.fsq); this.fsCam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
  }
  resize(cssW, cssH, dpr = window.devicePixelRatio || 1) {
    this.cssW = cssW; this.cssH = cssH; this.dpr = dpr;
    const pr = Math.min(dpr, this.q.maxPr) * this.q.scale;
    const w = Math.max(2, Math.round(cssW * pr)), h = Math.max(2, Math.round(cssH * pr));
    if (w === this.w && h === this.h) return;
    this.r.setSize(w, h, false); this.w = w; this.h = h;
    this.rtS.setSize(w, h);
    const bw = Math.max(2, w >> 2), bh = Math.max(2, h >> 2);
    this.rtB[0].setSize(bw, bh); this.rtB[1].setSize(bw, bh); this.rtB[2].setSize(Math.max(2, bw >> 1), Math.max(2, bh >> 1)); this.rtB[3].setSize(Math.max(2, bw >> 1), Math.max(2, bh >> 1));
    this.cam.aspect = w / h; this.cam.updateProjectionMatrix();
    this.parts.U.uScale.value = h * 0.9;
  }
  /** Keep the drawing buffer matched to the canvas's CSS box. */
  fit() { const r = this.canvas.getBoundingClientRect(); if (r.width > 1 && r.height > 1) this.resize(r.width, r.height); }
  setQuality(scale) { this.q.scale = Math.max(0.5, Math.min(1, scale)); const w = this.cssW, h = this.cssH; this.w = 0; this.resize(w, h, this.dpr); }

  // ---------- juice
  shake(amount = 0.5) { this.trauma = Math.min(1, this.trauma + amount); }
  flash(color = '#FFFFFF', a = 0.5) { this.flashCol.set(color); this.flashA = Math.max(this.flashA, a); }
  punch(k = 0.006) { this.kick = Math.max(this.kick, k); }
  burst(v, o) { this.parts.burst(v.x, v.y, v.z, o); }

  /**
   * Draw one frame. dt in seconds (fixed when recording). Shake moves the camera a little around where the game put it
   * (call setCam(pos, look) each frame before render, or set stage.cam yourself and leave camBase empty).
   */
  render(dt) {
    dt = Math.min(0.1, Math.max(0, dt));
    this.t += dt;
    this.parts.update(dt);
    if (this.skyU) this.skyU.uTime.value = this.t;
    // camera shake (trauma^2 noise, decays)
    const tr = this.trauma * this.trauma; this.trauma = Math.max(0, this.trauma - dt * 1.6);
    const cam = this.cam;
    if (this.camSet) { cam.position.copy(this.camBase); cam.lookAt(this.camLook); }
    if (tr > 0.0001) {
      const n = (k) => Math.sin(this.t * (37 + k * 11) + k * 7) * 0.6 + Math.sin(this.t * (71 + k * 5) + k) * 0.4;
      const s = this.shakeScale || 1;
      cam.position.x += n(1) * tr * s; cam.position.y += n(2) * tr * s; cam.rotation.z += n(3) * tr * 0.04;
    }
    // adaptive quality from real frame times: lower the resolution, then bloom; a device that still can't keep up gets
    // tooSlow, and the game swaps back to its 2D renderer (same rules, same run) so it always stays playable.
    const nowR = performance.now(), real = this.lastR ? nowR - this.lastR : 16; this.lastR = nowR;
    if (this.auto && real < 1000 && ++this.frames > 8) {
      this.ft = this.ft * 0.85 + real * 0.15;
      if (this.ft > 34) {
        this.fast = 0;
        if (++this.slow > 12) {
          this.slow = 0;
          if (this.q.scale > 0.55) this.setQuality(this.q.scale - 0.2);
          else if (this.q.bloom) this.q.bloom = false;
          else if (++this.strikes > 2) this.tooSlow = true;
        }
      } else if (this.ft < 18 && this.q.scale < 1) { this.slow = 0; if (++this.fast > 300) { this.fast = 0; this.setQuality(this.q.scale + 0.1); } }
      else this.slow = Math.max(0, this.slow - 1);
    }
    const r = this.r, L = this.look, U = this.mComp.uniforms;
    r.setRenderTarget(this.rtS); r.render(this.scene, cam);
    let b1 = this.rtB[0].texture, b2 = this.rtB[2].texture;
    if (this.q.bloom && L.bloom > 0) {
      const pass = (mat, out) => { this.fsq.material = mat; r.setRenderTarget(out); r.render(this.fsScene, this.fsCam); };
      this.mBright.uniforms.t.value = this.rtS.texture; this.mBright.uniforms.uTh.value = L.threshold; this.mBright.uniforms.uPx.value.set(1 / this.w, 1 / this.h);
      pass(this.mBright, this.rtB[0]);
      const blur = (src, tmp, dst, w, h) => {
        this.mBlur.uniforms.t.value = src.texture; this.mBlur.uniforms.uDir.value.set(1 / w, 0); pass(this.mBlur, tmp);
        this.mBlur.uniforms.t.value = tmp.texture; this.mBlur.uniforms.uDir.value.set(0, 1 / h); pass(this.mBlur, dst);
      };
      blur(this.rtB[0], this.rtB[1], this.rtB[0], this.rtB[0].width, this.rtB[0].height);
      this.mBlur.uniforms.t.value = this.rtB[0].texture; this.mBlur.uniforms.uDir.value.set(0, 0); pass(this.mBlur, this.rtB[2]);
      blur(this.rtB[2], this.rtB[3], this.rtB[2], this.rtB[2].width, this.rtB[2].height);
      blur(this.rtB[2], this.rtB[3], this.rtB[2], this.rtB[2].width, this.rtB[2].height);
      b1 = this.rtB[0].texture; b2 = this.rtB[2].texture; U.uBloom.value = L.bloom;
    } else U.uBloom.value = 0;
    U.tScene.value = this.rtS.texture; U.tB1.value = b1; U.tB2.value = b2;
    U.uExp.value = L.exposure; U.uSat.value = L.sat; U.uCon.value = L.con; U.uVig.value = L.vig; U.uGrain.value = L.grain;
    U.uLift.value.set(...L.lift); U.uGain.value.set(...L.gain); U.uTime.value = this.t;
    U.uFlash.value = this.flashA; U.uFlashCol.value.copy(this.flashCol); this.flashA *= Math.exp(-dt * 7); if (this.flashA < 0.004) this.flashA = 0;
    U.uAb.value = this.kick + tr * 0.012; this.kick *= Math.exp(-dt * 8);
    this.fsq.material = this.mComp; r.setRenderTarget(null); r.render(this.fsScene, this.fsCam);
  }
  setCam(pos, look) { this.camSet = true; this.camBase.copy(pos); this.camLook.copy(look); }
  dispose() { this.r.dispose(); }
}

/**
 * Mount a 3D layer behind a game's 2D canvas: a WebGL canvas the same size, in the same box. The 2D canvas stays on
 * top (HUD text, taps, focus, tests) and the game draws it transparent while 3D is on.
 */
export function mountBehind(cv2d, cls = 'cmx3d') {
  const host = cv2d.parentElement;
  const wrap = document.createElement('div'); wrap.className = `${cls}-wrap`;
  host.insertBefore(wrap, cv2d); wrap.appendChild(cv2d);
  const c = document.createElement('canvas'); c.className = cls; c.setAttribute('aria-hidden', 'true');
  wrap.insertBefore(c, cv2d);
  return c;
}