You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: articles/tutorials/advanced/2d_shaders/02_hot_reload/index.md
+37-36Lines changed: 37 additions & 36 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -33,41 +33,42 @@ Our snake-like game already has a shader effect and we can use it to validate th
33
33
### MSBuild Targets
34
34
35
35
The existing automatic shader compilation is happening because the `DungeonSlime.csproj` file is referencing the `MonoGame.Content.Builder.Task` Nuget package.
Nuget packages can add custom build behaviours and the `MonoGame.Content.Builder.Task` package is adding a step to the game's build that runs the MonoGame Content Builder tool. These sorts of build extensions use a conventional `.prop` and `.target` file system. If you are interested, you can learn more about how Nuget packages may extend MSBuild systems on Microsoft's [documentation website](https://learn.microsoft.com/en-us/visualstudio/msbuild/msbuild?view=vs-2022). For reference, [this](https://github.com/MonoGame/MonoGame/blob/develop/Tools/MonoGame.Content.Builder.Task/MonoGame.Content.Builder.Task.targets#L172) is the `.targets` file for the `MonoGame.Content.Builder.Task`.
39
40
40
41
This line defines a new MSBuild step, called `IncludeContent`:
41
42
42
-
[!code-xml](./snippets/snippet-2-01.xml)
43
+
[!code-xml[](./snippets/snippet-2-01.xml)]
43
44
44
45
You can learn more about what all the attributes do in MSBuild. Of particular note, the `BeforeTargets` attribute causes MSBuild to run the `IncludeContent` target before the `BeforeCompile` target is run, which is a standard target in the dotnet sdk.
45
46
46
47
The `IncludeContent` target can run manually by invoking `dotnet build` by hand. In VSCode, open the embedded terminal to the _DungeonSlime_ project folder, and run the following command:
47
48
48
-
[!code-sh](./snippets/snippet-2-02.sh)
49
+
[!code-sh[](./snippets/snippet-2-02.sh)]
49
50
50
51
You should see log output indicating that the content for the _DungeonSlime_ game was built.
51
52
52
53
### Dotnet Watch
53
54
54
55
There is a tool called [`dotnet watch`](https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-watch) that comes with the standard installation of `dotnet`. Normally, `dotnet watch` is used to watch for changes to `.cs` code files, recompile, and reload those changes into a program without restarting the program. You can try out `dotnet watch`'s normal behaviour by opening VSCode's embedded terminal to the _DungeonSlime_ project, and running the following command. The game should start normally:
55
56
56
-
[!code-sh](./snippets/snippet-2-03.sh)
57
+
[!code-sh[](./snippets/snippet-2-03.sh)]
57
58
58
59
> [!Tip]
59
60
> Use the `ctrl` + `c` key at the same time to quit the `dotnet watch` terminal process.
60
61
61
62
Then, comment out the `Clear()` function call in the title screen's `Draw()` method. Save the file, and you should see the title screen immediately stop clearing the background on each frame. If you restore the line and save again, the scene will start clearing the background again:
In our case, we do not want to recompile `.cs` files, but rather `.fx` files. First, `dotnet watch` can be configured to execute any MSBuild target rather than a recompile code. The following command uses the existing target provided by the `MonoGame.Content.Builder.Task`.
66
67
67
68
> [!Tip]
68
69
> All arguments passed after the `--` characters are passed to the `build` command itself, not `dotnet watch`:
69
70
70
-
[!code-sh](./snippets/snippet-2-05.sh)
71
+
[!code-sh[](./snippets/snippet-2-05.sh)]
71
72
72
73
Now, when you change a _`.fx`_ file, all of the content files are rebuilt into `.xnb` files.
73
74
@@ -76,51 +77,51 @@ However, the `.xnb` files are not being copied from the `Content/bin` folder to
76
77
The existing `MonoGame.Content.Builder.Task` system knows what the files are, so we can re-use properties defined in the MonoGame package.
77
78
Add this `<Target>` block to your `.csproj` file:
78
79
79
-
[!code-xml](./snippets/snippet-2-06.xml)
80
+
[!code-xml[](./snippets/snippet-2-06.xml)]
80
81
81
82
Now, instead of calling the `IncludeContent` target directly, change your terminal command to invoke the new `BuildAndCopyContent` target:
82
83
83
-
[!code-sh](./snippets/snippet-2-07.sh)
84
+
[!code-sh[](./snippets/snippet-2-07.sh)]
84
85
85
86
If you delete the `DungeonSlime/bin/Debug/net8.0/Content` folder, make an edit to a `.cs` file and save, you should see the `DungeonSlime/bin/Debug/net8.0/Content` folder be restored.
86
87
87
88
The next step is to only invoke the target when `.fx` files are edited instead of `.cs` files. These settings can be configured with custom MSBuild item configurations. Open the `DungeonSlime.csproj` file and add this `<ItemGroup>` to specify configuration settings:
88
89
89
-
[!code-xml](./snippets/snippet-2-08.xml)
90
+
[!code-xml[](./snippets/snippet-2-08.xml)]
90
91
91
92
Now when you re-run the command from earlier, it will only run the `IncludeContent` target when `.fx` files have been changed. All edits to `.cs` files are ignored. Try adding a blank line to the `grayscaleEffect.fx` file, and notice the `dotnet watch` process re-build the content.
92
93
93
94
However, if you would like to use `dotnet watch` for anything else in your workflow, then the configuration settings are too aggressive, because they will be applied _all_ invocations of `dotnet watch`. The `ItemGroup` can be optionally included when a certain condition is met. We will introduce a new MSBuild property called `OnlyWatchContentFiles`:
And now when `dotnet watch` is invoked, it needs to specify the new parameter:
98
99
99
-
[!code-sh](./snippets/snippet-2-10.sh)
100
+
[!code-sh[](./snippets/snippet-2-10.sh)]
100
101
101
102
The command is getting long and hard to type, and if we want to add more configuration, it will likely get even longer. Instead of invoking `dotnet watch` directly, it can be run as a new `<Target>` MSBuild step. Add this `<Target>` to your `DungeonSlime.csproj` file:
102
103
103
-
[!code-xml](./snippets/snippet-2-11.xml)
104
+
[!code-xml[](./snippets/snippet-2-11.xml)]
104
105
105
106
And now from the terminal, run the following `dotnet build` command:
106
107
107
-
[!code-sh](./snippets/snippet-2-12.sh)
108
+
[!code-sh[](./snippets/snippet-2-12.sh)]
108
109
109
110
We now have a way to dynamically recompile shaders on file changes and copy the `.xnb` files into the game folder! There are a few final adjustments to make to the configuration.
110
111
111
112
### DotNet Watch, but smarter
112
113
113
114
First, you may notice some odd characters in the log output after putting the `dotnet watch` inside the `WatchContent` target. This is because there are _emoji_ characters in the standard `dotnet watch` log stream, and some terminals do not understand how to display those, especially when streamed between `dotnet build`. To disable the _emoji_ characters, a `DOTNET_WATCH_SUPPRESS_EMOJIS` environment variable needs to be set:
Next, the `IncludeContent` target is doing a little too much work for our use case. It is trying to make sure the MonoGame Content Builder tools are installed. For our use case, we can opt out of that check by disabling the existing `AutoRestoreMGCBTool` MSBuild property. It also makes sense to pass `--restore:false` as well so that Nuget packages are not restored on each content file change:
To experiment with the system, re-run the following command:
122
123
123
-
[!code-sh](./snippets/snippet-2-15.sh)
124
+
[!code-sh[](./snippets/snippet-2-15.sh)]
124
125
125
126
And then cause some sort of compiler-error in the `grayscaleEffect.fx` file, such as adding the line, `"tunafish"` to the top of the file. When you save it, you should see the terminal spit out an error containing information about the compilation failure,
126
127
@@ -155,37 +156,37 @@ It is important to make a distinction between assets the game _expects_ to be re
155
156
156
157
Currently, the `grayscaleEffect.fx` is being loaded in the `GameScene` 's `LoadContent()` method like this:
157
158
158
-
[!code-csharp](./snippets/snippet-2-16.cs)
159
+
[!code-csharp[](./snippets/snippet-2-16.cs)]
159
160
160
161
The `.Load()` function in the existing `ContentManager` is almost sufficient for our needs, but it returns a regular `Effect`, which has no understanding of the dynamic nature of the new content workflow.
161
162
162
163
1. Create a new _Content_ folder within the _MonoGameLibrary_ project, add a new file named `ContentManagerExtensions.cs`, and add the following code for the foundation of the new system:
163
164
164
-
[!code-csharp](./snippets/snippet-2-17.cs)
165
+
[!code-csharp[](./snippets/snippet-2-17.cs)]
165
166
166
167
2. Within this Extension class we will add an [extension method](https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/extension-methods) for the existing `MonoGame`'s `ContentManager` class and give it capabilities it does not currently have:
167
168
168
-
[!code-csharp](./snippets/snippet-2-18.cs)
169
+
[!code-csharp[](./snippets/snippet-2-18.cs)]
169
170
170
171
3. This new `Watch` function is an opportunity to enhance how content is loaded. Use this new function to load the `_greyscaleEffect` effect in the `GameScene`:
171
172
172
-
[!code-csharp](./snippets/snippet-2-19.cs)
173
+
[!code-csharp[](./snippets/snippet-2-19.cs)]
173
174
174
175
### The `WatchedAsset` class
175
176
176
177
The new system will need to keep track of additional information for each asset that we plan to be _hot-reloadable_. The data will live in a new class, `WatchedAsset<T>`.
177
178
178
179
1. Add a new file named `WatchedAsset.cs` in the _MonoGameLibrary/Content_ folder:
179
180
180
-
[!code-csharp](./snippets/snippet-2-20.cs)
181
+
[!code-csharp[](./snippets/snippet-2-20.cs)]
181
182
182
183
2. The new `Watch` method should return a `WatchedAsset<T>` instead of the direct `Effect`:
183
184
184
-
[!code-csharp](./snippets/snippet-2-21.cs)
185
+
[!code-csharp[](./snippets/snippet-2-21.cs)]
185
186
186
187
3. This will require that the type of `_greyscaleEffect` change to a `WatchedAsset<Effect>` instead of simply an `Effect`:
187
188
188
-
[!code-csharp](./snippets/snippet-2-22.cs)
189
+
[!code-csharp[](./snippets/snippet-2-22.cs)]
189
190
190
191
> [!IMPORTANT]
191
192
> This will cause a few compilation errors where the `_greyscaleEffect` is used throughout the rest of the `GameScene`.
@@ -197,15 +198,15 @@ It is time to extend the `ContentManagerExtensions` extension method that the ga
197
198
198
199
1. The new `TryRefresh` method will take a `WatchedAsset<T>` and update the inner `Asset` property _if_ the `.xnb` file is newer. The method returns `true` when the asset is reloaded, which will be useful later:
199
200
200
-
[!code-csharp](./snippets/snippet-2-23.cs)
201
+
[!code-csharp[](./snippets/snippet-2-23.cs)]
201
202
202
203
2. At the top of the `GameScene.Update()` method, add the following line to opt into reloading the `_grayscaleEffect` asset:
3. Now, when the `grayscaleEffect.fx` file is modified, the `dotnet watch` system will compile it to an `.xnb` file, copy it to the game's runtime folder, and then in the `Update()` loop, the `TryRefresh()` method will load the new effect and the new shader code will be running live in the game. Try it out by adding this temporary line right before the `return` statement in the `grayscaleEffect.fx` file:
Finally, we need to address a subtle usability bug in the existing code. The `TryRefresh` function may `Unload` an asset if a new version is loaded. However, it is not obvious that the `ContentManager` instance doing the `Unload` operation is the same `ContentManager` instance that loaded the original asset in the first place. To solve this:
253
254
254
255
1. Add a `ContentManager` property to the `WatchedAsset<T>` class so that the asset itself knows which `ContentManager` is responsible for unloading old versions:
255
256
256
-
[!code-csharp](./snippets/snippet-2-31.cs)
257
+
[!code-csharp[](./snippets/snippet-2-31.cs)]
257
258
258
259
2. Adjust the `WatchAsset` function of the `ContentManagerExtensions` class to fill in this new property:
It is annoying to need use the `ContentManager` directly to call `TryRefresh` in the game loop. It would be easier to rely on the new `Owner` property:
267
268
268
269
4. Add the following method to the `WatchedAsset<T>` class:
269
270
270
-
[!code-csharp](./snippets/snippet-2-34.cs)
271
+
[!code-csharp[](./snippets/snippet-2-34.cs)]
271
272
272
273
5. Finally, update the `GameScene` to use the new convenience method to refresh the `_grayscaleEffect`:
0 commit comments