From e2ce57527f5f332f41da54ff4d846316083a3f8d Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Fri, 10 Apr 2026 22:17:36 -0400 Subject: [PATCH] feat: replace particle fire with Three.js FBM shader overlay Switch fire effect from tsParticles to a full-screen Three.js WebGL shader using layered FBM noise for volumetric-style flames rising from the bottom of the screen. Also fix rain/embers canvas banding by switching them to tsParticles fullScreen mode. Co-Authored-By: Claude Sonnet 4.6 --- client/package-lock.json | 81 ++++++++- client/package.json | 4 +- client/src/components/ParticleOverlay.tsx | 33 +--- client/src/components/ThreeFireOverlay.tsx | 186 +++++++++++++++++++++ client/src/lib/particleConfigs.ts | 41 +---- client/src/pages/CampaignView.tsx | 2 + 6 files changed, 276 insertions(+), 71 deletions(-) create mode 100644 client/src/components/ThreeFireOverlay.tsx diff --git a/client/package-lock.json b/client/package-lock.json index 6375f88..dbb6ef4 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -13,11 +13,13 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "three": "^0.183.2" }, "devDependencies": { "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.0", "vite": "^6.0.0" @@ -47,6 +49,12 @@ "three": "^0.143.0" } }, + "node_modules/@3d-dice/dice-box-threejs/node_modules/three": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.143.0.tgz", + "integrity": "sha512-oKcAGYHhJ46TGEuHjodo2n6TY2R6lbvrkp+feKZxqsUL/WkH7GKKaeu6RHeyb2Xjfk2dPLRKLsOP0KM2VgT8Zg==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -354,6 +362,13 @@ "@babylonjs/core": "^5.22.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1673,6 +1688,13 @@ "@tsparticles/engine": "3.9.1" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1753,6 +1775,36 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1774,6 +1826,13 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/babylonjs-gltf2interface": { "version": "5.57.1", "resolved": "https://registry.npmjs.org/babylonjs-gltf2interface/-/babylonjs-gltf2interface-5.57.1.tgz", @@ -2004,6 +2063,13 @@ } } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2083,6 +2149,13 @@ "yallist": "^3.0.2" } }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2352,9 +2425,9 @@ } }, "node_modules/three": { - "version": "0.143.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.143.0.tgz", - "integrity": "sha512-oKcAGYHhJ46TGEuHjodo2n6TY2R6lbvrkp+feKZxqsUL/WkH7GKKaeu6RHeyb2Xjfk2dPLRKLsOP0KM2VgT8Zg==", + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT" }, "node_modules/tinyglobby": { diff --git a/client/package.json b/client/package.json index 40dda09..d502123 100644 --- a/client/package.json +++ b/client/package.json @@ -14,11 +14,13 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.1.1", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "three": "^0.183.2" }, "devDependencies": { "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", + "@types/three": "^0.183.1", "@vitejs/plugin-react": "^4.3.4", "typescript": "^5.7.0", "vite": "^6.0.0" diff --git a/client/src/components/ParticleOverlay.tsx b/client/src/components/ParticleOverlay.tsx index f8cfedc..ad6d70b 100644 --- a/client/src/components/ParticleOverlay.tsx +++ b/client/src/components/ParticleOverlay.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from "react"; -import type { CSSProperties } from "react"; import Particles, { initParticlesEngine } from "@tsparticles/react"; import { loadSlim } from "@tsparticles/slim"; import type { AtmosphereState } from "../lib/atmosphereTypes"; -import { getFireConfig, getRainConfig, getEmbersConfig } from "../lib/particleConfigs"; +import { getRainConfig, getEmbersConfig } from "../lib/particleConfigs"; // Module-level singleton so the engine is only initialised once let enginePromise: Promise | null = null; @@ -16,18 +15,6 @@ function ensureEngine(): Promise { return enginePromise; } -const overlayStyle: CSSProperties = { - position: "fixed", - inset: 0, - zIndex: 9997, - pointerEvents: "none", -}; - -const instanceStyle: CSSProperties = { - position: "absolute", - inset: 0, -}; - interface ParticleOverlayProps { atmosphere: AtmosphereState; } @@ -41,26 +28,15 @@ export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) { if (!ready) return null; - const { fire, rain, embers } = atmosphere; - const anyActive = fire.active || rain.active || embers.active; - if (!anyActive) return null; + const { rain, embers } = atmosphere; return ( -
- {fire.active && ( - - )} + <> {rain.active && ( )} {embers.active && ( @@ -68,9 +44,8 @@ export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) { key={`embers-${embers.intensity}`} id="particles-embers" options={getEmbersConfig(embers.intensity)} - style={instanceStyle} /> )} -
+ ); } diff --git a/client/src/components/ThreeFireOverlay.tsx b/client/src/components/ThreeFireOverlay.tsx new file mode 100644 index 0000000..9890866 --- /dev/null +++ b/client/src/components/ThreeFireOverlay.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef } from "react"; +import * as THREE from "three"; + +// Full-screen quad trick — bypasses the camera entirely +const VERT = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = vec4(position.xy, 0.0, 1.0); + } +`; + +// Gradient-noise FBM fire shader +// vUv.y = 0 at screen bottom, 1 at screen top (Three.js PlaneGeometry convention) +const FRAG = /* glsl */ ` + uniform float uTime; + uniform float uIntensity; // 0–1 + uniform float uAspect; + varying vec2 vUv; + + // Gradient noise ───────────────────────────────────────────────────── + vec2 hash2(vec2 p) { + p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3))); + return -1.0 + 2.0 * fract(sin(p) * 43758.5453); + } + float noise(vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix( + mix(dot(hash2(i), f), + dot(hash2(i+vec2(1,0)), f-vec2(1,0)), u.x), + mix(dot(hash2(i+vec2(0,1)), f-vec2(0,1)), + dot(hash2(i+vec2(1,1)), f-vec2(1,1)), u.x), + u.y); + } + float fbm(vec2 p) { + float v = 0.0; + v += 0.5000 * noise(p); p = p * 2.03 + vec2(0.31, 0.12); + v += 0.2500 * noise(p); p = p * 1.97 + vec2(0.24, 0.35); + v += 0.1250 * noise(p); p = p * 2.01 + vec2(0.17, 0.28); + v += 0.0625 * noise(p); + return v / 0.9375; + } + + // Fire colour ramp: black → dark red → orange → yellow → pale yellow + vec3 fireRamp(float t) { + t = clamp(t, 0.0, 1.0); + vec3 c0 = vec3(0.00, 0.00, 0.00); + vec3 c1 = vec3(0.65, 0.04, 0.00); + vec3 c2 = vec3(1.00, 0.28, 0.00); + vec3 c3 = vec3(1.00, 0.76, 0.05); + vec3 c4 = vec3(1.00, 1.00, 0.55); + if (t < 0.25) return mix(c0, c1, t * 4.0); + if (t < 0.50) return mix(c1, c2, (t - 0.25) * 4.0); + if (t < 0.75) return mix(c2, c3, (t - 0.50) * 4.0); + return mix(c3, c4, (t - 0.75) * 4.0); + } + + void main() { + // How tall the fire reaches (fraction of screen height) + float fireH = 0.28 + uIntensity * 0.24; // 0.28–0.52 + + // Normalised height inside fire zone (0=ground, 1=top of flames) + float yN = vUv.y / fireH; + + // Discard pixels comfortably above the fire zone + if (yN > 1.6) { gl_FragColor = vec4(0.0); return; } + + float t = uTime * 0.45; + + // Noise coords: aspect-corrected X, upward-scrolling Y + vec2 p = vec2(vUv.x * uAspect * 1.6, vUv.y / fireH * 1.4 - t); + + // Layered FBM — coarse structure + fine flicker + float n = fbm(p * 1.8); + n += 0.55 * fbm(p * 3.6 + vec2(t * 0.25, 0.0)); + n += 0.20 * fbm(p * 7.0 - vec2(0.0, t * 0.6)); + n /= 1.75; + + // Vary flame height horizontally for ragged top edge + float crest = 0.25 * noise(vec2(vUv.x * uAspect * 2.0 + t * 0.18, 0.3)); + float yMod = yN + crest; + + // Core fire value + float fire = (1.0 - yMod) * 1.1 + n * 0.75 - 0.05; + fire *= 1.0 + uIntensity * 0.35; + fire = clamp(fire, 0.0, 1.0); + + // Soft edge fade at left/right screen edges + float edge = smoothstep(0.0, 0.07, vUv.x) * smoothstep(1.0, 0.93, vUv.x); + fire *= 0.72 + edge * 0.28; + + vec3 col = fireRamp(fire); + float alpha = fire * fire; // quadratic → softer halo falloff + + gl_FragColor = vec4(col * alpha, alpha); + } +`; + +interface Props { + active: boolean; + intensity: number; // 0–100 +} + +export default function ThreeFireOverlay({ active, intensity }: Props) { + const canvasRef = useRef(null); + + useEffect(() => { + if (!active || !canvasRef.current) return; + + const canvas = canvasRef.current; + const w = window.innerWidth; + const h = window.innerHeight; + + const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: false }); + renderer.setClearColor(0x000000, 0); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(w, h, false); + + const scene = new THREE.Scene(); + // Camera irrelevant for full-screen quad, but required by renderer + const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); + + const uniforms = { + uTime: { value: 0 }, + uIntensity: { value: intensity / 100 }, + uAspect: { value: w / h }, + }; + + const material = new THREE.ShaderMaterial({ + uniforms, + vertexShader: VERT, + fragmentShader: FRAG, + transparent: true, + blending: THREE.AdditiveBlending, + depthTest: false, + depthWrite: false, + }); + + const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); + scene.add(mesh); + + let rafId: number; + const startTime = performance.now(); + + function animate() { + rafId = requestAnimationFrame(animate); + uniforms.uTime.value = (performance.now() - startTime) / 1000; + renderer.render(scene, camera); + } + animate(); + + function onResize() { + const nw = window.innerWidth; + const nh = window.innerHeight; + renderer.setSize(nw, nh, false); + uniforms.uAspect.value = nw / nh; + } + window.addEventListener("resize", onResize); + + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener("resize", onResize); + material.dispose(); + renderer.dispose(); + }; + }, [active, intensity]); + + if (!active) return null; + + return ( + + ); +} diff --git a/client/src/lib/particleConfigs.ts b/client/src/lib/particleConfigs.ts index c9ad284..fa85718 100644 --- a/client/src/lib/particleConfigs.ts +++ b/client/src/lib/particleConfigs.ts @@ -1,38 +1,5 @@ import type { ISourceOptions } from "@tsparticles/engine"; -/** - * Fire — intensity 0–100 → count 60–200, speed 4–10 - * Small-to-medium orange/red/yellow particles rising upward fast. - * outModes "out" respawns particles at the bottom when they exit the top. - * Noticeably faster and brighter than embers. - */ -export function getFireConfig(intensity: number): ISourceOptions { - const count = Math.round(60 + (intensity / 100) * 140); - const speed = 4 + (intensity / 100) * 6; - return { - fullScreen: { enable: false }, - background: { opacity: 0 }, - particles: { - number: { value: count, density: { enable: false } }, - color: { value: ["#ff2200", "#ff5500", "#ff8800", "#ffbb00", "#ffee00"] }, - shape: { type: "circle" }, - opacity: { - value: { min: 0.6, max: 1 }, - animation: { enable: true, speed: 3, sync: false, startValue: "random" }, - }, - size: { value: { min: 1, max: 5 } }, - move: { - enable: true, - speed: { min: speed * 0.6, max: speed }, - direction: "top", - random: true, - straight: false, - outModes: { default: "out" }, - }, - }, - }; -} - /** * Rain — intensity 0–100 → count 200–600, speed 15–30 * Fast-falling blue-white circles. At this speed the motion itself @@ -42,8 +9,7 @@ export function getRainConfig(intensity: number): ISourceOptions { const count = Math.round(200 + (intensity / 100) * 400); const speed = 15 + (intensity / 100) * 15; return { - fullScreen: { enable: false }, - background: { opacity: 0 }, + fullScreen: { enable: true, zIndex: 9997 }, particles: { number: { value: count, density: { enable: false } }, color: { value: "#aad4f5" }, @@ -58,6 +24,7 @@ export function getRainConfig(intensity: number): ISourceOptions { outModes: { default: "out" }, }, }, + interactivity: { events: { onClick: { enable: false }, onHover: { enable: false } } }, }; } @@ -70,8 +37,7 @@ export function getEmbersConfig(intensity: number): ISourceOptions { const count = Math.round(20 + (intensity / 100) * 60); const speed = 0.5 + (intensity / 100) * 1.5; return { - fullScreen: { enable: false }, - background: { opacity: 0 }, + fullScreen: { enable: true, zIndex: 9997 }, particles: { number: { value: count, density: { enable: false } }, color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] }, @@ -90,5 +56,6 @@ export function getEmbersConfig(intensity: number): ISourceOptions { outModes: { default: "out" }, }, }, + interactivity: { events: { onClick: { enable: false }, onHover: { enable: false } } }, }; } diff --git a/client/src/pages/CampaignView.tsx b/client/src/pages/CampaignView.tsx index 3add717..535b8c0 100644 --- a/client/src/pages/CampaignView.tsx +++ b/client/src/pages/CampaignView.tsx @@ -22,6 +22,7 @@ import DiceTray from "../components/DiceTray"; import FogOverlay from "../components/FogOverlay"; import AtmospherePanel from "../components/AtmospherePanel"; import ParticleOverlay from "../components/ParticleOverlay"; +import ThreeFireOverlay from "../components/ThreeFireOverlay"; import type { AtmosphereState } from "../lib/atmosphereTypes"; import { defaultAtmosphere } from "../lib/atmosphereTypes"; import SelectDropdown from "../components/SelectDropdown"; @@ -484,6 +485,7 @@ export default function CampaignView() { + );