V0.1.4, added Floyd Steinberg algorithm and revisited UI
This commit is contained in:
parent
d2b78668c3
commit
cddbbec233
|
|
@ -9,7 +9,7 @@ struct ContentView: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
|
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
|
||||||
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
|
||||||
} detail: {
|
} detail: {
|
||||||
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
||||||
}
|
}
|
||||||
|
|
@ -100,66 +100,115 @@ struct SidebarView: View {
|
||||||
@Binding var isExporting: Bool
|
@Binding var isExporting: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
ScrollView {
|
||||||
Section("Dithering Algorithm") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
||||||
|
// --- ALGORITHM SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("ALGORITHM")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
|
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
|
||||||
ForEach(DitherAlgorithm.allCases) { algo in
|
ForEach(DitherAlgorithm.allCases) { algo in
|
||||||
Text(algo.name).tag(algo)
|
Text(algo.name).tag(algo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.labelsHidden() // Native look: just the dropdown
|
||||||
|
|
||||||
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Pre-Processing") {
|
Divider()
|
||||||
VStack(alignment: .leading) {
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- PRE-PROCESSING SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("PRE-PROCESSING")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Brightness slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Brightness")
|
Text("Brightness")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.2f", viewModel.brightness))
|
Text(String(format: "%.2f", viewModel.brightness))
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.brightness, in: -1.0...1.0)
|
Slider(value: $viewModel.brightness, in: -1.0...1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
// Contrast slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Contrast")
|
Text("Contrast")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.2f", viewModel.contrast))
|
Text(String(format: "%.2f", viewModel.contrast))
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.contrast, in: 0.0...4.0)
|
Slider(value: $viewModel.contrast, in: 0.0...4.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
// Pixel Scale slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Pixel Scale")
|
Text("Pixel Scale")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(viewModel.pixelScale))x")
|
Text("\(Int(viewModel.pixelScale))x")
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
|
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- QUANTIZATION SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("QUANTIZATION")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Color Depth")
|
Text("Color Depth")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(viewModel.colorDepth))")
|
Text("\(Int(viewModel.colorDepth))")
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
|
Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.padding(20)
|
||||||
.padding(.vertical)
|
}
|
||||||
|
.background(.regularMaterial) // Native material background
|
||||||
|
.ignoresSafeArea(edges: .top) // Fix for titlebar gap
|
||||||
.navigationTitle("iDither")
|
.navigationTitle("iDither")
|
||||||
|
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Button(action: { isImporting = true }) {
|
Button(action: { isImporting = true }) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ final class MetalImageRenderer: Sendable {
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
private let commandQueue: MTLCommandQueue
|
private let commandQueue: MTLCommandQueue
|
||||||
private let pipelineState: MTLComputePipelineState
|
private let pipelineState: MTLComputePipelineState
|
||||||
|
private let pipelineStateFS_Pass1: MTLComputePipelineState?
|
||||||
|
private let pipelineStateFS_Pass2: MTLComputePipelineState?
|
||||||
|
|
||||||
init?() {
|
init?() {
|
||||||
guard let device = MTLCreateSystemDefaultDevice(),
|
guard let device = MTLCreateSystemDefaultDevice(),
|
||||||
|
|
@ -29,6 +31,15 @@ final class MetalImageRenderer: Sendable {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.pipelineState = try device.makeComputePipelineState(function: function)
|
self.pipelineState = try device.makeComputePipelineState(function: function)
|
||||||
|
// Load FS Kernels
|
||||||
|
if let f1 = library.makeFunction(name: "ditherShaderFS_Pass1"),
|
||||||
|
let f2 = library.makeFunction(name: "ditherShaderFS_Pass2") {
|
||||||
|
self.pipelineStateFS_Pass1 = try device.makeComputePipelineState(function: f1)
|
||||||
|
self.pipelineStateFS_Pass2 = try device.makeComputePipelineState(function: f2)
|
||||||
|
} else {
|
||||||
|
self.pipelineStateFS_Pass1 = nil
|
||||||
|
self.pipelineStateFS_Pass2 = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create pipeline state: \(error)")
|
print("Failed to create pipeline state: \(error)")
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -60,11 +71,53 @@ final class MetalImageRenderer: Sendable {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var params = params
|
||||||
|
|
||||||
|
if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
|
||||||
|
// FLOYD-STEINBERG MULTI-PASS
|
||||||
|
|
||||||
|
// Create Error Texture (Float16 or Float32 for precision)
|
||||||
|
let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
|
||||||
|
width: inputTexture.width,
|
||||||
|
height: inputTexture.height,
|
||||||
|
mipmapped: false)
|
||||||
|
errorDesc.usage = [.shaderWrite, .shaderRead]
|
||||||
|
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
|
||||||
|
computeEncoder.endEncoding()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 1: Even Rows
|
||||||
|
computeEncoder.setComputePipelineState(pipe1)
|
||||||
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
computeEncoder.setTexture(errorTexture, index: 2)
|
||||||
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
|
// Dispatch (1, H/2, 1) -> Each thread handles one full row
|
||||||
|
let h = (inputTexture.height + 1) / 2
|
||||||
|
let threadsPerGrid = MTLSizeMake(1, h, 1)
|
||||||
|
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
|
||||||
|
|
||||||
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
|
||||||
|
// Memory Barrier (Ensure Pass 1 writes are visible to Pass 2)
|
||||||
|
computeEncoder.memoryBarrier(scope: .textures)
|
||||||
|
|
||||||
|
// PASS 2: Odd Rows
|
||||||
|
computeEncoder.setComputePipelineState(pipe2)
|
||||||
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
computeEncoder.setTexture(errorTexture, index: 2)
|
||||||
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// STANDARD ALGORITHMS
|
||||||
computeEncoder.setComputePipelineState(pipelineState)
|
computeEncoder.setComputePipelineState(pipelineState)
|
||||||
computeEncoder.setTexture(inputTexture, index: 0)
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
computeEncoder.setTexture(outputTexture, index: 1)
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
|
||||||
var params = params
|
|
||||||
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
let w = pipelineState.threadExecutionWidth
|
let w = pipelineState.threadExecutionWidth
|
||||||
|
|
@ -73,13 +126,13 @@ final class MetalImageRenderer: Sendable {
|
||||||
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
|
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
|
||||||
|
|
||||||
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
}
|
||||||
|
|
||||||
computeEncoder.endEncoding()
|
computeEncoder.endEncoding()
|
||||||
|
|
||||||
commandBuffer.commit()
|
commandBuffer.commit()
|
||||||
commandBuffer.waitUntilCompleted()
|
commandBuffer.waitUntilCompleted()
|
||||||
|
|
||||||
// Convert back to CGImage (for simplicity in this iteration, though MTKView is better for display)
|
|
||||||
// We will use a helper to convert MTLTexture to CGImage
|
|
||||||
return createCGImage(from: outputTexture)
|
return createCGImage(from: outputTexture)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -165,5 +165,211 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
outputTexture.write(float4(rgb, color.a), gid);
|
outputTexture.write(float4(rgb, color.a), gid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// FLOYD-STEINBERG ERROR DIFFUSION HELPERS & KERNELS (Algorithm ID 7)
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
|
// Helper to get luminance for error calculation in grayscale mode
|
||||||
|
float getLuma(float3 rgb) {
|
||||||
|
return dot(rgb, float3(0.299, 0.587, 0.114));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 1: EVEN ROWS (Left -> Right)
|
||||||
|
// - Reads original pixel
|
||||||
|
// - Dithers it
|
||||||
|
// - Writes result to outputTexture
|
||||||
|
// - Writes RAW error 'diff' to errorTexture (at current coord) for Pass 2 to consume
|
||||||
|
kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
|
texture2d<float, access::write> errorTexture [[texture(2)]],
|
||||||
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
|
// Dispatch: (1, height/2, 1). Each thread processes one FULL ROW.
|
||||||
|
uint y = gid.y * 2; // Pass 1 processes EVEN rows: 0, 2, 4...
|
||||||
|
|
||||||
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
|
uint width = inputTexture.get_width();
|
||||||
|
float3 currentError = float3(0.0); // Error propagated from immediate Left neighbor
|
||||||
|
|
||||||
|
// Scale handling (minimal implementation for now, usually FS runs 1:1)
|
||||||
|
// If pixel scale > 1, FS behaves weirdly unless we downsample/upsample.
|
||||||
|
// For now, let's treat FS as operating on the native coordinates (or scaled ones).
|
||||||
|
// The previous shader code did manual pixelation.
|
||||||
|
// To support `pixelScale`, we simply use the scaled coordinates for reading input,
|
||||||
|
// but we iterate 1:1 on output? No, if we pixelate, we want blocky dither?
|
||||||
|
// FS is hard to 'blocky' dither without pre-scaling.
|
||||||
|
// Let's stick to 1:1 processing for the error diffusion logic itself.
|
||||||
|
// But we read the input color from the "pixelated" coordinate.
|
||||||
|
|
||||||
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
|
for (uint x = 0; x < width; x++) {
|
||||||
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
|
// Pixelate Input Read
|
||||||
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||||
|
float3 originalColor = colorRaw.rgb;
|
||||||
|
|
||||||
|
// Color Adjust
|
||||||
|
originalColor = originalColor + params.brightness;
|
||||||
|
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||||
|
|
||||||
|
// Grayscale
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
float l = getLuma(originalColor);
|
||||||
|
originalColor = float3(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------
|
||||||
|
// ERROR DIFFUSION CORE
|
||||||
|
// ----------------------------------------------------
|
||||||
|
|
||||||
|
// Add error from Left Neighbor (Pass 1 is L->R)
|
||||||
|
float3 pixelIn = originalColor + currentError;
|
||||||
|
|
||||||
|
// Quantize
|
||||||
|
float3 pixelOut = float3(0.0);
|
||||||
|
float levels = max(1.0, params.colorDepth); // Ensure no div by zero
|
||||||
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
||||||
|
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
|
||||||
|
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Calculate Error
|
||||||
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
|
||||||
|
// Store RAW error for Pass 2 (Row below) to read
|
||||||
|
// Note: we store 'diff', NOT the distributed parts. Pass 2 will calculate distribution.
|
||||||
|
if (y + 1 < inputTexture.get_height()) {
|
||||||
|
errorTexture.write(float4(diff, 1.0), coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
|
// Propagate to Right Neighbor (7/16)
|
||||||
|
currentError = diff * (7.0 / 16.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 2: ODD ROWS (Right -> Left Serpentine)
|
||||||
|
// - Reads original pixel
|
||||||
|
// - Absorbs error from Row Above (which stored RAW diffs)
|
||||||
|
// - Dithers
|
||||||
|
// - Writes result
|
||||||
|
kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
|
texture2d<float, access::read> errorTexture [[texture(2)]], // Contains diffs from Pass 1
|
||||||
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
|
// Dispatch: (1, height/2, 1)
|
||||||
|
uint y = gid.y * 2 + 1; // Pass 2 processes ODD rows: 1, 3, 5...
|
||||||
|
|
||||||
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
|
uint width = inputTexture.get_width();
|
||||||
|
float3 currentError = float3(0.0); // Error propagated from immediate Right neighbor (Serpentine R->L)
|
||||||
|
|
||||||
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
|
// Serpentine: Iterate Right to Left
|
||||||
|
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
|
||||||
|
uint x = uint(x_int);
|
||||||
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
|
// 1. Calculate Incoming Error from Row Above (Even Row, L->R)
|
||||||
|
// Row Above is y-1. We are at x.
|
||||||
|
// Even Row (y-1) propagated error to us (y) via:
|
||||||
|
// - (x-1, y-1) sent 3/16 (Bottom Left) -> reaches ME at x if I am (x-1+1) = x. Correct.
|
||||||
|
// - (x, y-1) sent 5/16 (Down) -> reaches ME at x. Correct.
|
||||||
|
// - (x+1, y-1) sent 1/16 (Bottom Right)-> reaches ME at x. Correct.
|
||||||
|
|
||||||
|
float3 errorFromAbove = float3(0.0);
|
||||||
|
uint prevY = y - 1;
|
||||||
|
|
||||||
|
// Read neighbor errors (and apply weights now)
|
||||||
|
|
||||||
|
// From Top-Left (x-1, y-1): It pushed 3/16 to Bottom-Right (x) ? No.
|
||||||
|
// Standard FS (Left->Right scan):
|
||||||
|
// P(x, y) distributes:
|
||||||
|
// Right (x+1, y): 7/16
|
||||||
|
// Bottom-Left (x-1, y+1): 3/16
|
||||||
|
// Bottom (x, y+1): 5/16
|
||||||
|
// Bottom-Right (x+1, y+1): 1/16
|
||||||
|
|
||||||
|
// So, ME (x, y) receives from:
|
||||||
|
// (x+1, y-1) [Top Right]: sent 3/16 to its Bottom Left (which is ME).
|
||||||
|
// (x, y-1) [Top]: sent 5/16 to its Bottom (which is ME).
|
||||||
|
// (x-1, y-1) [Top Left]: sent 1/16 to its Bottom Right (which is ME).
|
||||||
|
|
||||||
|
// Read Top Right (x+1, prevY)
|
||||||
|
if (x + 1 < width) {
|
||||||
|
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
|
||||||
|
errorFromAbove += e * (3.0 / 16.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Top (x, prevY)
|
||||||
|
{
|
||||||
|
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
|
||||||
|
errorFromAbove += e * (5.0 / 16.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read Top Left (x-1, prevY)
|
||||||
|
if (x >= 1) {
|
||||||
|
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
|
||||||
|
errorFromAbove += e * (1.0 / 16.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read Pixel
|
||||||
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||||
|
float3 originalColor = colorRaw.rgb;
|
||||||
|
originalColor = originalColor + params.brightness;
|
||||||
|
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||||
|
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
float l = getLuma(originalColor);
|
||||||
|
originalColor = float3(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Combine
|
||||||
|
float3 pixelIn = originalColor + currentError + errorFromAbove;
|
||||||
|
|
||||||
|
// 4. Quantize
|
||||||
|
float3 pixelOut = float3(0.0);
|
||||||
|
float levels = max(1.0, params.colorDepth);
|
||||||
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
||||||
|
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||||
|
|
||||||
|
// 5. Diff
|
||||||
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
|
||||||
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
|
// 6. Propagate Horizontally (Serpentine R->L)
|
||||||
|
// In R->L scan, 'Right' neighbor in FS diagram is actually 'Left' neighbor in spatial.
|
||||||
|
// We push 7/16 to the next pixel we visit (x-1).
|
||||||
|
currentError = diff * (7.0 / 16.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case cluster4x4 = 4
|
case cluster4x4 = 4
|
||||||
case cluster8x8 = 5
|
case cluster8x8 = 5
|
||||||
case blueNoise = 6
|
case blueNoise = 6
|
||||||
|
case floydSteinberg = 7
|
||||||
|
|
||||||
var id: Int { rawValue }
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
|
@ -22,6 +23,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case .cluster4x4: return "Cluster 4x4 (Vintage)"
|
case .cluster4x4: return "Cluster 4x4 (Vintage)"
|
||||||
case .cluster8x8: return "Cluster 8x8 (Soft)"
|
case .cluster8x8: return "Cluster 8x8 (Soft)"
|
||||||
case .blueNoise: return "Blue Noise / Organic (Best Quality)"
|
case .blueNoise: return "Blue Noise / Organic (Best Quality)"
|
||||||
|
case .floydSteinberg: return "Floyd-Steinberg (Error Diffusion)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ struct iDitherApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
.windowToolbarStyle(.unified)
|
.windowToolbarStyle(.unified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue