LoginSignup
285
196

Three.jsで新宿駅構内図を3Dで可視化してみる

Last updated at Posted at 2023-12-24

これは MIERUNE AdventCalendar 2023 24日目の記事です! 昨日は@northprintさんによるSvelteKitでURLクエリパラメーターの操作をするでした。

はじめに

この記事では新宿駅の屋内地図データを使用して、Three.jsで3Dによる可視化をします。

image.png

DEMOはこちら

サンプルコードはこちら

使用するデータ

今回は、G空間情報センターで公開されている「新宿駅屋内地図オープンデータ」の統合版(ShapeFile)を使用します。

image.png

データについての詳細は製品仕様書に記載されています。

この記事のように、データの加工利用には以下の出典が必要となります。

コンテンツを編集・加工等して利用する場合は、上記出典とは別に、編集・加工等を行ったことを記載してください。なお、編集・加工した情報を、あたかも国(又は府省等)が作成したかのような態様で公表・利用してはいけません。(コンテンツを編集・加工等して利用する場合の記載例)「新宿駅周辺屋内地図データ」(国土交通省)(https://www.geospatial.jp/ckan/dataset/mlit-indoor-shinjuku-r2)を加工して作成

統合版(ShapeFile)のデータからShinjukuTerminalディレクトリに入っている以下の地物データを使用します。

地物データ

ファイル名 データ名称 地物タイプ
ShinjukuTerminal_<階層名>_Floor.shp 階層データ Polygon
ShinjukuTerminal_<階層名>_Space.shp 物理的な空間データ Polygon
ShinjukuTerminal_<階層名>_Fixture.shp 固定設置物データ Polygon

QGISでの表示(※半透明にしてます)
image.png

また、nwディレクトリに入っているネットワークデータも使用します。

ネットワークデータ

ファイル名 データ名称 地物タイプ
Shinjuku_node.shp ノードデータ Point
Shinjuku_link.shp リンクデータ LineString

QGISでの表示
image.png

データ変換

これらのデータはすべてShapeFileとなっています。JavaScriptで扱いやすいように
GDALコマンドでShapeFileをGeoJsonに変換します。また、座標系はEPSG6677に変換します。

mkdir geojson

for f in *.shp; do
  ogr2ogr -f GeoJSON -t_srs EPSG:6677 "geojson/${f%.*}.geojson" $f
done

Three.jsで描画する

データの変換後にThree.js上で表示させます。

シーンの作成

シーンを作成し、カメラとコントロールを追加します。コントロールの種類はお好みで構いませんが、この記事では移動操作をMapControls、ズーム操作をTrackballControlsに割り当ててます。

npm install three
main.js
import * as THREE from 'three';
import { MapControls } from 'three/examples/jsm/controls/MapControls.js';
import { TrackballControls } from 'three/examples/jsm/controls/TrackballControls.js';

const sizes = {
    width: window.innerWidth,
    height: window.innerHeight,
};

// キャンバスの作成
const canvas = document.createElement('canvas');
document.body.appendChild(canvas);

// シーンの作成
const scene = new THREE.Scene();

// カメラ
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100000);
camera.position.set(-190, 280, -350);
scene.add(camera);

// コントロール
const mapControls = new MapControls(camera, canvas);
mapControls.enableDamping = true;
mapControls.enableZoom = false;
mapControls.maxDistance = 1000;

const zoomControls = new TrackballControls(camera, canvas);
zoomControls.noPan = true;
zoomControls.noRotate = true;
zoomControls.noZoom = false;
zoomControls.zoomSpeed = 0.5;

// レンダラー
const renderer = new THREE.WebGLRenderer({
    canvas: canvas,
    alpha: true,
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

// 画面リサイズ時にキャンバスもリサイズ
const onResize = () => {
    // サイズを取得
    const width = window.innerWidth;
    const height = window.innerHeight;

    // レンダラーのサイズを調整する
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(width, height);

    // カメラのアスペクト比を正す
    camera.aspect = width / height;
    camera.updateProjectionMatrix();
};
window.addEventListener('resize', onResize);

// アニメーション
const animate = () => {
    requestAnimationFrame(animate);

    const target = mapControls.target;
    mapControls.update();
    zoomControls.target.set(target.x, target.y, target.z);
    zoomControls.update();
    renderer.render(scene, camera);
};
animate();

また、WebGLRendererのオプションでalpha: trueにすることで背景を透過させ、CSSで背景にグラデーションを適用させます。

style.css
canvas {
    background-image: radial-gradient(#382c6e, #000000);
}

シーンができました。
image.png

GUIとグループの作成

シーンの中に地物データを描画させます。今回は地物を階層ごとにグループ分けしたいと思うので、シーンのにあらかじめGroupを作成しておき、地物データによって作成したオブジェクトを各階層ごとのグループに追加することにします。そして、各階層の表示切り替えができるようにチェックボックスのGUIも追加します。

main.js
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';

// dat.GUIのインスタンスを作成
const gui = new GUI({ width: 150 });

// グループの作成
const groupList = [4, 3, 2, 1, 0, -1, -2, -3];
const layers = ['4F', '3F', '2F', '1F', '0', 'B1', 'B2', 'B3'];

groupList.forEach((num, i) => {
    const group = new THREE.Group();
    group.name = `group${num}`;
    scene.add(group);
    const key = `group${num}`;

    // GUIにチェックボックスを追加
    gui.add(
        {
            [key]: true,
        },
        key,
    )
        .onChange((isVisible) => {
            scene.getObjectByName(key).visible = isVisible;
        })
        .name(layers[i]);
});

階層ごとのチェックボックス
image.png

地物データの追加

作成した階層グループごとに地物データを追加していきます。地物データは2次元のポリゴンデータなので、ExtrudeGeometryを使用して立ち上げることで立体化します。階層情報はファイル名にありますが、高さ情報はないため、地物データの種類で高さ分けをすることにします。

また、地物データは前述の変換処理によりEPSG:6677になっていますがThree.jsのワールド座標にそのままの座標値で乗っけてしまうと、原点0地点から遠くは慣れた場所に描画されてしまうので、地物にオフセットをかける必要があります。

まずは、シーンの原点0をEPSG:6677上のどの座標点にするかを決めます。今回は-12035.29, -34261.85(x,y)の地点をワールド座標の原点0に合わせます。この座標は新宿駅の中心あたりになります。

image.png

地物データのポリゴンの座標値からExtrudeGeometryを作成するときに、各座標点から中心とする点([-12035.29, -34261.85])を減算することでオフセットをかけます。これにより地物がシーンの原点0の近くに描画されます。

もう一つ注意点として、Three.js上(ワールド座標)のY、Zのベクトル方向とGIS上(地理座標)のY、Zのベクトル方向が90度違うため、ExtrudeGeometryを作成後にx軸を起点に前方向(Z軸を正面として)に90度倒す必要があります(そうしないと地物が縦向きになってしまいます)。

image.png

これらを踏まえた上でGeoJsonからExtrudeGeometryを作成します。

Space、Floor、Fixtureのデータを各配列に記述してループ処理をし、getFloorNumberという関数で階層情報を取得し、loadAndAddToSceneという関数で、引数にGeoJsonの情報と、階層情報、高さにする数値を渡します。

main.js
// Spaceの配列
const SpaceLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_1_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Space.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Space.geojson',
];

// Spaceの読み込み
SpaceLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Space');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 5);
    }
});


// Spaceの配列
const FloorLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_1_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Floor.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Floor.geojson',
];

// Floorの読み込み
FloorLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Floor');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 0.5);
    }
});

// Fixtureの配列
const FixtureLists = [
    './ShinjukuTerminal/ShinjukuTerminal_B3_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B2_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_B1_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_0_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_2out_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_3out_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4_Fixture.geojson',
    './ShinjukuTerminal/ShinjukuTerminal_4out_Fixture.geojson',
];

// Fixtureの読み込み
FixtureLists.forEach((geojson) => {
    const floorNumber = getFloorNumber(geojson, 'Fixture');
    if (floorNumber !== null) {
        loadAndAddToScene(geojson, floorNumber, 5);
    }
});

getFloorNumber関数は以下になります。正規表現を使ってファイル名の数字の部分を抜き出し、地下である「B」が含まれている場合は負の数を返すようにしています。

main.js
// 正規表現を用いて階層番号を取得
const getFloorNumber = (geojson, type) => {
    const regex = new RegExp(`ShinjukuTerminal_([-B\\d]+)(out)?_${type}`);
    const match = geojson.match(regex);
    if (!match) return null;

    let floor = match[1].replace('B', '-');
    return parseInt(match[2] === 'out' ? floor.replace('out', '') : floor, 10);
};

loadAndAddToScene関数は以下になります。FileLoaderを使用してGeoJsonを読み込み、createExtrudedGeometryという関数でExtrudeGeometryを生成し、ExtrudeGeometryからEdgesGeometryを作成することでアウトラインの立方体ポリゴンを作成します。このとき、立方体ポリゴンは縦向きになっているのでapplyMatrix4で90度回転させています。
そして、階層情報ごとにY軸のどのあたりに地物を置くかを決め、各階層のGroupに追加しています。

main.js

// 階ごとに離すY軸方向の距離
const verticalOffset = 30;

// FileLoaderをインスタンス化。JSON形式でデータを取得する
const loader = new THREE.FileLoader().setResponseType('json');

// ファイルを読み込んで、シーンに追加。geometryの情報がないものは除外
const loadAndAddToScene = (geojson, floorNumber, depth) => {
    loader.load(geojson, (data) => {
        // Lineのマテリアル
        const lineMaterial = new THREE.LineBasicMaterial({ color: 'rgb(255, 255, 255)' });

        // geometryの情報がないものは除外
        data.features
            .filter((feature) => feature.geometry)
            .forEach((feature) => {
                // ExtrudeGeometryを作成
                const geometry = createExtrudedGeometry(feature.geometry.coordinates, depth);

                // 90度回転させる
                const matrix = new THREE.Matrix4().makeRotationX(Math.PI / -2);
                geometry.applyMatrix4(matrix);

                // ExtrudeGeometryからLineを作成
                const edges = new THREE.EdgesGeometry(geometry);
                const line = new THREE.LineSegments(edges, lineMaterial);
                line.position.y += floorNumber * verticalOffset - 1;

                // Groupに追加
                const group = scene.getObjectByName(`group${floorNumber}`);
                group.add(line);
            });
    });
};

createExtrudedGeometryは以下になります。前述に説明した通り、ここでGeoJsonのcoordinates(座標点)からExtrudedGeometryの元となるShapeGeometryを作成しています。各座標点はあらかじめ中心にする地理座標を減算しています。
また、地理空間データのポリゴンデータはポリゴンの閉じるための頂点が必要なので、頂点配列の最初と最後に同じ頂点座標を持っています(四角形のポリゴンは5つの頂点を持っている)。ShapeGeometryはこの最後の頂点は不要なので、最後の頂点は処理を飛ばしています。ExtrudeGeometryのオプションのdepthは押し出す高さを示してます。

main.js
// シーンの中心にする地理座標(EPSG:6677)
const center = [-12035.29, -34261.85];

// ポリゴンからExtrudeGeometryを返す関数
const createExtrudedGeometry = (coordinates, depth) => {
    const shape = new THREE.Shape();

    // ポリゴンの座標からShapeを作成
    coordinates[0].forEach((point, index) => {
        const [x, y] = point.map((coord, idx) => coord - center[idx]);
        if (index === 0) {
            // 最初の点のみmoveTo
            shape.moveTo(x, y);
        } else if (index + 1 === coordinates[0].length) {
            // 最後の点のみclosePathで閉じる
            shape.closePath();
        } else {
            // それ以外はlineTo
            shape.lineTo(x, y);
        }
    });
    return new THREE.ExtrudeGeometry(shape, {
        steps: 1,
        depth: depth,
        bevelEnabled: false,
    });
};

以上のような処理を記述することで、地物データがシーンに追加されました。要塞みたいですね。
image.png

ネットワークデータの追加

続いて、ネットワークデータ(歩行者ネットワーク)をシーンに追加します。前述で変換したノードデータのGeoJsonを先ほどインスタンス化したFileLoaderで読み込み、nodeIdordinal(階層情報)を取得して配列を作成し、creatingLinkという関数に渡します。

main.js
// ノードデータからnode_idと階層(ordinal)を取得
loader.load('./nw/Shinjuku_node.geojson', (data) => {
    const nodeIds = data.features.map((feature) => {
        return {
            node_id: feature.properties.node_id,
            ordinal: feature.properties.ordinal,
        };
    });

    // 歩行者ネットワークの作成
    creatingLink(nodeIds);
});

creatingLink関数は下記になります。リンクデータを読み込んで先ほど作成した配列から、リンクデータの始点と終点の階層を取得します。これはリンクデータに階層情報が含まれていないためです(事前にQGISのテーブル結合で階層情報を付与するのも良いかもしれません)。これで、リンクデータの始点と終点の階層はわかりますが、その途中のラインの階層がどこに当てはまるかがわかりません。そこで、条件分岐を用意して始点のノードデータしか見つからない場合は始点の階高さ、終点のノードデータしか見つからない場合は終点の階の高さにリンクデータのラインを作成します。もし始点と終点両方のノードデータが見つかる場合は、始点と終点の階が同じの場合は、その階にしかリンクデータのラインが存在しないことがわかりますが、始点と終点の階が違う場合は、その中間の階層にラインを仮で引くようにしました。

また、リンクデータのラインは幅のあるラインを描画できるMeshLineを使用します。コードの後半で、ラインを2点間の頂点を持つラインにわざわざ分割して、BufferGeometryUtilsで、マージしてからシーンに追加していますが、その理由はこの後説明します。

npm install three.meshline
main.js
import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import { MeshLine, MeshLineMaterial } from 'three.meshline';

// マテリアル
const linkMaterial = new MeshLineMaterial({
    transparent: true,
    lineWidth: 1,
    color: new THREE.Color('rgb(0, 255, 255)'),
});

// MeshLineの配列
const meshLines = [];

// 歩行者ネットワークの作成
const creatingLink = (nodeId) => {
    loader.load('./nw/Shinjuku_link.geojson', (data) => {
        data.features.forEach((feature) => {
            const coordinates = feature.geometry.coordinates;

            // ノードデータからstart_idとend_idの取得
            const start_id = nodeId.find((node) => node.node_id === feature.properties.start_id);
            const end_id = nodeId.find((node) => node.node_id === feature.properties.end_id);

            // 3次元のpointの配列を作成
            const points = coordinates.map((point, index) => {
                let y;

                if (!start_id && !end_id) {
                    // start_idとend_idがない場合は、0階層に配置
                    y = 0;
                } else if (start_id && !end_id) {
                    // start_idのみある場合は、start_idの階層に配置
                    y = start_id.ordinal;
                } else if (!start_id && end_id) {
                    // end_idのみある場合は、end_idの階層に配置
                    y = end_id.ordinal;
                } else {
                    // start_idとend_idがある場合
                    if (index === 0) {
                        // 最初の点の場合はstart_idの階層に配置
                        y = start_id.ordinal;
                    } else if (index === coordinates.length - 1) {
                        // 最後の点の場合はend_idの階層に配置
                        y = end_id.ordinal;
                    } else if (start_id.ordinal === end_id.ordinal) {
                        // start_idとend_idの階層が同じ場合は、その階層に配置
                        y = end_id.ordinal;
                    } else {
                        // start_idとend_idの階層が異なる場合は、中間の階層に配置
                        y = Math.round((start_id.ordinal + end_id.ordinal) / 2);
                    }
                }
                return new THREE.Vector3(point[0] - center[0], y * verticalOffset + 1, -(point[1] - center[1]));
            });

            // pointの配列からMeshLineを作成
            points.forEach((point, index) => {
                // 最後の点の場合は処理を終了
                if (index + 1 === points.length) return;

                // MeshLineを作成。2点間のMeshLineを別々に作成する
                const geometry = new THREE.BufferGeometry().setFromPoints([point, points[index + 1]]);
                const line = new MeshLine();
                line.setGeometry(geometry);

                // MeshLineの配列に追加
                const mesh = new THREE.Mesh(line, linkMaterial);
                meshLines.push(mesh.geometry);
            });
        });

        // MeshLineをマージ
        const linkGeometry = new THREE.Mesh(BufferGeometryUtils.mergeGeometries(meshLines), linkMaterial);
        linkGeometry.name = 'link';

        // シーンに追加
        scene.add(linkGeometry);
    });
};

歩行者ネットワークをうまく3次元化できました。

image.png

ですが、このままでは面白くないので、歩行者ネットワーク感?を表現するためにシェーダーでアニメーションをつけることにします。

シェーダーを書く

リンクデータは製品仕様書によると進行方向を示す情報(direction)をもっているので、これに合わせて違うアニメーションを適用させることにします。

image.png

今回使用したデータは両方向(1)と起点より終点方向(2)の2パターンしかなかったので2パターンの簡易的なシェーダーを用意しました。

「両方向」のシェーダー

Kapture 2023-12-24 at 10.13.46.gif

「起点より終点方向」のシェーダー

Kapture 2023-12-24 at 10.19.21.gif

MeshLineにシェーダーを適用させます。まず、先ほど定義したMeshLineMaterialからカラー情報を削除します。

main.js
// マテリアル
const linkMaterial = new MeshLineMaterial({
    transparent: true,
    lineWidth: 1,
-   color: new THREE.Color('rgb(0, 255, 255)'),
});

そして、MeshLineの既存のシェーダーを上書きするためにonBeforeCompileを使用してシェーダーを追記していきます。

こちらの記事を参考にさせていただきました。

main.js

// Shaderを追加
linkMaterial.onBeforeCompile = (shader) => {
    // userDataにuniformsを追加
    Object.assign(shader.uniforms, linkMaterial.userData.uniforms);

    const keyword1 = 'void main() {';
    shader.vertexShader = shader.vertexShader.replace(
        keyword1,
        /* GLSL */ `
        varying vec2 vUv;
        attribute float uDistance;
        attribute float uDirection;
        varying float vDistance;
        varying float vDirection;
        ${keyword1}`,
    );

    // 置換してシェーダーに追記する
    const keyword2 = 'vUV = uv;';
    shader.vertexShader = shader.vertexShader.replace(
        keyword2,
        /* GLSL */ `
        ${keyword2}
        vUv = uv;
        vDistance = uDistance;
        vDirection = uDirection;
        `,
    );

    const keyword3 = 'void main() {';
    shader.fragmentShader = shader.fragmentShader.replace(
        keyword3,
        /* GLSL */ `
        uniform float uTime;
        varying float vDirection;
        varying float vDistance;
        varying vec2 vUv;
        ${keyword3}`,
    );
    // 置換してシェーダーに追記する
    const keyword4 = 'gl_FragColor.a *= step(vCounters, visibility);';
    shader.fragmentShader = shader.fragmentShader.replace(
        keyword4,
        /* GLSL */ `${keyword4}
        vec2 p;
        p.x = vUv.x * vDistance;
        p.y = vUv.y * 1.0 - 0.5;

        float centerDistY = p.y; // 中心からのY距離
        float offset = abs(centerDistY) * 0.5; // 斜めの角度を制御

        float time = uTime;
        // 中心より上と下で斜めの方向を変える
        if(centerDistY < 0.0) {
            if(vDirection == 1.0){
                time = -uTime;
                offset = -offset;
            }else if(vDirection == 2.0) {
                offset = offset;
            }
        }

        // mod関数と中心からのy距離に基づくオフセットを使用して線を生成
        float line = mod(p.x - time + offset, 1.9) < 0.9 ? 1.0 : 0.0;
        vec3 mainColor;

        // 方向によって色を変える
        if(vDirection == 1.0) {
            mainColor = vec3(0.0, 1.0, 1.0);
        } else if(vDirection == 2.0) {
            mainColor = vec3(1.0, 1.0, 0.0);
        }
        vec3 color = mix(mainColor, mainColor, line);

        gl_FragColor = vec4(color, line * 0.7);
        `,
    );
};

そしてcreatingLink関数に追記します。各MeshLineのUV座標のアスペクト比を合わせるためにdistanceToで分割したラインの2点間の距離を取得してuDirectionというattribute変数をシェーダーに渡します。さらにリンクデータの方向の情報もuDirectionというattribute変数でシェーダーに渡します。アニメーションをさせるためにuTimeというuniforms変数を追加してます。

main.js

// メッシュラインの配列
const meshLines = [];

// 歩行者ネットワークの作成
const creatingLink = (nodeId) => {

    loader.load('./nw/Shinjuku_link.geojson', (data) => {
        data.features.forEach((feature) => {

        /* 省略 */

            // pointの配列からMeshLineを作成
            points.forEach((point, index) => {
                // 最後の点の場合は処理を終了
                if (index + 1 === points.length) return;

                // MeshLineを作成。2点間のMeshLineを別々に作成する
                const geometry = new THREE.BufferGeometry().setFromPoints([point, points[index + 1]]);
                const line = new MeshLine();
                line.setGeometry(geometry);

+               // 2点間の距離を計算
+               const distance = point.distanceTo(points[index + 1]);
+
+               // MeshLineの頂点数を取得
+               const numVerticesAfter = line.geometry.getAttribute('position').count;
+
+               // 頂点数に基づいて distances 配列を生成しsetAttributeで頂点属性を追加。UV座標のアスペクト比の計算に使用
+               const distances = new Float32Array(numVerticesAfter).fill(distance);
+               line.setAttribute('uDistance', new THREE.BufferAttribute(distances, 1));
+
+               // 頂点数に基づいて directions 配列を生成しsetAttributeで頂点属性を追加。リンクデータの方向を表す
+               const directions = new Float32Array(numVerticesAfter).fill(feature.properties.direction);
+               line.setAttribute('uDirection', new THREE.BufferAttribute(directions, 1));
+                // uniforms変数にuTime(時間)を追加。アニメーションに使用
+               Object.assign(linkMaterial.userData, {
+                   uniforms: {
+                       uTime: { value: 0 },
+                   },
+               });

                // MeshLineの配列に追加
                const mesh = new THREE.Mesh(line, linkMaterial);
                meshLines.push(mesh.geometry);
            });
        });

/* 省略 */

あとはanimate関数内でuTimeに加算する処理を記述します。

main.js
// アニメーション
const animate = () => {
    requestAnimationFrame(animate);
    /* 省略 */

+   // 歩行者ネットワークのアニメーション
+   if (linkMaterial.uniforms.uTime) {
+       linkMaterial.uniforms.uTime.value += 0.1;
+   }

    renderer.render(scene, camera);
};
animate();

動きのある歩行者ネットワークができました。
Kapture 2023-12-24 at 10.38.23.gif

地上の可視化

仕上げに地上の部分も可視化します。基盤地図情報の基本項目から新宿駅が含まれている「533945」のメッシュを選択し、地物データをダウンロードします。

image.png

ダウンロードした地物データから「道路縁(Road Edge)」と「道路構成線(Road Component)」をGDALコマンドでGeoJsonに変換し、座標系をEPSG:6677に変換します。

ogr2ogr -f GeoJSON -t_srs EPSG:6677 FG-GML-533945-RdEdg-20231001-0001.geojson FG-GML-533945-RdEdg-20231001-0001.xml

ogr2ogr -f GeoJSON -t_srs EPSG:6677 FG-GML-533945-RdCompt-20231001-0001.geojson FG-GML-533945-RdCompt-20231001-0001.xml

変換後にQGISに読み込んで加工します。プロセシングツールで「ベクタレイヤをマージ」で二つのデータをマージし、「バッファ」で中心点([-12035.29, -34261.85])から円ポリゴンを作成します。そのあと「切り抜く」でマージした道路データに対して円ポリゴンをオーバレイレイヤとして切り抜き処理をすることで、円形の道路ラインデータを作成します。

image.png

この加工したデータをThree.jsで読み込み、LINEで生成してシーンに追加することで完成となります。

main.js
// 基盤地図情報道路データの読み込み;
loader.load('./road.geojson', (data) => {
    const material = new THREE.LineBasicMaterial({
        color: new THREE.Color('rgb(209, 102, 255)'),
    });
    data.features.forEach((feature) => {
        const coordinates = feature.geometry.coordinates;
        const points = coordinates[0].map((point) => {
            return new THREE.Vector3(point[0] - center[0], point[1] - center[1], 0);
        });

        const geometry = new THREE.BufferGeometry().setFromPoints(points);
        const matrix = new THREE.Matrix4().makeRotationX(Math.PI / -2);
        geometry.applyMatrix4(matrix);

        const line = new THREE.Line(geometry, material);
        scene.getObjectByName(`group0`).add(line);
    });
});

image.png

地上面を追加したことにより、歩行者ネットワークがより見やすくなったかと思います。
image.png

おわりに

今回は2次元のGISデータを無理やり3次元化したものなので、高さ情報の正確性がないのが難点でした。正確性をもとめた3次元の可視化はPLATEAUのような3次元モデルのデータの使用を推奨します。

G空間情報センターの「3D都市モデル(Project PLATEAU)新宿区(2023年度)」のデータの中に地下街モデルが公開されているようです。

明日は@Kanahiroさんによる記事です!お楽しみに!!

285
196
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
285
196