Last active
December 13, 2025 03:12
-
-
Save Kielan/950923c538785c3ef2b07242bbea977f to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use bevy::prelude::*; | |
| use bevy::render::mesh::{Indices, PrimitiveTopology}; | |
| fn main() { | |
| App::new() | |
| .add_plugins(DefaultPlugins) | |
| .add_startup_system(setup) | |
| .add_system(update_lod) | |
| .run(); | |
| } | |
| #[derive(Resource)] | |
| struct LodSettings { | |
| distances: Vec<(f32, u32)>, // (distance, subdivisions) | |
| } | |
| #[derive(Component)] | |
| struct Planet; | |
| #[derive(Component)] | |
| struct LodLevel(u32); | |
| #[derive(Component)] | |
| struct FocusPoint; | |
| /// ───────────────────────────────────────────── | |
| /// Setup | |
| /// ───────────────────────────────────────────── | |
| fn setup( | |
| mut commands: Commands, | |
| mut meshes: ResMut<Assets<Mesh>>, | |
| mut materials: ResMut<Assets<StandardMaterial>>, | |
| ) { | |
| commands.insert_resource(LodSettings { | |
| distances: vec![ | |
| (200.0, 0), | |
| (100.0, 1), | |
| (50.0, 2), | |
| (20.0, 3), | |
| ], | |
| }); | |
| // Camera (pod-scroll compatible) | |
| commands.spawn(Camera3dBundle { | |
| transform: Transform::from_xyz(0.0, 0.0, 150.0), | |
| ..default() | |
| }) | |
| .insert(FocusPoint); | |
| // Light | |
| commands.spawn(PointLightBundle { | |
| transform: Transform::from_xyz(100.0, 100.0, 100.0), | |
| ..default() | |
| }); | |
| // Planet | |
| let mesh = generate_hex_sphere(1, 50.0); | |
| let mesh_handle = meshes.add(mesh); | |
| let material = materials.add(StandardMaterial { | |
| base_color: Color::rgb(0.4, 0.6, 1.0), | |
| perceptual_roughness: 0.8, | |
| ..default() | |
| }); | |
| commands.spawn(( | |
| PbrBundle { | |
| mesh: mesh_handle, | |
| material, | |
| transform: Transform::default(), | |
| ..default() | |
| }, | |
| Planet, | |
| LodLevel(1), | |
| )); | |
| } | |
| fn generate_hex_sphere(subdivisions: u32, radius: f32) -> Mesh { | |
| // Start from an icosahedron | |
| let mut vertices = icosahedron_vertices(); | |
| let mut indices = icosahedron_indices(); | |
| for _ in 0..subdivisions { | |
| let (v, i) = subdivide(&vertices, &indices); | |
| vertices = v; | |
| indices = i; | |
| } | |
| // Project to sphere | |
| for v in &mut vertices { | |
| *v = v.normalize() * radius; | |
| } | |
| // Build mesh | |
| let mut mesh = Mesh::new(PrimitiveTopology::TriangleList); | |
| mesh.insert_attribute( | |
| Mesh::ATTRIBUTE_POSITION, | |
| vertices.iter().map(|v| [v.x, v.y, v.z]).collect::<Vec<_>>(), | |
| ); | |
| mesh.set_indices(Some(Indices::U32(indices))); | |
| mesh.compute_normals(); | |
| mesh | |
| } | |
| fn icosahedron_vertices() -> Vec<Vec3> { | |
| let t = (1.0 + 5.0_f32.sqrt()) / 2.0; | |
| vec![ | |
| Vec3::new(-1.0, t, 0.0), | |
| Vec3::new( 1.0, t, 0.0), | |
| Vec3::new(-1.0, -t, 0.0), | |
| Vec3::new( 1.0, -t, 0.0), | |
| Vec3::new( 0.0, -1.0, t), | |
| Vec3::new( 0.0, 1.0, t), | |
| Vec3::new( 0.0, -1.0, -t), | |
| Vec3::new( 0.0, 1.0, -t), | |
| Vec3::new( t, 0.0, -1.0), | |
| Vec3::new( t, 0.0, 1.0), | |
| Vec3::new(-t, 0.0, -1.0), | |
| Vec3::new(-t, 0.0, 1.0), | |
| ] | |
| } | |
| fn icosahedron_indices() -> Vec<u32> { | |
| vec![ | |
| 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 subdivide(vertices: &Vec<Vec3>, indices: &Vec<u32>) -> (Vec<Vec3>, Vec<u32>) { | |
| let mut new_vertices = vertices.clone(); | |
| let mut new_indices = Vec::new(); | |
| let mut midpoint_cache = std::collections::HashMap::new(); | |
| let mut midpoint = |a: u32, b: u32| -> u32 { | |
| let key = if a < b { (a, b) } else { (b, a) }; | |
| if let Some(&i) = midpoint_cache.get(&key) { | |
| return i; | |
| } | |
| let v = (vertices[a as usize] + vertices[b as usize]) * 0.5; | |
| let index = new_vertices.len() as u32; | |
| new_vertices.push(v); | |
| midpoint_cache.insert(key, index); | |
| index | |
| }; | |
| for tri in indices.chunks(3) { | |
| let a = tri[0]; | |
| let b = tri[1]; | |
| let c = tri[2]; | |
| let ab = midpoint(a, b); | |
| let bc = midpoint(b, c); | |
| let ca = midpoint(c, a); | |
| new_indices.extend([ | |
| a, ab, ca, | |
| b, bc, ab, | |
| c, ca, bc, | |
| ab, bc, ca, | |
| ]); | |
| } | |
| (new_vertices, new_indices) | |
| } | |
| fn update_lod( | |
| mut commands: Commands, | |
| camera: Query<&Transform, With<FocusPoint>>, | |
| mut planets: Query<(Entity, &Transform, &mut LodLevel), With<Planet>>, | |
| settings: Res<LodSettings>, | |
| mut meshes: ResMut<Assets<Mesh>>, | |
| ) { | |
| let cam_pos = camera.single().translation; | |
| for (entity, transform, mut lod) in &mut planets { | |
| let distance = cam_pos.distance(transform.translation); | |
| let target = settings | |
| .distances | |
| .iter() | |
| .find(|(d, _)| distance < *d) | |
| .map(|(_, l)| *l) | |
| .unwrap_or(settings.distances.last().unwrap().1); | |
| if lod.0 != target { | |
| lod.0 = target; | |
| let mesh = generate_hex_sphere(target, 50.0); | |
| let handle = meshes.add(mesh); | |
| commands.entity(entity).insert(handle); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use bevy::prelude::*; | |
| #[derive(Resource, Clone)] | |
| pub struct PlanetData { | |
| pub lod_focus: Vec3, | |
| // add radius, noise params, max lod, etc | |
| } | |
| #[derive(Component)] | |
| pub struct Planet; | |
| #[derive(Component)] | |
| pub struct PlanetMeshFace { | |
| pub normal: Vec3, | |
| pub name: &'static str, | |
| } | |
| const PLANET_FACES: [(Vec3, &str); 6] = [ | |
| (Vec3::Y, "Top"), | |
| (-Vec3::Y, "Bot"), | |
| (-Vec3::X, "Left"), | |
| (Vec3::X, "Right"), | |
| (-Vec3::Z, "Back"), | |
| (Vec3::Z, "Front"), | |
| ]; | |
| pub fn spawn_planet( | |
| mut commands: Commands, | |
| ) { | |
| commands | |
| .spawn(( | |
| Planet, | |
| Transform::default(), | |
| GlobalTransform::default(), | |
| )) | |
| .with_children(|parent| { | |
| for (normal, name) in PLANET_FACES { | |
| parent.spawn(( | |
| PlanetMeshFace { normal, name }, | |
| Transform::default(), | |
| GlobalTransform::default(), | |
| )); | |
| } | |
| }); | |
| } | |
| pub fn regenerate_faces_on_planet_data_change( | |
| planet_data: Res<PlanetData>, | |
| mut faces: Query<&PlanetMeshFace>, | |
| ) { | |
| if !planet_data.is_changed() { | |
| return; | |
| } | |
| for face in &mut faces { | |
| regenerate_mesh(face, &planet_data); | |
| } | |
| } | |
| fn regenerate_mesh(face: &PlanetMeshFace, planet_data: &PlanetData) { | |
| // Equivalent of: | |
| // child._regenerate_mesh(planet_data) | |
| // Typical steps: | |
| // - Build quadtree | |
| // - Project cube → sphere | |
| // - LOD based on planet_data.lod_focus | |
| // - Write Mesh | |
| } | |
| #[derive(Component)] | |
| pub struct Player; | |
| pub fn update_lod_focus_from_player( | |
| player: Query<&GlobalTransform, With<Player>>, | |
| mut planet_data: ResMut<PlanetData>, | |
| ) { | |
| if let Ok(player_transform) = player.get_single() { | |
| planet_data.lod_focus = player_transform.translation(); | |
| } | |
| } | |
| pub struct PlanetPlugin; | |
| impl Plugin for PlanetPlugin { | |
| fn build(&self, app: &mut App) { | |
| app | |
| .insert_resource(PlanetData { | |
| lod_focus: Vec3::ZERO, | |
| }) | |
| .add_startup_system(spawn_planet) | |
| .add_system(update_lod_focus_from_player) | |
| .add_system(regenerate_faces_on_planet_data_change); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #[derive(Clone)] | |
| pub struct LodLevel { | |
| pub distance: f32, | |
| pub resolution: u32, | |
| } | |
| pub trait PlanetNoise: Send + Sync { | |
| fn get_noise_3d(&self, p: Vec3) -> f32; | |
| fn is_base_layer(&self) -> bool; | |
| fn amplitude(&self) -> f32; | |
| fn min_height(&self) -> f32; | |
| } | |
| use bevy::prelude::*; | |
| #[derive(Resource)] | |
| pub struct PlanetData { | |
| pub radius: f32, | |
| pub lod_focus: Vec3, | |
| pub max_lod: usize, | |
| pub lod_levels: Vec<LodLevel>, | |
| /// Noise layers | |
| pub planet_noise: Vec<Box<dyn PlanetNoise>>, | |
| pub min_height: f32, | |
| pub max_height: f32, | |
| /// Gradient texture (optional) | |
| pub planet_color: Option<Handle<Image>>, | |
| } | |
| impl Default for PlanetData { | |
| fn default() -> Self { | |
| let lod_levels = vec![ | |
| LodLevel { distance: 500.0, resolution: 2 }, | |
| LodLevel { distance: 50.0, resolution: 2 }, | |
| LodLevel { distance: 25.0, resolution: 3 }, | |
| LodLevel { distance: 10.0, resolution: 4 }, | |
| LodLevel { distance: 1.0, resolution: 10 }, | |
| ]; | |
| Self { | |
| radius: 1.0, | |
| lod_focus: Vec3::ZERO, | |
| max_lod: lod_levels.len() - 1, | |
| lod_levels, | |
| planet_noise: Vec::new(), | |
| min_height: 9999.0, | |
| max_height: 0.0, | |
| planet_color: None, | |
| } | |
| } | |
| } | |
| impl PlanetData { | |
| pub fn point_on_planet(&self, point_on_sphere: Vec3) -> Vec3 { | |
| let mut elevation = 0.0; | |
| let mut base_layer_mask = 0.0; | |
| if !self.planet_noise.is_empty() { | |
| // Base layers | |
| for n in &self.planet_noise { | |
| if n.is_base_layer() { | |
| let mut level_base_elevation = | |
| n.get_noise_3d(point_on_sphere * 100.0); | |
| level_base_elevation = | |
| (level_base_elevation + 1.0) / 2.0 * n.amplitude(); | |
| level_base_elevation = | |
| (level_base_elevation - n.min_height()).max(0.0); | |
| base_layer_mask += level_base_elevation; | |
| } | |
| } | |
| // All layers | |
| for n in &self.planet_noise { | |
| let mut level_elevation = | |
| n.get_noise_3d(point_on_sphere * 100.0); | |
| level_elevation = | |
| (level_elevation + 1.0) / 2.0 * n.amplitude(); | |
| level_elevation = | |
| (level_elevation - n.min_height()).max(0.0) | |
| * base_layer_mask; | |
| elevation += level_elevation; | |
| } | |
| } | |
| point_on_sphere * self.radius * (elevation + 1.0) | |
| } | |
| } | |
| pub fn planet_data_changed( | |
| planet_data: Res<PlanetData>, | |
| ) { | |
| if planet_data.is_changed() { | |
| // Regenerate meshes / LODs | |
| } | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| use bevy::prelude::*; | |
| use std::collections::HashMap; | |
| #[derive(Component)] | |
| pub struct PlanetMeshFace { | |
| pub normal: Vec3, | |
| } | |
| #[derive(Clone)] | |
| pub struct QuadtreeChunk { | |
| pub bounds_pos: Vec3, | |
| pub bounds_size: f32, | |
| pub depth: usize, | |
| pub max_chunk_depth: usize, | |
| pub identifier: String, | |
| pub children: Vec<QuadtreeChunk>, | |
| } | |
| impl QuadtreeChunk { | |
| pub fn new(bounds_pos: Vec3, bounds_size: f32, depth: usize, max_depth: usize) -> Self { | |
| let identifier = format!("{:?}_{:?}_{}", bounds_pos, bounds_size, depth); | |
| Self { | |
| bounds_pos, | |
| bounds_size, | |
| depth, | |
| max_chunk_depth: max_depth, | |
| identifier, | |
| children: Vec::new(), | |
| } | |
| } | |
| pub fn subdivide( | |
| &mut self, | |
| focus_point: Vec3, | |
| face_origin: Vec3, | |
| axis_a: Vec3, | |
| axis_b: Vec3, | |
| planet_data: &PlanetData, | |
| ) { | |
| let half = self.bounds_size * 0.5; | |
| let quarter = self.bounds_size * 0.25; | |
| let offsets = [ | |
| Vec2::new(-quarter, -quarter), | |
| Vec2::new( quarter, -quarter), | |
| Vec2::new(-quarter, quarter), | |
| Vec2::new( quarter, quarter), | |
| ]; | |
| for offset in offsets { | |
| let child_2d = Vec2::new(self.bounds_pos.x, self.bounds_pos.z) + offset; | |
| let center_3d = | |
| face_origin + child_2d.x * axis_a + child_2d.y * axis_b; | |
| let distance = planet_data | |
| .point_on_planet(center_3d.normalize()) | |
| .distance(focus_point); | |
| let next_depth = self.depth + 1; | |
| let should_split = | |
| self.depth < self.max_chunk_depth | |
| && distance <= planet_data.lod_levels[self.depth].distance; | |
| let mut child = QuadtreeChunk::new( | |
| Vec3::new(child_2d.x, 0.0, child_2d.y), | |
| half, | |
| next_depth, | |
| self.max_chunk_depth, | |
| ); | |
| if should_split { | |
| child.subdivide( | |
| focus_point, | |
| face_origin, | |
| axis_a, | |
| axis_b, | |
| planet_data, | |
| ); | |
| } | |
| self.children.push(child); | |
| } | |
| } | |
| } | |
| #[derive(Component, Default)] | |
| pub struct PlanetFaceRuntime { | |
| pub chunks: HashMap<String, Entity>, | |
| pub chunks_current: HashMap<String, bool>, | |
| } | |
| pub fn regenerate_planet_face( | |
| mut commands: Commands, | |
| mut meshes: ResMut<Assets<Mesh>>, | |
| mut materials: ResMut<Assets<StandardMaterial>>, | |
| planet_data: Res<PlanetData>, | |
| mut query: Query<(Entity, &PlanetMeshFace, &mut PlanetFaceRuntime)>, | |
| ) { | |
| if !planet_data.is_changed() { | |
| return; | |
| } | |
| for (entity, face, mut runtime) in &mut query { | |
| runtime.chunks_current.clear(); | |
| let focus_point = planet_data.lod_focus; | |
| let axis_a = Vec3::new(face.normal.y, face.normal.z, face.normal.x).normalize(); | |
| let axis_b = face.normal.cross(axis_a).normalize(); | |
| let mut root = QuadtreeChunk::new( | |
| Vec3::ZERO, | |
| 2.0, | |
| 0, | |
| planet_data.max_lod, | |
| ); | |
| root.subdivide( | |
| focus_point, | |
| face.normal, | |
| axis_a, | |
| axis_b, | |
| &planet_data, | |
| ); | |
| visualize_quadtree( | |
| &mut commands, | |
| &mut meshes, | |
| &mut materials, | |
| entity, | |
| &mut runtime, | |
| &root, | |
| face.normal, | |
| axis_a, | |
| axis_b, | |
| &planet_data, | |
| ); | |
| // Remove unused chunks | |
| let old_chunks: Vec<String> = runtime | |
| .chunks | |
| .keys() | |
| .filter(|id| !runtime.chunks_current.contains_key(*id)) | |
| .cloned() | |
| .collect(); | |
| for id in old_chunks { | |
| if let Some(e) = runtime.chunks.remove(&id) { | |
| commands.entity(e).despawn_recursive(); | |
| } | |
| } | |
| } | |
| } | |
| fn visualize_quadtree( | |
| commands: &mut Commands, | |
| meshes: &mut Assets<Mesh>, | |
| materials: &mut Assets<StandardMaterial>, | |
| parent: Entity, | |
| runtime: &mut PlanetFaceRuntime, | |
| chunk: &QuadtreeChunk, | |
| face_origin: Vec3, | |
| axis_a: Vec3, | |
| axis_b: Vec3, | |
| planet_data: &PlanetData, | |
| ) { | |
| if chunk.children.is_empty() { | |
| runtime.chunks_current.insert(chunk.identifier.clone(), true); | |
| if runtime.chunks.contains_key(&chunk.identifier) { | |
| return; | |
| } | |
| let res = planet_data.lod_levels[chunk.depth - 1].resolution as usize; | |
| let size = chunk.bounds_size; | |
| let offset = chunk.bounds_pos; | |
| let mut positions = Vec::with_capacity(res * res); | |
| let mut normals = vec![Vec3::ZERO; res * res]; | |
| let mut indices = Vec::new(); | |
| for y in 0..res { | |
| for x in 0..res { | |
| let percent = Vec2::new(x as f32, y as f32) / (res as f32 - 1.0); | |
| let local = Vec2::new(offset.x, offset.z) + percent * size; | |
| let plane = face_origin + local.x * axis_a + local.y * axis_b; | |
| let sphere = planet_data.point_on_planet(plane.normalize()); | |
| positions.push(sphere); | |
| if x < res - 1 && y < res - 1 { | |
| let i = (x + y * res) as u32; | |
| indices.extend_from_slice(&[ | |
| i, | |
| i + res as u32, | |
| i + res as u32 + 1, | |
| i, | |
| i + res as u32 + 1, | |
| i + 1, | |
| ]); | |
| } | |
| } | |
| } | |
| // Normals | |
| for tri in indices.chunks_exact(3) { | |
| let a = tri[0] as usize; | |
| let b = tri[1] as usize; | |
| let c = tri[2] as usize; | |
| let n = (positions[b] - positions[a]) | |
| .cross(positions[c] - positions[a]) | |
| .normalize(); | |
| normals[a] += n; | |
| normals[b] += n; | |
| normals[c] += n; | |
| } | |
| for n in &mut normals { | |
| *n = n.normalize(); | |
| } | |
| let mut mesh = Mesh::new(PrimitiveTopology::TriangleList); | |
| mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); | |
| mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals); | |
| mesh.set_indices(Some(Indices::U32(indices))); | |
| let mesh_handle = meshes.add(mesh); | |
| let material = materials.add(StandardMaterial { | |
| base_color_texture: planet_data.planet_color.clone(), | |
| ..default() | |
| }); | |
| let chunk_entity = commands | |
| .spawn(( | |
| PbrBundle { | |
| mesh: mesh_handle, | |
| material, | |
| ..default() | |
| }, | |
| )) | |
| .set_parent(parent) | |
| .id(); | |
| runtime.chunks.insert(chunk.identifier.clone(), chunk_entity); | |
| } | |
| for child in &chunk.children { | |
| visualize_quadtree( | |
| commands, | |
| meshes, | |
| materials, | |
| parent, | |
| runtime, | |
| child, | |
| face_origin, | |
| axis_a, | |
| axis_b, | |
| planet_data, | |
| ); | |
| } | |
| } | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment