Explorar el Código

Fixed Shader reload bug. (#18555)

* Fixed Shader reload bug.

There are three parts to this Pull Request:
1- Bug fix (Shader::OnAssetReloaded)
2- A new level called `ShaderReloadTest`, which contains assets + python
   script to validate the fix.
3- A new feature for O3DE that allows Gems and Game Projects to load custom
   PassTemplate assets without writing C++ code.

To understand the Bug Fix let's review the structure of ShaderAssets:
The `AZ::RPI::Shader`` class owns `AZ::RPI::ShaderAsset m_asset`.
This was the only asset that the Shader class was expecting to be notified
during Shader::OnAssetReloaded.
But, the ShaderAsset references several Root AZ::RPI::ShaderVariantAsset(s),
one for each Supervariant. The structure looks like:
```
AZ::RPI::ShaderAsset m_asset
  |
  |---> AZ::RPI::ShaderVariantAsset (Supervariant 0 Root variant)
  |---> AZ::RPI::ShaderVariantAsset (Supervariant 1 Root variant)
  | ...
  |---> AZ::RPI::ShaderVariantAsset (Supervariant N-1 Root variant)
```
When the user modified the Shader code, the AssetManager would dispatch
an `OnAssetReloaded` event for each asset in the chain shown above... But
the `Shader` class was only listening to `m_asset`. To make matters worst
the AssetManager wouldn't update the internal references to the Root
 ShaderVariantAssets.

The solution is two folds:
1. Register for OnAssetReloaded events for m_asset and for each of its Root
   ShaderVariantAsset(s).
2. When OnAssetReloaded() is called, keep track of all the new asset references
   in a dictionary. And only when the size of the Dictionary matches the total
   number of expected notifications then the Shader calls Init() again, and
   the dictionary is cleared and ready for the next time the user modifies the
   shader code.

In order to validate the bug fix, a new Level called `ShaderReloadTest` was
 added, which is a self contained set of assets and a Python script called
 `shader_reload_test.py`. To learn more about this script, please read the
 attached `README.md`. REMARK: This level is NOT a typical Python Automation
 Test, instead it is expected to be ran manually by engineers whenever they
 suspect that there are issues with Shader reloading.

Finally a new System Component called AZ::RPI::PassTemplatesAutoLoader was
added. It provides a very convenient opt-in service for Gems and Game Projects
to load custom PassTemplates. See `PassTemplatesAutoLoader.h` for details.
The O3DE wiki will be updated with description on how to use this service.

Signed-off-by: galibzon <[email protected]>
galibzon hace 7 meses
padre
commit
34c6e89c76
Se han modificado 22 ficheros con 1810 adiciones y 10 borrados
  1. 20 0
      AutomatedTesting/Assets/Passes/AutomatedTesting/AutoLoadPassTemplates.azasset
  2. 79 0
      AutomatedTesting/Levels/ShaderReloadTest/README.md
  3. 809 0
      AutomatedTesting/Levels/ShaderReloadTest/ShaderReloadTest.prefab
  4. 54 0
      AutomatedTesting/Levels/ShaderReloadTest/SimpleMesh.azsl
  5. 24 0
      AutomatedTesting/Levels/ShaderReloadTest/SimpleMesh.shader
  6. 70 0
      AutomatedTesting/Levels/ShaderReloadTest/SimpleMeshPass.pass
  7. 36 0
      AutomatedTesting/Levels/ShaderReloadTest/SimpleMeshPipeline.pass
  8. 21 0
      AutomatedTesting/Levels/ShaderReloadTest/billboard_visualize_rtt.attimage
  9. 27 0
      AutomatedTesting/Levels/ShaderReloadTest/billboard_visualize_rtt.material
  10. 311 0
      AutomatedTesting/Levels/ShaderReloadTest/shader_reload_test.py
  11. 4 0
      AutomatedTesting/Levels/ShaderReloadTest/simple_mesh.material
  12. 9 0
      AutomatedTesting/Levels/ShaderReloadTest/simple_mesh.materialtype
  13. 2 0
      Code/Editor/Lib/Tests/test_ViewportTitleDlgPythonBindings.cpp
  14. 13 0
      Code/Editor/ViewportTitleDlg.cpp
  15. 16 1
      Gems/Atom/RPI/Code/Include/Atom/RPI.Public/Shader/Shader.h
  16. 7 0
      Gems/Atom/RPI/Code/Include/Atom/RPI.Reflect/Shader/ShaderAsset.h
  17. 4 1
      Gems/Atom/RPI/Code/Source/RPI.Private/Module.cpp
  18. 135 0
      Gems/Atom/RPI/Code/Source/RPI.Private/PassTemplatesAutoLoader.cpp
  19. 81 0
      Gems/Atom/RPI/Code/Source/RPI.Private/PassTemplatesAutoLoader.h
  20. 72 8
      Gems/Atom/RPI/Code/Source/RPI.Public/Shader/Shader.cpp
  21. 14 0
      Gems/Atom/RPI/Code/Source/RPI.Reflect/Shader/ShaderAsset.cpp
  22. 2 0
      Gems/Atom/RPI/Code/atom_rpi_private_files.cmake

+ 20 - 0
AutomatedTesting/Assets/Passes/AutomatedTesting/AutoLoadPassTemplates.azasset

@@ -0,0 +1,20 @@
+{
+    "Type": "JsonSerialization",
+    "Version": 1,
+    "ClassName": "AssetAliasesSourceData",
+    "ClassData": {
+        "AssetPaths": [
+            //////////////////////////////////////////////////////////////
+            // Passes required by ShaderReloadTest Level 
+            {
+                "Name": "SimpleMeshPassTemplate",
+                "Path": "Levels/ShaderReloadTest/SimpleMeshPass.pass"
+            },
+            {
+                "Name": "SimpleMeshPipelineTemplate",
+                "Path": "Levels/ShaderReloadTest/SimpleMeshPipeline.pass"
+            }
+            //////////////////////////////////////////////////////////////
+        ]
+    }
+}

+ 79 - 0
AutomatedTesting/Levels/ShaderReloadTest/README.md

@@ -0,0 +1,79 @@
+# About ShaderReloadTest
+This level, along with the companion python script named `shader_reload_test.py` were created with the only purpose of validating that the engine can properly react to changes to Shader Assets.  
+  
+Historically, OnAssetReloaded() event was not being properly processed by the AZ::RPI::Shader class. Developers would find themselves in the situation of modifying and saving a shader file several times to make sure the rendered viewport would represent the correct state of the Shader files.  
+  
+The script `shader_reload_test.py` is expected to be executed manually. This is not part of the Unit Tests, nor should be executed
+as part of the Automation Tests.  
+  
+# How to use
+1- In the Editor, open this level: `ShaderReloadTest`.  
+2- After the level is loaded, execute the following command in the console:  
+```
+pyRunFile C:\GIT\o3de\AutomatedTesting\Levels\ShaderReloadTest\shader_reload_test.py
+```
+Depending on where the engine was installed, you may change the begginning of the absolute path shown above: `C:\GIT\o3de\AutomatedTesting\...`
+  
+By default, the previous command will run only ONE Shader modification cycle, if you want to run 10 test cycles you can try the `-i`(`--iterations`) argument:
+```
+pyRunFile C:\GIT\o3de\AutomatedTesting\Levels\ShaderReloadTest\shader_reload_test.py -i 10
+```
+  
+# For Power Users
+To get a list of all the options this script supports use the `-h`(`--help`) argument:  
+```
+pyRunFile C:\GIT\o3de\AutomatedTesting\Levels\ShaderReloadTest\shader_reload_test.py --help
+[CONSOLE] Executing console command 'pyRunFile C:\GIT\o3de\AutomatedTesting\Levels\ShaderReloadTest\shader_reload_test.py --help'
+(python_test) - usage: shader_reload_test.py [-h] [-i ITERATIONS]
+                             [--screen_update_wait_time SCREEN_UPDATE_WAIT_TIME]
+                             [--capture_count_on_failure CAPTURE_COUNT_ON_FAILURE]
+                             [-p SCREENSHOT_IMAGE_PATH]
+                             [-q QUICK_SHADER_OVERWRITE_COUNT]
+                             [-w QUICK_SHADER_OVERWRITE_WAIT]
+                             [-c CAMERA_ENTITY_NAME]
+
+Records several frames of pass attachments as image files.
+
+options:
+  -h, --help            show this help message and exit
+  -i ITERATIONS, --iterations ITERATIONS
+                        How many times the Shader should be modified and the
+                        screen pixel validated.
+  --screen_update_wait_time SCREEN_UPDATE_WAIT_TIME
+                        Minimum time to wait after modifying the shader and
+                        taking the screen snapshot to validate color output.
+  --capture_count_on_failure CAPTURE_COUNT_ON_FAILURE
+                        How many times the screen should be recaptured if the
+                        pixel output failes.
+  -p SCREENSHOT_IMAGE_PATH, --screenshot_image_path SCREENSHOT_IMAGE_PATH
+                        Absolute path of the file where the screenshot will be
+                        written to. Must include image extensions 'ppm',
+                        'png', 'bmp', 'tif'. By default a temporary png path
+                        will be created
+  -q QUICK_SHADER_OVERWRITE_COUNT, --quick_shader_overwrite_count QUICK_SHADER_OVERWRITE_COUNT
+                        How many times the shader should be overwritten before
+                        capturing the screenshot. This simulates real life
+                        cases where a shader file is updated and saved to the
+                        file system several times consecutively
+  -w QUICK_SHADER_OVERWRITE_WAIT, --quick_shader_overwrite_wait QUICK_SHADER_OVERWRITE_WAIT
+                        Minimum time to wait in between quick shader
+                        overwrites.
+  -c CAMERA_ENTITY_NAME, --camera_entity_name CAMERA_ENTITY_NAME
+                        Name of the entity that contains a Camera Component.
+                        If found, the Editor camera will be set to it before
+                        starting the test.
+```  
+  
+# Technical Details
+The level named `ShaderReloadTest` looks like the `Default Atom` level, but contains Three aditional entities, all of them working together using the Render To Texture technique. The Texture asset is called `billboard_visualize_rtt.attimage`:  
+
+1- `Billboard` Entity: This entity presents in a flat Mesh the rendering result of a custom Render Pipeline named `SimpleMeshPipeline`. The `Base Color` Texture in its material (`billboard_visualize_rtt.material`) points to `billboard_visualize_rtt.attimage`.  
+2- `RTT Camera` Entity: This entity holds a Camera that Renders To Texture, it uses the ustom Render Pipeline named `SimpleMeshPipeline` to render into the Texture Asset named `billboard_visualize_rtt.attimage`.  
+3- `Shader Ball Simple Pipeline` Entity: This entity has the `Shader Ball` mesh with a custom material named `simple_mesh.material`. Any object that uses this Material will be visible under the  `SimpleMeshPipeline`, with a simple color Red, Green or Blue.  
+  
+## Why A Custom Render Pipeline For This Test?
+The idea is to be able to change Shader code and wait the least amount of time for the Asset Processor to complete the compilation. When you modify a Shader file related to the Main Render Pipeline it may trigger a chain reaction of around 500 files to be reprocessed. On the other hand, by having a custom Render Pipeline, along with a custom material type (`simple_mesh.materialtype`) which references only one Shader (`SimpleMesh.shader`) it only take a less than 3 seconds to compile the Shader and see color updates in the screen.  
+  
+## Why All Assets Are Placed under `Levels\ShaderReloadTest`?
+Because it makes the whole test suite self contained and easy to port to other Game Projects.  
+The only asset related to this Level, that was added outside of this folder is `Assets/Passes/AutomatedTesting/AutoLoadPassTemplates.azsset`. In the future, if more custom passes and/or pipelines need to be added by other Test Suites, they can be added to this file.  

+ 809 - 0
AutomatedTesting/Levels/ShaderReloadTest/ShaderReloadTest.prefab

@@ -0,0 +1,809 @@
+{
+    "ContainerEntity": {
+        "Id": "Entity_[1146574390643]",
+        "Name": "Level",
+        "Components": {
+            "Component_[10641544592923449938]": {
+                "$type": "EditorInspectorComponent",
+                "Id": 10641544592923449938
+            },
+            "Component_[12039882709170782873]": {
+                "$type": "EditorOnlyEntityComponent",
+                "Id": 12039882709170782873
+            },
+            "Component_[12265484671603697631]": {
+                "$type": "EditorPendingCompositionComponent",
+                "Id": 12265484671603697631
+            },
+            "Component_[14126657869720434043]": {
+                "$type": "EditorEntitySortComponent",
+                "Id": 14126657869720434043,
+                "Child Entity Order": [
+                    "Entity_[1176639161715]",
+                    "Entity_[697889136307]",
+                    "Entity_[499622636480]",
+                    "Entity_[762313645747]"
+                ]
+            },
+            "Component_[15230859088967841193]": {
+                "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                "Id": 15230859088967841193,
+                "Parent Entity": ""
+            },
+            "Component_[16239496886950819870]": {
+                "$type": "EditorDisabledCompositionComponent",
+                "Id": 16239496886950819870
+            },
+            "Component_[5688118765544765547]": {
+                "$type": "EditorEntityIconComponent",
+                "Id": 5688118765544765547
+            },
+            "Component_[7247035804068349658]": {
+                "$type": "EditorPrefabComponent",
+                "Id": 7247035804068349658
+            },
+            "Component_[9307224322037797205]": {
+                "$type": "EditorLockComponent",
+                "Id": 9307224322037797205
+            },
+            "Component_[9562516168917670048]": {
+                "$type": "EditorVisibilityComponent",
+                "Id": 9562516168917670048
+            },
+            "LocalViewBookmarkComponent": {
+                "$type": "LocalViewBookmarkComponent",
+                "Id": 8201633998829772483,
+                "LocalBookmarkFileName": "ShaderReloadTest_17338497290764052.setreg"
+            }
+        }
+    },
+    "Entities": {
+        "Entity_[1155164325235]": {
+            "Id": "Entity_[1155164325235]",
+            "Name": "Sun",
+            "Components": {
+                "Component_[13620450453324765907]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 13620450453324765907
+                },
+                "Component_[2134313378593666258]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 2134313378593666258
+                },
+                "Component_[234010807770404186]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 234010807770404186
+                },
+                "Component_[2970359110423865725]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 2970359110423865725
+                },
+                "Component_[3722854130373041803]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 3722854130373041803
+                },
+                "Component_[5992533738676323195]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 5992533738676323195
+                },
+                "Component_[7378860763541895402]": {
+                    "$type": "AZ::Render::EditorDirectionalLightComponent",
+                    "Id": 7378860763541895402,
+                    "Controller": {
+                        "Configuration": {
+                            "Intensity": 1.0,
+                            "CameraEntityId": "",
+                            "ShadowFilterMethod": 1
+                        }
+                    }
+                },
+                "Component_[7892834440890947578]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 7892834440890947578,
+                    "Parent Entity": "Entity_[1176639161715]",
+                    "Transform Data": {
+                        "Translate": [
+                            0.0,
+                            0.0,
+                            13.487043380737305
+                        ],
+                        "Rotate": [
+                            -76.13099670410156,
+                            -0.847000002861023,
+                            -15.8100004196167
+                        ]
+                    }
+                },
+                "Component_[8599729549570828259]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 8599729549570828259
+                },
+                "Component_[952797371922080273]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 952797371922080273
+                }
+            }
+        },
+        "Entity_[1159459292531]": {
+            "Id": "Entity_[1159459292531]",
+            "Name": "Ground",
+            "Components": {
+                "Component_[12260880513256986252]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 12260880513256986252
+                },
+                "Component_[13711420870643673468]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 13711420870643673468
+                },
+                "Component_[138002849734991713]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 138002849734991713
+                },
+                "Component_[16578565737331764849]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 16578565737331764849,
+                    "Parent Entity": "Entity_[1176639161715]"
+                },
+                "Component_[16919232076966545697]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 16919232076966545697
+                },
+                "Component_[4228479570410194639]": {
+                    "$type": "EditorStaticRigidBodyComponent",
+                    "Id": 4228479570410194639
+                },
+                "Component_[5182430712893438093]": {
+                    "$type": "EditorMaterialComponent",
+                    "Id": 5182430712893438093
+                },
+                "Component_[5245524694917323904]": {
+                    "$type": "EditorColliderComponent",
+                    "Id": 5245524694917323904,
+                    "ColliderConfiguration": {
+                        "Position": [
+                            0.0,
+                            0.0,
+                            -0.5
+                        ],
+                        "MaterialSlots": {
+                            "Slots": [
+                                {
+                                    "Name": "Entire object"
+                                }
+                            ]
+                        }
+                    },
+                    "ShapeConfiguration": {
+                        "ShapeType": 1,
+                        "Box": {
+                            "Configuration": [
+                                512.0,
+                                512.0,
+                                1.0
+                            ]
+                        }
+                    },
+                    "DebugDrawSettings": {
+                        "LocallyEnabled": false
+                    }
+                },
+                "Component_[5675108321710651991]": {
+                    "$type": "AZ::Render::EditorMeshComponent",
+                    "Id": 5675108321710651991,
+                    "Controller": {
+                        "Configuration": {
+                            "ModelAsset": {
+                                "assetId": {
+                                    "guid": "{0CD745C0-6AA8-569A-A68A-73A3270986C4}",
+                                    "subId": 277889906
+                                },
+                                "assetHint": "objects/groudplane/groundplane_512x512m.fbx.azmodel"
+                            }
+                        }
+                    }
+                },
+                "Component_[5681893399601237518]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 5681893399601237518
+                },
+                "Component_[592692962543397545]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 592692962543397545
+                },
+                "Component_[7090012899106946164]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 7090012899106946164
+                },
+                "Component_[9410832619875640998]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 9410832619875640998
+                }
+            }
+        },
+        "Entity_[1163754259827]": {
+            "Id": "Entity_[1163754259827]",
+            "Name": "Camera",
+            "Components": {
+                "Component_[11895140916889160460]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 11895140916889160460
+                },
+                "Component_[16880285896855930892]": {
+                    "$type": "{CA11DA46-29FF-4083-B5F6-E02C3A8C3A3D} EditorCameraComponent",
+                    "Id": 16880285896855930892,
+                    "Controller": {
+                        "Configuration": {
+                            "Field of View": 55.0
+                        }
+                    }
+                },
+                "Component_[17187464423780271193]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 17187464423780271193
+                },
+                "Component_[17495696818315413311]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 17495696818315413311
+                },
+                "Component_[18086214374043522055]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 18086214374043522055,
+                    "Parent Entity": "Entity_[1176639161715]",
+                    "Transform Data": {
+                        "Translate": [
+                            0.0,
+                            -1.0,
+                            2.0
+                        ]
+                    }
+                },
+                "Component_[2654521436129313160]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 2654521436129313160
+                },
+                "Component_[5265045084611556958]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 5265045084611556958
+                },
+                "Component_[7169798125182238623]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 7169798125182238623
+                },
+                "Component_[7255796294953281766]": {
+                    "$type": "GenericComponentWrapper",
+                    "Id": 7255796294953281766,
+                    "m_template": {
+                        "$type": "FlyCameraInputComponent"
+                    }
+                },
+                "Component_[8866210352157164042]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 8866210352157164042
+                },
+                "Component_[9129253381063760879]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 9129253381063760879
+                }
+            }
+        },
+        "Entity_[1168049227123]": {
+            "Id": "Entity_[1168049227123]",
+            "Name": "Grid",
+            "Components": {
+                "Component_[11443347433215807130]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 11443347433215807130
+                },
+                "Component_[14249419413039427459]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 14249419413039427459
+                },
+                "Component_[15448581635946161318]": {
+                    "$type": "AZ::Render::EditorGridComponent",
+                    "Id": 15448581635946161318,
+                    "Controller": {
+                        "Configuration": {
+                            "primarySpacing": 4.0,
+                            "primaryColor": [
+                                0.501960813999176,
+                                0.501960813999176,
+                                0.501960813999176
+                            ],
+                            "secondarySpacing": 0.5,
+                            "secondaryColor": [
+                                0.250980406999588,
+                                0.250980406999588,
+                                0.250980406999588
+                            ]
+                        }
+                    }
+                },
+                "Component_[1843303322527297409]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 1843303322527297409
+                },
+                "Component_[380249072065273654]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 380249072065273654,
+                    "Parent Entity": "Entity_[1176639161715]"
+                },
+                "Component_[7476660583684339787]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 7476660583684339787
+                },
+                "Component_[7557626501215118375]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 7557626501215118375
+                },
+                "Component_[7984048488947365511]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 7984048488947365511
+                },
+                "Component_[8118181039276487398]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 8118181039276487398
+                },
+                "Component_[9189909764215270515]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 9189909764215270515
+                }
+            }
+        },
+        "Entity_[1172344194419]": {
+            "Id": "Entity_[1172344194419]",
+            "Name": "Shader Ball",
+            "Components": {
+                "Component_[10789351944715265527]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 10789351944715265527
+                },
+                "Component_[12037033284781049225]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 12037033284781049225
+                },
+                "Component_[13759153306105970079]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 13759153306105970079
+                },
+                "Component_[14135560884830586279]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 14135560884830586279
+                },
+                "Component_[16247165675903986673]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 16247165675903986673
+                },
+                "Component_[18082433625958885247]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 18082433625958885247
+                },
+                "Component_[6472623349872972660]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 6472623349872972660,
+                    "Parent Entity": "Entity_[1176639161715]",
+                    "Transform Data": {
+                        "Rotate": [
+                            0.0,
+                            0.10000000149011612,
+                            180.0
+                        ]
+                    }
+                },
+                "Component_[6495255223970673916]": {
+                    "$type": "AZ::Render::EditorMeshComponent",
+                    "Id": 6495255223970673916,
+                    "Controller": {
+                        "Configuration": {
+                            "ModelAsset": {
+                                "assetId": {
+                                    "guid": "{FD340C30-755C-5911-92A3-19A3F7A77931}",
+                                    "subId": 281415304
+                                },
+                                "assetHint": "objects/shaderball/shaderball_default_1m.azmodel"
+                            }
+                        }
+                    }
+                },
+                "Component_[8550141614185782969]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 8550141614185782969
+                },
+                "Component_[9439770997198325425]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 9439770997198325425
+                }
+            }
+        },
+        "Entity_[1176639161715]": {
+            "Id": "Entity_[1176639161715]",
+            "Name": "Atom Default Environment",
+            "Components": {
+                "Component_[10757302973393310045]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 10757302973393310045,
+                    "Parent Entity": "Entity_[1146574390643]"
+                },
+                "Component_[14505817420424255464]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 14505817420424255464,
+                    "ComponentOrderEntryArray": [
+                        {
+                            "ComponentId": 10757302973393310045
+                        }
+                    ]
+                },
+                "Component_[14988041764659020032]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 14988041764659020032
+                },
+                "Component_[15900837685796817138]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 15900837685796817138
+                },
+                "Component_[3298767348226484884]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 3298767348226484884
+                },
+                "Component_[4076975109609220594]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 4076975109609220594
+                },
+                "Component_[5679760548946028854]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 5679760548946028854
+                },
+                "Component_[5855590796136709437]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 5855590796136709437,
+                    "Child Entity Order": [
+                        "Entity_[1155164325235]",
+                        "Entity_[1180934129011]",
+                        "Entity_[1172344194419]",
+                        "Entity_[1168049227123]",
+                        "Entity_[1163754259827]",
+                        "Entity_[1159459292531]"
+                    ]
+                },
+                "Component_[9277695270015777859]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 9277695270015777859
+                }
+            }
+        },
+        "Entity_[1180934129011]": {
+            "Id": "Entity_[1180934129011]",
+            "Name": "Global Sky",
+            "Components": {
+                "Component_[11231930600558681245]": {
+                    "$type": "AZ::Render::EditorHDRiSkyboxComponent",
+                    "Id": 11231930600558681245,
+                    "Controller": {
+                        "Configuration": {
+                            "CubemapAsset": {
+                                "assetId": {
+                                    "guid": "{215E47FD-D181-5832-B1AB-91673ABF6399}",
+                                    "subId": 1000
+                                },
+                                "assetHint": "lightingpresets/highcontrast/goegap_4k_skyboxcm.exr.streamingimage"
+                            }
+                        }
+                    }
+                },
+                "Component_[1428633914413949476]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 1428633914413949476
+                },
+                "Component_[14936200426671614999]": {
+                    "$type": "AZ::Render::EditorImageBasedLightComponent",
+                    "Id": 14936200426671614999,
+                    "Controller": {
+                        "Configuration": {
+                            "diffuseImageAsset": {
+                                "assetId": {
+                                    "guid": "{3FD09945-D0F2-55C8-B9AF-B2FD421FE3BE}",
+                                    "subId": 3000
+                                },
+                                "assetHint": "lightingpresets/highcontrast/goegap_4k_iblglobalcm_ibldiffuse.exr.streamingimage"
+                            },
+                            "specularImageAsset": {
+                                "assetId": {
+                                    "guid": "{3FD09945-D0F2-55C8-B9AF-B2FD421FE3BE}",
+                                    "subId": 2000
+                                },
+                                "assetHint": "lightingpresets/highcontrast/goegap_4k_iblglobalcm_iblspecular.exr.streamingimage"
+                            }
+                        }
+                    }
+                },
+                "Component_[14994774102579326069]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 14994774102579326069
+                },
+                "Component_[15417479889044493340]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 15417479889044493340
+                },
+                "Component_[15826613364991382688]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 15826613364991382688
+                },
+                "Component_[1665003113283562343]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 1665003113283562343
+                },
+                "Component_[3704934735944502280]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 3704934735944502280
+                },
+                "Component_[5698542331457326479]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 5698542331457326479
+                },
+                "Component_[6644513399057217122]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 6644513399057217122,
+                    "Parent Entity": "Entity_[1176639161715]"
+                },
+                "Component_[931091830724002070]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 931091830724002070
+                }
+            }
+        },
+        "Entity_[499622636480]": {
+            "Id": "Entity_[499622636480]",
+            "Name": "Shader Ball Simple Pipeline",
+            "Components": {
+                "Component_[10789351944715265527]": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 10789351944715265527
+                },
+                "Component_[12037033284781049225]": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 12037033284781049225
+                },
+                "Component_[13759153306105970079]": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 13759153306105970079
+                },
+                "Component_[14135560884830586279]": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 14135560884830586279
+                },
+                "Component_[16247165675903986673]": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 16247165675903986673
+                },
+                "Component_[18082433625958885247]": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 18082433625958885247
+                },
+                "Component_[6472623349872972660]": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 6472623349872972660,
+                    "Parent Entity": "Entity_[1146574390643]",
+                    "Transform Data": {
+                        "Rotate": [
+                            0.0,
+                            0.10000000149011612,
+                            180.0
+                        ]
+                    }
+                },
+                "Component_[6495255223970673916]": {
+                    "$type": "AZ::Render::EditorMeshComponent",
+                    "Id": 6495255223970673916,
+                    "Controller": {
+                        "Configuration": {
+                            "ModelAsset": {
+                                "assetId": {
+                                    "guid": "{FD340C30-755C-5911-92A3-19A3F7A77931}",
+                                    "subId": 281415304
+                                },
+                                "assetHint": "objects/shaderball/shaderball_default_1m.fbx.azmodel"
+                            }
+                        }
+                    }
+                },
+                "Component_[8550141614185782969]": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 8550141614185782969
+                },
+                "Component_[9439770997198325425]": {
+                    "$type": "EditorLockComponent",
+                    "Id": 9439770997198325425
+                },
+                "EditorMaterialComponent": {
+                    "$type": "EditorMaterialComponent",
+                    "Id": 8792075311785516254,
+                    "Controller": {
+                        "Configuration": {
+                            "materials": [
+                                {
+                                    "Key": {
+                                        "materialSlotStableId": 3716062507
+                                    },
+                                    "Value": {
+                                        "MaterialAsset": {
+                                            "assetId": {
+                                                "guid": "{A2BEE861-DE26-539E-9BF7-2734D59BC051}"
+                                            },
+                                            "assetHint": "levels/shaderreloadtest/simple_mesh.azmaterial"
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                }
+            }
+        },
+        "Entity_[697889136307]": {
+            "Id": "Entity_[697889136307]",
+            "Name": "Billboard",
+            "Components": {
+                "AZ::Render::EditorMeshComponent": {
+                    "$type": "AZ::Render::EditorMeshComponent",
+                    "Id": 12572874725164207156,
+                    "Controller": {
+                        "Configuration": {
+                            "ModelAsset": {
+                                "assetId": {
+                                    "guid": "{E65E9ED3-3E38-5ABA-9E22-95E34DA4C3AE}",
+                                    "subId": 280178048
+                                },
+                                "assetHint": "objects/plane.fbx.azmodel"
+                            }
+                        }
+                    }
+                },
+                "EditorDisabledCompositionComponent": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 2049210613040809400
+                },
+                "EditorEntityIconComponent": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 8767708722047716714
+                },
+                "EditorEntitySortComponent": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 10433889388491226237
+                },
+                "EditorInspectorComponent": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 15374788991867137193
+                },
+                "EditorLockComponent": {
+                    "$type": "EditorLockComponent",
+                    "Id": 9708938003199330964
+                },
+                "EditorMaterialComponent": {
+                    "$type": "EditorMaterialComponent",
+                    "Id": 3978890222431028414,
+                    "Controller": {
+                        "Configuration": {
+                            "materials": [
+                                {
+                                    "Key": {
+                                        "materialSlotStableId": 902256226
+                                    },
+                                    "Value": {
+                                        "MaterialAsset": {
+                                            "assetId": {
+                                                "guid": "{21D3FAD5-B12A-5209-9B4E-B5F8542FB906}"
+                                            },
+                                            "assetHint": "levels/shaderreloadtest/billboard_visualize_rtt.azmaterial"
+                                        },
+                                        "PropertyOverrides": {
+                                            "baseColor.textureMap": {
+                                                "$type": "AssetId",
+                                                "Value": {
+                                                    "guid": "{9662B3E7-8AEA-5C28-8A48-D659A68929DC}"
+                                                }
+                                            }
+                                        }
+                                    }
+                                }
+                            ]
+                        }
+                    }
+                },
+                "EditorOnlyEntityComponent": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 2774212051242505881
+                },
+                "EditorPendingCompositionComponent": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 3145368490915592884
+                },
+                "EditorVisibilityComponent": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 12731054138024196490
+                },
+                "TransformComponent": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 17527923803740433782,
+                    "Parent Entity": "Entity_[1146574390643]",
+                    "Transform Data": {
+                        "Translate": [
+                            0.0,
+                            0.0,
+                            2.0
+                        ],
+                        "Rotate": [
+                            89.99999237060547,
+                            0.0,
+                            -180.0
+                        ]
+                    }
+                }
+            }
+        },
+        "Entity_[762313645747]": {
+            "Id": "Entity_[762313645747]",
+            "Name": "RTT Camera",
+            "Components": {
+                "EditorCameraComponent": {
+                    "$type": "{CA11DA46-29FF-4083-B5F6-E02C3A8C3A3D} EditorCameraComponent",
+                    "Id": 15407199020367077062,
+                    "Controller": {
+                        "Configuration": {
+                            "RenderToTexture": {
+                                "assetId": {
+                                    "guid": "{9662B3E7-8AEA-5C28-8A48-D659A68929DC}"
+                                },
+                                "assetHint": "levels/shaderreloadtest/billboard_visualize_rtt.attimage"
+                            },
+                            "PipelineTemplate": "SimpleMeshPipelineTemplate"
+                        }
+                    }
+                },
+                "EditorDisabledCompositionComponent": {
+                    "$type": "EditorDisabledCompositionComponent",
+                    "Id": 7240730447703738116
+                },
+                "EditorEntityIconComponent": {
+                    "$type": "EditorEntityIconComponent",
+                    "Id": 2159960220969934884
+                },
+                "EditorEntitySortComponent": {
+                    "$type": "EditorEntitySortComponent",
+                    "Id": 4374852188315041882
+                },
+                "EditorInspectorComponent": {
+                    "$type": "EditorInspectorComponent",
+                    "Id": 9738900035991710312
+                },
+                "EditorLockComponent": {
+                    "$type": "EditorLockComponent",
+                    "Id": 1372682394445022290
+                },
+                "EditorOnlyEntityComponent": {
+                    "$type": "EditorOnlyEntityComponent",
+                    "Id": 66580470534316952
+                },
+                "EditorPendingCompositionComponent": {
+                    "$type": "EditorPendingCompositionComponent",
+                    "Id": 9257311930242844718
+                },
+                "EditorVisibilityComponent": {
+                    "$type": "EditorVisibilityComponent",
+                    "Id": 3242155584068297903
+                },
+                "TransformComponent": {
+                    "$type": "{27F1E1A1-8D9D-4C3B-BD3A-AFB9762449C0} TransformComponent",
+                    "Id": 4381083404231325115,
+                    "Parent Entity": "Entity_[1146574390643]",
+                    "Transform Data": {
+                        "Translate": [
+                            0.0,
+                            -1.25,
+                            0.5299999713897705
+                        ]
+                    }
+                }
+            }
+        }
+    }
+}

+ 54 - 0
AutomatedTesting/Levels/ShaderReloadTest/SimpleMesh.azsl

@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+#include <scenesrg.srgi>
+#include <viewsrg.srgi>
+// To get the world matrix shader constant for the current object.
+#include <Atom/Features/PBR/DefaultObjectSrg.azsli>
+
+struct VSInput
+{
+    float3 m_position : POSITION;
+    float3 m_normal : NORMAL;
+    // For the complete list of supported input stream semantics see ModelAssetBuilderComponent::CreateMesh()
+};
+
+struct VSOutput
+{
+    float4 m_position : SV_Position;
+    float3 m_normal: NORMAL;
+};
+
+VSOutput MainVS(VSInput IN)
+{
+    VSOutput OUT;
+
+    float3 worldPosition = mul(ObjectSrg::GetWorldMatrix(), float4(IN.m_position, 1.0)).xyz;
+    OUT.m_position = mul(ViewSrg::m_viewProjectionMatrix, float4(worldPosition, 1.0));
+    OUT.m_normal = IN.m_normal;
+
+    return OUT;
+}
+
+struct PSOutput
+{
+    float4 m_color : SV_Target0;
+};
+
+PSOutput MainPS(VSOutput IN)
+{
+    PSOutput OUT;
+    const float3 RED_COLOR = float3(1, 0, 0);
+    const float3 GREEN_COLOR = float3(0, 1, 0);
+    const float3 BLUE_COLOR = float3(0, 0, 1);
+    // ShaderReloadTest START
+    const float3 TEST_COLOR = BLUE_COLOR;
+    // ShaderReloadTest END
+    OUT.m_color = float4(TEST_COLOR, 1.0);
+    return OUT;
+}

+ 24 - 0
AutomatedTesting/Levels/ShaderReloadTest/SimpleMesh.shader

@@ -0,0 +1,24 @@
+{
+    "Source" : "SimpleMesh.azsl",
+
+    "DepthStencilState" : { 
+        "Depth" : { "Enable" : true, "CompareFunc" : "GreaterEqual" }
+    },
+
+    "DrawList" : "simplepass",
+
+    "ProgramSettings":
+    {
+      "EntryPoints":
+      [
+        {
+          "name": "MainVS",
+          "type": "Vertex"
+        },
+        {
+          "name": "MainPS",
+          "type": "Fragment"
+        }
+      ]
+    }
+}

+ 70 - 0
AutomatedTesting/Levels/ShaderReloadTest/SimpleMeshPass.pass

@@ -0,0 +1,70 @@
+{
+    "Type": "JsonSerialization",
+    "Version": 1,
+    "ClassName": "PassAsset",
+    "ClassData": {
+        "PassTemplate": {
+            "Name": "SimpleMeshPassTemplate",
+            "PassClass": "RasterPass",
+            "Slots": [
+                {
+                    "Name": "DepthOutput",
+                    "SlotType": "Output",
+                    "ScopeAttachmentUsage": "DepthStencil",
+                    "LoadStoreAction": {
+                        "ClearValue": {
+                            "Type": "DepthStencil"
+                        },
+                        "LoadAction": "Clear",
+                        "LoadActionStencil": "Clear"
+                    }
+                },
+                {
+                    "Name": "LightingOutput",
+                    "SlotType": "Output",
+                    "ScopeAttachmentUsage": "RenderTarget",
+                    "LoadStoreAction": {
+                        "ClearValue": {
+                            "Value": [
+                                0.7,
+                                0.0,
+                                0.0,
+                                0.0
+                            ]
+                        },
+                        "LoadAction": "Clear"
+                    }
+                }
+            ],
+            "ImageAttachments": [
+                {
+                    "Name": "DepthAttachment",
+                    "SizeSource": {
+                        "Source": {
+                            "Pass": "Parent",
+                            "Attachment": "PipelineOutput"
+                        }
+                    },
+                    "ImageDescriptor": {
+                        "Format": "D32_FLOAT_S8X24_UINT",
+                        "SharedQueueMask": "Graphics"
+                    }
+                }
+            ],
+            "Connections": [
+                {
+                    "LocalSlot": "DepthOutput",
+                    "AttachmentRef": {
+                        "Pass": "This",
+                        "Attachment": "DepthAttachment"
+                    }
+                }
+            ],
+            "PassData": {
+                "$type": "RasterPassData",
+                "DrawListTag": "simplepass",
+                "BindViewSrg": true
+            }
+        }
+    }
+}

+ 36 - 0
AutomatedTesting/Levels/ShaderReloadTest/SimpleMeshPipeline.pass

@@ -0,0 +1,36 @@
+{
+    "Type": "JsonSerialization",
+    "Version": 1,
+    "ClassName": "PassAsset",
+    "ClassData": {
+        "PassTemplate": {
+            "Name": "SimpleMeshPipelineTemplate",
+            "PassClass": "ParentPass",
+            "Slots": [
+                {
+                    // The Slot name must be exactly "PipelineOutput" because this the Parent Pass that
+                    // Describes a Render Pipeline and the C++ code looks for a PassSlotBinding
+                    // with this name, which will be connected to the SwapChain.
+                    "Name": "PipelineOutput",
+                    "SlotType": "InputOutput"
+                }
+            ],
+            "PassRequests": [     
+                {
+                    "Name": "SimpleMeshPass",
+                    "TemplateName": "SimpleMeshPassTemplate",
+                    "Enabled": true,
+                    "Connections": [
+                        {
+                            "LocalSlot": "LightingOutput",
+                            "AttachmentRef": {
+                                "Pass": "Parent",
+                                "Attachment": "PipelineOutput"
+                            }
+                        }
+                    ]
+                }
+            ]
+        }
+    }
+}

+ 21 - 0
AutomatedTesting/Levels/ShaderReloadTest/billboard_visualize_rtt.attimage

@@ -0,0 +1,21 @@
+{
+    "Type": "JsonSerialization",
+    "Version": 1,
+    "ClassName": "AttachmentImageAsset",
+    "ClassData": {
+        "m_imageDescriptor": {
+            "BindFlags": [
+                "ShaderRead",
+                "ShaderWrite",
+                "Color"
+            ],
+            "Size": {
+                "Width": 512,
+                "Height": 512
+            },
+            "Format": 19 //AZ::RHI::Format::R8G8B8A8_UNORM
+        },
+        "Name": "$BillboardVisualizeRtt",
+        "IsUniqueName": true
+    }
+}

+ 27 - 0
AutomatedTesting/Levels/ShaderReloadTest/billboard_visualize_rtt.material

@@ -0,0 +1,27 @@
+{
+    "materialType": "@gemroot:Atom_Feature_Common@/Assets/Materials/Types/StandardPBR.materialtype",
+    "materialTypeVersion": 5,
+    "propertyValues": {
+        "baseColor.color": [
+            0.800000011920929,
+            0.800000011920929,
+            0.800000011920929,
+            1.0
+        ],
+        // Commenting this out because it causes the `AssetProcessor` to never
+        // complete the compilation of this material. The solution is to run
+        // the Editor and open the `Material Instance Editor` and pick this
+        // same attachment image for the `baseColor.textureMap`. The actual
+        // GUID of the asset is stored in `ShaderLoadTest.prefab` (which is
+        // how Material Instance Properties are stored).
+        // "baseColor.textureMap": "Levels/ShaderReloadTest/billboard_visualize_rtt.attimage",
+        "emissive.color": [
+            0.0,
+            0.0,
+            0.0,
+            1.0
+        ],
+        "opacity.factor": 1.0,
+        "roughness.factor": 0.5527864098548889
+    }
+}

+ 311 - 0
AutomatedTesting/Levels/ShaderReloadTest/shader_reload_test.py

@@ -0,0 +1,311 @@
+#
+# Copyright (c) Contributors to the Open 3D Engine Project.
+# For complete copyright and license terms please see the LICENSE at the root of this distribution.
+#
+# SPDX-License-Identifier: Apache-2.0 OR MIT
+#
+#
+import os
+import numpy as np
+import tempfile
+from PIL import Image
+
+import azlmbr.bus as azbus
+import azlmbr.legacy.general as azgeneral
+import azlmbr.editor as azeditor
+import azlmbr.atom as azatom
+import azlmbr.entity as azentity
+import azlmbr.camera as azcamera
+
+# See README.md for details.
+
+# Some global constants
+EXPECTED_LEVEL_NAME = "ShaderReloadTest"
+SUPPORTED_IMAGE_FILE_EXTENSIONS = {".ppm", ".bmp", ".tiff", ".tif", ".png"}
+
+def OverwriteFile(azslFilePath: str, begin: list, middle: list, end: list):
+    fileObj = open(azslFilePath, "w")
+    fileObj.writelines(begin)
+    fileObj.writelines(middle)
+    fileObj.writelines(end)
+    fileObj.close()
+
+
+def FlipTestColorLine(line: str) -> tuple[tuple[int, int, int], str]:
+    """
+    Flips/toggles "BLUE_COLOR" for "GREEN_COLOR" in @line
+    and viceversa. Also retuns the expected pixel color in addition
+    to the modified line.
+    """
+    if "BLUE_COLOR" in line:
+        expectedColor = "GREEN_COLOR"
+        newLine = line.replace("BLUE_COLOR", expectedColor)
+        return (0, 152, 15), newLine
+    elif "GREEN_COLOR" in line:
+        expectedColor = "BLUE_COLOR"
+        newLine = line.replace("GREEN_COLOR", expectedColor)
+        return (0, 1, 155), newLine
+    raise Exception("Can't find color")
+
+
+def FlipShaderColor(azslFilePath: str) -> tuple[int, int, int]:
+    """
+    Modifies the Shader code in @azslFilePath by flipping the line
+    that outputs the expected color.
+    If the line is found as:
+        const float3 TEST_COLOR = BLUE_COLOR;
+    It gets flipped to:
+        const float3 TEST_COLOR = GREEN_COLOR;
+    and viceversa.
+    """
+    begin = []
+    middle = []
+    end = []
+    fileObj = open(azslFilePath, 'rt')
+    sectionStart = False
+    sectionEnd = False
+    expectedColor = ""
+    for line in fileObj:
+        if ("ShaderReloadTest" in line):
+            if ("START" in line):
+                middle.append(line)
+                sectionStart = True
+                continue
+            elif ("END" in line):
+                middle.append(line)
+                sectionEnd = True
+                continue
+        if (not sectionStart) and (not sectionEnd):
+            begin.append(line)
+            continue
+        if sectionEnd:
+            end.append(line)
+            continue
+        if "TEST_COLOR" in line:
+            expectedColor, newLine = FlipTestColorLine(line)
+            middle.append(newLine)
+
+    fileObj.close()
+    OverwriteFile(azslFilePath, begin, middle, end)
+    return expectedColor
+    
+
+def UpdateShaderAndTestPixelResult(azslFilePath: str, screenUpdateWaitTime: float, captureCountOnFailure: int, screenshotImagePath: str, quickShaderOverwriteCount: int, quickShaderOverwriteWait: float) -> bool:
+    """
+    This function represents the work done for a single iteration of the shader modification test.
+    Modifies the content of the Shader (@azslFilePath), waits @screenUpdateWaitTime, and captures
+    the pixels of the Editor Viewport.  
+    Some leniency was added with the variable @captureCountOnFailure because sometimes @@screenUpdateWaitTime is not
+    enought time for the screen to update. This function retries @captureCountOnFailure times before
+    considering it a failure.
+    """
+    expectedPixelColor = FlipShaderColor(azslFilePath)
+    print(f"Expecting color {expectedPixelColor}")
+    for overwriteCount in range(quickShaderOverwriteCount):
+        azgeneral.idle_wait(quickShaderOverwriteWait)
+        expectedPixelColor = FlipShaderColor(azslFilePath)
+        print(f"Shader quickly overwritten {overwriteCount + 1} of {quickShaderOverwriteCount}")
+        print(f"Expecting color {expectedPixelColor}")
+    azgeneral.idle_wait(screenUpdateWaitTime)
+    # Capture the screenshot
+    captureCount = -1
+    success = False
+    while captureCount < captureCountOnFailure:
+        captureCount += 1
+        outcome = azatom.FrameCaptureRequestBus(
+                    azbus.Broadcast, "CaptureScreenshot", screenshotImagePath
+                )
+        if not outcome.IsSuccess():
+            frameCaptureError = outcome.GetError()
+            errorMsg = frameCaptureError.error_message
+            print(f"Failed to capture screenshot at outputImagePath='{screenshotImagePath}'\nError:\n{errorMsg}")
+            return False
+        azgeneral.idle_wait(screenUpdateWaitTime)
+        img = Image.open(screenshotImagePath)
+        width, height = img.size
+        r = int(height/2)
+        c = int(width/2)
+        image_array = np.array(img)
+        color = image_array[r][c]
+        print(f"captureCount {captureCount}: Center Pixel[{r},{c}] Color={color}, type:{type(color)}, r={color[0]}, g={color[1]}, b={color[2]}")
+        success = (color[0] == expectedPixelColor[0]) and (color[1] == expectedPixelColor[1]) and (color[2] == expectedPixelColor[2])
+        if success:
+            return success
+    return success 
+
+
+def ShaderReloadTest(iterationCountMax: int, screenUpdateWaitTime: float, captureCountOnFailure: int, screenshotImagePath: str, quickShaderOverwriteCount: int, quickShaderOverwriteWait: float) -> tuple[bool, int]:
+    """
+    This function is the main loop. Runs @iterationCountMax iterations and all iterations must PASS
+    to consider the test a success.
+    A single iteration modifies the Shader file, waits @screenUpdateWaitTime, captures the pixel content
+    of the Editor Viewport, and reads the center pixel of the image for an expected color.
+    """    
+    levelPath = azeditor.EditorToolsApplicationRequestBus(azbus.Broadcast, "GetCurrentLevelPath")
+    levelName = os.path.basename(levelPath)
+    if levelName != EXPECTED_LEVEL_NAME:
+        print(f"ERROR: This test suite expects a level named '{EXPECTED_LEVEL_NAME}', instead got '{levelName}'")
+        return False, 0
+    azslFilePath = os.path.join(levelPath, "SimpleMesh.azsl") 
+
+    iterationCount = 0
+    success = False
+    while iterationCount < iterationCountMax:
+        iterationCount += 1
+        print(f"Starting Retry {iterationCount} of {iterationCountMax}...")
+        success = UpdateShaderAndTestPixelResult(azslFilePath, screenUpdateWaitTime, captureCountOnFailure, screenshotImagePath, quickShaderOverwriteCount, quickShaderOverwriteWait)
+        if not success:
+            break
+    return success, iterationCount
+
+
+def ValidateImageExtension(screenshotImagePath: str) -> bool:
+    _, file_extension = os.path.splitext(screenshotImagePath)
+    if file_extension in SUPPORTED_IMAGE_FILE_EXTENSIONS:
+        return True
+    print(f"ERROR: Image path '{screenshotImagePath}' has an unsupported file extension.\nSupported extensions: {SUPPORTED_IMAGE_FILE_EXTENSIONS}")
+    return False
+
+
+def AdjustEditorCameraPosition(cameraEntityName: str) -> azentity.EntityId:
+    """
+    Searches for an entity named @cameraEntityName, assumes the entity has a Camera Component,
+    and forces the Editor Viewport to make it the Active Camera. This helps center the `Billboard`
+    entity because this test Samples the middle the of the screen for the correct Pixel color.
+    """
+    if not cameraEntityName:
+        return None
+    # Find the first entity with such name.
+    searchFilter = azentity.SearchFilter()
+    searchFilter.names = [cameraEntityName,]
+    entityList = azentity.SearchBus(azbus.Broadcast, "SearchEntities", searchFilter)
+    print(f"Found {len(entityList)} entities named {cameraEntityName}. Will use the first.")
+    if len(entityList) < 1:
+        print(f"No camera entity with name {cameraEntityName} was found. Viewport camera won't be adjusted.")
+        return None
+    cameraEntityId = entityList[0]
+    isActiveCamera = azcamera.EditorCameraViewRequestBus(azbus.Event, "IsActiveCamera", cameraEntityId)
+    if isActiveCamera:
+        print(f"Entity '{cameraEntityName}' is already the active camera")
+        return cameraEntityId
+    azcamera.EditorCameraViewRequestBus(azbus.Event, "ToggleCameraAsActiveView", cameraEntityId)
+    print(f"Entity '{cameraEntityName}' is now the active camera. Will wait 2 seconds for the screen to settle.")
+    print(f"REMARK: It is expected that the camera is located at [0, -1, 2] with all euler angles at 0.")
+    azgeneral.idle_wait(2.0)
+    return cameraEntityId
+
+
+def ClearViewportOfHelpers():
+    """
+    Makes sure all helpers and artifacts that add unwanted pixels
+    are hidden.
+    """
+    # Make sure no entity is selected when the test runs because entity selection adds unwanted colored pixels
+    azeditor.ToolsApplicationRequestBus(azbus.Broadcast, "SetSelectedEntities", [])
+    # Hide helpers
+    if azgeneral.is_helpers_shown():
+        azgeneral.toggle_helpers()
+    # Hide icons
+    if azgeneral.is_icons_shown():
+        azgeneral.toggle_icons()
+    #Hide FPS, etc
+    azgeneral.set_cvar_integer("r_displayInfo", 0)
+    # Wait a little for the screen to update.
+    azgeneral.idle_wait(0.25)
+
+
+# Quick Example on how to run this test from the Editor Console (See README.md for more details):
+# Runs 10 iterations:
+# pyRunFile C:\GIT\o3de\AutomatedTesting\Levels\ShaderReloadTest\shader_reload_test.py -i 10
+def MainFunc():
+    import argparse
+
+    parser = argparse.ArgumentParser(
+        description="Records several frames of pass attachments as image files."
+    )
+
+    parser.add_argument(
+        "-i",
+        "--iterations",
+        type=int,
+        default=1,
+        help="How many times the Shader should be modified and the screen pixel validated.",
+    )
+
+    parser.add_argument(
+        "--screen_update_wait_time",
+        type=float,
+        default=3.0,
+        help="Minimum time to wait after modifying the shader and taking the screen snapshot to validate color output.",
+    )
+
+    parser.add_argument(
+        "--capture_count_on_failure",
+        type=int,
+        default=2,
+        help="How many times the screen should be recaptured if the pixel output failes.",
+    )
+
+    parser.add_argument(
+        "-p",
+        "--screenshot_image_path",
+        default="",
+        help="Absolute path of the file where the screenshot will be written to. Must include image extensions 'ppm', 'png', 'bmp', 'tif'. By default a temporary png path will be created",
+    )
+
+    parser.add_argument(
+        "-q",
+        "--quick_shader_overwrite_count",
+        type=int,
+        default=0,
+        help="How many times the shader should be overwritten before capturing the screenshot. This simulates real life cases where a shader file is updated and saved to the file system several times consecutively",
+    )
+
+    parser.add_argument(
+        "-w",
+        "--quick_shader_overwrite_wait",
+        type=float,
+        default=1.0,
+        help="Minimum time to wait in between quick shader overwrites.",
+    )
+
+    parser.add_argument(
+        "-c",
+        "--camera_entity_name",
+        default="Camera",
+        help="Name of the entity that contains a Camera Component. If found, the Editor camera will be set to it before starting the test.",
+    )
+
+    args = parser.parse_args()
+    iterationCountMax = args.iterations
+    screenUpdateWaitTime = args.screen_update_wait_time
+    captureCountOnFailure = args.capture_count_on_failure
+    screenshotImagePath = args.screenshot_image_path
+    quickShaderOverwriteCount = args.quick_shader_overwrite_count
+    quickShaderOverwriteWait = args.quick_shader_overwrite_wait
+    cameraEntityName = args.camera_entity_name
+    tmpDir = None
+    if not screenshotImagePath:
+        tmpDir = tempfile.TemporaryDirectory()
+        screenshotImagePath = os.path.join(tmpDir.name, "shader_reload.png")
+        print(f"The temporary file '{screenshotImagePath}' will be used to capture screenshots")
+    else:
+        if not ValidateImageExtension(screenshotImagePath):
+            return # Exit test suite.
+    
+    cameraEntityId = AdjustEditorCameraPosition(cameraEntityName)
+    ClearViewportOfHelpers()
+
+    result, iterationCount = ShaderReloadTest(iterationCountMax, screenUpdateWaitTime, captureCountOnFailure, screenshotImagePath, quickShaderOverwriteCount, quickShaderOverwriteWait)
+    if result:
+        print(f"ShaderReloadTest PASSED after retrying {iterationCount}/{iterationCountMax} times.")
+    else:
+        print(f"ShaderReloadTest FAILED after retrying {iterationCount}/{iterationCountMax} times.")
+    
+    if cameraEntityId is not None:
+        azcamera.EditorCameraViewRequestBus(azbus.Event, "ToggleCameraAsActiveView", cameraEntityId)
+    
+    if tmpDir:
+        tmpDir.cleanup()
+if __name__ == "__main__":
+    MainFunc()

+ 4 - 0
AutomatedTesting/Levels/ShaderReloadTest/simple_mesh.material

@@ -0,0 +1,4 @@
+{
+    "materialType": "simple_mesh.materialtype",
+    "materialTypeVersion": 1
+}

+ 9 - 0
AutomatedTesting/Levels/ShaderReloadTest/simple_mesh.materialtype

@@ -0,0 +1,9 @@
+{
+    "description": "For ShaderReloadTest.",
+    "version": 1,
+    "shaders": [
+        {
+            "file": "Levels/ShaderReloadTest/SimpleMesh.shader"
+        }
+    ]
+}

+ 2 - 0
Code/Editor/Lib/Tests/test_ViewportTitleDlgPythonBindings.cpp

@@ -54,5 +54,7 @@ namespace ViewportTitleDlgFuncsUnitTests
 
         EXPECT_TRUE(behaviorContext->m_methods.find("toggle_helpers") != behaviorContext->m_methods.end());
         EXPECT_TRUE(behaviorContext->m_methods.find("is_helpers_shown") != behaviorContext->m_methods.end());
+        EXPECT_TRUE(behaviorContext->m_methods.find("toggle_icons") != behaviorContext->m_methods.end());
+        EXPECT_TRUE(behaviorContext->m_methods.find("is_icons_shown") != behaviorContext->m_methods.end());
     }
 }

+ 13 - 0
Code/Editor/ViewportTitleDlg.cpp

@@ -318,6 +318,17 @@ inline double Round(double fVal, double fStep)
     {
         return AzToolsFramework::HelpersVisible();
     }
+
+    void PyToggleIcons()
+    {
+        AzToolsFramework::SetIconsVisible(!AzToolsFramework::IconsVisible());
+        AzToolsFramework::EditorSettingsAPIBus::Broadcast(&AzToolsFramework::EditorSettingsAPIBus::Events::SaveSettingsRegistryFile);
+    }
+
+    bool PyIsIconsShown()
+    {
+        return AzToolsFramework::IconsVisible();
+    }
 } // namespace
 
 namespace AzToolsFramework
@@ -336,6 +347,8 @@ namespace AzToolsFramework
 
             addLegacyGeneral(behaviorContext->Method("toggle_helpers", PyToggleHelpers, nullptr, "Toggles the display of helpers."));
             addLegacyGeneral(behaviorContext->Method("is_helpers_shown", PyIsHelpersShown, nullptr, "Gets the display state of helpers."));
+            addLegacyGeneral(behaviorContext->Method("toggle_icons", PyToggleIcons, nullptr, "Toggles the display of icons."));
+            addLegacyGeneral(behaviorContext->Method("is_icons_shown", PyIsIconsShown, nullptr, "Gets the display state of icons."));
         }
     }
 } // namespace AzToolsFramework

+ 16 - 1
Gems/Atom/RPI/Code/Include/Atom/RPI.Public/Shader/Shader.h

@@ -55,7 +55,7 @@ namespace AZ
         AZ_PUSH_DISABLE_DLL_EXPORT_BASECLASS_WARNING
         class ATOM_RPI_PUBLIC_API Shader final
             : public Data::InstanceData
-            , public Data::AssetBus::Handler
+            , public Data::AssetBus::MultiHandler
             , public ShaderVariantFinderNotificationBus::Handler
         {
             AZ_POP_DISABLE_DLL_EXPORT_BASECLASS_WARNING
@@ -187,6 +187,21 @@ namespace AZ
             //! A strong reference to the shader asset.
             Data::Asset<ShaderAsset> m_asset;
 
+            /////////////////////////////////////////////////////////////////////////////////////
+            //! The following variables are necessary to reliably reload the Shader
+            //! whenever the Shader source assets and dependencies change.
+            //! 
+            //! Each time the Shader is initialized, this variable
+            //! caches all the Assets that we are expecting to be reloaded whenever
+            //! the Shader asset changes. This includes m_asset + each Supervariant ShaderVariantAsset.
+            //! Typically most shaders only contain one Supervariant, so this variable becomes 2. 
+            size_t m_expectedAssetReloadCount = 0;
+            //! Each time one of the assets is reloaded we store it here, and when the
+            //! size of this dictionary equals @m_expectedAssetReloadCount then we know it is safe
+            //! to reload the Shader.
+            AZStd::unordered_map<Data::AssetId, Data::Asset<Data::AssetData>> m_reloadedAssets;
+            /////////////////////////////////////////////////////////////////////////////////////
+
             //! Selects current supervariant to be used.
             //! This value is defined at instantiation.
             SupervariantIndex m_supervariantIndex;

+ 7 - 0
Gems/Atom/RPI/Code/Include/Atom/RPI.Reflect/Shader/ShaderAsset.h

@@ -276,6 +276,13 @@ namespace AZ
                 AZStd::vector<Supervariant> m_supervariants;
             };
 
+            //! Searches across all Supervariants for the matching AssetId of the each Root
+            //! ShaderVariantAsset. The first root ShaderVariantAsset that matches is replaced
+            //! with @shaderVariantAsset.
+            //! @returns true if a matching AssetId was found and replaced.
+            //! @remark This function is only useful during Shader::OnAssetReloaded.
+            bool UpdateRootShaderVariantAsset(Data::Asset<ShaderVariantAsset> shaderVariantAsset);
+
             bool PostLoadInit() override;
             void SetReady();
 

+ 4 - 1
Gems/Atom/RPI/Code/Source/RPI.Private/Module.cpp

@@ -13,12 +13,14 @@
 #include <Atom/RPI.Public/Image/ImageTagSystemComponent.h>
 #include <Atom/RPI.Public/Model/ModelTagSystemComponent.h>
 #include <RPI.Private/RPISystemComponent.h>
+#include <RPI.Private/PassTemplatesAutoLoader.h>
 
 AZ::RPI::Module::Module()
 {
     m_descriptors.push_back(AZ::RPI::RPISystemComponent::CreateDescriptor());
     m_descriptors.push_back(AZ::RPI::ImageTagSystemComponent::CreateDescriptor());
     m_descriptors.push_back(AZ::RPI::ModelTagSystemComponent::CreateDescriptor());
+    m_descriptors.push_back(AZ::RPI::PassTemplatesAutoLoader::CreateDescriptor());
 }
 
 AZ::ComponentTypeList AZ::RPI::Module::GetRequiredSystemComponents() const
@@ -27,7 +29,8 @@ AZ::ComponentTypeList AZ::RPI::Module::GetRequiredSystemComponents() const
     {
         azrtti_typeid<AZ::RPI::RPISystemComponent>(),
         azrtti_typeid<AZ::RPI::ImageTagSystemComponent>(),
-        azrtti_typeid<AZ::RPI::ModelTagSystemComponent>()
+        azrtti_typeid<AZ::RPI::ModelTagSystemComponent>(),
+        azrtti_typeid<AZ::RPI::PassTemplatesAutoLoader>()
     };
 }
 

+ 135 - 0
Gems/Atom/RPI/Code/Source/RPI.Private/PassTemplatesAutoLoader.cpp

@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+
+#include <RPI.Private/PassTemplatesAutoLoader.h>
+
+#include <AzCore/Serialization/EditContext.h>
+#include <AzCore/Settings/SettingsRegistry.h>
+#include <AzCore/Utils/Utils.h>
+
+#include <AzFramework/Gem/GemInfo.h>
+
+#include <Atom/RPI.Reflect/Asset/AssetUtils.h>
+
+namespace AZ
+{
+    namespace RPI
+    {
+        void PassTemplatesAutoLoader::Reflect(ReflectContext* context)
+        {
+            if (auto* serializeContext = azrtti_cast<SerializeContext*>(context))
+            {
+                serializeContext
+                    ->Class<PassTemplatesAutoLoader, Component>()
+                    ->Version(0)
+                    ;
+
+                if (AZ::EditContext* ec = serializeContext->GetEditContext())
+                {
+                    ec->Class<PassTemplatesAutoLoader>("PassTemplatesAutoLoader", "A service that loads PassTemplates.")
+                        ->ClassElement(AZ::Edit::ClassElements::EditorData, "")
+                        ->Attribute(AZ::Edit::Attributes::AutoExpand, true)
+                        ;
+                }
+            }
+        }
+
+        void PassTemplatesAutoLoader::GetRequiredServices(ComponentDescriptor::DependencyArrayType& required)
+        {
+            required.push_back(AZ_CRC_CE("RPISystem"));
+        }
+
+        void PassTemplatesAutoLoader::GetProvidedServices(ComponentDescriptor::DependencyArrayType& provided)
+        {
+            provided.push_back(AZ_CRC_CE("PassTemplatesAutoLoader"));
+        }
+
+        void PassTemplatesAutoLoader::GetIncompatibleServices(AZ::ComponentDescriptor::DependencyArrayType& incompatible)
+        {
+            incompatible.push_back(AZ_CRC_CE("PassTemplatesAutoLoader"));
+        }
+
+        void PassTemplatesAutoLoader::Activate()
+        {
+            // Register the event handler.
+            m_loadTemplatesHandler = AZ::RPI::PassSystemInterface::OnReadyLoadTemplatesEvent::Handler(
+                [&]()
+                {
+                    LoadPassTemplates();
+                });
+            AZ::RPI::PassSystemInterface::Get()->ConnectEvent(m_loadTemplatesHandler);
+        }
+
+        void PassTemplatesAutoLoader::Deactivate()
+        {
+
+        }
+
+        void PassTemplatesAutoLoader::LoadPassTemplates()
+        {
+            auto* settingsRegistry = AZ::SettingsRegistry::Get();
+            AZStd::vector<AzFramework::GemInfo> gemInfoList;
+            if (!AzFramework::GetGemsInfo(gemInfoList, *settingsRegistry))
+            {
+                AZ_Warning(LogWindow, false, "%s Failed to get Gems info.\n", __FUNCTION__);
+                return;
+            }
+
+            auto* passSystemInterface = AZ::RPI::PassSystemInterface::Get();
+            AZStd::unordered_set<AZStd::string> loadedTemplates; // See CAVEAT below.
+            auto loadFunc = [&](const AZStd::string& assetPath)
+            {
+                if (loadedTemplates.count(assetPath) > 0)
+                {
+                    // CAVEAT: Most of the times Game Projects contain a Gem of the same name
+                    // inside of them, this is why we check first with @loadedTemplates before attempting to load
+                    // the PassTemplate asset located at <PROJECT_ROOT>/Passes/<PROJECT_NAME>/AutoLoadPassTemplates.azasset
+                    return;
+                }
+                const auto assetId = AssetUtils::GetAssetIdForProductPath(assetPath.c_str(), AssetUtils::TraceLevel::None);
+                if (!assetId.IsValid())
+                {
+                    // This is the most common scenario.
+                    return;
+                }
+                if (!passSystemInterface->LoadPassTemplateMappings(assetPath))
+                {
+                    AZ_Error(LogWindow, false, "Failed to load PassTemplates at '%s'.\n", assetPath.c_str());
+                    return;
+                }
+                AZ_Printf(LogWindow, "Successfully load PassTemplates from '%s'.\n", assetPath.c_str());
+                loadedTemplates.emplace(AZStd::move(assetPath));
+            };
+
+            for (const auto& gemInfo : gemInfoList)
+            {
+                AZStd::string assetPath = AZStd::string::format("Passes/%s/AutoLoadPassTemplates.azasset", gemInfo.m_gemName.c_str());
+                loadFunc(assetPath);
+            }
+
+            // Besides the Gems, a Game Project can also provide PassTemplates.
+            // <PROJECT_ROOT>/Assets/Passes/<PROJECT_NAME>/AutoLoadPassTemplates.azasset
+            // <PROJECT_ROOT>/Passes/<PROJECT_NAME>/AutoLoadPassTemplates.azasset
+            const auto projectName = AZ::Utils::GetProjectName(settingsRegistry);
+            if (!projectName.empty())
+            {
+                {
+                    AZStd::string assetPath = AZStd::string::format("Passes/%s/AutoLoadPassTemplates.azasset", projectName.c_str());
+                    loadFunc(assetPath);
+                }
+
+                {
+                    AZStd::string assetPath = AZStd::string::format("Assets/Passes/%s/AutoLoadPassTemplates.azasset", projectName.c_str());
+                    loadFunc(assetPath);
+                }
+            }
+        }
+
+    } // namespace RPI
+} // namespace AZ

+ 81 - 0
Gems/Atom/RPI/Code/Source/RPI.Private/PassTemplatesAutoLoader.h

@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) Contributors to the Open 3D Engine Project.
+ * For complete copyright and license terms please see the LICENSE at the root of this distribution.
+ *
+ * SPDX-License-Identifier: Apache-2.0 OR MIT
+ *
+ */
+
+/**
+ * @file PassTemplatesAutoLoader.h
+ * @brief Contains the definition of the PassTemplatesAutoLoader which provides
+ *        a data-driven alternative for Gems and Projects to load custom PassTemplates.
+ */
+#pragma once
+
+#include <AzCore/Component/Component.h>
+
+#include <Atom/RPI.Public/Pass/PassSystemInterface.h>
+
+namespace AZ
+{
+    namespace RPI
+    {
+        /**
+         * @brief A data-driven System Component that loads PassTemplates across all Gems and the Game Project.
+         * @detail This service provides an opt-in mechanism for Gems and any Game Project to load
+         *         custom PassTemplates.azasset WITHOUT having to write C++ code.
+                   This system component works as a convenience service because there's already an API
+         *         in AZ::RPI::PassSystemInterface::OnReadyLoadTemplatesEvent that helps Gems and Game Projects
+         *         load their custom PassTemplates (*.azasset), the problem is, of course, that C++ code
+         *         needs to be written to invoque the API. And this is where this System Component comes
+         *         to the rescue.
+         *         How it works?
+         *         This service, at startup time, looks across all active Gems for assets with the following
+         *         naming convention:
+         *         "Passes/<Gem Name>/AutoLoadPassTemplates.azassset".
+         *         or (Applicable to the Game Project)
+         *         "Passes/<Project Name>/AutoLoadPassTemplates.azassset".
+         *         or (Applicable to the Game Project)
+         *         "Assets/Passes/<Project Name>/AutoLoadPassTemplates.azassset".
+         *         If any of those asset paths exist, this service will automatically add those
+         *         PassTemplates to the PassLibrary.
+         */
+        class PassTemplatesAutoLoader final
+            : public AZ::Component
+        {
+        public:
+            AZ_COMPONENT(PassTemplatesAutoLoader, "{75FEC6CC-ACA7-419C-8A63-4286998CBC0B}");
+
+            static void Reflect(AZ::ReflectContext* context);
+            static void GetRequiredServices(AZ::ComponentDescriptor::DependencyArrayType& required);
+            static void GetProvidedServices(AZ::ComponentDescriptor::DependencyArrayType& provided);
+            static void GetIncompatibleServices(AZ::ComponentDescriptor::DependencyArrayType& incompatible);
+
+            PassTemplatesAutoLoader() = default;
+            ~PassTemplatesAutoLoader() override = default;
+
+            void Activate() override;
+            void Deactivate() override;
+
+        private:
+            PassTemplatesAutoLoader(const PassTemplatesAutoLoader&) = delete;
+
+            static constexpr char LogWindow[] = "PassTemplatesAutoLoader";
+
+            //! Loads PassTemplates across all Gems and the Game Project according to the following
+            //! naming convention:
+            //! "Passes/<Gem Name>/AutoLoadPassTemplates.azassset".
+            //! Or (Applicable to the Game Project)
+            //! "Passes/<Project Name>/AutoLoadPassTemplates.azassset".
+            //! Or (Applicable to the Game Project)
+            //! "Assets/Passes/<Project Name>/AutoLoadPassTemplates.azassset".
+            void LoadPassTemplates();
+
+            //! We use this event handler to register with RPI::PassSystem to be notified
+            //! of the right time to load PassTemplates.
+            //! The callback will invoke this->LoadPassTemplates()
+            AZ::RPI::PassSystemInterface::OnReadyLoadTemplatesEvent::Handler m_loadTemplatesHandler;
+        };
+    } // namespace RPI
+} // namespace AZ

+ 72 - 8
Gems/Atom/RPI/Code/Source/RPI.Public/Shader/Shader.cpp

@@ -137,7 +137,7 @@ namespace AZ
 
         RHI::ResultCode Shader::Init(ShaderAsset& shaderAsset)
         {
-            Data::AssetBus::Handler::BusDisconnect();
+            Data::AssetBus::MultiHandler::BusDisconnect();
             ShaderVariantFinderNotificationBus::Handler::BusDisconnect();
 
             RHI::RHISystemInterface* rhiSystem = RHI::RHISystemInterface::Get();
@@ -188,15 +188,22 @@ namespace AZ
             }
 
             ShaderVariantFinderNotificationBus::Handler::BusConnect(m_asset.GetId());
-            Data::AssetBus::Handler::BusConnect(m_asset.GetId());
 
+            m_reloadedAssets.clear();
+            const auto& supervariants = m_asset->GetCurrentShaderApiData().m_supervariants;
+            m_expectedAssetReloadCount = 1 /*m_asset*/ + supervariants.size();
+            Data::AssetBus::MultiHandler::BusConnect(m_asset.GetId());
+            for (const auto& supervariant : supervariants)
+            {
+                Data::AssetBus::MultiHandler::BusConnect(supervariant.m_rootShaderVariantAsset.GetId());
+            }
             return RHI::ResultCode::Success;
         }
 
         void Shader::Shutdown()
         {
             ShaderVariantFinderNotificationBus::Handler::BusDisconnect();
-            Data::AssetBus::Handler::BusDisconnect();
+            Data::AssetBus::MultiHandler::BusDisconnect();
 
             if (m_pipelineLibraryHandle.IsValid())
             {
@@ -222,18 +229,75 @@ namespace AZ
         // AssetBus overrides
         void Shader::OnAssetReloaded(Data::Asset<Data::AssetData> asset)
         {
-            ShaderReloadDebugTracker::ScopedSection reloadSection("{%p}->Shader::OnAssetReloaded %s", this, asset.GetHint().c_str());
+            ShaderReloadDebugTracker::ScopedSection reloadSection("{%p}->Shader::OnAssetReloaded %s.\n",
+                this, asset.GetHint().c_str());
 
-            m_asset = asset;
+            m_reloadedAssets.emplace(asset.GetId(), asset);
 
             if (ShaderReloadDebugTracker::IsEnabled())
             {
+                ShaderReloadDebugTracker::Printf(
+                    "Current ShaderAssetPtr={%p} with RootVariantAssetPtr={%p}", m_asset.Get(), m_asset->GetRootVariantAsset().Get());
+
+                ShaderReloadDebugTracker::Printf("{%p} -> Shader::OnAssetReloaded so far only %zu of %zu assets have been reloaded.",
+                    this, m_reloadedAssets.size(), m_expectedAssetReloadCount);
+
                 AZStd::sys_time_t now = AZStd::GetTimeUTCMilliSecond();
+                if (asset.GetType() == AZ::AzTypeInfo<ShaderVariantAsset>::Uuid())
+                {
+                    ShaderReloadDebugTracker::Printf(
+                        "{%p}->Shader::OnRootVariantReloaded [current time %lld] got new variant {%p}'%s'",
+                        this,
+                        now,
+                        asset.Get(),
+                        asset.GetHint().c_str());
+                }
+                else
+                {
+                    const auto* newShaderAsset = asset.GetAs<ShaderAsset>();
+                    const auto shaderVariantAsset = newShaderAsset->GetRootVariantAsset();
+                    ShaderReloadDebugTracker::Printf(
+                        "{%p}->Shader::OnShaderAssetReloaded [current time %lld] got new shader {%p}'%s' with included variant {%p}'%s'",
+                        this,
+                        now,
+                        newShaderAsset,
+                        asset.GetHint().c_str(),
+                        shaderVariantAsset.Get(),
+                        shaderVariantAsset.GetHint().c_str());
+                }
+            }
+
+            if (m_reloadedAssets.size() != m_expectedAssetReloadCount)
+            {
+                return;
+            }
 
-                const auto shaderVariantAsset = m_asset->GetRootVariantAsset();
-                ShaderReloadDebugTracker::Printf("{%p}->Shader::OnAssetReloaded for shader '%s' [current time %lld] found variant '%s'",
-                    this, m_asset.GetHint().c_str(), now, shaderVariantAsset.GetHint().c_str());
+            // Time to update all references:
+            auto itor = m_reloadedAssets.find(m_asset.GetId());
+            if (itor == m_reloadedAssets.end())
+            {
+                AZ_Error("Shader", false, "Can not find the reloaded ShaderAsset with ID '%s'. Hint '%s'",
+                    m_asset.GetId().ToString<AZStd::string>().c_str(),
+                    m_asset.GetHint().c_str());
+                return;
             }
+
+            m_asset = itor->second;
+            m_reloadedAssets.erase(itor);
+            for (auto& [assetId, rootVariantAsset] : m_reloadedAssets)
+            {
+                AZ_Assert(rootVariantAsset.GetType() == AZ::AzTypeInfo<ShaderVariantAsset>::Uuid(),
+                    "Was expecting only ShaderVariantAsset(s)");
+                if (!m_asset->UpdateRootShaderVariantAsset(Data::static_pointer_cast<ShaderVariantAsset>(rootVariantAsset)))
+                {
+                    AZ_Error("Shader", false,
+                        "Failed to update Root ShaderVariantAsset {%p}'%s'",
+                        rootVariantAsset.Get(),
+                        rootVariantAsset.GetHint().c_str());
+                }
+            }
+            m_reloadedAssets.clear();
+
             Init(*m_asset.Get());
             ShaderReloadNotificationBus::Event(asset.GetId(), &ShaderReloadNotificationBus::Events::OnShaderReinitialized, *this);
         }

+ 14 - 0
Gems/Atom/RPI/Code/Source/RPI.Reflect/Shader/ShaderAsset.cpp

@@ -593,6 +593,20 @@ namespace AZ
             return true;
         }
 
+        bool ShaderAsset::UpdateRootShaderVariantAsset(Data::Asset<ShaderVariantAsset> shaderVariantAsset)
+        {
+            auto& supervariants = GetCurrentShaderApiData().m_supervariants;
+            for (auto& supervariant : supervariants)
+            {
+                if (supervariant.m_rootShaderVariantAsset.GetId() == shaderVariantAsset.GetId())
+                {
+                    supervariant.m_rootShaderVariantAsset = shaderVariantAsset;
+                    return true;
+                }
+            }
+            return false;
+        }
+
         bool ShaderAsset::PostLoadInit()
         {
             ShaderVariantFinderNotificationBus::Handler::BusConnect(GetId());

+ 2 - 0
Gems/Atom/RPI/Code/atom_rpi_private_files.cmake

@@ -13,4 +13,6 @@ set(FILES
     Source/RPI.Private/RPISystemComponent.h
     Source/RPI.Private/PerformanceCVarManager.cpp
     Source/RPI.Private/PerformanceCVarManager.h
+    Source/RPI.Private/PassTemplatesAutoLoader.cpp
+    Source/RPI.Private/PassTemplatesAutoLoader.h
 )