diff --git a/examples/src/examples/graphics/light-physical-units.example.mjs b/examples/src/examples/graphics/light-physical-units.example.mjs index 5445695e229..bf3dc549ca9 100644 --- a/examples/src/examples/graphics/light-physical-units.example.mjs +++ b/examples/src/examples/graphics/light-physical-units.example.mjs @@ -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); } diff --git a/examples/src/examples/graphics/shadow-soft.controls.mjs b/examples/src/examples/graphics/shadow-soft.controls.mjs new file mode 100644 index 00000000000..b51c29f4114 --- /dev/null +++ b/examples/src/examples/graphics/shadow-soft.controls.mjs @@ -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 + }) + ) + ) + ); +}; diff --git a/examples/src/examples/graphics/shadow-soft.example.mjs b/examples/src/examples/graphics/shadow-soft.example.mjs new file mode 100644 index 00000000000..d2b7b2dab92 --- /dev/null +++ b/examples/src/examples/graphics/shadow-soft.example.mjs @@ -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} */ + 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} */ + 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 }; diff --git a/examples/thumbnails/graphics_shadow-soft_large.webp b/examples/thumbnails/graphics_shadow-soft_large.webp new file mode 100644 index 00000000000..841dc046dd2 Binary files /dev/null and b/examples/thumbnails/graphics_shadow-soft_large.webp differ diff --git a/examples/thumbnails/graphics_shadow-soft_small.webp b/examples/thumbnails/graphics_shadow-soft_small.webp new file mode 100644 index 00000000000..64df84ce5c0 Binary files /dev/null and b/examples/thumbnails/graphics_shadow-soft_small.webp differ diff --git a/src/framework/components/light/component.js b/src/framework/components/light/component.js index e71dd19950a..a7469061e6b 100644 --- a/src/framework/components/light/component.js +++ b/src/framework/components/light/component.js @@ -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. + * + * @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 16. Note + * that this values can be lower than shadowSamples to optimize performance, often without large + * impact on quality. + * + * @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 @@ -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; diff --git a/src/framework/components/light/data.js b/src/framework/components/light/data.js index 7ffe73927b5..1bb259013df 100644 --- a/src/framework/components/light/data.js +++ b/src/framework/components/light/data.js @@ -101,6 +101,12 @@ class LightComponentData { layers = [LAYERID_WORLD]; penumbraSize = 1; + + penumbraFalloff = 1; + + shadowSamples = 16; + + shadowBlockerSamples = 16; } const properties = Object.keys(new LightComponentData()); diff --git a/src/scene/constants.js b/src/scene/constants.js index f28c3edb1d3..633a577f416 100644 --- a/src/scene/constants.js +++ b/src/scene/constants.js @@ -340,7 +340,10 @@ export const SHADOW_PCF1 = 5; // alias for SHADOW_PCF1_32F for backwards compat /** * A shadow sampling technique using a 32-bit shadow map that adjusts filter size based on blocker - * distance, producing realistic, soft shadow edges that vary with the light's occlusion. + * distance, producing realistic, soft shadow edges that vary with the light's occlusion. Note that + * this technique requires either {@link GraphicsDevice#textureFloatRenderable} or + * {@link GraphicsDevice#textureHalfFloatRenderable} to be true, and falls back to + * {@link SHADOW_PCF3_32F} otherwise. * * @type {number} * @category Graphics diff --git a/src/scene/light.js b/src/scene/light.js index e8f486fad66..9d650d6319e 100644 --- a/src/scene/light.js +++ b/src/scene/light.js @@ -225,10 +225,15 @@ class Light { this._normalOffsetBias = 0.0; this.shadowUpdateMode = SHADOWUPDATE_REALTIME; this.shadowUpdateOverrides = null; - this._penumbraSize = 1.0; this._isVsm = false; this._isPcf = true; + this._softShadowParams = new Float32Array(4); + this.shadowSamples = 16; + this.shadowBlockerSamples = 16; + this.penumbraSize = 1.0; + this.penumbraFalloff = 1.0; + // cookie matrix (used in case the shadow mapping is disabled and so the shadow matrix cannot be used) this._cookieMatrix = null; @@ -280,6 +285,22 @@ class Light { this.layers.delete(layer); } + set shadowSamples(value) { + this._softShadowParams[0] = value; + } + + get shadowSamples() { + return this._softShadowParams[0]; + } + + set shadowBlockerSamples(value) { + this._softShadowParams[1] = value; + } + + get shadowBlockerSamples() { + return this._softShadowParams[1]; + } + set shadowBias(value) { if (this._shadowBias !== value) { this._shadowBias = value; @@ -406,6 +427,11 @@ class Light { const device = this.device; + // PCSS requires F16 or F32 render targets + if (value === SHADOW_PCSS_32F && !device.textureFloatRenderable && !device.textureHalfFloatRenderable) { + value = SHADOW_PCF3_32F; + } + // omni light supports PCF1, PCF3 and PCSS only if (this._type === LIGHTTYPE_OMNI && value !== SHADOW_PCF1_32F && value !== SHADOW_PCF3_32F && value !== SHADOW_PCF1_16F && value !== SHADOW_PCF3_16F && value !== SHADOW_PCSS_32F) { @@ -555,12 +581,21 @@ class Light { set penumbraSize(value) { this._penumbraSize = value; + this._softShadowParams[2] = value; } get penumbraSize() { return this._penumbraSize; } + set penumbraFalloff(value) { + this._softShadowParams[3] = value; + } + + get penumbraFalloff() { + return this._softShadowParams[3]; + } + _updateOuterAngle(angle) { const radAngle = angle * Math.PI / 180; this._outerConeAngleCos = Math.cos(radAngle); @@ -777,7 +812,6 @@ class Light { clone.vsmBlurSize = this._vsmBlurSize; clone.vsmBlurMode = this.vsmBlurMode; clone.vsmBias = this.vsmBias; - clone.penumbraSize = this.penumbraSize; clone.shadowUpdateMode = this.shadowUpdateMode; clone.mask = this.mask; @@ -805,6 +839,11 @@ class Light { clone.shadowDistance = this.shadowDistance; clone.shadowIntensity = this.shadowIntensity; + clone.shadowSamples = this.shadowSamples; + clone.shadowBlockerSamples = this.shadowBlockerSamples; + clone.penumbraSize = this.penumbraSize; + clone.penumbraFalloff = this.penumbraFalloff; + // Cookies properties // clone.cookie = this._cookie; // clone.cookieIntensity = this.cookieIntensity; diff --git a/src/scene/renderer/forward-renderer.js b/src/scene/renderer/forward-renderer.js index 03e6ea1a320..c03a75b4e9f 100644 --- a/src/scene/renderer/forward-renderer.js +++ b/src/scene/renderer/forward-renderer.js @@ -121,6 +121,7 @@ class ForwardRenderer extends Renderer { this.lightCookieOffsetId = []; this.lightShadowSearchAreaId = []; this.lightCameraParamsId = []; + this.lightSoftShadowParamsId = []; // shadow cascades this.shadowMatrixPaletteId = []; @@ -198,6 +199,7 @@ class ForwardRenderer extends Renderer { this.lightCookieMatrixId[i] = scope.resolve(`${light}_cookieMatrix`); this.lightCookieOffsetId[i] = scope.resolve(`${light}_cookieOffset`); this.lightCameraParamsId[i] = scope.resolve(`${light}_cameraParams`); + this.lightSoftShadowParamsId[i] = scope.resolve(`${light}_softShadowParams`); // shadow cascades this.shadowMatrixPaletteId[i] = scope.resolve(`${light}_shadowMatrixPalette[0]`); @@ -268,6 +270,7 @@ class ForwardRenderer extends Renderer { this.shadowCascadeCountId[cnt].setValue(directional.numCascades); this.shadowCascadeBlendId[cnt].setValue(1 - directional.cascadeBlend); this.lightShadowIntensity[cnt].setValue(directional.shadowIntensity); + this.lightSoftShadowParamsId[cnt].setValue(directional._softShadowParams); const shadowRT = lightRenderData.shadowCamera.renderTarget; if (shadowRT) { @@ -276,7 +279,7 @@ class ForwardRenderer extends Renderer { const cameraParams = directional._shadowCameraParams; cameraParams.length = 4; - cameraParams[0] = 3.0; // unused + cameraParams[0] = 0; // unused cameraParams[1] = lightRenderData.shadowCamera._farClip; cameraParams[2] = lightRenderData.shadowCamera._nearClip; cameraParams[3] = 1; diff --git a/src/scene/renderer/shadow-map.js b/src/scene/renderer/shadow-map.js index 8850c7df129..614412043b3 100644 --- a/src/scene/renderer/shadow-map.js +++ b/src/scene/renderer/shadow-map.js @@ -3,6 +3,7 @@ import { ADDRESS_CLAMP_TO_EDGE, FILTER_LINEAR, FILTER_NEAREST, FUNC_LESS, + PIXELFORMAT_R32F, PIXELFORMAT_R16F, pixelFormatInfo, TEXHINT_SHADOWMAP } from '../../platform/graphics/constants.js'; @@ -83,14 +84,20 @@ class ShadowMap { const shadowInfo = shadowTypeInfo.get(shadowType); Debug.assert(shadowInfo); + let format = shadowInfo.format; + + // when F32 is needed but not supported, fallback to F16 (PCSS) + if (format === PIXELFORMAT_R32F && !device.textureFloatRenderable && device.textureHalfFloatRenderable) { + format = PIXELFORMAT_R16F; + } + const formatName = pixelFormatInfo.get(format)?.name; const filter = this.getShadowFiltering(device, shadowType); - const formatName = pixelFormatInfo.get(shadowInfo.format)?.name; const texture = new Texture(device, { // #if _PROFILER profilerHint: TEXHINT_SHADOWMAP, // #endif - format: shadowInfo?.format, + format: format, width: size, height: size, mipmaps: false, diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index b61adea89d5..2b342c91802 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -289,6 +289,8 @@ class ShadowRenderer { meshInstance.ensureMaterial(device); const material = meshInstance.material; + DebugGraphics.pushGpuMarker(device, `Node: ${meshInstance.node.name}, Material: ${material.name}`); + // set basic material states/parameters renderer.setBaseConstants(device, material); renderer.setSkinning(device, meshInstance); @@ -330,6 +332,8 @@ class ShadowRenderer { // draw renderer.drawInstance(device, meshInstance, mesh, style); renderer._shadowDrawCalls++; + + DebugGraphics.popGpuMarker(device); } } diff --git a/src/scene/shader-lib/chunks/chunks.js b/src/scene/shader-lib/chunks/chunks.js index 3c5756634dd..9d8379d7368 100644 --- a/src/scene/shader-lib/chunks/chunks.js +++ b/src/scene/shader-lib/chunks/chunks.js @@ -44,6 +44,7 @@ import envMultiplyPS from './common/frag/envMultiply.js'; import falloffInvSquaredPS from './lit/frag/falloffInvSquared.js'; import falloffLinearPS from './lit/frag/falloffLinear.js'; import floatUnpackingPS from './lit/frag/float-unpacking.js'; +import floatAsUintPS from './common/frag/float-as-uint.js'; import fogExpPS from './lit/frag/fogExp.js'; import fogExp2PS from './lit/frag/fogExp2.js'; import fogLinearPS from './lit/frag/fogLinear.js'; @@ -163,6 +164,7 @@ import shadowEVSMPS from './lit/frag/shadowEVSM.js'; import shadowEVSMnPS from './lit/frag/shadowEVSMn.js'; import shadowPCSSPS from './lit/frag/shadowPCSS.js'; import shadowSampleCoordPS from './lit/frag/shadowSampleCoord.js'; +import shadowSoftPS from './lit/frag/shadowSoft.js'; import shadowStandardPS from './lit/frag/shadowStandard.js'; import shadowStandardGL2PS from './lit/frag/shadowStandardGL2.js'; import shadowVSM_commonPS from './lit/frag/shadowVSM_common.js'; @@ -257,6 +259,7 @@ const shaderChunks = { falloffInvSquaredPS, falloffLinearPS, floatUnpackingPS, + floatAsUintPS, fogExpPS, fogExp2PS, fogLinearPS, @@ -376,6 +379,7 @@ const shaderChunks = { shadowEVSMnPS, shadowPCSSPS, shadowSampleCoordPS, + shadowSoftPS, shadowStandardPS, shadowStandardGL2PS, shadowVSM_commonPS, diff --git a/src/scene/shader-lib/chunks/common/frag/float-as-uint.js b/src/scene/shader-lib/chunks/common/frag/float-as-uint.js new file mode 100644 index 00000000000..049b863d88d --- /dev/null +++ b/src/scene/shader-lib/chunks/common/frag/float-as-uint.js @@ -0,0 +1,33 @@ +// Chunk that allows us to store all 32bits of float in a single RGBA8 texture without any loss of +// precision. The float value is encoded to RGBA8 and decoded back to float. Used as a fallback +// for platforms that do not support float textures but need to render to a float texture (without +// filtering) +export default /* glsl */` + +#ifndef FLOAT_AS_UINT +#define FLOAT_AS_UINT + +// encode float value to RGBA8 +vec4 float2uint(float value) { + uint intBits = floatBitsToUint(value); + return vec4( + float((intBits >> 24u) & 0xFFu) / 255.0, + float((intBits >> 16u) & 0xFFu) / 255.0, + float((intBits >> 8u) & 0xFFu) / 255.0, + float(intBits & 0xFFu) / 255.0 + ); +} + +// decode RGBA8 value to float +float uint2float(vec4 value) { + uint intBits = + (uint(value.r * 255.0) << 24u) | + (uint(value.g * 255.0) << 16u) | + (uint(value.b * 255.0) << 8u) | + uint(value.a * 255.0); + + return uintBitsToFloat(intBits); +} + +#endif // FLOAT_AS_UINT +`; diff --git a/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js b/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js index 3c93dce5b4c..2b93a1c4fb7 100644 --- a/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js +++ b/src/scene/shader-lib/chunks/lit/frag/shadowPCSS.js @@ -1,13 +1,14 @@ export default /* glsl */` /** - * PCSS is a shadow sampling method that provides contact hardening soft shadows. + * PCSS is a shadow sampling method that provides contact hardening soft shadows, used for omni and spot lights. * Based on: * - https://www.gamedev.net/tutorials/programming/graphics/effect-area-light-shadows-part-1-pcss-r4971/ * - https://github.com/pboechat/PCSS */ #define PCSS_SAMPLE_COUNT 16 + uniform float pcssDiskSamples[PCSS_SAMPLE_COUNT]; uniform float pcssSphereSamples[PCSS_SAMPLE_COUNT]; @@ -48,7 +49,7 @@ float PCSSBlockerDistance(TEXTURE_ACCEPT(shadowMap), vec2 sampleCoords[PCSS_SAMP vec2 offset = sampleCoords[i] * searchSize; vec2 sampleUV = shadowCoords + offset; - float blocker = textureLod(shadowMap, sampleUV, 0.0).r; + float blocker = texture2DLod(shadowMap, sampleUV, 0.0).r; float isBlocking = step(blocker, z); blockers += isBlocking; averageBlocker += blocker * isBlocking; @@ -83,48 +84,14 @@ float PCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, vec2 vec2 sampleUV = samplePoints[i] * filterRadius; sampleUV = shadowCoords.xy + sampleUV; - float depth = textureLod(shadowMap, sampleUV, 0.0).r; - shadow += step(receiverDepth, depth); - } - return shadow / float(PCSS_SAMPLE_COUNT); - } -} - -float PCSSDirectional(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, vec2 shadowSearchArea) { - float receiverDepth = linearizeDepth(shadowCoords.z, cameraParams); - - vec2 samplePoints[PCSS_SAMPLE_COUNT]; - float noise = noise( gl_FragCoord.xy ) * 2.0 * PI; - for (int i = 0; i < PCSS_SAMPLE_COUNT; i++) { - float pcssPresample = pcssDiskSamples[i]; - samplePoints[i] = vogelDisk(i, float(PCSS_SAMPLE_COUNT), noise, pcssPresample); - } - - float averageBlocker = PCSSBlockerDistance(TEXTURE_PASS(shadowMap), samplePoints, shadowCoords.xy, shadowSearchArea, receiverDepth, cameraParams); - if (averageBlocker == -1.0) { - return 1.0; - } else { - float depthDifference = saturate((receiverDepth - averageBlocker) / cameraParams.x); - vec2 filterRadius = depthDifference * shadowSearchArea; - - float shadow = 0.0; - - for (int i = 0; i < PCSS_SAMPLE_COUNT; i ++) - { - vec2 sampleUV = samplePoints[i] * filterRadius; - sampleUV = shadowCoords.xy + sampleUV; - - #ifdef GL2 - float depth = texture(shadowMap, sampleUV).r; - #else // GL1 - float depth = unpackFloat(texture2D(shadowMap, sampleUV)); - #endif + float depth = texture2DLod(shadowMap, sampleUV, 0.0).r; shadow += step(receiverDepth, depth); } return shadow / float(PCSS_SAMPLE_COUNT); } } +#ifndef WEBGPU float PCSSCubeBlockerDistance(samplerCube shadowMap, vec3 lightDirNorm, vec3 samplePoints[PCSS_SAMPLE_COUNT], float z, float shadowSearchArea) { float blockers = 0.0; @@ -181,12 +148,10 @@ float getShadowPointPCSS(samplerCube shadowMap, vec3 shadowCoord, vec4 shadowPar return PCSSCube(shadowMap, shadowParams, shadowCoord, cameraParams, shadowSearchArea.x, lightDir); } +#endif + float getShadowSpotPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 shadowSearchArea, vec3 lightDir) { return PCSS(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, shadowSearchArea); } -float getShadowPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec2 shadowSearchArea, vec3 lightDir) { - return PCSSDirectional(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, shadowSearchArea); -} - `; diff --git a/src/scene/shader-lib/chunks/lit/frag/shadowSoft.js b/src/scene/shader-lib/chunks/lit/frag/shadowSoft.js new file mode 100644 index 00000000000..2997bb2137d --- /dev/null +++ b/src/scene/shader-lib/chunks/lit/frag/shadowSoft.js @@ -0,0 +1,131 @@ +export default /* glsl */` + +/** + * Soft directional shadows PCSS - with and without blocker search. + */ + +highp float fractSinRand( const in vec2 uv ) { + const highp float a = 12.9898, b = 78.233, c = 43758.5453; + highp float dt = dot(uv.xy, vec2(a, b)), sn = mod(dt, PI); + return fract(sin(sn) * c); +} + +// struct to hold precomputed constants and current state +struct PoissonDiskData { + float invNumSamples; + float angleStep; + float initialAngle; + float currentRadius; + float currentAngle; +}; + +// prepare the Poisson disk constants and initialize the current state in the struct +void preparePoissonConstants(out PoissonDiskData data, int sampleCount, int numRings, float randomSeed) { + const float pi2 = 6.28318530718; + data.invNumSamples = 1.0 / float(sampleCount); + data.angleStep = pi2 * float(numRings) * data.invNumSamples; + data.initialAngle = randomSeed * pi2; + data.currentRadius = data.invNumSamples; + data.currentAngle = data.initialAngle; +} + +// generate a Poisson sample using the precomputed struct +vec2 generatePoissonSample(inout PoissonDiskData data) { + vec2 offset = vec2(cos(data.currentAngle), sin(data.currentAngle)) * pow(data.currentRadius, 0.75); + data.currentRadius += data.invNumSamples; + data.currentAngle += data.angleStep; + return offset; +} + +void PCSSFindBlocker(TEXTURE_ACCEPT(shadowMap), out float avgBlockerDepth, out int numBlockers, + vec2 shadowCoords, float z, int shadowBlockerSamples, float penumbraSize, float invShadowMapSize, float randomSeed) { + + PoissonDiskData poissonData; + preparePoissonConstants(poissonData, shadowBlockerSamples, 11, randomSeed); + + float searchWidth = penumbraSize * invShadowMapSize; + float blockerSum = 0.0; + numBlockers = 0; + + for( int i = 0; i < shadowBlockerSamples; ++i ) { + vec2 poissonUV = generatePoissonSample(poissonData); + vec2 sampleUV = shadowCoords + poissonUV * searchWidth; + float shadowMapDepth = texture2DLod(shadowMap, sampleUV, 0.0).r; + if ( shadowMapDepth < z ) { + blockerSum += shadowMapDepth; + numBlockers++; + } + } + avgBlockerDepth = blockerSum / float(numBlockers); +} + +float PCSSFilter(TEXTURE_ACCEPT(shadowMap), vec2 uv, float receiverDepth, int shadowSamples, float filterRadius, float randomSeed) { + + PoissonDiskData poissonData; + preparePoissonConstants(poissonData, shadowSamples, 11, randomSeed); + + float sum = 0.0f; + for ( int i = 0; i < shadowSamples; ++i ) + { + vec2 poissonUV = generatePoissonSample(poissonData); + vec2 sampleUV = uv + poissonUV * filterRadius; + float depth = texture2DLod(shadowMap, sampleUV, 0.0).r; + sum += step(receiverDepth, depth); + } + return sum / float(shadowSamples); +} + +float getPenumbra(float dblocker, float dreceiver, float penumbraSize, float penumbraFalloff) { + float dist = dreceiver - dblocker; + float penumbra = 1.0 - pow(1.0 - dist, penumbraFalloff); + return penumbra * penumbraSize; +} + +float PCSSDirectional(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoords, vec4 cameraParams, vec4 softShadowParams) { + + float receiverDepth = shadowCoords.z; + float randomSeed = fractSinRand(gl_FragCoord.xy); + int shadowSamples = int(softShadowParams.x); + int shadowBlockerSamples = int(softShadowParams.y); + float penumbraSize = softShadowParams.z; + float penumbraFalloff = softShadowParams.w; + + // normalized inverse shadow map size to preserve the shadow softness regardless of the shadow resolution + int shadowMapSize = textureSize(shadowMap, 0).x; + float invShadowMapSize = 1.0 / float(shadowMapSize); + invShadowMapSize *= float(shadowMapSize) / 2048.0; + + float penumbra; + + // contact hardening path + if (shadowBlockerSamples > 0) { + + // find average blocker depth + float avgBlockerDepth = 0.0; + int numBlockers = 0; + PCSSFindBlocker(TEXTURE_PASS(shadowMap), avgBlockerDepth, numBlockers, shadowCoords.xy, receiverDepth, shadowBlockerSamples, penumbraSize, invShadowMapSize, randomSeed); + + // early out when no blockers are present + if (numBlockers < 1) + return 1.0f; + + // penumbra size is based on the blocker depth + penumbra = getPenumbra(avgBlockerDepth, shadowCoords.z, penumbraSize, penumbraFalloff); + + } else { + + // constant filter size, no contact hardening + penumbra = penumbraSize; + } + + float filterRadius = penumbra * invShadowMapSize; + + // filtering + return PCSSFilter(TEXTURE_PASS(shadowMap), shadowCoords.xy, receiverDepth, shadowSamples, filterRadius, randomSeed); +} + +float getShadowPCSS(TEXTURE_ACCEPT(shadowMap), vec3 shadowCoord, vec4 shadowParams, vec4 cameraParams, vec4 softShadowParams, vec3 lightDir) { + return PCSSDirectional(TEXTURE_PASS(shadowMap), shadowCoord, cameraParams, softShadowParams); +} + +`; diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js index 679fcd22269..3b48ef2c7bf 100644 --- a/src/scene/shader-lib/programs/lit-shader.js +++ b/src/scene/shader-lib/programs/lit-shader.js @@ -399,6 +399,7 @@ class LitShader { let code = this._fsGetBeginCode(); code += this.varyings; code += this.varyingDefines; + code += this.chunks.floatAsUintPS; code += this.frontendDecl; code += this.frontendCode; code += ShaderGenerator.begin(); @@ -410,13 +411,7 @@ class LitShader { ` : // storing linear depth float value in RGBA8 ` - uint intBits = floatBitsToUint(vLinearDepth); - gl_FragColor = vec4( - float((intBits >> 24u) & 0xFFu) / 255.0, - float((intBits >> 16u) & 0xFFu) / 255.0, - float((intBits >> 8u) & 0xFFu) / 255.0, - float(intBits & 0xFFu) / 255.0 - ); + gl_FragColor = float2uint(vLinearDepth); `; code += ShaderGenerator.end(); @@ -480,8 +475,11 @@ class LitShader { if (usePerspectiveDepth) { code += ' float depth = gl_FragCoord.z;\n'; if (isPcss) { - // Transform depth values to world space - code += ' depth = linearizeDepth(depth, camera_params);\n'; + // spot/omni shadows currently use linear depth. + // TODO: use perspective depth for spot/omni the same way as directional + if (lightType !== LIGHTTYPE_DIRECTIONAL) { + code += ' depth = linearizeDepth(depth, camera_params);\n'; + } } } else { code += ' float depth = min(distance(view_position, vPositionW) / light_radius, 0.99999);\n'; @@ -588,6 +586,10 @@ class LitShader { if (light._shadowType === SHADOW_PCSS_32F && light.castShadows && !options.noShadow) { decl.append(`uniform float light${i}_shadowSearchArea;`); decl.append(`uniform vec4 light${i}_cameraParams;`); + + if (lightType === LIGHTTYPE_DIRECTIONAL) { + decl.append(`uniform vec4 light${i}_softShadowParams;`); + } } if (lightType === LIGHTTYPE_DIRECTIONAL) { @@ -803,6 +805,7 @@ class LitShader { if (usePcss) { func.append(chunks.linearizeDepthPS); func.append(chunks.shadowPCSSPS); + func.append(chunks.shadowSoftPS); } } @@ -1239,7 +1242,7 @@ class LitShader { // VSM shadowCoordArgs = `${shadowCoordArgs}, ${evsmExp}, dLightDirW`; } else if (pcssShadows) { - let penumbraSizeArg = `vec2(light${i}_shadowSearchArea)`; + let penumbraSizeArg = lightType === LIGHTTYPE_DIRECTIONAL ? `light${i}_softShadowParams` : `vec2(light${i}_shadowSearchArea)`; if (lightShape !== LIGHTSHAPE_PUNCTUAL) { penumbraSizeArg = `vec2(length(light${i}_halfWidth), length(light${i}_halfHeight)) * light${i}_shadowSearchArea`; }