Skip to content

Commit

Permalink
Improved shadow cascades rendering, allowing per cascade update (#4921)
Browse files Browse the repository at this point in the history
* Improved shadow cascades rendering, allowing per cascade update

* lint

* small improvement

* api rename

* comment

* updated type of the api to accept null as well

Co-authored-by: Martin Valigursky <mvaligursky@snapchat.com>
  • Loading branch information
mvaligursky and Martin Valigursky committed Dec 13, 2022
1 parent 741a53f commit 1ed8cb1
Show file tree
Hide file tree
Showing 5 changed files with 163 additions and 51 deletions.
92 changes: 81 additions & 11 deletions examples/src/examples/graphics/shadow-cascades.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import * as pc from '../../../../';

import { BindingTwoWay } from '@playcanvas/pcui';
import { LabelGroup, Panel, SelectInput, SliderInput } from '@playcanvas/pcui/react';
import { BooleanInput, LabelGroup, Panel, SelectInput, SliderInput } from '@playcanvas/pcui/react';
import { Observer } from '@playcanvas/observer';

class ShadowCascadesExample {
Expand All @@ -25,6 +25,9 @@ class ShadowCascadesExample {
<LabelGroup text='Count'>
<SliderInput binding={new BindingTwoWay()} link={{ observer: data, path: 'settings.light.numCascades' }} min={1} max={4} precision={0}/>
</LabelGroup>
<LabelGroup text='Every Frame'>
<BooleanInput type='toggle' binding={new BindingTwoWay()} link={{ observer: data, path: 'settings.light.everyFrame' }} value={data.get('settings.light.everyFrame')}/>
</LabelGroup>
<LabelGroup text='Resolution'>
<SliderInput binding={new BindingTwoWay()} link={{ observer: data, path: 'settings.light.shadowResolution' }} min={128} max={2048} precision={0}/>
</LabelGroup>
Expand Down Expand Up @@ -61,7 +64,8 @@ class ShadowCascadesExample {
shadowResolution: 2048, // shadow map resolution storing 4 cascades
cascadeDistribution: 0.5, // distribution of cascade distances to prefer sharpness closer to the camera
shadowType: pc.SHADOW_PCF3, // shadow filter type
vsmBlurSize: 11 // shader filter blur size for VSM shadows
vsmBlurSize: 11, // shader filter blur size for VSM shadows
everyFrame: true // true if all cascades update every frame
}
});

Expand All @@ -84,6 +88,34 @@ class ShadowCascadesExample {
terrain.setLocalScale(30, 30, 30);
app.root.addChild(terrain);

// get the clouds so that we can animate them
const srcClouds : Array<pc.Entity> = terrain.find((node: pc.GraphNode) => {

const isCloud = node.name.includes('Icosphere');

if (isCloud) {
// no shadow receiving for clouds
(node as pc.Entity).render.receiveShadows = false;
}

return isCloud;
});

// clone some additional clouds
const clouds : Array<pc.Entity> = [];
srcClouds.forEach((cloud) => {
clouds.push(cloud);

for (let i = 0; i < 3; i++) {
const clone = cloud.clone() as pc.Entity;
cloud.parent.addChild(clone);
clouds.push(clone);
}
});

// shuffle the array to give clouds random order
clouds.sort(() => Math.random() - 0.5);

// find a tree in the middle to use as a focus point
const tree = terrain.findOne("name", "Arbol 2.002");

Expand All @@ -95,7 +127,7 @@ class ShadowCascadesExample {
});

// and position it in the world
camera.setLocalPosition(300, 60, 25);
camera.setLocalPosition(300, 160, 25);

// add orbit camera script with a mouse and a touch support
camera.addComponent("script");
Expand Down Expand Up @@ -129,21 +161,59 @@ class ShadowCascadesExample {
app.root.addChild(dirLight);
dirLight.setLocalEulerAngles(45, 350, 20);

// update mode of cascades
let updateEveryFrame = true;

// handle HUD changes - update properties on the light
data.on('*:set', (path: string, value: any) => {
const pathArray = path.split('.');
// @ts-ignore
dirLight.light[pathArray[2]] = value;

if (pathArray[2] === 'everyFrame') {
updateEveryFrame = value;
} else {
// @ts-ignore
dirLight.light[pathArray[2]] = value;
}
});

// on the first frame, when camera is updated, move it further away from the focus tree
let firstFrame = true;
app.on("update", function () {
if (firstFrame) {
firstFrame = false;
const cloudSpeed = 0.2;
let frameNumber = 0;
let time = 0;
app.on("update", function (dt: number) {

time += dt;

// on the first frame, when camera is updated, move it further away from the focus tree
if (frameNumber === 0) {
// @ts-ignore engine-tsd
camera.script.orbitCamera.distance = 320;
camera.script.orbitCamera.distance = 470;
}

if (updateEveryFrame) {

// no per cascade rendering control
dirLight.light.shadowUpdateOverrides = null;

} else {

// set up shadow update overrides, nearest cascade updates each frame, then next one every 5 and so on
dirLight.light.shadowUpdateOverrides = [
pc.SHADOWUPDATE_THISFRAME,
(frameNumber % 5) === 0 ? pc.SHADOWUPDATE_THISFRAME : pc.SHADOWUPDATE_NONE,
(frameNumber % 10) === 0 ? pc.SHADOWUPDATE_THISFRAME : pc.SHADOWUPDATE_NONE,
(frameNumber % 15) === 0 ? pc.SHADOWUPDATE_THISFRAME : pc.SHADOWUPDATE_NONE
];
}

// move the clouds around
clouds.forEach((cloud, index: number) => {
const redialOffset = (index / clouds.length) * (6.24 / cloudSpeed);
const radius = 9 + 4 * Math.sin(redialOffset);
const cloudTime = time + redialOffset;
cloud.setLocalPosition(2 + radius * Math.sin(cloudTime * cloudSpeed), 4, -5 + radius * Math.cos(cloudTime * cloudSpeed));
});

frameNumber++;
});
});
}
Expand Down
17 changes: 13 additions & 4 deletions src/framework/components/light/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,6 @@ class LightComponent extends Component {
this.onEnable();
}

updateShadow() {
this.light.updateShadow();
}

onCookieAssetSet() {
let forceLoad = false;

Expand Down Expand Up @@ -321,6 +317,19 @@ class LightComponent extends Component {
// remove cookie asset events
this.cookieAsset = null;
}

/**
* Returns an array of SHADOWUPDATE_ settings per shadow cascade, or undefined if not used.
*
* @type {number[] | null}
*/
set shadowUpdateOverrides(values) {
this.light.shadowUpdateOverrides = values;
}

get shadowUpdateOverrides() {
return this.light.shadowUpdateOverrides;
}
}

function _defineProperty(name, defaultValue, setFunc, skipEqualsCheck) {
Expand Down
25 changes: 18 additions & 7 deletions src/scene/light.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Vec2 } from '../core/math/vec2.js';
import { Vec3 } from '../core/math/vec3.js';
import { Vec4 } from '../core/math/vec4.js';

import { DEVICETYPE_WEBGPU } from '../platform/graphics/constants.js';

import {
BLUR_GAUSSIAN,
LIGHTTYPE_DIRECTIONAL, LIGHTTYPE_OMNI, LIGHTTYPE_SPOT,
Expand Down Expand Up @@ -171,6 +173,7 @@ class Light {
this.shadowIntensity = 1.0;
this._normalOffsetBias = 0.0;
this.shadowUpdateMode = SHADOWUPDATE_REALTIME;
this.shadowUpdateOverrides = null;
this._isVsm = false;
this._isPcf = true;

Expand Down Expand Up @@ -250,6 +253,7 @@ class Light {

const stype = this._shadowType;
this._shadowType = null;
this.shadowUpdateOverrides = null;
this.shadowType = stype; // refresh shadow type; switching from direct/spot to omni and back may change it
}

Expand Down Expand Up @@ -294,7 +298,8 @@ class Light {
if (this._type === LIGHTTYPE_OMNI)
value = SHADOW_PCF3; // VSM or HW PCF for omni lights is not supported yet

if (value === SHADOW_PCF5 && !device.webgl2) {
const supportsPCF5 = device.webgl2 || device.deviceType === DEVICETYPE_WEBGPU;
if (value === SHADOW_PCF5 && !supportsPCF5) {
value = SHADOW_PCF3; // fallback from HW PCF to old PCF
}

Expand Down Expand Up @@ -574,6 +579,14 @@ class Light {
if (this.shadowUpdateMode === SHADOWUPDATE_NONE) {
this.shadowUpdateMode = SHADOWUPDATE_THISFRAME;
}

if (this.shadowUpdateOverrides) {
for (let i = 0; i < this.shadowUpdateOverrides.length; i++) {
if (this.shadowUpdateOverrides[i] === SHADOWUPDATE_NONE) {
this.shadowUpdateOverrides[i] = SHADOWUPDATE_THISFRAME;
}
}
}
}

// returns LightRenderData with matching camera and face
Expand Down Expand Up @@ -620,6 +633,10 @@ class Light {
clone.shadowUpdateMode = this.shadowUpdateMode;
clone.mask = this.mask;

if (this.shadowUpdateOverrides) {
clone.shadowUpdateOverrides = this.shadowUpdateOverrides.slice();
}

// Spot properties
clone.innerConeAngle = this._innerConeAngle;
clone.outerConeAngle = this._outerConeAngle;
Expand Down Expand Up @@ -801,12 +818,6 @@ class Light {
this._updateFinalColor();
}

updateShadow() {
if (this.shadowUpdateMode !== SHADOWUPDATE_REALTIME) {
this.shadowUpdateMode = SHADOWUPDATE_THISFRAME;
}
}

layersDirty() {
if (this._scene?.layers) {
this._scene.layers._dirtyLights = true;
Expand Down
41 changes: 38 additions & 3 deletions src/scene/renderer/shadow-renderer-directional.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Debug, DebugHelper } from '../../core/debug.js';
import { math } from '../../core/math/math.js';
import { Vec3 } from '../../core/math/vec3.js';
import { Mat4 } from '../../core/math/mat4.js';
import { BoundingBox } from '../../core/shape/bounding-box.js';

import {
LIGHTTYPE_DIRECTIONAL
LIGHTTYPE_DIRECTIONAL, SHADOWUPDATE_NONE, SHADOWUPDATE_THISFRAME
} from '../constants.js';
import { RenderPass } from '../../platform/graphics/render-pass.js';

Expand Down Expand Up @@ -63,8 +64,14 @@ class ShadowRendererDirectional extends ShadowRenderer {
const nearDist = camera._nearClip;
this.generateSplitDistances(light, nearDist, light.shadowDistance);

const shadowUpdateOverrides = light.shadowUpdateOverrides;
for (let cascade = 0; cascade < light.numCascades; cascade++) {

// if manually controlling cascade rendering and the cascade does not render this frame
if (shadowUpdateOverrides?.[cascade] === SHADOWUPDATE_NONE) {
break;
}

const lightRenderData = light.getRenderData(camera, cascade);
const shadowCam = lightRenderData.shadowCamera;

Expand Down Expand Up @@ -160,22 +167,50 @@ class ShadowRendererDirectional extends ShadowRenderer {
}
}

// function to generate frustum split distances
generateSplitDistances(light, nearDist, farDist) {

light._shadowCascadeDistances.fill(farDist);
for (let i = 1; i < light.numCascades; i++) {

// lerp between linear and logarithmic distance, called practical split distance
const fraction = i / light.numCascades;
const linearDist = nearDist + (farDist - nearDist) * fraction;
const logDist = nearDist * (farDist / nearDist) ** fraction;
const dist = math.lerp(linearDist, logDist, light.cascadeDistribution);
light._shadowCascadeDistances[i - 1] = dist;
}
}

addLightRenderPasses(frameGraph, light, camera) {

// shadow cascades have more faces rendered within a singe render pass
const faceCount = light.numShadowFaces;
const shadowUpdateOverrides = light.shadowUpdateOverrides;

// prepare render targets / cameras for rendering
let allCascadesRendering = true;
let shadowCamera;
for (let face = 0; face < faceCount; face++) {

if (shadowUpdateOverrides?.[face] === SHADOWUPDATE_NONE)
allCascadesRendering = false;

shadowCamera = this.prepareFace(light, camera, face);
}

const renderPass = new RenderPass(this.device, () => {

// inside the render pass, render all faces
for (let face = 0; face < faceCount; face++) {
this.renderFace(light, camera, face, false);

if (shadowUpdateOverrides?.[face] !== SHADOWUPDATE_NONE) {
this.renderFace(light, camera, face, !allCascadesRendering);
}

if (shadowUpdateOverrides?.[face] === SHADOWUPDATE_THISFRAME) {
shadowUpdateOverrides[face] = SHADOWUPDATE_NONE;
}
}

}, () => {
Expand All @@ -186,7 +221,7 @@ class ShadowRendererDirectional extends ShadowRenderer {
});

// setup render pass using any of the cameras, they all have the same pass related properties
this.setupRenderPass(renderPass, shadowCamera);
this.setupRenderPass(renderPass, shadowCamera, allCascadesRendering);
DebugHelper.setName(renderPass, `DirShadow-${light._node.name}`);

frameGraph.addRenderPass(renderPass);
Expand Down
39 changes: 13 additions & 26 deletions src/scene/renderer/shadow-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Debug } from '../../core/debug.js';
import { now } from '../../core/time.js';
import { Color } from '../../core/math/color.js';
import { Mat4 } from '../../core/math/mat4.js';
import { math } from '../../core/math/math.js';
import { Vec3 } from '../../core/math/vec3.js';
import { Vec4 } from '../../core/math/vec4.js';

Expand Down Expand Up @@ -160,21 +159,6 @@ class ShadowRenderer {
visible.sort(this.renderer.sortCompareDepth);
}

// function to generate frustum split distances
generateSplitDistances(light, nearDist, farDist) {

light._shadowCascadeDistances.fill(farDist);
for (let i = 1; i < light.numCascades; i++) {

// lerp between linear and logarithmic distance, called practical split distance
const fraction = i / light.numCascades;
const linearDist = nearDist + (farDist - nearDist) * fraction;
const logDist = nearDist * (farDist / nearDist) ** fraction;
const dist = math.lerp(linearDist, logDist, light.cascadeDistribution);
light._shadowCascadeDistances[i - 1] = dist;
}
}

setupRenderState(device, light) {

const isClustered = this.renderer.scene.clusteredLightingEnabled;
Expand Down Expand Up @@ -340,20 +324,23 @@ class ShadowRenderer {
return light.getRenderData(light._type === LIGHTTYPE_DIRECTIONAL ? camera : null, face);
}

setupRenderPass(renderPass, shadowCamera) {
setupRenderPass(renderPass, shadowCamera, clearRenderTarget) {

const rt = shadowCamera.renderTarget;
renderPass.init(rt);

// color
const clearColor = shadowCamera.clearColorBuffer;
renderPass.colorOps.clear = clearColor;
if (clearColor)
renderPass.colorOps.clearValue.copy(shadowCamera.clearColor);

// depth
renderPass.depthStencilOps.storeDepth = !clearColor;
renderPass.setClearDepth(1.0);
// only clear the render pass target if all faces (cascades) are getting rendered
if (clearRenderTarget) {
// color
const clearColor = shadowCamera.clearColorBuffer;
renderPass.colorOps.clear = clearColor;
if (clearColor)
renderPass.colorOps.clearValue.copy(shadowCamera.clearColor);

// depth
renderPass.depthStencilOps.storeDepth = !clearColor;
renderPass.setClearDepth(1.0);
}

// not sampling dynamically generated cubemaps
renderPass.requiresCubemaps = false;
Expand Down

0 comments on commit 1ed8cb1

Please sign in to comment.