Zum Inhalt

native/macos/KuroNativeSaver/Core/TextRenderer.swift

// TextRenderer — batches font-atlas glyph quads into a vertex buffer and draws
// them (alpha-blended) into the HDR scene, so the CRT post treats the text.
// Screen-pixel coordinates, top-left origin (like the web HUD).

import Metal
import simd

final class TextRenderer {
    private let device: MTLDevice
    let atlas: FontAtlas
    private let pipeline: MTLRenderPipelineState
    private var verts: [Float] = []
    private var buffer: MTLBuffer?
    private var vpW: Float = 1, vpH: Float = 1

    init(device: MTLDevice, library: MTLLibrary, atlas: FontAtlas, format: MTLPixelFormat) {
        self.device = device
        self.atlas = atlas
        let pd = MTLRenderPipelineDescriptor()
        pd.vertexFunction = library.makeFunction(name: "text_v")
        pd.fragmentFunction = library.makeFunction(name: "text_f")
        let a = pd.colorAttachments[0]!
        a.pixelFormat = format
        a.isBlendingEnabled = true
        a.rgbBlendOperation = .add; a.alphaBlendOperation = .add
        a.sourceRGBBlendFactor = .sourceAlpha; a.destinationRGBBlendFactor = .oneMinusSourceAlpha
        a.sourceAlphaBlendFactor = .one; a.destinationAlphaBlendFactor = .oneMinusSourceAlpha
        pipeline = try! device.makeRenderPipelineState(descriptor: pd)
    }

    func begin(width: Int, height: Int) {
        verts.removeAll(keepingCapacity: true)
        vpW = Float(max(1, width)); vpH = Float(max(1, height))
    }

    var isEmpty: Bool { verts.isEmpty }

    /// Glyph cell width in px for a given cell height.
    func charWidth(pxHeight: Float) -> Float { pxHeight * atlas.cellAspect }

    /// Draw text at screen pixel (xPx, yPx) = top-left of the first cell.
    func add(_ text: String, xPx: Float, yPx: Float, pxHeight: Float,
             color: SIMD3<Float>, opacity: Float = 1) {
        let cw = pxHeight * atlas.cellAspect
        var cx = xPx
        for ch in text {
            defer { cx += cw }
            if ch == " " { continue }
            guard let r = atlas.uvRect(ch) else { continue }
            let x0 = cx / vpW * 2 - 1, x1 = (cx + cw) / vpW * 2 - 1
            let yt = 1 - yPx / vpH * 2
            let yb = 1 - (yPx + pxHeight) / vpH * 2
            let r3 = color.x, g3 = color.y, b3 = color.z, a = opacity
            func v(_ x: Float, _ y: Float, _ u: Float, _ vv: Float) {
                verts.append(contentsOf: [x, y, u, vv, r3, g3, b3, a])
            }
            // screen-top → glyph-top (vTop); screen-bottom → vBottom
            v(x0, yt, r.uLo, r.vTop); v(x1, yt, r.uHi, r.vTop); v(x0, yb, r.uLo, r.vBottom)
            v(x0, yb, r.uLo, r.vBottom); v(x1, yt, r.uHi, r.vTop); v(x1, yb, r.uHi, r.vBottom)
        }
    }

    /// Draw a solid filled rectangle in the same batch (samples the centre of the
    /// █ glyph, coverage ≈ 1.0) — used for the Lisa terminal-window chrome.
    func fillRect(xPx: Float, yPx: Float, wPx: Float, hPx: Float, color: SIMD3<Float>, opacity: Float) {
        guard opacity > 0.002, wPx > 0, hPx > 0 else { return }
        let u = atlas.solidU, vv = atlas.solidV
        let x0 = xPx / vpW * 2 - 1, x1 = (xPx + wPx) / vpW * 2 - 1
        let yt = 1 - yPx / vpH * 2, yb = 1 - (yPx + hPx) / vpH * 2
        let r3 = color.x, g3 = color.y, b3 = color.z, a = opacity
        func v(_ x: Float, _ y: Float) { verts.append(contentsOf: [x, y, u, vv, r3, g3, b3, a]) }
        v(x0, yt); v(x1, yt); v(x0, yb)
        v(x0, yb); v(x1, yt); v(x1, yb)
    }

    /// Draw a thickness-wide line between two screen-pixel points (a rotated quad,
    /// solid fill). Used for non-axis-aligned HUD geometry like the radar sweep/rings.
    func line(_ ax: Float, _ ay: Float, _ bx: Float, _ by: Float, width: Float, color: SIMD3<Float>, opacity: Float) {
        guard opacity > 0.002 else { return }
        let dx = bx - ax, dy = by - ay
        let len = max(0.0001, (dx * dx + dy * dy).squareRoot())
        let hw = width * 0.5
        let nx = -dy / len * hw, ny = dx / len * hw      // perpendicular half-width offset
        let u = atlas.solidU, vv = atlas.solidV
        let r3 = color.x, g3 = color.y, b3 = color.z, a = opacity
        func v(_ px: Float, _ py: Float) {
            verts.append(contentsOf: [px / vpW * 2 - 1, 1 - py / vpH * 2, u, vv, r3, g3, b3, a])
        }
        v(ax + nx, ay + ny); v(bx + nx, by + ny); v(ax - nx, ay - ny)
        v(ax - nx, ay - ny); v(bx + nx, by + ny); v(bx - nx, by - ny)
    }

    /// Measured pixel width of a string at a given cell height.
    func width(_ text: String, pxHeight: Float) -> Float { Float(text.count) * pxHeight * atlas.cellAspect }

    func flush(_ enc: MTLRenderCommandEncoder) {
        guard !verts.isEmpty else { return }
        let bytes = verts.count * MemoryLayout<Float>.stride
        if buffer == nil || buffer!.length < bytes {
            buffer = device.makeBuffer(length: max(bytes, 8192), options: .storageModeShared)
        }
        memcpy(buffer!.contents(), verts, bytes)
        enc.setRenderPipelineState(pipeline)
        enc.setVertexBuffer(buffer, offset: 0, index: 0)
        enc.setFragmentTexture(atlas.texture, index: 0)
        enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: verts.count / 8)
    }
}