← Shader Desk / API
Token panel

Drive Shader Desk from your own code

Everything the web app does is one HTTP API away. Base URL: https://api.skillsafe.ai/v1/app-api, scoped to this app by the token you send. Every response is wrapped in an envelope: success is {"ok":true,"data":{…}}, failure is {"ok":false,"error":{"code":"…","message":"…","details":{…}}}.

Read the envelope, not the HTTP status, for anything the app itself decided: a job that ran and then failed still arrives inside {"ok":true,"data":{…}} with a terminal status. The status line matters for the transport errors — 401, 402, 403, 429.

The request body for /run, /run-stream and /estimate is the input object itself. There is no input wrapper and no X-App-Slug header — the slug is carried by the token. This is the single most common way to waste a credit here, so it is spelled out again in the input contract.

Error codes you will actually meet

CodeHTTPWhat it meansWhat to do
UNAUTHORIZED401Missing, malformed or expired token.Mint a new one from the token panel.
FORBIDDEN403The token belongs to another app, or it is a guest token on a metered route.Use an account token minted for shader-desk. Guests cannot run.
INSUFFICIENT_CREDITS402Balance is under min_credits for this run.Compare /me against /estimate before running.
VALIDATION_ERROR400The input object is the wrong shape.Check error.details; task and shader are required.
NOT_FOUND404No such route, or no such job_id for this subject.Check the path and that the job was created by this token.
RATE_LIMITED429Too many requests.Back off; do not tight-loop a poll.
INTERNAL500Something broke on our side.Retry with the same idempotency key. It will not double-bill.

A guest token can look, not run. POST /v1/app-api/guest mints an anonymous subject that can call /me and /estimate — enough to price a lane and show a caller what the contract is. A /run or /run-stream is metered, and this app does not sponsor guest usage, so a guest run comes back 403 FORBIDDEN. Use a personal token from the token panel for anything that produces a review. The in-browser reader — the parse, the flags and the real compile — is free and needs no token at all.

The task field comes first

This app has four lanes over one work object: a single GLSL shader source. task selects the lane and is the field to get right before any other — it decides the checks you get back, which lane block is present, and the price. An unrecognised value is not an error: the model picks the closest lane, names that choice in overview, and sets lane to what it picked. It never blends two lanes' contracts into one object. So always read lane back rather than assuming the one you asked for.

taskLaneAnswersLane blockSource skill
diagnoseDiagnoseWhy will this shader not compile, and what is the patched source?repair{}@sickn33/shader-programming-glsl
explainExplainWhat is the maths doing, line by line, and which numbers can I turn?walkthrough{}@bbeierle12/shader-fundamentals + @minimax-ai/shader-dev
optimizeOptimizeWhat does this cost per pixel, and what can be cut without changing the image?budget{}@minimax-ai/shader-dev
portPortWhat does it take to run this in a three.js ShaderMaterial?port{}@cloudai-x/threejs-shaders

One worked example per lane

Each request body below is the whole body — copy the shape, not just the fields. The shader value is elided here; in a real call it is the whole GLSL source as one JSON string, newlines and all. Only task and shader are required; every other field may be omitted or sent empty.

task: "diagnose" — why will it not compile?

Request body:

{"task":"diagnose","shader":"#version 300 es\nvarying vec2 vUv;\nuniform sampler2D uTexture;\n…\n    gl_FragColor = vec4(col, 1.0);\n}","vertex":"","target":"webgl2","symptom":"The compiler gives me four errors I do not understand and the material renders solid black.","carryover":"","facts":"BROWSER-MEASURED FACTS …\n- REAL COMPILE in this browser (WebGL2): FAILED with 4 diagnostic(s)\n    line 5: 'varying' : syntax error\n\nFLAGS RAISED (5):\n  GX-008 [high] line 5 — varying / attribute under #version 300 es: …\n  GX-010 [high] line 24 — texture2D() under #version 300 es: …\n  GX-007 [high] line 38 — gl_FragColor under #version 300 es: …"}

The reply is the shared envelope plus this lane's block:

{ …envelope…, "verdict": "fixable",
  "repair": {"ready": true,
    "fixes": [{"line": 5, "problem": "varying was removed in GLSL ES 3.00",
               "change": "in vec2 vUv;", "confidence": "certain"},
              {"line": 24, "problem": "texture2D() is the 1.00 spelling",
               "change": "vec4 src = texture(uTexture, uv);", "confidence": "certain"},
              {"line": 20, "problem": "1/3 is integer division and evaluates to 0",
               "change": "float slice = 1.0 / 3.0;", "confidence": "likely"}],
    "patched_source": "#version 300 es\nprecision highp float;\nin vec2 vUv;\n…",
    "still_open": ["uUnusedGain is declared and never read; deleting it is a host-side decision"],
    "note": "Only the language-forced fixes are in patched_source; slice is unused, so its value is a guess."} }

task: "explain" — what is the maths doing?

Request body:

{"task":"explain","shader":"float sdSphere(vec3 p, float r) {\n    return length(p) - r;\n}\n…\nvoid mainImage( out vec4 fragColor, in vec2 fragCoord ) {\n…\n}","vertex":"","target":"shadertoy","symptom":"I can run it but I do not understand the loop or the normal function.","carryover":"","facts":"BROWSER-MEASURED FACTS …\n- interface: 0 uniforms (0 samplers), plus host-injected iTime/iResolution\n- per-pixel cost: 0 texture fetches, 14 transcendental calls, 2 branches, 1 loops …\n\nFLAGS RAISED (3):\n  GX-026 [info] line - — Shadertoy uniforms are supplied by the host, not by this source: …\n  GX-027 [info] line - — mainImage() needs a main() wrapper outside Shadertoy: …\n  GX-014 [medium] line 37 — A 256-iteration loop runs per pixel: …"}

The reply is the shared envelope plus this lane's block:

{ …envelope…, "verdict": "dense",
  "walkthrough": {"technique": "sphere-traced SDF raymarching with a wobbling ball over a slab",
    "one_liner": "It shoots one ray per pixel into a distance field and shades whatever it hits.",
    "sections": [{"lines": "5-11", "title": "Primitive distance functions",
                  "what": "sdSphere and sdBox return the signed distance to each shape",
                  "math": "length(p) - r is negative inside the sphere and zero on its surface"},
                 {"lines": "31-40", "title": "The march",
                  "what": "steps t forward by the distance to the nearest surface, 256 times",
                  "math": "the distance field guarantees the step cannot overshoot"}],
    "uniforms": [{"name": "iTime", "type": "float", "means": "seconds since start",
                  "typical": "0.0 upwards, host-driven"}],
    "knobs": [{"name": "256", "line": 37, "effect": "how far and how finely the ray marches",
               "try": "96 with an early break"}],
    "note": "iChannel inputs are not used, so nothing here depends on a texture."} }

task: "optimize" — what does it cost per pixel?

Request body:

{"task":"optimize","shader":"float sdSphere(vec3 p, float r) {\n    return length(p) - r;\n}\n…","vertex":"","target":"shadertoy","symptom":"It drops to 20fps in fullscreen on my laptop.","carryover":"Previous lane: explain. Verdict: dense. The 256-step march is the whole cost.","facts":"BROWSER-MEASURED FACTS …\n- per-pixel cost: 0 texture fetches, 14 transcendental calls, 2 branches, 1 loops (deepest nesting 1, largest static bound 256), tier heavy\n- transcendental breakdown: sin x1, cos x1, pow x3, normalize x4, length x5\n\nFLAGS RAISED (3):\n  GX-014 [medium] line 37 — A 256-iteration loop runs per pixel: …\n  GX-020 [low] line 52 — pow() with a small integer exponent: …\n  GX-021 [low] line 61 — length() compared against a threshold: …"}

The reply is the shared envelope plus this lane's block:

{ …envelope…, "verdict": "expensive",
  "budget": {"cost_now": "256 unconditional march steps, each calling map() once; 14 transcendental calls; 0 texture fetches",
    "cost_after": "at most 96 steps with an early break; 11 transcendental calls; 0 texture fetches",
    "reduction_pct": 62,
    "basis": "loop bound 256 to 96 with a hit break at the typical 40 steps, pow x3 to x1, length x5 to x4; fetches unchanged at 0",
    "visual_change": "subtle",
    "wins": [{"action": "Break out of the march when the ray has converged", "where": "line 37, the for loop",
              "saves": "roughly 200 of the 256 map() calls on a hit pixel", "effort": "low", "visual": "none",
              "before": "for (int i = 0; i < 256; i++) {\n    vec3 p = ro + rd * t;\n    float d = map(p);\n    t += d;\n}",
              "after": "for (int i = 0; i < 96; i++) {\n    vec3 p = ro + rd * t;\n    float d = map(p);\n    if (d < 0.001 || t > 20.0) break;\n    t += d;\n}"}],
    "note": "calcNormal's six map() calls were left alone: they run once per hit pixel, not once per step."} }

task: "port" — move it to a three.js ShaderMaterial

Request body:

{"task":"port","shader":"float sdSphere(vec3 p, float r) {\n    return length(p) - r;\n}\n…\nvoid mainImage( out vec4 fragColor, in vec2 fragCoord ) {\n…\n}","vertex":"","target":"threejs","symptom":"It runs perfectly on Shadertoy. I pasted it into my own page and got a black canvas.","carryover":"Previous lane: diagnose. Verdict: fixable.","facts":"BROWSER-MEASURED FACTS …\n- dialect: GLSL ES 1.00 (Shadertoy); entry point mainImage()\n\nFLAGS RAISED (2):\n  GX-026 [info] line - — Shadertoy uniforms are supplied by the host, not by this source: …\n  GX-027 [info] line - — mainImage() needs a main() wrapper outside Shadertoy: …"}

The reply is the shared envelope plus this lane's block:

{ …envelope…, "verdict": "adapted",
  "port": {"target": "three.js ShaderMaterial (WebGL2 / GLSL ES 3.00)",
    "fragment_shader": "precision highp float;\nuniform float iTime;\nuniform vec2 iResolution;\nin vec2 vUv;\nout vec4 fragColor;\n…\nvoid main() { mainImage(fragColor, vUv * iResolution); }",
    "vertex_shader": "out vec2 vUv;\nvoid main() {\n  vUv = uv;\n  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);\n}",
    "host_glue": "const material = new THREE.ShaderMaterial({\n  uniforms: { iTime: { value: 0 }, iResolution: { value: new THREE.Vector2(1, 1) } },\n  vertexShader, fragmentShader, glslVersion: THREE.GLSL3\n});\n…",
    "uniform_map": [{"from": "iTime", "to": "iTime", "type": "float", "host_value": "clock.getElapsedTime()"},
                    {"from": "iResolution", "to": "iResolution", "type": "vec2",
                     "host_value": "renderer.getSize() in device-independent pixels"}],
    "caveats": ["iResolution is the canvas on Shadertoy but the mesh's own footprint here, so the aspect correction changes if the quad is not full-screen",
                "iTime starts at zero on page load rather than at play, so any animation phased against it starts elsewhere"],
    "note": "The rename was mechanical; the fragCoord-to-vUv substitution is the judgement call, and it relies on three.js injecting uv, position, projectionMatrix and modelViewMatrix."} }

The input contract

These are the exact fields the web app submits — taken from its run path, not from intent.

{
  "task": "diagnose",
  "shader": "<the GLSL source, as a STRING — required>",
  "vertex": "<the paired vertex shader, or \"\">",
  "target": "webgl2",
  "symptom": "free text: what the user sees, wants, or cannot follow",
  "carryover": "optional: the previous lane's digest",
  "facts": "<the browser-measured facts block, with its GX-nnn flags>"
}

That object is the request body. Do not wrap it. A body of {"input":{"task":"diagnose",…}} is accepted, returns 200, creates a job and bills it — and the model never sees task or shader, because the fields it was told to read are one level deeper than it looks. The reply comes back as a review of nothing. There is no X-App-Slug header either; the token names the app.

FieldTypeRequiredNotes
taskstringyesOne of exactly four lane ids: diagnose, explain, optimize, port. Document it first because it decides everything else.
shaderstringyesThe shader source as one string, newlines and all. This is the work object; every lane operates on this one artifact. Max about 40 000 characters.
vertexstringnoThe vertex shader that pairs with it, when there is one. Send "" for a fragment-only paste — the interpolant-wiring checks then come back n/a with that as the reason.
targetstringnoshadertoy, webgl1, webgl2, threejs or "". Where it has to run. The port lane ports to this; empty means a three.js ShaderMaterial.
symptomstringnoFree text from the user: what they see, what they want, what confuses them. The single cheapest way to aim a lane.
carryoverstringnoA digest of the previous lane's conclusion. The model builds on it, acknowledges it, and does not re-litigate it.
factsstringnoThe deterministic reader's output — a multi-line block, not an array. Optional, strongly recommended — see below.

The four lane ids

An unrecognised task is answered by the closest lane rather than rejected: a symptom about errors gets diagnose, one about frame rate gets optimize, one that names another engine gets port, anything else gets explain. The lane it chose is named in lane and explained in overview. It will not blend two lanes' contracts — you always get one lane block and only that lane's checks.

Clipping shader yourself

shader is capped at about 40 000 characters. Over that, the app clips on whole-line boundaries, keeps the head and the tail, and writes // ---- Shader Desk cut N lines here (lines A-B of T) ---- in-band where the cut happened, so the model knows the source it is reading is incomplete and can say so. A caller driving this API should do the same rather than a bare truncation. A shader's identity lives at both ends: the #version line, the precision statement and the uniform block are at the top, and the entry point — main() or mainImage() — is at the bottom. Cut only the tail and you delete the entry point, which reads to any lane as a missing main() rather than as your cut.

Line numbers are the other reason to mark the cut. Every lane quotes 1-based line numbers against shader exactly as you sent it, so the marker is what lets a finding on line 180 be understood as line 180 of the original rather than line 180 of your slice.

facts: give the model ground truth

facts is one multi-line string, produced by a parser rather than a model. The web app fills it from the in-browser reader: the detected dialect and stage, the declaration inventory, counted texture fetches, transcendental calls, branches and loop bounds, the portability delta in both directions, the driver's own compile log verbatim where WebGL is available, and then a FLAGS RAISED list where every flag carries a GX-nnn id.

The model is instructed to treat every line of it as true. A compiler diagnostic in facts outranks the model's reading of the source: if the driver says line 14 has an undeclared identifier, line 14 has an undeclared identifier. Counted numbers are quoted, never paraphrased — six texture fetches is written as six, not as "a handful". Every GX-nnn flag you send comes back exactly once in coverage_check, in the order you sent it — with handled: true when the output addresses it, or handled: false and an honest reason when it does not. That is the contract, and it is the cheapest way to tell a real review from a plausible one. The model may not invent a GX id that was not in facts.

If your parser cannot read the source, say so in facts in those words — the lane then states plainly in overview that there were no measured facts and judges the source on its own, which is a better answer than one anchored to nothing.

The output contract

The model replies with one JSON object as the job's output.output string. The outer shape is identical in every lane, so one parser handles all four:

{
  "lane": "diagnose",
  "shader_name": "Sphere-traced SDF ball and slab",
  "verdict": "<one of the lane's verdict tokens>",
  "headline": "one line, under 120 characters, the single most important thing",
  "overview": "2 to 5 sentences: what this shader is, and what this lane concluded",
  "checks": [{"name": "<exactly the lane's check name, in the lane's order>",
              "status": "pass | warn | fail | n/a",
              "note": "one sentence"}],
  "findings": [{"id": "SD-001",
                "severity": "critical | high | medium | low",
                "title": "short, specific",
                "where": "line 24, or map(), or the march loop",
                "why": "why it matters",
                "fix": "what to do",
                "snippet": "GLSL you could paste, or \"\""}],
  "coverage_check": [{"flag": "GX-010", "handled": true, "note": "how this lane addressed it"}],
  "next_steps": ["one short imperative sentence, in the order they should be done"],
  "<lane block>": "<exactly one, see the table below>"
}

findings ids run SD-001, SD-002, … in the order listed, most severe first. Severity means something specific here: critical is it does not compile or does not run; high is it runs but is wrong or unsafe on some hardware; medium is it works and costs more than it should, or will break on a move; low is style, clarity, small waste. Zero findings is a legitimate answer and arrives as [], never as a finding that says there are none.

checks carries every check name the lane lists, in the lane's order, even when the answer is n/a — a short table is a bug, not brevity. A fail on any check has a matching entry in findings, and a finding of severity critical or high has a matching fail or warn check: the two lists are two views of one judgement and they may not disagree. That invariant is worth asserting in your client, because a reply where they diverge is a reply to distrust.

Snippet fields are GLSL, not prose. snippet, change, before, after, patched_source, fragment_shader and vertex_shader all contain code you could paste, with no fences and no line-number prefixes inside the string.

The lane block: exactly one, chosen by task

taskverdict tokensKeyShape
diagnose compiles / fixable / broken repair Object: {ready (bool), fixes[{line, problem, change, confidence}], patched_source (string), still_open[], note}. confidence is certain, likely or guess; only certain fixes are applied to patched_source, and ready is true only when that source compiles as written.
explain clear / dense / obscure walkthrough Object: {technique, one_liner, sections[{lines, title, what, math}], uniforms[{name, type, means, typical}], knobs[{name, line, effect, try}], note}. sections cover the source top to bottom without overlapping, every lines range is inside the source, and knobs holds three to eight numbers actually worth turning.
optimize lean / tunable / expensive budget Object: {cost_now, cost_after, reduction_pct (integer 0-95), basis, visual_change, wins[{action, where, saves, effort, visual, before, after}], note}. cost_now agrees with the counted numbers in facts exactly; basis shows the arithmetic so you can disagree with it; visual_change and each win's visual are none, subtle or visible.
port direct / adapted / blocked port Object: {target, fragment_shader, vertex_shader, host_glue, uniform_map[{from, to, type, host_value}], caveats[], note}. The two shaders are complete and self-consistent — the interpolants the fragment stage reads are the ones the vertex stage writes — and host_glue is real JavaScript naming the same uniforms as uniform_map.

Route on lane, then read that one key. The other three are absent, not null — a client that reaches for budget on a diagnose reply is reading a lane it did not ask for. Note the one collision worth knowing about: the port lane's block is also called port, and inside it port.target echoes where the shader was ported to, which is not necessarily the string you sent as target.

The checks each lane returns

Ten per lane, always in this order. Knowing the list up front means you can build the table before the reply lands and fill it in as the stream arrives.

Lanechecks[].name, in order
diagnosecompile errors, version and dialect, precision qualifiers, declaration completeness, type correctness, uniform and interpolant wiring, entry point shape, loop and branch legality, sampler usage, undefined behaviour
explainentry point, coordinate space, uniform contract, core technique, helper functions, colour pipeline, animation, control flow, constants and magic numbers, output range
optimizetexture fetches, transcendental calls, loop bounds, branch divergence, precision choice, redundant computation, normalize and length, step budget, dependent texture reads, early exit
portdialect translation, entry point rewrite, uniform mapping, interpolant wiring, texture sampling calls, output variable, precision declarations, built-in substitutions, host glue completeness, behavioural differences

The vocabulary is fixed too. pass — the shader is fine on this axis. warn — it works but there is something the author should know. fail — it is wrong on this axis and a finding says so. n/a — the axis does not apply to this shader, with the note saying why. A fragment-only paste, for instance, gets n/a on interpolant wiring rather than a guess.

Step by step

1. Get a token

Every call needs Authorization: Bearer <token>. Two ways to get one:

Keep the token out of your source. Read it from the environment at runtime and never commit it.

2. A tiny client helper

Two things repeat on every call: the Authorization header, and unwrapping data out of the envelope. Write them once. Everything after this step uses the call helper below, and every one of these raises on ok: false instead of returning a half-empty object.

3. Check the session and the balance

GET /me tells you which subject the token belongs to and how many credits it holds. Read two fields: subject_type (user or guest) and credits. Do this before a run — a guest here means the run will come back 403 no matter how healthy the balance looks, and comparing credits against the estimate's min_credits is how you avoid a 402 after submitting.

4. Estimate the lane — free, no job

POST /estimate takes the same body as /run — the input object, unwrapped — costs nothing and creates no job. Assert three things on the way back, because they are the contract this page is written against:

It also returns hold_credits, min_credits and sponsor_enabled (false here — that is why guests cannot run).

hold_credits is a reservation, not a price. It is the ceiling the platform sets aside while the job runs, sized for the worst case of that lane's output cap. The charged_credits you see on the settled job is usually far lower — a reply that comes in short is billed short. Budget against hold_credits so a run is never rejected mid-flight; report against charged_credits.

Re-estimate on every lane change. The hold differs per lane because the prompt sections and output caps differ, and here the spread is wide: port returns two complete shaders plus the host glue, and diagnose returns the whole patched source, so both carry far higher ceilings than explain or optimize, which return prose and fragments. An estimate for explain does not price port. The web app re-estimates on every lane switch for exactly this reason.

5. Run it, then poll the job

POST /run takes the input object as the body and returns {"job_id": "job_…", "status": "queued"} immediately. Poll GET /jobs/{job_id} until status is terminal — succeeded, failed or cancelled — no faster than once a second, and back off on a 429. The reply is the string at job.output.output; parse it as JSON.

Always send an Idempotency-Key header, and derive it from a hash of (task, shader, attempt). The lane belongs in the key because two lanes over the same shader are two distinct runs and must not collide; the shader belongs in it because the same lane over an edited source is a new run — and editing the source is exactly what this app encourages between lanes; the attempt counter belongs in it because a deliberate re-run of an identical input is a second answer you are choosing to pay for.

A retry must reuse the same key. A network timeout, a dropped connection, a 500 — none of those tell you whether the job was created. Replaying the request with the same key returns the original job instead of starting a second one. Minting a fresh key on retry is how you get billed twice for one run, and nothing downstream will tell you it happened: you will simply have two jobs and one answer you wanted.

6. Or stream it

POST /run-stream is the same call, the same body and the same Idempotency-Key, delivered as server-sent events. Each line of interest starts with data: and carries one JSON event with a type:

The web app uses this route so its progress card can advance as sections arrive. Streaming is worth more here than on most apps: the diagnose and port lanes end with a whole shader in a string field, so the last third of the reply is often the longest part of the wait, and the verdict, the checks and the fix list are all readable before it lands. If the stream dies mid-flight, keep what arrived — see step 7 for closing a truncated buffer rather than throwing the run away.

7. Parse the result

Four moves, in this order, whichever route you took:

  1. Get the JSON object out of the reply. The model is instructed to emit one object and nothing else — first character {, last character } — so a strict parse of output.output normally works. Be tolerant anyway: slice from the first { to the last } before parsing, so a stray fence or a leading newline is not an outage.
  2. Route on lane, not on what you asked for. An unrecognised task is answered by the nearest lane, and that lane is what lane says.
  3. Read that lane's block and nothing else. repair, walkthrough, budget, port — exactly one is present.
  4. Handle a truncated reply. When the job carries "truncated": true, or when a stream died, close the buffer at the last complete structure and render what parsed. headline, overview and the first checks arrive early and are worth showing; throwing the whole run away because patched_source never landed wastes a credit you have already spent — and the fix list above it is usually enough to patch the shader by hand.

One more assertion worth writing once: every GX-nnn id you put in facts should appear exactly once in coverage_check. A missing id, a duplicate, or an id you never sent are all signals to distrust the reply rather than render it.

What the free reader gives you

Before any lane is run, the web app reads the shader in the browser. That reader is the whole of what this app does for free: no account, no token, no job, nothing uploaded. It detects the dialect and the stage, inventories every uniform, attribute, varying, in, out, const, struct and function with its line and whether it is ever read, counts texture fetches, transcendental calls, branches and loop bounds, works out what moving to GLSL ES 3.00 or back to 1.00 would cost in changes — and then, where WebGL is available, hands the source to the machine's own GLSL compiler and reports the driver's log verbatim, line numbers corrected for the wrapper it had to add.

Three things come out of it, and all three are free:

If you drive this API yourself, produce your own equivalent facts and send them. The lanes are written to reconcile ground truth, not to rediscover it: counted numbers that arrive in facts are quoted verbatim, and every GX-nnn you send comes back once in coverage_check. Without them the reply is still a review, but nothing anchors it — there is no list of things the model was obliged to answer for, so a plausible answer and a correct one look the same from outside. A compile you already trust plus facts is the cheapest quality gain available on this API, and if you are calling from a browser you can get the real one for nothing.

The reader is deterministic and the lane is not. Keep the split: dialect detection, declaration inventory, counts, loop bounds and the compiler log belong to your parser; judgement, patches, explanation, ranking and the port belong to the lane.

Rate limits and good manners

Attribution

Shader Desk is a derived work built on @sickn33/shader-programming-glsl (the diagnose lane), @bbeierle12/shader-fundamentals and @minimax-ai/shader-dev (the explain lane), @minimax-ai/shader-dev (the optimize lane) and @cloudai-x/threejs-shaders (the port lane). GLSL, WebGL and OpenGL ES are specifications of the Khronos Group; Shadertoy and three.js are the property of their respective owners. This app is not affiliated with or endorsed by any of them.