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:
parent
bcf118093b
commit
e2ce57527f
6 changed files with 276 additions and 71 deletions
81
client/package-lock.json
generated
81
client/package-lock.json
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
186
client/src/components/ThreeFireOverlay.tsx
Normal file
186
client/src/components/ThreeFireOverlay.tsx
Normal 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; // 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<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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,38 +1,5 @@
|
||||||
import type { ISourceOptions } from "@tsparticles/engine";
|
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
|
* Rain — intensity 0–100 → count 200–600, speed 15–30
|
||||||
* 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 } } },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue