如何制作异次元门效果

732 阅读6分钟

最终效果

在这个例子中,森林中有一个石门从背面看是一个正常的石门,但正面却能通向一片海岛。

视频封面

好的标题可以获得更多的推荐及关注者

灵感来源

此前在网上看到过一个错误的示范

视频封面

原理剖析

思路就是创建两台相机,一台拍摄门内世界(camera0),一台拍摄现实世界(camera1),然后把拍摄到的门内世界作为纹理贴图,贴在门上。

renderer.setRenderTarget(renderTarget);
renderer.render(secondaryScene, secondaryCamera);
renderer.setRenderTarget(null);
// render the scene to the canvas
renderer.render(primaryScene, primaryCamera);

为了使camera0拍摄的纹理能正好贴合在门上,camera0的相机前平面会选者和门同一个尺寸。但是稍微动一动就会发现门内世界的透视实际是错的。这是因为camera1和camera0的相机参数,不一致,使得门中世界和门外世界使用的P矩阵不一致。

其实用上面的思路也是能做到透视一致的,只要把门中世界的物体和门外世界的物体放在不同的层,用同一个相机拍摄即可:

// 门中物体放在 layer0,门外物体放在 layer1

// pass1  相机只渲染门,把它作为遮罩模版

// pass2 相机拍门中门中世界,使用pass1的纹理作为遮罩

// pass3 正常渲染门外世界,最终结果叠加pass2的纹理

three.js官网有一个例子就是使用这样的遮罩原理实现的

const renderTarget = new THREE.WebGLRenderTarget( window.innerWidth, window.innerHeight, parameters );

composer = new EffectComposer( renderer, renderTarget );
composer.addPass( clearPass );
composer.addPass( maskPass1 );
composer.addPass( texturePass1 );
composer.addPass( clearMaskPass );
composer.addPass( maskPass2 );
composer.addPass( texturePass2 );
composer.addPass( clearMaskPass );
composer.addPass( outputPass );

但是如果我们有n个异次元门,难道每帧渲染就多加2n个pass?这显然很消耗性能,能不能只使用一个pass解决问题?

模版测试

原理剖析

上面的问题答案是肯定的,我们可以使用模版测试实现这一效果。关于模版测试,读者可以自行翻阅OpenGL。简单说来模版测试可以让程序以一定的规则筛选参与测试的片源。

比如图中的门,我们就可以在它渲染时写入模版,然后在火山渲染时进行模版测试。

如上图所示,其实火山一直都在场景之中,只是绘制石门时,生成了图中红色的模版,在火山渲染时,会进行模版测试那些不通过的值就直接被丢弃了。

代码实现

在webgl中通过

gl.enable(gl.STENCIL_TEST);

来开启模版测试。还可通过gl.stencilMask设置位掩码来开放/禁止写入模板缓冲:

gl.stencilMask(0xFF); // 每一位写入模板缓冲时都保持原样
gl.stencilMask(0x00); // 每一位在写入模板缓冲时都会变成0(禁用写入)

在three.js中通过material的属性来开启模版写入与模版测试

// 门的material
new MeshPhongMaterial({
    color: new Color('red'),
    stencilWrite: true,  // 开启模版写入
    stencilRef: 1, //模版基准值设为1
    stencilPass: ReplaceStencilOp, // 写入模版缓存
})
// 火山的material
new MeshPhongMaterial({
   stencilFunc: EqualStencilFunc,  //当模版基准与模版缓冲上的值一致时才通过
   stencilRef: 1, //模版基准值设为1
   stencilWriteMask: 0x00, // 只做模版测试,不写入模版
   stencilWrite: true, // 开启模版测试
})

这样实现之后火山就消失了,但也没有在门内出现:

原因是火山虽然通过了模版测试,但由于火山本来就比门距离相机更远,所以没能通过深度测试,因此需要在渲染门中世界之前清除深度缓冲:

door.onAfterRender = function () {
  renderer.clearDepth()
}

但这样还有出现另一个问题,就是深度缓冲被清空了,如果有门外世界的物体位于门前面,如何实现遮挡? 这里我们需要调整渲染顺序,在three.js中每一个mesh都可以设置renderOrder,渲染队列按renderOrder按从小到大顺序进行。

因此,我们需要先渲染门外世界,再渲染门,清空深度缓冲,再渲染门中世界的物体。

此时也不是stencilPass: ReplaceStencilOp而是stencilZPass: ReplaceStencilOp。需要同时通过深度测试才写入模版。

视频封面

再多想一想

怎么实现多个门?

我们说过,在之前的方案中,多加一个门需要多两个pass,那stencil这套方案,需要吗?答案是否定的!

对于不同的门我们只需要使用不同的基准值即可:

// 门1的material
new MeshPhongMaterial({
    color: new Color('red'),
    stencilWrite: true,  // 开启模版写入
    stencilRef: 1, //模版基准值设为1
    stencilPass: ReplaceStencilOp, // 写入模版缓存
})
// 世界1的material
new MeshPhongMaterial({
   stencilFunc: EqualStencilFunc,  //当模版基准与模版缓冲上的值一致时才通过
   stencilRef: 1, //模版基准值设为1
   stencilWriteMask: 0x00, // 只做模版测试,不写入模版
   stencilWrite: true, // 开启模版测试
})

// 门2的material
new MeshPhongMaterial({
    color: new Color('red'),
    stencilWrite: true,  // 开启模版写入
    stencilRef: 2, //模版基准值设为2
    stencilPass: ReplaceStencilOp, // 写入模版缓存
})
// 世界2的material
new MeshPhongMaterial({
   stencilFunc: EqualStencilFunc,  //当模版基准与模版缓冲上的值一致时才通过
   stencilRef: 2, //模版基准值设为2
   stencilWriteMask: 0x00, // 只做模版测试,不写入模版
   stencilWrite: true, // 开启模版测试
})

在最后一扇门渲染后,清空深度缓冲即可

性能如何优化?

我们知道里世界可能很大,但门通常不大,管中窥豹。如上图,其实这时很多里世界的物体根本看不到,就不用去做光栅化,因此我们可以在每帧渲染之前,做一次类似视锥体剔除的剔除操作。从相机位置,向门的4个顶点连线,加上门所在的平面,与camera的远平面,一共有六个面,当门内世界的某个物体的球包围盒,完全不在这六个面围成的区域内时,我们就不再渲染该物体。这样它后续的shader操作,光栅化,各种测试都不会进行,从而提高性能!

本篇没有讨论的问题:

如果需要实现透明物体?是怎样的顺序?

我们知道渲染透明物体是需要知道深度信息的,每个里世界是有深度信息的,但表世界的深度信息,在最后一扇门被渲染之后就被清空了,渲染外世界的透明物体需要深度,但渲染里世界的物体需要清空深度。那外世界的透明物体是不是应该在里世界的物体前被渲染?但是此时如果有一个透明物体刚好挡住里世界的门,但门内物体还没有被渲染完成,该如何进行blend?

如何实现多光源和多阴影?

如果有多个里世界,里世界之间的光源,里世界和表世界的光源能否相互独立?表世界的shadow会影响到里世界?

以上两个问题可以在留言区讨论!