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 aninand anoutstruct. 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 tofloat4(in.uv, 0.5, 1). If you pass allints(1, 0, 0)it’ll be anint3and 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,float4x4etc. - 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 programdeltaTime: The time in seconds since the last frameframe: The current frame numberscreenSize: The resolution of the screenmouse: Special mouse data (likeenv.mouse.screen)keyboard: Special keyboard data (likeenv.keyboard.keySpace)camera: User controllable camera with apositionrotationfovnearandfarproperties- …
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
stringfield 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’sprojectionandviewmatrices. 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 originMiddle click drag: Pan the cameraScroll: Zoom in and out
- free mode (hold right click to unlock):
WASD: Move the cameraRight click drag: Rotate the cameraRight click hold + Scroll: Incrase/decrease movement speedE/Q: Move up/downC/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 aint2oruint2pixel 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 =intoruint- See the Reference for all the atomic functions
statis a helper function that shows a labeled value on the top right of the screen
Workgroup scope
workgroupis a special scope that lets you share data between threads in a workgroupworkgroupBarrieris 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::slidercreates a slider that returns a valueui::buttoncreates a button that returns true when clickedui::groupcreates a collapsable groupui::labelcreates a labelui::textboxcreates 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