# dsh-shader-workbench 架构说明 面向要改这份代码的人。看完应该能回答三个问题: **数据怎么流、为什么这么设计、坑在哪里。** --- ## 1. 一句话架构 ``` DSH Host(Node) ├── 9 个 agent 工具 ──→ Workspace(文件服务 + 路径安全) └── 前缀路由 /plugins/dsh-shader-workbench/ ├── /api action 分发 ──→ Workspace / codegen ├── /texture 送纹理字节 └── /module/* 原样送 client/ 下的 ESM 源码 │ DSH Client(浏览器) │ import() ├── client/index.js(__ModuleLoader__ 载荷) │ └── slots 注册:侧栏一行 + main 一页 └── panel/ ──→ runtime/(零依赖,纯计算 + WebGL) ``` **核心取舍:面板不打包。** Host 把 `client/` 下的源码原样送出去, 浏览器用原生 ESM `import()` 加载。好处是零构建链、改完刷新就生效; 代价是面板代码不能用裸包名 import(只能相对路径 + 浏览器内置 API)。 --- ## 2. 为什么这么设计 ### 2.1 节点图是扁平的,Pass 只存 id ```js graph.nodes = [...] // 所有节点,扁平 graph.passes = [{ name: 'image', graph: ['n1', 'n2'] }] ``` 如果按 Pass 存节点副本,「同一个噪声函数被两个 Pass 用」就要存两份, 改一处忘一处是必然的。扁平存储 + id 引用没有同步问题。 代价:`compileGraph` 必须先按 Pass 过滤子图(`passSubgraph`)。 这个代价可以接受,因为过滤是 O(n) 的纯函数。 ### 2.2 codegen 是纯函数,不碰 WebGL `client/runtime/codegen.js` 只做字符串生成,不 import 任何 WebGL 相关的东西。 所以它能被 Node 直接 import,也就有了 17 个纯字符串单测。 **这条边界要守住**:一旦 codegen 开始碰 `gl`,就没法在无显卡环境单测了。 ### 2.3 渲染器「全成功才替换」 `ShaderToy.setGraph()` 先把所有 Pass 都生成 + 编译成功,才整体替换。 任何一个失败就保留上一份能跑的 program。 理由:用户在编辑器里改图时,中间状态**必然**编译不过。 如果边改边报错边黑屏,这个工具就没法用。 ### 2.4 参数改动不走防抖,结构改动走防抖 | 改动 | 路径 | 延迟 | |---|---|---| | 拖滑杆改 uniform 值 | `renderer.setUniform` → 下一帧生效 | 0 | | 改节点参数 / 连线 / 增删节点 | `HotReload.invalidate`(120ms 防抖)→ 重编译 | 120ms | uniform 值只是上传一个 float,不需要重编译;节点结构变了才需要。 所以调参手感是连续的,改结构会有一点点合并延迟。 ### 2.5 面板内容用原生 DOM,不用 React 元素树 工作台里全是 canvas、拖拽、WebGL 循环这类命令式状态。 如果让 React 管这些 DOM,每次 reconcile 都可能把 canvas 干掉、把 WebGL 上下文弄丢。 所以做法是:**最外层用 React 挂一个容器,里面全自己管**。 `client/index.js` 里的 `WorkbenchPanel` 只做两件事: 给一个 host 元素、在卸载时调 `disposeWorkbench()`。 顺带一个坑:React 的 ref 回调**不能直接返回 DOM 节点**。 箭头函数简写 `ref={node => setHost(node)}` 会返回 `setHost(...)` 的返回值, React 19 会把它当 cleanup 函数处理(React 18 只是告警)。 必须写成带花括号的语句体。 --- ## 3. 数据流 ### 3.1 编辑器改图 ``` editor._emit(reason) → workbench._onGraphChange(graph, meta) ├─ meta.reason === 'move-node' ? 只更新状态栏,return ├─ params.render() ├─ hotreload.invalidate(graph, { uniforms }) └─ _updateStatus() → HotReload(120ms 防抖) → renderer.setGraph(graph, { uniforms }) → 每个 Pass:compileGraph() → setPass() → 全成功才替换 → 事件 compile:done / compile:failed → workbench 记日志 + 更新代码视图 + 状态栏 ``` ### 3.2 面板读写磁盘 ``` panel.resources.reload() → fetch POST /api { action: 'workspace.overview' } → routes.handleApi → ACTIONS[action] → workspace.overview() → 拿回 JSON → 重建资源树 ``` 面板本身**没有任何文件系统权限**,所有路径都经过 `Workspace.path()` 的 前缀校验。这是安全模型的基础。 ### 3.3 端口缺省值的两层结构 节点库里定义的端口缺省值(`port.def`)是**只读模板**。 用户改的是节点实例上的一份覆盖表 `node.paramDefaults`。 ``` 生成时: edge ? 上游表达式 : node.paramDefaults[portId] ?? port.def ``` 面板里改它时先存进 `node.params.__defaults`(UI 临时态), 存盘前 `_withPortDefaults()` 把它搬到 `node.paramDefaults`。 **为什么不直接改 `node.params[portId]`**:`params` 是节点定义参数的命名空间, 塞端口缺省值进去会和同名参数打架。 --- ## 4. 安全模型 三道防线,任何一道单独都不够: 1. **名字白名单**(`assertSafeName`) 拒绝 `\ / : * ? " < > |`、控制字符、`.`、`..`、Windows 保留名(CON/PRN/…)、超长。 2. **路径前缀校验**(`Workspace.path()`) 解析成绝对路径后,必须 `=== root` 或 `startsWith(root + sep)`。 这是唯一允许生成写路径的入口。 3. **HTTP 同源校验**(`isSameOrigin`) 带 Origin 且 host 不匹配的请求一律 403。 另外: - 请求体上限 256 KiB。 - `/module/` 只送 `.js` / `.mjs`,且路径解析后必须仍在 `client/` 内。 - 导入纹理时**只读源文件、绝不在源路径上写**;目标路径仍走第 2 道防线。 - 归档材质是 `rename` 到 `.trash/`,不是删除。 - 只读模式(`config.readOnly`)下所有写操作被 `assertWritable()` 拒绝。 --- ## 5. 坑清单(都踩过,别重踩) ### 5.1 GLSL | 坑 | 现象 | 修法 | |---|---|---| | 缺 `#version 300 es` | `'out' : storage qualifier supported in GLSL ES 3.00 and above only` | 必须是文件第一行,前面不能有空行 | | uniform 带初始化器 | `cannot initialize this type of qualifier` | ES 3.00 不允许,声明必须裸的 | | 重载返回类型不一致 | `function must have the same return type in all of its declarations` | `float st_pow` 与 `vec4 st_pow` 不能共存 | | 常量表达式与声明类型不符 | `dimension mismatch` | `const` 节点按**输出端口类型**重新序列化值 | | 表达式参数不递归展开 | `'$' : invalid character` | `$p.expr` 里可能还是 `$in.a`,要递归替换 | | vec3 → vec4 alpha 补 0 | 画面全透明 | 第 4 位补 1.0 | ### 5.2 DSH / 沙箱 | 坑 | 现象 | 修法 | |---|---|---| | **模块写了 `export default`** | **模块 import 了(boot log 有 `module-loaded`)、`apply()` 不跑;即使跑了,一碰 `ctx.tools` 就抛 `cannot get property "tools" without inject`,而且只写进宿主的 stderr** | `@deepseek-ai/cordis-plugin-include` 装载写的是 `data = module.default \|\| module`(`lib/index.js:157`):有 default 就只剩裸函数,`inject` / `Config` / `name` 全丢。**删掉 default 导出**。对照:能正常工作的 `dsh-image-gen` 没有 default | | **`Config` 写成裸 JSON Schema** | 同上(`module-loaded` 有、`apply` 不跑、不报错);宿主的 `resolveConfig` 抛 `TypeError` | Cordis 激活 fiber 时无条件调 `Config['~standard'].validate(config)`。改成手写最小 Standard Schema(不引 schemastery:插件用 `link:` 装在 profile 之外,从插件目录解析不到 `@deepseek-ai/schemastery`)。**只在修好上一条、宿主真读到 Config 之后才会咬人 —— 两条都得修** | | **侧栏图标返回原生 DOM 节点** | 渲染器抛 `Minified React error #31 ... [object SVGSVGElement]`,侧栏渲染整块炸掉 | 侧栏槽位把组件返回值当 React child 用(`renderSlot('sidebar.panellist', {size, active})`)。**必须返回 React 元素**,用 `React.createElement` 建 svg | | **webServer 前缀注册时带了尾斜杠** | boot log 一切正常(`route:"ok"`、工具全注册),但 `/plugins/<插件>/…` **全是 404**;面板点开就 `Failed to fetch dynamically imported module` | 宿主匹配规则是 `pathname !== prefix && !pathname.startsWith(prefix + '/')` —— **它自己会补 `/`**。注册成 `/plugins/x/` 就变成比 `/plugins/x//`,永远为假。注册用不带斜杠的 `ROUTE_PREFIX`,处理器内部切路径仍用带斜杠的 `API_PREFIX`,两个常量别合并 | | 工具缺 `output` 契约 | 整条插件 failed,一个工具都注册不上 | 每个工具都要 `output: { schema, render }` | | 面板 ESM 不能用裸包名 | 浏览器解析失败 | 只能相对路径 import | | `/module/` 只放行 `client/` | 面板 import `../../shared/*.js` 全 404、白屏 | 白名单加 `shared/` 与插件根;URL 层级与磁盘深度对不上,所以要把 `module/` 后的**整条路径**规范化 | | 改了函数导出忘了同步 | `mod.Workbench is not a constructor` | 动态 import 改造后要回头补再导出 | | ref 回调返回 DOM | React 19 当成 cleanup 函数 | 用带花括号的语句体箭头函数 | | `node --test tests/` | 沙箱下 `spawn EPERM` | 改成同进程顺序 import(`scripts/run-tests.mjs`) | | 浏览器用户数据目录放 %TEMP% | 沙箱写不了,Edge 直接崩(0x80000003) | 放工作区内 `.cache/` | | 捕获子进程输出 | 沙箱下带管道 stdio 的 `spawn` 会 EPERM | 用 `stdio: 'ignore'` + 固定调试端口 + HTTP 探测 CDP | | 受限沙箱下 Edge 的子进程被降权 | `OpenProcess: 拒绝访问` + `mojo platform_channel` FATAL | 环境限制,检测到就跳过真机校验 | | 量 CPU 侧 `render()` 判定帧率 | WebGL draw 是异步的,只有 0.03 ms,等于没量 | 用 `EXT_disjoint_timer_query_webgl2` 量 GPU 侧真实耗时 | ### 5.3 产品 bug:编译得过、单测过得去,但功能是坏的 这三个都是真拖一次 / 真读一次像素才暴露的。 | 坑 | 现象 | 修法 | |---|---|---| | **`_canDrop` 读已被清空的 `this.drag`** | **手动拖线从来没成功过**——编译过、单测过、内置模板也正常(模板绕过检查直接连) | `_onPointerUp` 一进来就 `this.drag = null`,所以 `_canDrop(hit)` 里读 `this.drag` 永远是 null、永远返回 false。改成把 drag 当参数显式传进去 | | **`canConnect` 比生成器更严** | `uv`(vec2) → `output`(vec4) 这种最基本的连法在 UI 上被拒;而示例模板自己却在连 | **编辑器的规则不能比生成器严。** 生成器 `castExpr` 对 16 种组合都有确定规则,所以改成全放行;有损性交给 `conversionKind` 在状态栏提示 | | `setChannel` 换源只丢记录不删纹理 | GPU 显存泄漏,换几轮图积一堆 | 换源时先 `deleteTexture` 上一张 | ### 5.4 测试自身的坑(写测试时踩的) | 坑 | 现象 | 修法 | |---|---|---| | 端口坐标在**拖节点之前**取 | 拖完再用旧坐标按下 → 打在被拖空的画布上,走了框选 | 每次用之前现取(`portPosition` 的结果随节点位置失效) | | 只切 program 不 draw 就读像素 | 读回 `(0,0,0,0)`,看起来像「纹理没绑上」 | `readPixels` 读的是后备缓冲,**必须先 `render()` 再读** | | 多 Pass 只渲一帧就读 Buffer 反馈 | 读到空内容(Buffer 是「上一帧」语义) | 至少渲两帧;测试里渲 3 帧 | | `node --test tests/`(传目录) | `MODULE_NOT_FOUND`:Node 24 把它当文件 require | 传 glob:`node --test "tests/*.test.js"` | ### 5.5 数据 | 坑 | 现象 | 修法 | |---|---|---| | 一个输入端口连多条线 | 生成器只认第一条 | `normalizeGraph` 按「上游.端口→下游.端口」去重,来源不同都保留;生成器 `find` 拿第一条。编辑器连线时会先删同目标的老线,所以 UI 上不会积 | | 纹理存绝对路径 | 换机器就烂 | 一律存相对材质目录的 POSIX 路径 | | 面板创建材质传 `name`、工具传 `material` | 「材质名不能为空」 | `createMaterial` 两个键都接受 | | `material.list` 返回 `{materials:[...]}` 被当裸数组用 | 资源树显示 `undefined`、项目不展开 | 取 `.materials` | | 空图被当成编译错误 | 新建材质一打开就是红框报错 | 区分 `empty-pass` 错误码,显示灰色提示 | --- ## 6. 测试策略 ``` tests/ ├── helpers/browser.mjs 共用的真浏览器夹具(起服务 / 驱动 CDP / 读像素) ├── codegen.test.js 21 例 纯字符串:生成器正确性、类型转换、连接规则一致性、错误码 ├── workspace.test.js 10 例 真文件系统:路径安全、项目/材质/快照/纹理全流程 ├── http.test.js 14 例 真 HTTP 服务:action 分发、错误码、同源、纹理字节、模块送发 ├── plugin-load.test.js 10 例 假 Cordis context:apply() 注册形状 + 工具真执行 ├── module-graph.test.js 6 例 真 import:面板模块能被解析 + 导出契约 + __ModuleLoader__ 载荷 ├── glsl-compile.test.js 4 例 真 WebGL2:50 个节点的 GLSL 全部编译通过 ├── panel-smoke.test.js 2 例 真浏览器:面板整块挂起来、打开材质、编译、渲染 ├── render-gpu.test.js 1 例 真浏览器:**读像素**验证 iChannel0-3 / 多 Pass / iMouse ├── editor-interaction.test.js 1 例 真浏览器:**真鼠标拖拽**验证移动节点与连线 └── perf-1080p.test.js 1 例 真浏览器:1080p 帧率、GPU 单帧耗时、隐藏时空转 ``` **为什么要真机编译**:Node 里编译不了 GLSL,纯字符串断言挡不住语法错误。 第 5.1 节那 6 个坑里有 5 个是**只有真机编译才能发现**的。 **为什么要真浏览器冒烟**:`module-graph` 能证明模块可解析,但证明不了 「面板挂载后 DOM 建起来了」「WebGL2 上下文拿到了」「打开材质能走通 HTTP → 编译 → 渲染」。 `panel-smoke` 抓到了 5 个只在浏览器里暴露的 bug。 **为什么要读像素**:编译通过 ≠ 画得对。`render-gpu` 用 `gl.readPixels` 把像素读回来对值, 覆盖了「纹理到底有没有绑上」「兜底纹理走没走」「Buffer 反馈拿的是不是上一帧」这类 **没有报错但结果错**的问题——这类 bug 编译器和控制台都看不见。 **为什么要真鼠标拖拽**:`editor-interaction` 用 CDP 的 `Input.dispatchMouseEvent` 走真输入通道,抓到过一个**致命 bug**:`_onPointerUp` 先把 `this.drag` 清空, 紧接着 `_canDrop()` 又去读 `this.drag` → 永远返回 false → **手动连线从来没成功过**。 这个 bug 编译得过、单测过得去、模板建图看不出来,只有真拖一次才暴露。 **为什么不能只量 CPU**:WebGL 的 draw 是异步的,CPU 侧的 `render()` 只有 0.03 ms——量它等于什么都没量。真数据得靠 `EXT_disjoint_timer_query_webgl2`。 跑法: ```bash node --test "tests/*.test.js" # 主入口(Node 原生测试运行器) node scripts/run-tests.mjs # 沙箱兜底:同进程顺序 import,不派生子进程 node tests/codegen.test.js # 单个文件也能直接跑 ``` > **两种 runner 都要留着。** `node --test` 会为每个测试文件**派生子进程**, > 在受限的文件沙箱下这个 spawn 会被拒(EPERM);`run-tests.mjs` 改成同进程 > 顺序 import,同一个测试集在受限环境下也能跑。 > `package.json` 里对应 `test` 与 `test:sandbox` 两个 script。 注意 `node --test` 传**目录**不工作(Node 24 会把它当文件 require,报 MODULE_NOT_FOUND), 要用 glob:`node --test "tests/*.test.js"`。 当前:**70 个用例全绿**(两种 runner 都是)。 三个真浏览器测试在受限沙箱下会**自动跳过**(而不是假失败)—— `isEnvironmentBlocked()` 识别 0x80000003 / EPERM / 拒绝访问这几种环境性错误。 --- ## 7. 扩展点 ### 7.1 加一个节点 **先别急着改源码。** 加节点有三条路,代价从低到高,按这个顺序选: 1. **用户节点**(推荐):往 `<工作区>/library/nodes/*.json` 丢一个文件。 宿主按「文件名 + mtime + size」签名判断要不要重读,面板点节点库右上角的 **↻** 同步 —— **不重启、不碰源码**。格式见 [`接口契约.md` 4.2](接口契约.md)。 2. **打成组**:现有节点能搭出来的效果,画布上搭好 → 框选 → 右键「打成 Group」。 组是元节点,子图内联在节点里,生成时展开 —— 同样不碰源码。 3. **内置节点**:只有"要新增 GLSL 函数、而且要当基础件反复用"时才走这条。 改完 `shared/nodelib.js` **也不用重启**:↻(或 `nodelib.reload {builtin:true}`)会带查询串重新 import 这个文件拿一份新副本, 再原地重建注册表(`shared/nodes.js` 的三个导出是同一批对象、原地更新, 所以宿主工具 / 生成器 / 面板都会自动看到)。 > **判据:这个节点别人用得上吗?** 只有某一个效果用得上 → 走第 1 条(用户节点); > 多个效果、多种场景都用得上(uv / add / mix / custom 这种)→ 才进内置库。 > 内置库现在 54 个,全是通用件。`ifsTunnel` / `fourierSeries` / `epicycles` 先后做过 > 内置节点和示例自带的用户节点,**两次都是黑盒**(循环藏在 `glslPrelude` 里,展不开)。 > 现在它们被**展开成普通节点**了,见 > [`scripts/rebuild-primitive-demos.mjs`](../scripts/rebuild-primitive-demos.mjs)。 > > 教训:判据不只是「谁用得上」,还有**「展开之后看不看得懂」** —— > 展不开的节点,放哪儿都是黑盒。 > > 还有一条同样重要的:**重复是语言结构,不是复制节点**。把循环展开成 N 份复制, > 输出确实一致,但 99 份就是 676 KB 着色器 + 2 MB 图 + codegen 352 ms, > 面板一打开就卡(这条是被用户投诉出来的)。组的 `repeat > 1` 就是循环: > 循环体只写一遍、外面套 `for`,跨迭代的量走 `carries`、轮次用 `loopIndex` 节点拿。 > 现在的 IFS 隧道和贝塞尔都是这么搭的,输出和复制版**逐像素一致**,图小了 80 倍。 > > 为什么以前必须重启:那三个导出是 `Object.freeze` 的模块级常量,只在首次 > import 时算一次;面板经 `/module/` 从磁盘现拉所以刷新就生效,宿主是静态 > import、进程活着就永远是旧的 —— 两边不对称。**别把注册表改回冻结/重新赋值。** 第 3 条路的写法:在 `shared/nodelib.js` 里加一条 `defineNode({...})`: ```js defineNode({ type: 'checker', title: '棋盘格', category: '程序化', inputs: [v2('uv', 'uv'), f('scale', '格子数', 8)], outputs: [f('out', 'value')], params: {}, code: 'st_mod(floor($in.uv.x * $in.scale) + floor($in.uv.y * $in.scale), 2.0)', helpers: ['st_mod'], }) ``` 不用改编辑器、不用改参数面板、不用改生成器——它们都从节点库读。 需要新的 GLSL 函数就往 `shared/glsl-helpers.js` 的 `GLSL_HELPERS` 加, 并在 `HELPER_DEPS` 里登记依赖;生成器会做依赖闭包裁剪。 #### 节点要带循环 / 多条语句怎么办:用 `glslPrelude` **生成器给每个节点写的是 `vec4 x = <表达式>;` —— 单条表达式。** `shared/nodes.js` 顶部那条「`code` 必须是单个 GLSL 表达式」的约定不是建议,是硬约束: 节点里的 `code` 会被原样拼进赋值右边,写 `for` 进去直接语法错误。 所以节点定义还有第二个字段:`glslPrelude`(一段完整的 GLSL,可以含函数定义)。 它在 `main()` **之前**注入,按**节点类型去重**,且只在用到这个节点时才进 shader: ```js defineNode({ type: 'ifsTunnel', inputs: [v2('uv', 'uv'), f('time', 'time', 0)], outputs: [v4('out', 'color')], params: { steps: { type: 'int', value: 99, min: 1, max: 128, label: '累积步数' } }, glslPrelude: `float st_ifsField(vec3 p) { for (int i = 0; i < 8; i++) { ... } } vec4 st_ifsTunnel(vec2 uv, float t, int steps) { for (int i = 0; i < 128; i++) { if (i >= steps) break; ... } }`, code: 'st_ifsTunnel($in.uv, $in.time, $p.steps)', }) ``` 三条硬规矩(`tests/node-prelude.test.js` 守着): 1. **prelude 是静态的**,不随参数变——要变的东西必须做成函数参数。所以循环上界 写成常量(`< 128`),真实步数用 `if (i >= steps) break;` 控制。WebGL2/ANGLE 上常量上界最稳,别指望驱动帮你动态展开。 2. **同一类型出现多个节点只会注入一份**——否则 GLSL 函数重定义,编译直接炸。 3. 函数名一律 `st_` 前缀,和内置 helper、和 GLSL 内建都别撞。 `ifsTunnel`([Phantom Star 移植](https://www.shadertoy.com/view/ttKGDt))是第一个这么做的节点。 ### 7.2 加一个 API action 在 `host/routes.js` 的 `ACTIONS` 表加一条,`(args, ctx) => Promise`。 抛错会被自动转成 `{ ok: false, error: { code, message } }`。 ### 7.3 加一个导出目标 `shader_export` 工具里 `target` 目前只认 `glsl` / `json`。 扩展点在这里:拿着 `compileGraph` 的结果去喂跨编译器。 ### 7.4 接 ComfyUI(M4) 已预留的位置: - `MaterialManifest.comfyWorkflows` - `EVENT_TYPES` 里的 `comfy:started` / `comfy:done` - `library/comfy-workflows/` 目录 - `texture.import` 已支持 `source: 'comfyui'` 标记来源 需要新加的:一个 `ComfyAdapter`(REST 调用 + 轮询 + 结果落盘) 和 `comfy.run` action。 --- ## 8. 性能 - 全屏三角形不用 VBO,零顶点数据上传。 - Buffer Pass 用 RGBA16F 双缓冲,只在尺寸变化时重建。 - `document.hidden` 时跳过渲染(满足「隐藏时 CPU < 1%」)。 - 分辨率倍率可调(0.5× / 1× / 1.5× / 2×)。 - 热重载防抖 120ms;一次真实重载通常 1–5 ms。 - `requestAnimationFrame` 单循环管两个 canvas(全屏 shader 与材质网格互斥)。