Build GPU rtx mesh with real x/y resolution (#2861)#2887
Open
brendancol wants to merge 2 commits into
Open
Conversation
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.
brendancol
commented
Jun 2, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
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(...)unpacksscale, 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_resolutionreadsindex.valuesand 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_resolutionguardsn <= 1and returns 1.0, which removes a latent divide-by-zero in_viewshed_rt. The old code didew_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_rtisn't tested directly on the GPU path. The CPU path already hastest_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
brendancol
commented
Jun 2, 2026
Contributor
Author
brendancol
left a comment
There was a problem hiding this comment.
Follow-up review (after 8c20f05)
Suggestion -- fixed
hillshade.pynow 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_resolutionis 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sew_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.(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.max(H, W) / maxH). It must not depend onew_res/ns_res, or it would cancel the x/y stretch.create_triangulationnow 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:
ew_res/ns_resdownstream.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_resolutionasserts vertices land at(col*ew_res, row*ns_res).test_create_triangulation_returns_resolutioncovers the new return tuple.test_viewshed_gpu_anisotropic_matches_cpu(both anisotropy orientations) checks GPU vs CPU on an anisotropic raster.has_rtx()and skip without a CUDA / rtxpy device.