WebGL 测试4 - Framebuffer 和滤镜

示例可以在这里查看。此次的示例挺有意思的(个人觉得),推荐看看。效果的图片预览请展开。

我似乎有几篇博文都说“预览请展开”的,那是因为不想浪费大家流量,如果有图片,或者文章过长的话就把一两句描述放前面,感兴趣的同学可以花一点流量阅读细节。

题外话:

前天晚上和村长对话了。村长得知我要考研之后很惊讶:“你居然去考研?堕落了!”然后推荐我去申国外。其他保守看应该没问题,不过先把外语水平考试(I/T)考了,才能投申请啊(所以不趁有时间考 I/T 的我是不是很愚蠢)。

所以之后会很忙(其实已经比正常的准备时间都推后了),不管是哪条线都会很忙。所以尽快将 Bulletproof 写到能运行示例的程度就暂时放在一边,火力全开为生存而斗争吧。jabanny 的邮件还没回复,和 tt 在 xross 上的试验讨论也暂缓(suspended)了。先说声对不起了。


这次我们来反向的是 Pixi 的滤镜,采用帧缓冲(Framebuffer)作为绘制缓冲区。想当初 Bulletproof 底层换到 Pixi 上就是因为有一个高速滤镜的硬需求。

此次的着色器和 JavaScript 代码都是几乎推倒重来的,因为要考虑迁移到 TypeScript 上时的 OO 特性。

(文中的代码和示例的代码可能有少许不同,以示例为准,因为是之后又经过调试的。)

通过的测试环境:Windows 10, Chrome 45 / Firefox 41 / IE 11 / Edge。

未通过的测试环境(由尝鲜的杨同学提供):OSX El Capitan, Chrome 47-dev / Safari 9。

图片预览:

原始图像

像素化图像

可以看到,应用了滤镜的矩形被像素化了,而没应用滤镜的三角形前后无变化。

话说玩过 XNA/MonoGame 的同学应该知道,其中有一个继承自 Texture2D 的类 RenderTarget2D,使用方式大概是这样的(参数什么的从简了):

RenderTarget2D target = new RenderTarget2D();
game.SetRenderTarget(target); // 切换到缓冲区输出
SomeDrawableComponents.draw(gameTime);
game.SetRenderTarget(null); // 切换回屏幕输出
DrawTheTextureToASurface(target, someSurface);

这样实现的是一个类似游戏内屏幕的效果(例如 Half Life 2 的 Breencast 在各种屏幕上的投影)。很“碰巧”地,这种方式在 Pixi 中也见到了(详情请见 Pixi 的多重滤镜机制的代码)。

来分析一下。滤镜的基本操作流程是:

  1. 绘制原始图形;
  2. 处理此图形;
  3. 拷贝到输出(可选,如果前两步直接在屏幕缓冲区完成的话)。

WebGL 中的绘图完全是由着色器根据给定的顶点、颜色、索引数据画的。像 BitBlt() 这样的缓冲区数据复制函数是不能用的。那么有没有一种方法,叫做 copyBetweenRenderTargets(from, to) 呢?没有。

这里从 WebGL 的一个着色器类型 sampler2DFramebuffer 的一个操作 gl.framebufferTexture2D() 和 XNA/MonoGame 的 API 可以猜想(还用得着猜吗),既然我们看的屏幕是二维的,Texture2D 是二维的,那么就利用 Texture2D 作为真正的缓冲区。(附:GDI+ 中,WinForms 封装了一个 Graphics.FromBitmap() 方法,理由类似。)其实也可以想一下,如果屏幕进入到三维(立体投影),这种绘图习惯应该会改变吧?

首先我们来看一下这次用到的三组着色器。

原始绘图着色器(顶点、片元):

attribute vec3 aVertexPosition;
attribute vec4 aColor;

uniform mat4 uProjMatrix;

varying vec4 vColor;

void main() {
    gl_Position = uProjMatrix * vec4(aVertexPosition.xyz, 1.0);
    vColor = aColor;
}
precision mediump float;

varying vec4 vColor;

void main() {
    gl_FragColor = vColor;
}

像素化着色器(顶点、片元):

// Borrowed from pixi.js - default vertex shader
precision lowp float;
attribute vec3 aVertexPosition;
attribute vec2 aTextureCoord;
attribute vec4 aColor;

uniform mat4 uProjMatrix;

varying vec2 vTextureCoord;
varying vec4 vColor;

void main() {
    gl_Position = uProjMatrix * vec4(aVertexPosition.xyz, 1.0);
    vTextureCoord = aTextureCoord;
    vColor = vec4(aColor.rgb * aColor.a, aColor.a);
}
// Borrowed from pixi.js - pixelate filter - fragment shader
precision mediump float;

varying vec2 vTextureCoord;

uniform vec4 dimensions;
uniform vec2 pixelSize;
uniform sampler2D uSampler;

void main()
{
    vec2 coord = vTextureCoord;
    vec2 size = dimensions.xy / pixelSize;
    vec2 color = floor((vTextureCoord * size)) / size + pixelSize / dimensions.xy * 0.5;
    gl_FragColor = texture2D(uSampler, color);
}

输出至屏幕时的着色器(顶点、片元):

attribute vec3 aVertexPosition;
attribute vec2 aTextureCoord;

uniform mat4 uProjMatrix;

varying vec2 vTextureCoord;

void main() {
    gl_Position = uProjMatrix * vec4(aVertexPosition.xyz, 1.0);
    vTextureCoord = aTextureCoord;
}
precision mediump float;

uniform sampler2D uSampler;

varying vec2 vTextureCoord;

void main() {
    gl_FragColor = texture2D(uSampler, vTextureCoord);
}

可以看到,变量名统一了(aVertexPositionaColoraTextureCoorduSampler),因为要在多个处理过程中用到。

其中,原始着色器的作用是按照指定的颜色和位置进行输出;像素化着色器处理;屏幕输出着色器则是做了类似拷贝的操作(直接绘制贴图),稍后在代码里可以看到是怎么结合使用的。

接下来从总绘制逻辑开始分解说明。

/**
 * @param {Boolean} [pixelate]
 */
function render(pixelate) {
    if (pixelate === undefined) {
        pixelate = true;
    }
    if (pixelate) {
        rawTarget.use();
        rawTarget.renderElements($_rect_vertices, $_rect_colors, $_rect_indices);
        pixelateTarget.use();
        pixelateTarget.renderRenderTarget(rawTarget);
        screenTarget.use();
        screenTarget.renderRenderTarget(pixelateTarget);
    } else {
        rawScreenTarget.use();
        rawScreenTarget.renderElements($_rect_vertices, $_rect_colors, $_rect_indices);
    }
    rawScreenTarget.use();
    rawScreenTarget.renderElements($_tri_vertices, $_tri_colors, $_tri_indices, false);
}

先进行最初的绘制,画出一个矩形(renderElements()),然后由像素化滤镜在自己的缓冲区内处理(renderRenderTarget()),最后绘制到屏幕上(renderRenderTarget())。

在创建上,四个 RenderTarget 指定了不同的着色器和输出方式。

function initRenderTargets() {
    glc.disable(gl.DEPTH_TEST);
    glc.enable(gl.BLEND);
    glc.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    var mat4 = new Matrix4();
    mat4.setOrtho(0, canvas.width, 0, canvas.height, -1000, 0);
    /**
     * @type {Shader}
     */
    var rawShader = new Shader(glc,
        Shader.getShaderSourceFromElement(document.getElementById("vshader-raw")),
        Shader.getShaderSourceFromElement(document.getElementById("fshader-raw")),
        {
            "uProjMatrix": {type: UniformType.MAT4, value: mat4.elements}
        },
        ["aVertexPosition", "aColor"]
    );
    /**
     * @type {Shader}
     */
    var screenShader = new Shader(glc,
        Shader.getShaderSourceFromElement(document.getElementById("vshader-screen")),
        Shader.getShaderSourceFromElement(document.getElementById("fshader-screen")),
        {
            "uProjMatrix": {type: UniformType.MAT4, value: mat4.elements},
            "uSampler": {type: UniformType.SAMPLER2D, value: 0}
        },
        ["aVertexPosition", "aTextureCoord"]
    );
    /**
     * @type {Shader}
     */
    var pixelateShader = new Shader(glc,
        Shader.getShaderSourceFromElement(document.getElementById("vshader-pixelate")),
        Shader.getShaderSourceFromElement(document.getElementById("fshader-pixelate")),
        {
            "uProjMatrix": {type: UniformType.MAT4, value: mat4.elements},
            "dimensions": {type: UniformType.VEC4, value: [canvas.width, canvas.height, 0, 1]},
            "pixelSize": {type: UniformType.VEC2, value: [50, 50]},
            "uSampler": {type: UniformType.SAMPLER2D, value: 0}
        },
        ["aVertexPosition", "aTextureCoord", "aColor"]
    );
    rawTarget = new RenderTarget(glc, canvas.width, canvas.height, rawShader);
    pixelateTarget = new RenderTarget(glc, canvas.width, canvas.height, pixelateShader);
    screenTarget = new RenderTarget(glc, canvas.width, canvas.height, screenShader, true);
    rawScreenTarget = new RenderTarget(glc, canvas.width, canvas.height, rawShader, true);
    g_pixelateShader = pixelateShader;
}

Shader 的构造函数如下:

/**
 * @param {WebGLRenderingContext} glc
 * @param {String} vshader 顶点着色器源代码
 * @param {String} fshader 片元着色器源代码
 * @param {Object} uniforms 存储键值对,键为 uniform 变量名称,值为对应的 WebGLUniform 类实例
 * @param {String[]} attributeNames attribute 变量的名称(注意只需要名称,因为对一般是手工操作)
 * @constructor
 */
function Shader(glc, vshader, fshader, uniforms, attributeNames) {
}

Shader 类负责管理一个着色器实例(关联至 WebGLProgram),以及提供对应变量的自动更新操作。这种做法学习自 Pixi。

RenderTarget 的构造函数如下:

/***
 * @param {WebGLRenderingContext} glc
 * @param {Number} width
 * @param {Number} height
 * @param {Shader} shader
 * @param {Boolean} [isRoot]
 * @constructor
 */
function RenderTarget(glc, width, height, shader, isRoot) {
}

isRoot 参数控制着 RenderTarget 的行为。为 true 时表示将要直接绘制到屏幕上(缓冲区为 null),为 false 时表示这个 RenderTarget 是一个缓冲用的 RenderTarget,绘制操作发生在帧缓冲区(Framebuffer)内。

其实整个绘制的过程几乎是一致的,除了:

  1. 直接绘制到屏幕上的 RenderTarget 中,frameBuffernulltexturenull
  2. 作为缓冲区的 RenderTarget 中,frameBuffertexture 都不为 null

二者的区别在初始化时就已经决定,在绘制时两种 RenderTarget 共享绘制代码。大致如下:

// renderElements() 中

glc.viewport(0, 0, this._width, this._height);
this.use();
shader.use();
shader.updateUniforms();

var FSIZE = Float32Array.BYTES_PER_ELEMENT;
glc.bindBuffer(gl.ARRAY_BUFFER, verticesBuffer);
glc.bufferData(gl.ARRAY_BUFFER, verticesTyped, gl.STATIC_DRAW);
glc.vertexAttribPointer(shader.getAttributeLocation("aVertexPosition"), 3, gl.FLOAT, false, FSIZE * 3, 0);
glc.enableVertexAttribArray(shader.getAttributeLocation("aVertexPosition"));
glc.bindBuffer(gl.ARRAY_BUFFER, colorsBuffer);
glc.bufferData(gl.ARRAY_BUFFER, colorsTyped, gl.STATIC_DRAW);
glc.vertexAttribPointer(shader.getAttributeLocation("aColor"), 4, gl.FLOAT, false, FSIZE * 4, 0);
glc.enableVertexAttribArray(shader.getAttributeLocation("aColor"));

glc.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indicesBuffer);
glc.bufferData(gl.ELEMENT_ARRAY_BUFFER, indicesTyped, gl.STATIC_DRAW);

if (clear) {
    glc.clearColor(0, 0, 0, 0);
    glc.clearDepth(0);
    glc.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
glc.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT, 0);
// renderRenderTarget() 中

this.use();
shader.use();
shader.updateUniforms();

var FSIZE = Float32Array.BYTES_PER_ELEMENT;

// Bind target texture to current content
glc.activeTexture(gl.TEXTURE0);
glc.bindTexture(gl.TEXTURE_2D, renderTarget.texture);

glc.bindBuffer(gl.ARRAY_BUFFER, glVertexPositionBuffer.buffer);
glc.bufferData(gl.ARRAY_BUFFER, vertexPositionsTyped, gl.STATIC_DRAW);
glc.vertexAttribPointer(shader.getAttributeLocation("aVertexPosition"), 3, gl.FLOAT, false, FSIZE * 3, 0);
glc.enableVertexAttribArray(shader.getAttributeLocation("aVertexPosition"));
glc.bindBuffer(gl.ARRAY_BUFFER, glTextureCoordBuffer.buffer);
glc.bufferData(gl.ARRAY_BUFFER, textureCoordsTyped, gl.STATIC_DRAW);
glc.vertexAttribPointer(shader.getAttributeLocation("aTextureCoord"), 2, gl.FLOAT, false, FSIZE * 2, 0);
glc.enableVertexAttribArray(shader.getAttributeLocation("aTextureCoord"));

glc.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, glTextureIndexBuffer.buffer);
glc.bufferData(gl.ELEMENT_ARRAY_BUFFER, textureIndicesTyped, gl.STATIC_DRAW);

if (clear) {
    glc.clearColor(1, 0, 0, 0);
    glc.clearDepth(0);
    glc.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
}
glc.drawElements(gl.TRIANGLES, textureIndices.length, gl.UNSIGNED_SHORT, 0);

RenderTarget.use() 方法很简单:

RenderTarget.prototype.use = function () {
    var glc = this._glc;
    // We did not provide a image as the input, so there is no need to flip vertically.
    glc.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
    glc.bindFramebuffer(gl.FRAMEBUFFER, this.frameBuffer);
};

核心就在一个 gl.bindFramebuffer() 上。之前说过直接输出时 frameBuffernull,否则不为 null。而将渲染目标切回屏幕时 bindFramebuffer() 的参数正好为 null,于是二者在形式上就统一了。

其他的代码大多数是做绘制数据管理(创建、保存、回收)的,按照规范来就可以了。

最后提醒一下,在创建贴图的时候,要创建一个长宽都为2的整数次方的贴图,否则 WebGL 会报错说无法创建。因此在创建缓冲区(基于贴图)之前要手工调整宽高,使之都为2的整数次方。

Enjoy.

分享到 评论