@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.
573 lines (483 loc) • 26.3 kB
text/typescript
import type { Effect, EffectComposer, Pass, ToneMappingEffect as _TonemappingEffect } from "postprocessing";
import { Camera as Camera3, DepthTexture, HalfFloatType, LinearFilter, NoToneMapping, Scene, Texture, ToneMapping, WebGLRenderTarget } from "three";
import { showBalloonWarning } from "../../engine/debug/index.js";
// import { internal_SetSharpeningEffectModule } from "./Effects/Sharpening.js";
import { MODULES } from "../../engine/engine_modules.js";
import { Context } from "../../engine/engine_setup.js";
import { Graphics } from "../../engine/engine_three_utils.js";
import { Constructor } from "../../engine/engine_types.js";
import { getParam } from "../../engine/engine_utils.js";
import { Camera } from "../Camera.js";
import { threeToneMappingToEffectMode } from "./Effects/Tonemapping.utils.js";
import { PostProcessingEffect, PostProcessingEffectContext } from "./PostProcessingEffect.js";
import { orderEffects, PostprocessingEffectData, PostProcessingEffectOrder } from "./utils.js";
declare const NEEDLE_USE_POSTPROCESSING: boolean;
globalThis["NEEDLE_USE_POSTPROCESSING"] = globalThis["NEEDLE_USE_POSTPROCESSING"] !== undefined ? globalThis["NEEDLE_USE_POSTPROCESSING"] : true;
const debug = getParam("debugpost");
const activeKey = Symbol("needle:postprocessing-handler");
const autoclearSetting = Symbol("needle:previous-autoclear-state");
const previousToneMapping = Symbol("needle:previous-tone-mapping");
/**
* PostProcessingHandler is responsible for applying post processing effects to the scene. It is internally used by the {@link Volume} component
*/
export class PostProcessingHandler {
private _composer: EffectComposer | null = null;
private _lastVolumeComponents?: PostProcessingEffect[];
private readonly _effects: Array<PostprocessingEffectData> = [];
/**
* Returns true if a specific effect is currently active in the post processing stack.
*/
getEffectIsActive(effect: Effect): boolean {
if (!effect) return false;
return this._isActive && this._effects.some(e => e.effect === effect);
}
get isActive() {
return this._isActive;
}
get composer() {
return this._composer;
}
private _isActive: boolean = false;
private readonly context: Context;
constructor(context: Context) {
this.context = context;
}
apply(components: PostProcessingEffect[]): Promise<void> {
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(dispose: boolean = true) {
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] as PostProcessingHandler | null;
if (active === this) {
delete context[activeKey];
// Restore the auto clear setting
if (typeof context.renderer[autoclearSetting] === "boolean") {
context.renderer.autoClear = context.renderer[autoclearSetting];
}
if (typeof context.renderer[previousToneMapping] === "number") {
context.renderer.toneMapping = context.renderer[previousToneMapping] as ToneMapping;
}
}
this._composer?.removeAllPasses();
if (dispose) this._composer?.dispose();
if (context.composer === this._composer) {
context.composer = null;
}
}
dispose() {
this.unapply(true);
for (const effect of this._effects) {
effect.effect.dispose();
}
this._effects.length = 0;
this._composer = null;
}
private async onApply(context: Context, components: PostProcessingEffect[]) {
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: PostProcessingEffectContext = {
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;
const name = component.typeName || component.constructor.name;
if (Array.isArray(res)) {
for (const effect of res) {
if (!validateEffect(name, effect)) continue;
this._effects.push({
effect,
typeName: component.typeName,
priority: component.order
});
}
}
else {
if (!validateEffect(name, res)) continue;
this._effects.push({
effect: res,
typeName: component.typeName,
priority: component.order
});
}
function validateEffect(source: string, effect: Effect | Pass): boolean {
if (!effect) {
return false;
}
if (!(effect instanceof MODULES.POSTPROCESSING.MODULE.Effect || effect instanceof MODULES.POSTPROCESSING.MODULE.Pass)) {
console.warn(`PostprocessingEffect ${source} created neither Effect nor Pass - this might be caused by a bundler error or false import. Below you find some possible solutions:\n- If you create custom effect try creating it like this: 'new NEEDLE_ENGINE_MODULES.POSTPROCESSING.MODULE.Effect(...)' instead of 'new Effect(...)'`);
}
return true;
}
}
}
else {
if (component.active)
showBalloonWarning("Volume component is not a VolumeComponent: " + component["__type"]);
}
}
this.applyEffects(context);
}
private _anyPassHasDepth = false;
private _anyPassHasNormal = false;
private _hasSmaaEffect = false;
get anyPassHasDepth() { return this._anyPassHasDepth; }
get anyPassHasNormal() { return this._anyPassHasNormal; }
get hasSmaaEffect() { return this._hasSmaaEffect; }
private _customInputBuffer: WebGLRenderTarget<Texture> | null = null;
private _customInputBufferId = 0;
private _multisampling: number = 0;
set multisampling(value: number) {
this._multisampling = value;
}
get multisampling() {
return this._multisampling;
}
/** Build composer passes */
private applyEffects(context: Context) {
// Reset state
this._anyPassHasDepth = false;
this._anyPassHasNormal = false;
this._hasSmaaEffect = false;
if (this._effects.length <= 0) {
return;
}
const camera = context.mainCameraComponent as Camera;
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
// First we need to get the previously set autoClear setting, if it exists
if (typeof renderer[autoclearSetting] === "boolean") {
renderer.autoClear = renderer[autoclearSetting];
}
renderer[autoclearSetting] = renderer.autoClear;
if (typeof renderer[previousToneMapping] === "number") {
renderer.toneMapping = renderer[previousToneMapping] as ToneMapping;
}
renderer[previousToneMapping] = renderer.toneMapping;
// Ensure that we have a tonemapping effect if the renderer is set to use a tone mapping
if (renderer.toneMapping != NoToneMapping) {
if (!this._effects.find(e => e instanceof MODULES.POSTPROCESSING.MODULE.ToneMappingEffect)) {
const tonemapping = new MODULES.POSTPROCESSING.MODULE.ToneMappingEffect();
tonemapping.name = `ToneMapping (${renderer.toneMapping})`;
tonemapping.mode = threeToneMappingToEffectMode(renderer.toneMapping);
this._effects.push({
typeName: "ToneMapping",
effect: tonemapping,
priority: PostProcessingEffectOrder.ToneMapping
});
}
}
// 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);
composer.autoRenderToScreen = true; // Must be enabled because it might be disabled during addPass by the composer itself (depending on the effect's settings or order)
composer.multisampling = 0; // Disable multisampling by default
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 = "RenderPass";
screenpass.mainScene = scene;
composer.addPass(screenpass);
const screenPassRender = screenpass.render;
this._customInputBuffer?.dispose();
this._customInputBuffer = null;
screenpass.render = (renderer, inputBuffer, outputBuffer, deltaTime, stencilTest) => {
if (!inputBuffer) return;
// screenPassRender.call(screenpass, renderer, inputBuffer, outputBuffer, deltaTime, stencilTest);
// return;
// Make sure multisampling is disabled on the composer buffers. Technically a user could still set multisampling directly on the composer so this is to override that and make sure these textures do NOT use multisampling
inputBuffer.samples = 0;
if (outputBuffer) {
outputBuffer.samples = 0;
}
// Make sure the input buffer is a WebGLRenderTarget with the correct settings
if (!this._customInputBuffer
|| this._customInputBuffer.width !== inputBuffer.width
|| this._customInputBuffer.height !== inputBuffer.height
|| this._customInputBuffer.samples !== this._multisampling
|| this._customInputBuffer.texture.format !== inputBuffer.texture.format
|| this._customInputBuffer.texture.type !== HalfFloatType
) {
this._customInputBuffer?.dispose();
this._customInputBuffer = new WebGLRenderTarget(inputBuffer.width, inputBuffer.height, {
format: inputBuffer.texture.format,
type: HalfFloatType,
depthBuffer: inputBuffer.depthBuffer,
depthTexture: inputBuffer.depthTexture
? new DepthTexture(inputBuffer.width, inputBuffer.height)
: undefined,
stencilBuffer: inputBuffer.stencilBuffer,
samples: Math.max(0, this._multisampling),
minFilter: inputBuffer.texture.minFilter ?? LinearFilter,
magFilter: inputBuffer.texture.magFilter ?? LinearFilter,
generateMipmaps: inputBuffer.texture.generateMipmaps,
});
this._customInputBufferId++;
this._customInputBuffer.texture.name = `CustomInputBuffer-${this._customInputBufferId}`;
if (this._customInputBuffer.depthTexture && inputBuffer.depthTexture) {
this._customInputBuffer.depthTexture.format = inputBuffer.depthTexture.format;
this._customInputBuffer.depthTexture.type = inputBuffer.depthTexture.type;
}
// https://github.com/pmndrs/postprocessing/blob/ad338df710ef41fee4e5d10ad2c2c299030d46ef/src/core/EffectComposer.js#L366
if (this._customInputBuffer.samples > 0)
(this._customInputBuffer as any).ignoreDepthForMultisampleCopy = false;
if (debug) console.warn(`[PostProcessing] Input buffer created with size ${this._customInputBuffer.width}x${this._customInputBuffer.height} and samples ${this._customInputBuffer.samples}`);
}
// Calling the original render function with the input buffer
screenPassRender.call(screenpass, renderer, this._customInputBuffer, outputBuffer, deltaTime, stencilTest);
// Blit the resulting buffer to the input buffer passed in by the composer so it's used for subsequent effects
Graphics.blit(this._customInputBuffer.texture, inputBuffer, {
renderer,
depthTexture: this._customInputBuffer.depthTexture,
depthWrite: true,
depthTest: true,
});
};
try {
orderEffects(this._effects);
let foundTonemappingEffect = false;
let activeTonemappingEffect: _TonemappingEffect | null = null;
for (let i = this._effects.length - 1; i >= 0; i--) {
const ef = this._effects[i].effect;
if (ef instanceof MODULES.POSTPROCESSING.MODULE.ToneMappingEffect) {
// If we already have a tonemapping effect, we can skip this one
if (foundTonemappingEffect) {
if (debug) console.warn(`[PostProcessing] Found multiple tonemapping effects in the scene: ${ef.name} and ${activeTonemappingEffect?.name}. Only the last one added will be used.`);
this._effects.splice(i, 1);
continue;
}
activeTonemappingEffect = ef;
foundTonemappingEffect = true;
}
}
const effectsToMerge: Array<Effect> = [];
let hasConvolutionEffectInArray = false;
for (let i = 0; i < this._effects.length; i++) {
const entry = this._effects[i];
const ef = entry.effect;
if (ef instanceof MODULES.POSTPROCESSING.MODULE.SMAAEffect) {
this._hasSmaaEffect = true;
}
else if (ef instanceof MODULES.POSTPROCESSING.MODULE.NormalPass) {
this._anyPassHasNormal = true;
}
// There can be only one tonemapping effect in the scene, so we skip all others
if (ef instanceof MODULES.POSTPROCESSING.MODULE.ToneMappingEffect && activeTonemappingEffect !== ef) {
// If we already have a tonemapping effect, we can skip this one
continue;
}
// We can also not merge multiple effects of the same type in one pass
// So we first need to create a new pass with whatever effects we have so far
// TODO: this seems to work fine for some effects (like ColorAdjustments) and only caused issues with multiple Tonemapping effects so far which is handled above
// const constructor = ef.constructor;
// if (effectsToMerge.find(e => e.constructor === constructor)) {
// this.createPassForMergeableEffects(effectsToMerge, composer, cam, scene);
// }
if (ef instanceof MODULES.POSTPROCESSING.MODULE.Effect) {
const attributes = ef.getAttributes();
const convolution = MODULES.POSTPROCESSING.MODULE.EffectAttribute.CONVOLUTION;
if (attributes & convolution) {
if (debug) console.log("[PostProcessing] Convolution effect: " + ef.name);
if (hasConvolutionEffectInArray) {
if (debug) console.log("[PostProcessing] → Merging effects [" + effectsToMerge.map(e => e.name).join(", ") + "]");
this.createPassForMergeableEffects(effectsToMerge, composer, cam, scene);
}
hasConvolutionEffectInArray = true;
}
// Otherwise we can merge it
effectsToMerge.push(ef as Effect);
}
else if (ef instanceof MODULES.POSTPROCESSING.MODULE.Pass) {
hasConvolutionEffectInArray = false;
this.createPassForMergeableEffects(effectsToMerge, composer, cam, scene);
ef.renderToScreen = false;
composer.addPass(ef as Pass);
}
else {
// seems some effects are not correctly typed, but three can deal with them,
// so we might need to just pass them through
hasConvolutionEffectInArray = false;
this.createPassForMergeableEffects(effectsToMerge, composer, cam, scene);
composer.addPass(ef);
}
}
this.createPassForMergeableEffects(effectsToMerge, composer, cam, scene);
}
catch (e) {
console.error("Error while applying postprocessing effects", e);
composer.passes.forEach(p => p.dispose());
composer.removeAllPasses();
}
// The last pass is the one that renders to the screen, so we need to set the gamma correction for it (and enable it for all others)
let foundEnabled = false;
for (let i = composer.passes.length - 1; i >= 0; i--) {
const pass = composer.passes[i];
let gammaCorrect = false;
let renderToScreen = false;
if (pass.enabled) {
if (!foundEnabled) {
gammaCorrect = true;
renderToScreen = true;
}
foundEnabled = true;
}
pass.renderToScreen = renderToScreen;
if ((pass as any)?.configuration !== undefined) {
(pass as any).configuration.gammaCorrection = gammaCorrect;
}
else if ("autosetGamma" in pass) {
// Some effects have a autosetGamma property that we can use to set the gamma correction
pass.autosetGamma = gammaCorrect;
}
this._anyPassHasDepth ||= pass.needsDepthTexture;
}
// DEBUG LAND BELOW
if (debug) console.log("[PostProcessing] Passes →", [...composer.passes], "\n---------------------------------\n• " + composer.passes.map(i => i.name).join("\n• ") + "\n");
if (debug) this._onCreateEffectsDebug(this._composer!, cam);
}
/** Should be called before `composer.addPass()` to create an effect pass with all previously collected effects that can be merged up to that point */
private createPassForMergeableEffects(effects: Array<Effect>, composer: EffectComposer, camera: Camera3, scene: Scene) {
if (effects.length > 0) {
const pass = new MODULES.POSTPROCESSING.MODULE.EffectPass(camera, ...effects);
pass.name = effects.map(e => e.name).join(", ");
pass.mainScene = scene;
pass.enabled = true;
pass.renderToScreen = false;
composer.addPass(pass);
effects.length = 0; // Clear effects after adding them to the pass
}
}
private _menuEntry: HTMLSelectElement | null = null;
private _passIndices: number[] | null = null;
private _onCreateEffectsDebug(composer: EffectComposer, cam: Camera3) {
if (debug === "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(this.context);
});
}
}
}
}