Unity を検索

時間を節約するための高度なエディタースクリプティングハック(その 1)

2022年10月18日 カテゴリ: テクノロジー | 15 分 で読めます
A guide to advanced Editor scripting that will save you time | Hero
A guide to advanced Editor scripting that will save you time | Hero

開発者が行う作業で、特に新しいアートアセットの組み込みに関する作業が、繰り返しが多く、ミスが起こりやすいものになっているというプロジェクトを私は数多く見てきました。例えば、キャラクターを設定する場合には、アセット参照を何度もドラッグアンドドロップして設定し、チェックボックスにチェックを入れ、ボタンをクリックする作業がよく発生します。具体的には、モデルのリグを Humanoid に設定する、SDF テクスチャの sRGB を無効にする、法線マップ画像を法線マップとして、UI テクスチャをスプライトに設定するなどの作業です。ここでミスがよく起きるということは、言い方を変えれば、貴重な時間を費やしているのに、重要なステップを見逃すことがあるということです。

こうしたワークフローを改善し、次のプロジェクトが前回よりもスムーズに進むようにするためのハックを、今回から 2 回に分けてご紹介します。この記事の内容をさらに深く説明するために、ユニットを集めてできたチームが自動的に敵の建物や他のユニットを攻撃するという、リアルタイムストラテジーゲーム(RTS)に似た簡単なプロトタイプを作りました。それぞれのスクリプティングハックによって、テクスチャやモデルなど、プロセスに含まれるさまざまな点を 1 つずつ改良していきます。

プロトタイプはこのようなものです。

初期ビルド

ハック 1:アセットの整理と自動化

アセットをインポートする際に、開発者が多くの細かい設定をしなければならない主な理由は単純なものです。Unity はそのアセットがどのように使われるかを知らないので、そのアセットに最適な設定が何なのかがわからないのです。これらの作業の一部を自動化する場合、まずこの問題を解決する必要があります。

あるアセットが何のためにあり、他のアセットとどのように関連しているかを知る最も簡単な方法は、特定の命名規則とフォルダー構造に従うようにすることです。例えば以下のようにします。

  • 命名規則:アセットの名前そのものに何らかの要素を付け加えます。例えば、Shield_BC.png はベースカラー、Shield_N.png は法線マップ、のようにです。
  • フォルダー構造Knight/Animations/Walk.fbx はアニメーションで、Knight/Models/Knight.fbx はモデルだということが明確にわかります。どちらも同じフォーマット(.fbx)であるにもかかわらず、です。

これの問題点は、一方向にしかうまく機能しないことです。つまり、アセットが何のためにあるのかは、そのパスが分かれば自明なことかもしれませんが、アセットが何をするかという情報だけが与えられても、そのパスを推測することはできないのです。アセット(例えばキャラクターのマテリアル)を検索できることは、アセットのある部分の設定を自動化しようとする場合に有効です。この問題は厳密な命名規則を使ってパスを容易に推測できるようにすることで解決できますが、それでもミスの可能性があります。規則を覚えていても、誤字脱字はよくあることです。

これを解決するための面白いアプローチが、ラベルを使うことです。アセットのパスを解析し、それに応じてラベルを割り当てるエディタースクリプトを使用することができます。ラベルは自動化されているので、アセットに貼られるラベルを正確に把握することが可能です。AssetDatabase.FindAssets を使えば、ラベルでアセットを探すこともできます。

この一連の作業を自動化したい場合、AssetPostprocessor という非常に便利なクラスがあります。AssetPostprocessor は、Unity がアセットをインポートする際に、さまざまなメッセージを受け取ります。その 1 つが OnPostprocessAllAssets で、これは Unity がアセットをインポートし終わるたびに呼び出されるメソッドです。インポートしたアセットへのすべてのパスが示され、それらのパスに何かの処理を行う機会が与えられます。以下のような簡単なメソッドを書いて、処理することができます。

private static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, 
    string[] movedAssets, string[] movedFromAssetPaths)
{
    foreach (var asset in importedAssets)
        ProcessAssetLabels(asset);

    foreach (var asset in movedAssets)
        ProcessAssetLabels(asset);
}

このプロトタイプの場合はインポートされたアセットのリストに注目します。新しいアセットや移動されたアセットを捕捉しようとするためです。結局のところ、パスが変わればラベルも更新したくなってきます。

ラベルを作成するには、パスを解析し、関連するフォルダー、名前の接頭辞、接尾辞、および拡張子を探します。ラベルを生成したら、1 つの文字列にまとめ、アセットに設定します。

ラベルを割り当てるには、AssetDatabase.LoadAssetAtPath でアセットをロードし、AssetDatabase.SetLabels でそのラベルを割り当てます。

var obj = AssetDatabase.LoadAssetAtPath<Object>(assetPath);
if (obj)
{
    if (labels.Count == 0)
    {
        AssetDatabase.ClearLabels(obj);
        return;
    }

    var oldLabels = AssetDatabase.GetLabels(obj);
    var labelsArray = new string[] { string.Join('-', labels) };
    if (HaveLabelsChanged(oldLabels, labelsArray))
    {
        AssetDatabase.SetLabels(obj, labelsArray);
    }
}

ラベルは、実際に変更された場合のみ設定する必要があることを覚えておきましょう。ラベルを設定するとアセットの再インポートが発生するので、厳密に行う必要がある場合以外はこれは行わないでください。

これをチェックしておけば、再インポートは問題ではなくなります。ラベルはアセットを初めてインポートするときに設定され、.meta ファイルに保存されます。すなわち、バージョン管理システムにも保存されます。再インポートは、アセット名を変更したり、アセットを移動した場合にのみ発生します。

以上の手順を完了すると、下図の例のように、すべてのアセットに自動的にラベルが付きます。

Screen capture of automatic labeling post-processing in the Unity Editor.
ラベルの付いたマテリアルの例

ハック 2:テクスチャの設定とサイズを決める

テクスチャをプロジェクトにインポートするには、通常、各テクスチャの設定を調整する必要があります。普通のテクスチャでしょうか。法線マップは。スプライトは。リニアでしょうか sRGB でしょうか。アセットインポーターの設定を変更したい場合は、もう一度 AssetPostprocessor を使用します。

この場合、OnPreprocessTexture メッセージを使用します。これは、テクスチャをインポートする直前に呼び出されるメッセージです。これにより、インポーターの設定を変更することができます。

テクスチャごとに適切な設定を選ぶには、どのようなテクスチャを扱っているかを確認する必要があります。まさにこれが、最初のステップでラベルが重要になる理由です。

この情報をもとに、簡単な TexturePreprocessor を書いてみましょう。

private void OnPreprocessTexture()
{
    var labels = AssetDatabase.GetLabels(assetImporter);

    // 我们只想影响资产
    if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ART_LABEL))
        return;

    // 获取导入器
    var textureImporter = assetImporter as TextureImporter;
    if (!textureImporter)
        return;

このスクリプトは、アートラベルを持つテクスチャ(自分で作ったテクスチャ)に対してのみ実行するようにすることが重要です。その後、インポーターへの参照を取得し、テクスチャのサイズからすべてを設定できるようになります。

AssetPostprocessor には context プロパティがあり、ここからターゲットプラットフォームを決定することができます。そのため、モバイル向けにテクスチャを低解像度に設定するなど、プラットフォーム固有の変更も完了します。

// 设置纹理大小,比如android使用的纹理要小一些
if (context.selectedBuildTarget == BuildTarget.iOS || context.selectedBuildTarget == BuildTarget.Android)
    textureImporter.maxTextureSize = 256;
else
    textureImporter.maxTextureSize = 512;

次に、テクスチャが UI テクスチャであるかどうかをラベルで確認し、適宜設定します。

// UI纹理是一个特例
if (labels[0].Contains(AssetClassifier.UI_LABEL))
{
    textureImporter.textureType = TextureImporterType.Sprite;
    textureImporter.sRGBTexture = true;
    return;
}

それ以外のテクスチャについては、デフォルトの値を設定します。アルベドが sRGB を有効にする唯一のテクスチャであることは覚えておいてよいでしょう。

// 我们所有的纹理都是标准纹理,但如果我们使用法线,可以在这里设置它
    textureImporter.textureType = TextureImporterType.Default;
    textureImporter.textureShape = TextureImporterShape.Texture2D;

    // 将它们设置为可读以便在编辑器中处理
    textureImporter.isReadable = true;

    // 只有 albedo 文理是 sRGB
    var texName = Path.GetFileNameWithoutExtension(assetPath);    
    textureImporter.sRGBTexture = labels[0].Contains(AssetClassifier.BASE_COLOR_LABEL)
}

上記のスクリプトのおかげで、新しいテクスチャをエディターにドラッグアンドドロップすると、自動的に正しい設定が行われるようになりました。

設定を施したテクスチャ

ハック 3:テクスチャチャンネルのパッキングに挑戦する

「チャンネルパッキング」とは、異なるチャンネルを使って、多様なテクスチャを 1 つにまとめることです。一般的であり、多くの利点があります。例えば、Red チャンネルの値はメタリックであり、Green チャンネルの値はその滑らかさで決まっています。

しかし、すべてのテクスチャを 1 つにまとめるには、アートチームによる追加作業が必要です。何らかの理由でパックの内容を変更する必要がある場合(シェーダーの変更など)、アートチームはそのシェーダーで使用するテクスチャをすべて作り直さなければなりません。

ご覧の通り、ここには改善の余地があります。私がチャンネルパッキングに好んで使うアプローチは、「生」のテクスチャを設定し、チャンネルパッキングを施したテクスチャを生成し、マテリアルで使用する特殊なアセットのタイプを作成することです。

まず、特定の拡張子を持つダミーファイルを作成し、そのアセットをインポートする際にすべての作業を行ってくれる Scripted Importer を使います。仕組みは以下のようになっています。

  • インポーターには、合成する必要のあるテクスチャなどのパラメーターを設定することができます。
  • インポーターから、テクスチャとの依存関係を設定することで、ソースとなるテクスチャのいずれかが変更されるたびにダミーアセットが再インポートされるようにすることができます。これによって、生成されたテクスチャを適宜再ビルドすることができます。
  • インポーターにはバージョンがあります。テクスチャのパッキング方法を変える必要がある場合は、インポーターを改造して、バージョンを上げるとよいでしょう。これにより、プロジェクト内でパッキングされたテクスチャの再生成が強制的に行われ、すべてのテクスチャが新しい方法で即座にパッキングされます。
  • インポーターで生成すると、生成されたアセットが Library フォルダーにしか残らないので、バージョン管理システムがそれだけで一杯にならないという、良い副作用もあります。

これを実装するには、作成したテクスチャを保持し、インポーターの結果として提供する ScriptableObject を作成します。この例では、このクラスを TexturePack と呼びました。

これを作成したら、まずインポーターのクラスを宣言し、ScriptedImporterAttribute を追加して、インポーターにひもづくバージョンと拡張子を定義します。

[ScriptedImporter(0, PathHelpers.TEXTURE_PACK_EXTENSION)]
public class TexturePackImporter : ScriptedImporter
{
}

インポーターで、使用するフィールドを宣言します。これらは、MonoBehaviour や ScriptableObject と同じように、インスペクターに表示されます。

public LazyLoadReference<Texture2D> albedo;
public LazyLoadReference<Texture2D> playerMap;
public LazyLoadReference<Texture2D> metallic;
public LazyLoadReference<Texture2D> smoothness;
The inspector for the importer.
インポーターのインスペクター

パラメーターの準備ができたので、パラメーターとして設定したものから新しいテクスチャを作成します。ただし、(前のセクションで示した)プリプロセッサーでは、isReadableTrue に設定して、これを実現していることに注意してください。

このプロトタイプでは、RGB にアルベド、アルファにプレイヤーの色を適用するためのマスクが入った「Albedo」と、R チャンネルにメタリック、G チャンネルにスムースネスが入った「Mask」という 2 つのテクスチャがあることが分かります。

この記事の範囲外かもしれませんが、例として Albedo とプレイヤーのマスクの組み合わせ方を見てみましょう。まず、テクスチャが設定されているかどうかを確認し、設定されている場合はその色のデータを取得します。次に、AssetImportContext.DependsOnArtifact を使って、テクスチャを依存関係に設定します。上記のように、テクスチャが変更された場合、オブジェクトの再計算が必要になります。

public Texture2D CombineAlbedoPlayer(AssetImportContext ctx)
{
    Color32[] albedoPixels = new Color32[0];
    bool albedoPresent = albedo.isSet;
    if (albedoPresent)
    {
        ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(albedo.asset));
        albedoPixels = albedo.asset.GetPixels32();
    }

    Color32[] playerPixels = new Color32[0];
    bool playerPresent = playerMap.isSet;
    if (playerPresent)
    {
        ctx.DependsOnArtifact(AssetDatabase.GetAssetPath(playerMap.asset));
        playerPixels = playerMap.asset.GetPixels32();
    }

    if (!albedoPresent && !playerPresent)
        return null;

また、新しいテクスチャを作成する必要があります。そのためには、前のセクションで作成した TexturePreprocessor から、プリセットの制限に沿うようにサイズを取得します。

var size = TexturePreProcessor.GetMaxSizeForTarget(ctx.selectedBuildTarget);
var newTexture = new Texture2D(size, size, TextureFormat.RGBA32, true, false);
var pixels = new Color32[size * size];

次に、新しいテクスチャのデータをすべて入力します。これは、ジョブと Burst を使うことで大幅に最適化することが可能です(これだけで記事が 1 本書けてしまうほどです)。ここでは、簡単なループを使用することにします。

Color32 tmp = new Color32();
Color32 white = new Color32(255, 255, 255, 255);
for (int i = 0; i < pixels.Length; ++i)
{
    var color = albedoPresent ? albedoPixels[i] : white;
    var alpha = playerPresent ? playerPixels[i] : white;
    tmp.r = color.r;
    tmp.g = color.g;
    tmp.b = color.b;
    tmp.a = alpha.r;
    pixels[i] = tmp;
}

このデータをテクスチャに設定します。

newTexture.SetPixels32(pixels);
 // 将更改应用于mipmap
newTexture.Apply(true, false);
// 压缩纹理
newTexture.Compress(true);
// 设为不可读
newTexture.Apply(true, true);
newTexture.name = "AlbedoPlayer";
// 返回结果
return newTexture;

さて、別のテクスチャを生成する方法も、よく似た方法で作ることができます。これが出来たら、インポーター本体を作成します。今回は、結果を保持する ScriptableObject を作成し、テクスチャを作成し、AssetImportContext を通じてインポーターの結果を設定するのみとします。

インポーターを書く際には、生成されたすべてのアセットを AssetImportContext.AddObjectToAsset を使って登録し、プロジェクトウィンドウに表示されるようにする必要があります。AssetImportContext.SetMainObject を使って、メインのアセットを選択します。このようになります。

public override void OnImportAsset(AssetImportContext ctx)
{
    var result = ScriptableObject.CreateInstance<TexturePack>();

    result.albedoPlayer = CombineAlbedoPlayer(ctx);
    if (result.albedoPlayer)
        ctx.AddObjectToAsset("albedoPlayer", result.albedoPlayer);

    result.mask = CombineMask(ctx);
    if (result.mask)
        ctx.AddObjectToAsset("mask", result.mask);

    ctx.AddObjectToAsset("result", result);
    ctx.SetMainObject(result);
}

あとはダミーアセットを作成するだけです。これらはカスタムなので、CreateAssetMenu 属性を使用することはできません。その代わり、手動で作成する必要があります。

MenuItem 属性を使って、アセット作成メニューのフルパス、Assets/Create を指定します。アセットを作成するには、ProjectWindowUtil.CreateAssetWithContent を使用します。指定した内容のファイルを生成し、ユーザーがその名前を入力できるようにするものです。このようになります。

[MenuItem("Assets/Create/Texture Pack", priority = 0)]
private static void CreateAsset()
{

最後に、チャンネルパッキングが施されたテクスチャを作成します。

チャンネルパッキングが施されたテクスチャの作成の様子

ハック 4:マテリアルにカスタムシェーダーを使用する

多くのプロジェクトでカスタムシェーダーが使用されています。倒した敵をフェードアウトさせるディゾルブエフェクトのような追加エフェクトのために使われることもあれば、トゥーンシェーダーのようなカスタムのアートスタイルを実装するためのシェーダーもあります。どのような用途であれ、Unity はデフォルトのシェーダーで新しいマテリアルを作成するので、マテリアルがカスタムシェーダーを使用するように変更する必要があります。

この例では、ユニットに使用するシェーダーに、ディゾルブエフェクトとプレイヤーの色(動画中のプロトタイプでは赤と青)の 2 つの機能を追加しています。これらをプロジェクトに実装する場合、すべての建物とユニットが適切なシェーダーを使用するようにする必要があります。

アセットが特定の要件に合致しているかどうか(この場合は正しいシェーダーを使用しているかどうか)を検証するために、もう 1 つ便利なクラスがあります。AssetModificationProcessor です。AssetModificationProcessor.OnWillSaveAssets を使うと、特に Unity がアセットをディスクに書き込もうとするときに通知を受け取ることができます。これによって、アセットが正しいかどうかを確認し、保存する前に修正することができます。

さらに、Unity にアセットを保存しないように「指示」することもできます。これは検出した問題を自動で修正できない場合に有効です。これを実現するために、OnWillSaveAssets メソッドを作成します。

private static string[] OnWillSaveAssets(string[] paths)
{
    foreach (string path in paths)
    {
        ProcessMaterial(path);
    }

    // 如果不想保存,移除列表的资产路径
    return paths;
}

アセットを処理するために、マテリアルであるかどうか、正しいラベルが付いているかどうかを確認します。以下のコードによる検証を通れば、正しいシェーダーです。

private static void ProcessMaterial(string path)
{
    var mat = AssetDatabase.LoadAssetAtPath<Material>(path);
    // 检查是否为材质
    if (!mat)
        return;

    // 检查是building还是unit
    var labels = AssetDatabase.GetLabels(mat);
    if (labels.Length == 0 || !(labels[0].Contains(AssetClassifier.UNIT_LABEL) 
        || labels[0].Contains(AssetClassifier.BUILDING_LABEL)))
        return;

    if (mat.shader.name != UNIT_SHADER_NAME)
    {
        mat.shader = Shader.Find(UNIT_SHADER_NAME);
    }
}

ここで便利なのは、このコードがアセット作成時にも呼び出されることです。つまり、新しいマテリアルには正しいシェーダーが設定されることになります。

スクリプトの動作

Unity 2022 の新機能として、マテリアルバリアントも用意されています。マテリアルバリアントは、ユニット用のマテリアルを作成する際に非常に便利です。実際、ベースのマテリアルを作成し、そこから各ユニットのマテリアルを派生させ、関連するフィールド(テクスチャなど)をオーバーライドし、残りのプロパティを継承させることができます。これにより、マテリアルにデフォルトをきちんと設定できますし、必要に応じて更新することもできます。

ハック 5:アニメーションを管理する

アニメーションのインポートは、テクスチャの取り込みと似ています。さまざまな設定を行う必要がありますが、そのうちのいくつかは自動化することが可能です。

Unity は、デフォルトですべての FBX(.fbx)ファイルのマテリアルをインポートします。アニメーションの場合、使用したいマテリアルは、プロジェクト内か、メッシュの FBX にあります。アニメーション FBX に付いている余計なマテリアルは、プロジェクト内でマテリアルを検索するたびに表示され、かなりのノイズとなるため、これを無効にしておくことに一定の価値があります。

リグを設定するには、HumanoidGeneric のどちらかを選択し、注意深く設定が施されたアバターを使う場合は、それを割り当てます。テクスチャに適用したのと同じアプローチが適用されます。しかしアニメーションの場合、使用するメッセージは AssetPostprocessor.OnPreprocessModel となります。これはすべての FBX ファイルに対して呼び出されるので、アニメーションの FBX ファイルとモデルの FBX ファイルを区別する必要があります。

先ほど設定したラベルのおかげで、それほど複雑にはならないはずです。メソッドの最初の部分はテクスチャの場合とほぼ同じです。

private void OnPreprocessModel()
{
    // 我们只想影响动画
    var labels = AssetDatabase.GetLabels(assetImporter);
    if (labels.Length == 0 || !labels[0].Contains(AssetClassifier.ANIMATION_LABEL))
        return;

    // 获取导入器
    var modelImporter = assetImporter as ModelImporter;
    if (!modelImporter)
        return;

    // 我们需要动画
    modelImporter.importAnimation = true;
    // 我们不想要任何材质
    modelImporter.materialImportMode = ModelImporterMaterialImportMode.None;

次に、メッシュの FBX からリグを使用したいので、そのアセットを見つける必要があります。アセットを探すには、もう一度ラベルを使用します。このプロトタイプの場合、アニメーションは「animation」で終わるラベルを持ち、メッシュは「model」で終わるラベルを持ちます。簡単な置換作業で、モデルに使うラベルを作ることができます。ラベルができたら、AssetDatabase.FindAssets で「l:label-name」を指定して、アセットを探します。

他のアセットにアクセスする場合、他にも考慮すべきことがあります。インポート処理の途中で、このメソッドが呼ばれたときにはまだアバターがインポートされていない可能性があるのです。この場合、LoadAssetAtPath は null を返し、アバターを設定することができなくなります。この問題を回避するには、アバターのパスに依存関係を設定します。アバターがインポートされるとアニメーションが再度インポートされますので、そこで設定することができます。

ここまで説明したことのすべてをコードに落とすと、次のようになります。

// 尝试获取avatar
var avatarLabel = labels[0].Replace(AssetClassifier.ANIMATION_LABEL, AssetClassifier.MODEL_LABEL);
var possibleModels = AssetDatabase.FindAssets("l:" + avatarLabel);
Avatar avatar = null;
if (possibleModels.Length > 0)
{
    var avatarPath = AssetDatabase.GUIDToAssetPath(possibleModels[0]);
    avatar = AssetDatabase.LoadAssetAtPath<Avatar>(avatarPath);

    if (!avatar)
        context.DependsOnArtifact(avatarPath);
}

modelImporter.animationType = ModelImporterAnimationType.Generic;
modelImporter.sourceAvatar = avatar;
modelImporter.avatarSetup = ModelImporterAvatarSetup.CopyFromOther;

あとは、アニメーションを正しいフォルダーにドラッグし、メッシュの準備が整えば、それぞれ自動的に設定されます。しかし、アニメーションをインポートするときにアバターがなければ、プロジェクトが作成された時点でそれを拾うことができません。その場合は、アニメーションを作成した後に手動で再インポートする必要があります。これはアニメーションのあるフォルダーを右クリックし、Reimport を選択することで実行できます。

以下のサンプル動画で、ここで説明した手順のすべてをご覧いただけます。

アニメーションサンプル動画

ハック 6:FBX インポーターを使ったメッシュの設定

前のセクションとまったく同じ考え方で、使用するモデルをセットアップしていきます。この場合、AssetPostrocessor.OnPreprocessModel を採用し、このモデルのインポーター設定を行います。

プロトタイプでは、インポーターでマテリアルを生成しないように設定し(プロジェクトで作成したものを使用します)、モデルがユニットなのか建物なのか(いつものようにラベルを確認することで)チェックしました。ユニットはアバターを生成するように設定されていますが、建物はアニメーションしないので、建物のアバター生成は無効になっています。

皆さんのプロジェクトでは、モデルをインポートする際にマテリアルとアニメーター(および、他に追加したいものはすべて)を設定するのがよいでしょう。こうすることで、インポーターで生成されたプレハブをすぐに使用することができるようになります。

これには、AssetPostprocessor.OnPostprocessModel メソッドを使用します。このメソッドは、モデルのインポートが終了した後に呼び出されます。生成されたプレハブをパラメーターとして受け取り、プレハブを好きなように変更することができます。

プロトタイプの場合は、アニメーションのアバターの位置を決めるのと同じように、ラベルを照合してマテリアルとアニメーションコントローラーを探しました。プレハブのレンダラーとアニメーターで、通常のゲームプレイと同じようにマテリアルとコントローラーを設定しました。

あとは、そのモデルをプロジェクトにドロップすれば、どんなシーンにも落とし込めるようになります。ただし、ゲームプレイに関連するコンポーネントは何も設定していません。それについては、このブログの後編で紹介します。

そのモデルをプロジェクトにドラッグアンドドロップすれば、どんなシーンにも落とし込めるようになります。

次回をお楽しみに...

これらの高度なスクリプティングのヒントがあれば、ゲームを作る準備は万端です。今回 2 回にわたってお送りしている Tech from the Trenches の記事ですが、次回はゲームデータのバランスをとるためのハックなどをご紹介しますので、ご期待ください。

この記事について議論したり、記事を読んだ後の感想を共有したりしたい場合は、私たちのスクリプティングフォーラムをご覧ください。また、Twitter でも @CaballolD とつながりましょう。

2022年10月18日 カテゴリ: テクノロジー | 15 分 で読めます
関連する投稿