// Turfbreak renderer: a rain-soaked night city in three.js r170 on the CyberMax stage (HDR bloom, ACES, grading, // grain, shake). Physically based materials, wet asphalt that mirrors neon through a generated environment map, // instanced buildings with world-space facades and lit windows, an x-ray cutaway so buildings never hide your // operator, neon signs, sodium lamps, rain, tracers, muzzle lights, charges, Skyfall strikes and capture zones. // Draws from a plain "view" object (live sim or a history frame), so kill-cams and replays use the same code. // (c) 2026 CyberMax. All rights reserved. import { Stage, THREE, col, arcMat, setArc, TUBE, damp, clamp01 } from './stage3d.js'; import { TEAMS, BOUND, BODY } from './core.js'; import { ViewModel, mergeParts, thirdPersonGun } from './fpv.js'; const R2 = (s) => { let a = s >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; const cv = (w, h) => { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }; const tex = (c, rep = true, srgb = true) => { const t = new THREE.CanvasTexture(c); if (rep) t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = srgb ? THREE.SRGBColorSpace : THREE.NoColorSpace; t.anisotropy = 4; t.needsUpdate = true; return t; }; export const SKINS = [ { id: 'standard', name: 'Field Issue', jacket: null, plate: null, visor: null, free: true }, { id: 'nightshade', name: 'Nightshade', jacket: '#101014', plate: '#1C1A24', visor: '#B36BFF' }, { id: 'ember', name: 'Ember', jacket: '#3A1A0E', plate: '#5A2A12', visor: '#FF5A1F', trace: '#FF7A2A' }, { id: 'arctic', name: 'Arctic', jacket: '#B9C2CC', plate: '#E4E9EE', visor: '#7FE3FF' }, { id: 'gilded', name: 'Gilded', jacket: '#16130C', plate: '#C9A43A', visor: '#FFE08A', metal: true, trace: '#FFD36A' }, { id: 'static', name: 'Static', jacket: '#0B1418', plate: '#14262C', visor: '#4CFFB0', stripe: '#4CFFB0', trace: '#5CFFC0' }, { id: 'carbon', name: 'Carbon', jacket: '#141618', plate: '#24272B', visor: '#FF2E4C', trace: '#FF4A5E' }, ]; const THEMES = { row: { ground: '#1B1D22', side: '#2A2C31', fog: '#0C1322', fogD: 0.0105, hemi: ['#6070A8', '#2A2228', 1.45], moon: ['#A8BCFF', 1.5], neon: ['#FF2E88', '#2EE6FF', '#FFB13B', '#B36BFF', '#4CFF9A'], lamp: '#FFA347', look: { lift: [0.012, 0.01, 0.03], gain: [1.02, 0.98, 1.04] } }, docks: { ground: '#1E2124', side: '#2B2E30', fog: '#081419', fogD: 0.0115, hemi: ['#5A8494', '#28241F', 1.45], moon: ['#B0D8FF', 1.6], neon: ['#2EE6FF', '#FFB13B', '#FF5A3D', '#2EE6FF', '#FFE16B'], lamp: '#FFB35C', look: { lift: [0.0, 0.015, 0.025], gain: [0.98, 1.0, 1.03] } }, yard: { ground: '#1F1C1D', side: '#2C2829', fog: '#140D16', fogD: 0.011, hemi: ['#86689A', '#2A2222', 1.45], moon: ['#FFD0B0', 1.4], neon: ['#FF4A5E', '#FFB13B', '#B36BFF', '#2EE6FF', '#FF2E88'], lamp: '#FF9A3C', look: { lift: [0.02, 0.008, 0.02], gain: [1.04, 0.98, 1.0] } }, }; const WORDS = ['RAMEN', 'HOTEL', 'ARCADE', 'LAUNDRY', '24/7', 'KAITO', 'NOODLES', 'DINER', 'TATTOO', 'MOTEL']; // ------------------------------------------------------------------ procedural textures function facadeTexture(seed) { const W = 512, H = 410, c = cv(W, H), g = c.getContext('2d'); const r = R2(seed); const base = ['#6A6E78', '#7A6C62', '#5C646C', '#80786E'][seed % 4]; g.fillStyle = base; g.fillRect(0, 0, W, H); const id = g.getImageData(0, 0, W, H); const d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 22; d[i] += n; d[i + 1] += n; d[i + 2] += n; } g.putImageData(id, 0, 0); // floor bands, panel seams, grime streaks g.fillStyle = 'rgba(0,0,0,.28)'; for (const y of [0, H / 2]) g.fillRect(0, y, W, 16); g.strokeStyle = 'rgba(0,0,0,.22)'; g.lineWidth = 2; for (let x = 0; x <= W; x += 128) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, H); g.stroke(); } for (let k = 0; k < 40; k++) { const x = r() * W, y = r() * H; const gr = g.createLinearGradient(0, y, 0, y + 90); gr.addColorStop(0, 'rgba(0,0,0,.18)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(x, y, 3 + r() * 6, 90); } // two windows per floor: frame + dark glass with a faint reflection for (const fy of [0, H / 2]) for (const fx of [0, W / 2]) { const x = fx + 38, y = fy + 46, w = W / 2 - 76, h = H / 2 - 92; g.fillStyle = '#16181C'; g.fillRect(x - 6, y - 6, w + 12, h + 12); const gl = g.createLinearGradient(x, y, x + w, y + h); gl.addColorStop(0, '#202A36'); gl.addColorStop(0.5, '#0C1016'); gl.addColorStop(1, '#1A2230'); g.fillStyle = gl; g.fillRect(x, y, w, h); g.fillStyle = '#16181C'; g.fillRect(x + w / 2 - 3, y, 6, h); g.fillStyle = 'rgba(0,0,0,.45)'; g.fillRect(x - 10, y + h + 6, w + 20, 8); // sill } return tex(c); } /** Lit-window atlas aligned with facadeTexture's windows: 16 x 16 floors-by-columns, random warm/cool/off. */ function windowAtlas(seed) { const N = 16, cw = 64, ch = 51.25, c = cv(N * cw, Math.round(N * ch)), g = c.getContext('2d'); const r = R2(seed); g.fillStyle = '#000'; g.fillRect(0, 0, c.width, c.height); // facade texture = 2x2 windows; atlas covers 8x8 facade tiles -> 16x16 windows, each window in its tile at the same spot const tw = c.width / 8, th = c.height / 8; for (let ty = 0; ty < 8; ty++) for (let tx = 0; tx < 8; tx++) for (const fy of [0, 0.5]) for (const fx of [0, 0.5]) { const v = r(); if (v < 0.52) continue; const warm = r() < 0.72; const k = 0.35 + r() * 0.65; const colr = warm ? [255, 170 + r() * 50, 90 + r() * 40] : [140, 200, 255]; const x = tx * tw + fx * tw + tw * (38 / 512), y = ty * th + fy * th + th * (46 / 410), w = tw * (0.5 - 76 / 512), h = th * (0.5 - 92 / 410); const gr = g.createLinearGradient(0, y, 0, y + h); gr.addColorStop(0, `rgba(${colr[0]},${colr[1]},${colr[2]},${k * 0.55})`); gr.addColorStop(1, `rgba(${colr[0]},${colr[1]},${colr[2]},${k})`); g.fillStyle = gr; g.fillRect(x, y, w, h); if (r() < 0.4) { g.fillStyle = 'rgba(0,0,0,.6)'; g.fillRect(x + w * r() * 0.6, y, w * 0.3, h); } // a curtain / silhouette } return tex(c); } function roofTexture(seed) { const W = 256, c = cv(W, W), g = c.getContext('2d'); const r = R2(seed); g.fillStyle = '#2A2B2E'; g.fillRect(0, 0, W, W); const id = g.getImageData(0, 0, W, W); const d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 30; d[i] += n; d[i + 1] += n; d[i + 2] += n * 0.9; } g.putImageData(id, 0, 0); g.strokeStyle = 'rgba(0,0,0,.35)'; g.lineWidth = 3; for (let x = 0; x <= W; x += 64) { g.beginPath(); g.moveTo(x, 0); g.lineTo(x, W); g.stroke(); g.beginPath(); g.moveTo(0, x); g.lineTo(W, x); g.stroke(); } for (let k = 0; k < 6; k++) { g.fillStyle = 'rgba(10,12,16,.5)'; g.beginPath(); g.ellipse(r() * W, r() * W, 10 + r() * 30, 6 + r() * 20, r() * 3, 0, 7); g.fill(); } // puddles on the roof return tex(c); } function containerTexture() { const c = cv(256, 256), g = c.getContext('2d'); g.fillStyle = '#C8C8C8'; g.fillRect(0, 0, 256, 256); for (let x = 0; x < 256; x += 16) { const gr = g.createLinearGradient(x, 0, x + 16, 0); gr.addColorStop(0, 'rgba(0,0,0,.35)'); gr.addColorStop(0.5, 'rgba(255,255,255,.15)'); gr.addColorStop(1, 'rgba(0,0,0,.35)'); g.fillStyle = gr; g.fillRect(x, 0, 16, 256); } g.fillStyle = 'rgba(60,30,10,.35)'; for (let k = 0; k < 30; k++) g.fillRect(Math.random() * 256, Math.random() * 256, 2 + Math.random() * 8, 6 + Math.random() * 30); return tex(c); } /** Close-up surfaces for cover props (first person): crate slats, painted metal, concrete, rail-car panels. */ function propTexture(kind, seed = 1) { const S = 256, c = cv(S, S), g = c.getContext('2d'); const r = R2(seed * 31 + kind.length); const grain = (k, mono = true) => { const id = g.getImageData(0, 0, S, S); const d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * k; d[i] += n; d[i + 1] += mono ? n : n * 0.9; d[i + 2] += mono ? n : n * 0.8; } g.putImageData(id, 0, 0); }; if (kind === 'crate') { g.fillStyle = '#7A5E3E'; g.fillRect(0, 0, S, S); grain(26, false); for (let y = 0; y < S; y += 32) { g.fillStyle = 'rgba(0,0,0,.35)'; g.fillRect(0, y, S, 3); g.fillStyle = 'rgba(255,230,190,.08)'; g.fillRect(0, y + 3, S, 2); } g.strokeStyle = '#3A2A18'; g.lineWidth = 18; g.strokeRect(9, 9, S - 18, S - 18); g.beginPath(); g.moveTo(18, 18); g.lineTo(S - 18, S - 18); g.stroke(); g.fillStyle = 'rgba(20,20,20,.75)'; g.font = '800 30px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.fillText(['PORT VARO', 'VARO-7', 'HANDLE', 'FRAGILE'][seed % 4], S / 2, S / 2 + 48); for (const [x, y] of [[22, 22], [S - 22, 22], [22, S - 22], [S - 22, S - 22]]) { g.fillStyle = '#9A9A9A'; g.beginPath(); g.arc(x, y, 4, 0, 7); g.fill(); } } else if (kind === 'dumpster') { g.fillStyle = '#2E5A44'; g.fillRect(0, 0, S, S); grain(20); for (let x = 0; x < S; x += 42) { g.fillStyle = 'rgba(0,0,0,.28)'; g.fillRect(x, 0, 5, S); g.fillStyle = 'rgba(255,255,255,.06)'; g.fillRect(x + 5, 0, 3, S); } g.fillStyle = 'rgba(120,60,20,.45)'; for (let k = 0; k < 40; k++) g.fillRect(r() * S, S * 0.6 + r() * S * 0.4, 2 + r() * 10, 4 + r() * 20); g.fillStyle = '#E8E2D0'; g.fillRect(S * 0.28, S * 0.3, S * 0.44, S * 0.16); g.fillStyle = '#1A1A1A'; g.font = '800 26px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.fillText('NO PARKING', S / 2, S * 0.42); } else if (kind === 'concrete' || kind === 'planter' || kind === 'pillar' || kind === 'platform' || kind === 'wall') { g.fillStyle = kind === 'planter' ? '#7A7670' : '#6E6C68'; g.fillRect(0, 0, S, S); grain(34); for (let k = 0; k < 30; k++) { g.fillStyle = `rgba(0,0,0,${0.05 + r() * 0.1})`; g.beginPath(); g.ellipse(r() * S, r() * S, 4 + r() * 30, 3 + r() * 14, r() * 3, 0, 7); g.fill(); } g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(0, S / 2 - 1, S, 2); if (kind === 'platform') { g.fillStyle = '#E2B21A'; g.fillRect(0, 0, S, 18); g.fillStyle = 'rgba(0,0,0,.3)'; for (let x = 0; x < S; x += 12) g.fillRect(x, 4, 4, 10); } for (let k = 0; k < 6; k++) { const x = r() * S; const gr = g.createLinearGradient(0, 0, 0, S); gr.addColorStop(0, 'rgba(0,0,0,.25)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(x, 0, 3 + r() * 5, S); } } else if (kind === 'kiosk') { g.fillStyle = '#3C4450'; g.fillRect(0, 0, S, S); grain(16); // a lit shop window: warm interior, shelves with goods, a reflection streak (reads as a kiosk, not a black slab) { const x0 = S * 0.1, y0 = S * 0.18, w = S * 0.8, h = S * 0.42; const gr = g.createLinearGradient(0, y0, 0, y0 + h); gr.addColorStop(0, '#6A5A44'); gr.addColorStop(1, '#3A3228'); g.fillStyle = gr; g.fillRect(x0, y0, w, h); for (let k = 1; k < 3; k++) { g.fillStyle = '#2A241E'; g.fillRect(x0, y0 + (h * k) / 3, w, 4); for (let j = 0; j < 9; j++) { g.fillStyle = ['#C8503A', '#E2B21A', '#3A7AB0', '#4E9A62', '#D8D0C0'][(j + k) % 5]; g.fillRect(x0 + 6 + j * (w - 12) / 9, y0 + (h * k) / 3 - 14, (w - 12) / 9 - 4, 12); } } g.fillStyle = 'rgba(255,255,255,.12)'; g.beginPath(); g.moveTo(x0 + w * 0.15, y0); g.lineTo(x0 + w * 0.32, y0); g.lineTo(x0 + w * 0.12, y0 + h); g.lineTo(x0 - w * 0.05, y0 + h); g.fill(); g.strokeStyle = '#1A1E24'; g.lineWidth = 6; g.strokeRect(x0, y0, w, h); } g.fillStyle = 'rgba(46,230,255,.35)'; g.fillRect(S * 0.12, S * 0.2, S * 0.76, S * 0.05); g.strokeStyle = 'rgba(0,0,0,.4)'; g.lineWidth = 3; for (let y = S * 0.66; y < S; y += 20) { g.beginPath(); g.moveTo(0, y); g.lineTo(S, y); g.stroke(); } } else if (kind === 'rail') { g.fillStyle = '#9A9A9A'; g.fillRect(0, 0, S, S); grain(22); for (let x = 0; x < S; x += 32) { g.fillStyle = 'rgba(0,0,0,.3)'; g.fillRect(x, 0, 4, S); } g.fillStyle = 'rgba(0,0,0,.35)'; g.fillRect(0, S * 0.15, S, 6); g.fillRect(0, S * 0.85, S, 6); g.fillStyle = 'rgba(255,255,255,.75)'; g.font = '800 24px "Arial Narrow", Arial, sans-serif'; g.fillText(`PVR ${100 + (seed % 900)}`, 18, S * 0.3); g.fillStyle = 'rgba(110,60,20,.4)'; for (let k = 0; k < 50; k++) g.fillRect(r() * S, r() * S, 2 + r() * 6, 6 + r() * 30); } return tex(c); } function stripeTexture() { const c = cv(128, 32), g = c.getContext('2d'); g.fillStyle = '#C8C3B8'; g.fillRect(0, 0, 128, 32); g.fillStyle = '#E2A21A'; for (let x = -32; x < 160; x += 32) { g.beginPath(); g.moveTo(x, 32); g.lineTo(x + 16, 32); g.lineTo(x + 32, 0); g.lineTo(x + 16, 0); g.fill(); } g.fillStyle = 'rgba(0,0,0,.25)'; g.fillRect(0, 0, 128, 4); return tex(c); } function signTexture(word, color) { const c = cv(512, 128), g = c.getContext('2d'); g.fillStyle = 'rgba(8,8,12,.85)'; g.fillRect(0, 0, 512, 128); g.font = '800 84px "Arial Narrow", "Roboto Condensed", Arial, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.shadowColor = color; g.shadowBlur = 18; g.fillStyle = color; g.fillText(word, 256, 68); g.shadowBlur = 0; g.fillStyle = '#FFFFFF'; g.globalAlpha = 0.75; g.fillText(word, 256, 68); g.globalAlpha = 1; g.strokeStyle = color; g.lineWidth = 6; g.strokeRect(10, 10, 492, 108); return tex(c, false); } function tagTexture(text, color) { const c = cv(512, 192), g = c.getContext('2d'); g.clearRect(0, 0, 512, 192); g.translate(256, 100); g.rotate(-0.08); g.font = 'italic 900 108px Impact, "Arial Black", sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.lineWidth = 14; g.strokeStyle = 'rgba(0,0,0,.85)'; g.strokeText(text, 0, 0); g.fillStyle = color; g.fillText(text, 0, 0); g.fillStyle = color; for (let k = 0; k < 14; k++) g.fillRect(-200 + Math.random() * 400, 40 + Math.random() * 10, 3, 10 + Math.random() * 30); // drips return tex(c, false); } function letterTexture(L) { const c = cv(128, 128), g = c.getContext('2d'); g.clearRect(0, 0, 128, 128); g.font = '900 96px "Arial Narrow", Arial, sans-serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; g.fillStyle = '#FFFFFF'; g.fillText(L, 64, 70); return tex(c, false); } function blobTexture() { const c = cv(128, 128), g = c.getContext('2d'); const gr = g.createRadialGradient(64, 64, 0, 64, 64, 64); gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.35, 'rgba(255,255,255,.45)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); g.fillStyle = gr; g.fillRect(0, 0, 128, 128); return tex(c, false); } function detailTexture() { const S = 256, c = cv(S, S), g = c.getContext('2d'); const r = R2(4242); const id = g.createImageData(S, S); const d = id.data; const N = 16, grid = []; for (let i = 0; i < N * N; i++) grid.push(r()); const at = (x, y) => grid[((y % N + N) % N) * N + ((x % N + N) % N)]; for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) { const fx = (x / S) * N, fy = (y / S) * N; const ix = Math.floor(fx), iy = Math.floor(fy); const tx = fx - ix, ty = fy - iy; const sx = tx * tx * (3 - 2 * tx), sy = ty * ty * (3 - 2 * ty); const v = (at(ix, iy) * (1 - sx) + at(ix + 1, iy) * sx) * (1 - sy) + (at(ix, iy + 1) * (1 - sx) + at(ix + 1, iy + 1) * sx) * sy; const n = Math.max(0, Math.min(255, (0.35 + v * 0.4 + (r() - 0.5) * 0.35) * 255)); const o = (y * S + x) * 4; d[o] = d[o + 1] = d[o + 2] = n; d[o + 3] = 255; } g.putImageData(id, 0, 0); g.strokeStyle = 'rgba(0,0,0,.55)'; g.lineWidth = 1.2; for (let k = 0; k < 14; k++) { let x = r() * S, y = r() * S; g.beginPath(); g.moveTo(x, y); for (let j = 0; j < 6; j++) { x += (r() - 0.5) * 40; y += (r() - 0.5) * 40; g.lineTo(x, y); } g.stroke(); } for (let k = 0; k < 60; k++) { g.fillStyle = r() < 0.5 ? 'rgba(255,255,255,.25)' : 'rgba(0,0,0,.3)'; g.fillRect(r() * S, r() * S, 1 + r() * 2, 1 + r() * 2); } const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; t.colorSpace = THREE.NoColorSpace; t.anisotropy = 8; return t; } function envTexture(theme) { const W = 512, H = 256, c = cv(W, H), g = c.getContext('2d'); const gr = g.createLinearGradient(0, 0, 0, H); gr.addColorStop(0, '#1C2440'); gr.addColorStop(0.3, '#232B48'); gr.addColorStop(0.45, theme.fog); gr.addColorStop(0.55, '#141826'); gr.addColorStop(1, '#040406'); g.fillStyle = gr; g.fillRect(0, 0, W, H); const r = R2(77); for (let k = 0; k < 40; k++) { const x = r() * W, y = H * (0.12 + r() * 0.38); const cc = theme.neon[k % theme.neon.length]; const rg = g.createRadialGradient(x, y, 0, x, y, 18 + r() * 40); rg.addColorStop(0, cc); rg.addColorStop(1, 'rgba(0,0,0,0)'); g.globalAlpha = 0.55; g.fillStyle = rg; g.fillRect(x - 60, y - 60, 120, 120); } g.globalAlpha = 1; for (let k = 0; k < 400; k++) { g.fillStyle = r() < 0.7 ? 'rgba(255,190,120,.8)' : 'rgba(160,210,255,.8)'; g.fillRect(r() * W, H * (0.3 + r() * 0.2), 1.5, 1.5); } const t = new THREE.CanvasTexture(c); t.mapping = THREE.EquirectangularReflectionMapping; t.colorSpace = THREE.SRGBColorSpace; return t; } /** The ground in map space (120 m square): asphalt, sidewalks, markings, crosswalks, lots, plaza, docks/rail details. */ function groundTextures(map, theme, size) { const S = size, M = 120, px = S / M, c = cv(S, S), g = c.getContext('2d'); const rc = cv(S / 2, S / 2), rg = rc.getContext('2d'); const r = R2(map.seed); const X = (x) => (x + M / 2) * px, Z = (z) => (z + M / 2) * px; g.fillStyle = theme.ground; g.fillRect(0, 0, S, S); const id = g.getImageData(0, 0, S, S); const d = id.data; for (let i = 0; i < d.length; i += 4) { const n = (r() - 0.5) * 18 + (r() < 0.02 ? 14 : 0); d[i] += n; d[i + 1] += n; d[i + 2] += n; } g.putImageData(id, 0, 0); rg.fillStyle = '#6A6A6A'; rg.fillRect(0, 0, S / 2, S / 2); // roughness ~0.42 on asphalt (wet) const rect = (x, z, w, dd, fill) => { g.fillStyle = fill; g.fillRect(X(x - w / 2), Z(z - dd / 2), w * px, dd * px); }; const blocks = [-30, 0, 30]; // sidewalks around every block + curbs for (const bx of blocks) for (const bz of blocks) { rect(bx, bz, 22, 22, theme.side); g.strokeStyle = 'rgba(200,200,200,.18)'; g.lineWidth = px * 0.25; g.strokeRect(X(bx - 11), Z(bz - 11), 22 * px, 22 * px); g.strokeStyle = 'rgba(0,0,0,.25)'; g.lineWidth = 1; for (let k = -10; k <= 10; k += 2) { g.beginPath(); g.moveTo(X(bx + k), Z(bz - 11)); g.lineTo(X(bx + k), Z(bz + 11)); g.stroke(); } rg.fillStyle = '#B8B8B8'; rg.fillRect(X(bx - 11) / 2, Z(bz - 11) / 2, 11 * px, 11 * px); } // plaza tiles (centre) and the two lots rect(0, 0, 20, 20, '#2E2C2A'); g.strokeStyle = 'rgba(0,0,0,.35)'; g.lineWidth = 1.5; for (let k = -10; k <= 10; k += 1.25) { g.beginPath(); g.moveTo(X(-10), Z(k)); g.lineTo(X(10), Z(k)); g.stroke(); g.beginPath(); g.moveTo(X(k), Z(-10)); g.lineTo(X(k), Z(10)); g.stroke(); } for (const lx of [-30, 30]) { rect(lx, 0, 20, 20, theme.ground); g.strokeStyle = 'rgba(230,230,230,.35)'; g.lineWidth = px * 0.15; for (let k = -9; k <= 9; k += 3) { g.beginPath(); g.moveTo(X(lx + k), Z(-9)); g.lineTo(X(lx + k), Z(-4)); g.stroke(); g.beginPath(); g.moveTo(X(lx + k), Z(4)); g.lineTo(X(lx + k), Z(9)); g.stroke(); } rg.fillStyle = '#6A6A6A'; rg.fillRect(X(lx - 10) / 2, Z(-10) / 2, 10 * px, 10 * px); } // lane markings and crosswalks g.strokeStyle = 'rgba(235,225,200,.32)'; g.lineWidth = px * 0.16; g.setLineDash([3 * px, 3 * px]); for (const s of [-15, 15]) { g.beginPath(); g.moveTo(X(s), Z(-60)); g.lineTo(X(s), Z(60)); g.stroke(); g.beginPath(); g.moveTo(X(-60), Z(s)); g.lineTo(X(60), Z(s)); g.stroke(); } g.setLineDash([]); for (const s of [-15, 15]) for (const t of [-41, -19, -11, 11, 19, 41]) { for (let k = -4; k <= 4; k += 1.1) { g.fillStyle = 'rgba(220,220,220,.26)'; g.fillRect(X(s + k - 0.3), Z(t - 1.2), 0.6 * px, 2.4 * px); g.fillRect(X(t - 1.2), Z(s + k - 0.3), 2.4 * px, 0.6 * px); } } if (map.id === 'docks') { g.strokeStyle = 'rgba(240,190,40,.6)'; g.lineWidth = px * 0.3; for (const s of [-45, -20, 20, 45]) { g.beginPath(); g.moveTo(X(-60), Z(s)); g.lineTo(X(60), Z(s)); g.stroke(); } } if (map.id === 'yard') { g.strokeStyle = 'rgba(120,110,100,.9)'; g.lineWidth = px * 0.18; for (const bz of blocks) for (const o of [-6.2, -3.8, 0.4, 2.8]) { g.beginPath(); g.moveTo(X(-60), Z(bz + o)); g.lineTo(X(60), Z(bz + o)); g.stroke(); } g.fillStyle = 'rgba(60,45,35,.8)'; for (const bz of blocks) for (let x = -60; x < 60; x += 0.9) for (const o of [-5, 1.6]) g.fillRect(X(x), Z(bz + o - 1.4), 0.25 * px, 2.8 * px); } // spawn pads for (const [z, cc] of [[45.5, TEAMS[0].color], [-45.5, TEAMS[1].color]]) { g.strokeStyle = cc; g.globalAlpha = 0.4; g.lineWidth = px * 0.3; g.strokeRect(X(-26), Z(z - 3), 52 * px, 6 * px); g.globalAlpha = 1; } // puddles: dark + mirror smooth for (const p of map.decor.filter((q) => q.kind === 'puddle')) { g.fillStyle = 'rgba(8,10,16,.22)'; g.beginPath(); g.ellipse(X(p.x), Z(p.z), p.s * 2.4 * px, p.s * 1.4 * px, p.a, 0, 7); g.fill(); rg.fillStyle = '#383838'; rg.beginPath(); rg.ellipse(X(p.x) / 2, Z(p.z) / 2, p.s * 1.2 * px, p.s * 0.7 * px, p.a, 0, 7); rg.fill(); } // baked contact shadows: soft dark skirts around everything that stands on the street (fake ambient occlusion) for (const o of map.obstacles) { if (o.h < 0.5 || Math.abs(o.x) > 62 || Math.abs(o.z) > 62) continue; const big = o.kind === 'bldg' || o.kind === 'wall'; const rings = big ? [[0.25, 0.2], [0.8, 0.11], [1.8, 0.06]] : [[0.12, 0.26], [0.45, 0.13], [0.95, 0.06]]; for (const [pad, a] of rings) { g.fillStyle = `rgba(0,0,0,${a})`; g.fillRect(X(o.x - o.w / 2 - pad), Z(o.z - o.d / 2 - pad), (o.w + pad * 2) * px, (o.d + pad * 2) * px); } } // crew tags sprayed on the ground near the spawns, cracks, oil g.globalAlpha = 0.12; g.font = `italic 900 ${6 * px}px Impact, sans-serif`; g.textAlign = 'center'; g.fillStyle = TEAMS[0].color; g.fillText(TEAMS[0].name, X(0), Z(40)); g.fillStyle = TEAMS[1].color; g.fillText(TEAMS[1].name, X(0), Z(-38)); g.globalAlpha = 1; g.strokeStyle = 'rgba(0,0,0,.4)'; g.lineWidth = 1.2; for (let k = 0; k < 90; k++) { let x = X(r() * 110 - 55), z = Z(r() * 110 - 55); g.beginPath(); g.moveTo(x, z); for (let j = 0; j < 5; j++) { x += (r() - 0.5) * 30; z += (r() - 0.5) * 30; g.lineTo(x, z); } g.stroke(); } const map1 = tex(c, false); const rough = tex(rc, false, false); return { map: map1, rough }; } // ------------------------------------------------------------------ the renderer export class World { constructor(canvas, { record = false, tier } = {}) { // bloom only catches what is truly brighter than white (threshold 1.3), so neon, lamps and muzzle flashes get a soft // halo instead of a screen-filling smear up close this.stage = new Stage(canvas, { record, tier, shadows: true, bloom: 0.55, threshold: 1.3, exposure: 1.18, sat: 1.1, con: 1.1, vig: 0.5, grain: 0.022, fov: 38, particles: 2400 }); const S = this.stage; this.THREE = THREE; this.low = S.tier === 'low'; // phones / low tier: no bloom, no shadows, capped pixel ratio (first person fills the screen with lit walls) if (this.low) { S.q.maxPr = Math.min(S.q.maxPr, 1.3); S.q.msaa = 2; S.q.bloom = false; } // first person: sparks and smoke that drift right past the lens must not balloon into screen-sized blobs. Cap the // sprite size (about 6 % of the screen height) and fade particles out inside the last 1.2 m before the camera. { const pm = S.parts.mesh.material; pm.vertexShader = pm.vertexShader.replace('gl_PointSize = aSize * uScale / max(-mv.z, 0.1);', 'gl_PointSize = min(aSize * uScale / max(-mv.z, 0.1), uScale * 0.06); vA *= smoothstep(0.35, 1.2, -mv.z);'); pm.needsUpdate = true; } S.r.shadowMap.enabled = !this.low; this.scene = S.scene; this.cam = S.cam; this.cam.far = 600; this.blob = blobTexture(); this.camPos = new THREE.Vector3(0, 40, 30); this.camLook = new THREE.Vector3(); this.ops = []; this.tr = []; this.booms = []; this.scorch = []; this.marks = []; this.t = 0; this.cutA = new THREE.Vector3(); this.cutB = new THREE.Vector3(); this.cutR = { value: 0 }; this.mode = 'menu'; this.orbit = 0; this.vm = new ViewModel(S, this.cam); this.scene.add(this.cam); this.focus = new THREE.Vector3(); } /** Build the whole city for a map. */ build(map) { const S = this.stage, scene = this.scene; const theme = THEMES[map.id] || THEMES.row; this.theme = theme; this.map = map; for (const o of [...scene.children]) if (o !== S.parts.mesh && o !== S.skyMesh && o !== this.cam) scene.remove(o); this.ops = []; this.tr = []; this.booms = []; this.scorch = []; this.marks = []; S.parts.clear(); S.look = { ...S.look, ...theme.look }; S.sky({ top: '#020308', hor: theme.fog, low: '#020204', glow: theme.neon[0], glowDir: [0, 0.15, -1], glowK: 0.16, stars: 0.0 }); scene.fog = new THREE.FogExp2(col(theme.fog), theme.fogD); // environment for reflections (wet ground, car paint, visors) if (!this.pmrem) this.pmrem = new THREE.PMREMGenerator(S.r); if (this.envRT) this.envRT.dispose(); this.envRT = this.pmrem.fromEquirectangular(envTexture(theme)); scene.environment = this.envRT.texture; scene.environmentIntensity = 0.85; // lights: dim sky fill, a cold moon with shadows, 4 sodium lamps that follow the camera, 3 muzzle/boom lights // sky fill strong enough that walls facing away from the moon still read as surfaces (not black slabs), plus a soft // cool bounce from the opposite side with no shadows const hemi = new THREE.HemisphereLight(col(theme.hemi[0]), col(theme.hemi[1]), theme.hemi[2] * 2.6); scene.add(hemi); const fill = new THREE.DirectionalLight(col('#8FA0D8'), 1.1); fill.position.set(20, 25, 40); scene.add(fill); scene.add(fill.target); this.fill = fill; const moon = new THREE.DirectionalLight(col(theme.moon[0]), theme.moon[1]); moon.position.set(-30, 60, -20); moon.castShadow = !this.low; moon.shadow.mapSize.set(2048, 2048); const sc = moon.shadow.camera; sc.left = -34; sc.right = 34; sc.top = 34; sc.bottom = -34; sc.near = 1; sc.far = 160; moon.shadow.bias = -0.0006; moon.shadow.normalBias = 0.03; scene.add(moon); scene.add(moon.target); this.moon = moon; this.lampLights = (this.low ? [0, 1] : [0, 1, 2, 3]).map(() => { const l = new THREE.PointLight(col(theme.lamp), 0, 16, 1.6); l.position.set(0, 5.2, 0); scene.add(l); return l; }); this.fxLights = (this.low ? [0, 1] : [0, 1, 2]).map(() => { const l = new THREE.PointLight(col('#FFC27A'), 0, 12, 2); scene.add(l); l.life = 0; return l; }); this.fxI = 0; // ground const gt = groundTextures(map, theme, this.low ? 1024 : 2048); const gm = new THREE.MeshStandardMaterial({ map: gt.map, roughnessMap: gt.rough, roughness: 1, metalness: 0.0, envMapIntensity: 2.2 }); // close-up detail for first person: two octaves of tiled grit + cracks, in world space, on top of the map texture const det = detailTexture(); gm.onBeforeCompile = (s) => { s.uniforms.tDet = { value: det }; s.vertexShader = s.vertexShader.replace('#include ', '#include \nvarying vec2 vGW;').replace('#include ', '#include \nvGW = (modelMatrix * vec4(transformed, 1.0)).xz;'); s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nuniform sampler2D tDet; varying vec2 vGW;') .replace('#include ', '#include \nfloat dtl = texture2D(tDet, vGW * 0.31).r * 0.6 + texture2D(tDet, vGW * 0.071 + 0.37).r * 0.4; diffuseColor.rgb *= 0.62 + 0.76 * dtl;') .replace('#include ', '#include \nroughnessFactor = clamp(roughnessFactor * (0.75 + 0.5 * texture2D(tDet, vGW * 0.31 + 0.5).r), 0.08, 1.0);'); }; gm.customProgramCacheKey = () => 'tbGround'; const ground = new THREE.Mesh(new THREE.PlaneGeometry(120, 120), gm); ground.rotation.x = -Math.PI / 2; ground.receiveShadow = true; scene.add(ground); const outer = new THREE.Mesh(new THREE.PlaneGeometry(600, 600), new THREE.MeshStandardMaterial({ color: col('#0E0F12'), roughness: 0.6 })); outer.rotation.x = -Math.PI / 2; outer.position.y = -0.02; scene.add(outer); this.buildCity(map, theme); this.buildZones(map); this.buildRain(); this.buildTracers(); this.ground = ground; } buildCity(map, theme) { const scene = this.scene; const cut = { a: this.cutA, b: this.cutB, r: this.cutR }; // ---- buildings: one instanced mesh, world-space facade + windows + roof, x-ray cutaway near the camera line const bl = map.obstacles.filter((o) => o.kind === 'bldg'); const fac = facadeTexture(map.seed % 4), win = windowAtlas(map.seed), roof = roofTexture(map.seed); const bm = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.82, metalness: 0.05, envMapIntensity: 0.6, side: THREE.DoubleSide, shadowSide: THREE.BackSide }); bm.onBeforeCompile = (s) => { Object.assign(s.uniforms, { tFac: { value: fac }, tWin: { value: win }, tRoof: { value: roof }, uCutA: { value: cut.a }, uCutB: { value: cut.b }, uCutR: cut.r, uWinK: { value: 2.4 } }); s.vertexShader = s.vertexShader.replace('#include ', '#include \nattribute float aSeed; varying vec3 vWP; varying vec3 vWN; varying float vSeed;') .replace('#include ', '#include \nvec4 wpp = modelMatrix * instanceMatrix * vec4(transformed, 1.0); vWP = wpp.xyz; vWN = normalize(mat3(modelMatrix * instanceMatrix) * objectNormal); vSeed = aSeed;'); s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nuniform sampler2D tFac, tWin, tRoof; uniform vec3 uCutA, uCutB; uniform float uCutR, uWinK; varying vec3 vWP; varying vec3 vWN; varying float vSeed; vec3 winE;') .replace('#include ', `#include { float dx = abs(vWP.x - uCutB.x); float south = vWP.z - (uCutB.z - 1.5); float yc = 3.0 + max(0., dx - 13.) * 1.6 + max(0., -south) * 40.; if (uCutR > 0.5 && vWP.y > yc) discard; }`) .replace('#include ', `vec3 an = abs(vWN); winE = vec3(0.); if (!gl_FrontFacing) { diffuseColor.rgb = vec3(0.035, 0.037, 0.045); } else if (vWN.y > 0.5) { diffuseColor.rgb *= texture2D(tRoof, vWP.xz / 10.).rgb * 1.1; } else { vec2 fuv = (an.x > an.z ? vec2(vWP.z, vWP.y) : vec2(vWP.x, vWP.y)) / vec2(8.0, 6.4); diffuseColor.rgb *= texture2D(tFac, fuv).rgb; vec2 wuv = fuv / 8.0 + vec2(vSeed, fract(vSeed * 7.31)); winE = texture2D(tWin, wuv).rgb * step(2.8, vWP.y) * uWinK; } diffuseColor.rgb *= mix(0.45, 1.0, smoothstep(0.0, 1.6, vWP.y));`) .replace('#include ', '#include \ntotalEmissiveRadiance += winE * winE * 1.6;'); }; bm.customProgramCacheKey = () => 'tbBldg'; const bmesh = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), bm, bl.length); const seeds = new Float32Array(bl.length); const m4 = new THREE.Matrix4(); const r = R2(map.seed + 5); bl.forEach((o, i) => { m4.makeScale(o.w, o.h, o.d).setPosition(o.x, o.h / 2, o.z); bmesh.setMatrixAt(i, m4); seeds[i] = r(); }); bmesh.geometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(seeds, 1)); bmesh.castShadow = bmesh.receiveShadow = true; scene.add(bmesh); this.bldg = bmesh; // roof clutter: parapets, AC units, water tanks (instanced boxes) const roofBits = []; const parapet = []; for (const o of bl) { parapet.push([o.x, o.h + 0.25, o.z - o.d / 2 + 0.15, o.w, 0.5, 0.3], [o.x, o.h + 0.25, o.z + o.d / 2 - 0.15, o.w, 0.5, 0.3], [o.x - o.w / 2 + 0.15, o.h + 0.25, o.z, 0.3, 0.5, o.d], [o.x + o.w / 2 - 0.15, o.h + 0.25, o.z, 0.3, 0.5, o.d]); const n = Math.max(1, Math.floor((o.w * o.d) / 40)); for (let k = 0; k < n; k++) roofBits.push([o.x + (r() - 0.5) * (o.w - 2.5), o.h + 0.6, o.z + (r() - 0.5) * (o.d - 2.5), 1.2 + r() * 1.6, 1.2, 1 + r() * 1.4]); } this.inst(parapet, new THREE.MeshStandardMaterial({ color: col('#3E4046'), roughness: 0.9 }), { cut }); // the harbour wall on the south edge: concrete with a hazard stripe and a row of dim lights const walls = map.obstacles.filter((o) => o.kind === 'wall'); this.inst(walls.map((o) => [o.x, o.h / 2, o.z - o.d / 2 + 0.5, o.w, o.h, 1]), new THREE.MeshStandardMaterial({ map: propTexture('wall', 3), roughness: 0.85 })); this.inst(walls.map((o) => [o.x, o.h + 0.06, o.z - o.d / 2 + 0.5, o.w, 0.12, 1.02]), new THREE.MeshStandardMaterial({ map: stripeTexture(), roughness: 0.7 })); this.inst(walls.map((o) => [o.x, 1.4, o.z - o.d / 2 - 0.02, o.w * 0.9, 0.08, 0.04]), S_glow(this.stage, theme.neon[1], 1.6), { shadow: false }); this.inst(roofBits, new THREE.MeshStandardMaterial({ color: col('#6A6E74'), roughness: 0.55, metalness: 0.5 }), { cut }); // ---- street props by kind const P = (kind) => map.obstacles.filter((o) => o.kind === kind); const tints = ['#C8503A', '#3A7AB0', '#4E9A62', '#D8A030', '#8A929E', '#B04A7A']; const ctex = containerTexture(); const contMat = new THREE.MeshStandardMaterial({ map: ctex, roughness: 0.62, metalness: 0.28, envMapIntensity: 0.8 }); this.inst(P('container').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d, tints[o.tint % tints.length]]), contMat, { colors: true, cut }); const trainMat = new THREE.MeshStandardMaterial({ map: propTexture('rail', map.seed), roughness: 0.55, metalness: 0.25 }); this.inst(P('train').map((o) => [o.x, o.h / 2 + 0.3, o.z, o.w, o.h - 0.3, o.d, ['#5E6670', '#844634', '#4A6680', '#6E6E54'][o.tint % 4]]), trainMat, { colors: true, cut }); this.inst(P('train').map((o) => [o.x, 0.15, o.z, o.w - 1, 0.3, o.d - 0.6]), new THREE.MeshStandardMaterial({ color: col('#101012'), roughness: 0.9 })); this.inst(P('platform').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('platform'), roughness: 0.85 })); this.inst(P('pillar').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('pillar', 2), roughness: 0.8 }), { cut }); this.inst(P('barrier').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: stripeTexture(), roughness: 0.7 })); this.inst(P('dumpster').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('dumpster'), roughness: 0.6, metalness: 0.2 })); this.inst(P('crate').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('crate', map.seed), roughness: 0.85 })); this.inst(P('planter').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('planter'), roughness: 0.9 })); this.inst(P('planter').map((o) => [o.x, o.h + 0.25, o.z, o.w - 0.25, 0.5, o.d - 0.25]), new THREE.MeshStandardMaterial({ color: col('#1C2A1C'), roughness: 1 })); this.inst(P('kiosk').map((o) => [o.x, o.h / 2, o.z, o.w, o.h, o.d]), new THREE.MeshStandardMaterial({ map: propTexture('kiosk'), roughness: 0.45, metalness: 0.2 }), { cut }); this.inst(P('kiosk').map((o) => [o.x, o.h - 0.5, o.z, o.w + 0.05, 0.35, o.d + 0.05]), S_glow(this.stage, theme.neon[1], 1.7)); // cars: body + cabin glass + head/tail lights const cars = P('car'); const paint = ['#3A3E46', '#7A1E22', '#2E4A62', '#A8A8A4', '#4A4A52', '#6A4A22']; this.inst(cars.map((o) => [o.x, 0.5, o.z, o.w, 0.7, o.d, paint[o.tint % paint.length]]), new THREE.MeshPhysicalMaterial({ roughness: 0.38, metalness: 0.18, clearcoat: 1, clearcoatRoughness: 0.1, envMapIntensity: 1.4 }), { colors: true }); this.inst(cars.map((o) => { const along = o.d > o.w; return [o.x, 1.08, o.z, along ? o.w * 0.86 : o.w * 0.5, 0.48, along ? o.d * 0.5 : o.d * 0.86]; }), new THREE.MeshStandardMaterial({ color: col('#24303C'), roughness: 0.08, metalness: 0.6, envMapIntensity: 2.2 })); const head = [], tail = []; for (const o of cars) { const along = o.d > o.w; const s = o.mir ? -1 : 1; for (const k of [-1, 1]) { if (along) { head.push([o.x + k * o.w * 0.3, 0.55, o.z - s * o.d / 2, 0.3, 0.14, 0.06]); tail.push([o.x + k * o.w * 0.3, 0.6, o.z + s * o.d / 2, 0.3, 0.12, 0.06]); } else { head.push([o.x - s * o.w / 2, 0.55, o.z + k * o.d * 0.3, 0.06, 0.14, 0.3]); tail.push([o.x + s * o.w / 2, 0.6, o.z + k * o.d * 0.3, 0.06, 0.12, 0.3]); } } } this.inst(head, S_glow(this.stage, '#FFF2D8', 1.9)); this.inst(tail, S_glow(this.stage, '#FF2030', 1.8)); // ---- lamps: poles + glowing heads + light pools on the wet ground this.lamps = map.lamps; this.inst(map.lamps.map((l) => [l.x, 2.7, l.z, 0.16, 5.4, 0.16]), new THREE.MeshStandardMaterial({ color: col('#2A2C30'), roughness: 0.5, metalness: 0.7 })); this.inst(map.lamps.map((l) => [l.x, 5.45, l.z, 0.7, 0.18, 0.35]), S_glow(this.stage, theme.lamp, 2.6)); const pool = new THREE.MeshBasicMaterial({ map: this.blob, color: col(theme.lamp).multiplyScalar(0.55), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }); const pg = new THREE.PlaneGeometry(1, 1); pg.rotateX(-Math.PI / 2); const pools = new THREE.InstancedMesh(pg, pool, map.lamps.length); map.lamps.forEach((l, i) => { m4.makeScale(10, 1, 10).setPosition(l.x, 0.03, l.z); pools.setMatrixAt(i, m4); }); scene.add(pools); // ---- neon signs on facades + their glow on the street const words = new Map(); for (const s of map.signs) { const o = s.on; const color = theme.neon[s.hue % theme.neon.length]; const word = WORDS[s.word % WORDS.length]; const key = word + color; if (!words.has(key)) words.set(key, new THREE.MeshBasicMaterial({ map: signTexture(word, color), color: col('#FFFFFF').multiplyScalar(1.6), toneMapped: false, transparent: true, depthWrite: false })); const w = Math.min(4.2, (s.side % 2 ? o.d : o.w) * 0.7), h = w * 0.25; const m = new THREE.Mesh(new THREE.PlaneGeometry(w, h), words.get(key)); const off = 0.06; const side = s.side % 4; // 0:+z 1:+x 2:-z 3:-x if (side === 0) { m.position.set(o.x, s.y, o.z + o.d / 2 + off); } else if (side === 1) { m.position.set(o.x + o.w / 2 + off, s.y, o.z); m.rotation.y = Math.PI / 2; } else if (side === 2) { m.position.set(o.x, s.y, o.z - o.d / 2 - off); m.rotation.y = Math.PI; } else { m.position.set(o.x - o.w / 2 - off, s.y, o.z); m.rotation.y = -Math.PI / 2; } scene.add(m); m.userData.cut = true; const glow = new THREE.Mesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(color).multiplyScalar(0.7), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending })); const nx = side === 1 ? 1 : side === 3 ? -1 : 0, nz = side === 0 ? 1 : side === 2 ? -1 : 0; glow.position.set(m.position.x + nx * 2.4, 0.04, m.position.z + nz * 2.4); glow.scale.set(w * 1.9, 1, w * 1.9); scene.add(glow); } // ---- storefront neon strips along every building base, with their smeared reflections on the wet street const strips = {}; const refl = []; const r4 = R2(map.seed + 17); for (const o of bl) for (let side = 0; side < 4; side++) { if (r4() < 0.45) continue; const c = theme.neon[Math.floor(r4() * theme.neon.length)]; const along = side % 2 === 0; const L = (along ? o.w : o.d) * (0.35 + r4() * 0.45); const nx = side === 1 ? 1 : side === 3 ? -1 : 0, nz = side === 0 ? 1 : side === 2 ? -1 : 0; const off = (r4() - 0.5) * ((along ? o.w : o.d) - L) * 0.8; const x = o.x + nx * (o.w / 2 + 0.06) + (along ? off : 0), z = o.z + nz * (o.d / 2 + 0.06) + (along ? 0 : off); (strips[c] = strips[c] || []).push([x, 2.95, z, along ? L : 0.1, 0.16, along ? 0.1 : L]); refl.push({ x: x + nx * 2.2, z: z + nz * 2.2, sx: along ? L * 1.2 : 2.4, sz: along ? 4.6 : L * 1.2, c }); } for (const [c, rows] of Object.entries(strips)) this.inst(rows, S_glow(this.stage, c, 2.0), { shadow: false }); const rm = {}; for (const q of refl) (rm[q.c] = rm[q.c] || []).push(q); for (const [c, qs] of Object.entries(rm)) { const mesh = new THREE.InstancedMesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(c).multiplyScalar(0.55), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }), qs.length); qs.forEach((q, i) => { m4.makeScale(q.sx, 1, q.sz).setPosition(q.x, 0.035, q.z); mesh.setMatrixAt(i, m4); }); scene.add(mesh); } // lamp reflections: long streaks towards the camera on the wet asphalt const lr = new THREE.InstancedMesh(pg, new THREE.MeshBasicMaterial({ map: this.blob, color: col(theme.lamp).multiplyScalar(0.5), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending }), map.lamps.length); map.lamps.forEach((l, i) => { m4.makeScale(1.6, 1, 9).setPosition(l.x, 0.036, l.z + 4.2); lr.setMatrixAt(i, m4); }); scene.add(lr); // ---- crew tags on walls near each spawn (fictional crews) for (const [team, z] of [[0, 1], [1, -1]]) { const T = TEAMS[team]; const cands = bl.filter((o) => Math.sign(o.z) === z && Math.abs(o.z) < 44 && o.h > 4).slice(0, 3); for (const o of cands) { const m = new THREE.Mesh(new THREE.PlaneGeometry(4.2, 1.6), new THREE.MeshStandardMaterial({ map: tagTexture(T.name, T.color), transparent: true, roughness: 0.8, emissive: col(T.color), emissiveIntensity: 0.25, emissiveMap: null, depthWrite: false })); m.position.set(o.x, 1.9, o.z + z * (o.d / 2 + 0.05)); if (z < 0) m.rotation.y = Math.PI; scene.add(m); } } // ---- steam vents (render-only particles) and cones this.vents = map.decor.filter((d) => d.kind === 'vent'); this.inst(this.vents.map((v) => [v.x, 0.03, v.z, 1.1, 0.05, 1.1]), new THREE.MeshStandardMaterial({ color: col('#1A1A1A'), roughness: 0.4, metalness: 0.8 })); this.inst(map.decor.filter((d) => d.kind === 'cone').map((v) => [v.x, 0.35, v.z, 0.4, 0.7, 0.4]), new THREE.MeshStandardMaterial({ color: col('#E2561A'), roughness: 0.6 })); this.inst(map.decor.filter((d) => d.kind === 'trash').map((v) => [v.x, 0.25, v.z, 0.7 * v.s, 0.5, 0.5 * v.s]), new THREE.MeshStandardMaterial({ color: col('#121214'), roughness: 0.3, metalness: 0.2 })); // perimeter skyline beyond the walls (dark silhouettes with windows, fogged) const far = []; const r3 = R2(map.seed + 99); for (let a = 0; a < 40; a++) { const ang = (a / 40) * Math.PI * 2; const d = 82 + r3() * 30; far.push([Math.cos(ang) * d, 0, Math.sin(ang) * d, 10 + r3() * 10, 20 + r3() * 50, 10 + r3() * 10]); } const farM = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), bm, far.length); const fs = new Float32Array(far.length); far.forEach((b, i) => { m4.makeScale(b[3], b[4], b[5]).setPosition(b[0], b[4] / 2, b[2]); farM.setMatrixAt(i, m4); fs[i] = r3(); }); farM.geometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(fs, 1)); scene.add(farM); } /** Instanced boxes: rows of [x, y, z, w, h, d, color?]. */ inst(rows, mat, { colors = false, cut = null, shadow = true } = {}) { if (!rows.length) return null; if (cut && !mat.userData.cutDone) { mat.userData.cutDone = true; const prev = mat.onBeforeCompile; mat.onBeforeCompile = (s, r) => { prev && prev.call(mat, s, r); Object.assign(s.uniforms, { uCutA: { value: cut.a }, uCutB: { value: cut.b }, uCutR: cut.r }); s.vertexShader = s.vertexShader.replace('#include ', '#include \nvarying vec3 vWP2;').replace('#include ', '#include \nvWP2 = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).xyz;'); s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nuniform vec3 uCutA, uCutB; uniform float uCutR; varying vec3 vWP2;') .replace('#include ', `#include { float dx = abs(vWP2.x - uCutB.x); float south = vWP2.z - (uCutB.z - 1.5); float yc = 3.0 + max(0., dx - 13.) * 1.6 + max(0., -south) * 40.; if (uCutR > 0.5 && vWP2.y > yc) discard; }`); }; mat.customProgramCacheKey = () => 'tbCut'; // same shader code for every cut material (uniforms stay per material), so they share one program } if (mat.isMeshStandardMaterial && !mat.userData.aoDone) aoPatch(mat); const m = new THREE.InstancedMesh(new THREE.BoxGeometry(1, 1, 1), mat, rows.length); const m4 = new THREE.Matrix4(); rows.forEach((b, i) => { m4.makeScale(b[3], b[4], b[5]).setPosition(b[0], b[1], b[2]); m.setMatrixAt(i, m4); if (colors) m.setColorAt(i, col(b[6])); }); m.castShadow = shadow && !(mat.blending === THREE.AdditiveBlending); m.receiveShadow = true; this.scene.add(m); return m; } buildZones(map) { this.zoneFx = map.zones.map((z) => { const g = new THREE.Group(); g.position.set(z.x, 0, z.z); const ring = new THREE.Mesh(new THREE.RingGeometry(z.r - 0.18, z.r, 72), new THREE.MeshBasicMaterial({ color: col('#FFFFFF').multiplyScalar(1.6), transparent: true, opacity: 0.8, depthWrite: false, side: THREE.DoubleSide })); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.05; g.add(ring); const fill = new THREE.Mesh(new THREE.CircleGeometry(z.r, 72), new THREE.MeshBasicMaterial({ color: col('#FFFFFF'), transparent: true, opacity: 0.06, depthWrite: false, blending: THREE.AdditiveBlending })); fill.rotation.x = -Math.PI / 2; fill.position.y = 0.04; g.add(fill); const arc = new THREE.Mesh(TUBE, arcMat('#FFFFFF', 3)); setArc(arc, -Math.PI / 2, 0, z.r + 0.35, 0.12, 0.1); g.add(arc); const col1 = new THREE.Mesh(new THREE.CylinderGeometry(z.r * 0.98, z.r, 7, 48, 1, true), new THREE.ShaderMaterial({ uniforms: { uC: { value: col('#FFFFFF') }, uA: { value: 0.12 } }, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide, vertexShader: 'varying float vY; void main(){ vY = uv.y; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.); }', fragmentShader: 'uniform vec3 uC; uniform float uA; varying float vY; void main(){ gl_FragColor = vec4(uC * 1.4, uA * pow(1. - vY, 2.2)); }' })); col1.position.y = 3.5; g.add(col1); const lt = new THREE.Sprite(new THREE.SpriteMaterial({ map: letterTexture(z.id), color: col('#FFFFFF').multiplyScalar(1.2), depthWrite: false, toneMapped: false, transparent: true, opacity: 0.7 })); lt.scale.set(2.2, 2.2, 1); lt.position.y = 4.6; g.add(lt); this.scene.add(g); return { g, ring, fill, arc, col1, lt, z }; }); } buildRain() { const N = this.low ? 700 : 1600; const pos = new Float32Array(N * 6); const seed = new Float32Array(N * 2); for (let i = 0; i < N; i++) { const x = Math.random() * 60 - 30, z = Math.random() * 60 - 30, y = Math.random() * 30; pos.set([x, y, z, x, y, z], i * 6); seed[i * 2] = seed[i * 2 + 1] = Math.random(); } const g = new THREE.BufferGeometry(); g.setAttribute('position', new THREE.BufferAttribute(pos, 3)); g.setAttribute('aS', new THREE.BufferAttribute(seed, 1)); const ends = new Float32Array(N * 2); for (let i = 0; i < N; i++) { ends[i * 2] = 0; ends[i * 2 + 1] = 1; } g.setAttribute('aE', new THREE.BufferAttribute(ends, 1)); this.rainU = { uT: { value: 0 }, uC: { value: new THREE.Vector3() } }; const m = new THREE.ShaderMaterial({ uniforms: this.rainU, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, vertexShader: `uniform float uT; uniform vec3 uC; attribute float aS; attribute float aE; varying float vA; void main(){ vec3 p = position; float fall = mod(p.y - uT * (24. + aS * 8.), 30.); vec3 w = vec3(mod(p.x - uC.x + 30., 60.) - 30. + uC.x, fall, mod(p.z - uC.z + 30., 60.) - 30. + uC.z); w += vec3(0.25, 1.1, 0.12) * aE; vA = (0.08 + 0.14 * aS) * (aE > .5 ? 1. : 0.1); gl_Position = projectionMatrix * viewMatrix * vec4(w, 1.); }`, fragmentShader: 'varying float vA; void main(){ gl_FragColor = vec4(vec3(0.55,0.65,0.85), vA); }' }); const L = new THREE.LineSegments(g, m); L.frustumCulled = false; L.renderOrder = 5; this.scene.add(L); this.rain = L; } buildTracers() { const N = 128; const g = new THREE.BoxGeometry(1, 1, 1); const m = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, toneMapped: false }); this.trMesh = new THREE.InstancedMesh(g, m, N); this.trMesh.frustumCulled = false; this.trMesh.renderOrder = 8; this.trMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); for (let i = 0; i < N; i++) { this.trMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(0, 0, 0)); this.trMesh.setColorAt(i, col('#000000')); } this.scene.add(this.trMesh); this.trI = 0; this.trs = new Array(N).fill(null); // muzzle flash sprites this.flashMat = new THREE.SpriteMaterial({ map: this.blob, color: col('#FFB860').multiplyScalar(1.7), blending: THREE.AdditiveBlending, depthWrite: false, toneMapped: false }); // scorch decals const sg = new THREE.PlaneGeometry(1, 1); sg.rotateX(-Math.PI / 2); this.scMesh = new THREE.InstancedMesh(sg, new THREE.MeshBasicMaterial({ map: this.blob, color: col('#000000'), transparent: true, opacity: 0.75, depthWrite: false }), 24); for (let i = 0; i < 24; i++) this.scMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(0, 0, 0)); this.scMesh.renderOrder = 1; this.scene.add(this.scMesh); this.scI = 0; // shock rings this.ringPool = [0, 1, 2, 3, 4, 5].map(() => { const m = new THREE.Mesh(new THREE.RingGeometry(0.85, 1, 48), new THREE.MeshBasicMaterial({ color: col('#FFB070').multiplyScalar(3), transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending, side: THREE.DoubleSide })); m.rotation.x = -Math.PI / 2; m.position.y = 0.15; this.scene.add(m); m.life = 0; return m; }); this.ringI = 0; // skyfall warning lines + sentry/drone meshes are created lazily this.warn = []; } // ------------------------------------------------------------------ operators // A human-scale tactical operator (1.8 m, eye at 1.62 m, matching the sim's hitboxes): thighs and shins that walk // and crouch, a vest with a crew-coloured plate strip, a helmet with a glowing visor, arms that pitch with the aim // and the operator's real weapon. Each rigid part is one merged mesh (vertex colours), ~9 draw calls per operator. makeOp(team, skinId = 0, you = false, weapon = 'vk9') { const T = TEAMS[team]; const sk = SKINS[skinId] || SKINS[0]; const jacket = sk.jacket || (team === 0 ? '#45473A' : '#34465A'), plate = sk.plate || (team === 0 ? '#7A6650' : '#4A6278'); const rimC = col(sk.visor || T.color).multiplyScalar(you ? 1.2 : 1.0); if (!this.opMats) this.opMats = {}; const key = `${team}-${skinId}-${you ? 1 : 0}`; if (!this.opMats[key]) { const m = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.6, metalness: sk.metal ? 0.6 : 0.12, envMapIntensity: 0.8 }); m.onBeforeCompile = (s) => { s.uniforms.uRimC = { value: rimC }; s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nuniform vec3 uRimC;').replace('#include ', 'float rimF = 1.0 - max(dot(normalize(vViewPosition), normal), 0.0);\noutgoingLight += uRimC * pow(rimF, 2.6) * 0.75;\n#include '); }; m.customProgramCacheKey = () => 'tbOp2'; this.opMats[key] = { body: m, vis: new THREE.MeshBasicMaterial({ color: col(sk.visor || T.color).multiplyScalar(2.6), toneMapped: false }), gun: new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.4, metalness: 0.6 }) }; } const MT = this.opMats[key]; const V = (x, y, z) => new THREE.Vector3(x, y, z); const BX = new THREE.BoxGeometry(1, 1, 1), CP = new THREE.CapsuleGeometry(1, 1, 3, 8), SP = new THREE.SphereGeometry(1, 14, 10); const part = (g, x, y, z, sx, sy, sz, c, rx = 0, ry = 0, rz = 0) => ({ g, m: new THREE.Matrix4().compose(V(x, y, z), new THREE.Quaternion().setFromEuler(new THREE.Euler(rx, ry, rz)), V(sx, sy, sz)), c }); const mesh = (parts, mat, parent) => { const m = new THREE.Mesh(mergeParts(parts), mat); m.castShadow = true; m.receiveShadow = false; parent.add(m); return m; }; const dark = '#22252B', boot = '#16171A', stripe = sk.stripe || T.color; const root = new THREE.Group(); const hip = new THREE.Group(); hip.position.y = 0.95; root.add(hip); const legs = []; for (const s of [-1, 1]) { const thigh = new THREE.Group(); thigh.position.set(0, -0.02, s * 0.11); hip.add(thigh); mesh([part(BX, 0, -0.23, 0, 0.18, 0.46, 0.17, dark), part(BX, 0.03, -0.18, s * 0.06, 0.12, 0.14, 0.06, plate)], MT.body, thigh); const shin = new THREE.Group(); shin.position.set(0, -0.46, 0); thigh.add(shin); mesh([part(BX, 0, -0.21, 0, 0.15, 0.42, 0.15, dark), part(BX, 0.06, -0.06, 0, 0.06, 0.14, 0.13, plate), part(BX, 0.05, -0.44, 0, 0.27, 0.1, 0.15, boot)], MT.body, shin); legs.push({ thigh, shin }); } const torso = new THREE.Group(); hip.add(torso); mesh([ part(BX, 0, 0.12, 0, 0.24, 0.26, 0.34, dark), part(BX, 0, 0.42, 0, 0.27, 0.36, 0.44, jacket), part(BX, 0.03, 0.42, 0, 0.26, 0.32, 0.42, plate), part(BX, -0.19, 0.4, 0, 0.13, 0.36, 0.3, '#2A2C30'), part(BX, 0.17, 0.33, 0, 0.04, 0.16, 0.3, dark), part(CP, 0, 0.6, -0.25, 0.075, 0.08, 0.075, jacket), part(CP, 0, 0.6, 0.25, 0.075, 0.08, 0.075, jacket), part(BX, 0.16, 0.5, 0, 0.012, 0.035, 0.36, stripe), // shoulder armour, a pack on the back and pouches: a broad, readable silhouette at any range part(BX, 0, 0.63, -0.27, 0.18, 0.07, 0.13, plate, 0.35), part(BX, 0, 0.63, 0.27, 0.18, 0.07, 0.13, plate, -0.35), part(BX, -0.23, 0.42, 0, 0.13, 0.32, 0.3, '#1C1E22'), part(BX, -0.3, 0.46, 0, 0.03, 0.2, 0.2, '#26292E'), part(BX, 0.17, 0.25, -0.09, 0.05, 0.08, 0.07, dark), part(BX, 0.17, 0.25, 0.09, 0.05, 0.08, 0.07, dark), ], MT.body, torso); // crew-colour light strips (chest, shoulders, pack): who is who reads instantly, even in the dark and the rain { const lm = new THREE.Mesh(mergeParts([part(BX, 0.168, 0.5, 0, 0.01, 0.03, 0.3, '#FFFFFF'), part(BX, 0.02, 0.672, -0.27, 0.12, 0.012, 0.022, '#FFFFFF'), part(BX, 0.02, 0.672, 0.27, 0.12, 0.012, 0.022, '#FFFFFF'), part(BX, -0.316, 0.5, 0, 0.006, 0.14, 0.02, '#FFFFFF')]), MT.vis); torso.add(lm); } const head = new THREE.Group(); head.position.set(0.02, 0.67, 0); torso.add(head); mesh([part(CP, 0, -0.02, 0, 0.07, 0.06, 0.07, dark), part(SP, 0, 0.0, 0, 0.115, 0.13, 0.11, '#2E3036'), part(SP, -0.01, 0.06, 0, 0.14, 0.11, 0.135, plate), part(BX, -0.02, 0.0, 0, 0.2, 0.05, 0.27, plate)], MT.body, head); const visor = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.05, 0.19), MT.vis); visor.position.set(0.105, 0.02, 0); head.add(visor); const arms = new THREE.Group(); arms.position.set(0.02, 0.56, 0); torso.add(arms); mesh([ part(CP, 0.12, -0.08, -0.2, 0.06, 0.2, 0.06, jacket, 0, 0, 1.2), part(CP, 0.3, -0.13, -0.08, 0.05, 0.18, 0.05, jacket, 0.9, 0, 1.45), part(CP, 0.14, -0.09, 0.2, 0.06, 0.2, 0.06, jacket, 0, 0, 1.2), part(CP, 0.38, -0.11, 0.08, 0.05, 0.2, 0.05, jacket, -0.9, 0, 1.5), part(BX, 0.43, -0.13, -0.0, 0.08, 0.08, 0.08, boot), part(BX, 0.58, -0.08, 0.0, 0.08, 0.08, 0.08, boot), ], MT.body, arms); const gun = thirdPersonGun(weapon, MT.gun); gun.position.set(0.42, -0.06, 0); gun.scale.setScalar(1.15); arms.add(gun); const muzzle = new THREE.Sprite(this.flashMat); muzzle.position.set(weapon === 'lancer' ? 1.62 : weapon === 'wasp' ? 0.98 : weapon === 'breach' ? 1.36 : 1.32, -0.05, 0); muzzle.scale.setScalar(0); arms.add(muzzle); // team ring under the feet (only for the overhead menu camera) and a teammate marker over the head (first person) const ringM = new THREE.MeshBasicMaterial({ color: col(T.color).multiplyScalar(you ? 2.4 : 1.2), transparent: true, opacity: you ? 0.9 : 0.55, depthWrite: false, blending: THREE.AdditiveBlending }); const ring = new THREE.Mesh(new THREE.RingGeometry(0.55, you ? 0.72 : 0.64, 32), ringM); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04; root.add(ring); const mark = new THREE.Sprite(this.markMat(team)); mark.position.y = 2.2; mark.scale.set(0.028, 0.028, 1); mark.renderOrder = 20; root.add(mark); mark.visible = false; this.scene.add(root); return { root, hip, legs, torso, head, arms, gun, muzzle, ring, mark, team, vis: MT.vis, skin: sk, fade: 1, wasAlive: true, ph: Math.random() * 6, dieT: 0, weapon }; } markMat(team) { if (!this.marks2) this.marks2 = {}; if (!this.marks2[team]) { const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'); g.fillStyle = TEAMS[team].color; g.strokeStyle = 'rgba(0,0,0,.8)'; g.lineWidth = 6; g.beginPath(); g.moveTo(10, 16); g.lineTo(32, 46); g.lineTo(54, 16); g.lineTo(32, 28); g.closePath(); g.stroke(); g.fill(); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; this.marks2[team] = new THREE.SpriteMaterial({ map: t, color: col('#FFFFFF').multiplyScalar(1.6), depthTest: false, depthWrite: false, transparent: true, toneMapped: false, sizeAttenuation: false }); // same size on screen at any distance } return this.marks2[team]; } setOps(ents, youSkin = 0) { for (const o of this.ops) this.scene.remove(o.root); this.ops = ents.map((e) => this.makeOp(e.team, e.you ? youSkin : (e.skin || 0) % 1, e.you, e.weapon)); this.youSkin = SKINS[youSkin] || SKINS[0]; const T = TEAMS[0]; if (this.vm) this.vm.setLook(this.youSkin.jacket || '#45473A', this.youSkin.visor || T.color); } // ------------------------------------------------------------------ effects from sim events fx(e, view) { const S = this.stage; const T = (t) => TEAMS[t]; if (e.k === 'shot') { const shooter = view && view.ents[e.from]; const mine = view && view.cam && view.cam.mode === 'fps' && e.from === view.cam.follow; const colr = e.w === 'sentry' ? '#FFFFFF' : (shooter && shooter.you && this.youSkin && this.youSkin.trace) || (e.team === 0 ? '#FFC07A' : '#9AF0FF'); const y0 = e.y0 != null ? e.y0 : 1.35, y1 = e.y1 != null ? e.y1 : 1.35; let sx = e.x0, sy = y0, sz = e.z0; if (mine) { const m = this.vm.muzzleWorld; if (m) { sx = m.x; sy = m.y; sz = m.z; } this.vm.shot(e.w); } else if (shooter && e.from >= 0) { const a = shooter.yaw || 0; sx += Math.cos(a) * 0.9 - Math.sin(a) * 0.12; sz += Math.sin(a) * 0.9 + Math.cos(a) * 0.12; sy -= 0.1; } if (!mine || (Math.random() < 0.6 && this.vm.st.ads < 0.5)) this.tracer(sx, sy, sz, e.x1, y1, e.z1, colr, e.w === 'lancer' ? 0.035 : e.w === 'breach' ? 0.018 : 0.025, e.w === 'lancer' ? 9 : 4.5); if (e.hit === -1) { const nx = e.nx || 0, ny = e.ny || 0, nz = e.nz || 0; S.parts.burst(e.x1, y1, e.z1, { n: 5, colors: ['#FFD8A0', '#FFFFFF'], speed: 5, life: 0.22, size: 1.1, hdr: 3, dir: [nx * 3, ny * 3, nz * 3] }); S.parts.burst(e.x1, y1, e.z1, { n: 2, colors: ['#6A6660', '#4A4844'], speed: 1, life: 0.6, size: 3.5, hdr: 0.5, drag: 3, dir: [nx * 1.2, ny * 1.2 + 0.4, nz * 1.2] }); if (e.world) this.decal(e.x1, y1, e.z1, nx, ny, nz); } else if (this.fpsMode) S.parts.burst(e.x1, y1, e.z1, { n: e.hs ? 6 : 3, colors: [T(1 - e.team).glow, '#FFFFFF'], speed: e.hs ? 5 : 3.5, life: 0.18, size: e.hs ? 0.7 : 0.45, hdr: 1.6 }); else S.parts.burst(e.x1, y1, e.z1, { n: e.hs ? 12 : 7, colors: [T(1 - e.team).glow, '#FFFFFF'], speed: e.hs ? 7 : 5, life: 0.3, size: e.hs ? 1.6 : 1.2, hdr: 3.5 }); if (Math.random() < (mine ? 0.9 : 0.5)) this.fxLight(sx, sy + 0.1, sz, e.team === 0 ? '#FFB060' : '#80E8FF', mine ? 3.2 : 2.6, 0.05); if (mine) { const p = this.vm.muzzleWorld; if (p) S.parts.burst(p.x, p.y, p.z, { n: 1, colors: ['#D8A040'], speed: 2.4, life: 0.5, size: 0.5, hdr: 1.4, up: 1.6, drag: 0.6 }); } } else if (e.k === 'kill') { const vt = view.ents[e.victim] ? view.ents[e.victim].team : 0; const c = T(vt); const ky = e.y || 0; const fp = this.fpsMode; S.parts.burst(e.x, ky + 1.1, e.z, { n: 30, colors: [c.color, c.glow, c.color], speed: fp ? 6 : 12, life: 0.8, size: fp ? 1.4 : 2.2, up: 3, hdr: 1.9 }); S.parts.burst(e.x, ky + 0.6, e.z, { n: 14, colors: ['#40444C', '#2A2C30'], speed: 2.5, life: 1.2, size: fp ? 3 : 5, up: 1.5, hdr: 0.6, drag: 3 }); this.shock(e.x, e.z, c.color, 2.6, ky); } else if (e.k === 'boom') { const big = !e.small; const r = e.r || 3; const by = e.y || 0; S.parts.burst(e.x, by + 1, e.z, { n: big ? 26 : 12, colors: ['#FFB060', '#FF7A1C', '#E2400C'], speed: r * 5.2, life: 0.55, size: big ? 3.2 : 2.4, up: 5, hdr: 0.95 }); S.parts.burst(e.x, by + 0.6, e.z, { n: big ? 40 : 14, colors: ['#FF9A3A', '#FFD27A'], speed: r * 7, life: 0.35, size: 1.2, up: 7, hdr: 2.2, drag: 1.2 }); S.parts.burst(e.x, by + 1.4, e.z, { n: big ? 40 : 12, colors: ['#3A3634', '#24221F', '#4A4440'], speed: r * 1.6, life: 1.8, size: 9, up: 3, hdr: 0.5, drag: 1.6 }); this.fxLight(e.x, by + 2.5, e.z, '#FF9A40', big ? 16 : 6, 0.35); this.shock(e.x, e.z, '#FF9A50', r * 1.15, by); if (big) { this.scorchAt(e.x, e.z, r * 1.5); } if (view && view.cam) { const d = Math.hypot(e.x - view.cam.x, e.z - view.cam.z); S.shake(Math.max(0, (big ? 0.45 : 0.18) - d * 0.015) * (this.fpsMode ? 0.5 : 1)); } } else if (e.k === 'dash') { const en = view.ents[e.id]; if (en && !(this.fpsMode && view.cam && e.id === view.cam.follow)) S.parts.burst(en.x, (en.y || 0) + 0.4, en.z, { n: 14, colors: [T(en.team).glow], speed: 5, life: 0.35, size: 2.4, hdr: 2.2 }); } else if (e.k === 'spawn') { const en = view.ents[e.id]; if (en) { S.parts.burst(en.x, 1.2, en.z, { n: 30, colors: [T(en.team).glow, '#FFFFFF'], speed: 3, life: 0.8, size: 2.5, up: 6, hdr: 2.6 }); this.shock(en.x, en.z, T(en.team).color, 2); } } else if (e.k === 'cap') { const z = this.zoneFx.find((q) => q.z.id === e.zone); if (z) { S.parts.burst(z.z.x, 2, z.z.z, { n: 80, colors: [T(e.team).color, T(e.team).glow, '#FFFFFF'], speed: 14, life: 1, size: 3, up: 8, hdr: 3 }); this.shock(z.z.x, z.z.z, T(e.team).color, 7); } } else if (e.k === 'skyfall') { for (const [x, z] of e.pts) this.warnAt(x, z, e.team); } } tracer(x0, y0, z0, x1, y1, z1, c, w, len = 5) { const dx = x1 - x0, dy = y1 - y0, dz = z1 - z0, L = Math.hypot(dx, dy, dz); if (L < 0.3) return; const i = this.trI = (this.trI + 1) % this.trs.length; this.trs[i] = { x0, y0, z0, dx: dx / L, dy: dy / L, dz: dz / L, L, c: col(c).multiplyScalar(2.3), w, age: 0, len }; } /** Bullet marks on walls and the ground (oriented to the surface; the pool of 96 recycles the oldest). */ decal(x, y, z, nx, ny, nz) { if (!this.dcMesh) { const c = document.createElement('canvas'); c.width = c.height = 64; const g = c.getContext('2d'); const gr = g.createRadialGradient(32, 32, 0, 32, 32, 30); gr.addColorStop(0, 'rgba(0,0,0,1)'); gr.addColorStop(0.25, 'rgba(10,10,12,.95)'); gr.addColorStop(0.45, 'rgba(40,38,36,.5)'); gr.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = gr; g.fillRect(0, 0, 64, 64); const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; this.dcMesh = new THREE.InstancedMesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: t, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -2, opacity: 0.85 }), 96); for (let k = 0; k < 96; k++) this.dcMesh.setMatrixAt(k, new THREE.Matrix4().makeScale(0, 0, 0)); this.dcMesh.renderOrder = 2; this.dcMesh.frustumCulled = false; this.dcI = 0; } if (this.dcMesh.parent !== this.scene) this.scene.add(this.dcMesh); const k = this.dcI = (this.dcI + 1) % 96; const n = new THREE.Vector3(nx, ny, nz); if (n.lengthSq() < 0.5) return; const qq = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), n); qq.multiply(new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.random() * 6.28)); const s = 0.09 + Math.random() * 0.05; this.dcMesh.setMatrixAt(k, new THREE.Matrix4().compose(new THREE.Vector3(x + nx * 0.012, y + ny * 0.012, z + nz * 0.012), qq, new THREE.Vector3(s, s, s))); this.dcMesh.instanceMatrix.needsUpdate = true; } fxLight(x, y, z, c, k, life) { const l = this.fxLights[this.fxI = (this.fxI + 1) % this.fxLights.length]; l.position.set(x, y, z); l.color.set(c); l.intensity = k; l.life = life; l.max = life; l.k = k; } shock(x, z, c, r, y = 0) { const m = this.ringPool[this.ringI = (this.ringI + 1) % this.ringPool.length]; m.position.set(x, y + 0.12, z); m.material.color.set(c).multiplyScalar(1.8); m.life = 0.5; m.r = r; } scorchAt(x, z, r) { const i = this.scI = (this.scI + 1) % 24; this.scMesh.setMatrixAt(i, new THREE.Matrix4().makeScale(r, 1, r).setPosition(x, 0.025, z)); this.scMesh.instanceMatrix.needsUpdate = true; } warnAt(x, z, team) { const m = new THREE.Mesh(new THREE.RingGeometry(0.4, 4.8, 40), new THREE.MeshBasicMaterial({ color: col(team === 0 ? '#FF6A1A' : '#2EC8FF').multiplyScalar(1.5), transparent: true, opacity: 0.35, depthWrite: false, blending: THREE.AdditiveBlending })); m.rotation.x = -Math.PI / 2; m.position.set(x, 0.08, z); this.scene.add(m); this.warn.push({ m, life: 2.6 }); } // ------------------------------------------------------------------ per-frame draw /** * view: { ents: [{x,z,yaw,alive,moving,firing,hp,dash,kick,team,you,show}], sentries: [[x,z,yaw,team,firing]], zones: [[owner, prog, contest]], * charges: [[x0,z0,x,z,t,fly]], cam: {x, z, ax, az, zoom, mode}, recon: [a,b], slow } */ draw(view, dt) { const S = this.stage; this.t += dt; S.r.info.autoReset = false; S.r.info.reset(); // camera: first person (eye, yaw, pitch, horizontal FOV, zoom) or the overhead menu orbit / follow camera const cam = this.cam; const c = view.cam; const aspect = S.w / S.h || 1.6; const portrait = aspect < 1; const fps = c.mode === 'fps'; this.fpsMode = fps; if (fps) { const hf = ((c.fov || 82) * Math.PI) / 180; let vf = Math.min(1.75, 2 * Math.atan(Math.tan(hf / 2) / aspect)); vf = 2 * Math.atan(Math.tan(vf / 2) / (c.zoom || 1)); const deg = (vf * 180) / Math.PI; if (Math.abs(cam.fov - deg) > 0.01 || cam.near !== 0.05) { cam.fov = deg; cam.near = 0.05; cam.updateProjectionMatrix(); } const cp = Math.cos(c.pitch || 0), dx = Math.cos(c.yaw) * cp, dy = Math.sin(c.pitch || 0), dz = Math.sin(c.yaw) * cp; this.camPos.set(c.x, c.y, c.z); this.camLook.set(c.x + dx * 30, c.y + dy * 30, c.z + dz * 30); this.focus.set(c.x + dx * 12, 0, c.z + dz * 12); } else { if (Math.abs(cam.fov - 38) > 0.01 || cam.near !== 0.1) { cam.fov = 38; cam.near = 0.1; cam.updateProjectionMatrix(); } if (c.mode === 'orbit') { this.orbit += dt * 0.06; const R = c.r || 62; const h = c.h || 46; this.camPos.set(Math.cos(this.orbit) * R, h, Math.sin(this.orbit) * R); this.camLook.set(c.x || 0, 0, c.z || 0); } else { const zoom = (c.zoom || 1) * (portrait ? 1.55 / Math.max(0.5, aspect) ** 0.25 : 1); const lead = c.mode === 'kill' ? 0 : 3.4; const lx = c.x + (c.ax || 0) * lead, lz = c.z + (c.az || 0) * lead; const tx = lx, ty = 21 * zoom, tz = lz + 17 * zoom; const k = c.snap ? 1e3 : c.mode === 'kill' ? 4 : 6; this.camPos.set(damp(this.camPos.x, tx, k, dt), damp(this.camPos.y, ty, k, dt), damp(this.camPos.z, tz, k, dt)); this.camLook.set(damp(this.camLook.x, lx, k, dt), 0.8, damp(this.camLook.z, lz, k, dt)); } this.focus.set(this.camLook.x, 0, this.camLook.z); } S.setCam(this.camPos, this.camLook); // cutaway between the camera and the followed operator (overhead follow camera only) if (c.mode === 'follow' || c.mode === 'kill') { this.cutA.copy(this.camPos); this.cutB.set(c.x, 1.0, c.z); this.cutR.value = 1; } else this.cutR.value = 0; // the first-person viewmodel if (fps && c.weapon) this.vm.use(c.weapon); this.vm.update(dt, { fov: cam.fov, visible: fps && !!c.weapon && c.alive !== false, moving: c.moving, sprint: c.sprint, ads: c.ads || 0, ground: c.ground !== false, crouch: c.crouch || 0, reload: c.reload == null ? -1 : c.reload, lookDX: c.lookDX, lookDY: c.lookDY, hideOnScope: c.weapon === 'lancer', slide: !!c.slide }); // moon shadow follows the camera target const F = this.focus; if (this.moon) { this.moon.target.position.set(F.x, 0, F.z); this.moon.position.set(F.x - 26, 60, F.z - 18); } // lamps: the 4 nearest get a real light if (this.lamps && this.lamps.length) { const near = this.lamps.map((l) => [l, (l.x - F.x) ** 2 + (l.z - F.z) ** 2]).sort((a, b) => a[1] - b[1]).slice(0, 4); near.forEach(([l], i) => { const L = this.lampLights[i]; if (!L) return; L.position.set(l.x, 5.2, l.z); L.intensity = 26 * (0.94 + 0.06 * Math.sin(this.t * 13 + i)); }); } for (const l of this.fxLights) { if (l.life > 0) { l.life -= dt; l.intensity = l.k * Math.max(0, l.life / l.max); } else l.intensity = 0; } // operators: walk / crouch / aim poses; a tagged-out operator staggers, drops and breaks into sparks view.ents.forEach((e, i) => { const op = this.ops[i]; if (!op) return; if (op.wasAlive && !e.alive) op.dieT = 0.75; op.wasAlive = e.alive; if (op.dieT > 0) op.dieT -= dt; const show = (e.alive || op.dieT > 0) && e.show !== false && !(fps && i === c.follow); op.fade = damp(op.fade, show ? 1 : 0, show ? 20 : 9, dt); op.root.visible = show && op.fade > 0.05; if (!op.root.visible) return; const cr = e.crouch || 0, mv = Math.min(1, (e.moving || 0) / 6.3), dying = !e.alive ? 1 - Math.max(0, op.dieT) / 0.75 : 0; op.root.position.set(e.x, (e.y || 0) - dying * 0.25, e.z); op.root.rotation.set(0, -e.yaw, -dying * 1.25, 'YXZ'); op.root.scale.setScalar(dying > 0.8 ? 1 - (dying - 0.8) * 4 : 1); op.ph += dt * (2 + (e.moving || 0) * 1.45); const sw = Math.sin(op.ph) * mv * 0.7; op.hip.position.y = 0.95 - cr * 0.4 + Math.abs(Math.cos(op.ph)) * 0.04 * mv; op.legs[0].thigh.rotation.z = sw + cr * 1.2; op.legs[0].shin.rotation.z = -(cr * 2.1 + Math.max(0, Math.sin(op.ph)) * mv * 0.9); op.legs[1].thigh.rotation.z = -sw + cr * 1.2; op.legs[1].shin.rotation.z = -(cr * 2.1 + Math.max(0, -Math.sin(op.ph)) * mv * 0.9); const pitch = e.pitch || 0; op.fl = damp(op.fl || 0, e.flinch ? 1 : 0, e.flinch ? 40 : 10, dt); // hit flinch: the torso snaps back for a moment op.torso.rotation.z = -0.06 * mv - cr * 0.22 - (e.dash ? 0.3 : 0) + op.fl * 0.22; op.head.rotation.z = pitch * 0.5 + cr * 0.15 + op.fl * 0.15; op.torso.rotation.y = Math.sin(op.ph) * 0.09 * mv; // shoulders counter-swing the stride op.arms.rotation.z = pitch + cr * 0.22 + Math.sin(op.ph * 2) * 0.025 * mv; op.arms.position.x = 0.02 - (e.kick || 0) * 0.07; const fl = e.firing > 0 ? 1 : 0; op.muzzle.scale.setScalar(fl ? 0.16 + Math.random() * 0.08 : 0); // sized to the gun, not the body op.ring.visible = !fps; op.ring.material.opacity = (e.you ? 0.9 : 0.5) * op.fade; op.mark.visible = fps && !!e.mark && e.alive; if (op.mark.visible) op.mark.position.y = 2.15 - cr * 0.45; }); // sentries this.syncSentries(view.sentries || []); // zones const T = TEAMS; view.zones.forEach(([owner, prog, contest], i) => { const z = this.zoneFx[i]; if (!z) return; const c0 = owner >= 0 ? col(T[owner].color) : col('#C8CCD8'); const pulse = contest ? 0.6 + 0.4 * Math.sin(this.t * 14) : 1; z.ring.material.color.copy(c0).multiplyScalar((owner >= 0 ? 1.4 : 0.9) * pulse); z.fill.material.color.copy(c0); z.fill.material.opacity = owner >= 0 ? 0.08 : 0.03; z.col1.material.uniforms.uC.value.copy(c0); z.col1.material.uniforms.uA.value = owner >= 0 ? 0.16 : 0.07; z.lt.material.color.copy(c0).multiplyScalar(1.15 * pulse); z.lt.position.y = 4.6 + Math.sin(this.t * 1.6 + i) * 0.15; const pc = prog >= 0 ? T[0].color : T[1].color; z.arc.material.color.set(pc).multiplyScalar(3); setArc(z.arc, -Math.PI / 2, Math.abs(prog) * Math.PI * 2, z.z.r + 0.35, 0.12, 0.1); }); // charges in flight + blinking this.syncCharges(view.charges || []); // tracers: a short bright streak travelling from the muzzle to the hit point const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), ax = new THREE.Vector3(1, 0, 0), v = new THREE.Vector3(), sc = new THREE.Vector3(), dv = new THREE.Vector3(); for (let i = 0; i < this.trs.length; i++) { const t = this.trs[i]; if (!t) continue; t.age += dt; const L = t.L; const head = Math.min(L, t.age * 320); const tail = Math.max(0, head - t.len); if (tail >= L - 0.01 || t.age > 0.5) { this.trs[i] = null; this.trMesh.setMatrixAt(i, m4.makeScale(0, 0, 0)); continue; } dv.set(t.dx, t.dy, t.dz); q.setFromUnitVectors(ax, dv); const mid = (head + tail) / 2; v.set(t.x0 + t.dx * mid, t.y0 + t.dy * mid, t.z0 + t.dz * mid); sc.set(Math.max(0.01, head - tail), t.w, t.w); this.trMesh.setMatrixAt(i, m4.compose(v, q, sc)); this.trMesh.setColorAt(i, t.c); } this.trMesh.instanceMatrix.needsUpdate = true; if (this.trMesh.instanceColor) this.trMesh.instanceColor.needsUpdate = true; for (const m of this.ringPool) { if (m.life > 0) { m.life -= dt; const k = 1 - m.life / 0.5; m.scale.setScalar(0.4 + k * m.r); m.material.opacity = (1 - k) * 0.9; } else m.material.opacity = 0; } this.warn = this.warn.filter((w) => { w.life -= dt; w.m.material.opacity = 0.2 + 0.25 * (Math.sin(this.t * 22) * 0.5 + 0.5); if (w.life <= 0) { this.scene.remove(w.m); return false; } return true; }); // ambience: rain follows the camera, steam from vents, rain splashes if (this.rainU) { this.rainU.uT.value = this.t; this.rainU.uC.value.set(F.x, 0, F.z); } if (this.vents) for (const vnt of this.vents) if (Math.random() < dt * 9 && Math.abs(vnt.x - F.x) < 30 && Math.abs(vnt.z - F.z) < 30) S.parts.burst(vnt.x, 0.3, vnt.z, { n: 1, colors: ['#8A9098'], speed: 0.6, life: 2.2, size: fps ? 5 : 11, up: 2.2, hdr: 0.32, drag: 0.6 }); for (let k = 0; k < (this.low ? 2 : 5); k++) S.parts.burst(F.x + (Math.random() - 0.5) * (fps ? 24 : 36), 0.05, F.z + (Math.random() - 0.5) * (fps ? 24 : 30), { n: 1, colors: ['#9AB0D0'], speed: 1.0, life: 0.14, size: fps ? 0.35 : 0.6, up: 1.2, hdr: 0.7 }); S.render(dt); } syncSentries(list) { if (!this.sentryMeshes) this.sentryMeshes = []; while (this.sentryMeshes.length < list.length) { const g = new THREE.Group(); const base = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.6, 0.6, 8), new THREE.MeshStandardMaterial({ color: col('#2A2C30'), roughness: 0.4, metalness: 0.8 })); base.position.y = 0.3; g.add(base); const head = new THREE.Group(); head.position.y = 0.85; g.add(head); head.add(new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.35, 0.5), new THREE.MeshStandardMaterial({ color: col('#3A3E44'), roughness: 0.35, metalness: 0.8 }))); const barrel = new THREE.Mesh(new THREE.BoxGeometry(0.7, 0.1, 0.1), new THREE.MeshStandardMaterial({ color: col('#111'), metalness: 0.9, roughness: 0.3 })); barrel.position.x = 0.55; head.add(barrel); const eye = new THREE.Mesh(new THREE.BoxGeometry(0.05, 0.08, 0.3), new THREE.MeshBasicMaterial({ color: col('#FF3040').multiplyScalar(4), toneMapped: false })); eye.position.set(0.36, 0.06, 0); head.add(eye); g.userData = { head, eye }; g.traverse((o) => { if (o.isMesh) o.castShadow = true; }); this.scene.add(g); this.sentryMeshes.push(g); } this.sentryMeshes.forEach((g, i) => { const s = list[i]; g.visible = !!s; if (!s) return; g.position.set(s[0], 0, s[1]); g.userData.head.rotation.y = -s[2]; g.userData.eye.material.color.set(TEAMS[s[3]].color).multiplyScalar(4); }); } syncCharges(list) { if (!this.chargeMeshes) this.chargeMeshes = []; while (this.chargeMeshes.length < list.length) { const m = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), new THREE.MeshBasicMaterial({ color: col('#FF4030').multiplyScalar(4), toneMapped: false })); this.scene.add(m); this.chargeMeshes.push(m); } this.chargeMeshes.forEach((m, i) => { const c = list[i]; m.visible = !!c; if (!c) return; const [x0, z0, x, z, t, fly, y0 = 1.2, y1 = 0] = c; const k = Math.min(1, t / fly); m.position.set(x0 + (x - x0) * k, y0 + (y1 + 0.16 - y0) * k + Math.sin(k * Math.PI) * 2.4, z0 + (z - z0) * k); m.material.color.set(Math.sin(t * 30) > 0 ? '#FF3020' : '#FFC0A0').multiplyScalar(4); }); } /** Pick the ground point under a screen position (CSS px). */ pick(px, py, w, h) { const ndc = new THREE.Vector2((px / w) * 2 - 1, -(py / h) * 2 + 1); const ray = new THREE.Raycaster(); ray.setFromCamera(ndc, this.cam); const o = ray.ray.origin, d = ray.ray.direction; const t = (1.2 - o.y) / d.y; return t > 0 ? { x: o.x + d.x * t, z: o.z + d.z * t } : null; } /** World point -> CSS px on the canvas. */ toScreen(x, y, z, w, h) { const v = new THREE.Vector3(x, y, z).project(this.cam); return { x: (v.x * 0.5 + 0.5) * w, y: (-v.y * 0.5 + 0.5) * h, on: v.z < 1 && Math.abs(v.x) < 1.05 && Math.abs(v.y) < 1.05 }; } stats() { const i = this.stage.r.info.render; return { calls: i.calls, tris: i.triangles, w: this.stage.w, h: this.stage.h, hdr: this.stage.hdr, tier: this.stage.tier }; } } /** * Fake ambient occlusion for instanced props (no extra pass): surfaces darken towards the ground they stand on and * along the edges of every face, so crates, cars and barriers sit in the street and read as solid cover. */ function aoPatch(mat) { mat.userData.aoDone = true; const prev = mat.onBeforeCompile; const prevKey = mat.customProgramCacheKey && mat.customProgramCacheKey !== THREE.Material.prototype.customProgramCacheKey ? mat.customProgramCacheKey.bind(mat) : null; mat.onBeforeCompile = (s, r) => { if (prev) prev.call(mat, s, r); s.vertexShader = s.vertexShader.replace('#include ', '#include \nvarying float vAoY; varying vec2 vAoUv;') .replace('#include ', '#include \n#ifdef USE_INSTANCING\nvAoY = (modelMatrix * instanceMatrix * vec4(transformed, 1.0)).y;\n#else\nvAoY = (modelMatrix * vec4(transformed, 1.0)).y;\n#endif\nvAoUv = uv;'); s.fragmentShader = s.fragmentShader.replace('#include ', '#include \nvarying float vAoY; varying vec2 vAoUv;') .replace('#include ', `#include { float ao = mix(0.42, 1.0, smoothstep(0.0, 1.15, vAoY)); vec2 eu = min(vAoUv, 1.0 - vAoUv); float edge = min(eu.x, eu.y); ao *= mix(0.72, 1.0, smoothstep(0.0, 0.06, edge)); diffuseColor.rgb *= ao; }`); }; mat.customProgramCacheKey = () => `${prevKey ? prevKey() : 'tb'}-ao`; // shared programs: one per feature set, not one per material return mat; } function S_glow(stage, c, k) { return new THREE.MeshBasicMaterial({ color: col(c).multiplyScalar(k), toneMapped: false }); } export { THEMES };