前言
在Three.js提供了材质ShaderMaterial,使得我们可以很方便的自己编写着色器程序实现各种外观效果。在自定义一些复杂的着色器代码时,为了直观的查看某一步是否正确,经常需要把中间某一步的结果显示到到屏幕上。尤其在使用WebGLRenderTarget渲染到纹理时,更是很需要将渲染得到的结果显示出来。我在开发过程中,发现这看似简单的一步也并是那么简单,本文就是探索过程中的一些经验。
渲染到纹理的原理
我觉得应该首先了解一下渲染到纹理的原理。我们知道,默认情况下,WebGL是在颜色缓冲区进行绘图的,绘制的结果是存储在颜色缓冲区中的。如果我们不想直接显示在屏幕画布之上,而是想将渲染结果存储到一张纹理图像中,那就需要创建一个帧缓冲区(FBO),帧缓冲区提供了颜色缓冲区的替代,绘制并不直接发生在帧缓冲区,而是发生在帧缓冲区所关联的对象上,一个帧缓冲区有3个关联对象,颜色、深度、模板。本文只讨论颜色关联对象。WebGL可以向帧缓冲区的关联对象中写入数据。当把纹理对象作为颜色关联对象关联到帧缓冲区对象后,WebGL就可以在纹理对象中绘图。
整个过程可以参考下面的代码。先用gl.createTexture创建一个texture对象,这个texture对象最后一个参数是null,可以说是一片空白区域。然后将这个texture对象关联到帧缓冲区对象上。
// Create a frame buffer object (FBO)
framebuffer = gl.createFramebuffer();
// Create a texture object and set its size and parameters
texture = gl.createTexture(); // Create a texture object
gl.bindTexture(gl.TEXTURE_2D, texture); // Bind the object to target
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
我们再看看Three.js是怎么做的。Three.js提供了renderer.setRenderTarget方法,这个方法为每个WebGLRenderTarget对象创建了一个renderTargetProperties对象,这个对象中存储着FBO对象,就是__webglFramebuffer字段。为每个renderTarget.texture创建了一个textureProperties对象,这个对象存储着WebGL的texture对象,就是__webglTexture字段。所以当我们使用renderTarget.texture作为参数值,不管是赋值给map还是uniforms,其实使用的都是这个__webglTexture。
将renderTarget.texture显示出来
搞明白了原理,下来就看怎么显示了。首先能想到的是利用WebGL提供的gl.readPixels,从帧缓冲区中读取像素数据,然后写在JavaScript的Image对象中。Three.js提供了renderer.readRenderTargetPixels方法,这个方法对绑定缓冲区gl.bindFramebuffer和读取像素值gl.readPixels进行了封装。可以参考以下示例。
const img = new Image();
document.body.appendChild(img);
img.style.position = 'absolute';
img.style.top = '0';
img.style.right = '0';
img.style.zIndex = '1000';
function renderTargetToImage(renderer, renderTarget) {
// 创建临时canvas
const canvas = document.createElement('canvas');
canvas.width = renderTarget.width;
canvas.height = renderTarget.height;
// 读取像素数据
const pixels = new Uint8Array(renderTarget.width * renderTarget.height * 4);
renderer.readRenderTargetPixels(
renderTarget,
0, 0,
renderTarget.width, renderTarget.height,
pixels
);
// 创建ImageData
const imageData = new ImageData(
new Uint8ClampedArray(pixels),
renderTarget.width,
renderTarget.height
);
// 绘制到canvas
const ctx = canvas.getContext('2d');
ctx.putImageData(imageData, 0, 0);
img.src = canvas.toDataURL('image/png');
}
这种方法可以直接将帧缓冲区中的值以img图片的形式显示在屏幕上。但这个方法有以下缺点:
- texture对象可以用gl.texImage2D设置不同数据类型和颜色格式,不同数据类型有不同的字节长度,不是所有的类型都可以用JavaScript中Uint8Array这个类型来读取的,需要自己根据类型来判断在JavaScript应该用什么类型来读取,如果没有合适的类型,甚至可能需要转码。不同颜色格式代表不同的通道数,所以读取的数组长度也不同,而JavaScript中ImageData的对象接收的data类型只能是Uint8ClampedArray,而且这个类型是一个数组,每个元素只能是8位,要表达其他数据类型或不同的通道数,还是只能手写算法转码,才能正确的显示。
针对这样的缺点,我们得再想办法。之所以要渲染到纹理,是为了在后续的渲染中将渲染的结果作为纹理来使用,那么我们干脆使用Three.js的渲染器将renderTarget.texture直接渲染到屏幕上,这样可以利用Three.js中的各种现成的转码函数,如unpackRGBAToDepth函数和各种toneMapping函数。这个texture有多大,就在屏幕上渲染多大。可以参考以下示例。
function createRenderTextureToScreen( renderer, renderTarget ) {
const targetWidth = renderTarget.width;
const targetHeight = renderTarget.height;
const rendererWidth = renderer.getSize( new Vector2() ).width;
const rendererHeight = renderer.getSize( new Vector2() ).height;
const camera = new OrthographicCamera( rendererWidth / - 2, rendererWidth / 2, rendererHeight / 2, rendererHeight / - 2, 0, 1000 );
const material = new ShaderMaterial( {
vertexShader: `
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);
}`,
fragmentShader: `
uniform sampler2D testMap;
varying vec2 vUv;
void main(){
vec4 testColor = texture2D( testMap, vUv );
gl_FragColor=testColor;
}`,
uniforms: {
testMap: { value: null }
}
} );
const quad = new PlaneGeometry( targetWidth, targetHeight );
const mesh = new Mesh( quad, material );
return function () {
material.uniforms.testMap.value = renderTarget.texture;
renderer.render( mesh, camera );
};
}
这个createRenderTextureToScreen函数返回了一个匿名函数,这个匿名函数中执行了将renderTarget.texture作为一个uniforms传递给material,然后渲染。只要在每一帧渲染时同步执行这个匿名函数就行。(这里注意,最好将renderer.autoClear设为false,然后手动调用renderer.clear函数,不然可能会清空本身的渲染结果。)