diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -11,6 +11,10 @@ on:
     types: [opened, synchronize, reopened, labeled]
   workflow_dispatch:
     inputs:
+      selection_combining:
+        description: "Compare direct GRAY8 selection combining on 36 MP (Linux, exact outputs)"
+        type: boolean
+        default: false
       selection_morphology:
         description: "Compare selection morphology on 36 MP (Linux, exact outputs)"
         type: boolean
@@ -459,7 +463,7 @@ jobs:
           path: selection-morphology-perf.txt
 
   selection-morphology-quick-perf:
-    if: inputs.selection_morphology == true
+    if: inputs.selection_morphology == true || inputs.selection_combining == true
     # Match perf-nightly.yml: Linux llvmpipe lacks the float render targets needed by
     # bevel_bench, so its required GPU rows cannot be measured there.
     runs-on: macos-15
@@ -481,3 +485,27 @@ jobs:
           path: |
             target/perf/*.json
             target/perf/summary.md
+
+  selection-combining:
+    if: inputs.selection_combining == true
+    runs-on: ubuntu-latest
+    timeout-minutes: 30
+    env:
+      RAYON_NUM_THREADS: "4"
+    steps:
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+      - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+      - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+        with:
+          key: selection-combining-perf
+      - name: Compare exact outputs and release timings on 36 MP
+        run: |
+          set -o pipefail
+          cargo test --release -p photocraft-algo --lib gray8_combining_release_comparison -- --ignored --nocapture | tee selection-combining-perf.txt
+      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+        if: always()
+        with:
+          name: selection-combining-performance
+          path: selection-combining-perf.txt
diff --git a/crates/algo/src/selection.rs b/crates/algo/src/selection.rs
--- a/crates/algo/src/selection.rs
+++ b/crates/algo/src/selection.rs
@@ -134,18 +134,30 @@ pub fn mask_to_surface(m: &[f32], area: Rect) -> Surface {
     surface_from_masks(area, m, None, |_, v| v)
 }
 
+mod combine_mask;
+pub use combine_mask::combine_band;
+
 /// Combines `new` with `old` by `mode`. Returns `None` when nothing is selected.
 pub fn combine(old: Option<&Surface>, new: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
     if area.is_empty() {
         return None;
     }
+    // Replace already writes straight from the input mask to tiles; it needs no old mask.
+    if mode == SelectionMode::Replace || (old.is_none() && mode == SelectionMode::Add) {
+        let s = mask_to_surface(new, area);
+        return (s.tile_count() > 0).then_some(s);
+    }
+    let old = old?;
+    if old.format() == PixelFormat::GRAY8 {
+        return combine_mask::gray8(Some(old), new, area, mode, false);
+    }
     // Quantize to the 8-bit grid selections are stored on, so combining a mask with itself is
     // exact. Below half an 8-bit step is nothing.
     let q = |v: f32| f32::from(coverage8(v)) / 255.0;
     let s = match mode {
         SelectionMode::Replace => surface_from_masks(area, new, None, |_, v| v),
         _ => {
-            let prev = mask_from_surface(old, area);
+            let prev = mask_from_surface(Some(old), area);
             match mode {
                 SelectionMode::Add => surface_from_masks(area, new, Some(&prev), |p, v| p.max(q(v))),
                 SelectionMode::Subtract => surface_from_masks(area, new, Some(&prev), |p, v| (p - q(v)).max(0.0)),
@@ -381,6 +393,9 @@ pub fn combine_region(old: Option<&Surface>, new: Option<&Region>, mode: Selecti
     let empty = Region { bbox: Rect::EMPTY, mask: Vec::new() };
     let new = new.unwrap_or(&empty);
     let b = new.bbox;
+    if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
+        return combine_mask::gray8_bytes(old, &new.mask, b, mode);
+    }
     let prev_u8 = |s: &Surface| -> Vec<u8> {
         let mut v = Vec::new();
         s.read_region_into(b, &mut v);
diff --git a/crates/algo/src/selection/combine_mask.rs b/crates/algo/src/selection/combine_mask.rs
new file mode 100644
--- /dev/null
+++ b/crates/algo/src/selection/combine_mask.rs
@@ -0,0 +1,112 @@
+//! Float mask inputs meet stored GRAY8 selections at the tile boundary. No dense copy of
+//! the previous selection or of the combined result is needed.
+
+use super::{SelectionMode, combine, coverage8};
+use crate::photo_util::par_map;
+use photocraft_color::PixelFormat;
+use photocraft_geom::{Rect, TILE_SIZE, TileCoord};
+use photocraft_raster::{Surface, Tile};
+use std::sync::Arc;
+
+/// Combines a mask covering only `band` (zero elsewhere). Add and Subtract retain the old
+/// selection outside it, including pixels sharing a tile with the band's edges. Replace
+/// and Intersect return only the selection inside the band. Missing mask samples read zero.
+pub fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, mode: SelectionMode) -> Option<Surface> {
+    let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
+    if !keep || old.is_none() {
+        return combine(old, mask, band, mode);
+    }
+    if band.is_empty() {
+        return old.cloned();
+    }
+    if old.is_none_or(|s| s.format() == PixelFormat::GRAY8) {
+        return gray8(old, mask, band, mode, true);
+    }
+    // Preserve the format and conversion behavior of unusual selection surfaces. The normal
+    // GRAY8 path above never materializes these band-sized buffers.
+    let inside = combine(old, mask, band, mode);
+    let mut out = old.cloned()?;
+    let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |s| s.read_region(band));
+    if out.channels() == 1 {
+        out.write_region(band, &values);
+    } else {
+        // A selection uses its first channel as coverage. Broadcast it when writing back to
+        // an unusual multichannel surface, rather than passing a short buffer to write_region.
+        let pixels: Vec<f32> = values.iter().flat_map(|v| std::iter::repeat_n(*v, out.channels())).collect();
+        out.write_region(band, &pixels);
+    }
+    out.prune();
+    (out.tile_count() > 0).then_some(out)
+}
+
+pub(super) fn gray8(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode, keep_outside: bool) -> Option<Surface> {
+    build(old, area, mode, keep_outside, |i| coverage8(mask.get(i).copied().unwrap_or(0.0)))
+}
+
+pub(super) fn gray8_bytes(old: Option<&Surface>, mask: &[u8], area: Rect, mode: SelectionMode) -> Option<Surface> {
+    let keep = matches!(mode, SelectionMode::Add | SelectionMode::Subtract);
+    if area.is_empty() {
+        return if keep { old.cloned().filter(|s| !s.content_bounds().is_empty()) } else { None };
+    }
+    build(old, area, mode, keep, |i| mask.get(i).copied().unwrap_or(0))
+}
+
+fn build(old: Option<&Surface>, area: Rect, mode: SelectionMode, keep_outside: bool, sample: impl Fn(usize) -> u8 + Sync) -> Option<Surface> {
+    let mut out = if keep_outside { old.cloned().unwrap_or_else(|| Surface::new(PixelFormat::GRAY8)) } else { Surface::new(PixelFormat::GRAY8) };
+    let default = old.and_then(|s| s.default_bytes().first()).copied().unwrap_or(0);
+    let out_default = out.default_bytes().first().copied().unwrap_or(0);
+    let (blank, full) = (out.solid_tile(&[0.0]), out.solid_tile(&[1.0]));
+    let untouched = out.solid_tile(&[f32::from(out_default) / 255.0]);
+    let coords: Vec<TileCoord> = area.tiles().collect();
+    let width = area.width() as usize;
+    let ts = TILE_SIZE as usize;
+    let built = par_map(coords.len(), |k| {
+        let tc = *coords.get(k)?;
+        let rect = tc.rect();
+        let cut = rect.intersect(&area);
+        let previous = old.and_then(|s| s.tile(tc));
+        let mut tile = Tile::clone(if keep_outside { previous.unwrap_or(&untouched) } else { &blank });
+        for y in cut.y0..cut.y1 {
+            let offset = y.abs_diff(rect.y0) as usize * ts + cut.x0.abs_diff(rect.x0) as usize;
+            let len = cut.width() as usize;
+            let Some(dst) = tile.bytes_mut().get_mut(offset..offset + len) else { continue };
+            let src = previous.and_then(|t| t.bytes().get(offset..offset + len));
+            let start = (y.abs_diff(area.y0) as usize).checked_mul(width).and_then(|i| i.checked_add(cut.x0.abs_diff(area.x0) as usize));
+            for (i, d) in dst.iter_mut().enumerate() {
+                let new = start.and_then(|j| j.checked_add(i)).map(&sample).unwrap_or(0);
+                let prev = src.and_then(|s| s.get(i)).copied().unwrap_or(default);
+                *d = match mode {
+                    SelectionMode::Replace => new,
+                    SelectionMode::Add => prev.max(new),
+                    SelectionMode::Subtract => prev.saturating_sub(new),
+                    SelectionMode::Intersect => prev.min(new),
+                };
+            }
+        }
+        let (lo, hi) = tile.bytes().iter().fold((u8::MAX, 0u8), |(lo, hi), b| (lo.min(*b), hi.max(*b)));
+        let tile = if lo == out_default && hi == out_default {
+            None
+        } else if previous.is_some_and(|p| p.bytes() == tile.bytes()) {
+            previous.cloned()
+        } else if lo == u8::MAX {
+            Some(Arc::clone(&full))
+        } else {
+            Some(Arc::new(tile))
+        };
+        Some((tc, tile))
+    });
+    for (tc, tile) in built.into_iter().flatten() {
+        match tile {
+            Some(tile) => out.put_tiles([(tc, tile)]),
+            None if keep_outside && out.tile(tc).is_some() => drop(out.take_tiles(tc.rect())),
+            None => {}
+        }
+    }
+    if keep_outside {
+        out.prune();
+    }
+    (out.tile_count() > 0).then_some(out)
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/algo/src/selection/combine_mask/tests.rs b/crates/algo/src/selection/combine_mask/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/algo/src/selection/combine_mask/tests.rs
@@ -0,0 +1,179 @@
+use super::*;
+use crate::selection::{Region, combine_region, mask_from_surface, surface_from_masks};
+use photocraft_color::{ColorMode, SampleType};
+
+const MODES: [SelectionMode; 4] = [SelectionMode::Replace, SelectionMode::Add, SelectionMode::Subtract, SelectionMode::Intersect];
+
+// The pre-refactor float formulas, including quantization before the binary operation.
+fn reference(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
+    if area.is_empty() {
+        return None;
+    }
+    if mode == SelectionMode::Replace {
+        let s = surface_from_masks(area, mask, None, |_, v| v);
+        return (s.tile_count() > 0).then_some(s);
+    }
+    let prev = mask_from_surface(old, area);
+    let s = surface_from_masks(area, mask, Some(&prev), |p, v| {
+        let q = f32::from(coverage8(v)) / 255.0;
+        match mode {
+            SelectionMode::Replace => v,
+            SelectionMode::Add => p.max(q),
+            SelectionMode::Subtract => (p - q).max(0.0),
+            SelectionMode::Intersect => p.min(q),
+        }
+    });
+    (s.tile_count() > 0).then_some(s)
+}
+
+fn reference_band(old: Option<&Surface>, mask: &[f32], area: Rect, mode: SelectionMode) -> Option<Surface> {
+    let inside = reference(old, mask, area, mode);
+    let Some(old) = old.filter(|_| matches!(mode, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
+    if area.is_empty() {
+        return Some(old.clone());
+    }
+    let values = inside.map_or_else(|| vec![0.0; area.width() as usize * area.height() as usize], |s| s.read_region(area));
+    let mut out = old.clone();
+    out.write_region(area, &values);
+    out.prune();
+    (out.tile_count() > 0).then_some(out)
+}
+
+#[test]
+fn every_byte_pair_matches_float_combining() {
+    let area = Rect::new(0, 0, 256, 256);
+    let prev: Vec<f32> = (0..65536).map(|i| (i / 256) as f32 / 255.0).collect();
+    let mask: Vec<f32> = (0..65536).map(|i| (i % 256) as f32 / 255.0).collect();
+    let old = crate::selection::mask_to_surface(&prev, area);
+    for mode in MODES {
+        assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode), "{mode:?}");
+    }
+}
+
+#[test]
+fn cropped_bands_match_dense_writeback_with_defaults_and_short_masks() {
+    let values = [f32::NAN, f32::NEG_INFINITY, -0.2, 0.0, 0.49 / 255.0, 0.5 / 255.0, 0.51 / 255.0, 0.5, 1.0, 1.2, f32::INFINITY];
+    for area in [Rect::new(-259, -2, 259, 3), Rect::new(255, 255, 258, 259), Rect::new(0, 0, 512, 256), Rect::EMPTY] {
+        let n = area.width() as usize * area.height() as usize;
+        let mask: Vec<f32> = (0..n).map(|i| values[i % values.len()]).collect();
+        for default in [0.0, 0.5, 1.0] {
+            let mut old = Surface::with_default(PixelFormat::GRAY8, &[default]);
+            old.fill_rect(Rect::new(-300, -20, 300, 270), &[0.75]);
+            old.write_pixel(1024, 1024, &[1.0]);
+            old.write_pixel(-257, 0, &[0.0]);
+            let snapshot = old.clone();
+            for prev in [None, Some(&old)] {
+                for len in [0, n / 2, n] {
+                    for mode in MODES {
+                        let mask = &mask[..len];
+                        assert_eq!(combine(prev, mask, area, mode), reference(prev, mask, area, mode), "{area:?} {mode:?} {default} {len}");
+                        assert_eq!(combine_band(prev, mask, area, mode), reference_band(prev, mask, area, mode), "band {area:?} {mode:?} {default} {len}");
+                    }
+                }
+            }
+            assert_eq!(old, snapshot, "combining must leave undo snapshots unchanged");
+        }
+    }
+}
+
+#[test]
+fn unusual_selection_formats_keep_their_float_behavior() {
+    let area = Rect::new(-2, -1, 3, 2);
+    for sample in SampleType::ALL {
+        for color in [ColorMode::Grayscale, ColorMode::Rgb] {
+            let mut old = Surface::with_default(PixelFormat::new(color, sample, false), &vec![0.123; if color == ColorMode::Rgb { 3 } else { 1 }]);
+            old.write_pixel(0, 0, &vec![0.754; old.channels()]);
+            for mode in MODES {
+                let mask = [0.5; 15];
+                assert_eq!(combine(Some(&old), &mask, area, mode), reference(Some(&old), &mask, area, mode));
+                if color == ColorMode::Grayscale {
+                    assert_eq!(combine_band(Some(&old), &mask, area, mode), reference_band(Some(&old), &mask, area, mode));
+                } else {
+                    let out = combine_band(Some(&old), &mask, area, mode).unwrap();
+                    let inside = reference(Some(&old), &mask, area, mode).unwrap();
+                    let expected = inside.pixel(0, 0)[0];
+                    let encoded = Surface::with_default(out.format(), &vec![expected; out.channels()]);
+                    assert_eq!(out.pixel(0, 0), encoded.pixel(0, 0));
+                    if matches!(mode, SelectionMode::Add | SelectionMode::Subtract) {
+                        assert_eq!(out.pixel(20, 20), old.pixel(20, 20));
+                    }
+                }
+            }
+        }
+    }
+}
+
+#[test]
+fn region_bytes_match_float_bands() {
+    let bbox = Rect::new(-257, 255, 258, 258);
+    let mask: Vec<u8> = (0..bbox.width() * bbox.height()).map(|i| (i % 256) as u8).collect();
+    let floats: Vec<f32> = mask.iter().map(|b| f32::from(*b) / 255.0).collect();
+    let region = Region { bbox, mask };
+    let mut old = Surface::new(PixelFormat::GRAY8);
+    old.fill_rect(Rect::new(-300, 250, 300, 270), &[0.75]);
+    for prev in [None, Some(&old)] {
+        for mode in MODES {
+            assert_eq!(combine_region(prev, Some(&region), mode), reference_band(prev, &floats, bbox, mode));
+            assert_eq!(combine_region(prev, None, mode), reference_band(prev, &[], Rect::EMPTY, mode));
+        }
+    }
+}
+
+#[test]
+fn tiles_stay_shared_or_are_pruned() {
+    let area = Rect::new(0, 0, 768, 256);
+    let mut old = Surface::new(PixelFormat::GRAY8);
+    old.fill_rect(area, &[1.0]);
+    let tc = TileCoord::new(0, 0);
+    let unchanged = combine(Some(&old), &[], area, SelectionMode::Add).unwrap();
+    assert!(Arc::ptr_eq(unchanged.tile(tc).unwrap(), old.tile(tc).unwrap()));
+    let band = Rect::new(257, 1, 260, 3);
+    let changed = combine_band(Some(&old), &[1.0; 6], band, SelectionMode::Subtract).unwrap();
+    assert!(Arc::ptr_eq(changed.tile(tc).unwrap(), old.tile(tc).unwrap()));
+    assert_eq!(changed.pixel(256, 1), vec![1.0]);
+    assert_eq!(changed.pixel(257, 1), vec![0.0]);
+    assert_eq!(changed.pixel(260, 1), vec![1.0]);
+    let full = combine(None, &vec![1.0; 768 * 256], area, SelectionMode::Replace).unwrap();
+    assert!(Arc::ptr_eq(full.tile(tc).unwrap(), full.tile(TileCoord::new(1, 0)).unwrap()));
+    assert!(combine_band(Some(&old), &vec![1.0; 768 * 256], area, SelectionMode::Subtract).is_none());
+    assert!(combine(None, &[], area, SelectionMode::Replace).is_none());
+}
+
+/// Run only in release on CI: compares the former implementation, including dense reads and
+/// band writeback, against the byte path with identical 36 MP inputs and exact output checks.
+#[test]
+#[ignore = "36 MP release performance comparison; run on GitHub Actions"]
+fn gray8_combining_release_comparison() {
+    use std::{hint::black_box, time::Instant};
+    let area = Rect::new(0, 0, 6000, 6000);
+    let mut old = Surface::new(PixelFormat::GRAY8);
+    old.fill_rect(Rect::new(100, 100, 5900, 5900), &[0.75]);
+    println!("36 MP; median of three release runs including allocation; exact output checks; previous dense mask alone = 144000000 bytes");
+    for (case, size) in [("sparse", 50), ("dense", 4000), ("full", 6000)] {
+        let mut mask = vec![0.0; 6000 * 6000];
+        let start = (6000 - size) / 2;
+        for row in mask.as_chunks_mut::<6000>().0.iter_mut().skip(start).take(size) {
+            row[start..start + size].fill(0.5);
+        }
+        for mode in MODES {
+            for band in [false, true] {
+                let baseline = if band { reference_band } else { reference };
+                let direct = if band { combine_band } else { combine };
+                assert_eq!(direct(Some(&old), &mask, area, mode), baseline(Some(&old), &mask, area, mode));
+                let mut medians = [0.0; 2];
+                for (median, op) in medians.iter_mut().zip([baseline, direct]) {
+                    let mut runs = [0.0; 3];
+                    for ms in &mut runs {
+                        let start = Instant::now();
+                        let out = black_box(op(Some(black_box(&old)), black_box(&mask), area, mode));
+                        *ms = start.elapsed().as_secs_f64() * 1000.0;
+                        drop(out);
+                    }
+                    runs.sort_by(f64::total_cmp);
+                    *median = runs[1];
+                }
+                println!("{case} {mode:?} band={band}: old={:.2}ms new={:.2}ms speedup={:.2}x exact=true", medians[0], medians[1], medians[0] / medians[1]);
+            }
+        }
+    }
+}
diff --git a/crates/engine/src/cmd/selection_cmds.rs b/crates/engine/src/cmd/selection_cmds.rs
--- a/crates/engine/src/cmd/selection_cmds.rs
+++ b/crates/engine/src/cmd/selection_cmds.rs
@@ -656,16 +656,7 @@ fn polygon_rows(pts: &[(f32, f32)], area: Rect) -> Rect {
 /// whole-canvas combine gives: Replace and Intersect are empty outside the band, and Add and
 /// Subtract keep the old selection there.
 pub(crate) fn combine_band(old: Option<&Surface>, mask: &[f32], band: Rect, m: SelectionMode) -> Option<Surface> {
-    let inside = sel::combine(old, mask, band, m);
-    let Some(old) = old.filter(|_| matches!(m, SelectionMode::Add | SelectionMode::Subtract)) else { return inside };
-    if band.is_empty() {
-        return Some(old.clone());
-    }
-    let values = inside.map_or_else(|| vec![0.0; band.width() as usize * band.height() as usize], |w| w.read_region(band));
-    let mut s = old.clone();
-    s.write_region(band, &values);
-    s.prune();
-    (s.tile_count() > 0).then_some(s)
+    sel::combine_band(old, mask, band, m)
 }
 
 /// Keep dense feather masks local to the drawn shape. Clip before inflating: pixels outside
