
こんにちは。 aptpod Advent Calendar 2023 12月21日を担当するVisual M2Mグループの白金です。
普段は Visual M2M Data Visualizer (以下 Data Visualizer )の製品開発を担当しています。
当製品においてLiDARで計測した3D点群を可視化するための開発をする機会がありました。3D点群には、ひとつひとつの点について、X、Y、Zの位置情報を含みます。また、LiDARで計測する環境によって、1回の描画で数千個以上の点の情報を含む場合があり、可視化アプリケーションではパフォーマンスを意識し、効率よく可視化する処理が要求されます。
また、3D点群の表示の一例として、原点から距離に応じて色が変化する可視化の方法があります。各点ごとに一つ一つ3Dモデルを作成し色を指定する方法でも実現できますが、点の数に比例してCPUの計算コストが肥大化し、パフォーマンスが低下する課題があります。
そこで、上記課題を解決するため、GPUで計算するGLSLを使用して可視化する方法についてサンプルコードも交えてご紹介します。

GLSLとは
3Dや2Dを描画するために、GPUを直接操作するためのシェーダー言語です。 three.js 標準のGLSLも利用可能ですが、GLSLをカスタマイズしたい場合は、目的に応じてGLSLの処理を追加する必要があります。
WebGLで3Dを表現するためには、頂点シェーダー、フラグメントシェーダーの2つのシェーダーを使用します。 この2つのシェーダーをGLSLで表現します。
詳しくは下記リンク先に掲載されています。ここではピックアップした情報のみご紹介します。
頂点シェーダー
頂点ごとの空間座標を生成します。入力データとして、3Dモデルの頂点データを受け取ります。主に3Dモデルや、カメラの位置・姿勢情報から、頂点の空間座標を生成する場合に使用します。
頂点シェーダーを拡張することで、目的に応じて、3Dモデルの頂点データから生成する空間座標をカスタマイズすることができます。 当ページのサンプルでは、3D点群を1つの3Dモデルとして受け取り、各頂点ごとに原点からの距離に応じて色を設定する方法をご紹介します。
フラグメントシェーダー
描画対象となるピクセルに対して色を決定するために呼び出されるシェーダーです。 また、頂点データで計算したデータを受け取ることができます。
当ページのサンプルでは、各点を丸の形状で表現するため、フラグメントシェーダーをカスタマイズしています。
その他の使用するフレームワーク
GLSLを除く、使用している他のフレームワークは下記のとおりです。 必要最低限の環境で動作を可能にするためのフレームワークとして使用しました。
Viteのワークスペースを作成する機能を使用します。 当フレームワークを使用することで、最低限のサンプルコードで可視化を実現するための準備が可能です。
Webアプリケーションで3Dを描画するフレームワークです。 WebGLのAPIを直接利用することも可能ですが、three.js を利用することで開発コストかなり削減できます。
three.jsをReactのコンポーネントで使いやすくするためのフレームワークです。 Reactのコンポーネント内で、three.jsを利用する場合において、可読性の向上、コードの削減化が期待できます。
@react-three/fiber用の便利なヘルパーが用意されています。 当ページではOrbitControlsや、GizmoHelper を使用することで、カメラ操作、ビューヘルパーの可視化を少ないコードで実現しています。
では、サンプルコードの準備を進めていきましょう。
ワークスペースを作成する
Viteの公式サイトを参考に、サンプルコード用のワークスペースを作成します。
npm create vite@latest --template react-ts
作成したワークスペースに移動し、依存パッケージをインストールします。
npm install
さらに、3Dを可視化するために必要なパッケージをインストールします。
npm install three @react-three/fiber @react-three/drei
ソースコードを追加・編集する
GLSLを活用し、同心円の点群を一定の距離ごとに色を変えて3D点群を可視化するサンプルコードを準備していきます。
まずは、src/App.tsxを以下のように書き換えます。createCirclePointsのFunctionで同心円上の点群のサンプルデータを作成し、距離に応じて高さの表示位置を変えています。
import { useMemo } from 'react'
import { Vector3 } from 'three'
import { Canvas } from '@react-three/fiber'
import { OrbitControls, GizmoHelper, GizmoViewport } from '@react-three/drei'
import './App.css'
import { vertexShader } from './vertex-shader'
import { fragmentShader } from './fragment-shader'
function App() {
/**
* 3D点群のPoint配列をFloat32Arrayで作成します。
* 1つの点は、X、Y、Zの3つの配列要素で構成されます。
*/
const positions = useMemo(() => new Float32Array([
...createCirclePoints({ radius: 0.2, height: 0, divisions: 100 }),
...createCirclePoints({ radius: 0.5, height: 0.1, divisions: 100 }),
...createCirclePoints({ radius: 1.0, height: 0.2, divisions: 100 }),
...createCirclePoints({ radius: 1.5, height: 0.3, divisions: 100 }),
...createCirclePoints({ radius: 2.0, height: 0.4, divisions: 100 }),
...createCirclePoints({ radius: 2.5, height: 0.5, divisions: 100 }),
]), [])
/**
* 頂点シェーダーへ連携する値を設定します。
*/
const uniforms = useMemo(() => {
return {
/* 距離に応じた色を判定する際に使用する最大値を設定します。 */
uDistanceRangeMax: { value: 3 },
/* 点の大きさを設定します。 */
uPointSize: { value: 5 },
}
}, [])
/**
* ページ表示直後のカメラの位置を設定します。
*/
const cameraInitPosition = useMemo(() => {
return new Vector3(0, 2.5, 5)
}, [])
return (
<div className="canvas-area">
<Canvas camera={{ position: cameraInitPosition }}>
{/* カメラを操作するためのフレームワーク使用します。 */}
<OrbitControls makeDefault enableDamping enablePan enableRotate />
{/* グリッドを表示します。 */}
<gridHelper />
{/* 3D点群を表示します。 */}
<points>
<bufferGeometry attach="geometry">
<bufferAttribute
attach="attributes-position"
array={positions}
itemSize={3}
count={positions.length / 3}
/>
</bufferGeometry>
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={uniforms}
/>
</points>
{/* 画面右上にGizmoを表示します。 */}
<GizmoHelper alignment="top-right">
<GizmoViewport/>
</GizmoHelper>
</Canvas>
</div>
)
}
/**
* 水平方向に同心円の点郡データを作成します。
*/
const createCirclePoints = (config: {
/** 原点からの半径 */
radius: number
/** 表示する高さの位置 */
height: number
/** 同心円における点群の分割数 */
divisions: number
}): Float32Array => {
const { radius, height, divisions } = config
const points: number[] = []
const degreePerPoint = 360 / divisions
for (let i = 0; i < 360; i += degreePerPoint) {
const radian = i * Math.PI / 180
const x = radius * Math.cos(radian)
const y = height
const z = radius * Math.sin(radian)
points.push(x, y, z)
}
return new Float32Array(points)
}
export default App
src/App.css を下記コードに置き換えます。
/* Canvaののエリアは全画面で表示します。 */ .canvas-area { width: 100vw; height: 100vh; }
最後に、GPUを直接操作する vertex-shader、fragment-shaderのファイルをsrcディレクトリ以下に追加します。
src/vertex-shader.ts の頂点シェーダーのファイルを追加します。
当シェーダーでは、原点から各頂点までの距離に応じて表示するカラーを判定し、フラグメントシェーダーに連携しています。
export const vertexShader = `
precision highp float;
/* フラグメントシェーダーにカラー情報を連携するための変数です。 */
varying lowp vec3 vColor;
/* 距離に応じた色を判定する際に使用する最大値が設定された変数です。 */
uniform float uDistanceRangeMax;
/* 点の大きさが設定された変数です。 */
uniform float uPointSize;
/* レベルに応じたカラーを定義する構造体です。 */
struct ColorLevel {
float ratio;
vec3 color;
};
/* レベルに応じたカラーを定義した配列です。 */
ColorLevel[4] COLOR_LEVELS = ColorLevel[4](
ColorLevel(0.0, vec3(1.0, 0.0, 0.0)),
ColorLevel(0.333, vec3(1.0, 1.0, 0.0)),
ColorLevel(0.667, vec3(0.0, 1.0, 1.0)),
ColorLevel(1.0, vec3(0.0, 0.0, 1.0))
);
/* 原点の座標です。 */
vec3 ZERO_POSITION = vec3(0.0, 0.0, 0.0);
/* 原点から指定した位置までの距離を計算します。 */
float calcDistance(vec3 position) {
return distance(ZERO_POSITION, position);
}
/* 0 〜 最大値の範囲で指定した値の割合を計算します。 */
float calcRatio (float value, float rangeMax) {
return max(min(value / rangeMax, 1.0), 0.0);
}
/* 割合からカラー情報を取得します。 */
vec3 calcColor(float ratio) {
int levelIndex = 0;
for (int i = 0; i < COLOR_LEVELS.length(); i++) {
if (ratio <= COLOR_LEVELS[i].ratio) {
levelIndex = i;
break;
}
}
if (levelIndex == 0) {
ColorLevel level = COLOR_LEVELS[levelIndex];
return level.color;
}
ColorLevel currentLevel = COLOR_LEVELS[levelIndex];
ColorLevel prevLevel = COLOR_LEVELS[levelIndex - 1];
float colorRatioLen = currentLevel.ratio - prevLevel.ratio;
if (colorRatioLen <= 0.0) {
return currentLevel.color;
}
float colorRatio = (ratio - prevLevel.ratio) / colorRatioLen;
float r = prevLevel.color[0] + (currentLevel.color[0] - prevLevel.color[0]) * colorRatio;
float g = prevLevel.color[1] + (currentLevel.color[1] - prevLevel.color[1]) * colorRatio;
float b = prevLevel.color[2] + (currentLevel.color[2] - prevLevel.color[2]) * colorRatio;
return vec3(r, g, b);
}
void main() {
float distance = calcDistance(position);
float ratio = calcRatio(distance, uDistanceRangeMax);
vec3 color = calcColor(ratio);
/* 点の頂点データを設定します。 */
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
/* 点の大きさを設定します。 */
gl_PointSize = uPointSize;
/* フラグメントシェーダーにカラー情報を連携します。 */
vColor = color;
}
`
src/fragment-shader.ts のフラグメントシェーダーのファイルを追加します。
当シェーダーでは、頂点シェーダーからカラー情報を受け取り、各点を丸の形状で描画します。
export const fragmentShader = `
/* 頂点シェーダーから点のカラー情報を受け取ります。 */
varying lowp vec3 vColor;
void main() {
/* 点を丸の形状で表示します。丸の外側(中心から半径0.5より大きいエリア)は描画が不要なエリアのためdiscardして描画をスキップします。 */
if (length(gl_PointCoord - vec2(0.5)) > 0.5) {
discard;
}
/* 描画する色を指定します。 */
gl_FragColor = vec4(vColor, 1.0);
}
`
実行する
ソースコードの追加、及び編集が完了したら、以下コマンドを実行し、表示されたURLにブラウザでアクセスします。
npm run dev
実行結果の例
VITE v5.0.10 ready in 482 ms ➜ Local: http://localhost:5173/ ➜ Network: use --host to expose ➜ press h + enter to show help
上記URLをブラウザで表示した結果です。3D点群の原点から距離が遠くになるほど、赤から青に色が変化する点群を可視化できました。
Data Visualizerで3D点群を可視化する
最後に、上記サンプルコードを拡張し、当社製品のVisual M2M Data Visualizerへ実装した例をご紹介します。Data Visualizerは、当社が開発・提供しているWebベースの可視化ダッシュボード製品です。ノーコードなGUI操作だけで、様々なセンサーデータをブラウザでリアルタイムに可視化することができます。Data Visualizerについての詳細は、当社のWebサイトからご確認ください。
この例では、Data Visualizerで使用する可視化ウィジェットであるビジュアルパーツに、これまでご紹介した点群可視化処理を実装し、LiDARから取得した3D点群をリアルタイムに可視化しています。製品版での実装方法については説明を割愛しますが、今回のサンプルコードをもとに拡張を行えば、このような可視化機能をWebアプリに組み込むことも可能です。
詳細については、次回以降のブログでご紹介したいと思います。
おわりに
今回はGLSLを使用し、3D点群、及び各点に色を指定する方法をご紹介しました。GLSLで使用したことがない実用的な方法が他にもあると思います。今後も積極的に使用して製品へ取り込むことができるよう推進したいと思います。
製品に関するお問い合わせはこちらへ!