Writing a Shader

Per pixel programs in TypeScript, interpreted here and compiled after an eject

Some effects are per pixel: fire, water, plasma, energy fields, animated gradients. fx composes those from a fixed menu of operations. sl lets you write the operation.

import { sl, encode, ShaderProgram, View, mount } from "oj"

const plasma = encode(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)).add(sl.sin(p.x.add(p.y).mul(0.7)))
    return sl.ramp(v.mul(0.25).add(0.5), ["#000018", "#0080ff", "#ffffff"])
}))

mount(
    <View style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
        <ShaderProgram program={plasma} style={{ width: 300, height: 300 }} />
    </View>
)

That is a complete animated effect. No shader file, no C#, no Unity.

Record at module scope

The function you hand sl.program runs once, when your module loads. It does not run per pixel and it does not run per frame. What it does is record a graph, which is then evaluated on the GPU for every pixel.

So write programs at module scope, next to your constants, not inside a component. A program is a constant: nothing about it depends on props or state. Values that do change are uniforms.

The one thing worth understanding

Your program is interpreted here and compiled after an eject, and you cannot tell the difference.

Unity cannot compile a shader while a game is running, and every game on this site runs inside one prebuilt container. So a program written here becomes data that a fixed shader evaluates, instruction by instruction.

Open the game in Unity and the same program is turned into real shader code that Unity compiles like any other asset. You change nothing. The picture is identical, and there is a test that renders both and compares them pixel by pixel to keep it that way.

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

Values and types

Four types, matching what a shader thinks in:

Float one component
Vec2, Vec3, Vec4 two, three, four

Everything chains, and plain numbers are fine anywhere a value is:

uv.mul(8).add(0.5)
uv.x.mul(time).sin()          // no: sin is a function, see below
sl.sin(uv.x.mul(time))        // yes

Mixing widths is an error in your editor, at the line you wrote, rather than a black rectangle you have to debug:

const a = sl.vec2(1, 2)
const b = sl.vec3(1, 2, 3)
a.add(b)     // Error: cannot combine a vec2 with a vec3
uv.z         // Error: z is component 3 of a vec2, which has 2

A Float broadcasts against a vector, as it does in a real shader, so uv.mul(2) doubles both components.

Swizzles

Single components and pairs are properties. Longer ones go through swz, which is typed by what you pass it:

p.x         p.y         p.xy        p.yx
c.r         c.rgb       c.rgba      c.swz("wzyx")

What a program is given

uv Vec2, 0 to 1 across the element
time Float, seconds since it started
resolution Vec2, size in pixels
fragCoord Vec2, pixel coordinates
aspect Float, width over height

uv already has its origin corrected, so uv.y of 0 is the bottom everywhere. This is the one place a hand written shader most often goes wrong, ending up upside down in a browser and the right way up in the editor.

Uniforms

A value that changes without changing the program:

const glow = encode(sl.program(({ uv }) => {
    const intensity = sl.uniform.float("intensity", 1)
    const d = uv.sub(0.5).length()
    return sl.vec4(sl.smoothstep(0.5, 0, d).mul(intensity), 0.4, 0.1, 1)
}))

<ShaderProgram program={glow} uniforms={{ intensity: hovered ? 2 : 1 }} />

Uniforms exist so a program encodes once and only the value crosses when it changes. Without them, moving a slider would rebuild the whole program sixty times a second.

Loops

There are none, and sl.repeat is not one:

const v = sl.repeat(4, (i, acc) =>
    acc.add(sl.noise(uv.mul(2 ** i)).mul(0.5 ** i)), sl.float(0))

repeat runs while your program is being recorded, so the count has to be a plain number and the result is the same as writing the four lines out. It covers layered noise, fbm and small iterated shapes, which is most of what a 2D shader loops for.

For branching, use sl.select, sl.step and sl.smoothstep. They pick without branching, which is what a GPU wants anyway.

What you get

Arithmetic (add, sub, mul, div, mod, pow, neg), maths (sl.sin, cos, tan, asin, acos, atan2, exp, log, sqrt, abs, sign, floor, ceil, round, fract, min, max, clamp, saturate), geometry (length, distance, dot, normalize), interpolation (sl.mix, step, smoothstep, select), colour (sl.ramp, luminance), and noise (sl.noise, sl.fbm).

Textures too:

const art = sl.texture("art")
const c = art.sample(uv.add(offset))

Limits, and why they are where they are

Eight values live at once. The interpreter keeps values in eight registers, which is not an arbitrary number: on Windows GPUs a larger file costs three to four times more, because it stops fitting in fast memory. In practice this is generous. The plasma above peaks at four, and four octaves of fbm at six. If you exceed it you get an error naming the limit, not a wrong picture.

256 instructions. Every operation is one, so this is a long program.

Four textures.

When to reach for something else

  • A few shapes with crisp edges: use the batched painter. Vector paths beat per pixel maths for anything with an outline.
  • Many moving pieces: use particles.
  • Adjusting an image you already have: use fx, which is built for chaining operations over whole textures.
  • A ready made effect: <Flame> and <TextureFX> are there and need no shader thinking at all.

sl is for the case where the picture is a function of the pixel, and you want to write that function.