tank model and collision

This commit is contained in:
tresabhi committed 2026-08-04 20:18:05 -05:00
1 parent 9968b2c83d
commit 9e4d9c7968
19 files changed
+278 -206

No files matched your search

+10
View File
@@ -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);
}
}
@@ -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();
}
@@ -1,15 +0,0 @@
import { VertexAttribute, vertexAttributeVectorSizes } from "@blitzkit/core";
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;
@@ -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 };
}
@@ -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();
}
@@ -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;
}
@@ -1,6 +0,0 @@
export enum TextureMutation {
Normal,
RoughnessMetallicness,
Albedo,
Miscellaneous,
}
@@ -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;
}
+2
View File
@@ -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",
@@ -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() {
>
<img
alt="Dynamic Armor"
src={asset("icons/consumables/73.webp")}
src={"/api/icons/consumables/73.webp"}
style={{
width: "50%",
height: "50%",
@@ -79,7 +79,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
>
<img
alt={unwrap(tank.name!)}
src={asset(`icons/tanks/big/${id}.webp`)}
src={`/api/tanks/${id}/icons/big.webp`}
width={64}
height={64}
style={{
@@ -99,7 +99,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
<Flex gap="1" align="center">
<img
alt="XP"
src={asset("icons/currencies/xp.webp")}
src={"/api/icons/currencies/xp.webp"}
style={{
width: "1em",
height: "1em",
@@ -114,7 +114,7 @@ export function Node({ id, highlight, nextIds, skeleton }: NodeProps) {
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset("icons/currencies/silver.webp")}
src={"/api/icons/currencies/silver.webp"}
style={{
width: "1em",
height: "1em",
@@ -121,7 +121,7 @@ export function TechTreeSection({ skeleton }: MaybeSkeletonComponentProps) {
<Flex gap="1" align="center">
<img
alt="Silver"
src={asset("icons/currencies/silver.webp")}
src={"/api/icons/currencies/silver.webp"}
style={{
width: "1em",
height: "1em",
+1 -2
View File
@@ -1,10 +1,9 @@
import { asset } from "@blitzkit/core";
import { useLoader } from "@react-three/fiber";
import { GLTFLoader } from "three-stdlib";
import { useDispose } from "./useDispose";
export function useArmor(id: number) {
const path = asset(`3d/tanks/armor/${id}.glb`);
const path = `/api/tanks/${id}/collision.glb`;
const gltf = useLoader(GLTFLoader, path);
useDispose(gltf, path);
@@ -0,0 +1,11 @@
import { EquipmentDefinitions } from "@blitzkit/core";
import { api } from "../../../core/blitzkit/api";
export { getStaticPaths } from "../_index";
export async function GET() {
const definitions = await api.equipmentDefinitions();
const bytes = EquipmentDefinitions.encode(definitions).finish();
return new Response(bytes);
}
@@ -0,0 +1,11 @@
import { SkillDefinitions } from "@blitzkit/core";
import { api } from "../../../core/blitzkit/api";
export { getStaticPaths } from "../_index";
export async function GET() {
const definitions = await api.skillDefinitions();
const bytes = SkillDefinitions.encode(definitions).finish();
return new Response(bytes);
}
@@ -1,18 +1,49 @@
import {
Hierarchy,
Sc2ReadStream,
ScgReadStream,
toUniqueId,
VertexAttribute,
vertexAttributeVectorSizes,
type Hierarchy,
type VehicleDefinitionList,
} from "@blitzkit/core";
import { Accessor, Document, Node, Scene } from "@gltf-transform/core";
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core";
import type { APIContext } from "astro";
import { times } from "lodash-es";
import { AbstractVFS } from "../vfs/abstract";
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
} from "./extractModel/constants";
import { vfs } from "../../../../core/blitzkit/vfs";
export async function extractArmor(vfs: AbstractVFS, fileName: string) {
export { getStaticPaths } from "./_index";
export async function GET({ props }: APIContext<{ id: number }>) {
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;
}
}
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;
@@ -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<TankParameters>(
`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;
}