diff --git a/.gitattributes b/.gitattributes
index 1b9fa9897e25dff2bf7cf2c9f0caa97af12411a9..1e2d8988161bac54258c860903d5a3e138ba9199 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,5 @@
 *,ogg filter=lfs diff=lfs merge=lfs -text
+*.mtl filter=lfs diff=lfs merge=lfs -text
+*.jpg filter=lfs diff=lfs merge=lfs -text
+*.obj filter=lfs diff=lfs merge=lfs -text
+*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/demo/CMakeLists.txt b/demo/CMakeLists.txt
index facfd04a2fcd5bead3edcf12c513f20c37436fd2..f6343045ba9645ecfa92e663b31d9073bcc0b19c 100644
--- a/demo/CMakeLists.txt
+++ b/demo/CMakeLists.txt
@@ -9,6 +9,7 @@ add_executable(demo
         demo/topdown.cpp
         demo/grid.cpp
         demo/robot/programmer.cpp
+        demo/models/viewer.cpp
 )
 
 target_include_directories(demo
diff --git a/demo/data/backpack/ao.jpg b/demo/data/backpack/ao.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..0b28edbae703ba2256a2b0ed20abda2283c8901e
--- /dev/null
+++ b/demo/data/backpack/ao.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d7b0855da6e4f621546b5bbf8a41bc0963a898c44ccce578c89e6bf929cdcfd7
+size 1895126
diff --git a/demo/data/backpack/backpack.mtl b/demo/data/backpack/backpack.mtl
new file mode 100644
index 0000000000000000000000000000000000000000..868905efd2453d53787ff8fbd967f4609bb5767a
--- /dev/null
+++ b/demo/data/backpack/backpack.mtl
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:f9aaffb7ce3f968c01c3ab0e2de60fa38dff9695993c79d082c2e0a10c115cb5
+size 278
diff --git a/demo/data/backpack/backpack.obj b/demo/data/backpack/backpack.obj
new file mode 100644
index 0000000000000000000000000000000000000000..9c182198f48ac01868bd76de8cdd4ab175de07db
--- /dev/null
+++ b/demo/data/backpack/backpack.obj
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8d3253f4032314c72353b79cbe589ec5751f50c294668cfd55fe70988e1d64ea
+size 6998040
diff --git a/demo/data/backpack/diffuse.jpg b/demo/data/backpack/diffuse.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..cc28b9f601204ca9b7ccbbd32ce2bc180db6e100
--- /dev/null
+++ b/demo/data/backpack/diffuse.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bbac15f06974d2ff27ea3aa3c4c555025b4d90bef533a23bd6c89630efc8f582
+size 6108855
diff --git a/demo/data/backpack/normal.png b/demo/data/backpack/normal.png
new file mode 100644
index 0000000000000000000000000000000000000000..0f1e060b3fdf63b51efffe78c3d1148ef615a8c8
--- /dev/null
+++ b/demo/data/backpack/normal.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d4a2f93658af8c4881cfa6ac329993879adfb7b3ce48000a3928f20593a9908d
+size 15214871
diff --git a/demo/data/backpack/roughness.jpg b/demo/data/backpack/roughness.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..2668d9232b71cc399eadb03b22dd431f8e53d604
--- /dev/null
+++ b/demo/data/backpack/roughness.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8e6d31f76cd1cc19694c99f4c2d05dc75baf5f09580439ddf5f3cb934779df08
+size 4388250
diff --git a/demo/data/backpack/source_attribution.txt b/demo/data/backpack/source_attribution.txt
new file mode 100644
index 0000000000000000000000000000000000000000..31bb94894086d91f0b021c01e81ce135da652961
--- /dev/null
+++ b/demo/data/backpack/source_attribution.txt
@@ -0,0 +1,3 @@
+Model by Berk Gedik, from: https://sketchfab.com/3d-models/survival-guitar-backpack-low-poly-799f8c4511f84fab8c3f12887f7e6b36
+
+Modified material assignment (Joey de Vries) for easier load in OpenGL model loading chapter, and renamed albedo to diffuse and metallic to specular to match non-PBR lighting setup.
\ No newline at end of file
diff --git a/demo/data/backpack/specular.jpg b/demo/data/backpack/specular.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..08395c7e43dc1c8ede92873fe020fed4ed88a3cc
--- /dev/null
+++ b/demo/data/backpack/specular.jpg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:dfc3fac4a52b709caa998aec8699ae2421f59ae8c353c7e05779cd93cff8153b
+size 6722296
diff --git a/demo/demo/main.cpp b/demo/demo/main.cpp
index d9c058759279cf06976427d0e50dbede977eef7a..32281693246c4b32c52c1a8d37accd8e7de2c026 100644
--- a/demo/demo/main.cpp
+++ b/demo/demo/main.cpp
@@ -31,14 +31,13 @@
 #include "fggl/entity/module.hpp"
 
 #include "fggl/audio/openal/audio.hpp"
-
-#include "fggl/gfx/atlas.hpp"
 #include "fggl/display/glfw/window.hpp"
 
-#include "fggl/platform/paths.hpp"
 
 #include "fggl/scenes/menu.hpp"
 #include "fggl/modules/manager.hpp"
+
+#include "fggl/data/assimp/module.hpp"
 #include "fggl/assets/module.hpp"
 #include "fggl/script/lua/module.hpp"
 
@@ -46,13 +45,14 @@
 #include "rollball.hpp"
 #include "topdown.hpp"
 #include "grid.hpp"
+#include "models/viewer.hpp"
 
 static void setup_menu(fggl::App& app) {
 	auto *menu = app.addState<fggl::scenes::BasicMenu>("menu");
 
 	// add some menu items for the game states
-	const std::array labels = {"terrain", "rollball", "Top Down", "Grid World"};
-	const std::array scenes = {"game", "rollball", "topdown", "gridworld"};
+	const std::array labels = {"terrain", "rollball", "Top Down", "Grid World", "Viewer"};
+	const std::array scenes = {"game", "rollball", "topdown", "gridworld", "viewer"};
 
 	for (std::size_t i = 0; i < labels.size(); ++i) {
 		std::string sceneName = scenes.at(i);
@@ -85,6 +85,9 @@ int main(int argc, const char* argv[]) {
 	moduleManager.use<fggl::entity::ECS>();
 	moduleManager.use<fggl::script::Lua>();
 
+	// debug/testing use
+	moduleManager.use<fggl::data::AssimpLoader>();
+
 	#ifdef FGGL_MODULE_BULLET
 		moduleManager.use<fggl::phys::Bullet3>();
 	#else
@@ -109,6 +112,7 @@ int main(int argc, const char* argv[]) {
 	app.addState<demo::RollBall>("rollball");
 	app.addState<demo::TopDown>("topdown");
 	app.addState<demo::GridScene>("gridworld");
+	app.addState<demo::Viewer>("viewer");
 
 	return app.run(argc, argv);
 }
diff --git a/demo/demo/models/viewer.cpp b/demo/demo/models/viewer.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..048b8e563d608e0820679304a846277545f38305
--- /dev/null
+++ b/demo/demo/models/viewer.cpp
@@ -0,0 +1,132 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 18/10/22.
+//
+
+#include "models/viewer.hpp"
+
+#include "fggl/assets/module.hpp"
+#include "fggl/data/assimp/module.hpp"
+
+#include "fggl/mesh/mesh.hpp"
+#include "fggl/mesh/components.hpp"
+
+#include "fggl/input/camera_input.hpp"
+
+#include "fggl/gfx/phong.hpp"
+#include "fggl/gfx/camera.hpp"
+
+namespace demo {
+
+	static fggl::entity::EntityID build_model(fggl::entity::EntityManager& manager, fggl::assets::AssetManager *assets){
+		auto model = manager.create();
+
+		manager.add<fggl::math::Transform>(model);
+		auto& mesh = manager.add<fggl::mesh::StaticMultiMesh3D>(model);
+
+		auto* meshData = assets->get<fggl::mesh::MultiMesh3D>("backpack/backpack.obj");
+		if ( meshData == nullptr ) {
+			fggl::debug::warning("loading model did not work!");
+		} else {
+			mesh.mesh = *meshData;
+			mesh.pipeline = "redbook/debug";
+		}
+
+		auto& material = manager.add<fggl::gfx::PhongMaterial>(model);
+
+		return model;
+	}
+
+	static void process_camera(fggl::entity::EntityManager& ecs, const fggl::input::Input& input) {
+		auto cameras = ecs.find<fggl::gfx::Camera>();
+		auto cam = cameras[0];
+
+		auto camTransform = ecs.get<fggl::math::Transform>(cam);
+		auto camComp = ecs.get<fggl::gfx::Camera>(cam);
+
+		const glm::vec3 dir = ( camTransform.origin() - camComp.target );
+		const glm::vec3 forward = glm::normalize( dir );
+
+		// scroll wheel
+		glm::vec3 motion(0.0f);
+		float delta = input.mouse.axis( fggl::input::MouseAxis::SCROLL_Y );
+		if ( (glm::length( dir ) < 25.0f && delta < 0.0f) || (glm::length( dir ) > 2.5f && delta > 0.0f) )
+			motion -= (forward * delta);
+		camTransform.origin( camTransform.origin() + motion );
+
+		fggl::input::process_arcball(ecs, input, cam);
+	}
+
+	static void setup_camera(fggl::entity::EntityManager& world) {
+		auto prototype = world.create();
+
+		// setup camera position/transform
+		auto& transform = world.add<fggl::math::Transform>(prototype);
+		transform.origin(glm::vec3(10.0f, 3.0f, 10.0f));
+
+		// setup camera components
+		world.add<fggl::gfx::Camera>(prototype);
+
+		// interactive camera
+		auto& cameraKeys = world.add<fggl::input::FreeCamKeys>(prototype);
+		cameraKeys.forward = glfwGetKeyScancode(GLFW_KEY_W);
+		cameraKeys.backward = glfwGetKeyScancode(GLFW_KEY_S);
+		cameraKeys.left = glfwGetKeyScancode(GLFW_KEY_A);
+		cameraKeys.right = glfwGetKeyScancode(GLFW_KEY_D);
+		cameraKeys.rotate_cw = glfwGetKeyScancode(GLFW_KEY_Q);
+		cameraKeys.rotate_ccw = glfwGetKeyScancode(GLFW_KEY_E);
+	}
+
+	Viewer::Viewer(fggl::App &app) : fggl::scenes::Game(app), m_model(fggl::entity::INVALID) {
+
+	}
+
+	void Viewer::activate() {
+		Game::activate();
+
+		// force load required data
+		auto *loader = owner().service<fggl::assets::Loader>();
+		auto *manager = owner().service<fggl::assets::AssetManager>();
+
+		loader->load("backpack/backpack.obj", fggl::data::models::ASSIMP_MODEL, manager);
+
+		// create camera
+		setup_camera(world());
+
+		// setup model
+		m_model = build_model(world(), manager);
+
+		// asset loader
+
+		//loader->
+	}
+
+	void Viewer::deactivate() {
+		Game::deactivate();
+	}
+
+	void Viewer::update(float dt) {
+		Game::update(dt);
+		process_camera(world(), input());
+	}
+
+	void Viewer::render(fggl::gfx::Graphics &gfx) {
+		Game::render(gfx);
+
+		gfx.drawScene(world());
+	}
+
+}
diff --git a/demo/include/models/viewer.hpp b/demo/include/models/viewer.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..7e29623eb02c4e523580495759ab7c0cb36876e0
--- /dev/null
+++ b/demo/include/models/viewer.hpp
@@ -0,0 +1,43 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 18/10/22.
+//
+
+#ifndef FGGL_DEMO_INCLUDE_MODELS_VIEWER_HPP
+#define FGGL_DEMO_INCLUDE_MODELS_VIEWER_HPP
+
+#include "fggl/scenes/game.hpp"
+
+namespace demo {
+
+	class Viewer : public fggl::scenes::Game {
+
+		public:
+			explicit Viewer(fggl::App& app);
+
+			void activate() override;
+			void deactivate() override;
+
+			void update(float dt) override;
+			void render(fggl::gfx::Graphics& gfx) override;
+
+		private:
+			fggl::entity::EntityID m_model;
+
+	};
+}
+
+#endif //FGGL_DEMO_INCLUDE_MODELS_VIEWER_HPP
diff --git a/fggl/CMakeLists.txt b/fggl/CMakeLists.txt
index a62f5fcb5b4a0ba73e7ab21f237690796cb01edf..d054dc3bdbfe3c41dded56b8db69153a24a92352 100644
--- a/fggl/CMakeLists.txt
+++ b/fggl/CMakeLists.txt
@@ -65,12 +65,7 @@ find_package(yaml-cpp)
 target_link_libraries(fggl PUBLIC yaml-cpp)
 
 # model loading
-find_package(assimp CONFIG)
-if (MSVC)
-    target_link_libraries(${PROJECT_NAME} PUBLIC assimp::assimp)
-else ()
-    target_link_libraries(${PROJECT_NAME} PUBLIC assimp)
-endif ()
+add_subdirectory(data/assimp)
 
 find_package(Freetype)
 target_link_libraries(${PROJECT_NAME} PUBLIC Freetype::Freetype)
diff --git a/fggl/data/assimp/CMakeLists.txt b/fggl/data/assimp/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..1daab2bba0c24f17c093d3d51e036494d39e7768
--- /dev/null
+++ b/fggl/data/assimp/CMakeLists.txt
@@ -0,0 +1,11 @@
+find_package(assimp CONFIG)
+if (MSVC)
+    target_link_libraries(${PROJECT_NAME} PUBLIC assimp::assimp)
+else ()
+    target_link_libraries(${PROJECT_NAME} PUBLIC assimp)
+endif ()
+
+target_sources(fggl
+        PRIVATE
+            module.cpp
+)
\ No newline at end of file
diff --git a/fggl/data/assimp/module.cpp b/fggl/data/assimp/module.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..99d2a63b8d56c4dc7cfb2c0b302246ff9f125b1a
--- /dev/null
+++ b/fggl/data/assimp/module.cpp
@@ -0,0 +1,116 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 18/10/22.
+//
+
+#include "fggl/data/assimp/module.hpp"
+#include "fggl/data/model.hpp"
+#include "fggl/debug/logging.hpp"
+#include "fggl/assets/manager.hpp"
+#include "fggl/mesh/mesh.hpp"
+
+#include <assimp/Importer.hpp>
+#include <assimp/scene.h>
+#include <assimp/postprocess.h>
+
+namespace fggl::data::models {
+
+	constexpr math::vec3 convert(aiVector3D& vec) {
+		return {vec.x, vec.y, vec.z};
+	}
+
+	constexpr math::vec2 convert(aiVector2D& vec) {
+		return {vec.x, vec.y};
+	}
+
+	static void process_mesh(mesh::Mesh3D& mesh, aiMesh* assimpMesh, const aiScene* scene) {
+		assert( assimpMesh != nullptr );
+		assert( scene != nullptr );
+
+		for ( auto idx = 0U; idx < assimpMesh->mNumVertices; ++idx ) {
+			mesh::Vertex3D vertex{
+				.position = convert(assimpMesh->mVertices[idx]),
+				.normal = convert(assimpMesh->mNormals[idx])
+			};
+
+			if ( assimpMesh->mTextureCoords[0] != nullptr ) {
+				vertex.texPos = convert( assimpMesh->mTextureCoords[0][idx] );
+			}
+
+			mesh.data.push_back(vertex);
+		}
+
+		for ( auto idx = 0U; idx < assimpMesh->mNumFaces; ++idx ) {
+			auto face = assimpMesh->mFaces[idx];
+			assert( face.mNumIndices == 3);
+
+			for ( auto vid = 0U; vid < face.mNumIndices; ++vid) {
+				mesh.indices.push_back( face.mIndices[vid] );
+			}
+		}
+	}
+
+	static void process_node(mesh::MultiMesh3D& mesh, aiNode* node, const aiScene* scene) {
+		for ( auto idx = 0U; idx < node->mNumMeshes; ++idx ) {
+			auto *assimpMesh = scene->mMeshes[ node->mMeshes[idx] ];
+
+			// process assimp submesh
+			mesh::Mesh3D meshData;
+			process_mesh(meshData, assimpMesh, scene);
+
+			mesh.meshes.push_back(meshData);
+		}
+
+		for ( auto idx = 0U; idx < node->mNumChildren; ++idx) {
+			process_node(mesh, node->mChildren[idx], scene);
+		}
+	}
+
+
+	static assets::AssetRefRaw load_assimp_model(assets::Loader* loader, const assets::AssetGUID& guid, const assets::AssetData& data, void* userPtr) {
+		auto *filePath = std::get<assets::AssetPath *>(data);
+
+		// assimp stuff
+		Assimp::Importer importer;
+
+		// import the scene from disk
+		const aiScene *scene = importer.ReadFile(filePath->c_str(), aiProcess_Triangulate | aiProcess_FlipUVs);
+		if ( !scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode ) {
+			debug::warning("unable to load required model asset");
+			return nullptr;
+		}
+
+		debug::debug("Processing assimp mesh, {} meshes", scene->mNumMeshes);
+
+		// now we can try importing the mesh data
+		mesh::MultiMesh3D* packedMesh = new mesh::MultiMesh3D();
+		process_node( *packedMesh, scene->mRootNode, scene);
+
+		// FIXME asset loading system needs rework, this is bonkers.
+		assets::AssetManager* manager = (assets::AssetManager*)userPtr;
+		manager->set(guid, packedMesh);
+
+		return nullptr;
+	}
+
+	bool AssimpModule::factory(modules::ModuleService service, modules::Services &serviceManager) {
+		if ( service == MODEL_PROVIDER ) {
+			auto assetLoader = serviceManager.get<assets::Loader>();
+			assetLoader->setFactory( ASSIMP_MODEL, load_assimp_model, assets::LoadType::PATH );
+		}
+	}
+
+} // namespace fggl::data
diff --git a/fggl/gfx/ogl4/CMakeLists.txt b/fggl/gfx/ogl4/CMakeLists.txt
index e00047055ea3833dcbeb550666289faa81626ef6..fee95dc0bb88c9d4ecd5fe26949f74d79610965b 100644
--- a/fggl/gfx/ogl4/CMakeLists.txt
+++ b/fggl/gfx/ogl4/CMakeLists.txt
@@ -6,5 +6,6 @@ target_sources(fggl
         canvas.cpp
         models.cpp
         debug.cpp
+        meshes.cpp
         module.cpp
         )
diff --git a/fggl/gfx/ogl4/meshes.cpp b/fggl/gfx/ogl4/meshes.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6f8fc8624d4c8bc817e6cde2b6aa78722e44d372
--- /dev/null
+++ b/fggl/gfx/ogl4/meshes.cpp
@@ -0,0 +1,213 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+#include "fggl/gfx/ogl4/meshes.hpp"
+
+
+//
+// Created by webpigeon on 22/10/22.
+//
+
+namespace fggl::gfx::ogl4 {
+
+	static std::shared_ptr<ogl::ArrayBuffer> setup_array_buffer(std::shared_ptr<ogl::VertexArray> &vao,
+															  const std::vector<mesh::Vertex3D> &data) {
+		// upload the data to the GPU
+		auto buff = std::make_shared<ogl::ArrayBuffer>();
+		buff->write(data.size() * sizeof(mesh::Vertex3D), data.data(), ogl::BufUsage::STATIC_DRAW);
+
+		// set up the vertex attributes
+		auto posAttr = ogl::attribute<mesh::Vertex3D, math::vec3>(offsetof(mesh::Vertex3D, position));
+		auto normalAttr = ogl::attribute<mesh::Vertex3D, math::vec3>(offsetof(mesh::Vertex3D, normal));
+		auto colAttr = ogl::attribute<mesh::Vertex3D, math::vec3>(offsetof(mesh::Vertex3D, colour));
+		auto texAttr = ogl::attribute<mesh::Vertex3D, math::vec3>(offsetof(mesh::Vertex3D, colour));
+
+		vao->setAttribute(*buff, 0, posAttr);
+		vao->setAttribute(*buff, 1, normalAttr);
+		vao->setAttribute(*buff, 2, colAttr);
+		vao->setAttribute(*buff, 3, texAttr);
+
+		return buff;
+	}
+
+	static std::shared_ptr<ogl::ElementBuffer> setup_index_buffer(const std::vector<uint32_t>& data) {
+		auto elementBuffer = std::make_shared<ogl::ElementBuffer>();
+		elementBuffer->write(data.size() * sizeof(uint32_t),
+							 data.data(), ogl::BufUsage::STATIC_DRAW);
+		return elementBuffer;
+	}
+
+	void MeshData::draw() const {
+		vao->bind();
+		vertexData->bind();
+
+		if ( drawInfo.restartIndex != ogl::NO_RESTART_IDX) {
+			glEnable(GL_PRIMITIVE_RESTART);
+			glPrimitiveRestartIndex( drawInfo.restartIndex );
+		}
+
+		vao->drawElements(*elements, drawInfo.mode, elementCount);
+
+		if ( drawInfo.restartIndex != ogl::NO_RESTART_IDX ) {
+			glDisable(GL_PRIMITIVE_RESTART);
+		}
+	}
+
+	void StaticMultiMesh::draw() const {
+		for ( const auto& mesh : meshes ) {
+			mesh.draw();
+		}
+	}
+
+	void setup_material(const std::shared_ptr<ogl::Shader>& shader, const PhongMaterial* material) {
+		if ( !shader->hasUniform("materials[0].ambient") ) {
+			return;
+		}
+
+		// setup material block
+		shader->setUniformF(shader->uniform("materials[0].emission"), material->emission);
+		shader->setUniformF(shader->uniform("materials[0].ambient"), material->ambient);
+		shader->setUniformF(shader->uniform("materials[0].diffuse"), material->diffuse);
+		shader->setUniformF(shader->uniform("materials[0].specular"), material->specular);
+		shader->setUniformF(shader->uniform("materials[0].shininess"), material->shininess);
+	}
+
+	void setup_lighting(const std::shared_ptr<ogl::Shader>& shader, const math::mat4& viewMatrix, const math::Transform& camTransform, const math::Transform& transform, math::vec3 lightPos) {
+		if (shader->hasUniform("lightPos")) {
+			shader->setUniformF(shader->uniform("lightPos"), lightPos);
+		}
+
+		if ( !shader->hasUniform("lights[0].isEnabled") ) {
+			return;
+		}
+
+		bool local = true;
+
+		shader->setUniformI(shader->uniform("lights[0].isEnabled"), 1);
+		shader->setUniformI(shader->uniform("lights[0].isLocal"), local);
+		shader->setUniformI(shader->uniform("lights[0].isSpot"), 0);
+
+		shader->setUniformF(shader->uniform("lights[0].constantAttenuation"), 5.0f);
+		shader->setUniformF(shader->uniform("lights[0].linearAttenuation"), 0.0f);
+		shader->setUniformF(shader->uniform("lights[0].quadraticAttenuation"), 0.0f);
+
+		shader->setUniformF(shader->uniform("Strength"), 0.6F);
+
+		if (!local) {
+			lightPos = glm::normalize(lightPos);
+			auto viewDir = glm::normalize(camTransform.origin() - transform.origin());
+			auto halfVector = glm::normalize(lightPos + viewDir);
+			shader->setUniformF(shader->uniform("lights[0].halfVector"), halfVector);
+			shader->setUniformF(shader->uniform("EyeDirection"), viewDir);
+			shader->setUniformF(shader->uniform("lights[0].position"), lightPos);
+		} else {
+			auto camModelView = (viewMatrix * camTransform.model() * math::vec4(0.0f, 0.0f, 0.0f, 1.0f));
+			auto modelModelView = (viewMatrix * transform.model() * math::vec4(0.0f, 0.0f, 0.0f, 1.0f));
+			math::vec3 viewDir = glm::normalize(camModelView - modelModelView);
+			shader->setUniformF(shader->uniform("EyeDirection"), viewDir);
+
+			shader->setUniformF(shader->uniform("lights[0].position"),
+								math::vec3(viewMatrix * math::vec4(lightPos, 1.0f)));
+		}
+
+		shader->setUniformF(shader->uniform("lights[0].ambient"), {0.0f, 0.5f, 0.0f});
+		shader->setUniformF(shader->uniform("lights[0].colour"), {0.5f, 0.5f, 0.5f});
+	}
+
+
+	void forward_pass_multi_mesh(const entity::EntityID& camera, const fggl::entity::EntityManager& world) {
+
+		// enable required OpenGL state
+		glEnable(GL_CULL_FACE);
+		glCullFace(GL_BACK);
+
+		// enable depth testing
+		glEnable(GL_DEPTH_TEST);
+
+		// set-up camera matrices
+		const auto &camTransform = world.get<fggl::math::Transform>(camera);
+		const auto &camComp = world.get<fggl::gfx::Camera>(camera);
+
+		const math::mat4 projectionMatrix =
+			glm::perspective(camComp.fov, camComp.aspectRatio, camComp.nearPlane, camComp.farPlane);
+		const math::mat4 viewMatrix = glm::lookAt(camTransform.origin(), camComp.target, camTransform.up());
+
+		// TODO lighting needs to not be this...
+		math::vec3 lightPos{0.0f, 10.0f, 0.0f};
+
+		std::shared_ptr<ogl::Shader> shader = nullptr;
+		ogl::Location mvpMatrixUniform = 0;
+		ogl::Location mvMatrixUniform = 0;
+
+		auto entityView = world.find<StaticMultiMesh>();
+		for (const auto &entity : entityView) {
+			debug::trace("Multi-mesh happened!");
+
+			// ensure that the model pipeline actually exists...
+			const auto &model = world.get<StaticMultiMesh>(entity);
+			if (model.pipeline == nullptr) {
+				debug::warning("shader was null, aborting render");
+				continue;
+			}
+
+			// check if we switched shaders
+			if (shader == nullptr || shader->shaderID() != model.pipeline->shaderID()) {
+				// new shader - need to re-send the view and projection matrices
+				shader = model.pipeline;
+				shader->use();
+				if (shader->hasUniform("projection")) {
+					shader->setUniformMtx(shader->uniform("view"), viewMatrix);
+					shader->setUniformMtx(shader->uniform("projection"), projectionMatrix);
+				}
+				mvpMatrixUniform = shader->uniform("MVPMatrix");
+				mvMatrixUniform = shader->uniform("MVMatrix");
+			}
+
+			// set model transform
+			const auto &transform = world.get<math::Transform>(entity);
+			shader->setUniformMtx(mvpMatrixUniform, projectionMatrix * viewMatrix * transform.model());
+			shader->setUniformMtx(mvMatrixUniform, viewMatrix * transform.model());
+
+			auto normalMatrix = glm::mat3(glm::transpose(inverse(transform.model())));
+			shader->setUniformMtx(shader->uniform("NormalMatrix"), normalMatrix);
+
+			// setup lighting mode
+			setup_lighting(shader, viewMatrix, camTransform, transform, lightPos);
+			setup_material(shader, world.tryGet<PhongMaterial>(entity, &DEFAULT_MATERIAL));
+
+			model.draw();
+
+		}
+	}
+
+	MeshData upload_mesh(const mesh::Mesh3D& rawMesh) {
+		auto vao = std::make_shared<ogl::VertexArray>();
+		return {
+			.vao = vao,
+			.elements = setup_index_buffer( rawMesh.indices ),
+			.vertexData = setup_array_buffer(vao, rawMesh.data ),
+			.elementCount = rawMesh.indices.size(),
+			.drawInfo = { .mode = ogl::Primitive::TRIANGLE }
+		};
+	}
+
+	std::vector<MeshData> upload_multi_mesh(const mesh::MultiMesh3D& rawMesh) {
+		std::vector<MeshData> gpuMeshes;
+		for (const auto& mesh : rawMesh.meshes) {
+			gpuMeshes.push_back( upload_mesh(mesh) );
+		}
+		return gpuMeshes;
+	}
+
+}
diff --git a/fggl/gfx/ogl4/models.cpp b/fggl/gfx/ogl4/models.cpp
index b70131b4e223393d1a794fb4210e0ba8ad6c6e29..99a2384c6e13bb9d144c6551df7231b98aa98659 100644
--- a/fggl/gfx/ogl4/models.cpp
+++ b/fggl/gfx/ogl4/models.cpp
@@ -17,6 +17,8 @@
 //
 
 #include "fggl/gfx/ogl4/models.hpp"
+#include "fggl/gfx/ogl4/meshes.hpp"
+
 #include "fggl/data/heightmap.hpp"
 
 #include "fggl/debug/logging.hpp"
@@ -41,6 +43,7 @@ namespace fggl::gfx::ogl4 {
 		return buff;
 	}
 
+
 	static std::shared_ptr<ogl::ElementBuffer> setupIndexBuffer(std::shared_ptr<ogl::VertexArray> &vao,
 																const std::vector<uint32_t> &data) {
 		auto elementBuffer = std::make_shared<ogl::ElementBuffer>();
@@ -88,6 +91,31 @@ namespace fggl::gfx::ogl4 {
 		return modelAsset;
 	}
 
+	/*MeshData* StaticModelRenderer::uploadMesh(assets::AssetGUID guid, const mesh::Mesh3D &mesh, bool allowCache) {
+		assert( m_assets != nullptr );
+
+		// if the asset has already been uploaded, we don't need to do anything
+		if ( allowCache && m_assets->has(guid) ) {
+			m_assets->require(guid);
+			return m_assets->get<MeshData>(guid);
+		}
+
+		// the asset does not exist, we need to upload it
+		auto* modelAsset = new MeshData();
+		modelAsset->vao = std::make_shared<ogl::VertexArray>();
+		modelAsset->vertexData = setupArrayBuffer(modelAsset->vao, mesh.data);
+		modelAsset->elements = setupIndexBuffer(modelAsset->vao, mesh.indices);
+		modelAsset->elementCount = mesh.indices.size();
+		modelAsset->drawInfo.mode = ogl::Primitive::TRIANGLE;
+
+		// if caching is enabled, then use the cache
+		if ( allowCache ) {
+			m_assets->set(guid, modelAsset);
+		}
+
+		return modelAsset;
+	}*/
+
 	StaticModelGPU* StaticModelRenderer::uploadMesh2(const assets::AssetGUID& meshName, const data::Mesh &mesh) {
 		assert( m_assets != nullptr );
 
@@ -107,6 +135,52 @@ namespace fggl::gfx::ogl4 {
 	}
 
 	#ifdef FGGL_ALLOW_DEFERRED_UPLOAD
+		static void setup_meshes(entity::EntityManager& world, ShaderCache* shaders) {
+			auto entsWithModels = world.find<mesh::StaticMesh3D>();
+			for ( const auto& mesher : entsWithModels ) {
+
+				// check if this entity already has a mesh...
+				const auto* currModel = world.tryGet<StaticMesh>(mesher);
+				if ( currModel != nullptr ) {
+					continue;
+				}
+
+				// figure out the requirements
+				auto& rawMesh = world.get<mesh::StaticMesh3D>(mesher);
+				if ( rawMesh.guid != util::make_guid("__NO_CACHE__") ){
+					// TODO support fetching of loaded meshes
+					debug::warning("multiple entities sharing a mesh has not been implemented yet...");
+				}
+
+				auto& entityMesh = world.add<StaticMesh>(mesher);
+				entityMesh.pipeline = shaders->get( rawMesh.pipeline );
+				entityMesh.mesh = upload_mesh( rawMesh.mesh );
+			}
+		}
+
+		static void setup_multi_meshes(entity::EntityManager& world, ShaderCache* shaders) {
+			auto entsWithModels = world.find<mesh::StaticMultiMesh3D>();
+			for ( const auto& mesher : entsWithModels ) {
+
+				// check if this entity already has a mesh...
+				const auto* currModel = world.tryGet<StaticMultiMesh>(mesher);
+				if ( currModel != nullptr ) {
+					continue;
+				}
+
+				// figure out the requirements
+				auto& multiMesh = world.get<mesh::StaticMultiMesh3D>(mesher);
+				if ( multiMesh.guid != util::make_guid("__NO_CACHE__") ){
+					// TODO support fetching of loaded meshes
+					debug::warning("multiple entities sharing a mesh has not been implemented yet...");
+				}
+
+				auto& entityMesh = world.add<StaticMultiMesh>(mesher);
+				entityMesh.pipeline = shaders->get( multiMesh.pipeline );
+				entityMesh.meshes = upload_multi_mesh(multiMesh.mesh);
+			}
+		}
+
 		void StaticModelRenderer::resolveModels(entity::EntityManager &world) {
 			// FIXME: this needs something reactive or performance will suck.
 			auto renderableEnts = world.find<data::StaticMesh>();
@@ -131,6 +205,10 @@ namespace fggl::gfx::ogl4 {
 				debug::log(debug::Level::info, "Added static mesh to {}, pipeline was: {}", (uint64_t) renderable, meshComp.pipeline);
 			}
 
+			// multi-meshes
+			setup_meshes(world, m_shaders);
+			setup_multi_meshes(world, m_shaders);
+
 			// terrain
 			auto terrain = world.find<data::HeightMap>();
 			for (auto &renderable : terrain) {
@@ -268,14 +346,14 @@ namespace fggl::gfx::ogl4 {
 			vao->bind();
 
 			model.vertexData->bind();
-			if (model.restartIndex != NO_RESTART_IDX) {
+			if (model.restartIndex != ogl::NO_RESTART_IDX) {
 				glEnable(GL_PRIMITIVE_RESTART);
 				glPrimitiveRestartIndex(model.restartIndex);
 			}
 
 			auto *elements = model.elements.get();
 			vao->drawElements(*elements, model.drawType, model.elementCount);
-			if (model.restartIndex != NO_RESTART_IDX) {
+			if (model.restartIndex != ogl::NO_RESTART_IDX) {
 				glDisable(GL_PRIMITIVE_RESTART);
 			}
 		}
@@ -293,8 +371,7 @@ namespace fggl::gfx::ogl4 {
 		const auto &camTransform = world.get<fggl::math::Transform>(camera);
 		const auto &camComp = world.get<fggl::gfx::Camera>(camera);
 
-		const math::mat4 projectionMatrix =
-			glm::perspective(camComp.fov, camComp.aspectRatio, camComp.nearPlane, camComp.farPlane);
+		const math::mat4 projectionMatrix = camComp.perspective();
 		const math::mat4 viewMatrix = glm::lookAt(camTransform.origin(), camComp.target, camTransform.up());
 
 		ogl::Location modelUniform = shader->uniform("model");
@@ -320,14 +397,14 @@ namespace fggl::gfx::ogl4 {
 			vao->bind();
 
 			model.vertexData->bind();
-			if (model.restartIndex != NO_RESTART_IDX) {
+			if (model.restartIndex != ogl::NO_RESTART_IDX) {
 				glEnable(GL_PRIMITIVE_RESTART);
 				glPrimitiveRestartIndex(model.restartIndex);
 			}
 
 			auto *elements = model.elements.get();
 			vao->drawElements(*elements, model.drawType, model.elementCount);
-			if (model.restartIndex != NO_RESTART_IDX) {
+			if (model.restartIndex != ogl::NO_RESTART_IDX) {
 				glDisable(GL_PRIMITIVE_RESTART);
 			}
 		}
@@ -349,9 +426,15 @@ namespace fggl::gfx::ogl4 {
 			//TODO should be clipping this to only visible objects
 			forward_camera_pass(cameraEnt, world);
 
+			forward_pass<ogl4::StaticMesh>(cameraEnt, world);
+			forward_pass<ogl4::StaticMultiMesh>(cameraEnt, world);
+
 			// enable rendering normals
 			if ( m_renderNormals ) {
-				forward_normal_pass(cameraEnt, world, m_shaders->get("normals"));
+				auto normalShader = m_shaders->get("normals");
+				forward_pass_normals<StaticMesh>(cameraEnt, world, normalShader);
+				forward_pass_normals<StaticMultiMesh>(cameraEnt, world, normalShader);
+				forward_normal_pass(cameraEnt, world, normalShader);
 			}
 		}
 	}
diff --git a/include/fggl/assets/manager.hpp b/include/fggl/assets/manager.hpp
index fef3c4efdb5cd2e494d8ebde805e76466a4a81b2..27a9fc9704ab3625f24a97ef75e4f31ac9702340 100644
--- a/include/fggl/assets/manager.hpp
+++ b/include/fggl/assets/manager.hpp
@@ -24,6 +24,7 @@
 #include <functional>
 #include <memory>
 
+#include "fggl/debug/logging.hpp"
 #include "fggl/assets/types.hpp"
 #include "fggl/util/safety.hpp"
 
@@ -61,6 +62,10 @@ namespace fggl::assets {
 				try {
 					const auto &assetRecord = m_registry.at(guid);
 					std::shared_ptr<AssetBoxT<T>> casted = std::dynamic_pointer_cast<AssetBoxT<T>>(assetRecord);
+					if ( casted == nullptr ) {
+						debug::error("Asset type requested did not match loaded asset type!");
+						return nullptr;
+					}
 					return casted->asset;
 				} catch (std::out_of_range& e) {
 					return nullptr;
diff --git a/include/fggl/data/assimp/module.hpp b/include/fggl/data/assimp/module.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..3c4a186a020aeac4ec1f488168148849033b8114
--- /dev/null
+++ b/include/fggl/data/assimp/module.hpp
@@ -0,0 +1,48 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 18/10/22.
+//
+
+#ifndef FGGL_DATA_ASSIMP_MODULE_HPP
+#define FGGL_DATA_ASSIMP_MODULE_HPP
+
+#include "fggl/modules/module.hpp"
+#include "fggl/assets/loader.hpp"
+
+
+namespace fggl::data::models {
+
+	constexpr auto MODEL_PROVIDER = modules::make_service("fggl::data::Model");
+	constexpr auto ASSIMP_MODEL = assets::AssetType::make("model::assimp");
+
+	struct AssimpModule {
+		constexpr static const char *name = "fggl::data::Assimp";
+		constexpr static const std::array<modules::ModuleService, 1> provides = {
+			MODEL_PROVIDER
+		};
+		constexpr static const std::array<modules::ModuleService, 1> depends = {
+			assets::Loader::service
+		};
+		static bool factory(modules::ModuleService service, modules::Services &serviceManager);
+	};
+
+}
+
+namespace fggl::data {
+	using AssimpLoader = models::AssimpModule;
+}
+
+#endif //FGGL_DATA_ASSIMP_MODULE_HPP
diff --git a/include/fggl/entity/entity.hpp b/include/fggl/entity/entity.hpp
index cb1527e30ca8bd87129cf3a0e1f6e3d1db7d3050..04f5f96a967664fbc7936f801fb1cbc1b5f8c81c 100644
--- a/include/fggl/entity/entity.hpp
+++ b/include/fggl/entity/entity.hpp
@@ -68,8 +68,9 @@ namespace fggl::entity {
 			}
 
 			template<typename Component>
-			const Component *tryGet(EntityID entity) const {
-				return m_registry.try_get<Component>(entity);
+			const Component *tryGet(EntityID entity, const Component* defaultValue = nullptr) const {
+				auto* comp = m_registry.try_get<Component>(entity);
+				return comp == nullptr ? defaultValue : comp;
 			}
 
 			template<typename ...Components>
diff --git a/include/fggl/gfx/camera.hpp b/include/fggl/gfx/camera.hpp
index 8a14da3776a3c467bbcfe085ddd18a30ab389230..adf983168fb5d58c80d1c2544ca698a68a83eb08 100644
--- a/include/fggl/gfx/camera.hpp
+++ b/include/fggl/gfx/camera.hpp
@@ -27,6 +27,10 @@ namespace fggl::gfx {
 		float fov = glm::radians(45.0f);
 		float nearPlane = 0.1f;
 		float farPlane = 100.0f;
+
+		inline math::mat4 perspective() const {
+			return glm::perspective(fov, aspectRatio, nearPlane, farPlane);
+		}
 	};
 
 	inline math::mat4 calc_proj_matrix(const Camera &camera) {
diff --git a/include/fggl/gfx/ogl/backend.hpp b/include/fggl/gfx/ogl/backend.hpp
index 5116f0d57a9bb2aec80608106f37a3a5f0c91ecb..034f878d58df489220c516d3cc8edb6b90f77059 100644
--- a/include/fggl/gfx/ogl/backend.hpp
+++ b/include/fggl/gfx/ogl/backend.hpp
@@ -24,7 +24,6 @@
  * FGGL OpenGL 4.x rendering backend.
  */
 namespace fggl::gfx {
-
 }
 
 #endif
diff --git a/include/fggl/gfx/ogl/types.hpp b/include/fggl/gfx/ogl/types.hpp
index 6e237b25a806297aace9b63fb7f1d9d2c468534d..6f070c7c0458441100089566fe2bc5c248453895 100644
--- a/include/fggl/gfx/ogl/types.hpp
+++ b/include/fggl/gfx/ogl/types.hpp
@@ -306,6 +306,8 @@ namespace fggl::gfx::ogl {
 			void release();
 	};
 
+	constexpr std::size_t NO_RESTART_IDX = 0;
+
 	enum class BuffAttrF {
 		HALF_FLOAT = GL_HALF_FLOAT,
 		FLOAT = GL_FLOAT,
diff --git a/include/fggl/gfx/ogl4/meshes.hpp b/include/fggl/gfx/ogl4/meshes.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..6061fa450892c61224346dfa9e9be95a9ffebc8f
--- /dev/null
+++ b/include/fggl/gfx/ogl4/meshes.hpp
@@ -0,0 +1,177 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 22/10/22.
+//
+
+#ifndef FGGL_GFX_OGL4_MESHES_HPP
+#define FGGL_GFX_OGL4_MESHES_HPP
+
+#include "fggl/mesh/components.hpp"
+#include "fggl/entity/entity.hpp"
+
+#include "fggl/gfx/ogl/types.hpp"
+#include "fggl/gfx/camera.hpp"
+#include "fggl/gfx/phong.hpp"
+
+namespace fggl::gfx::ogl4 {
+
+	struct DrawType {
+		ogl::Primitive mode;
+		std::size_t restartIndex = ogl::NO_RESTART_IDX;
+	};
+
+	struct MeshData {
+		std::shared_ptr<ogl::VertexArray> vao;
+		std::shared_ptr<ogl::ElementBuffer> elements;
+		std::shared_ptr<ogl::ArrayBuffer> vertexData;
+		std::size_t elementCount;
+		DrawType drawInfo;
+
+		void draw() const;
+	};
+
+	struct StaticMesh {
+		constexpr static auto name = "StaticMultiMesh";
+
+		std::shared_ptr<ogl::Shader> pipeline;
+		MeshData mesh;
+
+		inline void draw() const {
+			mesh.draw();
+		}
+	};
+
+	struct StaticMultiMesh {
+		constexpr static auto name = "StaticMultiMesh";
+
+		std::shared_ptr<ogl::Shader> pipeline;
+		std::vector<MeshData> meshes;
+
+		void draw() const;
+	};
+
+	void setup_material(const std::shared_ptr<ogl::Shader>& shader, const PhongMaterial* material);
+	void setup_lighting(const std::shared_ptr<ogl::Shader>& shader, const math::mat4& viewMatrix, const math::Transform& camTransform, const math::Transform& transform, math::vec3 lightPos);
+
+	template<typename T>
+	void forward_pass(const entity::EntityID& camera, const fggl::entity::EntityManager& world) {
+
+		// enable required OpenGL state
+		glEnable(GL_CULL_FACE);
+		glCullFace(GL_BACK);
+
+		// enable depth testing
+		glEnable(GL_DEPTH_TEST);
+
+		// set-up camera matrices
+		const auto &camTransform = world.get<fggl::math::Transform>(camera);
+		const auto &camComp = world.get<fggl::gfx::Camera>(camera);
+
+		const math::mat4 projectionMatrix = camComp.perspective();
+		const math::mat4 viewMatrix = glm::lookAt(camTransform.origin(), camComp.target, camTransform.up());
+
+		// TODO lighting needs to not be this...
+		math::vec3 lightPos{0.0F, 10.0F, 0.0F};
+
+		std::shared_ptr<ogl::Shader> shader = nullptr;
+		ogl::Location mvpMatrixUniform = 0;
+		ogl::Location mvMatrixUniform = 0;
+
+		auto entityView = world.find<T>();
+		debug::info("Triggering rendering pass for {} entities", entityView.size());
+
+		for (const auto &entity : entityView) {
+			// ensure that the model pipeline actually exists...
+			const auto &model = world.get<T>(entity);
+			if (model.pipeline == nullptr) {
+				debug::warning("shader was null, aborting render");
+				continue;
+			}
+
+			// check if we switched shaders
+			if (shader == nullptr || shader->shaderID() != model.pipeline->shaderID()) {
+				// new shader - need to re-send the view and projection matrices
+				shader = model.pipeline;
+				shader->use();
+				if (shader->hasUniform("projection")) {
+					shader->setUniformMtx(shader->uniform("view"), viewMatrix);
+					shader->setUniformMtx(shader->uniform("projection"), projectionMatrix);
+				}
+				mvpMatrixUniform = shader->uniform("MVPMatrix");
+				mvMatrixUniform = shader->uniform("MVMatrix");
+			}
+
+			// set model transform
+			const auto &transform = world.get<math::Transform>(entity);
+			shader->setUniformMtx(mvpMatrixUniform, projectionMatrix * viewMatrix * transform.model());
+			shader->setUniformMtx(mvMatrixUniform, viewMatrix * transform.model());
+
+			auto normalMatrix = glm::mat3(glm::transpose(inverse(transform.model())));
+			shader->setUniformMtx(shader->uniform("NormalMatrix"), normalMatrix);
+
+			// setup lighting mode
+			setup_lighting(shader, viewMatrix, camTransform, transform, lightPos);
+			setup_material(shader, world.tryGet<PhongMaterial>(entity, &DEFAULT_MATERIAL));
+
+			model.draw();
+		}
+	}
+
+	template<typename T>
+	void forward_pass_normals(const entity::EntityID& camera, const fggl::entity::EntityManager& world, const std::shared_ptr<ogl::Shader>& shader) {
+		// enable required OpenGL state
+		glEnable(GL_CULL_FACE);
+		glCullFace(GL_BACK);
+
+		// enable depth testing
+		glEnable(GL_DEPTH_TEST);
+
+		// set-up camera matrices
+		const auto &camTransform = world.get<fggl::math::Transform>(camera);
+		const auto &camComp = world.get<fggl::gfx::Camera>(camera);
+
+		const math::mat4 projectionMatrix = camComp.perspective();
+		const math::mat4 viewMatrix = glm::lookAt(camTransform.origin(), camComp.target, camTransform.up());
+
+		ogl::Location modelUniform = shader->uniform("model");
+		ogl::Location viewUniform = shader->uniform("view");
+		ogl::Location projUniform = shader->uniform("projection");
+
+		shader->use();
+		shader->setUniformMtx(projUniform, projectionMatrix);
+		shader->setUniformMtx(viewUniform, viewMatrix);
+
+		auto entities = world.find<T>();
+		for (const auto &entity : entities) {
+
+			// ensure that the model pipeline actually exists...
+			const auto &model = world.get<T>(entity);
+
+			// set model transform
+			const auto &transform = world.get<math::Transform>(entity);
+			shader->setUniformMtx(modelUniform, transform.model());
+
+			// render model
+			model.draw();
+		}
+	}
+
+	MeshData upload_mesh(const mesh::Mesh3D& meshComponent);
+	std::vector<MeshData> upload_multi_mesh(const mesh::MultiMesh3D& meshComponent);
+
+} // namespace fggl::gfx::ogl4
+
+#endif //FGGL_GFX_OGL4_MESHES_HPP
diff --git a/include/fggl/gfx/ogl4/models.hpp b/include/fggl/gfx/ogl4/models.hpp
index 6a8beb1c9d1ee75c1b1a255c15ff528143bebfb6..bf58fdd84ccdb662fb797d5218a1230347a2819c 100644
--- a/include/fggl/gfx/ogl4/models.hpp
+++ b/include/fggl/gfx/ogl4/models.hpp
@@ -27,13 +27,14 @@
 #include "fggl/gfx/ogl/types.hpp"
 
 #include "fggl/data/model.hpp"
+#include "fggl/mesh/mesh.hpp"
+
 #include "fggl/assets/manager.hpp"
 
 #define FGGL_ALLOW_DEFERRED_UPLOAD
 
 namespace fggl::gfx::ogl4 {
 
-	const std::size_t NO_RESTART_IDX = 0;
 
 	struct StaticModelGPU {
 		std::shared_ptr<ogl::VertexArray> vao;
@@ -41,7 +42,7 @@ namespace fggl::gfx::ogl4 {
 		std::shared_ptr<ogl::ArrayBuffer> vertices;
 		std::size_t elementCount;
 		ogl::Primitive drawType = ogl::Primitive::TRIANGLE;
-		std::size_t restartIndex = NO_RESTART_IDX;
+		std::size_t restartIndex = ogl::NO_RESTART_IDX;
 	};
 
 	struct StaticModelInstance {
@@ -63,7 +64,7 @@ namespace fggl::gfx::ogl4 {
 		std::size_t elementCount;
 
 		ogl::Primitive drawType;
-		std::size_t restartIndex = NO_RESTART_IDX;
+		std::size_t restartIndex = ogl::NO_RESTART_IDX;
 	};
 
 	class StaticModelRenderer {
diff --git a/include/fggl/math/types.hpp b/include/fggl/math/types.hpp
index b6c790dcda5cb2a97c03ff5c77e83bc999de5bb2..795e6659615c172f5fce0a2a58750b3ab6caebec 100644
--- a/include/fggl/math/types.hpp
+++ b/include/fggl/math/types.hpp
@@ -61,7 +61,7 @@ namespace fggl::math {
 	/**
 	 * A 4D unsigned integer vector.
 	 */
-	using vec4ui = glm::ivec4;
+	using vec4ui = glm::uvec4;
 
 	/**
 	 * A 3D floating-point vector.
@@ -76,7 +76,7 @@ namespace fggl::math {
 	/**
 	 * A 3D unsigned integer vector.
 	 */
-	using vec3ui = glm::ivec3;
+	using vec3ui = glm::uvec3;
 
 	/**
 	 * A 2D floating-point vector
@@ -93,6 +93,10 @@ namespace fggl::math {
 	 */
 	using vec2ui = glm::uvec2;
 
+	using vec2b = glm::bvec2;
+	using vec3b = glm::bvec3;
+	using vec4b = glm::bvec4;
+
 	/**
 	 * A 2x2 floating-point matrix.
 	 */
diff --git a/include/fggl/mesh/components.hpp b/include/fggl/mesh/components.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..ea1630a4c092d215ba9e265055d831f7986419b3
--- /dev/null
+++ b/include/fggl/mesh/components.hpp
@@ -0,0 +1,55 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 22/10/22.
+// FIXME HACKY IMPLEMENTATION DETAIL BECAUSE THE ASSET LOADING PIPELINE IS BAD
+//
+
+#include "fggl/mesh/mesh.hpp"
+
+#ifndef FGGL_MESH_COMPONENTS_HPP
+#define FGGL_MESH_COMPONENTS_HPP
+
+namespace fggl::mesh {
+
+	struct StaticMesh3D {
+		constexpr static const char name[] = "StaticMesh3D";
+		constexpr static const util::GUID guid = util::make_guid(name);
+
+		util::GUID meshReference;
+		Mesh3D mesh;
+		std::string pipeline;
+
+		inline StaticMesh3D() = default;
+		inline StaticMesh3D(const Mesh3D &aMesh, std::string aPipeline) :
+			mesh(aMesh), pipeline(std::move(aPipeline)) {}
+	};
+
+	struct StaticMultiMesh3D {
+		constexpr static const char name[] = "StaticMultiMesh3D";
+		constexpr static const util::GUID guid = util::make_guid(name);
+
+		util::GUID meshReference;
+		MultiMesh3D mesh;
+		std::string pipeline;
+
+		inline StaticMultiMesh3D() = default;
+		inline StaticMultiMesh3D(const MultiMesh3D &aMesh, std::string aPipeline) :
+			mesh(aMesh), pipeline(std::move(aPipeline)) {}
+	};
+
+}
+
+#endif //FGGL_MESH_COMPONENTS// _HPP
diff --git a/include/fggl/mesh/mesh.hpp b/include/fggl/mesh/mesh.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8f0f8985cb2209c0c73d3ee3ad468d551d59244b
--- /dev/null
+++ b/include/fggl/mesh/mesh.hpp
@@ -0,0 +1,61 @@
+/*
+ * This file is part of FGGL.
+ *
+ * FGGL is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any
+ * later version.
+ *
+ * FGGL is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License along with FGGL.
+ * If not, see <https://www.gnu.org/licenses/>.
+ */
+
+//
+// Created by webpigeon on 18/10/22.
+//
+
+#ifndef FGGL_MESH_MESH_HPP
+#define FGGL_MESH_MESH_HPP
+
+#include "fggl/math/types.hpp"
+
+#include <vector>
+#include <span>
+
+namespace fggl::mesh {
+
+	struct Vertex3D {
+		math::vec3 position;
+		math::vec3 normal;
+		math::vec3 colour{ 1.0F, 1.0F, 1.0f };
+		math::vec2 texPos{ 0.0F, 0.0F };
+	};
+
+	struct Vertex2D {
+		math::vec2 position;
+		math::vec2 colour;
+		math::vec2 texPos;
+	};
+
+	template<typename VertexFormat>
+	struct Mesh {
+		std::vector<VertexFormat> data;
+		std::vector<uint32_t> indices;
+	};
+
+	template<typename MeshFormat>
+	struct MultiMesh {
+		std::vector<MeshFormat> meshes;
+	};
+
+	using Mesh2D = Mesh<Vertex2D>;
+	using MultiMesh2D = MultiMesh<Mesh2D>;
+
+	using Mesh3D = Mesh<Vertex3D>;
+	using MultiMesh3D = MultiMesh<Mesh3D>;
+
+}
+
+#endif //FGGL_MESH_MESH_HPP