← Files FigmaARCHIVED FILE
skills/figma-shaders/references/authoring.md
10.7 KB · Oct 3, 2026 · 06:02 UTC
# Shader source authoring
Use this reference when producing the complete `main.ts` replacement passed to `update_shader` as `{ path: "main.ts", content: "..." }`. This MCP workflow cannot replace `features.json` directly, but `update_shader.metadata.isAnimated` and `update_shader.metadata.usesMouse` update its capability fields.
## Contents
- [Plan before writing](#plan-before-writing)
- [Required module contract](#required-module-contract)
- [Runtime lifecycle](#runtime-lifecycle)
- [Animation and mouse input](#animation-and-mouse-input)
- [WebGPU patterns](#webgpu-patterns)
- [Controls and parameters](#controls-and-parameters)
- [Effect and fill contracts](#effect-and-fill-contracts)
- [WGSL rules](#wgsl-rules)
- [Pre-build checklist](#pre-build-checklist)
## Plan before writing
Resolve these pieces together before coding:
1. Kind: `effect` or `fill`.
2. Visible controls: names, types, defaults, ranges, units, and which values stay hardcoded.
3. Capabilities: whether the source reads time or mouse input and which metadata flags that requires.
4. GPU resources: shader modules, buffers, samplers, textures, layouts, and passes.
5. Bindings: every WGSL binding must match the JavaScript bind group.
6. Uniform layout: field order, padding, and total byte size.
7. Alpha contract: premultiplied for effects, straight for fills.
Do not write until the controls, uniforms, WGSL bindings, and JavaScript resources agree.
## Required module contract
Author valid TypeScript. The build runs TypeScript and ESLint in process, so type annotations and normal function-local declarations are supported.
```javascript
import { defineProperties } from "figma:shaders"
export default function Effect() {}
export function setup(device, frame) {
// Compile shader modules and allocate format-independent GPU resources.
// Store persistent values on frame.state.
}
export function render(device, frame) {
// Read frame.params, write uniforms, encode passes, and submit.
}
defineProperties(Effect, {
// User-editable controls.
})
```
Rules:
- The only allowed import is `defineProperties` from `figma:shaders`.
- Keep `defineProperties` at module scope.
- Do not declare module-scope `var`, `let`, or `const`; the runtime strips top-level declarations and the build rejects them. Put runtime constants inside `setup`/`render` or on `frame.state`.
- Shader entry points are `vs_main` and `fs_main`; compute conventionally uses `main`.
- Every complete WGSL module string begins with `diagnostic(off,derivative_uniformity);` as its first non-empty declaration.
- Uniform buffer sizes are multiples of 16 bytes.
- Buffers written by `queue.writeBuffer` need `GPUBufferUsage.COPY_DST`.
- Textures written by `writeTexture` need `GPUTextureUsage.COPY_DST`.
- Build pipelines lazily in `render()` and cache them by `frame.output.format`. Never hardcode `rgba8unorm`.
## Runtime lifecycle
`device` is a real `GPUDevice`. `frame` provides:
| Field | Meaning |
| --- | --- |
| `frame.input` | Input `GPUTexture` or `null`. Effects may sample it; fills must not. |
| `frame.output` | Target texture. Render into `frame.output.createView()`. |
| `frame.params` | Values declared by `defineProperties`. |
| `frame.state` | Persistent mutable bag for modules, buffers, samplers, layouts, and pipelines. |
| `frame.time` | Absolute animation clock in milliseconds. |
| `frame.deltaTime` | Milliseconds since the previous rendered frame. |
| `frame.frame` | Zero-based rendered-frame counter. |
| `frame.mousePosition` | Layer-local mouse position. |
Use `frame.output.width` and `frame.output.height` for dimensions. Allocate format-independent resources once in `setup`. Write live params into buffers and construct input-dependent bind groups in `render`, because the input texture may be recreated.
Available JavaScript includes WebGPU enums, `Float32Array`, integer typed arrays, `Math`, collections, JSON, and promises. There is no DOM, `window`, `document`, `navigator`, `fetch`, console, timer, animation-frame, microtask, `Float64Array`, or `Float16Array`. Use `Math.sin`, `Math.max`, and similar JavaScript forms—not bare WGSL-style math in JavaScript.
## Animation and mouse input
Animation and mouse-driven shaders require source and manifest metadata to agree:
- If the source reads `frame.time`, `frame.deltaTime`, or `frame.frame`, pass `metadata: { isAnimated: true }` to `update_shader`.
- If the source reads `frame.mousePosition`, pass `metadata: { usesMouse: true }`.
- When removing the last use of one capability, pass its metadata value as `false`. Omitted metadata preserves the existing manifest value.
Prefer the absolute `frame.time` clock over accumulating `frame.deltaTime` in `frame.state`; absolute time remains stable when rendering skips frames.
`frame.mousePosition.x` and `.y` are local layer pixels. Normalize coordinates against the output dimensions when the visual should resize with the layer.
## WebGPU patterns
Prefer a six-vertex fullscreen quad with interleaved clip-space position and UV:
```javascript
frame.state.quad = device.createBuffer({
size: 6 * 4 * 4,
usage: GPUBufferUsage.VERTEX,
mappedAtCreation: true,
})
new Float32Array(frame.state.quad.getMappedRange()).set([
-1, -1, 0, 1, 1, -1, 1, 1, -1, 1, 0, 0,
-1, 1, 0, 0, 1, -1, 1, 1, 1, 1, 1, 0,
])
frame.state.quad.unmap()
```
Its vertex layout is:
```javascript
buffers: [{
arrayStride: 16,
attributes: [
{ shaderLocation: 0, format: "float32x2", offset: 0 },
{ shaderLocation: 1, format: "float32x2", offset: 8 },
],
}]
```
Create the render pipeline only when the output format changes:
```javascript
if (frame.state.pipelineFormat !== frame.output.format) {
frame.state.pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: frame.state.shaderModule,
entryPoint: "vs_main",
buffers: [{
arrayStride: 16,
attributes: [
{ shaderLocation: 0, format: "float32x2", offset: 0 },
{ shaderLocation: 1, format: "float32x2", offset: 8 },
],
}],
},
fragment: {
module: frame.state.shaderModule,
entryPoint: "fs_main",
targets: [{ format: frame.output.format }],
},
primitive: { topology: "triangle-list" },
})
frame.state.pipelineFormat = frame.output.format
}
```
Allocate uniform buffers once and write them every render. Prefer `vec4f` slots so padding is explicit:
```javascript
frame.state.uniforms = device.createBuffer({
size: 16,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
device.queue.writeBuffer(
frame.state.uniforms,
0,
new Float32Array([frame.params.amount, frame.output.width, frame.output.height, 0]),
)
```
Build bind groups that contain `frame.input` each render. If compute feeds render, encode both passes on one command encoder before submitting.
## Controls and parameters
Expose values users will tune per layer. Keep implementation details hardcoded inside `setup` or `render`; do not invent module-scope constants.
| Type | Use | Important shape |
| --- | --- | --- |
| `boolean` | On/off behavior | `{ type: "boolean", defaultValue: true }` |
| `string` | Genuine free text only | `{ type: "string", defaultValue: "Hello" }` |
| `number` slider | Bounded continuous value | Include `min`, `max`, `step`, `control: "slider"` |
| `number` input | Free numeric value | `control: "input"`, optional `unit` |
| numeric select | Modes/presets | `control: "select"`, numeric option values `0,1,2...` |
| `color` | RGBA control | Channels are normalized `0..1` |
| `gradient` | Two-to-eight color stops | Ordered positions `0..1`; pack a fixed eight-stop uniform plus count |
| `point` | Center/origin | Prefer `mode: "canvas_and_ui"`, `unit: "%"` |
| `point-radius` | Center plus radius | Radius percent is relative to the smaller layer dimension |
| `point-point-line` | Two endpoints | Prefer percent units for resize-relative geometry |
| `point-angle-radius` | Polar control | Angle is degrees |
| `color-point` | Coupled position and color | Prefer over separate controls for one visual feature |
Dropdowns must be numeric selects. Do not use a string with `control: "select"`; it becomes unstable after edits.
Percent positions are layer-relative and should be divided by 100 for UVs. Pixel values are absolute local pixels and do not scale with layer resizing.
## Effect and fill contracts
### Effects
- Guard `frame.input == null` before calling `createView()`.
- Input and output use premultiplied alpha. When changing opacity, scale the full RGBA value, not alpha alone.
- Clamp offset UVs before sampling unless transparent out-of-bounds reads are intentional.
```wgsl
let opacity = 0.5;
color *= opacity;
```
### Fills
- Do not bind or sample `frame.input`.
- Output uses straight alpha. Scale only the alpha channel when changing opacity.
```wgsl
let opacity = 0.5;
color.a *= opacity;
```
## WGSL rules
- `let` is immutable; use `var` for mutated values and accumulators.
- Do not use function-scope `const`.
- Do not assign multi-component swizzles. Replace the entire vector.
- Array literals use `array(v1, v2)`, not `[v1, v2]`.
- Matrices are constructed from columns.
- `select(falseValue, trueValue, condition)` uses the opposite ordering from a C ternary.
- Avoid reserved or ambiguous names such as `texture`, `sampler`, `sample`, `min`, `max`, `meta`, and WGSL keywords.
- Use WGSL builtins such as `@builtin(position)`; never use GLSL `gl_*` names.
- Use `textureSample` for filtered normalized UV sampling and `textureLoad` for integer texels.
- `textureSample`, derivatives, and gathers must execute in uniform control flow. For a sample needed inside a per-fragment branch, sample first and use `select`, or use `textureSampleLevel(..., 0.0)`/`textureLoad` deliberately.
- Fixed-bound loops are preferable. Avoid per-pixel loop bounds or breaks around implicit-derivative sampling.
## Pre-build checklist
Before calling `update_shader` with `files: [{ path: "main.ts", content: source }]`, check:
1. Kind matches the existing resource.
2. Source is the complete module, not a diff.
3. `metadata.isAnimated` matches any use of `frame.time`, `frame.deltaTime`, or `frame.frame`.
4. `metadata.usesMouse` matches any use of `frame.mousePosition`.
5. Time values are treated as milliseconds and preferably derive from the absolute clock.
6. No top-level runtime declarations.
7. Bindings and bind-group entries match exactly.
8. Uniform sizes and writes match and are 16-byte aligned.
9. Pipelines track `frame.output.format`.
10. Effects guard and sample input; fills never bind input.
11. Alpha handling matches the kind.
12. WGSL starts with the diagnostic directive and uses valid entrypoint names.
13. No mutated `let`, swizzle assignment, bracket array literal, GLSL builtin, or non-uniform implicit-derivative sample.
On a build error, fix the specific compiler failure with the smallest change and retry once.
SHA-256: 4ed8fb1fa1afed7092d338786a16a70e848f2e2e3d3ef1f5a4e1d5c65a746401