playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
400 lines (343 loc) • 13.9 kB
JavaScript
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 };