use std::collections::HashMap;
use std::error::Error;
use std::fmt::{Debug, Display, Formatter};
use std::marker::PhantomData;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex, PoisonError};
use std::time::{Duration, Instant, SystemTime};
use bevy_ecs::prelude::{Local, ResMut, Resource};
use serde::{Deserialize, Serialize};
use crate::runtime::{
App, AppError, Camera, DirectionalLight, MeshRenderer, Name, Plugin,
PointLight, ScheduleStage, SpotLight,
};
pub struct Handle<T> {
index: u32,
generation: u32,
marker: PhantomData<fn() -> T>,
}
impl<T> Handle<T> {
#[must_use]
pub const fn index(self) -> u32 {
self.index
}
#[must_use]
pub const fn generation(self) -> u32 {
self.generation
}
#[must_use]
pub const fn key(self) -> u64 {
((self.generation as u64) << 32) | self.index as u64
}
}
impl<T> Clone for Handle<T> {
fn clone(&self) -> Self {
*self
}
}
impl<T> Copy for Handle<T> {}
impl<T> Debug for Handle<T> {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Handle")
.field("index", &self.index)
.field("generation", &self.generation)
.finish()
}
}
impl<T> PartialEq for Handle<T> {
fn eq(&self, other: &Self) -> bool {
self.index == other.index && self.generation == other.generation
}
}
impl<T> Eq for Handle<T> {}
impl<T> std::hash::Hash for Handle<T> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.key().hash(state);
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AssetError {
EmptyPath,
Missing(AssetKey),
StillReferenced { key: AssetKey, references: u32 },
Load { path: PathBuf, message: String },
}
impl Display for AssetError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyPath => {
formatter.write_str("asset path cannot be empty")
}
Self::Missing(key) => {
write!(formatter, "asset {key:?} is missing or stale")
}
Self::StillReferenced { key, references } => {
write!(
formatter,
"asset {key:?} still has {references} references"
)
}
Self::Load { path, message } => {
write!(
formatter,
"failed to load `{}`: {message}",
path.display()
)
}
}
}
}
impl Error for AssetError {}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct AssetKey {
pub index: u32,
pub generation: u32,
}
impl<T> From<Handle<T>> for AssetKey {
fn from(handle: Handle<T>) -> Self {
Self {
index: handle.index,
generation: handle.generation,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LoadState {
Loading,
Loaded,
Failed(String),
}
struct Slot<T> {
generation: u32,
revision: u64,
value: Option<T>,
path: Option<PathBuf>,
references: u32,
state: LoadState,
modified: Option<SystemTime>,
reloading: bool,
}
pub struct Assets<T> {
slots: Vec<Slot<T>>,
free: Vec<u32>,
paths: HashMap<PathBuf, Handle<T>>,
deferred: Vec<(u64, T)>,
finished: Arc<Mutex<FinishedLoads<T>>>,
reload_failures: Vec<AssetError>,
}
type FinishedLoads<T> = Vec<(AssetKey, Result<T, AssetError>)>;
impl<T> Default for Assets<T> {
fn default() -> Self {
Self {
slots: Vec::new(),
free: Vec::new(),
paths: HashMap::new(),
deferred: Vec::new(),
finished: Arc::default(),
reload_failures: Vec::new(),
}
}
}
impl<T> Assets<T> {
pub fn insert(&mut self, value: T) -> Handle<T> {
self.insert_slot(Some(value), None, LoadState::Loaded)
}
pub fn insert_with_path(
&mut self,
path: impl AsRef<Path>,
value: T,
) -> Result<Handle<T>, AssetError> {
let path = normalize_path(path.as_ref())?;
if let Some(handle) = self.paths.get(&path).copied() {
if self.contains(handle) {
return Ok(handle);
}
self.paths.remove(&path);
}
Ok(self.insert_slot(Some(value), Some(path), LoadState::Loaded))
}
pub fn get_or_insert_with(
&mut self,
path: impl AsRef<Path>,
loader: impl FnOnce(&Path) -> Result<T, AssetError>,
) -> Result<Handle<T>, AssetError> {
let path = normalize_path(path.as_ref())?;
if let Some(handle) = self.paths.get(&path).copied() {
if self.contains(handle) {
return Ok(handle);
}
self.paths.remove(&path);
}
let value = loader(&path).map_err(|error| AssetError::Load {
path: path.clone(),
message: error.to_string(),
})?;
Ok(self.insert_slot(Some(value), Some(path), LoadState::Loaded))
}
#[must_use]
pub fn get(&self, handle: Handle<T>) -> Option<&T> {
self.slot(handle).and_then(|slot| slot.value.as_ref())
}
pub fn get_mut(&mut self, handle: Handle<T>) -> Option<&mut T> {
let slot = self.slot_mut(handle)?;
let value = slot.value.as_mut()?;
slot.revision = slot.revision.saturating_add(1);
Some(value)
}
#[must_use]
pub fn contains(&self, handle: Handle<T>) -> bool {
self.get(handle).is_some()
}
#[must_use]
pub fn load_state(&self, handle: Handle<T>) -> Option<&LoadState> {
self.slot(handle).map(|slot| &slot.state)
}
#[must_use]
pub fn path(&self, handle: Handle<T>) -> Option<&Path> {
self.slot(handle).and_then(|slot| slot.path.as_deref())
}
#[must_use]
pub fn handle_for_path(&self, path: impl AsRef<Path>) -> Option<Handle<T>> {
let path = normalize_path(path.as_ref()).ok()?;
self.paths
.get(&path)
.copied()
.filter(|handle| self.contains(*handle))
}
#[must_use]
pub fn revision(&self, handle: Handle<T>) -> Option<u64> {
self.slot(handle).map(|slot| slot.revision)
}
pub fn retain(&mut self, handle: Handle<T>) -> Result<(), AssetError> {
let slot = self
.slot_mut(handle)
.ok_or_else(|| AssetError::Missing(handle.into()))?;
slot.references = slot.references.saturating_add(1);
Ok(())
}
pub fn release(&mut self, handle: Handle<T>) -> Result<(), AssetError> {
let slot = self
.slot_mut(handle)
.ok_or_else(|| AssetError::Missing(handle.into()))?;
slot.references = slot.references.saturating_sub(1);
Ok(())
}
pub fn remove(&mut self, handle: Handle<T>) -> Result<T, AssetError> {
let slot = self
.slot_mut(handle)
.ok_or_else(|| AssetError::Missing(handle.into()))?;
if slot.references > 0 {
return Err(AssetError::StillReferenced {
key: handle.into(),
references: slot.references,
});
}
let path = slot.path.take();
let value = slot.value.take();
slot.state = LoadState::Loaded;
if let Some(path) = path {
self.paths.remove(&path);
}
self.free.push(handle.index);
value.ok_or_else(|| AssetError::Missing(handle.into()))
}
pub fn poll_loads(&mut self) -> usize {
let finished = std::mem::take(
&mut *self.finished.lock().unwrap_or_else(PoisonError::into_inner),
);
let mut published = 0;
for (key, result) in finished {
let Some(slot) = self.slots.get_mut(key.index as usize) else {
continue;
};
if slot.generation != key.generation
|| (slot.state != LoadState::Loading && !slot.reloading)
{
continue;
}
let reloading = std::mem::take(&mut slot.reloading);
match result {
Ok(value) => {
slot.value = Some(value);
slot.state = LoadState::Loaded;
}
Err(error) if reloading => {
self.reload_failures.push(error);
continue;
}
Err(error) => slot.state = LoadState::Failed(error.to_string()),
}
slot.revision = slot.revision.saturating_add(1);
published += 1;
}
published
}
pub fn changed(&mut self) -> Vec<(Handle<T>, PathBuf)> {
let mut changed = Vec::new();
for (index, slot) in self.slots.iter_mut().enumerate() {
let Some(path) = slot.path.as_ref() else {
continue;
};
if !slot.is_live() || slot.reloading {
continue;
}
let modified = file_modified(path);
if modified.is_some() && modified != slot.modified {
slot.modified = modified;
changed.push((
Handle {
index: index as u32,
generation: slot.generation,
marker: PhantomData,
},
path.clone(),
));
}
}
changed
}
pub fn take_reload_failures(&mut self) -> Vec<AssetError> {
std::mem::take(&mut self.reload_failures)
}
pub fn retire(
&mut self,
handle: Handle<T>,
safe_after_frame: u64,
) -> Result<(), AssetError> {
let value = self.remove(handle)?;
self.deferred.push((safe_after_frame, value));
Ok(())
}
pub fn collect_retired(&mut self, completed_frame: u64) -> usize {
let before = self.deferred.len();
self.deferred.retain(|(safe_after_frame, _)| {
*safe_after_frame > completed_frame
});
before - self.deferred.len()
}
#[must_use]
pub fn len(&self) -> usize {
self.slots
.iter()
.filter(|slot| slot.value.is_some())
.count()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn iter(&self) -> impl Iterator<Item = (Handle<T>, &T)> {
self.slots.iter().enumerate().filter_map(|(index, slot)| {
slot.value.as_ref().map(|value| {
(
Handle {
index: index as u32,
generation: slot.generation,
marker: PhantomData,
},
value,
)
})
})
}
pub fn paths(&self) -> impl Iterator<Item = (Handle<T>, &Path)> {
self.slots.iter().enumerate().filter_map(|(index, slot)| {
Some((
Handle {
index: index as u32,
generation: slot.generation,
marker: PhantomData,
},
slot.path.as_deref()?,
))
})
}
fn insert_slot(
&mut self,
value: Option<T>,
path: Option<PathBuf>,
state: LoadState,
) -> Handle<T> {
let modified = path.as_deref().and_then(file_modified);
let handle = if let Some(index) = self.free.pop() {
let slot = &mut self.slots[index as usize];
slot.generation = slot.generation.wrapping_add(1).max(1);
slot.revision = slot.revision.saturating_add(1);
slot.value = value;
slot.path = path.clone();
slot.references = 0;
slot.state = state;
slot.modified = modified;
slot.reloading = false;
Handle {
index,
generation: slot.generation,
marker: PhantomData,
}
} else {
let index = u32::try_from(self.slots.len())
.expect("asset slot count exceeds u32");
self.slots.push(Slot {
generation: 1,
revision: 1,
value,
path: path.clone(),
references: 0,
state,
modified,
reloading: false,
});
Handle {
index,
generation: 1,
marker: PhantomData,
}
};
if let Some(path) = path {
self.paths.insert(path, handle);
}
handle
}
fn slot(&self, handle: Handle<T>) -> Option<&Slot<T>> {
self.slots.get(handle.index as usize).filter(|slot| {
slot.generation == handle.generation && slot.is_live()
})
}
fn slot_mut(&mut self, handle: Handle<T>) -> Option<&mut Slot<T>> {
self.slots.get_mut(handle.index as usize).filter(|slot| {
slot.generation == handle.generation && slot.is_live()
})
}
}
impl<T: Send + 'static> Assets<T> {
pub fn load_async(
&mut self,
path: impl AsRef<Path>,
loader: impl FnOnce(&Path) -> Result<T, AssetError> + Send + 'static,
) -> Result<Handle<T>, AssetError> {
let path = normalize_path(path.as_ref())?;
if let Some(handle) = self.paths.get(&path).copied() {
if self
.slot(handle)
.is_some_and(|slot| !matches!(slot.state, LoadState::Failed(_)))
{
return Ok(handle);
}
self.paths.remove(&path);
}
let handle =
self.insert_slot(None, Some(path.clone()), LoadState::Loading);
self.spawn_load(handle.into(), path, loader);
Ok(handle)
}
pub fn reload_async(
&mut self,
handle: Handle<T>,
loader: impl FnOnce(&Path) -> Result<T, AssetError> + Send + 'static,
) -> Result<(), AssetError> {
let slot = self
.slot_mut(handle)
.ok_or_else(|| AssetError::Missing(handle.into()))?;
let path = slot.path.clone().ok_or(AssetError::EmptyPath)?;
if slot.state == LoadState::Loading || slot.reloading {
return Ok(());
}
slot.reloading = true;
self.spawn_load(handle.into(), path, loader);
Ok(())
}
fn spawn_load(
&self,
key: AssetKey,
path: PathBuf,
loader: impl FnOnce(&Path) -> Result<T, AssetError> + Send + 'static,
) {
let finished = Arc::clone(&self.finished);
std::thread::spawn(move || {
let result = catch_unwind(AssertUnwindSafe(|| loader(&path)))
.unwrap_or_else(|_| {
Err(AssetError::Load {
path: path.clone(),
message: "loader panicked".to_owned(),
})
})
.map_err(|error| AssetError::Load {
path: path.clone(),
message: error.to_string(),
});
finished
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((key, result));
});
}
}
fn read_asset_file(path: &Path) -> Result<Vec<u8>, AssetError> {
std::fs::read(path).map_err(|error| AssetError::Load {
path: path.to_owned(),
message: error.to_string(),
})
}
fn decode_cooked<T: serde::de::DeserializeOwned>(
path: &Path,
) -> Result<T, AssetError> {
bincode::deserialize(&read_asset_file(path)?).map_err(|error| {
AssetError::Load {
path: path.to_owned(),
message: error.to_string(),
}
})
}
fn decode_mesh_file(path: &Path) -> Result<MeshAsset, AssetError> {
decode_cooked(path)
}
fn decode_texture_file(path: &Path) -> Result<TextureAsset, AssetError> {
if path
.extension()
.is_some_and(|extension| extension == "rtexture")
{
return decode_cooked(path);
}
let image = image::open(path).map_err(|error| AssetError::Load {
path: path.to_owned(),
message: error.to_string(),
})?;
let rgba = image.to_rgba8();
Ok(TextureAsset {
size: [rgba.width(), rgba.height()],
rgba8: rgba.into_raw(),
color_space: TextureColorSpace::Srgb,
sampler: TextureSampler::default(),
})
}
fn file_modified(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
}
impl<T> Slot<T> {
fn is_live(&self) -> bool {
self.value.is_some() || self.state != LoadState::Loaded
}
}
pub fn generate_tangents(vertices: &mut [MeshVertex], indices: &[u32]) {
use nalgebra::Vector3;
let mut tangents = vec![Vector3::<f32>::zeros(); vertices.len()];
let mut bitangents = tangents.clone();
for triangle in indices.chunks_exact(3) {
let [a, b, c] = [0, 1, 2].map(|i| triangle[i] as usize);
if a.max(b).max(c) >= vertices.len() {
continue;
}
let position = |i: usize| Vector3::from(vertices[i].position);
let edge1 = position(b) - position(a);
let edge2 = position(c) - position(a);
let [u0, v0] = vertices[a].uv;
let (du1, dv1) = (vertices[b].uv[0] - u0, vertices[b].uv[1] - v0);
let (du2, dv2) = (vertices[c].uv[0] - u0, vertices[c].uv[1] - v0);
let determinant = du1 * dv2 - du2 * dv1;
if determinant.abs() <= f32::EPSILON {
continue;
}
let tangent = (edge1 * dv2 - edge2 * dv1) / determinant;
let bitangent = (edge2 * du1 - edge1 * du2) / determinant;
for i in [a, b, c] {
tangents[i] += tangent;
bitangents[i] += bitangent;
}
}
for (index, vertex) in vertices.iter_mut().enumerate() {
let normal = Vector3::from(vertex.normal)
.try_normalize(f32::EPSILON)
.unwrap_or_else(Vector3::y);
let orthogonal = |t: Vector3<f32>| {
(t - normal * normal.dot(&t)).try_normalize(1.0e-6)
};
let tangent = orthogonal(tangents[index]).unwrap_or_else(|| {
let axis = if normal.x.abs() < 0.9 {
Vector3::x()
} else {
Vector3::y()
};
orthogonal(axis).expect("axis is not parallel to the normal")
});
let handedness = if normal.cross(&tangent).dot(&bitangents[index]) < 0.0
{
-1.0
} else {
1.0
};
vertex.tangent = [tangent.x, tangent.y, tangent.z, handedness];
}
}
#[cfg(feature = "gltf")]
fn gltf_texture_sampler(sampler: &gltf::texture::Sampler) -> TextureSampler {
use gltf::texture::{MagFilter, MinFilter, WrappingMode};
use TextureFilter::{Linear, Nearest};
let wrap = |mode| match mode {
WrappingMode::Repeat => TextureWrap::Repeat,
WrappingMode::MirroredRepeat => TextureWrap::MirroredRepeat,
WrappingMode::ClampToEdge => TextureWrap::ClampToEdge,
};
let (min_filter, mipmap_filter) = match sampler.min_filter() {
Some(MinFilter::Nearest | MinFilter::NearestMipmapNearest) => {
(Nearest, Nearest)
}
Some(MinFilter::NearestMipmapLinear) => (Nearest, Linear),
Some(MinFilter::LinearMipmapNearest) => (Linear, Nearest),
Some(MinFilter::Linear | MinFilter::LinearMipmapLinear) | None => {
(Linear, Linear)
}
};
TextureSampler {
mag_filter: match sampler.mag_filter() {
Some(MagFilter::Nearest) => Nearest,
Some(MagFilter::Linear) | None => Linear,
},
min_filter,
mipmap_filter,
wrap: [wrap(sampler.wrap_s()), wrap(sampler.wrap_t())],
}
}
#[cfg(feature = "gltf")]
fn gltf_image_to_rgba8(image: &gltf::image::Data) -> Vec<u8> {
use gltf::image::Format;
let pixel_count = (image.width as usize) * (image.height as usize);
let mut rgba8 = Vec::with_capacity(pixel_count * 4);
match image.format {
Format::R8 => {
for &r in &image.pixels {
rgba8.extend_from_slice(&[r, r, r, 255]);
}
}
Format::R8G8 => {
for chunk in image.pixels.chunks_exact(2) {
rgba8.extend_from_slice(&[chunk[0], chunk[1], 0, 255]);
}
}
Format::R8G8B8 => {
for chunk in image.pixels.chunks_exact(3) {
rgba8.extend_from_slice(&[chunk[0], chunk[1], chunk[2], 255]);
}
}
Format::R8G8B8A8 => rgba8.extend_from_slice(&image.pixels),
Format::R16 => {
for chunk in image.pixels.chunks_exact(2) {
let v = chunk[1];
rgba8.extend_from_slice(&[v, v, v, 255]);
}
}
Format::R16G16 => {
for chunk in image.pixels.chunks_exact(4) {
rgba8.extend_from_slice(&[chunk[1], chunk[3], 0, 255]);
}
}
Format::R16G16B16 => {
for chunk in image.pixels.chunks_exact(6) {
rgba8.extend_from_slice(&[chunk[1], chunk[3], chunk[5], 255]);
}
}
Format::R16G16B16A16 => {
for chunk in image.pixels.chunks_exact(8) {
rgba8.extend_from_slice(&[
chunk[1], chunk[3], chunk[5], chunk[7],
]);
}
}
Format::R32G32B32FLOAT => {
for chunk in image.pixels.chunks_exact(12) {
let channel = |bytes: &[u8]| {
(f32::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3],
])
.clamp(0.0, 1.0)
* 255.0) as u8
};
rgba8.extend_from_slice(&[
channel(&chunk[0..4]),
channel(&chunk[4..8]),
channel(&chunk[8..12]),
255,
]);
}
}
Format::R32G32B32A32FLOAT => {
for chunk in image.pixels.chunks_exact(16) {
let channel = |bytes: &[u8]| {
(f32::from_le_bytes([
bytes[0], bytes[1], bytes[2], bytes[3],
])
.clamp(0.0, 1.0)
* 255.0) as u8
};
rgba8.extend_from_slice(&[
channel(&chunk[0..4]),
channel(&chunk[4..8]),
channel(&chunk[8..12]),
channel(&chunk[12..16]),
]);
}
}
}
rgba8
}
fn normalize_path(path: &Path) -> Result<PathBuf, AssetError> {
if path.as_os_str().is_empty() {
return Err(AssetError::EmptyPath);
}
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(canonical);
}
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|error| AssetError::Load {
path: path.to_path_buf(),
message: error.to_string(),
})?
.join(path)
};
let mut normalized = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
component => normalized.push(component.as_os_str()),
}
}
Ok(normalized)
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshVertex {
pub position: [f32; 3],
pub normal: [f32; 3],
pub uv: [f32; 2],
pub tangent: [f32; 4],
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeshAsset {
pub vertices: Vec<MeshVertex>,
pub indices: Vec<u32>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum PrimitiveShape {
Cube,
Sphere,
Triangle,
Plane,
Tetrahedron,
Octahedron,
Dodecahedron,
Icosahedron,
Pyramid,
Cylinder,
Cone,
Torus,
}
impl PrimitiveShape {
pub const ALL: [Self; 12] = [
Self::Cube,
Self::Sphere,
Self::Triangle,
Self::Plane,
Self::Tetrahedron,
Self::Octahedron,
Self::Dodecahedron,
Self::Icosahedron,
Self::Pyramid,
Self::Cylinder,
Self::Cone,
Self::Torus,
];
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Cube => "Cube",
Self::Sphere => "Sphere",
Self::Triangle => "Triangle",
Self::Plane => "Plane",
Self::Tetrahedron => "Tetrahedron",
Self::Octahedron => "Octahedron",
Self::Dodecahedron => "Dodecahedron",
Self::Icosahedron => "Icosahedron",
Self::Pyramid => "Pyramid",
Self::Cylinder => "Cylinder",
Self::Cone => "Cone",
Self::Torus => "Torus",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TextureColorSpace {
Srgb,
Linear,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub enum TextureFilter {
Nearest,
#[default]
Linear,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub enum TextureWrap {
#[default]
Repeat,
MirroredRepeat,
ClampToEdge,
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub struct TextureSampler {
pub mag_filter: TextureFilter,
pub min_filter: TextureFilter,
pub mipmap_filter: TextureFilter,
pub wrap: [TextureWrap; 2],
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TextureAsset {
pub size: [u32; 2],
pub rgba8: Vec<u8>,
pub color_space: TextureColorSpace,
pub sampler: TextureSampler,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum MaterialModel {
#[default]
Pbr,
Unlit,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask {
cutoff: f32,
},
Blend,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MaterialAsset {
pub model: MaterialModel,
pub alpha_mode: AlphaMode,
pub base_color: [f32; 4],
pub emissive: [f32; 3],
pub metallic: f32,
pub roughness: f32,
pub base_color_texture: Option<Handle<TextureAsset>>,
pub normal_texture: Option<Handle<TextureAsset>>,
pub metallic_roughness_texture: Option<Handle<TextureAsset>>,
pub occlusion_texture: Option<Handle<TextureAsset>>,
pub emissive_texture: Option<Handle<TextureAsset>>,
}
impl Default for MaterialAsset {
fn default() -> Self {
Self {
model: MaterialModel::Pbr,
alpha_mode: AlphaMode::Opaque,
base_color: [1.0; 4],
emissive: [0.0; 3],
metallic: 0.0,
roughness: 0.5,
base_color_texture: None,
normal_texture: None,
metallic_roughness_texture: None,
occlusion_texture: None,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SceneAsset {
pub source: Option<PathBuf>,
}
#[derive(Resource)]
pub struct AssetServer {
pub meshes: Assets<MeshAsset>,
pub textures: Assets<TextureAsset>,
pub materials: Assets<MaterialAsset>,
pub scenes: Assets<SceneAsset>,
pub fallback_mesh: Handle<MeshAsset>,
pub builtin_sphere: Handle<MeshAsset>,
pub builtin_primitives: HashMap<PrimitiveShape, Handle<MeshAsset>>,
pub fallback_texture: Handle<TextureAsset>,
pub fallback_material: Handle<MaterialAsset>,
}
#[derive(Clone, Debug)]
pub struct ImportedGltfPrimitive {
pub name: String,
pub mesh: Handle<MeshAsset>,
pub material: Handle<MaterialAsset>,
}
impl AssetServer {
#[must_use]
pub fn builtin_primitive(
&self,
shape: PrimitiveShape,
) -> Handle<MeshAsset> {
self.builtin_primitives[&shape]
}
#[must_use]
pub fn primitive_for_handle(
&self,
handle: Handle<MeshAsset>,
) -> Option<PrimitiveShape> {
self.builtin_primitives
.iter()
.find_map(|(shape, candidate)| {
(*candidate == handle).then_some(*shape)
})
}
pub fn load_mesh(
&mut self,
path: impl AsRef<Path>,
) -> Result<Handle<MeshAsset>, AssetError> {
self.meshes.get_or_insert_with(path, decode_mesh_file)
}
pub fn load_texture(
&mut self,
path: impl AsRef<Path>,
) -> Result<Handle<TextureAsset>, AssetError> {
self.textures.get_or_insert_with(path, decode_texture_file)
}
pub fn reload_changed(&mut self) -> usize {
let mut started = 0;
for (handle, _) in self.meshes.changed() {
started += usize::from(
self.meshes.reload_async(handle, decode_mesh_file).is_ok(),
);
}
for (handle, _) in self.textures.changed() {
started += usize::from(
self.textures
.reload_async(handle, decode_texture_file)
.is_ok(),
);
}
started
}
#[cfg(feature = "gltf")]
fn import_gltf_texture(
&mut self,
source: &Path,
images: &[gltf::image::Data],
texture: &gltf::texture::Texture,
color_space: TextureColorSpace,
) -> Result<Handle<TextureAsset>, AssetError> {
let image = &images[texture.source().index()];
let suffix = match color_space {
TextureColorSpace::Srgb => "srgb",
TextureColorSpace::Linear => "linear",
};
let gltf_sampler = texture.sampler();
let sampler_key = gltf_sampler
.index()
.map_or_else(|| "default".to_owned(), |index| index.to_string());
let texture_key = source.with_extension(format!(
"gltf-image-{}-sampler-{sampler_key}-{suffix}.rtexture",
texture.source().index()
));
self.textures.get_or_insert_with(texture_key, |key| {
let texture = TextureAsset {
size: [image.width, image.height],
rgba8: gltf_image_to_rgba8(image),
color_space,
sampler: gltf_texture_sampler(&gltf_sampler),
};
write_cooked_asset(key, &texture)?;
Ok(texture)
})
}
#[cfg(feature = "gltf")]
pub fn import_gltf(
&mut self,
path: impl AsRef<Path>,
) -> Result<Vec<ImportedGltfPrimitive>, AssetError> {
let source = normalize_path(path.as_ref())?;
let (document, buffers, images) =
gltf::import(&source).map_err(|error| AssetError::Load {
path: source.clone(),
message: error.to_string(),
})?;
let mut imported = Vec::new();
for mesh in document.meshes() {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(AssetError::Load {
path: source.clone(),
message: "only triangle glTF primitives are supported"
.into(),
});
}
let reader =
primitive.reader(|buffer| Some(&buffers[buffer.index()]));
let positions = reader
.read_positions()
.ok_or_else(|| AssetError::Load {
path: source.clone(),
message: "glTF primitive has no positions".into(),
})?
.collect::<Vec<_>>();
let normals = reader
.read_normals()
.map(Iterator::collect)
.unwrap_or_else(|| vec![[0.0, 1.0, 0.0]; positions.len()]);
let uvs = reader
.read_tex_coords(0)
.map(|values| values.into_f32().collect())
.unwrap_or_else(|| vec![[0.0; 2]; positions.len()]);
let imported_tangents =
reader.read_tangents().map(Iterator::collect::<Vec<_>>);
let generate = imported_tangents.is_none();
let tangents = imported_tangents.unwrap_or_else(|| {
vec![[1.0, 0.0, 0.0, 1.0]; positions.len()]
});
if normals.len() != positions.len()
|| uvs.len() != positions.len()
|| tangents.len() != positions.len()
{
return Err(AssetError::Load {
path: source.clone(),
message:
"glTF vertex attributes have different lengths"
.into(),
});
}
let mut vertices = positions
.into_iter()
.enumerate()
.map(|(index, position)| MeshVertex {
position,
normal: normals[index],
uv: uvs[index],
tangent: tangents[index],
})
.collect::<Vec<_>>();
let indices: Vec<u32> =
if let Some(values) = reader.read_indices() {
values.into_u32().collect()
} else {
let vertex_count = u32::try_from(vertices.len())
.map_err(|_| AssetError::Load {
path: source.clone(),
message: "glTF primitive has too many vertices"
.into(),
})?;
(0..vertex_count).collect()
};
if generate {
generate_tangents(&mut vertices, &indices);
}
let mesh_key = source.with_extension(format!(
"mesh-{}-{}.rmesh",
mesh.index(),
primitive.index()
));
let mesh_asset = MeshAsset { vertices, indices };
write_cooked_asset(&mesh_key, &mesh_asset)?;
let mesh_handle =
self.meshes.insert_with_path(mesh_key, mesh_asset)?;
let gltf_material = primitive.material();
let pbr = gltf_material.pbr_metallic_roughness();
let base_color_texture = pbr
.base_color_texture()
.map(|info| {
self.import_gltf_texture(
&source,
&images,
&info.texture(),
TextureColorSpace::Srgb,
)
})
.transpose()?;
let metallic_roughness_texture = pbr
.metallic_roughness_texture()
.map(|info| {
self.import_gltf_texture(
&source,
&images,
&info.texture(),
TextureColorSpace::Linear,
)
})
.transpose()?;
let normal_texture = gltf_material
.normal_texture()
.map(|info| {
self.import_gltf_texture(
&source,
&images,
&info.texture(),
TextureColorSpace::Linear,
)
})
.transpose()?;
let occlusion_texture = gltf_material
.occlusion_texture()
.map(|info| {
self.import_gltf_texture(
&source,
&images,
&info.texture(),
TextureColorSpace::Linear,
)
})
.transpose()?;
let emissive_texture = gltf_material
.emissive_texture()
.map(|info| {
self.import_gltf_texture(
&source,
&images,
&info.texture(),
TextureColorSpace::Srgb,
)
})
.transpose()?;
let material_key = source.with_extension(format!(
"gltf-material-{}",
gltf_material.index().unwrap_or(usize::MAX)
));
let material = self.materials.insert_with_path(
material_key,
MaterialAsset {
model: MaterialModel::Pbr,
alpha_mode: match gltf_material.alpha_mode() {
gltf::material::AlphaMode::Opaque => {
AlphaMode::Opaque
}
gltf::material::AlphaMode::Mask => {
AlphaMode::Mask {
cutoff: gltf_material
.alpha_cutoff()
.unwrap_or(0.5),
}
}
gltf::material::AlphaMode::Blend => {
AlphaMode::Blend
}
},
base_color: pbr.base_color_factor(),
emissive: gltf_material.emissive_factor(),
metallic: pbr.metallic_factor(),
roughness: pbr.roughness_factor(),
base_color_texture,
normal_texture,
metallic_roughness_texture,
occlusion_texture,
emissive_texture,
},
)?;
imported.push(ImportedGltfPrimitive {
name: format!(
"{} / Primitive {}",
mesh.name().unwrap_or("Mesh"),
primitive.index()
),
mesh: mesh_handle,
material,
});
}
}
Ok(imported)
}
#[cfg(feature = "gltf")]
pub fn import_gltf_scene(
&mut self,
path: impl AsRef<Path>,
) -> Result<Vec<ImportedGltfNode>, AssetError> {
let primitives = self.import_gltf(&path)?;
let source = normalize_path(path.as_ref())?;
let document = gltf::Gltf::open(&source)
.map_err(|error| AssetError::Load {
path: source.clone(),
message: error.to_string(),
})?
.document;
let mut first_primitive = Vec::new();
let mut offset = 0;
for mesh in document.meshes() {
first_primitive.push(offset);
offset += mesh.primitives().len();
}
let mut nodes = document
.nodes()
.map(|node| {
let (position, [x, y, z, w], scale) =
node.transform().decomposed();
let (roll, pitch, yaw) =
nalgebra::UnitQuaternion::from_quaternion(
nalgebra::Quaternion::new(w, x, y, z),
)
.euler_angles();
ImportedGltfNode {
name: node.name().map_or_else(
|| format!("Node {}", node.index()),
str::to_owned,
),
parent: None,
transform: crate::Transform {
position,
rotation: [roll, pitch, yaw],
scale,
},
primitives: node.mesh().map_or_else(Vec::new, |mesh| {
let start = first_primitive[mesh.index()];
primitives[start..start + mesh.primitives().len()]
.to_vec()
}),
camera: node.camera().map(|camera| Camera {
projection: match camera.projection() {
gltf::camera::Projection::Perspective(p) => {
crate::runtime::Projection::Perspective {
vertical_fov_radians: p.yfov(),
near: p.znear(),
far: p.zfar().unwrap_or(1_000.0),
}
}
gltf::camera::Projection::Orthographic(o) => {
crate::runtime::Projection::Orthographic {
vertical_size: o.ymag() * 2.0,
near: o.znear(),
far: o.zfar(),
}
}
},
..Camera::default()
}),
light: node.light().map(|light| {
let color = light.color();
let intensity = light.intensity();
let range = light
.range()
.unwrap_or(PointLight::default().range);
match light.kind() {
gltf::khr_lights_punctual::Kind::Directional => {
ImportedGltfLight::Directional(
DirectionalLight {
color,
illuminance: intensity,
..DirectionalLight::default()
},
)
}
gltf::khr_lights_punctual::Kind::Point => {
ImportedGltfLight::Point(PointLight {
color,
intensity,
range,
})
}
gltf::khr_lights_punctual::Kind::Spot {
inner_cone_angle,
outer_cone_angle,
} => ImportedGltfLight::Spot(SpotLight {
color,
intensity,
range,
inner_angle: inner_cone_angle,
outer_angle: outer_cone_angle,
}),
}
}),
}
})
.collect::<Vec<_>>();
for node in document.nodes() {
for child in node.children() {
nodes[child.index()].parent = Some(node.index());
}
}
Ok(nodes)
}
}
#[cfg(feature = "gltf")]
fn write_cooked_asset(
path: &Path,
asset: &impl Serialize,
) -> Result<(), AssetError> {
let error = |message: String| AssetError::Load {
path: path.to_owned(),
message,
};
let bytes =
bincode::serialize(asset).map_err(|value| error(value.to_string()))?;
std::fs::write(path, bytes).map_err(|value| error(value.to_string()))
}
pub fn spawn_gltf_nodes(
app: &mut App,
nodes: &[ImportedGltfNode],
material_override: Option<Handle<MaterialAsset>>,
) -> Result<Vec<bevy_ecs::entity::Entity>, AppError> {
let renderer = |primitive: &ImportedGltfPrimitive| MeshRenderer {
mesh: primitive.mesh,
material: material_override.unwrap_or(primitive.material),
cast_shadows: true,
receive_shadows: true,
};
let entities = nodes
.iter()
.map(|node| {
let entity = app.spawn((Name(node.name.clone()), node.transform));
let mut world_entity = app.world_mut().entity_mut(entity);
if let Some(camera) = node.camera {
world_entity.insert(camera);
}
match node.light {
Some(ImportedGltfLight::Directional(light)) => {
world_entity.insert(light);
}
Some(ImportedGltfLight::Point(light)) => {
world_entity.insert(light);
}
Some(ImportedGltfLight::Spot(light)) => {
world_entity.insert(light);
}
None => {}
}
if let Some(first) = node.primitives.first() {
world_entity.insert(renderer(first));
}
entity
})
.collect::<Vec<_>>();
for (node, &entity) in nodes.iter().zip(&entities) {
if let Some(parent) = node.parent {
app.set_parent(entity, entities[parent])?;
}
for primitive in node.primitives.iter().skip(1) {
let child = app.spawn((
Name(primitive.name.clone()),
crate::Transform::default(),
renderer(primitive),
));
app.set_parent(child, entity)?;
}
}
Ok(entities)
}
#[derive(Clone, Debug)]
pub struct ImportedGltfNode {
pub name: String,
pub parent: Option<usize>,
pub transform: crate::Transform,
pub primitives: Vec<ImportedGltfPrimitive>,
pub camera: Option<Camera>,
pub light: Option<ImportedGltfLight>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ImportedGltfLight {
Directional(DirectionalLight),
Point(PointLight),
Spot(SpotLight),
}
impl Default for AssetServer {
fn default() -> Self {
let mut meshes = Assets::default();
let mut builtin_primitives = HashMap::new();
for shape in PrimitiveShape::ALL {
builtin_primitives
.insert(shape, meshes.insert(procedural_primitive_mesh(shape)));
}
let fallback_mesh = builtin_primitives[&PrimitiveShape::Cube];
let builtin_sphere = builtin_primitives[&PrimitiveShape::Sphere];
let mut textures = Assets::default();
let fallback_texture = textures.insert(TextureAsset {
size: [1, 1],
rgba8: vec![255; 4],
color_space: TextureColorSpace::Srgb,
sampler: TextureSampler::default(),
});
let mut materials = Assets::default();
let fallback_material = materials.insert(MaterialAsset {
base_color: [1.0, 0.0, 1.0, 1.0],
base_color_texture: Some(fallback_texture),
..MaterialAsset::default()
});
Self {
meshes,
textures,
materials,
scenes: Assets::default(),
fallback_mesh,
builtin_sphere,
builtin_primitives,
fallback_texture,
fallback_material,
}
}
}
pub fn procedural_sphere_mesh(subdivisions: u32) -> MeshAsset {
use std::f32::consts::PI;
let stacks = subdivisions.clamp(2, 128);
let sectors = stacks * 2;
let mut vertices =
Vec::with_capacity(((stacks + 1) * (sectors + 1)) as usize);
let mut indices = Vec::with_capacity((stacks * sectors * 6) as usize);
for stack in 0..=stacks {
let vertical = stack as f32 / stacks as f32;
let phi = PI * vertical;
let ring = 0.5 * phi.sin();
let y = 0.5 * phi.cos();
for sector in 0..=sectors {
let horizontal = sector as f32 / sectors as f32;
let theta = 2.0 * PI * horizontal;
let x = ring * theta.cos();
let z = ring * theta.sin();
vertices.push(MeshVertex {
position: [x, y, z],
normal: [x * 2.0, y * 2.0, z * 2.0],
uv: [horizontal, vertical],
tangent: [0.0, 1.0, 0.0, 1.0],
});
}
}
let row = sectors + 1;
for stack in 0..stacks {
for sector in 0..sectors {
let first = stack * row + sector;
let second = first + row;
indices.extend_from_slice(&[
first,
second,
first + 1,
second,
second + 1,
first + 1,
]);
}
}
MeshAsset { vertices, indices }
}
#[must_use]
pub fn procedural_primitive_mesh(shape: PrimitiveShape) -> MeshAsset {
match shape {
PrimitiveShape::Cube => fallback_cube(),
PrimitiveShape::Sphere => procedural_sphere_mesh(16),
PrimitiveShape::Triangle => mesh_from_triangles(&[[
[-0.5, -0.5, 0.0],
[0.5, -0.5, 0.0],
[0.0, 0.5, 0.0],
]]),
PrimitiveShape::Plane => mesh_from_triangles(&[
[[-0.5, 0.0, -0.5], [-0.5, 0.0, 0.5], [0.5, 0.0, 0.5]],
[[-0.5, 0.0, -0.5], [0.5, 0.0, 0.5], [0.5, 0.0, -0.5]],
]),
PrimitiveShape::Tetrahedron => polyhedron_mesh(
&[
[0.5, 0.5, 0.5],
[-0.5, -0.5, 0.5],
[-0.5, 0.5, -0.5],
[0.5, -0.5, -0.5],
],
&[[0, 1, 2], [0, 3, 1], [0, 2, 3], [1, 3, 2]],
),
PrimitiveShape::Octahedron => polyhedron_mesh(
&[
[0.5, 0.0, 0.0],
[-0.5, 0.0, 0.0],
[0.0, 0.5, 0.0],
[0.0, -0.5, 0.0],
[0.0, 0.0, 0.5],
[0.0, 0.0, -0.5],
],
&[
[0, 2, 4],
[4, 2, 1],
[1, 2, 5],
[5, 2, 0],
[4, 3, 0],
[1, 3, 4],
[5, 3, 1],
[0, 3, 5],
],
),
PrimitiveShape::Dodecahedron => dodecahedron_mesh(),
PrimitiveShape::Icosahedron => icosahedron_mesh(),
PrimitiveShape::Pyramid => pyramid_mesh(),
PrimitiveShape::Cylinder => cylinder_mesh(32),
PrimitiveShape::Cone => cone_mesh(32),
PrimitiveShape::Torus => torus_mesh(32, 12),
}
}
fn mesh_from_triangles(triangles: &[[[f32; 3]; 3]]) -> MeshAsset {
let mut vertices = Vec::with_capacity(triangles.len() * 3);
let mut indices = Vec::with_capacity(triangles.len() * 3);
for triangle in triangles {
let edge_a = subtract(triangle[1], triangle[0]);
let edge_b = subtract(triangle[2], triangle[0]);
let normal = normalize3(cross(edge_a, edge_b));
let base = vertices.len() as u32;
for (position, uv) in
triangle
.iter()
.copied()
.zip([[0.0, 0.0], [1.0, 0.0], [0.5, 1.0]])
{
vertices.push(MeshVertex {
position,
normal,
uv,
tangent: [1.0, 0.0, 0.0, 1.0],
});
}
indices.extend_from_slice(&[base, base + 1, base + 2]);
}
MeshAsset { vertices, indices }
}
fn polyhedron_mesh(points: &[[f32; 3]], faces: &[[usize; 3]]) -> MeshAsset {
let triangles = faces
.iter()
.map(|face| {
let mut triangle =
[points[face[0]], points[face[1]], points[face[2]]];
let normal = cross(
subtract(triangle[1], triangle[0]),
subtract(triangle[2], triangle[0]),
);
let center = [
(triangle[0][0] + triangle[1][0] + triangle[2][0]) / 3.0,
(triangle[0][1] + triangle[1][1] + triangle[2][1]) / 3.0,
(triangle[0][2] + triangle[1][2] + triangle[2][2]) / 3.0,
];
if dot(normal, center) < 0.0 {
triangle.swap(1, 2);
}
triangle
})
.collect::<Vec<_>>();
mesh_from_triangles(&triangles)
}
fn icosahedron_mesh() -> MeshAsset {
let golden = (1.0 + 5.0_f32.sqrt()) * 0.5;
let raw = [
[-1.0, golden, 0.0],
[1.0, golden, 0.0],
[-1.0, -golden, 0.0],
[1.0, -golden, 0.0],
[0.0, -1.0, golden],
[0.0, 1.0, golden],
[0.0, -1.0, -golden],
[0.0, 1.0, -golden],
[golden, 0.0, -1.0],
[golden, 0.0, 1.0],
[-golden, 0.0, -1.0],
[-golden, 0.0, 1.0],
];
let points = raw.map(|point| {
let normal = normalize3(point);
[normal[0] * 0.5, normal[1] * 0.5, normal[2] * 0.5]
});
polyhedron_mesh(
&points,
&[
[0, 11, 5],
[0, 5, 1],
[0, 1, 7],
[0, 7, 10],
[0, 10, 11],
[1, 5, 9],
[5, 11, 4],
[11, 10, 2],
[10, 7, 6],
[7, 1, 8],
[3, 9, 4],
[3, 4, 2],
[3, 2, 6],
[3, 6, 8],
[3, 8, 9],
[4, 9, 5],
[2, 4, 11],
[6, 2, 10],
[8, 6, 7],
[9, 8, 1],
],
)
}
fn dodecahedron_mesh() -> MeshAsset {
let golden = (1.0 + 5.0_f32.sqrt()) * 0.5;
let inverse = 1.0 / golden;
let raw = [
[-1.0, -1.0, -1.0],
[-1.0, -1.0, 1.0],
[-1.0, 1.0, -1.0],
[-1.0, 1.0, 1.0],
[1.0, -1.0, -1.0],
[1.0, -1.0, 1.0],
[1.0, 1.0, -1.0],
[1.0, 1.0, 1.0],
[0.0, -inverse, -golden],
[0.0, -inverse, golden],
[0.0, inverse, -golden],
[0.0, inverse, golden],
[-inverse, -golden, 0.0],
[-inverse, golden, 0.0],
[inverse, -golden, 0.0],
[inverse, golden, 0.0],
[-golden, 0.0, -inverse],
[golden, 0.0, -inverse],
[-golden, 0.0, inverse],
[golden, 0.0, inverse],
];
let scale = 0.5 / 3.0_f32.sqrt();
let points =
raw.map(|point| [point[0] * scale, point[1] * scale, point[2] * scale]);
let mut face_sets = Vec::<Vec<usize>>::new();
for first in 0..points.len() - 2 {
for second in first + 1..points.len() - 1 {
for third in second + 1..points.len() {
let mut normal = cross(
subtract(points[second], points[first]),
subtract(points[third], points[first]),
);
if dot(normal, normal) < 1.0e-8 {
continue;
}
normal = normalize3(normal);
let plane = dot(normal, points[first]);
let distances = points.map(|point| dot(normal, point) - plane);
let supports_hull = distances
.iter()
.all(|distance| *distance <= 1.0e-4)
|| distances.iter().all(|distance| *distance >= -1.0e-4);
if !supports_hull {
continue;
}
let face = distances
.iter()
.enumerate()
.filter_map(|(index, distance)| {
(distance.abs() <= 1.0e-4).then_some(index)
})
.collect::<Vec<_>>();
if face.len() == 5 && !face_sets.contains(&face) {
face_sets.push(face);
}
}
}
}
let mut triangles = Vec::with_capacity(36);
for mut face in face_sets {
let center = face.iter().fold([0.0; 3], |mut sum, index| {
for axis in 0..3 {
sum[axis] += points[*index][axis] / 5.0;
}
sum
});
let normal = normalize3(center);
let basis_x = normalize3(subtract(points[face[0]], center));
let basis_y = cross(normal, basis_x);
face.sort_by(|left, right| {
let left_delta = subtract(points[*left], center);
let right_delta = subtract(points[*right], center);
let left_angle =
dot(left_delta, basis_y).atan2(dot(left_delta, basis_x));
let right_angle =
dot(right_delta, basis_y).atan2(dot(right_delta, basis_x));
left_angle.total_cmp(&right_angle)
});
for corner in 1..face.len() - 1 {
triangles.push([
points[face[0]],
points[face[corner]],
points[face[corner + 1]],
]);
}
}
mesh_from_triangles(&triangles)
}
fn pyramid_mesh() -> MeshAsset {
polyhedron_mesh(
&[
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, -0.5, 0.5],
[-0.5, -0.5, 0.5],
[0.0, 0.5, 0.0],
],
&[
[0, 1, 2],
[0, 2, 3],
[0, 4, 1],
[1, 4, 2],
[2, 4, 3],
[3, 4, 0],
],
)
}
fn cylinder_mesh(segments: u32) -> MeshAsset {
let mut triangles = Vec::with_capacity((segments * 4) as usize);
for step in 0..segments {
let a = std::f32::consts::TAU * step as f32 / segments as f32;
let b = std::f32::consts::TAU * (step + 1) as f32 / segments as f32;
let bottom_a = [0.5 * a.cos(), -0.5, 0.5 * a.sin()];
let bottom_b = [0.5 * b.cos(), -0.5, 0.5 * b.sin()];
let top_a = [bottom_a[0], 0.5, bottom_a[2]];
let top_b = [bottom_b[0], 0.5, bottom_b[2]];
triangles.extend_from_slice(&[
[bottom_a, top_b, bottom_b],
[bottom_a, top_a, top_b],
[[0.0, 0.5, 0.0], top_b, top_a],
[[0.0, -0.5, 0.0], bottom_a, bottom_b],
]);
}
mesh_from_triangles(&triangles)
}
fn cone_mesh(segments: u32) -> MeshAsset {
let mut triangles = Vec::with_capacity((segments * 2) as usize);
for step in 0..segments {
let a = std::f32::consts::TAU * step as f32 / segments as f32;
let b = std::f32::consts::TAU * (step + 1) as f32 / segments as f32;
let point_a = [0.5 * a.cos(), -0.5, 0.5 * a.sin()];
let point_b = [0.5 * b.cos(), -0.5, 0.5 * b.sin()];
triangles.push([point_a, [0.0, 0.5, 0.0], point_b]);
triangles.push([[0.0, -0.5, 0.0], point_a, point_b]);
}
mesh_from_triangles(&triangles)
}
fn torus_mesh(major_segments: u32, minor_segments: u32) -> MeshAsset {
let mut vertices =
Vec::with_capacity((major_segments * minor_segments) as usize);
for major in 0..major_segments {
let u = std::f32::consts::TAU * major as f32 / major_segments as f32;
for minor in 0..minor_segments {
let v =
std::f32::consts::TAU * minor as f32 / minor_segments as f32;
let normal = [u.cos() * v.cos(), v.sin(), u.sin() * v.cos()];
let ring = 0.36 + 0.14 * v.cos();
vertices.push(MeshVertex {
position: [ring * u.cos(), 0.14 * v.sin(), ring * u.sin()],
normal,
uv: [
major as f32 / major_segments as f32,
minor as f32 / minor_segments as f32,
],
tangent: [-u.sin(), 0.0, u.cos(), 1.0],
});
}
}
let mut indices =
Vec::with_capacity((major_segments * minor_segments * 6) as usize);
for major in 0..major_segments {
for minor in 0..minor_segments {
let a = major * minor_segments + minor;
let b = ((major + 1) % major_segments) * minor_segments + minor;
let c = major * minor_segments + (minor + 1) % minor_segments;
let d = ((major + 1) % major_segments) * minor_segments
+ (minor + 1) % minor_segments;
indices.extend_from_slice(&[a, c, b, b, c, d]);
}
}
MeshAsset { vertices, indices }
}
fn subtract(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn normalize3(value: [f32; 3]) -> [f32; 3] {
let length = dot(value, value).sqrt();
if length > f32::EPSILON {
[value[0] / length, value[1] / length, value[2] / length]
} else {
[0.0, 1.0, 0.0]
}
}
fn fallback_cube() -> MeshAsset {
let faces = [
(
[0.0, 0.0, 1.0],
[
[-0.5, -0.5, 0.5],
[0.5, -0.5, 0.5],
[0.5, 0.5, 0.5],
[-0.5, 0.5, 0.5],
],
),
(
[0.0, 0.0, -1.0],
[
[0.5, -0.5, -0.5],
[-0.5, -0.5, -0.5],
[-0.5, 0.5, -0.5],
[0.5, 0.5, -0.5],
],
),
(
[1.0, 0.0, 0.0],
[
[0.5, -0.5, 0.5],
[0.5, -0.5, -0.5],
[0.5, 0.5, -0.5],
[0.5, 0.5, 0.5],
],
),
(
[-1.0, 0.0, 0.0],
[
[-0.5, -0.5, -0.5],
[-0.5, -0.5, 0.5],
[-0.5, 0.5, 0.5],
[-0.5, 0.5, -0.5],
],
),
(
[0.0, 1.0, 0.0],
[
[-0.5, 0.5, 0.5],
[0.5, 0.5, 0.5],
[0.5, 0.5, -0.5],
[-0.5, 0.5, -0.5],
],
),
(
[0.0, -1.0, 0.0],
[
[-0.5, -0.5, -0.5],
[0.5, -0.5, -0.5],
[0.5, -0.5, 0.5],
[-0.5, -0.5, 0.5],
],
),
];
let mut vertices = Vec::with_capacity(24);
let mut indices = Vec::with_capacity(36);
for (normal, positions) in faces {
let base = vertices.len() as u32;
for (position, uv) in positions.into_iter().zip([
[0.0, 0.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
]) {
vertices.push(MeshVertex {
position,
normal,
uv,
tangent: [1.0, 0.0, 0.0, 1.0],
});
}
indices.extend_from_slice(&[
base,
base + 1,
base + 2,
base,
base + 2,
base + 3,
]);
}
MeshAsset { vertices, indices }
}
pub const HOT_RELOAD_SCAN_INTERVAL: Duration = Duration::from_millis(500);
fn poll_asset_loads(
mut server: ResMut<AssetServer>,
mut last_scan: Local<Option<Instant>>,
) {
if last_scan.is_none_or(|last| last.elapsed() >= HOT_RELOAD_SCAN_INTERVAL) {
*last_scan = Some(Instant::now());
server.reload_changed();
}
server.meshes.poll_loads();
server.textures.poll_loads();
server.materials.poll_loads();
server.scenes.poll_loads();
}
#[derive(Clone, Copy, Debug, Default)]
pub struct AssetPlugin;
impl Plugin for AssetPlugin {
fn build(&self, app: &mut App) -> Result<(), AppError> {
app.insert_resource(AssetServer::default());
app.add_systems(ScheduleStage::Update, poll_asset_loads);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "gltf")]
fn gltf_image(
format: gltf::image::Format,
pixels: Vec<u8>,
) -> gltf::image::Data {
gltf::image::Data {
pixels,
format,
width: 1,
height: 1,
}
}
fn poll_until_settled(assets: &mut Assets<u32>, handles: &[Handle<u32>]) {
let deadline =
std::time::Instant::now() + std::time::Duration::from_secs(5);
while handles.iter().any(|handle| {
assets.load_state(*handle) == Some(&LoadState::Loading)
}) {
assert!(std::time::Instant::now() < deadline, "load timed out");
assets.poll_loads();
std::thread::yield_now();
}
}
#[test]
fn changed_files_reload_on_workers_and_failures_keep_last_value() {
let folder = std::env::temp_dir()
.join(format!("rusting-hot-reload-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("tri.rmesh");
let mesh = |count: u32| MeshAsset {
vertices: Vec::new(),
indices: (0..count).collect(),
};
let write = |bytes: Vec<u8>, seconds: u64| {
std::fs::write(&path, bytes).unwrap();
std::fs::File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(
SystemTime::UNIX_EPOCH + Duration::from_secs(seconds),
)
.unwrap();
};
let settle = |server: &mut AssetServer| {
let deadline = Instant::now() + Duration::from_secs(5);
while server.meshes.slots.iter().any(|slot| slot.reloading) {
assert!(Instant::now() < deadline, "reload timed out");
server.meshes.poll_loads();
std::thread::yield_now();
}
};
write(bincode::serialize(&mesh(3)).unwrap(), 1_000);
let mut server = AssetServer::default();
let handle = server.load_mesh(&path).unwrap();
let first_revision = server.meshes.revision(handle).unwrap();
assert_eq!(
server.reload_changed(),
0,
"unchanged file is not reloaded"
);
write(bincode::serialize(&mesh(6)).unwrap(), 2_000);
assert_eq!(server.reload_changed(), 1);
assert_eq!(
server.meshes.get(handle).unwrap().indices.len(),
3,
"old value stays visible while the worker decodes"
);
settle(&mut server);
assert_eq!(server.meshes.get(handle).unwrap().indices.len(), 6);
assert!(server.meshes.revision(handle).unwrap() > first_revision);
write(b"not a mesh".to_vec(), 3_000);
assert_eq!(server.reload_changed(), 1);
settle(&mut server);
assert_eq!(server.meshes.get(handle).unwrap().indices.len(), 6);
assert_eq!(server.meshes.load_state(handle), Some(&LoadState::Loaded));
assert_eq!(server.meshes.take_reload_failures().len(), 1);
std::fs::remove_file(&path).unwrap();
assert_eq!(server.reload_changed(), 0, "deleted file keeps its value");
assert!(server.meshes.get(handle).is_some());
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn async_loads_publish_success_failure_panic_and_discard_cancelled() {
let (release, gate) = std::sync::mpsc::channel::<()>();
let mut assets = Assets::<u32>::default();
let loaded = assets
.load_async("async/ok.bin", move |_| {
gate.recv().unwrap();
Ok(7)
})
.unwrap();
assert_eq!(assets.load_state(loaded), Some(&LoadState::Loading));
assert!(!assets.contains(loaded));
assert_eq!(
assets.load_async("async/./ok.bin", |_| Ok(99)).unwrap(),
loaded,
"a path already loading is deduplicated"
);
let failed = assets
.load_async("async/bad.bin", |path| {
Err(AssetError::Load {
path: path.to_owned(),
message: "corrupt".to_owned(),
})
})
.unwrap();
let panicked = assets
.load_async("async/panic.bin", |_| panic!("decoder bug"))
.unwrap();
let cancelled = assets.load_async("async/gone.bin", |_| Ok(1)).unwrap();
assert_eq!(
assets.remove(cancelled),
Err(AssetError::Missing(cancelled.into()))
);
release.send(()).unwrap();
poll_until_settled(&mut assets, &[loaded, failed, panicked]);
assert_eq!(assets.get(loaded), Some(&7));
assert_eq!(assets.handle_for_path("async/ok.bin"), Some(loaded));
assert!(matches!(
assets.load_state(failed),
Some(LoadState::Failed(message)) if message.contains("corrupt")
));
assert!(matches!(
assets.load_state(panicked),
Some(LoadState::Failed(message)) if message.contains("panicked")
));
assert_eq!(assets.load_state(cancelled), None);
assert_eq!(assets.len(), 1);
let retried = assets.load_async("async/bad.bin", |_| Ok(3)).unwrap();
assert_ne!(retried, failed, "a failed path can be retried");
poll_until_settled(&mut assets, &[retried]);
assert_eq!(assets.get(retried), Some(&3));
}
#[test]
#[cfg(feature = "gltf")]
fn gltf_image_conversion_expands_every_supported_channel_layout() {
assert_eq!(
gltf_image_to_rgba8(&gltf_image(
gltf::image::Format::R8,
vec![200]
)),
vec![200, 200, 200, 255]
);
assert_eq!(
gltf_image_to_rgba8(&gltf_image(
gltf::image::Format::R8G8B8,
vec![10, 20, 30]
)),
vec![10, 20, 30, 255]
);
assert_eq!(
gltf_image_to_rgba8(&gltf_image(
gltf::image::Format::R8G8B8A8,
vec![10, 20, 30, 40]
)),
vec![10, 20, 30, 40]
);
assert_eq!(
gltf_image_to_rgba8(&gltf_image(
gltf::image::Format::R32G32B32FLOAT,
[1.0f32, 0.5, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect(),
)),
vec![255, 127, 0, 255]
);
}
#[test]
fn stale_handle_does_not_resolve_reused_slot() {
let mut assets = Assets::default();
let stale = assets.insert(String::from("old"));
assert_eq!(assets.remove(stale).unwrap(), "old");
let current = assets.insert(String::from("new"));
assert_eq!(stale.index(), current.index());
assert_ne!(stale.generation(), current.generation());
assert!(assets.get(stale).is_none());
assert_eq!(assets.get(current).map(String::as_str), Some("new"));
}
#[test]
fn equivalent_paths_are_deduplicated() {
let mut assets = Assets::default();
let first = assets
.get_or_insert_with("assets/../assets/cube.mesh", |_| Ok(7_u32))
.unwrap();
let second = assets
.get_or_insert_with("./assets/cube.mesh", |_| Ok(9_u32))
.unwrap();
assert_eq!(first, second);
assert_eq!(assets.get(first), Some(&7));
}
#[test]
fn referenced_assets_cannot_be_removed() {
let mut assets = Assets::default();
let handle = assets.insert(42);
assets.retain(handle).unwrap();
assert!(matches!(
assets.remove(handle),
Err(AssetError::StillReferenced { .. })
));
assets.release(handle).unwrap();
assert_eq!(assets.remove(handle), Ok(42));
}
#[test]
fn retired_assets_wait_for_safe_frame() {
let mut assets = Assets::default();
let handle = assets.insert(42);
assets.retire(handle, 5).unwrap();
assert_eq!(assets.collect_retired(4), 0);
assert_eq!(assets.collect_retired(5), 1);
}
#[test]
fn mutable_access_advances_asset_revision() {
let mut assets = Assets::default();
let handle = assets.insert(1_u32);
let initial = assets.revision(handle).unwrap();
*assets.get_mut(handle).unwrap() = 2;
assert!(assets.revision(handle).unwrap() > initial);
assert_eq!(assets.get(handle), Some(&2));
}
#[test]
#[cfg(feature = "gltf")]
fn gltf_import_carries_sampler_state_and_alpha_mode() {
let folder = std::env::temp_dir()
.join(format!("rusting-gltf-sampler-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
image::RgbaImage::from_raw(1, 1, vec![1, 2, 3, 255])
.unwrap()
.save(folder.join("pixel.png"))
.unwrap();
let positions: Vec<u8> =
[0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect();
std::fs::write(folder.join("tri.bin"), &positions).unwrap();
let path = folder.join("tri.gltf");
std::fs::write(
&path,
r#"{
"asset": {"version": "2.0"},
"buffers": [{"uri": "tri.bin", "byteLength": 36}],
"bufferViews": [{"buffer": 0, "byteLength": 36}],
"accessors": [{"bufferView": 0, "componentType": 5126,
"count": 3, "type": "VEC3",
"min": [0, 0, 0], "max": [1, 1, 0]}],
"images": [{"uri": "pixel.png"}],
"samplers": [{"magFilter": 9728, "minFilter": 9986,
"wrapS": 33071, "wrapT": 33648}],
"textures": [{"source": 0, "sampler": 0}, {"source": 0}],
"materials": [{
"pbrMetallicRoughness": {"baseColorTexture": {"index": 0}},
"emissiveTexture": {"index": 1},
"alphaMode": "MASK", "alphaCutoff": 0.25}],
"meshes": [{"primitives": [
{"attributes": {"POSITION": 0}, "material": 0}]}]
}"#,
)
.unwrap();
let mut server = AssetServer::default();
let imported = server.import_gltf(&path).unwrap();
let material = server.materials.get(imported[0].material).unwrap();
assert_eq!(material.alpha_mode, AlphaMode::Mask { cutoff: 0.25 });
let sampled = material.base_color_texture.unwrap();
let default = material.emissive_texture.unwrap();
assert_ne!(
sampled, default,
"sampler state is part of texture identity"
);
assert_eq!(
server.textures.get(sampled).unwrap().sampler,
TextureSampler {
mag_filter: TextureFilter::Nearest,
min_filter: TextureFilter::Nearest,
mipmap_filter: TextureFilter::Linear,
wrap: [TextureWrap::ClampToEdge, TextureWrap::MirroredRepeat],
}
);
assert_eq!(
server.textures.get(default).unwrap().sampler,
TextureSampler::default()
);
std::fs::remove_dir_all(folder).unwrap();
}
#[cfg(feature = "gltf")]
#[test]
fn gltf_scene_import_spawns_hierarchy_cameras_and_lights() {
use crate::runtime::Parent;
use crate::Transform;
let folder = std::env::temp_dir()
.join(format!("rusting-gltf-scene-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let positions: Vec<u8> =
[0.0f32, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect();
std::fs::write(folder.join("tri.bin"), &positions).unwrap();
let path = folder.join("scene.gltf");
std::fs::write(
&path,
r#"{
"asset": {"version": "2.0"},
"extensionsUsed": ["KHR_lights_punctual"],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "color": [1, 0.5, 0], "intensity": 40,
"range": 7,
"spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.6}}]}},
"buffers": [{"uri": "tri.bin", "byteLength": 36}],
"bufferViews": [{"buffer": 0, "byteLength": 36}],
"accessors": [{"bufferView": 0, "componentType": 5126,
"count": 3, "type": "VEC3",
"min": [0, 0, 0], "max": [1, 1, 0]}],
"cameras": [{"type": "orthographic", "orthographic":
{"xmag": 2, "ymag": 1.5, "znear": 0.5, "zfar": 50}}],
"meshes": [{"primitives": [
{"attributes": {"POSITION": 0}},
{"attributes": {"POSITION": 0}}]}],
"nodes": [
{"name": "Root", "mesh": 0, "children": [1, 2],
"translation": [1, 2, 3],
"rotation": [0, 0.70710677, 0, 0.70710677]},
{"name": "Eye", "camera": 0, "scale": [2, 2, 2]},
{"extensions": {"KHR_lights_punctual": {"light": 0}}}],
"scenes": [{"nodes": [0]}]
}"#,
)
.unwrap();
let mut server = AssetServer::default();
let nodes = server.import_gltf_scene(&path).unwrap();
assert_eq!(nodes.len(), 3);
assert_eq!(nodes[0].name, "Root");
assert_eq!(nodes[2].name, "Node 2");
assert_eq!(
nodes.iter().map(|node| node.parent).collect::<Vec<_>>(),
[None, Some(0), Some(0)]
);
assert_eq!(nodes[0].transform.position, [1.0, 2.0, 3.0]);
let [x, y, z] = nodes[0].transform.rotation;
assert!(x.abs() < 1.0e-5 && z.abs() < 1.0e-5);
assert!((y - std::f32::consts::FRAC_PI_2).abs() < 1.0e-3);
assert_eq!(nodes[1].transform.scale, [2.0; 3]);
assert_eq!(nodes[0].primitives.len(), 2);
assert_eq!(
nodes[1].camera.unwrap().projection,
crate::runtime::Projection::Orthographic {
vertical_size: 3.0,
near: 0.5,
far: 50.0
}
);
assert_eq!(
nodes[2].light,
Some(ImportedGltfLight::Spot(SpotLight {
color: [1.0, 0.5, 0.0],
intensity: 40.0,
range: 7.0,
inner_angle: 0.2,
outer_angle: 0.6,
}))
);
let mut app = App::new();
let entities = spawn_gltf_nodes(&mut app, &nodes, None).unwrap();
let world = app.world_mut();
let root = entities[0];
assert_eq!(world.get::<Name>(root).unwrap().0, "Root");
assert!(world.get::<MeshRenderer>(root).is_some());
assert_eq!(world.get::<Transform>(root), Some(&nodes[0].transform));
assert_eq!(world.get::<Parent>(entities[1]), Some(&Parent(root)));
assert!(world.get::<Camera>(entities[1]).is_some());
assert!(world.get::<SpotLight>(entities[2]).is_some());
let extra_primitives = world
.query::<(&Parent, &MeshRenderer)>()
.iter(world)
.filter(|(parent, _)| parent.0 == root)
.count();
assert_eq!(extra_primitives, 1, "second primitive is a child entity");
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn generated_tangents_follow_uvs_handedness_and_degenerate_fallback() {
let quad = |flip_v: bool, uv_scale: f32| {
let v = |y: f32| if flip_v { 1.0 - y } else { y } * uv_scale;
[[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
.map(|[x, y]: [f32; 2]| MeshVertex {
position: [x, y, 0.0],
normal: [0.0, 0.0, 1.0],
uv: [x * uv_scale, v(y)],
tangent: [0.0; 4],
})
.to_vec()
};
let indices = [0, 1, 2, 0, 2, 3];
let mut right_handed = quad(false, 1.0);
generate_tangents(&mut right_handed, &indices);
let mut flipped = quad(true, 1.0);
generate_tangents(&mut flipped, &indices);
let mut degenerate = quad(false, 0.0);
generate_tangents(&mut degenerate, &indices);
for vertex in &right_handed {
assert_eq!(vertex.tangent, [1.0, 0.0, 0.0, 1.0]);
}
for vertex in &flipped {
assert_eq!(vertex.tangent, [1.0, 0.0, 0.0, -1.0]);
}
for vertex in °enerate {
let [x, y, z, _] = vertex.tangent;
assert!(z.abs() < 1.0e-6, "tangent is perpendicular to normal");
assert!(((x * x + y * y) - 1.0).abs() < 1.0e-6, "unit length");
}
}
#[test]
fn texture_loader_decodes_and_deduplicates_image_files() {
let folder = std::env::temp_dir()
.join(format!("rusting-texture-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("pixel.png");
image::RgbaImage::from_raw(1, 1, vec![12, 34, 56, 255])
.unwrap()
.save(&path)
.unwrap();
let mut server = AssetServer::default();
let first = server.load_texture(&path).unwrap();
let second = server.load_texture(&path).unwrap();
assert_eq!(first, second);
assert_eq!(server.textures.get(first).unwrap().size, [1, 1]);
assert_eq!(
server.textures.get(first).unwrap().rgba8,
[12, 34, 56, 255]
);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn native_mesh_loader_restores_imported_mesh_data() {
let folder = std::env::temp_dir()
.join(format!("rusting-mesh-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("triangle.rmesh");
let mesh = MeshAsset {
vertices: vec![MeshVertex::default(); 3],
indices: vec![0, 1, 2],
};
std::fs::write(&path, bincode::serialize(&mesh).unwrap()).unwrap();
let mut server = AssetServer::default();
let handle = server.load_mesh(&path).unwrap();
assert_eq!(server.meshes.get(handle), Some(&mesh));
assert_eq!(server.load_mesh(&path).unwrap(), handle);
std::fs::remove_dir_all(folder).unwrap();
}
#[test]
fn every_builtin_primitive_has_valid_triangle_geometry() {
let server = AssetServer::default();
for shape in PrimitiveShape::ALL {
let handle = server.builtin_primitive(shape);
let mesh = server.meshes.get(handle).unwrap();
assert!(
!mesh.vertices.is_empty(),
"{} has no vertices",
shape.label()
);
assert_eq!(
mesh.indices.len() % 3,
0,
"{} is not triangulated",
shape.label()
);
assert!(
mesh.indices
.iter()
.all(|index| (*index as usize) < mesh.vertices.len()),
"{} contains an invalid index",
shape.label()
);
assert_eq!(server.primitive_for_handle(handle), Some(shape));
}
}
}