Shader Effects#

Some effects are per-pixel: fire, smoke, water, energy fields, animated gradients. Drawing those with paths or particles means fighting the tool. OneJS can run a fragment shader straight into an element's background instead, evaluating every pixel on the GPU while the element stays a completely ordinary piece of UI.

There are three levels, and you can stop at whichever one solves your problem:

Use when
<Flame> and friendsYou want a ready-made effect with a few knobs
<TextureFX>You want to compose your own from noise, shapes and blends, with no shader code
<ShaderEffect>You have written a shader and want to run it

Quick Start#

import { Flame, View, render } from "onejs-react"

function Torch() {
    return (
        <View style={{ backgroundColor: "#0a0a12", padding: 40 }}>
            <Flame style={{ width: 160, height: 240 }} />
        </View>
    )
}

render(<Torch />, __root)

That is a complete animated flame: no textures, no shader file, no C#. Effects read best over dark backgrounds, since the cool end of the gradient is transparent.

<Flame> takes a handful of props: colors, speed, width, taper, topFalloff, threshold, softness, and gain. It is not a special shader, though. It is a preset over <TextureFX>, so everything it does is reachable directly, which is the next section.

Effects are normal elements. The shader renders into a texture that becomes the element's backgroundImage, so borderRadius, clipping, opacity and transforms all still apply, and the element keeps UI Toolkit's antialiasing. This is different from 2D Particles, which replace the element's material and so cannot be styled directly.

Composing an Effect#

<TextureFX> builds a texture out of layers. Each layer produces a value per pixel, and blends into everything under it. Once the stack is done, the accumulated value is eroded to a defined edge and mapped through a colour ramp.

import { TextureFX } from "onejs-react"

<TextureFX
    style={{ width: 160, height: 240 }}
    build={(fx) => {
        fx.noise({ scale: [3, 4], seed: 1, scroll: [0.02, -1.1] })
        fx.noise({ scale: [6, 8], seed: 2, scroll: [-0.03, -1.9] }).multiply()
        fx.shape("flame", { width: 0.44, taper: 0.7 }).multiply()
        fx.erode(0.30, 1.0)
        fx.ramp(["#00000000", "#c22200", "#ff6a10", "#fff4d2"])
    }}
/>

Read that top to bottom: two noise fields multiplied together give wispy, non-repeating structure, a flame silhouette carves it into shape, erosion turns the soft blob into licks, and the ramp colours the result. Swap the shape for "radial" and the ramp for blues and the same four lines are a magic orb.

Sources#

Every source appends a layer and returns a handle, so the blend reads as a sentence: fx.noise({...}).multiply().

fx.noise(options) is the workhorse. It is fBm value noise computed in the shader from a seed, so it needs no texture and never repeats no matter how long it scrolls.

OptionMeaning
scaleRepeats across the element. A number applies to both axes. Default 4
seedAny number. Different seeds give unrelated fields. Default 1
octavesDetail levels, 1 to 4. More is wispier and costs more. Default 3
scrollElement-heights per second, as [x, y]. Negative y travels upward. Default [0, 0]
amountMultiplier on this layer before blending. Default 1

fx.shape(kind, options) is a positional falloff that carves the silhouette. Shapes are never scaled or scrolled, because distorting their UV would deform the outline you asked for.

KindOptions
"flame"width (half-width at the base), taper (how fast it narrows), softness (fade off the base), falloff (how fast it thins with height)
"radial"falloff exponent, for a soft disc
"linear"falloff exponent, for a vertical gradient
"box"width, height (half-sizes), softness (edge)

fx.constant(value) is a flat value, for biasing or flooring the stack.

Blending#

Each handle carries the ops: .set(), .multiply(), .add(), .subtract(), .min(), .max(), .screen(). The first layer always replaces whatever it declares, because there is nothing underneath it to blend with.

Erode and ramp#

fx.erode(threshold, softness)
fx.ramp(["#00000000", "#c22200", "#ff6a10", "#fff4d2"])
fx.setSpeed(1.5)

erode remaps the accumulated field to 0..1 around a cutoff, and it is what turns fog into fire. Raise threshold to eat the effect back to fewer, sharper features; softness is the width of the transition band. softness has to be on the order of the stack's own value range, so if you multiply layers by a large amount, raise softness to match or the ramp saturates to its hot end.

ramp maps the eroded value through evenly spaced gradient stops. Alpha is carried through, so the first stop being transparent is what lets the effect sit on any background.

setSpeed multiplies every layer's scroll rate at once, which is the knob to animate when you want the whole effect to surge.

Scroll is independent of scale. scroll is in element-heights per second, not noise cells, so changing scale re-sizes the features without also changing how fast they move. Feature size and speed stay separate knobs.

Limits#

A stack holds at most 6 layers. That ceiling is not arbitrary: runtime shader compilation is unavailable in player builds, so the builder cannot generate shader code from your graph. It produces data instead, which one shader with a bounded loop evaluates. The upside is that an effect is a single draw with no compilation step and behaves identically on every platform.

Custom Shaders#

When you want something the builder cannot express, write a shader and point <ShaderEffect> at it.

import { ShaderEffect } from "onejs-react"

<ShaderEffect
    shader="OneJS/Fire"
    style={{ width: 160, height: 240 }}
    floats={{ _Speed: 1.2 }}
    textures={{ _NoiseA: "noise:1", _NoiseB: "noise:2" }}
    ramp={["#00000000", "#7a0d00", "#ff5a00", "#ffd042", "#fffbe8"]}
/>
PropPurpose
shaderShader name, resolved through Resources (so it survives builds)
floatsScalar properties
vectorsfloat4 properties as [x, y, z, w]
vectorArraysfloat4 array properties, as one flat array of 4 floats per element
colorsColour properties as CSS hex strings
texturesTexture properties (see below)
ramp / rampPropertyGradient stops built into a 256x1 texture, bound to _Ramp by default
resolutionRender size in px. Omit to follow the element's own layout size
pausedFreezes the effect's clock

A textures value that is a string names a built-in procedural texture, so an effect can ship with no art at all: "noise", "noise:2" (any seed), "flame-mask", "radial-mask". Anything else is treated as a C# Texture you supply yourself.

Props are diffed by value before they cross to C#, so passing a fresh object literal every render does not cost you a crossing unless something actually changed.

Writing the shader#

Put it anywhere under a Resources folder so Resources.Load finds it in a build. Write an ordinary unlit shader, not a UI Toolkit one: it renders into an offscreen target, so it owns its fragment completely.

The host sets two properties for you every frame:

PropertyMeaning
_SecsSeconds since the effect started
_FlipYRender-target UV origin correction

Everything else comes from your props. By convention an effect also declares a _Speed float and multiplies it into _Secs, so callers get one rate knob for the whole shader.

_FlipY exists because render-target UV origin differs across graphics APIs. Apply it in the vertex shader and the rest of your shader can always treat uv.y = 0 as the bottom of the element, on every platform:

o.uv = float2(v.uv.x, lerp(v.uv.y, 1.0 - v.uv.y, _FlipY));

Performance#

Each effect costs one blit per frame at its render resolution, plus whatever your fragment shader does per pixel. The JS side does no per-frame work at all: props cross once when they change, and the C# bridge drives the clock from there.

Two knobs matter when you have many effects on screen:

  • resolution: soft effects like fire or smoke hold up well at half size or less. An effect on a 320x480 element rendering at [80, 120] costs a sixteenth of the pixels and often looks identical.
  • paused: a frozen effect skips its blit entirely, so pausing offscreen or background effects is close to free.

Gotchas#

Give the effect its own element. The element's backgroundImage belongs to the effect, so it will overwrite any background image you set yourself. Background colour is fine, and shows through wherever the ramp is transparent.

Effects need a size. The render target is sized from the element's layout, so an element with no resolved width or height renders nothing. Give it explicit dimensions, flexGrow, or an explicit resolution.

Erosion and gain move together. If you raise layer amount values and the effect suddenly reads as a solid block of the hottest colour, softness is too narrow for the new value range rather than the colours being wrong.

See Also#