From 519474591530548d0efd08dafa6fc68f74168489 Mon Sep 17 00:00:00 2001 From: teodosin Date: Tue, 5 Aug 2025 23:23:31 +0300 Subject: [PATCH 01/14] working initial example --- Cargo.lock | 104 +++++++ Cargo.toml | 7 + assets/scenes/monkey.glb | Bin 0 -> 69888 bytes examples/mesh_to_cloud.rs | 435 +++++++++++++++++++++++++++++ examples/mesh_to_cloud/Cargo.toml | 20 ++ examples/mesh_to_cloud/src/main.rs | 340 ++++++++++++++++++++++ 6 files changed, 906 insertions(+) create mode 100644 assets/scenes/monkey.glb create mode 100644 examples/mesh_to_cloud.rs create mode 100644 examples/mesh_to_cloud/Cargo.toml create mode 100644 examples/mesh_to_cloud/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 9df39847..ace3ed35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,40 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "bevy_gltf" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa25b809ee024ef2682bafc1ca22ca8275552edb549dc6f69a030fdffd976c63" +dependencies = [ + "base64 0.22.1", + "bevy_app 0.16.0", + "bevy_asset 0.16.0", + "bevy_color 0.16.1", + "bevy_core_pipeline 0.16.0", + "bevy_ecs 0.16.0", + "bevy_image", + "bevy_math 0.16.0", + "bevy_mesh", + "bevy_pbr 0.16.0", + "bevy_platform", + "bevy_reflect 0.16.0", + "bevy_render 0.16.0", + "bevy_scene 0.16.0", + "bevy_tasks 0.16.0", + "bevy_transform 0.16.0", + "bevy_utils 0.16.0", + "fixedbitset 0.5.7", + "gltf", + "itertools 0.14.0", + "percent-encoding", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.12", + "tracing", +] + [[package]] name = "bevy_hierarchy" version = "0.14.2" @@ -1472,7 +1506,9 @@ dependencies = [ "guillotiere", "half", "image", + "ktx2", "rectangle-pack", + "ruzstd", "serde", "thiserror 2.0.12", "tracing", @@ -1619,6 +1655,7 @@ dependencies = [ "bevy_diagnostic 0.16.0", "bevy_ecs 0.16.0", "bevy_gizmos 0.16.0", + "bevy_gltf", "bevy_image", "bevy_input 0.16.0", "bevy_input_focus", @@ -2139,6 +2176,7 @@ dependencies = [ "image", "indexmap", "js-sys", + "ktx2", "naga 24.0.0", "naga_oil 0.17.0", "nonmax", @@ -4266,6 +4304,42 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gltf" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" +dependencies = [ + "byteorder", + "gltf-json", + "lazy_static", + "serde_json", +] + +[[package]] +name = "gltf-derive" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" +dependencies = [ + "inflections", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]] +name = "gltf-json" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" +dependencies = [ + "gltf-derive", + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "glutin_wgl_sys" version = "0.5.0" @@ -4757,6 +4831,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +[[package]] +name = "inflections" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" + [[package]] name = "instant" version = "0.1.13" @@ -4912,6 +4992,15 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "ktx2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87d65e08a9ec02e409d27a0139eaa6b9756b4d81fe7cde71f6941a83730ce838" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -6594,6 +6683,15 @@ dependencies = [ "unicode-script", ] +[[package]] +name = "ruzstd" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640bec8aad418d7d03c72ea2de10d5c646a598f9883c7babc160d91e3c1b26c" +dependencies = [ + "twox-hash", +] + [[package]] name = "ryu" version = "1.0.19" @@ -7521,6 +7619,12 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "twox-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b907da542cbced5261bd3256de1b3a1bf340a3d37f93425a07362a1d687de56" + [[package]] name = "typeid" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 1fa7e121..a7a8456d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -194,12 +194,14 @@ default-features = false features = [ "bevy_asset", "bevy_core_pipeline", + "bevy_gltf", "bevy_log", "bevy_pbr", "bevy_render", "bevy_winit", "serialize", "std", + "tonemapping_luts", "x11", ] @@ -286,6 +288,11 @@ path = "examples/headless.rs" name = "multi_camera" path = "examples/multi_camera.rs" +[[example]] +name = "mesh_to_cloud" +path = "examples/mesh_to_cloud.rs" +required-features = ["viewer", "io_ply", "planar", "buffer_storage", "bevy/bevy_ui", "bevy/bevy_scene"] + [[bench]] name = "io" diff --git a/assets/scenes/monkey.glb b/assets/scenes/monkey.glb new file mode 100644 index 0000000000000000000000000000000000000000..fffc4c435abfb9a1d24474e8371d2021a4994b93 GIT binary patch literal 69888 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 literal 0 HcmV?d00001 diff --git a/examples/mesh_to_cloud.rs b/examples/mesh_to_cloud.rs new file mode 100644 index 00000000..a3142e6a --- /dev/null +++ b/examples/mesh_to_cloud.rs @@ -0,0 +1,435 @@ +// Converts a mesh (monkey.glb) into a Gaussian cloud on CPU: one splat per vertex, edge, and face, +// with color derived from the primitive normal. +// +// Run: cargo run --example mesh_to_cloud --features="viewer io_ply planar buffer_storage bevy/bevy_ui bevy/bevy_scene" +// Ensure assets/scenes/monkey.glb exists under bevy_gaussian_splatting/assets. + +use std::collections::HashSet; +use bevy::prelude::*; +use bevy::render::mesh::{ + Indices, + PrimitiveTopology, + VertexAttributeValues, +}; + +use bevy_gaussian_splatting::{ + CloudSettings, + Gaussian3d, + GaussianCamera, + GaussianSplattingPlugin, + PlanarGaussian3d, + PlanarGaussian3dHandle, +}; + +const GLB_PATH: &str = "scenes/monkey.glb"; + +// Tunables for splat appearance +const DEFAULT_OPACITY: f32 = 0.8; +const DEFAULT_SCALE: f32 = 0.01; // Small vertices +const EDGE_SCALE: f32 = 0.005; // Thin edges +const FACE_SCALE: f32 = 0.002; // Very flat faces + +// Entry +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(GaussianSplattingPlugin) + .add_systems(Startup, (spawn_camera_and_light, load_monkey)) + .add_systems(Update, try_convert_loaded_mesh) + .run(); +} + +fn spawn_camera_and_light(mut commands: Commands) { + commands.spawn(( + GaussianCamera { + warmup: true, + }, + Camera3d::default(), + Transform::from_translation(Vec3::new(0.0, 5.0, 2.0)).looking_at(Vec3::ZERO, Vec3::Y), + )); + commands.spawn(( + DirectionalLight::default(), + Transform::from_xyz(2.0, 4.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y), + )); +} + +#[derive(Resource, Default)] +struct PendingScene(Handle); + +fn load_monkey(mut commands: Commands, assets: Res) { + let scene: Handle = assets.load(GLB_PATH.to_string() + "#Scene0"); + commands.insert_resource(PendingScene(scene.clone())); + commands.spawn(( + SceneRoot(scene), + Transform::default(), + Visibility::Visible, + )); +} + +fn try_convert_loaded_mesh( + mut commands: Commands, + pending: Option>, + pnp: Query<(Entity, &Mesh3d, &GlobalTransform)>, + meshes: Res>, + mut planar_gaussians: ResMut>, +) { + if pending.is_none() { + return; + } + + let mut collected: Vec<(Handle, Transform)> = Vec::new(); + let mut mesh_entities: Vec = Vec::new(); + for (entity, mesh3d, transform) in pnp.iter() { + info!("Found mesh entity with Mesh3d component"); + collected.push((mesh3d.0.clone(), transform.compute_transform())); + mesh_entities.push(entity); + } + + if collected.is_empty() { + info!("No meshes found with Mesh3d component, waiting..."); + return; + } + + info!("Converting {} mesh(es) to Gaussian cloud", collected.len()); + + commands.remove_resource::(); + + // Hide original mesh entities now that we know the Gaussians work + for entity in mesh_entities { + commands.entity(entity).insert(Visibility::Hidden); + } + + let mut all_vertices: Vec = Vec::new(); + let mut all_edges: Vec = Vec::new(); + let mut all_faces: Vec = Vec::new(); + let mesh_count = collected.len(); + + for (mh, transform) in collected { + if let Some(mesh) = meshes.get(&mh) { + info!("Converting mesh with {} vertices", mesh.attribute(Mesh::ATTRIBUTE_POSITION).map(|attr| attr.len()).unwrap_or(0)); + let (vertices, edges, faces) = convert_mesh_to_gaussians_separated(mesh, transform); + info!("Generated {} vertices, {} edges, {} faces", vertices.len(), edges.len(), faces.len()); + all_vertices.extend(vertices); + all_edges.extend(edges); + all_faces.extend(faces); + } else { + warn!("Mesh handle not found in assets!"); + } + } + + if all_vertices.is_empty() && all_edges.is_empty() && all_faces.is_empty() { + warn!("mesh_to_cloud: No gaussians produced from {} meshes", mesh_count); + return; + } + + info!("Total gaussians: {} vertices, {} edges, {} faces", all_vertices.len(), all_edges.len(), all_faces.len()); + + // Spawn three separate clouds positioned side by side + let spacing = 3.0; + + // Vertices cloud (left) + if !all_vertices.is_empty() { + let vertices_cloud = PlanarGaussian3d::from(all_vertices); + let vertices_handle = planar_gaussians.add(vertices_cloud); + let vertices_entity = commands.spawn(( + PlanarGaussian3dHandle(vertices_handle), + CloudSettings { + aabb: true, + ..default() + }, + Transform::from_xyz(-spacing, 0.0, 0.0), + )).id(); + info!("Spawned vertices cloud entity {:?}", vertices_entity); + } + + // Edges cloud (center) + if !all_edges.is_empty() { + let edges_cloud = PlanarGaussian3d::from(all_edges); + let edges_handle = planar_gaussians.add(edges_cloud); + let edges_entity = commands.spawn(( + PlanarGaussian3dHandle(edges_handle), + CloudSettings { + aabb: true, + ..default() + }, + Transform::from_xyz(0.0, 0.0, 0.0), + )).id(); + info!("Spawned edges cloud entity {:?}", edges_entity); + } + + // Faces cloud (right) + if !all_faces.is_empty() { + let faces_cloud = PlanarGaussian3d::from(all_faces); + let faces_handle = planar_gaussians.add(faces_cloud); + let faces_entity = commands.spawn(( + PlanarGaussian3dHandle(faces_handle), + CloudSettings { + aabb: true, + ..default() + }, + Transform::from_xyz(spacing, 0.0, 0.0), + )).id(); + info!("Spawned faces cloud entity {:?}", faces_entity); + } +} + +// Convert a Mesh into separate Gaussian3d collections for vertices, edges, and faces +fn convert_mesh_to_gaussians_separated(mesh: &Mesh, transform: Transform) -> (Vec, Vec, Vec) { + info!("Starting mesh conversion..."); + let topology = mesh.primitive_topology(); + info!("Mesh topology: {:?}", topology); + + let positions = match read_positions(mesh) { + Some(v) => { + info!("Found {} vertex positions", v.len()); + v + }, + None => { + warn!("mesh_to_cloud: mesh missing positions"); + return (Vec::new(), Vec::new(), Vec::new()); + } + }; + let normals_opt = read_normals(mesh); + info!("Normals available: {}", normals_opt.is_some()); + + // Build index buffer as u32 + let indices_u32: Option> = match mesh.indices() { + Some(Indices::U32(ix)) => Some(ix.clone()), + Some(Indices::U16(ix)) => Some(ix.iter().map(|&x| x as u32).collect()), + None => None, + }; + + // Vertex normals: either from attribute or computed from faces + let vertex_normals = normals_opt.unwrap_or_else(|| compute_vertex_normals(topology, &positions, indices_u32.as_ref())); + + let mut vertices: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + let mut faces: Vec = Vec::new(); + + // 1) Vertices - small isotropic splats + for (vpos, vnorm) in positions.iter().zip(vertex_normals.iter()) { + let pos = transform.transform_point(*vpos); + let rot = Quat::IDENTITY; // No special rotation needed for isotropic vertex splats + let scale = Vec3::splat(DEFAULT_SCALE); + vertices.push(gaussian_from_transform(pos, rot, scale, *vnorm, DEFAULT_OPACITY)); + } + + // For edges and faces we need indices and triangles + if let Some(indices) = indices_u32 { + // 2) Faces - flat splats oriented in the triangle plane + let tri_iter = triangles_from(topology, &indices); + let tris: Vec<[u32; 3]> = tri_iter.collect(); + for tri in &tris { + let p0 = positions[tri[0] as usize]; + let p1 = positions[tri[1] as usize]; + let p2 = positions[tri[2] as usize]; + + let centroid = (p0 + p1 + p2) / 3.0; + + // Calculate face normal and create local coordinate system + let edge1 = p1 - p0; + let edge2 = p2 - p0; + let face_normal = edge1.cross(edge2).normalize_or_zero(); + + // Create a rotation that aligns the Z-axis with the face normal + // This makes the XY plane of the splat lie in the triangle plane + let rot = Quat::from_rotation_arc(Vec3::Z, face_normal); + + // Scale: make it flat in Z direction, and sized to cover the triangle area + // Shrink by 25% total (15% + 10% additional) for better visual separation + let edge1_len = edge1.length(); + let edge2_len = edge2.length(); + let scale_factor = 0.65; // 25% smaller total + let scale = Vec3::new(edge1_len * 0.5 * scale_factor, edge2_len * 0.5 * scale_factor, FACE_SCALE); + + faces.push(gaussian_from_transform( + transform.transform_point(centroid), + rot, + scale, + face_normal, + DEFAULT_OPACITY, + )); + } + + // 3) Edges - elongated splats along edge direction + let mut set: HashSet<(u32, u32)> = HashSet::new(); + for tri in &tris { + let e = [ + (tri[0], tri[1]), + (tri[1], tri[2]), + (tri[2], tri[0]), + ]; + for (a, b) in e { + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + if set.insert((lo, hi)) { + let pa = positions[lo as usize]; + let pb = positions[hi as usize]; + let mid = (pa + pb) * 0.5; + let na = vertex_normals[lo as usize]; + let nb = vertex_normals[hi as usize]; + let avg_normal = (na + nb).normalize_or_zero(); + + let edge_vec = pb - pa; + let edge_length = edge_vec.length(); + let edge_dir = edge_vec.normalize_or_zero(); + + // Create rotation that aligns the splat's long axis with edge direction + // Since Gaussian splats are longest in their X direction by default, + // we want to align X with the edge direction + let rot = if edge_dir.length() > 0.001 { + Quat::from_rotation_arc(Vec3::X, edge_dir) + } else { + Quat::IDENTITY + }; + + // Scale: long along edge (X), thin in other directions + // Reduce edge length by 5x for better proportions + let scale = Vec3::new(edge_length * 0.2, EDGE_SCALE, EDGE_SCALE); + + edges.push(gaussian_from_transform( + transform.transform_point(mid), + rot, + scale, + avg_normal, + DEFAULT_OPACITY, + )); + } + } + } + } else { + // No indices; treat as point cloud of vertices only + debug!("mesh_to_cloud: mesh had no indices; produced only vertex splats"); + } + + info!("Conversion complete: {} vertices, {} edges, {} faces", vertices.len(), edges.len(), faces.len()); + (vertices, edges, faces) +} + +fn triangles_from(topology: PrimitiveTopology, indices: &[u32]) -> impl Iterator + '_ { + match topology { + PrimitiveTopology::TriangleList => Box::new(indices.chunks_exact(3).map(|c| [c[0], c[1], c[2]])) as Box + '_>, + _ => { + warn!("mesh_to_cloud: non-triangle topology {:?} not fully supported; attempting naive 3-chunking", topology); + Box::new(indices.chunks(3).filter(|c| c.len() == 3).map(|c| [c[0], c[1], c[2]])) + } + } +} + +// --- Mesh attribute readers --- + +fn read_positions(mesh: &Mesh) -> Option> { + // Bevy standard attribute + let attr = Mesh::ATTRIBUTE_POSITION; + mesh.attribute(attr).and_then(|a| { + // Convert any supported format to f32 Vec3 + match a { + VertexAttributeValues::Float32x3(v) => { + Some(v.iter().map(|p| Vec3::from_slice(p)).collect()) + } + VertexAttributeValues::Float32x2(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect()) + } + VertexAttributeValues::Float32x4(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect()) + } + VertexAttributeValues::Uint32x3(v) => { + Some(v.iter().map(|p| Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32)).collect()) + } + _ => None, + } + }) +} + +fn read_normals(mesh: &Mesh) -> Option> { + let attr = Mesh::ATTRIBUTE_NORMAL; + mesh.attribute(attr).and_then(|a| { + match a { + VertexAttributeValues::Float32x3(v) => { + Some(v.iter().map(|p| Vec3::from_slice(p)).collect()) + } + VertexAttributeValues::Float32x4(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect()) + } + VertexAttributeValues::Uint32x3(v) => { + Some(v.iter().map(|p| Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32)).collect()) + } + _ => None, + } + }) +} + +// Compute per-vertex normals if missing +fn compute_vertex_normals(topology: PrimitiveTopology, positions: &[Vec3], indices: Option<&Vec>) -> Vec { + let mut normals = vec![Vec3::ZERO; positions.len()]; + + if let Some(ix) = indices { + for tri in triangles_from(topology, ix) { + let p0 = positions[tri[0] as usize]; + let p1 = positions[tri[1] as usize]; + let p2 = positions[tri[2] as usize]; + let n = face_normal(p0, p1, p2); + normals[tri[0] as usize] += n; + normals[tri[1] as usize] += n; + normals[tri[2] as usize] += n; + } + } + + for n in &mut normals { + *n = n.normalize_or_zero(); + } + normals +} + +fn face_normal(p0: Vec3, p1: Vec3, p2: Vec3) -> Vec3 { + (p1 - p0).cross(p2 - p0).normalize_or_zero() +} + +fn normal_to_rgb(n: Vec3) -> [f32; 3] { + // Enhanced contrast: map normals to more vibrant colors + let normalized = n.normalize_or_zero(); + + // Map from [-1, 1] to [0, 1] with enhanced contrast + let base = (normalized * 0.5) + Vec3::splat(0.5); + + // Apply stronger contrast enhancement: make colors much more saturated + let contrast_factor = 2.2; // Increased from 1.5 to 2.2 + let enhanced = ((base - Vec3::splat(0.5)) * contrast_factor) + Vec3::splat(0.5); + + // Clamp to valid range + let clamped = enhanced.clamp(Vec3::ZERO, Vec3::ONE); + + [clamped.x, clamped.y, clamped.z] +} + +// Construct a Gaussian3d from a transform, a normal for color, and an opacity. +fn gaussian_from_transform( + pos: Vec3, + rot: Quat, + scale: Vec3, + norm: Vec3, + opacity: f32, +) -> Gaussian3d { + let mut g = Gaussian3d::default(); + // position + visibility + g.position_visibility.position = pos.to_array(); + g.position_visibility.visibility = 1.0; + + // rotation - use the rotation as provided (each caller handles orientation appropriately) + g.rotation.rotation = rot.to_array(); + + // scale and opacity + g.scale_opacity.scale = scale.to_array(); + g.scale_opacity.opacity = opacity; + + // Color via SH DC coefficients (first 3 channels as RGB) + let rgb = normal_to_rgb(norm); + g.spherical_harmonic.set(0, rgb[0]); + g.spherical_harmonic.set(1, rgb[1]); + g.spherical_harmonic.set(2, rgb[2]); + // zero the rest for determinism + for i in 3..bevy_gaussian_splatting::material::spherical_harmonics::SH_COEFF_COUNT { + g.spherical_harmonic.set(i, 0.0); + } + + g +} \ No newline at end of file diff --git a/examples/mesh_to_cloud/Cargo.toml b/examples/mesh_to_cloud/Cargo.toml new file mode 100644 index 00000000..e137af4c --- /dev/null +++ b/examples/mesh_to_cloud/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mesh_to_cloud_example" +version = "0.1.0" +edition = "2021" + +[dependencies] +bevy = { version = "0.16", features = [ + "bevy_asset", + "bevy_core_pipeline", + "bevy_log", + "bevy_pbr", + "bevy_render", + "bevy_scene", + "bevy_ui", + "bevy_winit", + "serialize", + "std", + "x11", +] } +bevy_gaussian_splatting = { path = "../..", features = ["viewer", "io_ply", "planar", "buffer_storage"] } diff --git a/examples/mesh_to_cloud/src/main.rs b/examples/mesh_to_cloud/src/main.rs new file mode 100644 index 00000000..710679ad --- /dev/null +++ b/examples/mesh_to_cloud/src/main.rs @@ -0,0 +1,340 @@ +// Converts a mesh (monkey.glb) into a Gaussian cloud on CPU: one splat per vertex, edge, and face, +// with color derived from the primitive normal. +// +// Run: cargo run +// Ensure assets/scenes/monkey.glb exists under bevy_gaussian_splatting/assets. + +use std::collections::HashSet; +use bevy::prelude::*; +use bevy::render::mesh::{ + Indices, + PrimitiveTopology, + VertexAttributeValues, +}; + +use bevy_gaussian_splatting::{ + CloudSettings, + Gaussian3d, + GaussianSplattingPlugin, + PlanarGaussian3d, + PlanarGaussian3dHandle, + RasterizeMode, +}; + +const GLB_PATH: &str = "scenes/monkey.glb"; + +// Tunables for splat appearance +const DEFAULT_OPACITY: f32 = 0.8; +const DEFAULT_SCALE: f32 = 0.02; // isotropic; adjust to your mesh units +const EDGE_SCALE: f32 = 0.015; +const FACE_SCALE: f32 = 0.03; + +// Entry +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(GaussianSplattingPlugin) + .add_systems(Startup, (spawn_camera_and_light, load_monkey)) + .add_systems(Update, try_convert_loaded_mesh) + .run(); +} + +fn spawn_camera_and_light(mut commands: Commands) { + commands.spawn(( + Camera3d::default(), + Transform::from_translation(Vec3::new(0.0, 0.6, 2.2)).looking_at(Vec3::ZERO, Vec3::Y), + )); + commands.spawn(( + DirectionalLight::default(), + Transform::from_xyz(2.0, 4.0, 2.0).looking_at(Vec3::ZERO, Vec3::Y), + )); +} + +#[derive(Resource, Default)] +struct PendingScene(Handle); + +fn load_monkey(mut commands: Commands, assets: Res) { + let scene: Handle = assets.load(GLB_PATH.to_string() + "#Scene0"); + commands.insert_resource(PendingScene(scene.clone())); + commands.spawn(( + SceneRoot(scene), + Transform::default(), + Visibility::Visible, + )); +} + +fn try_convert_loaded_mesh( + mut commands: Commands, + pending: Option>, + pnp: Query<(&Mesh3d, &GlobalTransform)>, + meshes: Res>, + mut planar_gaussians: ResMut>, +) { + if pending.is_none() { + return; + } + + let mut collected: Vec<(Handle, Transform)> = Vec::new(); + for (mesh3d, transform) in pnp.iter() { + collected.push((mesh3d.0.clone(), transform.compute_transform())); + } + + if collected.is_empty() { + return; + } + + commands.remove_resource::(); + + let mut gaussians: Vec = Vec::new(); + for (mh, transform) in collected { + if let Some(mesh) = meshes.get(&mh) { + gaussians.extend(convert_mesh_to_gaussians(mesh, transform)); + } + } + + if gaussians.is_empty() { + warn!("mesh_to_cloud: No gaussians produced"); + return; + } + + let cloud: PlanarGaussian3d = PlanarGaussian3d::from(gaussians); + + let handle = planar_gaussians.add(cloud); + commands.spawn(( + PlanarGaussian3dHandle(handle), + CloudSettings { + rasterize_mode: RasterizeMode::Color, + global_scale: 1.0, + ..default() + }, + Transform::default(), + Visibility::Visible, + )); + + info!("mesh_to_cloud: spawned Gaussian cloud from {}", GLB_PATH); +} + +// Convert a Mesh into Gaussian3d instances for vertices, edges, and faces +fn convert_mesh_to_gaussians(mesh: &Mesh, transform: Transform) -> Vec { + let topology = mesh.primitive_topology(); + let positions = match read_positions(mesh) { + Some(v) => v, + None => { + warn!("mesh_to_cloud: mesh missing positions"); + return Vec::new(); + } + }; + let normals_opt = read_normals(mesh); + + // Build index buffer as u32 + let indices_u32: Option> = match mesh.indices() { + Some(Indices::U32(ix)) => Some(ix.clone()), + Some(Indices::U16(ix)) => Some(ix.iter().map(|&x| x as u32).collect()), + None => None, + }; + + // Vertex normals: either from attribute or computed from faces + let vertex_normals = normals_opt.unwrap_or_else(|| compute_vertex_normals(topology, &positions, indices_u32.as_ref())); + + let mut out: Vec = Vec::new(); + + // 1) Vertices + for (vpos, vnorm) in positions.iter().zip(vertex_normals.iter()) { + let pos = transform.transform_point(*vpos); + let rot = Quat::IDENTITY; + let scale = Vec3::splat(DEFAULT_SCALE); + out.push(gaussian_from_transform(pos, rot, scale, *vnorm, DEFAULT_OPACITY)); + } + + // For edges and faces we need indices and triangles + if let Some(indices) = indices_u32 { + // 2) Faces: assumes triangle topology + let tri_iter = triangles_from(topology, &indices); + let tris: Vec<[u32; 3]> = tri_iter.collect(); + for tri in &tris { + let p0 = positions[tri[0] as usize]; + let p1 = positions[tri[1] as usize]; + let p2 = positions[tri[2] as usize]; + + let centroid = (p0 + p1 + p2) / 3.0; + + let u = p1 - p0; + let v = p2 - p0; + + let x_axis = u.normalize_or_zero(); + let z_axis = u.cross(v).normalize_or_zero(); + let y_axis = z_axis.cross(x_axis); + + let rot = Quat::from_mat3(&Mat3::from_cols(x_axis, y_axis, z_axis)); + + let u_len = u.length(); + let v_on_y = v.dot(y_axis).abs(); + + let scale = Vec3::new(u_len, v_on_y, FACE_SCALE); + let face_n = z_axis; + + out.push(gaussian_from_transform( + transform.transform_point(centroid), + rot, + scale, + face_n, + DEFAULT_OPACITY, + )); + } + + // 3) Edges: dedupe undirected + let mut set: HashSet<(u32, u32)> = HashSet::new(); + for tri in &tris { + let e = [ + (tri[0], tri[1]), + (tri[1], tri[2]), + (tri[2], tri[0]), + ]; + for (a, b) in e { + let (lo, hi) = if a < b { (a, b) } else { (b, a) }; + if set.insert((lo, hi)) { + let pa = positions[lo as usize]; + let pb = positions[hi as usize]; + let mid = (pa + pb) * 0.5; + let na = vertex_normals[lo as usize]; + let nb = vertex_normals[hi as usize]; + let n = (na + nb).normalize_or_zero(); + + let edge_vec = pb - pa; + let rot = Quat::from_rotation_arc(Vec3::X, edge_vec.normalize_or_zero()); + let scale = Vec3::new(edge_vec.length(), EDGE_SCALE, EDGE_SCALE); + + out.push(gaussian_from_transform( + transform.transform_point(mid), + rot, + scale, + n, + DEFAULT_OPACITY, + )); + } + } + } + } else { + // No indices; treat as point cloud of vertices only + debug!("mesh_to_cloud: mesh had no indices; produced only vertex splats"); + } + + out +} + +fn triangles_from(topology: PrimitiveTopology, indices: &[u32]) -> impl Iterator + '_ { + match topology { + PrimitiveTopology::TriangleList => Box::new(indices.chunks_exact(3).map(|c| [c[0], c[1], c[2]])) as Box + '_>, + _ => { + warn!("mesh_to_cloud: non-triangle topology {:?} not fully supported; attempting naive 3-chunking", topology); + Box::new(indices.chunks(3).filter(|c| c.len() == 3).map(|c| [c[0], c[1], c[2]])) + } + } +} + +// --- Mesh attribute readers --- + +fn read_positions(mesh: &Mesh) -> Option> { + // Bevy standard attribute + let attr = Mesh::ATTRIBUTE_POSITION; + mesh.attribute(attr).and_then(|a| { + // Convert any supported format to f32 Vec3 + match a { + VertexAttributeValues::Float32x3(v) => { + Some(v.iter().map(|p| Vec3::from_slice(p)).collect()) + } + VertexAttributeValues::Float32x2(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], 0.0)).collect()) + } + VertexAttributeValues::Float32x4(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect()) + } + VertexAttributeValues::Uint32x3(v) => { + Some(v.iter().map(|p| Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32)).collect()) + } + _ => None, + } + }) +} + +fn read_normals(mesh: &Mesh) -> Option> { + let attr = Mesh::ATTRIBUTE_NORMAL; + mesh.attribute(attr).and_then(|a| { + match a { + VertexAttributeValues::Float32x3(v) => { + Some(v.iter().map(|p| Vec3::from_slice(p)).collect()) + } + VertexAttributeValues::Float32x4(v) => { + Some(v.iter().map(|p| Vec3::new(p[0], p[1], p[2])).collect()) + } + VertexAttributeValues::Uint32x3(v) => { + Some(v.iter().map(|p| Vec3::new(p[0] as f32, p[1] as f32, p[2] as f32)).collect()) + } + _ => None, + } + }) +} + +// Compute per-vertex normals if missing +fn compute_vertex_normals(topology: PrimitiveTopology, positions: &[Vec3], indices: Option<&Vec>) -> Vec { + let mut normals = vec![Vec3::ZERO; positions.len()]; + + if let Some(ix) = indices { + for tri in triangles_from(topology, ix) { + let p0 = positions[tri[0] as usize]; + let p1 = positions[tri[1] as usize]; + let p2 = positions[tri[2] as usize]; + let n = face_normal(p0, p1, p2); + normals[tri[0] as usize] += n; + normals[tri[1] as usize] += n; + normals[tri[2] as usize] += n; + } + } + + for n in &mut normals { + *n = n.normalize_or_zero(); + } + normals +} + +fn face_normal(p0: Vec3, p1: Vec3, p2: Vec3) -> Vec3 { + (p1 - p0).cross(p2 - p0).normalize_or_zero() +} + +fn normal_to_rgb(n: Vec3) -> [f32; 3] { + let c = (n * 0.5) + Vec3::splat(0.5); + [c.x, c.y, c.z] +} + +// Construct a Gaussian3d from a transform, a normal for color, and an opacity. +fn gaussian_from_transform( + pos: Vec3, + rot: Quat, + scale: Vec3, + norm: Vec3, + opacity: f32, +) -> Gaussian3d { + let mut g = Gaussian3d::default(); + // position + visibility + g.position_visibility.position = pos.to_array(); + g.position_visibility.visibility = 1.0; + + // rotation + g.rotation.rotation = rot.to_array(); + + // scale and opacity + g.scale_opacity.scale = scale.to_array(); + g.scale_opacity.opacity = opacity; + + // Color via SH DC coefficients (first 3 channels as RGB) + let rgb = normal_to_rgb(norm); + g.spherical_harmonic.set(0, rgb[0]); + g.spherical_harmonic.set(1, rgb[1]); + g.spherical_harmonic.set(2, rgb[2]); + // zero the rest for determinism + for i in 3..bevy_gaussian_splatting::material::spherical_harmonics::SH_COEFF_COUNT { + g.spherical_harmonic.set(i, 0.0); + } + + g +} From f207d73a53cc33145379ed13bd304bebaae8e40a Mon Sep 17 00:00:00 2001 From: teodosin Date: Tue, 5 Aug 2025 23:32:30 +0300 Subject: [PATCH 02/14] camera controls --- examples/mesh_to_cloud.rs | 55 +++++++++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/examples/mesh_to_cloud.rs b/examples/mesh_to_cloud.rs index a3142e6a..34941c81 100644 --- a/examples/mesh_to_cloud.rs +++ b/examples/mesh_to_cloud.rs @@ -35,7 +35,7 @@ fn main() { .add_plugins(DefaultPlugins) .add_plugins(GaussianSplattingPlugin) .add_systems(Startup, (spawn_camera_and_light, load_monkey)) - .add_systems(Update, try_convert_loaded_mesh) + .add_systems(Update, (try_convert_loaded_mesh, camera_controls)) .run(); } @@ -45,7 +45,7 @@ fn spawn_camera_and_light(mut commands: Commands) { warmup: true, }, Camera3d::default(), - Transform::from_translation(Vec3::new(0.0, 5.0, 2.0)).looking_at(Vec3::ZERO, Vec3::Y), + Transform::from_translation(Vec3::new(0.0, 1.0, 8.0)).looking_at(Vec3::ZERO, Vec3::Y), )); commands.spawn(( DirectionalLight::default(), @@ -239,7 +239,7 @@ fn convert_mesh_to_gaussians_separated(mesh: &Mesh, transform: Transform) -> (Ve // Shrink by 25% total (15% + 10% additional) for better visual separation let edge1_len = edge1.length(); let edge2_len = edge2.length(); - let scale_factor = 0.65; // 25% smaller total + let scale_factor = 0.55; // 25% smaller total let scale = Vec3::new(edge1_len * 0.5 * scale_factor, edge2_len * 0.5 * scale_factor, FACE_SCALE); faces.push(gaussian_from_transform( @@ -284,7 +284,7 @@ fn convert_mesh_to_gaussians_separated(mesh: &Mesh, transform: Transform) -> (Ve // Scale: long along edge (X), thin in other directions // Reduce edge length by 5x for better proportions - let scale = Vec3::new(edge_length * 0.2, EDGE_SCALE, EDGE_SCALE); + let scale = Vec3::new(edge_length * 0.14, EDGE_SCALE, EDGE_SCALE); edges.push(gaussian_from_transform( transform.transform_point(mid), @@ -392,7 +392,7 @@ fn normal_to_rgb(n: Vec3) -> [f32; 3] { let base = (normalized * 0.5) + Vec3::splat(0.5); // Apply stronger contrast enhancement: make colors much more saturated - let contrast_factor = 2.2; // Increased from 1.5 to 2.2 + let contrast_factor = 100.0; // Increased from 2.2 to 3.0 for maximum contrast let enhanced = ((base - Vec3::splat(0.5)) * contrast_factor) + Vec3::splat(0.5); // Clamp to valid range @@ -432,4 +432,49 @@ fn gaussian_from_transform( } g +} + +// Camera controls: orbit around origin with arrow keys +fn camera_controls( + mut camera_query: Query<&mut Transform, With>, + input: Res>, + time: Res