刚开始看到"ArtCraft"这个名字的时候,大多数人会以为它又是一个给游戏美术团队用的素材平台。但如果你在独立开发或者小团队里待过,就会明白市面上缺的往往不是一个"能画画的软件",而是一个"能让你把脑子里的立体造型快速搭出来、还不至于被复杂操作劝退"的工具。ArtCraft 这个项目,一开始只是我自己练手用的体素编辑器原型,结果越做越顺手,索性把它做成了一套可以正经出作品的完整流程。这篇文章就把我整个开发过程中的核心思路和技术选型记录下来,包括数据结构的取舍、渲染管线的搭建、以及一个又一个的坑。
1. 为什么偏偏叫 ArtCraft:从一个体素迷的痛点说起
体素这个概念并不新鲜,但大部分现成的体素编辑器要么偏向"涂鸦玩具",要么就绑定了特定游戏的存档格式,想拿来做点独立作品,总感觉哪里别扭。我自己以前用别的工具画模型时,最痛苦的就是两件事:一是造型到一半想改某个细节,结果整个区块的数据都得跟着重建;二是导出到通用三维软件时,网格质量一塌糊涂,顶点乱飞。所以当决定自己写一个编辑器时,目标就很明确:它要能像搭积木一样自由,又要能用工程化的方式管理数据,最好还能一键导出干净的网格。
ArtCraft 这个名字其实就是"Art"加"Craft",艺术和手工拼接的组合。我希望它给人的感觉不是冷冰冰的参数化建模工具,而是一个类似"做手工"的工作台。你不用记一堆快捷键组合,你只需要像捏粘土一样去增删方块、调整颜色、刷材质。整个项目的定位也由此确定:面向独立创作者、原型设计师、像素艺术爱好者和游戏关卡原型师,提供一套低门槛、高可控、可扩展的体素建模方案。
这个定位也决定了我在技术选型上的方向。编辑器不能太过于依赖显卡的光线追踪特性,因为用户的机器参差不齐;数据结构不能一上来就追求极致压缩,因为交互响应比节省几百兆内存更重要;网络协议暂时不做,因为单机稳定永远是第一位。很多项目野心太大,结果死在一半,我宁愿先把基础体验打磨到"自己画得停不下来",再去想协作和云端的事情。
在实际动手之前,我画了一张很简单的功能清单作为设计基准。第一版必须有:基本选区、方块增删、颜色替换、大范围填充擦除、自动网格优化、撤销重做、导出 OBJ 和 GLTF。第二版才考虑:镜像对称、平面切割、自定义调色板、外部模型导入、场景文件序列化。现在回看这份清单,它救了我很多次,因为只要新想法和这份清单冲突,我就果断砍掉暂缓。
2. 体素数据结构的选型:Dense 数组之外的第三条路
我最早写的原型用的是最朴素的三维数组,也就是 Dense 存储。假设一个 16x16x16 的区域,每个位置存一个颜色索引,跑起来确实飞快,做增删方块就是改一个数组下标,完全没有任何心理负担。但一旦把尺寸放大到 128x128x64 这种稍微实用一点的范围,内存和迭代开销立刻变得不友好。纯 Dense 存储若要支持多套材料属性,体积会成倍上升;更麻烦的是,很多区域其实是空的,空气所占的存储完全浪费了。
后来我研究了常见体素渲染引擎的做法,发现现阶段比较流行的是三种路线:线性八叉树、RLE 行程编码、以及区块化稀疏存储。我自己在 ArtCraft 里采用的是区块化方案,也就是把整个建模空间划分成很多个小 cube chunk。每个 chunk 内部仍然用一个相对紧凑的字节序列存储体素类型和颜色索引,但只对非空气区域做实体化描述。这样做的优点是:当你在编辑器的某一小块区域加方块时,只需要在对应 chunk 上做局部更新,完全不用重建全局结构,撤销操作也可以以 chunk 为粒度做快照。
这里有一个很关键的取舍要讲清楚。既然目标是"像手工一样创作",那么体素模型的体积往往不会像地形那样动辄几百万个方块,大多数作品集中在几万个到几十万个方块的量级。在这个体量下,过度复杂的空间索引不但无法带来明显性能提升,反而会显著增加代码维护难度。因此 ArtCraft 里,默认的编辑空间是 256x256x160,采用的 chunk 尺寸是 32x32x32。靠近编辑区的活跃 chunk 会被缓存成即时的 Dense 数组以方便算法处理,而离编辑器视点较远的 chunk 则以紧凑序列存储等待状态同步。实际测试下来,一个包含约 14 万方块的场景,内存占用只有纯三维数组方案的六分之一左右,而方块拾取、放置、填充这些高频操作的延迟基本都能控制在 1 毫秒以内。
代码层面,我定义了简单的体素数据类型。每个方块仅用一个字节记录其"大类",例如空气、实心、半透明、液体等,再用另一个字节记录它在全局调色板中的颜色索引。这样每次渲染或物理检测时,不需要去读取完整的材料属性结构,性能确实友好。水体、玻璃、发光体这类特殊材料先不进入基础数据结构,而是作为后续扩展属性挂在 super block 上面,只有在特定操作时才被解压出来。
3. 从方块到造型:核心建模操作的设计思路
数据结构只是地基,真正让一个体素编辑器好用的是交互和操作算法。ArtCraft 的交互模型其实特别简单:你拿一把"虚拟方块刷子",在三维网格上点一下,它就往那个格子里放一个方块;长按并拖动,就能连续放置。点一下减号模式,又可以连续删除。听起来是不是太简单了?但把细节抠到位后,你会发现这比那些拖拽一堆控制点的建模软件更容易出活。
第一个要注意的细节是拾取精度。因为体素编辑是在网格对齐的格子上操作,你看到的方块其实是一个个平行投影后的四边形表面。在拾取时,我做了两步检测:先用射线和当前 chunk 的包围盒相交,得到进入网格空间的参数 t;然后以这个 t 为起点,沿着射线方向逐格子步进,找到第一个非空气的体素格子。为了不让方块的角部在拾取时产生视觉偏差,我把方格之间的边缘处理成略微内缩的边框模型,渲染出的线框与实际数据严格对齐。这个小细节做起来花不了半天,但观感提升非常明显。
相比单纯的增删,更关键的是选区操作。ArtCraft 里提供了三种选区工具:正方体选区、球体选区、以及自由绘制选区。正方体选区本质上是两个三维坐标的最小/最大值的组合,任何遍历只需要套两层循环。球体选区则要通过给定的半径计算出一个包含所有受影响格子的坐标集合,对每个格子判断是否满足距离约束。自由绘制选区更复杂,它的输入是屏幕上的一组手绘轨迹,我会先把这些二维轨迹投影到当前对齐平面上,然后做一次栅格化,凡是与轨迹相交的格子都进入候选集。经过这层处理,用户可以用鼠标像画遮罩一样精确划定修改范围,非常直观。
填充和擦除是体素编辑器里最需要小心的功能。如果直接用洪水填充,遇到一个大而复杂的区域会慢到让人崩溃。我在这里采取了一个很实用的折中方案:对普通填充操作,使用基于队列的"只扩散至非空气格子"算法,同时设置一个步数上限,当达到几十万次操作后自动终止并提示用户缩小范围。对于大范围的平面填充,则会先切出该平面对应的二维区域,在二维层面完成连通性判断后一次性回写体素数据,速度和稳定性都有保障。
填充算法里还有一个比较隐蔽的问题:颜色混合。当你用新颜色覆盖旧颜色时,如果完全按格子逐位替换,边缘处容易留下"接缝感"的单层体素,渲染后像是裂缝。这是因为相邻体素的法线方向差异突然变大,光照接续不上。后来我给填充功能加了一个"颜色边缘渐变"选项,默认关闭,开启后会对填充边界做一圈只影响颜色索引的过渡扩散,以 3x3x3 邻域做加权平均。虽然它不是物理层面的平滑,但在视觉上能有效软化体素特有的锯齿边,很多用户都反馈这个功能"莫名解压"。
撤销和重做我采用的是多级快照方案。由于体素数据大部分在 chunk 内存储,每次操作结束后,我只会把被改动过的 chunk 的紧凑序列保存到撤销栈里。如果一次操作跨越了大量 chunk,那么快照会带上区块索引清单,后续重做时直接按清单对比。这个经济策略最大的好处是内存开销小,即使连续操作几百步,也不会把内存打成气球。我实际测试过连续删除一片 80x80x20 的实体区域,每次撤销耗时基本在 15 毫秒以内,完全不影响创作节奏。对于追求极致实时性的用户,还可以开启自动压缩快照,系统会每隔几次操作做一次增量合并,代价是撤销历史的粒度变粗。
4. 渲染那几步:把体素变成顺畅画面的关键管线
体素编辑器的画面流畅度直接决定创作体验。如果一个画笔下去要等上两三帧才看到画面更新,你会下意识地认为这个工具很"钝"。ArtCraft 的渲染管线并不走复杂的实时光追,而是老老实实用传统光栅化配合高效的网格生成。核心原则是:永远只渲染与空气相邻的六面体表面,也就是常见的 greedy mesh 优化思路。
网上的 greedy mesh 教程很多,但多数只是针对单一颜色实体,碰到多颜色、多材料、半透明时就会露怯。我在 ArtCraft 里用的是一种简化但稳定的实现:先沿着 Z 轴逐层扫描每个单元,尝试在 X-Y 平面内合并同一材料的单元面,形成更大的矩形;然后再对 Y 轴做一次类似的合并。这样生成的面数相比"一个格子六个面"的暴力方案大概可以减少 70% 到 80%。对一个 100x100x50 的雕像模型,暴力方案可能会有几百万个三角形,而经过合并处理后可以降到 20 万左右,普通独立显卡已经可以轻松跑满 60 帧。
材质渲染上,我没有引入过于复杂的 PBR 参数,给每个体素材料定义了三项基本属性:基础颜色、粗糙度、以及自发光强度。在着色阶段,我用一个简单的 Blinn-Phong 高光配合方向光的阴影贴图来做光照,效果好过大部分同级别的体素编辑器,且场景构建非常简单。半透明材料如水、玻璃会被单独分到一个渲染批次,按距离排序后优先绘制深度值较大的部分,然后启用混合模式,这样能避免透明物体相互遮挡时出现的明显错误。
真正让画面"有立体感"的点是法线计算。传统做法是为每个单元面指定固定的朝向法线,这在几何体表面平坦时看起来还行,但遇到台阶状和起伏边缘时,光照会显得特别生硬。我在 ArtCraft 里对每个顶点做了一次基于邻域体素分布的法线扰动。具体说来,就是检查该方格周围哪些方向存在邻接方块,如果某个方向没有邻接,就对法线做一个微小的偏移,让倾斜面看起来更平滑。虽然物理上不太严谨,但从观感上接近于柔和阴影的效果,体素建筑一下子就从"像素块"升级成"棱角分明的立体模型"。
渲染管线的另一个大头是视锥裁剪和场景更新。ArtCraft 使用很简单的场景图来组织 chunk,但每个 chunk 拥有一份独立的顶点缓冲。当编辑操作发生,我只重新生成被修改的 chunk 对应的网格,然后上传到 GPU。这样,即使画面里同时存在几百个 chunk,也只有少数几个需要更新,性能压力就转移到了网格生成算法这一侧。为了让网格生成足够快,我把所有循环尽量做成可并行的形式:先用外循环收集所有被修改的候选面,再用内部分块任务队列交给工作线程处理,最后在主线程完成 buffer 提交。实测一个约 30x30x30 区域的快速涂刷操作,从数据变更到画面反映出来的链路大概 8 毫秒,基本达到了"所见即所得"。
5. 导出与生态:让作品不局限于编辑器
如果只是在内置编辑器里画得开心,那这个工具的价值就打折扣了。真正能融入创作者工作流的,必须是开放的导出能力和稳定的文件格式。ArtCraft 在项目初期就定下目标:既能输出常见的 OBJ 格式,又能输出更现代的 GLTF 2.0 格式,并且保证导出后的模型在第三方软件里的表现与编辑器预览基本一致。
OBJ 导出相对简单,但有一个容易踩坑的地方:顶点坐标轴的朝向。不同的三位软件使用不同的坐标轴约定,如果直接按照原始数据写,导入进去模型可能是倒的。我的做法是在导出设置里提供 Y-UP 和 Z-UP 两个选项,默认使用 Y-UP,这是大多数游戏引擎和建模软件的标准。颜色信息方面,OBJ 使用基础的 MTL 文件来定义每个材质的颜色和粗糙度,确保在不同的查看器里都有一致的观感。
GLTF 导出则要做更多工程化的处理。除了网格数据,还得把调色板映射为独立的材质实例,把自发光、金属度、透明度的参数一一对应到 GLTF 的扩展字段里。因为 GLTF 是二进制格式,如果直接先写文本再转换会浪费内存和时间,我在实现时直接将网格数据打包为二进制存取块,再拼接 JSON 描述头。对于一张 20 万面的场景,导出耗时基本控制在 2 到 3 秒左右。导出之前,我还会执行一次顶点去重和网格清理,把隐藏的重叠面移除,进一步减小文件体积。
不少用户希望把 ArtCraft 里的场景直接放进游戏引擎或者在 3D 打印软件里切片处理。为了覆盖这些场景,我额外加入了固定大小的尺寸校准功能。比如约定一个体素对应的真实尺寸,用户可以把设置切换为"1 体素 = 1 毫米",导出 OBJ 时就会按这个比例缩放模型。3D 打印条件下,一个立方体的底座可能会因为悬空结构需要自动增加支撑,所以我又加了一个简单的体素镂空检查:自动识别所有内部封闭空腔,并允许导出时将这些空腔填充为实体。这样打印出来的模型不光外观完整,也不需要后期手动修补。
我还做了一套轻量的场景文件格式,扩展名为 ARC,里面包含了调色板、地图数据、相机视角和操作历史。这个格式没有采用常见的数据格式重造轮子,而是直接把 JSON 头加上二进制块拼在一起,解析起来特别直接。每当你 Ctrl+S 保存场景,系统都会把当前活跃 chunk 的紧凑序列序列化,再附带一个全局哈希值。有了哈希值之后,下次打开场景能迅速判断哪些 chunk 不需要重复加载,因此大场景的打开速度也能保持稳定。
6. 填坑记录:我踩过的几个典型问题
开发过程中踩过的坑林林总总,这里挑几个最典型的记录一下,每个拉出来都能节约不少人半天时间。
第一个坑是贪婪网格合并时的颜色边界错误。早期我直接按照资料上的算法把同色方块合并成大面,结果相邻不同颜色但几何连续的表面出现了大量"三角形跳跃"。这是因为贪婪网格的边长不一致,在跨颜色的边界处法线插值会产生突变。修复方法倒不复杂:在每个候选矩形的合并过程中,多判断一次相邻格子的调色板索引,一旦不一致就强制截断;同时把顶点切线信息一并生成,避免双色交界处的阴影异常。
第二个坑是半透明体素的排序渲染问题。当透明块和非透明块交错存在于同一视线路径时,先画谁、后画谁直接决定了最终颜色。我第一版直接把透明块和非透明块混在同一批次渲染,结果玻璃内部的碎片显示成一片混乱。后来我调整策略,将场景中的所有非透明网格先渲染,然后关闭深度写入但保留深度测试,再绘制透明网格,并按照它们到相机的距离排序。这样分批次之后,体素玻璃终于有了通透效果。
第三个坑比较冷门,是撤销栈和 chunk 合并的冲突。当两次小操作恰好落在同一个 chunk 的不同区域,快照会保存该 chunk 的完整初始状态,这本来没问题。但如果一次撤销只应回滚其中一个区域,另一个区域的操作要保留,直接恢复整个快照就会把后者的操作抹掉。解决办法是给每个 chunk 增加一个"操作区间列表",在每个快照里记录具体操作的网格坐标范围。撤销时只恢复这个坐标范围内的数据,其他区域的数据保留不动。
第四个坑和编辑器响应速度有关,可能更适合有渲染经验的人参考。有一次我升级了顶点缓冲的更新策略,做成了"标记所有待更新 chunk,下一帧统一提交",结果一遇到大量小操作,GPU 缓冲区的更新额高达数百兆,帧率瞬间掉到 20 多帧。后来我判断问题不在于提交频率,而在于每帧都执行了 buffer orphan,频繁分配新显存。最后的优化是每帧只处理一个 chunk 的更新请求,其余都排入队列,并复用空闲 buffer,这才把帧率稳定回来。这个经验告诉我,体素编辑器的瓶颈往往不是算法复杂度,而是你的更新节奏设计过于激进。
7. 分享一个小技巧:用画笔模拟圆滑边缘
身体素模型总给人"硬边"的印象,但很多作品其实可以通过颜色技巧来模拟圆滑边缘。我在 ArtCraft 里试验过一个很有意思的思路:在目标形状的边缘处不直接换成相邻的颜色,而是插入一到两层过渡色,让表面产生"像木雕打磨过"的渐变感。因为体素本质上是离散的方块,过度色层不会增加很多数据量,但视觉上能模拟出圆角的反射变化。这个方法特别适合做像素风格的卡通角色,很多人第一眼都以为我用了曲面建模。
如果你也打算做一个体素编辑器,我的建议是别一上来就追求无限世界或者多人协同,先把核心的单人创作循环调到最舒服,把导出格式做干净,这个领域其实没有那么多竞争,愿意认真打磨基础体验的人少之又少。ArtCraft 目前已经可以支撑我每天快速记录一些想法性草图,偶尔也出片用来演示小关卡原型,效果比预想中好不少。后面我大概率会继续补充导入 MOCUP 格式的支持,让积木爱好者和体素创作者有一座现成的桥。说到底,好的工具不是功能越多越好,而是让你想表达的东西能马上表达出来,这个道理放到任何创作软件上都成立。