diff --git a/projects/CMakeLists.txt b/projects/CMakeLists.txt
index d1acdbcbf011aab7de96f060aeb55ede83c516eb..460766ad31045c5ac37d6aa8ccffc614a8921fdc 100644
--- a/projects/CMakeLists.txt
+++ b/projects/CMakeLists.txt
@@ -8,4 +8,5 @@ add_subdirectory(sph)
 add_subdirectory(voxelization)
 add_subdirectory(mesh_shader)
 add_subdirectory(saf_r)
-add_subdirectory(indirect_dispatch)
\ No newline at end of file
+add_subdirectory(indirect_dispatch)
+add_subdirectory(path_tracer)
\ No newline at end of file
diff --git a/projects/path_tracer/.gitignore b/projects/path_tracer/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..ff3dff30031efafa24269a9ac0ef93f64f63ded1
--- /dev/null
+++ b/projects/path_tracer/.gitignore
@@ -0,0 +1 @@
+saf_r
\ No newline at end of file
diff --git a/projects/path_tracer/CMakeLists.txt b/projects/path_tracer/CMakeLists.txt
new file mode 100644
index 0000000000000000000000000000000000000000..2b8edc208c70a5e8e74c6c28221f783a68a3ec6c
--- /dev/null
+++ b/projects/path_tracer/CMakeLists.txt
@@ -0,0 +1,29 @@
+cmake_minimum_required(VERSION 3.16)
+project(path_tracer)
+
+# setting c++ standard for the project
+set(CMAKE_CXX_STANDARD 17)
+set(CMAKE_CXX_STANDARD_REQUIRED ON)
+
+# this should fix the execution path to load local files from the project
+set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR})
+
+# adding source files to the project
+add_executable(path_tracer
+		src/main.cpp)
+
+# this should fix the execution path to load local files from the project (for MSVC)
+if(MSVC)
+	set_target_properties(path_tracer PROPERTIES RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
+	set_target_properties(path_tracer PROPERTIES RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
+
+	# in addition to setting the output directory, the working directory has to be set
+	# by default visual studio sets the working directory to the build directory, when using the debugger
+	set_target_properties(path_tracer PROPERTIES VS_DEBUGGER_WORKING_DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY})
+endif()
+
+# including headers of dependencies and the VkCV framework
+target_include_directories(path_tracer SYSTEM BEFORE PRIVATE ${vkcv_include} ${vkcv_includes} ${vkcv_testing_include} ${vkcv_asset_loader_include} ${vkcv_camera_include} ${vkcv_shader_compiler_include} ${vkcv_gui_include})
+
+# linking with libraries from all dependencies and the VkCV framework
+target_link_libraries(path_tracer vkcv vkcv_testing vkcv_asset_loader ${vkcv_asset_loader_libraries} vkcv_camera vkcv_shader_compiler vkcv_gui)
diff --git a/projects/path_tracer/shaders/clearImage.comp b/projects/path_tracer/shaders/clearImage.comp
new file mode 100644
index 0000000000000000000000000000000000000000..97998e945112d166be7d00df98ee44ea8322a633
--- /dev/null
+++ b/projects/path_tracer/shaders/clearImage.comp
@@ -0,0 +1,17 @@
+#version 440
+#extension GL_GOOGLE_include_directive : enable
+
+layout(set=0, binding=0, rgba32f) 	uniform image2D outImage;
+
+layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
+
+void main(){
+
+    ivec2 outImageRes = imageSize(outImage);
+    ivec2 coord       = ivec2(gl_GlobalInvocationID.xy);
+
+    if(any(greaterThanEqual(coord, outImageRes)))
+        return;
+
+    imageStore(outImage, coord, vec4(0));
+}
\ No newline at end of file
diff --git a/projects/path_tracer/shaders/combineImages.comp b/projects/path_tracer/shaders/combineImages.comp
new file mode 100644
index 0000000000000000000000000000000000000000..d1a4e85caf175dfc3125afd847d7458ddec2fef1
--- /dev/null
+++ b/projects/path_tracer/shaders/combineImages.comp
@@ -0,0 +1,21 @@
+#version 440
+#extension GL_GOOGLE_include_directive : enable
+
+layout(set=0, binding=0, rgba32f) uniform image2D newImage;
+layout(set=0, binding=1, rgba32f) uniform image2D meanImage;
+
+layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
+
+void main(){
+
+    ivec2 outImageRes = imageSize(meanImage);
+    ivec2 coord       = ivec2(gl_GlobalInvocationID.xy);
+
+    if(any(greaterThanEqual(coord, outImageRes)))
+        return;
+   
+    vec4 colorNew 	= imageLoad(newImage,  coord);
+	vec4 colorMean 	= imageLoad(meanImage, coord);
+
+    imageStore(meanImage, coord, colorNew + colorMean);
+}
\ No newline at end of file
diff --git a/projects/path_tracer/shaders/path_tracer.comp b/projects/path_tracer/shaders/path_tracer.comp
new file mode 100644
index 0000000000000000000000000000000000000000..f08bdfd123ede964befe5feed4ba9f438dc0a498
--- /dev/null
+++ b/projects/path_tracer/shaders/path_tracer.comp
@@ -0,0 +1,430 @@
+#version 450 core
+#extension GL_ARB_separate_shader_objects : enable
+
+const float pi      	= 3.1415926535897932384626433832795;
+const float hitBias 	= 0.0001;   // used to offset hits to avoid self intersection
+const float denomMin 	= 0.001;
+
+layout(local_size_x = 16, local_size_y = 16, local_size_z = 1) in;
+
+struct Material {
+    vec3 	emission;
+	float 	ks;		// specular percentage
+    vec3 	albedo;
+    float 	r;		// roughness
+	vec3 	f0;
+	float 	padding;
+};
+
+struct Sphere{
+    vec3 	center;
+    float 	radius;
+    int 	materialIndex;
+	float 	padding[3];
+};
+
+struct Plane{
+	vec3 	center;
+	int 	materialIndex;
+	vec3 	N;
+	float 	padding1;
+	vec2 	extent;
+	vec2 	padding2;
+};
+
+layout(std430, binding = 0) buffer spheres{
+    Sphere inSpheres[];
+};
+
+layout(std430, binding = 1) buffer planes{
+    Plane inPlanes[];
+};
+
+layout(std430, binding = 2) buffer materials{
+    Material inMaterials[];
+};
+
+layout(set=0, binding = 3, rgba32f) uniform image2D outImage;
+
+layout( push_constant ) uniform constants{
+    mat4 	viewToWorld;
+	vec3 	skyColor;
+    int 	sphereCount;
+	int 	planeCount;
+	int 	frameIndex;
+};
+
+// ---- Intersection functions ----
+
+struct Ray{
+	vec3 origin;
+	vec3 direction;
+};
+
+struct Intersection{
+    bool 		hit;
+	float 		distance;
+    vec3 		pos;
+    vec3 		N;
+    Material 	material;
+};
+
+// https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-sphere-intersection
+Intersection raySphereIntersect(Ray ray, Sphere sphere){
+
+	Intersection intersection;
+	intersection.hit = false;
+	
+    vec3 	L 	= sphere.center - ray.origin;
+    float 	tca = dot(L, ray.direction);
+    float 	d2 	= dot(L, L) - tca * tca;
+	
+    if (d2 > sphere.radius * sphere.radius){
+        return intersection;
+    }
+    float thc 	= float(sqrt(sphere.radius * sphere.radius - d2));
+    float t0 	= tca - thc;
+    float t1 	= tca + thc;
+	
+    if (t0 < 0)
+        t0 = t1;
+    
+    if (t0 < 0)
+        return intersection;
+	
+	intersection.hit 		= true;
+	intersection.distance 	= t0;
+	intersection.pos        = ray.origin + ray.direction * intersection.distance;
+	intersection.N          = normalize(intersection.pos - sphere.center);
+	intersection.material 	= inMaterials[sphere.materialIndex];
+	
+    return intersection;
+}
+
+struct Basis{
+	vec3 right;
+	vec3 up;
+	vec3 forward;
+};
+
+Basis buildBasisAroundNormal(vec3 N){
+	Basis 	basis;
+	basis.up 		= N;
+	basis.right 	= abs(basis.up.x) < 0.99 ?  vec3(1, 0, 0) : vec3(0, 0, 1);
+	basis.forward 	= normalize(cross(basis.up, basis.right));
+	basis.right 	= cross(basis.up, basis.forward);
+	return basis;
+}
+
+// see: https://www.scratchapixel.com/lessons/3d-basic-rendering/minimal-ray-tracer-rendering-simple-shapes/ray-plane-and-ray-disk-intersection
+Intersection rayPlaneIntersect(Ray ray, Plane plane){
+
+	Intersection intersection;
+	intersection.hit = false;
+
+	vec3 	toPlane = plane.center - ray.origin;
+	float 	denom 	= dot(ray.direction, plane.N);
+	if(abs(denom) < 0.001)
+		return intersection;
+		
+	intersection.distance = dot(toPlane, plane.N) / denom;
+	
+	if(intersection.distance < 0)
+		return intersection;
+	
+	intersection.pos 				= ray.origin + ray.direction * intersection.distance;
+	
+	vec3 	centerToIntersection	= intersection.pos - plane.center;
+	Basis 	planeBasis 				= buildBasisAroundNormal(plane.N);
+	float 	projectedRight			= dot(centerToIntersection, planeBasis.right);
+	float 	projectedUp				= dot(centerToIntersection, planeBasis.forward);
+
+	intersection.hit 		= abs(projectedRight) <= plane.extent.x && abs(projectedUp) <= plane.extent.y;
+	intersection.N 			= plane.N;
+	intersection.material 	= inMaterials[plane.materialIndex];
+	
+	return intersection;
+}
+
+Intersection sceneIntersect(Ray ray) {
+    float minDistance = 100000;  // lets start with something big
+    
+    Intersection intersection;
+    intersection.hit = false;
+    
+    for (int i = 0; i < sphereCount; i++) {
+		Intersection sphereIntersection = raySphereIntersect(ray, inSpheres[i]);
+        if (sphereIntersection.hit && sphereIntersection.distance < minDistance) {            
+            intersection 	= sphereIntersection;
+			minDistance 	= intersection.distance;
+        }
+    }
+	for (int i = 0; i < planeCount; i++){
+		Intersection planeIntersection = rayPlaneIntersect(ray, inPlanes[i]);
+        if (planeIntersection.hit && planeIntersection.distance < minDistance) {
+            intersection 	= planeIntersection;
+			minDistance 	= intersection.distance;
+        }
+	}
+    return intersection;
+}
+
+vec3 biasHitPosition(vec3 hitPos, vec3 rayDirection, vec3 N){
+    // return hitPos + N * hitBias; // works as long as no refraction/transmission is used and camera is outside sphere
+    return hitPos + sign(dot(rayDirection, N)) * N * hitBias;
+}
+
+// ---- noise/hash functions for pseudorandom variables ----
+
+// extremelearning.com.au/unreasonable-effectiveness-of-quasirandom-sequences
+vec2 r2Sequence(uint n){
+	n = n % 42000;
+	const float g = 1.32471795724474602596;
+	return fract(vec2(
+		n /  g,
+		n / (g*g)));
+}
+
+// random() and helpers from: https://www.shadertoy.com/view/XlycWh
+float g_seed = 0;
+
+uint base_hash(uvec2 p) {
+    p = 1103515245U*((p >> 1U)^(p.yx));
+    uint h32 = 1103515245U*((p.x)^(p.y>>3U));
+    return h32^(h32 >> 16);
+}
+
+vec2 hash2(inout float seed) {
+    uint n = base_hash(floatBitsToUint(vec2(seed+=.1,seed+=.1)));
+    uvec2 rz = uvec2(n, n*48271U);
+    return vec2(rz.xy & uvec2(0x7fffffffU))/float(0x7fffffff);
+}
+
+void initRandom(ivec2 coord){
+	g_seed = float(base_hash(coord)/float(0xffffffffU)+frameIndex);
+}
+
+vec2 random(){
+	return hash2(g_seed);
+}
+
+// ---- shading ----
+
+vec3 lambertBRDF(vec3 albedo){
+	return albedo / pi;
+}
+
+vec3 computeDiffuseBRDF(Material material){
+    return lambertBRDF(material.albedo);
+}
+
+float distributionGGX(float r, float NoH){
+	float r2 	= r*r;
+	float denom = pi * pow(NoH*NoH * (r2-1) + 1, 2);
+	return r2 / max(denom, denomMin);
+}
+
+float geometryGGXSmith(float r, float NoL){
+	float r2 	= r*r;
+	float denom = NoL + sqrt(r2 + (1-r2) * NoL*NoL);
+	return 2 * NoL / max(denom, denomMin);
+}
+
+float geometryGGX(float r, float NoV, float NoL){
+	return geometryGGXSmith(r, NoV) * geometryGGXSmith(r, NoL);
+}
+
+vec3 fresnelSchlick(vec3 f0, float NoH){
+	return f0 + (1 - f0) * pow(1 - NoH, 5);
+}
+
+vec3 computeSpecularBRDF(vec3 f0, float r, float NoV, float NoL, float NoH){
+	float 	denom 	= 4 * NoV * NoL;
+	float 	D 		= distributionGGX(r, NoH);
+	float 	G 		= geometryGGX(r, NoV, NoL);
+	vec3	F 		= fresnelSchlick(f0, NoH);
+	return D * F * G / max(denom, denomMin);
+}
+
+// ---- pathtracing and main ----
+
+// distributions: https://link.springer.com/content/pdf/10.1007/978-1-4842-4427-2_16.pdf
+float cosineDistributionPDF(float NoL){
+	return NoL / pi;
+}
+
+vec3 sampleCosineDistribution(vec2 xi){
+	float phi = 2 * pi * xi.y;
+	return vec3(
+		sqrt(xi.x) * cos(phi),
+		sqrt(1 - xi.x),
+		sqrt(xi.x) * sin(phi));
+}
+
+float uniformDistributionPDF(){
+	return 1.f / (2 * pi);
+}
+
+vec3 sampleUniformDistribution(vec2 xi){
+	float phi = 2 * pi * xi.y;
+	return vec3(
+		sqrt(xi.x) * cos(phi),
+		1 - xi.x,
+		sqrt(xi.x) * sin(phi));
+}
+
+float ggxDistributionPDF(float r, float NoH){
+	return distributionGGX(r, NoH) * NoH;
+}
+
+float ggxDistributionPDFReflected(float r, float NoH, float NoV){
+	float jacobian = 0.25 / max(NoV, denomMin);
+	return ggxDistributionPDF(r, NoH) * jacobian;
+}
+
+vec3 sampleGGXDistribution(vec2 xi, float r){
+	float phi 		= 2 * pi * xi.y;
+	float cosTheta 	= sqrt((1 - xi.x) / ((r*r - 1) * xi.x + 1));
+	float sinTheta	= sqrt(1 - cosTheta*cosTheta);
+	return vec3(
+		cos(phi) * sinTheta,
+		cosTheta,
+		sin(phi) * sinTheta);
+}
+
+vec3 sampleTangentToWorldSpace(vec3 tangentSpaceSample, vec3 N){
+	Basis tangentBasis = buildBasisAroundNormal(N);
+	return
+		tangentBasis.right		* tangentSpaceSample.x +
+		tangentBasis.up			* tangentSpaceSample.y +
+		tangentBasis.forward 	* tangentSpaceSample.z;
+}
+
+vec3 castRay(Ray ray) {
+    
+    vec3   	throughput 	= vec3(1);
+    vec3	color   	= vec3(0);
+    
+	const int maxDepth = 10;
+    for(int i = 0; i < maxDepth; i++){
+
+        Intersection intersection = sceneIntersect(ray);
+        
+        vec3 hitLighting 	= vec3(0);
+        vec3 brdf 			= vec3(1);
+		
+		// V is where the ray came from and will lead back to the camera (over multiple bounces)
+		vec3 	V 				= -normalize(ray.direction);
+		vec3 	R 				= reflect(-V, intersection.N);
+		float 	NoV				= max(dot(intersection.N, V), 0);
+		
+		intersection.material.r *= intersection.material.r;	// remapping for perceuptual linearity
+		intersection.material.r = max(intersection.material.r, 0.01);
+		
+		float 	kd 				= 1 - intersection.material.ks;
+		bool 	sampleDiffuse 	= random().x < kd;
+		
+		vec3 	sampleTangentSpace;
+		float 	pdf;
+		if(sampleDiffuse){
+			sampleTangentSpace 	= sampleCosineDistribution(random());
+			ray.direction   	= sampleTangentToWorldSpace(sampleTangentSpace, intersection.N);
+			
+			float NoL 			= max(dot(intersection.N, ray.direction), 0);
+			pdf 				= cosineDistributionPDF(NoL);
+		}
+		else{			
+			#define IMPORTANCE
+			
+			#ifdef IMPORTANCE
+			sampleTangentSpace 	= sampleGGXDistribution(random(), intersection.material.r);
+			ray.direction   	= sampleTangentToWorldSpace(sampleTangentSpace, R);		
+			vec3 	L 			= normalize(ray.direction);
+			pdf 				= ggxDistributionPDFReflected(intersection.material.r, max(sampleTangentSpace.y, 0.01), max(dot(intersection.N, V), 0.01));
+			#else
+			sampleTangentSpace 	= sampleUniformDistribution(random());
+			ray.direction   	= sampleTangentToWorldSpace(sampleTangentSpace, intersection.N);		
+			pdf 				= uniformDistributionPDF();
+			#endif
+		}
+        
+        ray.origin				= biasHitPosition(intersection.pos, ray.direction, intersection.N);
+		
+		// L is where the ray is going, as that is the direction where light will from
+		vec3 	L 			= normalize(ray.direction);
+		vec3 	H			= normalize(L + V);
+		
+        float 	NoL 		= max(dot(intersection.N, L), 0);
+		float	NoH			= max(dot(intersection.N, H), 0);
+		
+        if(intersection.hit){
+            vec3 	diffuseBRDF 	= computeDiffuseBRDF(intersection.material);
+
+			vec3 	specularBRDF 	= computeSpecularBRDF(intersection.material.f0, intersection.material.r, NoV, NoL, NoH);
+			brdf 					= mix(diffuseBRDF, specularBRDF, intersection.material.ks);
+			
+			
+			hitLighting = intersection.material.emission * max(sign(NoV), 0);	// objects only emit in direction of normal
+        }
+        else{
+            hitLighting = skyColor;
+        }
+        
+        color       	+= hitLighting * throughput;
+        throughput  	*= brdf * NoL / max(pdf, denomMin);
+        
+        if(!intersection.hit)
+            break;
+    }
+
+    return color;
+}
+
+// coord must be in pixel coordinates, but already shifted to pixel center
+vec3 computeCameraRay(vec2 coord){
+
+    ivec2 outImageRes   = imageSize(outImage);
+    float fovDegree     = 45;
+    float fov           = fovDegree * pi / 180;
+    
+    vec2 uv     		= coord / vec2(outImageRes);
+    vec2 ndc    		= 2 * uv - 1;
+    
+    float tanFovHalf    = tan(fov / 2.f);
+    float aspectRatio   = outImageRes.x / float(outImageRes.y);
+    float x             =  ndc.x * tanFovHalf * aspectRatio;
+    float y             = -ndc.y * tanFovHalf;
+    
+    // view direction goes through pixel on image plane with z=1
+    vec3 directionViewSpace     = normalize(vec3(x, y, 1));
+    vec3 directionWorldSpace    = mat3(viewToWorld) * directionViewSpace;
+    return directionWorldSpace;
+}
+
+void main(){
+    ivec2 	coord     = ivec2(gl_GlobalInvocationID.xy);
+	vec2 	pixelSize = 1.f / coord;
+	initRandom(coord);
+	
+	Ray cameraRay;
+    cameraRay.origin  	= viewToWorld[3].xyz;
+	vec2 coordCentered 	= coord + 0.5;
+	
+	vec3 color = vec3(0);
+	
+	const int samplesPerPixel = 1;
+	for(int i = 0; i < samplesPerPixel; i++){
+		vec2 jitter 		= r2Sequence(i + frameIndex) - 0.5;
+		cameraRay.direction = computeCameraRay(coordCentered + jitter);
+		color 				+= castRay(cameraRay);
+	}
+	color /= samplesPerPixel;
+		
+	vec4 final = vec4(color, 1);
+	
+	// occasional NaNs in reflection, should be fixed properly
+	if(any(isnan(color)))
+		final = vec4(0);
+	
+    imageStore(outImage, coord, final);
+}
\ No newline at end of file
diff --git a/projects/path_tracer/shaders/presentImage.comp b/projects/path_tracer/shaders/presentImage.comp
new file mode 100644
index 0000000000000000000000000000000000000000..a52159c0c6173779b091e5d4153b15b0a6361780
--- /dev/null
+++ b/projects/path_tracer/shaders/presentImage.comp
@@ -0,0 +1,23 @@
+#version 440
+#extension GL_GOOGLE_include_directive : enable
+
+layout(set=0, binding=0, rgba32f) 	uniform image2D inImage;
+layout(set=0, binding=1, rgba8) 	uniform image2D outImage;
+
+layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
+
+void main(){
+
+    ivec2 outImageRes = imageSize(outImage);
+    ivec2 coord       = ivec2(gl_GlobalInvocationID.xy);
+
+    if(any(greaterThanEqual(coord, outImageRes)))
+        return;
+   
+    vec4 colorRaw 				= imageLoad(inImage, coord);
+	vec3 colorNormalized 		= colorRaw.rgb / colorRaw.a;
+	vec3 colorTonemapped		= colorNormalized / (1 + dot(colorNormalized, vec3(0.71, 0.21, 0.08)));	// reinhard tonemapping
+	vec3 colorGammaCorrected 	= pow(colorTonemapped, vec3(1.f / 2.2));
+
+    imageStore(outImage, coord, vec4(colorGammaCorrected, 0));
+}
\ No newline at end of file
diff --git a/projects/path_tracer/src/main.cpp b/projects/path_tracer/src/main.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..c3b21f78cea479a463fb6224605a202d35d8e581
--- /dev/null
+++ b/projects/path_tracer/src/main.cpp
@@ -0,0 +1,451 @@
+#include <vkcv/Core.hpp>
+#include <vkcv/camera/CameraManager.hpp>
+#include <vkcv/asset/asset_loader.hpp>
+#include <vkcv/shader/GLSLCompiler.hpp>
+#include "vkcv/gui/GUI.hpp"
+#include <chrono>
+#include <vector>
+
+int main(int argc, const char** argv) {
+
+	// structs must match shader version
+	struct Material {
+		Material(const glm::vec3& emission, const glm::vec3& albedo, float ks, float roughness, const glm::vec3& f0)
+			: emission(emission), albedo(albedo), ks(ks), roughness(roughness), f0(f0){}
+
+		glm::vec3   emission;
+		float       ks;
+		glm::vec3   albedo;
+		float       roughness;
+		glm::vec3   f0;
+		float       padding;
+	};
+
+	struct Sphere {
+		Sphere(const glm::vec3& c, const float& r, const int m) : center(c), radius(r), materialIndex(m) {}
+
+		glm::vec3   center;
+		float       radius;
+		uint32_t    materialIndex;
+		float       padding[3];
+	};
+
+	struct Plane {
+		Plane(const glm::vec3& c, const glm::vec3& n, const glm::vec2 e, int m)
+			: center(c), normal(n), extent(e), materialIndex(m) {}
+
+		glm::vec3   center;
+		uint32_t    materialIndex;
+		glm::vec3   normal;
+		float       padding1;
+		glm::vec2   extent;
+		glm::vec2   padding3;
+	};
+
+	const char* applicationName = "Path Tracer";
+
+	const int initialWidth = 1280;
+	const int initialHeight = 720;
+	vkcv::Window window = vkcv::Window::create(
+		applicationName,
+		initialWidth,
+		initialHeight,
+		true);
+
+	vkcv::Core core = vkcv::Core::create(
+		window,
+		applicationName,
+		VK_MAKE_VERSION(0, 0, 1),
+		{ vk::QueueFlagBits::eTransfer,vk::QueueFlagBits::eGraphics, vk::QueueFlagBits::eCompute },
+		{ "VK_KHR_swapchain" }
+	);
+
+	// images
+	vkcv::ImageHandle outputImage = core.createImage(
+		vk::Format::eR32G32B32A32Sfloat,
+		initialWidth,
+		initialHeight,
+		1,
+		false,
+		true).getHandle();
+
+	vkcv::ImageHandle meanImage = core.createImage(
+		vk::Format::eR32G32B32A32Sfloat,
+		initialWidth,
+		initialHeight,
+		1,
+		false,
+		true).getHandle();
+
+	vkcv::shader::GLSLCompiler compiler;
+
+	// path tracing shader
+	vkcv::ShaderProgram traceShaderProgram{};
+
+	compiler.compile(vkcv::ShaderStage::COMPUTE, "shaders/path_tracer.comp", [&](vkcv::ShaderStage shaderStage, const std::filesystem::path& path) {
+		traceShaderProgram.addShader(shaderStage, path);
+	});
+
+	const vkcv::DescriptorBindings& traceDescriptorBindings     = traceShaderProgram.getReflectedDescriptors().at(0);
+	vkcv::DescriptorSetLayoutHandle traceDescriptorSetLayout    = core.createDescriptorSetLayout(traceDescriptorBindings);
+	vkcv::DescriptorSetHandle       traceDescriptorSet          = core.createDescriptorSet(traceDescriptorSetLayout);
+
+	// image combine shader
+	vkcv::ShaderProgram imageCombineShaderProgram{};
+
+	compiler.compile(vkcv::ShaderStage::COMPUTE, "shaders/combineImages.comp", [&](vkcv::ShaderStage shaderStage, const std::filesystem::path& path) {
+		imageCombineShaderProgram.addShader(shaderStage, path);
+	});
+
+	const vkcv::DescriptorBindings& imageCombineDescriptorBindings  = imageCombineShaderProgram.getReflectedDescriptors().at(0);
+	vkcv::DescriptorSetLayoutHandle imageCombineDescriptorSetLayout = core.createDescriptorSetLayout(imageCombineDescriptorBindings);
+	vkcv::DescriptorSetHandle       imageCombineDescriptorSet       = core.createDescriptorSet(imageCombineDescriptorSetLayout);
+	vkcv::PipelineHandle            imageCombinePipeline            = core.createComputePipeline(
+		imageCombineShaderProgram, 
+		{ core.getDescriptorSetLayout(imageCombineDescriptorSetLayout).vulkanHandle });
+
+	vkcv::DescriptorWrites imageCombineDescriptorWrites;
+	imageCombineDescriptorWrites.storageImageWrites = {
+		vkcv::StorageImageDescriptorWrite(0, outputImage),
+		vkcv::StorageImageDescriptorWrite(1, meanImage)
+	};
+	core.writeDescriptorSet(imageCombineDescriptorSet, imageCombineDescriptorWrites);
+
+	// image present shader
+	vkcv::ShaderProgram presentShaderProgram{};
+
+	compiler.compile(vkcv::ShaderStage::COMPUTE, "shaders/presentImage.comp", [&](vkcv::ShaderStage shaderStage, const std::filesystem::path& path) {
+		presentShaderProgram.addShader(shaderStage, path);
+	});
+
+	const vkcv::DescriptorBindings& presentDescriptorBindings   = presentShaderProgram.getReflectedDescriptors().at(0);
+	vkcv::DescriptorSetLayoutHandle presentDescriptorSetLayout  = core.createDescriptorSetLayout(presentDescriptorBindings);
+	vkcv::DescriptorSetHandle       presentDescriptorSet        = core.createDescriptorSet(presentDescriptorSetLayout);
+	vkcv::PipelineHandle            presentPipeline             = core.createComputePipeline(
+		presentShaderProgram,
+		{ core.getDescriptorSetLayout(presentDescriptorSetLayout).vulkanHandle });
+
+	// clear shader
+	vkcv::ShaderProgram clearShaderProgram{};
+
+	compiler.compile(vkcv::ShaderStage::COMPUTE, "shaders/clearImage.comp", [&](vkcv::ShaderStage shaderStage, const std::filesystem::path& path) {
+		clearShaderProgram.addShader(shaderStage, path);
+	});
+
+	const vkcv::DescriptorBindings& imageClearDescriptorBindings    = clearShaderProgram.getReflectedDescriptors().at(0);
+	vkcv::DescriptorSetLayoutHandle imageClearDescriptorSetLayout   = core.createDescriptorSetLayout(imageClearDescriptorBindings);
+	vkcv::DescriptorSetHandle       imageClearDescriptorSet         = core.createDescriptorSet(imageClearDescriptorSetLayout);
+	vkcv::PipelineHandle            imageClearPipeline              = core.createComputePipeline(
+		clearShaderProgram,
+		{ core.getDescriptorSetLayout(imageClearDescriptorSetLayout).vulkanHandle });
+
+	vkcv::DescriptorWrites imageClearDescriptorWrites;
+	imageClearDescriptorWrites.storageImageWrites = {
+		vkcv::StorageImageDescriptorWrite(0, meanImage)
+	};
+	core.writeDescriptorSet(imageClearDescriptorSet, imageClearDescriptorWrites);
+
+	// buffers
+	typedef std::pair<std::string, Material> MaterialSetting;
+
+	std::vector<MaterialSetting> materialSettings;
+	materialSettings.emplace_back(MaterialSetting("white",  Material(glm::vec3(0),    glm::vec3(0.65),          0, 0.25, glm::vec3(0.04))));
+	materialSettings.emplace_back(MaterialSetting("red",    Material(glm::vec3(0),    glm::vec3(0.5, 0.0, 0.0), 0, 0.25, glm::vec3(0.04))));
+	materialSettings.emplace_back(MaterialSetting("green",  Material(glm::vec3(0),    glm::vec3(0.0, 0.5, 0.0), 0, 0.25, glm::vec3(0.04))));
+	materialSettings.emplace_back(MaterialSetting("light",  Material(glm::vec3(20),   glm::vec3(0),             0, 0.25, glm::vec3(0.04))));
+	materialSettings.emplace_back(MaterialSetting("sphere", Material(glm::vec3(0),    glm::vec3(0.65),          1, 0.25, glm::vec3(0.04))));
+	materialSettings.emplace_back(MaterialSetting("ground", Material(glm::vec3(0),    glm::vec3(0.65),          0, 0.25, glm::vec3(0.04))));
+
+	const uint32_t whiteMaterialIndex   = 0;
+	const uint32_t redMaterialIndex     = 1;
+	const uint32_t greenMaterialIndex   = 2;
+	const uint32_t lightMaterialIndex   = 3;
+	const uint32_t sphereMaterialIndex  = 4;
+	const uint32_t groundMaterialIndex  = 5;
+
+	std::vector<Sphere> spheres;
+	spheres.emplace_back(Sphere(glm::vec3(0, -1.5, 0), 0.5, sphereMaterialIndex));
+
+	std::vector<Plane> planes;
+	planes.emplace_back(Plane(glm::vec3( 0, -2,     0), glm::vec3( 0,  1,  0), glm::vec2(2), groundMaterialIndex));
+	planes.emplace_back(Plane(glm::vec3( 0,  2,     0), glm::vec3( 0, -1,  0), glm::vec2(2), whiteMaterialIndex));
+	planes.emplace_back(Plane(glm::vec3( 2,  0,     0), glm::vec3(-1,  0,  0), glm::vec2(2), redMaterialIndex));
+	planes.emplace_back(Plane(glm::vec3(-2,  0,     0), glm::vec3( 1,  0,  0), glm::vec2(2), greenMaterialIndex));
+	planes.emplace_back(Plane(glm::vec3( 0,  0,     2), glm::vec3( 0,  0, -1), glm::vec2(2), whiteMaterialIndex));
+	planes.emplace_back(Plane(glm::vec3( 0,  1.9,   0), glm::vec3( 0, -1,  0), glm::vec2(1), lightMaterialIndex));
+
+	vkcv::Buffer<Sphere> sphereBuffer = core.createBuffer<Sphere>(
+		vkcv::BufferType::STORAGE,
+		spheres.size());
+	sphereBuffer.fill(spheres);
+
+	vkcv::Buffer<Plane> planeBuffer = core.createBuffer<Plane>(
+		vkcv::BufferType::STORAGE,
+		planes.size());
+	planeBuffer.fill(planes);
+
+	vkcv::Buffer<Material> materialBuffer = core.createBuffer<Material>(
+		vkcv::BufferType::STORAGE,
+		materialSettings.size());
+
+	vkcv::DescriptorWrites traceDescriptorWrites;
+	traceDescriptorWrites.storageBufferWrites = { 
+		vkcv::BufferDescriptorWrite(0, sphereBuffer.getHandle()),
+		vkcv::BufferDescriptorWrite(1, planeBuffer.getHandle()),
+		vkcv::BufferDescriptorWrite(2, materialBuffer.getHandle())};
+	traceDescriptorWrites.storageImageWrites = {
+		vkcv::StorageImageDescriptorWrite(3, outputImage)};
+	core.writeDescriptorSet(traceDescriptorSet, traceDescriptorWrites);
+
+	vkcv::PipelineHandle tracePipeline = core.createComputePipeline(
+		traceShaderProgram, 
+		{ core.getDescriptorSetLayout(traceDescriptorSetLayout).vulkanHandle });
+
+	if (!tracePipeline)
+	{
+		vkcv_log(vkcv::LogLevel::ERROR, "Could not create graphics pipeline. Exiting.");
+		return EXIT_FAILURE;
+	}
+
+	vkcv::camera::CameraManager cameraManager(window);
+	uint32_t camIndex0 = cameraManager.addCamera(vkcv::camera::ControllerType::PILOT);
+
+	cameraManager.getCamera(camIndex0).setPosition(glm::vec3(0, 0, -2));
+
+	auto    startTime       = std::chrono::system_clock::now();
+	float   time            = 0;
+	int     frameIndex      = 0;
+	bool    clearMeanImage  = true;
+	bool    updateMaterials = true;
+
+	float cameraPitchPrevious   = 0;
+	float cameraYawPrevious     = 0;
+	glm::vec3 cameraPositionPrevious = glm::vec3(0);
+
+	uint32_t widthPrevious  = initialWidth;
+	uint32_t heightPrevious = initialHeight;
+
+	vkcv::gui::GUI gui(core, window);
+
+	bool renderUI = true;
+	window.e_key.add([&renderUI](int key, int scancode, int action, int mods) {
+		if (key == GLFW_KEY_I && action == GLFW_PRESS) {
+			renderUI = !renderUI;
+		}
+	});
+
+	glm::vec3   skyColor            = glm::vec3(0.2, 0.7, 0.8);
+	float       skyColorMultiplier  = 1;
+
+	while (window.isWindowOpen())
+	{
+		vkcv::Window::pollEvents();
+
+		uint32_t swapchainWidth, swapchainHeight; // No resizing = No problem
+		if (!core.beginFrame(swapchainWidth, swapchainHeight)) {
+			continue;
+		}
+
+		if (swapchainWidth != widthPrevious || swapchainHeight != heightPrevious) {
+
+			// resize images
+			outputImage = core.createImage(
+				vk::Format::eR32G32B32A32Sfloat,
+				swapchainWidth,
+				swapchainHeight,
+				1,
+				false,
+				true).getHandle();
+
+			meanImage = core.createImage(
+				vk::Format::eR32G32B32A32Sfloat,
+				swapchainWidth,
+				swapchainHeight,
+				1,
+				false,
+				true).getHandle();
+
+			// update descriptor sets
+			traceDescriptorWrites.storageImageWrites = {
+			vkcv::StorageImageDescriptorWrite(3, outputImage) };
+			core.writeDescriptorSet(traceDescriptorSet, traceDescriptorWrites);
+
+			vkcv::DescriptorWrites imageCombineDescriptorWrites;
+			imageCombineDescriptorWrites.storageImageWrites = {
+				vkcv::StorageImageDescriptorWrite(0, outputImage),
+				vkcv::StorageImageDescriptorWrite(1, meanImage)
+			};
+			core.writeDescriptorSet(imageCombineDescriptorSet, imageCombineDescriptorWrites);
+
+			vkcv::DescriptorWrites imageClearDescriptorWrites;
+			imageClearDescriptorWrites.storageImageWrites = {
+				vkcv::StorageImageDescriptorWrite(0, meanImage)
+			};
+			core.writeDescriptorSet(imageClearDescriptorSet, imageClearDescriptorWrites);
+
+			widthPrevious  = swapchainWidth;
+			heightPrevious = swapchainHeight;
+
+			clearMeanImage = true;
+		}
+
+		auto end = std::chrono::system_clock::now();
+		auto deltatime = std::chrono::duration_cast<std::chrono::microseconds>(end - startTime);
+		startTime = end;
+
+		time += 0.000001f * static_cast<float>(deltatime.count());
+
+		cameraManager.update(0.000001 * static_cast<double>(deltatime.count()));
+
+		const vkcv::CommandStreamHandle cmdStream = core.createCommandStream(vkcv::QueueType::Graphics);
+
+		uint32_t fullscreenDispatchCount[3] = {
+			static_cast<uint32_t> (std::ceil(swapchainWidth  / 8.f)),
+			static_cast<uint32_t> (std::ceil(swapchainHeight / 8.f)),
+			1 };
+
+		if (updateMaterials) {
+			std::vector<Material> materials;
+			for (const auto& settings : materialSettings) {
+				materials.push_back(settings.second);
+			}
+			materialBuffer.fill(materials);
+			updateMaterials = false;
+			clearMeanImage  = true;
+		}
+
+		float cameraPitch;
+		float cameraYaw;
+		cameraManager.getActiveCamera().getAngles(cameraPitch, cameraYaw);
+
+		if (glm::abs(cameraPitch - cameraPitchPrevious) > 0.01 || glm::abs(cameraYaw - cameraYawPrevious) > 0.01)
+			clearMeanImage = true;	// camera rotated
+
+		cameraPitchPrevious = cameraPitch;
+		cameraYawPrevious   = cameraYaw;
+
+		glm::vec3 cameraPosition = cameraManager.getActiveCamera().getPosition();
+
+		if(glm::distance(cameraPosition, cameraPositionPrevious) > 0.0001)
+			clearMeanImage = true;	// camera moved
+
+		cameraPositionPrevious = cameraPosition;
+
+		if (clearMeanImage) {
+			core.prepareImageForStorage(cmdStream, meanImage);
+
+			core.recordComputeDispatchToCmdStream(cmdStream,
+				imageClearPipeline,
+				fullscreenDispatchCount,
+				{ vkcv::DescriptorSetUsage(0, core.getDescriptorSet(imageClearDescriptorSet).vulkanHandle) },
+				vkcv::PushConstants(0));
+
+			clearMeanImage = false;
+		}
+
+		// path tracing
+		struct RaytracingPushConstantData {
+			glm::mat4   viewToWorld;
+			glm::vec3   skyColor;
+			int32_t     sphereCount;
+			int32_t     planeCount;
+			int32_t     frameIndex;
+		};
+
+		RaytracingPushConstantData raytracingPushData;
+		raytracingPushData.viewToWorld  = glm::inverse(cameraManager.getActiveCamera().getView());
+		raytracingPushData.skyColor     = skyColor * skyColorMultiplier;
+		raytracingPushData.sphereCount  = spheres.size();
+		raytracingPushData.planeCount   = planes.size();
+		raytracingPushData.frameIndex   = frameIndex;
+
+		vkcv::PushConstants pushConstantsCompute(sizeof(RaytracingPushConstantData));
+		pushConstantsCompute.appendDrawcall(raytracingPushData);
+
+		uint32_t traceDispatchCount[3] = { 
+			static_cast<uint32_t> (std::ceil(swapchainWidth  / 16.f)),
+			static_cast<uint32_t> (std::ceil(swapchainHeight / 16.f)),
+			1 };
+
+		core.prepareImageForStorage(cmdStream, outputImage);
+
+		core.recordComputeDispatchToCmdStream(cmdStream,
+			tracePipeline,
+			traceDispatchCount,
+			{ vkcv::DescriptorSetUsage(0,core.getDescriptorSet(traceDescriptorSet).vulkanHandle) },
+			pushConstantsCompute);
+
+		core.prepareImageForStorage(cmdStream, meanImage);
+		core.recordImageMemoryBarrier(cmdStream, outputImage);
+
+		// combine images
+		core.recordComputeDispatchToCmdStream(cmdStream,
+			imageCombinePipeline,
+			fullscreenDispatchCount,
+			{ vkcv::DescriptorSetUsage(0,core.getDescriptorSet(imageCombineDescriptorSet).vulkanHandle) },
+			vkcv::PushConstants(0));
+
+		core.recordImageMemoryBarrier(cmdStream, meanImage);
+
+		// present image
+		const vkcv::ImageHandle swapchainInput = vkcv::ImageHandle::createSwapchainImageHandle();
+
+		vkcv::DescriptorWrites presentDescriptorWrites;
+		presentDescriptorWrites.storageImageWrites = {
+			vkcv::StorageImageDescriptorWrite(0, meanImage),
+			vkcv::StorageImageDescriptorWrite(1, swapchainInput) };
+		core.writeDescriptorSet(presentDescriptorSet, presentDescriptorWrites);
+
+		core.prepareImageForStorage(cmdStream, swapchainInput);
+
+		core.recordComputeDispatchToCmdStream(cmdStream,
+			presentPipeline,
+			fullscreenDispatchCount,
+			{ vkcv::DescriptorSetUsage(0,core.getDescriptorSet(presentDescriptorSet).vulkanHandle) },
+			vkcv::PushConstants(0));
+
+		core.prepareSwapchainImageForPresent(cmdStream);
+		core.submitCommandStream(cmdStream);
+
+		if (renderUI) {
+			gui.beginGUI();
+
+			ImGui::Begin("Settings");
+
+			clearMeanImage |= ImGui::ColorEdit3("Sky color", &skyColor.x);
+			clearMeanImage |= ImGui::InputFloat("Sky color multiplier", &skyColorMultiplier);
+
+			if (ImGui::CollapsingHeader("Materials")) {
+
+				for (auto& setting : materialSettings) {
+					if (ImGui::CollapsingHeader(setting.first.c_str())) {
+
+						const glm::vec3 emission            = setting.second.emission;
+						float           emissionStrength    = glm::max(glm::max(glm::max(emission.x, emission.y), emission.z), 1.f);
+						glm::vec3       emissionColor       = emission / emissionStrength;
+
+						updateMaterials |= ImGui::ColorEdit3((std::string("Emission color ")    + setting.first).c_str(), &emissionColor.x);
+						updateMaterials |= ImGui::InputFloat((std::string("Emission strength ") + setting.first).c_str(), &emissionStrength);
+
+						setting.second.emission = emissionStrength * emissionColor;
+
+						updateMaterials |= ImGui::ColorEdit3((std::string("Albedo color ")  + setting.first).c_str(), &setting.second.albedo.x);
+						updateMaterials |= ImGui::ColorEdit3((std::string("F0 ")            + setting.first).c_str(), &setting.second.f0.x);
+						updateMaterials |= ImGui::DragFloat(( std::string("ks ")            + setting.first).c_str(), &setting.second.ks, 0.01, 0, 1);
+						updateMaterials |= ImGui::DragFloat(( std::string("roughness ")     + setting.first).c_str(), &setting.second.roughness, 0.01, 0, 1);
+
+					}
+				}
+			}
+
+			ImGui::End();
+
+			gui.endGUI();
+		}
+
+		core.endFrame();
+
+		frameIndex++;
+	}
+	return 0;
+}