This article introduces posh, an experimental Rust graphics library aimed at enhancing the type-safety, composability, and overall experience of graphics programming. The post covers the fundamental concepts of posh, showcases examples, discusses related work and limitations of the approach, and, for readers who might be interested in details, delves into the internal workings of the library.

posh consists of two closely integrated modules: posh::gl, a graphics library responsible for uploading data to the GPU and executing draw calls, and posh::sl, a functional shading language embedded within Rust. The tight integration between these modules enables static verification, ensuring that the data provided in draw calls aligns with the shader’s signature.

The typical structure of posh code follows this pattern:

use posh::{gl, sl};

// ... define custom shader interface types U, V, W, and F ...

fn vertex_shader(uniform: U, vertex: V) -> sl::VsOutput<W> {
    // ... compute `sl::VsOutput { clip_position, interpolant }` ...
}

fn fragment_shader(uniform: U, interpolant: W) -> F {
    // ... compute F ...
}

let program: gl::Program<U, V, F> = gl.create_program(
    vertex_shader,
    fragment_shader,
)?;

program
    .with_uniforms(/* uniform bindings matching U */)
    .with_framebuffer(/* framebuffer matching F */)
    .with_settings(/* draw settings */)
    .draw(/* vertex specification matching V */)?;

Shader functions are written as normal Rust code that interacts with types from posh::sl, thereby leveraging the benefits of Rust’s type checking and composability. Internally, at runtime, shader functions generate expression graphs that are translated to GLSL. Since these are regular Rust functions, their signatures naturally become part of the program’s type, ensuring type safety when invoking the draw method.

The library has been designed to avoid the need for procedural macros when writing shader code. Instead, all macro magic is contained within derive macros for user-defined types that occur in shader signatures. This approach aims to make shader code transparently readable.

The development of posh is motivated by the aim to simplify and streamline graphics programming. Traditional graphics code often involves two distinct languages: the host language (e.g., Rust) and the shading language (e.g., GLSL). This separation leads to boilerplate code on both sides and hampers the composition of functionality. Moreover, draw calls, which act as a foreign function interface from host code to shader code, typically lack static type checks. To address these challenges, posh provides a platform for defining shaders using a functional language embedded within Rust and integrates shader signatures with the graphics library.

Table of Contents

Current Status

Currently, in order to narrow down the initial scope of the project, posh targets subsets of OpenGL ES 3.0 and GLSL ES 3.0. However, we are considering to transition to wgpu in the long run.

It is important to note that posh is still in its early stages and may require several revisions to reach stabilization. We welcome contributions to the design and implementation. Feel free to explore posh’s repository, including its examples and open issues, to get an idea of where we currently stand. Please be aware that there is no release available on crates.io at this time.

A Basic Example: Hello, Triangle!

Example: A triangle

In order to introduce the basic concepts behind posh, let us draw a triangle whose position and shading depend on the current time. You can find the complete source code for this example in the repository.

When writing posh code, there are three basic steps to follow.

1) Define Shader Interface Types

Shaders typically require access to uniform inputs provided by the host. In this example, we will need the current time and the desired size of the triangle. To achieve this, we create a struct and derive Block for it, which allows us to use it as a uniform block.

use posh::{Block, BlockDom};

#[derive(Clone, Copy, Block)]
#[repr(C)]
struct MyGlobals<D: BlockDom> {
    time: D::F32,
    size: D::Vec2,
}

Shader interface types like MyGlobals<D> are generic in the domain D. There are two domains: posh::Gl, which provides data and bindings for draw calls, and posh::Sl, which provides types used in shader definitions. The concept of domains connects the two sides of graphics programming. We’ll use MyGlobals<Gl> to describe data to be stored in buffers on the GPU and MyGlobals<Sl> to access inputs in our shader code.

2) Write Shader Code

Next, we will write shader code using posh::sl. We will define the vertex shader and the fragment shader functions to specify where and how the triangle should be drawn.

In posh::sl, shader functions have two arguments. The first argument is the uniform input, while the second argument is the current input of the shader (vertex input for the vertex shader and interpolant input for the fragment shader).

In this example, the vertex shader receives MyGlobals<Sl> as uniform input, and a two-dimensional position as vertex input. The shader computes the clip_position output (equivalent to gl_Position in GLSL) along with an interpolant, which is interpolated and passed on to the fragment shader by the GPU:

use posh::{sl, Sl};

fn vertex_shader(
    globals: MyGlobals<Sl>,
    vertex: sl::Vec2,
) -> sl::VsOutput<sl::Vec2> {
    let position = sl::Vec2::from_angle(globals.time)
        .rotate(vertex * globals.size);

    sl::VsOutput {
        clip_position: sl::vec4(position.x, position.y, 0.0, 1.0),
        interpolant: vertex,
    }
}

The fragment shader uses the interpolant to compute a time-dependent color for each fragment:

fn fragment_shader(globals: MyGlobals<Sl>, interpolant: sl::Vec2) -> sl::Vec4 {
    let rg = (interpolant + globals.time).cos().powf(2.0);

    sl::vec4(rg.x, rg.y, 0.5, 1.0)
}

Internally, types like MyGlobals<Sl> or sl::Vec2 represent expression graphs that can be transformed to GLSL. For instance, the variables globals and interpolant are leaf nodes representing inputs of the fragment shader. Expressions like interpolant + globals.time are nodes in the expression graph that describe how their values are computed. This makes it possible to transpile vertex_shader and fragment_shader as a whole to GLSL without requiring macro magic.

3) Write Host Code

Now, we will write host code using posh::gl. We will set up buffer data on the GPU and then perform a draw call with the shader that we have defined above. For the purpose of this example, we will assume that we already have a variable gl: gl::Context available (ignoring context creation).

First, we compile our two shader functions into a program. Note that the program’s type carries the shader’s signature:

use posh::gl;

let program: gl::Program<MyGlobals<Sl>, sl::Vec2> = gl.create_program(
    vertex_shader,
    fragment_shader,
)?;

Next, we set up buffers on the GPU to hold data to be supplied to the shader. The types of globals and vertices align with the signature of the program:

let globals: gl::UniformBuffer<MyGlobals<Gl>> = gl.create_uniform_buffer(
    MyGlobals {
        time: 42.0,
        size: [1.0, 1.0].into(),
    },
    gl::BufferUsage::StreamDraw,
)?;

let vertices: gl::VertexBuffer<gl::Vec2> = gl.create_vertex_buffer(
    &[
        [0.0f32, 1.0].into(),
        [-0.5, -0.5].into(),
        [0.5, -0.5].into(),
    ],
    gl::BufferUsage::StreamDraw,
)?;

Finally, we perform a draw call using a method of program. This requires us to supply bindings for uniform inputs and vertex inputs, along with various draw settings.

program
    .with_uniforms(globals.as_binding())
    .with_settings(
        gl::DrawSettings::default()
            .with_clear_color([0.1, 0.2, 0.3, 1.0])
    )
    .draw(vertices.as_vertex_spec(gl::PrimitiveMode::Triangles))?;

The draw call is where everything comes together in posh. It takes our compiled shader, supplies GPU buffer bindings, and draws something to a framebuffer (in this instance, the default framebuffer). The draw method benefits from static type-checking, allowing the Rust compiler to help ensure that the data we provide matches the expected types in the shader. This provides an additional level of correctness to our graphics programming workflow.

A More Complex Example: Shadow Mapping

Example: Shadow Mapping

In this section, we will a explore a more complex example. We will look at a way to render a scene with shadow mapping (assuming that the shadow map has already been created). You can find the complete source code for this example in the repository.

Without delving into details of how shadow mapping works, let us again follow the three basic steps of writing posh code.

1) Define Shader Interface Types

First, we define a custom vertex type, SceneVertex<D>, to hold world-space information of the scene to be drawn. We also define the Camera<D> and Light<D> types for the view camera and the light source.

use posh::{Block, BlockDom};

#[derive(Clone, Copy, Block)]
#[repr(C)]
struct SceneVertex<D: BlockDom> {
    world_pos: D::Vec3,
    world_normal: D::Vec3,
    color: D::Vec3,
}

#[derive(Clone, Copy, Block)]
#[repr(C)]
struct Camera<D: BlockDom> {
    world_to_eye: D::Mat4,
    eye_to_clip: D::Mat4,
}

#[derive(Clone, Copy, Block)]
#[repr(C)]
struct Light<D: BlockDom> {
    camera: Camera<D>,
    world_pos: D::Vec3,
    color: D::Vec3,
    ambient: D::Vec3,
}

Lastly, we define the SceneUniforms<D> type, which encapsulates the uniform inputs required by our shaders, including the depth map sampler.

use posh::{UniformInterface, UniformInterfaceDom};

#[derive(Clone, UniformInterface)]
struct SceneUniforms<D: UniformInterfaceDom> {
    camera: D::Block<Camera<Sl>>,
    light: D::Block<Light<Sl>>,
    light_depth_map: D::ComparisonSampler2d,
}

Any type that implements UniformInterface can be used as uniform input for shaders. Similar to Block declarations, UniformInterface declarations are generic in the domain D. In this example, SceneUniforms<Gl> contains bindings of uniform buffers and samplers, while SceneUniforms<Sl> provides the inputs for shader definitions.

2) Write Shader Code

Next, let us define an Interpolant struct, which connects the vertex and fragment shaders. The fragment shader requires access to the interpolated input vertex and the input vertex’s position in the light source’s clip space.

use posh::{sl, Sl};

#[derive(Clone, Copy, sl::Value, sl::Interpolant)]
struct MyInterpolant {
    vertex: SceneVertex<Sl>,
    light_clip_pos: sl::Vec4,
}

Now, we can implement our vertex shader, using a utility function for Camera<Sl>:

impl Camera<Sl> {
    fn world_to_clip(self, world_pos: sl::Vec3) -> sl::Vec4 {
        self.eye_to_clip * self.world_to_eye * world_pos.extend(1.0)
    }
}

fn vertex_shader(
    SceneUniforms { light, camera, .. }: SceneUniforms<Sl>,
    vertex: SceneVertex<Sl>,
) -> sl::VsOutput<MyInterpolant> {
    // Slightly extrude along the normal to reduce shadow artifacts.
    const EXTRUDE: f32 = 0.1;
    let light_clip_pos = light
        .camera
        .world_to_clip(vertex.world_pos + vertex.world_normal * EXTRUDE);

    sl::VsOutput {
        clip_position: camera.world_to_clip(vertex.world_pos),
        interpolant: MyInterpolant { vertex, light_clip_pos },
    }
}

The MyInterpolant value is fed from the vertex shader to the fragment shader, which uses it to sample the shadow map and shade the fragment.

fn sample_shadow(
    light_depth_map: sl::ComparisonSampler2d,
    light_clip_pos: sl::Vec4,
) -> sl::F32 {
    let ndc = light_clip_pos.xyz() / light_clip_pos.w;
    let uvw = ndc * 0.5 + 0.5;

    // Fall back to zero if the UV coordinates would be clamped.
    let clamp = sl::any([
        uvw.x.lt(0.0),
        uvw.x.gt(1.0),
        uvw.y.lt(0.0),
        uvw.y.gt(1.0),
    ]);

    sl::branch(
        clamp,
        0.0,
        light_depth_map.sample_compare(uvw.xy(), uvw.z),
    )
}

fn fragment_shader(
    SceneUniforms { light, light_depth_map, ..  }: SceneUniforms<Sl>,
    MyInterpolant { vertex, light_clip_pos }: MyInterpolant,
) -> sl::Vec4 {
    let light_dir = (light.world_pos - vertex.world_pos).normalize();
    let diffuse = light.color * vertex.world_normal.dot(light_dir).max(0.0);
    let shadow = sample_shadow(light_depth_map, light_clip_pos);
    let color = (light.ambient + shadow * diffuse) * vertex.color;

    color.extend(1.0)
}

3) Write Host Code

On the host side, we compile the two shader functions into a program:

use posh::gl;

let program: gl::Program<SceneUniforms<Sl>, SceneVertex<Sl>> =
    gl.create_program(vertex_shader, fragment_shader)?;

We also need to set up several GPU buffers. Ignoring the contents of the buffers, we have the following objects:

let camera_buffer: gl::UniformBuffer<Camera<Gl>> = todo!();
let light_buffer: gl::UniformBuffer<Light<Gl>> = todo!();
let light_depth_map: gl::DepthTexture2d = todo!();
let scene_vertices: gl::VertexBuffer<SceneVertex<Gl>> = todo!();
let scene_elements: gl::ElementBuffer = todo!();

Finally, we can render the scene with shadow mapping:

scene_program
    .with_uniforms(SceneUniforms {
        camera: camera_buffer.as_binding(),
        light: light_buffer.as_binding(),
        light_depth_map: light_depth_map.as_comparison_sampler(
            gl::Sampler2dSettings::linear(),
            gl::Comparison::Less,
        ),
    })
    .with_settings(
        gl::DrawSettings::default()
            .with_clear_color([1.0, 1.0, 1.0, 1.0].into())
            .with_clear_depth(2.0)
            .with_depth_test(gl::Comparison::Less)
            .with_cull_face(gl::CullFace::Back)
    )
    .draw(
        scene_vertices
            .as_vertex_spec(gl::PrimitiveMode::Triangles)
            .with_element_data(scene_elements.as_binding())
    )?;

Once again, the draw call serves as the point where all the components come together in a type-safe manner. In the with_uniforms call, we provide uniform bindings of type SceneUniforms<Gl>, and in the subsequent draw call, we supply a vertex buffer of type SceneVertex<Gl>. These types precisely match the expected SceneUniforms<Sl> and SceneVertex<Sl> types in the program’s signature.

The development of posh has drawn inspiration from several amazing existing projects.

rust-gpu enables Rust to be used as a first-class language for writing shaders by implementing a rustc backend that generates SPIR-V. While posh shares the goal of enhancing shader development, it takes a different approach. Instead of treating Rust as a primary shading language, posh employs a functional language embedded within Rust to implement shaders. Additionally, while rust-gpu primarily focuses on shader code, posh places emphasis on achieving a type-safe integration between shader code and host code.

Shades provides an embedded domain-specific language for shaders, similar to posh. However, Shades is designed as a general-purpose library without specific ties to a target shading language or graphics library. In contrast, posh intentionally narrows its scope to a functional subset of GLSL and aligns itselfs with a subset of OpenGL. We hope that this limitation will allow us to iterate quickly and focus on the integration of shader code with host code. However, as a result, posh is less powerful than Shades in many ways.

glium is an OpenGL wrapper that demonstrates that OpenGL can be used elegantly in Rust. In glium, the dependencies of a draw call are consolidated into a single method. posh builds upon this concept by introducing typed shader signatures.

Discussion

Aquadise Island: A Project that uses `posh`

I have been dogfooding posh for a WebGL2-based game project that I am involved with (screenshot above). After many iterations on the library, the experience has become quite pleasant to me. Despite some rough edges, I find myself truly enjoying the process of writing shaders and making draw calls with posh.

However, it is important to acknowledge that the approach taken by posh does have its disadvantages. While some of these drawbacks can be addressed or mitigated to some extent, others are inherent to the approach itself. Let us examine some of these drawbacks:

  1. Writing shader code in posh::sl differs from writing normal Rust code. Shader functions are evaluated at shader compile-time, which happens during the host program’s runtime. As a result, Rust if expressions turn into shader compile-time branches, requiring users to familiarize themselves with this concept and utilize sl::branch for dynamic branches.
  2. In certain cases, such as when optimizing shader code or performing uniformity analysis, users may need to develop a mental model of how shaders are transpiled into GLSL.
  3. Certain usage patterns in posh::sl can lead to the generation of redundant GLSL code (see also issue #96). In particular, when a function call is made in shader code, such as the call to sample_shadow in the earlier example, the result of the function is inlined in the generated GLSL code. If the same function is called multiple times, it will be inlined each time, potentially leading to bloat in the generated GLSL code. Previous iterations of posh attempted to address this issue with a procedural macro #[posh::define_function] that could be attached to functions, but it introduced cognitive overhead when reading shader code.
  4. The shading language in posh lacks support for mutable variables. While this is a deliberate design choice, it does limit certain programming patterns that rely on mutable state within shaders.
  5. Making values in the shading language Copy requires some trickery. The section below provides more detail on this aspect.
  6. The types of the bindings provided in host code must precisely match the shader signature. There is no concept of allowing the binding of supersets of data required by the shader signature, limiting flexibility in some situations.
  7. Changing shaders requires recompilation. This can be addressed to some degree by hotloading the shader code.
  8. Clearly, type safety is just one aspect of writing correct shaders. posh does not solve the problem that shaders are fundamentally hard to get right. However, in my experience, it does help to have these basic guardrails in place.
  9. Finally, this is a lot to take in just to write some graphics code! I’ve become familiar with it, and now feel empowered by it, but I’m not sure if it would be easy for others to get into it.

Despite these challenges, I remain confident in the value of an integrated approach to graphics programming. There may be better ways of getting there than the one currently taken by posh. It is worth exploring alternative approaches to address the identified drawbacks and improve the overall experience. Making the behavior of the library transparent to the user, containing the “magic” parts to a limited number of places, can be a key factor in ensuring its usability.

This domain feels underexplored to me, and I hope that posh can serve as inspiration for further investigations.

How Does it Work?

posh utilizes Rust’s powerful trait system and provides several traits and derive macros to establish the integration between the posh::gl graphics library and the posh::sl shading language.

This section delves into some implementation details of posh and explores the underlying mechanisms that enable interoperability between the two modules. It is intended for readers who are interested in understanding the inner workings of posh. However, it is worth noting that posh can be used effectively without a deep understanding of implementation details.

Shader Interface Traits

The core of posh’s integration lies in the concept of shader interfaces, which are defined through traits. These traits enable types to be used in shader signatures, allowing for type-safe interactions between the host code and the shaders.

The following traits play a crucial role in posh:

  • UniformInterface<D>: Allows a type to be used as a uniform input in shaders.
  • VsInterface<D>: Represents the vertex shader input interface.
  • FsInterface<D>: Represents the fragment shader output interface.

Additionally, the Block<D> trait enables types to be used as part of uniform inputs or vertex shader inputs.

These traits are generic over the domain D, which can be either posh::Sl (representing the shading language domain) or posh::Gl (representing the graphics library domain). This duality allows for the same struct to serve both as part of a shader definition and as the actual input data for the shader.

To simplify the implementation of these traits for user-defined structs, posh provides derive macros. These macros automate the generation of trait implementations, reducing the boilerplate and making it easier to define shaders and bind input data. Importantly, posh avoids the use of procedural macros in other areas, allowing user code to remain familiar and maintain its readability as plain Rust.

Block Data

By implementing the Block<D> trait, user-defined types can be used in uniform buffers or in vertex buffers, i.e. it enables them to be used as part of a UniformInterface<D> or a VsInterface<D>.

Block<D> is generic in D: BlockDom, which represents a mapping for the core types that can be put into blocks. It defines core type representations as follows:

// Defined in `posh`:

pub trait BlockDom {
    type F32: Block<Self>;
    type I32: Block<Self>;
    type U32: Block<Self>;
    type Vec2: Block<Self>;
    // ...
}

The implementations of BlockDom for posh::Gl and posh::Sl simply map to their respective types:

// Implemented in `posh`:

impl BlockDom for Gl {
    type F32 = f32;
    type I32 = i32;
    type U32 = u32;
    type Vec2 = gl::Vec2;
    // ...
}

impl BlockDom for Sl {
    type F32 = sl::F32;
    type I32 = sl::I32;
    type U32 = sl::U32;
    type Vec2 = sl::Vec2;
    // ...
}

With these definitions, Block<D> is defined as follows:

// Defined in `posh`:

pub unsafe trait Block<D: BlockDom>: ToSl {
    type Gl: Block<Gl> + AsStd140 + Pod + ToSl<Output = Self::Sl>;
    type Sl: Block<Sl> + sl::Interpolant + ToSl<Output = Self::Sl>;
    // ... ignoring implementation details ...
}

The associated types Block::Gl and Block::Sl enable posh to map between the shading language representation and the graphics library representation of MyGlobals<D>. The bounds on the associated types specify the required traits that the struct needs to implement in the respective domains.

Let us revisit the MyGlobals<D> type from the initial example:

#[derive(Clone, Copy, Block)]
struct MyGlobals<D: BlockDom> {
    time: D::F32,
    size: D::Vec2,
}

The Block derive macro generates the necessary trait implementations for MyGlobals<Gl> and MyGlobals<Sl>:

// Generated by `derive(Block)`:

// ... impl `AsStd140`, `Pod`, and `ToSl` for `MyGlobals<Gl>` ...
// ... impl `sl::Value` and `sl::Interpolant` for `MyGlobals<Sl>` ...

unsafe impl Block<Gl> for MyGlobals<Gl> {
    type Gl = MyGlobals<Gl>;
    type Sl = MyGlobals<Sl>;
    // ...
}

unsafe impl Block<Sl> for MyGlobals<Sl> {
    type Gl = MyGlobals<Gl>;
    type Sl = MyGlobals<Sl>;
    // ...
}

Core types like f32, i32, u32, gl::Vec2, etc. already come with implementations of Block<Gl>. Therefore, they can be used directly in uniform buffers or vertex buffers without requiring a custom type definition.

Uniform Interface

The uniform interface contains data that is constant on the level of individual draw calls. It encompasses uniform blocks and texture samplers and is the first argument passed to shader functions.

Similar to other interface traits, UniformInterface<D> has a corresponding domain trait that provides a mapping for the types that can be used as uniform inputs:

// Defined in `posh`:

pub trait UniformInterfaceDom {
    type Block<B: Block<Sl, Sl = B>>: UniformInterface<Self>;
    type ColorSampler2d<S: sl::ColorSample>: UniformInterface<Self>;
    // ...
}

The implementation of UniformInterfaceDom for posh::Gl provides uniform binding types, while posh::Sl provides types for accessing uniforms in shader definitions:

// Implemented in `posh`:

impl UniformInterfaceDom for Gl {
    type Block<B: Block<Sl, Sl = B>> = gl::UniformBufferBinding<B>;
    type ColorSampler2d<S: sl::ColorSample> = gl::ColorSampler2d<S>;
    // ...
}

impl UniformInterfaceDom for Sl {
    type Block<B: Block<Sl, Sl = B>> = B;
    type ColorSampler2d<S: sl::ColorSample> = sl::ColorSampler2d<S>;
    // ...
}

The definition of UniformInterface<D> is straightforward. Like Block<D> and other interface traits, it provides associated types Gl and Sl for mapping the implementing struct between the two domains.

// Defined in `posh`:

pub unsafe trait UniformInterface<D: UniformInterfaceDom>: Sized {
    type Gl: UniformInterface<Gl>;
    type Sl: UniformInterface<Sl>;
    // ... ignoring implementation details ...
}

Recalling SceneUniforms<D> from the shadow mapping example shown earlier, we used a derive macro to implement UniformInterface<D> for a custom type.

#[derive(Clone, UniformInterface)]
struct SceneUniforms<D: UniformInterfaceDom> {
    camera: D::Block<Camera<Sl>>,
    light: D::Block<Light<Sl>>,
    light_depth_map: D::ComparisonSampler2d,
}

As a result of implementing UniformInterface<D>, we can use SceneUniforms<Sl> as uniform shader input (i.e., the first argument in shader functions) and SceneUniforms<Gl> to provide uniform bindings for draw calls.

Types that implement Block<D> can be used directly as UniformInterface<D> without the need to define a custom type that contains the block. This is achieved through blanket implementations provided by posh:

// Implemented in `posh`:

unsafe impl<B: Block<Sl, Sl = B>> UniformInterface<Gl>
    for gl::UniformBufferBinding<U> 
{
    type Gl = gl::UniformBufferBinding<B>;
    type Sl = B;
    // ...
}

unsafe impl<B: Block<Sl, Sl = B>> UniformInterface<Sl> for B {
    type Gl = gl::UniformBufferBinding<B>;
    type Sl = B;
    // ...
}

The same applies to sampler types.

In practice, the vertex shader often requires a different subset of uniform inputs than the fragment shader. However, gl::Program<U, ...> specifies only a single type U: UniformInterface<Sl>. To simplify the definition of shader functions, the uniform types required by the fragment shader and the vertex shader can be unified into a single uniform type using the UniformUnion trait.

The UniformUnion trait allows for the following unifications (assuming U, U1, U2: UniformInterface<Sl>):

  1. U + () -> U if U ≠ ().
  2. () + U -> U if U ≠ ().
  3. () + () -> ().
  4. U + U -> U if U ≠ ().
  5. U1 + U2 -> (U1, U2) if U1 ≠ () and U2 ≠ ().
  6. U1 + (U1, U2) -> (U1, U2) if U1 ≠ ().
  7. (U1, U2) + U1 -> (U1, U2) if U1 ≠ ().

For example, using unification #5, we can receive a uniform block containing a projection matrix in a vertex shader and a uniform sampler in a fragment shader, and compile these two shaders into a program that takes the pair of the two individual uniform inputs as follows:

fn vertex_shader(projection: sl::Mat4, vertex: sl::Vec4) -> sl::Vec4 {
    todo!()
}

fn fragment_shader(sampler: sl::ColorSampler2d, interpolant: ()) -> sl::Vec4 {
    todo!()
}

// The uniform input types of the two shader functions are unified to a pair.
let program: gl::Program<(sl::Mat4, sl::ColorSampler2d)> = gl.create_program(
    vertex_shader,
    fragment_shader,
);

Vertex Shader Interface

In most cases, defining a custom vertex struct containing attributes (such as position, color, etc.), and deriving Block<D> for it is sufficient. Such vertex data can be stored in a vertex buffer in the graphics library, and individual vertex values can be read from it in the shading language.

However, in certain scenarios like instanced rendering, it becomes necessary to bind vertex data from multiple vertex buffers. To support this, posh allows users to implement VsInterface<D> for their own structs.

The corresponding domain trait is defined as follows:

// Defined in `posh`:

pub trait VsInterfaceDom: BlockDom {
    type Block<B: Block<Sl>>: VertexField<Self>;
}

Its implementation for posh::Gl provides bindings of vertex buffers, while posh::Sl allows reading from an individual vertex input:

// Implemented in `posh`:

impl VsInterfaceDom for Gl {
    type Block<B: Block<Sl>> = gl::VertexBufferBinding<B>;
}

impl VsInterfaceDom for Sl {
    type Block<B: Block<Sl>> = B;
}

Based on this, the interface trait is defined as follows, following the pattern of other interface traits:

// Defined in `posh`:

pub unsafe trait VsInterface<D: VsInterfaceDom> {
    type Gl: VsInterface<Gl>;
    type Sl: VsInterface<Sl>;
    // ...
}

In the instancing example in the repository, a custom struct VsInput<D> is defined, which contains both per-instance input (a custom block type, Instance<D>) and per-vertex input (a position vector). These inputs can then be accessed in the vertex shader to obtain their current values:

#[derive(Clone, Copy, Block)]
#[repr(C)]
struct Instance<D: BlockDom> {
    model_to_view: D::Mat4,
    color: D::Vec3,
}

#[derive(Copy, Clone, VsInterface)]
struct VsInput<D: VsInterfaceDom> {
    instance: D::Block<Instance<Sl>>,
    model_pos: D::Block<sl::Vec3>,
}

fn vertex_shader(
    camera: Camera<Sl>,
    vertex: VsInput<Sl>,
) -> sl::VsOutput<sl::Vec3> {
    sl::VsOutput {
        clip_position: camera.view_to_screen
            * camera.world_to_view
            * vertex.instance.model_to_view
            * vertex.model_pos.extend(1.0),
        interpolant: vertex.instance.color,
    }
}

On the host side, individual vertex buffer bindings need to be provided for instance data and vertex data. To mark a vertex buffer binding as per-instance data, the with_instancing() method is used.

let program: gl::Program<Camera<Sl>, VsInput<Sl>> = gl.create_program(
    vertex_shader,
    fragment_shader,
)?;

// Ignoring buffer creation...
let instances: gl::VertexBuffer<Instance<Gl>> = todo!();
let teapot: gl::VertexBuffer<gl::Vec3> = todo!();

program
    // ... other bindings ...
    .draw(
        gl::VertexSpec::new(gl::PrimitiveMode::Triangles)
            .with_vertex_data(VsInput {
                instance: instances.as_binding().with_instancing(),
                model_pos: teapot.as_binding(),
            }),
    )?;

Fragment Shader Interface

Finally, let us take a look at custom fragment shader interfaces. So far, the examples have computed a single sl::Vec4 color in their fragment shaders. However, in some cases, it may be necessary to compute multiple colors that are written into individual framebuffer attachments on the host side. To achieve this, we need to implement FsInterface<D> for a custom struct.

The corresponding domain trait is defined as follows, where sl::ColorSample is a trait implemented for core types representing a single sample of the framebuffer in the shading language (e.g., sl::F32 or sl::Vec4):

// Defined in `posh`:

pub trait FsInterfaceDom {
    type ColorAttachment<S: sl::ColorSample>: FsInterface<Self>;
}

Its implementation for posh::Gl provides framebuffer attachments, while posh::Sl provides the type that contains a single output value to be written to the framebuffer in the shading language.

// Implemented in `posh`:

#[sealed]
impl FsInterfaceDom for Gl {
    type ColorAttachment<S: sl::ColorSample> = gl::ColorAttachment<S>;
}

#[sealed]
impl FsInterfaceDom for Sl {
    type ColorAttachment<S: sl::ColorSample> = S;
}

The interface trait follows the same pattern as other interface traits we have seen:

// Defined in `posh`:

pub unsafe trait FsInterface<D: FsInterfaceDom> {
    type Gl: FsInterface<Gl>;
    type Sl: FsInterface<Sl> + sl::Interpolant + ToSl<Output = Self::Sl>;
    // ...
}

A typical use case for a custom fragment shader interface is deferred shading, where the scene is first rendered to screen space textures containing information such as world position, world normal, or color. In the deferred example in the repository, FsInterface<D> is derived for a custom struct that contains three output fields:

#[derive(Clone, Copy, FsInterface)]
pub struct SceneAttachments<D: FsInterfaceDom> {
    albedo: D::ColorAttachment<sl::Vec3>,
    world_normal: D::ColorAttachment<sl::Vec3>,
    world_pos: D::ColorAttachment<sl::Vec3>,
}

On the host side, the struct SceneAttachments<Gl> contains attachments of three individual textures that will be rendered into. On the shading language side, the struct SceneAttachments<Sl> contains expressions in the shading language for the output values of a single fragment shader invocation.

In compiled programs, the shader signature type F: FsInterface<Sl> is captured as the third generic argument to gl::Program<U, V, F>. By default, it is set to sl::Vec4, which is the fragment shader output type compatible with the default framebuffer.

The Shading Language

The shading language posh::sl provides a set of types and primitives that are designed to enable the definition of shaders in readable code embedded in Rust. By embedding shaders in Rust, it becomes trivial to capture their type signatures. This is what makes it possible to integrate typed shaders with the graphics library posh::gl based on the shader interface traits defined in the previous section.

This section takes a closer look at the types and primitives provided by posh::sl. It also shows how these types enable transpiling user-defined shader functions to valid GLSL code at runtime.

Types

The shading language provides a variety of types that correspond to GLSL types. Here are the available core types:

  • Scalar types sl::F32, sl::I32, sl::U32, sl::Bool.
  • Floating-point vector types: sl::Vec2, sl::Vec3, and sl::Vec4.
  • Integer vector types: sl::IVec2, sl::IVec3, and sl::IVec4.
  • Unsigned integer vector types: sl::UVec2, sl::UVec3, and sl::UVec4.
  • Boolean vector types: sl::BVec2, sl::BVec3, and sl::BVec4.
  • Floating-point matrix types: sl::Mat2, sl::Mat3, and sl::Mat4.

The API for vector and matrix types largely imitates glam. In some places, methods have been modified to better match GLSL.

Constant values can be converted to posh::sl with the sl::ToSl trait, which provides the to_sl() method. For example, you can convert a f32 to an sl::F32 type as follows:

use posh::ToSl;

let c: sl::F32 = 5.0f32.to_sl();

The shading language also supports pairs of arbitrary types. However, larger tuples are yet to be implemented. Constant-size arrays are supported through sl::Array<V, N>, where V is the inner type and const N: usize is the size of the array.

In addition to these basic types, posh::sl provides sampler types such as sl::ColorSampler2d<S> and sl::ComparisonSampler2d. However, some sampler types, like cube maps, are yet to be implemented.

Values

Internally, all types that occur in posh::sl expressions implement the trait sl::Object, which allows converting the value into an expression graph.

// Defined in `posh`:

pub trait Object {
    fn ty() -> Type;

    // Implementation detail:
    fn expr(&self) -> Rc<Expr>;

    // ...
}

The Expr type is an internal enum in posh::sl that represents an expression graph. It is used for transpilation to GLSL. It consists of different variants such as Binary for binary operations and Field for accessing struct fields.

// Defined in `posh`:

enum Expr {
    Binary {
        left: Rc<Expr>,
        op: BinaryOp,
        right: Rc<Expr>,
        ty: Type,
    },
    Field {
        base: Rc<Expr>,
        name: &'static str,
        ty: Type,
    },
    // ...
}

Most types in the shading language also implement the Value trait in addition to Object. The Value trait indicates that a type can be stored in variables in the generated GLSL code. Types implementing Value are called transparent. This distinction is necessary due to a rule in GLSL, by which opaque types like sampler2D can not be stored in variables. In order to implement Value, types need to provide a method for constructing Self from an Expr:

// Defined in `posh`:

pub trait Value: Object + Copy + ToSl<Output = Self> {
    // Implementation detail:
    fn from_expr(expr: Expr) -> Self;

    // ...
}

The core types provided by the shading language implement the Value and Object traits as appropriate. User-defined structs can implement Value using the derive macro derive(Value).

One important detail to notice is that Value has Copy as a base trait. This design choice was made to make shader code look more natural, as values in GLSL are implicitly copyable. However, this poses a problem, because the Expr type cannot implement Copy due to its fields of type Rc<Expr>. To work around this, posh uses a hack where Rc<Expr> instances are stored in a global thread_local registry of type BTreeMap<usize, Rc<Expr>>. Values in the shading language then store the key for looking up the Rc<Expr> from this map with a wrapper type called Trace. For example, the scalar floating-point type sl::F32 is defined like this:

// Defined in `posh`:

#[derive(Debug, Copy, Clone)]
pub struct F32(Trace);

impl Object for F32 {
    fn ty() -> Type {
        Type::BuiltIn(BuiltInType::F32)
    }

    fn expr(&self) -> Rc<Expr> {
        // Look up `Rc<Expr>` from the global registry using our `Trace` key.
        self.0.expr()
    }
}

impl Value for F32 {
    fn from_expr(expr: Expr) -> Self {
        // Make a new entry in the global registry and obtain a `Trace` key.
        Self(Trace::new(expr))
    }
}

This approach allows values to be Copy, making shader code more intuitive to write. However, it has some downsides, such as the question of invalidation of entries in the registry, which is currently not addressed in the implementation.

Primitives

The shading language posh::sl provides a range of primitive functions that allow the creation of complex expressions out of simpler ones.

For scalar types, vector types, and matrix types, the arithmetic operators are overloaded. This enables, for example, vector-vector, vector-scalar, matrix-matrix, or matrix-vector operations. These operators take the expressions of their inputs, represented as Rc<Expr>, and combine them into a new expression that describes the computation. As an example, the addition operator is implemented for the scalar floating-point type sl::F32 as follows:

// Implemented in `posh`:

impl std::ops::Add<sl::F32> for sl::F32 {
    type Output = Self;

    fn add(self, right: sl::F32) -> Self {
        F32::from_expr(Expr::Binary {
            ty: Self::ty(),
            left: self.expr(),
            op: BinaryOp::Add,
            right: right.expr(),
        })
    }
}

Other operators provided by posh::sl follow the same pattern, utilizing the expressions of their constituents to construct a new expression.

In addition to overloaded operators, there are functions and methods for additional primitives that cannot be defined through operator overloading in Rust:

  • sl::and and sl::or: Binary boolean operations on sl::Bool.
  • sl::all and sl::any: Boolean operations on iterators of sl::Bool.
  • sl::Value::eq: Checks equality between two values.
  • Methods like sl::I32::lt (less than) for comparing values.

One important primitive function is sl::branch(condition, yes, no), which represents a conditional expression. If the condition is true, the yes value is returned; otherwise, the no is returned. In the implementation of posh::sl, transpilation of this function requires special care to ensure correct scoping of variables.

It is worth noting that all of the provided primitives in posh::sl are functional, meaning they are stateless and do not have side effects. As of now, posh::sl does not support mutable values, and there are no immediate plans to introduce them. However, there are plans to provide a primitive function like sl::iterate to support loop constructs in the future.

Transpilation

The shading language in posh has been specifically designed to obtain Rc<Expr> representations of user-defined shader functions. This is achieved by evaluating the user’s vertex shader and fragment shader functions once at runtime, resulting in posh::sl values that carry expression graphs.

Now, given a list of Rc<Expr>, we could simply turn them into GLSL code recursively, but this is not practical. By defining variables (with Rust’s usual let statement), it becomes possible for users to refer to the same expression multiple times. This would lead to a possible exponential blowup in the size of the generated GLSL code.

Therefore, posh::sl takes care to turn expressions that are referred to multiple times into actual variables in the GLSL code. This is done internally by VarForm, which applies a topological sort to expressions.

However, the introduction of GLSL variables leads to a second challenge, since posh offers conditional expressions in the form of sl::branch. If a user refers to an expression in only one of the branches, the inferred variable must be scoped to that branch. posh addresses this problem by inferring a tree of scopes and placing variable declarations in the lowest common ancestor of all scopes that refer to the expression. This is done internally by ScopeForm.

Here is an example. Consider this (nonsensical) vertex shader:

#[derive(Copy, Clone, Block)]
#[repr(C)]
struct MyVertex<D: BlockDom> {
    position: D::Vec4,
    color: D::Vec4,
}

fn my_vertex_shader(
    mode: sl::U32,
    vertex: MyVertex<Sl>,
) -> sl::VsOutput<MyVertex<Sl>> {
    let shifted_position = vertex.position + 2.0;
    let sin_position = shifted_position.sin();
    let complex_vertex = MyVertex::<Sl> {
        position: sin_position.cos().powf(2.0),
        color: sin_position,
    };

    let interpolant = sl::branch(mode.eq(42u32), vertex, complex_vertex);

    sl::VsOutput {
        clip_position: vertex.position,
        interpolant,
    }
}

The generated GLSL code contains the definitions of user-defined structs used in the shader. It also includes the inputs and outputs of the shader stage. Notably, variables such as var_0 and var_1 are inferred since their expressions are referenced multiple times. Crucially, var_0 is scoped to the else branch because the expression is only used there.

struct MyVertex_Posh0 {
    vec4 position;
    vec4 color;
};

layout(std140) uniform uniforms_posh_block {
    uint uniforms;
};

in vec4 vertex_input_position;
in vec4 vertex_input_color;
smooth out vec4 vertex_output_position;
smooth out vec4 vertex_output_color;

void main() {
    MyVertex_Posh0 var_1;
    if ((uniforms == 42u)) {
        var_1 = MyVertex_Posh0(vertex_input_position, vertex_input_color);
    } else {
        vec4 var_0 = sin((vertex_input_position + 2.0));
        var_1 = MyVertex_Posh0(pow(cos(var_0), (vec4(1.0, 1.0, 1.0, 1.0) * 2.0)), var_0);
    }
    gl_Position = vertex_input_position;
    vertex_output_position = var_1.position;
    vertex_output_color = var_1.color;
}

The Graphics Library

The graphics library posh::gl offers methods to create and update GPU buffers. These GPU buffers can be used as bindings in draw calls as long as they match the signature of the shader.

This article will not delve into the full API details of posh::gl. However, here is a brief summary of the current GPU objects available:

  • VertexBuffer<B> where B: Block<Gl>, which can be bound as a VertexBufferBinding<B>.
  • ElementBuffer<E> where E is u16 or u32.
  • UniformBuffer<B> where B: Block<Gl>, which can be bound as a UniformBufferBinding<B>.
  • ColorTexture2d<S> where S: ColorSample, which can be bound as a ColorSampler2d<S>, or attached to a framebuffer as a ColorAttachment<S>.
  • DepthTexture2d, which can be bound as a ColorSampler<sl::F32>, or as a ComparisonSampler2d, or attached to a framebuffer as a DepthAttachment.

Internally, these objects are generic wrappers around untyped OpenGL code in posh::gl::raw. This design allows clear separation between the actual implementation of OpenGL code and the typed abstraction provided by the library.

The key component that ties everything together is the Program<U, V, F> type, where:

  • U: UniformInterface<Sl> defines the uniform inputs of the program.
  • V: VsInterface<Sl> defines the inputs of the vertex shader.
  • F: FsInterface<Sl> defines the outputs of the fragment shader.

Typically, users compile their shader functions into a Program<U, V, F> at the beginning of their program, and then use it for drawing in the main loop.

Now, finally, we can look at the draw method, which performs a draw call using compatible GPU buffer bindings. To simplify the setup of draw calls, the necessary inputs are provided by using the builder pattern. Here is an example path through the builder, focusing on the type signatures:

// Implemented in `posh`:

impl<U, V, F> Program<U, V, F>
where
    U: UniformInterface<Sl>,
    V: VsInterface<Sl>,
    F: FsInterface<Sl>,
{
    #[must_use]
    pub fn with_uniforms(
        &self,
        uniforms: U::Gl,
    ) -> DrawBuilderWithUniforms<U, V, F> {
        // ...
    }
}

In this code snippet, U::Gl is used to map the representation of U from the shading language to its representation in the graphics library, through which uniform bindings are provided.

The DrawBuilderWithUniforms<U, V, F> struct serves as a helper for the builder, carrying the information that data for U has already been provided. Once users have this struct, they only need to provide a vertex specification to complete the draw call:

// Implemented in `posh`:

impl<U, V> DrawBuilderWithUniforms<U, V, sl::Vec4>
where
    U: UniformInterface<Sl>,
    V: VsInterface<Sl>,
{
    pub fn draw(self, vertex_spec: VertexSpec<V>) -> Result<Self, DrawError> {
        // ... actually perform the draw call with `posh::gl::raw` ...
    }
}

Here, VertexSpec<V> represents a vertex stream compatible with V. It contains bindings for the required vertex buffers, the primitive type, and optionally an element buffer.

In this example, we have not considered user-defined fragment shader interfaces, so this particular draw method is useful only if F = sl::Vec4. There are other paths through the builder that allow providing framebuffers.

That is all! By defining the draw method in this way, the library ensures that the GPU bindings are aligned with the shader signature, providing type safety and preventing mismatched bindings during draw calls.