3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】iPhone アプリでの Haptic Feedback 入門 - Core Haptics 編

Last updated at Posted at 2025-02-24

こちらは【Unity】iPhone アプリでの Haptic Feedback 入門 の続きです。

ハプティックフィードバック自体の概要や、UIFeedbackGenerator についての解説は上記記事をご覧ください。

Core Haptics について

改めて Core Haptics について解説すると、こちらは「独自に定義した任意のパプティックパターン(振動のパターン)を再生可能な API」であり、他にも以下に挙げるような様々な機能がサポートされてます。

  • 連続的な振動の再生 (言わばバイブレーション機能みたいな振動)
  • ループ再生の設定
  • 再生中のパターンの動的な値の変更

そのため、ゲーム系統のアプリでは上手く使いこなすことができれば没入感のある体験を提供できるようになるかと思われ、実際に WWDC 2019 の「Introducing Core Haptics」と言う講演では、活用できる分野として「ゲーム」や「ARアプリ」と言った例が取り上げられていました。

今回は興味あってこちらの Core Haptics について調べてみたので、自分の考えなども交えつつ、「利用する上で必要となる予備知識の解説」や「Unity から呼び出す方法」について解説していきたいと思います。
(もし間違いとかあったらコメントなりで教えて頂けると幸いです:bow: )

UIFeedbackGenerator との違いについて。

前の記事で話した UIFeedbackGenerator は「システムが提供するプリセットのハプティックパターン」を再生するための API であり、独自のパターンを再生することができないと言った違いが挙げられます。

その上で再生可能なパターン自体も UI 周りでの利用を想定としたものが主となるため、ゲームによっては利用が適さない可能性がある1他、連続的な振動(言わばバイブレーション機能みたいな振動)の再生が行えないと言った違いもあります。

Apple 公式の Unity プラグインを利用することが可能

前回の記事でも記載した内容ですが、Core Haptics を Unity 上から呼び出すにあたっては、Apple 公式が公開しているプラグインを利用することが可能です。2
( Haptic Feedback については Apple.CoreHaptics を参照)

今回は自分が内部実装を把握する目的もあって、敢えてプラグインを再実装する形でサンプルプロジェクトを用意しましたが、単に利用することが目的であるならば、(特に理由がなければ)上記の公式プラグインを導入するのが手軽に済むかもしれません。
※再実装したと言いつつも、実装内容は似た感じなので、ここで得た知識は使い回せるはず。

サンプルプロジェクトについて

解説用にサンプルプロジェクトを用意してます。

前回の記事同様に、取り扱っている内容が Haptic Feedback と言う物理的に作用するものとなっているので、 実機ビルドをして実際に手元で体験しながら読み進めてみて頂けると幸いです。

  • Unity 2022.3.54f1
  • Xcode 16.1

 2025-02-11 17.47.05.png

シーン及びコード一式は以下のフォルダをご覧ください。

Assets/Examples/CoreHaptics
.
├── AHAP
│   └── AHAP ファイルのサンプル (詳しくは後述)
├── Editor
│   └── AHAP に対応した ScriptedImporter (詳しくは後述)
├── Plugins
│   └── iOS
│       └── (プラグイン一式 `.cs`, `.swift` )
├── (サンプルのソース一式 `.cs` )
└── CoreHapticsExample.unity

サンプルの動作要件としては 「iOS 13 以降」 且つ 「iPhone 8 以降」 である必要があります。
(そのため、シミュレーターは勿論、Taptic Engine が搭載されていない iPad 各種でも動作しないものとなります) 3

解説しない内容

この記事では以下の内容については解説しないのでご了承下さい。

  • Core Haptics 関連
    • オーディオ機能との連携周り4
    • ゲームパッドとの連携
  • Unity 関連
    • ネイティブプラグインの基本的な実装方法

登場するクラスと記事中での表記

Core Haptics には様々なクラスが登場します。
各々の概要は後述していくとして、最初に登場するクラス一覧と記事中での呼び方を纏めておきます。

クラス名 記事中での表記
CHHapticEngine エンジン
CHHapticPatternPlayer プレイヤー
CHHapticPattern パターン
CHHapticEvent イベント
CHHapticEventParameter イベントパラメータ
CHHapticDynamicParameter ダイナミックパラメータ
CHHapticParameterCurve パラメータカーブ

再生するまでの流れ

まずは全体についてイメージしやすいように、再生するまでの流れについて解説していきます。

  1. 再生するパターンの定義
  2. エンジンを経由してパターンからプレイヤーを生成5
  3. 2 で生成したプレイヤーからハプティクスを再生

flow.png

この中で、特に理解する上で重要となってくるのが「1. 再生するパターンの定義」 です。

更に言うとパターンの構成要素であるイベントイベントパラメータについての理解も重要な要素となります。

そのため、ここからは「パターンの定義」を中心に解説していき、2, 3 については「実装解説」の章にて後述します。

パターンについて

パターン (CHHapticPattern) は「ハプティックパターン(振動のパターン)」を表すオブジェクトです。

具体的に言うと、「単一・または複数のイベント」から構成されたデータであり、他にもイベントの値を調整するためのダイナミックパラメータパラメータカーブと言った要素もデータとして持ちます。
(各要素については順に解説していきます)

※上記の図は公式ドキュメントから引用した「パターン全体のイメージ図」

イベントについて

イベント (CHHapticEvent) は「単一のハプティクス(振動)を示すオブジェクト」です。

「パターン全体のイメージ図」から見ると、赤枠の箇所に該当します。

イベント では主に以下の設定値を指定していくことで、パターンを構成していく形となります。

パラメータ 概要
type イベントのタイプ (後述)
eventParameters イベントパラメータの配列 (後述)
relativeTime イベントの発生タイミング
duration イベントの長さ6

イベントのタイプ

タイプには大きく分けて TransientContinuous の 2種類があります。7

Transient

Transient は「短い衝撃」などを示すハプティクス(振動)であり、長さ (duration) を持たない一瞬の振動が再生されます。

主な用途としては「スイッチを切り替えた際の衝撃感」や「物と物がぶつかったときの衝撃感」など、持続しない一瞬の衝撃を表現する場面などが挙げられるかと思います。
他にもゲーム用途で考えれば「パズルゲームでピースが揃ったとき」とかにも使えるかもしれません。8

サンプルでは以下の Play ボタンから再生できます。
(各種パラメータについては後述します)

「長さを持たない」と記載した通り、こちらのタイプを指定した場合にはイベントduration の値は無視されます。

Continuous

Continuous は任意の長さ (duration) を持つハプティクスであり、指定の時間だけ振動が再生され続けます。
(言葉だけだと若干分かりにくいかもですが、「指定した時間だけ再生され続けるバイブレーション機能」辺りをイメージすると分かりやすいかもしれません)

主な用途としては「武器をチャージしているときの衝撃感」や「地震的な要素を演出する際の衝撃感」などが挙げられるかと思います。

サンプルでは以下の Play ボタンから再生できます。
(各種パラメータや「SendParams ボタン」については後述します)

イベントパラメータについて

イベントパラメータ (CHHapticEventParameter)イベントで再生されるハプティクスの「強さ」や「鮮明さ」と言った具体的なパラメータを指定するためのオブジェクトです。

「パターン全体のイメージ図」から見ると、赤枠の箇所に該当します。

値としては以下の要素を持ちます。

パラメータ 概要
parameterID パラメータのタイプ (次で解説)
value 設定値

パラメータのタイプ

指定できるパラメータにはいくつかの種類がありますが、ここでは一番わかり易いものとして IntensitySharpness について解説します。

Intensity

Intensity とは「ハプティクス(振動)の強さ」を示す値であり、値が大きいほど振動が強くなり、値が小さいほど振動が弱くなります。
値は 0.0 ~ 1.0 の範囲で指定可能です。

Sharpness

Sharpness は「ハプティクスの鮮明さ」を示す値であり、値が大きいほどハプティクスが鋭い(硬い)感覚となり、値が小さいほどハプティクスが柔らかい感覚になります。
値は 0.0 ~ 1.0 の範囲で指定可能です。

イメージ的には「岩や鉄など硬いものと接触したときには大きい値を設定」、「クッションや羊さんなど柔らかいものと接触したときには小さい値を設定」と言った感じに適用できるかもしれません。

これらの値を制御することで、例えば「玉転がしゲーム」に Core Haptics を導入するとした場合、ボールが落ちる際の高低差に応じて Intensity の値を変え、ボールが走ったり落ちたりする地面や床の種類に応じて Sharpness の値を調整すると言った使い方が考えられます。
(例えば鉄板など硬い物の上を走る際には Sharpness の値を大きめに設定、芝生など柔らかい物の上を走る際には小さめに設定すると言った具合に)

他のタイプについて

ここでは解説しませんが、他にも Attack, Decay, Release, Sustained と言った値も設定することが可能です。7

正直に言うと自分が応用例と合わせて解説できるほど理解が至っていないというのが理由ではありますが...サンプルには制御できるように組み込んでいるので、興味のある方はドキュメントと合わせて御覧ください。

(軽く調べてみた感じだとオーディオ周りの概念..?)

どういった感じに値が増減されるのか?については以下の資料が参考になります。

パターンについて振り返り

一旦ここまでの内容を踏まえてパターンについて振り返ります。

シンプルなパターンを定義して解説

例えば以下のイベントで構成されたシンプルなパターンがあるとします。
(Sharpnessはいずれも 1 固定)

relativeTime type duration Intensity (イベントパラメータ)
0s Transient - 1
0.1s Transient - 0.7
0.2s Transient - 0.6
0.4s Continuous 0.4s 0.5
0.9s Transient - 0.7

これを時系列上に並べると以下のような図となり、

この中の「緑色の矢印で指しているものがイベント」、「赤色の枠で囲っているもの(つまり、イベントを時系列順に並べた集合)がパターン」に該当します。

イベント パターン

このパターンは実際にサンプルにも組み込んでおり、以下のボタンから再生可能です。
実際にどういったパターンが再生されるかは試してみてください。

パターンを定義するには?

パターンを定義するには 2通りほどのやり方があり、一つは「コード上から定義する方法」、もう一つが「AHAP ファイルとして定義する方法」です。

前者については文字通りで、コード上からパターンイベントなどの各種クラスをインスタンス化する形で定義を行います。
(このやり方については「実装解説」の章にて後述)

ここからは後者の「AHAP ファイルとして定義する方法」について解説します。

AHAP ファイルについて

AHAP9 (Apple Haptic Audio Pattern) ファイルとは、「パターンを JSON フォーマットで保存することが出来るファイル形式」です。
(拡張子は .ahap )

ファイルとして保存することで、データをプロジェクト間で使い回しやすくなる上に、OS 標準のプレビュー機能が利用可能になると言った利点が出てきます。

前章のパターンを AHAP ファイルに落とし込んでみる

例として前述したシンプルなパターンを AHAP ファイルに落とし込むと、以下のようになります。
(本来 JSON にコメントは無いですが、ここでは説明用に幾つかコメントを付け加えてます)

AHAP ファイルのサンプル
{
    // ファイルをサポートする Core Haptics のバージョン
    "Version" : 1.0,

    // メタデータ (Optional)
    "Metadata" : {
        "Project" : "(プロジェクト名)",
        "Created" : "(作成日)",
        "Description" : "(説明)"
    },

    // パターンの定義
    "Pattern" : [
        {
            // イベント1: 0.1 秒後に [Intensity:1] で Transient を再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 1
                    },
                    // `Sharpness` は全イベント共通で 1 固定
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1
                    }
                ],
                "EventType" : "HapticTransient",
                "Time" : 0.1
            }
        },
        {
            // イベント2: 0.2 秒後に [Intensity:0.7] で Transient を再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 0.7
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1
                    }
                ],
                "EventType" : "HapticTransient",
                "Time" : 0.2
            }
        },
        {
            // イベント3: 0.3 秒後に [Intensity:0.6] で Transient を再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 0.6
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1
                    }
                ],
                "EventType" : "HapticTransient",
                "Time" : 0.3
            }
        },
        {
            // イベント4: 0.4 秒後に [Intensity:0.5] で Continuous を [0.4秒間] 再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 0.5
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1
                    }
                ],
                "EventType" : "HapticContinuous",
                "Time" : 0.4,
                "EventDuration" : 0.4
            }
        },
        {
            // イベント5: 0.9 秒後に [Intensity:0.7] で Transient を再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 0.7
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1
                    }
                ],
                "EventType" : "HapticTransient",
                "Time" : 0.9
            }
        }
    ]
}

Quick Look でプレビューが可能

AHAP ファイルは macOS の Quick Look 機能 (ファイルを選択状態でスペースキーを押すことでプレビューする機能) を用いることで、以下のようにパターンをビジュアライズすることが可能です。
(以下はこちらで定義した AHAP ファイルをプレビューしたもの)

コードベースの定義だと「時系列順にどう並んでいるか?」の把握が直感的にわかりにくいかもですが、AHAP ファイル形式に落とし込んでおけば、こういった機能を活用することが可能となります。

Apple 公式が提供する AHAP ファイルのサンプル集

ちなみに、前述した Apple 公式のプラグインリポジトリには、幾つかの実装例となる AHAP ファイルのサンプル集が含まれています。

上記リポジトリで公開されている AHAP ファイルの中には、実際にゲームで使えそうなサンプルも含まれているので、興味のある方は参考にしてみてください。
(今回用意したサンプルプロジェクトにも組み込んでおり、↓から再生可能です)

ダイナミックパラメータパラメータカーブ

パターンに設定できるデータとしては、イベントの他にも 「ダイナミックパラメータパラメータカーブ」が設定可能です。

こちらを用いれば、再生中のパターンに紐づくイベントパラメータの値を動的に変更できるようになる他、事前にパターンとして組み込んでおくと言った使い方も可能です。

ダイナミックパラメータについて

ダイナミックパラメータ (CHHapticDynamicParameter) は「指定のタイミングでイベントパラメータの値を変える機能」を持ちます。

例として「Intensity:1, Sharpness:1 のイベントを 1秒間再生するパターン」に、「0.5秒後に Intensity, Sharpness をともに 0.5 に変更するダイナミックパラメータ」を適用すると、以下のような結果となります。
(説明用に幾つかコメントを付け加えてます)

ダイナミックパラメータサンプル (AHAP)
{
    "Version" : 1.0,

    // パターンの定義
    "Pattern" : [
    
        // イベントの設定
        // → [Intensity:1], [Shaprness:1] で Continuous を [1秒間] 再生
        {
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 1.0
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1.0
                    }
                ],
                "EventType" : "HapticContinuous",
                "Time" : 0.0,
                "EventDuration" : 1
            }
        },

        // ダイナミックパラメータの設定
        // → [0.5秒後]に Intensity と Sharpness の値を変更
        {
            // Intensity の値を 0.5 に変更
            // → 元の [1.0] に対し、[0.5] を乗算 (後述)
            "Parameter" : {
                "ParameterID" : "HapticIntensityControl",
                "ParameterValue" : 0.5,
                "Time" : 0.5
            },
        },
        {
            // Sharpness の値を 0.5 に変更
            // → 元の [1.0] に対し、[-0.5] を加算 (後述)
            "Parameter" : {
                "ParameterID" : "HapticSharpnessControl",
                "ParameterValue" : -0.5,
                "Time" : 0.5
            }
        }
    ]
}

パターン全体

Intensity は乗算され、Sharpness は加算される点に注意

「設定値 (ParameterValue)」は IntensitySharpness で計算結果が異なる点に注意する必要があります。
(正確に言うと「Intensity」と「それ以外」で異なってくる)

Intensity

Intensity は「元の値に乗算される」性質を持つので、再生中の値が 0.5 だとした場合には、ParameterValue0.5 を指定すると、0.5 * 0.5 = 0.25 が設定されることになります。

そのため、上記のダイナミックパラメータサンプルでは、0.5を指定することで 1.0(元の値) * 0.5(変更値) = 0.5(変更結果) となるように指定してます。

// Intensity の値を 0.5 に変更
// → 元の [1.0] に対し、[0.5] を乗算 (後述)
"Parameter" : {
    "ParameterID" : "HapticIntensityControl",
    "ParameterValue" : 0.5,
    "Time" : 0.5
},

Sharpness

Sharpness は「元の値に加算される」性質を持つので、再生中の値が 0.5 だとした場合には、ParameterValue0.5 を指定すると、0.5 + 0.5 = 1.0 が設定されることになります。

そのため、上記のダイナミックパラメータサンプルでは、-0.5を指定することで 1.0(元の値) + -0.5(変更値) = 0.5(変更結果) となるように指定してます。

// Sharpness の値を 0.5 に変更
// → 元の [1.0] に対し、[-0.5] を加算 (後述)
"Parameter" : {
    "ParameterID" : "HapticSharpnessControl",
    "ParameterValue" : -0.5,
    "Time" : 0.5
}

パラメータカーブについて

パラメータカーブ (CHHapticParameterCurve) は「指定したコントロールポイントから線形補間された値を適用できる機能」を持ちます。

例として「Intensity:1, Sharpness:1 のイベントを 1秒間再生するパターン」に「開始から 0.5秒間かけて、IntensityShapness1.0 から 0.5 に下げていくカーブ」を適用すると、以下のような結果となります。
(説明用に幾つかコメントを付け加えてます)

パラメータカーブサンプル (AHAP)
{
    "Version" : 1.0,

    // パターンの定義
    "Pattern" : [
        {
            // イベントの設定
            // → [Intensity:1], [Shaprness:1] で Continuous を [1秒間] 再生
            "Event" : {
                "EventParameters" : [
                    {
                        "ParameterID" : "HapticIntensity",
                        "ParameterValue" : 1.0
                    },
                    {
                        "ParameterID" : "HapticSharpness",
                        "ParameterValue" : 1.0
                    }
                ],
                "EventType" : "HapticContinuous",
                "Time" : 0.0,
                "EventDuration" : 1
            }
        },

        // 「パラメータカーブ」の設定
        // → 開始から 0.5 秒間かけて、 Intensity と Sharpness を [1.0] から [0.5] に下げていく
        {
            // Intensity の値を [1.0] から [0.5] に変更していく
            // NOTE: ParameterValue の性質についてはダイナミックパラメータと同様
            "ParameterCurve" : {
                "ParameterID" : "HapticIntensityControl",
                "ParameterCurveControlPoints" : [
                    {
                        "ParameterValue" : 1.0,
                        "Time" : 0.0
                    },
                    {
                        "ParameterValue" : 0.5,
                        "Time" : 0.5
                    }
                ],
                "Time" : 0.0
            }
        },
        {
            // Sharpness の値を [1.0] から [0.5] に変更していく
            "ParameterCurve" : {
                "ParameterID" : "HapticSharpnessControl",
                "ParameterCurveControlPoints" : [
                    {
                        "ParameterValue" : 0.0,
                        "Time" : 0.0
                    },
                    {
                        "ParameterValue" : -0.5,
                        "Time" : 0.5
                    }
                ],
                "Time" : 0.0
            }
        }
    ]
}

パターン全体

実装解説

ここからは実装解説に入っていきます。

前回の記事同様に、ネイティブプラグインの基本的な実装方法までは取り扱わないので、キャッチアップが必要な方は拙著ですが、以下の記事などに目を通してみてください。

全編通してコード量が多いため、基本的にサンプルコードは以下のような形式で折り畳むようにしてます。

サンプルコード (クリックで展開)
(ファイル名)
// サンプルコード

再生するパターンの定義

イベントの実装

先ずはイベントのクラスである CHHapticEvent の実装から入ります。

内容としては、ほぼネイティブ側のクラスを C# で再実装したものとなりますが、ポイントとしては AHAP 形式 (≒ JSON 形式) へのシリアライズを行えるように、[Serializable] 属性を付けた上で、フィールド名も AHAP に準拠させてます。
(FYI: Representing haptic patterns in AHAP files )

サンプルコード (クリックで展開)
CHHapticEvent.cs
using System;

// ReSharper disable InconsistentNaming
namespace Examples.CoreHaptics.Plugins.iOS
{
    /// <summary>
    /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticevent">CHHapticEvent</see> の実装
    /// </summary>
    [Serializable]
    public sealed class CHHapticEvent
    {
        // type
        public string EventType;

        // eventParameters
        public CHHapticEventParameter[] EventParameters;

        // relativeTime
        public float Time;

        // duration
        public float EventDuration;

        public CHHapticEvent(
            Type eventType,
            CHHapticEventParameter[] eventParameters,
            float relativeTime,
            float duration = 0f)
        {
            EventType = eventType.ToString();
            EventParameters = eventParameters;
            Time = relativeTime;
            EventDuration = duration;
        }

        /// <summary>
        /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticevent/eventtype">CHHapticEvent.EventType</see>
        /// </summary>
        public enum Type
        {
            HapticTransient = 0,
            HapticContinuous,
        }
    }
}

AHAP 形式へシリアライズする理由について先に説明しておくと、今回のサンプルでは C# 上で定義したパターンをネイティブに送る際には、一度 AHAP 形式に変換してから送るような実装としているためです。

AHAP 形式は前述した通り、JSON 形式と同一であり、データとしては文字列として持つことが可能であるため、ネイティブ側へデータを送る際にも文字列の送信だけで済むようになります。

イベントパラメータの実装

次はイベントパラメータである CHHapticEventParameter の実装です。

こちらも前述のイベント同様に、ネイティブ側のクラスを C# で再実装したもの + AHAP 形式へシリアライズ出来るようにしてます。

サンプルコード (クリックで展開)
CHHapticEventParameter.cs
using System;

// ReSharper disable InconsistentNaming
namespace Examples.CoreHaptics.Plugins.iOS
{
    /// <summary>
    /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticeventparameter">CHHapticEventParameter</see> の実装
    /// </summary>
    [Serializable]
    public sealed class CHHapticEventParameter
    {
        // parameterID
        public string ParameterID;

        // value
        public float ParameterValue;

        public CHHapticEventParameter(ID parameterID, float value)
        {
            ParameterID = parameterID.ToString();
            ParameterValue = value;
        }

        /// <summary>
        /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid">CHHapticEvent.ParameterID</see>
        /// </summary>
        public enum ID
        {
            HapticIntensity = 0,
            HapticSharpness,
            AttackTime,
            DecayTime,
            ReleaseTime,
            Sustained,
        }
    }
}

パターンの実装

パターンである CHHapticPattern の実装です。

ここではコンストラクタで受け取ったイベントや各種パラメータを保持しているだけであり、それらのデータを纏めて AHAP 形式の文字列として出力するメソッドだけを持ちます。

サンプルコード (クリックで展開)
CHHapticPattern.cs
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.Pool;

namespace Examples.CoreHaptics.Plugins.iOS
{
    /// <summary>
    /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticpattern">CHHapticPattern</see> の実装
    /// </summary>
    public sealed class CHHapticPattern
    {
        private readonly List<CHHapticEvent> _events;
        private readonly List<CHHapticDynamicParameter> _dynamicParameters;
        private readonly List<CHHapticParameterCurve> _parameterCurves;

        public CHHapticPattern(CHHapticEvent[] events)
        {
            _events = new List<CHHapticEvent>(events);
        }

        public CHHapticPattern(CHHapticEvent[] events, CHHapticDynamicParameter[] parameters)
        {
            _events = new List<CHHapticEvent>(events);
            _dynamicParameters = new List<CHHapticDynamicParameter>(parameters);
        }

        public CHHapticPattern(CHHapticEvent[] events, CHHapticParameterCurve[] parameterCurves)
        {
            _events = new List<CHHapticEvent>(events);
            _parameterCurves = new List<CHHapticParameterCurve>(parameterCurves);
        }

        /// <summary>
        /// AHAP 形式に変換
        /// </summary>
        public string ToAhap()
        {
            // 各パラメータを AHAP に準拠した JSON 形式に変換して返す。

            // NOTE:
            // このサンプルでは AHAP へのシリアライズを行う際には JsonUtility を用いており、
            // 一部の箇所はその制約からそのままシリアライズを行うことが出来ないので、手動で文字列を組み立てることで解決している。
            using var _ = ListPool<string>.Get(out var pattern);

            if (_events != null)
            {
                pattern.AddRange(_events.Select(JsonUtility.ToJson)
                    .Select(str => $"{{ \"Event\": {str} }}"));
            }

            if (_dynamicParameters != null)
            {
                pattern.AddRange(_dynamicParameters.Select(JsonUtility.ToJson)
                    .Select(str => $"{{ \"Parameter\": {str} }}"));
            }

            if (_parameterCurves != null)
            {
                pattern.AddRange(_parameterCurves.Select(JsonUtility.ToJson)
                    .Select(str => $"{{ \"ParameterCurve\": {str} }}"));
            }

            // NOTE: サンプルなのでバージョンは `1.0` 固定
            return $"{{\"Version\":1.0,\"Pattern\":[{string.Join(",", pattern)}]}}";
        }
    }
}

ちなみに AHAP 形式への変換には JsonUtility を用いてますが、特にこれと言った理由は無いので、別のライブラリを用いればもっと簡潔に実装できるかもしれません。10

パターンをコード上から定義する

前の章で定義したシンプルなパターンをコード上から定義すると、以下のようになります。

サンプルコード (クリックで展開)
PatternSamples.cs
public static CHHapticPattern Sample1()
{
    // Sharpness は 1 固定
    const float sharpness = 1f;

    var hapticEvents = new[]
    {
        // 0.1 秒後に [Intensity:1] で Transient を再生
        new CHHapticEvent(
            CHHapticEvent.Type.HapticTransient,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 1f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, sharpness),
            },
            relativeTime: 0.1f),

        // 0.2 秒後に [Intensity:0.7] で Transient を再生
        new CHHapticEvent(
            CHHapticEvent.Type.HapticTransient,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 0.7f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, sharpness),
            },
            relativeTime: 0.2f),

        // 0.3 秒後に [Intensity:0.6] で Transient を再生
        new CHHapticEvent(
            CHHapticEvent.Type.HapticTransient,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 0.6f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, sharpness),
            },
            relativeTime: 0.3f),

        // 0.4 秒後に [Intensity:0.5] で Continuous を [0.4秒間] 再生
        new CHHapticEvent(
            CHHapticEvent.Type.HapticContinuous,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 0.5f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, sharpness),
            },
            duration: 0.4f,
            relativeTime: 0.4f),

        // 0.9 秒後に [Intensity:0.7] で Transient を再生
        new CHHapticEvent(
            CHHapticEvent.Type.HapticTransient,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 0.7f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, sharpness),
            },
            relativeTime: 0.9f),
    };

    return new CHHapticPattern(hapticEvents);
}

また、今回のサンプルでは固定値のみですが、工夫すれば引数から各種パラメータを設定できるようにすることも可能かと思います。

AHAP ファイルを TextAsset としてインポートできるようにする

AHAP ファイルは実態こそ JSON ではありますが、拡張子が .ahap なために Unity でインポートしても JSON ファイル(と言うよりか TextAsset)として認識されません。

そのため、サンプルプロジェクトでは以下のような ScriptedImporter を継承したエディタ拡張を実装することで、AHAP ファイルを TextAsset として扱えるようにしてます。

サンプルコード (クリックで展開)
AHAPFileImporter.cs
using System.IO;
using UnityEditor.AssetImporters;
using UnityEngine;

namespace Examples.CoreHaptics.Editor
{
    /// <summary>
    /// AHAP ファイルを TextAsset としてインポートするためのクラス
    /// </summary>
    [ScriptedImporter(1, "ahap")]
    internal sealed class AhapFileImporter : ScriptedImporter
    {
        public override void OnImportAsset(AssetImportContext ctx)
        {
            var data = File.ReadAllText(ctx.assetPath);
            var ahapTextAsset = new TextAsset(data);
            ctx.AddObjectToAsset("main", ahapTextAsset);
            ctx.SetMainObject(ahapTextAsset);
        }
    }
}

エンジンについて

エンジン (CHHapticEngine) は「プレイヤーの生成や、ミュートの設定・イベントハンドリングと言った各種制御」を行うためのオブジェクトです。(プレイヤーについては後述)

他にもパターンを渡すことで直接ハプティクスを再生できる API なんかも持ちます。

Core Haptics を利用する上では、先ずはこちらのクラスを生成しておく必要がありますが、全部解説すると長いので、ここでは幾つかのポイントだけ解説します。

イベントハンドラについて

エンジンは何かしらの要因で停止する可能性があるため、それを検知するためのイベントハンドラが提供されています。

こちらは停止してしまった際の復帰処理などに必要となるため、C# 側でも購読できるようにしておきます。

サンプルコード (クリックで展開)

※全部載せると長いので、初期化部分だけサンプルコードを載せておきます。

CHHapticEngine.cs
using System;
using System.Runtime.InteropServices;
using AOT;

namespace Examples.CoreHaptics.Plugins.iOS
{
    /// <summary>
    /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticengine">CHHapticEngine</see> の実装
    /// </summary>
    // ReSharper disable once InconsistentNaming
    public sealed class CHHapticEngine : IDisposable
    {
        /// <summary>
        /// 停止した理由
        /// </summary>
        /// <seealso href="https://developer.apple.com/documentation/corehaptics/chhapticengine/stoppedreason"/>
        public enum StoppedReason
        {
            AudioSessionInterrupt = 1,
            ApplicationSuspended = 2,
            IdleTimeout = 3,
            NotifyWhenFinished = 4,
            EngineDestroyed = 5,
            GameControllerDisconnect = 6,
            SystemError = -1
        }

        public interface IEventHandler
        {
            /// <summary>
            /// <see cref="CHHapticEngine"/> が停止したときに呼ばれるイベント
            /// </summary>
            /// <seealso href="https://developer.apple.com/documentation/corehaptics/chhapticengine/stoppedhandler-swift.property"/>
            void OnStopped(StoppedReason reason);

            /// <summary>
            /// <see cref="CHHapticEngine"/> がリセットされたときに呼ばれるイベント
            /// </summary>
            /// <seealso href="https://developer.apple.com/documentation/corehaptics/chhapticengine/resethandler-swift.property"/>
            void OnReset();

            // 各メソッドごとのエラーイベント(初期化以外のメソッドは中略)
            void OnErrorWithInit(string message);
        }

        private static IEventHandler _eventHandler;
        private readonly IntPtr _instance;

        public CHHapticEngine()
        {
            // エンジンを生成してインスタンスを受け取る
            // その際に各種イベントのコールバックも渡しておく
            _instance = NativeMethod(OnError, OnStopped, OnReset);

            [DllImport("__Internal", EntryPoint = "init_CHHapticEngine")]
            static extern IntPtr NativeMethod(
                [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onCreateError,
                [MarshalAs(UnmanagedType.FunctionPtr)] OnStoppedDelegate onStopped,
                [MarshalAs(UnmanagedType.FunctionPtr)] OnResetDelegate onReset);

            // エラー発生時のコールバック
            [MonoPInvokeCallback(typeof(OnErrorDelegate))]
            static void OnError(string error) => _eventHandler?.OnErrorWithInit(error);

            // エンジンが停止した際のコールバック
            [MonoPInvokeCallback(typeof(OnStoppedDelegate))]
            static void OnStopped(Int32 code) => _eventHandler?.OnStopped((StoppedReason)code);

            // エンジンがリセットされた際のコールバック
            [MonoPInvokeCallback(typeof(OnResetDelegate))]
            static void OnReset() => _eventHandler?.OnReset();
        }

        public void SetEventHandler(IEventHandler eventHandler)
        {
            _eventHandler = eventHandler;
        }

        private delegate void OnStoppedDelegate(Int32 code);
        private delegate void OnResetDelegate();
        private delegate void OnErrorDelegate(string error);
    }
}

CHHapticEngineBridge.swift
import Foundation
import CoreHaptics

@_cdecl("init_CHHapticEngine")
func init_CHHapticEngine(
    _ onCreateError: OnErrorDelegate,
    _ onStopped: OnStoppedDelegate,
    _ onReset: OnResetDelegate)
-> UnsafeMutableRawPointer? {
    do {
        let engine = try CHHapticEngine()
        
        // NOTE: コールバックは基本的に MainThread に戻してから返すようにする
        engine.stoppedHandler = { reason in
            DispatchQueue.main.async {
                onStopped(Int32(reason.rawValue))
            }
        }
        
        engine.resetHandler = {
            DispatchQueue.main.async {
                onReset()
            }
        }
        
        let unmanaged = Unmanaged<CHHapticEngine>.passRetained(engine)
        return unmanaged.toOpaque()
    } catch let error {
        onCreateError(error.localizedDescription.toCharPtr())
        return nil
    }
}

復帰処理のサンプルコード

サンプルでは CHHapticEngineHapticEngine と言うクラスでラップしており、その際に自身が実装している CHHapticEngine.IEventHandler を渡すことでイベント処理を受け取れるようにしてます。

その上で、エラー発生時にはエンジンが停まっている可能性があるため、 イベント処理が発火された際には_isNeedsStart と言うフラグを立てておき、各メソッド実行時にフラグをチェックするによって、必要に応じてエンジンの再起動を掛けられるようにしてます。
(StartHapticEngineIfNeeded を参照)

サンプルコード (クリックで展開)
HapticEngine.cs
using System;
using Examples.CoreHaptics.Plugins.iOS;
using UnityEngine;

namespace Examples.CoreHaptics
{
    /// <summary>
    /// CHHapticEngine のラッパークラス
    /// </summary>
    public sealed class HapticEngine : IHapticEngine, CHHapticEngine.IEventHandler
    {
        private readonly CHHapticEngine _hapticEngine;
        private bool _isNeedsStart;

        public HapticEngine()
        {
            _hapticEngine = new CHHapticEngine();
            _hapticEngine.SetEventHandler(this);
        }

        public void Dispose()
        {
            _hapticEngine.Dispose();
        }

        public void StartHapticEngine()
        {
            _hapticEngine.Start();
            _isNeedsStart = false;
        }

        public void StopHapticEngine()
        {
            _hapticEngine.Stop();
            _isNeedsStart = true;
        }

        public void PlayPattern(CHHapticPattern pattern)
        {
            StartHapticEngineIfNeeded();
            _hapticEngine.PlayPattern(pattern);
        }

        public void PlayPattern(string patternJson)
        {
            StartHapticEngineIfNeeded();
            _hapticEngine.PlayPattern(patternJson);
        }

        public CHHapticPatternPlayer MakePlayer(CHHapticPattern pattern)
        {
            StartHapticEngineIfNeeded();
            return _hapticEngine.MakePlayer(pattern);
        }

        private void StartHapticEngineIfNeeded()
        {
            // エンジンが停まっている場合には再起動を行う
            if (_isNeedsStart)
            {
                StartHapticEngine();
            }
        }

        void CHHapticEngine.IEventHandler.OnReset()
        {
            Debug.Log("OnReset");
            _isNeedsStart = true;
        }

        void CHHapticEngine.IEventHandler.OnStopped(CHHapticEngine.StoppedReason reason)
        {
            Debug.Log($"OnStopped: {reason}");

            switch (reason)
            {
                case CHHapticEngine.StoppedReason.AudioSessionInterrupt:
                    Debug.Log("Audio session interrupt.");
                    break;
                case CHHapticEngine.StoppedReason.ApplicationSuspended:
                    Debug.Log("Application suspended.");
                    break;
                case CHHapticEngine.StoppedReason.IdleTimeout:
                    Debug.Log("Idle timeout.");
                    break;
                case CHHapticEngine.StoppedReason.NotifyWhenFinished:
                    Debug.Log("Finished.");
                    break;
                case CHHapticEngine.StoppedReason.EngineDestroyed:
                    Debug.Log("Engine destroyed.");
                    break;
                case CHHapticEngine.StoppedReason.GameControllerDisconnect:
                    Debug.Log("Controller disconnected.");
                    break;
                case CHHapticEngine.StoppedReason.SystemError:
                    Debug.Log("System error.");
                    break;
                default:
                    Debug.Log("Unknown error");
                    break;
            }

            // NOTE:
            // 今回はサンプル実装と言うのもあって、エラーの種類関係なしに復帰を行うような簡易実装に留めているが、
            // ちゃんとやるならエラー内容を見て適切な復帰処理を実装したほうが良いかも。
            _isNeedsStart = true;
        }

        void CHHapticEngine.IEventHandler.OnErrorWithInit(string message)
            => Debug.LogError($"CHHapticEngine.Init Error: {message}");
    }
}

アプリが Foreground の時のみ有効化

Core Haptics を動かすには、アプリが Foreground で実行されている必要があります。
そのため、サンプルでは OnApplicationPause からエンジンの停止・復帰処理を呼び出すようにしてます。

サンプルコード (クリックで展開)
CoreHapticsExample.cs
private void OnApplicationPause(bool pauseStatus)
{
    if (pauseStatus)
    {
        _hapticEngine?.StopHapticEngine();
    }
    else
    {
        _hapticEngine?.StartHapticEngine();
    }
}

プレイヤーを生成せずにパターンを再生

次で解説するプレイヤーが持つ機能を使わずにパターンを再生するだけであれば、エンジンが持つメソッドから直接再生することが可能です。

今回用意したサンプルでは、上記のメソッドをプラグインから呼び出す機能も実装しているので、参考程度にコードを載せておきます。

サンプルコード (クリックで展開)
CHHapticEngine.cs
/// <summary>
/// パターンの再生
/// </summary>
/// <param name="ahapStr">再生する AHAP ファイル形式の文字列</param>
public void PlayPattern(string ahapStr)
{
    NativeMethod(_instance, ahapStr, OnError);

    [DllImport("__Internal", EntryPoint = "playPattern_CHHapticEngine")]
    static extern void NativeMethod(IntPtr instance, string patternJson, [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

    [MonoPInvokeCallback(typeof(OnErrorDelegate))]
    static void OnError(string error) => _eventHandler?.OnErrorWithPlayPattern(error);
}
CHHapticEngineBridge.swift
@_cdecl("playPattern_CHHapticEngine")
func playPattern_CHHapticEngine(
    _ instancePtr: UnsafeRawPointer,
    _ ahapStrPtr: CCharPtr,
    _ onError: OnErrorDelegate)
{
    let engine = Unmanaged<CHHapticEngine>.fromOpaque(instancePtr).takeUnretainedValue()
    let ahapStr = ahapStrPtr.toString()
    do {
        if let data = ahapStr.data(using: .utf8) {
            try engine.playPattern(from: data)
        }
    } catch let error {
        onError(error.localizedDescription.toCharPtr())
    }
}

プレイヤーについて

プレイヤー (CHHapticPatternPlayer) とはエンジンを通してパターンから生成されるオブジェクトであり、「パターンの再生・停止、イベントパラメータの動的な更新」と言った機能を持ちます。

また、上記の CHHapticPatternPlayer の他にも「ループ再生、再生速度の変更、再生完了時のイベント通知」と言った、より高度な機能を持つ CHHapticAdvancedPatternPlayer と言うのもあります。

今回用意したサンプルでは CHHapticPatternPlayer の方を実装しており、コード全体を載せると以下のようになります。

「プレイヤー」全体のサンプルコード (クリックで展開)
CHHapticPatternPlayer.cs
using System;
using System.Linq;
using System.Runtime.InteropServices;
using AOT;

namespace Examples.CoreHaptics.Plugins.iOS
{
    /// <summary>
    /// <see href="https://developer.apple.com/documentation/corehaptics/chhapticpatternplayer">CHHapticPatternPlayer</see> の実装
    /// </summary>
    // ReSharper disable once InconsistentNaming
    public sealed class CHHapticPatternPlayer : IDisposable
    {
        /// <summary>
        /// エラー発生時のイベント
        /// </summary>
        /// <remarks>
        /// NOTE:
        /// - こちらは複数のインスタンスが生成される想定があるので、event 構文で登録できるようにしている
        /// - このサンプルでは簡略化のために全 API で共通のイベントを呼び出しているが、ちゃんとやるならメソッドごとに分けるなりしても良いかも
        /// </remarks>
        public event Action<string> OnError
        {
            add => OnErrorInternal += value;
            remove => OnErrorInternal -= value;
        }

        private static event Action<string> OnErrorInternal;
        private readonly IntPtr _instance;

        internal CHHapticPatternPlayer(IntPtr instance)
        {
            _instance = instance;
        }

        public void Dispose()
        {
            NativeMethod(_instance);

            [DllImport("__Internal", EntryPoint = "release_CHHapticPatternPlayer")]
            static extern void NativeMethod(IntPtr instance);
        }

        /// <summary>
        /// 再生
        /// </summary>
        public void Start(float atTime = 0f)
        {
            NativeMethod(_instance, atTime, OnError);

            [DllImport("__Internal", EntryPoint = "start_CHHapticPatternPlayer")]
            static extern void NativeMethod(IntPtr instance, float atTime, [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

            [MonoPInvokeCallback(typeof(OnErrorDelegate))]
            static void OnError(string error) => OnErrorInternal?.Invoke(error);
        }

        /// <summary>
        /// 停止
        /// </summary>
        public void Stop(float atTime = 0f)
        {
            NativeMethod(_instance, atTime, OnError);

            [DllImport("__Internal", EntryPoint = "stop_CHHapticPatternPlayer")]
            static extern void NativeMethod(IntPtr instance, float atTime, [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

            [MonoPInvokeCallback(typeof(OnErrorDelegate))]
            static void OnError(string error) => OnErrorInternal?.Invoke(error);
        }

        /// <summary>
        /// キャンセル
        /// </summary>
        public void Cancel()
        {
            NativeMethod(_instance, OnError);

            [DllImport("__Internal", EntryPoint = "cancel_CHHapticPatternPlayer")]
            static extern void NativeMethod(IntPtr instance, [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

            [MonoPInvokeCallback(typeof(OnErrorDelegate))]
            static void OnError(string error) => OnErrorInternal?.Invoke(error);
        }

        /// <summary>
        /// パラメータの設定
        /// </summary>
        public void SendParameters(CHHapticDynamicParameter[] parameters, float atTime = 0f)
        {
            if (parameters is not { Length: > 0 })
            {
                throw new ArgumentException("No CHHapticDynamicParameters were provided for sending.");
            }

            var sendParams = parameters.Select(p => p.ToBlittableDynamicParameter()).ToArray();
            var handle = GCHandle.Alloc(sendParams, GCHandleType.Pinned);
            NativeMethod(_instance, handle.AddrOfPinnedObject(), parameters.Length, atTime, OnError);
            handle.Free();

            [DllImport("__Internal", EntryPoint = "sendParameters_CHHapticPatternPlayer")]
            static extern void NativeMethod(
                IntPtr instance,
                IntPtr parameters,
                Int32 parametersLength,
                float atTime,
                [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

            [MonoPInvokeCallback(typeof(OnErrorDelegate))]
            static void OnError(string error) => OnErrorInternal?.Invoke(error);
        }

        private delegate void OnErrorDelegate(string error);
    }
}
CHHapticPatternPlayerBridge.swift
import Foundation
import CoreHaptics

@_cdecl("release_CHHapticPatternPlayer")
func release_CHHapticPatternPlayer(_ instancePtr: UnsafeRawPointer) {
    let unmanaged = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr)
    unmanaged.release()
}

@_cdecl("start_CHHapticPatternPlayer")
func start_CHHapticPatternPlayer(
    _ instancePtr: UnsafeRawPointer,
    _ atTime: Float32,
    _ onError: OnErrorDelegate)
{
    let player = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr).takeUnretainedValue()
    do {
        let atTime = atTime == 0 ? CHHapticTimeImmediate : TimeInterval(atTime)
        try player.start(atTime: atTime)
    } catch let error {
        onError(error.localizedDescription.toCharPtr())
    }
}

@_cdecl("stop_CHHapticPatternPlayer")
func stop_CHHapticPatternPlayer(
    _ instancePtr: UnsafeRawPointer,
    _ atTime: Float32,
    _ onError: OnErrorDelegate)
{
    let player = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr).takeUnretainedValue()
    do {
        let atTime = atTime == 0 ? CHHapticTimeImmediate : TimeInterval(atTime)
        try player.stop(atTime: atTime)
    } catch let error {
        onError(error.localizedDescription.toCharPtr())
    }
}

@_cdecl("cancel_CHHapticPatternPlayer")
func cancel_CHHapticPatternPlayer(
    _ instancePtr: UnsafeRawPointer,
    _ onError: OnErrorDelegate)
{
    let player = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr).takeUnretainedValue()
    do {
        try player.cancel()
    } catch let error {
        onError(error.localizedDescription.toCharPtr())
    }
}

@_cdecl("sendParameters_CHHapticPatternPlayer")
func sendParameters_CHHapticPatternPlayer(
    _ instancePtr: UnsafeRawPointer,
    _ parametersPtr: UnsafeRawPointer,
    _ parametersLength: Int32,
    _ atTime: Float32,
    _ onError: OnErrorDelegate)
{
    let player = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr).takeUnretainedValue()
    
    // UnsafeRawPointer から [BlittableDynamicParameter] への変換
    let count = Int(parametersLength)
    let typedPointer = parametersPtr.bindMemory(to: BlittableDynamicParameter.self, capacity: count)
    let buffer = UnsafeBufferPointer(start: typedPointer, count: count)
    let blittableDynamicParams = Array(buffer)
    
    // [BlittableDynamicParameter] を [CHHapticDynamicParameter] に変換
    var dynamicParams = [CHHapticDynamicParameter]()
    for param in blittableDynamicParams {
        let dynamicParam = CHHapticDynamicParameter(
            parameterID: dynamicParameterForInt(Int(param.parameterId)),
            value: Float(param.parameterValue),
            relativeTime: TimeInterval(param.time))
        dynamicParams.append(dynamicParam)
    }
    
    do {
        let atTime = atTime == 0 ? CHHapticTimeImmediate : TimeInterval(atTime)
        try player.sendParameters(dynamicParams, atTime: atTime)
    }catch let error {
        
        onError(error.localizedDescription.toCharPtr())
    }
}

struct BlittableDynamicParameter {
    let parameterId: Int32
    let time: Float32
    let parameterValue: Float32
}

// refered to: https://github.com/apple/unityplugins/blob/main/plug-ins/Apple.CoreHaptics/Native/CoreHapticsWrapper/Haptics/Utilities.swift
@available(iOS 13, tvOS 14, macOS 10, *)
func dynamicParameterForInt(_ val: Int) -> CHHapticDynamicParameter.ID {
    switch val {
    case 0:
        return CHHapticDynamicParameter.ID.hapticIntensityControl
    case 1:
        return CHHapticDynamicParameter.ID.hapticSharpnessControl
    case 2:
        return CHHapticDynamicParameter.ID.hapticAttackTimeControl
    case 3:
        return CHHapticDynamicParameter.ID.hapticDecayTimeControl
    case 4:
        return CHHapticDynamicParameter.ID.hapticReleaseTimeControl
    case 5:
        return CHHapticDynamicParameter.ID.audioVolumeControl
    case 6:
        return CHHapticDynamicParameter.ID.audioPanControl
    case 7:
        return CHHapticDynamicParameter.ID.audioPitchControl
    case 8:
        return CHHapticDynamicParameter.ID.audioBrightnessControl
    case 9:
        return CHHapticDynamicParameter.ID.audioAttackTimeControl
    case 10:
        return CHHapticDynamicParameter.ID.audioDecayTimeControl
    case 11:
        return CHHapticDynamicParameter.ID.audioReleaseTimeControl
    default:
        return CHHapticDynamicParameter.ID.hapticIntensityControl
    }
}

ちなみに、前述の Apple 公式プラグインからは CHHapticAdvancedPatternPlayer を生成することも可能なので、公式プラグインを用いる場合には、用途に応じて使い分けて行くと良いかもしれません。 

(FYI: Apple - Core Haptics -> 6. Advanced Features)

プレイヤーの生成と再生

プレイヤーの生成はエンジンを通して行います。
サンプルでは以下のように「パターンのインスタンス」と「AHAP の文字列」の両方から生成できるようにしてます。

サンプルコード (クリックで展開)
CHHapticEngine.cs
/// <summary>
/// <see cref="CHHapticPatternPlayer"/> の生成
/// </summary>
/// <param name="pattern">再生する <see cref="CHHapticPattern"/></param>
public CHHapticPatternPlayer MakePlayer(CHHapticPattern pattern)
{
    return MakePlayer(pattern.ToAhap());
}

/// <summary>
/// <see cref="CHHapticPatternPlayer"/> の生成
/// </summary>
/// <param name="ahapStr">再生する AHAP 形式の文字列</param>
public CHHapticPatternPlayer MakePlayer(string ahapStr)
{
    var instance = NativeMethod(_instance, ahapStr, OnError);
    return new CHHapticPatternPlayer(instance);

    [DllImport("__Internal", EntryPoint = "makePlayer_CHHapticEngine")]
    static extern IntPtr NativeMethod(IntPtr instance, string patternJson, [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

    [MonoPInvokeCallback(typeof(OnErrorDelegate))]
    static void OnError(string error) => _eventHandler?.OnErrorWithMakePlayer(error);
}

あとは Start() メソッドを呼び出すことで、「生成時に渡したパターン」が再生されます。
ここまでのおさらいとして、一通りの流れを実装すると以下のようになります。

// 1. 再生するパターンを定義
var pattern = new HapticPattern(new[]
{
    new HapticEvent(
        HapticEvent.Type.HapticTransient,
        new[]
        {
            new HapticEventParameter(HapticEventParameter.ID.HapticIntensity, 1f),
            new HapticEventParameter(HapticEventParameter.ID.HapticSharpness, 1f),
        },
        relativeTime: 0f),
});

// 2. パターンからプレイヤーを生成
using var player = _hapticEngine.MakePlayer(pattern);

// 3. 再生
player.Start();

再生中のパターンの値を変更する

プレイヤーが持つ sendPamaterers と言うメソッドを用いれば、再生中のパターンに紐づくイベントパラメータの値を動的に変更することが可能です。

ここで渡す値は前述したダイナミックパラメータであり、サンプルでは以下の SendParams ボタンから再生中のパターンに対して変更を適用することが可能です。
(こちらで記載した通り、IntensitySharpness で計算結果が異なる点には注意)

サンプルコード (クリックで展開)
CHHapticPatternPlayer.cs
/// <summary>
/// パラメータの設定
/// </summary>
public void SendParameters(CHHapticDynamicParameter[] parameters, float atTime = 0f)
{
    if (parameters is not { Length: > 0 })
    {
        throw new ArgumentException("No CHHapticDynamicParameters were provided for sending.");
    }

    var sendParams = parameters.Select(p => p.ToBlittableDynamicParameter()).ToArray();
    var handle = GCHandle.Alloc(sendParams, GCHandleType.Pinned);
    NativeMethod(_instance, handle.AddrOfPinnedObject(), parameters.Length, atTime, OnError);
    handle.Free();

    [DllImport("__Internal", EntryPoint = "sendParameters_CHHapticPatternPlayer")]
    static extern void NativeMethod(
        IntPtr instance,
        IntPtr parameters,
        Int32 parametersLength,
        float atTime,
        [MarshalAs(UnmanagedType.FunctionPtr)] OnErrorDelegate onError);

    [MonoPInvokeCallback(typeof(OnErrorDelegate))]
    static void OnError(string error) => OnErrorInternal?.Invoke(error);
}
CHHapticPatternPlayerBridge.swift
@_cdecl("sendParameters_CHHapticPatternPlayer")
func sendParameters_CHHapticPatternPlayer(
    _ instancePtr: UnsafeRawPointer,
    _ parametersPtr: UnsafeRawPointer,
    _ parametersLength: Int32,
    _ atTime: Float32,
    _ onError: OnErrorDelegate)
{
    let player = Unmanaged<CHHapticPatternPlayer>.fromOpaque(instancePtr).takeUnretainedValue()
    
    // UnsafeRawPointer から [BlittableDynamicParameter] への変換
    let count = Int(parametersLength)
    let typedPointer = parametersPtr.bindMemory(to: BlittableDynamicParameter.self, capacity: count)
    let buffer = UnsafeBufferPointer(start: typedPointer, count: count)
    let blittableDynamicParams = Array(buffer)
    
    // [BlittableDynamicParameter] を [CHHapticDynamicParameter] に変換
    var dynamicParams = [CHHapticDynamicParameter]()
    for param in blittableDynamicParams {
        let dynamicParam = CHHapticDynamicParameter(
            parameterID: dynamicParameterForInt(Int(param.parameterId)),
            value: Float(param.parameterValue),
            relativeTime: TimeInterval(param.time))
        dynamicParams.append(dynamicParam)
    }
    
    do {
        let atTime = atTime == 0 ? CHHapticTimeImmediate : TimeInterval(atTime)
        try player.sendParameters(dynamicParams, atTime: atTime)
    }catch let error {
        
        onError(error.localizedDescription.toCharPtr())
    }
}

おわりに

Core Haptics について一通り解説してみました。
(カスタマイズ性の高さ故に思ったよりもボリューミーになってしまった...)

パターンの定義については実機上での確認を容易にするためのツールがあると便利そうだな〜と思いつつも、今回は用意することができなかったので....もし作る機会ができたら追記でもしようかなと考えてます。
(例えば「PC 上からエディットした AHAP を実機上に送信して再生する仕組み」など)

最後に幾つか書き漏らした点について補足して終わりとします。

Core Haptics を使う上での参考情報

補足する場所が無かったので、こちらで紹介しますが、WWDC の以下の講演動画辺りは Core Haptics を使いこなす上での参考情報になりそうでした。

具体的に言うと Core Haptics を導入する上でのノウハウについて話されており、「因果関係 (Causality)」「調和性 (Harmony)」「有用性 (Utility)」と言った 3つの原則を元に「どういった設計が良いのか?」と言った話がされてます。

サンプルの未使用ボタンについて

本編で書き漏らしましたが、サンプルにある以下のボタンは未使用です。

とは言いつつも、PatternSamples.cs にある Sample2, Sample3 と言うメソッドを呼ぶようには作ってあるので、自分で独自に書き換えるなどして検証用途に用いてみてください

サンプルコード (クリックで展開)
PatternSamples.cs
public static CHHapticPattern Sample2()
{
    // 検証用
    var hapticEvents = new CHHapticEvent[6];
    for (var i = 0; i < hapticEvents.Length; ++i)
    {
        var value = i * 0.2f;
        hapticEvents[i] =
            new CHHapticEvent(
                CHHapticEvent.Type.HapticTransient,
                new[]
                {
                    new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 1f),
                    new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, value),
                },
                relativeTime: value);
    }

    return new CHHapticPattern(hapticEvents);
}

public static CHHapticPattern Sample3()
{
    // 検証用
    var hapticEvents = new[]
    {
        new CHHapticEvent(
            CHHapticEvent.Type.HapticTransient,
            new[]
            {
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticIntensity, 1f),
                new CHHapticEventParameter(CHHapticEventParameter.ID.HapticSharpness, 1f),
            },
            relativeTime: 0f),
    };

    return new CHHapticPattern(hapticEvents);
}

参考リンク

  1. 「ゲームによっては利用が適さない」と書きましたが、前の記事でも書いた通り、「アウトゲーム周り」や「インゲーム中の UI」とかでは使えるかもしれません。他にも UIImpactFeedbackGenerator 辺りであればそれ以外でも適用できる箇所はあるかもしれません。

  2. Core Haptics API の呼び出しの他にも、後述する「AHAP」と言うファイルの編集機能なども付いてます。詳しくは以下の動画を参照。

  3. ちなみに DualSense と言ったゲームコントローラーに接続すれば、コントローラー側で再生可能みたいですが...この記事では未調査なため取り扱いません。

  4. 余談ですが、Apple 公式のプラグインではオーディオ連携周りまでサポートされているみたいです。ただし、Core Haptics と連携させるにあたっては、サウンドデータの類はネイティブ側で持つ必要があるので、「連携させるサウンドデータだけ StreamingAssets に入れて管理する」と言った運用が必要になりそうでした。

  5. プレイヤーを生成せずにパターンを再生する方法もあるが、そこについては追々補足。

  6. typeContinuous のときのみ有効

  7. audio と付くタイプもあるが、今回は音周りについては触れないので割愛。 2

  8. キャンディクラッシュ辺りが恐らくはこんな感じで使ってそうな雰囲気だった。

  9. 発音は WWDC 講演を見るに恐らくは「エーハップ」になるかと思われる。

  10. 強いて言うならサンプルをライブラリ非依存で簡潔に実装させたかったので JsonUtility を使っているぐらい

3
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?