add convert options

This commit is contained in:
Robin Appelman 2023-12-27 18:28:21 +01:00
commit 16d20a5faf
11 changed files with 171 additions and 41 deletions

3
Cargo.lock generated
View file

@ -2181,6 +2181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8277e703c934b9693d0773d5749faacc6366b3d81d012da556a4cfd4ab87f336"
dependencies = [
"libm",
"rayon",
]
[[package]]
@ -2610,6 +2611,7 @@ dependencies = [
name = "vbsp-to-gltf"
version = "0.1.0"
dependencies = [
"ahash",
"async-tempfile",
"axum",
"bytemuck",
@ -2622,6 +2624,7 @@ dependencies = [
"miette",
"reqwest",
"serde",
"texpresso",
"tf-asset-loader",
"thiserror",
"tokio",

View file

@ -34,9 +34,10 @@ gltf-json = { version = "1.4.0", features = ["KHR_texture_transform"] }
gltf = "1.4.0"
cgmath = "0.18.0"
bytemuck = { version = "1.14.0", features = ["derive"] }
texpresso = { version = "2.0.1", features = ["rayon"] }
serde = "1.0.193"
url = { version = "2.5.0", optional = true, features = ["serde"] }
serde = { version = "1.0.193", optional = true }
toml = { version = "0.8.8", optional = true }
axum = { version = "0.7.2", optional = true, features = ["macros"] }
tokio = { version = "1.35.1", features = ["full"], optional = true }
@ -44,9 +45,14 @@ reqwest = { version = "0.11.23", optional = true, default-features = false, feat
async-tempfile = { version = "0.5.0", optional = true }
tower-http = { version = "0.5.0", optional = true, features = ["cors"] }
http = { version = "1.0.0", optional = true }
ahash = { version = "0.8.6", optional = true }
[features]
server = ["url", "serde", "toml", "axum", "tokio", "reqwest", "async-tempfile", "tower-http", "http"]
server = ["url", "toml", "axum", "tokio", "reqwest", "async-tempfile", "tower-http", "http", "ahash"]
[profile.dev.package."*"]
opt-level = 2
opt-level = 2
[profile.release]
codegen-units = 1
lto = true

View file

@ -1,6 +1,7 @@
use crate::convert::map_coords;
use crate::error::Error;
use crate::gltf_builder::push_or_get_material;
use crate::ConvertOptions;
use bytemuck::{offset_of, Pod, Zeroable};
use gltf_json::accessor::{ComponentType, GenericComponentType, Type};
use gltf_json::buffer::{Stride, Target, View};
@ -73,11 +74,12 @@ pub fn push_bsp_model(
loader: &Loader,
model: &Handle<Model>,
offset: Vector,
options: &ConvertOptions,
) -> Node {
let primitives = model
.faces()
.filter(|face| face.is_visible())
.map(|face| push_bsp_face(buffer, gltf, loader, &face))
.map(|face| push_bsp_face(buffer, gltf, loader, &face, options))
.collect();
let mesh = Mesh {
@ -112,6 +114,7 @@ pub fn push_bsp_face(
gltf: &mut Root,
loader: &Loader,
face: &Handle<Face>,
options: &ConvertOptions,
) -> Primitive {
let vertex_count = face.vertex_positions().count() as u64;
@ -175,7 +178,17 @@ pub fn push_bsp_face(
gltf.accessors.push(positions);
gltf.accessors.push(uvs);
let material_index = push_or_get_material(buffer, gltf, loader, face.texture().name());
let material_index = if options.textures {
Some(push_or_get_material(
buffer,
gltf,
loader,
face.texture().name(),
options,
))
} else {
None
};
Primitive {
attributes: {
@ -190,7 +203,7 @@ pub fn push_bsp_face(
extensions: Default::default(),
extras: Default::default(),
indices: None,
material: Some(material_index),
material: material_index,
mode: Valid(Mode::Triangles),
targets: None,
}

View file

@ -8,7 +8,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter;
use tracing_tree::HierarchicalLayer;
use vbsp::Bsp;
use vbsp_to_gltf::{export, Error};
use vbsp_to_gltf::{export, ConvertOptions, Error};
fn setup() {
miette::set_panic_hook();
@ -43,7 +43,7 @@ fn main() -> miette::Result<()> {
let map = Bsp::read(&data).map_err(Error::from)?;
loader.add_source(map.pack.clone().into_zip());
let glb = export(map, &loader)?;
let glb = export(map, &loader, ConvertOptions::default())?;
let writer = File::create(&args.target)
.map_err(Error::from)

View file

@ -2,7 +2,7 @@ use gltf_json as json;
use crate::bsp::{bsp_models, push_bsp_model};
use crate::prop::push_or_get_model;
use crate::Error;
use crate::{ConvertOptions, Error};
use cgmath::{Deg, Quaternion, Rotation3};
use gltf::Glb;
use gltf_json::scene::UnitQuaternion;
@ -12,18 +12,25 @@ use std::borrow::Cow;
use tf_asset_loader::Loader;
use vbsp::Bsp;
pub fn export(bsp: Bsp, loader: &Loader) -> Result<Glb<'static>, Error> {
pub fn export(bsp: Bsp, loader: &Loader, options: ConvertOptions) -> 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);
let node = push_bsp_model(&mut buffer, &mut root, loader, &model, offset, &options);
root.nodes.push(node);
}
for prop in bsp.static_props() {
let mesh = push_or_get_model(&mut buffer, &mut root, loader, prop.model(), prop.skin);
let mesh = push_or_get_model(
&mut buffer,
&mut root,
loader,
prop.model(),
prop.skin,
&options,
);
let rotation = prop.rotation();
let node = Node {

View file

@ -1,5 +1,6 @@
use crate::convert::pad_byte_vector;
use crate::materials::{load_material_fallback, MaterialData, TextureData};
use crate::ConvertOptions;
use gltf_json::buffer::View;
use gltf_json::extensions::texture::{
TextureTransform, TextureTransformOffset, TextureTransformRotation, TextureTransformScale,
@ -20,12 +21,13 @@ pub fn push_or_get_material(
gltf: &mut Root,
loader: &Loader,
material: &str,
options: &ConvertOptions,
) -> Index<Material> {
let material = material.to_ascii_lowercase();
match get_material_index(&gltf.materials, &material) {
Some(index) => index,
None => {
let material = load_material_fallback(&material, &[String::new()], loader);
let material = load_material_fallback(&material, &[String::new()], loader, options);
let index = gltf.materials.len() as u32;
let material = push_material(buffer, gltf, material);
gltf.materials.push(material);

View file

@ -5,5 +5,42 @@ pub mod gltf_builder;
mod materials;
mod prop;
use ahash::RandomState;
pub use convert::export;
pub use error::Error;
use serde::Deserialize;
use std::hash::{BuildHasher, Hash, Hasher};
#[derive(Debug, Deserialize, Clone)]
pub struct ConvertOptions {
#[serde(default = "default_enable")]
pub textures: bool,
#[serde(default = "default_scale")]
pub texture_scale: f32,
}
impl ConvertOptions {
pub fn key(&self) -> u64 {
let mut hasher = RandomState::with_seeds(1, 2, 3, 4).build_hasher();
self.textures.hash(&mut hasher);
self.texture_scale.to_le_bytes().hash(&mut hasher);
hasher.finish()
}
}
impl Default for ConvertOptions {
fn default() -> Self {
ConvertOptions {
textures: true,
texture_scale: 1.0,
}
}
}
fn default_enable() -> bool {
true
}
fn default_scale() -> f32 {
1.0
}

View file

@ -1,13 +1,19 @@
use crate::Error;
use image::DynamicImage;
use crate::{ConvertOptions, Error};
use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView};
use tf_asset_loader::Loader;
use tracing::{error, instrument};
use vmt_parser::material::{Material, WaterMaterial};
use vmt_parser::{from_str, TextureTransform};
use vtf::vtf::VTF;
pub fn load_material_fallback(name: &str, search_dirs: &[String], loader: &Loader) -> MaterialData {
match load_material(name, search_dirs, loader) {
pub fn load_material_fallback(
name: &str,
search_dirs: &[String],
loader: &Loader,
options: &ConvertOptions,
) -> MaterialData {
match load_material(name, search_dirs, loader, options) {
Ok(mat) => mat,
Err(e) => {
error!(error = ?e, "failed to load material");
@ -44,6 +50,7 @@ pub fn load_material(
name: &str,
search_dirs: &[String],
loader: &Loader,
options: &ConvertOptions,
) -> Result<MaterialData, Error> {
let dirs = search_dirs
.iter()
@ -94,11 +101,11 @@ pub fn load_material(
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 texture = load_texture(base_texture, loader, options)?;
let bump_map = material.bump_map().and_then(|path| {
Some(TextureData {
image: load_texture(path, loader).ok()?,
image: load_texture(path, loader, options).ok()?,
name: path.into(),
})
});
@ -124,7 +131,11 @@ pub fn load_material(
})
}
fn load_texture(name: &str, loader: &Loader) -> Result<DynamicImage, Error> {
fn load_texture(
name: &str,
loader: &Loader,
options: &ConvertOptions,
) -> Result<DynamicImage, Error> {
let path = format!(
"materials/{}.vtf",
name.trim_end_matches(".vtf").trim_start_matches('/')
@ -134,5 +145,13 @@ fn load_texture(name: &str, loader: &Loader) -> Result<DynamicImage, Error> {
.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)
if options.texture_scale != 1.0 {
Ok(image.resize(
(image.width() as f32 * options.texture_scale) as u32,
(image.height() as f32 * options.texture_scale) as u32,
FilterType::CatmullRom,
))
} else {
Ok(image)
}
}

View file

@ -1,6 +1,6 @@
use crate::convert::map_coords;
use crate::gltf_builder::push_or_get_material;
use crate::Error;
use crate::{ConvertOptions, Error};
use bytemuck::{offset_of, Pod, Zeroable};
use gltf_json::accessor::{ComponentType, GenericComponentType, Type};
use gltf_json::buffer::{Stride, Target, View};
@ -125,6 +125,7 @@ pub fn push_or_get_model(
loader: &Loader,
model: &str,
skin: i32,
options: &ConvertOptions,
) -> Index<Mesh> {
let skinned_name = format!("{model}_{skin}");
match get_mesh_index(&gltf.meshes, &skinned_name) {
@ -132,7 +133,7 @@ pub fn push_or_get_model(
None => {
let prop = load_prop(loader, model).expect("failed to load prop");
let index = gltf.meshes.len() as u32;
let material = push_model(buffer, gltf, loader, &prop, skin, skinned_name);
let material = push_model(buffer, gltf, loader, &prop, skin, skinned_name, options);
gltf.meshes.push(material);
Index::new(index)
}
@ -154,6 +155,7 @@ pub fn push_model(
model: &Model,
skin: i32,
skinned_name: String,
options: &ConvertOptions,
) -> Mesh {
let accessor_start = gltf.accessors.len() as u32;
push_vertices(buffer, gltf, model);
@ -164,7 +166,17 @@ pub fn push_model(
let primitives = model
.meshes()
.map(|mesh| push_primitive(buffer, gltf, loader, &mesh, accessor_start, &skin_table))
.map(|mesh| {
push_primitive(
buffer,
gltf,
loader,
&mesh,
accessor_start,
&skin_table,
options,
)
})
.collect();
Mesh {
@ -183,6 +195,7 @@ pub fn push_primitive(
mesh: &vmdl::Mesh,
vertex_accessor_start: u32,
skin: &SkinTable,
options: &ConvertOptions,
) -> Primitive {
let buffer_start = buffer.len() as u64;
let view_start = gltf.buffer_views.len() as u32;
@ -224,12 +237,22 @@ pub fn push_primitive(
};
gltf.accessors.push(accessor);
let texture = skin
.texture_info(mesh.material_index())
.expect("mat out of bounds");
let texture_path = find_material(&texture.name, &texture.search_paths, loader)
.expect("failed to find texture");
let material_index = push_or_get_material(buffer, gltf, loader, &texture_path);
let material = if options.textures {
let texture = skin
.texture_info(mesh.material_index())
.expect("mat out of bounds");
let texture_path = find_material(&texture.name, &texture.search_paths, loader)
.expect("failed to find texture");
Some(push_or_get_material(
buffer,
gltf,
loader,
&texture_path,
options,
))
} else {
None
};
Primitive {
attributes: {
@ -251,7 +274,7 @@ pub fn push_primitive(
extensions: Default::default(),
extras: Default::default(),
indices: Some(Index::new(accessor_start)),
material: Some(material_index),
material,
mode: Valid(Mode::Triangles),
targets: None,
}

View file

@ -20,5 +20,13 @@
<aside>
Note that map conversion can take 10 to 30 seconds, once a map has been converted once it is cached for subsequent requests.
</aside>
<h2>Options</h2>
<p>
The following options can be set as query parameters
</p>
<ul>
<li><code>textures=false</code>: disable textures</li>
<li><code>texture_scale=&lt;scale&gt;</code>: scale textures, lower scale for significantly smaller filesizes</li>
</ul>
</body>
</html>

View file

@ -1,7 +1,7 @@
mod pack;
use crate::pack::pack;
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::get;
@ -27,7 +27,7 @@ use tracing_subscriber::EnvFilter;
use tracing_tree::HierarchicalLayer;
use url::Url;
use vbsp::{Bsp, BspError};
use vbsp_to_gltf::{export, Error};
use vbsp_to_gltf::{export, ConvertOptions, Error};
type Result<T, E = ServerError> = std::result::Result<T, E>;
@ -163,8 +163,12 @@ struct App {
}
impl App {
fn cached(&self, map: &str) -> Result<Option<Vec<u8>>> {
let path = self.cache_dir.join(map);
fn cache_path(&self, map: &str, options_key: u64) -> PathBuf {
self.cache_dir.join(format!("{options_key:016x}_{map}"))
}
fn cached(&self, map: &str, options_key: u64) -> Result<Option<Vec<u8>>> {
let path = self.cache_path(map, options_key);
if path.exists() {
Ok(Some(read(path)?))
} else {
@ -172,8 +176,8 @@ impl App {
}
}
fn cache(&self, map: &str, data: &[u8]) -> Result<()> {
let path = self.cache_dir.join(map);
fn cache(&self, map: &str, data: &[u8], options_key: u64) -> Result<()> {
let path = self.cache_path(map, options_key);
Ok(write(path, data)?)
}
@ -198,11 +202,19 @@ async fn index() -> impl IntoResponse {
Html(include_str!("./index.html"))
}
async fn convert(State(app): State<Arc<App>>, Path(map): Path<String>) -> impl IntoResponse {
async fn convert(
State(app): State<Arc<App>>,
Path(map): Path<String>,
Query(mut options): Query<ConvertOptions>,
) -> impl IntoResponse {
if options.texture_scale > 1.0 {
options.texture_scale = 1.0;
}
let options_key = options.key();
if !map.is_ascii() || map.contains('/') || !map.ends_with(".glb") {
return Err(ServerError::InvalidMapName(map));
}
if let Some(cached) = app.cached(&map)? {
if let Some(cached) = app.cached(&map, options_key)? {
info!(map = map, "serving cached model");
return Ok(cached);
}
@ -215,7 +227,7 @@ async fn convert(State(app): State<Arc<App>>, Path(map): Path<String>) -> impl I
let bsp = Bsp::read(&bsp_data).map_err(Error::from)?;
loader.add_source(bsp.pack.clone().into_zip());
let glb = export(bsp, &loader)?;
let glb = export(bsp, &loader, options)?;
let glb = glb.to_vec().map_err(Error::from)?;
let packed = pack(&map, &glb).await?;
@ -226,7 +238,7 @@ async fn convert(State(app): State<Arc<App>>, Path(map): Path<String>) -> impl I
"optimized model"
);
app.cache(&map, &packed)?;
app.cache(&map, &packed, options_key)?;
Ok(packed)
}