Unity6(Preview)のWebGL書き出しでAPI叩きしてみたよ

前回の続きです。

https://qiita.com/sagaraKUN/items/ef9ba477edf14c585a05

UnityではアプリならUnityWebRequest使ってしれっと外部API叩き出来ちゃうんですけど
WebGLだとHTMLに乗っけてるのが原因でクロスオリジンリソース共有 (CORS)を
ちゃんとしないといけないようになってます。
特に画像とかのバイナリ系はすぐ引っかかる。
なんでその辺をクリアしてWebGLで書き出したものでも
できるだけアプリと同じことをできるようにする土台作りだと思ってください。
以下はオレがChatGPTに聞きながらなんとかした方法なので
詳しい人に聞いたらもっとあっさりした方法あるかも。

https://x.com/sagaraKUN/status/1794917785623245065

https://sagarakun.github.io/UnityWebGLTest/

このデモでは毎回始めるたびに
OpenAIのDall-E3のAPIを使って生成したSkyBoxを背景に貼っています。
そしてChatのAPIでJson形式で書き出させたWebGLにまつわるクイズが地面のブロックの側面に表示されます。
処理が重いので時間差ありますが。
スマホで見てみてね。PCでも動くけども。

# この記事を読むのに向いてる人
**Heroku設定してCORSを掻い潜るためにProxy経由でOpenAIのAPIを叩きたい人**
**Herokuわからんけど使いたい人**
**OpenAIのAPI料金設定のわけのわからない仕様の謎をつきとめたい人**
**何でもいいからUnityWebGLからAPI叩きたい人**

### 詳細書くとクッソ長くなるからサクサク行きますよ！

# 使用するツールのインストール
* Node.js 
* Heroku CLI

winの人はターミナル(PowerShell)orコマンドプロンプトから
winget経由のインストールが楽です。

```
winget install --id OpenJS.NodeJS.LTS
winget install --id Heroku.HerokuCLI
```

# Herokuの準備
Herokuのアカウントを作ります。

https://jp.heroku.com/home

Herokuの認証にMicrosoft Authenticator使うのでiPhoneかAndroidを用意してね

https://www.microsoft.com/ja-jp/security/mobile-authenticator-app

winの人はターミナル(PowerShell)orコマンドプロンプトからHeroku CLIにログイン
```
heroku login
```
このコマンド叩くとブラウザが立ち上がってここに行くのでログインします

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30562/8ce0f5a4-a1b9-b471-0a1f-ee3020f1fed8.png)

# Proxy用のProjectフォルダの準備
Proxy用のProjectフォルダを作って中に入ります
```
mkdir CorsProxy
cd CorsProxy
```

以下の内容でpackage.jsonファイルを作ります。
```json:package.json
{
  "name": "cors-proxy",
  "version": "1.0.0",
  "description": "CORS Proxy server",
  "main": "server.js",
  "scripts": {
    "start": "node server.js"
  },
  "dependencies": {
    "cors-anywhere": "^0.4.4",
    "express": "^4.17.1"
  },
  "engines": {
    "node": "20.x"
  }
}
```

必要なパッケージをインストールします。
さっきのNode.jsのインストールしてればいけます
```
npm install
```
一旦node_modulesを作ると管理者権限で開いた
ターミナル(PowerShell)から消さなきゃいけません
以下のコマンドで消せます。
```
Remove-Item -Recurse -Force node_modules
```
拡張子とか付けないProcfileという名前のファイルを作ります。
アプリケーションの起動コマンドを指定するのに使うファイルです。
中身はこれだけ
```:Procfile
web: node server.js
```
server.jsファイルを作成します
originWhitelistの中は前回作ったGitHub PagesのURLが入ります。
このオリジンからの通知は通してねみたいな穴です。
```javascript:server.js
const express = require('express');
const corsAnywhere = require('cors-anywhere');

const host = process.env.HOST || '0.0.0.0';
const port = process.env.PORT || 8080;

corsAnywhere.createServer({
  originWhitelist: ['ここには前回つくったGitHub PagesのURLを入れます'], 
  requireHeader: ['origin', 'x-requested-with'],
  removeHeaders: ['cookie', 'cookie2']
}).listen(port, host, function () {
  console.log('Running CORS Anywhere on ' + host + ':' + port);
});

```
この時点でCorsProxyフォルダの中はこんな感じになってます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30562/d5081f37-7597-b9b9-71ee-7cd92757edc5.png)
# Herokuへのデプロイ
引き続きターミナル(PowerShell)orコマンドプロンプトは
cdでCorsProxyフォルダにフォーカスしておいてください。

my-cors-proxyって名前でHerokuアプリを作成
```
heroku create my-cors-proxy
```

Gitの初期化+Herokuリモートリポジトリを追加
```
git init
heroku git:remote -a my-cors-proxy
```
すべてのファイルをGitに追加してコミット
```
git add .
git commit -m "Initial commit"
```
Herokuにデプロイします。
```
git push heroku master
```
手元のブランチがmainでHerokuがmasterみたいな名前違う時は
```
git push heroku master:main
```
って書き方でもOK
デプロイが完了すると、Heroku CLIにアプリのURLが表示されます。
以下のコマンドでアプリ名(my-cors-proxy)から詳細を確認してもOK
```
heroku info -a my-cors-proxy
```
```
 »   Warning: heroku update available from 7.53.0 to 8.11.5.
=== my-cors-proxy
Auto Cert Mgmt: false
Dynos:          web: 1
Git URL:        https://git.heroku.com/my-cors-proxy.git
Owner:          Herokuに登録した時のメールアドレス
Region:         us
Repo Size:      510 MB
Slug Size:      45 MB
Stack:          heroku-22
Web URL:        このアプリのURL
```
このアプリのURLをブラウザにコピペして入れて
以下の文言が表示されたら成功です。
```
This API enables cross-origin requests to anywhere.

Usage:

/               Shows help
/iscorsneeded   This is the only resource on this host which is served without CORS headers.
/<url>          Create a request to <url>, and includes CORS headers in the response.

If the protocol is omitted, it defaults to http (https if port 443 is specified).

Cookies are disabled and stripped from requests.

Redirects are automatically followed. For debugging purposes, each followed redirect results
in the addition of a X-CORS-Redirect-n header, where n starts at 1. These headers are not
accessible by the XMLHttpRequest API.
After 5 redirects, redirects are not followed any more. The redirect response is sent back
to the browser, which can choose to follow the redirect (handled automatically by the browser).

The requested URL is available in the X-Request-URL response header.
The final URL, after following all redirects, is available in the X-Final-URL response header.


To prevent the use of the proxy for casual browsing, the API requires either the Origin
or the X-Requested-With header to be set. To avoid unnecessary preflight (OPTIONS) requests,
it's recommended to not manually set these headers in your code.


Demo          :   https://robwu.nl/cors-anywhere.html
Source code   :   https://github.com/Rob--W/cors-anywhere/
Documentation :   https://github.com/Rob--W/cors-anywhere/#documentation
```
失敗してるとこんな表示になります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30562/ca45b5ab-1d2d-edd2-5fc8-0d413da930d2.png)

### このアプリのURLはあとから使うのでメモっておいてください。

# OpenAIのAPI keyを作成する
https://platform.openai.com/apps

ここで重要なのが
* ChatGPTとAPIの料金形態は別
* APIKeyの作成は無料で可能
* APIKeyの無料期間はOpenAIのなんらかのアカウント登録から3か月
の三点になります。
無料で三か月ってのがまさかChatGPTのアカウント登録の時期からと思ってなくて
気づくのにえらい時間かかった...おとなしくAPI課金しました。
ちなみに無料期間すぎてるのに無料でAPIにアクセスしようとすると
上限超えてます的なこと言われるだけなのでマジで気づけない。



# UnityWebGL上での呼び出し方
で、OpenAIのAPI KeyとHerokuアプリのURLを組み合わせて
以下のデモを作りました。

以下はOpenAIのDall-E3を使ったデモです。
適当なゲームオブジェクトに貼って使ってください。
UniTaskがちょっと挙動があやしかったのでコルーチン使ってます。
GameObjectとShaderは任意で追加してください

```c#:DalleAPIProxy.cs
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Collections.Generic;
using Newtonsoft.Json;

public class DalleAPIProxy : MonoBehaviour
{
    private string apiKey = "OpenAIのAPIキー";
    private string apiUrl = "https://api.openai.com/v1/images/generations";
    private string proxyUrl = "HerokuアプリのURL";

    //ここで参照したGameObjectにDall-E3からの画像を張り付ける。
    [SerializeField] private GameObject cube;

    public void GenerateImage()
    {
        //ねこちゃんが3匹戯れるのをプロンプトにしたもの。ここを変えれば別の画像生成になります。
        string prompt = "Three adorable kittens playing together with a ball of yarn, in a cozy living room with sunlight streaming in through the window.";
        StartCoroutine(SendPostRequest(prompt));
    }

    private IEnumerator SendPostRequest(string prompt)
    {
        var requestBody = new Dictionary<string, object>
        {
            { "prompt", prompt },
            { "n", 1 },
            { "size", "512x512" }
        };

        string jsonBody = JsonConvert.SerializeObject(requestBody);
        byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonBody);

        UnityWebRequest webRequest = new UnityWebRequest(proxyUrl + apiUrl, "POST");
        webRequest.uploadHandler = new UploadHandlerRaw(bodyRaw);
        webRequest.downloadHandler = new DownloadHandlerBuffer();
        webRequest.SetRequestHeader("Content-Type", "application/json");
        webRequest.SetRequestHeader("Authorization", "Bearer " + apiKey);

        yield return webRequest.SendWebRequest();

        if (webRequest.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("Error: " + webRequest.error);
            Debug.LogError("Response: " + webRequest.downloadHandler.text);
        }
        else
        {
            var jsonResponse = webRequest.downloadHandler.text;
            DalleResponse response = JsonConvert.DeserializeObject<DalleResponse>(jsonResponse);
            string imageUrl = response.data[0].url;
            Debug.Log("Image URL: " + imageUrl);

            StartCoroutine(DownloadImage(imageUrl));
        }
    }

    private IEnumerator DownloadImage(string imageUrl)
    {
        UnityWebRequest webRequest = UnityWebRequestTexture.GetTexture(proxyUrl + imageUrl);

        yield return webRequest.SendWebRequest();

        if (webRequest.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("Error downloading image: " + webRequest.error);
        }
        else
        {
            Texture2D texture = ((DownloadHandlerTexture)webRequest.downloadHandler).texture;
            ApplyTextureToCube(texture);
        }
    }

    private void ApplyTextureToCube(Texture2D texture)
    {
        Renderer renderer = cube.GetComponent<Renderer>();
        if (renderer != null)
        {
            Material material = new Material(Shader.Find("任意のShader名"));
            material.mainTexture = texture;
            material.mainTextureScale = new Vector2(4, 4);//タイル配置する時の分割数
            renderer.material = material;
        }
    }

    [System.Serializable]
    public class DalleResponse
    {
        public List<Data> data;
    }

    [System.Serializable]
    public class Data
    {
        public string url;
    }
}

```

といった感じでUnityWebGLでも全然API叩けるようにできる
という記事でした。

できたけどHerokuまだあんまよくわかってない。昨日よりはだいぶわかったけど。
結局ChatGPTに頼りきっちゃダメで何のこと言ってるか
自分で理解して推測してテストしないとダメですね。
使い手が理解してないとなんにもならない。

個人的にUnityで公式がモバイルWebGL対応するようになった
ことに可能性感じてるのでデモガンガン作りたい。

気になった人はレッツトライ！

しかし自分ひとりでやるの限界ありますね。
聞ける人がいない。
もしあなたの開発で出てくる問題もオレに声かけてくれたら一緒に考えられるかも。

あと引き続きWebGL系の仕事を募集しています。
仕事決まったら募集してること消します。

# おまけ
アプリとEditor用のソース
```c#:DalleAPI.cs
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Collections.Generic;
using Newtonsoft.Json;

public class DalleAPI : MonoBehaviour
{
    private string apiKey = "OpenAIのAPIKey";
    private string apiUrl = "https://api.openai.com/v1/images/generations";

    //ここで参照したGameObjectにDall-E3からの画像を張り付ける。
    [SerializeField] private GameObject cube;

    public void GenerateImage()
    {
        string prompt = "Three adorable kittens playing together with a ball of yarn, in a cozy living room with sunlight streaming in through the window.";
        StartCoroutine(SendPostRequest(prompt));
    }

    private IEnumerator SendPostRequest(string prompt)
    {
        var requestBody = new Dictionary<string, object>
        {
            { "prompt", prompt },
            { "n", 1 },
            { "size", "512x512" }
        };

        string jsonBody = JsonConvert.SerializeObject(requestBody);
        byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(jsonBody);

        UnityWebRequest webRequest = new UnityWebRequest(apiUrl, "POST");
        webRequest.uploadHandler = new UploadHandlerRaw(bodyRaw);
        webRequest.downloadHandler = new DownloadHandlerBuffer();
        webRequest.SetRequestHeader("Content-Type", "application/json");
        webRequest.SetRequestHeader("Authorization", "Bearer " + apiKey);

        yield return webRequest.SendWebRequest();

        if (webRequest.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("Error: " + webRequest.error);
            Debug.LogError("Response: " + webRequest.downloadHandler.text);
        }
        else
        {
            var jsonResponse = webRequest.downloadHandler.text;
            DalleResponse response = JsonConvert.DeserializeObject<DalleResponse>(jsonResponse);
            string imageUrl = response.data[0].url;
            Debug.Log("Image URL: " + imageUrl);

            StartCoroutine(DownloadImage(imageUrl));
        }
    }

    private IEnumerator DownloadImage(string imageUrl)
    {
        UnityWebRequest webRequest = UnityWebRequestTexture.GetTexture(imageUrl);

        yield return webRequest.SendWebRequest();

        if (webRequest.result != UnityWebRequest.Result.Success)
        {
            Debug.LogError("Error downloading image: " + webRequest.error);
        }
        else
        {
            Texture2D texture = ((DownloadHandlerTexture)webRequest.downloadHandler).texture;
            ApplyTextureToCube(texture);
        }
    }

    private void ApplyTextureToCube(Texture2D texture)
    {
        Renderer renderer = cube.GetComponent<Renderer>();
        if (renderer != null)
        {
            Material material = new Material(Shader.Find("任意のShader"));
            material.mainTexture = texture;
            material.mainTextureScale = new Vector2(4, 4);
            renderer.material = material;
        }
    }

    [System.Serializable]
    public class DalleResponse
    {
        public List<Data> data;
    }

    [System.Serializable]
    public class Data
    {
        public string url;
    }
}

```