1use std::error::Error;
4use std::fmt;
5use std::io::{BufRead, Seek};
6use std::sync::Arc;
7
8use galeon_engine::{
9 Engine, MaterialHandle, MeshHandle, ObjectType, Plugin, Transform, Visibility,
10};
11
12#[derive(Debug, Clone, PartialEq)]
18pub struct Terrain {
19 origin: [f32; 2],
20 size: [f32; 2],
21 sample_count: [u32; 2],
22 pixel_stride: [f32; 2],
23 heights: Arc<[f32]>,
24 min_height: f32,
25 max_height: f32,
26}
27
28impl Terrain {
29 pub fn new(
34 origin: [f32; 2],
35 size: [f32; 2],
36 width: u32,
37 height: u32,
38 heights: Vec<f32>,
39 ) -> Result<Self, TerrainError> {
40 if width < 2 || height < 2 {
41 return Err(TerrainError::InvalidDimensions { width, height });
42 }
43 let expected = width as usize * height as usize;
44 if heights.len() != expected {
45 return Err(TerrainError::HeightCount {
46 expected,
47 actual: heights.len(),
48 });
49 }
50 if !size[0].is_finite() || !size[1].is_finite() || size[0] <= 0.0 || size[1] <= 0.0 {
51 return Err(TerrainError::InvalidSize { size });
52 }
53 if !origin[0].is_finite() || !origin[1].is_finite() {
54 return Err(TerrainError::InvalidOrigin { origin });
55 }
56 if heights.iter().any(|h| !h.is_finite()) {
57 return Err(TerrainError::NonFiniteHeight);
58 }
59
60 let (min_height, max_height) = min_max(&heights);
61 Ok(Self {
62 origin,
63 size,
64 sample_count: [width, height],
65 pixel_stride: [size[0] / (width - 1) as f32, size[1] / (height - 1) as f32],
66 heights: heights.into(),
67 min_height,
68 max_height,
69 })
70 }
71
72 pub fn from_png16_reader<R: BufRead + Seek>(
74 reader: R,
75 options: Png16HeightmapOptions,
76 ) -> Result<Self, TerrainError> {
77 if !options.height_min.is_finite()
78 || !options.height_max.is_finite()
79 || !options.vertical_exaggeration.is_finite()
80 {
81 return Err(TerrainError::InvalidHeightScale);
82 }
83 if options.height_max < options.height_min {
84 return Err(TerrainError::InvalidHeightRange {
85 min: options.height_min,
86 max: options.height_max,
87 });
88 }
89
90 let decoder = png::Decoder::new(reader);
91 let mut png_reader = decoder.read_info().map_err(TerrainError::PngDecode)?;
92 let info = png_reader.info();
93 if info.color_type != png::ColorType::Grayscale || info.bit_depth != png::BitDepth::Sixteen
94 {
95 return Err(TerrainError::UnsupportedPng {
96 color_type: info.color_type,
97 bit_depth: info.bit_depth,
98 });
99 }
100
101 let buffer_size = png_reader
102 .output_buffer_size()
103 .ok_or(TerrainError::UnknownPngBufferSize)?;
104 let mut bytes = vec![0; buffer_size];
105 let frame = png_reader
106 .next_frame(&mut bytes)
107 .map_err(TerrainError::PngDecode)?;
108 let data = &bytes[..frame.buffer_size()];
109 if data.len() % 2 != 0 {
110 return Err(TerrainError::MalformedPngData);
111 }
112
113 let height_range = options.height_max - options.height_min;
114 let mut heights = Vec::with_capacity(data.len() / 2);
115 for px in data.chunks_exact(2) {
116 let raw = u16::from_be_bytes([px[0], px[1]]);
117 let normalized = raw as f32 / u16::MAX as f32;
118 heights.push(
119 options.height_min + normalized * height_range * options.vertical_exaggeration,
120 );
121 }
122
123 Self::new(
124 options.origin,
125 options.size,
126 frame.width,
127 frame.height,
128 heights,
129 )
130 }
131
132 pub fn origin(&self) -> [f32; 2] {
134 self.origin
135 }
136
137 pub fn size(&self) -> [f32; 2] {
139 self.size
140 }
141
142 pub fn sample_count(&self) -> [u32; 2] {
144 self.sample_count
145 }
146
147 pub fn pixel_stride(&self) -> [f32; 2] {
149 self.pixel_stride
150 }
151
152 pub fn heights(&self) -> &[f32] {
154 &self.heights
155 }
156
157 pub fn min_height(&self) -> f32 {
159 self.min_height
160 }
161
162 pub fn max_height(&self) -> f32 {
164 self.max_height
165 }
166
167 pub fn bounds(&self) -> ([f32; 3], [f32; 3]) {
169 (
170 [self.origin[0], self.min_height, self.origin[1]],
171 [
172 self.origin[0] + self.size[0],
173 self.max_height,
174 self.origin[1] + self.size[1],
175 ],
176 )
177 }
178
179 pub fn height_at(&self, x: f32, z: f32) -> Option<f32> {
183 let [u, v] = self.world_to_grid(x, z)?;
184 Some(self.sample_bilinear(u, v))
185 }
186
187 pub fn normal_at(&self, x: f32, z: f32) -> Option<[f32; 3]> {
192 let [u, v] = self.world_to_grid(x, z)?;
193 let max_u = (self.sample_count[0] - 1) as f32;
194 let max_v = (self.sample_count[1] - 1) as f32;
195 let left_u = (u - 1.0).max(0.0);
196 let right_u = (u + 1.0).min(max_u);
197 let down_v = (v - 1.0).max(0.0);
198 let up_v = (v + 1.0).min(max_v);
199
200 let left = self.sample_bilinear(left_u, v);
201 let right = self.sample_bilinear(right_u, v);
202 let down = self.sample_bilinear(u, down_v);
203 let up = self.sample_bilinear(u, up_v);
204
205 let dx = (right_u - left_u) * self.pixel_stride[0];
206 let dz = (up_v - down_v) * self.pixel_stride[1];
207 let dhdx = (right - left) / dx;
208 let dhdz = (up - down) / dz;
209 normalize([-dhdx, 1.0, -dhdz])
210 }
211
212 fn world_to_grid(&self, x: f32, z: f32) -> Option<[f32; 2]> {
213 if !x.is_finite() || !z.is_finite() {
214 return None;
215 }
216 let max_x = self.origin[0] + self.size[0];
217 let max_z = self.origin[1] + self.size[1];
218 if x < self.origin[0] || x > max_x || z < self.origin[1] || z > max_z {
219 return None;
220 }
221 Some([
222 (x - self.origin[0]) / self.pixel_stride[0],
223 (z - self.origin[1]) / self.pixel_stride[1],
224 ])
225 }
226
227 fn sample_bilinear(&self, u: f32, v: f32) -> f32 {
228 let max_x = (self.sample_count[0] - 1) as f32;
229 let max_z = (self.sample_count[1] - 1) as f32;
230 let u = u.clamp(0.0, max_x);
231 let v = v.clamp(0.0, max_z);
232
233 let x0 = u.floor() as u32;
234 let z0 = v.floor() as u32;
235 let x1 = (x0 + 1).min(self.sample_count[0] - 1);
236 let z1 = (z0 + 1).min(self.sample_count[1] - 1);
237 let tx = u - x0 as f32;
238 let tz = v - z0 as f32;
239
240 let h00 = self.sample_at(x0, z0);
241 let h10 = self.sample_at(x1, z0);
242 let h01 = self.sample_at(x0, z1);
243 let h11 = self.sample_at(x1, z1);
244 let a = h00 + (h10 - h00) * tx;
245 let b = h01 + (h11 - h01) * tx;
246 a + (b - a) * tz
247 }
248
249 fn sample_at(&self, x: u32, z: u32) -> f32 {
250 self.heights[(z * self.sample_count[0] + x) as usize]
251 }
252
253 fn normal_at_sample(&self, x: u32, z: u32) -> [f32; 3] {
254 debug_assert!(x < self.sample_count[0]);
255 debug_assert!(z < self.sample_count[1]);
256
257 let max_x = self.sample_count[0] - 1;
258 let max_z = self.sample_count[1] - 1;
259 let left_x = x.saturating_sub(1);
260 let right_x = (x + 1).min(max_x);
261 let down_z = z.saturating_sub(1);
262 let up_z = (z + 1).min(max_z);
263
264 let left = self.sample_at(left_x, z);
265 let right = self.sample_at(right_x, z);
266 let down = self.sample_at(x, down_z);
267 let up = self.sample_at(x, up_z);
268
269 let dx = (right_x - left_x) as f32 * self.pixel_stride[0];
270 let dz = (up_z - down_z) as f32 * self.pixel_stride[1];
271 let dhdx = (right - left) / dx;
272 let dhdz = (up - down) / dz;
273 normalize([-dhdx, 1.0, -dhdz]).expect("normal vector includes +Y component")
274 }
275}
276
277#[derive(Debug, Clone, PartialEq)]
284pub struct TerrainMesh {
285 positions: Vec<f32>,
286 normals: Vec<f32>,
287 indices: Vec<u32>,
288}
289
290impl TerrainMesh {
291 pub fn from_terrain(terrain: &Terrain) -> Self {
293 let [width, height] = terrain.sample_count();
294 let [stride_x, stride_z] = terrain.pixel_stride();
295 let vertex_count = width as usize * height as usize;
296 let quad_count = (width - 1) as usize * (height - 1) as usize;
297 let mut positions = Vec::with_capacity(vertex_count * 3);
298 let mut normals = Vec::with_capacity(vertex_count * 3);
299 let mut indices = Vec::with_capacity(quad_count * 6);
300
301 for z in 0..height {
302 for x in 0..width {
303 let local_x = x as f32 * stride_x;
304 let local_z = z as f32 * stride_z;
305 positions.extend_from_slice(&[local_x, terrain.sample_at(x, z), local_z]);
306
307 let normal = terrain.normal_at_sample(x, z);
308 normals.extend_from_slice(&normal);
309 }
310 }
311
312 for z in 0..(height - 1) {
313 for x in 0..(width - 1) {
314 let top_left = z * width + x;
315 let top_right = top_left + 1;
316 let bottom_left = top_left + width;
317 let bottom_right = bottom_left + 1;
318 indices.extend_from_slice(&[
319 top_left,
320 bottom_left,
321 top_right,
322 top_right,
323 bottom_left,
324 bottom_right,
325 ]);
326 }
327 }
328
329 Self {
330 positions,
331 normals,
332 indices,
333 }
334 }
335
336 pub fn vertex_count(&self) -> usize {
338 self.positions.len() / 3
339 }
340
341 pub fn positions(&self) -> &[f32] {
343 &self.positions
344 }
345
346 pub fn normals(&self) -> &[f32] {
348 &self.normals
349 }
350
351 pub fn indices(&self) -> &[u32] {
353 &self.indices
354 }
355}
356
357#[derive(Debug, Clone, Copy, PartialEq)]
359pub struct Png16HeightmapOptions {
360 pub origin: [f32; 2],
361 pub size: [f32; 2],
362 pub height_min: f32,
363 pub height_max: f32,
364 pub vertical_exaggeration: f32,
365}
366
367impl Default for Png16HeightmapOptions {
368 fn default() -> Self {
369 Self {
370 origin: [0.0, 0.0],
371 size: [1.0, 1.0],
372 height_min: 0.0,
373 height_max: 1.0,
374 vertical_exaggeration: 1.0,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct TerrainRenderSettings {
382 pub mesh_handle: MeshHandle,
383 pub material_handle: MaterialHandle,
384}
385
386impl TerrainRenderSettings {
387 pub fn new(mesh_handle: MeshHandle, material_handle: MaterialHandle) -> Self {
388 Self {
389 mesh_handle,
390 material_handle,
391 }
392 }
393}
394
395#[derive(Debug, Clone, PartialEq)]
397pub struct HeightmapPlugin {
398 terrain: Terrain,
399 render: Option<TerrainRenderSettings>,
400}
401
402impl HeightmapPlugin {
403 pub fn new(terrain: Terrain) -> Self {
404 Self {
405 terrain,
406 render: None,
407 }
408 }
409
410 pub fn with_render_mesh(
417 mut self,
418 mesh_handle: MeshHandle,
419 material_handle: MaterialHandle,
420 ) -> Self {
421 self.render = Some(TerrainRenderSettings::new(mesh_handle, material_handle));
422 self
423 }
424}
425
426impl Plugin for HeightmapPlugin {
427 fn build(&self, engine: &mut Engine) {
428 if let Some(render) = self.render {
429 engine.insert_resource(TerrainMesh::from_terrain(&self.terrain));
430 let origin = self.terrain.origin();
431 engine.world_mut().spawn((
432 Transform::from_position(origin[0], 0.0, origin[1]),
433 Visibility { visible: true },
434 render.mesh_handle,
435 render.material_handle,
436 ObjectType::Mesh,
437 ));
438 }
439 engine.insert_resource(self.terrain.clone());
440 }
441}
442
443#[derive(Debug)]
444pub enum TerrainError {
445 InvalidDimensions {
446 width: u32,
447 height: u32,
448 },
449 HeightCount {
450 expected: usize,
451 actual: usize,
452 },
453 InvalidOrigin {
454 origin: [f32; 2],
455 },
456 InvalidSize {
457 size: [f32; 2],
458 },
459 NonFiniteHeight,
460 InvalidHeightScale,
461 InvalidHeightRange {
462 min: f32,
463 max: f32,
464 },
465 UnsupportedPng {
466 color_type: png::ColorType,
467 bit_depth: png::BitDepth,
468 },
469 UnknownPngBufferSize,
470 MalformedPngData,
471 PngDecode(png::DecodingError),
472}
473
474impl fmt::Display for TerrainError {
475 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
476 match self {
477 Self::InvalidDimensions { width, height } => {
478 write!(
479 f,
480 "terrain dimensions must be at least 2x2, got {width}x{height}"
481 )
482 }
483 Self::HeightCount { expected, actual } => {
484 write!(
485 f,
486 "terrain expected {expected} height samples, got {actual}"
487 )
488 }
489 Self::InvalidOrigin { origin } => {
490 write!(f, "terrain origin must be finite, got {origin:?}")
491 }
492 Self::InvalidSize { size } => {
493 write!(f, "terrain size must be finite and positive, got {size:?}")
494 }
495 Self::NonFiniteHeight => write!(f, "terrain heights must be finite"),
496 Self::InvalidHeightScale => write!(f, "PNG height scale values must be finite"),
497 Self::InvalidHeightRange { min, max } => {
498 write!(f, "PNG height_max must be >= height_min, got {max} < {min}")
499 }
500 Self::UnsupportedPng {
501 color_type,
502 bit_depth,
503 } => write!(
504 f,
505 "heightmap PNG must be 16-bit grayscale, got {color_type:?} {bit_depth:?}",
506 ),
507 Self::UnknownPngBufferSize => {
508 write!(f, "PNG decoder did not report output buffer size")
509 }
510 Self::MalformedPngData => write!(f, "PNG frame data had an odd byte count"),
511 Self::PngDecode(err) => write!(f, "PNG decode failed: {err}"),
512 }
513 }
514}
515
516impl Error for TerrainError {
517 fn source(&self) -> Option<&(dyn Error + 'static)> {
518 match self {
519 Self::PngDecode(err) => Some(err),
520 _ => None,
521 }
522 }
523}
524
525fn min_max(values: &[f32]) -> (f32, f32) {
526 let mut min = f32::INFINITY;
527 let mut max = f32::NEG_INFINITY;
528 for value in values {
529 min = min.min(*value);
530 max = max.max(*value);
531 }
532 (min, max)
533}
534
535fn normalize(v: [f32; 3]) -> Option<[f32; 3]> {
536 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
537 if len <= f32::EPSILON {
538 return None;
539 }
540 Some([v[0] / len, v[1] / len, v[2] / len])
541}
542
543#[cfg(test)]
544mod tests {
545 use std::io::Cursor;
546
547 use super::*;
548
549 fn synthetic_4x4() -> Terrain {
550 Terrain::new(
551 [10.0, 20.0],
552 [3.0, 3.0],
553 4,
554 4,
555 vec![
556 0.0, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0,
560 ],
561 )
562 .unwrap()
563 }
564
565 #[test]
566 fn height_at_bilinear_samples_synthetic_grid() {
567 let terrain = synthetic_4x4();
568
569 assert_eq!(terrain.height_at(10.0, 20.0), Some(0.0));
570 assert_eq!(terrain.height_at(13.0, 23.0), Some(6.0));
571 assert_eq!(terrain.height_at(11.5, 21.5), Some(3.0));
572 assert_eq!(terrain.height_at(9.9, 20.0), None);
573 }
574
575 #[test]
576 fn normal_at_uses_source_height_gradient() {
577 let terrain = synthetic_4x4();
578 let normal = terrain.normal_at(11.0, 21.0).unwrap();
579 let expected = [
580 -1.0 / 3.0_f32.sqrt(),
581 1.0 / 3.0_f32.sqrt(),
582 -1.0 / 3.0_f32.sqrt(),
583 ];
584
585 assert!((normal[0] - expected[0]).abs() < 1e-6);
586 assert!((normal[1] - expected[1]).abs() < 1e-6);
587 assert!((normal[2] - expected[2]).abs() < 1e-6);
588 }
589
590 #[test]
591 fn normal_at_preserves_planar_gradient_near_edges() {
592 let terrain = synthetic_4x4();
593 let interior = terrain.normal_at(11.0, 21.0).unwrap();
594 let near_max_edge = terrain.normal_at(12.9, 22.9).unwrap();
595
596 for i in 0..3 {
597 assert!(
598 (near_max_edge[i] - interior[i]).abs() < 1e-6,
599 "component {i}: expected {interior:?}, got {near_max_edge:?}",
600 );
601 }
602 }
603
604 #[test]
605 fn bounds_and_stride_reflect_loaded_grid() {
606 let terrain = synthetic_4x4();
607
608 assert_eq!(terrain.sample_count(), [4, 4]);
609 assert_eq!(terrain.pixel_stride(), [1.0, 1.0]);
610 assert_eq!(terrain.bounds(), ([10.0, 0.0, 20.0], [13.0, 6.0, 23.0]));
611 }
612
613 #[test]
614 fn heightmap_plugin_inserts_terrain_resource() {
615 let terrain = synthetic_4x4();
616 let height_storage = terrain.heights().as_ptr();
617 let mut engine = Engine::new();
618
619 engine.add_plugin(HeightmapPlugin::new(terrain.clone()));
620
621 assert_eq!(engine.world().resource::<Terrain>(), &terrain);
622 assert_eq!(
623 engine.world().resource::<Terrain>().heights().as_ptr(),
624 height_storage
625 );
626 }
627
628 #[test]
629 fn terrain_mesh_generates_vertices_normals_and_indices() {
630 let terrain = Terrain::new(
631 [0.0, 0.0],
632 [2.0, 2.0],
633 3,
634 3,
635 vec![
636 0.0, 1.0, 2.0, 1.0, 2.0, 3.0, 2.0, 3.0, 4.0,
639 ],
640 )
641 .unwrap();
642
643 let mesh = TerrainMesh::from_terrain(&terrain);
644
645 assert_eq!(mesh.vertex_count(), (2 + 1) * (2 + 1));
646 assert_eq!(mesh.positions().len(), 9 * 3);
647 assert_eq!(mesh.normals().len(), 9 * 3);
648 assert_eq!(mesh.indices().len(), 2 * 2 * 6);
649 assert_eq!(&mesh.positions()[0..3], &[0.0, 0.0, 0.0]);
650 assert_eq!(&mesh.positions()[24..27], &[2.0, 4.0, 2.0]);
651
652 let expected = [
653 -1.0 / 3.0_f32.sqrt(),
654 1.0 / 3.0_f32.sqrt(),
655 -1.0 / 3.0_f32.sqrt(),
656 ];
657 let center_normal = &mesh.normals()[12..15];
658 for i in 0..3 {
659 assert!((center_normal[i] - expected[i]).abs() < 1e-6);
660 }
661 }
662
663 #[test]
664 fn terrain_mesh_computes_edge_normals_from_grid_samples() {
665 let terrain = Terrain::new(
666 [0.0, 0.0],
667 [0.1, 0.1],
668 4,
669 4,
670 vec![
671 0.0, 1.0, 2.0, 3.0, 1.0, 2.0, 3.0, 4.0, 2.0, 3.0, 4.0, 5.0, 3.0, 4.0, 5.0, 6.0,
675 ],
676 )
677 .unwrap();
678
679 let mesh = TerrainMesh::from_terrain(&terrain);
680 let last_normal = &mesh.normals()[45..48];
681 let gradient = 1.0 / (0.1_f32 / 3.0);
682 let expected = normalize([-gradient, 1.0, -gradient]).unwrap();
683
684 for i in 0..3 {
685 assert!(
686 (last_normal[i] - expected[i]).abs() < 1e-6,
687 "component {i}: expected {expected:?}, got {last_normal:?}",
688 );
689 }
690 }
691
692 #[test]
693 fn heightmap_plugin_emits_terrain_render_entity_through_frame_packet() {
694 let terrain = synthetic_4x4();
695 let mut engine = Engine::new();
696
697 engine.add_plugin(
698 HeightmapPlugin::new(terrain)
699 .with_render_mesh(MeshHandle { id: 77 }, MaterialHandle { id: 9 }),
700 );
701
702 let mesh = engine.world().resource::<TerrainMesh>();
703 assert_eq!(mesh.vertex_count(), (4 - 1 + 1) * (4 - 1 + 1));
704
705 let packet = galeon_engine_three_sync::extract_frame(engine.world());
706 assert_eq!(packet.entity_count(), 1);
707 assert_eq!(packet.mesh_handles[0], 77);
708 assert_eq!(packet.material_handles[0], 9);
709 assert_eq!(packet.transforms[0], 10.0);
710 assert_eq!(packet.transforms[1], 0.0);
711 assert_eq!(packet.transforms[2], 20.0);
712 }
713
714 #[test]
715 fn terrain_clone_shares_immutable_height_storage() {
716 let terrain = synthetic_4x4();
717 let clone = terrain.clone();
718
719 assert_eq!(clone, terrain);
720 assert_eq!(clone.heights().as_ptr(), terrain.heights().as_ptr());
721 }
722
723 #[test]
724 fn png16_loader_decodes_fixture_corner_values() {
725 let bytes = include_bytes!("../tests/fixtures/heightmap-16x16-gray16.png");
726 let terrain = Terrain::from_png16_reader(
727 Cursor::new(bytes.as_slice()),
728 Png16HeightmapOptions {
729 origin: [-8.0, -8.0],
730 size: [15.0, 15.0],
731 height_min: -10.0,
732 height_max: 10.0,
733 vertical_exaggeration: 1.5,
734 },
735 )
736 .unwrap();
737
738 assert_eq!(terrain.sample_count(), [16, 16]);
739 assert_eq!(terrain.pixel_stride(), [1.0, 1.0]);
740 assert!((terrain.height_at(-8.0, -8.0).unwrap() - -10.0).abs() < 1e-6);
741 assert!((terrain.height_at(7.0, 7.0).unwrap() - 20.0).abs() < 1e-6);
742 assert_eq!(terrain.min_height(), -10.0);
743 assert_eq!(terrain.max_height(), 20.0);
744 }
745
746 #[test]
747 fn png16_loader_rejects_non_gray16_png() {
748 let mut bytes = Vec::new();
749 {
750 let mut encoder = png::Encoder::new(&mut bytes, 1, 1);
751 encoder.set_color(png::ColorType::Grayscale);
752 encoder.set_depth(png::BitDepth::Eight);
753 let mut writer = encoder.write_header().unwrap();
754 writer.write_image_data(&[0]).unwrap();
755 }
756
757 let err = Terrain::from_png16_reader(Cursor::new(bytes), Png16HeightmapOptions::default())
758 .unwrap_err();
759 assert!(matches!(err, TerrainError::UnsupportedPng { .. }));
760 }
761
762 #[test]
763 fn sample_bilinear_clamps_coordinates_above_max_index() {
764 let terrain = synthetic_4x4();
765
766 let height = terrain.sample_bilinear(4.0, 4.0);
767
768 assert_eq!(height, 6.0);
769 }
770}