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 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-10 22:17:36 -04:00
parent bcf118093b
commit e2ce57527f
6 changed files with 276 additions and 71 deletions

View file

@ -13,11 +13,13 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"socket.io-client": "^4.8.1" "socket.io-client": "^4.8.1",
"three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0" "vite": "^6.0.0"
@ -47,6 +49,12 @@
"three": "^0.143.0" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@ -354,6 +362,13 @@
"@babylonjs/core": "^5.22.0" "@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": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -1673,6 +1688,13 @@
"@tsparticles/engine": "3.9.1" "@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": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1753,6 +1775,36 @@
"@types/react": "^18.0.0" "@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": { "node_modules/@vitejs/plugin-react": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", "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" "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": { "node_modules/babylonjs-gltf2interface": {
"version": "5.57.1", "version": "5.57.1",
"resolved": "https://registry.npmjs.org/babylonjs-gltf2interface/-/babylonjs-gltf2interface-5.57.1.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -2083,6 +2149,13 @@
"yallist": "^3.0.2" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -2352,9 +2425,9 @@
} }
}, },
"node_modules/three": { "node_modules/three": {
"version": "0.143.0", "version": "0.183.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.143.0.tgz", "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
"integrity": "sha512-oKcAGYHhJ46TGEuHjodo2n6TY2R6lbvrkp+feKZxqsUL/WkH7GKKaeu6RHeyb2Xjfk2dPLRKLsOP0KM2VgT8Zg==", "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {

View file

@ -14,11 +14,13 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.1.1", "react-router-dom": "^7.1.1",
"socket.io-client": "^4.8.1" "socket.io-client": "^4.8.1",
"three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/three": "^0.183.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"vite": "^6.0.0" "vite": "^6.0.0"

View file

@ -1,9 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react"; import Particles, { initParticlesEngine } from "@tsparticles/react";
import { loadSlim } from "@tsparticles/slim"; import { loadSlim } from "@tsparticles/slim";
import type { AtmosphereState } from "../lib/atmosphereTypes"; 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 // Module-level singleton so the engine is only initialised once
let enginePromise: Promise<void> | null = null; let enginePromise: Promise<void> | null = null;
@ -16,18 +15,6 @@ function ensureEngine(): Promise<void> {
return enginePromise; return enginePromise;
} }
const overlayStyle: CSSProperties = {
position: "fixed",
inset: 0,
zIndex: 9997,
pointerEvents: "none",
};
const instanceStyle: CSSProperties = {
position: "absolute",
inset: 0,
};
interface ParticleOverlayProps { interface ParticleOverlayProps {
atmosphere: AtmosphereState; atmosphere: AtmosphereState;
} }
@ -41,26 +28,15 @@ export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) {
if (!ready) return null; if (!ready) return null;
const { fire, rain, embers } = atmosphere; const { rain, embers } = atmosphere;
const anyActive = fire.active || rain.active || embers.active;
if (!anyActive) return null;
return ( return (
<div style={overlayStyle}> <>
{fire.active && (
<Particles
key={`fire-${fire.intensity}`}
id="particles-fire"
options={getFireConfig(fire.intensity)}
style={instanceStyle}
/>
)}
{rain.active && ( {rain.active && (
<Particles <Particles
key={`rain-${rain.intensity}`} key={`rain-${rain.intensity}`}
id="particles-rain" id="particles-rain"
options={getRainConfig(rain.intensity)} options={getRainConfig(rain.intensity)}
style={instanceStyle}
/> />
)} )}
{embers.active && ( {embers.active && (
@ -68,9 +44,8 @@ export default function ParticleOverlay({ atmosphere }: ParticleOverlayProps) {
key={`embers-${embers.intensity}`} key={`embers-${embers.intensity}`}
id="particles-embers" id="particles-embers"
options={getEmbersConfig(embers.intensity)} options={getEmbersConfig(embers.intensity)}
style={instanceStyle}
/> />
)} )}
</div> </>
); );
} }

View file

@ -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; // 01
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.280.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; // 0100
}
export default function ThreeFireOverlay({ active, intensity }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(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 (
<canvas
ref={canvasRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
pointerEvents: "none",
zIndex: 9997,
}}
/>
);
}

View file

@ -1,38 +1,5 @@
import type { ISourceOptions } from "@tsparticles/engine"; import type { ISourceOptions } from "@tsparticles/engine";
/**
* Fire intensity 0100 count 60200, speed 410
* 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 0100 count 200600, speed 1530 * Rain intensity 0100 count 200600, speed 1530
* Fast-falling blue-white circles. At this speed the motion itself * 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 count = Math.round(200 + (intensity / 100) * 400);
const speed = 15 + (intensity / 100) * 15; const speed = 15 + (intensity / 100) * 15;
return { return {
fullScreen: { enable: false }, fullScreen: { enable: true, zIndex: 9997 },
background: { opacity: 0 },
particles: { particles: {
number: { value: count, density: { enable: false } }, number: { value: count, density: { enable: false } },
color: { value: "#aad4f5" }, color: { value: "#aad4f5" },
@ -58,6 +24,7 @@ export function getRainConfig(intensity: number): ISourceOptions {
outModes: { default: "out" }, 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 count = Math.round(20 + (intensity / 100) * 60);
const speed = 0.5 + (intensity / 100) * 1.5; const speed = 0.5 + (intensity / 100) * 1.5;
return { return {
fullScreen: { enable: false }, fullScreen: { enable: true, zIndex: 9997 },
background: { opacity: 0 },
particles: { particles: {
number: { value: count, density: { enable: false } }, number: { value: count, density: { enable: false } },
color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] }, color: { value: ["#ff8800", "#ffaa00", "#ffcc44", "#ff6600"] },
@ -90,5 +56,6 @@ export function getEmbersConfig(intensity: number): ISourceOptions {
outModes: { default: "out" }, outModes: { default: "out" },
}, },
}, },
interactivity: { events: { onClick: { enable: false }, onHover: { enable: false } } },
}; };
} }

View file

@ -22,6 +22,7 @@ import DiceTray from "../components/DiceTray";
import FogOverlay from "../components/FogOverlay"; import FogOverlay from "../components/FogOverlay";
import AtmospherePanel from "../components/AtmospherePanel"; import AtmospherePanel from "../components/AtmospherePanel";
import ParticleOverlay from "../components/ParticleOverlay"; import ParticleOverlay from "../components/ParticleOverlay";
import ThreeFireOverlay from "../components/ThreeFireOverlay";
import type { AtmosphereState } from "../lib/atmosphereTypes"; import type { AtmosphereState } from "../lib/atmosphereTypes";
import { defaultAtmosphere } from "../lib/atmosphereTypes"; import { defaultAtmosphere } from "../lib/atmosphereTypes";
import SelectDropdown from "../components/SelectDropdown"; import SelectDropdown from "../components/SelectDropdown";
@ -484,6 +485,7 @@ export default function CampaignView() {
<RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} /> <RollLog campaignId={campaignId} rolls={rolls} freshIds={freshIds} />
<DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} /> <DiceTray roll={diceRoll} onAnimationComplete={handleDiceComplete} />
<FogOverlay active={atmosphere.fog.active} /> <FogOverlay active={atmosphere.fog.active} />
<ThreeFireOverlay active={atmosphere.fire.active} intensity={atmosphere.fire.intensity} />
<ParticleOverlay atmosphere={atmosphere} /> <ParticleOverlay atmosphere={atmosphere} />
</div> </div>
); );