可雕刻的绘画引擎:基于Canvas的高度场画笔实现
看到 “A painting engine that lets you sculpt drawings (tech demo)” 这种标题很容易产生两种误判。第一种以为它只是个 AI 生成画图工具输入一句话就出图第二种以为它只是给 2D 画布加了一层凹凸滤镜本质还是在覆盖像素颜色。但从技术本质看这类 demo 真正改变的是画布的数据模型它不是把“画”的结果当作像素集合而是当作可以被推、拉、挖、抹的高度场数据。这个判断直接决定了你会怎么看它。如果你仍然把画布理解成“一张只能叠加颜色的平面”那么雕刻式绘画对你来说只是笔刷效果如果你把画布理解成“底层还藏着一张高度场”那么你得到的是一套新的交互方式颜色和几何可以在同一笔触中同时存在画出来的东西能继续被“塑形”。这篇文章不打算把某个别人的 demo 包装成成熟软件来介绍也不去复述官网截图。我会把“可雕刻的绘画引擎”拆成几个问题来看它到底解决了什么痛点核心机制是什么如果你想自己写一个最小可运行的 tech demo工程上应该怎么做读完这篇文章你不仅能看懂这类演示动图背后的原理还能得到一段完整的 Canvas 代码亲手跑通“画一笔 写入颜色 修改高度”的效果。后面的实操代码不依赖任何重型框架不需要安装引擎一个浏览器文件就可以运行。1. 这类绘画引擎到底解决了什么问题1.1 传统绘图工具的问题画布是“死”的先回忆一下我们在普通绘图软件里画图的体验。笔刷落到画布上本质上做的是“颜色覆盖”这一层像素被新的 RGB 值替换或者按透明度混合。画错了可以用橡皮擦橡皮擦的本质也是把像素还原到背景色或者原始图层。整个过程里画面是一个二维数组数组元素是颜色值。这种模型的优点是简单缺点也很明显它不支持“形状”层面的修改。例如你画了一个高光想让它稍微鼓起来一点你画了一个凹槽想让它再深一点这些操作在传统像素画布里必须靠手绘调整颜色和明暗而不是“拉扯”画面本身。因为在数据模型上根本没有“鼓起来”这个概念。这也是为什么很多 2D 美术工作者会跑到 3D 雕刻软件里做前期造型再回头导贴图。因为 Sculpting 工具天然支持“增料、减料、平滑、移动”这些操作它们修改的是几何而不只是颜色。传统绘图工具缺少的就是被雕刻的数据维度。1.2 “sculpt drawings” 的真正贡献标题里的 “lets you sculpt drawings” 点明了核心它让画出来的线条和色块变成一种可以被继续雕刻的材质。你可以在已经画好的笔触上增厚、削薄、压平让二维画面呈现出类似浮雕的质感而且整个过程是实时交互的。从技术分层看它并不是把 3D 模型硬塞进 2D 画布而是给 2D 画面补了一个“高度”通道。原本画布上的每个点只有颜色现在每个点除了颜色还记录了一个高度值。高度值不一定需要精确到毫米它更像一张“凹凸信息”。所以它降低了哪一类开发者的创作成本我的理解是它把 2D 绘画和 3D 雕刻之间的切换成本砍掉了一大截。以前做概念设计或者低多边形原型需要在绘图软件和建模软件之间来回倒文件如果在绘图软件内部直接提供雕刻能力创作流程会变得更连续也更适合快速验证“形状颜色”的组合想法。1.3 tech demo 形态说明了什么“Show HN”和“tech demo”放在一起通常说明作者想验证的是某个核心交互能不能成立而不是交付一个完整商业产品。所以这类项目往往只做很小的一个点笔刷能改变高度、光源能实时反馈、不同操作之间能自然切换。它不一定具备图层系统、色彩管理、动画系统、导出管线等完整工程能力。理解这一点很重要。你不需要把这类 demo 当成一个可以装入生产流程的完整工具而应该把它当成一个“可能性证明”。它证明了绘画引擎可以吸收雕刻软件的数据模型绘画过程可以同时修改颜色和几何。2. 高度场让 2D 画面具备第三个维度2.1 什么是高度场高度场Height Field是一种用二维坐标存储一个数值的数据结构。可以用一个Float32Array[width * height]来描述每个像素位置记录一个高度值。高度值大于 0 表示表面凸起等于 0 表示基线小于 0 或者被裁减到 0可以表示凹陷或不可继续压入。我们可以说“高度场就是一张灰度图灰度越白表示越高越黑表示越低。”但它和普通灰度图不同灰度图最终用来做颜色显示而高度场更多是作为几何数据去驱动渲染结果。渲染时通过计算相邻像素的高度差可以得到该点的法线再用法线做光照画面就有了立体感。这就是“雕刻式绘画”最重要的数据底座颜色数据负责告诉屏幕“这里是什么材质”高度数据负责告诉屏幕“这里比旁边高多少”。两者的精度和分辨率可以不一样也可以存在同一张纹理的不同通道里。2.2 为什么用高度场而不是直接做网格你可能会问既然要雕刻为什么不用三角形网格像 Blender 或 ZBrush 那样做 3D 雕刻因为网格雕刻的拓扑管理很复杂。一个笔刷扫过需要修改几十万个顶点还要做动态细分、法线重算、撤销内存管理。对于实时原型来说代价太高。高度场最大的好处是规则。它在内存里就是一块二维数组相邻关系固定不需要维护拓扑关系。对画布上任意一个像素它的上下左右邻居就是索引减一、加一、减一行、加一行。这种规律非常适合 GPU 并行处理也适合把数据丢给 WebGL 纹理。用高度场做浮雕式雕刻本质上是用“数值叠加”模拟“形状变化”。笔刷扫过不是删除三角形而是修改一个区域的数值。物理上不够精确但视觉上非常直观。2.3 数据层与渲染层分离为了不让概念混乱建议把“数据”和“显示”拆开看待。在一个典型的雕刻式绘画引擎内部数据层至少包含两样东西高度数据用于描述表面形状通常用浮点数组保存。材质数据用于描述反照率颜色通常用 RGBA 通道保存。渲染层每次读取这两个数据计算法线、光照、阴影并把它呈现到屏幕上。如果只把图像颜色保存下来而不单独保存高度值那你就无法继续雕刻。如果只保存高度值但不同时保存颜色那画出来的东西就只能是单色浮雕。这也是我推荐在概念上理解“颜色和几何同时写入”的原因。一次笔触落下它在画布上可能同时做两件事往颜色缓冲区覆盖一层带透明度的颜色往高度缓冲区叠加一段带衰减的高度。这个设计也可以理解为“每一笔都带体积”。下面是传统光栅绘图和高度场雕刻绘图的一个对比维度传统光栅绘图高度场雕刻式绘图核心数据RGBA 像素颜色颜色数据 高度数据笔触覆盖颜色同时修改颜色和深度橡皮擦恢复背景色降低高度或恢复网格基线修改方式需要画笔涂抹可以雕刻、推拉、平滑、压平阴影效果手动绘制根据高度场自动计算光照平台成本低中等需引入法线和光照逻辑对开发者来说第二条就是这篇文章最重要的技术结论它不是一个滤镜而是一种新的笔刷数据协议。3. 雕刻式绘画引擎的典型交互流程如果只是把 3D 雕刻的概念搬到 2D 画布交互应该怎么设计从常见雕刻软件里提取可以归纳为几类核心操作绘制/隆起按住画笔拖动在画布上叠加颜色同时增加高度。雕刻/削减按住画笔反向操作减少高度形成凹槽不影响颜色或只影响材质磨损。平滑对高度场做模糊消除锯齿和突变让表面更柔和。压平把局部高度统一到某个目标值。移动/拉伸把某一区域的高度场往某个方向推形成倒角效果。在最小 demo 里前三项就足够体验核心手感。你不需要一下子实现全套雕刻工具因为“能否在一个画面上体现颜色 立体感 可修改性”才是需要验证的重点。工作流程也可以设计得非常简单。第一步选择颜色和笔刷半径第二步在画布上绘制主体形状第三步切换到雕刻工具用左键增料右键减料第四步点击平滑把边缘过渡弄自然。最后可以再叠加一层纹理或者光照调整。这种工作流会让创作者感觉“绘画仍然是绘画”只是多了第三个维度。用户不需要先理解法线、渲染或贴图这些概念核心操作仍然是“在哪里落笔”。4. 核心技术原理一笔下去内部发生了什么4.1 分层架构把一个雕刻式绘画引擎拆到最小工程结构大致是三层第一层是输入层负责监听鼠标、触控或数位板的坐标与压力。压力数据可以映射到笔刷半径和强度这在数字绘画中很常见。第二层是数据层核心是一张高度场和一张颜色缓冲区。每次笔刷事件产生一个“印记”stamp印记会遍历自身覆盖的小区域把高度值和颜色值写入缓冲区。第三层是渲染层根据高度场计算法线再结合光照方向和颜色缓冲区输出到屏幕。如果数据层发生变化渲染层需要能实时更新。这三层解耦之后最大的好处是输入方式可以变从鼠标变成触控笔或手写板只需要改输入层渲染方式也可以变从简单的 Canvas 光照模拟变成 WebGL 着色器数据层可以保持不变。4.2 从高度差到法线高度场本身不包含颜色和明暗。要让观察者觉得表面是立体的关键一步是根据高度场计算每个像素的法线。通俗地说法线就表示这个表面“朝哪个方向”。对于二维高度场x 方向的高度差可以写成hR - hLy 方向的高度差可以写成hD - hU。法线向量在三维空间中约等于nx -(hR - hL) * scale ny -(hD - hU) * scale nz 1然后归一化得到一个单位向量。scale是高度影响系数它决定了高度变化看起来有多陡。如果scale太小画面会很平如果太大表面会像刀刃一样产生大量尖锐高光。有了每个像素的法线就能用一个平行光方向做点乘得到漫反射强度。再把漫反射强度与反照率颜色相乘就能渲染出“有淡蓝色凸起、有轻微凹陷阴影”的画面。4.3 为什么用像素级法线而不是 3D 网格在只做浮雕效果时法线计算比真实网格便宜得多。每个像素之间都是独立计算不需要构建索引不需要处理共享顶点。如果使用 WebGL可以把高度场上传到纹理在片元着色器里通过采样相邻纹素计算法线这样所有像素可以同时并行计算性能很高。对于技术 demo用 CPU 在128 × 128分辨率下做逐像素计算也完全够用。把内部高度场分辨率保持在 128 或 256再通过 Canvas 2D 放大到 512 或 1024 显示能兼顾性能和输出效果。5. 最小可运行原型Canvas 的高度场雕刻画笔5.1 原型目标为了让前面的原理落到代码上我准备了一个可以直接保存运行的 HTML 文件。它不需要安装任何依赖你把它保存成sculpt-draw-demo.html双击打开就能运行。这个原型重点验证三件事按住左键拖动能在画布上绘制颜色并同时增高画布表面。按住右键拖动能在画布上雕刻下凹。点击“平滑”后高度过渡会变得更自然。代码里使用Float32Array保存高度数据用Uint8ClampedArray保存反照率颜色。内部高度场分辨率为 128实际显示画布为 512方便在性能不高的网页里做实时展示。5.2 完整代码!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleHeightfield Sculpt Draw Demo/title style body { font-family: system-ui, sans-serif; margin: 16px; background: #f5f5f5; } canvas { border: 1px solid #ccc; background: #fff; cursor: crosshair; touch-action: none; } #toolbar { margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; padding: 10px; background: #fff; border: 1px solid #ddd; border-radius: 6px; max-width: 512px; } #toolbar label { font-size: 13px; } button { padding: 4px 10px; cursor: pointer; } /style /head body h2Sculpt Draw Demo高度场雕刻画笔/h2 div idtoolbar button idsmoothBtn平滑/button button idclearBtn清空/button label 颜色 input typecolor idbrushColor value#4dabf7 / /label label 笔刷半径 input typerange idradiusInput min2 max18 value7 step1 / /label label 力度 input typerange idstrengthInput min0.02 max0.3 value0.08 step0.02 / /label /div canvas idcanvas width512 height512/canvas p stylecolor:#666; font-size:13px; 说明按住鼠标左键 绘制并隆起按住鼠标右键 雕刻凹陷拖动时自动插值。 /p script const canvas document.getElementById(canvas); const ctx canvas.getContext(2d); const W canvas.width; const H canvas.height; // 内部高度场分辨率 const RES 128; const height new Float32Array(RES * RES); const albedo new Uint8ClampedArray(RES * RES * 4); function initAlbedo(color) { for (let i 0; i RES * RES; i) { albedo[i * 4] color[0]; albedo[i * 4 1] color[1]; albedo[i * 4 2] color[2]; albedo[i * 4 3] 255; } } initAlbedo([245, 242, 237]); // 用来渲染的小画布 const internalCanvas document.createElement(canvas); internalCanvas.width RES; internalCanvas.height RES; const internalCtx internalCanvas.getContext(2d); const rgb hex [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16) ]; let brushColor rgb(document.getElementById(brushColor).value); document.getElementById(brushColor).addEventListener(input, e { brushColor rgb(e.target.value); }); const radiusInput document.getElementById(radiusInput); const strengthInput document.getElementById(strengthInput); let drawing