Bug fixes - text_fragment now v-flips its sample. TextRasterizer writes glyphs via a y-flipped CGContext (CoreText y-up → image-y-down), so memory row 0 holds the visual top of the text. quad_vertex already flips v for camera frames, so HUD text needed its own compensating flip in the fragment. - EdgePass was creating the threshold compute pipeline but never binding the threshold value to buffer(0). The kernel read garbage memory which usually evaluated as ~0, letting almost every fragment pass and producing the inverted look in the EDGES pane. Now binds Theme.Performance.edgeThreshold (default 0.18) explicitly. - Footer was anchored to y=2 (top of drawable). Moved to drawableSize.height minus footer height so it sits along the bottom edge where it belongs. Pane changes (per user request) - Pane 1 (JARVIS) now combines the stylization with the detection overlay: jarvis fragment → bounding-box strokes → per-box text labels. Labels are cached by string in labelTextureCache so a typical scene only rasterizes half a dozen unique strings ever. - Pane 3 was DETECT; now ASCII. Renders the live source through a new ascii_fragment that quantizes the image into a grid of monospaced glyphs picked from a pre-rasterized dark→bright atlas (` .:-=+*#%@`). New components - Sources/Shaders/Ascii.metal — ascii_fragment with AsciiUniforms (gridSize, atlasCharCount, tintColor). - Sources/Process/AsciiPass.swift — builds the glyph atlas at startup via CTLineDraw into a CGContext, uploads to a BGRA MTLTexture, and produces uniforms sized to keep glyph cells roughly square at any pane aspect. - Pipelines gains an ascii render state (quad_vertex + ascii_fragment). - Theme: paneLabels now ["LIVE", "JARVIS", "EDGES", "ASCII"], plus Performance.edgeThreshold and Performance.asciiColumns knobs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
87 lines
3.1 KiB
Swift
87 lines
3.1 KiB
Swift
import Metal
|
|
import MetalPerformanceShaders
|
|
|
|
final class EdgePass {
|
|
private let device: MTLDevice
|
|
private let sobel: MPSImageSobel
|
|
private let thresholdPipeline: MTLComputePipelineState?
|
|
|
|
private var sobelOutput: MTLTexture?
|
|
private var thresholdOutput: MTLTexture?
|
|
private var lastSize: (width: Int, height: Int) = (0, 0)
|
|
|
|
init(device: MTLDevice) {
|
|
self.device = device
|
|
self.sobel = MPSImageSobel(device: device)
|
|
|
|
if let library = device.makeDefaultLibrary(),
|
|
let function = library.makeFunction(name: "edge_threshold") {
|
|
self.thresholdPipeline = try? device.makeComputePipelineState(function: function)
|
|
} else {
|
|
self.thresholdPipeline = nil
|
|
}
|
|
}
|
|
|
|
/// Encodes Sobel + threshold into `commandBuffer`. Returns the texture the renderer
|
|
/// should sample. Output is reused across frames; resized only on dimension change.
|
|
func encode(source: MTLTexture, commandBuffer: MTLCommandBuffer) -> MTLTexture {
|
|
ensureOutputs(width: source.width, height: source.height)
|
|
|
|
let sobelTex = sobelOutput ?? source
|
|
sobel.encode(commandBuffer: commandBuffer, sourceTexture: source, destinationTexture: sobelTex)
|
|
|
|
guard let pipeline = thresholdPipeline,
|
|
let thresholdTex = thresholdOutput,
|
|
let encoder = commandBuffer.makeComputeCommandEncoder() else {
|
|
return sobelTex
|
|
}
|
|
|
|
encoder.setComputePipelineState(pipeline)
|
|
encoder.setTexture(sobelTex, index: 0)
|
|
encoder.setTexture(thresholdTex, index: 1)
|
|
var threshold: Float = Theme.Performance.edgeThreshold
|
|
encoder.setBytes(&threshold, length: MemoryLayout<Float>.size, index: 0)
|
|
|
|
let w = pipeline.threadExecutionWidth
|
|
let h = max(1, pipeline.maxTotalThreadsPerThreadgroup / w)
|
|
let threadsPerGroup = MTLSize(width: w, height: h, depth: 1)
|
|
let threadgroups = MTLSize(
|
|
width: (thresholdTex.width + w - 1) / w,
|
|
height: (thresholdTex.height + h - 1) / h,
|
|
depth: 1
|
|
)
|
|
encoder.dispatchThreadgroups(threadgroups, threadsPerThreadgroup: threadsPerGroup)
|
|
encoder.endEncoding()
|
|
|
|
return thresholdTex
|
|
}
|
|
|
|
private func ensureOutputs(width: Int, height: Int) {
|
|
if lastSize.width == width && lastSize.height == height && sobelOutput != nil {
|
|
return
|
|
}
|
|
|
|
let desc = MTLTextureDescriptor.texture2DDescriptor(
|
|
pixelFormat: .bgra8Unorm,
|
|
width: width,
|
|
height: height,
|
|
mipmapped: false
|
|
)
|
|
desc.usage = [.shaderRead, .shaderWrite]
|
|
desc.storageMode = .private
|
|
sobelOutput = device.makeTexture(descriptor: desc)
|
|
|
|
let descT = MTLTextureDescriptor.texture2DDescriptor(
|
|
pixelFormat: .bgra8Unorm,
|
|
width: width,
|
|
height: height,
|
|
mipmapped: false
|
|
)
|
|
descT.usage = [.shaderRead, .shaderWrite]
|
|
descT.storageMode = .private
|
|
thresholdOutput = device.makeTexture(descriptor: descT)
|
|
|
|
lastSize = (width, height)
|
|
}
|
|
}
|