Fireside chat · 13 Nov 2026
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.
GPU basics · 1 of 3
A CPU is a few clever cores. A GPU is a huge number of simple lanes running one program together.
A handful of big cores. Each runs its own code, with large caches and branch prediction to stay fast on twisty logic.
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.
if is cheap when neighbouring pixels agree. When they don't, the group runs both sides.GPU basics · 2 of 3
Whatever draws to the screen, a game, Skia or the phone's own UI, ends up as triangles going through these stages, every frame.
Corner points from a buffer: position, colour, texture coordinates.
Runs once per vertex. Works out where each corner lands on screen.
Joins every three vertices into a triangle.
Finds the pixels whose centre is inside the triangle. Each one becomes a fragment.
Runs once per fragment. Decides its colour.
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
Two small programs, two jobs. Drag the corners: the vertex shader still runs 3 times, the fragment shader once per square covered.
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.
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.
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
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
pixels[y][x] = colorAt(x, y); // one at a time
}
}
colorAtvec4 main(vec2 pos) {
return vec4(1.0, 0.5, 0.2, 1.0); // r, g, b, a
}
calls to main() per second on a 1080 × 2400 phone at 60 fps. The GPU runs thousands of them side by side.
pos and the values you pass in.So you describe the picture as maths of position, not as a list of drawing steps.
Live · 1 of 7
Position in, colour out. This one ignores the position, so every pixel gets the same colour.
Live · 2 of 7
The only input that differs per pixel is pos. Showing it as colour is the shader version of console.log.
Live · 3 of 7
There is no drawCircle. A circle is every pixel closer than 140 px to the centre.
Live · 4 of 7
The page sets mouse and time every frame. Move the pointer over the canvas.
Live · 5 of 7
Swap the hard edge for a smooth falloff and a circle becomes a glow. The stickers use exactly this.
Live · 6 of 7
One line of maths gives a whole rainbow. Feed it position, time or distance to the pointer.
Live · 7 of 7
fract tiles the plane and hash gives every tile its own random number, the same one every frame.
Part 1 · Recap
Every effect in the app is a combination of these six ideas.
pos / resolution
Where am I? The only thing that differs per pixel.
length(pos - center)
Circles, rounded rectangles, rings, glows.
smoothstep exp pow
Turn a distance into an edge, a falloff or a highlight.
floor fract hash
Tiles, grain, glitter, shards. Random but stable.
mix spectrum screen
Blend, rainbow, brighten. Blend modes are just maths.
uniform vec2 light;
Time, pointer, sensors, images. The only way in.
Part 1 → Part 2
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 · GLSL | React Native Skia · SkSL | |
|---|---|---|
| Entry | void main() writes an output | vec4 main(vec2 pos) returns it |
| Position | gl_FragCoord: device px, y up | pos: local dp, y down |
| Images | sampler2D + texture(img, uv) | uniform shader + img.eval(pos) |
| Colour | Depends on context settings | Premultiplied: rgb × alpha |
| Compile | gl.compileShader, at runtime | Skia.RuntimeEffect.Make, at runtime |
#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
useTilt(), scroll handler, pan gesture.light and lift.main() for every pixel of every sticker.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
Part 2 · Mood stickers
The mood's energy × pleasantness quadrant picks the material. Each one fakes a bit of physics in a few lines.
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));
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));
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);
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
Two shaders: a backdrop with the glow and sparkles, and the foil card. Move the pointer to tilt it.
Part 2 · Phase badge
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));
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,
...);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
The sticker returns early outside its circle. The badge backdrop tests 20 sparkles for every pixel of the modal, every frame.
.eval() is a readThe badge card reads its art 5 times per pixel: once for colour, four times for the slope.
A typo fails at runtime on the device. Make the effect at module level; PhaseBadge draws an empty canvas when it's missing.
Feed shared values through useDerivedValue. React state per frame would re-render the tree 60 times a second.
Stripes sized in 0–1 sticker units grow coarse on a big sticker. Fine grain uses pos in dp.
pow(x, y) with x < 0 is undefined in GLSL, so GPUs disagree. Test on iOS and Android. Respect reduced motion too.
Wrap up
naluri://dev/shader-labThe 9 sticker steps. Edit a step file and it hot-reloads on the phone.box() comes from.spectrum() comes from.