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.
To start from a Shadertoy or WebGL shader, paste it into New file in the play.onejs.com editor, or call fromGLSL from onejs-unity/sl/compiler: you get a .sl file with notes on what did not carry over.
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, previous, frame and deltaTime need 0.7.0, and a texture's own sampler, textureSize, tex2Dfetch and more than 16 uniforms or 4 textures need 0.9.0 with OneJS 3.9.9 or newer. A new project gets 0.9.0, with onejs-unity 0.11. An older project keeps the onejs-sl it has, since onejs-unity 0.10 accepts 0.4 to 0.8: npm ls onejs-sl in ~/ shows which you have, and npm install onejs-unity@^0.11.0 onejs-sl@^0.9.0 moves both 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.
The resolution prop draws the program at fewer pixels than the element has, and resolution, fragCoord and texel then count those pixels. The element smooths them up to its size, which suits a render made smaller to save time. For a picture that is meant to be low resolution, add pixelated and each pixel shows as a hard-edged block (OneJS 3.9.11 and newer):
<ShaderProgram program={triangle} resolution={[32, 32]} pixelated
style={{ width: 320, height: 320 }} />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 or newer, which a new project has (Setup says how to move an older one up), 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. A program takes up to 64 uniforms and 8 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.
A texture samples as it is set in Unity, its Wrap Mode and Filter Mode. A program that depends on how it samples can say so itself, and then every texture bound to it samples that way:
[Wrap(Repeat)] texture2D grain; // tiles; also Mirror, and Clamp
[Filter(Point)] texture2D sprite; // hard pixels; also LinearGive a texture either one and the program owns its sampler, so the one you leave out is Clamp or Linear rather than the texture's. Linear blends the four nearest texels within one mip level, as Unity's Bilinear does.
textureSize(grain) is the texture's width and height in pixels, and tex2Dfetch(grain, pixel) reads one texel exactly, with no filtering. Pixel (0, 0) is the texel at uv (0, 0), and a pixel past an edge reads the edge texel:
float2 size = textureSize(grain);
float4 texel = tex2Dfetch(grain, uv * size); // the texel under this pixel, unblendedAn 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.
Passes#
A program can read what another drew this frame. usePass draws a program into a texture every frame, and another program reads it through a texture2D, the way it reads any texture. This is how a glow, FXAA or depth of field works: the scene is drawn once, and each effect reads the picture.
// bright.sl: what is brighter than white, and by how much
texture2D src;
float4 main() {
return float4(max(tex2D(src, uv).rgb - 1, 0), 1);
}import { ShaderProgram, usePass } from "onejs-react"
function Glow() {
const scene = usePass(sceneProgram)
const over = usePass(bright, { textures: { src: scene }, scale: 0.5 })
const across = usePass(blur, { textures: { src: over }, uniforms: { dx: 1, dy: 0 }, scale: 0.5 })
const glow = usePass(blur, { textures: { src: across }, uniforms: { dx: 0, dy: 1 }, scale: 0.5 })
return <ShaderProgram program={composite} textures={{ scene, glow }}
style={{ width: 640, height: 360 }} />
}A pass is drawn before anything that reads it, every frame, so a reader sees this frame's picture rather than the one before. A pass nothing reads is not drawn, and one read twice is drawn once. It takes uniforms and textures as a ShaderProgram does, other passes included.
A pass is the size of the picture it ends up in: the element at the end of the chain. scale draws it at a fraction of that, so every pass of the glow above says 0.5 and the blur runs on a quarter of the pixels. resolution sets a size outright.
A pass keeps values over 1 and below 0 (16 bits a channel, linear), so a scene can be brighter than white and a later pass can tell by how much. A program writes one float4, so a pass with more to say, such as the depth that depth of field needs, puts it in alpha.
A pass can read its own previous, so a path tracer that accumulates is a pass and a tonemap reads it. A pass cannot read itself through other passes, since neither could be drawn first: that binding is refused with an error. SetTime, ResetTime and Step on the element at the end of a chain do the same to every pass in it.
usePass needs OneJS 3.9.11. An older OneJS says so once, and a program reading a pass reads black there. A Play game imports it from oj.
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.