This commit is contained in:
2025-01-04 00:34:03 +01:00
parent 41829408dc
commit 0ca14bbc19
18111 changed files with 1871397 additions and 0 deletions

View File

@@ -0,0 +1,81 @@
/**
* A layer of background alteration effects which change the appearance of the primary group render texture.
* @category - Canvas
*/
class CanvasBackgroundAlterationEffects extends CanvasLayer {
constructor() {
super();
/**
* A collection of effects which provide background vision alterations.
* @type {PIXI.Container}
*/
this.vision = this.addChild(new PIXI.Container());
this.vision.sortableChildren = true;
/**
* A collection of effects which provide background preferred vision alterations.
* @type {PIXI.Container}
*/
this.visionPreferred = this.addChild(new PIXI.Container());
this.visionPreferred.sortableChildren = true;
/**
* A collection of effects which provide other background alterations.
* @type {PIXI.Container}
*/
this.lighting = this.addChild(new PIXI.Container());
this.lighting.sortableChildren = true;
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
// Add the background vision filter
const vf = this.vision.filter = new VoidFilter();
vf.blendMode = PIXI.BLEND_MODES.NORMAL;
vf.enabled = false;
this.vision.filters = [vf];
this.vision.filterArea = canvas.app.renderer.screen;
// Add the background preferred vision filter
const vpf = this.visionPreferred.filter = new VoidFilter();
vpf.blendMode = PIXI.BLEND_MODES.NORMAL;
vpf.enabled = false;
this.visionPreferred.filters = [vpf];
this.visionPreferred.filterArea = canvas.app.renderer.screen;
// Add the background lighting filter
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
const lf = this.lighting.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.BACKGROUND
});
lf.blendMode = PIXI.BLEND_MODES.NORMAL;
this.lighting.filters = [lf];
this.lighting.filterArea = canvas.app.renderer.screen;
canvas.effects.visualEffectsMaskingFilters.add(lf);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.lighting?.filter);
this.clear();
}
/* -------------------------------------------- */
/**
* Clear background alteration effects vision and lighting containers
*/
clear() {
this.vision.removeChildren();
this.visionPreferred.removeChildren();
this.lighting.removeChildren();
}
}

View File

@@ -0,0 +1,59 @@
/**
* A CanvasLayer for displaying coloration visual effects
* @category - Canvas
*/
class CanvasColorationEffects extends CanvasLayer {
constructor() {
super();
this.sortableChildren = true;
this.#background = this.addChild(new PIXI.LegacyGraphics());
this.#background.zIndex = -Infinity;
}
/**
* Temporary solution for the "white scene" bug (foundryvtt/foundryvtt#9957).
* @type {PIXI.LegacyGraphics}
*/
#background;
/**
* The filter used to mask visual effects on this layer
* @type {VisualEffectsMaskingFilter}
*/
filter;
/* -------------------------------------------- */
/**
* Clear coloration effects container
*/
clear() {
this.removeChildren();
this.addChild(this.#background);
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
this.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.COLORATION
});
this.filter.blendMode = PIXI.BLEND_MODES.ADD;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
canvas.effects.visualEffectsMaskingFilters.add(this.filter);
this.#background.clear().beginFill().drawShape(canvas.dimensions.rect).endFill();
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
this.#background.clear();
}
}

View File

@@ -0,0 +1,29 @@
/**
* A layer of background alteration effects which change the appearance of the primary group render texture.
* @category - Canvas
*/
class CanvasDarknessEffects extends CanvasLayer {
constructor() {
super();
this.sortableChildren = true;
}
/* -------------------------------------------- */
/**
* Clear coloration effects container
*/
clear() {
this.removeChildren();
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
this.filter = VoidFilter.create();
this.filter.blendMode = PIXI.BLEND_MODES.NORMAL;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
}
}

View File

@@ -0,0 +1,253 @@
/**
* A CanvasLayer for displaying illumination visual effects
* @category - Canvas
*/
class CanvasIlluminationEffects extends CanvasLayer {
constructor() {
super();
this.#initialize();
}
/**
* The filter used to mask visual effects on this layer
* @type {VisualEffectsMaskingFilter}
*/
filter;
/**
* The container holding the lights.
* @type {PIXI.Container}
*/
lights = new PIXI.Container();
/**
* A minimalist texture that holds the background color.
* @type {PIXI.Texture}
*/
backgroundColorTexture;
/**
* The background color rgb array.
* @type {number[]}
*/
#backgroundColorRGB;
/**
* The base line mesh.
* @type {SpriteMesh}
*/
baselineMesh = new SpriteMesh();
/**
* The cached container holding the illumination meshes.
* @type {CachedContainer}
*/
darknessLevelMeshes = new DarknessLevelContainer();
/* -------------------------------------------- */
/**
* To know if dynamic darkness level is active on this scene.
* @returns {boolean}
*/
get hasDynamicDarknessLevel() {
return this.darknessLevelMeshes.children.length > 0;
}
/**
* The illumination render texture.
* @returns {PIXI.RenderTexture}
*/
get renderTexture() {
return this.darknessLevelMeshes.renderTexture;
}
/* -------------------------------------------- */
/**
* Initialize the layer.
*/
#initialize() {
// Configure background color texture
this.backgroundColorTexture = this._createBackgroundColorTexture();
// Configure the base line mesh
this.baselineMesh.setShaderClass(BaselineIlluminationSamplerShader);
this.baselineMesh.texture = this.darknessLevelMeshes.renderTexture;
// Add children
canvas.masks.addChild(this.darknessLevelMeshes); // Region meshes cached container
this.addChild(this.lights); // Light and vision illumination
// Add baseline rendering for light
const originalRender = this.lights.render;
const baseMesh = this.baselineMesh;
this.lights.render = renderer => {
baseMesh.render(renderer);
originalRender.call(this.lights, renderer);
};
// Configure
this.lights.sortableChildren = true;
}
/* -------------------------------------------- */
/**
* Set or retrieve the illumination background color.
* @param {number} color
*/
set backgroundColor(color) {
const cb = this.#backgroundColorRGB = Color.from(color).rgb;
if ( this.filter ) this.filter.uniforms.replacementColor = cb;
this.backgroundColorTexture.baseTexture.resource.data.set(cb);
this.backgroundColorTexture.baseTexture.resource.update();
}
/* -------------------------------------------- */
/**
* Clear illumination effects container
*/
clear() {
this.lights.removeChildren();
}
/* -------------------------------------------- */
/**
* Invalidate the cached container state to trigger a render pass.
* @param {boolean} [force=false] Force cached container invalidation?
*/
invalidateDarknessLevelContainer(force=false) {
// If global light is enabled, the darkness level texture is affecting the vision mask
if ( canvas.environment.globalLightSource.active ) canvas.masks.vision.renderDirty = true;
if ( !(this.hasDynamicDarknessLevel || force) ) return;
this.darknessLevelMeshes.renderDirty = true;
// Sort by adjusted darkness level in descending order such that the final darkness level
// at a point is the minimum of the adjusted darkness levels
const compare = (a, b) => b.shader.darknessLevel - a.shader.darknessLevel;
this.darknessLevelMeshes.children.sort(compare);
canvas.visibility.vision.light.global.meshes.children.sort(compare);
}
/* -------------------------------------------- */
/**
* Create the background color texture used by illumination point source meshes.
* 1x1 single pixel texture.
* @returns {PIXI.Texture} The background color texture.
* @protected
*/
_createBackgroundColorTexture() {
return PIXI.Texture.fromBuffer(new Float32Array(3), 1, 1, {
type: PIXI.TYPES.FLOAT,
format: PIXI.FORMATS.RGB,
wrapMode: PIXI.WRAP_MODES.CLAMP,
scaleMode: PIXI.SCALE_MODES.NEAREST,
mipmap: PIXI.MIPMAP_MODES.OFF
});
}
/* -------------------------------------------- */
/** @override */
render(renderer) {
// Prior blend mode is reinitialized. The first render into PointSourceMesh will use the background color texture.
PointSourceMesh._priorBlendMode = undefined;
PointSourceMesh._currentTexture = this.backgroundColorTexture;
super.render(renderer);
}
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const maskingFilter = CONFIG.Canvas.visualEffectsMaskingFilter;
this.darknessLevel = canvas.darknessLevel;
this.filter = maskingFilter.create({
visionTexture: canvas.masks.vision.renderTexture,
darknessLevelTexture: canvas.effects.illumination.renderTexture,
mode: maskingFilter.FILTER_MODES.ILLUMINATION
});
this.filter.blendMode = PIXI.BLEND_MODES.MULTIPLY;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.filter];
canvas.effects.visualEffectsMaskingFilters.add(this.filter);
}
/* -------------------------------------------- */
/** @override */
async _tearDown(options) {
canvas.effects.visualEffectsMaskingFilters.delete(this.filter);
this.clear();
}
/* -------------------------------------------- */
/* Deprecations */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
updateGlobalLight() {
const msg = "CanvasIlluminationEffects#updateGlobalLight has been deprecated.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return false;
}
/**
* @deprecated since v12
* @ignore
*/
background() {
const msg = "CanvasIlluminationEffects#background is now obsolete.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return null;
}
/**
* @deprecated since v12
* @ignore
*/
get globalLight() {
const msg = "CanvasIlluminationEffects#globalLight has been deprecated without replacement. Check the" +
"canvas.environment.globalLightSource.active instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 12, until: 14});
return canvas.environment.globalLightSource.active;
}
}
/**
* Cached container used for dynamic darkness level. Display objects (of any type) added to this cached container will
* contribute to computing the darkness level of the masked area. Only the red channel is utilized, which corresponds
* to the desired darkness level. Other channels are ignored.
*/
class DarknessLevelContainer extends CachedContainer {
constructor(...args) {
super(...args);
this.autoRender = false;
this.on("childAdded", this.#onChildChange);
this.on("childRemoved", this.#onChildChange);
}
/** @override */
static textureConfiguration = {
scaleMode: PIXI.SCALE_MODES.NEAREST,
format: PIXI.FORMATS.RED,
multisample: PIXI.MSAA_QUALITY.NONE,
mipmap: PIXI.MIPMAP_MODES.OFF
};
/**
* Called when a display object is added or removed from this container.
*/
#onChildChange() {
this.autoRender = this.children.length > 0;
this.renderDirty = true;
canvas.perception.update({refreshVisionSources: true, refreshLightSources: true});
}
}

View File

@@ -0,0 +1,928 @@
// 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<string, number>}
*/
#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<VisionSource, boolean>} 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;
}
}

View File

@@ -0,0 +1,374 @@
/**
* A CanvasLayer for displaying visual effects like weather, transitions, flashes, or more.
*/
class WeatherEffects extends FullCanvasObjectMixin(CanvasLayer) {
constructor() {
super();
this.#initializeFilters();
this.mask = canvas.masks.scene;
this.sortableChildren = true;
this.eventMode = "none";
}
/**
* The container in which effects are added.
* @type {PIXI.Container}
*/
weatherEffects;
/* -------------------------------------------- */
/**
* The container in which suppression meshed are added.
* @type {PIXI.Container}
*/
suppression;
/* -------------------------------------------- */
/**
* Initialize the inverse occlusion and the void filters.
*/
#initializeFilters() {
this.#suppressionFilter = VoidFilter.create();
this.occlusionFilter = WeatherOcclusionMaskFilter.create({
occlusionTexture: canvas.masks.depth.renderTexture
});
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
// FIXME: this does not produce correct results for weather effects that are configured
// with the occlusion filter disabled and use a different blend mode than SCREEN
this.#suppressionFilter.blendMode = PIXI.BLEND_MODES.SCREEN;
this.occlusionFilter.elevation = this.#elevation;
this.filterArea = canvas.app.renderer.screen;
this.filters = [this.occlusionFilter, this.#suppressionFilter];
}
/* -------------------------------------------- */
/** @inheritdoc */
static get layerOptions() {
return foundry.utils.mergeObject(super.layerOptions, {name: "effects"});
}
/* -------------------------------------------- */
/**
* Array of weather effects linked to this weather container.
* @type {Map<string,(ParticleEffect|WeatherShaderEffect)[]>}
*/
effects = new Map();
/**
* @typedef {Object} WeatherTerrainMaskConfiguration
* @property {boolean} enabled Enable or disable this mask.
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
* @property {boolean} [reverse=false] If the mask should be reversed.
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
*/
/**
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
* @type {WeatherTerrainMaskConfiguration}
*/
terrainMaskConfig;
/**
* @typedef {Object} WeatherOcclusionMaskConfiguration
* @property {boolean} enabled Enable or disable this mask.
* @property {number[]} channelWeights An RGBA array of channel weights applied to the mask texture.
* @property {boolean} [reverse=false] If the mask should be reversed.
* @property {PIXI.Texture|PIXI.RenderTexture} texture A texture which defines the mask region.
*/
/**
* A default configuration of the terrain mask that is automatically applied to any shader-based weather effects.
* This configuration is automatically passed to WeatherShaderEffect#configureTerrainMask upon construction.
* @type {WeatherOcclusionMaskConfiguration}
*/
occlusionMaskConfig;
/**
* The inverse occlusion mask filter bound to this container.
* @type {WeatherOcclusionMaskFilter}
*/
occlusionFilter;
/**
* The filter that is needed for suppression if the occlusion filter isn't enabled.
* @type {VoidFilter}
*/
#suppressionFilter;
/* -------------------------------------------- */
/**
* The elevation of this object.
* @type {number}
* @default Infinity
*/
get elevation() {
return this.#elevation;
}
set elevation(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#elevation must be a numeric value.");
}
if ( value === this.#elevation ) return;
this.#elevation = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#elevation = Infinity;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation of different layers.
* @type {number}
* @default PrimaryCanvasGroup.SORT_LAYERS.WEATHER
*/
get sortLayer() {
return this.#sortLayer;
}
set sortLayer(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#sortLayer must be a numeric value.");
}
if ( value === this.#sortLayer ) return;
this.#sortLayer = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#sortLayer = PrimaryCanvasGroup.SORT_LAYERS.WEATHER;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer.
* @type {number}
* @default 0
*/
get sort() {
return this.#sort;
}
set sort(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#sort must be a numeric value.");
}
if ( value === this.#sort ) return;
this.#sort = value;
if ( this.parent ) this.parent.sortDirty = true;
}
#sort = 0;
/* -------------------------------------------- */
/**
* A key which resolves ties amongst objects at the same elevation within the same layer and same sort.
* @type {number}
* @default 0
*/
get zIndex() {
return this._zIndex;
}
set zIndex(value) {
if ( (typeof value !== "number") || Number.isNaN(value) ) {
throw new Error("WeatherEffects#zIndex must be a numeric value.");
}
if ( value === this._zIndex ) return;
this._zIndex = value;
if ( this.parent ) this.parent.sortDirty = true;
}
/* -------------------------------------------- */
/* Weather Effect Rendering */
/* -------------------------------------------- */
/** @override */
async _draw(options) {
const effect = CONFIG.weatherEffects[canvas.scene.weather];
this.weatherEffects = this.addChild(new PIXI.Container());
this.suppression = this.addChild(new PIXI.Container());
for ( const event of ["childAdded", "childRemoved"] ) {
this.suppression.on(event, () => {
this.#suppressionFilter.enabled = !this.occlusionFilter.enabled && !!this.suppression.children.length;
});
}
this.initializeEffects(effect);
}
/* -------------------------------------------- */
/** @inheritDoc */
async _tearDown(options) {
this.clearEffects();
return super._tearDown(options);
}
/* -------------------------------------------- */
/* Weather Effect Management */
/* -------------------------------------------- */
/**
* Initialize the weather container from a weather config object.
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
*/
initializeEffects(weatherEffectsConfig) {
this.#destroyEffects();
Hooks.callAll("initializeWeatherEffects", this, weatherEffectsConfig);
this.#constructEffects(weatherEffectsConfig);
}
/* -------------------------------------------- */
/**
* Clear the weather container.
*/
clearEffects() {
this.initializeEffects(null);
}
/* -------------------------------------------- */
/**
* Destroy all effects associated with this weather container.
*/
#destroyEffects() {
if ( this.effects.size === 0 ) return;
for ( const effect of this.effects.values() ) effect.destroy();
this.effects.clear();
}
/* -------------------------------------------- */
/**
* Construct effects according to the weather effects config object.
* @param {object} [weatherEffectsConfig] Weather config object (or null/undefined to clear the container).
*/
#constructEffects(weatherEffectsConfig) {
if ( !weatherEffectsConfig ) {
this.#suppressionFilter.enabled = this.occlusionFilter.enabled = false;
return;
}
const effects = weatherEffectsConfig.effects;
let zIndex = 0;
// Enable a layer-wide occlusion filter unless it is explicitly disabled by the effect configuration
const useOcclusionFilter = weatherEffectsConfig.filter?.enabled !== false;
if ( useOcclusionFilter ) {
WeatherEffects.configureOcclusionMask(this.occlusionFilter, this.occlusionMaskConfig || {enabled: true});
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(this.occlusionFilter, this.terrainMaskConfig);
this.occlusionFilter.blendMode = weatherEffectsConfig.filter?.blendMode ?? PIXI.BLEND_MODES.NORMAL;
this.occlusionFilter.enabled = true;
this.#suppressionFilter.enabled = false;
}
else {
this.#suppressionFilter.enabled = !!this.suppression.children.length;
}
// Create each effect
for ( const effect of effects ) {
const requiredPerformanceLevel = Number.isNumeric(effect.performanceLevel) ? effect.performanceLevel : 0;
if ( canvas.performance.mode < requiredPerformanceLevel ) {
console.debug(`Skipping weather effect ${effect.id}. The client performance level ${canvas.performance.mode}`
+ ` is less than the required performance mode ${requiredPerformanceLevel} for the effect`);
continue;
}
// Construct the effect container
let ec;
try {
ec = new effect.effectClass(effect.config, effect.shaderClass);
} catch(err) {
err.message = `Failed to construct weather effect: ${err.message}`;
console.error(err);
continue;
}
// Configure effect container
ec.zIndex = effect.zIndex ?? zIndex++;
ec.blendMode = effect.blendMode ?? PIXI.BLEND_MODES.NORMAL;
// Apply effect-level occlusion and terrain masking only if we are not using a layer-wide filter
if ( effect.shaderClass && !useOcclusionFilter ) {
WeatherEffects.configureOcclusionMask(ec.shader, this.occlusionMaskConfig || {enabled: true});
if ( this.terrainMaskConfig ) WeatherEffects.configureTerrainMask(ec.shader, this.terrainMaskConfig);
}
// Add to the layer, register the effect, and begin play
this.weatherEffects.addChild(ec);
this.effects.set(effect.id, ec);
ec.play();
}
}
/* -------------------------------------------- */
/**
* Set the occlusion uniforms for this weather shader.
* @param {PIXI.Shader} context The shader context
* @param {WeatherOcclusionMaskConfiguration} config Occlusion masking options
* @protected
*/
static configureOcclusionMask(context, {enabled=false, channelWeights=[0, 0, 1, 0], reverse=false, texture}={}) {
if ( !(context instanceof PIXI.Shader) ) return;
const uniforms = context.uniforms;
if ( texture !== undefined ) uniforms.occlusionTexture = texture;
else uniforms.occlusionTexture ??= canvas.masks.depth.renderTexture;
uniforms.useOcclusion = enabled;
uniforms.occlusionWeights = channelWeights;
uniforms.reverseOcclusion = reverse;
if ( enabled && !uniforms.occlusionTexture ) {
console.warn(`The occlusion configuration for the weather shader ${context.constructor.name} is enabled but`
+ " does not have a valid texture");
uniforms.useOcclusion = false;
}
}
/* -------------------------------------------- */
/**
* Set the terrain uniforms for this weather shader.
* @param {PIXI.Shader} context The shader context
* @param {WeatherTerrainMaskConfiguration} config Terrain masking options
* @protected
*/
static configureTerrainMask(context, {enabled=false, channelWeights=[1, 0, 0, 0], reverse=false, texture}={}) {
if ( !(context instanceof PIXI.Shader) ) return;
const uniforms = context.uniforms;
if ( texture !== undefined ) {
uniforms.terrainTexture = texture;
const terrainMatrix = new PIXI.TextureMatrix(texture);
terrainMatrix.update();
uniforms.terrainUvMatrix.copyFrom(terrainMatrix.mapCoord);
}
uniforms.useTerrain = enabled;
uniforms.terrainWeights = channelWeights;
uniforms.reverseTerrain = reverse;
if ( enabled && !uniforms.terrainTexture ) {
console.warn(`The terrain configuration for the weather shader ${context.constructor.name} is enabled but`
+ " does not have a valid texture");
uniforms.useTerrain = false;
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get weather() {
const msg = "The WeatherContainer at canvas.weather.weather is deprecated and combined with the layer itself.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this;
}
}

View File

@@ -0,0 +1,78 @@
/**
* An interface for defining particle-based weather effects
* @param {PIXI.Container} parent The parent container within which the effect is rendered
* @param {object} [options] Options passed to the getParticleEmitters method which can be used to customize
* values of the emitter configuration.
* @interface
*/
class ParticleEffect extends FullCanvasObjectMixin(PIXI.Container) {
constructor(options={}) {
super();
/**
* The array of emitters which are active for this particle effect
* @type {PIXI.particles.Emitter[]}
*/
this.emitters = this.getParticleEmitters(options);
}
/* -------------------------------------------- */
/**
* Create an emitter instance which automatically updates using the shared PIXI.Ticker
* @param {PIXI.particles.EmitterConfigV3} config The emitter configuration
* @returns {PIXI.particles.Emitter} The created Emitter instance
*/
createEmitter(config) {
config.autoUpdate = true;
config.emit = false;
return new PIXI.particles.Emitter(this, config);
}
/* -------------------------------------------- */
/**
* Get the particle emitters which should be active for this particle effect.
* This base class creates a single emitter using the explicitly provided configuration.
* Subclasses can override this method for more advanced configurations.
* @param {object} [options={}] Options provided to the ParticleEffect constructor which can be used to customize
* configuration values for created emitters.
* @returns {PIXI.particles.Emitter[]}
*/
getParticleEmitters(options={}) {
if ( foundry.utils.isEmpty(options) ) {
throw new Error("The base ParticleEffect class may only be used with an explicitly provided configuration");
}
return [this.createEmitter(/** @type {PIXI.particles.EmitterConfigV3} */ options)];
}
/* -------------------------------------------- */
/** @override */
destroy(...args) {
for ( const e of this.emitters ) e.destroy();
this.emitters = [];
super.destroy(...args);
}
/* -------------------------------------------- */
/**
* Begin animation for the configured emitters.
*/
play() {
for ( let e of this.emitters ) {
e.emit = true;
}
}
/* -------------------------------------------- */
/**
* Stop animation for the configured emitters.
*/
stop() {
for ( let e of this.emitters ) {
e.emit = false;
}
}
}

View File

@@ -0,0 +1,74 @@
/**
* A full-screen weather effect which renders gently falling autumn leaves.
* @extends {ParticleEffect}
*/
class AutumnLeavesWeatherEffect extends ParticleEffect {
/** @inheritdoc */
static label = "WEATHER.AutumnLeaves";
/**
* Configuration for the particle emitter for falling leaves
* @type {PIXI.particles.EmitterConfigV3}
*/
static LEAF_CONFIG = {
lifetime: {min: 10, max: 10},
behaviors: [
{
type: "alpha",
config: {
alpha: {
list: [{time: 0, value: 0.9}, {time: 1, value: 0.5}]
}
}
},
{
type: "moveSpeed",
config: {
speed: {
list: [{time: 0, value: 20}, {time: 1, value: 60}]
},
minMult: 0.6
}
},
{
type: "scale",
config: {
scale: {
list: [{time: 0, value: 0.2}, {time: 1, value: 0.4}]
},
minMult: 0.5
}
},
{
type: "rotation",
config: {accel: 0, minSpeed: 100, maxSpeed: 200, minStart: 0, maxStart: 365}
},
{
type: "textureRandom",
config: {
textures: Array.fromRange(6).map(n => `ui/particles/leaf${n + 1}.png`)
}
}
]
};
/* -------------------------------------------- */
/** @inheritdoc */
getParticleEmitters() {
const d = canvas.dimensions;
const maxParticles = (d.width / d.size) * (d.height / d.size) * 0.25;
const config = foundry.utils.deepClone(this.constructor.LEAF_CONFIG);
config.maxParticles = maxParticles;
config.frequency = config.lifetime.min / maxParticles;
config.behaviors.push({
type: "spawnShape",
config: {
type: "rect",
data: {x: d.sceneRect.x, y: d.sceneRect.y, w: d.sceneRect.width, h: d.sceneRect.height}
}
});
return [this.createEmitter(config)];
}
}