LambdaCube 3D
Purely Functional Rendering Engine
Introducing the LambdaCube Intermediate Representation
October 12, 2013
Posted by on A few months ago we gave a quick overview of our long-term plans with LambdaCube. One of the central elements in this vision is an intermediate representation (IR) that allows us to split the LambdaCube compiler, separating the front-end and back-end functionalities. Currently we’re reorganising the implementation into two packages: one to compile the EDSL to the IR, and the other to execute the IR over native OpenGL. Today we’ll have a quick glance at the present shape of the IR that makes this possible.
Before jumping to the meat of the topic, we need a little context. The figure in the linked post provides a good overview, so let’s have a good look at it:
The front-end compiler produces the IR, which is just a plain data structure. The back-end provides a library-style API that allows applications to manipulate the inputs of the pipeline (uniforms, textures, and geometry), and execute it at will. A typical LambdaCube back-end API is expected to contain functions along these lines:
compilePipeline :: IR -> IO Pipeline setUniform :: Pipeline -> UniformName -> GpuPrimitive -> IO () setTexture :: Pipeline -> TextureName -> Texture -> IO () addGeometry :: Pipeline -> SlotName -> Geometry -> IO GeometryRef removeGeometry :: Pipeline -> GeometryRef -> IO () executePipeline :: Pipeline -> IO ()
Or if the back-end is e.g. a Java library, it could define a Pipeline class:
class Pipeline { Pipeline(IR description) { ... } void setUniform(UniformName name, GpuPrimitive value) { ... } void setTexture(TextureName name, Texture texture) { ... } GeometryRef addGeometry(SlotName slot, Geometry geometry) { ... } void removeGeometry(GeometryRef geometry) { ... } void execute() { ... } }
In typical usage, compilePipeline is invoked once in the initialisation phase to build a run-time representation of the pipeline. Afterwards, rendering a frame consists of setting up the inputs as desired, then executing the pipeline. Note that geometry doesn’t necessarily mean just vertex attributes, it can also include uniform settings. Our OpenGL back-end also allows per-object uniforms.
Now we can have a closer look at the pipeline! The top level definition of the pipeline IR is captured by the following data structure:
data Pipeline = Pipeline { textures :: Vector TextureDescriptor , samplers :: Vector SamplerDescriptor , targets :: Vector RenderTarget , programs :: Vector Program , slots :: Vector Slot , commands :: [Command] } deriving Show
A pipeline is represented as a collection of various top-level constants followed by a series of rendering commands. The constants are sorted into five different categories:
- Texture descriptions: specify the size and shape of textures, and also contain references to samplers.
- Sampler descriptions: specify the sampler parameters like wrap logic or filter settings.
- Render targets: each target is a list of images (either a texture or the output) with semantics (colour, depth, or stencil) specified.
- Shader programs: isolated fragments of the original pipeline that can be compiled separately, each corresponding to a rendering pass; they also specify the structure of their inputs and outputs (names and types).
- Input slots: each slot is a reference to some storage space for geometry that will be fed to the rendering passes. Slot descriptions define the structure of the input (vertex attributes and uniforms coming from the geometry), and they also enumerate references to all the passes that use them as a convenience feature.
Given all the data above, the commands describe the steps needed to execute the pipeline.
data Command = SetRasterContext RasterContext | SetAccumulationContext AccumulationContext | SetRenderTarget RenderTargetName | SetProgram ProgramName | SetSamplerUniform UniformName TextureUnit | SetTexture TextureUnit TextureName | SetSampler TextureUnit (Maybe SamplerName) | RenderSlot SlotName | ClearRenderTarget [(ImageSemantic,Value)] | GenerateMipMap TextureUnit | SaveImage FrameBufferComponent ImageRef | LoadImage ImageRef FrameBufferComponent
As we can see, this instruction set doesn’t resemble that of a conventional assembly language. There are no control structures, and we don’t deal with values at this level, only data dependencies. In essence, the pipeline program defines a suitable traversal order for the original pipeline definition. The job of the front-end compiler is to figure out this order and the necessary allocation of resources (mainly the texture units).
Most of the instructions – the ones whose name starts with Set – just specify the GPU state required to render a given pass. When everything is set up, RenderSlot is used to perform the pass. It can be optionally preceded by a ClearRenderTarget call. We also need some extra machinery to keep the results of passes around for further processing if the dependency graph refers to them several times. For the time being LoadImage and SaveImage are supposed to serve this purpose, but this part of the IR is still in flux.
The nice thing about this scheme is its clear separation of static structure and data. We basically take the standard OpenGL API and replace direct loads of data with named references. The rendering API is used to assign data during run-time using these names. This doesn’t necessarily have to involve a hash lookup, since the API can provide additional functionality to retrieve direct references to use in time critical code. The language maps closely to existing graphics interfaces, so it’s easy to create a lightweight interpreter or even a native code generator for it. Finally, there is a straightforward way to extend it with features we don’t support yet, like instancing or transform feedback, if the need arises.
A more interesting approach would be to delegate lambdacube to actually upload the geometry, so that it can do optimizations, like atlas, simple occlusion for rectangular geometry, texture and vertex cache.
The generated pipeline does upload the geometry when it is associated with a slot. However, we don’t think it should perform any complex processing at this level. What you’re suggesting is a layer that can be implemented on top of the rendering functionality.
Ultimately, we’d like to keep a clear separation of concerns. For now LambdaCube is only responsible for providing a pure API over the GPU, and we’d like to keep it as simple as possible. That said, we’re of course open to suggestions about advanced functionality, but avoiding scope creep is necessary for the time being.