像素画里的填充和取色经常被低估。填充按钮看上去只是“把同色区域换成新色”,取色按钮也只是“读一个格子的颜色”,但它们都直接碰到编辑器最核心的数据:透明色怎么处理、半透明颜色怎么保留、取色应该读活动层还是合成结果、一次填充怎样不把历史记录拆成几百个小操作。bitArt 让像素服务返回下一份像素和可选的 pickedColor,再由图层与工作流决定读合成色、写活动层以及更新编辑状态。

填充前先分清目标颜色与透明色

如果把填充写成递归函数,小画布也许看不出问题,尺寸升到 128×128 或 256×256 后就可能遇到调用栈过深。另一类问题是用当前颜色判断是否继续扩散,却忘了比较起始格颜色,导致同色区域没填完或把本不相连的区域改掉。取色则容易犯“顺手切到画笔”的错:用户本来可能只想确认颜色,不应该被强制改变当前工具。

谁负责读取颜色,谁负责写入颜色

填充和取色虽然来自同一次点按,却分别属于写操作和读操作。把它们在服务返回值里拆开,可以让“吸到颜色”不意外制造一条历史记录。

取色不应该偷偷改动画笔状态

正常编辑路径先在 LayerCanvasService 处理 picker:它读取所有可见图层合成后的颜色,不受活动图层锁定状态影响,也不修改任何图层像素。完全透明的位置会保留内部透明标记;其他颜色按合成结果作为 pickedColor 返回。fill、pencil 与 eraser 再进入活动层写入路径,具体像素计算由 PixelCanvasService 完成;当填充透明度为 0 时,它直接返回原数组,不让一次无效点击进入后续链路。真正填充时会把 activeColor 按透明度转成内部颜色格式,然后从起始索引向四个相邻方向扩散。

这种安排让图层服务可以复用单层逻辑。它只需找到活动层,把工具结果放回对应图层,而工作流只在 pickedColor 非空时更新活动色。取色因此是“读状态”,填充才是“改状态”,两者的副作用不会混在一起。

取色在图层服务中直接读取合成色,填充才会继续检查活动层锁定状态并写入单层像素。

if (toolId === 'picker') {
  return {
    layersPixels: layersPixels,
    pickedColor: LayerCanvasService.getCompositePixel(layersPixels, layers, width, row, col)
  };
}
const layerIndex = LayerCanvasService.getLayerIndex(layers, activeLayerId);
if (layers[layerIndex].locked) {
  return {
    layersPixels: layersPixels,
    pickedColor: ''
  };
}

广度优先遍历更适合大面积同色区域

填充使用队列和 visited 数组。队列中的索引先被标记,出队时再次确认它仍是目标颜色,再写入新颜色并尝试上下左右四个邻居。这样不会在大块同色区域里反复访问同一个像素,也避免递归深度随着区域面积增长。它目前是四邻域填充:斜对角相同颜色不会因为只接触角落而连在一起,这更符合大多数像素画工具的直觉。

const nextPixels = pixels.slice();
const visited: boolean[] = new Array<boolean>(pixels.length);
const queue: number[] = [startIndex];
visited[startIndex] = true;
while (queueIndex < queue.length) {
  const index = queue[queueIndex++];
  if (pixels[index] !== targetColor) continue;
  nextPixels[index] = color;
  const row = Math.floor(index / width);
  const col = index % width;
  // 仅在行列边界允许时,将上下左右同色邻居加入队列
}

读色与改色走两条不同的状态路径

工作流拿到 LayerToolResult 后,会根据当前工具计算是否需要脏区。填充通常会影响不规则的大区域,不应该假装它只有一个小矩形;渲染层可以据此选择完整刷新。取色没有像素变化,只更新活动颜色,也不应创建撤销快照。活动图层锁定时由图层服务拒绝写入,读色路径仍可保持为只读。

填充会改写活动层而取色只更新活动色,这两个结果都应由工作流统一收束,避免组件各自写状态。

const result = LayerCanvasService.applyTool(target.layersPixels, target.editorLayers, target.activeLayer,
  target.canvasWidth, target.canvasHeight, row, col, target.activeTool, target.activeColor,
  EditorCanvasDrawingWorkflowService.getActivePaintSize(target),
  EditorCanvasDrawingWorkflowService.getActiveToolOpacity(target));
EditorCanvasStateService.setLayersPixels(target,
  EditorCanvasDrawingWorkflowService.maskLayersPixelsToSelection(
    target, target.layersPixels, result.layersPixels), dirtyBounds);
EditorCanvasDrawingWorkflowService.applyPickedColor(target, result.pickedColor, hooks);

填充和取色中最容易误判的地方

  • 开始填充前必须判断 targetColor 与新颜色是否相同,否则会做一轮没有意义的遍历。
  • 队列索引必须根据 width 还原 row 和 col,左右邻居不能跨到上一行或下一行。
  • 取色读取的是可见图层的合成结果,不要误写成只读取活动层;完全透明时仍要按内部透明色语义处理。

填充性能和视觉正确性要同时考虑。小面积区域可以局部更新,大面积或边界复杂的区域应当让渲染层走更稳妥的完整路径。把算法限制在活动图层,也保证了填充不会穿透隐藏层或半透明合成结果:用户编辑的是层数据,不是最终显示的合成图。

为填充范围建立可复现样本

填充功能适合准备一组很小但结构清晰的像素样本:封闭色块、两个只在对角接触的色块、被透明格切开的区域、同一颜色分布在多个图层的场景。用这组样本先确定四邻域规则,再观察填充后活动层以外的图层是否保持不变。若直接用一张复杂作品测试,出现错误时很难判断是连通算法、图层路由还是渲染缓存的问题。

透明度也应单列测试。填充 100% 的不透明色、50% 的半透明色和 0% 的无效填充,结果的差别不仅是画面颜色,还包括是否产生历史、是否需要保存。取色则要从不透明格、半透明格和透明格分别读取,确认返回值没有把内部透明标记泄漏到用户可选颜色中。把这些样本保留下来,之后优化渲染时也能防止改色逻辑被误伤。

填充后的渲染选择也要保守

填充区域的形状通常不是规则矩形。一个 64×64 画布里,用户可能从细缝进入一大片背景,最终影响数千个像素。此时工作流不应该把起点附近的小矩形当成脏区并强迫渲染器局部清除,因为旧颜色可能分散在画布各处。更稳妥的做法是让填充结果按实际刷新策略走完整绘制,局部优化留给能准确描述影响范围的画笔和直线。

半透明填充还有一个语义需要提前说清:它把当前颜色按填充透明度转换为内部颜色,再替换连通区域的原色;它不是在每一个目标格上做图层级的混合。图层的 source-over 合成仍发生在之后的图层合成阶段。把这两层概念分开,用户调 50% 填充时才不会误以为它会自动保留目标像素的全部颜色信息。

怎样确认改色范围没有跑偏

填充检查要把连通性、透明度和锁层三个维度拆开,避免只在最简单的色块上验证。

  • 在封闭区域、多个不相连同色区域和带透明洞的区域分别填充,确认只有四邻域连通部分被修改。
  • 把填充颜色设成起始格同色、透明度设为 0,确认像素、历史与保存状态都不改变。
  • 改变图层可见性后在同一格取色,确认返回的是新的合成色,但当前工具不被强制切换。
  • 锁定活动层后填充,确认没有改色;随后解锁重复操作,确认一次填充只产生一个撤销步骤。
填充与取色检查记录
封闭色块:只改起点所在的连通区域
取色:读取可见图层合成色且不修改像素
同色填充和 0% 透明度:不创建历史
锁定层:填充无效,取色保持只读

填充与取色的能力边界

当前填充以精确颜色字符串作为连通条件,不包含容差填充、按合成后的可见色填充或跨图层填充。对像素画的离散色块而言这是更可预测的默认行为;若引入容差,需要额外定义 RGBA 距离和半透明比较规则。

这一篇的重点是把“读取合成色”和“大面积修改活动层”放进两条副作用清楚的数据路径。接下来把视线移到选区:怎样让一块像素可以移动、清除,并且不在拖动中留下拖影。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐