UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

400 lines (343 loc) 13.9 kB
import { Script, Plane, Mat4, Vec3, Texture, RenderTarget, PIXELFORMAT_SRGBA8, ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, FILTER_LINEAR_MIPMAP_LINEAR } from 'playcanvas'; // Reusable objects to avoid allocations const _reflectionMatrix = new Mat4(); const _reflectedPos = new Vec3(); const _reflectedTarget = new Vec3(); const _plane = new Plane(); const _clipPoint = new Vec3(); const _clipNormal = new Vec3(); /** * PlanarRenderer script renders the scene from a camera mirrored (reflection mode) or matching * (refraction mode) the main scene camera, into a texture. The texture can then be used by a * material, typically to render planar reflections or refractions - for example by the water * surface, a mirror or a glass pane. * * How to use: * - Create an entity with a camera component, set up its layers to what you want to render into * the texture. This camera is fully controlled by this script. * - Add the planarRenderer script to it, and set its sceneCameraEntity to the main camera of the * scene. * - Call frameUpdate on the script each frame to update the texture. This needs to be called after * all properties of the main camera, including its transform, have been set for the frame. * * Note: Objects that use the resulting texture should not be in the layers this camera renders. * * @example * const reflectionCamera = new Entity('ReflectionCamera'); * reflectionCamera.addComponent('camera', { * layers: [worldLayer.id, skyboxLayer.id], * priority: -1, * toneMapping: TONEMAP_ACES * }); * reflectionCamera.addComponent('script'); * const planarRenderer = reflectionCamera.script.create(PlanarRenderer, { * properties: { * sceneCameraEntity: cameraEntity, * mode: 'reflection', * planePoint: new Vec3(0, 0, 0), * planeNormal: new Vec3(0, 1, 0) * } * }); * app.root.addChild(reflectionCamera); * * // each frame, after the main camera was updated * const texture = planarRenderer.frameUpdate(); * @category Rendering */ class PlanarRenderer extends Script { static scriptName = 'planarRenderer'; /** * The entity containing the main camera of the scene. * * @attribute * @type {import('playcanvas').Entity} */ sceneCameraEntity = null; /** * The mode of the renderer. In 'reflection' mode the camera is mirrored by the plane, in * 'refraction' mode the camera matches the scene camera, and only the clipping differs. * * @attribute * @type {string} */ mode = 'reflection'; /** * Scale of the texture compared to the render buffer of the main camera. * * @attribute * @range [0.05, 1] * @precision 2 * @step 0.05 */ scale = 0.5; /** * If set to true, mipmaps will be allocated and autogenerated. * * @attribute * @type {boolean} */ mipmaps = false; /** * If set to true, a depth buffer will be created for the render target. * * @attribute * @type {boolean} */ depth = true; /** * A point on the plane. * * @attribute * @type {Vec3} */ planePoint = new Vec3(); /** * Normal of the plane. * * @attribute * @type {Vec3} */ planeNormal = new Vec3(0, 1, 0); /** * If set to true, an oblique projection matrix is used, to clip the rendered geometry by the * plane. In reflection mode this removes geometry below the plane, in refraction mode the * geometry above the plane. When disabled, no clipping takes place. * * @attribute * @type {boolean} */ obliqueClipping = true; /** * Distance the clipping plane is shifted against the clipped side, allowing geometry slightly * past the plane to be rendered. This helps hide artifacts along the intersection of the plane * with the geometry, especially when the surface using the texture is displaced by waves. * * @attribute * @range [0, 1] * @precision 2 * @step 0.01 */ clipBias = 0.05; /** * The texture the scene renders to. * * @type {Texture|null} * @private */ _texture = null; /** * The plane clipping equation in the view space of the planar camera, used by the oblique * projection callback. Stored as (nx, ny, nz, d). * * @type {number[]} * @private */ _viewSpaceClipPlane = [0, 0, 0, 0]; initialize() { // sceneCameraEntity needs to be set const sceneCamera = this.sceneCameraEntity?.camera; if (!sceneCamera) { console.error('PlanarRenderer script requires sceneCameraEntity attribute to be set.'); return; } // this entity needs to have camera component as well const planarCamera = this.entity.camera; if (!planarCamera) { console.error('PlanarRenderer script requires a camera component on the same entity.'); return; } // When the camera is finished rendering, trigger onPlanarPostRender event on the entity. // This can be listened to by the user, and the resulting texture can be further processed // (e.g prefiltered) const evtPostRender = this.app.scene.on('postrender', (cameraComponent) => { if (planarCamera === cameraComponent) { this.entity.fire('onPlanarPostRender'); } }); // when the script is destroyed, remove event listeners and release resources this.on('destroy', () => { evtPostRender.off(); planarCamera.calculateProjection = null; this._destroyRenderTarget(); }); } _destroyRenderTarget() { const planarCamera = this.entity.camera; if (planarCamera?.renderTarget) { planarCamera.renderTarget.destroy(); planarCamera.renderTarget = null; } if (this._texture) { this._texture.destroy(); this._texture = null; } } updateRenderTarget() { // main camera resolution const sceneCamera = this.sceneCameraEntity.camera; const device = this.app.graphicsDevice; const sceneCameraWidth = sceneCamera.renderTarget?.width ?? device.width; const sceneCameraHeight = sceneCamera.renderTarget?.height ?? device.height; // texture resolution, limited to the maximum texture size const width = Math.min(Math.floor(sceneCameraWidth * this.scale), device.maxTextureSize); const height = Math.min(Math.floor(sceneCameraHeight * this.scale), device.maxTextureSize); const planarCamera = this.entity.camera; if (!planarCamera.renderTarget || planarCamera.renderTarget.width !== width || planarCamera.renderTarget.height !== height) { // destroy old render target this._destroyRenderTarget(); // create texture render target with specified resolution and mipmap generation this._texture = new Texture(device, { name: `${this.entity.name}:PlanarRenderer`, width: width, height: height, format: PIXELFORMAT_SRGBA8, mipmaps: this.mipmaps, addressU: ADDRESS_CLAMP_TO_EDGE, addressV: ADDRESS_CLAMP_TO_EDGE, minFilter: this.mipmaps ? FILTER_LINEAR_MIPMAP_LINEAR : FILTER_LINEAR, magFilter: FILTER_LINEAR }); planarCamera.renderTarget = new RenderTarget({ colorBuffer: this._texture, depth: this.depth }); } } /** * Builds the projection matrix for the planar camera, and when oblique clipping is enabled, * modifies its near plane to match the clipping plane, using the technique described in * "Oblique View Frustum Depth Projection and Clipping" by Eric Lengyel. * * @param {Mat4} projMat - The output projection matrix. * @private */ _calculateProjection(projMat) { const planarCamera = this.entity.camera; const rt = planarCamera.renderTarget; const aspect = rt ? rt.width / rt.height : 1; // rebuild the base projection from scratch each time, as the incoming matrix can already // contain the result of the previous modification projMat.setPerspective(planarCamera.fov, aspect, planarCamera.nearClip, planarCamera.farClip, planarCamera.horizontalFov); if (!this.obliqueClipping) { return; } // view space clipping plane const [cx, cy, cz, cw] = this._viewSpaceClipPlane; const m = projMat.data; // clip-space corner point opposite the clipping plane, transformed to camera space const qx = (Math.sign(cx) + m[8]) / m[0]; const qy = (Math.sign(cy) + m[9]) / m[5]; const qz = -1; const qw = (1 + m[10]) / m[14]; // scale the plane vector so that the far plane of the modified projection touches the // corner point, minimizing depth precision loss const s = 2 / (cx * qx + cy * qy + cz * qz + cw * qw); // replace the third row of the projection matrix (near plane becomes the clipping plane) m[2] = cx * s; m[6] = cy * s; m[10] = cz * s + 1; m[14] = cw * s; } /** * Transforms the world space clipping plane to the view space of the planar camera and stores * it for the projection callback. Must be called after the camera transform is updated. * * @private */ _updateViewSpaceClipPlane() { // clipping plane faces the kept side: in reflection mode geometry above the plane is kept, // in refraction mode the geometry below the plane _clipNormal.copy(this.planeNormal).normalize(); if (this.mode !== 'reflection') { _clipNormal.mulScalar(-1); } // shift the plane against the kept side by the bias, to render a bit of extra geometry // past the waterline _clipPoint.copy(_clipNormal).mulScalar(-this.clipBias).add(this.planePoint); // world space plane: n . x + d = 0 const dist = -_clipNormal.dot(_clipPoint); // transform the plane to the view space of the planar camera: planeVS = transpose(W) * planeWS, // where W is the camera world transform (transforms view space points to world space) const w = this.entity.getWorldTransform().data; const nx = _clipNormal.x; const ny = _clipNormal.y; const nz = _clipNormal.z; const vp = this._viewSpaceClipPlane; vp[0] = w[0] * nx + w[1] * ny + w[2] * nz; vp[1] = w[4] * nx + w[5] * ny + w[6] * nz; vp[2] = w[8] * nx + w[9] * ny + w[10] * nz; vp[3] = w[12] * nx + w[13] * ny + w[14] * nz + dist; // the technique requires the camera to be on the negative side of the plane - if it is not // (the plane faces away from the camera), flip the plane if (vp[3] > 0) { vp[0] = -vp[0]; vp[1] = -vp[1]; vp[2] = -vp[2]; vp[3] = -vp[3]; } } /** * Updates the render target and the camera, and returns the texture the camera renders to. * Call this every frame, after the main camera has been fully updated for the frame. * * @returns {Texture|null} The texture the camera renders to, or null when disabled. */ frameUpdate() { const planarCamera = this.entity.camera; const sceneCameraEntity = this.sceneCameraEntity; if (!planarCamera || !sceneCameraEntity?.camera) { return null; } this.updateRenderTarget(); if (planarCamera.enabled) { const pos = sceneCameraEntity.getPosition(); if (this.mode === 'reflection') { // mirror the scene camera by the plane _plane.setFromPointNormal(this.planePoint, this.planeNormal); _reflectionMatrix.setReflection(_plane.normal, _plane.distance); _reflectionMatrix.transformPoint(pos, _reflectedPos); _reflectedTarget.copy(pos).add(sceneCameraEntity.forward); _reflectionMatrix.transformPoint(_reflectedTarget, _reflectedTarget); this.entity.setPosition(_reflectedPos); this.entity.lookAt(_reflectedTarget); } else { // refraction camera matches the scene camera this.entity.setPosition(pos); this.entity.setRotation(sceneCameraEntity.getRotation()); } // copy other properties from the scene camera const sceneCamera = sceneCameraEntity.camera; planarCamera.fov = sceneCamera.fov; planarCamera.horizontalFov = sceneCamera.horizontalFov; planarCamera.orthoHeight = sceneCamera.orthoHeight; planarCamera.nearClip = sceneCamera.nearClip; planarCamera.farClip = sceneCamera.farClip; planarCamera.aperture = sceneCamera.aperture; planarCamera.sensitivity = sceneCamera.sensitivity; planarCamera.shutter = sceneCamera.shutter; // oblique clipping using a custom projection if (this.obliqueClipping) { this._updateViewSpaceClipPlane(); if (!planarCamera.calculateProjection) { planarCamera.calculateProjection = (projMat, view) => this._calculateProjection(projMat); } } else { planarCamera.calculateProjection = null; } return this._texture; } return null; } } export { PlanarRenderer };