2Dゲームのダイナミックライトとシャドウ

私はゲームプレイで影が大きな役割を果たすステルスアクションゲームに取り組んでいます。 そこで、WebGLシェーダーを使用して動的な照明/シェーディングを作成しました。





パート1:ダイナミックライティング



私はredditの投稿に触発され、aionskullはUnityの法線マップを使用してスプライトを動的にカバーしました。 ニックネームgpillowを持つユーザーは、Love2Dでようなことをしたとコメントに投稿しました。 結果が表示された8 MBのgifを次に示します。 彼女のjusksmitに感謝します。



それでは、動的照明とは何ですか? これは、光源がステージ上のオブジェクトを照らす3Dグラフィックスの手法です。 ソースが移動するとリアルタイムで更新されるため、動的です。 もちろん、シェーダーを利用できない限り、3Dの世界ではかなり標準的なものであり、2Dに簡単に適用できます。



平面への光の入射角が照明を決定することを認識して動的照明を作成できます。また、平面が「見える」場所を示す法線ベクトルを認識することで照明を決定できます。







上の写真では、これはパネルの中央から突き出ている矢印です。 光線が(法線に対して)大きな角度で進むと、パネルが非常に悪く点灯することがわかります。 したがって、最終的に、アルゴリズムは非常に単純です-角度が大きいほど、パネルが受ける光は少なくなります。 照明を計算する最も簡単な方法は、光源からのベクトルと法線ベクトル間のスカラー積を計算することです。



OK、すべてが非常にクールですが、2Dゲームで通常のベクターを取得する方法は? ここでは、実際には、ボリュームオブジェクトはありません...しかし、ここでは、必要な情報が記録される追加のテクスチャ(非常に通常のマップ)が役立ちます。 上記のビデオで2つの家の2つのそのようなマップを作成し、それらを使用して照明を計算しました。以下に例を示します。



画像



最初は、陰影のない通常の家のスプライトが表示されます。 画像の2番目の部分には、テクスチャの色で法線ベクトルをエンコードする法線マップがあります。 ベクトルには(x、y、z)座標があり、テクスチャピクセルにはr、g、b成分が含まれているため、法線を簡単にエンコードできます。家の正面を南向きにします。 その法線は、座標[x:0、y:0.5、z:0]のベクトルになります。 (良いことに、法線は(0、1、0)に等しくなければなりませんが、ベクトルを-1から+1に決定し、0から1の範囲でエンコードする必要があるので、明らかに、著者は気をつけてすぐに検討しないことに決めました通常-0.5から+0.5まで。約transl。)



RGB値を負にすることはできないため、すべての値を0.5に移動します:[x:0.5、y:1、z:0.5]。 RGBは通常、0〜255の数値で表されるため、255を掛けて[x:128、y:255、z:128]を取得します。つまり、ベクトル「南」はこの光になります法線マップ上の



法線ができたので、グラフィックカードに魔法をかけることができます。

ImpactJSを使用していますが、 WebGL2Dとの互換性は良好です(有料です。pixi.jsまたはwebglレンダラーを備えた他のグラフィックライブラリをお勧めします。類似物をもっと知りたい場合は、コメントを書いてください!コメント transl



#ifdef GL_ES precision highp float; #endif varying vec2 vTextureCoord; uniform sampler2D uSampler; uniform vec3 lightDirection; uniform vec4 lightColor; void main(void) { //      vec4 rawNormal = texture2D(uSampler, vTextureCoord); //  -  ,    : if(rawNormal.a == 0.0) { gl_FragColor = vec4(0, 0, 0, 0); } else { //   RGB  ,    0..1  -0.5..+0.5 rawNormal -= 0.5; //    float lightWeight = dot(normalize(rawNormal.xyz), normalize(lightDirection)); lightWeight = max(lightWeight, 0.0); //     gl_FragColor = lightColor * lightWeight; } }
      
      







いくつかの注意事項:ピクセルごとのライティングを取得しますが、これは頂点ライティング(3Dでは通常)とは少し異なります。 2dの頂点は無意味であるため、特別な選択はありません(シーン上に平面を表示するための頂点は4つしかありません)。 しかし、実際には、これは問題ではなく、ピクセルごとの照明がはるかに正確です。 また、シェーダーはメインスプライトなしでライティングのみをレンダリングすることにも注意してください。 実際、スプライトに光を当てるのではなく、陰影を付けて、 lightColorで濃い灰色を送信するため、少し浮気します。 ピクセルの実際の照明、つまり輝度の増加は悪化し、ピクセルは「消耗」しているように見えます。 この問題には解決策がありますが、現時点では重要ではありません。



画像



パート2:影を描く。



3Dでの影の投影は、 レイトレーシングシャドウマッピングなどのよく知られたソリューションでよく研究されている問題です。 しかし、2dの受け入れ可能な既製のソリューションを見つけるのは難しいと思いました。自分でやらなければなりませんでしたが、いくつかの欠点もありますが、うまくいったと思います。



要するに、ステージ上のピクセルから太陽まで線を引き、障害物があるかどうかを確認します。 存在する場合-ピクセルは日陰にあり、存在しない場合-太陽にあるため、原則として、複雑なものはありません。



シェーダーは、 xyAngleおよびzAngleを受け入れます。これらは、太陽の位置を決定します。 非常に離れているため、光線は平行になり、したがって、これら2つの角度はすべてのピクセルで同じになります。 また、シェーダーは世界の高さのマップを受け入れます。 すべてのオブジェクト、建物、木などの高さが表示されます。 ピクセルが建物に属する場合、ピクセル値は約10になり、この時点での建物の高さは10ピクセルになります。



したがって、シェーダーは照らされる必要があるピクセルで開始され、 xyAngleベクトルを使用して、太陽に向かって少しずつ移動します。 それぞれについて、高さマップの指定されたピクセルに何かがあるかどうかを確認します。

画像

障害物を見つけるとすぐに、その高さと、太陽を遮る特定のポイントでの高さを決定します( zAngleを使用)。

画像

高さマップの値が大きい場合は、すべて、影のピクセルです。 そうでない場合は、検索を続けます。 しかし、遅かれ早かれ降伏し、ピクセルが太陽に照らされていることを発表します。 この例では、100ステップをハードコーディングしましたが、これまでのところ完全に機能しています。



これは、簡略化/擬似形式のシェーダーコードです。



 void main(void) { float alpha = 0.0; if(isInShadow()) { alpha = 0.5; } gl_FragColor = vec4(0, 0, 0, alpha); } bool isInShadow() { float height = getHeight(currentPixel); float distance = 0; for(int i = 0; i < 100; ++i) { distance += moveALittle(); vec2 otherPixel = getPixelAt(distance); float otherHeight = getHeight(otherPixel); if(otherHeight > height) { float traceHeight = getTraceHeightAt(distance); if(traceHeight <= otherHeight) { return true; } } } return false; }
      
      







そして、ここにコード全体があります:



 #ifdef GL_ES precision highp float; #endif vec2 extrude(vec2 other, float angle, float length) { float x = length * cos(angle); float y = length * sin(angle); return vec2(other.x + x, other.y + y); } float getHeightAt(vec2 texCoord, float xyAngle, float distance, sampler2D heightMap) { vec2 newTexCoord = extrude(texCoord, xyAngle, distance); return texture2D(heightMap, newTexCoord).r; } float getTraceHeight(float height, float zAngle, float distance) { return distance * tan(zAngle) + height; } bool isInShadow(float xyAngle, float zAngle, sampler2D heightMap, vec2 texCoord, float step) { float distance; float height; float otherHeight; float traceHeight; height = texture2D(heightMap, texCoord).r; for(int i = 0; i < 100; ++i) { distance = step * float(i); otherHeight = getHeightAt(texCoord, xyAngle, distance, heightMap); if(otherHeight > height) { traceHeight = getTraceHeight(height, zAngle, distance); if(traceHeight <= otherHeight) { return true; } } } return false; } varying vec2 vTextureCoord; uniform sampler2D uHeightMap; uniform float uXYAngle; uniform float uZAngle; uniform int uMaxShadowSteps; uniform float uTexStep; void main(void) { float alpha = 0.0; if(isInShadow(uXYAngle, uZAngle, uHeightMap, uMaxShadowSteps, vTextureCoord, uTexStep)) { alpha = 0.5; } gl_FragColor = vec4(0, 0, 0, alpha); }
      
      







UTexStepは、ピクセルをチェックするためのステップ長を保存します。 通常、OpenGLテクスチャ座標の値は0〜1であるため、1 / heightMap.widthまたは1 / heightMap.heightで十分です。したがって、1 /解像度では1ピクセルのサイズが得られます。



おわりに



実際、上記のコードでは省略したいくつかの詳細がありますが、基本的な考え方は明確である必要があります。 (たとえば、高さマップという考え!=法線マップがちょうど今私に来ました。翻訳に注意してください。) この方法には、シーン内の各ピクセルが1つの高さしか持てないため、大きな欠点があります。 したがって、たとえば、樹木では困難が生じます。 エンジンは、細い幹と青々とした冠の形でそれらからの影を正しく表示できません-葉と地面の間の空隙が高さマップに記録されないため、太い円柱影または幹からの細い棒があります。



画像







All Articles