巨大な座標と微細なディテールを扱うMacのキャンバスでは、大まかな位置と局所オフセットを分けて保存します。ズームを適用する前にビューの原点を差し引き、各変換で精度を確認します。このチュートリアルでは、合成シーンのデータを使ってその計算を組み立てます。

具体的な課題は、ズーム可能なデスクトップキャンバス上で合成マーカーを配置する際、シーン全体のアドレスが大きいために小さな変位が失われないようにすることです。この例では一方の軸を扱います。同じ計算をもう一方の軸にも個別に適用してください。キャンバスに接続する前に計算を確認できるよう、数値の描画座標を生成します。

大きな座標では、なぜ小さな移動が消えるのでしょうか?

SwiftのDoubleは64ビット浮動小数点型です。大きな値と小さな値の両方を扱える範囲を持ちますが、大きな値に加えた微小な増分をすべて保持できるわけではありません。AppleはDoubleで、この型と変換APIについて説明しています。

倍精度浮動小数点数の有効桁数は53ビットです。そのため、表現可能な値の間隔は値の大きさとともに広がります。Goldbergの算術に関する資料では、この表現方法と、丸められた値どうしを減算すると以前に失われた情報が表面化する仕組みの両方が説明されています。すべてのコンピューター科学者が知っておくべき浮動小数点演算

明示的に例示用とした次の入力を考えてみましょう。セルインデックスは2^40、セル幅は1024シーン単位、マーカーのオフセットは2^-30シーン単位です。セルのアドレスと幅を組み合わせると、絶対座標は2^50になります。この正の座標では、隣接するDouble値の間隔は0.25単位です。微小なオフセットを加えても、丸めによって元の座標に戻ります。

その後で原点を差し引くと、結果はゼロになります。このゼロに大きなズーム係数を掛けても、オフセットは復元できません。絶対座標を組み立てた時点で情報が失われています。

実際に保存する値の大きさでの間隔を調べるには、x.ulpを使います。Appleはこれを最下位の桁の単位と定義し、非有限値を含む例外についても説明しています。Double.ulpOfOneが示すのは1における間隔であり、シーン座標に共通して使える絶対許容誤差ではありません。ulp

座標表現で何を保持すべきでしょうか?

このキャンバスでは、各軸をInt64のセルインデックスとDoubleの局所オフセットで表します。位置は概念上、cell × cellSize + localを意味しますが、計算ではこの絶対浮動小数点値の生成を避けます。

マーカーとビューの原点は、最初からこの分割形式で保持します。すでに丸められた絶対座標をセルとオフセットに変換しても、失われたディテールは復元できません。

次の内容は、この例で用いる設計上の前提であり、数値に関する普遍的な上限ではありません。

  • 各セルの幅は正確に1024シーン単位です。
  • セルインデックスにはInt64を使います。これはアドレスや個数であり、浮動小数点値ではありません。
  • 各局所オフセットは有限値で、0..<1024の範囲にあります。
  • 詳細な位置計算では、原点セルとその直近の隣接セルだけを受け付けます。
  • ズームは有限かつ正の値で、1シーン単位あたりの表示単位数を表します。
  • 画面の原点は、合成描画座標512です。

相対座標はcellDifference × cellSize + localDifferenceとして計算します。重要なのは、セルインデックスの差を取ってから、その差をDoubleに変換することです。Appleの通常の整数変換では丸めが発生する場合がありますが、Double(exactly:)は正確に表現できない場合に失敗します。Double

有効なInt64値を2つ減算しても、その型の範囲を超える場合があります。subtractingReportingOverflow(_:)を使い、結果を使う前にフラグを確認してください。Appleは、オーバーフローが発生すると部分結果が切り捨てられることを明記しています。subtractingReportingOverflow(_:)

この表現方法では、大きなアドレスと局所的なディテールを独立して保持できます。ただし、精度は局所オフセットにも依存します。局所オフセットが十分に大きければ、さらに微小な増分を加えたときに失われることがあります。現在のビューが必要とする最小のディテールに合わせて、セル幅と局所アンカーを選んでください。

Macでローカル計算を最後まで実行する

動作するSwiftツールチェーンが必要です。必要に応じて、最新のSwiftのインストール — macOSの手順に従ってください。Terminalでインストールを確認し、コマンドラインツールのビルドに記載された手順で小さな実行可能パッケージを作成します。

swift --version
mkdir SceneCoordinates
cd SceneCoordinates
swift package init --name SceneCoordinates --type executable

生成された実行可能ターゲットのSourcesディレクトリ内にあるエントリーポイントのSwiftファイルを、以下のプログラム全体に置き換えます。このプログラムを1つのエントリーポイントファイルに記述し、ほかのトップレベル文は追加しないでください。Swift標準ライブラリだけをインポートするため、パッケージ依存関係は不要です。

import Swift

struct AxisPosition {
    let cell: Int64
    let local: Double
}

enum CoordinateError: Error {
    case invalidInput
    case cellDifferenceOverflow
    case outsideDetailedView
    case inexactCellConversion
    case unrepresentableResult
}

func screenCoordinate(
    point: AxisPosition,
    origin: AxisPosition,
    zoom: Double
) throws -> Double {
    let cellSize = 1024.0
    let screenOrigin = 512.0

    guard point.local.isFinite,
          origin.local.isFinite,
          point.local >= 0, point.local < cellSize,
          origin.local >= 0, origin.local < cellSize,
          zoom.isFinite, zoom > 0 else {
        throw CoordinateError.invalidInput
    }

    let difference = point.cell.subtractingReportingOverflow(origin.cell)
    guard !difference.overflow else {
        throw CoordinateError.cellDifferenceOverflow
    }
    guard (-1...1).contains(difference.partialValue) else {
        throw CoordinateError.outsideDetailedView
    }
    guard let cells = Double(exactly: difference.partialValue) else {
        throw CoordinateError.inexactCellConversion
    }

    let localDifference = point.local - origin.local
    let delta = localDifference.addingProduct(cells, cellSize)
    let scaled = delta * zoom
    guard delta.isFinite, scaled.isFinite,
          delta == 0 || scaled != 0 else {
        throw CoordinateError.unrepresentableResult
    }

    let screen = screenOrigin.addingProduct(delta, zoom)
    guard screen.isFinite,
          delta == 0 || screen != screenOrigin else {
        throw CoordinateError.unrepresentableResult
    }
    return screen
}

@main
struct SceneCoordinates {
    static func main() throws {
        let cell: Int64 = 1_099_511_627_776
        let step = Double(sign: .plus, exponent: -30, significand: 1)
        let zoom = Double(sign: .plus, exponent: 30, significand: 1)
        let absoluteBase = Double(cell) * 1024.0
        let collapsed = (absoluteBase + step) - absoluteBase

        let screen = try screenCoordinate(
            point: AxisPosition(cell: cell, local: step),
            origin: AxisPosition(cell: cell, local: 0),
            zoom: zoom
        )

        let tiny = Double(sign: .plus, exponent: -600, significand: 1)
        let largeZoom = Double(sign: .plus, exponent: 600, significand: 1)
        let tinyScreen = try screenCoordinate(
            point: AxisPosition(cell: 0, local: tiny),
            origin: AxisPosition(cell: 0, local: 0),
            zoom: largeZoom
        )

        precondition(absoluteBase.ulp == 0.25)
        precondition(collapsed == 0)
        precondition(screen == 513)
        precondition(tinyScreen == 513)

        print("Absolute spacing:", absoluteBase.ulp)
        print("Absolute displacement:", collapsed)
        print("Split-coordinate screen:", screen)
        print("Tiny-coordinate screen:", tinyScreen)
    }
}

パッケージのディレクトリから実行します。

swift run SceneCoordinates

期待される数値結果は、それぞれ0.25、0.0、513.0、513.0です。これらは、与えられた合成入力から導かれる結果です。基礎となるbinary64の間隔、消失する変位、2のべき乗によるスケーリングは、調査環境でPythonを使って確認しました。そこではSwiftコンパイラを利用できなかったため、Swiftプログラムは確認しましたが実行していません。

最初の安定した結果では、2^-30を巨大なセルアドレスとは別に保持し、それを2^30倍して1表示単位にします。2つ目では2^-600とズーム値2^600を使います。この例では、どちらの入力もその積も表現可能です。値が小さいからといって、精度が失われるとは限りません。

このプログラムでは、a + b × cにa.addingProduct(b, c)を使います。Appleによると、これは中間丸めなしで融合演算を行います。この演算内の丸めは抑えられますが、局所値の減算や、それ以前の入力丸めは別に残ります。addingProduct(::)

適切な精度予算をどう選ぶのでしょうか?

まず、現在のズームで表示し続ける必要がある最小の間隔を決めます。例として、表示単位の4分の1を目標にするなら、0.25をズーム値で割って、シーン空間の誤差予算を求めます。次に、計算で使う局所座標と中間値における間隔を調べます。

たとえば、ズーム値が2^30なら、例示用の誤差予算は2^-32シーン単位です。局所座標が512の場合、Doubleの間隔は2^-43で、この予算よりかなり細かくなります。絶対座標が2^50の場合、間隔は0.25となり、大きすぎます。こうした計算値から、このビューで近くのアンカーを使う理由がわかります。

間隔は診断材料であり、誤差の完全な上限ではありません。入力変換、減算、座標変換、最終的な描画時の変換は、それぞれ誤差を加える可能性があります。ズーム範囲を採用する前に、実際のレンダラーも含む一連の処理を通して既知の変位をテストしてください。

観察結果 数値上の原因として考えられること 次の対応
オフセットを加えても保存された座標が変わらない 保存値の間隔が増分より大きい オフセットを別に保持するか、より近いアンカーを選ぶ
変換するまでは相対座標が正しく機能する 変換先の精度が足りない 原点を差し引いてズームした後に変換し、結果の誤差を確認する
値が無限大になる 演算が有限範囲を超えている 演算の順序を変えるか、対応するビューの範囲を制限する
ゼロでない変位がゼロになる アンダーフローまたは丸めによって消失した もっと前の段階でスケールするか、局所表現を変える
このプログラムで離れたセルが拒否される 詳細ビューの方針で定めた範囲を超えた 近くの原点を選ぶか、概要表示用に別の粗い計算を使う

概要表示では、微細なディテールが表示単位より大幅に小さく変換されても問題ない場合があります。その動作を明示的に定義してください。このサンプルでは、詳細計算の範囲を意図的に隣接セルに制限しています。この制限を広げるには、ガードを単に削除するのではなく、新たな精度予算が必要です。

非常に小さなスケールや描画時には何が起こるのでしょうか?

AppleはleastNormalMagnitudeと、それより小さい正の非正規化数を区別しています。非正規化数の精度は正規数より低いため、最小の正の数を既定の作業スケールにするのは適切ではありません。微小なオフセットにズームを掛ける場合は、入力だけでなく中間結果も確認してください。leastNormalMagnitude

このサンプルは、ゼロでない変位をスケーリングした結果がゼロになる場合や、画面の原点と区別できなくなる場合に、その値を拒否します。ゼロでないすべての結果が視覚的な許容誤差を満たすことを保証するものではありません。キャンバスに適した許容誤差に基づく判定を追加し、エラーが投げられた場合は原点座標に置き換えず、明示的に処理してください。

2次元キャンバスでは、各軸の位置を分割形式で保持し、描画前に両方の相対座標を計算します。3次元に拡張する場合、AppleはSIMD3<Double>のエイリアスであるsimd_double3を提供しています。各成分はそれぞれ固有の精度上限を持つ64ビット浮動小数点値です。simd_double3

分割されたシーン状態を、正規の位置として保持してください。描画APIが求める型には、近くの最終描画座標だけを変換し、その変換誤差を表示空間の予算と照らして測定します。最終値がまだ非常に大きい場合は、まずそのオブジェクトが現在のビューポートに属しているかを確認してください。

安定したキャンバスには、対応範囲を定義する必要があります。整数セルアドレス、範囲を制限した局所オフセット、ズームの区間、許容する表示空間誤差です。大きな値を組み立てる前にディテールを保持し、近くの原点から相対計算を行い、その範囲の限界で代表的な変位を検証してください。

参考資料