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" checksum = "8277e703c934b9693d0773d5749faacc6366b3d81d012da556a4cfd4ab87f336"
dependencies = [ dependencies = [
"libm", "libm",
"rayon",
] ]
[[package]] [[package]]
@ -2610,6 +2611,7 @@ dependencies = [
name = "vbsp-to-gltf" name = "vbsp-to-gltf"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"ahash",
"async-tempfile", "async-tempfile",
"axum", "axum",
"bytemuck", "bytemuck",
@ -2622,6 +2624,7 @@ dependencies = [
"miette", "miette",
"reqwest", "reqwest",
"serde", "serde",
"texpresso",
"tf-asset-loader", "tf-asset-loader",
"thiserror", "thiserror",
"tokio", "tokio",

View file

@ -34,9 +34,10 @@ gltf-json = { version = "1.4.0", features = ["KHR_texture_transform"] }
gltf = "1.4.0" gltf = "1.4.0"
cgmath = "0.18.0" cgmath = "0.18.0"
bytemuck = { version = "1.14.0", features = ["derive"] } 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"] } url = { version = "2.5.0", optional = true, features = ["serde"] }
serde = { version = "1.0.193", optional = true }
toml = { version = "0.8.8", optional = true } toml = { version = "0.8.8", optional = true }
axum = { version = "0.7.2", optional = true, features = ["macros"] } axum = { version = "0.7.2", optional = true, features = ["macros"] }
tokio = { version = "1.35.1", features = ["full"], optional = true } 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 } async-tempfile = { version = "0.5.0", optional = true }
tower-http = { version = "0.5.0", optional = true, features = ["cors"] } tower-http = { version = "0.5.0", optional = true, features = ["cors"] }
http = { version = "1.0.0", optional = true } http = { version = "1.0.0", optional = true }
ahash = { version = "0.8.6", optional = true }
[features] [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."*"] [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::convert::map_coords;
use crate::error::Error; use crate::error::Error;
use crate::gltf_builder::push_or_get_material; use crate::gltf_builder::push_or_get_material;
use crate::ConvertOptions;
use bytemuck::{offset_of, Pod, Zeroable}; use bytemuck::{offset_of, Pod, Zeroable};
use gltf_json::accessor::{ComponentType, GenericComponentType, Type}; use gltf_json::accessor::{ComponentType, GenericComponentType, Type};
use gltf_json::buffer::{Stride, Target, View}; use gltf_json::buffer::{Stride, Target, View};
@ -73,11 +74,12 @@ pub fn push_bsp_model(
loader: &Loader, loader: &Loader,
model: &Handle<Model>, model: &Handle<Model>,
offset: Vector, offset: Vector,
options: &ConvertOptions,
) -> Node { ) -> Node {
let primitives = model let primitives = model
.faces() .faces()
.filter(|face| face.is_visible()) .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(); .collect();
let mesh = Mesh { let mesh = Mesh {
@ -112,6 +114,7 @@ pub fn push_bsp_face(
gltf: &mut Root, gltf: &mut Root,
loader: &Loader, loader: &Loader,
face: &Handle<Face>, face: &Handle<Face>,
options: &ConvertOptions,
) -> Primitive { ) -> Primitive {
let vertex_count = face.vertex_positions().count() as u64; 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(positions);
gltf.accessors.push(uvs); 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 { Primitive {
attributes: { attributes: {
@ -190,7 +203,7 @@ pub fn push_bsp_face(
extensions: Default::default(), extensions: Default::default(),
extras: Default::default(), extras: Default::default(),
indices: None, indices: None,
material: Some(material_index), material: material_index,
mode: Valid(Mode::Triangles), mode: Valid(Mode::Triangles),
targets: None, targets: None,
} }

View file

@ -8,7 +8,7 @@ use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use tracing_tree::HierarchicalLayer; use tracing_tree::HierarchicalLayer;
use vbsp::Bsp; use vbsp::Bsp;
use vbsp_to_gltf::{export, Error}; use vbsp_to_gltf::{export, ConvertOptions, Error};
fn setup() { fn setup() {
miette::set_panic_hook(); miette::set_panic_hook();
@ -43,7 +43,7 @@ fn main() -> miette::Result<()> {
let map = Bsp::read(&data).map_err(Error::from)?; let map = Bsp::read(&data).map_err(Error::from)?;
loader.add_source(map.pack.clone().into_zip()); 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) let writer = File::create(&args.target)
.map_err(Error::from) .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::bsp::{bsp_models, push_bsp_model};
use crate::prop::push_or_get_model; use crate::prop::push_or_get_model;
use crate::Error; use crate::{ConvertOptions, Error};
use cgmath::{Deg, Quaternion, Rotation3}; use cgmath::{Deg, Quaternion, Rotation3};
use gltf::Glb; use gltf::Glb;
use gltf_json::scene::UnitQuaternion; use gltf_json::scene::UnitQuaternion;
@ -12,18 +12,25 @@ use std::borrow::Cow;
use tf_asset_loader::Loader; use tf_asset_loader::Loader;
use vbsp::Bsp; 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 buffer = Vec::new();
let mut root = Root::default(); let mut root = Root::default();
for (model, offset) in bsp_models(&bsp)? { 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); root.nodes.push(node);
} }
for prop in bsp.static_props() { 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 rotation = prop.rotation();
let node = Node { let node = Node {

View file

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

View file

@ -5,5 +5,42 @@ pub mod gltf_builder;
mod materials; mod materials;
mod prop; mod prop;
use ahash::RandomState;
pub use convert::export; pub use convert::export;
pub use error::Error; 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 crate::{ConvertOptions, Error};
use image::DynamicImage; use image::imageops::FilterType;
use image::{DynamicImage, GenericImageView};
use tf_asset_loader::Loader; use tf_asset_loader::Loader;
use tracing::{error, instrument}; use tracing::{error, instrument};
use vmt_parser::material::{Material, WaterMaterial}; use vmt_parser::material::{Material, WaterMaterial};
use vmt_parser::{from_str, TextureTransform}; use vmt_parser::{from_str, TextureTransform};
use vtf::vtf::VTF; use vtf::vtf::VTF;
pub fn load_material_fallback(name: &str, search_dirs: &[String], loader: &Loader) -> MaterialData { pub fn load_material_fallback(
match load_material(name, search_dirs, loader) { name: &str,
search_dirs: &[String],
loader: &Loader,
options: &ConvertOptions,
) -> MaterialData {
match load_material(name, search_dirs, loader, options) {
Ok(mat) => mat, Ok(mat) => mat,
Err(e) => { Err(e) => {
error!(error = ?e, "failed to load material"); error!(error = ?e, "failed to load material");
@ -44,6 +50,7 @@ pub fn load_material(
name: &str, name: &str,
search_dirs: &[String], search_dirs: &[String],
loader: &Loader, loader: &Loader,
options: &ConvertOptions,
) -> Result<MaterialData, Error> { ) -> Result<MaterialData, Error> {
let dirs = search_dirs let dirs = search_dirs
.iter() .iter()
@ -94,11 +101,11 @@ pub fn load_material(
let translucent = material.translucent(); let translucent = material.translucent();
let glass = material.surface_prop() == Some("glass"); let glass = material.surface_prop() == Some("glass");
let alpha_test = material.alpha_test(); 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| { let bump_map = material.bump_map().and_then(|path| {
Some(TextureData { Some(TextureData {
image: load_texture(path, loader).ok()?, image: load_texture(path, loader, options).ok()?,
name: path.into(), 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!( let path = format!(
"materials/{}.vtf", "materials/{}.vtf",
name.trim_end_matches(".vtf").trim_start_matches('/') 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)))?; .ok_or(Error::Other(format!("Can't find file {}", path)))?;
let vtf = VTF::read(&mut raw)?; let vtf = VTF::read(&mut raw)?;
let image = vtf.highres_image.decode(0)?; 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::convert::map_coords;
use crate::gltf_builder::push_or_get_material; use crate::gltf_builder::push_or_get_material;
use crate::Error; use crate::{ConvertOptions, Error};
use bytemuck::{offset_of, Pod, Zeroable}; use bytemuck::{offset_of, Pod, Zeroable};
use gltf_json::accessor::{ComponentType, GenericComponentType, Type}; use gltf_json::accessor::{ComponentType, GenericComponentType, Type};
use gltf_json::buffer::{Stride, Target, View}; use gltf_json::buffer::{Stride, Target, View};
@ -125,6 +125,7 @@ pub fn push_or_get_model(
loader: &Loader, loader: &Loader,
model: &str, model: &str,
skin: i32, skin: i32,
options: &ConvertOptions,
) -> Index<Mesh> { ) -> Index<Mesh> {
let skinned_name = format!("{model}_{skin}"); let skinned_name = format!("{model}_{skin}");
match get_mesh_index(&gltf.meshes, &skinned_name) { match get_mesh_index(&gltf.meshes, &skinned_name) {
@ -132,7 +133,7 @@ pub fn push_or_get_model(
None => { None => {
let prop = load_prop(loader, model).expect("failed to load prop"); let prop = load_prop(loader, model).expect("failed to load prop");
let index = gltf.meshes.len() as u32; 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); gltf.meshes.push(material);
Index::new(index) Index::new(index)
} }
@ -154,6 +155,7 @@ pub fn push_model(
model: &Model, model: &Model,
skin: i32, skin: i32,
skinned_name: String, skinned_name: String,
options: &ConvertOptions,
) -> Mesh { ) -> Mesh {
let accessor_start = gltf.accessors.len() as u32; let accessor_start = gltf.accessors.len() as u32;
push_vertices(buffer, gltf, model); push_vertices(buffer, gltf, model);
@ -164,7 +166,17 @@ pub fn push_model(
let primitives = model let primitives = model
.meshes() .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(); .collect();
Mesh { Mesh {
@ -183,6 +195,7 @@ pub fn push_primitive(
mesh: &vmdl::Mesh, mesh: &vmdl::Mesh,
vertex_accessor_start: u32, vertex_accessor_start: u32,
skin: &SkinTable, skin: &SkinTable,
options: &ConvertOptions,
) -> Primitive { ) -> Primitive {
let buffer_start = buffer.len() as u64; let buffer_start = buffer.len() as u64;
let view_start = gltf.buffer_views.len() as u32; let view_start = gltf.buffer_views.len() as u32;
@ -224,12 +237,22 @@ pub fn push_primitive(
}; };
gltf.accessors.push(accessor); gltf.accessors.push(accessor);
let texture = skin let material = if options.textures {
.texture_info(mesh.material_index()) let texture = skin
.expect("mat out of bounds"); .texture_info(mesh.material_index())
let texture_path = find_material(&texture.name, &texture.search_paths, loader) .expect("mat out of bounds");
.expect("failed to find texture"); let texture_path = find_material(&texture.name, &texture.search_paths, loader)
let material_index = push_or_get_material(buffer, gltf, loader, &texture_path); .expect("failed to find texture");
Some(push_or_get_material(
buffer,
gltf,
loader,
&texture_path,
options,
))
} else {
None
};
Primitive { Primitive {
attributes: { attributes: {
@ -251,7 +274,7 @@ pub fn push_primitive(
extensions: Default::default(), extensions: Default::default(),
extras: Default::default(), extras: Default::default(),
indices: Some(Index::new(accessor_start)), indices: Some(Index::new(accessor_start)),
material: Some(material_index), material,
mode: Valid(Mode::Triangles), mode: Valid(Mode::Triangles),
targets: None, targets: None,
} }

View file

@ -20,5 +20,13 @@
<aside> <aside>
Note that map conversion can take 10 to 30 seconds, once a map has been converted once it is cached for subsequent requests. Note that map conversion can take 10 to 30 seconds, once a map has been converted once it is cached for subsequent requests.
</aside> </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> </body>
</html> </html>

View file

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