Skip to content

Hello Triangle

Note

The code on this page is illustrative. Sigil is pre-release; exact syntax details may differ from the canonical source in the compiler repo. The concepts and type-level invariants shown are accurate to the design. Refer to examples/hello_triangle.cssl in the compiler repo for the authoritative version.

The hello triangle is the GPU programmer’s “Hello World”. It renders a single colored triangle to the screen. In Sigil, even this minimal program demonstrates the three properties that distinguish the language: type-safe GPU/CPU separation, effect tracking, and typed coordinate spaces.

hello_triangle.cssl
// Renders a single triangle. CPU host + GPU shaders in one file.
use cssl::gpu::{Pipeline, Buffer, Surface};
use cssl::math::{Vec2, Vec3, Vec4};
use cssl::space::{Clip, Color};
// ── Vertex data ──────────────────────────────────────────────────────────────
struct Vertex {
position: Vec2, // local/object space — not Clip, not World
color: Vec3,
}
// ── Shaders ──────────────────────────────────────────────────────────────────
// The #[shader(vertex)] attribute marks this as a GPU function.
// Its return type is Vec4<Clip> — not Vec4<f32>. The Clip space
// tag is a phantom type; it carries zero runtime cost.
#[shader(vertex)]
fn vs_main(v: Vertex) -> Vec4<Clip> !gpu {
// Direct lift from 2D local space into 4D clip space.
// The .0 depth and 1.0 w are intentional.
Vec4::new(v.position.x, v.position.y, 0.0, 1.0)
}
// Fragment shader: returns Vec4<Color>, not Vec4<f32>.
// The Color space tag tells the compiler this is the final
// RGBA output — it will enforce that no further coordinate
// transform is applied after this.
#[shader(fragment)]
fn fs_main(color: Vec3) -> Vec4<Color> !gpu {
Vec4::new(color.r, color.g, color.b, 1.0)
}
// ── CPU host ─────────────────────────────────────────────────────────────────
// The !io effect means this function touches the outside world:
// it opens a window, allocates GPU memory, and runs a draw loop.
// Calling it from a pure context is a compile error.
fn main() !io {
let verts: Buffer<Vertex> = Buffer::from([
Vertex { position: Vec2::new( 0.0, 0.5), color: Vec3::RED },
Vertex { position: Vec2::new(-0.5, -0.5), color: Vec3::GREEN },
Vertex { position: Vec2::new( 0.5, -0.5), color: Vec3::BLUE },
]);
let surface = Surface::new(800, 600, "Hello Triangle") !io;
let pipeline = Pipeline::new(vs_main, fs_main);
// Draw loop
surface.run(|frame| !io {
frame.clear(Vec4::BLACK);
pipeline.draw(&verts, frame);
});
}
use cssl::space::{Clip, Color};

Clip and Color are phantom types — they carry no data at runtime. They exist solely to distinguish Vec4<Clip> (a clip-space position) from Vec4<Color> (an RGBA value) from a bare Vec4<f32>. Passing a Vec4<Color> where Vec4<Clip> is expected is a compile error, not a silent cast.

struct Vertex {
position: Vec2, // local/object space
color: Vec3,
}

Vec2 here is untagged — it’s in the object’s local 2D space. The shader is responsible for transforming it. Tagging it Vec2<Clip> would be wrong (it isn’t in clip space yet) and the compiler would reject any code that tried to use it as clip-space coordinates without a transform.

#[shader(vertex)]
fn vs_main(v: Vertex) -> Vec4<Clip> !gpu {

Three things to notice:

  1. #[shader(vertex)] — marks this function as GPU-resident. You cannot call vs_main from CPU-side code. If you try, the compiler errors with “cannot call !gpu function from !io context”.

  2. -> Vec4<Clip> — the return type is tagged. The Vulkan SPIR-V output will have the correct built-in decoration (gl_Position), verified by the type.

  3. !gpu — the effect annotation. GPU shaders have the !gpu effect, which is incompatible with !io. The compiler ensures they never mix.

fn fs_main(color: Vec3) -> Vec4<Color> !gpu {
Vec4::new(color.r, color.g, color.b, 1.0)
}

Vec4<Color> is the output color. The .r, .g, .b accessors on Vec3 are swizzles — syntax sugar for .x, .y, .z when the semantic context is color. The compiler knows this from the field layout.

fn main() !io {
let verts: Buffer<Vertex> = Buffer::from([...]);

Buffer<Vertex> is GPU memory that holds Vertex values. It’s GPU-resident, not a plain array. The CPU can write it during setup (the Buffer::from call here), but the shader reads it with !gpu, not !io.

let pipeline = Pipeline::new(vs_main, fs_main);

Pipeline::new takes the vertex and fragment shader functions as first-class values. The compiler checks that the input type of fs_main matches the interpolated output of vs_main. Mismatching shader interfaces are a compile error, not a driver-level error.

surface.run(|frame| !io {
frame.clear(Vec4::BLACK);
pipeline.draw(&verts, frame);
});

The draw loop closure has the !io effect because it interacts with the window system. pipeline.draw bridges CPU (!io) to GPU (!gpu) — the pipeline itself manages this transition. The frame isn’t Vec4<Color> — it’s a framebuffer abstraction that knows it receives Vec4<Color> output from the fragment shader.

No runtime coordinate-space bugs: Vec2 object-space positions cannot be passed directly to a Vec4<Clip> parameter. No accidental CPU-GPU function calls: vs_main and fs_main cannot be called from main directly. No unsafe GPU/CPU data sharing: Buffer<Vertex> has ownership semantics that prevent the GPU from reading while the CPU writes.

All of this is enforced at compile time. The binary has zero overhead from these checks.

Quick Orientation — the six non-negotiables and how to read the rest of this KB