WebGLRenderTarget与截图功能

1,204 阅读5分钟

WebGLRenderTarget与截图功能

WebGLRenderTrarget

WebGLRenderTarget,从字面意思“渲染目标”很直观的就就可以明白它的作用。通常,我们会使用WebGLRenderer直接将场景渲染到“屏幕”(渲染缓冲区发)中,但我们也可以使用WebGLRenderer.setRenderTarget指定一个渲染目标,将场景渲染到WebGLRenderTarget

为什么不直接渲染到屏幕?

可以用来做后处理(post-processing),对于渲染后的2D图片,再次进行处理。

创建一个WebGLRenderTarget

创建方法就是使用new来调用构造器,它的构造器有三个参数:

WebGLRenderTarget(width : number, height : number, options?)

我们先关注前两个参数,就是宽和高,和使用WebGLRenderer.setSize()是一样的,是一个渲染缓冲区的大小(size)。

要注意的是:在调用WebGLRenderer.setSize()之前,需要调用WebGLRenderer.setPixelRatio()来设置像素比。在setSize()方法中,会使用传入的widthheight乘这个像素比。 但是,在WebGLRenderTarget中没有设置像素比的方法和属性,在设置widthheight,需要手动的乘像素比。

对于这一点,也可以通过后处理的EffectComposer的构造器窥探一二:

// three.js/examples/jsm/postprocessing/EffectComposer.js 
constructor(renderer, renderTarget) {

  this.renderer = renderer;

  if (renderTarget === undefined) {

    const size = renderer.getSize(new Vector2());
    this._pixelRatio = renderer.getPixelRatio();
    this._width = size.width;
    this._height = size.height;

    renderTarget = new WebGLRenderTarget(this._width * this._pixelRatio, this._height * this._pixelRatio);
    renderTarget.texture.name = 'EffectComposer.rt1';

  } 
  //...
}

接下来我们聊第三个参数:

interface Options {
  /** texture's attribute */
  wrapS: number // default is ClampToEdgeWrapping.
  wrapT: number // default is ClampToEdgeWrapping.
  magFilter: number // default is LinearFilter.
  minFilter: number // default is LinearFilter.
  generateMipmaps: boolean // default is false.
  format: number // default is RGBAFormat.
  type: number // default is UnsignedByteType.
  anisotropy: number // default is 1. See Texture.anisotropy
  encoding: number // default is LinearEncoding.
  /** end */

  depthBuffer: boolean // default is true.
  stencilBuffer: boolean // default is false.
  samples:boolean // default is 0.
}

WebGLRenderTarget(width : number, height : number, options?: Options)

可以看到,有很多参数和Texture的属性一样。在RenderTarget内部,有一个texture: Texture属性,用来表示渲染后的“图片”。这个Texture在创建时,需要设置它的属性,这个参数中的大部分属性,都是用来创建这个Texture的。可以看一下WebGLRenderTarget构造器的源码:

this.texture = new Texture(image, options.mapping, options.wrapS, options.wrapT, options.magFilter, options.minFilter, options.format, options.type, options.anisotropy, options.encoding);

this.texture.flipY = false;
this.texture.generateMipmaps = options.generateMipmaps !== undefined ? options.generateMipmaps : false;
this.texture.internalFormat = options.internalFormat !== undefined ? options.internalFormat : null;
this.texture.minFilter = options.minFilter !== undefined ? options.minFilter : LinearFilter;

使用过后不要忘记.dispose()

Three.jsGPU相关资源的释放都交给了用户自行管理,对于有dispose()方法的对象,在已经确定不会再使用时,需要调用dispose()方法释放相关资源。

对于WebGLRenderTarget来说,它也有dispose()方法,需要在不使用它时,手动调用释放资源。

如果使用了PMREMGenerator使用HDR图片作为环境贴图,它的方法.fromEquirectangular().fromCubemap()的返回值都是WebGLRenderTarget。在使用过程中,一般会这样写:

// 初始化场景时
const pmrem = new PMREMGenerator(renderer)
const map = pmrem.fromEquirectangular(texture).texture
scene.environment = map
scene.background = map
pmrem.dispose()


// 销毁场景时
scene.environment?.dispose()
scene.background?.dispose()

在销毁场景之后,通过WebGLRenderer.info.memory可以看到,textures没有释放干净。这里就是因为pmrem.fromEquirectangular(texture)返回的WebGLRenderTarget没有释放。上面的代码改一下即可:

// 初始化场景时
const pmrem = new PMREMGenerator(renderer)
const mapTarget = pmrem.fromEquirectangular(texture)
scene.environment = mapTarget.texture
scene.background = mapTarget.texture
pmrem.dispose()

// 销毁场景时
scene.environment?.dispose()
scene.background?.dispose()
mapTarget.dispose()

截图/预览

实现截图/预览大致的过程如下:

  1. 通过WebGLRenderer.setRenderTarget()方法,将场景渲染到RenderTarget
  2. 通过WebGLRenderer.readRenderTargetPixels()方法,读取第1步渲染的RenderTarget中的像素信息
  3. 通过读取到的像素信息通过创建ImageData对象
  4. 通过CanvasContext.putImageData()方法,绘制ImageData表示的图像信息

具体代码如下:

function screenshot() {
  // render to renderTarget
  const rt = createRenderTarget({ encoding: sRGBEncoding })
  renderer.setRenderTarget(rt)
  renderer.render(scene, camera)
  renderer.setRenderTarget(null)
  
  // read pixel from renderTarget
  const { width, height } = rt
  const buffer = new Uint8Array(width * height * 4)
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buffer)  
  rt.dispose()
  
  // put pixel into canvas
  const clamped = new Uint8ClampedArray(buffer.buffer)
  const imageData = new ImageData(clamped, width, height)
  previewCanvas.width = width
  previewCanvas.height = height
  const ctx = previewCanvas.getContext('2d')
  ctx.putImageData(imageData, 0, 0)
}

function createRenderTarget({encoding}) {
  const width = threeCanvas.clientWidth
  const height = threeCanvas.clientHeight
  const pixelRatio = window.devicePixelRatio
  
  const rt = new THREE.WebGLRenderTarget(width * pixelRatio, height * pixelRatio, {encoding})
  
  return rt
}

如果使用上述代码,会发现绘制出来的图片在y轴方向是相反的,所以,还需要进行一次翻转操作。

注意点一:在Y轴方向反转

进行翻转陈操作之前,需要知道,ImageData中保存的信息,是按照像素的顺序排列的,每个像素有4Byte(四个通道:RGBA,各一个字节)。我们要把这些像素信息按照行进行反转。

function flipY(buffer, width, height) {
  // 每一轮循环 第i行和第j行交换
  for(let i = 0, j = height - 1; i < j; ++i, --j) {

    // 交换两行的数据
    for(let k = 0; k < 4 * width; ++k) {
      const t = buffer[i * width * 4 + k]
      buffer[i * width * 4 + k] = buffer[j * width * 4 + k]
      buffer[j * width * 4 + k] = t
    }
  }
}

在之前的截图代码中,加入flipY()

function screenshot() {
  // render to renderTarget
  // ...
  
  // read pixel from renderTarget
  const { width, height } = rt
  const buffer = new Uint8Array(width * height * 4)
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buffer)  
  rt.dispose()
  
  // flip y 
  flipY(buffer, width, height)

  // put pixel into canvas
  // ...
}

经过了上面的修改,虽然图片可以渲染出来,但还是有些问题,颜色偏暗。对于有一定经验的同学,应该可以立刻反应过来,是颜色空间的问题。最终的结果应该放在sRGB颜色空间。

注意点二:通过WebGLRender.texture.encoding指定颜色空间

通常,我们把场景渲染到屏幕上时,会设置WebGLRenderer.outputEncoding = sRGBEncoding

来看一下Three.js对于.outputEncoding的描述:

.outputEncoding : number

Defines the output encoding of the renderer. Default is THREE.LinearEncoding.

If a render target has been set using .setRenderTarget then renderTarget.texture.encoding will be used instead.

如果使用WebGLRenderer.setRenderTarget()设置了renderTarget,那么会优先使用renderTarget.texture.encoding,而不是renderer.outputEncoding

所以,在创建rendererTarget时,可以通过第三个参数指定encoding

function createRenderTarget({ encoding }) {
  const width = threeCanvas.clientWidth
  const height = threeCanvas.clientHeight
  const pixelRatio = window.devicePixelRatio
  
  const rt = new THREE.WebGLRenderTarget(width * pixelRatio, height * pixelRatio, { encoding })
  
  return rt
}

使用了post-process的截图方法

如果使用了后处理,在渲染时,会调用EffectComposer.render()方法,没有办法设置RenderTarget

但是,在EffectComposer中,有一个renderToScreenboolean属性,如果设置为false,则不会渲染到屏幕,最后的渲染结果会保留在EffectCompser.readBuffer中。

仅需要在获取RenderTarget的地方做一些修改:

function screenshot() {
  // render to EffectComposer.readBuffer
  const rt = composer.readBuffer
  composer.renderToScreen = false
  composer.render()
  renderer.renderToScreen = true
  
  // read pixel from renderTarget
  const { width, height } = rt
  const buffer = new Uint8Array(width * height * 4)
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buffer)  
  rt.dispose()
  
  // put pixel into canvas
  const clamped = new Uint8ClampedArray(buffer.buffer)
  const imageData = new ImageData(clamped, width, height)
  previewCanvas.width = width
  previewCanvas.height = height
  const ctx = previewCanvas.getContext('2d')
  ctx.putImageData(imageData, 0, 0)
}

这样渲染之后,很可能又会看到颜色偏暗的情况,还是和上面一样,因为颜色空间引起的。

Gamma矫正(转换到sRGB颜色空间)

如果使用了EffectComposer,它内部的两个WebGLRenderTarget.texture.encoding都是在线性空间(LinearEncoding)。当渲染到屏幕时,如果设置了WebGLRenderer.outputEncoding = sRGBEncoding,则会进行Gamma矫正;但是在用上面的方法截图时,就会出现问题。这是因为上面说的原因,WebGLRenderTarget.texture.encoding会覆盖WebGLRenderer.outputEncoding

为了解决这个问题,我们可以不依赖WebGLRenderer.outputEncoding进行Gamma矫正,而是添加一个用来Gamma矫正的后处理阶段,WebGLRenderer.outputEncoding设置为LinearEncoding即可。

在创建composer

import { GammaCorrectionShader } from 'three/examples/jsm/shaders/GammaCorrectionShader.js'

composer.addPass(new ShaderPass(GammaCorrectionShader));

这里也可以大致了解一下这个Gamma矫正的Shader: 重点就是在片段着色器

	fragmentShader: /* glsl */`

		uniform sampler2D tDiffuse;

		varying vec2 vUv;

		void main() {

      // 对图片的进行采样
			vec4 tex = texture2D( tDiffuse, vUv );

      // 对采样后的像素进行颜色空间的转换
			gl_FragColor = LinearTosRGB( tex );

		}`