takumifukasawa’s blog

WebGL, Shader, Unity, UE4

CSSスプライトアニメーションをGPUレンダリングさせる

※内容は以前書いたこちらの記事と同じでさらに文章に校正を加えたものになります。jsdo.itをデモとして使っていたのですがjsdo.itがクローズになってデモが動作しなくなったのでデモをcodepenに移したためです。

CSSスプライトアニメーションをGPUレンダリングさせる - Qiita

スプライトアニメーションと再描画

CSSでスプライト画像の連番アニメーションを実装する場合、background-positionをずらす方法を使っていました。しかし、同じ画面内でサイズの大きい画像のスプライトアニメーションをいくつも動かしたい場面があり、パフォーマンスがネックになりました。

実際、background-positionのcss animationはGPUレンダリングではないので再描画処理が走ってしまいます。chromeのデバッガーツールでRenderingPaint Flashingを見ると、このように再描画処理が走っているのがわかります。

f:id:takumifukasawa:20200328232048p:plain

@keyframes sprite-image {
  0% {
    background-position: 0 0;
  }
  100% {
    background-position: 0 -1064px;
  }
}

.sprite {
  width: 400px;
  height: 266px;
  margin: 15px;
  background-image: url(https://user-images.githubusercontent.com/947953/77824893-2f663480-7149-11ea-873b-9fda24fe1022.jpg);
  background-size: 400px 1064px;
  animation: sprite-image 1s steps(4) infinite;    
}

css transformを使うことで再描画を防ぐ

そこでGPU処理が適用されるcss transformを使った方法を試してみました。before要素の大きさをスプライト画像と同じ大きさにし、背景にスプライト画像をあて、animationでtransformを操作することにより、background-positionの時と同じDOM構造のままcssを変更するのみで実現できました。

先ほどと同じようにchromeのデバッガーツールを見てみると、再描画が走っていないことがわかります。この方法によって、background-positionを使った時よりも描画負荷を軽減することができました。

f:id:takumifukasawa:20200328232059p:plain

こちらがデモになります。

See the Pen 【CSS】Sprite Animation by GPU Rendering by takumifukasawa (@takumifukasawa) on CodePen.

cssの抜粋です。

@keyframes sprite-image {
  0% {
    transform: translate(0, 0);
  }
  100% {
    transform: translate(0, -100%);
  }
}

.sprite {
  overflow: hidden;
  width: 400px;
  height: 266px;
  margin: 15px;

  // animationでtransformのtranslateを動かす
  &:before {
    content: "";
    display: block;
    width: 400px;
    height: 1064px;
    background-image: url(https://user-images.githubusercontent.com/947953/77824893-2f663480-7149-11ea-873b-9fda24fe1022.jpg);
    background-size: 400px 1064px;
    animation: sprite-image 1s steps(4) infinite;
  }
}

【WebGL】Google Chrome の Hardware Acceleration 無効時の挙動

WebGL開発をする際に、Chromeのハードウェアアクセラレーションを無効にしたときでもWebGLコンテンツを動作させたい機会があり、挙動を調べる機会があったのでメモとして残しておきます。

設定方法の変更

Chrome設定詳細設定ハードウェア アクセラレーションが使用可能な場合は使用する で切り替えることができます。

切り替える際はブラウザを再起動する必要があります。

f:id:takumifukasawa:20200328102201p:plain

f:id:takumifukasawa:20200328102209p:plain

有効・無効時のブラウザの機能の違い

chrome://gpu にアクセスすることでグラフィック回りの使える機能の状態を確認することができます。

↓ ハードウェアアクセラレーション有効時 f:id:takumifukasawa:20200328111007p:plain

↓ ハードウェアアクセラレーション無効時 f:id:takumifukasawa:20200328111015p:plain

無効時のWebGL部分を見るとSoftware only, hardware acceleration unavailableの状態になっています。ハードウェアアクセラレーションは使えなくなっていますが(= GPUを使わない)なんらかのソフトウェアは動作しているようで、そのソフトウェアとはSwiftShaderを指しています。

Chrome と CPUレンダラー

ChromeにはSwiftShaderというCPUベースのレンダラーが組み込まれています。

github.com

SwiftShader is a high-performance CPU-based implementation of the Vulkan, OpenGL ES, and Direct3D 9 graphics APIs12. Its goal is to provide hardware independence for advanced 3D graphics.

つまりSwiftShaderはVulkanやOpenGL ESなどのグラフィックAPI相当の処理をCPUで動作させてくれるソフトウェアということになります。

Googleのこちらの記事によると2009年から組み込まれているようです。

2009 年以来、Chrome はハードウェア アクセラレーションによるレンダリングを完全にはサポートしていないシステムでの 3D レンダリングに SwiftShader を使用しています。WebGL などの 3D コンテンツは GPU 向けに記述されていますが、ユーザーの端末にはこのようなコンテンツを実行できるだけのグラフィック ハードウェアが搭載されていない場合もあります。

developers-jp.googleblog.com

WebGLの挙動

ハードウェアアクセラレーションをオフにしてGPUが使えない状態でも SwiftShader が動作することにより、WebGLを使用したコンテンツも表示することができます。ただし、処理速度はどうしてもGPU動作時よりも劣ります。

処理速度

ハードウェアアクセラレーションのオプションを有効・無効にしたときのWebGLコンテンツのFPSを比較してみました。参考にしたのはthree.jsのこちらのサンプルです。

https://threejs.org/examples/#webgl_buffergeometry

↓ 有効時: 約60FPS f:id:takumifukasawa:20200328110025p:plain

↓ 無効時: 約20FPS f:id:takumifukasawa:20200328110036p:plain

処理の内容にももちろん大きく左右されると思いますが、ハードウェアアクセラレーションを無効にしてもWebGLが使われているページがCPUのみの動作で20FPS近く出るとは驚きでした。見た目にも違いは見受けられませんでした。

注意点

調べきれてはいないのですが、SwiftShaderはあくまでもCPUレンダリングなので処理によってはGPUで動作していたときと同じ挙動・見た目にならないものもあるかもしれません。特にWebGL拡張機能回りを使いたいときはあらかじめ動作確認をしておいた方が安全、と思いました。

【three.js】背面カリングを用いたアウトライン表現

f:id:takumifukasawa:20200326234027p:plain

リアルタイムレンダリングにおいてアウトラインを生成する方法はいくつかあります。 背面カリングを利用した方法や、ポストプロセスによる生成が主かと思います。

今回は前者の、背面カリングを使ったアウトライン表現を行ってみたいと思います。

デモ

最終的なデモはこちらになります。

↓ モデルを使った場合。色と境界線の太さを変えられるようにしています。

See the Pen BackFace Culling Outline: Model by takumifukasawa (@takumifukasawa) on CodePen.

↓ three.jsのTorusGeometryを使った場合。境界線用のメッシュ・境界線内用のメッシュの表示非表示を切り替えられるようにしています。

See the Pen BackFace Culling Outline by takumifukasawa (@takumifukasawa) on CodePen.

手法と流れ

  1. 描画したいモデルを複製する
  2. 一つは、頂点シェーダーで各頂点を法線方向にオフセットし(膨らまし)、境界線につけたい色で背面を描画する。もしくはあらかじめ各頂点を法線方向にオフセット済みのモデルを用意する
  3. もう一つは、任意のマテリアル(境界線の内側に表示したいマテリアル)で通常通りレンダリング

このようにすることで、境界線用に背面描画したモデルの上に、描画したいモデルが乗っかるように描画され、あたかも境界線がついたかのように見えるということになります。


こちらのGIFは、境界線用のMeshと境界線の内側用のMeshの表示・非表示を切り替えてみたものです。

境界線用のMeshは境界線のみを描画しているのではなくベタ塗りされ、その上に境界線内に描画したいMeshが乗っかっているようになっていることが分かるかなと思います。

f:id:takumifukasawa:20200327001814g:plain

three.jsの場合

three.jsの場合はこのような流れで実現することができます。

  1. 描画したいMeshを2つ用意する
  2. 片方のMeshには境界線用に背面描画のMaterialを割り当てる。このマテリアルはシェーダーを書く
  3. もう片方のMeshは任意の見た目のMaterial(境界線の内側に描画させたい見た目のマテリアル)
  4. 描画

コード

こちらが境界線描画回りのコードになります。上のサンプルのTorusGeometryの方から抜粋しました。

コード中にコメントで説明を書いていきたいと思います。

...

const outlineVertexShader = `
void main() {
  // 法線方向に頂点を膨らませる
  // .04 は任意の膨らませたい大きさ
  vec3 _position = position + normal * .04; 
  gl_Position = projectionMatrix * modelViewMatrix * vec4(_position, 1.);
}
`;

const outlineFragmentShader = `
precision mediump float;
void main() {
  // 境界線色として黒を指定
  gl_FragColor = vec4(0., 0., 0., 1.);
}
`;

// 境界線用の背面描画マテリアル
const outlineMaterial = new THREE.ShaderMaterial({
  vertexShader: outlineVertexShader,
  fragmentShader: outlineFragmentShader,
  side: THREE.BackSide,
});

// 境界線の内側に描画する用のPBRマテリアル
const surfaceMaterial = new THREE.MeshStandardMaterial({
  color: 0xff0000
});
  
// 描画したい形状
const torusGeometry = new THREE.TorusGeometry(1, 0.3, 16, 100);

// 境界線用のMeshを作成しSceneに追加
const outlineMesh = new THREE.Mesh(torusGeometry, outlineMaterial);
scene.add(outlineMesh);

// 境界線の内側用のMeshを作成しSceneに追加
const innerMesh = new THREE.Mesh(torusGeometry, surfaceMaterial);
scene.add(innerMesh);

...

こうしてみると、境界線用のコードはそこまで多くないことが分かります。

特徴・注意点

モデルを2回描画する必要がある

  • 特に対策をしない場合は単純にポリゴン数が倍になる
  • そのためポリゴン数が多いGeometryの場合には採用がしづらく、採用する場合はポリゴン数を減らしたモデルにするなどの対策が必要?になるのかなと思います

カメラの位置によって太さが変わる

  • 太さを変えたくない場合は、カメラの位置によって膨らませる大きさを調整するか、ポストプロセスによる境界線の方法を用いる必要があります
  • 見せ方によっては、カメラの位置によって太さが変わっても問題ない場合もあるかと思います。

輪郭以外の境界線のディティールを出しづらい

  • 下のスクショのように、アボガドを横から見たときは種と身の境目に境界線が生じていません。また、種の左側に浮くように境界線が表示されているかと思います
  • 折り目がついているところや段差がある部分(このアボガドの場合は種と身の境目)に境界線をつけやすくしたい場合はポストプロセスの方が向いています
    • ポストプロセスであればシーン内の法線方向や色を用いた境界線の生成を行う方法をとることができるからです

f:id:takumifukasawa:20200327003200p:plain

【Unity】AzureKinectの環境構築・接続

AzureKinectを使う機会があったので、セットアップとミニマムな疎通確認までをまとめておきたいと思います。

azure.microsoft.com

3月18日現在、日本での発売はまだされていません。しかし、マイクロソフトの公式ブログによると3月中には発売可能になる予定とのことです。

Azure Kinect Development Kit を日本、ドイツ、イギリスにて2020年3月より発売! - Windows Blog for Japan

環境

  • Windows10
  • azure kinect sdk version 1.3.0
  • Unity 2019.2.18f

ハードウェア要件はこちらに記載されていました。

docs.microsoft.com

手順

1. Azure Kinect SDK をダウンロード

2. SDKのインストール

ダウンロードしたインストーラーを開いてインストール手順に従います

3. Unityプロジェクトを作成

Unity 2019.2.18f で作成しました。パイプラインはビルトインパイプラインにしました。

4. NuGet をUnityPackageからインストール

5. Nuget UnityPackage を作成したプロジェクトにインストール

UnityPackageをドラッグ・ドロップでUnityにインストールします。

6. NuGetでSDKのモジュールをインストール

  • menuの NuGet -> Manage Nuget Packages を開く

  • Nuget のインストール直後だと menuにNuGetが表示されない場合があったので、そのときはUnityを再起動してみます

  • Microsoft.Azure.Kinect.Sensor SDKをインストール

    • 検索バーで AzureKinect を入れると上の方に出てきます

f:id:takumifukasawa:20200310005344p:plain

7. dllのインポート

1でインストールしたAzureKinectSDKからdllを2つ(depthengine_2.0.dll, k4a.dll)、unityのAssets/Pluginsにドラッグ&ドロップしてインポート

  • Assets 以下に Pluginsフォルダがなければ作成しておきます

  • dllのpathは、SDKのインストールのパスを特に変えていない場合はこちらになるはずです

    • C:\Program Files\Azure Kinect SDK v1.3.0\sdk\windows-desktop\amd64\release\bin

f:id:takumifukasawa:20200310005416p:plain

f:id:takumifukasawa:20200310005435p:plain

8. AzureKinectをPCにつなぐ

AzureKinectの使用には、ACアダプターと、PCとつなぐUSBの、2つを最低限つなぐ必要がなります。

複数台接続の際はデイジーチェーンでのタイムスタンプの同期のためオーディオケーブルをつなぐことになります。

9. 接続確認用のスクリプトを作成

  • Playするとkinectを接続し、Playを止めるとkinectの接続を解除するスクリプトです

  • こちらを任意の名前で作成しGameObjectにアタッチします

gist.github.com

10. Playした時の挙動確認

エラーなどなくログが表示されている(シリアル番号Open)かつ、LEDが赤く点灯することを確認します。

11. 止めたときの挙動確認

Closeというログが出るようになっています。

AzureKinectの接続を解除する際は、状態破棄のため Dispose 処理を忘れずに呼ぶ必要があるようです。


以上まで問題なく動作すれば、AzureKinectとUnityの接続はひとまず無事に上手くいっていることが分かります。

注意点

Playした際に missing assembly reference のエラーが出ることがあります。

その場合は AzureKinect SDK が依存している?モジュールが入っていないためなので、 エラーの出ているモジュールを Nuget でインストールします。

【Unity】CSVを読み込むC#クラス

CSVファイルを読み込んでパースするクラスを、MonoBehaviourではなくC#クラスとして欲しい場面があったので作成してみました。

まず、コードの全文はこちらです。

gist.github.com

Assets/Resources/ 以下に入っているCSVファイルを読み込みます。

例えば Assets/Resources/test.csv

a, b, c, d
e, f, g, h

というデータになっている場合、 CSVReader.getPath("test") を呼ぶことで読み込むことができます。結果はこのようになります。

// 文字列の配列のリスト
[
  ["a", "b", "c", "d"],
  ["e", "f", "g", "h"]
]

string.Split 関数を呼ぶ時に System.StringSplitOptions.RemoveEmptyEntries を指定することで空白の文字列を削除していますが、こちらは扱いたいデータに応じて変えたり、引数で分割方法を指定できるようにするなどの使用方法も有りうると思います。

【SparkAR】リムライト風シェーダー

SparkARは、簡単なシェーディングであればパッチエディターでノードを組み合わせることで実現できるのですが、それだけでは難しい表現の場合はJSでスクリプトを書く必要があります。

なぜスクリプトかと言うと、SparkARではシェーダーを直接書くことができず、SparkARが提供しているモジュールを通してシェーダーの用な計算をスクリプトで書いていく必要があるためです。

リムライト風シェーダーもその一つだったので、メモとして残しておきたいと思います。

f:id:takumifukasawa:20200312211030p:plain

プロジェクトはこちらに置きました。SparkARのバージョンはv83です。

github.com

ソースの抜粋はこちらになります。

フレネルの計算や考え方はシェーダーで書くときと同じなのですが、ライトの向きをスクリプトで取得することができなさそうだったので、法線ベクトルと視線ベクトルの内積を使う形にしています。

const Shaders = require('Shaders');
const Reactive = require('Reactive');
const Materials = require('Materials');

// vertexのattributeを取得
const localPosition = Shaders.vertexAttribute({ variableName: Shaders.VertexAttribute.POSITION });
const localNormal = Shaders.vertexAttribute({ variableName: Shaders.VertexAttribute.NORMAL });

// 座標変換用の行列を取得
const mvMatrix = Shaders.vertexTransform({ variableName: Shaders.BuiltinUniform.MV_MATRIX });
const normalMatrix = Shaders.vertexTransform({ variableName: Shaders.BuiltinUniform.NORMAL_MATRIX });

const normal = normalMatrix.mul(localNormal).normalize();

const position = Reactive.pack4(
  localPosition.x,
  localPosition.y,
  localPosition.z,
  1.
);

const mvPosition = mvMatrix.mul(position);

const viewPosition = mvPosition.mul(-1);
const viewDir = Reactive.normalize(Reactive.pack3(viewPosition.x, viewPosition.y, viewPosition.z));

const dotNV = Reactive.dot(viewDir, normal);
const fresnel = Reactive.pow(Reactive.sub(1, Reactive.clamp(dotNV, 0, 1)), 2);

const emissiveColor = Reactive.pack4(
  fresnel, fresnel, fresnel, 1.
);

// Emissiveスロットに色を割り当てる
const material = Materials.get('RimLight');
material.setTextureSlot(Shaders.DefaultMaterialTextures.EMISSIVE, emissiveColor);

【Javascript】ブラウザでシェーダーを使わずにレイマーチングをしてみる

はじめに

ブラウザでレイマーチングを行うには、WebGLを使ってシェーダーで書くのが一般的なやり方かと思います。むしろ、高いFPSを実現するにはそれ以外の方法は実質難しいはずです。具体的には、描画したい範囲に板ポリを貼りそのマテリアルにレイマーチングを行うシェーダーを割り当てる方法です。

ピクセルごとに複数回レイを飛ばしてシーンを構築するような処理そのものは、負荷は大きいですがGPUの得意とするところで、CPUで1ピクセルずつ順番に計算するよりも圧倒的に高速だからです。

ただ、処理速度は遅くなりますがCPUでは実現できないという訳ではありません。負荷はともかくとして、レイマーチングそのものはあくまでも手法で、処理自体はGPUでもCPUでも実現そのものは出来るはず・・・と思っていたのですが、どのぐらい負荷に差が出るのかまでは予測できなかったので、実際にやってみました。

作ったもの

  1. CPU版: CanvasのContext2Dでレイマーチングしたデモ
  2. GPU版: シェーダーでレイマーチングしたデモ

比較のためにこの2つを作ってみました。見た目は同じです。球にディレクショナルライトが当たり、ライトの位置が回転するように動いています。

f:id:takumifukasawa:20200304003623p:plain

環境

  • Mac 15-inch
  • プロセッサ: 3.1Ghz Intel Core i7
  • グラフィックス: Intel HD Graphics 630 1536MB

CPU版

デモ

Typescriptで書いてみました。Context2Dを使っています。

ベクトルや行列周りの処理は、今回のデモで必要になる最低限の計算を自分で作ってみました。

See the Pen Canvas2D Raymarching by takumifukasawa (@takumifukasawa) on CodePen.

コード

HTML

<canvas id="canvas" width="200" height="200"></canvas>

CSS

canvas {
  display: block;
}

Javascript

const ITERATION_NUM: number = 16;
const NORMAL_EPS: number = 0.0001;
const STOP_THRESHOLD: number = 0.0001;

//---------------------------------------------------------------
// utils
//---------------------------------------------------------------

function clamp(x: number, a: number, b: number): number {
  return Math.min(Math.max(x, a), b);
}

//---------------------------------------------------------------
// vector
//---------------------------------------------------------------

class Vector2 {
  x: number;
  y: number;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

class Vector3 {
  x: number;
  y: number;
  z: number;
  constructor(x: number, y: number, z: number): Vector3 {
    this.x = x;
    this.y = y;
    this.z = z;
    return this;
  }
  get magnitude(): number {
    return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
  }
  static magnitude(Vector3: v): number {
    return Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
  }
  get normalized(): Vector3 {
    const mag = this.magnitude;
    return new Vector3(
      this.x / mag,
      this.y / mag,
      this.z / mag
    );
  }
  static normalize(v: Vector3): Vector3 {
    const mag = v.magnitude;
    return new Vector3(
      v.x / mag,
      v.y / mag,
      v.z / mag
    );
  }
  normalize(): Vector3 {
    const mag = this.magnitude;
    return new Vector3(
      this.x / mag,
      this.y / mag,
      this.z / mag
    );
  }
  static sub(a: Vector3, b: Vector3): Vector3 {
    return new Vector3(
      a.x - b.x,
      a.y - b.y,
      a.z - b.z
    );
  }
  static dot(a: Vector3, b: Vector3): number {
    return a.x * b.x + a.y * b.y + a.z * b.z;
  }
  static cross(a: Vector3, b: Vector3): Vector3 {
    return new Vector3(
      a.y * b.z - a.z * b.y,
      a.z * b.x - a.x * b.z,
      a.x * b.y - a.y * b.x
    );
  }
  static add(v1: Vector3, v2: Vector3): Vector3 {
    return new Vector3(
      v1.x + v2.x,
      v1.y + v2.y,
      v1.z + v2.z,
    );
  }
  static mul(v: Vector3, m: number): Vector3 {
    return new Vector3(
      v.x * m,
      v.y * m,
      v.z * m
    );
  }
}

//---------------------------------------------------------------
// mat3
//---------------------------------------------------------------

class Mat3 {
  elem: number[3][3];
  constructor(row1: Vector3, row2: Vector3, row3: Vector3) {
    this.elem = [
      [ row1.x, row1.y, row1.z ],
      [ row2.x, row2.y, row2.z ],
      [ row3.x, row3.y, row3.z ],
    ];
  }
  mul(v3: Vector3): Vector3 {
    return new Vector3(
      this.elem[0][0] * v3.x + this.elem[0][1] * v3.y + this.elem[0][2] * v3.z,
      this.elem[1][0] * v3.x + this.elem[1][1] * v3.y + this.elem[1][2] * v3.z,
      this.elem[2][0] * v3.x + this.elem[2][1] * v3.y + this.elem[2][2] * v3.z
    );
  }
}

//---------------------------------------------------------------
// color
//---------------------------------------------------------------

class Color {
  r: number;
  g: number;
  b: number;
  a: number;
  constructor(r: number, g: number, b: number, a: number = 255) {
    this.r = r;
    this.g = g;
    this.b = b;
    this.a = a;
  }
  add(color: Color): void {
    this.r = clamp(this.r + color.r, 0, 255);
    this.g = clamp(this.g + color.g, 0, 255);
    this.b = clamp(this.b + color.b, 0, 255);
    this.a = clamp(this.a + color.a, 0, 255);
  }
}

//---------------------------------------------------------------
// raymarch block
//---------------------------------------------------------------

function scene(p: Vector3): number {
  return p.magnitude - 1;
}

function getCamera(ro: Vector3, rd: Vector3): Mat3 {
  const dir = Vector3.sub(rd, ro);
  const forward: Vector3 = dir.normalized;
  const right: Vector3 = Vector3.cross(forward, new Vector3(0, 1, 0));
  const up: Vector3 = Vector3.cross(right, forward);
  return new Mat3(right, up, forward);
}

function getNormal(p: Vector3): Vector3 {
  const e: Vector2 = new Vector2(NORMAL_EPS, 0);
  const n: Vector3 = new Vector3(
    scene(Vector3.add(p, new Vector3(e.x, e.y, e.y))) - scene(Vector3.sub(p, new Vector3(e.x, e.y, e.y))),
    scene(Vector3.add(p, new Vector3(e.y, e.x, e.y))) - scene(Vector3.sub(p, new Vector3(e.y, e.x, e.y))),
    scene(Vector3.add(p, new Vector3(e.y, e.y, e.x))) - scene(Vector3.sub(p, new Vector3(e.y, e.y, e.x)))
  );
  return n.normalized;
}

function raymarch(u: number, v: number): Color {
  const time = performance.now() / 1000, // [s]
  const uv: Vector2 = new Vector2(u, v);
  const ro: Vector3 = new Vector3(
    Math.cos(time) * 5,
    0,
    Math.sin(time) * 5
  );
  const target: Vector3 = new Vector3(0, 0, 0);
  const fov: number = 1.5;
  const camera: Mat3 = getCamera(ro, target);
  const rd: Vector3 = camera.mul(new Vector3(uv.x, uv.y, fov).normalized);
  
  let depth: number = 0;
  let dist: number = 0;
  
  // raymarch
  for(let i: number = 0; i < ITERATION_NUM; i++) {
    dist = scene(Vector3.add(ro, Vector3.mul(rd, depth)));
    if(dist < STOP_THRESHOLD) {
      break;
    }
    depth += dist;
  }
  
  // no hit
  if(dist >= STOP_THRESHOLD) {
    return new Color(0, 0, 0, 255);
  }
 
  // hit
  const color: Color = new Color(0, 0, 0, 255);
  const position: Vector3 = Vector3.add(ro, Vector3.mul(rd, depth));
  const normal: Vector3 = getNormal(position);
  const lightPos: Vector3 = new Vector3(-1, 1, 1);
  const lambert: float = Math.max(0, Vector3.dot(normal, lightPos.normalized));
  const surfaceColor: Vector3 = Vector3.mul(new Vector3(255, 255, 255), lambert);
  color.add(new Color(
    surfaceColor.x,
    surfaceColor.y,
    surfaceColor.z
  ));

  return color;
}

//---------------------------------------------------------------
// main block
//---------------------------------------------------------------

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');

const width: number = canvas.offsetWidth;
const height: number = canvas.offsetHeight;

const stats = new Stats();
stats.domElement.style.position = 'fixed';
stats.domElement.style.top = '0px';
stats.domElement.style.left = '0px';
document.body.appendChild(stats.domElement);

function draw() {
  stats.begin();
  
  const imageData = ctx.createImageData(width, height);
  const data = imageData.data;
  for(let y = 0; y < height; y++) {
    for(let x = 0; x < width; x++) {
      const w = x;
      // flip y
      const h = height - y;
      const i = (x + y * width) * 4;
      const coord: Vector2 = new Vector2(w, h);
      const resolution: Vector2 = new Vector2(width, height);
      const color: Color = raymarch(
        (coord.x - width * .5) / Math.min(width, height),
        (coord.y - height * .5) / Math.min(width, height)
      );
      data[i    ] = color.r;
      data[i + 1] = color.g;
      data[i + 2] = color.b;
      data[i + 3] = color.a;
    }
  }

  ctx.putImageData(imageData, 0, 0);
  
  stats.end();
  
  requestAnimationFrame(draw);
}

requestAnimationFrame(draw);

GPU

せっかくなのでシェーダーで動作するコードも書いてみました。

こちらはShaertoyでのデモになります。

デモ

www.shadertoy.com

コード

#define EPS .0001
#define NORMAL_EPS .0001

const float stopThreshold = .0001;

float scene(vec3 p) {
    return length(p) - 1.;
}

mat3 camera(vec3 o, vec3 t) {
    vec3 forward = normalize(t - o);
    vec3 right = cross(forward, vec3(0., 1., 0.));
    vec3 up = cross(right, forward);
    return mat3(right, up, forward);
}

vec3 getNormal(vec3 p) {
    vec2 e = vec2(NORMAL_EPS, 0);
    return normalize(
        vec3(
            scene(p + e.xyy) - scene(p - e.xyy),
            scene(p + e.yxy) - scene(p - e.yxy),
            scene(p + e.yyx) - scene(p - e.yyx)
        )
    );
}

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = (fragCoord.xy - iResolution.xy * .5) / min(iResolution.x, iResolution.y);
    
    vec3 ro = vec3(
        cos(iTime) * 5.,
        0.,
        sin(iTime) * 5.
    );
    vec3 target = vec3(0.);
    float fov = 1.5;
    
    vec3 rd = camera(ro, target) * normalize(vec3(uv, fov));
    
    // raymarching
    float depth = 0.;
    float dist = 0.;
    for(int i = 0; i < 64; i++) {
        dist = scene(ro + rd * depth);
        if(dist < stopThreshold) {
            break;
        }
        depth += dist;
    }
    
    // no hit
    if(dist >= stopThreshold) {
        fragColor = vec4(vec3(0.), 1.);
        return;
    }
    
    vec3 color = vec3(0.);
   
    vec3 position = ro + rd * depth;
    vec3 normal = getNormal(position);
    
    // directional lighting
    vec3 lightPos = vec3(-1., 1., 1.);
    float lambert = max(0., dot(normal, normalize(lightPos)));
    color += lambert * vec3(1.);    
    
    fragColor = vec4(color, 1.);
}

比較

CPU版をGPU版と同じ条件にするとFPSが1ぐらいしか出なかったので解像度などを下げました。

  • CPU版
    • レイを進める最大回数: 16回
    • 解像度[px]: 200 x 200
    • FPS: 20FPS前後

f:id:takumifukasawa:20200305000449p:plain

  • GPU
    • レイを進める最大回数: 64回
    • 解像度[px]: 640 x 360
    • FPS: ほぼ60FPS

f:id:takumifukasawa:20200305000505p:plain

結果

やはりレイマーチングそのものはCPUでも実現はできましたが、処理速度はGPUには到底及ばないぐらいの差が開きました。

参考

www.iquilezles.org