onejs-sl
v0.2.0
Published
The OneJS shader language: a small typed language for per pixel programs, compiled to a portable IR and emitted as HLSL, WGSL, GLSL ES or a VM buffer
Maintainers
Readme
onejs-sl
The OneJS shader language: a small typed language for per pixel programs. A
program is written as a .sl file (HLSL text) or with the TypeScript form, and
recorded as one portable IR, a flat typed graph with a hash. The package emits
that IR as the buffer OneJS's VM evaluates, a Unity ShaderLab shader, WGSL and
GLSL ES.
It is the compiler OneJS uses, taken out of onejs-unity
so a host without Unity can run it too. onejs-unity/sl and
onejs-unity/sl/compiler re-export it, so nothing in a OneJS project imports
this package directly. User documentation is the
shader language guide; this
file is the design.
import { parse, sl } from "onejs-sl"
const ripple = parse(`float4 main() {
float v = sin(length(uv - 0.5) * 40 - time * 4);
return float4(v * 0.5 + 0.5, 0, 0, 1);
}`, { file: "ripple.sl" })
const plasma = sl.program(({ uv, time }) => {
const p = uv.mul(8).add(time.mul(0.4))
const v = sl.sin(p.x).add(sl.sin(p.y))
return sl.vec4(v.mul(0.5).add(0.5), 0, 0, 1)
})Entry points
"sideEffects": false, and each backend is its own entry, so a host bundles
only what it calls.
| Entry | Contents |
|---|---|
| onejs-sl | parse and analyze, diagnose (every error in a file, not just the first), classify (every token with its class, comments kept, never throws), fromGLSL (a pasted Shadertoy or WebGL shader as a .sl file, with notes on what did not carry over), the TypeScript form sl, the IR types, SL_IR_VERSION, toJSON/fromJSON, SLParseError (file, line, column, offset, length, the bare text, and a fix where one is certain) |
| onejs-sl/core | the same without the parser: what a game needs at run time |
| onejs-sl/tables | BUILTINS, SL_HLSL, INPUTS, SL_SDF_SHAPES, SL_SDF_PARAMS, SL_KEYWORDS, SL_TYPES, TYPE_WIDTH, PRELUDE_NAMES: what completion and highlighting read. BUILTIN_PARAMS, SL_SDF_PARAM_NAMES and LIB_SIGNATURES name every parameter, so an editor can show lerp(x, y, s), and BUILTIN_DOCS, INPUT_DOCS and PRELUDE_DOCS give each a line for its tooltip |
| onejs-sl/compile | compile(program): what a host draws a program with. Its hash, its uniform and texture names in slot order, their defaults, and hlsl, wgsl and glsl as lazy getters. No budget |
| onejs-sl/limits | vmFit(program): whether the VM runs it, and why not. The VM's, until it is deleted |
| onejs-sl/vm | encode, SL_WIRE_VERSION: compile plus the VM's buffer. The VM's, until it is deleted |
| onejs-sl/emit/hlsl-body | emitBody: a program as a function body for a host's own frame; emitLibrary: the library functions it calls |
| onejs-sl/emit/unity | emitShader: the .shader a Unity editor generates, a frame over emitBody |
| onejs-sl/emit/web | emitWGSL, emitGLSL: OneJS's web frame |
| onejs-sl/goldens.json | the goldens (below), as data |
src/entries.test.ts pins every name, since removing one breaks a host.
A host's own frame: emitBody
emitBody(program, target) prints the body only: one local per node, in the
HLSL and Metal shared subset (HLSL spelling, fmod, no mul, no static, no
derivatives, no swizzle of a scalar, and every literal and scalar in an
intrinsic at its exact type, since Metal overloads where HLSL converts). The BodyTarget says what differs between hosts: an expression
for each input, the float4 holding a uniform slot, a texture sample, whether
toLinear is real (colour: "linear") or the identity ("gamma"), and
optionally a local to assign the result to. It returns the uniform and texture
slots the body uses and the library functions it calls. The sample's contract
is on BodyTarget.sample: straight alpha in and out, rgb in the space colour
names (an sRGB texture decoded before filtering when it is linear), and
filtering and wrapping left to the host, which OneJS's hosts take from the
bound texture's own settings. OneJS's Unity shader is
one frame over it (hlsl.ts); Magerie's compute kernel is another, and its
target is in src/body.test.ts so an opcode cannot change without the text
Magerie compiles changing in front of a test.
emitLibrary(body.uses.helpers, colour) prints the library functions the body
calls, and everything they call, in the same subset and dependency order, with
the colour branch asked for: the text a host puts ahead of its frame. It is a
separate call because OneJS's own frame never needs it (the Unity shader
includes SLCommon.cginc), and a bundle that prints only Unity shaders should
not carry the library too. On a Mac with Xcode, src/metal.test.ts compiles
the whole library and a body for every corpus program and every builtin at
every width as Metal, behind the two defines a host's compatibility header
supplies (frac, lerp).
A uniform declared with a hex default (uniform float4 tint = #ff8040;), or
with sl.uniform.colour, is marked colour: true: its value is sRGB as
written, which is what a colour picker shows, and its reads convert. inputsUsed
says which inputs the result depends on, dead nodes aside, so a host knows
whether a program animates.
The helper library: one source
Value and simplex noise, the octave kinds, voronoi, hsv2rgb, toLinear and the
42 distance shapes are written once, in HLSL, in lib/sdf2d.hlsl,
lib/noise2d.hlsl and lib/common.hlsl. Everything else is printed from them
by npm run lib, and nobody edits the copies:
- OneJS's
.cgincfiles (SDF2D,Noise2D,SLCommoninResources/OneJS/) are the source with an include guard and a generated header around it. The VM, the generated Unity shaders andfxinclude them.npm run libwrites them when the package sits in the OneJS container. src/lib/:table.ts(every function, its signature, what it calls, and the shape table read fromsl_sdfDistance's switch), and the text of each function as GLSL ES (glsl.ts), WGSL (wgsl.ts) and the shared subset (hlsl.ts). The web emitters andemitLibrarytake only what a program calls.names.tsholds each function's parameter names, which onlyonejs-sl/tablesreads.
lib/translate.ts does the printing, at build time only. It reads a subset of
HLSL, listed at its top (functions, locals, if, bounded for, switch,
intrinsics, mul(v, float2x2(...)), uint and uint2 for the hashes, and one
preprocessor switch, UNITY_COLORSPACE_GAMMA, which becomes the target's
colour), and anything
outside it is an error with a file and line rather than a guess. HLSL converts
where GLSL, WGSL and Metal refuse, so it makes every conversion explicit first;
the printers then differ in spelling, plus what WGSL lacks (overloading,
ternaries, swizzle assignment, assignable parameters). On the web the colour
switch stays a runtime branch on the frame's opt.x, since a web build does not
know the project's colour space when it prints.
src/lib/lib.test.ts regenerates everything and fails on any byte of
difference, the .cginc copies included in the container, and OneJS's
SLSharedLibraryTests checks them from the other side. What proves the
translations draw the same picture: the goldens compile the whole library on
WebGPU and WebGL2 and draw every fixture; OneJS's parity harness draws every
fixture through a real WebGL player and holds it to the goldens; SLSharedLibraryTests draws every corpus program in Unity
through SLCommon.cginc and through the translated shared subset and requires
them to agree; and the Metal check above compiles the shared subset.
Goldens
goldens/goldens.json is every corpus fixture drawn as a Linear OneJS game
stores it: the package's WGSL and GLSL ES in OneJS's web frame, into an
rgba8unorm-srgb target (the format the element's render texture has), read
back raw, with no Unity, no UI Toolkit and no browser colour management. The
goldens stand on their own: the two backends must agree, and the anchors and
the hash probes must match arithmetic. A host's own backend is checked against
them, as OneJS's parity harness (Tools/sl-web-parity in the container) checks
what a WebGL player draws.
npm run goldens draws it on WebGPU and WebGL2 in a Chrome with its own
profile (set CHROME to choose one). The two backends must agree within 1/255
over every pixel, and three anchors must match arithmetic, not each other:
orient.sl (orientation, and the linear to sRGB store), hex.sl (a hex colour
stores as written) and texture.sl (a texture's orientation and sRGB decode).
The whole translated library must also compile on both backends, including
the functions no fixture reaches. The file describes the sampling grid, the
times and the texture every sampled slot gets. A host imports it as
onejs-sl/goldens.json.
The hashes are held to the bit, not to 1/255: two backends that happen to round
alike prove nothing about a third. goldens/probes.hlsl draws each of the
library's hashes (hash21, the value noise's, and hash22, voronoi's) as three
bits per pixel, and corpus/probe-hash.sl does the same through a program;
every pixel must match goldens/reference.mjs, which computes the hashes in
JavaScript. goldens.json ships each probe under its name, as HLSL, WGSL and
GLSL ES and with the bits it must draw, for a host to run on its own compiler,
and marks the fixtures compared at 0/255 exact.
npm run goldens -- --check draws everything again and compares it with
goldens.json instead of writing it: within 1/255, and exact fixtures and probes
to the byte. It is how a machine that did not write the file checks its own
GPU and compilers. No CI runner here has a GPU, so src/goldens.test.ts checks instead
that the file still covers the corpus at today's hashes; a change that moves a
hash fails there until the goldens are drawn again.
Runs anywhere ES2020 runs
The source reaches for no host API: no fs, process, Buffer,
TextEncoder, performance, structuredClone or Intl. It runs in a
browser (the Play editor's diagnostics), a Cloudflare worker (the Play build)
and QuickJS (Magerie). Two checks hold it to that:
- lint refuses those globals and any Node module in
src/; npm run test:quickjsbundlesquickjs/corpus.tsas one ES2020 IIFE, the way Magerie bundles a script, runs it in QuickJS-ng and in a bare Node context, and fails unless the two results agree byte for byte. The corpus parses, encodes, fits and emits every program incorpus/, every shape at its full parameter count, and the error paths.npm testruns it after vitest.
Releasing
Push a tag v<version> matching package.json; .github/workflows/publish.yml
publishes through npm trusted publishing (OIDC), so no token exists anywhere.
Before the tag:
- Note the release in
CHANGELOG.mdand bumppackage.json. - Prove the corpus: a program that compiled before compiles to the same bytes, unless the release says otherwise.
- Run PlaySite's checks in the container, where the Play editor bundles this
package:
node scripts/gen-sl-parser.mjs, thennpx vitest run, inPlaySite/. It is a private repo, so no workflow here can. Whendiagnoseor an error's fix changed, also runnode Tools/editor-sl-smoke/editor-sl-smoke.mjsfrom the container root, which drives the Play editor in real Monaco. - Run
npm run goldens -- --checkon Windows, whose WebGPU compiles through a different shader compiler from the Mac's that writes goldens.json. A compiler that rounds the library differently shows here first, and nowhere in CI, which has no GPU. - Push
mainand wait for CI, whoseconsumersjob runs onejs-unity's and onejs-play's typecheck and tests against the commit (consumers.yml).publish.ymlruns the same job and publishes nothing if it fails. When a consumer has to follow a change, land it here, fix the consumer, then re-run and tag.
The one exception to publishing by tag is the first version: npm attaches a
trusted publisher to a package that already exists, so 0.1.0 was published by
hand, and the workflow checks its tag and publishes nothing. In the OneJS
container this package is checked out at JSModules/onejs-sl, and
onejs-unity links it with file:../onejs-sl as a dev dependency beside its
^ peer range.
The one idea
One authoring surface, one IR, several backends. Unity cannot compile a shader at runtime in a player build, on any graphics API, so the first backend made a program data that a fixed shader evaluates: a VM. An editor does compile shaders, at build time, so the second prints HLSL for it.
So the same source draws compiled from generated HLSL in the editor and in a
native player, with no edit in between, and the VM stays behind a switch: OneJS
leaves it out unless a project defines ONEJS_SL_VM. compile.ts gives a host
everything it draws with and no VM buffer; encode.ts adds the buffer for
FxProgram.shader, and goes with the VM. hlsl.ts feeds OneJS's
Editor/SLShaderGenerator.cs. Nobody writes a manifest for it: an editor that
draws a program asks the compiled program for its
hlsl (a lazy getter, never read in Play), records it into
Assets/OneJS/Recorded.sl.json, generates the shader and
moves the live material onto it. manifest() is still there for an app that
would rather write its programs out at build time.
A third and fourth backend, for the browser. A player cannot compile a
shader, but the page it runs in can. web.ts prints every program as WGSL and
as GLSL ES 3.00 (carrying the library functions it calls, translated from
lib/*.hlsl), and OneJS's
Plugins/WebGL/OneJSSLWeb.jslib compiles whichever one Unity's device speaks
and draws it into the element's target. A .sl import carries both strings,
printed at build time; a compile() result has them as lazy getters, like
hlsl. A WebGL player has no VM: the element draws nothing until the compiled
program is ready, and nothing after a compile error, which the page reports.
The host contract (the frame block, the
16 uniform slots, one binding pair per sampled texture) is written out at the
top of web.ts.
The emitters match the HLSL emitter's semantics rather than each language's
own: % truncates like fmod, pow takes abs of its base, asin and
acos clamp, log and sqrt guard their argument, a select is the VM's
branchless lerp. web.test.ts checks the structure (every shape, every
opcode, the library order); whether the output matches the goldens is
measured in a browser, through the real element, by Tools/sl-web-parity in
the container.
Two ways to write one
A .sl file is HLSL text and sl.program is a TypeScript EDSL, and they record
the same graph: a file lowers THROUGH the EDSL, so sin(x) and sl.sin(x) are
one call. lang/ is the parser and lang/README.md covers it; the rest of this
file is the IR, the hash and the EDSL, which both surfaces sit on.
float4 main() {
float2 p = uv * 8 + time * 0.4;
float v = sin(p.x) + sin(p.y);
return float4(v * 0.5 + 0.5, 0, 0, 1);
}Docs lead with the file. The EDSL is the programmatic form: the IR builder, the parser's target, and what a program built by code uses.
Why an EDSL came first
A TypeScript EDSL inherits completion, type errors at the call site, jump to definition, rename and the author's editor for free. Monaco in the Play editor already has these types loaded.
It also gives common subexpression elimination for nothing, which is the most
valuable optimisation here, because a const in the host language IS the
shared node. const p = uv.mul(8) used three times is one node with three
references, and writing it out long hand three times costs exactly the same,
because nodes are interned as they are built.
That reasoning is why the parser, when it came, cost only a parser: it emits this same IR, so it inherited the encoder, the emitter, the hash and every test that runs on a program.
What is checked, and when
Everything an author can get wrong is refused when the program is written, at module load, not at draw time:
| Mistake | What happens |
|---|---|
| vec2 combined with a vec3 | TypeScript error at the call site, and a runtime error behind it |
| uv.z on a two component value | "z is component 3 of a vec2, which has 2" |
| A program returning something other than a vec4 | "a program must return a vec4. Wrap it: sl.vec4(value, 1)" |
| vec4 given the wrong number of parts | "vec4 needs 4 components, got 3" |
| One uniform name at two widths | "declared as both a float and a vec4" |
| More than 15 textures | Names the limit and why it cannot be widened |
| A value borrowed from another program | "a value from another program cannot be used in this one" |
The texture ceiling is the fragment shader's sampler slots on the WebGL2 baseline, which is the one resource neither backend can widen.
The hash is the fragile part
Program.hash is what will link a program to its compiled shader. If it differs
between the machine that generated the shader and the machine that runs it, the
player finds no shader for the program: it draws nothing and logs one error
naming the hash. Before OneJS 3.6.0 it fell back to the VM and nobody was
told: correct output, quietly slow, no error, the worst failure this design
could have, and the reason the hash is built the way it is.
So it is a Merkle hash over the graph reachable from the result, not a walk of
the node array. An earlier version hashed storage order, which meant hoisting a
shared subexpression into a const changed the hash without changing what the
program computed. Constants go through a fixed precision, so 0.1 + 0.2 and
0.3 do not produce different shaders. It is eight lowercase hex characters
from FNV-1a, chosen so a C# implementation can produce the same string rather
than for any cryptographic reason.
Versions
Two numbers, with one rule: a reader accepts every version up to its own and refuses a newer one with a message naming both.
SL_IR_VERSION(ir.ts) is on everyProgramand in the hash, and is bumped whenever an opcode, a shape or what one computes changes. Because it is in the hash, a bump recompiles every cached shader.toJSONandfromJSON(serial.ts) are the IR as JSON for a host that stores programs;fromJSONchecks everything an emitter relies on, refuses a newer version, and migrates an older one.SL_WIRE_VERSION(ops.ts) is the newest VM encoding, andSLProgramBridge.WireVersionin OneJS must match it (a container test compares the two).Encoded.wire, and thewirein a.slimport, is the LOWEST version that can run that program, so a program using nothing new stays 1 and still runs on an older Play container. The VM refuses a newer one, only where the VM runs; a WebGL player draws compiled and never reads the buffer.
IR 2 and wire 2 came with #129. A shape takes as many parameters as it reads
(SL_SDF_PARAMS, six at most, and never fewer than four accepted), where it
used to take four and lose the rest. One instruction holds a shape id and four
immediates, so the encoder's forVm turns a shape given a fifth or sixth into
SDF_WIDE, which reads the remaining four from a constant register. Only the
VM sees it, and only those programs are wire 2.
Control flow
There is none in the IR, deliberately. sl.select, sl.step, sl.smoothstep and
sl.mix cover branching without branching, and sl.repeat(n, body, seed)
unrolls at record time because n is a JavaScript number.
repeat is honest about being a macro rather than a loop. It covers fbm,
layered noise and small iterated distance fields, which is most of what 2D
shaders loop for. A data dependent loop is out of scope: the VM would need a
nested bounded loop with a dynamic trip count while codegen would handle it
fine, and the two backends agreeing is the property the whole design protects.
Because it unrolls, the count multiplies the body's operation count. compile
has no ceiling, so a long program costs what it costs, as any shader does;
corpus/long.sl is over a thousand operations. encode still refuses past the
VM's 256, and names any repeat that fills a quarter of that.
Every loop that reaches a GPU is therefore bounded by a constant: repeat is
unrolled, fbm's octaves are a constant 1 to 4, the helper loops in the noise
and Voronoi functions have fixed trip counts. No program can hang a GPU today,
so the compiled backends carry no loop cap. A data dependent loop (the
raymarching tier) would need one emitted into every loop it prints, since a GPU
reset takes the whole page's device, Unity's included.
See also
Specs/SHADER_LANG.mdin the OneJS container, sections 3 and 4, and section 5.4 for the Phase 0 measurements that decided the VM's shapeTools/shader-vm-spike/, the harness behind those numbersonejs-unity'sfx, the image pipeline this becomes a source and an operand for
What a program is given
uv, fragCoord, resolution, time and aspect, and the last three are the
target's, not the window's. uv and fragCoord are the centre of the pixel
being drawn, so the first column's fragCoord.x is 0.5 on both backends.
Two more are built from those: texel, one pixel in uv (1 / resolution), and
centered, uv with 0 at the centre and x scaled by aspect, so a circle drawn
in it stays round. They are recorded only when a program names them, so every
program that does not compiles to the bytes it always did, and a program with a
texel of its own keeps it: a local, a parameter or a uniform of that name
shadows the input.
Why resolution and aspect are the target's: a program is drawn with
Graphics.Blit into the element's own render texture, and Unity sets _ScreenParams per camera and
leaves it alone for a blit: reading it from a 64x256 target answers with the
game view's size. Both backends read the same wrong thing, so they agreed with
each other and the eject test, which compares them, saw nothing. What saw it was
a picture, because aspect correction, the one thing aspect exists for,
stretched every circle by the shape of whatever window it was in. The host now
sets _Res from the target and both backends read that.
fx had already learned this: ShaderEffectElement sets _Aspect from the
render texture with a comment saying why. The lesson did not travel.
Controls
A uniform can say how a host should present it: range (a slider, with an
optional step), toggle, options (a dropdown whose value is the index),
header, label and hide, all on the UniformDecl. A .sl file writes them
as Unity's attributes ([Range(0, 2)] uniform float warp = 1;), the EDSL as the
last argument of sl.uniform.float and the rest. They are metadata: the hash
never reads them, so two programs that differ only in a control share one
compiled shader, and toJSON carries them for a host that stores a program.
The generated shader's Properties block does not carry them, and that was
measured rather than assumed (Unity 6000.5, 2026-09-27). Unity binds a material
property by the HLSL variable's type, not the Properties type: a float4
variable takes only SetVector, a float only SetFloat. Every uniform is a
float4 set with SetVector, so a Range(0, 2) property over one draws a
slider whose default and every drag never reach the shader. A Color property
is worse, since SetVector on one converts sRGB to linear and the program
already converts a colour uniform. Carrying them means declaring a scalar
control as float, with a matching Properties line ([ToggleUI], since
[Toggle] also defines a keyword; [Enum] as name, value pairs), and the bridge
setting it with SetFloat. Specs/SL_NEXT.md 1 keeps that as a later change.
Colours
A hex colour is sRGB as written, the way CSS reads it, and the target holds
linear light. sl.ramp mixes its stops in sRGB, which is what reads as an even
ramp, and converts the result once through TO_LINEAR; sl.color("#hex") is
parseColor plus that conversion, and sl.toLinear is the conversion on its
own for a vec4 built from raw components. Both backends implement it gamma
aware (sl_toLinear in lib/common.hlsl), so a Gamma project gets the value
as written. Alpha is coverage and is never converted. Same rule as fx.
Noise
sl.noise, sl.simplex, sl.fbm(p, octaves, base), sl.turbulence and
sl.ridged are the fields fx.noise draws, from the same lib/noise2d.hlsl, so
a simplex here is the simplex there. Octaves are 1 to 4. A program has no seed;
offset the input for a different field. sl.simplex used to be value noise on
a rotated lattice, and sl.fbm had its own value noise; both changed on
2026-09-06 when the fields were unified.
The lattice hash under value noise and voronoi is integer arithmetic (pcg2d),
so every GPU draws the same bits: lib/noise2d.hlsl says why a float hash
cannot promise that. A cell is converted to an integer, so a point past
-2^31 or 2^31 lands in the edge cell, and a NaN in an undefined one. fx.noise
seeds are read to 1/65536: two seeds closer than that draw the same field.
