NekoPixel —— 一起来画像素画吧!

NekoPixel —— 一起来画像素画吧!

创意 4588 字 / 10 分钟

文章封面使用 DALL·E 3 生成

NekoBox 自从 2020 年初上线以来,至今磕磕绊绊运行了四年。一开始我只是将其当做一个 CRUD 的练手项目,做完后丢到线上就没管了。谁知在 2022 年开始,这个小站不知什么原因,突然迎来了大量的注册用户,同时还有几个粘性很强的用户,个人主页上有上百条提问。(也是这两三个重度用户,页面改版前每天会跑掉我 3-4 块钱的 CDN 流量)

我感叹自己又一次无心插柳柳成荫,于 2022 年底又写了很多新功能,功能包括数据导出、注销账号、防骚扰、内容安全、内部 BI 面板等等。就在我看着一切都将往好的方向发展时,去年二月被炸弹人搞了一波,这事之后再慢慢聊。从那之后 NekoBox 关站了几个月,后面数据全部迁移到境外,使用新的域名重新恢复了。

我并没有大张旗鼓地去宣传恢复后的域名,原以为之前的用户就这样流失再也不见了。没曾想有铁粉,一遍遍刷着兔小巢上是否有新的动向,找到了我的新域名。这件事令我挺感动的。如今的 NekoBox 每天还有零零散散的几个新注册账号,和几条新增留言评论,我觉得自己不该一直“躺平摆烂式”管理,得想办法给这个小站加点新的元素。

因此,NekoPixel 就诞生了。同 NekoBox 一样,它也是完全开源的:https://github.com/wuhan005/NekoPixel

为什么选择做像素画?

我一直不想宣传 NekoBox,不想让它被太多人知道或被滥用。究其原因,这是我一个人因为兴趣开发运营的站点,我没有那么多精力去即时响应它发生的问题。当我在兔小巢上收到了新的用户反馈时,我只能等到一个不怎么忙且不怎么困的周末,才能静下心来好好写代码开发。我也在刻意降低 NekoBox 的社交属性。访客只能通过给定的链接看到注册用户的提问箱,没有其它任何热门用户推荐的功能。不同的注册用户之间,只能是在现实中或者其它平台上建立联系,在 NekoBox 中,他们互相不会打扰到对方。

既然不方便强调独立个体,那就展现群体的力量!

一群人在网络上一起绘制一幅图画,最早好像是从 Reddit 开始的,后面 B站在 2017 年暑假做了个夏日绘板的活动,用户每间隔一段冷却时间,可以拥有几个像素点,在一张共享的画布上作画。虽然当时B站还没上市,但用户体量是摆在那的,整场活动下来难免有用脚本捣乱的人。但好在最后效果挺好,可以说是 B站二次元属性最后的余晖了。时至今日,当年的活动页还有人在“缅怀”。我认为日后 B站不太有机会再举办这样的活动了,既赚不到钱,还得在内容安全上加大投入。

NekoBox 就很适合做这个,不同兴趣爱好的用户可以画自己喜欢的东西,但前端又不会知道是谁主导绘制的。再加上 NekoBox 的用户本来就不多,大家圈地自萌玩一玩多好。

如何实现的?

像素画的前端开发难度远大于后端。我们先从相对简单的后端讲起。

通过直接生啃 B站夏日绘板的前端(具体文件在 pixel-drawing.d41b770e4052375671dc.js),我们可以知道这是一个 1280 x 720 的图片。通过魔改的 Vue DevTools,可以直接看到其 Vue data 部分的内容:

bilibili-painting-vue-tools

colorMaps 对象存储的就是页面上调色盘的颜色。colorMaps 的 Value 是对应颜色的十六进制,Key 则是从 0 开始一直递增到 A B C… 的索引。那么考虑使用一位的字母或数字作为 Key,我们可以表达 36 种颜色(0-9A-Z),要是加上特殊符号全角半角,则可以表示更多。

在页面的 1.0b2b4b3ccd53641b013c.js 文件中,我们可以看到其返回了很长一串字符串:

webpackJsonp([1],{1697:function(Q,O,E){
  "use strict";function L(){
    return"MGE9EEEE0000090000001100000000"..."111011101"		// 就是这一段几百KB的
  }Object.defineProperty(O,"__esModule",{value:!0}),O.getFreeSketchingBitmap=L}});

该字符串中的每个字符是一个像素点,其对应的就是上述 colorMaps 中 Key 所指的颜色。前端通过解析该字符串,在 Canvas 中绘制出原本的图片。这种存储方式颇有点 bitmap 的味道。那么对于后端而言,我们只需要想办法能存储,并快速返回这段字符串即可。

MongoDB?Postgres!

GitHub 上的开源大多是使用 MongoDB 来存储单个像素点,最后汇集起来返回。但我们这个场景下其实不太需要 NoSQL 的灵活功能,我便决定依旧使用 Postgres 来实现。我在 Postgres 中,创建一张名为 canvas_pixels 的表,共 921600 行(1280*720),用于存储整个画面的最新像素。

字段名类型说明
user_idINT最后绘制该像素的用户 ID
xINT像素在画布上的 X 值
yINT像素在画布上的 Y 值
indexSTRING像素的颜色索引
colorSTRING冗余字段,存储像素的十六进制编码

整张表很简单易懂对不对?然后就可以愉快的使用 SQL,现将 xy 排序,保证他们在画布上是依次排列出来的,再将 index 颜色索引字符串合并即可,如此简单粗暴的方法, 就可以将上面的字符串生成出来了啦~ 查询用时在 400ms 左右。

SELECT
	STRING_AGG(t.index, '')
FROM (
	SELECT
		INDEX
	FROM
		"canvas_pixels"
	WHERE
		x >= 0 AND y >= 0 AND x <= 1280 AND y <= 720
	ORDER BY
		y, x
) AS t

但只存这张表会有一个问题,新的像素绘制将老的记录给盖掉了,我们没法追踪整张画布上图像随时间的变化。因此还有张 pixels 表来归档存储所有用户的每次像素操作。必要时可以通过 Scan 这张表,做出像 av13900223 的画板变化动画。

当用户绘制一个像素时,我们先往 pixels 插入一条数据,再更新 canvas_pixels,两个操作包在一个事务中即可。当然这里我有意画蛇添足用了 Trigger 触发器来做,也是想实际体验下触发器的使用。下方这段触发器的代码是直接让 ChatGPT 写的,可以看到它创建了一个函数,先从 colors 表中拿到十六进制颜色所对应的索引,然后更新 canvas_pixels 中对应的像素记录。

CREATE OR REPLACE FUNCTION public.upsert_canvas_pixel()
 RETURNS trigger
 LANGUAGE plpgsql
AS $function$
DECLARE
    colorIndex TEXT;
BEGIN
    SELECT index INTO colorIndex FROM colors WHERE color = NEW.color LIMIT 1;

    IF colorIndex IS NOT NULL THEN
        UPDATE canvas_pixels
        SET color = NEW.color, index = colorIndex
        WHERE x = NEW.x AND y = NEW.y;

        IF NOT FOUND THEN
            INSERT INTO canvas_pixels(x, y, color, index)
            VALUES (NEW.x, NEW.y, NEW.color, colorIndex);
        END IF;
    ELSE
        RAISE EXCEPTION 'Color not found in colors table.';
    END IF;

    RETURN NEW;
END;
$function$

--- 创建触发器
CREATE OR REPLACE TRIGGER trigger_upsert_canvas_pixel AFTER INSERT ON pixels FOR EACH ROW EXECUTE FUNCTION upsert_canvas_pixel ();

上层的 RESTful API 那就随便糊一糊了,创建像素点的时候往 pixels 插一条记录即可,这里就不再赘述。

困难重重的 Canvas

NekoPixel 最难的部分在前端,更确切地说是在 Canvas。一开始我打算直接裸写 HTML + JavaScript,然后被一堆 EventListener搞得很烦,最后还是决定上 Vue3。

先明确一下前端总体的功能:

  • 绘制像素:我们需要将后端返回的字符串转化成十六进制颜色,一个像素一个像素地绘制到 Canvas 上。
  • 滚轮缩放:用户滚动鼠标滚轮,可以实现画布的放大缩小。
  • 点击拖动:用户在放大画布后,点击画布可随意拖动查看。
  • 用户绘制:用户选择颜色后,点击 Canvas,将颜色填充到鼠标所指的像素上。

绘制像素

首先前端请求接口,拿到颜色的字符到十六进制的映射表,然后将后端返回的字符串,一个个字符转换成十六进制颜色数组。然后将颜色绘制上去。

const imageData = baseContext.value.createImageData(width, height)

const arrayBuffer = new ArrayBuffer(imageData.data.length)
const clampedArray = new Uint8ClampedArray(arrayBuffer)
const uint32Array = new Uint32Array(arrayBuffer)
for (let i = 0; i < pixels.canvas.length; i++) {
  const index = pixels.canvas[i]
  const color = colorMap.get(index) ?? [0, 0, 0]
  const pixelValue = (255 << 24) | (color[2] << 16) | (color[1] << 8) | color[0]; // 注意: 这里使用的是big-endian
  uint32Array[i] = pixelValue;
}

imageData.data.set(clampedArray)
baseContext.value.putImageData(imageData, 0, 0)

通过阅读代码,你会发现我们是将像素绘制到了在代码中新建的 baseContext 中,而不是 DOM 上展示的 canvasPixels。这是因为 Canvas 绘制刷新相当于直接将像素盖上去了,我们在后续点击拖动的过程中,看似是在拖动一张大的画布,Canvas 负责展示画布的一部分,其实 Canvas 是在不停地重绘覆盖之前的内容。因此需要有一份完整的备份,页面上的 Canvas 只是从备份中选取指定的部分展示。

还有一个小细节是 Canvas 的 ctx.imageSmoothingEnabled 这个属性,一开始我发现图片绘制到 Canvas 上,放大后整个是糊的,不像 B站一样放大是棱角分明的像素点。问题就出在这个属性上,Canvas 默认将其设置为 True,即开启图像平滑,我们需要设置成 False 才能在 Canvas 放大后显示像素点。

滚轮缩放

用户在 Canvas 上滑动滚轮,我们需要处理 Canvas 的 @wheel 事件。首先使用 preventDefault() 来禁用默认的效果,防止整个浏览器页面被放大了。然后通过事件的 deltaY 属性的正负来判断是放大还是缩小,设置缩放比例后,重绘画布。

画布的缩放,可以直接用 Canvas Context 的 scale()方法:

ctx.scale(ratio.value, ratio.value)

关于画布刷新函数 refreshCanvas(),ChatGPT 告诉我了超好用的 save()restore() 来保存和还原画布状态。

ctx.save()

ctx.clearRect(0, 0, paintingCanvas.value.width, paintingCanvas.value.height)
ctx.scale(ratio.value, ratio.value)
ctx.translate(deltaX.value, deltaY.value)
ctx.drawImage(baseCanvas.value, 0, 0)

ctx.restore()

当调用 save() 时,Canvas 的当前全部状态将被放入栈中,相当于当下成为了 Canvas 的一个默认状态,在 save() 后的任何修改,都是在这个默认状态之上进行。当我们的改动完成后,使用 restore() 将保存的状态从栈中弹出,恢复状态。

点击拖动

点击拖动需要同时处理 @mousedown @mousemove @mouseup 三个事件,分别对应用户操作中的鼠标点击、鼠标移动拖动、鼠标抬起结束拖动。这边使用 isMoving 变量来判断当前鼠标点击,是要拖动还是要画像素点。Canvas Context 中使用 translate() 方法来平移画布,我们根据鼠标拖动事件的增量来计算平移的距离即可:

 // Move canvas with translate.
if (event.buttons === 1) {
  deltaX.value += event.movementX / ratio.value
  deltaY.value += event.movementY / ratio.value

  if (deltaX.value > 0) {
    deltaX.value = 0
  }
  if (deltaY.value > 0) {
    deltaY.value = 0
  }

  if (baseContext.value) {
    refreshCanvas()
  }
}

用户绘制

用户绘制即在 @mousedown 的时候,判断 isMoving === false 时,将对应像素点的颜色,填充进上面提到的备份 Canvas baseContext 中,再用 refreshCanvas() 函数刷到页面上的 Canvas 里。最后用户需要手动点击页面上的结束绘制,这时将用户绘制的像素点信息发送到后端接口入库保存。

如何引入到现有项目中?

以上就是 NekoPixel 的实现原理和关键点,你可以对照开源的代码仔细分析。

NekoPixel 是一个由 Vue3 编写的前后端分离的应用,我该如何将其引入到我的前后端不分离的 NekoBox 中呢?我了解到 Vue 支持 UMD (Universal Module Definition) 组件化构建,最终产物是一个 JavaScript 文件,将其内嵌到 NekoBox 页面中,然后设置其 Mount 到指定的 <div> 元素中即可。

你可以在 vite.config.umd.ts 中看到其 VIte 构建配置。构建出来的前端产物将被发布为 NPM 包:@e99p1ant/neko-pixel-umd,找个 NPM 镜像源引入其 JavaScript 和 CSS 到 NekoBox 中即可使用。日后需要更新,也只用在 NekoBox 的模板中修改下 NPM 的版本号即可,十分方便。

<!-- CSS 样式 -->
<link rel="stylesheet" href="https://unpkg.com/@e99p1ant/neko-pixel-umd@0.0.17/style.css"/>

<!-- 挂载 NekoPixel 的 div 标签 -->
<div id="app"></div>

<!-- 自定义配置 -->
<script>
  var NEKO_CONFIG = {pixelBaseURL: '/api/v1/pixel'}
</script>

<!-- NekoPixel UMD 产物 -->
<script src="https://unpkg.com/@e99p1ant/neko-pixel-umd@0.0.17/neko-pixel-app.umd.js"></script>

你可能注意到了上面代码中的 NEKO_CONFIG 属性,在 NekoPixel 的 interceptor.ts 中,我通过全局环境下的该变量设置 axios 请求库的 baseURL。这样其实就简单实现了外部与 UMD 组件的沟通。

if(window.NEKO_CONFIG){
  axios.defaults.baseURL = window.NEKO_CONFIG.pixelBaseURL;
}

通过修改原本的 baseURL,将 NekoPixel 画板的所有请求指向 NekoBox 的 /pixel 下,/pixel 路由转发用户请求到服务器上的 NekoPixel。相当于 NekoBox 在中间做了层反代 pixel.go,为的是能将绘制像素点的用户 ID 带上,放到最终请求入库保存。

最后说几句

NekoPixel 是我今年在老家过年的时候开发的,发布上线后,我简单的用像素点写了个 NKBOX。后续真的有大触在上面画了像素画!我的 NekoBox 账号也收到了用户匿名反馈说很喜欢这个新功能。

neko-pixel-0224

但其实还有很多需要完善的地方,比如画板不会向鼠标所在的位置缩放(还是 Y7 提的呜呜呜)、加载画板的时候没有提示、用户绘制时的验证与限流等,都是要努力去实现的。

自从过年那阵子,就有人一直在 DDoS NekoBox 及其子域。刚开始的时候毫无防御被刷了一波阿里云账单,后续由于阿里云产品的各种离谱设定,加上工单客服给的防御方案根本不起作用,我将 NekoBox 以及自己的其他服务由阿里云迁移到了 Cloudflare 上,这才有所缓解。特别是前些日子一晚上打出了 10T 的流量!这要是还放阿里云上,我直接就 5000 块没了。

互联网上还是坏人多呀,一开始是疯狂刷我 NekoBox 上挂的支付宝收款码图片。Y7 也劝我不要再将 NekoBox 的打赏记录公开出来,省得有人眼红搞事,但我想着这是需要对社区公开的信息,且打赏的人可能也是抱着能被展示出来的心情才打赏的。

我也在想 NekoBox 这个站还要不要继续搞下去,更深层次的,我是不是不应该再抱着所谓“开源”和“用爱发电”的心情去面对技术。就像我最近博客收到的一条评论中所提及的,是不是用技术以及信息差去割韭菜是不是才是更重要的?以前看到很多 GitHub 上千 stars 项目的作者,在个人 Profile 里发 want a job,当时还疑惑他们这么出名这么厉害,怎么会没工作的呢?最近这段时间,我开始慢慢理解了。我开始越发觉得“开源”本身是奢侈的。每次发现我的一些开源项目被人拿去商用赚的盆满钵满的时候,我什么也得不到,所谓的“协议”也只是自欺欺人罢了。我现在日常把“开源”当做乐趣,实在是一种“不自量力”的行为。当我下个月没地方住、下顿饭没钱吃的时候,所谓的“社区”又在哪呢?