はじめに
調べても実装方法が出てこなかったので自分で考えながら、ポストエフェクトとして実装します。あまり精査していないので最適化出来ていませんが、色々勉強できたのでメモとして残しておきます。他の方にも試していただいて、よりよい実装方法を知りたいです。
鉛筆画像を準備
手書きイラスト風のタイリングテクスチャを用意しました。濃度によって3種類用意しています。以下のサイトからダウンロードして使わせていただきました。
ポストエフェクト用スクリプト
ポストエフェクト用にカメラから必要なデータを送ります。
[ExecuteInEditMode, RequireComponent(typeof(Camera))] public class NormalTest : MonoBehaviour { [SerializeField] Material m_Material; Camera m_Camera; private void Awake() { m_Camera = GetComponent<Camera>(); } void Start() { // DepthとDepthNormalsを描画する m_Camera.depthTextureMode = DepthTextureMode.Depth | DepthTextureMode.DepthNormals; } private void OnRenderImage(RenderTexture src, RenderTexture dest) { // uvとdepthからワールド座標に戻すマトリックス var v = m_Camera.worldToCameraMatrix; var p = GL.GetGPUProjectionMatrix(m_Camera.projectionMatrix, true); var ivp = (p * v).inverse; m_Material.SetMatrix("_I_VP", ivp); // カメラ座標からワールド座標に戻すマトリックス m_Material.SetMatrix("_ViewToWorld", m_Camera.cameraToWorldMatrix); Graphics.Blit(src, dest, m_Material); } }
note
// DepthとDepthNormalsを描画する m_Camera.depthTextureMode = DepthTextureMode.Depth | DepthTextureMode.DepthNormals;
DepthやNormalsがあると使えそうなのでTextureとして取得できるようにしています。
// uvとdepthからワールド座標に戻すマトリックス var v = m_Camera.worldToCameraMatrix; var p = GL.GetGPUProjectionMatrix(m_Camera.projectionMatrix, true); var ivp = (p * v).inverse; m_Material.SetMatrix("_I_VP", ivp);
ポストエフェクトで参照できる情報からワールド位置を取得するのに使用します。
// カメラ座標からワールド座標に戻すマトリックス m_Material.SetMatrix("_ViewToWorld", m_Camera.cameraToWorldMatrix);
いろいろワールド座標で扱いたいので、これも渡しておきます。
ポストエフェクト用シェーダー
まずはスクリーン座標で描画結果をグレースケール化し、グレースケールの濃度によって鉛筆画像を適応してみます。
Shader "Hidden/Sketch" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlushTex ("濃いBlush", 2D) = "white" {} _Blush2Tex ("中間Blush", 2D) = "white" {} _Blush3Tex ("薄いBlush", 2D) = "white" {} _Min ("Min", Range(0,1)) = 0 _Max ("Max", Range(0,1)) = 1 } SubShader { // No culling or depth Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _CameraDepthTexture; sampler2D _CameraDepthNormalsTexture; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } sampler2D _MainTex; sampler2D _BlushTex; sampler2D _Blush2Tex; sampler2D _Blush3Tex; // グレースケール上での適応範囲 float _Min; float _Max; // スクリプトから float4x4 _I_VP; float4x4 _ViewToWorld; fixed4 frag (v2f i) : SV_Target { // 描画結果を取得 fixed4 col = tex2D(_MainTex, i.uv); // 描画結果をグレースケールにする col = dot(col.rgb, fixed3(0.299, 0.587, 0.114)); float4 gray = (col - _Min) / (_Max - _Min); // 画面上のuvから各種鉛筆画像を取得する float4 s = tex2D(_BlushTex, i.uv * 3); float4 s2 = tex2D(_Blush2Tex, i.uv * 3); float4 s3 = tex2D(_Blush3Tex, i.uv * 3); // グレースケール値から画像を選択 s = lerp(s, s2, saturate(gray * 3)); s = lerp(s, s3, saturate(gray * 3 - 1)); s = lerp(s, 1, saturate(gray * 3 - 2)); // 元のグレースケール色を薄くする col.rgb = lerp(col.rgb, 1, .7); // 元のグレースケール色に鉛筆画像を合成 return col * s; } ENDCG } } }
note
float4 gray = (col - _Min) / (_Max - _Min);
InverseLerpになります。グレースケールにしても0や1の値はあまり出てこないのでmin〜maxの間のグレースケール値に対して適応するようにしています。
// グレースケール値から画像を選択 s = lerp(s, s2, saturate(gray * 3)); s = lerp(s, s3, saturate(gray * 3 - 1)); s = lerp(s, 1, saturate(gray * 3 - 2));
グレースケールの値によって3種類+1種類(鉛筆なし)の鉛筆画像を使いわけるための処理です。もっとよい方法がありそうです。
グレースケールの値によって濃淡のある鉛筆画像を使って描画できました。スクリーン座標なのでカメラが移動しても追従しません。ワールド座標に描画する方法を考えていきます。
ワールド座標で適応するシェーダー
Shader "Hidden/Sketch" { Properties { _MainTex ("Texture", 2D) = "white" {} _BlushTex ("濃いBlush", 2D) = "white" {} _Blush2Tex ("中間Blush", 2D) = "white" {} _Blush3Tex ("薄いBlush", 2D) = "white" {} _Min ("Min", Range(0,1)) = 0 _Max ("Max", Range(0,1)) = 1 } SubShader { // No culling or depth Cull Off ZWrite Off ZTest Always Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; sampler2D _CameraDepthTexture; sampler2D _CameraDepthNormalsTexture; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = v.uv; return o; } sampler2D _MainTex; sampler2D _BlushTex; sampler2D _Blush2Tex; sampler2D _Blush3Tex; // グレースケール上での適応範囲 float _Min; float _Max; // スクリプトから float4x4 _I_VP; float4x4 _ViewToWorld; fixed4 frag (v2f i) : SV_Target { // 描画結果を取得 fixed4 col = tex2D(_MainTex, i.uv); // 描画結果をグレースケールにする col = dot(col.rgb, fixed3(0.299, 0.587, 0.114)); // グレースケールの内エフェクトを適応する範囲を指定する float4 gray = (col - _Min) / (_Max - _Min); // DepthNormalsTextureからdepthとnormalを取り出す処理 float3 normal; float depth; DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal); // 上記depthはrawな値じゃなさそうなのでこっちを使う float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); // ワールド法線にする float3 wNormal = mul(_ViewToWorld, normal); // 平面が正面を向いてる時に1になる float d = dot(wNormal, float3(0, 0 , -1.0)); // uvとdepthからワールド座標に戻す float4 pPos = float4(i.uv * 2.0 - 1.0, rawDepth, 1.0); // 座標系を合わせる #if UNITY_UV_STARTS_AT_TOP pPos.y = -pPos.y; #endif float4 tmp = mul(_I_VP, pPos); float3 wpos = tmp.xyz / tmp.w; // depthをカメラからの距離にする float vDepth = LinearEyeDepth(rawDepth); // 遠くのものには適応しない gray = lerp(gray, 1, smoothstep(0, 100, vDepth)); // 濃い鉛筆画像や薄い鉛筆画像を取得 float4 s = tex2D(_BlushTex, float2(length(wpos.xy), length(wpos.zy))); float4 s2 = tex2D(_Blush2Tex, float2(length(wpos.xy), length(wpos.zy))); float4 s3 = tex2D(_Blush3Tex, float2(length(wpos.xy), length(wpos.zy))); // float4 s = tex2D(_BlushTex, i.uv * 3); // float4 s2 = tex2D(_Blush2Tex, i.uv * 3); // float4 s3 = tex2D(_Blush3Tex, i.uv * 3); // グレースケール値から画像を選択 s = lerp(s, s2, saturate(gray * 3)); s = lerp(s, s3, saturate(gray * 3 - 1)); s = lerp(s, 1, saturate(gray * 3 - 2)); // 元の色を薄くする col.rgb = lerp(col.rgb, 1, .7); // 元の色に鉛筆画像を合成 return col * s; } ENDCG } } }
note
// DepthNormalsTextureからdepthとnormalを取り出す処理 float3 normal; float depth; DecodeDepthNormal(tex2D(_CameraDepthNormalsTexture, i.uv), depth, normal);
DepthNormalsTextureからnormalとdepthを分離して取得しています。
// 上記depthはrawな値じゃなさそうなのでこっちを使う float rawDepth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
DepthTextureは非線形らしいです。上記から得られるdepthは線形に補正されてる?っぽく生のdepthではなかったので別途取得しています。
// ワールド法線にする float3 wNormal = mul(_ViewToWorld, normal);
今回は使用しなかったのですがワールド座標での法線を取得しています。渡ってくる法線はカメラ座標での法線なのでワールド座標に変換しています。
// 平面が正面を向いてる時に1になる float d = dot(wNormal, float3(0, 0 , -1.0));
使えそうなので取得しましたが今回は使っていません。
// uvとdepthからワールド座標に戻す float4 pPos = float4(i.uv * 2.0 - 1.0, rawDepth, 1.0); // 座標系を合わせる #if UNITY_UV_STARTS_AT_TOP pPos.y = -pPos.y; #endif float4 tmp = mul(_I_VP, pPos); float3 wpos = tmp.xyz / tmp.w;
uvとdepthがあれば「UNITY_MATRIX_VP」の逆行列を使って、そのピクセルのワールド座標に戻せるようです。そのための計算です。
// depthをカメラからの距離にする float vDepth = LinearEyeDepth(rawDepth);
depthをカメラから見た距離に変換しています。
// 濃い鉛筆画像や薄い鉛筆画像を取得 float4 s = tex2D(_BlushTex, float2(length(wpos.xy), length(wpos.zy))); float4 s2 = tex2D(_Blush2Tex, float2(length(wpos.xy), length(wpos.zy))); float4 s3 = tex2D(_Blush3Tex, float2(length(wpos.xy), length(wpos.zy)));
ポストエフェクトなので各メッシュのuvが使えません。上記で求めた放線を使えばいい感じにuvを指定出来そうですが、うまくいかなかったのでこんな感じでなんとなく取得しました。xz平面上に貼り付けつつyも考慮するといったイメージです。
ワールド座標を元に鉛筆画像を貼り付けたので、カメラが動いても追従してくれるようになりました。
おわり
探せばそれっぽいアルゴリズムがありそうですが、今回は自分で考えて実装してみました。楽しかったです。
参考にさせていただいた記事
感謝です。