V0.1.4.1 Added chaos effect
This commit is contained in:
parent
cddbbec233
commit
a470a232e8
|
|
@ -4,11 +4,18 @@ import UniformTypeIdentifiers
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var viewModel = DitherViewModel()
|
@State private var viewModel = DitherViewModel()
|
||||||
@State private var isImporting = false
|
@State private var isImporting = false
|
||||||
@State private var isExporting = false
|
|
||||||
|
// Export State
|
||||||
|
@State private var showExportOptionsSheet = false
|
||||||
|
@State private var exportFormat: ExportFormat = .png
|
||||||
|
@State private var exportScale: CGFloat = 1.0
|
||||||
|
@State private var jpegQuality: Double = 0.85
|
||||||
|
@State private var preserveMetadata = true
|
||||||
|
@State private var flattenTransparency = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
|
SidebarView(viewModel: viewModel, isImporting: $isImporting, showExportOptions: $showExportOptionsSheet)
|
||||||
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
|
||||||
} detail: {
|
} detail: {
|
||||||
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
||||||
|
|
@ -23,6 +30,8 @@ struct ContentView: View {
|
||||||
.onChange(of: viewModel.colorDepth) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.colorDepth) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
|
||||||
|
// CHAOS / FX PARAMETERS (Grouped in modifier)
|
||||||
|
.onChaosChange(viewModel: viewModel)
|
||||||
// File Importer at the very top level
|
// File Importer at the very top level
|
||||||
.fileImporter(
|
.fileImporter(
|
||||||
isPresented: $isImporting,
|
isPresented: $isImporting,
|
||||||
|
|
@ -38,14 +47,84 @@ struct ContentView: View {
|
||||||
print("Import failed: \(error.localizedDescription)")
|
print("Import failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fileExporter(
|
// Export Options Sheet
|
||||||
isPresented: $isExporting,
|
.sheet(isPresented: $showExportOptionsSheet) {
|
||||||
document: ImageDocument(image: viewModel.processedImage),
|
NavigationStack {
|
||||||
contentType: .png,
|
Form {
|
||||||
defaultFilename: "dithered_image"
|
// SECTION 1: Format
|
||||||
) { result in
|
Section("Format") {
|
||||||
if case .failure(let error) = result {
|
Picker("Format", selection: $exportFormat) {
|
||||||
print("Export failed: \(error.localizedDescription)")
|
ForEach(ExportFormat.allCases) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
// Show quality slider ONLY for JPEG
|
||||||
|
if exportFormat == .jpeg {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Quality")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(jpegQuality * 100))%")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
Slider(value: $jpegQuality, in: 0.1...1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 2: Resolution
|
||||||
|
Section("Resolution") {
|
||||||
|
Picker("Scale", selection: $exportScale) {
|
||||||
|
Text("1× (Original)").tag(1.0)
|
||||||
|
Text("2× (Double)").tag(2.0)
|
||||||
|
Text("4× (Quadruple)").tag(4.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 3: Options
|
||||||
|
Section("Options") {
|
||||||
|
Toggle("Preserve metadata", isOn: $preserveMetadata)
|
||||||
|
|
||||||
|
if exportFormat == .png || exportFormat == .tiff {
|
||||||
|
Toggle("Flatten transparency", isOn: $flattenTransparency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 4: Info
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Export will apply all current dithering settings")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Export Options")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
showExportOptionsSheet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Export...") {
|
||||||
|
showExportOptionsSheet = false
|
||||||
|
// Now open NSSavePanel with configured settings
|
||||||
|
performExportWithOptions()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 450, idealWidth: 500, minHeight: 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -65,39 +144,41 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for FileExporter
|
func performExportWithOptions() {
|
||||||
struct ImageDocument: FileDocument {
|
let savePanel = NSSavePanel()
|
||||||
var image: CGImage?
|
savePanel.canCreateDirectories = true
|
||||||
|
savePanel.showsTagField = true
|
||||||
|
|
||||||
init(image: CGImage?) {
|
// Set filename with correct extension based on chosen format
|
||||||
self.image = image
|
let baseName = "dithered_image"
|
||||||
}
|
savePanel.nameFieldStringValue = "\(baseName).\(exportFormat.fileExtension)"
|
||||||
|
|
||||||
static var readableContentTypes: [UTType] { [.png] }
|
// Set allowed file types
|
||||||
|
savePanel.allowedContentTypes = [exportFormat.utType]
|
||||||
|
|
||||||
init(configuration: ReadConfiguration) throws {
|
savePanel.begin { response in
|
||||||
// Read not implemented for export-only
|
guard response == .OK,
|
||||||
self.image = nil
|
let url = savePanel.url else { return }
|
||||||
}
|
|
||||||
|
|
||||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
// Perform export with the configured settings
|
||||||
guard let image = image else { throw CocoaError(.fileWriteUnknown) }
|
viewModel.exportImage(to: url,
|
||||||
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
|
format: exportFormat,
|
||||||
guard let tiffData = nsImage.tiffRepresentation,
|
scale: exportScale,
|
||||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
jpegQuality: jpegQuality,
|
||||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
preserveMetadata: preserveMetadata,
|
||||||
throw CocoaError(.fileWriteUnknown)
|
flattenTransparency: flattenTransparency)
|
||||||
}
|
}
|
||||||
return FileWrapper(regularFileWithContents: pngData)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SidebarView (Updated to trigger sheet)
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var viewModel: DitherViewModel
|
@Bindable var viewModel: DitherViewModel
|
||||||
@Binding var isImporting: Bool
|
@Binding var isImporting: Bool
|
||||||
@Binding var isExporting: Bool
|
@Binding var showExportOptions: Bool
|
||||||
|
|
||||||
|
@State private var showChaosSection = false // Chaos Section State
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
|
@ -115,7 +196,7 @@ struct SidebarView: View {
|
||||||
Text(algo.name).tag(algo)
|
Text(algo.name).tag(algo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelsHidden() // Native look: just the dropdown
|
.labelsHidden()
|
||||||
|
|
||||||
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
|
|
@ -201,12 +282,109 @@ struct SidebarView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- CHAOS / FX SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.snappy) {
|
||||||
|
showChaosSection.toggle()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("CHAOS / FX")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: showChaosSection ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
if showChaosSection {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Pattern Distortion
|
||||||
|
Text("Pattern Distortion")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Offset Jitter", value: $viewModel.offsetJitter, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Rotation", value: $viewModel.patternRotation, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Error Propagation (Floyd-Steinberg only)
|
||||||
|
if viewModel.selectedAlgorithm == .floydSteinberg {
|
||||||
|
Text("Error Propagation")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Error Amplify", value: $viewModel.errorAmplify, range: 0.5...3.0, format: .multiplier)
|
||||||
|
SliderControl(label: "Random Direction", value: $viewModel.errorRandomness, range: 0...1, format: .percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold Effects
|
||||||
|
Text("Threshold Effects")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Noise Injection", value: $viewModel.thresholdNoise, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Wave Distortion", value: $viewModel.waveDistortion, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Spatial Glitch
|
||||||
|
Text("Spatial Glitch")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Pixel Displace", value: $viewModel.pixelDisplace, range: 0...50, format: .pixels)
|
||||||
|
SliderControl(label: "Turbulence", value: $viewModel.turbulence, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Chroma Aberration", value: $viewModel.chromaAberration, range: 0...20, format: .pixels)
|
||||||
|
|
||||||
|
// Quantization Chaos
|
||||||
|
Text("Quantization Chaos")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Bit Depth Chaos", value: $viewModel.bitDepthChaos, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Palette Randomize", value: $viewModel.paletteRandomize, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Reset Button
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.resetChaosEffects()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Reset All Chaos")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(20)
|
.padding(20)
|
||||||
}
|
}
|
||||||
.background(.regularMaterial) // Native material background
|
.background(.regularMaterial)
|
||||||
.ignoresSafeArea(edges: .top) // Fix for titlebar gap
|
.ignoresSafeArea(edges: .top)
|
||||||
.navigationTitle("iDither")
|
.navigationTitle("iDither")
|
||||||
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
|
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|
@ -216,7 +394,7 @@ struct SidebarView: View {
|
||||||
}
|
}
|
||||||
.help("Import Image")
|
.help("Import Image")
|
||||||
|
|
||||||
Button(action: { isExporting = true }) {
|
Button(action: { showExportOptions = true }) {
|
||||||
Label("Export", systemImage: "square.and.arrow.up")
|
Label("Export", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.disabled(viewModel.processedImage == nil)
|
.disabled(viewModel.processedImage == nil)
|
||||||
|
|
@ -388,6 +566,80 @@ struct FloatingToolbar: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Custom Modifier to handle Chaos/FX parameter observation
|
||||||
|
// Extracts complexity from the main ContentView body to fix compiler type-check timeout
|
||||||
|
struct ChaosEffectObserver: ViewModifier {
|
||||||
|
var viewModel: DitherViewModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onChange(of: viewModel.offsetJitter) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.patternRotation) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.errorAmplify) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.errorRandomness) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.thresholdNoise) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.waveDistortion) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.pixelDisplace) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.turbulence) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.chromaAberration) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.bitDepthChaos) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.paletteRandomize) { _, _ in viewModel.processImage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func onChaosChange(viewModel: DitherViewModel) -> some View {
|
||||||
|
self.modifier(ChaosEffectObserver(viewModel: viewModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SliderControl: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: Double
|
||||||
|
let range: ClosedRange<Double>
|
||||||
|
let format: ValueFormat
|
||||||
|
|
||||||
|
enum ValueFormat {
|
||||||
|
case percent
|
||||||
|
case multiplier
|
||||||
|
case pixels
|
||||||
|
case raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Spacer()
|
||||||
|
Text(formattedValue)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(value: $value, in: range)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
switch format {
|
||||||
|
case .percent:
|
||||||
|
return "\(Int(value * 100))%"
|
||||||
|
case .multiplier:
|
||||||
|
return String(format: "%.1f×", value)
|
||||||
|
case .pixels:
|
||||||
|
return "\(Int(value))px"
|
||||||
|
case .raw:
|
||||||
|
return String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CheckeredBackground: View {
|
struct CheckeredBackground: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Canvas { context, size in
|
Canvas { context, size in
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,28 @@ struct RenderParameters {
|
||||||
var brightness: Float
|
var brightness: Float
|
||||||
var contrast: Float
|
var contrast: Float
|
||||||
var pixelScale: Float
|
var pixelScale: Float
|
||||||
var colorDepth: Float // New parameter
|
var colorDepth: Float
|
||||||
var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
|
var algorithm: Int32
|
||||||
var isGrayscale: Int32 // 0: false, 1: true
|
var isGrayscale: Int32
|
||||||
|
|
||||||
|
// CHAOS / FX PARAMETERS
|
||||||
|
var offsetJitter: Float
|
||||||
|
var patternRotation: Float
|
||||||
|
|
||||||
|
var errorAmplify: Float
|
||||||
|
var errorRandomness: Float
|
||||||
|
|
||||||
|
var thresholdNoise: Float
|
||||||
|
var waveDistortion: Float
|
||||||
|
|
||||||
|
var pixelDisplace: Float
|
||||||
|
var turbulence: Float
|
||||||
|
var chromaAberration: Float
|
||||||
|
|
||||||
|
var bitDepthChaos: Float
|
||||||
|
var paletteRandomize: Float
|
||||||
|
|
||||||
|
var randomSeed: UInt32
|
||||||
}
|
}
|
||||||
|
|
||||||
final class MetalImageRenderer: Sendable {
|
final class MetalImageRenderer: Sendable {
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,164 @@
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
|
|
||||||
struct RenderParameters {
|
struct RenderParameters {
|
||||||
|
// Existing parameters
|
||||||
float brightness;
|
float brightness;
|
||||||
float contrast;
|
float contrast;
|
||||||
float pixelScale;
|
float pixelScale;
|
||||||
float colorDepth; // New parameter: 1.0 to 32.0 (Levels)
|
float colorDepth;
|
||||||
int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise
|
int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise, 7: Floyd-Steinberg
|
||||||
int isGrayscale;
|
int isGrayscale;
|
||||||
|
|
||||||
|
// CHAOS / FX PARAMETERS
|
||||||
|
float offsetJitter; // 0.0 to 1.0
|
||||||
|
float patternRotation; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float errorAmplify; // 0.5 to 3.0 (1.0 = normal)
|
||||||
|
float errorRandomness; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float thresholdNoise; // 0.0 to 1.0
|
||||||
|
float waveDistortion; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float pixelDisplace; // 0.0 to 50.0 (pixels)
|
||||||
|
float turbulence; // 0.0 to 1.0
|
||||||
|
float chromaAberration; // 0.0 to 20.0 (pixels)
|
||||||
|
|
||||||
|
float bitDepthChaos; // 0.0 to 1.0
|
||||||
|
float paletteRandomize; // 0.0 to 1.0
|
||||||
|
|
||||||
|
uint randomSeed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// CHAOS HELPER FUNCTIONS
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
|
float random(float2 st, uint seed) {
|
||||||
|
return fract(sin(dot(st.xy + float2(seed * 0.001), float2(12.9898, 78.233))) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 random2(float2 st, uint seed) {
|
||||||
|
float2 s = float2(seed * 0.001, seed * 0.002);
|
||||||
|
return float2(
|
||||||
|
fract(sin(dot(st.xy + s, float2(12.9898, 78.233))) * 43758.5453),
|
||||||
|
fract(sin(dot(st.xy + s, float2(93.9898, 67.345))) * 23421.6312)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
float noise(float2 st, uint seed) {
|
||||||
|
float2 i = floor(st);
|
||||||
|
float2 f = fract(st);
|
||||||
|
|
||||||
|
float a = random(i, seed);
|
||||||
|
float b = random(i + float2(1.0, 0.0), seed);
|
||||||
|
float c = random(i + float2(0.0, 1.0), seed);
|
||||||
|
float d = random(i + float2(1.0, 1.0), seed);
|
||||||
|
|
||||||
|
float2 u = f * f * (3.0 - 2.0 * f);
|
||||||
|
|
||||||
|
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 applySpatialChaos(float2 coord, constant RenderParameters ¶ms, uint2 gid) {
|
||||||
|
float2 chaosCoord = coord;
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0) {
|
||||||
|
float2 offset = random2(coord * 0.01, params.randomSeed) - 0.5;
|
||||||
|
chaosCoord += offset * params.pixelDisplace;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.turbulence > 0.0) {
|
||||||
|
float scale = 0.05;
|
||||||
|
float offsetX = noise(coord * scale, params.randomSeed) * 2.0 - 1.0;
|
||||||
|
float offsetY = noise(coord * scale + float2(100.0), params.randomSeed) * 2.0 - 1.0;
|
||||||
|
chaosCoord += float2(offsetX, offsetY) * params.turbulence * 20.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosCoord;
|
||||||
|
}
|
||||||
|
|
||||||
|
float applyThresholdChaos(float threshold, float2 coord, constant RenderParameters ¶ms) {
|
||||||
|
float chaosThreshold = threshold;
|
||||||
|
|
||||||
|
if (params.thresholdNoise > 0.0) {
|
||||||
|
float noise = random(coord, params.randomSeed);
|
||||||
|
chaosThreshold = mix(chaosThreshold, noise, params.thresholdNoise);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.waveDistortion > 0.0) {
|
||||||
|
float wave = sin(coord.x * 0.1) * cos(coord.y * 0.1) * 0.5 + 0.5;
|
||||||
|
chaosThreshold = mix(chaosThreshold, wave, params.waveDistortion * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint2 applyPatternChaos(uint2 matrixCoord, float2 pixelCoord, constant RenderParameters ¶ms, uint matrixSize) {
|
||||||
|
uint2 chaosCoord = matrixCoord;
|
||||||
|
|
||||||
|
if (params.offsetJitter > 0.0) {
|
||||||
|
float2 jitter = random2(pixelCoord * 0.1, params.randomSeed) * params.offsetJitter * float(matrixSize);
|
||||||
|
chaosCoord = uint2((float2(chaosCoord) + jitter)) % matrixSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.patternRotation > 0.0) {
|
||||||
|
float rotRandom = random(pixelCoord * 0.05, params.randomSeed);
|
||||||
|
if (rotRandom < params.patternRotation) {
|
||||||
|
uint temp = chaosCoord.x;
|
||||||
|
chaosCoord.x = matrixSize - 1 - chaosCoord.y;
|
||||||
|
chaosCoord.y = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosCoord;
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 applyChromaAberration(texture2d<float, access::read> inputTexture,
|
||||||
|
float2 coord,
|
||||||
|
float amount,
|
||||||
|
uint2 texSize) {
|
||||||
|
if (amount == 0.0) {
|
||||||
|
uint2 pixelCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||||
|
return inputTexture.read(pixelCoord).rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 redOffset = coord + float2(amount, 0);
|
||||||
|
float2 blueOffset = coord - float2(amount, 0);
|
||||||
|
|
||||||
|
uint2 redCoord = uint2(clamp(redOffset, float2(0), float2(texSize) - 1.0));
|
||||||
|
uint2 greenCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||||
|
uint2 blueCoord = uint2(clamp(blueOffset, float2(0), float2(texSize) - 1.0));
|
||||||
|
|
||||||
|
float r = inputTexture.read(redCoord).r;
|
||||||
|
float g = inputTexture.read(greenCoord).g;
|
||||||
|
float b = inputTexture.read(blueCoord).b;
|
||||||
|
|
||||||
|
return float3(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
float applyQuantizationChaos(float value, float2 coord, constant RenderParameters ¶ms) {
|
||||||
|
float chaosValue = value;
|
||||||
|
|
||||||
|
if (params.bitDepthChaos > 0.0) {
|
||||||
|
float randVal = random(coord * 0.1, params.randomSeed);
|
||||||
|
if (randVal < params.bitDepthChaos) {
|
||||||
|
float reducedDepth = floor(randVal * 3.0) + 2.0;
|
||||||
|
chaosValue = floor(value * reducedDepth) / reducedDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.paletteRandomize > 0.0) {
|
||||||
|
float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize;
|
||||||
|
chaosValue = clamp(value + randShift, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// DITHERING MATRICES
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
// Bayer 2x2 Matrix
|
// Bayer 2x2 Matrix
|
||||||
constant float bayer2x2[2][2] = {
|
constant float bayer2x2[2][2] = {
|
||||||
{0.0/4.0, 2.0/4.0},
|
{0.0/4.0, 2.0/4.0},
|
||||||
|
|
@ -69,11 +219,6 @@ constant float blueNoise8x8[8][8] = {
|
||||||
};
|
};
|
||||||
|
|
||||||
float ditherChannel(float value, float threshold, float limit) {
|
float ditherChannel(float value, float threshold, float limit) {
|
||||||
// Quantization Formula
|
|
||||||
// value: 0.0 to 1.0
|
|
||||||
// threshold: 0.0 to 1.0 (from matrix)
|
|
||||||
// limit: colorDepth (e.g. 4.0)
|
|
||||||
|
|
||||||
float ditheredValue = value + (threshold - 0.5) * (1.0 / (limit - 1.0));
|
float ditheredValue = value + (threshold - 0.5) * (1.0 / (limit - 1.0));
|
||||||
return floor(ditheredValue * (limit - 1.0) + 0.5) / (limit - 1.0);
|
return floor(ditheredValue * (limit - 1.0) + 0.5) / (limit - 1.0);
|
||||||
}
|
}
|
||||||
|
|
@ -179,41 +324,31 @@ float getLuma(float3 rgb) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 1: EVEN ROWS (Left -> Right)
|
// 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)]],
|
kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
texture2d<float, access::write> outputTexture [[texture(1)]],
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
texture2d<float, access::write> errorTexture [[texture(2)]],
|
texture2d<float, access::write> errorTexture [[texture(2)]],
|
||||||
constant RenderParameters ¶ms [[buffer(0)]],
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
uint2 gid [[thread_position_in_grid]]) {
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
// Dispatch: (1, height/2, 1). Each thread processes one FULL ROW.
|
uint y = gid.y * 2;
|
||||||
uint y = gid.y * 2; // Pass 1 processes EVEN rows: 0, 2, 4...
|
|
||||||
|
|
||||||
if (y >= inputTexture.get_height()) return;
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
uint width = inputTexture.get_width();
|
uint width = inputTexture.get_width();
|
||||||
float3 currentError = float3(0.0); // Error propagated from immediate Left neighbor
|
float3 currentError = float3(0.0);
|
||||||
|
|
||||||
// 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);
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
for (uint x = 0; x < width; x++) {
|
for (uint x = 0; x < width; x++) {
|
||||||
uint2 coords = uint2(x, y);
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
// Pixelate Input Read
|
// Pixelate Input Read with Chaos
|
||||||
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||||
|
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||||
|
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||||
|
}
|
||||||
|
|
||||||
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
|
@ -230,16 +365,23 @@ kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[t
|
||||||
originalColor = float3(l);
|
originalColor = float3(l);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// Error Diffusion Core
|
||||||
// ERROR DIFFUSION CORE
|
|
||||||
// ----------------------------------------------------
|
|
||||||
|
|
||||||
// Add error from Left Neighbor (Pass 1 is L->R)
|
|
||||||
float3 pixelIn = originalColor + currentError;
|
float3 pixelIn = originalColor + currentError;
|
||||||
|
|
||||||
|
// Apply Quantization Chaos
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = pixelIn.r;
|
||||||
|
pixelIn.b = pixelIn.r;
|
||||||
|
} else {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||||
|
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||||
|
}
|
||||||
|
|
||||||
// Quantize
|
// Quantize
|
||||||
float3 pixelOut = float3(0.0);
|
float3 pixelOut = float3(0.0);
|
||||||
float levels = max(1.0, params.colorDepth); // Ensure no div by zero
|
float levels = max(1.0, params.colorDepth);
|
||||||
if (levels <= 1.0) levels = 2.0;
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
||||||
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
|
@ -251,90 +393,93 @@ kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[t
|
||||||
// Calculate Error
|
// Calculate Error
|
||||||
float3 diff = pixelIn - pixelOut;
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
|
||||||
// Store RAW error for Pass 2 (Row below) to read
|
// Chaos: Error Amplify
|
||||||
// Note: we store 'diff', NOT the distributed parts. Pass 2 will calculate distribution.
|
if (params.errorAmplify != 1.0) {
|
||||||
|
diff *= params.errorAmplify;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store RAW error for Pass 2
|
||||||
if (y + 1 < inputTexture.get_height()) {
|
if (y + 1 < inputTexture.get_height()) {
|
||||||
errorTexture.write(float4(diff, 1.0), coords);
|
errorTexture.write(float4(diff, 1.0), coords);
|
||||||
}
|
}
|
||||||
|
|
||||||
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
// Propagate to Right Neighbor (7/16)
|
// Chaos: Error Randomness in Propagation
|
||||||
currentError = diff * (7.0 / 16.0);
|
float weight = 7.0 / 16.0;
|
||||||
|
if (params.errorRandomness > 0.0) {
|
||||||
|
float r = random(float2(coords), params.randomSeed);
|
||||||
|
weight = mix(weight, r * 0.8, params.errorRandomness);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentError = diff * weight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PASS 2: ODD ROWS (Right -> Left Serpentine)
|
// 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)]],
|
kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
texture2d<float, access::write> outputTexture [[texture(1)]],
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
texture2d<float, access::read> errorTexture [[texture(2)]], // Contains diffs from Pass 1
|
texture2d<float, access::read> errorTexture [[texture(2)]],
|
||||||
constant RenderParameters ¶ms [[buffer(0)]],
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
uint2 gid [[thread_position_in_grid]]) {
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
// Dispatch: (1, height/2, 1)
|
uint y = gid.y * 2 + 1;
|
||||||
uint y = gid.y * 2 + 1; // Pass 2 processes ODD rows: 1, 3, 5...
|
|
||||||
|
|
||||||
if (y >= inputTexture.get_height()) return;
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
uint width = inputTexture.get_width();
|
uint width = inputTexture.get_width();
|
||||||
float3 currentError = float3(0.0); // Error propagated from immediate Right neighbor (Serpentine R->L)
|
float3 currentError = float3(0.0);
|
||||||
|
|
||||||
float scale = max(1.0, params.pixelScale);
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
// Serpentine: Iterate Right to Left
|
|
||||||
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
|
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
|
||||||
uint x = uint(x_int);
|
uint x = uint(x_int);
|
||||||
uint2 coords = uint2(x, y);
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
// 1. Calculate Incoming Error from Row Above (Even Row, L->R)
|
// 1. Calculate Incoming Error from Row Above
|
||||||
// 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);
|
float3 errorFromAbove = float3(0.0);
|
||||||
uint prevY = y - 1;
|
uint prevY = y - 1;
|
||||||
|
|
||||||
// Read neighbor errors (and apply weights now)
|
// Weights
|
||||||
|
float w_tr = 3.0 / 16.0;
|
||||||
|
float w_t = 5.0 / 16.0;
|
||||||
|
float w_tl = 1.0 / 16.0;
|
||||||
|
|
||||||
// From Top-Left (x-1, y-1): It pushed 3/16 to Bottom-Right (x) ? No.
|
// Chaos: Error Randomness
|
||||||
// Standard FS (Left->Right scan):
|
if (params.errorRandomness > 0.0) {
|
||||||
// P(x, y) distributes:
|
float r = random(float2(coords) + float2(10.0), params.randomSeed);
|
||||||
// Right (x+1, y): 7/16
|
if (r < params.errorRandomness) {
|
||||||
// Bottom-Left (x-1, y+1): 3/16
|
float r1 = random(float2(coords) + float2(1.0), params.randomSeed);
|
||||||
// Bottom (x, y+1): 5/16
|
float r2 = random(float2(coords) + float2(2.0), params.randomSeed);
|
||||||
// Bottom-Right (x+1, y+1): 1/16
|
float r3 = random(float2(coords) + float2(3.0), params.randomSeed);
|
||||||
|
float sum = r1 + r2 + r3 + 0.1;
|
||||||
|
w_tr = r1 / sum;
|
||||||
|
w_t = r2 / sum;
|
||||||
|
w_tl = r3 / sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// So, ME (x, y) receives from:
|
// Read neighbors
|
||||||
// (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) {
|
if (x + 1 < width) {
|
||||||
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
|
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
|
||||||
errorFromAbove += e * (3.0 / 16.0);
|
errorFromAbove += e * w_tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read Top (x, prevY)
|
|
||||||
{
|
{
|
||||||
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
|
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
|
||||||
errorFromAbove += e * (5.0 / 16.0);
|
errorFromAbove += e * w_t;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read Top Left (x-1, prevY)
|
|
||||||
if (x >= 1) {
|
if (x >= 1) {
|
||||||
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
|
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
|
||||||
errorFromAbove += e * (1.0 / 16.0);
|
errorFromAbove += e * w_tl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Read Pixel
|
// 2. Read Pixel
|
||||||
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||||
|
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||||
|
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||||
|
}
|
||||||
|
|
||||||
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
|
@ -352,6 +497,16 @@ kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[t
|
||||||
float3 pixelIn = originalColor + currentError + errorFromAbove;
|
float3 pixelIn = originalColor + currentError + errorFromAbove;
|
||||||
|
|
||||||
// 4. Quantize
|
// 4. Quantize
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = pixelIn.r;
|
||||||
|
pixelIn.b = pixelIn.r;
|
||||||
|
} else {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||||
|
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||||
|
}
|
||||||
|
|
||||||
float3 pixelOut = float3(0.0);
|
float3 pixelOut = float3(0.0);
|
||||||
float levels = max(1.0, params.colorDepth);
|
float levels = max(1.0, params.colorDepth);
|
||||||
if (levels <= 1.0) levels = 2.0;
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
@ -361,15 +516,20 @@ kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[t
|
||||||
pixelOut.b = floor(pixelIn.b * (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);
|
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||||
|
|
||||||
// 5. Diff
|
// 5. Diff & Propagate
|
||||||
float3 diff = pixelIn - pixelOut;
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
if (params.errorAmplify != 1.0) {
|
||||||
|
diff *= params.errorAmplify;
|
||||||
|
}
|
||||||
|
|
||||||
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
// 6. Propagate Horizontally (Serpentine R->L)
|
float weight = 7.0 / 16.0;
|
||||||
// In R->L scan, 'Right' neighbor in FS diagram is actually 'Left' neighbor in spatial.
|
if (params.errorRandomness > 0.0) {
|
||||||
// We push 7/16 to the next pixel we visit (x-1).
|
float r = random(float2(coords), params.randomSeed);
|
||||||
currentError = diff * (7.0 / 16.0);
|
weight = mix(weight, r * 0.8, params.errorRandomness);
|
||||||
|
}
|
||||||
|
currentError = diff * weight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case noDither = 0
|
case noDither = 0
|
||||||
|
|
@ -43,11 +45,39 @@ class DitherViewModel {
|
||||||
var selectedAlgorithm: DitherAlgorithm = .bayer4x4
|
var selectedAlgorithm: DitherAlgorithm = .bayer4x4
|
||||||
var isGrayscale: Bool = false
|
var isGrayscale: Bool = false
|
||||||
|
|
||||||
|
// Chaos / FX Parameters
|
||||||
|
var offsetJitter: Double = 0.0
|
||||||
|
var patternRotation: Double = 0.0
|
||||||
|
var errorAmplify: Double = 1.0
|
||||||
|
var errorRandomness: Double = 0.0
|
||||||
|
var thresholdNoise: Double = 0.0
|
||||||
|
var waveDistortion: Double = 0.0
|
||||||
|
var pixelDisplace: Double = 0.0
|
||||||
|
var turbulence: Double = 0.0
|
||||||
|
var chromaAberration: Double = 0.0
|
||||||
|
var bitDepthChaos: Double = 0.0
|
||||||
|
var paletteRandomize: Double = 0.0
|
||||||
|
|
||||||
private let renderer = MetalImageRenderer()
|
private let renderer = MetalImageRenderer()
|
||||||
private var renderTask: Task<Void, Never>?
|
private var renderTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
|
func resetChaosEffects() {
|
||||||
|
offsetJitter = 0.0
|
||||||
|
patternRotation = 0.0
|
||||||
|
errorAmplify = 1.0
|
||||||
|
errorRandomness = 0.0
|
||||||
|
thresholdNoise = 0.0
|
||||||
|
waveDistortion = 0.0
|
||||||
|
pixelDisplace = 0.0
|
||||||
|
turbulence = 0.0
|
||||||
|
chromaAberration = 0.0
|
||||||
|
bitDepthChaos = 0.0
|
||||||
|
paletteRandomize = 0.0
|
||||||
|
processImage()
|
||||||
|
}
|
||||||
|
|
||||||
func load(url: URL) {
|
func load(url: URL) {
|
||||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||||
|
|
@ -66,13 +96,30 @@ class DitherViewModel {
|
||||||
// Cancel previous task to prevent UI freezing and Metal overload
|
// Cancel previous task to prevent UI freezing and Metal overload
|
||||||
renderTask?.cancel()
|
renderTask?.cancel()
|
||||||
|
|
||||||
|
// Generate a random seed for consistent chaos per frame/update
|
||||||
|
let seed = UInt32.random(in: 0...UInt32.max)
|
||||||
|
|
||||||
let params = RenderParameters(
|
let params = RenderParameters(
|
||||||
brightness: Float(brightness),
|
brightness: Float(brightness),
|
||||||
contrast: Float(contrast),
|
contrast: Float(contrast),
|
||||||
pixelScale: Float(pixelScale),
|
pixelScale: Float(pixelScale),
|
||||||
colorDepth: Float(colorDepth),
|
colorDepth: Float(colorDepth),
|
||||||
algorithm: Int32(selectedAlgorithm.rawValue),
|
algorithm: Int32(selectedAlgorithm.rawValue),
|
||||||
isGrayscale: isGrayscale ? 1 : 0
|
isGrayscale: isGrayscale ? 1 : 0,
|
||||||
|
|
||||||
|
// Chaos Params
|
||||||
|
offsetJitter: Float(offsetJitter),
|
||||||
|
patternRotation: Float(patternRotation),
|
||||||
|
errorAmplify: Float(errorAmplify),
|
||||||
|
errorRandomness: Float(errorRandomness),
|
||||||
|
thresholdNoise: Float(thresholdNoise),
|
||||||
|
waveDistortion: Float(waveDistortion),
|
||||||
|
pixelDisplace: Float(pixelDisplace),
|
||||||
|
turbulence: Float(turbulence),
|
||||||
|
chromaAberration: Float(chromaAberration),
|
||||||
|
bitDepthChaos: Float(bitDepthChaos),
|
||||||
|
paletteRandomize: Float(paletteRandomize),
|
||||||
|
randomSeed: seed
|
||||||
)
|
)
|
||||||
|
|
||||||
renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
|
renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
|
||||||
|
|
@ -89,14 +136,168 @@ class DitherViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
func exportResult(to url: URL) {
|
func exportResult(to url: URL) {
|
||||||
guard let image = processedImage else { return }
|
// Legacy export, keeping for compatibility but forwarding to new system with defaults
|
||||||
|
exportImage(to: url, format: .png, scale: 1.0, jpegQuality: 1.0, preserveMetadata: true, flattenTransparency: false)
|
||||||
|
}
|
||||||
|
|
||||||
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
|
// MARK: - Advanced Export
|
||||||
print("Failed to create image destination")
|
|
||||||
|
func exportImage(to url: URL,
|
||||||
|
format: ExportFormat,
|
||||||
|
scale: CGFloat,
|
||||||
|
jpegQuality: Double,
|
||||||
|
preserveMetadata: Bool,
|
||||||
|
flattenTransparency: Bool) {
|
||||||
|
|
||||||
|
guard let currentImage = processedImage else { return }
|
||||||
|
|
||||||
|
// Convert CGImage to NSImage for processing
|
||||||
|
let nsImage = NSImage(cgImage: currentImage, size: NSSize(width: currentImage.width, height: currentImage.height))
|
||||||
|
|
||||||
|
// Apply scaling if needed
|
||||||
|
let finalImage: NSImage
|
||||||
|
if scale > 1.0 {
|
||||||
|
finalImage = resizeImage(nsImage, scale: scale)
|
||||||
|
} else {
|
||||||
|
finalImage = nsImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export based on format
|
||||||
|
switch format {
|
||||||
|
case .png:
|
||||||
|
exportAsPNG(finalImage, to: url, flattenAlpha: flattenTransparency)
|
||||||
|
case .jpeg:
|
||||||
|
exportAsJPEG(finalImage, to: url, quality: jpegQuality)
|
||||||
|
case .tiff:
|
||||||
|
exportAsTIFF(finalImage, to: url, flattenAlpha: flattenTransparency)
|
||||||
|
case .pdf:
|
||||||
|
exportAsPDF(finalImage, to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeImage(_ image: NSImage, scale: CGFloat) -> NSImage {
|
||||||
|
let newSize = NSSize(width: image.size.width * scale,
|
||||||
|
height: image.size.height * scale)
|
||||||
|
|
||||||
|
let newImage = NSImage(size: newSize)
|
||||||
|
newImage.lockFocus()
|
||||||
|
|
||||||
|
NSGraphicsContext.current?.imageInterpolation = .none // Nearest neighbor for pixel art
|
||||||
|
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: image.size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
|
||||||
|
newImage.unlockFocus()
|
||||||
|
return newImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattenImageAlpha(_ image: NSImage) -> NSImage {
|
||||||
|
let flattened = NSImage(size: image.size)
|
||||||
|
flattened.lockFocus()
|
||||||
|
|
||||||
|
// Draw white background
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSRect(origin: .zero, size: image.size).fill()
|
||||||
|
|
||||||
|
// Draw image on top
|
||||||
|
image.draw(at: .zero, from: NSRect(origin: .zero, size: image.size),
|
||||||
|
operation: .sourceOver, fraction: 1.0)
|
||||||
|
|
||||||
|
flattened.unlockFocus()
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Format Exporters
|
||||||
|
|
||||||
|
private func exportAsPNG(_ image: NSImage, to url: URL, flattenAlpha: Bool) {
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
|
||||||
|
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
bitmapRep.size = image.size
|
||||||
|
|
||||||
|
// Handle alpha flattening
|
||||||
|
if flattenAlpha {
|
||||||
|
let flattened = flattenImageAlpha(image)
|
||||||
|
guard let flatCGImage = flattened.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
let flatRep = NSBitmapImageRep(cgImage: flatCGImage)
|
||||||
|
flatRep.size = image.size
|
||||||
|
|
||||||
|
guard let pngData = flatRep.representation(using: .png, properties: [:]) else { return }
|
||||||
|
try? pngData.write(to: url, options: .atomic)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
CGImageDestinationAddImage(destination, image, nil)
|
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else { return }
|
||||||
CGImageDestinationFinalize(destination)
|
try? pngData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsJPEG(_ image: NSImage, to url: URL, quality: Double) {
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
|
||||||
|
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
bitmapRep.size = image.size
|
||||||
|
|
||||||
|
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||||
|
.compressionFactor: NSNumber(value: quality)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let jpegData = bitmapRep.representation(using: .jpeg, properties: properties) else { return }
|
||||||
|
try? jpegData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsTIFF(_ image: NSImage, to url: URL, flattenAlpha: Bool) {
|
||||||
|
let imageToExport = flattenAlpha ? flattenImageAlpha(image) : image
|
||||||
|
guard let tiffData = imageToExport.tiffRepresentation else { return }
|
||||||
|
try? tiffData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsPDF(_ image: NSImage, to url: URL) {
|
||||||
|
let pdfData = NSMutableData()
|
||||||
|
|
||||||
|
guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return }
|
||||||
|
|
||||||
|
var mediaBox = CGRect(origin: .zero, size: image.size)
|
||||||
|
|
||||||
|
guard let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return }
|
||||||
|
|
||||||
|
pdfContext.beginPage(mediaBox: &mediaBox)
|
||||||
|
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
pdfContext.draw(cgImage, in: mediaBox)
|
||||||
|
|
||||||
|
pdfContext.endPage()
|
||||||
|
pdfContext.closePDF()
|
||||||
|
|
||||||
|
try? pdfData.write(to: url, options: .atomic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||||
|
case png = "PNG"
|
||||||
|
case jpeg = "JPEG"
|
||||||
|
case tiff = "TIFF"
|
||||||
|
case pdf = "PDF"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var fileExtension: String {
|
||||||
|
switch self {
|
||||||
|
case .png: return "png"
|
||||||
|
case .jpeg: return "jpg"
|
||||||
|
case .tiff: return "tiff"
|
||||||
|
case .pdf: return "pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var utType: UTType {
|
||||||
|
switch self {
|
||||||
|
case .png: return .png
|
||||||
|
case .jpeg: return .jpeg
|
||||||
|
case .tiff: return .tiff
|
||||||
|
case .pdf: return .pdf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue