diff --git a/CMakeLists.txt b/CMakeLists.txt
index 91a64d608c2f9a39fbb583dcd835444b4c40225d..806df0144fb4bd98fe5161b88f1a1803d5243e50 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -9,5 +9,13 @@ add_library(lava-vr ${LAVA_LINK_TYPE}
     ${HEADER_FILES}
     )
 
+list(APPEND lava_pack_shaders
+        "${CMAKE_CURRENT_LIST_DIR}/shaders/vrmesh.vert" "lava-vr/vrmesh.vert"
+        "${CMAKE_CURRENT_LIST_DIR}/shaders/vrmesh.frag" "lava-vr/vrmesh.frag"
+        "${CMAKE_CURRENT_LIST_DIR}/shaders/albedo.vert" "lava-vr/albedo.vert"
+        "${CMAKE_CURRENT_LIST_DIR}/shaders/albedo.frag" "lava-vr/albedo.frag"
+        )
+set(lava_pack_shaders "${lava_pack_shaders}" CACHE INTERNAL "lava_pack_shaders")
+
 target_include_directories(lava-vr PUBLIC ./src)
-target_link_libraries(lava-vr PUBLIC lava lava-extras-openvr lava-extras-pipeline lava-extras-geometry z)
+target_link_libraries(lava-vr PUBLIC lava lava-extras-openvr lava-extras-pipeline lava-extras-geometry lava-extras-pack z)
diff --git a/shaders/albedo.frag b/shaders/albedo.frag
new file mode 100644
index 0000000000000000000000000000000000000000..4fd721b17b95d63661f7e7df04aff53a1f61b269
--- /dev/null
+++ b/shaders/albedo.frag
@@ -0,0 +1,15 @@
+#version 450
+#extension GL_ARB_separate_shader_objects : enable
+#extension GL_ARB_shading_language_420pack : enable
+
+
+layout (location = 0) in vec3 vNormal;
+layout (location = 1) in vec2 vTexCoord;
+
+layout (location = 0) out vec4 fColor;
+
+layout (set = 1, binding = 0) uniform sampler2D uTexture;
+
+void main() {
+    fColor = texture(uTexture, vTexCoord * vec2(1, -1)) * (0.95 * normalize(vNormal).y + 0.05);
+}
diff --git a/shaders/albedo.vert b/shaders/albedo.vert
new file mode 100644
index 0000000000000000000000000000000000000000..3988cfb1137d67c0f3222af87da85f6780e4882d
--- /dev/null
+++ b/shaders/albedo.vert
@@ -0,0 +1,44 @@
+#version 450
+#extension GL_ARB_separate_shader_objects : enable
+#extension GL_ARB_shading_language_420pack : enable
+#extension GL_EXT_multiview : enable
+#extension GL_NVX_multiview_per_view_attributes : enable
+
+
+layout(location=0) in vec3 aPosition;
+layout(location=1) in vec3 aNormal;
+layout(location=2) in vec2 aTexCoord;
+
+layout (location = 0) out vec3 vNormal;
+layout (location = 1) out vec2 vTexCoord;
+
+layout(push_constant) uniform PushConstants {
+    mat4 modelMatrix;
+};
+
+
+layout(set = 0, binding = 0) uniform CameraMatrices {
+    mat4 view[2];
+    mat4 proj[2];
+} cams;
+
+out gl_PerVertex
+{
+    vec4 gl_Position;
+};
+
+void main() {
+    vTexCoord = aTexCoord;
+    vNormal = mat3(modelMatrix) * aNormal;
+
+    //vec4 worldPos = modelMatrix * vec4(aPosition, 1.0);
+    vec4 worldPos = modelMatrix * vec4(aPosition, 1.0);
+
+    gl_PositionPerViewNV[0] =
+        cams.proj[0] * cams.view[0] * worldPos;
+    gl_PositionPerViewNV[1] =
+        cams.proj[1] * cams.view[1] * worldPos;
+    gl_Position = cams.proj[gl_ViewIndex]
+                * cams.view[gl_ViewIndex]
+                * worldPos;
+}
diff --git a/shaders/vrmesh.frag b/shaders/vrmesh.frag
new file mode 100644
index 0000000000000000000000000000000000000000..6704cfccaaef76e601db7e871a49bd9b2f71d78d
--- /dev/null
+++ b/shaders/vrmesh.frag
@@ -0,0 +1,17 @@
+#version 450
+#extension GL_ARB_separate_shader_objects : enable
+#extension GL_ARB_shading_language_420pack : enable
+
+
+layout (location = 0) in vec2 vTexCoord;
+layout (location = 1) flat in uint vLayer;
+
+layout (location = 0) out vec4 fColor;
+
+layout (set = 1, binding = 0) uniform sampler2DArray uTexture;
+
+void main() {
+    fColor = texture(uTexture, vec3(vTexCoord,vLayer));
+    if (fColor.a < 0.5) discard;
+    //if (pu.premultiplied && false) fColor.rgb /= fColor.a;
+}
diff --git a/shaders/vrmesh.vert b/shaders/vrmesh.vert
new file mode 100644
index 0000000000000000000000000000000000000000..6c017ec336a703700e61336aa98fc70612c20258
--- /dev/null
+++ b/shaders/vrmesh.vert
@@ -0,0 +1,44 @@
+#version 450
+#extension GL_ARB_separate_shader_objects : enable
+#extension GL_ARB_shading_language_420pack : enable
+#extension GL_EXT_multiview : enable
+#extension GL_NVX_multiview_per_view_attributes : enable
+
+
+layout(location=0) in vec3 aPosition;
+layout(location=1) in vec2 aTexCoord;
+layout(location=2) in uint aLayer;
+
+layout (location = 0) out vec2 vTexCoord;
+layout (location = 1) flat out uint vLayer;
+
+layout(push_constant) uniform PushConstants {
+    mat4 modelMatrix;
+};
+
+
+layout(set = 0, binding = 0) uniform CameraMatrices {
+    mat4 view[2];
+    mat4 proj[2];
+} cams;
+
+out gl_PerVertex
+{
+    vec4 gl_Position;
+};
+
+void main() {
+    vTexCoord = aTexCoord;
+    vLayer = aLayer;
+
+    //vec4 worldPos = modelMatrix * vec4(aPosition, 1.0);
+    vec4 worldPos = modelMatrix * vec4(aPosition, 1.0);
+
+    gl_PositionPerViewNV[0] =
+        cams.proj[0] * cams.view[0] * worldPos;
+    gl_PositionPerViewNV[1] =
+        cams.proj[1] * cams.view[1] * worldPos;
+    gl_Position = cams.proj[gl_ViewIndex]
+                * cams.view[gl_ViewIndex]
+                * worldPos;
+}
diff --git a/src/lava-vr/BatchingRenderer.cc b/src/lava-vr/BatchingRenderer.cc
index 4b17e8af3e9a5cea6ecbbf7df73de90bb30f0ca5..67f1b1af9fa5a3457541b6230c06cd5777150cf3 100644
--- a/src/lava-vr/BatchingRenderer.cc
+++ b/src/lava-vr/BatchingRenderer.cc
@@ -40,8 +40,8 @@ BatchingRenderer::BatchingRenderer(
     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.addStage(lava::pack::shader(device, "lava-vr/albedo.vert"));
+    ci.addStage(lava::pack::shader(device, "lava-vr/albedo.frag"));
 
     ci.vertexInputState.addAttribute(&Vertex::pos, 0);
     ci.vertexInputState.addAttribute(&Vertex::normal, 1);
diff --git a/src/lava-vr/VRMeshRenderer.cc b/src/lava-vr/VRMeshRenderer.cc
new file mode 100644
index 0000000000000000000000000000000000000000..f4b937ee096ae89684822b43bade6808c4f71fd4
--- /dev/null
+++ b/src/lava-vr/VRMeshRenderer.cc
@@ -0,0 +1,167 @@
+#include "VRMeshRenderer.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/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/RenderPass.hh>
+#include <lava/raii/ActiveRenderPass.hh>
+
+#include "VRMesh.hh"
+
+namespace lava {
+namespace vr {
+
+namespace {
+using Vertex = VRMesh::Vertex;
+
+struct OffsetHelper {
+    size_t width, height, depth, bytes_per_pixel, mip_layers;
+
+    ptrdiff_t getOffset(size_t layer, size_t mip) {
+        ptrdiff_t result = 0;
+        for (size_t i = 0; i < mip; i++) {
+            size_t block =
+                (width >> i) * (height >> i) * depth * bytes_per_pixel;
+            result += block;
+        }
+
+        size_t partial =
+            (width >> mip) * (height >> mip) * layer * bytes_per_pixel;
+        result += partial;
+
+        return result;
+    }
+
+    size_t bytesPerLayer(size_t mip) {
+        return (width >> mip) * (height >> mip) * bytes_per_pixel;
+    }
+};
+} // namespace
+
+VRMeshRenderer::VRMeshRenderer(
+    const lava::Subpass &forwardPass,
+    const lava::SharedDescriptorSetLayout &cameraLayout) {
+    auto device = forwardPass.pass->device();
+
+    mTextureLayout = lava::DescriptorSetLayoutCreateInfo() //
+                         .addCombinedImageSampler()
+                         .create(device);
+    mLayout =
+        device->createPipelineLayout<glm::mat4>({cameraLayout, mTextureLayout});
+
+    auto ci = lava::GraphicsPipelineCreateInfo::defaults();
+    ci.setLayout(mLayout);
+
+    ci.depthStencilState.setDepthTestEnable(true).setDepthWriteEnable(true);
+    ci.rasterizationState.setFrontFace(vk::FrontFace::eClockwise);
+
+    ci.addStage(lava::pack::shader(device, "lava-vr/vrmesh.vert"));
+    ci.addStage(lava::pack::shader(device, "lava-vr/vrmesh.frag"));
+
+    ci.vertexInputState.addAttribute(&Vertex::position, 0);
+    ci.vertexInputState.addAttribute(&Vertex::texCoord, 1);
+    ci.vertexInputState.addAttribute(&Vertex::layer, 2);
+
+    mPipeline = forwardPass.createPipeline(ci);
+
+    mSampler = device->createSampler({});
+}
+
+void VRMeshRenderer::loadFile(const std::string &filename) {
+    auto mesh = VRMesh::readFromFile(filename);
+    upload(mesh);
+}
+
+void VRMeshRenderer::upload(const VRMesh &mesh) {
+    auto const &device = mPipeline->device();
+
+    lava::debug() << "Loaded mesh with " << mesh.indices.size() / 3u
+                  << " triangles:";
+
+    mVertexBuffer = device->createBuffer(lava::arrayBuffer());
+    mVertexBuffer->setDataVRAM(mesh.vertices);
+
+    mIndexBuffer = device->createBuffer(lava::indexBuffer());
+    mIndexBuffer->setDataVRAM(mesh.indices);
+
+    {
+        auto meta = mesh.colorTexture.meta;
+
+        mTexture = lava::texture2DArray(meta.width, meta.height, meta.depth,
+                                        vk::Format::eBc7SrgbBlock)
+                       .setMipLevels(meta.mipLayers)
+                       .create(device);
+
+        mTexture->realizeVRAM();
+        mTexture->changeLayout(vk::ImageLayout::eTransferDstOptimal);
+
+        auto staging = device->createBuffer(lava::stagingBuffer());
+        staging->setDataRAM(mesh.colorTexture.data);
+
+        std::vector<vk::BufferImageCopy> copies;
+
+        OffsetHelper helper;
+        helper.width = meta.width;
+        helper.height = meta.height;
+        helper.depth = meta.depth;
+        helper.bytes_per_pixel = meta.bytes_per_pixel;
+        helper.mip_layers = meta.mipLayers;
+
+        for (uint32_t mip = 0; mip < meta.mipLayers; mip++) {
+            for (uint32_t layer = 0; layer < meta.depth; layer++) {
+                vk::ImageSubresourceLayers sub;
+                sub.setAspectMask(vk::ImageAspectFlagBits::eColor)
+                    .setMipLevel(mip)
+                    .setBaseArrayLayer(layer)
+                    .setLayerCount(1);
+
+                vk::Extent3D extent;
+                extent.setWidth(meta.width >> mip)
+                    .setHeight(meta.height >> mip)
+                    .setDepth(1);
+
+                vk::BufferImageCopy copy;
+                copy.setImageSubresource(sub)
+                    .setImageExtent(extent)
+                    .setBufferOffset(helper.getOffset(layer, mip));
+
+                copies.push_back(copy);
+            }
+        }
+
+        auto cmd = device->graphicsQueue().beginCommandBuffer();
+        cmd->copyBufferToImage(staging->handle(), mTexture->handle(),
+                               vk::ImageLayout::eTransferDstOptimal, copies);
+        mTexture->changeLayout(vk::ImageLayout::eTransferDstOptimal,
+                               vk::ImageLayout::eShaderReadOnlyOptimal, cmd);
+    }
+
+    mTextureSet = mTextureLayout->createDescriptorSet();
+    mTextureSet->write().combinedImageSampler(mSampler, mTexture->createView());
+}
+
+void VRMeshRenderer::draw(lava::InlineSubpass &sub,
+                          const lava::SharedDescriptorSet &cameraSet) {
+    sub.bindPipeline(mPipeline);
+
+    sub.bindDescriptorSets({cameraSet, mTextureSet});
+    sub.bindVertexBuffers({mVertexBuffer});
+    sub.bindIndexBuffer(mIndexBuffer);
+
+    sub.pushConstantBlock(mModelMatrix);
+
+    sub.drawIndexed(mIndexBuffer->size() / sizeof(uint32_t));
+}
+
+} // namespace vr
+} // namespace lava
diff --git a/src/lava-vr/VRMeshRenderer.hh b/src/lava-vr/VRMeshRenderer.hh
new file mode 100644
index 0000000000000000000000000000000000000000..87fb25cf6245170c387663a41c491956be70f485
--- /dev/null
+++ b/src/lava-vr/VRMeshRenderer.hh
@@ -0,0 +1,37 @@
+#pragma once
+#include "VRMesh.hh"
+#include <glm/mat4x4.hpp>
+#include <lava/fwd.hh>
+
+namespace lava {
+namespace vr {
+
+class VRMeshRenderer {
+  public:
+    VRMeshRenderer(lava::Subpass const &forwardPass,
+                   lava::SharedDescriptorSetLayout const &cameraLayout);
+    void loadFile(std::string const &filename);
+    void upload(VRMesh const &mesh);
+
+    void draw(lava::InlineSubpass &sub,
+              lava::SharedDescriptorSet const &cameraSet);
+
+    void setTransform(glm::mat4 const &t) { mModelMatrix = t; }
+    glm::mat4 const &getTransform() const { return mModelMatrix; }
+
+  protected:
+    glm::mat4 mModelMatrix;
+
+    lava::SharedGraphicsPipeline mPipeline;
+    lava::SharedPipelineLayout mLayout;
+
+    lava::SharedBuffer mVertexBuffer;
+    lava::SharedBuffer mIndexBuffer;
+    lava::SharedImage mTexture;
+
+    lava::SharedDescriptorSetLayout mTextureLayout;
+    lava::SharedDescriptorSet mTextureSet;
+    lava::SharedSampler mSampler;
+};
+} // namespace vr
+} // namespace lava