开源 3D 建筑编辑器 Pascal Editor 的地形系统与接缝处理

2026-08-05

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

读这块代码最值得抄走的一条判断是:地形系统的难点不在”怎么把地推起来”,而在”谁说了算”——只要读高度的入口不止一个,渲染、拾取、放置、碰撞就会各自算出一个略微不同的地面,而这类偏差全部在地形和建筑的交接处爆发。 Pascal Editor(一个在浏览器里跑的开源 3D 建筑编辑器,和 Pascal 编程语言、和压强单位帕斯卡都没有关系,它自带一套让 AI Agent 直接建模的 MCP 服务器)在这一点上做得很硬:packages/core/src/lib/terrain-field.ts 文件头把两条不变量写在最前面,一条是高度全部量化成 Int16,另一条是所有读取都必须经过 heightAt。后面所有设计都从这两条推出来。

一、这块到底解决什么问题

先把两个名词摊平,读者是做 AI 工程的,不该被建筑黑话卡住。

高度场(heightfield)就是一张规则的网格表:在水平面上按固定间距布点,每个点只存一个高度数字,地面的形状是这张表插值出来的曲面。它天然表达不了悬挑、山洞、上下重叠——一个水平位置只能有一个高度。楼板(slab)是建筑里的水平板,你可以理解成”一层的地面那块厚板”;它之所以在地形这篇里反复出现,是因为一块平的板放在坡地上,板底和斜坡之间会露出一条缝,那条缝就是本篇最后要收的口。还有一个下文一直在用的词是红线,也叫用地边界(代码里写作 lot line / property line),就是这块地皮的产权边界那条闭合折线,红线以内才是你能动的用地;它在数据里是场地节点上的一个多边形,既决定地形网格铺多大,也决定笔刷改到哪儿要被截住。至于包围盒,是把一堆点用一个刚好装得下它们的正对齐矩形框起来,取尺寸时只看这个框比逐点算便宜得多。

编辑器在有地形之前,地面就是 y = 0 那个平面。加地形以后,整个场景要回答一个新问题:一个东西”落在地上”到底落在多高?这个问题的答案如果分散在渲染器、射线拾取、放置判定、碰撞体里各写一份,早晚会分叉。所以这套地形被拆成四块职责非常窄的模块,各自只干一件事。

组成部分它负责什么对应仓库位置你什么时候会碰到它
高度场本体存高度、量化、唯一的读接口与唯一的写接口packages/core/src/lib/terrain-field.ts任何时候你想知道”这里地面多高”
雕刻笔刷指针轨迹 → 一块高度补丁,保证不累积packages/core/src/lib/terrain-brush.ts加新笔刷动词、调手感、排查”拖不动”的问题
编解码高度场 ↔ 场景 JSON 里的 base64 字符串packages/core/src/lib/terrain-codec.ts存盘、加载、场景变脏、地形突然变平
工具层指针事件、相机冲突、live 预览、提交成一步撤销packages/editor/src/components/tools/site/terrain-sculpt-tool.tsx手感 bug、多指触摸、Escape 语义
场地侧收口网格范围、红线裁剪、整场铺平、提交策略packages/editor/src/lib/terrain-sculpt.ts大地块、笔刷范围、“按钮点了没反应”
贴地与接缝把线和板贴到折面上,不穿模packages/nodes/src/site/terrain-drape.ts红线、楼板下沿、任何画在地上的东西

二、高度场:量化不是抠内存,是为了能用整数相等

TerrainField 是一个只读结构:origin([0,0] 号样本的世界 XZ 坐标)、spacing(相邻样本的米数)、cols/rowsstep(每个高度单位代表多少米),加一条行主序的 Int16Array。默认 DEFAULT_TERRAIN_STEP 是 0.01,DEFAULT_TERRAIN_SPACING 是 0.5,默认网格 65×65。

为什么要把高度量化成整数?文件里给的理由不是省内存,而是让”这块地平不平”变成精确的整数相等判断,而不是一个需要选阈值的浮点比较。isFlatOver 遍历一个矩形范围内的样本,直接比 !==。注释里点明了另一条路的代价:浮点场需要一个容差,而这个容差还得和支撑判定自己的 epsilon 保持一致,两个可调参数迟早会漂开。至于范围,注释算过一笔账:step 取 0.01 时 Int16 覆盖 ±327 米的高差,远超任何建筑场地会用到的量。

第二条不变量更关键。所有消费方都不许直接索引 heights,一律走 heightAt

const col = Math.min(field.cols - 2, Math.floor(u))
const row = Math.min(field.rows - 2, Math.floor(v))
const tc = u - col
const tr = v - row
// ...
if (tc + tr <= 1) return h00 + (h10 - h00) * tc + (h01 - h00) * tr
return h11 + (h01 - h11) * (1 - tc) + (h10 - h11) * (1 - tr)

这段的分支就是三角剖分约定:每个格子沿 (c+1,r)(c,r+1) 这条对角线切成两个三角形,落在哪半边就用哪个平面。同一个约定在 packages/nodes/src/site/terrain-geometry.ts 里被再写一次(那边负责生成真正的网格顶点)。这是整套系统里唯一一处刻意的重复,两边的注释互相点名——改一边不改另一边,画在地上的所有线都会沉进每个格子的一半里。

还有几条小设计值得记:越界索引不报错,heightAtSample 直接 clamp 到边缘行,越界的读者拿到边界高度而不是一个洞;surfaceHeightAt 只是 heightAt 的别名,注释明确写”故意没有第二套插值模型”;normalAt 用中心差分而不是逐三角形的面法线,因为法线是”场”的属性,这样它跨三角形边界是连续的。写入口同样只有一个,applyHeightPatch 吃一个矩形 HeightPatch,返回新的场(新的 heights 缓冲),越界的补丁样本直接忽略而不是抛错——导入的地形数据允许超出场地。

三、笔刷:一次笔画是一次有界、可重复的编辑

terrain-brush.ts 的文件头直接把朴素实现判死刑:每帧 h += strength * falloff 有三个病,停在原地不动会无限累积、重叠的笔触会重复叠加、拖得快的一笔比拖得慢的一笔堆的土少。它换成的模型有四条:

  1. 按下指针时冻结一份快照,之后每一笔都对着快照算,不对着上一次的结果算。
  2. 覆盖度用饱和的方式累加。一笔把某个样本的覆盖度抬到 max(mask, falloff) 而不是加上去,高度永远是”快照 + 增量 × 覆盖度”。所以你反复蹭同一块地,第二遍算出来的还是同一个值。
  3. 按弧长布点。advanceStroke 从上一个落点沿路径按固定米数补点,笔画强度是走过的距离的函数,不是鼠标速度或帧率的函数。
  4. 余弦钟形衰减,在中心和边缘都是一阶连续,笔刷边界不会留一圈可见的脊。

四个动词 raise/lower/flatten/smooth 共用同一个形状,resolveSample 里一目了然:目标值减快照值,乘覆盖度乘强度。差别只在”目标”怎么来——抬升/下压是相对快照的偏移(RAISE_METRES_PER_STROKE 定死一笔最多推 1 米),压平是绝对高度,平滑是扩散过的快照。

平滑那一支的取舍很有代表性:扩散是整场做的,8 次 Jacobi 迭代、λ 取 0.25(注释说这是离散扩散的稳定上界,再大就震荡不收敛),而不是只在笔刷范围内做。原因是局部模糊必须把边界样本钉死,而钉死的边界恰恰就是平滑笔刷本来要抹掉的那道脊。

对做 Agent 的人,这套模型的迁移价值不在三维:它本质上是把一次交互设计成幂等且收敛的——重复施加不叠加,中断再续不跳变,整段手势合成一次可撤销的提交。你给模型设计工具时想要的性质和这个是同一个,只是换了个场景,这方面的取舍我在Agent 工具设计里单独写过。

一个容易被忽略的常量:MIN_BRUSH_RADIUS_IN_SPACINGS = 1.5。笔刷只会动落在半径内的样本,半径比样本间距还小时可能整个落进四个样本中间,什么都不改——用户看到的是拖了半天没反应、光标不变、也没有报错。取 1.5 而不是”圆刷刚好蹭到角”的约 0.71,是因为一支只有骑到样本上才生效的笔比一支老老实实告诉你太小的笔更糟。而且它是间距的倍数不是固定值,因为间距本身会被拉大(见下一节)。

四、编解码:把一张 Int16 表塞进场景 JSON

场景图是以 JSON 存的(注释写明落在 projects_models.scene_graph 这个 jsonb 列),Int16Array 不能直接进去——JSON.stringify 会把类型化数组变成一个键是数字字符串的对象,往返有损而且每个样本要多花好几个字节。terrain-codec.ts 是唯一跨这条边界的地方,选的是”小端 Int16 的 base64”,并且用 DataView 显式写字节序,不依赖运行机器恰好是小端。

不压缩是明写的决定,理由很实在:导入的地形数据本身噪声大、最难压,一个带压缩的格式只会在最脏、也最可能是真实的场景上出问题。控制体积靠的是切块而不是压缩——diffToPatches 按整行为单位切,先扫出脏行区间,再按字节预算切段。注释里给了预算的来源:场景操作有 64 KiB 的上限(MAX_SCENE_OPERATION_BYTES,这个名字在仓库里目前只出现在这条注释中),而每次变更同时带 fromto,所以每行的成本要按两倍算。

解码这一侧的态度更值得抄。decodeTerrainField 遇到任何问题返回 null,既不抛异常也不修补:地形解不出来的场景必须仍然能打开(按平地渲染),而悄悄换一块”差不多的地”比什么都不显示更糟。decodeHeightPatch 还会卡 MAX_TERRAIN_SIDE(257)的边界。最狠的一处细节在 decodeInt16Samples:它把解出来的字节重新编码一遍,和原字符串逐字比对,不一致就判失败——这一招堵的是”能解出东西但根本不是原始负载”的那类脏输入。这种”输出必须能回炉验证”的思路和模型侧结构化输出的校验是一回事,那边的做法我在结构化输出为什么不稳里展开过。

最后还有 isDatumField:全零的场不写进节点,site.terrain 保持 undefined。理由是雕完再压平回去,应该回到”从没碰过地形”的状态,而不是每次存盘都背上约 11 KB 的 base64 零。

五、接缝:这块的代码量比造山多

接缝有三类,性质完全不同。

第一类是网格边和场地红线的接缝。 fieldExtentForSite(在 packages/editor/src/lib/terrain-sculpt.ts)按场地多边形的包围盒挑一个尺寸,候选是 33/65/129/257 这几个 2ⁿ+1,并且往外多留两个间距的余量。为什么要留余量:网格范围在创建时就定死了,一张停在红线以内的网格会在地形网格边缘和外面的平地之间留一道看得见的崖。地块大到 257 个样本按默认间距盖不住时,它拉大间距而不是缩范围——注释的原话是粗一点的地也好过在红线处断掉的地。代价是精度换覆盖,而这个代价会顺着 MIN_BRUSH_RADIUS_IN_SPACINGS 传导到笔刷的最小可用半径上。

第二类是”存储范围”和”可编辑范围”的接缝。 网格是方的、带 padding 的,但那不等于可编辑用地。clipTerrainPatchToSite 逐样本判断是否在场地多边形内,不在的一律还原成原值:

if (terrainPointInsideSite(site, x, z)) continue
const patchIndex = row * patch.cols + col
const previous = field.heights[fieldRow * field.cols + fieldCol] ?? 0
if ((patch.heights[patchIndex] ?? 0) === previous) continue
heights ??= patch.heights.slice()
heights[patchIndex] = previous

注意它只在真的要改动时才复制一份数组,笔刷完全落在场地内时零拷贝。

第三类才是真正的重头:地形和建筑的接缝。 它被拆成了两个方向。

垂直方向的问题是”谁跟着地面走”。packages/core/src/lib/terrain-support.ts 的答案是:只有被层堆叠放在基准面上的那一层跟着走。基准面是 SITE_DATUM_Y(0),判定用 SITE_DATUM_EPSILON(1e-4)这个容差——松到能吸收矩阵运算的浮点漂移,紧到 5 厘米厚的地面板仍然读作”建造出来的面”而不是地形。上面的楼层有真实的楼板,跟着地面走只会把楼里的东西沉进山坡。terrainSupportLift 在不适用时返回 null 而不是 0,这样所有旧的平地代码路径一个字都不用改;需要一个总函数的调用方去用 levelBaseElevationAt

这里还有一个很聪明的机制:哪些构件类型需要在地面变动后重建几何,是运行时”学”出来的,不是声明的。谁的几何构建器调用了 ctx.levelBaseAt,谁就被 noteLevelBaseConsumer 记进一张按类型索引的集合里。注释里把两条声明式方案的问题写得很直白:在定义上加标记等于又一个逐类型 opt-in,而正是逐个 opt-in 导致过半个场景留在平地上;反过来把”所有有几何构建器的类型”全算上,则会让笔刷的每一笔都去重建地面层的每一个柜子和风管。

水平方向的问题是”板和墙的下沿怎么收”。slabwall 的 schema 里都有一个 fillToTerrain 布尔字段,墙的描述写的是把墙向下延伸到地形而不改它的既定高度。真正的手艺在 packages/nodes/src/slab/geometry.ts 的填充几何里:它沿多边形的每一条边调 creaseCrossings 插点,再逐点取 Math.min(top, ground - baseWorldY) 作为底边高度。

creaseCrossings 值得单独说,它在 packages/nodes/src/site/terrain-drape.ts。渲染出来的地面是分段平面的,只在三处折:两族网格线和每个格子的对角线。把一条线段恰好在这些折线处切开,每段就整个落在一个三角形里,而三角形内部是平面,直线在平面上就是处处贴合。换到网格索引空间里,三族折线就是 u ∈ ℤv ∈ ℤu + v ∈ ℤ,三者对线段参数都是仿射的,每个交点一次除法。注释里点了另一条路的问题:均匀过采样只能逼近,而且密度取多少永远可以吵。

配套的还有一条性能设计:折点集合只取决于网格(terrainGridKey 只拼 origin、spacing、cols、rows),不取决于高度。所以笔刷的每一笔只用 updateDrapedHeights 原地改 Y,XZ 一个都不动;弧长故意用水平距离而不是三维距离算,否则山一升起来虚线的疏密就会顺着环线滑动。

六、边界与代价:它明确不管什么

  • 不表达非高度场的地形。 悬挑、山洞、洞口、垂直挡土墙的内部结构,一个 XZ 一个高度的模型都做不了。要这些就是另一套数据结构,不是给这套加参数。
  • 网格是方的。 注释解释得很干脆:矩形网格要么两个间距要么两套计数,下游每一处索引计算都要多一层分支,而视觉上没有收益。
  • 单边最多 257 个样本。 超过就拉大间距,精度换覆盖,并且这个代价会传导到笔刷手感上。
  • 不压缩,也不修复。 存储体积靠切块解决;解码遇到问题就当没有地形,绝不猜。
  • LOD 目前只是留了口子。 2ⁿ+1 的尺寸约定,文件里写的是”未来的 LOD 减半”能落在已有样本上、不用插一条缝出来。这是为将来预留的形状,不要读成已经有多级细节。
  • 地形没有对应的 MCP 工具。 我在这个 commit 下对 packages/mcp/src 整目录做了一次不分大小写的 terrain 搜索,零命中。也就是说这套地形目前是编辑器交互侧的能力,Agent 手上没有专门的地形工具可用。这一条随时可能变,你自己 grep 一遍最稳。
  • 它会在你机器上跑一个本地服务并写本地文件。 场景落在一个 SQLite 文件里,packages/mcp/src/storage/sqlite-scene-store.ts 里的路径解析顺序写得很清楚:先看 PASCAL_DB_PATH,再看 PASCAL_DATA_DIR 下的 pascal.db,然后才是各平台的默认目录。还有一个 PASCAL_MAX_SCENE_BYTES 可以配场景大小上限。这意味着接上 Agent 之后,它拿到的是这份库里场景的写权限,不是一个只读预览;把服务端口暴露出去,等于把这份写权限一起交出去。这类边界怎么划,可以对照MCP 的安全边界那篇。

顺带把本篇和站内几篇相近内容的分工说清楚:这篇只拆 Pascal Editor 地形这一块的具体机制,Agent 工具设计讲的是工具粒度与边界怎么切,结构化输出为什么不稳讲模型侧输出校验,而想把仓库这类结构性数据自己数一遍、做成统计的,去看用 AI 写数据分析脚本

七、上手与避坑清单

  • 别直接索引 field.heights 会踩是因为这么写又短又快,而且在平地上测不出问题;一旦落在格子的另外半边,你算出来的高度和渲染差半个三角形。全部走 heightAtheightAtSample
  • 改三角剖分方向必须两处同改。 terrain-field.ts 的插值分支和 terrain-geometry.ts 的顶点缠绕是同一个约定的两份实现,只改一边,所有贴地的线都会沉进每个格子的一半里。改之前先把两边注释读完。
  • 笔刷半径低于间距的 1.5 倍会静默失效。 踩点在于没有任何报错,用户只觉得”拖不动”。用 minBrushRadius(field) 或场地侧的 brushRadiusRange / clampBrushRadius 兜底;工具在开笔时也做了一次 Math.max(settings.radius, minBrushRadius(field)),因为默认半径是个固定值,而间距在大地块上会被拉大。
  • 笔画中途动相机要断锚点。 落点间距是真弧长,指针流断一段它会自动补一串点把中间连上——掉帧时这正确,缩放后这就是横贯整块地的一道条带。工具里的做法是发现相机在拖动就跳过这一笔并调 detachStrokeAnchor,只跳过不断锚反而更糟。
  • pointercancel 不能接到 pointerup 上。 前者是浏览器把手势收走了(系统边缘手势、误触手掌、指针被别处捕获),用户根本没松手;当成提交就会把半截拖拽写进场景。仓库注释说明这里以前就是接错的。
  • 整场写入前先结束在飞的笔画。 所有读取方都优先用 live 的笔画结果,所以铺平整块地这类写入会落在笔画下面,看不见;等笔画自己提交时又会用它按下时的快照把结果盖掉。用户看到的是”按钮点了没反应”,没有报错也没有可撤销的记录。
  • 不要每一笔都写场景。 中途走 live 状态、松手时一次 commitStroke,并用单次历史步收拢,否则撤销会一笔一笔往回退,长度还不可控。
  • 提交时别把整张场当补丁传。diffToPatches 的脏行区间;整场写入在网格稍大时就会超出那 64 KiB 预算。
  • 别指望地形解码失败会抛错。 它返回 null,表现是场景照常打开、地却变平了。排查这类”地形丢了”的问题,第一步直接对 site.terrain 单独跑 decodeTerrainField,而不是从渲染层往回找。

收尾:一份可执行的自检

把这套东西搬进你自己的项目前,先回答四个问题:读高度的入口是不是只有一个;写高度的入口是不是只有一个;一次交互能不能重复施加而不叠加;持久化失败时你是变平、报错,还是猜一个差不多的结果。这四条在 Pascal Editor 里分别对应 heightAtapplyHeightPatch、饱和覆盖的笔刷模型、以及 decodeTerrainField 返回 null

接着往下读的话,顺序建议是:先 packages/core/src/lib/terrain-field.ts 的文件头注释(两条不变量是全部设计的根),再 packages/core/src/lib/terrain-brush.ts(交互模型),然后跳到 packages/nodes/src/site/terrain-drape.ts 看接缝那套折线数学,最后回到 packages/editor/src/lib/terrain-sculpt.ts 看这些决定在场地这一层是怎么被收口的。这个仓库的注释密度相当高,很多地方直接写了”另一条路为什么被否掉”,那些段落比代码本身更值得读。

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

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