diff --git a/assets/voxygen/shaders/dual-downsample-filtered-frag.glsl b/assets/voxygen/shaders/dual-downsample-filtered-frag.glsl
new file mode 100644
index 0000000000..9ce06098ae
--- /dev/null
+++ b/assets/voxygen/shaders/dual-downsample-filtered-frag.glsl
@@ -0,0 +1,90 @@
+#version 420 core
+
+layout(set = 0, binding = 0)
+uniform texture2D t_src_color;
+layout(set = 0, binding = 1)
+uniform sampler s_src_color;
+layout(set = 0, binding = 2)
+
+uniform u_locals {
+    vec2 halfpixel;
+};
+
+layout(location = 0) in vec2 uv;
+
+layout(location = 0) out vec4 tgt_color;
+
+vec4 simplefetch(ivec2 uv) {
+    return texelFetch(sampler2D(t_src_color, s_src_color), uv, 0);
+}
+
+// Check whether the texel color is higher than threshold, if so output as brightness color
+vec4 filterDim(vec4 color) {
+    // constants from: https://learnopengl.com/Advanced-Lighting/Bloom
+    float brightness = dot(color.rgb, vec3(0.2126, 0.7152, 0.0722));
+    if(brightness > 1.00)
+        return vec4(color.rgb, 1.0);
+    else
+        return vec4(0.0, 0.0, 0.0, 1.0);
+}
+
+vec4 filteredFetch(ivec2 uv) {
+    return filterDim(simplefetch(uv));
+}
+
+// Derived from: https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/siggraph2015_2D00_mmg_2D00_marius_2D00_notes.pdf
+vec4 filteredDownsample(vec2 uv, vec2 halfpixel) {
+    vec2 tex_res = 0.5 / halfpixel;
+    // coordinate of the top left texel
+    //  _ _ _ _
+    // |x|_|_|_|
+    // |_|_|_|_|
+    // |_|_|_|_|
+    // |_|_|_|_|
+    //
+    ivec2 tl_coord = ivec2(uv * tex_res + vec2(-1.5, 1.5));
+    
+    // Fetch inner square
+    vec4 sum = filteredFetch(tl_coord + ivec2(1, 1));
+    sum += filteredFetch(tl_coord + ivec2(2, 1));
+    sum += filteredFetch(tl_coord + ivec2(1, 2));
+    sum += filteredFetch(tl_coord + ivec2(2, 2));
+    // Weight inner square
+    sum *= 5.0;
+    // Fetch border
+    sum += filteredFetch(tl_coord + ivec2(0, 0));
+    sum += filteredFetch(tl_coord + ivec2(1, 0));
+    sum += filteredFetch(tl_coord + ivec2(2, 0));
+    sum += filteredFetch(tl_coord + ivec2(3, 0));
+    sum += filteredFetch(tl_coord + ivec2(0, 1));
+    sum += filteredFetch(tl_coord + ivec2(3, 1));
+    sum += filteredFetch(tl_coord + ivec2(0, 2));
+    sum += filteredFetch(tl_coord + ivec2(3, 2));
+    sum += filteredFetch(tl_coord + ivec2(0, 3));
+    sum += filteredFetch(tl_coord + ivec2(1, 3));
+    sum += filteredFetch(tl_coord + ivec2(2, 3));
+    sum += filteredFetch(tl_coord + ivec2(3, 3));
+    
+    return sum / 32.0;
+}
+
+vec4 simplesample(vec2 uv) {
+    return textureLod(sampler2D(t_src_color, s_src_color), uv, 0);
+}
+
+// From: https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/siggraph2015_2D00_mmg_2D00_marius_2D00_notes.pdf
+vec4 downsample(vec2 uv, vec2 halfpixel) {
+    vec4 sum = simplesample(uv) * 4.0;
+    sum += simplesample(uv - halfpixel.xy);
+    sum += simplesample(uv + halfpixel.xy);
+    sum += simplesample(uv + vec2(halfpixel.x, -halfpixel.y));
+    sum += simplesample(uv - vec2(halfpixel.x, -halfpixel.y));
+
+    return sum / 8.0;
+}
+
+void main() {
+    // Uncomment to experiment with filtering out dim pixels
+    //tgt_color = filteredDownsample(uv, halfpixel);
+    tgt_color = downsample(uv, halfpixel);
+}
diff --git a/assets/voxygen/shaders/dual-downsample-frag.glsl b/assets/voxygen/shaders/dual-downsample-frag.glsl
new file mode 100644
index 0000000000..af9fa46c09
--- /dev/null
+++ b/assets/voxygen/shaders/dual-downsample-frag.glsl
@@ -0,0 +1,34 @@
+#version 420 core
+
+layout(set = 0, binding = 0)
+uniform texture2D t_src_color;
+layout(set = 0, binding = 1)
+uniform sampler s_src_color;
+layout(set = 0, binding = 2)
+
+uniform u_locals {
+    vec2 halfpixel;
+};
+
+layout(location = 0) in vec2 uv;
+
+layout(location = 0) out vec4 tgt_color;
+
+vec4 simplesample(vec2 uv) {
+    return textureLod(sampler2D(t_src_color, s_src_color), uv, 0);
+}
+
+// From: https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/siggraph2015_2D00_mmg_2D00_marius_2D00_notes.pdf
+vec4 downsample(vec2 uv, vec2 halfpixel) {
+    vec4 sum = simplesample(uv) * 4.0;
+    sum += simplesample(uv - halfpixel.xy);
+    sum += simplesample(uv + halfpixel.xy);
+    sum += simplesample(uv + vec2(halfpixel.x, -halfpixel.y));
+    sum += simplesample(uv - vec2(halfpixel.x, -halfpixel.y));
+
+    return sum / 8.0;
+}
+
+void main() {
+    tgt_color = downsample(uv, halfpixel);
+}
diff --git a/assets/voxygen/shaders/dual-upsample-frag.glsl b/assets/voxygen/shaders/dual-upsample-frag.glsl
new file mode 100644
index 0000000000..10d1b1fdc6
--- /dev/null
+++ b/assets/voxygen/shaders/dual-upsample-frag.glsl
@@ -0,0 +1,35 @@
+#version 420 core
+
+layout(set = 0, binding = 0)
+uniform texture2D t_src_color;
+layout(set = 0, binding = 1)
+uniform sampler s_src_color;
+layout(set = 0, binding = 2)
+uniform u_locals {
+    vec2 halfpixel;
+};
+
+layout(location = 0) in vec2 uv;
+
+layout(location = 0) out vec4 tgt_color;
+
+vec4 simplesample(vec2 uv) {
+    return textureLod(sampler2D(t_src_color, s_src_color), uv, 0);
+}
+
+// From: https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/siggraph2015_2D00_mmg_2D00_marius_2D00_notes.pdf
+vec4 upsample(vec2 uv, vec2 halfpixel) {
+    vec4 sum = simplesample(uv + vec2(-halfpixel.x * 2.0, 0.0));
+    sum += simplesample(uv + vec2(-halfpixel.x, halfpixel.y)) * 2.0;
+    sum += simplesample(uv + vec2(0.0, halfpixel.y * 2.0));
+    sum += simplesample(uv + vec2(halfpixel.x, halfpixel.y)) * 2.0;
+    sum += simplesample(uv + vec2(halfpixel.x * 2.0, 0.0));
+    sum += simplesample(uv + vec2(halfpixel.x, -halfpixel.y)) * 2.0;
+    sum += simplesample(uv + vec2(0.0, -halfpixel.y * 2.0));
+    sum += simplesample(uv + vec2(-halfpixel.x, -halfpixel.y)) * 2.0;
+    return sum / 12.0;
+}
+
+void main() {
+    tgt_color = upsample(uv, halfpixel);
+}
diff --git a/assets/voxygen/shaders/figure-frag.glsl b/assets/voxygen/shaders/figure-frag.glsl
index 3b084366da..0c1948023c 100644
--- a/assets/voxygen/shaders/figure-frag.glsl
+++ b/assets/voxygen/shaders/figure-frag.glsl
@@ -198,7 +198,7 @@ void main() {
     // For now, just make glowing material light be the same colour as the surface
     // TODO: Add a way to control this better outside the shaders
     if ((material & (1u << 0u)) > 0u) {
-        emitted_light += 1000 * surf_color;
+        emitted_light += 20 * surf_color;
     }
 
     float glow_mag = length(model_glow.xyz);
diff --git a/assets/voxygen/shaders/fluid-frag/shiny.glsl b/assets/voxygen/shaders/fluid-frag/shiny.glsl
index 019e2b0618..67aae23766 100644
--- a/assets/voxygen/shaders/fluid-frag/shiny.glsl
+++ b/assets/voxygen/shaders/fluid-frag/shiny.glsl
@@ -145,7 +145,7 @@ void main() {
     nmap = mix(f_norm, normalize(nmap), min(1.0 / pow(frag_dist, 0.75), 1));
 
     //float suppress_waves = max(dot(), 0);
-    vec3 norm = vec3(0, 0, 1) * nmap.z + b_norm * nmap.x + c_norm * nmap.y;
+    vec3 norm = normalize(vec3(0, 0, 1) * nmap.z + b_norm * nmap.x + c_norm * nmap.y);
     // vec3 norm = f_norm;
 
     vec3 water_color = (1.0 - MU_WATER) * MU_SCATTER;
diff --git a/assets/voxygen/shaders/particle-vert.glsl b/assets/voxygen/shaders/particle-vert.glsl
index 8f2a494079..1e29ffb9e4 100644
--- a/assets/voxygen/shaders/particle-vert.glsl
+++ b/assets/voxygen/shaders/particle-vert.glsl
@@ -355,7 +355,7 @@ void main() {
                     sin(lifetime * 2.0 + rand2) + sin(lifetime * 9.0 + rand5) * 0.3
                 ),
                 vec3(raise),
-                vec4(vec3(5, 5, 1.1), 1),
+                vec4(vec3(10.3, 9, 1.5), 1),
                 spin_in_axis(vec3(rand6, rand7, rand8), rand9 * 3 + lifetime * 5)
             );
             break;
@@ -386,7 +386,7 @@ void main() {
             attr = Attr(
                 spiral_motion(vec3(0, 0, rand3 + 1), spiral_radius, lifetime, abs(rand0), rand1 * 2 * PI) + vec3(0, 0, rand2),
                 vec3(6 * abs(rand4) * (1 - slow_start(2)) * pow(spiral_radius / length(inst_dir), 0.5)),
-                vec4(vec3(0, 1.7, 0.7), 1),
+                vec4(vec3(0, 1.7, 0.7) * 3, 1),
                 spin_in_axis(vec3(rand6, rand7, rand8), rand9 * 3)
             );
             break;
@@ -397,7 +397,7 @@ void main() {
             attr = Attr(
                 spiral_motion(inst_dir, 0.3 * (floor(2 * rand0 + 0.5) - 0.5) * min(linear_scale(10), 1), lifetime / inst_lifespan, 10.0, inst_time),
                 vec3((1.7 - 0.7 * abs(floor(2 * rand0 - 0.5) + 0.5)) * (1.5 + 0.5 * sin(tick.x * 10 - lifetime * 4))),
-                vec4(vec3(purple_col, green_col, 0.75 * purple_col), 1),
+                vec4(vec3(purple_col, green_col, 0.75 * purple_col) * 3, 1),
                 spin_in_axis(inst_dir, tick.z)
             );
             break;
diff --git a/assets/voxygen/shaders/postprocess-frag.glsl b/assets/voxygen/shaders/postprocess-frag.glsl
index aeba45f350..17b90c0895 100644
--- a/assets/voxygen/shaders/postprocess-frag.glsl
+++ b/assets/voxygen/shaders/postprocess-frag.glsl
@@ -27,7 +27,6 @@ uniform texture2D t_src_color;
 layout(set = 1, binding = 1)
 uniform sampler s_src_color;
 
-
 layout(location = 0) in vec2 uv;
 
 layout (std140, set = 1, binding = 2)
@@ -36,6 +35,11 @@ uniform u_locals {
     mat4 view_mat_inv;
 };
 
+#ifdef BLOOM_FACTOR
+layout(set = 1, binding = 3)
+uniform texture2D t_src_bloom;
+#endif
+
 layout(location = 0) out vec4 tgt_color;
 
 vec3 rgb2hsv(vec3 c) {
@@ -182,6 +186,16 @@ void main() {
 
     vec4 aa_color = aa_apply(t_src_color, s_src_color, uv * screen_res.xy, screen_res.xy);
 
+    // Bloom
+    #ifdef BLOOM_FACTOR
+        vec4 bloom = textureLod(sampler2D(t_src_bloom, s_src_color), uv, 0);
+        #if (BLOOM_UNIFORM_BLUR == false)
+            // divide by 4.0 to account for adding blurred layers together
+            bloom /= 4.0;
+        #endif
+        aa_color = mix(aa_color, bloom, BLOOM_FACTOR);
+    #endif
+
     // Tonemapping
     float exposure_offset = 1.0;
     // Adding an in-code offset to gamma and exposure let us have more precise control over the game's look
diff --git a/voxygen/src/lib.rs b/voxygen/src/lib.rs
index cf4d673534..6404738e50 100644
--- a/voxygen/src/lib.rs
+++ b/voxygen/src/lib.rs
@@ -4,6 +4,8 @@
 #![deny(clippy::clone_on_ref_ptr)]
 #![feature(
     array_map,
+    array_methods,
+    array_zip,
     bool_to_option,
     const_generics,
     drain_filter,
diff --git a/voxygen/src/menu/char_selection/mod.rs b/voxygen/src/menu/char_selection/mod.rs
index 8941cfc8bf..a5e96f3c20 100644
--- a/voxygen/src/menu/char_selection/mod.rs
+++ b/voxygen/src/menu/char_selection/mod.rs
@@ -257,6 +257,8 @@ impl PlayState for CharSelectionState {
         if let Some(mut second_pass) = drawer.second_pass() {
             second_pass.draw_clouds();
         }
+        // Bloom (does nothing if bloom is disabled)
+        drawer.run_bloom_passes();
         // PostProcess and UI
         let mut third_pass = drawer.third_pass();
         third_pass.draw_postprocess();
diff --git a/voxygen/src/render/mod.rs b/voxygen/src/render/mod.rs
index d7598d5508..26006ad843 100644
--- a/voxygen/src/render/mod.rs
+++ b/voxygen/src/render/mod.rs
@@ -265,6 +265,66 @@ impl From<PresentMode> for wgpu::PresentMode {
     }
 }
 
+/// Bloom factor
+/// Controls fraction of output image luminosity that is blurred bloom
+#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
+pub enum BloomFactor {
+    Low,
+    High,
+    /// Max valid value is 1.0
+    Custom(f32),
+    // other variant has to be placed last
+    #[serde(other)]
+    Standard,
+}
+
+impl Default for BloomFactor {
+    fn default() -> Self { Self::Standard }
+}
+
+impl BloomFactor {
+    /// Fraction of output image luminosity that is blurred bloom
+    pub fn fraction(self) -> f32 {
+        match self {
+            Self::Low => 0.05,
+            Self::Standard => 0.10,
+            Self::High => 0.25,
+            Self::Custom(val) => val.max(0.0).min(1.0),
+        }
+    }
+}
+
+/// Bloom settings
+#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
+pub struct BloomConfig {
+    /// Controls fraction of output image luminosity that is blurred bloom
+    ///
+    /// Defaults to `Standard`
+    factor: BloomFactor,
+    /// Turning this on make the bloom blur less sharply concentrated around the
+    /// high intensity phenomena (removes adding in less blurred layers to the
+    /// final blur)
+    ///
+    /// Defaults to `false`
+    uniform_blur: bool,
+    // TODO: allow configuring the blur radius and/or the number of passes
+}
+
+#[derive(PartialEq, Clone, Copy, Debug, Serialize, Deserialize)]
+pub enum BloomMode {
+    On(BloomConfig),
+    #[serde(other)]
+    Off,
+}
+
+impl Default for BloomMode {
+    fn default() -> Self { Self::Off }
+}
+
+impl BloomMode {
+    fn is_on(&self) -> bool { matches!(self, BloomMode::On(_)) }
+}
+
 /// Render modes
 #[derive(PartialEq, Clone, Debug, Default, Serialize, Deserialize)]
 #[serde(default)]
@@ -274,7 +334,49 @@ pub struct RenderMode {
     pub fluid: FluidMode,
     pub lighting: LightingMode,
     pub shadow: ShadowMode,
+    pub bloom: BloomMode,
+
     pub upscale_mode: UpscaleMode,
     pub present_mode: PresentMode,
     pub profiler_enabled: bool,
 }
+
+impl RenderMode {
+    fn split(self) -> (PipelineModes, OtherModes) {
+        (
+            PipelineModes {
+                aa: self.aa,
+                cloud: self.cloud,
+                fluid: self.fluid,
+                lighting: self.lighting,
+                shadow: self.shadow,
+                bloom: self.bloom,
+            },
+            OtherModes {
+                upscale_mode: self.upscale_mode,
+                present_mode: self.present_mode,
+                profiler_enabled: self.profiler_enabled,
+            },
+        )
+    }
+}
+
+/// Render modes that require pipeline recreation (e.g. shader recompilation)
+/// when changed
+#[derive(PartialEq, Clone, Debug)]
+pub struct PipelineModes {
+    aa: AaMode,
+    cloud: CloudMode,
+    fluid: FluidMode,
+    lighting: LightingMode,
+    pub shadow: ShadowMode,
+    bloom: BloomMode,
+}
+
+/// Other render modes that don't effect pipelines
+#[derive(PartialEq, Clone, Debug)]
+struct OtherModes {
+    upscale_mode: UpscaleMode,
+    present_mode: PresentMode,
+    profiler_enabled: bool,
+}
diff --git a/voxygen/src/render/pipelines/bloom.rs b/voxygen/src/render/pipelines/bloom.rs
new file mode 100644
index 0000000000..123f25680c
--- /dev/null
+++ b/voxygen/src/render/pipelines/bloom.rs
@@ -0,0 +1,228 @@
+//! Based on: https://community.arm.com/cfs-file/__key/communityserver-blogs-components-weblogfiles/00-00-00-20-66/siggraph2015_2D00_mmg_2D00_marius_2D00_notes.pdf
+//!
+//! See additional details in the [NUM_SIZES] docs
+
+use super::super::{BloomConfig, Consts};
+use bytemuck::{Pod, Zeroable};
+use vek::*;
+
+// TODO: auto-tune the number of passes to maintain roughly constant blur per
+// unit of FOV so changing resolution / FOV doesn't change the blur appearance
+// significantly.
+//
+/// Blurring is performed while downsampling to the smaller sizes in steps and
+/// then upsampling back up to the original resolution. Each level is half the
+/// size in both dimensions from the previous. For instance with 5 distinct
+/// sizes there is a total of 8 passes going from the largest to the smallest to
+/// the largest again:
+///
+/// 1 -> 1/2 -> 1/4 -> 1/8 -> 1/16 -> 1/8 -> 1/4 -> 1/2 -> 1
+///                           ~~~~
+///     [downsampling]      smallest      [upsampling]
+///
+/// The textures used for downsampling are re-used when upsampling.
+///
+/// Additionally, instead of clearing them the colors are added together in an
+/// attempt to obtain a more concentrated bloom near bright areas rather than
+/// a uniform blur. In the example above, the added layers would include 1/8,
+/// 1/4, and 1/2. The smallest size is not upsampled to and the original full
+/// resolution has no blurring and we are already combining the bloom into the
+/// full resolution image in a later step, so they are not included here. The 3
+/// extra layers added in mean the total luminosity of the final blurred bloom
+/// image will be 4 times more than the input image. To account for this, we
+/// divide the bloom intensity by 4 before applying it.
+///
+/// Nevertheless, we have not fully evaluated how this visually compares to the
+/// bloom obtained without adding with the previous layers so there is the
+/// potential for further artistic investigation here.
+///
+/// NOTE: This constant includes the full resolution size and it is
+/// assumed that there will be at least one smaller image to downsample to and
+/// upsample back from (otherwise no blurring would be done). Thus, the minimum
+/// valid value is 2 and panicking indexing operations we perform assume this
+/// will be at least 2.
+pub const NUM_SIZES: usize = 5;
+
+pub struct BindGroup {
+    pub(in super::super) bind_group: wgpu::BindGroup,
+}
+
+#[repr(C)]
+#[derive(Copy, Clone, Debug, Zeroable, Pod)]
+pub struct Locals {
+    halfpixel: [f32; 2],
+}
+
+impl Locals {
+    pub fn new(source_texture_resolution: Vec2<f32>) -> Self {
+        Self {
+            halfpixel: source_texture_resolution.map(|e| 0.5 / e).into_array(),
+        }
+    }
+}
+
+pub struct BloomLayout {
+    pub layout: wgpu::BindGroupLayout,
+}
+
+impl BloomLayout {
+    pub fn new(device: &wgpu::Device) -> Self {
+        Self {
+            layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+                label: None,
+                entries: &[
+                    // Color source
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 0,
+                        visibility: wgpu::ShaderStage::FRAGMENT,
+                        ty: wgpu::BindingType::Texture {
+                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                            view_dimension: wgpu::TextureViewDimension::D2,
+                            multisampled: false,
+                        },
+                        count: None,
+                    },
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 1,
+                        visibility: wgpu::ShaderStage::FRAGMENT,
+                        ty: wgpu::BindingType::Sampler {
+                            filtering: true,
+                            comparison: false,
+                        },
+                        count: None,
+                    },
+                    // halfpixel
+                    wgpu::BindGroupLayoutEntry {
+                        binding: 2,
+                        visibility: wgpu::ShaderStage::FRAGMENT,
+                        ty: wgpu::BindingType::Buffer {
+                            ty: wgpu::BufferBindingType::Uniform,
+                            has_dynamic_offset: false,
+                            min_binding_size: None,
+                        },
+                        count: None,
+                    },
+                ],
+            }),
+        }
+    }
+
+    pub fn bind(
+        &self,
+        device: &wgpu::Device,
+        src_color: &wgpu::TextureView,
+        sampler: &wgpu::Sampler,
+        half_pixel: Consts<Locals>,
+    ) -> BindGroup {
+        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
+            label: None,
+            layout: &self.layout,
+            entries: &[
+                wgpu::BindGroupEntry {
+                    binding: 0,
+                    resource: wgpu::BindingResource::TextureView(src_color),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 1,
+                    resource: wgpu::BindingResource::Sampler(sampler),
+                },
+                wgpu::BindGroupEntry {
+                    binding: 2,
+                    resource: half_pixel.buf().as_entire_binding(),
+                },
+            ],
+        });
+
+        BindGroup { bind_group }
+    }
+}
+
+pub struct BloomPipelines {
+    pub downsample_filtered: wgpu::RenderPipeline,
+    pub downsample: wgpu::RenderPipeline,
+    pub upsample: wgpu::RenderPipeline,
+}
+
+impl BloomPipelines {
+    pub fn new(
+        device: &wgpu::Device,
+        vs_module: &wgpu::ShaderModule,
+        downsample_filtered_fs_module: &wgpu::ShaderModule,
+        downsample_fs_module: &wgpu::ShaderModule,
+        upsample_fs_module: &wgpu::ShaderModule,
+        target_format: wgpu::TextureFormat,
+        layout: &BloomLayout,
+        bloom_config: &BloomConfig,
+    ) -> Self {
+        common_base::span!(_guard, "BloomPipelines::new");
+        let render_pipeline_layout =
+            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+                label: Some("Bloom pipelines layout"),
+                push_constant_ranges: &[],
+                bind_group_layouts: &[&layout.layout],
+            });
+
+        let create_pipeline = |label, fs_module, blend| {
+            device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+                label: Some(label),
+                layout: Some(&render_pipeline_layout),
+                vertex: wgpu::VertexState {
+                    module: vs_module,
+                    entry_point: "main",
+                    buffers: &[],
+                },
+                primitive: wgpu::PrimitiveState {
+                    topology: wgpu::PrimitiveTopology::TriangleList,
+                    strip_index_format: None,
+                    front_face: wgpu::FrontFace::Ccw,
+                    cull_mode: None,
+                    clamp_depth: false,
+                    polygon_mode: wgpu::PolygonMode::Fill,
+                    conservative: false,
+                },
+                depth_stencil: None,
+                multisample: wgpu::MultisampleState {
+                    count: 1,
+                    mask: !0,
+                    alpha_to_coverage_enabled: false,
+                },
+                fragment: Some(wgpu::FragmentState {
+                    module: fs_module,
+                    entry_point: "main",
+                    targets: &[wgpu::ColorTargetState {
+                        format: target_format,
+                        blend,
+                        write_mask: wgpu::ColorWrite::ALL,
+                    }],
+                }),
+            })
+        };
+
+        let downsample_filtered_pipeline = create_pipeline(
+            "Bloom downsample filtered pipeline",
+            downsample_filtered_fs_module,
+            None,
+        );
+        let downsample_pipeline =
+            create_pipeline("Bloom downsample pipeline", downsample_fs_module, None);
+        let upsample_pipeline = create_pipeline(
+            "Bloom upsample pipeline",
+            upsample_fs_module,
+            (!bloom_config.uniform_blur).then(|| wgpu::BlendState {
+                color: wgpu::BlendComponent {
+                    src_factor: wgpu::BlendFactor::One,
+                    dst_factor: wgpu::BlendFactor::One,
+                    operation: wgpu::BlendOperation::Add,
+                },
+                // We don't reaaly use this but we need something here..
+                alpha: wgpu::BlendComponent::REPLACE,
+            }),
+        );
+
+        Self {
+            downsample_filtered: downsample_filtered_pipeline,
+            downsample: downsample_pipeline,
+            upsample: upsample_pipeline,
+        }
+    }
+}
diff --git a/voxygen/src/render/pipelines/mod.rs b/voxygen/src/render/pipelines/mod.rs
index 592d1da64d..d4a0ee3c46 100644
--- a/voxygen/src/render/pipelines/mod.rs
+++ b/voxygen/src/render/pipelines/mod.rs
@@ -1,4 +1,5 @@
 pub mod blit;
+pub mod bloom;
 pub mod clouds;
 pub mod debug;
 pub mod figure;
diff --git a/voxygen/src/render/pipelines/postprocess.rs b/voxygen/src/render/pipelines/postprocess.rs
index 18c982f50c..3d67c2d4d4 100644
--- a/voxygen/src/render/pipelines/postprocess.rs
+++ b/voxygen/src/render/pipelines/postprocess.rs
@@ -1,4 +1,4 @@
-use super::super::{Consts, GlobalsLayouts};
+use super::super::{Consts, GlobalsLayouts, PipelineModes};
 use bytemuck::{Pod, Zeroable};
 use vek::*;
 
@@ -31,43 +31,61 @@ pub struct PostProcessLayout {
 }
 
 impl PostProcessLayout {
-    pub fn new(device: &wgpu::Device) -> Self {
+    pub fn new(device: &wgpu::Device, pipeline_modes: &PipelineModes) -> Self {
+        let mut bind_entries = vec![
+            // src color
+            wgpu::BindGroupLayoutEntry {
+                binding: 0,
+                visibility: wgpu::ShaderStage::FRAGMENT,
+                ty: wgpu::BindingType::Texture {
+                    sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                    view_dimension: wgpu::TextureViewDimension::D2,
+                    multisampled: false,
+                },
+                count: None,
+            },
+            wgpu::BindGroupLayoutEntry {
+                binding: 1,
+                visibility: wgpu::ShaderStage::FRAGMENT,
+                ty: wgpu::BindingType::Sampler {
+                    filtering: true,
+                    comparison: false,
+                },
+                count: None,
+            },
+            // Locals
+            wgpu::BindGroupLayoutEntry {
+                binding: 2,
+                visibility: wgpu::ShaderStage::VERTEX | wgpu::ShaderStage::FRAGMENT,
+                ty: wgpu::BindingType::Buffer {
+                    ty: wgpu::BufferBindingType::Uniform,
+                    has_dynamic_offset: false,
+                    min_binding_size: None,
+                },
+                count: None,
+            },
+        ];
+
+        if pipeline_modes.bloom.is_on() {
+            bind_entries.push(
+                // src bloom
+                wgpu::BindGroupLayoutEntry {
+                    binding: 3,
+                    visibility: wgpu::ShaderStage::FRAGMENT,
+                    ty: wgpu::BindingType::Texture {
+                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
+                        view_dimension: wgpu::TextureViewDimension::D2,
+                        multisampled: false,
+                    },
+                    count: None,
+                },
+            );
+        }
+
         Self {
             layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
                 label: None,
-                entries: &[
-                    // src color
-                    wgpu::BindGroupLayoutEntry {
-                        binding: 0,
-                        visibility: wgpu::ShaderStage::VERTEX | wgpu::ShaderStage::FRAGMENT,
-                        ty: wgpu::BindingType::Texture {
-                            sample_type: wgpu::TextureSampleType::Float { filterable: true },
-                            view_dimension: wgpu::TextureViewDimension::D2,
-                            multisampled: false,
-                        },
-                        count: None,
-                    },
-                    wgpu::BindGroupLayoutEntry {
-                        binding: 1,
-                        visibility: wgpu::ShaderStage::VERTEX | wgpu::ShaderStage::FRAGMENT,
-                        ty: wgpu::BindingType::Sampler {
-                            filtering: true,
-                            comparison: false,
-                        },
-                        count: None,
-                    },
-                    // Locals
-                    wgpu::BindGroupLayoutEntry {
-                        binding: 2,
-                        visibility: wgpu::ShaderStage::VERTEX | wgpu::ShaderStage::FRAGMENT,
-                        ty: wgpu::BindingType::Buffer {
-                            ty: wgpu::BufferBindingType::Uniform,
-                            has_dynamic_offset: false,
-                            min_binding_size: None,
-                        },
-                        count: None,
-                    },
-                ],
+                entries: &bind_entries,
             }),
         }
     }
@@ -76,26 +94,42 @@ impl PostProcessLayout {
         &self,
         device: &wgpu::Device,
         src_color: &wgpu::TextureView,
+        src_bloom: Option<&wgpu::TextureView>,
         sampler: &wgpu::Sampler,
         locals: &Consts<Locals>,
     ) -> BindGroup {
+        let mut entries = vec![
+            wgpu::BindGroupEntry {
+                binding: 0,
+                resource: wgpu::BindingResource::TextureView(src_color),
+            },
+            wgpu::BindGroupEntry {
+                binding: 1,
+                resource: wgpu::BindingResource::Sampler(sampler),
+            },
+            wgpu::BindGroupEntry {
+                binding: 2,
+                resource: locals.buf().as_entire_binding(),
+            },
+        ];
+        // Optional bloom source
+        if let Some(src_bloom) = src_bloom {
+            entries.push(
+                // TODO: might be cheaper to premix bloom at lower resolution if we are doing
+                // extensive upscaling
+                // TODO: if there is no upscaling we can do the last bloom upsampling in post
+                // process to save a pass and the need for the final full size bloom render target
+                wgpu::BindGroupEntry {
+                    binding: 3,
+                    resource: wgpu::BindingResource::TextureView(src_bloom),
+                },
+            );
+        }
+
         let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
             label: None,
             layout: &self.layout,
-            entries: &[
-                wgpu::BindGroupEntry {
-                    binding: 0,
-                    resource: wgpu::BindingResource::TextureView(src_color),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 1,
-                    resource: wgpu::BindingResource::Sampler(sampler),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 2,
-                    resource: locals.buf().as_entire_binding(),
-                },
-            ],
+            entries: &entries,
         });
 
         BindGroup { bind_group }
diff --git a/voxygen/src/render/renderer.rs b/voxygen/src/render/renderer.rs
index 4b58ca095f..603fb2a915 100644
--- a/voxygen/src/render/renderer.rs
+++ b/voxygen/src/render/renderer.rs
@@ -21,11 +21,12 @@ use super::{
     mesh::Mesh,
     model::{DynamicModel, Model},
     pipelines::{
-        blit, clouds, debug, figure, postprocess, shadow, sprite, terrain, ui, GlobalsBindGroup,
-        GlobalsLayouts, ShadowTexturesBindGroup,
+        blit, bloom, clouds, debug, figure, postprocess, shadow, sprite, terrain, ui,
+        GlobalsBindGroup, GlobalsLayouts, ShadowTexturesBindGroup,
     },
     texture::Texture,
-    AaMode, AddressMode, FilterMode, RenderError, RenderMode, ShadowMapMode, ShadowMode, Vertex,
+    AaMode, AddressMode, FilterMode, OtherModes, PipelineModes, RenderError, RenderMode,
+    ShadowMapMode, ShadowMode, Vertex,
 };
 use common::assets::{self, AssetExt, AssetHandle};
 use common_base::span;
@@ -45,21 +46,35 @@ pub type ColLightInfo = (Vec<[u8; 4]>, Vec2<u16>);
 const QUAD_INDEX_BUFFER_U16_START_VERT_LEN: u16 = 3000;
 const QUAD_INDEX_BUFFER_U32_START_VERT_LEN: u32 = 3000;
 
-/// A type that stores all the layouts associated with this renderer.
-struct Layouts {
+/// A type that stores all the layouts associated with this renderer that never
+/// change when the RenderMode is modified.
+struct ImmutableLayouts {
     global: GlobalsLayouts,
 
-    clouds: clouds::CloudsLayout,
     debug: debug::DebugLayout,
     figure: figure::FigureLayout,
-    postprocess: postprocess::PostProcessLayout,
     shadow: shadow::ShadowLayout,
     sprite: sprite::SpriteLayout,
     terrain: terrain::TerrainLayout,
+    clouds: clouds::CloudsLayout,
+    bloom: bloom::BloomLayout,
     ui: ui::UiLayout,
     blit: blit::BlitLayout,
 }
 
+/// A type that stores all the layouts associated with this renderer.
+struct Layouts {
+    immutable: Arc<ImmutableLayouts>,
+
+    postprocess: Arc<postprocess::PostProcessLayout>,
+}
+
+impl core::ops::Deref for Layouts {
+    type Target = ImmutableLayouts;
+
+    fn deref(&self) -> &Self::Target { &self.immutable }
+}
+
 /// Render target views
 struct Views {
     // NOTE: unused for now, maybe... we will want it for something
@@ -67,6 +82,8 @@ struct Views {
 
     tgt_color: wgpu::TextureView,
     tgt_depth: wgpu::TextureView,
+
+    bloom_tgts: Option<[wgpu::TextureView; bloom::NUM_SIZES]>,
     // TODO: rename
     tgt_color_pp: wgpu::TextureView,
 }
@@ -81,6 +98,7 @@ struct Shadow {
 /// 1. Only interface pipelines created
 /// 2. All of the pipelines have been created
 #[allow(clippy::large_enum_variant)] // They are both pretty large
+#[allow(clippy::type_complexity)]
 enum State {
     // NOTE: this is used as a transient placeholder for moving things out of State temporarily
     Nothing,
@@ -93,7 +111,19 @@ enum State {
     Complete {
         pipelines: Pipelines,
         shadow: Shadow,
-        recreating: Option<PipelineCreation<Result<(Pipelines, ShadowPipelines), RenderError>>>,
+        recreating: Option<(
+            PipelineModes,
+            PipelineCreation<
+                Result<
+                    (
+                        Pipelines,
+                        ShadowPipelines,
+                        Arc<postprocess::PostProcessLayout>,
+                    ),
+                    RenderError,
+                >,
+            >,
+        )>,
     },
 }
 
@@ -112,11 +142,11 @@ pub struct Renderer {
     depth_sampler: wgpu::Sampler,
 
     state: State,
-    // true if there is a pending need to recreate the pipelines (e.g. RenderMode change or shader
+    // Some if there is a pending need to recreate the pipelines (e.g. RenderMode change or shader
     // hotloading)
-    recreation_pending: bool,
+    recreation_pending: Option<PipelineModes>,
 
-    layouts: Arc<Layouts>,
+    layouts: Layouts,
     // Note: we keep these here since their bind groups need to be updated if we resize the
     // color/depth textures
     locals: Locals,
@@ -128,7 +158,8 @@ pub struct Renderer {
 
     shaders: AssetHandle<Shaders>,
 
-    mode: RenderMode,
+    pipeline_modes: PipelineModes,
+    other_modes: OtherModes,
     resolution: Vec2<u32>,
 
     // If this is Some then a screenshot will be taken and passed to the handler here
@@ -152,7 +183,8 @@ pub struct Renderer {
 impl Renderer {
     /// Create a new `Renderer` from a variety of backend-specific components
     /// and the window targets.
-    pub fn new(window: &winit::window::Window, mut mode: RenderMode) -> Result<Self, RenderError> {
+    pub fn new(window: &winit::window::Window, mode: RenderMode) -> Result<Self, RenderError> {
+        let (pipeline_modes, mut other_modes) = mode.split();
         // Enable seamless cubemaps globally, where available--they are essentially a
         // strict improvement on regular cube maps.
         //
@@ -285,7 +317,7 @@ impl Renderer {
             format,
             width: dims.width,
             height: dims.height,
-            present_mode: mode.present_mode.into(),
+            present_mode: other_modes.present_mode.into(),
         };
 
         let swap_chain = device.create_swap_chain(&surface, &sc_desc);
@@ -293,7 +325,7 @@ impl Renderer {
         let shadow_views = ShadowMap::create_shadow_views(
             &device,
             (dims.width, dims.height),
-            &ShadowMapMode::try_from(mode.shadow).unwrap_or_default(),
+            &ShadowMapMode::try_from(pipeline_modes.shadow).unwrap_or_default(),
         )
         .map_err(|err| {
             warn!("Could not create shadow map views: {:?}", err);
@@ -305,41 +337,51 @@ impl Renderer {
         let layouts = {
             let global = GlobalsLayouts::new(&device);
 
-            let clouds = clouds::CloudsLayout::new(&device);
             let debug = debug::DebugLayout::new(&device);
             let figure = figure::FigureLayout::new(&device);
-            let postprocess = postprocess::PostProcessLayout::new(&device);
             let shadow = shadow::ShadowLayout::new(&device);
             let sprite = sprite::SpriteLayout::new(&device);
             let terrain = terrain::TerrainLayout::new(&device);
+            let clouds = clouds::CloudsLayout::new(&device);
+            let bloom = bloom::BloomLayout::new(&device);
+            let postprocess = Arc::new(postprocess::PostProcessLayout::new(
+                &device,
+                &pipeline_modes,
+            ));
             let ui = ui::UiLayout::new(&device);
             let blit = blit::BlitLayout::new(&device);
 
-            Layouts {
+            let immutable = Arc::new(ImmutableLayouts {
                 global,
 
-                clouds,
                 debug,
                 figure,
-                postprocess,
                 shadow,
                 sprite,
                 terrain,
+                clouds,
+                bloom,
                 ui,
                 blit,
+            });
+
+            Layouts {
+                immutable,
+                postprocess,
             }
         };
 
-        // Arcify the device and layouts
+        // Arcify the device
         let device = Arc::new(device);
-        let layouts = Arc::new(layouts);
 
         let (interface_pipelines, creating) = pipeline_creation::initial_create_pipelines(
-            // TODO: combine Arcs?
             Arc::clone(&device),
-            Arc::clone(&layouts),
+            Layouts {
+                immutable: Arc::clone(&layouts.immutable),
+                postprocess: Arc::clone(&layouts.postprocess),
+            },
             shaders.read().clone(),
-            mode.clone(),
+            pipeline_modes.clone(),
             sc_desc.clone(), // Note: cheap clone
             shadow_views.is_some(),
         )?;
@@ -350,7 +392,12 @@ impl Renderer {
             creating,
         };
 
-        let views = Self::create_rt_views(&device, (dims.width, dims.height), &mode)?;
+        let (views, bloom_sizes) = Self::create_rt_views(
+            &device,
+            (dims.width, dims.height),
+            &pipeline_modes,
+            &other_modes,
+        );
 
         let create_sampler = |filter| {
             device.create_sampler(&wgpu::SamplerDescriptor {
@@ -389,6 +436,13 @@ impl Renderer {
             postprocess_locals,
             &views.tgt_color,
             &views.tgt_depth,
+            views.bloom_tgts.as_ref().map(|tgts| locals::BloomParams {
+                locals: bloom_sizes.map(|size| {
+                    Self::create_consts_inner(&device, &queue, &[bloom::Locals::new(size)])
+                }),
+                src_views: [&views.tgt_color_pp, &tgts[1], &tgts[2], &tgts[3], &tgts[4]],
+                final_tgt_view: &tgts[0],
+            }),
             &views.tgt_color_pp,
             &sampler,
             &depth_sampler,
@@ -399,9 +453,9 @@ impl Renderer {
         let quad_index_buffer_u32 =
             create_quad_index_buffer_u32(&device, QUAD_INDEX_BUFFER_U32_START_VERT_LEN as usize);
         let mut profiler = wgpu_profiler::GpuProfiler::new(4, queue.get_timestamp_period());
-        mode.profiler_enabled &= profiler_features_enabled;
-        profiler.enable_timer = mode.profiler_enabled;
-        profiler.enable_debug_marker = mode.profiler_enabled;
+        other_modes.profiler_enabled &= profiler_features_enabled;
+        profiler.enable_timer = other_modes.profiler_enabled;
+        profiler.enable_debug_marker = other_modes.profiler_enabled;
 
         #[cfg(feature = "egui-ui")]
         let egui_renderpass =
@@ -415,7 +469,7 @@ impl Renderer {
             sc_desc,
 
             state,
-            recreation_pending: false,
+            recreation_pending: None,
 
             layouts,
             locals,
@@ -430,7 +484,8 @@ impl Renderer {
 
             shaders,
 
-            mode,
+            pipeline_modes,
+            other_modes,
             resolution: Vec2::new(dims.width, dims.height),
 
             take_screenshot: None,
@@ -467,7 +522,7 @@ impl Renderer {
     /// Returns `Some((total, complete))` if in progress
     pub fn pipeline_recreation_status(&self) -> Option<(usize, usize)> {
         if let State::Complete { recreating, .. } = &self.state {
-            recreating.as_ref().map(|r| r.status())
+            recreating.as_ref().map(|(_, c)| c.status())
         } else {
             None
         }
@@ -475,37 +530,46 @@ impl Renderer {
 
     /// Change the render mode.
     pub fn set_render_mode(&mut self, mode: RenderMode) -> Result<(), RenderError> {
-        // TODO: are there actually any issues with the current mode not matching the
-        // pipelines (since we could previously have inconsistencies from
-        // pipelines failing to build due to shader editing)?
-        // TODO: FIXME: defer mode changing until pipelines are rebuilt to prevent
-        // incompatibilities as pipelines are now rebuilt in a deferred mannder in the
-        // background TODO: consider separating changes that don't require
-        // rebuilding pipelines
-        self.mode = mode;
-        self.sc_desc.present_mode = self.mode.present_mode.into();
+        let (pipeline_modes, other_modes) = mode.split();
 
-        // Only enable profiling if the wgpu features are enabled
-        self.mode.profiler_enabled &= self.profiler_features_enabled;
-        // Enable/disable profiler
-        if !self.mode.profiler_enabled {
-            // Clear the times if disabled
-            core::mem::take(&mut self.profile_times);
+        if self.other_modes != other_modes {
+            self.other_modes = other_modes;
+
+            // Update present mode in swap chain descriptor
+            self.sc_desc.present_mode = self.other_modes.present_mode.into();
+
+            // Only enable profiling if the wgpu features are enabled
+            self.other_modes.profiler_enabled &= self.profiler_features_enabled;
+            // Enable/disable profiler
+            if !self.other_modes.profiler_enabled {
+                // Clear the times if disabled
+                core::mem::take(&mut self.profile_times);
+            }
+            self.profiler.enable_timer = self.other_modes.profiler_enabled;
+            self.profiler.enable_debug_marker = self.other_modes.profiler_enabled;
+
+            // Recreate render target
+            self.on_resize(self.resolution);
         }
-        self.profiler.enable_timer = self.mode.profiler_enabled;
-        self.profiler.enable_debug_marker = self.mode.profiler_enabled;
 
-        // Recreate render target
-        self.on_resize(self.resolution)?;
-
-        // Recreate pipelines with the new AA mode
-        self.recreate_pipelines();
+        // We can't cancel the pending recreation even if the new settings are equal
+        // to the current ones becuase the recreation could be triggered by something
+        // else like shader hotloading
+        if self.pipeline_modes != pipeline_modes
+            || self
+                .recreation_pending
+                .as_ref()
+                .map_or(false, |modes| modes != &pipeline_modes)
+        {
+            // Recreate pipelines with new modes
+            self.recreate_pipelines(pipeline_modes);
+        }
 
         Ok(())
     }
 
-    /// Get the render mode.
-    pub fn render_mode(&self) -> &RenderMode { &self.mode }
+    /// Get the pipelines mode.
+    pub fn pipeline_modes(&self) -> &PipelineModes { &self.pipeline_modes }
 
     /// Get the current profiling times
     /// Nested timings immediately follow their parent
@@ -535,7 +599,7 @@ impl Renderer {
     }
 
     /// Resize internal render targets to match window render target dimensions.
-    pub fn on_resize(&mut self, dims: Vec2<u32>) -> Result<(), RenderError> {
+    pub fn on_resize(&mut self, dims: Vec2<u32>) {
         // Avoid panics when creating texture with w,h of 0,0.
         if dims.x != 0 && dims.y != 0 {
             self.is_minimized = false;
@@ -546,13 +610,36 @@ impl Renderer {
             self.swap_chain = self.device.create_swap_chain(&self.surface, &self.sc_desc);
 
             // Resize other render targets
-            self.views = Self::create_rt_views(&self.device, (dims.x, dims.y), &self.mode)?;
-            // Rebind views to clouds/postprocess bind groups
+            let (views, bloom_sizes) = Self::create_rt_views(
+                &self.device,
+                (dims.x, dims.y),
+                &self.pipeline_modes,
+                &self.other_modes,
+            );
+            self.views = views;
+
+            // appease borrow check (TODO: remove after Rust 2021)
+            let device = &self.device;
+            let queue = &self.queue;
+            let views = &self.views;
+            let bloom_params = self
+                .views
+                .bloom_tgts
+                .as_ref()
+                .map(|tgts| locals::BloomParams {
+                    locals: bloom_sizes.map(|size| {
+                        Self::create_consts_inner(device, queue, &[bloom::Locals::new(size)])
+                    }),
+                    src_views: [&views.tgt_color_pp, &tgts[1], &tgts[2], &tgts[3], &tgts[4]],
+                    final_tgt_view: &tgts[0],
+                });
+
             self.locals.rebind(
                 &self.device,
                 &self.layouts,
                 &self.views.tgt_color,
                 &self.views.tgt_depth,
+                bloom_params,
                 &self.views.tgt_color_pp,
                 &self.sampler,
                 &self.depth_sampler,
@@ -576,7 +663,7 @@ impl Renderer {
             };
 
             if let (Some((point_depth, directed_depth)), ShadowMode::Map(mode)) =
-                (shadow_views, self.mode.shadow)
+                (shadow_views, self.pipeline_modes.shadow)
             {
                 match ShadowMap::create_shadow_views(&self.device, (dims.x, dims.y), &mode) {
                     Ok((new_point_depth, new_directed_depth)) => {
@@ -608,8 +695,6 @@ impl Renderer {
         } else {
             self.is_minimized = true;
         }
-
-        Ok(())
     }
 
     pub fn maintain(&self) {
@@ -624,12 +709,13 @@ impl Renderer {
     fn create_rt_views(
         device: &wgpu::Device,
         size: (u32, u32),
-        mode: &RenderMode,
-    ) -> Result<Views, RenderError> {
+        pipeline_modes: &PipelineModes,
+        other_modes: &OtherModes,
+    ) -> (Views, [Vec2<f32>; bloom::NUM_SIZES]) {
         let upscaled = Vec2::<u32>::from(size)
-            .map(|e| (e as f32 * mode.upscale_mode.factor) as u32)
+            .map(|e| (e as f32 * other_modes.upscale_mode.factor) as u32)
             .into_tuple();
-        let (width, height, sample_count) = match mode.aa {
+        let (width, height, sample_count) = match pipeline_modes.aa {
             AaMode::None | AaMode::Fxaa => (upscaled.0, upscaled.1, 1),
             AaMode::MsaaX4 => (upscaled.0, upscaled.1, 4),
             AaMode::MsaaX8 => (upscaled.0, upscaled.1, 8),
@@ -637,7 +723,7 @@ impl Renderer {
         };
         let levels = 1;
 
-        let color_view = || {
+        let color_view = |width, height| {
             let tex = device.create_texture(&wgpu::TextureDescriptor {
                 label: None,
                 size: wgpu::Extent3d {
@@ -665,8 +751,22 @@ impl Renderer {
             })
         };
 
-        let tgt_color_view = color_view();
-        let tgt_color_pp_view = color_view();
+        let tgt_color_view = color_view(width, height);
+        let tgt_color_pp_view = color_view(width, height);
+
+        let mut size_shift = 0;
+        // TODO: skip creating bloom stuff when it is disabled
+        let bloom_sizes = [(); bloom::NUM_SIZES].map(|()| {
+            // .max(1) to ensure we don't create zero sized textures
+            let size = Vec2::new(width, height).map(|e| (e >> size_shift).max(1));
+            size_shift += 1;
+            size
+        });
+
+        let bloom_tgt_views = pipeline_modes
+            .bloom
+            .is_on()
+            .then(|| bloom_sizes.map(|size| color_view(size.x, size.y)));
 
         let tgt_depth_tex = device.create_texture(&wgpu::TextureDescriptor {
             label: None,
@@ -717,12 +817,16 @@ impl Renderer {
             array_layer_count: None,
         });
 
-        Ok(Views {
-            tgt_color: tgt_color_view,
-            tgt_depth: tgt_depth_view,
-            tgt_color_pp: tgt_color_pp_view,
-            _win_depth: win_depth_view,
-        })
+        (
+            Views {
+                tgt_color: tgt_color_view,
+                tgt_depth: tgt_depth_view,
+                bloom_tgts: bloom_tgt_views,
+                tgt_color_pp: tgt_color_pp_view,
+                _win_depth: win_depth_view,
+            },
+            bloom_sizes.map(|s| s.map(|e| e as f32)),
+        )
     }
 
     /// Get the resolution of the render target.
@@ -796,7 +900,7 @@ impl Renderer {
         }
 
         // Try to get the latest profiling results
-        if self.mode.profiler_enabled {
+        if self.other_modes.profiler_enabled {
             // Note: this lags a few frames behind
             if let Some(profile_times) = self.profiler.process_finished_frame() {
                 self.profile_times = profile_times;
@@ -806,6 +910,10 @@ impl Renderer {
         // Handle polling background pipeline creation/recreation
         // Temporarily set to nothing and then replace in the statement below
         let state = core::mem::replace(&mut self.state, State::Nothing);
+        // Indicator for if pipeline recreation finished and we need to recreate bind
+        // groups / render targets (handling defered so that State will be valid
+        // when calling Self::on_resize)
+        let mut trigger_on_resize = false;
         // If still creating initial pipelines, check if complete
         self.state = if let State::Interface {
             pipelines: interface,
@@ -857,11 +965,11 @@ impl Renderer {
         } else if let State::Complete {
             pipelines,
             mut shadow,
-            recreating: Some(recreating),
+            recreating: Some((new_pipeline_modes, pipeline_creation)),
         } = state
         {
-            match recreating.try_complete() {
-                Ok(Ok((pipelines, shadow_pipelines))) => {
+            match pipeline_creation.try_complete() {
+                Ok(Ok((pipelines, shadow_pipelines, postprocess_layout))) => {
                     if let (
                         Some(point_pipeline),
                         Some(terrain_directed_pipeline),
@@ -877,6 +985,14 @@ impl Renderer {
                         shadow_map.terrain_directed_pipeline = terrain_directed_pipeline;
                         shadow_map.figure_directed_pipeline = figure_directed_pipeline;
                     }
+
+                    self.pipeline_modes = new_pipeline_modes;
+                    self.layouts.postprocess = postprocess_layout;
+                    // TODO: we have the potential to skip recreating bindings / render targets on
+                    // pipeline recreation trigged by shader reloading (would need to ensure new
+                    // postprocess_layout is not created...)
+                    trigger_on_resize = true;
+
                     State::Complete {
                         pipelines,
                         shadow,
@@ -892,27 +1008,36 @@ impl Renderer {
                     }
                 },
                 // Not complete
-                Err(recreating) => State::Complete {
+                Err(pipeline_creation) => State::Complete {
                     pipelines,
                     shadow,
-                    recreating: Some(recreating),
+                    recreating: Some((new_pipeline_modes, pipeline_creation)),
                 },
             }
         } else {
             state
         };
 
+        // Call on_resize to recreate render targets and their bind groups if the
+        // pipelines were recreated with a new postprocess layout and or changes in the
+        // render modes
+        if trigger_on_resize {
+            self.on_resize(self.resolution);
+        }
+
         // If the shaders files were changed attempt to recreate the shaders
         if self.shaders.reloaded() {
-            self.recreate_pipelines();
+            self.recreate_pipelines(self.pipeline_modes.clone());
         }
 
         // Or if we have a recreation pending
-        if self.recreation_pending
-            && matches!(&self.state, State::Complete { recreating, .. } if recreating.is_none())
-        {
-            self.recreation_pending = false;
-            self.recreate_pipelines();
+        if matches!(&self.state, State::Complete {
+            recreating: None,
+            ..
+        }) {
+            if let Some(new_pipeline_modes) = self.recreation_pending.take() {
+                self.recreate_pipelines(new_pipeline_modes);
+            }
         }
 
         let tex = match self.swap_chain.get_current_frame() {
@@ -920,7 +1045,8 @@ impl Renderer {
             // If lost recreate the swap chain
             Err(err @ wgpu::SwapChainError::Lost) => {
                 warn!("{}. Recreating swap chain. A frame will be missed", err);
-                return self.on_resize(self.resolution).map(|()| None);
+                self.on_resize(self.resolution);
+                return Ok(None);
             },
             Err(wgpu::SwapChainError::Timeout) => {
                 // This will probably be resolved on the next frame
@@ -945,29 +1071,36 @@ impl Renderer {
     }
 
     /// Recreate the pipelines
-    fn recreate_pipelines(&mut self) {
+    fn recreate_pipelines(&mut self, pipeline_modes: PipelineModes) {
         match &mut self.state {
             State::Complete { recreating, .. } if recreating.is_some() => {
                 // Defer recreation so that we are not building multiple sets of pipelines in
                 // the background at once
-                self.recreation_pending = true;
+                self.recreation_pending = Some(pipeline_modes);
             },
             State::Complete {
                 recreating, shadow, ..
             } => {
-                *recreating = Some(pipeline_creation::recreate_pipelines(
-                    Arc::clone(&self.device),
-                    Arc::clone(&self.layouts),
-                    self.shaders.read().clone(),
-                    self.mode.clone(),
-                    self.sc_desc.clone(), // Note: cheap clone
-                    shadow.map.is_enabled(),
+                *recreating = Some((
+                    pipeline_modes.clone(),
+                    pipeline_creation::recreate_pipelines(
+                        Arc::clone(&self.device),
+                        Arc::clone(&self.layouts.immutable),
+                        self.shaders.read().clone(),
+                        pipeline_modes,
+                        // NOTE: if present_mode starts to be used to configure pipelines then it
+                        // needs to become a part of the pipeline modes
+                        // (note here since the present mode is accessible
+                        // through the swap chain descriptor)
+                        self.sc_desc.clone(), // Note: cheap clone
+                        shadow.map.is_enabled(),
+                    ),
                 ));
             },
             State::Interface { .. } => {
                 // Defer recreation so that we are not building multiple sets of pipelines in
                 // the background at once
-                self.recreation_pending = true;
+                self.recreation_pending = Some(pipeline_modes);
             },
             State::Nothing => {},
         }
@@ -1184,7 +1317,7 @@ impl Renderer {
         // Queue screenshot
         self.take_screenshot = Some(Box::new(screenshot_handler));
         // Take profiler snapshot
-        if self.mode.profiler_enabled {
+        if self.other_modes.profiler_enabled {
             let file_name = format!(
                 "frame-trace_{}.json",
                 std::time::SystemTime::now()
diff --git a/voxygen/src/render/renderer/drawer.rs b/voxygen/src/render/renderer/drawer.rs
index 8034a251b5..16f67b76f2 100644
--- a/voxygen/src/render/renderer/drawer.rs
+++ b/voxygen/src/render/renderer/drawer.rs
@@ -4,8 +4,8 @@ use super::{
         instances::Instances,
         model::{DynamicModel, Model, SubModel},
         pipelines::{
-            blit, clouds, debug, figure, fluid, lod_terrain, particle, shadow, skybox, sprite,
-            terrain, ui, ColLights, GlobalsBindGroup, ShadowTexturesBindGroup,
+            blit, bloom, clouds, debug, figure, fluid, lod_terrain, particle, shadow, skybox,
+            sprite, terrain, ui, ColLights, GlobalsBindGroup, ShadowTexturesBindGroup,
         },
     },
     Renderer, ShadowMap, ShadowMapRenderer,
@@ -61,7 +61,7 @@ struct RendererBorrow<'frame> {
     pipelines: Pipelines<'frame>,
     locals: &'frame super::locals::Locals,
     views: &'frame super::Views,
-    mode: &'frame super::super::RenderMode,
+    pipeline_modes: &'frame super::super::PipelineModes,
     quad_index_buffer_u16: &'frame Buffer<u16>,
     quad_index_buffer_u32: &'frame Buffer<u32>,
     #[cfg(feature = "egui-ui")]
@@ -112,7 +112,7 @@ impl<'frame> Drawer<'frame> {
             pipelines,
             locals: &renderer.locals,
             views: &renderer.views,
-            mode: &renderer.mode,
+            pipeline_modes: &renderer.pipeline_modes,
             quad_index_buffer_u16: &renderer.quad_index_buffer_u16,
             quad_index_buffer_u32: &renderer.quad_index_buffer_u32,
             #[cfg(feature = "egui-ui")]
@@ -131,13 +131,13 @@ impl<'frame> Drawer<'frame> {
         }
     }
 
-    /// Get the render mode.
-    pub fn render_mode(&self) -> &super::super::RenderMode { self.borrow.mode }
+    /// Get the pipeline modes.
+    pub fn pipeline_modes(&self) -> &super::super::PipelineModes { self.borrow.pipeline_modes }
 
     /// Returns None if the shadow renderer is not enabled at some level or the
     /// pipelines are not available yet
     pub fn shadow_pass(&mut self) -> Option<ShadowPassDrawer> {
-        if !self.borrow.mode.shadow.is_map() {
+        if !self.borrow.pipeline_modes.shadow.is_map() {
             return None;
         }
 
@@ -241,6 +241,94 @@ impl<'frame> Drawer<'frame> {
         })
     }
 
+    /// To be ran between the second pass and the third pass
+    /// does nothing if the ingame pipelines are not yet ready
+    /// does nothing if bloom is disabled
+    pub fn run_bloom_passes(&mut self) {
+        let locals = &self.borrow.locals;
+        let views = &self.borrow.views;
+
+        let bloom_pipelines = match self.borrow.pipelines.all() {
+            Some(super::Pipelines { bloom: Some(p), .. }) => p,
+            _ => return,
+        };
+
+        // TODO: consider consolidating optional bloom bind groups and optional pipeline
+        // into a single structure?
+        let (bloom_tgts, bloom_binds) =
+            match views.bloom_tgts.as_ref().zip(locals.bloom_binds.as_ref()) {
+                Some((t, b)) => (t, b),
+                None => return,
+            };
+
+        let device = self.borrow.device;
+        let mut encoder = self.encoder.as_mut().unwrap().scope("bloom", device);
+
+        let mut run_bloom_pass = |bind, view, label: String, pipeline, load| {
+            let pass_label = format!("bloom {} pass", label);
+            let mut render_pass =
+                encoder.scoped_render_pass(&label, device, &wgpu::RenderPassDescriptor {
+                    label: Some(&pass_label),
+                    color_attachments: &[wgpu::RenderPassColorAttachment {
+                        resolve_target: None,
+                        view,
+                        ops: wgpu::Operations { store: true, load },
+                    }],
+                    depth_stencil_attachment: None,
+                });
+
+            render_pass.set_bind_group(0, bind, &[]);
+            render_pass.set_pipeline(pipeline);
+            render_pass.draw(0..3, 0..1);
+        };
+
+        // Downsample filter passes
+        (0..bloom::NUM_SIZES - 1).for_each(|index| {
+            let bind = &bloom_binds[index].bind_group;
+            let view = &bloom_tgts[index + 1];
+            // Do filtering out of non-bright things during the first downsample
+            let (label, pipeline) = if index == 0 {
+                (
+                    format!("downsample filtered {}", index + 1),
+                    &bloom_pipelines.downsample_filtered,
+                )
+            } else {
+                (
+                    format!("downsample {}", index + 1),
+                    &bloom_pipelines.downsample,
+                )
+            };
+            run_bloom_pass(
+                bind,
+                view,
+                label,
+                pipeline,
+                wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+            );
+        });
+
+        // Upsample filter passes
+        (0..bloom::NUM_SIZES - 1).for_each(|index| {
+            let bind = &bloom_binds[bloom::NUM_SIZES - 1 - index].bind_group;
+            let view = &bloom_tgts[bloom::NUM_SIZES - 2 - index];
+            let label = format!("upsample {}", index + 1);
+            run_bloom_pass(
+                bind,
+                view,
+                label,
+                &bloom_pipelines.upsample,
+                if index + 2 == bloom::NUM_SIZES {
+                    // Clear for the final image since that is just stuff from the pervious frame.
+                    wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT)
+                } else {
+                    // Add to less blurred images to get gradient of blur instead of a smudge>
+                    // https://catlikecoding.com/unity/tutorials/advanced-rendering/bloom/
+                    wgpu::LoadOp::Load
+                },
+            );
+        });
+    }
+
     pub fn third_pass(&mut self) -> ThirdPassDrawer {
         let encoder = self.encoder.as_mut().unwrap();
         let device = self.borrow.device;
@@ -321,7 +409,7 @@ impl<'frame> Drawer<'frame> {
         chunks: impl Clone
         + Iterator<Item = (&'data Model<terrain::Vertex>, &'data terrain::BoundLocals)>,
     ) {
-        if !self.borrow.mode.shadow.is_map() {
+        if !self.borrow.pipeline_modes.shadow.is_map() {
             return;
         }
 
diff --git a/voxygen/src/render/renderer/locals.rs b/voxygen/src/render/renderer/locals.rs
index b7c94c5396..fb3b0301ac 100644
--- a/voxygen/src/render/renderer/locals.rs
+++ b/voxygen/src/render/renderer/locals.rs
@@ -1,15 +1,23 @@
 use super::{
     super::{
         consts::Consts,
-        pipelines::{clouds, postprocess},
+        pipelines::{bloom, clouds, postprocess},
     },
     Layouts,
 };
 
+pub struct BloomParams<'a> {
+    pub locals: [Consts<bloom::Locals>; bloom::NUM_SIZES],
+    pub src_views: [&'a wgpu::TextureView; bloom::NUM_SIZES],
+    pub final_tgt_view: &'a wgpu::TextureView,
+}
+
 pub struct Locals {
     pub clouds: Consts<clouds::Locals>,
     pub clouds_bind: clouds::BindGroup,
 
+    pub bloom_binds: Option<[bloom::BindGroup; bloom::NUM_SIZES]>,
+
     pub postprocess: Consts<postprocess::Locals>,
     pub postprocess_bind: postprocess::BindGroup,
 }
@@ -22,6 +30,7 @@ impl Locals {
         postprocess_locals: Consts<postprocess::Locals>,
         tgt_color_view: &wgpu::TextureView,
         tgt_depth_view: &wgpu::TextureView,
+        bloom: Option<BloomParams>,
         tgt_color_pp_view: &wgpu::TextureView,
         sampler: &wgpu::Sampler,
         depth_sampler: &wgpu::Sampler,
@@ -34,14 +43,26 @@ impl Locals {
             depth_sampler,
             &clouds_locals,
         );
-        let postprocess_bind =
-            layouts
-                .postprocess
-                .bind(device, tgt_color_pp_view, sampler, &postprocess_locals);
+
+        let postprocess_bind = layouts.postprocess.bind(
+            device,
+            tgt_color_pp_view,
+            bloom.as_ref().map(|b| b.final_tgt_view),
+            sampler,
+            &postprocess_locals,
+        );
+
+        let bloom_binds = bloom.map(|bloom| {
+            bloom
+                .src_views
+                .zip(bloom.locals) // zip arrays
+                .map(|(view, locals)| layouts.bloom.bind(device, view, sampler, locals))
+        });
 
         Self {
             clouds: clouds_locals,
             clouds_bind,
+            bloom_binds,
             postprocess: postprocess_locals,
             postprocess_bind,
         }
@@ -55,6 +76,7 @@ impl Locals {
         // e.g. resizing
         tgt_color_view: &wgpu::TextureView,
         tgt_depth_view: &wgpu::TextureView,
+        bloom: Option<BloomParams>,
         tgt_color_pp_view: &wgpu::TextureView,
         sampler: &wgpu::Sampler,
         depth_sampler: &wgpu::Sampler,
@@ -67,9 +89,18 @@ impl Locals {
             depth_sampler,
             &self.clouds,
         );
-        self.postprocess_bind =
-            layouts
-                .postprocess
-                .bind(device, tgt_color_pp_view, sampler, &self.postprocess);
+        self.postprocess_bind = layouts.postprocess.bind(
+            device,
+            tgt_color_pp_view,
+            bloom.as_ref().map(|b| b.final_tgt_view),
+            sampler,
+            &self.postprocess,
+        );
+        self.bloom_binds = bloom.map(|bloom| {
+            bloom
+                .src_views
+                .zip(bloom.locals) // zip arrays
+                .map(|(view, locals)| layouts.bloom.bind(device, view, sampler, locals))
+        });
     }
 }
diff --git a/voxygen/src/render/renderer/pipeline_creation.rs b/voxygen/src/render/renderer/pipeline_creation.rs
index 1aca7f43f1..3ec6f4f479 100644
--- a/voxygen/src/render/renderer/pipeline_creation.rs
+++ b/voxygen/src/render/renderer/pipeline_creation.rs
@@ -1,13 +1,14 @@
 use super::{
     super::{
         pipelines::{
-            blit, clouds, debug, figure, fluid, lod_terrain, particle, postprocess, shadow, skybox,
-            sprite, terrain, ui,
+            blit, bloom, clouds, debug, figure, fluid, lod_terrain, particle, postprocess, shadow,
+            skybox, sprite, terrain, ui,
         },
-        AaMode, CloudMode, FluidMode, LightingMode, RenderError, RenderMode, ShadowMode,
+        AaMode, BloomMode, CloudMode, FluidMode, LightingMode, PipelineModes, RenderError,
+        ShadowMode,
     },
     shaders::Shaders,
-    Layouts,
+    ImmutableLayouts, Layouts,
 };
 use common_base::prof_span;
 use std::sync::Arc;
@@ -20,6 +21,7 @@ pub struct Pipelines {
     pub lod_terrain: lod_terrain::LodTerrainPipeline,
     pub particle: particle::ParticlePipeline,
     pub clouds: clouds::CloudsPipeline,
+    pub bloom: Option<bloom::BloomPipelines>,
     pub postprocess: postprocess::PostProcessPipeline,
     // Consider reenabling at some time
     // player_shadow: figure::FigurePipeline,
@@ -39,6 +41,7 @@ pub struct IngamePipelines {
     lod_terrain: lod_terrain::LodTerrainPipeline,
     particle: particle::ParticlePipeline,
     clouds: clouds::CloudsPipeline,
+    pub bloom: Option<bloom::BloomPipelines>,
     postprocess: postprocess::PostProcessPipeline,
     // Consider reenabling at some time
     // player_shadow: figure::FigurePipeline,
@@ -74,6 +77,7 @@ impl Pipelines {
             lod_terrain: ingame.lod_terrain,
             particle: ingame.particle,
             clouds: ingame.clouds,
+            bloom: ingame.bloom,
             postprocess: ingame.postprocess,
             //player_shadow: ingame.player_shadow,
             skybox: ingame.skybox,
@@ -107,6 +111,9 @@ struct ShaderModules {
     lod_terrain_frag: wgpu::ShaderModule,
     clouds_vert: wgpu::ShaderModule,
     clouds_frag: wgpu::ShaderModule,
+    dual_downsample_filtered_frag: wgpu::ShaderModule,
+    dual_downsample_frag: wgpu::ShaderModule,
+    dual_upsample_frag: wgpu::ShaderModule,
     postprocess_vert: wgpu::ShaderModule,
     postprocess_frag: wgpu::ShaderModule,
     blit_vert: wgpu::ShaderModule,
@@ -120,7 +127,7 @@ impl ShaderModules {
     pub fn new(
         device: &wgpu::Device,
         shaders: &Shaders,
-        mode: &RenderMode,
+        pipeline_modes: &PipelineModes,
         has_shadow_views: bool,
     ) -> Result<Self, RenderError> {
         prof_span!(_guard, "ShaderModules::new");
@@ -150,11 +157,11 @@ impl ShaderModules {
             &constants.0,
             // TODO: Configurable vertex/fragment shader preference.
             "VOXYGEN_COMPUTATION_PREFERENCE_FRAGMENT",
-            match mode.fluid {
+            match pipeline_modes.fluid {
                 FluidMode::Cheap => "FLUID_MODE_CHEAP",
                 FluidMode::Shiny => "FLUID_MODE_SHINY",
             },
-            match mode.cloud {
+            match pipeline_modes.cloud {
                 CloudMode::None => "CLOUD_MODE_NONE",
                 CloudMode::Minimal => "CLOUD_MODE_MINIMAL",
                 CloudMode::Low => "CLOUD_MODE_LOW",
@@ -162,20 +169,38 @@ impl ShaderModules {
                 CloudMode::High => "CLOUD_MODE_HIGH",
                 CloudMode::Ultra => "CLOUD_MODE_ULTRA",
             },
-            match mode.lighting {
+            match pipeline_modes.lighting {
                 LightingMode::Ashikhmin => "LIGHTING_ALGORITHM_ASHIKHMIN",
                 LightingMode::BlinnPhong => "LIGHTING_ALGORITHM_BLINN_PHONG",
                 LightingMode::Lambertian => "LIGHTING_ALGORITHM_LAMBERTIAN",
             },
-            match mode.shadow {
+            match pipeline_modes.shadow {
                 ShadowMode::None => "SHADOW_MODE_NONE",
                 ShadowMode::Map(_) if has_shadow_views => "SHADOW_MODE_MAP",
                 ShadowMode::Cheap | ShadowMode::Map(_) => "SHADOW_MODE_CHEAP",
             },
         );
 
+        let constants = match pipeline_modes.bloom {
+            BloomMode::Off => constants,
+            BloomMode::On(config) => {
+                format!(
+                    r#"
+{}
+
+#define BLOOM_FACTOR {}
+#define BLOOM_UNIFORM_BLUR {}
+
+"#,
+                    constants,
+                    config.factor.fraction(),
+                    config.uniform_blur,
+                )
+            },
+        };
+
         let anti_alias = shaders
-            .get(match mode.aa {
+            .get(match pipeline_modes.aa {
                 AaMode::None => "antialias.none",
                 AaMode::Fxaa => "antialias.fxaa",
                 AaMode::MsaaX4 => "antialias.msaa-x4",
@@ -185,7 +210,7 @@ impl ShaderModules {
             .unwrap();
 
         let cloud = shaders
-            .get(match mode.cloud {
+            .get(match pipeline_modes.cloud {
                 CloudMode::None => "include.cloud.none",
                 _ => "include.cloud.regular",
             })
@@ -228,7 +253,7 @@ impl ShaderModules {
             create_shader_module(device, &mut compiler, glsl, kind, &file_name, &options)
         };
 
-        let selected_fluid_shader = ["fluid-frag.", match mode.fluid {
+        let selected_fluid_shader = ["fluid-frag.", match pipeline_modes.fluid {
             FluidMode::Cheap => "cheap",
             FluidMode::Shiny => "shiny",
         }]
@@ -255,6 +280,12 @@ impl ShaderModules {
             lod_terrain_frag: create_shader("lod-terrain-frag", ShaderKind::Fragment)?,
             clouds_vert: create_shader("clouds-vert", ShaderKind::Vertex)?,
             clouds_frag: create_shader("clouds-frag", ShaderKind::Fragment)?,
+            dual_downsample_filtered_frag: create_shader(
+                "dual-downsample-filtered-frag",
+                ShaderKind::Fragment,
+            )?,
+            dual_downsample_frag: create_shader("dual-downsample-frag", ShaderKind::Fragment)?,
+            dual_upsample_frag: create_shader("dual-upsample-frag", ShaderKind::Fragment)?,
             postprocess_vert: create_shader("postprocess-vert", ShaderKind::Vertex)?,
             postprocess_frag: create_shader("postprocess-frag", ShaderKind::Fragment)?,
             blit_vert: create_shader("blit-vert", ShaderKind::Vertex)?,
@@ -305,7 +336,7 @@ struct PipelineNeeds<'a> {
     device: &'a wgpu::Device,
     layouts: &'a Layouts,
     shaders: &'a ShaderModules,
-    mode: &'a RenderMode,
+    pipeline_modes: &'a PipelineModes,
     sc_desc: &'a wgpu::SwapChainDescriptor,
 }
 
@@ -360,7 +391,7 @@ fn create_interface_pipelines(
 fn create_ingame_and_shadow_pipelines(
     needs: PipelineNeeds,
     pool: &rayon::ThreadPool,
-    tasks: [Task; 13],
+    tasks: [Task; 14],
 ) -> IngameAndShadowPipelines {
     prof_span!(_guard, "create_ingame_and_shadow_pipelines");
 
@@ -368,7 +399,7 @@ fn create_ingame_and_shadow_pipelines(
         device,
         layouts,
         shaders,
-        mode,
+        pipeline_modes,
         sc_desc,
     } = needs;
 
@@ -382,6 +413,7 @@ fn create_ingame_and_shadow_pipelines(
         particle_task,
         lod_terrain_task,
         clouds_task,
+        bloom_task,
         postprocess_task,
         // TODO: if these are ever actually optionally done, counting them 
         // as tasks to do beforehand seems kind of iffy since they will just 
@@ -403,7 +435,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.debug_frag,
                     &layouts.global,
                     &layouts.debug,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "debug pipeline creation",
@@ -418,7 +450,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.skybox_vert,
                     &shaders.skybox_frag,
                     &layouts.global,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "skybox pipeline creation",
@@ -434,7 +466,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.figure_frag,
                     &layouts.global,
                     &layouts.figure,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "figure pipeline creation",
@@ -450,7 +482,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.terrain_frag,
                     &layouts.global,
                     &layouts.terrain,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "terrain pipeline creation",
@@ -466,7 +498,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.fluid_frag,
                     &layouts.global,
                     &layouts.terrain,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "fluid pipeline creation",
@@ -483,7 +515,7 @@ fn create_ingame_and_shadow_pipelines(
                     &layouts.global,
                     &layouts.sprite,
                     &layouts.terrain,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "sprite pipeline creation",
@@ -498,7 +530,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.particle_vert,
                     &shaders.particle_frag,
                     &layouts.global,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "particle pipeline creation",
@@ -513,7 +545,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.lod_terrain_vert,
                     &shaders.lod_terrain_frag,
                     &layouts.global,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "lod terrain pipeline creation",
@@ -529,12 +561,36 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.clouds_frag,
                     &layouts.global,
                     &layouts.clouds,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "clouds pipeline creation",
         )
     };
+    // Pipelines for rendering our bloom
+    let create_bloom = || {
+        bloom_task.run(
+            || {
+                match &pipeline_modes.bloom {
+                    BloomMode::Off => None,
+                    BloomMode::On(config) => Some(config),
+                }
+                .map(|bloom_config| {
+                    bloom::BloomPipelines::new(
+                        device,
+                        &shaders.blit_vert,
+                        &shaders.dual_downsample_filtered_frag,
+                        &shaders.dual_downsample_frag,
+                        &shaders.dual_upsample_frag,
+                        wgpu::TextureFormat::Rgba16Float,
+                        &layouts.bloom,
+                        bloom_config,
+                    )
+                })
+            },
+            "bloom pipelines creation",
+        )
+    };
     // Pipeline for rendering our post-processing
     let create_postprocess = || {
         postprocess_task.run(
@@ -584,7 +640,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.point_light_shadows_vert,
                     &layouts.global,
                     &layouts.terrain,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "point shadow pipeline creation",
@@ -599,7 +655,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.light_shadows_directed_vert,
                     &layouts.global,
                     &layouts.terrain,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "terrain directed shadow pipeline creation",
@@ -614,7 +670,7 @@ fn create_ingame_and_shadow_pipelines(
                     &shaders.light_shadows_figure_vert,
                     &layouts.global,
                     &layouts.figure,
-                    mode.aa,
+                    pipeline_modes.aa,
                 )
             },
             "figure directed shadow pipeline creation",
@@ -622,7 +678,7 @@ fn create_ingame_and_shadow_pipelines(
     };
 
     let j1 = || pool.join(create_debug, || pool.join(create_skybox, create_figure));
-    let j2 = || pool.join(create_terrain, create_fluid);
+    let j2 = || pool.join(create_terrain, || pool.join(create_fluid, create_bloom));
     let j3 = || pool.join(create_sprite, create_particle);
     let j4 = || pool.join(create_lod_terrain, create_clouds);
     let j5 = || pool.join(create_postprocess, create_point_shadow);
@@ -636,7 +692,7 @@ fn create_ingame_and_shadow_pipelines(
     // Ignore this
     let (
         (
-            ((debug, (skybox, figure)), (terrain, fluid)),
+            ((debug, (skybox, figure)), (terrain, (fluid, bloom))),
             ((sprite, particle), (lod_terrain, clouds)),
         ),
         ((postprocess, point_shadow), (terrain_directed_shadow, figure_directed_shadow)),
@@ -653,6 +709,7 @@ fn create_ingame_and_shadow_pipelines(
             lod_terrain,
             particle,
             clouds,
+            bloom,
             postprocess,
             skybox,
             sprite,
@@ -675,9 +732,9 @@ fn create_ingame_and_shadow_pipelines(
 /// NOTE: this tries to use all the CPU cores to complete as soon as possible
 pub(super) fn initial_create_pipelines(
     device: Arc<wgpu::Device>,
-    layouts: Arc<Layouts>,
+    layouts: Layouts,
     shaders: Shaders,
-    mode: RenderMode,
+    pipeline_modes: PipelineModes,
     sc_desc: wgpu::SwapChainDescriptor,
     has_shadow_views: bool,
 ) -> Result<
@@ -690,7 +747,7 @@ pub(super) fn initial_create_pipelines(
     prof_span!(_guard, "initial_create_pipelines");
 
     // Process shaders into modules
-    let shader_modules = ShaderModules::new(&device, &shaders, &mode, has_shadow_views)?;
+    let shader_modules = ShaderModules::new(&device, &shaders, &pipeline_modes, has_shadow_views)?;
 
     // Create threadpool for parallel portion
     let pool = rayon::ThreadPoolBuilder::new()
@@ -702,7 +759,7 @@ pub(super) fn initial_create_pipelines(
         device: &device,
         layouts: &layouts,
         shaders: &shader_modules,
-        mode: &mode,
+        pipeline_modes: &pipeline_modes,
         sc_desc: &sc_desc,
     };
 
@@ -729,7 +786,7 @@ pub(super) fn initial_create_pipelines(
             device: &device,
             layouts: &layouts,
             shaders: &shader_modules,
-            mode: &mode,
+            pipeline_modes: &pipeline_modes,
             sc_desc: &sc_desc,
         };
 
@@ -745,14 +802,24 @@ pub(super) fn initial_create_pipelines(
 /// Use this to recreate all the pipelines in the background.
 /// TODO: report progress
 /// NOTE: this tries to use all the CPU cores to complete as soon as possible
+#[allow(clippy::type_complexity)]
 pub(super) fn recreate_pipelines(
     device: Arc<wgpu::Device>,
-    layouts: Arc<Layouts>,
+    immutable_layouts: Arc<ImmutableLayouts>,
     shaders: Shaders,
-    mode: RenderMode,
+    pipeline_modes: PipelineModes,
     sc_desc: wgpu::SwapChainDescriptor,
     has_shadow_views: bool,
-) -> PipelineCreation<Result<(Pipelines, ShadowPipelines), RenderError>> {
+) -> PipelineCreation<
+    Result<
+        (
+            Pipelines,
+            ShadowPipelines,
+            Arc<postprocess::PostProcessLayout>,
+        ),
+        RenderError,
+    >,
+> {
     prof_span!(_guard, "recreate_pipelines");
 
     // Create threadpool for parallel portion
@@ -780,20 +847,32 @@ pub(super) fn recreate_pipelines(
 
         // Process shaders into modules
         let guard = shader_task.start("process shaders");
-        let shader_modules = match ShaderModules::new(&device, &shaders, &mode, has_shadow_views) {
-            Ok(modules) => modules,
-            Err(err) => {
-                result_send.send(Err(err)).expect("Channel disconnected");
-                return;
-            },
-        };
+        let shader_modules =
+            match ShaderModules::new(&device, &shaders, &pipeline_modes, has_shadow_views) {
+                Ok(modules) => modules,
+                Err(err) => {
+                    result_send.send(Err(err)).expect("Channel disconnected");
+                    return;
+                },
+            };
         drop(guard);
 
+        // Create new postprocess layouts
+        let postprocess_layouts = Arc::new(postprocess::PostProcessLayout::new(
+            &device,
+            &pipeline_modes,
+        ));
+
+        let layouts = Layouts {
+            immutable: immutable_layouts,
+            postprocess: postprocess_layouts,
+        };
+
         let needs = PipelineNeeds {
             device: &device,
             layouts: &layouts,
             shaders: &shader_modules,
-            mode: &mode,
+            pipeline_modes: &pipeline_modes,
             sc_desc: &sc_desc,
         };
 
@@ -806,7 +885,11 @@ pub(super) fn recreate_pipelines(
 
         // Send them
         result_send
-            .send(Ok((Pipelines::consolidate(interface, ingame), shadow)))
+            .send(Ok((
+                Pipelines::consolidate(interface, ingame),
+                shadow,
+                layouts.postprocess,
+            )))
             .expect("Channel disconnected");
     });
 
diff --git a/voxygen/src/render/renderer/shaders.rs b/voxygen/src/render/renderer/shaders.rs
index eb3a148a89..2d8f32556c 100644
--- a/voxygen/src/render/renderer/shaders.rs
+++ b/voxygen/src/render/renderer/shaders.rs
@@ -68,6 +68,10 @@ impl assets::Compound for Shaders {
             "lod-terrain-frag",
             "clouds-vert",
             "clouds-frag",
+            "dual-downsample-filtered-frag",
+            "dual-downsample-frag",
+            "dual-upsample-frag",
+            "clouds-frag",
             "postprocess-vert",
             "postprocess-frag",
             "blit-vert",
diff --git a/voxygen/src/scene/figure/mod.rs b/voxygen/src/scene/figure/mod.rs
index 9fac2b5fff..6ec10087d4 100644
--- a/voxygen/src/scene/figure/mod.rs
+++ b/voxygen/src/scene/figure/mod.rs
@@ -518,7 +518,7 @@ impl FigureMgr {
             let ray_direction = scene_data.get_sun_dir();
             let is_daylight = ray_direction.z < 0.0/*0.6*/;
             // Are shadows enabled at all?
-            let can_shadow_sun = renderer.render_mode().shadow.is_map() && is_daylight;
+            let can_shadow_sun = renderer.pipeline_modes().shadow.is_map() && is_daylight;
             let Dependents {
                 proj_mat: _,
                 view_mat: _,
diff --git a/voxygen/src/scene/mod.rs b/voxygen/src/scene/mod.rs
index c7659e18ca..4f6d0af4d5 100644
--- a/voxygen/src/scene/mod.rs
+++ b/voxygen/src/scene/mod.rs
@@ -681,7 +681,7 @@ impl Scene {
 
         let sun_dir = scene_data.get_sun_dir();
         let is_daylight = sun_dir.z < 0.0;
-        if renderer.render_mode().shadow.is_map() && (is_daylight || !lights.is_empty()) {
+        if renderer.pipeline_modes().shadow.is_map() && (is_daylight || !lights.is_empty()) {
             let fov = self.camera.get_fov();
             let aspect_ratio = self.camera.get_aspect_ratio();
 
@@ -1062,7 +1062,7 @@ impl Scene {
         let camera_data = (&self.camera, scene_data.figure_lod_render_distance);
 
         // would instead have this as an extension.
-        if drawer.render_mode().shadow.is_map() && (is_daylight || !self.light_data.is_empty()) {
+        if drawer.pipeline_modes().shadow.is_map() && (is_daylight || !self.light_data.is_empty()) {
             if is_daylight {
                 prof_span!("directed shadows");
                 if let Some(mut shadow_pass) = drawer.shadow_pass() {
diff --git a/voxygen/src/scene/terrain.rs b/voxygen/src/scene/terrain.rs
index dae0847392..31d06afd4e 100644
--- a/voxygen/src/scene/terrain.rs
+++ b/voxygen/src/scene/terrain.rs
@@ -1244,7 +1244,7 @@ impl<V: RectRasterableVol> Terrain<V> {
             return min.partial_cmple(&max).reduce_and();
         };
         let (visible_light_volume, visible_psr_bounds) = if ray_direction.z < 0.0
-            && renderer.render_mode().shadow.is_map()
+            && renderer.pipeline_modes().shadow.is_map()
         {
             let visible_bounding_box = math::Aabb::<f32> {
                 min: math::Vec3::from(visible_bounding_box.min - focus_off),
diff --git a/voxygen/src/session/mod.rs b/voxygen/src/session/mod.rs
index 3e7bda0b77..23c8bed695 100644
--- a/voxygen/src/session/mod.rs
+++ b/voxygen/src/session/mod.rs
@@ -1483,6 +1483,11 @@ impl PlayState for SessionState {
                 second_pass.draw_clouds();
             }
         }
+        // Bloom (call does nothing if bloom is off)
+        {
+            prof_span!("bloom");
+            drawer.run_bloom_passes()
+        }
         // PostProcess and UI
         {
             prof_span!("post-process and ui");
diff --git a/voxygen/src/window.rs b/voxygen/src/window.rs
index 4f2c3da9d4..f29c197fb6 100644
--- a/voxygen/src/window.rs
+++ b/voxygen/src/window.rs
@@ -791,8 +791,7 @@ impl Window {
                 let physical = self.window.inner_size();
 
                 self.renderer
-                    .on_resize(Vec2::new(physical.width, physical.height))
-                    .unwrap();
+                    .on_resize(Vec2::new(physical.width, physical.height));
                 // TODO: update users of this event with the fact that it is now the physical
                 // size
                 let winit::dpi::PhysicalSize { width, height } = physical;