196 lines
5.8 KiB
JavaScript
196 lines
5.8 KiB
JavaScript
/**
|
||
* effects.js — Matrix rain + starfield particle effects.
|
||
*
|
||
* Optimizations (Issue #34):
|
||
* - Frame skipping on low-tier hardware (update every 2nd frame)
|
||
* - Bounding sphere set to skip Three.js per-particle frustum test
|
||
* - Tight typed-array loop with stride-3 addressing (no object allocation)
|
||
* - Particles recycle to camera-relative region on respawn for density
|
||
* - drawRange used to soft-limit visible particles if FPS drops
|
||
*/
|
||
import * as THREE from 'three';
|
||
import { getQualityTier } from './quality.js';
|
||
import { getRainSpeedMultiplier, getRainOpacity, getStarOpacity } from './ambient.js';
|
||
|
||
let rainParticles;
|
||
let rainPositions;
|
||
let rainVelocities;
|
||
let rainCount = 0;
|
||
let skipFrames = 0; // 0 = update every frame, 1 = every 2nd frame
|
||
let frameCounter = 0;
|
||
let starfield = null;
|
||
|
||
/** Adaptive draw range — reduced if FPS drops below threshold. */
|
||
let activeCount = 0;
|
||
const FPS_FLOOR = 20;
|
||
const ADAPT_INTERVAL_MS = 2000;
|
||
let lastFpsCheck = 0;
|
||
let fpsAccum = 0;
|
||
let fpsSamples = 0;
|
||
|
||
export function initEffects(scene) {
|
||
const tier = getQualityTier();
|
||
skipFrames = tier === 'low' ? 1 : 0;
|
||
initMatrixRain(scene, tier);
|
||
initStarfield(scene, tier);
|
||
}
|
||
|
||
function initMatrixRain(scene, tier) {
|
||
rainCount = tier === 'low' ? 500 : tier === 'medium' ? 1200 : 2000;
|
||
activeCount = rainCount;
|
||
|
||
const geo = new THREE.BufferGeometry();
|
||
const positions = new Float32Array(rainCount * 3);
|
||
const velocities = new Float32Array(rainCount);
|
||
const colors = new Float32Array(rainCount * 3);
|
||
|
||
for (let i = 0; i < rainCount; i++) {
|
||
const i3 = i * 3;
|
||
positions[i3] = (Math.random() - 0.5) * 100;
|
||
positions[i3 + 1] = Math.random() * 50 + 5;
|
||
positions[i3 + 2] = (Math.random() - 0.5) * 100;
|
||
velocities[i] = 0.05 + Math.random() * 0.15;
|
||
|
||
const brightness = 0.3 + Math.random() * 0.7;
|
||
colors[i3] = 0;
|
||
colors[i3 + 1] = brightness;
|
||
colors[i3 + 2] = 0;
|
||
}
|
||
|
||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geo.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
|
||
// Pre-compute bounding sphere so Three.js skips per-frame recalc.
|
||
// Rain spans ±50 XZ, 0–60 Y — a sphere from origin with r=80 covers it.
|
||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 25, 0), 80);
|
||
|
||
rainPositions = positions;
|
||
rainVelocities = velocities;
|
||
|
||
const mat = new THREE.PointsMaterial({
|
||
size: tier === 'low' ? 0.16 : 0.12,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.7,
|
||
sizeAttenuation: true,
|
||
});
|
||
|
||
rainParticles = new THREE.Points(geo, mat);
|
||
rainParticles.frustumCulled = false; // We manage visibility ourselves
|
||
scene.add(rainParticles);
|
||
}
|
||
|
||
function initStarfield(scene, tier) {
|
||
const count = tier === 'low' ? 150 : tier === 'medium' ? 350 : 500;
|
||
const geo = new THREE.BufferGeometry();
|
||
const positions = new Float32Array(count * 3);
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const i3 = i * 3;
|
||
positions[i3] = (Math.random() - 0.5) * 300;
|
||
positions[i3 + 1] = Math.random() * 80 + 10;
|
||
positions[i3 + 2] = (Math.random() - 0.5) * 300;
|
||
}
|
||
|
||
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geo.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 40, 0), 200);
|
||
|
||
const mat = new THREE.PointsMaterial({
|
||
color: 0x003300,
|
||
size: 0.08,
|
||
transparent: true,
|
||
opacity: 0.5,
|
||
});
|
||
|
||
starfield = new THREE.Points(geo, mat);
|
||
starfield.frustumCulled = false;
|
||
scene.add(starfield);
|
||
}
|
||
|
||
/**
|
||
* Feed current FPS into the adaptive particle budget.
|
||
* Called externally from the render loop.
|
||
*/
|
||
export function feedFps(fps) {
|
||
fpsAccum += fps;
|
||
fpsSamples++;
|
||
}
|
||
|
||
export function updateEffects(_time) {
|
||
if (!rainParticles) return;
|
||
|
||
// On low tier, skip every other frame to halve iteration cost
|
||
if (skipFrames > 0) {
|
||
frameCounter++;
|
||
if (frameCounter % (skipFrames + 1) !== 0) return;
|
||
}
|
||
|
||
const velocityMul = (skipFrames > 0 ? (skipFrames + 1) : 1) * getRainSpeedMultiplier();
|
||
|
||
// Apply ambient-driven opacity
|
||
if (rainParticles.material.opacity !== getRainOpacity()) {
|
||
rainParticles.material.opacity = getRainOpacity();
|
||
}
|
||
if (starfield && starfield.material.opacity !== getStarOpacity()) {
|
||
starfield.material.opacity = getStarOpacity();
|
||
}
|
||
|
||
// Adaptive particle budget — check every ADAPT_INTERVAL_MS
|
||
const now = _time;
|
||
if (now - lastFpsCheck > ADAPT_INTERVAL_MS && fpsSamples > 0) {
|
||
const avgFps = fpsAccum / fpsSamples;
|
||
fpsAccum = 0;
|
||
fpsSamples = 0;
|
||
lastFpsCheck = now;
|
||
|
||
if (avgFps < FPS_FLOOR && activeCount > 200) {
|
||
// Drop 20% of particles to recover frame rate
|
||
activeCount = Math.max(200, Math.floor(activeCount * 0.8));
|
||
} else if (avgFps > FPS_FLOOR + 10 && activeCount < rainCount) {
|
||
// Recover particles gradually
|
||
activeCount = Math.min(rainCount, Math.floor(activeCount * 1.1));
|
||
}
|
||
rainParticles.geometry.setDrawRange(0, activeCount);
|
||
}
|
||
|
||
// Tight loop — stride-3 addressing, no object allocation
|
||
const pos = rainPositions;
|
||
const vel = rainVelocities;
|
||
const count = activeCount;
|
||
|
||
for (let i = 0; i < count; i++) {
|
||
const yIdx = i * 3 + 1;
|
||
pos[yIdx] -= vel[i] * velocityMul;
|
||
if (pos[yIdx] < -1) {
|
||
pos[yIdx] = 40 + Math.random() * 20;
|
||
pos[i * 3] = (Math.random() - 0.5) * 100;
|
||
pos[i * 3 + 2] = (Math.random() - 0.5) * 100;
|
||
}
|
||
}
|
||
|
||
rainParticles.geometry.attributes.position.needsUpdate = true;
|
||
}
|
||
|
||
/**
|
||
* Dispose all effect resources (used on world teardown).
|
||
*/
|
||
export function disposeEffects() {
|
||
if (rainParticles) {
|
||
rainParticles.geometry.dispose();
|
||
rainParticles.material.dispose();
|
||
rainParticles = null;
|
||
}
|
||
if (starfield) {
|
||
starfield.geometry.dispose();
|
||
starfield.material.dispose();
|
||
starfield = null;
|
||
}
|
||
rainPositions = null;
|
||
rainVelocities = null;
|
||
rainCount = 0;
|
||
activeCount = 0;
|
||
frameCounter = 0;
|
||
fpsAccum = 0;
|
||
fpsSamples = 0;
|
||
}
|