Effects

Particles, textures and per pixel programs

Four ways to make a sketch look like something, in rough order of how much thinking each one asks for.

Reach for When
A ready made component You want fire, and you want it now
Particles Many small moving pieces: sparks, confetti, smoke, trails
fx A picture built or animated from a chain of operations
A shader The picture is a function of the pixel, and you want to write that function

For a few shapes with crisp edges, none of these is the answer: use the batched painter, because vector paths beat per pixel maths for anything with an outline.

Ready made

<Flame> is a procedural fire that needs no shader thinking at all:

import { Flame } from "oj"

<Flame style={{ width: 120, height: 200 }} speed={1.2} />

It takes colors, speed, threshold, softness, width, taper, topFalloff and gain. It is a preset over <TextureFX>, so anything it does is reachable from <TextureFX> directly when you want to go further.

Particles

A C# owned particle system rendered inside any element. Steady emission costs your JavaScript nothing per frame.

import { useParticles } from "oj"

const ref = useRef(null)
const fx = useParticles(ref, {
    max: 2000,
    emitters: [{
        rate: 200,
        shape: { type: "circle", radius: 12 },
        speed: [40, 120],
        lifetime: [0.5, 1.5],
        sizeOverLife: [1, 0],
        colorOverLife: ["#ffd080", "#ff408000"],
        glow: 1,
        gravity: [0, 240],
    }],
})

<View ref={ref} onPointerDown={(e) => fx.burst({ x: e.localX, y: e.localY, count: 30 })} />

A pointer event's x and y are panel coordinates; localX and localY are relative to the element the handler is on, which is what a burst wants.

Ranges are written as [min, max] or as a single number. glow runs from 0 (normal alpha) to 1 (pure additive). An emitter can also take a texture from your own files, a sheet to play that texture as a flipbook, attract to pull particles to a point by the end of their life, tintPalette for random per particle colour, and edge to bounce or stick against the element's rect.

The full reference covers every option.

fx: building a texture

fx builds and animates a picture from a chain of operations, on the GPU. Reach for it when the thing you want is an image rather than a field of moving pieces.

import { View, mount, fx } from "oj"

const canvas = fx.canvas(512)

const shape = canvas.sdf("egg", { h: 0.6, r: 0.17, bulge: 0.7 }).blur(40)
const mask = shape.multiply(canvas.gradient(["#ffffff", "#000000"], "up").pow(0.68))
const body: fx.NoiseOptions = { type: "turbulence", seed: 1, octaves: 3, scroll: [0, -0.7] }

function Fire() {
    const flame = fx.useAnimatedTexture(canvas, () => (
        canvas.noise(body).multiply(2).multiply(mask).threshold(0, 0.2).ramp(["#400000", "#ffd800", "#ffffff"])
    ))
    return <View style={{ width: canvas.width, height: canvas.height, backgroundImage: flame }} />
}

mount(<Fire />)

fx.canvas(w, h) fixes a size and gives you the sources: color, blank, noise, gradient and sdf. Every operation returns another image, so they chain. fx.useTexture(build, deps) renders one once; fx.useAnimatedTexture(canvas, build) re-renders it each frame, with the seconds so far passed in.

Everything in fx is fragment blits, so all of it works in the browser.

Shaders

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

// 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, View, mount } from "oj"
import plasma from "./plasma.sl"

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

That is a complete animated effect. It is HLSL, so it is a syntax a Unity developer already knows, with the boilerplate gone: no Properties block, no SubShader, no CGPROGRAM, no appdata or v2f, no _Time.y. The file is the fragment function, and uniform declares both the slot and the name React binds against.

The editor knows the language. Errors are underlined as you type, at the character the build would name.

Interpreted here, compiled after an eject

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

Open the sketch in Unity and the same file becomes real shader code that Unity compiles like any other asset. You change nothing.

The first run in the editor is still interpreted, and the compiled shader is generated from it, so every run after that one uses it. Where there is no compiled shader the runtime interprets rather than failing, which means the picture is the same either way and only the speed differs.

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

Your program is parsed by the build, not at runtime. What the import resolves to is a small object of numbers, so no parser and no shader text ships to a player.

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 declaration 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

Arithmetic is component wise with a scalar broadcasting on either side. Swizzles read with xyzw or rgba and are read only. A colour is written as it is anywhere else: #ff8040, #rgb, or #rrggbbaa.

What 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 everywhere. This is the one place a hand written shader most often goes wrong.

Uniforms

A value that changes without changing the program:

// glow.sl
uniform float intensity = 1;
uniform float4 tint = #ff6619;

float4 main() {
    float d = length(uv - 0.5);
    return tint * smoothstep(0.5, 0, d) * intensity;
}
<ShaderProgram program={glow} uniforms={{ intensity: hovered ? 2 : 1 }} />

The default in the file is what the program starts at, so it looks right before anything sets it. A name in uniforms has to be one the file declares, and the editor underlines a misspelling where you wrote it.

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.

Textures

texture2D smoke;

float4 main() {
    return tex2D(smoke, uv * 2 + time * 0.1);
}
<ShaderProgram program={fire} textures={{ smoke: "noise" }} />

A string names one of the built in procedural textures (noise, noise:2, flame-mask, radial-mask), which is how an effect ships with no art at all. Anything else is a texture you loaded. Four slots.

Loops and branches

A for loop with constant bounds unrolls:

float s = 0;
for (int i = 0; i < 3; i++) {
    s = s + tex2D(smoke, 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 when the sketch is built. A bound that is not constant is refused, and says so.

if becomes a select: both sides are evaluated and the result is picked between. So is ?:. That is what a GPU wants anyway, and it is why return inside an if is refused. Assign to a local and return it once at the end.

float v = 0.25;
if (uv.x > 0.5) { v = 0.75; }     // both sides run; the answer is selected
float w = uv.y > 0.5 ? v : 1 - v; // the same thing, written shorter

Functions, and the ones you already have

A function in the file inlines, so calling it costs what writing the body out would. Recursion is refused.

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). Alongside them are the things a shader usually pastes from a forum: noise, simplex, fbm, turbulence, ridged, voronoi, ramp, and sdf.circle, sdf.box and forty more shapes.

Limits

Values live at once 8
Instructions 256
Uniforms 16
Textures 4

These are real, and the build tells you when you cross one rather than rendering something wrong. A fire with a texture, a three iteration loop and a four stop ramp uses all eight registers.

Writing a program from code

sl.program records the same graph from TypeScript, and it is what a .sl file is parsed into:

import { sl, encode } from "oj"

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 the editor understands it.