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:
Emil
2026-09-02 05:28:15 +03:00
co-authored by Claude Sonnet 5
parent 45daa6e114
commit 95d69efcdc
20 changed files with 826 additions and 22 deletions
+48
View File
@@ -41,6 +41,14 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input.Contracts", "p
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Input", "plugins\engine.input\Engine.Input\Engine.Input.csproj", "{A3784B8F-8782-4B55-807B-1BADD06D5211}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.assets", "engine.assets", "{BBD4296A-8D59-75AB-1261-7E1302A19746}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets.Contracts", "plugins\engine.assets\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj", "{1F6F4B27-5624-42F8-87A7-A4729FB198E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets", "plugins\engine.assets\Engine.Assets\Engine.Assets.csproj", "{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Assets.Tests", "tests\Engine.Assets.Tests\Engine.Assets.Tests.csproj", "{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -195,6 +203,42 @@ Global
{A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x64.Build.0 = Release|Any CPU
{A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.ActiveCfg = Release|Any CPU
{A3784B8F-8782-4B55-807B-1BADD06D5211}.Release|x86.Build.0 = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x64.ActiveCfg = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x64.Build.0 = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x86.ActiveCfg = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Debug|x86.Build.0 = Debug|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|Any CPU.Build.0 = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x64.ActiveCfg = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x64.Build.0 = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x86.ActiveCfg = Release|Any CPU
{1F6F4B27-5624-42F8-87A7-A4729FB198E3}.Release|x86.Build.0 = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x64.ActiveCfg = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x64.Build.0 = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x86.ActiveCfg = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Debug|x86.Build.0 = Debug|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|Any CPU.Build.0 = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x64.ActiveCfg = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x64.Build.0 = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x86.ActiveCfg = Release|Any CPU
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15}.Release|x86.Build.0 = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x64.ActiveCfg = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x64.Build.0 = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x86.ActiveCfg = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Debug|x86.Build.0 = Debug|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|Any CPU.Build.0 = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x64.ActiveCfg = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x64.Build.0 = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.ActiveCfg = Release|Any CPU
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -216,5 +260,9 @@ Global
{A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{7374240C-A59B-437B-818A-620F7A3B391C} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}
{A3784B8F-8782-4B55-807B-1BADD06D5211} = {A97AE94C-1787-9CBF-B2A1-C74EA1A0F17A}
{BBD4296A-8D59-75AB-1261-7E1302A19746} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8}
{1F6F4B27-5624-42F8-87A7-A4729FB198E3} = {BBD4296A-8D59-75AB-1261-7E1302A19746}
{8C2DE8CB-3207-4990-8F6D-87CF31D6CF15} = {BBD4296A-8D59-75AB-1261-7E1302A19746}
{AFAB0A32-2A70-4BCA-8561-5F94B195DFAB} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
+22
View File
@@ -60,6 +60,28 @@ both shipped; `sandbox.echo` subscribes to `PluginLoaded` for real, so the
(the fast path) is deliberately still open, but with a concrete trigger
condition instead of a deadline, not left vague.
**M2 done.** `World` actually saves and loads now (`SceneFormat`,
replacing the old introspection-only `WorldDumper` — there was never a
real reason for "what an agent reads to check a frame" and "what a scene
file is" to be different shapes). Verified beyond round-trip unit tests:
two separate CLI runs against the same scene file, second one picking up
right where the first left off, component state and all.
`engine.assets` hot-reloads textures from disk — the actual "done when"
for M2. `engine.render`'s triangle became a textured quad; swap the PNG
file on disk while the app is running and the picture changes with no
restart, no manual reload command, just a `FileSystemWatcher` noticing
and `IEventBus` carrying `TextureReloaded` from `engine.assets` to
`engine.render`. Verified the same honest way as M1 — real screenshots,
before and after, same running process — plus two things caught and fixed
along the way rather than papered over: a PNG decoder was needed (no
`SixLabors.ImageSharp`, same licensing reason as the encoder — it's a
second, independent implementation of the format, tested against all five
PNG filter types, not just the one this codebase's own writer produces),
and a real hang, not a hypothetical one: `SwapBuffers` blocking forever
once VSync had nothing to wait on — reproduced by locking the screen,
fixed by turning VSync off, since nothing here needs frame pacing yet.
No physics yet — see the build order (M0M4) in
[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next.
@@ -0,0 +1,9 @@
namespace Engine.Assets.Contracts;
/// <summary>
/// M2's "done when": swapping a texture on disk changes the picture with
/// nothing stopped. engine.assets watches the file and publishes this
/// through IEventBus when it changes; engine.render subscribes and
/// re-uploads to the GPU. Nothing else connects the two plugins directly.
/// </summary>
public readonly record struct TextureReloaded(string Path, TextureData Data);
@@ -0,0 +1,3 @@
<Project Sdk="Microsoft.NET.Sdk">
</Project>
@@ -0,0 +1,12 @@
namespace Engine.Assets.Contracts;
public interface IAssetService
{
/// <summary>
/// Decodes the PNG at <paramref name="path"/> and starts watching it
/// for changes. Returns the initial data; later changes arrive via
/// <see cref="TextureReloaded"/>, not a return value — there's nothing
/// to return to once the caller has moved on.
/// </summary>
TextureData LoadTexture(string path);
}
@@ -0,0 +1,8 @@
namespace Engine.Assets.Contracts;
/// <summary>
/// Raw decoded pixels, nothing graphics-API-specific — uploading this to
/// an actual GPU texture is engine.render's job, not this plugin's, so a
/// future non-OpenGL render plugin could reuse engine.assets unchanged.
/// </summary>
public sealed record TextureData(int Width, int Height, byte[] Rgba);
@@ -0,0 +1,6 @@
using System.Runtime.CompilerServices;
// PngReader is internal — nothing outside this plugin needs it, texture
// loading goes through IAssetService. The test project needs direct access
// to test the codec itself without going through a GL-dependent round trip.
[assembly: InternalsVisibleTo("Engine.Assets.Tests")]
@@ -0,0 +1,34 @@
using Engine.Assets.Contracts;
using Engine.Kernel.Plugins;
using Engine.Kernel.Scheduling;
namespace Engine.Assets;
/// <summary>
/// M2's second half: engine.windowing made a window visible, engine.render
/// made it draw something, this makes what it draws hot-reloadable from a
/// file on disk — the actual "done when" for M2. See docs/kernel-contract.md
/// §8.
/// </summary>
public sealed class AssetPlugin : IPlugin
{
private AssetService? _service;
public void Configure(IPluginContext ctx)
{
_service = new AssetService(ctx.Events, ctx.Log);
ctx.Services.Provide<IAssetService>(_service);
// Pumping on Update, not from the FileSystemWatcher callback
// directly — see the note on AssetService.PumpReloads.
ctx.Schedule.Add(Stage.Update, _ => _service!.PumpReloads());
}
public void Shutdown(IPluginContext ctx)
{
ctx.Schedule.RemoveAllFrom("engine.assets");
ctx.Services.Revoke<IAssetService>();
_service?.Dispose();
_service = null;
}
}
@@ -0,0 +1,106 @@
using System.Collections.Concurrent;
using Engine.Assets.Contracts;
using Engine.Kernel.Diagnostics;
using Engine.Kernel.Events;
namespace Engine.Assets;
internal sealed class AssetService(IEventBus events, ILogger log) : IAssetService, IDisposable
{
private readonly List<FileSystemWatcher> _watchers = [];
private readonly ConcurrentQueue<TextureReloaded> _pendingEvents = new();
private readonly Dictionary<string, DateTime> _lastTriggered = [];
private readonly Lock _debounceLock = new();
public TextureData LoadTexture(string path)
{
var fullPath = Path.GetFullPath(path);
var data = Decode(fullPath);
Watch(fullPath);
return data;
}
/// <summary>
/// Drains reloads decoded on background threads and publishes them on
/// whichever thread calls this — meant to run once per Update stage,
/// which always runs on the frame loop's own thread. A subscriber that
/// reacts to TextureReloaded by touching a GL texture needs that: GL
/// contexts are thread-affine, and FileSystemWatcher.Changed fires on
/// a ThreadPool thread that was never made current for any of them.
/// </summary>
public void PumpReloads()
{
while (_pendingEvents.TryDequeue(out var evt))
{
events.Publish(evt);
log.Info($"reloaded texture '{evt.Path}'");
}
}
private void Watch(string fullPath)
{
var directory = Path.GetDirectoryName(fullPath)!;
var fileName = Path.GetFileName(fullPath);
var watcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
};
watcher.Changed += (_, _) => OnChanged(fullPath);
watcher.EnableRaisingEvents = true;
_watchers.Add(watcher);
}
private void OnChanged(string path)
{
// Editors/tools often fire several Changed events for one save
// (truncate + write, or multiple flushes) — a quiet window per
// path collapses those into a single reload instead of several.
lock (_debounceLock)
{
var now = DateTime.UtcNow;
if (_lastTriggered.TryGetValue(path, out var last) && now - last < TimeSpan.FromMilliseconds(200))
return;
_lastTriggered[path] = now;
}
_ = Task.Run(() => ReloadWithRetry(path));
}
private async Task ReloadWithRetry(string path)
{
// The writer may still be flushing when Changed fires — a short
// retry window absorbs that instead of surfacing a transient
// IOException as a real failure.
for (var attempt = 0; attempt < 5; attempt++)
{
try
{
_pendingEvents.Enqueue(new TextureReloaded(path, Decode(path)));
return;
}
catch (IOException) when (attempt < 4)
{
await Task.Delay(50);
}
}
}
private static TextureData Decode(string path)
{
var (width, height, rgba) = PngReader.Read(path);
return new TextureData(width, height, rgba);
}
public void Dispose()
{
// Undisposed FileSystemWatchers would pin this plugin's ALC the
// same way a forgotten Schedule/EventBus registration would —
// each one's Changed handler is a delegate into this assembly.
foreach (var watcher in _watchers)
watcher.Dispose();
_watchers.Clear();
}
}
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\..\..\src\Engine.Kernel\Engine.Kernel.csproj" />
<ProjectReference Include="..\Engine.Assets.Contracts\Engine.Assets.Contracts.csproj" />
</ItemGroup>
<PropertyGroup>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
</Project>
@@ -0,0 +1,189 @@
using System.IO.Compression;
namespace Engine.Assets;
/// <summary>
/// Minimal PNG decoder — the inverse of Engine.Render's PngWriter, and
/// written for the same reason: SixLabors.ImageSharp's license isn't
/// MIT/Apache, so it's not something an MIT engine should depend on for
/// its own texture loading. Same deliberate subset as the encoder: 8-bit
/// RGBA (color type 6), no interlacing. Unlike the encoder, this decodes
/// all five PNG filter types (None/Sub/Up/Average/Paeth), not just
/// None — a real image tool exporting a texture won't necessarily pick
/// the same filter this codebase's own writer does, and rejecting
/// anything but self-produced files would defeat the point of loading
/// textures at all.
///
/// Not shared with Engine.Render's PngWriter as a common library: the two
/// plugins would gain a dependency on each other (or on a third one) for
/// a couple hundred lines neither owns conceptually — texture loading and
/// screenshot writing are different concerns that happen to touch the
/// same file format. Small, deliberate duplication over a premature
/// shared abstraction for two consumers.
/// </summary>
internal static class PngReader
{
private static readonly byte[] Signature = [137, 80, 78, 71, 13, 10, 26, 10];
private static readonly uint[] Crc32Table = BuildCrc32Table();
public static (int Width, int Height, byte[] Rgba) Read(string path)
{
using var stream = File.OpenRead(path);
Span<byte> signature = stackalloc byte[8];
stream.ReadExactly(signature);
if (!signature.SequenceEqual(Signature))
throw new InvalidDataException($"'{path}' is not a PNG file.");
var width = 0;
var height = 0;
var sawIhdr = false;
using var idat = new MemoryStream();
// Allocated once, outside the loop, and reused every iteration —
// a PNG can carry an unbounded number of chunks (multiple IDATs
// are routine for a large image), and a stackalloc that re-runs
// per iteration doesn't free the previous one until this whole
// method returns. The compiler's own CA2014 caught this — a real
// stack-overflow risk on a large enough file, not a style nit.
Span<byte> lengthBytes = stackalloc byte[4];
Span<byte> typeBytes = stackalloc byte[4];
Span<byte> crcBytes = stackalloc byte[4];
while (true)
{
stream.ReadExactly(lengthBytes);
var length = ReadUInt32BE(lengthBytes);
stream.ReadExactly(typeBytes);
var type = System.Text.Encoding.ASCII.GetString(typeBytes);
var data = new byte[length];
stream.ReadExactly(data);
stream.ReadExactly(crcBytes);
var expectedCrc = ReadUInt32BE(crcBytes);
var actualCrc = Crc32(typeBytes, data);
if (actualCrc != expectedCrc)
throw new InvalidDataException($"'{path}': corrupt {type} chunk (CRC mismatch).");
switch (type)
{
case "IHDR":
width = (int)ReadUInt32BE(data.AsSpan(0, 4));
height = (int)ReadUInt32BE(data.AsSpan(4, 4));
var bitDepth = data[8];
var colorType = data[9];
var interlace = data[12];
if (bitDepth != 8 || colorType != 6 || interlace != 0)
{
throw new NotSupportedException(
$"'{path}': only 8-bit non-interlaced RGBA PNGs are supported " +
$"(got bit depth {bitDepth}, color type {colorType}, interlace {interlace}).");
}
sawIhdr = true;
break;
case "IDAT":
idat.Write(data);
break;
case "IEND":
goto doneReadingChunks;
}
}
doneReadingChunks:
if (!sawIhdr)
throw new InvalidDataException($"'{path}': no IHDR chunk found.");
idat.Position = 0;
using var zlib = new ZLibStream(idat, CompressionMode.Decompress);
using var raw = new MemoryStream();
zlib.CopyTo(raw);
var scanlines = raw.ToArray();
var rgba = Unfilter(scanlines, width, height);
return (width, height, rgba);
}
private static byte[] Unfilter(byte[] scanlines, int width, int height)
{
const int bytesPerPixel = 4; // fixed: 8-bit RGBA, see the color type check above
var stride = width * bytesPerPixel;
var rawStride = stride + 1; // +1 filter-type byte per row
var rgba = new byte[stride * height];
for (var y = 0; y < height; y++)
{
var filterType = scanlines[y * rawStride];
var rowStart = y * rawStride + 1;
for (var x = 0; x < stride; x++)
{
var filtered = scanlines[rowStart + x];
int a = x >= bytesPerPixel ? rgba[y * stride + x - bytesPerPixel] : 0;
int b = y > 0 ? rgba[(y - 1) * stride + x] : 0;
int c = x >= bytesPerPixel && y > 0 ? rgba[(y - 1) * stride + x - bytesPerPixel] : 0;
int reconstructed = filterType switch
{
0 => filtered,
1 => filtered + a,
2 => filtered + b,
3 => filtered + (a + b) / 2,
4 => filtered + Paeth(a, b, c),
_ => throw new NotSupportedException($"Unknown PNG filter type {filterType}."),
};
rgba[y * stride + x] = (byte)reconstructed;
}
}
return rgba;
}
private static int Paeth(int a, int b, int c)
{
var p = a + b - c;
var pa = Math.Abs(p - a);
var pb = Math.Abs(p - b);
var pc = Math.Abs(p - c);
if (pa <= pb && pa <= pc)
return a;
return pb <= pc ? b : c;
}
private static uint ReadUInt32BE(ReadOnlySpan<byte> bytes) =>
((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3];
private static uint[] BuildCrc32Table()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++)
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
private static uint Crc32(ReadOnlySpan<byte> type, byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in type)
crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
foreach (var b in data)
crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
return crc ^ 0xFFFFFFFFu;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"id": "engine.assets",
"version": "0.1.0",
"contracts": "Engine.Assets.Contracts.dll",
"assembly": "Engine.Assets.dll",
"dependsOn": {},
"reloadable": true
}
@@ -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();
}
+2 -1
View File
@@ -4,7 +4,8 @@
"contracts": "Engine.Render.Contracts.dll",
"assembly": "Engine.Render.dll",
"dependsOn": {
"engine.windowing": "^0.1"
"engine.windowing": "^0.1",
"engine.assets": "^0.1"
},
"reloadable": true
}
@@ -20,6 +20,16 @@ public sealed class WindowingPlugin : IPlugin
{
Size = new(1280, 720),
Title = "Lingua Engine",
// VSync waits for the compositor's frame callback before a
// SwapBuffers call returns — found the hard way: under a
// nested/off-screen-ish Wayland session, that callback can
// simply never arrive, and the *second* frame's SwapBuffers
// (the first has nothing to wait on yet) blocks the whole
// process forever. Nothing here needs frame pacing yet — no
// input-driven gameplay, no animation — so there's no reason
// to pay for it before it's needed.
VSync = false,
};
_window = Window.Create(options);
Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

+1
View File
@@ -2,6 +2,7 @@
"engineVersion": "^0.1",
"plugins": [
{ "id": "engine.windowing" },
{ "id": "engine.assets" },
{ "id": "engine.render" },
{ "id": "engine.input" }
],
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\plugins\engine.assets\Engine.Assets\Engine.Assets.csproj" />
</ItemGroup>
</Project>
+231
View File
@@ -0,0 +1,231 @@
using System.IO.Compression;
using System.Text;
using Engine.Assets;
namespace Engine.Assets.Tests;
/// <summary>
/// Builds tiny test PNGs by hand rather than reusing Engine.Render's
/// PngWriter — keeps this test project independent of a second plugin,
/// and a from-scratch encoder here is also a second, independent
/// implementation of the format to check PngReader against, not the same
/// code testing itself.
/// </summary>
public class PngReaderTests
{
[Fact]
public void Read_Recovers_Exact_Pixel_Values_Through_A_Round_Trip()
{
// 2x2, four distinct colors — catches row-order and channel-order
// mistakes that a single flat color would hide.
byte[] pixels =
[
255, 0, 0, 255, 0, 255, 0, 255, // row 0: red, green
0, 0, 255, 255, 255, 255, 0, 255, // row 1: blue, yellow
];
var path = WriteTestPng(2, 2, pixels, filterType: 0);
try
{
var (width, height, rgba) = PngReader.Read(path);
Assert.Equal(2, width);
Assert.Equal(2, height);
Assert.Equal(pixels, rgba);
}
finally
{
File.Delete(path);
}
}
[Theory]
[InlineData((byte)0)] // None
[InlineData((byte)1)] // Sub
[InlineData((byte)2)] // Up
[InlineData((byte)3)] // Average
[InlineData((byte)4)] // Paeth
public void Read_Correctly_Unfilters_Every_Filter_Type(byte filterType)
{
byte[] pixels =
[
10, 20, 30, 255, 40, 50, 60, 255,
70, 80, 90, 255, 100, 110, 120, 255,
];
var path = WriteTestPng(2, 2, pixels, filterType);
try
{
var (_, _, rgba) = PngReader.Read(path);
Assert.Equal(pixels, rgba);
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Read_Throws_On_A_File_That_Is_Not_A_PNG()
{
var path = Path.GetTempFileName();
File.WriteAllText(path, "not a png");
try
{
Assert.Throws<InvalidDataException>(() => PngReader.Read(path));
}
finally
{
File.Delete(path);
}
}
[Fact]
public void Read_Throws_On_An_Unsupported_Color_Type()
{
// Grayscale (color type 0) instead of RGBA (6) — the reader is
// deliberately scoped to the one subset it actually supports.
var path = WriteTestPng(1, 1, [128], filterType: 0, colorType: 0, bytesPerPixel: 1);
try
{
Assert.Throws<NotSupportedException>(() => PngReader.Read(path));
}
finally
{
File.Delete(path);
}
}
private static string WriteTestPng(
int width, int height, byte[] rgba, byte filterType, byte colorType = 6, int bytesPerPixel = 4)
{
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.png");
using var file = File.Create(path);
file.Write((byte[]) [137, 80, 78, 71, 13, 10, 26, 10]);
var ihdr = new byte[13];
WriteUInt32BE(ihdr, 0, (uint)width);
WriteUInt32BE(ihdr, 4, (uint)height);
ihdr[8] = 8; // bit depth
ihdr[9] = colorType;
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0; // interlace: none
WriteChunk(file, "IHDR", ihdr);
var stride = width * bytesPerPixel;
using var raw = new MemoryStream();
for (var y = 0; y < height; y++)
{
var row = new byte[stride];
Array.Copy(rgba, y * stride, row, 0, stride);
var filtered = ApplyFilter(filterType, row, y > 0 ? Slice(rgba, (y - 1) * stride, stride) : null, bytesPerPixel);
raw.WriteByte(filterType);
raw.Write(filtered);
}
using var compressed = new MemoryStream();
using (var zlib = new ZLibStream(compressed, CompressionLevel.Optimal, leaveOpen: true))
zlib.Write(raw.ToArray());
WriteChunk(file, "IDAT", compressed.ToArray());
WriteChunk(file, "IEND", []);
return path;
}
private static byte[] Slice(byte[] source, int offset, int length)
{
var slice = new byte[length];
Array.Copy(source, offset, slice, 0, length);
return slice;
}
private static byte[] ApplyFilter(byte filterType, byte[] row, byte[]? previousRow, int bytesPerPixel)
{
var result = new byte[row.Length];
for (var x = 0; x < row.Length; x++)
{
int a = x >= bytesPerPixel ? row[x - bytesPerPixel] : 0;
int b = previousRow?[x] ?? 0;
int c = x >= bytesPerPixel ? previousRow?[x - bytesPerPixel] ?? 0 : 0;
result[x] = filterType switch
{
0 => row[x],
1 => (byte)(row[x] - a),
2 => (byte)(row[x] - b),
3 => (byte)(row[x] - (a + b) / 2),
4 => (byte)(row[x] - Paeth(a, b, c)),
_ => throw new ArgumentOutOfRangeException(nameof(filterType)),
};
}
return result;
}
private static int Paeth(int a, int b, int c)
{
var p = a + b - c;
var pa = Math.Abs(p - a);
var pb = Math.Abs(p - b);
var pc = Math.Abs(p - c);
if (pa <= pb && pa <= pc)
return a;
return pb <= pc ? b : c;
}
private static void WriteChunk(Stream stream, string type, byte[] data)
{
var typeBytes = Encoding.ASCII.GetBytes(type);
var length = new byte[4];
WriteUInt32BE(length, 0, (uint)data.Length);
stream.Write(length);
stream.Write(typeBytes);
stream.Write(data);
var crc = new byte[4];
WriteUInt32BE(crc, 0, Crc32(typeBytes, data));
stream.Write(crc);
}
private static void WriteUInt32BE(byte[] buffer, int offset, uint value)
{
buffer[offset] = (byte)(value >> 24);
buffer[offset + 1] = (byte)(value >> 16);
buffer[offset + 2] = (byte)(value >> 8);
buffer[offset + 3] = (byte)value;
}
private static readonly uint[] Crc32Table = BuildCrc32Table();
private static uint[] BuildCrc32Table()
{
var table = new uint[256];
for (uint n = 0; n < 256; n++)
{
var c = n;
for (var k = 0; k < 8; k++)
c = (c & 1) != 0 ? 0xEDB88320 ^ (c >> 1) : c >> 1;
table[n] = c;
}
return table;
}
private static uint Crc32(byte[] type, byte[] data)
{
var crc = 0xFFFFFFFFu;
foreach (var b in type)
crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
foreach (var b in data)
crc = Crc32Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
return crc ^ 0xFFFFFFFFu;
}
}