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