diff --git a/CMakeLists.txt b/CMakeLists.txt
index 72d57ab..41d5001 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -75,7 +75,8 @@ set(WGSL_SHADER_FILES
     reference_path_tracer.wgsl
     texture_blit.wgsl
     hybrid_renderer_gbuffer_pass.wgsl
-    hybrid_renderer_debug_pass.wgsl)
+    hybrid_renderer_debug_pass.wgsl
+    hybrid_renderer_sky_pass.wgsl)
 
 set(SHADER_SOURCE_HEADER_FILE src/pt/shader_source.hpp)
 
diff --git a/src/pt/aligned_sky_state.hpp b/src/pt/aligned_sky_state.hpp
new file mode 100644
index 0000000..1da45eb
--- /dev/null
+++ b/src/pt/aligned_sky_state.hpp
@@ -0,0 +1,72 @@
+#pragma once
+
+#include <common/assert.hpp>
+#include <common/units/angle.hpp>
+
+#include <glm/glm.hpp>
+#include <hw-skymodel/hw_skymodel.h>
+
+#include <array>
+#include <cstring>
+#include <numbers>
+
+namespace nlrs
+{
+struct Sky
+{
+    float                turbidity = 1.0f;
+    std::array<float, 3> albedo = {1.0f, 1.0f, 1.0f};
+    float                sunZenithDegrees = 30.0f;
+    float                sunAzimuthDegrees = 0.0f;
+
+    bool operator==(const Sky&) const noexcept = default;
+};
+
+// A 16-byte aligned sky state for the hw-skymodel library. Matches the layout of the following WGSL
+// struct:
+//
+// struct SkyState {
+//     params: array<f32, 27>,
+//     skyRadiances: array<f32, 3>,
+//     solarRadiances: array<f32, 3>,
+//     sunDirection: vec3<f32>,
+// };
+struct AlignedSkyState
+{
+    float     params[27];        // offset: 0
+    float     skyRadiances[3];   // offset: 27
+    float     solarRadiances[3]; // offset: 30
+    float     padding1[3];       // offset: 33
+    glm::vec3 sunDirection;      // offset: 36
+    float     padding2;          // offset: 39
+
+    inline AlignedSkyState(const Sky& sky)
+        : params{0},
+          skyRadiances{0},
+          solarRadiances{0},
+          padding1{0.f, 0.f, 0.f},
+          sunDirection(0.f),
+          padding2(0.0f)
+    {
+        const float sunZenith = Angle::degrees(sky.sunZenithDegrees).asRadians();
+        const float sunAzimuth = Angle::degrees(sky.sunAzimuthDegrees).asRadians();
+
+        sunDirection = glm::normalize(glm::vec3(
+            std::sin(sunZenith) * std::cos(sunAzimuth),
+            std::cos(sunZenith),
+            -std::sin(sunZenith) * std::sin(sunAzimuth)));
+
+        const sky_params skyParams{
+            .elevation = 0.5f * std::numbers::pi_v<float> - sunZenith,
+            .turbidity = sky.turbidity,
+            .albedo = {sky.albedo[0], sky.albedo[1], sky.albedo[2]}};
+
+        sky_state skyState;
+        NLRS_ASSERT(sky_state_new(&skyParams, &skyState) == sky_state_result_success);
+
+        std::memcpy(params, skyState.params, sizeof(skyState.params));
+        std::memcpy(skyRadiances, skyState.sky_radiances, sizeof(skyState.sky_radiances));
+        std::memcpy(solarRadiances, skyState.solar_radiances, sizeof(skyState.solar_radiances));
+    }
+};
+} // namespace nlrs
diff --git a/src/pt/hybrid_renderer.cpp b/src/pt/hybrid_renderer.cpp
index 926e15f..daae70c 100644
--- a/src/pt/hybrid_renderer.cpp
+++ b/src/pt/hybrid_renderer.cpp
@@ -71,7 +71,8 @@ HybridRenderer::HybridRenderer(
       mGbufferBindGroupLayout(),
       mGbufferBindGroup(),
       mGbufferPass(gpuContext, rendererDesc),
-      mDebugPass()
+      mDebugPass(),
+      mSkyPass(gpuContext)
 {
     {
         const std::array<WGPUTextureFormat, 1> depthFormats{
@@ -169,8 +170,10 @@ HybridRenderer::~HybridRenderer()
 
 void HybridRenderer::render(
     const GpuContext&     gpuContext,
-    const WGPUTextureView textureView,
-    const glm::mat4&      viewProjectionMat)
+    const glm::mat4&      viewProjectionMat,
+    const glm::vec3&      cameraPosition,
+    const Sky&            sky,
+    const WGPUTextureView textureView)
 {
     wgpuDeviceTick(gpuContext.device);
 
@@ -190,7 +193,7 @@ void HybridRenderer::render(
         mAlbedoTextureView,
         mNormalTextureView);
 
-    mDebugPass.render(mGbufferBindGroup, encoder, textureView);
+    mSkyPass.render(gpuContext, viewProjectionMat, cameraPosition, sky, encoder, textureView);
 
     const WGPUCommandBuffer cmdBuffer = [encoder]() {
         const WGPUCommandBufferDescriptor cmdBufferDesc{
@@ -1020,4 +1023,240 @@ void HybridRenderer::DebugPass::resize(const GpuContext& gpuContext, const Exten
     wgpuQueueWriteBuffer(
         gpuContext.queue, mUniformBuffer.ptr(), 0, &uniformData.x, sizeof(Extent2<float>));
 }
+
+HybridRenderer::SkyPass::SkyPass(const GpuContext& gpuContext)
+    : mCurrentSky{},
+      mVertexBuffer{
+          gpuContext.device,
+          "Sky vertex buffer",
+          GpuBufferUsage::Vertex | GpuBufferUsage::CopyDst,
+          std::span<const float[2]>(quadVertexData)},
+      mSkyStateBuffer{
+          gpuContext.device,
+          "Sky state buffer",
+          GpuBufferUsage::ReadOnlyStorage | GpuBufferUsage::CopyDst,
+          sizeof(AlignedSkyState)},
+      mSkyStateBindGroup{},
+      mUniformBuffer{
+          gpuContext.device,
+          "Sky uniform buffer",
+          GpuBufferUsage::Uniform | GpuBufferUsage::CopyDst,
+          sizeof(Uniforms)},
+      mUniformBindGroup{},
+      mPipeline(nullptr)
+{
+    {
+        const AlignedSkyState skyState{mCurrentSky};
+        wgpuQueueWriteBuffer(
+            gpuContext.queue, mSkyStateBuffer.ptr(), 0, &skyState, sizeof(AlignedSkyState));
+    }
+
+    const GpuBindGroupLayout skyStateBindGroupLayout{
+        gpuContext.device,
+        "Sky pass sky state bind group layout",
+        mSkyStateBuffer.bindGroupLayoutEntry(0, WGPUShaderStage_Fragment, sizeof(AlignedSkyState))};
+
+    mSkyStateBindGroup = GpuBindGroup{
+        gpuContext.device,
+        "Sky pass sky state bind group",
+        skyStateBindGroupLayout.ptr(),
+        mSkyStateBuffer.bindGroupEntry(0)};
+
+    const GpuBindGroupLayout uniformBindGroupLayout{
+        gpuContext.device,
+        "Sky passs uniform bind group layout",
+        mUniformBuffer.bindGroupLayoutEntry(0, WGPUShaderStage_Fragment, sizeof(Uniforms))};
+
+    mUniformBindGroup = GpuBindGroup{
+        gpuContext.device,
+        "Sky pass uniform bind group",
+        uniformBindGroupLayout.ptr(),
+        mUniformBuffer.bindGroupEntry(0)};
+
+    {
+        // Pipeline layout
+
+        const WGPUBindGroupLayout bindGroupLayouts[] = {
+            skyStateBindGroupLayout.ptr(), uniformBindGroupLayout.ptr()};
+
+        const WGPUPipelineLayoutDescriptor pipelineLayoutDesc{
+            .nextInChain = nullptr,
+            .label = "Sky pass pipeline layout",
+            .bindGroupLayoutCount = std::size(bindGroupLayouts),
+            .bindGroupLayouts = bindGroupLayouts,
+        };
+
+        const WGPUPipelineLayout pipelineLayout =
+            wgpuDeviceCreatePipelineLayout(gpuContext.device, &pipelineLayoutDesc);
+
+        // Vertex layout
+
+        const WGPUVertexAttribute vertexAttributes[] = {WGPUVertexAttribute{
+            .format = WGPUVertexFormat_Float32x2,
+            .offset = 0,
+            .shaderLocation = 0,
+        }};
+
+        const WGPUVertexBufferLayout vertexBufferLayout{
+            .arrayStride = sizeof(float[2]),
+            .stepMode = WGPUVertexStepMode_Vertex,
+            .attributeCount = std::size(vertexAttributes),
+            .attributes = vertexAttributes,
+        };
+
+        // Shader module
+
+        const WGPUShaderModule shaderModule = [&gpuContext]() -> WGPUShaderModule {
+            const WGPUShaderModuleWGSLDescriptor wgslDesc = {
+                .chain =
+                    WGPUChainedStruct{
+                        .next = nullptr,
+                        .sType = WGPUSType_ShaderModuleWGSLDescriptor,
+                    },
+                .code = HYBRID_RENDERER_SKY_PASS_SOURCE,
+            };
+
+            const WGPUShaderModuleDescriptor moduleDesc{
+                .nextInChain = &wgslDesc.chain,
+                .label = "Sky pass shader",
+            };
+
+            return wgpuDeviceCreateShaderModule(gpuContext.device, &moduleDesc);
+        }();
+        NLRS_ASSERT(shaderModule != nullptr);
+
+        // Fragment state
+
+        const WGPUBlendState blendState{
+            .color =
+                WGPUBlendComponent{
+                    .operation = WGPUBlendOperation_Add,
+                    .srcFactor = WGPUBlendFactor_One,
+                    .dstFactor = WGPUBlendFactor_OneMinusSrcAlpha,
+                },
+            .alpha =
+                WGPUBlendComponent{
+                    .operation = WGPUBlendOperation_Add,
+                    .srcFactor = WGPUBlendFactor_Zero,
+                    .dstFactor = WGPUBlendFactor_One,
+                },
+        };
+
+        const WGPUColorTargetState colorTargets[] = {WGPUColorTargetState{
+            .nextInChain = nullptr,
+            .format = Window::SWAP_CHAIN_FORMAT,
+            .blend = &blendState,
+            .writeMask = WGPUColorWriteMask_All}};
+
+        const WGPUFragmentState fragmentState{
+            .nextInChain = nullptr,
+            .module = shaderModule,
+            .entryPoint = "fsMain",
+            .constantCount = 0,
+            .constants = nullptr,
+            .targetCount = std::size(colorTargets),
+            .targets = colorTargets,
+        };
+
+        // Pipeline
+
+        const WGPURenderPipelineDescriptor pipelineDesc{
+            .nextInChain = nullptr,
+            .label = "Sky pass render pipeline",
+            .layout = pipelineLayout,
+            .vertex =
+                WGPUVertexState{
+                    .nextInChain = nullptr,
+                    .module = shaderModule,
+                    .entryPoint = "vsMain",
+                    .constantCount = 0,
+                    .constants = nullptr,
+                    .bufferCount = 1,
+                    .buffers = &vertexBufferLayout,
+                },
+            .primitive =
+                WGPUPrimitiveState{
+                    .nextInChain = nullptr,
+                    .topology = WGPUPrimitiveTopology_TriangleList,
+                    .stripIndexFormat = WGPUIndexFormat_Undefined,
+                    .frontFace = WGPUFrontFace_CCW,
+                    .cullMode = WGPUCullMode_Back,
+                },
+            .depthStencil = nullptr,
+            .multisample =
+                WGPUMultisampleState{
+                    .nextInChain = nullptr,
+                    .count = 1,
+                    .mask = ~0u,
+                    .alphaToCoverageEnabled = false,
+                },
+            .fragment = &fragmentState,
+        };
+
+        mPipeline = wgpuDeviceCreateRenderPipeline(gpuContext.device, &pipelineDesc);
+        wgpuPipelineLayoutRelease(pipelineLayout);
+    }
+}
+
+HybridRenderer::SkyPass::~SkyPass()
+{
+    renderPipelineSafeRelease(mPipeline);
+    mPipeline = nullptr;
+}
+
+void HybridRenderer::SkyPass::render(
+    const GpuContext&        gpuContext,
+    const glm::mat4&         viewProjectionMat,
+    const glm::vec3&         cameraPosition,
+    const Sky&               sky,
+    const WGPUCommandEncoder cmdEncoder,
+    const WGPUTextureView    textureView)
+{
+    if (mCurrentSky != sky)
+    {
+        mCurrentSky = sky;
+        const AlignedSkyState skyState{sky};
+        wgpuQueueWriteBuffer(
+            gpuContext.queue, mSkyStateBuffer.ptr(), 0, &skyState, sizeof(AlignedSkyState));
+    }
+
+    {
+        const Uniforms uniforms{glm::inverse(viewProjectionMat), glm::vec4(cameraPosition, 1.f)};
+        wgpuQueueWriteBuffer(
+            gpuContext.queue, mUniformBuffer.ptr(), 0, &uniforms, sizeof(Uniforms));
+    }
+
+    const WGPURenderPassEncoder renderPass = [cmdEncoder, textureView]() -> WGPURenderPassEncoder {
+        const WGPURenderPassColorAttachment colorAttachment{
+            .nextInChain = nullptr,
+            .view = textureView,
+            .depthSlice = WGPU_DEPTH_SLICE_UNDEFINED,
+            .resolveTarget = nullptr,
+            .loadOp = WGPULoadOp_Clear,
+            .storeOp = WGPUStoreOp_Store,
+            .clearValue = WGPUColor{0.0, 0.0, 0.0, 1.0},
+        };
+
+        const WGPURenderPassDescriptor renderPassDesc{
+            .nextInChain = nullptr,
+            .label = "Sky pass render pass",
+            .colorAttachmentCount = 1,
+            .colorAttachments = &colorAttachment,
+            .depthStencilAttachment = nullptr,
+            .occlusionQuerySet = nullptr,
+            .timestampWrites = nullptr,
+        };
+
+        return wgpuCommandEncoderBeginRenderPass(cmdEncoder, &renderPassDesc);
+    }();
+    NLRS_ASSERT(renderPass != nullptr);
+
+    wgpuRenderPassEncoderSetPipeline(renderPass, mPipeline);
+    wgpuRenderPassEncoderSetBindGroup(renderPass, 0, mSkyStateBindGroup.ptr(), 0, nullptr);
+    wgpuRenderPassEncoderSetBindGroup(renderPass, 1, mUniformBindGroup.ptr(), 0, nullptr);
+    wgpuRenderPassEncoderSetVertexBuffer(
+        renderPass, 0, mVertexBuffer.ptr(), 0, mVertexBuffer.byteSize());
+    wgpuRenderPassEncoderDraw(renderPass, 6, 1, 0, 0);
+    wgpuRenderPassEncoderEnd(renderPass);
+}
 } // namespace nlrs
diff --git a/src/pt/hybrid_renderer.hpp b/src/pt/hybrid_renderer.hpp
index 31fe5ac..92fc2b8 100644
--- a/src/pt/hybrid_renderer.hpp
+++ b/src/pt/hybrid_renderer.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include "aligned_sky_state.hpp"
 #include "gpu_bind_group.hpp"
 #include "gpu_bind_group_layout.hpp"
 #include "gpu_buffer.hpp"
@@ -43,7 +44,12 @@ class HybridRenderer
     HybridRenderer(HybridRenderer&&) = delete;
     HybridRenderer& operator=(HybridRenderer&&) = delete;
 
-    void render(const GpuContext&, WGPUTextureView, const glm::mat4& viewProjectionMatrix);
+    void render(
+        const GpuContext& gpuContext,
+        const glm::mat4&  viewProjectionMatrix,
+        const glm::vec3&  cameraPosition,
+        const Sky&        sky,
+        WGPUTextureView   targetTextureView);
     void resize(const GpuContext&, const Extent2u&);
 
 private:
@@ -124,6 +130,42 @@ class HybridRenderer
         void resize(const GpuContext&, const Extent2u&);
     };
 
+    struct SkyPass
+    {
+    private:
+        Sky                mCurrentSky;
+        GpuBuffer          mVertexBuffer;
+        GpuBuffer          mSkyStateBuffer;
+        GpuBindGroup       mSkyStateBindGroup;
+        GpuBuffer          mUniformBuffer;
+        GpuBindGroup       mUniformBindGroup;
+        WGPURenderPipeline mPipeline;
+
+        struct Uniforms
+        {
+            glm::mat4 inverseViewProjection;
+            glm::vec4 cameraPosition;
+        };
+
+    public:
+        SkyPass(const GpuContext&);
+        ~SkyPass();
+
+        SkyPass(const SkyPass&) = delete;
+        SkyPass& operator=(const SkyPass&) = delete;
+
+        SkyPass(SkyPass&&) = delete;
+        SkyPass& operator=(SkyPass&&) = delete;
+
+        void render(
+            const GpuContext&  gpuContext,
+            const glm::mat4&   viewProjectionMatrix,
+            const glm::vec3&   cameraPosition,
+            const Sky&         sky,
+            WGPUCommandEncoder cmdEncoder,
+            WGPUTextureView    textureView);
+    };
+
     WGPUTexture        mDepthTexture;
     WGPUTextureView    mDepthTextureView;
     WGPUTexture        mAlbedoTexture;
@@ -134,5 +176,6 @@ class HybridRenderer
     GpuBindGroup       mGbufferBindGroup;
     GbufferPass        mGbufferPass;
     DebugPass          mDebugPass;
+    SkyPass            mSkyPass;
 };
 } // namespace nlrs
diff --git a/src/pt/hybrid_renderer_sky_pass.wgsl b/src/pt/hybrid_renderer_sky_pass.wgsl
new file mode 100644
index 0000000..b03c252
--- /dev/null
+++ b/src/pt/hybrid_renderer_sky_pass.wgsl
@@ -0,0 +1,112 @@
+struct VertexInput {
+    @location(0) position: vec2f,
+}
+
+struct VertexOutput {
+    @builtin(position) position: vec4f,
+    @location(0) texCoord: vec2f,
+}
+
+@vertex
+fn vsMain(in: VertexInput) -> VertexOutput {
+    var out: VertexOutput;
+    out.position = vec4f(in.position, 0.0, 1.0);
+    out.texCoord = 0.5 * in.position + vec2f(0.5);
+
+    return out;
+}
+
+struct SkyState {
+    params: array<f32, 27>,
+    skyRadiances: array<f32, 3>,
+    solarRadiances: array<f32, 3>,
+    sunDirection: vec3<f32>,
+};
+
+struct Uniforms {
+    inverseViewProjectionMat: mat4x4f,
+    cameraEye: vec4f
+}
+
+@group(0) @binding(0) var<storage, read> skyState: SkyState;
+@group(1) @binding(0) var<uniform> uniforms: Uniforms;
+
+const CHANNEL_R = 0u;
+const CHANNEL_G = 1u;
+const CHANNEL_B = 2u;
+
+@fragment
+fn fsMain(in: VertexOutput) -> @location(0) vec4f {
+    let uv = in.texCoord;
+    let world = worldFromUv(uv);
+    let v = normalize((world - uniforms.cameraEye).xyz);
+    let s = skyState.sunDirection;
+
+    let theta = acos(v.y);
+    let gamma = acos(clamp(dot(v, s), -1f, 1f));
+    let color = vec3f(
+        skyRadiance(theta, gamma, CHANNEL_R),
+        skyRadiance(theta, gamma, CHANNEL_G),
+        skyRadiance(theta, gamma, CHANNEL_B)
+    );
+
+    let exposure = 1f / pow(2f, 4);
+    return vec4(acesFilmic(exposure * color), 1.0);
+}
+
+fn worldFromUv(uv: vec2f) -> vec4f {
+    let ndc = vec4(2.0 * uv - vec2(1.0), 0.0, 1.0);
+    let worldInvW = uniforms.inverseViewProjectionMat * ndc;
+    let world = worldInvW / worldInvW.w;
+    return world;
+}
+
+const PI = 3.1415927f;
+const DEGREES_TO_RADIANS = PI / 180f;
+const TERRESTRIAL_SOLAR_RADIUS = 0.255f * DEGREES_TO_RADIANS;
+
+@must_use
+fn skyRadiance(theta: f32, gamma: f32, channel: u32) -> f32 {
+    // Sky dome radiance
+    let r = skyState.skyRadiances[channel];
+    let idx = 9u * channel;
+    let p0 = skyState.params[idx + 0u];
+    let p1 = skyState.params[idx + 1u];
+    let p2 = skyState.params[idx + 2u];
+    let p3 = skyState.params[idx + 3u];
+    let p4 = skyState.params[idx + 4u];
+    let p5 = skyState.params[idx + 5u];
+    let p6 = skyState.params[idx + 6u];
+    let p7 = skyState.params[idx + 7u];
+    let p8 = skyState.params[idx + 8u];
+
+    let cosGamma = cos(gamma);
+    let cosGamma2 = cosGamma * cosGamma;
+    let cosTheta = abs(cos(theta));
+
+    let expM = exp(p4 * gamma);
+    let rayM = cosGamma2;
+    let mieMLhs = 1.0 + cosGamma2;
+    let mieMRhs = pow(1.0 + p8 * p8 - 2.0 * p8 * cosGamma, 1.5f);
+    let mieM = mieMLhs / mieMRhs;
+    let zenith = sqrt(cosTheta);
+    let radianceLhs = 1.0 + p0 * exp(p1 / (cosTheta + 0.01));
+    let radianceRhs = p2 + p3 * expM + p5 * rayM + p6 * mieM + p7 * zenith;
+    let radianceDist = radianceLhs * radianceRhs;
+
+    // Solar radiance
+    let solarDiskRadius = gamma / TERRESTRIAL_SOLAR_RADIUS;
+    let solarRadiance = select(0f, skyState.solarRadiances[channel], solarDiskRadius <= 1f);
+
+    return r * radianceDist + solarRadiance;
+}
+
+@must_use
+fn acesFilmic(x: vec3f) -> vec3f {
+    let a = 2.51f;
+    let b = 0.03f;
+    let c = 2.43f;
+    let d = 0.59f;
+    let e = 0.14f;
+    return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
+}
diff --git a/src/pt/main.cpp b/src/pt/main.cpp
index 25ae810..4623f17 100644
--- a/src/pt/main.cpp
+++ b/src/pt/main.cpp
@@ -423,10 +423,23 @@ try
             renderer.render(gpuContext, textureBlitter.textureView());
             break;
         case RendererType_Hybrid:
+        {
             const glm::mat4 viewProjectionMat = appState.cameraController.viewProjectionMatrix();
-            hybridRenderer.render(gpuContext, textureBlitter.textureView(), viewProjectionMat);
+            const nlrs::Sky sky{
+                appState.ui.skyTurbidity,
+                appState.ui.skyAlbedo,
+                appState.ui.sunZenithDegrees,
+                appState.ui.sunAzimuthDegrees,
+            };
+            hybridRenderer.render(
+                gpuContext,
+                viewProjectionMat,
+                appState.cameraController.position(),
+                sky,
+                textureBlitter.textureView());
             break;
         }
+        }
         textureBlitter.render(gpuContext, gui, swapChain);
     };
 
diff --git a/src/pt/reference_path_tracer.cpp b/src/pt/reference_path_tracer.cpp
index 29123ce..271c0c5 100644
--- a/src/pt/reference_path_tracer.cpp
+++ b/src/pt/reference_path_tracer.cpp
@@ -7,7 +7,6 @@
 
 #include <common/bvh.hpp>
 #include <common/gltf_model.hpp>
-#include <hw-skymodel/hw_skymodel.h>
 
 #include <fmt/core.h>
 #include <glm/glm.hpp>
@@ -27,9 +26,6 @@
 
 namespace nlrs
 {
-inline constexpr float PI = std::numbers::pi_v<float>;
-inline constexpr float DEGREES_TO_RADIANS = PI / 180.0f;
-
 namespace
 {
 struct FrameDataLayout
@@ -96,47 +92,6 @@ struct SamplingStateLayout
     }
 };
 
-struct SkyStateLayout
-{
-    float     params[27];        // offset: 0
-    float     skyRadiances[3];   // offset: 27
-    float     solarRadiances[3]; // offset: 30
-    float     padding1[3];       // offset: 33
-    glm::vec3 sunDirection;      // offset: 36
-    float     padding2;          // offset: 39
-
-    SkyStateLayout(const Sky& sky)
-        : params{0},
-          skyRadiances{0},
-          solarRadiances{0},
-          padding1{0.f, 0.f, 0.f},
-          sunDirection(0.f),
-          padding2(0.0f)
-    {
-        const float sunZenith = sky.sunZenithDegrees * DEGREES_TO_RADIANS;
-        const float sunAzimuth = sky.sunAzimuthDegrees * DEGREES_TO_RADIANS;
-
-        sunDirection = glm::normalize(glm::vec3(
-            std::sin(sunZenith) * std::cos(sunAzimuth),
-            std::cos(sunZenith),
-            -std::sin(sunZenith) * std::sin(sunAzimuth)));
-
-        const sky_params skyParams{
-            .elevation = 0.5f * PI - sunZenith,
-            .turbidity = sky.turbidity,
-            .albedo = {sky.albedo[0], sky.albedo[1], sky.albedo[2]}};
-
-        sky_state                   skyState;
-        [[maybe_unused]] const auto r = sky_state_new(&skyParams, &skyState);
-        // TODO: exceptional error handling
-        assert(r == sky_state_result_success);
-
-        std::memcpy(params, skyState.params, sizeof(skyState.params));
-        std::memcpy(skyRadiances, skyState.sky_radiances, sizeof(skyState.sky_radiances));
-        std::memcpy(solarRadiances, skyState.solar_radiances, sizeof(skyState.solar_radiances));
-    }
-};
-
 struct RenderParamsLayout
 {
     FrameDataLayout     frameData;
@@ -187,7 +142,7 @@ ReferencePathTracer::ReferencePathTracer(
           gpuContext.device,
           "sky state buffer",
           GpuBufferUsage::ReadOnlyStorage | GpuBufferUsage::CopyDst,
-          sizeof(SkyStateLayout)),
+          sizeof(AlignedSkyState)),
       mRenderParamsBindGroup(),
       mBvhNodeBuffer(
           gpuContext.device,
@@ -624,9 +579,9 @@ void ReferencePathTracer::render(const GpuContext& gpuContext, WGPUTextureView t
             0,
             &mCurrentPostProcessingParams,
             sizeof(PostProcessingParameters));
-        const SkyStateLayout skyStateLayout{mCurrentRenderParams.sky};
+        const AlignedSkyState skyState{mCurrentRenderParams.sky};
         wgpuQueueWriteBuffer(
-            gpuContext.queue, mSkyStateBuffer.ptr(), 0, &skyStateLayout, sizeof(SkyStateLayout));
+            gpuContext.queue, mSkyStateBuffer.ptr(), 0, &skyState, sizeof(AlignedSkyState));
     }
 
     const WGPUCommandEncoder encoder = [&gpuContext]() {
diff --git a/src/pt/reference_path_tracer.hpp b/src/pt/reference_path_tracer.hpp
index a666e34..2bc1c75 100644
--- a/src/pt/reference_path_tracer.hpp
+++ b/src/pt/reference_path_tracer.hpp
@@ -1,5 +1,6 @@
 #pragma once
 
+#include "aligned_sky_state.hpp"
 #include "gpu_bind_group.hpp"
 #include "gpu_buffer.hpp"
 
@@ -29,16 +30,6 @@ struct SamplingParams
     bool operator==(const SamplingParams&) const noexcept = default;
 };
 
-struct Sky
-{
-    float                turbidity = 1.0f;
-    std::array<float, 3> albedo = {1.0f, 1.0f, 1.0f};
-    float                sunZenithDegrees = 30.0f;
-    float                sunAzimuthDegrees = 0.0f;
-
-    bool operator==(const Sky&) const noexcept = default;
-};
-
 struct RenderParameters
 {
     Extent2u       framebufferSize;
diff --git a/src/pt/shader_source.hpp b/src/pt/shader_source.hpp
index f077574..1ef269c 100644
--- a/src/pt/shader_source.hpp
+++ b/src/pt/shader_source.hpp
@@ -775,4 +775,118 @@ fn fsMain(in: VertexOutput) -> @location(0) vec4f {
 }
 )";
 
+const char* const HYBRID_RENDERER_SKY_PASS_SOURCE = R"(struct VertexInput {
+    @location(0) position: vec2f,
+}
+
+struct VertexOutput {
+    @builtin(position) position: vec4f,
+    @location(0) texCoord: vec2f,
+}
+
+@vertex
+fn vsMain(in: VertexInput) -> VertexOutput {
+    var out: VertexOutput;
+    out.position = vec4f(in.position, 0.0, 1.0);
+    out.texCoord = 0.5 * in.position + vec2f(0.5);
+
+    return out;
+}
+
+struct SkyState {
+    params: array<f32, 27>,
+    skyRadiances: array<f32, 3>,
+    solarRadiances: array<f32, 3>,
+    sunDirection: vec3<f32>,
+};
+
+struct Uniforms {
+    inverseViewProjectionMat: mat4x4f,
+    cameraEye: vec4f
+}
+
+@group(0) @binding(0) var<storage, read> skyState: SkyState;
+@group(1) @binding(0) var<uniform> uniforms: Uniforms;
+
+const CHANNEL_R = 0u;
+const CHANNEL_G = 1u;
+const CHANNEL_B = 2u;
+
+@fragment
+fn fsMain(in: VertexOutput) -> @location(0) vec4f {
+    let uv = in.texCoord;
+    let world = worldFromUv(uv);
+    let v = normalize((world - uniforms.cameraEye).xyz);
+    let s = skyState.sunDirection;
+
+    let theta = acos(v.y);
+    let gamma = acos(clamp(dot(v, s), -1f, 1f));
+    let color = vec3f(
+        skyRadiance(theta, gamma, CHANNEL_R),
+        skyRadiance(theta, gamma, CHANNEL_G),
+        skyRadiance(theta, gamma, CHANNEL_B)
+    );
+
+    let exposure = 1f / pow(2f, 4);
+    return vec4(acesFilmic(exposure * color), 1.0);
+}
+
+fn worldFromUv(uv: vec2f) -> vec4f {
+    let ndc = vec4(2.0 * uv - vec2(1.0), 0.0, 1.0);
+    let worldInvW = uniforms.inverseViewProjectionMat * ndc;
+    let world = worldInvW / worldInvW.w;
+    return world;
+}
+
+const PI = 3.1415927f;
+const DEGREES_TO_RADIANS = PI / 180f;
+const TERRESTRIAL_SOLAR_RADIUS = 0.255f * DEGREES_TO_RADIANS;
+
+@must_use
+fn skyRadiance(theta: f32, gamma: f32, channel: u32) -> f32 {
+    // Sky dome radiance
+    let r = skyState.skyRadiances[channel];
+    let idx = 9u * channel;
+    let p0 = skyState.params[idx + 0u];
+    let p1 = skyState.params[idx + 1u];
+    let p2 = skyState.params[idx + 2u];
+    let p3 = skyState.params[idx + 3u];
+    let p4 = skyState.params[idx + 4u];
+    let p5 = skyState.params[idx + 5u];
+    let p6 = skyState.params[idx + 6u];
+    let p7 = skyState.params[idx + 7u];
+    let p8 = skyState.params[idx + 8u];
+
+    let cosGamma = cos(gamma);
+    let cosGamma2 = cosGamma * cosGamma;
+    let cosTheta = abs(cos(theta));
+
+    let expM = exp(p4 * gamma);
+    let rayM = cosGamma2;
+    let mieMLhs = 1.0 + cosGamma2;
+    let mieMRhs = pow(1.0 + p8 * p8 - 2.0 * p8 * cosGamma, 1.5f);
+    let mieM = mieMLhs / mieMRhs;
+    let zenith = sqrt(cosTheta);
+    let radianceLhs = 1.0 + p0 * exp(p1 / (cosTheta + 0.01));
+    let radianceRhs = p2 + p3 * expM + p5 * rayM + p6 * mieM + p7 * zenith;
+    let radianceDist = radianceLhs * radianceRhs;
+
+    // Solar radiance
+    let solarDiskRadius = gamma / TERRESTRIAL_SOLAR_RADIUS;
+    let solarRadiance = select(0f, skyState.solarRadiances[channel], solarDiskRadius <= 1f);
+
+    return r * radianceDist + solarRadiance;
+}
+
+@must_use
+fn acesFilmic(x: vec3f) -> vec3f {
+    let a = 2.51f;
+    let b = 0.03f;
+    let c = 2.43f;
+    let d = 0.59f;
+    let e = 0.14f;
+    return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
+}
+)";
+
 } // namespace nlrs