Files
healer-man/src/scene/OriginalCombatEffect.tsx
T
2026-09-27 14:31:15 -04:00

959 lines
40 KiB
TypeScript

import { memo, useEffect, useMemo, useRef, useState, type MutableRefObject, type ReactNode } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import {
AdditiveBlending,
BufferAttribute,
BufferGeometry,
DoubleSide,
DynamicDrawUsage,
Group,
Matrix4,
MeshBasicMaterial,
MultiplyBlending,
NormalBlending,
ShaderMaterial,
SRGBColorSpace,
Texture,
TextureLoader,
Vector2,
Vector3,
} from "three";
import {
combatOriginalPhaseAssets,
isPenancePulseVisual,
preloadCombatEffectManifest,
type CombatEffectPhaseAsset,
type CombatPresentationEvent,
type CombatPresentationPhase,
type CombatVfxQuality,
} from "../game/combatPresentation";
import { fetchCombatMediaAsset } from "../game/combatMediaPack";
import { abilityById } from "../game/abilityCatalog";
import { PLAYER_AURA_ENTITY_ID, useCombatStore } from "../game/combatStore";
import { getMobPosition } from "../game/mobRuntimeRegistry";
import { getPartyRuntimePosition } from "../game/partyRuntimeRegistry";
import {
interpolateOriginalLifetime,
interpolateOriginalScalar,
originalCombatAnchorActorId,
originalAuraMatchesPresentationEvent,
originalEmitterParticleBudget,
selectCombatEffectRender,
parseOriginalCombatEffectGraph,
type OriginalCombatEffectAnimatedTrackName,
type OriginalCombatEffectGraph,
type OriginalCombatEffectParticle,
} from "./originalCombatEffectRuntime";
import {
originalCombatMeshLayerAppearance,
originalCombatMeshPlaybackFrame,
parseOriginalCombatMeshGraph,
type OriginalCombatMeshGraph,
} from "./originalCombatMeshRuntime";
import { combatEffectTargetVisualSize, updateCombatEffectAnchorPosition } from "./combatEffectAnchorRegistry";
import {
combatEffectVisualTuning, projectileTravelDurationMs, risingTideImpactPlacement, shouldUseGeneratedOriginalFallback,
} from "./combatEffectRuntime";
import { OriginalWowModel } from "./OriginalWowModel";
import { OriginalParticleGeometry } from "./OriginalParticleGeometry";
import { RunewakerParticleEffect } from "./RunewakerParticleEffect";
import {
parseRunewakerParticleGraph, runeDurationMs, type RunewakerParticleGraph,
} from "./runewakerParticleRuntime";
import {
parseOriginalWowModelGraph, updateWowBoneMatrices, wowModelFrame, wowModelDurationMs, wowModelTextureUrls, wowParticleChild,
type OriginalWowModelGraph,
} from "./originalWowModelRuntime";
type OriginalSourceEffectGraph = OriginalCombatEffectGraph | OriginalCombatMeshGraph
| OriginalWowModelGraph | RunewakerParticleGraph;
function sourceParticles(graph: OriginalSourceEffectGraph): OriginalCombatEffectGraph | null {
return graph.kind === "wow-m2-model" ? graph.particleGraph
: graph.kind === "wow-m2-particle-effect" ? graph : null;
}
interface LoadedOriginalGraph {
readonly asset: CombatEffectPhaseAsset;
readonly graph: OriginalSourceEffectGraph;
readonly textures: ReadonlyMap<number, Texture>;
readonly sourceTextures: ReadonlyMap<string, Texture>;
}
interface OriginalCombatEffectProps {
readonly event: CombatPresentationEvent;
readonly alternateOriginalPhase?: CombatPresentationPhase;
readonly quality: Exclude<CombatVfxQuality, "off">;
readonly active: boolean;
readonly onDone: () => void;
}
const textureLoader = new TextureLoader();
const graphRequestCache = new Map<string, Promise<OriginalSourceEffectGraph>>();
const textureRequestCache = new Map<string, Promise<Texture>>();
const ORIGINAL_EFFECT_SLOT_LIMIT = { low: 384, high: 1_024 } as const;
const vertexShader = /* glsl */ `
attribute vec3 particleColor;
attribute float particleAlpha;
attribute float particleSize;
attribute float particleFrame;
varying vec3 vParticleColor;
varying float vParticleAlpha;
varying float vParticleFrame;
uniform float viewportHeight;
void main() {
vParticleColor = particleColor;
vParticleAlpha = particleAlpha;
vParticleFrame = particleFrame;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
float inheritedScale = length(modelViewMatrix[0].xyz);
gl_Position = projectionMatrix * viewPosition;
gl_PointSize = clamp(
particleSize * inheritedScale * viewportHeight / max(1.0, -viewPosition.z),
1.0,
256.0
);
}
`;
const fragmentShader = /* glsl */ `
varying vec3 vParticleColor;
varying float vParticleAlpha;
varying float vParticleFrame;
uniform sampler2D sourceTexture;
uniform vec2 atlasSize;
uniform float alphaKey;
void main() {
float frameCount = max(1.0, atlasSize.x * atlasSize.y);
float frame = mod(max(0.0, floor(vParticleFrame)), frameCount);
float column = mod(frame, atlasSize.x);
float row = floor(frame / atlasSize.x);
vec2 atlasUv = (
vec2(column, atlasSize.y - row - 1.0) + gl_PointCoord
) / atlasSize;
vec4 source = texture2D(sourceTexture, atlasUv);
float resolvedAlpha = source.a * vParticleAlpha;
if ((alphaKey > 0.5 && source.a < 0.5) || resolvedAlpha <= 0.002) discard;
gl_FragColor = vec4(source.rgb * vParticleColor, resolvedAlpha);
#include <colorspace_fragment>
}
`;
function sourceRandom(seed: number): number {
const value = Math.sin(seed * 12.9898 + 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function maximumSampleValue(samples: readonly { readonly value: number }[]): number {
return samples.reduce((maximum, sample) => Math.max(maximum, sample.value), 0);
}
function graphDurationMs(graph: OriginalCombatEffectGraph): number {
return Math.max(1, graph.durationMs, ...graph.animations.map((animation) => animation.durationMs));
}
function graphTailMs(graph: OriginalCombatEffectGraph): number {
return graph.particles.reduce((maximum, particle) => (
Math.max(maximum, maximumSampleValue(particle.samples.lifespan) * 1_000)
), 0);
}
function placementHeight(model: string | undefined, event: CombatPresentationEvent): number {
const normalized = model?.toLowerCase() ?? "";
if (normalized.endsWith(".ros") && /(?:_|\/)down(?:\.|_|$)/.test(normalized)) return 0.08;
if (/(?:_|\/)head(?:\.|_|$)/.test(normalized)) return 1.72;
if (/(?:_|\/)chest(?:\.|_|$)/.test(normalized)) return 1.12;
if (/(?:_|\/)base(?:\.|_|$)/.test(normalized)) return 0.08;
if (event.phase === "cast-start" || /(?:_|\/)hand(?:\.|_|$)/.test(normalized)) return 1.15;
return event.delivery === "area" ? 0.08 : 1.05;
}
function liveActorPosition(actorId: string | undefined) {
if (!actorId) return null;
if (actorId === PLAYER_AURA_ENTITY_ID) return useCombatStore.getState().playerPosition;
return getMobPosition(actorId) ?? getPartyRuntimePosition(actorId);
}
function auraDurationMs(event: CombatPresentationEvent): number {
const ability = abilityById(event.abilityId);
if (!ability) return 6_000;
return ability.effects.reduce((maximum, effect) => {
if ("durationMs" in effect && typeof effect.durationMs === "number") {
return Math.max(maximum, effect.durationMs);
}
if ("aura" in effect && effect.aura && typeof effect.aura.durationMs === "number") {
return Math.max(maximum, effect.aura.durationMs);
}
return maximum;
}, 6_000);
}
function auraStillActive(event: CombatPresentationEvent): boolean {
const state = useCombatStore.getState();
const targetId = event.targetActorId ?? PLAYER_AURA_ENTITY_ID;
const legacyPlayerShield = targetId === PLAYER_AURA_ENTITY_ID
&& event.abilityId.includes("power-word-shield")
&& state.shield > 0;
const eventWithTarget = event.targetActorId === targetId
? event
: { ...event, targetActorId: targetId };
return legacyPlayerShield || state.auras.some((aura) => (
originalAuraMatchesPresentationEvent(aura, eventWithTarget)
)) || state.effects.some((effect) => (
effect.targetId === targetId && effect.sourceAbilityId === event.abilityId
));
}
function effectScale(asset: CombatEffectPhaseAsset): number {
return typeof asset.scale === "number" && Number.isFinite(asset.scale)
? Math.max(0.05, Math.min(5, asset.scale))
: 1;
}
function effectPlacementHeight(
asset: CombatEffectPhaseAsset,
graph: OriginalSourceEffectGraph,
event: CombatPresentationEvent,
): number {
const fallback = placementHeight(asset.model, event);
if (graph.kind !== "wow-m2-static-mesh") return fallback;
// The converted mesh remains in WoW's source scale. Lift its lowest Z-up
// vertex to the actor's feet after applying the packaged presentation scale.
return Math.max(fallback, -graph.bounds.minimum[2] * effectScale(asset));
}
async function loadGraph(url: string): Promise<OriginalSourceEffectGraph> {
const existing = graphRequestCache.get(url);
if (existing) return existing;
// Graph filenames are keyed to the immutable source M2, while the decoded
// browser schema can improve between releases. Revalidate the small JSON so
// an older cached decoder payload cannot fail the current strict parser.
const request = fetchCombatMediaAsset(url)
.then((response) => response.ok
? response.json()
: Promise.reject(new Error(`Original effect graph request failed (${response.status}): ${url}`)))
.then((value) => (
value?.kind === "runewaker-ros-particle-archive" ? parseRunewakerParticleGraph(value)
: value?.kind === "wow-m2-model" ? parseOriginalWowModelGraph(value)
: value && typeof value === "object" && (value as { kind?: unknown }).kind === "wow-m2-static-mesh"
? parseOriginalCombatMeshGraph(value)
: parseOriginalCombatEffectGraph(value)
));
graphRequestCache.set(url, request);
request.catch(() => graphRequestCache.delete(url));
return request;
}
async function loadTexture(url: string): Promise<Texture> {
const existing = textureRequestCache.get(url);
if (existing) return existing;
const request = fetchCombatMediaAsset(url).then(async (response) => {
if (!response.ok) throw new Error(`Original effect texture request failed (${response.status}): ${url}`);
const objectUrl = URL.createObjectURL(await response.blob());
try {
const texture = await textureLoader.loadAsync(objectUrl);
texture.colorSpace = SRGBColorSpace;
texture.name = `original-combat-effect:${url}`;
texture.needsUpdate = true;
return texture;
} finally {
URL.revokeObjectURL(objectUrl);
}
});
textureRequestCache.set(url, request);
request.catch(() => textureRequestCache.delete(url));
return request;
}
async function loadOriginalGraphAsset(asset: CombatEffectPhaseAsset): Promise<LoadedOriginalGraph> {
if (!asset.effectGraphUrl) throw new Error("Original effect phase has no graph URL.");
const graph = await loadGraph(asset.effectGraphUrl);
const sourceTextures = new Map<string, Texture>();
if (graph.kind === "wow-m2-model") {
await Promise.all(wowModelTextureUrls(graph).map(async (url) => sourceTextures.set(url, await loadTexture(url))));
} else if (graph.kind === "runewaker-ros-particle-archive") {
await Promise.all(graph.textures.map(async (texture) => (
sourceTextures.set(texture.url, await loadTexture(texture.url))
)));
}
const textureIndices = graph.kind === "wow-m2-model"
? graph.textures.flatMap((texture, index) => texture.url ? [index] : [])
: graph.kind === "wow-m2-static-mesh"
? [...new Set(graph.layers.map((layer) => layer.textureIndex))]
: graph.kind === "runewaker-ros-particle-archive"
? []
: [...new Set(graph.runtime.readyParticleIndices.map((particleIndex) => (
graph.particles[particleIndex]?.textureIndex
)).filter((textureIndex): textureIndex is number => textureIndex !== undefined))];
const loadedTextures = await Promise.all(textureIndices.map(async (textureIndex) => {
const url = graph.textures[textureIndex]?.url;
if (!url) throw new Error(`Original effect texture ${textureIndex} has no packaged URL.`);
return [textureIndex, await loadTexture(url)] as const;
}));
return { asset, graph, textures: new Map(loadedTextures), sourceTextures };
}
function materialForParticle(particle: OriginalCombatEffectParticle, texture: Texture): ShaderMaterial {
const additive = particle.blend === "no-alpha-add"
|| particle.blend === "add"
|| particle.blend === "blend-add";
const multiply = particle.blend === "mod" || particle.blend === "mod-2x";
return new ShaderMaterial({
name: `wow-m2-particle-${particle.id}`,
vertexShader,
fragmentShader,
uniforms: {
sourceTexture: { value: texture },
atlasSize: { value: new Vector2(particle.atlas.columns, particle.atlas.rows) },
viewportHeight: { value: 720 },
alphaKey: { value: particle.blend === "alpha-key" ? 1 : 0 },
},
blending: additive ? AdditiveBlending : multiply ? MultiplyBlending : NormalBlending,
transparent: particle.blend !== "opaque",
depthWrite: false,
toneMapped: false,
});
}
export const OriginalParticleEmitter = memo(function OriginalParticleEmitter({
graph,
particle,
texture,
budget,
active,
eventId,
emitterIndex,
loopTimeline,
modelGraph,
sourceTextures,
timelineRef,
opacity = 1,
}: {
readonly graph: OriginalCombatEffectGraph;
readonly particle: OriginalCombatEffectParticle;
readonly texture: Texture;
readonly budget: number;
readonly active: boolean;
readonly eventId: number;
readonly emitterIndex: number;
readonly loopTimeline: boolean;
readonly modelGraph?: OriginalWowModelGraph;
readonly sourceTextures?: ReadonlyMap<string, Texture>;
readonly timelineRef?: MutableRefObject<number>;
readonly opacity?: number;
}) {
const elapsedRef = useRef(0);
const sourcePose = useMemo(() => ({
bones: modelGraph?.bones.map(() => new Matrix4()) ?? [],
births: new Float64Array(budget).fill(Number.NaN),
birthMatrices: Array.from({ length: modelGraph ? budget : 0 }, () => new Matrix4()),
position: new Vector3(), velocity: new Vector3(), origin: new Vector3(),
}), [modelGraph, budget]);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const position = new BufferAttribute(new Float32Array(budget * 3), 3);
const color = new BufferAttribute(new Float32Array(budget * 3), 3);
const alpha = new BufferAttribute(new Float32Array(budget), 1);
const size = new BufferAttribute(new Float32Array(budget), 1);
const frame = new BufferAttribute(new Float32Array(budget), 1);
position.setUsage(DynamicDrawUsage);
color.setUsage(DynamicDrawUsage);
alpha.setUsage(DynamicDrawUsage);
size.setUsage(DynamicDrawUsage);
frame.setUsage(DynamicDrawUsage);
next.setAttribute("position", position);
next.setAttribute("particleColor", color);
next.setAttribute("particleAlpha", alpha);
next.setAttribute("particleSize", size);
next.setAttribute("particleFrame", frame);
next.setAttribute("particleAge", new BufferAttribute(new Float32Array(budget), 1));
next.setDrawRange(0, budget);
return next;
}, [budget]);
const material = useMemo(() => materialForParticle(particle, texture), [particle, texture]);
const durationMs = graphDurationMs(graph);
const peakRate = maximumSampleValue(particle.samples.emissionRate);
const peakLifespan = maximumSampleValue(particle.samples.lifespan);
useEffect(() => () => {
geometry.dispose();
material.dispose();
}, [geometry, material]);
useFrame((state, delta) => {
if (!active || budget === 0) return;
if (timelineRef) elapsedRef.current = timelineRef.current;
else elapsedRef.current += Math.min(0.1, Math.max(0, delta));
material.uniforms.viewportHeight.value = state.size.height * state.viewport.dpr;
const elapsed = elapsedRef.current;
const positions = geometry.getAttribute("position") as BufferAttribute;
const colors = geometry.getAttribute("particleColor") as BufferAttribute;
const alphas = geometry.getAttribute("particleAlpha") as BufferAttribute;
const sizes = geometry.getAttribute("particleSize") as BufferAttribute;
const frames = geometry.getAttribute("particleFrame") as BufferAttribute;
const ages = geometry.getAttribute("particleAge") as BufferAttribute;
const cycleLength = Math.max(0.02, peakLifespan);
const emitterSeed = eventId * 1_009
+ particle.id * 17
+ particle.textureIndex * 131
+ emitterIndex * 7_919;
for (let index = 0; index < budget; index += 1) {
const slotOffset = peakRate > 0 ? index / peakRate : Number.POSITIVE_INFINITY;
const completedCycles = Math.floor((elapsed - slotOffset) / cycleLength);
const birthTime = slotOffset + Math.max(0, completedCycles) * cycleLength;
const age = elapsed - birthTime;
ages.setX(index, Math.max(0, age));
const cycleSeed = emitterSeed + index * 97 + Math.max(0, completedCycles) * 271;
const rawTimelineMs = birthTime * 1_000;
const timelineMs = loopTimeline ? rawTimelineMs % durationMs : rawTimelineMs;
const scalarAt = (track: OriginalCombatEffectAnimatedTrackName) => (
interpolateOriginalScalar(
particle.samples[track],
timelineMs,
particle.interpolation[track],
) ?? 0
);
const emissionRate = rawTimelineMs <= durationMs || loopTimeline
? Math.max(0, scalarAt("emissionRate"))
: 0;
const lifespanBase = Math.max(0.02, scalarAt("lifespan"));
const lifespan = lifespanBase * Math.max(
0.05,
1 + (sourceRandom(cycleSeed + 1) * 2 - 1) * particle.lifespanVariation,
);
const accepted = elapsed >= slotOffset
&& age >= 0
&& age < lifespan
&& (particle.samples.enabled.length === 0 || scalarAt("enabled") > 0.5)
&& sourceRandom(cycleSeed + 2) <= Math.min(1, emissionRate / Math.max(peakRate, 0.001));
if (!accepted) {
alphas.setX(index, 0);
sizes.setX(index, 0);
continue;
}
const lifeProgress = Math.min(1, Math.max(0, age / lifespan));
const speedBase = scalarAt("emissionSpeed");
const speedVariation = scalarAt("speedVariation");
const speed = speedBase * Math.max(0, 1 + (sourceRandom(cycleSeed + 3) * 2 - 1) * speedVariation);
const horizontalRange = particle.samples.horizontalRange.length
? scalarAt("horizontalRange")
: Math.PI * 2;
const verticalRange = scalarAt("verticalRange");
const gravity = scalarAt("gravity");
const areaLength = scalarAt("emissionAreaLength");
const areaWidth = scalarAt("emissionAreaWidth");
const zSource = scalarAt("zSource");
const azimuth = (sourceRandom(cycleSeed + 4) - 0.5) * horizontalRange;
const elevation = (sourceRandom(cycleSeed + 5) - 0.5) * verticalRange;
const radial = Math.cos(elevation);
const velocityX = Math.sin(azimuth) * radial * speed;
const velocityY = Math.cos(azimuth) * radial * speed;
const velocityZ = Math.sin(elevation) * speed;
let localX = (sourceRandom(cycleSeed + 6) - 0.5) * areaWidth;
let localY = (sourceRandom(cycleSeed + 7) - 0.5) * areaLength;
let localZ = zSource;
if (particle.emitter === "sphere") {
const longitude = sourceRandom(cycleSeed + 6) * Math.PI * 2;
const sphereZ = sourceRandom(cycleSeed + 7) * 2 - 1;
const sphereRadius = Math.sqrt(Math.max(0, 1 - sphereZ * sphereZ));
localX = Math.cos(longitude) * sphereRadius * areaWidth;
localY = Math.sin(longitude) * sphereRadius * areaLength;
localZ += sphereZ * Math.max(areaWidth, areaLength);
}
// WoW M2 is Z-up; the game scene is Y-up.
sourcePose.position.set(particle.position[0] + localX, particle.position[1] + localY, particle.position[2] + localZ);
sourcePose.velocity.set(velocityX * age, velocityY * age, velocityZ * age);
if (modelGraph && sourcePose.bones[particle.parentBone]) {
if (sourcePose.births[index] !== rawTimelineMs) {
updateWowBoneMatrices(modelGraph, wowModelFrame(modelGraph, rawTimelineMs, loopTimeline), sourcePose.bones);
sourcePose.birthMatrices[index].copy(sourcePose.bones[particle.parentBone]);
sourcePose.births[index] = rawTimelineMs;
}
const bone = sourcePose.birthMatrices[index];
sourcePose.position.applyMatrix4(bone);
sourcePose.origin.set(0, 0, 0).applyMatrix4(bone);
sourcePose.velocity.applyMatrix4(bone).sub(sourcePose.origin);
}
sourcePose.position.add(sourcePose.velocity);
positions.setXYZ(index, sourcePose.position.x,
sourcePose.position.z - gravity * age * age * 0.5, -sourcePose.position.y);
const color = interpolateOriginalLifetime(particle.samples.color, lifeProgress) ?? [255, 255, 255];
const alpha = interpolateOriginalLifetime(particle.samples.alpha, lifeProgress) ?? 0;
const scale = interpolateOriginalLifetime(particle.samples.scale, lifeProgress) ?? [0, 0];
const scaleVariation = 1 + (sourceRandom(cycleSeed + 8) * 2 - 1)
* (particle.scaleVariation[0] + particle.scaleVariation[1]) * 0.5;
colors.setXYZ(index, color[0] / 255, color[1] / 255, color[2] / 255);
alphas.setX(index, Math.min(1, Math.max(0, alpha / 32_767)) * opacity);
sizes.setX(index, Math.max(0.002, (scale[0] + scale[1]) * 0.5 * Math.max(0.05, scaleVariation)));
const frameSamples = particle.samples.headCell.length
? particle.samples.headCell
: particle.samples.tailCell;
const sourceFrame = interpolateOriginalLifetime(frameSamples, lifeProgress) ?? 0;
frames.setX(index, Math.max(0, Math.floor(sourceFrame)));
}
positions.needsUpdate = true;
colors.needsUpdate = true;
alphas.needsUpdate = true;
sizes.needsUpdate = true;
frames.needsUpdate = true;
});
const geometryChild = wowParticleChild(modelGraph, particle.geometryModel);
const recursiveChild = wowParticleChild(modelGraph, particle.recursionModel);
return budget > 0 ? (
<group>
{geometryChild && sourceTextures ? <OriginalParticleGeometry graph={geometryChild} source={geometry}
textures={sourceTextures} budget={budget} active={active} /> : !recursiveChild
? <points geometry={geometry} material={material} frustumCulled={false} /> : null}
{recursiveChild && sourceTextures && <OriginalRecursiveParticles graph={recursiveChild} source={geometry}
textures={sourceTextures} budget={budget} active={active} eventId={eventId} />}
</group>
) : null;
});
const OriginalRecursiveParticles = memo(function OriginalRecursiveParticles({ graph, source, textures, budget, active, eventId }: {
readonly graph: OriginalWowModelGraph; readonly source: BufferGeometry; readonly textures: ReadonlyMap<string, Texture>;
readonly budget: number; readonly active: boolean; readonly eventId: number;
}) {
// Recursive effects share the parent's particle pool. Bound the secondary
// emitters so large dispels cannot multiply the entire effect budget.
const slots = useMemo(() => Array.from({ length: Math.min(8, budget) }, () => ({
group: new Group(), age: { current: 0 },
})), [budget]);
useFrame(() => {
if (!active) return;
const positions = source.getAttribute("position"), alpha = source.getAttribute("particleAlpha");
const sizes = source.getAttribute("particleSize"), ages = source.getAttribute("particleAge");
slots.forEach((slot, index) => {
slot.group.position.fromBufferAttribute(positions, index);
slot.group.visible = alpha.getX(index) > 0;
slot.group.scale.setScalar(sizes.getX(index)); slot.age.current = ages.getX(index);
});
});
const particles = graph.particleGraph;
return <group name="original-wow-recursive-particles">{slots.map((slot, slotIndex) =>
<primitive key={slotIndex} object={slot.group}>
{particles?.runtime.readyParticleIndices.map((index) => {
const particle = particles.particles[index];
const texture = textures.get(graph.textures[particle.textureIndex].url!);
return texture && <OriginalParticleEmitter key={index} graph={particles} modelGraph={graph} particle={particle}
texture={texture} budget={Math.min(16, originalEmitterParticleBudget(particle, "high"))} active={active}
eventId={eventId} emitterIndex={slotIndex * 32 + index} loopTimeline timelineRef={slot.age} sourceTextures={textures} />;
})}
</primitive>,
)}</group>;
});
function meshBlendMode(blendMode: number) {
if (blendMode === 3 || blendMode === 4 || blendMode === 7) return AdditiveBlending;
if (blendMode === 5 || blendMode === 6) return MultiplyBlending;
return NormalBlending;
}
const OriginalStaticMesh = memo(function OriginalStaticMesh({
graph,
textures,
active,
playbackRef,
}: {
readonly graph: OriginalCombatMeshGraph;
readonly textures: ReadonlyMap<number, Texture>;
readonly active: boolean;
readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>;
}) {
const camera = useThree((state) => state.camera);
const sourcePlaneRef = useRef<Group>(null);
const geometry = useMemo(() => {
const next = new BufferGeometry();
const sourcePositions = graph.geometry.positions;
const sourceNormals = graph.geometry.normals;
const xCenter = Array.from({ length: sourcePositions.length / 3 }, (_, index) => (
sourcePositions[index * 3]
)).reduce((total, value) => total + value, 0) / (sourcePositions.length / 3);
const positions = new Float32Array(sourcePositions.length);
const normals = new Float32Array(sourceNormals.length);
for (let index = 0; index < sourcePositions.length; index += 3) {
// Turn WoW's Z-up source plane into an upright local XY plane.
positions[index] = -sourcePositions[index + 1];
positions[index + 1] = sourcePositions[index + 2];
positions[index + 2] = sourcePositions[index] - xCenter;
normals[index] = -sourceNormals[index + 1];
normals[index + 1] = sourceNormals[index + 2];
normals[index + 2] = sourceNormals[index];
}
next.setAttribute("position", new BufferAttribute(positions, 3));
next.setAttribute("normal", new BufferAttribute(normals, 3));
next.setAttribute("uv", new BufferAttribute(new Float32Array(graph.geometry.uvs), 2));
next.setIndex([...graph.geometry.indices]);
next.clearGroups();
for (const [index, layer] of graph.layers.entries()) {
next.addGroup(layer.indexStart, layer.indexCount, index);
}
next.computeBoundingSphere();
return next;
}, [graph]);
const materials = useMemo(() => {
const initialFrame = originalCombatMeshPlaybackFrame(graph, 0);
return graph.layers.map((layer) => {
const appearance = originalCombatMeshLayerAppearance(graph, layer, initialFrame);
const material = new MeshBasicMaterial({
name: `wow-m2-static-layer-${layer.submeshIndex}`,
map: textures.get(layer.textureIndex) ?? null,
blending: meshBlendMode(layer.blendMode),
transparent: layer.blendMode !== 0,
opacity: appearance.opacity,
alphaTest: layer.blendMode === 1 ? 0.5 : 0,
depthWrite: layer.blendMode === 0,
side: DoubleSide,
toneMapped: false,
});
material.color.setRGB(...appearance.color);
return material;
});
}, [graph, textures]);
useEffect(() => () => {
geometry.dispose();
for (const material of materials) material.dispose();
}, [geometry, materials]);
useEffect(() => {
// WoW authored this mesh as a billboard. Resolve that orientation once at
// spawn so it stays readable without rotating whenever the camera moves.
sourcePlaneRef.current?.lookAt(camera.position);
}, [camera]);
useFrame(() => {
if (!active) return;
const playback = originalCombatMeshPlaybackFrame(
graph,
playbackRef.current.elapsedMs,
playbackRef.current.decaying,
);
for (const [index, layer] of graph.layers.entries()) {
const appearance = originalCombatMeshLayerAppearance(graph, layer, playback);
materials[index].color.setRGB(...appearance.color);
materials[index].opacity = appearance.opacity;
}
});
return (
<group ref={sourcePlaneRef}>
<mesh
name="original-combat-static-source-plane"
geometry={geometry}
material={materials}
renderOrder={20}
frustumCulled={false}
/>
<mesh
name="original-combat-static-cross-plane"
geometry={geometry}
material={materials}
rotation={[0, Math.PI / 2, 0]}
renderOrder={20}
frustumCulled={false}
/>
</group>
);
});
function LoadedOriginalCombatEffect({
event,
quality,
active,
loaded,
onDone,
}: OriginalCombatEffectProps & { readonly loaded: readonly LoadedOriginalGraph[] }) {
const elapsedRef = useRef(0);
const doneRef = useRef(false);
const groupRef = useRef<Group>(null);
const liveAnchorPosition = useMemo(() => new Vector3(), []);
const auraDecayStartedAtRef = useRef<number | null>(null);
const staticMeshPlaybackRef = useRef({ elapsedMs: 0, decaying: false });
const particleBudgets = useMemo(() => {
const emitters = loaded.flatMap((item, loadedIndex) => {
const graph = sourceParticles(item.graph);
return !graph
? []
: graph.runtime.readyParticleIndices.map((particleIndex) => ({
key: `${loadedIndex}:${particleIndex}`,
requested: originalEmitterParticleBudget(graph.particles[particleIndex], quality),
}));
});
const requestedTotal = emitters.reduce((total, emitter) => total + emitter.requested, 0);
const limit = ORIGINAL_EFFECT_SLOT_LIMIT[quality];
if (requestedTotal <= limit) return new Map(emitters.map((emitter) => [emitter.key, emitter.requested]));
const scale = limit / requestedTotal;
let remaining = limit;
return new Map(emitters.map((emitter) => {
const budget = Math.min(
remaining,
emitter.requested > 0 ? Math.max(1, Math.floor(emitter.requested * scale)) : 0,
);
remaining -= budget;
return [emitter.key, budget];
}));
}, [loaded, quality]);
const durationMs = useMemo(() => {
const longestGraph = loaded.reduce((maximum, item) => (
Math.max(
maximum,
item.graph.kind === "wow-m2-model" ? wowModelDurationMs(item.graph)
+ (item.graph.particleGraph ? graphTailMs(item.graph.particleGraph) : 0)
: item.graph.kind === "wow-m2-static-mesh"
? 6_000
: item.graph.kind === "runewaker-ros-particle-archive"
? runeDurationMs(item.graph, event.phase)
: graphDurationMs(item.graph) + graphTailMs(item.graph),
)
), 0);
if (event.phase === "cast-start") return Math.max(120, event.durationMs ?? longestGraph);
return Math.max(120, Math.min(6_000, longestGraph));
}, [event.durationMs, event.phase, loaded]);
const staticMeshDecayDurationMs = useMemo(() => loaded.reduce((maximum, item) => (
item.graph.kind === "wow-m2-static-mesh" || item.graph.kind === "wow-m2-model"
? Math.max(
maximum,
item.graph.animations.find((animation) => animation.id === 159)?.durationMs ?? 0,
)
: maximum
), 0), [loaded]);
const projectile = (event.phase === "release" || (event.phase === "tick" && isPenancePulseVisual(event)))
&& event.delivery === "projectile"
&& event.targetPosition !== undefined;
const projectileDurationMs = projectile ? projectileTravelDurationMs(event) : 0;
const anchor = event.phase === "cast-start" || projectile
? event.origin
: event.targetPosition ?? event.origin;
const loopTimeline = event.phase === "cast-start" || event.phase === "aura-start";
const tuning = combatEffectVisualTuning(event);
useFrame((_state, delta) => {
if (!active || doneRef.current) return;
elapsedRef.current += Math.min(100, Math.max(0, delta * 1_000));
if (auraDecayStartedAtRef.current === null) {
staticMeshPlaybackRef.current.elapsedMs = elapsedRef.current;
staticMeshPlaybackRef.current.decaying = false;
} else {
staticMeshPlaybackRef.current.elapsedMs = Math.max(
0,
elapsedRef.current - auraDecayStartedAtRef.current,
);
staticMeshPlaybackRef.current.decaying = true;
}
if (groupRef.current) {
if (projectile && event.targetPosition) {
const progress = Math.min(1, elapsedRef.current / projectileDurationMs);
groupRef.current.position.set(
event.origin[0] + (event.targetPosition[0] - event.origin[0]) * progress,
event.origin[1] + (event.targetPosition[1] - event.origin[1]) * progress,
event.origin[2] + (event.targetPosition[2] - event.origin[2]) * progress,
);
} else {
const actorId = originalCombatAnchorActorId(event);
if (!updateCombatEffectAnchorPosition(actorId, groupRef.current, liveAnchorPosition)) {
const live = liveActorPosition(actorId);
if (live) groupRef.current.position.set(live[0], live[1], live[2]);
}
}
}
const terminalDuration = projectile
? projectileDurationMs
: event.phase === "aura-start"
? auraDurationMs(event)
: durationMs;
if (auraDecayStartedAtRef.current !== null) {
if (staticMeshPlaybackRef.current.elapsedMs < staticMeshDecayDurationMs) return;
doneRef.current = true;
onDone();
return;
}
if (event.phase === "aura-start"
&& elapsedRef.current > 350
&& !auraStillActive(event)) {
if (staticMeshDecayDurationMs > 0) {
auraDecayStartedAtRef.current = elapsedRef.current;
staticMeshPlaybackRef.current.elapsedMs = 0;
staticMeshPlaybackRef.current.decaying = true;
return;
}
doneRef.current = true;
onDone();
return;
}
if (elapsedRef.current < terminalDuration) return;
doneRef.current = true;
onDone();
});
return (
<group ref={groupRef} position={anchor}>
{loaded.map((item, loadedIndex) => {
const graph = item.graph;
const risingTideTarget = event.phase === "impact" && event.sourceSpellId === 490256
&& item.asset.model?.toLowerCase() === "model/fx/skill/ice/act_riptide_t_freeze_hit.ros"
? risingTideImpactPlacement(event, combatEffectTargetVisualSize(event.targetActorId))
: null;
const scale = effectScale(item.asset) * (risingTideTarget?.scale ?? 1) * tuning.scale;
return (
<group
key={`${item.asset.effectGraphUrl}:${item.asset.model ?? "model"}`}
position={risingTideTarget
? [risingTideTarget.offsetX, risingTideTarget.height, risingTideTarget.offsetZ]
: [0, effectPlacementHeight(item.asset, graph, event), 0]}
scale={scale}
>
{graph.kind === "wow-m2-model" && <OriginalWowModel graph={graph} textures={item.textures}
active={active} playbackRef={staticMeshPlaybackRef} loop={loopTimeline} opacity={tuning.opacity} />}
{graph.kind === "runewaker-ros-particle-archive" ? (
<RunewakerParticleEffect graph={graph} textures={item.sourceTextures}
phase={event.phase} quality={quality} active={active} eventId={event.id}
playbackRef={staticMeshPlaybackRef} />
) : graph.kind === "wow-m2-static-mesh" ? (
<OriginalStaticMesh
graph={graph}
textures={item.textures}
active={active}
playbackRef={staticMeshPlaybackRef}
/>
) : sourceParticles(graph)?.runtime.readyParticleIndices.map((particleIndex) => {
const particles = sourceParticles(graph)!;
const particle = particles.particles[particleIndex];
const texture = item.textures.get(particle?.textureIndex ?? -1);
return particle && texture ? (
<OriginalParticleEmitter
key={`${particleIndex}:${particle.id}:${particle.textureIndex}`}
graph={particles}
modelGraph={graph.kind === "wow-m2-model" ? graph : undefined}
sourceTextures={item.sourceTextures}
particle={particle}
texture={texture}
budget={particleBudgets.get(`${loadedIndex}:${particleIndex}`) ?? 0}
active={active}
eventId={event.id}
emitterIndex={loadedIndex * 4_096 + particleIndex}
loopTimeline={loopTimeline}
opacity={tuning.opacity}
/>
) : null;
})}
</group>
);
})}
</group>
);
}
export const OriginalCombatEffect = memo(function OriginalCombatEffect({
event,
alternateOriginalPhase,
quality,
active,
onDone,
fallback,
}: OriginalCombatEffectProps & { readonly fallback: ReactNode }) {
const [loaded, setLoaded] = useState<readonly LoadedOriginalGraph[] | null>(null);
const [renderEvent, setRenderEvent] = useState(event);
const completedMissingOriginalRef = useRef(false);
useEffect(() => {
let cancelled = false;
setLoaded(null);
setRenderEvent(event);
completedMissingOriginalRef.current = false;
void preloadCombatEffectManifest().then(async () => {
const loadEvent = async (candidate: CombatPresentationEvent) => {
const assets = combatOriginalPhaseAssets(candidate);
const settled = await Promise.allSettled(assets.map(loadOriginalGraphAsset));
return {
event: candidate,
assets,
settled,
ready: settled.flatMap((result) => result.status === "fulfilled" ? [result.value] : []),
};
};
const primary = await loadEvent(event);
const attempts = [primary];
let selected = primary;
if (!cancelled && selected.ready.length === 0 && alternateOriginalPhase) {
const alternate = await loadEvent({
...event,
id: -event.id,
phase: alternateOriginalPhase,
});
attempts.push(alternate);
if (alternate.ready.length > 0) selected = alternate;
}
if (cancelled) return;
if (import.meta.env.DEV) {
for (const attempt of attempts) {
if (attempt.assets.length <= attempt.ready.length) continue;
const failures = attempt.settled.flatMap((result) => (
result.status === "rejected"
? [result.reason instanceof Error ? result.reason.message : String(result.reason)]
: []
));
console.warn(
`Original spell effect failed to load for ${attempt.event.name} (${attempt.event.phase}).`,
failures,
);
}
}
// Keep any usable original layers; only substitute when none can render.
setRenderEvent(selected.event);
setLoaded(selected.ready);
}).catch(() => {
if (!cancelled) {
setRenderEvent(event);
setLoaded([]);
}
});
return () => {
cancelled = true;
};
}, [alternateOriginalPhase, event]);
const selection = loaded === null
? null
: selectCombatEffectRender("original", loaded.map((item) => item.asset));
const hasOriginalInstantKit = event.phase === "release" && event.instantCast === true
&& (["cast-start", "impact", "aura-start"] as const).some((phase) => (
combatOriginalPhaseAssets({ ...event, phase }).length > 0
));
const resolvedFallback = shouldUseGeneratedOriginalFallback(event, hasOriginalInstantKit)
? fallback
: null;
useEffect(() => {
if (
loaded === null
|| selection?.kind === "original"
|| resolvedFallback !== null
|| completedMissingOriginalRef.current
) return;
completedMissingOriginalRef.current = true;
onDone();
}, [loaded, onDone, resolvedFallback, selection?.kind]);
if (loaded === null || selection === null) return null;
return selection.kind === "original" ? (
<LoadedOriginalCombatEffect
event={renderEvent}
quality={quality}
active={active}
loaded={loaded}
onDone={onDone}
/>
) : resolvedFallback;
}, (previous, next) => (
previous.event === next.event
&& previous.alternateOriginalPhase === next.alternateOriginalPhase
&& previous.quality === next.quality
&& previous.active === next.active
));