Repository: godotengine/godot
Pull request #106145: Add specular occlusion from ambient light
Author: login: lander-vr
Labels: name: enhancement
name: topic:rendering
name: topic:3d
Created: 2025-05-07T12:51:18Z | Merged: 2025-05-09T16:34:40Z

Closes https://github.com/godotengine/godot-proposals/issues/8637
Supersedes https://github.com/godotengine/godot/pull/86102

This is a rebase and rework of @guerro323's specular occlusion PR.

There's a few fundamental changes:
- Specular occlusion is now based on ambient light instead of just the lightmap. This has the benefit of providing specular occlusion for any object regardless of whether they are lightmapped or not.
- Because of this, AO also provides specular occlusion now. (This is relevant for https://github.com/godotengine/godot/pull/89988, as it also implements specular occlusion from AO)
- In accordance with it not being based off of just lightmaps now, the setting has been moved to `reflections`.
- Instead of various modes, it is now a toggle. Either it is enabled or disabled. This is because the "Reduce" method worked very poorly on shiny surfaces, strayed further from ground truth, and simply looked worse.
- The implementation is a little simpler.
- This PR does a slightly better job at maintaining reflections that shouldn't be occluded, occluding reflections only once the ambient lighting crosses a certain threshold.

I'm not sure whether or not the project setting should still force a restart, since the implementation is cheaper and simpler now.

### Forward+
| Disabled | Enabled |
| - | - |
| ![image](https://github.com/user-attachments/assets/05e8fdcf-d106-484d-afc7-201f9557c3dc) | ![image](https://github.com/user-attachments/assets/42ae4e0f-9f13-46aa-920b-ef3c25875021) |
| ![image](https://github.com/user-attachments/assets/f41cf459-8f2d-4a0d-a494-a8a75757e020) | ![image](https://github.com/user-attachments/assets/4c14b9ef-3614-4cbf-acb4-5c2799ee0f07) |
| ![image](https://github.com/user-attachments/assets/ec3e86ba-de04-4d8a-825c-3bdc8009a0b1) | ![image](https://github.com/user-attachments/assets/f6556f0b-7ab6-4ae2-af72-e56b84c212e1) |
| ![image](https://github.com/user-attachments/assets/b931009b-246d-429d-9977-25d61b36cad5) | ![image](https://github.com/user-attachments/assets/fbc889d0-9bd5-4ed0-b6be-62b340cfd100) |




### Mobile
| Disabled | Enabled |
| - | - |
| ![image](https://github.com/user-attachments/assets/8819bcbc-8041-4ca9-b2e4-7a8542050bed) | ![image](https://github.com/user-attachments/assets/c0ef1b28-c8ad-4282-aaae-1c965b4231c9) |

### Compatibility
| Disabled | Enabled |
| - | - |
| ![image](https://github.com/user-attachments/assets/eaed9f9f-da55-4035-9ebe-8b578685cc8a) | ![image](https://github.com/user-attachments/assets/a02ebe8e-5575-47d9-a206-dd3581ca756d) |

_Tile-room test-scene is a quick remake of the scene in this [Unity forum post](https://discussions.unity.com/t/specular-occlusion/807318)_



Diff:
diff --git a/doc/classes/BaseMaterial3D.xml b/doc/classes/BaseMaterial3D.xml
index 76f9c20f0f71..e76bb5dc9c7f 100644
--- a/doc/classes/BaseMaterial3D.xml
+++ b/doc/classes/BaseMaterial3D.xml
@@ -182,6 +182,9 @@
 		<member name="disable_receive_shadows" type="bool" setter="set_flag" getter="get_flag" default="false">
 			If [code]true[/code], the object receives no shadow that would otherwise be cast onto it.
 		</member>
+		<member name="disable_specular_occlusion" type="bool" setter="set_flag" getter="get_flag" default="false">
+			If [code]true[/code], disables specular occlusion even if [member ProjectSettings.rendering/reflections/specular_occlusion/enabled] is [code]false[/code].
+		</member>
 		<member name="distance_fade_max_distance" type="float" setter="set_distance_fade_max_distance" getter="get_distance_fade_max_distance" default="10.0">
 			Distance at which the object appears fully opaque.
 			[b]Note:[/b] If [member distance_fade_max_distance] is less than [member distance_fade_min_distance], the behavior will be reversed. The object will start to fade away at [member distance_fade_max_distance] and will fully disappear once it reaches [member distance_fade_min_distance].
@@ -706,7 +709,10 @@
 		<constant name="FLAG_DISABLE_FOG" value="21" enum="Flags">
 			Disables receiving depth-based or volumetric fog.
 		</constant>
-		<constant name="FLAG_MAX" value="22" enum="Flags">
+		<constant name="FLAG_DISABLE_SPECULAR_OCCLUSION" value="22" enum="Flags">
+			Disables specular occlusion.
+		</constant>
+		<constant name="FLAG_MAX" value="23" enum="Flags">
 			Represents the size of the [enum Flags] enum.
 		</constant>
 		<constant name="DIFFUSE_BURLEY" value="0" enum="DiffuseMode">
diff --git a/doc/classes/ProjectSettings.xml b/doc/classes/ProjectSettings.xml
index 9ab9c088ed6b..7713ab327c55 100644
--- a/doc/classes/ProjectSettings.xml
+++ b/doc/classes/ProjectSettings.xml
@@ -3095,6 +3095,9 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
+			If [code]true[/code], reduces reflections based on ambient light.
+		</member>
 		<member name="rendering/renderer/rendering_method" type="String" setter="" getter="" default="&quot;forward_plus&quot;">
 			Sets the renderer that will be used by the project. Options are:
 			[b]forward_plus[/b] (Forward+): High-end renderer designed for desktop devices. Has a higher base overhead, but scales well with complex scenes. Not suitable for older devices or mobile.
diff --git a/drivers/gles3/rasterizer_scene_gles3.cpp b/drivers/gles3/rasterizer_scene_gles3.cpp
index e23e3b935b31..d0d297f79161 100644
--- a/drivers/gles3/rasterizer_scene_gles3.cpp
+++ b/drivers/gles3/rasterizer_scene_gles3.cpp
@@ -4225,6 +4225,9 @@ RasterizerSceneGLES3::RasterizerSceneGLES3() {
 		if (config->force_vertex_shading) {
 			global_defines += "\n#define USE_VERTEX_LIGHTING\n";
 		}
+		if (!config->specular_occlusion) {
+			global_defines += "\n#define SPECULAR_OCCLUSION_DISABLED\n";
+		}
 		material_storage->shaders.scene_shader.initialize(global_defines);
 		scene_globals.shader_default_version = material_storage->shaders.scene_shader.version_create();
 		material_storage->shaders.scene_shader.version_bind_shader(scene_globals.shader_default_version, SceneShaderGLES3::MODE_COLOR);
diff --git a/drivers/gles3/shaders/scene.glsl b/drivers/gles3/shaders/scene.glsl
index 53cd6988e4d3..0fa22f1958c2 100644
--- a/drivers/gles3/shaders/scene.glsl
+++ b/drivers/gles3/shaders/scene.glsl
@@ -2120,8 +2120,18 @@ void main() {
 #endif // USE_LIGHTMAP_CAPTURE
 #endif // !DISABLE_LIGHTMAP
 
-	ambient_light *= albedo.rgb;
 	ambient_light *= ao;
+#ifndef SPECULAR_OCCLUSION_DISABLED
+	float specular_occlusion = (ambient_light.r * 0.3 + ambient_light.g * 0.59 + ambient_light.b * 0.11) * 2.0; // Luminance of ambient light.
+	specular_occlusion = min(specular_occlusion * 4.0, 1.0); // This multiplication preserves speculars on bright areas.
+
+	float reflective_f = (1.0 - roughness) * metallic;
+	// 10.0 is a magic number, it gives the intended effect in most scenarios.
+	// Low enough for occlusion, high enough for reaction to lights and shadows.
+	specular_occlusion = max(min(reflective_f * specular_occlusion * 10.0, 1.0), specular_occlusion);
+	specular_light *= specular_occlusion;
+#endif // !SPECULAR_OCCLUSION_DISABLED
+	ambient_light *= albedo.rgb;
 
 #endif // !AMBIENT_LIGHT_DISABLED
 
diff --git a/drivers/gles3/storage/config.cpp b/drivers/gles3/storage/config.cpp
index 749947ebc3b0..9816061eb48f 100644
--- a/drivers/gles3/storage/config.cpp
+++ b/drivers/gles3/storage/config.cpp
@@ -185,6 +185,7 @@ Config::Config() {
 #endif
 
 	force_vertex_shading = GLOBAL_GET("rendering/shading/overrides/force_vertex_shading");
+	specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
 	use_nearest_mip_filter = GLOBAL_GET("rendering/textures/default_filters/use_nearest_mipmap_filter");
 
 	use_depth_prepass = bool(GLOBAL_GET("rendering/driver/depth_prepass/enable"));
diff --git a/drivers/gles3/storage/config.h b/drivers/gles3/storage/config.h
index 531c74002a9b..60753d2312a5 100644
--- a/drivers/gles3/storage/config.h
+++ b/drivers/gles3/storage/config.h
@@ -83,6 +83,7 @@ class Config {
 	bool srgb_framebuffer_supported = false;
 
 	bool force_vertex_shading = false;
+	bool specular_occlusion = false;
 
 	bool support_anisotropic_filter = false;
 	float anisotropic_level = 0.0f;
diff --git a/drivers/gles3/storage/material_storage.cpp b/drivers/gles3/storage/material_storage.cpp
index 30097741d8db..75f1edb4a260 100644
--- a/drivers/gles3/storage/material_storage.cpp
+++ b/drivers/gles3/storage/material_storage.cpp
@@ -1382,6 +1382,8 @@ MaterialStorage::MaterialStorage() {
 		}
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		actions.render_mode_defines["specular_occlusion_disabled"] = "#define SPECULAR_OCCLUSION_DISABLED\n";
+
 		actions.default_filter = ShaderLanguage::FILTER_LINEAR_MIPMAP;
 		actions.default_repeat = ShaderLanguage::REPEAT_ENABLE;
 
diff --git a/scene/resources/material.cpp b/scene/resources/material.cpp
index 4e84e15e73f9..206cac213de7 100644
--- a/scene/resources/material.cpp
+++ b/scene/resources/material.cpp
@@ -873,7 +873,9 @@ void BaseMaterial3D::_update_shader() {
 	if (flags[FLAG_DISABLE_FOG]) {
 		code += ", fog_disabled";
 	}
-
+	if (flags[FLAG_DISABLE_SPECULAR_OCCLUSION]) {
+		code += ", specular_occlusion_disabled";
+	}
 	if (transparency == TRANSPARENCY_ALPHA_DEPTH_PRE_PASS) {
 		code += ", depth_prepass_alpha";
 	}
@@ -3208,6 +3210,7 @@ void BaseMaterial3D::_bind_methods() {
 	ADD_PROPERTY(PropertyInfo(Variant::INT, "specular_mode", PROPERTY_HINT_ENUM, "SchlickGGX,Toon,Disabled"), "set_specular_mode", "get_specular_mode");
 	ADD_PROPERTYI(PropertyInfo(Variant::BOOL, "disable_ambient_light"), "set_flag", "get_flag", FLAG_DISABLE_AMBIENT_LIGHT);
 	ADD_PROPERTYI(PropertyInfo(Variant::BOOL, "disable_fog"), "set_flag", "get_flag", FLAG_DISABLE_FOG);
+	ADD_PROPERTYI(PropertyInfo(Variant::BOOL, "disable_specular_occlusion"), "set_flag", "get_flag", FLAG_DISABLE_SPECULAR_OCCLUSION);
 
 	ADD_GROUP("Vertex Color", "vertex_color");
 	ADD_PROPERTYI(PropertyInfo(Variant::BOOL, "vertex_color_use_as_albedo"), "set_flag", "get_flag", FLAG_ALBEDO_FROM_VERTEX_COLOR);
@@ -3466,6 +3469,7 @@ void BaseMaterial3D::_bind_methods() {
 	BIND_ENUM_CONSTANT(FLAG_PARTICLE_TRAILS_MODE);
 	BIND_ENUM_CONSTANT(FLAG_ALBEDO_TEXTURE_MSDF);
 	BIND_ENUM_CONSTANT(FLAG_DISABLE_FOG);
+	BIND_ENUM_CONSTANT(FLAG_DISABLE_SPECULAR_OCCLUSION);
 	BIND_ENUM_CONSTANT(FLAG_MAX);
 
 	BIND_ENUM_CONSTANT(DIFFUSE_BURLEY);
diff --git a/scene/resources/material.h b/scene/resources/material.h
index 4c9044c30f29..d9487769502f 100644
--- a/scene/resources/material.h
+++ b/scene/resources/material.h
@@ -267,6 +267,7 @@ class BaseMaterial3D : public Material {
 		FLAG_PARTICLE_TRAILS_MODE,
 		FLAG_ALBEDO_TEXTURE_MSDF,
 		FLAG_DISABLE_FOG,
+		FLAG_DISABLE_SPECULAR_OCCLUSION,
 		FLAG_MAX
 	};
 
diff --git a/servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp b/servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp
index 193a14580994..ff1e6b42b48b 100644
--- a/servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp
+++ b/servers/rendering/renderer_rd/forward_clustered/render_forward_clustered.cpp
@@ -4872,6 +4872,11 @@ RenderForwardClustered::RenderForwardClustered() {
 			defines += "\n#define USE_VERTEX_LIGHTING\n";
 		}
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (!specular_occlusion) {
+			defines += "\n#define SPECULAR_OCCLUSION_DISABLED\n";
+		}
+
 		{
 			//lightmaps
 			scene_state.max_lightmaps = MAX_LIGHTMAPS;
diff --git a/servers/rendering/renderer_rd/forward_clustered/scene_shader_forward_clustered.cpp b/servers/rendering/renderer_rd/forward_clustered/scene_shader_forward_clustered.cpp
index 69075ad169f6..f13f364f3c12 100644
--- a/servers/rendering/renderer_rd/forward_clustered/scene_shader_forward_clustered.cpp
+++ b/servers/rendering/renderer_rd/forward_clustered/scene_shader_forward_clustered.cpp
@@ -766,6 +766,8 @@ void SceneShaderForwardClustered::init(const String p_defines) {
 		actions.render_mode_defines["debug_shadow_splits"] = "#define DEBUG_DRAW_PSSM_SPLITS\n";
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		actions.render_mode_defines["specular_occlusion_disabled"] = "#define SPECULAR_OCCLUSION_DISABLED\n";
+
 		actions.base_texture_binding_index = 1;
 		actions.texture_layout_set = RenderForwardClustered::MATERIAL_UNIFORM_SET;
 		actions.base_uniform_string = "material.";
diff --git a/servers/rendering/renderer_rd/forward_mobile/render_forward_mobile.cpp b/servers/rendering/renderer_rd/forward_mobile/render_forward_mobile.cpp
index 5ab4ce570d4c..f809077416b6 100644
--- a/servers/rendering/renderer_rd/forward_mobile/render_forward_mobile.cpp
+++ b/servers/rendering/renderer_rd/forward_mobile/render_forward_mobile.cpp
@@ -3221,6 +3221,11 @@ RenderForwardMobile::RenderForwardMobile() {
 		defines += "\n#define USE_VERTEX_LIGHTING\n";
 	}
 
+	bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+	if (!specular_occlusion) {
+		defines += "\n#define SPECULAR_OCCLUSION_DISABLED\n";
+	}
+
 	{
 		//lightmaps
 		scene_state.max_lightmaps = 2;
diff --git a/servers/rendering/renderer_rd/forward_mobile/scene_shader_forward_mobile.cpp b/servers/rendering/renderer_rd/forward_mobile/scene_shader_forward_mobile.cpp
index 7ae930665c1d..47250fd6aecb 100644
--- a/servers/rendering/renderer_rd/forward_mobile/scene_shader_forward_mobile.cpp
+++ b/servers/rendering/renderer_rd/forward_mobile/scene_shader_forward_mobile.cpp
@@ -698,6 +698,8 @@ void SceneShaderForwardMobile::init(const String p_defines) {
 		actions.render_mode_defines["debug_shadow_splits"] = "#define DEBUG_DRAW_PSSM_SPLITS\n";
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		actions.render_mode_defines["specular_occlusion_disabled"] = "#define SPECULAR_OCCLUSION_DISABLED\n";
+
 		actions.base_texture_binding_index = 1;
 		actions.texture_layout_set = RenderForwardMobile::MATERIAL_UNIFORM_SET;
 		actions.base_uniform_string = "material.";
diff --git a/servers/rendering/renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl b/servers/rendering/renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl
index bb78cd1a7ac3..b1812d72f8a6 100644
--- a/servers/rendering/renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl
+++ b/servers/rendering/renderer_rd/shaders/forward_clustered/scene_forward_clustered.glsl
@@ -1969,8 +1969,18 @@ void fragment_shader(in SceneData scene_data) {
 
 	//finalize ambient light here
 	{
-		ambient_light *= albedo.rgb;
 		ambient_light *= ao;
+#ifndef SPECULAR_OCCLUSION_DISABLED
+		float specular_occlusion = (ambient_light.r * 0.3 + ambient_light.g * 0.59 + ambient_light.b * 0.11) * 2.0; // Luminance of ambient light.
+		specular_occlusion = min(specular_occlusion * 4.0, 1.0); // This multiplication preserves speculars on bright areas.
+
+		float reflective_f = (1.0 - roughness) * metallic;
+		// 10.0 is a magic number, it gives the intended effect in most scenarios.
+		// Low enough for occlusion, high enough for reaction to lights and shadows.
+		specular_occlusion = max(min(reflective_f * specular_occlusion * 10.0, 1.0), specular_occlusion);
+		specular_light *= specular_occlusion;
+#endif // SPECULAR_OCCLUSION_DISABLED
+		ambient_light *= albedo.rgb;
 
 		if (bool(implementation_data.ss_effects_flags & SCREEN_SPACE_EFFECTS_FLAGS_USE_SSIL)) {
 #ifdef USE_MULTIVIEW
diff --git a/servers/rendering/renderer_rd/shaders/forward_mobile/scene_forward_mobile.glsl b/servers/rendering/renderer_rd/shaders/forward_mobile/scene_forward_mobile.glsl
index edbebbd14c33..83fd4bff89a6 100644
--- a/servers/rendering/renderer_rd/shaders/forward_mobile/scene_forward_mobile.glsl
+++ b/servers/rendering/renderer_rd/shaders/forward_mobile/scene_forward_mobile.glsl
@@ -1437,9 +1437,18 @@ void main() {
 	} //Reflection probes
 
 	// finalize ambient light here
-
-	ambient_light *= albedo.rgb;
 	ambient_light *= ao;
+#ifndef SPECULAR_OCCLUSION_DISABLED
+	float specular_occlusion = (ambient_light.r * 0.3 + ambient_light.g * 0.59 + ambient_light.b * 0.11) * 2.0; // Luminance of ambient light.
+	specular_occlusion = min(specular_occlusion * 4.0, 1.0); // This multiplication preserves speculars on bright areas.
+
+	float reflective_f = (1.0 - roughness) * metallic;
+	// 10.0 is a magic number, it gives the intended effect in most scenarios.
+	// Low enough for occlusion, high enough for reaction to lights and shadows.
+	specular_occlusion = max(min(reflective_f * specular_occlusion * 10.0, 1.0), specular_occlusion);
+	specular_light *= specular_occlusion;
+#endif // USE_SPECULAR_OCCLUSION
+	ambient_light *= albedo.rgb;
 
 #endif // !AMBIENT_LIGHT_DISABLED
 
diff --git a/servers/rendering/shader_types.cpp b/servers/rendering/shader_types.cpp
index 5eb113c87624..cf8c1bd6ca0f 100644
--- a/servers/rendering/shader_types.cpp
+++ b/servers/rendering/shader_types.cpp
@@ -242,6 +242,7 @@ ShaderTypes::ShaderTypes() {
 		shader_modes[RS::SHADER_SPATIAL].modes.push_back({ PNAME("alpha_to_coverage_and_one") });
 		shader_modes[RS::SHADER_SPATIAL].modes.push_back({ PNAME("debug_shadow_splits") });
 		shader_modes[RS::SHADER_SPATIAL].modes.push_back({ PNAME("fog_disabled") });
+		shader_modes[RS::SHADER_SPATIAL].modes.push_back({ PNAME("specular_occlusion_disabled") });
 	}
 
 	/************ CANVAS ITEM **************************/
diff --git a/servers/rendering_server.cpp b/servers/rendering_server.cpp
index f0768c1ef8ba..37a9d0947a3e 100644
--- a/servers/rendering_server.cpp
+++ b/servers/rendering_server.cpp
@@ -3632,6 +3632,7 @@ void RenderingServer::init() {
 	GLOBAL_DEF(PropertyInfo(Variant::INT, "rendering/reflections/reflection_atlas/reflection_size", PROPERTY_HINT_RANGE, "0,4096,1"), 256);
 	GLOBAL_DEF(PropertyInfo(Variant::INT, "rendering/reflections/reflection_atlas/reflection_size.mobile", PROPERTY_HINT_RANGE, "0,2048,1"), 128);
 	GLOBAL_DEF(PropertyInfo(Variant::INT, "rendering/reflections/reflection_atlas/reflection_count", PROPERTY_HINT_RANGE, "0,256,1"), 64);
+	GLOBAL_DEF_RST("rendering/reflections/specular_occlusion/enabled", true);
 
 	GLOBAL_DEF("rendering/global_illumination/gi/use_half_resolution", false);
 


Reviews:
user: login: Calinou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-07T14:37:46Z
user: login: lander-vr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T15:17:24Z
user: login: lander-vr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T15:17:45Z
user: login: clayjohn
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-05-08T16:08:58Z
user: login: lander-vr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T17:08:41Z
user: login: clayjohn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T17:27:49Z
user: login: lander-vr
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T18:58:03Z
user: login: clayjohn
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T20:46:37Z
user: login: clayjohn
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T20:46:53Z
user: login: Calinou
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-08T21:37:34Z
user: login: Calinou
body: Tested locally, it works as expected. Code looks good to me.
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-08T21:48:16Z

Review comments:
diff_hunk: @@ -3095,6 +3095,10 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
+			If [code]true[/code], reduces reflections based on ambient light.
+			[b]Note:[/b] When [member LightmapGI.directional] is set to [code]true[/code], this feature is disabled.
path: doc/classes/ProjectSettings.xml
user: login: Calinou
body: Out of curiosity, why is that the case? I can't see any references to directional lightmaps (or `sh`) in the PR's diff.

Also, I believe that even if this was done, SSAO would still be affected by this (as SSAO can't know whether direct or indirect light was baked in the lightmap, regardless of whether it's directional or not). As a result, SSAO treats lightmaps as pure ambient (indirect) light. It will always darken lightmaps as if SSAO Light Affect was always 1.0, even if it's a lower value.

Quoting from the PR description:

> This PR does a slightly better job at maintaining reflections that shouldn't be occluded, occluding reflections only once the ambient lighting crosses a certain threshold.

We could address this caveat of SSAO by treating lightmap brightness above a certain value as direct light, and making it take SSAO Light Affect into account. For example, if lightmap value is above 0.5, you could treat values above 0.5 as direct light for the purposes of SSAO.
created_at: 2025-05-07T14:35:51Z
author_association: MEMBER
diff_hunk: @@ -1382,6 +1382,11 @@ MaterialStorage::MaterialStorage() {
 		}
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (specular_occlusion) {
+			actions.render_mode_defines["specular_occlusion"] = "#define USE_SPECULAR_OCCLUSION\n";
+		}
path: drivers/gles3/storage/material_storage.cpp
user: login: Calinou
body: Should the render mode be flipped to `specular_occlusion_disabled`? Right now, custom shaders that don't specify any render mode will look different to a BaseMaterial3D in its default configuration, since the BaseMaterial3D will use specular occlusion but not the custom shader.

Doing so would also match the high-level API in BaseMaterial3D.
created_at: 2025-05-07T14:37:03Z
author_association: MEMBER
diff_hunk: @@ -3095,6 +3095,10 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
+			If [code]true[/code], reduces reflections based on ambient light.
+			[b]Note:[/b] When [member LightmapGI.directional] is set to [code]true[/code], this feature is disabled.
path: doc/classes/ProjectSettings.xml
user: login: lander-vr
body: Ah yeah, that line is irrelevant in my pr, just something I forgot to edit out of the original PR. :)

> We could address this caveat of SSAO

What I'm referencing in that quote is not an issue caused by SSAO, it's just kind of a caveat of this technique as a whole and I don't think it can be mitigated (easily). Doing something like baking scene-wide bent normals would probably improve this from my understanding (And I believe this is what Unreal does), but that's a little out of scope for me. I think this approach still gets us 90% of the way there for a fraction of the cost though.

What would hugely improve specular occlusion based on SSAO though would be an improved SSAO technique that captures the variety in depth better.
created_at: 2025-05-07T15:17:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1382,6 +1382,11 @@ MaterialStorage::MaterialStorage() {
 		}
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (specular_occlusion) {
+			actions.render_mode_defines["specular_occlusion"] = "#define USE_SPECULAR_OCCLUSION\n";
+		}
path: drivers/gles3/storage/material_storage.cpp
user: login: lander-vr
body: Yeah, this makes sense to me!
created_at: 2025-05-07T15:17:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1382,6 +1382,11 @@ MaterialStorage::MaterialStorage() {
 		}
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (!specular_occlusion) {
path: drivers/gles3/storage/material_storage.cpp
user: login: clayjohn
body: I think you can get rid of this check now. Since enabling specular occlusion in the project settings means that the shader can choose to disable it with the render mode (so the render mode should get registered). Disabling it in the project settings adds the define already, so the render mode is basically ignored anyway
created_at: 2025-05-08T16:06:26Z
author_association: MEMBER
diff_hunk: @@ -766,6 +766,12 @@ void SceneShaderForwardClustered::init(const String p_defines) {
 		actions.render_mode_defines["debug_shadow_splits"] = "#define DEBUG_DRAW_PSSM_SPLITS\n";
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (specular_occlusion) {
+			// If specular_occlusion == false, this will be defined already.
path: servers/rendering/renderer_rd/forward_clustered/scene_shader_forward_clustered.cpp
user: login: clayjohn
body: Same comment as above, but note that if you want to keep the check in, then the compatibility code needs to change to match this (i.e. checking `if (specular_occlusion)` instead of `if (!specular_occlusion)`
created_at: 2025-05-08T16:08:04Z
author_association: MEMBER
diff_hunk: @@ -698,6 +698,11 @@ void SceneShaderForwardMobile::init(const String p_defines) {
 		actions.render_mode_defines["debug_shadow_splits"] = "#define DEBUG_DRAW_PSSM_SPLITS\n";
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (!specular_occlusion) {
path: servers/rendering/renderer_rd/forward_mobile/scene_shader_forward_mobile.cpp
user: login: clayjohn
body: Same comment as the others, note that this would need to be flipped if you choose to keep the check
created_at: 2025-05-08T16:08:34Z
author_association: MEMBER
diff_hunk: @@ -1382,6 +1382,11 @@ MaterialStorage::MaterialStorage() {
 		}
 		actions.render_mode_defines["fog_disabled"] = "#define FOG_DISABLED\n";
 
+		bool specular_occlusion = GLOBAL_GET("rendering/reflections/specular_occlusion/enabled");
+		if (!specular_occlusion) {
path: drivers/gles3/storage/material_storage.cpp
user: login: lander-vr
body: That makes sense, I've removed them!
created_at: 2025-05-08T17:08:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3095,6 +3095,9 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
path: doc/classes/ProjectSettings.xml
user: login: clayjohn
body: Sorry to review again, but I'm starting to think we might want to reverse this to be `force_disable_specular_occlusion`. 

Right now `enabled` means "enabled by default, but can be turned off on a case-by-case basis. While "disabled" means "force disabled, can't be enabled". So implicitly it acts as a force disable rather than something to enable/disable. 

What do you think?
created_at: 2025-05-08T17:27:48Z
author_association: MEMBER
diff_hunk: @@ -3095,6 +3095,9 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
path: doc/classes/ProjectSettings.xml
user: login: lander-vr
body: No worries! :)
From the user perspective (Which I think is what matters most here) it's just a toggle of the feature, if it's not enabled the feature isn't active as a whole (and you cannot enable it on a case-by-case basis). We see the same behavior for things like fog, where you can enable fog and still choose to disable it for individual shaders.

Contextualizing a feature toggle as a force disable to the user end seems a little backwards to me. I feel like it _could_ add an implicit connotation that this is not something you'd want to disable, while it's completely fine to do so.

I'd be in favor of keeping it as an `enabled` checkbox, to me it seems like it'd make the most sense/be the most straightforward from the user's perspective.
created_at: 2025-05-08T18:58:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -3095,6 +3095,9 @@
 		<member name="rendering/reflections/sky_reflections/texture_array_reflections.mobile" type="bool" setter="" getter="" default="false">
 			Lower-end override for [member rendering/reflections/sky_reflections/texture_array_reflections] on mobile devices, due to performance concerns or driver support.
 		</member>
+		<member name="rendering/reflections/specular_occlusion/enabled" type="bool" setter="" getter="" default="true">
path: doc/classes/ProjectSettings.xml
user: login: clayjohn
body: Alright, that makes sense! Let's go ahead as-is
created_at: 2025-05-08T20:46:37Z
author_association: MEMBER
diff_hunk: @@ -182,6 +182,9 @@
 		<member name="disable_receive_shadows" type="bool" setter="set_flag" getter="get_flag" default="false">
 			If [code]true[/code], the object receives no shadow that would otherwise be cast onto it.
 		</member>
+		<member name="disable_specular_occlusion" type="bool" setter="set_flag" getter="get_flag" default="false">
+			If [code]true[/code], disables specular occlusion regardless of [member ProjectSettings.rendering/reflections/specular_occlusion/enabled].
path: doc/classes/BaseMaterial3D.xml
user: login: Calinou
body: ```suggestion
			If [code]true[/code], disables specular occlusion even if [member ProjectSettings.rendering/reflections/specular_occlusion/enabled] is [code]false[/code].
```
created_at: 2025-05-08T21:37:34Z
author_association: MEMBER

PR comments:
user: login: passivestar
created_at: 2025-05-08T01:44:25Z
body: Nice!

| Master | PR |
|--------|--------|
| ![before](https://github.com/user-attachments/assets/24b1c9e0-7012-4bf5-bc6d-69e1f916ca24) | ![after](https://github.com/user-attachments/assets/4329009a-f744-4624-8b7c-284a796528d1) |
| ![before2](https://github.com/user-attachments/assets/d83f5c75-f426-465a-9cd7-ef3f19763cc6) | ![after2](https://github.com/user-attachments/assets/b0431ab2-6a8d-41a4-87a7-15be562a8156) |

_Edit: In my initial test above I was using tonemapper exposure to make the resulting image brighter. However per @lander-vr's suggestion I tried to use camera exposure instead. This brought out reflections on open surfaces more, while corners still remain occluded, which gives a better result:_

![after4](https://github.com/user-attachments/assets/ed138c90-5f1e-4d3a-b80a-61021fe2f76c)



author_association: CONTRIBUTOR
user: login: LiveTrower
created_at: 2025-05-09T01:16:31Z
body: This might sound silly, but I've noticed a strange behavior while testing this PR. When you have a metallic material and you gradually reduce its roughness value, it undergoes a transition where the material abruptly changes from a dark color to a lighter one. This doesn't happen when you disable specular occlusion.

https://github.com/user-attachments/assets/499c505c-d161-4a27-ac10-cda27d2c5404

Is this expected behavior?
author_association: CONTRIBUTOR
user: login: lander-vr
created_at: 2025-05-09T09:25:17Z
body: @LiveTrower Mmmh, I cannot seem to reproduce this/notice any strange behavior.

https://github.com/user-attachments/assets/afe60da3-5c45-4baa-a673-04780496239e

What's happening in your video seems to be expected, I don't think this behavior is unusual. The effect is darkening specular lighting where ambient lighting is darker (In this case on faces with normals facing downwards),
`float reflective_f = (1.0 - roughness) * metallic;` ensures that metallic materials preserve more of their reflection strength, so the effect of specular occlusion is reduced as you decrease the roughness.

I don't think what's shown in your video looks out of the ordinary. Specular occlusion adds a little more depth to reflective surfaces, so it's normal for it to not be an exact match to when it is disabled.

@clayjohn What do you think?
author_association: CONTRIBUTOR
user: login: lander-vr
created_at: 2025-05-09T11:42:22Z
body: After testing a little more, while I still cannot reproduce the exact effect of whats happening in your video, it looks like a culprit may be that fully rough surfaces still receive specular lighting. This seems to be the case both for metallic and non-metallic materials.
As far as I'm aware, a fully rough PBR material, regardless of metalness, should have no specular lighting. It seems like this is not the case for Godots shader. I'm not sure if that is expected due to assumptions being made in the BRDF, or if this is unintentional.

There should be no difference between these two as they're both fully rough:
| Metal 0.0 - Specular 0.0 - Rough 1.0 | Metal 0.0 - Specular 1.0 - Rough 1.0 |
| - | - |
| ![image](https://github.com/user-attachments/assets/6247e90d-7b00-4c3c-9b24-5f9a66ad7b60) | ![image](https://github.com/user-attachments/assets/25036cfb-dad5-4bee-ae3f-5dccd8886420) |

It's not a perfect comparison, but if we compare a fully rough principled shader to a diffuse shader in Blender, we see an exact match:
| Blender Diffuse BSDF | Blender Principled BSDF |
| - | - |
| ![image](https://github.com/user-attachments/assets/968aae13-c825-4b09-8875-e91e2a23ba16) | ![image](https://github.com/user-attachments/assets/b1d297b1-9916-4687-8c20-f3eb529527bf) |

This is also the reason why when LiveTrower toggles the `disable specular occlusion` flag in their video we see a change in shading on their 100% rough material.

Either way, this is not something caused by how specular occlusion is tackled by this PR though. After having tested again with a bunch of different ambient light strengths I think it's safe to say that the feature does behave as expected.
author_association: CONTRIBUTOR
user: login: LiveTrower
created_at: 2025-05-09T15:54:16Z
body: @lander-vr As far as I know, default Godot shaders use diffuse Lambert by default, and StandardMaterials use diffuse Burley by default, so that could explain a different behavior.
author_association: CONTRIBUTOR
user: login: ettiSurreal
created_at: 2025-05-09T16:05:05Z
body: > It's not a perfect comparison, but if we compare a fully rough principled shader to a diffuse shader in Blender, we see an exact match:

It's not an exact match. I will provide a very exaggerated example.
Both materials are on default settings other than roughness being 1.0 on Principled BSDF and color being solid black.
Color Management exposure is also set to 5.0.
| Diffuse BSDF | Principled BSDF |
| ------------- | ------------- |
| ![image](https://github.com/user-attachments/assets/050b81e8-2b29-4aa1-917c-656b8a041c26) | ![image](https://github.com/user-attachments/assets/84a44dde-c961-46aa-a9d4-5e4f93536b80) |
author_association: CONTRIBUTOR
user: login: Repiteo
created_at: 2025-05-09T16:35:15Z
body: Thanks!
author_association: CONTRIBUTOR
user: login: Calinou
created_at: 2025-05-10T16:38:46Z
body: > There should be no difference between these two as they're both fully rough:

See this PR for context as to why some specularity is left by default: https://github.com/godotengine/godot/pull/63587
author_association: MEMBER