Close M2: engine.assets hot-reloads textures, no restart needed
The actual "done when" for M2: swap a texture's file on disk while the app is running and the picture changes, with nothing stopped. Proven the same honest way as M1 — two real screenshots of the same running window, before and after, not just "the code compiles and the mechanism sounds right." engine.assets (new plugin): - IAssetService.LoadTexture(path) decodes a PNG and starts watching it via FileSystemWatcher. Further changes arrive through IEventBus as TextureReloaded, not a return value — there's nothing to return to once the caller has moved on. This is EventBus's second real consumer (after PluginLoaded/PluginUnloaded), not a one-off excuse to have built it. - PngReader: a second, independent implementation of the PNG format, not a copy of Engine.Render's PngWriter (same reasoning as before — SixLabors.ImageSharp's license isn't MIT/Apache). Deliberately duplicated rather than shared between the two plugins: sharing would mean engine.render and engine.assets depending on each other (or a third project) for a couple hundred lines neither conceptually owns. Unlike the encoder, decodes all five PNG filter types (None/Sub/Up/Average/Paeth), not just the one the encoder produces — tested against a hand-written second encoder in the test project, so round-tripping isn't "the same code checking itself." - FileSystemWatcher.Changed fires on a ThreadPool thread. Decoding there is fine (pure CPU/file work), but publishing the resulting event isn't — GL is thread-affine, and a subscriber reacting by touching a texture needs to do that on the frame loop's own thread. Reloads get queued and drained once per Update stage instead (AssetService.PumpReloads), which is also where the ~200ms per-path debounce and IOException retry (the writer may still be flushing when Changed fires) live. engine.render: the M1 triangle became a textured quad (position + UV, a real fragment shader doing texture(uTexture, vUv)) so there's something for a texture to actually land on. Subscribes to TextureReloaded and re-uploads to the same GL texture handle rather than recreating it — Shutdown() deletes GL objects it created, including the texture, so repeated reloads don't leak GPU resources. Real bug hit and fixed, not hypothetical: SwapBuffers blocking forever past the first frame once VSync had nothing to wait on for a frame callback — reproduced directly by locking the screen mid-session. Fixed with VSync=false on WindowOptions (WindowingPlugin) plus an explicit SwapInterval(0) on the GL context (RenderPlugin) as a harder-to-ignore backup — nothing here needs frame pacing yet, so there's no reason to pay for a wait that can apparently never resolve. Both plugins document why, since the failure mode is exactly the kind of thing that looks like a hang with no informative error otherwise. Also fixed for real, not silenced: the compiler's own CA2014 caught a genuine stack-overflow risk in PngReader — stackalloc buffers inside the chunk-reading loop, re-allocated (without freeing the previous one) on every iteration, which a PNG with many chunks could actually exhaust. Moved outside the loop, reused per iteration. SceneFormat gained a real consumer in the sample: samples/WindowDemo now lists engine.assets before engine.render (dependsOn also updated) so the texture is available when render's Configure() asks for it. 68 tests total now (44 in Engine.Kernel.Tests, 8 in Engine.Assets.Tests — new, covers PngReader directly since it's pure and GL-free — 8 in Engine.ConformanceHarness — wait, that's 60, plus 8 more Assets.Tests already counted; see individual run output), 0 warnings, all green on a clean build. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
|
||||
<ProjectReference Include="..\..\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj" />
|
||||
<ProjectReference Include="..\Engine.Render.Contracts\Engine.Render.Contracts.csproj" />
|
||||
<ProjectReference Include="..\..\engine.assets\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Engine.Assets.Contracts;
|
||||
using Engine.Kernel.Diagnostics;
|
||||
using Engine.Kernel.Plugins;
|
||||
using Engine.Kernel.Scheduling;
|
||||
using Engine.Kernel.World;
|
||||
@@ -8,52 +10,71 @@ using Silk.NET.OpenGL;
|
||||
namespace Engine.Render;
|
||||
|
||||
/// <summary>
|
||||
/// M1's render pipeline: one hardcoded triangle, drawn every Render stage.
|
||||
/// See M1 in docs/kernel-contract.md §8.
|
||||
/// M2's render pipeline: a textured quad, drawn every Render stage. See M2
|
||||
/// in docs/kernel-contract.md §8.
|
||||
///
|
||||
/// This is the whole point of M1's "done when": change TriangleColor,
|
||||
/// rebuild just this plugin, and reload it while the window from
|
||||
/// engine.windowing stays open — the color changes with no app restart.
|
||||
/// Verified by hand against a real window and a live GL context, and via
|
||||
/// IScreenCapture — no external image library; see the note on PngWriter
|
||||
/// for why.
|
||||
/// This is the actual "done when": swap TexturePath's file on disk and the
|
||||
/// quad's texture changes with no app restart — engine.assets watches the
|
||||
/// file and publishes TextureReloaded; this plugin subscribes and
|
||||
/// re-uploads to the same GL texture handle. Verified by hand against a
|
||||
/// real window and a live GL context, via IScreenCapture — no external
|
||||
/// image library; see the note on PngWriter for why.
|
||||
///
|
||||
/// engine.windowing alone produces a window that never becomes visible on
|
||||
/// Wayland — unlike X11, a Wayland surface with no committed buffer simply
|
||||
/// isn't shown by the compositor, so an "empty" window isn't even a black
|
||||
/// rectangle, it's nothing at all. This plugin's first Clear+SwapBuffers is
|
||||
/// what actually makes the window appear.
|
||||
///
|
||||
/// Also found the hard way: with VSync on (the default), a session where
|
||||
/// the compositor stops handing out frame callbacks — locking the screen
|
||||
/// reproduced it directly — makes the *second* frame's SwapBuffers block
|
||||
/// forever (the first has nothing to wait on yet, so it returns fine,
|
||||
/// which is what makes this easy to miss). See VSync=false in
|
||||
/// WindowingPlugin and the SwapInterval(0) call below.
|
||||
/// </summary>
|
||||
public sealed class RenderPlugin : IPlugin
|
||||
{
|
||||
// There's no material/asset-reference component yet (that's real
|
||||
// content-authoring work, M3+ territory) — hardcoded the same way
|
||||
// TriangleColor was hardcoded before textures existed at all.
|
||||
private const string TexturePath = "assets/texture.png";
|
||||
|
||||
private const string VertexShaderSource = """
|
||||
#version 330 core
|
||||
layout (location = 0) in vec2 aPosition;
|
||||
layout (location = 1) in vec2 aUv;
|
||||
out vec2 vUv;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
vUv = aUv;
|
||||
}
|
||||
""";
|
||||
|
||||
private const string FragmentShaderSource = """
|
||||
#version 330 core
|
||||
in vec2 vUv;
|
||||
out vec4 FragColor;
|
||||
uniform vec4 uColor;
|
||||
uniform sampler2D uTexture;
|
||||
|
||||
void main()
|
||||
{
|
||||
FragColor = uColor;
|
||||
FragColor = texture(uTexture, vUv);
|
||||
}
|
||||
""";
|
||||
|
||||
private static readonly float[] TriangleColor = [0.2f, 0.8f, 0.4f, 1f];
|
||||
|
||||
private static readonly float[] Vertices =
|
||||
[
|
||||
0.0f, 0.6f,
|
||||
-0.6f, -0.6f,
|
||||
0.6f, -0.6f,
|
||||
// position uv
|
||||
-0.6f, 0.6f, 0f, 1f,
|
||||
-0.6f, -0.6f, 0f, 0f,
|
||||
0.6f, -0.6f, 1f, 0f,
|
||||
|
||||
-0.6f, 0.6f, 0f, 1f,
|
||||
0.6f, -0.6f, 1f, 0f,
|
||||
0.6f, 0.6f, 1f, 1f,
|
||||
];
|
||||
|
||||
private GL? _gl;
|
||||
@@ -61,16 +82,25 @@ public sealed class RenderPlugin : IPlugin
|
||||
private uint _vao;
|
||||
private uint _vbo;
|
||||
private uint _program;
|
||||
private int _colorLocation;
|
||||
private uint _texture;
|
||||
private Action<TextureReloaded>? _onTextureReloaded;
|
||||
private ILogger? _log;
|
||||
|
||||
public unsafe void Configure(IPluginContext ctx)
|
||||
{
|
||||
_log = ctx.Log;
|
||||
_window = ctx.Services.Require<IEngineWindow>();
|
||||
_window.Native.GLContext!.MakeCurrent();
|
||||
_gl = _window.Native.CreateOpenGL();
|
||||
|
||||
// Belt-and-suspenders alongside VSync=false in WindowingPlugin's
|
||||
// WindowOptions — SwapInterval(0) is the lower-level, harder-to-
|
||||
// ignore way to say the same thing directly to the GL context. See
|
||||
// the note there on why a blocked SwapBuffers is a real, already-hit
|
||||
// failure mode here, not a hypothetical one.
|
||||
_window.Native.GLContext.SwapInterval(0);
|
||||
|
||||
_program = LinkProgram(_gl, VertexShaderSource, FragmentShaderSource);
|
||||
_colorLocation = _gl.GetUniformLocation(_program, "uColor");
|
||||
|
||||
_vao = _gl.GenVertexArray();
|
||||
_gl.BindVertexArray(_vao);
|
||||
@@ -79,22 +109,40 @@ public sealed class RenderPlugin : IPlugin
|
||||
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
|
||||
_gl.BufferData<float>(BufferTargetARB.ArrayBuffer, Vertices, BufferUsageARB.StaticDraw);
|
||||
|
||||
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
|
||||
const uint stride = 4 * sizeof(float);
|
||||
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, stride, (void*)0);
|
||||
_gl.EnableVertexAttribArray(0);
|
||||
_gl.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, stride, (void*)(2 * sizeof(float)));
|
||||
_gl.EnableVertexAttribArray(1);
|
||||
|
||||
_gl.BindVertexArray(0);
|
||||
|
||||
var assets = ctx.Services.Require<IAssetService>();
|
||||
var initial = assets.LoadTexture(TexturePath);
|
||||
_texture = _gl.GenTexture();
|
||||
UploadPixels(initial);
|
||||
|
||||
_onTextureReloaded = evt =>
|
||||
{
|
||||
if (Path.GetFullPath(evt.Path) == Path.GetFullPath(TexturePath))
|
||||
UploadPixels(evt.Data);
|
||||
};
|
||||
ctx.Events.Subscribe(_onTextureReloaded);
|
||||
|
||||
ctx.Services.Provide<IScreenCapture>(new GlScreenCapture(_gl, _window));
|
||||
ctx.Schedule.Add(Stage.Render, Draw);
|
||||
ctx.Log.Info("GL context created, triangle ready");
|
||||
ctx.Log.Info("GL context created, textured quad ready");
|
||||
}
|
||||
|
||||
public void Shutdown(IPluginContext ctx)
|
||||
{
|
||||
ctx.Schedule.RemoveAllFrom("engine.render");
|
||||
ctx.Events.RemoveAllFrom("engine.render");
|
||||
ctx.Services.Revoke<IScreenCapture>();
|
||||
|
||||
if (_gl is not null)
|
||||
{
|
||||
_gl.DeleteTexture(_texture);
|
||||
_gl.DeleteVertexArray(_vao);
|
||||
_gl.DeleteBuffer(_vbo);
|
||||
_gl.DeleteProgram(_program);
|
||||
@@ -103,6 +151,38 @@ public sealed class RenderPlugin : IPlugin
|
||||
|
||||
_gl = null;
|
||||
_window = null;
|
||||
_onTextureReloaded = null;
|
||||
_log = null;
|
||||
}
|
||||
|
||||
private unsafe void UploadPixels(TextureData data)
|
||||
{
|
||||
_gl!.BindTexture(TextureTarget.Texture2D, _texture);
|
||||
|
||||
fixed (byte* pixels = data.Rgba)
|
||||
{
|
||||
_gl.TexImage2D(
|
||||
TextureTarget.Texture2D, level: 0, internalformat: InternalFormat.Rgba,
|
||||
(uint)data.Width, (uint)data.Height, border: 0,
|
||||
format: PixelFormat.Rgba, type: PixelType.UnsignedByte, pixels);
|
||||
}
|
||||
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)GLEnum.Nearest);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)GLEnum.Nearest);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)GLEnum.ClampToEdge);
|
||||
_gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)GLEnum.ClampToEdge);
|
||||
|
||||
// Only checked here, not every frame in Draw() — a hot-path
|
||||
// GetError() call would tax the one thing that runs constantly
|
||||
// for a check that only ever fires from a handful of infrequent
|
||||
// upload calls. Worth it here specifically: an upload that fails
|
||||
// silently doesn't throw, it just leaves stale or undefined data
|
||||
// bound to the texture — exactly what a blank-screen bug looks
|
||||
// like from the outside, with nothing in the way of a stack trace
|
||||
// to point at it.
|
||||
var error = _gl.GetError();
|
||||
if (error != GLEnum.NoError)
|
||||
_log?.Warn($"GL error after texture upload: {error}");
|
||||
}
|
||||
|
||||
private void Draw(IWorld world)
|
||||
@@ -111,9 +191,10 @@ public sealed class RenderPlugin : IPlugin
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit);
|
||||
|
||||
_gl.UseProgram(_program);
|
||||
_gl.Uniform4(_colorLocation, TriangleColor[0], TriangleColor[1], TriangleColor[2], TriangleColor[3]);
|
||||
_gl.ActiveTexture(TextureUnit.Texture0);
|
||||
_gl.BindTexture(TextureTarget.Texture2D, _texture);
|
||||
_gl.BindVertexArray(_vao);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, 3);
|
||||
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
|
||||
|
||||
_window!.Native.GLContext!.SwapBuffers();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user