diff --git a/scripts/buildAssets.ts b/scripts/buildAssets.ts index e0c53cb95..a82d3cda8 100644 --- a/scripts/buildAssets.ts +++ b/scripts/buildAssets.ts @@ -6,7 +6,7 @@ import { argv } from 'process'; import sharp from 'sharp'; import { TankType } from '../src/components/Tanks'; import { NATION_IDS } from '../src/constants/nations'; -import { SCPGStream, VertexAttribute } from '../src/core/blitz/SCPGStream'; +import { extractModel } from '../src/core/blitz/extractModel'; import { readBase64DVPL } from '../src/core/blitz/readBase64DVPL'; import { readDVPLFile } from '../src/core/blitz/readDVPLFile'; import { readStringDVPL } from '../src/core/blitz/readStringDVPL'; @@ -25,6 +25,7 @@ import { Tier, TurretDefinition, } from '../src/core/blitzkrieg/tankDefinitions'; +import { VertexAttribute } from '../src/core/streams/scpg'; const vertexAttributeGLTFName: Partial> = { [VertexAttribute.VERTEX]: 'POSITION', @@ -526,7 +527,7 @@ if (allTargets || targets?.includes('tankModels')) { const parameters = await readYAMLDVPL( `${DATA}/${DOI.tankParameters}/${nation}/${tankIndex}.yaml.dvpl`, ); - const model = await SCPGStream.extractModel( + const model = await extractModel( DATA, parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''), ); diff --git a/src/core/blitz/SCPGStream.ts b/src/core/blitz/SCPGStream.ts deleted file mode 100644 index f872e5d5a..000000000 --- a/src/core/blitz/SCPGStream.ts +++ /dev/null @@ -1,830 +0,0 @@ -import { Document, Material, Node, Scene } from '@gltf-transform/core'; -import { writeFileSync } from 'fs'; -import { range, times } from 'lodash'; -import { dirname } from 'path'; -import sharp from 'sharp'; -import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three'; -import { Hierarchy, SC2, Textures } from '../../types/sc2'; -import { readTexture } from '../blitzkrieg/readTexture'; -import { readDVPL } from './readDVPL'; -import { readDVPLFile } from './readDVPLFile'; - -const MAX_FLOAT32 = 2 ** 127 * (2 - 2 ** -23); - -enum KAType { - NONE = 0, - BOOLEAN = 1, - INT32 = 2, - FLOAT = 3, - STRING = 4, - WIDE_STRING = 5, - BYTE_ARRAY = 6, - UINT32 = 7, - KEYED_ARCHIVE = 8, - INT64 = 9, - UINT64 = 10, - VECTOR2 = 11, - VECTOR3 = 12, - VECTOR4 = 13, - MATRIX2 = 14, - MATRIX3 = 15, - MATRIX4 = 16, - COLOR = 17, - FASTNAME = 18, - AABBOX3 = 19, - FILEPATH = 20, - FLOAT64 = 21, - INT8 = 22, - UINT8 = 23, - INT16 = 24, - UINT16 = 25, - ARRAY = 27, - TRANSFORM = 29, -} - -export enum VertexAttribute { - VERTEX = 0, - NORMAL = 1, - COLOR = 2, - TEXCOORD0 = 3, - TEXCOORD1 = 4, - TEXCOORD2 = 5, - TEXCOORD3 = 6, - TANGENT = 7, - BINORMAL = 8, - HARD_JOINTINDEX = 9, - PIVOT4 = 10, - PIVOT_DEPRECATED = 11, - FLEXIBILITY = 12, - ANGLE_SIN_COS = 13, - JOINTINDEX = 14, - JOINTWEIGHT = 15, - CUBETEXCOORD0 = 16, - CUBETEXCOORD1 = 17, - CUBETEXCOORD2 = 18, - CUBETEXCOORD3 = 19, -} - -export const vertexAttributeVectorSizes = { - [VertexAttribute.VERTEX]: 3, - [VertexAttribute.NORMAL]: 3, - [VertexAttribute.COLOR]: 1, - [VertexAttribute.TEXCOORD0]: 2, - [VertexAttribute.TEXCOORD1]: 2, - [VertexAttribute.TEXCOORD2]: 2, - [VertexAttribute.TEXCOORD3]: 2, - [VertexAttribute.TANGENT]: 3, - [VertexAttribute.BINORMAL]: 3, - [VertexAttribute.HARD_JOINTINDEX]: 1, - [VertexAttribute.CUBETEXCOORD0]: 3, - [VertexAttribute.CUBETEXCOORD1]: 3, - [VertexAttribute.CUBETEXCOORD2]: 3, - [VertexAttribute.CUBETEXCOORD3]: 3, - [VertexAttribute.PIVOT4]: 4, - [VertexAttribute.PIVOT_DEPRECATED]: 3, - [VertexAttribute.FLEXIBILITY]: 1, - [VertexAttribute.ANGLE_SIN_COS]: 2, - [VertexAttribute.JOINTINDEX]: 4, - [VertexAttribute.JOINTWEIGHT]: 4, -} as const; - -const vertexAttributeGLTFName: Partial> = { - [VertexAttribute.VERTEX]: 'POSITION', - [VertexAttribute.NORMAL]: 'NORMAL', - [VertexAttribute.COLOR]: 'COLOR_0', - [VertexAttribute.TEXCOORD0]: 'TEXCOORD_0', - [VertexAttribute.TEXCOORD1]: 'TEXCOORD_0', - [VertexAttribute.TEXCOORD2]: 'TEXCOORD_0', - [VertexAttribute.TEXCOORD3]: 'TEXCOORD_0', - [VertexAttribute.TANGENT]: 'TANGENT', - [VertexAttribute.JOINTINDEX]: 'JOINT_0', - [VertexAttribute.JOINTWEIGHT]: 'WEIGHT_0', -}; - -interface PolygonGroupRaw { - '##name': 'PolygonGroup'; - '#id': Buffer; - cubeTextureCoordCount: number; - indexCount: number; - indexFormat: 0 | 1; - indices: Buffer; - packing: 0 | 1; - primitiveCount: number; - rhi_primitiveType: 1 | 2 | 10; - textureCoordCount: number; - vertexCount: number; - vertexFormat: number; - vertices: Buffer; -} -type BlitzkriegVertex = { attribute: VertexAttribute; value: number[] }[]; -interface BlitzkriegPolygonGroup { - vertices: BlitzkriegVertex[]; - indices: number[]; -} - -export class SCPGStream { - public index = 0; - constructor(public buffer: Buffer) {} - - skip(size: number) { - this.index += size; - } - back(size: number) { - this.skip(-size); - } - read(size: number) { - return this.buffer.subarray(this.index, this.index + size); - } - consume(size: number) { - const subarray = this.read(size); - this.skip(size); - - return subarray; - } - - readRemaining() { - return this.read(Number.POSITIVE_INFINITY); - } - readRemainingLength() { - return this.buffer.length - this.index; - } - consumeRemaining() { - return this.consume(Number.POSITIVE_INFINITY); - } - - consumeByteArray(size: number) { - return this.consume(size); - } - - consumeAscii(size: number) { - return this.consume(size).toString('ascii'); - } - - consumeInt8() { - return this.consume(1).readInt8(); - } - consumeInt16() { - return this.consume(2).readInt16LE(); - } - consumeInt32() { - return this.consume(4).readInt32LE(); - } - consumeInt64() { - return this.consume(8).readBigInt64LE(); - } - consumeUInt8() { - return this.consume(1).readUInt8(); - } - consumeUInt16() { - return this.consume(2).readUInt16LE(); - } - consumeUInt32() { - return this.consume(4).readUInt32LE(); - } - consumeUInt64() { - return this.consume(8).readBigUInt64LE(); - } - consumeFloat() { - return this.consume(4).readFloatLE(); - } - - consumeBoolean() { - return this.consume(1).readUInt8() === 1; - } - - consumeVectorN(size: number) { - return times(size, () => this.consumeFloat()); - } - consumeVector2() { - return this.consumeVectorN(2); - } - consumeVector3() { - return this.consumeVectorN(3); - } - consumeVector4() { - return this.consumeVectorN(4); - } - - consumeMatrixN(size: number) { - return times(size, () => this.consumeVectorN(size)); - } - consumeMatrix2() { - return this.consumeMatrixN(2); - } - consumeMatrix3() { - return this.consumeMatrixN(3); - } - consumeMatrix4() { - return this.consumeMatrixN(4); - } - - static getVertexStride(vertexFormat: number) { - const flags = vertexFormat.toString(2); - - return Object.values(VertexAttribute) - .filter((value) => typeof value === 'number') - .reduce( - (accumulator, attribute) => - flags[attribute as VertexAttribute] === '1' - ? accumulator + - vertexAttributeVectorSizes[attribute as VertexAttribute] * 4 - : accumulator, - 0, - ); - } - - consumeSCGHeader() { - return { - name: this.consumeAscii(4), - version: this.consumeUInt32(), - nodeCount: this.consumeUInt32(), - nodeCount2: this.consumeUInt32(), - }; - } - consumeSCG() { - const header = this.consumeSCGHeader(); - const polygonGroupsRaw: PolygonGroupRaw[] = []; - const polygonGroups = new Map(); - - for (let index = 0; index < header.nodeCount; index++) { - const polygonGroupRaw = this.consumeKA() as PolygonGroupRaw; - polygonGroupsRaw.push(polygonGroupRaw); - } - - polygonGroupsRaw.forEach((polygonGroupRaw) => { - const indicesStream = new SCPGStream(polygonGroupRaw.indices); - const indices: number[] = []; - - for (let index = 0; index < polygonGroupRaw.indexCount; index++) { - indices.push( - polygonGroupRaw.indexFormat === 0 - ? indicesStream.consumeUInt16() - : indicesStream.consumeUInt32(), - ); - } - - const verticesStream = new SCPGStream(polygonGroupRaw.vertices); - const vertices: BlitzkriegVertex[] = []; - const stride = - SCPGStream.getVertexStride(polygonGroupRaw.vertexFormat) * - polygonGroupRaw.vertexCount; - - if (verticesStream.buffer.length !== stride) { - console.warn( - `Vertex stride mismatch; expected ${stride}, got ${verticesStream.buffer.length}`, - ); - - return; - } - - const vertexFormat = ( - polygonGroupRaw.vertexFormat - .toString(2) - .split('') - .map((bitString, index) => - bitString === '1' ? index : null, - ) as VertexAttribute[] - ).filter((type) => type !== null); - - for (let index = 0; index < polygonGroupRaw.vertexCount; index++) { - vertices[index] = vertexFormat.map((attribute) => { - return { - attribute, - value: verticesStream.consumeVectorN( - vertexAttributeVectorSizes[attribute], - ), - }; - }); - } - - if (verticesStream.readRemainingLength() !== 0) { - throw new RangeError('Vertices stream was not fully consumed'); - } - - polygonGroups.set(polygonGroupRaw['#id'].readBigUInt64LE(), { - vertices, - indices, - }); - }); - - return polygonGroups; - } - - consumeSC2Header() { - const header = { - name: this.consumeAscii(4), - version: this.consumeUInt32(), - nodeCount: this.consumeUInt32(), - }; - const versionTags = this.consumeKA(); - const descriptor = this.consumeSC2Descriptor(); - - return { header, versionTags, descriptor }; - } - consumeSC2Descriptor() { - const size = this.consumeUInt32(); - const fileType = this.consumeUInt32(); - - // other felids that we don't care about for some reason lol - this.skip(size - 4); - - return { size, fileType }; - } - consumeSC2() { - this.consumeSC2Header(); - return this.consumeKA() as SC2; - } - - consumeKAValue( - type: KAType = this.consumeUInt8(), - stringTable?: Record, - ): unknown { - switch (type) { - case KAType.NONE: - return undefined; - - case KAType.FILEPATH: - case KAType.STRING: { - if (stringTable) { - const id = this.consumeUInt32(); - return stringTable[id]; - } else { - const length = this.consumeUInt32(); - return this.consumeAscii(length); - } - } - - case KAType.BYTE_ARRAY: { - const length = this.consumeUInt32(); - return this.consumeByteArray(length); - } - - case KAType.FLOAT: - return this.consumeFloat(); - - case KAType.ARRAY: { - const length = this.consumeUInt32(); - const value = []; - - for (let index = 0; index < length; index++) { - value.push(this.consumeKAValue(undefined, stringTable)); - } - - return value; - } - - case KAType.KEYED_ARCHIVE: { - this.skip(4); // UInt32 length of the nested archive in bytes - - return this.consumeKA(stringTable); - } - - case KAType.INT8: - return this.consumeInt8(); - case KAType.INT16: - return this.consumeInt16(); - case KAType.INT32: - return this.consumeInt32(); - case KAType.INT64: - return this.consumeInt64(); - case KAType.UINT8: - return this.consumeUInt8(); - case KAType.UINT16: - return this.consumeUInt16(); - case KAType.UINT32: - return this.consumeUInt32(); - case KAType.UINT64: - return this.consumeUInt64(); - - case KAType.BOOLEAN: - return this.consumeBoolean(); - - case KAType.VECTOR2: - return this.consumeVector2(); - case KAType.VECTOR3: - return this.consumeVector3(); - case KAType.VECTOR4: - return this.consumeVector4(); - - case KAType.COLOR: - return this.consumeVector4(); // as RGBA - - case KAType.MATRIX2: - return this.consumeMatrix2(); - case KAType.MATRIX3: - return this.consumeMatrix3(); - case KAType.MATRIX4: - return this.consumeMatrix4(); - - case KAType.AABBOX3: { - const minimum = this.consumeVector3(); - const maximum = this.consumeVector3(); - - return { minimum, maximum }; - } - - case KAType.FASTNAME: { - if (!stringTable) throw new Error('No string table provided'); - const index = this.consumeUInt32(); - return stringTable[index]; - } - - case KAType.TRANSFORM: { - const position = this.consumeVector3(); - const scale = this.consumeVector3(); - const rotation = this.consumeVector4(); - - return { - position, - scale, - rotation, - }; - } - - default: - throw new TypeError( - `Unhandled KA type: ${type} (${KAType[type] ?? 'unknown type'})`, - ); - } - } - consumeKA(stringTable?: Record) { - this.skip(2); // "KA" - const version = this.consumeUInt16(); - const pairs: Record = {}; - - if (version === 0x0001) { - const count = this.consumeUInt32(); - - for (let index = 0; index < count; index++) { - const name = this.consumeKAValue(undefined, stringTable) as string; - const value = this.consumeKAValue(undefined, stringTable); - - pairs[name] = value; - } - } else if (version === 0x0002) { - const count = this.consumeUInt32(); - const strings: string[] = []; - const stringTable: Record = {}; - - for (let index = 0; index < count; index++) { - const length = this.consumeUInt16(); - const string = this.consumeAscii(length); - - strings.push(string); - } - - for (let index = 0; index < count; index++) { - const id = this.consumeUInt32(); - stringTable[id] = strings[index]; - } - - const nodeCount = this.consumeUInt32(); - - for (let index = 0; index < nodeCount; index++) { - const keyId = this.consumeUInt32(); - const value = this.consumeKAValue(undefined, stringTable); - const key = stringTable[keyId]; - - pairs[key] = value; - } - } else if (version === 0x0102) { - if (!stringTable) throw new Error('No string table provided'); - - const count = this.consumeUInt32(); - - for (let index = 0; index < count; index++) { - const keyIndex = this.consumeUInt32(); - const key = stringTable[keyIndex]; - const valueType = this.consumeUInt8(); - - if (valueType === KAType.STRING) { - const valueIndex = this.consumeUInt32(); - const value = stringTable[valueIndex]; - pairs[key] = value; - } else { - const value = this.consumeKAValue(valueType, stringTable); - pairs[key] = value; - } - } - } else if (version === 0xff02) { - // same as 0x0102 but empty and with no count - return {}; - } else throw new RangeError(`Unhandled KA version: ${version}`); - - return pairs; - } - - static fromDVPL(buffer: Buffer) { - return new SCPGStream(readDVPL(buffer)); - } - static async fromDVPLFile(file: string) { - return new SCPGStream(await readDVPLFile(file)); - } - - static async extractModel(data: string, path: string) { - const sc2Path = `${data}/3d/${path}.sc2.dvpl`; - const scgPath = `${data}/3d/${path}.scg.dvpl`; - const sc2 = (await SCPGStream.fromDVPLFile(sc2Path)).consumeSC2(); - const scg = (await SCPGStream.fromDVPLFile(scgPath)).consumeSCG(); - const document = new Document(); - const scene = document.createScene(); - const buffer = document.createBuffer(); - const materials = new Map(); - - writeFileSync('test.sc2.json', JSON.stringify(sc2, null, 2)); - - // create materials - await Promise.all( - sc2['#dataNodes'].map(async (node) => { - const id = node['#id'].readBigUInt64LE(); - - if (node.parentMaterialKey !== undefined) { - /** - * material depends on a parent and doesn't seem to have any - * properties so we just point it to the parent - */ - materials.set(id, node.parentMaterialKey); - return; - } - - const material = document.createMaterial(node.materialName); - let textures: Textures | undefined = undefined; - - if (node.textures) { - textures = node.textures; - } else if (typeof node.configCount === 'number') { - textures = node.configArchive_0.textures; - } - - if (textures) { - material.setBaseColorTexture( - document - .createTexture(node.materialName) - .setMimeType('image/jpg') - .setImage( - await readTexture( - `${data}/3d/${dirname(path)}/${textures.albedo}`, - ), - ), - ); - - if (textures.baseRMMap) { - material.setMetallicRoughnessTexture( - document - .createTexture(node.materialName) - .setMimeType('image/jpg') - .setImage( - await sharp( - await readTexture( - `${data}/3d/${dirname(path)}/${textures.baseRMMap}`, - ), - ) - .raw() - .toBuffer({ resolveWithObject: true }) - .then(({ data, info }) => { - // fake it till you make it - for (let index = 0; index < data.length; index++) { - data[index] = 255 - data[index]; - } - - return sharp(data, { raw: info }).jpeg().toBuffer(); - }), - ), - ); - } - - if (textures.baseNormalMap) { - material.setNormalTexture( - document - .createTexture(node.materialName) - .setMimeType('image/jpg') - .setImage( - await sharp( - await readTexture( - `${data}/3d/${dirname(path)}/${textures.baseNormalMap}`, - ), - ) - .raw() - .toBuffer({ resolveWithObject: true }) - .then(({ data, info }) => { - // https://www.youtube.com/watch?v=CGys6CnnYSg - for (let index = 0; index < data.length; index += 3) { - data[index + 2] = 255 - data[index + 2]; - } - - return sharp(data, { raw: info }).jpeg().toBuffer(); - }), - ), - ); - } - } - - materials.set(node['#id'].readBigUInt64LE(), material); - }), - ); - - // replace children materials with parents - materials.forEach((material, id) => { - if (typeof material !== 'bigint') return; - - let resolvedMaterial: Material | bigint = material; - - while (typeof resolvedMaterial === 'bigint') { - const linkedParentMaterial = materials.get(resolvedMaterial); - - if (linkedParentMaterial === undefined) { - throw new Error('Could not resolve material'); - } - - resolvedMaterial = linkedParentMaterial; - } - - materials.set(id, resolvedMaterial); - }); - - writeFileSync('test.sc2.json', JSON.stringify(sc2, null, 2)); - - function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { - hierarchies.forEach((hierarchy) => { - const node = document.createNode(hierarchy.name); - const components = times( - hierarchy.components.count, - (index) => hierarchy.components[index.toString().padStart(4, '0')], - ); - - components.forEach((component) => { - switch (component['comp.typename']) { - case 'LodComponent': - case 'DecorItemComponent': - case 'NewSlotComponent': - case 'SlotComponent': - break; - - case 'TransformComponent': { - const translation = new Vector3(); - const rotation = new Quaternion(); - const scale = new Vector3(); - - new Matrix4() - .multiplyMatrices( - new Matrix4().compose( - new Vector3().fromArray(component['tc.worldTranslation']), - new Quaternion().fromArray(component['tc.worldRotation']), - new Vector3().fromArray(component['tc.worldScale']), - ), - new Matrix4().compose( - new Vector3().fromArray(component['tc.localTranslation']), - new Quaternion().fromArray(component['tc.localRotation']), - new Vector3().fromArray(component['tc.localScale']), - ), - ) - .decompose(translation, rotation, scale); - - node.setTranslation(translation.toArray()); - node.setRotation(rotation.toArray() as Vector4Tuple); - node.setScale(scale.toArray()); - - break; - } - - case 'RenderComponent': { - let minLODDistanceBatchId: undefined | number = undefined; - - if (component['rc.renderObj']['rb0.lodIndex'] === -1) { - minLODDistanceBatchId = 0; - } else { - const lodList = components.find( - (component) => component['comp.typename'] === 'LodComponent', - ); - - if ( - lodList === undefined || - lodList['comp.typename'] !== 'LodComponent' // type annotation hack - ) { - throw new SyntaxError('Missing LodComponent'); - } - - let minLODDistance = Infinity; - - range(component['rc.renderObj']['ro.batchCount']).forEach( - (id) => { - const lodIndex = - component['rc.renderObj'][`rb${id}.lodIndex`]; - const lodDistance = - lodList['lc.loddist'][`distance${lodIndex}`]; - - if ( - lodDistance <= minLODDistance || - lodDistance === MAX_FLOAT32 - ) { - minLODDistance = lodDistance; - minLODDistanceBatchId = id; - } - }, - ); - } - - const batch = - component['rc.renderObj']['ro.batches'][ - minLODDistanceBatchId!.toString().padStart(4, '0') - ]; - const polygonGroup = scg.get(batch['rb.datasource']); - - if (!polygonGroup) { - console.warn( - `Missing polygon group ${batch['rb.datasource']}; skipping...`, - ); - - break; - } - - const indexedVertexAttributes: VertexAttribute[] = []; - const unpackedVertices: Partial< - Record - > = {}; - - polygonGroup.vertices.forEach((vertex) => { - vertex.map((vertexItem) => { - if (!(vertexItem.attribute in unpackedVertices)) { - unpackedVertices[vertexItem.attribute] = []; - indexedVertexAttributes.push(vertexItem.attribute); - } - - unpackedVertices[vertexItem.attribute]!.push( - ...vertexItem.value, - ); - }); - }); - - const indexAccessor = document - .createAccessor() - .setType('SCALAR') - .setArray(new Uint16Array(polygonGroup.indices)) - .setBuffer(buffer); - const material = materials.get(batch['rb.nmatname']); - - if (!(material instanceof Material)) { - throw new Error( - `Material ${batch['rb.nmatname']} is unresolved`, - ); - } - - const primitive = document - .createPrimitive() - .setIndices(indexAccessor) - .setMaterial(material); - - indexedVertexAttributes.forEach((attribute) => { - if (!(attribute in vertexAttributeGLTFName)) return; - - const vertexSize = vertexAttributeVectorSizes[attribute]; - - if ( - !primitive.getAttribute(vertexAttributeGLTFName[attribute]!) - ) { - primitive.setAttribute( - vertexAttributeGLTFName[attribute]!, - document - .createAccessor() - .setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`) - .setArray(new Float32Array(unpackedVertices[attribute]!)) - .setBuffer(buffer), - ); - } - }); - - const mesh = document - .createMesh(batch['##name']) - .addPrimitive(primitive); - - node.setMesh(mesh); - - break; - } - - default: - throw new TypeError( - `Unhandled component type: ${component['comp.typename']}`, - ); - } - }); - - if (hierarchy['#hierarchy']) { - parseHierarchies(hierarchy['#hierarchy'], node); - } - - parent.addChild(node); - }); - } - - // rotate 90 degrees on the x axis - const rootNode = document - .createNode() - .setRotation([Math.cos(Math.PI / 4), 0, 0, -Math.sin(Math.PI / 4)]); - - scene.addChild(rootNode); - parseHierarchies(sc2['#hierarchy'], rootNode); - - return document; - } -} diff --git a/src/core/blitz/extractModel.ts b/src/core/blitz/extractModel.ts new file mode 100644 index 000000000..660f22d65 --- /dev/null +++ b/src/core/blitz/extractModel.ts @@ -0,0 +1,330 @@ +import { Document, Material, Node, Scene } from '@gltf-transform/core'; +import { writeFileSync } from 'fs'; +import { range, times } from 'lodash'; +import { dirname } from 'path'; +import sharp from 'sharp'; +import { Matrix4, Quaternion, Vector3, Vector4Tuple } from 'three'; +import { readTexture } from '../blitzkrieg/readTexture'; +import { Hierarchy, Sc2Stream, Textures } from '../streams/sc2'; +import { ScgStream, vertexAttributeVectorSizes } from '../streams/scg'; +import { VertexAttribute } from '../streams/scpg'; +import { readDVPLFile } from './readDVPLFile'; + +const MAX_FLOAT32 = 2 ** 127 * (2 - 2 ** -23); + +const vertexAttributeGLTFName: Partial> = { + [VertexAttribute.VERTEX]: 'POSITION', + [VertexAttribute.NORMAL]: 'NORMAL', + [VertexAttribute.COLOR]: 'COLOR_0', + [VertexAttribute.TEXCOORD0]: 'TEXCOORD_0', + [VertexAttribute.TEXCOORD1]: 'TEXCOORD_0', + [VertexAttribute.TEXCOORD2]: 'TEXCOORD_0', + [VertexAttribute.TEXCOORD3]: 'TEXCOORD_0', + [VertexAttribute.TANGENT]: 'TANGENT', + [VertexAttribute.JOINTINDEX]: 'JOINT_0', + [VertexAttribute.JOINTWEIGHT]: 'WEIGHT_0', +}; + +export async function extractModel(data: string, path: string) { + const sc2Path = `${data}/3d/${path}.sc2.dvpl`; + const scgPath = `${data}/3d/${path}.scg.dvpl`; + const sc2 = new Sc2Stream(await readDVPLFile(sc2Path)).sc2(); + const scg = new ScgStream(await readDVPLFile(scgPath)).scg(); + const document = new Document(); + const scene = document.createScene(); + const buffer = document.createBuffer(); + const materials = new Map(); + + writeFileSync('test.sc2.json', JSON.stringify(sc2, null, 2)); + + // create materials + await Promise.all( + sc2['#dataNodes'].map(async (node) => { + const id = node['#id'].readBigUInt64LE(); + + if (node.parentMaterialKey !== undefined) { + /** + * material depends on a parent and doesn't seem to have any + * properties so we just point it to the parent + */ + materials.set(id, node.parentMaterialKey); + return; + } + + const material = document.createMaterial(node.materialName); + let textures: Textures | undefined = undefined; + + if (node.textures) { + textures = node.textures; + } else if (typeof node.configCount === 'number') { + textures = node.configArchive_0.textures; + } + + if (textures) { + material.setBaseColorTexture( + document + .createTexture(node.materialName) + .setMimeType('image/jpg') + .setImage( + await readTexture( + `${data}/3d/${dirname(path)}/${textures.albedo}`, + ), + ), + ); + + if (textures.baseRMMap) { + material.setMetallicRoughnessTexture( + document + .createTexture(node.materialName) + .setMimeType('image/jpg') + .setImage( + await sharp( + await readTexture( + `${data}/3d/${dirname(path)}/${textures.baseRMMap}`, + ), + ) + .raw() + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => { + // fake it till you make it + for (let index = 0; index < data.length; index++) { + data[index] = 255 - data[index]; + } + + return sharp(data, { raw: info }).jpeg().toBuffer(); + }), + ), + ); + } + + if (textures.baseNormalMap) { + material.setNormalTexture( + document + .createTexture(node.materialName) + .setMimeType('image/jpg') + .setImage( + await sharp( + await readTexture( + `${data}/3d/${dirname(path)}/${textures.baseNormalMap}`, + ), + ) + .raw() + .toBuffer({ resolveWithObject: true }) + .then(({ data, info }) => { + // https://www.youtube.com/watch?v=CGys6CnnYSg + for (let index = 0; index < data.length; index += 3) { + data[index + 2] = 255 - data[index + 2]; + } + + return sharp(data, { raw: info }).jpeg().toBuffer(); + }), + ), + ); + } + } + + materials.set(node['#id'].readBigUInt64LE(), material); + }), + ); + + // replace children materials with parents + materials.forEach((material, id) => { + if (typeof material !== 'bigint') return; + + let resolvedMaterial: Material | bigint = material; + + while (typeof resolvedMaterial === 'bigint') { + const linkedParentMaterial = materials.get(resolvedMaterial); + + if (linkedParentMaterial === undefined) { + throw new Error('Could not resolve material'); + } + + resolvedMaterial = linkedParentMaterial; + } + + materials.set(id, resolvedMaterial); + }); + + writeFileSync('test.sc2.json', JSON.stringify(sc2, null, 2)); + + function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { + hierarchies.forEach((hierarchy) => { + const node = document.createNode(hierarchy.name); + const components = times( + hierarchy.components.count, + (index) => hierarchy.components[index.toString().padStart(4, '0')], + ); + + components.forEach((component) => { + switch (component['comp.typename']) { + case 'LodComponent': + case 'DecorItemComponent': + case 'NewSlotComponent': + case 'SlotComponent': + break; + + case 'TransformComponent': { + const translation = new Vector3(); + const rotation = new Quaternion(); + const scale = new Vector3(); + + new Matrix4() + .multiplyMatrices( + new Matrix4().compose( + new Vector3().fromArray(component['tc.worldTranslation']), + new Quaternion().fromArray(component['tc.worldRotation']), + new Vector3().fromArray(component['tc.worldScale']), + ), + new Matrix4().compose( + new Vector3().fromArray(component['tc.localTranslation']), + new Quaternion().fromArray(component['tc.localRotation']), + new Vector3().fromArray(component['tc.localScale']), + ), + ) + .decompose(translation, rotation, scale); + + node.setTranslation(translation.toArray()); + node.setRotation(rotation.toArray() as Vector4Tuple); + node.setScale(scale.toArray()); + + break; + } + + case 'RenderComponent': { + let minLODDistanceBatchId: undefined | number = undefined; + + if (component['rc.renderObj']['rb0.lodIndex'] === -1) { + minLODDistanceBatchId = 0; + } else { + const lodList = components.find( + (component) => component['comp.typename'] === 'LodComponent', + ); + + if ( + lodList === undefined || + lodList['comp.typename'] !== 'LodComponent' // type annotation hack + ) { + throw new SyntaxError('Missing LodComponent'); + } + + let minLODDistance = Infinity; + + range(component['rc.renderObj']['ro.batchCount']).forEach( + (id) => { + const lodIndex = + component['rc.renderObj'][`rb${id}.lodIndex`]; + const lodDistance = + lodList['lc.loddist'][`distance${lodIndex}`]; + + if ( + lodDistance <= minLODDistance || + lodDistance === MAX_FLOAT32 + ) { + minLODDistance = lodDistance; + minLODDistanceBatchId = id; + } + }, + ); + } + + const batch = + component['rc.renderObj']['ro.batches'][ + minLODDistanceBatchId!.toString().padStart(4, '0') + ]; + const polygonGroup = scg.get(batch['rb.datasource']); + + if (!polygonGroup) { + console.warn( + `Missing polygon group ${batch['rb.datasource']}; skipping...`, + ); + + break; + } + + const indexedVertexAttributes: VertexAttribute[] = []; + const unpackedVertices: Partial> = + {}; + + polygonGroup.vertices.forEach((vertex) => { + vertex.map((vertexItem) => { + if (!(vertexItem.attribute in unpackedVertices)) { + unpackedVertices[vertexItem.attribute] = []; + indexedVertexAttributes.push(vertexItem.attribute); + } + + unpackedVertices[vertexItem.attribute]!.push( + ...vertexItem.value, + ); + }); + }); + + const indexAccessor = document + .createAccessor() + .setType('SCALAR') + .setArray(new Uint16Array(polygonGroup.indices)) + .setBuffer(buffer); + const material = materials.get(batch['rb.nmatname']); + + if (!(material instanceof Material)) { + throw new Error(`Material ${batch['rb.nmatname']} is unresolved`); + } + + const primitive = document + .createPrimitive() + .setIndices(indexAccessor) + .setMaterial(material); + + indexedVertexAttributes.forEach((attribute) => { + if (!(attribute in vertexAttributeGLTFName)) return; + + const vertexSize = vertexAttributeVectorSizes[attribute]; + + if ( + !primitive.getAttribute(vertexAttributeGLTFName[attribute]!) + ) { + primitive.setAttribute( + vertexAttributeGLTFName[attribute]!, + document + .createAccessor() + .setType(vertexSize === 1 ? 'SCALAR' : `VEC${vertexSize}`) + .setArray(new Float32Array(unpackedVertices[attribute]!)) + .setBuffer(buffer), + ); + } + }); + + const mesh = document + .createMesh(batch['##name']) + .addPrimitive(primitive); + + node.setMesh(mesh); + + break; + } + + default: + throw new TypeError( + `Unhandled component type: ${component['comp.typename']}`, + ); + } + }); + + if (hierarchy['#hierarchy']) { + parseHierarchies(hierarchy['#hierarchy'], node); + } + + parent.addChild(node); + }); + } + + // rotate 90 degrees on the x axis + const rootNode = document + .createNode() + .setRotation([Math.cos(Math.PI / 4), 0, 0, -Math.sin(Math.PI / 4)]); + + scene.addChild(rootNode); + parseHierarchies(sc2['#hierarchy'], rootNode); + + return document; +} diff --git a/src/core/blitzkrieg/readDds.ts b/src/core/blitzkrieg/readDds.ts new file mode 100644 index 000000000..cd7062764 --- /dev/null +++ b/src/core/blitzkrieg/readDds.ts @@ -0,0 +1,15 @@ +import { readDVPLFile } from '../blitz/readDVPLFile'; +import { PrimitiveStream } from '../streams/primitive'; + +export function readDds(buffer: Buffer) { + const stream = new PrimitiveStream(buffer); + const name = stream.consumeAscii(4); + + if (name !== 'DDS ') throw new TypeError('Not a DDS file'); +} + +readDds( + await readDVPLFile( + 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data/3d/Tanks/German/images_pbr/E_100_BC.dx11.dds.dvpl', + ), +); diff --git a/src/core/streams/primitive.ts b/src/core/streams/primitive.ts new file mode 100644 index 000000000..f01e2ea0c --- /dev/null +++ b/src/core/streams/primitive.ts @@ -0,0 +1,67 @@ +export class PrimitiveStream { + public index = 0; + constructor(public buffer: Buffer) {} + + skip(size: number) { + this.index += size; + } + read(size: number) { + return this.buffer.subarray(this.index, this.index + size); + } + consume(size: number) { + const subarray = this.read(size); + this.skip(size); + + return subarray; + } + + readRemaining() { + return this.read(Number.POSITIVE_INFINITY); + } + readRemainingLength() { + return this.buffer.length - this.index; + } + consumeRemaining() { + return this.consume(Number.POSITIVE_INFINITY); + } + + consumeByteArray(size: number) { + return this.consume(size); + } + + consumeAscii(size: number) { + return this.consume(size).toString('ascii'); + } + + int8() { + return this.consume(1).readInt8(); + } + int16() { + return this.consume(2).readInt16LE(); + } + int32() { + return this.consume(4).readInt32LE(); + } + int64() { + return this.consume(8).readBigInt64LE(); + } + uint8() { + return this.consume(1).readUInt8(); + } + uint16() { + return this.consume(2).readUInt16LE(); + } + uint32() { + return this.consume(4).readUInt32LE(); + } + uint64() { + return this.consume(8).readBigUInt64LE(); + } + float() { + return this.consume(4).readFloatLE(); + } + + boolean() { + return this.consume(1).readUInt8() === 1; + } +} diff --git a/src/types/sc2.ts b/src/core/streams/sc2.ts similarity index 87% rename from src/types/sc2.ts rename to src/core/streams/sc2.ts index bf868e8dd..9949e3b4a 100644 --- a/src/types/sc2.ts +++ b/src/core/streams/sc2.ts @@ -1,4 +1,5 @@ import { Vector3Tuple, Vector4Tuple } from 'three'; +import { ScpgStream } from './scpg'; export interface SC2 { '#dataNodes': DataNode[]; @@ -172,3 +173,35 @@ export type Component = interface SceneComponents { count: number; } + +export class Sc2Stream extends ScpgStream { + header() { + return { + name: this.consumeAscii(4), + version: this.uint32(), + nodeCount: this.uint32(), + }; + } + + versionTags() { + return this.ka(); + } + + descriptor() { + const size = this.uint32(); + const fileType = this.uint32(); + + // other felids that we don't have any info about + this.skip(size - 4); + + return { size, fileType }; + } + + sc2() { + this.header(); + this.versionTags(); + this.descriptor(); + + return this.ka() as SC2; + } +} diff --git a/src/core/streams/scg.ts b/src/core/streams/scg.ts new file mode 100644 index 000000000..50fc3e240 --- /dev/null +++ b/src/core/streams/scg.ts @@ -0,0 +1,142 @@ +import { ScpgStream, VertexAttribute } from './scpg'; + +interface PolygonGroupRaw { + '##name': 'PolygonGroup'; + '#id': Buffer; + cubeTextureCoordCount: number; + indexCount: number; + indexFormat: 0 | 1; + indices: Buffer; + packing: 0 | 1; + primitiveCount: number; + rhi_primitiveType: 1 | 2 | 10; + textureCoordCount: number; + vertexCount: number; + vertexFormat: number; + vertices: Buffer; +} + +type BlitzkriegVertex = { attribute: VertexAttribute; value: number[] }[]; + +interface BlitzkriegPolygonGroup { + vertices: BlitzkriegVertex[]; + indices: number[]; +} + +export const vertexAttributeVectorSizes = { + [VertexAttribute.VERTEX]: 3, + [VertexAttribute.NORMAL]: 3, + [VertexAttribute.COLOR]: 1, + [VertexAttribute.TEXCOORD0]: 2, + [VertexAttribute.TEXCOORD1]: 2, + [VertexAttribute.TEXCOORD2]: 2, + [VertexAttribute.TEXCOORD3]: 2, + [VertexAttribute.TANGENT]: 3, + [VertexAttribute.BINORMAL]: 3, + [VertexAttribute.HARD_JOINTINDEX]: 1, + [VertexAttribute.CUBETEXCOORD0]: 3, + [VertexAttribute.CUBETEXCOORD1]: 3, + [VertexAttribute.CUBETEXCOORD2]: 3, + [VertexAttribute.CUBETEXCOORD3]: 3, + [VertexAttribute.PIVOT4]: 4, + [VertexAttribute.PIVOT_DEPRECATED]: 3, + [VertexAttribute.FLEXIBILITY]: 1, + [VertexAttribute.ANGLE_SIN_COS]: 2, + [VertexAttribute.JOINTINDEX]: 4, + [VertexAttribute.JOINTWEIGHT]: 4, +} as const; + +export class ScgStream extends ScpgStream { + header() { + return { + name: this.consumeAscii(4), + version: this.uint32(), + nodeCount: this.uint32(), + nodeCount2: this.uint32(), + }; + } + + scg() { + const header = this.header(); + const polygonGroupsRaw: PolygonGroupRaw[] = []; + const polygonGroups = new Map(); + + for (let index = 0; index < header.nodeCount; index++) { + const polygonGroupRaw = this.ka() as PolygonGroupRaw; + polygonGroupsRaw.push(polygonGroupRaw); + } + + polygonGroupsRaw.forEach((polygonGroupRaw) => { + const indicesStream = new ScpgStream(polygonGroupRaw.indices); + const indices: number[] = []; + + for (let index = 0; index < polygonGroupRaw.indexCount; index++) { + indices.push( + polygonGroupRaw.indexFormat === 0 + ? indicesStream.uint16() + : indicesStream.uint32(), + ); + } + + const verticesStream = new ScpgStream(polygonGroupRaw.vertices); + const vertices: BlitzkriegVertex[] = []; + const stride = + ScgStream.vertexStride(polygonGroupRaw.vertexFormat) * + polygonGroupRaw.vertexCount; + + if (verticesStream.buffer.length !== stride) { + console.warn( + `Vertex stride mismatch; expected ${stride}, got ${verticesStream.buffer.length}`, + ); + + return; + } + + const vertexFormat = ( + polygonGroupRaw.vertexFormat + .toString(2) + .split('') + .map((bitString, index) => + bitString === '1' ? index : null, + ) as VertexAttribute[] + ).filter((type) => type !== null); + + for (let index = 0; index < polygonGroupRaw.vertexCount; index++) { + vertices[index] = vertexFormat.map((attribute) => { + return { + attribute, + value: verticesStream.vectorN( + vertexAttributeVectorSizes[attribute], + ), + }; + }); + } + + if (verticesStream.readRemainingLength() !== 0) { + throw new RangeError('Vertices stream was not fully consumed'); + } + + polygonGroups.set(polygonGroupRaw['#id'].readBigUInt64LE(), { + vertices, + indices, + }); + }); + + return polygonGroups; + } + + static vertexStride(vertexFormat: number) { + const flags = vertexFormat.toString(2); + + return Object.values(VertexAttribute) + .filter((value) => typeof value === 'number') + .reduce( + (accumulator, attribute) => + flags[attribute as VertexAttribute] === '1' + ? accumulator + + vertexAttributeVectorSizes[attribute as VertexAttribute] * 4 + : accumulator, + 0, + ); + } +} diff --git a/src/core/streams/scpg.ts b/src/core/streams/scpg.ts new file mode 100644 index 000000000..342914141 --- /dev/null +++ b/src/core/streams/scpg.ts @@ -0,0 +1,263 @@ +import { times } from 'lodash'; +import { PrimitiveStream } from './primitive'; + +enum KAType { + NONE = 0, + BOOLEAN = 1, + INT32 = 2, + FLOAT = 3, + STRING = 4, + WIDE_STRING = 5, + BYTE_ARRAY = 6, + UINT32 = 7, + KEYED_ARCHIVE = 8, + INT64 = 9, + UINT64 = 10, + VECTOR2 = 11, + VECTOR3 = 12, + VECTOR4 = 13, + MATRIX2 = 14, + MATRIX3 = 15, + MATRIX4 = 16, + COLOR = 17, + FASTNAME = 18, + AABBOX3 = 19, + FILEPATH = 20, + FLOAT64 = 21, + INT8 = 22, + UINT8 = 23, + INT16 = 24, + UINT16 = 25, + ARRAY = 27, + TRANSFORM = 29, +} + +export enum VertexAttribute { + VERTEX = 0, + NORMAL = 1, + COLOR = 2, + TEXCOORD0 = 3, + TEXCOORD1 = 4, + TEXCOORD2 = 5, + TEXCOORD3 = 6, + TANGENT = 7, + BINORMAL = 8, + HARD_JOINTINDEX = 9, + PIVOT4 = 10, + PIVOT_DEPRECATED = 11, + FLEXIBILITY = 12, + ANGLE_SIN_COS = 13, + JOINTINDEX = 14, + JOINTWEIGHT = 15, + CUBETEXCOORD0 = 16, + CUBETEXCOORD1 = 17, + CUBETEXCOORD2 = 18, + CUBETEXCOORD3 = 19, +} + +export class ScpgStream extends PrimitiveStream { + vectorN(size: number) { + return times(size, () => this.float()); + } + vector2() { + return this.vectorN(2); + } + vector3() { + return this.vectorN(3); + } + vector4() { + return this.vectorN(4); + } + + matrixN(size: number) { + return times(size, () => this.vectorN(size)); + } + matrix2() { + return this.matrixN(2); + } + matrix3() { + return this.matrixN(3); + } + matrix4() { + return this.matrixN(4); + } + + kaValue( + type: KAType = this.uint8(), + stringTable?: Record, + ): unknown { + switch (type) { + case KAType.NONE: + return undefined; + + case KAType.FILEPATH: + case KAType.STRING: { + if (stringTable) { + const id = this.uint32(); + return stringTable[id]; + } else { + const length = this.uint32(); + return this.consumeAscii(length); + } + } + + case KAType.BYTE_ARRAY: { + const length = this.uint32(); + return this.consumeByteArray(length); + } + + case KAType.FLOAT: + return this.float(); + + case KAType.ARRAY: { + const length = this.uint32(); + const value = []; + + for (let index = 0; index < length; index++) { + value.push(this.kaValue(undefined, stringTable)); + } + + return value; + } + + case KAType.KEYED_ARCHIVE: { + this.skip(4); // UInt32 length of the nested archive in bytes + + return this.ka(stringTable); + } + + case KAType.INT8: + return this.int8(); + case KAType.INT16: + return this.int16(); + case KAType.INT32: + return this.int32(); + case KAType.INT64: + return this.int64(); + case KAType.UINT8: + return this.uint8(); + case KAType.UINT16: + return this.uint16(); + case KAType.UINT32: + return this.uint32(); + case KAType.UINT64: + return this.uint64(); + + case KAType.BOOLEAN: + return this.boolean(); + + case KAType.VECTOR2: + return this.vector2(); + case KAType.VECTOR3: + return this.vector3(); + case KAType.VECTOR4: + return this.vector4(); + + case KAType.COLOR: + return this.vector4(); // as RGBA + + case KAType.MATRIX2: + return this.matrix2(); + case KAType.MATRIX3: + return this.matrix3(); + case KAType.MATRIX4: + return this.matrix4(); + + case KAType.AABBOX3: { + const minimum = this.vector3(); + const maximum = this.vector3(); + + return { minimum, maximum }; + } + + case KAType.FASTNAME: { + if (!stringTable) throw new Error('No string table provided'); + const index = this.uint32(); + return stringTable[index]; + } + + case KAType.TRANSFORM: { + const position = this.vector3(); + const scale = this.vector3(); + const rotation = this.vector4(); + + return { + position, + scale, + rotation, + }; + } + + default: + throw new TypeError( + `Unhandled KA type: ${type} (${KAType[type] ?? 'unknown type'})`, + ); + } + } + ka(stringTable?: Record) { + this.skip(2); // "KA" + const version = this.uint16(); + const pairs: Record = {}; + + if (version === 0x0001) { + const count = this.uint32(); + + for (let index = 0; index < count; index++) { + const name = this.kaValue(undefined, stringTable) as string; + const value = this.kaValue(undefined, stringTable); + + pairs[name] = value; + } + } else if (version === 0x0002) { + const count = this.uint32(); + const strings: string[] = []; + const stringTable: Record = {}; + + for (let index = 0; index < count; index++) { + const length = this.uint16(); + const string = this.consumeAscii(length); + + strings.push(string); + } + + for (let index = 0; index < count; index++) { + const id = this.uint32(); + stringTable[id] = strings[index]; + } + + const nodeCount = this.uint32(); + + for (let index = 0; index < nodeCount; index++) { + const keyId = this.uint32(); + const value = this.kaValue(undefined, stringTable); + const key = stringTable[keyId]; + + pairs[key] = value; + } + } else if (version === 0x0102) { + if (!stringTable) throw new Error('No string table provided'); + + const count = this.uint32(); + + for (let index = 0; index < count; index++) { + const keyIndex = this.uint32(); + const key = stringTable[keyIndex]; + const valueType = this.uint8(); + + if (valueType === KAType.STRING) { + const valueIndex = this.uint32(); + const value = stringTable[valueIndex]; + pairs[key] = value; + } else { + const value = this.kaValue(valueType, stringTable); + pairs[key] = value; + } + } + } else if (version === 0xff02) { + // same as 0x0102 but empty and with no count + return {}; + } else throw new RangeError(`Unhandled KA version: ${version}`); + + return pairs; + } +} diff --git a/src/core/streams/windows.ts b/src/core/streams/windows.ts new file mode 100644 index 000000000..61e1d8fc2 --- /dev/null +++ b/src/core/streams/windows.ts @@ -0,0 +1,3 @@ +import { PrimitiveStream } from './primitive'; + +export class WindowsStream extends PrimitiveStream {}