f56a1eea3b
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
216 lines
7.1 KiB
Rust
216 lines
7.1 KiB
Rust
//! glTF 2.0 static-mesh importer.
|
|
//!
|
|
//! Loads the mesh primitives of a glTF document into engine [`Mesh`]es, reading
|
|
//! their PBR-lite [`Material`] factors and the world [`Transform`] of each
|
|
//! placement (the node hierarchy is flattened into world space). Missing
|
|
//! normals are generated; missing UVs default to zero. Animation, skinning, and
|
|
//! textures are out of scope for Stage 4.
|
|
|
|
use std::path::Path;
|
|
|
|
use crate::math::{Color, Transform, Vec2, Vec3};
|
|
use crate::render::{Material, Mesh, Vertex};
|
|
|
|
/// Errors produced while importing a glTF document.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum GltfError {
|
|
/// The file could not be read or parsed as glTF.
|
|
#[error("failed to load glTF: {0}")]
|
|
Load(#[from] gltf::Error),
|
|
|
|
/// A mesh primitive was missing the required `POSITION` attribute.
|
|
#[error("glTF primitive has no POSITION attribute")]
|
|
MissingPositions,
|
|
}
|
|
|
|
/// One imported mesh placement: geometry, material, and world transform.
|
|
pub struct GltfMesh {
|
|
/// Optional node/mesh name from the document.
|
|
pub name: Option<String>,
|
|
/// The primitive's geometry.
|
|
pub mesh: Mesh,
|
|
/// The primitive's PBR-lite material.
|
|
pub material: Material,
|
|
/// World-space placement (node hierarchy flattened).
|
|
pub transform: Transform,
|
|
}
|
|
|
|
/// An imported glTF model: a flat list of mesh placements in world space.
|
|
pub struct GltfModel {
|
|
/// Every mesh primitive in the default scene, already placed in world space.
|
|
pub meshes: Vec<GltfMesh>,
|
|
}
|
|
|
|
impl GltfModel {
|
|
/// Total triangle count across all imported primitives.
|
|
pub fn triangle_count(&self) -> usize {
|
|
self.meshes.iter().map(|m| m.mesh.triangle_count()).sum()
|
|
}
|
|
}
|
|
|
|
/// Imports a glTF/GLB file from `path` (external buffers are resolved relative
|
|
/// to the file).
|
|
pub fn load_gltf(path: impl AsRef<Path>) -> Result<GltfModel, GltfError> {
|
|
let (document, buffers, _images) = gltf::import(path)?;
|
|
build_model(&document, &buffers)
|
|
}
|
|
|
|
/// The [`AssetServer`](super::AssetServer) loader for glTF/GLB files.
|
|
///
|
|
/// Registered by default (handles `.gltf` and `.glb`), so
|
|
/// `assets.load::<GltfModel>("model.gltf")` works out of the box; it simply
|
|
/// wraps [`load_gltf`] and adapts its error into [`AssetError`].
|
|
pub struct GltfLoader;
|
|
|
|
impl super::AssetLoader for GltfLoader {
|
|
type Asset = GltfModel;
|
|
|
|
fn extensions(&self) -> &'static [&'static str] {
|
|
&["gltf", "glb"]
|
|
}
|
|
|
|
fn load(&self, path: &Path) -> Result<GltfModel, super::AssetError> {
|
|
load_gltf(path).map_err(|err| super::AssetError::Load {
|
|
path: path.to_path_buf(),
|
|
message: err.to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Imports a glTF/GLB document from an in-memory byte slice (buffers must be
|
|
/// embedded; used for tests and bundled assets).
|
|
pub fn load_gltf_slice(bytes: &[u8]) -> Result<GltfModel, GltfError> {
|
|
let (document, buffers, _images) = gltf::import_slice(bytes)?;
|
|
build_model(&document, &buffers)
|
|
}
|
|
|
|
/// Walks the default scene's node hierarchy, accumulating world transforms and
|
|
/// emitting one [`GltfMesh`] per primitive.
|
|
fn build_model(
|
|
document: &gltf::Document,
|
|
buffers: &[gltf::buffer::Data],
|
|
) -> Result<GltfModel, GltfError> {
|
|
let mut meshes = Vec::new();
|
|
let scene = document
|
|
.default_scene()
|
|
.or_else(|| document.scenes().next());
|
|
if let Some(scene) = scene {
|
|
for node in scene.nodes() {
|
|
visit_node(&node, Transform::IDENTITY, buffers, &mut meshes)?;
|
|
}
|
|
}
|
|
Ok(GltfModel { meshes })
|
|
}
|
|
|
|
fn visit_node(
|
|
node: &gltf::Node,
|
|
parent: Transform,
|
|
buffers: &[gltf::buffer::Data],
|
|
out: &mut Vec<GltfMesh>,
|
|
) -> Result<(), GltfError> {
|
|
let world = parent.mul_transform(&node_transform(node));
|
|
|
|
if let Some(mesh) = node.mesh() {
|
|
for primitive in mesh.primitives() {
|
|
let geometry = read_primitive(&primitive, buffers)?;
|
|
out.push(GltfMesh {
|
|
name: node.name().or_else(|| mesh.name()).map(str::to_owned),
|
|
mesh: geometry,
|
|
material: read_material(&primitive),
|
|
transform: world,
|
|
});
|
|
}
|
|
}
|
|
|
|
for child in node.children() {
|
|
visit_node(&child, world, buffers, out)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Converts a node's local TRS into an engine [`Transform`].
|
|
fn node_transform(node: &gltf::Node) -> Transform {
|
|
let (t, r, s) = node.transform().decomposed();
|
|
Transform::from_trs(
|
|
Vec3::from_array(t),
|
|
glam::Quat::from_array(r),
|
|
Vec3::from_array(s),
|
|
)
|
|
}
|
|
|
|
/// Reads one primitive's vertices and indices into a [`Mesh`].
|
|
fn read_primitive(
|
|
primitive: &gltf::Primitive,
|
|
buffers: &[gltf::buffer::Data],
|
|
) -> Result<Mesh, GltfError> {
|
|
let reader = primitive.reader(|buffer| Some(&buffers[buffer.index()]));
|
|
|
|
let positions: Vec<[f32; 3]> = reader
|
|
.read_positions()
|
|
.ok_or(GltfError::MissingPositions)?
|
|
.collect();
|
|
|
|
let normals: Option<Vec<[f32; 3]>> = reader.read_normals().map(|n| n.collect());
|
|
let uvs: Option<Vec<[f32; 2]>> = reader.read_tex_coords(0).map(|tc| tc.into_f32().collect());
|
|
|
|
let indices: Vec<u32> = match reader.read_indices() {
|
|
Some(idx) => idx.into_u32().collect(),
|
|
// Non-indexed primitive: every three positions form a triangle.
|
|
None => (0..positions.len() as u32).collect(),
|
|
};
|
|
|
|
// Generate flat normals when the document omits them, so lighting still works.
|
|
let normals = normals.unwrap_or_else(|| compute_normals(&positions, &indices));
|
|
|
|
let vertices = positions
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, &p)| {
|
|
let n = normals.get(i).copied().unwrap_or([0.0, 1.0, 0.0]);
|
|
let uv = uvs
|
|
.as_ref()
|
|
.and_then(|u| u.get(i))
|
|
.copied()
|
|
.unwrap_or([0.0, 0.0]);
|
|
Vertex::new(
|
|
Vec3::from_array(p),
|
|
Vec3::from_array(n),
|
|
Vec2::from_array(uv),
|
|
)
|
|
})
|
|
.collect();
|
|
|
|
Ok(Mesh::new(vertices, indices))
|
|
}
|
|
|
|
/// Smooth per-vertex normals: accumulate each triangle's face normal at its
|
|
/// vertices, then normalize.
|
|
fn compute_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
|
|
let mut normals = vec![Vec3::ZERO; positions.len()];
|
|
for tri in indices.chunks_exact(3) {
|
|
let (a, b, c) = (tri[0] as usize, tri[1] as usize, tri[2] as usize);
|
|
let pa = Vec3::from_array(positions[a]);
|
|
let pb = Vec3::from_array(positions[b]);
|
|
let pc = Vec3::from_array(positions[c]);
|
|
let face = (pb - pa).cross(pc - pa);
|
|
normals[a] += face;
|
|
normals[b] += face;
|
|
normals[c] += face;
|
|
}
|
|
normals
|
|
.into_iter()
|
|
.map(|n| n.normalize_or_zero().to_array())
|
|
.collect()
|
|
}
|
|
|
|
/// Maps a primitive's PBR metallic-roughness factors onto a [`Material`].
|
|
fn read_material(primitive: &gltf::Primitive) -> Material {
|
|
let pbr = primitive.material().pbr_metallic_roughness();
|
|
let [r, g, b, a] = pbr.base_color_factor();
|
|
Material {
|
|
albedo: Color::rgba(r, g, b, a),
|
|
metallic: pbr.metallic_factor(),
|
|
roughness: pbr.roughness_factor(),
|
|
}
|
|
}
|