diff --git a/LinguaEngine.sln b/LinguaEngine.sln index b02a63d..83d01ab 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -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 diff --git a/README.md b/README.md index 215ab4f..816d772 100644 --- a/README.md +++ b/README.md @@ -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 (M0–M4) in [`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next. diff --git a/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs b/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs new file mode 100644 index 0000000..ca059fc --- /dev/null +++ b/plugins/engine.assets/Engine.Assets.Contracts/AssetEvents.cs @@ -0,0 +1,9 @@ +namespace Engine.Assets.Contracts; + +/// +/// 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. +/// +public readonly record struct TextureReloaded(string Path, TextureData Data); diff --git a/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj b/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj new file mode 100644 index 0000000..c632161 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets.Contracts/Engine.Assets.Contracts.csproj @@ -0,0 +1,3 @@ + + + diff --git a/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs b/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs new file mode 100644 index 0000000..f797d03 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets.Contracts/IAssetService.cs @@ -0,0 +1,12 @@ +namespace Engine.Assets.Contracts; + +public interface IAssetService +{ + /// + /// Decodes the PNG at and starts watching it + /// for changes. Returns the initial data; later changes arrive via + /// , not a return value — there's nothing + /// to return to once the caller has moved on. + /// + TextureData LoadTexture(string path); +} diff --git a/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs b/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs new file mode 100644 index 0000000..3f84f35 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets.Contracts/TextureData.cs @@ -0,0 +1,8 @@ +namespace Engine.Assets.Contracts; + +/// +/// 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. +/// +public sealed record TextureData(int Width, int Height, byte[] Rgba); diff --git a/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs b/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs new file mode 100644 index 0000000..56ac559 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets/AssemblyInfo.cs @@ -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")] diff --git a/plugins/engine.assets/Engine.Assets/AssetPlugin.cs b/plugins/engine.assets/Engine.Assets/AssetPlugin.cs new file mode 100644 index 0000000..7b9f085 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets/AssetPlugin.cs @@ -0,0 +1,34 @@ +using Engine.Assets.Contracts; +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; + +namespace Engine.Assets; + +/// +/// 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. +/// +public sealed class AssetPlugin : IPlugin +{ + private AssetService? _service; + + public void Configure(IPluginContext ctx) + { + _service = new AssetService(ctx.Events, ctx.Log); + ctx.Services.Provide(_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(); + _service?.Dispose(); + _service = null; + } +} diff --git a/plugins/engine.assets/Engine.Assets/AssetService.cs b/plugins/engine.assets/Engine.Assets/AssetService.cs new file mode 100644 index 0000000..4d4c9a8 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets/AssetService.cs @@ -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 _watchers = []; + private readonly ConcurrentQueue _pendingEvents = new(); + private readonly Dictionary _lastTriggered = []; + private readonly Lock _debounceLock = new(); + + public TextureData LoadTexture(string path) + { + var fullPath = Path.GetFullPath(path); + var data = Decode(fullPath); + Watch(fullPath); + return data; + } + + /// + /// 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. + /// + 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(); + } +} diff --git a/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj b/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj new file mode 100644 index 0000000..b039de2 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets/Engine.Assets.csproj @@ -0,0 +1,12 @@ + + + + + + + + + true + + + diff --git a/plugins/engine.assets/Engine.Assets/PngReader.cs b/plugins/engine.assets/Engine.Assets/PngReader.cs new file mode 100644 index 0000000..87c5fb8 --- /dev/null +++ b/plugins/engine.assets/Engine.Assets/PngReader.cs @@ -0,0 +1,189 @@ +using System.IO.Compression; + +namespace Engine.Assets; + +/// +/// 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. +/// +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 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 lengthBytes = stackalloc byte[4]; + Span typeBytes = stackalloc byte[4]; + Span 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 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 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; + } +} diff --git a/plugins/engine.assets/plugin.json b/plugins/engine.assets/plugin.json new file mode 100644 index 0000000..80c774a --- /dev/null +++ b/plugins/engine.assets/plugin.json @@ -0,0 +1,8 @@ +{ + "id": "engine.assets", + "version": "0.1.0", + "contracts": "Engine.Assets.Contracts.dll", + "assembly": "Engine.Assets.dll", + "dependsOn": {}, + "reloadable": true +} diff --git a/plugins/engine.render/Engine.Render/Engine.Render.csproj b/plugins/engine.render/Engine.Render/Engine.Render.csproj index 11917a6..c5e4d10 100644 --- a/plugins/engine.render/Engine.Render/Engine.Render.csproj +++ b/plugins/engine.render/Engine.Render/Engine.Render.csproj @@ -20,6 +20,7 @@ + diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs index 951f75a..f01ca82 100644 --- a/plugins/engine.render/Engine.Render/RenderPlugin.cs +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -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; /// -/// 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. /// 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? _onTextureReloaded; + private ILogger? _log; public unsafe void Configure(IPluginContext ctx) { + _log = ctx.Log; _window = ctx.Services.Require(); _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(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(); + 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(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(); 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(); } diff --git a/plugins/engine.render/plugin.json b/plugins/engine.render/plugin.json index 16af25a..68bd549 100644 --- a/plugins/engine.render/plugin.json +++ b/plugins/engine.render/plugin.json @@ -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 } diff --git a/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs index 581eb30..0bb6754 100644 --- a/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs +++ b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs @@ -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); diff --git a/samples/WindowDemo/assets/texture.png b/samples/WindowDemo/assets/texture.png new file mode 100644 index 0000000..524e41a Binary files /dev/null and b/samples/WindowDemo/assets/texture.png differ diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json index 3ab7f40..2e298c2 100644 --- a/samples/WindowDemo/project.json +++ b/samples/WindowDemo/project.json @@ -2,6 +2,7 @@ "engineVersion": "^0.1", "plugins": [ { "id": "engine.windowing" }, + { "id": "engine.assets" }, { "id": "engine.render" }, { "id": "engine.input" } ], diff --git a/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj b/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj new file mode 100644 index 0000000..8e95467 --- /dev/null +++ b/tests/Engine.Assets.Tests/Engine.Assets.Tests.csproj @@ -0,0 +1,22 @@ + + + + false + + + + + + + + + + + + + + + + + + diff --git a/tests/Engine.Assets.Tests/PngReaderTests.cs b/tests/Engine.Assets.Tests/PngReaderTests.cs new file mode 100644 index 0000000..d1e23b1 --- /dev/null +++ b/tests/Engine.Assets.Tests/PngReaderTests.cs @@ -0,0 +1,231 @@ +using System.IO.Compression; +using System.Text; +using Engine.Assets; + +namespace Engine.Assets.Tests; + +/// +/// 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. +/// +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(() => 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(() => 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; + } +}