WebGL
three.js
adventcalendar2017
WebGLDay 22

Three.jsとシェーダ事始め

この記事はWebGL Advent Calendar 2017の12月22日の記事です。

Three.jsは色々なgeometryやmaterialが予め用意してくれているので便利です。が、いろいろ遊んでいるとShaderMaterialなるものに遭遇するかと思います。

これ、ドキュメントを読んでみたんですが自信持って言えます。

一字一句何を言っているのかわかりませんでした。

https://threejs.org/docs/index.html#api/materials/ShaderMaterial

A material rendered with custom shaders. A shader is a small program written in GLSL that runs on the GPU. You may want to use a custom shader if you need to:
・ implement an effect not included with any of the built-in materials
・ combine many objects into a single Geometry or BufferGeometry in order to improve performance

しぇーだ?じーえるえすえる?じーぴーゆー(←おい)?みたいな感じでした。
恥ずかしながらこの説明で初めてGLSLという単語を知ったくらいの知識量だったので、正直「…ふう…また高い壁が出てきましたネ…(; ・`ω・´)」という感じでした。ただ2つ目のこの部分!!!これは!!やってみたい!!!

・ combine many objects into a single Geometry or BufferGeometry in order to improve performance

…ということで、Three.jsだけをちまちま使うことから卒業して、生のWebGL、GLSLの基本を https://wgld.org でひと通りやりました。ホント助かりましたm(_ _)m

今回は、Three.jsでシェーダを使って大量の何かを描画することに挑戦してみました。アドベントカレンダー書かれている他の方々とレベルが違いすぎて…本当に…恐縮なんですが…。もしこれからThree.jsでシェーダ使ってみようという方がいましたら、読んでいただけると幸いですm(_ _)m

まず最初に成果物を

スクリーンショット 2017-12-22 2.20.53.png
30万個のパーティクルを描画してみました。こんなに多いのにMacのファンが全然回っていませんでした。これ一つ一つドローコールをしていたら大変なことになりますが、頂点情報を全部まとめてメッシュに格納することで、ドローコールの回数が1回で済むようになります。それによって、処理負荷が軽くなっているというわけらしいです。

コードとURLはこちら

サンプルサイト: https://threejs-sample-with-shader.herokuapp.com/
リポジトリ: https://github.com/FumiyaShibusawa/Sample-of-Three.js-with-Shader

実装の順序

ShaderMaterialをつくる

Three.jsにはxxxMaterialといった形でいろいろな種類のmaterialが用意されていますが、自作のシェーダを使いたい場合はnew THREE.ShaderMaterialを使用します。

var ParamsShaderMaterial = {
  uniforms: {
    "time": {value: 1.0}
  },
  vertexShader: [
    "precision mediump float;",
    "attribute vec4 color;",
    "varying vec4 vColor;",
    "void main() {",
    "vColor = color;",
    "gl_PointSize = 1.5;",
    "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
    "}"
  ].join( "\n" ),
  fragmentShader: [
    "precision mediump float;",
    "uniform float time;",
    "varying vec4 vColor;",
    "void main() {",
    "float t = time * 0.001;",
    "gl_FragColor = vec4( vColor.r * abs(sin(t)), vColor.g * abs(cos(t)), vColor.b * abs(sin(t)), 1.0 );",
    "}"
  ].join( "\n" ),
  side: THREE.DoubleSide,
  transparent: true
}
var material = new THREE.ShaderMaterial(ParamsShaderMaterial);

大きく分けて引数に突っ込むパラメータは以下の通りです。

パラメータ
uniforms 使いたいuniform変数の名称と値
vertexShader 頂点シェーダのソース
fragmentShader フラグメントシェーダのソース

他のパラメータを確認したい場合はこちら -> https://threejs.org/docs/index.html#api/materials/ShaderMaterial

uniforms

uniformsにはカスタムで使用したいuniform変数の名前と初期値を与えます。与える初期値はGLSL側で定義する型と一致している必要があるという点は注意。なお、今回サンプルをつくるにあたってtypeを定義しなくても動作に問題なかったんですが…これはもう要らなくなったのかな…。

vertexShader

上のソースではそのまま文字列にして突っ込んでしまっていますが、当然<script id="vertexShader" type"x-shader/x-vertex">の中に記述してgetElementByIdで持ってくるのも可能です。というかそっちの方が普通かもしれないです。自分は横着なのでそのまま記述してしまっています。

また以下の部分ですが、座標変換行列はmodelMatrix、viewMatrix、modelViewMatrix、projectionMatrixといった変数名でThree.js側で予め定義されているので、そのまま使わせてもらっています。この場合は普通にモデル・ビュー・プロジェクション座標変換を行っているだけです。

    "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
fragmentShader

こちらもvertexShaderと同様にそのまま文字列にして記述してしまっています。
uniformsの中で指定したtimeは、こちらのフラグメントシェーダの方でuniform変数に渡されていますね。

    "uniform float time;",

BufferGeometryをつくる

ShaderMaterialの時と同じ話ですが、Three.jsにはxxxGeometryといった形でいろいろな種類のモデルが用意されています。しかし、それら以外の図形を表現したい場合は、自作で頂点情報や色情報をつくってGLSL側に渡してあげる必要があります。その時に使うのがnew THREE.BufferGeometryです。
https://threejs.org/docs/#api/core/BufferGeometry

function init(){
  geometry = new THREE.BufferGeometry();
  let positions = [];
  let colors = [];
  let x, y, z;
  for(let i = 0; i < particles; i++){
    x = Math.random() * 2.0 - 1.0;
    y = Math.random() * 2.0 - 1.0;
    z = Math.random() * 2.0 - 1.0;
    if(x * x + y * y + z * z <= 1) {
      positions.push(x * 500.0);
      positions.push(y * 10.0);
      positions.push(z * 500.0);
      colors.push(Math.random() * 255.0);
      colors.push(Math.random() * 255.0);
      colors.push(Math.random() * 255.0);
      colors.push(Math.random() * 255.0);
    }
  }
  let positionAttribute = new THREE.Float32BufferAttribute(positions, 3);
  let colorAttribute = new THREE.Uint8BufferAttribute(colors, 4);
  colorAttribute.normalized = true;
  geometry.addAttribute( 'position', positionAttribute );
  geometry.addAttribute( 'color', colorAttribute );
  material = new THREE.ShaderMaterial(ParamsShaderMaterial);
  mesh = new THREE.Points(geometry, material);
  scene.add(mesh);
}

空のBufferGeometryを用意しておき、頂点座標と色情報を配列に格納していきます。特にfor文の中では特別な処理をしていませんが、銀河というか天の河っぽい形を表現したかったので、球体をペッタンコにするようにしてみました。
geometry.addAttributeメソッドで、GLSL側で持っているattribute変数に型定義した値を入れることができます。今回は

  • positionAttribute => パーティクルの座標 … attribute vec3 position に保存
  • colorAttribute => パーティクルの色情報(初期値) … attribute vec4 color に保存

の2つを使っています。ちなみにpositionはあらかじめThree.jsによって定義されているattribute変数なので、頂点シェーダ側では特に定義していません。最初このことを知らずに、「position再定義しちゃってるよ!」とのエラーを食らってしまいました…。

THREE.WebGLProgram: shader error:  0 gl.VALIDATE_STATUS false gl.getProgramInfoLog invalid shaders  ERROR: 0:45: 'position' : redefinition

あとは、アニメーションを回してレンダリングするだけ。基本の基本はこれでカバーできるかなと思います。意外と簡単だった!

function animate(){
  requestAnimationFrame(animate);
  render();
}

function render(){
  time = performance.now();
  material.uniforms.time.value = time;
  mesh.rotation.x = (Math.cos(Math.PI * time * 0.1 / 360) * 0.05) + 0.1;
  mesh.rotation.y += Math.PI / 720;
  renderer.render(scene, camera);
}

もっとシェーダでいろいろいじくればいろんな表現ができそう!Three.jsではシェーダのコンパイルやWebGLProgramの定義などを全部裏側で処理してくれるので、細かい下準備はThree.jsにまかせて、シェーダの部分だけに集中…という形で今後もうちょい勉強してみようと思います。

Three.jsのリポジトリにシェーダのサンプルがたくさん

https://github.com/mrdoob/three.js/tree/master/examples/js/shaders
ちなみにThree.jsのソースを眺めていて見つけたんですが、シェーダ使ったサンプルが結構たくさんリポジトリに詰まっているようです。少し覗いてみましたが、まったくもってチンプンカンプンなので、これを…理解できるようになれば…。ただ更新日が2年前のものが多いのでそのあたりは注意が必要かと。。