[译]WebGL 基础系列:图像处理(续)

原文地址:http://webglfundamentals.org/webgl/lessons/webgl-image-processing-continued.html

El Capitan

This article is a continuation of WebGL Image Processing. If you haven’t read that I suggest you start there.

本文接上一篇WebGL 图像处理的内容继续讨论。如果你还没有读过,那么我建议你先去了解下上一篇的内容。

The next most obvious question for image processing is how to apply multiple effects?

对于图像处理而言,另一个比较重要的问题是:如何同时添加多种处理效果?

Well, you could try to generate shaders on the fly. Provide a UI that lets the user select the effects he wants to use then generate a shader that does all of the effects. That might not always be possible though that technique is often used to create effects for real time graphics.

当然,你可能会想要动态的来创建着色器。首先提供一个 UI 界面,让用户自己选择想要的效果,然后生成一个包含全部效果的着色器。这个技术不常用,但是在为实时图像创建效果的时候经常使用。

译者注:on the fly,这里应该是即时的、所见即所得的意思。我这里翻译成动态的,表示一边操作一边看到相应的着色器。


A more flexible way is to use 2 more textures and render to each texture in turn, ping ponging back and forth and applying the next effect each time.

这里有一种更加灵活的解决方案:使用多个纹理,然后在这几个纹理间进行多次处理后结果的存储,操作方式就像打乒乓球一样,来来回回,每次处理一个特殊效果。操作过程如下:


Original Image -> [Blur]        -> Texture 1
Texture 1 -> [Sharpen] -> Texture 2
Texture 2 -> [Edge Detect] -> Texture 1
Texture 1 -> [Blur] -> Texture 2
Texture 2 -> [Normal] -> Canvas

To do this we need to create framebuffers. In WebGL and OpenGL, a Framebuffer is actually a poor name. A WebGL/OpenGL Framebuffer is really just a collection of state and not actually a buffer of any kind. But, by attaching a texture to a framebuffer we can render into that texture.

为了达到这样的目的,我们需要创建帧缓冲区(framebuffer)。在 WebGL 和 OpenGL 中,帧缓冲区是一个很“奇葩”的命名,它实际上只是一个状态集合,而并不是一个特定类型的缓冲区。但是,当你给它绑定一个纹理的时候,这个纹理就会被渲染出来。

First let’s turn the old texture creation code into a function

首先,让我们把旧的创建纹理的代码封装到一个函数里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function createAndSetupTexture(gl) {
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);

// 设置纹理参数,从而让我们可以渲染任何大小的图片,注意这些操作都是针对像素级的操作。
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

return texture;
}

// 创建纹理并把图片放入纹理中
var originalImageTexture = createAndSetupTexture(gl);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);

And now let’s use that function to make 2 more textures and attach them to 2 framebuffers.

那么现在我们就可以使用这个函数了。我们使用它来创建 2 个纹理并将它们同 2 个帧缓冲区联系起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 创建 2 个纹理并把它们同帧缓冲区联系起来
var textures = [];
var framebuffers = [];
for (var ii = 0; ii < 2; ++ii) {
var texture = createAndSetupTexture(gl);
textures.push(texture);

// 把纹理设置成图片的大小
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA, image.width, image.height, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);

// 创建一个帧缓冲区
var fbo = gl.createFramebuffer();
framebuffers.push(fbo);
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);

// 把纹理同帧缓冲区联系起来
gl.framebufferTexture2D(
gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
}

Now let’s make a set of kernels and then a list of them to apply.

现在,我们来创建一组核和一个需要用到的效果列表。

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
// 定义一些卷积核
var kernels = {
normal: [
0, 0, 0,
0, 1, 0,
0, 0, 0
],
gaussianBlur: [
0.045, 0.122, 0.045,
0.122, 0.332, 0.122,
0.045, 0.122, 0.045
],
unsharpen: [
-1, -1, -1,
-1, 9, -1,
-1, -1, -1
],
emboss: [
-2, -1, 0,
-1, 1, 1,
0, 1, 2
]
};

// 执行的效果列表
var effectsToApply = [
"gaussianBlur",
"emboss",
"gaussianBlur",
"unsharpen"
];

And finally let’s apply each one, ping ponging which texture we are rendering too

最后,我们会把这些效果全部应用起来。

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
// 从原图开始
gl.bindTexture(gl.TEXTURE_2D, originalImageTexture);

// 绘制纹理的时候不需要对图片进行y轴翻转
gl.uniform1f(flipYLocation, 1);

// 遍历我们想使用的效果
for (var ii = 0; ii < effectsToApply.length; ++ii) {
// 设置要绘制到的帧缓冲区
setFramebuffer(framebuffers[ii % 2], image.width, image.height);

drawWithKernel(effectsToApply[ii]);

// 下一次绘制时,使用刚刚渲染的纹理
gl.bindTexture(gl.TEXTURE_2D, textures[ii % 2]);
}

// 最后将结果绘制到 canvas 上
gl.uniform1f(flipYLocation, -1); // 需要为 canvas 做y轴翻转
setFramebuffer(null, canvas.width, canvas.height);
drawWithKernel("normal");

function setFramebuffer(fbo, width, height) {
// 设置要渲染的帧缓冲区
gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);

// 告诉着色器帧缓冲区的分辨率
gl.uniform2f(resolutionLocation, width, height);

// 设置视图大小
gl.viewport(0, 0, width, height);
}

function drawWithKernel(name) {
// 设置核
gl.uniform1fv(kernelLocation, kernels[name]);

// 绘制矩形
gl.drawArrays(gl.TRIANGLES, 0, 6);
}

Here’s a working version with a slightly more flexible UI. Check the effects to turn them on. Drag the effects to reorder how they are applied.

下面是一个可运行的例子,你可以看下它最终的显示效果,也可以改变各个效果的执行顺序。

你可以在新窗口打开这个实例。

Some things I should go over.

我们来回顾下上面的代码。

Calling gl.bindFramebuffer with null tells WebGL you want to render to the canvas instead of to one of your framebuffers.

调用 gl.bindFramebuffer 时,如果使用 null 作为参数,这实际上是告诉 WebGL 你想要渲染到 canvas 而不是帧缓冲区中。

WebGL has to convert from clipspace back into pixels. It does this based on the settings of gl.viewport. The settings of gl.viewport default to the size of the canvas when we initialize WebGL. Since the framebuffers we are rendering into are a different size than the canvas we need to set the viewport appropriately.

WebGL 需要将剪切空间转换为像素。这种转换依赖 gl.viewport 的设置。默认情况下,可以设置为初始化 WebGL 时 canvas 的大小。但是帧缓冲区的大小并不一致,因此我们需要合理设置下 viewport。

Finally in the original example we flipped the Y coordinate when rendering because WebGL displays the canvas with 0,0 being the bottom left corner instead of the more traditional for 2D top left. That’s not needed when rendering to a framebuffer. Because the framebuffer is never displayed, which part is top and bottom is irrelevant. All that matters is that pixel 0,0 in the framebuffer corresponds to 0,0 in our calculations. To deal with this I made it possible to set whether to flip or not by adding one more input into the shader.

最后要说的是,在最开始的例子中,在渲染的时候需要翻转下 y 轴坐标,这是因为 WebGL 纹理坐标系统把左下角作为坐标原点,而不是像图片坐标系统那样把左上角作为坐标原点。但是在渲染到帧缓冲区时,不需要这么做。这是因为,帧缓冲区从来都不会被显示出来,哪个地方是坐标原点其实是无关紧要的。有关系的地方在于,帧缓冲区里的坐标原点的像素需要匹配到我们计算出的原点。为了解决这个问题,可以通过多添加一个输入参数来决定是否需要进行翻转操作。

1
2
3
4
5
6
7
8
9
10
11
<script id="2d-vertex-shader" type="x-shader/x-vertex">
...
uniform float u_flipY;
...

void main() {
...
gl_Position = vec4(clipSpace * vec2(1, u_flipY), 0, 1);
...
}
</script>

And then we can set it when we render with

然后我们就可以通过设置它来达到目的了:

1
2
3
4
5
6
7
8
...
var flipYLocation = gl.getUniformLocation(program, "u_flipY");
...
// 不翻转
gl.uniform1f(flipYLocation, 1);
...
// 翻转
gl.uniform1f(flipYLocation, -1);

I kept this example simple by using a single GLSL program that can achieve multiple effects. If you wanted to do full on image processing you’d probably need many GLSL programs. A program for hue, saturation and luminance adjustment. Another for brightness and contrast. One for inverting, another for adjusting levels, etc. You’d need to change the code to switch GLSL programs and update the parameters for that particular program. I’d considered writing that example but it’s an exercise best left to the reader because multiple GLSL programs each with their own parameter needs probably means some major refactoring to keep it all from becoming a big mess of spaghetti.

为了让实例看起来比较简单,我在这里只使用了一个 GLSL 程序来达到使用多种效果的目的。如果你要进行更全面的图像处理,那么可能需要多个 GLSL 程序。例如,一个程序用来控制色调、饱和度和控制亮度,另一个用来控制亮度和对比度。或者,一个程序用来控制反相,另一个用来调整程度。你需要修改代码来切换 GLSL 程序并为制定的程序更新相应参数。我原来也想要写个这样的例子,但是后来认为这是一个很好的练习题,所以留给读者自己来完成了。需要注意,多个程序意味着需要进行重构从而避免让代码成为一堆杂乱无章的代码。

I hope this and the preceding examples have made WebGL seem a little more approachable and I hope starting with 2D helps make WebGL a little easier to understand. If I find the time I’ll try to write a few more articles about how to do 3D as well as more details on what WebGL is really doing under the hood.
For a next step consider learning how to use 2 or more textures.

我希望上面的这些例子能让 WebGL 看起来更加“平易近人”。我认为以 2D 操作作为开始能让 WebGL 更容易理解。如果有时间的话,我也会写一些文章来讨论 3D 以及 WebGL 内部执行的细节。接下来可以考虑去学习下
如何使用多个纹理