Fireside chat · 13 Nov 2026

Introduction to Shaders

From one coloured pixel to the holo mood stickers and phase badges in the Naluri app. Every shader in this deck runs live, and you can edit it.

← → slidesEsc leave the codeF fullscreen

GPU basics · 1 of 3

What a GPU is good at

A CPU is a few clever cores. A GPU is a huge number of simple lanes running one program together.

CPU

A handful of big cores. Each runs its own code, with large caches and branch prediction to stay fast on twisty logic.

GPU

Hundreds to thousands of small lanes. Each row here is a group that runs the same instruction at the same moment, every lane on its own data.

  • Perfect for one small function over millions of inputs: every vertex, every pixel.
  • An if is cheap when neighbouring pixels agree. When they don't, the group runs both sides.

GPU basics · 2 of 3

The render pipeline

Whatever draws to the screen, a game, Skia or the phone's own UI, ends up as triangles going through these stages, every frame.

Data

Vertices

Corner points from a buffer: position, colour, texture coordinates.

You write it

Vertex shader

Runs once per vertex. Works out where each corner lands on screen.

Fixed

Assemble

Joins every three vertices into a triangle.

Fixed

Rasterize

Finds the pixels whose centre is inside the triangle. Each one becomes a fragment.

You write it

Fragment shader

Runs once per fragment. Decides its colour.

Fixed

Blend

Mixes that colour with what's already there and writes the pixel.

Two of the six stages run your code: those are the shaders. The rest is fixed hardware that you only configure.

GPU basics · 3 of 3

Vertex shader vs fragment shader

Two small programs, two jobs. Drag the corners: the vertex shader still runs 3 times, the fragment shader once per square covered.

Once per vertex

Vertex shader: where

in vec2 position; // from a buffer
in vec3 color;
out vec3 vColor; // to rasterizer

void main() {
  vColor = color;
  // where this corner lands
  gl_Position = vec4(position, 0, 1);
}

Gets each corner's data from a buffer and passes outputs on.

Once per pixel covered

Fragment shader: what colour

in vec3 vColor; // interpolated
out vec4 fragColor;

void main() {
  // the colour of this one pixel
  fragColor = vec4(vColor, 1.0);
}

Gets the corners' outputs blended for its position, plus uniforms and textures.

3vertex shader runs0fragment shader runs

React Native Skia writes the vertex side for you: each <Rect> becomes vertices, and your SkSL main() runs inside the fragment shader. From here on, everything is a fragment shader.

Part 1 · Basics

Drawing, two ways

On the CPU, you write the loop

for (let y = 0; y < height; y++) {
  for (let x = 0; x < width; x++) {
    pixels[y][x] = colorAt(x, y); // one at a time
  }
}

On the GPU, you write only colorAt

vec4 main(vec2 pos) {
  return vec4(1.0, 0.5, 0.2, 1.0); // r, g, b, a
}
155,520,000

calls to main() per second on a 1080 × 2400 phone at 60 fps. The GPU runs thousands of them side by side.

  • Each call knows only its own pos and the values you pass in.
  • It can't see what its neighbours returned.
  • It forgets everything when it returns.

So you describe the picture as maths of position, not as a list of drawing steps.

Live · 1 of 7

A shader is a function

Position in, colour out. This one ignores the position, so every pixel gets the same colour.

Live · 2 of 7

Every pixel knows where it is

The only input that differs per pixel is pos. Showing it as colour is the shader version of console.log.

Live · 3 of 7

Distance draws shapes

There is no drawCircle. A circle is every pixel closer than 140 px to the centre.

Live · 4 of 7

Uniforms carry values from JavaScript

The page sets mouse and time every frame. Move the pointer over the canvas.

Live · 5 of 7

A light is a soft distance

Swap the hard edge for a smooth falloff and a circle becomes a glow. The stickers use exactly this.

Live · 6 of 7

Colour from a cosine

One line of maths gives a whole rainbow. Feed it position, time or distance to the pointer.

Live · 7 of 7

Repetition is free

fract tiles the plane and hash gives every tile its own random number, the same one every frame.

Part 1 · Recap

The whole toolkit

Every effect in the app is a combination of these six ideas.

Position

pos / resolution

Where am I? The only thing that differs per pixel.

Distance

length(pos - center)

Circles, rounded rectangles, rings, glows.

Shaping

smoothstep  exp  pow

Turn a distance into an edge, a falloff or a highlight.

Repetition

floor  fract  hash

Tiles, grain, glitter, shards. Random but stable.

Colour

mix  spectrum  screen

Blend, rainbow, brighten. Blend modes are just maths.

Input

uniform vec2 light;

Time, pointer, sensors, images. The only way in.

Part 1 → Part 2

You were writing SkSL all along

SkSL is Skia's dialect of GLSL, the language React Native Skia compiles on the phone. This page wraps it in a few lines of WebGL, so the app's own .sksl files run here unchanged.

WebGL · GLSLReact Native Skia · SkSL
Entryvoid main() writes an outputvec4 main(vec2 pos) returns it
Positiongl_FragCoord: device px, y uppos: local dp, y down
Imagessampler2D + texture(img, uv)uniform shader + img.eval(pos)
ColourDepends on context settingsPremultiplied: rgb × alpha
Compilegl.compileShader, at runtimeSkia.RuntimeEffect.Make, at runtime

The wrapper this page adds

#version 300 es
precision highp float;
#define main sk_main  // your vec4 main(vec2 pos)

// ...your SkSL, with `uniform shader face`
// turned into a texture and face_eval(pos)...

#undef main
void main() {
  vec2 pos = vec2(gl_FragCoord.x, height - gl_FragCoord.y) / ratio;
  fragColor = sk_main(pos);
}

Part 2 · In the app

How a shader gets into the app

Once, at module load

moodSticker.skslPlain shader source in its own file.
↓
sksl-transformer.jsMetro imports it as a string. Editing it hot-reloads just that shader.
↓
RuntimeEffect.Make()Skia compiles it on the device.

Every frame, UI thread

Gravity · scroll · draguseTilt(), scroll handler, pan gesture.
↓
useDerivedValueA worklet turns them into light and lift.
↓
<Shader uniforms>Skia passes the values to the GPU.
↓
GPURuns main() for every pixel of every sticker.

MoodStickers.tsx (trimmed)

const effect = Skia.RuntimeEffect.Make(moodStickerSksl);

// A lamp fixed above the screen: stickers scrolling under it
// catch it, tilting swings it.
const light = useDerivedValue(() => [
  SCREEN_WIDTH / 2 - tilt.value.x * SCREEN_WIDTH * 0.7,
  scrollY.value + SCREEN_HEIGHT * 0.4 + tilt.value.y * SCREEN_HEIGHT * 0.4,
]);

const uniforms = useDerivedValue(() => ({
  center: [x, y], radius, light: light.value, material, lift: lift.value,
}));

<Shader source={effect} uniforms={uniforms}>
  <ImageShader image={image} rect={...} /> // uniform shader face
</Shader>

No React render per frame. React only runs when the list of moods changes.

Part 2 · Mood stickers

Building the mood sticker

Part 2 · Mood stickers

Four materials, four cheap tricks

The mood's energy × pleasantness quadrant picks the material. Each one fakes a bit of physics in a few lines.

Holo

A diffraction grating, faked: a rainbow anchored to the lamp, so the bands sweep as the phone tilts. Fine ridges in dp jitter the hue.

float phase = dot(pos - light, vec2(0.0021, 0.0034));

Gold foil

Metal is mostly reflection: dark away from the lamp, bright near it. One random value per pixel row gives brushed streaks.

float brushed = 0.8 + 0.2 * hash(vec2(floor(pos.y), 0.0));

Glitter

Every flake is a tiny mirror at a random angle. The power of 140 means it only flashes when it lines up with the lamp.

float flash = pow(max(dot(tilted, h), 0.0), 140.0);

Prism

Voronoi shards: find the nearest of 9 random points, then shift the rainbow by that shard's own hash.

color = mix(base, overlay(base, spectrum(phase * 1.6 + hash(shard))), ...);

Part 2 · Phase badge

The phase badge unlock

Two shaders: a backdrop with the glow and sparkles, and the foil card. Move the pointer to tilt it.

Part 2 · Phase badge

Three tricks in the badge

One distance, three features

A signed distance to a rounded rectangle gives the cut-out, a bevel within 6 dp of the edge, and the gold frame 8 dp inside it.

float d = box(p);
float cut = 1.0 - smoothstep(-1.0, 0.0, d);
float frame = 1.0 - smoothstep(0.6, 1.4, abs(d + 8.0));

Alpha as a height map

Sampling the art's alpha 1.5 dp to each side gives a slope, and the slope is a surface normal. The print looks stamped in and its edges catch the light.

vec2 slope = vec2(
  art.eval(pos + vec2(1.5, 0.0)).a
    - art.eval(pos - vec2(1.5, 0.0)).a,
  ...);

The animation is two numbers

reveal sweeps a diagonal front from locked (the phase list's greyscale) to foil. burst throws 20 four-pointed stars.

reveal.value = withDelay(450,
  withTiming(1, { duration: 1600 }, ...));
// star: sqrt(x) + sqrt(y) < sqrt(size)

Part 2 · Practical

Costs and gotchas

Cost is pixels × work

The sticker returns early outside its circle. The badge backdrop tests 20 sparkles for every pixel of the modal, every frame.

Every .eval() is a read

The badge card reads its art 5 times per pixel: once for colour, four times for the slope.

Compile once, guard it

A typo fails at runtime on the device. Make the effect at module level; PhaseBadge draws an empty canvas when it's missing.

Animate with uniforms

Feed shared values through useDerivedValue. React state per frame would re-render the tree 60 times a second.

Measure detail in dp

Stripes sized in 0–1 sticker units grow coarse on a big sticker. Fine grain uses pos in dp.

Undefined maths varies

pow(x, y) with x < 0 is undefined in GLSL, so GPUs disagree. Test on iOS and Android. Respect reduced motion too.

Wrap up

Try it, then questions

In the app (dev builds)

Read next