Files
james.maesandClaude Opus 4.7 d4c358d943 Fix HUD text rotation, fix edge threshold, merge boxes into JARVIS, add ASCII pane
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>
2026-05-15 11:01:15 -05:00

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)
}
}