📺 Datatypes can be rendered
I've been working on a purely functional GUI and I realized that the structures I've been using for rendering could be easily abstracted out into a library. The ideas are simple enough.
The main idea behind renderable
is that all graphics can be broken down into
primitives.
Rendering
A rendering is simply an effectful value that draws something on the screen in a specific place. Also needed is an effectful value that releases any resources allocated when creating the rendering. Since both values are created at the same time from here on out a "rendering" will be a tuple of the two. So let's dive into what we'll be rendering.
Primitive
First off we have the typeclass Primitive. A primitive is an atomic unit of "graphics". In my current project I've
chosen to render boxes, polylines and text. Each of these are a primitive that
I'll use in different combinations to create my interface. Primitive
has three
associated types - a monad, a transform type and a resource type. The monad
represents the context of the primitive rendering calls themselves and in most
cases will be the IO monad. If you're using OpenGL you'll probably use IO. The
transform represents the kind of transformations you will apply to your
primitives. I'm using a two dimensional affine transformation but you can use
anything. It just represents how a rendering can be changed without having to
alter the underlying resources.
Lastly the resource type is whatever datatype holds the resources needed to
render primitives. This may be a record that holds shaders or references to
windows, fonts, etc. For my current project I'm using a Rez
data Rez = Rez { rezGeom :: GeomRenderSource
, rezBez :: BezRenderSource
, rezMask :: MaskRenderSource
, rezWindow :: Window
, rezFont :: Font
, rezIcons :: Font
} deriving (Typeable)
Primitives must have Hashable instances - this is so they can be cached after
being allocated. If you're using OpenGL like I am then 'allocation' means making
some IO calls in order to send geometry and other data to the GPU. The
compilePrimitive
function is where we run the initial IO calls to allocate resources for the
datatype's rendering and then return a tuple of the cleanup function and the
draw function. Since all Primitive instances are also instances of Hashable the
renderable
package will automatically look up any needed renderings in the
cache, create new ones and release stale ones without you having to think about
it.
Here are some Primitive
instances to give you an example - they use another
(very) experimental project of mine called gelatin,
which at this point is a thin wrapper around gl
that provides some very specific things I need for my programs
-- Unit for fun
instance Primitive () where
type PrimM () = IO
type PrimR () = Rez
type PrimT () = Transform
compilePrimitive _ _ = return (return (), const $ return ())
-- Polyline
instance Primitive Polyline where
type PrimM Polyline = IO
type PrimR Polyline = Rez
type PrimT Polyline = Transform
compilePrimitive (Rez geom _ _ win _ _) Polyline{..} = do
let fill = solid polylineColor
p = polyline EndCapSquare LineJoinMiter polylineWidth polylinePath
Rendering f c <- filledTriangleRendering win geom p fill
return (c, f)
instance Hashable Polyline where
hashWithSalt s Polyline{..} =
s `hashWithSalt` polylineWidth
`hashWithSalt` polylineColor
`hashWithSalt` polylinePath
data Polyline = Polyline { polylineWidth :: Float
, polylineColor :: Color
, polylinePath :: [V2 Float]
} deriving (Show, Eq, Typeable, Generic)
path2Polyline :: Float -> Color -> Path -> Polyline
path2Polyline = Polyline
-- Box
boxPath :: Box -> Path
boxPath Box{..} = poly
where poly = [V2 x1 y1, V2 x2 y1, V2 x2 y2, V2 x1 y2, V2 x1 y1]
(V2 w h) = boxSize
x1 = 0
x2 = w
y1 = 0
y2 = h
boxPolyline :: Float -> Box -> Polyline
boxPolyline lw Box{..} = Polyline lw boxColor path
where path = [V2 x1 y1, V2 x2 y1, V2 x2 y2, V2 x1 y2, V2 x1 y1]
(V2 w h) = boxSize
x1 = -hw
x2 = w + hw
y1 = -hw
y2 = h + hw
hw = lw/2
data Box = Box { boxSize :: Size
, boxColor :: Color
} deriving (Show, Eq, Typeable, Generic)
instance Hashable Box where
hashWithSalt s (Box sz c) = s `hashWithSalt` sz `hashWithSalt` c
instance Primitive Box where
type PrimM Box = IO
type PrimR Box = Rez
type PrimT Box = Transform
compilePrimitive (Rez geom _ _ win _ _) (Box (V2 w h) c) = do
let [tl, tr, br, bl] = [zero, V2 w 0, V2 w h, V2 0 h]
vs = [tl, tr, br, tl, br, bl]
cs = replicate 6 c
Rendering f c' <- colorRendering win geom GL_TRIANGLES vs cs
return (c',f)
-- PlainText
instance Primitive PlainText where
type PrimM PlainText = IO
type PrimR PlainText = Rez
type PrimT PlainText = Transform
compilePrimitive (Rez geom bz _ win font _) (PlainText str fc) = do
Rendering f c <- stringRendering win geom bz font str fc (0,0)
return (c,f)
instance Hashable PlainText where
hashWithSalt s PlainText{..} =
s `hashWithSalt` plainTxtString `hashWithSalt` plainTxtColor
data PlainText = PlainText { plainTxtString :: String
, plainTxtColor :: Color
} deriving (Show, Eq, Generic)
Composite
The next step up in abstraction applies when you have described some adequate number of primitive types.
From here on up you can graphically represent new types as a heterogeneous list
of those more primitive types. Element
is used to package those primitive types in a list. composite
simply takes
your type and "decomposes" it into transformed Primitive elements.
This is where making new renderings gets really easy
-- TextInput
data TextInput = TextInput { textInputTransform :: Transform
, textInputText :: PlainText
, textInputBox :: Box
, textInputActive :: Bool
} deriving (Show, Eq, Typeable)
localTextInputPath :: TextInput -> Path
localTextInputPath = boxPath . textInputBox
globalTextInputPath :: TextInput -> Path
globalTextInputPath t@TextInput{..} =
transformPoly textInputTransform $ localTextInputPath t
textInputOutline :: TextInput -> Polyline
textInputOutline t@TextInput{..} = path2Polyline 1 white $ localTextInputPath t
instance Composite TextInput IO Rez Transform where
composite txt@TextInput{..} =
[ (textInputTransform, Element textInputBox)
, (textInputTransform, Element textInputText)
] ++ [(textInputTransform, Element poly) | textInputActive]
where poly = textInputOutline txt
Rendering a frame
After you have some datatypes to render from primitives it's dead simple to get them on the screen. All your loop has to keep around is the current data to render and the last rendering cache. Then you can use renderData to render your data to the screen. Here is an example of the function I'm using to render to the screen. There's only three relevant lines and the rest is GLFW noise
renderFrame :: Workspace -> UI -> IO Workspace
renderFrame ws ui = do
-- Get the Rez (resource type)
let rz = wsRez ws
-- Get the rendering cache from last
old = wsCache ws
(fbw,fbh) <- getFramebufferSize $ rezWindow rz
glViewport 0 0 (fromIntegral fbw) (fromIntegral fbh)
glClear $ GL_COLOR_BUFFER_BIT .|. GL_DEPTH_BUFFER_BIT
new <- renderData rz old ui
pollEvents
swapBuffers $ rezWindow rz
shouldClose <- windowShouldClose $ rezWindow rz
if shouldClose
then exitSuccess
else threadDelay 100
return $ ws { wsCache = new }
As you can see renderData pulls out the renderings we need from the old cache, creates the new ones, cleans the stale ones, renders your data and returns your new cache that you can use to render the next frame. This way if your interface never changes you don't have to allocate any new resources - you shouldn't even have to think about it.
2015-10-03