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 anin
and anout
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 tofloat4(in.uv, 0.5, 1)
. If you pass allint
s(1, 0, 0)
it’ll be anint3
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 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 aposition
rotation
fov
near
andfar
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’sprojection
andview
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 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 aint2
oruint2
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
oruint
- 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 workgroupworkgroupBarrier
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 valueui::button
creates a button that returns true when clickedui::group
creates a collapsable groupui::label
creates a labelui::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