Files
healer-man/src/scene/RunewakerParticleEffect.tsx
T
2026-09-24 16:42:43 -04:00

279 lines
13 KiB
TypeScript

import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react";
import { useFrame } from "@react-three/fiber";
import {
AddEquation, BufferAttribute, CustomBlending, DoubleSide, DynamicDrawUsage,
InstancedBufferAttribute, InstancedBufferGeometry, OneFactor, OneMinusSrcAlphaFactor,
ShaderMaterial, Vector2, ZeroFactor, type Texture,
} from "three";
import type { CombatPresentationPhase, CombatVfxQuality } from "../game/combatPresentation";
import {
runeEmissionsBetween, runeLayers, runeSample, type RuneParticleLayer, type RuneVector3,
type RunewakerParticleGraph,
} from "./runewakerParticleRuntime";
interface Particle {
born: number;
life: number;
origin: RuneVector3;
velocity: RuneVector3;
acceleration: RuneVector3;
scale: number;
orientation: number;
rotationSign: number;
}
const vertexShader = /* glsl */ `
attribute vec3 instancePosition;
attribute vec4 instanceColor;
attribute vec2 instanceSize;
attribute float instanceRotation;
attribute float instanceFrame;
varying vec2 vUv;
varying vec4 vColor;
varying float vFrame;
uniform vec2 quadOffset;
void main() {
vec4 viewPosition = modelViewMatrix * vec4(instancePosition, 1.0);
vec2 local = (position.xy + quadOffset) * instanceSize;
float s = sin(instanceRotation);
float c = cos(instanceRotation);
vec2 rotated = vec2(local.x * c - local.y * s, local.x * s + local.y * c);
viewPosition.xy += rotated * length(modelViewMatrix[0].xyz);
gl_Position = projectionMatrix * viewPosition;
vUv = uv;
vColor = instanceColor;
vFrame = instanceFrame;
}
`;
const fragmentShader = /* glsl */ `
uniform sampler2D sourceTexture;
uniform vec2 tileUv;
uniform float atlasColumns;
uniform float frameCount;
varying vec2 vUv;
varying vec4 vColor;
varying float vFrame;
void main() {
if (vColor.a <= 0.002) discard;
float frame = mod(floor(max(0.0, vFrame)), max(1.0, frameCount));
float column = mod(frame, atlasColumns);
float row = floor(frame / atlasColumns);
vec2 sourceUv = vec2(column * tileUv.x + vUv.x * tileUv.x,
1.0 - (row + 1.0) * tileUv.y + vUv.y * tileUv.y);
vec4 sampled = texture2D(sourceTexture, sourceUv);
float alpha = sampled.a * vColor.a;
if (alpha <= 0.002) discard;
gl_FragColor = vec4(sampled.rgb * vColor.rgb * alpha, alpha);
#include <colorspace_fragment>
}
`;
function random(seed: number): number {
let value = (seed + 0x6d2b79f5) | 0;
value = Math.imul(value ^ value >>> 15, value | 1);
value ^= value + Math.imul(value ^ value >>> 7, value | 61);
return ((value ^ value >>> 14) >>> 0) / 4_294_967_296;
}
function vectorAt(layer: RuneParticleLayer, track: number, time: number, fallback: RuneVector3): RuneVector3 {
return runeSample(layer.emitter.tracks[track] as { time: number; value: RuneVector3 }[], time, fallback);
}
function scalarAt(layer: RuneParticleLayer, track: number, time: number, fallback = 0): number {
return runeSample(layer.emitter.tracks[track] as { time: number; value: number }[], time, fallback);
}
function spawn(layer: RuneParticleLayer, time: number, seed: number): Particle {
const randomAt = (index: number) => random(seed + index * 1619);
const kind = Math.round(scalarAt(layer, 1, time));
const emitterPosition = vectorAt(layer, 2, time, [0, 0, 0]);
const extents = vectorAt(layer, 3, time, [0, 0, 0]);
const radius = scalarAt(layer, 4, time);
let offset: RuneVector3 = [0, 0, 0];
if (kind === 1) {
offset = [(randomAt(1) * 2 - 1) * extents[0],
(randomAt(2) * 2 - 1) * extents[1], (randomAt(3) * 2 - 1) * extents[2]];
} else if (kind === 4) {
const angle = randomAt(1) * Math.PI * 2;
const distance = randomAt(2) * radius;
const orientation = layer.emitter.emitterOrientation;
offset = orientation === 1 || orientation === 4
? [0, Math.sin(angle) * distance, Math.cos(angle) * distance]
: orientation === 2 || orientation === 5
? [Math.sin(angle) * distance, 0, Math.cos(angle) * distance]
: [Math.sin(angle) * distance, Math.cos(angle) * distance, 0];
}
const direction = vectorAt(layer, 10, time, [0, 1, 0]);
const deviation = Math.min(Math.PI, Math.abs(scalarAt(layer, 11, time)));
const spread = Math.tan(deviation * 0.5);
const dx = direction[0] + (randomAt(4) * 2 - 1) * spread;
const dy = direction[1] + (randomAt(5) * 2 - 1) * spread;
const dz = direction[2] + (randomAt(6) * 2 - 1) * spread;
const magnitude = Math.max(0.00001, Math.hypot(dx, dy, dz));
const speed = scalarAt(layer, 14, time) + randomAt(7) * (
scalarAt(layer, 15, time) - scalarAt(layer, 14, time));
const force0 = vectorAt(layer, 20, time, [0, 0, 0]);
const force1 = vectorAt(layer, 21, time, [0, 0, 0]);
const minLife = scalarAt(layer, 12, time, 0.5);
const maxLife = scalarAt(layer, 13, time, 0.5);
const minScale = scalarAt(layer, 16, time, 1);
const maxScale = scalarAt(layer, 17, time, 1);
const minOrientation = vectorAt(layer, 18, time, [0, 0, 0]);
const maxOrientation = vectorAt(layer, 19, time, [0, 0, 0]);
return {
born: time,
life: Math.max(0.02, minLife + randomAt(8) * (maxLife - minLife)),
origin: [layer.position[0] + emitterPosition[0] + offset[0],
layer.position[1] + emitterPosition[1] + offset[1],
-layer.position[2] - emitterPosition[2] - offset[2]],
velocity: [dx / magnitude * speed, dy / magnitude * speed, -dz / magnitude * speed],
acceleration: [force0[0] + force1[0], force0[1] + force1[1], -force0[2] - force1[2]],
scale: minScale + randomAt(9) * (maxScale - minScale),
orientation: (minOrientation[0] + randomAt(10) * (maxOrientation[0] - minOrientation[0])) * Math.PI / 180,
rotationSign: layer.sprite.randomRotation && randomAt(11) < 0.5 ? -1 : 1,
};
}
const RuneEmitter = memo(function RuneEmitter({
layer, texture, budget, active, eventId, timelineRef,
}: {
readonly layer: RuneParticleLayer;
readonly texture: Texture;
readonly budget: number;
readonly active: boolean;
readonly eventId: number;
readonly timelineRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const system = useRef({ particles: Array<Particle | null>(budget).fill(null), debt: 0, births: 0, lastTime: 0 });
const geometry = useMemo(() => {
const next = new InstancedBufferGeometry();
next.setAttribute("position", new BufferAttribute(new Float32Array([
-0.5, -0.5, 0, 0.5, -0.5, 0, 0.5, 0.5, 0, -0.5, 0.5, 0,
]), 3));
next.setAttribute("uv", new BufferAttribute(new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]), 2));
next.setIndex([0, 1, 2, 0, 2, 3]);
for (const [name, components] of [
["instancePosition", 3], ["instanceColor", 4], ["instanceSize", 2],
["instanceRotation", 1], ["instanceFrame", 1],
] as const) {
const attribute = new InstancedBufferAttribute(new Float32Array(budget * components), components);
attribute.setUsage(DynamicDrawUsage);
next.setAttribute(name, attribute);
}
next.instanceCount = budget;
return next;
}, [budget]);
const material = useMemo(() => {
const image = texture.image as { width?: number; height?: number } | undefined;
const tileWidth = Math.max(1, layer.sprite.tileSizePixels[0]);
const tileHeight = Math.max(1, layer.sprite.tileSizePixels[1]);
const width = Math.max(tileWidth, image?.width ?? tileWidth);
const height = Math.max(tileHeight, image?.height ?? tileHeight);
return new ShaderMaterial({
name: `runewaker-particle-${layer.sprite.anchor}`,
vertexShader, fragmentShader,
uniforms: {
sourceTexture: { value: texture },
tileUv: { value: new Vector2(tileWidth / width, tileHeight / height) },
atlasColumns: { value: Math.max(1, Math.floor(width / tileWidth)) },
frameCount: { value: Math.max(1, layer.sprite.frameCount) },
quadOffset: { value: new Vector2(layer.sprite.quadOffset[0], layer.sprite.quadOffset[1]) },
},
transparent: true,
depthWrite: false,
side: DoubleSide,
toneMapped: false,
blending: CustomBlending,
blendEquation: AddEquation,
blendSrc: layer.sprite.blendMode === 2 ? ZeroFactor : OneFactor,
blendDst: layer.sprite.blendMode === 1 ? OneFactor : OneMinusSrcAlphaFactor,
});
}, [layer, texture]);
useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]);
useFrame(() => {
if (!active || budget === 0) return;
const time = timelineRef.current.elapsedMs / 1_000;
const current = system.current;
if (time < current.lastTime) {
current.particles.fill(null); current.debt = 0; current.births = 0;
}
const previousTime = current.lastTime;
current.lastTime = time;
current.debt = Math.min(budget, current.debt + runeEmissionsBetween(
layer.emitter.tracks[9] as { time: number; value: number }[], previousTime, time,
));
let births = Math.min(budget, Math.floor(current.debt));
current.debt -= births;
for (let index = 0; index < budget; index++) {
const particle = current.particles[index];
if (particle && time - particle.born >= particle.life) current.particles[index] = null;
if (!current.particles[index] && births > 0) {
const seed = eventId * 1009 + current.births * 7919 + index * 97;
current.particles[index] = spawn(layer, time, seed);
current.births++; births--;
}
}
const positions = geometry.getAttribute("instancePosition") as InstancedBufferAttribute;
const colors = geometry.getAttribute("instanceColor") as InstancedBufferAttribute;
const sizes = geometry.getAttribute("instanceSize") as InstancedBufferAttribute;
const rotations = geometry.getAttribute("instanceRotation") as InstancedBufferAttribute;
const frames = geometry.getAttribute("instanceFrame") as InstancedBufferAttribute;
for (let index = 0; index < budget; index++) {
const particle = current.particles[index];
if (!particle) { colors.setXYZW(index, 0, 0, 0, 0); continue; }
const age = Math.max(0, time - particle.born);
const lifetime = Math.min(1, age / particle.life);
const color = runeSample(layer.sprite.tracks[0], lifetime, [1, 1, 1, 1]);
const alpha = runeSample(layer.sprite.tracks[1], lifetime, 1);
const scale = runeSample(layer.sprite.tracks[2], lifetime, 1) * particle.scale;
const rotationSpeed = runeSample(layer.sprite.tracks[3], lifetime, 0) * Math.PI / 180;
const ageSquared = age * age * 0.5;
positions.setXYZ(index,
particle.origin[0] + particle.velocity[0] * age + particle.acceleration[0] * ageSquared,
particle.origin[1] + particle.velocity[1] * age + particle.acceleration[1] * ageSquared,
particle.origin[2] + particle.velocity[2] * age + particle.acceleration[2] * ageSquared);
colors.setXYZW(index, color[0], color[1], color[2], Math.max(0, Math.min(1, alpha * color[3])));
sizes.setXY(index, Math.max(0.001, scale * layer.sprite.quadScale[0]),
Math.max(0.001, scale * layer.sprite.quadScale[1]));
rotations.setX(index, layer.sprite.orientToTravel
? Math.atan2(particle.velocity[0], particle.velocity[1])
: particle.orientation + age * rotationSpeed * particle.rotationSign);
frames.setX(index, Math.floor(age * Math.max(0, layer.sprite.frameRate)));
}
for (const attribute of [positions, colors, sizes, rotations, frames]) attribute.needsUpdate = true;
});
return <mesh name="original-runewaker-particle-layer" geometry={geometry} material={material} frustumCulled={false} />;
});
export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({
graph, textures, phase, quality, active, eventId, playbackRef,
}: {
readonly graph: RunewakerParticleGraph;
readonly textures: ReadonlyMap<string, Texture>;
readonly phase: CombatPresentationPhase;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly eventId: number;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const layers = useMemo(() => runeLayers(graph, phase), [graph, phase]);
const limit = quality === "high" ? 256 : 96;
return <group name="original-runewaker-particles" scale={0.1}>
{layers.map((layer, index) => {
const texture = textures.get(layer.sprite.textureAssetUrl);
if (!texture) return null;
const sourceMaximum = Number(layer.emitter.tracks[0][0]?.value ?? 0);
const budget = Math.max(1, Math.min(sourceMaximum, Math.floor(limit / Math.max(1, layers.length))));
return <RuneEmitter key={`${layer.sprite.anchor}:${index}`} layer={layer} texture={texture}
budget={budget} active={active} eventId={eventId + index * 271}
timelineRef={playbackRef} />;
})}
</group>;
});