3Dモデルに徐々にひびを入れる表現ができないかと考え、シェーダーで挑戦してみました。
テクスチャの準備が不要なものを目指しました。
その試行錯誤の過程を残しておきます。
シリーズ目次
どうやってひびを描くか
シェーダーでひび模様を描きたいので、それっぽい図になる計算式を探します。
ボロノイパターンというものが使えそうです。
ひび模様を始め、きりんの模様だとか葉脈など、近い模様が自然界に複数存在するのだそうです。面白いですね。
シェーダーでのボロノイパターンの描き方はこちらが参考になります。
境界線の描き方
上記のコードで色分けしたパターンを描くことはできますが、今回は境界線を引いてひびに見立てたいです。
境界線の引き方はこちらが参考になりました。
これを3Dで行って立体的なボロノイパターンを描きます。
大まかに以下の流れとなります。
- 座標空間を任意の単位でセル分割する。
- 各セルに1つずつランダムな点を設定する。
- 各セルごとに、隣接する26個(9 * 3 - 1)のセル内のランダム点との、垂直二等分線上にひび色を塗る。
これをフラグメントシェーダーで行うので、以下のように処理を行います。
- オブジェクト座標を整数部分と小数部分に分ける。
- 整数部分の値をXYZそれぞれ-1, +0, +1加算したセル内のランダム点との距離を比較し、最も近い点と2番目に近い点を見付ける。
- ランダム点の座標は、各セルのXYZの整数値から疑似乱数を生成して設定する。
- 以下のベクトルの内積がランダム点同士の垂直二等分線(=ボロノイパターンの境界線)までの距離となる。
- オブジェクト座標から最も近いランダム点と2番目に近い点の中点に向かうベクトル
- 最も近いランダム点から2番目に近い点へのベクトルの単位ベクトル
- 距離が任意の値以下の場合のみ色を塗るようにすると、任意の太さの境界線を描くことが可能となる。
指定した座標がボロノイパターンの境界線に該当するかを算出するコード
/**
* 指定した座標に対して、ボロノイパターンの最も近いランダム点と、2番目に近いランダム点を取得する
*/
void CreateVoronoi(float3 pos, out float3 closest, out float3 secondClosest) {
// セル番号が負の値とならないようにオフセット加算
const uint offset = 100;
uint3 cellIdx;
float3 reminders = modf(pos + offset, cellIdx);
// 対象地点が所属するセルと隣接するセル全てに対してランダム点との距離をチェックし
// 1番近い点と2番目に近い点を見付ける
float2 closestDistances = 8.0;
[unroll]
for(int i = -1; i <= 1; i++)
[unroll]
for(int j = -1; j <= 1; j++)
[unroll]
for(int k = -1; k <= 1; k++) {
int3 neighborIdx = int3(i, j, k);
// そのセル内でのランダム点の相対位置を取得
float3 randomPos = Random3(cellIdx + neighborIdx, _RandomSeed);
// 対象地点からランダム点に向かうベクトル
float3 vec = randomPos + float3(neighborIdx) - reminders;
// 距離は全て二乗で比較
float distance = dot(vec, vec);
if (distance < closestDistances.x) {
closestDistances.y = closestDistances.x;
closestDistances.x = distance;
secondClosest = closest;
closest = vec;
} else if (distance < closestDistances.y) {
closestDistances.y = distance;
secondClosest = vec;
}
}
}
/**
* 指定した座標がボロノイ図の境界線となるかどうかを0~1で返す
*/
float GetVoronoiBorder(float3 pos) {
float3 closest, secondClosest;
CreateVoronoi(pos, closest, secondClosest);
/*
* 以下のベクトルの内積が境界線までの距離となる
* ・対象地点から、1番近いランダム点と2番目に近い点の中点に向かうベクトル
* ・1番近い点から2番目に近い点に向かうベクトルの単位ベクトル
*/
float distance = dot(0.5 * (closest + secondClosest), normalize(secondCLosest - closest));
return 1.0 - saturate(distance - _CrackWidth);
}
ランダム地点の算出
疑似乱数の生成アルゴリズムは処理が軽いXorshiftを使用しました。
3次元のセルインデックスから疑似乱数を生成し、それを座標データにマッピングすることでランダム地点を求めます。
- 3次元の整数を1つの整数にマッピング
- 1.で算出した整数にXorshiftをかけて疑似乱数生成
- 整数の疑似乱数を各要素0〜1となる3次元ベクトルにマッピング
3次元ベクトルでのランダム値算出アルゴリズムがパッと見付からなかったので独自処理になっています。
規則的な模様にならないようにある程度工夫しましたが、より適切なやり方があるかもしれません。
疑似乱数を用いてランダム地点を算出するコード
/**
* Xorshift32を用いて32bitの擬似乱数を生成する
*/
uint XOrShift32(uint value) {
value = value ^ (value << 13);
value = value ^ (value >> 17);
value = value ^ (value << 5);
return value;
}
/**
* 整数の値を1未満の小数にマッピングする
*/
float MapToFloat(uint value) {
const float precion = 100000000.0;
return (value % precion) * rcp(precion);
}
/**
* 3次元のランダムな値を算出する
*/
float3 Random3(uint3 src, int seed) {
uint3 random;
random.x = XOrShift32(mad(src.x, src.y, src.z));
random.y = XOrShift32(mad(random.x, src.z, src.x) + seed);
random.z = XOrShift32(mad(random.y, src.x, src.y) + seed);
random.x = XOrShift32(mad(random.z, src.y, src.z) + seed);
return float3(MapToFloat(random.x), MapToFloat(random.y), MapToFloat(random.z));
}
ボロノイパターン描画
0〜1でボロノイパターンの境界線からの近さを取得できるようになったので、これが任意の値以上の場合のみ色を付けるようにすると、3Dでボロノイパターンの境界線を描くことができます。
部分的にひびを消す
徐々にひびが入っていくようにしたいので、ひび具合に応じてボロノイパターンを描かないようにします。
ノイズテクスチャを読み込んで部分的に境界線を描かないようにするときれいにできそうな気がしますが、今回はテクスチャ無しでいきます。
新たに計算でノイズを生成すると処理負荷が上がってしまうので、ボロノイパターン生成時の計算を利用しました。
ボロノイパターンの境界線算出時に各座標から2番目に近いランダム点がわかるので、その距離に応じて境界線を描かないようにします。
一応コードを載せますが、現物合わせで作ったものなので何かのアルゴリズムに基づいたものではありません。
2番目に近いランダム点までの距離を用いて部分的に境界線を書かないようにするコード
void CreateVoronoi(float3 pos, out float3 closest, out float3 secondClosest, out float secondDistance) {
// 中略
secondDistance = closestDistances.y;
}
/**
* 指定した座標のひび度合いを0~1で返す
*/
float GetCrackLevel(float3 pos) {
if (_CrackProgress == 0) {
return 0.0;
}
// ボロノイ図の境界線で擬似的なクラック模様を表現
float secondDistance;
float level = GetVoronoiBorder(pos, secondDistance);
/*
* 部分的にひびを消すためにノイズを追加
* 計算量が少なくて済むようにボロノイのF2(2番目に近い点との距離)を利用する
* 距離が一定値以下の場合はひび対象から外す
*/
float f2Factor = 1.0 - sin(_CrackProgress * PI * 0.5);
float minTh = (2.9 * f2Factor);
float maxTh = (3.5 * f2Factor);
float factor = smoothstep(minTh, maxTh, secondDistance * 2.0);
level *= factor;
return level;
}
_CrackProgress
の値に応じてひびが入るようになったので、スクリプトからこれを変化させると徐々にひびが入っていく動きが作れます。
_CrackProgress = 0.4
_CrackProgress = 0.5
_CrackProgress = 0.6
ひび模様のサイズを可変にする
オブジェクト座標でボロノイパターンのセルを区切っていますが、モデルによってはそれだと模様が大きすぎたりして思った表現になりません。
ひびのサイズをシェーダープロパティで指定できると便利です。
ボロノイパターン計算用の座標に対して任意の値を乗算して縮小サイズで図を描けるようにしました。
/**
* 指定した座標のひび度合いを0~1で返す
*/
float GetCrackLevel(float3 pos, float3 worldNormal) {
// 中略
// ボロノイ図の境界線で擬似的なひび模様を表現
float secondDistance;
float level = GetVoronoiBorder(pos * _CrackDetailedness, secondDistance);
// 省略
}
_CrackDetailedness = 1.5
_CrackDetailedness = 4.0
陰影をつける
ボロノイパターンの境界線を濃い色で描くだけでも簡易なひび表現にはなりますが、ひびが入っている部分は実際には凹んでいる筈なので、立体感がほしいところです。
立体感を出すためには影が必要です。
壁を作る
境界線とそうでない部分の境目に任意の幅の壁部分を設定できるようにします。
float GetVoronoiBorder(float3 pos, out float secondDistance) {
// 中略
return 1.0 - smoothstep(_CrackWidth, _CrackWidth + _CrackWallWidth, distance);
}
法線を整える
ひび部分は凹んでいると見なして法線を計算し直します。
- ひびレベルに応じてオブジェクト座標を法線の逆方向に移動
- 移動後の座標で法線を簡易計算
- 偏微分を用いて隣接ピクセルとの座標の差分を、画面上の水平、垂直方向それぞれに対して求める
- 上記の2つのベクトルで接平面を表せるので、この2つのベクトルの外戚が法線となる
ひびの陰影をつけるコード
/**
* ひびが入った後の座標を計算する
*/
float3 CalcCrackedPos(float3 localPos, float3 localNormal, out float crackLevel) {
// ひび対象の場合は法線と逆方向に凹ませる
crackLevel = GetCrackLevel(localPos);
float depth = crackLevel * _CrackDepth;
localPos -= localNormal * depth;
return localPos;
}
/**
* フラグメントシェーダー
*/
half4 Frag(v2f input) : SV_Target {
float crackLevel = 0.0;
input.positionOS
= (_CrackProgress == 0 || dot(input.normalWS, GetViewForwardDir()) > 0.5)
? input.positionOS
: CalcCrackedPos(input.positionOS, input.normalOS, crackLevel);
Varyings varyings = (Varyings)0;
varyings.uv = input.uv;
varyings.positionWS = crackLevel > 0.0 ? TransformObjectToWorld(input.positionOS) : input.positionWS;
varyings.positionCS = crackLevel > 0.0 ? TransformObjectToHClip(input.positionOS) : input.positionCS;
// 隣接のピクセルとのワールド座標の差分を取得後に外積を求めて法線算出
varyings.normalWS = crackLevel > 0.0 ? normalize(cross(ddy(varyings.positionWS), ddx(varyings.positionWS))) : input.normalWS;
SurfaceData surfaceData;
InitializeStandardLitSurfaceData(varyings.uv, surfaceData);
OUTPUT_SH(varyings.normalWS, varyings.vertexSH);
InputData inputData;
InitializeInputData(varyings, surfaceData.normalTS, inputData);
inputData.vertexLighting = VertexLighting(varyings.positionWS, inputData.normalWS);
/* ひび模様 */
// ひび対象の場合はひびの色を追加
surfaceData.albedo = lerp(surfaceData.albedo, _CrackColor.rgb, crackLevel);
half4 color = UniversalFragmentPBR(inputData, surfaceData);
return color;
}
ライティングの処理はLitシェーダーで使用している関数を呼ぶようにしています。
Occlusion設定
シーンの環境によってはOcclusionを設定するとより陰影がはっきりするかもしれません。
どの程度設定するかはお好みでどうぞ。
ひび部分にOcclusionを設定するコード例
/**
* CrackLevelに応じたOcclusionを算出する
*/
half CalcOcclusion(float crackLevel) {
// ひびの深さに応じて影を濃くする
half occlusion = pow(lerp(1.0, 0.9, crackLevel), 2.0);
// ひびが深い部分で、隣接ピクセルの高低差が大きい場合は影を濃くする
occlusion *= (crackLevel > 0.95 ? lerp(0.9, 1.0, 1.0 - smoothstep(0.0, 0.1, max(abs(ddy(crackLevel)), abs(ddx(crackLevel))))) : 1.0);
return occlusion;
}
/**
* フラグメントシェーダー
*/
half4 Frag(v2f input) : SV_Target {
// 中略
// ひび部分はAO設定
surfaceData.occlusion = min(surfaceData.occlusion, CalcOcclusion(crackLevel));
half4 color = UniversalFragmentPBR(inputData, surfaceData);
return color;
}
陰影追加後
こんな感じになりました。
やっぱり陰影がつくとぐっと立体感が出てきますね。
ひび模様のバリエーションを増やす
同じオブジェクトが並んでいるときに全部同じひび模様が入っているとちょっと格好悪いです。
折角テクスチャではなく計算でひびを入れているので、それぞれひび模様を変えたいところです。
ランダム関数にSeedを入れられるようにしたので、これをスクリプトから設定するようにすると、GameObjectごとにひび模様を変えることができます。
コード全文
Smoothness, Metallicの値も設定できるようにしたシェーダー全文です。
ライティングはLitシェーダーの関数を利用しています。
ひび入れシェーダー(フラグメントシェーダー編)コード全文
Shader "Custom/Crack" {
Properties {
[Header(Albedo)]
[MainColor] _BaseColor("Base Color", Color) = (1.0, 1.0, 1.0, 1.0)
[MainTexture] _BaseMap("Base Map", 2D) = "white" {}
[Header(Smoothness and Metallic)]
_Smoothness("Smoothness", Range(0.0, 1.0)) = 0.0
_Metallic("Metallic", Range(0.0, 1.0)) = 0.0
[Header(Crack)]
_CrackProgress("クラック進行具合", Range(0.0, 1.0)) = 0.0
[HDR] _CrackColor("クラック色", Color) = (0.0, 0.0, 0.0, 1.0)
_CrackDetailedness("クラック模様の細かさ", Range(0.0, 8.0)) = 3.0
_CrackDepth("クラックの深さ", Range(0.0, 1.0)) = 0.5
_CrackWidth("クラックの幅", Range(0.01, 0.1)) = 0.05
_CrackWallWidth("クラックの壁部分の幅", Range(0.001, 0.2)) = 0.08
[Space]
_RandomSeed("クラック模様のランダムシード(非負整数のみ可)", Int) = 0
}
SubShader {
Tags {
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"UniversalMaterialType" = "Lit"
}
LOD 300
HLSLINCLUDE
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
ENDHLSL
Pass {
Name "Crack"
Tags { "LightMode" = "UniversalForward" }
HLSLPROGRAM
// -------------------------------------
// Material Keywords
#pragma shader_feature_local_fragment _ALPHATEST_ON
#pragma shader_feature_local_fragment _ALPHAPREMULTIPLY_ON
#pragma shader_feature_local_fragment _SPECULARHIGHLIGHTS_OFF
#pragma shader_feature_local_fragment _ENVIRONMENTREFLECTIONS_OFF
#pragma shader_feature_local_fragment _SPECULAR_SETUP
#pragma shader_feature_local _RECEIVE_SHADOWS_OFF
// -------------------------------------
// Universal Pipeline keywords
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS
#pragma multi_compile _ _MAIN_LIGHT_SHADOWS_CASCADE
#pragma multi_compile _ _ADDITIONAL_LIGHTS_VERTEX _ADDITIONAL_LIGHTS
#pragma multi_compile_fragment _ _ADDITIONAL_LIGHT_SHADOWS
#pragma multi_compile_fragment _ _SHADOWS_SOFT
//--------------------------------------
// GPU Instancing
#pragma multi_compile_instancing
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
#include "Packages/com.unity.render-pipelines.universal/Shaders/LitForwardPass.hlsl"
#pragma vertex Vert
#pragma fragment Frag
// ---------------------------------------------------------------------------------------
// 変数宣言
// ---------------------------------------------------------------------------------------
float _CrackProgress;
half4 _CrackColor;
float _CrackDetailedness;
float _CrackDepth;
float _CrackWidth;
float _CrackWallWidth;
uint _RandomSeed;
// ---------------------------------------------------------------------------------------
// 構造体
// ---------------------------------------------------------------------------------------
struct v2f {
float4 positionCS : SV_POSITION;
float2 uv : TEXCOORD0;
float3 normalOS: NORMAL;
float3 normalWS: TEXCOORD1;
float3 positionOS: TEXCOORD2;
float3 positionWS: TEXCOORD3;
float3 viewDirWS: TEXCOORD4;
};
// ---------------------------------------------------------------------------------------
// メソッド
// ---------------------------------------------------------------------------------------
/**
* Xorshift32を用いて32bitの擬似乱数を生成する
*/
uint Xorshift32(uint value) {
value = value ^ (value << 13);
value = value ^ (value >> 17);
value = value ^ (value << 5);
return value;
}
/**
* 整数の値を1未満の小数にマッピングする
*/
float ToFloat(uint value) {
const float precion = 100000000.0;
return (value % precion) * rcp(precion);
}
/**
* 3次元のランダムな値を算出する
*/
float3 Random3(uint3 src, int seed) {
uint3 random;
random.x = Xorshift32(mad(src.x, src.y, src.z));
random.y = Xorshift32(mad(random.x, src.z, src.x) + seed);
random.z = Xorshift32(mad(random.y, src.x, src.y) + seed);
random.x = Xorshift32(mad(random.z, src.y, src.z) + seed);
return float3(ToFloat(random.x), ToFloat(random.y), ToFloat(random.z));
}
/**
* 指定した座標に対して、ボロノイパターンの最も近いランダム点と、2番目に近いランダム点を取得する
*/
void CreateVoronoi(float3 pos, out float3 closest, out float3 secondClosest, out float secondDistance) {
// セル番号が負の値とならないようにオフセット加算
const uint offset = 100;
uint3 cellIdx;
float3 reminders = modf(pos + offset, cellIdx);
// 対象地点が所属するセルと隣接するセル全てに対してランダム点との距離をチェックし
// 1番近い点と2番目に近い点を見付ける
float2 closestDistances = 8.0;
[unroll]
for(int i = -1; i <= 1; i++)
[unroll]
for(int j = -1; j <= 1; j++)
[unroll]
for(int k = -1; k <= 1; k++) {
int3 neighborIdx = int3(i, j, k);
// そのセル内でのランダム点の相対位置を取得
float3 randomPos = Random3(cellIdx + neighborIdx, _RandomSeed);
// 対象地点からランダム点に向かうベクトル
float3 vec = randomPos + float3(neighborIdx) - reminders;
// 距離は全て二乗で比較
float distance = dot(vec, vec);
if (distance < closestDistances.x) {
closestDistances.y = closestDistances.x;
closestDistances.x = distance;
secondClosest = closest;
closest = vec;
} else if (distance < closestDistances.y) {
closestDistances.y = distance;
secondClosest = vec;
}
}
secondDistance = closestDistances.y;
}
/**
* 指定した座標がボロノイ図の境界線となるかどうかを0~1で返す
*/
float GetVoronoiBorder(float3 pos, out float secondDistance) {
float3 a, b;
CreateVoronoi(pos, a, b, secondDistance);
/*
* 以下のベクトルの内積が境界線までの距離となる
* ・対象地点から、1番近いランダム点と2番目に近い点の中点に向かうベクトル
* ・1番近い点と2番目に近い点を結ぶ線の単位ベクトル
*/
float distance = dot(0.5 * (a + b), normalize(b - a));
return 1.0 - smoothstep(_CrackWidth, _CrackWidth + _CrackWallWidth, distance);
}
/**
* 指定した座標のひび度合いを0~1で返す
*/
float GetCrackLevel(float3 pos) {
// ボロノイ図の境界線で擬似的なクラック模様を表現
float secondDistance;
float level = GetVoronoiBorder(pos * _CrackDetailedness, secondDistance);
/*
* 部分的にひびを消すためにノイズを追加
* 計算量が少なくて済むようにボロノイのF2(2番目に近い点との距離)を利用する
* 距離が一定値以下の場合はクラック対象から外す
*/
float f2Factor = 1.0 - sin(_CrackProgress * PI * 0.5);
float minTh = (2.9 * f2Factor);
float maxTh = (3.5 * f2Factor);
float factor = smoothstep(minTh, maxTh, secondDistance * 2.0);
level *= factor;
return level;
}
/**
* ひびが入った後の座標を計算する
*/
float3 CalcCrackedPos(float3 localPos, float3 localNormal, out float crackLevel) {
// ひび対象の場合は法線と逆方向に凹ませる
crackLevel = GetCrackLevel(localPos);
float depth = crackLevel * _CrackDepth;
localPos -= localNormal * depth;
return localPos;
}
/**
* CrackLevelに応じたOcclusionを算出する
*/
half CalcOcclusion(float crackLevel) {
// ひびの深さに応じて影を濃くする
half occlusion = pow(lerp(1.0, 0.9, crackLevel), 2.0);
// ひびが深い部分で、隣接ピクセルの高低差が大きい場合は影を濃くする
occlusion *= (crackLevel > 0.95 ? lerp(0.9, 1.0, 1.0 - smoothstep(0.0, 0.1, max(abs(ddy(crackLevel)), abs(ddx(crackLevel))))) : 1.0);
return occlusion;
}
// ---------------------------------------------------------------------------------------
// シェーダー関数
// ---------------------------------------------------------------------------------------
/**
* 頂点シェーダー
*/
v2f Vert(Attributes input) {
v2f output;
output.positionOS = input.positionOS.xyz;
output.normalOS = input.normalOS;
Varyings varyings = LitPassVertex(input);
output.positionCS = varyings.positionCS;
output.uv = varyings.uv;
output.positionWS = varyings.positionWS;
output.normalWS = varyings.normalWS;
return output;
}
/**
* フラグメントシェーダー
*/
half4 Frag(v2f input) : SV_Target {
float crackLevel = 0.0;
input.positionOS
= (_CrackProgress == 0 || dot(input.normalWS, GetViewForwardDir()) > 0.5)
? input.positionOS
: CalcCrackedPos(input.positionOS, input.normalOS, crackLevel);
Varyings varyings = (Varyings)0;
varyings.uv = input.uv;
varyings.positionWS = crackLevel > 0.0 ? TransformObjectToWorld(input.positionOS) : input.positionWS;
varyings.positionCS = crackLevel > 0.0 ? TransformObjectToHClip(input.positionOS) : input.positionCS;
// 隣接のピクセルとのワールド座標の差分を取得後に外積を求めて法線算出
varyings.normalWS = crackLevel > 0.0 ? normalize(cross(ddy(varyings.positionWS), ddx(varyings.positionWS))) : input.normalWS;
SurfaceData surfaceData;
InitializeStandardLitSurfaceData(varyings.uv, surfaceData);
OUTPUT_SH(varyings.normalWS, varyings.vertexSH);
InputData inputData;
InitializeInputData(varyings, surfaceData.normalTS, inputData);
inputData.vertexLighting = VertexLighting(varyings.positionWS, inputData.normalWS);
/* ひび模様 */
// ひび対象の場合はクラックカラーを追加
surfaceData.albedo = lerp(surfaceData.albedo, _CrackColor.rgb, crackLevel);
// ひび部分はAO設定
surfaceData.occlusion = min(surfaceData.occlusion, CalcOcclusion(crackLevel));
half4 color = UniversalFragmentPBR(inputData, surfaceData);
return color;
}
ENDHLSL
}
}
FallBack "Universal Render Pipeline/Lit"
}
更にひびっぽさを出すには
ここではフラグメントシェーダーのみでひびを描きました。
更にモデル変形も行ったらよりひびっぽさが出るのかも?ということで次に続きます。