开源 3D 建筑编辑器 Pascal Editor 的材质与主题机制

2026-08-05

本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。

这套东西的关键不在于它带了多少张贴图,而在于:颜色从来不存在模型里。模型只存一个”表面角色”令牌,真正的颜色是渲染时由当前主题和调色板算出来的。 理解这一条,你才能解释为什么 Agent 改完材质刷新后画面纹丝不动,也才知道这块的失败模式为什么大多不是报错,而是”看起来没生效”。

先把名字说清楚:这里的 Pascal Editor 是一个开源的 3D 建筑编辑器,不是 Pascal 编程语言,也和压强单位帕斯卡(Pa)没有任何关系。仓库 README 是这样定位自己的——「用 React Three Fiber 和 WebGPU 构建的 3D 建筑编辑器」;仓库里的宿主应用 apps/editor 是一个 Next.js 站点,所以它是跑在浏览器里的那类工具,而不是桌面 CAD。仓库地址是 https://github.com/pascalorg/editor,许可证 MIT(Copyright 2026 Pascal Group Inc.)。仓库里还带一个 MCP 服务器包 packages/mcp,AI Agent 可以通过它调用工具直接建模。本篇只钻表面上色这一条链路。

一、这块要解决的问题:一套模型,多种观感

建筑模型和一般的三维资产不一样:一栋房子从方案讨论到效果展示,模型本身几乎不变,但要看的东西一直在变。方案阶段你想看素模——所有面一个色调,只看体块关系;给甲方看时你想要地中海白墙蓝顶;出汇报图时你可能想要一张接近工程图的蓝图风。如果每换一种观感都要重新赋一遍材质,那就没法用了。

Pascal Editor 的解法是把”观感”从模型里彻底抽出来,拆成几根互不干扰的轴,全部挂在 viewer 的状态里(packages/viewer/src/store/use-viewer.ts)。按仓库 wiki/architecture/materials-and-themes.md 的说法,这几根轴分别是:shading(solid 还是 rendered)、textures(要不要显示贴图)、colorPreset(无贴图表面的基础调色板)、sceneTheme(灯光、背景、地面、角色色调的整包)、shadowsedges。它们互相正交,可以任意组合。

这里先把三个建筑词说清楚,后面会反复出现。楼板(slab)是你站的那层水平结构板,也就是一层的地面;天花(ceiling)是抬头看到的那层面,和上一层楼板不是同一个东西;门窗木作(joinery)指门扇、窗框、楼梯、柜体这类由木工或成品件构成的构件。它们在这套系统里之所以要分开,是因为要各自取到不同的颜色。

顺带说清本篇与站内几篇相近文章的分工:想横向比较各家 AI 建站与可视化工具怎么选,看 AI 建站工具对比;想学 Agent 工具本身该怎么设计接口,看 Agent 工具设计;想解决模型返回的 JSON 结构不稳定,看 结构化输出不稳怎么办。本篇不谈选型也不谈通用方法论,只把一个真实开源项目的上色链路从令牌一路读到缓存键。

二、颜色是怎么算出来的:一个令牌、两层调色

链路的起点在 packages/core/src/registry/types.ts。每种节点类型可以在自己的 NodeDefinition 上声明一个字段:

surfaceRole?: SurfaceRole

取值是七个之一:wallfloorceilingroofjoineryglazingfurnishing(glazing 指玻璃面,furnishing 指家具陈设)。关键在于 core 这一层只存这个令牌,不带任何颜色值。wiki 里的说法是 core「从不引入 three.js」,回仓库核过之后更准确的表述是:packages/core/src 的非测试代码里凡是碰到 three 的地方全部是 import type——registry/types.ts 引的是 Object3DBufferGeometry 这几个类型,hooks/scene-registry 那两个文件也是 import type * as THREE。也就是说只借类型、不落运行时依赖,颜色值和材质对象一个都不在这层产生。这是个很克制的分层:核心包不知道自己会被渲染成什么样,它只回答”这个面在建筑语义上是什么角色”。

我在 packages/nodes/src 下 grep 了一遍 surfaceRole:,25 个 kind 目录的 definition.ts 里带了这个声明(共 26 处,cabinet 有两处)。烟囱和柱子都声明成 wall,天窗声明成 glazing,太阳能板、老虎窗、屋脊通风器都声明成 roof——按视觉归属而不是按构件分类归。

真正的取色函数在 packages/viewer/src/lib/materials.ts

export function resolveSurfaceColor(
  role: SurfaceRole,
  preset: ColorPreset,
  sceneThemeId?: string,
): string {
  const tints = sceneThemeId ? getSceneTheme(sceneThemeId).clayTints : undefined
  return tints?.[role] ?? (PRESET_PALETTES[preset] ?? CLAY_PALETTE)[role]
}

两层:当前场景主题的 clayTints 里如果给这个角色定了色,用它;没定就回落到 colorPreset 那套调色板。同一个文件里定义了四套调色板常量——CLAY_PALETTEWHITE_PALETTEMONO_PALETTEBLUEPRINT_PALETTE,聚合成 PRESET_PALETTES,每套都给七个角色各配一个十六进制色。

WHITE_PALETTE 上方有一条注释值得单拎出来:反照率被钳到大约 0.83 线性(最高通道 #eb),理由是真实白漆只反射约 80%,而纯白反照率会把全局光照和阴影的对比度吃掉。反照率(albedo)就是表面的基础颜色反射率,全局光照(GI)指光在场景里多次反弹形成的间接照明——面越接近纯白,反弹回来的光越多,明暗层次就越平,模型看上去会糊成一片。这是个很典型的经验值——“白色”在渲染里从来不是 #ffffff

场景主题定义在 packages/viewer/src/lib/scene-themes.tsSCENE_THEMES 数组里我数到 9 条:studiopapersunsetovercastblueprintmediterraneantwilightnightverdant。每条包含 appearance(light 或 dark,驱动二维画布的底色、网格线、标注对比度)、background、可选的 backgroundSky(天顶色,post 管线会渲一道竖向渐变)、ground(场地地面填充色,刻意和 background 分开,好让暗色主题的地面不至于黑成一片)、ambient / hemi / lights 灯光组、toneMappingExposure(色调映射曝光),以及可选的 clayTints

mediterranean 举例,它的 clayTintswall 定成 #f6f1e6roof 定成 #3e6585——白墙蓝顶就是这么来的,不需要碰任何一个模型节点。clayTintsPartial 类型,你省略的角色自动回落到当前 colorPresetgetSceneTheme(id) 对未知 id 回落到 SCENE_THEMES[0],也就是 studio,不抛错。

这里有一条容易被忽略的规则,wiki 把它列为重要不变式:一个面算”有贴图”,当且仅当它的节点显式带了 materialPresetmaterialtextures 开着的时候,有贴图的面显示贴图,没贴图的面照样走 resolveSurfaceColor,而不是退回某个写死的白灰色。所以这套系统里不存在”全白模式”,没赋材质就等于”主题决定的角色色”。

三、材质库这一层:引用语法与”每米几块砖”

上面那层管的是没赋材质的面。真赋了材质的面走另一条路——静态材质目录,在 packages/core/src/material-library.ts

我按顶层条目数了一遍,MATERIAL_CATALOG 里有 114 条。按 category 字段统计:colors 45、wood 21、tile 12、metal 7、stone 6、concrete 6、fabric 6、roofing 4、brick 3、leather 2、glass 1、ground 1。另一边 MATERIAL_CATEGORIES 常量列了 16 个分类,也就是说 wallpaper、plastic、carpet、other 这四个分类目前静态目录里还没有条目——分类枚举先于内容铺好了。

每条目录项长这样:idlabelcategory、可选的 source'pascal' | 'community' | 'mine' | 'workspace',缺省即 pascal)、可选的 surfaces(这个饰面适合用在哪,取值来自 MATERIAL_SURFACES 的六项:floor、wall、ceiling、roof、furniture、outdoor)、缩略图路径,以及核心的 presetpresetmapsmapProperties 两部分组成:maps 是各张贴图的路径,槽位包括 albedoMapnormalMaproughnessMapmetalnessMapaoMap 等;mapProperties 是一堆渲染参数,roughnessmetalnessrepeatX / repeatYrotationwrapS / wrapTnormalScaleX / normalScaleYopacity 之类。贴图文件是 .ktx2(GPU 压缩纹理格式),缩略图是 .webp

repeatX / repeatY 这个参数需要一句解释才不会误读。UV 是把二维图片映射到三维表面用的坐标系。这个项目的所有程序化生成表面共用一条契约:UV 以米为单位,1 个 UV 单位就是 1 米。于是 repeat 不是抽象的重复次数,而是”每米铺几块”——repeat: 1 是每米一块,0.4 是每 2.5 米一块,1.5 是每米一块半。wiki 里明确写了这是材质自身的属性,对所有用它的面都一样,不是逐个物件或逐个面去打的临时补丁。目录里也能看出这条被认真执行了:地砖类 flooring-tile85a 的 repeat 是 0.3(每块约 3.3 米),砖墙类 flooring-rusticbrick 是 1.5。

节点引用材质用的是一套前缀语法,由同文件里的 parseMaterialRef 解析:library:<id> 指静态或动态注册的目录材质,scene:<id> 指场景内自定义材质。目录本身还支持运行时增删——registerLibraryMaterials / unregisterLibraryMaterials / subscribeLibraryMaterials 那一组函数就是给动态来源(社区、工作区)用的。

下面这张表把这块拆开来看:

组成部分它负责什么仓库位置你什么时候会碰到它
surfaceRole 令牌给每类节点标一个表面角色,本身不携带颜色packages/core/src/registry/types.ts;各类型在 packages/nodes/src/<kind>/definition.ts 声明新增节点类型,或某类构件换主题不变色
PRESET_PALETTESclay / white / mono / blueprint 四套按角色取色的基础调色板packages/viewer/src/lib/materials.ts想改素模的默认观感
resolveSurfaceColor主题 clayTints 优先,否则回落调色板packages/viewer/src/lib/materials.ts排查”换了主题但墙没变色”
createSurfaceRoleMaterial按角色造一个带光照的材质并缓存packages/viewer/src/lib/materials.ts出现”切主题后仍是旧颜色”
SCENE_THEMES九套场景主题:背景、地面、灯光、曝光、角色色调packages/viewer/src/lib/scene-themes.ts想加一套新观感
MATERIAL_CATALOG114 条静态材质目录,含贴图路径与渲染参数packages/core/src/material-library.ts给具体的面指定真实饰面
parseMaterialRef解析 library: / scene: 引用前缀packages/core/src/material-library.tsAgent 往节点上写 materialPreset
viewer 外观状态shading / textures / colorPreset / sceneTheme 等轴的持久化packages/viewer/src/store/use-viewer.ts加新的外观开关

四、Agent 在这块最容易改坏的三件事

第一件:缓存键漏了主题,切主题不重新上色。 createSurfaceRoleMaterial 的缓存键是这么拼的:

const cacheKey = `${role}-${preset}-${resolvedSide}-${sceneThemeId ?? 'base'}`

主题 id 是键的一部分。wiki 把这条讲得很直白:正因为缓存键包含主题,每个调用方都必须把 sceneTheme 一路传下来,否则切主题时命中的是旧材质。而且不止缓存键——各个 kind 的材质重建依赖数组里也得带上它。这类问题的表现是”部分构件变了色,另一部分没变”,不报错,只在视觉上不一致,代码评审时也很难看出来。想系统性地想清楚缓存和重复执行的关系,可以看 缓存与幂等

第二件:materialPreset 是一个没人校验的自由字符串。 MCP 层的工具入参里,materialPreset 的类型就是 z.string().optional()create_story_shellwallMaterialPreset / slabMaterialPreset / ceilingMaterialPreset 三个,create_roofcreate_stair_between_levels 各有一个 materialPresetapply_patch 则可以直接更新任意节点字段。我在 packages/mcp/src 下 grep 过,找不到 library: 这个前缀,也没有把材质目录暴露成 MCP 资源的地方——MCP 服务器根本不知道有哪些材质存在

结果就是:Agent 可以往这个字段写任何字符串,工具调用会成功返回。到了渲染侧,getMaterialPresetByRef 查不到就返回 null,调用方回落到默认材质。resolveMaterialRef 的文档注释把这个行为写死了:未知或悬空的引用返回 null,让调用方回落到槽位默认(先是已赋材质,再是主题默认),而且永不抛错。

这是个刻意的健壮性选择,代价是失败被彻底静音了。Agent 拿不到任何”这个材质不存在”的信号,只会得到一次成功的工具返回和一面颜色不对的墙。要在自己的编排里补上这层,得靠外部校验——这正是 Agent 参数校验 讨论的那类问题:模型能写出格式合法但语义无效的入参,schema 拦不住。务实做法是在调工具之前,先从 MATERIAL_CATALOG 生成一份合法 id 清单塞进上下文,或者建模完成后回读一遍场景,把所有 materialPreset 拿去对表。

第三件:想让 Agent 换主题,但主题压根不在它能碰的范围里。 我在 packages/mcp/srcpackages/core/src 下 grep sceneTheme,零命中;它只出现在 packages/viewerpackages/editorpackages/nodesapps/editor/components/viewer-toolbar.tsx 里。也就是说,场景主题是纯粹的观看端偏好,跟着 viewer 的持久化状态走(存储键是 viewer-preferences,默认 sceneTheme: 'studio'colorPreset: 'clay'textures: true),既不进场景数据,也不经过 MCP。

这条边界其实划得很干净:Agent 负责建筑物本身,人负责怎么看它。但如果你的预期是”让 Agent 把这个方案渲成夜景”,那就得知道这个能力当前不在 MCP 工具面上。

五、边界与代价

它明确不管的事,值得逐条列清楚。

主题不进模型数据。同一份场景在你这儿是 studio,在同事那儿可能是 night,因为这是各自浏览器里的本地偏好。好处是模型文件干净,坏处是”这个方案长什么样”没法随文件一起交付——你只能另外约定,或者截图。

角色色调只有七个角色的粒度。clayTints 是按 SurfaceRole 索引的,主题能表达的是”所有墙偏暖、屋顶偏蓝”,表达不了”南立面这面墙特殊”。要做到那种粒度,只能落到具体节点上赋材质,而那样一来就脱离了主题的控制——赋了材质的面不再跟着主题走。这是这套设计里最直接的取舍:主题的统一性,恰恰来自它对个别面无能为力。

repeat 是材质级而非表面级。同一种砖贴在任何一面墙上,每米的块数都一样。这保证了尺度一致,代价是没有”这面墙的砖大一点”这种局部调整的余地,除非你新建一条材质。

渲染上也有硬约束。glazing 角色的材质被强制走单面渲染——文件里的注释解释得很细:在多目标渲染的场景通道里给节点材质开双面,会编译出一个背面着色器变体,它的输出没覆盖全部渲染目标,校验器拒绝之后整个渲染上下文就废了。多目标渲染(MRT)是一次绘制同时往好几张缓冲区写数据(这里是颜色、法线这几路),管线要求每个着色器变体都得把这几路输出写全,少写一路就通不过校验。需要两面都可见的调用方只能把承载它的网格转 180 度,注释里举的例子是老虎窗(dormer,屋顶上凸出来的那种带窗小屋面)背侧的山墙——山墙就是坡屋顶两端那面三角形的墙。这类约束不写在类型系统里,只写在注释里,Agent 改到附近时基本不可能自己推出来。

再就是数据与出网。这个项目的 MCP 服务器默认把场景写进本机的 ~/.pascal/data/pascal.db,可以用环境变量 PASCAL_DB_PATH 指定确切文件、或用 PASCAL_DATA_DIR 指定目录。传输层同时提供 stdio 和 HTTP 两种,选 HTTP 就意味着开了一个能创建、修改、删除场景节点的本地端口,谁能连上谁就能改你的模型,这个面自己评估。材质贴图那一侧,viewer 里的 ASSETS_CDN_URLprocess.env.NEXT_PUBLIC_ASSETS_CDN_URL,没配就落到 https://editor.pascal.app;目录里那些 /material/... 开头的路径会被拼到这个地址上去取,也就是说默认配置下渲染材质会向外发请求。要完全离线跑,这个变量得指到自己的资源服务。

六、上手与避坑清单

加主题时别只填一半的 clayTints 为什么会踩:clayTintsPartial,少写几个角色能通过类型检查,跑起来也不报错,你只会觉得”这个主题差点意思”。怎么避:主题选择器的色板是从 clayTints 拼一个 2×2 小图渲染的,wiki 明确建议至少把 wallrooffloorglazing 四个填上,不然选择器上那个色块本身就不具代表性。

新增节点类型时记得声明 surfaceRole 为什么会踩:这个字段是可选的,不写照样能注册、能建模、能渲染出形状。怎么避:把”新 kind 的 definition 里有没有 surfaceRole”当成一条固定检查项。漏了的表现是这类构件在所有主题下都保持某个默认色,别的构件在变,就它不变——现象出现得晚,排查时很难第一时间联想到是几周前新增类型时漏了一行。

改上色相关代码时,同时改缓存键和依赖数组。 为什么会踩:这两处离得远,改一处不改另一处不会报错。怎么避:搜一遍 sceneTheme 在 viewer 侧的所有出现位置,确认每个造材质的地方都把它同时用在了这两处。wiki 里那张”各 kind 在哪里应用角色颜色”的表可以直接当检查清单用,从 systems/wall/wall-materials.tsnodes/<kind>/renderer.tsx 逐条对。

验证材质是否真的生效,别只看颜色。 为什么会踩:写错的 materialPreset 会静默回落到主题默认色,而主题默认色本身就是”正常的颜色”,肉眼过不了这一关。怎么避:改完之后回读场景,把节点上的 materialPreset 值拿去和目录 id 对一遍;要更省事就把 textures 关掉再打开——真有贴图的面会有明显的纹理变化,回落到默认色的面前后一模一样。

别把 Agent 写的 materialPreset 当成已校验的输入。 为什么会踩:工具调用返回成功,你很自然会认为这个值是合法的。怎么避:把合法 id 集合当成上下文的一部分显式给出,并在批量建模结束后做一次统一对表。这比让模型”记住”114 个 id 可靠得多。

离线或内网部署前,先把资源地址落定。 为什么会踩:默认值藏在一个逻辑或运算的右边,本地开发一切正常,换到隔离环境才发现贴图全丢。怎么避:部署前明确设置 NEXT_PUBLIC_ASSETS_CDN_URL,并确认目标服务上有对应的 /material/... 路径。

最后:接下来读哪个文件

如果你要动这块,读的顺序建议是:先看 wiki/architecture/materials-and-themes.md 建立全景(这个仓库的 wiki/architecture/ 下有 20 份 md,1 份索引加 19 篇分主题,密度相当高),再读 packages/viewer/src/lib/materials.ts 把取色和缓存这两件事看透,然后翻 packages/viewer/src/lib/scene-themes.ts 看九套主题各自的取值,最后按需要跳到具体 kind 的材质文件。材质目录那份文件很长但结构极其规整,用 grep 定位比通读高效。

顺带说一句这个仓库的整体规模,方便你判断投入:全仓 2508 个受版本控制的文件,apps/ 下 2 个应用,packages/ 下 9 个包,体量最大的几个是 nodes(733 个文件)、editor(445)、core(233)、mcp(152)、viewer(107)。packages/nodes/src 下有 46 个子目录,其中 shared/ 不是节点类型,所以节点类型是 45 种。根目录同时放了 AGENTS.mdCLAUDE.mdGEMINI.md 三份 Agent 约定文件——这个项目显然是把”会被 Agent 改”当默认前提在维护的。

自检三问,改完这块之前先过一遍:切换主题时,所有构件是不是同步变色?关掉再打开贴图开关,你以为赋了材质的面有没有真的变化?部署环境里的资源地址是不是指向了你能控制的服务?三个都答得上,这块就算稳了。

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 三维建筑编辑器:图层与隔离怎么让你只看一层楼开源 3D 建筑编辑器 Pascal Editor 的测量体系拆解

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。