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.