Shaders

User shaders are WGSL fragments authored in .shader / .frag files and attached to render targets via :AttachShader(asset). The engine wraps every snippet in a prelude that exposes the right uniforms, samplers, and storage bindings so you only have to write the interesting bits.

Three contexts

WhereMethodVariant
2D primitives primitive:AttachShader(asset) Per-pixel fragment shader for one GUI element.
3D parts / models part:AttachShader(asset) Per-pixel fragment shader for one 3D part. Storage buffers and shadow flags available.
Skybox GUI.SetSkybox(asset) Run once per pixel as the scene background.
Post-effect GUI.SetPostEffect(asset) Run after everything else with the rendered scene as input.
Sounds sound:AttachShader(asset) Per-sample DSP shader. Works on the audio buffer, not the GPU.

Prelude

Every shader is concatenated with a prelude that declares the bind groups and provides helpers. You write the body; the engine handles wiring.

2D fragment

// Bindings provided by the prelude:
// @group(0) @binding(0) var<uniform> F: Frame;
// @group(0) @binding(1) var<uniform> I: Instance;
// @group(0) @binding(2) var IMG: texture_2d<f32>;
// @group(0) @binding(3) var IMG_SAMPLER: sampler;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let base = textureSample(IMG, IMG_SAMPLER, in.uv);
    return base * I.color;
}

3D part fragment

The F (Frame) uniform exposes everything the engine knows about the current frame — camera, time, sun, ambient, and the full set of shadow knobs pushed by GPU.SetShadow*. Your shader can branch on any of them without binding anything extra:

FieldTypeNotes
F.view_projmat4x4<f32>Combined view+projection matrix.
F.camera_posvec3<f32>World-space camera position.
F.timef32Seconds since window opened.
F.frame_indexu32Frame counter.
F.viewportvec2<f32>Pixel size of the window.
F.light_dirvec3<f32>Sun direction (from LightingService.SetSun).
F.sun_colorvec3<f32>Sun tint.
F.ambientvec3<f32>Ambient term.
F.shadow_enabledu320/1, from GPU.SetShadowsEnabled.
F.shadow_qualityu32Pixel size, from SetShadowMapQuality.
F.shadow_distancef32Fade distance, from SetShadowDistance.
F.shadow_biasf32Normal-offset bias, from SetShadowBias.
F.shadow_pcfu32Tap count (1/3/5/9), from SetShadowPCF.
F.shadow_strengthf32Engine-derived receiver darkening.
F.shadow_softnessf32Engine-derived terminator band width.

The I (Instance) uniform is per-part: I.model (mat4), I.color (vec4), I.params[16], plus the per-part flags I.cast_shadow, I.receive_shadow, and I.lit (all u32, 0 or 1). Storage at @group(0) @binding(4) is user data via GPU.NewBuffer + GPU.SetBuffer.

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let N = normalize(in.world_normal);
    let L = normalize(-F.light_dir);
    let diffuse = max(dot(N, L), 0.0);

    // Branch on the live GPU.SetShadow* state.
    var mask = 1.0;
    if (F.shadow_enabled != 0u && I.receive_shadow != 0u && diffuse > 0.0) {
        mask = compute_shadow(in.world_pos, N, L);
    }

    let lit = F.ambient + F.sun_color * diffuse * mask;
    return vec4<f32>(I.color.rgb * lit, 1.0);
}

For a complete ray-traced soft-shadow implementation that reads a caster list from SDATA and jitters by F.shadow_pcf taps, see GPU > Reading shadow state from your own shaders.

@ruzit param, runtime parameters

Mark // @ruzit param <name> at the top of your shader to expose a float slot. Each shader can declare up to 16 of them, ordered by appearance.

// @ruzit param threshold
// @ruzit param strength

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let threshold = I.params[0];   // slot 0
    let strength  = I.params[1];   // slot 1
    ...
}

From Lua:

local shader = Asset.GetAsset("Shader", "effects/glow.shader")
part:AttachShader(shader)
part:SetData(shader, "threshold", 0.6)
part:SetData(shader, "strength", 2.0)

Storage buffers

The 3D pipeline exposes a read-only storage buffer at @group(0) @binding(4) that user code can populate via the GPU library. Useful for instancing data, splat positions, cast-shadow lists, etc.

local GPU = import("GPU")
local buf = GPU.NewBuffer(1024)        -- 1024 floats
buf:Write(0, { 1, 2, 3, 4 })
GPU.SetBuffer(buf)                  -- bind to @binding(4)
@group(0) @binding(4) var<storage, read> SDATA: array<f32>;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let count = u32(SDATA[0]);
    ...
}

Skybox

A skybox shader runs once per screen pixel as the scene background, the engine still draws every BasePart on top.

// @ruzit param horizon_y

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let y = in.uv.y;
    let sky    = vec3<f32>(0.30, 0.55, 0.95);
    let ground = vec3<f32>(0.10, 0.10, 0.10);
    return vec4<f32>(mix(sky, ground, smoothstep(0.45, 0.55, y)), 1.0);
}

From Lua:

local sky = Asset.GetAsset("Shader", "sky.shader")
GUI.SetSkybox(sky)

Post-effects

A post-effect shader gets the rendered scene as SCENE_TEX and writes the final pixel. Stack them by attaching multiple effects; they run in attach order.

// @ruzit param vignette_strength

@group(0) @binding(5) var SCENE_TEX: texture_2d<f32>;
@group(0) @binding(6) var SCENE_SAMP: sampler;

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    let base = textureSample(SCENE_TEX, SCENE_SAMP, in.uv);
    let r = distance(in.uv, vec2<f32>(0.5, 0.5));
    let v = 1.0 - I.params[0] * r * r;
    return vec4<f32>(base.rgb * v, 1.0);
}

Sound shaders

Per-sample audio DSP. The shader runs in CPU code (not on the GPU) against the playback buffer. Used for filters, distortion, environmental EQ, voice effects.

-- A simple low-pass via a one-pole IIR.
-- @ruzit param cutoff

local prev = 0.0
function on_sample(s, params, dt)
    local a = math.exp(-2 * math.pi * params.cutoff * dt)
    prev = (1 - a) * s + a * prev
    return prev
end

WGSL helpers (3D pipeline)

The 3D prelude declares utility functions you can call freely. Skim these before writing your own, the engine versions are tuned to match the lighting setup the rest of the renderer uses.

view_dir(pos)Normalized vector from pos toward the camera.
tangent_basis(N)Returns an orthonormal (T, B, N) basis from a normal.
fresnel_schlick(cos_theta, F0)Schlick approximation for view-angle reflectance.
to_screen_uv(world_pos)Project a world-space point to screen-space UV.
linearize_depth(d, near, far)Turn a non-linear depth-buffer value into world units.
srgb_to_linear(c) / linear_to_srgb(c)Colour-space conversions.
hash3(p)3D fragment hash, useful for procedural noise / dithering.