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 } `; 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(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 ; }); export const RunewakerParticleEffect = memo(function RunewakerParticleEffect({ graph, textures, phase, quality, active, eventId, playbackRef, }: { readonly graph: RunewakerParticleGraph; readonly textures: ReadonlyMap; readonly phase: CombatPresentationPhase; readonly quality: Exclude; 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 {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 ; })} ; });