ブラウザで画像をアニメーション化する方法。 WebGLマルチパスレンダリング

遅かれ早かれ3次元グラフィックスに出会った人は全員、レンダラーのいくつかのパスを含むレンダリングメソッドのドキュメントを開きました。 このような方法により、明るいスポットの輝き(グロー)、アンビエントオクルージョン、被写界深度の効果など、美しい効果で画像を補完できます。







「大人の」OpenGLと私のお気に入りのWebGLはどちらも、中間テクスチャで結果をレンダリングするための豊富な機能を提供します。 ただし、この機能の管理はかなり複雑なプロセスであり、必要な解像度のテクスチャの作成からユニフォームの命名、対応するシェーダーへの転送まで、あらゆる段階でエラーが発生しやすくなります。







WebGLを適切に準備する方法を理解するために、 Align Technologyのスペシャリストに頼りました 。 彼らは、さまざまなテクスチャからこのすべての動物園を管理する特別なマネージャーを作成することを決定しました。これは使いやすいでしょう。 それから来たものはカットの下にあります。 マルチパスレンダリングを整理する必要性にこれまで遭遇したことのない、準備のできていない読者にとって、この記事は理解できないように思われるかもしれません。 タスクは非常に具体的ですが、非常に興味深いものです。













状況の深刻さを理解してもらうために、会社について簡単に説明します。 Alignは、従来のブレースなしで人々が笑顔を修正できる製品を発売します。 つまり、彼らの直接の消費者は医師です。 これは、ユーザーインターフェイスの信頼性、パフォーマンス、および品質に素晴らしい要求を課す特定の要求があるかなり限られたユーザー層です。 かつて、C ++がメインツールとして選択されましたが、重大な制限がありました。デスクトップアプリケーションのみで、Windows専用です。 約2年前、Webバージョンへの移行が始まりました。 最新のブラウザーとテクノロジースタックの機能により、ほぼ15年前に作成されたユーザーインターフェイスを迅速かつ便利に再作成し、コードベースを適合させることができました。 もちろん、これにより、データボリュームとダウンロード速度を最適化する必要性など、フロントエンドとバックエンドでの一連のタスクを解決する必要が生じました。 この記事および以下の記事は、これらのタスクに専念します。







そして、二度起きないように、私はソースで投稿を混乱させないようにします。 つまり、実装の詳細に含まれ、コードの読者にインスピレーションを与えるものはすべて、可能であれば落書きされたり、きれいで明確なアイデアに還元されたりします。 ナレーションは、WebGLフロントの内部キッチンの秘密のベールを開くことに同意したAlign Technologyスペシャリストの1人であるVasily Stavenkoが私たちに語ったように、一人称で行われます。







問題の説明



そもそも、何を実装したいのか、これに何が必要なのかを伝える価値があります。 私たちの仕様は、多数の視覚効果を意味しません。 Screen Space Ambient Occlusion(またはSSAO)とシンプルなシャドウを実装することにしました。







SSAOは、大まかに言って、他のポイントに囲まれたポイントでの合計シャドウイングの計算です。 このアイデアの要点は次のとおりです。







float light = 0; float deltaLight; for(int astep =0; astep < ANGULAR_STEPS; ++astep){ vec2 offset = getOffset(astep, ANGULAR_STEPS); for (int rstep = 0; rstep < RADIAL_STEPS; ++rstep ){ float radius = getRadius(rstep, RADIAL_STEPS); vec4 otherPointPosition = textureLookup(offset, radius); float screenSpaceDistance = length(point.xy - otherPointPosition.xy); screenSpaceDistance = max(screenSpaceDistance, 0.00001); float deltaHeight = otherPointPosition.z - point.z; float lightness = (deltaHeight / screenSpaceDistance); // ! deltaLight = companyRelatedMagic(lightness); } light += companyRelatedMagic2(deltaLight); }
      
      





textureLookup



関数は、接続されたテクスチャからピクセルを選択します。ピクセルは色ではなく、ポイントの位置です。 次に、イルミネーションを、深さの現在の描画されたフラグメントからの距離に対する比として計算します。これは、座標gl_FragCoords



ます。 次に、マジックナンバーを使用して人のマジックを行い、目的の範囲の値を取得します。







結果のテクスチャは次のようになります。













これは最終的な結果のようです:









SSAOテクスチャの解像度がフルイメージよりも低いことがわかります。 これは意図的に行われます。 フラグメントに位置をレンダリングした直後に、テクスチャを圧縮し、その後のみSSAOを計算します。 解像度が低いと、レンダリングと処理が高速になります。 つまり、最終画像を作成する前に、中間画像の解像度を上げる必要があります。







要約すると、次のテクスチャを描画する必要があります。







  1. GL_FLOAT



    形式の元の解像度位置のテクスチャ
  2. 低解像度の位置のテクスチャ。
  3. 低解像度のSSAOテクスチャ。
  4. 低解像度SSAOのテクスチャがぼやけています。
  5. 高解像度のSSAOテクスチャがぼやけています。
  6. シャドウマスクテクスチャ。
  7. 正しいマテリアルでレンダリングされたシーン画像。


依存関係と再利用



ほとんどのテクスチャは、既にレンダリングされたテクスチャがある場合にのみレンダリングできます。 さらに、それらのいくつかは複数回使用できます。 つまり、依存関係で機能するメカニズムが必要です。







デバッグ



レンダリングプロセスをデバッグするには、任意のテクスチャを既存のコンテキストに持ってくると便利です。







テクスチャとフレームバッファの管理



私たちはすでにTHREE.jsフレームワークを作業に使用しているため、次の要件が既にそれとの相互作用から生じています。 純粋なWebGLにスリップしないことを決定し、残念ながらフレームバッファーのオーバーヘッドを提供するTHREE.WebGLRenderTarget



を使用しTHREE.WebGLRenderTarget



。 しかし、このオーバーヘッドがあっても、レンダリングは許容可能な速度で動作し、このようなオブジェクトの管理は、2つの関連するが同時に独立したオブジェクトを管理するよりもはるかに簡単です。







テクスチャ解像度管理



画像出力コードを完全に変更する必要がなく、解像度、マトリックスなどを変更する必要がないという事実に煩わされることなく、数値と光の制限の魔法でダウンサンプリングパラメーターを「再生」できるようにしたいと考えています。 したがって、マネージャーのサンプリングメカニズムを「縫い合わせる」ことが決定されました。







シーンをレンダリングする前にマテリアルを置き換える



THREE.Scene



内のすべてのオブジェクトのマテリアルは、オブジェクトの可視性を考慮して位置を描画するために交換し、その後損失なく復元する必要があります。 ここで、 Scene.overrideMaterial



パラメーターを使用できることにScene.overrideMaterial



ください。 しかし、この場合、ロジックはやや複雑になりました。







実装が主なアイデアです



その結果、私たちは何をしましたか?

最初に、彼らはマネージャーを作りました。その説明は以下にあります。 また、シェーダーが自動的に読み取り、レンダリングするのに必要なテクスチャーを確認するクラスを作成しました。 マネージャは、テクスチャをレンダリングするための依存関係があることを理解でき、必要な依存関係を描画する必要があります。







このマネージャーは、Passクラスインスタンスで初期化されることになっています。 つまり、パッセージを追加する別のオブジェクトが必要であり、すでにアプリケーション固有です。 最新のWebGLシェーダーでは発信テクスチャの名前を指定できないため、ScreenSpacePassを匿名にし、追加するときに名前を付ける必要がありました。 そして、彼らはシェーダーテキストからそれを読むことができました。







そのような方法は次のとおりです。







 addPass(name, pass){ if(!pass instanceof Pass) throw new Error("Adding wrong class to renderer passes"); pass.setSceneAndCamera(this.screenSpaceScene, this.camera); this.passes.set(name, pass); }
      
      





はい、同じマネージャでscreenSpaceScene状態管理も切断しました。 幸いなことに、これは画面全体を閉じる唯一のジオメトリメッシュです。







特定のパッセージを画面に描画するために必要なメソッドは次のとおりです。







 if(!this.passes.has(name)) throw new Error(`Multipass manager has no rendertarget named ${name}`) const target = this.passes.get(name); if(target.dependencies) { this._prepareDependencies(target.dependencies); // <---     target.installDependencies(this.passes); } if(this.prerenderCallbacks[name]) //     . this.prerenderCallbacks[name].forEach(fn=>fn(this)); let clear = options.clear || {color:true, depth:true, stencil:true}; clear = {...clear, ...target.clearOptions} target.setResolutionWithoutScaling(this.width, this.height); //   -  target.prerender(); this.setupClearing(clear); this.renderer.render(target.getScene(), target.getCamera()); this.restoreClearing(); target.postrender();
      
      





いくつかのコメント:







  1. 各目標は、レンダリングのパスです。
  2. this.passes



    はjavascript Map()のインスタンスです(タイプ: Map<String, Pass>



    )。
  3. target.dependencies



    は、シェーダーのテクスチャユニフォームのリストです。 正規表現を使用してシェーダーソースから読み取ります。
  4. installDependencies



    はユニフォームのインストールにすぎません。
  5. 各依存関係のthis.prerender



    は、指定された関数の妹であるthis.prerender



    関数を実行します。 メソッドの違いはわずかです。たとえば、レンダリングはターゲットのフレームフレームバッファに送られます。


 this.renderer.render(target.getScene(), target.getCamera(), target.framebuffer);
      
      





したがって、このインターフェイスを使用してパスの共通クラスを次のように描画しました。







 class Pass{ // : constructor(framebufferOptions = {}) {} //   get clearOptions() get framebuffer() resize(w, h) //    setResolution(width, height) //     . setResolutionWithoutScaling(width, height) //       . touchUniformFunctions() prerender() postrender() installDependencies(dependenciesMap) getScene() getCamera() }
      
      





仕組み



まず、マネージャーを構成する必要があります。 これを行うには、それをインスタンス化し、それにいくつかのPass-sを追加します。 次に、コンテキストにパスを描画する必要がある場合は、単に呼び出します







 manager.renderOnCanvas("passName");
      
      





このパスは画面に描画する必要があり、マネージャーはこの前にすべての依存関係を準備する必要があります。 テクスチャを再利用したいので、マネージャーは既に描画されたテクスチャの存在を確認し、最後のフレームのテクスチャが描画できないテクスチャであると判断しないように、描画を開始する前に古いテクスチャをリセットします。 このため、マネージャーには、対応する名前start



関数があります。







 function render(){ manager.start(); manager.renderOnCanvas('mainPass'); }
      
      





調和のとれたスキームの混乱は、メインキャンバスに半透明のテクスチャを描画する必要性によって生じました。 ブレンドする場合、以前の結果を消去する必要はなく、ブレンド自体を設定する必要があります。 私たちの場合、準備されたテクスチャは、ブレンドによって最終レンダリング中に画像に重ねられます。 手順は次のとおりです。







  1. gl.Clearを使用して背景を消去します。three.jsは、消去する必要がないことを通知しない場合、これを自動的に実行します。
  2. ブレンドで影を付けます。
  3. 透明度を使用して顎の画像をオーバーレイします。
  4. SSAOを課します。


このように:







 function render(){ this.passManager.start(); if(showShadow) this.passManager.renderOnCanvas('displayShadow'); this.passManager.renderOnCanvas('main', { clear:{color:false, stencil:true, depth:true} }); if(showSSAO) this.passManager.renderOnCanvas('displaySSAO',{ clear:{color:false, stencil:true, depth:true} }); }
      
      





わずかな違いは、カラーバッファーが消去されず、他のすべてのバッファーがクリアされることです。







何らかの種類の中間テクスチャを表示したい場合(デバッグ目的など)、レンダリングをわずかに変更することしかできません。 たとえば、上で引用したSSAOのテクスチャは、次のコードでレンダリングされました。







 function render(){ this.passManager.start(); this.passManager.renderOnCanvas('ssao'); }
      
      





ScenePassの実装



次に、テクスチャのシーンの通路を描画する方法について詳しく説明します。 明らかに、シーンをレンダリングしてマテリアルを置き換えることができるものと、すべてを画面座標でレンダリングするものが必要です。







 export class ScenePass extends Pass{ constructor(scene, camera, options={}){ let prerender=options.prerender ||null, postrender=options.postrender || null; super(options.framebufferOptions); this.scene = scene; this.camera = camera; this._prerender = prerender; this._postrender = postrender; this._clearOptions = options.clearOptions; this.overrideMaterial = options.overrideMaterial || null; } setSceneAndCamera(){ // Do not rewrite our scene } }
      
      





これがクラス全体です。 ほとんどすべての機能が親に残っているため、非常に簡単であることがわかりました。 ご覧のように、すべての適切なオブジェクトでのマテリアルの順次交換中ではなく、シーン全体のマテリアルを1つの割り当て操作で一度に置き換えることができる場合、 overrideMaterial



を残すことにしました。 実際、 _prerender



_postrender



これらは、個々のメッシュのマテリアルのかなり賢い代替品です。 私たちの場合、次のようになります。







 class Pass{ /// Skip-skip prerender(){ if(this.overrideMaterial) this.scene.overrideMaterial = this.overrideMaterial; if(this._prerender) this.scene.traverse(this._prerender); } postrender(){ if(this.scene.overrideMaterial) this.scene.overrideMaterial = null; if(this._postrender) this.scene.traverse(this._postrender); } /// Skip-skip }
      
      





Scene.traverse



は、シーン全体で再帰的に実行されるTHREE.jsメソッドです。







ScreenSpacePassの実装



ScreenSpacePassは、不必要なボイラープレートなしで作業するために、シェーダーから必要な最大情報を抽出するように考案されました。 クラスは非常に複雑であることが判明しました。 主な難点は、サンプリングを提供するロジック、つまり、テクスチャに適切な権限を設定することです。 テクスチャではなく画面上に描画したい場合、現在のフレームバッファの解像度を設定するための追加の方法を開始する必要がありました。 技術的な複雑さ、クラスの責任、エンティティの数、タスクに割り当てられる時間の間で妥協しなければなりませんでした。







ユニフォームの自動検索とインストールは、テクスチャのユニフォーム名のタイプミスなどの問題をすばやく見つけるのに役立ちました。 そのような場合、GLは他のテクスチャーを使用できますが、画面に表示されるものは正確に表示されず、その理由もわかりません。







 export class ScreenSpacePass extends Pass { constructor(fragmentShader, options = {}){ // scaleFactor = 1.0, uniforms={}){ let scaleFactor = options.scaleFactor || 1.0; let uniforms = options.uniforms || {}; let blendingOptions = options.blendingOptions || {}; super(options.framebufferOptions); /// Skip } resize(w, h){ const scaler = getScaler(this.scaleFactor, w, h); let v = new Vector2(w,h).multiply(scaler); this.framebuffer.setSize(Math.round(vx), Math.round(vy)); } setResolution(width, height){ const scaling = getScaler(this.scaleFactor, width, height); let v = new Vector2(width, height).multiply(scaling); this.uniforms.resolution.value = v; } setResolutionWithoutScaling(width, height){ this.uniforms.resolution.value = new Vector2(width, height); } isSampler(uname){ return this.samplerUniforms.indexOf(uname) != -1; } tryFindDefaultValueForUniformType(utype){ switch(utype){ case 'vec2': return new Vector2(0., 0.); default: return null; } } getValueForUniform(uniformDescr){ if(!this.uniformData.hasOwnProperty(uniformDescr.name )){ if(uniformDescr.name != 'resolution' && !this.isSampler(uniformDescr.name)) console.warn(`Value for uniform '${uniformDescr.name}' is not found.`); return this.tryFindDefaultValueForUniformType(uniformDescr.type); } if(typeof(this.uniformData[uniformDescr.name]) == 'function'){ this.uniformData[uniformDescr.name] = this.uniformData[uniformDescr.name].bind(this); return this.uniformData[uniformDescr.name](); } else return this.uniformData[uniformDescr.name]; } touchUniformFunctions(){ for(const k in this.uniformData){ if(typeof this.uniformData[k] !== 'function') continue; this.uniforms[k].value = this.uniformData[k](); } } prerender(){ this.scene.overrideMaterial = this.shader; this.touchUniformFunctions(); } parseUniforms(glslShader){ let shaderLines = glslShader.split('\n'); const uniformRe = /uniform ([\w\d]+) ([\w\d]+);/; let foundUniforms = shaderLines.map(line=>line.match(uniformRe)) .filter(x=>x) .map(x=>{return {type:x[1],name:x[2]}}); const umap = this.mapping; this.dependencies = foundUniforms.filter(x=>x.type == 'sampler2D').map(x=>umap[x.name]?umap[x.name]:x.name); this.samplerUniforms = foundUniforms.filter(x=>x.type == 'sampler2D').map(x=>x.name); this.uniforms = {}; foundUniforms.forEach(u=>{ this.uniforms[u.name] = {value:this.getValueForUniform(u)}; }); if(!this.uniforms.hasOwnProperty('resolution')) throw new Error('ScreenSpace shader in WebGL must have resolution uniform'); } installDependencies(dependenciesMap){ this.samplerUniforms.forEach(uname=>{ this.uniforms[uname].value = dependenciesMap.get(uname).framebuffer.texture; }) } parseShader(fragmentShader){ let glslShader = parseIncludes(fragmentShader); this.parseUniforms(glslShader); return new RawShaderMaterial({ vertexShader: ssVertex, fragmentShader:glslShader, uniforms: this.uniforms, transparent: this.blendingOptions.transparent || false }); } } function parseIncludes( string ) { // Stolen from three.js var pattern = /#include +<([\w\d.]+)>/g; function replace( match, include ) { var replace = ShaderChunk[ include ]; if ( replace === undefined ) { throw new Error( 'Can not resolve #include <' + include + '>' ); } return parseIncludes( replace ); } return string.replace( pattern, replace ); }
      
      





ここで、ソースコードは非常に大きく、クラスは非常にスマートであることが判明しました。 ただし、ほとんどのコードは、シェーダーにテクスチャのユニフォームがあるかどうかを検出し、それらを依存関係として設定するだけであることがわかります。







最後に、どのように使用したかを示します。 EffectComposer



と呼ばれるアプリケーション固有のエンティティ。 彼のコンストラクターで、記述されたマネージャーを作成し、彼のパスを作成します。







 this.passManager = new PassManager(threeRenderer); this.passManager.addPass('downscalePositionSSAO', new ScreenSpacePass(require('./shaders/passingFragmentShader.glsl'), {scaleFactor}) ); this.passManager.addPass('downscalePositionShadow', new ScreenSpacePass(require('./shaders/positionDownSampler.glsl'), {scaleFactor}) ); this.passManager.addPass('ssao', new ScreenSpacePass(require('./shaders/SSAO.glsl'), {scaleFactor}) ); /// Skip-skip-skip
      
      





例として、passingFragmentShader.glslファイルの内容:







 precision highp float; uniform sampler2D positions; //   -     positions uniform vec2 resolution; void main(){ vec2 vUv = gl_FragCoord.xy / resolution; gl_FragColor = texture2D(positions, vUv); }
      
      





シェーダーは非常に短い-補間されるピクセルを取得し、それを渡します。 すべての作業は、テクスチャ設定( GL_LINEAR



)の線形補間によって行われます。







次に、 positions



がどのように描画されるかを見てみましょう。







プログラムの他の場所で作業シーンが必要なので、 EffectComposer



その所有者でEffectComposer



なく、必要なときに尋ねられます。







 function updateScenes(scenes, camera){ this.passManager.addPass('main', new ScenePass(scene, camera)); this.passManager.addPass('positions', new ScenePass(scene, camera, { prerender: materialReplacer, postrender:materialRestore, framebufferOptions })) }
      
      





ご覧のように、誰かがシーンの変更について私たちに通知すると、 EffectComposer



は2つのPass-aを作成します。1つはデフォルト設定で、もう1つは巧妙なマテリアルの置き換えです。 シーンのパッセージにはトリッキーな依存関係は含まれていません。原則として、それらは単独で描画されますが、説明したアプローチでは、依存関係を追加するためにScenePassにいくつかのメソッドを追加するとこれを行うことができます。 シーンのどの種類のマテリアルがレンダリングされた依存関係を持ちたいのかは明らかではないからです。







おわりに



このケースでは使いやすさにもかかわらず、シェーダーに基づいたパスの完全自動生成を実現できませんでした。 テクスチャ出力パラメーターGL_RGB



GL_RGBA



GL_FLOAT



GL_UNSIGNED_BYTE



などの追加パラメーターでシーンレンダリングパッセージを補完するマーカーをシェーダーに追加したくありませんGL_UNSIGNED_BYTE



。 これにより、一方ではコードが簡素化されますが、シェーダーを再利用する自由度が低くなります。 つまり、この設定はまだ説明する必要がありました。







依存関係マッピングをまだ実装しなければならなかったことに言及する価値があります。 これは、1つのシェーダーを複数のパスで使用し、異なる入力テクスチャで使用する場合に役立つことが判明しました。 この場合、各パスは関数のように見え始めたため、少し「より機能的」に行う方法を考えました。







しかし、開発全体が非常に有用であることが判明しました。 特に、大幅な困難なしにプロジェクトにエフェクトを追加できます。 個人的には、イメージを簡単にデバッグできる機能が最も気に入っています。








All Articles