y5 background shader — skill
SkillDev toolsNourish OS. The modern way of using a computer.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the y5 background shader — skill skill
What this skill tells your AI
The instructions your AI receives, as published by y5-snowies/nourish in document/shader-skill/SKILL.md and read by ahel’s review.
Audience: an agent writing a shader bundle for the y5 compositor from a natural-language request. This document is the specification. It is exhaustive: everything you need to emit a correct bundle is here. The example bundles listed at the end are for calibration — read one when you want to see a shape in practice, not to discover what exists.
This folder is self-contained. Everything it refers to is inside it, and every path below is relative to this file:
SKILL.md this document
y5-shader-x86 a prebuilt client for the compositor's gRPC service
shader.proto the service schema, for any other gRPC client
examples/ every bundle referenced below
Resolve the paths against wherever you loaded the skill from. Nothing here reaches outside the folder, so it can be copied anywhere and still work.
Every fact here is checked against the implementation. Where something is parsed but does not work, it says so — those are the traps that cost the most time.
0. Before you write anything
Read first, then build
You are being asked for an effect, and this document is long. Read the sections the request actually needs before emitting a single line — a bundle that has to be corrected three times is slower and more visible to the person waiting than two minutes of reading.
Always read §1 (which form) and §3 (the push block). Then, by what the request involves:
| The request mentions | Read | Open |
|---|---|---|
| just a background, wallpaper, pattern | §1.1, §10 | examples/aurora |
| the desktop, "everything", a whole-screen look | §5, §5.1 | examples/mp-grayscale |
| windows — glow, outline, per-window treatment | §5, §6.1 | examples/tb-window-frost |
| seeing behind a window: transparency, refraction | §7 | examples/tb-window-chroma |
| focus, dragging, resizing, "the active window" | §6.2, §6.3 | examples/tb-window-descriptors |
| the cursor | §6.4 | examples/tb-storage-embers |
| several stages, blur, bloom, pyramids | §2, §11 | examples/tb-chain-bleed |
| trails, decay, "leaves a mark", feedback | §9.1 | examples/tb-persist-trail |
| particles, counters, anything accumulating | §9.2 | examples/tb-storage-embers |
| barrel/lens/CRT distortion, anything that bends the picture | §8 | examples/mp-crt |
Open at least one example of the shape you are about to write. The guide gives you the schema;
the example gives you the idiom — how a band loop is actually written, where the sRGB encode goes,
what a sensible @prop range looks like. They are short and heavily commented.
Before writing WGSL you should be able to answer, without looking anything up again: single-file
or multipass; before- or after-content; which requires; and whether you need to own the band.
If any of those is still open, you are not ready — go back to the table.
Then check §12 (the refusals) and §13 (the checklist) before you emit. Most first drafts fail on something in §13.
Reason from the primitives, not from the examples
The examples are idioms, not a catalogue. They show a handful of things people happened to build. They do not describe the boundary of what is possible, and an effect being absent from them means nothing at all.
What you can build is decided by one question: for the pixel you are shading, what do you know?
Under windows: world with geometry and textures, at every pixel you hold
- the position of that pixel, and the whole screen it sits in;
- every window's rectangle, crop, opacity, kind and descriptor flags — not just the one under the pixel;
- every window's texture, samplable at any coordinate you like;
- when each window opened, was focused, was resized, was moved (§6.3);
- where the cursor is and what it is holding (§6.4);
- whatever you computed last frame, if you asked for it (§9);
- and you decide, per pixel, what the final colour is.
Anything that is a function of those inputs is expressible. That is a very large space, and it includes most things that sound physical:
| Sounds like it needs… | It does not, because |
|---|---|
| light from one window falling on another | you hold both rectangles and both textures while shading either; brightness added as a function of distance to the emitter is the effect |
| shadows between windows | draw order is the occluder order; darken by distance to a rect above |
| reflections of one window in another | sample the other window's texture at a reflected coordinate |
| windows reacting to each other | every window's data is available while shading every pixel |
| depth, layering, "in front of" | the band is an ORDERED list — index is your depth |
| glow, bleed, spill, refraction | resample the composited scene, or the individual windows, however you like |
The compositor is a 2D screen-space renderer. Screen-space is the medium, not a compromise — there is no more "real" version of these effects available and none is being approximated away. Do not describe such work as faking, impersonating or approximating; it is the implementation.
Build the whole effect; cost is not your decision
Several sections below talk about what things cost the engine. None of that is a reason to build a smaller effect than the one you were asked for.
The cost material exists for two reasons, neither of which is "prefer something simpler":
- Declaring accurately is a correctness rule. The loader refuses a pass that reads something it did not declare. Declaring what you read is not thrift, it is the thing working at all.
- The techniques in §11 make an ambitious effect affordable — downscale before you blur, run the widest stage at half rate. They exist so you can build the big version, not so you settle for the small one.
So: build what was asked. Reach for a three-level pyramid, per-window textures, storage buffers, whatever the effect actually needs. If the result is genuinely heavy — a full-resolution multi-pass chain over every window, say — build it anyway and mention in one sentence that it is doing real work and can be scaled back if their machine struggles. That is their call, made while looking at it, not yours made in advance.
Silently substituting a cheaper, weaker effect is the worst outcome available: they get something that does not match what they asked for, and no reason why.
The actual limits
Short, and worth knowing precisely so you never invent a limit that is not here:
- No z-buffer and no 3D geometry. Windows are flat rectangles in an ordered list. You can use the order as depth; you cannot get true per-pixel depth or perspective from it.
- Only what is on screen, in this world. Windows that are off-screen, minimised, on another world, or that a client has not drawn contribute nothing — they are not in the set.
- Fixed capacity. 256 entries in the geometry arrays; over that the tail is dropped.
- Hardware.
window_texturesneeds descriptor indexing;storageneedsfragmentStoresAndAtomics. Both are refused honestly at load, not silently. - No client interaction. You are drawing; you cannot move a window, change its size or send it input. Read the geometry, do not expect to set it.
If a request runs into one of these, say which one and offer the nearest thing that works. If it does not run into one of these, it is buildable — work out the function, do not decline.
Talk about the effect, not the machinery
The person asking wants their desktop to look different. They did not ask about push constants,
descriptor indexing, requires, band ordering, or why storage buffers need a device feature.
None of that belongs in your reply unless they ask.
- Say in a sentence or two what you are about to make. Make it. Say whether it worked.
- Offer the knobs by their labels ("Cursor pull", "Trail persistence"), never by prop name or slot number, and only the two or three that matter.
- Mention a technical fact only when it changes what they see or what they must do: an effect their GPU cannot run, something that only shows while windows are open, a setting that resets when they change resolution.
- Translate failures.
declares storage, which this GPU cannot write from a fragment shader (no fragmentStoresAndAtomics)is a message for you. To them it is "your graphics card can't do this one — I've used a different approach that looks close." Fix it yourself if you can; only surface it if they have a decision to make. - Do not paste the manifest, the WGSL, binding tables, or your reasoning about bands and requirements. If they want to see it, they will ask, and the files are on disk either way.
- Never call your own work fake. "Impersonating", "faking", "only an approximation" — a screen-space compositor has no other kind of effect, so that description is both discouraging and untrue. Describe what it does instead: "the fire brightens whatever is near it, so windows next to it pick up the glow."
- Do not say something is impossible unless it is on the limits list above. If you are unsure, work out what the shader would have to read at each pixel and check it against that list. Most physically-worded requests are ordinary functions of data you already hold.
The exception is when they are clearly authoring rather than decorating — they name a requires
entry, ask why something is slow, or ask how it works. Then match them and use the real terms.
Always tell them how to adjust it themselves
Every @prop you declare becomes a live slider on their desktop. When you finish a shader, hand
over the controls — otherwise they have to come back to you to nudge a number, which is a poor
trade for both of you.
Say it once, at the end, in plain words. Something like:
You can tune this yourself without me — double-click (or right-click) any empty spot on the desktop and pick the shader icon, and the sliders appear right there while the effect is running. They're also in Super+Tab → Settings → Current World if you'd rather have the full panel.
Try Cursor pull first — that's the one that changes the feel most.
And any time you want a different look entirely, Super+Tab gets you to the shader list — everything built in is there alongside this one, and switching back and forth costs nothing.
Four things make that useful rather than noise:
- Name the one or two sliders worth touching first, by their label, and say what each changes in the picture. A list of nine controls is the same as no guidance.
- The inline editor is the one to lead with. It floats over the live desktop, so a drag shows its result immediately; the settings panel shows a small preview pane instead. Mention the settings route second, for people who prefer a real window.
- Mention Super+Tab as the way out. A user who does not like the effect, or who wants to compare it against something, should not have to ask you to change it back. Say it once, with the sliders.
- Say it once, when you hand the work over — not before you start, and not every time you touch the shader afterwards.
If the effect has no adjustable variables, say so plainly ("this one has no settings — tell me what to change and I'll edit it") rather than sending them to an empty panel.
Both routes edit the same values, they save per world, and they take effect as you drag — no restart, nothing to apply.
1. Decide the form first
There are two kinds of bundle. Choosing wrong is the most common failure.
| Ask | Form |
|---|---|
| "an animated wallpaper", "a plasma / aurora / gradient background" | Single-file. One WGSL file, no manifest. |
| Anything that reads the desktop, the windows, the cursor, or needs more than one pass | Multipass. A pipeline.json graph. |
A single-file bundle cannot see the desktop at all — it only draws a background. The moment the
request involves what is on screen ("make my windows glow", "tint everything", "CRT effect"),
you need multipass with composited_scene.
Do not emit a pipeline.json for a plain background. It works, but it costs an offscreen
composite the effect does not use.
1.1 Single-file layout
<bundle>/
wgsl/shader.wgsl # portable WGSL (preferred)
vulkan/shader.wgsl # Vulkan-only WGSL
glsl/shader.frag # desktop GLSL 450
gles/shader.frag # OpenGL ES 1.00
Provide one. wgsl/shader.wgsl unless you have a reason. The loader tries the formats the
active renderer supports, in its preferred order, and falls back to the built-in parallax if none
compile. Vulkan is the primary renderer; GLES exists but is not the target — if you only ship
wgsl/, GLES sessions get the built-in background, which is acceptable.
The entry point is fs_main, a fragment shader. Same Push block as §3.
1.2 Multipass layout
<bundle>/
pipeline.json
passes/*.wgsl # one file per pass (a file may be used by several passes)
lib/*.wgsl # optional shared modules, #import-ed
2. pipeline.json — complete schema
Every field, with its default. Unknown fields are a hard error (deny_unknown_fields) — do
not invent keys.
{
"name": "my-bundle", // required, string
"version": 1, // default 1
"category": "Multipass", // optional; the picker heading. Omit → "User"
"modules": ["lib/shared.wgsl"], // default []; #import-able sources
"targets": { // default {}; named offscreen images
"blur": {
"format": "rgba16f", // rgba8 | rgba16f (default) | rgba32f
"scale": 0.5, // default 1.0; edge length vs output
"persist": false // default false; see §9
}
},
"storage": { // default {}; GPU buffers, see §9
"acc": { "bytes": 65536 } // required; rounded up to 16
},
"textures": { // default {}; images shipped with the bundle, see §9.4
"sheet": {
"file": "art/sparks.png", // required; PNG, bundle-relative, no `..`
"srgb": true // default true; FALSE for anything read as data
}
},
"windows": "engine", // engine (default) | pipeline | world — see §7
"decorations": "keep", // keep (default) | off
"letterbox": "keep", // keep (default) | on-resize | always — CURRENTLY INERT
"hit": { ... }, // optional pointer warp, see §8
"passes": [ // required, ≥1
{
"name": "blur", // required
"shader": "passes/blur.wgsl", // required, relative to the bundle
"inputs": { "src": "blur" }, // default {}; binding name → target name
"output": "output", // default "output" (= the swapchain)
"when": "before-content", // before-content (default) | after-content
"defines": { "HORIZONTAL": "true" }, // default {}; naga_oil shader-defs
"requires": ["composited_scene"], // default []; see §5
"place": "auto", // auto (default) | worker | compositor
"cadence": 1 // default 1; run every Nth frame
}
]
}
letterbox is currently inert. It parses and is carried, but the draw path takes the same arm
for every value. Setting it is harmless and does nothing. Do not promise a user it will change
anything.
3. The push constants
Every pass, single-file or multipass, receives this. Declare exactly this struct (you may declare
params shorter — array<vec4<f32>, 1> — if you use fewer props):
struct Push {
res_zoom_time: vec4<f32>, // xy = resolution, z = world zoom, w = time (seconds)
pan_flow: vec4<f32>, // xy = camera pan, zw = flow offset
lock_alpha: vec4<f32>, // x = lock amount, y = alpha,
// z = sRGB-encode flag, w = packed pan velocity
params: array<vec4<f32>, 4>, // 16 floats: your @prop values
};
var<immediate> pc: Push;
Notes that matter:
res_zoom_time.xyis THIS PASS'S target, not the screen. Ascale: 0.25pass sees quarter resolution. Souv = frag.xy / resis always right and needs no correction.res_zoom_time.wis the shared animation clock, seconds since compositor start. The same clock window timestamps are on (§6.2), which is what makest - timestampan age.lock_alpha.z > 0.5means you must sRGB-encode your output. Always end with:
Omit this and the bundle is visibly washed out on the affected path. Only the pass writingif (pc.lock_alpha.z > 0.5) { col = pow(max(col, vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2)); }outputshould do it.lock_alpha.wis the pan velocity, two snorm16 halves:let vel = unpack2x16snorm(bitcast<u32>(pc.lock_alpha.w)) * 16384.0; // world px/s
3.1 The world moves — zoom and pan
y5's world is pannable and zoomable, and res_zoom_time.z / pan_flow.xy are how a background
knows. A shader that ignores them is not neutral: it stays welded to the glass while the desktop
slides and scales underneath it, which reads as the background having come unstuck from the world.
The engine's own convention, from the stock parallax — follow it unless you mean something else:
// Centred, aspect-corrected by HEIGHT, then divided by zoom.
var uv = (frag.xy - 0.5 * res) / max(res.y, 1.0);
uv = uv / max(pc.res_zoom_time.z, 0.0001);
// Horizontal tracks the camera as -pan; the vertical is inverted here, which is
// the baseline the per-world "Invert pan Y" toggle flips back from.
let pan = vec2<f32>(pc.pan_flow.x, -pc.pan_flow.y);
zoomdivides, so zooming in makes the content bigger. Guard it (max(zoom, 0.0001)).panis in world pixels, not UV — scale it into your own space (the parallax uses factors like0.001per depth layer, which is what makes the layers separate).- Depth is
panscaled per layer: distant things move less. That is the whole of parallax. pan_flow.zwis a separate slow flow drift the engine advances on its own; it is what keeps a background alive while the camera is still.
Positions are handled for you. Lengths are not. This is where zoom goes wrong.
windows.rects[i], windows.srcs[i] and pointer.at.xy are screen UV, post-transform — the
compositor has already applied pan and zoom. Never transform them again; that is a separate bug
whose symptom is windows and effects drifting apart as the user scrolls.
But that only settles where things are. Every length you write yourself is a screen length by default, and a screen length does not change when the world zooms — while everything the engine positioned does. So the two drift apart in scale, and an effect that looked right at 100 % is visibly wrong at 50 %.
This is the mistake to watch for, because "I used the window rect the engine gave me" feels like it should be enough, and it is not. It applies to every self-authored magnitude: a sprite's extent, a glow radius, a noise frequency, a blur width, a border thickness, a particle size, a drift depth, a displacement amount.
Worked example — snow settling on windows. Zoom out. The window halves in size, because the engine applied the camera to its rect. The flake size, drift thickness and noise frequency were written in screen UV, so they do not halve. The snow now looks twice as coarse relative to the window it is sitting on. Nothing is "wrong" in the code; the lengths are simply in the wrong space.
Two correct fixes — pick by what the thing belongs to:
// (a) WINDOW-RELATIVE. Work inside the window's own 0..1 box. Every length is then
// a fraction of that window and tracks it at any zoom, for free, with no `zoom`
// read at all. Best for anything that belongs to ONE window: snow on its top
// edge, a border, a frost pattern, wear on its surface.
let local = (uv - r.xy) / max(r.zw, vec2<f32>(0.0001));
let flake = 0.04; // 4% of the window, always
// (b) WORLD-SCALED. Multiply screen lengths by `zoom`, or sample fields at
// `uv / zoom`. Best for anything that belongs to the WORLD rather than to one
// window: sparks drifting between windows, a field, ambient particles.
let zoom = max(pc.res_zoom_time.z, 0.0001);
let flake = 0.04 * zoom; // a fixed WORLD size, drawn at screen scale
let field = fbm(uv / zoom); // world-locked feature size
The test, and it takes five seconds: zoom the world out. If the effect does not shrink along with the desktop, its lengths are in the wrong space. Do this before handing a shader over — it is the single most common thing that looks perfect while authoring and wrong in use.
Screen-anchored is a legitimate choice — but make it one
A vignette, scanlines, film grain, a sheet of glass over the display: those live on the output,
not in the world, and should stay in screen units at every zoom. What is not acceptable is not
deciding. If the shader paints a place, it scales and moves with the world; if it paints the
screen, it does not. tb-texture-paper is the second kind, on purpose, and says so in a comment.
mp-parallax is the reference for the first kind — it is the stock
background, and lib/parallax.wgsl does zoom, pan, per-layer depth and flow in one place. Read it
rather than deriving the convention again.
3.2 Make it look the same at 60 Hz and 120 Hz
Drive every animation from res_zoom_time.w. It is an absolute wall clock in seconds, shared
by the compositor and the off-thread worker on purpose, so anything written as a function of t
looks identical at any refresh rate, on any path, on every monitor. This is the default and it is
free — sin(t * 2.0), fract(t * speed), t - times.life[i].x are all already right.
Never advance state by a fixed amount per frame. x = x * 0.94 or pos += speed in a
persist target or a storage buffer is a per-frame step, and the frame rate is not a constant:
120 Hz decays twice as fast as 60 Hz, and an offloaded background runs at its own paced rate that
matches neither. The same bundle will look different on two monitors of the same desktop.
The push carries no frame delta. If you genuinely need one — feedback and simulation are the only cases — store the previous timestamp yourself and subtract:
// One spare channel of a `persist` target (or one word of `storage`) holds the
// last frame's clock reading.
let prev = textureSampleLevel(state, samp, uv, 0.0).a;
// Clamp: the first frame reads a zeroed target, so `dt` would be the whole
// uptime; a hitch or a resume would otherwise take one enormous step.
let dt = clamp(pc.res_zoom_time.w - prev, 0.0, 0.1);
Then express constants per second and convert, rather than tuning a per-frame number:
let decay = pow(decay_per_second, dt); // NOT a fixed per-frame factor
let moved = pos + velocity * dt; // NOT pos + velocity
Write pc.res_zoom_time.w back into that channel each frame and the accumulator is rate-independent.
cadence is a cost knob, never a timing mechanism. cadence: 2 means "every second frame",
so it ticks at refresh ÷ N — 30 Hz on a 60 Hz screen and 60 Hz on a 120 Hz one. It is correct for
throttling expensive work whose result is a held image; it is wrong as a way to slow an animation
down. Slow the animation by scaling t.
4. Bindings
// group 0 — the pass's own inputs. Always present if the pass has inputs or any group-1/2 block.
@group(0) @binding(0) var samp: sampler;
@group(0) @binding(1) var first_input: texture_2d<f32>; // in `inputs` declaration order
@group(0) @binding(2) var second_input: texture_2d<f32>;
// …bindings 1..=N for N inputs, in the order `inputs` is written (BTreeMap → sorted by key)
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 229
- Forks
- 7
- Last commit
- Sep 2026
ahel review
K1binfo
installs-packages
Automated review, not a security audit. Ruleset v1+k2.
Advanced
- Catalog kind
- skill
- Gateway key
shader-skill- Source
- github.com/y5-snowies/nourish