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 shader | In .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, vert | nothing; the file is the fragment function |
i.uv, with the origin flipped on some graphics APIs | uv, corrected before you see it |
_Time.y, scaled by hand | time |
| Aspect correction by hand | aspect |
fixed4(1, 0.5, 0.25, 1) | #ff8040 |
| A noise function pasted from a forum | noise, simplex, fbm, turbulence, ridged, voronoi |
| An SDF library pasted from another forum | sdf.circle, sdf.box, and forty more shapes |
| Recompile to see a change | Hot 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, souniforms={{ wrap: 1 }}is a red squiggle where you wrote it rather than a warning on a frame nobody is watching.app.sl.jsonbeside the bundle, which the editor turns into a compiled.shaderon 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 float2Arithmetic 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 youWhat a program is given#
uv | float2, 0 to 1 across the element |
time | float, seconds since it started |
resolution | float2, the element's size in pixels |
fragCoord | float2, pixel coordinates |
aspect | float, 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
.shaderyou wrote yourself, and for composing effects from noise and shapes with no shader code at all.