Skip to content

Instantly share code, notes, and snippets.

@RonenNess
Created September 8, 2021 21:12
Show Gist options
  • Save RonenNess/7cec9287402175cc9292f0f4e58bc8ff to your computer and use it in GitHub Desktop.
Save RonenNess/7cec9287402175cc9292f0f4e58bc8ff to your computer and use it in GitHub Desktop.
A utility class to load raw assets into MonoGame without building them as xnb first. To use this, add the MonoGame.Framework.Content.Pipeline and MonoGame.Framework.xxx NuGet packages.
using System;
using System.Linq;
using System.Collections.Generic;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Audio;
using MonoGame.Framework.Content.Pipeline.Builder;
using System.Reflection;
using System.IO;
using Microsoft.Xna.Framework.Content.Pipeline.Audio;
using Microsoft.Xna.Framework.Media;
using Microsoft.Xna.Framework;
namespace MonoGame.FreeLoader
{
/// <summary>
/// An asset loader for MonoGame to import raw files (textures, fbx etc) without needing to build them upfront via the monogame content manager tool.
/// In other words, use this class to load models and assets directly into MonoGame project.
/// Author: Ronen Ness
/// Date: 09/2021
/// </summary>
public class AssetsLoader
{
// graphics device
GraphicsDevice _graphics;
// context objects
PipelineImporterContext _importContext;
PipelineProcessorContext _processContext;
// importers
OpenAssetImporter _openImporter;
EffectImporter _effectImporter;
FontDescriptionImporter _fontImporter;
Dictionary<string, ContentImporter<AudioContent>> _soundImporters = new Dictionary<string, ContentImporter<AudioContent>>();
// processors
ModelProcessor _modelProcessor;
EffectProcessor _effectProcessor;
FontDescriptionProcessor _fontProcessor;
// loaded assets caches
Dictionary<string, Object> _loadedAssets = new Dictionary<string, Object>();
// default effect to assign to all meshes
public BasicEffect DefaultEffect;
/// <summary>
/// Method to generate / return effect per mesh part (or null to use default).
/// </summary>
public EffectsGenerator EffectsGenerator;
/// <summary>
/// Create the assets loader.
/// </summary>
public AssetsLoader(GraphicsDevice graphics)
{
_graphics = graphics;
_openImporter = new OpenAssetImporter();
_effectImporter = new EffectImporter();
_soundImporters[".wav"] = new WavImporter();
_soundImporters[".ogg"] = new OggImporter();
_soundImporters[".wma"] = new WmaImporter();
_soundImporters[".mp3"] = new Mp3Importer();
_fontImporter = new FontDescriptionImporter();
string projectDir = "_proj";
string outputDir = "_output";
string intermediateDir = "_inter";
var pipelineManager = new PipelineManager(projectDir, outputDir, intermediateDir);
_importContext = new PipelineImporterContext(pipelineManager);
_processContext = new PipelineProcessorContext(pipelineManager, new PipelineBuildEvent());
_modelProcessor = new ModelProcessor();
_effectProcessor = new EffectProcessor();
_fontProcessor = new FontDescriptionProcessor();
DefaultEffect = new BasicEffect(_graphics);
}
/// <summary>
/// Clear models cache.
/// </summary>
public void ClearCache()
{
_loadedAssets.Clear();
}
/// <summary>
/// Validate given path, and attempt to return from cache.
/// Will return true if found in cache, false otherwise (fromCache will return result).
/// Will throw exceptions if path not found, or cache contains wrong type.
/// </summary>
bool ValidatePathAndGetCached<T>(string assetPath, out T fromCache) where T : class
{
// get from cache
if (_loadedAssets.TryGetValue(assetPath, out object cached))
{
fromCache = cached as T;
if (fromCache == null) { throw new InvalidOperationException($"Asset path found in cache, but has a wrong type! Expected type: '{(typeof(T)).Name}', found type: '{cached.GetType().Name}'."); }
return true;
}
// make sure file exists
if (!File.Exists(assetPath))
{
throw new FileNotFoundException($"{(typeof(T)).Name} asset file '{assetPath}' not found!", assetPath);
}
// not found in cache
fromCache = null;
return false;
}
/// <summary>
/// Load a model from path.
/// </summary>
/// <param name="modelPath">Model file path.</param>
/// <returns>MonoGame model.</returns>
public Model LoadModel(string modelPath)
{
// validate path and get from cache
if (ValidatePathAndGetCached(modelPath, out Model cached))
{
return cached;
}
// load model and convert to model content
var node = _openImporter.Import(modelPath, _importContext);
ModelContent modelContent = _modelProcessor.Process(node, _processContext);
// sanity
if (modelContent.Meshes.Count == 0)
{
throw new FormatException("Model file contains 0 meshes (could it be corrupted or unsupported type?)");
}
// extract bones
var bones = new List<ModelBone>();
foreach (var boneContent in modelContent.Bones)
{
var bone = new ModelBone
{
Transform = boneContent.Transform,
Index = bones.Count,
Name = boneContent.Name,
ModelTransform = modelContent.Root.Transform
};
bones.Add(bone);
}
// resolve bones hirarchy
for (var index = 0; index < bones.Count; ++index)
{
var bone = bones[index];
var content = modelContent.Bones[index];
if (content.Parent != null && content.Parent.Index != -1)
{
bone.Parent = bones[content.Parent.Index];
bone.Parent.AddChild(bone);
}
}
// extract meshes
var meshes = new List<ModelMesh>();
foreach (var meshContent in modelContent.Meshes)
{
// get params
var name = meshContent.Name;
var parentBoneIndex = meshContent.ParentBone.Index;
var boundingSphere = meshContent.BoundingSphere;
var meshTag = meshContent.Tag;
// extract parts
var parts = new List<ModelMeshPart>();
foreach (var partContent in meshContent.MeshParts)
{
// build index buffer
IndexBuffer indexBuffer = new IndexBuffer(_graphics, IndexElementSize.ThirtyTwoBits, partContent.IndexBuffer.Count, BufferUsage.WriteOnly);
{
Int32[] data = new Int32[partContent.IndexBuffer.Count];
partContent.IndexBuffer.CopyTo(data, 0);
indexBuffer.SetData(data);
}
// build vertex buffer
var vbDeclareContent = partContent.VertexBuffer.VertexDeclaration;
List<VertexElement> elements = new List<VertexElement>();
foreach (var declareContentElem in vbDeclareContent.VertexElements)
{
elements.Add(new VertexElement(declareContentElem.Offset, declareContentElem.VertexElementFormat, declareContentElem.VertexElementUsage, declareContentElem.UsageIndex));
}
var vbDeclare = new VertexDeclaration(elements.ToArray());
VertexBuffer vertexBuffer = new VertexBuffer(_graphics, vbDeclare, partContent.NumVertices, BufferUsage.WriteOnly);
{
vertexBuffer.SetData(partContent.VertexBuffer.VertexData);
}
// create and add part
#pragma warning disable CS0618 // Type or member is obsolete
ModelMeshPart part = new ModelMeshPart()
{
VertexOffset = partContent.VertexOffset,
NumVertices = partContent.NumVertices,
PrimitiveCount = partContent.PrimitiveCount,
StartIndex = partContent.StartIndex,
Tag = partContent.Tag,
IndexBuffer = indexBuffer,
VertexBuffer = vertexBuffer
};
#pragma warning restore CS0618 // Type or member is obsolete
parts.Add(part);
}
// create and add mesh to meshes list
var mesh = new ModelMesh(_graphics, parts)
{
Name = name,
BoundingSphere = boundingSphere,
Tag = meshTag,
};
meshes.Add(mesh);
// set parts effect (note: this must come *after* we add parts to the mesh otherwise we get exception).
foreach (var part in parts)
{
var effect = EffectsGenerator != null ? EffectsGenerator(modelPath, modelContent, part) ?? DefaultEffect : DefaultEffect;
part.Effect = effect;
}
// add to parent bone
if (parentBoneIndex != -1)
{
mesh.ParentBone = bones[parentBoneIndex];
mesh.ParentBone.AddMesh(mesh);
}
}
// create model
var model = new Model(_graphics, bones, meshes);
model.Root = bones[modelContent.Root.Index];
model.Tag = modelContent.Tag;
// we need to call BuildHierarchy() but its internal, so we use reflection to access it ¯\_(ツ)_/¯
var methods = model.GetType().GetMethods(BindingFlags.Instance | BindingFlags.NonPublic);
var BuildHierarchy = methods.Where(x => x.Name == "BuildHierarchy" && x.GetParameters().Length == 0).First();
BuildHierarchy.Invoke(model, null);
// add to cache and return
_loadedAssets[modelPath] = model;
return model;
}
/// <summary>
/// Load an effect from path.
/// Note: requires the mgfxc dll to work.
/// </summary>
/// <param name="effectFile">Effect file path.</param>
/// <returns>MonoGame Effect.</returns>
public Effect LoadEffect(string effectFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(effectFile, out Effect cached))
{
return cached;
}
// create effect
var effectContent = _effectImporter.Import(effectFile, _importContext);
var effectData = _effectProcessor.Process(effectContent, _processContext);
var dataBuffer = effectData.GetEffectCode();
var effect = new Effect(_graphics, dataBuffer, 0, dataBuffer.Length);
// add to cache and return
_loadedAssets[effectFile] = effect;
return effect;
}
/// <summary>
/// Load a compiled effect (.fx file that was built via mgfxc) from path.
/// To build .fx files into compiled shaders:
/// 1. run `dotnet tool install -g dotnet-mgfxc` to get the building tool.
/// 2. Run `mgfxc <SourceFile> <OutputFile> /Profile:OpenGL` to build the shader (you can change the Profile param for DX or PS, check out --help).
/// </summary>
/// <param name="effectFile">Effect file path.</param>
/// <returns>MonoGame Effect.</returns>
public Effect LoadCompiledEffect(string effectFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(effectFile, out Effect cached))
{
return cached;
}
// create effect
byte[] bytecode = File.ReadAllBytes(effectFile);
var effect = new Effect(_graphics, bytecode, 0, bytecode.Length);
// add to cache and return
_loadedAssets[effectFile] = effect;
return effect;
}
/// <summary>
/// Load a sound effect from file.
/// </summary>
/// <param name="soundFile">Sound effect file path.</param>
/// <returns>MonoGame SoundEffect.</returns>
public SoundEffect LoadSound(string soundFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(soundFile, out SoundEffect cached))
{
return cached;
}
// import audio
var extension = Path.GetExtension(soundFile).ToLower();
if (!_soundImporters.ContainsKey(extension))
{
throw new InvalidContentException($"Invalid sound file type '{extension}'. Can only load sound files of types: '{string.Join(',', _soundImporters.Keys)}'.");
}
var audioContent = _soundImporters[extension].Import(soundFile, _importContext);
// create sound and return
byte[] data = new byte[audioContent.Data.Count];
audioContent.Data.CopyTo(data, 0);
var sound = new SoundEffect(data, 0, data.Length, audioContent.Format.SampleRate, audioContent.Format.ChannelCount == 1 ? AudioChannels.Mono : AudioChannels.Stereo, audioContent.LoopStart, audioContent.LoopLength);
// add to cache and return
_loadedAssets[soundFile] = sound;
return sound;
}
/// <summary>
/// Load a spritefont from file.
/// </summary>
/// <param name="fontFile">Spritefont file path (xml file describing the spritefont).</param>
/// <returns>MonoGame SpriteFont.</returns>
public SpriteFont LoadSpriteFont(string fontFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(fontFile, out SpriteFont cached))
{
return cached;
}
// import spritefont xml file
var fontDescription = _fontImporter.Import(fontFile, _importContext);
var spriteFontContent = _fontProcessor.Process(fontDescription, _processContext);
// create spritefont
var textureContent = spriteFontContent.Texture.Mipmaps[0];
textureContent.TryGetFormat(out SurfaceFormat format);
Texture2D texture = new Texture2D(_graphics, textureContent.Width, textureContent.Height, false, format);
texture.SetData(textureContent.GetPixelData());
List<Rectangle> glyphBounds = spriteFontContent.Glyphs;
List<Rectangle> cropping = spriteFontContent.Cropping;
List<char> characters = spriteFontContent.CharacterMap;
int lineSpacing = spriteFontContent.VerticalLineSpacing;
float spacing = spriteFontContent.HorizontalSpacing;
List<Vector3> kerning = spriteFontContent.Kerning;
char? defaultCharacter = spriteFontContent.DefaultCharacter;
var sf = new SpriteFont(texture, glyphBounds, cropping, characters, lineSpacing, spacing, kerning, defaultCharacter);
// add to cache and return
_loadedAssets[fontFile] = sf;
return sf;
}
/// <summary>
/// Load a song from file.
/// </summary>
/// <param name="songFile">Song file path.</param>
/// <returns>MonoGame Song.</returns>
public Song LoadSong(string songFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(songFile, out Song cached))
{
return cached;
}
// load song
var name = Path.GetFileNameWithoutExtension(songFile);
var song = Song.FromUri(name, new Uri(songFile));
// add to cache and return
_loadedAssets[songFile] = song;
return song;
}
/// <summary>
/// Load a texture from path.
/// </summary>
/// <param name="textureFile">Texture file path.</param>
/// <returns>MonoGame Texture2D.</returns>
public Texture2D LoadTexture(string textureFile)
{
// validate path and get from cache
if (ValidatePathAndGetCached(textureFile, out Texture2D cached))
{
return cached;
}
// load texture
FileStream fileStream = new FileStream(textureFile, FileMode.Open);
Texture2D loadedTexture = Texture2D.FromStream(_graphics, fileStream);
fileStream.Dispose();
// add to cache and return
_loadedAssets[textureFile] = loadedTexture;
return loadedTexture;
}
}
/// <summary>
/// A method to generate / return effect per mesh part.
/// </summary>
/// <param name="modelPath">Model path this part belongs to.</param>
/// <param name="modelContent">Loaded model raw content.</param>
/// <param name="part">Part instance we want to create effect for.</param>
/// <returns>Effect instance or null to use default.</returns>
public delegate Effect EffectsGenerator(string modelPath, ModelContent modelContent, ModelMeshPart part);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment