diff --git a/CMakeLists.txt b/CMakeLists.txt
index e060e14ab2c0287e25a5bac91c34f83b5d44fd31..0c1ab40c3633e359c657b6e60f99c3d067135821 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -10,4 +10,4 @@ add_library(lava-vr ${LAVA_LINK_TYPE}
     )
 
 target_include_directories(lava-vr PUBLIC ./src)
-target_link_libraries(lava-vr PUBLIC lava lava-extras-openvr lava-extras-pipeline)
+target_link_libraries(lava-vr PUBLIC lava lava-extras-openvr lava-extras-pipeline lava-extras-geometry)
diff --git a/README.md b/README.md
index 31f405a3eba9daeabba7fbf0ec06338abefa62e5..01089d8b73d6a1c77c07e5668fc6ab97a92cdbee 100644
--- a/README.md
+++ b/README.md
@@ -7,3 +7,7 @@ Higher-level utility classes and functions for VR-Applications based on lava and
 ## RopeLocomotion
 
 Use two controllers to move around in the scene in a multi-touch / "Mime pulling an invisible rope" fashion.
+
+## BatchingRenderer
+
+Manages an arbitrary amount of meshes with texture and records drawing requests that can be executed at once with a single bind of pipeline and buffers.
diff --git a/src/lava-vr/BatchingRenderer.cc b/src/lava-vr/BatchingRenderer.cc
new file mode 100644
index 0000000000000000000000000000000000000000..4b17e8af3e9a5cea6ecbbf7df73de90bb30f0ca5
--- /dev/null
+++ b/src/lava-vr/BatchingRenderer.cc
@@ -0,0 +1,120 @@
+#include "BatchingRenderer.hh"
+#include <glm/glm.hpp>
+#include <lava-extras/pack/pack.hh>
+#include <lava/common/log.hh>
+#include <lava/createinfos/Buffers.hh>
+#include <lava/createinfos/DescriptorSetLayoutCreateInfo.hh>
+#include <lava/createinfos/GraphicsPipelineCreateInfo.hh>
+#include <lava/createinfos/Images.hh>
+#include <lava/createinfos/Sampler.hh>
+#include <lava/features/DebugMarkers.hh>
+#include <lava/objects/Buffer.hh>
+#include <lava/objects/DescriptorSet.hh>
+#include <lava/objects/DescriptorSetLayout.hh>
+#include <lava/objects/Device.hh>
+#include <lava/objects/GraphicsPipeline.hh>
+#include <lava/objects/Image.hh>
+#include <lava/objects/ImageData.hh>
+#include <lava/objects/RenderPass.hh>
+#include <lava/raii/ActiveRenderPass.hh>
+
+#include <lava-extras/geometry/IO.hh>
+
+namespace lava {
+namespace vr {
+
+BatchingRenderer::BatchingRenderer(
+    const lava::Subpass &forwardPass,
+    const lava::SharedDescriptorSetLayout &cameraLayout) {
+    auto device = forwardPass.pass->device();
+
+    mMaterialLayout = lava::DescriptorSetLayoutCreateInfo() //
+                          .addCombinedImageSampler()
+                          .create(device, 100);
+    auto layout = device->createPipelineLayout<glm::mat4>(
+        {cameraLayout, mMaterialLayout});
+
+    auto ci = lava::GraphicsPipelineCreateInfo::defaults();
+    ci.setLayout(layout);
+
+    ci.depthStencilState.setDepthTestEnable(true).setDepthWriteEnable(true);
+    ci.rasterizationState.setFrontFace(vk::FrontFace::eClockwise);
+
+    ci.addStage(lava::pack::shader(device, "albedo.vert"));
+    ci.addStage(lava::pack::shader(device, "albedo.frag"));
+
+    ci.vertexInputState.addAttribute(&Vertex::pos, 0);
+    ci.vertexInputState.addAttribute(&Vertex::normal, 1);
+    ci.vertexInputState.addAttribute(&Vertex::texCoord, 2);
+
+    mPipeline = forwardPass.createPipeline(ci);
+
+    mSampler = device->createSampler({});
+}
+
+BatchingRenderer::MeshHandle BatchingRenderer::add(const std::string &modelfile,
+                                                   const std::string &texture) {
+    MeshHandle result;
+
+    auto debug = mPipeline->device()->get<lava::features::DebugMarkers>();
+
+    {
+        std::vector<Vertex> vertices;
+        auto indices = lava::geometry::Importer().load(
+            modelfile,
+            [&](glm::vec3 position, glm::vec3 normal, glm::vec3 /* tangent */,
+                glm::vec3 texcoord, glm::vec4 /* color */) {
+                vertices.push_back({position, normal, glm::vec2(texcoord)});
+            });
+        uint32_t firstIndex = mIndices.size();
+        uint32_t offset = mVertices.size();
+        mVertices.insert(mVertices.end(), vertices.begin(), vertices.end());
+        std::transform(indices.begin(), indices.end(), back_inserter(mIndices),
+                       [&](uint32_t idx) { return idx + offset; });
+        result.firstIndex = firstIndex;
+        result.count = indices.size();
+    }
+
+    {
+        auto image = lava::ImageData::createFromFile(texture)->uploadTo(
+            mPipeline->device());
+        image->changeLayout(vk::ImageLayout::eTransferSrcOptimal,
+                            vk::ImageLayout::eShaderReadOnlyOptimal);
+        if (debug)
+            debug->mark(image, "BatchingRenderer - " + texture);
+        auto view = image->createView(vk::ImageViewType::e2D);
+        auto set = mMaterialLayout->createDescriptorSet();
+        set->write().combinedImageSampler(mSampler, view);
+        mMaterials.push_back(set);
+
+        result.material = mMaterials.size() - 1;
+    }
+
+    return result;
+}
+
+void BatchingRenderer::upload() {
+    auto const &device = mPipeline->device();
+    mVertexBuffer = device->createBuffer(lava::arrayBuffer());
+    mVertexBuffer->setDataVRAM(mVertices);
+
+    mIndexBuffer = device->createBuffer(lava::indexBuffer());
+    mIndexBuffer->setDataVRAM(mIndices);
+}
+
+void BatchingRenderer::draw(lava::InlineSubpass &sub,
+                            lava::SharedDescriptorSet const &cameraSet) {
+    sub.bindPipeline(mPipeline);
+    sub.bindVertexBuffers({mVertexBuffer});
+    sub.bindIndexBuffer(mIndexBuffer);
+    sub.bindDescriptorSets({cameraSet});
+
+    for (auto const &d : mDraws) {
+        sub.bindDescriptorSets({mMaterials[d.first.material]}, 1);
+        sub.pushConstantBlock(d.second);
+        sub.drawIndexed(d.first.count, 1, 0, d.first.firstIndex);
+    }
+}
+
+} // namespace vr
+} // namespace lava
diff --git a/src/lava-vr/BatchingRenderer.hh b/src/lava-vr/BatchingRenderer.hh
new file mode 100644
index 0000000000000000000000000000000000000000..9f7fc1a2291f1a96eebca584e1f085fc36e31fd7
--- /dev/null
+++ b/src/lava-vr/BatchingRenderer.hh
@@ -0,0 +1,53 @@
+#pragma once
+#include <glm/common.hpp>
+#include <glm/mat4x4.hpp>
+#include <lava/fwd.hh>
+#include <vector>
+
+namespace lava {
+namespace vr {
+
+class BatchingRenderer {
+  public:
+    struct Vertex {
+        glm::vec3 pos;
+        glm::vec3 normal;
+        glm::vec2 texCoord;
+    };
+
+    struct MeshHandle {
+        uint32_t firstIndex;
+        uint32_t count;
+        uint32_t material;
+    };
+
+    BatchingRenderer(lava::Subpass const &forwardPass,
+                     lava::SharedDescriptorSetLayout const &cameraLayout);
+
+    MeshHandle add(std::string const &modelfile, std::string const &texture);
+    void upload();
+
+    void reset() { mDraws.clear(); }
+    void enqueue(MeshHandle const &handle, glm::mat4 const &modelMatrix) {
+        mDraws.emplace_back(handle, modelMatrix);
+    }
+    void draw(lava::InlineSubpass &sub,
+              const lava::SharedDescriptorSet &cameraSet);
+
+  protected:
+    std::vector<Vertex> mVertices;
+    std::vector<uint32_t> mIndices;
+    std::vector<lava::SharedDescriptorSet> mMaterials;
+
+    std::vector<std::pair<MeshHandle, glm::mat4>> mDraws;
+
+    lava::SharedBuffer mVertexBuffer;
+    lava::SharedBuffer mIndexBuffer;
+
+    lava::SharedGraphicsPipeline mPipeline;
+    lava::SharedDescriptorSetLayout mMaterialLayout;
+    lava::SharedSampler mSampler;
+};
+
+} // namespace vr
+} // namespace lava