summaryrefslogtreecommitdiff
path: root/import
diff options
context:
space:
mode:
Diffstat (limited to 'import')
-rw-r--r--import/Cargo.toml32
-rw-r--r--import/src/animation.rs133
-rw-r--r--import/src/main.rs304
-rw-r--r--import/src/mesh.rs431
-rw-r--r--import/src/physics.rs89
-rw-r--r--import/src/prefab.rs442
-rw-r--r--import/src/vrm.rs135
7 files changed, 1566 insertions, 0 deletions
diff --git a/import/Cargo.toml b/import/Cargo.toml
new file mode 100644
index 0000000..b8b3a10
--- /dev/null
+++ b/import/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "weareimport"
+version = "0.1.0"
+edition = "2024"
+
+[[bin]]
+name = "weareimporting"
+path = "src/main.rs"
+
+[dependencies]
+anyhow = "1.0.95"
+clap = { version = "4.5.23", features = ["derive"] }
+env_logger = "0.11.6"
+serde = { version = "1.0.217", features = ["derive"] }
+gltf = { version = "1.4.1", features = [
+ "extras",
+ "names",
+ "extensions",
+ "KHR_lights_punctual",
+ "KHR_materials_transmission",
+ "KHR_materials_ior",
+ "KHR_materials_volume",
+ "KHR_materials_emissive_strength",
+] }
+glam = "0.29.2"
+humansize = "2.1.3"
+image = "0.25.5"
+log = "0.4.22"
+rand = "0.9.0-beta.1"
+rayon = "1.10.0"
+serde_json = "1.0.138"
+weareshared = { path = "../shared" }
diff --git a/import/src/animation.rs b/import/src/animation.rs
new file mode 100644
index 0000000..ca049e5
--- /dev/null
+++ b/import/src/animation.rs
@@ -0,0 +1,133 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use anyhow::Result;
+use glam::{Quat, vec3a};
+use gltf::{
+ Animation,
+ animation::{Property, util::ReadOutputs},
+ buffer::Data,
+};
+use log::{debug, info};
+use std::collections::BTreeMap;
+use weareshared::{
+ Affine3A,
+ packets::Resource,
+ resources::{AnimationChannel, AnimationPart},
+ store::ResourceStore,
+};
+
+pub fn import_animation(
+ a: Animation<'_>,
+ store: &ResourceStore,
+ transform: Affine3A,
+ joint_index_to_ibm: &BTreeMap<usize, Affine3A>,
+ joint_index_to_arm_index: &BTreeMap<usize, usize>,
+ node_to_meshes: &BTreeMap<usize, Vec<usize>>,
+ buffers: &[Data],
+) -> Result<Resource<AnimationPart>> {
+ let mut max_time = 0f32;
+ let mut channels = Vec::new();
+ for c in a.channels() {
+ let node = c.target().node().index();
+ let reader = c.reader(|i| Some(&buffers[i.index()].0));
+ let inputs: Vec<f32> = reader.read_inputs().unwrap().collect::<Vec<f32>>();
+ let outputs: Vec<f32> =
+ if joint_index_to_ibm.contains_key(&node) || transform != Affine3A::IDENTITY {
+ let t = transform
+ * joint_index_to_ibm
+ .get(&node)
+ .copied()
+ .unwrap_or_default()
+ .inverse();
+ let (_, rot, _) = t.to_scale_rotation_translation();
+ match reader.read_outputs().unwrap() {
+ ReadOutputs::Translations(iter) => iter
+ .flat_map(|[x, y, z]| (t.matrix3 * vec3a(x, y, z)).to_array())
+ .collect(),
+ ReadOutputs::Rotations(iter) => iter
+ .into_f32()
+ .map(Quat::from_array)
+ .map(|q| q * rot)
+ .flat_map(|q| q.to_array())
+ .collect(),
+ // ReadOutputs::Scales(iter) => iter
+ // .flat_map(|[x, y, z]| (t.matrix3 * vec3a(x, y, z)).to_array())
+ // .collect(),
+ ReadOutputs::MorphTargetWeights(iter) => iter.into_f32().collect(),
+ _ => continue,
+ }
+ } else {
+ match reader.read_outputs().unwrap() {
+ ReadOutputs::Translations(iter) => iter.flatten().collect(),
+ ReadOutputs::Rotations(iter) => iter.into_f32().flatten().collect(),
+ ReadOutputs::Scales(iter) => iter.flatten().collect(),
+ ReadOutputs::MorphTargetWeights(iter) => iter.into_f32().collect(),
+ }
+ };
+ for x in &inputs {
+ max_time = max_time.max(*x)
+ }
+ let time = store.set(&inputs)?;
+ let value = store.set(&outputs)?;
+
+ if let Some(&m) = joint_index_to_arm_index.get(&node) {
+ let a = 0; // TODO
+ let mut ch = AnimationChannel::default();
+ match c.target().property() {
+ Property::Translation => ch.t_joint_translation = Some((a, m as u32)),
+ Property::Rotation => ch.t_joint_rotation = Some((a, m as u32)),
+ Property::Scale => ch.t_joint_scale = Some((a, m as u32)),
+ Property::MorphTargetWeights => continue,
+ }
+ ch.time = Some(time.clone());
+ ch.value = Some(value.clone());
+ debug!(
+ "animation channel {:?} of joint {m} of armature {a} with {} time and {} component values",
+ c.target().property(),
+ inputs.len(),
+ outputs.len()
+ );
+ channels.push(ch);
+ }
+ if let Some(meshes) = node_to_meshes.get(&node) {
+ for &m in meshes {
+ let mut ch = AnimationChannel::default();
+ match c.target().property() {
+ Property::Translation => ch.t_mesh_translation = Some(m as u32),
+ Property::Rotation => ch.t_mesh_rotation = Some(m as u32),
+ Property::Scale => ch.t_mesh_scale = Some(m as u32),
+ Property::MorphTargetWeights => continue,
+ }
+ ch.time = Some(time.clone());
+ ch.value = Some(value.clone());
+ debug!(
+ "animation channel {:?} of mesh {m} with {} time and {} component values",
+ c.target().property(),
+ inputs.len(),
+ outputs.len()
+ );
+ channels.push(ch);
+ }
+ }
+ }
+ info!("adding animation with {} channels", channels.len());
+ store.set(&AnimationPart {
+ name: a.name().map(|n| n.to_string()),
+ channel: channels,
+ duration: Some(max_time),
+ })
+}
diff --git a/import/src/main.rs b/import/src/main.rs
new file mode 100644
index 0000000..554a2bd
--- /dev/null
+++ b/import/src/main.rs
@@ -0,0 +1,304 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+#![feature(iter_array_chunks)]
+#![allow(clippy::too_many_arguments, clippy::type_complexity)]
+pub mod animation;
+pub mod mesh;
+pub mod physics;
+pub mod prefab;
+pub mod vrm;
+
+use anyhow::{Result, bail};
+use clap::Parser;
+use gltf::{image::Source, scene::Transform};
+use humansize::BINARY;
+use image::{ImageReader, codecs::webp::WebPEncoder};
+use log::{debug, info};
+use prefab::import_prefab;
+use rand::random;
+use std::{
+ borrow::Cow,
+ collections::HashMap,
+ fs::File,
+ io::{BufWriter, Cursor, Read, Write},
+ marker::PhantomData,
+ net::{SocketAddr, TcpStream},
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex},
+ thread::{self, sleep},
+ time::Duration,
+};
+use weareshared::{
+ Affine3A, Vec3A,
+ helper::ReadWrite,
+ packets::{Data, Object, Packet, Resource},
+ resources::{Image, RespackEntry},
+ respack::save_respack,
+ store::ResourceStore,
+ vec3a,
+};
+
+#[derive(Parser)]
+pub struct Args {
+ #[arg(short, long)]
+ address: Option<SocketAddr>,
+
+ /// Output converted prefab as resource package
+ #[arg(short = 'o', long)]
+ pack: Option<PathBuf>,
+
+ /// Path(s) to a glTF file, binary or json format
+ scene: Vec<PathBuf>,
+ /// Send all resources to the server then quit
+ #[arg(short, long)]
+ push: bool,
+
+ /// Remove all other object from the world
+ #[arg(short, long)]
+ clear: bool,
+ /// Add the object to the world
+ #[arg(short, long)]
+ add: bool,
+ /// Transcode all textures to WebP
+ #[arg(short, long)]
+ webp: bool,
+ /// Add skybox
+ #[arg(long)]
+ skybox: Option<PathBuf>,
+ /// Override prefab name
+ #[arg(short, long)]
+ name: Option<String>,
+ #[arg(short, long)]
+ scale: Option<f32>,
+ #[arg(short, long)]
+ dry_run: bool,
+ #[arg(short, long)]
+ line_up: bool,
+
+ #[arg(long)]
+ use_cache: bool,
+
+ #[arg(short = 'S', long)]
+ with_default_sun: bool,
+
+ #[arg(long)]
+ animation: Option<PathBuf>,
+ #[arg(long)]
+ animation_bone_map: Option<PathBuf>,
+ #[arg(long)]
+ animation_rotation_y: Option<f32>,
+ #[arg(long)]
+ animation_scale: Option<f32>,
+ #[arg(long)]
+ animation_apply_ibm: bool,
+
+ /// Spins the object
+ #[arg(long)]
+ debug_spin: bool,
+ /// Adds a light
+ #[arg(long)]
+ debug_light: bool,
+ #[arg(long)]
+ no_particles: bool,
+ #[arg(long)]
+ no_animations: bool,
+}
+
+fn main() -> Result<()> {
+ env_logger::init_from_env("LOG");
+ let args = Args::parse();
+
+ let store = if args.use_cache && !args.pack.is_some() {
+ ResourceStore::new_env()?
+ } else {
+ ResourceStore::new_memory()
+ };
+
+ let mut prefabs = Vec::new();
+ let texture_cache = Arc::new(Mutex::new(HashMap::new()));
+
+ for scenepath in &args.scene {
+ prefabs.push(import_prefab(&store, &texture_cache, scenepath, &args)?);
+ }
+
+ let mut size = 0;
+ store.iter(|_k, len| size += len).unwrap();
+ info!(
+ "prefab has network size of {}",
+ humansize::format_size(size, BINARY)
+ );
+
+ if args.dry_run {
+ return Ok(());
+ }
+ if let Some(outpath) = args.pack {
+ let entry = store.set(&RespackEntry { name: None })?;
+ let mut resources = Vec::new();
+ store.iter(|r, _| resources.push(r))?;
+ save_respack(
+ BufWriter::new(File::create(outpath)?),
+ &store,
+ &resources,
+ Some(entry),
+ )?;
+ } else if let Some(address) = args.address {
+ let mut sock = TcpStream::connect(address)?;
+ Packet::Connect(random()).write(&mut sock)?;
+ for p in &prefabs {
+ Packet::AnnouncePrefab(p.clone()).write(&mut sock)?;
+ }
+ sock.flush()?;
+
+ let mut obs = Vec::new();
+ if args.add {
+ for (i, p) in prefabs.iter().enumerate() {
+ let ob = Object::new();
+ info!("adding object {ob}");
+ Packet::Add(ob, p.clone()).write(&mut sock)?;
+ if args.line_up {
+ Packet::Position(ob, vec3a(i as f32 * 1.2, 0., i as f32 * 0.3), Vec3A::ZERO)
+ .write(&mut sock)?;
+ }
+ obs.push(ob);
+ }
+ sock.flush()?;
+ }
+
+ if args.debug_spin {
+ let ob = obs[0];
+ let mut sock2 = sock.try_clone().unwrap();
+ thread::spawn(move || {
+ let mut x = 0.;
+ loop {
+ Packet::Position(ob, Vec3A::ZERO, vec3a(0., x * 0.1, 0.))
+ .write(&mut sock2)
+ .unwrap();
+ sock2.flush().unwrap();
+ x += 0.1;
+ sleep(Duration::from_millis(50));
+ }
+ });
+ }
+ if args.push {
+ if args.use_cache {
+ return Ok(());
+ }
+ store.iter(|k, _| {
+ Packet::RespondResource(k, Data(store.get_raw(k).unwrap().unwrap()))
+ .write(&mut sock)
+ .unwrap();
+ })?;
+ sock.flush()?;
+ } else {
+ loop {
+ let packet = Packet::read(&mut sock)?;
+ match packet {
+ Packet::RequestResource(hash) => {
+ if let Some(d) = store.get_raw(hash)? {
+ Packet::RespondResource(hash, Data(d)).write(&mut sock)?;
+ sock.flush()?;
+ }
+ }
+ Packet::Add(ob_a, _) => {
+ if args.clear && !obs.contains(&ob_a) {
+ info!("removing object {ob_a}");
+ Packet::Remove(ob_a).write(&mut sock)?;
+ sock.flush()?;
+ }
+ }
+ _ => (),
+ }
+ }
+ }
+ } else {
+ bail!("no output option specified. either provide an address to a server or use --pack")
+ }
+ Ok(())
+}
+
+pub type TextureCache = Arc<Mutex<HashMap<String, Resource<Image<'static>>>>>;
+fn load_texture(
+ name: &str,
+ store: &ResourceStore,
+ path: &Path,
+ buffers: &[gltf::buffer::Data],
+ source: &Source,
+ webp: bool,
+ texture_cache: &TextureCache,
+) -> Result<Resource<Image<'static>>> {
+ let (mut image, uri) = match source {
+ gltf::image::Source::View { view, mime_type } => {
+ debug!("{name} texture is embedded and of type {mime_type:?}");
+ let buf =
+ &buffers[view.buffer().index()].0[view.offset()..view.offset() + view.length()];
+ (Image(Cow::Borrowed(buf)), None)
+ }
+ gltf::image::Source::Uri {
+ uri,
+ mime_type: Some(mime_type),
+ } => {
+ debug!("{name} texture is {uri:?} and of type {mime_type:?}");
+ if let Some(res) = texture_cache.lock().unwrap().get(*uri) {
+ return Ok(res.to_owned());
+ }
+ let path = path.join(uri);
+ let mut buf = Vec::new();
+ File::open(path)?.read_to_end(&mut buf)?;
+ (Image(buf.into()), Some(uri.to_string()))
+ }
+ gltf::image::Source::Uri {
+ uri,
+ mime_type: None,
+ } => {
+ debug!("{name} texture is {uri:?} and has no type");
+ if let Some(res) = texture_cache.lock().unwrap().get(*uri) {
+ return Ok(res.to_owned());
+ }
+ let path = path.join(uri);
+ let mut buf = Vec::new();
+ File::open(path)?.read_to_end(&mut buf)?;
+ (Image(buf.into()), Some(uri.to_string()))
+ }
+ };
+
+ if webp {
+ let mut image_out = Vec::new();
+
+ let len = image.0.len();
+ ImageReader::new(Cursor::new(image.0))
+ .with_guessed_format()?
+ .decode()?
+ .write_with_encoder(WebPEncoder::new_lossless(&mut image_out))?;
+ debug!("webp encode: {len} -> {}", image_out.len());
+ image = Image(Cow::Owned(image_out));
+ }
+ let res = Resource(store.set(&image)?.0, PhantomData);
+ if let Some(uri) = uri {
+ texture_cache.lock().unwrap().insert(uri, res.clone());
+ }
+ Ok(res)
+}
+
+pub fn transform_to_affine(trans: Transform) -> Affine3A {
+ let mat = trans.matrix();
+ Affine3A::from_cols_array_2d(&[
+ [mat[0][0], mat[0][1], mat[0][2]],
+ [mat[1][0], mat[1][1], mat[1][2]],
+ [mat[2][0], mat[2][1], mat[2][2]],
+ [mat[3][0], mat[3][1], mat[3][2]],
+ ])
+}
diff --git a/import/src/mesh.rs b/import/src/mesh.rs
new file mode 100644
index 0000000..ffc0f2f
--- /dev/null
+++ b/import/src/mesh.rs
@@ -0,0 +1,431 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use crate::{Args, TextureCache, load_texture, vrm::VrmInfo};
+use anyhow::Result;
+use gltf::{Mesh, Node, buffer::Data};
+use log::{debug, info, warn};
+use std::{collections::BTreeMap, path::Path};
+use weareshared::{
+ Affine3A, Vec3A, packets::Resource, resources::MeshPart, store::ResourceStore, vec2, vec3a,
+ vec4,
+};
+
+pub fn import_mesh(
+ mesh: Mesh,
+ trans: Affine3A,
+ buffers: &[Data],
+ store: &ResourceStore,
+ path_base: &Path,
+ node: &Node,
+ args: &Args,
+ texture_cache: &TextureCache,
+ joint_index_map: &BTreeMap<(usize, u16), u32>,
+ vrm: &VrmInfo,
+ head_bones: &[u32],
+) -> Result<Vec<(usize, Affine3A, Resource<MeshPart>)>> {
+ let mut meshes = Vec::new();
+ for p in mesh.primitives() {
+ let name = mesh.name().or(node.name()).map(|e| e.to_owned());
+ if let Some(name) = &name {
+ info!("adding mesh {name:?}");
+ } else {
+ info!("adding mesh");
+ }
+ let reader = p.reader(|buf| Some(&buffers[buf.index()]));
+
+ let mut num_vertex = 0;
+ let va_position = reader
+ .read_positions()
+ .map(|iter| {
+ let a = iter.map(|[x, y, z]| vec3a(x, y, z)).collect::<Vec<_>>();
+ debug!("{} vertex positions", a.len());
+ num_vertex = a.len();
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_normal = reader
+ .read_normals()
+ .map(|iter| {
+ let a = iter.map(|[x, y, z]| vec3a(x, y, z)).collect::<Vec<_>>();
+ debug!("{} vertex normals", a.len());
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_tangent = reader
+ .read_tangents()
+ .map(|iter| {
+ // TODO dont ignore handedness
+ let a = iter
+ .map(|[x, y, z, h]| vec4(x, y, z, h))
+ .collect::<Vec<_>>();
+ debug!("{} vertex tangents", a.len());
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let mut many_head_bones = false;
+ let va_joint_index = reader
+ .read_joints(0)
+ .map(|iter| {
+ let si = node.skin().unwrap().index();
+ let a = iter
+ .into_u16()
+ .map(|x| x.map(|x| joint_index_map[&(si, x)]))
+ .collect::<Vec<_>>();
+
+ let head_bone_count = a
+ .iter()
+ .flatten()
+ .filter(|b| head_bones.contains(*b))
+ .count();
+ many_head_bones |= head_bone_count > a.len() / 2;
+ debug!("{} vertex joint indecies", a.len());
+ if many_head_bones {
+ debug!("many joints are head bones");
+ }
+ if a.len() != num_vertex {
+ warn!("joint index count does not vertex count")
+ }
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_joint_weight = reader
+ .read_weights(0)
+ .map(|iter| {
+ let a = iter.into_f32().collect::<Vec<_>>();
+ debug!("{} vertex joint weights", a.len());
+ if a.len() != num_vertex {
+ warn!("joint weight count does not vertex count")
+ }
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_texcoord = reader
+ .read_tex_coords(0)
+ .map(|iter| {
+ let a = iter.into_f32().map(|[x, y]| vec2(x, y)).collect::<Vec<_>>();
+ debug!("{} vertex texture coordinates", a.len());
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_albedo = reader
+ .read_colors(0)
+ .map(|iter| {
+ let a = iter
+ .into_rgb_f32()
+ .map(|[x, y, z]| vec3a(x, y, z))
+ .collect::<Vec<_>>();
+ debug!("{} vertex colors", a.len());
+ store.set(&a)
+ })
+ .transpose()?;
+
+ let va_alpha = reader
+ .read_colors(0)
+ .map(|iter| {
+ let mut color_a = vec![];
+ for p in iter.into_rgba_f32() {
+ color_a.push(p[3]);
+ }
+ let o = if color_a.iter().any(|x| *x != 1.) {
+ debug!("{} vertex transmissions", color_a.len());
+ Some(store.set(&color_a)?)
+ } else {
+ debug!("vertex transmission pruned");
+ None
+ };
+ Ok::<_, anyhow::Error>(o)
+ })
+ .transpose()?
+ .flatten();
+
+ let index = reader
+ .read_indices()
+ .unwrap()
+ .into_u32()
+ .array_chunks::<3>()
+ .collect::<Vec<_>>();
+
+ debug!("{} indecies", index.len() * 3);
+ let index = Some(store.set(&index)?);
+
+ let mut tex_albedo = None;
+ let mut tex_alpha = None;
+ if let Some(tex) = p.material().pbr_metallic_roughness().base_color_texture() {
+ let r = load_texture(
+ "albedo",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?;
+ tex_albedo = Some(r.clone());
+ tex_alpha = Some(r.clone());
+ }
+ let mut tex_normal = None;
+ if let Some(tex) = p.material().normal_texture() {
+ tex_normal = Some(load_texture(
+ "normal",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?);
+ }
+ let mut tex_emission = None;
+ if let Some(tex) = p.material().emissive_texture() {
+ tex_emission = Some(load_texture(
+ "emission",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?);
+ }
+ let mut tex_transmission = None;
+ if let Some(tex) = p
+ .material()
+ .transmission()
+ .and_then(|t| t.transmission_texture())
+ {
+ tex_transmission = Some(load_texture(
+ "transmission",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?);
+ }
+ let mut tex_thickness = None;
+ if let Some(tex) = p.material().volume().and_then(|t| t.thickness_texture()) {
+ tex_thickness = Some(load_texture(
+ "thickness",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?);
+ }
+ let mut tex_occlusion = None;
+ if let Some(tex) = p.material().occlusion_texture() {
+ tex_occlusion = Some(load_texture(
+ "occlusion",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?);
+ }
+ let mut tex_roughness = None;
+ let mut tex_metallic = None;
+ if let Some(tex) = p
+ .material()
+ .pbr_metallic_roughness()
+ .metallic_roughness_texture()
+ {
+ let r = load_texture(
+ "metallic+roughness",
+ store,
+ path_base,
+ buffers,
+ &tex.texture().source().source(),
+ args.webp,
+ texture_cache,
+ )?;
+ tex_roughness = Some(r.clone());
+ tex_metallic = Some(r.clone());
+ }
+
+ let g_metallic = Some(p.material().pbr_metallic_roughness().metallic_factor());
+ let g_roughness = Some(p.material().pbr_metallic_roughness().roughness_factor());
+
+ let base_color = p.material().pbr_metallic_roughness().base_color_factor();
+
+ let g_albedo = if base_color[0] != 1. || base_color[1] != 1. || base_color[2] != 1. {
+ debug!(
+ "albedo is r={},g={},b={}",
+ base_color[0], base_color[1], base_color[2]
+ );
+ Some(Vec3A::new(base_color[0], base_color[1], base_color[2]))
+ } else {
+ debug!("albedo pruned");
+ None
+ };
+ let g_alpha = if base_color[3] != 1. {
+ debug!("alpha is {}", base_color[3]);
+ Some(base_color[3])
+ } else {
+ debug!("alpha pruned");
+ None
+ };
+
+ let emission = p.material().emissive_factor();
+ let g_emission = if emission[0] != 0. || emission[1] != 0. || emission[2] != 0. {
+ debug!(
+ "emission is r={},g={},b={}",
+ base_color[0], base_color[1], base_color[2]
+ );
+ Some(Vec3A::new(emission[0], emission[1], emission[2]))
+ } else {
+ debug!("emission pruned");
+ None
+ };
+
+ let transmission = p
+ .material()
+ .transmission()
+ .map(|t| t.transmission_factor())
+ .unwrap_or(0.);
+
+ let g_transmission = if transmission != 0. {
+ debug!("transmission is {transmission}");
+ Some(transmission)
+ } else {
+ debug!("transmission pruned");
+ None
+ };
+
+ let g_dispersion = p
+ .material()
+ .extension_value("KHR_materials_dispersion")
+ .and_then(|e| e.get("dispersion"))
+ .and_then(|e| e.as_f64())
+ .map(|e| e as f32);
+ if let Some(d) = g_dispersion {
+ debug!("dispersion is {d}");
+ }
+
+ // if node.name() == Some("fog") {
+ // eprintln!("{:#?}", p.material().volume().is_some());
+ // eprintln!("{:#?}", p.material().ior());
+ // eprintln!("{:#?}", p.material().transmission().is_some());
+ // }
+
+ let g_attenuation = p.material().volume().map(|v| {
+ let ref_dist = v.attenuation_distance();
+ let att = Vec3A::from_array(v.attenuation_color().map(
+ // manually derived from attenuation coefficient formula. i hope this is correct.
+ |factor| -(factor.powf(1. / ref_dist)).ln(),
+ ));
+ debug!("attenuation is {att}");
+ att
+ });
+ let g_refractive_index = p.material().ior();
+ if let Some(i) = g_refractive_index {
+ debug!("refractive index is {i}");
+ }
+ let g_thickness = p.material().volume().map(|v| v.thickness_factor());
+
+ let g_unlit = bool_to_opt(
+ p.material()
+ .extension_value("KHR_materials_unlit")
+ .is_some(),
+ "unlit",
+ );
+
+ let g_double_sided = bool_to_opt(p.material().double_sided(), "double sided");
+
+ let hint_volume = bool_to_opt(
+ g_attenuation.is_some_and(|a| a.length() > 0.01),
+ "volume hint",
+ );
+
+ let hint_hide_first_person = bool_to_opt(
+ many_head_bones | vrm.hide_first_person.contains(&node.index()),
+ "hide first person hint",
+ );
+
+ let hint_mirror = bool_to_opt(
+ node.name().unwrap_or_default().ends_with("-mirror"),
+ "mirror hint",
+ );
+
+ let armature = node.skin().map(|_| 0);
+
+ let mesh = MeshPart {
+ name,
+ index,
+ armature,
+ g_albedo,
+ g_alpha,
+ g_metallic,
+ g_roughness,
+ g_emission,
+ g_transmission,
+ g_attenuation,
+ g_thickness,
+ g_refractive_index,
+ g_dispersion,
+ g_unlit,
+ g_double_sided,
+ va_position,
+ va_normal,
+ va_tangent,
+ va_texcoord,
+ va_albedo,
+ va_alpha,
+ va_joint_index,
+ va_joint_weight,
+ tex_albedo,
+ tex_normal,
+ tex_roughness,
+ tex_metallic,
+ tex_alpha,
+ tex_emission,
+ tex_transmission,
+ tex_thickness,
+ tex_occlusion,
+ hint_hide_first_person,
+ hint_mirror,
+ hint_volume,
+ // not supported by gltf
+ hint_static: None, // TODO Set when instancing
+ va_transmission: None,
+ va_emission: None,
+ va_metallic: None,
+ va_roughness: None,
+ };
+ meshes.push((node.index(), trans, store.set(&mesh)?))
+ }
+ Ok(meshes)
+}
+
+fn bool_to_opt(b: bool, log: &str) -> Option<()> {
+ if b {
+ debug!("{log}");
+ Some(())
+ } else {
+ None
+ }
+}
diff --git a/import/src/physics.rs b/import/src/physics.rs
new file mode 100644
index 0000000..3beb49b
--- /dev/null
+++ b/import/src/physics.rs
@@ -0,0 +1,89 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use anyhow::{Result, anyhow};
+use gltf::{Gltf, Node, buffer::Data, json::Value};
+use log::{debug, info};
+use weareshared::{
+ Affine3A, packets::Resource, resources::CollisionPart, store::ResourceStore, vec3a,
+};
+
+pub fn import_physics(
+ gltf: &Gltf,
+ trans: Affine3A,
+ node: &Node,
+ store: &ResourceStore,
+ buffers: &[Data],
+) -> Result<Vec<(Affine3A, Resource<CollisionPart>)>> {
+ let mut collision = Vec::new();
+ if let Some(physics) = node
+ .extensions()
+ .and_then(|e| e.get("KHR_physics_rigid_bodies"))
+ {
+ debug!("--- COLLISION ---");
+ if let Some(collider) = physics.get("collider") {
+ if let Some(geometry) = collider.get("geometry") {
+ if let Some(&Value::Bool(chull)) = geometry.get("convexHull") {
+ let node = geometry
+ .get("node")
+ .and_then(|n| n.as_u64())
+ .ok_or(anyhow!("coll geom node missing"))?;
+ let node = gltf
+ .nodes()
+ .nth(node as usize)
+ .ok_or(anyhow!("coll geom node reference invalid"))?;
+ let mesh = node.mesh().ok_or(anyhow!("coll geom node has no mesh"))?;
+ for p in mesh.primitives() {
+ let reader = p.reader(|buf| Some(&buffers[buf.index()]));
+
+ let index = reader
+ .read_indices()
+ .ok_or(anyhow!("coll geom no index buffer"))?
+ .into_u32()
+ .array_chunks::<3>()
+ .collect::<Vec<_>>();
+ let position = reader
+ .read_positions()
+ .ok_or(anyhow!("coll geom no positions"))?
+ .map(|[x, y, z]| vec3a(x, y, z))
+ .collect::<Vec<_>>();
+
+ let mut collpart = CollisionPart {
+ name: node.name().map(|s| s.to_string()),
+ ..Default::default()
+ };
+
+ if chull {
+ debug!("convex hull has {} positions", position.len());
+ collpart.sh_convex_hull = Some(store.set(&position)?);
+ } else {
+ debug!(
+ "mesh has {} indecies and {} positions",
+ index.len(),
+ position.len()
+ );
+ collpart.sh_mesh = Some((store.set(&index)?, store.set(&position)?));
+ }
+
+ info!("added collision {:?}", node.name().unwrap_or_default());
+ collision.push((trans, store.set(&collpart)?));
+ }
+ }
+ }
+ }
+ }
+ Ok(collision)
+}
diff --git a/import/src/prefab.rs b/import/src/prefab.rs
new file mode 100644
index 0000000..62690f7
--- /dev/null
+++ b/import/src/prefab.rs
@@ -0,0 +1,442 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use crate::{
+ Args, TextureCache, animation::import_animation, mesh::import_mesh, physics::import_physics,
+ transform_to_affine, vrm::extract_vrm_data,
+};
+use anyhow::{Context, Result, anyhow};
+use glam::{Affine3A, Vec3, Vec3A, vec3a};
+use gltf::{Gltf, Node, import_buffers, scene::Transform};
+use log::{debug, info};
+use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
+use serde::Deserialize;
+use serde_json::Value;
+use std::{
+ borrow::Cow,
+ collections::BTreeMap,
+ fs::{File, read_to_string},
+ io::Read,
+ path::Path,
+ sync::Mutex,
+};
+use weareshared::{
+ packets::Resource,
+ resources::{
+ ArmaturePart, AvatarInfoPart, EnvironmentPart, Image, LightPart, ParticlesPart, Prefab,
+ },
+ store::ResourceStore,
+};
+
+pub fn import_prefab(
+ store: &ResourceStore,
+ texture_cache: &TextureCache,
+ scenepath: &Path,
+ args: &Args,
+) -> Result<Resource<Prefab>> {
+ let path_base = scenepath.parent().unwrap();
+ let mut gltf =
+ Gltf::from_reader_without_validation(File::open(scenepath)?).context("gltf parsing")?;
+ let blob = gltf.blob.take();
+ let buffers = import_buffers(&gltf, Some(path_base), blob).context("importing buffers")?;
+
+ let root = gltf.default_scene().ok_or(anyhow!("no default scene"))?;
+
+ // gltf.as_json().asset.copyright
+ // eprintln!("{:?}", gltf.extensions_used());
+ // eprintln!("{:?}", gltf.extensions());
+ // eprintln!("{:?}", root.extensions());
+ // eprintln!("{:?}", root.extras());
+
+ let mut nodes = Vec::new();
+ fn traverse<'a>(out: &mut Vec<(Affine3A, Node<'a>)>, node: Node<'a>, trans: Affine3A) {
+ let trans = trans * transform_to_affine(node.transform());
+ for c in node.children() {
+ traverse(out, c, trans);
+ }
+ out.push((trans, node));
+ }
+
+ let mut root_affine = Affine3A::IDENTITY;
+ root_affine.matrix3 *= args.scale.unwrap_or(1.);
+ root_affine.translation *= args.scale.unwrap_or(1.);
+
+ for node in root.nodes() {
+ traverse(&mut nodes, node, root_affine);
+ }
+
+ let vrm = extract_vrm_data(&gltf)?;
+
+ let mut skin_index_to_arm_index = BTreeMap::new();
+ let mut joint_index_to_arm_index = BTreeMap::new();
+ let joint_name_to_arm_index: BTreeMap<String, usize>;
+ let armature = {
+ let mut name = Vec::new();
+ let mut parent_pre_map = Vec::new();
+ let mut transform = Vec::new();
+ let mut inverse_bind_transform = Vec::new();
+
+ for skin in gltf.skins() {
+ let mut inverse_bind_mat = skin
+ .reader(|buf| Some(&buffers[buf.index()]))
+ .read_inverse_bind_matrices();
+ for (j_ind, j) in skin.joints().enumerate() {
+ let ibm = inverse_bind_mat.as_mut().map(|x| x.next().unwrap());
+ let a_ind = match joint_index_to_arm_index.get(&j.index()) {
+ Some(i) => *i,
+ None => {
+ let a_ind = name.len();
+ // name.push(j.name().unwrap_or("").to_string());
+ name.push(format!("bone{}", a_ind));
+ transform.push(transform_to_affine(j.transform()));
+ parent_pre_map.push(
+ gltf.nodes()
+ .find(|n| n.children().any(|c| c.index() == j.index()))
+ .map(|n| n.index()),
+ );
+ inverse_bind_transform.push(
+ ibm.map(|a| transform_to_affine(Transform::Matrix { matrix: a }))
+ .unwrap_or(Affine3A::IDENTITY),
+ );
+
+ joint_index_to_arm_index.insert(j.index(), a_ind);
+ a_ind
+ }
+ };
+ skin_index_to_arm_index.insert((skin.index(), j_ind as u16), a_ind as u32);
+ }
+ }
+
+ let parent = parent_pre_map
+ .into_iter()
+ .enumerate()
+ .map(|(i, p)| {
+ p.and_then(|i| joint_index_to_arm_index.get(&i).copied())
+ .unwrap_or(i) as u16
+ })
+ .collect::<Vec<_>>();
+
+ for (node, bname) in &vrm.bone_node_names {
+ let ind = joint_index_to_arm_index[node];
+ name[ind] = bname.to_owned();
+ }
+
+ joint_name_to_arm_index = name
+ .iter()
+ .cloned()
+ .enumerate()
+ .map(|(a, b)| (b, a))
+ .collect();
+
+ ArmaturePart {
+ name: Some(name),
+ parent: Some(parent),
+ transform: Some(transform),
+ inverse_bind_transform: Some(inverse_bind_transform),
+ }
+ };
+
+ let head_bones = {
+ let pa = armature.parent.clone().unwrap_or_default();
+ let na = armature.name.clone().unwrap_or_default();
+ (0..pa.len())
+ .filter(|&(mut i)| {
+ let mut f = false;
+ while pa[i] as usize != i {
+ f |= na[i] == "head";
+ i = pa[i] as usize;
+ }
+ f
+ })
+ .map(|e| e as u32)
+ .collect::<Vec<_>>()
+ };
+
+ let prefab = Mutex::new(Prefab::default());
+ let node_to_meshes = Mutex::new(BTreeMap::<usize, Vec<usize>>::new());
+
+ nodes
+ .par_iter()
+ .map(|(trans, node)| {
+ // if node.name().unwrap_or_default() == "particles" {
+ // eprintln!("{:?}", node.transform());
+ // eprintln!("{:?}", node.extensions());
+ // eprintln!("{:?}", node.extras());
+ // }
+ let name = node.name().unwrap_or_default();
+ let extras: Value = node
+ .extras()
+ .to_owned()
+ .map(|v| serde_json::from_str(v.get()).unwrap())
+ .unwrap_or(Value::Object(Default::default()));
+
+ if !name.ends_with("-collider") {
+ if let Some(mesh) = node.mesh() {
+ let meshes = import_mesh(
+ mesh,
+ *trans,
+ &buffers,
+ &store,
+ path_base,
+ node,
+ &args,
+ &texture_cache,
+ &skin_index_to_arm_index,
+ &vrm,
+ &head_bones,
+ )?;
+ for (node, trans, mesh) in meshes {
+ let mut k = prefab.lock().unwrap();
+ let i = k.mesh.len();
+ k.mesh.push((trans, mesh));
+ node_to_meshes
+ .lock()
+ .unwrap()
+ .entry(node)
+ .or_default()
+ .push(i)
+ }
+ }
+ }
+ if extras.get("particles") == Some(&Value::Bool(true)) && !args.no_particles {
+ #[derive(Deserialize)]
+ struct ParticlesAttr {
+ density: Option<f32>,
+ lifetime: Option<f32>,
+ lifetime_spread: Option<f32>,
+ velocity: Option<Vec3A>,
+ velocity_spread: Option<Vec3A>,
+ }
+ // let sprite = extras
+ // .get("sprite")
+ // .ok_or(anyhow!("particle volume is missing sprite"))?;
+
+ let attr: ParticlesAttr =
+ serde_json::from_value(extras).context("particles attributes")?;
+
+ info!("adding particles part");
+
+ let part = store.set(&ParticlesPart {
+ sprite: None,
+ density: attr.density,
+ lifetime: attr.lifetime,
+ lifetime_spread: attr.lifetime_spread,
+ velocity: attr.velocity,
+ velocity_spread: attr.velocity_spread,
+ })?;
+
+ prefab
+ .lock()
+ .unwrap()
+ .particles
+ .push((transform_to_affine(node.transform()), part));
+ }
+
+ if let Some(light) = node.light() {
+ let name = node.name().map(|e| e.to_owned());
+ if let Some(name) = &name {
+ info!("adding light {name:?}");
+ } else {
+ info!("adding light");
+ }
+ let emission = Some(Vec3A::from_array(light.color()) * light.intensity());
+ if let Some(e) = emission {
+ debug!("emission is {e}");
+ }
+ let (position, _, _) = node.transform().decomposed();
+ let part = store.set(&LightPart {
+ emission,
+ name,
+ radius: None,
+ })?;
+ prefab
+ .lock()
+ .unwrap()
+ .light
+ .push((Vec3A::from_array(position), part));
+ }
+ {
+ let collider = import_physics(&gltf, *trans, node, &store, &buffers)?;
+ prefab.lock().unwrap().collision.extend(collider);
+ }
+
+ Ok::<_, anyhow::Error>(())
+ })
+ .reduce(
+ || Ok(()),
+ |a, b| match (a, b) {
+ (Ok(()), Ok(())) => Ok(()),
+ (Ok(()), a) => a,
+ (a, _) => a,
+ },
+ )?;
+
+ let mut prefab = prefab.into_inner().unwrap();
+ let node_to_meshes = node_to_meshes.into_inner().unwrap();
+
+ if let Some(apath) = args.animation.clone() {
+ let path_base = apath.parent().unwrap();
+ let mut gltf =
+ Gltf::from_reader_without_validation(File::open(&apath)?).context("gltf parsing")?;
+ let blob = gltf.blob.take();
+ let buffers = import_buffers(&gltf, Some(path_base), blob).context("importing buffers")?;
+
+ let anim_name_map = if let Some(ref map_path) = args.animation_bone_map {
+ let mut map = BTreeMap::new();
+ for l in read_to_string(map_path)?.lines() {
+ if !l.trim().is_empty() && !l.starts_with(";") {
+ let (a, b) = l.split_once("=").unwrap();
+ map.insert(a.to_string(), b.to_string());
+ }
+ }
+ Some(map)
+ } else {
+ None
+ };
+
+ let mut anim_joint_index_to_arm_index = BTreeMap::new();
+ let mut joint_index_to_ibm = BTreeMap::new();
+ for n in gltf.nodes() {
+ if let Some(name) = n.name() {
+ let Some(vrm_name) = (if let Some(map) = &anim_name_map {
+ map.get(name).cloned()
+ } else {
+ Some(name.to_string())
+ }) else {
+ continue;
+ };
+ let anim_node_index = n.index();
+ if let Some(scene_arm_index) = joint_name_to_arm_index.get(&vrm_name) {
+ debug!(
+ "mapping {name:?} (node={}) -> {vrm_name:?} (bone={})",
+ anim_node_index, scene_arm_index
+ );
+ anim_joint_index_to_arm_index.insert(anim_node_index, *scene_arm_index);
+ }
+ }
+ }
+ if args.animation_apply_ibm {
+ for s in gltf.skins() {
+ let reader = s.reader(|buf| Some(&buffers[buf.index()]));
+ if let Some(ibms) = reader.read_inverse_bind_matrices() {
+ for (jn, ibm) in s.joints().zip(ibms) {
+ joint_index_to_ibm.insert(
+ jn.index(),
+ transform_to_affine(Transform::Matrix { matrix: ibm }),
+ );
+ }
+ }
+ }
+ debug!("{} joint IBMs found", joint_index_to_ibm.len());
+ }
+ let transform = args
+ .animation_rotation_y
+ .map(Affine3A::from_rotation_y)
+ .unwrap_or_default()
+ * args
+ .animation_scale
+ .map(Vec3::splat)
+ .map(Affine3A::from_scale)
+ .unwrap_or_default();
+ for a in gltf.animations() {
+ prefab.animation.push(import_animation(
+ a,
+ &store,
+ transform,
+ &joint_index_to_ibm,
+ &anim_joint_index_to_arm_index,
+ &BTreeMap::new(),
+ &buffers,
+ )?);
+ }
+ }
+
+ for a in gltf.animations() {
+ if !args.no_animations {
+ prefab.animation.push(import_animation(
+ a,
+ &store,
+ Affine3A::IDENTITY,
+ &BTreeMap::new(),
+ &joint_index_to_arm_index,
+ &node_to_meshes,
+ &buffers,
+ )?);
+ };
+ }
+
+ if vrm.camera_mount.is_some()
+ || vrm.camera_mount_offset.is_some()
+ || !vrm.bone_node_names.is_empty()
+ {
+ info!("avatar info enabled");
+ prefab.avatar_info = Some(
+ store.set(&AvatarInfoPart {
+ armature: Some(0), // TODO
+ camera_mount: vrm
+ .camera_mount
+ .map(|e| joint_index_to_arm_index[&e] as u32),
+ camera_mount_offset: vrm.camera_mount_offset,
+ ..Default::default()
+ })?,
+ );
+ }
+
+ prefab.armature = if armature.parent.as_ref().is_some_and(|a| !a.is_empty()) {
+ vec![store.set(&armature)?]
+ } else {
+ vec![]
+ };
+
+ let skybox = if let Some(skybox) = &args.skybox {
+ let mut buf = Vec::new();
+ File::open(skybox)?.read_to_end(&mut buf)?;
+ Some(store.set(&Image(Cow::Owned(buf)))?)
+ } else {
+ None
+ };
+ let sun = if args.with_default_sun {
+ Some((
+ vec3a(1., -5., 1.).normalize(),
+ vec3a(1., 1., 1.).normalize() * 100_000.,
+ ))
+ } else {
+ None
+ };
+ prefab.environment = if skybox.is_some() || sun.is_some() {
+ Some(store.set(&EnvironmentPart { skybox, sun })?)
+ } else {
+ None
+ };
+
+ prefab.name = args.name.clone().or(gltf
+ .default_scene()
+ .and_then(|n| n.name())
+ .map(|n| n.to_owned()));
+
+ if args.debug_light {
+ prefab.light.push((
+ vec3a(5., 5., 5.),
+ store.set(&LightPart {
+ name: Some("debug light".to_owned()),
+ emission: Some(vec3a(10., 5., 15.)),
+ radius: Some(0.3),
+ })?,
+ ));
+ }
+
+ Ok(store.set(&prefab)?)
+}
diff --git a/import/src/vrm.rs b/import/src/vrm.rs
new file mode 100644
index 0000000..f014e66
--- /dev/null
+++ b/import/src/vrm.rs
@@ -0,0 +1,135 @@
+/*
+ wearechat - generic multiplayer game with voip
+ Copyright (C) 2025 metamuffin
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, version 3 of the License only.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+*/
+use anyhow::Result;
+use gltf::Gltf;
+use serde::Deserialize;
+use std::collections::{BTreeMap, BTreeSet};
+use weareshared::Vec3A;
+
+#[derive(Debug, Default)]
+pub struct VrmInfo {
+ pub bone_node_names: Vec<(usize, String)>,
+ pub hide_first_person: BTreeSet<usize>,
+ pub camera_mount: Option<usize>,
+ pub camera_mount_offset: Option<Vec3A>,
+}
+
+pub fn extract_vrm_data(gltf: &Gltf) -> Result<VrmInfo> {
+ let mut o = VrmInfo::default();
+ let mut fp = None;
+ if let Some(vrm) = gltf.extension_value("VRM") {
+ // serde_json::to_writer(std::fs::File::create("/tmp/vrm").unwrap(), vrm).unwrap();
+ let vrm: Vrm = serde_json::from_value(vrm.clone())?;
+ for bone in vrm.humanoid.human_bones {
+ o.bone_node_names.push((bone.node, bone.bone))
+ }
+ fp = vrm.first_person;
+ }
+ if let Some(vrm) = gltf.extension_value("VRMC_vrm") {
+ // serde_json::to_writer(std::fs::File::create("/tmp/vrmc").unwrap(), vrm).unwrap();
+ let vrm: Vrmc = serde_json::from_value(vrm.clone())?;
+ for (name, bone) in vrm.humanoid.human_bones {
+ o.bone_node_names.push((bone.node, name))
+ }
+ fp = vrm.first_person;
+ }
+ if let Some(fp) = fp {
+ o.camera_mount = fp.first_person_bone;
+ o.camera_mount_offset = fp.first_person_bone_offset.map(convert_vrm_vec);
+ for ann in fp.mesh_annotations {
+ if let FirstPersonFlag::ThirdPersonOnly = ann.first_person_flag {
+ o.hide_first_person.insert(ann.node);
+ }
+ }
+ }
+ Ok(o)
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Vrm {
+ humanoid: VrmHumanoid,
+ first_person: Option<VrmcFirstPerson>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmHumanoid {
+ human_bones: Vec<VrmHumanBone>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmHumanBone {
+ bone: String,
+ node: usize,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct Vrmc {
+ humanoid: VrmcHumanoid,
+ first_person: Option<VrmcFirstPerson>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmcFirstPerson {
+ first_person_bone: Option<usize>,
+ first_person_bone_offset: Option<VrmVec3>,
+ mesh_annotations: Vec<VrmcFirstPersonMeshAnnotation>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmcFirstPersonMeshAnnotation {
+ node: usize,
+ #[serde(alias = "type")] // spec says something but their sample uses "type" instead
+ first_person_flag: FirstPersonFlag,
+}
+
+#[derive(Debug, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+enum FirstPersonFlag {
+ ThirdPersonOnly,
+ FirstPersonOnly,
+ Both,
+ #[default]
+ Auto,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmcHumanoid {
+ human_bones: BTreeMap<String, VrmcHumanBone>,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct VrmcHumanBone {
+ node: usize,
+}
+
+#[derive(Debug, Deserialize)]
+struct VrmVec3 {
+ x: f32,
+ y: f32,
+ z: f32,
+}
+fn convert_vrm_vec(VrmVec3 { x, y, z }: VrmVec3) -> Vec3A {
+ Vec3A::new(x, y, z)
+}