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 oneJSConfig, which a new project builds with. An esbuild config of your own adds it:
import { slPlugin } from "onejs-unity/esbuild"
plugins: [
// ...
slPlugin({ generateTypes: true }),
]The compiler is the onejs-sl package, which npm installs with onejs-unity. Control flow, int, uint and bool need onejs-sl 0.5.0 or newer, and previous, frame and deltaTime need 0.7.0. A new project gets 0.7.0, but an older project keeps its onejs-sl when onejs-unity updates, since onejs-unity accepts 0.4 to 0.7: npm ls onejs-sl in ~/ shows which you have, and npm install onejs-sl@^0.7.0 moves it up.
It parses and compiles each .sl file at build time, so the bundle carries the compiled program and never the parser, 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" } }Where a program runs#
Every build compiles a program to a real shader. Only where the compiling happens differs:
| The editor | A real Unity shader, generated from app.sl.json and picked by hash |
| A WebGL build | A real shader the browser compiles: the build also prints each program as WGSL for WebGPU and GLSL ES for WebGL2 |
| A native player | A real Unity shader, generated when the player is built from every *.sl.json in the project |
You write the file once, and there is nothing to commit for it beyond its source: the build generates the shaders it ships. In a WebGL build the browser compiles in the background, so an effect appears a frame or two after it mounts and is clear until then. The picture is the same everywhere, and tests render each form and compare 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, int, uint, bool, and texture2D to declare a sampler.
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
float3 g = 0.5; // fine: one number fills every component
float3 t = #ff8040; // fine: a float4 into a float3 drops its alpha
float h; // fine: zero until something is assignedArithmetic is component wise with a scalar broadcasting from either side. Swizzles read and write with xyzw or rgba: p.x *= aspect; changes one component and keeps the rest. A colour is written as #rrggbb, #rgb, or #rrggbbaa.
A local or a uniform may take a builtin's name, such as circle or turbulence. Where the value is visible, the builtin is hidden, and calling it says so.
An int is a whole number, and two of them divide as whole numbers, toward zero, as HLSL does: int a = 7 / 2; is 3 and int b = -7 / 2; is -3. % keeps the sign of the number divided, and dividing by zero gives 0 on every backend. A number written without a point stays a float until it meets an int, so float c = 7 / 2; is 3.5 and 1 / 2 is still 0.5. An int goes into a float by itself; a float goes into an int as int(x), which truncates toward zero.
A uint is for hashing: 5u writes one, and &, |, ^, ~, << and >> work on it, wrapping at 32 bits. A bool holds a comparison, combined with &&, || and !, and it is 0 or 1 where a float is wanted.
float2 p = uv * 8;
float2 q = p.yx;
float4 c = float4(p, 0, 1);
float4 d = #ff8040; // sRGB as written, converted for youNumbers are in the project's colour space, linear in a Linear project and gamma in a Gamma one, while a hex colour is always written as sRGB.
What a program is given#
uv | float2, 0 to 1 across the element, at the centre of this pixel |
time | float, seconds since it started |
resolution | float2, the element's size in pixels |
fragCoord | float2, the centre of this pixel in pixels, so the first column is 0.5 |
aspect | float, the element's width over its height |
texel | float2, one pixel in uv (1 / resolution), the step to a neighbouring pixel |
centered | float2, uv with 0 at the centre and x scaled by aspect, so a circle drawn in it stays round |
frame | int, the frames drawn since the element last cleared, 0 on the first |
deltaTime | float, seconds since the frame before, 0 on the first |
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.
Earlier versions got two of these wrong in a WebGL build: effects drew upside down on WebGL2, and resolution, fragCoord and aspect read 0. Both are fixed.
The frame before#
tex2D(previous, uv) reads what the program drew on the frame before, which is how an effect leaves a trail, builds up over time, or runs a simulation one step a frame:
float4 main() {
float4 last = tex2D(previous, uv);
float2 at = 0.5 + 0.3 * float2(cos(time * 6), sin(time * 6));
float spark = smoothstep(0.08, 0.02, length(uv - at));
return max(last * pow(0.5, deltaTime * 3), float4(1, 0.6, 0.2, 1) * spark);
}Scale anything that fades or moves by deltaTime, and it looks the same at any frame rate.
The element keeps that frame only for a program that reads it: 16 bits a channel in linear colour, straight alpha, filtered and clamped at the edges. It starts transparent black, and goes back to transparent black with frame back at 0 when the program changes, the element changes size, or its clock is set with SetTime or ResetTime.
A frame is one step of the element's clock, so a paused element draws nothing new. Step(dt) on the element (the ref of a ShaderProgram) draws exactly one frame dt seconds on, paused or not. After SetTime(0), eight steps of 1 / 30 draw frames 0 to 7 at the same times on every run, which is what a recorder or a test needs and a live clock cannot give.
previous, frame and deltaTime need onejs-sl 0.7.0 (npm install onejs-sl@^0.7.0), with OneJS 3.9.1 and onejs-unity 0.9.1. An older OneJS draws nothing for a program that reads them, and says which version it needs. An older onejs-sl fails the build at the first of them it meets. onejs-unity 0.9.2 and newer add npm install onejs-sl@^0.7.0 to that error; before 0.9.2 it named neither package, so check the version first (Setup).
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.
tex2Dlod(grain, uv, lod) reads one mip level of a texture that has them: 0 is full size and each level up halves it; a fraction blends the two levels either side, whatever the texture's filter mode.
An attribute before a uniform says what control it wants, in Unity's own words:
[Header("Shape")]
[Range(0, 2)] uniform float warp = 1;
[Range(1, 16, 1)] uniform float petals = 5; // a third number is the step
[Toggle] uniform float invert = 0; // 0 or 1
[Enum(Soft, Hard, Glow)] uniform float edge = 0; // the value is the index
[Label("Glow colour")] uniform float4 tint = #ff8040;
[Hide] uniform float seed = 0; // set from code onlyOneJS does not draw these controls: the program still takes its values through uniforms. They describe each uniform for a tool that does draw them, and hovering the imported program in your editor lists every uniform with its control and default. They never change what the program draws, and a default that does not fit its control, such as [Range(0, 2)] on a default of 5, is an error on the attribute. The one attribute that does change the program is [Color]: it makes a default written as numbers a colour, read the same way as a hex default.
Loops, branches and functions#
An if is a real branch: the side not taken does not run. return works anywhere, so a pixel with nothing to draw can leave before the expensive part:
if (length(centered) > 0.5) return #00000000;A for loop unrolls when its turns are known at build time, there are 64 or fewer, and nothing in it leaves early. The body is emitted once per turn:
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));
}Any other loop is a real one in the compiled shader: a bound that is a uniform, a while, a loop that stops with break or skips a turn with continue. Stopping as soon as a pixel is done is usually faster than running every turn:
[Range(16, 256, 1)] uniform int iterations = 128;
float4 main() {
float2 c = centered * 2.5 - float2(0.5, 0);
float2 z = 0;
int n = 0;
for (int i = 0; i < iterations; i++) {
if (dot(z, z) > 4) break;
z = float2(z.x * z.x - z.y * z.y, 2 * z.x * z.y) + c;
n++;
}
if (n == iterations) return #000000;
return float4(hsv2rgb(float3(frac(float(n) / iterations * 3), 0.8, 1)), 1);
}A real loop always stops. It runs at most the count its bound gives, or its uniform bound's [Range] maximum (256 above), or 1024 turns when the bound says neither, so a condition that never fails ends there instead of hanging the GPU.
switch picks by an int. A case never falls through into the next: each ends in break or return, and labels written one after another share a body.
switch (int(uv.x * 3)) {
case 0: return #ff4040;
case 1: case 2: { float g = uv.y; return float4(g, g, g, 1); }
default: return #000000;
}?: picks between two values. A texture read inside a branch or a loop where neighbouring pixels go different ways samples mip level 0, since those pixels cannot agree on a level.
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).
The programmatic form#
sl.program records the same graph from TypeScript, and it is what a .sl file is parsed into:
import { sl, compile } from "onejs-unity/sl"
const plasma = compile(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"])
}))No build can see a program built in code, so the editor records each one the first time it draws it, into Assets/OneJS.Recorded.sl.json, and player builds compile what that file lists. Until its shader is compiled, a moment later, the element is blank. Commit the file. A program it does not list draws nothing in a player, and the player logs an error naming its hash. A value that changes belongs in a uniform (sl.uniform.float("warp", 0.5)): a value baked into the program makes a new program for each value, and each one needs recording.
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.