From 30a1c96a504d7a1cd6b1cac02a6e16f00bd97c64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 17:29:52 +0200 Subject: [PATCH 1/5] feat(3d): add SpotLight object with nearest-lights performance guardrails --- Extensions/3D/JsExtension.js | 716 +++++++++++++++- Extensions/3D/SpotLightRuntimeObject.ts | 767 ++++++++++++++++++ .../src/ObjectEditor/Editors/Model3DEditor.js | 11 + newIDE/app/src/ObjectsList/index.js | 1 + 4 files changed, 1494 insertions(+), 1 deletion(-) create mode 100644 Extensions/3D/SpotLightRuntimeObject.ts diff --git a/Extensions/3D/JsExtension.js b/Extensions/3D/JsExtension.js index a1600760912c..ee23bfc48562 100644 --- a/Extensions/3D/JsExtension.js +++ b/Extensions/3D/JsExtension.js @@ -1690,6 +1690,447 @@ module.exports = { .getCodeExtraInformation() .setFunctionName('setColor'); + const SpotLightObject = new gd.ObjectJsImplementation(); + SpotLightObject.updateProperty = function (propertyName, newValue) { + const objectContent = this.content; + if ( + propertyName === 'width' || + propertyName === 'height' || + propertyName === 'depth' || + propertyName === 'intensity' || + propertyName === 'distance' || + propertyName === 'angle' || + propertyName === 'penumbra' || + propertyName === 'decay' || + propertyName === 'shadowBias' || + propertyName === 'shadowNormalBias' || + propertyName === 'shadowRadius' || + propertyName === 'shadowNear' || + propertyName === 'shadowFar' + ) { + const value = parseFloat(newValue); + if (value !== value) { + return false; + } + objectContent[propertyName] = value; + return true; + } + if (propertyName === 'color' || propertyName === 'shadowQuality') { + objectContent[propertyName] = newValue; + return true; + } + if ( + propertyName === 'enabled' || + propertyName === 'castShadow' || + propertyName === 'guardrailsEnabled' + ) { + objectContent[propertyName] = + newValue === '1' || newValue === 'true'; + return true; + } + + return false; + }; + SpotLightObject.getProperties = function () { + const objectProperties = new gd.MapStringPropertyDescriptor(); + const objectContent = this.content; + + objectProperties + .getOrCreate('width') + .setValue((objectContent.width || 0).toString()) + .setType('number') + .setLabel(_('Width')) + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Default size')); + objectProperties + .getOrCreate('height') + .setValue((objectContent.height || 0).toString()) + .setType('number') + .setLabel(_('Height')) + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Default size')); + objectProperties + .getOrCreate('depth') + .setValue((objectContent.depth || 0).toString()) + .setType('number') + .setLabel(_('Depth')) + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Default size')); + + objectProperties + .getOrCreate('enabled') + .setValue(objectContent.enabled ? 'true' : 'false') + .setType('boolean') + .setLabel(_('Enabled')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('color') + .setValue(objectContent.color || '255;255;255') + .setType('color') + .setLabel(_('Color')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('intensity') + .setValue((objectContent.intensity || 0).toString()) + .setType('number') + .setLabel(_('Intensity')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('distance') + .setValue((objectContent.distance || 0).toString()) + .setType('number') + .setLabel(_('Maximum distance')) + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription(_('0 means unlimited range.')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('angle') + .setValue((objectContent.angle || 0).toString()) + .setType('number') + .setLabel(_('Cone angle')) + .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) + .setDescription(_('Maximum cone angle in degrees.')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('penumbra') + .setValue((objectContent.penumbra || 0).toString()) + .setType('number') + .setLabel(_('Penumbra')) + .setDescription(_('Edge softness, between 0 and 1.')) + .setGroup(_('Light')); + objectProperties + .getOrCreate('decay') + .setValue((objectContent.decay || 0).toString()) + .setType('number') + .setLabel(_('Decay')) + .setDescription(_('How quickly light fades with distance.')) + .setGroup(_('Light')); + + objectProperties + .getOrCreate('guardrailsEnabled') + .setValue(objectContent.guardrailsEnabled ? 'true' : 'false') + .setType('boolean') + .setLabel(_('Use nearest-lights guardrails')) + .setDescription( + _( + 'If enabled, this light participates in the nearest-lights limit system.' + ) + ) + .setGroup(_('Performance')) + .setAdvanced(true); + + objectProperties + .getOrCreate('castShadow') + .setValue(objectContent.castShadow ? 'true' : 'false') + .setType('boolean') + .setLabel(_('Shadow casting')) + .setGroup(_('Shadows')); + objectProperties + .getOrCreate('shadowQuality') + .setValue(objectContent.shadowQuality || 'medium') + .setType('choice') + .addChoice('low', _('Low quality')) + .addChoice('medium', _('Medium quality')) + .addChoice('high', _('High quality')) + .setLabel(_('Shadow quality')) + .setGroup(_('Shadows')); + objectProperties + .getOrCreate('shadowBias') + .setValue((objectContent.shadowBias || 0).toString()) + .setType('number') + .setLabel(_('Shadow bias')) + .setGroup(_('Shadows')) + .setAdvanced(true); + objectProperties + .getOrCreate('shadowNormalBias') + .setValue((objectContent.shadowNormalBias || 0).toString()) + .setType('number') + .setLabel(_('Shadow normal bias')) + .setGroup(_('Shadows')) + .setAdvanced(true); + objectProperties + .getOrCreate('shadowRadius') + .setValue((objectContent.shadowRadius || 0).toString()) + .setType('number') + .setLabel(_('Shadow softness')) + .setGroup(_('Shadows')) + .setAdvanced(true); + objectProperties + .getOrCreate('shadowNear') + .setValue((objectContent.shadowNear || 0).toString()) + .setType('number') + .setLabel(_('Shadow near')) + .setGroup(_('Shadows')) + .setAdvanced(true); + objectProperties + .getOrCreate('shadowFar') + .setValue((objectContent.shadowFar || 0).toString()) + .setType('number') + .setLabel(_('Shadow far')) + .setGroup(_('Shadows')) + .setAdvanced(true); + + return objectProperties; + }; + SpotLightObject.content = { + width: 48, + height: 48, + depth: 48, + enabled: true, + color: '255;244;214', + intensity: 1, + distance: 600, + angle: 45, + penumbra: 0.1, + decay: 2, + castShadow: false, + shadowQuality: 'medium', + shadowBias: 0.001, + shadowNormalBias: 0.02, + shadowRadius: 1.5, + shadowNear: 1, + shadowFar: 2000, + guardrailsEnabled: true, + }; + SpotLightObject.updateInitialInstanceProperty = function ( + instance, + propertyName, + newValue + ) { + return false; + }; + SpotLightObject.getInitialInstanceProperties = function (instance) { + const instanceProperties = new gd.MapStringPropertyDescriptor(); + return instanceProperties; + }; + + const spotLightObject = extension + .addObject( + 'SpotLightObject', + _('3D Spot Light'), + _( + 'A spotlight object for 3D scenes. Includes nearest-lights guardrails to keep performance stable.' + ), + 'CppPlatform/Extensions/lightIcon32.png', + SpotLightObject + ) + .setCategory('Visual effect') + .addDefaultBehavior('ResizableCapability::ResizableBehavior') + .addDefaultBehavior('ScalableCapability::ScalableBehavior') + .addDefaultBehavior('FlippableCapability::FlippableBehavior') + .addDefaultBehavior('Scene3D::Base3DBehavior') + .markAsRenderedIn3D() + .setIncludeFile('Extensions/3D/A_RuntimeObject3D.js') + .addIncludeFile('Extensions/3D/A_RuntimeObject3DRenderer.js') + .addIncludeFile('Extensions/3D/SpotLightRuntimeObject.js'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'boolean', + 'Enabled', + _('Enabled'), + _('if the spot light is enabled'), + _('enabled'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters( + 'boolean', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Enable or disable this light.') + ) + ) + .setFunctionName('setEnabled') + .setGetter('isEnabled'); + + spotLightObject + .addScopedCondition( + 'IsActiveAfterGuardrails', + _('Is active after guardrails'), + _('Check if this spot light is currently active after guardrails.'), + _('_PARAM0_ is active after guardrails'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png', + 'CppPlatform/Extensions/lightIcon16.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .setFunctionName('isActiveAfterGuardrails'); + + spotLightObject + .addScopedAction( + 'SetColor', + _('Color'), + _('Set the light color.'), + _('Set color of _PARAM0_ to _PARAM1_'), + _('Spot light'), + 'res/actions/color24.png', + 'res/actions/color.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .addParameter('color', _('Color'), '', false) + .getCodeExtraInformation() + .setFunctionName('setColor'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'number', + 'Intensity', + _('Intensity'), + _('the light intensity'), + _('the intensity'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setIntensity') + .setGetter('getIntensity'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'number', + 'Distance', + _('Maximum distance'), + _('the maximum distance'), + _('the maximum distance'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Maximum range of the light. 0 means unlimited.') + ) + ) + .setFunctionName('setDistance') + .setGetter('getDistance'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'number', + 'Angle', + _('Cone angle'), + _('the cone angle in degrees'), + _('the cone angle'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Cone angle in degrees.') + ) + ) + .setFunctionName('setConeAngle') + .setGetter('getConeAngle'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'number', + 'Penumbra', + _('Penumbra'), + _('the penumbra between 0 and 1'), + _('the penumbra'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setPenumbra') + .setGetter('getPenumbra'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'number', + 'Decay', + _('Decay'), + _('the light decay'), + _('the decay'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setDecay') + .setGetter('getDecay'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'boolean', + 'CastShadow', + _('Shadow casting'), + _('if shadow casting is enabled'), + _('shadow casting'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters( + 'boolean', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Enable or disable shadow casting.') + ) + ) + .setFunctionName('setCastShadow') + .setGetter('isCastingShadow'); + + spotLightObject + .addExpressionAndConditionAndAction( + 'boolean', + 'GuardrailsEnabled', + _('Use nearest-lights guardrails'), + _('if nearest-lights guardrails are enabled for this light'), + _('nearest-lights guardrails'), + _('Spot light'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addParameter('object', _('3D spot light'), 'SpotLightObject', false) + .useStandardParameters( + 'boolean', + gd.ParameterOptions.makeNewOptions().setDescription( + _('If enabled, this light is managed by the nearest-lights limit system.') + ) + ) + .setFunctionName('setGuardrailsEnabled') + .setGetter('areGuardrailsEnabled'); + + extension + .addAction( + 'SetMaxActiveSpotLights', + _('Set max active 3D spot lights'), + _( + 'Set the maximum number of active spot lights for a layer (nearest to camera are kept active).' + ), + _('Set max active 3D spot lights on layer _PARAM1_ to _PARAM2_'), + _('Layers and cameras'), + 'CppPlatform/Extensions/lightIcon24.png', + 'CppPlatform/Extensions/lightIcon16.png' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter('layer', _('Layer'), '', true) + .setDefaultValue('""') + .addParameter('expression', _('Maximum active spot lights'), '', false) + .setFunctionName('gdjs.scene3d.spotLights.setMaxActiveSpotLights') + .setIncludeFile('Extensions/3D/SpotLightRuntimeObject.js'); + + extension + .addExpression( + 'number', + 'MaxActiveSpotLights', + _('Max active 3D spot lights'), + _('the maximum number of active 3D spot lights on the layer'), + _('Layers and cameras'), + 'CppPlatform/Extensions/lightIcon24.png' + ) + .addCodeOnlyParameter('currentScene', '') + .addParameter('layer', _('Layer'), '', true) + .setDefaultValue('""') + .setFunctionName('gdjs.scene3d.spotLights.getMaxActiveSpotLights') + .setIncludeFile('Extensions/3D/SpotLightRuntimeObject.js'); + extension .addExpressionAndConditionAndAction( 'number', @@ -2257,7 +2698,14 @@ module.exports = { * * ℹ️ Run `node import-GDJS-Runtime.js` (in newIDE/app/scripts) if you make any change. */ - registerEditorConfigurations: function (objectsEditorService) {}, + registerEditorConfigurations: function (objectsEditorService) { + objectsEditorService.registerEditorConfiguration( + 'Scene3D::SpotLightObject', + objectsEditorService.getDefaultObjectJsImplementationPropertiesEditor({ + helpPagePath: '/all-features/3d/reference', + }) + ); + }, /** * Register renderers for instance of objects on the scene editor. * @@ -2345,6 +2793,263 @@ module.exports = { return newTransparentMaterial; }; + class RenderedSpotLightObject2DInstance extends RenderedInstance { + /** @type {number} */ + _defaultWidth; + /** @type {number} */ + _defaultHeight; + /** @type {number} */ + _defaultDepth; + _coneGraphics; + _originGraphics; + + constructor( + project, + instance, + associatedObjectConfiguration, + pixiContainer, + pixiResourcesLoader + ) { + super( + project, + instance, + associatedObjectConfiguration, + pixiContainer, + pixiResourcesLoader + ); + const object = gd.castObject( + this._associatedObjectConfiguration, + gd.ObjectJsImplementation + ); + this._defaultWidth = object.content.width || 48; + this._defaultHeight = object.content.height || 48; + this._defaultDepth = object.content.depth || 48; + + this._pixiObject = new PIXI.Container(); + this._coneGraphics = new PIXI.Graphics(); + this._originGraphics = new PIXI.Graphics(); + this._pixiObject.addChild(this._coneGraphics); + this._pixiObject.addChild(this._originGraphics); + this._pixiContainer.addChild(this._pixiObject); + } + + onRemovedFromScene() { + super.onRemovedFromScene(); + this._pixiObject.destroy({ children: true }); + } + + static getThumbnail() { + return 'CppPlatform/Extensions/lightIcon32.png'; + } + + update() { + const object = gd.castObject( + this._associatedObjectConfiguration, + gd.ObjectJsImplementation + ); + + this._defaultWidth = object.content.width || 48; + this._defaultHeight = object.content.height || 48; + this._defaultDepth = object.content.depth || 48; + + const width = this.getWidth(); + const height = this.getHeight(); + const centerX = width / 2; + const centerY = height / 2; + this._pixiObject.position.x = this._instance.getX() + centerX; + this._pixiObject.position.y = this._instance.getY() + centerY; + this._pixiObject.angle = this._instance.getAngle(); + + const distance = Math.max(0, Number(object.content.distance || 0)); + const angle = Math.max(1, Math.min(89, Number(object.content.angle || 45))); + const previewDistance = Math.max( + 20, + Math.min(220, distance > 0 ? distance * 0.25 : 120) + ); + const halfAngleInRad = (angle * Math.PI) / 360; + const leftX = -Math.sin(halfAngleInRad) * previewDistance; + const leftY = -Math.cos(halfAngleInRad) * previewDistance; + const rightX = -leftX; + const rightY = leftY; + + const color = objectsRenderingService.rgbOrHexToHexNumber( + object.content.color || '255;255;255' + ); + const enabled = object.content.enabled !== false; + const guardrailsEnabled = object.content.guardrailsEnabled !== false; + + this._coneGraphics.clear(); + this._coneGraphics.lineStyle(2, color, enabled ? 0.85 : 0.4); + this._coneGraphics.beginFill(color, enabled ? 0.12 : 0.05); + this._coneGraphics.moveTo(0, 0); + this._coneGraphics.lineTo(leftX, leftY); + this._coneGraphics.lineTo(rightX, rightY); + this._coneGraphics.closePath(); + this._coneGraphics.endFill(); + + this._originGraphics.clear(); + this._originGraphics.beginFill(color, enabled ? 0.95 : 0.5); + this._originGraphics.drawCircle(0, 0, 7); + this._originGraphics.endFill(); + this._originGraphics.lineStyle( + 2, + guardrailsEnabled ? 0x5cf26f : 0xb0b0b0, + 0.85 + ); + this._originGraphics.drawCircle(0, 0, 10); + } + + getDefaultWidth() { + return this._defaultWidth; + } + + getDefaultHeight() { + return this._defaultHeight; + } + + getDefaultDepth() { + return this._defaultDepth; + } + } + + class RenderedSpotLightObject3DInstance extends Rendered3DInstance { + /** @type {number} */ + _defaultWidth; + /** @type {number} */ + _defaultHeight; + /** @type {number} */ + _defaultDepth; + _coneMesh; + _originMesh; + + constructor( + project, + instance, + associatedObjectConfiguration, + pixiContainer, + threeGroup, + pixiResourcesLoader + ) { + super( + project, + instance, + associatedObjectConfiguration, + pixiContainer, + threeGroup, + pixiResourcesLoader + ); + const object = gd.castObject( + this._associatedObjectConfiguration, + gd.ObjectJsImplementation + ); + this._defaultWidth = object.content.width || 48; + this._defaultHeight = object.content.height || 48; + this._defaultDepth = object.content.depth || 48; + + this._pixiObject = new PIXI.Graphics(); + this._pixiContainer.addChild(this._pixiObject); + + const markerGroup = new THREE.Group(); + const originMesh = new THREE.Mesh( + new THREE.SphereGeometry(0.08, 14, 12), + new THREE.MeshBasicMaterial({ + color: 0xffeb8a, + transparent: true, + opacity: 0.95, + depthWrite: false, + }) + ); + const coneMesh = new THREE.Mesh( + new THREE.ConeGeometry(0.16, 0.55, 20, 1, true), + new THREE.MeshBasicMaterial({ + color: 0xffeb8a, + wireframe: true, + transparent: true, + opacity: 0.85, + depthWrite: false, + }) + ); + coneMesh.rotation.x = Math.PI / 2; + coneMesh.position.z = -0.28; + markerGroup.add(originMesh); + markerGroup.add(coneMesh); + + this._threeObject = markerGroup; + this._threeGroup.add(markerGroup); + this._originMesh = originMesh; + this._coneMesh = coneMesh; + } + + onRemovedFromScene() { + super.onRemovedFromScene(); + this._pixiObject.destroy({ children: true }); + } + + static getThumbnail() { + return 'CppPlatform/Extensions/lightIcon32.png'; + } + + update() { + const object = gd.castObject( + this._associatedObjectConfiguration, + gd.ObjectJsImplementation + ); + this._defaultWidth = object.content.width || 48; + this._defaultHeight = object.content.height || 48; + this._defaultDepth = object.content.depth || 48; + + const width = this.getWidth(); + const height = this.getHeight(); + const depth = this.getDepth(); + + const x = this._instance.getX() + width / 2; + const y = this._instance.getY() + height / 2; + const z = this._instance.getZ() + depth / 2; + this._threeObject.position.set(x, y, z); + this._threeObject.rotation.set( + RenderedInstance.toRad(this._instance.getRotationX()), + RenderedInstance.toRad(this._instance.getRotationY()), + RenderedInstance.toRad(this._instance.getAngle()) + ); + + const angle = Math.max(1, Math.min(89, Number(object.content.angle || 45))); + const distance = Math.max(0, Number(object.content.distance || 0)); + const coneRadiusScale = Math.max(0.25, Math.tan(RenderedInstance.toRad(angle / 2))); + const coneLengthScale = Math.max( + 0.7, + Math.min(2.4, distance > 0 ? distance / 320 : 1.2) + ); + this._coneMesh.scale.set(coneRadiusScale, coneLengthScale, coneRadiusScale); + + const color = objectsRenderingService.rgbOrHexToHexNumber( + object.content.color || '255;255;255' + ); + const enabled = object.content.enabled !== false; + this._originMesh.material.color.setHex(color); + this._coneMesh.material.color.setHex(color); + this._originMesh.material.opacity = enabled ? 0.95 : 0.4; + this._coneMesh.material.opacity = enabled ? 0.85 : 0.25; + + this._pixiObject.clear(); + this._pixiObject.beginFill(color, enabled ? 0.55 : 0.25); + this._pixiObject.drawCircle(0, 0, 5); + this._pixiObject.endFill(); + this._pixiObject.position.set(x, y); + } + + getDefaultWidth() { + return this._defaultWidth; + } + + getDefaultHeight() { + return this._defaultHeight; + } + + getDefaultDepth() { + return this._defaultDepth; + } + } + class RenderedCube3DObject2DInstance extends RenderedInstance { /** @type {number} */ _defaultWidth; @@ -3052,6 +3757,15 @@ module.exports = { } } + objectsRenderingService.registerInstanceRenderer( + 'Scene3D::SpotLightObject', + RenderedSpotLightObject2DInstance + ); + objectsRenderingService.registerInstance3DRenderer( + 'Scene3D::SpotLightObject', + RenderedSpotLightObject3DInstance + ); + objectsRenderingService.registerInstanceRenderer( 'Scene3D::Cube3DObject', RenderedCube3DObject2DInstance diff --git a/Extensions/3D/SpotLightRuntimeObject.ts b/Extensions/3D/SpotLightRuntimeObject.ts new file mode 100644 index 000000000000..bb805450172d --- /dev/null +++ b/Extensions/3D/SpotLightRuntimeObject.ts @@ -0,0 +1,767 @@ +namespace gdjs { + const DEFAULT_MAX_ACTIVE_SPOT_LIGHTS = 8; + + const clampSpotLightAngle = (value: number): number => + Math.max(1, Math.min(89, value)); + + const clampShadowQuality = (value: string): 'low' | 'medium' | 'high' => { + const normalized = (value || '').toLowerCase(); + if (normalized === 'low' || normalized === 'high') { + return normalized; + } + return 'medium'; + }; + + const shadowQualityToMapSize = (quality: string): integer => { + const normalized = clampShadowQuality(quality); + if (normalized === 'low') { + return 512; + } + if (normalized === 'high') { + return 2048; + } + return 1024; + }; + + const sanitizeLayerName = (layerName: string): string => layerName || ''; + + interface SpotLightGuardrailsState { + lights: Set; + maxActiveByLayerName: Map; + lastUpdateTimeFromStartMs: number; + } + + const spotLightGuardrailsStateByRuntimeScene = new WeakMap< + gdjs.RuntimeScene, + SpotLightGuardrailsState + >(); + + const getOrCreateGuardrailsState = ( + runtimeScene: gdjs.RuntimeScene + ): SpotLightGuardrailsState => { + const existingState = spotLightGuardrailsStateByRuntimeScene.get( + runtimeScene + ); + if (existingState) { + return existingState; + } + + const newState: SpotLightGuardrailsState = { + lights: new Set(), + maxActiveByLayerName: new Map(), + lastUpdateTimeFromStartMs: -1, + }; + spotLightGuardrailsStateByRuntimeScene.set(runtimeScene, newState); + return newState; + }; + + export namespace scene3d { + export namespace spotLights { + export const registerSpotLight = ( + light: gdjs.SpotLightRuntimeObject + ): void => { + const runtimeScene = light.getRuntimeScene(); + const state = getOrCreateGuardrailsState(runtimeScene); + state.lights.add(light); + }; + + export const unregisterSpotLight = ( + light: gdjs.SpotLightRuntimeObject + ): void => { + const runtimeScene = light.getRuntimeScene(); + const state = spotLightGuardrailsStateByRuntimeScene.get(runtimeScene); + if (!state) { + return; + } + state.lights.delete(light); + }; + + export const setMaxActiveSpotLights = ( + runtimeScene: gdjs.RuntimeScene, + layerName: string, + maxActiveSpotLights: number + ): void => { + const state = getOrCreateGuardrailsState(runtimeScene); + const safeMax = Math.max(0, Math.floor(maxActiveSpotLights)); + state.maxActiveByLayerName.set(sanitizeLayerName(layerName), safeMax); + }; + + export const getMaxActiveSpotLights = ( + runtimeScene: gdjs.RuntimeScene, + layerName: string + ): number => { + const state = getOrCreateGuardrailsState(runtimeScene); + const configuredMax = state.maxActiveByLayerName.get( + sanitizeLayerName(layerName) + ); + if (configuredMax === undefined) { + return DEFAULT_MAX_ACTIVE_SPOT_LIGHTS; + } + return configuredMax; + }; + + export const updateGuardrailsForScene = ( + runtimeScene: gdjs.RuntimeScene + ): void => { + const state = spotLightGuardrailsStateByRuntimeScene.get(runtimeScene); + if (!state) { + return; + } + + const timeFromStart = runtimeScene.getTimeManager().getTimeFromStart(); + if (state.lastUpdateTimeFromStartMs === timeFromStart) { + return; + } + state.lastUpdateTimeFromStartMs = timeFromStart; + + const spotLightsByLayerName = new Map< + string, + gdjs.SpotLightRuntimeObject[] + >(); + + for (const spotLight of state.lights) { + if (!spotLight.shouldUseGuardrails()) { + spotLight.setGuardrailActive(true); + continue; + } + + const layerName = sanitizeLayerName(spotLight.getLayer()); + const lights = spotLightsByLayerName.get(layerName); + if (lights) { + lights.push(spotLight); + } else { + spotLightsByLayerName.set(layerName, [spotLight]); + } + } + + for (const [layerName, lights] of spotLightsByLayerName) { + if (lights.length === 0) { + continue; + } + + if (!runtimeScene.hasLayer(layerName)) { + for (const light of lights) { + light.setGuardrailActive(false); + } + continue; + } + + const layer = runtimeScene.getLayer(layerName); + const cameraX = layer.getCameraX(); + const cameraY = layer.getCameraY(); + const cameraZ = layer.getCameraZ(layer.getInitialCamera3DFieldOfView()); + + lights.sort( + (firstLight, secondLight) => + firstLight.getDistanceToCameraSquared(cameraX, cameraY, cameraZ) - + secondLight.getDistanceToCameraSquared(cameraX, cameraY, cameraZ) + ); + + const maxActiveLights = getMaxActiveSpotLights(runtimeScene, layerName); + + for (let index = 0; index < lights.length; index++) { + lights[index].setGuardrailActive(index < maxActiveLights); + } + } + }; + } + } + + export interface SpotLightObjectData extends Object3DData { + content: Object3DDataContent & { + enabled: boolean | undefined; + color: string; + intensity: number | undefined; + distance: number | undefined; + angle: number | undefined; + penumbra: number | undefined; + decay: number | undefined; + castShadow: boolean | undefined; + shadowQuality: 'low' | 'medium' | 'high' | undefined; + shadowBias: number | undefined; + shadowNormalBias: number | undefined; + shadowRadius: number | undefined; + shadowNear: number | undefined; + shadowFar: number | undefined; + guardrailsEnabled: boolean | undefined; + }; + } + + type SpotLightObjectNetworkSyncDataType = { + en: boolean; + c: string; + i: number; + d: number; + a: number; + p: number; + dc: number; + cs: boolean; + sq: 'low' | 'medium' | 'high'; + sb: number; + snb: number; + sr: number; + sn: number; + sf: number; + ge: boolean; + ga: boolean; + }; + + type SpotLightObjectNetworkSyncData = Object3DNetworkSyncData & + SpotLightObjectNetworkSyncDataType; + + export class SpotLightRuntimeObject extends gdjs.RuntimeObject3D { + private _renderer: gdjs.SpotLightRuntimeObjectRenderer; + + private _enabled: boolean; + private _color: string; + private _intensity: number; + private _distance: number; + private _angle: number; + private _penumbra: number; + private _decay: number; + private _castShadow: boolean; + private _shadowQuality: 'low' | 'medium' | 'high'; + private _shadowBias: number; + private _shadowNormalBias: number; + private _shadowRadius: number; + private _shadowNear: number; + private _shadowFar: number; + private _guardrailsEnabled: boolean; + private _guardrailActive: boolean; + + constructor( + instanceContainer: gdjs.RuntimeInstanceContainer, + objectData: SpotLightObjectData, + instanceData?: InstanceData + ) { + super(instanceContainer, objectData, instanceData); + + const objectContent = objectData.content; + this._enabled = objectContent.enabled === undefined ? true : !!objectContent.enabled; + this._color = objectContent.color || '255;255;255'; + this._intensity = Math.max( + 0, + objectContent.intensity !== undefined ? objectContent.intensity : 1 + ); + this._distance = Math.max( + 0, + objectContent.distance !== undefined ? objectContent.distance : 600 + ); + this._angle = clampSpotLightAngle( + objectContent.angle !== undefined ? objectContent.angle : 45 + ); + this._penumbra = Math.max( + 0, + Math.min(1, objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1) + ); + this._decay = Math.max(0, objectContent.decay !== undefined ? objectContent.decay : 2); + this._castShadow = !!objectContent.castShadow; + this._shadowQuality = clampShadowQuality(objectContent.shadowQuality || 'medium'); + this._shadowBias = objectContent.shadowBias !== undefined ? objectContent.shadowBias : 0.001; + this._shadowNormalBias = Math.max( + 0, + objectContent.shadowNormalBias !== undefined ? objectContent.shadowNormalBias : 0.02 + ); + this._shadowRadius = Math.max( + 0, + objectContent.shadowRadius !== undefined ? objectContent.shadowRadius : 1.5 + ); + this._shadowNear = Math.max( + 0.01, + objectContent.shadowNear !== undefined ? objectContent.shadowNear : 1 + ); + this._shadowFar = Math.max( + this._shadowNear + 1, + objectContent.shadowFar !== undefined ? objectContent.shadowFar : 2000 + ); + this._guardrailsEnabled = + objectContent.guardrailsEnabled === undefined + ? true + : !!objectContent.guardrailsEnabled; + this._guardrailActive = true; + + this._renderer = new gdjs.SpotLightRuntimeObjectRenderer( + this, + instanceContainer + ); + this._applyAllPropertiesToRenderer(); + + gdjs.scene3d.spotLights.registerSpotLight(this); + + this.onCreated(); + } + + getRenderer(): gdjs.RuntimeObject3DRenderer { + return this._renderer; + } + + private _applyAllPropertiesToRenderer(): void { + this._renderer.setColor(this._color); + this._renderer.setIntensity(this._intensity); + this._renderer.setDistance(this._distance); + this._renderer.setAngle(this._angle); + this._renderer.setPenumbra(this._penumbra); + this._renderer.setDecay(this._decay); + this._renderer.setCastShadow(this._castShadow); + this._renderer.setShadowMapSize(shadowQualityToMapSize(this._shadowQuality)); + this._renderer.setShadowBias(this._shadowBias); + this._renderer.setShadowNormalBias(this._shadowNormalBias); + this._renderer.setShadowRadius(this._shadowRadius); + this._renderer.setShadowNear(this._shadowNear); + this._renderer.setShadowFar(this._shadowFar); + this._renderer.setRuntimeEnabled(this._enabled); + this._renderer.setGuardrailActive( + !this._guardrailsEnabled || this._guardrailActive + ); + } + + override updateFromObjectData( + oldObjectData: SpotLightObjectData, + newObjectData: SpotLightObjectData + ): boolean { + super.updateFromObjectData(oldObjectData, newObjectData); + + const objectContent = newObjectData.content; + this.setEnabled(objectContent.enabled === undefined ? true : !!objectContent.enabled); + this.setColor(objectContent.color || '255;255;255'); + this.setIntensity(objectContent.intensity !== undefined ? objectContent.intensity : 1); + this.setDistance(objectContent.distance !== undefined ? objectContent.distance : 600); + this.setConeAngle(objectContent.angle !== undefined ? objectContent.angle : 45); + this.setPenumbra(objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1); + this.setDecay(objectContent.decay !== undefined ? objectContent.decay : 2); + this.setCastShadow(!!objectContent.castShadow); + this.setShadowQuality(objectContent.shadowQuality || 'medium'); + this.setShadowBias(objectContent.shadowBias !== undefined ? objectContent.shadowBias : 0.001); + this.setShadowNormalBias( + objectContent.shadowNormalBias !== undefined ? objectContent.shadowNormalBias : 0.02 + ); + this.setShadowRadius(objectContent.shadowRadius !== undefined ? objectContent.shadowRadius : 1.5); + this.setShadowNear(objectContent.shadowNear !== undefined ? objectContent.shadowNear : 1); + this.setShadowFar(objectContent.shadowFar !== undefined ? objectContent.shadowFar : 2000); + this.setGuardrailsEnabled( + objectContent.guardrailsEnabled === undefined + ? true + : !!objectContent.guardrailsEnabled + ); + + return true; + } + + override onDeletedFromScene(): void { + gdjs.scene3d.spotLights.unregisterSpotLight(this); + super.onDeletedFromScene(); + } + + override onDestroyed(): void { + gdjs.scene3d.spotLights.unregisterSpotLight(this); + super.onDestroyed(); + } + + override updatePreRender(): void { + gdjs.scene3d.spotLights.updateGuardrailsForScene(this.getRuntimeScene()); + } + + override getNetworkSyncData( + syncOptions: GetNetworkSyncDataOptions + ): SpotLightObjectNetworkSyncData { + return { + ...super.getNetworkSyncData(syncOptions), + en: this._enabled, + c: this._color, + i: this._intensity, + d: this._distance, + a: this._angle, + p: this._penumbra, + dc: this._decay, + cs: this._castShadow, + sq: this._shadowQuality, + sb: this._shadowBias, + snb: this._shadowNormalBias, + sr: this._shadowRadius, + sn: this._shadowNear, + sf: this._shadowFar, + ge: this._guardrailsEnabled, + ga: this._guardrailActive, + }; + } + + override updateFromNetworkSyncData( + networkSyncData: SpotLightObjectNetworkSyncData, + options: UpdateFromNetworkSyncDataOptions + ): void { + super.updateFromNetworkSyncData(networkSyncData, options); + + if (networkSyncData.en !== undefined) this.setEnabled(networkSyncData.en); + if (networkSyncData.c !== undefined) this.setColor(networkSyncData.c); + if (networkSyncData.i !== undefined) this.setIntensity(networkSyncData.i); + if (networkSyncData.d !== undefined) this.setDistance(networkSyncData.d); + if (networkSyncData.a !== undefined) this.setConeAngle(networkSyncData.a); + if (networkSyncData.p !== undefined) this.setPenumbra(networkSyncData.p); + if (networkSyncData.dc !== undefined) this.setDecay(networkSyncData.dc); + if (networkSyncData.cs !== undefined) this.setCastShadow(networkSyncData.cs); + if (networkSyncData.sq !== undefined) + this.setShadowQuality(networkSyncData.sq); + if (networkSyncData.sb !== undefined) + this.setShadowBias(networkSyncData.sb); + if (networkSyncData.snb !== undefined) + this.setShadowNormalBias(networkSyncData.snb); + if (networkSyncData.sr !== undefined) + this.setShadowRadius(networkSyncData.sr); + if (networkSyncData.sn !== undefined) + this.setShadowNear(networkSyncData.sn); + if (networkSyncData.sf !== undefined) + this.setShadowFar(networkSyncData.sf); + if (networkSyncData.ge !== undefined) + this.setGuardrailsEnabled(networkSyncData.ge); + if (networkSyncData.ga !== undefined) + this.setGuardrailActive(networkSyncData.ga); + } + + setEnabled(enabled: boolean): void { + this._enabled = !!enabled; + this._renderer.setRuntimeEnabled(this._enabled); + } + + isEnabled(): boolean { + return this._enabled; + } + + isActiveAfterGuardrails(): boolean { + return this._enabled && (!this._guardrailsEnabled || this._guardrailActive); + } + + setColor(color: string): void { + this._color = color; + this._renderer.setColor(color); + } + + getColor(): string { + return this._color; + } + + setIntensity(intensity: number): void { + this._intensity = Math.max(0, intensity); + this._renderer.setIntensity(this._intensity); + } + + getIntensity(): number { + return this._intensity; + } + + setDistance(distance: number): void { + this._distance = Math.max(0, distance); + this._renderer.setDistance(this._distance); + } + + getDistance(): number { + return this._distance; + } + + setConeAngle(angle: number): void { + this._angle = clampSpotLightAngle(angle); + this._renderer.setAngle(this._angle); + } + + getConeAngle(): number { + return this._angle; + } + + setPenumbra(penumbra: number): void { + this._penumbra = Math.max(0, Math.min(1, penumbra)); + this._renderer.setPenumbra(this._penumbra); + } + + getPenumbra(): number { + return this._penumbra; + } + + setDecay(decay: number): void { + this._decay = Math.max(0, decay); + this._renderer.setDecay(this._decay); + } + + getDecay(): number { + return this._decay; + } + + setCastShadow(castShadow: boolean): void { + this._castShadow = !!castShadow; + this._renderer.setCastShadow(this._castShadow); + } + + isCastingShadow(): boolean { + return this._castShadow; + } + + setShadowQuality(shadowQuality: string): void { + this._shadowQuality = clampShadowQuality(shadowQuality); + this._renderer.setShadowMapSize(shadowQualityToMapSize(this._shadowQuality)); + } + + getShadowQuality(): 'low' | 'medium' | 'high' { + return this._shadowQuality; + } + + setShadowBias(shadowBias: number): void { + this._shadowBias = shadowBias; + this._renderer.setShadowBias(this._shadowBias); + } + + getShadowBias(): number { + return this._shadowBias; + } + + setShadowNormalBias(shadowNormalBias: number): void { + this._shadowNormalBias = Math.max(0, shadowNormalBias); + this._renderer.setShadowNormalBias(this._shadowNormalBias); + } + + getShadowNormalBias(): number { + return this._shadowNormalBias; + } + + setShadowRadius(shadowRadius: number): void { + this._shadowRadius = Math.max(0, shadowRadius); + this._renderer.setShadowRadius(this._shadowRadius); + } + + getShadowRadius(): number { + return this._shadowRadius; + } + + setShadowNear(shadowNear: number): void { + this._shadowNear = Math.max(0.01, shadowNear); + if (this._shadowFar < this._shadowNear + 1) { + this._shadowFar = this._shadowNear + 1; + this._renderer.setShadowFar(this._shadowFar); + } + this._renderer.setShadowNear(this._shadowNear); + } + + getShadowNear(): number { + return this._shadowNear; + } + + setShadowFar(shadowFar: number): void { + this._shadowFar = Math.max(this._shadowNear + 1, shadowFar); + this._renderer.setShadowFar(this._shadowFar); + } + + getShadowFar(): number { + return this._shadowFar; + } + + setGuardrailsEnabled(enabled: boolean): void { + this._guardrailsEnabled = !!enabled; + this._renderer.setGuardrailActive( + !this._guardrailsEnabled || this._guardrailActive + ); + } + + areGuardrailsEnabled(): boolean { + return this._guardrailsEnabled; + } + + shouldUseGuardrails(): boolean { + return this._guardrailsEnabled && this._enabled && !this.isHidden(); + } + + setGuardrailActive(active: boolean): void { + this._guardrailActive = !!active; + this._renderer.setGuardrailActive( + !this._guardrailsEnabled || this._guardrailActive + ); + } + + getDistanceToCameraSquared( + cameraX: number, + cameraY: number, + cameraZ: number + ): number { + const dx = this.getCenterXInScene() - cameraX; + const dy = this.getCenterYInScene() - cameraY; + const dz = this.getCenterZInScene() - cameraZ; + return dx * dx + dy * dy + dz * dz; + } + } + + export class SpotLightRuntimeObjectRenderer extends gdjs.RuntimeObject3DRenderer { + private _object: gdjs.SpotLightRuntimeObject; + private _spotLight: THREE.SpotLight; + private _shadowMapSize: integer; + private _shadowMapDirty: boolean; + private _shadowNear: number; + private _shadowFar: number; + private _shadowCameraDirty: boolean; + private _runtimeEnabled: boolean; + private _guardrailActive: boolean; + + constructor( + runtimeObject: gdjs.SpotLightRuntimeObject, + instanceContainer: gdjs.RuntimeInstanceContainer + ) { + const threeGroup = new THREE.Group(); + const spotLight = new THREE.SpotLight(0xffffff, 1, 600, gdjs.toRad(45), 0.1, 2); + spotLight.position.set(0, 0, 0); + spotLight.target.position.set(0, 0, -1); + threeGroup.add(spotLight); + threeGroup.add(spotLight.target); + + super(runtimeObject, instanceContainer, threeGroup); + + this._object = runtimeObject; + this._spotLight = spotLight; + this._shadowMapSize = 1024; + this._shadowMapDirty = true; + this._shadowNear = 1; + this._shadowFar = 2000; + this._shadowCameraDirty = true; + this._runtimeEnabled = true; + this._guardrailActive = true; + + this.updateSize(); + this.updatePosition(); + this.updateRotation(); + this._updateShadowMapSize(); + this._updateShadowCamera(); + this._updateLightVisibility(); + } + + override updateSize(): void { + // Keep spotlight transforms stable regardless of object scaling. + this.updatePosition(); + } + + override updateRotation(): void { + super.updateRotation(); + this._spotLight.target.position.set(0, 0, -1); + this._spotLight.target.updateMatrixWorld(true); + } + + override updateVisibility(): void { + this._updateLightVisibility(); + } + + private _updateLightVisibility(): void { + this._spotLight.visible = + !this._object.isHidden() && this._runtimeEnabled && this._guardrailActive; + } + + private _sanitizeShadowMapSize(size: number): integer { + const safeSize = Math.max(256, Math.min(4096, Math.floor(size))); + const power = Math.round(Math.log2(safeSize)); + return Math.max(256, Math.min(4096, Math.pow(2, power))); + } + + private _updateShadowMapSize(): void { + if (!this._shadowMapDirty || !this._spotLight.castShadow) { + return; + } + this._shadowMapDirty = false; + + this._spotLight.shadow.mapSize.set(this._shadowMapSize, this._shadowMapSize); + this._spotLight.shadow.map?.dispose(); + this._spotLight.shadow.map = null; + this._spotLight.shadow.needsUpdate = true; + } + + private _updateShadowCamera(): void { + if (!this._shadowCameraDirty || !this._spotLight.castShadow) { + return; + } + this._shadowCameraDirty = false; + + const safeNear = Math.max(0.01, this._shadowNear); + const safeFar = Math.max(safeNear + 1, this._shadowFar); + const shadowCamera = this._spotLight.shadow.camera as THREE.PerspectiveCamera; + shadowCamera.near = safeNear; + shadowCamera.far = safeFar; + shadowCamera.fov = Math.max( + 2, + Math.min(170, gdjs.toDegrees(this._spotLight.angle) * 2) + ); + shadowCamera.updateProjectionMatrix(); + this._spotLight.shadow.needsUpdate = true; + } + + setRuntimeEnabled(enabled: boolean): void { + this._runtimeEnabled = !!enabled; + this._updateLightVisibility(); + } + + setGuardrailActive(active: boolean): void { + this._guardrailActive = !!active; + this._updateLightVisibility(); + } + + setColor(color: string): void { + this._spotLight.color.set(gdjs.rgbOrHexStringToNumber(color)); + } + + setIntensity(intensity: number): void { + this._spotLight.intensity = Math.max(0, intensity); + } + + setDistance(distance: number): void { + this._spotLight.distance = Math.max(0, distance); + this._shadowCameraDirty = true; + this._updateShadowCamera(); + } + + setAngle(angleInDegrees: number): void { + this._spotLight.angle = gdjs.toRad(clampSpotLightAngle(angleInDegrees)); + this._shadowCameraDirty = true; + this._updateShadowCamera(); + } + + setPenumbra(penumbra: number): void { + this._spotLight.penumbra = Math.max(0, Math.min(1, penumbra)); + } + + setDecay(decay: number): void { + this._spotLight.decay = Math.max(0, decay); + } + + setCastShadow(castShadow: boolean): void { + this._spotLight.castShadow = !!castShadow; + if (this._spotLight.castShadow) { + this._shadowMapDirty = true; + this._shadowCameraDirty = true; + } + this._updateShadowMapSize(); + this._updateShadowCamera(); + } + + setShadowMapSize(shadowMapSize: number): void { + this._shadowMapSize = this._sanitizeShadowMapSize(shadowMapSize); + this._shadowMapDirty = true; + this._updateShadowMapSize(); + } + + setShadowBias(shadowBias: number): void { + this._spotLight.shadow.bias = shadowBias; + } + + setShadowNormalBias(shadowNormalBias: number): void { + this._spotLight.shadow.normalBias = Math.max(0, shadowNormalBias); + } + + setShadowRadius(shadowRadius: number): void { + this._spotLight.shadow.radius = Math.max(0, shadowRadius); + } + + setShadowNear(shadowNear: number): void { + this._shadowNear = Math.max(0.01, shadowNear); + this._shadowCameraDirty = true; + this._updateShadowCamera(); + } + + setShadowFar(shadowFar: number): void { + this._shadowFar = Math.max(this._shadowNear + 1, shadowFar); + this._shadowCameraDirty = true; + this._updateShadowCamera(); + } + } + + gdjs.registerObject('Scene3D::SpotLightObject', gdjs.SpotLightRuntimeObject); +} diff --git a/newIDE/app/src/ObjectEditor/Editors/Model3DEditor.js b/newIDE/app/src/ObjectEditor/Editors/Model3DEditor.js index 554a25d67bfb..d1ee2424ac1a 100644 --- a/newIDE/app/src/ObjectEditor/Editors/Model3DEditor.js +++ b/newIDE/app/src/ObjectEditor/Editors/Model3DEditor.js @@ -71,6 +71,17 @@ export const hasLight = (layout: ?gd.Layout): boolean => { if (!layout) { return true; } + const objects = layout.getObjects(); + for ( + let objectIndex = 0; + objectIndex < objects.getObjectsCount(); + objectIndex++ + ) { + const object = objects.getObjectAt(objectIndex); + if (object.getType() === 'Scene3D::SpotLightObject') { + return true; + } + } for (let layerIndex = 0; layerIndex < layout.getLayersCount(); layerIndex++) { const layer = layout.getLayerAt(layerIndex); if (layer.getRenderingType() === '2d') { diff --git a/newIDE/app/src/ObjectsList/index.js b/newIDE/app/src/ObjectsList/index.js index 9b563296dc42..70a9d03c49ee 100644 --- a/newIDE/app/src/ObjectsList/index.js +++ b/newIDE/app/src/ObjectsList/index.js @@ -423,6 +423,7 @@ const objectTypeToDefaultName = { 'TextInput::TextInputObject': 'NewTextInput', 'Scene3D::Model3DObject': 'New3DModel', 'Scene3D::Cube3DObject': 'New3DBox', + 'Scene3D::SpotLightObject': 'New3DSpotLight', 'SpineObject::SpineObject': 'NewSpine', 'Video::VideoObject': 'NewVideo', }; From 5ae922e1770dd9428e13276569b2be18968d265f Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 19:15:08 +0200 Subject: [PATCH 2/5] style: run prettier on changed files for CI --- Extensions/3D/JsExtension.js | 28 ++++-- Extensions/3D/SpotLightRuntimeObject.ts | 128 ++++++++++++++++++------ 2 files changed, 118 insertions(+), 38 deletions(-) diff --git a/Extensions/3D/JsExtension.js b/Extensions/3D/JsExtension.js index ee23bfc48562..0880bd5df8ca 100644 --- a/Extensions/3D/JsExtension.js +++ b/Extensions/3D/JsExtension.js @@ -1724,8 +1724,7 @@ module.exports = { propertyName === 'castShadow' || propertyName === 'guardrailsEnabled' ) { - objectContent[propertyName] = - newValue === '1' || newValue === 'true'; + objectContent[propertyName] = newValue === '1' || newValue === 'true'; return true; } @@ -2091,7 +2090,9 @@ module.exports = { .useStandardParameters( 'boolean', gd.ParameterOptions.makeNewOptions().setDescription( - _('If enabled, this light is managed by the nearest-lights limit system.') + _( + 'If enabled, this light is managed by the nearest-lights limit system.' + ) ) ) .setFunctionName('setGuardrailsEnabled') @@ -2861,7 +2862,10 @@ module.exports = { this._pixiObject.angle = this._instance.getAngle(); const distance = Math.max(0, Number(object.content.distance || 0)); - const angle = Math.max(1, Math.min(89, Number(object.content.angle || 45))); + const angle = Math.max( + 1, + Math.min(89, Number(object.content.angle || 45)) + ); const previewDistance = Math.max( 20, Math.min(220, distance > 0 ? distance * 0.25 : 120) @@ -3012,14 +3016,24 @@ module.exports = { RenderedInstance.toRad(this._instance.getAngle()) ); - const angle = Math.max(1, Math.min(89, Number(object.content.angle || 45))); + const angle = Math.max( + 1, + Math.min(89, Number(object.content.angle || 45)) + ); const distance = Math.max(0, Number(object.content.distance || 0)); - const coneRadiusScale = Math.max(0.25, Math.tan(RenderedInstance.toRad(angle / 2))); + const coneRadiusScale = Math.max( + 0.25, + Math.tan(RenderedInstance.toRad(angle / 2)) + ); const coneLengthScale = Math.max( 0.7, Math.min(2.4, distance > 0 ? distance / 320 : 1.2) ); - this._coneMesh.scale.set(coneRadiusScale, coneLengthScale, coneRadiusScale); + this._coneMesh.scale.set( + coneRadiusScale, + coneLengthScale, + coneRadiusScale + ); const color = objectsRenderingService.rgbOrHexToHexNumber( object.content.color || '255;255;255' diff --git a/Extensions/3D/SpotLightRuntimeObject.ts b/Extensions/3D/SpotLightRuntimeObject.ts index bb805450172d..d91dc88fcdd8 100644 --- a/Extensions/3D/SpotLightRuntimeObject.ts +++ b/Extensions/3D/SpotLightRuntimeObject.ts @@ -39,9 +39,8 @@ namespace gdjs { const getOrCreateGuardrailsState = ( runtimeScene: gdjs.RuntimeScene ): SpotLightGuardrailsState => { - const existingState = spotLightGuardrailsStateByRuntimeScene.get( - runtimeScene - ); + const existingState = + spotLightGuardrailsStateByRuntimeScene.get(runtimeScene); if (existingState) { return existingState; } @@ -149,7 +148,9 @@ namespace gdjs { const layer = runtimeScene.getLayer(layerName); const cameraX = layer.getCameraX(); const cameraY = layer.getCameraY(); - const cameraZ = layer.getCameraZ(layer.getInitialCamera3DFieldOfView()); + const cameraZ = layer.getCameraZ( + layer.getInitialCamera3DFieldOfView() + ); lights.sort( (firstLight, secondLight) => @@ -157,7 +158,10 @@ namespace gdjs { secondLight.getDistanceToCameraSquared(cameraX, cameraY, cameraZ) ); - const maxActiveLights = getMaxActiveSpotLights(runtimeScene, layerName); + const maxActiveLights = getMaxActiveSpotLights( + runtimeScene, + layerName + ); for (let index = 0; index < lights.length; index++) { lights[index].setGuardrailActive(index < maxActiveLights); @@ -237,7 +241,8 @@ namespace gdjs { super(instanceContainer, objectData, instanceData); const objectContent = objectData.content; - this._enabled = objectContent.enabled === undefined ? true : !!objectContent.enabled; + this._enabled = + objectContent.enabled === undefined ? true : !!objectContent.enabled; this._color = objectContent.color || '255;255;255'; this._intensity = Math.max( 0, @@ -252,19 +257,34 @@ namespace gdjs { ); this._penumbra = Math.max( 0, - Math.min(1, objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1) + Math.min( + 1, + objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1 + ) + ); + this._decay = Math.max( + 0, + objectContent.decay !== undefined ? objectContent.decay : 2 ); - this._decay = Math.max(0, objectContent.decay !== undefined ? objectContent.decay : 2); this._castShadow = !!objectContent.castShadow; - this._shadowQuality = clampShadowQuality(objectContent.shadowQuality || 'medium'); - this._shadowBias = objectContent.shadowBias !== undefined ? objectContent.shadowBias : 0.001; + this._shadowQuality = clampShadowQuality( + objectContent.shadowQuality || 'medium' + ); + this._shadowBias = + objectContent.shadowBias !== undefined + ? objectContent.shadowBias + : 0.001; this._shadowNormalBias = Math.max( 0, - objectContent.shadowNormalBias !== undefined ? objectContent.shadowNormalBias : 0.02 + objectContent.shadowNormalBias !== undefined + ? objectContent.shadowNormalBias + : 0.02 ); this._shadowRadius = Math.max( 0, - objectContent.shadowRadius !== undefined ? objectContent.shadowRadius : 1.5 + objectContent.shadowRadius !== undefined + ? objectContent.shadowRadius + : 1.5 ); this._shadowNear = Math.max( 0.01, @@ -303,7 +323,9 @@ namespace gdjs { this._renderer.setPenumbra(this._penumbra); this._renderer.setDecay(this._decay); this._renderer.setCastShadow(this._castShadow); - this._renderer.setShadowMapSize(shadowQualityToMapSize(this._shadowQuality)); + this._renderer.setShadowMapSize( + shadowQualityToMapSize(this._shadowQuality) + ); this._renderer.setShadowBias(this._shadowBias); this._renderer.setShadowNormalBias(this._shadowNormalBias); this._renderer.setShadowRadius(this._shadowRadius); @@ -322,22 +344,48 @@ namespace gdjs { super.updateFromObjectData(oldObjectData, newObjectData); const objectContent = newObjectData.content; - this.setEnabled(objectContent.enabled === undefined ? true : !!objectContent.enabled); + this.setEnabled( + objectContent.enabled === undefined ? true : !!objectContent.enabled + ); this.setColor(objectContent.color || '255;255;255'); - this.setIntensity(objectContent.intensity !== undefined ? objectContent.intensity : 1); - this.setDistance(objectContent.distance !== undefined ? objectContent.distance : 600); - this.setConeAngle(objectContent.angle !== undefined ? objectContent.angle : 45); - this.setPenumbra(objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1); - this.setDecay(objectContent.decay !== undefined ? objectContent.decay : 2); + this.setIntensity( + objectContent.intensity !== undefined ? objectContent.intensity : 1 + ); + this.setDistance( + objectContent.distance !== undefined ? objectContent.distance : 600 + ); + this.setConeAngle( + objectContent.angle !== undefined ? objectContent.angle : 45 + ); + this.setPenumbra( + objectContent.penumbra !== undefined ? objectContent.penumbra : 0.1 + ); + this.setDecay( + objectContent.decay !== undefined ? objectContent.decay : 2 + ); this.setCastShadow(!!objectContent.castShadow); this.setShadowQuality(objectContent.shadowQuality || 'medium'); - this.setShadowBias(objectContent.shadowBias !== undefined ? objectContent.shadowBias : 0.001); + this.setShadowBias( + objectContent.shadowBias !== undefined + ? objectContent.shadowBias + : 0.001 + ); this.setShadowNormalBias( - objectContent.shadowNormalBias !== undefined ? objectContent.shadowNormalBias : 0.02 + objectContent.shadowNormalBias !== undefined + ? objectContent.shadowNormalBias + : 0.02 + ); + this.setShadowRadius( + objectContent.shadowRadius !== undefined + ? objectContent.shadowRadius + : 1.5 + ); + this.setShadowNear( + objectContent.shadowNear !== undefined ? objectContent.shadowNear : 1 + ); + this.setShadowFar( + objectContent.shadowFar !== undefined ? objectContent.shadowFar : 2000 ); - this.setShadowRadius(objectContent.shadowRadius !== undefined ? objectContent.shadowRadius : 1.5); - this.setShadowNear(objectContent.shadowNear !== undefined ? objectContent.shadowNear : 1); - this.setShadowFar(objectContent.shadowFar !== undefined ? objectContent.shadowFar : 2000); this.setGuardrailsEnabled( objectContent.guardrailsEnabled === undefined ? true @@ -398,7 +446,8 @@ namespace gdjs { if (networkSyncData.a !== undefined) this.setConeAngle(networkSyncData.a); if (networkSyncData.p !== undefined) this.setPenumbra(networkSyncData.p); if (networkSyncData.dc !== undefined) this.setDecay(networkSyncData.dc); - if (networkSyncData.cs !== undefined) this.setCastShadow(networkSyncData.cs); + if (networkSyncData.cs !== undefined) + this.setCastShadow(networkSyncData.cs); if (networkSyncData.sq !== undefined) this.setShadowQuality(networkSyncData.sq); if (networkSyncData.sb !== undefined) @@ -427,7 +476,9 @@ namespace gdjs { } isActiveAfterGuardrails(): boolean { - return this._enabled && (!this._guardrailsEnabled || this._guardrailActive); + return ( + this._enabled && (!this._guardrailsEnabled || this._guardrailActive) + ); } setColor(color: string): void { @@ -495,7 +546,9 @@ namespace gdjs { setShadowQuality(shadowQuality: string): void { this._shadowQuality = clampShadowQuality(shadowQuality); - this._renderer.setShadowMapSize(shadowQualityToMapSize(this._shadowQuality)); + this._renderer.setShadowMapSize( + shadowQualityToMapSize(this._shadowQuality) + ); } getShadowQuality(): 'low' | 'medium' | 'high' { @@ -601,7 +654,14 @@ namespace gdjs { instanceContainer: gdjs.RuntimeInstanceContainer ) { const threeGroup = new THREE.Group(); - const spotLight = new THREE.SpotLight(0xffffff, 1, 600, gdjs.toRad(45), 0.1, 2); + const spotLight = new THREE.SpotLight( + 0xffffff, + 1, + 600, + gdjs.toRad(45), + 0.1, + 2 + ); spotLight.position.set(0, 0, 0); spotLight.target.position.set(0, 0, -1); threeGroup.add(spotLight); @@ -644,7 +704,9 @@ namespace gdjs { private _updateLightVisibility(): void { this._spotLight.visible = - !this._object.isHidden() && this._runtimeEnabled && this._guardrailActive; + !this._object.isHidden() && + this._runtimeEnabled && + this._guardrailActive; } private _sanitizeShadowMapSize(size: number): integer { @@ -659,7 +721,10 @@ namespace gdjs { } this._shadowMapDirty = false; - this._spotLight.shadow.mapSize.set(this._shadowMapSize, this._shadowMapSize); + this._spotLight.shadow.mapSize.set( + this._shadowMapSize, + this._shadowMapSize + ); this._spotLight.shadow.map?.dispose(); this._spotLight.shadow.map = null; this._spotLight.shadow.needsUpdate = true; @@ -673,7 +738,8 @@ namespace gdjs { const safeNear = Math.max(0.01, this._shadowNear); const safeFar = Math.max(safeNear + 1, this._shadowFar); - const shadowCamera = this._spotLight.shadow.camera as THREE.PerspectiveCamera; + const shadowCamera = this._spotLight.shadow + .camera as THREE.PerspectiveCamera; shadowCamera.near = safeNear; shadowCamera.far = safeFar; shadowCamera.fov = Math.max( From 5228092806c72f621d05c9b6561b4cf03aeb5a92 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 19:33:54 +0200 Subject: [PATCH 3/5] fix(ci): validate downloaded libGD assets before type checks --- newIDE/app/scripts/import-libGD.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index 2accaaeac2f9..f131bdbbc508 100644 --- a/newIDE/app/scripts/import-libGD.js +++ b/newIDE/app/scripts/import-libGD.js @@ -102,12 +102,37 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { ); }; + const validateDownloadedLibGdJs = baseUrl => { + const libGdJsPath = path.join(__dirname, '..', 'public', 'libGD.js'); + const libGdWasmPath = path.join(__dirname, '..', 'public', 'libGD.wasm'); + + if (!shell.test('-f', libGdJsPath) || !shell.test('-f', libGdWasmPath)) { + shell.echo( + `⚠️ Downloaded libGD.js is incomplete (baseUrl=${baseUrl}), trying another source.` + ); + throw new Error('Incomplete libGD.js download'); + } + + const syntaxCheckResult = shell.exec(`node --check "${libGdJsPath}"`, { + silent: true, + }); + if (syntaxCheckResult.code !== 0) { + shell.echo( + `⚠️ Downloaded libGD.js is not valid JavaScript (baseUrl=${baseUrl}), trying another source.` + ); + throw new Error('Invalid libGD.js JavaScript syntax'); + } + }; + const downloadLibGdJs = baseUrl => Promise.all([ downloadLocalFile(baseUrl + '/libGD.js', '../public/libGD.js'), downloadLocalFile(baseUrl + '/libGD.wasm', '../public/libGD.wasm'), ]).then( - responses => {}, + responses => { + validateDownloadedLibGdJs(baseUrl); + return responses; + }, error => { if (error.statusCode === 403) { shell.echo( From 7dc6280f5fc283d77cd4c531f93431c1abfa40bf Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 19:41:47 +0200 Subject: [PATCH 4/5] fix(3d): resolve spotlight typing errors in extension metadata --- Extensions/3D/JsExtension.js | 1 - Extensions/3D/SpotLightRuntimeObject.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/Extensions/3D/JsExtension.js b/Extensions/3D/JsExtension.js index 0880bd5df8ca..9d6524a67d79 100644 --- a/Extensions/3D/JsExtension.js +++ b/Extensions/3D/JsExtension.js @@ -2119,7 +2119,6 @@ module.exports = { extension .addExpression( - 'number', 'MaxActiveSpotLights', _('Max active 3D spot lights'), _('the maximum number of active 3D spot lights on the layer'), diff --git a/Extensions/3D/SpotLightRuntimeObject.ts b/Extensions/3D/SpotLightRuntimeObject.ts index d91dc88fcdd8..937504f93506 100644 --- a/Extensions/3D/SpotLightRuntimeObject.ts +++ b/Extensions/3D/SpotLightRuntimeObject.ts @@ -639,7 +639,6 @@ namespace gdjs { } export class SpotLightRuntimeObjectRenderer extends gdjs.RuntimeObject3DRenderer { - private _object: gdjs.SpotLightRuntimeObject; private _spotLight: THREE.SpotLight; private _shadowMapSize: integer; private _shadowMapDirty: boolean; @@ -669,7 +668,6 @@ namespace gdjs { super(runtimeObject, instanceContainer, threeGroup); - this._object = runtimeObject; this._spotLight = spotLight; this._shadowMapSize = 1024; this._shadowMapDirty = true; From 75e7b408c478cc5d2feb1695407bc0cc70d9b9e2 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 2 Mar 2026 20:21:40 +0200 Subject: [PATCH 5/5] fix(ci): retry and validate libGD downloads in detached head --- newIDE/app/scripts/import-libGD.js | 53 +++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index f131bdbbc508..c1b595122923 100644 --- a/newIDE/app/scripts/import-libGD.js +++ b/newIDE/app/scripts/import-libGD.js @@ -1,6 +1,7 @@ const shell = require('shelljs'); const { downloadLocalFile } = require('./lib/DownloadLocalFile'); const path = require('path'); +const fs = require('fs'); const sourceDirectory = '../../../Binaries/embuild/GDevelop.js'; const destinationTestDirectory = '../node_modules/libGD.js-for-tests-only'; @@ -49,13 +50,19 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { let branch = (branchShellString.stdout || '').trim(); if (branch === 'HEAD') { // We're in detached HEAD. Try to read the branch from the CI environment variables. - if (process.env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH) { + if (process.env.SEMAPHORE_GIT_BRANCH) { + branch = process.env.SEMAPHORE_GIT_BRANCH; + } else if (process.env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH) { branch = process.env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH; } else if (process.env.APPVEYOR_REPO_BRANCH) { branch = process.env.APPVEYOR_REPO_BRANCH; } } + if (branch === 'HEAD') { + branch = ''; + } + if (!branch) { shell.echo( `⚠️ Can't find the branch of the associated commit - if you're in detached HEAD, you need to be on a branch instead.` @@ -85,7 +92,7 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { } resolve( - downloadLibGdJs( + downloadLibGdJsWithRetries( `https://s3.amazonaws.com/gdevelop-gdevelop.js/${branch}/commit/${hash}` ) ); @@ -97,28 +104,43 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { `ℹ️ Trying to download libGD.js from ${branchName}, latest build.` ); - return downloadLibGdJs( + return downloadLibGdJsWithRetries( `https://s3.amazonaws.com/gdevelop-gdevelop.js/${branchName}/latest` ); }; + const MIN_LIBGD_JS_SIZE_BYTES = 1024 * 1024; + const MIN_LIBGD_WASM_SIZE_BYTES = 1024 * 1024; + const validateDownloadedLibGdJs = baseUrl => { const libGdJsPath = path.join(__dirname, '..', 'public', 'libGD.js'); const libGdWasmPath = path.join(__dirname, '..', 'public', 'libGD.wasm'); if (!shell.test('-f', libGdJsPath) || !shell.test('-f', libGdWasmPath)) { shell.echo( - `⚠️ Downloaded libGD.js is incomplete (baseUrl=${baseUrl}), trying another source.` + `Warning: Downloaded libGD.js is incomplete (baseUrl=${baseUrl}), trying another source.` ); throw new Error('Incomplete libGD.js download'); } + const libGdJsSize = fs.statSync(libGdJsPath).size; + const libGdWasmSize = fs.statSync(libGdWasmPath).size; + if ( + libGdJsSize < MIN_LIBGD_JS_SIZE_BYTES || + libGdWasmSize < MIN_LIBGD_WASM_SIZE_BYTES + ) { + shell.echo( + `Warning: Downloaded libGD.js assets are unexpectedly small (baseUrl=${baseUrl}), trying another source.` + ); + throw new Error('Incomplete libGD.js download (unexpected file size)'); + } + const syntaxCheckResult = shell.exec(`node --check "${libGdJsPath}"`, { silent: true, }); if (syntaxCheckResult.code !== 0) { shell.echo( - `⚠️ Downloaded libGD.js is not valid JavaScript (baseUrl=${baseUrl}), trying another source.` + `Warning: Downloaded libGD.js is not valid JavaScript (baseUrl=${baseUrl}), trying another source.` ); throw new Error('Invalid libGD.js JavaScript syntax'); } @@ -158,6 +180,27 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { } ); + const wait = milliseconds => + new Promise(resolve => { + setTimeout(resolve, milliseconds); + }); + + const downloadLibGdJsWithRetries = (baseUrl, maxAttempts = 3) => { + let attempt = 1; + const download = () => + downloadLibGdJs(baseUrl).catch(error => { + if (attempt >= maxAttempts) { + throw error; + } + attempt += 1; + shell.echo( + `Warning: Retrying libGD.js download from ${baseUrl} (attempt ${attempt}/${maxAttempts}).` + ); + return wait(attempt * 1000).then(download); + }); + return download(); + }; + const onLibGdJsDownloaded = response => { shell.echo('✅ libGD.js downloaded and stored in public/libGD.js');