Skip to main content

oxigdal_gpu/
ray_march.rs

1//! Ray-marching WGSL shader for volumetric DEM rendering.
2//!
3//! This module implements GPU-accelerated ray-marching over a Digital Elevation
4//! Model (DEM) raster to produce per-pixel shadow and depth information.
5//! It also exposes a CPU reference implementation for testing and fallback.
6//!
7//! # Algorithm overview
8//!
9//! For each pixel `(px, py)` in the DEM, a ray is cast from the surface point
10//! in the direction of the light source.  At each marching step the terrain
11//! elevation sampled along the ray is compared to the current ray height.  If
12//! the terrain is higher than the ray, the pixel is marked as shadowed and the
13//! march stops early.  After all steps the pixel is marked as lit.
14//!
15//! # GPU dispatch
16//!
17//! The WGSL kernel dispatches 16 × 16 workgroups.  Each invocation handles one
18//! output pixel.  The DEM, configuration uniform, and two output buffers
19//! (shadow factor and normalised depth) are bound at group 0.
20//!
21//! # Pure-CPU helpers
22//!
23//! [`normalize3`], [`sample_dem_bilinear`], and [`ray_march_cpu`] are fully
24//! self-contained CPU functions that mirror the GPU kernel behaviour.  They are
25//! used by unit tests and can serve as a CPU fallback on headless systems.
26
27use std::sync::Arc;
28
29use crate::context::GpuContext;
30use crate::error::{GpuError, GpuResult};
31use crate::shaders::{
32    ComputePipelineBuilder, WgslShader, create_compute_bind_group_layout, storage_buffer_layout,
33    uniform_buffer_layout,
34};
35
36use wgpu::{
37    BindGroupDescriptor, BindGroupEntry, BufferDescriptor, BufferUsages, CommandEncoderDescriptor,
38    ComputePassDescriptor,
39};
40
41// ─────────────────────────────────────────────────────────────────────────────
42// RayMarchConfig
43// ─────────────────────────────────────────────────────────────────────────────
44
45/// Configuration parameters for the DEM ray-marching kernel.
46///
47/// All vectors use a right-handed coordinate system where X runs east, Y runs
48/// north, and Z runs upward.
49#[derive(Debug, Clone)]
50pub struct RayMarchConfig {
51    /// Unit-vector describing the view direction (from eye toward scene).
52    /// Defaults to `[0, 0, -1]` (top-down orthographic).
53    pub view_dir: [f32; 3],
54
55    /// Unit-vector pointing toward the light source.
56    /// Defaults to the normalised `(1, 1, 1)` diagonal.
57    pub light_dir: [f32; 3],
58
59    /// World-space distance advanced per march step.
60    /// Must be `> 0`.
61    pub step_size: f32,
62
63    /// Maximum number of steps before the ray is considered unoccluded.
64    /// Must be `> 0`.
65    pub max_steps: u32,
66
67    /// Factor applied to DEM elevations before ray-height comparisons.
68    /// Set to `> 1.0` to exaggerate terrain relief.
69    pub vertical_exaggeration: f32,
70
71    /// Real-world length of one DEM pixel (in the same units as elevations).
72    /// Used to convert pixel-space step offsets to world-space distances.
73    pub pixel_size_world: f32,
74}
75
76impl Default for RayMarchConfig {
77    fn default() -> Self {
78        let inv_sqrt3 = 1.0_f32 / 3.0_f32.sqrt();
79        Self {
80            view_dir: [0.0, 0.0, -1.0],
81            light_dir: [inv_sqrt3, inv_sqrt3, inv_sqrt3],
82            step_size: 0.5,
83            max_steps: 512,
84            vertical_exaggeration: 1.0,
85            pixel_size_world: 1.0,
86        }
87    }
88}
89
90impl RayMarchConfig {
91    /// Validate configuration parameters against the supplied raster dimensions.
92    ///
93    /// # Errors
94    ///
95    /// Returns [`GpuError::InvalidKernelParams`] if:
96    /// - `step_size <= 0.0`
97    /// - `max_steps == 0`
98    /// - `width == 0` or `height == 0`
99    pub fn validate(&self, width: u32, height: u32) -> GpuResult<()> {
100        if self.step_size <= 0.0 {
101            return Err(GpuError::invalid_kernel_params(format!(
102                "step_size must be > 0, got {}",
103                self.step_size
104            )));
105        }
106        if self.max_steps == 0 {
107            return Err(GpuError::invalid_kernel_params("max_steps must be > 0"));
108        }
109        if width == 0 {
110            return Err(GpuError::invalid_kernel_params("DEM width must be > 0"));
111        }
112        if height == 0 {
113            return Err(GpuError::invalid_kernel_params("DEM height must be > 0"));
114        }
115        Ok(())
116    }
117}
118
119// ─────────────────────────────────────────────────────────────────────────────
120// RayMarchResult
121// ─────────────────────────────────────────────────────────────────────────────
122
123/// Output buffers produced by a DEM ray-march pass.
124pub struct RayMarchResult {
125    /// Width of the output raster in pixels.
126    pub width: u32,
127    /// Height of the output raster in pixels.
128    pub height: u32,
129
130    /// Per-pixel shadow factor stored in row-major order.
131    ///
132    /// `0.0` means the pixel is fully in shadow; `1.0` means the pixel is lit.
133    pub shaded: Vec<f32>,
134
135    /// Per-pixel normalised march depth stored in row-major order.
136    ///
137    /// `0.0` means no marching was performed (pixel is in shadow at step 0) or
138    /// the pixel was not marched.  `1.0` means the full `max_steps` were walked
139    /// without finding occlusion (pixel is lit after exhausting all steps).
140    pub depth: Vec<f32>,
141}
142
143// ─────────────────────────────────────────────────────────────────────────────
144// GPU uniform layout (must match WGSL `RayMarchUniforms`)
145// ─────────────────────────────────────────────────────────────────────────────
146
147/// WGSL-layout-compatible uniform struct for the ray-march kernel.
148///
149/// # WGSL memory layout notes
150///
151/// WGSL host-shareable uniform layout (spec §13.4.3) only inserts padding
152/// after a `vec3<f32>` when the following member itself requires alignment
153/// of 16 or greater (e.g. another `vec3<f32>` or a `mat`).  When the next
154/// member is a scalar like `f32`, it packs directly into the trailing 4
155/// bytes of the `vec3`'s 16-byte slot.  Hence `step_size` follows
156/// `light_dir` at offset 28, not 32.
157///
158/// | Offset | Size | Field                |
159/// |--------|------|----------------------|
160/// |  0     |  12  | view_dir             |
161/// | 12     |   4  | _pad0 (align next vec3 to 16) |
162/// | 16     |  12  | light_dir            |
163/// | 28     |   4  | step_size            |
164/// | 32     |   4  | max_steps            |
165/// | 36     |   4  | vertical_exaggeration|
166/// | 40     |   4  | pixel_size_world     |
167/// | 44     |   4  | width                |
168/// | 48     |   4  | height               |
169/// | 52     |  12  | _pad_end (align struct size to 16) |
170/// Total = 64 bytes (multiple of 16).
171#[repr(C, align(16))]
172#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
173struct RayMarchUniforms {
174    view_dir: [f32; 3],
175    _pad0: f32,
176    light_dir: [f32; 3],
177    step_size: f32,
178    max_steps: u32,
179    vertical_exaggeration: f32,
180    pixel_size_world: f32,
181    width: u32,
182    height: u32,
183    _pad_end: [u32; 3],
184}
185
186impl RayMarchUniforms {
187    fn from_config(cfg: &RayMarchConfig, width: u32, height: u32) -> Self {
188        Self {
189            view_dir: cfg.view_dir,
190            _pad0: 0.0,
191            light_dir: cfg.light_dir,
192            step_size: cfg.step_size,
193            max_steps: cfg.max_steps,
194            vertical_exaggeration: cfg.vertical_exaggeration,
195            pixel_size_world: cfg.pixel_size_world,
196            width,
197            height,
198            _pad_end: [0, 0, 0],
199        }
200    }
201}
202
203// ─────────────────────────────────────────────────────────────────────────────
204// DemRayMarcher
205// ─────────────────────────────────────────────────────────────────────────────
206
207/// GPU-accelerated DEM ray-marching kernel.
208///
209/// Construct once with [`DemRayMarcher::new`] and call [`DemRayMarcher::march`]
210/// for each DEM raster you wish to process.
211pub struct DemRayMarcher {
212    ctx: Arc<GpuContext>,
213    pipeline: Arc<wgpu::ComputePipeline>,
214    bind_group_layout: wgpu::BindGroupLayout,
215}
216
217impl DemRayMarcher {
218    /// Compile the ray-march compute shader and create the GPU pipeline.
219    ///
220    /// # Errors
221    ///
222    /// Returns a shader compilation or pipeline creation error when the WGSL
223    /// source cannot be compiled on the current device.
224    pub fn new(ctx: Arc<GpuContext>) -> GpuResult<Self> {
225        let shader_src = make_ray_march_shader_source();
226        let mut shader = WgslShader::new(shader_src, "main");
227        let shader_module = shader.compile(ctx.device())?;
228
229        // Bind group layout:
230        //   binding 0 — DEM storage buffer (read-only)
231        //   binding 1 — config uniform buffer
232        //   binding 2 — shaded output buffer (read-write)
233        //   binding 3 — depth  output buffer (read-write)
234        let bind_group_layout = create_compute_bind_group_layout(
235            ctx.device(),
236            &[
237                storage_buffer_layout(0, true),  // dem
238                uniform_buffer_layout(1),        // config
239                storage_buffer_layout(2, false), // shaded
240                storage_buffer_layout(3, false), // depth_buf
241            ],
242            Some("DemRayMarcher Bind Group Layout"),
243        )?;
244
245        let pipeline = ComputePipelineBuilder::new(ctx.device(), shader_module, "main")
246            .bind_group_layout(&bind_group_layout)
247            .label("DemRayMarcher Pipeline")
248            .build()?;
249
250        Ok(Self {
251            ctx,
252            pipeline: Arc::new(pipeline),
253            bind_group_layout,
254        })
255    }
256
257    /// Execute the ray-march kernel over the supplied DEM raster.
258    ///
259    /// # Arguments
260    ///
261    /// * `dem`    — flat row-major f32 elevations, length must equal `width * height`.
262    /// * `width`  — number of columns in the raster.
263    /// * `height` — number of rows in the raster.
264    /// * `config` — ray-marching parameters (validated before dispatch).
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if:
269    /// - `config.validate()` fails.
270    /// - `dem.len() != width * height`.
271    /// - Any GPU operation (buffer creation, dispatch, readback) fails.
272    pub fn march(
273        &self,
274        dem: &[f32],
275        width: u32,
276        height: u32,
277        config: &RayMarchConfig,
278    ) -> GpuResult<RayMarchResult> {
279        config.validate(width, height)?;
280
281        let n_pixels = (width as usize) * (height as usize);
282        if dem.len() != n_pixels {
283            return Err(GpuError::invalid_kernel_params(format!(
284                "DEM length {} does not match width*height = {}",
285                dem.len(),
286                n_pixels
287            )));
288        }
289
290        let device = self.ctx.device();
291        let queue = self.ctx.queue();
292
293        // ---- Build GPU buffers ----
294
295        let f32_size = std::mem::size_of::<f32>() as u64;
296        let dem_byte_size = (n_pixels as u64) * f32_size;
297        let out_byte_size = (n_pixels as u64) * f32_size;
298
299        // Align buffer sizes to COPY_BUFFER_ALIGNMENT (256 bytes).
300        let align = wgpu::COPY_BUFFER_ALIGNMENT;
301        let dem_aligned = ((dem_byte_size + align - 1) / align) * align;
302        let out_aligned = ((out_byte_size + align - 1) / align) * align;
303
304        // DEM input buffer
305        let dem_buf = device.create_buffer(&BufferDescriptor {
306            label: Some("DemRayMarcher dem_buf"),
307            size: dem_aligned,
308            usage: BufferUsages::STORAGE | BufferUsages::COPY_DST,
309            mapped_at_creation: false,
310        });
311        queue.write_buffer(&dem_buf, 0, bytemuck::cast_slice(dem));
312
313        // Config uniform buffer (64 bytes, aligned to 16)
314        let uniforms = RayMarchUniforms::from_config(config, width, height);
315        let uniform_bytes: &[u8] = bytemuck::bytes_of(&uniforms);
316        let uniform_size = std::mem::size_of::<RayMarchUniforms>() as u64;
317        // Uniform buffers must satisfy min-binding-size and COPY_BUFFER_ALIGNMENT
318        let uniform_aligned = ((uniform_size + align - 1) / align) * align;
319        let uniform_buf = device.create_buffer(&BufferDescriptor {
320            label: Some("DemRayMarcher uniform_buf"),
321            size: uniform_aligned,
322            usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
323            mapped_at_creation: false,
324        });
325        queue.write_buffer(&uniform_buf, 0, uniform_bytes);
326
327        // Shaded output buffer
328        let shaded_buf = device.create_buffer(&BufferDescriptor {
329            label: Some("DemRayMarcher shaded_buf"),
330            size: out_aligned,
331            usage: BufferUsages::STORAGE | BufferUsages::COPY_SRC,
332            mapped_at_creation: false,
333        });
334
335        // Depth output buffer
336        let depth_buf = device.create_buffer(&BufferDescriptor {
337            label: Some("DemRayMarcher depth_buf"),
338            size: out_aligned,
339            usage: BufferUsages::STORAGE | BufferUsages::COPY_SRC,
340            mapped_at_creation: false,
341        });
342
343        // Staging buffers for CPU readback
344        let shaded_staging = device.create_buffer(&BufferDescriptor {
345            label: Some("DemRayMarcher shaded_staging"),
346            size: out_aligned,
347            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
348            mapped_at_creation: false,
349        });
350        let depth_staging = device.create_buffer(&BufferDescriptor {
351            label: Some("DemRayMarcher depth_staging"),
352            size: out_aligned,
353            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
354            mapped_at_creation: false,
355        });
356
357        // ---- Bind group ----
358
359        let bind_group = device.create_bind_group(&BindGroupDescriptor {
360            label: Some("DemRayMarcher BindGroup"),
361            layout: &self.bind_group_layout,
362            entries: &[
363                BindGroupEntry {
364                    binding: 0,
365                    resource: dem_buf.as_entire_binding(),
366                },
367                BindGroupEntry {
368                    binding: 1,
369                    resource: uniform_buf.as_entire_binding(),
370                },
371                BindGroupEntry {
372                    binding: 2,
373                    resource: shaded_buf.as_entire_binding(),
374                },
375                BindGroupEntry {
376                    binding: 3,
377                    resource: depth_buf.as_entire_binding(),
378                },
379            ],
380        });
381
382        // ---- Encode and submit ----
383
384        let mut encoder = device.create_command_encoder(&CommandEncoderDescriptor {
385            label: Some("DemRayMarcher encoder"),
386        });
387
388        {
389            let mut cpass = encoder.begin_compute_pass(&ComputePassDescriptor {
390                label: Some("DemRayMarcher compute pass"),
391                timestamp_writes: None,
392            });
393            cpass.set_pipeline(&self.pipeline);
394            cpass.set_bind_group(0, &bind_group, &[]);
395
396            // Dispatch 16×16 workgroups to cover width × height pixels
397            let wg_x = (width + 15) / 16;
398            let wg_y = (height + 15) / 16;
399            cpass.dispatch_workgroups(wg_x, wg_y, 1);
400        }
401
402        encoder.copy_buffer_to_buffer(&shaded_buf, 0, &shaded_staging, 0, out_aligned);
403        encoder.copy_buffer_to_buffer(&depth_buf, 0, &depth_staging, 0, out_aligned);
404
405        queue.submit(Some(encoder.finish()));
406
407        // Map before poll: poll drives both submit completion and map callbacks.
408        let shaded_slice = shaded_staging.slice(..);
409        let (shaded_tx, shaded_rx) = std::sync::mpsc::sync_channel(1);
410        shaded_slice.map_async(wgpu::MapMode::Read, move |result| {
411            let _ = shaded_tx.send(result);
412        });
413
414        let depth_slice = depth_staging.slice(..);
415        let (depth_tx, depth_rx) = std::sync::mpsc::sync_channel(1);
416        depth_slice.map_async(wgpu::MapMode::Read, move |result| {
417            let _ = depth_tx.send(result);
418        });
419
420        device
421            .poll(wgpu::PollType::wait_indefinitely())
422            .map_err(|e| GpuError::execution_failed(format!("device poll failed: {e}")))?;
423
424        let shaded_vec =
425            finish_staging_f32_read(&shaded_staging, shaded_slice, shaded_rx, n_pixels)?;
426        let depth_vec = finish_staging_f32_read(&depth_staging, depth_slice, depth_rx, n_pixels)?;
427
428        Ok(RayMarchResult {
429            width,
430            height,
431            shaded: shaded_vec,
432            depth: depth_vec,
433        })
434    }
435}
436
437/// Complete a staging-buffer read whose `map_async` has already been
438/// scheduled and whose mapping callback has been driven by a preceding
439/// `device.poll(...)`.  This function only does `recv` + read + `unmap`.
440fn finish_staging_f32_read(
441    staging: &wgpu::Buffer,
442    slice: wgpu::BufferSlice<'_>,
443    rx: std::sync::mpsc::Receiver<Result<(), wgpu::BufferAsyncError>>,
444    count: usize,
445) -> GpuResult<Vec<f32>> {
446    rx.recv()
447        .map_err(|_| GpuError::buffer_mapping("staging channel closed unexpectedly"))?
448        .map_err(|e| GpuError::buffer_mapping(e.to_string()))?;
449
450    let mapped = slice.get_mapped_range();
451    let floats: &[f32] = bytemuck::cast_slice(&mapped[..count * 4]);
452    let out = floats.to_vec();
453    drop(mapped);
454    staging.unmap();
455    Ok(out)
456}
457
458// ─────────────────────────────────────────────────────────────────────────────
459// Pure-CPU helper: normalize3
460// ─────────────────────────────────────────────────────────────────────────────
461
462/// Normalise a 3-element vector to unit length.
463///
464/// Returns `[0.0, 0.0, 0.0]` when the input magnitude is less than `1e-10`.
465///
466/// # Examples
467///
468/// ```
469/// use oxigdal_gpu::normalize3;
470///
471/// let n = normalize3([3.0, 0.0, 0.0]);
472/// assert!((n[0] - 1.0).abs() < 1e-6);
473/// ```
474pub fn normalize3(v: [f32; 3]) -> [f32; 3] {
475    let mag = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
476    if mag < 1e-10 {
477        [0.0, 0.0, 0.0]
478    } else {
479        [v[0] / mag, v[1] / mag, v[2] / mag]
480    }
481}
482
483// ─────────────────────────────────────────────────────────────────────────────
484// Pure-CPU helper: sample_dem_bilinear
485// ─────────────────────────────────────────────────────────────────────────────
486
487/// Sample a DEM raster at a sub-pixel position using bilinear interpolation.
488///
489/// `fx` and `fy` are pixel-space coordinates with `(0.0, 0.0)` at the
490/// top-left corner of the first pixel.  Values outside the raster extent are
491/// clamped to the nearest valid pixel.
492///
493/// # Panics
494///
495/// Never panics; all index computations are bounds-checked.
496///
497/// # Examples
498///
499/// ```
500/// use oxigdal_gpu::sample_dem_bilinear;
501///
502/// // 2×2 raster: [1, 2; 3, 4]
503/// let dem = [1.0_f32, 2.0, 3.0, 4.0];
504/// assert_eq!(sample_dem_bilinear(&dem, 2, 2, 0.0, 0.0), 1.0);
505/// assert_eq!(sample_dem_bilinear(&dem, 2, 2, 1.0, 1.0), 4.0);
506/// ```
507pub fn sample_dem_bilinear(dem: &[f32], width: u32, height: u32, fx: f32, fy: f32) -> f32 {
508    let w = width as f32;
509    let h = height as f32;
510
511    // Clamp sample coordinates to the valid pixel-space range.
512    let cx = fx.clamp(0.0, w - 1.0);
513    let cy = fy.clamp(0.0, h - 1.0);
514
515    let x0 = cx.floor() as u32;
516    let y0 = cy.floor() as u32;
517
518    // Clamp index bounds to avoid out-of-bounds on the upper edge.
519    let x1 = (x0 + 1).min(width - 1);
520    let y1 = (y0 + 1).min(height - 1);
521
522    let tx = cx - x0 as f32; // fractional part in [0, 1]
523    let ty = cy - y0 as f32;
524
525    let w_u = width as usize;
526    let v00 = dem[y0 as usize * w_u + x0 as usize];
527    let v10 = dem[y0 as usize * w_u + x1 as usize];
528    let v01 = dem[y1 as usize * w_u + x0 as usize];
529    let v11 = dem[y1 as usize * w_u + x1 as usize];
530
531    // Bilinear interpolation: lerp in X first, then in Y.
532    let top = v00 + (v10 - v00) * tx;
533    let bot = v01 + (v11 - v01) * tx;
534    top + (bot - top) * ty
535}
536
537// ─────────────────────────────────────────────────────────────────────────────
538// Pure-CPU reference: ray_march_cpu
539// ─────────────────────────────────────────────────────────────────────────────
540
541/// CPU reference implementation of the DEM ray-march algorithm.
542///
543/// For each pixel `(px, py)` a ray originates at the surface point
544/// `(px, py, dem[py*width+px] * vert_exag)` and advances in the light
545/// direction by `step_size` per step.  If the terrain elevation sampled along
546/// the ray (bilinearly) exceeds the current ray height, the pixel is shadowed
547/// and the march stops early.  Otherwise the pixel is lit and receives a
548/// normalised depth of `1.0`.
549///
550/// This function never panics and never requires GPU access.
551pub fn ray_march_cpu(
552    dem: &[f32],
553    width: u32,
554    height: u32,
555    config: &RayMarchConfig,
556) -> RayMarchResult {
557    let n_pixels = (width as usize) * (height as usize);
558    let mut shaded = vec![1.0_f32; n_pixels];
559    let mut depth = vec![1.0_f32; n_pixels];
560
561    let ld = config.light_dir;
562    let step = config.step_size;
563    let vert = config.vertical_exaggeration;
564    let max_s = config.max_steps;
565
566    for py in 0..height {
567        for px in 0..width {
568            let idx = (py as usize) * (width as usize) + (px as usize);
569
570            let start_x = px as f32;
571            let start_y = py as f32;
572            let start_z = dem[idx] * vert;
573
574            let mut shadowed = false;
575            let mut last_step = max_s; // used to compute depth
576
577            for s in 1..=max_s {
578                let sf = s as f32;
579                let sample_x = start_x + ld[0] * step * sf;
580                let sample_y = start_y + ld[1] * step * sf;
581                let terrain_z = sample_dem_bilinear(dem, width, height, sample_x, sample_y) * vert;
582                let ray_z = start_z + ld[2] * step * sf;
583
584                if terrain_z > ray_z {
585                    shadowed = true;
586                    last_step = s;
587                    break;
588                }
589            }
590
591            if shadowed {
592                shaded[idx] = 0.0;
593                // Normalised depth: fraction of steps taken before occlusion.
594                depth[idx] = (last_step as f32) / (max_s as f32);
595            } else {
596                shaded[idx] = 1.0;
597                depth[idx] = 1.0;
598            }
599        }
600    }
601
602    RayMarchResult {
603        width,
604        height,
605        shaded,
606        depth,
607    }
608}
609
610// ─────────────────────────────────────────────────────────────────────────────
611// WGSL shader source generation
612// ─────────────────────────────────────────────────────────────────────────────
613
614/// Return the WGSL compute shader source for GPU DEM ray-marching.
615///
616/// The emitted shader:
617/// - Binds the DEM at `@group(0) @binding(0)` as a read-only storage buffer.
618/// - Binds the config struct at `@binding(1)` as a uniform buffer.
619/// - Binds the shadow-factor output at `@binding(2)` as a read-write storage buffer.
620/// - Binds the depth output at `@binding(3)` as a read-write storage buffer.
621/// - Uses `@workgroup_size(16, 16, 1)`.
622/// - Uses a `for` loop with early `break` instead of recursion (WGSL has no recursion).
623///
624/// The WGSL config struct layout matches `RayMarchUniforms` exactly.
625pub fn make_ray_march_shader_source() -> String {
626    r#"
627// ── Ray-march DEM shadow kernel ─────────────────────────────────────────────
628//
629// Bindings
630//   0  dem         : array<f32>  (read-only storage) — row-major DEM elevations
631//   1  config      : RayMarchUniforms (uniform)
632//   2  shaded      : array<f32>  (read-write storage) — 0=shadow, 1=lit
633//   3  depth_buf   : array<f32>  (read-write storage) — normalised march depth
634//
635// Workgroup: 16 × 16 × 1
636
637struct RayMarchUniforms {
638    view_dir              : vec3<f32>,
639    // _pad0 (4 bytes) implicitly added by vec3 alignment
640    light_dir             : vec3<f32>,
641    // step_size follows light_dir at offset 28 (no extra pad — WGSL only pads
642    // vec3 when the *next* member is itself align-≥-16, e.g. another vec3).
643    step_size             : f32,
644    max_steps             : u32,
645    vertical_exaggeration : f32,
646    pixel_size_world      : f32,
647    width                 : u32,
648    height                : u32,
649    // _pad_end (12 bytes) — aligns total struct size to 16
650}
651
652@group(0) @binding(0) var<storage, read>       dem       : array<f32>;
653@group(0) @binding(1) var<uniform>              config    : RayMarchUniforms;
654@group(0) @binding(2) var<storage, read_write>  shaded    : array<f32>;
655@group(0) @binding(3) var<storage, read_write>  depth_buf : array<f32>;
656
657// ── Bilinear sample of the DEM at fractional pixel coordinates ───────────────
658fn sample_dem(fx: f32, fy: f32) -> f32 {
659    let w  = f32(config.width);
660    let h  = f32(config.height);
661    let cx = clamp(fx, 0.0, w - 1.0);
662    let cy = clamp(fy, 0.0, h - 1.0);
663
664    let x0 = u32(cx);
665    let y0 = u32(cy);
666    let x1 = min(x0 + 1u, config.width  - 1u);
667    let y1 = min(y0 + 1u, config.height - 1u);
668
669    let tx = cx - f32(x0);
670    let ty = cy - f32(y0);
671
672    let v00 = dem[y0 * config.width + x0];
673    let v10 = dem[y0 * config.width + x1];
674    let v01 = dem[y1 * config.width + x0];
675    let v11 = dem[y1 * config.width + x1];
676
677    let top = v00 + (v10 - v00) * tx;
678    let bot = v01 + (v11 - v01) * tx;
679    return top + (bot - top) * ty;
680}
681
682@compute @workgroup_size(16, 16, 1)
683fn main(@builtin(global_invocation_id) id: vec3<u32>) {
684    let px = id.x;
685    let py = id.y;
686
687    // Discard threads that fall outside the raster bounds.
688    if px >= config.width || py >= config.height {
689        return;
690    }
691
692    let idx      = py * config.width + px;
693    let start_x  = f32(px);
694    let start_y  = f32(py);
695    let start_z  = dem[idx] * config.vertical_exaggeration;
696
697    let ld       = config.light_dir;
698    let step     = config.step_size;
699    let vert     = config.vertical_exaggeration;
700    let max_s    = config.max_steps;
701
702    var shadow_flag : f32 = 1.0;
703    var depth_val   : f32 = 1.0;
704
705    for (var s: u32 = 1u; s <= max_s; s = s + 1u) {
706        let sf       = f32(s);
707        let sample_x = start_x + ld.x * step * sf;
708        let sample_y = start_y + ld.y * step * sf;
709        let terrain_z = sample_dem(sample_x, sample_y) * vert;
710        let ray_z     = start_z + ld.z * step * sf;
711
712        if terrain_z > ray_z {
713            shadow_flag = 0.0;
714            depth_val   = f32(s) / f32(max_s);
715            break;
716        }
717    }
718
719    shaded[idx]    = shadow_flag;
720    depth_buf[idx] = depth_val;
721}
722"#
723    .to_string()
724}