こんにちは、やまだたいし( やまだ たいし (@OrotiYamatano) / Twitter )です。
たまにはシェーダーの勉強をしようと思いVTFに触れたので
今回はその時の知見を共有したいと思い記事化しました。
UnityバージョンはUnity2020.3です。
目次
VTFとは
超簡単に言うと「頂点シェーダ内でテクスチャを参照すること」らしいです。
↓このような感じで水面を作ったり。
参照元
news.mynavi.jp
↓風ノ旅ビトのような足跡を残したりするのにも使えます。(実際に風ノ旅ビトがVTFで実装されているかどうかは分かりません)
実装結果
今回は雪山などで使えるキャラクターが歩いた位置が凹む動作をするものを実装に挑戦しました。
結果としては、影の付け方が分からなかったのですが、一応実装できました。
影についてはタイミングがあればリベンジしようと思います。
↓影が無いから分かりづらいケド……
↓ちゃんと、凹んだように実装できてる……!
いくつかのサイトを参考にしながら、実装しました。
しかしながら、ただVTFをするのもツマラナイと思い、今回はURPで実装に挑戦。
では、実装の説明に移ろうと思います。
テッセレーション
参考にしたプログラムではテッセレーションというものが使われてたので、今回はテッセレーションを利用したもので実装しています。
テッセレーション is 何
簡単に言うと、頂点シェーダから送られてきたポリゴンを分割することができるものらしいです。
処理的にいうと頂点シェーダーとピクセルシェーダーの間に属するみたいです。
具体的にはハイポリモデルをローポリ化してプログラム側でLODを実現したり、
逆にローポリモデルを分割してハイポリ化することも出来るようです。
LODをプログラム側で実現できるならデザイナーの手を煩わせずに済むので嬉しいですね!
しかしながら、せっかくポリゴンを分割するプログラムを作ってもピクセルシェーダー側で簡略化されてしまうこともあり、無駄な処理になりやすく、最適化が難しいとされているようです。
使う際は、気をつけて使ったほうが良さそうです。
テッセレーションの詳細
テッセレーションはDirectX11ではハルシェーダーとテッセレータとドメインシェーダに分割されるようです。
ハルシェーダー
与えられた頂点ポリゴンをどの様に分割するかを決めるシェーダー。
設定情報はパッチという物に保存されるようです。
パッチにはコントロールポイントと呼ばれる分割用の制御点を持っており1~32までのポイントを設置することが可能で、
複数のポイントを設定することでポリゴンをより細やかに分割出来るようです。(参照サイトの受け売り)
テッセレータ
DirectX11では触ることの出来ない固定機能です。
ハルシェーダで設定を行ったパッチをもとに頂点分割を行うシェーダ。
ドメインシェーダ
分割されたポリゴンに対して、様々な変更を行うためのシェーダーです。
曲線を描くようにしたり、凹凸が出来るようにしたり。
テッセレーションはビルドインならUnity側でメソッドが用意されていて、UnityDistanceBasedTessという関数で実現できるようですが
URPはそうもいかない(詳しくは知らんけど)みたいなので、(とりあえず)自前で書いてみます。
とはいえ……
今回は私が使ったのでテッセレーションの説明をしましたが雪山に足跡を残す処理をつくるなら
テッセレーションを使わずにハイトマップから法線を取り頂点位置を調整(バーテックスシェーダーで調整)するシェーダーを書くだけで良いかも知れません。
ハイトマップにペイント
雪道や砂漠などにキャラクターが通った後をつけるためには、頂点座標を移動させる処理が必要です。
では、何を元に頂点座標をどの程度動かすのかを決めるのかと言うと、ハイトマップというものを使います。
ハイトマップとは平らの表面のオブジェクトに凹凸の情報を加えるテクスチャーのことです。
今回、どうやってハイトマップを作っていくか、いくつか検討しました。
カメラを使う方法
カメラを Orthographic
にしてデプスを取得しレンダーテクスチャーに深度情報を渡すことで、ハイトマップを作るやりかたです。
URPのカメラではDon't Clearフラグがなくなったのでそのまま使えません。
以下のようなスクリプトを用意して、カメラ情報をクリアされないようにすることで、足跡を作ることが出来ます。
(↓コレとか使えそう?)
比較的カンタンに実装できますが、平面な土地にしか使えないため、使い勝手は微妙です。
他の実装方法を検討してみます。
「いけにえと雪のセツナ」ではこの方法の応用を利用しているっぽいです。
このカメラを使う方法は海や湖と言った平面にしかなりえない水に表現に影響を及ぼすハイトマップになら利用してもいいかも知れないですね。
しかしながら、カメラを使うのは少々重いので、使わないことを推奨します。
Unity4系まではこのやり方が主流だったようです。
因みにURPでは↓のようにも実装できるようです。
https://www.patreon.com/posts/47452596www.patreon.com
座標位置を渡す方法
キャラクターなどの座標位置を元にレンダーテクスチャーに書き込む方法です。
CommandBufferなどを使ってレンダーテクスチャに書き込みを行います。
山のような地形でもやろうと思えば使用可能だと思いますが、ちょっとロジックを考えなくてはいけません。
コライダー衝突箇所位置座標を渡す方法
座標位置を渡す方法とあまり変わらないのですが、コライダーを使うことで、地面に対してコライダーがあるものはすべて反応します。
欠点としては地面にはある程度細かいメッシュコライダー等を設定する必要があるので少々重いです。
しかしながら、山のような地形でも使うことが出来ます。
(山のような地形のメッシュでは試してないので言い切れないで部分はありますが)
今回はこのやり方で実装してみようと思いコレで実装しました。
衝突位置からUVの座標位置を特定するのにはESさんのUnityテクスチャーペイントを参考に作っていきます。
↓ESさんの記事
esprog.hatenablog.com
スクリプト
ESさんのコードを一部借りていますし、スクリプト量もそんなに多くないので、今回はGitHubはナシでコードをベタ貼りする形にしようと思います。
実装をみたい方がいらっしゃったら、githubにあげようと思います。(気軽にTwitterアカウントにDM、リプしてね)
Shader "Custom/VTX_Test" { Properties { [MainTexture] _MainTex ("MainTex", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Metallic ("Metallic", Range(0,1)) = 0.0 [MainTexture]_DispTex ("Disp Texture", 2D) = "gray" { } [MainTexture]_NormalMap ("Normalmap", 2D) = "bump" { } _SpecColor ("Spec color", color) = (0.5, 0.5, 0.5, 0.5) _MinDist ("Min Distance", Range(0.1, 50)) = 10 _MaxDist ("Max Distance", Range(0.1, 50)) = 25 _TessFactor ("Tessellation", Range(1, 50)) = 10 _Displacement ("Displacement", Range(0, 1.0)) = 0.3 [HideInInspector] _Cull("__cull", Float) = 2.0 } SubShader { Tags { "RenderType"="Opaque" "RenderPipeline" = "UniversalPipeline" } LOD 200 Pass { Name "ForwardVTX_Test" Tags { "LightMode"="UniversalForward" } HLSLPROGRAM #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl" #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl" // Physically based Standard lighting model, and enable shadows on all light types #pragma require tessellation #pragma fragment frag #pragma vertex TessellationVertexProgram #pragma hull hullExec #pragma domain domainExec #pragma target 5.0 CBUFFER_START(UnityPerMaterial) sampler2D _MainTex; sampler2D _DispTex; sampler2D _NormalMap; float _Displacement; float _TessFactor; float _MinDist; float _MaxDist; half _Glossiness; half _Metallic; half4 _Color; CBUFFER_END struct Input { float2 uv_MainTex; }; struct Attributes { float4 vertex : POSITION; half3 normal : NORMAL; float2 uv : TEXCOORD0; float4 color : COLOR; }; struct Varyings { float4 color : COLOR; float3 normal : NORMAL; float2 uv : TEXCOORD0; float4 positionCS : POSITION; float3 lightTS : TEXCOORD3; // light Direction in tangent space }; struct TessellationFactors { float edge[3] : SV_TessFactor; float inside : SV_InsideTessFactor; }; struct ControlPoint { float4 vertex : INTERNALTESSPOS; float2 uv : TEXCOORD0; float4 color : COLOR; float3 normal : NORMAL; }; float CalcDistanceTessFactor(float4 vertex, float minDist, float maxDist, float tess) { float dist = 0.; float f = clamp(1.0 - ( dist - minDist) / (maxDist - minDist), 0.01, 1.0) * tess; return (f); } [domain("tri")] [partitioning("integer")] [outputcontrolpoints(3)] [outputtopology("triangle_cw")] [patchconstantfunc("patchConstantFunction")] ControlPoint hullExec(InputPatch<ControlPoint, 3> patch, uint id : SV_OutputControlPointID) { return patch[id]; } //これがパッチの設定 TessellationFactors patchConstantFunction(const InputPatch<ControlPoint, 3> patch) { TessellationFactors f; float edge0 = CalcDistanceTessFactor(patch[0].vertex, _MinDist, _MaxDist, _TessFactor); float edge1 = CalcDistanceTessFactor(patch[1].vertex, _MinDist, _MaxDist, _TessFactor); float edge2 = CalcDistanceTessFactor(patch[2].vertex, _MinDist, _MaxDist, _TessFactor); f.edge[0] = (edge1 + edge2) / 2; f.edge[1] = (edge2 + edge0) / 2; f.edge[2] = (edge0 + edge1) / 2; f.inside = (edge0 + edge1 + edge2) / 3; return f; } //フラグメントシェーダーにわたす前の最後の頂点処理 Varyings vert(Attributes input) { Varyings output; //凹みを適用させる処理----------------------------------------------------------------------------- const float d = tex2Dlod(_DispTex, float4(1 - input.uv.x, input.uv.y, 0, 0)).r * _Displacement; input.vertex.xyz += input.normal * (1 - d); //---------------------------------------------------------------------------------------------- output.positionCS = TransformObjectToHClip(input.vertex.xyz); //Vertの反映 output.normal = input.normal; output.uv = input.uv; //UVテクスチャ反映 output.color = input.color; //色反映 VertexNormalInputs vertex_normal_input = GetVertexNormalInputs(input.normal, input.color); const Light main_light = GetMainLight(); const float3x3 tangent_mat = float3x3(vertex_normal_input.tangentWS, vertex_normal_input.bitangentWS, vertex_normal_input.normalWS); output.lightTS = mul(tangent_mat, main_light.direction); return output; } [domain("tri")] Varyings domainExec(TessellationFactors factors, OutputPatch<ControlPoint, 3> patch, float3 barycentricCoordinates : SV_DomainLocation) { Attributes v; #define DomainPos(fieldName) v.fieldName = \ patch[0].fieldName * barycentricCoordinates.x + \ patch[1].fieldName * barycentricCoordinates.y + \ patch[2].fieldName * barycentricCoordinates.z; DomainPos(vertex) DomainPos(uv) DomainPos(color) DomainPos(normal) return vert(v); } ControlPoint TessellationVertexProgram(Attributes v) { ControlPoint p; p.vertex = v.vertex; p.uv = v.uv; p.normal = v.normal; p.color = v.color; return p; } half4 frag (const Varyings input) : SV_Target { half4 c = tex2D (_MainTex, input.uv); const float3 normal = UnpackNormal(tex2D(_NormalMap, input.uv)); const float diff = saturate(dot(input.lightTS, normal)); c *= diff; return c; } ENDHLSL } } FallBack "Diffuse" }
↑ちょっと、雑いですが、シェーダーのコードになります。
#pragma require tessellation
と書くとテッセレーションがUnityで有効になるようです。
因みにテッセレーションはコードじゃないと実装できないです。
他の #pragma
定義は各処理がどの関数で実装されているかを表します。
例えば #pragma vertex TessellationVertexProgram
ですがこれは、バーテックスシェーダーの処理を TessellationVertexProgram
関数で実装するという意味です。
今回のシェーダーでは、ポイントとなってくるのはテッセレーションの処理ですが、先程解説した通りの実装しかしておらず、比較的単純なので大体わかると思います。
(凹みを適用する部分はコメント書いてあるし)
強いていうなら、 hullExec
関数の前に定義されているものが分からなないと思いますが、これらは「どのようにテッセレーションをするか」という設定です。
DirectXの設定になるので、理解したいなら、そちらを参考にしつつUnityの公式を見ると良いと思います。
↓このへん見ればよさそう?
docs.microsoft.com
↓テッセレーションの解説はKENTOさんの解説がわかりやすい
zenn.dev
using System.Collections; using System.Collections.Generic; using UnityEngine; public class PaintObject : MonoBehaviour { private void OnCollisionStay(Collision collision) { foreach (var collisionContact in collision.contacts) { var ground = collisionContact.otherCollider.GetComponent<GroundPaint>(); if (ground != null) { ground.Paint(collisionContact.point); } } } }
球体に貼り付けているスクリプトです。
衝突を取得。GroundPaint
というスクリプト(後述)が検出されたら、
ContactPoint
を使い座標位置を渡し、書き込み処理を促します。
docs.unity3d.com
(それはそうと ContactPoint
って言うのがあるって今回初めて知りました。
FPSの銃弾が当たったときのパーティクルの出現位置を決めるのに使いやすそうですね!)
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Experimental.TerrainAPI; using UnityEngine.Rendering; public class GroundPaint : MonoBehaviour { // Start is called before the first frame update private const float TOLERANCE = 1E-2f; private Mesh mesh; private int[] meshTriangles; private Vector3[] meshVertices; private Vector2[] meshUV; [SerializeField] private RenderTexture renderTexture; [SerializeField] private Vector3 paintSize; void Start() { MeshFilter meshfil = GetComponent<MeshFilter>(); var mesh1 = meshfil.mesh; meshTriangles = mesh1.triangles; meshVertices = mesh1.vertices; meshUV = mesh1.uv; mesh = new Mesh(); PolyMesh(0.1f,10); } void PolyMesh(float radius, int n) { //verticies List<Vector3> verticiesList = new List<Vector3> { }; float x; float y; for (int i = 0; i < n; i ++) { x = radius * Mathf.Sin((2 * Mathf.PI * i) / n); y = radius * Mathf.Cos((2 * Mathf.PI * i) / n); verticiesList.Add(new Vector3(x, y, 0f)); } Vector3[] verticies = verticiesList.ToArray(); //triangles List<int> trianglesList = new List<int> { }; for(int i = 0; i < (n-2); i++) { trianglesList.Add(0); trianglesList.Add(i+1); trianglesList.Add(i+2); } int[] triangles = trianglesList.ToArray(); //normals List<Vector3> normalsList = new List<Vector3> { }; for (int i = 0; i < verticies.Length; i++) { normalsList.Add(-Vector3.forward); } Vector3[] normals = normalsList.ToArray(); //initialise mesh.vertices = verticies; mesh.triangles = triangles; mesh.normals = normals; } public void Paint(Vector3 worldPos, Camera renderCamera = null) { var material = new Material(Shader.Find("Unlit/Color")); material.SetColor("_Color", Color.red); Vector2 uv; if(renderCamera == null) renderCamera = Camera.main; Vector3 p = transform.InverseTransformPoint(worldPos); Matrix4x4 mvp = renderCamera.projectionMatrix * renderCamera.worldToCameraMatrix * transform.localToWorldMatrix; if (LocalPointToUV(p, mvp, out uv)) { var cmd = new CommandBuffer(); // コマンドバッファを作る cmd.SetViewProjectionMatrices(Matrix4x4.identity, Matrix4x4.identity); // 2Dとして描画する cmd.SetRenderTarget(renderTexture); // RenderTargetを設定 uv = new Vector2(uv.x*-2+1.0f,uv.y*2-1.0f); cmd.DrawMesh(mesh, Matrix4x4.TRS((uv), Quaternion.identity, paintSize), material, 0, 0, default); // 位置、回転、大きさを指定してMeshを描画 Graphics.ExecuteCommandBuffer(cmd); // コマンドバッファを実行 } } /// <summary> /// Convert local-space point to texture coordinates. /// </summary> /// <param name="localPoint">Local-Space Point</param> /// <param name="matrixMVP">World-View-Projection Transformation matrix.</param> /// <param name="uv">UV coordinates after conversion.</param> /// <returns>Whether the conversion was successful.</returns> public bool LocalPointToUV(Vector3 localPoint, Matrix4x4 matrixMVP, out Vector2 uv) { int index0; int index1; int index2; Vector3 t1; Vector3 t2; Vector3 t3; Vector3 p = localPoint; for(var i = 0; i < meshTriangles.Length; i += 3) { index0 = i + 0; index1 = i + 1; index2 = i + 2; t1 = meshVertices[meshTriangles[index0]]; t2 = meshVertices[meshTriangles[index1]]; t3 = meshVertices[meshTriangles[index2]]; if(!ExistPointInPlane(p, t1, t2, t3)) continue; if(!ExistPointOnTriangleEdge(p, t1, t2, t3) && !ExistPointInTriangle(p, t1, t2, t3)) continue; var uv1 = meshUV[meshTriangles[index0]]; var uv2 = meshUV[meshTriangles[index1]]; var uv3 = meshUV[meshTriangles[index2]]; uv = TextureCoordinateCalculation(p, t1, uv1, t2, uv2, t3, uv3, matrixMVP); return true; } uv = default(Vector3); return false; } /// <summary> /// Investigate whether a point exists inside the triangle. /// All points to be entered must be on the same plane. /// </summary> /// <param name="p">Points to investigate.</param> /// <param name="t1">Vertex of triangle.</param> /// <param name="t2">Vertex of triangle.</param> /// <param name="t3">Vertex of triangle.</param> /// <returns>Whether the point exists inside the triangle.</returns> public static bool ExistPointInTriangle(Vector3 p, Vector3 t1, Vector3 t2, Vector3 t3) { var a = Vector3.Cross(t1 - t3, p - t1).normalized; var b = Vector3.Cross(t2 - t1, p - t2).normalized; var c = Vector3.Cross(t3 - t2, p - t3).normalized; var d_ab = Vector3.Dot(a, b); var d_bc = Vector3.Dot(b, c); if(1 - TOLERANCE < d_ab && 1 - TOLERANCE < d_bc) return true; return false; } /// <summary> /// Investigate whether a point exists on a side of a triangle. /// </summary> /// <param name="p">Points to investigate.</param> /// <param name="t1">Vertex of triangle.</param> /// <param name="t2">Vertex of triangle.</param> /// <param name="t3">Vertex of triangle.</param> /// <returns>Whether points lie on the sides of the triangle.</returns> public static bool ExistPointOnTriangleEdge(Vector3 p, Vector3 t1, Vector3 t2, Vector3 t3) { if(ExistPointOnEdge(p, t1, t2) || ExistPointOnEdge(p, t2, t3) || ExistPointOnEdge(p, t3, t1)) return true; return false; } /// <summary> /// Investigate whether a point exists on an edge. /// </summary> /// <param name="p">Points to investigate.</param> /// <param name="v1">Edge forming point.</param> /// <param name="v2">Edge forming point.</param> /// <returns>Whether a point exists on an edge.</returns> public static bool ExistPointOnEdge(Vector3 p, Vector3 v1, Vector3 v2) { return 1 - TOLERANCE < Vector3.Dot((v2 - p).normalized, (v2 - v1).normalized); } /// <summary> /// Determine if there are points in the plane. /// </summary> /// <param name="p">Points to investigate.</param> /// <param name="t1">Plane point.</param> /// <param name="t2">Plane point.</param> /// <param name="t3">Plane point.</param> /// <returns>Whether points exist in the triangle plane.</returns> public static bool ExistPointInPlane(Vector3 p, Vector3 t1, Vector3 t2, Vector3 t3) { var v1 = t2 - t1; var v2 = t3 - t1; var vp = p - t1; var nv = Vector3.Cross(v1, v2); var val = Vector3.Dot(nv.normalized, vp.normalized); if(-TOLERANCE < val && val < TOLERANCE) return true; return false; } /// <summary> /// Calculate UV coordinates within a triangle of points. /// The point to be investigated needs to be a point inside the triangle. /// </summary> /// <param name="p">Points to investigate.</param> /// <param name="t1">Vertex of triangle.</param> /// <param name="t1UV">UV coordinates of t1.</param> /// <param name="t2">Vertex of triangle.</param> /// <param name="t2UV">UV coordinates of t2.</param> /// <param name="t3">Vertex of triangle.</param> /// <param name="t3UV">UV coordinates of t3.</param> /// <param name="transformMatrix">MVP transformation matrix.</param> /// <returns>UV coordinates of the point to be investigated.</returns> public static Vector2 TextureCoordinateCalculation(Vector3 p, Vector3 t1, Vector2 t1UV, Vector3 t2, Vector2 t2UV, Vector3 t3, Vector2 t3UV, Matrix4x4 transformMatrix) { Vector4 p1_p = transformMatrix * new Vector4(t1.x, t1.y, t1.z, 1); Vector4 p2_p = transformMatrix * new Vector4(t2.x, t2.y, t2.z, 1); Vector4 p3_p = transformMatrix * new Vector4(t3.x, t3.y, t3.z, 1); Vector4 p_p = transformMatrix * new Vector4(p.x, p.y, p.z, 1); Vector2 p1_n = new Vector2(p1_p.x, p1_p.y) / p1_p.w; Vector2 p2_n = new Vector2(p2_p.x, p2_p.y) / p2_p.w; Vector2 p3_n = new Vector2(p3_p.x, p3_p.y) / p3_p.w; Vector2 p_n = new Vector2(p_p.x, p_p.y) / p_p.w; var s = 0.5f * ((p2_n.x - p1_n.x) * (p3_n.y - p1_n.y) - (p2_n.y - p1_n.y) * (p3_n.x - p1_n.x)); var s1 = 0.5f * ((p3_n.x - p_n.x) * (p1_n.y - p_n.y) - (p3_n.y - p_n.y) * (p1_n.x - p_n.x)); var s2 = 0.5f * ((p1_n.x - p_n.x) * (p2_n.y - p_n.y) - (p1_n.y - p_n.y) * (p2_n.x - p_n.x)); var u = s1 / s; var v = s2 / s; var w = 1 / ((1 - u - v) * 1 / p1_p.w + u * 1 / p2_p.w + v * 1 / p3_p.w); return w * ((1 - u - v) * t1UV / p1_p.w + u * t2UV / p2_p.w + v * t3UV / p3_p.w); } }
床のオブジェクトに貼り付けるスクリプトです。
ESさんのコードをココで拝借しています。
そのせいで私が完全理解していない部分がありますが、
衝突位置からUV座標を検出するプログラムの詳細についてはコチラ↓を参考にすると良さそうです。
qiita.com
今回は詳細に理解していなかったせいで uv = new Vector2(uv.x*-2+1.0f,uv.y*2-1.0f);
とちょっと荒っぽく座標位置の変換を実装しました。
本来であれば、 TextureCoordinateCalculation
の処理を書き換えたり、 SetViewMatrix
にて変換を行うのが正しいのだと思いますが、私の勉強不足で分からなかったので、この様に実装しました。
(MVP行列の勉強とかしたのに結局分からなかった)
後、使用するレンダーテクスチャーのフォーマットは AUTO_Depth
を指定しないとうまく動かないと思います。注意。
とりあえず、実装については以上になります。
参考記事等
参考にした資料を貼っておきます。
他にもありましたが、覚えていないです。
Unityのシェーダーについて、もっと深く知りたい人へ
今回勉強をしてみて痛感したのはUnityのシェーダー自身だけでなく、ともかくHLSL、DX(DirectX)についての理解が少なかったという痛感です。
UnityのシェーダーはHLSL、DXベースで作られており、URPを書く上では生のHLSLやDXを理解していたほうが有利だと感じました。
私はまだ読んでないですが↓といった書籍を読むと理解が深まりそうなので参考までに貼っておきます。
DirectX 12の魔導書 3Dレンダリングの基礎からMMDモデルを踊らせるまで https://www.amazon.co.jp/dp/B082WY8HDH/
HLSL シェーダーの魔導書 シェーディングの基礎からレイトレーシングまで https://www.amazon.co.jp/dp/B09371QYXS/
まとめ
以上、【Unity】URPで頂点テクスチャフェッチ(VTF:Vertex Texture Fetching)に触れるでした。
実は言うと、最近技術ブログ書けてないなと思い突拍子もなく勉強して記事化しました。
結果としては勉強不足を痛感することになり、中途半端な実装になってしまい、申し訳ないです。
(影については途中に貼った https://www.patreon.com/posts/47452596 を参考にすればいい感じに出るんじゃないかなぁとは思いますが)
今回に関しては勘弁してください。
Unityのシェーダーについてはコレまでフラグメントシェーダーを少々イジったことがある程度で、ちゃんと触ったことがなかったので今の私にはコレが限界でした。
まぁ、今回VTFを勉強したことで、Unityのシェーダーの理解がかなり深まったように感じます。
暇があれば今度は水面シェーダーや視差マッピングなどに取り組みたいと思います。(本当にやるかは分からないですが……)
最後に、記事を最後まで読んでくれた方に感謝いたします。
後、色々Unityゲーム開発者ギルドにて教えてくださった方々に感謝。(主に、のたぐすさん)
以上、やまだたいしでした。