Render asteroid belts as instanced jittered icospheres

Replace the flat-grey torus asteroid belt with a swarm of 60 individual
rocks per belt — one shared jittered-icosphere mesh asset scaled, rotated,
and palette-tinted per instance (5-entry greyscale/brown material set).

- New GeneratedAsteroid data (position/scale/rotation/material_index)
  generated deterministically alongside the belt, preserving the
  same-seed-same-galaxy invariant
- New build_asteroid_mesh + asteroid_material_palette helpers
- ContentAssets drops belt_material, adds asteroid_mesh + asteroid_materials
- spawn_system_contents signature simplified (no more &mut Assets<Mesh>)
- Belt entity is now a no-mesh parent (Mineable/Identifiable/...) with
  rock meshes as children — sets the stage for per-asteroid mining
- Bundles the POI component/marker scaffolding (poi.rs) and per-system
  contents generator (contents.rs: planets, stations, anomalies, stargates,
  gas clouds) that the asteroid rendering sits on top of
- Tests: asteroid_generation_is_deterministic,
  asteroid_positions_stay_inside_annulus
This commit is contained in:
2026-06-06 17:57:05 -04:00
parent ef07880ca3
commit 4240c2b2ef
3 changed files with 1910 additions and 43 deletions

View File

@@ -6,10 +6,14 @@
//! live in [`super::ui`].
mod axes;
mod contents;
mod params;
mod poi;
mod selection;
mod ui;
pub use poi::*;
use bevy::prelude::*;
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
@@ -17,6 +21,7 @@ use rand::{Rng, SeedableRng};
use crate::camera::apply_orbit_reset;
use crate::state::AppState;
pub use contents::{SystemContents, SystemContext, SystemSummary};
pub use params::{GalaxyParams, SelectedStar};
use params::{CORE_COUNT, NEAREST_NEIGHBOR_CONNECTIONS, SPACING_ATTEMPTS};
@@ -26,8 +31,14 @@ impl Plugin for GalaxyCreationPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GalaxyParams>()
.init_resource::<SelectedStar>()
.add_systems(OnEnter(AppState::GalaxyCreation), (setup_galaxy_scene, ui::setup_galaxy_ui))
.add_systems(OnExit(AppState::GalaxyCreation), (despawn_galaxy_creation, reset_selection))
.add_systems(
OnEnter(AppState::GalaxyCreation),
(setup_galaxy_scene, ui::setup_galaxy_ui),
)
.add_systems(
OnExit(AppState::GalaxyCreation),
(despawn_galaxy_creation, reset_selection),
)
.add_systems(
Update,
(
@@ -59,15 +70,35 @@ pub struct GalaxyCreationSpawned;
#[derive(Component)]
pub struct GalaxyScene;
/// A star system in the procedural galaxy.
/// A star system in the procedural galaxy. Acts as a parent entity for the
/// system's [`Star`] (visual marker) and all POI children (planets, belts,
/// stations, anomalies, stargates, gas clouds).
#[derive(Component, Debug)]
pub struct StarSystem {
pub id: String,
pub name: String,
pub faction: &'static str,
pub security: f32,
/// Cached POI count for quick display in the info panel without walking
/// the child hierarchy. Updated whenever [`contents::SystemContents`] is
/// regenerated.
pub poi_count: usize,
}
/// The anchor celestial of a star system — its primary star. Distinct from
/// POIs (which are destinations within a system): a star is the body the
/// system is named after and the parent that planets/moons/belts orbit.
///
/// Requires components that describe its physical nature; mass-locks nearby
/// warp drives, emits light (used to compute habitable zones for orbiting
/// planets).
#[derive(Component, Debug, Clone, Copy, Reflect)]
#[reflect(Component)]
#[require(Massive, Luminosity, MassLock, BoundingVolume, Identifiable)]
pub struct Star;
// POI components, markers, and events live in [`poi`] (re-exported as `pub use`).
// ── Faction palette (matches docs GalaxyScene) ──────────────────────────────
const FACTIONS: &[(&str, [f32; 3])] = &[
@@ -86,16 +117,70 @@ fn setup_galaxy_scene(
mut materials: ResMut<Assets<StandardMaterial>>,
params: Res<GalaxyParams>,
) {
let systems = generate_galaxy(&params);
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
let (systems, contents, connections) = generate_galaxy(&params);
spawn_galaxy_scene(
&mut commands,
&mut meshes,
&mut materials,
&systems,
&contents,
&connections,
);
}
/// Generate the full galaxy layout (pure data — no Bevy types).
///
/// Faithful port of the TS reference in
/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
/// `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`,
/// extended with per-system POI generation (planets, belts, stations,
/// anomalies, gas clouds, stargates).
///
/// Returns `(systems, contents_per_system, connections)`. The contents are
/// returned in parallel with the systems (same index) so the spawner can
/// attach them as children of each [`StarSystem`] entity.
fn generate_galaxy(
params: &GalaxyParams,
) -> (
Vec<GeneratedSystem>,
Vec<SystemContents>,
Vec<(usize, usize)>,
) {
let mut rng = StdRng::seed_from_u64(params.seed);
let systems = generate_system_positions(params, &mut rng);
let connections = build_connections(&systems);
// Per-system contents (planets, belts, stations, anomalies, gas clouds).
let mut contents: Vec<SystemContents> = systems
.iter()
.map(|sys| {
let ctx = SystemContext {
id: &sys.id,
name: &sys.name,
faction: sys.faction,
security: sys.security,
is_core: sys.is_core,
};
contents::generate_system_contents(&mut rng, &ctx)
})
.collect();
// Stargates — paired across each connection.
let summaries: Vec<SystemSummary> = systems
.iter()
.map(|sys| SystemSummary {
id: sys.id.clone(),
name: sys.name.clone(),
})
.collect();
contents::generate_stargates(&summaries, &mut contents, &connections);
(systems, contents, connections)
}
/// Position-only galaxy generation. Pure faithful port of the TS reference
/// in `apps/docs/src/prototypes/r3f/galaxy/GalaxyScene.tsx::generateGalaxy`.
/// Split out so [`generate_galaxy`] can compose it with POI generation.
fn generate_system_positions(params: &GalaxyParams, rng: &mut StdRng) -> Vec<GeneratedSystem> {
let mut systems: Vec<GeneratedSystem> = Vec::with_capacity(params.count);
// Spacing scales with density: smaller for tight cores, larger spread overall.
@@ -129,8 +214,16 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
} else {
rng.gen::<f32>().powf(0.62) * params.size
};
let arm_count = if vertical { vertical_arms.max(1) } else { horizontal_arms };
let arm_twist = if vertical { params.vertical_twist } else { params.twist };
let arm_count = if vertical {
vertical_arms.max(1)
} else {
horizontal_arms
};
let arm_twist = if vertical {
params.vertical_twist
} else {
params.twist
};
let angle = if core {
rng.gen::<f32>() * std::f32::consts::TAU
} else {
@@ -147,7 +240,11 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
let vy = angle.sin() * r * 0.72 + (rng.gen::<f32>() - 0.5) * 12.0;
(vx, vy, vz)
} else {
(angle.cos() * r, (rng.gen::<f32>() - 0.5) * 20.0, angle.sin() * r)
(
angle.cos() * r,
(rng.gen::<f32>() - 0.5) * 20.0,
angle.sin() * r,
)
};
let candidate = Vec3::new(x, y, z);
final_radius = r;
@@ -170,8 +267,7 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
}
}
let security =
((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
let security = ((1.0 - (final_radius / params.size) * 2.0) * 100.0).round() / 100.0;
let name = if core {
format!("COR-{}", 100 + index)
} else {
@@ -186,6 +282,7 @@ fn generate_galaxy(params: &GalaxyParams) -> Vec<GeneratedSystem> {
faction_index,
color,
security,
is_core: core,
});
}
@@ -200,9 +297,12 @@ struct GeneratedSystem {
faction_index: usize,
/// Per-system tint; currently the renderer uses per-faction materials indexed
/// by `faction_index`, but this is kept for future per-system variation.
#[allow(dead_code)]
color: [f32; 3],
security: f32,
/// True for the [`CORE_COUNT`] Concord systems clustered near the origin.
/// Used by [`contents::generate_system_contents`] to bias planet / station
/// counts toward core systems.
is_core: bool,
}
fn spawn_galaxy_scene(
@@ -210,10 +310,19 @@ fn spawn_galaxy_scene(
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
systems: &[GeneratedSystem],
contents: &[SystemContents],
connections: &[(usize, usize)],
) {
// Shared meshes (Bevy 0.16 required-components model — no bundle needed).
let star_mesh = meshes.add(Sphere::new(1.5).mesh().ico(3).unwrap());
let core_star_mesh = meshes.add(Sphere::new(3.0).mesh().ico(4).unwrap());
debug_assert_eq!(
systems.len(),
contents.len(),
"systems and contents must be parallel arrays of the same length"
);
// Shared star visuals (Bevy 0.16 required-components model — no bundle needed).
// Unit spheres, scaled per-faction at the spawn site.
let star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(3).unwrap());
let core_star_mesh = meshes.add(Sphere::new(1.0).mesh().ico(4).unwrap());
let faction_materials: Vec<Handle<StandardMaterial>> = FACTIONS
.iter()
@@ -233,45 +342,89 @@ fn spawn_galaxy_scene(
..default()
});
let connections = build_connections(systems);
// Cached POI meshes/materials (one per type — entities scale via Transform).
let content_assets = contents::ContentAssets::new(meshes, materials);
// Parent group so all galaxy contents despawn together.
commands
.spawn((
Transform::default(),
GalaxyScene,
GalaxyCreationSpawned,
))
.spawn((Transform::default(), GalaxyScene, GalaxyCreationSpawned))
.with_children(|parent| {
// XYZ reference axes through the origin.
axes::spawn_axes(parent, meshes, materials);
// Star systems.
for sys in systems {
let (mesh, material) = if sys.faction_index == 0 && sys.position.length() < 40.0
{
// Star systems — each is a parent entity that owns its Star + POI children.
for (sys, sys_contents) in systems.iter().zip(contents.iter()) {
let is_core = sys.faction_index == 0 && sys.is_core;
let (mesh, material, scale) = if is_core {
// Visual differentiation for the 7 Concord core systems.
(core_star_mesh.clone(), faction_materials[0].clone())
(core_star_mesh.clone(), faction_materials[0].clone(), 1.1)
} else {
(star_mesh.clone(), faction_materials[sys.faction_index].clone())
(
star_mesh.clone(),
faction_materials[sys.faction_index].clone(),
1.0,
)
};
parent.spawn((
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::from_translation(sys.position),
StarSystem {
id: sys.id.clone(),
name: sys.name.clone(),
faction: sys.faction,
security: sys.security,
},
));
let poi_count = sys_contents.total();
parent
.spawn((
Transform::from_translation(sys.position),
StarSystem {
id: sys.id.clone(),
name: sys.name.clone(),
faction: sys.faction,
security: sys.security,
poi_count,
},
))
.with_children(|sys_parent| {
// The anchor celestial — system's primary star.
let star_entity = sys_parent
.spawn((
Star,
Massive {
mass: if sys.is_core { 10000.0 } else { 5000.0 },
},
Luminosity {
value: if sys.is_core { 100.0 } else { 50.0 },
},
MassLock { radius: 5.0 },
BoundingVolume { radius: 1.5 },
Identifiable {
id: format!("{}-star", sys.id),
display_name: sys.name.clone(),
classification: Classification::Celestial,
},
Mesh3d(mesh),
MeshMaterial3d(material),
Transform::default().with_scale(Vec3::splat(scale)),
))
.id();
// POI children (planets, belts, stations, anomalies, stargates, gas).
let ctx = SystemContext {
id: &sys.id,
name: &sys.name,
faction: sys.faction,
security: sys.security,
is_core: sys.is_core,
};
contents::spawn_system_contents(
sys_parent,
&ctx,
sys_contents,
star_entity,
&content_assets,
);
});
}
// Connections rendered as thin cylinders between linked systems.
for (a, b) in connections {
let from = systems[a].position;
let to = systems[b].position;
let from = systems[*a].position;
let to = systems[*b].position;
let delta = to - from;
let length = delta.length();
if length < 0.01 {
@@ -371,6 +524,13 @@ fn regenerate_galaxy_on_param_change(
commands.entity(entity).despawn();
}
let systems = generate_galaxy(&params);
spawn_galaxy_scene(&mut commands, &mut meshes, &mut materials, &systems);
let (systems, contents, connections) = generate_galaxy(&params);
spawn_galaxy_scene(
&mut commands,
&mut meshes,
&mut materials,
&systems,
&contents,
&connections,
);
}