Deluxe Paint(以下简称 DPaint)诞生于 1985 年,是 Amiga 平台上最具影响力的图像编辑软件。它的成功很大程度上依赖于 Amiga 独特的硬件架构 —— 特别是 OCS(Original Chip Set)和 ECS(Enhanced Chip Set)提供的 12 位色深、Copper 协处理器的调色板切换能力,以及 Blitter 的像素操作单元。要在现代浏览器中完整复现 DPaint 的创作体验,仅靠 Canvas 2D API 的像素操作是远远不够的。steffest 开发的 DPaint.js 项目提供了一种思路:用 WebGL shader 来模拟 Amiga 硬件的渲染行为,从而在保持向后兼容 Amiga 文件格式(如 IFF ILBM、ADF)的同时,让现代用户也能体验到当年受限色彩下的创作美学。
Amiga 硬件渲染模型的核心限制
Amiga OCS 的色彩系统建立在严格的硬件限制之上。图形芯片通过 32 个颜色寄存器提供输出,每个寄存器占用 12 位(红、绿、蓝各 4 位),这意味着理论上最多可显示 4096 种颜色。与现代显卡的 24 位或 30 位色深相比,这个数字显得微不足道,但 Amiga 的设计者们巧妙地利用了这个限制,创造了独特的创作范式。
OCS 的位图(Bitplane)结构将像素深度与可同时显示的颜色数量解耦。一个 1 位深的位图只使用两种颜色,而 5 位深的位图(使用全部 32 个颜色寄存器)可以同时显示最多 32 种颜色。这种设计使得开发者可以根据图像复杂度选择合适的色彩深度,而非被固定色深所束缚。DPaint 支持从 1 位深(2 色)到 5 位深(32 色)的所有模式,并且能够导入 Amiga 标志性的 HAM(Hold And Modify)模式 —— 这种模式允许在保持颜色数量限制的同时,通过修改已存在颜色的分量来合成新颜色。
ECS 引入了更多的颜色寄存器(64 个)和更高的分辨率支持,但核心渲染模型保持不变。在 DPaint.js 中,可以通过菜单将调色板限制为 12 位(OCS/ECS 模式)或 9 位(Atari ST 模式),这种硬件仿真层的存在是理解其架构的关键。不同于简单的色彩量化,现代浏览器中的复刻必须考虑 Amiga 硬件的行为特性,包括色彩寄存器的写入时序、Copper 触发的调色板切换,以及不同分辨率模式下的像素宽高比。
抖动算法的着色器实现
当图像包含的颜色超过可用调色板时,必须通过抖动(Dithering)来维持视觉连续性。DPaint.js 内置了 16 种不同的抖动算法,从最简单的检查板(Checks)模式到复杂的 Floyd-Steinberg 及其变体。这种丰富的选择源于 Amiga 时代的实践积累 —— 不同的抖动算法会产生截然不同的纹理感和视觉噪点分布,创作者会根据最终用途(打印、屏幕显示、游戏素材)选择最合适的算法。
在 WebGL 中实现这些抖动算法需要将整个抖动过程封装为片元着色器(Fragment Shader)。以 Floyd-Steinberg 抖动为例,其核心思想是将量化误差扩散到相邻像素。具体而言,当前像素的误差按照特定比例(通常是 7/16、3/16、5/16、1/16)分配给右侧、下方及左下 / 右下方的像素。这种误差扩散机制在传统 CPU 实现中需要按行扫描并维护误差状态,但着色器天然支持像素级并行处理,因此可以采用基于坐标的确定性随机函数来模拟误差扩散行为。
检查板模式的抖动实现则更为直接。对于每个像素,只需计算其坐标的奇偶性:如果 (x + y) % 2 == 0 则使用调色板中较亮的颜色,否则使用较暗的颜色。这种二值抖动在 2 色或 4 色模式下非常有效,能够产生清晰的图案感。DPaint.js 中的 Checks 变体(从 very low 到 very high 2)实际上是通过改变检查板单元的尺寸来控制抖动密度 ——very low 使用 1 像素单元,very high 则使用 8 像素或更大的单元,从而在保留细节和产生艺术效果之间取得平衡。
更高级的抖动算法如 Jarvis-Judice-Ninke 或 Stucki 采用了更大的误差扩散矩阵,这些算法的扩散范围覆盖更多相邻像素,因此产生的视觉噪点更加分散,纹理感更强。在 WebGL 实现中,这意味着着色器需要采样更多周围的像素,计算量随扩散半径平方增长。对于实时编辑器而言,可以预先计算误差扩散核并作为卷积核传递,或者使用多通道渲染(Multi-pass Rendering)来分阶段处理误差累积。Atkinson 抖动是 DPaint.js 提供的另一种选择,它刻意忽略了部分误差扩散(只扩散到右、下、左下、右下四个方向),因此产生的图像更亮、细节更锐利,常用于早期的 Macintosh 系统。
调色板管理的状态机设计
Amiga 的调色板管理远非简单的颜色数组查找。Copper 协处理器允许在扫描线的任意位置修改颜色寄存器,这为实现扫描线级别的调色板切换提供了硬件支持。DPaint 中的 Color Cycling 功能正是利用这一特性:定义一组颜色索引作为循环池,Copper 在每个垂直空白期(VBlank)递增或递减这些索引的指针,从而产生 "流动" 的动画效果,而不需要 CPU 逐帧重绘图像。
在 DPaint.js 的 WebGL 实现中,调色板被实现为一个 1D 纹理数组,每个颜色占用 4 个字节(R、G、B、A)。当 Color Cycling 激活时,着色器不再直接从调色板纹理中读取固定索引,而是通过一个动态计算的偏移量来确定实际读取的位置。这个偏移量由 JavaScript 层的动画循环根据当前帧时间更新,并通过 Uniform 变量传递给着色器。关键在于偏移量的计算必须与 Amiga 的行为一致:Color Cycling 通常作用于连续的调色板区间,且可以设置循环方向和速度。
HAM 模式的实现则更为复杂。在这种模式下,像素的颜色值可能直接编码为颜色寄存器的索引,也可能编码为要修改的颜色分量。例如,一个 6 位的 HAM 像素中,最高位表示模式(0 = 直接索引,1=HAM),接下来的 2 位选择要修改的分量(00 = 蓝,01 = 绿,10 = 红),剩余的 4 位提供分量的新值。在 HAM 模式下,每个像素的实际颜色取决于其左侧像素的颜色值加上当前像素指定的分量修改。着色器需要维护一个水平扫描线级别的状态变量来追踪 "基础颜色",并在每行开始时重置为调色板的第一个颜色。
调色板的编辑功能(包括从图像提取、预设加载、手动调整)本质上是在维护一个状态机。当用户修改某个颜色寄存器时,系统需要立即更新对应的调色板纹理,并在必要时触发重新渲染。DPaint.js 支持 20 多种预设调色板,包括经典的 DawnBringer 系列、Grafxkid 系列,以及 PICO-8、CGA、ZX Spectrum 等复古平台的色彩空间。这些预设不仅提供了一致的色彩参考,也使得从其他平台迁移素材时的色彩映射有了可预测的基准。
笔刷混合与 Blitter 仿真
Deluxe Paint 的笔刷系统是其创作体验的核心。不同于现代绘图软件将笔刷简单地视为不透明度可调的形状,DPaint 的笔刷融合了 Amiga Blitter 的硬件特性。Blitter 是一个专用的位图运算单元,能够在单个 DMA 周期内执行位逻辑操作(AND、OR、XOR、ADD)和矩形填充。DPaint.js 通过 WebGL 的多重渲染目标(MRT)或分阶段渲染来模拟这些操作。
笔刷的混合模式在着色器中实现为逐像素的颜色运算。最简单的 Replace 模式直接将笔刷像素写入目标,而不会考虑目标像素的原有内容。Multiply 模式则计算源颜色与目标颜色的乘积(通常需要先将索引颜色转换为 RGB),产生类似叠加的效果。Screen、Overlay、Soft Light 等高级混合模式在 DPaint.js 中同样可用,它们通过不同的数学公式组合源颜色和目标颜色的分量。这些混合模式的实现难点在于保持与调色板索引颜色的兼容性 —— 混合操作必须在 RGB 空间进行,结果又需要映射回最近的调色板颜色以维持索引图像的特性。
Smudge(涂抹)工具是 DPaint 的标志性功能之一,它将当前像素的颜色 "拖拽" 到相邻位置,同时将当前像素设置为周围像素的平均值或某种聚合运算。在硬件层面,这可以通过 Blitter 的 A=B+C 模式实现(A 目标 = B 源 + C 掩码)。着色器实现需要维护一个滚动缓冲区来存储最近几行的像素值,并在每帧更新时向前滚动。这种状态依赖使得纯并行着色器实现变得困难,通常需要结合 JavaScript 层的状态管理和 GPU 计算。
对于 Amiga 文件格式的导入导出,DPaint.js 实现了完整的 IFF ILBM 解析器。ILBM 格式使用 16 进制的颜色深度前缀、压缩类型字节和可选的 Copper 列表信息。解析器必须处理多种压缩算法,包括 PackBits(类似于 LZW 的行程编码)和专门的位图压缩。导出的调色板被编码为标准的 32 颜色条目序列,每个条目占 3 个字节(红、绿、蓝),这与 WebGL 纹理格式完全兼容。HAM 图像的导入则需要额外的解码步骤,将 6 位或 8 位的 HAM 像素转换为 24 位 RGB,以便在 WebGL 中进行后续处理。
工程化参数与性能调优
在浏览器中实现完整的 Amiga 硬件仿真需要仔细的性能规划。DPaint.js 采用零依赖的纯 JavaScript 架构,所有图形操作都通过 Canvas API 或 WebGL 完成。对于 320×256 或 640×512 分辨率的图像,Canvas 2D 的性能足以应对大多数编辑操作;但对于全屏实时预览和着色器特效,WebGL 成为必选项。
调色板纹理的大小通常是 32×1 像素(对于基本模式)或 64×1 像素(对于 ECS/AGA 模式),这使得着色器中的纹理查找开销几乎可以忽略。抖动算法的性能瓶颈在于需要访问相邻像素 ——Floyd-Steinberg 的 4 像素扩散需要 5 次纹理查找(中心 + 4 个邻居),而 Stucki 算法的 13 像素扩散则需要 14 次查找。对于 640×512 的画布,这相当于每帧数万次纹理采样,现代 GPU 可以轻松处理这一负载,但如果在移动设备上运行,可能需要降低采样精度或使用降级算法。
Blitter 仿真的性能优化依赖于减少 CPU-GPU 数据传输。当用户执行复制、粘贴或变形操作时,源位图被存储为 TypedArray(Uint8Array),修改后通过 gl.texSubImage2D 上传到 GPU。这种批量更新模式避免了逐像素的 gl.drawPixels 调用,是保持交互流畅的关键。JavaScript 层维护位图的 CPU 镜像,GPU 端只负责最终渲染,两者通过脏标记(Dirty Flag)机制同步。
内存占用方面,一个 640×512 的 8 位索引图像需要 320KB 存储(无压缩),加上同样大小的 RGBA 预览缓冲和若干调色板纹理,总计不超过 2MB。这对于现代浏览器而言微不足道,但 Amiga 时代的开发者必须在 512KB 或 1MB 的 RAM 限制内完成所有操作。DPaint.js 的离线运行能力意味着它不依赖网络加载资源,所有调色板预设、字体和帮助文档都打包在本地,这与其说是技术选择,不如说是向原始软件致敬的设计决策。
监控与调试要点
实现 Amiga 硬件仿真时,需要建立多个监控维度来确保正确性。调色板状态的可视化是最基本的 ——DPaint.js 在界面底部始终显示当前的 32 色调色板,任何不一致都会立即暴露。抖动效果的对比是另一个重点:同一图像使用不同抖动算法应该产生明显不同的视觉效果,这可以通过并排预览来验证。
对于 WebGL 相关的 bug,着色器编译错误和 uniform 位置不匹配是最常见的问题来源。调试时应该启用 gl.getShaderParameter 和 gl.getProgramParameter 来检查着色器状态,并在控制台输出详细的编译日志。纹理坐标的翻转(Y 轴)是另一个容易踩坑的点 ——Amiga 的位图通常是自底向上存储的,而 WebGL 纹理默认是自顶向下,需要在上传纹理时指定 gl.UNPACK_FLIP_Y_WEBGL 或在着色器中进行垂直翻转。
性能监控应该关注帧率和 GPU 时间。Chrome DevTools 的 Performance 面板可以录制帧的分解时间,识别哪些 JavaScript 调用占用了过多 CPU,或者哪些着色器执行时间过长。对于实时性要求高的操作(如笔刷绘制),每帧处理时间应该控制在 16ms 以内,否则会出现明显的延迟感。DPaint.js 的 split-screen 视图功能提供了一种便捷的对比方式:屏幕左侧显示 1:1 的像素视图,右侧显示缩放后的整体视图,这种设计直接继承了 DPaint 的工作流程,也成为检验仿真正确性的实用工具。
资料来源:DPaint.js 项目(https://github.com/steffest/DPaint-js)。