diff --git a/bun.lock b/bun.lock index 3bc4bf582..ff4e2722a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "blitzkit", "dependencies": { + "@gltf-transform/extensions": "^4.4.2", "lz4js": "^0.2.0", "sharp": "^0.34.5", "typescript": "^5.9.3", @@ -106,6 +107,8 @@ "@blitzkit/core": "workspace:*", "@blitzkit/i18n": "workspace:*", "@fontsource/inter": "^5.2.8", + "@gltf-transform/core": "^4.4.2", + "@gltf-transform/functions": "^4.4.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.2.1", @@ -455,7 +458,7 @@ "@gltf-transform/core": ["@gltf-transform/core@4.2.1", "", { "dependencies": { "property-graph": "^3.0.0" } }, "sha512-qKhrQ29KJ9K6sz7xGoGRllqPBlROjPJMQPvCLBOfgMPH3S/Ph5E0e6fD5WmwTY5QPyBsDMRqKd1Vl0ncThTXGw=="], - "@gltf-transform/extensions": ["@gltf-transform/extensions@4.2.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.1", "ktx-parse": "^1.0.1" } }, "sha512-ieHSJU9qvb3ucWDxgHFcff+C7fLtFPa6G0Wtc/ki7GsN6Rh7o9eoyqi7Ggj2LO4i3ynCwLLPb/onbJKw2fBtsA=="], + "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="], "@gltf-transform/functions": ["@gltf-transform/functions@4.2.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.1", "@gltf-transform/extensions": "^4.2.1", "ktx-parse": "^1.0.1", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.0.1" } }, "sha512-hFCI80N6zt7yHlqXbzlYEYQbFwK+2OOZNvacrRtrWiCWKI0k1Ad5+nZFsmuIZZV03aXb5XG6UF7JINT3wETS1Q=="], @@ -2521,6 +2524,16 @@ "@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@blitzkit/scripts/@gltf-transform/extensions": ["@gltf-transform/extensions@4.2.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.1", "ktx-parse": "^1.0.1" } }, "sha512-ieHSJU9qvb3ucWDxgHFcff+C7fLtFPa6G0Wtc/ki7GsN6Rh7o9eoyqi7Ggj2LO4i3ynCwLLPb/onbJKw2fBtsA=="], + + "@blitzkit/website/@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], + + "@blitzkit/website/@gltf-transform/functions": ["@gltf-transform/functions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "@gltf-transform/extensions": "^4.4.2", "ktx-parse": "^1.1.0", "ndarray": "^1.0.19", "ndarray-lanczos": "^0.3.0", "ndarray-pixels": "^5.0.1" } }, "sha512-dclXgv9TshMaWBqPDUYd4xTwBQ2PpuR8p0Y9pokrRzGQDUPXRP6lTDzbqT0UmEmxFSvRyPJjvOWUmzeiRafpvw=="], + + "@gltf-transform/extensions/@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], + + "@gltf-transform/functions/@gltf-transform/extensions": ["@gltf-transform/extensions@4.2.1", "", { "dependencies": { "@gltf-transform/core": "^4.2.1", "ktx-parse": "^1.0.1" } }, "sha512-ieHSJU9qvb3ucWDxgHFcff+C7fLtFPa6G0Wtc/ki7GsN6Rh7o9eoyqi7Ggj2LO4i3ynCwLLPb/onbJKw2fBtsA=="], + "@jimp/core/file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], "@octokit/endpoint/@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], @@ -2701,6 +2714,10 @@ "yaml-language-server/yaml": ["yaml@2.2.2", "", {}, "sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA=="], + "@blitzkit/website/@gltf-transform/core/property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + + "@gltf-transform/extensions/@gltf-transform/core/property-graph": ["property-graph@4.1.0", "", {}, "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="], + "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], "@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], diff --git a/package.json b/package.json index 9dd548e13..7205267e1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "packages/*" ], "dependencies": { + "@gltf-transform/extensions": "^4.4.2", "lz4js": "^0.2.0", "sharp": "^0.34.5", "typescript": "^5.9.3" diff --git a/packages/core/src/blitzkit/api/client.ts b/packages/core/src/blitzkit/api/client.ts index 8ba62e128..38b57c408 100644 --- a/packages/core/src/blitzkit/api/client.ts +++ b/packages/core/src/blitzkit/api/client.ts @@ -2,9 +2,11 @@ import { fetchPB } from "../../protobuf"; import { CamouflageDefinitions, ConsumableDefinitions, + EquipmentDefinitions, GameDefinitions, ModelDefinitions, ProvisionDefinitions, + SkillDefinitions, TankDefinitions, } from "../../protos"; import { BlitzKitAPI } from "./base"; @@ -33,4 +35,12 @@ export class ClientBlitzKitAPI extends BlitzKitAPI { modelDefinitions() { return fetchPB("/api/definitions/models.pb", ModelDefinitions); } + + skillDefinitions() { + return fetchPB("/api/definitions/skills.pb", SkillDefinitions); + } + + equipmentDefinitions() { + return fetchPB("/api/definitions/equipment.pb", EquipmentDefinitions); + } } diff --git a/packages/scripts/src/buildAssets/tankArmors.ts b/packages/scripts/src/buildAssets/tankArmors.ts deleted file mode 100644 index 1deb46514..000000000 --- a/packages/scripts/src/buildAssets/tankArmors.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { toUniqueId } from "@blitzkit/core"; -import { NodeIO } from "@gltf-transform/core"; -import { extractArmor } from "../core/blitz/extractArmor"; -import { AssetUploader } from "../core/github/assetUploader"; -import { vfs } from "./constants"; -import { VehicleDefinitionList } from "./definitions"; - -export async function tankArmors() { - console.log("Building tank armors..."); - - using uploader = new AssetUploader("tank armors"); - const nodeIO = new NodeIO(); - const nations = await vfs - .dir(`Data/XML/item_defs/vehicles`) - .then((files) => files.filter((nation) => nation !== "common")); - - for (const nation of nations) { - const tanks = await vfs.xml<{ root: VehicleDefinitionList }>( - `Data/XML/item_defs/vehicles/${nation}/list.xml`, - ); - - for (const tankKey in tanks.root) { - const tank = tanks.root[tankKey]; - - if (tankKey.includes("tutorial_bot")) continue; - - const id = toUniqueId(nation, tank.id); - const model = await extractArmor(vfs, `${nation}-${tankKey}`); - const content = await nodeIO.writeBinary(model); - - await uploader.add({ path: `3d/tanks/armor/${id}.glb`, content }); - } - } - - await uploader.flush(); -} diff --git a/packages/scripts/src/core/blitz/extractModel/constants.ts b/packages/scripts/src/core/blitz/extractModel/constants.ts deleted file mode 100644 index 07775a4d2..000000000 --- a/packages/scripts/src/core/blitz/extractModel/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { VertexAttribute, vertexAttributeVectorSizes } from "@blitzkit/core"; - -export const vertexAttributeGLTFName: Partial> = - { - [VertexAttribute.VERTEX]: "POSITION", - [VertexAttribute.NORMAL]: "NORMAL", - [VertexAttribute.TEXCOORD0]: "TEXCOORD_0", - [VertexAttribute.TEXCOORD1]: "TEXCOORD_1", - [VertexAttribute.TEXCOORD2]: "TEXCOORD_2", - [VertexAttribute.TEXCOORD3]: "TEXCOORD_3", - }; -export const vertexAttributeGltfVectorSizes = { - ...vertexAttributeVectorSizes, - [VertexAttribute.TANGENT]: 4, -} as const; diff --git a/packages/scripts/src/core/blitz/readBaseColor.ts b/packages/scripts/src/core/blitz/readBaseColor.ts deleted file mode 100644 index 50647add3..000000000 --- a/packages/scripts/src/core/blitz/readBaseColor.ts +++ /dev/null @@ -1,50 +0,0 @@ -import sharp from "sharp"; -import { readTexture } from "./readTexture"; - -export async function readBaseColor(path: string, occlusionPath?: string) { - let hasAlpha = false; - const baseRaw = await readTexture(path); - const occlusionRaw = occlusionPath - ? await readTexture(occlusionPath) - : undefined; - const base = await sharp(baseRaw.data, { raw: baseRaw }).raw().toBuffer(); - const occlusion = occlusionRaw - ? await sharp(occlusionRaw.data, { raw: occlusionRaw }) - .extractChannel(3) - .raw() - .toBuffer() - : undefined; - - const combined = Buffer.alloc(baseRaw.width * baseRaw.height * 4); - - for (let i = 0; i < baseRaw.width * baseRaw.height; i++) { - let c = 1; - - if (occlusionRaw) { - const x = i % baseRaw.width; - const y = Math.floor(i / baseRaw.width); - const u = Math.floor(x * (occlusionRaw.width / baseRaw.width)); - const v = Math.floor(y * (occlusionRaw.height / baseRaw.height)); - - const occlusionI = u + v * occlusionRaw.width; - - c = occlusion![occlusionI] / 255; - } - - const alpha = base[i * 4 + 3]; - hasAlpha ||= alpha < 255; - - combined[i * 4 + 0] = Math.round(base[i * 4 + 0] * c); - combined[i * 4 + 1] = Math.round(base[i * 4 + 1] * c); - combined[i * 4 + 2] = Math.round(base[i * 4 + 2] * c); - combined[i * 4 + 3] = alpha; - } - - const image = await sharp(combined, { - raw: { width: baseRaw.width, height: baseRaw.height, channels: 4 }, - }) - .webp() - .toBuffer(); - - return { hasAlpha, image }; -} diff --git a/packages/scripts/src/core/blitz/readNormal.ts b/packages/scripts/src/core/blitz/readNormal.ts deleted file mode 100644 index 8744e4e7e..000000000 --- a/packages/scripts/src/core/blitz/readNormal.ts +++ /dev/null @@ -1,29 +0,0 @@ -import sharp from "sharp"; -import { readTexture } from "./readTexture"; - -export async function readNormal(path: string, isBase: boolean) { - const raw = await readTexture(path); - - if (!isBase) { - return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer(); - } - - const bytes = 4 * raw.width * raw.height; - - for (let index = 0; index < bytes; index += 4) { - /** - * Red is always 255 and blue is always 0. Only alpha and green contain any - * sort of information. - */ - let x = raw.data[index + 3] * (2 / 255) - 1; - let y = raw.data[index + 1] * (2 / 255) - 1; - let z = Math.sqrt(Math.max(0, 1 - x ** 2 - y ** 2)); - - raw.data[index] = Math.round((x + 1) * (255 / 2)); - raw.data[index + 1] = Math.round((y + 1) * (255 / 2)); - raw.data[index + 2] = Math.round((z + 1) * (255 / 2)); - raw.data[index + 3] = 255; - } - - return await sharp(raw.data, { raw }).webp().toBuffer(); -} diff --git a/packages/scripts/src/core/blitz/readRoughnessMetallic.ts b/packages/scripts/src/core/blitz/readRoughnessMetallic.ts deleted file mode 100644 index 6c56ec43a..000000000 --- a/packages/scripts/src/core/blitz/readRoughnessMetallic.ts +++ /dev/null @@ -1,24 +0,0 @@ -import sharp from "sharp"; -import { readTexture } from "./readTexture"; - -export async function readRoughnessMetallic(path: string) { - const raw = await readTexture(path); - const blitz = sharp(raw.data, { raw: raw }); - const metallicness = await blitz?.extractChannel(1).raw().toBuffer(); - const roughness = await blitz?.extractChannel(3).raw().toBuffer(); - - const combined = Buffer.alloc(raw.width * raw.height * 3); - - for (let i = 0; i < raw.width * raw.height; i++) { - combined[i * 3 + 1] = roughness[i]; - combined[i * 3 + 2] = metallicness[i]; - } - - const image = await sharp(combined, { - raw: { width: raw.width, height: raw.height, channels: 3 }, - }) - .webp() - .toBuffer(); - - return image; -} diff --git a/packages/scripts/src/core/blitz/readTexture/constants.ts b/packages/scripts/src/core/blitz/readTexture/constants.ts deleted file mode 100644 index 560a59f5f..000000000 --- a/packages/scripts/src/core/blitz/readTexture/constants.ts +++ /dev/null @@ -1,6 +0,0 @@ -export enum TextureMutation { - Normal, - RoughnessMetallicness, - Albedo, - Miscellaneous, -} diff --git a/packages/scripts/src/core/blitz/readTexture/index.ts b/packages/scripts/src/core/blitz/readTexture/index.ts deleted file mode 100644 index 605c41640..000000000 --- a/packages/scripts/src/core/blitz/readTexture/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { DdsReadStream, PvrReadStream } from "@blitzkit/core"; -import { vfs } from "../../../buildAssets/constants"; - -export async function readTexture(path: string) { - const ddsTexturePath = path.replace(".tex", ".dx11.dds"); - const isDds = await vfs.resolve(ddsTexturePath); - const resolvedTexturePath = isDds - ? ddsTexturePath - : ddsTexturePath.replace(".dds", ".pvr"); - const file = await vfs.file(resolvedTexturePath); - - const raw = isDds - ? await new DdsReadStream(file.buffer as ArrayBuffer).dds() - : new PvrReadStream(file.buffer as ArrayBuffer).pvr(); - - return raw; -} diff --git a/packages/website/package.json b/packages/website/package.json index a32568a55..8ad371933 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -16,6 +16,8 @@ "@blitzkit/core": "workspace:*", "@blitzkit/i18n": "workspace:*", "@fontsource/inter": "^5.2.8", + "@gltf-transform/core": "^4.4.2", + "@gltf-transform/functions": "^4.4.2", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.2.1", diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx index 6da4bfb18..6a5bef873 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/DynamicArmorSwitcher.tsx @@ -1,4 +1,3 @@ -import { asset } from "@blitzkit/core"; import { IconButton } from "@radix-ui/themes"; import { useArmor } from "../../../../../../hooks/useArmor"; import { Duel } from "../../../../../../stores/duel"; @@ -7,7 +6,7 @@ import { Tankopedia } from "../../../../../../stores/tankopedia"; export function DynamicArmorSwitcher() { const tank = Duel.use((state) => state.protagonist.tank); const isDynamicArmorActive = Duel.use((state) => - state.protagonist.consumables.includes(73) + state.protagonist.consumables.includes(73), ); const { hasDynamicArmor } = useArmor(tank.id); @@ -38,7 +37,7 @@ export function DynamicArmorSwitcher() { > Dynamic Armor {unwrap(tank.name!)} XP Silver Silver) { + const nodeIO = new NodeIO(); + const nations = await vfs + .dir(`Data/XML/item_defs/vehicles`) + .then((files) => files.filter((nation) => nation !== "common")); + + for (const nation of nations) { + const tanks = await vfs.xml<{ root: VehicleDefinitionList }>( + `Data/XML/item_defs/vehicles/${nation}/list.xml`, + ); + + for (const tankKey in tanks.root) { + const tank = tanks.root[tankKey]; + + if (tankKey.includes("tutorial_bot")) continue; + + const id = toUniqueId(nation, tank.id); + + if (id !== props.id) continue; + + const model = await extractArmor(vfs, `${nation}-${tankKey}`); + const bytes = await nodeIO.writeBinary(model); + + return new Response(bytes); + } + } +} + +async function extractArmor(vfs: AbstractVFS, fileName: string) { const sc2Path = `Data/3d/Tanks/CollisionMeshes/${fileName}.sc2`; const scgPath = `Data/3d/Tanks/CollisionMeshes/${fileName}.scg`; const sc2 = new Sc2ReadStream( @@ -141,4 +172,18 @@ export async function extractArmor(vfs: AbstractVFS, fileName: string) { parseHierarchies(sc2["#hierarchy"], scene); return document; -} \ No newline at end of file +} + +export const vertexAttributeGLTFName: Partial> = + { + [VertexAttribute.VERTEX]: "POSITION", + [VertexAttribute.NORMAL]: "NORMAL", + [VertexAttribute.TEXCOORD0]: "TEXCOORD_0", + [VertexAttribute.TEXCOORD1]: "TEXCOORD_1", + [VertexAttribute.TEXCOORD2]: "TEXCOORD_2", + [VertexAttribute.TEXCOORD3]: "TEXCOORD_3", + }; +export const vertexAttributeGltfVectorSizes = { + ...vertexAttributeVectorSizes, + [VertexAttribute.TANGENT]: 4, +} as const; diff --git a/packages/scripts/src/core/blitz/extractModel/index.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts similarity index 65% rename from packages/scripts/src/core/blitz/extractModel/index.ts rename to packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts index 3139a8608..6c7471e0c 100644 --- a/packages/scripts/src/core/blitz/extractModel/index.ts +++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts @@ -1,24 +1,66 @@ import { bufferToBigInt, - ConfigArchive, - Hierarchy, + DdsReadStream, + PvrReadStream, Sc2ReadStream, ScgReadStream, - Textures, + toUniqueId, VertexAttribute, + type ConfigArchive, + type Hierarchy, + type TankParameters, + type Textures, + type VehicleDefinitionList, } from "@blitzkit/core"; -import { Document, Material, Node, Scene } from "@gltf-transform/core"; +import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; +import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { dedup, prune } from "@gltf-transform/functions"; +import type { APIContext } from "astro"; import { times } from "lodash-es"; import { dirname } from "path"; -import { AbstractVFS } from "../../vfs/abstract"; -import { readBaseColor } from "../readBaseColor"; -import { readNormal } from "../readNormal"; -import { readRoughnessMetallic } from "../readRoughnessMetallic"; +import sharp from "sharp"; +import { vfs } from "../../../../core/blitzkit/vfs"; import { vertexAttributeGLTFName, vertexAttributeGltfVectorSizes, -} from "./constants"; +} from "./collision.glb"; + +export { getStaticPaths } from "./_index"; + +export async function GET({ props }: APIContext<{ id: number }>) { + const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS); + const nations = await vfs + .dir(`Data/XML/item_defs/vehicles`) + .then((files) => files.filter((nation) => nation !== "common")); + + for (const nationIndex in nations) { + const nation = nations[nationIndex]; + const tanks = await vfs.xml<{ root: VehicleDefinitionList }>( + `Data/XML/item_defs/vehicles/${nation}/list.xml`, + ); + const entries = Object.entries(tanks.root); + + for (const [tankKey, tank] of entries) { + if (tankKey.includes("tutorial_bot")) continue; + + const id = toUniqueId(nation, tank.id); + + if (id !== props.id) continue; + + const parameters = await vfs.yaml( + `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, + ); + const model = await extractModel( + vfs, + parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ""), + ); + const bytes = await nodeIO.writeBinary(model); + + return new Response(bytes); + } + } +} const ERROR_ON_UNKNOWN_COMPONENT = false; @@ -27,7 +69,7 @@ const omitMeshNames = { end: ["_POINT"], }; -export async function extractModel(vfs: AbstractVFS, path: string) { +async function extractModel(vfs: AbstractVFS, path: string) { const sc2Path = `Data/3d/${path}.sc2`; const scgPath = `Data/3d/${path}.scg`; const sc2 = new Sc2ReadStream( @@ -325,3 +367,115 @@ export async function extractModel(vfs: AbstractVFS, path: string) { return document; } + +async function readBaseColor(path: string, occlusionPath?: string) { + let hasAlpha = false; + const baseRaw = await readTexture(path); + const occlusionRaw = occlusionPath + ? await readTexture(occlusionPath) + : undefined; + const base = await sharp(baseRaw.data, { raw: baseRaw }).raw().toBuffer(); + const occlusion = occlusionRaw + ? await sharp(occlusionRaw.data, { raw: occlusionRaw }) + .extractChannel(3) + .raw() + .toBuffer() + : undefined; + + const combined = Buffer.alloc(baseRaw.width * baseRaw.height * 4); + + for (let i = 0; i < baseRaw.width * baseRaw.height; i++) { + let c = 1; + + if (occlusionRaw) { + const x = i % baseRaw.width; + const y = Math.floor(i / baseRaw.width); + const u = Math.floor(x * (occlusionRaw.width / baseRaw.width)); + const v = Math.floor(y * (occlusionRaw.height / baseRaw.height)); + + const occlusionI = u + v * occlusionRaw.width; + + c = occlusion![occlusionI] / 255; + } + + const alpha = base[i * 4 + 3]; + hasAlpha ||= alpha < 255; + + combined[i * 4 + 0] = Math.round(base[i * 4 + 0] * c); + combined[i * 4 + 1] = Math.round(base[i * 4 + 1] * c); + combined[i * 4 + 2] = Math.round(base[i * 4 + 2] * c); + combined[i * 4 + 3] = alpha; + } + + const image = await sharp(combined, { + raw: { width: baseRaw.width, height: baseRaw.height, channels: 4 }, + }) + .webp() + .toBuffer(); + + return { hasAlpha, image }; +} + +async function readRoughnessMetallic(path: string) { + const raw = await readTexture(path); + const blitz = sharp(raw.data, { raw: raw }); + const metallicness = await blitz?.extractChannel(1).raw().toBuffer(); + const roughness = await blitz?.extractChannel(3).raw().toBuffer(); + + const combined = Buffer.alloc(raw.width * raw.height * 3); + + for (let i = 0; i < raw.width * raw.height; i++) { + combined[i * 3 + 1] = roughness[i]; + combined[i * 3 + 2] = metallicness[i]; + } + + const image = await sharp(combined, { + raw: { width: raw.width, height: raw.height, channels: 3 }, + }) + .webp() + .toBuffer(); + + return image; +} + +async function readNormal(path: string, isBase: boolean) { + const raw = await readTexture(path); + + if (!isBase) { + return await sharp(raw.data, { raw }).removeAlpha().jpeg().toBuffer(); + } + + const bytes = 4 * raw.width * raw.height; + + for (let index = 0; index < bytes; index += 4) { + /** + * Red is always 255 and blue is always 0. Only alpha and green contain any + * sort of information. + */ + let x = raw.data[index + 3] * (2 / 255) - 1; + let y = raw.data[index + 1] * (2 / 255) - 1; + let z = Math.sqrt(Math.max(0, 1 - x ** 2 - y ** 2)); + + raw.data[index] = Math.round((x + 1) * (255 / 2)); + raw.data[index + 1] = Math.round((y + 1) * (255 / 2)); + raw.data[index + 2] = Math.round((z + 1) * (255 / 2)); + raw.data[index + 3] = 255; + } + + return await sharp(raw.data, { raw }).webp().toBuffer(); +} + +async function readTexture(path: string) { + const ddsTexturePath = path.replace(".tex", ".dx11.dds"); + const isDds = await vfs.resolve(ddsTexturePath); + const resolvedTexturePath = isDds + ? ddsTexturePath + : ddsTexturePath.replace(".dds", ".pvr"); + const file = await vfs.file(resolvedTexturePath); + + const raw = isDds + ? await new DdsReadStream(file.buffer as ArrayBuffer).dds() + : new PvrReadStream(file.buffer as ArrayBuffer).pvr(); + + return raw; +}