polish: improve atmosphere effects and fix fog opacity jump

- Fire: reworked colour ramp (dark red → orange → amber → warm yellow core),
  per-column height variation via colFireH for jagged silhouette, ember glow
  at base, taller overall height, fire/rain combined in screenshot
- Rain: more translucent (0.15–0.40 opacity), halved speed range, direction
  tilts 90°→70° as intensity rises
- Embers: smaller particles (0.8–2.2px), more numerous (50–150)
- Fog: fix opacity jump at ~3s — CSS keyframe animation was overriding the
  inline intensity-based opacity value; replaced with CSS transition via
  React-managed mounted state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Aaron Wood 2026-04-11 22:24:05 -04:00
parent 3059fbaedf
commit 1e881764e3
6 changed files with 72 additions and 50 deletions

View file

@ -4,19 +4,9 @@
z-index: 9998;
pointer-events: none;
overflow: hidden;
animation: fadeIn 3s ease-in;
filter: var(--fog-filter);
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.layer1,
.layer2,
.layer3 {

View file

@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import styles from "./FogOverlay.module.css";
interface FogOverlayProps {
@ -6,13 +7,23 @@ interface FogOverlayProps {
}
export default function FogOverlay({ active, intensity }: FogOverlayProps) {
// One-frame delay lets the browser paint opacity:0 first so the CSS transition fires.
// Without this the element mounts at full opacity with no fade-in.
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) { setVisible(false); return; }
const id = requestAnimationFrame(() => setVisible(true));
return () => cancelAnimationFrame(id);
}, [active]);
if (!active) return null;
// Map 0100 to 0.151.0 so fog is always at least faintly visible when active
const opacity = 0.15 + (intensity / 100) * 0.85;
const opacity = visible ? 0.15 + (intensity / 100) * 0.85 : 0;
return (
<div className={styles.overlay} style={{ opacity }}>
<div className={styles.overlay} style={{ opacity, transition: "opacity 3s ease-in" }}>
<div className={styles.layer1} />
<div className={styles.layer2} />
<div className={styles.layer3} />

View file

@ -43,14 +43,14 @@ const FRAG = /* glsl */ `
return v / 0.9375;
}
// Fire colour ramp: black → dark red → orange → yellow → pale yellow
// Fire colour ramp: black → deep red tips → orange → amber → warm yellow core
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);
vec3 c0 = vec3(0.00, 0.00, 0.00); // black / transparent
vec3 c1 = vec3(0.68, 0.05, 0.00); // deep red (cool tips)
vec3 c2 = vec3(1.00, 0.32, 0.00); // rich orange (mid flame)
vec3 c3 = vec3(1.00, 0.56, 0.02); // amber-orange (inner flame)
vec3 c4 = vec3(1.00, 0.84, 0.28); // warm yellow (dense core, not pure white)
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);
@ -58,19 +58,25 @@ const FRAG = /* glsl */ `
}
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);
// Base fire height (fraction of screen height) — taller overall
float fireH = 0.42 + uIntensity * 0.28; // 0.420.70
// Per-column height variation: slow wide FBM humps create distinct tall/short columns.
// Varying colFireH changes where yN=1 lands on screen, so the early discard below
// produces a naturally jagged silhouette rather than a flat top.
float heightVar = 0.52 * fbm(vec2(vUv.x * uAspect * 1.1 + t * 0.07, t * 0.05));
float colFireH = fireH * (1.0 + heightVar); // per-column effective height
// Normalised height inside this column's fire zone (0=ground, 1=column top)
float yN = vUv.y / colFireH;
// Discard pixels above the fire zone — jagged because colFireH varies per column
if (yN > 1.6) { gl_FragColor = vec4(0.0); return; }
// Noise coords: aspect-corrected X, upward-scrolling Y scaled to column height
vec2 p = vec2(vUv.x * uAspect * 1.6, vUv.y / colFireH * 1.4 - t);
// Layered FBM — coarse structure + fine flicker
float n = fbm(p * 1.8);
@ -78,12 +84,12 @@ const FRAG = /* glsl */ `
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));
// Fine ragged tips on top of the broad column silhouette
float crest = 0.16 * noise(vec2(vUv.x * uAspect * 4.0 + t * 0.22, 1.1));
float yMod = yN + crest;
// Core fire value
float fire = (1.0 - yMod) * 1.1 + n * 0.75 - 0.05;
// Core fire value — 0.88 lets noise breathe at ground level
float fire = (1.0 - yMod) * 0.88 + n * 0.92 - 0.05;
fire *= 1.0 + uIntensity * 0.35;
fire = clamp(fire, 0.0, 1.0);
@ -92,7 +98,10 @@ const FRAG = /* glsl */ `
fire *= 0.72 + edge * 0.28;
vec3 col = fireRamp(fire);
float alpha = fire * fire; // quadratic → softer halo falloff
// Deep red/ember tint near the base — coals and heat where flame meets ground
float emberBase = smoothstep(0.30, 0.0, yN);
col = mix(col, vec3(0.70, 0.08, 0.00), emberBase * 0.45);
float alpha = pow(fire, 1.6);
gl_FragColor = vec4(col * alpha, alpha);
}

View file

@ -1,25 +1,26 @@
import type { ISourceOptions } from "@tsparticles/engine";
/**
* Rain intensity 0100 count 200600, speed 1530
* Fast-falling blue-white circles. At this speed the motion itself
* reads as rain streaks no elongated shape needed.
* Rain intensity 0100 count 200600, speed 1522.5
* More translucent, half speed range, direction tilts from 90° (vertical)
* to 70° (wind-driven) as intensity rises. In tsparticles degrees: 90=down, 70=bottom-right lean.
*/
export function getRainConfig(intensity: number): ISourceOptions {
const count = Math.round(200 + (intensity / 100) * 400);
const speed = 15 + (intensity / 100) * 15;
const speed = 15 + (intensity / 100) * 7.5; // half the previous speed range
const direction = 90 - (intensity / 100) * 20; // 90° straight down → 70° angled
return {
fullScreen: { enable: true, zIndex: 9997 },
particles: {
number: { value: count, density: { enable: false } },
color: { value: "#aad4f5" },
shape: { type: "circle" },
opacity: { value: { min: 0.4, max: 0.75 } },
opacity: { value: { min: 0.15, max: 0.40 } },
size: { value: { min: 1, max: 2.5 } },
move: {
enable: true,
speed: speed,
direction: "bottom",
direction: direction as never,
straight: true,
outModes: { default: "out" },
},
@ -29,12 +30,11 @@ export function getRainConfig(intensity: number): ISourceOptions {
}
/**
* Embers intensity 0100 count 2080, speed 0.52
* Slow-drifting warm amber dots with flickering opacity.
* Clearly slower and dimmer than fire different mood entirely.
* Embers intensity 0100 count 50150, speed 0.52
* Smaller, more numerous slow-drifting sparks. Flickering opacity.
*/
export function getEmbersConfig(intensity: number): ISourceOptions {
const count = Math.round(20 + (intensity / 100) * 60);
const count = Math.round(50 + (intensity / 100) * 100);
const speed = 0.5 + (intensity / 100) * 1.5;
return {
fullScreen: { enable: true, zIndex: 9997 },
@ -46,7 +46,7 @@ export function getEmbersConfig(intensity: number): ISourceOptions {
value: { min: 0.15, max: 0.9 },
animation: { enable: true, speed: 0.6, sync: false, startValue: "random" },
},
size: { value: { min: 1.5, max: 4 } },
size: { value: { min: 0.8, max: 2.2 } },
move: {
enable: true,
speed: speed,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,018 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View file

@ -198,13 +198,25 @@ async function captureAtmosphere(page) {
await page.click('button[title="Atmosphere effects"]');
await page.waitForTimeout(300);
// Toggle Fog on — the toggle button has aria-label="Toggle Fog"
// Toggle Fog on
await page.click('button[aria-label="Toggle Fog"]');
await page.waitForTimeout(800); // wait for fog to render
// Screenshot captures fog effect over the campaign view
await page.waitForTimeout(400);
// Close the atmosphere panel after so it doesn't clutter other captures
await page.keyboard.press('Escape');
// Toggle Fire on and set intensity to 25%
// Effects order: fog(0), fire(1), rain(2), embers(3) — fire is 2nd slider
await page.click('button[aria-label="Toggle Fire"]');
await page.waitForTimeout(300);
await page.evaluate(() => {
const sliders = document.querySelectorAll('input[type="range"]');
const fireSlider = sliders[1];
fireSlider.value = '25';
fireSlider.dispatchEvent(new Event('input', { bubbles: true }));
fireSlider.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
});
await page.waitForTimeout(1200); // wait for both effects to render
// Close the atmosphere panel so it doesn't clutter the screenshot
await page.click('button[title="Atmosphere effects"]');
await page.waitForTimeout(200);
}