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,10 @@
export {default as BaseEffectSource} from "./base-effect-source.mjs";
export {default as BaseLightSource} from "./base-light-source.mjs";
export {default as GlobalLightSource} from "./global-light-source.mjs";
export {default as PointDarknessSource} from "./point-darkness-source.mjs";
export {default as PointEffectSourceMixin} from "./point-effect-source.mjs";
export {default as PointLightSource} from "./point-light-source.mjs";
export {default as PointMovementSource} from "./point-movement-source.mjs";
export {default as PointSoundSource} from "./point-sound-source.mjs";
export {default as PointVisionSource} from "./point-vision-source.mjs";
export {default as RenderedEffectSource} from "./rendered-effect-source.mjs";

View File

@@ -0,0 +1,370 @@
/**
* @typedef {Object} BasseEffectSourceOptions
* @property {PlaceableObject} [options.object] An optional PlaceableObject which is responsible for this source
* @property {string} [options.sourceId] A unique ID for this source. This will be set automatically if an
* object is provided, otherwise is required.
*/
/**
* @typedef {Object} BaseEffectSourceData
* @property {number} x The x-coordinate of the source location
* @property {number} y The y-coordinate of the source location
* @property {number} elevation The elevation of the point source
* @property {boolean} disabled Whether or not the source is disabled
*/
/**
* TODO - Re-document after ESM refactor.
* An abstract base class which defines a framework for effect sources which originate radially from a specific point.
* This abstraction is used by the LightSource, VisionSource, SoundSource, and MovementSource subclasses.
*
* @example A standard PointSource lifecycle:
* ```js
* const source = new PointSource({object}); // Create the point source
* source.initialize(data); // Configure the point source with new data
* source.refresh(); // Refresh the point source
* source.destroy(); // Destroy the point source
* ```
*
* @template {BaseEffectSourceData} SourceData
* @template {PIXI.Polygon} SourceShape
* @abstract
*/
export default class BaseEffectSource {
/**
* An effect source is constructed by providing configuration options.
* @param {BasseEffectSourceOptions} [options] Options which modify the base effect source instance
*/
constructor(options={}) {
if ( options instanceof PlaceableObject ) {
const warning = "The constructor PointSource(PlaceableObject) is deprecated. "
+ "Use new PointSource({ object }) instead.";
foundry.utils.logCompatibilityWarning(warning, {since: 11, until: 13});
this.object = options;
this.sourceId = this.object.sourceId;
}
else {
this.object = options.object ?? null;
this.sourceId = options.sourceId;
}
}
/**
* The type of source represented by this data structure.
* Each subclass must implement this attribute.
* @type {string}
*/
static sourceType;
/**
* The target collection into the effects canvas group.
* @type {string}
* @abstract
*/
static effectsCollection;
/**
* Effect source default data.
* @type {SourceData}
*/
static defaultData = {
x: 0,
y: 0,
elevation: 0,
disabled: false
}
/* -------------------------------------------- */
/* Source Data */
/* -------------------------------------------- */
/**
* Some other object which is responsible for this source.
* @type {object|null}
*/
object;
/**
* The source id linked to this effect source.
* @type {Readonly<string>}
*/
sourceId;
/**
* The data of this source.
* @type {SourceData}
*/
data = foundry.utils.deepClone(this.constructor.defaultData);
/**
* The geometric shape of the effect source which is generated later.
* @type {SourceShape}
*/
shape;
/**
* A collection of boolean flags which control rendering and refresh behavior for the source.
* @type {Record<string, boolean|number>}
* @protected
*/
_flags = {};
/**
* The x-coordinate of the point source origin.
* @type {number}
*/
get x() {
return this.data.x;
}
/**
* The y-coordinate of the point source origin.
* @type {number}
*/
get y() {
return this.data.y;
}
/**
* The elevation bound to this source.
* @type {number}
*/
get elevation() {
return this.data.elevation;
}
/* -------------------------------------------- */
/* Source State */
/* -------------------------------------------- */
/**
* The EffectsCanvasGroup collection linked to this effect source.
* @type {Collection<string, BaseEffectSource>}
*/
get effectsCollection() {
return canvas.effects[this.constructor.effectsCollection];
}
/**
* Returns the update ID associated with this source.
* The update ID is increased whenever the shape of the source changes.
* @type {number}
*/
get updateId() {
return this.#updateId;
}
#updateId = 0;
/**
* Is this source currently active?
* A source is active if it is attached to an effect collection and is not disabled or suppressed.
* @type {boolean}
*/
get active() {
return this.#attached && !this.data.disabled && !this.suppressed;
}
/**
* Is this source attached to an effect collection?
* @type {boolean}
*/
get attached() {
return this.#attached;
}
#attached = false;
/* -------------------------------------------- */
/* Source Suppression Management */
/* -------------------------------------------- */
/**
* Is this source temporarily suppressed?
* @type {boolean}
*/
get suppressed() {
return Object.values(this.suppression).includes(true);
};
/**
* Records of suppression strings with a boolean value.
* If any of this record is true, the source is suppressed.
* @type {Record<string, boolean>}
*/
suppression = {};
/* -------------------------------------------- */
/* Source Initialization */
/* -------------------------------------------- */
/**
* Initialize and configure the source using provided data.
* @param {Partial<SourceData>} data Provided data for configuration
* @param {object} options Additional options which modify source initialization
* @param {object} [options.behaviors] An object containing optional behaviors to apply.
* @param {boolean} [options.reset=false] Should source data be reset to default values before applying changes?
* @returns {BaseEffectSource} The initialized source
*/
initialize(data={}, {reset=false}={}) {
// Reset the source back to default data
if ( reset ) data = Object.assign(foundry.utils.deepClone(this.constructor.defaultData), data);
// Update data for the source
let changes = {};
if ( !foundry.utils.isEmpty(data) ) {
const prior = foundry.utils.deepClone(this.data) || {};
for ( const key in data ) {
if ( !(key in this.data) ) continue;
this.data[key] = data[key] ?? this.constructor.defaultData[key];
}
this._initialize(data);
changes = foundry.utils.flattenObject(foundry.utils.diffObject(prior, this.data));
}
// Update shapes for the source
try {
this._createShapes();
this.#updateId++;
}
catch (err) {
console.error(err);
this.remove();
}
// Configure attached and non disabled sources
if ( this.#attached && !this.data.disabled ) this._configure(changes);
return this;
}
/* -------------------------------------------- */
/**
* Subclass specific data initialization steps.
* @param {Partial<SourceData>} data Provided data for configuration
* @abstract
*/
_initialize(data) {}
/* -------------------------------------------- */
/**
* Create the polygon shape (or shapes) for this source using configured data.
* @protected
* @abstract
*/
_createShapes() {}
/* -------------------------------------------- */
/**
* Subclass specific configuration steps. Occurs after data initialization and shape computation.
* Only called if the source is attached and not disabled.
* @param {Partial<SourceData>} changes Changes to the source data which were applied
* @protected
*/
_configure(changes) {}
/* -------------------------------------------- */
/* Source Refresh */
/* -------------------------------------------- */
/**
* Refresh the state and uniforms of the source.
* Only active sources are refreshed.
*/
refresh() {
if ( !this.active ) return;
this._refresh();
}
/* -------------------------------------------- */
/**
* Subclass-specific refresh steps.
* @protected
* @abstract
*/
_refresh() {}
/* -------------------------------------------- */
/* Source Destruction */
/* -------------------------------------------- */
/**
* Steps that must be performed when the source is destroyed.
*/
destroy() {
this.remove();
this._destroy();
}
/* -------------------------------------------- */
/**
* Subclass specific destruction steps.
* @protected
*/
_destroy() {}
/* -------------------------------------------- */
/* Source Management */
/* -------------------------------------------- */
/**
* Add this BaseEffectSource instance to the active collection.
*/
add() {
if ( !this.sourceId ) throw new Error("A BaseEffectSource cannot be added to the active collection unless it has"
+ " a sourceId assigned.");
this.effectsCollection.set(this.sourceId, this);
const wasConfigured = this.#attached && !this.data.disabled;
this.#attached = true;
if ( !wasConfigured && !this.data.disabled ) this._configure({});
}
/* -------------------------------------------- */
/**
* Remove this BaseEffectSource instance from the active collection.
*/
remove() {
if ( !this.effectsCollection.has(this.sourceId) ) return;
this.effectsCollection.delete(this.sourceId);
this.#attached = false;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get sourceType() {
const msg = "BaseEffectSource#sourceType is deprecated. Use BaseEffectSource.sourceType instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.constructor.sourceType;
}
/**
* @deprecated since v12
* @ignore
*/
_createShape() {
const msg = "BaseEffectSource#_createShape is deprecated in favor of BaseEffectSource#_createShapes.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this._createShapes();
}
/**
* @deprecated since v12
* @ignore
*/
get disabled() {
foundry.utils.logCompatibilityWarning("BaseEffectSource#disabled is deprecated in favor of " +
"BaseEffectSource#data#disabled or BaseEffectSource#active depending on your use case.", { since: 11, until: 13});
return this.data.disabled;
}
}

View File

@@ -0,0 +1,306 @@
import RenderedEffectSource from "./rendered-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
*/
/**
* @typedef {Object} LightSourceData
* @property {number} alpha An opacity for the emitted light, if any
* @property {number} bright The allowed radius of bright vision or illumination
* @property {number} coloration The coloration technique applied in the shader
* @property {number} contrast The amount of contrast this light applies to the background texture
* @property {number} dim The allowed radius of dim vision or illumination
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
* @property {number} luminosity The luminosity applied in the shader
* @property {number} saturation The amount of color saturation this light applies to the background texture
* @property {number} shadows The depth of shadows this light applies to the background texture
* @property {boolean} vision Whether or not this source provides a source of vision
* @property {number} priority Strength of this source to beat or not negative/positive sources
*/
/**
* A specialized subclass of BaseEffectSource which deals with the rendering of light or darkness.
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & LightSourceData>}
* @abstract
*/
export default class BaseLightSource extends RenderedEffectSource {
/** @override */
static sourceType = "light";
/** @override */
static _initializeShaderKeys = ["animation.type", "walls"];
/** @override */
static _refreshUniformsKeys = ["dim", "bright", "attenuation", "alpha", "coloration", "color", "contrast",
"saturation", "shadows", "luminosity"];
/**
* The corresponding lighting levels for dim light.
* @type {number}
* @protected
*/
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
/**
* The corresponding lighting levels for bright light.
* @type {string}
* @protected
*/
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
/**
* The corresponding animation config.
* @type {LightSourceAnimationConfig}
* @protected
*/
static get ANIMATIONS() {
return CONFIG.Canvas.lightAnimations;
}
/** @inheritDoc */
static defaultData = {
...super.defaultData,
priority: 0,
alpha: 0.5,
bright: 0,
coloration: 1,
contrast: 0,
dim: 0,
attenuation: 0.5,
luminosity: 0.5,
saturation: 0,
shadows: 0,
vision: false
}
/* -------------------------------------------- */
/* Light Source Attributes */
/* -------------------------------------------- */
/**
* A ratio of dim:bright as part of the source radius
* @type {number}
*/
ratio = 1;
/* -------------------------------------------- */
/* Light Source Initialization */
/* -------------------------------------------- */
/** @override */
_initialize(data) {
super._initialize(data);
const animationConfig = foundry.utils.deepClone(this.constructor.ANIMATIONS[this.data.animation.type] || {});
this.animation = Object.assign(this.data.animation, animationConfig);
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/** @inheritDoc */
_updateColorationUniforms() {
super._updateColorationUniforms();
const u = this.layers.coloration.shader?.uniforms;
if ( !u ) return;
// Adapting color intensity to the coloration technique
switch ( this.data.coloration ) {
case 0: // Legacy
// Default 0.25 -> Legacy technique needs quite low intensity default to avoid washing background
u.colorationAlpha = Math.pow(this.data.alpha, 2);
break;
case 4: // Color burn
case 5: // Internal burn
case 6: // External burn
case 9: // Invert absorption
// Default 0.5 -> These techniques are better at low color intensity
u.colorationAlpha = this.data.alpha;
break;
default:
// Default 1 -> The remaining techniques use adaptive lighting,
// which produces interesting results in the [0, 2] range.
u.colorationAlpha = this.data.alpha * 2;
}
u.useSampler = this.data.coloration > 0; // Not needed for legacy coloration (technique id 0)
// Flag uniforms as updated
this.layers.coloration.reset = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateIlluminationUniforms() {
super._updateIlluminationUniforms();
const u = this.layers.illumination.shader?.uniforms;
if ( !u ) return;
u.useSampler = false;
// Flag uniforms as updated
const i = this.layers.illumination;
i.reset = i.suppressed = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateBackgroundUniforms() {
super._updateBackgroundUniforms();
const u = this.layers.background.shader?.uniforms;
if ( !u ) return;
canvas.colors.background.applyRGB(u.colorBackground);
u.backgroundAlpha = this.data.alpha;
u.useSampler = true;
// Flag uniforms as updated
this.layers.background.reset = false;
}
/* -------------------------------------------- */
/** @override */
_updateCommonUniforms(shader) {
const u = shader.uniforms;
const c = canvas.colors;
// Passing common environment values
u.computeIllumination = true;
u.darknessLevel = canvas.environment.darknessLevel;
c.ambientBrightest.applyRGB(u.ambientBrightest);
c.ambientDarkness.applyRGB(u.ambientDarkness);
c.ambientDaylight.applyRGB(u.ambientDaylight);
u.weights[0] = canvas.environment.weights.dark;
u.weights[1] = canvas.environment.weights.halfdark;
u.weights[2] = canvas.environment.weights.dim;
u.weights[3] = canvas.environment.weights.bright;
u.dimLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._dimLightingLevel);
u.brightLevelCorrection = this.constructor.getCorrectedLevel(this.constructor._brightLightingLevel);
// Passing advanced color correction values
u.luminosity = this.data.luminosity;
u.exposure = this.data.luminosity * 2.0 - 1.0;
u.contrast = (this.data.contrast < 0 ? this.data.contrast * 0.5 : this.data.contrast);
u.saturation = this.data.saturation;
u.shadows = this.data.shadows;
u.hasColor = this._flags.hasColor;
u.ratio = this.ratio;
u.technique = this.data.coloration;
// Graph: https://www.desmos.com/calculator/e7z0i7hrck
// mapping [0,1] attenuation user value to [0,1] attenuation shader value
if ( this.cachedAttenuation !== this.data.attenuation ) {
this.computedAttenuation = (Math.cos(Math.PI * Math.pow(this.data.attenuation, 1.5)) - 1) / -2;
this.cachedAttenuation = this.data.attenuation;
}
u.attenuation = this.computedAttenuation;
u.elevation = this.data.elevation;
u.color = this.colorRGB ?? shader.constructor.defaultUniforms.color;
// Passing screenDimensions to use screen size render textures
u.screenDimensions = canvas.screenDimensions;
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
}
/* -------------------------------------------- */
/* Animation Functions */
/* -------------------------------------------- */
/**
* An animation with flickering ratio and light intensity.
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the flame animation
* @param {number} [options.speed=5] The animation speed, from 0 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateTorch(dt, {speed=5, intensity=5, reverse=false} = {}) {
this.animateFlickering(dt, {speed, intensity, reverse, amplification: intensity / 5});
}
/* -------------------------------------------- */
/**
* An animation with flickering ratio and light intensity
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the flame animation
* @param {number} [options.speed=5] The animation speed, from 0 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {number} [options.amplification=1] Noise amplification (>1) or dampening (<1)
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateFlickering(dt, {speed=5, intensity=5, reverse=false, amplification=1} = {}) {
this.animateTime(dt, {speed, intensity, reverse});
// Create the noise object for the first frame
const amplitude = amplification * 0.45;
if ( !this._noise ) this._noise = new SmoothNoise({amplitude: amplitude, scale: 3, maxReferences: 2048});
// Update amplitude
if ( this._noise.amplitude !== amplitude ) this._noise.amplitude = amplitude;
// Create noise from animation time. Range [0.0, 0.45]
let n = this._noise.generate(this.animation.time);
// Update brightnessPulse and ratio with some noise in it
const co = this.layers.coloration.shader;
const il = this.layers.illumination.shader;
co.uniforms.brightnessPulse = il.uniforms.brightnessPulse = 0.55 + n; // Range [0.55, 1.0 <* amplification>]
co.uniforms.ratio = il.uniforms.ratio = (this.ratio * 0.9) + (n * 0.222);// Range [ratio * 0.9, ratio * ~1.0 <* amplification>]
}
/* -------------------------------------------- */
/**
* A basic "pulse" animation which expands and contracts.
* @param {number} dt Delta time
* @param {object} [options={}] Additional options which modify the pulse animation
* @param {number} [options.speed=5] The animation speed, from 0 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animatePulse(dt, {speed=5, intensity=5, reverse=false}={}) {
// Determine the animation timing
let t = canvas.app.ticker.lastTime;
if ( reverse ) t *= -1;
this.animation.time = ((speed * t)/5000) + this.animation.seed;
// Define parameters
const i = (10 - intensity) * 0.1;
const w = 0.5 * (Math.cos(this.animation.time * 2.5) + 1);
const wave = (a, b, w) => ((a - b) * w) + b;
// Pulse coloration
const co = this.layers.coloration.shader;
co.uniforms.intensity = intensity;
co.uniforms.time = this.animation.time;
co.uniforms.pulse = wave(1.2, i, w);
// Pulse illumination
const il = this.layers.illumination.shader;
il.uniforms.intensity = intensity;
il.uniforms.time = this.animation.time;
il.uniforms.ratio = wave(this.ratio, this.ratio * i, w);
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isDarkness() {
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
return false;
}
}

View File

@@ -0,0 +1,80 @@
import BaseLightSource from "./base-light-source.mjs";
/**
* A specialized subclass of the BaseLightSource which is used to render global light source linked to the scene.
*/
export default class GlobalLightSource extends BaseLightSource {
/** @inheritDoc */
static sourceType = "GlobalLight";
/** @override */
static effectsCollection = "lightSources";
/** @inheritDoc */
static defaultData = {
...super.defaultData,
rotation: 0,
angle: 360,
attenuation: 0,
priority: -Infinity,
vision: false,
walls: false,
elevation: Infinity,
darkness: {min: 0, max: 0}
}
/**
* Name of this global light source.
* @type {string}
* @defaultValue GlobalLightSource.sourceType
*/
name = this.constructor.sourceType;
/**
* A custom polygon placeholder.
* @type {PIXI.Polygon|number[]|null}
*/
customPolygon = null;
/* -------------------------------------------- */
/* Global Light Source Initialization */
/* -------------------------------------------- */
/** @override */
_createShapes() {
this.shape = this.customPolygon ?? canvas.dimensions.sceneRect.toPolygon();
}
/* -------------------------------------------- */
/** @override */
_initializeSoftEdges() {
this._flags.renderSoftEdges = false;
}
/* -------------------------------------------- */
/** @override */
_updateGeometry() {
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const pm = new PolygonMesher(this.shape, {offset});
this._geometry = pm.triangulate(this._geometry);
const bounds = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
else this._geometry.bounds = bounds;
}
/* -------------------------------------------- */
/** @override */
_updateCommonUniforms(shader) {
super._updateCommonUniforms(shader);
const {min, max} = this.data.darkness;
const u = shader.uniforms;
u.globalLight = true;
u.globalLightThresholds[0] = min;
u.globalLightThresholds[1] = max;
}
}

View File

@@ -0,0 +1,253 @@
import BaseLightSource from "./base-light-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* A specialized subclass of the BaseLightSource which renders a source of darkness as a point-based effect.
* @extends {BaseLightSource}
* @mixes {PointEffectSource}
*/
export default class PointDarknessSource extends PointEffectSourceMixin(BaseLightSource) {
/** @override */
static effectsCollection = "darknessSources";
/** @override */
static _dimLightingLevel = LIGHTING_LEVELS.HALFDARK;
/** @override */
static _brightLightingLevel = LIGHTING_LEVELS.DARKNESS;
/** @override */
static get ANIMATIONS() {
return CONFIG.Canvas.darknessAnimations;
}
/** @override */
static get _layers() {
return {
darkness: {
defaultShader: AdaptiveDarknessShader,
blendMode: "MAX_COLOR"
}
};
}
/**
* The optional geometric shape is solely utilized for visual representation regarding darkness sources.
* Used only when an additional radius is added for visuals.
* @protected
* @type {SourceShape}
*/
_visualShape;
/**
* Padding applied on the darkness source shape for visual appearance only.
* Note: for now, padding is increased radius. It might evolve in a future release.
* @type {number}
* @protected
*/
_padding = (CONFIG.Canvas.darknessSourcePaddingMultiplier ?? 0) * canvas.grid.size;
/**
* The Edge instances added by this darkness source.
* @type {Edge[]}
*/
edges = [];
/**
* The normalized border distance.
* @type {number}
*/
#borderDistance = 0;
/* -------------------------------------------- */
/* Darkness Source Properties */
/* -------------------------------------------- */
/**
* A convenience accessor to the darkness layer mesh.
* @type {PointSourceMesh}
*/
get darkness() {
return this.layers.darkness.mesh;
}
/* -------------------------------------------- */
/* Source Initialization and Management */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
this.data.radius = this.data.bright = this.data.dim = Math.max(this.data.dim ?? 0, this.data.bright ?? 0);
this.#borderDistance = this.radius / (this.radius + this._padding);
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this.#deleteEdges();
const origin = {x: this.data.x, y: this.data.y};
const config = this._getPolygonConfiguration();
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
// Create shapes based on padding
if ( this.radius < config.radius ) {
this._visualShape = polygonClass.create(origin, config);
this.shape = this.#createShapeFromVisualShape(this.radius);
}
else {
this._visualShape = null;
this.shape = polygonClass.create(origin, config);
}
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
super._configure(changes);
this.#createEdges();
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {
useThreshold: true,
includeDarkness: false,
radius: (this.data.disabled || this.suppressed) ? 0 : this.radius + this._padding,
});
}
/* -------------------------------------------- */
_drawMesh(layerId) {
const mesh = super._drawMesh(layerId);
if ( mesh ) mesh.scale.set(this.radius + this._padding);
return mesh;
}
/* -------------------------------------------- */
/** @override */
_updateGeometry() {
const {x, y} = this.data;
const radius = this.radius + this._padding;
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const shape = this._visualShape ?? this.shape;
const pm = new PolygonMesher(shape, {x, y, radius, normalize: true, offset});
this._geometry = pm.triangulate(this._geometry);
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
if ( radius > 0 ) {
const b = shape instanceof PointSourcePolygon ? shape.bounds : shape.getBounds();
bounds.x = (b.x - x) / radius;
bounds.y = (b.y - y) / radius;
bounds.width = b.width / radius;
bounds.height = b.height / radius;
}
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
else this._geometry.bounds = bounds;
}
/* -------------------------------------------- */
/**
* Create a radius constrained polygon from the visual shape polygon.
* If the visual shape is not created, no polygon is created.
* @param {number} radius The radius to constraint to.
* @returns {PointSourcePolygon} The new polygon or null if no visual shape is present.
*/
#createShapeFromVisualShape(radius) {
if ( !this._visualShape ) return null;
const {x, y} = this.data;
const circle = new PIXI.Circle(x, y, radius);
const density = PIXI.Circle.approximateVertexDensity(radius);
return this._visualShape.applyConstraint(circle, {density, scalingFactor: 100});
}
/* -------------------------------------------- */
/**
* Create the Edge instances that correspond to this darkness source.
*/
#createEdges() {
if ( !this.active || this.isPreview ) return;
const cls = foundry.canvas.edges.Edge;
const block = CONST.WALL_SENSE_TYPES.NORMAL;
const direction = CONST.WALL_DIRECTIONS.LEFT;
const points = [...this.shape.points];
let p0 = {x: points[0], y: points[1]};
points.push(p0.x, p0.y);
let p1;
for ( let i=2; i<points.length; i+=2 ) {
p1 = {x: points[i], y: points[i+1]};
const id = `${this.sourceId}.${i/2}`;
const edge = new cls(p0, p1, {type: "darkness", id, object: this.object, direction, light: block, sight: block});
this.edges.push(edge);
canvas.edges.set(edge.id, edge);
p0 = p1;
}
}
/* -------------------------------------------- */
/**
* Remove edges from the active Edges collection.
*/
#deleteEdges() {
for ( const edge of this.edges ) canvas.edges.delete(edge.id);
this.edges.length = 0;
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/**
* Update the uniforms of the shader on the darkness layer.
*/
_updateDarknessUniforms() {
const u = this.layers.darkness.shader?.uniforms;
if ( !u ) return;
u.color = this.colorRGB ?? this.layers.darkness.shader.constructor.defaultUniforms.color;
u.enableVisionMasking = canvas.effects.visionSources.some(s => s.active) || !game.user.isGM;
u.borderDistance = this.#borderDistance;
u.colorationAlpha = this.data.alpha * 2;
// Passing screenDimensions to use screen size render textures
u.screenDimensions = canvas.screenDimensions;
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
if ( !u.visionTexture ) u.visionTexture = canvas.masks.vision.renderTexture;
// Flag uniforms as updated
this.layers.darkness.reset = false;
}
/* -------------------------------------------- */
/** @inheritDoc */
_destroy() {
this.#deleteEdges();
super._destroy();
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v12
* @ignore
*/
get isDarkness() {
const msg = "BaseLightSource#isDarkness is now obsolete. Use DarknessSource instead.";
foundry.utils.logCompatibilityWarning(msg, { since: 12, until: 14});
return true;
}
}

View File

@@ -0,0 +1,165 @@
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
*/
/**
* @typedef {Object} PointEffectSourceData
* @property {number} radius The radius of the source
* @property {number} externalRadius A secondary radius used for limited angles
* @property {number} rotation The angle of rotation for this point source
* @property {number} angle The angle of emission for this point source
* @property {boolean} walls Whether or not the source is constrained by walls
*/
/**
* TODO - documentation required about what a PointEffectSource is.
* @param BaseSource
* @returns {{new(): PointEffectSource, prototype: PointEffectSource}}
* @mixin
*/
export default function PointEffectSourceMixin(BaseSource) {
/**
* @extends {BaseEffectSource<BaseEffectSourceData & PointEffectSourceData, PointSourcePolygon>}
* @abstract
*/
return class PointEffectSource extends BaseSource {
/** @inheritDoc */
static defaultData = {
...super.defaultData,
radius: 0,
externalRadius: 0,
rotation: 0,
angle: 360,
walls: true
}
/* -------------------------------------------- */
/**
* A convenience reference to the radius of the source.
* @type {number}
*/
get radius() {
return this.data.radius ?? 0;
}
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
}
/* -------------------------------------------- */
/* Point Source Geometry Methods */
/* -------------------------------------------- */
/** @inheritDoc */
_initializeSoftEdges() {
super._initializeSoftEdges();
const isCircle = (this.shape instanceof PointSourcePolygon) && this.shape.isCompleteCircle();
this._flags.renderSoftEdges &&= !isCircle;
}
/* -------------------------------------------- */
/**
* Configure the parameters of the polygon that is generated for this source.
* @returns {PointSourcePolygonConfig}
* @protected
*/
_getPolygonConfiguration() {
return {
type: this.data.walls ? this.constructor.sourceType : "universal",
radius: (this.data.disabled || this.suppressed) ? 0 : this.radius,
externalRadius: this.data.externalRadius,
angle: this.data.angle,
rotation: this.data.rotation,
source: this
};
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
const origin = {x: this.data.x, y: this.data.y};
const config = this._getPolygonConfiguration();
const polygonClass = CONFIG.Canvas.polygonBackends[this.constructor.sourceType];
this.shape = polygonClass.create(origin, config);
}
/* -------------------------------------------- */
/* Rendering methods */
/* -------------------------------------------- */
/** @override */
_drawMesh(layerId) {
const mesh = super._drawMesh(layerId);
if ( mesh ) mesh.scale.set(this.radius);
return mesh;
}
/** @override */
_updateGeometry() {
const {x, y} = this.data;
const radius = this.radius;
const offset = this._flags.renderSoftEdges ? this.constructor.EDGE_OFFSET : 0;
const pm = new PolygonMesher(this.shape, {x, y, radius, normalize: true, offset});
this._geometry = pm.triangulate(this._geometry);
const bounds = new PIXI.Rectangle(0, 0, 0, 0);
if ( radius > 0 ) {
const b = this.shape instanceof PointSourcePolygon ? this.shape.bounds : this.shape.getBounds();
bounds.x = (b.x - x) / radius;
bounds.y = (b.y - y) / radius;
bounds.width = b.width / radius;
bounds.height = b.height / radius;
}
if ( this._geometry.bounds ) this._geometry.bounds.copyFrom(bounds);
else this._geometry.bounds = bounds;
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set radius(radius) {
const msg = "The setter PointEffectSource#radius is deprecated."
+ " The radius should not be set anywhere except in PointEffectSource#_initialize.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.data.radius = radius;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get los() {
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
return this.shape;
}
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
set los(shape) {
const msg = "PointEffectSource#los is deprecated in favor of PointEffectSource#shape.";
foundry.utils.logCompatibilityWarning(msg, { since: 11, until: 13});
this.shape = shape;
}
}
}

View File

@@ -0,0 +1,89 @@
import BaseLightSource from "./base-light-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseLightSource which renders a source of light as a point-based effect.
* @extends {BaseLightSource}
* @mixes {PointEffectSourceMixin}
*/
export default class PointLightSource extends PointEffectSourceMixin(BaseLightSource) {
/** @override */
static effectsCollection = "lightSources";
/* -------------------------------------------- */
/* Source Suppression Management */
/* -------------------------------------------- */
/**
* Update darkness suppression according to darkness sources collection.
*/
#updateDarknessSuppression() {
this.suppression.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
}
/* -------------------------------------------- */
/* Light Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
Object.assign(this.data, {
radius: Math.max(this.data.dim ?? 0, this.data.bright ?? 0)
});
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this.#updateDarknessSuppression();
super._createShapes();
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
this.ratio = Math.clamp(Math.abs(this.data.bright) / this.data.radius, 0, 1);
super._configure(changes);
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true, includeDarkness: true});
}
/* -------------------------------------------- */
/* Visibility Testing */
/* -------------------------------------------- */
/**
* Test whether this LightSource provides visibility to see a certain target object.
* @param {object} config The visibility test configuration
* @param {CanvasVisibilityTest[]} config.tests The sequence of tests to perform
* @param {PlaceableObject} config.object The target object being tested
* @returns {boolean} Is the target object visible to this source?
*/
testVisibility({tests, object}={}) {
if ( !(this.data.vision && this._canDetectObject(object)) ) return false;
return tests.some(test => this.shape.contains(test.point.x, test.point.y));
}
/* -------------------------------------------- */
/**
* Can this LightSource theoretically detect a certain object based on its properties?
* This check should not consider the relative positions of either object, only their state.
* @param {PlaceableObject} target The target object being tested
* @returns {boolean} Can the target object theoretically be detected by this vision source?
*/
_canDetectObject(target) {
const tgt = target?.document;
const isInvisible = ((tgt instanceof TokenDocument) && tgt.hasStatusEffect(CONFIG.specialStatusEffects.INVISIBLE));
return !isInvisible;
}
}

View File

@@ -0,0 +1,13 @@
import BaseEffectSource from "./base-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseEffectSource which describes a movement-based source.
* @extends {BaseEffectSource}
* @mixes {PointEffectSource}
*/
export default class PointMovementSource extends PointEffectSourceMixin(BaseEffectSource) {
/** @override */
static sourceType = "move";
}

View File

@@ -0,0 +1,46 @@
import BaseEffectSource from "./base-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
/**
* A specialized subclass of the BaseEffectSource which describes a point-based source of sound.
* @extends {BaseEffectSource}
* @mixes {PointEffectSource}
*/
export default class PointSoundSource extends PointEffectSourceMixin(BaseEffectSource) {
/** @override */
static sourceType = "sound";
/** @override */
get effectsCollection() {
return canvas.sounds.sources;
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {useThreshold: true});
}
/* -------------------------------------------- */
/**
* Get the effective volume at which an AmbientSound source should be played for a certain listener.
* @param {Point} listener
* @param {object} [options]
* @param {boolean} [options.easing]
* @returns {number}
*/
getVolumeMultiplier(listener, {easing=true}={}) {
if ( !listener ) return 0; // No listener = 0
const {x, y, radius} = this.data;
const distance = Math.hypot(listener.x - x, listener.y - y);
if ( distance === 0 ) return 1;
if ( distance > radius ) return 0; // Distance outside of radius = 0
if ( !this.shape?.contains(listener.x, listener.y) ) return 0; // Point outside of shape = 0
if ( !easing ) return 1; // No easing = 1
const dv = Math.clamp(distance, 0, radius) / radius;
return (Math.cos(Math.PI * dv) + 1) * 0.5; // Cosine easing [0, 1]
}
}

View File

@@ -0,0 +1,445 @@
import RenderedEffectSource from "./rendered-effect-source.mjs";
import PointEffectSourceMixin from "./point-effect-source.mjs";
import {LIGHTING_LEVELS} from "../../../common/constants.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
* @typedef {import("./rendered-effect-source-source.mjs").RenderedEffectSourceData} RenderedEffectSourceData
*/
/**
* @typedef {Object} VisionSourceData
* @property {number} contrast The amount of contrast
* @property {number} attenuation Strength of the attenuation between bright, dim, and dark
* @property {number} saturation The amount of color saturation
* @property {number} brightness The vision brightness.
* @property {string} visionMode The vision mode.
* @property {number} lightRadius The range of light perception.
* @property {boolean} blinded Is this vision source blinded?
*/
/**
* A specialized subclass of RenderedEffectSource which represents a source of point-based vision.
* @extends {RenderedEffectSource<BaseEffectSourceData & RenderedEffectSourceData & VisionSourceData, PointSourcePolygon>}
*/
export default class PointVisionSource extends PointEffectSourceMixin(RenderedEffectSource) {
/** @inheritdoc */
static sourceType = "sight";
/** @override */
static _initializeShaderKeys = ["visionMode", "blinded"];
/** @override */
static _refreshUniformsKeys = ["radius", "color", "attenuation", "brightness", "contrast", "saturation", "visionMode"];
/**
* The corresponding lighting levels for dim light.
* @type {number}
* @protected
*/
static _dimLightingLevel = LIGHTING_LEVELS.DIM;
/**
* The corresponding lighting levels for bright light.
* @type {string}
* @protected
*/
static _brightLightingLevel = LIGHTING_LEVELS.BRIGHT;
/** @inheritdoc */
static EDGE_OFFSET = -2;
/** @override */
static effectsCollection = "visionSources";
/** @inheritDoc */
static defaultData = {
...super.defaultData,
contrast: 0,
attenuation: 0.5,
saturation: 0,
brightness: 0,
visionMode: "basic",
lightRadius: null
}
/** @override */
static get _layers() {
return foundry.utils.mergeObject(super._layers, {
background: {
defaultShader: BackgroundVisionShader
},
coloration: {
defaultShader: ColorationVisionShader
},
illumination: {
defaultShader: IlluminationVisionShader
}
});
}
/* -------------------------------------------- */
/* Vision Source Attributes */
/* -------------------------------------------- */
/**
* The vision mode linked to this VisionSource
* @type {VisionMode|null}
*/
visionMode = null;
/**
* The vision mode activation flag for handlers
* @type {boolean}
* @internal
*/
_visionModeActivated = false;
/**
* The unconstrained LOS polygon.
* @type {PointSourcePolygon}
*/
los;
/**
* The polygon of light perception.
* @type {PointSourcePolygon}
*/
light;
/* -------------------------------------------- */
/**
* An alias for the shape of the vision source.
* @type {PointSourcePolygon|PIXI.Polygon}
*/
get fov() {
return this.shape;
}
/* -------------------------------------------- */
/**
* If this vision source background is rendered into the lighting container.
* @type {boolean}
*/
get preferred() {
return this.visionMode?.vision.preferred;
}
/* -------------------------------------------- */
/**
* Is the rendered source animated?
* @type {boolean}
*/
get isAnimated() {
return this.active && this.data.animation && this.visionMode?.animated;
}
/* -------------------------------------------- */
/**
* Light perception radius of this vision source, taking into account if the source is blinded.
* @type {number}
*/
get lightRadius() {
return this.#hasBlindedVisionMode ? 0 : (this.data.lightRadius ?? 0);
}
/* -------------------------------------------- */
/** @override */
get radius() {
return (this.#hasBlindedVisionMode ? this.data.externalRadius : this.data.radius) ?? 0;
}
/* -------------------------------------------- */
/* Point Vision Source Blinded Management */
/* -------------------------------------------- */
/**
* Is this source temporarily blinded?
* @type {boolean}
*/
get isBlinded() {
return (this.data.radius === 0) && ((this.data.lightRadius === 0) || !this.visionMode?.perceivesLight)
|| Object.values(this.blinded).includes(true);
};
/**
* Records of blinding strings with a boolean value.
* By default, if any of this record is true, the source is blinded.
* @type {Record<string, boolean>}
*/
blinded = {};
/**
* Data overrides that could happen with blindness vision mode.
* @type {object}
*/
visionModeOverrides = {};
/* -------------------------------------------- */
/**
* Update darkness blinding according to darkness sources collection.
*/
#updateBlindedState() {
this.blinded.darkness = canvas.effects.testInsideDarkness({x: this.x, y: this.y}, this.elevation);
}
/* -------------------------------------------- */
/**
* To know if blindness vision mode is configured for this source.
* Note: Convenient method used to avoid calling this.blinded which is costly.
* @returns {boolean}
*/
get #hasBlindedVisionMode() {
return this.visionMode === CONFIG.Canvas.visionModes.blindness;
}
/* -------------------------------------------- */
/* Vision Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
this.data.lightRadius ??= canvas.dimensions.maxR;
if ( this.data.lightRadius > 0 ) this.data.lightRadius = Math.max(this.data.lightRadius, this.data.externalRadius);
if ( this.data.radius > 0 ) this.data.radius = Math.max(this.data.radius, this.data.externalRadius);
if ( !(this.data.visionMode in CONFIG.Canvas.visionModes) ) this.data.visionMode = "basic";
}
/* -------------------------------------------- */
/** @inheritDoc */
_createShapes() {
this._updateVisionMode();
super._createShapes();
this.los = this.shape;
this.light = this._createLightPolygon();
this.shape = this._createRestrictedPolygon();
}
/* -------------------------------------------- */
/**
* Responsible for assigning the Vision Mode and calling the activation and deactivation handlers.
* @protected
*/
_updateVisionMode() {
const previousVM = this.visionMode;
this.visionMode = CONFIG.Canvas.visionModes[this.data.visionMode];
// Check blinding conditions
this.#updateBlindedState();
// Apply vision mode according to conditions
if ( this.isBlinded ) this.visionMode = CONFIG.Canvas.visionModes.blindness;
// Process vision mode overrides for blindness
const defaults = this.visionMode.vision.defaults;
const data = this.data;
const applyOverride = prop => this.#hasBlindedVisionMode && (defaults[prop] !== undefined) ? defaults[prop] : data[prop];
const blindedColor = applyOverride("color");
this.visionModeOverrides.colorRGB = blindedColor !== null ? Color.from(blindedColor).rgb : null;
this.visionModeOverrides.brightness = applyOverride("brightness");
this.visionModeOverrides.contrast = applyOverride("contrast");
this.visionModeOverrides.saturation = applyOverride("saturation");
this.visionModeOverrides.attenuation = applyOverride("attenuation");
// Process deactivation and activation handlers
if ( this.visionMode !== previousVM ) previousVM?.deactivate(this);
}
/* -------------------------------------------- */
/** @inheritDoc */
_configure(changes) {
this.visionMode.activate(this);
super._configure(changes);
this.animation.animation = this.visionMode.animate;
}
/* -------------------------------------------- */
/** @override */
_configureLayer(layer, layerId) {
const vmUniforms = this.visionMode.vision[layerId].uniforms;
layer.vmUniforms = Object.entries(vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_getPolygonConfiguration() {
return Object.assign(super._getPolygonConfiguration(), {
radius: this.data.disabled || this.suppressed ? 0 : (this.blinded.darkness
? this.data.externalRadius : canvas.dimensions.maxR),
useThreshold: true,
includeDarkness: true
});
}
/* -------------------------------------------- */
/**
* Creates the polygon that represents light perception.
* If the light perception radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
* @protected
*/
_createLightPolygon() {
return this.#createConstrainedPolygon(this.lightRadius);
}
/* -------------------------------------------- */
/**
* Create a restricted FOV polygon by limiting the radius of the unrestricted LOS polygon.
* If the vision radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
* @protected
*/
_createRestrictedPolygon() {
return this.#createConstrainedPolygon(this.radius || this.data.externalRadius);
}
/* -------------------------------------------- */
/**
* Create a constrained polygon by limiting the radius of the unrestricted LOS polygon.
* If the radius is unconstrained, no new polygon instance is created;
* instead the LOS polygon of this vision source is returned.
* @param {number} radius The radius to constraint to.
* @returns {PointSourcePolygon} The new polygon or `this.los`.
*/
#createConstrainedPolygon(radius) {
if ( radius >= this.los.config.radius ) return this.los;
const {x, y} = this.data;
const circle = new PIXI.Circle(x, y, radius);
const density = PIXI.Circle.approximateVertexDensity(radius);
return this.los.applyConstraint(circle, {density, scalingFactor: 100});
}
/* -------------------------------------------- */
/* Shader Management */
/* -------------------------------------------- */
/** @override */
_configureShaders() {
const vm = this.visionMode.vision;
const shaders = {};
for ( const layer in this.layers ) {
shaders[layer] = vm[`${layer.toLowerCase()}`]?.shader || this.layers[layer].defaultShader;
}
return shaders;
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateColorationUniforms() {
super._updateColorationUniforms();
const shader = this.layers.coloration.shader;
if ( !shader ) return;
const u = shader?.uniforms;
const d = shader.constructor.defaultUniforms;
u.colorEffect = this.visionModeOverrides.colorRGB ?? d.colorEffect;
u.useSampler = true;
const vmUniforms = this.layers.coloration.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateIlluminationUniforms() {
super._updateIlluminationUniforms();
const shader = this.layers.illumination.shader;
if ( !shader ) return;
shader.uniforms.useSampler = false; // We don't need to use the background sampler into vision illumination
const vmUniforms = this.layers.illumination.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateBackgroundUniforms() {
super._updateBackgroundUniforms();
const shader = this.layers.background.shader;
if ( !shader ) return;
const u = shader.uniforms;
u.technique = 0;
u.contrast = this.visionModeOverrides.contrast;
u.useSampler = true;
const vmUniforms = this.layers.background.vmUniforms;
if ( vmUniforms.length ) this._updateVisionModeUniforms(shader, vmUniforms);
}
/* -------------------------------------------- */
/** @inheritDoc */
_updateCommonUniforms(shader) {
const u = shader.uniforms;
const d = shader.constructor.defaultUniforms;
const c = canvas.colors;
// Passing common environment values
u.computeIllumination = true;
u.darknessLevel = canvas.environment.darknessLevel;
c.ambientBrightest.applyRGB(u.ambientBrightest);
c.ambientDarkness.applyRGB(u.ambientDarkness);
c.ambientDaylight.applyRGB(u.ambientDaylight);
u.weights[0] = canvas.environment.weights.dark;
u.weights[1] = canvas.environment.weights.halfdark;
u.weights[2] = canvas.environment.weights.dim;
u.weights[3] = canvas.environment.weights.bright;
u.dimLevelCorrection = this.constructor._dimLightingLevel;
u.brightLevelCorrection = this.constructor._brightLightingLevel;
// Vision values
const attenuation = this.visionModeOverrides.attenuation;
u.attenuation = Math.max(attenuation, 0.0125);
const brightness = this.visionModeOverrides.brightness;
u.brightness = (brightness + 1) / 2;
u.saturation = this.visionModeOverrides.saturation;
u.linkedToDarknessLevel = this.visionMode.vision.darkness.adaptive;
// Other values
u.elevation = this.data.elevation;
u.screenDimensions = canvas.screenDimensions;
u.colorTint = this.visionModeOverrides.colorRGB ?? d.colorTint;
// Textures
if ( !u.depthTexture ) u.depthTexture = canvas.masks.depth.renderTexture;
if ( !u.primaryTexture ) u.primaryTexture = canvas.primary.renderTexture;
if ( !u.darknessLevelTexture ) u.darknessLevelTexture = canvas.effects.illumination.renderTexture;
}
/* -------------------------------------------- */
/**
* Update layer uniforms according to vision mode uniforms, if any.
* @param {AdaptiveVisionShader} shader The shader being updated.
* @param {Array} vmUniforms The targeted layer.
* @protected
*/
_updateVisionModeUniforms(shader, vmUniforms) {
const shaderUniforms = shader.uniforms;
for ( const [uniform, value] of vmUniforms ) {
if ( Array.isArray(value) ) {
const u = (shaderUniforms[uniform] ??= []);
for ( const i in value ) u[i] = value[i];
}
else shaderUniforms[uniform] = value;
}
}
}

View File

@@ -0,0 +1,625 @@
import BaseEffectSource from "./base-effect-source.mjs";
/**
* @typedef {import("./base-effect-source.mjs").BaseEffectSourceData} BaseEffectSourceData
*/
/**
* @typedef {Object} RenderedEffectSourceData
* @property {object} animation An animation configuration for the source
* @property {number|null} color A color applied to the rendered effect
* @property {number|null} seed An integer seed to synchronize (or de-synchronize) animations
* @property {boolean} preview Is this source a temporary preview?
*/
/**
* @typedef {Object} RenderedEffectSourceAnimationConfig
* @property {string} [label] The human-readable (localized) label for the animation
* @property {Function} [animation] The animation function that runs every frame
* @property {AdaptiveIlluminationShader} [illuminationShader] A custom illumination shader used by this animation
* @property {AdaptiveColorationShader} [colorationShader] A custom coloration shader used by this animation
* @property {AdaptiveBackgroundShader} [backgroundShader] A custom background shader used by this animation
* @property {AdaptiveDarknessShader} [darknessShader] A custom darkness shader used by this animation
* @property {number} [seed] The animation seed
* @property {number} [time] The animation time
*/
/**
* @typedef {Object} RenderedEffectLayerConfig
* @property {AdaptiveLightingShader} defaultShader The default shader used by this layer
* @property {PIXI.BLEND_MODES} blendMode The blend mode used by this layer
*/
/**
* An abstract class which extends the base PointSource to provide common functionality for rendering.
* This class is extended by both the LightSource and VisionSource subclasses.
* @extends {BaseEffectSource<BaseEffectSourceData & RenderedEffectSourceData>}
* @abstract
*/
export default class RenderedEffectSource extends BaseEffectSource {
/**
* Keys of the data object which require shaders to be re-initialized.
* @type {string[]}
* @protected
*/
static _initializeShaderKeys = ["animation.type"];
/**
* Keys of the data object which require uniforms to be refreshed.
* @type {string[]}
* @protected
*/
static _refreshUniformsKeys = [];
/**
* Layers handled by this rendered source.
* @type {Record<string, RenderedEffectLayerConfig>}
* @protected
*/
static get _layers() {
return {
background: {
defaultShader: AdaptiveBackgroundShader,
blendMode: "MAX_COLOR"
},
coloration: {
defaultShader: AdaptiveColorationShader,
blendMode: "SCREEN"
},
illumination: {
defaultShader: AdaptiveIlluminationShader,
blendMode: "MAX_COLOR"
}
};
}
/**
* The offset in pixels applied to create soft edges.
* @type {number}
*/
static EDGE_OFFSET = -8;
/** @inheritDoc */
static defaultData = {
...super.defaultData,
animation: {},
seed: null,
preview: false,
color: null
}
/* -------------------------------------------- */
/* Rendered Source Attributes */
/* -------------------------------------------- */
/**
* The animation configuration applied to this source
* @type {RenderedEffectSourceAnimationConfig}
*/
animation = {};
/**
* @typedef {Object} RenderedEffectSourceLayer
* @property {boolean} active Is this layer actively rendered?
* @property {boolean} reset Do uniforms need to be reset?
* @property {boolean} suppressed Is this layer temporarily suppressed?
* @property {PointSourceMesh} mesh The rendered mesh for this layer
* @property {AdaptiveLightingShader} shader The shader instance used for the layer
*/
/**
* Track the status of rendering layers
* @type {{
* background: RenderedEffectSourceLayer,
* coloration: RenderedEffectSourceLayer,
* illumination: RenderedEffectSourceLayer
* }}
*/
layers = Object.entries(this.constructor._layers).reduce((obj, [layer, config]) => {
obj[layer] = {active: true, reset: true, suppressed: false,
mesh: undefined, shader: undefined, defaultShader: config.defaultShader,
vmUniforms: undefined, blendMode: config.blendMode};
return obj;
}, {});
/**
* Array of update uniforms functions.
* @type {Function[]}
*/
#updateUniformsFunctions = (() => {
const initializedFunctions = [];
for ( const layer in this.layers ) {
const fn = this[`_update${layer.titleCase()}Uniforms`];
if ( fn ) initializedFunctions.push(fn);
}
return initializedFunctions;
})();
/**
* The color of the source as an RGB vector.
* @type {[number, number, number]|null}
*/
colorRGB = null;
/**
* PIXI Geometry generated to draw meshes.
* @type {PIXI.Geometry|null}
* @protected
*/
_geometry = null;
/* -------------------------------------------- */
/* Source State */
/* -------------------------------------------- */
/**
* Is the rendered source animated?
* @type {boolean}
*/
get isAnimated() {
return this.active && this.data.animation?.type;
}
/**
* Has the rendered source at least one active layer?
* @type {boolean}
*/
get hasActiveLayer() {
return this.#hasActiveLayer;
}
#hasActiveLayer = false;
/**
* Is this RenderedEffectSource a temporary preview?
* @returns {boolean}
*/
get isPreview() {
return !!this.data.preview;
}
/* -------------------------------------------- */
/* Rendered Source Properties */
/* -------------------------------------------- */
/**
* A convenience accessor to the background layer mesh.
* @type {PointSourceMesh}
*/
get background() {
return this.layers.background.mesh;
}
/**
* A convenience accessor to the coloration layer mesh.
* @type {PointSourceMesh}
*/
get coloration() {
return this.layers.coloration.mesh;
}
/**
* A convenience accessor to the illumination layer mesh.
* @type {PointSourceMesh}
*/
get illumination() {
return this.layers.illumination.mesh;
}
/* -------------------------------------------- */
/* Rendered Source Initialization */
/* -------------------------------------------- */
/** @inheritDoc */
_initialize(data) {
super._initialize(data);
const color = Color.from(this.data.color ?? null);
this.data.color = color.valid ? color.valueOf() : null;
const seed = this.data.seed ?? this.animation.seed ?? Math.floor(Math.random() * 100000);
this.animation = this.data.animation = {seed, ...this.data.animation};
// Initialize the color attributes
const hasColor = this._flags.hasColor = (this.data.color !== null);
if ( hasColor ) Color.applyRGB(color, this.colorRGB ??= [0, 0, 0]);
else this.colorRGB = null;
// We need to update the hasColor uniform attribute immediately
for ( const layer of Object.values(this.layers) ) {
if ( layer.shader ) layer.shader.uniforms.hasColor = hasColor;
}
this._initializeSoftEdges();
}
/* -------------------------------------------- */
/**
* Decide whether to render soft edges with a blur.
* @protected
*/
_initializeSoftEdges() {
this._flags.renderSoftEdges = canvas.performance.lightSoftEdges && !this.isPreview;
}
/* -------------------------------------------- */
/** @override */
_configure(changes) {
// To know if we need a first time initialization of the shaders
const initializeShaders = !this._geometry;
// Initialize meshes using the computed shape
this.#initializeMeshes();
// Initialize shaders
if ( initializeShaders || this.constructor._initializeShaderKeys.some(k => k in changes) ) {
this.#initializeShaders();
}
// Refresh uniforms
else if ( this.constructor._refreshUniformsKeys.some(k => k in changes) ) {
for ( const config of Object.values(this.layers) ) {
config.reset = true;
}
}
// Update the visible state the layers
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/**
* Configure which shaders are used for each rendered layer.
* @returns {{
* background: AdaptiveLightingShader,
* coloration: AdaptiveLightingShader,
* illumination: AdaptiveLightingShader
* }}
* @private
*/
_configureShaders() {
const a = this.animation;
const shaders = {};
for ( const layer in this.layers ) {
shaders[layer] = a[`${layer.toLowerCase()}Shader`] || this.layers[layer].defaultShader;
}
return shaders;
}
/* -------------------------------------------- */
/**
* Specific configuration for a layer.
* @param {object} layer
* @param {string} layerId
* @protected
*/
_configureLayer(layer, layerId) {}
/* -------------------------------------------- */
/**
* Initialize the shaders used for this source, swapping to a different shader if the animation has changed.
*/
#initializeShaders() {
const shaders = this._configureShaders();
for ( const [layerId, layer] of Object.entries(this.layers) ) {
layer.shader = RenderedEffectSource.#createShader(shaders[layerId], layer.mesh);
this._configureLayer(layer, layerId);
}
this.#updateUniforms();
Hooks.callAll(`initialize${this.constructor.name}Shaders`, this);
}
/* -------------------------------------------- */
/**
* Create a new shader using a provider shader class
* @param {typeof AdaptiveLightingShader} cls The shader class to create
* @param {PointSourceMesh} container The container which requires a new shader
* @returns {AdaptiveLightingShader} The shader instance used
*/
static #createShader(cls, container) {
const current = container.shader;
if ( current?.constructor === cls ) return current;
const shader = cls.create({
primaryTexture: canvas.primary.renderTexture
});
shader.container = container;
container.shader = shader;
container.uniforms = shader.uniforms;
if ( current ) current.destroy();
return shader;
}
/* -------------------------------------------- */
/**
* Initialize the geometry and the meshes.
*/
#initializeMeshes() {
this._updateGeometry();
if ( !this._flags.initializedMeshes ) this.#createMeshes();
}
/* -------------------------------------------- */
/**
* Create meshes for each layer of the RenderedEffectSource that is drawn to the canvas.
*/
#createMeshes() {
if ( !this._geometry ) return;
const shaders = this._configureShaders();
for ( const [l, layer] of Object.entries(this.layers) ) {
layer.mesh = this.#createMesh(shaders[l]);
layer.mesh.blendMode = PIXI.BLEND_MODES[layer.blendMode];
layer.shader = layer.mesh.shader;
}
this._flags.initializedMeshes = true;
}
/* -------------------------------------------- */
/**
* Create a new Mesh for this source using a provided shader class
* @param {typeof AdaptiveLightingShader} shaderCls The shader class used for this mesh
* @returns {PointSourceMesh} The created Mesh
*/
#createMesh(shaderCls) {
const state = new PIXI.State();
const mesh = new PointSourceMesh(this._geometry, shaderCls.create(), state);
mesh.drawMode = PIXI.DRAW_MODES.TRIANGLES;
mesh.uniforms = mesh.shader.uniforms;
mesh.cullable = true;
return mesh;
}
/* -------------------------------------------- */
/**
* Create the geometry for the source shape that is used in shaders and compute its bounds for culling purpose.
* Triangulate the form and create buffers.
* @protected
* @abstract
*/
_updateGeometry() {}
/* -------------------------------------------- */
/* Rendered Source Canvas Rendering */
/* -------------------------------------------- */
/**
* Render the containers used to represent this light source within the LightingLayer
* @returns {{background: PIXI.Mesh, coloration: PIXI.Mesh, illumination: PIXI.Mesh}}
*/
drawMeshes() {
const meshes = {};
for ( const layerId of Object.keys(this.layers) ) {
meshes[layerId] = this._drawMesh(layerId);
}
return meshes;
}
/* -------------------------------------------- */
/**
* Create a Mesh for a certain rendered layer of this source.
* @param {string} layerId The layer key in layers to draw
* @returns {PIXI.Mesh|null} The drawn mesh for this layer, or null if no mesh is required
* @protected
*/
_drawMesh(layerId) {
const layer = this.layers[layerId];
const mesh = layer.mesh;
if ( layer.reset ) {
const fn = this[`_update${layerId.titleCase()}Uniforms`];
fn.call(this);
}
if ( !layer.active ) {
mesh.visible = false;
return null;
}
// Update the mesh
const {x, y} = this.data;
mesh.position.set(x, y);
mesh.visible = mesh.renderable = true;
return layer.mesh;
}
/* -------------------------------------------- */
/* Rendered Source Refresh */
/* -------------------------------------------- */
/** @override */
_refresh() {
this.#updateUniforms();
this.#updateVisibleLayers();
}
/* -------------------------------------------- */
/**
* Update uniforms for all rendered layers.
*/
#updateUniforms() {
for ( const updateUniformsFunction of this.#updateUniformsFunctions ) updateUniformsFunction.call(this);
}
/* -------------------------------------------- */
/**
* Update the visible state of the component channels of this RenderedEffectSource.
* @returns {boolean} Is there an active layer?
*/
#updateVisibleLayers() {
const active = this.active;
let hasActiveLayer = false;
for ( const layer of Object.values(this.layers) ) {
layer.active = active && (layer.shader?.isRequired !== false);
if ( layer.active ) hasActiveLayer = true;
}
this.#hasActiveLayer = hasActiveLayer;
}
/* -------------------------------------------- */
/**
* Update shader uniforms used by every rendered layer.
* @param {AbstractBaseShader} shader
* @protected
*/
_updateCommonUniforms(shader) {}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the background layer.
* @protected
*/
_updateBackgroundUniforms() {
const shader = this.layers.background.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the coloration layer.
* @protected
*/
_updateColorationUniforms() {
const shader = this.layers.coloration.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/**
* Update shader uniforms used for the illumination layer.
* @protected
*/
_updateIlluminationUniforms() {
const shader = this.layers.illumination.shader;
if ( !shader ) return;
this._updateCommonUniforms(shader);
}
/* -------------------------------------------- */
/* Rendered Source Destruction */
/* -------------------------------------------- */
/** @override */
_destroy() {
for ( const layer of Object.values(this.layers) ) layer.mesh?.destroy();
this._geometry?.destroy();
}
/* -------------------------------------------- */
/* Animation Functions */
/* -------------------------------------------- */
/**
* Animate the PointSource, if an animation is enabled and if it currently has rendered containers.
* @param {number} dt Delta time.
*/
animate(dt) {
if ( !this.isAnimated ) return;
const {animation, ...options} = this.animation;
return animation?.call(this, dt, options);
}
/* -------------------------------------------- */
/**
* Generic time-based animation used for Rendered Point Sources.
* @param {number} dt Delta time.
* @param {object} [options] Options which affect the time animation
* @param {number} [options.speed=5] The animation speed, from 0 to 10
* @param {number} [options.intensity=5] The animation intensity, from 1 to 10
* @param {boolean} [options.reverse=false] Reverse the animation direction
*/
animateTime(dt, {speed=5, intensity=5, reverse=false}={}) {
// Determine the animation timing
let t = canvas.app.ticker.lastTime;
if ( reverse ) t *= -1;
this.animation.time = ( (speed * t) / 5000 ) + this.animation.seed;
// Update uniforms
for ( const layer of Object.values(this.layers) ) {
const u = layer.mesh.uniforms;
u.time = this.animation.time;
u.intensity = intensity;
}
}
/* -------------------------------------------- */
/* Static Helper Methods */
/* -------------------------------------------- */
/**
* Get corrected level according to level and active vision mode data.
* @param {VisionMode.LIGHTING_LEVELS} level
* @returns {number} The corrected level.
*/
static getCorrectedLevel(level) {
// Retrieving the lighting mode and the corrected level, if any
const lightingOptions = canvas.visibility.visionModeData?.activeLightingOptions;
return (lightingOptions?.levels?.[level]) ?? level;
}
/* -------------------------------------------- */
/**
* Get corrected color according to level, dim color, bright color and background color.
* @param {VisionMode.LIGHTING_LEVELS} level
* @param {Color} colorDim
* @param {Color} colorBright
* @param {Color} [colorBackground]
* @returns {Color}
*/
static getCorrectedColor(level, colorDim, colorBright, colorBackground) {
colorBackground ??= canvas.colors.background;
// Returning the corrected color according to the lighting options
const levels = VisionMode.LIGHTING_LEVELS;
switch ( this.getCorrectedLevel(level) ) {
case levels.HALFDARK:
case levels.DIM: return colorDim;
case levels.BRIGHT:
case levels.DARKNESS: return colorBright;
case levels.BRIGHTEST: return canvas.colors.ambientBrightest;
case levels.UNLIT: return colorBackground;
default: return colorDim;
}
}
/* -------------------------------------------- */
/* Deprecations and Compatibility */
/* -------------------------------------------- */
/**
* @deprecated since v11
* @ignore
*/
get preview() {
const msg = "The RenderedEffectSource#preview is deprecated. Use RenderedEffectSource#isPreview instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
return this.isPreview;
}
/**
* @deprecated since v11
* @ignore
*/
set preview(preview) {
const msg = "The RenderedEffectSource#preview is deprecated. "
+ "Set RenderedEffectSource#preview as part of RenderedEffectSource#initialize instead.";
foundry.utils.logCompatibilityWarning(msg, {since: 11, until: 13});
this.data.preview = preview;
}
}