mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-10 11:16:10 +01:00
YZ correction across collision and glb
This commit is contained in:
7 files changed
+579
-406
No files matched your search
@@ -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,
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user