Lab
Payload CMS Shader Plugin
We built a shader studio inside our own CMS. Editors choose a Streak Field in any media slot, tune it in a drawer over the page, and publish. Posters render in the browser, and every page that uses the field updates at once.
Why
A content page needs something alive beside the copy
Our new site is live while its art direction is still taking shape. Wherever that direction lands, it will need shaders. The content-rich internal pages needed them first. They carry the depth that search engines and AI look for, and without something alive beside the copy they read as a wall of text.
The plan was one abstract shader we could place across those pages. Something that feels like data, and can be tuned toward something more organic. Stock images would have been faster. We built the shader anyway.
The problem
Getting it out of the lab
The shader was the fun part. The harder question was how to get it out of the lab and into the hands of whoever is editing a page.
The goal was not a deep creative exploration. It was to find an interesting look, then build it with performance at the forefront. A background effect on a content page cannot slow down the page it sits behind.
In the first version, editors could only choose looks that already existed in code. Every tweak was a pull request. The person with the taste for a page and the person with repo access had to be the same person.
So the goal got sharper. Anyone logged into the CMS can create a shader and use it on any block that shows media. Each one comes with a poster, so visitors get a real image while the WebGL loads, or instead of it on devices that should not run it.
The Streak Field
A particle system where the CPU does almost nothing
Up to 8,000 streaks in one draw call, with nearly all of the math in the vertex shader. It runs on three.js through React Three Fiber.
Every streak is an instance of the same small strip of quads. Each carries eight seeded random values that decide its row, phase, length, brightness, speed, lifetime, jitter and flicker. After mount those buffers never change. Each frame the CPU writes a handful of uniforms and gets out of the way.
Description and steps
A flow diagram in two groups. The first group, once on mount, runs a seeded pseudo random generator into two four component hashes per streak and then into instanced attribute buffers. The second group, every frame, has the CPU write about fifty uniform floats; those uniforms and the buffers from the first group both feed the vertex shader, which computes position, life, bend and shade. The vertex shader feeds the fragment shader, which draws the caps, the thickness profile and the tail, and the whole field leaves as one draw call. The point of the figure is that the buffers are built once and never touched again, so a frame costs a handful of uniforms and nothing else on the CPU.
- Seeded PRNG (start or end, Once, on mount)
- Two vec4 hashes per streak (Once, on mount)
- Instanced attribute buffers (Once, on mount)
- CPU writes about 50 uniform floats (Every frame)
- Vertex shader: position, life, bend, shade (Every frame)
- Fragment shader: caps, tent, tail (Every frame)
- One draw call (start or end, Every frame)
Connections
- Seeded PRNG to Two vec4 hashes per streak
- Two vec4 hashes per streak to Instanced attribute buffers
- Instanced attribute buffers to Vertex shader: position, life, bend, shade
- CPU writes about 50 uniform floats to Vertex shader: position, life, bend, shade
- Vertex shader: position, life, bend, shade to Fragment shader: caps, tent, tail
- Fragment shader: caps, tent, tail to One draw call
Position is a pure function of time
In the default drift motion there is no simulation state at all. A streak sits wherever the clock and its own hashes say it should sit.
1float span = uResolution.x + lengthPx * 2.0;2float xPx = mod(start + uTime * speed + reseat, span) - lengthPx;
Three details hide in those two lines. The wrap span is one streak length wider than the frame on each side, so nothing ever pops at an edge. Each rebirth jumps the streak along its row by a golden ratio multiple of the span, which is the most irrational step available and stops a spot reading as one dash blinking in place. And a power curve skews the length hash toward the short end, so a field is mostly ticks with a few long trails.
Time is integrated on the CPU rather than read off the clock, so changing speed bends the rate instead of teleporting every streak to a new phase. Delta is clamped at 50ms, so a tab switch cannot jump the field. Stateless also means deterministic: we can freeze time, scrub it, or render frame N offscreen and get exactly the same picture. That property pays for itself twice later.
Description
One streak drawn large with its parts labelled. Length runs along the dash, between the minimum and maximum length controls. Thickness is its height. The cap control rounds the two ends. The tail control fades the dash from a bright head at the leading end to a dim tail at the trailing end; at zero the dash is flat. Sliders for length, thickness and tail redraw the dash as they move.
The field
One flow field, three jobs
The field is 3D noise where the third axis is time, so it evolves in place instead of sliding across the screen. Six noise formulas sit behind a single uniform, which means switching looks live never relinks the program.
That one field displaces each streak so it bends, turns each dash like a tick on a vector plot, and shades brightness as a height map. When particles need to travel through the field, a small ping-pong simulation gives them memory. For a hero, the whole simulation state is a 90 by 89 texture.
curl: along the contours
gradient: up the slope
Description
Two fields of short dashes drawn over the same height map. On the left the dashes follow the curl of the map, so they run along its contours and circle each hill like the lines on a topographic chart. On the right they follow the gradient, so they point straight up each slope and converge on the peaks. A slider turns the dashes from lying flat on their rows to facing the field fully. Same noise, same positions: only the direction rule differs.
Six noise formulas sit behind a single integer uniform: value, simplex, fbm, ridged, curl and gradient. It is a uniform branch and not a compile time switch, so every vertex in the draw takes the same path, the unused formulas cost nothing, and switching noise live never relinks the program. A relink would be a compile stall and a dropped frame.
That one field does three jobs. It displaces every vertex of the strip independently, so a streak bends along the field instead of sliding as a rigid dash. It orients each dash toward the field direction at its centre, like a tick on a vector plot. And its scalar value doubles as a height map, shading brightness and shortening streaks so high ground glows and low ground goes dark. A bent streak still keeps its thickness, because the centreline is sampled twice and the strip is extruded along the local normal.
Where the noise runs
Noise samples per frame
Description and data table
A bar chart of noise samples per frame in a hero field of 8,000 streaks. Running the noise per vertex, which is what ships, costs about 96,000 samples per frame. Shading the same noise per pixel costs roughly 2.9 million, about thirty times more. The takeaway is that the single biggest performance decision was keeping simplex noise out of the fragment stage, and that the vertex cost does not grow with the device pixel ratio while the per pixel cost does.
| Where the noise runs | Samples per frame |
|---|---|
| Per vertex, what we ship | 96K |
| Per pixel | 2.9M |
Interaction
Four forces, and one uniform for light or dark
Pointer response is four forces sharing one falloff: a radial push, a swirl around the cursor, a wake along the pointer's velocity, and a local boost to the noise. Position, velocity and presence are all eased on the CPU with frame rate independent damping, so the shader only ever reads settled values. The canvas never swallows a scroll or a click, and an idle frame costs no layout.
Light and dark are a single uniform. It crossfades between light on a dark ground, where brightness lives in the colour, and ink on paper, where brightness becomes coverage. Both are premultiplied through one blend function, so a theme toggle is a uniform sweep with no recompile and no pop.
Delivery
Poster first
The server HTML ships a real image. The WebGL chunk loads only once the slot is hydrated, near the viewport, visible, and on a device that should run it. That rules out reduced motion, coarse pointers, and software WebGL. A document-level GPU budget has to admit it too. The canvas crossfades in on its first drawn frame, not on compile.
A watchdog averages frame time. Two slow windows step quality down once. Two more fall back to the poster. It never steps back up, on purpose. Every failure lands on the poster.
Description and steps
A top down decision flow. The server HTML always ships a real poster image. From there a canvas has to pass three gates in order: the slot is hydrated, the device should run it at all, and the slot is in view with the GPU budget free. Failing either of the first two leaves the visitor on the poster for good. The third gate only makes it wait and re-check. Passing all three crossfades the canvas in over the poster. The takeaway is that the poster is the default and the canvas is the exception, so a visitor never waits on WebGL to see the page.
- Server HTML: a real poster (start or end)
- Slot hydrated? (decision)
- Device should run it? (decision)
- In view and budget allows? (decision)
- Crossfade to the canvas (start or end)
- Stay on the poster (start or end)
Connections
- Server HTML: a real poster to Slot hydrated?
- Slot hydrated? to Stay on the poster: no
- Slot hydrated? to Device should run it?: yes
- Device should run it? to Stay on the poster: no
- Device should run it? to In view and budget allows?: yes
- In view and budget allows? to Stay on the poster: not yet (optional or async)
- In view and budget allows? to Crossfade to the canvas: yes
Description and states
A state diagram of the runtime. A slot starts on its poster and goes live once it is admitted and has drawn its first frame. Frame time is averaged over 90 frame windows: two consecutive windows under 40fps step a live field down to degraded, which halves the particles and drops the pixel ratio to one. Two more slow windows and it falls back to the poster for good. Going offscreen suspends the field, and coming back into view resumes it. The takeaway is that every path out of trouble ends on the poster and nothing ever steps back up, which is deliberate hysteresis.
- Poster (start or end)
- Live
- Degraded
- Suspended
- Back to poster (start or end)
Connections
- Poster to Live: admitted
- Live to Degraded: 2 slow windows
- Degraded to Back to poster: 2 more
- Live to Suspended: offscreen
- Suspended to Live: returns
The budget
Every slot gets its own budget
Density and pixel ratio are capped per placement, not per look. A hero may run 8,000 streaks at a pixel ratio of 1.5. A content block gets 4,000 at 1.0. The menu runs 1,000 at 2.0, because it is small and sharpness matters there more than density. Cards never run the shader at all and stay on their poster.
When a cap lands below the number of grid cells a look asks for, both pitches widen so the grid stays full at a coarser density instead of losing its bottom rows. Every length in the shader is a CSS pixel scaled by the device pixel ratio, so a look reads the same at any ratio.
Placement
Streaks granted
Description and data table
A horizontal bar chart of how many streaks each placement is allowed. A hero gets 8,000, which is the ceiling. A content block gets 4,000. The menu gets 1,000. A card gets none and stays on its poster. The takeaway is that the ceiling belongs to the placement and not to the look, so an editor can lower a count in the Studio but can never raise it past what the slot allows.
| Placement | Streaks granted |
|---|---|
| Hero | 8K |
| Content block | 4K |
| Menu | 1K |
| Card | 0 |
Try one
Copy, paste, deploy
The shader was born in our Immersive Lab with a control wired to every uniform. That is a great place to design a look and a terrible place to ship one.
The first bridge made the Streak Field a media type. Anywhere an editor could pick an image or a video, they could pick a Streak Field and choose a look from a dropdown. A look was a named preset in code with two posters committed beside it. It worked. The last look shipped this way touched 13 files and needed a production deploy for what was, creatively, a handful of slider positions.
The playground gained a Ship as look folder, and its Copy button emitted paste ready source instead of a bag of props. Two blocks of code registered a look: the tuning as deltas against the defaults, and the entry that named it.
1// src/features/immersive/presets.ts2export const STREAK_FIELD_TECHNICAL_B2B = {3 count: 1500,4 segments: 8,5 layout: 'grid',6 columnPitch: 10.5,7 rowPitch: 11,8 motion: 'flow',9 // deltas only, never a restated default10}1112// src/features/immersive/visual/looks.ts13'technical-b2b-v1': look({14 id: 'technical-b2b-v1',15 label: 'Technical B2B',16 description: 'A sparse grid of bent dashes streaming along a simplex field.',17 motion: 'flow',18 tuning: STREAK_FIELD_TECHNICAL_B2B,19}),
Posters were a script: boot Storybook, point a browser at each look on a dark and a light ground, write the files, bump a revision so nothing served a stale still. Then stories, a pull request, a deploy.
It worked. Editors could place shaders. They could only place ours.
Try two
The first Studio lasted 26 hours
We modeled the first Studio like a package registry. A look held a draft. Publishing froze it into an immutable release. Page slots pinned a release, and posters came from a render queue drained by headless Chromium on a serverless function.
It was correct. It was also 3,753 lines, and it fell apart the moment it was used like an editor instead of like its author. A changed color did not reach the site until every page was re-pinned and republished. Publishing answered with a queue. The poster came from a software GPU, not the one the editor had been looking at. There were five concepts to learn before anyone could change a color.
Description and messages
A sequence diagram of the first Studio publish. The editor hits Publish release. The Studio saves the draft and queues a render job, then answers the editor with Queued and tells them to poll a Renders tab. A separate render worker claims the job under a lease, boots headless Chromium to capture the posters, and writes the posters and an immutable release back to Payload. The editor is then left with a manual pass of their own: re-pin the slot and publish, page by page. The takeaway is that a colour change took an asynchronous queue plus a walk over every page before anything reached the site.
- Editor to Studio: Publish release
- Studio to Payload: Save the draft, queue a render job
- Studio to Editor: Queued. Poll the Renders tab (reply)
- Render worker to Payload: Claim the job under a lease
- Render worker, to itself: Boot Chromium, capture posters
- Render worker to Payload: Posters plus an immutable release
- Editor, to itself: Re-pin and publish every page
Milestone
Lines of code
Description and data table
A diverging bar chart of lines of code around a zero baseline. Shipping Studio v1 added 3,753 lines. The rewrite that replaced it removed 1,545. The takeaway is that the version that worked was the smaller one, and that deleting the distribution model was the change that made the product easier to explain.
| Milestone | Lines of code |
|---|---|
| Studio v1 shipped | 3,753 |
| The rewrite | -1,545 |
The reframe
A shader is just a media file
Use a Streak Field the way you use an image.
When you replace an image in a media library, every page that uses it updates. Nobody pins pages to image versions. The page stores a reference, the library owns the pixels, and the library keeps its own history. Editors already understand this completely.
We considered a separate app, the way you would build in Spline or Rive and embed the result. We did not want new infrastructure for one shader, and Payload is easy to extend. So the Studio is a plugin: one collection, custom admin views, three endpoints. It lives under Assets, right next to Media. That placement is the whole point.
The field is seeded and its motion is a pure function of time. The same seed and the same frame always produce the same picture.
So the Studio mounts the scene on an offscreen canvas in the editor's browser, steps it a fixed number of frames with a neutral pointer, and reads back one poster for the dark ground and one for the light. The placeholder is a real frame of the real shader, rendered on the GPU the editor was looking at.
The Inspector can lower the particle count but never raise it past the hero ceiling. Segments are fixed and octaves are capped. A budget line under the stage reports what the placement will actually grant, and goes amber when a cap bites.
Try three
A shader is a media file
The slot now stores the field's id and the site renders whatever is published. Everything that existed to support pinning and queued rendering was deleted: the releases and renders collections, the queue, the worker function, the capture route, the polling UI, and two heavyweight dependencies.
Publish became one request and one transaction. It checks that the recipe is the saved draft, verifies the posters, files them in Media, and publishes the snapshot. History is Payload's native versions. The migration was additive, so the site rendered the same pixels from the first request.
Description and messages
A sequence diagram of the current publish. The editor hits Publish. The Studio captures a dark poster and a light poster on the editor’s own GPU by stepping the deterministic scene a fixed number of frames offscreen. It sends the recipe and the two images to a single publish endpoint, which runs one transaction: lock the look, check the recipe is the saved draft, verify the images, file the posters in Media, publish the snapshot. It answers in a few seconds, and the field is live on every page that uses it. The takeaway is that determinism turned an asynchronous render queue into one synchronous request.
- Editor to Studio in the browser: Publish
- Studio in the browser, to itself: Capture a dark and a light poster
- Studio in the browser to Publish endpoint: The recipe plus two images
- Publish endpoint to Payload: One transaction: verify, file, publish
- Publish endpoint to Studio in the browser: Done, in a few seconds (reply)
- Payload to Editor: Live on every page that uses it (reply)
- Studio v1
- Studio v2
What it carries
Count
Description and data table
A horizontal bar chart comparing Studio v1 and Studio v2 by how many of each thing they carry. Collections went from three to one. Worker functions from one to none. Long lived secrets from two to none. Continuous integration drain jobs from one to none. Concepts an editor has to learn from five to two. The takeaway is that the rewrite removed a whole layer of infrastructure and, more importantly, three of the five nouns an editor previously had to understand before changing a colour.
| What it carries | Studio v1 | Studio v2 |
|---|---|---|
| Concepts to learn | 5 | 2 |
| Collections | 3 | 1 |
| Long lived secrets | 2 | 0 |
| Worker functions | 1 | 0 |
| CI drain jobs | 1 | 0 |
Guardrails
Rules that keep the site whole
A look can only be published with its posters, so the site never has a live field without a placeholder. A page cannot be published while a slot holds a never-published field. A field in use cannot be deleted, and its posters cannot be edited in Media.
On read, one hook walks the document once, batches a single query per request, and swaps each field id for its published snapshot and posters.
In production
The editorial workflow now
In any slot that takes media, an editor chooses Streak Field. The field sits there like a media file would, with a thumbnail, a title, Edit field and Change. A new field is named after the page and the slot, so the library organizes itself.
The Studio opens in a drawer over the page, at that slot's placement. The page never closes. Starters load a shipped look into the draft, and dark and light grounds are one toggle. Publish renders two posters in the browser in a few seconds, and the field goes live with them everywhere it is used. No second publish on the page.
Description and steps
A short left to right flow. Pick Streak Field in any media slot, create a new field named after the page and the slot, tune it in a Studio drawer that opens over the live page, publish, and it is live everywhere it is used. A dashed edge runs back from live to tuning, labelled Edit field, because editing is the same loop. The takeaway is that the path from wanting a change to the change being live no longer includes a pull request or a deploy.
- Pick Streak Field in a media slot (start or end)
- New field, named after page and slot
- Tune in the Studio drawer
- Publish
- Live everywhere it is used (start or end)
Connections
- Pick Streak Field in a media slot to New field, named after page and slot
- New field, named after page and slot to Tune in the Studio drawer
- Tune in the Studio drawer to Publish
- Publish to Live everywhere it is used
- Live everywhere it is used to Tune in the Studio drawer: Edit field (optional or async)
What it removed
Smaller, faster, easier to explain
Three collections, a queue, a worker, two secrets and a CI job became one collection and three endpoints. Five concepts became two: draft and published. Updating a field used to mean re-pinning and republishing every page. Now it is one publish.
The rewrite removed 1,545 lines and two heavyweight dependencies. The product got faster and easier to explain.
Export
Stills for everything else
The same deterministic capture renders a still at any size for decks, social, or a poster override, and files it in Media.
Next
Next: more than one effect
The Studio is becoming effect-agnostic. Each shader describes itself through a small authoring contract of parameters, groups, looks and scenes. The Light Leak from our footer is being wired in as the second effect.
Learnings
What we took from it
The shader was the part we expected to be hard. What cost us a rewrite was the model we wrapped around it.
Immutable pinned releases are the right answer for packages and the wrong answer for a background. Nobody wants semver for a background. Telling editors it works like an image needed zero onboarding.
The recipe format, the validation envelope, snapshot hashing, the capture scene and the whole Inspector survived. What died was the distribution model around them.
Seeded hashes and stateless time made the shader cheap. Then they made server-side rendering infrastructure unnecessary.
The best version of the Studio was the one with the most removed. Fewer collections, fewer concepts, no worker. We were polishing a model editors should never have had to learn, and the fix was to take it away.
About this project
- Status
- In progress
- Built with
- Payload CMSNext.jsReact Three Fiberthree.jsGLSLWebGL2TypeScriptPostgreSQL