YZ correction across collision and glb

This commit is contained in:
tresabhi committed 2026-11-09 21:51:40 -06:00
1 parent 1fce0ecb5a
commit ddd0e049c6
7 files changed
+579 -406

No files matched your search

+7
View File
@@ -137,6 +137,13 @@ interface RbAabbox {
maximum: number[];
}
export type ComponentName = Component["comp.typename"];
export type ComponentOf<Name extends ComponentName> = Extract<
Component,
{ "comp.typename": Name }
>;
export type Component =
| {
"comp.typename": "TransformComponent";
+9 -6
View File
@@ -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<bigint, PolygonGroup>;
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<bigint, BlitzkitPolygonGroup>();
const polygonGroups = new Map<bigint, PolygonGroup>();
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) {
@@ -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<Record<VertexAttribute, string>> =
{
[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<T extends [number, number, number]>(
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>([
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<VertexAttribute, number[][]>();
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<VertexAttribute, number[][]>,
) {
const accessors = new Map<string, Accessor>();
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<Hierarchy, Motion>();
const loading: Promise<void>[] = [];
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<bigint, Material | bigint | undefined>;
/** 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<Hierarchy, Motion>;
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<Record<ComponentName, number>> = {
TransformComponent: 0,
StateSwitcherComponent: 0,
SkeletonComponent: 1,
SkeletonJointBindingComponent: 2,
RenderComponent: 3,
};
function componentPriority(component: Component) {
return COMPONENT_PRIORITY[component["comp.typename"]] ?? 10;
}
type ComponentHandler<Name extends ComponentName> = (
component: ComponentOf<Name>,
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<Name> | null;
};
export class Sc2ComponentHandler {
private readonly skeletons = new Map<Hierarchy, Skeleton>();
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<Sc2RenderMode, { kind: "visual" }>,
) {
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<number>();
const vertexHardJointIndices = new Map<number, number>();
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)),
);
});
}
}
@@ -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<string, Node>;
}
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<string, Node>,
motion: Motion,
component: MotionComponent,
component: ComponentOf<"MotionComponent">,
) {
const animation = document.createAnimation(clip);
const playbackRate = component["motion.playbackRate"];
@@ -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<Hierarchy, string | null>;
/** entities left looping their motion */
motions: Set<Hierarchy>;
}
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<Sc2ReadStream["sc2"]>,
sc2: SC2,
preset: string | undefined,
attachments: Map<string, string[]>,
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<Sc2ReadStream["sc2"]>,
sc2: SC2,
preset: string | undefined,
attachments: Map<string, string[]>,
) {
@@ -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<Sc2ReadStream["sc2"]>,
preset: string | undefined,
) {
export function partGarage(sc2: SC2, preset: string | undefined): Garage {
return replay(sc2, preset, new Map(), true);
}
@@ -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<Scene | Node>(
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
* `<hierarchy>_armor_<n>` 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<number>();
const vertexHardJointIndices = new Map<number, number>();
const attributes = new Map<VertexAttribute, number[][]>();
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<string, Accessor>();
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<Record<VertexAttribute, string>> =
{
[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;
@@ -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<Hierarchy, ReturnType<typeof createSkeleton>>();
const jointBindings: [
owner: Node,
entity: string,
joint: Node,
skin: Skin,
][] = [];
const motions = new Map<Hierarchy, Motion>();
const garage = isPart ? partGarage(sc2, preset) : undefined;
if (isPart) {
const loading: Promise<void>[] = [];
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<Scene | Node>(
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<VertexAttribute, number[][]>();
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<Sc2ReadStream["sc2"]>,
sc2: SC2,
path: string,
config?: string,
) {