Spaces:
Running
Running
Download stage3d.js from CyberMax-tools/fellwing: direct link, hf CLI and curl.
- Browser
- Download file 22.7 kB
-
https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/stage3d.js
- Command line
-
hf download hf://spaces/CyberMax-tools/fellwing/stage3d.js
-
curl -L -o stage3d.js https://huggingface.co/spaces/CyberMax-tools/fellwing/resolve/main/stage3d.js
22.7 kB
| // 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; | |
| } | |