This demo presents a 3D scene rendered in real time using the ray marching technique, implemented with the PolyHok DSL and coordinated using the Elixir actor model. It illustrates how device-accelerated kernels can be combined with lightweight Elixir processes to build real-time 3D graphics simulations.
The implementation approach we will present here is not specific to 3D graphics. The same idea can be used to separate independent tasks in many other applications, allowing them to communicate through messages instead of sharing state directly.
The complete demo source code is available in the PolyHok Ray Marching Demo Repository.
Unlike traditional rasterization, which projects triangular meshes onto a 2D screen, ray marching can represent a scene using only mathematical functions! In this demo, the scene is described using Signed Distance Functions (SDFs).
For each pixel on the screen, a ray is cast from the virtual camera through the pixel and then marched along its direction. At each step, the scene SDF is queried to determine the distance from the current point to the closest object. This distance provides a safe step size: because no geometry can exist closer than the returned distance, the ray can advance by that amount without entering an object. The diagram below demonstrates this process:
Sphere Tracing diagram by Tyler Bovenzi
If that sounds a bit confusing, think about it like this:
You are blindfolded in a room and can only walk forward (just like the rays!). You want to avoid bumping into any obstacles, so you ask someone in the room how far away the closest object is. You don't know what the object is, and you don't even know whether it is in your walk path or not. However, if the closest object is 5 steps away, you know for sure that you can safely walk at least 5 steps without hitting anything.
That's the same idea behind sphere tracing!
After each move along the ray, the scene SDF is evaluated again at the new position. This process continues until either the returned distance becomes smaller than a tiny threshold (for example, 0.001), indicating that the ray has reached a surface, or the ray travels too far, performing too many steps without hitting anything. In the latter case, the pixel is considered part of the background.
When a ray hits an object, the surface normal is estimated by sampling the SDF around the intersection point. The normal is then compared with the direction of a light source using a dot product to calculate diffuse lighting.
In this demo, the scene consists of two animated spheres. Their SDFs are combined using a polynomial smooth minimum function (smooth_min), producing a smooth, organic transition between the spheres and creating a metaball-like effect.
This is one of the coolest aspects of ray marching rendering: because the geometry is described mathematically, objects can be combined and transformed using mathematical operations. Complex-looking shapes and effects can therefore be created from relatively small and computationally inexpensive operations!
Unfortunately, this great flexibility can also be a limitation. Describing simple primitives such as spheres, planes, and boxes using mathematical functions is very straightforward, but representing detailed arbitrary models using only SDFs can be incredibly hard and practically inviable. For instance, imagine having to describe a person's face or a car model using only mathematical operations… 😳
For this reason, ray marching is mostly used in the demoscene by computer nerds (like you and me) than by the industry.
The ray marching algorithm was implemented entirely using PolyHok, with a single kernel and several device functions.
Device functions such as sphere_sdf, distance, and dot provide basic mathematical operations, while scene_sdf describes the complete scene by evaluating the two animated spheres and combining their distances with smooth_min. The ray_march function then repeatedly evaluates this scene description while advancing the ray through the scene.
The actual rendering is performed by the render_kernel. Each kernel instance processes one pixel, using its global ID to determine the corresponding pixel coordinates. The kernel constructs a camera ray, performs the ray marching procedure, and, when the ray intersects an object, calculates the surface normal and diffuse lighting before writing the final RGB value into the image array. Pixels for which no intersection is found are simply written as black.
This approach allows us to process each pixel independently, so thousands of kernel instances can perform the ray marching calculations in parallel.
An important aspect of the implementation is that the rendering kernel is launched once per frame, but it is not recompiled from scratch every launch.
The render loop repeatedly calls PolyHok.spawn with the same render_kernel:
PolyHok.spawn(
&RayMarching.render_kernel/5,
{blocks_x, blocks_y, 1},
{threads_per_block, threads_per_block, 1},
[gpu_array, dim_x, dim_y, time, fov]
)
On every launch, even thought the runtime parameters, such as the animation time, may change between frames, PolyHok's kernel caching mechanism detects that the kernel used is the same. So it reuses the already compiled kernel from previous iterations across these launches instead of recompiling it every time. It only changes the kernel arguments.
This is particularly important for a real-time application. The rendering loop can continuously provide a new time value to animate the scene while reusing the kernel. The kernel describes how a frame is rendered, while its arguments describe the state of the current frame.
After launching the kernel, PolyHok.get_gnx/1 retrieves the rendered image from the GPU. This also provides the synchronization point at which the rendering process obtains the completed frame before sending it to the display process.
The rendering process is simply an Elixir process that executes a recursion loop. When started, it allocates a single GNx array with the dimensions of the window and reuses it for every frame. The kernel is launched with a 2D execution grid, with each GPU thread responsible for one pixel.
The current animation time is provided to the kernel, which uses it to update the positions of the two spheres before performing the ray marching calculations.
After retrieving the rendered image from the GPU, the rendering process sends it to the SDL2 process as an ordinary Elixir message:
send(:sdl2, {:render, result_ram})
This simple message is the bridge between the GPU computation and the graphical user interface (GUI).
As mentioned above, our demo has an Elixir process responsible for the render loop. But we also have a second process responsible for the GUI window.
Therefore, the demo effectively has two main actors:
* Rendering process: runs the PolyHok rendering loop, launches the GPU kernel, retrieves the rendered frame, and sends it to the SDL2 process. * SDL2 process: this is the process responsible for the window. It receives the rendered frames and updates the displayed image in the GUI. It is called SDL2 because this module uses Simple DirectMedia Layer 2 (SDL2) under the hood to create and manage windows on the operating system. Check the SimpleSDL2 Elixir library that we made for more details if you're interested.
The SDL2 process creates and initializes the window when the program starts, and we then register this process under the name :sdl2. The process waits for messages containing rendered frames. When it receives a {:render, nx_array} message, it passes the image to the SDL2 backend for display.
The rendering process, meanwhile, does not need to know how the window is implemented. Once a frame has been rendered and copied back from the GPU, it simply sends a message to the process responsible for displaying it and continues with the next iteration.
This creates a clean separation of responsibilities:
The two processes therefore communicate through message passing rather than shared mutable state. This is a natural fit for Elixir's concurrency model and also makes the rendering code independent from the code responsible for the window.
The SDL2 process continuously waits for new frames while also checking whether the window should close. When the window is closed, the process terminates normally. The rendering process monitors the SDL2 process and terminates its rendering worker when the window process goes down, preventing the rendering loop from continuing in the background after the application has exited.
This demo is therefore a small example of how PolyHok's GPU programming abstractions and Elixir's concurrency model can work together. The GPU is responsible for the highly parallel numerical workload of rendering the scene, while Elixir processes coordinate the application and communication between the renderer and the GUI.