Shadeup Crash Course

If you’re familiar with APIs like DirectX or vulkan, then this quick intro is for you, shadeup aims to bring the power of those APIs but without as much hassle. It’s still a work in progress, but it’s usable today.

Core values

  • Bridging Worlds: Shadeup smoothly connects CPU and GPU code, making it easier for them to work in tandem. You can write a function once and share it between the two without the need to hand-transpile.

  • Cleaner Syntax: One of the core principals while designing shadeup was the ability to distil an algorithm to its fundemental parts and keep the boilerplate hidden. With shadeup, you’ll find a more streamlined way to handle uniforms, buffer modifications, and texture drawings. Less clutter, more clarity.

  • Helpful Features: Shadeup comes with a lot of helper tools and functions out of the box. These let you focus on crafting, not just typing trivial code.

Jumping in

Let’s start with a simple example, we’ll be drawing a fullscreen quad with a single fragment shader

There’s a bit to unpack here so I’ll bullet them:

  • fn main(): This is the frame loop, it runs every frame on the CPU.
  • draw(shader { ... }): This is the draw call, it takes a shader as an argument and runs it on the GPU. This has a few overloads but passing a single shader argument will dispatch a fullscreen quad draw.
  • out.color: Every shader has an in and an out struct. Here we’re just setting the color of the fragment shader to a vector.
  • in.uv: As you can guess, this is the UV coordinate of the fragment. In this case it’s spanning the screen
  • (in.uv, 0.5, 1): Shadeup lets you define vectors by just listing their components, this is equivalent to float4(in.uv, 0.5, 1). If you pass all ints (1, 0, 0) it’ll be an int3 and so on.

Uniforms

Getting data into a shader is done via uniforms (or texture/buffer bindings). Making this as simple as possible was a core goal of shadeup. Let’s look at a simple example:

You’ll notice we can define a variable on the CPU and then pull that into our shader by simply referencing it. This is called a closure and allows you to pass data from the CPU to the GPU.

A lot of data-types are supported, including:

  • Any numeric primitive e.g. int, float, uint, float3, float4x4 etc.
  • Arrays
  • Structs
  • buffer<T>
  • texture2d<T>

Things like map<K, T> and string are not supported among others.

I also slipped in a swizzle up operator: .xyzw. Any single component can be swizzled up to a vector of the same type. So 1.xyz is equivalent to int3(1, 1, 1) and 5.0.xy is float2(5.0, 5.0).

Finally, we introduced the env global, this is a special struct that contains data about the current frame. Its contents are:

  • time: The time in seconds since the start of the program
  • deltaTime: The time in seconds since the last frame
  • frame: The current frame number
  • screenSize: The resolution of the screen
  • mouse: Special mouse data (like env.mouse.screen)
  • keyboard: Special keyboard data (like env.keyboard.keySpace)
  • camera: User controllable camera with a position rotation fov near and far properties

You can view the full list in the Reference.


Additional uniform example

Here’s a more complex example that shows off a few more features:

We can define structs and arrays of structs on the CPU and pass them into the GPU. This is a very powerful feature that lets you define complex data structures on the CPU and then use them in your shaders.

Note:

  • Non-cpu data is stripped away when you pass a struct into a shader. So if you have a string field on a struct, it’ll be stripped away when you pass it into a shader.
  • Dynamic arrays inside structs are not supported. These will be stripped away.
  • You can use structured buffers for more effecient data passing. Arrays are uploaded each dispatch, while buffers are uploaded once and can be read/written to on the GPU.
    • let arr = buffer<Circle>(1000)

Drawing a cube

Now that we have a basic understanding of how to pass data into a shader, let’s look at how to draw a mesh.

I’ll touch on a couple important parts:

  • If you pass 3 arguments into draw it’ll draw a mesh with a vertex and fragment shader.
  • env.camera.getCombinedMatrix() is a helper function that returns a matrix that combines the camera’s projection and view matrices. More on this directly below

The env.camera is a built-in camera that has the following controls:

  • orbit mode (default):
    • Left click drag: Rotate the camera around the origin
    • Middle click drag: Pan the camera
    • Scroll: Zoom in and out
  • free mode (hold right click to unlock):
    • WASD: Move the camera
    • Right click drag: Rotate the camera
    • Right click hold + Scroll: Incrase/decrease movement speed
    • E/Q: Move up/down
    • C/Z: Adjust fov

Drawing into a texture

Off-screen textures are an important part of any graphics API and shadeup is no exception. Let’s look at a simple example:

You can create textures via texture2d<T>(size) where T is any numeric vector/scalar primitive.

By default textures will be created with their respective 32-bit numeric format (int, float, uint), but you can specify a different format via texture2d<T>(size, format).

Example formats:

Textures have a lot of the same functions as the normal root drawing scope (draw, drawAdvanced). They include their own depth buffer and can be used as a render target.

At the moment filtering defaults to linear and cannot be changed. You have two options for reading from a texture:

  • tex.sample(uv): This will return a filtered value from the texture.
  • tex[pixel]: This will return the exact value from the texture and expects a int2 or uint2 pixel coordinate.

Advanced drawing

drawAdvanced() provides a lot of flexibility when it comes to drawing meshes or index buffers:

You can also draw into multiple textures at once using attachments:

Writing to a buffer via compute

Buffers are fairly simple to use:

Creation is done via buffer<T>(size) where T is any primitive, atomic or user-defined struct. You can mutate the buffer on the CPU and then upload it to the GPU via buf.upload(). You can also download the buffer from the GPU via buf.download().

Any type of shader can read/write to a buffer.

buffer.download() is a slow blocking operation and should not be used directly within the frame loop for large buffers. You can instead async the operation like so:


Atomics

The above example demonstrates a very poor approximation of PI using a monte carlo method. It also shows how to use atomics to share data between the CPU and GPU.

  • atomic<T> where T = int or uint
  • See the Reference for all the atomic functions
  • stat is a helper function that shows a labeled value on the top right of the screen

Workgroup scope

  • workgroup is a special scope that lets you share data between threads in a workgroup
  • workgroupBarrier is a special function that ensures all threads in a workgroup have reached that point before continuing

UI

The ui module provide immediate mode UI components.

  • ui::slider creates a slider that returns a value
  • ui::button creates a button that returns true when clicked
  • ui::group creates a collapsable group
  • ui::label creates a label
  • ui::textbox creates a textbox that returns a string

The ui::puck function creates a draggable puck that returns a position.

You can change the values in between frames to restrict the puck’s movement.

Stat

Wrapping up

That’s it for the crash course, if you feel like digging into some examples check out the following:


Loading null