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.
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);
}
}
}
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);
}
}
#[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
}
}
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