Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overhaul of directional PCSS shadows #7258

Merged
merged 8 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ assetListLoader.load(() => {
app.scene.physicalUnits = value;
} else if (path === 'script.scene.sky') {
if (value) {
app.scene.setSkybox(assets.helipad.resources);
app.scene.envAtlas = assets.helipad.resource;
} else {
app.scene.setSkybox(null);
}
Expand Down
77 changes: 77 additions & 0 deletions examples/src/examples/graphics/shadow-soft.controls.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @param {import('../../app/components/Example.mjs').ControlOptions} options - The options.
* @returns {JSX.Element} The returned JSX Element.
*/
export const controls = ({ observer, ReactPCUI, React, jsx, fragment }) => {
const { BindingTwoWay, BooleanInput, LabelGroup, Panel, SliderInput } = ReactPCUI;
return fragment(
jsx(
Panel,
{ headerText: 'Soft Shadow Settings' },
jsx(
LabelGroup,
{ text: 'Soft Shadows' },
jsx(BooleanInput, {
type: 'toggle',
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.soft' }
})
),
jsx(
LabelGroup,
{ text: 'Resolution' },
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.shadowResolution' },
min: 512,
max: 4096,
precision: 0
})
),
jsx(
LabelGroup,
{ text: 'Penumbra' },
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.penumbraSize' },
min: 1,
max: 100,
precision: 0
})
),
jsx(
LabelGroup,
{ text: 'Falloff' },
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.penumbraFalloff' },
min: 1,
max: 10,
precision: 1
})
),
jsx(
LabelGroup,
{ text: 'Samples' },
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.shadowSamples' },
min: 1,
max: 128,
precision: 0
})
),
jsx(
LabelGroup,
{ text: 'Blocker Samples' },
jsx(SliderInput, {
binding: new BindingTwoWay(),
link: { observer, path: 'settings.light.shadowBlockerSamples' },
min: 0,
max: 128,
precision: 0
})
)
)
);
};
212 changes: 212 additions & 0 deletions examples/src/examples/graphics/shadow-soft.example.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { data } from 'examples/observer';
import { deviceType, rootPath } from 'examples/utils';
import * as pc from 'playcanvas';

const canvas = /** @type {HTMLCanvasElement} */ (document.getElementById('application-canvas'));
window.focus();

const assets = {
script: new pc.Asset('script', 'script', { url: `${rootPath}/static/scripts/camera/orbit-camera.js` }),
terrain: new pc.Asset('terrain', 'container', { url: `${rootPath}/static/assets/models/terrain.glb` }),
helipad: new pc.Asset(
'helipad-env-atlas',
'texture',
{ url: `${rootPath}/static/assets/cubemaps/helipad-env-atlas.png` },
{ type: pc.TEXTURETYPE_RGBP, mipmaps: false }
)
};

const gfxOptions = {
deviceTypes: [deviceType],
glslangUrl: `${rootPath}/static/lib/glslang/glslang.js`,
twgslUrl: `${rootPath}/static/lib/twgsl/twgsl.js`
};

const device = await pc.createGraphicsDevice(canvas, gfxOptions);
device.maxPixelRatio = Math.min(window.devicePixelRatio, 2);

const createOptions = new pc.AppOptions();
createOptions.graphicsDevice = device;
createOptions.mouse = new pc.Mouse(document.body);
createOptions.touch = new pc.TouchDevice(document.body);

createOptions.componentSystems = [
pc.RenderComponentSystem,
pc.CameraComponentSystem,
pc.LightComponentSystem,
pc.ScriptComponentSystem
];
createOptions.resourceHandlers = [
pc.TextureHandler,
pc.ContainerHandler,
pc.ScriptHandler
];

const app = new pc.AppBase(canvas);
app.init(createOptions);

const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets);
assetListLoader.load(() => {
app.start();

data.set('settings', {
light: {
soft: true,
shadowResolution: 2048,
penumbraSize: 20,
penumbraFalloff: 4,
shadowSamples: 16,
shadowBlockerSamples: 16
}
});

// Set the canvas to fill the window and automatically change resolution to be the same as the canvas size
app.setCanvasFillMode(pc.FILLMODE_FILL_WINDOW);
app.setCanvasResolution(pc.RESOLUTION_AUTO);

// Ensure canvas is resized when window changes size
const resize = () => app.resizeCanvas();
window.addEventListener('resize', resize);
app.on('destroy', () => {
window.removeEventListener('resize', resize);
});

// setup skydome
app.scene.skyboxMip = 3;
app.scene.envAtlas = assets.helipad.resource;
app.scene.skyboxRotation = new pc.Quat().setFromEulerAngles(0, -70, 0);

// instantiate the terrain
/** @type {pc.Entity} */
const terrain = assets.terrain.resource.instantiateRenderEntity();
terrain.setLocalScale(30, 30, 30);
app.root.addChild(terrain);

// get the clouds so that we can animate them
/** @type {Array<pc.Entity>} */
const srcClouds = terrain.find((node) => {
const isCloud = node.name.includes('Icosphere');

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

return isCloud;
});

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

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

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

// a large orange pillar
const material = new pc.StandardMaterial();
material.diffuse = new pc.Color(1, 0.5, 0);
const pillar = new pc.Entity('sphere');
pillar.addComponent('render', {
type: 'box',
material: material
});
pillar.setLocalScale(10, 130, 10);
pillar.setLocalPosition(180, 50, 110);
app.root.addChild(pillar);

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

// create an Entity with a camera component
const camera = new pc.Entity();
camera.addComponent('camera', {
clearColor: new pc.Color(0.9, 0.9, 0.9),
farClip: 1000,
toneMapping: pc.TONEMAP_ACES
});

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

// add orbit camera script with a mouse and a touch support
camera.addComponent('script');
camera.script.create('orbitCamera', {
attributes: {
inertiaFactor: 0.2,
focusEntity: tree,
distanceMax: 600
}
});
camera.script.create('orbitCameraInputMouse');
camera.script.create('orbitCameraInputTouch');
app.root.addChild(camera);

// Create a directional light casting soft shadows
const dirLight = new pc.Entity('Cascaded Light');
dirLight.addComponent('light', {
...{
type: 'directional',
color: pc.Color.WHITE,
shadowBias: 0.3,
normalOffsetBias: 0.2,
intensity: 1.0,

// enable shadow casting
castShadows: true,
shadowType: data.get('settings.light.soft') ? pc.SHADOW_PCSS_32F : pc.SHADOW_PCF3_32F,
shadowDistance: 1000
},
...data.get('settings.light')
});
app.root.addChild(dirLight);
dirLight.setLocalEulerAngles(75, 120, 20);

// handle HUD changes - update properties on the light
data.on('*:set', (/** @type {string} */ path, value) => {
const pathArray = path.split('.');
if (pathArray[2] === 'soft') {
dirLight.light.shadowType = value ? pc.SHADOW_PCSS_32F : pc.SHADOW_PCF3_32F;
} else {
dirLight.light[pathArray[2]] = value;
}
});

const cloudSpeed = 0.2;
let frameNumber = 0;
let time = 0;
app.on('update', (/** @type {number} */ dt) => {
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 = 470;
}

// move the clouds around
clouds.forEach((cloud, index) => {
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++;
});
});

export { app };
Binary file not shown.
Binary file added examples/thumbnails/graphics_shadow-soft_small.webp
Binary file not shown.
66 changes: 66 additions & 0 deletions src/framework/components/light/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,51 @@ class LightComponent extends Component {
return this.light.shadowUpdateOverrides;
}

/**
* Sets the number of shadow samples used for soft shadows when the shadow type is
* {@link SHADOW_PCSS_32F}. This value must be a positive whole number starting at 1. Higher
* values result in smoother shadows but can significantly decrease performance. Defaults to 16.
mvaligursky marked this conversation as resolved.
Show resolved Hide resolved
*
* @type {number}
*/
set shadowSamples(value) {
this.light.shadowSamples = value;
}

/**
* Gets the number of shadow samples used for soft shadows.
*
* @type {number}
*/
get shadowSamples() {
return this.light.shadowSamples;
}

/**
* Sets the number of blocker samples used for soft shadows when the shadow type is
* {@link SHADOW_PCSS_32F}. These samples are used to estimate the distance between the shadow
* caster and the shadow receiver, which is then used for the estimation of contact hardening in
* the shadow. This value must be a positive whole number starting at 0. Higher values improve
* shadow quality by considering more occlusion points, but can decrease performance. When set
* to 0, contact hardening is disabled and the shadow has constant softness. Defaults to 4. Note
mvaligursky marked this conversation as resolved.
Show resolved Hide resolved
* that this values can be lower than shadowSamples to optimize performance, often without large
* impact on quality.
mvaligursky marked this conversation as resolved.
Show resolved Hide resolved
*
* @type {number}
*/
set shadowBlockerSamples(value) {
this.light.shadowBlockerSamples = value;
}

/**
* Gets the number of blocker samples used for contact hardening shadows.
*
* @type {number}
*/
get shadowBlockerSamples() {
return this.light.shadowBlockerSamples;
}

/**
* Sets the size of penumbra for contact hardening shadows. For area lights, acts as a
* multiplier with the dimensions of the area light. For punctual and directional lights it's
Expand All @@ -1139,6 +1184,27 @@ class LightComponent extends Component {
return this.light.penumbraSize;
}

/**
* Sets the falloff rate for shadow penumbra for contact hardening shadows. This is a value larger
* than or equal to 1. This parameter determines how quickly the shadow softens with distance.
* Higher values result in a faster softening of the shadow, while lower values produce a more
* gradual transition. Defaults to 1.
*
* @type {number}
*/
set penumbraFalloff(value) {
this.light.penumbraFalloff = value;
}

/**
* Gets the falloff rate for shadow penumbra for contact hardening shadows.
*
* @type {number}
*/
get penumbraFalloff() {
return this.light.penumbraFalloff;
}

/** @ignore */
_setValue(name, value, setFunc, skipEqualsCheck) {
const data = this.data;
Expand Down
6 changes: 6 additions & 0 deletions src/framework/components/light/data.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,12 @@ class LightComponentData {
layers = [LAYERID_WORLD];

penumbraSize = 1;

penumbraFalloff = 1;

shadowSamples = 16;

shadowBlockerSamples = 16;
}

const properties = Object.keys(new LightComponentData());
Expand Down
Loading
Loading