~13 min · precision, quantization, premultiplied-alpha, metal, code-review
Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
그림 하나, 정밀도 둘
Ariandel의 레이어는 전부 두 번 존재해. 진실은 타일이야. 프리멀티플라이드 RGBA, 15비트 고정소수점이고, 텅 빈 데서 꽉 찰 때까지 32,768단계야. 거울은 GPU 레이어 배열의 슬라이스 한 장이야. 프리멀티플라이드 RGBA, 8비트, 255단계지. 거울은 보라고 있는 거고, 보는 데엔 충분해. 화면에 닿는 건 어차피 8비트를 거쳐 가니까.
문제는 프리멀티플라이드가 그 255단계에 하는 짓이야. 프리멀티플라이드 채널은 색에 알파를 곱해서 담으니까, 희미한 페인트는 범위의 아주 조금밖에 못 받아. 엔진 테스트에 나오는 값을 그대로 써 보자. 부드러운 파랑이고, 원래 색은 (0.3, 0.6, 0.9), 알파는 0.008이야. fix15로는 알파가 262단계, 빨강이 79쯤이라 나눠서 색을 되찾기에 넉넉해. 8비트로는 알파가 2이고, 채널은 0, 1, 2 중 하나밖에 못 돼. 나눠서 되돌리면 색이 (0.5, 0.5, 1.0)이야. 색상이 움직였어. 부드러운 파랑이 라벤더가 됐지. 화면에선 이 손실이 안 보여. 알파 2/255면 애초에 색이 아무한테도 안 보이거든. 나중에 뭔가가 그 페인트를 진하게 만들 때에야 드러나.
그리고 그림엔 희미한 페인트가 넘쳐. 에어브러시는 그걸로 쌓아 올리고, 부드러운 브러시 가장자리는 그걸로 이뤄져 있고, 글레이즈는 그게 전부야.
거울을 읽은 병합
Ariandel의 첫날 저녁, Merge Down은 뻔한 방식으로 쓰였어. 렌더러엔 이미 두 레이어가 GPU에 올라가 있었고, 블렌드 모드를 다 아는 합성기도 있었어. 그래서 병합은 먼저 거울을 맞췄어. 그다음 두 레이어의 거울 슬라이스를 캔버스 크기의 float 텍스처에 합성하고, 결과를 fix15로 바꿔서 아래 레이어에 써넣었지. 픽셀이 하나도 빠짐없이 8비트 슬라이스를 지나간 거야.
결함은 8비트 자체가 아니었어. 화면 표시도 매 프레임 같은 8비트를 읽는데 멀쩡하잖아. 결함은 방향이었어. 화면의 8비트 그림은 다음 프레임에 버려지고 타일에서 다시 만들어져. 병합 결과는 타일에 써넣어져. 거울의 반올림이 영원히 진실이 됐고, 그 뒤 모든 작업이 거기서 출발했어. 몇 시간 뒤 6479f1f 리뷰가 코드를 읽다가 이걸 찾았고, 한 줄로 적었어. Merge Down이 "8비트 프리멀티플라이드 화면 거울을 거쳐서, 아래 레이어의 희미한 페인트 색을 지워 버렸다."
자기 텍스처를 따로 만드는 병합
지금의 mergedTiles(첫 번째 코드 블록)는 거울에 손도 안 대. 두 레이어가 실제로 덮는 영역, 곧 둘의 타일 합집합을 계산하고, 그 영역만을 위한 텍스처 두 개를 만들어.
작은 rgba16Unorm 2D 배열. 슬라이스 0은 아래 레이어, 1은 위 레이어, 그 뒤로 켜진 마스크 페인트들. 타일은 ard_fix15_to_rgba16_premultiplied로 바꾸는데, fix15의 32,768단계를 16비트의 65,535단계에 옮기니까 올라가는 길에 잃는 게 없어. 올리는 건 덮인 타일뿐이야. 덮인 타일 자리에 자기 타일이 없는 슬라이스는 0으로 채워. 바깥 사각형 안이라도 두 레이어 다 안 덮은 타일은 아예 안 써. 커널은 거기 남아 있던 메모리로 그 자리도 합성하지만, 다시 읽어 올 때 건너뛰니까 쓰레기 값이 레이어에 들어가진 않아.
rgba32Float 대상 하나.
그다음 같은 합성기를 돌려. 스택엔 둘뿐이야. 불투명도 1의 아래 레이어, 그리고 블렌드·불투명도·클리핑·마스크를 가진 위 레이어. float 결과를 한 줄씩 읽어 와서 32,768을 곱하고, 반올림하고, 범위를 자르고, fix15 타일로 돌려줘. 완전히 투명하게 나온 타일은 버리고. 커널은 입력이 8비트든 16비트든 상관 안 해. 어느 쪽이든 texture2d_array<float>로 읽으니까. 블렌드 공식 하나에 입력 정밀도 둘. 새 길은 텍스처 크기도 영역에 맞춰. 옛 길은 병합할 때마다 캔버스 크기 대상을 잡았거든.
이걸 지키는 테스트(두 번째 코드 블록)가 쓰는 게 바로 위의 희미한 파랑이야. 빈 레이어와 병합한 뒤 원래 색이 (0.3, 0.6, 0.9)에서 0.02 안으로 돌아오는지 봐.
flatten은 왜 8비트로 남아도 되나, 대체로는
내보내기, 갤러리 미리보기, 피파의 캔버스 보기는 전부 flatten을 부르고, flatten은 바로 그 8비트 슬라이스에서 합성해. 같은 버그 아니냐고? 꼭 그렇진 않아. 그 차이가 이 레슨의 핵심이야. 병합은 결과를 다음 병합이 읽을 바로 그 레이어에 써. 그래서 반올림이 진실이 되고 쌓여. flatten 결과는 대부분 앱을 떠나(PNG, JPEG, PDF 페이지, 미리보기, 피파의 보기). 그런 데선 반올림이 한 번 잃고 끝나. 그렇다고 늘 안 보이는 건 아냐. 투명 PNG는 straight 색을 저장하니까, (0.5, 0.5, 1.0)으로 도착한 희미한 파랑은 딱 그만큼 틀린 채로 적혀. 그 형식이면 제대로 담을 수 있었는데도.
그리고 flatten 출력은 실제로 다시 들어와. 기준을 모든 레이어로 둔 ColorDrop과 자동 선택은 투명 flatten을 읽어서 어디까지 닿을지 정해. 그러니까 희미한 페인트의 틀어진 색이 채우기나 선택의 경계를 옮길 수 있어. Copy All은 flatten을 PNG로 클립보드에 올리고, Paste는 그걸 새 레이어로 만들어. 거울의 반올림까지 통째로. 이 중 어느 것도 자기 출력을 루프로 다시 읽진 않아. Merge Layers는 계속 병합해 넣는 그 레이어를 매번 다시 읽지. 그래서 거울을 먼저 떠나야 했던 게 병합이었던 거야. 다른 읽기가 손실이 없다는 증거는 아니고.
세 번째 코드 블록이 그 차이를 숫자로 보여줘. 희미한 파랑을 8비트로 한 번 병합하면 색이 (0.502, 0.502, 1.0)이 돼. 16비트면 (0.302, 0.599, 0.901) 그대로고. 그다음엔 그림 그리는 사람이 하는 걸 해. 알파 0.01짜리 글레이즈 마흔 번을 하나씩 아래로 병합하는 거야. 병합할 때마다 자라나는 아래 레이어를 다시 읽으니까 8비트 길은 오차가 쌓여. 색이 (0.417, 0.656, 0.656)으로 흘러가고, 알파도 부풀어 올라(float 쪽 10,847에 비해 12,196). 16비트 길은 (0.299, 0.600, 0.899)에서 끝나. 앱의 Merge Layers가 말 그대로 이 루프야. 고른 레이어들을 맨 아래 레이어로 하나씩 병합하면서, 매번 그 레이어를 다시 읽어.
손실 있는 사본은 한 방향으로만 흐를 때 안전해. 캐시, 미리보기, 내보내기는 보는 쪽이 못 알아챌 만큼은 반올림해도 돼. 그중 하나라도 원본에 되써지는 순간, 그 반올림이 진실이 되고 뒤따르는 모든 단계가 물려받아. 되쓰기를 발견하면 그게 읽는 모든 것의 정밀도부터 확인해.
Code
덮인 영역에서 16비트로 올려 32비트 float로 합성하는 병합 (AriandelEngine/CanvasRenderer.swift, 축약: 마스크 처리와 스택 구성은 생략)·swift
/// The lower layer's new tiles after `upperID` is merged into it with the
/// upper layer's blend mode, opacity, clipping and mask; the lower
/// layer's own mask is applied too, so the result needs no mask (an
/// enabled mask is baked, a disabled one let everything through). The
/// lower layer keeps its other properties; only its pixels change.
///
/// The layers are uploaded at 16 bits per channel and composited in
/// 32-bit float over just the region they cover, so a merge never drops
/// the lower layer to the display mirror's 8 bits (soft, faint paint
/// would lose its color).
public func mergedTiles(of artwork: Artwork, upper upperID: UUID) throws -> [TileKey: TileBuffer] {
guard let upperIndex = artwork.index(of: upperID), upperIndex > 0 else { return [:] }
let upper = artwork.layers[upperIndex]
let lower = artwork.layers[upperIndex - 1]
let size = artwork.size
let keys = Set(lower.tiles.keys).union(upper.tiles.keys).filter { PixelRect(tile: $0).clipped(to: size) != nil }
guard !keys.isEmpty else { return [:] }
let region = keys.map { PixelRect(tile: $0) }.reduce(PixelRect(tile: keys.first!)) { $0.union($1) }
// Slices: 0 lower, 1 upper, then each enabled mask's paint.
// ... the tile stores of the lower layer, the upper layer and any enabled masks go into `sources` ...
let arrayDescriptor = MTLTextureDescriptor()
arrayDescriptor.textureType = .type2DArray
arrayDescriptor.pixelFormat = .rgba16Unorm
arrayDescriptor.width = region.width
arrayDescriptor.height = region.height
arrayDescriptor.arrayLength = sources.count
arrayDescriptor.usage = [.shaderRead]
arrayDescriptor.storageMode = .shared
let targetDescriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba32Float, width: region.width,
height: region.height, mipmapped: false)
targetDescriptor.usage = [.shaderRead, .shaderWrite]
targetDescriptor.storageMode = .shared
guard let array = device.makeTexture(descriptor: arrayDescriptor),
let target = device.makeTexture(descriptor: targetDescriptor),
let commandBuffer = queue.makeCommandBuffer() else {
throw RendererError.outOfMemory("merge \(region.width)×\(region.height)")
}
var tile16 = [UInt16](repeating: 0, count: Tile.valueCount)
let zero = [UInt16](repeating: 0, count: Tile.valueCount)
for (slice, store) in sources.enumerated() {
for key in keys {
let origin = key.pixelOrigin
// Every tile of the region is written, empty ones as zeros, so no slice reads stale memory.
if let tile = store.tile(at: key) {
tile16.withUnsafeMutableBufferPointer { buffer in
ard_fix15_to_rgba16_premultiplied(tile.pixels, buffer.baseAddress!, Tile.pixelCount)
}
tile16.withUnsafeBytes { bytes in
array.replace(region: MTLRegionMake2D(origin.x - region.x, origin.y - region.y, Tile.size, Tile.size),
mipmapLevel: 0, slice: slice, withBytes: bytes.baseAddress!,
bytesPerRow: Tile.size * 8, bytesPerImage: Tile.size * Tile.size * 8)
}
} else {
zero.withUnsafeBytes { bytes in
array.replace(region: MTLRegionMake2D(origin.x - region.x, origin.y - region.y, Tile.size, Tile.size),
mipmapLevel: 0, slice: slice, withBytes: bytes.baseAddress!,
bytesPerRow: Tile.size * 8, bytesPerImage: Tile.size * Tile.size * 8)
}
}
}
}
// ... the stack: the lower layer at opacity 1, then the upper layer with its blend, opacity, clip and mask ...
compose(stack: stack, background: nil, region: PixelRect(x: 0, y: 0, width: region.width, height: region.height),
layers: array, into: target, commandBuffer: commandBuffer)
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
var tiles: [TileKey: TileBuffer] = [:]
var row = [Float](repeating: 0, count: Tile.size * 4)
for key in keys {
guard let rect = PixelRect(tile: key).clipped(to: size) else { continue }
let tile = TileBuffer()
let origin = key.pixelOrigin
for y in rect.y..<(rect.y + rect.height) {
row.withUnsafeMutableBytes { bytes in
target.getBytes(bytes.baseAddress!, bytesPerRow: Tile.size * 16,
from: MTLRegionMake2D(rect.x - region.x, y - region.y, rect.width, 1), mipmapLevel: 0)
}
let base = ((y - origin.y) * Tile.size + (rect.x - origin.x)) * 4
for i in 0..<(rect.width * 4) {
let value = row[i] * Float(Tile.fix15One)
tile.pixels[base + i] = UInt16(min(Float(Tile.fix15One), max(0, value.rounded())))
}
}
if !tile.isTransparent {
tiles[key] = tile
}
}
return tiles
}
희미한 페인트의 색을 지키는 테스트 (Tests/AriandelEngineTests/TransformAndAdjustmentTests.swift, 원문 그대로)·swift
@Test func mergingKeepsTheColorOfFaintPaint() throws {
// At alpha 2/255 the display mirror's premultiplied 8 bits hold almost
// no color; a merge must not go through it.
let renderer = try CanvasRenderer()
let artwork = Artwork(size: CanvasSize(width: 64, height: 64), background: nil)
artwork.fillLayer(artwork.activeLayerID, with: RGBAColor(red: 0.3, green: 0.6, blue: 0.9, alpha: 0.008))
let upper = artwork.addLayer()
artwork.fillLayer(upper.id, with: RGBAColor(red: 1, green: 1, blue: 1, alpha: 0.0))
let merged = try renderer.mergedTiles(of: artwork, upper: upper.id)
artwork.mergeDown(upper.id, merged: merged)
let p = artwork.activeLayer.tiles.tile(at: TileKey(x: 0, y: 0))!.color(atX: 5, y: 5)
#expect(abs(p.r / p.a - 0.3) < 0.02 && abs(p.g / p.a - 0.6) < 0.02 && abs(p.b / p.a - 0.9) < 0.02,
"straight color survives: \(p.r / p.a), \(p.g / p.a), \(p.b / p.a)")
}
최소 스케치: 희미한 병합 한 번과 글레이즈 마흔 번을 8비트와 16비트로 읽어 비교 (Swift 6 모드 swiftc로 컴파일하고 실행함, 출력은 맨 아래)·swift
// A minimal sketch of why merge down must not read the 8-bit display mirror.
// Tiles hold premultiplied fix15 (1.0 = 32768). A merge reads both layers at
// some precision, composites in float, and writes fix15 back into the lower layer.
// Build and run: swiftc -swift-version 6 mirror.swift -o mirror && ./mirror
typealias Pixel = SIMD4<Double> // premultiplied r, g, b, a in 0...1
let one = 32768.0 // fix15's 1.0
func toFix15(_ p: Pixel) -> SIMD4<UInt32> { // what a layer stores
func c(_ v: Double) -> UInt32 { UInt32(min(one, max(0, (v * one).rounded()))) }
return SIMD4(c(p.x), c(p.y), c(p.z), c(p.w))
}
// Upload at `bits` per channel, rounding as ard_fix15_to_rgba8/16_premultiplied do.
func read(_ v: SIMD4<UInt32>, bits: UInt32) -> Pixel {
let maximum = (UInt32(1) << bits) - 1
func q(_ c: UInt32) -> Double { Double((c * maximum + 16384) >> 15) / Double(maximum) }
return Pixel(q(v.x), q(v.y), q(v.z), q(v.w))
}
func over(_ dst: Pixel, _ src: Pixel) -> Pixel { src + dst * (1 - src.w) } // Normal, premultiplied
func straight(_ v: SIMD4<UInt32>) -> String {
guard v.w > 0 else { return "transparent" }
let a = Double(v.w)
func f(_ c: UInt32) -> String { String(Int((Double(c) / a * 1000).rounded())) }
return "rgb/1000 = (\(f(v.x)), \(f(v.y)), \(f(v.z))) alpha/32768 = \(v.w)"
}
let blue = SIMD3<Double>(0.3, 0.6, 0.9)
func glaze(_ alpha: Double) -> Pixel { Pixel(blue.x * alpha, blue.y * alpha, blue.z * alpha, alpha) }
// 1. One faint layer (alpha 0.008, the engine test's value) merged with an empty one.
let faint = toFix15(glaze(0.008))
print("truth ", straight(faint))
for bits: UInt32 in [8, 16] {
let merged = toFix15(over(read(faint, bits: bits), read(SIMD4(0, 0, 0, 0), bits: bits)))
print("merge at \(bits) bits".padding(16), straight(merged))
}
// 2. Forty glazes of alpha 0.01, merged down one at a time: every merge re-reads
// the growing lower layer at the mirror's precision, so the error compounds.
var exact = Pixel(0, 0, 0, 0)
var lower8 = SIMD4<UInt32>(0, 0, 0, 0), lower16 = SIMD4<UInt32>(0, 0, 0, 0)
for _ in 0..<40 {
let upper = toFix15(glaze(0.01))
exact = over(exact, glaze(0.01))
lower8 = toFix15(over(read(lower8, bits: 8), read(upper, bits: 8)))
lower16 = toFix15(over(read(lower16, bits: 16), read(upper, bits: 16)))
}
print("40 glazes, float ", straight(toFix15(exact)))
print("40 glazes, 8-bit ", straight(lower8))
print("40 glazes, 16-bit", straight(lower16))
extension String {
func padding(_ width: Int) -> String { self + String(repeating: " ", count: max(0, width - count)) }
}
// Prints:
// truth rgb/1000 = (302, 599, 901) alpha/32768 = 262
// merge at 8 bits rgb/1000 = (502, 502, 1000) alpha/32768 = 257
// merge at 16 bits rgb/1000 = (302, 599, 901) alpha/32768 = 262
// 40 glazes, float rgb/1000 = (300, 600, 900) alpha/32768 = 10847
// 40 glazes, 8-bit rgb/1000 = (417, 656, 656) alpha/32768 = 12196
// 40 glazes, 16-bit rgb/1000 = (299, 600, 899) alpha/32768 = 10861
스케치를 돌린 다음 희미한 병합 한 번을 훑어 봐. 알파를 0.004에서 0.5까지 0.001씩 올리면서, 8비트 길에서 원래 색의 최대 오차를 알파마다 찍어. 오차가 여전히 1% 이상인 가장 큰 알파를 찾아. 그다음 네 파이프라인(이미지 처리, 오디오, 데이터 내보내기 뭐든)을 보고, 정밀도가 낮아진 사본이 진실의 원본에 되써지는 곳을 하나 찾아.
Hint
8비트 프리멀티플라이드 채널엔 대략 255 × 알파만큼의 단계가 있어서, 오차는 1 / (255 × 알파) 비슷하게 줄어. 알파 0.01에서 약 23%, 0.05에서 5%, 0.1에서 2%. 단조롭게 줄진 않아. 알파마다 반올림이 다르게 떨어지거든. 이 파랑이면 약 0.28까지 몇몇 알파에서 여전히 1% 이상이야. 네 파이프라인에선 왕복을 찾아. 입력으로 다시 쓰는 썸네일, 원 단위로 반올림해서 다시 더하는 값, 다시 인코딩하는 압축 파일 같은 것.
Progress
Progress is local-only — sign in to sync across devices.