RealityKitのCustomMaterialでアウトラインを描くShader

# RealityKitのCustomMaterialでアウトラインを描くShader

RealityKitの[CustomMaterial](https://developer.apple.com/documentation/realitykit/custommaterial)でアウトラインを描くShaderを作ってみました
オマケでゲーミングアウトラインモードもあります

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">RealityKitでアウトライン付けるのできた！<br>ついでにゲーミングモードも作ってみたw <a href="https://t.co/JmDKzQinIA">pic.twitter.com/JmDKzQinIA</a></p>&mdash; ふじき (@fzkqi) <a href="https://twitter.com/fzkqi/status/1744683123080831454?ref_src=twsrc%5Etfw">January 9, 2024</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

リポジトリはこちら

https://github.com/fuziki/RealityKit-CustomMaterial-Outline

# アウトラインを描く仕組み

今回は最もシンプルな？法線方向へモデルを拡大して塗りつぶした画像を合成することでアウトラインを描く仕組みを実装しました

![Frame 294.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/7ec0c840-c21c-2a9a-8e2f-632c73945536.png)

こちらのブログを大変参考にさせていただきました
他にもPost Effectを利用する方法もあるようです

https://light11.hatenadiary.com/entry/2018/05/13/183314

# アウトラインを付ける実装
こちらの記事で取り扱ったRealityKitのnonARモードで試してます

https://qiita.com/fuziki/items/6619550c4181a9b5a690

## CustomMaterialの設定
アウトラインをつけたいEntityのクローンを作成し、CustomMaterialを設定し、子Entityとして追加します
`surfaceShader` と `geometryShader` はShaderで、実装は後述します
GeometryModifierでモデルを拡大し、SurfaceShaderで任意の色で塗りつぶします
カリングで前方を指定し、被っている範囲は描画しないようにします

```swift
let plane: Entity // アウトラインをつけたいEntity
let clone = plane.clone(recursive: true)

let device = MTLCreateSystemDefaultDevice()!
let library = device.makeDefaultLibrary()!
let surfaceShader = CustomMaterial.SurfaceShader(named: "surfaceShader", in: library)
let geometryShader = CustomMaterial.GeometryModifier(named: "geometryShader", in: library)
clone.modifyMaterials { original in
    var mat = try! CustomMaterial(from: original, surfaceShader: surfaceShader, geometryModifier: geometryShader)
    mat.faceCulling = .front
    customMaterials.append(mat)
    return mat
}
plane.addChild(clone)

// https://developer.apple.com/documentation/realitykit/altering_realitykit_rendering_with_shader_functions
extension Entity {
    func modifyMaterials(_ closure: (RealityKit.Material) throws -> RealityKit.Material) rethrows {
        try children.forEach { try $0.modifyMaterials(closure) }

        guard var comp = components[ModelComponent.self] as? ModelComponent else { return }
        comp.materials = try comp.materials.map { try closure($0) }
        components[ModelComponent.self] = comp
    }
}
```

## Shaderの実装
まずはシンプルにアウトラインを描くShaderです
metalファイルに記述します
geometryShaderでモデルの各頂点を法線方向へ押し出し、
surfaceShaderでモデルを真っ黒に塗りつぶします

```c
#include <metal_stdlib>
#include <RealityKit/RealityKit.h>
using namespace metal;

[[visible]]
void geometryShader(realitykit::geometry_parameters params) {
    float3 norm = params.geometry().normal();

    params.geometry().set_model_position_offset(norm * 0.4);
    params.geometry().set_normal(-norm);
}

[[visible]]
void surfaceShader(realitykit::surface_parameters params) {
    params.surface().set_base_color(half3());
    params.surface().set_ambient_occlusion(0);
}
```

## 結果
シンプルな黒縁のアウトラインを描くことができました

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/000c475a-9dac-c441-10f5-7c998e6e5597.png" width="300">

# Shaderに動的に値を渡す
UI上のスライダーでアウトラインの太さやゲーミングモードをコントロールするためには、Shaderに対して動的に値を渡す必要があります
動的に値を渡すためには、[CustomMaterial.custom](https://developer.apple.com/documentation/realitykit/custommaterial/custom-swift.property)を利用します

## customに値を渡す
SIMD4<Float>を渡せるので、xにゲーミングの有効フラグ、yにゲーミングの色を流すタイミング、wにアウトラインの太さを入れます

```swift
var offset = 0
let width: Float = 0.2
// ---
offset %= 30
clone.setCustomVector(vector: .init(x: gaming ? 1 : 0, y: Float(offset) / 30, z: 0, w: width))
            
// https://developer.apple.com/documentation/realitykit/altering_realitykit_rendering_with_shader_functions
extension Entity {
    func setCustomVector(vector: SIMD4<Float>) {
        children.forEach { $0.setCustomVector(vector: vector) }

        guard var comp = components[ModelComponent.self] as? ModelComponent else { return }
        comp.materials = comp.materials.map { (material) -> Material in
            if var customMaterial = material as? CustomMaterial {
                customMaterial.custom.value = vector
                return customMaterial
            }
            return material
        }
        components[ModelComponent.self] = comp
    }
}
```

## customの値を読み取る
Shaderで設定された値をparams.uniforms().custom_parameter()で読み取ります
geometryShaderではw、surfaceShaderではxとyを使用します
スクリーン上の位置とタイミングとyのタイミングを足して、色相に変換することで、流れるゲーミングを再現してます

```c
#include <metal_stdlib>
#include <RealityKit/RealityKit.h>
using namespace metal;

[[visible]]
void geometryShader(realitykit::geometry_parameters params) {
    float3 norm = params.geometry().normal();

    params.geometry().set_model_position_offset(norm * params.uniforms().custom_parameter().w);
    params.geometry().set_normal(-norm);
}

half3 hue2Rgba(half h) {
    half hueDeg = h * 360.0;
    half x = (1 - abs(fmod(hueDeg / 60.0, 2) - 1));
    half3 rgba;
    if (hueDeg < 60) rgba = half3(1, x, 0);
    else if (hueDeg < 120) rgba = half3(x, 1, 0);
    else if (hueDeg < 180) rgba = half3(0, 1, x);
    else if (hueDeg < 240) rgba = half3(0, x, 1);
    else if ( hueDeg < 300) rgba = half3(x, 0, 1);
    else rgba = half3(1, 0, x);
    return rgba;
}

[[visible]]
void surfaceShader(realitykit::surface_parameters params) {
    auto cp = params.uniforms().custom_parameter();
    float4 position = params.geometry().screen_position();
    params.surface().set_base_color(cp.x > 0.5 ? hue2Rgba(position.x / 2000 + cp.y) : half3());
    params.surface().set_ambient_occlusion(cp.x > 0.5 ? 1 : 0);
}
```

## 結果
アウトラインの太さを変更や、ゲーミングモードを実装できました

![output-palette.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/517e74f2-bc0f-4149-c71e-c92910bcbae9.gif)

# まとめ
RealityKitのCustomMaterialでアウトラインを描くShaderを作ってみました
情報が少なすぎたり、カメラを起動しないといけなかったりで、開発のハードルは高いですが、
RealityKitは思っていたより、様々な機能があり、柔軟性もありそうです
また、nonARモードとSimulatorを使用することで、ARのためのカメラを起動せずに開発できたので良かったです