做了一个glsl在线调试工具

最近在深入探究 GLSL,每次想写一段 shader 看下效果,都要新开一个 HTML、写 <script> 标签、引入threejs包、配 <canvas>、起本地服务、改完刷新看效果 ------ 即便有 AI 加持,这一套流程的心智负担还是太重了。我其实只是想验证一些变换公式,查看顶点变换或颜色变化等效果,而不是搭一个项目

试了一圈现成的在线 GLSL playground,要么功能停在能跑就行,要么稍微改点代码就出 bug,再要么就是必须登录或者收费的复杂产品。于是决定自己撸一个 playground ------ ShaderPad

目标

  • 「打开浏览器 30 秒内跑起一个 shader」:零登录、零配置、零下载,面向 Web 着色器学习和调试的极简 playground
  • 支持分享和快速复现 3d 场景
  • 提供可快速插入 mdx 文本里的 npm 包

在线体验 ,对应的 github 仓库

整体效果

整体页面结构跟大部分在线编辑器类似,左边是代码编辑区,提供了顶点着色器和片元着色器的编辑功能,会高亮 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 这个组件才是真正的交互孤岛。

底层原理上的优势:

  1. 极致的编译时剥离 :Astro 的渲染模式是 SSG。在 build 时它会把 .astro 组件全跑一遍,暴力剥离掉所有不需要在客户端执行的 JS 逻辑,只输出纯 HTML。
  2. 精准的按需水合 :因为 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 主站保持轻量,作为包的消费者,部署产物也更加干净。
bash 复制代码
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)

数据流

scss 复制代码
[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:

ts 复制代码
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 变量,如下:

glsl 复制代码
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(比如 cameraPositionmodelViewMatrix 等)。但在「教学和调试」场景下,这反而成了致命缺点------用户会很困惑:「我明明没声明这个变量,为什么它能跑?」

为了做到**「所见即所得」**,我最终选择了 RawShaderMaterial。它是一张白纸,不注入任何隐藏代码,用户写的 source 就是最终跑在 GPU 里的 GLSL。 这也意味着报错行号能做到 1:1 绝对对应,不会出现「明明只有 10 行代码,控制台却报第 150 行错误」的灵异事件。代价是用户必须在代码开头显式声明所需的内置矩阵:

glsl 复制代码
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 自己解析,把行号 / 列号 / 错误消息拆成结构体再浮条展示:

ts 复制代码
{ line: 5, column: 12, message: "'foo' : undeclared identifier" }

这样写 GLSL 时,看到的都是真实可定位的错误,而不是"WebGL: ERROR: 0:5"这种天书。

模块沉淀:从单一工具到通用的 npm 包

做完主站后我意识到------「实时编辑 + 实时预览」这套交互本身非常有价值,它不应该只局限在 ShaderPad 自己的网站里。如果能在任何 MDX 文档或技术博客里直接嵌入一个能跑的 Shader,阅读体验会呈指数级上升。

效果如图:

于是我把 Playground 抽成了一个独立的 npm 包:@lucascv/shaderpad-playground5 行代码就能嵌进任何 React 文档站。

bash 复制代码
pnpm add @lucascv/shaderpad-playground react three monaco-editor @monaco-editor/react
jsx 复制代码
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

封装过程踩到几个值得记一笔的点:

持久化代码

用户编辑过的代码要保留下来(下次打开还是他改过的版本),但如果作者改了文章的示例代码,旧草稿就会"幽灵生效"------看起来加载了,但内容是上一版的。

包里的做法是把源文件内容用 djb2 算一个短 hash,写进 localStorage key 里

ts 复制代码
// v2 key 格式:embed-test/pair-box:a3f9b1c2
function buildKey(storageKey, source) {
  return `${storageKey}:${shortHash(source)}`;
}

源文件一变 hash 就变,自动生成新 key,旧草稿自然绕过。这套机制上线后,文档示例库再迭代也没出过"代码不匹配"的玄学问题。

单 / 双着色器配置

最简的用法是只传一个 code 字段,跑单 stage。但有些场景(顶点动画、varying 传递)必须 vertex + fragment 联动才能跑起来,所以包内同时提供了 pair 配置:

jsx 复制代码
<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 里手动接管输出扩展名:

ts 复制代码
export default defineConfig({
  format: ["esm", "cjs"],
  outExtension({ format }) {
    // 强制把 ESM 产物后缀设为 .js(因为 type: module),CJS 产物后缀设为 .cjs
    return { js: format === "cjs" ? ".cjs" : ".js" };
  },
});

同时,package.json 里的 exports 字段必须严丝合缝地对齐

json 复制代码
"exports": {
  ".": {
    "types": "./dist/index.d.ts",
    "import": "./dist/index.js",     // ESM 消费者走这里
    "require": "./dist/index.cjs"    // CJS 消费者走这里
  }
}

只有当这两边绝对对齐,各种宿主框架在根据自身环境 importrequire 时,才能精准命中正确的模块,彻底消灭 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,然后新打开标签页粘贴体验

为什么是这套组合

bash 复制代码
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-deployments action 把 dist/ 推到服务器的 /var/www/shaderpad/dist/(与 NPM 静态资源目录保持一致)

总结与思考

以前我一直想做个在线工具,但总觉得市面上轮子已经够多了,加上开发和部署成本,迟迟没有动手。这次在 AI 的加持下(核心代码大量借助了 Minimax-M3 等大模型),极大地压缩了「从想法到上线」的周期。

ShaderPad 不仅让我调试和学习 glsl 代码更方便,也让我跑通了从「单体应用开发」到「通用组件抽离」,再到「自动化发版部署」的完整工程化闭环。

第一版先保持极简,后续如果大家觉得好用,会考虑扩展对 TSL 和 WGSL 的支持。欢迎来玩!

在线体验地址:ShaderPad ,对应 github 仓库

相关推荐
To_OC9 小时前
LC 51 N 皇后:我以为难的是回溯,结果栽在了对角线下标
javascript·算法·leetcode
不好听6139 小时前
从一行 JSX 到屏幕像素:前端开发者必须懂的浏览器渲染管线
前端
恒拓高科WorkPlus9 小时前
企业级内网即时通讯建设模型:BeeWorks内网IM四层架构
前端
前端小李子10 小时前
前端环境变量裸奔?我用 EnvShield 给它穿了件防弹衣
前端
youtootech10 小时前
HarmonyOS《柚兔学伴》项目实战25-我的页面、Web 嵌入与项目总结
前端·华为·harmonyos
小林ixn11 小时前
从零到一理解 React 父子组件通信:手写一个 Todo 应用带你彻底搞懂单向数据流
前端·javascript·react.js
醇氧12 小时前
CountDownLatch / CyclicBarrier / Semaphore 面试高频问答清单
前端·面试·职场和发展
窝子面13 小时前
手搓最简前后端协作-node
javascript·数据库