GPU-Accelerated Raytracer in Rust

Hello Rustaceans!

I'm excited to share a project I've been working on and would greatly appreciate your insights and feedback. I've developed a GPU-accelerated raytracer using Rust and wgpu, and I'm looking for code review, suggestions for improvement and approve :innocent:.

Key Features

  • Real-time ray tracing with multiple sphere rendering
  • GPU acceleration using wgpu
  • Reflection and shading effects
  • Future enhancements :sweat_smile:

Preview

Tech Stack

  • wgpu
  • WGSL
  • winit

Thank you in advance for your time and expertise. I'm looking forward to your feedback and the opportunity to learn from the Rust community!

5 Likes

Overall, this is quite clean code. Most of what I have to say is tips and tricks, and minor code style things.

It could use more comments; for example, a comment on every Rust struct that matches a WGSL struct mentioning that both places need to be modified if one is.

const VERTICES: [Vertex; 6] = [
  • You can skip constructing this vertex buffer and instead calculate the vertices in your vertex shader, from the vertex ID. This is not a big deal either way, but it takes less code since you don't have to create, fill, and bind the buffer.
  • When you want a full-screen render pass, it's better to use one large (clipped) triangle instead of two triangles. This is because the GPU needs to do extra work to process the vicinity of the diagonal line where the two triangles meet; a single triangle that just gets clipped to the screen rectangle is cheaper.
pub struct Application<'a> {
    window: &'a Window,
    surface: Surface<'a>,

You can avoid this lifetime relationship by putting the Window in an Arc and passing a clone of that Arc to create_surface(). This way, the Application struct isn't bound to a borrow from a stack frame, making it instead:

pub struct Application {
    window: Arc<Window>,
    surface: Surface<'static>,

This doesn't matter for your particular application, but in general, it's useful to avoid lifetimes on long-lived structs. For example, if you wanted to have multiple windows (documents) that the user can create and delete, you would need to do this, because the &'a Window pattern would mean that windows are hard to create and impossible to delete.

use wgpu::{util::{BufferInitDescriptor, DeviceExt}, BindGroup, BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, Buffer, …

This is a matter of code style, not objective improvement, but I personally recommend that when you have such a large list of used items, you should not import them individually but qualify the usage sites instead:

pub struct Application<'a> {
    ...
    device: wgpu::Device,
    queue: wgpu::Queue,
    ...

This may be somewhat more tokens in total, but it has several advantages:

  • Readers of the code do not need to scroll up to see where a type is from.
  • If you are using two libraries with the same one-word type name, say Device, Queue, Error, etc., they do not conflict.
  • You never have to maintain the imports:
    • No merge conflicts.
    • No unused imports to delete when you delete code.
    • Moving code to a different file doesn't require copying and pruning the use.
        let instance = Instance::new(InstanceDescriptor::default());
        let surface = instance.create_surface(window)?;
        let adapter = instance.request_adapter(
            &RequestAdapterOptions {

Consider using the functions from wgpu::util, backend_bits_from_env(), initialize_adapter_from_env_or_default(), and power_preference_from_env(). This gives your application more configurability that may be useful for troubleshooting or working around adapter bugs.

        let texture_format = surface_caps.formats
            .into_iter()
            .find(|format| format.eq(&TextureFormat::Rgba8Unorm))
            .context("preferred texture format error")?;
  • This is a roundabout way of checking if formats contains Rgba8Unorm — the variable texture_format will always contain Rgba8Unorm if it doesn't fail. This is a good basis for supporting multiple formats, but you're not actually doing that.
  • Code style: Don't call PartialEq::eq directly; use the == operator.
        let light = Light {
            position: [-100.0, -250.0, -60.0],
            intensity: 1.0
        };

        let light_buffer = device.create_buffer_init(&BufferInitDescriptor {
            label: Some("Spheres buffer"),
  • It's inconsistent that the scene data for the light is embedded here, but the scene data for the spheres is above at the top level.
  • The label for the light_buffer is incorrect.
                    resource: BindingResource::Buffer(BufferBinding {
                        buffer: &camera_uniform_buffer,
                        offset: 0,
                        size: None
                    })

You can replace this repetitive code with Buffer::as_entire_binding().

    pub fn resize(&mut self, new_size: &PhysicalSize<u32>) {
  • PhysicalSize is a small value type, so passing it by reference is unnecessary.

  • This method overwrites all of the fields of self in a way which is identical to calling CameraUniform::new() again, so it doesn't need to exist at all — just call CameraUniform::new() and assign the result. (It would make sense to change new() to accept a PhysicalSize<u32>, since you have that available everywhere.) As a general principle: avoid writing two functions that compute the same formula if you don't have to, because someday you'll update one and forget the other.


light.rs and sphere.rs are so small that there isn't much value in them existing. I would suggest creating instead a scene.rs that contains both types, since they are both parts of the scene.

        let oc = ray.position - sphere.center;
        let a = dot(ray.direction, ray.direction);
        let b = 2.0 * dot(oc, ray.direction);
        let c = dot(oc, oc) - sphere.radius * sphere.radius;

Give these variables more descriptive names, and a comment or five explaining what quantities this code is actually calculating. (Mathematics may use short names, but math papers also come with explanatory text, not just formulas. Code needs to be easily comprehended so that it is maintainable.)

7 Likes

Thank you very much for the feedback and I would be grateful if you would approve my pull request. I have already made a few changes.

No, I will not do that, for several reasons.

  • I’ve already provided you a free review; if I were to approve the PR it would behoove me to re-review it first, and that's not nearly as much like the fun hobby of answering people’s questions on forums.
  • It feels too much like being asked to promote someone else’s GitHub profile by adding fake participation. Perhaps I’m over-sensitive to such things, but I don't want to lend my name in that way or anything close to it.
  • When I leave a GitHub review, it’s because I maintain the project, or because I care about the project for my own reasons and I think I have something worth saying about that PR. This is your project, which you get to choose what you approve of, and I'm not planning to use your raytracer, so neither case applies.
4 Likes

I need approval because it's a requirement of the rust boot camp I'm participating in, which is get approval from a forum user. Once again, thank you very much.