Import Oxide engine (Stages 0–10) under MIT license
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>
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user