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.

323 lines (276 loc) 13.7 kB
import type { EffectComposer } from "postprocessing"; import type { ToneMapping } from "three"; import type { EffectComposer as ThreeEffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js"; import { isDevEnvironment } from "../debug/index.js"; import type { Context } from "../engine_context.js"; import { DeviceUtilities, getParam } from "../engine_utils.js"; import type { IPostProcessingEffect, IPostProcessingHandler, ITonemappingEffect } from "./types.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 { private readonly _context: Context; private _handler: IPostProcessingHandler | null = null; private readonly _effects: IPostProcessingEffect[] = []; private _isDirty: boolean = false; /** Currently active postprocessing effects in the stack */ get effects(): readonly IPostProcessingEffect[] { return this._effects; } get dirty() { return this._isDirty; } set dirty(value: boolean) { this._isDirty = value; } /** The internal PostProcessingHandler that manages the EffectComposer pipeline */ get handler(): IPostProcessingHandler | null { return this._handler; } /** * The effect composer used to render postprocessing effects. * This is set internally by the PostProcessingHandler when effects are applied. */ get composer(): EffectComposer | ThreeEffectComposer | null { return this._composer; } set composer(value: EffectComposer | ThreeEffectComposer | null) { this._composer = value; } private _composer: EffectComposer | ThreeEffectComposer | null = 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" | number = "auto"; /** When enabled, the device pixel ratio will be gradually reduced when FPS is low * and restored when performance recovers. * @default true */ adaptiveResolution: boolean = true; constructor(context: Context) { this._context = context; } /** * Add a post processing effect to the stack. * The effect stack will be rebuilt on the next update. */ addEffect(effect: IPostProcessingEffect): void { 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: IPostProcessingEffect): void { 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(): void { this._isDirty = true; } // --- Adaptive multisampling state --- private _enabledTime: number = -1; private _multisampleAutoChangeTime: number = 0; private _multisampleAutoDecreaseTime: number = 0; /** @internal Called from the context render loop to update the postprocessing pipeline */ update(): void { 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] as ITonemappingEffect; 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 as EffectComposer; // 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 as number, 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; } } } } private _lastApplyTime?: number; private _rapidApplyCount = 0; // --- Tonemapping-only state --- /** When true, tonemapping is applied directly to the renderer (no full pipeline) */ private _tonemappingOnlyActive = false; private _previousToneMapping?: ToneMapping; private _previousToneMappingExposure?: number; private 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] as ITonemappingEffect; 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 as 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) as Promise<void> | void; }) .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 as number, 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 */ private 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 */ private async ensureHandler(): Promise<IPostProcessingHandler> { 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; } }