0
$\begingroup$

I'm currently on a journey of writing a basic path-tracer. My current implementation uses fragment shaders to run the path tracing algorithm per pixel. I am at the point where I need to start modelling materials, and it has left me confused as to how I could reasonably implement this part of the application.


Let's say I have an object-oriented implementation of different materials CPU side, e.g. a class can look like this:

class Lambertian : public BTDF {
public:    
    vec3 albedo{};
    virtual vec3 f(...) const override;
    virtual Sample sample(...) const override;
};

...

std::vector<Material> materials{}; // how would I send this to the fragment shader???

class Sphere : Shape {
    ...
    int materialIdx{-1};
};

This kind of structure does not work in my case since it's not possible to do object oriented programming in glsl. For the sake of testing and just getting something really basic up and running for e.g. Lambertian and metallic materials, I could write up some basic structs that represent the materials, and then some functions which correlate to that each material's f and sample and call the appropriate version depending on some flag or something to identify the material.

However, I am interested in learning a more robust way of doing things in. The above mentioned "hacky way" doesn't sound pleasant to work with, and it's not wise to start introducing branches in the shaders for deciding what functions to call based on material type.

Is it even possible to achieve something more robust considering I'm so limited by the fragment shaders? Note that I am not necessarily aiming to create some super performant and robust application, but I'd like to at least try to make some good decisions and not produce a heap of hacky solutions.


I am very curious on how problems like these are solved. There are many other examples that I can think of where data modelling is a problem.

For example, when constructing accelerated data structures (e.g. an AABB-BVH) on the GPU you need access to the triangles in the scene, but for a complex scene with tons and tons of triangles it seems problematic to try and send millions of triangles and belonging information to the GPU... and even if you did, how do you structure it? You are severely limited with how much data you can send to e.g. compute shaders using uniforms, no?

Or, when you want to traverse over said BVH during your path tracing, how do you actually send the BVH to the GPU? On the CPU you'd implement it like you'd implement any form of tree using pointers and stuff, but you can't really do that e.g. as uniforms in shaders.

$\endgroup$

1 Answer 1

1
$\begingroup$

This kind of structure does not work in my case since it's not possible to do object oriented programming in glsl.

If you want to think in terms of conventional CPU programming paradigms, perhaps a better one would be database design: find ways to structure your data as tables (arrays) that can be processed in bulk or efficiently queried. (But the GPU is its own thing, and you should expect that GPU programming is unique in some ways.)

…it's not wise to start introducing branches in the shaders for deciding what functions to call based on material type.

Yes and no. The cost of branches is often overstated.

The way modern GPUs execute code is that they batch work items (e.g. fragments to be produced by the fragment shader) into small groups (called “warps” or “wavefronts” depending on which manufacturer you ask). The shader code is then executed much like a CPU does, except that every instruction is SIMD — every instruction operates on every item in the warp/wavefront, in parallel.

So, what happens when the code meets a branch instruction? Well, since everything is in parallel, the condition of the branch (x > 0 or whatever) is an array of booleans. Then what the GPU does is:

  • If all the booleans are false, take the false branch.
  • If all the booleans are true, take the true branch.
  • If they are not all the same, take both branches and use the booleans to choose the appropriate results for each element of the warp/wavefront.

So, branches can be very costly, but they don't have to be.

If a branch is very likely to be identical for neighboring pixels, then it is likely to take the fast “only execute one branch” approach, where it is no more costly than a branch in CPU code. So, for example, if you branch on materials, then this is just fine for your primary rays, which are likely to all hit the same object. But once you start processing further bounces, they're more likely to hit different objects.

But even if both branches must be executed, that doesn't have to be disastrous — if both branches are small then not much is wasted.

So, it's a reasonable approach to branch on material type — but you should keep the branches small. Make the materials share as much code as they possibly can, and only branch on the calculations that really apply to one material.

And yes, this is a very not-OOP (and in a sense not-functional too) programming style. Instead of composing or inheriting the final thing from simpler components/parent classes, the simpler components contain the complex ones. But as I mentioned above, this is more like database design — you try to make your data as regular as possible, and when you have to have an exception you keep it small, like a nullable field or a table with fewer rows.

For example, when constructing accelerated data structures (e.g. an AABB-BVH) on the GPU you need access to the triangles in the scene, but for a complex scene with tons and tons of triangles it seems problematic to try and send millions of triangles and belonging information to the GPU...

Regardless of the acceleration structure, consider: the GPU has got to have the data if it's ever going to render it at all!

Techniques for fitting complex scenes in memory include:

  • Streaming: keep in GPU memory only as much data as is (estimated to be) needed. Distant objects are only kept as lower LODs until you get closer; objects that are far enough away to be totally out of view don't need to be loaded all yet.

  • Designing your vertex format to be as small as possible — even packing multiple pieces of information into a single integer. Waste no bits.

  • In conventional triangle rasterization, not path tracing, there is the concept of vertex pulling: instead of using the standard vertex-buffer and index-buffer mechanisms, have your vertex shader do its own loading of vertex information from a buffer. This allows the vertex shader to reuse more data based on its knowledge about the scene. For example, suppose that you're rendering a large number of squares (voxels, particles, billboards…) — then instead of having a vertex buffer storing all four corners of each square, you can store just one corner, the size, and the rotation; and let the vertex shader compute the other three corners.

    For path tracing this same thing does not quite apply, since you're always “pulling” the scene information based on where the rays travel, but take the general principle: don't write a totally generic renderer, write one that renders the kind of content you want, and take advantage of those scenes' properties to compress them into less memory.

You are severely limited with how much data you can send to e.g. compute shaders using uniforms, no?

Certainly you cannot transfer any worthwhile amount of scene data using uniforms. You want storage buffers, which can be much bigger, can be used as dynamically-sized arrays, and can be written to by compute shaders.

(Or if you are in an environment where storage buffers are unavailable, like WebGL, use a texture, and figure out how to pack your data as “colors”.)

On the CPU you'd implement it like you'd implement any form of tree using pointers and stuff, but you can't really do that e.g. as uniforms in shaders.

Use a storage buffer. You don't use pointers, but you can use indexes into the buffer. Even in regular CPU-side programming, a tree can be stored in a single array by having each node store indexes of its children instead of pointers to its children, and this can have performance advantages via memory locality and more compact storage.

And consider the database analogy again: if you wanted to store a tree in a database, you'd do it by storing all the nodes in a single table, not by creating a new table for each node.

$\endgroup$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.