ShaderPad: A Zero-Friction GLSL Playground That Ships as an Embeddable React Component
Recently, I've been diving deep into GLSL. Every time I wanted to write a shader to see the effect, I had to create a new HTML file, write <script> tags, import the Three.js package, configure a <canvas>, start a local server, and refresh to see the changes after editing — even with AI assistance, the mental overhead of this process was still too heavy. I really just wanted to verify some transformation formulas and view the effects of vertex transformations or color changes, not build a project.
After trying a bunch of existing online GLSL playgrounds, they either only had the bare minimum functionality, would bug out with slight code changes, or were complex products requiring login or payment. So I decided to build my own playground — ShaderPad
Goals:
- 'Run a shader within 30 seconds of opening a browser': A minimalist playground for Web shader learning and debugging with zero login, zero configuration, and zero downloads.
- Support sharing and quick reproduction of 3D scenes.
- Provide an npm package that can be quickly inserted into MDX text.
Online Experience , corresponding GitHub repository
Overall Effect
The overall page structure is similar to most online editors. The left side is the code editing area, providing editing functions for vertex shaders and fragment shaders, with GLSL syntax highlighting. The right side is a 3D scene based on Three.js, providing a real-time preview of the currently edited code's effect, and it offers a floating console panel for viewing errors or logs.
For easy observation, the 3D scene has built-in auxiliary grids and coordinate axes, and introduces OrbitControls, allowing users to rotate the scene with the mouse to view different angles.
Tech Stack
| Dimension | Choice | Reason |
|---|---|---|
| Frontend Framework | Astro 4.15 + React Island | Island architecture + first-screen friendly |
| Editor | Monaco Editor 0.50 | Same as VSCode, TS smart prompts, GLSL syntax highlighting |
| 3D Rendering | Three.js 0.170 (WebGLRenderer + RawShaderMaterial) | |
| State Management | nanostores 0.11 | Extremely small (<1KB), React integration via @nanostores/react |
| Package Management | pnpm 8.11 workspace | Hard links save space, monorepo friendly |
| Deployment | Lightweight server + Nginx Proxy Manager + GitHub Actions |
Why Choose Astro?
Before building ShaderPad, I hadn't really used Astro much. The reason I chose it this time was that I was impressed by its 'zero JS by default' design. It's not built on React, but allows you to embed React / Vue / Svelte etc. as 'islands' into static HTML through integrations.
Compared to heavy solutions like Gatsby, Docusaurus, or Next.js that push the entire React runtime to the browser by default, Astro appears very restrained. Under the 'island architecture', it treats the entire page as a static ocean, dotted with a few interactive islands. For ShaderPad, the documentation content is the static sea surface, and only the Playground component is the true interactive island.
Advantages in underlying principles:
- Extreme compile-time stripping: Astro's rendering mode is SSG. During build, it runs all
.astrocomponents, brutally stripping away all JS logic that doesn't need to execute on the client, outputting only pure HTML. - Precise on-demand hydration: Because the Playground heavily depends on WebGL (which cannot execute in Node at all), I added the
client:only="react"directive. When Astro encounters it, it only leaves a placeholder DOM with anastro-islandtag in the HTML and injects a very lightweight scheduler (a few KB). After the page loads, the scheduler fetches the React runtime and component code, completing 'hydration' on the client.
In terms of the actual output: the built documentation site's index page is only 6.84 kB (2.73 kB after Gzip), with the bulk being in the on-demand loaded Playground island (about 500+ kB). This granular control of 'mainly static, secondary interactive' perfectly fits the needs of heavy client-side tools, completely shedding the performance burden of full-site React rendering. This is also why Astro is becoming increasingly popular in 'documentation site + tool website' scenarios—it does the job of 'saving what should be saved to the extreme' very thoroughly.
Architecture Design
Monorepo Structure
Monorepo is now a standard for multi-package management. It not only manages multiple packages in one repository but also forces me to face a question: 'If this project needs to be embedded into someone else's webpage, where should the boundary be drawn?' So from the start, I built it with an SDK perspective: the core engine, UI components, and style layer each manage their own part, common parts are hoisted to packages/, and the main site is only responsible for the shell and experience.
@shaderpad/runtimeextracts the LanguageAdapter interface (lightweight GLSL syntax pre-check), which can be directly reused for future expansion to Node / Tauri desktop.@lucascv/shaderpad-playgroundextracts the Playground into an independent npm package, independently versioned, and embeddable into any React documentation site.apps/webmain site remains lightweight, as a consumer of the packages, resulting in cleaner deployment artifacts.
shaderPad/
├── apps/
│ └── web/ # Main site (Astro)
│ ├── src/
│ │ ├── pages/ # Routes (index / play / learn/*)
│ │ ├── components/ # React components
│ │ ├── lib/
│ │ │ ├── runtime/ # Browser-side rendering engine
│ │ │ └── share/ # URL/localStorage persistence
│ │ └── shaders/examples.ts # Built-in example library
│ └── astro.config.mjs
├── packages/
│ ├── shader-runtime/ # Cross-platform shared core (future desktop expansion)
│ │ └── src/languages/ # GLSL / TSL / WGSL adapter
│ └── shader-playground/ # Independently releasable npm package
│ ├── src/
│ │ ├── runtime/three-engine.ts
│ │ ├── ui/ # ShaderPlayground / CodeEditor / PreviewCanvas
│ │ └── styles/playground.css
│ └── tsup.config.ts
└── .github/workflows/
├── deploy-web.yml # Main site deployment
└── release.yml # npm automatic release (OIDC)
Data Flow
[Monaco Editor] --change--> Playground state (codeRef)
|
|--auto save (1s debounce)--> localStorage
|
'--compileAndRun()--> [ShaderEngine]
|
+---------+---------+
| |
(vertex/fragment) (uniforms)
| |
v v
Three.js RawShaderMaterial <-- OrbitControls / Grid / Axes
Core Rendering Module
ShaderEngine (Runtime Core)
To run code on a webpage, a stable and efficient renderer is necessary. I encapsulated the ShaderEngine core class to handle the dirty work of Three.js.
It is not just a simple wrapper for WebGLRenderer; more importantly, it takes over the rendering lifecycle and error capture, exposing only the most minimal API to the outside:
class ShaderEngine {
init() // Create WebGLRenderer + perspective camera + auxiliary coordinate system
applyShader(source, mode) // Inject user's source code into RawShaderMaterial
forceCompile() // Bypass Three.js top layer, directly call WebGL API to pre-compile and capture line numbers
setGeometry(type) // Seamlessly switch geometry (reuse Material, no flicker)
start() / stop() / dispose() // Mount RAF animation loop, ensure no memory leaks on destruction
}
Built-in Modules
The following common Three.js geometries are provided:
PlaneGeometryPlaneBoxGeometryCubeSphereGeometrySphere
The default is PlaneGeometry, which users can switch. Several common shader examples are provided for different geometries, such as time gradients, mouse following, noise effects, etc. Users can select them to view the effects and choose as needed.
Another key point is that I also built in some common uniform variables used in development, as follows:
uniform float u_time; // Seconds since startup, increments per frame
uniform vec2 u_resolution; // Canvas width and height (pixels)
uniform vec2 u_mouse; // Mouse position, normalized to [0,1] (Y flipped)
uniform float u_random; // Random number [0,1) at applyShader time
This allows these variables to be used directly in shaders for dynamic effects.
Of course, fully custom uniforms are not yet possible. For now, common variables are gradually added. If you have needs, you can comment or add a GitHub issue
Why Use RawShaderMaterial?
When implementing ShaderEngine, I faced a trade-off: use ShaderMaterial or RawShaderMaterial?
ShaderMaterial is very convenient; it automatically injects a bunch of Three.js built-in uniforms and attributes (like cameraPosition, modelViewMatrix, etc.). But in a 'teaching and debugging' scenario, this becomes a fatal flaw—users get confused: 'I clearly didn't declare this variable, why does it work?'
To achieve 'What You See Is What You Get', I ultimately chose RawShaderMaterial. It's a blank slate, injecting no hidden code, the source code the user writes is exactly the GLSL that runs on the GPU.
This also means error line numbers can achieve a 1:1 absolute correspondence, avoiding the paranormal event of 'clearly only 10 lines of code, but the console reports an error on line 150'. The cost is that users must explicitly declare the required built-in matrices at the beginning of the code:
attribute vec3 position;
attribute vec2 uv;
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
But this brings complete transparency to the runtime mechanism, a trade-off well worth it for a learning tool.
Precise Location of Compilation Errors
Three.js's default error message for compilation failures is a string like 'WebGL: ERROR: 0:5: 'foo' : undeclared identifier', which cannot be structurally processed. The Playground, within ShaderEngine, directly bypasses Three.js's encapsulation, calls the underlying gl.getShaderInfoLog + gl.getShaderSource to parse it itself, splitting the line number / column number / error message into a struct and displaying it as a floating bar:
{ line: 5, column: 12, message: "'foo' : undeclared identifier" }
Thus, when writing GLSL, what you see are real, locatable errors, not the cryptic 'WebGL: ERROR: 0:5'.
Module Precipitation: From a Single Tool to a Universal npm Package
After finishing the main site, I realized—the 'real-time editing + real-time preview' interaction itself is very valuable, and it shouldn't be confined to ShaderPad's own website. If a runnable Shader could be directly embedded in any MDX document or technical blog, the reading experience would increase exponentially.
The effect is as follows:
So I extracted the Playground into an independent npm package: @lucascv/shaderpad-playground, which can be embedded into any React documentation site with 5 lines of code.
pnpm add @lucascv/shaderpad-playground react three monaco-editor @monaco-editor/react
import { ShaderPlayground } from "@lucascv/shaderpad-playground";
import "@lucascv/shaderpad-playground/styles";
<ShaderPlayground
code="void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }"
storageKey="my-article/hello"
/>;
Live Demo: shaderpad.lucaslib.net/embed-test | npm: @lucascv/shaderpad-playground
Why It Must Be MDX (The Prerequisite)
The reason this works is key to MDX itself. Ordinary Markdown can only write text + code blocks; when encountering JSX like <ShaderPlayground />, it's baffled—it can't even recognize <div>, let alone embed interactive components.
MDX (Markdown + JSX) was born for this problem: it extends Markdown syntax, allowing direct writing of React components in documents. The principle isn't complicated—at compile time, tools like @mdx-js/mdx parse .mdx files into a React tree: the Markdown part goes through the remark pipeline to produce React elements, the JSX part is passed through as-is, and finally, a complete component tree is synthesized for rendering.
Moreover, mainstream documentation frameworks almost all natively support MDX: Docusaurus (the blog you are reading now), Astro, Nextra, VitePress all work out of the box without extra scaffolding. If your blog / documentation site is already using these tech stacks, zero migration cost is needed to integrate—this is crucial, meaning the Playground's audience isn't just heavy React users, but almost anyone writing technical documentation.
This mechanism allows 'prose + code blocks + live demo' to be seamlessly mixed in the same file. Other routes can't achieve this level:
- iframe embedding external playground: style fragmentation, cross-origin communication hassle, host theme cannot blend in
- Screenshot + jump to CodePen / ShaderToy: readers are forced to jump out of the current reading flow
- Record video: completely loses editability, essentially castrating the core value
The MDX route allows interactive components and the main text to truly grow together
Persisting Code
The code edited by the user needs to be preserved (so it's the version they modified when they open it next time), but if the author changes the article's example code, the old draft will 'ghostly take effect'—it looks loaded, but the content is from the previous version.
The approach in the package is to calculate a short hash of the source file content using djb2, and write it into the localStorage key:
// v2 key format: embed-test/pair-box:a3f9b1c2
function buildKey(storageKey, source) {
return `${storageKey}:${shortHash(source)}`;
}
If the source file changes, the hash changes, automatically generating a new key, and the old draft is naturally bypassed. After this mechanism went live, there have been no more paranormal issues of 'code mismatch' when iterating the documentation example library.
Single / Dual Shader Configuration
The simplest usage is to pass only a code field, running a single stage. But some scenarios (vertex animation, varying passing) require vertex + fragment linkage to run, so the package also provides a pair configuration:
<ShaderPlayground
pair={{
vertex: `void main() { gl_Position = vec4(position, 1.0); }`,
fragment: `void main() { gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); }`,
}}
storageKey="docs/glsl/coords"
/>
When pair is passed, the canvas automatically switches to Vertex / Fragment dual tabs, the editor uses a single instance, each side has its own code, and they don't conflict.
Component Package Bundling
When extracting the independent npm package @lucascv/shaderpad-playground, I needed a bundling tool. The reason for choosing tsup is that it's based on esbuild under the hood, offering extremely fast bundling speed, and out-of-the-box support for .d.ts type generation. For a pure TS/React UI component library that doesn't need complex Webpack loader configuration, the experience is a dimensionality reduction strike.
But I stepped on a classic pitfall here regarding ESM/CJS dual format output.
Initially, after bundling, the Astro main site reported a SyntaxError for page hydration failure when importing the component. After investigation, it turned out that because the package's package.json declared "type": "module", the host received a format-mismatched artifact when resolving dependencies.
To perfectly support both modern frameworks (which need ESM for tree-shaking) and tools like Docusaurus 2.x that might rely on older Webpack configurations (which need CJS), the output extensions must be manually managed in tsup.config.ts:
export default defineConfig({
format: ["esm", "cjs"],
outExtension({ format }) {
// Force ESM artifact suffix to .js (because type: module), CJS artifact suffix to .cjs
return { js: format === "cjs" ? ".cjs" : ".js" };
},
});
At the same time, the exports field in package.json must be perfectly aligned:
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // ESM consumers go here
"require": "./dist/index.cjs" // CJS consumers go here
}
}
Only when these two sides are absolutely aligned can various host frameworks, when importing or requireing based on their own environment, precisely hit the correct module, completely eliminating Hydration errors.
A Few Engineering Trade-offs
- CSS variables all use
spg-prefix: Colors / borders / accent colors all use CSS variables, theme followsdocumentElement[data-theme], naturally blending with the host site's theme, without the abrupt 'white background black text'. - Responsive breakpoint 720px: Wide screen splits left and right (editor + canvas), narrow screen automatically stacks into a top-bottom structure, can view effects directly on mobile.
- React 17 / 18 / 19 full compatibility:
react/react-dom/three/monaco-editorare allpeerDependencies, not bundled into dist, core package size ~66KB (gzip), installed by consumers as needed. This website, based on the Docusaurus 2.4 + React 17 antique environment, also supports integration.
URL Sharing Feature
As an online debugging tool, how to share code without a backend database?
The solution here is: compress the entire Shader source code using LZString + Base64 encoding, and directly stuff it into the URL's hash routing parameter. You can click share in the upper right corner of ShaderPad, then open a new tab and paste to experience it.
Why This Combination
https://shaderpad.lucaslib.net/?a=1&b=2#/playground?code=xxx
└── query ──┘ └────── hash ──────┘
Only the hash part of the entire URL stays on the client; query and path are sent to the server—meaning backend cooperation is needed, and protection against logs and CDN pollution is required. Changing the hash does not trigger an HTTP request, making it a natural choice for 'no backend + static deployment'.
- LZString compression. GLSL is naturally highly redundant, with keywords and template snippets appearing repeatedly. LZString is designed for 'short strings + URL' scenarios, its output is a string itself, with a compression ratio typically 3~5x.
- Base64 as a 'URL-safe' fallback. The compressed byte stream is binary, which may contain control characters. Base64 maps arbitrary bytes to 64 URL-safe characters, the ~33% volume cost is covered by the compression ratio of the previous layer.
Browsers have an implicit upper limit on URL length (in practice, Chrome is around 8KB~32KB), long code will be truncated. So shared links are naturally suited for 'short and concise examples'—this actually fits the debugging scenario quite well, a single-file shader shouldn't be too long anyway.
This way, anyone who gets the link can open it and directly restore the current editing state, completely without backend intervention, truly achieving 'stateless' minimalist sharing.
Deployment
Coincidentally, I recently switched to a new server, and ShaderPad went live as the first deployed application. Regarding configuring the new server environment, I also wrote a dedicated article "Linux Personal Cloud Server Pioneering Guide"
By the way, a complaint about a certain cloud provider: the renewal fee for the old 1-core 2G cloud server was still ridiculously expensive, while a new 2-core 4G purchase had a big first-year discount, making the cost about the same. There weren't many applications running on the old machine, migration cost wasn't high, so I simply switched to a higher-spec one.
The core component for deployment is Nginx Proxy Manager (hereinafter NPM, note this is not the same as Node's npm).
Nginx Proxy Manager
NPM is a visual reverse proxy management tool based on Nginx, packaged as a Docker image, it can be started with a single command, very nice.
- Provides a Web management interface, no need to manually write
nginx.conf, no need fornginx -s reload - SSL certificate application + deployment in one go (Let's Encrypt automation), farewell to the tedious process of manually applying on a cloud provider's control panel and then
vim nginx.conf
Port Planning (three ports will be occupied by default, remember to allow rules in the cloud provider's firewall):
| Port | Use | Exposure Suggestion |
|---|---|---|
| 80 | HTTP | Public |
| 443 | HTTPS | Public |
| 81 | Management Interface | Only open to trusted IPs |
Automated Deployment
Using GitHub Actions + rsync:
- Push to main → triggers
.github/workflows/deploy-web.yml - Runs
pnpm install+pnpm --filter web build, artifacts inapps/web/dist/ rsync-deploymentsaction pushesdist/to the server's/var/www/shaderpad/dist/(consistent with the NPM static resource directory)
Summary and Thoughts
Previously, I always wanted to make an online tool, but felt there were already enough wheels on the market, plus the development and deployment costs, so I never started. This time, with the help of AI (the core code heavily utilized large models like Minimax-M3), the cycle from 'idea to launch' was greatly compressed.
ShaderPad not only makes it more convenient for me to debug and learn GLSL code, but also allowed me to run through the complete engineering closed loop from 'monolithic application development' to 'universal component extraction', and then to 'automated release and deployment'.
The first version remains minimalist. If people find it useful later, I will consider expanding support for TSL and WGSL. Welcome to play!
Online experience address: ShaderPad , corresponding GitHub repository