/** * @typedef {foundry.utils.Collection} EffectsCollection */ /** * A container group which contains visual effects rendered above the primary group. * * TODO: * The effects canvas group is now only performing shape initialization, logic that needs to happen at * the placeable or object level is now their burden. * - [DONE] Adding or removing a source from the EffectsCanvasGroup collection. * - [TODO] A change in a darkness source should re-initialize all overlaping light and vision source. * * ### Hook Events * - {@link hookEvents.lightingRefresh} * * @category - Canvas */ class EffectsCanvasGroup extends CanvasGroupMixin(PIXI.Container) { /** * The name of the darkness level animation. * @type {string} */ static #DARKNESS_ANIMATION_NAME = "lighting.animateDarkness"; /** * Whether to currently animate light sources. * @type {boolean} */ animateLightSources = true; /** * Whether to currently animate vision sources. * @type {boolean} */ animateVisionSources = true; /** * A mapping of light sources which are active within the rendered Scene. * @type {EffectsCollection} */ lightSources = new foundry.utils.Collection(); /** * A mapping of darkness sources which are active within the rendered Scene. * @type {EffectsCollection} */ darknessSources = new foundry.utils.Collection(); /** * A Collection of vision sources which are currently active within the rendered Scene. * @type {EffectsCollection} */ visionSources = new foundry.utils.Collection(); /** * A set of vision mask filters used in visual effects group * @type {Set} */ visualEffectsMaskingFilters = new Set(); /* -------------------------------------------- */ /** * Iterator for all light and darkness sources. * @returns {Generator} * @yields foundry.canvas.sources.PointDarknessSource|foundry.canvas.sources.PointLightSource */ * allSources() { for ( const darknessSource of this.darknessSources ) yield darknessSource; for ( const lightSource of this.lightSources ) yield lightSource; } /* -------------------------------------------- */ /** @override */ _createLayers() { /** * A layer of background alteration effects which change the appearance of the primary group render texture. * @type {CanvasBackgroundAlterationEffects} */ this.background = this.addChild(new CanvasBackgroundAlterationEffects()); /** * A layer which adds illumination-based effects to the scene. * @type {CanvasIlluminationEffects} */ this.illumination = this.addChild(new CanvasIlluminationEffects()); /** * A layer which adds color-based effects to the scene. * @type {CanvasColorationEffects} */ this.coloration = this.addChild(new CanvasColorationEffects()); /** * A layer which adds darkness effects to the scene. * @type {CanvasDarknessEffects} */ this.darkness = this.addChild(new CanvasDarknessEffects()); return { background: this.background, illumination: this.illumination, coloration: this.coloration, darkness: this.darkness }; } /* -------------------------------------------- */ /** * Clear all effects containers and animated sources. */ clearEffects() { this.background.clear(); this.illumination.clear(); this.coloration.clear(); this.darkness.clear(); } /* -------------------------------------------- */ /** @override */ async _draw(options) { // Draw each component layer await this.background.draw(); await this.illumination.draw(); await this.coloration.draw(); await this.darkness.draw(); // Call hooks Hooks.callAll("drawEffectsCanvasGroup", this); // Activate animation of drawn objects this.activateAnimation(); } /* -------------------------------------------- */ /* Perception Management Methods */ /* -------------------------------------------- */ /** * Initialize positive light sources which exist within the active Scene. * Packages can use the "initializeLightSources" hook to programmatically add light sources. */ initializeLightSources() { for ( let source of this.lightSources ) source.initialize(); Hooks.callAll("initializeLightSources", this); } /* -------------------------------------------- */ /** * Re-initialize the shapes of all darkness sources in the Scene. * This happens before initialization of light sources because darkness sources contribute additional edges which * limit perception. * Packages can use the "initializeDarknessSources" hook to programmatically add darkness sources. */ initializeDarknessSources() { for ( let source of this.darknessSources ) source.initialize(); Hooks.callAll("initializeDarknessSources", this); } /* -------------------------------------------- */ /** * Refresh the state and uniforms of all light sources and darkness sources objects. */ refreshLightSources() { for ( const source of this.allSources() ) source.refresh(); // FIXME: We need to refresh the field of an AmbientLight only after the initialization of the light source when // the shape of the source could have changed. We don't need to refresh all fields whenever lighting is refreshed. canvas.lighting.refreshFields(); } /* -------------------------------------------- */ /** * Refresh the state and uniforms of all VisionSource objects. */ refreshVisionSources() { for ( const visionSource of this.visionSources ) visionSource.refresh(); } /* -------------------------------------------- */ /** * Refresh the active display of lighting. */ refreshLighting() { // Apply illumination and visibility background color change this.illumination.backgroundColor = canvas.colors.background; if ( this.illumination.darknessLevelMeshes.clearColor[0] !== canvas.environment.darknessLevel ) { this.illumination.darknessLevelMeshes.clearColor[0] = canvas.environment.darknessLevel; this.illumination.invalidateDarknessLevelContainer(true); } const v = canvas.visibility.filter; if ( v ) { v.uniforms.visionTexture = canvas.masks.vision.renderTexture; v.uniforms.primaryTexture = canvas.primary.renderTexture; canvas.colors.fogExplored.applyRGB(v.uniforms.exploredColor); canvas.colors.fogUnexplored.applyRGB(v.uniforms.unexploredColor); canvas.colors.background.applyRGB(v.uniforms.backgroundColor); } // Clear effects canvas.effects.clearEffects(); // Add effect meshes for active light and darkness sources for ( const source of this.allSources() ) this.#addLightEffect(source); // Add effect meshes for active vision sources for ( const visionSource of this.visionSources ) this.#addVisionEffect(visionSource); // Update vision filters state this.background.vision.filter.enabled = !!this.background.vision.children.length; this.background.visionPreferred.filter.enabled = !!this.background.visionPreferred.children.length; // Hide the background and/or coloration layers if possible const lightingOptions = canvas.visibility.visionModeData.activeLightingOptions; this.background.vision.visible = (this.background.vision.children.length > 0); this.background.visionPreferred.visible = (this.background.visionPreferred.children.length > 0); this.background.lighting.visible = (this.background.lighting.children.length > 0) || (lightingOptions.background?.postProcessingModes?.length > 0); this.coloration.visible = (this.coloration.children.length > 1) || (lightingOptions.coloration?.postProcessingModes?.length > 0); // Call hooks Hooks.callAll("lightingRefresh", this); } /* -------------------------------------------- */ /** * Add a vision source to the effect layers. * @param {RenderedEffectSource & PointVisionSource} source The vision source to add mesh layers */ #addVisionEffect(source) { if ( !source.active || (source.radius <= 0) ) return; const meshes = source.drawMeshes(); if ( meshes.background ) { // Is this vision source background need to be rendered into the preferred vision container, over other VS? const parent = source.preferred ? this.background.visionPreferred : this.background.vision; parent.addChild(meshes.background); } if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination); if ( meshes.coloration ) this.coloration.addChild(meshes.coloration); } /* -------------------------------------------- */ /** * Add a light source or a darkness source to the effect layers * @param {RenderedEffectSource & BaseLightSource} source The light or darkness source to add to the effect layers. */ #addLightEffect(source) { if ( !source.active ) return; const meshes = source.drawMeshes(); if ( meshes.background ) this.background.lighting.addChild(meshes.background); if ( meshes.illumination ) this.illumination.lights.addChild(meshes.illumination); if ( meshes.coloration ) this.coloration.addChild(meshes.coloration); if ( meshes.darkness ) this.darkness.addChild(meshes.darkness); } /* -------------------------------------------- */ /** * Test whether the point is inside light. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {boolean} Is inside light? */ testInsideLight(point, elevation) { // First test light source excluding the global light source for ( const lightSource of this.lightSources ) { if ( !lightSource.active || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue; if ( lightSource.shape.contains(point.x, point.y) ) return true; } // Second test Global Illumination and Darkness Level meshes const globalLightSource = canvas.environment.globalLightSource; if ( !globalLightSource.active ) return false; const {min, max} = globalLightSource.data.darkness; const darknessLevel = this.getDarknessLevel(point, elevation); return (darknessLevel >= min) && (darknessLevel <= max); } /* -------------------------------------------- */ /** * Test whether the point is inside darkness. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {boolean} Is inside a darkness? */ testInsideDarkness({x, y}, elevation) { for ( const source of this.darknessSources ) { if ( !source.active || source.isPreview ) continue; for ( let dx = -1; dx <= 1; dx += 1 ) { for ( let dy = -1; dy <= 1; dy += 1 ) { if ( source.shape.contains(x + dx, y + dy) ) return true; } } } return false; } /* -------------------------------------------- */ /** * Get the darkness level at the given point. * @param {Point} point The point. * @param {number} elevation The elevation of the point. * @returns {number} The darkness level. */ getDarknessLevel(point, elevation) { const darknessLevelMeshes = canvas.effects.illumination.darknessLevelMeshes.children; for ( let i = darknessLevelMeshes.length - 1; i >= 0; i-- ) { const darknessLevelMesh = darknessLevelMeshes[i]; if ( darknessLevelMesh.region.testPoint(point, elevation) ) { return darknessLevelMesh.shader.uniforms.darknessLevel; } } return canvas.environment.darknessLevel; } /* -------------------------------------------- */ /** @override */ async _tearDown(options) { CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME); this.deactivateAnimation(); this.darknessSources.clear(); this.lightSources.clear(); for ( const c of this.children ) { if ( c.clear ) c.clear(); else if ( c.tearDown ) await c.tearDown(); else c.destroy(); } this.visualEffectsMaskingFilters.clear(); } /* -------------------------------------------- */ /** * Activate vision masking for visual effects * @param {boolean} [enabled=true] Whether to enable or disable vision masking */ toggleMaskingFilters(enabled=true) { for ( const f of this.visualEffectsMaskingFilters ) { f.uniforms.enableVisionMasking = enabled; } } /* -------------------------------------------- */ /** * Activate post-processing effects for a certain effects channel. * @param {string} filterMode The filter mode to target. * @param {string[]} [postProcessingModes=[]] The post-processing modes to apply to this filter. * @param {Object} [uniforms={}] The uniforms to update. */ activatePostProcessingFilters(filterMode, postProcessingModes=[], uniforms={}) { for ( const f of this.visualEffectsMaskingFilters ) { if ( f.uniforms.mode === filterMode ) { f.updatePostprocessModes(postProcessingModes, uniforms); } } } /* -------------------------------------------- */ /** * Reset post-processing modes on all Visual Effects masking filters. */ resetPostProcessingFilters() { for ( const f of this.visualEffectsMaskingFilters ) { f.reset(); } } /* -------------------------------------------- */ /* Animation Management */ /* -------------------------------------------- */ /** * Activate light source animation for AmbientLight objects within this layer */ activateAnimation() { this.deactivateAnimation(); if ( game.settings.get("core", "lightAnimation") === false ) return; canvas.app.ticker.add(this.#animateSources, this); } /* -------------------------------------------- */ /** * Deactivate light source animation for AmbientLight objects within this layer */ deactivateAnimation() { canvas.app.ticker.remove(this.#animateSources, this); } /* -------------------------------------------- */ /** * The ticker handler which manages animation delegation * @param {number} dt Delta time * @private */ #animateSources(dt) { // Animate light and darkness sources if ( this.animateLightSources ) { for ( const source of this.allSources() ) { source.animate(dt); } } // Animate vision sources if ( this.animateVisionSources ) { for ( const source of this.visionSources.values() ) { source.animate(dt); } } } /* -------------------------------------------- */ /** * Animate a smooth transition of the darkness overlay to a target value. * Only begin animating if another animation is not already in progress. * @param {number} target The target darkness level between 0 and 1 * @param {number} duration The desired animation time in milliseconds. Default is 10 seconds * @returns {Promise} A Promise which resolves once the animation is complete */ async animateDarkness(target=1.0, {duration=10000}={}) { CanvasAnimation.terminateAnimation(EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME); if ( target === canvas.environment.darknessLevel ) return false; if ( duration <= 0 ) return canvas.environment.initialize({environment: {darknessLevel: target}}); // Update with an animation const animationData = [{ parent: {darkness: canvas.environment.darknessLevel}, attribute: "darkness", to: Math.clamp(target, 0, 1) }]; return CanvasAnimation.animate(animationData, { name: EffectsCanvasGroup.#DARKNESS_ANIMATION_NAME, duration: duration, ontick: (dt, animation) => canvas.environment.initialize({environment: {darknessLevel: animation.attributes[0].parent.darkness}}) }).then(completed => { if ( !completed ) canvas.environment.initialize({environment: {darknessLevel: target}}); }); } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ get visibility() { const msg = "EffectsCanvasGroup#visibility has been deprecated and moved to " + "Canvas#visibility."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.visibility; } /** * @deprecated since v12 * @ignore */ get globalLightSource() { const msg = "EffectsCanvasGroup#globalLightSource has been deprecated and moved to " + "EnvironmentCanvasGroup#globalLightSource."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.environment.globalLightSource; } /** * @deprecated since v12 * @ignore */ updateGlobalLightSource() { const msg = "EffectsCanvasGroup#updateGlobalLightSource has been deprecated and is part of " + "EnvironmentCanvasGroup#initialize workflow."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); canvas.environment.initialize(); } }