mirror of
https://codeberg.org/icewind/vbsp-to-gltf.git
synced 2026-06-03 10:14:08 +02:00
bsp geometry
This commit is contained in:
commit
304dc2d031
13 changed files with 3380 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
64
.github/workflows/ci.yml
vendored
Normal file
64
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
name: "CI"
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v20
|
||||
- uses: icewind1991/attic-action@v1
|
||||
with:
|
||||
name: ci
|
||||
instance: https://cache.icewind.me
|
||||
authToken: '${{ secrets.ATTIC_TOKEN }}'
|
||||
- run: nix build .#check
|
||||
|
||||
clippy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v20
|
||||
- uses: icewind1991/attic-action@v1
|
||||
with:
|
||||
name: ci
|
||||
instance: https://cache.icewind.me
|
||||
authToken: '${{ secrets.ATTIC_TOKEN }}'
|
||||
- run: nix build .#clippy
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v20
|
||||
- uses: icewind1991/attic-action@v1
|
||||
with:
|
||||
name: ci
|
||||
instance: https://cache.icewind.me
|
||||
authToken: '${{ secrets.ATTIC_TOKEN }}'
|
||||
- run: nix build .#test
|
||||
|
||||
semver:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Check semver
|
||||
uses: obi1kenobi/cargo-semver-checks-action@v2
|
||||
|
||||
msrv:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: cachix/install-nix-action@v20
|
||||
- uses: icewind1991/attic-action@v1
|
||||
with:
|
||||
name: ci
|
||||
instance: https://cache.icewind.me
|
||||
authToken: '${{ secrets.ATTIC_TOKEN }}'
|
||||
- run: nix build .#msrv
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
target
|
||||
*.bench
|
||||
*.obj
|
||||
result
|
||||
.direnv
|
||||
*.snap.new
|
||||
*.bsp
|
||||
*.glb
|
||||
2286
Cargo.lock
generated
Normal file
2286
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
25
Cargo.toml
Normal file
25
Cargo.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "vbsp-to-gltf"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
|
||||
[dependencies]
|
||||
miette = { version = "5.5.0", features = ["fancy"] }
|
||||
vbsp = "0.4.0"
|
||||
thiserror = "1.0.37"
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||
tracing-tree = "0.2.2"
|
||||
vtf = "0.1.6"
|
||||
vmt-parser = { version = "0.1", git = "https://github.com/icewind1991/vmt-parser" }
|
||||
image = "0.23.14"
|
||||
tf-asset-loader = { version = "0.1", features = ["bsp"] }
|
||||
vmdl = { version = "*", git = "https://github.com/icewind1991/vmdl" }
|
||||
clap = { version = "4.0.29", features = ["derive"] }
|
||||
gltf-json = "=1.3.0"
|
||||
gltf = "=1.3.0"
|
||||
bytemuck = { version = "1.14.0", features = ["derive"] }
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
105
flake.lock
generated
Normal file
105
flake.lock
generated
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"nodes": {
|
||||
"naersk": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1698420672,
|
||||
"narHash": "sha256-/TdeHMPRjjdJub7p7+w55vyABrsJlt5QkznPYy55vKA=",
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"rev": "aeb58d5e8faead8980a807c840232697982d47b9",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-community",
|
||||
"repo": "naersk",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1702346276,
|
||||
"narHash": "sha256-eAQgwIWApFQ40ipeOjVSoK4TEHVd6nbSd9fApiHIw5A=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "cf28ee258fd5f9a52de6b9865cdb93a1f96d09b7",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"id": "nixpkgs",
|
||||
"ref": "nixos-23.11",
|
||||
"type": "indirect"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"naersk": "naersk",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay",
|
||||
"utils": "utils"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": [
|
||||
"utils"
|
||||
],
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1702520151,
|
||||
"narHash": "sha256-jxJWosN7hgcW+dFT8V3EBDCYUOjv5tpjEBRmlakS7tU=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "d6a1d8f80dbcda4c13993b859a3574c3dde61072",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1701680307,
|
||||
"narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "4022d587cbbfd70fe950c1e2083a02621806a725",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
102
flake.nix
Normal file
102
flake.nix
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "nixpkgs/nixos-23.11";
|
||||
utils.url = "github:numtide/flake-utils";
|
||||
naersk.url = "github:nix-community/naersk";
|
||||
naersk.inputs.nixpkgs.follows = "nixpkgs";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
rust-overlay.inputs.flake-utils.follows = "utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
utils,
|
||||
naersk,
|
||||
rust-overlay,
|
||||
}:
|
||||
utils.lib.eachDefaultSystem (system: let
|
||||
overlays = [(import rust-overlay)];
|
||||
pkgs = (import nixpkgs) {
|
||||
inherit system overlays;
|
||||
};
|
||||
inherit (pkgs) lib callPackage rust-bin mkShell;
|
||||
inherit (lib.sources) sourceByRegex;
|
||||
|
||||
msrv = (fromTOML (readFile ./Cargo.toml)).package.rust-version;
|
||||
inherit (builtins) fromTOML readFile;
|
||||
toolchain = rust-bin.stable.latest.default;
|
||||
msrvToolchain = rust-bin.stable."${msrv}".default;
|
||||
|
||||
naersk' = callPackage naersk {
|
||||
rustc = toolchain;
|
||||
cargo = toolchain;
|
||||
};
|
||||
msrvNaersk = callPackage naersk {
|
||||
rustc = msrvToolchain;
|
||||
cargo = msrvToolchain;
|
||||
};
|
||||
|
||||
src = sourceByRegex ./. ["Cargo.*" "(src|derive|benches|tests|examples|koth_bagel.*)(/.*)?"];
|
||||
nearskOpt = {
|
||||
pname = "vbsp";
|
||||
root = src;
|
||||
};
|
||||
in rec {
|
||||
packages = {
|
||||
check = naersk'.buildPackage (nearskOpt
|
||||
// {
|
||||
mode = "check";
|
||||
});
|
||||
clippy = naersk'.buildPackage (nearskOpt
|
||||
// {
|
||||
mode = "clippy";
|
||||
});
|
||||
test = naersk'.buildPackage (nearskOpt
|
||||
// {
|
||||
release = false;
|
||||
mode = "test";
|
||||
});
|
||||
msrv = msrvNaersk.buildPackage (nearskOpt
|
||||
// {
|
||||
mode = "check";
|
||||
});
|
||||
};
|
||||
|
||||
devShells = let
|
||||
tools = with pkgs; [
|
||||
bacon
|
||||
cargo-edit
|
||||
cargo-outdated
|
||||
cargo-audit
|
||||
cargo-msrv
|
||||
cargo-semver-checks
|
||||
cargo-insta
|
||||
(writeShellApplication {
|
||||
name = "cargo-fuzz";
|
||||
runtimeInputs = [cargo-fuzz toolchain];
|
||||
text = ''
|
||||
# shellcheck disable=SC2068
|
||||
RUSTC_BOOTSTRAP=1 cargo-fuzz $@
|
||||
'';
|
||||
})
|
||||
(writeShellApplication {
|
||||
name = "cargo-expand";
|
||||
runtimeInputs = [cargo-expand toolchain];
|
||||
text = ''
|
||||
# shellcheck disable=SC2068
|
||||
RUSTC_BOOTSTRAP=1 cargo-expand $@
|
||||
'';
|
||||
})
|
||||
];
|
||||
in {
|
||||
default = mkShell {
|
||||
nativeBuildInputs = [toolchain] ++ tools;
|
||||
};
|
||||
msrv = mkShell {
|
||||
nativeBuildInputs = [msrvToolchain] ++ tools;
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
195
src/bsp.rs
Normal file
195
src/bsp.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
use crate::gltf_builder::push_or_get_material;
|
||||
use crate::{map_coords, Error};
|
||||
use bytemuck::{offset_of, Pod, Zeroable};
|
||||
use gltf_json::accessor::{ComponentType, GenericComponentType, Type};
|
||||
use gltf_json::buffer::{Target, View};
|
||||
use gltf_json::mesh::{Mode, Primitive, Semantic};
|
||||
use gltf_json::validation::Checked::Valid;
|
||||
use gltf_json::{Accessor, Index, Mesh, Node, Root, Value};
|
||||
use std::mem::size_of;
|
||||
use tf_asset_loader::Loader;
|
||||
use vbsp::{Bsp, Entity, Face, Handle, Model, Vector};
|
||||
|
||||
pub fn bsp_models(bsp: &Bsp) -> Result<Vec<(Handle<Model>, Vector)>, Error> {
|
||||
let world_model = bsp
|
||||
.models()
|
||||
.next()
|
||||
.ok_or(Error::Other("No world model".into()))?;
|
||||
|
||||
let mut models: Vec<_> = bsp
|
||||
.entities
|
||||
.iter()
|
||||
.flat_map(|ent| ent.parse())
|
||||
.filter_map(|ent| match ent {
|
||||
Entity::Brush(ent)
|
||||
| Entity::BrushIllusionary(ent)
|
||||
| Entity::BrushWall(ent)
|
||||
| Entity::BrushWallToggle(ent) => Some(ent),
|
||||
_ => None,
|
||||
})
|
||||
.flat_map(|brush| Some((brush.model[1..].parse::<usize>().ok()?, brush.origin)))
|
||||
.flat_map(|(index, origin)| Some((bsp.models().nth(index)?, origin)))
|
||||
.collect();
|
||||
models.push((
|
||||
world_model,
|
||||
Vector {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
z: 0.0,
|
||||
},
|
||||
));
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn bounding_box<'a>(vertices: impl IntoIterator<Item = Vector>) -> ([f32; 3], [f32; 3]) {
|
||||
let mut min = Vector::from([f32::MAX, f32::MAX, f32::MAX]);
|
||||
let mut max = Vector::from([f32::MIN, f32::MIN, f32::MIN]);
|
||||
|
||||
for point in vertices {
|
||||
min.x = f32::min(min.x, point.x);
|
||||
min.y = f32::min(min.y, point.y);
|
||||
min.z = f32::min(min.z, point.z);
|
||||
|
||||
max.x = f32::max(max.x, point.x);
|
||||
max.y = f32::max(max.y, point.y);
|
||||
max.z = f32::max(max.z, point.z);
|
||||
}
|
||||
(min.into(), max.into())
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Zeroable, Pod)]
|
||||
#[repr(C)]
|
||||
pub struct BspVertexData {
|
||||
position: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
}
|
||||
|
||||
pub fn push_bsp_model(
|
||||
buffer: &mut Vec<u8>,
|
||||
gltf: &mut Root,
|
||||
loader: &Loader,
|
||||
model: &Handle<Model>,
|
||||
offset: Vector,
|
||||
) -> Node {
|
||||
let primitives = model
|
||||
.faces()
|
||||
.filter(|face| face.is_visible())
|
||||
.map(|face| push_bsp_face(buffer, gltf, loader, &face))
|
||||
.collect();
|
||||
|
||||
let mesh = Mesh {
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
name: None,
|
||||
primitives,
|
||||
weights: None,
|
||||
};
|
||||
|
||||
let mesh_index = gltf.meshes.len() as u32;
|
||||
gltf.meshes.push(mesh);
|
||||
|
||||
Node {
|
||||
camera: None,
|
||||
children: None,
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
matrix: None,
|
||||
mesh: Some(Index::new(mesh_index)),
|
||||
name: None,
|
||||
rotation: None,
|
||||
scale: None,
|
||||
translation: Some(map_coords(offset)),
|
||||
skin: None,
|
||||
weights: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_bsp_face(
|
||||
buffer: &mut Vec<u8>,
|
||||
gltf: &mut Root,
|
||||
loader: &Loader,
|
||||
face: &Handle<Face>,
|
||||
) -> Primitive {
|
||||
let vertex_count = face.vertex_positions().count() as u32;
|
||||
|
||||
let buffer_start = buffer.len() as u32;
|
||||
|
||||
let (min, max) = bounding_box(face.vertex_positions());
|
||||
|
||||
let texture = face.texture();
|
||||
let vertices = face.vertex_positions().map(move |pos| BspVertexData {
|
||||
position: map_coords(pos),
|
||||
uv: texture.uv(pos),
|
||||
});
|
||||
|
||||
let vertex_data = vertices.flat_map(bytemuck::cast::<_, [u8; size_of::<BspVertexData>()]>);
|
||||
buffer.extend(vertex_data);
|
||||
|
||||
let vertex_buffer_view = View {
|
||||
buffer: Index::new(0),
|
||||
byte_length: buffer.len() as u32 - buffer_start,
|
||||
byte_offset: Some(buffer_start),
|
||||
byte_stride: Some(size_of::<BspVertexData>() as u32),
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
name: None,
|
||||
target: Some(Valid(Target::ArrayBuffer)),
|
||||
};
|
||||
|
||||
let vertex_view = Index::new(gltf.buffer_views.len() as u32);
|
||||
gltf.buffer_views.push(vertex_buffer_view);
|
||||
|
||||
let positions = Accessor {
|
||||
buffer_view: Some(vertex_view),
|
||||
byte_offset: Some(offset_of!(BspVertexData, position) as u32),
|
||||
count: vertex_count,
|
||||
component_type: Valid(GenericComponentType(ComponentType::F32)),
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
type_: Valid(Type::Vec3),
|
||||
min: Some(Value::from(map_coords(min).to_vec())),
|
||||
max: Some(Value::from(map_coords(max).to_vec())),
|
||||
name: None,
|
||||
normalized: false,
|
||||
sparse: None,
|
||||
};
|
||||
let uvs = Accessor {
|
||||
buffer_view: Some(vertex_view),
|
||||
byte_offset: Some(offset_of!(BspVertexData, uv) as u32),
|
||||
count: vertex_count,
|
||||
component_type: Valid(GenericComponentType(ComponentType::F32)),
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
type_: Valid(Type::Vec2),
|
||||
min: None,
|
||||
max: None,
|
||||
name: None,
|
||||
normalized: false,
|
||||
sparse: None,
|
||||
};
|
||||
|
||||
let accessor_start = gltf.accessors.len() as u32;
|
||||
gltf.accessors.push(positions);
|
||||
gltf.accessors.push(uvs);
|
||||
|
||||
let material_index = push_or_get_material(buffer, gltf, loader, face.texture().name());
|
||||
|
||||
Primitive {
|
||||
attributes: {
|
||||
let mut map = std::collections::BTreeMap::new();
|
||||
map.insert(Valid(Semantic::Positions), Index::new(accessor_start));
|
||||
map.insert(
|
||||
Valid(Semantic::TexCoords(0)),
|
||||
Index::new(accessor_start + 1),
|
||||
);
|
||||
map
|
||||
},
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
indices: None,
|
||||
material: Some(material_index),
|
||||
mode: Valid(Mode::Triangles),
|
||||
targets: None,
|
||||
}
|
||||
}
|
||||
29
src/error.rs
Normal file
29
src/error.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use miette::Diagnostic;
|
||||
use std::string::FromUtf8Error;
|
||||
use tf_asset_loader::LoaderError;
|
||||
use thiserror::Error;
|
||||
use vmt_parser::VdfError;
|
||||
|
||||
#[derive(Debug, Error, Diagnostic)]
|
||||
pub enum Error {
|
||||
#[error(transparent)]
|
||||
Bsp(#[from] vbsp::BspError),
|
||||
#[error(transparent)]
|
||||
IO(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Vtf(#[from] vtf::Error),
|
||||
#[error(transparent)]
|
||||
Vdf(#[from] VdfError),
|
||||
#[error(transparent)]
|
||||
Mdl(#[from] vmdl::ModelError),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
#[error(transparent)]
|
||||
String(#[from] FromUtf8Error),
|
||||
#[error(transparent)]
|
||||
Loader(#[from] LoaderError),
|
||||
#[error(transparent)]
|
||||
Gltf(#[from] gltf::Error),
|
||||
#[error("resource {0} not found in vpks or pack")]
|
||||
ResourceNotFound(String),
|
||||
}
|
||||
177
src/gltf_builder.rs
Normal file
177
src/gltf_builder.rs
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
use crate::materials::{load_material_fallback, MaterialData, TextureData};
|
||||
use crate::pad_byte_vector;
|
||||
use bytemuck::{Pod, Zeroable};
|
||||
use gltf_json::buffer::View;
|
||||
use gltf_json::image::MimeType;
|
||||
use gltf_json::material::{AlphaCutoff, AlphaMode, PbrBaseColorFactor, PbrMetallicRoughness};
|
||||
use gltf_json::texture::Info;
|
||||
use gltf_json::validation::Checked::Valid;
|
||||
use gltf_json::{Extras, Image, Index, Material, Root, Texture};
|
||||
use image::png::PngEncoder;
|
||||
use image::{ColorType, DynamicImage, GenericImageView};
|
||||
use tf_asset_loader::Loader;
|
||||
|
||||
#[derive(Copy, Clone, Debug, Default, Zeroable, Pod)]
|
||||
#[repr(C)]
|
||||
pub struct Vertex {
|
||||
position: [f32; 3],
|
||||
normal: [f32; 3],
|
||||
uv: [f32; 2],
|
||||
}
|
||||
|
||||
impl From<&vmdl::vvd::Vertex> for Vertex {
|
||||
fn from(vertex: &vmdl::vvd::Vertex) -> Self {
|
||||
Vertex {
|
||||
position: vertex.position.into(),
|
||||
uv: vertex.texture_coordinates.into(),
|
||||
normal: vertex.normal.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_or_get_material(
|
||||
buffer: &mut Vec<u8>,
|
||||
gltf: &mut Root,
|
||||
loader: &Loader,
|
||||
material: &str,
|
||||
) -> Index<Material> {
|
||||
let material = material.to_ascii_lowercase();
|
||||
match get_material_index(&mut gltf.materials, &material) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
let material = load_material_fallback(&material, &[String::new()], loader);
|
||||
let index = gltf.materials.len() as u32;
|
||||
let material = push_material(buffer, gltf, material);
|
||||
gltf.materials.push(material);
|
||||
Index::new(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_material_index(materials: &[Material], path: &str) -> Option<Index<Material>> {
|
||||
materials
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, mat)| (mat.name.as_deref() == Some(path)).then_some(i))
|
||||
.map(|i| Index::new(i as u32))
|
||||
}
|
||||
|
||||
pub fn push_material(buffer: &mut Vec<u8>, gltf: &mut Root, material: MaterialData) -> Material {
|
||||
let texture_index = material
|
||||
.texture
|
||||
.map(|tex| push_or_get_texture(buffer, gltf, tex));
|
||||
|
||||
let alpha_mode = match (material.translucent, material.alpha_test.is_some()) {
|
||||
(true, _) => AlphaMode::Blend,
|
||||
(false, true) => AlphaMode::Mask,
|
||||
_ => AlphaMode::Opaque,
|
||||
};
|
||||
|
||||
Material {
|
||||
name: Some(material.name),
|
||||
alpha_cutoff: material
|
||||
.alpha_test
|
||||
.map(AlphaCutoff)
|
||||
.filter(|_| alpha_mode == AlphaMode::Mask),
|
||||
double_sided: true,
|
||||
alpha_mode: Valid(alpha_mode),
|
||||
pbr_metallic_roughness: PbrMetallicRoughness {
|
||||
base_color_factor: PbrBaseColorFactor(
|
||||
material.color.map(|channel| channel as f32 / 255.0),
|
||||
),
|
||||
base_color_texture: texture_index.map(|index| Info {
|
||||
index,
|
||||
tex_coord: 0,
|
||||
extensions: None,
|
||||
extras: Extras::default(),
|
||||
}),
|
||||
..PbrMetallicRoughness::default()
|
||||
},
|
||||
..Material::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn push_or_get_texture(
|
||||
buffer: &mut Vec<u8>,
|
||||
gltf: &mut Root,
|
||||
texture: TextureData,
|
||||
) -> Index<Texture> {
|
||||
match get_texture_index(&mut gltf.textures, &texture.name) {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
let index = gltf.textures.len() as u32;
|
||||
let texture = push_texture(buffer, gltf, texture);
|
||||
gltf.textures.push(texture);
|
||||
Index::new(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_texture_index(textures: &[Texture], name: &str) -> Option<Index<Texture>> {
|
||||
textures
|
||||
.iter()
|
||||
.enumerate()
|
||||
.find_map(|(i, tex)| (tex.name.as_deref() == Some(name)).then_some(i))
|
||||
.map(|i| Index::new(i as u32))
|
||||
}
|
||||
|
||||
fn push_texture(buffer: &mut Vec<u8>, gltf: &mut Root, texture: TextureData) -> Texture {
|
||||
let mut image = texture.image;
|
||||
if image.color() != ColorType::Rgba8 && image.color() != ColorType::Rgb8 {
|
||||
if image.color().has_alpha() {
|
||||
image = DynamicImage::ImageRgba8(image.into_rgba8());
|
||||
} else {
|
||||
image = DynamicImage::ImageRgb8(image.into_rgb8());
|
||||
}
|
||||
}
|
||||
let buffer_start = buffer.len() as u32;
|
||||
let view_start = gltf.buffer_views.len() as u32;
|
||||
let image_start = gltf.images.len() as u32;
|
||||
|
||||
let mut png_buffer = Vec::new();
|
||||
let encoder = PngEncoder::new(&mut png_buffer);
|
||||
encoder
|
||||
.encode(
|
||||
image.as_bytes(),
|
||||
image.width(),
|
||||
image.height(),
|
||||
image.color(),
|
||||
)
|
||||
.expect("failed to encode");
|
||||
|
||||
buffer.extend_from_slice(&png_buffer);
|
||||
|
||||
let byte_length = buffer.len() as u32 - buffer_start;
|
||||
pad_byte_vector(buffer);
|
||||
|
||||
let view = View {
|
||||
buffer: Index::new(0),
|
||||
byte_length,
|
||||
byte_offset: Some(buffer_start),
|
||||
byte_stride: None,
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
name: Some(texture.name.clone()),
|
||||
target: None,
|
||||
};
|
||||
|
||||
gltf.buffer_views.push(view);
|
||||
|
||||
let image = Image {
|
||||
buffer_view: Some(Index::new(view_start)),
|
||||
mime_type: Some(MimeType("image/png".into())),
|
||||
name: Some(texture.name.clone()),
|
||||
uri: None,
|
||||
extensions: None,
|
||||
extras: Default::default(),
|
||||
};
|
||||
gltf.images.push(image);
|
||||
|
||||
Texture {
|
||||
name: Some(texture.name),
|
||||
sampler: None,
|
||||
source: Index::new(image_start),
|
||||
extensions: None,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
132
src/main.rs
Normal file
132
src/main.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
use gltf_json as json;
|
||||
mod bsp;
|
||||
mod error;
|
||||
pub mod gltf_builder;
|
||||
mod materials;
|
||||
// mod prop;
|
||||
|
||||
use crate::bsp::{bsp_models, push_bsp_model};
|
||||
use clap::Parser;
|
||||
pub use error::Error;
|
||||
use gltf::Glb;
|
||||
use gltf_json::{Buffer, Index, Root, Scene};
|
||||
use miette::Context;
|
||||
use std::borrow::Cow;
|
||||
use std::fs::{read, File};
|
||||
use std::path::PathBuf;
|
||||
use tf_asset_loader::Loader;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_tree::HierarchicalLayer;
|
||||
use vbsp::Bsp;
|
||||
|
||||
fn setup() {
|
||||
miette::set_panic_hook();
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(EnvFilter::from_default_env())
|
||||
.with(
|
||||
HierarchicalLayer::new(2)
|
||||
.with_targets(true)
|
||||
.with_bracketed_fields(true),
|
||||
)
|
||||
.init();
|
||||
}
|
||||
|
||||
/// View a demo file
|
||||
#[derive(Parser, Debug)]
|
||||
#[clap(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Path of the map file
|
||||
source: PathBuf,
|
||||
/// Path to save the glb to
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
fn main() -> miette::Result<()> {
|
||||
setup();
|
||||
|
||||
let args = Args::parse();
|
||||
|
||||
let mut loader = Loader::new().map_err(Error::from)?;
|
||||
let data = read(args.source).map_err(Error::from)?;
|
||||
let map = Bsp::read(&data).map_err(Error::from)?;
|
||||
loader.add_source(map.pack.clone());
|
||||
|
||||
let glb = export(map, &mut loader)?;
|
||||
|
||||
let writer = File::create(&args.target)
|
||||
.map_err(Error::from)
|
||||
.wrap_err("Failed to open target")?;
|
||||
|
||||
glb.to_writer(writer)
|
||||
.map_err(Error::from)
|
||||
.wrap_err("glTF binary output error")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export(bsp: Bsp, loader: &mut Loader) -> Result<Glb<'static>, Error> {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
let mut root = Root::default();
|
||||
|
||||
for (model, offset) in bsp_models(&bsp)? {
|
||||
let node = push_bsp_model(&mut buffer, &mut root, loader, &model, offset);
|
||||
root.nodes.push(node);
|
||||
}
|
||||
|
||||
let node_indices = 0..root.nodes.len();
|
||||
root.scenes = vec![Scene {
|
||||
name: None,
|
||||
extensions: None,
|
||||
extras: Default::default(),
|
||||
nodes: node_indices.map(|index| Index::new(index as u32)).collect(),
|
||||
}];
|
||||
|
||||
root.buffers.push(Buffer {
|
||||
byte_length: buffer.len() as u32,
|
||||
extensions: Default::default(),
|
||||
extras: Default::default(),
|
||||
name: None,
|
||||
uri: None,
|
||||
});
|
||||
|
||||
let json_string = json::serialize::to_string(&root).expect("Serialization error");
|
||||
let mut json_offset = json_string.len() as u32;
|
||||
align_to_multiple_of_four(&mut json_offset);
|
||||
|
||||
pad_byte_vector(&mut buffer);
|
||||
Ok(Glb {
|
||||
header: gltf::binary::Header {
|
||||
magic: *b"glTF",
|
||||
version: 2,
|
||||
length: json_offset + buffer.len() as u32,
|
||||
},
|
||||
bin: Some(Cow::Owned(buffer)),
|
||||
json: Cow::Owned(json_string.into_bytes()),
|
||||
})
|
||||
}
|
||||
|
||||
fn align_to_multiple_of_four(n: &mut u32) {
|
||||
*n = (*n + 3) & !3;
|
||||
}
|
||||
|
||||
fn pad_byte_vector(vec: &mut Vec<u8>) {
|
||||
while vec.len() % 4 != 0 {
|
||||
vec.push(0); // pad to multiple of four bytes
|
||||
}
|
||||
}
|
||||
|
||||
// 1 hammer unit is ~1.905cm
|
||||
pub const UNIT_SCALE: f32 = 1.0 / (1.905 * 100.0);
|
||||
|
||||
pub fn map_coords<C: Into<[f32; 3]>>(vec: C) -> [f32; 3] {
|
||||
let vec = vec.into();
|
||||
[
|
||||
vec[1] * UNIT_SCALE,
|
||||
vec[2] * UNIT_SCALE,
|
||||
vec[0] * UNIT_SCALE,
|
||||
]
|
||||
}
|
||||
129
src/materials.rs
Normal file
129
src/materials.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
use crate::Error;
|
||||
use image::DynamicImage;
|
||||
use tf_asset_loader::Loader;
|
||||
use tracing::{error, instrument};
|
||||
use vmt_parser::from_str;
|
||||
use vmt_parser::material::{Material, WaterMaterial};
|
||||
use vtf::vtf::VTF;
|
||||
|
||||
pub fn load_material_fallback(name: &str, search_dirs: &[String], loader: &Loader) -> MaterialData {
|
||||
match load_material(name, search_dirs, loader) {
|
||||
Ok(mat) => mat,
|
||||
Err(e) => {
|
||||
error!(error = ?e, "failed to load material");
|
||||
MaterialData {
|
||||
name: name.into(),
|
||||
color: [255, 0, 255, 255],
|
||||
..MaterialData::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct MaterialData {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub color: [u8; 4],
|
||||
pub texture: Option<TextureData>,
|
||||
pub alpha_test: Option<f32>,
|
||||
pub bump_map: Option<TextureData>,
|
||||
pub translucent: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TextureData {
|
||||
pub name: String,
|
||||
pub image: DynamicImage,
|
||||
}
|
||||
|
||||
#[instrument(skip(loader))]
|
||||
pub fn load_material(
|
||||
name: &str,
|
||||
search_dirs: &[String],
|
||||
loader: &Loader,
|
||||
) -> Result<MaterialData, Error> {
|
||||
let dirs = search_dirs
|
||||
.iter()
|
||||
.map(|dir| {
|
||||
format!(
|
||||
"materials/{}",
|
||||
dir.to_ascii_lowercase().trim_start_matches("/")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let path = format!("{}.vmt", name.to_ascii_lowercase().trim_end_matches(".vmt"));
|
||||
let path = loader
|
||||
.find_in_paths(&path, &dirs)
|
||||
.ok_or(Error::Other(format!("Can't find file {}", path)))?;
|
||||
let raw = loader.load(&path)?.expect("didn't find foudn path?");
|
||||
let vdf = String::from_utf8(raw)?;
|
||||
|
||||
let material = from_str(&vdf).map_err(|e| {
|
||||
let report = miette::ErrReport::new(e);
|
||||
println!("{:?}", report);
|
||||
Error::Other(format!("Failed to load material {}", path))
|
||||
})?;
|
||||
let material = material.resolve(|path| {
|
||||
let data = loader
|
||||
.load(path)?
|
||||
.ok_or(Error::Other(format!("Can't find file {}", path)))?;
|
||||
let vdf = String::from_utf8(data)?;
|
||||
Ok::<_, Error>(vdf)
|
||||
})?;
|
||||
|
||||
if let Material::Water(WaterMaterial {
|
||||
base_texture: None, ..
|
||||
}) = &material
|
||||
{
|
||||
return Ok(MaterialData {
|
||||
color: [82, 180, 217, 128],
|
||||
name: name.into(),
|
||||
path,
|
||||
texture: None,
|
||||
bump_map: None,
|
||||
alpha_test: None,
|
||||
translucent: true,
|
||||
});
|
||||
}
|
||||
|
||||
let base_texture = material.base_texture();
|
||||
|
||||
let translucent = material.translucent();
|
||||
let glass = material.surface_prop() == Some("glass");
|
||||
let alpha_test = material.alpha_test();
|
||||
let texture = load_texture(base_texture, loader)?;
|
||||
|
||||
let bump_map = material.bump_map().and_then(|path| {
|
||||
Some(TextureData {
|
||||
image: load_texture(&path, loader).ok()?,
|
||||
name: path.into(),
|
||||
})
|
||||
});
|
||||
|
||||
Ok(MaterialData {
|
||||
color: [255; 4],
|
||||
name: name.into(),
|
||||
path,
|
||||
texture: Some(TextureData {
|
||||
name: base_texture.into(),
|
||||
image: texture,
|
||||
}),
|
||||
bump_map,
|
||||
alpha_test,
|
||||
translucent: translucent | glass,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_texture(name: &str, loader: &Loader) -> Result<DynamicImage, Error> {
|
||||
let path = format!(
|
||||
"materials/{}.vtf",
|
||||
name.trim_end_matches(".vtf").trim_start_matches('/')
|
||||
);
|
||||
let mut raw = loader
|
||||
.load(&path)?
|
||||
.ok_or(Error::Other(format!("Can't find file {}", path)))?;
|
||||
let vtf = VTF::read(&mut raw)?;
|
||||
let image = vtf.highres_image.decode(0)?;
|
||||
Ok(image)
|
||||
}
|
||||
127
src/prop.rs
Normal file
127
src/prop.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use crate::bsp::map_coords;
|
||||
use crate::materials::{load_material_fallback};
|
||||
use crate::Error;
|
||||
use tf_asset_loader::Loader;
|
||||
use tracing::{error, warn};
|
||||
use vbsp::{Handle, StaticPropLump};
|
||||
use vmdl::mdl::{Mdl, TextureInfo};
|
||||
use vmdl::vtx::Vtx;
|
||||
use vmdl::vvd::Vvd;
|
||||
|
||||
#[tracing::instrument(skip(loader))]
|
||||
pub fn load_prop(loader: &Loader, name: &str) -> Result<vmdl::Model, Error> {
|
||||
let load = |name: &str| -> Result<Vec<u8>, Error> {
|
||||
loader
|
||||
.load(name)?
|
||||
.ok_or(Error::ResourceNotFound(name.into()))
|
||||
};
|
||||
let mdl = Mdl::read(&load(name)?)?;
|
||||
let vtx = Vtx::read(&load(&name.replace(".mdl", ".dx90.vtx"))?)?;
|
||||
let vvd = Vvd::read(&load(&name.replace(".mdl", ".vvd"))?)?;
|
||||
|
||||
Ok(vmdl::Model::from_parts(mdl, vtx, vvd))
|
||||
}
|
||||
pub fn load_props<'a, I: Iterator<Item = Handle<'a, StaticPropLump>>>(
|
||||
loader: &Loader,
|
||||
props: I,
|
||||
) -> Result<Vec<CpuModel>, Error> {
|
||||
let props = props
|
||||
.filter_map(|prop| match load_prop(loader, prop.model()) {
|
||||
Ok(model) => Some((prop, model)),
|
||||
Err(e) => {
|
||||
error!(error = ?e, prop = prop.model(), "Failed to load prop");
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(|(prop, model)| {
|
||||
let transform =
|
||||
Mat4::from_translation(map_coords(prop.origin)) * Mat4::from(prop.rotation());
|
||||
PropData {
|
||||
name: prop.model(),
|
||||
model,
|
||||
transform,
|
||||
skin: prop.skin,
|
||||
}
|
||||
});
|
||||
|
||||
props
|
||||
.map(|prop| {
|
||||
let geometries: Vec<_> = prop_to_meshes(&prop).collect();
|
||||
let materials: Vec<_> = prop
|
||||
.model
|
||||
.textures()
|
||||
.iter()
|
||||
.map(|tex| prop_texture_to_material(tex, loader))
|
||||
.collect();
|
||||
|
||||
Ok(CpuModel {
|
||||
name: prop.name.into(),
|
||||
geometries,
|
||||
materials,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct PropData<'a> {
|
||||
name: &'a str,
|
||||
model: vmdl::Model,
|
||||
transform: Mat4,
|
||||
skin: i32,
|
||||
}
|
||||
|
||||
fn prop_to_meshes<'a>(prop: &'a PropData) -> impl Iterator<Item = Primitive> + 'a {
|
||||
let transform = prop.transform;
|
||||
let model = &prop.model;
|
||||
|
||||
let skin = match model.skin_tables().nth(prop.skin as usize) {
|
||||
Some(skin) => skin,
|
||||
None => {
|
||||
warn!(index = prop.skin, prop = prop.name, "invalid skin index");
|
||||
model.skin_tables().next().unwrap()
|
||||
}
|
||||
};
|
||||
|
||||
model.meshes().map(move |mesh| {
|
||||
let material_index = skin.texture_index(mesh.material_index());
|
||||
|
||||
let positions: Vec<Vec3> = mesh
|
||||
.vertices()
|
||||
.map(|vertex| map_coords(vertex.position))
|
||||
.collect();
|
||||
let normals: Vec<Vec3> = mesh
|
||||
.vertices()
|
||||
.map(|vertex| map_coords(vertex.normal))
|
||||
.collect();
|
||||
let uvs: Vec<Vec2> = mesh
|
||||
.vertices()
|
||||
.map(|vertex| vertex.texture_coordinates.into())
|
||||
.collect();
|
||||
|
||||
let tangents: Vec<Vec4> = mesh.tangents().map(|tangent| tangent.into()).collect();
|
||||
|
||||
let geometry = Geometry::Triangles(TriMesh {
|
||||
positions: Positions::F32(positions),
|
||||
normals: Some(normals),
|
||||
uvs: Some(uvs),
|
||||
tangents: Some(tangents),
|
||||
..TriMesh::default()
|
||||
});
|
||||
|
||||
Primitive {
|
||||
name: mesh.model_name.into(),
|
||||
transformation: transform,
|
||||
animations: vec![],
|
||||
geometry,
|
||||
material_index,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn prop_texture_to_material(texture: &TextureInfo, loader: &Loader) -> CpuMaterial {
|
||||
convert_material(load_material_fallback(
|
||||
&texture.name,
|
||||
&texture.search_paths,
|
||||
loader,
|
||||
))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue