279 lines
13 KiB
TypeScript
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>;
|
|
});
|