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.

352 lines (304 loc) • 13.4 kB
import type { Effect } from "postprocessing"; import { showBalloonMessage } from "../../engine/debug/index.js"; import type { EditorModification, IEditorModification as IEditorModificationReceiver } from "../../engine/engine_editor-sync.js"; import { serializeable } from "../../engine/engine_serialization_decorator.js"; import { getParam } from "../../engine/engine_utils.js"; import type { PostProcessing } from "../../engine/postprocessing/index.js"; import { Behaviour } from "../Component.js"; import { EffectWrapper } from "./Effects/EffectWrapper.js"; import { PostProcessingEffect } from "./PostProcessingEffect.js"; import { IPostProcessingManager, setPostprocessingManagerType } from "./utils.js"; import { VolumeParameter } from "./VolumeParameter.js"; import { VolumeProfile } from "./VolumeProfile.js"; const debug = getParam("debugpost"); /** [Volume](https://engine.needle.tools/docs/api/Volume) The Volume/PostprocessingManager component is responsible for managing post processing effects. * Add this component to any object in your scene to enable post processing effects. * * Effects added to this Volume (via profile or code) are pushed to `context.postprocessing` when the Volume is enabled, * and removed when it is disabled. * * @example Add bloom * ```ts * const volume = new Volume(); * volume.addEffect(new BloomEffect({ * intensity: 3, * luminanceThreshold: .2 * })); * gameObject.addComponent(volume); * ``` * * @example Remove bloom * ```ts * volume.removeEffect(bloom); * ``` * * @example Add pixelation * ```ts * const pixelation = new PixelationEffect(); * pixelation.granularity.value = 10; * volume.addEffect(pixelation); * ``` * * @summary Manage Post-Processing Effects * @category Rendering * @category Effects * @see {@link VolumeProfile} for profile-based effect management * @see {@link PostProcessingEffect} for creating custom effects * @see {@link PostProcessing} for core Needle Engine postprocessing control, also accessible via `context.postprocessing` * @group Components */ export class Volume extends Behaviour implements IEditorModificationReceiver, IPostProcessingManager { get isPostProcessingManager() { return true; } /** Currently active postprocessing effects managed by this Volume */ get effects() { return this._activeEffects; } get dirty() { return this._isDirty; } set dirty(value: boolean) { this._isDirty = value; } @serializeable(VolumeProfile) sharedProfile?: VolumeProfile; /** * Set multisampling to "auto" to automatically adjust the multisampling level based on performance. * Set to a number to manually set the multisampling level. * Pushed to `context.postprocessing.multisampling` when this Volume is active. * @default "auto" * @min 0 * @max renderer.capabilities.maxSamples */ @serializeable() multisampling: "auto" | number = "auto"; /** When enabled, the device pixel ratio will be gradually reduced when FPS is low * and restored when performance recovers. This helps maintain smooth frame rates * on devices where full retina resolution is too expensive for postprocessing. * Pushed to `context.postprocessing.adaptiveResolution` when this Volume is active. * Disable this if you need a fixed resolution and prefer consistent quality over frame rate. * @default true */ @serializeable() adaptiveResolution: boolean = true; /** * Add a post processing effect to this Volume and push it to the core postprocessing stack. */ addEffect<T extends PostProcessingEffect | Effect>(effect: T & { order?: number }): T { let entry = effect as PostProcessingEffect; if (!(entry instanceof PostProcessingEffect)) { entry = new EffectWrapper(entry); if (typeof effect.order === "number") entry.order = effect.order; } if (entry.gameObject === undefined) this.gameObject.addComponent(entry); if (this._effects.includes(entry)) return effect; this._effects.push(entry); // Track as active so removeEffectsFromCore cleans it up on disable if (!this._activeEffects.includes(entry)) this._activeEffects.push(entry); // Push to core stack this.context.postprocessing.addEffect(entry); this._isDirty = true; return effect; } /** * Remove a post processing effect from this Volume and the core postprocessing stack. */ removeEffect<T extends PostProcessingEffect | Effect>(effect: T): T { let index = -1; let entry: PostProcessingEffect | undefined; if (!(effect instanceof PostProcessingEffect)) { index = this._effects.findIndex(e => e instanceof EffectWrapper && e.effect === effect); if (index !== -1) entry = this._effects[index]; } else { index = this._effects.indexOf(effect); if (index !== -1) entry = this._effects[index]; } if (index !== -1 && entry) { this._effects.splice(index, 1); this.context.postprocessing.removeEffect(entry); this._isDirty = true; return effect; } else if (effect instanceof PostProcessingEffect) { // if the effect is part of the shared profile remove it from there const si = this.sharedProfile?.components?.indexOf(effect); if (si !== undefined && si !== -1) { this.sharedProfile?.components?.splice(si, 1); this.context.postprocessing.removeEffect(effect); this._isDirty = true; } } return effect; } private readonly _activeEffects: PostProcessingEffect[] = []; private readonly _effects: PostProcessingEffect[] = []; /** * When dirty the post processing effects will be re-applied */ markDirty(): void { this._isDirty = true; } /** @internal */ awake() { if (debug) { console.log("PostprocessingManager Awake", this); console.log("Press P to toggle post processing"); window.addEventListener("keydown", (e) => { if (e.key === "p") { this.enabled = !this.enabled; showBalloonMessage("Toggle PostProcessing " + this.name + ": Enabled=" + this.enabled); this.markDirty(); } }); } // ensure the profile is initialized this.sharedProfile?.__init(this); } private _isDirty: boolean = false; /** @internal */ onEnable(): void { this._isDirty = true; this.pushEffectsToCore(); this.syncConfigToCore(); } /** @internal */ onDisable() { this.removeEffectsFromCore(); this._isDirty = false; } /** @internal */ onBeforeRender(): void { if (this._isDirty) { this.removeEffectsFromCore(); this.pushEffectsToCore(); this._isDirty = false; } // Push config changes (user may change multisampling/adaptiveResolution at runtime) this.syncConfigToCore(); // Process queued editor modifications after the handler is ready if (this.context.postprocessing.handler) { this._applyPostQueue(); } } /** @internal */ onDestroy(): void { this.removeEffectsFromCore(); } /** Collect active effects from profile + code and push them to context.postprocessing */ private pushEffectsToCore() { this._activeEffects.length = 0; // get from profile if (this.sharedProfile?.components) { const comps = this.sharedProfile.components; for (const effect of comps) { if (effect.active && effect.enabled && !this._activeEffects.includes(effect)) this._activeEffects.push(effect); } } // add effects registered via code for (const effect of this._effects) { if (effect.active && effect.enabled && !this._activeEffects.includes(effect)) this._activeEffects.push(effect); } const pp = this.context.postprocessing; for (const effect of this._activeEffects) { pp.addEffect(effect); } } /** Remove all effects this Volume contributed from the core stack */ private removeEffectsFromCore() { const pp = this.context.postprocessing; for (const effect of this._activeEffects) { pp.removeEffect(effect); } this._activeEffects.length = 0; } /** Push multisampling and adaptiveResolution config to the core stack */ private syncConfigToCore() { const pp = this.context.postprocessing; pp.multisampling = this.multisampling; pp.adaptiveResolution = this.adaptiveResolution; } private _applyPostQueue() { if (this._modificationQueue) { for (const entry of this._modificationQueue.values()) this.onEditorModification(entry); this._modificationQueue.clear(); } } /** called from needle editor sync package if its active */ onEditorModification(modification: EditorModification): void | boolean | undefined { if (modification.propertyName.startsWith("postprocessing.")) { if (!this.context.postprocessing.handler) { if (!this._modificationQueue) this._modificationQueue = new Map<string, EditorModification>(); this._modificationQueue.set(modification.propertyName, modification); return true; } if (!this._activeEffects?.length) return; const path = modification.propertyName.split("."); if (path.length === 3 || path.length === 4) { const componentName = path[1]; const propertyName = path[2]; for (const comp of this._activeEffects) { if (comp.typeName?.toLowerCase() === componentName.toLowerCase()) { if (propertyName === "active") { comp.active = modification.value; this.scheduleRecreate(); return; } // cache the volume parameters if (!effectVolumeProperties.has(componentName)) { const volumeParameterKeys = new Array<string>(); effectVolumeProperties.set(componentName, volumeParameterKeys); const keys = Object.keys(comp); for (const key of keys) { const prop = comp[key]; if (prop instanceof VolumeParameter) { volumeParameterKeys.push(key); } } } if (effectVolumeProperties.has(componentName)) { const paramName = propertyName.toLowerCase(); const volumeParameterKeys = effectVolumeProperties.get(componentName)!; for (const key of volumeParameterKeys) { if (key.toLowerCase() === paramName) { const prop = comp[key] as VolumeParameter; if (prop instanceof VolumeParameter) { const isActiveStateChange = path.length === 4 && path[3] === "active"; if (isActiveStateChange) { prop.overrideState = modification.value; this.scheduleRecreate(); } else if (prop && prop.value !== undefined) { prop.value = modification.value; } } return; } } } console.warn("Unknown modification", propertyName); return; } } } return true; } return false; } private _modificationQueue?: Map<string, EditorModification>; private _recreateId: number = -1; private scheduleRecreate() { // When the editor modifications come in with changed active effects we want/need to re-create the effects // We defer it slightly because multiple active changes could be made and we dont want to recreate the full effect stack multiple times const id = ++this._recreateId; setTimeout(() => { if (id !== this._recreateId) return; this.onDisable(); this.onEnable(); }, 200); } } /** cached VolumeParameter keys per object */ const effectVolumeProperties: Map<string, string[]> = new Map<string, string[]>(); setPostprocessingManagerType(Volume); export { Volume as PostProcessingManager };