From ddd0e049c68276ab2df530677d4ed1476986f86c Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:51:40 -0600 Subject: [PATCH] YZ correction across collision and glb --- packages/core/src/streams/sc2.ts | 7 + packages/core/src/streams/scg.ts | 15 +- .../src/core/blitzkit/sc2Components.ts | 503 ++++++++++++++++++ .../core/blitzkit/skinAnimations/skeleton.ts | 26 +- .../src/core/blitzkit/switcherStates.ts | 25 +- .../[id]/collision.glb.ts | 133 +---- .../[...chunk_models_tanks]/[id]/model.glb.ts | 276 +--------- 7 files changed, 579 insertions(+), 406 deletions(-) create mode 100644 packages/website/src/core/blitzkit/sc2Components.ts diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index 712fe37b2..ea455b6f2 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -137,6 +137,13 @@ interface RbAabbox { maximum: number[]; } +export type ComponentName = Component["comp.typename"]; + +export type ComponentOf = Extract< + Component, + { "comp.typename": Name } +>; + export type Component = | { "comp.typename": "TransformComponent"; diff --git a/packages/core/src/streams/scg.ts b/packages/core/src/streams/scg.ts index 00e5ed403..660845dff 100644 --- a/packages/core/src/streams/scg.ts +++ b/packages/core/src/streams/scg.ts @@ -16,13 +16,16 @@ interface PolygonGroupRaw { vertices: ArrayBuffer; } -type BlitzkitVertex = { attribute: VertexAttribute; value: number[] }[]; +export type Vertex = { attribute: VertexAttribute; value: number[] }[]; -interface BlitzkitPolygonGroup { - vertices: BlitzkitVertex[]; +export interface PolygonGroup { + vertices: Vertex[]; indices: number[]; } +/** polygon group id -> polygon group */ +export type ScgPolygonGroups = Map; + const vertexAttributesArray = Object.values(VertexAttribute).filter( (value) => typeof value === "number", ); @@ -63,7 +66,7 @@ export class ScgReadStream extends ScpgReadStream { scg() { const header = this.header(); const polygonGroupsRaw: PolygonGroupRaw[] = []; - const polygonGroups = new Map(); + const polygonGroups = new Map(); for (let index = 0; index < header.nodeCount; index++) { const polygonGroupRaw = this.ka() as PolygonGroupRaw; @@ -83,7 +86,7 @@ export class ScgReadStream extends ScpgReadStream { } const verticesStream = new ScpgReadStream(polygonGroupRaw.vertices); - const vertices: BlitzkitVertex[] = []; + const vertices: Vertex[] = []; const { format, stride } = ScgReadStream.parseVertexFormat( polygonGroupRaw.vertexFormat, ); @@ -110,7 +113,7 @@ export class ScgReadStream extends ScpgReadStream { ); }); - return polygonGroups; + return polygonGroups satisfies ScgPolygonGroups; } static parseVertexFormat(formatInt: number) { diff --git a/packages/website/src/core/blitzkit/sc2Components.ts b/packages/website/src/core/blitzkit/sc2Components.ts new file mode 100644 index 000000000..1d7e3477b --- /dev/null +++ b/packages/website/src/core/blitzkit/sc2Components.ts @@ -0,0 +1,503 @@ +import { + parseHierarchies, + VertexAttribute, + vertexAttributeVectorSizes, + type Component, + type ComponentName, + type ComponentOf, + type Hierarchy, + type PolygonGroup, + type SC2, + type Scg, +} from "@blitzkit/core"; +import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; +import { + Accessor, + Document, + Material, + Node, + type Buffer, + type Skin, +} from "@gltf-transform/core"; +import { sortBy, times } from "lodash-es"; +import type { Motion } from "./skinAnimations/motion"; +import { + bindSocket, + createMotionClip, + createSkeleton, + loadMotion, + skinPrimitive, + type Skeleton, +} from "./skinAnimations/skeleton"; +import type { Garage } from "./switcherStates"; + +/** + * Shared SC2 -> glTF component handling. Every exporter (visual model, armor + * collision, ...) goes through {@link Sc2ComponentHandler} so that coordinate + * conversion and component semantics can never drift between them. + */ + +export const vertexAttributeGLTFName: Partial> = + { + [VertexAttribute.VERTEX]: "POSITION", + [VertexAttribute.NORMAL]: "NORMAL", + [VertexAttribute.TEXCOORD0]: "TEXCOORD_0", + [VertexAttribute.TEXCOORD1]: "TEXCOORD_1", + [VertexAttribute.TEXCOORD2]: "TEXCOORD_2", + [VertexAttribute.TEXCOORD3]: "TEXCOORD_3", + }; + +export const vertexAttributeGltfVectorSizes = { + ...vertexAttributeVectorSizes, + [VertexAttribute.TANGENT]: 4, +} as const; + +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, + VertexAttribute.TANGENT, + VertexAttribute.BINORMAL, +]); + +/** Groups a polygon group's interleaved vertices by attribute. */ +export function collectVertexAttributes(polygonGroup: PolygonGroup) { + const attributes = new Map(); + + polygonGroup.vertices.forEach((vertex) => { + vertex.forEach(({ attribute, value }) => { + if (!attributes.has(attribute)) attributes.set(attribute, []); + + attributes.get(attribute)!.push(value); + }); + }); + + return attributes; +} + +/** Converts attributes to glTF accessors, correcting the SC2 -> glTF axes. */ +export function createAttributeAccessors( + document: Document, + buffer: Buffer, + attributes: Map, +) { + const accessors = new Map(); + + attributes.forEach((value, attribute) => { + const name = vertexAttributeGLTFName[attribute]; + + if (name === undefined || accessors.has(name)) return; + + 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; + } + } + + accessors.set( + name, + document + .createAccessor(name) + .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) + .setArray(array) + .setBuffer(buffer), + ); + }); + + return accessors; +} + +/** + * Motion files are needed before the hierarchy is walked because they are + * loaded asynchronously. + */ +export async function loadHierarchyMotions( + vfs: AbstractVFS, + path: string, + sc2: SC2, +) { + const motions = new Map(); + const loading: Promise[] = []; + + parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { + for (const component of components) { + if (component["comp.typename"] !== "MotionComponent") continue; + + loading.push( + loadMotion(vfs, path, component).then((motion) => { + motions.set(hierarchy, motion); + }), + ); + } + + return null; + }); + + await Promise.all(loading); + + return motions; +} + +export type Sc2RenderMode = + | { + kind: "visual"; + /** material key -> material (or an unresolved/shadow entry) */ + materials: Map; + /** parts keep lod-less (-1) batches */ + isPart: boolean; + } + | { + /** armor: untextured, one node per hard joint */ + kind: "collision"; + }; + +export interface Sc2ComponentHandlerOptions { + document: Document; + buffer: Buffer; + scg: Scg; + render: Sc2RenderMode; + /** enables skeletons, motion clips and joint bindings */ + skeleton?: { + motions: Map; + garage?: Garage; + }; + /** throw on component types this handler does not know about */ + strict?: boolean; +} + +interface HierarchyContext { + hierarchy: Hierarchy; + components: Component[]; + node: Node; +} + +/** Order in which components must be applied; the rest keep file order. */ +const COMPONENT_PRIORITY: Partial> = { + TransformComponent: 0, + StateSwitcherComponent: 0, + SkeletonComponent: 1, + SkeletonJointBindingComponent: 2, + RenderComponent: 3, +}; + +function componentPriority(component: Component) { + return COMPONENT_PRIORITY[component["comp.typename"]] ?? 10; +} + +type ComponentHandler = ( + component: ComponentOf, + context: HierarchyContext, +) => void; + +type AnyComponentHandler = ( + component: Component, + context: HierarchyContext, +) => void; + +/** + * A `null` handler marks a component type that carries nothing the exporters + * need. They are listed explicitly so the handler table is checked + * exhaustively against the Component union. + */ +type ComponentHandlers = { + [Name in ComponentName]: ComponentHandler | null; +}; + +export class Sc2ComponentHandler { + private readonly skeletons = new Map(); + private readonly jointBindings: [ + owner: Node, + entity: string, + joint: Node, + skin: Skin, + ][] = []; + + private readonly handlers: ComponentHandlers = { + TransformComponent: (component, { node }) => { + node.setTranslation(sc2ToGltfVector(component["tc.localTranslation"])); + node.setRotation(sc2ToGltfQuaternion(component["tc.localRotation"])); + node.setScale(component["tc.localScale"]); + }, + StateSwitcherComponent: (component, { hierarchy, node }) => { + const garage = this.options.skeleton?.garage; + + node.setExtras({ + // a part's scenarios can pick its state as it loads + state: garage + ? (garage.states.get(hierarchy) ?? null) + : component[`ssc.state${component["ssc.activeState"]}`], + }); + }, + SkeletonComponent: (component, { hierarchy, components, node }) => { + const { skeleton: options, document, buffer } = this.options; + + if (!options) return; + + const motion = options.motions.get(hierarchy); + const motionComponent = components.find( + (other): other is ComponentOf<"MotionComponent"> => + other["comp.typename"] === "MotionComponent", + ); + const created = createSkeleton( + document, + buffer, + node, + component, + motion, + ); + + this.skeletons.set(hierarchy, created); + + // only what loops in the garage is exported + if (motion && motionComponent && options.garage?.motions.has(hierarchy)) { + createMotionClip( + document, + buffer, + `motion${document.getRoot().listAnimations().length}`, + created.joints, + motion, + motionComponent, + ); + } + }, + SkeletonJointBindingComponent: (component, { hierarchy, node }) => { + const skeleton = this.skeletons.get(hierarchy); + + if (!skeleton) return; + + times(component.count, (index) => { + const binding = component.map[ + index.toString().padStart(4, "0") + ] as unknown as { entityUid: string; jointUid: string }; + const joint = skeleton.joints.get(binding.jointUid); + + if (!joint) throw new Error(`Missing joint ${binding.jointUid}`); + + this.jointBindings.push([ + node, + binding.entityUid, + joint, + skeleton.skin, + ]); + }); + }, + RenderComponent: (component, context) => { + if (this.options.render.kind === "visual") { + this.renderVisual(component, context, this.options.render); + } else { + this.renderCollision(component, context); + } + }, + + // lod distances are implied by each batch's lodIndex + LodComponent: null, + MotionComponent: null, // consumed by SkeletonComponent + AnimationComponent: null, + SlotComponent: null, + NewSlotComponent: null, + DecorItemComponent: null, + ScenarioComponent: null, + CustomPropertiesComponent: null, + ActionComponent: null, + ParticleEffectComponent: null, + TankElementComponent: null, + }; + + constructor(private readonly options: Sc2ComponentHandlerOptions) {} + + /** Applies every component of one hierarchy entry onto its node. */ + handle(hierarchy: Hierarchy, components: Component[], node: Node) { + const context: HierarchyContext = { hierarchy, components, node }; + + for (const component of sortBy(components, componentPriority)) { + this.dispatch(component, context); + } + } + + 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; + + if (handler === undefined) { + if (this.options.strict) { + throw new TypeError(`Unhandled component type: ${name}`); + } + + return; + } + + handler?.(component, context); + } + + /** Call once after the whole hierarchy has been handled. */ + resolveJointBindings() { + for (const [owner, entity, joint, skin] of this.jointBindings) { + // the game only binds the skeleton entity's own children + const socket = owner + .listChildren() + .find( + (child) => + child.getName() === entity && !skin.listJoints().includes(child), + ); + + if (!socket) throw new Error(`Missing socket ${entity}`); + + bindSocket(socket, joint, skin); + } + } + + private renderVisual( + component: RenderComponent, + { hierarchy, node }: HierarchyContext, + render: Extract, + ) { + const { document, buffer, scg } = this.options; + const renderObject = component["rc.renderObj"]; + + times(renderObject["ro.batchCount"], (batchIndex) => { + const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; + + // -1 means the batch has no lods and is always drawn + if (lodIndex !== 0 && !(render.isPart && lodIndex === -1)) return; + + const batchKey = batchIndex.toString().padStart(4, "0"); + const batch = renderObject["ro.batches"][batchKey]; + const material = render.materials.get(batch["rb.nmatname"]); + const polygonGroup = scg.get(batch["rb.datasource"]); + + // probably shadow material + if (!(material instanceof Material)) return; + + if (polygonGroup === undefined) { + throw new Error(`Missing polygon group ${batch["rb.datasource"]}`); + } + + const attributes = collectVertexAttributes(polygonGroup); + const primitive = document + .createPrimitive() + .setIndices( + document + .createAccessor() + .setType("SCALAR") + .setArray(new Uint16Array(polygonGroup.indices)) + .setBuffer(buffer), + ) + .setMaterial(material) + .setName(batchKey); + + createAttributeAccessors(document, buffer, attributes).forEach( + (accessor, name) => primitive.setAttribute(name, accessor), + ); + + const lodNode = document + .createNode(batchKey) + .setMesh(document.createMesh(batch["##name"]).addPrimitive(primitive)); + const skeleton = this.skeletons.get(hierarchy); + + if ( + skeleton && + skinPrimitive( + document, + buffer, + primitive, + attributes, + renderObject[`skinnedObject.batch${batchIndex}.targetsData`], + ) + ) { + lodNode.setSkin(skeleton.skin); + } + + node.addChild(lodNode); + }); + } + + private renderCollision( + component: RenderComponent, + { hierarchy, node }: HierarchyContext, + ) { + const { document, buffer, scg } = this.options; + const batch = component["rc.renderObj"]["ro.batches"]["0000"]; + const polygonGroup = scg.get(batch["rb.datasource"]); + + if (!polygonGroup) { + console.warn( + `Missing polygon group ${batch["rb.datasource"]} (${hierarchy.name}); skipping...`, + ); + + return; + } + + const hardJointIndices = new Set(); + const vertexHardJointIndices = new Map(); + + polygonGroup.vertices.forEach((vertex, index) => { + vertex.forEach(({ attribute, value }) => { + if (attribute !== VertexAttribute.HARD_JOINTINDEX) return; + + hardJointIndices.add(value[0]); + vertexHardJointIndices.set(index, value[0]); + }); + }); + + // geometry is shared; only the index list differs per hard joint + const accessors = createAttributeAccessors( + document, + buffer, + collectVertexAttributes(polygonGroup), + ); + + hardJointIndices.forEach((hardJointIndex) => { + const primitive = document + .createPrimitive() + .setIndices( + document + .createAccessor() + .setType("SCALAR") + .setArray( + new Uint16Array( + polygonGroup.indices.filter( + (index) => + vertexHardJointIndices.get(index) === hardJointIndex, + ), + ), + ) + .setBuffer(buffer), + ); + + accessors.forEach((accessor, name) => + primitive.setAttribute(name, accessor), + ); + + node.addChild( + document + .createNode(`${hierarchy.name}_armor_${hardJointIndex}`) + .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 c6a006414..e039e2379 100644 --- a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -1,4 +1,4 @@ -import { VertexAttribute, type Component } from "@blitzkit/core"; +import { VertexAttribute, type ComponentOf } from "@blitzkit/core"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import { MathUtils, @@ -17,14 +17,12 @@ import { dirname, normalize } from "path/posix"; import { Matrix4 } from "three"; import { MotionChannel, MotionReadStream, type Motion } from "./motion"; -type SkeletonComponent = Extract< - Component, - { "comp.typename": "SkeletonComponent" } ->; -type MotionComponent = Extract< - Component, - { "comp.typename": "MotionComponent" } ->; +export interface Skeleton { + skin: Skin; + /** joint uid -> joint node */ + joints: Map; +} + type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4"; const JOINT_ROOT = 0xffffff; @@ -50,7 +48,7 @@ function createAccessor( export async function loadMotion( vfs: AbstractVFS, path: string, - component: MotionComponent, + component: ComponentOf<"MotionComponent">, ) { const file = await vfs.file( normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`), @@ -68,9 +66,9 @@ export function createSkeleton( document: Document, buffer: Buffer, node: Node, - skeleton: SkeletonComponent, + skeleton: ComponentOf<"SkeletonComponent">, motion: Motion | undefined, -) { +): Skeleton { const joints = times( skeleton.jointsCount, (index) => skeleton.joints[index.toString().padStart(4, "0")], @@ -133,7 +131,7 @@ export function createSkeleton( joints: new Map( joints.map((joint, index) => [joint["joint.uid"], jointNodes[index]]), ), - }; + } satisfies Skeleton; } export function createMotionClip( @@ -142,7 +140,7 @@ export function createMotionClip( clip: string, joints: Map, motion: Motion, - component: MotionComponent, + component: ComponentOf<"MotionComponent">, ) { const animation = document.createAnimation(clip); const playbackRate = component["motion.playbackRate"]; diff --git a/packages/website/src/core/blitzkit/switcherStates.ts b/packages/website/src/core/blitzkit/switcherStates.ts index f645b9e4a..00ec2be2f 100644 --- a/packages/website/src/core/blitzkit/switcherStates.ts +++ b/packages/website/src/core/blitzkit/switcherStates.ts @@ -1,10 +1,14 @@ -import { - parseHierarchies, - type Hierarchy, - type Sc2ReadStream, -} from "@blitzkit/core"; +import { parseHierarchies, type Hierarchy, type SC2 } from "@blitzkit/core"; import { sortBy, times } from "lodash-es"; +/** the replayed end state of a part or tank */ +export interface Garage { + /** state switcher -> chosen state */ + states: Map; + /** entities left looping their motion */ + motions: Set; +} + interface Value { "any.type": string; "any.value": unknown; @@ -89,7 +93,7 @@ function resolveTarget(owner: Entity, entityName: string) { * through their motions, delays and alarms until they settle */ function replay( - sc2: ReturnType, + sc2: SC2, preset: string | undefined, attachments: Map, animate: boolean, @@ -506,7 +510,7 @@ function replay( .filter(isShown) .map((entity) => entity.hierarchy as Hierarchy), ), - }; + } satisfies Garage; } /** @@ -514,7 +518,7 @@ function replay( * as they are when the tank appears */ export function switcherStates( - sc2: ReturnType, + sc2: SC2, preset: string | undefined, attachments: Map, ) { @@ -532,9 +536,6 @@ export function switcherStates( * where a customization part ends up: each state switcher's state and the * motions left looping */ -export function partGarage( - sc2: ReturnType, - preset: string | undefined, -) { +export function partGarage(sc2: SC2, preset: string | undefined): Garage { return replay(sc2, preset, new Map(), true); } diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts index a6d89abc4..8efc04810 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts @@ -3,13 +3,12 @@ import { Sc2ReadStream, ScgReadStream, toUniqueId, - VertexAttribute, - vertexAttributeVectorSizes, type VehicleDefinitionList, } from "@blitzkit/core"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; -import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core"; +import { Document, Node, NodeIO, Scene } from "@gltf-transform/core"; import type { APIContext } from "astro"; +import { Sc2ComponentHandler } from "../../../core/blitzkit/sc2Components"; import { vfs } from "../../../core/blitzkit/vfs"; export { getStaticPaths } from "./_index"; @@ -54,110 +53,28 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { const document = new Document(); const scene = document.createScene(); const buffer = document.createBuffer(); + const handler = new Sc2ComponentHandler({ + document, + buffer, + scg, + render: { kind: "collision" }, + strict: true, + }); parseHierarchies( sc2["#hierarchy"], scene, (hierarchy, components, parent) => { - components.forEach((component) => { - switch (component["comp.typename"]) { - case "TransformComponent": - break; + /** + * Consumers (nameToArmorId and friends) expect a flat list of + * `_armor_` nodes under the scene, so the hierarchy node + * is only a scratch holder: its armor children are hoisted out of it + * and it is never attached. + */ + const holder = document.createNode(hierarchy.name); - case "RenderComponent": { - const batch = component["rc.renderObj"]["ro.batches"]["0000"]; - const polygonGroup = scg.get(batch["rb.datasource"]); - - if (!polygonGroup) { - console.warn( - `Missing polygon group ${batch["rb.datasource"]} (${hierarchy.name}); skipping...`, - ); - - break; - } - - const hardJointIndices = new Set(); - const vertexHardJointIndices = new Map(); - const attributes = new Map(); - - polygonGroup.vertices.forEach((vertex, index) => { - vertex.forEach(({ attribute, value }) => { - if (!attributes.has(attribute)) { - attributes.set(attribute, []); - } - - attributes.get(attribute)!.push(value); - - if (attribute === VertexAttribute.HARD_JOINTINDEX) { - hardJointIndices.add(value[0]); - vertexHardJointIndices.set(index, value[0]); - } - }); - }); - - if (attributes.has(VertexAttribute.TANGENT)) { - attributes.set( - VertexAttribute.TANGENT, - attributes - .get(VertexAttribute.TANGENT)! - .map((tangent) => [...tangent, 1]), - ); - } - - const accessors = new Map(); - - attributes.forEach((value, attribute) => { - const name = vertexAttributeGLTFName[attribute]; - if (!name || accessors.has(name)) return; - const vertexSize = vertexAttributeGltfVectorSizes[attribute]; - const accessor = document - .createAccessor(name) - .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) - .setArray(new Float32Array(value.flat())) - .setBuffer(buffer); - - accessors.set(name, accessor); - }); - - hardJointIndices.forEach((hardJointIndex) => { - const node = document.createNode( - `${hierarchy.name}_armor_${hardJointIndex}`, - ); - const mesh = document.createMesh(batch["##name"]); - const indicesAccessor = document - .createAccessor() - .setType("SCALAR") - .setArray( - new Uint16Array( - polygonGroup.indices.filter( - (index) => - vertexHardJointIndices.get(index) === hardJointIndex, - ), - ), - ) - .setBuffer(buffer); - const primitive = document - .createPrimitive() - .setIndices(indicesAccessor); - - accessors.forEach((accessor, name) => { - primitive.setAttribute(name, accessor); - }); - - mesh.addPrimitive(primitive); - node.setMesh(mesh); - parent.addChild(node); - }); - - break; - } - - default: - throw new TypeError( - `Unhandled component type: ${component["comp.typename"]}`, - ); - } - }); + handler.handle(hierarchy, components, holder); + holder.listChildren().forEach((armor) => scene.addChild(armor)); return parent; }, @@ -165,17 +82,3 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { return document; } - -export const vertexAttributeGLTFName: Partial> = - { - [VertexAttribute.VERTEX]: "POSITION", - [VertexAttribute.NORMAL]: "NORMAL", - [VertexAttribute.TEXCOORD0]: "TEXCOORD_0", - [VertexAttribute.TEXCOORD1]: "TEXCOORD_1", - [VertexAttribute.TEXCOORD2]: "TEXCOORD_2", - [VertexAttribute.TEXCOORD3]: "TEXCOORD_3", - }; -export const vertexAttributeGltfVectorSizes = { - ...vertexAttributeVectorSizes, - [VertexAttribute.TANGENT]: 4, -} as const; diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index a3ec84e39..1fa7ebc03 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -6,10 +6,9 @@ import { Sc2ReadStream, ScgReadStream, toUniqueId, - VertexAttribute, type ConfigArchive, - type Hierarchy, type TankParameters, + type SC2, type Textures, type VehicleDefinitionList, } from "@blitzkit/core"; @@ -20,7 +19,6 @@ import { Node, NodeIO, Scene, - type Skin, } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { dedup, prune } from "@gltf-transform/functions"; @@ -28,20 +26,12 @@ import type { APIContext } from "astro"; import { isEqual, times } from "lodash-es"; import { dirname } from "path"; import sharp from "sharp"; -import type { Motion } from "../../../core/blitzkit/skinAnimations/motion"; import { - bindSocket, - createMotionClip, - createSkeleton, - loadMotion, - skinPrimitive, -} from "../../../core/blitzkit/skinAnimations/skeleton"; + loadHierarchyMotions, + Sc2ComponentHandler, +} from "../../../core/blitzkit/sc2Components"; import { partGarage } from "../../../core/blitzkit/switcherStates"; import { vfs } from "../../../core/blitzkit/vfs"; -import { - vertexAttributeGLTFName, - vertexAttributeGltfVectorSizes, -} from "./collision.glb"; export { getStaticPaths } from "./_index"; @@ -110,35 +100,17 @@ export async function extractModel( const scene = document.createScene(); const buffer = document.createBuffer(); const materials = await extractMaterials(document, sc2, path); - const skeletons = new Map>(); - const jointBindings: [ - owner: Node, - entity: string, - joint: Node, - skin: Skin, - ][] = []; - const motions = new Map(); const garage = isPart ? partGarage(sc2, preset) : undefined; - - if (isPart) { - const loading: Promise[] = []; - - parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { - for (const component of components) { - if (component["comp.typename"] !== "MotionComponent") continue; - - loading.push( - loadMotion(vfs, path, component).then((motion) => { - motions.set(hierarchy, motion); - }), - ); - } - - return null; - }); - - await Promise.all(loading); - } + const handler = new Sc2ComponentHandler({ + document, + buffer, + scg, + render: { kind: "visual", materials, isPart }, + skeleton: isPart + ? { motions: await loadHierarchyMotions(vfs, path, sc2), garage } + : undefined, + strict: ERROR_ON_UNKNOWN_COMPONENT, + }); parseHierarchies( sc2["#hierarchy"], @@ -152,228 +124,14 @@ export async function extractModel( const node = document.createNode(hierarchy.name); - if (isPart) { - const skeleton = components.find( - (component) => component["comp.typename"] === "SkeletonComponent", - ); - const motionComponent = components.find( - (component) => component["comp.typename"] === "MotionComponent", - ); - const motion = motions.get(hierarchy); - - if (skeleton) { - const created = createSkeleton( - document, - buffer, - node, - skeleton, - motion, - ); - - skeletons.set(hierarchy, created); - - // only what loops in the garage is exported - if (motion && motionComponent && garage?.motions.has(hierarchy)) { - createMotionClip( - document, - buffer, - `motion${document.getRoot().listAnimations().length}`, - created.joints, - motion, - motionComponent, - ); - } - } - } - - components.forEach((component) => { - switch (component["comp.typename"]) { - case "LodComponent": - // found and used later by transform component - // TODO: lod component always shows up before transform component; cache it before parsing transform - break; - - case "TransformComponent": { - const t = component["tc.localTranslation"]; - const r = component["tc.localRotation"]; - - node.setTranslation([t[0], t[2], -t[1]]); - node.setRotation([r[0], r[2], -r[1], r[3]]); - node.setScale(component["tc.localScale"]); - - break; - } - - case "StateSwitcherComponent": - node.setExtras({ - // a part's scenarios can pick its state as it loads - state: garage - ? (garage.states.get(hierarchy) ?? null) - : component[`ssc.state${component["ssc.activeState"]}`], - }); - break; - - case "SkeletonJointBindingComponent": { - const skeleton = skeletons.get(hierarchy); - - if (!skeleton) break; - - times(component.count, (index) => { - const binding = component.map[ - index.toString().padStart(4, "0") - ] as unknown as { entityUid: string; jointUid: string }; - const joint = skeleton.joints.get(binding.jointUid); - - if (!joint) throw new Error(`Missing joint ${binding.jointUid}`); - - jointBindings.push([ - node, - binding.entityUid, - joint, - skeleton.skin, - ]); - }); - break; - } - - case "RenderComponent": { - const renderObject = component["rc.renderObj"]; - - times(renderObject["ro.batchCount"], (batchIndex): void => { - const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; - - // -1 means the batch has no lods and is always drawn - if (lodIndex !== 0 && !(isPart && lodIndex === -1)) return; - - const batchKey = batchIndex.toString().padStart(4, "0"); - const batch = renderObject["ro.batches"][batchKey]; - const material = materials.get(batch["rb.nmatname"]); - const polygonGroup = scg.get(batch["rb.datasource"]); - - if (!(material instanceof Material)) { - // probably shadow material - return; - } - if (polygonGroup === undefined) { - throw new Error( - `Missing polygon group ${batch["rb.datasource"]}`, - ); - } - - const lodNode = document.createNode(batchKey); - const indicesAccessor = document - .createAccessor() - .setType("SCALAR") - .setArray(new Uint16Array(polygonGroup.indices)) - .setBuffer(buffer); - const primitive = document - .createPrimitive() - .setIndices(indicesAccessor) - .setMaterial(material) - .setName(batchKey); - - const attributes = new Map(); - - polygonGroup.vertices.forEach((vertex) => { - vertex.forEach(({ attribute, value }) => { - if (!attributes.has(attribute)) { - attributes.set(attribute, []); - } - - attributes.get(attribute)!.push(value); - }); - }); - - attributes.forEach((value, attribute) => { - const name = vertexAttributeGLTFName[attribute]; - - if ( - name === undefined || - primitive.getAttribute(name) !== null - ) { - return; - } - - const vertexSize = vertexAttributeGltfVectorSizes[attribute]; - const processed = new Float32Array(value.flat()); - - if ( - attribute === VertexAttribute.VERTEX || - attribute === VertexAttribute.NORMAL || - attribute === VertexAttribute.TANGENT || - attribute === VertexAttribute.BINORMAL - ) { - for (let i = 0; i < processed.length; i += 3) { - const y = processed[i + 1]; - const z = processed[i + 2]; - - processed[i + 1] = z; - processed[i + 2] = -y; - } - } - - const attributeAccessor = document - .createAccessor(name) - .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) - .setArray(processed) - .setBuffer(buffer); - - primitive.setAttribute(name, attributeAccessor); - }); - - const skeleton = skeletons.get(hierarchy); - const mesh = document - .createMesh(batch["##name"]) - .addPrimitive(primitive); - lodNode.setMesh(mesh); - - if ( - skeleton && - skinPrimitive( - document, - buffer, - primitive, - attributes, - renderObject[`skinnedObject.batch${batchIndex}.targetsData`], - ) - ) { - lodNode.setSkin(skeleton.skin); - } - node.addChild(lodNode); - }); - - break; - } - - default: { - if (ERROR_ON_UNKNOWN_COMPONENT) { - throw new Error( - `Unhandled component type: ${component["comp.typename"]}`, - ); - } - } - } - }); - + handler.handle(hierarchy, components, node); parent.addChild(node); return node; }, ); - for (const [owner, entity, joint, skin] of jointBindings) { - // the game only binds the skeleton entity's own children - const socket = owner - .listChildren() - .find( - (child) => - child.getName() === entity && !skin.listJoints().includes(child), - ); - - if (!socket) throw new Error(`Missing socket ${entity}`); - - bindSocket(socket, joint, skin); - } + handler.resolveJointBindings(); scene.addChild(document.createNode("test")); @@ -384,7 +142,7 @@ export async function extractModel( export async function extractMaterials( document: Document, - sc2: ReturnType, + sc2: SC2, path: string, config?: string, ) {