Arrowised Materials in Purely Functional Ray Tracing

This article assumes at least a basic understanding of arrows as they are implemented in Haskell. It you haven't come across them before, I recommend this excellent tutorial by Ertugrul Söylemez which should tell you everything you need to know.

For my final year project at university, I developed a purely functional ray tracer in Haskell which I have since open sourced. While working on it as a project I had extremely limited flexibility with regards to adding additional features or substantially altering the design. However, now that the project is done and dusted, I have been working on a few changes I've been wanting to make for a while; one of which is a fully programmable material shader system to replace the original pre-defined set of choices for materials.

-- Original material type.
data Material = Shaded Colour  
              | Shadeless Colour
              | Emissive Colour Scalar
              | Reflective
              | Transmissive Double Double

The different constructors for Material were pattern-matched in a giant monolithic function which handled each material type in its own way. While I wasn't happy with this system, it served its purpose for the project, which didn't require much in the way of flexibility or extensibility. Even though HaskRay isn't intended for any sort of serious use, I felt a a more programmable system would not only be interesting but could teach me a lot at the same time. This post describes and attempts to explain the system I devised using John Hughes' arrow abstraction to create programmable materials.

What is a material really?

Blender's material panel

If you've ever used a 3D program such as Blender or 3DS Max you've probably seen some kind of material properties panel (above). If not, it's a very simple method of defining the way a surface is rendered by choosing colours and numerical values for different elements such as a base colour, specular highlights, transparency, reflection, etc. This provides a certain intuition of what a material is to an artist, but how materials are really represented inside a ray tracer is quite different. The mathematical core of a ray traced material is the Bidirectional Reflectance Distribution Function (BRDF) - a function which defines the ratio of light reflected in the eye direction to the negative incident light direction, with respect to the surface normal (below).

BRDF

What does a BRDF look like as a Haskell function? Well in order to calculate raw intensity we only require three values: view direction, light direction and the surface normal. HaskRay's Intersection type encapsulates the view direction and surface normal, the light direction can be provided as a separate parameter. The type Intersection -> Vec3 -> Scattering seems reasonable, and such a function can be partially applied to get a function Vec3 -> Intensity which can then be mapped over multiple light sources. This type could also be made into a monad by replacing Scattering with a type parameter. This would enable us to compose materials together to create more complicated ones - just like functions in the State monad can be composed.

This works well enough for simple diffuse materials, but in order to create mirror or glass-like materials, or cast shadows, the BRDF must be able to trace additional rays. The trace function in HaskRay is in the Render monad - a simple state monad used elsewhere in the renderer for random number generation and tracking recursive depth. Adding the HaskRay trace function into our BRDF type gives us (Ray -> Render (Maybe (Scalar, Intersection, Scattering Colour, Bool))) -> Intersection -> Vec3 -> Render a which is still a monad - even though it's using another monad internally.

However, there is one more piece missing from the puzzle, and that is the ability to track which materials emit light and need to be treated as a light source. This important for the renderer to know so that it can calculate light direction vectors to pass to the BRDFs (it could assume that every material emits light but that would be computationally impractical). This extra piece of information must be kept along side the BRDF and preserved when composed with other materials. However, the material is no longer a monad at this point, but an arrow instead. This is what it looks like:

-- New material type.
data Material a b = Material {  
    isEmissive :: Bool,
    closure :: a -> (Ray -> Render (Maybe (Scalar, Intersection, Scattering Colour, Bool))) -> Intersection -> Vec3 -> Render b
}

data Scattering a = Scattering { reflected :: a, transmitted :: a } deriving (Show, Read, Eq, Functor)  
  -- Also defined are instances for Applicative and Monoid.

holdout :: Scattering Colour  
holdout = mempty  -- Reflected and transmitted components set to black.  

Why isn't Material a monad? Looking at the type of >>= makes it quite clear: (>>=) :: Monad m => m a -> (a -> m b) -> m b. If the left hand side of >>= is emissive then the combination with another material must also be emissive. However, if the left hand side is not emissive, then the new value of isEmissive depends on the computation generated by the right hand side - which can't be known within the definition of >>=. Not convinced? Try and implement >>= for yourself!

Even though it's not a monad, Material is an arrow! Here are the instance definitions for Category and Arrow:

instance Category Material where  
    id = Material False $ \inp _ _ _ -> return inp  -- The identity material does nothing and is not emissive (hence False)
    Material im1 cl1 . Material im2 cl2 = Material (im1 || im2) $ \inp trace int om_i -> do  -- Preserve isEmissive flag by ORing
        x1 <- cl2 inp trace int om_i 
        cl1 x1 trace int om_i

instance Arrow Material where  
    arr f = Material False $ \inp _ _ _ -> return $ f inp  -- Lift pure function into arrow; only requires input value
    first (Material im cl) = Material im $ \(x1, x2) trace int om_i -> do  -- Material a b -> Material (a, c) (b, c)
        r1 <- cl x1 trace int om_i
        return (r1, x2)

The definitions are a little messy, but such is life when dealing with a type such as Material. It could also be beneficial in some cased to define materials with conditional statements. To enable these, we also require an instance for ArrowChoice:

instance ArrowChoice Material where  
    left (Material im cl) = Material im inner
        where
            inner (Left x) trace int om_i = fmap Left $ cl x trace int om_i
            inner (Right x) _ _ _ = return $ Right x

Lets define some basic materials. My own understanding of how these functions should be implemented is limited at best; it's something I intend to improve later down the line. If you're not familiar with shading algorithms, feel free to gloss over these definitions:

-- Simple diffuse shading without shadows.
diffuseShading :: Material Colour (Scattering Colour)  
diffuseShading = Material False fun  
    where
        fun col _ (Intersection {inorm}) om_i = return $ holdout { reflected = ref }
            where
                ref = scale (max 0 (om_i `dot` inorm)) col

-- Primitive emissive material.
emissive :: Material (Colour, Scalar) (Scattering Colour)  
emissive = Material True $ \(col, power) _ _ _ -> return $ holdout { reflected = power `scale` col }

-- Primitive reflective material.
mirror :: Material () (Scattering Colour)  
mirror = proc () -> do  
    maxDepth <- liftRender atMaxDepth -< ()
    if maxDepth
    then returnA -< holdout
    else do
        (Intersection {ipos, inorm, iray}) <- getIntersection -< ()
        traced <- traceM -< Ray ipos $ rdir iray `sub` scale (2 * (inorm `dot` rdir iray)) inorm
        returnA -< maybe holdout (\(_,_,scattering,_) -> scattering) traced

It would also be nice to include some functions which can combine the effects of different materials. Here we define addMaterial and mixMaterial which operate on Scattering values to blend them together.

-- Primitive material function which adds two Scatterings together.
addMaterial :: Material (Scattering Colour, Scattering Colour) (Scattering Colour)  
addMaterial = arr $ uncurry (<>)

-- Mix two scatterings according to a given ratio.
mixMaterial :: Scalar -> Material (Scattering Colour, Scattering Colour) (Scattering Colour)  
mixMaterial r = proc (s1, s2) -> do  
    let ri = 1-r
    let s1' = (*r) <$> s1
    let s2' = (*ri) <$> s2
    addMaterial -< (s1', s2')

By composing primitive materials like mirror and transmissive with functions like mixMaterial, more complex materials can be created. For example, a glass-like material, which adds a small amount of reflection to a refraction, can be created from these simple building blocks like so:

-- Adds a subtle reflection to pure transmission.
glass :: Material () (Scattering Colour)  
glass = proc () -> do  
    m <- mirror -< ()
    t <- transmissive -< (1.5, 0.9)
    mixMaterial 0.5 -< (m, t)

All this provides an extensible framework for building arbitrarily complex materials, while enabling the renderer to recognise materials which act as light sources. I intend to continue working on this when I have time - both creating more exotic materials with this system and improving the rest of the renderer. To finish, here is a scene rendered with the materials and system just discussed.

Example output from HaskRay

Attribution