Skip to content

Build GPU rtx mesh with real x/y resolution (#2861)#2887

Open
brendancol wants to merge 2 commits into
mainfrom
issue-2861
Open

Build GPU rtx mesh with real x/y resolution (#2861)#2887
brendancol wants to merge 2 commits into
mainfrom
issue-2861

Conversation

@brendancol
Copy link
Copy Markdown
Contributor

Summary

Closes #2861.

The GPU ray-tracing mesh in create_triangulation (xrspatial/gpu_rtx/mesh_utils.py) placed vertices at integer grid indices and ignored the raster's ew_res/ns_res, while the CPU viewshed sweep works in real-world units. This builds the mesh in real-resolution coordinates so the GPU geometry matches the CPU coordinate convention.

  • Mesh vertices now sit at (col * ew_res, row * ns_res), and the camera rays in both the viewshed and hillshade RTX paths start at the matching real-world positions.
  • The z-scale stays resolution-independent (max(H, W) / maxH). It must not depend on ew_res/ns_res, or it would cancel the x/y stretch.
  • create_triangulation now returns (scale, ew_res, ns_res) so callers can cast rays at the matching positions.

Investigation note on impact

While implementing this I checked the end-to-end effect, and it's smaller than the issue implies. The issue measured 105/225 cells disagreeing between GPU and CPU on an anisotropic raster. That disagreement isn't caused by the mesh ignoring x/y resolution. It comes from the separate z-scale heuristic that flattens the mesh.

Two things explain why the mesh x/y fix alone doesn't change the viewshed result:

  1. Ray-triangle occlusion is invariant under a per-axis (linear) scaling of the mesh. Stretching the mesh in y by a constant maps lines to lines and preserves which triangles a ray hits, so the set of occluded cells doesn't change.
  2. The viewshed output angle is already computed from the real ew_res/ns_res downstream.

I confirmed this empirically: across several anisotropic varied-terrain rasters the GPU output is byte-identical before and after the change (max abs diff 0.0, 0 occlusion flips), and isotropic hillshade output is byte-identical too. The CPU sweep is likewise occlusion-invariant to anisotropic resolution along any direction.

So this PR removes a latent geometry inconsistency and makes the GPU mesh trace the same shape the CPU works on, rather than fixing a numeric divergence. The larger GPU-vs-CPU difference on varied terrain is the z-scale heuristic, which is out of scope here. Happy to file a follow-up if maintainers want that looked at.

Backend coverage

GPU only (cupy and dask+cupy route through the same RTX path). The numpy and dask+numpy viewshed backends don't use this mesh.

Test plan

  • test_cell_resolution_* cover anisotropic coords and the no-coords unit fallback.
  • test_triangulate_places_vertices_at_real_resolution asserts vertices land at (col*ew_res, row*ns_res).
  • test_create_triangulation_returns_resolution covers the new return tuple.
  • test_viewshed_gpu_anisotropic_matches_cpu (both anisotropy orientations) checks GPU vs CPU on an anisotropic raster.
  • Existing viewshed, hillshade, and mesh-guard tests pass.
  • GPU tests are gated on has_rtx() and skip without a CUDA / rtxpy device.

create_triangulation placed mesh vertices at integer grid indices and
ignored ew_res/ns_res, while the CPU viewshed sweep works in real-world
units. That left the GPU mesh geometry inconsistent with the CPU
coordinate convention on anisotropic rasters.

Mesh vertices now use (col*ew_res, row*ns_res), and the camera rays in
both the viewshed and hillshade RTX paths start at the matching
real-world positions. The z-scale stays resolution-independent
(max(H,W)/maxH) so it doesn't cancel the x/y stretch.
create_triangulation now returns (scale, ew_res, ns_res).

Note: ray-triangle occlusion is invariant under per-axis linear scaling,
and the viewshed output angle already uses the real resolution, so the
end-to-end viewshed result is unchanged. This removes a latent geometry
inconsistency rather than fixing a numeric divergence; the larger
GPU-vs-CPU difference on varied terrain comes from the separate z-scale
heuristic. Isotropic hillshade output is byte-identical.

Adds tests for resolution-aware mesh vertices and GPU-vs-CPU viewshed
parity on anisotropic flat terrain.
@github-actions github-actions Bot added the performance PR touches performance-sensitive code label Jun 2, 2026
Copy link
Copy Markdown
Contributor Author

@brendancol brendancol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review: Build GPU rtx mesh with real x/y resolution (#2861)

Blockers (must fix before merge)

None.

Suggestions (should fix, not blocking)

  • xrspatial/gpu_rtx/hillshade.py:203 -- scale, ew_res, ns_res = create_triangulation(...) unpacks scale, but hillshade never uses it (only viewshed scales observer/target elev by it). Harmless, but unpacking to _ (_, ew_res, ns_res = ...) would signal that intent. Minor.

Nits (optional improvements)

  • xrspatial/gpu_rtx/mesh_utils.py:14 -- _axis_resolution reads index.values and assumes evenly spaced coordinates (first and last divided by n-1). That matches how the CPU viewshed computes its own ew_res/ns_res, so the two are consistent, but neither path detects an irregular coordinate axis. Not introduced here, just flagging the shared assumption.

What looks good

  • The new _axis_resolution guards n <= 1 and returns 1.0, which removes a latent divide-by-zero in _viewshed_rt. The old code did ew_res = (x_range[1]-x_range[0])/(W-1) and would have raised on a single-column GPU raster. Nice incidental fix.
  • The z-scale stays resolution-independent, and the docstring explains why a resolution-dependent z-scale would cancel the x/y stretch. That's the subtle part and it's documented well.
  • The sub-cell ray nudge is scaled by ew_res/ns_res so it stays sub-cell once the coordinates are in real-world units. Easy to miss.
  • The PR is upfront that the end-to-end viewshed result doesn't change (occlusion is invariant under per-axis linear scaling), and the byte-identical before/after check backs that up.
  • Hillshade and viewshed both go through the shared mesh, and both ray generators were updated together, so the two paths stay in sync.

Test coverage notes

  • Mesh-level tests assert the vertices land at (colew_res, rowns_res) and cover the no-coords unit fallback. The end-to-end GPU-vs-CPU test covers both anisotropy orientations.
  • The single-column (W=1) divide-by-zero fix in _viewshed_rt isn't tested directly on the GPU path. The CPU path already has test_viewshed_single_row_or_column; a GPU analogue would lock in the incidental fix, but it needs a CUDA device and the payoff is small.

Checklist

  • Algorithm matches reference/convention (mesh now matches CPU coordinate units)
  • Backends consistent (GPU viewshed + hillshade share the mesh, both updated)
  • NaN handling unchanged (all-zero/all-NaN guard preserved)
  • Edge cases: single-column divide-by-zero now guarded
  • Dask chunk boundaries -- n/a (GPU mesh path isn't chunked)
  • No premature materialization or extra copies
  • Benchmark -- n/a (no perf-sensitive change; output byte-identical)
  • README feature matrix -- n/a (no new public API)
  • Docstrings present and accurate

Copy link
Copy Markdown
Contributor Author

@brendancol brendancol left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up review (after 8c20f05)

Suggestion -- fixed

  • hillshade.py now unpacks the unused z-scale to _ with a one-line comment about why hillshade ignores it. Hillshade GPU tests still pass.

Nit -- dismissed (with reason)

  • The even-spacing assumption in _axis_resolution is pre-existing: the CPU viewshed computes ew_res/ns_res the same way (first/last over n-1), so the GPU and CPU paths stay consistent. Detecting irregular coordinate axes would be a separate change across both backends, out of scope for this PR. Leaving it as-is.

No new findings. Geometry, ray generation, and the resolution-independent z-scale all look correct, and the end-to-end viewshed result stays byte-identical as documented.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

performance PR touches performance-sensitive code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GPU viewshed ignores x/y resolution: anisotropic rasters diverge from CPU

1 participant