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.
-
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.
Let's start with a simple example, we'll be drawing a fullscreen quad with a single fragment shader
<ShadeupInline code={fn main() { draw(shader { out.color = (in.uv, 0.5, 1); }); }
} />
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.
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:
<ShadeupInline code={fn main() { let myCpuValue = sin(env.time);\n draw(shader { out.color = myCpuValue.xyzw; }); }
} />
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.
Here's a more complex example that shows off a few more features:
<ShadeupInline code={struct Circle { pub position: float2, pub radius: float, pub color: float4, }\n let arr: Circle[] = [];\n for (let i = 0; i < 10; i++) { arr.push(Circle { position: (rand(), rand()) * env.screenSize, radius: rand() * 40, color: (rand(), rand(), rand(), 1), }); }\n fn main() { draw(shader { for (let i = 0; i < arr.len(); i++) { if (dist(in.screen, arr[i].position) < arr[i].radius) { out.color = arr[i].color; } } }); }
} />
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)
Now that we have a basic understanding of how to pass data into a shader, let's look at how to draw a mesh.
<ShadeupInline code={fn main() { // Create a cube mesh at the origin // with a size of 100 let cube = mesh::box(0.xyz, 100.xyz);\n let mat = env.camera.getCombinedMatrix();\n draw(cube, shader { // Vertex shader out.position = mat * (in.position, 1); }, shader { // Fragment shader out.color = ( dot(in.normal, normalize((1, 2, 1))).xyz, 1 ); }); }
} />
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
Off-screen textures are an important part of any graphics API and shadeup is no exception. Let's look at a simple example:
<ShadeupInline code={let tex = texture2d<float4>(env.screenSize);\n fn main() { let cube = mesh::box(0.xyz, 100.xyz);\n let mat = env.camera.getCombinedMatrix();\n tex.clear(); tex.drawAdvanced({ mesh: cube, vertex: shader { out.position = mat * (in.position + (in.instanceIndex * 110.0, 0, 0), 1); }, fragment: shader { out.color = ( dot(in.normal, normalize((1, 2, 1))).xyz, 1 ); }, instances: 10, );\n draw(shader { out.color = tex.sample((in.uv * 4) % 1).xyzw; });\n }
} />
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:
<ShadeupHighlight code={let myDepth = texture2d<float>(env.screenSize, "depth"); let myColor = texture2d<float4>(env.screenSize, "16bit");
} />
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.
drawAdvanced()
provides a lot of flexibility when it comes to drawing meshes or index buffers:
<ShadeupHighlight code={let cube = mesh::box(0.xyz, 100.xyz);\n fn main() { drawAdvanced({ // Provide one of the following mesh or indexBuffer: mesh: cube, // or indexBuffer: buffer<uint>\n\n // Provide both a vertex and fragment shader: vertex: shader { ... }, fragment: shader { ... },\n\n // Provide any of the following: indirect?: buffer<uint> | buffer<atomic<uint>>; // Indirect buffer standard (drawIndirect parameter order) indirectOffset?: int | uint; depth?: texture2d<float>; // Override depth texture depthOnly?: boolean; // Only write to the depth buffer? instances?: int; // Number of instances to draw }); }
} />
You can also draw into multiple textures at once using attachments:
<ShadeupHighlight code={let cube = mesh::box(0.xyz, 100.xyz);\n fn main() { let color = texture2d<float4>(100.xy); let normal = texture2d<float3>(100.xy);\n drawAdvanced({ mesh: cube,\n vertex: shader { ... }, fragment: shader { out.attachment0 = (1, 0, 0, 1); out.attachment1 = (0, 1, 0); },\n attachments: [albedo, normal], }); }
} />
Buffers are fairly simple to use:
<ShadeupInline code={let buf = buffer<float>(64);\n buf[0] = 1; buf.upload();\n fn main() { compute((2, 1, 1), shader<32, 1, 1> { buf[in.globalId.x] = buf[in.globalId.x] + 1; });\n buf.download();\n print(buf[0]); }
} />
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:
<ShadeupHighlight code={fn main() { async { buf.download(); // Buffer is now downloaded at some point in the future (could be this frame, next frame, etc.) } }
} />
<ShadeupInline code={let buf = buffer<atomic<uint>>(2);\n let inCircle = 0u; let numSamples = 0u;\n fn main() { buf[0].store(0u); buf[1].store(0u); buf.upload();\n compute((100, 100, 1), shader<16, 16, 1> { let randomSeed = float2(in.globalId.xy) + (env.frame * 1000.0).xy;\n let randomX = rand2((randomSeed.x, randomSeed.y)); let randomY = rand2((randomSeed.y + 251, randomSeed.y * 24.0)); if (length((randomX, randomY)) < 1.0) { buf[0].add(1u); // This point falls inside the circle } buf[1].add(1u); });\n buf.download();\n inCircle += buf[0].load(); numSamples += buf[1].load();\n stat("Apprx", (float(inCircle) / float(numSamples)) * 4.0); stat("Real", PI); stat("Total Samples", numSamples); }
} />
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
<ShadeupInline code={fn main() { let buf = buffer<uint>(1);\n compute((1, 1, 1), shader<16, 16, 1> { workgroup { count: atomic<uint> }\n count.add(1u);\n workgroupBarrier();\n if (in.globalId.x == 0 && in.globalId.y == 0) buf[0] = count.load(); });\n buf.download(); print(buf[0]); }
} />
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
<ShadeupInline code={let myValue = 50.0;\n fn main() { myValue = ui::slider(myValue, 0, 100);\n if (ui::button("Click me")) { print("Clicked!"); }\n ui::group("My Group"); ui::label("Hello World"); ui::textbox("This is a textbox") ui::pop(); }
} />
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
<ShadeupInline code={let pos = env.screenSize / 2.0;\n fn main() { pos = ui::puck(pos);\n if (dist(pos, env.screenSize / 2.0) > 100.0) { let angle = atan2(pos.y - env.screenSize.y / 2.0, pos.x - env.screenSize.x / 2.0); pos = (cos(angle), sin(angle)) * 100.0 + env.screenSize / 2.0; }\n draw(shader { if (dist(pos, in.screen) < 20) { out.color = 1.xyzw; } }); }
} />
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.
<ShadeupInline code={fn main() { stat("Frame count", env.frame); }
} />
<ShadeupInline code={fn main() { statGraph("sin(time)", abs(sin(env.time))); }
} />
That's it for the crash course, if you feel like digging into some examples check out the following:
<ProjectGallery ids={["kqk9w1su3719", "encdvj1wog5c", "1k94158buq0y"]} />