From 6a523f46e789a87090545da3cee10cf102ba0253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:20:30 +0200 Subject: [PATCH 1/2] Fix blur --- Extensions/3D/BloomEffect.ts | 11 ++ Extensions/Effects/kawase-blur-pixi-filter.ts | 6 +- .../pixi-renderers/layer-pixi-renderer.ts | 86 ++++++++- .../pixi-renderers/pixi-filters-tools.ts | 26 +++ GDJS/tests/karma.conf.js | 1 + GDJS/tests/tests/layer-post-processing.js | 182 ++++++++++++++++++ 6 files changed, 308 insertions(+), 4 deletions(-) create mode 100644 GDJS/tests/tests/layer-post-processing.js diff --git a/Extensions/3D/BloomEffect.ts b/Extensions/3D/BloomEffect.ts index 821dbecd052c..6464041b7f3b 100644 --- a/Extensions/3D/BloomEffect.ts +++ b/Extensions/3D/BloomEffect.ts @@ -25,6 +25,17 @@ namespace gdjs { 0, 0 ); + // The bloom is added on the rendered layer, but it must not make + // the transparent parts of the layer opaque (the layers rendered + // before this one must remain visible). + const blendMaterial = this.shaderPass.blendMaterial; + blendMaterial.blending = THREE.CustomBlending; + blendMaterial.blendEquation = THREE.AddEquation; + blendMaterial.blendSrc = THREE.SrcAlphaFactor; + blendMaterial.blendDst = THREE.OneFactor; + blendMaterial.blendEquationAlpha = THREE.AddEquation; + blendMaterial.blendSrcAlpha = THREE.ZeroFactor; + blendMaterial.blendDstAlpha = THREE.OneFactor; this._isEnabled = false; } diff --git a/Extensions/Effects/kawase-blur-pixi-filter.ts b/Extensions/Effects/kawase-blur-pixi-filter.ts index 0c877ecc62ff..341b3d446dad 100644 --- a/Extensions/Effects/kawase-blur-pixi-filter.ts +++ b/Extensions/Effects/kawase-blur-pixi-filter.ts @@ -9,7 +9,11 @@ namespace gdjs { 'KawaseBlur', new (class extends gdjs.PixiFiltersTools.PixiFilterCreator { makePIXIFilter(target: EffectsTarget, effectData) { - const kawaseBlurFilter = new PIXI.filters.KawaseBlurFilter(); + // Clamp the texture coordinates, so that the blur never reads the empty + // area that PixiJS can leave around what is rendered (which would show + // up as a seam on the right and bottom edges). + const clamp = true; + const kawaseBlurFilter = new PIXI.filters.KawaseBlurFilter(4, 3, clamp); return kawaseBlurFilter; } updatePreRender(filter: PIXI.Filter, target: EffectsTarget) {} diff --git a/GDJS/Runtime/pixi-renderers/layer-pixi-renderer.ts b/GDJS/Runtime/pixi-renderers/layer-pixi-renderer.ts index d80f611b4386..c0b2b0b0de83 100644 --- a/GDJS/Runtime/pixi-renderers/layer-pixi-renderer.ts +++ b/GDJS/Runtime/pixi-renderers/layer-pixi-renderer.ts @@ -189,6 +189,13 @@ namespace gdjs { private _pixiContainer: PIXI.Container; private _layer: gdjs.RuntimeLayer; + private _runtimeGameRenderer: gdjs.RuntimeGameRenderer; + + /** + * True for a layer of a scene, false for a layer inside a custom object + * (only the former is covering the whole screen). + */ + private _isSceneLayer: boolean; /** For a lighting layer, the sprite used to display the render texture. */ private _lightingSprite: PIXI.Sprite | null = null; @@ -245,6 +252,9 @@ namespace gdjs { this._pixiContainer = new PIXI.Container(); this._pixiContainer.sortableChildren = true; this._layer = layer; + this._runtimeGameRenderer = runtimeGameRenderer; + const instanceContainer = layer.getInstanceContainer(); + this._isSceneLayer = instanceContainer === instanceContainer.getScene(); this._isLightingLayer = layer.isLightingLayer(); const parentRendererObject = runtimeInstanceContainerRenderer.getRendererObject(); @@ -252,6 +262,7 @@ namespace gdjs { parentRendererObject.addChild(this._pixiContainer); } this._pixiContainer.filters = []; + this._updateFilterArea(); // Setup rendering for lighting or 3D rendering: const pixiRenderer = runtimeGameRenderer.getPIXIRenderer(); @@ -285,6 +296,47 @@ namespace gdjs { onGameResolutionResized() { // Ensure the 3D camera aspect is updated: this._update3DCameraAspectAndPosition(); + + this._updateFilterArea(); + } + + /** + * Tell PixiJS the area on which the effects (filters) of a scene layer must + * be applied: the whole screen. + * + * Without this, PixiJS uses the bounding box of what the layer contains, + * which has two downsides: + * - the bounding box of the whole layer is computed at every frame; + * - when this bounding box is smaller than the screen, PixiJS renders the + * layer in a bigger (rounded up to a power of two) texture taken from its + * pool. Effects reading the neighbor pixels (blurs notably) then read the + * empty area around the layer, which shows up as a seam on the right and + * bottom edges of the screen. + */ + private _updateFilterArea() { + if (!this._isSceneLayer) { + // A layer of a custom object only covers the object: let PixiJS compute + // the area from its content. + return; + } + const pixiRenderer = this._runtimeGameRenderer.getPIXIRenderer(); + if (!pixiRenderer) { + return; + } + const filterArea = this._pixiContainer.filterArea; + if (filterArea) { + filterArea.x = 0; + filterArea.y = 0; + filterArea.width = pixiRenderer.screen.width; + filterArea.height = pixiRenderer.screen.height; + } else { + this._pixiContainer.filterArea = new PIXI.Rectangle( + 0, + 0, + pixiRenderer.screen.width, + pixiRenderer.screen.height + ); + } } private _update3DCameraAspectAndPosition() { @@ -441,8 +493,17 @@ namespace gdjs { this._threeEffectComposer = new THREE_ADDONS.EffectComposer( threeRenderer ); + // Clear the composer buffers with a transparent color, so that the + // parts of the layer where nothing is rendered stay transparent + // (the layers rendered before this one must remain visible). this._threeEffectComposer.addPass( - new THREE_ADDONS.RenderPass(this._threeScene, this._threeCamera) + new THREE_ADDONS.RenderPass( + this._threeScene, + this._threeCamera, + null, + null, + 0 + ) ); if (game.getAntialiasingMode() !== 'none') { this._threeEffectComposer.addPass( @@ -452,7 +513,18 @@ namespace gdjs { ) ); } - this._threeEffectComposer.addPass(new THREE_ADDONS.OutputPass()); + const outputPass = new THREE_ADDONS.OutputPass(); + // The composer result is drawn on top of the layers already rendered + // on the canvas: blend it (the buffers hold premultiplied colors) + // instead of overwriting everything. + outputPass.material.transparent = true; + outputPass.material.depthTest = false; + outputPass.material.depthWrite = false; + outputPass.material.blending = THREE.CustomBlending; + outputPass.material.blendEquation = THREE.AddEquation; + outputPass.material.blendSrc = THREE.OneFactor; + outputPass.material.blendDst = THREE.OneMinusSrcAlphaFactor; + this._threeEffectComposer.addPass(outputPass); } if ( @@ -994,7 +1066,15 @@ namespace gdjs { updateResolution() { if (this._threeEffectComposer) { const game = this._layer.getRuntimeScene().getGame(); - this._threeEffectComposer.setPixelRatio(window.devicePixelRatio); + const threeRenderer = game.getRenderer().getThreeRenderer(); + if (threeRenderer) { + // The composer must render at the same resolution as the canvas, + // otherwise the layer would be scaled (and so, antialiased) when it's + // drawn on the canvas. + this._threeEffectComposer.setPixelRatio( + threeRenderer.getPixelRatio() + ); + } this._threeEffectComposer.setSize( game.getGameResolutionWidth(), game.getGameResolutionHeight() diff --git a/GDJS/Runtime/pixi-renderers/pixi-filters-tools.ts b/GDJS/Runtime/pixi-renderers/pixi-filters-tools.ts index 9e45d03b1590..15d3c62de0f8 100644 --- a/GDJS/Runtime/pixi-renderers/pixi-filters-tools.ts +++ b/GDJS/Runtime/pixi-renderers/pixi-filters-tools.ts @@ -164,6 +164,18 @@ namespace gdjs { ): void; } + /** + * Check if the target of an effect is a layer of a scene, as opposed to an + * object or a layer of a custom object. Only these cover the whole screen. + */ + const isSceneLayer = function (target: EffectsTarget): boolean { + if (!target.getRuntimeLayer) { + return false; + } + const instanceContainer = target.getRuntimeLayer().getInstanceContainer(); + return instanceContainer === instanceContainer.getScene(); + }; + /** * An effect used to manipulate a Pixi filter. * @category Core Engine > Effects @@ -197,6 +209,20 @@ namespace gdjs { if (!rendererObject) { return false; } + if (isSceneLayer(target)) { + // The area on which the effect is applied is the whole screen + // (see `LayerPixiRenderer`). Let PixiJS apply it a bit outside of the + // screen too (as much as the effect needs to read pixels around + // each pixel): + // - PixiJS renders the layer in a texture taken from a pool, which is + // often bigger than the area asked for. Effects reading the + // neighbor pixels (blurs notably) would otherwise read the empty + // part of this texture, which shows up as a seam on the right and + // bottom edges of the screen. + // - what is just outside of the screen is then properly taken into + // account by these effects. + this.pixiFilter.autoFit = false; + } rendererObject.filters = (rendererObject.filters || []).concat( this.pixiFilter ); diff --git a/GDJS/tests/karma.conf.js b/GDJS/tests/karma.conf.js index f1b0da5796e3..3c84bee170ee 100644 --- a/GDJS/tests/karma.conf.js +++ b/GDJS/tests/karma.conf.js @@ -175,6 +175,7 @@ module.exports = function (config) { './newIDE/app/resources/GDJS/Runtime/Extensions/3D/Cube3DRuntimeObjectPixiRenderer.js', './newIDE/app/resources/GDJS/Runtime/Extensions/3D/CustomRuntimeObject3D.js', './newIDE/app/resources/GDJS/Runtime/Extensions/3D/CustomRuntimeObject3DRenderer.js', + './newIDE/app/resources/GDJS/Runtime/Extensions/3D/BloomEffect.js', './newIDE/app/resources/GDJS/Runtime/Extensions/TopDownMovementBehavior/topdownmovementruntimebehavior.js', './newIDE/app/resources/GDJS/Runtime/Extensions/TweenBehavior/TweenManager.js', './newIDE/app/resources/GDJS/Runtime/Extensions/TweenBehavior/tweentools.js', diff --git a/GDJS/tests/tests/layer-post-processing.js b/GDJS/tests/tests/layer-post-processing.js new file mode 100644 index 000000000000..9bfb5d8619f4 --- /dev/null +++ b/GDJS/tests/tests/layer-post-processing.js @@ -0,0 +1,182 @@ +// @ts-nocheck + +describe('gdjs.LayerPixiRenderer (3D post-processing)', () => { + const makeLayerData = (name) => ({ + name, + visibility: true, + effects: [], + cameras: [], + ambientLightColorR: 255, + ambientLightColorG: 255, + ambientLightColorB: 255, + isLightingLayer: false, + followBaseLayerCamera: false, + renderingType: '3d', + camera3DNearPlaneDistance: 3, + camera3DFarPlaneDistance: 10000, + camera3DFieldOfView: 45, + cameraType: 'perspective', + }); + + const makeSceneData = (layers) => ({ + layers, + variables: [], + r: 0, + v: 0, + b: 255, + mangledName: 'Scene1', + name: 'Scene1', + stopSoundsOnStartup: false, + title: '', + behaviorsSharedData: [], + objects: [], + objectsGroups: [], + instances: [], + usedResources: [], + uiSettings: { + grid: false, + gridType: 'rectangular', + gridWidth: 10, + gridHeight: 10, + gridDepth: 10, + gridOffsetX: 0, + gridOffsetY: 0, + gridOffsetZ: 0, + gridColor: 0, + gridAlpha: 1, + snap: false, + }, + }); + + const bloomEffectData = { + name: 'MyBloom', + effectType: 'Scene3D::Bloom', + stringParameters: {}, + booleanParameters: {}, + doubleParameters: { strength: 1, radius: 0, threshold: 0 }, + }; + + /** + * Add a flat colored quad, positioned in "game coordinates", to the 3D + * objects of a layer. + */ + const addQuad = (layer, color, x, y, width, height) => { + const mesh = new THREE.Mesh( + new THREE.PlaneGeometry(width, height), + new THREE.MeshBasicMaterial({ color }) + ); + mesh.position.set(x, y, 0); + layer.getRenderer().add3DRendererObject(mesh); + return mesh; + }; + + /** Read the color of one pixel of the canvas, in "game coordinates". */ + const readPixel = (runtimeGame, x, y) => { + const gl = runtimeGame.getRenderer().getThreeRenderer().getContext(); + const pixel = new Uint8Array(4); + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + gl.readPixels( + x, + gl.drawingBufferHeight - y, + 1, + 1, + gl.RGBA, + gl.UNSIGNED_BYTE, + pixel + ); + return [pixel[0], pixel[1], pixel[2]]; + }; + + const makeSceneWithTwoLayers = () => { + const runtimeGame = gdjs.getPixiRuntimeGame({ + propertiesOverrides: { antialiasingMode: 'none' }, + }); + const gameContainer = document.createElement('div'); + document.body.appendChild(gameContainer); + runtimeGame.getRenderer().createStandardCanvas(gameContainer); + + const runtimeScene = new gdjs.RuntimeScene(runtimeGame); + runtimeScene.loadFromScene({ + sceneData: makeSceneData([makeLayerData(''), makeLayerData('Top')]), + usedExtensionsWithVariablesData: [], + }); + + // The base layer has a big red quad on the left of the screen, and the + // layer on top of it only has a small green quad in its top left corner. + addQuad(runtimeScene.getLayer(''), 0xff0000, 200, 300, 400, 4000); + addQuad(runtimeScene.getLayer('Top'), 0x00ff00, 100, 100, 100, 100); + + return { runtimeGame, runtimeScene, gameContainer }; + }; + + it('renders the layers on top of each other', () => { + const { runtimeGame, runtimeScene, gameContainer } = + makeSceneWithTwoLayers(); + runtimeScene.renderAndStep(1000 / 60); + + expect(readPixel(runtimeGame, 200, 300)).to.eql([255, 0, 0]); + expect(readPixel(runtimeGame, 100, 100)).to.eql([0, 255, 0]); + expect(readPixel(runtimeGame, 600, 300)).to.eql([0, 0, 255]); + + runtimeGame.dispose(true); + gameContainer.remove(); + }); + + it('keeps the layers below visible when a layer has a post-processing effect', () => { + const { runtimeGame, runtimeScene, gameContainer } = + makeSceneWithTwoLayers(); + const topLayer = runtimeScene.getLayer('Top'); + topLayer.addEffect(bloomEffectData); + expect(topLayer.getRenderer().hasPostProcessingPass()).to.be(true); + + runtimeScene.renderAndStep(1000 / 60); + + // The layers below are still visible where the layer with the effect is + // empty (the bloom of the green quad is added on top of them). + expect(readPixel(runtimeGame, 200, 300)[0]).to.be.greaterThan(200); + expect(readPixel(runtimeGame, 600, 300)[2]).to.be.greaterThan(200); + // And the object of the layer with the effect is still rendered. + expect(readPixel(runtimeGame, 100, 100)[1]).to.be.greaterThan(200); + + runtimeGame.dispose(true); + gameContainer.remove(); + }); + + it('renders the background color of the scene on a layer with a post-processing effect', () => { + const { runtimeGame, runtimeScene, gameContainer } = + makeSceneWithTwoLayers(); + runtimeScene.getLayer('').addEffect(bloomEffectData); + + runtimeScene.renderAndStep(1000 / 60); + + expect(readPixel(runtimeGame, 600, 300)[2]).to.be.greaterThan(200); + expect(readPixel(runtimeGame, 200, 300)[0]).to.be.greaterThan(200); + + runtimeGame.dispose(true); + gameContainer.remove(); + }); + + it('renders a layer with a post-processing effect at the resolution of the canvas', () => { + const { runtimeGame, runtimeScene, gameContainer } = + makeSceneWithTwoLayers(); + runtimeScene.getLayer('Top').addEffect(bloomEffectData); + + // This is done by the game as soon as the game resolution is updated + // (on startup or when the window is resized). + runtimeScene.onGameResolutionResized(); + runtimeScene.renderAndStep(1000 / 60); + + const threeRenderer = runtimeGame.getRenderer().getThreeRenderer(); + const effectComposer = runtimeScene + .getLayer('Top') + .getRenderer() + .getThreeEffectComposer(); + const gl = threeRenderer.getContext(); + + expect(effectComposer.renderTarget1.width).to.be(gl.drawingBufferWidth); + expect(effectComposer.renderTarget1.height).to.be(gl.drawingBufferHeight); + + runtimeGame.dispose(true); + gameContainer.remove(); + }); +}); From 34641cd112cca7b6b5c1f579792a9a28102db83b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Pasteau?= <4895034+ClementPasteau@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:30:19 +0200 Subject: [PATCH 2/2] Also fix Bevelfilter --- Extensions/Effects/bevel-pixi-filter.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Extensions/Effects/bevel-pixi-filter.ts b/Extensions/Effects/bevel-pixi-filter.ts index 85f981811ae9..ddccc96379c9 100644 --- a/Extensions/Effects/bevel-pixi-filter.ts +++ b/Extensions/Effects/bevel-pixi-filter.ts @@ -12,11 +12,24 @@ namespace gdjs { lc: number; sc: number; } + /** + * `PIXI.filters.BevelFilter` reads the pixels up to `thickness` pixels away, but + * declares a padding of 1. Keep the padding in sync with the thickness, otherwise + * the filter reads outside of what PixiJS rendered and a dark line appears on the + * edges of the screen. + */ + const updateBevelFilterPadding = function ( + bevelFilter: PIXI.filters.BevelFilter + ) { + bevelFilter.padding = Math.max(1, bevelFilter.thickness); + }; + gdjs.PixiFiltersTools.registerFilterCreator( 'Bevel', new (class extends gdjs.PixiFiltersTools.PixiFilterCreator { makePIXIFilter(target: EffectsTarget, effectData) { const bevelFilter = new PIXI.filters.BevelFilter(); + updateBevelFilterPadding(bevelFilter); return bevelFilter; } updatePreRender(filter: PIXI.Filter, target: EffectsTarget) {} @@ -31,6 +44,7 @@ namespace gdjs { bevelFilter.rotation = value; } else if (parameterName === 'thickness') { bevelFilter.thickness = value; + updateBevelFilterPadding(bevelFilter); } else if (parameterName === 'distance') { bevelFilter.distance = value; } else if (parameterName === 'lightAlpha') { @@ -128,6 +142,7 @@ namespace gdjs { bevelFilter.shadowAlpha = data.sa; bevelFilter.lightColor = data.lc; bevelFilter.shadowColor = data.sc; + updateBevelFilterPadding(bevelFilter); } })() );