FastShaders is a visual 3D graphics editor for web-based virtual reality content, built to be an accessible and convenient shader programming experience for both beginners and experienced content creators.
Main features:
- Nodes and code in sync: build a shader as a node graph or write it as TSL (Three.js Shading Language). The code follows every graph edit, and Apply turns edited code back into nodes.
- Performance cost while you build: each node's GPU cost is measured on the target VR headset, and the editor shows the shader's total against that device's budget.
- Visual-effect presets to start from or take apart, plus reconstructed TSL textures and distance-field (SDF) nodes. Switch the Textures and SDF tabs on from the toolbar's right-click menu.
- Your own material: images, 3D models (
.obj,.glb,.gltf), Gaussian splats, colour palettes, microphone or desktop audio, and CSV data. - Export for the web: a
.jsmodule for A-Frame or plain Three.js, a.zipwith its images and model, or one.glbthat holds the model, its textures and the shader. - Browser or desktop: runs in the browser, or offline as a desktop app for Windows and macOS. The interface is in English and Latvian.
Every export carries the editor project inside it, so dragging an exported file back onto FastShaders reopens its graph. A dropped .js or .zip asks first: Open replaces the current graph, Add places the dropped shader beside it as one group. A .glb comes back through its import dialog's Restore (see Single-GLB export).
- Podest — a standalone full-screen shader viewer built for
unattended pedestal displays. Drop a shader, a model or a
.zip; it reopens itself after a reload and can run for weeks. - ShaderCarousel — the benchmark suite that measures per-node GPU cost on the target headset, so the editor's cost bar shows measured numbers rather than guesses.
- a-frame-shaderloader — the A-Frame component that runs an exported shader on any entity (a git submodule, and the source of the scripts below, including the Gaussian-splat runtime).
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/a-frame-180-a-01.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/a-frame-shaderloader-0.8.js"></script>
<a-scene renderer="backend: webgl">
<a-sphere shader="src: myshader.js" position="0 1.6 -3"></a-sphere>
</a-scene>Those two scripts are all you need: a-frame-180-a-01.min.js bundles A-Frame 1.8.0 + Three.js r184 (WebGPU), and a-frame-shaderloader-0.8.js rewrites the module's import … from 'three/tsl' to read that bundle's single Three.js instance — so no import map and no shim are required. The loader version is not a choice: every export the app produces pins it (LOADER_FILE in src/engine/tslToShaderModule.ts), so bump this snippet whenever that moves. Shaders exported by older releases name 0.6 or 0.5 in their header; those files stay on the CDN, frozen, so an old export keeps loading the loader it was written for. Without A-Frame the same loader runs the exported .js on plain Three.js r184 — see Using the shader module with plain Three.js. Serve the page over http(s): the loader fetches myshader.js and imports it as a blob, so opening the HTML straight from disk (file://) leaves the mesh unshaded with Failed to fetch in the console.
renderer="backend: webgl" is what makes the page enter VR: Three.js r184 picks its WebGPU backend whenever navigator.gpu exists, and that backend refuses a WebXR session outright. The attribute forces the WebGL2 path, which compiles the same TSL and can present to a headset. Drop it for a flat page if you would rather have WebGPU.
A .glb export carries the shader inside the model and loads with shader="src: model" instead of a file path. Read Single-GLB export before you use it.
Loader 0.8 is a plain Three.js loader too: without A-Frame on the page it installs globalThis.FastShaders, which does for a mesh what the A-Frame component does for an entity. The code panel's Three.js tab writes such a page for the current shader, making these same three calls. With the loader:
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.min.js",
"three/webgpu": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.webgpu.min.js",
"three/tsl": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.tsl.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
}
}
</script>
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/a-frame-shaderloader-0.8.js"></script>
<script type="module">
import * as THREE from 'three/webgpu';
FastShaders.use(THREE); // the three/webgpu namespace
const shader = await FastShaders.load('./myshader.js'); // fetch, the loader's transforms, import
const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
await renderer.init();
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
50,
innerWidth / innerHeight,
0.1,
100,
);
camera.position.set(0, 0, 3);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const key = new THREE.DirectionalLight(0xffffff, 2);
key.position.set(2, 3, 2);
scene.add(key);
const mesh = new THREE.Mesh(new THREE.SphereGeometry(0.8, 64, 32));
scene.add(mesh);
const binding = FastShaders.apply(mesh, shader, { values: { speed: 2 } });
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>Three is pinned to 0.184.0, the revision every export names in its header and declares as export const threeRevision; a floating version tag can rename a TSL function out from under a saved page. FastShaders.use(THREE) takes the three/webgpu namespace and sets globalThis.THREE when the page has none. FastShaders.apply is synchronous and returns a binding: change a property at runtime with binding.set('speed', 3) (a number, or a colour as '#rrggbb'), read it as binding.uniforms.speed.value, and binding.dispose() puts the mesh back as it was. Serve the folder over http(s).
What apply does for you, as the A-Frame component does:
- Uniforms from
schema— oneuniform()node per entry, aTHREE.Colorfortype: 'color', starting atvalueswhere you pass them. - The material — the returned channels (
colorNode,roughnessNode, …) on aMeshPhysicalNodeMaterial, withemissiveNodecopied tocolorNodewhen no colour is wired, and the material settingstransparent,side,alphaTestanddepthWrite. parts— a per-mesh shader returnsparts: { "<mesh name>": { …same keys… } }, and each entry becomes the material of the sub-mesh with that name.- The weld — a shader with
positionNodehas a box's corner copies welded by POSITION, or its faces separate, unless the module returnsmergeVertices: false. Under the default it welds only three's own primitive geometries, never a loaded model. - Barycentric corners — a Wireframe node returns
barycentric: trueand reads anattribute('bary')the loader builds. - The revision check — one warning when the module's
threeRevisiondiffers from the page's three. It never stops the shader.
Tessellation stays your job: a shader with positionNode needs vertices to move, and a default PlaneGeometry/BoxGeometry has one segment per side.
glTF models. Register the loader's plugin on your GLTFLoader before loading — loader.register(FastShaders.gltfPlugin) — so a shader that shades by glTF material index knows which material each mesh came from; FastShaders.decoders.install(loader) adds the Draco and meshopt decoders the loader ships, from the decoders/ folder beside its script. The bare three/webgpu namespace has no DRACOLoader, so a plain page imports it from three/addons/loaders/DRACOLoader.js (the import map's three/addons/ entry, where GLTFLoader lives too) and calls FastShaders.decoders.configure({ DRACOLoader }) before install(loader). LoadingManager comes from the namespace use() bound.
WebGLRenderer. three r184 can render node materials on the classic renderer through renderer.setNodesHandler(new WebGLNodesHandler()) (three/addons/tsl/WebGLNodesHandler.js). WebGLRenderer is not in the three/webgpu build, so such a page maps three to build/three.module.min.js. The handler states its own limits (no VSM shadows, no MRT, no transmission, fog and environment that do not update until disposed), and an environment map may be prefiltered twice. Untested here.
Without the loader. A module imports THREE from three/webgpu when it builds a texture (Image, Data and Colormap nodes, and Data Stripes / Data Viz fed by a Data node) and imports every three/tsl function it calls, so await import('./myshader.js') works with the import map above. Everything in the list above is then yours to redo by hand: BufferGeometryUtils.mergeVertices, for one, does not weld a box, because its corner copies differ in normal and UV.
One .glb can carry the whole thing: the 3D model you dropped on the preview, the textures the shader uses, the shader module and the editor project.
EXPORT follows the preview. What it writes depends on what the preview is showing, and right-click EXPORT offers the other way out for that export:
| Preview shows | EXPORT writes | Right-click EXPORT |
|---|---|---|
a dropped .glb, or a .gltf with its data embedded |
that one .glb, with the shader inside |
Export .zip: the shader file, with the model under models/ |
| a built-in shape | the shader file alone: .js, or .zip when the graph has images |
Export with model: adds the shape as an .obj |
an .obj, a Gaussian splat, or a .gltf with separate buffer or image files |
a .zip with the shader file and the model |
nothing else to offer |
The study mode never writes a .glb. EXPORT leaves out nodes that feed no Output; tick Include unconnected nodes in the same right-click menu to keep them.
Imported materials keep four textures. When Mesh with Materials turns a dropped model's materials into a shader, it keeps only the four slots its Texture nodes fill: base colour, metal/rough, normal and emissive. Occlusion and extension textures (clearcoat and the like) are dropped at import, so no export can carry them.
The page that runs it is the code panel's A-Frame tab, which always shows the page for what EXPORT writes — for the .glb:
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/a-frame-180-a-01.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/a-frame-shaderloader-0.8.js"></script><a-entity gltf-model="url(my-shader.glb)" shader="src: model" position="0 1.6 -3"></a-entity>Loader 0.8 or later is required: src: model is its opt-in for the module stored inside the file. Loader 0.6 reads it as a file path, logs one shader-error, and the model keeps its own PBR materials. On plain Three.js the same module runs through FastShaders.applyFromGltf(gltf) after loader.register(FastShaders.gltfPlugin).
Never use src: model on a page that loads models other people supply. The code inside the file runs with the page's privileges, exactly like a script tag.
What other tools show:
- Blender, the three.js editor, Babylon's sandbox, model-viewer, any glTF 2.0 viewer — an ordinary PBR model with the OPTIMISED textures, which replace the originals. Viewers that implement
EXT_texture_webpread the WebP; the others read the PNG/JPEG copy, re-encoded from the same pixels (the extension is listed inextensionsUsed, never inextensionsRequired). None of them shows the authored shader look: extras are ignored. - Round trips that LOSE the shader, silently — a Blender re-export (even with Custom Properties ticked),
gltf-transform prune, gltfpack. The module and project live in buffer views that onlyextrasreferences, so a repack drops them or leaves the indices dangling. FastShaders then reports the file as carrying no shader, or as damaged. - Scale — the model appears at its AUTHORED scale on such a page; the editor's own preview normalises it to 1.6 units.
Drag the .glb back onto FastShaders and the import dialog offers Restore — the graph, its stored values and the model come back.
Drop a .splat, .spz, .ply (a Gaussian-splat PLY without spherical harmonics) or .ksplat file on the preview. A splat is shaded only through a Splat Output, so when the graph holds other nodes but no Splat Output, FastShaders asks first: Clear graph starts over with one Splat Output, and Keep graph loads the splat and leaves the graph as it is. Otherwise add a Splat Output from the Output tab. Then wire it:
- Cut removes every splat where the value is above zero. A distance field wired to Cut keeps its inside; Invert keeps the outside, and Feather softens the edge.
- Color tints each splat's own colour: a pattern or a swatch multiplies it, so white keeps a splat as it was and black darkens it. Tick Replace own colour (right-click the Splat Output) to paint over it instead, and also whenever your Color is built from Vertex Color, or the splat's colour counts twice. Opacity fades each splat.
- Patterns built from UV, such as the Checker or Gradient preset or an Image node, are projected onto the splats from the front, like a slide.
- Move displaces each splat. Keep it to small, smooth fields: splats are still depth-sorted by their original position.
- Size scales each splat.
- React to light (right-click the Splat Output) lights each splat with a key light, using its flattest axis as its surface normal, the way Blender relights splats. Set the light's direction, colour and ambient in the same menu. The captured colours already hold the light of the scan, so the side away from the light darkens. A Normal node points at the camera until React to light is on, so a rim or fresnel effect needs it.
Up to 1,000,000 splats. View-dependent colour (spherical harmonics) is not shown. This shades splats; it does not edit the file. To crop, delete or clean splats use SuperSplat, then drop its export here. Opening a shader clears a loaded model, so open the shader first and drop the splat after it, or drop both together.
An exported splat shader needs the splat runtime after the two scripts above, and a splat-model entity instead of a geometry:
<script src="https://cdn.jsdelivr.net/gh/Alvis1/a-frame-shaderloader@master/js/fs-splat-0.1.js"></script>
<a-entity
splat-model="src: url(scene.splat); kind: splat"
shader="src: myshader.js"
position="0 1.6 -3"
></a-entity>splat-model scales the scene to a longest side of 1.6 units, the frame the shader was authored in; size: 0 keeps the file's own units, and every pattern the shader drives from position or UV (a Checker, a noise, an image on Color) then follows those units instead of the ones you previewed.
- React 18 + TypeScript + Vite
- @xyflow/react v12 — node graph
- @monaco-editor/react — code editor (Monaco bundled locally, no CDN — the app works fully offline)
- zustand v5 — state management
- three.js 0.184 (WebGPU build) — shader runtime, exclusively
three/tslbuilt-ins (including the MaterialX noise family) - @babel/parser + @babel/traverse — code-to-graph parsing
- dagre — automatic graph layout
- Tauri v2 — offline desktop builds (Windows / macOS)
npm install
npm run dev # http://localhost:5173
npm test # vitest suite
npm run build # typecheck + production buildNode 22.6+. Clone with --recurse-submodules: the A-Frame and shaderloader scripts come from the
a-frame-shaderloader submodule and are generated into public/js/ at every dev, build and test
start (they are not committed), so a clone without the submodule stops with a message saying so.
Desktop builds additionally need a Rust toolchain:
npm run tauri dev / npm run tauri build. Release binaries are built by CI on version tags.
Chrome 111+ · Safari 16.4+ · Firefox 126+ (and Chromium-based Edge/Opera at the Chrome floor).
Builds target esnext with no polyfills and nothing transpiled down, so the floor is whatever the
newest feature actually used demands. That feature differs per engine, and so does the damage below it
— only Safari's is a hard break, which is exactly why the single number is not the whole story:
| Engine | Floor | Set by | What an older version does |
|---|---|---|---|
| Chrome | 111 | color-mix() (CostBar.css) |
Everything works; the benchmark drop-target loses a tint (a literal rgba() fallback runs first). |
| Safari | 16.4 | ES2022 class static blocks, shipped by monaco-editor 0.55 |
The app boots and the node editor works; the code panel fails when opened, because Monaco is a lazily-loaded chunk. |
| Firefox | 126 | the non-standard zoom property (NodePreviewCard.css) |
The app works, but every asset-browser tile renders ~1.49× and is clipped by the strip. |
A browser too old to start the app at all lands on a bilingual note in index.html rather than a blank
page. That watchdog fires only when React never mounts, so it does not catch the Safari case above —
there the editor really did start.
Beyond the app itself, the 3D preview needs WebGL2 (WebGPU is used when the browser offers a working adapter, and the preview falls back on its own when it does not).
MIT. Bundled third-party components (three.js, A-Frame, Monaco, fonts, scientific colormap data…) are credited in public/THIRD-PARTY-NOTICES.txt, which ships with every build.
Alvis Misjuns
- Email: [email protected]
- Web: alvismisjuns.lv
FastShaders is doctoral research at the Faculty of Engineering, Vidzeme University of Applied Sciences, on performance-aware shader authoring for standalone VR headsets: per-node GPU costs are measured on the target device with the bundled ShaderCarousel benchmark, so the editor can show a shader's cost against a real budget while it is being built. A paper is in preparation; until it appears, please link to this repository.
This research was supported by the project No. 1.1.1.8/1/24/I/001 VeA and ViA Doctoral Grants, co-funded by the European Union (European Regional Development Fund) and the Latvian state budget within the European Union Cohesion Policy Programme 2021–2027.
