0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

# ブラウザから操作可能なラジコンを作る【8】

Last updated at Posted at 2025-03-09

【8】ThreeJSでVRもどきを作成

映像が受け取れたので
画面を見ながらラジコン操作するだけならこれで十分ですが
ロマン感が足りません。

どうせ映像を表示するなら
VR表示できたほうが楽しいのは間違いありません。

しかし、360度カメラを使うと

  • 映像データのレート増大による過負荷
  • カメラ本体の価格が非常に高額
  • RaspberryPi3のUSB2.0では、映像の取得が出来ないかも
  • ドライバーもないかも

など、検討事項が多くなります。

ですので

  • 魚眼レンズの付いたUSBカメラを使用
  • 前半分だけVR

と言う割り切った構成で進めます。
いちいち後ろを気にするやつは、トップには立てない
と言うことです。


目次


ThreJSでパノラマ表示

結論から言うと
Googleのストリートビューを作れば良い
という事になります。

ですので

  • WebGUPなどで球体の3Dオブジェクトを描画
  • カメラを球体の中心辺りに設置
  • 球体の内側にカメラの映像を投影
  • カメラのパンのみ操作を認める

以上を満たすものを作れば良いわけです。

WebGPUをそのまま使うのはかなりマッチョな発想なので
定番のThreeJSと操作にOrbitControlsを使用します。

設定系の情報が多いので、以下のようにオブジェクトに数値を持たせて
一箇所で設定情報を管理します。

設定値の対応表を念の為作っておきます。

プロパティ 数値 用途
Spec.Screen.width 1980 描画のベース幅
Spec.Screen.height 1020 描画のベース高さ
Spec.Camera.fov 72 視野角
Spec.Camera.near 0.1 近接描画距離
Spec.Camera.far 1000 遠隔描画距離
Spec.Camera.x 0 カメラのx座標
Spec.Camera.y 1.6 カメラのy座標
Spec.Camera.z 1.2 カメラのz座標
Spec.Sphere.radius 400 球体の直径
Spec.Sphere.widthSegments 60 横方向の面の分割
Spec.Sphere.heightSegments 40 縦方向の面の分割
Spec.Controls.enableZoom false ズームを認めるか
Spec.Controls.enablePan false 移動を認めるか
Spec.Controls.enableRotate true 回転を認めるか
Spec.Controls.rotateSpeed -0.2 カメラを動かすスピード
Spec.Renderer.videoTarget remote-video カメラ映像が出力されるDOM
Spec.Renderer.xrViewTarget xr-view VR映像のWebGPU描画先

これらの情報を割り当て、必要な要素を描画させていきます。

const Spec = {
    Screen: {
        width: 990,
        height: 510,
    },
    Camera: {
        fov: 72,
        near: 0.1,
        far: 1000,
        x: 0,
        y: 1.6,
        z: 1.2,
    },
    Sphere: {
        radius: 400,
        widthSegments: 60,
        heightSegments: 40,
    },
    Controls: {
        enableZoom: false,
        enablePan: false,
        enableRotate: true,
        rotateSpeed: -0.2,
    },
    Renderer: {
        videoTarget: 'remote-video',
        xrViewTarget1: 'xr_view',
    }
}

では、ストリートビューもどきを作っていきます。


シーンの作成

全てのベースになる、シーンをまず作ります。

scene = new Scene()


カメラの作成

カメラを作り、シーンに追加します。
カメラと球体しかありませんので
nearとfarはかなり絞って大丈夫です。

camera = new PerspectiveCamera(
            Spec.Camera.fov,
            Spec.Screen.width / Spec.Screen.height,
            Spec.Camera.near,
            Spec.Camera.far
        )

// カメラの位置設定
camera.position.set(Spec.Camera.x, Spec.Camera.y, Spec.Camera.z)
scene.add(camera)
camera.lookAt(scene.position);


ビデオテクスチャの作成

ここが一番のキモです。
ビデオ映像をそのままテクスチャにすると、球全体に張り付く映像になるので
期待する表示とは大きく異なる表示になります。

求めている

  • 球体スクリーンの前面部分に映像が投影される

状態にするため

  • Canvas要素を作成
  • Canvas上にビデオ映像を縮小した状態で貼り付け
  • Canvasをソースにしてテクチャを作成

Canvas要素を1段挟み、必要なサイズにリサイズしてから貼り付けます。

理想は歪計算を行い、1フレームごとに変形させてから貼り付けるほうが理想ですが
ここでは映像ソースの縦横比のみ変更して貼り付けています。

// ビデオテクスチャの作成
videoTarget = document.getElementById(Spec.Renderer.videoTarget) as HTMLVideoElement
videoTarget.play()

// キャンバスの作成
const canvas = document.createElement('canvas');
canvas.width = Spec.Screen.width;
canvas.height = Spec.Screen.height;
const context = canvas.getContext('2d');
// アニメーションループでビデオをキャンバスに描画
function drawVideoToCanvas() {
    if (context && videoTarget) {
        context.fillStyle = 'green';
        context.fillRect(0, 0, canvas.width, canvas.height);
        context.drawImage(videoTarget, 1000, 200, Spec.Screen.width / 1.5, Spec.Screen.height / 1.5);
    }
    requestAnimationFrame(drawVideoToCanvas);
}
drawVideoToCanvas();

videoTexture = new Texture(canvas);
videoTexture.needsUpdate = true


球体の作成

球体を作成し、ビデオテクスチャの貼り付けと、シーンへの追加をします。
分割数が多いと滑らかになりますが、今回の場合ほぼ意味無いので
数値は適当で大丈夫です。

// 球体ジオメトリとマテリアルの作成
const geometry = new SphereGeometry(Spec.Sphere.radius, Spec.Sphere.widthSegments, Spec.Sphere.heightSegments)
geometry.scale(-1, 1, 1); // 内側に描画するためにスケールを反転
const material = new MeshBasicMaterial({ map: videoTexture })

// メッシュの作成とシーンへの追加
const sphere = new Mesh(geometry, material)
scene.add(sphere)


レンダラーの作成

最後にレンダラーを作成し、出力結果をDOMの子要素に追加します。

renderer = new WebGLRenderer({antialias: true})
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(Spec.Screen.width * 0.48, Spec.Screen.height * 0.8)

xrViewTarget = document.getElementById(Spec.Renderer.xrViewTarget) as HTMLElement
xrViewTarget.appendChild(renderer.domElement)


コントロールの作成

カメラの角度調整のみを有効とする。
のは設定通りですが、通常の操作範囲では
真上、真下、を見ることは無いはずなので
上下方向の移動に制限をかけています。

デフォルトでマウス操作によるカメラ操作が有効になります。

const controls = new OrbitControls(camera, renderer1.domElement)
controls.enableZoom = Spec.Controls.enableZoom
controls.enablePan = Spec.Controls.enablePan
controls.enableRotate = Spec.Controls.enableRotate
controls.rotateSpeed = Spec.Controls.rotateSpeed
controls.maxPolarAngle = Math.PI * 0.6
controls.minPolarAngle = Math.PI * 0.4


描画ループ開始

ビデオテクスチャを使用している場合は
「videoTexture.needsUpdate = true」このオプションが必須になるので注意して下さい

const animate = () => {
    if (!scene || !camera || !renderer1 || !renderer2 || !videoTexture) return
    animateId = requestAnimationFrame(animate)
    videoTexture.needsUpdate = true;
    renderer1.render(scene, camera)
    renderer2.render(scene, camera)
}

animate()

以上を組み合わせたコードを「panorama.service.ts」で保存します

panorama.service.ts
'use client'
import {
    Scene, PerspectiveCamera, WebGLRenderer,
    Mesh, MeshBasicMaterial, Color,
    SphereGeometry, VideoTexture,
    Texture, MathUtils
} from "three" 

import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'

import { KalmanFilter } from './filter.helper'


const Spec = {
    Screen: {
        width: 990,
        height: 510,
    },
    Camera: {
        fov: 72,
        near: 0.1,
        far: 1000,
        x: 0,
        y: 1.6,
        z: 1.2,
    },
    Sphere: {
        radius: 400,
        widthSegments: 60,
        heightSegments: 40,
    },
    Controls: {
        enableZoom: false,
        enablePan: false,
        enableRotate: true,
        rotateSpeed: -0.2,
    },
    Renderer: {
        videoTarget: 'remote-video',
        xrViewTarget1: 'xr_view1',
        xrViewTarget2: 'xr_view2',
    }
}

type SpecType = typeof Spec

/**
 * 
 * @param spec Partial<SpecType["Screen"]>
 * @returns
 * 
 * @argument width: number  // スクリーンの幅
 * @argument height: number // スクリーンの高さ
 */
export const setScreenSize = (spec: Partial<SpecType["Screen"]>) => {
    Object.assign(Spec.Screen, spec)
}

/**
 * 
 * @param spec Partial<SpecType["Camera"]>
 * @returns
 * 
 * @argument fov: number  // 視野角
 * @argument near: number // カメラの最近距離
 * @argument far: number  // カメラの最遠距離
 * @argument x: number    // カメラのx座標
 * @argument y: number    // カメラのy座標
 * @argument z: number    // カメラのz座標
 */
export const setCameraSpec = (spec: Partial<SpecType["Camera"]>) => {
    Object.assign(Spec.Camera, spec)
}

/**
 * 
 * @param spec Partial<SpecType["Sphere"]>
 * @returns
 * 
 * @argument radius: number        // 球体の半径
 * @argument widthSegments: number // 球体の横分割数
 * @argument heightSegments: number // 球体の縦分割数
 */
export const setCameraPosition = (spec: Partial<SpecType["Camera"]>) => {
    Object.assign(Spec.Camera, spec)
}

/**
 * 
 * @param spec Partial<SpecType["Sphere"]>
 * @returns
 * 
 * @argument radius: number        // 球体の半径
 * @argument widthSegments: number // 球体の横分割数
 * @argument heightSegments: number // 球体の縦分割数
 */
export const setSphereSpec = (spec: Partial<SpecType["Sphere"]>) => {
    Object.assign(Spec.Sphere, spec)
}

/**
 * 
 * @param spec Partial<SpecType["Controls"]>
 * @returns
 * 
 * @argument enableZoom: boolean   // ズームを有効にするか
 * @argument enablePan: boolean    // パンを有効にするか
 * @argument enableRotate: boolean // 回転を有効にするか
 * @argument rotateSpeed: number   // 回転速度
 */
export const setControlsSpec = (spec: Partial<SpecType["Controls"]>) => {
    Object.assign(Spec.Controls, spec)
}

/**
 * 
 * @param spec Partial<SpecType["Renderer"]>
 * @returns
 * 
 * @argument clearColor: number    // 背景色
 * @argument videoTarget: string   // ビデオターゲットのID
 * @argument xrViewTarget: string  // XRビューターゲットのID
 */
export const setRendererSpec = (spec: Partial<SpecType["Renderer"]>) => {
    Object.assign(Spec.Renderer, spec)
}

// カメラの角度を変更する関数
export const setCameraAngle = (
    lookAtX: number,
    lookAtY: number,
    lookAtZ: number
) => {
    if (!camera) return

    // 加速度を積分して速度を計算
    velocity.x += lookAtX * 0.01; // 0.01はサンプリング間隔の例
    velocity.y += lookAtY * 0.01;
    velocity.z += lookAtZ * 0.01;

    // 加速度を積分して速度を計算
    velocity.x += filteredX * 0.01; // 0.01はサンプリング間隔の例
    velocity.y += filteredY * 0.01;
    velocity.z += filteredZ * 0.01;

    // 速度を減衰させる
    velocity.x *= dampingFactor;
    velocity.y *= dampingFactor;
    velocity.z *= dampingFactor;
    

    // 速度を積分して位置を計算し、カメラの回転を更新
    cameraRotation.x += MathUtils.degToRad(velocity.x);
    cameraRotation.y -= MathUtils.degToRad(velocity.y);
    cameraRotation.z += MathUtils.degToRad(velocity.z);

    camera.rotation.x = cameraRotation.x;
    camera.rotation.y = cameraRotation.y;
    camera.rotation.z = cameraRotation.z;
}



let scene: Scene | null = null
let camera: PerspectiveCamera | null = null
let renderer1: WebGLRenderer | null = null
let renderer2: WebGLRenderer | null = null
let videoTexture: Texture | null = null

let videoTarget: HTMLVideoElement | null = null
let xrViewTarget1: HTMLElement | null = null
let xrViewTarget2: HTMLElement | null = null

let animateId: number | null = null

// カメラの回転を保持する変数
let cameraRotation = { x: 0, y: 0, z: 0 };
let velocity = { x: 0, y: 0, z: 0 };

// 減衰係数
const dampingFactor = 0.6;
const decay = 0.8;

/**
 * 初期化関数
 */
export const init = () => {
    // シーン、カメラ、レンダラーのセットアップ
    scene = new Scene()
    camera = new PerspectiveCamera(
                Spec.Camera.fov,
                Spec.Screen.width / Spec.Screen.height,
                Spec.Camera.near,
                Spec.Camera.far
            )

    // カメラの位置設定
    camera.position.set(Spec.Camera.x, Spec.Camera.y, Spec.Camera.z)
    scene.add(camera)
    camera.lookAt(scene.position);

    // ビデオテクスチャの作成
    videoTarget = document.getElementById(Spec.Renderer.videoTarget) as HTMLVideoElement
    videoTarget.play()

    // キャンバスの作成
    const canvas = document.createElement('canvas');
    canvas.width = Spec.Screen.width;
    canvas.height = Spec.Screen.height;
    const context = canvas.getContext('2d');

    // アニメーションループでビデオをキャンバスに描画
    function drawVideoToCanvas() {
        if (context && videoTarget) {
            context.fillStyle = 'green';
            context.fillRect(0, 0, canvas.width, canvas.height);
            context.drawImage(videoTarget, 1000, 200, Spec.Screen.width / 1.5, Spec.Screen.height / 1.5);
        }
        requestAnimationFrame(drawVideoToCanvas);
    }
    drawVideoToCanvas();

    videoTexture = new Texture(canvas);
    videoTexture.needsUpdate = true

    // 球体ジオメトリとマテリアルの作成
    const geometry = new SphereGeometry(Spec.Sphere.radius, Spec.Sphere.widthSegments, Spec.Sphere.heightSegments)
    geometry.scale(-1, 1, 1); // 内側に描画するためにスケールを反転
    const material = new MeshBasicMaterial({ map: videoTexture })

    // メッシュの作成とシーンへの追加
    const sphere = new Mesh(geometry, material)
    scene.add(sphere)

    renderer1 = new WebGLRenderer({antialias: true})
    renderer1.setPixelRatio(window.devicePixelRatio)
    renderer1.setSize(Spec.Screen.width * 0.48, Spec.Screen.height * 0.8)

    renderer2 = new WebGLRenderer({antialias: true})
    renderer2.setPixelRatio(window.devicePixelRatio)
    renderer2.setSize(Spec.Screen.width * 0.48, Spec.Screen.height * 0.8)

    xrViewTarget1 = document.getElementById(Spec.Renderer.xrViewTarget1) as HTMLElement
    xrViewTarget2 = document.getElementById(Spec.Renderer.xrViewTarget2) as HTMLElement
    xrViewTarget1.appendChild(renderer1.domElement)
    xrViewTarget2.appendChild(renderer2.domElement)

    // OrbitControlsの設定
    const controls = new OrbitControls(camera, renderer1.domElement)
    controls.enableZoom = Spec.Controls.enableZoom
    controls.enablePan = Spec.Controls.enablePan
    controls.enableRotate = Spec.Controls.enableRotate
    controls.rotateSpeed = Spec.Controls.rotateSpeed
    controls.maxPolarAngle = Math.PI * 0.6
    controls.minPolarAngle = Math.PI * 0.4

    // アニメーションループ
    const animate = () => {
        if (!scene || !camera || !renderer1 || !renderer2 || !videoTexture) return
        animateId = requestAnimationFrame(animate)
        videoTexture.needsUpdate = true;
        renderer1.render(scene, camera)
        renderer2.render(scene, camera)
    }

    animate()
}

export const stopAnimation = () => {
    scene = null
    camera = null
    renderer1 = null
    renderer2 = null
    videoTarget = null
    xrViewTarget1 = null
    xrViewTarget2 = null
    cancelAnimationFrame(animateId!)
}


カメラ

某中華サイトにて3000円ほどで購入可能な
魚眼タイプのUSBカメラを使用します。

互換問題等があるかもと思いましたが
IOT関係はRaspberryPi、Arduino互換品が当たり前になっているので
行けるんじゃないかと試してみたら、問題ありませんでした。

差し替えるだけでそのまま配信に使えます。


VRモード呼び出し機能

先にWebRTCの映像を再生するvideoタグに「hidden」を忘れずに付けておいて下さい。
デザインによりますが、2重に何かが表示されます。

画面のなにかしたのボタンに紐づける関数を最後に作っておきます。
クロスしていませんが、クロスリアリティの略「XR」関数です。

import {
    init,
    setCameraAngle,
    setCameraPosition,
    setCameraSpec,
    setControlsSpec,
    setRendererSpec,
    setScreenSize,
    setSphereSpec,
    stopAnimation
} from './panorama.service'

export const XR = () => {

    setScreenSize({
        width: 1980,
        height: 1020
    })
    setCameraSpec({
        fov: 72,
        near: 0.1,
        far: 500
    })
    setCameraPosition({
        x: -0.1,
        y: 0.6,
        z: 5
    })
    setRendererSpec({
        videoTarget: 'video',
        xrViewTarget: 'xr_view',
    })
    setControlsSpec({
        enablePan: false,
        enableRotate: true,
        enableZoom: false,
        rotateSpeed: -0.2
    })
    setSphereSpec({
        radius: 400,
        widthSegments: 80,
        heightSegments: 60,
    })

    init()
}

export const stop = () => {
    stopAnimation()
}


試験

WebRTCのストリーム開始後に、XR関数を呼び出すと
こんな感じで、グリグリ動かせる映像がでてくるはずです。




UZAYA

Uzayaでは、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?