The Shader Language#

A shader program lives in a .sl file, in HLSL, with the boilerplate gone.

// plasma.sl
uniform float warp = 0.5;
uniform float hue = 0.5;

float4 main() {
    float2 p = (uv - 0.5) * (warp * 14 + 2);
    float v = sin(p.x + time) + sin(p.y - time * 0.8);
    float n = saturate(v * 0.22 + 0.5);
    return float4(hsv2rgb(float3(frac(hue + n * 0.18), 0.75, n)), 1);
}
import { ShaderProgram } from "onejs-react"
import plasma from "./plasma.sl"

<ShaderProgram program={plasma} uniforms={{ warp: 0.3 }}
    style={{ width: 200, height: 200 }} />

That is the whole shader. Everything a Unity shader makes you write that is not the effect is absent:

In a Unity shaderIn .sl
Properties { _Warp ("Warp", Float) = 0.5 }, a float _Warp;, and a material property set from C#uniform float warp = 0.5;, bound by name from React
Shader, SubShader, Pass, CGPROGRAM, pragmas, includes, appdata, v2f, vertnothing; the file is the fragment function
i.uv, with the origin flipped on some graphics APIsuv, corrected before you see it
_Time.y, scaled by handtime
Aspect correction by handaspect
fixed4(1, 0.5, 0.25, 1)#ff8040
A noise function pasted from a forumnoise, simplex, fbm, turbulence, ridged, voronoi
An SDF library pasted from another forumsdf.circle, sdf.box, and forty more shapes
Recompile to see a changeHot reload

HLSL rather than GLSL because the target is a Unity shader: what you write in a .sl file is what you read in the generated .shader, so the second is the first with a Pass block around it.

Setup#

The loader is in the esbuild config a new project is scaffolded with:

import { slPlugin } from "onejs-unity/esbuild"

plugins: [
    // ...
    slPlugin({ generateTypes: true }),
]

It parses and encodes each .sl file at build time, so the bundle carries neither the parser nor the shader text, and a parse error is a build error with the file, line and column. It writes two things beside your code:

  • plasma.sl.d.ts, carrying the uniform names, so uniforms={{ wrap: 1 }} is a red squiggle where you wrote it rather than a warning on a frame nobody is watching.
  • app.sl.json beside the bundle, which the editor turns into a compiled .shader on import.

In VS Code, map *.sl to hlsl for highlighting with nothing installed:

{ "files.associations": { "*.sl": "hlsl" } }

Two backends, one file#

Unity cannot compile a shader at runtime in a player build, on any graphics API. So a program is interpreted by a small VM where that is true (a WebGL build, or a game running inside a prebuilt container) and compiled where an editor exists.

You write the file once. app.sl.json lists the programs, the editor generates a real shader per program, and the runtime picks it by hash. The picture is the same either way, and a test renders both and compares them pixel by pixel.

That is why this exists rather than letting you write GLSL: GLSL would work beautifully in a browser and could never leave one.

Types and values#

float, float2, float3, float4, and texture2D to declare a sampler. There is no int and no bool: a whole number is a float, and a comparison is a float that is 0 or 1.

Every local is declared with its type, and the type is checked against what you assigned:

float2 p = uv - 0.5;    // fine
float3 c = uv;          // error: c is declared float3 and this is a float2

Arithmetic is component wise with a scalar broadcasting from either side. Swizzles read with xyzw or rgba and are read only: build a new value rather than assigning into one.

float2 p = uv * 8;
float2 q = p.yx;
float4 c = float4(p, 0, 1);
float4 d = #ff8040;        // sRGB as written, converted for you

What a program is given#

uvfloat2, 0 to 1 across the element
timefloat, seconds since it started
resolutionfloat2, the element's size in pixels
fragCoordfloat2, pixel coordinates
aspectfloat, the element's width over its height

uv already has its origin corrected, so uv.y of 0 is the bottom on every graphics API. This is the single most common way a hand written shader ends up upside down in a browser and the right way up in the editor.

Uniforms and textures#

uniform float intensity = 1;
uniform float4 tint = #ff6619;
texture2D grain;

float4 main() {
    float g = tex2D(grain, uv * 4 + time * 0.05).r;
    return tint * smoothstep(0.5, 0, length(uv - 0.5)) * intensity * g;
}
<ShaderProgram program={glow}
    uniforms={{ intensity: hovered ? 2 : 1 }}
    textures={{ grain: noiseTexture }} />

The default in the file is what the program starts at, so it looks right before anything sets it. Sixteen uniforms, four textures.

Loops, branches and functions#

A for loop with constant bounds unrolls:

float s = 0;
for (int i = 0; i < 3; i++) {
    s = s + tex2D(grain, uv * (1 + i * 1.9) - time).r * (0.6 / (1 + i));
}

There is no loop on either backend, so the body is emitted once per iteration and the count has to be known at build time. A bound that is not constant is refused, and the error says why.

An if becomes a select: both sides are evaluated and the result is picked between, which is what a GPU wants anyway. ?: is the same thing written shorter. return inside an if is refused, because there would be nothing for it to skip.

float v = 0.25;
if (uv.x > 0.5) { v = 0.75; }
float w = uv.y > 0.5 ? v : 1 - v;

A function in the file inlines, so calling it costs exactly what writing its body out would. Recursion is refused.

float ring(float2 p, float r, float w) {
    return 1 - smoothstep(0, w, abs(length(p) - r));
}

A small library comes with every file and needs no import: rotate(p, angle), polar(p), circle(p, r), box(p, size), tile(p, n) and palette(t, a, b, c, d).

Limits#

Eight values live at once. The interpreter keeps values in eight registers. That is not an arbitrary number: on Windows GPUs a larger register file costs three to four times more, because it stops fitting in fast memory. A ninth live value is refused at build time, with a message, rather than rendered wrong.

256 instructions, and a loop spends its count.

These are real, and they are the trade for a program that runs in a browser at all. A fire with a texture, a three iteration loop and a four stop ramp uses all eight registers.

The programmatic form#

sl.program records the same graph from TypeScript, and it is what a .sl file is parsed into:

import { sl, encode } from "onejs-unity/sl"

const plasma = encode(sl.program(({ uv, time }) => {
    const p = uv.mul(8).add(time.mul(0.4))
    return sl.ramp(sl.sin(p.x).add(sl.sin(p.y)).mul(0.25).add(0.5),
        ["#000018", "#0080ff", "#ffffff"])
}))

Reach for it when a program is built by code: generated from data, or parameterised in a way a file cannot be. For a shader you sit down and write, a file is the better home. It reads as a shader, it has its own history in git, and your editor already understands it.

See also#

  • Shader Effects for running a real .shader you wrote yourself, and for composing effects from noise and shapes with no shader code at all.