Initial
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
29
resources/app/client/pixi/layers/effects/darkness-effects.js
Normal file
29
resources/app/client/pixi/layers/effects/darkness-effects.js
Normal 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];
|
||||
}
|
||||
}
|
||||
253
resources/app/client/pixi/layers/effects/illumination-effects.js
Normal file
253
resources/app/client/pixi/layers/effects/illumination-effects.js
Normal 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});
|
||||
}
|
||||
}
|
||||
|
||||
928
resources/app/client/pixi/layers/effects/visibility.js
Normal file
928
resources/app/client/pixi/layers/effects/visibility.js
Normal 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;
|
||||
}
|
||||
}
|
||||
374
resources/app/client/pixi/layers/effects/weather-effects.js
Normal file
374
resources/app/client/pixi/layers/effects/weather-effects.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user