20200628のUnityに関する記事は4件です。

【Unity】OpenVRで取得できるOculusコントローラー(Touch)のボタン入力

はじめに

公式のマニュアルが分かりづらかったので、備忘録を書いてみました。

OpenVR コントローラーの入力 - Unity マニュアル
https://docs.unity3d.com/ja/2018.4/Manual/OpenVRControllers.html

環境

  • Windows 10 Home
  • Untiy 2019.3.13f1
  • Oculus Desktop 2.38.4
    • Virtual Reality SDKsでOculusを追加すると自動的にインポートしてくれる
  • OpenVR Desktop 2.0.5
    • Virtual Reality SDKsでOpenVRを追加すると自動的にインポートしてくれる
  • Oculus Rift CV1
    • Rift S / QuestのTouchコンも互換あるはずなのでキーマッピングは同じ気がする

調べ方

適当なプロジェクトで下記コードを動かして対応関係を調べた

test.cs
using UnityEngine;

public class test : MonoBehaviour
{
    private void Update()
    {
        // コントローラーのキーマッピングを調べるためのコード
        for (var keyCode = KeyCode.JoystickButton0; keyCode <= KeyCode.JoystickButton19; ++keyCode)
        {
            if (Input.GetKeyDown(keyCode))
            {
                Debug.Log($"{keyCode}が反応しました。");
            }
        }
    }
}

Player Settings -> XR SettingsのVirtual Reality SDKsの設定で、どちらが有効か(上が優先される)で挙動が違いました。

xr.PNG

nkjzm/OpenVR-Oculus-test
https://github.com/nkjzm/OpenVR-Oculus-test

キーマッピング

左がPrimaryで、右がSecondaryです。

oculus_touch.jpg
出典: https://developer.oculus.com/documentation/unity/unity-ovrinput/

Oculusが有効な時

ボタン 説明 インタラクションタイプ KeyCode
Button.One Aボタン 押す JoystickButton0
Button.Two Bボタン 押す JoystickButton1
Button.Three Xボタン 押す JoystickButton2
Button.Four Yボタン 押す JoystickButton3
Axis1D.PrimaryIndexTrigger 左トリガー 押す なし
Axis1D.SecondaryIndexTrigger 右トリガー 押す なし
Axis1D.PrimaryHandTrigger 左グリップ 押す JoystickButton4
Axis1D.SecondaryHandTrigger 右グリップ 押す JoystickButton5
Button.PrimaryThumbstick 左ジョイスティック 押す JoystickButton8
Button.SecondaryThumbstick 右ジョイスティック 押す JoystickButton9
Button.One Aボタン 接触 JoystickButton10
Button.Two Bボタン 接触 JoystickButton11
Button.Three Xボタン 接触 JoystickButton12
Button.Four Yボタン 接触 JoystickButton13
Axis1D.PrimaryIndexTrigger 左トリガー 接触 JoystickButton14
Axis1D.SecondaryIndexTrigger 右トリガー 接触 JoystickButton15
Axis1D.PrimaryHandTrigger 左グリップ 接触 なし
Axis1D.SecondaryHandTrigger 右グリップ 接触 なし
Button.PrimaryThumbstick 左ジョイスティック 接触 JoystickButton16
Button.SecondaryThumbstick 右ジョイスティック 接触 JoystickButton17
Touch.PrimaryThumbRest 左の指置き場 接触 JoystickButton18
Touch.SecondaryThumbRest 右の指置き場 接触 JoystickButton19

トリガーを押した時の入力が取れないのしんどいですね

OpenVRが有効な時

ボタン 説明 インタラクションタイプ KeyCode
Button.One Aボタン 押す JoystickButton1
Button.Two Bボタン 押す JoystickButton0
Button.Three Xボタン 押す JoystickButton3
Button.Four Yボタン 押す JoystickButton2
Axis1D.PrimaryIndexTrigger 左トリガー 押す JoystickButton14
Axis1D.SecondaryIndexTrigger 右トリガー 押す JoystickButton15
Axis1D.PrimaryHandTrigger 左グリップ 押す JoystickButton4
Axis1D.SecondaryHandTrigger 右グリップ 押す JoystickButton5
Button.PrimaryThumbstick 左ジョイスティック 押す JoystickButton8
Button.SecondaryThumbstick 右ジョイスティック 押す JoystickButton9
Button.One Aボタン 接触 なし
Button.Two Bボタン 接触 なし
Button.Three Xボタン 接触 なし
Button.Four Yボタン 接触 なし
Axis1D.PrimaryIndexTrigger 左トリガー 接触 なし
Axis1D.SecondaryIndexTrigger 右トリガー 接触 なし
Axis1D.PrimaryHandTrigger 左グリップ 接触 なし
Axis1D.SecondaryHandTrigger 右グリップ 接触 なし
Button.PrimaryThumbstick 左ジョイスティック 接触 JoystickButton16
Button.SecondaryThumbstick 右ジョイスティック 接触 JoystickButton17
Touch.PrimaryThumbRest 左の指置き場 接触 なし
Touch.SecondaryThumbRest 右の指置き場 接触 なし

対応していない入力が増えます。AB, XYの対応関係が逆になる点に注意です。

使い方

// OpenVRが有効な状態でAボタンを押す場合
if (Input.GetKeyDown(KeyCode.JoystickButton1))
{
    // do something
}

シンプルな方法でアクセスできるのは、やはり便利そうですね。

最後に

間違ってたり漏れがあったら教えてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初心者向け UnityでC#の基礎を1から簡単に習得する方法

C#を学びたい!

Unityは触れるけどC#がいまいちよく分からない。
てっとり早くC#の基礎を学びたいそんな方におすすめの方法を紹介します。

動画で学ぶとめっちゃ楽!!

ドットインストール - 3分動画でマスターできるプログラミング学習サービス
老舗の動画で学べる学習サイトです。
おすすめするポイントはとにかく喋るのが早い!
「えー」とか「あー」とか余談とか余計な事を一切言わないので先生に学んでいるような「わかったから、もう先に進んでくれ。。。」という感情をまったく抱きません。
(先生ごめん!)

ただ注意点も

C#入門 #6から有料になってった。。。。
準備中気づいたのですが#6以降が有料になってました > <
なので#4、#5で勉強してみて自分にあってればプレミアム会員(1080円/月)に登録してください。
無料時代に全編見た僕の感想は「これ無料でいいの??マジか!神か!」と思ったので有料でも損は無い内容です。

ドットインストールで学べる内容

C# 入門 (全36回)
#01〜03はUnityで学ぶ上で必要が無いので割愛します。

#04 変数を使ってみよう (02:47) 無料公開中
変数、定数
型推論
#05 変数の型を見ていこう (02:34) 無料公開中
基本的なデータ型
#06 データの演算をしてみよう (02:51)
データの演算
#07 文字列を扱ってみよう (03:00)
文字列の連結
特殊文字
変数の展開
式の評価
書式指定
#08 ifで条件分岐をしてみよう (02:57)
条件分岐
比較演算子
#09 switchで条件分岐をしてみよう (02:25)
switch
#10 whileでループ処理をしてみよう (02:39)
while
do ... while
#11 forで繰り返し処理をしてみよう (02:33)
for
break
continue
#12 配列で複数のデータを扱おう (02:51)
配列
要素へのアクセス
#13 foreachを使ってみよう (02:05)
Length
foreach
#14 メソッドで処理をまとめてみよう (02:28)
メソッド
返り値
#15 引数を使えるようになろう (02:36)
引数
初期値
名前付き引数
#16 クラスを使ってみよう (02:51)
クラス
フィールド、メソッド
インスタンス
#17 コンストラクタを使ってみよう (02:59)
コンストラクタ
オーバーロード
#18 クラスを継承してみよう (03:01)
継承
オーバーライド
#19 アクセス修飾子を理解しよう (02:35)
アクセス修飾子
#20 プロパティを使ってみよう (02:58)
プロパティ
アクセスのコントロール
#21 インデクサを使ってみよう (02:29)
インデクサ
要素へのアクセス
#22 static修飾子を使ってみよう (02:42)
static修飾子
#23 抽象クラスを使ってみよう (02:46)
抽象クラス
抽象メソッド
#24 インターフェースを使ってみよう (02:15)
インターフェースの作成
インターフェースの実装
#25 ジェネリックで型を汎用化してみよう (02:53)
ジェネリックを利用しない例
ジェネリックを利用した例
#26 名前空間を使ってみよう (02:27)
名前空間
using
#27 構造体を使ってみよう (02:18)
構造体
クラスとの違い
#28 列挙体を使ってみよう (02:46)
列挙体
定数へのアクセス
数値への変換
#29 例外を扱ってみよう (02:39)
例外の捉え方
例外クラスの作り方
#30 delegateを使ってみよう (02:37)
delegate型の宣言
delegateの利用
マルチキャスト
#31 匿名メソッド、ラムダ式を使おう (01:55)
匿名メソッド
ラムダ式
#32 eventを使ってみよう (02:49)
event
動作確認
#33 Listでデータ管理してみよう (02:40)
Listの宣言、初期化
Count
要素へのアクセス
#34 HashSetを扱ってみよう (02:20)
HashSetの宣言、初期化
HashSetの扱い
#35 Dictionaryを使えるようになろう (02:49)
Dictionaryの宣言、初期化
Disctionaryの扱い
#36 LINQを使ってみよう (02:41)
SQLのようなLINQへのアクセス
メソッドのようなLINQへのアクセス

学習の準備

ドットインストールに会員登録する
動画を観るには必ず会員登録する必要があります。
登録したからといってプレミアム会員に入らないといけないわけではないので安心してください。
https://dotinstall.com/signup

Unityのプロジェクトを作る
適当なUnityのプロジェクトを作成してください。2Dでも3Dでもどっちでも良いです。

スクリプトを準備

Unityプロジェクトを開いて、Window > General > Test Runnerを開きます。
2017以前のバージョンをお使いの場合はWindow > Test Runner
スクリーンショット 2020-06-28 18.06.05.png

開いたTest Runnerのウィンドウを見やすいように配置します。
スクリーンショット 2020-06-28 18.26.59.png

EditorModeの状態で「Create EditMode Test Assembly Folder」をクリック
するとフォルダ名がリネームできる状態になるのでリネームせずフォルダをクリックします
スクリーンショット 2020-06-28 18.28.00.png

フォルダをクリックするとTest Runnerのボタン名が変わって「Create Test Script in current folder」になります。
スクリーンショット 2020-06-28 18.27.32.png

更にボタンをクリックすると新しく生成されたスクリプトがリネームできる状態になるのでLessonという名前に修正します。
スクリーンショット 2020-06-28 18.29.00.png

Lessonのドロップダウンを開いてLessonSimplePassesの上で右クリック > 「Open source code」でコードを開きます。
スクリーンショット 2020-06-28 18.29.38.png

下記のようなコードが生成されました。

using System.Collections;
using System.Collections.Generic;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

namespace Tests
{
    public class Lesson
    {
        // A Test behaves as an ordinary method
        [Test]
        public void LessonSimplePasses()
        {
            // Use the Assert class to test conditions
        }

        // A UnityTest behaves like a coroutine in Play Mode. In Edit Mode you can use
        // yield return null; to skip a frame.
        [UnityTest]
        public IEnumerator LessonWithEnumeratorPasses()
        {
            // Use the Assert class to test conditions.
            // Use yield to skip a frame.
            yield return null;
        }
    }
}

コードを下記のように修正します。

using NUnit.Framework;
using UnityEngine;

namespace Tests
{
    public class Lesson
    {
        [Test]
        public void Lesson04()
        {
          // ここにコードを記述していく
        }
    }
}

学習の進め方

ブラウザでドットインストールのC#入門 #04変数を使ってみようを開きます。

冒頭の「Console.WriteLine("Hello World")」をTest Runnerで表示する流れをやってみます。
スクリーンショット 2020-06-28 18.34.24.png

スクリプトを下記のように修正します。
Console.WriteLineDebug.Logに読み替えてください。

using NUnit.Framework;
using UnityEngine;

namespace Tests
{
    public class Lesson
    {
        [Test]
        public void Lesson04()
        {
            // Console.WriteLineは読み替える
            Debug.Log("Hello World");
        }

        // #05用に予め用意
        [Test]
        public void Lesson05()
        {

        }
    }
}

Test Runnerを見ると下記のように表示が変化しています。
スクリーンショット 2020-06-28 18.32.07.png

「Lesson04」をクリックします。
Test RunnerのログとConsoleに「Hello World」が表示されれば成功です。
スクリーンショット 2020-06-28 18.32.36.png

この要領でスクリプトを修正したらTest Runner上でクリック。終わったらLesson05を増やしてというのが学習の流れです。

Test Runnerについて

今回使用したTest RunnerはUnityを実行しなくてもコードをテストできるめちゃくちゃ便利なツールです。
いつか使うかもしれないので頭の片隅に置いといてください。

C#を勉強したその後は

最近Unityが提供するチュートリアルUnity Learn Premiumが無料になったってご存知ですか?
Unityのすべての機能を本家が解説しているので本で勉強するよりおすすめだと思います。
是非覗いてみてください。

▼ 日本語対応のチュートリアル一覧
https://learn.unity.com/tutorials/?k=%5B%22lang%3Aja%22%5D&ob=starts

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityでpcdファイル(点群)を表示

はじめに

Unityでpcdファイルを直接開いて表示したかったのでやってみました.
PCL(PointCloudLibrary)を使ってPCDをちょっと変換してから,Unityで開いていきます.

環境

PCLでの前処理

  • Ubuntu16.04

Unity

  • Windows10
  • Unity2019.4.0f1
  • 適当なPCDファイル(ASCIIで保存しといてください.バイナリはちょっと厳しかったっす...)

PCDを普通に開けない理由

  • バイナリであることが多い
  • 色情報の処理が面倒
    • ASCIIに変換してもFloat(4.2108e+06とか)になるから読み込むのが手間
  • ヘッダーが邪魔

なので,これらを解決していきます.

PCLを使って(強引に)前処理

サンプルは,PCLのサイトから拝借.

これは既にASCIIなので,開けない理由のひとつ目はクリアしてますが,色情報がFloat(4.2108e+06とか)で面倒です.
なので,まずは(強引に)unsigned intに変換していきます.

CMakeLists.txt
cmake_minimum_required(VERSION 2.6 FATAL_ERROR)
project(HELLO_WORLD)
find_package(PCL 1.3 REQUIRED COMPONENTS common io)
find_package(PCL 1.3 REQUIRED COMPONENTS common visualization)
include_directories(${PCL_INCLUDE_DIRS})
link_directories(${PCL_LIBRARY_DIRS})
add_definitions(${PCL_DEFINITIONS})
add_executable(convert_pcd convert_pcd.cpp)
target_link_libraries(convert_pcd ${PCL_COMMON_LIBRARIES} ${PCL_IO_LIBRARIES} ${PCL_VISUALIZATION_LIBRARIES})

convert_pcd.cpp
#include <iostream>
#include<string.h>
#include <pcl/io/pcd_io.h>

using namespace std;

int main(int argc, char *argv[])
{
    pcl::PointCloud<pcl::PointXYZRGB>::Ptr p_cloud(new pcl::PointCloud<pcl::PointXYZRGB>);

    // 作成したPointCloudを読み込む
  pcl::io::loadPCDFile(argv[1], *p_cloud);

  std::cout << "Loaded "
          << p_cloud->width * p_cloud->height
          << " data points from "<< argv[1] 
          << std::endl;

    //変換後の保存用
  pcl::PointCloud<pcl::PointXYZRGB>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZRGB>);
  u_int32_t r = 0, g = 0, b = 0;

    // 点群の変換開始
  cloud->width    = p_cloud->width;
  cloud->height   = p_cloud->height;
  cloud->is_dense = p_cloud->is_dense;
  cloud->points.resize (p_cloud->width * p_cloud->height);

  for (size_t i = 0; i < p_cloud->points.size (); ++i){
    cloud->points[i].x = p_cloud->points[i].x;
    cloud->points[i].y = p_cloud->points[i].y;
    cloud->points[i].z = p_cloud->points[i].z;

//色情報を強引に変更している部分
    r = p_cloud->points[i].r;
    g = p_cloud->points[i].g;
    b = p_cloud->points[i].b;
    cloud->points[i].rgb =  (r << 16) | (g << 8) |b;
//ここまで
  }
  pcl::io::savePCDFileASCII (argv[2], *cloud);

  std::cerr << "Saved " << cloud->points.size () << " data points XYZRGB to " << argv[2] << std::endl;

    return 0;
}

実行は「変換したい点群 出力ファイル名」の順で以下のように

./convert_pcd test_pcd.pcd aaa.pcd

実行後は

aaa.pcd
# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z rgb
SIZE 4 4 4 4
TYPE F F F F
COUNT 1 1 1 1
WIDTH 213
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 213
DATA ascii
0.93773001 0.33763 0 8421600
以下略

というようにTypeがFなのに最後行のrgb値はunsigned intになってます.

強引に変換しないといけない理由は,savePCDFileASCIIのドキュメントに「Floatでしか出力できないのごめんね」と書いてあるからです(2020/6/27現在)
(将来的には対応するかもとも書いてあります)

ちなみに,これを使うとバイナリからASCIIへの変換もできます.

PCDファイルのヘッダー

基本的にPCDファイルのヘッダーは,バージョンによる差異はあるかもしれませんが以下のようになっています.

# .PCD v.7 - Point Cloud Data file format
VERSION .7
FIELDS x y z rgb
SIZE 4 4 4 4
TYPE F F F F
COUNT 1 1 1 1
WIDTH 213
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 213
DATA ascii
以下点群の位置+色情報

これらの値の詳細については,PCLのサイトに載っています.

なので,大事な部分だけ説明しますと,
3行目は値がどのように入っているか(この場合 x座標 y座標 z座標 色情報)
5行目は数値の型(この場合全部Float)
10行目は点群の数(この場合は213個)
となっています.

つまり,この11行が値を受け取るのに邪魔です.
(この中に重要な情報が含まれている場合をのぞく)

Unity側の処理

なので,Unity側は以下のように読み取ります.

PCDImporter.cs
using System;
using System.IO;
using System.Text;
using UnityEngine;

[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
//MaterialにはSprites-Defaultを指定する

public class PCDImporter : MonoBehaviour
{
    public Material spritesDefaultMat;
    public string PCDpath = "aaa.pcd";

    public static Vector3[] points;
    public static Color[] colors;

    // Start is called before the first frame update
    void Start()
    {
        //PCDファイルの読み込み
        ReadPCDFile(PCDpath);

        //デフォルトマテリアルのセット
        GetComponent<MeshRenderer>().material = spritesDefaultMat;
    }

    // Update is called once per frame
    void Update()
    {
        //スペースキーを押したら処理開始
        if (Input.GetKeyDown(KeyCode.Space))
        {
            CreateMesh(this.gameObject, points, colors);
        }
    }

    // 読み込み関数
    void ReadPCDFile(string dataPath)
    {

        // ファイルを読み込む
        FileInfo fi = new FileInfo(Application.dataPath + "/" + dataPath);

        // 一行毎読み込み(pcdはx,y,z,rgb)
        using (StreamReader sr = new StreamReader(fi.OpenRead(), Encoding.UTF8))
        {
            string txt = sr.ReadToEnd();
            string[] arr = txt.Split('\n');
            string[][] pointXYZRGB = new string[arr.Length - 1][]; //最後の改行の分引く
            int i, pointData_num = 0;

            for (i = 0; i < arr.Length - 1; i++)
            {
                pointXYZRGB[i] = arr[i].Split(' ');

                if (pointXYZRGB[i][0] == "DATA")
                {
                    pointData_num = i + 1;
                }

            }

            Debug.Log("ヘッダーの行数 : " + pointData_num);

            int size = i - pointData_num;

            Debug.Log("点群の数 : " + size.ToString());

            points = new Vector3[size];
            colors = new Color[size];

            int temp = 0;
            long temp_rgb = 0;
            int r = 0, g = 0, b = 0;

            for (i = pointData_num; i < pointXYZRGB.Length; i++)
            {

                temp = i - pointData_num; //ヘッダの分ずらす

                //値取得(x,z,yの順)
                //ここは目的に合わせて変更
                points[temp].x = Convert.ToSingle(pointXYZRGB[i][0]) * -1.0f;
                points[temp].z = Convert.ToSingle(pointXYZRGB[i][1]);
                points[temp].y = Convert.ToSingle(pointXYZRGB[i][2]);

                //TryParseHtmlString関数も使えるかも
                temp_rgb = Convert.ToInt64(pointXYZRGB[i][3]);

                r = Convert.ToInt32((temp_rgb >> 16) & 0x0000ff);
                g = Convert.ToInt32((temp_rgb >> 8) & 0x0000ff);
                b = Convert.ToInt32((temp_rgb) & 0x0000ff);

                //Unityは0.0f~1.0fで色を表現しているので,変換
                colors[temp].r = r / 255.0f;
                colors[temp].g = g / 255.0f;
                colors[temp].b = b / 255.0f;

                //α値
                colors[temp].a = 1.0f;

            }

        }

    }

    void CreateMesh(GameObject meshObj, Vector3[] pointsVector, Color[] mesh_colors)
    {
        Mesh preMesh = meshObj.GetComponent<MeshFilter>().mesh;

        int[] indecies = new int[pointsVector.Length];
        for (int i = 0; i < pointsVector.Length; ++i)
        {
            indecies[i] = i;
        }

        preMesh.vertices = pointsVector;
        preMesh.colors = mesh_colors;
        preMesh.SetIndices(indecies, MeshTopology.Points, 0);

    }

}

設定は以下の通り

image.png

これで,Gameビューをクリックしてスペースキーを押すと可視化が始まります.
PCLのチュートリアル点群を表示するとこんな感じ

image.png

以上です.

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity で Automatically manage signing を行う

はじめに

Unity & Xcode でビルドを作成する際に毎回忘れてしまう署名関連の備忘録として残しておこうと思います。

Manual or Auto

『Certificates, Identifiers & Profiles』の署名関係のファイルなどを Xcode だけで出来るようになっています。
以前までは自前でポータルサイトにログインしてそれぞれのファイルを作成していましたが、今はそれが不要になっています。
エンタープライズ版の場合は * アスタリスクでどのアプリでも利用することができたのですが、今はそれを行う必要がなく Unity を利用する場合は Preferences と PlayerSettings に設定するだけです。

注意

Automatically manage signing を行う場合にポータルで同じ Bundle ID のプロビジョニングプロファイルなどの作成はしないようにしてください。もし、同じ Bundle ID が存在すると競合してしまいエラーになります。

Unity 側の設定

Unity 側には二箇所設定する項目があります。

Preferences の設定

まずは Preferences ですが『Xcode Default Settings』という設定がありますが、それぞれ以下のような値を設定します。

  • Automatically Sign にチェック
  • Signing Team Id にポータルの Team ID を指定
  • iOS/tvOS の Manual は利用しないのでそのまま

image.png

Team ID はポータルのアカウントで『Membership』に記載されています。

image.png

PlayerSettings の設定

次はプロジェクトごとの PlayerSettings です。
『PlayerSettings > Other Settings > Identification』で以下のような値を設定します。

  • Bundle Identifier に Bundle ID
  • Signing Team ID に Team ID を指定
  • Automatically Sign にチェック

image.png

Xcode Default Settings の値と同じにします。
まぁ、設定しなくてもデフォルト設定が利用されると思うのですが…。

ビルド

Unity の設定を行いビルドを行います。
生成された Xcode プロジェクトを開いて『Signing & Capabilities』の設定を見るとちゃんと設定がされています。

image.png

ポータル側も以下のように証明書、プロファイルがそれぞれ登録されています。

『Certificates,Identifiers & Profiles > Certificates』にはビルドを実行した端末が登録されています。
image.png

『Certificates,Identifiers & Profiles > Identifiers』には『XC <Bundle ID>』という ID が登録されます。
例えば『com.aaa.BBB』とした場合『XC com aaa BBB』という感じの ID です。
image.png

おわりに

とりあえず毎回忘れてしまう署名関連の処理。
Unity を使えば Unity の設定だけで済みますのでかなり楽になります。

一度 Manual ではなく Auto でビルドすることを検討してみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む