diff --git a/source/Vignette/Content/ContentManager.cs b/source/Vignette/Content/ContentManager.cs
new file mode 100644
index 0000000..cb3f855
--- /dev/null
+++ b/source/Vignette/Content/ContentManager.cs
@@ -0,0 +1,153 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using Sekai.Storages;
+
+namespace Vignette.Content;
+
+///
+/// Manages content.
+///
+public sealed class ContentManager
+{
+ private readonly Storage storage;
+ private readonly HashSet extensions = new();
+ private readonly HashSet loaders = new();
+ private readonly Dictionary content = new();
+
+ internal ContentManager(Storage storage)
+ {
+ this.storage = storage;
+ }
+
+ ///
+ /// Reads a file from and loads it as .
+ ///
+ /// The type of content to load.
+ /// The path to the content.
+ /// The loaded content.
+ /// Thrown when invalid path has been passed.
+ public T Load([StringSyntax(StringSyntaxAttribute.Uri)] string path)
+ where T : class
+ {
+ string ext = Path.GetExtension(path);
+
+ if (string.IsNullOrEmpty(ext))
+ {
+ throw new ArgumentException($"Failed to determine file extension.", nameof(path));
+ }
+
+ if (!extensions.Contains(ext))
+ {
+ throw new ArgumentException($"Cannot load unsupported file extension \"{ext}\".");
+ }
+
+ var key = new ContentKey(typeof(T), path);
+
+ if (!content.TryGetValue(key, out var weak))
+ {
+ weak = new WeakReference(null);
+ content.Add(key, weak);
+ }
+
+ if (!weak.IsAlive)
+ {
+ weak.Target = Load(storage.Open(path, FileMode.Open, FileAccess.Read));
+ }
+
+ return (T)weak.Target!;
+ }
+
+ ///
+ /// Loads a as .
+ ///
+ /// The type of content to load.
+ /// The stream to be read.
+ /// The loaded content.
+ /// Thrown when the stream can't be read.
+ public T Load(Stream stream)
+ where T : class
+ {
+ Span buffer = stackalloc byte[(int)stream.Length];
+
+ if (stream.Read(buffer) <= 0)
+ {
+ throw new InvalidOperationException("Failed to read stream.");
+ }
+
+ return Load((ReadOnlySpan)buffer);
+ }
+
+ ///
+ /// Loads a as .
+ ///
+ /// The type of content to load.
+ /// The byte data to be read.
+ /// The loaded content.
+ public T Load(byte[] bytes)
+ where T : class
+ {
+ return Load(bytes);
+ }
+
+ ///
+ /// Loads a as .
+ ///
+ /// The type of content to load.
+ /// The byte data to be read.
+ /// The loaded content.
+ /// Thrown when no loader was able to load the content.
+ public T Load(ReadOnlySpan bytes)
+ where T : class
+ {
+ var result = default(T);
+
+ foreach (var loader in loaders)
+ {
+ if (loader is not IContentLoader typedLoader)
+ {
+ continue;
+ }
+
+ try
+ {
+ result = typedLoader.Load(bytes);
+ break;
+ }
+ catch
+ {
+ }
+ }
+
+ if (result is null)
+ {
+ throw new InvalidOperationException("Failed to load content.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Adds a content loader to the content manager.
+ ///
+ /// The content loader to add.
+ /// The file extensions supported by this loader.
+ /// Thrown when
+ internal void Add(IContentLoader loader, params string[] extensions)
+ {
+ foreach (string extension in extensions)
+ {
+ string ext = extension.StartsWith(ext_separator) ? extension : ext_separator + extension;
+ this.loaders.Add(loader);
+ this.extensions.Add(ext);
+ }
+ }
+
+ private const char ext_separator = '.';
+
+ private readonly record struct ContentKey(Type Type, string Path);
+}
diff --git a/source/Vignette/Content/IContentLoader.cs b/source/Vignette/Content/IContentLoader.cs
new file mode 100644
index 0000000..9a73a15
--- /dev/null
+++ b/source/Vignette/Content/IContentLoader.cs
@@ -0,0 +1,28 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+
+namespace Vignette.Content;
+
+///
+/// Defines a mechanism for loading content.
+///
+public interface IContentLoader
+{
+}
+
+///
+/// Defines a mechanism for loading .
+///
+/// The type of content it loads.
+public interface IContentLoader : IContentLoader
+ where T : class
+{
+ ///
+ /// Loads a as .
+ ///
+ /// The byte data to be read.
+ /// The loaded content.
+ T Load(ReadOnlySpan bytes);
+}
diff --git a/source/Vignette/Content/ShaderLoader.cs b/source/Vignette/Content/ShaderLoader.cs
new file mode 100644
index 0000000..d075a66
--- /dev/null
+++ b/source/Vignette/Content/ShaderLoader.cs
@@ -0,0 +1,16 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using System.Text;
+using Vignette.Graphics;
+
+namespace Vignette.Content;
+
+internal sealed class ShaderLoader : IContentLoader
+{
+ public ShaderMaterial Load(ReadOnlySpan bytes)
+ {
+ return ShaderMaterial.Create(Encoding.UTF8.GetString(bytes));
+ }
+}
diff --git a/source/Vignette/Content/TextureLoader.cs b/source/Vignette/Content/TextureLoader.cs
new file mode 100644
index 0000000..c0b4268
--- /dev/null
+++ b/source/Vignette/Content/TextureLoader.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Cosyne
+// Licensed under GPL 3.0 with SDK Exception. See LICENSE for details.
+
+using System;
+using Sekai.Graphics;
+using StbiSharp;
+
+namespace Vignette.Content;
+
+internal sealed class TextureLoader : IContentLoader
+{
+ private readonly GraphicsDevice device;
+
+ public TextureLoader(GraphicsDevice device)
+ {
+ this.device = device;
+ }
+
+ public Texture Load(ReadOnlySpan bytes)
+ {
+ var image = Stbi.LoadFromMemory(bytes, 4);
+
+ var texture = device.CreateTexture(new TextureDescription
+ (
+ image.Width,
+ image.Height,
+ PixelFormat.R8G8B8A8_UNorm,
+ 1,
+ 1,
+ TextureUsage.Resource
+ ));
+
+ texture.SetData(image.Data, 0, 0, 0, 0, 0, image.Width, image.Height, 0);
+
+ return texture;
+ }
+}
diff --git a/source/Vignette/Graphics/UnlitMaterial.cs b/source/Vignette/Graphics/UnlitMaterial.cs
index 297b52b..424723b 100644
--- a/source/Vignette/Graphics/UnlitMaterial.cs
+++ b/source/Vignette/Graphics/UnlitMaterial.cs
@@ -70,6 +70,19 @@ public UnlitMaterial()
{
}
+ public UnlitMaterial(Texture texture)
+ : this(false)
+ {
+ Texture = texture;
+ }
+
+ public UnlitMaterial(Texture texture, Sampler sampler)
+ : this(false)
+ {
+ Texture = texture;
+ Sampler = sampler;
+ }
+
private UnlitMaterial(bool isDefault)
{
effect = Effect.From(shader, out layout, out properties);
diff --git a/source/Vignette/Vignette.csproj b/source/Vignette/Vignette.csproj
index 9054d8f..0bd80f1 100644
--- a/source/Vignette/Vignette.csproj
+++ b/source/Vignette/Vignette.csproj
@@ -7,6 +7,7 @@
+
diff --git a/source/Vignette/VignetteGame.cs b/source/Vignette/VignetteGame.cs
index 884fc58..8bb3f1b 100644
--- a/source/Vignette/VignetteGame.cs
+++ b/source/Vignette/VignetteGame.cs
@@ -3,18 +3,34 @@
using System;
using Sekai;
+using Vignette.Content;
using Vignette.Graphics;
namespace Vignette;
public sealed class VignetteGame : Game
{
+ private Window root = null!;
+ private Camera camera = null!;
private Renderer renderer = null!;
- private readonly Window root = new();
+ private ContentManager content = null!;
+ private ServiceLocator services = null!;
public override void Load()
{
+ content = new(Storage);
+ content.Add(new ShaderLoader(), ".hlsl");
+ content.Add(new TextureLoader(Graphics), ".png", ".jpg", ".jpeg", ".bmp", ".gif");
+
renderer = new(Graphics);
+
+ services = new();
+ services.Add(content);
+
+ root = new(services)
+ {
+ (camera = new Camera { ProjectionMode = CameraProjectionMode.OrthographicOffCenter })
+ };
}
public override void Draw()
@@ -24,6 +40,7 @@ public override void Draw()
public override void Update(TimeSpan elapsed)
{
+ camera.ViewSize = Window.Size;
root.Update(elapsed);
}