@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
JavaScript
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