Skip to content

Instantly share code, notes, and snippets.

@Kielan
Last active December 13, 2025 03:12
Show Gist options
  • Select an option

  • Save Kielan/950923c538785c3ef2b07242bbea977f to your computer and use it in GitHub Desktop.

Select an option

Save Kielan/950923c538785c3ef2b07242bbea977f to your computer and use it in GitHub Desktop.

Revisions

  1. Kielan revised this gist Dec 13, 2025. 1 changed file with 198 additions and 0 deletions.
    198 changes: 198 additions & 0 deletions hex_planet_lod.rs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,198 @@
    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);
    }
    }
    }
  2. Kielan revised this gist Dec 13, 2025. 1 changed file with 266 additions and 0 deletions.
    266 changes: 266 additions & 0 deletions PlanetMeshFace.rs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,266 @@
    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,
    );
    }
    }


  3. Kielan revised this gist Dec 13, 2025. 1 changed file with 106 additions and 0 deletions.
    106 changes: 106 additions & 0 deletions PlanetData.rs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,106 @@
    #[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
    }
    }

  4. Kielan created this gist Dec 13, 2025.
    94 changes: 94 additions & 0 deletions Planet.rs
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,94 @@
    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);
    }
    }