diff --git a/packages/website/src/core/blitzkit/sc2Axes.ts b/packages/website/src/core/blitzkit/sc2Axes.ts new file mode 100644 index 000000000..582e0a734 --- /dev/null +++ b/packages/website/src/core/blitzkit/sc2Axes.ts @@ -0,0 +1,59 @@ +import type { mat4, vec3, vec4 } from "@gltf-transform/core"; +import { Matrix4 } from "three"; + +/** + * SC2 is Z-up; glTF is Y-up. The basis change C is a -90° rotation about X: + * (x, y, z) -> (x, z, -y). Points and directions map through C, rotations and + * matrices are conjugated (C M C⁻¹), and scales swap their Y and Z factors. + */ + +const C = new Matrix4().makeRotationX(-Math.PI / 2); +const C_INVERSE = new Matrix4().makeRotationX(Math.PI / 2); + +export function sc2ToGltfVector(vector: readonly number[]): vec3 { + return [vector[0], vector[2], -vector[1]]; +} + +/** (x, y, z, w) quaternion */ +export function sc2ToGltfQuaternion(q: readonly number[]): vec4 { + return [q[0], q[2], -q[1], q[3]]; +} + +export function sc2ToGltfScale(scale: readonly number[]): vec3 { + return [scale[0], scale[2], scale[1]]; +} + +/** anything indexable by number, e.g. a plain array or a typed array */ +type NumberArray = { [index: number]: number; length: number }; + +/** column-major 4x4 */ +export function sc2ToGltfMatrix(matrix: readonly number[]): mat4 { + return new Matrix4() + .multiplyMatrices(C, new Matrix4().fromArray(matrix)) + .multiply(C_INVERSE) + .toArray() as mat4; +} + +/** Converts a flat list of tightly packed vec3s in place. */ +export function sc2ToGltfVectors(array: T) { + for (let i = 0; i < array.length; i += 3) { + const y = array[i + 1]; + + array[i + 1] = array[i + 2]; + array[i + 2] = -y; + } + + return array; +} + +/** Converts a flat list of tightly packed quaternions in place. */ +export function sc2ToGltfQuaternions(array: T) { + for (let i = 0; i < array.length; i += 4) { + const y = array[i + 1]; + + array[i + 1] = array[i + 2]; + array[i + 2] = -y; + } + + return array; +} diff --git a/packages/website/src/core/blitzkit/sc2Components.ts b/packages/website/src/core/blitzkit/sc2Components.ts index 1d7e3477b..ea69c9e0f 100644 --- a/packages/website/src/core/blitzkit/sc2Components.ts +++ b/packages/website/src/core/blitzkit/sc2Components.ts @@ -8,7 +8,7 @@ import { type Hierarchy, type PolygonGroup, type SC2, - type Scg, + type ScgPolygonGroups, } from "@blitzkit/core"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import { @@ -20,6 +20,12 @@ import { type Skin, } from "@gltf-transform/core"; import { sortBy, times } from "lodash-es"; +import { + sc2ToGltfQuaternion, + sc2ToGltfScale, + sc2ToGltfVector, + sc2ToGltfVectors, +} from "./sc2Axes"; import type { Motion } from "./skinAnimations/motion"; import { bindSocket, @@ -54,22 +60,6 @@ export const vertexAttributeGltfVectorSizes = { type RenderComponent = ComponentOf<"RenderComponent">; -/** - * SC2 is Z-up (and right handed); glTF is Y-up. (x, y, z) -> (x, z, -y). - */ -export function sc2ToGltfVector( - vector: T, -): [number, number, number] { - return [vector[0], vector[2], -vector[1]]; -} - -/** Same conversion for a quaternion's (x, y, z, w). */ -export function sc2ToGltfQuaternion( - q: [number, number, number, number], -): [number, number, number, number] { - return [q[0], q[2], -q[1], q[3]]; -} - const SPATIAL_ATTRIBUTES = new Set([ VertexAttribute.VERTEX, VertexAttribute.NORMAL, @@ -108,15 +98,7 @@ export function createAttributeAccessors( const vertexSize = vertexAttributeGltfVectorSizes[attribute]; const array = new Float32Array(value.flat()); - if (SPATIAL_ATTRIBUTES.has(attribute)) { - for (let i = 0; i < array.length; i += 3) { - const y = array[i + 1]; - const z = array[i + 2]; - - array[i + 1] = z; - array[i + 2] = -y; - } - } + if (SPATIAL_ATTRIBUTES.has(attribute)) sc2ToGltfVectors(array); accessors.set( name, @@ -178,7 +160,7 @@ export type Sc2RenderMode = export interface Sc2ComponentHandlerOptions { document: Document; buffer: Buffer; - scg: Scg; + scg: ScgPolygonGroups; render: Sc2RenderMode; /** enables skeletons, motion clips and joint bindings */ skeleton?: { @@ -240,7 +222,7 @@ export class Sc2ComponentHandler { TransformComponent: (component, { node }) => { node.setTranslation(sc2ToGltfVector(component["tc.localTranslation"])); node.setRotation(sc2ToGltfQuaternion(component["tc.localRotation"])); - node.setScale(component["tc.localScale"]); + node.setScale(sc2ToGltfScale(component["tc.localScale"])); }, StateSwitcherComponent: (component, { hierarchy, node }) => { const garage = this.options.skeleton?.garage; @@ -262,13 +244,7 @@ export class Sc2ComponentHandler { (other): other is ComponentOf<"MotionComponent"> => other["comp.typename"] === "MotionComponent", ); - const created = createSkeleton( - document, - buffer, - node, - component, - motion, - ); + const created = createSkeleton(document, buffer, node, component, motion); this.skeletons.set(hierarchy, created); @@ -341,7 +317,8 @@ export class Sc2ComponentHandler { private dispatch(component: Component, context: HierarchyContext) { const name = component["comp.typename"]; // the table is exhaustive per name, but indexing by a union loses that - const handler = this.handlers[name] as AnyComponentHandler | null | undefined; + const handler = this.handlers[name] as + AnyComponentHandler | null | undefined; if (handler === undefined) { if (this.options.strict) { @@ -472,22 +449,19 @@ export class Sc2ComponentHandler { ); hardJointIndices.forEach((hardJointIndex) => { - const primitive = document - .createPrimitive() - .setIndices( - document - .createAccessor() - .setType("SCALAR") - .setArray( - new Uint16Array( - polygonGroup.indices.filter( - (index) => - vertexHardJointIndices.get(index) === hardJointIndex, - ), + const primitive = document.createPrimitive().setIndices( + document + .createAccessor() + .setType("SCALAR") + .setArray( + new Uint16Array( + polygonGroup.indices.filter( + (index) => vertexHardJointIndices.get(index) === hardJointIndex, ), - ) - .setBuffer(buffer), - ); + ), + ) + .setBuffer(buffer), + ); accessors.forEach((accessor, name) => primitive.setAttribute(name, accessor), @@ -496,7 +470,9 @@ export class Sc2ComponentHandler { node.addChild( document .createNode(`${hierarchy.name}_armor_${hardJointIndex}`) - .setMesh(document.createMesh(batch["##name"]).addPrimitive(primitive)), + .setMesh( + document.createMesh(batch["##name"]).addPrimitive(primitive), + ), ); }); } diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts index e039e2379..cf2cf6e50 100644 --- a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -15,6 +15,13 @@ import { import { isEqual, times } from "lodash-es"; import { dirname, normalize } from "path/posix"; import { Matrix4 } from "three"; +import { + sc2ToGltfMatrix, + sc2ToGltfQuaternion, + sc2ToGltfQuaternions, + sc2ToGltfVector, + sc2ToGltfVectors, +} from "../sc2Axes"; import { MotionChannel, MotionReadStream, type Motion } from "./motion"; export interface Skeleton { @@ -79,9 +86,15 @@ export function createSkeleton( const scale: vec3 = [1, 1, 1]; const track = motion?.tracks.get(joint["joint.uid"]); const trackScale = track?.get(MotionChannel.Scale)?.values[0]; + const trackTranslation = track + ?.get(MotionChannel.Translation) + ?.values.slice(0, 3); + const trackRotation = track + ?.get(MotionChannel.Rotation) + ?.values.slice(0, 4); MathUtils.decompose( - joint["joint.bindPose"].flat() as mat4, + sc2ToGltfMatrix(joint["joint.bindPose"].flat()), translation, rotation, scale, @@ -91,12 +104,10 @@ export function createSkeleton( return document .createNode(joint["joint.name"]) .setTranslation( - (track?.get(MotionChannel.Translation)?.values.slice(0, 3) as vec3) ?? - translation, + trackTranslation ? sc2ToGltfVector(trackTranslation) : translation, ) .setRotation( - (track?.get(MotionChannel.Rotation)?.values.slice(0, 4) as vec4) ?? - rotation, + trackRotation ? sc2ToGltfQuaternion(trackRotation) : rotation, ) .setScale( trackScale === undefined ? scale : [trackScale, trackScale, trackScale], @@ -119,7 +130,9 @@ export function createSkeleton( buffer, "MAT4", new Float32Array( - joints.flatMap((joint) => joint["joint.invBindPose"].flat()), + joints.flatMap((joint) => + sc2ToGltfMatrix(joint["joint.invBindPose"].flat()), + ), ), ), ); @@ -153,10 +166,13 @@ export function createMotionClip( for (const [type, { times, values }] of track) { const keyTimes = times.map((time) => time / playbackRate); + // scale keys are uniform, so they need no axis conversion const keyValues = type === MotionChannel.Scale ? values.flatMap((value) => [value, value, value]) - : [...values]; + : type === MotionChannel.Rotation + ? sc2ToGltfQuaternions([...values]) + : sc2ToGltfVectors([...values]); // the game loops over the clip's duration, which can outlast its keys if (keyTimes[keyTimes.length - 1] < duration) {