@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
351 lines • 16.2 kB
JavaScript
import { HalfFloatType, NoToneMapping } from "three";
import { showBalloonWarning } from "../../engine/debug/index.js";
// import { internal_SetSharpeningEffectModule } from "./Effects/Sharpening.js";
import { MODULES } from "../../engine/engine_modules.js";
import { getParam } from "../../engine/engine_utils.js";
globalThis["NEEDLE_USE_POSTPROCESSING"] = globalThis["NEEDLE_USE_POSTPROCESSING"] !== undefined ? globalThis["NEEDLE_USE_POSTPROCESSING"] : true;
const debug = getParam("debugpost");
const dontMergePasses = getParam("debugpostpasses");
const activeKey = Symbol("needle:postprocessing-handler");
const autoclearSetting = Symbol("needle:previous-autoclear-state");
/**
* PostProcessingHandler is responsible for applying post processing effects to the scene. It is internally used by the {@link Volume} component
*/
export class PostProcessingHandler {
_composer = null;
_lastVolumeComponents;
_effects = [];
get isActive() {
return this._isActive;
}
get composer() {
return this._composer;
}
_isActive = false;
context;
constructor(context) {
this.context = context;
}
apply(components) {
if ("env" in import.meta && import.meta.env.VITE_NEEDLE_USE_POSTPROCESSING === "false") {
if (debug)
console.warn("Postprocessing is disabled via vite env setting");
else
console.debug("Postprocessing is disabled via vite env setting");
return Promise.resolve();
}
if (!NEEDLE_USE_POSTPROCESSING) {
if (debug)
console.warn("Postprocessing is disabled via global vite define setting");
else
console.debug("Postprocessing is disabled via vite define");
return Promise.resolve();
}
this._isActive = true;
return this.onApply(this.context, components);
}
unapply() {
if (debug)
console.log("Unapplying postprocessing effects");
this._isActive = false;
if (this._lastVolumeComponents) {
for (const component of this._lastVolumeComponents) {
component.unapply();
}
this._lastVolumeComponents.length = 0;
}
const context = this.context;
const active = context[activeKey];
if (active === this) {
delete context[activeKey];
}
if (context.composer === this._composer) {
context.composer?.dispose();
context.composer = null;
}
if (typeof context.renderer[autoclearSetting] === "boolean") {
context.renderer.autoClear = context.renderer[autoclearSetting];
}
}
dispose() {
this.unapply();
for (const effect of this._effects) {
effect.dispose();
}
this._effects.length = 0;
this._composer = null;
}
async onApply(context, components) {
if (!components)
return;
// IMPORTANT
// Load postprocessing modules ONLY here to get lazy loading of the postprocessing package
await Promise.all([
MODULES.POSTPROCESSING.load(),
MODULES.POSTPROCESSING_AO.load(),
// import("./Effects/Sharpening.effect")
]);
// try {
// internal_SetSharpeningEffectModule(modules[2]);
// }
// catch (err) {
// console.error(err);
// }
context[activeKey] = this;
if (debug)
console.log("Apply Postprocessing Effects", components);
this._lastVolumeComponents = [...components];
// store all effects in an array to apply them all in one pass
// const effects: Array<Effect | Pass> = [];
this._effects.length = 0;
// TODO: if an effect is added or removed during the loop this might not be correct anymore
const ctx = {
handler: this,
components: this._lastVolumeComponents,
};
for (let i = 0; i < this._lastVolumeComponents.length; i++) {
const component = this._lastVolumeComponents[i];
//@ts-ignore
component.context = context;
if (component.apply) {
if (component.active) {
if (!context.mainCameraComponent) {
console.error("No camera in scene found or available yet - can not create postprocessing effects");
return;
}
// apply or collect effects
const res = component.apply(ctx);
if (!res)
continue;
if (Array.isArray(res)) {
this._effects.push(...res);
}
else
this._effects.push(res);
}
}
else {
if (component.active)
showBalloonWarning("Volume component is not a VolumeComponent: " + component["__type"]);
}
}
// Ensure that we have a tonemapping effect if the renderer is set to use a tone mapping
if (this.context.renderer.toneMapping != NoToneMapping) {
if (!this._effects.find(e => e instanceof MODULES.POSTPROCESSING.MODULE.ToneMappingEffect)) {
const tonemapping = new MODULES.POSTPROCESSING.MODULE.ToneMappingEffect();
this._effects.push(tonemapping);
}
}
this.applyEffects(context);
}
/** Build composer passes */
applyEffects(context) {
const effectsOrPasses = this._effects;
if (effectsOrPasses.length <= 0)
return;
const camera = context.mainCameraComponent;
const renderer = context.renderer;
const scene = context.scene;
const cam = camera.threeCamera;
// Store the auto clear setting because the postprocessing composer just disables it
// and when we disable postprocessing we want to restore the original setting
// https://github.com/pmndrs/postprocessing/blob/271944b74b543a5b743a62803a167b60cc6bb4ee/src/core/EffectComposer.js#L230C12-L230C12
renderer[autoclearSetting] = renderer.autoClear;
// create composer and set active on context
if (!this._composer) {
// const hdrRenderTarget = new WebGLRenderTarget(window.innerWidth, window.innerHeight, { type: HalfFloatType });
this._composer = new MODULES.POSTPROCESSING.MODULE.EffectComposer(renderer, {
frameBufferType: HalfFloatType,
stencilBuffer: true,
});
}
if (context.composer && context.composer !== this._composer) {
console.warn("There's already an active EffectComposer in your scene: replacing it with a new one. This might cause unexpected behaviour. Make sure to only use one PostprocessingManager/Volume in your scene.");
}
context.composer = this._composer;
const composer = context.composer;
composer.setMainCamera(cam);
composer.setRenderer(renderer);
composer.setMainScene(scene);
for (const prev of composer.passes)
prev.dispose();
composer.removeAllPasses();
// Render to screen pass
const screenpass = new MODULES.POSTPROCESSING.MODULE.RenderPass(scene, cam);
screenpass.name = "Render To Screen";
screenpass.mainScene = scene;
composer.addPass(screenpass);
const automaticEffectsOrdering = true;
if (automaticEffectsOrdering && !dontMergePasses) {
try {
this.orderEffects();
const effects = [];
for (const ef of effectsOrPasses) {
if (ef instanceof MODULES.POSTPROCESSING.MODULE.Effect)
effects.push(ef);
else if (ef instanceof MODULES.POSTPROCESSING.MODULE.Pass) {
composer.addPass(ef);
}
else {
// seems some effects are not correctly typed, but three can deal with them,
// so we might need to just pass them through
composer.addPass(ef);
}
}
// create and apply uber pass
if (effects.length > 0) {
const pass = new MODULES.POSTPROCESSING.MODULE.EffectPass(cam, ...effects);
pass.name = effects.map(e => e.name).join(" ");
pass.mainScene = scene;
pass.enabled = true;
composer.addPass(pass);
}
}
catch (e) {
console.error("Error while applying postprocessing effects", e);
composer.removeAllPasses();
}
}
else {
// we still want to sort passes, but we do not want to merge them for debugging
if (automaticEffectsOrdering)
this.orderEffects();
for (const ef of effectsOrPasses) {
if (ef instanceof MODULES.POSTPROCESSING.MODULE.Effect) {
const pass = new MODULES.POSTPROCESSING.MODULE.EffectPass(cam, ef);
pass.name = ef.name;
composer.addPass(pass);
}
else if (ef instanceof MODULES.POSTPROCESSING.MODULE.Pass)
composer.addPass(ef);
else
// seems some effects are not correctly typed, but three can deal with them,
// so we just pass them through
composer.addPass(ef);
}
}
if (debug) {
console.log("[PostProcessing] Passes →", composer.passes);
// DepthEffect for debugging purposes, disabled by default, can be selected in the debug pass select
const depthEffect = new MODULES.POSTPROCESSING.MODULE.DepthEffect({
blendFunction: MODULES.POSTPROCESSING.MODULE.BlendFunction.NORMAL,
inverted: true,
});
depthEffect.name = "Depth Effect";
const depthPass = new MODULES.POSTPROCESSING.MODULE.EffectPass(cam, depthEffect);
depthPass.name = "Depth Effect Pass";
depthPass.enabled = false;
composer.passes.push(depthPass);
if (this._passIndices !== null) {
const newPasses = [composer.passes[0]];
if (this._passIndices.length > 0) {
newPasses.push(...this._passIndices
.filter(x => x !== 0)
.map(index => composer.passes[index])
.filter(pass => pass));
}
if (newPasses.length > 0) {
console.log("[PostProcessing] Passes (selected) →", newPasses);
}
composer.passes.length = 0;
for (const pass of newPasses) {
pass.enabled = true;
pass.renderToScreen = false; // allows automatic setting for the last pass
composer.addPass(pass);
}
}
const menu = this.context.menu;
if (menu && this._passIndices === null) {
if (this._menuEntry)
this._menuEntry.remove();
const select = document.createElement("select");
select.multiple = true;
const defaultOpt = document.createElement("option");
defaultOpt.innerText = "Final Output";
defaultOpt.value = "-1";
select.appendChild(defaultOpt);
for (const eff of composer.passes) {
const opt = document.createElement("option");
opt.innerText = eff.name;
opt.value = `${composer.passes.indexOf(eff)}`;
opt.title = eff.name;
select.appendChild(opt);
}
menu.appendChild(select);
this._menuEntry = select;
select.addEventListener("change", () => {
const indices = Array.from(select.selectedOptions).map(option => parseInt(option.value));
if (indices.length === 1 && indices[0] === -1) {
this._passIndices = null;
}
else {
this._passIndices = indices;
}
this.applyEffects(context);
});
}
}
}
_menuEntry = null;
_passIndices = null;
orderEffects() {
if (debug)
console.log("Before ordering effects", [...this._effects]);
// Order of effects for correct results.
// Aligned with https://github.com/pmndrs/postprocessing/wiki/Effect-Merging#effect-execution-order
// We can not put this into global scope because then the module might not yet be initialized
effectsOrder ??= [
MODULES.POSTPROCESSING.MODULE.NormalPass,
MODULES.POSTPROCESSING.MODULE.DepthDownsamplingPass,
MODULES.POSTPROCESSING.MODULE.SMAAEffect,
MODULES.POSTPROCESSING.MODULE.SSAOEffect,
MODULES.POSTPROCESSING_AO.MODULE.N8AOPostPass,
MODULES.POSTPROCESSING.MODULE.TiltShiftEffect,
MODULES.POSTPROCESSING.MODULE.DepthOfFieldEffect,
MODULES.POSTPROCESSING.MODULE.ChromaticAberrationEffect,
MODULES.POSTPROCESSING.MODULE.BloomEffect,
MODULES.POSTPROCESSING.MODULE.SelectiveBloomEffect,
MODULES.POSTPROCESSING.MODULE.VignetteEffect,
MODULES.POSTPROCESSING.MODULE.PixelationEffect,
MODULES.POSTPROCESSING.MODULE.ToneMappingEffect,
MODULES.POSTPROCESSING.MODULE.HueSaturationEffect,
MODULES.POSTPROCESSING.MODULE.BrightnessContrastEffect,
// __SHARPENING_MODULE._SharpeningEffect,
];
// TODO: enforce correct order of effects (e.g. DOF before Bloom)
const effects = this._effects;
effects.sort((a, b) => {
// we use find index here because sometimes constructor names are prefixed with `_`
// TODO: find a more robust solution that isnt name based (not sure if that exists tho... maybe we must give effect TYPES some priority/index)
const aidx = effectsOrder.findIndex(e => a.constructor.name.endsWith(e.name));
const bidx = effectsOrder.findIndex(e => b.constructor.name.endsWith(e.name));
// Unknown effects should be rendered first
if (aidx < 0) {
if (debug)
console.warn("Unknown effect found: ", a.constructor.name);
return -1;
}
else if (bidx < 0) {
if (debug)
console.warn("Unknown effect found: ", b.constructor.name);
return 1;
}
if (aidx < 0)
return 1;
if (bidx < 0)
return -1;
return aidx - bidx;
});
if (debug)
console.log("After ordering effects", [...this._effects]);
for (let i = 0; i < effects.length; i++) {
const effect = effects[i];
if (effect?.configuration?.gammaCorrection !== undefined) {
const isLast = i === effects.length - 1;
effect.configuration.gammaCorrection = isLast;
}
}
}
}
let effectsOrder = null;
//# sourceMappingURL=PostProcessingHandler.js.map