Three.js中将渲染到纹理的结果显示在屏幕上

31 阅读4分钟

前言

在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函数,不然可能会清空本身的渲染结果。)