|
| 1 | +import numpy as np |
| 2 | +import numba as nb |
| 3 | + |
| 4 | +import cupy |
| 5 | +import xarray as xr |
| 6 | +from rtxpy import RTX |
| 7 | + |
| 8 | +from typing import Optional |
| 9 | + |
| 10 | +from scipy.spatial.transform import Rotation as R |
| 11 | + |
| 12 | +from raytrace.cuda_utils import * |
| 13 | +from raytrace import mesh_utils |
| 14 | + |
| 15 | +@nb.cuda.jit |
| 16 | +def _generatePrimaryRays(data, x_coords, y_coords, H, W): |
| 17 | + """ |
| 18 | + A GPU kernel that given a set of x and y discrete coordinates on a raster terrain |
| 19 | + generates in @data a list of parallel rays that represent camera rays generated from an ortographic camera |
| 20 | + that is looking straight down at the surface from an origin height 10000 |
| 21 | + """ |
| 22 | + i, j = nb.cuda.grid(2) |
| 23 | + if i>=0 and i < H and j>=0 and j < W: |
| 24 | + #data[i,j,0] = j + 1e-6 # x_coords[j] + 1e-6 |
| 25 | + #data[i,j,1] = i + 1e-6 # y_coords[i] + 1e-6 |
| 26 | + |
| 27 | + if (j == W-1): |
| 28 | + data[i,j,0] = j - 1e-3 |
| 29 | + else: |
| 30 | + data[i,j,0] = j + 1e-3 |
| 31 | + |
| 32 | + if (i == H-1): |
| 33 | + data[i,j,1] = i - 1e-3 |
| 34 | + else: |
| 35 | + data[i,j,1] = i + 1e-3 |
| 36 | + |
| 37 | + data[i,j,2] = 10000 # Location of the camera (height) |
| 38 | + data[i,j,3] = 1e-3 |
| 39 | + data[i,j,4] = 0 |
| 40 | + data[i,j,5] = 0 |
| 41 | + data[i,j,6] = -1 |
| 42 | + data[i,j,7] = np.inf |
| 43 | + |
| 44 | + |
| 45 | +def generatePrimaryRays(rays, x_coords, y_coords, H, W): |
| 46 | + griddim, blockdim = calc_dims((H, W)) |
| 47 | + _generatePrimaryRays[griddim, blockdim](rays, x_coords, y_coords, H, W) |
| 48 | + return 0 |
| 49 | + |
| 50 | + |
| 51 | +@nb.cuda.jit |
| 52 | +def _generateShadowRays(rays, hits, normals, H, W, sunDir): |
| 53 | + """ |
| 54 | + A GPU kernel that given a set rays and their respective intersection points, |
| 55 | + generates in rays (overwriting the original content) a new set of rays (shadow rays) |
| 56 | + That have their origins at the point of intersection of their parent ray and direction - the direction towards the sun |
| 57 | + The normals vectors at the point of intersection of the original rays are cached in @normals |
| 58 | + Thus we can later use them to do lambertian shading, after the shadow rays have been traced |
| 59 | + """ |
| 60 | + i, j = nb.cuda.grid(2) |
| 61 | + if i>=0 and i < H and j>=0 and j < W: |
| 62 | + dist = hits[i,j,0] |
| 63 | + norm = make_float3(hits[i,j], 1) |
| 64 | + if (norm[2] < 0): |
| 65 | + norm = invert(norm) |
| 66 | + ray = rays[i,j] |
| 67 | + rayOrigin = make_float3(ray, 0) |
| 68 | + rayDir = make_float3(ray, 4) |
| 69 | + p = add(rayOrigin, mul(rayDir,dist)) |
| 70 | + |
| 71 | + newOrigin = add(p, mul(norm, 1e-3)) |
| 72 | + ray[0] = newOrigin[0] |
| 73 | + ray[1] = newOrigin[1] |
| 74 | + ray[2] = newOrigin[2] |
| 75 | + ray[3] = 1e-3 |
| 76 | + ray[4] = sunDir[0] |
| 77 | + ray[5] = sunDir[1] |
| 78 | + ray[6] = sunDir[2] |
| 79 | + ray[7] = np.inf if dist > 0 else 0 |
| 80 | + |
| 81 | + normals[i,j,0] = norm[0] |
| 82 | + normals[i,j,1] = norm[1] |
| 83 | + normals[i,j,2] = norm[2] |
| 84 | + |
| 85 | +def generateShadowRays(rays, hits, normals, H, W, sunDir): |
| 86 | + griddim, blockdim = calc_dims((H, W)) |
| 87 | + _generateShadowRays[griddim, blockdim](rays, hits, normals, H, W, sunDir) |
| 88 | + return 0 |
| 89 | + |
| 90 | + |
| 91 | +@nb.cuda.jit |
| 92 | +def _shadeLambert(hits, normals, output, H, W, sunDir, castShadows): |
| 93 | + """ |
| 94 | + This kernel does a simple Lambertian shading |
| 95 | + The hits array contains the results of tracing the shadow rays through the scene. |
| 96 | + If the value in hits[x,y,0] is > 0, then a valid intersection occurred and that means that the point |
| 97 | + at location x,y is in shadow. |
| 98 | + The normals array stores the normal at the intersecion point of each camera ray |
| 99 | + We then use the information for light visibility and normal to apply Lambert's cosine law |
| 100 | + The final result is stored in output which is an RGB array |
| 101 | + """ |
| 102 | + i, j = nb.cuda.grid(2) |
| 103 | + if i>=0 and i < H and j>=0 and j < W: |
| 104 | + # Normal at the intersection of camera ray (i,j) with the scene |
| 105 | + norm = make_float3(normals[i,j], 0) |
| 106 | + |
| 107 | + # Below is same as existing algorithm without shadows and is OK with shadows. |
| 108 | + # Could be improved with a bit of antialiasing at edges of shadow??? |
| 109 | + |
| 110 | + light_dir = make_float3(sunDir, 0) # Might have to make it zero if back cull. |
| 111 | + cos_theta = dot(light_dir, norm) # light_dir and norm are already normalised. |
| 112 | + |
| 113 | + temp = (cos_theta + 1) / 2 |
| 114 | + |
| 115 | + if castShadows and hits[i, j, 0] >= 0: |
| 116 | + temp = temp / 2 |
| 117 | + |
| 118 | + if temp > 1: |
| 119 | + temp = 1 |
| 120 | + elif temp < 0: |
| 121 | + temp = 0 |
| 122 | + |
| 123 | + output[i, j] = temp |
| 124 | + |
| 125 | + |
| 126 | + |
| 127 | +def shadeLambert(hits, normals, output, H, W, sunDir, castShadows): |
| 128 | + griddim, blockdim = calc_dims((H, W)) |
| 129 | + _shadeLambert[griddim, blockdim](hits, normals, output, H, W, sunDir, castShadows) |
| 130 | + return 0 |
| 131 | + |
| 132 | + |
| 133 | +def getSunDir(angle_altitude, azimuth): |
| 134 | + """ |
| 135 | + Calculate the vector towards the sun based on sun altitude angle and azimuth |
| 136 | + """ |
| 137 | + north = (0,1,0) |
| 138 | + rx = R.from_euler('x', angle_altitude, degrees=True) |
| 139 | + rz = R.from_euler('z', azimuth+180, degrees=True) |
| 140 | + sunDir = rx.apply(north) |
| 141 | + sunDir = rz.apply(sunDir) |
| 142 | + return sunDir |
| 143 | + |
| 144 | + |
| 145 | +def hillshade_rt(raster: xr.DataArray, |
| 146 | + optix: RTX, |
| 147 | + shadows: bool = False, |
| 148 | + azimuth: int = 225, |
| 149 | + angle_altitude: int = 25, |
| 150 | + name: Optional[str] = 'hillshade') -> xr.DataArray: |
| 151 | + |
| 152 | + H,W = raster.shape |
| 153 | + sunDir = cupy.array(getSunDir(angle_altitude, azimuth)) |
| 154 | + |
| 155 | + #output = np.zeros((H,W,3), np.float32) |
| 156 | + |
| 157 | + # Device buffers |
| 158 | + d_rays = cupy.empty((H,W,8), np.float32) |
| 159 | + d_hits = cupy.empty((H,W,4), np.float32) |
| 160 | + d_aux = cupy.empty((H,W,3), np.float32) |
| 161 | + d_output = cupy.empty((H,W), np.float32) |
| 162 | + |
| 163 | + y_coords = cupy.array(raster.indexes.get('y').values) |
| 164 | + x_coords = cupy.array(raster.indexes.get('x').values) |
| 165 | + |
| 166 | + generatePrimaryRays(d_rays, x_coords, y_coords, H, W) |
| 167 | + device = cupy.cuda.Device(0) |
| 168 | + device.synchronize() |
| 169 | + res = optix.trace(d_rays, d_hits, W*H) |
| 170 | + |
| 171 | + generateShadowRays(d_rays, d_hits, d_aux, H, W, sunDir) |
| 172 | + if shadows: |
| 173 | + device.synchronize() |
| 174 | + res = optix.trace(d_rays, d_hits, W*H) |
| 175 | + |
| 176 | + shadeLambert(d_hits, d_aux, d_output, H, W, sunDir, shadows) |
| 177 | + |
| 178 | + if isinstance(raster.data, np.ndarray): |
| 179 | + output = cupy.asnumpy(d_output[:, :]) |
| 180 | + nanValue = np.nan |
| 181 | + else: |
| 182 | + output = d_output[:, :] |
| 183 | + nanValue = cupy.nan |
| 184 | + |
| 185 | + output[0, :] = nanValue |
| 186 | + output[-1, :] = nanValue |
| 187 | + output[:, 0] = nanValue |
| 188 | + output[:, -1] = nanValue |
| 189 | + |
| 190 | + hill = xr.DataArray(output, |
| 191 | + name=name, |
| 192 | + coords=raster.coords, |
| 193 | + dims=raster.dims, |
| 194 | + attrs=raster.attrs) |
| 195 | + return hill |
| 196 | + |
| 197 | +def hillshade_gpu(raster: xr.DataArray, |
| 198 | + shadows: bool = False, |
| 199 | + azimuth: int = 225, |
| 200 | + angle_altitude: int = 25, |
| 201 | + name: Optional[str] = 'hillshade') -> xr.DataArray: |
| 202 | + # Move the terrain to GPU for testing the GPU path |
| 203 | + if not isinstance(raster.data, cupy.ndarray): |
| 204 | + print("WARNING: raster.data is not a cupy array. Additional overhead will be incurred") |
| 205 | + H,W = raster.shape |
| 206 | + optix = RTX() |
| 207 | + |
| 208 | + datahash = np.uint64(hash(str(raster.data.get()))) |
| 209 | + optixhash = np.uint64(optix.getHash()) |
| 210 | + if (optixhash != datahash): |
| 211 | + numTris = (H - 1) * (W - 1) * 2 |
| 212 | + verts = cupy.empty(H * W * 3, np.float32) |
| 213 | + triangles = cupy.empty(numTris * 3, np.int32) |
| 214 | + |
| 215 | + # Generate a mesh from the terrain (buffers are on the GPU, so generation happens also on GPU) |
| 216 | + res = mesh_utils.triangulateTerrain(verts, triangles, raster) |
| 217 | + if res: |
| 218 | + raise ValueError("Failed to generate mesh from terrain. Error code:{}".format(res)) |
| 219 | + res = optix.build(datahash, verts, triangles) |
| 220 | + if res: |
| 221 | + raise ValueError("OptiX failed to build GAS with error code:{}".format(res)) |
| 222 | + #Clear some GPU memory that we no longer need |
| 223 | + verts = None |
| 224 | + triangles = None |
| 225 | + cupy.get_default_memory_pool().free_all_blocks() |
| 226 | + |
| 227 | + hill = hillshade_rt(raster, optix, azimuth=azimuth, angle_altitude=angle_altitude, shadows=shadows, name=name) |
| 228 | + return hill |
0 commit comments