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()方法中,会使用传入的width和height乘这个像素比。
但是,在WebGLRenderTarget中没有设置像素比的方法和属性,在设置width和height,需要手动的乘像素比。
对于这一点,也可以通过后处理的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.js将GPU相关资源的释放都交给了用户自行管理,对于有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()
截图/预览
实现截图/预览大致的过程如下:
- 通过
WebGLRenderer.setRenderTarget()方法,将场景渲染到RenderTarget中 - 通过
WebGLRenderer.readRenderTargetPixels()方法,读取第1步渲染的RenderTarget中的像素信息 - 通过读取到的像素信息通过创建
ImageData对象 - 通过
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中,有一个renderToScreen的boolean属性,如果设置为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 );
}`