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
451        .get_mapped_range()
452        .map_err(|e| GpuError::buffer_mapping(e.to_string()))?;
453    let floats: &[f32] = bytemuck::cast_slice(&mapped[..count * 4]);
454    let out = floats.to_vec();
455    drop(mapped);
456    staging.unmap();
457    Ok(out)
458}
459
460// ─────────────────────────────────────────────────────────────────────────────
461// Pure-CPU helper: normalize3
462// ─────────────────────────────────────────────────────────────────────────────
463
464/// Normalise a 3-element vector to unit length.
465///
466/// Returns `[0.0, 0.0, 0.0]` when the input magnitude is less than `1e-10`.
467///
468/// # Examples
469///
470/// ```
471/// use oxigdal_gpu::normalize3;
472///
473/// let n = normalize3([3.0, 0.0, 0.0]);
474/// assert!((n[0] - 1.0).abs() < 1e-6);
475/// ```
476pub fn normalize3(v: [f32; 3]) -> [f32; 3] {
477    let mag = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
478    if mag < 1e-10 {
479        [0.0, 0.0, 0.0]
480    } else {
481        [v[0] / mag, v[1] / mag, v[2] / mag]
482    }
483}
484
485// ─────────────────────────────────────────────────────────────────────────────
486// Pure-CPU helper: sample_dem_bilinear
487// ─────────────────────────────────────────────────────────────────────────────
488
489/// Sample a DEM raster at a sub-pixel position using bilinear interpolation.
490///
491/// `fx` and `fy` are pixel-space coordinates with `(0.0, 0.0)` at the
492/// top-left corner of the first pixel.  Values outside the raster extent are
493/// clamped to the nearest valid pixel.
494///
495/// # Panics
496///
497/// Never panics; all index computations are bounds-checked.
498///
499/// # Examples
500///
501/// ```
502/// use oxigdal_gpu::sample_dem_bilinear;
503///
504/// // 2×2 raster: [1, 2; 3, 4]
505/// let dem = [1.0_f32, 2.0, 3.0, 4.0];
506/// assert_eq!(sample_dem_bilinear(&dem, 2, 2, 0.0, 0.0), 1.0);
507/// assert_eq!(sample_dem_bilinear(&dem, 2, 2, 1.0, 1.0), 4.0);
508/// ```
509pub fn sample_dem_bilinear(dem: &[f32], width: u32, height: u32, fx: f32, fy: f32) -> f32 {
510    let w = width as f32;
511    let h = height as f32;
512
513    // Clamp sample coordinates to the valid pixel-space range.
514    let cx = fx.clamp(0.0, w - 1.0);
515    let cy = fy.clamp(0.0, h - 1.0);
516
517    let x0 = cx.floor() as u32;
518    let y0 = cy.floor() as u32;
519
520    // Clamp index bounds to avoid out-of-bounds on the upper edge.
521    let x1 = (x0 + 1).min(width - 1);
522    let y1 = (y0 + 1).min(height - 1);
523
524    let tx = cx - x0 as f32; // fractional part in [0, 1]
525    let ty = cy - y0 as f32;
526
527    let w_u = width as usize;
528    let v00 = dem[y0 as usize * w_u + x0 as usize];
529    let v10 = dem[y0 as usize * w_u + x1 as usize];
530    let v01 = dem[y1 as usize * w_u + x0 as usize];
531    let v11 = dem[y1 as usize * w_u + x1 as usize];
532
533    // Bilinear interpolation: lerp in X first, then in Y.
534    let top = v00 + (v10 - v00) * tx;
535    let bot = v01 + (v11 - v01) * tx;
536    top + (bot - top) * ty
537}
538
539// ─────────────────────────────────────────────────────────────────────────────
540// Pure-CPU reference: ray_march_cpu
541// ─────────────────────────────────────────────────────────────────────────────
542
543/// CPU reference implementation of the DEM ray-march algorithm.
544///
545/// For each pixel `(px, py)` a ray originates at the surface point
546/// `(px, py, dem[py*width+px] * vert_exag)` and advances in the light
547/// direction by `step_size` per step.  If the terrain elevation sampled along
548/// the ray (bilinearly) exceeds the current ray height, the pixel is shadowed
549/// and the march stops early.  Otherwise the pixel is lit and receives a
550/// normalised depth of `1.0`.
551///
552/// This function never panics and never requires GPU access.
553pub fn ray_march_cpu(
554    dem: &[f32],
555    width: u32,
556    height: u32,
557    config: &RayMarchConfig,
558) -> RayMarchResult {
559    let n_pixels = (width as usize) * (height as usize);
560    let mut shaded = vec![1.0_f32; n_pixels];
561    let mut depth = vec![1.0_f32; n_pixels];
562
563    let ld = config.light_dir;
564    let step = config.step_size;
565    let vert = config.vertical_exaggeration;
566    let max_s = config.max_steps;
567
568    for py in 0..height {
569        for px in 0..width {
570            let idx = (py as usize) * (width as usize) + (px as usize);
571
572            let start_x = px as f32;
573            let start_y = py as f32;
574            let start_z = dem[idx] * vert;
575
576            let mut shadowed = false;
577            let mut last_step = max_s; // used to compute depth
578
579            for s in 1..=max_s {
580                let sf = s as f32;
581                let sample_x = start_x + ld[0] * step * sf;
582                let sample_y = start_y + ld[1] * step * sf;
583                let terrain_z = sample_dem_bilinear(dem, width, height, sample_x, sample_y) * vert;
584                let ray_z = start_z + ld[2] * step * sf;
585
586                if terrain_z > ray_z {
587                    shadowed = true;
588                    last_step = s;
589                    break;
590                }
591            }
592
593            if shadowed {
594                shaded[idx] = 0.0;
595                // Normalised depth: fraction of steps taken before occlusion.
596                depth[idx] = (last_step as f32) / (max_s as f32);
597            } else {
598                shaded[idx] = 1.0;
599                depth[idx] = 1.0;
600            }
601        }
602    }
603
604    RayMarchResult {
605        width,
606        height,
607        shaded,
608        depth,
609    }
610}
611
612// ─────────────────────────────────────────────────────────────────────────────
613// WGSL shader source generation
614// ─────────────────────────────────────────────────────────────────────────────
615
616/// Return the WGSL compute shader source for GPU DEM ray-marching.
617///
618/// The emitted shader:
619/// - Binds the DEM at `@group(0) @binding(0)` as a read-only storage buffer.
620/// - Binds the config struct at `@binding(1)` as a uniform buffer.
621/// - Binds the shadow-factor output at `@binding(2)` as a read-write storage buffer.
622/// - Binds the depth output at `@binding(3)` as a read-write storage buffer.
623/// - Uses `@workgroup_size(16, 16, 1)`.
624/// - Uses a `for` loop with early `break` instead of recursion (WGSL has no recursion).
625///
626/// The WGSL config struct layout matches `RayMarchUniforms` exactly.
627pub fn make_ray_march_shader_source() -> String {
628    r#"
629// ── Ray-march DEM shadow kernel ─────────────────────────────────────────────
630//
631// Bindings
632//   0  dem         : array<f32>  (read-only storage) — row-major DEM elevations
633//   1  config      : RayMarchUniforms (uniform)
634//   2  shaded      : array<f32>  (read-write storage) — 0=shadow, 1=lit
635//   3  depth_buf   : array<f32>  (read-write storage) — normalised march depth
636//
637// Workgroup: 16 × 16 × 1
638
639struct RayMarchUniforms {
640    view_dir              : vec3<f32>,
641    // _pad0 (4 bytes) implicitly added by vec3 alignment
642    light_dir             : vec3<f32>,
643    // step_size follows light_dir at offset 28 (no extra pad — WGSL only pads
644    // vec3 when the *next* member is itself align-≥-16, e.g. another vec3).
645    step_size             : f32,
646    max_steps             : u32,
647    vertical_exaggeration : f32,
648    pixel_size_world      : f32,
649    width                 : u32,
650    height                : u32,
651    // _pad_end (12 bytes) — aligns total struct size to 16
652}
653
654@group(0) @binding(0) var<storage, read>       dem       : array<f32>;
655@group(0) @binding(1) var<uniform>              config    : RayMarchUniforms;
656@group(0) @binding(2) var<storage, read_write>  shaded    : array<f32>;
657@group(0) @binding(3) var<storage, read_write>  depth_buf : array<f32>;
658
659// ── Bilinear sample of the DEM at fractional pixel coordinates ───────────────
660fn sample_dem(fx: f32, fy: f32) -> f32 {
661    let w  = f32(config.width);
662    let h  = f32(config.height);
663    let cx = clamp(fx, 0.0, w - 1.0);
664    let cy = clamp(fy, 0.0, h - 1.0);
665
666    let x0 = u32(cx);
667    let y0 = u32(cy);
668    let x1 = min(x0 + 1u, config.width  - 1u);
669    let y1 = min(y0 + 1u, config.height - 1u);
670
671    let tx = cx - f32(x0);
672    let ty = cy - f32(y0);
673
674    let v00 = dem[y0 * config.width + x0];
675    let v10 = dem[y0 * config.width + x1];
676    let v01 = dem[y1 * config.width + x0];
677    let v11 = dem[y1 * config.width + x1];
678
679    let top = v00 + (v10 - v00) * tx;
680    let bot = v01 + (v11 - v01) * tx;
681    return top + (bot - top) * ty;
682}
683
684@compute @workgroup_size(16, 16, 1)
685fn main(@builtin(global_invocation_id) id: vec3<u32>) {
686    let px = id.x;
687    let py = id.y;
688
689    // Discard threads that fall outside the raster bounds.
690    if px >= config.width || py >= config.height {
691        return;
692    }
693
694    let idx      = py * config.width + px;
695    let start_x  = f32(px);
696    let start_y  = f32(py);
697    let start_z  = dem[idx] * config.vertical_exaggeration;
698
699    let ld       = config.light_dir;
700    let step     = config.step_size;
701    let vert     = config.vertical_exaggeration;
702    let max_s    = config.max_steps;
703
704    var shadow_flag : f32 = 1.0;
705    var depth_val   : f32 = 1.0;
706
707    for (var s: u32 = 1u; s <= max_s; s = s + 1u) {
708        let sf       = f32(s);
709        let sample_x = start_x + ld.x * step * sf;
710        let sample_y = start_y + ld.y * step * sf;
711        let terrain_z = sample_dem(sample_x, sample_y) * vert;
712        let ray_z     = start_z + ld.z * step * sf;
713
714        if terrain_z > ray_z {
715            shadow_flag = 0.0;
716            depth_val   = f32(s) / f32(max_s);
717            break;
718        }
719    }
720
721    shaded[idx]    = shadow_flag;
722    depth_buf[idx] = depth_val;
723}
724"#
725    .to_string()
726}