diff --git a/Extensions/3D/DirectionalLight.ts b/Extensions/3D/DirectionalLight.ts index b9a5350873a7..0b0b3c9c25c1 100644 --- a/Extensions/3D/DirectionalLight.ts +++ b/Extensions/3D/DirectionalLight.ts @@ -5,8 +5,24 @@ namespace gdjs { e: number; r: number; t: string; + msb?: number; + snb?: number; + sr?: number; + ss?: boolean; + sss?: number; + dfc?: number; + fs?: number; + msd?: number; + csl?: number; + sms?: number; + sfl?: number; + sfc?: boolean; } + const shadowHelper = false; + const csmCascadeCount = 3; + const csmIntensityWeights = [0.5, 0.3, 0.2]; + gdjs.PixiFiltersTools.registerFilterCreator( 'Scene3D::DirectionalLight', new (class implements gdjs.PixiFiltersTools.FilterCreator { @@ -23,41 +39,290 @@ namespace gdjs { private _rotation: float = 0; private _shadowMapSize: float = 1024; private _minimumShadowBias: float = 0; + private _shadowNormalBias: float = 0.02; + private _shadowRadius: float = 2; + private _shadowStabilizationEnabled: boolean = true; + private _shadowStabilizationStep: float = 0; private _distanceFromCamera: float = 1500; private _frustumSize: float = 4000; + private _maxShadowDistance: float = 2000; + private _cascadeSplitLambda: float = 0.7; + private _shadowFollowLead: float = 0.45; + private _shadowFollowCamera: boolean = false; + private _intensity: float = 0.5; + private _colorHex: number = 0xffffff; + private _shadowCastingEnabled: boolean = false; private _isEnabled: boolean = false; - private _light: THREE.DirectionalLight; + + private _lights: THREE.DirectionalLight[] = []; + private _shadowCameraHelpers: Array = []; private _shadowMapDirty = true; private _shadowCameraDirty = true; - private _shadowCameraHelper: THREE.CameraHelper | null; + private _cascadeRanges: Array<{ near: float; far: float }> = [ + { near: 0, far: 200 }, + { near: 200, far: 800 }, + { near: 800, far: 2000 }, + ]; + private _cascadeFrustumSizes: float[] = [400, 1200, 2400]; + private _cascadeMapSizes: integer[] = [2048, 1024, 512]; + private _maxRendererShadowMapSize: integer = 2048; + private _hadPreviousCameraPosition = false; + private _previousCameraX: float = 0; + private _previousCameraY: float = 0; + private _previousCameraZ: float = 0; + private _staticAnchorInitialized = false; + private _staticAnchorX: float = 0; + private _staticAnchorY: float = 0; + private _staticAnchorZ: float = 0; constructor() { - this._light = new THREE.DirectionalLight(); + for (let i = 0; i < csmCascadeCount; i++) { + const light = new THREE.DirectionalLight(); + light.castShadow = false; + this._lights.push(light); + if (shadowHelper) { + this._shadowCameraHelpers.push( + new THREE.CameraHelper(light.shadow.camera) + ); + } else { + this._shadowCameraHelpers.push(null); + } + } + this._setAllLightsColor(this._colorHex); + this._setAllLightsIntensity(this._intensity); + this._updateShadowCameraDirtyState(); + } - if (shadowHelper) { - this._shadowCameraHelper = new THREE.CameraHelper( - this._light.shadow.camera - ); - } else { - this._shadowCameraHelper = null; + private _updateShadowCameraDirtyState(): void { + this._shadowCameraDirty = true; + this._shadowMapDirty = true; + } + + private _setAllLightsColor(colorHex: number): void { + this._colorHex = colorHex; + for (const light of this._lights) { + light.color.setHex(colorHex); } + } - this._light.shadow.camera.updateProjectionMatrix(); + private _setAllLightsIntensity(intensity: float): void { + this._intensity = Math.max(0, intensity); + for (let i = 0; i < this._lights.length; i++) { + const weight = + csmIntensityWeights[i] !== undefined + ? csmIntensityWeights[i] + : 1 / csmCascadeCount; + this._lights[i].intensity = this._intensity * weight; + } } - private _updateShadowCamera(): void { + private _setShadowCastingEnabled(enabled: boolean): void { + if (this._shadowCastingEnabled === enabled) { + return; + } + this._shadowCastingEnabled = enabled; + for (const light of this._lights) { + light.castShadow = enabled; + if (enabled) { + light.shadow.needsUpdate = true; + } + } + if (enabled) { + this._updateShadowCameraDirtyState(); + } + } + + private _getClosestShadowMapSize(value: float): integer { + const supportedSizes = [512, 1024, 2048, 4096]; + const target = Math.max(512, value); + let closestSize = supportedSizes[0]; + let closestDelta = Math.abs(target - closestSize); + for (let i = 1; i < supportedSizes.length; i++) { + const size = supportedSizes[i]; + const delta = Math.abs(target - size); + if (delta < closestDelta) { + closestDelta = delta; + closestSize = size; + } + } + return this._clampShadowMapSizeToRenderer(closestSize); + } + + private _clampShadowMapSizeToRenderer(size: integer): integer { + const safeRendererMax = Math.max( + 512, + this._maxRendererShadowMapSize + ); + let clampedSize = 512; + while (clampedSize * 2 <= safeRendererMax) { + clampedSize *= 2; + } + return Math.max(512, Math.min(size, clampedSize)); + } + + private _computeCascadeMapSize(cascadeIndex: integer): integer { + const baseSize = this._getClosestShadowMapSize(this._shadowMapSize); + if (cascadeIndex === 0) { + return this._clampShadowMapSizeToRenderer(baseSize * 2); + } + if (cascadeIndex === 1) { + return this._clampShadowMapSizeToRenderer(baseSize); + } + return this._clampShadowMapSizeToRenderer( + Math.max(512, Math.floor(baseSize / 2)) + ); + } + + private _computePracticalSplit( + splitFactor: float, + nearDistance: float, + maxDistance: float + ): float { + const safeSplitFactor = Math.max(0, Math.min(1, splitFactor)); + const safeNearDistance = Math.max(0.01, nearDistance); + const safeMaxDistance = Math.max(64, maxDistance); + const lambda = Math.max(0, Math.min(1, this._cascadeSplitLambda)); + + const uniformSplit = + safeNearDistance + + (safeMaxDistance - safeNearDistance) * safeSplitFactor; + const logarithmicSplit = + safeNearDistance * + Math.pow(safeMaxDistance / safeNearDistance, safeSplitFactor); + + return lambda * logarithmicSplit + (1 - lambda) * uniformSplit; + } + + private _updateCascadeRanges(layer: gdjs.RuntimeLayer): void { + const cameraNear = Math.max( + 0.01, + layer.getCamera3DNearPlaneDistance() + ); + const cameraFar = Math.max( + cameraNear + 1, + layer.getCamera3DFarPlaneDistance() + ); + const safeMaxShadowDistance = Math.max( + cameraNear + 1, + Math.min(this._maxShadowDistance, cameraFar) + ); + + const practicalSplit1 = this._computePracticalSplit( + 1 / csmCascadeCount, + cameraNear, + safeMaxShadowDistance + ); + const practicalSplit2 = this._computePracticalSplit( + 2 / csmCascadeCount, + cameraNear, + safeMaxShadowDistance + ); + + const safeSplit1 = Math.max( + cameraNear + 0.01, + Math.min(safeMaxShadowDistance - 0.02, practicalSplit1) + ); + const safeSplit2 = Math.max( + safeSplit1 + 0.01, + Math.min(safeMaxShadowDistance - 0.01, practicalSplit2) + ); + + this._cascadeRanges[0].near = cameraNear; + this._cascadeRanges[0].far = safeSplit1; + this._cascadeRanges[1].near = safeSplit1; + this._cascadeRanges[1].far = safeSplit2; + this._cascadeRanges[2].near = safeSplit2; + this._cascadeRanges[2].far = safeMaxShadowDistance; + } + + private _computeCascadeFrustumSize( + layer: gdjs.RuntimeLayer, + cascadeIndex: integer + ): float { + const range = this._cascadeRanges[cascadeIndex]; + const safeRangeFar = Math.max(range.far, range.near + 1); + const rangeDepth = Math.max(1, range.far - range.near); + + const cameraHeight = Math.max(1, layer.getCameraHeight()); + const cameraAspect = Math.max( + 0.1, + layer.getCameraWidth() / cameraHeight + ); + const fovRad = gdjs.toRad( + Math.max(1, layer.getInitialCamera3DFieldOfView()) + ); + const projectedHalfHeight = Math.tan(fovRad * 0.5) * safeRangeFar; + const projectedHeight = Math.max(1, projectedHalfHeight * 2); + const projectedWidth = projectedHeight * cameraAspect; + + const visibleCoverage = Math.max(projectedHeight, projectedWidth); + const depthPadding = Math.max(32, rangeDepth * 0.65); + + // Keep compatibility with the legacy "frustumSize" parameter as a global multiplier. + const frustumScale = Math.max(0.25, this._frustumSize / 4000); + const cascadeScale = + cascadeIndex === 0 ? 0.85 : cascadeIndex === 1 ? 1 : 1.2; + + return Math.max( + 64, + (visibleCoverage + depthPadding) * frustumScale * cascadeScale + ); + } + + private _updateShadowCamera(layer: gdjs.RuntimeLayer): void { if (!this._shadowCameraDirty) { return; } this._shadowCameraDirty = false; - this._light.shadow.camera.near = 1; - this._light.shadow.camera.far = this._distanceFromCamera + 10000; - this._light.shadow.camera.right = this._frustumSize / 2; - this._light.shadow.camera.left = -this._frustumSize / 2; - this._light.shadow.camera.top = this._frustumSize / 2; - this._light.shadow.camera.bottom = -this._frustumSize / 2; + this._distanceFromCamera = Math.max(10, this._distanceFromCamera); + this._frustumSize = Math.max(64, this._frustumSize); + this._maxShadowDistance = Math.max(64, this._maxShadowDistance); + this._cascadeSplitLambda = Math.max( + 0, + Math.min(1, this._cascadeSplitLambda) + ); + + this._updateCascadeRanges(layer); + + const safeDistanceFromCamera = Math.max( + 10, + this._distanceFromCamera + ); + + for ( + let cascadeIndex = 0; + cascadeIndex < this._lights.length; + cascadeIndex++ + ) { + const light = this._lights[cascadeIndex]; + const cascadeFrustumSize = this._computeCascadeFrustumSize( + layer, + cascadeIndex + ); + this._cascadeFrustumSizes[cascadeIndex] = cascadeFrustumSize; + const rangeDepth = Math.max( + 1, + this._cascadeRanges[cascadeIndex].far - + this._cascadeRanges[cascadeIndex].near + ); + // Tight depth range improves shadow precision and reduces acne. + const depthExtent = + rangeDepth + Math.max(100, cascadeFrustumSize * 0.7); + + light.shadow.camera.near = Math.max( + 0.5, + safeDistanceFromCamera - depthExtent + ); + light.shadow.camera.far = safeDistanceFromCamera + depthExtent; + light.shadow.camera.right = cascadeFrustumSize / 2; + light.shadow.camera.left = -cascadeFrustumSize / 2; + light.shadow.camera.top = cascadeFrustumSize / 2; + light.shadow.camera.bottom = -cascadeFrustumSize / 2; + light.shadow.camera.updateProjectionMatrix(); + light.shadow.needsUpdate = true; + } } private _updateShadowMapSize(): void { @@ -66,15 +331,360 @@ namespace gdjs { } this._shadowMapDirty = false; - this._light.shadow.mapSize.set( - this._shadowMapSize, - this._shadowMapSize + for ( + let cascadeIndex = 0; + cascadeIndex < this._lights.length; + cascadeIndex++ + ) { + const light = this._lights[cascadeIndex]; + const cascadeMapSize = this._computeCascadeMapSize(cascadeIndex); + this._cascadeMapSizes[cascadeIndex] = cascadeMapSize; + + light.shadow.mapSize.set(cascadeMapSize, cascadeMapSize); + + // Force recreation of shadow map texture. + light.shadow.map?.dispose(); + light.shadow.map = null; + light.shadow.needsUpdate = true; + } + } + + private _getEffectiveShadowStabilizationStep( + cascadeIndex: integer + ): float { + if (!this._shadowStabilizationEnabled) { + return 0; + } + if (this._shadowStabilizationStep > 0) { + return this._shadowStabilizationStep; + } + const frustumSize = this._cascadeFrustumSizes[cascadeIndex]; + const shadowMapSize = this._cascadeMapSizes[cascadeIndex]; + return Math.max(0.25, frustumSize / Math.max(1, shadowMapSize)); + } + + private _computeDirectionalPosition( + targetX: float, + targetY: float, + targetZ: float + ): [float, float, float] { + if (this._top === 'Y-') { + return [ + targetX + + this._distanceFromCamera * + Math.cos(gdjs.toRad(-this._rotation + 90)) * + Math.cos(gdjs.toRad(this._elevation)), + targetY - + this._distanceFromCamera * + Math.sin(gdjs.toRad(this._elevation)), + targetZ + + this._distanceFromCamera * + Math.sin(gdjs.toRad(-this._rotation + 90)) * + Math.cos(gdjs.toRad(this._elevation)), + ]; + } + + return [ + targetX + + this._distanceFromCamera * + Math.cos(gdjs.toRad(this._rotation)) * + Math.cos(gdjs.toRad(this._elevation)), + targetY + + this._distanceFromCamera * + Math.sin(gdjs.toRad(this._rotation)) * + Math.cos(gdjs.toRad(this._elevation)), + targetZ + + this._distanceFromCamera * + Math.sin(gdjs.toRad(this._elevation)), + ]; + } + + private _applyCascadeTransform( + light: THREE.DirectionalLight, + targetX: float, + targetY: float, + targetZ: float + ): void { + const [lightX, lightY, lightZ] = this._computeDirectionalPosition( + targetX, + targetY, + targetZ + ); + light.position.set(lightX, lightY, lightZ); + light.target.position.set(targetX, targetY, targetZ); + } + + private _applyCascadeShadowTuning(cascadeIndex: integer): void { + const light = this._lights[cascadeIndex]; + const cascadeMapSize = this._cascadeMapSizes[cascadeIndex]; + const cascadeFrustumSize = this._cascadeFrustumSizes[cascadeIndex]; + const texelWorldSize = + cascadeFrustumSize / Math.max(1, cascadeMapSize); + + const resolutionMultiplier = + cascadeMapSize < 1024 ? 2 : cascadeMapSize < 2048 ? 1.25 : 1; + const distanceMultiplier = + cascadeIndex === 0 ? 1 : cascadeIndex === 1 ? 1.8 : 2.8; + const automaticBias = Math.max(0.00005, texelWorldSize * 0.0008); + + const baseBias = Math.max(this._minimumShadowBias, automaticBias); + light.shadow.bias = + -baseBias * resolutionMultiplier * distanceMultiplier; + + const baseNormalBias = Math.max(0, this._shadowNormalBias); + const automaticNormalBias = texelWorldSize * 0.03; + light.shadow.normalBias = Math.max( + baseNormalBias * (1 + cascadeIndex * 0.35), + automaticNormalBias ); - // Force the recreation of the shadow map texture: - this._light.shadow.map?.dispose(); - this._light.shadow.map = null; - this._light.shadow.needsUpdate = true; + const baseRadius = Math.max(0, this._shadowRadius); + const radiusMultiplier = + cascadeIndex === 0 ? 0.75 : cascadeIndex === 1 ? 1 : 1.35; + light.shadow.radius = baseRadius * radiusMultiplier; + } + + private _computeLightDirection(): [float, float, float] { + let directionX = 0; + let directionY = 0; + let directionZ = 1; + if (this._top === 'Y-') { + directionX = + Math.cos(gdjs.toRad(-this._rotation + 90)) * + Math.cos(gdjs.toRad(this._elevation)); + directionY = -Math.sin(gdjs.toRad(this._elevation)); + directionZ = + Math.sin(gdjs.toRad(-this._rotation + 90)) * + Math.cos(gdjs.toRad(this._elevation)); + } else { + directionX = + Math.cos(gdjs.toRad(this._rotation)) * + Math.cos(gdjs.toRad(this._elevation)); + directionY = + Math.sin(gdjs.toRad(this._rotation)) * + Math.cos(gdjs.toRad(this._elevation)); + directionZ = Math.sin(gdjs.toRad(this._elevation)); + } + + const directionLength = Math.sqrt( + directionX * directionX + + directionY * directionY + + directionZ * directionZ + ); + if (directionLength < 0.00001) { + return [0, 0, 1]; + } + return [ + directionX / directionLength, + directionY / directionLength, + directionZ / directionLength, + ]; + } + + private _computeLightSpaceBasis(): { + rightX: float; + rightY: float; + rightZ: float; + upX: float; + upY: float; + upZ: float; + forwardX: float; + forwardY: float; + forwardZ: float; + } { + const [forwardX, forwardY, forwardZ] = + this._computeLightDirection(); + + // Build a stable orthonormal basis around light direction. + let referenceUpX = 0; + let referenceUpY = 0; + let referenceUpZ = 1; + if (Math.abs(forwardZ) > 0.97) { + referenceUpX = 0; + referenceUpY = 1; + referenceUpZ = 0; + } + + let rightX = referenceUpY * forwardZ - referenceUpZ * forwardY; + let rightY = referenceUpZ * forwardX - referenceUpX * forwardZ; + let rightZ = referenceUpX * forwardY - referenceUpY * forwardX; + let rightLength = Math.sqrt( + rightX * rightX + rightY * rightY + rightZ * rightZ + ); + if (rightLength < 0.00001) { + rightX = 1; + rightY = 0; + rightZ = 0; + rightLength = 1; + } + rightX /= rightLength; + rightY /= rightLength; + rightZ /= rightLength; + + let upX = forwardY * rightZ - forwardZ * rightY; + let upY = forwardZ * rightX - forwardX * rightZ; + let upZ = forwardX * rightY - forwardY * rightX; + const upLength = Math.sqrt(upX * upX + upY * upY + upZ * upZ); + if (upLength > 0.00001) { + upX /= upLength; + upY /= upLength; + upZ /= upLength; + } else { + upX = 0; + upY = 1; + upZ = 0; + } + + return { + rightX, + rightY, + rightZ, + upX, + upY, + upZ, + forwardX, + forwardY, + forwardZ, + }; + } + + private _stabilizeAnchorInLightSpace( + x: float, + y: float, + z: float, + step: float, + basis: { + rightX: float; + rightY: float; + rightZ: float; + upX: float; + upY: float; + upZ: float; + forwardX: float; + forwardY: float; + forwardZ: float; + } + ): [float, float, float] { + if (step <= 0) { + return [x, y, z]; + } + const rightCoord = + x * basis.rightX + y * basis.rightY + z * basis.rightZ; + const upCoord = x * basis.upX + y * basis.upY + z * basis.upZ; + const forwardCoord = + x * basis.forwardX + y * basis.forwardY + z * basis.forwardZ; + + const stabilizedRight = Math.round(rightCoord / step) * step; + const stabilizedUp = Math.round(upCoord / step) * step; + + return [ + stabilizedRight * basis.rightX + + stabilizedUp * basis.upX + + forwardCoord * basis.forwardX, + stabilizedRight * basis.rightY + + stabilizedUp * basis.upY + + forwardCoord * basis.forwardY, + stabilizedRight * basis.rightZ + + stabilizedUp * basis.upZ + + forwardCoord * basis.forwardZ, + ]; + } + + private _computeShadowFollowAnchor( + cameraX: float, + cameraY: float, + cameraZ: float + ): [float, float, float] { + if (!this._hadPreviousCameraPosition) { + this._hadPreviousCameraPosition = true; + this._previousCameraX = cameraX; + this._previousCameraY = cameraY; + this._previousCameraZ = cameraZ; + return [cameraX, cameraY, cameraZ]; + } + + const deltaX = cameraX - this._previousCameraX; + const deltaY = cameraY - this._previousCameraY; + const deltaZ = cameraZ - this._previousCameraZ; + const movementLength = Math.sqrt( + deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ + ); + const safeMaxDistance = Math.max(64, this._maxShadowDistance); + const adaptiveLead = + movementLength <= 0 + ? this._shadowFollowLead + : Math.min( + 1.4, + this._shadowFollowLead + movementLength / safeMaxDistance + ); + + this._previousCameraX = cameraX; + this._previousCameraY = cameraY; + this._previousCameraZ = cameraZ; + + return [ + cameraX + deltaX * adaptiveLead, + cameraY + deltaY * adaptiveLead, + cameraZ + deltaZ * adaptiveLead, + ]; + } + + private _computeShadowAnchor( + cameraX: float, + cameraY: float, + cameraZ: float + ): [float, float, float] { + if (this._shadowFollowCamera) { + return this._computeShadowFollowAnchor(cameraX, cameraY, cameraZ); + } + + if (!this._staticAnchorInitialized) { + this._staticAnchorInitialized = true; + this._staticAnchorX = cameraX; + this._staticAnchorY = cameraY; + this._staticAnchorZ = cameraZ; + } + return [ + this._staticAnchorX, + this._staticAnchorY, + this._staticAnchorZ, + ]; + } + + private _ensureSoftShadowRenderer(target: gdjs.EffectsTarget): void { + const runtimeScene = target.getRuntimeScene(); + if (!runtimeScene || !runtimeScene.getGame) { + return; + } + const gameRenderer = runtimeScene.getGame().getRenderer(); + if (!gameRenderer || !(gameRenderer as any).getThreeRenderer) { + return; + } + const threeRenderer = (gameRenderer as any).getThreeRenderer(); + if (!threeRenderer || !threeRenderer.shadowMap) { + return; + } + const rendererMaxTextureSize = + threeRenderer.capabilities && + typeof threeRenderer.capabilities.maxTextureSize === 'number' + ? threeRenderer.capabilities.maxTextureSize + : 2048; + this._maxRendererShadowMapSize = Math.max( + 512, + rendererMaxTextureSize + ); + + if (!this._shadowCastingEnabled) { + return; + } + + threeRenderer.shadowMap.enabled = true; + threeRenderer.shadowMap.autoUpdate = true; + // `radius` has effect with PCFShadowMap, while PCFSoftShadowMap gives built-in soft filtering. + threeRenderer.shadowMap.type = + this._shadowRadius > 1 + ? THREE.PCFShadowMap + : THREE.PCFSoftShadowMap; } isEnabled(target: EffectsTarget): boolean { @@ -98,12 +708,19 @@ namespace gdjs { if (!scene) { return false; } - scene.add(this._light); - scene.add(this._light.target); - if (this._shadowCameraHelper) { - scene.add(this._shadowCameraHelper); + + for (let i = 0; i < this._lights.length; i++) { + const light = this._lights[i]; + scene.add(light); + scene.add(light.target); + const helper = this._shadowCameraHelpers[i]; + if (helper) { + scene.add(helper); + } } + this._hadPreviousCameraPosition = false; + this._staticAnchorInitialized = false; this._isEnabled = true; return true; } @@ -115,96 +732,115 @@ namespace gdjs { if (!scene) { return false; } - scene.remove(this._light); - scene.remove(this._light.target); - if (this._shadowCameraHelper) { - scene.remove(this._shadowCameraHelper); + + for (let i = 0; i < this._lights.length; i++) { + const light = this._lights[i]; + scene.remove(light); + scene.remove(light.target); + const helper = this._shadowCameraHelpers[i]; + if (helper) { + scene.remove(helper); + } } + this._isEnabled = false; return true; } updatePreRender(target: gdjs.EffectsTarget): any { - // Apply any update to the camera or shadow map size. - this._updateShadowCamera(); - this._updateShadowMapSize(); - - // Avoid shadow acne due to depth buffer precision. - const biasMultiplier = - this._shadowMapSize < 1024 - ? 2 - : this._shadowMapSize < 2048 - ? 1.25 - : 1; - this._light.shadow.bias = -this._minimumShadowBias * biasMultiplier; - - // Apply update to the light position and its target. - // By doing this, the shadows are "following" the GDevelop camera. if (!target.getRuntimeLayer) { return; } const layer = target.getRuntimeLayer(); - const x = layer.getCameraX(); - const y = layer.getCameraY(); - const z = layer.getCameraZ(layer.getInitialCamera3DFieldOfView()); + const cameraX = layer.getCameraX(); + const cameraY = layer.getCameraY(); + const cameraZ = layer.getCameraZ( + layer.getInitialCamera3DFieldOfView() + ); + const [anchorX, anchorY, anchorZ] = this._computeShadowAnchor( + cameraX, + cameraY, + cameraZ + ); - const roundedX = Math.floor(x / 100) * 100; - const roundedY = Math.floor(y / 100) * 100; - const roundedZ = Math.floor(z / 100) * 100; - if (this._top === 'Y-') { - const posLightX = - roundedX + - this._distanceFromCamera * - Math.cos(gdjs.toRad(-this._rotation + 90)) * - Math.cos(gdjs.toRad(this._elevation)); - const posLightY = - roundedY - - this._distanceFromCamera * - Math.sin(gdjs.toRad(this._elevation)); - const posLightZ = - roundedZ + - this._distanceFromCamera * - Math.sin(gdjs.toRad(-this._rotation + 90)) * - Math.cos(gdjs.toRad(this._elevation)); - this._light.position.set(posLightX, posLightY, posLightZ); - this._light.target.position.set(roundedX, roundedY, roundedZ); - } else { - const posLightX = - roundedX + - this._distanceFromCamera * - Math.cos(gdjs.toRad(this._rotation)) * - Math.cos(gdjs.toRad(this._elevation)); - const posLightY = - roundedY + - this._distanceFromCamera * - Math.sin(gdjs.toRad(this._rotation)) * - Math.cos(gdjs.toRad(this._elevation)); - const posLightZ = - roundedZ + - this._distanceFromCamera * - Math.sin(gdjs.toRad(this._elevation)); + // CSM requires per-cascade cameras and map sizing to be refreshed when settings change. + this._ensureSoftShadowRenderer(target); + this._updateShadowCamera(layer); + this._updateShadowMapSize(); + const lightSpaceBasis = this._computeLightSpaceBasis(); + + for ( + let cascadeIndex = 0; + cascadeIndex < this._lights.length; + cascadeIndex++ + ) { + const light = this._lights[cascadeIndex]; + const stabilizationStep = this._shadowCastingEnabled + ? this._getEffectiveShadowStabilizationStep(cascadeIndex) + : 0; + const [stabilizedX, stabilizedY, stabilizedZ] = + this._stabilizeAnchorInLightSpace( + anchorX, + anchorY, + anchorZ, + stabilizationStep, + lightSpaceBasis + ); + + this._applyCascadeTransform( + light, + stabilizedX, + stabilizedY, + stabilizedZ + ); + + if (this._shadowCastingEnabled) { + this._applyCascadeShadowTuning(cascadeIndex); + } - this._light.position.set(posLightX, posLightY, posLightZ); - this._light.target.position.set(roundedX, roundedY, roundedZ); + const helper = this._shadowCameraHelpers[cascadeIndex]; + if (helper) { + helper.update(); + } } } updateDoubleParameter(parameterName: string, value: number): void { if (parameterName === 'intensity') { - this._light.intensity = value; + this._setAllLightsIntensity(value); } else if (parameterName === 'elevation') { this._elevation = value; } else if (parameterName === 'rotation') { this._rotation = value; } else if (parameterName === 'distanceFromCamera') { - this._distanceFromCamera = value; + this._distanceFromCamera = Math.max(10, value); + this._shadowCameraDirty = true; } else if (parameterName === 'frustumSize') { - this._frustumSize = value; + this._frustumSize = Math.max(64, value); + this._shadowCameraDirty = true; } else if (parameterName === 'minimumShadowBias') { - this._minimumShadowBias = value; + this._minimumShadowBias = Math.max(0, value); + } else if (parameterName === 'shadowNormalBias') { + this._shadowNormalBias = Math.max(0, value); + } else if (parameterName === 'shadowRadius') { + this._shadowRadius = Math.max(0, value); + } else if (parameterName === 'shadowStabilizationStep') { + this._shadowStabilizationStep = Math.max(0, value); + } else if (parameterName === 'maxShadowDistance') { + this._maxShadowDistance = Math.max(64, value); + this._shadowCameraDirty = true; + } else if (parameterName === 'cascadeSplitLambda') { + this._cascadeSplitLambda = Math.max(0, Math.min(1, value)); + this._shadowCameraDirty = true; + } else if (parameterName === 'shadowMapSize') { + this._shadowMapSize = this._getClosestShadowMapSize(value); + this._shadowMapDirty = true; + this._shadowCameraDirty = true; + } else if (parameterName === 'shadowFollowLead') { + this._shadowFollowLead = Math.max(0, Math.min(2, value)); } } getDoubleParameter(parameterName: string): number { if (parameterName === 'intensity') { - return this._light.intensity; + return this._intensity; } else if (parameterName === 'elevation') { return this._elevation; } else if (parameterName === 'rotation') { @@ -215,14 +851,26 @@ namespace gdjs { return this._frustumSize; } else if (parameterName === 'minimumShadowBias') { return this._minimumShadowBias; + } else if (parameterName === 'shadowNormalBias') { + return this._shadowNormalBias; + } else if (parameterName === 'shadowRadius') { + return this._shadowRadius; + } else if (parameterName === 'shadowStabilizationStep') { + return this._shadowStabilizationStep; + } else if (parameterName === 'maxShadowDistance') { + return this._maxShadowDistance; + } else if (parameterName === 'cascadeSplitLambda') { + return this._cascadeSplitLambda; + } else if (parameterName === 'shadowMapSize') { + return this._shadowMapSize; + } else if (parameterName === 'shadowFollowLead') { + return this._shadowFollowLead; } return 0; } updateStringParameter(parameterName: string, value: string): void { if (parameterName === 'color') { - this._light.color = new THREE.Color( - gdjs.rgbOrHexStringToNumber(value) - ); + this._setAllLightsColor(gdjs.rgbOrHexStringToNumber(value)); } if (parameterName === 'top') { this._top = value; @@ -231,48 +879,104 @@ namespace gdjs { if (value === 'low' && this._shadowMapSize !== 512) { this._shadowMapSize = 512; this._shadowMapDirty = true; + this._shadowCameraDirty = true; } if (value === 'medium' && this._shadowMapSize !== 1024) { this._shadowMapSize = 1024; this._shadowMapDirty = true; + this._shadowCameraDirty = true; } if (value === 'high' && this._shadowMapSize !== 2048) { this._shadowMapSize = 2048; this._shadowMapDirty = true; + this._shadowCameraDirty = true; + } + } + if (parameterName === 'shadowMapSize') { + const parsedValue = parseFloat(value); + if (!isNaN(parsedValue)) { + this._shadowMapSize = + this._getClosestShadowMapSize(parsedValue); + this._shadowMapDirty = true; + this._shadowCameraDirty = true; } } } updateColorParameter(parameterName: string, value: number): void { if (parameterName === 'color') { - this._light.color.setHex(value); + this._setAllLightsColor(value); } } getColorParameter(parameterName: string): number { if (parameterName === 'color') { - return this._light.color.getHex(); + return this._colorHex; } return 0; } updateBooleanParameter(parameterName: string, value: boolean): void { if (parameterName === 'isCastingShadow') { - this._light.castShadow = value; + this._setShadowCastingEnabled(value); + } else if (parameterName === 'shadowStabilization') { + this._shadowStabilizationEnabled = value; + } else if (parameterName === 'shadowFollowCamera') { + this._shadowFollowCamera = value; + this._hadPreviousCameraPosition = false; + this._staticAnchorInitialized = false; } } getNetworkSyncData(): DirectionalLightFilterNetworkSyncData { return { - i: this._light.intensity, - c: this._light.color.getHex(), + i: this._intensity, + c: this._colorHex, e: this._elevation, r: this._rotation, t: this._top, + msb: this._minimumShadowBias, + snb: this._shadowNormalBias, + sr: this._shadowRadius, + ss: this._shadowStabilizationEnabled, + sss: this._shadowStabilizationStep, + dfc: this._distanceFromCamera, + fs: this._frustumSize, + msd: this._maxShadowDistance, + csl: this._cascadeSplitLambda, + sms: this._shadowMapSize, + sfl: this._shadowFollowLead, + sfc: this._shadowFollowCamera, }; } - updateFromNetworkSyncData(syncData: any): void { - this._light.intensity = syncData.i; - this._light.color.setHex(syncData.c); + updateFromNetworkSyncData( + syncData: DirectionalLightFilterNetworkSyncData + ): void { + this._setAllLightsIntensity(syncData.i); + this._setAllLightsColor(syncData.c); this._elevation = syncData.e; this._rotation = syncData.r; this._top = syncData.t; + this._minimumShadowBias = Math.max(0, syncData.msb ?? 0); + this._shadowNormalBias = Math.max(0, syncData.snb ?? 0.02); + this._shadowRadius = Math.max(0, syncData.sr ?? 2); + this._shadowStabilizationEnabled = syncData.ss ?? true; + this._shadowStabilizationStep = Math.max(0, syncData.sss ?? 0); + this._distanceFromCamera = Math.max(10, syncData.dfc ?? 1500); + this._frustumSize = Math.max(64, syncData.fs ?? 4000); + this._maxShadowDistance = Math.max(64, syncData.msd ?? 2000); + this._cascadeSplitLambda = Math.max( + 0, + Math.min(1, syncData.csl ?? 0.7) + ); + this._shadowMapSize = this._getClosestShadowMapSize( + syncData.sms ?? 1024 + ); + this._shadowFollowLead = Math.max( + 0, + Math.min(2, syncData.sfl ?? 0.45) + ); + this._shadowFollowCamera = syncData.sfc ?? false; + this._hadPreviousCameraPosition = false; + this._staticAnchorInitialized = false; + this._shadowMapDirty = true; + this._shadowCameraDirty = true; } })(); } diff --git a/Extensions/3D/FlickeringLightBehavior.ts b/Extensions/3D/FlickeringLightBehavior.ts new file mode 100644 index 000000000000..25db3287da65 --- /dev/null +++ b/Extensions/3D/FlickeringLightBehavior.ts @@ -0,0 +1,385 @@ +namespace gdjs { + /** + * @category Behaviors > 3D + */ + export class FlickeringLightRuntimeBehavior extends gdjs.RuntimeBehavior { + private _enabled: boolean; + private _baseIntensity: float; + private _flickerSpeed: float; + private _flickerStrength: float; + private _failChance: float; + private _offDuration: float; + private _targetLayerName: string; + private _targetEffectName: string; + + private _phase: float; + private _noise: float; + private _remainingOffTime: float; + private _cachedLayerName: string; + private _cachedEffectName: string; + private _warnedNoTargetEffect: boolean; + + constructor( + instanceContainer: gdjs.RuntimeInstanceContainer, + behaviorData, + owner: gdjs.RuntimeObject + ) { + super(instanceContainer, behaviorData, owner); + this._enabled = + behaviorData.enabled === undefined ? true : !!behaviorData.enabled; + this._baseIntensity = Math.max( + 0, + behaviorData.baseIntensity !== undefined + ? behaviorData.baseIntensity + : 1.0 + ); + this._flickerSpeed = Math.max( + 0, + behaviorData.flickerSpeed !== undefined ? behaviorData.flickerSpeed : 10 + ); + this._flickerStrength = Math.max( + 0, + behaviorData.flickerStrength !== undefined + ? behaviorData.flickerStrength + : 0.4 + ); + this._failChance = Math.max( + 0, + behaviorData.failChance !== undefined ? behaviorData.failChance : 0.02 + ); + this._offDuration = Math.max( + 0, + behaviorData.offDuration !== undefined ? behaviorData.offDuration : 0.1 + ); + this._targetLayerName = behaviorData.targetLayerName || ''; + this._targetEffectName = behaviorData.targetEffectName || ''; + + this._phase = Math.random(); + this._noise = 0; + this._remainingOffTime = 0; + this._cachedLayerName = ''; + this._cachedEffectName = ''; + this._warnedNoTargetEffect = false; + } + + override applyBehaviorOverriding(behaviorData): boolean { + if (behaviorData.enabled !== undefined) { + this.setEnabled(!!behaviorData.enabled); + } + if (behaviorData.baseIntensity !== undefined) { + this.setBaseIntensity(behaviorData.baseIntensity); + } + if (behaviorData.flickerSpeed !== undefined) { + this.setFlickerSpeed(behaviorData.flickerSpeed); + } + if (behaviorData.flickerStrength !== undefined) { + this.setFlickerStrength(behaviorData.flickerStrength); + } + if (behaviorData.failChance !== undefined) { + this.setFailChance(behaviorData.failChance); + } + if (behaviorData.offDuration !== undefined) { + this.setOffDuration(behaviorData.offDuration); + } + if (behaviorData.targetLayerName !== undefined) { + this.setTargetLayerName(behaviorData.targetLayerName); + } + if (behaviorData.targetEffectName !== undefined) { + this.setTargetEffectName(behaviorData.targetEffectName); + } + return true; + } + + override onDeActivate(): void { + this._remainingOffTime = 0; + this._applyIntensity(this._baseIntensity); + } + + override onDestroy(): void { + this._remainingOffTime = 0; + this._applyIntensity(this._baseIntensity); + this._invalidateTargetEffect(); + } + + override doStepPostEvents( + instanceContainer: gdjs.RuntimeInstanceContainer + ): void { + const effect = this._getTargetLightEffect(); + if (!effect) { + return; + } + + if (!this._enabled) { + this._remainingOffTime = 0; + this._applyIntensity(this._baseIntensity, effect); + return; + } + + const deltaTime = Math.max(0, instanceContainer.getElapsedTime() / 1000); + if (this._remainingOffTime > 0) { + this._remainingOffTime = Math.max( + 0, + this._remainingOffTime - deltaTime + ); + this._applyIntensity(0, effect); + return; + } + + if (deltaTime > 0 && this._failChance > 0) { + const failureProbability = 1 - Math.exp(-this._failChance * deltaTime); + if (Math.random() < failureProbability) { + this._remainingOffTime = this._offDuration; + this._applyIntensity(0, effect); + return; + } + } + + const speed = Math.max(0, this._flickerSpeed); + this._phase += deltaTime * speed; + if (!Number.isFinite(this._phase)) { + this._phase = 0; + } else if (this._phase > 1000000) { + this._phase -= Math.floor(this._phase); + } + const sine = Math.sin(this._phase * Math.PI * 2); + const rawNoise = Math.random() * 2 - 1; + const smoothing = Math.min(1, deltaTime * (speed * 0.5 + 3)); + this._noise += (rawNoise - this._noise) * smoothing; + + const signal = sine * 0.65 + this._noise * 0.35; + const intensity = + this._baseIntensity * (1 + signal * Math.max(0, this._flickerStrength)); + this._applyIntensity(Math.max(0, intensity), effect); + } + + private _invalidateTargetEffect(): void { + this._cachedEffectName = ''; + } + + private _isSpotOrPointLightEffect( + effect: gdjs.PixiFiltersTools.Filter + ): boolean { + const anyEffect = effect as any; + const light = anyEffect._light || anyEffect.light; + if (!light) { + return false; + } + return !!( + light.isPointLight || + light.isSpotLight || + light.type === 'PointLight' || + light.type === 'SpotLight' + ); + } + + private _getAttachedObjectName( + effect: gdjs.PixiFiltersTools.Filter + ): string { + const attachedObjectName = (effect as any)._attachedObjectName; + return typeof attachedObjectName === 'string' ? attachedObjectName : ''; + } + + private _getLayerByName( + runtimeScene: gdjs.RuntimeScene, + layerName: string + ): gdjs.RuntimeLayer | null { + if (!layerName) { + return null; + } + if (!runtimeScene.hasLayer(layerName)) { + return null; + } + return runtimeScene.getLayer(layerName); + } + + private _getPreferredLayer( + runtimeScene: gdjs.RuntimeScene + ): gdjs.RuntimeLayer { + const targetLayerName = (this._targetLayerName || '').trim(); + const fallbackLayerName = this.owner.getLayer(); + if (targetLayerName) { + const targetLayer = this._getLayerByName(runtimeScene, targetLayerName); + if (targetLayer) { + return targetLayer; + } + } + return runtimeScene.getLayer(fallbackLayerName); + } + + private _warnTargetEffectNotFound(layerName: string): void { + if (this._warnedNoTargetEffect) { + return; + } + this._warnedNoTargetEffect = true; + console.warn( + `[Scene3D::FlickeringLight] No PointLight/SpotLight effect found for "${this.owner.getName()}" on layer "${layerName}".` + + ` Set "Target effect name" and "Target layer name" in the behavior if needed.` + ); + } + + private _getTargetLightEffect(): gdjs.PixiFiltersTools.Filter | null { + const runtimeScene = this.owner.getRuntimeScene(); + const layer = this._getPreferredLayer(runtimeScene); + const layerName = layer.getName(); + if (this._cachedLayerName !== layerName) { + this._cachedLayerName = layerName; + this._invalidateTargetEffect(); + } + const rendererEffects = layer.getRendererEffects(); + + const explicitTargetEffectName = (this._targetEffectName || '').trim(); + if (explicitTargetEffectName) { + const explicitTargetEffect = rendererEffects[explicitTargetEffectName]; + if ( + explicitTargetEffect && + this._isSpotOrPointLightEffect(explicitTargetEffect) + ) { + this._cachedEffectName = explicitTargetEffectName; + this._warnedNoTargetEffect = false; + return explicitTargetEffect; + } + } + + if (this._cachedEffectName) { + const cached = rendererEffects[this._cachedEffectName]; + if (cached && this._isSpotOrPointLightEffect(cached)) { + this._warnedNoTargetEffect = false; + return cached; + } + this._invalidateTargetEffect(); + } + + const ownerName = this.owner.getName(); + let firstLightEffectName = ''; + let firstLightEffect: gdjs.PixiFiltersTools.Filter | null = null; + for (const effectName in rendererEffects) { + const effect = rendererEffects[effectName]; + if (!this._isSpotOrPointLightEffect(effect)) { + continue; + } + + if (!firstLightEffect) { + firstLightEffect = effect; + firstLightEffectName = effectName; + } + + const attachedObjectName = this._getAttachedObjectName(effect); + if (attachedObjectName && attachedObjectName === ownerName) { + this._cachedEffectName = effectName; + this._warnedNoTargetEffect = false; + return effect; + } + } + + if (firstLightEffect) { + this._cachedEffectName = firstLightEffectName; + this._warnedNoTargetEffect = false; + return firstLightEffect; + } + + this._invalidateTargetEffect(); + this._warnTargetEffectNotFound(layerName); + return null; + } + + private _applyIntensity( + intensity: float, + targetEffect: gdjs.PixiFiltersTools.Filter | null = null + ): void { + const effect = targetEffect || this._getTargetLightEffect(); + if (!effect) { + return; + } + effect.updateDoubleParameter('intensity', Math.max(0, intensity)); + } + + isEnabled(): boolean { + return this._enabled; + } + + setEnabled(enabled: boolean): void { + this._enabled = !!enabled; + if (!this._enabled) { + this._remainingOffTime = 0; + this._applyIntensity(this._baseIntensity); + } + } + + getBaseIntensity(): float { + return this._baseIntensity; + } + + setBaseIntensity(value: float): void { + this._baseIntensity = Math.max(0, value); + if (!this._enabled) { + this._applyIntensity(this._baseIntensity); + } + } + + getFlickerSpeed(): float { + return this._flickerSpeed; + } + + setFlickerSpeed(value: float): void { + this._flickerSpeed = Math.max(0, value); + } + + getFlickerStrength(): float { + return this._flickerStrength; + } + + setFlickerStrength(value: float): void { + this._flickerStrength = Math.max(0, value); + } + + getFailChance(): float { + return this._failChance; + } + + setFailChance(value: float): void { + this._failChance = Math.max(0, value); + } + + getOffDuration(): float { + return this._offDuration; + } + + setOffDuration(value: float): void { + this._offDuration = Math.max(0, value); + } + + getTargetLayerName(): string { + return this._targetLayerName; + } + + setTargetLayerName(layerName: string): void { + const normalizedLayerName = (layerName || '').trim(); + if (this._targetLayerName === normalizedLayerName) { + return; + } + this._targetLayerName = normalizedLayerName; + this._cachedLayerName = ''; + this._invalidateTargetEffect(); + this._warnedNoTargetEffect = false; + } + + getTargetEffectName(): string { + return this._targetEffectName; + } + + setTargetEffectName(effectName: string): void { + const normalizedEffectName = (effectName || '').trim(); + if (this._targetEffectName === normalizedEffectName) { + return; + } + this._targetEffectName = normalizedEffectName; + this._invalidateTargetEffect(); + this._warnedNoTargetEffect = false; + } + } + + gdjs.registerBehavior( + 'Scene3D::FlickeringLight', + gdjs.FlickeringLightRuntimeBehavior + ); +} diff --git a/Extensions/3D/JsExtension.js b/Extensions/3D/JsExtension.js index a1600760912c..7a66f0425e35 100644 --- a/Extensions/3D/JsExtension.js +++ b/Extensions/3D/JsExtension.js @@ -251,6 +251,371 @@ module.exports = { .setFunctionName('turnAroundZ'); } + { + const behavior = new gd.BehaviorJsImplementation(); + + behavior.updateProperty = function ( + behaviorContent, + propertyName, + newValue + ) { + if (!behaviorContent.hasChild(propertyName)) { + if (propertyName === 'enabled') { + behaviorContent.addChild(propertyName).setBoolValue(true); + } else if ( + propertyName === 'targetLayerName' || + propertyName === 'targetEffectName' + ) { + behaviorContent.addChild(propertyName).setStringValue(''); + } else { + behaviorContent.addChild(propertyName).setDoubleValue(0); + } + } + + if (propertyName === 'enabled') { + behaviorContent + .getChild('enabled') + .setBoolValue(newValue === '1' || newValue === 'true'); + return true; + } + + if ( + propertyName === 'baseIntensity' || + propertyName === 'flickerSpeed' || + propertyName === 'flickerStrength' || + propertyName === 'failChance' || + propertyName === 'offDuration' + ) { + const value = parseFloat(newValue); + if (value !== value) { + return false; + } + behaviorContent + .getChild(propertyName) + .setDoubleValue(Math.max(0, value)); + return true; + } + + if ( + propertyName === 'targetLayerName' || + propertyName === 'targetEffectName' + ) { + behaviorContent.getChild(propertyName).setStringValue(newValue); + return true; + } + + return false; + }; + + behavior.getProperties = function (behaviorContent) { + const behaviorProperties = new gd.MapStringPropertyDescriptor(); + + if (!behaviorContent.hasChild('enabled')) { + behaviorContent.addChild('enabled').setBoolValue(true); + } + if (!behaviorContent.hasChild('baseIntensity')) { + behaviorContent.addChild('baseIntensity').setDoubleValue(1.0); + } + if (!behaviorContent.hasChild('flickerSpeed')) { + behaviorContent.addChild('flickerSpeed').setDoubleValue(10.0); + } + if (!behaviorContent.hasChild('flickerStrength')) { + behaviorContent.addChild('flickerStrength').setDoubleValue(0.4); + } + if (!behaviorContent.hasChild('failChance')) { + behaviorContent.addChild('failChance').setDoubleValue(0.02); + } + if (!behaviorContent.hasChild('offDuration')) { + behaviorContent.addChild('offDuration').setDoubleValue(0.1); + } + if (!behaviorContent.hasChild('targetLayerName')) { + behaviorContent.addChild('targetLayerName').setStringValue(''); + } + if (!behaviorContent.hasChild('targetEffectName')) { + behaviorContent.addChild('targetEffectName').setStringValue(''); + } + + behaviorProperties + .getOrCreate('enabled') + .setValue( + behaviorContent.getChild('enabled').getBoolValue() + ? 'true' + : 'false' + ) + .setType('Boolean') + .setLabel(_('Enabled')); + behaviorProperties + .getOrCreate('baseIntensity') + .setValue( + behaviorContent + .getChild('baseIntensity') + .getDoubleValue() + .toString(10) + ) + .setType('Number') + .setLabel(_('Base intensity')); + behaviorProperties + .getOrCreate('flickerSpeed') + .setValue( + behaviorContent + .getChild('flickerSpeed') + .getDoubleValue() + .toString(10) + ) + .setType('Number') + .setLabel(_('Flicker speed')); + behaviorProperties + .getOrCreate('flickerStrength') + .setValue( + behaviorContent + .getChild('flickerStrength') + .getDoubleValue() + .toString(10) + ) + .setType('Number') + .setLabel(_('Flicker strength')); + behaviorProperties + .getOrCreate('failChance') + .setValue( + behaviorContent.getChild('failChance').getDoubleValue().toString(10) + ) + .setType('Number') + .setLabel(_('Failure chance (per second)')); + behaviorProperties + .getOrCreate('offDuration') + .setValue( + behaviorContent + .getChild('offDuration') + .getDoubleValue() + .toString(10) + ) + .setType('Number') + .setLabel(_('Off duration (seconds)')); + behaviorProperties + .getOrCreate('targetLayerName') + .setValue( + behaviorContent.getChild('targetLayerName').getStringValue() + ) + .setType('String') + .setLabel(_('Target layer name (optional)')) + .setDescription( + _( + 'Optional explicit layer containing the SpotLight/PointLight effect. Leave empty to use the object layer.' + ) + ) + .setGroup(_('Advanced')) + .setAdvanced(true); + behaviorProperties + .getOrCreate('targetEffectName') + .setValue( + behaviorContent.getChild('targetEffectName').getStringValue() + ) + .setType('String') + .setLabel(_('Target effect name (optional)')) + .setDescription( + _( + 'Optional explicit effect name. Recommended when multiple 3D light effects exist on the same layer.' + ) + ) + .setGroup(_('Advanced')) + .setAdvanced(true); + + return behaviorProperties; + }; + + behavior.initializeContent = function (behaviorContent) { + behaviorContent.addChild('enabled').setBoolValue(true); + behaviorContent.addChild('baseIntensity').setDoubleValue(1.0); + behaviorContent.addChild('flickerSpeed').setDoubleValue(10.0); + behaviorContent.addChild('flickerStrength').setDoubleValue(0.4); + behaviorContent.addChild('failChance').setDoubleValue(0.02); + behaviorContent.addChild('offDuration').setDoubleValue(0.1); + behaviorContent.addChild('targetLayerName').setStringValue(''); + behaviorContent.addChild('targetEffectName').setStringValue(''); + }; + + const flickeringLight = extension + .addBehavior( + 'FlickeringLight', + _('Flickering 3D light'), + 'FlickeringLight', + _( + 'Randomly flickers Scene3D Point Light and Spot Light effects by updating their intensity every frame.' + ), + '', + 'res/conditions/3d_box.svg', + 'FlickeringLight', + // @ts-ignore + behavior, + new gd.BehaviorsSharedData() + ) + .setIncludeFile('Extensions/3D/FlickeringLightBehavior.js'); + + flickeringLight + .addScopedAction( + 'SetEnabled', + _('Enable/disable flickering'), + _('Enable or disable the light flicker simulation.'), + _('Set flickering of _PARAM0_ to _PARAM2_'), + _('Flickering light'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .addParameter('yesorno', _('Enabled')) + .setFunctionName('setEnabled'); + + flickeringLight + .addScopedCondition( + 'IsEnabled', + _('Flickering enabled'), + _('Check if the flickering logic is enabled.'), + _('Flickering is enabled for _PARAM0_'), + _('Flickering light'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .setFunctionName('isEnabled'); + + flickeringLight + .addScopedAction( + 'SetTargetLayerName', + _('Set target layer'), + _('Set the layer where the SpotLight/PointLight effect is searched.'), + _('Set flickering target layer of _PARAM0_ to _PARAM2_'), + _('Flickering light'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .addParameter('layer', _('Layer'), '', true) + .setFunctionName('setTargetLayerName'); + + flickeringLight + .addScopedAction( + 'SetTargetEffectName', + _('Set target effect'), + _('Set the exact SpotLight/PointLight effect name to control.'), + _('Set flickering target effect of _PARAM0_ to _PARAM2_'), + _('Flickering light'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .addParameter('layerEffectName', _('Light effect name')) + .setFunctionName('setTargetEffectName'); + + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'BaseIntensity', + _('Base intensity'), + _('the base light intensity'), + _('the base intensity'), + _('Flickering light'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Base intensity used when flicker offset is 0.') + ) + ) + .setFunctionName('setBaseIntensity') + .setGetter('getBaseIntensity'); + + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'FlickerSpeed', + _('Flicker speed'), + _('the flickering speed'), + _('the flicker speed'), + _('Flickering light'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('How fast the flicker oscillates.') + ) + ) + .setFunctionName('setFlickerSpeed') + .setGetter('getFlickerSpeed'); + + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'FlickerStrength', + _('Flicker strength'), + _('the flicker strength'), + _('the flicker strength'), + _('Flickering light'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('How much intensity can vary around the base value.') + ) + ) + .setFunctionName('setFlickerStrength') + .setGetter('getFlickerStrength'); + + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'FailChance', + _('Failure chance'), + _('the failure chance per second'), + _('the failure chance per second'), + _('Flickering light'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Probability per second for a complete temporary blackout.') + ) + ) + .setFunctionName('setFailChance') + .setGetter('getFailChance'); + + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'OffDuration', + _('Off duration'), + _('the off duration in seconds'), + _('the off duration'), + _('Flickering light'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('How long the light stays off when a failure occurs (seconds).') + ) + ) + .setFunctionName('setOffDuration') + .setGetter('getOffDuration'); + } + { const object = extension .addObject( @@ -2022,6 +2387,18 @@ module.exports = { .setLabel(_('Shadow quality')) .setType('choice') .setGroup(_('Shadows')); + properties + .getOrCreate('shadowMapSize') + .setValue('1024') + .setLabel(_('Shadow map size (base)')) + .setDescription( + _( + 'Base map size used by cascaded shadows. Recommended values: 512, 1024, 2048, or 4096 (high-end GPUs).' + ) + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Shadows')); properties .getOrCreate('minimumShadowBias') .setValue('0') @@ -2034,6 +2411,65 @@ module.exports = { .setType('number') .setGroup(_('Shadows')) .setAdvanced(true); + properties + .getOrCreate('shadowNormalBias') + .setValue('0.02') + .setLabel(_('Shadow normal bias')) + .setDescription( + _('Offset along normals to reduce acne on sloped/curved surfaces.') + ) + .setType('number') + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('shadowRadius') + .setValue('2') + .setLabel(_('Shadow softness')) + .setDescription( + _( + 'Softness radius for filtered shadow edges (higher = softer, may blur details).' + ) + ) + .setType('number') + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('shadowStabilization') + .setValue('true') + .setLabel(_('Shadow stabilization')) + .setDescription( + _( + 'Snap shadow tracking to a stable grid to reduce shimmering while the camera moves.' + ) + ) + .setType('boolean') + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('shadowFollowCamera') + .setValue('false') + .setLabel(_('Shadows follow camera')) + .setDescription( + _( + 'If disabled, directional shadow cascades stay fixed in world space (no shadow movement with the player).' + ) + ) + .setType('boolean') + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('shadowStabilizationStep') + .setValue('0') + .setLabel(_('Stabilization step')) + .setDescription( + _( + 'Pixel step used for shadow stabilization. 0 = automatic texel-based step.' + ) + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Shadows')) + .setAdvanced(true); properties .getOrCreate('frustumSize') .setValue('4000') @@ -2042,6 +2478,41 @@ module.exports = { .setMeasurementUnit(gd.MeasurementUnit.getPixel()) .setGroup(_('Shadows')) .setAdvanced(true); + properties + .getOrCreate('maxShadowDistance') + .setValue('2000') + .setLabel(_('Max shadow distance')) + .setDescription( + _('Maximum world distance covered by cascaded directional shadows.') + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('cascadeSplitLambda') + .setValue('0.7') + .setLabel(_('Cascade split lambda')) + .setDescription( + _( + 'Blend between logarithmic and uniform cascade split distribution (0 to 1).' + ) + ) + .setType('number') + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('shadowFollowLead') + .setValue('0.45') + .setLabel(_('Shadow follow lead')) + .setDescription( + _( + 'Predictive follow amount for the shadow anchor so shadows keep up with fast player movement.' + ) + ) + .setType('number') + .setGroup(_('Shadows')) + .setAdvanced(true); properties .getOrCreate('distanceFromCamera') .setValue('1500') @@ -2144,6 +2615,16 @@ module.exports = { .setType('resource') .addExtraInfo('image') .setLabel(_('Back face (Z-)')); + properties + .getOrCreate('environmentIntensity') + .setValue('1.0') + .setLabel(_('Environment intensity')) + .setType('number') + .setDescription( + _( + 'Intensity multiplier used when this skybox drives scene environment lighting.' + ) + ); } { const effect = extension diff --git a/Extensions/3D/Skybox.ts b/Extensions/3D/Skybox.ts index f60296d4074f..3705ce6c593a 100644 --- a/Extensions/3D/Skybox.ts +++ b/Extensions/3D/Skybox.ts @@ -1,5 +1,7 @@ namespace gdjs { - interface SkyboxFilterNetworkSyncData {} + interface SkyboxFilterNetworkSyncData { + i: number; + } gdjs.PixiFiltersTools.registerFilterCreator( 'Scene3D::Skybox', new (class implements gdjs.PixiFiltersTools.FilterCreator { @@ -12,11 +14,12 @@ namespace gdjs { } return new (class implements gdjs.PixiFiltersTools.Filter { _cubeTexture: THREE.CubeTexture; - _oldBackground: - | THREE.CubeTexture - | THREE.Texture - | THREE.Color - | null = null; + _pmremGenerator: THREE.PMREMGenerator | null = null; + _pmremRenderTarget: THREE.WebGLRenderTarget | null = null; + _oldBackground: THREE.Texture | THREE.Color | null = null; + _oldEnvironment: THREE.Texture | null = null; + _oldEnvironmentIntensity: number | null = null; + _environmentIntensity: number; _isEnabled: boolean = false; constructor() { @@ -32,6 +35,77 @@ namespace gdjs { effectData.stringParameters.frontFaceResourceName, effectData.stringParameters.backFaceResourceName ); + this._environmentIntensity = Math.max( + 0, + effectData.doubleParameters.environmentIntensity || 1 + ); + } + + private _getScene(target: EffectsTarget): THREE.Scene | null { + const scene = target.get3DRendererObject() as + | THREE.Scene + | null + | undefined; + return scene || null; + } + + private _getThreeRenderer( + target: EffectsTarget + ): THREE.WebGLRenderer | null { + if (!(target instanceof gdjs.Layer)) { + return null; + } + return target + .getRuntimeScene() + .getGame() + .getRenderer() + .getThreeRenderer(); + } + + private _disposePmremResources(): void { + if (this._pmremRenderTarget) { + this._pmremRenderTarget.dispose(); + this._pmremRenderTarget = null; + } + if (this._pmremGenerator) { + this._pmremGenerator.dispose(); + this._pmremGenerator = null; + } + } + + private _buildEnvironmentTexture( + target: EffectsTarget + ): THREE.Texture { + const renderer = this._getThreeRenderer(target); + if (!renderer) { + return this._cubeTexture; + } + if (!this._pmremGenerator) { + this._pmremGenerator = new THREE.PMREMGenerator(renderer); + } + if (this._pmremRenderTarget) { + this._pmremRenderTarget.dispose(); + this._pmremRenderTarget = null; + } + this._pmremRenderTarget = this._pmremGenerator.fromCubemap( + this._cubeTexture + ); + return this._pmremRenderTarget.texture; + } + + private _applyEnvironmentIntensity(scene: THREE.Scene): void { + const sceneWithEnvironmentIntensity = scene as THREE.Scene & { + environmentIntensity?: number; + }; + if ( + typeof sceneWithEnvironmentIntensity.environmentIntensity === + 'number' + ) { + sceneWithEnvironmentIntensity.environmentIntensity = Math.max( + 0, + this._environmentIntensity + ); + } } isEnabled(target: EffectsTarget): boolean { @@ -48,39 +122,69 @@ namespace gdjs { } } applyEffect(target: EffectsTarget): boolean { - const scene = target.get3DRendererObject() as - | THREE.Scene - | null - | undefined; + const scene = this._getScene(target); if (!scene) { return false; } - // TODO Add a background stack in LayerPixiRenderer to allow - // filters to stack them. this._oldBackground = scene.background; + this._oldEnvironment = scene.environment; + const sceneWithEnvironmentIntensity = scene as THREE.Scene & { + environmentIntensity?: number; + }; + this._oldEnvironmentIntensity = + typeof sceneWithEnvironmentIntensity.environmentIntensity === + 'number' + ? sceneWithEnvironmentIntensity.environmentIntensity + : null; + scene.background = this._cubeTexture; - if (!scene.environment) { - scene.environment = this._cubeTexture; - } + scene.environment = this._buildEnvironmentTexture(target); + this._applyEnvironmentIntensity(scene); this._isEnabled = true; return true; } removeEffect(target: EffectsTarget): boolean { - const scene = target.get3DRendererObject() as - | THREE.Scene - | null - | undefined; + const scene = this._getScene(target); if (!scene) { return false; } scene.background = this._oldBackground; - scene.environment = null; + scene.environment = this._oldEnvironment; + if (this._oldEnvironmentIntensity !== null) { + const sceneWithEnvironmentIntensity = scene as THREE.Scene & { + environmentIntensity?: number; + }; + if ( + typeof sceneWithEnvironmentIntensity.environmentIntensity === + 'number' + ) { + sceneWithEnvironmentIntensity.environmentIntensity = + this._oldEnvironmentIntensity; + } + } + this._disposePmremResources(); this._isEnabled = false; return true; } - updatePreRender(target: gdjs.EffectsTarget): any {} - updateDoubleParameter(parameterName: string, value: number): void {} + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + const scene = this._getScene(target); + if (!scene) { + return; + } + this._applyEnvironmentIntensity(scene); + } + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'environmentIntensity') { + this._environmentIntensity = Math.max(0, value); + } + } getDoubleParameter(parameterName: string): number { + if (parameterName === 'environmentIntensity') { + return this._environmentIntensity; + } return 0; } updateStringParameter(parameterName: string, value: string): void {} @@ -90,11 +194,15 @@ namespace gdjs { } updateBooleanParameter(parameterName: string, value: boolean): void {} getNetworkSyncData(): SkyboxFilterNetworkSyncData { - return {}; + return { + i: this._environmentIntensity, + }; } updateFromNetworkSyncData( syncData: SkyboxFilterNetworkSyncData - ): void {} + ): void { + this._environmentIntensity = Math.max(0, syncData.i); + } })(); } })() diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index 2accaaeac2f9..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,17 +104,57 @@ 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( + `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( + `Warning: 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( @@ -133,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');