User Pixel Shaders

EdenSpark supports custom pixel shaders authored in Daslang. A pixel shader is a GPU program that runs once per fragment and determines the final color output for that pixel, with full access to interpolated vertex attributes, material properties, and global engine state.

Quick Start

PBR shader — the surface is shaded by the scene lighting (directional light, point lights, global illumination):

require engine.render.shader_dsl

var {
    @color tint = float3(0.2, 0.8, 1.0)   // editable color picker in material inspector
    speed = 2.0
}

[pixel_shader]
def hologram_effect(inp : PbrInput) {
    let n     = noise(inp.worldPos)
    let pulse = sin(g_Time * speed) * 0.5 + 0.5

    return PbrOutput(
        albedo          = tint * n,
        emission        = tint * pulse,
        emissionStrength = pulse * 0.5,
        metalness       = 0.0,
        roughness       = 1.0,
        ao              = 1.0
    )
}

Unlit shader — no lighting, direct color output:

require engine.render.shader_dsl

var @color color = float3(1.0, 0.5, 0.0)

[pixel_shader]
def flat_color(inp : UnlitInput) {
    return UnlitOutput(
        color = color,
        alpha = 1.0
    )
}

Files and Hot-Reload

  • Create a foo.shader file anywhere in the project folder.

  • Assign the shader to a material via the Material inspector by selecting it in the shader slot — you can replace the built-in shader on any material or create a new .material file that references it.

  • Hot-reload is triggered when you save from within the editor (Ctrl+S). Saving from an external editor may not trigger a rebuild.

Material Properties

Module-level var declarations become per-material properties editable in the inspector and overridable per mesh instance.

var {
    speed         = 2.0                               // float
    offset        = float2(0.5, 0.0)                  // float2
    @color tint   = float3(1.0, 1.0, 1.0)             // float3 with color picker
    blend         = float4(1.0, 0.0, 0.0, 1.0)        // float4
    albedoTex     : Sampler2D = Sampler2D("assets/default.png")  // texture
}

Type

Notes

float

Single scalar

float2

Two-component vector; default as float2(x, y)

float3

Three-component vector; default as float3(x, y, z)

float4

Four-component vector; default as float4(x, y, z, w)

Sampler2D

Texture reference; default as Sampler2D("path/to/texture.png")

@color float3

Same storage as float3, but shown as a color picker in the inspector

Defaults must be constant literals. References to other module-level let constants are also allowed.

Note

Only variables declared in the same module as the [pixel_shader] function become material properties. Variables from other modules cannot be accessed.

Shader Types

The shader type is determined by the function signature: the parameter type selects what geometry data is available as input, and the return type selects which outputs are written.

PBR Shader (PbrInputPbrOutput)

Physically-based rendering — the surface is lit by the engine’s full lighting pipeline: directional light, point lights, spot lights, and global illumination. The lighting model applies NdotL and BRDF on top of albedo automatically. Do not pre-multiply your own lighting into albedo — use emission for self-lit additive effects instead.

PbrInput fields — access as inp.fieldName:

Field

Type

Description

uv

float2

Texture UV coordinates

worldPos

float3

World-space position of the current fragment

worldNormal

float3

World-space surface normal (normalized)

viewDir

float3

Direction from the fragment toward the camera (world-space, normalized)

localPos

float3

Object-space position of the current fragment

localNormal

float3

Object-space surface normal (normalized)

screenPos

float2

Screen-space UV coordinates [0, 1] for the current fragment. Used with sample_scene_color and sample_scene_depth.

PbrOutput fields — all optional; specify only what you need:

Field

Type

Default

Description

albedo

float3

float3(0)

Base color (diffuse reflectance). Modulated by scene lighting.

alpha

float

1.0

Opacity [0, 1]

alphaCutoff

float

0.0

Alpha-test threshold. Fragments below are discarded.

metalness

float

0.0

PBR metalness [0, 1]

roughness

float

1.0

PBR roughness [0, 1]

emission

float3

float3(0)

Additive emissive color. Not affected by lighting.

emissionStrength

float

1.0

Multiplier on emission

normalMap

float3

float3(0, 0, 1)

Tangent-space normal. Decode from a texture with unpack_normal(tex2d(...)).

ao

float

1.0

Ambient occlusion [0, 1]

Unlit Shader (UnlitInputUnlitOutput)

No lighting — direct color output. Use for HUD quads, UI meshes, additive particle effects, and any surface that must be lighting-independent.

UnlitInput has the same fields as PbrInput, including screenPos.

UnlitOutput fields:

Field

Type

Default

Description

color

float3

float3(0)

Output color

alpha

float

1.0

Opacity [0, 1]

alphaCutoff

float

0.0

Alpha-test threshold

Global Inputs

Available directly by name in any shader, without using the input struct:

Name

Type

Description

g_Time

float

Global time in seconds since the application started

g_LightDirection

float3

Main directional light direction — pointing toward the sun, world-space, normalized

g_CameraPosition

float3

Camera position in world space

g_CameraForward

float3

Camera forward direction, world-space, normalized

g_CameraRight

float3

Camera right direction, world-space, normalized

g_CameraUp

float3

Camera up direction, world-space, normalized

Built-in Functions

All functions are overloaded for float, float2, float3, and float4 unless noted otherwise. Operations are component-wise.

Arithmetic

Signature

Description

abs(x)

Absolute value: |x|

min(x, y)

Component-wise minimum

max(x, y)

Component-wise maximum

clamp(x, lo, hi)

Clamp each component to [lo, hi]

saturate(x)

Clamp to [0, 1]

frac(x)

Fractional part: x − floor(x)

floor(x)

Round down to the nearest integer

ceil(x)

Round up to the nearest integer

mod(x, y)

Floating-point remainder (fmod): sign follows x (unlike GLSL’s mod)

mad(a, b, c)

Multiply-add: a × b + c. Fused into a single instruction automatically.

lerp(a, b, t)

Linear interpolation: a + t × (b − a). t must be a scalar (float).

step(edge, x)

Step function: 0 if x < edge, 1 otherwise

smooth_step(lo, hi, x)

Smooth Hermite interpolation between lo and hi

one_minus(x)

1 − x

remap(x, inRange, outRange)

Remap x from inRange to outRange. inRange and outRange are float2.

Transcendentals

All component-wise:

Function

Description

sqrt(x)

Square root

pow(x, y)

Power: x^y

sin(x)

Sine (radians)

cos(x)

Cosine (radians)

atan2(y, x)

Arc-tangent of y/x

log(x)

Natural logarithm

exp(x)

Natural exponentiation: e^x

sign(x)

Sign: −1, 0, or +1

Vector Operations

Dimension-specific:

Signature

Description

dot(x, y)

Dot product (float2/3/4 × float2/3/4 → float)

cross(x, y)

Cross product (float3 × float3 → float3)

length(x)

Vector length (float2/3/4 → float)

normalize(x)

Normalize to unit length (float2/3/4 → same type)

Lighting

fresnel(power: float; worldNorm: float3; viewDir: float3): float

Schlick-style Fresnel approximation. Returns values close to 0 at the surface center and close to 1 at glancing angles. Increase power to tighten the rim. Pass inp.worldNormal and inp.viewDir directly:

let rim = fresnel(3.0, inp.worldNormal, inp.viewDir)
return PbrOutput(emission = float3(rim))

Texture Sampling

tex2d(sampler: Sampler2D; uv: float2): float4

Sample a 2D texture at the given UV coordinates. Declare the texture as a material property and pass it here:

var albedoTex : Sampler2D = Sampler2D("assets/base_color.png")

[pixel_shader]
def textured(inp : PbrInput) {
    let c = tex2d(albedoTex, inp.uv)
    return PbrOutput(albedo = c.xyz, alpha = c.w)
}

Use .rgba, .rgb, .a swizzles to extract channels from the returned float4.

Noise

noise(pos: float2): float

2D Perlin noise. Returns values in [0, 1].

noise(pos: float3): float

3D Perlin noise. Returns values in [0, 1].

Normal Map Utility

unpack_normal(sample: float4): float3

Decode a tangent-space normal from a standard RGB normal-map texture sample. Input is the float4 returned by tex2d; output is a float3 in [−1, 1]:

var normalTex : Sampler2D = Sampler2D("assets/normal.png")

[pixel_shader]
def with_normalmap(inp : PbrInput) {
    let n = unpack_normal(tex2d(normalTex, inp.uv))
    return PbrOutput(normalMap = n)
}

Scene Sampling

These functions read from the already-rendered scene buffer and depth buffer. They require inp.screenPos (screen-space UV) as the sampling coordinate. Both are PerPixel — they cannot be hoisted to the CPU tier.

sample_scene_color(uv: float2): float3

Sample the scene color buffer at the given screen-space UV. Used for refraction and glass-like effects — distort inp.screenPos by the surface normal before sampling:

let offset    = inp.worldNormal.xz * refractStrength
let sceneColor = sample_scene_color(inp.screenPos + offset)
return PbrOutput(albedo = sceneColor, alpha = 0.8)
sample_scene_depth(uv: float2): float

Sample the scene depth buffer at the given screen-space UV. Returns the eye depth — distance from the camera in world units — of the opaque geometry already rendered behind the current surface.

Subtract the fragment’s own depth to get intersection thickness:

let surfaceDepth = length(inp.worldPos - g_CameraPosition)
let sceneDepth   = sample_scene_depth(inp.screenPos)
let thickness    = clamp((sceneDepth - surfaceDepth) / softDepth, 0.0, 1.0)
return PbrOutput(albedo = lerp(shallowColor, deepColor, thickness),
                 alpha  = lerp(0.1, opacity, thickness))

Helper Functions

Plain def functions in the same .shader module are inlined at compile time. Use them to factor out repeated expressions or build utility sub-routines:

require engine.render.shader_dsl

var waveScale = 5.0

def wave_octave(p : float3; scale : float; t : float) : float {
    return noise(p * scale + float3(t, t * 0.3, t * 0.7))
}

[pixel_shader]
def water(inp : PbrInput) {
    let t  = g_Time * 0.6
    let n1 = wave_octave(inp.worldPos, waveScale,       t)
    let n2 = wave_octave(inp.worldPos, waveScale * 2.1, t)
    let n  = n1 * 0.6 + n2 * 0.4
    return PbrOutput(albedo = lerp(float3(0.0, 0.15, 0.35),
                                   float3(0.2, 0.6, 0.8), n))
}

Rules for helper functions:

  • Only one [pixel_shader] entry point per module — helper functions must not carry the [pixel_shader] annotation.

  • The same language subset applies inside helper functions: no var locals, no control flow, same supported types.

  • Module-level var properties are accessible from helper functions.

  • Helper functions are fully inlined — their arguments are just named sub-expressions; there is no call overhead.

Loop Unrolling

for loops with a compile-time-known iteration count are fully supported. The compiler unrolls them into straight-line code before building the shader graph — there is no branching or looping in the final program.

Four loop forms are accepted:

Literal array — the iterator is each element value directly:

var acc = float3(0)
for (off in [float2(-s, 0.0), float2(0.0, 0.0), float2(s, 0.0)]) {
    acc += tex2d(albedoTex, inp.uv + off).xyz
}

Multi-iterator — two parallel arrays unrolled in lockstep:

var acc = float3(0)
for (off, weight in [float2(-s, 0.0), float2(0.0, 0.0), float2(s, 0.0)],
                    [0.25, 0.5, 0.25]) {
    acc += tex2d(albedoTex, inp.uv + off).xyz * weight
}

range(N) — integer index 0 .. N-1; convert to float with float(i):

var acc = float3(0)
for (i in range(3)) {
    let o = blurSize * float(i)
    acc += tex2d(albedoTex, inp.uv + float2(o, o)).xyz
}

urange(N) — unsigned integer index, same pattern:

var acc = float3(0)
for (i in urange(3u)) {
    let off = float2(float(i) * blurSize, 0.0)
    acc += tex2d(albedoTex, inp.uv + off).xyz
}

break and continue are supported inside unrolled loops. After unrolling, break becomes a persistent mask that suppresses all later copies; continue becomes a per-copy mask that skips only that iteration’s accumulation.

Note

The loop count must be a literal constantrange(3), urange(3u), or a fixed-size array literal. A loop whose count depends on a material property or a shader input cannot be unrolled and will be rejected at compile time.

The accumulator variable must be declared as var (mutable local) and updated with += or a similar compound assignment. This is the one exception to the “no var locals” rule — loop accumulators are resolved into a chain of operation nodes in the final graph.

Language Subset

The shader DSL is a strict subset of Daslang. The [pixel_shader] macro walks the AST at compile time and rejects unsupported constructs.

Supported:

  • Arithmetic operators + - * / (unary - → Negate node)

  • Swizzle access: v.xyz, v.zyx, v.xxxx, etc.

  • Field reads on the input struct: inp.uv, inp.worldPos, etc.

  • let local declarations — type must be float/float2/float3/float4, value must be initialized

  • var accumulator locals inside unrolled for loops (see above)

  • Float and vector literals (1.0, float3(0.2, 0.8, 1.0))

  • Integer literals — auto-promoted to float

  • Module-level var declarations → material properties

  • All built-in functions listed above

  • if/else — converted to a select node; works anywhere in shader or helper code

  • for loops with a compile-time-constant count (see Loop Unrolling above)

Not supported:

  • Mutable var locals other than loop accumulators — use let

  • while loops

  • Ternary operator ?:

  • Heap allocations (new)

  • Strings, arrays, tables, variants

  • Pointer types and unsafe

  • bool, int, uint locals (integer literals are auto-promoted to float)

  • Multiple [pixel_shader] entry points in one module — each .shader file must have exactly one [pixel_shader] function.

See Shader and Shader Graph Compiler for details on how the compiler enforces these restrictions.