// noinspection JSPrimitiveTypeWrapperUsage /** * The visibility Layer which implements dynamic vision, lighting, and fog of war * This layer uses an event-driven workflow to perform the minimal required calculation in response to changes. * @see {@link PointSource} * * ### Hook Events * - {@link hookEvents.visibilityRefresh} * * @category - Canvas */ class CanvasVisibility extends CanvasLayer { /** * The currently revealed vision. * @type {CanvasVisionContainer} */ vision; /** * The exploration container which tracks exploration progress. * @type {PIXI.Container} */ explored; /** * The optional visibility overlay sprite that should be drawn instead of the unexplored color in the fog of war. * @type {PIXI.Sprite} */ visibilityOverlay; /** * The graphics used to render cached light sources. * @type {PIXI.LegacyGraphics} */ #cachedLights = new PIXI.LegacyGraphics(); /** * Matrix used for visibility rendering transformation. * @type {PIXI.Matrix} */ #renderTransform = new PIXI.Matrix(); /** * Dimensions of the visibility overlay texture and base texture used for tiling texture into the visibility filter. * @type {number[]} */ #visibilityOverlayDimensions; /** * The active vision source data object * @type {{source: VisionSource|null, activeLightingOptions: object}} */ visionModeData = { source: undefined, activeLightingOptions: {} }; /** * Define whether each lighting layer is enabled, required, or disabled by this vision mode. * The value for each lighting channel is a number in LIGHTING_VISIBILITY * @type {{illumination: number, background: number, coloration: number, * darkness: number, any: boolean}} */ lightingVisibility = { background: VisionMode.LIGHTING_VISIBILITY.ENABLED, illumination: VisionMode.LIGHTING_VISIBILITY.ENABLED, coloration: VisionMode.LIGHTING_VISIBILITY.ENABLED, darkness: VisionMode.LIGHTING_VISIBILITY.ENABLED, any: true }; /** * The map with the active cached light source IDs as keys and their update IDs as values. * @type {Map} */ #cachedLightSourceStates = new Map(); /** * The maximum allowable visibility texture size. * @type {number} */ static #MAXIMUM_VISIBILITY_TEXTURE_SIZE = 4096; /* -------------------------------------------- */ /* Canvas Visibility Properties */ /* -------------------------------------------- */ /** * A status flag for whether the layer initialization workflow has succeeded. * @type {boolean} */ get initialized() { return this.#initialized; } #initialized = false; /* -------------------------------------------- */ /** * Indicates whether containment filtering is required when rendering vision into a texture. * @type {boolean} * @internal */ get needsContainment() { return this.#needsContainment; } #needsContainment = false; /* -------------------------------------------- */ /** * Does the currently viewed Scene support Token field of vision? * @type {boolean} */ get tokenVision() { return canvas.scene.tokenVision; } /* -------------------------------------------- */ /** * The configured options used for the saved fog-of-war texture. * @type {FogTextureConfiguration} */ get textureConfiguration() { return this.#textureConfiguration; } /** @private */ #textureConfiguration; /* -------------------------------------------- */ /** * Optional overrides for exploration sprite dimensions. * @type {FogTextureConfiguration} */ set explorationRect(rect) { this.#explorationRect = rect; } /** @private */ #explorationRect; /* -------------------------------------------- */ /* Layer Initialization */ /* -------------------------------------------- */ /** * Initialize all Token vision sources which are present on this layer */ initializeSources() { canvas.effects.toggleMaskingFilters(false); // Deactivate vision masking before destroying textures for ( const source of canvas.effects.visionSources ) source.initialize(); Hooks.callAll("initializeVisionSources", canvas.effects.visionSources); } /* -------------------------------------------- */ /** * Initialize the vision mode. */ initializeVisionMode() { this.visionModeData.source = this.#getSingleVisionSource(); this.#configureLightingVisibility(); this.#updateLightingPostProcessing(); this.#updateTintPostProcessing(); Hooks.callAll("initializeVisionMode", this); } /* -------------------------------------------- */ /** * Identify whether there is one singular vision source active (excluding previews). * @returns {VisionSource|null} A singular source, or null */ #getSingleVisionSource() { return canvas.effects.visionSources.filter(s => s.active).sort((a, b) => (a.isPreview - b.isPreview) || (a.isBlinded - b.isBlinded) || (b.visionMode.perceivesLight - a.visionMode.perceivesLight) ).at(0) ?? null; } /* -------------------------------------------- */ /** * Configure the visibility of individual lighting channels based on the currently active vision source(s). */ #configureLightingVisibility() { const vs = this.visionModeData.source; const vm = vs?.visionMode; const lv = this.lightingVisibility; const lvs = VisionMode.LIGHTING_VISIBILITY; Object.assign(lv, { background: CanvasVisibility.#requireBackgroundShader(vm), illumination: vm?.lighting.illumination.visibility ?? lvs.ENABLED, coloration: vm?.lighting.coloration.visibility ?? lvs.ENABLED, darkness: vm?.lighting.darkness.visibility ?? lvs.ENABLED }); lv.any = (lv.background + lv.illumination + lv.coloration + lv.darkness) > VisionMode.LIGHTING_VISIBILITY.DISABLED; } /* -------------------------------------------- */ /** * Update the lighting according to vision mode options. */ #updateLightingPostProcessing() { // Check whether lighting configuration has changed const lightingOptions = this.visionModeData.source?.visionMode.lighting || {}; const diffOpt = foundry.utils.diffObject(this.visionModeData.activeLightingOptions, lightingOptions); this.visionModeData.activeLightingOptions = lightingOptions; if ( foundry.utils.isEmpty(lightingOptions) ) canvas.effects.resetPostProcessingFilters(); if ( foundry.utils.isEmpty(diffOpt) ) return; // Update post-processing filters and refresh lighting const modes = CONFIG.Canvas.visualEffectsMaskingFilter.FILTER_MODES; canvas.effects.resetPostProcessingFilters(); for ( const layer of ["background", "illumination", "coloration"] ) { if ( layer in lightingOptions ) { const options = lightingOptions[layer]; const filterMode = modes[layer.toUpperCase()]; canvas.effects.activatePostProcessingFilters(filterMode, options.postProcessingModes, options.uniforms); } } } /* -------------------------------------------- */ /** * Refresh the tint of the post processing filters. */ #updateTintPostProcessing() { // Update tint const activeOptions = this.visionModeData.activeLightingOptions; const singleSource = this.visionModeData.source; const color = singleSource?.visionModeOverrides.colorRGB; for ( const f of canvas.effects.visualEffectsMaskingFilters ) { const defaultTint = f.constructor.defaultUniforms.tint; const tintedLayer = activeOptions[f.uniforms.mode]?.uniforms?.tint; f.uniforms.tint = tintedLayer ? (color ?? (tintedLayer ?? defaultTint)) : defaultTint; } } /* -------------------------------------------- */ /** * Give the visibility requirement of the lighting background shader. * @param {VisionMode} visionMode The single Vision Mode active at the moment (if any). * @returns {VisionMode.LIGHTING_VISIBILITY} */ static #requireBackgroundShader(visionMode) { // Do we need to force lighting background shader? Force when : // - Multiple vision modes are active with a mix of preferred and non preferred visions // - Or when some have background shader required const lvs = VisionMode.LIGHTING_VISIBILITY; let preferred = false; let nonPreferred = false; for ( const vs of canvas.effects.visionSources ) { if ( !vs.active ) continue; const vm = vs.visionMode; if ( vm.lighting.background.visibility === lvs.REQUIRED ) return lvs.REQUIRED; if ( vm.vision.preferred ) preferred = true; else nonPreferred = true; } if ( preferred && nonPreferred ) return lvs.REQUIRED; return visionMode?.lighting.background.visibility ?? lvs.ENABLED; } /* -------------------------------------------- */ /* Layer Rendering */ /* -------------------------------------------- */ /** @override */ async _draw(options) { this.#configureVisibilityTexture(); // Initialize fog await canvas.fog.initialize(); // Create the vision container and attach it to the CanvasVisionMask cached container this.vision = this.#createVision(); canvas.masks.vision.attachVision(this.vision); this.#cacheLights(true); // Exploration container this.explored = this.addChild(this.#createExploration()); // Loading the fog overlay await this.#drawVisibilityOverlay(); // Apply the visibility filter with a normal blend this.filter = CONFIG.Canvas.visibilityFilter.create({ unexploredColor: canvas.colors.fogUnexplored.rgb, exploredColor: canvas.colors.fogExplored.rgb, backgroundColor: canvas.colors.background.rgb, visionTexture: canvas.masks.vision.renderTexture, primaryTexture: canvas.primary.renderTexture, overlayTexture: this.visibilityOverlay?.texture ?? null, dimensions: this.#visibilityOverlayDimensions, hasOverlayTexture: !!this.visibilityOverlay?.texture.valid }, canvas.visibilityOptions); this.filter.blendMode = PIXI.BLEND_MODES.NORMAL; this.filters = [this.filter]; this.filterArea = canvas.app.screen; // Add the visibility filter to the canvas blur filter list canvas.addBlurFilter(this.filter); this.visible = false; this.#initialized = true; } /* -------------------------------------------- */ /** * Create the exploration container with its exploration sprite. * @returns {PIXI.Container} The newly created exploration container. */ #createExploration() { const dims = canvas.dimensions; const explored = new PIXI.Container(); const explorationSprite = explored.addChild(canvas.fog.sprite); const exr = this.#explorationRect; // Check if custom exploration dimensions are required if ( exr ) { explorationSprite.position.set(exr.x, exr.y); explorationSprite.width = exr.width; explorationSprite.height = exr.height; } // Otherwise, use the standard behavior else { explorationSprite.position.set(dims.sceneX, dims.sceneY); explorationSprite.width = this.#textureConfiguration.width; explorationSprite.height = this.#textureConfiguration.height; } return explored; } /* -------------------------------------------- */ /** * Create the vision container and all its children. * @returns {PIXI.Container} The created vision container. */ #createVision() { const dims = canvas.dimensions; const vision = new PIXI.Container(); // Adding a void filter necessary when commiting fog on a texture for dynamic illumination vision.containmentFilter = VoidFilter.create(); vision.containmentFilter.blendMode = PIXI.BLEND_MODES.MAX_COLOR; vision.containmentFilter.enabled = false; // Disabled by default, used only when writing on textures vision.filters = [vision.containmentFilter]; // Areas visible because of light sources and light perception vision.light = vision.addChild(new PIXI.Container()); // The global light container, which hold darkness level meshes for dynamic illumination vision.light.global = vision.light.addChild(new PIXI.Container()); vision.light.global.source = vision.light.global.addChild(new PIXI.LegacyGraphics()); vision.light.global.meshes = vision.light.global.addChild(new PIXI.Container()); vision.light.global.source.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The light sources vision.light.sources = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.sources.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // Preview container, which is not cached vision.light.preview = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The cached light to avoid too many geometry drawings vision.light.cached = vision.light.addChild(new SpriteMesh(Canvas.getRenderTexture({ textureConfiguration: this.textureConfiguration }))); vision.light.cached.position.set(dims.sceneX, dims.sceneY); vision.light.cached.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // The masked area vision.light.mask = vision.light.addChild(new PIXI.LegacyGraphics()); vision.light.mask.preview = vision.light.mask.addChild(new PIXI.LegacyGraphics()); // Areas visible because of FOV of vision sources vision.sight = vision.addChild(new PIXI.LegacyGraphics()); vision.sight.blendMode = PIXI.BLEND_MODES.MAX_COLOR; vision.sight.preview = vision.sight.addChild(new PIXI.LegacyGraphics()); vision.sight.preview.blendMode = PIXI.BLEND_MODES.MAX_COLOR; // Eraser for darkness sources vision.darkness = vision.addChild(new PIXI.LegacyGraphics()); vision.darkness.blendMode = PIXI.BLEND_MODES.ERASE; /** @deprecated since v12 */ Object.defineProperty(vision, "base", { get() { const msg = "CanvasVisibility#vision#base is deprecated in favor of CanvasVisibility#vision#light#preview."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.fov.preview; } }); /** @deprecated since v12 */ Object.defineProperty(vision, "fov", { get() { const msg = "CanvasVisibility#vision#fov is deprecated in favor of CanvasVisibility#vision#light."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.light; } }); /** @deprecated since v12 */ Object.defineProperty(vision, "los", { get() { const msg = "CanvasVisibility#vision#los is deprecated in favor of CanvasVisibility#vision#light#mask."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.light.mask; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "lights", { get: () => { const msg = "CanvasVisibility#vision#fov#lights is deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.#cachedLights; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "lightsSprite", { get() { const msg = "CanvasVisibility#vision#fov#lightsSprite is deprecated in favor of CanvasVisibility#vision#light#cached."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this.cached; } }); /** @deprecated since v12 */ Object.defineProperty(vision.light, "tokens", { get() { const msg = "CanvasVisibility#vision#tokens is deprecated in favor of CanvasVisibility#vision#light."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14, once: true}); return this; } }); return vision; } /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { canvas.masks.vision.detachVision(); this.#cachedLightSourceStates.clear(); await canvas.fog.clear(); // Performs deep cleaning of the detached vision container this.vision.destroy({children: true, texture: true, baseTexture: true}); this.vision = undefined; canvas.effects.visionSources.clear(); this.#initialized = false; return super._tearDown(options); } /* -------------------------------------------- */ /** * Update the display of the sight layer. * Organize sources into rendering queues and draw lighting containers for each source */ refresh() { if ( !this.initialized ) return; // Refresh visibility if ( this.tokenVision ) { this.refreshVisibility(); this.visible = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM; } else this.visible = false; // Update visibility of objects this.restrictVisibility(); } /* -------------------------------------------- */ /** * Update vision (and fog if necessary) */ refreshVisibility() { canvas.masks.vision.renderDirty = true; if ( !this.vision ) return; const vision = this.vision; // Begin fills const fillColor = 0xFF0000; this.#cachedLights.beginFill(fillColor); vision.light.sources.clear().beginFill(fillColor); vision.light.preview.clear().beginFill(fillColor); vision.light.global.source.clear().beginFill(fillColor); vision.light.mask.clear().beginFill(); vision.light.mask.preview.clear().beginFill(); vision.sight.clear().beginFill(fillColor); vision.sight.preview.clear().beginFill(fillColor); vision.darkness.clear().beginFill(fillColor); // Checking if the lights cache needs a full redraw const redrawCache = this.#checkCachedLightSources(); if ( redrawCache ) this.#cachedLightSourceStates.clear(); // A flag to know if the lights cache render texture need to be refreshed let refreshCache = redrawCache; // A flag to know if fog need to be refreshed. let commitFog = false; // Iterating over each active light source for ( const [sourceId, lightSource] of canvas.effects.lightSources.entries() ) { // Ignoring inactive sources or global light (which is rendered using the global light mesh) if ( !lightSource.hasActiveLayer || (lightSource instanceof foundry.canvas.sources.GlobalLightSource) ) continue; // Is the light source providing vision? if ( lightSource.data.vision ) { if ( lightSource.isPreview ) vision.light.mask.preview.drawShape(lightSource.shape); else { vision.light.mask.drawShape(lightSource.shape); commitFog = true; } } // Update the cached state. Skip if already cached. const isCached = this.#shouldCacheLight(lightSource); if ( isCached ) { if ( this.#cachedLightSourceStates.has(sourceId) ) continue; this.#cachedLightSourceStates.set(sourceId, lightSource.updateId); refreshCache = true; } // Draw the light source if ( isCached ) this.#cachedLights.drawShape(lightSource.shape); else if ( lightSource.isPreview ) vision.light.preview.drawShape(lightSource.shape); else vision.light.sources.drawShape(lightSource.shape); } // Refresh the light source cache if necessary. // Note: With a full redraw, we need to refresh the texture cache, even if no elements are present if ( refreshCache ) this.#cacheLights(redrawCache); // Refresh global/dynamic illumination with global source and illumination meshes this.#refreshDynamicIllumination(); // Iterating over each active vision source for ( const visionSource of canvas.effects.visionSources ) { if ( !visionSource.hasActiveLayer ) continue; const blinded = visionSource.isBlinded; // Draw vision FOV if ( (visionSource.radius > 0) && !blinded && !visionSource.isPreview ) { vision.sight.drawShape(visionSource.shape); commitFog = true; } else vision.sight.preview.drawShape(visionSource.shape); // Draw light perception if ( (visionSource.lightRadius > 0) && !blinded && !visionSource.isPreview ) { vision.light.mask.drawShape(visionSource.light); commitFog = true; } else vision.light.mask.preview.drawShape(visionSource.light); } // Call visibility refresh hook Hooks.callAll("visibilityRefresh", this); // End fills vision.light.sources.endFill(); vision.light.preview.endFill(); vision.light.global.source.endFill(); vision.light.mask.endFill(); vision.light.mask.preview.endFill(); vision.sight.endFill(); vision.sight.preview.endFill(); vision.darkness.endFill(); // Update fog of war texture (if fow is activated) if ( commitFog ) canvas.fog.commit(); } /* -------------------------------------------- */ /** * Reset the exploration container with the fog sprite */ resetExploration() { if ( !this.explored ) return; this.explored.destroy(); this.explored = this.addChild(this.#createExploration()); } /* -------------------------------------------- */ /** * Refresh the dynamic illumination with darkness level meshes and global light. * Tell if a fence filter is needed when vision is rendered into a texture. */ #refreshDynamicIllumination() { // Reset filter containment this.#needsContainment = false; // Setting global light source container visibility const globalLightSource = canvas.environment.globalLightSource; const v = this.vision.light.global.visible = globalLightSource.active; if ( !v ) return; const {min, max} = globalLightSource.data.darkness; // Draw the global source if necessary const darknessLevel = canvas.environment.darknessLevel; if ( (darknessLevel >= min) && (darknessLevel <= max) ) { this.vision.light.global.source.drawShape(globalLightSource.shape); } // Then draw dynamic illumination meshes const illuminationMeshes = this.vision.light.global.meshes.children; for ( const mesh of illuminationMeshes ) { const darknessLevel = mesh.shader.darknessLevel; if ( (darknessLevel < min) || (darknessLevel > max)) { mesh.blendMode = PIXI.BLEND_MODES.ERASE; this.#needsContainment = true; } else mesh.blendMode = PIXI.BLEND_MODES.MAX_COLOR; } } /* -------------------------------------------- */ /** * Returns true if the light source should be cached. * @param {LightSource} lightSource The light source * @returns {boolean} */ #shouldCacheLight(lightSource) { return !(lightSource.object instanceof Token) && !lightSource.isPreview; } /* -------------------------------------------- */ /** * Check if the cached light sources need to be fully redrawn. * @returns {boolean} True if a full redraw is necessary. */ #checkCachedLightSources() { for ( const [sourceId, updateId] of this.#cachedLightSourceStates ) { const lightSource = canvas.effects.lightSources.get(sourceId); if ( !lightSource || !lightSource.active || !this.#shouldCacheLight(lightSource) || (updateId !== lightSource.updateId) ) return true; } return false; } /* -------------------------------------------- */ /** * Render `this.#cachedLights` into `this.vision.light.cached.texture`. * Note: A full cache redraw needs the texture to be cleared. * @param {boolean} clearTexture If the texture need to be cleared before rendering. */ #cacheLights(clearTexture) { const dims = canvas.dimensions; this.#renderTransform.tx = -dims.sceneX; this.#renderTransform.ty = -dims.sceneY; this.#cachedLights.blendMode = PIXI.BLEND_MODES.MAX_COLOR; canvas.app.renderer.render(this.#cachedLights, { renderTexture: this.vision.light.cached.texture, clear: clearTexture, transform: this.#renderTransform }); this.#cachedLights.clear(); } /* -------------------------------------------- */ /* Visibility Testing */ /* -------------------------------------------- */ /** * Restrict the visibility of certain canvas assets (like Tokens or DoorControls) based on the visibility polygon * These assets should only be displayed if they are visible given the current player's field of view */ restrictVisibility() { // Activate or deactivate visual effects vision masking canvas.effects.toggleMaskingFilters(this.visible); // Tokens & Notes const flags = {refreshVisibility: true}; for ( const token of canvas.tokens.placeables ) token.renderFlags.set(flags); for ( const note of canvas.notes.placeables ) note.renderFlags.set(flags); // Door Icons for ( const door of canvas.controls.doors.children ) door.visible = door.isVisible; Hooks.callAll("sightRefresh", this); } /* -------------------------------------------- */ /** * @typedef {Object} CanvasVisibilityTestConfig * @property {object|null} object The target object * @property {CanvasVisibilityTest[]} tests An array of visibility tests */ /** * @typedef {Object} CanvasVisibilityTest * @property {Point} point * @property {number} elevation * @property {Map} los */ /** * Test whether a target point on the Canvas is visible based on the current vision and LOS polygons. * @param {Point} point The point in space to test, an object with coordinates x and y. * @param {object} [options] Additional options which modify visibility testing. * @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match. * For example, if tolerance is 2 then the test will pass if the point * is within 2px of a vision polygon. * @param {object|null} [options.object] An optional reference to the object whose visibility is being tested * @returns {boolean} Whether the point is currently visible. */ testVisibility(point, options={}) { // If no vision sources are present, the visibility is dependant of the type of user if ( !canvas.effects.visionSources.some(s => s.active) ) return game.user.isGM; // Prepare an array of test points depending on the requested tolerance const object = options.object ?? null; const config = this._createVisibilityTestConfig(point, options); // First test basic detection for light sources which specifically provide vision for ( const lightSource of canvas.effects.lightSources ) { if ( !lightSource.data.vision || !lightSource.active ) continue; const result = lightSource.testVisibility(config); if ( result === true ) return true; } // Get scene rect to test that some points are not detected into the padding const sr = canvas.dimensions.sceneRect; const inBuffer = !sr.contains(point.x, point.y); // Skip sources that are not both inside the scene or both inside the buffer const activeVisionSources = canvas.effects.visionSources.filter(s => s.active && (inBuffer !== sr.contains(s.x, s.y))); const modes = CONFIG.Canvas.detectionModes; // Second test Basic Sight and Light Perception tests for vision sources for ( const visionSource of activeVisionSources ) { if ( visionSource.isBlinded ) continue; const token = visionSource.object.document; const basicMode = token.detectionModes.find(m => m.id === "basicSight"); if ( basicMode ) { const result = modes.basicSight.testVisibility(visionSource, basicMode, config); if ( result === true ) return true; } const lightMode = token.detectionModes.find(m => m.id === "lightPerception"); if ( lightMode ) { const result = modes.lightPerception.testVisibility(visionSource, lightMode, config); if ( result === true ) return true; } } // Special detection modes can only detect tokens if ( !(object instanceof Token) ) return false; // Lastly test special detection modes for vision sources for ( const visionSource of activeVisionSources ) { const token = visionSource.object.document; for ( const mode of token.detectionModes ) { if ( (mode.id === "basicSight") || (mode.id === "lightPerception") ) continue; const dm = modes[mode.id]; const result = dm?.testVisibility(visionSource, mode, config); if ( result === true ) { object.detectionFilter = dm.constructor.getDetectionFilter(); return true; } } } return false; } /* -------------------------------------------- */ /** * Create the visibility test config. * @param {Point} point The point in space to test, an object with coordinates x and y. * @param {object} [options] Additional options which modify visibility testing. * @param {number} [options.tolerance=2] A numeric radial offset which allows for a non-exact match. * For example, if tolerance is 2 then the test will pass if the point * is within 2px of a vision polygon. * @param {object|null} [options.object] An optional reference to the object whose visibility is being tested * @returns {CanvasVisibilityTestConfig} * @internal */ _createVisibilityTestConfig(point, {tolerance=2, object=null}={}) { const t = tolerance; const offsets = t > 0 ? [[0, 0], [-t, -t], [-t, t], [t, t], [t, -t], [-t, 0], [t, 0], [0, -t], [0, t]] : [[0, 0]]; const elevation = object instanceof Token ? object.document.elevation : 0; return { object, tests: offsets.map(o => ({ point: {x: point.x + o[0], y: point.y + o[1]}, elevation, los: new Map() })) }; } /* -------------------------------------------- */ /* Visibility Overlay and Texture management */ /* -------------------------------------------- */ /** * Load the scene fog overlay if provided and attach the fog overlay sprite to this layer. */ async #drawVisibilityOverlay() { this.visibilityOverlay = undefined; this.#visibilityOverlayDimensions = []; const overlaySrc = canvas.sceneTextures.fogOverlay ?? canvas.scene.fog.overlay; const overlayTexture = overlaySrc instanceof PIXI.Texture ? overlaySrc : getTexture(overlaySrc); if ( !overlayTexture ) return; // Creating the sprite and updating its base texture with repeating wrap mode const fo = this.visibilityOverlay = new PIXI.Sprite(overlayTexture); // Set dimensions and position according to overlay <-> scene foreground dimensions const bkg = canvas.primary.background; const baseTex = overlayTexture.baseTexture; if ( bkg && ((fo.width !== bkg.width) || (fo.height !== bkg.height)) ) { // Set to the size of the scene dimensions fo.width = canvas.scene.dimensions.width; fo.height = canvas.scene.dimensions.height; fo.position.set(0, 0); // Activate repeat wrap mode for this base texture (to allow tiling) baseTex.wrapMode = PIXI.WRAP_MODES.REPEAT; } else { // Set the same position and size as the scene primary background fo.width = bkg.width; fo.height = bkg.height; fo.position.set(bkg.x, bkg.y); } // The overlay is added to this canvas container to update its transforms only fo.renderable = false; this.addChild(this.visibilityOverlay); // Manage video playback const video = game.video.getVideoSource(overlayTexture); if ( video ) { const playOptions = {volume: 0}; game.video.play(video, playOptions); } // Passing overlay and base texture width and height for shader tiling calculations this.#visibilityOverlayDimensions = [fo.width, fo.height, baseTex.width, baseTex.height]; } /* -------------------------------------------- */ /** * @typedef {object} VisibilityTextureConfiguration * @property {number} resolution * @property {number} width * @property {number} height * @property {number} mipmap * @property {number} scaleMode * @property {number} multisample */ /** * Configure the fog texture will all required options. * Choose an adaptive fog rendering resolution which downscales the saved fog textures for larger dimension Scenes. * It is important that the width and height of the fog texture is evenly divisible by the downscaling resolution. * @returns {VisibilityTextureConfiguration} * @private */ #configureVisibilityTexture() { const dims = canvas.dimensions; let width = dims.sceneWidth; let height = dims.sceneHeight; const maxSize = CanvasVisibility.#MAXIMUM_VISIBILITY_TEXTURE_SIZE; // Adapt the fog texture resolution relative to some maximum size, and ensure that multiplying the scene dimensions // by the resolution results in an integer number in order to avoid fog drift. let resolution = 1.0; if ( (width >= height) && (width > maxSize) ) { resolution = maxSize / width; height = Math.ceil(height * resolution) / resolution; } else if ( height > maxSize ) { resolution = maxSize / height; width = Math.ceil(width * resolution) / resolution; } // Determine the fog texture options return this.#textureConfiguration = { resolution, width, height, mipmap: PIXI.MIPMAP_MODES.OFF, multisample: PIXI.MSAA_QUALITY.NONE, scaleMode: PIXI.SCALE_MODES.LINEAR, alphaMode: PIXI.ALPHA_MODES.NPM, format: PIXI.FORMATS.RED }; } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ get fogOverlay() { const msg = "fogOverlay is deprecated in favor of visibilityOverlay"; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return this.visibilityOverlay; } }