UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

280 lines 12.6 kB
import { isDevEnvironment } from "../debug/index.js"; import { DeviceUtilities, getParam } from "../engine_utils.js"; const debug = getParam("debugpost"); /** * Core postprocessing stack accessible via `context.postprocessing`. * Manages the effect pipeline independently of any specific component. * * Volumes and individual PostProcessingEffect components add/remove effects * to this stack. The stack builds the EffectComposer pipeline when dirty. * * @example Add an effect directly * ```ts * const bloom = new BloomEffect({ intensity: 3 }); * this.context.postprocessing.addEffect(bloom); * ``` * * @example Remove an effect * ```ts * this.context.postprocessing.removeEffect(bloom); * ``` */ export class PostProcessing { _context; _handler = null; _effects = []; _isDirty = false; /** Currently active postprocessing effects in the stack */ get effects() { return this._effects; } get dirty() { return this._isDirty; } set dirty(value) { this._isDirty = value; } /** The internal PostProcessingHandler that manages the EffectComposer pipeline */ get handler() { return this._handler; } /** * The effect composer used to render postprocessing effects. * This is set internally by the PostProcessingHandler when effects are applied. */ get composer() { return this._composer; } set composer(value) { this._composer = value; } _composer = null; /** * Set multisampling to "auto" to automatically adjust the multisampling level based on performance. * Set to a number to manually set the multisampling level. * @default "auto" */ multisampling = "auto"; /** When enabled, the device pixel ratio will be gradually reduced when FPS is low * and restored when performance recovers. * @default true */ adaptiveResolution = true; constructor(context) { this._context = context; } /** * Add a post processing effect to the stack. * The effect stack will be rebuilt on the next update. */ addEffect(effect) { if (this._effects.includes(effect)) return; this._effects.push(effect); this._isDirty = true; } /** * Remove a post processing effect from the stack. * The effect stack will be rebuilt on the next update. */ removeEffect(effect) { const index = this._effects.indexOf(effect); if (index !== -1) { this._effects.splice(index, 1); this._isDirty = true; } } /** Mark the stack as dirty so the effects are rebuilt on the next update */ markDirty() { this._isDirty = true; } // --- Adaptive multisampling state --- _enabledTime = -1; _multisampleAutoChangeTime = 0; _multisampleAutoDecreaseTime = 0; /** @internal Called from the context render loop to update the postprocessing pipeline */ update() { const context = this._context; if (context.isInXR) return; // Wait for a camera before applying if (this._isDirty && context.mainCamera) { this.apply(); } // In tonemapping-only mode, keep renderer values in sync with the active effect if (this._tonemappingOnlyActive) { const activeEffects = this._effects.filter(e => e.active && e.enabled && e.isToneMapping === true); if (activeEffects.length > 0) { const effect = activeEffects[activeEffects.length - 1]; context.renderer.toneMapping = effect.threeToneMapping; context.renderer.toneMappingExposure = effect.toneMappingExposure; } return; } if (!this._handler || !this._composer || this._handler.composer !== this._composer) return; // The composer is always a pmndrs EffectComposer (created by PostProcessingHandler) const composer = this._composer; // Handle context lost if (context.renderer.getContext().isContextLost()) { context.renderer.forceContextRestore(); } if (composer.getRenderer() !== context.renderer) composer.setRenderer(context.renderer); composer.setMainScene(context.scene); // --- Adaptive multisampling --- if (this.multisampling === "auto") { if (this._handler.hasSmaaEffect) { if (this._handler.multisampling !== 0) { this._handler.multisampling = 0; if (debug || isDevEnvironment()) { console.log(`[PostProcessing] multisampling is disabled because it's set to 'auto' and there is an SMAA effect.\n\nIf you need multisampling consider changing 'auto' to a fixed value (e.g. 4).`); } } } else { const timeSinceLastChange = context.time.realtimeSinceStartup - this._multisampleAutoChangeTime; if (context.time.realtimeSinceStartup - this._enabledTime > 2 && timeSinceLastChange > .5) { const prev = this._handler.multisampling; if (this._handler.multisampling > 0 && context.time.smoothedFps <= 50) { this._multisampleAutoChangeTime = context.time.realtimeSinceStartup; this._multisampleAutoDecreaseTime = context.time.realtimeSinceStartup; let newMultiSample = this._handler.multisampling * .5; newMultiSample = Math.floor(newMultiSample); if (newMultiSample != this._handler.multisampling) { this._handler.multisampling = newMultiSample; } if (debug) console.debug(`[PostProcessing] Reduced multisampling from ${prev} to ${this._handler.multisampling}`); } else if (timeSinceLastChange > 1 && context.time.smoothedFps >= 59 && this._handler.multisampling < context.renderer.capabilities.maxSamples && context.time.realtimeSinceStartup - this._multisampleAutoDecreaseTime > 10) { this._multisampleAutoChangeTime = context.time.realtimeSinceStartup; let newMultiSample = this._handler.multisampling <= 0 ? 1 : this._handler.multisampling * 2; newMultiSample = Math.floor(newMultiSample); if (newMultiSample !== this._handler.multisampling) { this._handler.multisampling = newMultiSample; } if (debug) console.debug(`[PostProcessing] Increased multisampling from ${prev} to ${this._handler.multisampling}`); } } } } else { const newMultiSample = Math.max(0, Math.min(this.multisampling, context.renderer.capabilities.maxSamples)); if (newMultiSample !== this._handler.multisampling) this._handler.multisampling = newMultiSample; } // --- Adaptive pixel ratio --- this._handler.adaptivePixelRatio = this.adaptiveResolution; this._handler.updateAdaptivePixelRatio(); // Update camera on passes if needed if (context.mainCamera) { const passes = composer.passes; for (const pass of passes) { if (pass.mainCamera && pass.mainCamera !== context.mainCamera) { composer.setMainCamera(context.mainCamera); break; } } } } _lastApplyTime; _rapidApplyCount = 0; // --- Tonemapping-only state --- /** When true, tonemapping is applied directly to the renderer (no full pipeline) */ _tonemappingOnlyActive = false; _previousToneMapping; _previousToneMappingExposure; apply() { if (debug) console.log(`[PostProcessing] Apply stack (${this._effects.length} effects)`); if (isDevEnvironment()) { if (this._lastApplyTime !== undefined && Date.now() - this._lastApplyTime < 100) { this._rapidApplyCount++; if (this._rapidApplyCount === 5) console.warn("[PostProcessing] Detected rapid post processing modifications - this might be a bug"); } this._lastApplyTime = Date.now(); } this._isDirty = false; // Collect active effects const activeEffects = this._effects.filter(e => e.active && e.enabled); if (activeEffects.length <= 0) { this.restoreTonemapping(); this._handler?.unapply(false); return; } // Check if ALL active effects are tonemapping-only const allToneMapping = activeEffects.every(e => e.isToneMapping === true); if (allToneMapping) { // Use the last tonemapping effect added (last in the array) const tonemappingEffect = activeEffects[activeEffects.length - 1]; if (debug) console.log(`[PostProcessing] Only tonemapping effects in stack — applying directly to renderer`); // Store previous values on first activation if (!this._tonemappingOnlyActive) { this._previousToneMapping = this._context.renderer.toneMapping; this._previousToneMappingExposure = this._context.renderer.toneMappingExposure; this._tonemappingOnlyActive = true; } // Apply tonemapping directly to renderer this._context.renderer.toneMapping = tonemappingEffect.threeToneMapping; this._context.renderer.toneMappingExposure = tonemappingEffect.toneMappingExposure; // Tear down any existing postprocessing pipeline this._handler?.unapply(false); return; } // We have non-tonemapping effects — restore renderer tonemapping if we were in tonemapping-only mode this.restoreTonemapping(); // Build full postprocessing pipeline this.ensureHandler() .then(handler => { if (!handler) return; return handler.apply(activeEffects); }) .then(() => { if (this._handler) { if (this.multisampling === "auto") { this._handler.multisampling = DeviceUtilities.isMobileDevice() ? 2 : 4; } else { this._handler.multisampling = Math.max(0, Math.min(this.multisampling, this._context.renderer.capabilities.maxSamples)); } if (debug) console.debug(`[PostProcessing] Set multisampling to ${this._handler.multisampling} (Is Mobile: ${DeviceUtilities.isMobileDevice()})`); } }); this._enabledTime = this._context.time.realtimeSinceStartup; } /** Restore renderer tonemapping to previous values when leaving tonemapping-only mode */ restoreTonemapping() { if (this._tonemappingOnlyActive) { if (this._previousToneMapping !== undefined) this._context.renderer.toneMapping = this._previousToneMapping; if (this._previousToneMappingExposure !== undefined) this._context.renderer.toneMappingExposure = this._previousToneMappingExposure; this._tonemappingOnlyActive = false; this._previousToneMapping = undefined; this._previousToneMappingExposure = undefined; if (debug) console.log(`[PostProcessing] Restored renderer tonemapping`); } } /** Lazily creates the PostProcessingHandler to avoid loading the postprocessing library until actually needed */ async ensureHandler() { if (!this._handler) { const { PostProcessingHandler } = await import("../../engine-components/postprocessing/PostProcessingHandler.js"); if (!this._handler) { this._handler = new PostProcessingHandler(this._context); } } return this._handler; } /** @internal */ dispose() { this.restoreTonemapping(); this._handler?.dispose(); this._handler = null; this._composer = null; this._effects.length = 0; } } //# sourceMappingURL=postprocessing.js.map