Skip to content

Instantly share code, notes, and snippets.

@AskingQuestions
Created April 24, 2024 23:40
Show Gist options
  • Save AskingQuestions/85c84e5a178b85e8e7b41c1800e2f01c to your computer and use it in GitHub Desktop.
Save AskingQuestions/85c84e5a178b85e8e7b41c1800e2f01c to your computer and use it in GitHub Desktop.
Shadeup lang prompt
The following is a specification for a language. This language is called Shadeup
Shadeup is a language that makes writing shaders and computer graphics code easier, it's built on Typescript and looks similar to hlsl.
Core values:
- Easy to read, boilerplate is abstracted away
- CPU/GPU Sharing of functions and data-structures. Write code once and reuse on the cpu or gpu.
- Runs on webgpu
Some basic syntax:
While similar to hlsl you can only define variables with let:
let a = 1; // inferred as int
let b: int = 2;
const c = 3.0; // inferred as float
const d: float = 4;
## Vectors:
(1, 2, 3); // int3 vector
(1.0, 2, 3); // float3 vector
float3(1.0, 2, 3); // float3 vector
int3(1, 2, 3); // int3 vector
float3x3(
1, 0, 0,
0, 1, 0,
0, 0, 1,
); // float3x3 matrix
let vec = (1, 2, 3);
vec.x; // 1
vec.xy; // (1, 2)
vec.xyzx; // (1, 2, 3, 1)
1.xyz // (1, 1, 1)
1.0.xyz // (1.0, 1.0, 1.0)
1.xyzw.xw // (1, 1)
Let's start with an example:
fn main() {
draw(shader {
out.color = (in.uv, 0.5, 1);
});
}
Explainer:
- `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 `int`s `(1, 0, 0)` it'll be an `int3` and so on.
- `draw()` is queued up automatically and dispatched at the end of the frame, shadeup will automatically flush any queued up draw calls or compute calls when needed.
You can use uniforms like so:
fn main() {
let myCpuValue = sin(env.time / 1000.0);
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.
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
Here's another example:
struct Circle {
pub position: float2,
pub radius: float,
pub color: float4,
}
let arr: Circle[] = [];
for (let i = 0; i < 10; i++) {
arr.push(Circle {
position: (rand(), rand()) * env.screenSize,
radius: rand() * 40,
color: (rand(), rand(), rand(), 1),
});
}
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 `pub` 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)`
Here's how we can draw a mesh:
fn main() {
// Create a cube mesh at the origin
// with a size of 100
let cube = mesh::box(0.xyz, 100.xyz);
let mat = env.camera.getCombinedMatrix();
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
);
});
}
Here's an example of render targets:
let tex = texture2d<float4>(env.screenSize);
fn main() {
let cube = mesh::box(0.xyz, 100.xyz);
let mat = env.camera.getCombinedMatrix();
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,
);
draw(shader {
out.color = tex.sample((in.uv * 4) % 1).xyzw;
});
}
Here's how buffers work:
let buf = buffer<float>(64);
buf[0] = 1;
buf.upload();
fn main() {
compute((2, 1, 1), shader<32, 1, 1> {
buf[in.globalId.x] = buf[in.globalId.x] + 1;
});
buf.download();
print(buf[0]);
}
Shadeup inherits a number of types from HLSL. The following types are available:
- `int`
- `int2`, `int3`, `int4`
- `float`
- `float2`, `float3`, `float4`
- `float2x2`, `float3x3`, `float4x4`
- `bool`
- `string` *(non-GPU)*
- `.split(sep: string) -> string[]`
- `.includes(substr: string) -> bool`
- `.startsWith(str: string) -> bool`
- `.endsWith(str: string) -> bool`
- `.replace(from: string, to: string)`
- `.trim(chars: string = ' \t
') -> string`
- `.lower() -> string`
- `.upper() -> string`
- `.substr(start: int, end: int) -> string`
- `.len() -> int`
- `[index: int] -> string`
- `T[]` *Variable length arrays* (partial GPU support)
- `.join(sep: string) -> string`
- `.push(val: T) -> string`
- `.len() -> int`
- `.first() -> T`
- `.last() -> T`
- `.append(vals: T[])`
- `.remove(index: int)`
- `[index: int] -> T`
- `T[3]` *Fixed length arrays* same methods as above
- `map<K extends Hashable, V>` *(non-GPU)*
- `.has(key: K) -> bool`
- `.set(key: K, value: V)`
- `.get(key: K) -> V`
- `.delete(key: K)`
- `.keys() -> K[]`
- `.values() -> V[]`
- `[index: K] -> V`
- `shader` *Instantiated shader*
Math:
abs((-1, 1)); // (1, 1)
2 * (3, 4) // (6, 8)
(1, 2) + (3, 4) // (4, 6)
dot((1, 2), (3, 4))
dist((0, 0, 0), (1, 2, 3))
reflect((1, 0, 0), (0, 1, 0))
## Type casting:
let a = 1.0;
let b = int(a); // b is now 1
## Conditionals:
let a = 1;
let b = 2;
if (a == b) {
// do something
} else if (a > b) {
// do something else
} else {
// do something else
}
let c = a == b ? 1 : 2;
## Structs
struct Ball {
position: float3;
velocity: float3;
radius: float;
}
let myBall = Ball {
position: float3(0.0, 0.0, 0.0),
// velocity and radius will default to 0.0
};
## Methods:
impl Ball {
fn update(self) {
self.position += self.velocity;
}
fn new() -> Ball {
return Ball {
position: float3(0.0, 0.0, 0.0),
velocity: float3(0.0, 0.0, 0.0),
radius: 0.0,
};
}
}
let myNewBall = Ball::new();
myNewBall.update();
Shadeup supports `for` and `while` loops. `break` and `continue` are also supported.
for (let i = 0; i < 10; i++) { continue; }
while (true) { break; }
for (let (x, y) of (10, 20)) { /* 2d for loop (left-right, top-down) */ }
for (let (x, y, z) of (10, 20, 30)) { /* 3d for loop (left-right, top-down, front-back) */ }
## Atomics example:
let buf = buffer<atomic<uint>>(2);
let inCircle = 0u;
let numSamples = 0u;
fn main() {
buf[0].store(0u);
buf[1].store(0u);
buf.upload();
compute((100, 100, 1), shader<16, 16, 1> {
let randomSeed = float2(in.globalId.xy) + (env.frame * 1000.0).xy;
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);
});
buf.download();
inCircle += buf[0].load();
numSamples += buf[1].load();
stat("Apprx", (float(inCircle) / float(numSamples)) * 4.0);
stat("Real", PI);
stat("Total Samples", numSamples);
}
## Workgroup example:
fn main() {
let buf = buffer<uint>(1);
compute((1, 1, 1), shader<16, 16, 1> {
workgroup {
count: atomic<uint>
}
count.add(1u);
workgroupBarrier();
if (in.globalId.x == 0 && in.globalId.y == 0)
buf[0] = count.load();
});
buf.download();
print(buf[0]);
}
API Reference:
## buffer<T>
methods:
len( ) -> int
Returns the underlying cpu buffer as a typed array.
[!NOTE] This is considerably faster than using the raw index [] operator.
[!NOTE] If the buffer contents are structured (atomic, or a struct), this will return a normal array
123456
let buf = buffer<uint>();
let data = buf.getData();
for (let i = 0; i < data.length; i += 4) {
// Do something with data[i]
}
getData( ) -> T[] | Float32Array | Int32Array | Uint32Array | Uint8Array
write( other: buffer_internal<T> ) -> void
download( ) -> Promise<void>
downloadAsync( ) -> Promise<void>
upload( ) -> void
## texture2d<T>
properties:
size: float2 = [0, 0]
paint: PaintingContext = null as any
methods:
draw: { (geometry: Mesh, vertexShader: shader<ShaderInput, ShaderOutput, 0>, pixelShader: shader<ShaderInput, ShaderOutput, 0>): void; (fullScreenPixelShader: shader<...>): void; }
drawIndexed: (indexBuffer: buffer<uint>, vertexShader: shader<ShaderInput, ShaderOutput, 0>, pixelShader: shader<ShaderInput, ShaderOutput, 0>) => void
drawAdvanced: { <A0, A1, A2, A3, A4, A5, A6, A7>(descriptor: DrawDescriptorBase & AttachmentBindings8<A0, A1, A2, A3, A4, A5, A6, A7>): void; <A0, A1, A2, A3, A4, A5, A6>(descriptor: DrawDescriptorBase & AttachmentBindings7<...>): void; <A0, A1, A2, A3, A4, A5>(descriptor: DrawDescriptorBase & AttachmentBindings6<...>): void; <A0...
Methods
__index( index: int2 | uint2 ) -> T
__index_assign( index: int2 | uint2,value: T ) -> void
getFast( index: int2 | uint2 ) -> T
setFast( index: int2 | uint2,value: T ) -> void
download( ) -> void
downloadAsync( ) -> Promise<void>
Returns the underlying cpu buffer as a typed array.
Note that this is considerably faster than using the raw index [] operator.
1234567891011
let tex = texture2d<float4>();
let data = tex.getData();
for (let i = 0; i < data.length; i += 4) {
let r = data[i];
let g = data[i + 1];
let b = data[i + 2];
let a = data[i + 3];
// Do something with the pixel
getData( ) -> Float32Array | Int32Array | Uint32Array | Uint8Array
upload( ) -> void
sample( position: float2 ) -> float4
clear( ) -> void
flush( ) -> void
Release the texture
destroy( ) -> void
## ShaderOutput (aka out):
Properties
Vertex output position
position: float4 = float4(0, 0, 0, 0)
Vertex output normal
normal: float3 = float4(0, 0, 0, 0)
UV channel 0 output
uv: float2 = float2(0, 0)
UV channel 1 output
uv1: float2 = float2(0, 0)
UV channel 2 output
uv2: float2 = float2(0, 0)
UV channel 3 output
uv3: float2 = float2(0, 0)
UV channel 4 output
uv4: float2 = float2(0, 0)
UV channel 5 output
uv5: float2 = float2(0, 0)
UV channel 6 output
uv6: float2 = float2(0, 0)
UV channel 7 output
uv7: float2 = float2(0, 0)
Pixel color output
color: float4 = float4(0, 0, 0, 0)
Methods
attr< T > ( index: int,value: T,interpolation: "flat" | "linear" | "perspective" | undefined ) -> void
## ShaderInput (aka in):
Properties
Interpolated world position (available in fragment, and vertex)
position: float3 = float3(0, 0, 0)
Interpolated normal (fragment), Source mesh normal (vertex)
normal: float3 = float3(0, 0, 0)
Vertex shader output position
clipPosition: float4 = float4(0, 0, 0, 0)
realPosition: float4 = float4(0, 0, 0, 0)
UV channel 0 input (available in fragment, and vertex)
uv: float2 = float2(0, 0)
UV channel 1 input
uv1: float2 = float2(0, 0)
UV channel 2 input
uv2: float2 = float2(0, 0)
UV channel 3 input
uv3: float2 = float2(0, 0)
UV channel 4 input
uv4: float2 = float2(0, 0)
UV channel 5 input
uv5: float2 = float2(0, 0)
UV channel 6 input
uv6: float2 = float2(0, 0)
UV channel 7 output
uv7: float2 = float2(0, 0)
Screen position in pixels (available in fragment, and vertex)
screen: float2 = float2(0, 0)
Interpolated vertex color (available in fragment, and vertex)
color: float4 = float4(0, 0, 0, 0)
Group ID (available in compute)
groupId: int3 = int3(0, 0, 0)
Group size (available in compute)
groupSize: int3 = int3(0, 0, 0)
Global id (groupId * groupSize + localId) (available in compute)
globalId: int3 = int3(0, 0, 0)
Local id (available in compute)
localId: int3 = int3(0, 0, 0)
Instance index (available in fragment, and vertex)
instanceIndex: int = 0
Vertex index (available in vertex)
vertexIndex: int = 0
Methods
attr< T > ( index: int,interpolation: "flat" | "linear" | "perspective" | undefined ) -> T
## Camera:
Properties
position: float3
rotation: float4
width: float
height: float
fov: float
near: float
far: float
Methods
getRay( screen: float2 ) -> float3
getTransformToViewMatrix( position: float3,scale: float3,rotation: float4 ) -> float4x4
getCombinedMatrix( ) -> float4x4
getWorldToViewMatrix( ) -> float4x4
getPerspectiveMatrix( ) -> float4x4
getOrthographicMatrix( ) -> float4x4
clone( ) -> Camera
Generally you should scale everything to 100 units in size as 1 unit = 1cm. The camera is zoomed such that 1m looks good by default
You also have access to a default camera under `env.camera` you should use this as much as possible as it gives the user orbit controls
## Quat:
can be accessed like `quat::...`
module quat
Creates a quaternion from an angle and axis.
fromAngleAxis( angle: float,axis: float3 ) -> float4
Rotates a vector by a quaternion and returns the rotated vector.
rotate( quaternion: float4,vector: float3 ) -> float3
Returns the conjugate of the input quaternion.
The conjugate of a quaternion number is a quaternion with the same magnitudes but with the sign of the imaginary parts changed
conjugate( quaternion: float4 ) -> float4
Returns the inverse of the input quaternion.
inverse( quaternion: float4 ) -> float4
Generates a quaternion that rotates from one direction to another via the shortest path.
fromToRotation( from: float3,to: float3 ) -> float4
diff( a: float4,b: float4 ) -> float4
Generates lookAt quaternion.
lookAt( forward: float3,up: float3 ) -> float4
Smooth interpolation between two quaternions.
slerp( a: float4,b: float4,t: float ) -> float4
Converts quaternion to matrix.
toMatrix( quaternion: float4 ) -> float4x4
clone( ) -> quat
module matrix
lookAt( from: float3,to: float3,up: float3 ) -> float4x4
perspective( fov: float,aspect: float,near: float,far: float ) -> float4x4
clone( ) -> matrix
some global functions:
draw( geometry: Mesh,vertexShader: shader<ShaderInput, ShaderOutput, 0>,pixelShader: shader<ShaderInput, ShaderOutput, 0> ) -> void
draw( fullScreenPixelShader: shader<ShaderInput, ShaderOutput, 0> ) -> void
drawAdvanced(...):
drawAdvanced({
mesh: mesh::box(0.xyz, 100.xyz),
vertex: shader {
// ...
},
fragment: shader {
// ...
},
});
drawAdvanced({
mesh: mesh::box(0.xyz, 100.xyz),
vertex: shader {
in.instanceIndex;
},
fragment: shader {
// ...
},
instances: 100,
});
let mesh = mesh::box(0.xyz, 100.xyz);
let indirectBuffer = buffer(5); indirectBuffer[0] = uint(m.getTriangles().len()); // indexCount indirectBuffer[1] = 1; // instanceCount indirectBuffer[2] = 0; // firstIndex indirectBuffer[3] = 0; // vertexOffset indirectBuffer[4] = 0; // firstInstance
drawAdvanced({
mesh: mesh::box(0.xyz, 100.xyz),
vertex: shader {
// ...
},
fragment: shader {
// ...
},
indirect: indirectBuffer,
});
type DrawAdvancedBaseInput = (
| {
mesh: Mesh;
}
| {
indexBuffer: buffer<uint>;
}
) & {
indirect?: buffer<uint> | buffer<atomic<uint>>;
indirectOffset?: int | uint;
depth?: texture2d<float>;
depthOnly?: boolean;
instances?: int;
};
--- end of tutorial ---
With that please write a scene with a spinning cube
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment