Car controller for Bevy and Avian3d 0.6.1
3

Configure Feed

Select the types of activity you want to include in your feed.

Squashed commit of the following:

commit 9f5f273d7abb562665810b1c34c706d01e66d585
Author: Matthew Blair <highway900@gmail.com>
Date: Tue May 26 10:33:24 2026 +1000

refactor: clean up unused dependencies and adjust object transforms in examples

commit fbbc632b652e773a858cd192cf7c5a94c7d75b7a
Author: Matthew Blair <highway900@gmail.com>
Date: Tue May 26 09:50:44 2026 +1000

refactor: add explanatory comments for damping constants and reformat terrain generation code for readability

commit 0301ffd4f94e182cc717b64aaf28a717c15fc7b8
Author: Matthew Blair <highway900@gmail.com>
Date: Tue May 26 09:46:35 2026 +1000

feat: implement grid-based terrain generation with dynamic boundary-aware height blending in examples

commit e3067d97627b2016db6b81111a23fa2e4c98a02a
Author: Matthew Blair <highway900@gmail.com>
Date: Tue May 26 08:57:32 2026 +1000

feat: implement procedural terrain generation and update car physics parameters

commit a6d8f2675513881625a0e6758215e10fc3754bcd
Author: Matthew Blair <highway900@gmail.com>
Date: Mon May 25 14:23:34 2026 +1000

update instructions

Matthew Blair (May 26, 2026, 2:24 PM +1000) c2ecff0c 9c00a218

+210 -138
+192 -75
examples/two.rs
··· 1 - use std::{ 2 - f32::consts::PI, 3 - fmt::{Debug, Display}, 4 - }; 1 + use std::fmt::{Debug, Display}; 5 2 6 3 #[cfg(feature = "debug_physics")] 7 4 use avian3d::prelude::PhysicsDebugPlugin; ··· 9 6 use bevy_hotpatching_experiments::prelude::*; 10 7 11 8 use avian3d::{ 12 - math::Scalar, 9 + math::{FRAC_PI_2, Scalar}, 13 10 prelude::{ 14 11 Collider, CollisionLayers, ComputedMass, Forces, PhysicsStepSystems, RayHitData, 15 12 ReadRigidBodyForces, RigidBody, RigidBodyQueryReadOnly, ShapeHits, SpatialQuery, ··· 25 22 prelude::*, 26 23 }; 27 24 use bevy_enhanced_input::prelude::*; 28 - use noisy_bevy::simplex_noise_3d_seeded; 25 + use noisy_bevy::fbm_simplex_3d_seeded; 29 26 use rand::{Rng, SeedableRng}; 30 27 use rand_chacha::ChaCha8Rng; 31 28 ··· 43 40 }; 44 41 45 42 const REST_LENGTH: f32 = 0.85; 46 - const STRENGTH: f32 = 75.0; 47 - const DAMPING: f32 = 5.91; //4.47; 43 + const STRENGTH: f32 = 175.0; 44 + // with a defined mass the damping can be calculated as 2 * sqrt(strength / mass) in practise this doesn't work very well. Maybe my mass is off. 45 + // higher DAMPING will result in crazed physics simulations. 46 + const DAMPING: f32 = 14.0; 48 47 pub const FORWARD_WORLD: Vec3 = Vec3::Z; 48 + const ALIGNMENT_TORQUE_STRENGTH: f32 = 17.0; 49 + const ALIGNMENT_TORQUE_MAX: f32 = 70.0; 50 + const ALIGNMENT_DEAD_ZONE: f32 = 0.01; // radians 51 + const MAX_SPEED: f32 = 85.0; 52 + const MAX_FORWARD_FORCE: f32 = 32.0; // Maximum engine power 53 + const MAX_LATERAL_GRIP: f32 = 28.0; // Lower = driftier, Higher = turns on rails 54 + 55 + /// The number of tiles along each dimension of the ground grid. 56 + pub const GRID_SIZE: usize = 8; 57 + /// The physical size (width and depth) of each grid tile. 58 + pub const TILE_SIZE: f32 = 25.0; 59 + 60 + /// The starting tile index (inclusive) for the terrain region along the X axis. 61 + pub const TERRAIN_START_I: usize = 4; 62 + /// The ending tile index (exclusive) for the terrain region along the X axis. 63 + pub const TERRAIN_END_I: usize = 8; 64 + /// The starting tile index (inclusive) for the terrain region along the Z axis. 65 + pub const TERRAIN_START_J: usize = 4; 66 + /// The ending tile index (exclusive) for the terrain region along the Z axis. 67 + pub const TERRAIN_END_J: usize = 8; 49 68 50 69 #[cfg_attr(feature = "hot", hot)] 51 70 fn main() { ··· 60 79 .add_systems(OnEnter(GameState::A), new_game) 61 80 .add_systems( 62 81 OnEnter(GameState::B), 63 - (setup_common, setup_car, setup_two, setup_instructions), 82 + ( 83 + setup_common, 84 + setup_car, 85 + setup_two, 86 + setup_instructions, 87 + spawn_terrains, 88 + ), 64 89 ) 65 90 .add_systems( 66 91 Update, ··· 120 145 is_grounded: bool, 121 146 use_sphere_cast: bool, 122 147 max_suspension_force: f32, 148 + mass: f32, 123 149 spatial_filter_ground: SpatialQueryFilter, 124 150 } 125 151 ··· 130 156 is_grounded: Default::default(), 131 157 use_sphere_cast: true, 132 158 max_suspension_force: Default::default(), 159 + mass: Default::default(), 133 160 spatial_filter_ground: Default::default(), 134 161 } 135 162 } ··· 139 166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 140 167 write!( 141 168 f, 142 - "\tground: {:?}\n\tis_grounded: {:?}\n\tuse_sphere_cast: {:?}\n\tmax_suspension_force: {:.2}", 143 - self.ground, self.is_grounded, self.use_sphere_cast, self.max_suspension_force 169 + "\tground: {:?}\n\tis_grounded: {:?}\n\tuse_sphere_cast: {:?}\n\tmax_suspension_force: {:.2}\n\tcomputed mass: {:?}", 170 + self.ground, 171 + self.is_grounded, 172 + self.use_sphere_cast, 173 + self.max_suspension_force, 174 + self.mass 144 175 ) 145 176 } 146 177 } ··· 202 233 mut config_store: ResMut<GizmoConfigStore>, 203 234 ) { 204 235 let seed_from_u64 = ChaCha8Rng::seed_from_u64(42); 236 + let seed_from_u64 = seed_from_u64; 205 237 let mut rng = seed_from_u64; 206 238 207 239 let (config, _) = config_store.config_mut::<CarGizmos>(); ··· 237 269 unlit: true, 238 270 ..default() 239 271 })), 240 - Transform::from_xyz(8.0, 0.0, 8.0 + i as f32 + 0.1).with_scale(Vec3::new( 272 + Transform::from_xyz(-60.0, 0.0, 8.0 + i as f32 + 0.1).with_scale(Vec3::new( 241 273 3.0, 242 274 0.2 + (i as f32 * 0.15), 243 275 0.5, ··· 270 302 unlit: true, 271 303 ..default() 272 304 })), 273 - Transform::from_xyz(-18.0, 1.0, 0.0).with_scale(Vec3::new(10.0, 2.0, 10.0)), 305 + Transform::from_xyz(-28.0, 1.0, 0.0).with_scale(Vec3::new(10.0, 2.0, 10.0)), 274 306 NotShadowCaster, 275 307 Collider::cuboid(1.0, 1.0, 1.0), 276 308 )); 277 309 278 310 commands.spawn(ramp( 279 - Transform::from_translation(Vec3::ZERO.with_x(-10.0)).with_scale(Vec3::new(2.4, 2.0, 10.0)), 311 + Transform::from_translation(Vec3::ZERO.with_x(-25.0)).with_scale(Vec3::new(2.4, 2.0, 10.0)), 280 312 &mut meshes, 281 313 &mut materials, 282 314 &asset_server, 283 315 &mut rng, 284 316 )); 285 317 commands.spawn(ramp( 286 - Transform::from_translation(Vec3::ZERO.with_x(10.0)).with_scale(Vec3::new(2.4, 1.5, 10.0)), 318 + Transform::from_translation(Vec3::ZERO.with_x(-50.0)).with_scale(Vec3::new(2.4, 1.5, 10.0)), 287 319 &mut meshes, 288 320 &mut materials, 289 321 &asset_server, 290 322 &mut rng, 291 323 )); 292 324 commands.spawn(ramp( 293 - Transform::from_translation(Vec3::ZERO.with_x(10.0)) 294 - .with_scale(Vec3::new(2.4, 1.5, 10.0)) 295 - .with_rotation(Quat::from_rotation_y(PI)), 325 + Transform::from_translation(Vec3::ZERO.with_x(-75.0)) 326 + .with_scale(Vec3::new(2.4, 8.5, 16.0)) 327 + .with_rotation(Quat::from_rotation_y(FRAC_PI_2)), 296 328 &mut meshes, 297 329 &mut materials, 298 330 &asset_server, 299 331 &mut rng, 300 332 )); 333 + } 301 334 302 - // height field 303 - // create a custom mesh and spawn with a collider 304 - let size = 25; 335 + pub fn spawn_terrains( 336 + mut commands: Commands, 337 + mut meshes: ResMut<Assets<Mesh>>, 338 + mut materials: ResMut<Assets<StandardMaterial>>, 339 + asset_server: Res<AssetServer>, 340 + ) { 341 + let seed_from_u64 = ChaCha8Rng::seed_from_u64(42); 342 + let mut rng = seed_from_u64; 305 343 306 - // Create a 20x20 heightfield using maps 307 - let seed = 88.88; 308 - let heights: Vec<Vec<Scalar>> = (0..size) 309 - .map(|i| { 310 - let max_dist_i = (size - 1) / 2; 311 - // Correct falloff: 0.0 at edge, 1.0 at center 312 - let falloff_i = if max_dist_i == 0 { 313 - 1.0 314 - } else { 315 - i.min(size - 1 - i) as f32 / max_dist_i as f32 316 - }; 344 + let start = TILE_SIZE * (GRID_SIZE as f32 / 2.0); // 100.0 345 + 346 + // Dynamically calculate the global bounds of the terrain region based on configuration constants 347 + let min_x = -start + (TERRAIN_START_I as f32 * TILE_SIZE) - TILE_SIZE / 2.0; 348 + let max_x = -start + ((TERRAIN_END_I - 1) as f32 * TILE_SIZE) + TILE_SIZE / 2.0; 349 + let min_z = -start + (TERRAIN_START_J as f32 * TILE_SIZE) - TILE_SIZE / 2.0; 350 + let max_z = -start + ((TERRAIN_END_J - 1) as f32 * TILE_SIZE) + TILE_SIZE / 2.0; 351 + 352 + let seed = 29.88; 353 + let cell_size = 1.0; 354 + // Number of vertices for a tile of size TILE_SIZE with cell_size 1.0 is TILE_SIZE + 1 355 + let vert_count = (TILE_SIZE / cell_size) as usize + 1; 356 + 357 + for i in 0..GRID_SIZE { 358 + for j in 0..GRID_SIZE { 359 + let tx = -start + (i as f32 * TILE_SIZE); 360 + let tz = -start + (j as f32 * TILE_SIZE); 317 361 318 - (0..size) 319 - .map(|j| { 320 - let max_dist_j = (size - 1) / 2; 321 - // Correct falloff: 0.0 at edge, 1.0 at center 322 - let falloff_j = if max_dist_j == 0 { 323 - 1.0 324 - } else { 325 - j.min(size - 1 - j) as f32 / max_dist_j as f32 326 - }; 362 + // Determine if this tile is configured as a terrain tile 363 + if i >= TERRAIN_START_I 364 + && i < TERRAIN_END_I 365 + && j >= TERRAIN_START_J 366 + && j < TERRAIN_END_J 367 + { 368 + // Generate terrain heights using the global tile position and terrain region bounds 369 + let tile_pos = Vec3::new(tx, 0.0, tz); 370 + let heights = gen_heights( 371 + vert_count, cell_size, tile_pos, min_x, max_x, min_z, max_z, seed, 372 + ); 373 + let terrain_mesh = create_heightmap_mesh(&heights, cell_size); 327 374 328 - simplex_noise_3d_seeded(Vec3::new(i as f32, 1.0, j as f32), Vec3::splat(seed)) 329 - * falloff_i 330 - * falloff_j 331 - }) 332 - .collect() 333 - }) 334 - .collect(); 375 + // Scale is the total width, height scale, and total depth 376 + let collider = Collider::heightfield(heights, Vec3::new(TILE_SIZE, 1.0, TILE_SIZE)); 335 377 336 - let cell_size = 1.0; 337 - let terrain_mesh = create_heightmap_mesh(&heights, cell_size); 338 - let collider = Collider::heightfield( 339 - heights, 340 - Vec3::new( 341 - (size - 1) as f32 * cell_size, 342 - 1.0, 343 - (size - 1) as f32 * cell_size, 344 - ), 345 - ); 346 - commands.spawn(( 347 - static_environment_bundle(), 348 - Mesh3d(meshes.add(terrain_mesh)), 349 - MeshMaterial3d(materials.add(StandardMaterial::default())), 350 - collider, 351 - Transform::from_xyz(25.0, 0.05, 25.0), 352 - )); 378 + commands.spawn(( 379 + static_environment_bundle(), 380 + Mesh3d(meshes.add(terrain_mesh)), 381 + MeshMaterial3d(materials.add(StandardMaterial { 382 + base_color_texture: Some(asset_server.load(PROTOTYPE_TEXTURE2)), 383 + base_color: rand_color(&mut rng), 384 + ..default() 385 + })), 386 + collider, 387 + Transform::from_xyz(tx, 0.0, tz), 388 + )); 389 + } else { 390 + // Flat ground tile 391 + commands.spawn(( 392 + static_environment_bundle(), 393 + Mesh3d(meshes.add(Cuboid::new(TILE_SIZE, 1.0, TILE_SIZE))), 394 + MeshMaterial3d(materials.add(StandardMaterial { 395 + base_color: Srgba::hex("C2B280").unwrap().into(), 396 + base_color_texture: Some(asset_server.load_with_settings( 397 + PROTOTYPE_TEXTURE2, 398 + |s: &mut _| { 399 + *s = ImageLoaderSettings { 400 + sampler: ImageSampler::Descriptor(ImageSamplerDescriptor { 401 + address_mode_u: ImageAddressMode::Repeat, 402 + address_mode_v: ImageAddressMode::Repeat, 403 + ..default() 404 + }), 405 + ..default() 406 + } 407 + }, 408 + )), 409 + uv_transform: Affine2::from_scale(Vec2::new(TILE_SIZE, TILE_SIZE)), 410 + unlit: true, 411 + ..default() 412 + })), 413 + Transform::from_xyz(tx, -0.5, tz), 414 + NotShadowCaster, 415 + Collider::cuboid(TILE_SIZE, 1.0, TILE_SIZE), 416 + )); 417 + } 418 + } 419 + } 353 420 } 354 421 355 422 pub fn static_environment_bundle() -> impl Bundle { ··· 360 427 ) 361 428 } 362 429 430 + /// Generate height values for the terrain mesh using global coordinates for seamless tiling 431 + pub fn gen_heights( 432 + size: usize, 433 + cell_size: f32, 434 + tile_pos: Vec3, 435 + min_x: f32, 436 + max_x: f32, 437 + min_z: f32, 438 + max_z: f32, 439 + seed: f32, 440 + ) -> Vec<Vec<Scalar>> { 441 + let max_height = 4.0; 442 + let half_width = (size - 1) as f32 * cell_size * 0.5; 443 + 444 + let center_x = (min_x + max_x) * 0.5; 445 + let center_z = (min_z + max_z) * 0.5; 446 + let half_w = (max_x - min_x) * 0.5; 447 + let half_d = (max_z - min_z) * 0.5; 448 + 449 + (0..size) 450 + .map(|i| { 451 + let local_x = i as f32 * cell_size - half_width; 452 + let global_x = tile_pos.x + local_x; 453 + 454 + (0..size) 455 + .map(|j| { 456 + let local_z = j as f32 * cell_size - half_width; 457 + let global_z = tile_pos.z + local_z; 458 + 459 + if global_x < min_x || global_x > max_x || global_z < min_z || global_z > max_z 460 + { 461 + 0.0 462 + } else { 463 + let dx = (global_x - center_x).abs() / half_w; 464 + let dz = (global_z - center_z).abs() / half_d; 465 + 466 + // Apply smooth falloff towards the boundaries of the terrain region 467 + let falloff_x = (1.0 - dx).clamp(0.0, 1.0); 468 + let falloff_z = (1.0 - dz).clamp(0.0, 1.0); 469 + let falloff = falloff_x * falloff_z; 470 + 471 + fbm_simplex_3d_seeded( 472 + Vec3::new(global_x, 0.0, global_z) * 0.04, 473 + 4, 474 + 2.0, 475 + 0.5, 476 + Vec3::splat(seed), 477 + ) * max_height 478 + * falloff 479 + } 480 + }) 481 + .collect() 482 + }) 483 + .collect() 484 + } 485 + 363 486 pub fn ramp( 364 487 transform: Transform, 365 488 meshes: &mut Assets<Mesh>, ··· 395 518 fn setup_instructions(mut commands: Commands) { 396 519 commands.spawn(( 397 520 Text::new( 398 - "Press F to flip the car\nPress 1 to hide car visuals\nPress 2 to use sphere casts", 521 + "Press F to flip the car\nPress 1 to hide car visuals\nPress 2 to toggle sphere/ray casts", 399 522 ), 400 523 TextFont { 401 524 font_size: 12.0, ··· 422 545 throttle: Query<&Action<Throttle>>, 423 546 mut camera_transform: Single<&mut Transform, With<CarCamera>>, 424 547 car: Single<(Forces, &ComputedMass), With<Car>>, 425 - car_info: Res<CarInfo>, 548 + mut car_info: ResMut<CarInfo>, 426 549 mut car_gizmos: Gizmos<CarGizmos>, 427 550 time: Res<Time>, 428 551 ) { ··· 433 556 .unwrap_or(Vec2::splat(0.0)); 434 557 let throttle = throttle.iter().next().map(|t| **t).unwrap_or(0.0); 435 558 let (mut forces, mass) = car.into_inner(); 559 + car_info.mass = mass.value(); 436 560 437 - const MAX_SPEED: f32 = 45.0; 438 - const MAX_FORWARD_FORCE: f32 = 22.0; // Maximum engine power 439 - const MAX_LATERAL_GRIP: f32 = 28.0; // Lower = driftier, Higher = turns on rails 440 561 // move and steer the player car 441 562 let rotation = Quat::from_rotation_y(-steering.x * 0.05); 442 563 camera_transform.rotate_around(Vec3::ZERO, rotation); ··· 451 572 camera_transform.translation = forces.position().0 + (forward * 7.0) + (Vec3::Y * 3.0); 452 573 let current_velocity = forces.linear_velocity(); 453 574 let dt = time.delta_secs(); 454 - let car_mass = mass.value() * 0.5; 575 + let car_mass = mass.value(); 455 576 if dt > 0.0 && throttle.abs() > 0.05 && car_info.is_grounded { 456 577 // ----------------------------------------------------------------- 457 578 // LONGITUDINAL LOOP (Forward Acceleration / Braking) ··· 524 645 camera_query: Single<&Transform, With<CarCamera>>, 525 646 mut car: Single<Forces, With<Car>>, 526 647 ) { 527 - const ALIGNMENT_TORQUE_STRENGTH: f32 = 8.0; 528 - const ALIGNMENT_TORQUE_MAX: f32 = 40.0; 529 - const ALIGNMENT_DEAD_ZONE: f32 = 0.01; // radians 530 - 531 648 let camera_transform = *camera_query; 532 649 let car_rotation = car.rotation(); 533 650
+5 -5
src/cars.rs
··· 1 1 use std::f32::consts::FRAC_PI_2; 2 2 3 3 use avian3d::prelude::{ 4 - AngularDamping, Collider, CollisionLayers, LinearDamping, RigidBody, ShapeCaster, 5 - SpatialQueryFilter, SweptCcd, 4 + AngularDamping, Collider, ColliderDensity, CollisionLayers, LinearDamping, RigidBody, 5 + ShapeCaster, SpatialQueryFilter, SweptCcd, 6 6 }; 7 7 use bevy::{ 8 8 color::palettes::css::{BLACK, RED}, 9 9 prelude::*, 10 10 }; 11 - 12 - use crate::common_example::GameLayer; 13 11 14 12 pub const WHEEL_RADIUS: f32 = 0.3; 15 13 const WHEEL_HEIGHT: f32 = 0.42; ··· 43 41 44 42 use crate::{ 45 43 Car, CarCamera, CarVisual, 46 - common_example::{CAR_HEIGHT, CAR_LENGTH, CAR_WIDTH}, 44 + common_example::{CAR_HEIGHT, CAR_LENGTH, CAR_WIDTH, GameLayer}, 47 45 controls::{Steer, car_control_bundle}, 48 46 }; 49 47 ··· 90 88 SweptCcd::LINEAR, 91 89 // tune up if still tumbling 92 90 LinearDamping(0.8), 91 + // Mass(10.0), 92 + ColliderDensity(2.0), 93 93 // Player collides with the ground, but not with itself 94 94 CollisionLayers::new(GameLayer::Player, [GameLayer::Ground]), 95 95 )
+13 -58
src/common_example.rs
··· 1 - use avian3d::prelude::{Collider, CollisionLayers, PhysicsLayer, RigidBody}; 1 + use avian3d::prelude::PhysicsLayer; 2 2 use bevy::{ 3 3 asset::RenderAssetUsages, 4 - image::{ImageAddressMode, ImageLoaderSettings, ImageSampler, ImageSamplerDescriptor}, 5 4 light::{CascadeShadowConfigBuilder, NotShadowCaster}, 6 - math::Affine2, 7 5 mesh::{Indices, PrimitiveTopology}, 8 6 prelude::*, 9 7 }; ··· 38 36 mut commands: Commands, 39 37 mut meshes: ResMut<Assets<Mesh>>, 40 38 mut materials: ResMut<Assets<StandardMaterial>>, 41 - asset_server: Res<AssetServer>, 42 39 ) { 43 40 // Configure a properly scaled cascade shadow map for this scene (defaults are too large, mesh units are in km) 44 41 let cascade_shadow_config = CascadeShadowConfigBuilder { ··· 73 70 Transform::from_scale(Vec3::splat(20.0)), 74 71 NotShadowCaster, 75 72 )); 76 - 77 - // Ground 78 - let ground_grid_size = 8; 79 - let size = 25.0; 80 - let start = size * (ground_grid_size as f32 / 2.0); 81 - info!("start {start:?}"); 82 - for i in 0..ground_grid_size { 83 - for j in 0..ground_grid_size { 84 - commands.spawn(( 85 - DespawnOnExit(GameState::B), 86 - Mesh3d(meshes.add(Cuboid::new(size, 1.0, size))), 87 - MeshMaterial3d(materials.add(StandardMaterial { 88 - base_color: Srgba::hex("C2B280").unwrap().into(), 89 - base_color_texture: Some(asset_server.load_with_settings( 90 - PROTOTYPE_TEXTURE2, 91 - |s: &mut _| { 92 - *s = ImageLoaderSettings { 93 - sampler: ImageSampler::Descriptor(ImageSamplerDescriptor { 94 - // rewriting mode to repeat image, 95 - address_mode_u: ImageAddressMode::Repeat, 96 - address_mode_v: ImageAddressMode::Repeat, 97 - ..default() 98 - }), 99 - ..default() 100 - } 101 - }, 102 - )), 103 - 104 - // uv_transform used here for proportions only, but it is full Affine2 105 - // that's why you can use rotation and shift also 106 - uv_transform: Affine2::from_scale(Vec2::new(size, size)), 107 - unlit: true, 108 - ..default() 109 - })), 110 - Transform::from_xyz(-start + (i as f32 * size), -0.5, -start + (j as f32 * size)), 111 - NotShadowCaster, 112 - RigidBody::Static, 113 - Collider::cuboid(size, 1.0, size), 114 - CollisionLayers::new(GameLayer::Ground, [GameLayer::Default, GameLayer::Player]), 115 - )); 116 - } 117 - } 118 73 } 119 74 120 75 /// Builds a grid plane mesh from a 2D height grid. ··· 134 89 "all heightmap rows must have the same length" 135 90 ); 136 91 137 - let half_width = (cols - 1) as f32 * cell_size * 0.5; 138 - let half_depth = (rows - 1) as f32 * cell_size * 0.5; 92 + let half_width = (rows - 1) as f32 * cell_size * 0.5; 93 + let half_depth = (cols - 1) as f32 * cell_size * 0.5; 139 94 140 95 // ── Positions, UVs, Normals ─────────────────────────────────────────────── 141 96 let mut positions: Vec<[f32; 3]> = Vec::with_capacity(rows * cols); ··· 143 98 // Normals will be accumulated then normalized. 144 99 let mut normals: Vec<[f32; 3]> = vec![[0.0, 0.0, 0.0]; rows * cols]; 145 100 146 - for x in 0..cols { 147 - for z in 0..rows { 101 + for x in 0..rows { 102 + for z in 0..cols { 148 103 let px = x as f32 * cell_size - half_width; 149 - let py = heights[x][z]; // ← swapped 104 + let py = heights[x][z]; 150 105 let pz = z as f32 * cell_size - half_depth; 151 106 152 107 positions.push([px, py, pz]); 153 - uvs.push([x as f32 / (cols - 1) as f32, z as f32 / (rows - 1) as f32]); 108 + uvs.push([x as f32 / (rows - 1) as f32, z as f32 / (cols - 1) as f32]); 154 109 } 155 110 } 156 111 157 112 // ── Indices ─────────────────────────────────────────────────────────────── 158 113 // 159 - // For each cell (z, x) we emit two counter-clockwise triangles when viewed 114 + // For each cell (x, z) we emit two counter-clockwise triangles when viewed 160 115 // from above (+Y): 161 116 // 162 117 // tl --- tr tl --- tr ··· 164 119 // | \ | | / | 165 120 // bl --- br bl --- br 166 121 // 167 - // tri 0: tl, bl, br (lower-right triangle) 168 - // tri 1: tl, br, tr (upper-right triangle) 122 + // tri 0: tl, tr, bl (upper-left triangle) 123 + // tri 1: tr, br, bl (lower-right triangle) 169 124 // 170 125 // Counter-clockwise winding is required for correct back-face culling in Bevy. 171 126 172 127 let mut indices: Vec<u32> = Vec::with_capacity((rows - 1) * (cols - 1) * 6); 173 128 174 - let idx = |x: usize, z: usize| -> u32 { (x * rows + z) as u32 }; 129 + let idx = |x: usize, z: usize| -> u32 { (x * cols + z) as u32 }; 175 130 176 - for x in 0..(cols - 1) { 177 - for z in 0..(rows - 1) { 131 + for x in 0..(rows - 1) { 132 + for z in 0..(cols - 1) { 178 133 let tl = idx(x, z); 179 134 let tr = idx(x + 1, z); 180 135 let bl = idx(x, z + 1);