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} * @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} * @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; } }