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

こちらは[【Unity】iPhone アプリでの Haptic Feedback 入門](https://qiita.com/mao_/items/8bc9e646d6ca6185cda5) の続きです。  

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


# Core Haptics について

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

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

https://developer.apple.com/documentation/corehaptics

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

https://developer.apple.com/videos/play/wwdc2019/520/  

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

:::note
**`UIFeedbackGenerator` との違いについて。**

前の記事で話した [UIFeedbackGenerator](https://developer.apple.com/documentation/uikit/uifeedbackgenerator) は「**システムが提供するプリセットのハプティックパターン**」を再生するための API であり、独自のパターンを再生することができないと言った違いが挙げられます。

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

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


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

[前回の記事でも記載した内容](https://qiita.com/mao_/items/8bc9e646d6ca6185cda5#apple-%E5%85%AC%E5%BC%8F%E3%81%AE-unity-%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%E3%82%92%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E3%81%93%E3%81%A8%E3%81%8C%E5%8F%AF%E8%83%BD)ですが、**Core Haptics を Unity 上から呼び出すにあたっては、Apple 公式が公開しているプラグインを利用することが可能です。**[^2] 
( Haptic Feedback については [Apple.CoreHaptics](https://github.com/apple/unityplugins/blob/main/plug-ins/Apple.CoreHaptics/Apple.CoreHaptics_Unity/Assets/Apple.CoreHaptics/Documentation~/Apple.CoreHaptics.md) を参照)

https://github.com/apple/unityplugins

https://developer.apple.com/jp/videos/play/wwdc2022/10065/

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

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





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

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

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

https://github.com/mao-test-h/Advent-Calendar-2024-SampleProject

- Unity 2022.3.54f1
- Xcode 16.1

![ 2025-02-11 17.47.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/ef9ef9e3-979b-cc92-eaf7-cebccabfc9e3.png)

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

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

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

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


## 解説しない内容

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

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


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


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

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


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




# 再生するまでの流れ

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

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

![flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/3c4a488a-9b51-41b9-9e3a-6dca15fd57c5.png)

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

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

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

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


## `パターン`について

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

https://developer.apple.com/documentation/corehaptics/chhapticpattern

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/fd3af34a-1c89-472f-acd8-2f1a50d08f80.png" width=100%>

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


## `イベント`について

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

https://developer.apple.com/documentation/corehaptics/chhapticevent

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/2e114fc8-b03b-4f51-9ab5-90b55cbbe78f.png" width=60%>


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

| パラメータ | 概要 |
|:-|:-|
| `type` | `イベント`のタイプ (後述) |
| `eventParameters` | `イベントパラメータ`の配列 (後述) |
| `relativeTime` | `イベント`の発生タイミング |
| `duration` | `イベント`の長さ[^3] |

[^3]: `type` が `Continuous` のときのみ有効


### `イベント`のタイプ

タイプには大きく分けて `Transient` と `Continuous` の 2種類があります。[^7]

https://developer.apple.com/documentation/corehaptics/chhapticevent/eventtype  

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


#### Transient

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

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

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

https://developer.apple.com/documentation/corehaptics/chhapticevent/eventtype/haptictransient

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/b30a4a2f-23da-4389-8b1e-644466a22278.jpeg" width=60%>

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

#### Continuous

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

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

https://developer.apple.com/documentation/corehaptics/chhapticevent/eventtype/hapticcontinuous

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/ec39bbd2-27e2-4fbd-a3cc-b055ed2e9ddc.jpeg" width=60%>



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

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


https://developer.apple.com/documentation/corehaptics/chhapticeventparameter

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/6530fee2-91fa-4c8b-b721-1f18b91d4203.png" width=60%>


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

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

### パラメータのタイプ

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

#### Intensity

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

https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/hapticintensity

#### Sharpness

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

https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/hapticsharpness

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/46b95aa1-2d0e-416d-b20f-8e5fe1031c38.png" width=80%>

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


#### 他のタイプについて

ここでは解説しませんが、他にも [Attack](https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/attacktime), [Decay](https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/decaytime), [Release](https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/releasetime), [Sustained](https://developer.apple.com/documentation/corehaptics/chhapticevent/parameterid/sustained) と言った値も設定することが可能です。[^7]

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/aff0f5bb-9884-468b-8b82-655db9036386.jpeg" width=60%>

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

- [ADSR - Wikipedia](https://ja.wikipedia.org/wiki/ADSR)
- [Haptrix — Core Haptics Designer](https://nthstate.medium.com/haptrix-core-haptics-designer-241a47b78cd)

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

https://speakerdeck.com/shiba1014/corehapticsru-men



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

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

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

例えば以下の`イベント`で構成されたシンプルな`パターン`があるとします。
(`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 |

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/5032682b-4965-41d7-991c-c536566c12bf.png" width=80%>

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

| イベント | パターン |
|:-:|:-:|
| <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/67ac7810-5a23-4cdb-9c1b-44ea8d6e04e6.png" width=80%> | <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/f6cb8b77-8ed1-4f0a-8ed3-6794ba6c0676.png" width=80%> |

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/1ddcf73f-2cda-4316-a755-27093311f421.jpeg" width=60%>


### パターンを定義するには？

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

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

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


## `AHAP` ファイルについて

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

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

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

https://developer.apple.com/documentation/corehaptics/representing_haptic_patterns_in_ahap_files


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

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

```js: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 機能](https://ja.wikipedia.org/wiki/Quick_Look) (ファイルを選択状態でスペースキーを押すことでプレビューする機能) を用いることで、**以下のようにパターンをビジュアライズすることが可能です。**
(以下は[こちら](#前章のパターンを-ahap-ファイルに落とし込んでみる)で定義した AHAP ファイルをプレビューしたもの)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/2a37f260-52bd-47ba-93ed-d2b03091323b.png" width=100%>

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


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

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

https://github.com/apple/unityplugins/tree/main/plug-ins/Apple.CoreHaptics/Apple.CoreHaptics_Unity/Assets/Apple.CoreHaptics/Demos/SampleAHAPs/Effects

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/6c17fe73-f792-43db-aaa8-e291b36c304c.jpeg" width=60%>



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

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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/4972fc04-c9c6-4b55-b8b2-1e3ed6e8ec21.png" width=80%>


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

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

https://developer.apple.com/documentation/corehaptics/chhapticdynamicparameter  

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

```js:ダイナミックパラメータサンプル (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
            }
        }
    ]
}
```

**パターン全体**

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/52e5e6b1-abc2-41fb-b8dc-ece32a109137.png" width=100%>

:::note warn
### `Intensity` は乗算され、`Sharpness` は加算される点に注意

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

#### Intensity

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

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

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

#### Sharpness

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

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

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


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

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

https://developer.apple.com/documentation/corehaptics/chhapticparametercurve

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

```js:パラメータカーブサンプル (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
            }
        }
    ]
}
```

**パターン全体**

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/e40a1dc6-ab34-402f-bce3-7cb1ea5e6a3b.png" width=100%>





# 実装解説

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

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

https://qiita.com/mao_/items/5b33c90e533a538570b8

https://qiita.com/mao_/items/07466e169d08cbeff221

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp:(ファイル名)
// サンプルコード
```

</details>
:::



## 再生する`パターン`の定義

### `イベント`の実装

先ずは`イベント`のクラスである [CHHapticEvent](https://developer.apple.com/documentation/corehaptics/chhapticevent) の実装から入ります。

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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,
        }
    }
}
```

</details>

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

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


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

次は`イベントパラメータ`である [CHHapticEventParameter](https://developer.apple.com/documentation/corehaptics/chhapticeventparameter) の実装です。

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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,
        }
    }
}
```

</details>


### `パターン`の実装

`パターン`である [CHHapticPattern](https://developer.apple.com/documentation/corehaptics/chhapticpattern) の実装です。

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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)}]}}";
        }
    }
}
```

</details>

ちなみに AHAP 形式への変換には [JsonUtility](https://docs.unity3d.com/ScriptReference/JsonUtility.html) を用いてますが、特にこれと言った理由は無いので、別のライブラリを用いればもっと簡潔に実装できるかもしれません。[^5]


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


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

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
}
```

</details>

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


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

AHAP ファイルは実態こそ JSON ではありますが、**拡張子が `.ahap` なために Unity でインポートしても JSON ファイル(と言うよりか `TextAsset`)として認識されません。**  
  
そのため、サンプルプロジェクトでは以下のような `ScriptedImporter` を継承したエディタ拡張を実装することで、AHAP ファイルを `TextAsset` として扱えるようにしてます。

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
        }
    }
}
```

</details>



## `エンジン`について

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

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

https://developer.apple.com/documentation/corehaptics/chhapticengine  

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


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

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

https://developer.apple.com/documentation/corehaptics/chhapticengine/3043665-stoppedhandler

https://developer.apple.com/documentation/corehaptics/chhapticengine/3043661-resethandler

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

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

```csharp: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);
    }
}

```

```swift: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
    }
}
```

</details>


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

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

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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}");
    }
}
```

</details>

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

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp:CoreHapticsExample.cs
private void OnApplicationPause(bool pauseStatus)
{
    if (pauseStatus)
    {
        _hapticEngine?.StopHapticEngine();
    }
    else
    {
        _hapticEngine?.StartHapticEngine();
    }
}
```

</details>

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

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

https://developer.apple.com/documentation/corehaptics/chhapticengine/playpattern(from:)-7u8se

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
}
```

```swift: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())
    }
}
```

</details>



## `プレイヤー`について

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

https://developer.apple.com/documentation/corehaptics/chhapticpatternplayer  

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

https://developer.apple.com/documentation/corehaptics/chhapticadvancedpatternplayer

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

<details><summary><font color="blue">「プレイヤー」全体のサンプルコード (クリックで展開)</font></summary>

```csharp: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);
    }
}
```

```swift: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
    }
}
```

</details>

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

(FYI: [Apple - Core Haptics -> 6. Advanced Features](https://github.com/apple/unityplugins/blob/main/plug-ins/Apple.CoreHaptics/Apple.CoreHaptics_Unity/Assets/Apple.CoreHaptics/Documentation~/Apple.CoreHaptics.md#6-advanced-features))
:::


### `プレイヤー`の生成と再生

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
}
```

</details>

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

```csharp
// 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` と言うメソッドを用いれば、**再生中のパターンに紐づく`イベントパラメータ`の値を動的に変更することが可能です。**

https://developer.apple.com/documentation/corehaptics/chhapticpatternplayer/sendparameters(_:attime:)

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/ec39bbd2-27e2-4fbd-a3cc-b055ed2e9ddc.jpeg" width=60%>

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
}
```

```swift: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())
    }
}
```

</details>



# おわりに

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

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

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

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

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

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

https://developer.apple.com/videos/play/wwdc2019/810/

https://developer.apple.com/videos/play/wwdc2021/10278


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

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/80207/8e18aa02-244b-4a40-9151-8af80a2e3a8b.jpeg" width=60%>

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

<details><summary><font color="blue">サンプルコード (クリックで展開)</font></summary>

```csharp: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);
}
```

</details>



# 参考リンク

- **Human Interface Guildelines**
    - [触覚フィードバックの提供](https://developer.apple.com/jp/design/human-interface-guidelines/playing-haptics)
- **Documents**
    - [Core Haptics](https://developer.apple.com/documentation/corehaptics)
- **WWDC**
    - [Introducing Core Haptics - WWDC 2019](https://developer.apple.com/videos/play/wwdc2019/520/)
    - [Designing Audio-Haptic Experiences - WWDC 2019](https://developer.apple.com/videos/play/wwdc2019/810/)
    - [Advancements in Game Controllers - WWDC 2020](https://developer.apple.com/videos/play/wwdc2020/10614)
    - [Practice audio haptic design - WWDC 2021](https://developer.apple.com/videos/play/wwdc2021/10278)
    - [Plug-in and play: Add Apple frameworks to your Unity game projects - WWDC 2022](https://developer.apple.com/videos/play/wwdc2022/10065/)
- **参考記事・スライド**
    - [CoreHaptics入門](https://speakerdeck.com/shiba1014/corehapticsru-men)
    - [忙しい人のためのCore Haptics](https://qiita.com/cyomox/items/bd6a02b468663ceda7e3)
    - [Haptrix — Core Haptics Designer](https://nthstate.medium.com/haptrix-core-haptics-designer-241a47b78cd)
    - [Core Haptics - カスタムハプティックパターンの作成と再生](https://qiita.com/shu223/items/7e1578d7515cb44d3980)