深度拆解PenEcho核心架构:20000×20000无限画布靠稀疏瓦片保持流畅的秘诀
【免费下载链接】penechoThink with AI beyond the chat box. A shared canvas for handwriting, equations, diagrams, and spatial reasoning.项目地址: https://gitcode.com/gh_mirrors/pe/penecho
PenEcho 是一款"超越聊天框"的 AI 协作画布,集手写笔迹、数学公式、专业图表与空间推理于一体。它最大的技术亮点,是把一块 20000×20000 逻辑坐标的无限画布,拆成稀疏的 512×512 小瓦片(sparse tiles)按需分配——整块画布永远不是一张真实位图,因此无论你怎么放大缩小、平移涂写,浏览器都始终流畅。下面带你用最通俗的方式,看懂这套稀疏瓦片架构的运作原理。
为什么无限画布不能"整张画"?
先算一笔账:一块 20000×20000 的画布,如果按常见的 4 通道 RGBA 位图计算,就是约1.6GB 内存的像素数据——这还没算撤销栈、快照和高分屏放大。
真实使用中,画布 99% 的面积是空白纸面。为空白像素白白分配内存,既浪费又拖慢渲染。
所以 PenEcho 做了一个根本性的决定:不整块分配。
核心定义就在画布运行时的开头:
const SIZE = 20000, TILE = 512,来源:public/app.js
也就是说:逻辑坐标空间是 20000×20000,但内存里实际存在的,只是被内容"碰过"的 512×512 小格子。官方架构文档把这句话说得很直白(英文原文):"The full logical canvas is never allocated as one bitmap. Rendering composites only visible sparse tiles into the viewport canvas."(完整逻辑画布从不被分配为单张位图,渲染时只把可见的稀疏瓦片合成到视口画布上。)
网格切分:瓦片坐标系是画布的地基
把 20000×20000 按 512 一格切分,整个画布最多有 39×39 ≈1521 个潜在瓦片槽位。每个槽位有一个整数坐标(tx, ty),对应的逻辑区域是[tx*512, ty*512]起的 512×512 方块。
任何一次笔触、擦除、AI 生成的图形,都会被"落"进它覆盖的瓦片里。跨瓦片的长线条会切成若干片段,分别写进各自瓦片的局部坐标。这样,一块瓦片坏了只需要重绘它,一张画布脏了也只需要重绘脏的那几格——这是后面局部渲染和局部存储的基础。
按需分配:空着的格子,一格内存都不占
这是"稀疏"二字的真正含义。瓦片不是启动时预先创建好的,而是谁被写到、谁才被"诞生":
function tile(tx, ty, create = true) { const k = key(tx, ty); if (!tiles.has(k) && create) { const c = document.createElement("canvas"); c.width = c.height = TILE; // 512×512 c.getContext("2d", { willReadFrequently: true }); tiles.set(k, c); state.inkBounds.set(k, null); } return tiles.get(k); }来源:public/app.js
- 全画布用一个字典
tiles(Map,键是瓦片坐标)管理; - 没有内容的坐标在字典里根本不存在——这就是"稀疏";
- 每个瓦片是一个独立的 512×512
<canvas>,带透明背景,铺在纸面之上。
画上一幅小图,可能只产生几块瓦片;整个画布的内存占用 ≈实际内容量,而不是 1.6GB。
只渲染视口:forTiles 与分层合成
滚动或缩放时,浏览器需要知道"当前屏幕露出哪些瓦片"。PenEcho 用一个统一的遍历函数forTiles完成这件事:
function forTiles(x, y, w, h, fn, create = true) { const x0 = Math.max(0, Math.floor(x / TILE)), y0 = Math.max(0, Math.floor(y / TILE)), x1 = Math.min(Math.ceil(SIZE / TILE) - 1, Math.ceil((x + w) / TILE) - 1), y1 = Math.min(Math.ceil(SIZE / TILE) - 1, Math.ceil((y + h) / TILE) - 1); if (x1 < x0 || y1 < y0) return; for (let ty = y0; ty <= y1; ty++) for (let tx = x0; tx <= x1; tx++) { const c = tile(tx, ty, create); if (c) fn(c, tx, ty); } }来源:public/app.js
传入视口矩形,它把坐标换算成瓦片索引范围,只遍历落在视口内的瓦片。渲染笔迹层时只有一行核心逻辑:
forTiles(visible.x, visible.y, visible.w, visible.h, (canvas, tx, ty) => inkCtx.drawImage(canvas, tx * TILE, ty * TILE), false);来源:public/app.js
配合几个细节,流畅度就被锁死了:
- 分层画布:背景、已放置内容、墨迹、动画、交互预览各在一层,互不牵连;
- 动画不进位图:动画场景活在独立的持久对象层上,只按"脏矩形"局部重绘,与瓦片位图完全解耦;
- 渲染队列:
requestRender()用state.renderQueued去重,同一帧内的多次脏标记只触发一次合成。
瓦片是存储的最小单元
稀疏瓦片的好处不止在内存里,还贯穿到本地持久化。浏览器使用 IndexedDB 数据库penecho-canvas-history,其中snapshot-tiles库每块有内容的瓦片存一个独立 PNG Blob,以快照 ID 索引。
这带来两个直接收益:
- 画布列表只加载轻量的预览缩略图,不碰任何瓦片数据;
- 点开某个画布时才按需分批解码瓦片(解码批次大小为 8),加载体验平滑。
画布导出/分享时同样按瓦片打包成自包含的penecho-raster-tiles格式,每个瓦片是独立资产,可无损迁移到云端或别人的设备。完整规则见:
- 架构总览:docs/architecture.md
- 画布存储格式 v2:spec/canvas-storage-v2.md
AI 如何"看懂"这么大的画布
如果画布真有一张完整位图,喂给 AI 的截图会大到离谱。稀疏瓦片让另一件事成为可能:按需裁剪。
AI 自动请求的流程是:
- 你的新笔迹更新了一个"脏区域"(dirty bounds)和热点轨迹;
- 延迟结束后,客户端以最新墨迹为中心,裁剪出一张白底小图(atlas),而不是整幅画布;
- 请求中附带全局几何参数、权威的最近输入矩形、8×8 热点网格和局部放大细节,模型据此判断"用户在画哪、想补全什么";
- 模型返回的结构化命令(公式、图表、统一绘图指令等)经校验后,作为"未确认草稿"展示;确认后写回稀疏瓦片,或进入动画对象层。
整条链路详见架构文档的 AI Request Flow 小节:docs/architecture.md。
稀疏瓦片架构小结
| 环节 | 做法 | 收益 |
|---|---|---|
| 内存 | 512×512 瓦片按需创建,空白不分配 | 占用 ≈ 内容量,与 1.6GB 大图无缘 |
| 渲染 | forTiles只遍历视口内瓦片,分层合成 | 缩放/平移始终只画"看得见的格子" |
| 更新 | 每块瓦片独立脏区跟踪,局部重绘 | 一笔只动几格,帧率稳定 |
| 存储 | 每块瓦片一个 PNG Blob 存 IndexedDB | 列表秒开、按需加载、可迁移 |
| AI | 围绕脏区域裁剪 atlas 而非整幅截图 | 请求小而准,模型看得清上下文 |
一句话总结:20000×20000 只是坐标系,不是内存。稀疏瓦片让"无限画布"从一句营销词,变成了浏览器里可以随手平移涂写的日常体验——这就是 PenEcho 流畅感的底层答案。
延伸阅读
- 想深入了解完整架构(含 AI 请求链路、模型执行器、统一绘图协议):docs/architecture.md
- 想弄清画布数据在本地与云端之间的持久化格式:spec/canvas-storage-v2.md
- 画布运行时源码入口:public/app.js
【免费下载链接】penechoThink with AI beyond the chat box. A shared canvas for handwriting, equations, diagrams, and spatial reasoning.项目地址: https://gitcode.com/gh_mirrors/pe/penecho
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考