diff --git a/Extensions/3D/BloomEffect.ts b/Extensions/3D/BloomEffect.ts index 821dbecd052c..e030e0005195 100644 --- a/Extensions/3D/BloomEffect.ts +++ b/Extensions/3D/BloomEffect.ts @@ -3,6 +3,7 @@ namespace gdjs { s: number; r: number; t: number; + q?: string; } gdjs.PixiFiltersTools.registerFilterCreator( 'Scene3D::Bloom', @@ -17,6 +18,8 @@ namespace gdjs { return new (class implements gdjs.PixiFiltersTools.Filter { shaderPass: THREE_ADDONS.UnrealBloomPass; _isEnabled: boolean; + _qualityMode: string; + _renderSize: THREE.Vector2; constructor() { this.shaderPass = new THREE_ADDONS.UnrealBloomPass( @@ -25,7 +28,11 @@ namespace gdjs { 0, 0 ); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'BLOOM'); this._isEnabled = false; + this._qualityMode = + effectData.stringParameters.qualityMode || 'medium'; + this._renderSize = new THREE.Vector2(); } isEnabled(target: EffectsTarget): boolean { @@ -46,6 +53,7 @@ namespace gdjs { return false; } target.getRenderer().addPostProcessingPass(this.shaderPass); + gdjs.reorderScene3DPostProcessingPasses(target); this._isEnabled = true; return true; } @@ -54,10 +62,54 @@ namespace gdjs { return false; } target.getRenderer().removePostProcessingPass(this.shaderPass); + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'BLOOM'); this._isEnabled = false; return true; } - updatePreRender(target: gdjs.EffectsTarget): any {} + updatePreRender(target: gdjs.EffectsTarget): any { + if (!(target instanceof gdjs.Layer)) { + return; + } + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + if (!threeRenderer) { + return; + } + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'BLOOM'); + return; + } + + gdjs.setScene3DPostProcessingEffectQualityMode( + target, + 'BLOOM', + this._qualityMode + ); + + const quality = gdjs.getScene3DPostProcessingQualityProfileForMode( + this._qualityMode + ); + threeRenderer.getDrawingBufferSize(this._renderSize); + const width = Math.max( + 1, + Math.round( + (this._renderSize.x || target.getWidth()) * quality.captureScale + ) + ); + const height = Math.max( + 1, + Math.round( + (this._renderSize.y || target.getHeight()) * + quality.captureScale + ) + ); + this.shaderPass.setSize(width, height); + this.shaderPass.enabled = true; + } updateDoubleParameter(parameterName: string, value: number): void { if (parameterName === 'strength') { this.shaderPass.strength = value; @@ -81,7 +133,11 @@ namespace gdjs { } return 0; } - updateStringParameter(parameterName: string, value: string): void {} + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } updateColorParameter(parameterName: string, value: number): void {} getColorParameter(parameterName: string): number { return 0; @@ -92,12 +148,14 @@ namespace gdjs { s: this.shaderPass.strength, r: this.shaderPass.radius, t: this.shaderPass.threshold, + q: this._qualityMode, }; } updateFromNetworkSyncData(data: BloomFilterNetworkSyncData) { this.shaderPass.strength = data.s; this.shaderPass.radius = data.r; this.shaderPass.threshold = data.t; + this._qualityMode = data.q || 'medium'; } })(); } diff --git a/Extensions/3D/ChromaticAberrationEffect.ts b/Extensions/3D/ChromaticAberrationEffect.ts new file mode 100644 index 000000000000..6f83d9115526 --- /dev/null +++ b/Extensions/3D/ChromaticAberrationEffect.ts @@ -0,0 +1,233 @@ +namespace gdjs { + interface ChromaticAberrationNetworkSyncData { + i: number; + rs: number; + e: boolean; + } + + const chromaticAberrationShader = { + uniforms: { + tDiffuse: { value: null }, + tSceneColor: { value: null }, + intensity: { value: 0.005 }, + radialScale: { value: 1.0 }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + uniform sampler2D tDiffuse; + uniform sampler2D tSceneColor; + uniform float intensity; + uniform float radialScale; + varying vec2 vUv; + + vec2 clampUv(vec2 uv) { + return clamp(uv, vec2(0.0), vec2(1.0)); + } + + void main() { + vec4 baseColor = texture2D(tDiffuse, vUv); + if (intensity <= 0.0) { + gl_FragColor = baseColor; + return; + } + + vec2 centered = vUv - vec2(0.5); + float distanceFromCenter = length(centered); + vec2 direction = + distanceFromCenter > 0.00001 + ? centered / distanceFromCenter + : vec2(0.0); + + float edgeFactor = clamp(distanceFromCenter * 1.41421356237, 0.0, 1.0); + edgeFactor = pow(edgeFactor, max(0.0001, radialScale)); + vec2 channelOffset = direction * intensity * edgeFactor; + + vec2 uvRed = clampUv(vUv + channelOffset); + vec2 uvBlue = clampUv(vUv - channelOffset); + + vec3 diffuseRed = texture2D(tDiffuse, uvRed).rgb; + vec3 diffuseCenter = texture2D(tDiffuse, vUv).rgb; + vec3 diffuseBlue = texture2D(tDiffuse, uvBlue).rgb; + + // Blend in a bit of shared scene capture to keep this pass coherent + // with the centralized PostProcessingStack capture flow. + vec3 sceneRed = texture2D(tSceneColor, uvRed).rgb; + vec3 sceneBlue = texture2D(tSceneColor, uvBlue).rgb; + float captureMix = 0.18; + + float red = mix(diffuseRed.r, sceneRed.r, captureMix); + float green = diffuseCenter.g; + float blue = mix(diffuseBlue.b, sceneBlue.b, captureMix); + + gl_FragColor = vec4(red, green, blue, baseColor.a); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::ChromaticAberration', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _intensity: number; + _radialScale: number; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass( + chromaticAberrationShader + ); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'CHROMA'); + this._isEnabled = false; + this._effectEnabled = true; + this._intensity = 0.005; + this._radialScale = 1.0; + this.shaderPass.enabled = true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + this._isEnabled = true; + return true; + } + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + this._isEnabled = false; + return true; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled || !this._effectEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + return; + } + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture) { + return; + } + + this.shaderPass.enabled = true; + this.shaderPass.uniforms.tSceneColor.value = + sharedCapture.colorTexture; + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.radialScale.value = this._radialScale; + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'intensity') { + this._intensity = Math.max(0, value); + this.shaderPass.uniforms.intensity.value = this._intensity; + } else if (parameterName === 'radialScale') { + this._radialScale = Math.max(0, value); + this.shaderPass.uniforms.radialScale.value = this._radialScale; + } + } + + getDoubleParameter(parameterName: string): number { + if (parameterName === 'intensity') { + return this._intensity; + } + if (parameterName === 'radialScale') { + return this._radialScale; + } + return 0; + } + + updateStringParameter(parameterName: string, value: string): void {} + updateColorParameter(parameterName: string, value: number): void {} + getColorParameter(parameterName: string): number { + return 0; + } + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + + getNetworkSyncData(): ChromaticAberrationNetworkSyncData { + return { + i: this._intensity, + rs: this._radialScale, + e: this._effectEnabled, + }; + } + + updateFromNetworkSyncData( + syncData: ChromaticAberrationNetworkSyncData + ): void { + this._intensity = Math.max(0, syncData.i); + this._radialScale = Math.max(0, syncData.rs); + this._effectEnabled = !!syncData.e; + + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.radialScale.value = this._radialScale; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/ColorGradingEffect.ts b/Extensions/3D/ColorGradingEffect.ts new file mode 100644 index 000000000000..8ed3880208b9 --- /dev/null +++ b/Extensions/3D/ColorGradingEffect.ts @@ -0,0 +1,271 @@ +namespace gdjs { + interface ColorGradingNetworkSyncData { + t: number; + ti: number; + s: number; + c: number; + b: number; + e: boolean; + } + + const colorGradingShader = { + uniforms: { + tDiffuse: { value: null }, + tSceneColor: { value: null }, + temperature: { value: -0.3 }, + tint: { value: -0.1 }, + saturation: { value: 0.8 }, + contrast: { value: 1.2 }, + brightness: { value: 0.95 }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + uniform sampler2D tDiffuse; + uniform sampler2D tSceneColor; + uniform float temperature; + uniform float tint; + uniform float saturation; + uniform float contrast; + uniform float brightness; + varying vec2 vUv; + + vec3 applyTemperatureAndTint(vec3 color, float temp, float tintShift) { + // Temperature: negative cools (blue), positive warms (orange). + color += vec3(temp * 0.12, temp * 0.03, -temp * 0.12); + + // Tint: negative -> green, positive -> magenta. + color += vec3(tintShift * 0.05, -tintShift * 0.1, tintShift * 0.05); + return color; + } + + vec3 applySaturation(vec3 color, float sat) { + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + return mix(vec3(luma), color, sat); + } + + vec3 applyContrast(vec3 color, float ctr) { + return (color - 0.5) * ctr + 0.5; + } + + void main() { + vec4 inputColor = texture2D(tDiffuse, vUv); + vec3 sceneColor = texture2D(tSceneColor, vUv).rgb; + + // Keep current stack output as primary source while integrating shared capture. + vec3 color = mix(sceneColor, inputColor.rgb, 0.85); + + color = applyTemperatureAndTint(color, temperature, tint); + color = applySaturation(color, saturation); + color = applyContrast(color, contrast); + color *= brightness; + + gl_FragColor = vec4(clamp(color, 0.0, 1.0), inputColor.a); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::ColorGrading', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _temperature: number; + _tint: number; + _saturation: number; + _contrast: number; + _brightness: number; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass(colorGradingShader); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'COLORGRADE'); + this._isEnabled = false; + this._effectEnabled = true; + this._temperature = -0.3; + this._tint = -0.1; + this._saturation = 0.8; + this._contrast = 1.2; + this._brightness = 0.95; + this.shaderPass.enabled = true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + this._isEnabled = true; + return true; + } + + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + this._isEnabled = false; + return true; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled || !this._effectEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + return; + } + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture) { + return; + } + + this.shaderPass.enabled = true; + this.shaderPass.uniforms.tSceneColor.value = + sharedCapture.colorTexture; + this.shaderPass.uniforms.temperature.value = this._temperature; + this.shaderPass.uniforms.tint.value = this._tint; + this.shaderPass.uniforms.saturation.value = this._saturation; + this.shaderPass.uniforms.contrast.value = this._contrast; + this.shaderPass.uniforms.brightness.value = this._brightness; + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'temperature') { + this._temperature = gdjs.evtTools.common.clamp(-2, 2, value); + this.shaderPass.uniforms.temperature.value = this._temperature; + } else if (parameterName === 'tint') { + this._tint = gdjs.evtTools.common.clamp(-2, 2, value); + this.shaderPass.uniforms.tint.value = this._tint; + } else if (parameterName === 'saturation') { + this._saturation = Math.max(0, value); + this.shaderPass.uniforms.saturation.value = this._saturation; + } else if (parameterName === 'contrast') { + this._contrast = Math.max(0, value); + this.shaderPass.uniforms.contrast.value = this._contrast; + } else if (parameterName === 'brightness') { + this._brightness = Math.max(0, value); + this.shaderPass.uniforms.brightness.value = this._brightness; + } + } + + getDoubleParameter(parameterName: string): number { + if (parameterName === 'temperature') { + return this._temperature; + } + if (parameterName === 'tint') { + return this._tint; + } + if (parameterName === 'saturation') { + return this._saturation; + } + if (parameterName === 'contrast') { + return this._contrast; + } + if (parameterName === 'brightness') { + return this._brightness; + } + return 0; + } + + updateStringParameter(parameterName: string, value: string): void {} + updateColorParameter(parameterName: string, value: number): void {} + getColorParameter(parameterName: string): number { + return 0; + } + + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + + getNetworkSyncData(): ColorGradingNetworkSyncData { + return { + t: this._temperature, + ti: this._tint, + s: this._saturation, + c: this._contrast, + b: this._brightness, + e: this._effectEnabled, + }; + } + + updateFromNetworkSyncData( + syncData: ColorGradingNetworkSyncData + ): void { + this._temperature = gdjs.evtTools.common.clamp(-2, 2, syncData.t); + this._tint = gdjs.evtTools.common.clamp(-2, 2, syncData.ti); + this._saturation = Math.max(0, syncData.s); + this._contrast = Math.max(0, syncData.c); + this._brightness = Math.max(0, syncData.b); + this._effectEnabled = !!syncData.e; + + this.shaderPass.uniforms.temperature.value = this._temperature; + this.shaderPass.uniforms.tint.value = this._tint; + this.shaderPass.uniforms.saturation.value = this._saturation; + this.shaderPass.uniforms.contrast.value = this._contrast; + this.shaderPass.uniforms.brightness.value = this._brightness; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/DepthOfFieldEffect.ts b/Extensions/3D/DepthOfFieldEffect.ts new file mode 100644 index 000000000000..be2effb52aad --- /dev/null +++ b/Extensions/3D/DepthOfFieldEffect.ts @@ -0,0 +1,363 @@ +namespace gdjs { + interface DepthOfFieldNetworkSyncData { + fd: number; + fr: number; + mb: number; + s: number; + e: boolean; + q?: string; + } + + const depthOfFieldShader = { + uniforms: { + tDiffuse: { value: null }, + tDepth: { value: null }, + resolution: { value: new THREE.Vector2(1, 1) }, + focusDistance: { value: 400.0 }, + focusRange: { value: 250.0 }, + maxBlur: { value: 6.0 }, + sampleCount: { value: 4.0 }, + cameraProjectionMatrixInverse: { value: new THREE.Matrix4() }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + uniform sampler2D tDiffuse; + uniform sampler2D tDepth; + uniform vec2 resolution; + uniform float focusDistance; + uniform float focusRange; + uniform float maxBlur; + uniform float sampleCount; + uniform mat4 cameraProjectionMatrixInverse; + varying vec2 vUv; + + const int MAX_DOF_SAMPLES = 8; + + vec3 viewPositionFromDepth(vec2 uv, float depth) { + vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 view = cameraProjectionMatrixInverse * clip; + return view.xyz / max(view.w, 0.00001); + } + + float getPixelDistance(float depth, vec2 uv) { + if (depth >= 1.0) { + return focusDistance + focusRange + maxBlur * 100.0; + } + return length(viewPositionFromDepth(uv, depth)); + } + + float getBlurFactor(float distanceToCamera) { + float safeRange = max(focusRange, 0.0001); + float distanceFromFocus = abs(distanceToCamera - focusDistance); + float raw = clamp(distanceFromFocus / safeRange, 0.0, 1.0); + return raw * raw * (3.0 - 2.0 * raw); + } + + void main() { + vec4 baseColor = texture2D(tDiffuse, vUv); + if (maxBlur <= 0.0) { + gl_FragColor = baseColor; + return; + } + + float depth = texture2D(tDepth, vUv).x; + float distanceToCamera = getPixelDistance(depth, vUv); + float blurFactor = getBlurFactor(distanceToCamera); + if (blurFactor <= 0.001) { + gl_FragColor = baseColor; + return; + } + + float blurRadius = maxBlur * blurFactor; + vec2 texel = 1.0 / resolution; + float count = clamp(sampleCount, 2.0, float(MAX_DOF_SAMPLES)); + + vec3 accumColor = baseColor.rgb; + float accumWeight = 1.0; + + for (int i = 0; i < MAX_DOF_SAMPLES; i++) { + if (float(i) >= count) { + break; + } + float t = (float(i) + 0.5) / count; + float angle = 6.28318530718 * t; + vec2 direction = vec2(cos(angle), sin(angle)); + vec2 sampleUv = clamp( + vUv + direction * texel * blurRadius, + vec2(0.0), + vec2(1.0) + ); + vec3 sampleColor = texture2D(tDiffuse, sampleUv).rgb; + accumColor += sampleColor; + accumWeight += 1.0; + } + + vec3 blurredColor = accumColor / max(accumWeight, 0.00001); + vec3 finalColor = mix(baseColor.rgb, blurredColor, blurFactor); + gl_FragColor = vec4(finalColor, baseColor.a); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::DepthOfField', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _focusDistance: number; + _focusRange: number; + _maxBlur: number; + _samples: number; + _effectiveSamples: number; + _effectiveBlurScale: number; + _qualityMode: string; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass(depthOfFieldShader); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'DOF'); + this._isEnabled = false; + this._effectEnabled = + effectData.booleanParameters.enabled === undefined + ? true + : !!effectData.booleanParameters.enabled; + this._focusDistance = + effectData.doubleParameters.focusDistance !== undefined + ? Math.max(0, effectData.doubleParameters.focusDistance) + : 400; + this._focusRange = + effectData.doubleParameters.focusRange !== undefined + ? Math.max(0.0001, effectData.doubleParameters.focusRange) + : 250; + this._maxBlur = + effectData.doubleParameters.maxBlur !== undefined + ? Math.max(0, effectData.doubleParameters.maxBlur) + : 6; + this._samples = + effectData.doubleParameters.samples !== undefined + ? Math.max( + 2, + Math.min(8, Math.round(effectData.doubleParameters.samples)) + ) + : 4; + this._effectiveSamples = this._samples; + this._effectiveBlurScale = 1.0; + this._qualityMode = + effectData.stringParameters.qualityMode || 'medium'; + + this.shaderPass.uniforms.focusDistance.value = this._focusDistance; + this.shaderPass.uniforms.focusRange.value = this._focusRange; + this.shaderPass.uniforms.maxBlur.value = this._maxBlur; + this.shaderPass.uniforms.sampleCount.value = this._samples; + this.shaderPass.enabled = true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + gdjs.reorderScene3DPostProcessingPasses(target); + this._isEnabled = true; + return true; + } + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'DOF'); + this._isEnabled = false; + return true; + } + + private _adaptQuality(target: gdjs.EffectsTarget): void { + if (!(target instanceof gdjs.Layer)) { + return; + } + const quality = gdjs.getScene3DPostProcessingQualityProfileForMode( + this._qualityMode + ); + this._effectiveSamples = Math.max( + 2, + Math.min(quality.dofSamples, this._samples) + ); + this._effectiveBlurScale = quality.dofBlurScale; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + if (!this._effectEnabled) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'DOF'); + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'DOF'); + return; + } + gdjs.setScene3DPostProcessingEffectQualityMode( + target, + 'DOF', + this._qualityMode + ); + this._adaptQuality(target); + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture || !sharedCapture.depthTexture) { + return; + } + + threeCamera.updateMatrixWorld(); + threeCamera.updateProjectionMatrix(); + threeCamera.projectionMatrixInverse + .copy(threeCamera.projectionMatrix) + .invert(); + this.shaderPass.enabled = true; + this.shaderPass.uniforms.resolution.value.set( + sharedCapture.width, + sharedCapture.height + ); + this.shaderPass.uniforms.tDepth.value = sharedCapture.depthTexture; + this.shaderPass.uniforms.cameraProjectionMatrixInverse.value.copy( + threeCamera.projectionMatrixInverse + ); + this.shaderPass.uniforms.focusDistance.value = this._focusDistance; + this.shaderPass.uniforms.focusRange.value = this._focusRange; + this.shaderPass.uniforms.maxBlur.value = + this._maxBlur * this._effectiveBlurScale; + this.shaderPass.uniforms.sampleCount.value = this._effectiveSamples; + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'focusDistance') { + this._focusDistance = Math.max(0, value); + this.shaderPass.uniforms.focusDistance.value = + this._focusDistance; + } else if (parameterName === 'focusRange') { + this._focusRange = Math.max(0.0001, value); + this.shaderPass.uniforms.focusRange.value = this._focusRange; + } else if (parameterName === 'maxBlur') { + this._maxBlur = Math.max(0, value); + this.shaderPass.uniforms.maxBlur.value = this._maxBlur; + } else if (parameterName === 'samples') { + this._samples = Math.max(2, Math.min(8, Math.round(value))); + this.shaderPass.uniforms.sampleCount.value = this._samples; + } + } + getDoubleParameter(parameterName: string): number { + if (parameterName === 'focusDistance') { + return this._focusDistance; + } + if (parameterName === 'focusRange') { + return this._focusRange; + } + if (parameterName === 'maxBlur') { + return this._maxBlur; + } + if (parameterName === 'samples') { + return this._samples; + } + return 0; + } + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } + updateColorParameter(parameterName: string, value: number): void {} + getColorParameter(parameterName: string): number { + return 0; + } + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + getNetworkSyncData(): DepthOfFieldNetworkSyncData { + return { + fd: this._focusDistance, + fr: this._focusRange, + mb: this._maxBlur, + s: this._samples, + e: this._effectEnabled, + q: this._qualityMode, + }; + } + updateFromNetworkSyncData( + syncData: DepthOfFieldNetworkSyncData + ): void { + this._focusDistance = syncData.fd; + this._focusRange = syncData.fr; + this._maxBlur = syncData.mb; + this._samples = Math.max(2, Math.min(8, Math.round(syncData.s))); + this._effectEnabled = syncData.e; + this._qualityMode = syncData.q || 'medium'; + + this.shaderPass.uniforms.focusDistance.value = this._focusDistance; + this.shaderPass.uniforms.focusRange.value = this._focusRange; + this.shaderPass.uniforms.maxBlur.value = this._maxBlur; + this.shaderPass.uniforms.sampleCount.value = this._samples; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/JsExtension.js b/Extensions/3D/JsExtension.js index a1600760912c..efc23c7401c0 100644 --- a/Extensions/3D/JsExtension.js +++ b/Extensions/3D/JsExtension.js @@ -252,631 +252,1467 @@ module.exports = { } { - const object = extension - .addObject( - 'Model3DObject', - _('3D Model'), - _('An animated 3D model, useful for most elements of a 3D game.'), - 'JsPlatform/Extensions/3d_model.svg', - new gd.Model3DObjectConfiguration() - ) - .setCategory('General') - // Effects are unsupported because the object is not rendered with PIXI. - .addDefaultBehavior('ResizableCapability::ResizableBehavior') - .addDefaultBehavior('ScalableCapability::ScalableBehavior') - .addDefaultBehavior('FlippableCapability::FlippableBehavior') - .addDefaultBehavior('AnimatableCapability::AnimatableBehavior') - .addDefaultBehavior('Scene3D::Base3DBehavior') - .markAsRenderedIn3D() - .setIncludeFile('Extensions/3D/A_RuntimeObject3D.js') - .addIncludeFile('Extensions/3D/A_RuntimeObject3DRenderer.js') - .addIncludeFile('Extensions/3D/Model3DRuntimeObject.js') - .addIncludeFile('Extensions/3D/Model3DRuntimeObject3DRenderer.js'); + const behavior = new gd.BehaviorJsImplementation(); - // Properties expressions/conditions/actions: + 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); + } + } - // Deprecated - object - .addExpressionAndConditionAndAction( - 'number', - 'Z', - _('Z (elevation)'), - _('the Z position (the "elevation")'), - _('the Z position'), - _('Position'), - 'res/conditions/3d_box.svg' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) - .setHidden() - .setFunctionName('setZ') - .setGetter('getZ'); + if (propertyName === 'enabled') { + behaviorContent + .getChild('enabled') + .setBoolValue(newValue === '1' || newValue === 'true'); + return true; + } - // Deprecated - object - .addExpressionAndConditionAndAction( - 'number', - 'Depth', - _('Depth (size on Z axis)'), - _('the depth (size on Z axis)'), - _('the depth'), - _('Size'), - 'res/conditions/3d_box.svg' + 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() ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) - .setHidden() - .setFunctionName('setDepth') - .setGetter('getDepth'); + .setIncludeFile('Extensions/3D/FlickeringLightBehavior.js'); - // Deprecated - object + flickeringLight .addScopedAction( - 'SetWidth', - _('Width'), - _('Change the width of an object.'), - _('the width'), - _('Size'), - 'res/actions/scaleWidth24_black.png', - 'res/actions/scaleWidth_black.png' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardOperatorParameters( - 'number', - gd.ParameterOptions.makeNewOptions() + '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' ) - .setHidden() - .markAsAdvanced() - .setFunctionName('setWidth') - .setGetter('getWidth'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .addParameter('yesorno', _('Enabled')) + .setFunctionName('setEnabled'); - // Deprecated - object + flickeringLight .addScopedCondition( - 'Width', - _('Width'), - _('Compare the width of an object.'), - _('the width'), - _('Size'), - 'res/actions/scaleWidth24_black.png', - 'res/actions/scaleWidth_black.png' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardRelationalOperatorParameters( - 'number', - gd.ParameterOptions.makeNewOptions() + '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' ) - .setHidden() - .markAsAdvanced() - .setFunctionName('getWidth'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .setFunctionName('isEnabled'); - // Deprecated - object + flickeringLight .addScopedAction( - 'SetHeight', - _('Height'), - _('Change the height of an object.'), - _('the height'), - _('Size'), - 'res/actions/scaleHeight24_black.png', - 'res/actions/scaleHeight_black.png' + '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', _('3D model'), 'Model3DObject', false) - .useStandardOperatorParameters( - 'number', - gd.ParameterOptions.makeNewOptions() + .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' ) - .setHidden() - .markAsAdvanced() - .setFunctionName('setHeight') - .setGetter('getHeight'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .addParameter('layerEffectName', _('Light effect name')) + .setFunctionName('setTargetEffectName'); - // Deprecated - object - .addScopedCondition( - 'Height', - _('Height'), - _('Compare the height of an object.'), - _('the height'), - _('Size'), - 'res/actions/scaleHeight24_black.png', - 'res/actions/scaleHeight_black.png' + flickeringLight + .addExpressionAndConditionAndAction( + 'number', + 'BaseIntensity', + _('Base intensity'), + _('the base light intensity'), + _('the base intensity'), + _('Flickering light'), + 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardRelationalOperatorParameters( + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( 'number', - gd.ParameterOptions.makeNewOptions() + gd.ParameterOptions.makeNewOptions().setDescription( + _('Base intensity used when flicker offset is 0.') + ) ) - .setHidden() - .markAsAdvanced() - .setFunctionName('getHeight'); + .setFunctionName('setBaseIntensity') + .setGetter('getBaseIntensity'); - // Deprecated - object + flickeringLight .addExpressionAndConditionAndAction( 'number', - 'Height', - _('Height'), - _('the height'), - _('the height'), - _('Size'), - 'res/actions/scaleHeight24_black.png' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) - .setHidden() - .setFunctionName('setHeight') - .setGetter('getHeight'); - - // Deprecated - object - .addScopedAction( - 'Scale', - _('Scale'), - _('Modify the scale of the specified object.'), - _('the scale'), - _('Size'), - 'res/actions/scale24_black.png', - 'res/actions/scale_black.png' + 'FlickerSpeed', + _('Flicker speed'), + _('the flickering speed'), + _('the flicker speed'), + _('Flickering light'), + 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardOperatorParameters( + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') + .useStandardParameters( 'number', gd.ParameterOptions.makeNewOptions().setDescription( - _('Scale (1 by default)') + _('How fast the flicker oscillates.') ) ) - .setHidden() - .markAsAdvanced() - .setFunctionName('setScale') - .setGetter('getScale'); + .setFunctionName('setFlickerSpeed') + .setGetter('getFlickerSpeed'); - // Deprecated - object + flickeringLight .addExpressionAndConditionAndAction( 'number', - 'ScaleX', - _('Scale on X axis'), - _("the width's scale of an object"), - _("the width's scale"), - _('Size'), - 'res/actions/scaleWidth24_black.png' + 'FlickerStrength', + _('Flicker strength'), + _('the flicker strength'), + _('the flicker strength'), + _('Flickering light'), + 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') .useStandardParameters( 'number', gd.ParameterOptions.makeNewOptions().setDescription( - _('Scale (1 by default)') + _('How much intensity can vary around the base value.') ) ) - .setHidden() - .markAsAdvanced() - .setFunctionName('setScaleX') - .setGetter('getScaleX'); + .setFunctionName('setFlickerStrength') + .setGetter('getFlickerStrength'); - // Deprecated - object + flickeringLight .addExpressionAndConditionAndAction( 'number', - 'ScaleY', - _('Scale on Y axis'), - _("the height's scale of an object"), - _("the height's scale"), - _('Size'), - 'res/actions/scaleHeight24_black.png' + 'FailChance', + _('Failure chance'), + _('the failure chance per second'), + _('the failure chance per second'), + _('Flickering light'), + 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') .useStandardParameters( 'number', gd.ParameterOptions.makeNewOptions().setDescription( - _('Scale (1 by default)') + _('Probability per second for a complete temporary blackout.') ) ) - .setHidden() - .markAsAdvanced() - .setFunctionName('setScaleY') - .setGetter('getScaleY'); + .setFunctionName('setFailChance') + .setGetter('getFailChance'); - // Deprecated - object + flickeringLight .addExpressionAndConditionAndAction( 'number', - 'ScaleZ', - _('Scale on Z axis'), - _("the depth's scale of an object"), - _("the depth's scale"), - _('Size'), + 'OffDuration', + _('Off duration'), + _('the off duration in seconds'), + _('the off duration'), + _('Flickering light'), 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'FlickeringLight') .useStandardParameters( 'number', gd.ParameterOptions.makeNewOptions().setDescription( - _('Scale (1 by default)') + _('How long the light stays off when a failure occurs (seconds).') ) ) - .markAsAdvanced() - .setHidden() - .setFunctionName('setScaleZ') - .setGetter('getScaleZ'); + .setFunctionName('setOffDuration') + .setGetter('getOffDuration'); + } - // Deprecated - object - .addScopedAction( - 'FlipX', - _('Flip the object horizontally'), - _('Flip the object horizontally'), - _('Flip horizontally _PARAM0_: _PARAM1_'), - _('Effects'), - 'res/actions/flipX24.png', - 'res/actions/flipX.png' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('yesorno', _('Activate flipping')) - .setHidden() - .markAsSimple() - .setFunctionName('flipX'); + { + const behavior = new gd.BehaviorJsImplementation(); - // Deprecated - object - .addScopedAction( - 'FlipY', - _('Flip the object vertically'), - _('Flip the object vertically'), - _('Flip vertically _PARAM0_: _PARAM1_'), - _('Effects'), - 'res/actions/flipY24.png', - 'res/actions/flipY.png' + behavior.updateProperty = function ( + behaviorContent, + propertyName, + newValue + ) { + if (!behaviorContent.hasChild('enabled')) { + behaviorContent.addChild('enabled').setBoolValue(true); + } + + if (propertyName === 'enabled') { + behaviorContent + .getChild('enabled') + .setBoolValue(newValue === '1' || newValue === 'true'); + return true; + } + + return false; + }; + + behavior.getProperties = function (behaviorContent) { + const behaviorProperties = new gd.MapStringPropertyDescriptor(); + + if (!behaviorContent.hasChild('enabled')) { + behaviorContent.addChild('enabled').setBoolValue(true); + } + + behaviorProperties + .getOrCreate('enabled') + .setValue( + behaviorContent.getChild('enabled').getBoolValue() + ? 'true' + : 'false' + ) + .setType('Boolean') + .setLabel(_('Enabled')); + + return behaviorProperties; + }; + + behavior.initializeContent = function (behaviorContent) { + behaviorContent.addChild('enabled').setBoolValue(true); + }; + + const ssrExclude = extension + .addBehavior( + 'SSRExclude', + _('SSR exclude'), + 'SSRExclude', + _('Exclude this 3D object from Scene3D screen-space reflections.'), + '', + 'res/conditions/3d_box.svg', + 'SSRExclude', + // @ts-ignore + behavior, + new gd.BehaviorsSharedData() ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('yesorno', _('Activate flipping')) - .setHidden() - .markAsSimple() - .setFunctionName('flipY'); + .setIncludeFile('Extensions/3D/SSRExcludeBehavior.js'); - // Deprecated - object + ssrExclude .addScopedAction( - 'FlipZ', - _('Flip the object on Z'), - _('Flip the object on Z axis'), - _('Flip on Z axis _PARAM0_: _PARAM1_'), - _('Effects'), + 'SetEnabled', + _('Enable/disable SSR exclusion'), + _('Enable or disable exclusion of this object from SSR.'), + _('Set SSR exclusion of _PARAM0_ to _PARAM2_'), + _('SSR exclusion'), 'res/conditions/3d_box.svg', 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('yesorno', _('Activate flipping')) - .markAsSimple() - .setHidden() - .setFunctionName('flipZ'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'SSRExclude') + .addParameter('yesorno', _('Enabled')) + .setFunctionName('setEnabled'); - // Deprecated - object + ssrExclude .addScopedCondition( - 'FlippedX', - _('Horizontally flipped'), - _('Check if the object is horizontally flipped'), - _('_PARAM0_ is horizontally flipped'), - _('Effects'), - 'res/actions/flipX24.png', - 'res/actions/flipX.png' + 'IsEnabled', + _('SSR exclusion enabled'), + _('Check if SSR exclusion is enabled for this object.'), + _('SSR exclusion is enabled for _PARAM0_'), + _('SSR exclusion'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .setHidden() - .setFunctionName('isFlippedX'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'SSRExclude') + .setFunctionName('isEnabled'); + } - // Deprecated - object - .addScopedCondition( - 'FlippedY', - _('Vertically flipped'), - _('Check if the object is vertically flipped'), - _('_PARAM0_ is vertically flipped'), - _('Effects'), - 'res/actions/flipY24.png', - 'res/actions/flipY.png' - ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .setHidden() - .setFunctionName('isFlippedY'); + { + const behavior = new gd.BehaviorJsImplementation(); - // Deprecated - object - .addScopedCondition( - 'FlippedZ', - _('Flipped on Z'), - _('Check if the object is flipped on Z axis'), - _('_PARAM0_ is flipped on Z axis'), - _('Effects'), + const ensurePBRMaterialDefaults = function (behaviorContent) { + if (!behaviorContent.hasChild('metalness')) { + behaviorContent.addChild('metalness').setDoubleValue(0.0); + } + if (!behaviorContent.hasChild('roughness')) { + behaviorContent.addChild('roughness').setDoubleValue(0.5); + } + if (!behaviorContent.hasChild('envMapIntensity')) { + behaviorContent.addChild('envMapIntensity').setDoubleValue(1.0); + } + if (!behaviorContent.hasChild('emissiveColor')) { + behaviorContent.addChild('emissiveColor').setStringValue('0;0;0'); + } + if (!behaviorContent.hasChild('emissiveIntensity')) { + behaviorContent.addChild('emissiveIntensity').setDoubleValue(0.0); + } + if (!behaviorContent.hasChild('normalScale')) { + behaviorContent.addChild('normalScale').setDoubleValue(1.0); + } + if (!behaviorContent.hasChild('normalMapAsset')) { + behaviorContent.addChild('normalMapAsset').setStringValue(''); + } + if (!behaviorContent.hasChild('aoMapAsset')) { + behaviorContent.addChild('aoMapAsset').setStringValue(''); + } + if (!behaviorContent.hasChild('aoMapIntensity')) { + behaviorContent.addChild('aoMapIntensity').setDoubleValue(1.0); + } + if (!behaviorContent.hasChild('map')) { + behaviorContent.addChild('map').setStringValue(''); + } + }; + + const clampValue = function (value, min, max) { + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return min; + } + return Math.max(min, Math.min(max, numericValue)); + }; + + behavior.updateProperty = function ( + behaviorContent, + propertyName, + newValue + ) { + ensurePBRMaterialDefaults(behaviorContent); + + if (propertyName === 'metalness') { + behaviorContent + .getChild('metalness') + .setDoubleValue(clampValue(newValue, 0, 1)); + return true; + } + if (propertyName === 'roughness') { + behaviorContent + .getChild('roughness') + .setDoubleValue(clampValue(newValue, 0, 1)); + return true; + } + if (propertyName === 'envMapIntensity') { + behaviorContent + .getChild('envMapIntensity') + .setDoubleValue(clampValue(newValue, 0, 4)); + return true; + } + if (propertyName === 'emissiveColor') { + behaviorContent.getChild('emissiveColor').setStringValue(newValue); + return true; + } + if (propertyName === 'emissiveIntensity') { + behaviorContent + .getChild('emissiveIntensity') + .setDoubleValue(clampValue(newValue, 0, 4)); + return true; + } + if (propertyName === 'normalScale') { + behaviorContent + .getChild('normalScale') + .setDoubleValue(clampValue(newValue, 0, 2)); + return true; + } + if (propertyName === 'normalMapAsset') { + behaviorContent.getChild('normalMapAsset').setStringValue(newValue); + return true; + } + if (propertyName === 'aoMapAsset') { + behaviorContent.getChild('aoMapAsset').setStringValue(newValue); + return true; + } + if (propertyName === 'aoMapIntensity') { + behaviorContent + .getChild('aoMapIntensity') + .setDoubleValue(clampValue(newValue, 0, 1)); + return true; + } + if (propertyName === 'map') { + behaviorContent.getChild('map').setStringValue(newValue); + return true; + } + + return false; + }; + + behavior.getProperties = function (behaviorContent) { + const behaviorProperties = new gd.MapStringPropertyDescriptor(); + ensurePBRMaterialDefaults(behaviorContent); + + behaviorProperties + .getOrCreate('metalness') + .setValue( + behaviorContent.getChild('metalness').getDoubleValue().toString() + ) + .setType('number') + .setLabel(_('Metalness')); + behaviorProperties + .getOrCreate('roughness') + .setValue( + behaviorContent.getChild('roughness').getDoubleValue().toString() + ) + .setType('number') + .setLabel(_('Roughness')); + behaviorProperties + .getOrCreate('envMapIntensity') + .setValue( + behaviorContent + .getChild('envMapIntensity') + .getDoubleValue() + .toString() + ) + .setType('number') + .setLabel(_('Environment intensity')); + behaviorProperties + .getOrCreate('emissiveColor') + .setValue(behaviorContent.getChild('emissiveColor').getStringValue()) + .setType('color') + .setLabel(_('Emissive color')); + behaviorProperties + .getOrCreate('emissiveIntensity') + .setValue( + behaviorContent + .getChild('emissiveIntensity') + .getDoubleValue() + .toString() + ) + .setType('number') + .setLabel(_('Emissive intensity')); + behaviorProperties + .getOrCreate('normalScale') + .setValue( + behaviorContent.getChild('normalScale').getDoubleValue().toString() + ) + .setType('number') + .setLabel(_('Normal scale')); + behaviorProperties + .getOrCreate('normalMapAsset') + .setValue(behaviorContent.getChild('normalMapAsset').getStringValue()) + .setType('resource') + .addExtraInfo('image') + .setLabel(_('Normal map')); + behaviorProperties + .getOrCreate('aoMapAsset') + .setValue(behaviorContent.getChild('aoMapAsset').getStringValue()) + .setType('resource') + .addExtraInfo('image') + .setLabel(_('AO map')); + behaviorProperties + .getOrCreate('aoMapIntensity') + .setValue( + behaviorContent + .getChild('aoMapIntensity') + .getDoubleValue() + .toString() + ) + .setType('number') + .setLabel(_('AO intensity')); + behaviorProperties + .getOrCreate('map') + .setValue(behaviorContent.getChild('map').getStringValue()) + .setType('resource') + .addExtraInfo('image') + .setLabel(_('Albedo map')); + + return behaviorProperties; + }; + + behavior.initializeContent = function (behaviorContent) { + behaviorContent.addChild('metalness').setDoubleValue(0.0); + behaviorContent.addChild('roughness').setDoubleValue(0.5); + behaviorContent.addChild('envMapIntensity').setDoubleValue(1.0); + behaviorContent.addChild('emissiveColor').setStringValue('0;0;0'); + behaviorContent.addChild('emissiveIntensity').setDoubleValue(0.0); + behaviorContent.addChild('normalScale').setDoubleValue(1.0); + behaviorContent.addChild('normalMapAsset').setStringValue(''); + behaviorContent.addChild('aoMapAsset').setStringValue(''); + behaviorContent.addChild('aoMapIntensity').setDoubleValue(1.0); + behaviorContent.addChild('map').setStringValue(''); + }; + + const pbrMaterial = extension + .addBehavior( + 'PBRMaterial', + _('PBR material'), + 'PBRMaterial', + _( + 'Control physically based material parameters for 3D meshes using MeshStandardMaterial and MeshPhysicalMaterial.' + ), + '', 'res/conditions/3d_box.svg', + 'PBRMaterial', + // @ts-ignore + behavior, + new gd.BehaviorsSharedData() + ) + .setIncludeFile('Extensions/3D/PBRMaterialBehavior.js'); + + pbrMaterial + .addExpressionAndConditionAndAction( + 'number', + 'Metalness', + _('Metalness'), + _('the metalness'), + _('the metalness'), + _('PBR material'), 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .setHidden() - .setFunctionName('isFlippedZ'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setMetalness') + .setGetter('getMetalness'); - // Deprecated - object + pbrMaterial .addExpressionAndConditionAndAction( 'number', - 'RotationX', - _('Rotation on X axis'), - _('the rotation on X axis'), - _('the rotation on X axis'), - _('Angle'), + 'Roughness', + _('Roughness'), + _('the roughness'), + _('the roughness'), + _('PBR material'), 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters( + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setRoughness') + .setGetter('getRoughness'); + + pbrMaterial + .addExpressionAndConditionAndAction( 'number', - gd.ParameterOptions.makeNewOptions().setDescription( - _('Angle (in degrees)') - ) + 'EnvironmentIntensity', + _('Environment intensity'), + _('the environment map intensity'), + _('the environment intensity'), + _('PBR material'), + 'res/conditions/3d_box.svg' ) - .setHidden() - .setFunctionName('setRotationX') - .setGetter('getRotationX'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setEnvMapIntensity') + .setGetter('getEnvMapIntensity'); - // Deprecated - object + pbrMaterial .addExpressionAndConditionAndAction( 'number', - 'RotationY', - _('Rotation on Y axis'), - _('the rotation on Y axis'), - _('the rotation on Y axis'), - _('Angle'), + 'EmissiveIntensity', + _('Emissive intensity'), + _('the emissive intensity'), + _('the emissive intensity'), + _('PBR material'), 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters( + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setEmissiveIntensity') + .setGetter('getEmissiveIntensity'); + + pbrMaterial + .addExpressionAndConditionAndAction( 'number', - gd.ParameterOptions.makeNewOptions().setDescription( - _('Angle (in degrees)') - ) + 'NormalScale', + _('Normal scale'), + _('the normal map scale'), + _('the normal scale'), + _('PBR material'), + 'res/conditions/3d_box.svg' ) - .setHidden() - .setFunctionName('setRotationY') - .setGetter('getRotationY'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setNormalScale') + .setGetter('getNormalScale'); - // Deprecated - object + pbrMaterial + .addExpressionAndConditionAndAction( + 'number', + 'AOMapIntensity', + _('AO map intensity'), + _('the AO map intensity'), + _('the AO map intensity'), + _('PBR material'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .setFunctionName('setAOMapIntensity') + .setGetter('getAOMapIntensity'); + + pbrMaterial .addScopedAction( - 'TurnAroundX', - _('Turn around X axis'), + 'SetEmissiveColor', + _('Set emissive color'), + _('Set the emissive color used by PBR materials on this object.'), + _('Set emissive color of _PARAM0_ to _PARAM2_'), + _('PBR material'), + 'res/actions/color24.png', + 'res/actions/color.png' + ) + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .addParameter('color', _('Emissive color')) + .setFunctionName('setEmissiveColor'); + + pbrMaterial + .addScopedAction( + 'SetNormalMapAsset', + _('Set normal map'), _( - "Turn the object around X axis. This axis doesn't move with the object rotation." + 'Set the normal map resource used by PBR materials on this object.' ), - _('Turn _PARAM0_ from _PARAM1_° around X axis'), - _('Angle'), + _('Set normal map of _PARAM0_ to _PARAM2_'), + _('PBR material'), 'res/conditions/3d_box.svg', 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('number', _('Angle to add (in degrees)'), '', false) - .markAsAdvanced() - .setHidden() - .setFunctionName('turnAroundX'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .addParameter('imageResource', _('Normal map'), '', true) + .setFunctionName('setNormalMapAsset'); - // Deprecated - object + pbrMaterial .addScopedAction( - 'TurnAroundY', - _('Turn around Y axis'), - _( - "Turn the object around Y axis. This axis doesn't move with the object rotation." - ), - _('Turn _PARAM0_ from _PARAM1_° around Y axis'), - _('Angle'), + 'SetAOMapAsset', + _('Set AO map'), + _('Set the AO map resource used by PBR materials on this object.'), + _('Set AO map of _PARAM0_ to _PARAM2_'), + _('PBR material'), 'res/conditions/3d_box.svg', 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('number', _('Angle to add (in degrees)'), '', false) - .markAsAdvanced() - .setHidden() - .setFunctionName('turnAroundY'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .addParameter('imageResource', _('AO map'), '', true) + .setFunctionName('setAOMapAsset'); - // Deprecated - object + pbrMaterial .addScopedAction( - 'TurnAroundZ', - _('Turn around Z axis'), + 'SetMap', + _('Set albedo map'), _( - "Turn the object around Z axis. This axis doesn't move with the object rotation." + 'Set the albedo (base color) map resource used by PBR materials on this object.' ), - _('Turn _PARAM0_ from _PARAM1_° around Z axis'), - _('Angle'), + _('Set albedo map of _PARAM0_ to _PARAM2_'), + _('PBR material'), 'res/conditions/3d_box.svg', 'res/conditions/3d_box.svg' ) - .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('number', _('Angle to add (in degrees)'), '', false) - .markAsAdvanced() - .setHidden() - .setFunctionName('turnAroundZ'); + .addParameter('object', _('Object'), '', false) + .addParameter('behavior', _('Behavior'), 'PBRMaterial') + .addParameter('imageResource', _('Albedo map'), '', true) + .setFunctionName('setMap'); + } - // Deprecated - object - .addExpressionAndConditionAndAction( - 'number', - 'Animation', - _('Animation (by number)'), - _( - 'the number of the animation played by the object (the number from the animations list)' - ), - _('the number of the animation'), - _('Animations and images'), - 'res/actions/animation24.png' + { + const object = extension + .addObject( + 'Model3DObject', + _('3D Model'), + _('An animated 3D model, useful for most elements of a 3D game.'), + 'JsPlatform/Extensions/3d_model.svg', + new gd.Model3DObjectConfiguration() + ) + .setCategory('General') + // Effects are unsupported because the object is not rendered with PIXI. + .addDefaultBehavior('ResizableCapability::ResizableBehavior') + .addDefaultBehavior('ScalableCapability::ScalableBehavior') + .addDefaultBehavior('FlippableCapability::FlippableBehavior') + .addDefaultBehavior('AnimatableCapability::AnimatableBehavior') + .addDefaultBehavior('Scene3D::Base3DBehavior') + .markAsRenderedIn3D() + .setIncludeFile('Extensions/3D/A_RuntimeObject3D.js') + .addIncludeFile('Extensions/3D/A_RuntimeObject3DRenderer.js') + .addIncludeFile('Extensions/3D/Model3DRuntimeObject.js') + .addIncludeFile('Extensions/3D/Model3DRuntimeObject3DRenderer.js'); + + // Properties expressions/conditions/actions: + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'Z', + _('Z (elevation)'), + _('the Z position (the "elevation")'), + _('the Z position'), + _('Position'), + 'res/conditions/3d_box.svg' ) .addParameter('object', _('3D model'), 'Model3DObject', false) .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) - .markAsSimple() .setHidden() - .setFunctionName('setAnimationIndex') - .setGetter('getAnimationIndex'); + .setFunctionName('setZ') + .setGetter('getZ'); // Deprecated object .addExpressionAndConditionAndAction( - 'string', - 'AnimationName', - _('Animation (by name)'), - _('the animation played by the object'), - _('the animation'), - _('Animations and images'), - 'res/actions/animation24.png' + 'number', + 'Depth', + _('Depth (size on Z axis)'), + _('the depth (size on Z axis)'), + _('the depth'), + _('Size'), + 'res/conditions/3d_box.svg' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters( - 'objectAnimationName', - gd.ParameterOptions.makeNewOptions().setDescription( - _('Animation name') - ) - ) - .markAsAdvanced() + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) .setHidden() - .setFunctionName('setAnimationName') - .setGetter('getAnimationName'); + .setFunctionName('setDepth') + .setGetter('getDepth'); // Deprecated object - .addAction( - 'PauseAnimation', - _('Pause the animation'), - _('Pause the animation of the object'), - _('Pause the animation of _PARAM0_'), - _('Animations and images'), - 'res/actions/animation24.png', - 'res/actions/animation.png' + .addScopedAction( + 'SetWidth', + _('Width'), + _('Change the width of an object.'), + _('the width'), + _('Size'), + 'res/actions/scaleWidth24_black.png', + 'res/actions/scaleWidth_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .markAsSimple() + .useStandardOperatorParameters( + 'number', + gd.ParameterOptions.makeNewOptions() + ) .setHidden() - .setFunctionName('pauseAnimation'); + .markAsAdvanced() + .setFunctionName('setWidth') + .setGetter('getWidth'); // Deprecated object - .addAction( - 'ResumeAnimation', - _('Resume the animation'), - _('Resume the animation of the object'), - _('Resume the animation of _PARAM0_'), - _('Animations and images'), - 'res/actions/animation24.png', - 'res/actions/animation.png' + .addScopedCondition( + 'Width', + _('Width'), + _('Compare the width of an object.'), + _('the width'), + _('Size'), + 'res/actions/scaleWidth24_black.png', + 'res/actions/scaleWidth_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .markAsSimple() + .useStandardRelationalOperatorParameters( + 'number', + gd.ParameterOptions.makeNewOptions() + ) .setHidden() - .setFunctionName('resumeAnimation'); + .markAsAdvanced() + .setFunctionName('getWidth'); // Deprecated object - .addExpressionAndConditionAndAction( - 'number', - 'AnimationSpeedScale', - _('Animation speed scale'), - _( - 'the animation speed scale (1 = the default speed, >1 = faster and <1 = slower)' - ), - _('the animation speed scale'), - _('Animations and images'), - 'res/actions/animation24.png' + .addScopedAction( + 'SetHeight', + _('Height'), + _('Change the height of an object.'), + _('the height'), + _('Size'), + 'res/actions/scaleHeight24_black.png', + 'res/actions/scaleHeight_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .useStandardParameters( + .useStandardOperatorParameters( 'number', - gd.ParameterOptions.makeNewOptions().setDescription(_('Speed scale')) + gd.ParameterOptions.makeNewOptions() ) - .markAsSimple() .setHidden() - .setFunctionName('setAnimationSpeedScale') - .setGetter('getAnimationSpeedScale'); + .markAsAdvanced() + .setFunctionName('setHeight') + .setGetter('getHeight'); // Deprecated object - .addCondition( - 'IsAnimationPaused', - _('Animation paused'), - _('Check if the animation of an object is paused.'), - _('The animation of _PARAM0_ is paused'), - _('Animations and images'), - 'res/conditions/animation24.png', - 'res/conditions/animation.png' + .addScopedCondition( + 'Height', + _('Height'), + _('Compare the height of an object.'), + _('the height'), + _('Size'), + 'res/actions/scaleHeight24_black.png', + 'res/actions/scaleHeight_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .markAsSimple() + .useStandardRelationalOperatorParameters( + 'number', + gd.ParameterOptions.makeNewOptions() + ) .setHidden() - .setFunctionName('isAnimationPaused'); + .markAsAdvanced() + .setFunctionName('getHeight'); // Deprecated object - .addCondition( - 'HasAnimationEnded', - _('Animation finished'), - _( - 'Check if the animation being played by the Sprite object is finished.' - ), - _('The animation of _PARAM0_ is finished'), - _('Animations and images'), - 'res/conditions/animation24.png', - 'res/conditions/animation.png' + .addExpressionAndConditionAndAction( + 'number', + 'Height', + _('Height'), + _('the height'), + _('the height'), + _('Size'), + 'res/actions/scaleHeight24_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .markAsSimple() + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) .setHidden() - .setFunctionName('hasAnimationEnded'); + .setFunctionName('setHeight') + .setGetter('getHeight'); + // Deprecated object .addScopedAction( - 'SetCrossfadeDuration', - _('Set crossfade duration'), - _('Set the crossfade duration when switching to a new animation.'), - _('Set crossfade duration of _PARAM0_ to _PARAM1_ seconds'), - _('Animations and images'), - 'res/conditions/animation24.png', - 'res/conditions/animation.png' + 'Scale', + _('Scale'), + _('Modify the scale of the specified object.'), + _('the scale'), + _('Size'), + 'res/actions/scale24_black.png', + 'res/actions/scale_black.png' ) .addParameter('object', _('3D model'), 'Model3DObject', false) - .addParameter('number', _('Crossfade duration (in seconds)'), '', false) - .setFunctionName('setCrossfadeDuration'); - } + .useStandardOperatorParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Scale (1 by default)') + ) + ) + .setHidden() + .markAsAdvanced() + .setFunctionName('setScale') + .setGetter('getScale'); - const Cube3DObject = new gd.ObjectJsImplementation(); - Cube3DObject.updateProperty = function (propertyName, newValue) { - const objectContent = this.content; - if ( - propertyName === 'width' || - propertyName === 'height' || - propertyName === 'depth' - ) { - objectContent[propertyName] = parseFloat(newValue); - return true; - } - if (propertyName === 'facesOrientation') { - const normalizedValue = newValue.toUpperCase(); - if (normalizedValue === 'Y' || normalizedValue === 'Z') { - objectContent.facesOrientation = normalizedValue; - return true; - } - return false; - } - if (propertyName === 'backFaceUpThroughWhichAxisRotation') { - const normalizedValue = newValue.toUpperCase(); - if (normalizedValue === 'X' || normalizedValue === 'Y') { - objectContent.backFaceUpThroughWhichAxisRotation = normalizedValue; - return true; - } - return false; - } - if (propertyName === 'materialType') { - const normalizedValue = newValue.toLowerCase(); - if (normalizedValue === 'basic') { - objectContent.materialType = 'Basic'; - return true; - } + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'ScaleX', + _('Scale on X axis'), + _("the width's scale of an object"), + _("the width's scale"), + _('Size'), + 'res/actions/scaleWidth24_black.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Scale (1 by default)') + ) + ) + .setHidden() + .markAsAdvanced() + .setFunctionName('setScaleX') + .setGetter('getScaleX'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'ScaleY', + _('Scale on Y axis'), + _("the height's scale of an object"), + _("the height's scale"), + _('Size'), + 'res/actions/scaleHeight24_black.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Scale (1 by default)') + ) + ) + .setHidden() + .markAsAdvanced() + .setFunctionName('setScaleY') + .setGetter('getScaleY'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'ScaleZ', + _('Scale on Z axis'), + _("the depth's scale of an object"), + _("the depth's scale"), + _('Size'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Scale (1 by default)') + ) + ) + .markAsAdvanced() + .setHidden() + .setFunctionName('setScaleZ') + .setGetter('getScaleZ'); + + // Deprecated + object + .addScopedAction( + 'FlipX', + _('Flip the object horizontally'), + _('Flip the object horizontally'), + _('Flip horizontally _PARAM0_: _PARAM1_'), + _('Effects'), + 'res/actions/flipX24.png', + 'res/actions/flipX.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('yesorno', _('Activate flipping')) + .setHidden() + .markAsSimple() + .setFunctionName('flipX'); + + // Deprecated + object + .addScopedAction( + 'FlipY', + _('Flip the object vertically'), + _('Flip the object vertically'), + _('Flip vertically _PARAM0_: _PARAM1_'), + _('Effects'), + 'res/actions/flipY24.png', + 'res/actions/flipY.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('yesorno', _('Activate flipping')) + .setHidden() + .markAsSimple() + .setFunctionName('flipY'); + + // Deprecated + object + .addScopedAction( + 'FlipZ', + _('Flip the object on Z'), + _('Flip the object on Z axis'), + _('Flip on Z axis _PARAM0_: _PARAM1_'), + _('Effects'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('yesorno', _('Activate flipping')) + .markAsSimple() + .setHidden() + .setFunctionName('flipZ'); + + // Deprecated + object + .addScopedCondition( + 'FlippedX', + _('Horizontally flipped'), + _('Check if the object is horizontally flipped'), + _('_PARAM0_ is horizontally flipped'), + _('Effects'), + 'res/actions/flipX24.png', + 'res/actions/flipX.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .setHidden() + .setFunctionName('isFlippedX'); + + // Deprecated + object + .addScopedCondition( + 'FlippedY', + _('Vertically flipped'), + _('Check if the object is vertically flipped'), + _('_PARAM0_ is vertically flipped'), + _('Effects'), + 'res/actions/flipY24.png', + 'res/actions/flipY.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .setHidden() + .setFunctionName('isFlippedY'); + + // Deprecated + object + .addScopedCondition( + 'FlippedZ', + _('Flipped on Z'), + _('Check if the object is flipped on Z axis'), + _('_PARAM0_ is flipped on Z axis'), + _('Effects'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .setHidden() + .setFunctionName('isFlippedZ'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'RotationX', + _('Rotation on X axis'), + _('the rotation on X axis'), + _('the rotation on X axis'), + _('Angle'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Angle (in degrees)') + ) + ) + .setHidden() + .setFunctionName('setRotationX') + .setGetter('getRotationX'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'RotationY', + _('Rotation on Y axis'), + _('the rotation on Y axis'), + _('the rotation on Y axis'), + _('Angle'), + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Angle (in degrees)') + ) + ) + .setHidden() + .setFunctionName('setRotationY') + .setGetter('getRotationY'); + + // Deprecated + object + .addScopedAction( + 'TurnAroundX', + _('Turn around X axis'), + _( + "Turn the object around X axis. This axis doesn't move with the object rotation." + ), + _('Turn _PARAM0_ from _PARAM1_° around X axis'), + _('Angle'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('number', _('Angle to add (in degrees)'), '', false) + .markAsAdvanced() + .setHidden() + .setFunctionName('turnAroundX'); + + // Deprecated + object + .addScopedAction( + 'TurnAroundY', + _('Turn around Y axis'), + _( + "Turn the object around Y axis. This axis doesn't move with the object rotation." + ), + _('Turn _PARAM0_ from _PARAM1_° around Y axis'), + _('Angle'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('number', _('Angle to add (in degrees)'), '', false) + .markAsAdvanced() + .setHidden() + .setFunctionName('turnAroundY'); + + // Deprecated + object + .addScopedAction( + 'TurnAroundZ', + _('Turn around Z axis'), + _( + "Turn the object around Z axis. This axis doesn't move with the object rotation." + ), + _('Turn _PARAM0_ from _PARAM1_° around Z axis'), + _('Angle'), + 'res/conditions/3d_box.svg', + 'res/conditions/3d_box.svg' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('number', _('Angle to add (in degrees)'), '', false) + .markAsAdvanced() + .setHidden() + .setFunctionName('turnAroundZ'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'Animation', + _('Animation (by number)'), + _( + 'the number of the animation played by the object (the number from the animations list)' + ), + _('the number of the animation'), + _('Animations and images'), + 'res/actions/animation24.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters('number', gd.ParameterOptions.makeNewOptions()) + .markAsSimple() + .setHidden() + .setFunctionName('setAnimationIndex') + .setGetter('getAnimationIndex'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'string', + 'AnimationName', + _('Animation (by name)'), + _('the animation played by the object'), + _('the animation'), + _('Animations and images'), + 'res/actions/animation24.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'objectAnimationName', + gd.ParameterOptions.makeNewOptions().setDescription( + _('Animation name') + ) + ) + .markAsAdvanced() + .setHidden() + .setFunctionName('setAnimationName') + .setGetter('getAnimationName'); + + // Deprecated + object + .addAction( + 'PauseAnimation', + _('Pause the animation'), + _('Pause the animation of the object'), + _('Pause the animation of _PARAM0_'), + _('Animations and images'), + 'res/actions/animation24.png', + 'res/actions/animation.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .markAsSimple() + .setHidden() + .setFunctionName('pauseAnimation'); + + // Deprecated + object + .addAction( + 'ResumeAnimation', + _('Resume the animation'), + _('Resume the animation of the object'), + _('Resume the animation of _PARAM0_'), + _('Animations and images'), + 'res/actions/animation24.png', + 'res/actions/animation.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .markAsSimple() + .setHidden() + .setFunctionName('resumeAnimation'); + + // Deprecated + object + .addExpressionAndConditionAndAction( + 'number', + 'AnimationSpeedScale', + _('Animation speed scale'), + _( + 'the animation speed scale (1 = the default speed, >1 = faster and <1 = slower)' + ), + _('the animation speed scale'), + _('Animations and images'), + 'res/actions/animation24.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .useStandardParameters( + 'number', + gd.ParameterOptions.makeNewOptions().setDescription(_('Speed scale')) + ) + .markAsSimple() + .setHidden() + .setFunctionName('setAnimationSpeedScale') + .setGetter('getAnimationSpeedScale'); + + // Deprecated + object + .addCondition( + 'IsAnimationPaused', + _('Animation paused'), + _('Check if the animation of an object is paused.'), + _('The animation of _PARAM0_ is paused'), + _('Animations and images'), + 'res/conditions/animation24.png', + 'res/conditions/animation.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .markAsSimple() + .setHidden() + .setFunctionName('isAnimationPaused'); + + // Deprecated + object + .addCondition( + 'HasAnimationEnded', + _('Animation finished'), + _( + 'Check if the animation being played by the Sprite object is finished.' + ), + _('The animation of _PARAM0_ is finished'), + _('Animations and images'), + 'res/conditions/animation24.png', + 'res/conditions/animation.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .markAsSimple() + .setHidden() + .setFunctionName('hasAnimationEnded'); + + object + .addScopedAction( + 'SetCrossfadeDuration', + _('Set crossfade duration'), + _('Set the crossfade duration when switching to a new animation.'), + _('Set crossfade duration of _PARAM0_ to _PARAM1_ seconds'), + _('Animations and images'), + 'res/conditions/animation24.png', + 'res/conditions/animation.png' + ) + .addParameter('object', _('3D model'), 'Model3DObject', false) + .addParameter('number', _('Crossfade duration (in seconds)'), '', false) + .setFunctionName('setCrossfadeDuration'); + } + + const Cube3DObject = new gd.ObjectJsImplementation(); + Cube3DObject.updateProperty = function (propertyName, newValue) { + const objectContent = this.content; + if ( + propertyName === 'width' || + propertyName === 'height' || + propertyName === 'depth' + ) { + objectContent[propertyName] = parseFloat(newValue); + return true; + } + if (propertyName === 'facesOrientation') { + const normalizedValue = newValue.toUpperCase(); + if (normalizedValue === 'Y' || normalizedValue === 'Z') { + objectContent.facesOrientation = normalizedValue; + return true; + } + return false; + } + if (propertyName === 'backFaceUpThroughWhichAxisRotation') { + const normalizedValue = newValue.toUpperCase(); + if (normalizedValue === 'X' || normalizedValue === 'Y') { + objectContent.backFaceUpThroughWhichAxisRotation = normalizedValue; + return true; + } + return false; + } + if (propertyName === 'materialType') { + const normalizedValue = newValue.toLowerCase(); + if (normalizedValue === 'basic') { + objectContent.materialType = 'Basic'; + return true; + } if (normalizedValue === 'standardwithoutmetalness') { objectContent.materialType = 'StandardWithoutMetalness'; return true; @@ -1889,66 +2725,413 @@ module.exports = { { const effect = extension - .addEffect('LinearFog') - .setFullName(_('Fog (linear)')) - .setDescription(_('Linear fog for 3D objects.')) + .addEffect('LinearFog') + .setFullName(_('Fog (linear)')) + .setDescription(_('Linear fog for 3D objects.')) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/LinearFog.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('color') + .setValue('255;255;255') + .setLabel(_('Fog color')) + .setType('color'); + properties + .getOrCreate('near') + .setValue('200') + .setLabel(_('Distance where the fog starts')) + .setType('number'); + properties + .getOrCreate('far') + .setValue('2000') + .setLabel(_('Distance where the fog is fully opaque')) + .setType('number'); + } + { + const effect = extension + .addEffect('ExponentialFog') + .setFullName(_('Fog (exponential)')) + .setDescription(_('Exponential fog for 3D objects.')) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/ExponentialFog.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('color') + .setValue('255;255;255') + .setLabel(_('Fog color')) + .setType('color'); + properties + .getOrCreate('density') + .setValue('0.0012') + .setLabel(_('Density')) + .setDescription( + _( + 'Density of the fog. Usual values are between 0.0005 (far away) and 0.005 (very thick fog).' + ) + ) + .setType('number'); + } + { + const effect = extension + .addEffect('AmbientLight') + .setFullName(_('Ambient light')) + .setDescription( + _( + 'A light that illuminates all objects from every direction. Often used along with a Directional light (though a Hemisphere light can be used instead of an Ambient light).' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/AmbientLight.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('color') + .setValue('255;255;255') + .setLabel(_('Light color')) + .setType('color'); + properties + .getOrCreate('intensity') + .setValue('0.25') + .setLabel(_('Intensity')) + .setType('number'); + } + { + const effect = extension + .addEffect('DirectionalLight') + .setFullName(_('Directional light')) + .setDescription( + _( + "A very far light source like the sun. This is the light to use for casting shadows for 3D objects (other lights won't emit shadows). Often used along with a Hemisphere light." + ) + ) .markAsNotWorkingForObjects() .markAsOnlyWorkingFor3D() - .addIncludeFile('Extensions/3D/LinearFog.js'); + .addIncludeFile('Extensions/3D/DirectionalLight.js'); const properties = effect.getProperties(); properties .getOrCreate('color') .setValue('255;255;255') - .setLabel(_('Fog color')) + .setLabel(_('Light color')) .setType('color'); properties - .getOrCreate('near') - .setValue('200') - .setLabel(_('Distance where the fog starts')) + .getOrCreate('intensity') + .setValue('0.5') + .setLabel(_('Intensity')) .setType('number'); properties - .getOrCreate('far') + .getOrCreate('top') + .setValue('Z+') + .setLabel(_('3D world top')) + .setType('choice') + .addExtraInfo('Z+') + .addExtraInfo('Y-') + .setGroup(_('Orientation')); + properties + .getOrCreate('elevation') + .setValue('45') + .setLabel(_('Elevation')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) + .setGroup(_('Orientation')) + .setDescription(_('Maximal elevation is reached at 90°.')); + properties + .getOrCreate('rotation') + .setValue('0') + .setLabel(_('Rotation')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) + .setGroup(_('Orientation')); + properties + .getOrCreate('isCastingShadow') + .setValue('false') + .setLabel(_('Shadow casting')) + .setType('boolean') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowQuality') + .setValue('medium') + .addChoice('low', _('Low quality')) + .addChoice('medium', _('Medium quality')) + .addChoice('high', _('High quality')) + .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') + .setLabel(_('Shadow bias')) + .setDescription( + _( + 'Use this to avoid "shadow acne" due to depth buffer precision. Choose a value small enough like 0.001 to avoid creating distance between shadows and objects but not too small to avoid shadow glitches on low/medium quality. This value is used for high quality, and multiplied by 1.25 for medium quality and 2 for low quality.' + ) + ) + .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') + .setLabel(_('Shadow frustum size')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Shadows')) + .setAdvanced(true); + properties + .getOrCreate('maxShadowDistance') .setValue('2000') - .setLabel(_('Distance where the fog is fully opaque')) - .setType('number'); + .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') + .setLabel(_("Distance from layer's camera")) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Shadows')) + .setAdvanced(true); } { const effect = extension - .addEffect('ExponentialFog') - .setFullName(_('Fog (exponential)')) - .setDescription(_('Exponential fog for 3D objects.')) + .addEffect('RimLight') + .setFullName(_('Rim light')) + .setDescription( + _( + 'Injects Fresnel-based rim lighting directly into 3D mesh materials via shader compilation. Rim direction is updated every frame from the active camera position.' + ) + ) .markAsNotWorkingForObjects() .markAsOnlyWorkingFor3D() - .addIncludeFile('Extensions/3D/ExponentialFog.js'); + .addIncludeFile('Extensions/3D/RimLight.js'); const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); properties .getOrCreate('color') .setValue('255;255;255') - .setLabel(_('Fog color')) + .setLabel(_('Rim color')) .setType('color'); properties - .getOrCreate('density') - .setValue('0.0012') - .setLabel(_('Density')) + .getOrCreate('intensity') + .setValue('0.8') + .setLabel(_('Intensity')) + .setDescription(_('Strength of the rim contribution near silhouettes.')) + .setType('number'); + properties + .getOrCreate('outerWrap') + .setValue('0.18') + .setLabel(_('Outer wrap')) .setDescription( _( - 'Density of the fog. Usual values are between 0.0005 (far away) and 0.005 (very thick fog).' + 'Ambient wrap amount for the 45 to 90 degree rim zone away from silhouette.' ) ) - .setType('number'); + .setType('number') + .setAdvanced(true); + properties + .getOrCreate('power') + .setValue('2.2') + .setLabel(_('Rim power')) + .setDescription( + _( + 'Controls rim falloff near silhouette. Higher values make a tighter, sharper rim.' + ) + ) + .setType('number') + .setAdvanced(true); + properties + .getOrCreate('fresnel0') + .setValue('0.04') + .setLabel(_('Fresnel F0')) + .setDescription( + _( + 'Base reflectance used by Schlick Fresnel. Typical non-metal values are around 0.02 to 0.08.' + ) + ) + .setType('number') + .setAdvanced(true); + properties + .getOrCreate('debugForceMaxRim') + .setValue('false') + .setLabel(_('Debug: force max rim')) + .setDescription( + _( + 'For debugging shader injection: force full rim contribution on patched materials regardless of view angle.' + ) + ) + .setType('boolean') + .setGroup(_('Debug')) + .setAdvanced(true); } { const effect = extension - .addEffect('AmbientLight') - .setFullName(_('Ambient light')) + .addEffect('HemisphereLight') + .setFullName(_('Hemisphere light')) + .setDescription( + _( + 'A light that illuminates objects from every direction with a gradient. Often used along with a Directional light.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/HemisphereLight.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('skyColor') + .setValue('255;255;255') + .setLabel(_('Sky color')) + .setType('color'); + properties + .getOrCreate('groundColor') + .setValue('127;127;127') + .setLabel(_('Ground color')) + .setType('color'); + properties + .getOrCreate('intensity') + .setValue('0.35') + .setLabel(_('Intensity')) + .setType('number'); + properties + .getOrCreate('top') + .setValue('Z+') + .setLabel(_('3D world top')) + .setType('choice') + .addExtraInfo('Z+') + .addExtraInfo('Y-') + .setGroup(_('Orientation')); + properties + .getOrCreate('elevation') + .setValue('90') + .setLabel(_('Elevation')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) + .setGroup(_('Orientation')) + .setDescription(_('Maximal elevation is reached at 90°.')); + properties + .getOrCreate('rotation') + .setValue('0') + .setLabel(_('Rotation')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) + .setGroup(_('Orientation')); + } + { + const effect = extension + .addEffect('PointLight') + .setFullName(_('Point light')) .setDescription( _( - 'A light that illuminates all objects from every direction. Often used along with a Directional light (though a Hemisphere light can be used instead of an Ambient light).' + 'A light that emits in all directions from a position, like a light bulb. Can cast shadows.' ) ) .markAsNotWorkingForObjects() .markAsOnlyWorkingFor3D() - .addIncludeFile('Extensions/3D/AmbientLight.js'); + .addIncludeFile('Extensions/3D/PointLight.js'); const properties = effect.getProperties(); properties .getOrCreate('color') @@ -1957,22 +3140,171 @@ module.exports = { .setType('color'); properties .getOrCreate('intensity') - .setValue('0.75') + .setValue('1') .setLabel(_('Intensity')) .setType('number'); + properties + .getOrCreate('top') + .setValue('Z+') + .setLabel(_('3D world top')) + .setType('choice') + .addExtraInfo('Z+') + .addExtraInfo('Y-') + .setGroup(_('Position')); + properties + .getOrCreate('positionX') + .setValue('0') + .setLabel(_('X position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Position')); + properties + .getOrCreate('positionY') + .setValue('0') + .setLabel(_('Y position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Position')); + properties + .getOrCreate('positionZ') + .setValue('500') + .setLabel(_('Z position (height)')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Position')); + properties + .getOrCreate('attachedObject') + .setValue('') + .setLabel(_('Attached object name')) + .setDescription( + _( + 'Object name to follow. Leave empty to use the manual position values.' + ) + ) + .setType('string') + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetX') + .setValue('0') + .setLabel(_('Attached offset X')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetY') + .setValue('0') + .setLabel(_('Attached offset Y')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetZ') + .setValue('0') + .setLabel(_('Attached offset Z')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('rotateOffsetsWithObjectAngle') + .setValue('false') + .setLabel(_('Rotate offsets with object angle')) + .setDescription( + _( + 'Rotate X/Y offsets using the attached object angle, useful for placing the light in a hand.' + ) + ) + .setType('boolean') + .setGroup(_('Attachment')); + properties + .getOrCreate('distance') + .setValue('0') + .setLabel(_('Maximum distance')) + .setDescription( + _('Maximum range of the light. 0 means unlimited range.') + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attenuation')); + properties + .getOrCreate('decay') + .setValue('2') + .setLabel(_('Decay')) + .setDescription( + _( + 'How quickly the light dims with distance. 2 is physically correct. 0 means no decay.' + ) + ) + .setType('number') + .setGroup(_('Attenuation')); + properties + .getOrCreate('isCastingShadow') + .setValue('false') + .setLabel(_('Shadow casting')) + .setType('boolean') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowQuality') + .setValue('medium') + .addChoice('low', _('Low quality')) + .addChoice('medium', _('Medium quality')) + .addChoice('high', _('High quality')) + .setLabel(_('Shadow quality')) + .setType('choice') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowBias') + .setValue('0.001') + .setLabel(_('Shadow bias')) + .setDescription( + _('Small offset to prevent shadow artifacts (acne). Default: 0.001.') + ) + .setType('number') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowNormalBias') + .setValue('0.02') + .setLabel(_('Shadow normal bias')) + .setDescription( + _( + 'Offset along object normals to prevent acne on curved surfaces. Default: 0.02.' + ) + ) + .setType('number') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowRadius') + .setValue('1.5') + .setLabel(_('Shadow softness')) + .setDescription(_('Softness radius for point-light shadow filtering.')) + .setType('number') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowNear') + .setValue('1') + .setLabel(_('Shadow near')) + .setDescription(_('Minimum distance for shadows to be cast.')) + .setType('number') + .setGroup(_('Shadows')); + properties + .getOrCreate('shadowFar') + .setValue('10000') + .setLabel(_('Shadow far')) + .setDescription(_('Maximum distance for shadows to be cast.')) + .setType('number') + .setGroup(_('Shadows')); } { const effect = extension - .addEffect('DirectionalLight') - .setFullName(_('Directional light')) + .addEffect('SpotLight') + .setFullName(_('Spot light')) .setDescription( _( - "A very far light source like the sun. This is the light to use for casting shadows for 3D objects (other lights won't emit shadows). Often used along with a Hemisphere light." + 'A light that emits a cone-shaped beam from a position toward a target, like a flashlight or a stage spotlight. Can cast shadows.' ) ) .markAsNotWorkingForObjects() .markAsOnlyWorkingFor3D() - .addIncludeFile('Extensions/3D/DirectionalLight.js'); + .addIncludeFile('Extensions/3D/SpotLight.js'); const properties = effect.getProperties(); properties .getOrCreate('color') @@ -1981,7 +3313,7 @@ module.exports = { .setType('color'); properties .getOrCreate('intensity') - .setValue('0.5') + .setValue('1') .setLabel(_('Intensity')) .setType('number'); properties @@ -1991,22 +3323,221 @@ module.exports = { .setType('choice') .addExtraInfo('Z+') .addExtraInfo('Y-') - .setGroup(_('Orientation')); + .setGroup(_('Light position')); properties - .getOrCreate('elevation') + .getOrCreate('positionX') + .setValue('0') + .setLabel(_('X position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Light position')); + properties + .getOrCreate('positionY') + .setValue('0') + .setLabel(_('Y position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Light position')); + properties + .getOrCreate('positionZ') + .setValue('500') + .setLabel(_('Z position (height)')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Light position')); + properties + .getOrCreate('attachedObject') + .setValue('') + .setLabel(_('Attached object name')) + .setDescription( + _( + 'Object name to follow for the light position. Leave empty to use manual values.' + ) + ) + .setType('string') + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetX') + .setValue('0') + .setLabel(_('Attached offset X')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetY') + .setValue('0') + .setLabel(_('Attached offset Y')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('attachedOffsetZ') + .setValue('0') + .setLabel(_('Attached offset Z')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attachment')); + properties + .getOrCreate('targetX') + .setValue('0') + .setLabel(_('Target X position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target position')); + properties + .getOrCreate('targetY') + .setValue('0') + .setLabel(_('Target Y position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target position')); + properties + .getOrCreate('targetZ') + .setValue('0') + .setLabel(_('Target Z position')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target position')); + properties + .getOrCreate('targetAttachedObject') + .setValue('') + .setLabel(_('Target attached object name')) + .setDescription( + _( + 'Object name to follow for the target position. Leave empty to use manual target values.' + ) + ) + .setType('string') + .setGroup(_('Target attachment')); + properties + .getOrCreate('targetAttachedOffsetX') + .setValue('0') + .setLabel(_('Target attached offset X')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target attachment')); + properties + .getOrCreate('targetAttachedOffsetY') + .setValue('0') + .setLabel(_('Target attached offset Y')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target attachment')); + properties + .getOrCreate('targetAttachedOffsetZ') + .setValue('0') + .setLabel(_('Target attached offset Z')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Target attachment')); + properties + .getOrCreate('rotateOffsetsWithObjectAngle') + .setValue('false') + .setLabel(_('Rotate offsets with object angle')) + .setDescription( + _( + 'Rotate X/Y offsets using the attached object angle, useful for flashlight-like behavior.' + ) + ) + .setType('boolean') + .setGroup(_('Attachment')); + properties + .getOrCreate('physicsBounceEnabled') + .setValue('false') + .setLabel(_('Physics bounce (Jolt)')) + .setDescription( + _( + 'Enable one-bounce reflected light using a raycast on Physics3D/Jolt bodies.' + ) + ) + .setType('boolean') + .setGroup(_('Physics bounce')); + properties + .getOrCreate('physicsBounceIntensityScale') + .setValue('0.35') + .setLabel(_('Bounce intensity scale')) + .setDescription( + _( + 'Intensity multiplier for the bounced light (0 disables bounced intensity).' + ) + ) + .setType('number') + .setGroup(_('Physics bounce')); + properties + .getOrCreate('physicsBounceDistance') + .setValue('600') + .setLabel(_('Bounce distance')) + .setDescription( + _('Maximum distance of the bounced light beam (in pixels).') + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Physics bounce')); + properties + .getOrCreate('physicsBounceOriginOffset') + .setValue('2') + .setLabel(_('Bounce origin offset')) + .setDescription( + _( + 'Small offset from the hit point along the surface normal to avoid self-intersection artifacts.' + ) + ) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Physics bounce')); + properties + .getOrCreate('physicsBounceCastShadow') + .setValue('false') + .setLabel(_('Bounce casts shadows')) + .setDescription( + _('Enable shadows for the bounced light (higher performance cost).') + ) + .setType('boolean') + .setGroup(_('Physics bounce')); + properties + .getOrCreate('angle') .setValue('45') - .setLabel(_('Elevation')) + .setLabel(_('Cone angle')) + .setDescription( + _( + 'Maximum angle of the light cone in degrees. A smaller value creates a narrower beam.' + ) + ) .setType('number') .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) - .setGroup(_('Orientation')) - .setDescription(_('Maximal elevation is reached at 90°.')); + .setGroup(_('Cone')); properties - .getOrCreate('rotation') + .getOrCreate('penumbra') + .setValue('0.1') + .setLabel(_('Penumbra')) + .setDescription( + _( + 'Percentage of the cone that is attenuated due to penumbra. 0 means sharp edges, 1 means fully soft edges.' + ) + ) + .setType('number') + .setGroup(_('Cone')); + properties + .getOrCreate('distance') .setValue('0') - .setLabel(_('Rotation')) + .setLabel(_('Maximum distance')) + .setDescription( + _('Maximum range of the light. 0 means unlimited range.') + ) .setType('number') - .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) - .setGroup(_('Orientation')); + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setGroup(_('Attenuation')); + properties + .getOrCreate('decay') + .setValue('2') + .setLabel(_('Decay')) + .setDescription( + _( + 'How quickly the light dims with distance. 2 is physically correct. 0 means no decay.' + ) + ) + .setType('number') + .setGroup(_('Attenuation')); properties .getOrCreate('isCastingShadow') .setValue('false') @@ -2023,85 +3554,44 @@ module.exports = { .setType('choice') .setGroup(_('Shadows')); properties - .getOrCreate('minimumShadowBias') - .setValue('0') + .getOrCreate('shadowBias') + .setValue('0.001') .setLabel(_('Shadow bias')) .setDescription( - _( - 'Use this to avoid "shadow acne" due to depth buffer precision. Choose a value small enough like 0.001 to avoid creating distance between shadows and objects but not too small to avoid shadow glitches on low/medium quality. This value is used for high quality, and multiplied by 1.25 for medium quality and 2 for low quality.' - ) - ) - .setType('number') - .setGroup(_('Shadows')) - .setAdvanced(true); - properties - .getOrCreate('frustumSize') - .setValue('4000') - .setLabel(_('Shadow frustum size')) - .setType('number') - .setMeasurementUnit(gd.MeasurementUnit.getPixel()) - .setGroup(_('Shadows')) - .setAdvanced(true); - properties - .getOrCreate('distanceFromCamera') - .setValue('1500') - .setLabel(_("Distance from layer's camera")) - .setType('number') - .setMeasurementUnit(gd.MeasurementUnit.getPixel()) - .setGroup(_('Shadows')) - .setAdvanced(true); - } - { - const effect = extension - .addEffect('HemisphereLight') - .setFullName(_('Hemisphere light')) - .setDescription( - _( - 'A light that illuminates objects from every direction with a gradient. Often used along with a Directional light.' - ) + _('Small offset to prevent shadow artifacts (acne). Default: 0.001.') ) - .markAsNotWorkingForObjects() - .markAsOnlyWorkingFor3D() - .addIncludeFile('Extensions/3D/HemisphereLight.js'); - const properties = effect.getProperties(); - properties - .getOrCreate('skyColor') - .setValue('255;255;255') - .setLabel(_('Sky color')) - .setType('color'); - properties - .getOrCreate('groundColor') - .setValue('127;127;127') - .setLabel(_('Ground color')) - .setType('color'); + .setType('number') + .setGroup(_('Shadows')); properties - .getOrCreate('intensity') - .setValue('0.5') - .setLabel(_('Intensity')) - .setType('number'); + .getOrCreate('shadowNormalBias') + .setValue('0.02') + .setLabel(_('Shadow normal bias')) + .setDescription( + _('Offset along normals to reduce acne on curved surfaces.') + ) + .setType('number') + .setGroup(_('Shadows')); properties - .getOrCreate('top') - .setValue('Z+') - .setLabel(_('3D world top')) - .setType('choice') - .addExtraInfo('Z+') - .addExtraInfo('Y-') - .setGroup(_('Orientation')); + .getOrCreate('shadowRadius') + .setValue('1.5') + .setLabel(_('Shadow softness')) + .setDescription(_('Softness radius for spot-light shadow filtering.')) + .setType('number') + .setGroup(_('Shadows')); properties - .getOrCreate('elevation') - .setValue('90') - .setLabel(_('Elevation')) + .getOrCreate('shadowNear') + .setValue('1') + .setLabel(_('Shadow near')) + .setDescription(_('Minimum distance for shadows to be cast.')) .setType('number') - .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) - .setGroup(_('Orientation')) - .setDescription(_('Maximal elevation is reached at 90°.')); + .setGroup(_('Shadows')); properties - .getOrCreate('rotation') - .setValue('0') - .setLabel(_('Rotation')) + .getOrCreate('shadowFar') + .setValue('10000') + .setLabel(_('Shadow far')) + .setDescription(_('Maximum distance for shadows to be cast.')) .setType('number') - .setMeasurementUnit(gd.MeasurementUnit.getDegreeAngle()) - .setGroup(_('Orientation')); + .setGroup(_('Shadows')); } { const effect = extension @@ -2144,6 +3634,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 @@ -2184,6 +3684,71 @@ module.exports = { .setType('number') .setDescription(_('Positive value')); } + { + const effect = extension + .addEffect('ToneMapping') + .setFullName(_('Tone mapping')) + .setDescription( + _( + 'Configure renderer tone mapping for a cinematic and physically based 3D look.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/ToneMappingEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('mode') + .setValue('ACESFilmic') + .addChoice('ACESFilmic', _('ACES Filmic')) + .addChoice('Reinhard', _('Reinhard')) + .addChoice('Cineon', _('Cineon')) + .addChoice('Linear', _('Linear')) + .setLabel(_('Mode')) + .setType('choice') + .setDescription( + _( + 'ACESFilmic for cinematic look, Reinhard for softer highlights, Cineon for film look, Linear for no tone mapping.' + ) + ); + properties + .getOrCreate('exposure') + .setValue('1.0') + .setLabel(_('Exposure')) + .setType('number') + .setDescription(_('Brightness multiplier applied by tone mapping.')); + } + { + const effect = extension + .addEffect('PostProcessingStack') + .setFullName(_('Post-processing stack')) + .setDescription( + _( + 'Master controller for 3D post-processing: captures scene/depth once, auto-orders effects, and applies shared quality.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/PostProcessingStackEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); + } { const effect = extension .addEffect('Bloom') @@ -2191,6 +3756,7 @@ module.exports = { .setDescription(_('Apply a bloom effect.')) .markAsNotWorkingForObjects() .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') .addIncludeFile('Extensions/3D/BloomEffect.js'); const properties = effect.getProperties(); properties @@ -2211,6 +3777,312 @@ module.exports = { .setLabel(_('Threshold')) .setType('number') .setDescription(_('Between 0 and 1')); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); + } + { + const effect = extension + .addEffect('ScreenSpaceReflections') + .setFullName(_('Screen-space reflections')) + .setDescription( + _( + 'Render approximate screen-space reflections for visible surfaces in 3D.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/ScreenSpaceReflectionsEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('intensity') + .setValue('0.75') + .setLabel(_('Intensity')) + .setType('number') + .setDescription(_('Overall strength of reflected light.')); + properties + .getOrCreate('maxDistance') + .setValue('420') + .setLabel(_('Max distance')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription( + _('Maximum reflection tracing distance (balanced for performance).') + ); + properties + .getOrCreate('thickness') + .setValue('4') + .setLabel(_('Thickness')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription( + _('Depth tolerance to detect reflection hits reliably.') + ); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); + } + { + const effect = extension + .addEffect('ChromaticAberration') + .setFullName(_('Chromatic aberration')) + .setDescription( + _( + 'Lens-like RGB channel separation that gets stronger toward screen edges.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/ChromaticAberrationEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('intensity') + .setValue('0.005') + .setLabel(_('Intensity')) + .setType('number') + .setDescription( + _('How far red/blue channels split from the center direction.') + ); + properties + .getOrCreate('radialScale') + .setValue('1.0') + .setLabel(_('Radial scale')) + .setType('number') + .setDescription( + _('How strongly the effect ramps from center to edges.') + ); + } + { + const effect = extension + .addEffect('ColorGrading') + .setFullName(_('Color grading')) + .setDescription( + _( + 'Apply cinematic color grading in screen space: temperature, tint, saturation, contrast, and brightness.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/ColorGradingEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('temperature') + .setValue('-0.3') + .setLabel(_('Temperature')) + .setType('number') + .setDescription( + _( + 'Negative = cool blue, positive = warm orange. Default tuned for cold horror mood.' + ) + ); + properties + .getOrCreate('tint') + .setValue('-0.1') + .setLabel(_('Tint')) + .setType('number') + .setDescription(_('Negative = greener, positive = magenta.')); + properties + .getOrCreate('saturation') + .setValue('0.8') + .setLabel(_('Saturation')) + .setType('number') + .setDescription(_('0 = grayscale, 1 = normal, >1 = oversaturated.')); + properties + .getOrCreate('contrast') + .setValue('1.2') + .setLabel(_('Contrast')) + .setType('number') + .setDescription(_('1 = neutral, >1 = stronger contrast.')); + properties + .getOrCreate('brightness') + .setValue('0.95') + .setLabel(_('Brightness')) + .setType('number') + .setDescription(_('1 = neutral, <1 darker, >1 brighter.')); + } + { + const effect = extension + .addEffect('SSAO') + .setFullName(_('Ambient occlusion (SSAO)')) + .setDescription( + _( + 'Screen-space ambient occlusion that darkens corners, crevices and contact areas using depth.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/SSAOEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('radius') + .setValue('60') + .setLabel(_('Radius')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription(_('Sampling radius in view space.')); + properties + .getOrCreate('intensity') + .setValue('0.9') + .setLabel(_('Intensity')) + .setType('number') + .setDescription(_('How strong occlusion darkening is.')); + properties + .getOrCreate('bias') + .setValue('0.6') + .setLabel(_('Bias')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription(_('Prevents self-occlusion artifacts.')); + properties + .getOrCreate('samples') + .setValue('4') + .setLabel(_('Samples')) + .setType('number') + .setDescription( + _('Quality/performance control (higher = better, slower).') + ); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); + } + { + const effect = extension + .addEffect('VolumetricFog') + .setFullName(_('Volumetric fog')) + .setDescription( + _( + 'Simulate volumetric light scattering by ray-marching fog in screen space around scene lights.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/VolumetricFogEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('fogColor') + .setValue('200;220;255') + .setLabel(_('Fog color')) + .setType('color'); + properties + .getOrCreate('density') + .setValue('0.012') + .setLabel(_('Density')) + .setType('number') + .setDescription(_('Base fog density in the volume.')); + properties + .getOrCreate('lightScatter') + .setValue('1') + .setLabel(_('Light scatter')) + .setType('number') + .setDescription(_('How much fog glows near PointLight and SpotLight.')); + properties + .getOrCreate('maxDistance') + .setValue('1200') + .setLabel(_('Max distance')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription(_('Maximum distance for volumetric ray marching.')); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); + } + { + const effect = extension + .addEffect('DepthOfField') + .setFullName(_('Depth of field')) + .setDescription( + _( + 'Blur pixels based on distance from the focus plane using depth-aware gaussian blur.' + ) + ) + .markAsNotWorkingForObjects() + .markAsOnlyWorkingFor3D() + .addIncludeFile('Extensions/3D/PostProcessingSharedResources.js') + .addIncludeFile('Extensions/3D/DepthOfFieldEffect.js'); + const properties = effect.getProperties(); + properties + .getOrCreate('enabled') + .setValue('true') + .setLabel(_('Enabled')) + .setType('boolean'); + properties + .getOrCreate('focusDistance') + .setValue('400') + .setLabel(_('Focus distance')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription(_('Distance from the camera that remains sharp.')); + properties + .getOrCreate('focusRange') + .setValue('250') + .setLabel(_('Focus range')) + .setType('number') + .setMeasurementUnit(gd.MeasurementUnit.getPixel()) + .setDescription( + _('How gradually blur increases around focus distance.') + ); + properties + .getOrCreate('maxBlur') + .setValue('6') + .setLabel(_('Max blur')) + .setType('number') + .setDescription(_('Maximum blur radius strength.')); + properties + .getOrCreate('samples') + .setValue('4') + .setLabel(_('Samples')) + .setType('number') + .setDescription( + _('Blur taps around each pixel (higher = smoother, slower).') + ); + properties + .getOrCreate('qualityMode') + .setValue('medium') + .setLabel(_('Quality mode')) + .setType('string') + .setDescription(_('Use: low, medium, or high.')); } { const effect = extension diff --git a/Extensions/3D/PBRMaterialBehavior.ts b/Extensions/3D/PBRMaterialBehavior.ts new file mode 100644 index 000000000000..3e8aa997eb90 --- /dev/null +++ b/Extensions/3D/PBRMaterialBehavior.ts @@ -0,0 +1,719 @@ +namespace gdjs { + const pbrManagedMaterialUserDataKey = '__gdScene3dPbrMaterial'; + const pbrMaterialRoughnessUserDataKey = '__gdScene3dPbrRoughness'; + const pbrSceneEnvMapIntensityUserDataKey = '__gdScene3dPbrEnvMapIntensity'; + const pbrMaterialScanIntervalFrames = 15; + + type RuntimeObjectWith3DRenderer = gdjs.RuntimeObject & { + get3DRendererObject?: () => THREE.Object3D | null; + }; + + type PBRManagedMaterial = + | THREE.MeshStandardMaterial + | THREE.MeshPhysicalMaterial; + + interface PBRPatchedMeshState { + originalMaterial: THREE.Material | THREE.Material[]; + patchedMaterial: THREE.Material | THREE.Material[]; + clonedMaterials: PBRManagedMaterial[]; + geometry: THREE.BufferGeometry | null; + hadUv2Attribute: boolean; + originalUv2Attribute: + | THREE.BufferAttribute + | THREE.InterleavedBufferAttribute + | null; + uv2WasPatched: boolean; + } + + interface ScenePBREnvironmentState { + fallbackRenderTarget: THREE.WebGLRenderTarget | null; + pmremGenerator: THREE.PMREMGenerator | null; + lastIntensityUpdateTimeMs: number; + sceneEnvMapIntensity: number; + } + + const pbrEnvironmentStateByScene = new WeakMap< + THREE.Scene, + ScenePBREnvironmentState + >(); + + /** + * @category Behaviors > 3D + */ + export class PBRMaterialRuntimeBehavior extends gdjs.RuntimeBehavior { + private _metalness: number; + private _roughness: number; + private _envMapIntensity: number; + private _emissiveColorHex: number; + private _emissiveIntensity: number; + private _normalScale: number; + private _normalMapAsset: string; + private _normalMapTexture: THREE.Texture | null; + private _normalMapTextureAsset: string; + private _aoMapAsset: string; + private _aoMapIntensity: number; + private _aoMapTexture: THREE.Texture | null; + private _aoMapTextureAsset: string; + private _albedoMapAsset: string; + private _albedoMapTexture: THREE.Texture | null; + private _albedoMapTextureAsset: string; + private _patchedMeshes: Map; + private _materialScanCounter: number; + + constructor( + instanceContainer: gdjs.RuntimeInstanceContainer, + behaviorData, + owner: gdjs.RuntimeObject + ) { + super(instanceContainer, behaviorData, owner); + + this._metalness = this._clamp01( + behaviorData.metalness !== undefined ? behaviorData.metalness : 0 + ); + this._roughness = this._clamp01( + behaviorData.roughness !== undefined ? behaviorData.roughness : 0.5 + ); + this._envMapIntensity = this._clamp( + behaviorData.envMapIntensity !== undefined + ? behaviorData.envMapIntensity + : 1.0, + 0, + 4 + ); + this._emissiveColorHex = gdjs.rgbOrHexStringToNumber( + behaviorData.emissiveColor || '0;0;0' + ); + this._emissiveIntensity = this._clamp( + behaviorData.emissiveIntensity !== undefined + ? behaviorData.emissiveIntensity + : 0, + 0, + 4 + ); + this._normalScale = this._clamp( + behaviorData.normalScale !== undefined ? behaviorData.normalScale : 1, + 0, + 2 + ); + this._normalMapAsset = behaviorData.normalMapAsset || ''; + this._normalMapTexture = null; + this._normalMapTextureAsset = ''; + this._aoMapAsset = behaviorData.aoMapAsset || ''; + this._aoMapIntensity = this._clamp( + behaviorData.aoMapIntensity !== undefined + ? behaviorData.aoMapIntensity + : 1, + 0, + 1 + ); + this._aoMapTexture = null; + this._aoMapTextureAsset = ''; + this._albedoMapAsset = behaviorData.map || ''; + this._albedoMapTexture = null; + this._albedoMapTextureAsset = ''; + this._patchedMeshes = new Map(); + this._materialScanCounter = pbrMaterialScanIntervalFrames; + } + + override applyBehaviorOverriding(behaviorData): boolean { + if (behaviorData.metalness !== undefined) { + this.setMetalness(behaviorData.metalness); + } + if (behaviorData.roughness !== undefined) { + this.setRoughness(behaviorData.roughness); + } + if (behaviorData.envMapIntensity !== undefined) { + this.setEnvMapIntensity(behaviorData.envMapIntensity); + } + if (behaviorData.emissiveColor !== undefined) { + this.setEmissiveColor(behaviorData.emissiveColor); + } + if (behaviorData.emissiveIntensity !== undefined) { + this.setEmissiveIntensity(behaviorData.emissiveIntensity); + } + if (behaviorData.normalScale !== undefined) { + this.setNormalScale(behaviorData.normalScale); + } + if (behaviorData.normalMapAsset !== undefined) { + this.setNormalMapAsset(behaviorData.normalMapAsset); + } + if (behaviorData.aoMapAsset !== undefined) { + this.setAOMapAsset(behaviorData.aoMapAsset); + } + if (behaviorData.aoMapIntensity !== undefined) { + this.setAOMapIntensity(behaviorData.aoMapIntensity); + } + if (behaviorData.map !== undefined) { + this.setMap(behaviorData.map); + } + return true; + } + + override onCreated(): void { + this._materialScanCounter = pbrMaterialScanIntervalFrames; + this._patchOwnerMaterials(); + this._ensureEnvironmentFallbackAndSceneIntensity(); + } + + override onActivate(): void { + this._materialScanCounter = pbrMaterialScanIntervalFrames; + this._patchOwnerMaterials(); + this._ensureEnvironmentFallbackAndSceneIntensity(); + } + + override onDeActivate(): void { + this._restorePatchedMeshes(); + } + + override onDestroy(): void { + this._restorePatchedMeshes(); + } + + override doStepPreEvents( + instanceContainer: gdjs.RuntimeInstanceContainer + ): void { + this._ensureEnvironmentFallbackAndSceneIntensity(); + + if (this._materialScanCounter >= pbrMaterialScanIntervalFrames) { + this._materialScanCounter = 0; + this._patchOwnerMaterials(); + } else { + this._materialScanCounter++; + } + } + + setMetalness(value: number): void { + this._metalness = this._clamp01(value); + this._applyParametersToPatchedMaterials(); + } + + getMetalness(): number { + return this._metalness; + } + + setRoughness(value: number): void { + this._roughness = this._clamp01(value); + this._applyParametersToPatchedMaterials(); + } + + getRoughness(): number { + return this._roughness; + } + + setEnvMapIntensity(value: number): void { + this._envMapIntensity = this._clamp(value, 0, 4); + this._applyParametersToPatchedMaterials(); + this._ensureEnvironmentFallbackAndSceneIntensity(); + } + + getEnvMapIntensity(): number { + return this._envMapIntensity; + } + + setEmissiveIntensity(value: number): void { + this._emissiveIntensity = this._clamp(value, 0, 4); + this._applyParametersToPatchedMaterials(); + } + + getEmissiveIntensity(): number { + return this._emissiveIntensity; + } + + setEmissiveColor(color: string): void { + this._emissiveColorHex = gdjs.rgbOrHexStringToNumber(color || '0;0;0'); + this._applyParametersToPatchedMaterials(); + } + + setNormalScale(value: number): void { + this._normalScale = this._clamp(value, 0, 2); + this._applyParametersToPatchedMaterials(); + } + + getNormalScale(): number { + return this._normalScale; + } + + setNormalMapAsset(assetName: string): void { + this._normalMapAsset = assetName || ''; + this._normalMapTextureAsset = ''; + this._normalMapTexture = null; + this._applyParametersToPatchedMaterials(); + } + + setAOMapAsset(assetName: string): void { + this._aoMapAsset = assetName || ''; + this._aoMapTextureAsset = ''; + this._aoMapTexture = null; + this._applyParametersToPatchedMaterials(); + } + + setAOMapIntensity(value: number): void { + this._aoMapIntensity = this._clamp(value, 0, 1); + this._applyParametersToPatchedMaterials(); + } + + getAOMapIntensity(): number { + return this._aoMapIntensity; + } + + setMap(assetName: string): void { + this._albedoMapAsset = assetName || ''; + this._albedoMapTextureAsset = ''; + this._albedoMapTexture = null; + this._applyParametersToPatchedMaterials(); + } + + private _clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); + } + + private _clamp01(value: number): number { + return this._clamp(value, 0, 1); + } + + private _isSupportedMaterial( + material: THREE.Material + ): material is PBRManagedMaterial { + const typedMaterial = material as THREE.Material & { + isMeshStandardMaterial?: boolean; + isMeshPhysicalMaterial?: boolean; + isMeshBasicMaterial?: boolean; + isShaderMaterial?: boolean; + }; + + if (!typedMaterial || typedMaterial.isMeshBasicMaterial) { + return false; + } + if (typedMaterial.isShaderMaterial) { + return false; + } + return !!( + typedMaterial.isMeshStandardMaterial || + typedMaterial.isMeshPhysicalMaterial + ); + } + + private _getOwner3DObject(): THREE.Object3D | null { + const owner3DObject = this.owner as RuntimeObjectWith3DRenderer; + if ( + !owner3DObject || + typeof owner3DObject.get3DRendererObject !== 'function' + ) { + return null; + } + return owner3DObject.get3DRendererObject() || null; + } + + private _getThreeSceneAndRenderer(): { + scene: THREE.Scene; + renderer: THREE.WebGLRenderer; + timeMs: number; + } | null { + const runtimeScene = this.owner.getRuntimeScene(); + if (!runtimeScene) { + return null; + } + const layer = runtimeScene.getLayer(this.owner.getLayer()); + if (!layer) { + return null; + } + const layerRenderer = layer.getRenderer() as any; + if ( + !layerRenderer || + typeof layerRenderer.getThreeScene !== 'function' || + !layerRenderer.getThreeScene() + ) { + return null; + } + const scene = layerRenderer.getThreeScene() as THREE.Scene; + const renderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer() as THREE.WebGLRenderer; + if (!renderer) { + return null; + } + const timeMs = runtimeScene.getTimeManager().getTimeFromStart(); + return { scene, renderer, timeMs }; + } + + private _getOrCreateEnvironmentState( + scene: THREE.Scene + ): ScenePBREnvironmentState { + const existing = pbrEnvironmentStateByScene.get(scene); + if (existing) { + return existing; + } + + const state: ScenePBREnvironmentState = { + fallbackRenderTarget: null, + pmremGenerator: null, + lastIntensityUpdateTimeMs: -1, + sceneEnvMapIntensity: 0, + }; + pbrEnvironmentStateByScene.set(scene, state); + return state; + } + + private _ensureEnvironmentFallbackAndSceneIntensity(): void { + const sceneAndRenderer = this._getThreeSceneAndRenderer(); + if (!sceneAndRenderer) { + return; + } + + const { scene, renderer, timeMs } = sceneAndRenderer; + const state = this._getOrCreateEnvironmentState(scene); + + if (state.lastIntensityUpdateTimeMs !== timeMs) { + state.lastIntensityUpdateTimeMs = timeMs; + state.sceneEnvMapIntensity = 0; + } + state.sceneEnvMapIntensity = Math.max( + state.sceneEnvMapIntensity, + this._envMapIntensity + ); + scene.userData = scene.userData || {}; + scene.userData[pbrSceneEnvMapIntensityUserDataKey] = + state.sceneEnvMapIntensity; + + if (scene.environment) { + return; + } + + if (!state.pmremGenerator) { + state.pmremGenerator = new THREE.PMREMGenerator(renderer); + } + + if (state.fallbackRenderTarget) { + scene.environment = state.fallbackRenderTarget.texture; + return; + } + + const pmremGenerator = state.pmremGenerator; + const background = scene.background; + let environmentRenderTarget: THREE.WebGLRenderTarget | null = null; + + try { + const backgroundTexture = background as THREE.Texture | undefined; + if (backgroundTexture && (backgroundTexture as any).isCubeTexture) { + environmentRenderTarget = pmremGenerator.fromCubemap( + backgroundTexture as THREE.CubeTexture + ); + } else if (backgroundTexture && (backgroundTexture as any).isTexture) { + environmentRenderTarget = + pmremGenerator.fromEquirectangular(backgroundTexture); + } else { + const fallbackScene = new THREE.Scene(); + fallbackScene.background = + background && (background as any).isColor + ? (background as THREE.Color).clone() + : new THREE.Color(0.5, 0.5, 0.5); + environmentRenderTarget = pmremGenerator.fromScene( + fallbackScene, + 0, + 0.1, + 100 + ); + } + } catch (error) { + const fallbackScene = new THREE.Scene(); + fallbackScene.background = new THREE.Color(0.5, 0.5, 0.5); + environmentRenderTarget = pmremGenerator.fromScene( + fallbackScene, + 0, + 0.1, + 100 + ); + } finally { + pmremGenerator.dispose(); + state.pmremGenerator = null; + } + + if (!environmentRenderTarget) { + return; + } + + state.fallbackRenderTarget = environmentRenderTarget; + scene.environment = environmentRenderTarget.texture; + } + + private _getImageManager(): gdjs.PixiImageManager | null { + const imageManager = this.owner + .getRuntimeScene() + .getGame() + .getImageManager() as gdjs.PixiImageManager; + if (!imageManager || typeof imageManager.getThreeTexture !== 'function') { + return null; + } + return imageManager; + } + + private _resolveNormalMapTexture(): THREE.Texture | null { + if (!this._normalMapAsset) { + return null; + } + + if (this._normalMapTextureAsset === this._normalMapAsset) { + return this._normalMapTexture; + } + + this._normalMapTextureAsset = this._normalMapAsset; + this._normalMapTexture = null; + + try { + const imageManager = this._getImageManager(); + if (imageManager) { + this._normalMapTexture = + imageManager.getThreeTexture(this._normalMapAsset) || null; + } + } catch (error) { + this._normalMapTexture = null; + } + + return this._normalMapTexture; + } + + private _resolveAOMapTexture(): THREE.Texture | null { + if (!this._aoMapAsset) { + return null; + } + + if (this._aoMapTextureAsset === this._aoMapAsset) { + return this._aoMapTexture; + } + + this._aoMapTextureAsset = this._aoMapAsset; + this._aoMapTexture = null; + + try { + const imageManager = this._getImageManager(); + if (imageManager) { + this._aoMapTexture = + imageManager.getThreeTexture(this._aoMapAsset) || null; + } + } catch (error) { + this._aoMapTexture = null; + } + + return this._aoMapTexture; + } + + private _resolveAlbedoMapTexture(): THREE.Texture | null { + if (!this._albedoMapAsset) { + return null; + } + + if (this._albedoMapTextureAsset === this._albedoMapAsset) { + return this._albedoMapTexture; + } + + this._albedoMapTextureAsset = this._albedoMapAsset; + this._albedoMapTexture = null; + + try { + const imageManager = this._getImageManager(); + if (imageManager) { + this._albedoMapTexture = + imageManager.getThreeTexture(this._albedoMapAsset) || null; + } + } catch (error) { + this._albedoMapTexture = null; + } + + return this._albedoMapTexture; + } + + private _ensureUv2ForAO( + mesh: THREE.Mesh, + patchState: PBRPatchedMeshState + ): void { + const geometry = mesh.geometry as THREE.BufferGeometry; + if (!geometry || !geometry.attributes) { + return; + } + if (geometry.attributes.uv2 || !geometry.attributes.uv) { + return; + } + + geometry.attributes.uv2 = geometry.attributes.uv; + patchState.uv2WasPatched = true; + patchState.geometry = geometry; + } + + private _restorePatchedUv2(patchState: PBRPatchedMeshState): void { + if (!patchState.uv2WasPatched || !patchState.geometry) { + return; + } + + const geometry = patchState.geometry; + if (!geometry.attributes) { + return; + } + + if (patchState.hadUv2Attribute && patchState.originalUv2Attribute) { + geometry.attributes.uv2 = patchState.originalUv2Attribute; + } else { + delete (geometry.attributes as any).uv2; + } + patchState.uv2WasPatched = false; + } + + private _applyParametersToMaterial( + material: PBRManagedMaterial, + normalMapTexture: THREE.Texture | null, + aoMapTexture: THREE.Texture | null, + albedoMapTexture: THREE.Texture | null + ): void { + material.metalness = this._metalness; + material.roughness = this._roughness; + material.envMapIntensity = this._envMapIntensity; + material.emissive.setHex(this._emissiveColorHex); + material.emissiveIntensity = this._emissiveIntensity; + material.normalScale.set(this._normalScale, this._normalScale); + material.aoMapIntensity = this._aoMapIntensity; + if (normalMapTexture) { + material.normalMap = normalMapTexture; + } + if (aoMapTexture) { + material.aoMap = aoMapTexture; + } + if (albedoMapTexture) { + material.map = albedoMapTexture; + } + + material.userData = material.userData || {}; + material.userData[pbrManagedMaterialUserDataKey] = true; + material.userData[pbrMaterialRoughnessUserDataKey] = this._roughness; + material.needsUpdate = true; + } + + private _applyParametersToMesh( + mesh: THREE.Mesh, + patchState: PBRPatchedMeshState + ): void { + const normalMapTexture = this._resolveNormalMapTexture(); + const aoMapTexture = this._resolveAOMapTexture(); + const albedoMapTexture = this._resolveAlbedoMapTexture(); + + if (aoMapTexture) { + this._ensureUv2ForAO(mesh, patchState); + } + + for (const material of patchState.clonedMaterials) { + this._applyParametersToMaterial( + material, + normalMapTexture, + aoMapTexture, + albedoMapTexture + ); + } + } + + private _applyParametersToPatchedMaterials(): void { + for (const [mesh, patchState] of this._patchedMeshes.entries()) { + this._applyParametersToMesh(mesh, patchState); + } + } + + private _disposePatchedMeshState(state: PBRPatchedMeshState): void { + for (const material of state.clonedMaterials) { + material.dispose(); + } + } + + private _restorePatchedMeshes(): void { + for (const [mesh, state] of this._patchedMeshes.entries()) { + mesh.material = state.originalMaterial as + | THREE.Material + | THREE.Material[]; + this._restorePatchedUv2(state); + this._disposePatchedMeshState(state); + } + this._patchedMeshes.clear(); + } + + private _patchMeshMaterial(mesh: THREE.Mesh): void { + if (!mesh.material) { + return; + } + + const previousState = this._patchedMeshes.get(mesh); + if (previousState) { + if (mesh.material === previousState.patchedMaterial) { + return; + } + this._restorePatchedUv2(previousState); + this._disposePatchedMeshState(previousState); + this._patchedMeshes.delete(mesh); + } + + const originalMaterial = mesh.material as + | THREE.Material + | THREE.Material[]; + const sourceMaterials = Array.isArray(originalMaterial) + ? originalMaterial.slice() + : [originalMaterial]; + const patchedMaterials = sourceMaterials.slice(); + const clonedMaterials: PBRManagedMaterial[] = []; + let hasPatchedMaterial = false; + + for (let index = 0; index < sourceMaterials.length; index++) { + const sourceMaterial = sourceMaterials[index]; + if (!sourceMaterial || !this._isSupportedMaterial(sourceMaterial)) { + continue; + } + + const clonedMaterial = sourceMaterial.clone() as PBRManagedMaterial; + patchedMaterials[index] = clonedMaterial; + clonedMaterials.push(clonedMaterial); + hasPatchedMaterial = true; + } + + if (!hasPatchedMaterial) { + return; + } + + const appliedMaterial = Array.isArray(originalMaterial) + ? patchedMaterials + : patchedMaterials[0]; + mesh.material = appliedMaterial as THREE.Material | THREE.Material[]; + + const geometry = mesh.geometry as THREE.BufferGeometry; + const hasGeometryAttributes = !!(geometry && geometry.attributes); + const hadUv2Attribute = !!( + hasGeometryAttributes && geometry.attributes.uv2 + ); + const patchState: PBRPatchedMeshState = { + originalMaterial, + patchedMaterial: appliedMaterial as THREE.Material | THREE.Material[], + clonedMaterials, + geometry: geometry || null, + hadUv2Attribute, + originalUv2Attribute: hadUv2Attribute ? geometry.attributes.uv2 : null, + uv2WasPatched: false, + }; + this._patchedMeshes.set(mesh, patchState); + this._applyParametersToMesh(mesh, patchState); + } + + private _patchOwnerMaterials(): void { + const owner3DObject = this._getOwner3DObject(); + if (!owner3DObject) { + return; + } + + owner3DObject.traverse((object3D) => { + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh || !mesh.material) { + return; + } + this._patchMeshMaterial(mesh); + }); + } + } + + gdjs.registerBehavior( + 'Scene3D::PBRMaterial', + gdjs.PBRMaterialRuntimeBehavior + ); +} diff --git a/Extensions/3D/PostProcessingSharedResources.ts b/Extensions/3D/PostProcessingSharedResources.ts new file mode 100644 index 000000000000..05a5557c0eac --- /dev/null +++ b/Extensions/3D/PostProcessingSharedResources.ts @@ -0,0 +1,437 @@ +namespace gdjs { + export type Scene3DPostProcessingQualityMode = 'low' | 'medium' | 'high'; + + export interface Scene3DPostProcessingQualityProfile { + captureScale: number; + ssaoSamples: number; + ssrSteps: number; + fogSteps: number; + dofSamples: number; + dofBlurScale: number; + } + + export interface Scene3DSharedCapture { + colorTexture: THREE.Texture; + depthTexture: THREE.DepthTexture | null; + width: number; + height: number; + quality: Scene3DPostProcessingQualityProfile; + } + + interface Scene3DSharedPostProcessingState { + renderTarget: THREE.WebGLRenderTarget; + renderSize: THREE.Vector2; + previousViewport: THREE.Vector4; + previousScissor: THREE.Vector4; + lastCaptureTimeFromStartMs: number; + qualityMode: Scene3DPostProcessingQualityMode; + hasStackController: boolean; + stackEnabled: boolean; + lastPassOrderSignature: string; + effectQualityOverrides: Record; + } + + const qualityProfiles: { + [key in Scene3DPostProcessingQualityMode]: Scene3DPostProcessingQualityProfile; + } = { + low: { + captureScale: 0.5, + ssaoSamples: 4, + ssrSteps: 10, + fogSteps: 14, + dofSamples: 4, + dofBlurScale: 0.65, + }, + medium: { + // Medium defaults to half-resolution to keep effects usable on mid-range GPUs. + captureScale: 0.5, + ssaoSamples: 4, + ssrSteps: 14, + fogSteps: 20, + dofSamples: 4, + dofBlurScale: 0.85, + }, + high: { + captureScale: 0.75, + ssaoSamples: 8, + ssrSteps: 24, + fogSteps: 34, + dofSamples: 8, + dofBlurScale: 1.05, + }, + }; + const qualityRank: Record = { + low: 0, + medium: 1, + high: 2, + }; + + const managedPassOrder: string[] = [ + 'SSAO', + 'RIM', + 'DOF', + 'SSR', + 'FOG', + 'BLOOM', + ]; + const managedPassOrderMap = new Map( + managedPassOrder.map((id, index) => [id, index]) + ); + + const sharedStateByLayerRenderer = new WeakMap< + object, + Scene3DSharedPostProcessingState + >(); + + const normalizeQualityMode = ( + value: string + ): Scene3DPostProcessingQualityMode => { + const normalized = (value || '').toLowerCase(); + if (normalized === 'low' || normalized === 'high') { + return normalized; + } + return 'medium'; + }; + const getHigherQualityMode = ( + first: Scene3DPostProcessingQualityMode, + second: Scene3DPostProcessingQualityMode + ): Scene3DPostProcessingQualityMode => { + return qualityRank[first] >= qualityRank[second] ? first : second; + }; + + const getLayerRendererKey = (target: gdjs.Layer): object | null => { + const renderer = target.getRenderer(); + if (!renderer) { + return null; + } + return renderer as unknown as object; + }; + + const createSharedState = (): Scene3DSharedPostProcessingState => { + const renderTarget = new THREE.WebGLRenderTarget(1, 1, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + depthBuffer: true, + stencilBuffer: false, + }); + renderTarget.texture.generateMipmaps = false; + renderTarget.depthTexture = new THREE.DepthTexture(1, 1); + renderTarget.depthTexture.format = THREE.DepthFormat; + renderTarget.depthTexture.type = THREE.UnsignedIntType; + renderTarget.depthTexture.needsUpdate = true; + + return { + renderTarget, + renderSize: new THREE.Vector2(), + previousViewport: new THREE.Vector4(), + previousScissor: new THREE.Vector4(), + lastCaptureTimeFromStartMs: -1, + qualityMode: 'medium', + hasStackController: false, + stackEnabled: true, + lastPassOrderSignature: '', + effectQualityOverrides: {}, + }; + }; + + const getOrCreateSharedState = ( + target: gdjs.Layer + ): Scene3DSharedPostProcessingState | null => { + const key = getLayerRendererKey(target); + if (!key) { + return null; + } + + const existingState = sharedStateByLayerRenderer.get(key); + if (existingState) { + return existingState; + } + + const newState = createSharedState(); + sharedStateByLayerRenderer.set(key, newState); + return newState; + }; + + const getTimeFromStartMs = (target: gdjs.Layer): number => { + const runtimeScene: any = target.getRuntimeScene(); + if (!runtimeScene) { + return 0; + } + + const scene = + typeof runtimeScene.getScene === 'function' + ? runtimeScene.getScene() + : runtimeScene; + if (!scene || typeof scene.getTimeManager !== 'function') { + return 0; + } + return scene.getTimeManager().getTimeFromStart(); + }; + + export const markScene3DPostProcessingPass = function ( + pass: THREE_ADDONS.Pass, + passId: string + ): void { + (pass as any).__scene3dEffectId = passId; + }; + + export const setScene3DPostProcessingStackConfig = function ( + target: gdjs.Layer, + enabled: boolean, + qualityMode: string + ): void { + const state = getOrCreateSharedState(target); + if (!state) { + return; + } + + state.hasStackController = true; + state.stackEnabled = enabled; + state.qualityMode = normalizeQualityMode(qualityMode); + }; + + export const setScene3DPostProcessingEffectQualityMode = function ( + target: gdjs.Layer, + effectId: string, + qualityMode: string + ): void { + const state = getOrCreateSharedState(target); + if (!state) { + return; + } + + if (!effectId) { + return; + } + + state.effectQualityOverrides[effectId] = normalizeQualityMode(qualityMode); + }; + + export const clearScene3DPostProcessingEffectQualityMode = function ( + target: gdjs.Layer, + effectId: string + ): void { + const state = getOrCreateSharedState(target); + if (!state) { + return; + } + if (!effectId) { + return; + } + delete state.effectQualityOverrides[effectId]; + }; + + export const clearScene3DPostProcessingStackConfig = function ( + target: gdjs.Layer + ): void { + const state = getOrCreateSharedState(target); + if (!state) { + return; + } + + state.hasStackController = false; + state.stackEnabled = true; + state.qualityMode = 'medium'; + state.lastPassOrderSignature = ''; + state.effectQualityOverrides = {}; + }; + + export const isScene3DPostProcessingEnabled = function ( + target: gdjs.Layer + ): boolean { + const state = getOrCreateSharedState(target); + if (!state) { + return true; + } + return !state.hasStackController || state.stackEnabled; + }; + + const getEffectiveScene3DQualityMode = ( + state: Scene3DSharedPostProcessingState + ): Scene3DPostProcessingQualityMode => { + let mode = state.qualityMode; + for (const effectId in state.effectQualityOverrides) { + mode = getHigherQualityMode(mode, state.effectQualityOverrides[effectId]); + } + return mode; + }; + + export const getScene3DPostProcessingQualityProfileForMode = function ( + qualityMode: string + ): Scene3DPostProcessingQualityProfile { + return qualityProfiles[normalizeQualityMode(qualityMode)]; + }; + + export const getScene3DPostProcessingQualityProfile = function ( + target: gdjs.Layer + ): Scene3DPostProcessingQualityProfile { + const state = getOrCreateSharedState(target); + if (!state) { + return qualityProfiles.medium; + } + return qualityProfiles[getEffectiveScene3DQualityMode(state)]; + }; + + export const captureScene3DSharedTextures = function ( + target: gdjs.Layer, + threeRenderer: THREE.WebGLRenderer, + scene: THREE.Scene, + camera: THREE.Camera + ): Scene3DSharedCapture | null { + const state = getOrCreateSharedState(target); + if (!state) { + return null; + } + + const quality = getScene3DPostProcessingQualityProfile(target); + const renderTarget = state.renderTarget; + + threeRenderer.getDrawingBufferSize(state.renderSize); + const width = Math.max( + 1, + Math.round( + (state.renderSize.x || target.getWidth()) * quality.captureScale + ) + ); + const height = Math.max( + 1, + Math.round( + (state.renderSize.y || target.getHeight()) * quality.captureScale + ) + ); + + if (renderTarget.width !== width || renderTarget.height !== height) { + renderTarget.setSize(width, height); + if (renderTarget.depthTexture) { + renderTarget.depthTexture.needsUpdate = true; + } + } + renderTarget.texture.colorSpace = threeRenderer.outputColorSpace; + + const timeFromStart = getTimeFromStartMs(target); + if (state.lastCaptureTimeFromStartMs !== timeFromStart) { + const previousRenderTarget = threeRenderer.getRenderTarget(); + const previousAutoClear = threeRenderer.autoClear; + const previousScissorTest = threeRenderer.getScissorTest(); + const previousXrEnabled = threeRenderer.xr.enabled; + threeRenderer.getViewport(state.previousViewport); + threeRenderer.getScissor(state.previousScissor); + + threeRenderer.xr.enabled = false; + threeRenderer.autoClear = true; + threeRenderer.setRenderTarget(renderTarget); + threeRenderer.setViewport(0, 0, renderTarget.width, renderTarget.height); + threeRenderer.setScissor(0, 0, renderTarget.width, renderTarget.height); + threeRenderer.setScissorTest(false); + threeRenderer.clear(true, true, true); + threeRenderer.render(scene, camera); + + threeRenderer.setRenderTarget(previousRenderTarget); + threeRenderer.setViewport(state.previousViewport); + threeRenderer.setScissor(state.previousScissor); + threeRenderer.setScissorTest(previousScissorTest); + threeRenderer.autoClear = previousAutoClear; + threeRenderer.xr.enabled = previousXrEnabled; + + state.lastCaptureTimeFromStartMs = timeFromStart; + } + + return { + colorTexture: renderTarget.texture, + depthTexture: renderTarget.depthTexture, + width, + height, + quality, + }; + }; + + export const reorderScene3DPostProcessingPasses = function ( + target: gdjs.Layer + ): void { + const state = getOrCreateSharedState(target); + if (!state) { + return; + } + + const layerRenderer: any = target.getRenderer(); + if ( + !layerRenderer || + typeof layerRenderer.getThreeEffectComposer !== 'function' + ) { + return; + } + const composer = layerRenderer.getThreeEffectComposer(); + if (!composer || !composer.passes) { + return; + } + + const composerPasses = composer.passes as Array; + const detectedPasses = composerPasses + .map((pass, index) => ({ + pass, + index, + id: (pass as any).__scene3dEffectId as string | undefined, + })) + .filter((entry) => !!entry.id && managedPassOrderMap.has(entry.id)); + + if (detectedPasses.length <= 1) { + return; + } + + const currentSignature = detectedPasses.map((entry) => entry.id).join('|'); + if (state.lastPassOrderSignature === currentSignature) { + return; + } + + const sorted = detectedPasses + .slice() + .sort((a, b) => { + const orderA = managedPassOrderMap.get(a.id || '') || 999; + const orderB = managedPassOrderMap.get(b.id || '') || 999; + if (orderA === orderB) { + return a.index - b.index; + } + return orderA - orderB; + }) + .map((entry) => entry.pass); + + const renderer = target.getRenderer(); + for (const pass of detectedPasses) { + renderer.removePostProcessingPass(pass.pass); + } + for (const pass of sorted) { + renderer.addPostProcessingPass(pass); + } + + state.lastPassOrderSignature = sorted + .map((pass) => (pass as any).__scene3dEffectId as string) + .join('|'); + }; + + export const hasManagedScene3DPostProcessingPass = function ( + target: gdjs.Layer + ): boolean { + const layerRenderer: any = target.getRenderer(); + if ( + !layerRenderer || + typeof layerRenderer.getThreeEffectComposer !== 'function' + ) { + return false; + } + const composer = layerRenderer.getThreeEffectComposer(); + if (!composer || !composer.passes) { + return false; + } + + const composerPasses = composer.passes as Array; + return composerPasses.some((pass) => { + const passId = (pass as any).__scene3dEffectId as string | undefined; + return ( + !!passId && + managedPassOrderMap.has(passId) && + (pass as any).enabled !== false + ); + }); + }; +} diff --git a/Extensions/3D/PostProcessingStackEffect.ts b/Extensions/3D/PostProcessingStackEffect.ts new file mode 100644 index 000000000000..680fdbc901f8 --- /dev/null +++ b/Extensions/3D/PostProcessingStackEffect.ts @@ -0,0 +1,151 @@ +namespace gdjs { + interface PostProcessingStackNetworkSyncData { + q: string; + e: boolean; + } + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::PostProcessingStack', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + + return new (class implements gdjs.PixiFiltersTools.Filter { + _isEnabled: boolean; + _stackEnabled: boolean; + _qualityMode: string; + + constructor() { + this._isEnabled = false; + this._stackEnabled = true; + this._qualityMode = 'medium'; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + this._isEnabled = true; + gdjs.setScene3DPostProcessingStackConfig( + target, + this._stackEnabled, + this._qualityMode + ); + gdjs.reorderScene3DPostProcessingPasses(target); + return true; + } + + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + gdjs.clearScene3DPostProcessingStackConfig(target); + this._isEnabled = false; + return true; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + + gdjs.setScene3DPostProcessingStackConfig( + target, + this._stackEnabled, + this._qualityMode + ); + gdjs.reorderScene3DPostProcessingPasses(target); + + if (!this._stackEnabled) { + return; + } + if (!gdjs.hasManagedScene3DPostProcessingPass(target)) { + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + } + + updateDoubleParameter(parameterName: string, value: number): void {} + + getDoubleParameter(parameterName: string): number { + return 0; + } + + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } + + updateColorParameter(parameterName: string, value: number): void {} + + getColorParameter(parameterName: string): number { + return 0; + } + + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._stackEnabled = value; + } + } + + getNetworkSyncData(): PostProcessingStackNetworkSyncData { + return { + q: this._qualityMode, + e: this._stackEnabled, + }; + } + + updateFromNetworkSyncData( + syncData: PostProcessingStackNetworkSyncData + ): void { + this._qualityMode = syncData.q || 'medium'; + this._stackEnabled = !!syncData.e; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/RimLight.ts b/Extensions/3D/RimLight.ts new file mode 100644 index 000000000000..741782acf5da --- /dev/null +++ b/Extensions/3D/RimLight.ts @@ -0,0 +1,610 @@ +namespace gdjs { + interface RimLightNetworkSyncData { + i: number; + c: number; + o: number; + s: number; + p?: number; + f?: number; + e: boolean; + d?: boolean; + } + + type RimLightPatchedMaterial = THREE.Material & { + onBeforeCompile?: (shader: any, renderer: THREE.WebGLRenderer) => void; + customProgramCacheKey?: () => string; + needsUpdate?: boolean; + userData: { + [key: string]: any; + }; + }; + + interface RimLightShaderUniforms { + rimColor: { value: THREE.Color }; + rimIntensity: { value: number }; + rimOuterWrap: { value: number }; + rimPower: { value: number }; + rimFresnel0: { value: number }; + rimCameraPosition: { value: THREE.Vector3 }; + rimCameraMatrixWorld: { value: THREE.Matrix4 }; + rimDebugForceMax: { value: number }; + } + + interface RimLightPatchedMaterialState { + originalOnBeforeCompile: ( + shader: any, + renderer: THREE.WebGLRenderer + ) => void; + originalCustomProgramCacheKey?: (() => string) | undefined; + uniforms: RimLightShaderUniforms | null; + shaderInjected: boolean; + } + + const rimLightShaderPatchKey = 'Scene3D_RimLight_Patch_v4'; + const rimLightShaderPatchToken = 'SCENE3D_RIM_LIGHT_PATCH'; + const rimMaterialScanIntervalFrames = 15; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::RimLight', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + + return new (class implements gdjs.PixiFiltersTools.Filter { + _isEnabled: boolean; + _effectEnabled: boolean; + _intensity: number; + _outerWrap: number; + _power: number; + _fresnel0: number; + _shadowStrength: number; + _colorHex: number; + _debugForceMaxRim: boolean; + _patchedMaterials: Map< + RimLightPatchedMaterial, + RimLightPatchedMaterialState + >; + _cameraPosition: THREE.Vector3; + _cameraMatrixWorld: THREE.Matrix4; + _materialScanCounter: number; + _warnedNoMaterials: boolean; + _warnedNoShaderInjection: boolean; + + constructor() { + this._isEnabled = false; + this._effectEnabled = + effectData.booleanParameters.enabled === undefined + ? true + : !!effectData.booleanParameters.enabled; + this._intensity = Math.max( + 0, + effectData.doubleParameters.intensity !== undefined + ? effectData.doubleParameters.intensity + : 0.8 + ); + this._outerWrap = Math.max( + 0, + Math.min( + 1, + effectData.doubleParameters.outerWrap !== undefined + ? effectData.doubleParameters.outerWrap + : 0.18 + ) + ); + this._power = Math.max( + 0.05, + effectData.doubleParameters.power !== undefined + ? effectData.doubleParameters.power + : 2.2 + ); + this._fresnel0 = Math.max( + 0, + Math.min( + 1, + effectData.doubleParameters.fresnel0 !== undefined + ? effectData.doubleParameters.fresnel0 + : 0.04 + ) + ); + // Kept for network sync compatibility with previous implementation. + this._shadowStrength = 1.0; + this._colorHex = gdjs.rgbOrHexStringToNumber( + effectData.stringParameters.color || '255;255;255' + ); + this._debugForceMaxRim = + effectData.booleanParameters.debugForceMaxRim === undefined + ? false + : !!effectData.booleanParameters.debugForceMaxRim; + + this._patchedMaterials = new Map(); + this._cameraPosition = new THREE.Vector3(); + this._cameraMatrixWorld = new THREE.Matrix4(); + this._materialScanCounter = rimMaterialScanIntervalFrames; + this._warnedNoMaterials = false; + this._warnedNoShaderInjection = false; + } + + private _isMaterialPatchable( + material: RimLightPatchedMaterial + ): boolean { + if (!material) { + return false; + } + // ShaderMaterial is already user-defined; avoid mutating it unexpectedly. + const typedMaterial = material as THREE.Material & { + isShaderMaterial?: boolean; + isMeshBasicMaterial?: boolean; + isMeshLambertMaterial?: boolean; + isMeshPhongMaterial?: boolean; + isMeshStandardMaterial?: boolean; + isMeshPhysicalMaterial?: boolean; + isMeshToonMaterial?: boolean; + isMeshMatcapMaterial?: boolean; + }; + if (typedMaterial.isShaderMaterial) { + return false; + } + return !!( + typedMaterial.isMeshBasicMaterial || + typedMaterial.isMeshLambertMaterial || + typedMaterial.isMeshPhongMaterial || + typedMaterial.isMeshStandardMaterial || + typedMaterial.isMeshPhysicalMaterial || + typedMaterial.isMeshToonMaterial || + typedMaterial.isMeshMatcapMaterial + ); + } + + private _injectShader(shader: any): boolean { + if ( + shader.fragmentShader.indexOf(rimLightShaderPatchToken) !== -1 + ) { + return true; + } + + if ( + shader.vertexShader.indexOf('#include ') === -1 || + shader.vertexShader.indexOf('#include ') === + -1 || + shader.vertexShader.indexOf('#include ') === -1 || + shader.fragmentShader.indexOf('#include ') === -1 || + shader.fragmentShader.indexOf('#include ') === -1 + ) { + return false; + } + + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include +uniform mat4 rimCameraMatrixWorld; +varying vec3 vScene3DRimWorldPosition; +varying vec3 vScene3DRimWorldNormal;` + ); + + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include +vScene3DRimWorldNormal = normalize(mat3(rimCameraMatrixWorld) * transformedNormal);` + ); + + shader.vertexShader = shader.vertexShader.replace( + '#include ', + `#include +vScene3DRimWorldPosition = (modelMatrix * vec4(transformed, 1.0)).xyz;` + ); + + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `#include +uniform vec3 rimColor; +uniform float rimIntensity; +uniform float rimOuterWrap; +uniform float rimPower; +uniform float rimFresnel0; +uniform vec3 rimCameraPosition; +uniform float rimDebugForceMax; +varying vec3 vScene3DRimWorldPosition; +varying vec3 vScene3DRimWorldNormal; + +float scene3dPow5(float x) { + float x2 = x * x; + return x2 * x2 * x; +} + +float scene3dSchlickFresnel(float ndv, float f0) { + float clampedNdv = clamp(ndv, 0.0, 1.0); + return f0 + (1.0 - f0) * scene3dPow5(1.0 - clampedNdv); +} + +float scene3dComputeRimStrength( + vec3 worldNormal, + vec3 viewDirWorld, + float outerWrap, + float rimPower, + float fresnel0 +) { + vec3 resolvedNormal = normalize(worldNormal); + #ifdef DOUBLE_SIDED + resolvedNormal = gl_FrontFacing ? resolvedNormal : -resolvedNormal; + #endif + + float ndv = clamp(dot(resolvedNormal, normalize(viewDirWorld)), 0.0, 1.0); + float oneMinusNdv = 1.0 - ndv; + + // Artistic shaping term used in most realtime rim-light implementations. + float rimCore = pow(max(oneMinusNdv, 0.0), max(rimPower, 0.05)); + + // "Outer wrap" broadens the highlighted zone away from the strict silhouette. + float wrapped = clamp(oneMinusNdv + clamp(outerWrap, 0.0, 1.0) * 0.5, 0.0, 1.0); + float rimEnvelope = smoothstep(0.0, 1.0, wrapped); + + // Physically-inspired angular response (Schlick Fresnel). + float fresnel = scene3dSchlickFresnel(ndv, clamp(fresnel0, 0.0, 1.0)); + + return clamp(rimCore * rimEnvelope * fresnel, 0.0, 1.0); +}` + ); + + shader.fragmentShader = shader.fragmentShader.replace( + '#include ', + `float scene3dRimStrength = 0.0; +if (rimDebugForceMax > 0.5) { + // Debug mode: force full-rim contribution everywhere to verify shader reach. + scene3dRimStrength = 1.0; +} else if (rimIntensity > 0.0) { + vec3 scene3dViewDir = normalize(rimCameraPosition - vScene3DRimWorldPosition); + scene3dRimStrength = scene3dComputeRimStrength( + vScene3DRimWorldNormal, + scene3dViewDir, + rimOuterWrap, + rimPower, + rimFresnel0 + ); +} +outgoingLight += rimColor * (rimIntensity * scene3dRimStrength); +// ${rimLightShaderPatchToken} +#include ` + ); + + return true; + } + + private _updateUniformState( + patchState: RimLightPatchedMaterialState + ): void { + const uniforms = patchState.uniforms; + if (!uniforms) { + return; + } + + uniforms.rimColor.value.setHex(this._colorHex); + uniforms.rimIntensity.value = this._effectEnabled + ? this._debugForceMaxRim + ? 1.0 + : this._intensity * + Math.max(0, Math.min(1, this._shadowStrength)) + : 0; + uniforms.rimOuterWrap.value = this._outerWrap; + uniforms.rimPower.value = this._power; + uniforms.rimFresnel0.value = this._fresnel0; + uniforms.rimCameraPosition.value.copy(this._cameraPosition); + uniforms.rimCameraMatrixWorld.value.copy(this._cameraMatrixWorld); + uniforms.rimDebugForceMax.value = this._debugForceMaxRim + ? 1.0 + : 0.0; + } + + private _patchMaterial(material: RimLightPatchedMaterial): void { + if (this._patchedMaterials.has(material)) { + return; + } + if (!this._isMaterialPatchable(material)) { + return; + } + + const originalOnBeforeCompile = material.onBeforeCompile + ? material.onBeforeCompile + : () => {}; + const originalCustomProgramCacheKey = + material.customProgramCacheKey; + + const patchState: RimLightPatchedMaterialState = { + originalOnBeforeCompile, + originalCustomProgramCacheKey, + uniforms: null, + shaderInjected: false, + }; + + material.onBeforeCompile = ( + shader: any, + renderer: THREE.WebGLRenderer + ) => { + patchState.originalOnBeforeCompile.call( + material, + shader, + renderer + ); + + if (!this._injectShader(shader)) { + return; + } + + shader.uniforms.rimColor = { + value: new THREE.Color(this._colorHex), + }; + shader.uniforms.rimIntensity = { + value: 0, + }; + shader.uniforms.rimOuterWrap = { + value: this._outerWrap, + }; + shader.uniforms.rimPower = { + value: this._power, + }; + shader.uniforms.rimFresnel0 = { + value: this._fresnel0, + }; + shader.uniforms.rimCameraPosition = { + value: new THREE.Vector3(), + }; + shader.uniforms.rimCameraMatrixWorld = { + value: new THREE.Matrix4(), + }; + shader.uniforms.rimDebugForceMax = { + value: this._debugForceMaxRim ? 1.0 : 0.0, + }; + + patchState.uniforms = shader.uniforms as RimLightShaderUniforms; + patchState.shaderInjected = true; + this._updateUniformState(patchState); + }; + + material.customProgramCacheKey = () => { + const previousKey = patchState.originalCustomProgramCacheKey + ? patchState.originalCustomProgramCacheKey.call(material) + : ''; + return `${previousKey}|${rimLightShaderPatchKey}`; + }; + + material.needsUpdate = true; + this._patchedMaterials.set(material, patchState); + } + + private _unpatchMaterial(material: RimLightPatchedMaterial): void { + const patchState = this._patchedMaterials.get(material); + if (!patchState) { + return; + } + + material.onBeforeCompile = patchState.originalOnBeforeCompile; + + if (patchState.originalCustomProgramCacheKey) { + material.customProgramCacheKey = + patchState.originalCustomProgramCacheKey; + } else { + material.customProgramCacheKey = () => ''; + } + + material.needsUpdate = true; + this._patchedMaterials.delete(material); + } + + private _unpatchAllMaterials(): void { + for (const material of Array.from(this._patchedMaterials.keys())) { + this._unpatchMaterial(material); + } + this._patchedMaterials.clear(); + } + + private _applyToSceneMaterials(scene: THREE.Scene): void { + let encounteredMaterials = 0; + + scene.traverse((object3D) => { + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh || !mesh.material) { + return; + } + + const materials = Array.isArray(mesh.material) + ? (mesh.material as RimLightPatchedMaterial[]) + : ([mesh.material] as RimLightPatchedMaterial[]); + + for (const material of materials) { + if (!material) { + continue; + } + encounteredMaterials++; + this._patchMaterial(material); + } + }); + + if (encounteredMaterials === 0 && !this._warnedNoMaterials) { + this._warnedNoMaterials = true; + console.warn( + '[Scene3D::RimLight] No mesh materials found on the target scene layer. Rim light was not applied.' + ); + } + } + + private _updatePatchedMaterialsUniforms(): void { + let injectedMaterialCount = 0; + for (const patchState of this._patchedMaterials.values()) { + if (patchState.shaderInjected) { + injectedMaterialCount++; + } + this._updateUniformState(patchState); + } + + if ( + this._patchedMaterials.size > 0 && + injectedMaterialCount === 0 && + !this._warnedNoShaderInjection + ) { + this._warnedNoShaderInjection = true; + console.warn( + '[Scene3D::RimLight] Materials were found, but shader injection has not compiled yet. Enable debugForceMaxRim to validate when compilation occurs.' + ); + } + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } + return this.removeEffect(target); + } + + applyEffect(target: EffectsTarget): boolean { + const scene = target.get3DRendererObject() as THREE.Scene | null; + if (!scene) { + return false; + } + + this._materialScanCounter = rimMaterialScanIntervalFrames; + this._warnedNoMaterials = false; + this._warnedNoShaderInjection = false; + this._applyToSceneMaterials(scene); + this._isEnabled = true; + return true; + } + + removeEffect(target: EffectsTarget): boolean { + this._unpatchAllMaterials(); + this._isEnabled = false; + return true; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeScene || !threeCamera) { + return; + } + + threeCamera.updateMatrixWorld(); + this._cameraMatrixWorld.copy(threeCamera.matrixWorld); + this._cameraPosition.setFromMatrixPosition(threeCamera.matrixWorld); + + if (this._materialScanCounter >= rimMaterialScanIntervalFrames) { + this._applyToSceneMaterials(threeScene); + this._materialScanCounter = 0; + } else { + this._materialScanCounter++; + } + + this._updatePatchedMaterialsUniforms(); + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'intensity') { + this._intensity = Math.max(0, value); + } else if (parameterName === 'outerWrap') { + this._outerWrap = Math.max(0, Math.min(1, value)); + } else if (parameterName === 'power') { + this._power = Math.max(0.05, value); + } else if (parameterName === 'fresnel0') { + this._fresnel0 = Math.max(0, Math.min(1, value)); + } else if (parameterName === 'shadowStrength') { + this._shadowStrength = Math.max(0, Math.min(1, value)); + } + } + + getDoubleParameter(parameterName: string): number { + if (parameterName === 'intensity') { + return this._intensity; + } + if (parameterName === 'outerWrap') { + return this._outerWrap; + } + if (parameterName === 'power') { + return this._power; + } + if (parameterName === 'fresnel0') { + return this._fresnel0; + } + if (parameterName === 'shadowStrength') { + return this._shadowStrength; + } + return 0; + } + + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'color') { + this._colorHex = gdjs.rgbOrHexStringToNumber(value); + } + } + + updateColorParameter(parameterName: string, value: number): void { + if (parameterName === 'color') { + this._colorHex = value; + } + } + + getColorParameter(parameterName: string): number { + if (parameterName === 'color') { + return this._colorHex; + } + return 0; + } + + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + } else if (parameterName === 'debugForceMaxRim') { + this._debugForceMaxRim = value; + } + } + + getNetworkSyncData(): RimLightNetworkSyncData { + return { + i: this._intensity, + c: this._colorHex, + o: this._outerWrap, + s: this._shadowStrength, + p: this._power, + f: this._fresnel0, + e: this._effectEnabled, + d: this._debugForceMaxRim, + }; + } + + updateFromNetworkSyncData(syncData: RimLightNetworkSyncData): void { + this._intensity = Math.max(0, syncData.i); + this._colorHex = syncData.c; + this._outerWrap = Math.max(0, Math.min(1, syncData.o)); + this._shadowStrength = Math.max(0, Math.min(1, syncData.s)); + if (syncData.p !== undefined) { + this._power = Math.max(0.05, syncData.p); + } + if (syncData.f !== undefined) { + this._fresnel0 = Math.max(0, Math.min(1, syncData.f)); + } + this._effectEnabled = !!syncData.e; + this._debugForceMaxRim = !!syncData.d; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/SSAOEffect.ts b/Extensions/3D/SSAOEffect.ts new file mode 100644 index 000000000000..30ed61d555b6 --- /dev/null +++ b/Extensions/3D/SSAOEffect.ts @@ -0,0 +1,405 @@ +namespace gdjs { + interface SSAONetworkSyncData { + r: number; + i: number; + b: number; + s: number; + e: boolean; + q?: string; + } + + const ssaoShader = { + uniforms: { + tDiffuse: { value: null }, + tDepth: { value: null }, + resolution: { value: new THREE.Vector2(1, 1) }, + radius: { value: 60.0 }, + intensity: { value: 0.9 }, + bias: { value: 0.6 }, + sampleCount: { value: 4.0 }, + cameraProjectionMatrix: { value: new THREE.Matrix4() }, + cameraProjectionMatrixInverse: { value: new THREE.Matrix4() }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + uniform sampler2D tDiffuse; + uniform sampler2D tDepth; + uniform vec2 resolution; + uniform float radius; + uniform float intensity; + uniform float bias; + uniform float sampleCount; + uniform mat4 cameraProjectionMatrix; + uniform mat4 cameraProjectionMatrixInverse; + varying vec2 vUv; + + const int MAX_SSAO_SAMPLES = 32; + + vec3 viewPositionFromDepth(vec2 uv, float depth) { + vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 view = cameraProjectionMatrixInverse * clip; + return view.xyz / max(view.w, 0.00001); + } + + vec2 projectToUv(vec3 viewPosition) { + vec4 clip = cameraProjectionMatrix * vec4(viewPosition, 1.0); + return clip.xy / max(clip.w, 0.00001) * 0.5 + 0.5; + } + + vec3 reconstructNormal(vec2 uv, float depth) { + vec2 texel = 1.0 / resolution; + float depthRight = texture2D(tDepth, uv + vec2(texel.x, 0.0)).x; + float depthUp = texture2D(tDepth, uv + vec2(0.0, texel.y)).x; + + vec3 center = viewPositionFromDepth(uv, depth); + vec3 right = viewPositionFromDepth(uv + vec2(texel.x, 0.0), depthRight); + vec3 up = viewPositionFromDepth(uv + vec2(0.0, texel.y), depthUp); + + vec3 normal = normalize(cross(right - center, up - center)); + if (dot(normal, -normalize(center)) < 0.0) { + normal = -normal; + } + return normal; + } + + float hash12(vec2 p) { + vec3 p3 = fract(vec3(p.xyx) * 0.1031); + p3 += dot(p3, p3.yzx + 33.33); + return fract((p3.x + p3.y) * p3.z); + } + + vec3 randomHemisphereDirection(vec2 uv, vec3 normal, float index) { + float u = hash12(uv * vec2(173.3, 157.7) + vec2(index, index * 1.37)); + float v = hash12(uv.yx * vec2(149.1, 181.9) + vec2(index * 2.11, index * 0.73)); + + float phi = 6.28318530718 * u; + float cosTheta = 1.0 - v; + float sinTheta = sqrt(max(0.0, 1.0 - cosTheta * cosTheta)); + + vec3 randomVec = vec3(cos(phi) * sinTheta, sin(phi) * sinTheta, cosTheta); + + vec3 tangent = normalize(abs(normal.z) < 0.999 + ? cross(normal, vec3(0.0, 0.0, 1.0)) + : cross(normal, vec3(0.0, 1.0, 0.0))); + vec3 bitangent = cross(normal, tangent); + + return normalize( + tangent * randomVec.x + + bitangent * randomVec.y + + normal * randomVec.z + ); + } + + float computeAO(vec3 originVS, vec3 normal, vec2 uv) { + float count = clamp(sampleCount, 4.0, float(MAX_SSAO_SAMPLES)); + float occlusion = 0.0; + + for (int i = 0; i < MAX_SSAO_SAMPLES; i++) { + if (float(i) >= count) { + break; + } + + float scale = (float(i) + 0.5) / count; + scale = mix(0.1, 1.0, scale * scale); + vec3 sampleDir = randomHemisphereDirection(uv, normal, float(i)); + vec3 samplePos = originVS + sampleDir * radius * scale; + vec2 sampleUv = projectToUv(samplePos); + + if ( + sampleUv.x <= 0.0 || sampleUv.x >= 1.0 || + sampleUv.y <= 0.0 || sampleUv.y >= 1.0 + ) { + continue; + } + + float sampleDepth = texture2D(tDepth, sampleUv).x; + if (sampleDepth >= 1.0) { + continue; + } + + vec3 geometryPos = viewPositionFromDepth(sampleUv, sampleDepth); + float signedDepth = geometryPos.z - samplePos.z; + float rangeWeight = smoothstep( + 0.0, + 1.0, + radius / (abs(originVS.z - geometryPos.z) + 0.0001) + ); + float isOccluded = signedDepth > bias ? 1.0 : 0.0; + occlusion += isOccluded * rangeWeight; + } + + float ao = 1.0 - (occlusion / count) * intensity; + return clamp(ao, 0.0, 1.0); + } + + void main() { + vec4 baseColor = texture2D(tDiffuse, vUv); + float depth = texture2D(tDepth, vUv).x; + if (depth >= 1.0 || intensity <= 0.0 || radius <= 0.0) { + gl_FragColor = baseColor; + return; + } + + vec3 viewPos = viewPositionFromDepth(vUv, depth); + vec3 normal = reconstructNormal(vUv, depth); + float ao = computeAO(viewPos, normal, vUv); + float aoBlend = 0.75; + vec3 aoColor = mix(baseColor.rgb, baseColor.rgb * ao, aoBlend); + gl_FragColor = vec4(aoColor, baseColor.a); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::SSAO', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _radius: number; + _intensity: number; + _bias: number; + _samples: number; + _effectiveSamples: number; + _qualityMode: string; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass(ssaoShader); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'SSAO'); + this._isEnabled = false; + this._effectEnabled = + effectData.booleanParameters.enabled === undefined + ? true + : !!effectData.booleanParameters.enabled; + this._radius = + effectData.doubleParameters.radius !== undefined + ? Math.max(0.1, effectData.doubleParameters.radius) + : 60; + this._intensity = + effectData.doubleParameters.intensity !== undefined + ? Math.max(0, effectData.doubleParameters.intensity) + : 0.9; + this._bias = + effectData.doubleParameters.bias !== undefined + ? Math.max(0, effectData.doubleParameters.bias) + : 0.6; + this._samples = + effectData.doubleParameters.samples !== undefined + ? Math.max( + 4, + Math.min( + 32, + Math.round(effectData.doubleParameters.samples) + ) + ) + : 4; + this._effectiveSamples = this._samples; + this._qualityMode = + effectData.stringParameters.qualityMode || 'medium'; + this.shaderPass.enabled = true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + gdjs.reorderScene3DPostProcessingPasses(target); + this._isEnabled = true; + return true; + } + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSAO'); + this._isEnabled = false; + return true; + } + + private _adaptQuality(target: gdjs.EffectsTarget): void { + if (!(target instanceof gdjs.Layer)) { + return; + } + const quality = gdjs.getScene3DPostProcessingQualityProfileForMode( + this._qualityMode + ); + this._effectiveSamples = Math.max( + 4, + Math.min(quality.ssaoSamples, this._samples) + ); + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + if (!this._effectEnabled) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSAO'); + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + this._adaptQuality(target); + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSAO'); + return; + } + gdjs.setScene3DPostProcessingEffectQualityMode( + target, + 'SSAO', + this._qualityMode + ); + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture || !sharedCapture.depthTexture) { + return; + } + + threeCamera.updateMatrixWorld(); + threeCamera.updateProjectionMatrix(); + threeCamera.projectionMatrixInverse + .copy(threeCamera.projectionMatrix) + .invert(); + + this.shaderPass.enabled = true; + this.shaderPass.uniforms.resolution.value.set( + sharedCapture.width, + sharedCapture.height + ); + this.shaderPass.uniforms.tDepth.value = sharedCapture.depthTexture; + this.shaderPass.uniforms.cameraProjectionMatrix.value.copy( + threeCamera.projectionMatrix + ); + this.shaderPass.uniforms.cameraProjectionMatrixInverse.value.copy( + threeCamera.projectionMatrixInverse + ); + this.shaderPass.uniforms.radius.value = this._radius; + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.bias.value = this._bias; + this.shaderPass.uniforms.sampleCount.value = this._effectiveSamples; + } + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'radius') { + this._radius = Math.max(0.1, value); + } else if (parameterName === 'intensity') { + this._intensity = Math.max(0, value); + } else if (parameterName === 'bias') { + this._bias = Math.max(0, value); + } else if (parameterName === 'samples') { + this._samples = Math.max(4, Math.min(32, Math.round(value))); + } + } + getDoubleParameter(parameterName: string): number { + if (parameterName === 'radius') { + return this._radius; + } + if (parameterName === 'intensity') { + return this._intensity; + } + if (parameterName === 'bias') { + return this._bias; + } + if (parameterName === 'samples') { + return this._samples; + } + return 0; + } + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } + updateColorParameter(parameterName: string, value: number): void {} + getColorParameter(parameterName: string): number { + return 0; + } + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + getNetworkSyncData(): SSAONetworkSyncData { + return { + r: this._radius, + i: this._intensity, + b: this._bias, + s: this._samples, + e: this._effectEnabled, + q: this._qualityMode, + }; + } + updateFromNetworkSyncData(syncData: SSAONetworkSyncData): void { + this._radius = Math.max(0.1, syncData.r); + this._intensity = Math.max(0, syncData.i); + this._bias = Math.max(0, syncData.b); + this._samples = Math.max(4, Math.min(32, Math.round(syncData.s))); + this._effectiveSamples = Math.max(4, Math.min(24, this._samples)); + this._effectEnabled = syncData.e; + this._qualityMode = syncData.q || 'medium'; + + this.shaderPass.uniforms.radius.value = this._radius; + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.bias.value = this._bias; + this.shaderPass.uniforms.sampleCount.value = this._effectiveSamples; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/SSRExcludeBehavior.ts b/Extensions/3D/SSRExcludeBehavior.ts new file mode 100644 index 000000000000..14cfd7640b1d --- /dev/null +++ b/Extensions/3D/SSRExcludeBehavior.ts @@ -0,0 +1,113 @@ +namespace gdjs { + const ssrExcludeUserDataKey = '__gdScene3dSsrExclude'; + const ssrExcludeRefreshIntervalFrames = 15; + + type RuntimeObjectWith3DRenderer = gdjs.RuntimeObject & { + get3DRendererObject?: () => THREE.Object3D | null; + }; + + /** + * @category Behaviors > 3D + */ + export class SSRExcludeRuntimeBehavior extends gdjs.RuntimeBehavior { + private _enabled: boolean; + private _refreshCounter: number; + + constructor( + instanceContainer: gdjs.RuntimeInstanceContainer, + behaviorData, + owner: gdjs.RuntimeObject + ) { + super(instanceContainer, behaviorData, owner); + + this._enabled = + behaviorData.enabled === undefined ? true : !!behaviorData.enabled; + this._refreshCounter = ssrExcludeRefreshIntervalFrames; + } + + override applyBehaviorOverriding(behaviorData): boolean { + if (behaviorData.enabled !== undefined) { + this.setEnabled(!!behaviorData.enabled); + } + return true; + } + + override onCreated(): void { + this._refreshCounter = ssrExcludeRefreshIntervalFrames; + this._applyExcludeState(); + } + + override onActivate(): void { + this._refreshCounter = ssrExcludeRefreshIntervalFrames; + this._applyExcludeState(); + } + + override onDeActivate(): void { + this._applyExcludeState(false); + } + + override onDestroy(): void { + this._applyExcludeState(false); + } + + override doStepPreEvents( + instanceContainer: gdjs.RuntimeInstanceContainer + ): void { + if (this._refreshCounter >= ssrExcludeRefreshIntervalFrames) { + this._refreshCounter = 0; + this._applyExcludeState(); + } else { + this._refreshCounter++; + } + } + + isEnabled(): boolean { + return this._enabled; + } + + setEnabled(enabled: boolean): void { + const normalizedEnabled = !!enabled; + if (this._enabled === normalizedEnabled) { + return; + } + + this._enabled = normalizedEnabled; + this._refreshCounter = ssrExcludeRefreshIntervalFrames; + this._applyExcludeState(); + } + + private _getOwner3DObject(): THREE.Object3D | null { + const owner3D = this.owner as RuntimeObjectWith3DRenderer; + if (!owner3D || typeof owner3D.get3DRendererObject !== 'function') { + return null; + } + return owner3D.get3DRendererObject() || null; + } + + private _applyExcludeState(forceExclude?: boolean): void { + const object3D = this._getOwner3DObject(); + if (!object3D) { + return; + } + + const shouldExclude = + forceExclude !== undefined + ? forceExclude + : this.activated() && this._enabled; + + object3D.userData = object3D.userData || {}; + object3D.userData[ssrExcludeUserDataKey] = shouldExclude; + + object3D.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh || !mesh.isMesh) { + return; + } + mesh.userData = mesh.userData || {}; + mesh.userData[ssrExcludeUserDataKey] = shouldExclude; + }); + } + } + + gdjs.registerBehavior('Scene3D::SSRExclude', gdjs.SSRExcludeRuntimeBehavior); +} diff --git a/Extensions/3D/ScreenSpaceReflectionsEffect.ts b/Extensions/3D/ScreenSpaceReflectionsEffect.ts new file mode 100644 index 000000000000..ab45af05eac5 --- /dev/null +++ b/Extensions/3D/ScreenSpaceReflectionsEffect.ts @@ -0,0 +1,1089 @@ +namespace gdjs { + interface ScreenSpaceReflectionsNetworkSyncData { + i: number; + md: number; + t: number; + e: boolean; + q?: string; + } + + const ssrExcludeUserDataKey = '__gdScene3dSsrExclude'; + const pbrManagedMaterialUserDataKey = '__gdScene3dPbrMaterial'; + const pbrMaterialRoughnessUserDataKey = '__gdScene3dPbrRoughness'; + + const screenSpaceReflectionsShader = { + uniforms: { + tDiffuse: { value: null }, + tSceneColor: { value: null }, + tDepth: { value: null }, + tSSRExcludeMask: { value: null }, + tRoughness: { value: null }, + resolution: { value: new THREE.Vector2(1, 1) }, + intensity: { value: 0.75 }, + maxDistance: { value: 420.0 }, + thickness: { value: 4.0 }, + maxSteps: { value: 24.0 }, + cameraProjectionMatrix: { value: new THREE.Matrix4() }, + cameraProjectionMatrixInverse: { value: new THREE.Matrix4() }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + uniform sampler2D tDiffuse; + uniform sampler2D tSceneColor; + uniform sampler2D tDepth; + uniform sampler2D tSSRExcludeMask; + uniform sampler2D tRoughness; + uniform vec2 resolution; + uniform float intensity; + uniform float maxDistance; + uniform float thickness; + uniform float maxSteps; + uniform mat4 cameraProjectionMatrix; + uniform mat4 cameraProjectionMatrixInverse; + varying vec2 vUv; + + const int SSR_STEPS = 64; + const int SSR_REFINEMENT_STEPS = 5; + + vec3 viewPositionFromDepth(vec2 uv, float depth) { + vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 view = cameraProjectionMatrixInverse * clip; + return view.xyz / max(view.w, 0.00001); + } + + vec3 reconstructNormal(vec2 uv, float depth) { + vec2 texel = 1.0 / resolution; + vec2 uvLeft = clamp(uv - vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)); + vec2 uvRight = clamp(uv + vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)); + vec2 uvDown = clamp(uv - vec2(0.0, texel.y), vec2(0.0), vec2(1.0)); + vec2 uvUp = clamp(uv + vec2(0.0, texel.y), vec2(0.0), vec2(1.0)); + + float depthLeft = texture2D(tDepth, uvLeft).x; + float depthRight = texture2D(tDepth, uvRight).x; + float depthDown = texture2D(tDepth, uvDown).x; + float depthUp = texture2D(tDepth, uvUp).x; + + vec3 center = viewPositionFromDepth(uv, depth); + vec3 left = viewPositionFromDepth(uvLeft, depthLeft); + vec3 right = viewPositionFromDepth(uvRight, depthRight); + vec3 down = viewPositionFromDepth(uvDown, depthDown); + vec3 up = viewPositionFromDepth(uvUp, depthUp); + + // Select derivatives with the most consistent depth variation to reduce + // noisy normals near depth discontinuities. + vec3 dxForward = right - center; + vec3 dxBackward = center - left; + vec3 dyForward = up - center; + vec3 dyBackward = center - down; + vec3 dx = abs(dxForward.z) < abs(dxBackward.z) ? dxForward : dxBackward; + vec3 dy = abs(dyForward.z) < abs(dyBackward.z) ? dyForward : dyBackward; + + vec3 normal = normalize(cross(dx, dy)); + if (length(normal) < 0.0001) { + normal = normalize(cross(right - center, up - center)); + } + if (dot(normal, -normalize(center)) < 0.0) { + normal = -normal; + } + return normal; + } + + vec2 projectToUv(vec3 viewPosition) { + vec4 clip = cameraProjectionMatrix * vec4(viewPosition, 1.0); + return clip.xy / max(clip.w, 0.00001) * 0.5 + 0.5; + } + + float estimateRoughness(vec3 normal, vec3 viewPos) { + float facing = clamp(dot(normal, -normalize(viewPos)), 0.0, 1.0); + return clamp(1.0 - facing * facing, 0.08, 0.8); + } + + float sampleSceneRoughness(vec2 uv, vec3 normal, vec3 viewPos) { + vec4 roughnessSample = texture2D(tRoughness, uv); + if (roughnessSample.a > 0.5) { + return clamp(roughnessSample.r, 0.0, 1.0); + } + return estimateRoughness(normal, viewPos); + } + + vec3 sampleReflectionColor(vec2 uv, float roughness) { + vec2 texel = 1.0 / resolution; + vec3 currentCenter = texture2D(tDiffuse, uv).rgb; + vec3 currentXPos = texture2D( + tDiffuse, + clamp(uv + vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)) + ).rgb; + vec3 currentXNeg = texture2D( + tDiffuse, + clamp(uv - vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)) + ).rgb; + vec3 currentYPos = texture2D( + tDiffuse, + clamp(uv + vec2(0.0, texel.y), vec2(0.0), vec2(1.0)) + ).rgb; + vec3 currentYNeg = texture2D( + tDiffuse, + clamp(uv - vec2(0.0, texel.y), vec2(0.0), vec2(1.0)) + ).rgb; + + vec3 neighborhoodMin = min( + min(currentCenter, currentXPos), + min(min(currentXNeg, currentYPos), currentYNeg) + ); + vec3 neighborhoodMax = max( + max(currentCenter, currentXPos), + max(max(currentXNeg, currentYPos), currentYNeg) + ); + + vec3 capturedCenter = texture2D(tSceneColor, uv).rgb; + capturedCenter = clamp( + capturedCenter, + neighborhoodMin - vec3(0.08), + neighborhoodMax + vec3(0.08) + ); + + vec2 blurOffset = texel * mix(0.5, 2.0, roughness); + vec3 capturedBlurred = + capturedCenter + + texture2D( + tSceneColor, + clamp(uv + vec2(blurOffset.x, 0.0), vec2(0.0), vec2(1.0)) + ).rgb + + texture2D( + tSceneColor, + clamp(uv - vec2(blurOffset.x, 0.0), vec2(0.0), vec2(1.0)) + ).rgb + + texture2D( + tSceneColor, + clamp(uv + vec2(0.0, blurOffset.y), vec2(0.0), vec2(1.0)) + ).rgb + + texture2D( + tSceneColor, + clamp(uv - vec2(0.0, blurOffset.y), vec2(0.0), vec2(1.0)) + ).rgb; + capturedBlurred *= 0.2; + + float currentFrameWeight = 0.04 + 0.08 * (1.0 - roughness); + vec3 reflectionColor = mix(capturedBlurred, currentCenter, currentFrameWeight); + return min(reflectionColor, vec3(4.0)); + } + + vec4 refineHit( + vec3 originVS, + vec3 lowPos, + vec3 highPos, + float roughness, + vec3 reflectedDirVS + ) { + vec3 a = lowPos; + vec3 b = highPos; + vec3 mid = highPos; + + for (int i = 0; i < SSR_REFINEMENT_STEPS; ++i) { + mid = (a + b) * 0.5; + vec2 midUv = projectToUv(mid); + if (midUv.x <= 0.0 || midUv.x >= 1.0 || midUv.y <= 0.0 || midUv.y >= 1.0) { + b = mid; + continue; + } + float sampledDepth = texture2D(tDepth, midUv).x; + if (sampledDepth >= 1.0) { + a = mid; + continue; + } + vec3 depthViewPos = viewPositionFromDepth(midUv, sampledDepth); + float signedDepth = depthViewPos.z - mid.z; + float hitThickness = max(thickness, maxDistance / max(maxSteps, 1.0)); + if (signedDepth > -hitThickness * (1.0 + roughness)) { + b = mid; + } else { + a = mid; + } + } + + vec2 finalUv = projectToUv(mid); + if (finalUv.x <= 0.0 || finalUv.x >= 1.0 || finalUv.y <= 0.0 || finalUv.y >= 1.0) { + return vec4(0.0); + } + float finalDepth = texture2D(tDepth, finalUv).x; + if (finalDepth >= 1.0) { + return vec4(0.0); + } + + vec3 hitNormal = reconstructNormal(finalUv, finalDepth); + float normalAlignment = clamp(dot(hitNormal, -reflectedDirVS), 0.0, 1.0); + if (normalAlignment <= 0.05) { + return vec4(0.0); + } + + vec3 finalDepthViewPos = viewPositionFromDepth(finalUv, finalDepth); + float finalDepthError = abs(finalDepthViewPos.z - mid.z); + float finalHitThickness = + max(thickness, maxDistance / max(maxSteps, 1.0)) * (1.0 + roughness * 0.45); + float depthConfidence = 1.0 - smoothstep( + finalHitThickness * 0.5, + finalHitThickness * 2.5, + finalDepthError + ); + float angleConfidence = smoothstep(0.08, 0.45, normalAlignment); + float hitConfidence = clamp(depthConfidence * angleConfidence, 0.0, 1.0); + if (hitConfidence <= 0.02) { + return vec4(0.0); + } + + vec3 hitColor = sampleReflectionColor(finalUv, roughness) * hitConfidence; + float hitDistance = length(mid - originVS); + return vec4(hitColor * normalAlignment, hitDistance); + } + + vec4 traceReflection(vec3 originVS, vec3 reflectedDirVS, float roughness) { + float clampedSteps = clamp(maxSteps, 8.0, float(SSR_STEPS)); + float stepSize = maxDistance / clampedSteps; + vec3 rayPos = originVS; + vec3 previousRayPos = rayPos; + vec4 hit = vec4(0.0); + + for (int i = 0; i < SSR_STEPS; ++i) { + if (float(i) >= clampedSteps) { + break; + } + + previousRayPos = rayPos; + rayPos += reflectedDirVS * stepSize; + vec2 uv = projectToUv(rayPos); + if (uv.x <= 0.0 || uv.x >= 1.0 || uv.y <= 0.0 || uv.y >= 1.0) { + break; + } + + float sampledDepth = texture2D(tDepth, uv).x; + if (sampledDepth >= 1.0) { + continue; + } + + vec3 depthViewPos = viewPositionFromDepth(uv, sampledDepth); + float signedDepth = depthViewPos.z - rayPos.z; + float hitThickness = + max(thickness, stepSize * 0.95) * (1.0 + roughness * 0.35); + + if (signedDepth >= -hitThickness && signedDepth <= hitThickness) { + hit = refineHit( + originVS, + previousRayPos, + rayPos, + roughness, + reflectedDirVS + ); + break; + } + } + + return hit; + } + + void main() { + vec4 baseColor = texture2D(tDiffuse, vUv); + if (intensity <= 0.0 || maxDistance <= 0.0) { + gl_FragColor = baseColor; + return; + } + + float depth = texture2D(tDepth, vUv).x; + if (depth >= 1.0) { + gl_FragColor = baseColor; + return; + } + float excludeMask = texture2D(tSSRExcludeMask, vUv).r; + if (excludeMask > 0.5) { + gl_FragColor = baseColor; + return; + } + + vec3 viewPos = viewPositionFromDepth(vUv, depth); + vec3 normal = reconstructNormal(vUv, depth); + vec3 reflectedDir = normalize(reflect(normalize(viewPos), normal)); + + float roughness = sampleSceneRoughness(vUv, normal, viewPos); + vec4 hit = traceReflection(viewPos, reflectedDir, roughness); + vec3 reflectionColor = hit.rgb; + float rayDistance = hit.a; + + if (rayDistance <= 0.0) { + vec2 fallbackUv = clamp( + vUv + reflectedDir.xy * (0.045 + 0.035 * (1.0 - roughness)), + vec2(0.0), + vec2(1.0) + ); + reflectionColor = sampleReflectionColor(fallbackUv, roughness); + rayDistance = maxDistance * 0.45; + } + + float fresnel = pow(1.0 - max(dot(normal, -normalize(viewPos)), 0.0), 4.0); + float viewFacing = clamp(dot(normal, -normalize(viewPos)), 0.0, 1.0); + float distanceFade = clamp(1.0 - rayDistance / maxDistance, 0.0, 1.0); + float edgeFade = + smoothstep(0.02, 0.16, vUv.x) * + smoothstep(0.02, 0.16, vUv.y) * + smoothstep(0.02, 0.16, 1.0 - vUv.x) * + smoothstep(0.02, 0.16, 1.0 - vUv.y); + float stabilityFade = smoothstep(0.03, 0.22, viewFacing); + float reflectionStrength = + intensity * + (0.25 + 0.75 * (1.0 - roughness)) * + (0.25 + 0.75 * fresnel) * + distanceFade * + edgeFade * + stabilityFade; + + // Clamp to reduce bright sparkles on disoccluded pixels. + reflectionColor = min( + reflectionColor, + baseColor.rgb * 2.5 + vec3(0.35) + ); + + gl_FragColor = vec4( + baseColor.rgb + reflectionColor * reflectionStrength, + baseColor.a + ); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::ScreenSpaceReflections', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _intensity: number; + _maxDistance: number; + _thickness: number; + _raySteps: number; + _qualityMode: string; + _excludeMaskRenderTarget: THREE.WebGLRenderTarget | null; + _excludeMaskMaterial: THREE.MeshBasicMaterial; + _excludeMaskFallbackTexture: THREE.DataTexture; + _excludeMaskPreviousViewport: THREE.Vector4; + _excludeMaskPreviousScissor: THREE.Vector4; + _roughnessRenderTarget: THREE.WebGLRenderTarget | null; + _roughnessFallbackTexture: THREE.DataTexture; + _roughnessMaterialCache: Map; + _roughnessSkipMaterial: THREE.MeshBasicMaterial; + _roughnessPreviousViewport: THREE.Vector4; + _roughnessPreviousScissor: THREE.Vector4; + _roughnessPreviousClearColor: THREE.Color; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass( + screenSpaceReflectionsShader + ); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'SSR'); + this._isEnabled = false; + this._effectEnabled = + effectData.booleanParameters.enabled === undefined + ? true + : !!effectData.booleanParameters.enabled; + this._intensity = + effectData.doubleParameters.intensity !== undefined + ? Math.max(0, effectData.doubleParameters.intensity) + : 0.75; + this._maxDistance = + effectData.doubleParameters.maxDistance !== undefined + ? Math.max(0, effectData.doubleParameters.maxDistance) + : 420; + this._thickness = + effectData.doubleParameters.thickness !== undefined + ? Math.max(0.0001, effectData.doubleParameters.thickness) + : 4; + this._qualityMode = + effectData.stringParameters.qualityMode || 'medium'; + this.shaderPass.enabled = true; + this._raySteps = 14; + this._excludeMaskRenderTarget = null; + this._excludeMaskMaterial = new THREE.MeshBasicMaterial({ + color: 0x000000, + toneMapped: false, + }); + const fallbackPixel = new Uint8Array([0, 0, 0, 255]); + this._excludeMaskFallbackTexture = new THREE.DataTexture( + fallbackPixel, + 1, + 1 + ); + this._excludeMaskFallbackTexture.needsUpdate = true; + this._excludeMaskFallbackTexture.generateMipmaps = false; + this._excludeMaskFallbackTexture.minFilter = THREE.NearestFilter; + this._excludeMaskFallbackTexture.magFilter = THREE.NearestFilter; + this.shaderPass.uniforms.tSSRExcludeMask.value = + this._excludeMaskFallbackTexture; + this._excludeMaskPreviousViewport = new THREE.Vector4(); + this._excludeMaskPreviousScissor = new THREE.Vector4(); + + this._roughnessRenderTarget = null; + const roughnessFallbackPixel = new Uint8Array([0, 0, 0, 0]); + this._roughnessFallbackTexture = new THREE.DataTexture( + roughnessFallbackPixel, + 1, + 1 + ); + this._roughnessFallbackTexture.needsUpdate = true; + this._roughnessFallbackTexture.generateMipmaps = false; + this._roughnessFallbackTexture.minFilter = THREE.NearestFilter; + this._roughnessFallbackTexture.magFilter = THREE.NearestFilter; + this.shaderPass.uniforms.tRoughness.value = + this._roughnessFallbackTexture; + + this._roughnessMaterialCache = new Map(); + this._roughnessSkipMaterial = new THREE.MeshBasicMaterial({ + color: 0x000000, + toneMapped: false, + }); + this._roughnessSkipMaterial.transparent = true; + this._roughnessSkipMaterial.opacity = 0; + this._roughnessSkipMaterial.depthTest = false; + this._roughnessSkipMaterial.depthWrite = false; + this._roughnessSkipMaterial.colorWrite = false; + + this._roughnessPreviousViewport = new THREE.Vector4(); + this._roughnessPreviousScissor = new THREE.Vector4(); + this._roughnessPreviousClearColor = new THREE.Color(0, 0, 0); + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + gdjs.reorderScene3DPostProcessingPasses(target); + this._isEnabled = true; + return true; + } + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSR'); + this.shaderPass.uniforms.tSSRExcludeMask.value = + this._excludeMaskFallbackTexture; + this.shaderPass.uniforms.tRoughness.value = + this._roughnessFallbackTexture; + this._disposeSSRExcludeMaskResources(); + this._disposeSSRoughnessResources(); + this._isEnabled = false; + return true; + } + + private _adaptQuality(target: gdjs.EffectsTarget): void { + if (!(target instanceof gdjs.Layer)) { + return; + } + const quality = gdjs.getScene3DPostProcessingQualityProfileForMode( + this._qualityMode + ); + this._raySteps = quality.ssrSteps; + } + + private _isSSRExcludedMesh(object3D: THREE.Object3D): boolean { + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh || !mesh.visible) { + return false; + } + const userData = (mesh as any).userData; + return !!(userData && userData[ssrExcludeUserDataKey]); + } + + private _sceneHasSSRExcludedMeshes(scene: THREE.Scene): boolean { + const stack: THREE.Object3D[] = [scene]; + while (stack.length > 0) { + const object3D = stack.pop() as THREE.Object3D; + if (this._isSSRExcludedMesh(object3D)) { + return true; + } + const children = object3D.children; + for (let i = 0; i < children.length; i++) { + stack.push(children[i]); + } + } + return false; + } + + private _ensureSSRExcludeMaskTarget( + width: number, + height: number, + outputColorSpace: THREE.ColorSpace + ): THREE.WebGLRenderTarget { + if (!this._excludeMaskRenderTarget) { + this._excludeMaskRenderTarget = new THREE.WebGLRenderTarget( + 1, + 1, + { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + depthBuffer: true, + stencilBuffer: false, + } + ); + this._excludeMaskRenderTarget.texture.generateMipmaps = false; + } + + if ( + this._excludeMaskRenderTarget.width !== width || + this._excludeMaskRenderTarget.height !== height + ) { + this._excludeMaskRenderTarget.setSize(width, height); + } + + this._excludeMaskRenderTarget.texture.colorSpace = outputColorSpace; + return this._excludeMaskRenderTarget; + } + + private _captureSSRExcludeMask( + threeRenderer: THREE.WebGLRenderer, + threeScene: THREE.Scene, + threeCamera: THREE.Camera, + width: number, + height: number + ): THREE.Texture { + const renderTarget = this._ensureSSRExcludeMaskTarget( + width, + height, + threeRenderer.outputColorSpace + ); + const previousRenderTarget = threeRenderer.getRenderTarget(); + const previousAutoClear = threeRenderer.autoClear; + const previousScissorTest = threeRenderer.getScissorTest(); + const previousXrEnabled = threeRenderer.xr.enabled; + threeRenderer.getViewport(this._excludeMaskPreviousViewport); + threeRenderer.getScissor(this._excludeMaskPreviousScissor); + const previousOverrideMaterial = threeScene.overrideMaterial; + + const hiddenMeshes: Array<{ mesh: THREE.Mesh; visible: boolean }> = + []; + try { + this._excludeMaskMaterial.color.setRGB(0, 0, 0); + this._excludeMaskMaterial.depthTest = true; + this._excludeMaskMaterial.depthWrite = true; + this._excludeMaskMaterial.transparent = false; + + threeRenderer.xr.enabled = false; + threeRenderer.autoClear = true; + threeRenderer.setRenderTarget(renderTarget); + threeRenderer.setViewport(0, 0, width, height); + threeRenderer.setScissor(0, 0, width, height); + threeRenderer.setScissorTest(false); + threeScene.overrideMaterial = this._excludeMaskMaterial; + threeRenderer.clear(true, true, true); + threeRenderer.render(threeScene, threeCamera); + + let hasExcludedMeshes = false; + threeScene.traverse((object3D) => { + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh) { + return; + } + + if (this._isSSRExcludedMesh(mesh)) { + hasExcludedMeshes = true; + return; + } + + hiddenMeshes.push({ + mesh, + visible: mesh.visible, + }); + mesh.visible = false; + }); + + if (hasExcludedMeshes) { + this._excludeMaskMaterial.color.setRGB(1, 1, 1); + this._excludeMaskMaterial.depthTest = true; + this._excludeMaskMaterial.depthWrite = false; + this._excludeMaskMaterial.transparent = false; + threeRenderer.autoClear = false; + threeScene.overrideMaterial = this._excludeMaskMaterial; + threeRenderer.render(threeScene, threeCamera); + } + } finally { + for (let i = 0; i < hiddenMeshes.length; i++) { + hiddenMeshes[i].mesh.visible = hiddenMeshes[i].visible; + } + threeScene.overrideMaterial = previousOverrideMaterial; + threeRenderer.setRenderTarget(previousRenderTarget); + threeRenderer.setViewport(this._excludeMaskPreviousViewport); + threeRenderer.setScissor(this._excludeMaskPreviousScissor); + threeRenderer.setScissorTest(previousScissorTest); + threeRenderer.autoClear = previousAutoClear; + threeRenderer.xr.enabled = previousXrEnabled; + } + + return renderTarget.texture; + } + + private _disposeSSRExcludeMaskResources(): void { + if (this._excludeMaskRenderTarget) { + this._excludeMaskRenderTarget.dispose(); + this._excludeMaskRenderTarget = null; + } + } + + private _isPBRManagedMaterial( + material: THREE.Material + ): material is + | THREE.MeshStandardMaterial + | THREE.MeshPhysicalMaterial { + const typedMaterial = material as THREE.Material & { + isMeshStandardMaterial?: boolean; + isMeshPhysicalMaterial?: boolean; + userData?: Record; + }; + if ( + !typedMaterial.isMeshStandardMaterial && + !typedMaterial.isMeshPhysicalMaterial + ) { + return false; + } + const userData = typedMaterial.userData as + | Record + | undefined; + return !!(userData && userData[pbrManagedMaterialUserDataKey]); + } + + private _getPBRMaterialRoughness( + material: THREE.MeshStandardMaterial | THREE.MeshPhysicalMaterial + ): number { + const userData = material.userData as + | Record + | undefined; + const roughnessFromBehavior = userData + ? userData[pbrMaterialRoughnessUserDataKey] + : undefined; + const resolvedRoughness = + typeof roughnessFromBehavior === 'number' + ? roughnessFromBehavior + : material.roughness; + return Math.max( + 0, + Math.min( + 1, + Number.isFinite(resolvedRoughness) ? resolvedRoughness : 0.5 + ) + ); + } + + private _sceneHasPBRManagedMeshes(scene: THREE.Scene): boolean { + let hasManagedMesh = false; + scene.traverse((object3D) => { + if (hasManagedMesh) { + return; + } + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh || !mesh.visible || !mesh.material) { + return; + } + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (let i = 0; i < materials.length; i++) { + if (this._isPBRManagedMaterial(materials[i])) { + hasManagedMesh = true; + return; + } + } + }); + return hasManagedMesh; + } + + private _ensureSSRoughnessTarget( + width: number, + height: number, + outputColorSpace: THREE.ColorSpace + ): THREE.WebGLRenderTarget { + if (!this._roughnessRenderTarget) { + this._roughnessRenderTarget = new THREE.WebGLRenderTarget(1, 1, { + minFilter: THREE.LinearFilter, + magFilter: THREE.LinearFilter, + format: THREE.RGBAFormat, + depthBuffer: true, + stencilBuffer: false, + }); + this._roughnessRenderTarget.texture.generateMipmaps = false; + } + + if ( + this._roughnessRenderTarget.width !== width || + this._roughnessRenderTarget.height !== height + ) { + this._roughnessRenderTarget.setSize(width, height); + } + this._roughnessRenderTarget.texture.colorSpace = outputColorSpace; + return this._roughnessRenderTarget; + } + + private _getOrCreateRoughnessRenderMaterial( + sourceMaterial: + | THREE.MeshStandardMaterial + | THREE.MeshPhysicalMaterial + ): THREE.MeshBasicMaterial { + const roughnessStep = Math.round( + this._getPBRMaterialRoughness(sourceMaterial) * 255 + ); + const sourceAny = sourceMaterial as any; + const materialKey = [ + roughnessStep, + sourceMaterial.side, + sourceAny.skinning ? 1 : 0, + sourceAny.morphTargets ? 1 : 0, + sourceAny.morphNormals ? 1 : 0, + ].join('|'); + + const existingMaterial = + this._roughnessMaterialCache.get(materialKey); + if (existingMaterial) { + return existingMaterial; + } + + const roughnessValue = roughnessStep / 255; + const roughnessMaterial = new THREE.MeshBasicMaterial({ + color: new THREE.Color( + roughnessValue, + roughnessValue, + roughnessValue + ), + toneMapped: false, + side: sourceMaterial.side, + }); + roughnessMaterial.depthTest = true; + roughnessMaterial.depthWrite = true; + roughnessMaterial.transparent = false; + (roughnessMaterial as any).skinning = !!sourceAny.skinning; + (roughnessMaterial as any).morphTargets = !!sourceAny.morphTargets; + (roughnessMaterial as any).morphNormals = !!sourceAny.morphNormals; + roughnessMaterial.needsUpdate = true; + + this._roughnessMaterialCache.set(materialKey, roughnessMaterial); + return roughnessMaterial; + } + + private _captureSSRoughnessTexture( + threeRenderer: THREE.WebGLRenderer, + threeScene: THREE.Scene, + threeCamera: THREE.Camera, + width: number, + height: number + ): THREE.Texture { + const renderTarget = this._ensureSSRoughnessTarget( + width, + height, + threeRenderer.outputColorSpace + ); + + const previousRenderTarget = threeRenderer.getRenderTarget(); + const previousAutoClear = threeRenderer.autoClear; + const previousScissorTest = threeRenderer.getScissorTest(); + const previousXrEnabled = threeRenderer.xr.enabled; + const previousClearAlpha = threeRenderer.getClearAlpha(); + threeRenderer.getViewport(this._roughnessPreviousViewport); + threeRenderer.getScissor(this._roughnessPreviousScissor); + threeRenderer.getClearColor(this._roughnessPreviousClearColor); + + const hiddenMeshes: Array<{ mesh: THREE.Mesh; visible: boolean }> = + []; + const materialOverrides: Array<{ + mesh: THREE.Mesh; + material: THREE.Material | THREE.Material[]; + }> = []; + + try { + threeRenderer.xr.enabled = false; + threeRenderer.autoClear = true; + threeRenderer.setRenderTarget(renderTarget); + threeRenderer.setViewport(0, 0, width, height); + threeRenderer.setScissor(0, 0, width, height); + threeRenderer.setScissorTest(false); + threeRenderer.setClearColor(0x000000, 0); + threeRenderer.clear(true, true, true); + + threeScene.traverse((object3D) => { + const mesh = object3D as THREE.Mesh; + if (!mesh || !mesh.isMesh || !mesh.material) { + return; + } + + const sourceMaterials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + let hasManagedMaterial = false; + const replacementMaterials: THREE.Material[] = + sourceMaterials.map((sourceMaterial) => { + if ( + sourceMaterial && + this._isPBRManagedMaterial(sourceMaterial) + ) { + hasManagedMaterial = true; + return this._getOrCreateRoughnessRenderMaterial( + sourceMaterial + ); + } + return this._roughnessSkipMaterial; + }); + + if (!hasManagedMaterial) { + hiddenMeshes.push({ + mesh, + visible: mesh.visible, + }); + mesh.visible = false; + return; + } + + materialOverrides.push({ + mesh, + material: mesh.material as THREE.Material | THREE.Material[], + }); + mesh.material = Array.isArray(mesh.material) + ? replacementMaterials + : replacementMaterials[0]; + }); + + threeRenderer.render(threeScene, threeCamera); + } finally { + for (let i = 0; i < materialOverrides.length; i++) { + const override = materialOverrides[i]; + override.mesh.material = override.material; + } + for (let i = 0; i < hiddenMeshes.length; i++) { + hiddenMeshes[i].mesh.visible = hiddenMeshes[i].visible; + } + + threeRenderer.setRenderTarget(previousRenderTarget); + threeRenderer.setViewport(this._roughnessPreviousViewport); + threeRenderer.setScissor(this._roughnessPreviousScissor); + threeRenderer.setScissorTest(previousScissorTest); + threeRenderer.setClearColor( + this._roughnessPreviousClearColor, + previousClearAlpha + ); + threeRenderer.autoClear = previousAutoClear; + threeRenderer.xr.enabled = previousXrEnabled; + } + + return renderTarget.texture; + } + + private _disposeSSRoughnessResources(): void { + if (this._roughnessRenderTarget) { + this._roughnessRenderTarget.dispose(); + this._roughnessRenderTarget = null; + } + + for (const material of this._roughnessMaterialCache.values()) { + material.dispose(); + } + this._roughnessMaterialCache.clear(); + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + if (!this._effectEnabled) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSR'); + this._disposeSSRExcludeMaskResources(); + this._disposeSSRoughnessResources(); + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + this._adaptQuality(target); + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'SSR'); + this._disposeSSRExcludeMaskResources(); + this._disposeSSRoughnessResources(); + return; + } + gdjs.setScene3DPostProcessingEffectQualityMode( + target, + 'SSR', + this._qualityMode + ); + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture || !sharedCapture.depthTexture) { + return; + } + + let ssrExcludeMaskTexture: THREE.Texture = + this._excludeMaskFallbackTexture; + if (this._sceneHasSSRExcludedMeshes(threeScene)) { + ssrExcludeMaskTexture = this._captureSSRExcludeMask( + threeRenderer, + threeScene, + threeCamera, + sharedCapture.width, + sharedCapture.height + ); + } else { + this._disposeSSRExcludeMaskResources(); + } + + let ssrRoughnessTexture: THREE.Texture = + this._roughnessFallbackTexture; + if (this._sceneHasPBRManagedMeshes(threeScene)) { + ssrRoughnessTexture = this._captureSSRoughnessTexture( + threeRenderer, + threeScene, + threeCamera, + sharedCapture.width, + sharedCapture.height + ); + } else { + this._disposeSSRoughnessResources(); + } + + threeCamera.updateMatrixWorld(); + threeCamera.updateProjectionMatrix(); + threeCamera.projectionMatrixInverse + .copy(threeCamera.projectionMatrix) + .invert(); + this.shaderPass.enabled = true; + this.shaderPass.uniforms.resolution.value.set( + sharedCapture.width, + sharedCapture.height + ); + this.shaderPass.uniforms.tSceneColor.value = + sharedCapture.colorTexture; + this.shaderPass.uniforms.tDepth.value = sharedCapture.depthTexture; + this.shaderPass.uniforms.tSSRExcludeMask.value = + ssrExcludeMaskTexture; + this.shaderPass.uniforms.tRoughness.value = ssrRoughnessTexture; + this.shaderPass.uniforms.cameraProjectionMatrix.value.copy( + threeCamera.projectionMatrix + ); + this.shaderPass.uniforms.cameraProjectionMatrixInverse.value.copy( + threeCamera.projectionMatrixInverse + ); + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + this.shaderPass.uniforms.thickness.value = this._thickness; + this.shaderPass.uniforms.maxSteps.value = this._raySteps; + } + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'intensity') { + this._intensity = Math.max(0, value); + this.shaderPass.uniforms.intensity.value = this._intensity; + } else if (parameterName === 'maxDistance') { + this._maxDistance = Math.max(0, value); + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + } else if (parameterName === 'thickness') { + this._thickness = Math.max(0.0001, value); + this.shaderPass.uniforms.thickness.value = this._thickness; + } + } + getDoubleParameter(parameterName: string): number { + if (parameterName === 'intensity') { + return this._intensity; + } + if (parameterName === 'maxDistance') { + return this._maxDistance; + } + if (parameterName === 'thickness') { + return this._thickness; + } + return 0; + } + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } + updateColorParameter(parameterName: string, value: number): void {} + getColorParameter(parameterName: string): number { + return 0; + } + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + getNetworkSyncData(): ScreenSpaceReflectionsNetworkSyncData { + return { + i: this._intensity, + md: this._maxDistance, + t: this._thickness, + e: this._effectEnabled, + q: this._qualityMode, + }; + } + updateFromNetworkSyncData( + syncData: ScreenSpaceReflectionsNetworkSyncData + ): void { + this._intensity = Math.max(0, syncData.i); + this._maxDistance = Math.max(0, syncData.md); + this._thickness = Math.max(0.0001, syncData.t); + this._effectEnabled = syncData.e; + this._qualityMode = syncData.q || 'medium'; + + this.shaderPass.uniforms.intensity.value = this._intensity; + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + this.shaderPass.uniforms.thickness.value = this._thickness; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/ToneMappingEffect.ts b/Extensions/3D/ToneMappingEffect.ts new file mode 100644 index 000000000000..4671e77c604c --- /dev/null +++ b/Extensions/3D/ToneMappingEffect.ts @@ -0,0 +1,227 @@ +namespace gdjs { + interface ToneMappingNetworkSyncData { + m: string; + x: number; + e: boolean; + } + + const normalizeToneMappingMode = (mode: string): string => { + const normalized = (mode || '') + .trim() + .toLowerCase() + .replace(/[\s_-]/g, ''); + if (normalized === 'reinhard') { + return 'Reinhard'; + } + if (normalized === 'cineon') { + return 'Cineon'; + } + if (normalized === 'linear') { + return 'Linear'; + } + return 'ACESFilmic'; + }; + + const getToneMappingConstant = (mode: string): THREE.ToneMapping => { + if (mode === 'Reinhard') { + return THREE.ReinhardToneMapping; + } + if (mode === 'Cineon') { + return THREE.CineonToneMapping; + } + if (mode === 'Linear') { + // Requested behavior: "Linear" acts as no tone mapping. + return THREE.NoToneMapping; + } + return THREE.ACESFilmicToneMapping; + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::ToneMapping', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + _isEnabled: boolean; + _effectEnabled: boolean; + _mode: string; + _exposure: number; + _oldUseLegacyLights: boolean | null; + + constructor() { + this._isEnabled = false; + this._effectEnabled = true; + this._mode = 'ACESFilmic'; + this._exposure = 1.0; + this._oldUseLegacyLights = null; + void effectData; + } + + private _getRenderer( + target: EffectsTarget + ): THREE.WebGLRenderer | null { + if (!(target instanceof gdjs.Layer)) { + return null; + } + return target + .getRuntimeScene() + .getGame() + .getRenderer() + .getThreeRenderer(); + } + + private _setPhysicalLighting(renderer: THREE.WebGLRenderer): void { + const rendererWithLegacyLights = renderer as THREE.WebGLRenderer & { + useLegacyLights?: boolean; + }; + if ( + this._oldUseLegacyLights === null && + typeof rendererWithLegacyLights.useLegacyLights === 'boolean' + ) { + this._oldUseLegacyLights = + rendererWithLegacyLights.useLegacyLights; + } + if (typeof rendererWithLegacyLights.useLegacyLights === 'boolean') { + rendererWithLegacyLights.useLegacyLights = false; + } + } + + private _restoreLegacyLighting(renderer: THREE.WebGLRenderer): void { + const rendererWithLegacyLights = renderer as THREE.WebGLRenderer & { + useLegacyLights?: boolean; + }; + if ( + this._oldUseLegacyLights !== null && + typeof rendererWithLegacyLights.useLegacyLights === 'boolean' + ) { + rendererWithLegacyLights.useLegacyLights = + this._oldUseLegacyLights; + } + } + + private _applyToneMapping(target: EffectsTarget): boolean { + const renderer = this._getRenderer(target); + if (!renderer) { + return false; + } + + const mode = normalizeToneMappingMode(this._mode); + renderer.toneMapping = getToneMappingConstant(mode); + renderer.toneMappingExposure = Math.max(0, this._exposure); + renderer.outputColorSpace = THREE.SRGBColorSpace; + this._setPhysicalLighting(renderer); + return true; + } + + private _disableToneMapping(target: EffectsTarget): boolean { + const renderer = this._getRenderer(target); + if (!renderer) { + return false; + } + + renderer.toneMapping = THREE.NoToneMapping; + this._restoreLegacyLighting(renderer); + return true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } + return this.removeEffect(target); + } + + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + this._isEnabled = true; + if (!this._effectEnabled) { + return this._disableToneMapping(target); + } + return this._applyToneMapping(target); + } + + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + this._isEnabled = false; + return this._disableToneMapping(target); + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + + if (this._effectEnabled) { + this._applyToneMapping(target); + } else { + this._disableToneMapping(target); + } + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'exposure') { + this._exposure = Math.max(0, value); + } + } + + getDoubleParameter(parameterName: string): number { + if (parameterName === 'exposure') { + return this._exposure; + } + return 0; + } + + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'mode') { + this._mode = normalizeToneMappingMode(value); + } + } + + updateColorParameter(parameterName: string, value: number): void {} + + getColorParameter(parameterName: string): number { + return 0; + } + + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + } + } + + getNetworkSyncData(): ToneMappingNetworkSyncData { + return { + m: this._mode, + x: this._exposure, + e: this._effectEnabled, + }; + } + + updateFromNetworkSyncData( + syncData: ToneMappingNetworkSyncData + ): void { + this._mode = normalizeToneMappingMode(syncData.m); + this._exposure = Math.max(0, syncData.x); + this._effectEnabled = !!syncData.e; + } + })(); + } + })() + ); +} diff --git a/Extensions/3D/VolumetricFogEffect.ts b/Extensions/3D/VolumetricFogEffect.ts new file mode 100644 index 000000000000..40eb09f88f35 --- /dev/null +++ b/Extensions/3D/VolumetricFogEffect.ts @@ -0,0 +1,467 @@ +namespace gdjs { + interface VolumetricFogNetworkSyncData { + c: number; + d: number; + ls: number; + md: number; + e: boolean; + q?: string; + } + + const MAX_VOLUMETRIC_LIGHTS = 16; + + const makeVector3Array = (count: integer): THREE.Vector3[] => { + const values: THREE.Vector3[] = []; + for (let i = 0; i < count; i++) { + values.push(new THREE.Vector3()); + } + return values; + }; + + const makeNumberArray = (count: integer): number[] => { + const values: number[] = []; + for (let i = 0; i < count; i++) { + values.push(0); + } + return values; + }; + + const volumetricFogShader = { + uniforms: { + tDiffuse: { value: null }, + tDepth: { value: null }, + resolution: { value: new THREE.Vector2(1, 1) }, + fogColor: { value: new THREE.Vector3(1.0, 1.0, 1.0) }, + density: { value: 0.012 }, + lightScatter: { value: 1.0 }, + maxDistance: { value: 1200.0 }, + stepCount: { value: 36.0 }, + lightCount: { value: 0 }, + lightPositions: { value: makeVector3Array(MAX_VOLUMETRIC_LIGHTS) }, + lightColors: { value: makeVector3Array(MAX_VOLUMETRIC_LIGHTS) }, + lightRanges: { value: makeNumberArray(MAX_VOLUMETRIC_LIGHTS) }, + cameraProjectionMatrixInverse: { value: new THREE.Matrix4() }, + }, + vertexShader: ` + varying vec2 vUv; + + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } + `, + fragmentShader: ` + precision highp float; + + #define MAX_VOLUMETRIC_LIGHTS ${MAX_VOLUMETRIC_LIGHTS} + #define MAX_VOLUMETRIC_STEPS 64 + + uniform sampler2D tDiffuse; + uniform sampler2D tDepth; + uniform vec2 resolution; + uniform vec3 fogColor; + uniform float density; + uniform float lightScatter; + uniform float maxDistance; + uniform float stepCount; + uniform int lightCount; + uniform vec3 lightPositions[MAX_VOLUMETRIC_LIGHTS]; + uniform vec3 lightColors[MAX_VOLUMETRIC_LIGHTS]; + uniform float lightRanges[MAX_VOLUMETRIC_LIGHTS]; + uniform mat4 cameraProjectionMatrixInverse; + varying vec2 vUv; + + vec3 viewPositionFromDepth(vec2 uv, float depth) { + vec4 clip = vec4(uv * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + vec4 view = cameraProjectionMatrixInverse * clip; + return view.xyz / max(view.w, 0.00001); + } + + void main() { + vec4 baseColor = texture2D(tDiffuse, vUv); + if (density <= 0.0 || maxDistance <= 0.0) { + gl_FragColor = baseColor; + return; + } + + float depth = texture2D(tDepth, vUv).x; + vec3 farViewPosition = viewPositionFromDepth(vUv, 1.0); + vec3 rayDirection = normalize(farViewPosition); + float rayLength = maxDistance; + + if (depth < 1.0) { + vec3 surfaceViewPosition = viewPositionFromDepth(vUv, depth); + float surfaceDistance = length(surfaceViewPosition); + if (surfaceDistance > 0.00001) { + rayDirection = normalize(surfaceViewPosition); + rayLength = min(surfaceDistance, maxDistance); + } + } + + float clampedStepCount = clamp(stepCount, 8.0, float(MAX_VOLUMETRIC_STEPS)); + float stepLength = rayLength / clampedStepCount; + float transmittance = 1.0; + vec3 accumulatedFog = vec3(0.0); + + for (int step = 0; step < MAX_VOLUMETRIC_STEPS; step++) { + if (float(step) >= clampedStepCount) { + break; + } + float sampleDistance = (float(step) + 0.5) * stepLength; + vec3 samplePosition = rayDirection * sampleDistance; + + float localDensity = density; + vec3 localLightColor = vec3(0.0); + + for (int i = 0; i < MAX_VOLUMETRIC_LIGHTS; i++) { + if (i >= lightCount) break; + + float range = max(lightRanges[i], 1.0); + float distanceToLight = length(samplePosition - lightPositions[i]); + float attenuation = exp( + -(distanceToLight * distanceToLight) / max(1.0, range * range * 0.5) + ); + + localLightColor += lightColors[i] * attenuation; + localDensity += density * lightScatter * attenuation * 0.5; + } + + float opticalDepth = localDensity * stepLength * 0.01; + float stepTransmittance = exp(-opticalDepth); + vec3 mediumColor = fogColor + localLightColor * lightScatter; + + accumulatedFog += + transmittance * (1.0 - stepTransmittance) * mediumColor; + transmittance *= stepTransmittance; + + if (transmittance < 0.01) { + break; + } + } + + vec3 finalColor = baseColor.rgb * transmittance + accumulatedFog; + gl_FragColor = vec4(finalColor, baseColor.a); + } + `, + }; + + gdjs.PixiFiltersTools.registerFilterCreator( + 'Scene3D::VolumetricFog', + new (class implements gdjs.PixiFiltersTools.FilterCreator { + makeFilter( + target: EffectsTarget, + effectData: EffectData + ): gdjs.PixiFiltersTools.Filter { + if (typeof THREE === 'undefined') { + return new gdjs.PixiFiltersTools.EmptyFilter(); + } + return new (class implements gdjs.PixiFiltersTools.Filter { + shaderPass: THREE_ADDONS.ShaderPass; + _isEnabled: boolean; + _effectEnabled: boolean; + _fogColor: THREE.Color; + _density: number; + _lightScatter: number; + _maxDistance: number; + _qualityMode: string; + _lightPositions: THREE.Vector3[]; + _lightColors: THREE.Vector3[]; + _lightRanges: number[]; + _tempWorldPosition: THREE.Vector3; + _tempViewPosition: THREE.Vector3; + + constructor() { + this.shaderPass = new THREE_ADDONS.ShaderPass(volumetricFogShader); + gdjs.markScene3DPostProcessingPass(this.shaderPass, 'FOG'); + this._isEnabled = false; + this._effectEnabled = + effectData.booleanParameters.enabled === undefined + ? true + : !!effectData.booleanParameters.enabled; + this._fogColor = new THREE.Color( + gdjs.rgbOrHexStringToNumber( + effectData.stringParameters.fogColor || '200;220;255' + ) + ); + this._density = + effectData.doubleParameters.density !== undefined + ? Math.max(0, effectData.doubleParameters.density) + : 0.012; + this._lightScatter = + effectData.doubleParameters.lightScatter !== undefined + ? Math.max(0, effectData.doubleParameters.lightScatter) + : 1.0; + this._maxDistance = + effectData.doubleParameters.maxDistance !== undefined + ? Math.max(0, effectData.doubleParameters.maxDistance) + : 1200; + this._qualityMode = + effectData.stringParameters.qualityMode || 'medium'; + + this._lightPositions = makeVector3Array(MAX_VOLUMETRIC_LIGHTS); + this._lightColors = makeVector3Array(MAX_VOLUMETRIC_LIGHTS); + this._lightRanges = makeNumberArray(MAX_VOLUMETRIC_LIGHTS); + this._tempWorldPosition = new THREE.Vector3(); + this._tempViewPosition = new THREE.Vector3(); + + this.shaderPass.uniforms.fogColor.value.set( + this._fogColor.r, + this._fogColor.g, + this._fogColor.b + ); + this.shaderPass.uniforms.density.value = this._density; + this.shaderPass.uniforms.lightScatter.value = this._lightScatter; + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + this.shaderPass.uniforms.lightPositions.value = + this._lightPositions; + this.shaderPass.uniforms.lightColors.value = this._lightColors; + this.shaderPass.uniforms.lightRanges.value = this._lightRanges; + this.shaderPass.enabled = true; + } + + isEnabled(target: EffectsTarget): boolean { + return this._isEnabled; + } + setEnabled(target: EffectsTarget, enabled: boolean): boolean { + if (this._isEnabled === enabled) { + return true; + } + if (enabled) { + return this.applyEffect(target); + } else { + return this.removeEffect(target); + } + } + applyEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().addPostProcessingPass(this.shaderPass); + gdjs.reorderScene3DPostProcessingPasses(target); + this._isEnabled = true; + return true; + } + removeEffect(target: EffectsTarget): boolean { + if (!(target instanceof gdjs.Layer)) { + return false; + } + target.getRenderer().removePostProcessingPass(this.shaderPass); + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'FOG'); + this._isEnabled = false; + return true; + } + + private _updateLightsUniforms( + scene: THREE.Scene, + camera: THREE.Camera + ): void { + let lightCount = 0; + + scene.traverse((object: THREE.Object3D) => { + if (lightCount >= MAX_VOLUMETRIC_LIGHTS) { + return; + } + if ( + !(object instanceof THREE.PointLight) && + !(object instanceof THREE.SpotLight) + ) { + return; + } + if (!object.visible || object.intensity <= 0) { + return; + } + + object.getWorldPosition(this._tempWorldPosition); + this._tempViewPosition + .copy(this._tempWorldPosition) + .applyMatrix4(camera.matrixWorldInverse); + + const distance = + object.distance > 0 + ? Math.min(object.distance, this._maxDistance) + : this._maxDistance; + + this._lightPositions[lightCount].copy(this._tempViewPosition); + this._lightColors[lightCount].set( + object.color.r * object.intensity, + object.color.g * object.intensity, + object.color.b * object.intensity + ); + this._lightRanges[lightCount] = Math.max(distance, 1); + lightCount++; + }); + + for (let i = lightCount; i < MAX_VOLUMETRIC_LIGHTS; i++) { + this._lightPositions[i].set(0, 0, 0); + this._lightColors[i].set(0, 0, 0); + this._lightRanges[i] = 0; + } + + this.shaderPass.uniforms.lightCount.value = lightCount; + this.shaderPass.uniforms.lightPositions.value = + this._lightPositions; + this.shaderPass.uniforms.lightColors.value = this._lightColors; + this.shaderPass.uniforms.lightRanges.value = this._lightRanges; + } + + updatePreRender(target: gdjs.EffectsTarget): any { + if (!this._isEnabled) { + return; + } + if (!(target instanceof gdjs.Layer)) { + return; + } + if (!this._effectEnabled) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'FOG'); + return; + } + + const runtimeScene = target.getRuntimeScene(); + const threeRenderer = runtimeScene + .getGame() + .getRenderer() + .getThreeRenderer(); + const layerRenderer = target.getRenderer(); + const threeScene = layerRenderer.getThreeScene(); + const threeCamera = layerRenderer.getThreeCamera(); + + if (!threeRenderer || !threeScene || !threeCamera) { + return; + } + + if (!gdjs.isScene3DPostProcessingEnabled(target)) { + this.shaderPass.enabled = false; + gdjs.clearScene3DPostProcessingEffectQualityMode(target, 'FOG'); + return; + } + gdjs.setScene3DPostProcessingEffectQualityMode( + target, + 'FOG', + this._qualityMode + ); + + const sharedCapture = gdjs.captureScene3DSharedTextures( + target, + threeRenderer, + threeScene, + threeCamera + ); + if (!sharedCapture || !sharedCapture.depthTexture) { + return; + } + + threeCamera.updateMatrixWorld(); + threeCamera.updateProjectionMatrix(); + threeCamera.projectionMatrixInverse + .copy(threeCamera.projectionMatrix) + .invert(); + threeCamera.matrixWorldInverse + .copy(threeCamera.matrixWorld) + .invert(); + + this.shaderPass.enabled = true; + this.shaderPass.uniforms.resolution.value.set( + sharedCapture.width, + sharedCapture.height + ); + this.shaderPass.uniforms.tDepth.value = sharedCapture.depthTexture; + this.shaderPass.uniforms.cameraProjectionMatrixInverse.value.copy( + threeCamera.projectionMatrixInverse + ); + this.shaderPass.uniforms.fogColor.value.set( + this._fogColor.r, + this._fogColor.g, + this._fogColor.b + ); + this.shaderPass.uniforms.density.value = this._density; + this.shaderPass.uniforms.lightScatter.value = this._lightScatter; + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + const quality = gdjs.getScene3DPostProcessingQualityProfileForMode( + this._qualityMode + ); + this.shaderPass.uniforms.stepCount.value = quality.fogSteps; + + this._updateLightsUniforms(threeScene, threeCamera); + } + + updateDoubleParameter(parameterName: string, value: number): void { + if (parameterName === 'density') { + this._density = Math.max(0, value); + } else if (parameterName === 'lightScatter') { + this._lightScatter = Math.max(0, value); + } else if (parameterName === 'maxDistance') { + this._maxDistance = Math.max(0, value); + } + } + getDoubleParameter(parameterName: string): number { + if (parameterName === 'density') { + return this._density; + } + if (parameterName === 'lightScatter') { + return this._lightScatter; + } + if (parameterName === 'maxDistance') { + return this._maxDistance; + } + return 0; + } + updateStringParameter(parameterName: string, value: string): void { + if (parameterName === 'fogColor') { + this._fogColor.setHex(gdjs.rgbOrHexStringToNumber(value)); + } else if (parameterName === 'qualityMode') { + this._qualityMode = value || 'medium'; + } + } + updateColorParameter(parameterName: string, value: number): void { + if (parameterName === 'fogColor') { + this._fogColor.setHex(value); + } + } + getColorParameter(parameterName: string): number { + if (parameterName === 'fogColor') { + return this._fogColor.getHex(); + } + return 0; + } + updateBooleanParameter(parameterName: string, value: boolean): void { + if (parameterName === 'enabled') { + this._effectEnabled = value; + this.shaderPass.enabled = value; + } + } + getNetworkSyncData(): VolumetricFogNetworkSyncData { + return { + c: this._fogColor.getHex(), + d: this._density, + ls: this._lightScatter, + md: this._maxDistance, + e: this._effectEnabled, + q: this._qualityMode, + }; + } + updateFromNetworkSyncData( + syncData: VolumetricFogNetworkSyncData + ): void { + this._fogColor.setHex(syncData.c); + this._density = syncData.d; + this._lightScatter = syncData.ls; + this._maxDistance = syncData.md; + this._effectEnabled = syncData.e; + this._qualityMode = syncData.q || 'medium'; + + this.shaderPass.uniforms.fogColor.value.set( + this._fogColor.r, + this._fogColor.g, + this._fogColor.b + ); + this.shaderPass.uniforms.density.value = this._density; + this.shaderPass.uniforms.lightScatter.value = this._lightScatter; + this.shaderPass.uniforms.maxDistance.value = this._maxDistance; + this.shaderPass.enabled = this._effectEnabled; + } + })(); + } + })() + ); +} diff --git a/Extensions/Firebase/tests/FirebaseExtension.spec.js b/Extensions/Firebase/tests/FirebaseExtension.spec.js index e7e712ea3484..4bd858ae8e0f 100644 --- a/Extensions/Firebase/tests/FirebaseExtension.spec.js +++ b/Extensions/Firebase/tests/FirebaseExtension.spec.js @@ -17,6 +17,39 @@ const promisifyCallbackVariables = (executor) => ); }); +/** + * Retry an async predicate until it returns true or timeout is reached. + * @param {() => Promise} predicate + * @param {{timeoutMs?: number, intervalMs?: number}} [options] + * @returns {Promise} + */ +const waitForCondition = async ( + predicate, + { timeoutMs = 5000, intervalMs = 200 } = {} +) => { + const startedAt = Date.now(); + /** @type {any} */ + let lastError = null; + + while (Date.now() - startedAt <= timeoutMs) { + try { + if (await predicate()) { + return true; + } + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + + if (lastError) { + throw lastError; + } + + return false; +}; + /** A complex variable using all variables types. */ // TODO: this is a global, should be moved in a scope. const variable = new gdjs.Variable().fromJSObject({ @@ -649,9 +682,36 @@ describeIfOnline('Firebase extension end-to-end tests', function () { }); }); - describe('Firebase storage', () => { + describe('Firebase storage', function () { + let storageAvailable = true; const filename = `MyImage-${Math.random().toString(16)}.png`; - it('uploads an image', async () => { + + before(async function () { + const availabilityProbeFilename = `AvailabilityProbe-${Math.random() + .toString(16) + .slice(2)}.txt`; + const probeRef = firebase.storage().ref(availabilityProbeFilename); + + try { + await probeRef.putString('availability-probe'); + await probeRef.delete(); + } catch (error) { + if ( + error && + (error.code === 'storage/quota-exceeded' || + error.code === 'storage/unauthorized' || + error.code === 'storage/retry-limit-exceeded') + ) { + storageAvailable = false; + return; + } + throw error; + } + }); + + it('uploads an image', async function () { + if (!storageAvailable) this.skip(); + await promisifyCallbackVariables((callback) => gdjs.evtTools.firebaseTools.storage.uploadFile( 'myUpload', @@ -663,7 +723,9 @@ describeIfOnline('Firebase extension end-to-end tests', function () { ); }); - it('gets download url for an image', async () => { + it('gets download url for an image', async function () { + if (!storageAvailable) this.skip(); + const url = await promisifyCallbackVariables((callback, result) => gdjs.evtTools.firebaseTools.storage.getDownloadURL( filename, @@ -671,18 +733,66 @@ describeIfOnline('Firebase extension end-to-end tests', function () { callback ) ); - expect((await fetch(url.getAsString())).ok).to.be.ok(); + const downloadUrl = url.getAsString(); + expect(downloadUrl.length > 0).to.be.ok(); + + const storageRefFromUrl = firebase.storage().refFromURL(downloadUrl); + expect(storageRefFromUrl.name).to.be(filename); + expect( + await waitForCondition(async () => { + try { + await storageRefFromUrl.getMetadata(); + return true; + } catch (error) { + if ( + error && + (error.code === 'storage/object-not-found' || + error.code === 'storage/retry-limit-exceeded') + ) { + return false; + } + throw error; + } + }) + ).to.be.ok(); }); - it('deletes an image', async () => { - const url = await firebase.storage().ref(filename).getDownloadURL(); - expect((await fetch(url)).ok).to.be.ok(); + it('deletes an image', async function () { + if (!storageAvailable) this.skip(); + + const storageRef = firebase.storage().ref(filename); + await storageRef.getDownloadURL(); + expect( + await waitForCondition(async () => { + try { + await storageRef.getMetadata(); + return true; + } catch (error) { + if (error && error.code === 'storage/object-not-found') { + return false; + } + throw error; + } + }) + ).to.be.ok(); await promisifyCallbackVariables((callback) => gdjs.evtTools.firebaseTools.storage.deleteFile(filename, callback) ); - expect((await fetch(url)).ok).to.not.be.ok(); + expect( + await waitForCondition(async () => { + try { + await storageRef.getMetadata(); + return false; + } catch (error) { + if (error && error.code === 'storage/object-not-found') { + return true; + } + throw error; + } + }) + ).to.be.ok(); }); }); }); diff --git a/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts b/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts index bc80d7bb0d45..3d00596e1eb1 100644 --- a/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts +++ b/GDJS/Runtime/pixi-renderers/pixi-image-manager.ts @@ -92,6 +92,9 @@ namespace gdjs { * @returns The requested texture, or a placeholder if not found. */ getPIXITexture(resourceName: string): PIXI.Texture { + if (!resourceName) { + return this._invalidTexture; + } const resource = this._getImageResource(resourceName); if (!resource) { logger.warn( @@ -129,6 +132,9 @@ namespace gdjs { * @returns The requested texture, or a placeholder if not valid. */ getOrLoadPIXITexture(resourceName: string): PIXI.Texture { + if (!resourceName) { + return this._invalidTexture; + } const resource = this._getImageResource(resourceName); if (!resource) { logger.warn( @@ -394,6 +400,9 @@ namespace gdjs { * used by calling `getPIXITexture`. */ async loadResource(resourceName: string): Promise { + if (!resourceName) { + return; + } const resource = this._resourceLoader.getResource(resourceName); if (!resource) { logger.warn( diff --git a/newIDE/app/scripts/import-GDJS-Runtime.js b/newIDE/app/scripts/import-GDJS-Runtime.js index 5154afe45253..9d7c6579352e 100644 --- a/newIDE/app/scripts/import-GDJS-Runtime.js +++ b/newIDE/app/scripts/import-GDJS-Runtime.js @@ -27,7 +27,7 @@ if (!args['skip-clean']) { // Build GDJS runtime (and extensions). destinationPaths.forEach(destinationPath => { const outPath = path.join(destinationPath, 'Runtime'); - const output = shell.exec(`node scripts/build.js --out ${outPath}`, { + const output = shell.exec(`node scripts/build.js --out "${outPath}"`, { cwd: path.join(gdevelopRootPath, 'GDJS'), }); if (output.code !== 0) { 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');