最近在深入探究 GLSL,每次想写一段 shader 看下效果,都要新开一个 HTML、写 <script> 标签、引入threejs包、配 <canvas>、起本地服务、改完刷新看效果 —— 即便有 AI 加持,这一套流程的心智负担还是太重了。我其实只是想验证一些变换公式,查看顶点变换或颜色变化等效果,而不是搭一个项目。
试了一圈现成的在线 GLSL playground,要么功能停在能跑就行,要么稍微改点代码就出 bug,再要么就是必须登录或者收费的复杂产品。于是决定自己撸一个 playground —— ShaderPad
目标:
- 「打开浏览器 30 秒内跑起一个 shader」:零登录、零配置、零下载,面向 Web 着色器学习和调试的极简 playground
- 支持分享和快速复现 3d 场景
- 提供可快速插入 mdx 文本里的 npm 包
整体效果
整体页面结构跟大部分在线编辑器类似,左边是代码编辑区,提供了顶点着色器和片元着色器的编辑功能,会高亮 glsl 语法。右边是基于 threejs 的3d场景,实时预览当前编辑的代码的效果,并且提供了悬浮的控制台面板,方便查看一些报错或 log
为了方便观察,3d场景内置了辅助网格和辅助坐标轴,并且引入了 OrbitControls,用户可以通过鼠标旋转场景,查看不同的角度
技术栈
| 维度 | 选型 | 理由 |
|---|---|---|
| 前端框架 | Astro 4.15 + React Island | 群岛架构 + 首屏友好 |
| 编辑器 | Monaco Editor 0.50 | VSCode 同款,TS 智能提示、GLSL 语法高亮 |
| 3d渲染 | Three.js 0.170(WebGLRenderer + RawShaderMaterial) | |
| 状态管理 | nanostores 0.11 | 极小(<1KB),React 集成通过 @nanostores/react |
| 包管理 | pnpm 8.11 workspace | 硬链接节省空间,monorepo 友好 |
| 部署 | 轻量服务器 + Nginx Proxy Manager + GitHub Actions |
为什么选 Astro?
做 ShaderPad 之前我其实没怎么用过 Astro。这次之所以选它,是被它的「默认零 JS」设计打动了。它并非基于 React 构建,而是允许你通过 integrations 把 React / Vue / Svelte 等当作「岛屿」嵌入到静态 HTML 中。
对比 Gatsby、Docusaurus 或 Next.js 这些默认把整个 React 运行时推到浏览器的重型方案,Astro 显得非常克制。在「孤岛架构」下,它把整个页面当成一片静态海洋,里面散落着几个交互小岛。对 ShaderPad 来说,文档内容就是静态海面,只有 Playground 这个组件才是真正的交互孤岛。
底层原理上的优势:
- 极致的编译时剥离:Astro 的渲染模式是 SSG。在 build 时它会把
.astro组件全跑一遍,暴力剥离掉所有不需要在客户端执行的 JS 逻辑,只输出纯 HTML。 - 精准的按需水合:因为 Playground 强依赖 WebGL(完全没法在 Node 端执行),我给它加了
client:only="react"指令。Astro 遇到它时,只会在 HTML 里留个带有astro-island标签的占位 DOM,并注入极轻量的调度器(几 KB)。等页面加载完,调度器才会去拉取 React 运行时和组件代码,在客户端完成「水合」。
落到实际产物上:构建出来的文档站 index 页面只有 6.84 kB(Gzip 后 2.73 kB),大头全在按需加载的 Playground 孤岛里(约 500+ kB)。这种「主静态、副交互」的颗粒度控制,完美契合了重客户端工具的需求,彻底甩掉了全站 React 渲染的性能包袱。这也是为什么 Astro 在「文档站 + 工具型网站」场景下越来越受欢迎的原因——它把"该省的省到极致"这件事做得很彻底。
架构设计
Monorepo 结构
monorepo 算是现在多仓管理的标配。它不光能在一个仓库里管多个 package,更像是在逼我面对一个问题:"如果这个项目要嵌进别人的网页里,边界该画在哪?"。所以从一开始我就带着 SDK 视角在搭:核心引擎、UI 组件、样式层各管一摊,公共部分一律上提到 packages/,主站只负责壳子和体验。
@shaderpad/runtime抽离出 LanguageAdapter 接口(GLSL 轻量语法预检),未来扩展到 Node / Tauri 桌面端可直接复用。@lucascv/shaderpad-playground把 Playground 抽成独立 npm 包,独立发版、可嵌入到任何 React 文档站。apps/web主站保持轻量,作为包的消费者,部署产物也更加干净。
shaderPad/
├── apps/
│ └── web/ # 主站(Astro)
│ ├── src/
│ │ ├── pages/ # 路由(index / play / learn/*)
│ │ ├── components/ # React 组件
│ │ ├── lib/
│ │ │ ├── runtime/ # 浏览器侧渲染引擎
│ │ │ └── share/ # URL/localStorage 持久化
│ │ └── shaders/examples.ts # 内置示例库
│ └── astro.config.mjs
├── packages/
│ ├── shader-runtime/ # 跨端共享核心(未来扩展桌面端)
│ │ └── src/languages/ # GLSL / TSL / WGSL adapter
│ └── shader-playground/ # 可独立发版的 npm 包
│ ├── src/
│ │ ├── runtime/three-engine.ts
│ │ ├── ui/ # ShaderPlayground / CodeEditor / PreviewCanvas
│ │ └── styles/playground.css
│ └── tsup.config.ts
└── .github/workflows/
├── deploy-web.yml # 主站部署
└── release.yml # npm 自动发版(OIDC)
数据流
[Monaco Editor] --change--> Playground state (codeRef)
|
|--auto save (1s debounce)--> localStorage
|
'--compileAndRun()--> [ShaderEngine]
|
+---------+---------+
| |
(vertex/fragment) (uniforms)
| |
v v
Three.js RawShaderMaterial <-- OrbitControls / Grid / Axes
核心渲染模块
ShaderEngine(运行时核心)
要让代码在网页上跑起来,必须有一套稳定、高效的渲染器。我封装了 ShaderEngine 这个核心类来处理 Three.js 的脏活累活。
它不仅是对 WebGLRenderer 的简单包装,更重要的是它接管了渲染的生命周期与错误捕获,对外只暴露最极简的 API:
class ShaderEngine {
init() // 创建 WebGLRenderer + 透视相机 + 辅助坐标系
applyShader(source, mode) // 将用户的源码注入 RawShaderMaterial
forceCompile() // 绕过 Three.js 顶层,直接调用 WebGL API 预编译并捕获行号
setGeometry(type) // 无缝切换几何体(复用 Material,不闪烁)
start() / stop() / dispose() // 挂载 RAF 动画循环,并确保销毁时不漏内存
}
内置模块
提供了以下几种 threejs 常见的几何体:
PlaneGeometry平面BoxGeometry立方体SphereGeometry球体
默认是 PlaneGeometry,用户可以自行切换。针对不同几何体提供了几种不同的常见 shader 示例,比如时间渐变、鼠标跟随、噪声效果等,选中后就可以查看效果,用户可以根据需要选择。
另外比较关键的是,我还内置了一些开发中常见的 uniform 变量,如下:
uniform float u_time; // 自启动以来的秒数,每帧递增
uniform vec2 u_resolution; // 画布宽高(像素)
uniform vec2 u_mouse; // 鼠标位置,归一化到 [0,1](Y 已翻转)
uniform float u_random; // applyShader 时的随机数 [0,1)
这样就可以在 shader 中直接使用这些变量来做一些动态效果。
当然目前没法完全自定义 uniform,暂时是逐步加入一些常见变量,有需求的可以评论或者追加 github issue
为什么用 RawShaderMaterial?
在实现 ShaderEngine 时,我面临一个取舍:用 ShaderMaterial 还是 RawShaderMaterial?
ShaderMaterial 很方便,它会自动帮你注入一堆 Three.js 内置的 uniforms 和 attributes(比如 cameraPosition、modelViewMatrix 等)。但在「教学和调试」场景下,这反而成了致命缺点——用户会很困惑:「我明明没声明这个变量,为什么它能跑?」
为了做到**「所见即所得」**,我最终选择了 RawShaderMaterial。它是一张白纸,不注入任何隐藏代码,用户写的 source 就是最终跑在 GPU 里的 GLSL。
这也意味着报错行号能做到 1:1 绝对对应,不会出现「明明只有 10 行代码,控制台却报第 150 行错误」的灵异事件。代价是用户必须在代码开头显式声明所需的内置矩阵:
attribute vec3 position;
attribute vec2 uv;
uniform mat4 projectionMatrix;
uniform mat4 viewMatrix;
uniform mat4 modelMatrix;
但这换来的是运行机制的完全透明,对于一个学习工具来说,这个权衡是非常值得的。
编译错误的精确定位
Three.js 编译失败的报错信息默认是「WebGL: ERROR: 0:5: 'foo' : undeclared identifier」这种字符串,没法结构化处理。Playground 在 ShaderEngine 里直接绕过 Three.js 的封装,调底层 gl.getShaderInfoLog + gl.getShaderSource 自己解析,把行号 / 列号 / 错误消息拆成结构体再浮条展示:
{ line: 5, column: 12, message: "'foo' : undeclared identifier" }
这样写 GLSL 时,看到的都是真实可定位的错误,而不是"WebGL: ERROR: 0:5"这种天书。
模块沉淀:从单一工具到通用的 npm 包
做完主站后我意识到——「实时编辑 + 实时预览」这套交互本身非常有价值,它不应该只局限在 ShaderPad 自己的网站里。如果能在任何 MDX 文档或技术博客里直接嵌入一个能跑的 Shader,阅读体验会呈指数级上升。
效果如图:
于是我把 Playground 抽成了一个独立的 npm 包:@lucascv/shaderpad-playground,5 行代码就能嵌进任何 React 文档站。
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
为什么必须是 MDX(前提中的前提)
这事能成立,关键在 MDX 这个东西本身。普通 Markdown 只能写文字 + 代码块,碰到 <ShaderPlayground /> 这种 JSX 直接懵——<div> 都识别不了,更别说塞交互组件。
MDX(Markdown + JSX)就是为这个问题生的:它扩展了 Markdown 语法,允许在文档里直接写 React 组件。原理不复杂——编译期用 @mdx-js/mdx 这类工具把 .mdx 文件解析成 React 树:Markdown 部分走 remark 管线出 React 元素,JSX 部分原样透传,最后合成一棵完整的组件树渲染。
而且目前主流文档框架几乎都原生支持 MDX:Docusaurus(你正在看的这个博客就是)、Astro、Nextra、VitePress 都是开箱即用,不用额外搭脚手架。如果你的博客 / 文档站已经在用这些技术栈,零迁移成本就能接入——这点很关键,意味着 Playground 的受众不是只有 React 重度用户,而是几乎所有写技术文档的人。
这套机制让「散文 + 代码块 + 实时 demo」在同一个文件里无缝混排。其他路线都做不到这种程度:
- iframe 嵌外部 playground:样式割裂、跨域通信麻烦、宿主主题融不进去
- 截图 + 跳 CodePen / ShaderToy:读者被迫跳出当前阅读流
- 录视频:完全丧失可编辑性,等于把核心价值阉了
MDX 路线能让交互组件和正文真正长在一起
持久化代码
用户编辑过的代码要保留下来(下次打开还是他改过的版本),但如果作者改了文章的示例代码,旧草稿就会"幽灵生效"——看起来加载了,但内容是上一版的。
包里的做法是把源文件内容用 djb2 算一个短 hash,写进 localStorage key 里:
// v2 key 格式:embed-test/pair-box:a3f9b1c2
function buildKey(storageKey, source) {
return `${storageKey}:${shortHash(source)}`;
}
源文件一变 hash 就变,自动生成新 key,旧草稿自然绕过。这套机制上线后,文档示例库再迭代也没出过"代码不匹配"的玄学问题。
单 / 双着色器配置
最简的用法是只传一个 code 字段,跑单 stage。但有些场景(顶点动画、varying 传递)必须 vertex + fragment 联动才能跑起来,所以包内同时提供了 pair 配置:
<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"
/>
传 pair 时画布会自动切到 Vertex / Fragment 双 tab,编辑器用单一实例、两边各自一份代码,互不打架。
组件包打包
在抽离 @lucascv/shaderpad-playground 这个独立的 npm 包时,我需要一个打包工具。之所以选 tsup,是因为它底层基于 esbuild,打包速度极快,而且开箱即用支持 .d.ts 类型生成。对于这种不用配复杂 Webpack loader 的纯 TS/React UI 组件库来说,体验简直是降维打击。
但我在这里踩了一个经典的 ESM/CJS 双格式导出的坑。
一开始打包后,Astro 主站引入组件时报了页面水合(Hydration)失败的 SyntaxError。排查后发现,是因为包的 package.json 声明了 "type": "module",导致宿主在解析依赖时,拿到了格式不匹配的产物。
为了同时完美支持现代框架(需要 ESM 以支持 Tree-shaking)和类似 Docusaurus 2.x 这种可能依赖旧版 Webpack 设定的工具(需要 CJS),必须在 tsup.config.ts 里手动接管输出扩展名:
export default defineConfig({
format: ["esm", "cjs"],
outExtension({ format }) {
// 强制把 ESM 产物后缀设为 .js(因为 type: module),CJS 产物后缀设为 .cjs
return { js: format === "cjs" ? ".cjs" : ".js" };
},
});
同时,package.json 里的 exports 字段必须严丝合缝地对齐:
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js", // ESM 消费者走这里
"require": "./dist/index.cjs" // CJS 消费者走这里
}
}
只有当这两边绝对对齐,各种宿主框架在根据自身环境 import 或 require 时,才能精准命中正确的模块,彻底消灭 Hydration 报错。
几个工程取舍
- CSS 变量全用
spg-前缀:颜色 / 边框 / 强调色全部走 CSS 变量,主题跟随documentElement[data-theme],和宿主站主题自然融合,不会突兀地"白底黑字"。 - 响应式断点 720px:宽屏左右分屏(编辑器 + 画布),窄屏自动堆叠成上下结构,手机也能直接看效果。
- React 17 / 18 / 19 全兼容:
react/react-dom/three/monaco-editor全是peerDependencies,不打包进 dist,包体核心 ~66KB(gzip),按需由消费方装。本网站基于 Docusaurus 2.4 + React 17 这个老古董环境,也都支持集成。
URL 分享功能
作为一个在线调试工具,如果不做后端数据库,怎么分享代码?
这里的方案是:把整个 Shader 源码通过 LZString 压缩 + Base64 编码后,直接塞进 URL 的 hash 路由参数里。可以在 ShaderPad 右上角点击 share,然后新打开标签页粘贴体验
为什么是这套组合
https://shaderpad.lucaslib.net/?a=1&b=2#/playground?code=xxx
└── query ──┘ └────── hash ──────┘
整段 URL 只有 hash 留在客户端,query 和 path 都会被发到服务器——意味着要后端配合、还要防日志和 CDN 污染。改 hash 不触发 HTTP 请求,对「无后端 + 静态部署」是天然选择。
- LZString 压缩。 GLSL 天然高冗余,关键字和模板片段反复出现。LZString 是为「短字符串 + URL」场景设计的,输出本身就是 string,压缩比通常 3~5x。
- Base64 兜底「URL 安全」。 压缩后的字节流是二进制,里面可能混着控制字符。Base64 把任意字节映射到 64 个 URL-safe 字符,~33% 的体积代价被上一层的压缩比覆盖。
浏览器对 URL 长度有隐性上限(实测 Chrome 大概在 8KB~32KB 之间),代码长了会被截断。所以分享出去的链接天然适合"短小精悍的示例"——这其实和调试场景挺契合的,单文件 shader 本来就不该太长。
这样任何人拿到链接,打开就能直接还原当前的编辑状态,完全不需要后端的介入,真正做到了「无状态」的极简分享。
部署
刚好最近换了台新服务器,ShaderPad 就作为第一个部署的应用上线了。关于新服务器配置环境,还专门写了一篇文章 《linux个人云服务器开荒指南》
顺便吐槽一句某某云:旧那台 1 核 2G 的云服务器续费依旧贵得离谱,反而新买一台 2 核 4G 首年还有大折扣,算下来差不多。旧机器上也没跑几个应用,迁移成本不高,索性换台配置高一点的。
部署的核心组件是 Nginx Proxy Manager(下文简称 NPM,注意和 Node 的 npm 不是一回事)。
Nginx Proxy Manager
NPM 是一个基于 Nginx 的可视化反向代理管理工具,包装成 Docker 镜像后一行命令就能起,很香。
- 提供 Web 管理界面,不用手写
nginx.conf、不用nginx -s reload - SSL 证书申请 + 部署一条龙(Let's Encrypt 自动化),告别以前去某某云控制台手动申请再
vim nginx.conf的繁琐
端口规划(默认会占三个,记得在某某云防火墙里放行规则):
| 端口 | 用途 | 暴露建议 |
|---|---|---|
| 80 | HTTP | 公开 |
| 443 | HTTPS | 公开 |
| 81 | 管理界面 | 只对可信 IP 开放 |
自动化部署
走 GitHub Actions + rsync:
- push 到 main → 触发
.github/workflows/deploy-web.yml - 跑
pnpm install+pnpm --filter web build,产物在apps/web/dist/ rsync-deploymentsaction 把dist/推到服务器的/var/www/shaderpad/dist/(与 NPM 静态资源目录保持一致)
总结与思考
以前我一直想做个在线工具,但总觉得市面上轮子已经够多了,加上开发和部署成本,迟迟没有动手。这次在 AI 的加持下(核心代码大量借助了 Minimax-M3 等大模型),极大地压缩了「从想法到上线」的周期。
ShaderPad 不仅让我调试和学习 glsl 代码更方便,也让我跑通了从「单体应用开发」到「通用组件抽离」,再到「自动化发版部署」的完整工程化闭环。
第一版先保持极简,后续如果大家觉得好用,会考虑扩展对 TSL 和 WGSL 的支持。欢迎来玩!