diff --git a/Cargo.toml b/Cargo.toml index 05eddaf..fc8c994 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,10 @@ path = "examples/pbr.rs" name = "midi" path = "examples/midi.rs" +[[example]] +name = "gltf_load" +path = "examples/gltf_load.rs" + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/assets/gltf/Duck.glb b/assets/gltf/Duck.glb new file mode 100644 index 0000000..7cb0cc1 Binary files /dev/null and b/assets/gltf/Duck.glb differ diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index a9b794a..ddb5fbc 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -568,7 +568,16 @@ pub extern "C" fn processing_perspective( ) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_perspective(graphics_entity, fov, aspect, near, far)); + error::check(|| { + graphics_perspective( + graphics_entity, + fov, + aspect, + near, + far, + bevy::math::Vec4::new(0.0, 0.0, -1.0, -near), + ) + }); } #[unsafe(no_mangle)] diff --git a/crates/processing_pyo3/examples/gltf_load.py b/crates/processing_pyo3/examples/gltf_load.py new file mode 100644 index 0000000..9f8e0ff --- /dev/null +++ b/crates/processing_pyo3/examples/gltf_load.py @@ -0,0 +1,46 @@ +import math +from processing import * + +gltf = None +duck_geo = None +duck_mat = None +light = None +frame = 0 + +def setup(): + global gltf, duck_geo, duck_mat, light + size(800, 600) + + gltf = load_gltf("gltf/Duck.glb") + duck_geo = gltf.geometry("LOD3spShape") + duck_mat = gltf.material("blinn3-fx") + + mode_3d() + gltf.camera(0) + light = gltf.light(0) + +def draw(): + global frame + t = frame * 0.02 + + radius = 1.5 + lx = math.cos(t) * radius + ly = 1.5 + lz = math.sin(t) * radius + light.position(lx, ly, lz) + light.look_at(0.0, 0.8, 0.0) + + r = math.sin(t * 8.0) * 0.5 + 0.5 + g = math.sin(t * 8.0 + 2.0) * 0.5 + 0.5 + b = math.sin(t * 8.0 + 4.0) * 0.5 + 0.5 + duck_mat.set_float4("base_color", r, g, b, 1.0) + + background(25) + use_material(duck_mat) + draw_geometry(duck_geo) + + frame += 1 + + +# TODO: this should happen implicitly on module load somehow +run() diff --git a/crates/processing_pyo3/src/gltf.rs b/crates/processing_pyo3/src/gltf.rs new file mode 100644 index 0000000..93dd6b3 --- /dev/null +++ b/crates/processing_pyo3/src/gltf.rs @@ -0,0 +1,53 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{exceptions::PyRuntimeError, prelude::*}; + +use crate::graphics::{Geometry, Light, get_graphics}; +use crate::material::Material; + +#[pyclass(unsendable)] +pub struct Gltf { + entity: Entity, +} + +#[pymethods] +impl Gltf { + pub fn geometry(&self, name: &str) -> PyResult { + let entity = gltf_geometry(self.entity, name) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Geometry { entity }) + } + + pub fn material(&self, name: &str) -> PyResult { + let entity = gltf_material(self.entity, name) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Material { entity }) + } + + pub fn mesh_names(&self) -> PyResult> { + gltf_mesh_names(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn material_names(&self) -> PyResult> { + gltf_material_names(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn camera(&self, index: usize) -> PyResult<()> { + gltf_camera(self.entity, index).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn light(&self, index: usize) -> PyResult { + let entity = + gltf_light(self.entity, index).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Light { entity }) + } +} + +#[pyfunction] +#[pyo3(pass_module)] +pub fn load_gltf(module: &Bound<'_, PyModule>, path: &str) -> PyResult { + let graphics = get_graphics(module)?; + let entity = + gltf_load(graphics.entity, path).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Gltf { entity }) +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index a14f6db..89a2c39 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -1,3 +1,4 @@ +use bevy::math::Vec4; use bevy::prelude::Entity; use processing::prelude::*; use pyo3::{exceptions::PyRuntimeError, prelude::*, types::PyDict}; @@ -26,7 +27,7 @@ impl Drop for Surface { #[pyclass] #[derive(Debug)] pub struct Light { - entity: Entity, + pub(crate) entity: Entity, } #[pymethods] @@ -62,7 +63,7 @@ impl Drop for Image { #[pyclass(unsendable)] pub struct Geometry { - entity: Entity, + pub(crate) entity: Entity, } #[pyclass] @@ -132,7 +133,7 @@ impl Geometry { #[pyclass(unsendable)] pub struct Graphics { - entity: Entity, + pub(crate) entity: Entity, pub surface: Surface, } @@ -357,8 +358,15 @@ impl Graphics { } pub fn perspective(&self, fov: f32, aspect: f32, near: f32, far: f32) -> PyResult<()> { - graphics_perspective(self.entity, fov, aspect, near, far) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + graphics_perspective( + self.entity, + fov, + aspect, + near, + far, + Vec4::new(0.0, 0.0, -1.0, -near), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[allow(clippy::too_many_arguments)] diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 74e9212..5116932 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -9,6 +9,7 @@ //! To allow Python users to create a similar experience, we provide module-level //! functions that forward to a singleton Graphics object pub(crate) behind the scenes. mod glfw; +mod gltf; mod graphics; pub(crate) mod material; @@ -21,6 +22,7 @@ use pyo3::{ }; use std::ffi::{CStr, CString}; +use gltf::Gltf; use std::env; #[pymodule] @@ -30,6 +32,8 @@ fn processing(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(gltf::load_gltf, m)?)?; m.add_function(wrap_pyfunction!(size, m)?)?; m.add_function(wrap_pyfunction!(run, m)?)?; m.add_function(wrap_pyfunction!(mode_3d, m)?)?; diff --git a/crates/processing_render/src/error.rs b/crates/processing_render/src/error.rs index 91eec57..9a3ab90 100644 --- a/crates/processing_render/src/error.rs +++ b/crates/processing_render/src/error.rs @@ -34,4 +34,6 @@ pub enum ProcessingError { MaterialNotFound, #[error("Unknown material property: {0}")] UnknownMaterialProperty(String), + #[error("GLTF load error: {0}")] + GltfLoadError(String), } diff --git a/crates/processing_render/src/gltf.rs b/crates/processing_render/src/gltf.rs new file mode 100644 index 0000000..47056cc --- /dev/null +++ b/crates/processing_render/src/gltf.rs @@ -0,0 +1,350 @@ +//! Load and query GLTF files, providing name-based lookup for meshes, +//! materials, cameras, and lights. + +use bevy::{ + asset::{ + AssetPath, LoadState, handle_internal_asset_events, + io::{AssetSourceId, embedded::GetAssetServer}, + }, + camera::visibility::RenderLayers, + ecs::system::RunSystemOnce, + gltf::{Gltf, GltfMeshName}, + prelude::*, + scene::SceneSpawner, +}; + +use crate::config::{Config, ConfigKey}; +use crate::error::{ProcessingError, Result}; +use crate::geometry::{BuiltinAttributes, Geometry, layout::VertexLayout}; +use crate::graphics; +use crate::render::material::UntypedMaterial; + +#[derive(Component)] +pub struct GltfNodeTransform(pub Transform); + +fn resolve_asset_path(config: &Config, path: &str) -> AssetPath<'static> { + let asset_path = AssetPath::parse(path).into_owned(); + match config.get(ConfigKey::AssetRootPath) { + Some(_) => asset_path.with_source(AssetSourceId::from("assets_directory")), + None => asset_path, + } +} + +fn block_on_load(world: &mut World, load_state: impl Fn(&World) -> LoadState) -> Result<()> { + loop { + match load_state(world) { + LoadState::Loading => { + world.run_system_once(handle_internal_asset_events).unwrap(); + } + LoadState::Loaded => return Ok(()), + LoadState::Failed(err) => { + return Err(ProcessingError::GltfLoadError(format!( + "Asset failed to load: {err}" + ))); + } + LoadState::NotLoaded => { + return Err(ProcessingError::GltfLoadError( + "Asset not loaded".to_string(), + )); + } + } + } +} + +fn compute_global_transform(world: &World, entity: Entity) -> Transform { + let local = world.get::(entity).copied().unwrap_or_default(); + match world.get::(entity) { + Some(child_of) => { + let parent_global = compute_global_transform(world, child_of.parent()); + Transform::from_matrix(parent_global.to_matrix() * local.to_matrix()) + } + None => local, + } +} + +#[derive(Component)] +pub struct GltfHandle { + handle: Handle, + instance_id: bevy::scene::InstanceId, + graphics_entity: Entity, + base_path: String, +} + +pub fn load( + In((graphics_entity, path)): In<(Entity, String)>, + world: &mut World, +) -> Result { + let config = world.resource::().clone(); + let base_path = match path.find('#') { + Some(idx) => path[..idx].to_string(), + None => path.clone(), + }; + let asset_path = resolve_asset_path(&config, &base_path); + let handle: Handle = world.get_asset_server().load(asset_path); + block_on_load(world, |w| w.get_asset_server().load_state(&handle))?; + + let scene_handle = { + let gltf_assets = world.resource::>(); + let gltf = gltf_assets + .get(&handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + gltf.default_scene + .clone() + .or_else(|| gltf.scenes.first().cloned()) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF has no scenes".into()))? + }; + + // we spawn the scene in to the world in a blocking fashion so that bevy runs all + // its hooks for the gltf, ex creating standard material instances + let instance_id = world.resource_scope(|world, mut spawner: Mut| { + spawner + .spawn_sync(world, &scene_handle) + .map_err(|e| ProcessingError::GltfLoadError(format!("Scene spawn failed: {e}"))) + })?; + + // we have to remove the existing cameras from the scene -- the user can request to set *this* + // graphics to a camera, but the scenes cameras should not exist + { + let spawner = world.resource::(); + let cam_entities: Vec = spawner + .iter_instance_entities(instance_id) + .filter(|&e| world.get::(e).is_some()) + .collect(); + for e in cam_entities { + // gltf is weird -- cameras can exist on any node. we remove just the camera component rather + // than despawn in order to be safe + world.entity_mut(e).remove::(); + } + } + + let entity = world + .spawn(GltfHandle { + handle, + instance_id, + graphics_entity, + base_path, + }) + .id(); + Ok(entity) +} + +pub fn geometry( + In((gltf_entity, name)): In<(Entity, String)>, + world: &mut World, +) -> Result { + let gltf_handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let instance_id = gltf_handle.instance_id; + + let (mesh_handle, global_transform) = { + let spawner = world.resource::(); + + // find the mesh with the given name component that bevy added post-spawn + // name is derived from gltf node or computed + let mesh_entity = spawner + .iter_instance_entities(instance_id) + .find(|&e| { + world + .get::(e) + .map(|n| n.0 == name) + .unwrap_or(false) + }) + .ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Mesh '{}' not found in GLTF scene", name)) + })?; + + let mesh3d = world.get::(mesh_entity).ok_or_else(|| { + ProcessingError::GltfLoadError(format!( + "Mesh '{}' scene entity has no Mesh3d component", + name + )) + })?; + let handle = mesh3d.0.clone(); + let transform = compute_global_transform(world, mesh_entity); + (handle, transform) + }; + + let builtins = world.resource::(); + let attrs = vec![ + builtins.position, + builtins.normal, + builtins.color, + builtins.uv, + ]; + let layout_entity = world.spawn(VertexLayout::with_attributes(attrs)).id(); + let entity = world + .spawn(( + Geometry::new(mesh_handle, layout_entity), + GltfNodeTransform(global_transform), + )) + .id(); + Ok(entity) +} + +pub fn material( + In((gltf_entity, name)): In<(Entity, String)>, + world: &mut World, +) -> Result { + let handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let gltf_handle = handle.handle.clone(); + let base_path = handle.base_path.clone(); + + let material_index = { + let gltf_assets = world.resource::>(); + let gltf = gltf_assets + .get(&gltf_handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + let named_handle = gltf.named_materials.get(name.as_str()).ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Material '{}' not found in GLTF", name)) + })?; + gltf.materials + .iter() + .position(|h| h.id() == named_handle.id()) + .ok_or_else(|| { + ProcessingError::GltfLoadError(format!( + "Material '{}' not found in materials list", + name + )) + })? + }; + + let config = world.resource::().clone(); + let std_path = format!("{}#Material{}/std", base_path, material_index); + let asset_path = resolve_asset_path(&config, &std_path); + let handle: Handle = world.get_asset_server().load(asset_path); + block_on_load(world, |w| w.get_asset_server().load_state(&handle))?; + let entity = world.spawn(UntypedMaterial(handle.untyped())).id(); + Ok(entity) +} + +pub fn mesh_names(In(gltf_entity): In, world: &mut World) -> Result> { + let handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let gltf_handle = handle.handle.clone(); + + let gltf_assets = world.resource::>(); + let gltf = gltf_assets + .get(&gltf_handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + Ok(gltf.named_meshes.keys().map(|k| k.to_string()).collect()) +} + +pub fn material_names(In(gltf_entity): In, world: &mut World) -> Result> { + let handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let gltf_handle = handle.handle.clone(); + + let gltf_assets = world.resource::>(); + let gltf = gltf_assets + .get(&gltf_handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + Ok(gltf.named_materials.keys().map(|k| k.to_string()).collect()) +} + +pub fn camera(In((gltf_entity, index)): In<(Entity, usize)>, world: &mut World) -> Result<()> { + let gltf_handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let instance_id = gltf_handle.instance_id; + let graphics_entity = gltf_handle.graphics_entity; + + let (projection, node_xform) = { + let spawner = world.resource::(); + let camera_entity = spawner + .iter_instance_entities(instance_id) + .filter(|&e| world.get::(e).is_some()) + .nth(index) + .ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Camera index {} not found", index)) + })?; + + let projection = world + .get::(camera_entity) + .ok_or_else(|| { + ProcessingError::GltfLoadError("Camera entity has no Projection component".into()) + })? + .clone(); + let transform = compute_global_transform(world, camera_entity); + (projection, transform) + }; + + match projection { + Projection::Perspective(p) => { + world + .run_system_cached_with(graphics::perspective, (graphics_entity, p)) + .unwrap()?; + } + Projection::Orthographic(o) => { + world + .run_system_cached_with( + graphics::ortho, + ( + graphics_entity, + graphics::OrthoArgs { + left: o.area.min.x, + right: o.area.max.x, + bottom: o.area.min.y, + top: o.area.max.y, + near: o.near, + far: o.far, + }, + ), + ) + .unwrap()?; + } + Projection::Custom(_) => { + return Err(ProcessingError::GltfLoadError( + "Custom projections are not supported".into(), + )); + } + } + + let mut transform = world + .get_mut::(graphics_entity) + .ok_or(ProcessingError::GraphicsNotFound)?; + *transform = node_xform; + + Ok(()) +} + +pub fn light(In((gltf_entity, index)): In<(Entity, usize)>, world: &mut World) -> Result { + let gltf_handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)?; + let instance_id = gltf_handle.instance_id; + let graphics_entity = gltf_handle.graphics_entity; + + let light_entities: Vec = { + let spawner = world.resource::(); + spawner + .iter_instance_entities(instance_id) + .filter(|&e| { + world.get::(e).is_some() + || world.get::(e).is_some() + || world.get::(e).is_some() + }) + .collect() + }; + + let scene_light_entity = *light_entities.get(index).ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Light index {} not found", index)) + })?; + + let render_layers = world + .get::(graphics_entity) + .ok_or(ProcessingError::GraphicsNotFound)? + .clone(); + world.entity_mut(scene_light_entity).insert(render_layers); + + let global = compute_global_transform(world, scene_light_entity); + *world + .get_mut::(scene_light_entity) + .ok_or(ProcessingError::GraphicsNotFound)? = global; + + Ok(scene_light_entity) +} diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 960c9e2..2af81d8 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1,6 +1,7 @@ pub mod config; pub mod error; pub mod geometry; +pub mod gltf; mod graphics; pub mod image; pub mod light; @@ -1278,3 +1279,66 @@ pub fn material_destroy(entity: Entity) -> error::Result<()> { .unwrap() }) } + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_load(graphics_entity: Entity, path: &str) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::load, (graphics_entity, path.to_string())) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_geometry(gltf_entity: Entity, name: &str) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::geometry, (gltf_entity, name.to_string())) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_material(gltf_entity: Entity, name: &str) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::material, (gltf_entity, name.to_string())) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_mesh_names(gltf_entity: Entity) -> error::Result> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::mesh_names, gltf_entity) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_material_names(gltf_entity: Entity) -> error::Result> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::material_names, gltf_entity) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_camera(gltf_entity: Entity, index: usize) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::camera, (gltf_entity, index)) + .unwrap() + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn gltf_light(gltf_entity: Entity, index: usize) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::light, (gltf_entity, index)) + .unwrap() + }) +} diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 5e8471f..eb8cc0b 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -18,6 +18,7 @@ use transform::TransformStack; use crate::{ Flush, geometry::Geometry, + gltf::GltfNodeTransform, image::Image, render::{material::UntypedMaterial, primitive::rect}, }; @@ -122,7 +123,7 @@ pub fn flush_draw_commands( With, >, p_images: Query<&Image>, - p_geometries: Query<&Geometry>, + p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in @@ -297,7 +298,7 @@ pub fn flush_draw_commands( DrawCommand::ShearX { angle } => state.transform.shear_x(angle), DrawCommand::ShearY { angle } => state.transform.shear_y(angle), DrawCommand::Geometry(entity) => { - let Some(geometry) = p_geometries.get(entity).ok() else { + let Some((geometry, node_transform)) = p_geometries.get(entity).ok() else { warn!("Could not find Geometry for entity {:?}", entity); continue; }; @@ -318,6 +319,14 @@ pub fn flush_draw_commands( let z_offset = -(batch.draw_index as f32 * 0.001); let mut transform = state.transform.to_bevy_transform(); + + // if the "source" geometry was parented in a gltf scene, we need to make sure that + // we apply the parent transform here to ensure the correct final transform + // TODO: think about how hierarchies should work, especially for retained + if let Some(nt) = node_transform { + transform = + Transform::from_matrix(transform.to_matrix() * nt.0.to_matrix()); + } transform.translation.z += z_offset; res.commands.spawn(( diff --git a/examples/gltf_load.rs b/examples/gltf_load.rs new file mode 100644 index 0000000..8abf3f4 --- /dev/null +++ b/examples/gltf_load.rs @@ -0,0 +1,75 @@ +mod glfw; + +use glfw::GlfwContext; +use processing::prelude::*; +use processing_render::material::MaterialValue; +use processing_render::render::command::DrawCommand; + +fn main() { + match sketch() { + Ok(_) => { + eprintln!("Sketch completed successfully"); + exit(0).unwrap(); + } + Err(e) => { + eprintln!("Sketch error: {:?}", e); + exit(1).unwrap(); + } + }; +} + +fn sketch() -> error::Result<()> { + let width = 800; + let height = 600; + let mut glfw_ctx = GlfwContext::new(width, height)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(width, height, 1.0)?; + let graphics = graphics_create(surface, width, height)?; + + let gltf = gltf_load(graphics, "gltf/Duck.glb")?; + let duck = gltf_geometry(gltf, "LOD3spShape")?; + let duck_mat = gltf_material(gltf, "blinn3-fx")?; + + graphics_mode_3d(graphics)?; + gltf_camera(gltf, 0)?; + let light = gltf_light(gltf, 0)?; + + let mut frame: u64 = 0; + + while glfw_ctx.poll_events() { + let t = frame as f32 * 0.02; + + let radius = 1.5; + let lx = t.cos() * radius; + let ly = 1.5; + let lz = t.sin() * radius; + transform_set_position(light, lx, ly, lz)?; + transform_look_at(light, 0.0, 0.8, 0.0)?; + + let r = (t * 8.0).sin() * 0.5 + 0.5; + let g = (t * 8.0 + 2.0).sin() * 0.5 + 0.5; + let b = (t * 8.0 + 4.0).sin() * 0.5 + 0.5; + material_set( + duck_mat, + "base_color", + MaterialValue::Float4([r, g, b, 1.0]), + )?; + + graphics_begin_draw(graphics)?; + + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.1, 0.1, 0.12)), + )?; + + graphics_record_command(graphics, DrawCommand::Material(duck_mat))?; + graphics_record_command(graphics, DrawCommand::Geometry(duck))?; + + graphics_end_draw(graphics)?; + + frame += 1; + } + + Ok(()) +}