Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ members = [
"crates/render_gpu",
"crates/profiling",
]
# The browser PoC builds standalone from web/ under its own nightly toolchain and
# `build-std` config (see web/.cargo/config.toml). Excluding it keeps native
# `cargo build --workspace` / CI from ever compiling it for the host target.
exclude = ["web"]
resolver = "2"

[package]
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ generate_windows_icon:
# compiled from crates.io; rustup ships llvm-tools as a component.
setup-local:
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov
rustup toolchain install nightly -c rust-src -t wasm32-unknown-unknown
cargo install cargo-llvm-cov trunk
@command -v jq >/dev/null 2>&1 || brew install jq

# Mirrors the `coverage` job in .github/workflows/main.yml. The `report`
Expand Down
9 changes: 9 additions & 0 deletions crates/dem_io/src/crs.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Seek};
use std::path::Path;
use tiff::decoder::Decoder;
use tiff::tags::Tag;
Expand Down Expand Up @@ -373,7 +374,15 @@ pub(crate) fn read_geo_key_data(path: &Path) -> Result<GeoKeyData, DemError> {
let file = File::open(path)?;
let mut decoder =
Decoder::new(std::io::BufReader::new(file)).map_err(|e| DemError::from(e.to_string()))?;
read_geo_key_data_from_decoder(&mut decoder)
}

/// Reader-based variant of [`read_geo_key_data`]. Lets the GeoTIFF parse path read the
/// CRS GeoKeys and the image from a single decoder (one byte source) — required for the
/// in-memory (`Cursor<Vec<u8>>`) wasm path where there is no file to re-open.
pub(crate) fn read_geo_key_data_from_decoder<R: Read + Seek>(
decoder: &mut Decoder<R>,
) -> Result<GeoKeyData, DemError> {
let raw = decoder
.get_tag(Tag::Unknown(34735))
.and_then(|v| v.into_u32_vec())
Expand Down
17 changes: 13 additions & 4 deletions crates/dem_io/src/geotiff.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::fs::File;
use std::io::{Read, Seek};
use std::path::Path;
use tiff::decoder::{Decoder, DecodingResult, Limits};
use tiff::tags::Tag;
Expand Down Expand Up @@ -29,7 +30,18 @@ pub fn geotiff_pixel_scale(path: &Path) -> f64 {
/// via proj4wkt (WKT from tag 34737) or crs-definitions (EPSG fallback).
/// No hardcoded CRS knowledge — works for any EPSG-registered projection.
pub fn parse_geotiff_auto(path: &Path) -> Result<Heightmap, DemError> {
let crs_data = crs::read_geo_key_data(path)?;
let file = File::open(path)?;
parse_geotiff_auto_reader(std::io::BufReader::new(file))
}

/// Reader-based variant of [`parse_geotiff_auto`] for callers that hold the GeoTIFF
/// bytes in memory rather than on disk (e.g. a browser file picker handing back a
/// `Cursor<Vec<u8>>`). CRS GeoKeys and pixel data are read from the same decoder so the
/// byte source is consumed exactly once. Behaviour is identical to the path variant.
pub fn parse_geotiff_auto_reader<R: Read + Seek>(reader: R) -> Result<Heightmap, DemError> {
let mut decoder = Decoder::new(reader)?.with_limits(Limits::unlimited());

let crs_data = crs::read_geo_key_data_from_decoder(&mut decoder)?;
let proj4 = crs::proj4_from_keys(&crs_data)?;
// For files whose CRS is encoded as inline GeoKeys (HMA mosaics, etc.) there
// is no single EPSG code that captures the projection. We still need a u32
Expand All @@ -40,9 +52,6 @@ pub fn parse_geotiff_auto(path: &Path) -> Result<Heightmap, DemError> {
.or(crs_data.geographic_epsg)
.unwrap_or(0);

let file = File::open(path)?;
let mut decoder = Decoder::new(std::io::BufReader::new(file))?.with_limits(Limits::unlimited());

let (cols, rows) = decoder.dimensions()?;

let scale = decoder.get_tag(Tag::Unknown(33550))?.into_f64_vec()?;
Expand Down
4 changes: 2 additions & 2 deletions crates/dem_io/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ mod overview;

pub use crs::get_tile_epsg;
pub use geotiff::{
extract_window, geotiff_pixel_scale, ifd_scales, parse_geotiff_auto, tile_bounds_wgs84,
tile_centre_crs,
extract_window, geotiff_pixel_scale, ifd_scales, parse_geotiff_auto,
parse_geotiff_auto_reader, tile_bounds_wgs84, tile_centre_crs,
};
pub use grid::{
assemble_grid, crop, load_grid_from_paths, stitch_windows, stitch_windows_geographic,
Expand Down
118 changes: 72 additions & 46 deletions crates/render_gpu/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,19 +122,29 @@ impl Default for GpuContext {
}

impl GpuContext {
/// Native constructor. Blocks on the async GPU init via pollster. On wasm the main
/// thread cannot block, so callers there must use [`GpuContext::new_async`] directly.
pub fn new() -> Self {
pollster::block_on(async {
let instance = wgpu::Instance::default();
pollster::block_on(Self::new_async())
}

/// Async GPU init shared by the native and browser entry points. Native callers reach
/// it through [`GpuContext::new`]; the wasm entry point awaits it inside `spawn_local`.
pub async fn new_async() -> Self {
let instance = wgpu::Instance::default();

// Enumerate all adapters and prefer discrete over integrated.
// Adapter selection. On native we enumerate all adapters and prefer a discrete
// GPU; on the WebGPU backend the browser hands back a single adapter via
// request_adapter, so enumeration is skipped.
#[cfg(not(target_arch = "wasm32"))]
let adapter = {
let adapters: Vec<wgpu::Adapter> =
instance.enumerate_adapters(wgpu::Backends::all()).await;
for a in &adapters {
let info = a.get_info();
println!(" [GPU] found: {} ({:?})", info.name, info.device_type);
}

let adapter = if let Some(discrete) = adapters
if let Some(discrete) = adapters
.into_iter()
.find(|a| a.get_info().device_type == wgpu::DeviceType::DiscreteGpu)
{
Expand All @@ -147,53 +157,69 @@ impl GpuContext {
})
.await
.expect("no GPU adapter found")
};
}
};
#[cfg(target_arch = "wasm32")]
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
..Default::default()
})
.await
.expect("no GPU adapter found");

let info = adapter.get_info();
let vram_class = VramClass::detect(&info);
println!(
" [GPU] selected: {} ({:?}) vram_class={:?}",
info.name, info.device_type, vram_class
);
let info = adapter.get_info();
let vram_class = VramClass::detect(&info);
println!(
" [GPU] selected: {} ({:?}) vram_class={:?}",
info.name, info.device_type, vram_class
);

// Request optional features that improve precision; only enable what the
// adapter actually supports so the build stays cross-platform.
let wanted = wgpu::Features::FLOAT32_FILTERABLE // R32Float + Linear sampler
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM; // Rg16Snorm normal textures
let enabled = adapter.features() & wanted;
// Request optional features that improve precision; only enable what the
// adapter actually supports so the build stays cross-platform.
let wanted = wgpu::Features::FLOAT32_FILTERABLE // R32Float + Linear sampler
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM; // Rg16Snorm normal textures
let enabled = adapter.features() & wanted;

let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
required_features: enabled,
required_limits: adapter.limits(),
..Default::default()
})
.await
.expect("failed to get device");
// Native: take the adapter's full limits (we raise the buffer-binding cap there).
// wasm/WebGPU: the browser exposes much smaller maximums than native and rejects
// adapter.limits() values it can't honour, so use the portable WebGPU defaults.
#[cfg(not(target_arch = "wasm32"))]
let required_limits = adapter.limits();
#[cfg(target_arch = "wasm32")]
let required_limits = wgpu::Limits::default();

// OOM safety net: wgpu's default behaviour on a failed allocation is
// to panic from a worker thread, which kills the whole process. The
// handler here logs the error and sets a flag that the viewer's
// frame loop polls to degrade gracefully (disable fine tier, then
// close tier) instead of crashing. Runs on wgpu's internal thread —
// no blocking, no allocation, just an atomic store.
device.on_uncaptured_error(Arc::new(|err: wgpu::Error| {
eprintln!("[GPU ERROR] {err:?}");
if matches!(err, wgpu::Error::OutOfMemory { .. }) {
OOM_OBSERVED.store(true, Ordering::SeqCst);
OOM_COUNT.fetch_add(1, Ordering::SeqCst);
}
}));
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
required_features: enabled,
required_limits,
..Default::default()
})
.await
.expect("failed to get device");

GpuContext {
instance,
device,
queue,
adapter_name: info.name,
adapter,
vram_class,
// OOM safety net: wgpu's default behaviour on a failed allocation is
// to panic from a worker thread, which kills the whole process. The
// handler here logs the error and sets a flag that the viewer's
// frame loop polls to degrade gracefully (disable fine tier, then
// close tier) instead of crashing. Runs on wgpu's internal thread —
// no blocking, no allocation, just an atomic store.
device.on_uncaptured_error(Arc::new(|err: wgpu::Error| {
eprintln!("[GPU ERROR] {err:?}");
if matches!(err, wgpu::Error::OutOfMemory { .. }) {
OOM_OBSERVED.store(true, Ordering::SeqCst);
OOM_COUNT.fetch_add(1, Ordering::SeqCst);
}
})
}));

GpuContext {
instance,
device,
queue,
adapter_name: info.name,
adapter,
vram_class,
}
}
}

Expand Down
60 changes: 60 additions & 0 deletions crates/render_gpu/tests/context_new.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Coverage for the public `GpuContext` constructors (`new` / `new_async` / `default`).
//!
//! The other GPU suites build a context by hand (`common::try_ctx`) to stay headless-safe,
//! so the real `GpuContext::new` device-creation path was never exercised. This file calls
//! it directly — but only after `try_ctx` confirms an adapter exists, so it still skips
//! cleanly on a headless runner and never hits the `.expect("no GPU adapter found")` panic.

mod common;

use render_gpu::{GpuContext, VramClass};

#[test]
fn new_builds_a_usable_context() {
// Probe for an adapter; skip headless. If this succeeds, `GpuContext::new` (which
// `.expect()`s an adapter) is guaranteed not to panic on the same default instance.
gpu_or_skip!(_probe);

let ctx = GpuContext::new();

assert!(
!ctx.adapter_name.is_empty(),
"adapter_name should be populated"
);
assert!(
matches!(
ctx.vram_class,
VramClass::Low | VramClass::Mid | VramClass::High
),
"vram_class must be a detected variant"
);

// The device is live: a trivial poll on a fresh context must not error.
let res = ctx.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
assert!(res.is_ok(), "device.poll on a fresh context should succeed");

// Clone is a cheap Arc bump that shares the same device (documented invariant).
let ctx2 = ctx.clone();
assert_eq!(ctx2.adapter_name, ctx.adapter_name);
}

#[test]
fn new_async_builds_a_usable_context() {
gpu_or_skip!(_probe);

// `new` is just `block_on(new_async())`; awaiting the async form directly covers it
// without the blocking wrapper (and is the entry point the wasm build uses).
let ctx = pollster::block_on(GpuContext::new_async());
assert!(!ctx.adapter_name.is_empty());
}

#[test]
fn default_constructs_via_new() {
gpu_or_skip!(_probe);

let ctx = GpuContext::default();
assert!(!ctx.adapter_name.is_empty());
}
33 changes: 33 additions & 0 deletions web/.cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Applies only when cargo/Trunk is invoked from web/ (cargo reads config from the
# invocation cwd upward). Native builds at the repo root are unaffected.

[target.wasm32-unknown-unknown]
rustflags = [
# SharedArrayBuffer threads: std must be built with atomics + shared memory, and so
# must every crate. wasm-bindgen-rayon and std::thread depend on these.
"-C", "target-feature=+atomics,+bulk-memory,+mutable-globals",
# This toolchain does not auto-configure wasm threading from +atomics, so spell it out:
# --shared-memory + --import-memory + --max-memory → one SharedArrayBuffer that every
# worker instantiation imports (so threads actually share memory);
# --export=__heap_base → wasm-bindgen needs this global exported to lay out per-thread
# stacks ("failed to find __heap_base for injecting thread id" without it).
"-C", "link-arg=--shared-memory",
"-C", "link-arg=--import-memory",
"-C", "link-arg=--max-memory=4294967296",
# wasm-bindgen's threading transform needs the heap base + the TLS symbols exported to
# set up per-thread stacks and thread-local storage. Missing any one aborts with
# "failed to find <symbol>".
"-C", "link-arg=--export=__heap_base",
"-C", "link-arg=--export=__wasm_init_tls",
"-C", "link-arg=--export=__tls_size",
"-C", "link-arg=--export=__tls_align",
"-C", "link-arg=--export=__tls_base",
]

[unstable]
# Rebuild std (with the atomics target-feature above) instead of using the prebuilt
# non-atomic std. Requires the nightly toolchain + rust-src component.
build-std = ["std", "panic_abort"]

[build]
target = "wasm32-unknown-unknown"
1 change: 1 addition & 0 deletions web/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
Loading
Loading