Your self-hosted workspace for building, tuning, and collecting WebGL shaders.
Browse a shader library, edit GLSL with live diagnostics, generate controls from a schema, save presets, and move everything between installations as portable JSON.
The shader you select becomes the application canvas, so you edit the thing you are looking at. A broken draft never blanks the preview: Shader Studio keeps the last valid version running and places compiler diagnostics in the editor.
- Live GLSL workflow — Monaco editing, driver-backed diagnostics, and a safe compile pipeline that preserves the last working render.
- Schema-generated controls — describe numbers, booleans, colors, and selects once; Shader Studio builds the control panel and uniforms for you.
- A library that stays yours — shaders and presets live as readable files on disk, with atomic writes and no external database.
- Portable by design — export one shader or the complete collection to a versioned JSON bundle and import it elsewhere.
- Wallpaper Engine HTML — export the current shader as one self-contained, live WebGL document and drag it directly into Wallpaper Engine.
- Interactive previews — pointer velocity, click ripples, pause, screenshots, a configurable post-processing chain (Bloom, Vignette), render scaling, and texture inputs are built in.
- Web and desktop — run the SSR web app on your own machine or package the Electron desktop app for Windows.
- MCP server — let Claude Code, Codex, or Cursor drive a running Shader Studio tab: list shaders, edit GLSL, tune uniforms, and capture screenshots.
- Quick start
- Self-hosting
- Desktop app
- Using Shader Studio
- Shader format
- Import and export
- API
- MCP server
- Included shaders
- Architecture
- Development
- Tests
- Known limitations
- License
- Node.js
^22.22.3 || ^24.15.0 || >=26 - pnpm 10
Clone the repository and start the development server:
git clone https://github.com/antelm-dev/shader-studio.git
cd shader-studio
pnpm install
pnpm devOpen http://localhost:4200. The development server runs the real Express API and SSR application; the API is not mocked.
The fastest way to run Shader Studio. Compose brings up PostgreSQL and the app together; the app waits for the database to be healthy before it starts.
git clone https://github.com/antelm-dev/shader-studio.git
cd shader-studio
cp .env.example .env # then set POSTGRES_PASSWORD to a long random value
docker compose up -d --buildShader Studio is then available at http://localhost:4000, with the five example shaders already seeded.
Keep .env local: it is ignored by both Git and the Docker build context.
Only .env.example, which contains no usable credential, belongs in version
control. If .env was already tracked, adding it to .gitignore is not enough:
remove it from the index and rotate any password that may have been shared.
Your shaders live in PostgreSQL, persisted in the named postgres-data
volume; everything else in the image is disposable. The database is not published
on a host port — only the app container reaches it, over Compose's internal
network — and the connection string never leaves the server (the browser only
ever talks to the REST API).
Reaching the app under any name other than localhost — a machine name on your
LAN, a domain behind a reverse proxy — means telling SSR about it in .env,
otherwise the request is rejected:
SHADER_ALLOWED_HOSTS=localhost,127.0.0.1,[::1],shaders.example.com.env also sets SHADER_PORT (host port, default 4000) and SHADER_SEED. To
upgrade, docker compose up -d --build; the volume and its data are left alone.
Build the production application and run its Node server against PostgreSQL:
pnpm build
DATABASE_URL=postgres://user:pass@localhost:5432/shader_studio pnpm serve:ssrShader Studio is then available at http://localhost:4000.
When DATABASE_URL is set the server uses PostgreSQL; when it is not set the
server falls back to a local SQLite file under SHADER_DATA_DIR (./data),
which is convenient for pnpm dev:server but not intended for production. When
exposing the app beyond localhost, set NG_ALLOWED_HOSTS to the hostnames that
are allowed to reach SSR.
Important
Shader Studio has no authentication or multi-user isolation. Deploy it only on a trusted private network, behind an authenticated reverse proxy, or through a private access layer.
The server reads these directly; under Compose they are derived from .env
(see .env.example).
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
— | PostgreSQL connection string; when set, selects PostgreSQL, otherwise SQLite |
PORT |
4000 |
Port for the SSR server |
SHADER_DATA_DIR |
./data |
SQLite database directory (used only when DATABASE_URL is unset) |
SHADER_EXAMPLES_DIR |
./examples |
Source directory for the bundled examples that seed an empty store |
SHADER_SEED |
— | Set to 0 to disable seeding an empty store |
NG_ALLOWED_HOSTS |
localhost,127.0.0.1,[::1] |
Comma-separated hosts SSR may render for; set this when deploying |
DATABASE_POOL_MAX |
10 |
Maximum PostgreSQL pool connections |
Compose-only variables (.env): POSTGRES_DB, POSTGRES_USER,
POSTGRES_PASSWORD (build DATABASE_URL), plus SHADER_PORT (host port) and
SHADER_ALLOWED_HOSTS (feeds NG_ALLOWED_HOSTS).
PostgreSQL (Web / Docker) is the primary store. Back it up and restore it with the standard tools, e.g. against the bundled Compose service:
# Backup
docker compose exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' > backup.sql
# Restore (into an empty database)
docker compose exec -T postgres sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup.sqlSQLite (Desktop / dev) lives at one file. On the desktop app it is
<userData>/library/shader-studio.sqlite (%APPDATA%/Shader Studio/library on
Windows, ~/Library/Application Support/Shader Studio/library on macOS,
~/.config/Shader Studio/library on Linux). From-source dev without
DATABASE_URL uses <SHADER_DATA_DIR>/shader-studio.sqlite. Back it up by
copying the file (and its -wal/-shm siblings) while the app is closed.
Importing an old file library. The legacy per-shader folder format is never
deleted. The desktop app imports <userData>/library/shaders automatically on
first launch after upgrading (once, verified, files kept). For Docker, mount the
old library and run the one-shot importer explicitly — it never modifies the
source:
# with the old library mounted at /legacy-data (see docker-compose.yml)
docker compose --profile migrate run --rm migrate --source=/legacy-data
# add --mode=overwrite to replace shaders whose id already exists (default: rename)Disabling examples. Set SHADER_SEED=0 to start with an empty library.
Seeding is idempotent and versioned in the database, so it never overwrites your
shaders and a deleted example does not reappear.
Diagnostics. The app fails fast and loudly if the database is unreachable or
its schema cannot be brought to the expected version — check the container logs
(docker compose logs shader-studio / docker compose logs postgres). The
health of the API is GET /api/shaders (also the container HEALTHCHECK).
Internal database errors are never leaked to the client: the REST/IPC layer only
ever returns { error: { code, message, details? } }, never SQL, the connection
string, or a stack trace.
The Electron target currently packages for Windows:
pnpm dev:desktop # Angular dev server + Electron with main-process reload
pnpm pack:win # unpacked app in release/win-unpacked
pnpm dist:win # NSIS installer and portable executable in release/The desktop target uses
electron-ipc-module and
electron-run. It stores its
library in a SQLite database under Electron's per-user application-data directory
(<userData>/library/shader-studio.sqlite) and does not start the Express
server. The web and SSR targets use PostgreSQL behind the same REST API.
The application uses Angular 22 (zoneless SSR), Angular Material, Express, three.js, lil-gui, and Monaco.
| Script | What it does |
|---|---|
pnpm dev |
Dev server with HMR, SSR and the API |
pnpm build |
Production build into dist/ |
pnpm serve:ssr |
Run the built SSR server |
pnpm test |
Unit tests (Vitest), incl. the MCP server |
pnpm lint |
Oxlint |
pnpm format |
Oxfmt |
pnpm typecheck |
tsc -b, strict |
pnpm dev:mcp |
Run the MCP server from source |
| Key | Action |
|---|---|
Space |
Pause / resume time |
H |
Show / hide the controls |
S |
Save the frame as a PNG |
Ctrl+S |
Save the shader |
Shift+Alt+F |
Format the GLSL in the editor |
Right-click the editor's toolbar for its menu.
- Format GLSL re-indents by block depth and does nothing else. No rewrapping, no spacing opinions: a shader is dense numeric code whose columns are usually aligned on purpose, and a formatter that argues with that is one people turn off.
- Copy full GLSL copies the fragment as a file that stands on its own — the
source plus the declarations the engine would otherwise have supplied: the
precision qualifier, the built-in uniforms, and one
uniformper control. Anything the source already declares is left alone, so the result never redeclares its way into a compile error somewhere else.
Typing offers snippets for the things you would otherwise be looking up: main,
uv, ripple (the u_clickData loop), channel, palette, hash21, noise,
fbm, rot2 and uniform.
The Effects Rack, above the parameter controls in the Controls tab, is the configurable chain applied after the shader's own Image pass (never to Buffer A-D): Bloom and Vignette today, at most one instance of each. A master switch bypasses the whole chain without touching any effect's own settings; each effect has its own enable toggle, a reset to defaults, and remove/add. Reordering (drag, or the move-up/move-down buttons) changes the order the effects are actually applied in — order is part of the chain, not just the rack's display. Every change to the rack is a draft edit like any parameter: it marks the shader unsaved and is undone by discarding the draft.
A preset captures the live parameter values under a name. Ticking Also capture the render settings when saving stores the shader's post-processing chain alongside them, and applying that preset brings it back — which, since the chain belongs to the shader rather than to the knobs, leaves the document with unsaved changes. Presets saved without it never touch the chain. The ones that carry it are marked with an icon on their chip.
On desktop, More actions → Open output window opens a clean, independently resizable render surface. It follows the active draft, live parameters, pause state, render scale, and texture assignments, making it suitable for a second monitor or projector while the main window remains the control workspace.
Move the pointer over the background to push the shader around; click to drop a ripple. Both are fed to the shader as uniforms (see below) — what a shader does with them is up to it.
Five concerns, kept apart on purpose. Nothing below the line knows about Angular.
apps/
web/
src/ Angular browser, SSR, and desktop entry points
app/ workspace state, rendering, editor, and UI
server/
src/ Express SSR host and REST API
api/ route parsing and HTTP error mapping
create-library.ts picks PostgreSQL (DATABASE_URL) or SQLite, then seeds
cli.ts `migrate-files` — one-shot legacy import
desktop/
main/src/ Electron lifecycle, windows, updates, and IPC handlers
preload/src/ sandboxed context bridge
package.json desktop build, development, packaging, and type checks
libs/
shared/ model, validation, GLSL, capture, and MCP contracts
backend/ Node-only storage and i18n shared by server and desktop
src/library/ ShaderLibrary — engine-agnostic shader domain logic
src/persistence/ ShaderRepository + SQLite / Postgres / legacy adapters
src/storage/ the legacy file store, now a read-only import source
desktop-api/ generated, typed IPC bridge contract
tools/
mcp/ standalone `@shader-studio/mcp` server
workspace/ checks, generators, smoke tests, and repo automation
Shaders, projects, presets, textures and thumbnails live in SQL: SQLite in
the Electron app (via Node's built-in node:sqlite, so there is no native module
to rebuild), PostgreSQL on the Web/Docker server (via Drizzle ORM's
node-postgres adapter). Both sit behind one
ShaderRepository contract, and all the domain logic — validation, id
generation, deriving fragment/vertex from the project, presets, import/export,
seeding — lives once in ShaderLibrary, above the contract. The web app knows
none of this: it still talks only to ShaderApi (HttpShaderApi over REST,
DesktopShaderApi over IPC), so SQLite stays in the Electron main process and the
PostgreSQL connection string never reaches the browser.
Every mutation of a shader and its dependents runs in a transaction. The schema
is versioned by ordered, deterministic migrations that run before any API/IPC is
exposed and fail loudly rather than auto-generate. Concurrent writes are caught
with a per-shader revision: a save may send the revision it read as
expectedRevision and get a 409 conflict instead of silently clobbering a newer
write (absent, it stays last-writer-wins). The old per-shader file library is no
longer the primary store but is kept as a read-only import source — imported once
on the desktop, or on demand via migrate-files under Docker, and never deleted.
The typed PostgreSQL model is in
libs/backend/src/persistence/postgres/schema.ts. Add future server relations
(users, sessions, memberships, invitations) there, and add the corresponding
ordered migration in postgres/migrations.ts. Migrations deliberately continue
to use the existing storage_metadata.schema_version ledger: deployed databases
already have that history, so introducing the ORM does not create a second
baseline or attempt to recreate live tables.
Each directory above is a pnpm workspace package with its own dependency manifest and runtime-specific scripts/configuration. The root package only orchestrates workspace commands and retains the desktop application metadata consumed by Electron Builder.
The store keeps three layers of state deliberately distinct:
- record — the shader as the server last gave it to us.
- draft — the editor buffers. The difference from
recordis what "unsaved changes" means, and whatSavesends. - params — the live uniform values. Turning a knob is not an unsaved edit to the source; it is a value you can capture as a preset.
Express serves the API and renders the app in the same process. During SSR the
app calls its own /api over a same-origin request (the absolute origin comes
from the incoming request; see app.config.server.ts).
The rendered state is handed to the browser through Angular's TransferState, so
the first client render is identical to the server's markup and hydration does
not throw the page away. The server deliberately opens the first shader rather
than the last one you had open — it cannot read your localStorage, and
rendering a different shader than the client would then hydrate is exactly the
mismatch the snapshot exists to prevent. The client switches to your remembered
shader once it takes over.
three.js, lil-gui and Monaco are all dynamically imported: none of them exist on the server (lil-gui injects a stylesheet at import time and would throw), and keeping them out of the initial bundle lets the shell paint first.
A candidate shader is compiled against an offscreen 1×1 render target before it is allowed anywhere near the screen. Only if the driver accepts it does the live material get swapped. If it does not, the previous shader keeps rendering and the driver's log comes back as diagnostics.
Line numbers in a driver's log count from the top of the source three.js assembled, which is not the source you typed — three prepends a prelude. The engine finds your source inside the full source and subtracts the offset, so a diagnostic lands on the line you are actually looking at.
Note
The live store is now SQL (see Storage). The per-shader directory
layout below is the legacy/import format: it is what the old file store
wrote, what the desktop app and migrate-files read to import into the
database, and the shape the examples ship in. The .shader.json bundle format
further down is unchanged and remains the interchange format for import/export.
One directory per shader. The directory name is the id — it is the primary key, which is why it is validated before it is ever joined onto a path.
data/
.seeded marker; stops examples coming back after you delete them
shaders/
poured-paint/
meta.json name, description, control schema, render settings
project.json the multi-pass project: passes, buffers, files,
channel wiring — the source of truth for a shader
fragment.glsl the Image pass's source — a mirror of project.json
vertex.glsl the project's vertex shader — also a mirror
presets.json { "presets": [ ... ] }
A shader is a project — an Image pass, an optional Common pass of shared code,
up to four buffers, and any number of plain source files a pass can #include.
project.json is that whole document; fragment.glsl/vertex.glsl are kept in
sync with it (the Image pass's source, and the project's vertex shader) so that
anything reading the old two-file shape still works. A shader that predates
project.json — or one whose copy of it is unreadable — reads as a single-pass
project built from its fragment.glsl/vertex.glsl, wired up exactly as the old
single-pass engine bound its four iChannels.
examples/shaders/ uses exactly this layout, and is copied into data/ the first
time you run an empty store. data/ is gitignored; examples/ is not.
Writes are atomic (temp file + rename), and mutations of a given shader are
serialized, so a half-written meta.json is never observable — project.json
included.
Ids are lowercase letters, digits and inner hyphens — no dots, no separators.
That rules out .., hidden files, and Windows' reserved device names. Renaming a
shader changes its display name only; the id, and therefore the path, is stable.
{
"name": "Hex Pulse",
"description": "A hexagonal lattice that answers back.",
"author": "Shader Studio",
"createdAt": "2026-07-12T00:00:00.000Z",
"updatedAt": "2026-07-12T00:00:00.000Z",
"controls": [/* see below */],
"render": {
"postProcessing": {
"enabled": true,
"effects": [
{
"type": "bloom",
"enabled": true,
"settings": { "strength": 0.55, "radius": 0.55, "threshold": 0.65 }
},
{
"type": "vignette",
"enabled": false,
"settings": { "intensity": 0.4, "softness": 0.5, "roundness": 1 }
}
]
}
}
}postProcessing.effects is ordered — that order is what the renderer applies
the chain in, not just how the rack displays it. Older render shapes,
including a bare { "bloom": {...} } (pre-chain) record, still import; see
Import and export.
{
"presets": [
{
"id": "solar-storm",
"name": "Solar Storm",
"createdAt": "2026-07-12T00:00:00.000Z",
"values": { "timeScale": 1.4, "colorLine": "#ff7a3d" },
"render": {
"postProcessing": {
"enabled": true,
"effects": [
{
"type": "bloom",
"enabled": true,
"settings": { "strength": 1.2, "radius": 0.4, "threshold": 0.7 }
}
]
}
}
}
]
}render is optional and usually absent: a preset without it restores the values
and leaves the shader's own post-processing chain alone. values are projected
onto the current control schema when applied — anything the schema no longer
declares is dropped, and a number outside a narrowed range is clamped rather
than discarded, so a preset outlives the edits made to the shader underneath it.
A shader declares its parameters; the GUI is generated from that declaration. No shader ever writes GUI code. Add a control in the Config tab and its knob appears immediately, bound to a uniform, without a reload.
The rule: a control keyed warpIntensity feeds uniform float u_warpIntensity.
The uniform is always the key prefixed with u_.
type |
GLSL uniform | Widget | Required fields |
|---|---|---|---|
number |
float u_<key> |
slider | default, min, max, (step) |
boolean |
bool u_<key> |
checkbox | default |
color |
vec3 u_<key> |
color picker | default as #rrggbb |
select |
float u_<key> |
dropdown | default, options (label → number) |
label (GUI text) and folder (grouping) are optional on all of them.
[
{
"key": "timeScale",
"type": "number",
"label": "Time Scale",
"folder": "Motion",
"default": 0.5,
"min": 0,
"max": 2
},
{ "key": "mirror", "type": "boolean", "label": "Mirror", "folder": "Flight", "default": false },
{
"key": "colorLine",
"type": "color",
"label": "Lattice",
"folder": "Palette",
"default": "#54e0ff"
},
{
"key": "paletteMode",
"type": "select",
"label": "Palette Mode",
"folder": "Palette",
"default": 1,
"options": { "Classic": 0, "Neon": 1, "Ember": 2 }
}
]Colors are passed to the shader as display-space sRGB vec3 (three.js's
colour management is off), which is what you almost certainly want when you pick
#54e0ff and expect to see #54e0ff.
Provided to every shader whether it declares them or not. Declare the ones you use.
uniform vec2 iResolution; // drawing-buffer size, pixels
uniform float iTime; // seconds; pausable, and it does not
// fast-forward when you resume
uniform vec4 iMouse; // xy: pointer in pixels, z: 1 while pressed
uniform vec2 iMouseVel; // pointer velocity, pixels/second
uniform vec3 u_clickData[__MAX_WAVES__]; // per click: xy pixels, z = birth time
// (z <= 0 means the slot is unused)__MAX_WAVES__ is substituted with the ripple-slot count (24) before compiling,
so use it for the array size and any loop bound. examples/shaders/hex-pulse is
the worked example.
The vertex shader is an ordinary three.js ShaderMaterial vertex shader, and gets
position, uv, projectionMatrix and modelViewMatrix. The default passes
vUv through, which is all a full-screen shader needs.
Changing the controls re-projects every preset onto the new schema: a value for a control you deleted is dropped, and a value now out of range is clamped rather than discarded — a preset saved before you narrowed a slider is still worth keeping. A schema that does not parse is reported in the diagnostics strip, blocks saving, and leaves the working GUI alone.
One documented format, used for both a single shader and a whole collection. Everything needed to reproduce a shader elsewhere is in it: the whole project (passes, buffers, files, channel wiring), schema, render settings and presets.
GET /api/shaders/:id/export →
{
"format": "shader-studio/v3",
"kind": "shader",
"exportedAt": "2026-07-12T12:00:00.000Z",
"shader": {
"id": "hex-pulse",
"name": "Hex Pulse",
"description": "...",
"author": "Shader Studio",
"controls": [/* the schema */],
"render": { "postProcessing": { "enabled": true, "effects": [/* see meta.json above */] } },
"fragment": "precision highp float; ...",
"vertex": "varying vec2 vUv; ...",
"project": {
"version": 1,
"vertex": "varying vec2 vUv; ...",
"passes": [/* Image, an optional Common, up to four buffers */],
"files": [/* plain #include-able source files */]
},
"presets": [
{
"id": "circuit",
"name": "Circuit",
"createdAt": "...",
"values": { "timeScale": 0.5, "colorPulse": "#5ef2ff" }
}
]
}
}fragment/vertex stay in the payload as mirrors of project (the Image pass's
source and the project's vertex shader), for anything that still reads the old
two-string shape.
GET /api/export returns the same thing with "kind": "collection" and a
"shaders": [ ... ] array of those payloads. Import accepts either kind.
shader-studio/v1 and v2 bundles still import. v1 predates project
entirely; on import, a project is synthesized from fragment/vertex the same
way a shader that predates project.json on disk does — one Image pass, an
empty Common pass, and the four iChannels bound exactly as the old
single-pass engine bound them. v2 has project but predates the
post-processing chain: its bare { "bloom": {...} } render is migrated into
a single-effect chain the same way a render missing entirely is filled in.
Import modes. rename (the default) never destroys anything: a shader whose id
already exists is given a fresh, suffixed one. overwrite replaces the shader
holding that id, which is what makes an export → import round trip idempotent. The
UI asks before it overwrites.
Bundles are validated on the way in, and a bundle with a broken id but a usable name is recovered rather than rejected — hand-edited files are expected.
Import & export → Export Wallpaper Engine HTML… downloads one standalone
.html file. The desktop app creates a dedicated project folder containing
index.html; after a web download, put the HTML in its own folder yourself. Then
drag index.html onto Wallpaper Engine's Create Wallpaper target. This matters
because Wallpaper Engine copies the selected file's whole directory. The document
contains its WebGL runtime, composed Image and buffer passes, current parameter
values, and base64-embedded channel textures; it has no CDN or network dependency.
Time, resolution, pointer velocity, click ripples, texture sampling, fixed/scaled buffer resolutions, and multipass feedback are supported. The post-processing chain (Bloom, Vignette) is not currently reproduced by the standalone runtime, and the export reports one generic compatibility warning whenever the source shader has any post-processing effect enabled.
Shader controls are exported with stable Wallpaper Engine property keys. Their
current values work immediately. To expose a control in Wallpaper Engine's user
interface, add a matching property under Edit → Change Project settings:
Slider for number/select, Checkbox for boolean, or Color for color. The generated
HTML contains the key mapping in window.__SHADER_STUDIO_WALLPAPER__.controls.
All errors are { "error": { "code", "message", "details"? } }.
400 invalid · 404 not found · 409 conflict.
| Method | Route | Purpose |
|---|---|---|
GET |
/api/shaders |
List (summaries) |
POST |
/api/shaders |
Create from the template |
GET |
/api/shaders/:id |
Read one, in full |
PUT |
/api/shaders/:id |
Partial update (name, source, controls, render) |
DELETE |
/api/shaders/:id |
Delete |
POST |
/api/shaders/:id/duplicate |
Copy, presets included |
GET |
/api/shaders/:id/presets |
List presets |
POST |
/api/shaders/:id/presets |
Save; reusing a name overwrites |
DELETE |
/api/shaders/:id/presets/:presetId |
Delete a preset |
GET |
/api/shaders/:id/export |
Export one |
GET |
/api/export |
Export everything |
POST |
/api/import |
Import a bundle |
Preset values are sanitized against the shader's schema on save, so a preset can never carry a value for a control that does not exist.
tools/mcp publishes @shader-studio/mcp
on npm: an MCP server that lets Claude Code,
Codex, Cursor, and other MCP clients drive a locally-open Shader Studio
tab — list shaders, edit GLSL live, tune uniforms, apply presets, and
capture screenshots — through the same authenticated localhost WebSocket
bridge the app already exposes. It speaks MCP over stdio to the client and
makes no outbound network calls of its own.
npx -y @shader-studio/mcp
# or, wired into Claude Code:
claude mcp add shader-studio -- npx -y @shader-studio/mcpPairing the browser tab, the security model, environment variables, and
per-client config (Codex, Cursor, Windows) are documented in
tools/mcp/README.md.
| Shader | Shows |
|---|---|
| Poured Paint | 43 controls. Domain-warped fbm, layers quantized into pooled bands with hard contour lips, wet specular relief, OKLab palette ramp, click ripples with chromatic dispersion, and momentum-tunable pointer smearing. Bloom on. |
| Aurora Veil | Curtains draped by warping the x axis with slow noise, over a twinkling star field. |
| Hex Pulse | u_clickData: click and a wavefront crosses the lattice. Hover lights the cells under the cursor. |
| Warp Tunnel | A tunnel from 1/r — no raymarching. Demonstrates select and boolean controls. |
Poured Paint and its five presets are carried over from the project this app grew out of, and are the reference for what the format can express.
All shaders distributed in examples/shaders are original Shader Studio examples
and are licensed under Apache-2.0 with the rest of the project.
pnpm test~600 tests, focused where a bug would actually cost you something:
-
backend/library/conformance.ts— one behavioural suite the storage engines must both pass, run against SQLite (a temp file) and, whenSHADER_TEST_DATABASE_URLis set, PostgreSQL. It is where "the logic is not duplicated between engines" is enforced: init/migrations, CRUD, sorted listing, cascade delete, duplicate, presets, textures on all four channels, thumbnails, import/export (v1 and v2, rename/overwrite), multipass projects, idempotent seeding, transaction rollback, revision conflicts, legacy migration, corrupt JSON and missing assets, and persistence across a restart. -
server/api/router.spec.ts— the REST layer over a real SQLite-backed library: status codes, the{ error: { code, message } }envelope, a409on a staleexpectedRevision, and texture upload/serve/clear. -
libs/shared/src/validate/validate.spec.ts— ids (every traversal and reserved-name case), the control schema, preset sanitization and clamping, and bundle round-trips — including ashader-studio/v1bundle (noprojectfield) synthesizing one viamigrateLegacyProject, and a malformedprojectbeing sanitized rather than failing the import. -
backend/storage/shader-storage.spec.ts— the legacy file store (now the read-only import source) against a real temp directory, not a mocked fs, because the whole point of that layer is what it does to the filesystem and a mock would let a traversal bug through. Covers CRUD, path traversal, atomic update, preset lifecycle, import modes, and seeding, plus aprojectdescribe block:project.jsonround-trips through a fresh read and a freshShaderStorage, a shader with none reads as a legacy-migrated project, duplicate and export/import carry buffers/Common/ files/wiring, and a legacyfragment/vertex-only patch reconciles onto the current project instead of replacing it. -
core/shader-store.spec.ts— the client-side store: selection, dirty tracking, save/discard paths, and preset application. -
core/project-workspace.spec.ts— a shader record becoming a project (legacy and otherwise), unsaved-changes detection, and the pre-upgradelocalStorage→ server migration: a matching baseline pushes and clears, a stale one reconciles (record wins for the Image pass, buffers survive) before pushing, and a failed push leaves the local copy for the next load to retry. -
ui/document-status.spec.ts— the saved/dirty/saving indicator, including the debounce timing. -
core/draft-recovery.spec.tsandcore/panel-prefs.spec.ts— what survives a reload: unsaved drafts, and panel sizes. -
rendering/glsl-diagnostics.spec.ts— both driver log dialects, and the prelude offset that makes a line number point at the right line. -
rendering/glsl-export.spec.ts— the generated uniform prelude, and above all what it declines to generate: a declaration the source already carries would be a redeclaration error wherever the copy is pasted. -
editor/glsl-format.spec.ts— indentation depth, braces that are only part of a comment, and idempotence. -
rendering/shadertoy-import.spec.ts— the Shadertoy source rewrite.
- One active shader. The browser list displays saved thumbnails, but it does not continuously render every shader in the library. The selected shader and the optional output window are the live render surfaces.
- No auth, no multi-user. The web API uses PostgreSQL or SQLite and assumes a single trusted user. It is a studio, not a public service; expose it only behind an authenticated proxy or private access layer.
- Bloom and Vignette are the only built-in post effects, at most one instance of each. Tone mapping, color grading, chromatic aberration, FXAA and LUTs are not implemented. A preset can optionally capture the complete render settings, including the chain.
- Monaco's stylesheet is global (~88 kB gzipped), not lazy: the CSS its ESM modules import lands in a chunk nothing links, so the editor comes out structurally unstyled if you rely on it. The editor's code is still lazy.
- Shader compilation uses a real draw call to a 1×1 target to force a compile. It is the only reliable way to make three.js compile eagerly, but it does mean a recompile costs one hidden frame.
- GLSL diagnostics come from the driver, so their exact wording varies by browser and GPU.
Copyright 2026 Adel Terki.
Shader Studio is licensed under the Apache License 2.0. Third-party software included by the project remains under its respective license; see THIRD_PARTY_NOTICES.md.
