diff --git a/Sources/iDither/Resources/Shaders.metal b/Sources/iDither/Resources/Shaders.metal index 89ba601..838ebee 100644 --- a/Sources/iDither/Resources/Shaders.metal +++ b/Sources/iDither/Resources/Shaders.metal @@ -5,10 +5,24 @@ struct RenderParameters { float brightness; float contrast; float pixelScale; - int algorithm; // 0: None, 1: Bayer 8x8, 2: Bayer 4x4 + int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise int isGrayscale; }; +// Bayer 2x2 Matrix +constant float bayer2x2[2][2] = { + {0.0/4.0, 2.0/4.0}, + {3.0/4.0, 1.0/4.0} +}; + +// Bayer 4x4 Matrix +constant float bayer4x4[4][4] = { + { 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0 }, + {12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0 }, + { 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0 }, + {15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 } +}; + // Bayer 8x8 Matrix constant float bayer8x8[8][8] = { { 0.0/64.0, 32.0/64.0, 8.0/64.0, 40.0/64.0, 2.0/64.0, 34.0/64.0, 10.0/64.0, 42.0/64.0 }, @@ -21,12 +35,36 @@ constant float bayer8x8[8][8] = { {63.0/64.0, 31.0/64.0, 55.0/64.0, 23.0/64.0, 61.0/64.0, 29.0/64.0, 53.0/64.0, 21.0/64.0 } }; -// Bayer 4x4 Matrix -constant float bayer4x4[4][4] = { - { 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0 }, - {12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0 }, - { 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0 }, - {15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 } +// Cluster 4x4 Matrix +constant float cluster4x4[4][4] = { + {12.0/16.0, 5.0/16.0, 6.0/16.0, 13.0/16.0}, + { 4.0/16.0, 0.0/16.0, 1.0/16.0, 7.0/16.0}, + {11.0/16.0, 3.0/16.0, 2.0/16.0, 8.0/16.0}, + {15.0/16.0, 10.0/16.0, 9.0/16.0, 14.0/16.0} +}; + +// Cluster 8x8 Matrix +constant float cluster8x8[8][8] = { + {24.0/64.0, 10.0/64.0, 12.0/64.0, 26.0/64.0, 35.0/64.0, 47.0/64.0, 49.0/64.0, 37.0/64.0}, + { 8.0/64.0, 0.0/64.0, 2.0/64.0, 14.0/64.0, 45.0/64.0, 59.0/64.0, 61.0/64.0, 51.0/64.0}, + {22.0/64.0, 6.0/64.0, 4.0/64.0, 20.0/64.0, 43.0/64.0, 57.0/64.0, 63.0/64.0, 53.0/64.0}, + {30.0/64.0, 18.0/64.0, 16.0/64.0, 28.0/64.0, 33.0/64.0, 41.0/64.0, 55.0/64.0, 39.0/64.0}, + {34.0/64.0, 46.0/64.0, 48.0/64.0, 36.0/64.0, 25.0/64.0, 11.0/64.0, 13.0/64.0, 27.0/64.0}, + {44.0/64.0, 58.0/64.0, 60.0/64.0, 50.0/64.0, 9.0/64.0, 1.0/64.0, 3.0/64.0, 15.0/64.0}, + {42.0/64.0, 56.0/64.0, 62.0/64.0, 52.0/64.0, 23.0/64.0, 7.0/64.0, 5.0/64.0, 21.0/64.0}, + {32.0/64.0, 40.0/64.0, 54.0/64.0, 38.0/64.0, 31.0/64.0, 19.0/64.0, 17.0/64.0, 29.0/64.0} +}; + +// Blue Noise 8x8 (Approx) +constant float blueNoise8x8[8][8] = { + {52.0/64.0, 21.0/64.0, 58.0/64.0, 10.0/64.0, 45.0/64.0, 33.0/64.0, 56.0/64.0, 17.0/64.0}, + { 4.0/64.0, 38.0/64.0, 28.0/64.0, 51.0/64.0, 5.0/64.0, 22.0/64.0, 40.0/64.0, 62.0/64.0}, + {61.0/64.0, 12.0/64.0, 48.0/64.0, 14.0/64.0, 55.0/64.0, 36.0/64.0, 7.0/64.0, 31.0/64.0}, + {32.0/64.0, 43.0/64.0, 2.0/64.0, 46.0/64.0, 25.0/64.0, 63.0/64.0, 19.0/64.0, 50.0/64.0}, + {16.0/64.0, 53.0/64.0, 23.0/64.0, 60.0/64.0, 9.0/64.0, 47.0/64.0, 29.0/64.0, 6.0/64.0}, + {44.0/64.0, 27.0/64.0, 39.0/64.0, 34.0/64.0, 54.0/64.0, 13.0/64.0, 59.0/64.0, 26.0/64.0}, + { 8.0/64.0, 57.0/64.0, 18.0/64.0, 1.0/64.0, 42.0/64.0, 30.0/64.0, 3.0/64.0, 49.0/64.0}, + {35.0/64.0, 24.0/64.0, 0.0/64.0, 41.0/64.0, 15.0/64.0, 52.0/64.0, 20.0/64.0, 37.0/64.0} }; kernel void ditherShader(texture2d inputTexture [[texture(0)]], @@ -38,22 +76,21 @@ kernel void ditherShader(texture2d inputTexture [[texture(0 return; } - // 1. Pixelation (Downsampling) + // 1. Pixelation float scale = max(1.0, params.pixelScale); uint2 sourceCoord = uint2(floor(float(gid.x) / scale) * scale, floor(float(gid.y) / scale) * scale); - // Clamp to texture bounds sourceCoord.x = min(sourceCoord.x, inputTexture.get_width() - 1); sourceCoord.y = min(sourceCoord.y, inputTexture.get_height() - 1); float4 color = inputTexture.read(sourceCoord); - // 2. Color Adjustment (Brightness & Contrast) + // 2. Color Adjustment float3 rgb = color.rgb; rgb = rgb + params.brightness; rgb = (rgb - 0.5) * params.contrast + 0.5; - // Grayscale conversion (Luma) + // Grayscale float luma = dot(rgb, float3(0.299, 0.587, 0.114)); if (params.isGrayscale > 0) { @@ -61,24 +98,46 @@ kernel void ditherShader(texture2d inputTexture [[texture(0 } // 3. Dithering - if (params.algorithm == 1) { // Bayer 8x8 - // Map current pixel to matrix coordinates - // We use the original gid (screen coordinates) for the matrix pattern to keep it stable across pixelation blocks? - // OR we use the sourceCoord (pixelated coordinates) to make the dither pattern scale with the pixels? - // Usually, dither is applied at screen resolution, but for "retro pixel art" look, the dither pattern usually matches the "big pixel" size. - // Let's try using the scaled coordinate index: sourceCoord / scale + float threshold = 0.5; + bool shouldDither = (params.algorithm > 0); + + if (shouldDither) { + uint x, y; - uint x = uint(sourceCoord.x / scale) % 8; - uint y = uint(sourceCoord.y / scale) % 8; - float threshold = bayer8x8[y][x]; - - // Apply threshold - rgb = (luma > threshold) ? float3(1.0) : float3(0.0); - - } else if (params.algorithm == 2) { // Bayer 4x4 - uint x = uint(sourceCoord.x / scale) % 4; - uint y = uint(sourceCoord.y / scale) % 4; - float threshold = bayer4x4[y][x]; + switch (params.algorithm) { + case 1: // Bayer 2x2 + x = uint(sourceCoord.x / scale) % 2; + y = uint(sourceCoord.y / scale) % 2; + threshold = bayer2x2[y][x]; + break; + case 2: // Bayer 4x4 + x = uint(sourceCoord.x / scale) % 4; + y = uint(sourceCoord.y / scale) % 4; + threshold = bayer4x4[y][x]; + break; + case 3: // Bayer 8x8 + x = uint(sourceCoord.x / scale) % 8; + y = uint(sourceCoord.y / scale) % 8; + threshold = bayer8x8[y][x]; + break; + case 4: // Cluster 4x4 + x = uint(sourceCoord.x / scale) % 4; + y = uint(sourceCoord.y / scale) % 4; + threshold = cluster4x4[y][x]; + break; + case 5: // Cluster 8x8 + x = uint(sourceCoord.x / scale) % 8; + y = uint(sourceCoord.y / scale) % 8; + threshold = cluster8x8[y][x]; + break; + case 6: // Blue Noise 8x8 + x = uint(sourceCoord.x / scale) % 8; + y = uint(sourceCoord.y / scale) % 8; + threshold = blueNoise8x8[y][x]; + break; + default: + break; + } rgb = (luma > threshold) ? float3(1.0) : float3(0.0); } diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift index 73d666b..f57b4d0 100644 --- a/Sources/iDither/ViewModel/DitherViewModel.swift +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -4,16 +4,24 @@ import ImageIO enum DitherAlgorithm: Int, CaseIterable, Identifiable { case noDither = 0 - case bayer8x8 = 1 + case bayer2x2 = 1 case bayer4x4 = 2 + case bayer8x8 = 3 + case cluster4x4 = 4 + case cluster8x8 = 5 + case blueNoise = 6 var id: Int { rawValue } var name: String { switch self { case .noDither: return "No Dither" - case .bayer8x8: return "Bayer 8x8" - case .bayer4x4: return "Bayer 4x4" + case .bayer2x2: return "Bayer 2x2 (Retro)" + case .bayer4x4: return "Bayer 4x4 (Balanced)" + case .bayer8x8: return "Bayer 8x8 (Smooth)" + case .cluster4x4: return "Cluster 4x4 (Vintage)" + case .cluster8x8: return "Cluster 8x8 (Soft)" + case .blueNoise: return "Blue Noise / Organic (Best Quality)" } } } @@ -29,7 +37,7 @@ class DitherViewModel { var brightness: Double = 0.0 var contrast: Double = 1.0 var pixelScale: Double = 4.0 - var selectedAlgorithm: DitherAlgorithm = .bayer8x8 + var selectedAlgorithm: DitherAlgorithm = .bayer4x4 // Default to Balanced var isGrayscale: Bool = false private let renderer = MetalImageRenderer()