|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: "Neural Graphics in an Afternoon" |
| 4 | +date: 2025-04-04 17:00:00 |
| 5 | +categories: [ "blog" ] |
| 6 | +tags: [slang] |
| 7 | +author: "Shannon Woods, NVIDIA, Slang Working Group Chair" |
| 8 | +image: /images/posts/2025-04-04-splatterjeep.webp |
| 9 | +human_date: "April 4, 2025" |
| 10 | +--- |
| 11 | + |
| 12 | +The intersection of computer graphics and machine learning is creating exciting new possibilities, from scene reconstruction with NeRFs and Gaussian splats to learning complex material properties. But getting started with neural graphics can seem daunting. Between understanding graphics APIs, shader programming, and automatic differentiation, there’s a lot to learn. That’s why the Slang team is introducing [SlangPy](https://slangpy.shader-slang.org/en/latest/), a new Python package that makes it dramatically easier to build neural graphics applications with Slang. With just a few lines of Python code, you can now: |
| 13 | + |
| 14 | +- Seamlessly call Slang functions on the GPU from Python |
| 15 | +- Leverage automatic differentiation without writing complex derivative code |
| 16 | +- Eliminate graphics API boilerplate and reduce potential bugs |
| 17 | +- Integrate with popular ML frameworks like PyTorch |
| 18 | +- Rapidly prototype and experiment with neural graphics techniques |
| 19 | + |
| 20 | +In this article, I’ll show you how to write your first neural graphics program with Slang and SlangPy by walking through our 2D Gaussian Splatting example. |
| 21 | + |
| 22 | +## Example: 2D Gaussian Splatting |
| 23 | + |
| 24 | +Our concrete example, which you can see in action on the [Slang playground](https://shader-slang.org/slang-playground/?demo=gsplat2d-diff.slang), uses 2D Gaussian splats (think of them as fuzzy circular blobs of color) to represent an image. Each splat has properties for: |
| 25 | + |
| 26 | +- Position (where it's centered) |
| 27 | +- Sigma (how fuzzy/spread out it is) |
| 28 | +- Color |
| 29 | + |
| 30 | +The challenge is: how do we determine the right parameters for thousands of splats to recreate a specific image? To do this, we can use a technique common in machine learning called gradient descent. Gradient descent can be used to find an optimal solution to a problem by making small adjustments to its inputs and checking whether they bring the result closer to our desired output. The basic idea is that we start with random splat properties, and define a “loss function”, which measures how different the resulting image is from what we want it to be, and then use gradient descent to adjust the splat properties until the difference is minimized. |
| 31 | + |
| 32 | +## The Challenge: Computing Gradients |
| 33 | + |
| 34 | +That's where things get a little tricky. There’s a mathematical operation to express how a function changes as you change one of its inputs– the derivative. A gradient is a collection of partial derivatives of a function with respect to each of its input parameters. If that sounds scary: don’t worry, Slang is here to help! |
| 35 | + |
| 36 | +Without Slang, calculating derivatives of our loss function with respect to every parameter can get very laborious. For complex graphics operations, this means: |
| 37 | + |
| 38 | +- Writing both the function itself, and a corresponding function (the derivative of the original) which calculates the gradients. These are referred to as the “forward” and “backward” forms of the function. |
| 39 | +- Making sure that any changes made to the original (forward) form of the function are also done correctly to its differential (backward) form. |
| 40 | +- Actually doing the derivatives, which can get extremely complex for an arbitrary shader function |
| 41 | + |
| 42 | +Slang makes this entire process much easier, because it can automatically calculate the backward form of your shader functions for you. You can take advantage of the power of gradient descent without having to wade hip-deep (or even dip your toes) into calculus. |
| 43 | + |
| 44 | +## The Code |
| 45 | + |
| 46 | +Let’s take a look at what it looks like to do this in the code. I’ll first go through a simplified version of the 2D Gaussian splatting example, so it’s very clear how the mechanism works. You can find this example in the SlangPy repository [here](https://github.com/shader-slang/slangpy/tree/main/examples/simplified-splatting). First, we’ll check out the Python side of things. With SlangPy, this code is pretty succinct. |
| 47 | + |
| 48 | +```Python |
| 49 | +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| 50 | + |
| 51 | +import slangpy as spy |
| 52 | +import sgl |
| 53 | +import pathlib |
| 54 | +import imageio |
| 55 | +import numpy as np |
| 56 | + |
| 57 | +# Create an SGL device, which will handle setup and invocation of the Slang |
| 58 | +# compiler for us. We give it both the slangpy PATH and the local include |
| 59 | +# PATH so that it can find Slang shader files |
| 60 | +device = sgl.Device(compiler_options={ |
| 61 | + "include_paths": [ |
| 62 | + spy.SHADER_PATH, |
| 63 | + pathlib.Path(__file__).parent.absolute(), |
| 64 | + ], |
| 65 | +}) |
| 66 | + |
| 67 | +# Load our Slang module -- we'll take a look at this in just a moment |
| 68 | +module = spy.Module.load_from_file(device, "simplediffsplatting2d.slang") |
| 69 | + |
| 70 | +# Create a buffer to store Gaussian blobs. We're going to make a very small one, |
| 71 | +# because right now this code is not very efficient, and will take a while to run. |
| 72 | +# For now, we are going to create 200 blobs, and each blob will be comprised of 9 |
| 73 | +# floats: |
| 74 | +# blob center x and y (2 floats) |
| 75 | +# sigma (a 2x2 covariance matrix - 4 floats) |
| 76 | +# color (3 floats) |
| 77 | +NUM_BLOBS = 200 |
| 78 | +FLOATS_PER_BLOB = 9 |
| 79 | +# SlangPy lets us create a Tensor and initialize it easily using numpy to generate |
| 80 | +# random values. This Tensor includes storage for gradients, because we call .with_grads() |
| 81 | +# on the created spy.Tensor. |
| 82 | +blobs = spy.Tensor.numpy(device, np.random.rand( |
| 83 | + NUM_BLOBS * FLOATS_PER_BLOB).astype(np.float32)).with_grads() |
| 84 | + |
| 85 | +# Load our target image from a file, using the imageio package, |
| 86 | +# and store its width and height in W, H |
| 87 | +image = imageio.imread("./jeep.jpg") |
| 88 | +W = image.shape[0] |
| 89 | +H = image.shape[1] |
| 90 | + |
| 91 | +# Convert the image from RGB_u8 to RGBA_f32 -- we're going |
| 92 | +# to be using texture values during derivative propagation, |
| 93 | +# so we need to be dealing with floats here. |
| 94 | +image = (image / 256.0).astype(np.float32) |
| 95 | +image = np.concatenate([image, np.ones((W, H, 1), dtype=np.float32)], axis=-1) |
| 96 | +input_image = device.create_texture( |
| 97 | + data=image, |
| 98 | + width=W, |
| 99 | + height=H, |
| 100 | + format=sgl.Format.rgba32_float, |
| 101 | + usage=sgl.ResourceUsage.shader_resource) |
| 102 | + |
| 103 | +# Create a per_pixel_loss Tensor to hold the calculated loss, and create gradient storage |
| 104 | +per_pixel_loss = spy.Tensor.empty(device, dtype=module.float4, shape=(W, H)) |
| 105 | +per_pixel_loss = per_pixel_loss.with_grads() |
| 106 | +# Set per-pixel loss' derivative to 1 (using a 1-line function in the slang file) |
| 107 | +module.ones(per_pixel_loss.grad_in) |
| 108 | + |
| 109 | +# Create storage for the Adam update moments |
| 110 | +# The Adam optimization algorithm helps us update the inputs to the function being optimized |
| 111 | +# in an efficient manner. It stores two "moments": the first is a moving average of the |
| 112 | +# of the gradient of the loss function. The second is a moving average of the squares of these |
| 113 | +# gradients. This allows us to "step" in the desired direction while maintaining momentum toward |
| 114 | +# the goal |
| 115 | +adam_first_moment = spy.Tensor.zeros_like(blobs) |
| 116 | +adam_second_moment = spy.Tensor.zeros_like(blobs) |
| 117 | + |
| 118 | +# Pre-allocate a texture to send data to tev occasionally. |
| 119 | +current_render = device.create_texture( |
| 120 | + width=W, |
| 121 | + height=H, |
| 122 | + format=sgl.Format.rgba32_float, |
| 123 | + usage=sgl.ResourceUsage.shader_resource | sgl.ResourceUsage.unordered_access) |
| 124 | + |
| 125 | +iterations = 10000 |
| 126 | +for iter in range(iterations): |
| 127 | + # Back-propagage the unit per-pixel loss with auto-diff. |
| 128 | + module.perPixelLoss.bwds(per_pixel_loss, |
| 129 | + spy.grid(shape=(input_image.width,input_image.height)), |
| 130 | + blobs, input_image) |
| 131 | + |
| 132 | + # Update the parameters using the Adam algorithm |
| 133 | + module.adamUpdate(blobs, blobs.grad_out, adam_first_moment, adam_second_moment) |
| 134 | + |
| 135 | + # Every 50 iterations, render the blobs out to a texture, and hand it off to tev |
| 136 | + # so that you can visualize the iteration towards ideal |
| 137 | + if iter % 50 == 0: |
| 138 | + module.renderBlobsToTexture(current_render, |
| 139 | + blobs, |
| 140 | + spy.grid(shape=(input_image.width,input_image.height))) |
| 141 | + sgl.tev.show_async(current_render, name=f"optimization_{(iter // 50):03d}") |
| 142 | + |
| 143 | +``` |
| 144 | + |
| 145 | +This is the entire Python file for setting up, initializing a set of 2D Gaussian blobs, and kicking off the derivative propagation that calculates the ideal values for all those blob parameters. The setup should be fairly straightforward and explained by the comments, so let’s take a closer look at the “meat” of this file, iterating through our gradient descent. |
| 146 | + |
| 147 | +```Python |
| 148 | +iterations = 10000 |
| 149 | +for iter in range(iterations): |
| 150 | + # Back-propagage the unit per-pixel loss with auto-diff. |
| 151 | + module.perPixelLoss.bwds(per_pixel_loss, |
| 152 | + spy.grid(shape=(input_image.width,input_image.height)), |
| 153 | + blobs, input_image) |
| 154 | +``` |
| 155 | + |
| 156 | +What the `module.perPixelLoss.bwds()` call is doing is going into the Slang module we loaded above, finding the `perPixelLoss()` function defined within it, and invoking the backwards differential form. The parameters we pass are: |
| 157 | + |
| 158 | +- `per_pixel_loss` - A tensor we created to store the loss value for each pixel of the calculated image |
| 159 | +- `spy.grid(shape=(input_image.width, input_image.height))` - This is part of what makes SlangPy so helpful. Much like the thread ID of a traditional compute kernel, SlangPy has a way for your kernel to know what thread it’s operating on in the context of the full dispatch. But what makes it especially handy for ML use cases is that Slang’s generator functions support arbitrary dimensionality, as opposed to the 3D-maximum in most traditional compute paradigms. There are [several generator methods](https://slangpy.shader-slang.org/en/latest/generators.html) provided by SlangPy; `grid()` is the one we want here because we can be explicit about the shape of the work we’re dispatching. We’re computing the values of a width x height image, and so we want to consider our compute threads in that context, so we provide those values to the grid function, and it will generate appropriate identifier information for each of the invocations of the kernel. |
| 160 | +- `blobs` - The tensor full of all the blob parameters, which also has storage for gradients associated with each of the blobs. Those gradients will give us the information we need to know which direction to adjust each parameters to get closer to our desired target output. |
| 161 | +- `input_image` - The target image that we’re trying to get our blobs to look like |
| 162 | + |
| 163 | +When this call finishes, per_pixel_loss will contain values representing the results of the loss function for each pixel based on the “calculated image” that results from all of our current blob parameters, and blobs will have a gradient associated with each blob, indicating which direction the parameters should move in order to get closer to the target. The input image will be unchanged. |
| 164 | + |
| 165 | +```Python |
| 166 | + # Update the parameters using the Adam algorithm |
| 167 | + module.adamUpdate(blobs, blobs.grad_out, adam_first_moment, adam_second_moment) |
| 168 | +``` |
| 169 | + |
| 170 | +This line calls into a Slang function in our module which provides an [optimized algorithm](https://optimization.cbe.cornell.edu/index.php?title=Adam) for updating our blobs based on the information stored in the blob gradients. It calculates moving averages of these gradients, so that we can update our blob parameters efficiently. You can read more about how Adam works in [the paper](https://arxiv.org/pdf/1412.6980) that introduced it, and you’ll see the implementation in our Slang module in a moment. Don’t worry– it’s less than thirty lines of Slang code! |
| 171 | + |
| 172 | +```Python |
| 173 | + # Every 50 iterations, render the blobs out to a texture, and hand it off to tev |
| 174 | + # so that you can visualize the iteration towards ideal |
| 175 | + if iter % 50 == 0: |
| 176 | + module.renderBlobsToTexture(current_render, |
| 177 | + blobs, |
| 178 | + spy.grid(shape=(input_image.width,input_image.height))) |
| 179 | + sgl.tev.show_async(current_render, name=f"optimization_{(iter // 50):03d}") |
| 180 | +``` |
| 181 | + |
| 182 | +And then finally, we use one last function in our Slang module to render the results of our blobs out to a texture, instead of just keeping them in memory, so that we can visualize the results of the iterations as we go on. We’re doing 10 thousand iterations, though, so looking at every iteration might be overkill, so we’ll only render out every 50th iteration. |
| 183 | + |
| 184 | + |
| 185 | +Ok! Now, for the Slang side of things. |
| 186 | + |
| 187 | +There’s a bit more to the Slang code, but let’s first take a look at the functions that we called out to from SlangPy just a moment ago. The workhorse of the module is that `perPixelLoss()` function and its helpers: |
| 188 | + |
| 189 | +```Slang |
| 190 | +// simpleSplatBlobs() is a naive implementation of the computation of color for a pixel. |
| 191 | +// It will iterate over all of the Gaussians for each pixel, to determine their contributions |
| 192 | +// to the pixel color, so this will become prohibitively slow with a very small number of |
| 193 | +// blobs, but it reduces the number of steps involved in determining the pixel color. |
| 194 | +// |
| 195 | +[Differentiable] |
| 196 | +float4 simpleSplatBlobs(GradInOutTensor<float, 1> blobsBuffer, uint2 pixelCoord, int2 texSize) |
| 197 | +{ |
| 198 | + Blobs blobs = {blobsBuffer}; |
| 199 | + |
| 200 | + float4 result = {0.0, 0.0, 0.0, 1.0}; |
| 201 | + float4 blobColor = {0.0, 0.0, 0.0, 0.0}; |
| 202 | + |
| 203 | + // iterate over the full list of Gaussion blobs |
| 204 | + for (uint i = 0; i < SIMPLE_BLOBCOUNT; i++) |
| 205 | + { |
| 206 | + // first, calculate the color of the current blob |
| 207 | + Gaussian2D gaussian = Gaussian2D.load(blobs, i); |
| 208 | + blobColor = gaussian.eval(pixelCoord * (1.0/texSize)); |
| 209 | + |
| 210 | + // then, blend with the blobs we've accumulated so far |
| 211 | + result = alphaBlend(result, blobColor); |
| 212 | + } |
| 213 | + |
| 214 | + // Blend with background |
| 215 | + return float4(result.rgb * (1.0 - result.a) + result.a, 1.0); |
| 216 | +} |
| 217 | +
|
| 218 | +// |
| 219 | +// loss() implements the standard L2 loss function to quantify the difference between |
| 220 | +// the rendered image and the target texture. |
| 221 | +// |
| 222 | +[Differentiable] |
| 223 | +float loss(uint2 pixelCoord, int2 imageSize, Blobs blobs, Texture2D<float4> targetTexture) |
| 224 | +{ |
| 225 | + int texWidth; |
| 226 | + int texHeight; |
| 227 | + targetTexture.GetDimensions(texWidth, texHeight); |
| 228 | + int2 texSize = int2(texWidth, texHeight); |
| 229 | + |
| 230 | + // Splat the blobs and calculate the color for this pixel. |
| 231 | + float4 color = simpleSplatBlobs(blobs.blobsBuffer, pixelCoord, imageSize); |
| 232 | + float4 targetColor; |
| 233 | + |
| 234 | + float weight; |
| 235 | + if (pixelCoord.x >= imageSize.x || pixelCoord.y >= imageSize.y) |
| 236 | + { |
| 237 | + return 0.f; |
| 238 | + } |
| 239 | + else |
| 240 | + { |
| 241 | + targetColor = no_diff targetTexture[pixelCoord]; |
| 242 | + return dot(color.rgb - targetColor.rgb, color.rgb - targetColor.rgb); |
| 243 | + } |
| 244 | + |
| 245 | + return 0.f; |
| 246 | +} |
| 247 | +
|
| 248 | +// Differentiable function to compute per-pixel loss |
| 249 | +// Parameters: |
| 250 | +// output: a 2-dimensional tensor of float4 values, representing the output texture |
| 251 | +// pixelCoord: the coordinates of the output pixel whose loss is being calculated |
| 252 | +// blobsBuffer: a 1-dimensional tensor of floats, containing the Gaussian blobs |
| 253 | +
|
| 254 | +[Differentiable] |
| 255 | +void perPixelLoss(GradInOutTensor<float4, 2> output, |
| 256 | + uint2 pixelCoord, |
| 257 | + GradInOutTensor<float, 1> blobsBuffer, |
| 258 | + Texture2D<float4> targetTexture) |
| 259 | +{ |
| 260 | + uint2 imageSize; |
| 261 | + targetTexture.GetDimensions(imageSize.x, imageSize.y); |
| 262 | + output.set( {pixelCoord.x, pixelCoord.y}, |
| 263 | + loss(pixelCoord, imageSize, {blobsBuffer}, targetTexture)); |
| 264 | +} |
| 265 | +``` |
| 266 | + |
| 267 | +You can see in this code block that `simpleSplatBlobs()` is doing most of the work: iterating over our entire list of Gaussian blobs, and accumulating their contributions to the color of the pixel we are currently calculating. Keep in mind that `perPixelLoss()` is going to be invoked once for each pixel in the output image, so the function is figuring out the loss value for just a single pixel. |
| 268 | + |
| 269 | +You might wonder if iterating over our entire list of Gaussians for each pixel in the image might be slow. It is. There are some clever things that we can do to speed up this calculation considerably, which I’ll cover in a follow-up blog post, but for now, let’s just focus on the simple– but slow– version. |
| 270 | + |
| 271 | +This set of functions is responsible for calculating all of the output pixels, as well as the difference between those values and our ideal target image, so they’re invoked not just for propagating loss derivatives (the `module.perPixelLoss.bwds` call we made in Python), but also during the rendering of our output texture, via `renderBlobsToTexture`, which looks like this: |
| 272 | + |
| 273 | +```Slang |
| 274 | +void renderBlobsToTexture( |
| 275 | + RWTexture2D<float4> output, |
| 276 | + GradInOutTensor<float, 1> blobsBuffer, |
| 277 | + uint2 pixelCoord) |
| 278 | +{ |
| 279 | + uint2 imageSize; |
| 280 | + output.GetDimensions(imageSize.x, imageSize.y); |
| 281 | + output[pixelCoord] = simpleSplatBlobs(blobsBuffer, pixelCoord, imageSize); |
| 282 | +} |
| 283 | +``` |
| 284 | + |
| 285 | +As you can see, this function just takes the result of `simpleSplatBlobs`, and writes the value to the appropriate pixel location in the output texture. |
| 286 | + |
| 287 | +The other piece of the equation is the Adam update algorithm: |
| 288 | + |
| 289 | +```Slang |
| 290 | +void adamUpdate(inout float val, |
| 291 | + inout float dVal, |
| 292 | + inout float firstMoment, |
| 293 | + inout float secondMoment) |
| 294 | +{ |
| 295 | + // Read & reset the derivative |
| 296 | + float g_t = dVal; |
| 297 | +
|
| 298 | + float g_t_2 = g_t * g_t; |
| 299 | +
|
| 300 | + // |
| 301 | + // Perform a gradient update using Adam optimizer rules for |
| 302 | + // a smoother optimization. |
| 303 | + // |
| 304 | + float m_t_prev = firstMoment; |
| 305 | + float v_t_prev = secondMoment; |
| 306 | + float m_t = ADAM_BETA_1 * m_t_prev + (1 - ADAM_BETA_1) * g_t; |
| 307 | + float v_t = ADAM_BETA_2 * v_t_prev + (1 - ADAM_BETA_2) * g_t_2; |
| 308 | +
|
| 309 | + firstMoment = m_t; |
| 310 | + secondMoment = v_t; |
| 311 | +
|
| 312 | + float m_t_hat = m_t / (1 - ADAM_BETA_1); |
| 313 | + float v_t_hat = v_t / (1 - ADAM_BETA_2); |
| 314 | +
|
| 315 | + float update = (ADAM_ETA / (sqrt(v_t_hat) + ADAM_EPSILON)) * m_t_hat; |
| 316 | + val -= update; |
| 317 | + dVal = 0.f; |
| 318 | +} |
| 319 | +``` |
| 320 | + |
| 321 | +This function isn’t marked as differentiable, because we don’t need to do any derivatives here– it’s just a straightforward update of all the blob parameters based on our gradients. |
| 322 | + |
| 323 | +And… that’s essentially it! Other than a few utility functions, this is all you need to write code that trains itself to match an output image. Your first neural graphics shader! |
| 324 | + |
| 325 | + |
| 326 | +<img src="/images/posts/splatting-jeep-final.gif" alt="An animation of the low-fi simplified 2D splatter in action" class="img-fluid"> |
| 327 | + |
| 328 | + |
| 329 | +Now, there are some notable shortcomings in this example– primarily, as mentioned before, that it takes quite a long time to execute. Because we look through our entire list of Gaussian blobs once for every pixel being calculated, at every iteration, it takes about 40 minutes (for me, on a system with a six-year-old graphics card) for all 10,000 iterations to complete. And this is with a very small number of blobs; I limited the number of blobs used to generate the image to 200, because going beyond that point starts to hang my GPU. And because of the small number of blobs, you can see that the image is pretty fuzzy. We could counter this with more, smaller blobs, but doing that will require some clever changes to improve execution speed. Thankfully, this is exactly the sort of work that GPUs are good at! And now that we’ve got the hang of how gradient descent and gaussian splatting work, we can dive into the optimization work in a follow-on blog post. |
| 330 | + |
| 331 | +If you have any questions or comments on this example code, or things you’d like to see covered in future walkthrough blog posts, please join us on the [Slang Discord](https://khr.io/slang-discord) – I and the rest of the Slang team can be found hanging out and answering questions there! |
0 commit comments