/** * The primary Canvas group which generally contains tangible physical objects which exist within the Scene. * This group is a {@link CachedContainer} which is rendered to the Scene as a {@link SpriteMesh}. * This allows the rendered result of the Primary Canvas Group to be affected by a {@link BaseSamplerShader}. * @extends {CachedContainer} * @mixes CanvasGroupMixin * @category - Canvas */ class PrimaryCanvasGroup extends CanvasGroupMixin(CachedContainer) { constructor(sprite) { sprite ||= new SpriteMesh(undefined, BaseSamplerShader); super(sprite); this.eventMode = "none"; this.#createAmbienceFilter(); this.on("childAdded", this.#onChildAdded); this.on("childRemoved", this.#onChildRemoved); } /** * Sort order to break ties on the group/layer level. * @enum {number} */ static SORT_LAYERS = Object.freeze({ SCENE: 0, TILES: 500, DRAWINGS: 600, TOKENS: 700, WEATHER: 1000 }); /** @override */ static groupName = "primary"; /** @override */ static textureConfiguration = { scaleMode: PIXI.SCALE_MODES.NEAREST, format: PIXI.FORMATS.RGB, multisample: PIXI.MSAA_QUALITY.NONE }; /** @override */ clearColor = [0, 0, 0, 0]; /** * The background color in RGB. * @type {[red: number, green: number, blue: number]} * @internal */ _backgroundColor; /** * Track the set of HTMLVideoElements which are currently playing as part of this group. * @type {Set} */ videoMeshes = new Set(); /** * Occludable objects above this elevation are faded on hover. * @type {number} */ hoverFadeElevation = 0; /** * Allow API users to override the default elevation of the background layer. * This is a temporary solution until more formal support for scene levels is added in a future release. * @type {number} */ static BACKGROUND_ELEVATION = 0; /* -------------------------------------------- */ /* Group Attributes */ /* -------------------------------------------- */ /** * The primary background image configured for the Scene, rendered as a SpriteMesh. * @type {SpriteMesh} */ background; /** * The primary foreground image configured for the Scene, rendered as a SpriteMesh. * @type {SpriteMesh} */ foreground; /** * A Quadtree which partitions and organizes primary canvas objects. * @type {CanvasQuadtree} */ quadtree = new CanvasQuadtree(); /** * The collection of PrimaryDrawingContainer objects which are rendered in the Scene. * @type {Collection} */ drawings = new foundry.utils.Collection(); /** * The collection of SpriteMesh objects which are rendered in the Scene. * @type {Collection} */ tokens = new foundry.utils.Collection(); /** * The collection of SpriteMesh objects which are rendered in the Scene. * @type {Collection} */ tiles = new foundry.utils.Collection(); /** * The ambience filter which is applying post-processing effects. * @type {PrimaryCanvasGroupAmbienceFilter} * @internal */ _ambienceFilter; /** * The objects that are currently hovered in reverse sort order. * @type {PrimaryCanvasObjec[]>} */ #hoveredObjects = []; /** * Trace the tiling sprite error to avoid multiple warning. * FIXME: Remove when the deprecation period for the tiling sprite error is over. * @type {boolean} * @internal */ #tilingSpriteError = false; /* -------------------------------------------- */ /* Group Properties */ /* -------------------------------------------- */ /** * Return the base HTML image or video element which provides the background texture. * @type {HTMLImageElement|HTMLVideoElement} */ get backgroundSource() { if ( !this.background.texture.valid || this.background.texture === PIXI.Texture.WHITE ) return null; return this.background.texture.baseTexture.resource.source; } /* -------------------------------------------- */ /** * Return the base HTML image or video element which provides the foreground texture. * @type {HTMLImageElement|HTMLVideoElement} */ get foregroundSource() { if ( !this.foreground.texture.valid ) return null; return this.foreground.texture.baseTexture.resource.source; } /* -------------------------------------------- */ /* Rendering */ /* -------------------------------------------- */ /** * Create the ambience filter bound to the primary group. */ #createAmbienceFilter() { if ( this._ambienceFilter ) this._ambienceFilter.enabled = false; else { this.filters ??= []; const f = this._ambienceFilter = PrimaryCanvasGroupAmbienceFilter.create(); f.enabled = false; this.filterArea = canvas.app.renderer.screen; this.filters.push(f); } } /* -------------------------------------------- */ /** * Refresh the primary mesh. */ refreshPrimarySpriteMesh() { const singleSource = canvas.visibility.visionModeData.source; const vmOptions = singleSource?.visionMode.canvas; const isBaseSampler = (this.sprite.shader.constructor === BaseSamplerShader); if ( !vmOptions && isBaseSampler ) return; // Update the primary sprite shader class (or reset to BaseSamplerShader) this.sprite.setShaderClass(vmOptions?.shader ?? BaseSamplerShader); this.sprite.shader.uniforms.sampler = this.renderTexture; // Need to update uniforms? if ( !vmOptions?.uniforms ) return; vmOptions.uniforms.linkedToDarknessLevel = singleSource?.visionMode.vision.darkness.adaptive; vmOptions.uniforms.darknessLevel = canvas.environment.darknessLevel; vmOptions.uniforms.darknessLevelTexture = canvas.effects.illumination.renderTexture; vmOptions.uniforms.screenDimensions = canvas.screenDimensions; // Assigning color from source if any vmOptions.uniforms.tint = singleSource?.visionModeOverrides.colorRGB ?? this.sprite.shader.constructor.defaultUniforms.tint; // Updating uniforms in the primary sprite shader for ( const [uniform, value] of Object.entries(vmOptions?.uniforms ?? {}) ) { if ( uniform in this.sprite.shader.uniforms ) this.sprite.shader.uniforms[uniform] = value; } } /* -------------------------------------------- */ /** * Update this group. Calculates the canvas transform and bounds of all its children and updates the quadtree. */ update() { if ( this.sortDirty ) this.sortChildren(); const children = this.children; for ( let i = 0, n = children.length; i < n; i++ ) { children[i].updateCanvasTransform?.(); } canvas.masks.depth._update(); if ( !CONFIG.debug.canvas.primary.bounds ) return; const dbg = canvas.controls.debug.clear().lineStyle(5, 0x30FF00); for ( const child of this.children ) { if ( child.canvasBounds ) dbg.drawShape(child.canvasBounds); } } /* -------------------------------------------- */ /** @inheritDoc */ async _draw(options) { this.#drawBackground(); this.#drawForeground(); this.#drawPadding(); this.hoverFadeElevation = 0; await super._draw(options); } /* -------------------------------------------- */ /** @inheritDoc */ _render(renderer) { const [r, g, b] = this._backgroundColor; renderer.framebuffer.clear(r, g, b, 1, PIXI.BUFFER_BITS.COLOR); super._render(renderer); } /* -------------------------------------------- */ /** * Draw the Scene background image. */ #drawBackground() { const bg = this.background = this.addChild(new PrimarySpriteMesh({name: "background", object: this})); bg.elevation = this.constructor.BACKGROUND_ELEVATION; const bgTextureSrc = canvas.sceneTextures.background ?? canvas.scene.background.src; const bgTexture = bgTextureSrc instanceof PIXI.Texture ? bgTextureSrc : getTexture(bgTextureSrc); this.#drawSceneMesh(bg, bgTexture); } /* -------------------------------------------- */ /** * Draw the Scene foreground image. */ #drawForeground() { const fg = this.foreground = this.addChild(new PrimarySpriteMesh({name: "foreground", object: this})); fg.elevation = canvas.scene.foregroundElevation; const fgTextureSrc = canvas.sceneTextures.foreground ?? canvas.scene.foreground; const fgTexture = fgTextureSrc instanceof PIXI.Texture ? fgTextureSrc : getTexture(fgTextureSrc); // Compare dimensions with background texture and draw the mesh const bg = this.background.texture; if ( fgTexture && bg && ((fgTexture.width !== bg.width) || (fgTexture.height !== bg.height)) ) { ui.notifications.warn("WARNING.ForegroundDimensionsMismatch", {localize: true}); } this.#drawSceneMesh(fg, fgTexture); } /* -------------------------------------------- */ /** * Draw a PrimarySpriteMesh that fills the entire Scene rectangle. * @param {PrimarySpriteMesh} mesh The target PrimarySpriteMesh * @param {PIXI.Texture|null} texture The loaded Texture or null */ #drawSceneMesh(mesh, texture) { const d = canvas.dimensions; mesh.texture = texture ?? PIXI.Texture.EMPTY; mesh.textureAlphaThreshold = 0.75; mesh.occludedAlpha = 0.5; mesh.visible = mesh.texture !== PIXI.Texture.EMPTY; mesh.position.set(d.sceneX, d.sceneY); mesh.width = d.sceneWidth; mesh.height = d.sceneHeight; mesh.sortLayer = PrimaryCanvasGroup.SORT_LAYERS.SCENE; mesh.zIndex = -Infinity; mesh.hoverFade = false; // Manage video playback const video = game.video.getVideoSource(mesh); if ( video ) { this.videoMeshes.add(mesh); game.video.play(video, {volume: game.settings.get("core", "globalAmbientVolume")}); } } /* -------------------------------------------- */ /** * Draw the Scene padding. */ #drawPadding() { const d = canvas.dimensions; const g = this.addChild(new PIXI.LegacyGraphics()); g.beginFill(0x000000, 0.025) .drawShape(d.rect) .beginHole() .drawShape(d.sceneRect) .endHole() .endFill(); g.elevation = -Infinity; g.sort = -Infinity; } /* -------------------------------------------- */ /* Tear-Down */ /* -------------------------------------------- */ /** @inheritDoc */ async _tearDown(options) { // Stop video playback for ( const mesh of this.videoMeshes ) game.video.stop(mesh.sourceElement); await super._tearDown(options); // Clear collections this.videoMeshes.clear(); this.tokens.clear(); this.tiles.clear(); // Clear the quadtree this.quadtree.clear(); // Reset the tiling sprite tracker this.#tilingSpriteError = false; } /* -------------------------------------------- */ /* Token Management */ /* -------------------------------------------- */ /** * Draw the SpriteMesh for a specific Token object. * @param {Token} token The Token being added * @returns {PrimarySpriteMesh} The added PrimarySpriteMesh */ addToken(token) { const name = token.objectId; // Create the token mesh const mesh = this.tokens.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: token})); mesh.texture = token.texture ?? PIXI.Texture.EMPTY; this.tokens.set(name, mesh); if ( mesh.isVideo ) this.videoMeshes.add(mesh); return mesh; } /* -------------------------------------------- */ /** * Remove a TokenMesh from the group. * @param {Token} token The Token being removed */ removeToken(token) { const name = token.objectId; const mesh = this.tokens.get(name); if ( mesh?.destroyed === false ) mesh.destroy({children: true}); this.tokens.delete(name); this.videoMeshes.delete(mesh); } /* -------------------------------------------- */ /* Tile Management */ /* -------------------------------------------- */ /** * Draw the SpriteMesh for a specific Token object. * @param {Tile} tile The Tile being added * @returns {PrimarySpriteMesh} The added PrimarySpriteMesh */ addTile(tile) { /** @deprecated since v12 */ if ( !this.#tilingSpriteError && tile.document.getFlag("core", "isTilingSprite") ) { this.#tilingSpriteError = true; ui.notifications.warn("WARNING.TilingSpriteDeprecation", {localize: true, permanent: true}); const msg = "Tiling Sprites are deprecated without replacement."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); } const name = tile.objectId; let mesh = this.tiles.get(name) ?? this.addChild(new PrimarySpriteMesh({name, object: tile})); mesh.texture = tile.texture ?? PIXI.Texture.EMPTY; this.tiles.set(name, mesh); if ( mesh.isVideo ) this.videoMeshes.add(mesh); return mesh; } /* -------------------------------------------- */ /** * Remove a TokenMesh from the group. * @param {Tile} tile The Tile being removed */ removeTile(tile) { const name = tile.objectId; const mesh = this.tiles.get(name); if ( mesh?.destroyed === false ) mesh.destroy({children: true}); this.tiles.delete(name); this.videoMeshes.delete(mesh); } /* -------------------------------------------- */ /* Drawing Management */ /* -------------------------------------------- */ /** * Add a PrimaryGraphics to the group. * @param {Drawing} drawing The Drawing being added * @returns {PrimaryGraphics} The created PrimaryGraphics instance */ addDrawing(drawing) { const name = drawing.objectId; const shape = this.drawings.get(name) ?? this.addChild(new PrimaryGraphics({name, object: drawing})); this.drawings.set(name, shape); return shape; } /* -------------------------------------------- */ /** * Remove a PrimaryGraphics from the group. * @param {Drawing} drawing The Drawing being removed */ removeDrawing(drawing) { const name = drawing.objectId; if ( !this.drawings.has(name) ) return; const shape = this.drawings.get(name); if ( shape?.destroyed === false ) shape.destroy({children: true}); this.drawings.delete(name); } /* -------------------------------------------- */ /** * Override the default PIXI.Container behavior for how objects in this container are sorted. * @override */ sortChildren() { const children = this.children; for ( let i = 0, n = children.length; i < n; i++ ) children[i]._lastSortedIndex = i; children.sort(PrimaryCanvasGroup.#compareObjects); this.sortDirty = false; } /* -------------------------------------------- */ /** * The sorting function used to order objects inside the Primary Canvas Group. * Overrides the default sorting function defined for the PIXI.Container. * Sort Tokens PCO above other objects except WeatherEffects, then Drawings PCO, all else held equal. * @param {PrimaryCanvasObject|PIXI.DisplayObject} a An object to display * @param {PrimaryCanvasObject|PIXI.DisplayObject} b Some other object to display * @returns {number} */ static #compareObjects(a, b) { return ((a.elevation || 0) - (b.elevation || 0)) || ((a.sortLayer || 0) - (b.sortLayer || 0)) || ((a.sort || 0) - (b.sort || 0)) || (a.zIndex - b.zIndex) || (a._lastSortedIndex - b._lastSortedIndex); } /* -------------------------------------------- */ /* PIXI Events */ /* -------------------------------------------- */ /** * Called when a child is added. * @param {PIXI.DisplayObject} child */ #onChildAdded(child) { if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true; } /* -------------------------------------------- */ /** * Called when a child is removed. * @param {PIXI.DisplayObject} child */ #onChildRemoved(child) { if ( child.shouldRenderDepth ) canvas.masks.depth._elevationDirty = true; } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** * Handle mousemove events on the primary group to update the hovered state of its children. * @internal */ _onMouseMove() { const time = canvas.app.ticker.lastTime; // Unset the hovered state of the hovered PCOs for ( const object of this.#hoveredObjects ) { if ( !object._hoverFadeState.hovered ) continue; object._hoverFadeState.hovered = false; object._hoverFadeState.hoveredTime = time; } this.#updateHoveredObjects(); // Set the hovered state of the hovered PCOs for ( const object of this.#hoveredObjects ) { if ( !object.hoverFade || !(object.elevation > this.hoverFadeElevation) ) break; object._hoverFadeState.hovered = true; object._hoverFadeState.hoveredTime = time; } } /* -------------------------------------------- */ /** * Update the hovered objects. Returns the hovered objects. */ #updateHoveredObjects() { this.#hoveredObjects.length = 0; // Get all PCOs that contain the mouse position const position = canvas.mousePosition; const collisionTest = ({t}) => t.visible && t.renderable && t._hoverFadeState && t.containsCanvasPoint(position); for ( const object of canvas.primary.quadtree.getObjects( new PIXI.Rectangle(position.x, position.y, 0, 0), {collisionTest} )) { this.#hoveredObjects.push(object); } // Sort the hovered PCOs in reverse primary order this.#hoveredObjects.sort((a, b) => PrimaryCanvasGroup.#compareObjects(b, a)); // Discard hit objects below the hovered placeable const hoveredPlaceable = canvas.activeLayer?.hover; if ( hoveredPlaceable ) { let elevation = 0; let sortLayer = Infinity; let sort = Infinity; let zIndex = Infinity; if ( (hoveredPlaceable instanceof Token) || (hoveredPlaceable instanceof Tile) ) { const mesh = hoveredPlaceable.mesh; if ( mesh ) { elevation = mesh.elevation; sortLayer = mesh.sortLayer; sort = mesh.sort; zIndex = mesh.zIndex; } } else if ( hoveredPlaceable instanceof Drawing ) { const shape = hoveredPlaceable.shape; if ( shape ) { elevation = shape.elevation; sortLayer = shape.sortLayer; sort = shape.sort; zIndex = shape.zIndex; } } else if ( hoveredPlaceable.document.schema.has("elevation") ) { elevation = hoveredPlaceable.document.elevation; } const threshold = {elevation, sortLayer, sort, zIndex, _lastSortedIndex: Infinity}; while ( this.#hoveredObjects.length && PrimaryCanvasGroup.#compareObjects(this.#hoveredObjects.at(-1), threshold) <= 0 ) { this.#hoveredObjects.pop(); } } } /* -------------------------------------------- */ /* Deprecations and Compatibility */ /* -------------------------------------------- */ /** * @deprecated since v12 * @ignore */ mapElevationToDepth(elevation) { const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. " + "Use canvas.masks.depth.mapElevation(elevation) instead."; foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14}); return canvas.masks.depth.mapElevation(elevation); } /* -------------------------------------------- */ /** * @deprecated since v11 * @ignore */ mapElevationAlpha(elevation) { const msg = "PrimaryCanvasGroup#mapElevationAlpha is deprecated. " + "Use canvas.masks.depth.mapElevation(elevation) instead."; foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13}); return canvas.masks.depth.mapElevation(elevation); } }