import { memo, useEffect, useMemo, useRef, type MutableRefObject } from "react"; import { useFrame } from "@react-three/fiber"; import { AdditiveBlending, BufferAttribute, BufferGeometry, ClampToEdgeWrapping, DoubleSide, Group, Matrix4, MeshBasicMaterial, MultiplyBlending, NormalBlending, Quaternion, RepeatWrapping, Texture, Vector3, } from "three"; import { sampleOriginalCombatMeshTrack } from "./originalCombatMeshRuntime"; import { updateWowBoneMatrices, wowModelFrame, type OriginalWowModelGraph, type WowRibbon } from "./originalWowModelRuntime"; const blend = (mode: number) => [3, 4, 7].includes(mode) ? AdditiveBlending : [5, 6].includes(mode) ? MultiplyBlending : NormalBlending; export const OriginalWowModel = memo(function OriginalWowModel({ graph, textures, active, playbackRef, loop, opacity = 1 }: { readonly graph: OriginalWowModelGraph; readonly textures: ReadonlyMap; readonly active: boolean; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean; readonly opacity?: number; }) { const root = useRef(null); const scratch = useMemo(() => ({ bones: graph.bones.map(() => new Matrix4()), position: new Vector3(), vertex: new Vector3(), normal: new Vector3(), normalSum: new Vector3(), camera: new Quaternion(), world: new Quaternion(), billboardAxis: new Quaternion().setFromAxisAngle(new Vector3(0, 1, 0), -Math.PI / 2), }), [graph]); const geometry = useMemo(() => { if (!graph.mesh) return null; const geometry = new BufferGeometry(); geometry.setAttribute("position", new BufferAttribute(new Float32Array(graph.mesh.positions), 3)); geometry.setAttribute("normal", new BufferAttribute(new Float32Array(graph.mesh.normals), 3)); geometry.setAttribute("uv", new BufferAttribute(new Float32Array(graph.mesh.uvs), 2)); geometry.setAttribute("uv1", new BufferAttribute(new Float32Array(graph.mesh.uvs2), 2)); geometry.setIndex([...graph.mesh.indices]); graph.mesh.layers.forEach((layer, index) => geometry.addGroup(layer.indexStart, layer.indexCount, index)); return geometry; }, [graph]); const materials = useMemo(() => graph.mesh?.layers.map((layer) => { const source = graph.materials[layer.materialIndex]; const maps = layer.textureIndices.map((index, slot) => { const texture = textures.get(index)!.clone(); texture.wrapS = graph.textures[index].flags & 1 ? RepeatWrapping : ClampToEdgeWrapping; texture.wrapT = graph.textures[index].flags & 2 ? RepeatWrapping : ClampToEdgeWrapping; texture.channel = Math.max(0, layer.textureCoordinateIndices[slot] ?? 0); texture.needsUpdate = true; return texture; }); const material = new MeshBasicMaterial({ map: maps[0], blending: blend(source.blendMode), transparent: source.blendMode !== 0, alphaTest: source.blendMode === 1 ? 0.5 : 0, depthWrite: !(source.flags & 16) && source.blendMode < 2, depthTest: !(source.flags & 8), side: DoubleSide, toneMapped: false, }); // The WotLK spell batches use one or two source textures. Preserve the // second color/alpha layer and its independent UV transform in the shader. if (maps[1]) { material.onBeforeCompile = (shader) => { shader.uniforms.wowSecondMap = { value: maps[1] }; shader.uniforms.wowSecondMatrix = { value: maps[1].matrix }; shader.vertexShader = shader.vertexShader.replace("#include ", "#include \nuniform mat3 wowSecondMatrix;\nvarying vec2 vWowSecondUv;" + (maps[1].channel === 1 ? "\n#ifndef USE_UV1\nattribute vec2 uv1;\n#endif" : "")); shader.vertexShader = shader.vertexShader.replace("#include ", `#include \nvWowSecondUv = (wowSecondMatrix * vec3(${maps[1].channel === 1 ? "uv1" : "uv"}, 1.0)).xy;`); shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \nuniform sampler2D wowSecondMap;\nvarying vec2 vWowSecondUv;"); shader.fragmentShader = shader.fragmentShader.replace("#include ", "#include \ndiffuseColor *= texture2D(wowSecondMap, vWowSecondUv);"); }; material.customProgramCacheKey = () => `wow-dual-${maps[1].channel}`; } return { material, maps }; }) ?? [], [graph, textures]); useEffect(() => () => { geometry?.dispose(); materials.forEach(({ material, maps }) => { material.dispose(); maps.forEach((texture) => texture.dispose()); }); }, [geometry, materials]); useFrame((state) => { if (!active) return; const frame = wowModelFrame(graph, playbackRef.current.elapsedMs, loop, playbackRef.current.decaying); root.current?.getWorldQuaternion(scratch.world); state.camera.getWorldQuaternion(scratch.camera).premultiply(scratch.world.invert()); // Authored billboard planes face source +X, whereas the camera faces +Z. scratch.camera.multiply(scratch.billboardAxis); updateWowBoneMatrices(graph, frame, scratch.bones, scratch.camera); if (!geometry || !graph.mesh) return; const mesh = graph.mesh; const positions = geometry.getAttribute("position") as BufferAttribute; const normals = geometry.getAttribute("normal") as BufferAttribute; for (let index = 0; index < mesh.positions.length / 3; index++) { scratch.position.set(0, 0, 0); scratch.normalSum.set(0, 0, 0); let total = 0; for (let slot = 0; slot < 4; slot++) { const weight = mesh.boneWeights[index * 4 + slot]; if (!weight) continue; const matrix = scratch.bones[mesh.boneIndices[index * 4 + slot]]; scratch.vertex.fromArray(mesh.positions, index * 3).applyMatrix4(matrix); scratch.normal.fromArray(mesh.normals, index * 3).transformDirection(matrix); scratch.position.addScaledVector(scratch.vertex, weight); scratch.normalSum.addScaledVector(scratch.normal, weight); total += weight; } if (!total) { scratch.position.fromArray(mesh.positions, index * 3); scratch.normalSum.fromArray(mesh.normals, index * 3); } positions.setXYZ(index, ...scratch.position.toArray()); normals.setXYZ(index, ...scratch.normalSum.normalize().toArray()); } positions.needsUpdate = true; normals.needsUpdate = true; mesh.layers.forEach((layer, index) => { const { material, maps } = materials[index]; const color = mesh.colors[layer.colorIndex]; const weight = mesh.textureWeights[layer.textureWeightIndex]; const rgb = color ? sampleOriginalCombatMeshTrack(graph, color.color, frame) : undefined; material.color.setRGB(...(rgb ?? [1, 1, 1])); material.opacity = Math.max(0, Math.min(1, (color ? sampleOriginalCombatMeshTrack(graph, color.alpha, frame) ?? 32767 : 32767) * (weight ? sampleOriginalCombatMeshTrack(graph, weight, frame) ?? 32767 : 32767) / (32767 * 32767))) * opacity; maps.forEach((texture, slot) => { const transform = graph.textureTransforms[layer.textureTransformIndices[slot]]; if (!transform) return; const t = sampleOriginalCombatMeshTrack(graph, transform.translation, frame) ?? [0, 0, 0]; const s = sampleOriginalCombatMeshTrack(graph, transform.scale, frame) ?? [1, 1, 1]; const q = sampleOriginalCombatMeshTrack(graph, transform.rotation, frame) ?? [0, 0, 0, 1]; texture.offset.set(t[0], t[1]); texture.repeat.set(s[0], s[1]); texture.center.set(0.5, 0.5); texture.rotation = 2 * Math.atan2(q[2], q[3]); texture.updateMatrix(); }); }); }); return {geometry && entry.material)} frustumCulled={false} />} {graph.ribbons.map((ribbon, index) => )} ; }); const OriginalWowRibbon = memo(function OriginalWowRibbon({ graph, ribbon, textures, active, bones, playbackRef, loop, opacity }: { readonly graph: OriginalWowModelGraph; readonly ribbon: WowRibbon; readonly textures: ReadonlyMap; readonly active: boolean; readonly bones: readonly Matrix4[]; readonly playbackRef: MutableRefObject<{ elapsedMs: number; decaying: boolean }>; readonly loop: boolean; readonly opacity: number; }) { const root = useRef(null); const capacity = Math.min(128, Math.max(4, Math.ceil(ribbon.edgesPerSecond * ribbon.edgeLifetime) + 2)); const geometry = useMemo(() => { const g = new BufferGeometry(); g.setAttribute("position", new BufferAttribute(new Float32Array(capacity * 6), 3)); g.setAttribute("uv", new BufferAttribute(new Float32Array(capacity * 4), 2)); g.setIndex(Array.from({ length: (capacity - 1) * 6 }, (_, i) => { const segment = Math.floor(i / 6) * 2; return segment + [0, 1, 2, 2, 1, 3][i % 6]; })); g.setDrawRange(0, 0); return g; }, [capacity]); const material = useMemo(() => new MeshBasicMaterial({ map: textures.get(ribbon.textureIndices[0]), transparent: true, depthWrite: false, side: DoubleSide, blending: blend(graph.materials[ribbon.materialIndices[0]]?.blendMode ?? 4), toneMapped: false, }), [graph, ribbon, textures]); const history = useMemo(() => ({ edges: [] as { time: number; above: Vector3; below: Vector3 }[], inverse: new Matrix4(), top: new Vector3(), bottom: new Vector3(), local: new Vector3(), last: -Infinity, }), []); useEffect(() => () => { geometry.dispose(); material.dispose(); }, [geometry, material]); useFrame(() => { if (!active || !root.current) return; const elapsed = playbackRef.current.elapsedMs; const frame = wowModelFrame(graph, elapsed, loop, playbackRef.current.decaying); const sample = (key: "alpha" | "heightAbove" | "heightBelow" | "textureSlot" | "visibility") => sampleOriginalCombatMeshTrack(graph, ribbon.tracks[key], frame); const lifetimeMs = Math.max(20, ribbon.edgeLifetime * 1000); if (elapsed < history.last) { history.edges.length = 0; history.last = -Infinity; } while (history.edges.length && elapsed - history.edges[0].time > lifetimeMs) history.edges.shift(); root.current.updateWorldMatrix(true, false); if (elapsed - history.last >= 1000 / Math.max(1, ribbon.edgesPerSecond) && (sample("visibility") ?? 1) > 0) { const bone = bones[ribbon.parentBone]; history.top.fromArray(ribbon.position); history.bottom.copy(history.top); history.top.z += sample("heightAbove") ?? 0.1; history.bottom.z -= sample("heightBelow") ?? 0.1; history.top.applyMatrix4(bone).applyMatrix4(root.current.matrixWorld); history.bottom.applyMatrix4(bone).applyMatrix4(root.current.matrixWorld); history.edges.push({ time: elapsed, above: history.top.clone(), below: history.bottom.clone() }); if (history.edges.length > capacity) history.edges.shift(); history.last = elapsed; } history.inverse.copy(root.current.matrixWorld).invert(); const positions = geometry.getAttribute("position") as BufferAttribute; const uv = geometry.getAttribute("uv") as BufferAttribute; const columns = Math.max(1, ribbon.textureColumns), rows = Math.max(1, ribbon.textureRows); const slot = Math.max(0, Math.floor(sample("textureSlot") ?? 0)) % (columns * rows); history.edges.forEach((edge, index) => { for (const side of [0, 1]) { history.local.copy(side ? edge.below : edge.above); history.local.y -= ribbon.gravity * ((elapsed - edge.time) / 1000) ** 2 * 0.5; history.local.applyMatrix4(history.inverse); positions.setXYZ(index * 2 + side, ...history.local.toArray()); uv.setXY(index * 2 + side, (slot % columns + index / Math.max(1, history.edges.length - 1)) / columns, (Math.floor(slot / columns) + side) / rows); } }); material.color.setRGB(...(sampleOriginalCombatMeshTrack(graph, ribbon.tracks.color, frame) ?? [1, 1, 1])); material.opacity = Math.max(0, Math.min(1, (sample("alpha") ?? 32767) / 32767)) * opacity; positions.needsUpdate = true; uv.needsUpdate = true; geometry.setDrawRange(0, Math.max(0, history.edges.length - 1) * 6); }); return ; });