最近在做服务端 canvas-webgl 渲染,探索下怎么通过自动化的方案来保证渲染效果。
探索背景
网络上对 Canvas 的渲染测试用例方案归纳有三种:
- 方案一:Node 记录 Canvas 的 API 操作记录,验证执行,例如:jest-mock-canvas
- 方案二:真实浏览器渲染比对,例如 cypress
- 方案三:学习 Three.js 的 e2e 测试用例,使用 puppeteer 进行截图比对
方案一没有太大意义,因为服务端渲染业务逻辑是跟前端完全一致的,影响渲染效果在更底层的 C++ 模块
方案二需要开启浏览器验证,而我们的开发环境全是在容器中进行,没有可视化环境
方案三主要借助 pixelmatch
库实现像素级对比,提供了很好的解决思路
截取原图:
和原图进行对比:
单图比对
因为我们的渲染方案本身就是在服务端操作数据,通过 gl.readPixels
来读取像素,天然就是数据操作,因此无需上述 puppeteer
截图流程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 生成原图或比对图
// const canvasBuffer = app.view.toBuffer();
// await jimp.read(canvasBuffer).bitmap;
const fs = require('fs');
const path = require('path');
const jimp = require('jimp');
const pixelmatch = require('pixelmatch');
const snapshotPic1Path = path.join(__dirname, '../snapshot', 'pixi02-SimHei_0_0.png');
const snapshotPic2Path = path.join(__dirname, '../snapshot', 'pixi02-SimHei_1_0.png');
const diffPicPath = path.join(__dirname, '../snapshot', 'diff.png');
(async () => {
const expectDiffRate = 0.001;
const actual = (await jimp.read(fs.readFileSync(snapshotPic1Path))).bitmap;
const expected = (await jimp.read(fs.readFileSync(snapshotPic2Path))).bitmap;
const diff = actual;
const { width, height } = actual;
const failPixel = pixelmatch(expected.data, actual.data, diff.data, width, height, {
diffMask: true,
});
const failRate = failPixel / (width * height);
if (failRate >= expectDiffRate) {
(await jimp.read(diff)).scale(1).quality(100).write(diffPicPath);
console.log(`create diff image at: ${diffPicPath}`);
} else {
console.log('pass');
}
})();
比对效果:
视频比对&文本结论
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1、将两段视频分别抽帧到两个文件夹
async function extractFramesFromVideo(videoPath, distDir) {
return new Promise((resolve, reject) => {
ffmpeg()
.input(videoPath)
.outputOptions(['-r 25', '-q:v 2', '-f image2'])
.output(`${distDir}/%08d.png`)
.on('end', () => {
resolve('Finished processing');
})
.on('progress', (progress) => {
console.log(`Processing: ${progress.percent}% done`);
})
.on('start', (commandLine) => {
console.log(`Spawned Ffmpeg with command: ${commandLine}`);
})
.on('error', (err) => {
reject(err);
})
.run();
});
}
1
2
// 2、按照上面单图的逻辑,逐帧比对逻辑
// 省略...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 3、输出比对文本结论
// 单帧比对不同的像素点个数
const diffPixelCount = pixelmatch(expectedImg.data, actualImg.data, diff.data, width, height, {
threshold: 0.5,
includeAA: true,
alpha: 0.5,
diffMask: false, // 背景是否空白
});
// 单帧比对像素点不同的个数占总像素点的比例
const diffPixelPercent = `${diffPixelCount / (width * height) * 100}%`;
// 写入文件,最后求平均值
fs.appendFileSync(reportTxtPath, `${fileName} ${diffPixelPercent}${os.EOL}`);
单图对比逻辑探索
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
// 1、判断入参是否为像素点数据
function isPixelData(arr) {
// work around instanceof Uint8Array not working properly in some Jest environments
return ArrayBuffer.isView(arr) && arr.constructor.BYTES_PER_ELEMENT === 1;
}
// 2、判断两张图片分辨率是否一致
if (img1.length !== img2.length || (output && output.length !== img1.length))
throw new Error('Image sizes do not match.');
// 3、设置两种颜色之间可接受的最大平方距离;35215 是 YIQ 差异度量的最大可能值
const maxDelta = 35215 * options.threshold * options.threshold;
// 4、计算两图在某位置处颜色之间的平方 YUV 距离 delta,如果 img2 像素较暗,则为负值
// 逐像素点遍历
// for (let y = 0; y < height; y++) {
// for (let x = 0; x < width; x++) {
// ...
const pos = (y * width + x) * 4;
const delta = colorDelta(img1, img2, pos, pos);
function colorDelta(img1, img2, k, m, yOnly) {
let r1 = img1[k + 0];
let g1 = img1[k + 1];
let b1 = img1[k + 2];
let a1 = img1[k + 3];
let r2 = img2[m + 0];
let g2 = img2[m + 1];
let b2 = img2[m + 2];
let a2 = img2[m + 3];
if (a1 === a2 && r1 === r2 && g1 === g2 && b1 === b2) return 0;
if (a1 < 255) {
a1 /= 255;
r1 = blend(r1, a1);
g1 = blend(g1, a1);
b1 = blend(b1, a1);
}
if (a2 < 255) {
a2 /= 255;
r2 = blend(r2, a2);
g2 = blend(g2, a2);
b2 = blend(b2, a2);
}
const y1 = rgb2y(r1, g1, b1);
const y2 = rgb2y(r2, g2, b2);
const y = y1 - y2;
if (yOnly) return y; // brightness difference only
const i = rgb2i(r1, g1, b1) - rgb2i(r2, g2, b2);
const q = rgb2q(r1, g1, b1) - rgb2q(r2, g2, b2);
const delta = 0.5053 * y * y + 0.299 * i * i + 0.1957 * q * q;
// encode whether the pixel lightens or darkens in the sign
return y1 > y2 ? -delta : delta;
}
// 5、判断 delta 与最大可接受阈值的对比
if (Math.abs(delta) > maxDelta) {
// 检查是否只是抗锯齿导致的变化:antialiased(img1, x, y, width, height, img2)
// 非抗锯齿的变化,将差异点通过颜色标注在图上
function drawPixel(output, pos, r, g, b) {
output[pos + 0] = r;
output[pos + 1] = g;
output[pos + 2] = b;
output[pos + 3] = 255;
}
// 6、通过比对的像素置灰略带灰色像素显示
// 将半透明色与白色混合
function blend(c, a) {
return 255 + (c - 255) * a;
}
function drawGrayPixel(img, i, alpha, output) {
const r = img[i + 0];
const g = img[i + 1];
const b = img[i + 2];
const val = blend(rgb2y(r, g, b), alpha * img[i + 3] / 255);
drawPixel(output, i, val, val, val);
}
总结
这个比对方案用在效果视频的自动化测试上
- 将一个全特效视频,隔10帧生成一张基准图片
- 后续开发功能在提交代码前跑此用例,观察渲染逻辑的改动是否影响到原有逻辑