SUITS & SANDALS

Expertise

  • Website Strategy, UX & Development
  • Sales, Marketing & Investor Communications
  • Clarifying Complex Stories
  • Embedded Creative & Digital Services
  • Product UX/UI & Design Systems

Who We Help

  • Technical B2B & Expert-Led Companies
  • Healthtech & Life Sciences
  • Growth & Repositioning Brands
  • Platforms & Digital Products
Ask
Answers about our work, services, and insights
  • HR TechVault Workforce Screening
  • InsurtechArturo
  • FintechInterchecks
  • Works
  • Insights
  • Immersive Lab
  • About Us
  • Website Strategy, UX & Development
  • Sales, Marketing & Investor Communications
  • Clarifying Complex Stories
  • Embedded Creative & Digital Services
  • Product UX/UI & Design Systems
  • Technical B2B & Expert-Led Companies
  • Healthtech & Life Sciences
  • Growth & Repositioning Brands
  • Platforms & Digital Products
Get in touch

Brooklyn, NY / Philadelphia, PA

Get in touch

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.

A shader an editor can publish like an image

Introduction

Our content-rich pages needed more life than a wall of text. So we built the Streak Field, a GPU particle shader designed with performance at the forefront, then a Payload CMS plugin that puts it in the hands of whoever is editing the page. It took three tries in ten days. The version that stuck treats a shader as a media file: one reference per slot, one publish, native version history, and a real poster for every visitor whose device should not run WebGL.

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.

Where the work happens

Once, on mountEvery frameSeeded PRNGTwo vec4 hashes perstreakInstanced attributebuffersCPU writes about 50uniform floatsVertex shader:position, life, bend,shadeFragment shader: caps,tent, tailOne draw call
The attribute buffers are written once and never uploaded again. Each frame the CPU writes a handful of uniform floats 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.

  1. Seeded PRNG (start or end, Once, on mount)
  2. Two vec4 hashes per streak (Once, on mount)
  3. Instanced attribute buffers (Once, on mount)
  4. CPU writes about 50 uniform floats (Every frame)
  5. Vertex shader: position, life, bend, shade (Every frame)
  6. Fragment shader: caps, tent, tail (Every frame)
  7. 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.

What one streak is made of

length (minLength to maxLength)thicknesstailhead (cap rounds both ends)
Length220px220px
Thickness10px10px
Tail0.420.42
Soft caps measured in UV over pixel length, so a cap is the same size on screen at any dash length. A tent profile across the thickness is the antialiasing: MSAA is off.
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 against gradient, same field

curl: along the contours

gradient: up the slope

Orient1.001.00
Curl is divergence free, so dashes circulate along the contours instead of piling up in sinks. The gradient of the same potential sends them straight up the slopes.
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 decides the frame cost

Where the noise runs

Noise samples per frame

A hero field is 8,000 streaks and each vertex samples the field up to three times, for its centre, its centreline and its tangent. The per pixel figure is the roughly thirtyfold multiple quoted in our own build notes, and unlike the vertex cost it grows with the device pixel ratio.
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 decides the frame cost
Where the noise runsSamples per frame
Per vertex, what we ship96K
Per pixel2.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.

Every gate a canvas has to pass

noyesnoyesnot yetyesServer HTML: a realposterSlot hydrated?Device should run it?In view and budgetallows?Crossfade to thecanvasStay on the poster
The device gate is four checks at once: not reduced motion, not a coarse pointer, WebGL2 present, and no software fallback. Beyond it a document level budget admits three live contexts and one streak field. The crossfade happens on the first drawn frame, not on compile.
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.

  1. Server HTML: a real poster (start or end)
  2. Slot hydrated? (decision)
  3. Device should run it? (decision)
  4. In view and budget allows? (decision)
  5. Crossfade to the canvas (start or end)
  6. 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

The watchdog never steps back up

admitted2 slow windows2 moreoffscreenreturnsPosterLiveDegradedSuspendedBack to poster
admitted2 slow windows2 moreoffscreenreturnsPosterLiveDegradedSuspendedBack to poster
Between poster and live there is a brief preparing state, and any error there fails straight back to the poster. Eight seconds suspended releases the context entirely. Quality steps down once and never back up.
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.

  1. Poster (start or end)
  2. Live
  3. Degraded
  4. Suspended
  5. 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.

What each placement is granted

Placement

Streaks granted

Device pixel ratio is capped alongside the count: 1.5 in a hero, 1.0 in a content block, 2.0 in the menu, because the menu is small and sharpness matters there more than density. The budget line under the Studio stage goes amber when a cap bites.
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.

What each placement is granted
PlacementStreaks granted
Hero8K
Content block4K
Menu1K
Card0

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.ts
2export 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 default
10}
11
12// src/features/immersive/visual/looks.ts
13'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.

Publishing in Studio v1

EditorStudioRender workerPayloadPublish releaseSave the draft, queue a render jobQueued. Poll the Renders tabClaim the job under a leaseBoot Chromium, capture postersPosters plus an immutable releaseRe-pin and publish every page
Correct, and 3,753 lines. The worker rendered in software Chromium, so the poster never came from the GPU the editor had been looking at.
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.

  1. Editor to Studio: Publish release
  2. Studio to Payload: Save the draft, queue a render job
  3. Studio to Editor: Queued. Poll the Renders tab (reply)
  4. Render worker to Payload: Claim the job under a lease
  5. Render worker, to itself: Boot Chromium, capture posters
  6. Render worker to Payload: Posters plus an immutable release
  7. Editor, to itself: Re-pin and publish every page

The rewrite was mostly deletion

Milestone

Lines of code

Studio v1 lasted about 26 hours before we started replacing it. The rewrite also dropped two heavyweight dependencies, headless Chromium and a browser driver, out of the bundle.
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.

The rewrite was mostly deletion
MilestoneLines of code
Studio v1 shipped3,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.

Publishing in Studio v2

EditorStudio in thebrowserPublish endpointPayloadPublishCapture a dark and a light posterThe recipe plus two imagesOne transaction: verify, file, publishDone, in a few secondsLive on every page that uses it
On read, one hook swaps each field id for its published snapshot and posters, so a page that uses the field is already current. History is Payload’s native versions: restoring a state copies it into the draft and nothing on the site moves until you publish.
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.

  1. Editor to Studio in the browser: Publish
  2. Studio in the browser, to itself: Capture a dark and a light poster
  3. Studio in the browser to Publish endpoint: The recipe plus two images
  4. Publish endpoint to Payload: One transaction: verify, file, publish
  5. Publish endpoint to Studio in the browser: Done, in a few seconds (reply)
  6. Payload to Editor: Live on every page that uses it (reply)

What the Studio carries, before and after

  • Studio v1
  • Studio v2

What it carries

Count

The concepts row is the one that mattered: draft, release, render, pin and version became draft and published.
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 the Studio carries, before and after
What it carriesStudio v1Studio v2
Concepts to learn52
Collections31
Long lived secrets20
Worker functions10
CI drain jobs10

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.

Nine steps and a deploy became this

Edit fieldPick Streak Field in amedia slotNew field, named afterpage and slotTune in the StudiodrawerPublishLive everywhere it isused
Edit fieldPick Streak Field in amedia slotNew field, named afterpage and slotTune in the StudiodrawerPublishLive everywhere it isused
The page never closes. The drawer runs the field at that slot’s real placement, with the budget line reporting what the placement will actually grant.
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.

  1. Pick Streak Field in a media slot (start or end)
  2. New field, named after page and slot
  3. Tune in the Studio drawer
  4. Publish
  5. 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

Ready to start?

Let’s make it make sense.

Request an estimateGet in touch

Want to go deeper?

Ask about our work, process, capabilities, or what working together could look like.

S&S

Ask Suits & Sandals

Online

Privacy PolicyTerms and Conditions