# dsh-shader-workbench 接口契约 > 本文件是**唯一事实来源**。任何模块实现都必须严格符合本文件。 > 发现契约有误,先在本文件里指出并说明理由,再改代码。 - 版本:v1(已冻结) - 插件名:`dsh-shader-workbench` - 目标 DSH:`>= 0.2.0-rc.2`(实测 0.2.0-rc.2) - Node:v24.14.0 - 路由前缀:`/plugins/dsh-shader-workbench/` - 覆盖范围:M0–M3(脚手架 / 渲染闭环 / 节点编辑器 / 资源管理) - 未覆盖:M4 ComfyUI 集成、M5 跨平台编译(HLSL/MSL/SPIR-V)——**接口已预留,实现留空** --- ## 0. 硬性约束(违反即返工) 1. **零外部运行时依赖**。`package.json` 的 `dependencies` 必须为空。 全部只用 Node 内置模块 + 浏览器内置 API。 2. **不引入构建链**。手写 ESM JavaScript,没有 TypeScript、没有打包器。 Host 侧靠 Node 直接 import;Client 侧靠 Host 原样送出源码,浏览器原生 `import()`。 3. **ESM only**。`"type": "module"`。 4. **所有文件操作走 Host**。面板自己绝不碰磁盘,一律通过 HTTP API。 5. **路径必须做前缀校验**,不是「过滤特殊字符」。 `Workspace.path()` 是唯一允许生成写路径的入口。 6. **永远不删除文件**。删材质是 `rename` 到 `.trash/`。 7. 核心 runtime(`client/runtime/`)不依赖 React、不依赖任何第三方库, 必须能被 Node 直接 import 做单测(所以里面不能出现 `window` / `document` 的顶层引用)。 --- ## 1. 目录归属 ``` dsh-shader-workbench/ ├── package.json 插件清单(dsh.bundle.patch + dsh.client.inject) ├── cordis.patch.yml Cordis bundle patch(insert 一行 id=shader-workbench) ├── host/ │ ├── index.js Cordis 插件主体:9 个工具 + 路由注册 │ ├── workspace.js 工作区文件服务(路径安全 + 项目/材质/纹理/快照) │ └── routes.js HTTP API:action 分发 + 纹理读取 + 模块送发 ├── shared/ │ ├── graph.js ShaderGraph 数据模型 + normalizeGraph + 端口/节点工厂 │ ├── manifest.js MaterialManifest + TextureRef │ ├── events.js 极简事件总线(两端共用) │ ├── nodelib.js 节点库数据表(只放数据) │ ├── glsl-helpers.js 内置 GLSL 辅助函数库 + 依赖闭包 │ └── nodes.js 节点库访问器(getNodeDef / resolvePortType) ├── client/ │ ├── index.js DSH 客户端插件入口(window.__ModuleLoader__ 载荷) │ ├── runtime/ 零依赖渲染核心 │ │ ├── codegen.js 节点图 → GLSL(纯函数) │ │ ├── shadertoy.js WebGL2 多 Pass Shadertoy 兼容渲染器 │ │ ├── hotreload.js 防抖热重载控制器 │ │ └── material-preview.js 球/盒/环网格材质预览 │ └── panel/ 工作台面板(DOM 自管,不用 React 元素树) │ ├── dom.js DOM 构造小工具 │ ├── style.js 样式表字符串 │ ├── editor.js canvas 自绘节点编辑器 │ ├── params.js 参数面板 │ ├── resources.js 资源浏览器 │ ├── workbench.js 主控制器 │ └── index.js 面板入口(挂载/卸载 + 样式注入) ├── scripts/run-tests.mjs 总测试入口 ├── tests/ 6 个测试文件,61 个用例 └── docs/ 本文件 + 架构说明 + 验收报告 ``` --- ## 2. 数据模型 ### 2.1 ShaderGraph ```ts interface ShaderGraph { id: string name: string version: 1 // GRAPH_FORMAT_VERSION nodes: GraphNode[] edges: GraphEdge[] uniforms: UniformDef[] passes: { name: PassName; graph: string[] | null }[] metadata: { version: string; tags: string[] } } type PassName = 'image' | 'BufferA' | 'BufferB' | 'BufferC' | 'BufferD' ``` **关键设计:节点是扁平的,Pass 只持有节点 id 列表。** 这样同一个节点可以被多个 Pass 引用,也避免了「同一个节点存两份」的同步问题。 ```ts interface GraphNode { id: string type: string // 节点库里的 type x: number; y: number // 画布坐标 params: Record paramDefaults?: Record // 未接线输入的端口缺省值覆盖表 annotation?: string } interface GraphEdge { id: string from: { node: string; port: string } // 上游输出端口 to: { node: string; port: string } // 下游输入端口 } interface UniformDef { name: string // 必须匹配 /^[A-Za-z_][A-Za-z0-9_]*$/ type: 'float' | 'vec2' | 'vec3' | 'vec4' value: number | number[] min?: number; max?: number // 滑杆范围 label?: string ui?: 'slider' | 'color' | 'angle' } ``` ### 2.2 MaterialManifest ```ts interface MaterialManifest { id: string name: string graphPath: string // 固定 'graph.json' shaderFiles: { frag: string; vert?: string } uniforms: Record // 参数快照 textures: TextureRef[] passes: PassName[] comfyWorkflows?: string[] // M4 预留,本版恒为空 tags: string[] updatedAt?: string // ISO 8601 } interface TextureRef { slot: 'iChannel0' | 'iChannel1' | 'iChannel2' | 'iChannel3' path: string // **相对材质目录**,POSIX 分隔符,绝不存绝对路径 type: 'image' | 'dds' | 'video' source: 'local' | 'comfyui' label?: string importedAt?: string } ``` `path` 存相对路径是硬规定:换机器、换盘符都不会烂。 ### 2.3 端口类型与连接规则 `float | vec2 | vec3 | vec4`。 **连接规则(`canConnect`):任意两种合法类型都能连,4×4 全放行。** > 这条踩过坑。v1 的规则是「同类型可连,float 与 vecN 之间可连」, > 理由是「只有这些组合有意义」。但生成器的 `castExpr` 对 **16 种组合全都有确定规则**, > 于是出现了两个后果: > 1. `uv`(vec2) → `output`(vec4) 这种最基本的连法在 UI 上被拒绝, > 用户想直连都没门,只能插一个「合成 vec4」节点; > 2. 内置示例模板走 `_link()` 绕过了检查直接连 —— **UI 拦的连线,模板自己却在连**。 > > **结论:编辑器的规则不能比生成器更严。** 有损与否由 `conversionKind` 描述, > UI 该提示就提示(状态栏显示「会丢弃多余分量」),但不该阻止。 转换规则(生成器 `castExpr` 负责实现): | 方向 | `conversionKind` | 生成的代码 | |---|---|---| | 同类型 | `exact` | `x` | | float → vecN | `broadcast` | `vecN(x)` 广播 | | vecN → vecM(M>N) | `extend` | `vec4(x.x, x.y, 0.0, 1.0)` 补位 | | vecM → vecN(N/ ├── projects// │ ├── project.json { name, title, description, createdAt, updatedAt, materials[] } │ ├── .trash/ 归档的材质(不删除,只移动) │ └── materials// │ ├── material.json MaterialManifest │ ├── graph.json ShaderGraph │ ├── .frag 导出的 GLSL(shader_export 写) │ ├── ..bundle.json 导出包 │ ├── textures/ 纹理文件 │ └── .snapshots/[_label]/ graph.json + material.json 的副本 ├── library/{nodes,snippets,presets,comfy-workflows}/ └── cache/{compiled,previews}/ ``` 工作区根目录解析优先级: 1. `config.workspaceRoot`(profile 的 `cordis.patch.yml`) 2. 环境变量 `DSH_SHADER_WORKBENCH_ROOT` 3. **`/shader-workbench.json` 里的 `workspaceRoot`** ← 面板「存储位置」写的就是这个 4. Session 工作目录 `/shader-workbench`(仅工具;没有会话上下文的面板跟着"最近一次工具解析出的根"走) 5. 进程 cwd `/shader-workbench` 第 3 条是**面板和 agent 共用**的那一层。这不是可有可无的糖:没有它的时候, 工具(有会话上下文)和面板(走 HTTP,没有会话上下文)会算出**两个不同的根** —— agent 把材质写进 A,用户打开面板看到的是 B(空的),资源树永远空、一堆按钮点了没反应。 面板会在资源树顶上显示当前用的是哪个根,并在被第 1/2 条压过时明确标出来。 ### 3.1 插件设置文件 `/shader-workbench.json`(测试可用 `DSH_SHADER_WORKBENCH_SETTINGS` 覆盖): ```json { "workspaceRoot": "D:\\Projects\\DSHP\\shader-workbench", "readOnly": false } ``` - 两个键都可以缺;读不到、坏了、权限不够,一律当"没有设置"(绝不让读设置把插件带崩)。 - 写入走"临时文件 + 改名",不会留下半截 JSON。 - `workspaceRoot` 必须是绝对路径;相对路径直接拒绝(面板进程的 cwd 不是用户的 cwd,猜出来的位置只会更让人困惑)。 - 启动日志的落点(`shader-workbench-boot.log`)在进程内**只解析一次**,跑起来之后不再变。 --- ## 4. 节点库契约 节点定义(`shared/nodelib.js`): ```ts interface NodeDef { type: string title: string // 中文显示名 category: '输入'|'数学'|'向量'|'颜色'|'程序化'|'纹理'|'自定义'|'输出' doc?: string inputs: PortDef[] outputs: PortDef[] params: Record code: string // **单个 GLSL 表达式**,禁止分号、禁止语句序列 helpers?: string[] // 需要哪些 GLSL_HELPERS glslPrelude?: string // 节点自带的函数定义 dynamicOut?: (params, ports) => PortDef[] // 输出类型随参数变化(const / uniform) terminal?: true // 终点节点(output),一个 Pass 只能有一个 split?: true // 多输出节点,各输出映射到输入的分量 textureSample?: 'channel' | 'buffer' } interface PortDef { id: string; type: PortType; label: string; def?: any } interface ParamDef { type: ParamType; value: any; label?: string; min?: number; max?: number; options?: string[] } ``` 表达式占位符: - `$in.<端口id>` → 该输入接上的表达式;没接线时用 `port.def`, 其次用节点实例上的 `node.paramDefaults[端口id]`。 - `$p.<参数名>` → 参数值按类型序列化成 GLSL 字面量。 **text 类型会递归展开**——`custom` 节点的表达式本身就是 `$in.a` 这种模板。 当前节点数:**54 个**(内置,只放通用件;效果专用节点随示例走,见 4.2)。 ### 4.1 节点注册表(运行时可变) `shared/nodes.js` 的 `NODE_LIBRARY` / `NODE_BY_TYPE` / `NODE_CATEGORIES` 是**同一批对象、原地重建**的(不是 `export let`,也不冻结): - 引用永远不变 → 所有已 import 的模块(宿主工具、生成器、面板)自动看到新节点; - 内容会被 `setBuiltinNodes` / `setUserNodes` 改写,`nodeLibraryVersion()` 递增。 **别把它们改成重新赋值或重新冻结** —— 那会让"加节点要重启"这个坑回来 (宿主是静态 import,进程活着就永远拿着旧的那一份)。 取节点定义用 `getNodeDef(type)`;取端口(含动态端口)用 `resolvePorts(node, dir)` / `resolvePortType(node, dir, portId)`。 ### 4.2 用户节点(`<工作区>/library/nodes/*.json`) 字段与内置节点一一对应:`type / title / category / doc / inputs / outputs / params / glslPrelude / code / defaults`。校验规则(`shared/user-nodes.js`): - `type` 必须是标识符,且**不能和内置节点重名**(重名跳过并记进诊断); - `code` 里**不许有分号**(生成器写的是 `vec4 x = <表达式>;`,多语句请用 `glslPrelude`); - 端口类型必须属于 `PORT_TYPES`,参数类型必须属于 `PARAM_TYPES`; - 单个文件写坏只影响它自己,错误带文件名回给 `nodelib.status.errors`。 宿主按「文件名 + mtime + size」签名判断要不要重读;面板用 `nodelib.status.userDefs` 在浏览器侧调 `setUserNodes()` 对齐。**都不需要重启。** **效果专用节点随示例走。** 只服务某一个效果的节点(`ifsTunnel` / `fourierSeries` / `epicycles`)不该进内置库 —— 它们放在 `examples/<示例>/library/nodes/*.json`, `example.load` 会连项目一起装进 `<工作区>/library/`(返回值的 `library[]` 列出装了哪些)。 同一个节点被两个示例各带一份是允许的,按 `.json` 落文件,本来就是同一份。 ### 4.3 组(Group)元节点 | 类型 | 端口 | 说明 | |---|---|---| | `group` | **动态**:来自 `params.graph.inputs/outputs` | 画布上的那颗"打包好的节点";`params.repeat > 1` 时是**循环** | | `loopIndex` | 出 out(float) | 重复组循环体里的当前轮次(从 0 起);放在循环体外报 `loop-index-outside` | | `groupInput` | 出 out | 子图内部的入口,`params.port` 对应组的一个输入端口 | | `groupOutput` | 入 color | 子图内部的出口,`params.port` 对应组的一个输出端口 | `params.graph` 的形状: ```json { "name": "…", "inputs": [{ "id", "label", "type" }], "outputs": [{ "id", "label", "type" }], "nodes": [ …子图节点… ], "edges": [{ "from": { "node", "port" }, "to": { "node", "port" } }] } ``` **子图是内联的**,不是引用外部文件:材质自包含,宿主与浏览器两侧的生成器 各自递归展开即可,不需要共享解析器;改模板也不会影响已有实例。 `repeat > 1` 时是**循环**(重复是语言结构,不是复制): ```json { "repeat": 99, "carries": ["dist", "acc", "acc2"], // 跨迭代传递的量(默认取 inputs ∩ outputs) "inputs": [{ "id": "ray" }, { "id": "dist" }, …], "outputs": [{ "id": "dist" }, …] } ``` 生成的样子:carry 变量在循环外声明(初值 = 该输入端口接的外层表达式), `for (int i = 0; i < 99; i++) { <循环体>; carry = <轮末值>; }`,组的输出 = carry 变量。 循环体里放 `loopIndex` 节点即可拿到轮次,用来把"第几段"写成公式 (贝塞尔的 24 段就是这么做的,不用复制 24 份)。 重复组**不能「拆开组」**(拆开就丢循环了):把「重复次数」改成 1 再拆。 生成器的展开规则(`codegen.js` 的 `buildGroup`):把子图节点/连线的 id 加上 `<组节点id>::` 前缀并进当前 Emitter,子图里的 `groupInput` 表达式由外层输入代入, 组的输出端口取对应 `groupOutput` 的 `color` 输入。嵌套上限 8 层。错误码: | code | 场景 | |---|---| | `empty-group` | 组还没有内容(刚拖出来还没打包) | | `unknown-group-port` | 外层连到了组没声明的端口 | | `group-output-missing` | 声明了输出端口,但子图里没有对应的 `groupOutput` | | `group-too-deep` | 嵌套 > 8 层 | | `group-input-outside` | `groupInput` 被放到了组外面 | --- ## 5. 代码生成契约 `compileGraph(graph, passName, opts)` → ```ts { ok: boolean code: string // 完整可编译的 GLSL ES 3.00 片元着色器 body: string // void main() { ... } header: string // #version + precision + uniform 声明 helpers: string[] // 用到的 helper 名(依赖闭包已展开) channels: string[] // 用到的 iChannel buffers: string[] // 用到的 Buffer 采样 uniforms: { name, type, init, inferred? }[] errors: { code, message, pass? }[] warnings: string[] } ``` 生成的着色器形态: ```glsl #version 300 es // 必须是第一行,前面不能有空行 precision highp float; precision highp int; uniform vec3 iResolution; uniform float iTime; uniform float iTimeDelta; uniform int iFrame; // 仅 Buffer Pass uniform vec4 iMouse; uniform vec4 iDate; uniform sampler2D iChannel0; // 仅用到时声明 uniform sampler2D BufferA; // 仅用到时声明 uniform float uSpeed; // 不带初始化器!ES 3.00 不允许 out vec4 st_fragColor; float st_hash(vec2 p) { ... } // 仅用到的 helper void main() { vec2 n_uv0 = gl_FragCoord.xy / iResolution.xy; st_fragColor = vec4(...); } ``` **四条踩过的坑,写进契约:** 1. `#version 300 es` 必须是文件第一行。缺了编译器退回 ES 1.00, `in/out`、`texture()` 全部报错。 2. **GLSL ES 3.00 的 uniform 声明不能带初始化器**, 写了报 `cannot initialize this type of qualifier`。 3. `const` / `uniform` 这类「表达式就是 `$p.x`」的节点, 必须按**输出端口类型**重新序列化值——否则 `float x = vec3(...)` 编译不过。 4. 函数重载的返回类型必须一致,不能把 `float st_pow` 和 `vec4 st_pow` 混在一个重载集里。 错误码: | code | 含义 | |---|---| | `empty-pass` | Pass 里没有节点 | | `no-output` | Pass 里没有 output 节点 | | `cycle` | 节点图成环 | | `unknown-node` | 不认识的节点 type | | `missing-node` | 连线指向不存在的节点 | --- ## 6. 渲染器契约 `new ShaderToy(canvas, config)`: ```ts class ShaderToy { setPass(name: PassName, fragmentSource: string): { ok, errors, shaderLog? } setGraph(graph: ShaderGraph, opts?): { ok, errors, compiled } setChannel(slot: string, source: TexImageSource | WebGLTexture | null): boolean setUniform(name: string, value: number | number[], type?: PortType): void setUniforms(values: Record): void start(): void stop(): void step(): void render(dt: number): void attachMouse(): () => void // 返回解绑函数 stats(): object dispose(): void } ``` - 顶点着色器用 `gl_VertexID` 生成全屏三角形,不需要 VBO。 - Buffer Pass 用 RGBA16F 双缓冲乒乓(需要 `EXT_color_buffer_float`)。 - **所有 Pass 必须全部编译成功才整体替换**。失败时保留上一份能跑的 program, 错误进 `errors` 并回调 `onError`。这是 Shadertoy 的体验,也是必须的—— 改一半必然编译不过。 - 页面 `document.hidden` 时跳过渲染(满足「隐藏时 CPU < 1%」)。 ### 6.1 iMouse 口径 | 分量 | 含义 | |---|---| | `xy` | 最近一次按下的位置(像素,y 向上);从没按过则取负 | | `zw` | 当前拖动位置;在画布外则取负 | ### 6.2 热重载契约 `new HotReload({ renderer, graph, debounceMs })`: - 默认防抖 **120 ms**(需求书要求「变更后 500ms 内完成热重载」, 500ms 是上限不是目标值;一次真实重载通常 1–5 ms)。 - `invalidate(graph, extra)` 返回 Promise,合并窗口内的多次改动。 - 失败不推翻画面,只把错误抛给 UI。 --- ## 7. HTTP API 前缀 `/plugins/dsh-shader-workbench/`。 ### 7.1 `GET /ping` ```json { "ok": true, "name": "dsh-shader-workbench", "version": "0.1.0", "root": "<绝对路径>" } ``` ### 7.2 `POST /api` 请求体 `{ action, ...args }`,响应 `{ ok: true, action, ...payload }` 或 `{ ok: false, error: { code, message } }`。 **所有带 Origin 的请求必须同源**,否则 403 `origin-rejected`。 请求体上限 256 KiB。 | action | 参数 | 返回 | |---|---|---| | `workspace.overview` | — | `{ root, readOnly, projects[], materialCount, library }` | | `workspace.init` | — | `{ root }` | | `project.create` | `name, title?, description?` | 项目元数据(摊平) | | `project.read` | `project` | 项目元数据 | | `material.list` | `project` | `{ materials[] }` | | `material.create` | `project, name \| material, title?` | 材质全量 | | `material.read` | `project, material` | `{ project, name, dir, manifest, graph, shader, textures[], warnings }` | | `material.save` | `project, material, graph?, manifest?` | `{ ok, graphWritten, manifest }` | | `material.snapshot` | `project, material, label?` | `{ ok, id, dir }` | | `material.snapshots` | `project, material` | `{ snapshots[] }` | | `material.restore` | `project, material, snapshotId` | 材质全量 | | `material.archive` | `project, material` | `{ ok, movedTo }` | | `texture.import` | `project, material, sourcePath, slot, label?, source?` | `{ ok, ref, filePath, manifest }` | | `texture.unbind` | `project, material, slot` | `{ ok, removed, manifest }` | | `shader.compile` | `graph, pass?, uniforms?` | compileGraph 的结果 | | `preset.list` | — | `{ presets[] }` | | `preset.save` | `id \| name, data` | `{ ok, id, ... }` | | `preset.read` | `id` | `{ preset }` | | `settings.read` | — | `{ settings, path, root, readOnly, overriddenBy }` | | `settings.write` | `workspaceRoot?, readOnly?` | `{ settings, root }` | | `shader.write` | `project, material, name, text` | `{ name, bytes, path }` | | `example.list` | — | `{ examples: [{ name, title, description, materials[] }] }` | | `example.load` | `name, overwrite?` | `{ name, project, dir, materials[], library[], overwritten }` | | `nodelib.status` | — | `{ version, total, builtin, user[], userDefs[], categories[], errors[], dir }` | | `nodelib.reload` | `builtin?` | 同 `nodelib.status`,外加 `reloaded: { builtin, user }` | | `group.list` | — | `{ groups: [{ id, name, inputs, outputs, graph }] }` | | `group.save` | `id \| name, graph, doc?` | `{ ok, id, name, graph }` | `settings.read/write` 管的是**存储位置**这一层(见 3 节):落盘在 `/shader-workbench.json`,面板和 agent 共用。 `workspaceRoot` 传空串 = 清掉、回到自动推断;必须是绝对路径,否则 400。 `overriddenBy` 为 `config` / `env` 时表示上层压过了这里的设置,UI 要如实提示。 `shader.write` 把一段 GLSL 正文落进材质目录(面板「导入材质文件」选 `.frag` 时用), 只接受 `.frag` / `.glsl`。**它写进去的文件不参与预览** —— 预览只渲染节点图生成的代码。 `example.*` 管的是**插件自带的示例**(`examples/<名字>/`,随包分发,见 [`examples/README.md`](../examples/README.md))。载入 = 把整棵示例目录拷进 `<工作区>/projects/<名字>/`,形状与工作区一致,所以示例里调好的参数、清单、 预览图原样保留。同名已存在时返回 409 `example-exists`,要覆盖得显式带 `overwrite: true`;名字只允许字母数字下划线连字符(挡路径穿越)。 ### 7.3 `GET /texture?project=&material=&path=` 送纹理原始字节。`path` 必须是材质目录内的相对路径,含 `..` 或绝对路径一律 400。 ### 7.4 `GET /module/<相对路径>.js` 原样送出 `client/` 或 `shared/` 下的 ESM 源码,给面板 `import()` 用。 只允许 `.js` / `.mjs`,路径解析后必须落在白名单根目录内。 **白名单有三个根**:`client/`、`shared/`、插件根。为什么不止 `client/`: ``` 磁盘: client/panel/foo.js ── import '../../shared/graph.js' ──→ shared/graph.js URL: /module/panel/foo.js ── 浏览器解析 ../../ ────────────→ /shared/graph.js ``` URL 里的 `module/` 那一层把深度对不上了。所以路由**不能按 URL 层级切目录**, 而是把 `module/` 后面的整条路径拼成物理路径再 `normalize`: `module/panel/../../shared/graph.js` → `shared/graph.js`。 为了兼容解析后丢掉 `module/` 的 URL,另外补了一条 `/shared/*` 别名路由。 ### 7.5 面板模块加载时序 ``` client/index.js(__ModuleLoader__ 载荷) └── apply(ctx) → slots.inject('sidebar.panellist') / slots.inject('main') (挂一个只有生命周期、没有内容的 React 组件) ↓ 用户点侧栏那一行,main 槽位挂载 WorkbenchPanel useEffect → import('/plugins//module/panel/index.js') → mod.ensureStyles() (注入