Andrei Keida

Graphics programmer / render developer

Real-time shaders and algorithms

Five demos running live in the browser on WebGL2 and GLSL ES 3.0: no engine, no libraries, no video. Each states the problem, the method, the render passes and the cost per frame, and its shader source can be edited on the page. The closing section describes the hardware they are written for.

7 live demos0 dependencies1 HTML fileeditable shaders, live

Paint that pools and ripples

Paint that has thickness: it piles up, levels out, ripples when hit, and gets glossier where it is thick.

GPU simulationMRTheight-field shadingping-pong targets
Drag to pour paint. Hold still to build a mound and watch it settle.
Live shader editor: simulation + present

This is paint with volume

Every pixel stores a physical height of paint; the pigment rides on top. Pouring adds volume, so a mound forms and slowly flattens, and a fast stroke leaves a wake of ripples that fade. The gloss follows the surface slope, so thick blobs read as wet and rounded while thin smears read as flat.

Under the hood

  • State lives in two RGBA16F ping-pong targets: (volume, wave displacement, wave velocity) and pigment. One simulation pass writes both through MRT, so there is no second pass for colour.
  • Volume and waves are separate fields. The brush adds volume, which levels out by relaxing toward its 4-neighbour mean (viscosity), and kicks a displacement field that obeys the discrete 2D wave equation: v += c²·∇²d, d += v, with c² = 0.2 < 0.5 for stability. Displacement is damped to zero where there is no paint, so ripples reflect off the paint edge like in a puddle.
  • Pigment mixes in proportion to the added volume; thin paint borrows colour from thicker neighbours, so spreading paint keeps its hue instead of turning grey.
  • Shading: surface height h = volume + displacement, n = normalize(−∂h/∂x·k, −∂h/∂y·k, 1) from a 5-tap stencil, dual-lobe Blinn-Phong (a sharp and a broad highlight), Fresnel rim, thickness-driven saturation albedo^(1+0.9·volume), a soft contact shadow, and a smoothstep on volume for a crisp meniscus edge.
  • The brush is a capsule between the previous and the current pointer position, so fast strokes never leave gaps.

What it costs

  • Simulation resolution is decoupled from the screen: 640 px wide here, 384 px on phones, at the canvas aspect. The present pass scales it up bilinearly, so the cost does not grow with device pixel ratio.
  • Two render-target switches per frame (simulate, present). On tile-based GPUs each switch is a tile flush, so the pass count is the budget, not the pixel count.
  • Simulation pass: 10 texture taps, about 40 ALU. Present pass: 6 taps. All values fit half precision; height is clamped to [−0.5, 3] so mediump is safe on Mali and Adreno.

Snow that keeps every track

A field of fresh snow and a ball. Roll it: the snow compresses into a trench, piles up along the rim and keeps every track. Not a single vertex buffer in the scene.

deformable terrainMAX-blended trailindex-free meshesinstanced snowfall
Move the pointer over the snow to roll the ball.
Live shader editor: terrain, trail, ball, snowfall

This is snow you can deform

Deformation that persists. The ball sinks, the snow it displaces builds a soft rim on both sides of the track, crossing tracks merge the way real ones do, and the trench walls catch the low sun. 4 000 flakes fall through the scene and land behind the terrain, not on top of it.

The same building blocks make a sand box, a mud road or a cake with a knife through it: a height field, a trail texture and a material that reacts to compression.

Under the hood

  • Terrain: an index-free grid (6 vertices per cell, 224×154 cells) generated from gl_VertexID and displaced in the vertex shader by height = base(x,z) − depth·trench + rim·(1 − trench).
  • The trail is a 512² RG render target written with MAX blending: every frame the ball rasterises a capsule between its previous and current position with two profiles, a rounded trench in R and a ring of pushed-up snow in G. MAX makes overlapping passes merge with no read-back and no ping-pong.
  • Normals come from the gradient of the same height function in the fragment shader (4 texture taps plus the analytic base), so trench edges stay crisp regardless of grid resolution.
  • Snow material: wrapped Lambert for subsurface scatter, sky-tinted ambient, compression darkening and a blue shift inside the trench, hashed micro-glints that catch the sun, Fresnel from the sky, exponential fog.
  • Ball: a lat-long sphere from gl_VertexID, rolling rotation integrated from its velocity (axis = up × v, angle = distance / r), patterned in its own space so the roll reads. Snowfall: instanced camera-facing quads with position, speed and phase from an integer hash, depth-tested against the terrain with depth writes off.

What it costs

  • Per frame: one tiny render-target write (the capsule), one vertex texture tap per terrain vertex, four taps per pixel for the normal. No read-back, no compute, no CPU simulation.
  • Phones get a 120×75 grid, a 256² trail and 1 200 flakes; the flakes are the only transparent geometry and draw last with depth writes off, so there is no sorting.
  • MAX blending is core OpenGL ES 3.0, and the trail is an 8-bit texture: bandwidth stays trivial on tile-based GPUs.

Light that finds its way around corners

An implementation of the method Alexander Sannikov published in 2023. Paint lights and walls: soft shadows, light bouncing off the walls, penumbrae that widen with distance, and no cost per light.

radiance cascadespenumbra hypothesismulti-bounceHDR pipeline
Paint: pick a tool, then drag on the scene.
Live shader editor: cascades, bounce, tone-mapping

This is light that bounces

Global illumination for a 2D scene with any number of emitters and occluders, updated every frame. Walls cast soft shadows whose softness grows with distance, light reaches around corners because the walls themselves bounce it, and emitters render as hot HDR cores with a bloom halo instead of flat blobs. Nothing here is a blur trick: every pixel gathers radiance from all directions.

Radiance cascades were introduced by Alexander Sannikov at Grinding Gear Games in 2023, first presented at ExileCon and used for global illumination in Path of Exile 2. The method rests on a single observation, the penumbra hypothesis: resolving light from a source requires high spatial resolution when the source is near and high angular resolution when it is far, and the two requirements move in opposite directions. A cascade hierarchy exploits that inverse relationship — each level halves probe density and quadruples the number of directions traced — so a full radiance field costs time linear in screen pixels and independent of the number of emitters. In 2D the result is noiseless, unlike ray-sampled radiosity; extending the method to 3D remains an open problem.

The implementation here follows the original formulation with the bilinear fix, and adds a feedback bounce term and an HDR present pass. Reference: radiance.wiki.

The idle light that orbits the scene is not painted into the texture; it is injected into the distance field and the hit lookup, which is the cheap way to have dynamic emitters on top of a static scene.

Under the hood

  • A hierarchy of probe grids, each tracing a short ray interval instead of one full ray per pixel. Cascade i has probe spacing 4·2^i, 16·4^i rays and interval [L·(4^i−1)/3, L·(4^(i+1)−1)/3]: spacing ×2, rays ×4, length ×4, so every cascade texture is the same W×H and total cost is linear in pixels. 16 rays at the base (instead of the usual 4) buys 4× angular resolution for free at the same texture size, which is what removes the radial spokes around small lights.
  • Rays are sphere-traced through a nearest-solid field rebuilt with a jump-flooding chain (log₂N passes, RGBA32F seeds). A ray hits when it comes within a texel of a solid and takes that texel's emission.
  • Merging goes top-down with the bilinear fix: for each of the four nearest upper probes the lower ray is traced to the exact point where that probe's ray begins, then merged with the mean of its four child rays, rad = lower.rgb + lower.T·upper.rgb, T = lower.T·upper.T, and the four results are blended bilinearly. Four short traces instead of one remove the ringing that vanilla cascades show at interval boundaries.
  • Bounce light by feedback: when a ray hits a wall it returns the wall's albedo times the radiance that reached that face on the previous frame, so multi-bounce GI converges over a few frames at zero extra passes.
  • Gather is bilinear over the four nearest cascade-0 probes (4 px spacing) into a linear radiance target; the composite writes an HDR image (emitter cores at 8× white), then a half-resolution two-pass bloom, ACES tone-mapping, gamma and a dither against banding.
  • 4 cascades at 768×480, 3 at 256×160 on phones.

What it costs

  • The cost is bounded by the scene texture, not the screen: about 20 small passes over 768×480 texels, all of them fullscreen-triangle draws with no geometry. The bloom runs at half resolution.
  • Cascades are RGBA16F; only the seed coordinates in the jump-flood chain use 32-bit floats, because half precision cannot address 512 texels exactly.
  • Sphere tracing is capped at 32 steps; the cap is the knob for the cheapest devices. The sampler holding the distance field is declared highp, which matters on mobile drivers where sampler precision is real.
  • Remaining artifacts are the known ones: slight light leaking through walls thinner than a probe spacing. Parallax-corrected merging is the next step for production.

Metal that catches the light

Sparkles that appear and vanish with the view angle, the way real glitter does. Nothing is baked, so it reacts to every micro-movement.

sphere tracingprocedural micro-normalsclear coat + floppure ALU
Drag to rotate. Hover to move the light.
Live shader editor

This is metal that sparkles

A pillowed slab of metallic flake paint under a clear coat. Thousands of tiny mirrors sit at slightly random angles: near the highlight many of them fire, further away only the odd one, and all of them catch a dim glint from the environment, which is what gives the surface its grain. Rotate the slab or move the light and a different set fires. That view dependence is the whole effect, and it is exactly the thing a texture cannot fake.

Under the hood

  • Surface: a signed-distance rounded box, sphere-traced in at most 90 steps, normals from central differences. The silhouette is anti-aliased by tracking the closest approach of missed rays in pixel units and blending the surface colour at that point.
  • Flakes: object space is diced into three cell grids of different size. Each cell gets a micro-normal fn = normalize(N + tilt·spread) where the tilt is the sum of two hashes, a triangular distribution, so the sparkles cluster around the highlight and thin out away from it the way a real flake distribution does. A hashed mask thresholded by the density slider decides whether the cell holds a flake.
  • Each flake has two responses: a sharp key-light glint pow(dot(reflect(−V, fn), L), 120/size) and a dim environment glint from the sky reflected in its mirror direction. Flakes are rounded by the distance to their cell centre, tinted with a view-dependent hashed hue for a hint of iridescence, and twinkle on a per-cell phase.
  • Base coat with "flop": the colour shifts from bright at normal incidence to dark at grazing angles, the signature of metallic paint. Clear coat: Schlick Fresnel against an analytic studio environment (gradient plus key and rim soft boxes) and a two-lobe highlight.
  • ACES tone-mapping and gamma at the end, so the sparkles bloom into white instead of clipping.

What it costs

  • No textures. Every flake sample is arithmetic, which on mobile is cheaper than a dependent texture fetch: per pixel the three layers cost six hashes, six pow and a handful of dot products.
  • The sphere tracer dominates the frame here and exists only because a browser demo has no mesh. In production the same flakes() function runs on a mesh with object-space position and normal, at a fraction of the cost.
  • Keep the hash inputs in highp (they multiply by hundreds) and everything else in mediump; on phones the demo also caps the device pixel ratio at 1.

Motion that has weight

A bouncing ball that collects gems. Squash and stretch, spring follow, impact shockwave, particle burst, coin-collect flight, camera shake: every motion is an explicit function of time. No keyframes, no physics engine, no per-particle state.

tweens and easingsspringssquash and stretchprocedural VFX
Move the pointer to steer the ball. Tap or click for a high jump.
Gems 0
Live shader editor: meshes, gems, particles

This is motion with weight

The layer of a game that players call "juicy". The ball stretches while it flies and flattens when it lands, the floor dips and springs back, a shockwave ring runs out, chips fly and settle, gems pop and are sucked into the counter along a curve, the camera flinches on a hard landing. Each of those is one formula with a start time; together they read as weight, material and mood.

Under the hood

  • Jump: a parabola y = 4H·u(1−u) with period T = 2√(2H/g), so a tap only changes H and gravity stays consistent. Velocity is its derivative, not a stored number.
  • Squash and stretch: sy = (1 + k·|v|)·(1 − a·(1 − spring(t))), sx = sz = 1/√sy, so the volume is preserved. The spring is the closed-form underdamped response 1 − e^(−ζωt)(cos ωdt + ζ/√(1−ζ²)·sin ωdt), shared by the floor dip and the camera flinch.
  • Steering: a critically damped spring toward the pointer (c = 2√k) gives lag without overshoot; the ball leans by its own velocity.
  • Gem pickup: a quadratic Bézier from the gem to the counter, timed with easeInBack (a tiny hesitation before the suck), an easeOutBack pop on pickup and a shrink on arrival; respawn is an easeOutElastic scale-in. All in the vertex shader from a 16-entry uniform table.
  • Burst: 64 chips, each p(t) = p0 + v0·t + g·t²/2, spin and shrink from easings, evaluated in the vertex shader from the instance id's hash and the landing time. Zero CPU work, zero buffers.
  • Shockwave: a ring scaled by easeOutExpo and faded linearly; camera shake: sinusoids under an exponential envelope.

What it costs

  • Six draw calls, about 4 000 vertices, three programs, no vertex buffers (cube, sphere and ring come from gl_VertexID). The entire scene costs less than a UI panel.
  • All animation state is a handful of timestamps; particles and gems are pure functions of uTime, so nothing is uploaded per frame except one small uniform array.
  • Translucent geometry is limited to the blob shadow and the ring, drawn last with depth writes off.

Cloth that hangs, folds and catches

5 184 particles integrated and constrained entirely on the GPU. Move the pointer: a sphere behind the sheet pushes it forward and the fabric wraps around it.

Verlet integrationconstraint solververtex texture fetchMRT ping-pong
Move the pointer to push the cloth. Change what is pinned and watch it fall.
Live shader editor: solver and fabric

This is cloth solved on the GPU

A 72×72 sheet of particles. Gravity and wind accelerate them, distance constraints hold the weave together, the sphere is a hard collision, and the top corners are nailed in place until you release them. Nothing about it is animated: every fold is the solver settling.

Turn the iteration count down and the fabric turns to rubber — that slider is the whole trade-off between accuracy and cost in one control.

Under the hood

  • State is two RGBA32F textures — current and previous position — written together through MRT and ping-ponged between two framebuffers. Velocity is never stored: Verlet reads it as p − pprev, which makes collisions behave like friction for free.
  • Integration is p' = p + (p − pprev)·d + a·Δt² at a fixed timestep, so a dropped frame cannot blow the simulation up.
  • Each iteration relaxes twelve distance constraints per particle — four structural, four shear, four bend — Jacobi style: every constraint proposes a correction, the corrections are averaged, then scaled by the stiffness factor. Jacobi rather than Gauss-Seidel because every texel must be solvable in parallel with no ordering.
  • Collisions are projections. The point is pushed to the sphere surface or above the floor plane; because Verlet infers velocity from positions, the projection alone produces the damping and sliding.
  • The mesh has no vertex buffer: the vertex shader turns gl_VertexID into a grid cell, fetches the four positions it needs with texelFetch and builds the normal from central differences of the position field.
  • Shading treats the sheet as two-sided, with wrapped diffuse, transmitted light on the shadow side, rim sheen, an antialiased weave and an analytic shadow from the sphere.

What it costs

  • Nine passes per frame over a 72×72 target: one integrate, twelve relaxations. That is 67 000 texels of work in total — less than a single 1080p fullscreen pass.
  • The solver is bandwidth-bound, not ALU-bound, which is why the resolution stays small and the iteration count is the knob. On phones the grid drops to 48×48.
  • Vertex texture fetch costs one dependent read per vertex; ES 3.0 guarantees at least 16 vertex texture units, so it is safe everywhere.
  • RGBA32F is used deliberately: positions accumulate error across hundreds of iterations, and fp16 drifts visibly within seconds. It is the one place in this portfolio where full precision is worth its bandwidth.

A cloud, lit from the inside out

Participating media raymarched in real time: light scatters through the volume, the cloud shadows itself, and the silhouette glows when the sun sits behind it. Hover to move the sun, drag to orbit.

volumetric raymarchingsingle scattering3D noisetemporal accumulation
Hover to move the sun. Drag to orbit. Put the sun behind the cloud.
Live shader editor: scattering and tone-mapping

This is light inside a volume

Not a sprite and not a shell: the ray actually travels through the cloud, and at every step it asks how much light survives the trip to the sun. That is why the rim lights up when the sun goes behind, why the core stays dark, and why moving the sun changes the whole read of the shape.

Drop the step count and the cloud bands; raise it and it smooths out. Quality here is a sampling problem, not a shading one.

Under the hood

  • The ray is clipped to a bounding sphere first, so pixels that miss the cloud cost one intersection test. Inside it, density is an ellipsoid field eroded by noise — the ellipsoid also acts as an early-out before any texture fetch.
  • Noise is a 64³ tileable three-octave value noise baked once into an R8 3D texture and sampled with hardware trilinear filtering: two fetches replace roughly thirty hashes per sample. The opposite trade to the glitter demo, and for the opposite reason — here the same noise is evaluated hundreds of thousands of times per frame.
  • Lighting is single scattering. At each step a short ray marches toward the sun with a geometrically growing step, accumulating exp(−σ·ρ·Δt) — Beer-Lambert transmittance — and the result is weighted by the Henyey-Greenstein phase function, whose g is on a slider.
  • Integration is energy conserving: each segment contributes T·(1 − exp(−σρΔt))·L and transmittance is multiplied through, instead of the usual lerp that quietly changes brightness with the step count.
  • Ray entry points are offset by a 4×4 ordered dither rotated per frame, and the result is accumulated over frames while the camera and sun hold still. The banding turns into noise, and the noise then averages away.

What it costs

  • The march runs at half resolution into an RGBA16F target and is upsampled bilinearly at present time. Volumetrics are low-frequency, so the half-resolution loss is nearly invisible — this is what ships in engines.
  • Worst case is 56 primary steps × 6 light steps, but transmittance below 0.02 breaks the loop early, so opaque interiors stop paying for what they cannot see.
  • Temporal accumulation blends at 0.28 while nothing moves and jumps to 1.0 the moment the camera or the sun does, so history is never reused across motion and there is no ghosting to hide.
  • On a phone this would run at quarter resolution with 24 steps, 4 light steps and a 32³ noise texture. The knobs are the step counts and the resolution — never the phase function or the transmittance, which are what make it read as a cloud.

Mobile GPU architecture

Every demo above targets the same hardware model: a tile-based renderer on a narrow, shared memory bus. That architecture, not the shading model, dictates how the shader is written.

Tile-based rendering

A desktop GPU is an immediate-mode renderer: fragments go straight to colour and depth buffers in VRAM behind a 300–1000 GB/s bus. A phone has 15–60 GB/s of LPDDR shared with the CPU, ISP and display controller, so it inverts the pipeline. Vertex processing runs first across the whole frame and bins primitives into screen tiles — 16×16 on Mali, 32×32 on PowerVR and Apple. Fragment processing then runs tile by tile with colour, depth and stencil resident in on-chip tile memory: hundreds of kilobytes of GMEM on Adreno, tens of kilobytes per tile elsewhere. The tile is resolved to main memory once.

Immediate-mode versus tile-based rendering Immediate mode (desktop) Geometry Fragments Colour + depth VRAM every blend and depth test crosses the bus Tile-based (phone) Geometry Binning shaded in tile memory (GMEM) DRAM one resolve per tile

Bandwidth arithmetic

1080p RGBA8 is 8.3 MB. One full-screen pass reading and writing it at 60 Hz costs 1 GB/s; the same target as RGBA32F costs 4 GB/s, out of a budget shared with the whole SoC. Hence R8 and RG8 wherever the range allows, RGBA16F only for HDR, ASTC for sampled textures, simulation targets at a fixed resolution decoupled from device pixel ratio, and analytic evaluation instead of LUT textures whenever ALU is cheaper than the fetch.

Precision and occupancy

mediump is fp16: double FMA throughput on Valhall and Adreno, half the register file per invocation. Register pressure sets occupancy — how many warps (16 threads on Valhall, 64 or 128 on Adreno, 32-wide SIMD groups on Apple) the core keeps resident to hide texture latency. Precision is a scheduling decision, not a quality one. highp goes to positions, hash inputs and texture addressing; lowp samplers are avoided outright, since a sampler is permitted to truncate the values it returns.

What breaks a tiler

  • discard and alpha test: coverage becomes unknown at raster time, so early-Z and HSR are disabled for the entire draw.
  • Transparency: blended layers cannot be culled by HSR, so overdraw is fill rate paid per layer.
  • FBO ping-pong and mid-pass readback: a tile flush, plus a full pipeline stall for readPixels.
  • Dependent texture reads: coordinates derived from another fetch defeat prefetch and expose the full latency.
  • Divergent control flow: a warp executes the union of its threads' branches, so an uncapped raymarcher bills its worst pixel to all of them.
  • Per-frame buffer and uniform uploads: driver validation and CPU/GPU synchronisation return to the critical path.

How the demos above comply

About

Software engineer since 2013 with a math degree and a long detour through 3D engines, TV platforms and native apps. Graphics is where the math and the craft meet, and the part I want to do full time.

Path

  • 2007–2012
    MSc Computer Science, Dnipropetrovsk National University (now Oles Honchar DNU)

    Linear algebra, numerical methods and geometry are the tools every demo above is built from.

  • 2013–2016
    C# developer, ISD and 3D Systematics
  • 2018–2020
    C# developer, Dimenco

    UE4 VR, then a Unity game project.

  • 2020–2024
    Senior software engineer, Uscreen

    Video monetization platform: Apple TV, Android TV and Roku apps, plus Flutter, SwiftUI and Ruby on Rails.

  • 2024–2026
    Senior software engineer, contract

    Chatbots, Revit plugins, ERP integrations, FastAPI services.

  • 2026
    Own products and engines

    Lead developer at AKEI Studio (TalkExplorer). Personal project: Protocol Echo, a browser FPS on Babylon.js and WebGL2, built to a playable vertical slice.

Stack

Shaders
GLSL ES 3.0 and WebGL2, written from the raw API up — everything on this page. HLSL, and GPU debugging and frame capture.
Engines and APIs
WebGL2 and GLSL ES, Babylon.js, WebGPU basics, Unreal Engine (C++), game engines in C#.
Math
Vector and matrix math, quaternions, numerical simulation (wave, diffusion), signed distance fields, sampling and filtering theory.
Languages
C#, C++, TypeScript, Swift, Python, Dart, Ruby.
Working style
Measure first, then change one thing. Written technical English; Ukrainian and Russian native.