From abdfa9d4e0cf983d45b456c9322f7f22b3d3c7bf Mon Sep 17 00:00:00 2001 From: Dzmitry Malyshau Date: Sat, 21 Mar 2026 12:44:33 -0700 Subject: [PATCH] asteroids: native color tint --- blade-engine/src/lib.rs | 11 ++ blade-render/src/lib.rs | 4 + blade-render/src/raster/mod.rs | 7 +- examples-android/asteroids/asteroids.rs | 21 ++-- examples-android/asteroids/game.rs | 128 +++++++++++------------- examples-android/asteroids/mesh.rs | 101 +++++++++++++++++++ examples/scene/main.rs | 2 + 7 files changed, 196 insertions(+), 78 deletions(-) diff --git a/blade-engine/src/lib.rs b/blade-engine/src/lib.rs index a9f506c9..997df7a5 100644 --- a/blade-engine/src/lib.rs +++ b/blade-engine/src/lib.rs @@ -352,6 +352,7 @@ struct Object { prev_isometry: nalgebra::Isometry3, colliders: Vec, visuals: Vec, + color_tint: [f32; 4], } #[derive(Clone, Debug, PartialEq)] @@ -499,6 +500,7 @@ impl Engine { z: mp.column(2).into(), }, model: visual.model, + color_tint: object.color_tint, }); } object.prev_isometry = isometry; @@ -1331,6 +1333,7 @@ impl Engine { self.extra_debug_lines.extend_from_slice(lines); } + #[cfg_attr(not(target_os = "android"), allow(dead_code))] fn make_particle_camera( camera: &blade_render::Camera, target_size: gpu::Extent, @@ -1758,6 +1761,7 @@ impl Engine { prev_isometry: nalgebra::Isometry3::default(), colliders, visuals, + color_tint: [1.0; 4], }); self.physics.rigid_bodies[rb_handle].user_data = raw_handle as u128; ObjectHandle(raw_handle) @@ -1800,6 +1804,7 @@ impl Engine { prev_isometry: nalgebra::Isometry3::default(), colliders: Vec::new(), visuals: vec![visual], + color_tint: [1.0; 4], }); // Store ObjectHandle index in rigid body user_data for fast lookup in contacts self.physics.rigid_bodies[rb_handle].user_data = raw_handle as u128; @@ -2013,6 +2018,12 @@ impl Engine { body.set_position(transform.into_isometry(), true); } + /// Set a per-object color tint that multiplies the model's material colors. + /// Default is [1, 1, 1, 1] (no tint). + pub fn set_color_tint(&mut self, handle: ObjectHandle, tint: [f32; 4]) { + self.objects[handle.0].color_tint = tint; + } + pub fn set_joint_motor( &mut self, handle: JointHandle, diff --git a/blade-render/src/lib.rs b/blade-render/src/lib.rs index a13e96b6..26d02640 100644 --- a/blade-render/src/lib.rs +++ b/blade-render/src/lib.rs @@ -82,6 +82,9 @@ pub struct Object { pub model: blade_asset::Handle, pub transform: blade_graphics::Transform, pub prev_transform: blade_graphics::Transform, + /// Per-object color tint multiplied with the material's base_color_factor. + /// Default: [1.0, 1.0, 1.0, 1.0] (no tint). + pub color_tint: [f32; 4], } #[cfg(not(any(gles, target_arch = "wasm32")))] @@ -91,6 +94,7 @@ impl From> for Object { model, transform: blade_graphics::IDENTITY_TRANSFORM, prev_transform: blade_graphics::IDENTITY_TRANSFORM, + color_tint: [1.0; 4], } } } diff --git a/blade-render/src/raster/mod.rs b/blade-render/src/raster/mod.rs index 1431ca6e..05cb3a3a 100644 --- a/blade-render/src/raster/mod.rs +++ b/blade-render/src/raster/mod.rs @@ -340,7 +340,12 @@ impl Rasterizer { draw_params: RasterDrawParams { model: world_transform.to_cols_array(), normal_quat: normal_quat.to_array(), - base_color_factor: material.base_color_factor, + base_color_factor: [ + material.base_color_factor[0] * object.color_tint[0], + material.base_color_factor[1] * object.color_tint[1], + material.base_color_factor[2] * object.color_tint[2], + material.base_color_factor[3] * object.color_tint[3], + ], material: [normal_scale, 0.0, 0.0, 0.0], }, vertices: model.vertex_buffer.at(0), diff --git a/examples-android/asteroids/asteroids.rs b/examples-android/asteroids/asteroids.rs index 435f3241..869c45f4 100644 --- a/examples-android/asteroids/asteroids.rs +++ b/examples-android/asteroids/asteroids.rs @@ -21,8 +21,7 @@ use game::{AsteroidField, GameState, LASER_DAMAGE, SIZE_RADII}; // --- XR Input --- -const LASER_LENGTH: f32 = 200.0; -const LASER_HIT_RADIUS: f32 = 1.5; +const LASER_LENGTH: f32 = 60.0; const LASER_BEAM_RADIUS: f32 = 0.015; struct HandState { @@ -240,21 +239,25 @@ impl XrInput { let to_asteroid = [pos.x - origin[0], pos.y - origin[1], pos.z - origin[2]]; let along = to_asteroid[0] * dir[0] + to_asteroid[1] * dir[1] + to_asteroid[2] * dir[2]; - if along < 0.0 || along > closest_t { - continue; - } - let hit_radius = SIZE_RADII[asteroid.size_class] * LASER_HIT_RADIUS; + let hit_radius = SIZE_RADII[asteroid.size_class]; let perp_sq = (to_asteroid[0] - dir[0] * along).powi(2) + (to_asteroid[1] - dir[1] * along).powi(2) + (to_asteroid[2] - dir[2] * along).powi(2); - if perp_sq < hit_radius * hit_radius { - closest_t = along; - closest_idx = Some(idx); + if perp_sq >= hit_radius * hit_radius { + continue; + } + // Ray enters the sphere at t = along - sqrt(r² - perp²) + let t_enter = along - (hit_radius * hit_radius - perp_sq).sqrt(); + if t_enter < 0.0 || t_enter > closest_t { + continue; } + closest_t = t_enter; + closest_idx = Some(idx); } if let Some(idx) = closest_idx { asteroid_field.asteroids[idx].health -= LASER_DAMAGE; + // Hit point is where the ray enters the sphere (near side) let hit_point = [ origin[0] + dir[0] * closest_t, origin[1] + dir[1] * closest_t, diff --git a/examples-android/asteroids/game.rs b/examples-android/asteroids/game.rs index 1cd06526..117e288e 100644 --- a/examples-android/asteroids/game.rs +++ b/examples-android/asteroids/game.rs @@ -17,7 +17,6 @@ const SPEED_VARIATION: f32 = 0.4; const MAX_HEALTH: f32 = 1.0; pub const LASER_DAMAGE: f32 = 0.016; const HEALTH_REGEN: f32 = 0.2; -const DAMAGE_STAGES: usize = 5; // --- Asteroid sizes --- pub const SIZE_RADII: [f32; 3] = [0.5, 1.0, 2.0]; @@ -35,16 +34,14 @@ const COMET_NUCLEUS_RADIUS: f32 = 3.0; pub struct Asteroid { pub object_handle: blade_engine::ObjectHandle, - variant: usize, pub size_class: usize, pub health: f32, - color_stage: usize, } pub struct AsteroidField { pub asteroids: Vec, - /// model_handles[size_class][variant][color_stage] - model_handles: Vec>>>, + /// model_handles[size_class][variant] + model_handles: Vec>>, flow_dir: [f32; 3], next_seed: u32, } @@ -52,16 +49,16 @@ pub struct AsteroidField { impl AsteroidField { pub fn new(engine: &mut blade_engine::Engine) -> Self { let variant_params: &[(f32, [f32; 3], [f32; 4])] = &[ - (0.35, [1.0, 1.0, 1.0], [0.45, 0.40, 0.35, 1.0]), - (0.50, [1.4, 0.7, 1.0], [0.50, 0.45, 0.38, 1.0]), - (0.45, [1.0, 0.5, 1.2], [0.40, 0.38, 0.35, 1.0]), - (0.60, [1.0, 1.0, 1.0], [0.35, 0.32, 0.30, 1.0]), - (0.30, [0.8, 1.3, 0.9], [0.55, 0.50, 0.42, 1.0]), - (0.55, [1.3, 0.8, 1.3], [0.42, 0.38, 0.32, 1.0]), - (0.40, [1.1, 1.1, 0.6], [0.48, 0.44, 0.38, 1.0]), - (0.65, [0.9, 0.9, 1.4], [0.38, 0.35, 0.30, 1.0]), + // (roughness, axis_scales, base_color) + (0.35, [1.0, 1.0, 1.0], [0.50, 0.45, 0.40, 1.0]), // warm gray + (0.50, [1.4, 0.7, 1.0], [0.60, 0.35, 0.25, 1.0]), // rusty orange + (0.45, [1.0, 0.5, 1.2], [0.30, 0.35, 0.45, 1.0]), // blue-gray + (0.60, [1.0, 1.0, 1.0], [0.25, 0.22, 0.20, 1.0]), // dark charcoal + (0.30, [0.8, 1.3, 0.9], [0.55, 0.55, 0.40, 1.0]), // sandy yellow + (0.55, [1.3, 0.8, 1.3], [0.45, 0.30, 0.35, 1.0]), // reddish brown + (0.40, [1.1, 1.1, 0.6], [0.35, 0.40, 0.30, 1.0]), // olive green + (0.65, [0.9, 0.9, 1.4], [0.55, 0.50, 0.55, 1.0]), // pale purple-gray ]; - let hot_color: [f32; 4] = [0.9, 0.15, 0.05, 1.0]; let mut model_handles = Vec::new(); for (sc, &radius) in SIZE_RADII.iter().enumerate() { let mut variants = Vec::new(); @@ -69,27 +66,16 @@ impl AsteroidField { let seed = (sc * 100 + i * 7 + 42) as u32; let (vertices, indices) = mesh::generate_asteroid_mesh(seed, radius, roughness, 2, axis_scales); - let mut stages = Vec::new(); - for stage in 0..DAMAGE_STAGES { - let t = stage as f32 / (DAMAGE_STAGES - 1) as f32; - let staged_color = [ - color[0] + (hot_color[0] - color[0]) * t, - color[1] + (hot_color[1] - color[1]) * t, - color[2] + (hot_color[2] - color[2]) * t, - 1.0, - ]; - let handle = engine.create_model( - &format!("asteroid_s{sc}_v{i}_t{stage}"), - vec![blade_render::ProceduralGeometry { - name: format!("asteroid_s{sc}_v{i}_t{stage}"), - vertices: vertices.clone(), - indices: indices.clone(), - base_color_factor: staged_color, - }], - ); - stages.push(handle); - } - variants.push(stages); + let handle = engine.create_model( + &format!("asteroid_s{sc}_v{i}"), + vec![blade_render::ProceduralGeometry { + name: format!("asteroid_s{sc}_v{i}"), + vertices, + indices, + base_color_factor: color, + }], + ); + variants.push(handle); } model_handles.push(variants); } @@ -222,7 +208,6 @@ impl AsteroidField { velocity: [f32; 3], angular_velocity: [f32; 3], ) { - let color_stage = 0; let axis = mesh::normalize(angular_velocity); let angle = mesh::hash_noise(self.next_seed.wrapping_add(999), 80.0, 81.0, 82.0) * std::f32::consts::TAU; @@ -246,7 +231,7 @@ impl AsteroidField { let handle = engine.add_object_with_model( "asteroid", - self.model_handles[size_class][variant][color_stage], + self.model_handles[size_class][variant], transform, blade_engine::DynamicInput::Full, ); @@ -269,10 +254,8 @@ impl AsteroidField { self.asteroids.push(Asteroid { object_handle: handle, - variant, size_class, health: MAX_HEALTH, - color_stage, }); } @@ -327,26 +310,12 @@ impl AsteroidField { continue; } - // Update color stage based on damage + // Tint toward red based on damage let damage = 1.0 - a.health / MAX_HEALTH; - let new_stage = (damage * (DAMAGE_STAGES - 1) as f32) as usize; - let new_stage = new_stage.min(DAMAGE_STAGES - 1); - if new_stage != a.color_stage { - let transform = engine - .get_object_transform(a.object_handle, blade_engine::Prediction::LastKnown); - let (linvel, angvel) = engine.get_velocity(a.object_handle); - engine.remove_object(a.object_handle); - let new_handle = engine.add_object_with_model( - "asteroid", - self.model_handles[a.size_class][a.variant][new_stage], - transform, - blade_engine::DynamicInput::Full, - ); - engine.add_ball_collider(new_handle, SIZE_RADII[a.size_class], 0.5); - engine.set_velocity(new_handle, linvel, angvel); - a.object_handle = new_handle; - a.color_stage = new_stage; - } + engine.set_color_tint( + a.object_handle, + [1.0, 1.0 - damage * 0.7, 1.0 - damage * 0.8, 1.0], + ); i += 1; } } @@ -539,22 +508,45 @@ pub fn setup_game(engine: &mut blade_engine::Engine) -> GameState { space_sky: true, }); - // Planet + // Gas giant with rings in the distance. + // The asteroids around us are conceptually part of the outermost ring. { - let planet_radius = 30.0; - let planet_model = mesh::generate_planet_model(engine, planet_radius); - let planet_transform = blade_engine::Transform { - position: mint::Vector3 { - x: 30.0, - y: -20.0, - z: -80.0, + let planet_radius = 120.0; + let planet_pos = mint::Vector3 { + x: 200.0, + y: -150.0, + z: -600.0, + }; + + // Tilt ~25° so the rings are clearly visible + let tilt_angle: f32 = 0.44; + let half = tilt_angle * 0.5; + let ring_orient = mint::Quaternion { + s: half.cos(), + v: mint::Vector3 { + x: half.sin(), + y: 0.0, + z: 0.0, }, - ..Default::default() }; + + let (planet_model, ring_model) = mesh::generate_ringed_planet(engine, planet_radius); engine.add_object_with_model( "planet", planet_model, - planet_transform, + blade_engine::Transform { + position: planet_pos, + ..Default::default() + }, + blade_engine::DynamicInput::Empty, + ); + engine.add_object_with_model( + "planet_rings", + ring_model, + blade_engine::Transform { + position: planet_pos, + orientation: ring_orient, + }, blade_engine::DynamicInput::Empty, ); } diff --git a/examples-android/asteroids/mesh.rs b/examples-android/asteroids/mesh.rs index a8217b4c..d626bdec 100644 --- a/examples-android/asteroids/mesh.rs +++ b/examples-android/asteroids/mesh.rs @@ -318,6 +318,107 @@ pub fn generate_planet_model( engine.create_model("planet", geometries) } +/// Generate a flat annular ring (disc with a hole) in the XZ plane. +/// Returns vertices and indices for a single ring band. +fn generate_ring_band( + inner_radius: f32, + outer_radius: f32, + segments: usize, + color: [f32; 4], +) -> blade_render::ProceduralGeometry { + let up = [0.0f32, 1.0, 0.0]; + let en = encode_normal(up); + let mut vertices = Vec::with_capacity(segments * 2); + let mut indices = Vec::with_capacity(segments * 6); + + for i in 0..=segments { + let angle = (i as f32 / segments as f32) * std::f32::consts::TAU; + let c = angle.cos(); + let s = angle.sin(); + // Inner vertex + vertices.push(blade_render::Vertex { + position: [c * inner_radius, 0.0, s * inner_radius], + bitangent_sign: 1.0, + tex_coords: [0.0, 0.0], + normal: en, + tangent: encode_normal([1.0, 0.0, 0.0]), + }); + // Outer vertex + vertices.push(blade_render::Vertex { + position: [c * outer_radius, 0.0, s * outer_radius], + bitangent_sign: 1.0, + tex_coords: [0.0, 0.0], + normal: en, + tangent: encode_normal([1.0, 0.0, 0.0]), + }); + } + + for i in 0..segments { + let base = (i * 2) as u32; + // Two triangles per quad (inner-outer pair) + indices.extend_from_slice(&[base, base + 1, base + 2, base + 1, base + 3, base + 2]); + } + + blade_render::ProceduralGeometry { + name: "ring_band".to_string(), + vertices, + indices, + base_color_factor: color, + } +} + +/// Generate a planet with rings (Saturn-like gas giant). +pub fn generate_ringed_planet( + engine: &mut blade_engine::Engine, + planet_radius: f32, +) -> ( + blade_asset::Handle, + blade_asset::Handle, +) { + let planet_model = generate_planet_model(engine, planet_radius); + + // Generate ring system as a separate model (so it can be placed at the same position) + let segments = 64; + let gap = planet_radius * 0.05; // small gap between bands + + // Ring bands with gaps — Saturn-inspired + let ring_bands = vec![ + // C ring (inner, dim) + generate_ring_band( + planet_radius * 1.2, + planet_radius * 1.5, + segments, + [0.35, 0.30, 0.25, 1.0], + ), + // B ring (main, brightest) + generate_ring_band( + planet_radius * 1.5 + gap, + planet_radius * 2.0, + segments, + [0.55, 0.48, 0.38, 1.0], + ), + // Cassini division (gap) + // A ring (outer, medium) + generate_ring_band( + planet_radius * 2.1, + planet_radius * 2.5, + segments, + [0.45, 0.40, 0.32, 1.0], + ), + // F ring (thin outer) + generate_ring_band( + planet_radius * 2.6, + planet_radius * 2.7, + segments, + [0.30, 0.28, 0.25, 1.0], + ), + ]; + + let ring_model = engine.create_model("planet_rings", ring_bands); + + (planet_model, ring_model) +} + /// Generate a comet model (nucleus only — tail is a particle trail). pub fn generate_comet_model( seed: u32, diff --git a/examples/scene/main.rs b/examples/scene/main.rs index 31de0fb2..eff3b56c 100644 --- a/examples/scene/main.rs +++ b/examples/scene/main.rs @@ -341,6 +341,7 @@ impl Example { model, transform: config_object.transform, prev_transform: config_object.transform, + color_tint: [1.0; 4], }); self.object_extras.push(ObjectExtra { path: PathBuf::from(config_object.path), @@ -787,6 +788,7 @@ impl Example { model, transform, prev_transform: transform, + color_tint: [1.0; 4], }); self.object_extras.push(ObjectExtra { path: file_path.to_owned(),