diff --git a/examples/assets/models/dispersion-test.glb b/examples/assets/models/dispersion-test.glb new file mode 100644 index 00000000000..45472057f9f Binary files /dev/null and b/examples/assets/models/dispersion-test.glb differ diff --git a/examples/src/examples/graphics/dispersion/config.mjs b/examples/src/examples/graphics/dispersion/config.mjs new file mode 100644 index 00000000000..b7fc3987320 --- /dev/null +++ b/examples/src/examples/graphics/dispersion/config.mjs @@ -0,0 +1,6 @@ +/** + * @type {import('../../../../types.mjs').ExampleConfig} + */ +export default { + WEBGPU_ENABLED: true +}; diff --git a/examples/src/examples/graphics/dispersion/example.mjs b/examples/src/examples/graphics/dispersion/example.mjs new file mode 100644 index 00000000000..05b28c33a2a --- /dev/null +++ b/examples/src/examples/graphics/dispersion/example.mjs @@ -0,0 +1,97 @@ +import * as pc from 'playcanvas'; +import { deviceType, rootPath } from '@examples/utils'; + +const canvas = document.getElementById('application-canvas'); +if (!(canvas instanceof HTMLCanvasElement)) { + throw new Error('No canvas found'); +} + +const assets = { + script: new pc.Asset('script', 'script', { url: rootPath + '/static/scripts/camera/orbit-camera.js' }), + model: new pc.Asset('cube', 'container', { url: rootPath + '/static/assets/models/dispersion-test.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); +const createOptions = new pc.AppOptions(); +createOptions.graphicsDevice = device; +createOptions.mouse = new pc.Mouse(document.body); +createOptions.touch = new pc.TouchDevice(document.body); +createOptions.keyboard = new pc.Keyboard(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); + +// 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); +}); + +const assetListLoader = new pc.AssetListLoader(Object.values(assets), app.assets); +assetListLoader.load(() => { + app.start(); + + // set skybox + app.scene.envAtlas = assets.helipad.resource; + app.scene.toneMapping = pc.TONEMAP_ACES; + app.scene.skyboxMip = 1; + + // get the instance of the cube it set up with render component and add it to scene + const glbEntity = assets.model.resource.instantiateRenderEntity(); + app.root.addChild(glbEntity); + + // Create an Entity with a camera component + const camera = new pc.Entity(); + camera.addComponent('camera', { + clearColor: new pc.Color(0.2, 0.2, 0.2) + }); + + // the color grab pass is needed + camera.camera.requestSceneColorMap(true); + + // Adjust the camera position + camera.translate(0, 0.3, 1); + + camera.addComponent('script'); + camera.script.create('orbitCamera', { + attributes: { + inertiaFactor: 0.2, + distanceMax: 0.15 + } + }); + camera.script.create('orbitCameraInputMouse'); + camera.script.create('orbitCameraInputTouch'); + app.root.addChild(camera); +}); + +export { app }; diff --git a/src/framework/parsers/glb-parser.js b/src/framework/parsers/glb-parser.js index 197ec1e5ae7..0cf89ef401b 100644 --- a/src/framework/parsers/glb-parser.js +++ b/src/framework/parsers/glb-parser.js @@ -1080,6 +1080,12 @@ const extensionIor = (data, material, textures) => { } }; +const extensionDispersion = (data, material, textures) => { + if (data.hasOwnProperty('dispersion')) { + material.dispersion = data.dispersion; + } +}; + const extensionTransmission = (data, material, textures) => { material.blendType = BLEND_NORMAL; material.useDynamicRefraction = true; @@ -1312,6 +1318,7 @@ const createMaterial = (gltfMaterial, textures, flipV) => { "KHR_materials_clearcoat": extensionClearCoat, "KHR_materials_emissive_strength": extensionEmissiveStrength, "KHR_materials_ior": extensionIor, + "KHR_materials_dispersion": extensionDispersion, "KHR_materials_iridescence": extensionIridescence, "KHR_materials_pbrSpecularGlossiness": extensionPbrSpecGlossiness, "KHR_materials_sheen": extensionSheen, diff --git a/src/platform/graphics/constants.js b/src/platform/graphics/constants.js index 7b96026c0a1..9f79399375c 100644 --- a/src/platform/graphics/constants.js +++ b/src/platform/graphics/constants.js @@ -1830,3 +1830,4 @@ export const CHUNKAPI_1_58 = '1.58'; export const CHUNKAPI_1_60 = '1.60'; export const CHUNKAPI_1_62 = '1.62'; export const CHUNKAPI_1_65 = '1.65'; +export const CHUNKAPI_1_70 = '1.70'; diff --git a/src/scene/materials/lit-material-options-builder.js b/src/scene/materials/lit-material-options-builder.js index 3ac1d743f5e..d9de53d38d0 100644 --- a/src/scene/materials/lit-material-options-builder.js +++ b/src/scene/materials/lit-material-options-builder.js @@ -79,6 +79,7 @@ class LitMaterialOptionsBuilder { litOptions.useIridescence = material.hasIrridescence; litOptions.useMetalness = material.hasMetalness; litOptions.useDynamicRefraction = material.dynamicRefraction; + litOptions.dispersion = material.dispersion > 0; litOptions.vertexColors = false; litOptions.lightMapEnabled = material.hasLighting; diff --git a/src/scene/materials/standard-material-options-builder.js b/src/scene/materials/standard-material-options-builder.js index ee14c3c9aa6..0c87bd65771 100644 --- a/src/scene/materials/standard-material-options-builder.js +++ b/src/scene/materials/standard-material-options-builder.js @@ -290,6 +290,7 @@ class StandardMaterialOptionsBuilder { options.litOptions.useIridescence = stdMat.useIridescence && stdMat.iridescence !== 0.0; options.litOptions.useMetalness = stdMat.useMetalness; options.litOptions.useDynamicRefraction = stdMat.useDynamicRefraction; + options.litOptions.dispersion = stdMat.dispersion > 0; } _updateEnvOptions(options, stdMat, scene) { diff --git a/src/scene/materials/standard-material-parameters.js b/src/scene/materials/standard-material-parameters.js index c805d949c9f..1f60923c1fa 100644 --- a/src/scene/materials/standard-material-parameters.js +++ b/src/scene/materials/standard-material-parameters.js @@ -103,6 +103,7 @@ const standardMaterialParameterTypes = { refractionTint: 'boolean', ..._textureParameter('refraction'), refractionIndex: 'number', + dispersion: 'number', thickness: 'number', thicknessTint: 'boolean', ..._textureParameter('thickness'), diff --git a/src/scene/materials/standard-material.js b/src/scene/materials/standard-material.js index ad818b89b72..74c43241f4a 100644 --- a/src/scene/materials/standard-material.js +++ b/src/scene/materials/standard-material.js @@ -279,6 +279,8 @@ let _params = new Set(); * indices of refraction, the one around the object and the one of its own surface. In most * situations outer medium is air, so outerIor will be approximately 1. Then you only need to do * (1.0 / surfaceIor). + * @property {number} dispersion The strength of the angular separation of colors (chromatic + * aberration) transmitting through a volume. Defaults to 0, which is equivalent to no dispersion. * @property {boolean} useDynamicRefraction Enables higher quality refractions using the grab pass * instead of pre-computed cube maps for refractions. * @property {number} thickness The thickness of the medium, only used when useDynamicRefraction @@ -787,6 +789,10 @@ class StandardMaterial extends Material { this._setParameter('material_refraction', this.refraction); } + if (this.dispersion > 0) { + this._setParameter('material_dispersion', this.dispersion); + } + if (this.useDynamicRefraction) { this._setParameter('material_thickness', this.thickness); this._setParameter('material_attenuation', getUniform('attenuation')); @@ -1171,6 +1177,7 @@ function _defineMaterialProps() { _defineFloat('occludeSpecularIntensity', 1); _defineFloat('refraction', 0); _defineFloat('refractionIndex', 1.0 / 1.5); // approx. (air ior / glass ior) + _defineFloat('dispersion', 0); _defineFloat('thickness', 0); _defineFloat('attenuationDistance', 0); _defineFloat('metalness', 1); diff --git a/src/scene/shader-lib/chunks/chunk-validation.js b/src/scene/shader-lib/chunks/chunk-validation.js index f6f5a51c587..4f18495df40 100644 --- a/src/scene/shader-lib/chunks/chunk-validation.js +++ b/src/scene/shader-lib/chunks/chunk-validation.js @@ -1,4 +1,4 @@ -import { CHUNKAPI_1_51, CHUNKAPI_1_55, CHUNKAPI_1_56, CHUNKAPI_1_57, CHUNKAPI_1_60, CHUNKAPI_1_62, CHUNKAPI_1_65 } from '../../../platform/graphics/constants.js'; +import { CHUNKAPI_1_51, CHUNKAPI_1_55, CHUNKAPI_1_56, CHUNKAPI_1_57, CHUNKAPI_1_60, CHUNKAPI_1_62, CHUNKAPI_1_65, CHUNKAPI_1_70 } from '../../../platform/graphics/constants.js'; import { Debug } from '../../../core/debug.js'; import { shaderChunks } from './chunks.js'; @@ -79,8 +79,8 @@ const chunkVersions = { iridescenceDiffractionPS: CHUNKAPI_1_65, lightmapAddPS: CHUNKAPI_1_65, lightmapDirAddPS: CHUNKAPI_1_65, - refractionCubePS: CHUNKAPI_1_65, - refractionDynamicPS: CHUNKAPI_1_65 + refractionCubePS: CHUNKAPI_1_70, + refractionDynamicPS: CHUNKAPI_1_70 }; // removed diff --git a/src/scene/shader-lib/chunks/lit/frag/refractionCube.js b/src/scene/shader-lib/chunks/lit/frag/refractionCube.js index 7ad2178498f..32f8c70173e 100644 --- a/src/scene/shader-lib/chunks/lit/frag/refractionCube.js +++ b/src/scene/shader-lib/chunks/lit/frag/refractionCube.js @@ -14,7 +14,8 @@ void addRefraction( vec3 specularity, vec3 albedo, float transmission, - float refractionIndex + float refractionIndex, + float dispersion #if defined(LIT_IRIDESCENCE) , vec3 iridescenceFresnel, float iridescenceIntensity diff --git a/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js b/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js index af1f8798f74..f2f7b271766 100644 --- a/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js +++ b/src/scene/shader-lib/chunks/lit/frag/refractionDynamic.js @@ -2,6 +2,29 @@ export default /* glsl */` uniform float material_invAttenuationDistance; uniform vec3 material_attenuation; +vec3 evalRefractionColor(vec3 refractionVector, float gloss, float refractionIndex) { + + // The refraction point is the entry point + vector to exit point + vec4 pointOfRefraction = vec4(vPositionW + refractionVector, 1.0); + + // Project to texture space so we can sample it + vec4 projectionPoint = matrix_viewProjection * pointOfRefraction; + + // use built-in getGrabScreenPos function to convert screen position to grab texture uv coords + vec2 uv = getGrabScreenPos(projectionPoint); + + #ifdef SUPPORTS_TEXLOD + // Use IOR and roughness to select mip + float iorToRoughness = (1.0 - gloss) * clamp((1.0 / refractionIndex) * 2.0 - 2.0, 0.0, 1.0); + float refractionLod = log2(uScreenSize.x) * iorToRoughness; + vec3 refraction = texture2DLodEXT(uSceneColorMap, uv, refractionLod).rgb; + #else + vec3 refraction = texture2D(uSceneColorMap, uv).rgb; + #endif + + return refraction; +} + void addRefraction( vec3 worldNormal, vec3 viewDir, @@ -10,7 +33,8 @@ void addRefraction( vec3 specularity, vec3 albedo, float transmission, - float refractionIndex + float refractionIndex, + float dispersion #if defined(LIT_IRIDESCENCE) , vec3 iridescenceFresnel, float iridescenceIntensity @@ -24,24 +48,22 @@ void addRefraction( modelScale.z = length(vec3(matrix_model[2].xyz)); // Calculate the refraction vector, scaled by the thickness and scale of the object - vec3 refractionVector = normalize(refract(-viewDir, worldNormal, refractionIndex)) * thickness * modelScale; + vec3 scale = thickness * modelScale; + vec3 refractionVector = normalize(refract(-viewDir, worldNormal, refractionIndex)) * scale; + vec3 refraction = evalRefractionColor(refractionVector, gloss, refractionIndex); - // The refraction point is the entry point + vector to exit point - vec4 pointOfRefraction = vec4(vPositionW + refractionVector, 1.0); + #ifdef DISPERSION + // based on the dispersion material property, calculate modified refraction index values + // for R and B channels and evaluate the refraction color for them. + float halfSpread = (1.0 / refractionIndex - 1.0) * 0.025 * dispersion; - // Project to texture space so we can sample it - vec4 projectionPoint = matrix_viewProjection * pointOfRefraction; + float refractionIndexR = refractionIndex - halfSpread; + refractionVector = normalize(refract(-viewDir, worldNormal, refractionIndexR)) * scale; + refraction.r = evalRefractionColor(refractionVector, gloss, refractionIndexR).r; - // use built-in getGrabScreenPos function to convert screen position to grab texture uv coords - vec2 uv = getGrabScreenPos(projectionPoint); - - #ifdef SUPPORTS_TEXLOD - // Use IOR and roughness to select mip - float iorToRoughness = (1.0 - gloss) * clamp((1.0 / refractionIndex) * 2.0 - 2.0, 0.0, 1.0); - float refractionLod = log2(uScreenSize.x) * iorToRoughness; - vec3 refraction = texture2DLodEXT(uSceneColorMap, uv, refractionLod).rgb; - #else - vec3 refraction = texture2D(uSceneColorMap, uv).rgb; + float refractionIndexB = refractionIndex + halfSpread; + refractionVector = normalize(refract(-viewDir, worldNormal, refractionIndexB)) * scale; + refraction.b = evalRefractionColor(refractionVector, gloss, refractionIndexB).b; #endif // Transmittance is our final refraction color diff --git a/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js b/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js index b0a0772bcde..4845100fd2d 100644 --- a/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js +++ b/src/scene/shader-lib/chunks/standard/frag/litShaderArgs.js @@ -48,6 +48,9 @@ float litArgs_thickness; // Index of refraction float litArgs_ior; +// Dispersion, range [0..1] typically, but can be higher +float litArgs_dispersion; + // Iridescence effect intensity, range [0..1] float litArgs_iridescence_intensity; diff --git a/src/scene/shader-lib/programs/lit-shader-options.js b/src/scene/shader-lib/programs/lit-shader-options.js index e8aa8ac40f3..85b20ca67e2 100644 --- a/src/scene/shader-lib/programs/lit-shader-options.js +++ b/src/scene/shader-lib/programs/lit-shader-options.js @@ -240,6 +240,8 @@ class LitShaderOptions { useDynamicRefraction = false; + dispersion = false; + /** * The type of fog being applied in the shader. See {@link Scene#fog} for the list of possible * values. diff --git a/src/scene/shader-lib/programs/lit-shader.js b/src/scene/shader-lib/programs/lit-shader.js index 118d3e9802d..b360b5429b4 100644 --- a/src/scene/shader-lib/programs/lit-shader.js +++ b/src/scene/shader-lib/programs/lit-shader.js @@ -831,6 +831,10 @@ class LitShader { if (options.useRefraction) { if (options.useDynamicRefraction) { + if (options.dispersion) { + decl.append("uniform float material_dispersion;"); + decl.append('#define DISPERSION\n'); + } func.append(chunks.refractionDynamicPS); } else if (this.reflections) { func.append(chunks.refractionCubePS); @@ -1451,7 +1455,8 @@ class LitShader { litArgs_specularity, litArgs_albedo, litArgs_transmission, - litArgs_ior + litArgs_ior, + litArgs_dispersion #if defined(LIT_IRIDESCENCE) , iridescenceFresnel, litArgs_iridescence_intensity diff --git a/src/scene/shader-lib/programs/standard.js b/src/scene/shader-lib/programs/standard.js index 2ee7effd651..30aac34c340 100644 --- a/src/scene/shader-lib/programs/standard.js +++ b/src/scene/shader-lib/programs/standard.js @@ -370,6 +370,10 @@ class ShaderGeneratorStandard extends ShaderGenerator { code.append(this._addMap("thickness", "thicknessPS", options, litShader.chunks, textureMapping)); func.append("getThickness();"); args.append("litArgs_thickness = dThickness;"); + + if (options.litOptions.dispersion) { + args.append("litArgs_dispersion = material_dispersion;"); + } } if (options.litOptions.useIridescence) { diff --git a/test/scene/materials/standard-material.test.mjs b/test/scene/materials/standard-material.test.mjs index f7bb14259f9..df3c8f26e3a 100644 --- a/test/scene/materials/standard-material.test.mjs +++ b/test/scene/materials/standard-material.test.mjs @@ -241,6 +241,7 @@ describe('StandardMaterial', function () { expect(material.reflectivity).to.equal(1); expect(material.refraction).to.equal(0); expect(material.refractionIndex).to.equal(1.0 / 1.5); + expect(material.dispersion).to.equal(0); expect(material.shadingModel).to.equal(SPECULAR_BLINN); expect(material.specular).to.be.instanceof(Color); diff --git a/test/test-assets/box/1/Box Material.json b/test/test-assets/box/1/Box Material.json index 5ab4e617a8f..d51a8080715 100644 --- a/test/test-assets/box/1/Box Material.json +++ b/test/test-assets/box/1/Box Material.json @@ -111,6 +111,7 @@ ], "reflectivity": 1, "refractionIndex": 0.6666666666666666, + "dispersion": 0, "cubeMapProjectionBox": { "center": [ 0, diff --git a/tests/test-assets/box/1/Box Material.json b/tests/test-assets/box/1/Box Material.json index 5ab4e617a8f..d51a8080715 100644 --- a/tests/test-assets/box/1/Box Material.json +++ b/tests/test-assets/box/1/Box Material.json @@ -111,6 +111,7 @@ ], "reflectivity": 1, "refractionIndex": 0.6666666666666666, + "dispersion": 0, "cubeMapProjectionBox": { "center": [ 0, diff --git a/utils/types-fixup.mjs b/utils/types-fixup.mjs index ac60cc0b02d..ebf5c11a110 100644 --- a/utils/types-fixup.mjs +++ b/utils/types-fixup.mjs @@ -386,6 +386,7 @@ const standardMaterialProps = [ ['reflectivity', 'number'], ['refraction', 'number'], ['refractionIndex', 'number'], + ['dispersion', 'number'], ['shadingModel', 'number'], ['specular', 'Color'], ['specularMap', 'Texture|null'],