Meta Quest 3で色んなMR作ってみた!マルチプレイからストアリリース、仕事での活用まで

この記事はTech KAYAC Advent Calendar 2024の20日目の記事です。

面白プロデュース事業部 技術部の藤澤です。

この記事は、2024年実施してきたMRに関する3つの取り組みのご紹介と、それに関連するちょっとしたTipsをお話しする内容となっております。

マルチプレイMRコンテンツの開発

作るまでの背景

時間は去年まで遡り、MetaQuest3が出たてぐらいの頃にこちらの方のポストがチームの中で話題になっていました。

これは見るからに楽しそうだなと思いましたし、人同士が対面した状態で面白い体験を共有できる形は、色んなリアルイベントをやっている弊社とも相性が良さそうな技術だなと思いました。

そんな感じの理由から、チームの中でMRマルチコンテンツを作ってみようという流れになりました。

どんなものを作ったか

では、面白プロデュース事業にも活かせそうなどんなマルチコンテンツを作るかブレストをしました。その結果アイデアとして、

10m*10mぐらいのちょっと広い空間から

長細い廊下のような限られた空間まで

色んな空間の中に複数のミニコンテンツを配置できて、その空間を複数人で渡り歩いて体験できるミニテーマパークみたいなものを構想しました。

最終的に以下のような要件になりました。

① 空間の中にミニコンテンツを開始するための領域を定義することができる。
② 定義した領域にユーザーの誰かが侵入すると、紐づけられたミニコンテンツが開始される。
③ ミニコンテンツ開始後は、早いもの順で領域に侵入したユーザーに体験するための権限が付与され、それ以外のユーザーは傍観者となる。
④ 再度、定義した別領域にユーザーの誰かが侵入すると別のミニコンテンツに切り替わる。

そして、こんな感じのコンテンツを作成しました。

www.youtube.com

Tips

同じ空間にモノを出す

MetaQuestのMR空間の原点座標は、コンテンツ起動時のヘッドマウントディスプレイの位置と回転を原点として扱います(多分)。そのためいつものようにVector3.Zeroとかに移動させても、それぞれで異なる結果になってしまいます。このことからMetaQuest間で共通の原点座標を定義することが重要となります。

上記のコンテンツでは、SDKが提供しているShared Spatial Anchorsという機能を使って複数のMetaQuest間で同じ原点座標を定義しました。

導入方法は、こちらの方の記事がより最新に近い方法をご紹介されていると思います。

zenn.dev

現在は上記の方の記事のようにBuildingBlocksという機能(必要なコンポーネントを構築してくれるUtility)を使って構築するのが効率的だと思いますが、コンテンツを実装していた当時はBuildingBlocksにShared Spatial Anchorsの項目が存在していなかったため、Meta公式のサンプルを参考に、Anchorを生成、シェア、ロードする機能を実装しました。

github.com

あとはオブジェクトの位置と回転を、定義した原点を基準に計算してあげれば、同じ空間にモノを表示することができます。具体的には以下のような流れになります。

アンカーとの相対座標、ワールド座標変換の方法についてはこちらの方の記事がとても参考になりました。

qiita.com

HMD間のマッチング、データ通信

MetaQuest間のマッチング、データ通信に関しても、現在はBuildingBlocksから構築可能ですが、このコンテンツでは自前で実装しました。BuildingBlocksから構築する場合、通信サービスとして、PhotonやNetcode for GameObjectsを選択できますが、こちらはMQTTをプロトコルとして採用し、MQTTnetを利用して実装しました。

github.com

マッチング

以下のような流れで、同じBrokerに接続しているユーザー間でマッチングを行いました。

またマッチングしたユーザー間で、ホスト側で作成した空間情報と紐づいたAnchorIDを共有することで、ユーザー間で同じ空間を再現しました。(空間情報は下のgifのような手動で定義できる機能を用意しました)

データ通信

マッチングしたユーザー間で、RoomIDと各コンテンツに必要なデータ(座標、何かの状態、etc...)をMQTTで相互通信しました。

この実装をしている時、通信データのロスト問題に悩まされました。

MQTTで送信するデータは、何かの状態のようなロストすると体験が破綻するデータをQoS(Quality of Service) 1以上、座標情報のような毎フレーム送信する必要があるデータをQoS 0で設定していました。そのため座標情報はロストする可能性がある前提でしたが、それにしてもQoS 0の通信がロストしまくっていました(カクカク動いて見える状態)。

解決策として、まず通信速度に対して早すぎる周期でデータを送り続けているではないのか?という仮説を立て、MQTTの送受信を行なっている処理のマルチスレッド化と、Semaphoreslimを用いたロック処理を行いました。SemaphoreSlim(1, 1)とすることで、必ず一つ前のデータが送られた後に、最新のデータを送るようにしました。しかし、このアプローチはあまり効果はありませんでした。

learn.microsoft.com

次に、Brokerを立てているPCの処理性能に目をつけました。これが一番効果がありました。Intel Mac Book ProからM1 Mac Book Proに変更すると、かなりのロストが改善されました。

結論、金で殴るのが一番効果ありました。

以上がマルチプレイに関する取り組みのご紹介になります。何度もお話ししている通り最新の方法ではなく、だいぶイレギュラーな方法のご紹介ですが、何かのお役に立つと幸いです。

MRコンテンツのストアリリース

HickonuQ The Rise of the Legendary FarmeというMRゲームをMetaストアにリリースしました。

www.meta.com

リリースしたコンテンツのご紹介

この世界では、ゴムのような弾力を持つ不思議な野菜 HickonuQが大流行しています。

皆さんには、そんなHickonuQを収穫して一儲けを狙う引っこ抜き農家になって頂きます。

HickonuQは非常に高い繁殖能力を有しておりどこにでも生えます。あなたの部屋も一瞬でHickonuQ畑になることでしょう。

一方でHickonuQはとても旬が短く、腐ってしまうのも一瞬です。HickonuQを高品質で収穫できるかはあなたの腕にかかっています。

HickonuQは最も旬な時期になると衝撃で根からタネを撒き散らし、次の世代へ生を繋ぎます。この性質を活かして繁殖させ、HickonuQ畑を存続させることもあなたの仕事です。

HickonuQが枯れ果て荒野になると、あなたのビジネスも終わりです。最後に今までの出来高報酬(スコア)が支払われます(現実世界のあなたには支払われません)。

ちなみに開発者の最高収穫利益は48000円です。

皆さんもぜひこのビッグウェーブに乗っかってください。(ゲームは無料で販売しております)

Tips

伸縮する野菜

HickonuQは、手で野菜を掴んで引っこ抜くことができるのが一番ユニークな点です。私たちがそんな体験をどうやって実装したのか、ご紹介します。

3Dモデル

野菜の3DモデルはKayacの3Dアーティストの高橋敦と連携して制作しました。

3Dモデルには葉っぱ、葉っぱと身の付け根、実の中心、実のお尻にかけてジョイントが設定されており、葉っぱ(Joint_End)のジョイントをUnity側で座標制御することで、野菜を引っ張っているようなビジュアルを表現しました。

また、高橋の方で各Jointに対するSkinWeight(メッシュの各頂点に対してジョイントからの影響度を割り当てる仕組み)を設定しました。これにより、葉っぱのジョイントを移動させると、葉っぱと身の付け根あたりのメッシュも少し引き伸ばされるような、リアルな柔らかの動きを再現しました。

Unity

Unity側では、葉っぱを引っ張っている状態から離した際にバウンドするように元の形状に戻る動きや、引っこ抜いた時に実がバネのように追従してくる挙動を再現するために、SpringJointというバネのような物理演算を行うコンポーネントを各ジョイントに設定することで表現しました。

docs.unity3d.com

また、野菜をMR上で掴む処理は、MetaXRCoreSDKが提供しているBuilding Blocksを用いて構築しました。詳細は去年のアドベントカレンダーでも触れていますので、そちらを参照頂ければと思います。

techblog.kayac.com

最後に、クライアントワーク事業の案件でMetaQuestのMRを採用した事例をご紹介させて頂きます。

ここからの紹介は実装を担当した小松原にバントタッチします。

クライアントワーク事業での活用

面白プロデュース事業部、Unityエンジニアの小松原です。 ここではクライアントワークの活用例として「模型AR」の事例をご紹介します。

www.kayac.com

模型AR概要

今回の事例では、展示会で使用する化学反応器の模型をわかりやすくすることが課題でした。 そこで、Quest3を用いたMR表現で構造や機能を説明することにチャレンジしました。

具体的には、以下のような実装要件が求められました:

  1. 実際の模型と同じ位置に3Dオブジェクトを配置する
  2. 展示模型の説明として、気体の流れなどをテクスチャアニメーションで表現する
  3. 上記のアニメーションをシームレスに連続再生する これらのうち、1と3はエンジニアの担当範囲、2は主にCGデザイナーが担当した工程です。 そして完成したコンテンツがこちらです。

www.youtube.com

新しいワークフロー

今回の制作では、従来の方法とは異なる新しいワークフローを採用しました。 エンジニアとCGデザイナーのタスクを以下のように明確に分離することで、双方がそれぞれの専門作業に集中できる仕組みとなります。

CGデザイナー
  • Cinema 4Dでコンテンツのビジュアルを作成
  • シェーダーの基本設計をCinema 4D上で作成
  • シェーダーのパラメータ(例:値の変化やテクスチャのオフセット)を、ジョイントの移動値として変換

  • 作成したデータをFBXファイルとして書き出し
エンジニア
  • UnityにFBXを取り込み、3Dモデルを配置
  • 上記のシェーダーと同じ機能のものをUnity上で再現
  • ジョイントの移動値をUnity側シェーダーのプロパティに変換するスクリプトを作成し、対応づけるよう設定

youtu.be

この手法により、CGデザイナーがCinema 4D上で作成したテクスチャアニメーションをUnity上で忠実に再現することが可能になりました。 さらに、表現の修正や変更が必要になった場合でも、CGデザイナーはFBXファイルを修正するだけで済み、Unity側ではファイルを差し替えるだけで対応が完了します。 この分業体制を採用したことで、作業フローの効率化を図るとともに、デザインと実装の統一性を保つことができました。

まとめ

2024年はマルチプレイ、ストアリリース、クライアントワーク事業への活用、という3点にフォーカスしてMR開発に取り組んで参りました。

2025年も様々なコンテンツを開発していきたいと思います!

カヤックと一緒にMRを使ったマルチプレイコンテンツ、ストアリリース、新しい展示表現を実現したい方はこちらからお問い合わせ頂けると幸いです!

www.kayac.com

【JS体操】第5問「画像からアスキーアートを生成しよう」解説

こんにちは!面白プロデュース事業部のおばらです。

またまた大変お待たせいたしました!!
『JS体操』第5問「画像からアスキーアートを生成しよう」の解説記事をお届けいたします。

この第5問は YAPC::Hakodate 2024 の開催に合わせ、同じくカヤック主催のPerl版コードゴルフ大会『Perlbatross』との同時開催となったスペシャル版です。

yapcjapan.org



ノベルティのチラシも用意し、イベント内で告知させていただきました。
運営のみなさま、『Perlbatross』と『JS体操』に参加して頂いたみなさま、ありがとうございます!



もし第5問まだ挑戦できていなかった!というひとは以下よりぜひ。

hubspot.kayac.com




目次


1位の回答

1位は halwhite さんでした!おめでとうございます!!

export default(b,t=document.createElement`canvas`.getContext`2d`)=>fetch(`test-cases/${t.drawImage(b,0,0),t.getImageData(8,32,1,1).data[0]?'kaya':'yap'}c.txt`).then(r=>r.text())

1ピクセルのみをチェックするというハック的な手法ですね!
UNBELIEVABLE♪




社内で元々想定していた最短文字数の回答

export default(t,W=128,c=new OffscreenCanvas(W,W).getContext`2d`)=>c.getImageData(~~c.drawImage(t,0,0,W,64),0,W,64).data.reduce((a,p,i)=>a+=i%4?~i%512?'':`
`:'#`'[p>>7],'')

想定回答は真面目に変換するロジックでした!




解説

① canvas の生成をなるべく短く書く

const cvs = document.createElement('canvas');

元のコードでは、上記のように canvas タグを生成しています。
ここで、この canvas タグは DOM ツリーにはその後も挿入されないことに注目しましょう。
つまり、画面上に表示するためではなく、単に内部的に(≒画面外で)画像処理のために生成しているに過ぎないのです。
ということは、画面外、言い換えるとオフスクリーン専用の OffscreenCanvas でも良さそうですね!

また、今回必要な canvas のサイズは既知ですから以下で OK です。

const cvs = new OffscreenCanvas(128, 128);



② 2行の平均をとる処理を canvas.drawImage() に委ねる

aa.js の以下の部分に注目してください

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //

このロジックを実現するために、元のコードでは泥臭く平均を計算しています。
でもこれ、drawImage する際に縦方向のみを 1/2 に縮小すれば良さそうではないでしょうか?
そう、(本問題では)それで代替できるのです。 drawImage() で拡縮して描画した際の結果は(通常は)環境依存であることに注意してください。本問題では環境ごとに差異が出ないようなテストケースにしています。



③ 元々が白黒画像なので、RGB の単純平均を取る必要はない

const r0 = rgbaArray[i + 0] / 0xFF; // 1行目の赤チャンネル(0〜1に正規化)
const g0 = rgbaArray[i + 1] / 0xFF; // 1行目の緑チャンネル(0〜1に正規化)
const b0 = rgbaArray[i + 2] / 0xFF; // 1行目の青チャンネル(0〜1に正規化)
...
const gray0 = (r0 + g0 + b0) / 3; // 1行目の赤緑青チャンネルの単純平均

元のコードでは上記のようにRGBの単純平均を計算してますが、RGB いずれかの値を参照すればよいですね!

ここまでの知識で、以下まで短くできます。

export default function aa(bmp) {
  //
  //  キャンバス作成とコンテキスト取得
  //
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');

  //
  //  画像をキャンバスに描画
  //
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );

  //
  //  ピクセルデータ取得
  //
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //
  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;
      const gray = rgbaArray[i + 0] / 0xFF;

      if (gray < 0.5) {
        row.push('#');
      } else {
        row.push('`');
      }
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');
}




④ if 文での分岐を配列の添字アクセスで代替する

以下の部分に注目してください。

const gray = rgbaArray[i + 0] / 0xFF;

if (gray < 0.5) {
  row.push('#');
} else {
  row.push('`');
}


まず / 0xFFif 文の条件部分に移動します

const gray = rgbaArray[i + 0];

if (gray < 0.5 * 0xFF) {
  row.push('#');
} else {
  row.push('`');
}


0.5 * 0xFF0.5 * 255、つまり 127.5 ですから、

const gray = rgbaArray[i + 0];

if (gray < 127.5) {
  row.push('#');
} else {
  row.push('`');
}


ここで、条件分岐を配列の添字アクセスで代替してしまいます。

const gray = rgbaArray[i + 0];

row.push('`#'[gray < 127.5 ? 1 : 0]);


更にシンプルにして、

const gray = rgbaArray[i + 0];

row.push('`#'[+(gray < 127.5)]);


本問題ではビット演算を使用してさらに短くできそうです。

const gray = rgbaArray[i + 0];

row.push('#`'[gray >> 7]);


整理すると

row.push('#`'[rgbaArray[i] >> 7]);


現状でここまで短くなっているはずです!

export default function aa(bmp) {
  //
  //  キャンバス作成とコンテキスト取得
  //
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');

  //
  //  画像をキャンバスに描画
  //
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );

  //
  //  ピクセルデータ取得
  //
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;

  //
  //  ピクセルデータを文字に変換
  //  1文字が縦長なので、横1px ✕ 縦2px の2ピクセルを1文字で表現する
  //
  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;

      row.push('#`'[rgbaArray[i] >> 7]);
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');
}




⑤ for ループを配列のメソッドで代替する

以下のループ部分を短くしましょう。

  const rows = [];

  for (let y = 0; y < 64; y += 1) {
    const row = [];

    for (let x = 0; x < 128; x += 1) {
      const i = ((128 * (y + 0)) + x) * 4;

      row.push('#`'[rgbaArray[i] >> 7]);
    }

    rows.push(row.join(''), '\n');
  }

  return rows.join('');


こう直せますね!

  const rows = Array(128 * 64)
    .fill()
    .map(
      (_, i) => {
        const isEndOfLine = (i + 1) % 128 === 0;
        
        return '#`'[rgbaArray[i * 4] >> 7] + (isEndOfLine ? '\n' : '');
      }
    );


文字数をチェックすると、、

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = Array(128 * 64)
    .fill()
    .map(
      (_, i) => {
        const isEndOfLine = (i + 1) % 128 === 0;
        
        return '#`'[rgbaArray[i * 4] >> 7] + (isEndOfLine ? '\n' : '');
      }
    );

  return rows.join('');
}

GOOD♪




⑥ 新規に作成した配列ではなく、ピクセルデータが格納されている Uint8ClampedArray のメソッドを使う

Array(128 * 64) で新たにからの配列データを作るのをやめましょう。
つまりこうです。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = [...rgbaArray].map(
    (pixel, i) => {
      const isEndOfLine = (i + 1) % (128 * 4) === 0;
      const isRedPixel = i % 4 === 0;
      
      return (
        (isRedPixel ? '#`'[pixel >> 7] : '') +
        (isEndOfLine ? '\n' : '')
      );
    }
  );

  return rows.join('');
}



ここで、rgbaArray.map ではなく [...rgbaArray].map としていることに注目してください。
TypedArraymap() の戻り値は、元の TypedArray と同じ型になるからです。

rgbaArray.xxxx と直接書けるように、map() ではなく reduce() にしてみましょう。 例えばこうです。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => {
      const isEndOfLine = (i + 1) % (128 * 4) === 0;
      const isRedPixel = i % 4 === 0;
      
      return result + (
        (isRedPixel ? '#`'[pixel >> 7] : '') +
        (isEndOfLine ? '\n' : '')
      );
    },
    ''
  );

  return rows;
}




⑦ 整理していく

さぁ、ここからどんどん短くしていきます!

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      (i % 4 ? '' : '#`'[pixel >> 7]) +
      ((i+1) % 512 ? '' : '\n')
    ),
    ''
  );

  return rows;
}



ふたつの三項演算子をまとめちゃいます。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      i % 4 ? ((i+1) % 512 ? '' : '\n') : '#`'[pixel >> 7]
    ),
    ''
  );

  return rows;
}



(i + 1)~i にして1文字減らしましょう。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result + (
      i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7]
    ),
    ''
  );

  return rows;
}



result + (...)result += ... にして丸括弧を省略できるようにします。

export default function aa(bmp) {
  const cvs = new OffscreenCanvas(128, 64);
  const ctx = cvs.getContext('2d');
  ctx.drawImage(
    bmp,
    0, 0, 128, 64,
  );
  const imageData = ctx.getImageData(0, 0, 128, 64);
  const rgbaArray = imageData.data;
  const rows = rgbaArray.reduce(
    (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
    ''
  );

  return rows;
}



どんどん整理しちゃいましょう!

export default bmp => {
  const ctx = new OffscreenCanvas(128, 64).getContext('2d');
  ctx.drawImage(bmp, 0, 0, 128, 64);
  return ctx.getImageData(0,0,128,64).data.reduce(
    (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
    ''
  );
};

GREAT♪



export default (bmp, ctx = new OffscreenCanvas(128, 64).getContext('2d')) => ctx.getImageData(
  ~~ctx.drawImage(bmp, 0, 0, 128, 64), 0, 128, 64
).data.reduce(
  (result, pixel, i) => result += i % 4 ? (~i % 512 ? '' : '\n') : '#`'[pixel >> 7],
  ''
);



最後に余計なホワイトスペースを消して、、

export default(t,W=128,c=new OffscreenCanvas(W,W).getContext`2d`)=>c.getImageData(~~c.drawImage(t,0,0,W,64),0,W,64).data.reduce((a,p,i)=>a+=i%4?~i%512?'':`
`:'#`'[p>>7],'')

できた!!!
UNBELIEVABLE♪




おまけ

アスキーアート楽しいですよね!
私はアスキーアートが好きすぎて、昔こんなツールも作ったことがあります。
ぜひ遊んでみてください笑



https://aagen.tsmallfield.com/




まとめ

最後まで読んでいただきありがとうございます。
そして挑戦してくださったみなさま、ありがとうございました。

実は今回の第5問で『JS体操』は一旦区切りとなります。

もともとは JavaScript の仕様の理解とプログラミング的思考力を鍛えるために始めた社内勉強会『JS体操』。
それをぜひ社外の方にも遊んでいただこうと始まった本シリーズ、全5問いかがでしたでしょうか?

もしこの『JS体操』という文化がいろんな会社に根付き、そして独自に発展していったら幸いです。
『TS体操』『GO体操』みたいにスケールするのも面白そうですね。

今まで参加&応援してくださったみなさま、本当にありがとうございました。




『JS体操』の情報を受け取ろう

登録フォーム

「解説ブログ」や「JS体操の問題」のお知らせが気になる方は以下のページにてご登録ください。

hubspot.kayac.com

技術部公式 X アカウント

面白法人カヤック技術部公式 X アカウント @kayac_tech でも随時情報を発信します。

カヤック技術部公式 X アカウント @kayac_tech


『JS体操』過去問一覧

『JS体操』の過去問、まだ挑戦していない!という方はぜひ。

hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com hubspot.kayac.com


お知らせ

先日、Perl のコードゴルフコンテストもカヤック主催で開催されました。
こちらも面白いのでぜひご覧ください!

techblog.kayac.com

そして、カヤックではコードゴルフが大好きな新卒&中途エンジニアも募集しています!

www.kayac.com www.kayac.com