hadronized / quaazar

Realtime 3D engine
BSD 3-Clause "New" or "Revised" License
6 stars 2 forks source link

Referential transparency #93

Closed hadronized closed 9 years ago

hadronized commented 9 years ago

Problem

There’s a nasty effect of using IO directly to render our objects. Functions such as:

render :: Camera -> Ambient -> [Instance Omni] -> [Instance (Model a)] -> RenderLayer

That function is opaque, because RenderLayer is an opaque IO value that wraps side-effects. We don’t really want that.

World, SubWorld and Retina

A SubWorld is a part of the whole scene. Actually, a SubWorld contain objects that will be shaded the same way. That is, a SubWorld is a type:

data SubWorld = forall a. SubWorld (Program' a) [Instance (Model a)]

We rely on existential quantification here so that we can abstract the shader away. If we group all SubWorld and add lights, we end up with a World:

data World = World Ambient [Instance Omni] [SubWorld]

And a Retina is a simple object that adds a Camera to a World. Add some perspective!

Pretty straight-forward. The cool stuff is that we can now traverse a World in order to render it.

Rendering vs. compositing

Rendering is the fact of turning a World into a Frame. We can really state that a renderer is such a function:

renderer :: World -> m Frame

But what is m? That depends on what we’ll need the Frame for. There aren’t many options. We’ll use the Frame in the next step: the compositing phasing.

Compositing is the fact of blending frames into others in order to enhance and assemble images. Compositing should be seen as a frame endomorphism:

compositing :: c Frame Frame

But once again, what is c? Well, c is the type representing the compositing.

compositing :: Compositing Frame Frame

Now, what does that type do? That’s actually pretty simple. A compositing graph is composed of nodes, which can host two kind of things:

Let’s capture that in a type Node:

data Node a b
  = Render Retina (Frame -> b)
  | PostProcess a (Program' a) (Frame -> b)

Render ret f is a Node that hosts the Retina ret and a function f that will use the resulting Frame to build another type b.

PostProcess a prog f hosts custom properties and a shader program to sink them and a function f that will use the resulting Frame to build another type b. It’s important to understand that custom properties a can have Frames, and that we should have a way to send Frames down to shaders.

That doesn’t tell us how to build the Compositing type. Well, that’s actually pretty simple. We can define a Functor instance for Node a:

instance Functor (Node a) where
  fmap f n = case n of
    Render ret g -> Render ret (f . g)
    PostProcess a prog g -> PostProcess a prog (f . g)

And then we can use a free monad to avoid the boiler-plate:

newtype Compositing a b = Compositing (Free (Node a) b) deriving (Applicative,Functor,Monad)

render :: Retina -> Compositing a Frame
render ret = Compositing . Free $ Render ret Pure

postProcess :: a -> Program' a -> Compositing a Frame
postProcess a prog = Compositing . Free $ PostProcess a prog Pure

We’ll then have to change our renderer function to adapt to compositing. Let’s call that display instead:

display :: (MonadIO m) => a -> Compositing a Frame -> m ()

And that’s possible because the Compositing a Frame hosts all Retina and all post-process phases and that we should output a single Frame. We could have used another type, like SinkFrame, to sink the resulting Frame through screen or onto the disk, but I don’t actually give a shit right now.