四次元

たましいホーム

0%

NiagaraSystemでマジックボールを作る

 今日はUE5のNiagaraSystemを使って三つのマジックボールを作った。作る時のキーポイントを記録したいと思う。


「1」エレクトリックボール(Electric Ball)

 このボールは、主に二つのパーツで組み合わせて完成された。一つ目は、外のボールの殻で、二つ目は、中の雷の渦巻きである。  

ボールの殻を作る

 まず、マテリアルの基本設定をし、それは「BlendMode」を「Translucent」にすることと「ShadingMode」を「Unlit」にすることだ。
 殻の外部が色を付けられ、内部が透明にするため、「VertexNormalWS」と「CameraVector」をドット積で計算する。それで、カメラに向いているほど、計算結果が「1」に近い。そして「OneMinus」で、内部が「0」に近い、その数字を透明度にすると、望みの効果が出来る。

 そして、殻が周りの風景を映すため、一つのCubeMapを使った。そのCubeMapをサンプリングするため、Node「Reflection Vector」を使った。そのノードは、カメラから表面までの射線を反射したベクターだ。
 最後に、二つのパーツを組み合わせて完成する。

雷の渦巻きを作る

 
 まず、デフォルトのUVの原点は左上なので、渦巻きを作るため、[UV.xy-(.5, .5)]*nの計算をする。その結果は以下である。

 そして、転換したUVを、「VectorToRadialValue」を使って、極座標に転換する。その極座標を使って、ちょっと乱雑なテキスチャーをサンプリングすると、渦巻っぽいの交換が出る。

 この効果を基にし、透明度とパーティクルカーラーを設定すると完了だ。

NiagaraSystemで組み合わせ

 ここで注意すべきのポイントはいくつある。
 まず、このボールをマップの中で自由に移動するため、これらのEmitterで「Local Space」をチェックする(でないと、一つのパーティクルのライフタイムが終わるまでずっと同じ場所にいる)。

 次は、色を調整する時、HSVの「V」は明度であり、その数値を上げるほど、色の「強さ」も上がる。その「強さ」は、PostEffectでのBloom効果を調整する時に役に立つ。


「2」火球(ファイアボール)

 この火球の効果は主に三つのパーツの組み合わせで完成された。一つ目は真ん中の火炎の球体で、二つ目は球体の後ろにうっすりと点滅している放射線状な効果で、三つ目は周囲にある渦巻のような効果である。

火炎の球体

 
 火炎の球体は球体のMesh+マテリアルで完成する。MaterialのShaderの基本設定は下図のようにする。

 今回はBlendModeをTranslucentやAdditiveではなく、Maskedに設定するのは、「透明度」の数字を調整する必要がないからだ(「見える(1)」と「見えない(0)」だけで十分)。設定完了すると、Material Graphに以下のように、二つの煙のテキスチャーをサンプリングして乗算すると、効果が出る。

 その灰色の球体を「OpacityMask」に設定し、基本設定の「Opacity Mask Clip Value」を調整し、火球が見える。

 そして、球体を着色する時、「Two Sided Sign」というノードを使った。このノードはMeshの表と裏のサーフェイス(triangle)に対するアウトプットは違い、表は「1」、裏は「-1」をアウトプットする。これを用いて火球の全体を完成する。

放射線状の効果

 SpriteのUVを調整するだけで、この効果が実現できる。
 
 

 上図のように、まずUVから(.5, .5)のバーテックス(Vertex)を引き(原点が真ん中にし)、そしてNormalizeと、原点から同じ方向のすべてのポイントは同じ数値を持っている。この数値で任意模様のテキスチャーをサンプリングすると、放射線みたいな効果が出る。

渦巻

 一番目のエレクトリックボールの渦巻きに類似するので、上の文章を参考してください。


「3」波のボール

 このボールは主に上記の二つのボールで使われたパーツの組み合わせで完成できる。しかし、緑色のリングの部分は少し記録しなければならないものがある。


緑色のリング

 まずは、リングを作る。「RadialGradientExponential」ノードにCosine計算を加えると、リングのような効果が出る。この結果に、Powerで計算すると、より細いリングが出来る。

 そして、UVにテキスチャーでNoiseを加えると、リングが歪んでる感じができる。ここのトリックは、一つの「Dynamic Parameter」を使って、その「歪み」の程度をコントロールする。


 
 そうすると、Niagara Systemでこのパラメータを操作することができる。下図のようにカーブを設定し、ライフタイムの最後に歪みながら消える効果が出る。


ディストーション(Distortion)効果を作る

 以上三つのボールの共通点は、バックグラウンドにはディストーション(Distortion)の効果がある。その効果をじつげんするため、Refractionを使う。下図のように基本設定をする。

 しかし、デフォルトで「Refraction」のチェックは不可能の状態であり、基本設定で「Refraction Method」を設定しなければならない。ここは「Noramlを使う」にした。

 そして下図のようにグラフを繋ぐと完成する。グラフの一番下の「Dynamic Parameter」を使った原因は、Niagara Systemでこのマテリアルを使う時、ディストーションの強さを調整しやすくしたいからだ。


 以上で全てのボールの紹介が完了した~

 Intensity Shootingの開発記録(The Develop Log)

 以前からローグライクゲームが好きで、今回は自分でローグライクのシューティングゲームを開発したいと思って、この文章を開発過程として記録する。
 Unity2021を用いて、ゼロから作り方と問題を記録し、以降何かの問題がある時参考になったら幸運だと思う。


 2022/11/02

 最新の機能を使用するため、URPのレンダーパイプラインを選択した。
 
 2Dゲームだから、私はTileを使ってゲームマップを作ると決めた。まずはアセットストアで無料の素材をダウンロードし、TilePattleを作成する。

 Rectangular Tilemapをクリエートし、マップを作成する。


 Tilemapのコライダーは、TilemapCollider2Dを使って形成することで、最初はTileAssetずつコライダーが分離されているが、「Used By Composite」をオンにして、CompositeCollider2Dを添付すると、一つのコライダーになることが可能だ。



 マップの背景は四つのレイヤーで作られたもので、カメラにスクリプトを付け、カメラの動きと共に、レイヤー1つずつ移動する距離が違い、シーンのパースペクティブな感じを作る。

[パースペクティブなシーン]

 2022/11/03

 今日は主人公の絵を描く予定だ。
 アセットストアでダウンロードしたアセットは主にピクセルスタイルだから、主人公も同じスタイルで描かれると考える。そのため、私はAsepriteを使って完成した。

 主人公の基本的な感じが決まった後、idleとrunningの動画キーフレームを完成した。


 その上、シューティングゲームだから、主人公の武器として、少しハイテク感のある銃を描いた。

 そしてUnityに導入し、Animatorに整理した。


2022/11/04

 今日は主人公の移動とジャンプをコントロールするスクリプトを開発する。
 まずは移動で、単にプレーヤーの押しているボタンをチェックし、移動方向を決め、現在のポジションに相応の距離を加えるだけだ。

1
2
3
4
5
6
void UpdateMove()
{
int moveDir = GetMoveDir();

this.transform.localPosition += new Vector3(moveDir * Time.deltaTime * this.MoveSpeed, 0, 0);
}

 次はジャンプで、一つ目のステップは毎フレームでプレーヤーが地面にいるかどうかをチェックし、状態をアップデートする。

 そして、CanJump()というファンクションを使って、主人公がジャンプできるかどうかを確認する。

 最後、RigidBody2Dにフォースをあげて、ジャンプさせる。ちなみに、DoJump()のはじめる所に、小さなY軸の距離を与えるのは、その次のフレームに、上のCanJump()にチェックされないようにするから。


2022/11/05

 今日は銃の動画とカメラのマウスにフォローする効果を作った。
 まず、銃の動画は、銃の角度をマウスの位置によって変化することである。これは、マウスの位置と銃の位置を引き算し、ベクトルのYとXを逆三角関数のTanを使い、角度を計算することにより、実現する。ちなみに、その角度はラジアンだから、πで加算しないで、そのまま使うと、違う結果が出る

1
2
3
double angle_PI = Math.Atan2((double)deltaPos.y , Mathf.Abs(deltaPos.x));
float angle_Rate = (float)angle_PI / (2 * Mathf.PI) * 360.0f;
this.transform.localRotation = Quaternion.Euler(0, 0, angle_Rate);

 次は、カメラがマウスの位置にフォローする効果を作る。これは、プレーヤーの視野を広げ、より良い体験を与えるため作った機能である。
 実現する方法はたくさんあるから、ここは表示しないで、直接に結果を展示する。


2022/11/06

 今日は銃の発砲する機能を作った。
 「発砲」という行為を面白くするため、弾丸が壁にぶつかる時、小さな光とパーティクルシステムを加えた。その上、今後の武器を強化する可能性を開くため、弾丸が壁にぶつかる時跳ね返る機能も追加する。
 次は実際の効果である。

 

2022/11/07

 今日は敵の怪物と主人公の攻撃される効果を作った。
 攻撃が命中することを表現するため、攻撃させる対象のSpriteにブリンクの効果を与える。これを実現するため、私はURPのSprite Lit Shader Graphを使った。一つのシェーダーグラフにブリンク(Blink)とディゾルブ(Dissolve)の効果が実現させる。

 そして、怪物にHPを計算するスクリプトを付け、HPが0になったら死亡する。怪物が死亡する時、上記のディゾルブ効果を使う。
 以下は全部の効果である。


2022/11/09

 今日は怪物の攻撃を作った。
 私は戦闘の時、賑やかな場面を作りたいため、怪物の攻撃が環境に影響を与えるようにデザインした。それは、「血の流れ」という感じで敵のブレットを作った。
 まずは、血の滝を作る。私はパーティクルシステムを利用し、以下のようなテキスチャーを使ってランダムな角度を付けて作った。


 次は、血の滝が地面に痕跡を残すようにする。私はスクリプトでOnParticleCollisionのファッションを使用し、血が地面にぶつかる所に新しい血のSpriteをインスタンス化する。そのファンクションを使うため、パーティクルシステムにCollisionの「Send Collision Messages」をオンにすることが必要である。


 そして、新しい血のSpriteにMask Interactionを「Visible Inside Mask」をチェックし、マップにMaskを付け、以下のような効果ができた。


2022/11/11

 今日は前の日に作った怪物の攻撃能力を具体的な怪物に与えた
 例として、幾つの怪物を地面に置いておき、それぞれの怪物にスクリプトでAIを付け、プレーヤーを攻撃するようになる。
 そして、プレーヤーが攻撃を受ける時、HPの減少を表現するため、私は簡易なUIを作った。ここまで、ゲームの基本的な循環ができた。
 効果が以下のように。


2022/11/13

 今日はVFXを使って二つ目の攻撃効果を作った。
 VFXを使うため、VFXをインストールする必要がある。まずはPackage Managerに「Visual Effect Graph」をインポートし、そしてPreferencesのVisual EffectExperimental Operators/Blocksをチャックする。それにより、実験的な方法が使えるようになる。

 Hexoで個人サイトを構築する方法

 最近、新しいパソコンを買った。ブログをアップロードしたくて、もう一度ブログ環境を構築しなければならない。今度このような時に作成方法を忘れないようになる為、ここで構築ステップを記録しておく。

 

Hexoとは

 Hexoとは、Node.jsというプログラム言語で駆動し、静的サイトを構築するプログラムの一種である。


Hexoでサイトを構築するステップ

Gitをインストール

 まず、Gitの公式サイトから、Gitをダウンロードしておく。
 ダウンロードしてインストールした後、任意のフォルダーの空白スペースに右クリックすると、このようなメニューが見えるはずだ。

 続いて、Git Bashで以下のように自分のGithubのユーザー名とメールアドレスのコンフィグを設定し、ssh秘密鍵を生成する。

1
2
3
4
git config --global user.name "Github User Name"
git config --global user.email "Github User EmailAddress"

ssh-keygen -t rsa -C "Github User EmailAddress"

 そして、生成した秘密鍵ファイルをオープンして、その内容を全てコピーし、Github Setting Keysに移動してペーストする(Title任意)。
 完成後、Git Bashで次の命令を入力し、以下の画面と同じ結果が出たら、成功と見なす。
1
$ ssh git@github.com


Node.jsをインストール

 Gitをインストールした後、Node.jsのインストールも必要である。
 Node.jsの公式サイトでダウンロードし、インストールしておく。
 自分でそのインストールが成功するか否かをチェックすることができる。Cmdの中で次の命令を入力して、バージョニングが出てくると、成功とする。

1
2
node -v
npm -v


Hexoをインストール

 最後は、Hexo自体のインストールである。
 Cmdの中で、次のnpm命令でHexoをダウンロードとインストールする。

1
npm install -g hexo-cli

 少し時間がかかる場合もあるので、安心に待ってください。


終わり

 以上のステップ全部終わったら、ブログ環境が全て構築完了である。
 これからまた楽しくブログを書こう。


 Hexoで個人サイトを構築する方法

 最近、新しいパソコンを買った。ブログをアップロードしたくて、もう一度ブログ環境を構築しなければならない。今度このような時に作成方法を忘れないようになる為、ここで構築ステップを記録しておく。

 

Hexoとは

 Hexoとは、Node.jsというプログラム言語で駆動し、静的サイトを構築するプログラムの一種である。


Hexoでサイトを構築するステップ

Gitをインストール

 まず、Gitの公式サイトから、Gitをダウンロードしておく。
 ダウンロードしてインストールした後、任意のフォルダーの空白スペースに右クリックすると、このようなメニューが見えるはずだ。

 続いて、Git Bashで以下のように自分のGithubのユーザー名とメールアドレスのコンフィグを設定し、ssh秘密鍵を生成する。

1
2
3
4
git config --global user.name "Github User Name"
git config --global user.email "Github User EmailAddress"

ssh-keygen -t rsa -C "Github User EmailAddress"

 そして、生成した秘密鍵ファイルをオープンして、その内容を全てコピーし、Github Setting Keysに移動してペーストする(Title任意)。
 完成後、Git Bashで次の命令を入力し、以下の画面と同じ結果が出たら、成功と見なす。
1
$ ssh git@github.com


Node.jsをインストール

 Gitをインストールした後、Node.jsのインストールも必要である。
 Node.jsの公式サイトでダウンロードし、インストールしておく。
 自分でそのインストールが成功するか否かをチェックすることができる。Cmdの中で次の命令を入力して、バージョニングが出てくると、成功とする。

1
2
node -v
npm -v


Hexoをインストール

 最後は、Hexo自体のインストールである。
 Cmdの中で、次のnpm命令でHexoをダウンロードとインストールする。

1
npm install -g hexo-cli

 少し時間がかかる場合もあるので、安心に待ってください。


終わり

 以上のステップ全部終わったら、ブログ環境が全て構築完了である。
 これからまた楽しくブログを書こう。


 レンズの言語(The Language of the Lens)

 映像は絵と同じく、作者が見ることや感じること、または思想を注いで創作した作品である。変化しない画像と違って、映像にはもう一つの軸、いわゆる時間軸がある。その時間という軸をうまく使ったら、画像より多くの情報や効果を表現することが可能になる。今日検討したい内容は、映像あるいはゲームなどの作品の中、視点を移動する時に使われるトリック、つまり、「レンズの言語」ということだ。
 レンズの言語とは、レンズそのものが言葉のように撮影者の意図を伝達することである。撮影する時の角度や構図、被写界深度や焦点距離により、観客が受ける感覚も大きく異なっている。それはまるで作者本人が直接に言葉で観客に作品について解説しているようだ。
 私は次の幾つかの方面で「レンズの言語」を具体的に紹介する。


 レンズとの距離(ショット)

 ロングショット

 ロングショットとはカメラと被写体の距離が長い状態で撮影する画面である。人間の場合、人を頭から足まで映ることが多くて、人間以外なら、ストーリーが発生した環境を映ることが多い。それは主に全体的に人間関係とストーリーの舞台を紹介するためだ。例えば、サマータイムレンダのエンディングに主人公がいる島の全体像が映られている。

[サマータイムレンダのエンディング]

 ミディアムショット

 ミディアムショットはロングショットとクローズショットの真ん中で、中間的な感じがあり、普通は人の腰または胸から頭までの部分(半身像)が映られている。それは画面の中の人数により、違い意味がある。
 同一の画面に複数な人がいれば、彼らの動作と関係を表す場合が多くて、一人しかいない場合は、その人の動作と表情を強調することが一般的に考えられている。

[东京ゴッドファーザーズ]

クローズショット・クローズアップ

毛发渲染(Fur Rendering)

实时渲染中,毛发的渲染可以看作一个单独的课题,毛发因为其数量多的原因,如果为每根毛发单独建模或渲染,会给CPU和GPU都带来很大的压力。因此,我们采用一些tricks,让毛发看起来是真实的,并且在实时渲染中可以接受的性能消耗之内来完成。
由于毛发渲染的方法有很多,这篇文章介绍的是多pass方式的毛发渲染,又叫做shell-based rendering。参考自📌bkenwright@xbdev.net的教程

毛发的特征

1、毛发数量众多、质地柔软
毛发由数量众多的细小圆柱体组成,并且毛发通常是柔软的,互相交叉重叠在一起。

[互相交叠的柔软毛发]

2、毛发自身互相产生阴影
毛发自身产生的阴影会投射到其他的毛发之上,由于毛发基本都是从根部到尖端由粗到细,因此越接近毛发的根部,阴影越强。在实时渲染中我们不会精确计算到毛发自身的投影,但是可以利用靠近根部的阴影强这一点来做一个模拟的AO。

[越靠近根部阴影越强]

3、毛发边缘透光、且颜色越淡透光越强
背光时,可以明显看出毛发的边缘部分能够透过一部分光线,并且毛发的颜色越浅,透光越强。在摄影中,逆光拍摄的时候可以明显看出这种现象。

[头发的边缘透光现象]

4、毛发的各向异性高光
与一般物体不同,毛发的表面有许多凸起、凹痕等,这让毛发并没有一个明显区域的高光,与之相反,毛发的高光更偏向各个方向均有一部分。

[显微镜下的毛发 + 高光]

但是
好在我们在实时渲染中可以效果较好、代价较低地模拟上述的毛发特征。这里引用《3D数学基础:图形与游戏开发(3D Math Primer For Graphics And Game Development)》中的“图形学第一定律”——

如果它看上去是对的,那么它就是对的(If it looks right, it is right)


毛发的渲染过程

多Pass渲染

本文中我们采用多pass渲染的方式来渲染毛发,即用多个pass,每个pass渲染一层,让多层叠加在一起产生毛发的效果。在每一层中,我们均将顶点沿法线方向挤出一小段距离,这样在多个pass的执行下,我们便得到了大量的层,每一层都是上一层沿法线方向的放大。

[沿法线挤出的多层示意]

通常,我们沿法线挤出后,所形成的新的层仍然是一个整体(即如果我们把一个平面挤出一次后,所得到的层仍然是一个平面),那按理说我们得到的仅仅是重叠在一起的多个层,并非是毛发。为了让这些层看起来像毛发,我们可以使用沃里噪声(Worley Noise)贴图,每层对其进行采样,采样值作为alpha逐层递减,由于沃里噪声形状的特性,我们便可以在每个pass中得到一系列逐层变细的面片,当层数足够多时,看上去就和毛发一样。

[沃里噪声]

以下是多pass渲染的一段简单结构示意:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SubShader
{
Tags { "RenderType"="Transparent" "Queue"="Transparent" }
Blend SrcAlpha OneMinusSrcAlpha

Pass
{
CGPROGRAM

#pragma vertex vert
#pragma fragment frag

v2f vert(a2v v) { return Vert_fur(v, FUR_OFFSET); }
fixed4 frag(v2f i) : SV_Target { return Frag_fur(i); }

ENDCG
}
Pass {...}
Pass {...}
...
}

其中的FUR_OFFSET为层高,即我们每层挤出的距离,FUR_OFFSET的间隔越大,毛发越长,但是每一层的区别也看得更明显,更容易穿帮。因此总层数与层高需要在效果与性能之间通过调试来做取舍。


第一步:法线挤出与噪声采样

具体原理在上述多pass渲染节已经说明完毕,我们按照其方法来做毛发渲染的第一步——法线挤出和噪声采样
首先,在顶点着色器中,我们将每个顶点沿法线挤出,并计算主纹理和沃里噪声的tilling和offset,代码如下

1
2
3
4
v.vertex.xyz += v.normal * FUR_OFFSET * _FurLength;

o.uv.xy = v.uv * _MainTex_ST.xy + _MainTex_ST.zw;
o.uv.zw = v.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw;

片元着色器中,我们采样沃里噪声,并根据FUR_OFFSET的值来让alpha逐层缩小,代码如下:
1
2
float alphaOrigin = tex2D(_NoiseTex, i.uv.zw).r;
float alpha = clamp(alphaOrigin - FUR_OFFSET, 0.0, 1.0);

至此,我们已经得到了沿法线挤出的多层,并且沃里噪声采样的alpha值逐层递减,目前我们得到的“毛发”效果如下:

可见效果非常不尽如人意,中间部分几乎看不清,考虑到开头提到的毛发特征第二点,我们先来为其加上AO。


第二步:毛发的自投影(AO)

因为我们每一层(每一pass)均有一个FUR_OFFSET变量,所以我们想计算AO会变得非常方便。试想,毛发越接近根部,其阴影越强,因此我们只需要让根部的光线作用效果减弱即可,而FUR_OFFSET又恰好是从根部到尖端由小到大,我们便可以轻松计算如下。
顶点着色器中,我们增加了__AO参数,用来调整阴影效果,其代码如下:

1
half ao = pow(FUR_OFFSET, _AO) + 0.04;

我们将结果传入片段着色器,并用结果乘以该值如下:
1
result *= aoVal;

至此,我们用模拟的AO给毛发加入了自投影,得到的“毛发”效果如下:

可见仅通过简单的AO计算,我们已经能够将毛发的阴影效果表现出来了,但是可以发现,我们的毛发现在均沿着法线向外,看起来像“刺猬”,并不符合毛发的特征一,即并不柔软,因此我们接下来需要让毛发变得更加柔软。


第三步:得到柔软的毛发

为了让我们的毛发看起来不像“刺猬”,我们需要让毛发变软,这里我们通过UV偏移的方式来实现这一效果。
我们根据FUR_OFFSET,来对采样沃里噪声时的UV进行逐层偏移,相当于我们每一层采样时都相比上一层进行了一小段偏移,这样最终我们得到的毛发就会是弯曲的,也就达到了我们想要的柔软的效果。
为了方便调整效果,我定义了向量__UVOffset变量,其xy分量来对UV进行偏移,其z分量用来调整FUR_OFFSET带来的影响,公式如下:
$uvOffset = UVOffset.xy · FUROFFSET^{UVOffset.z} $
于是,我们在顶点着色器中,有:

1
2
float2 uvOffset = _UVOffset.xy * pow(FUR_OFFSET, _UVOffset.z) * 0.1;
o.uv.zw = v.uv * _NoiseTex_ST.xy + _NoiseTex_ST.zw + uvOffset;

至此,我们给毛发加入了UV偏移,得到了逐层弯曲的毛发,并且能够通过一个变量调整效果,结果如下:

目前,我们已经得到了毛发的几何形态,接下来,我们需要让光照对我们的毛发产生影响,以此来实现开头提到的毛发特征3~4,在光照上,首先我们从最经典最基础的漫反射开始。


第四步:光照——漫反射

漫反射仍然采用经典的Lambert算法,用法线和入射光方向的点乘作为漫反射的结果,这里我们对结果加上一个变量LightFilter,用来调整光的穿透程度,代码实现如下:

1
2
float NdotL = dot(worldNormal, worldLightDir);
float diff = saturate(NdotL + LightFilter);

事实上,当我们将上述结果直接用作漫反射结果时,得到的效果是很差的,因为其破坏了毛发尖端阴影更少,亮度更强的原则。
因此,这里我们正好可以利用FUR_OFFSET变量来改善这一结果,因为NdotL的结果范围是(-1, 1),所以我们加上范围为(0, 1)的FUR_OFFSET后,就得到了范围在(0, 2)的结果,我们用saturate来将结果限制在(0, 1)即可得到优化后的结果。代码实现如下:
1
2
float NdotL = dot(worldNormal, worldLightDir);
float diff = saturate(NdotL + LightFilter + FUR_OFFSET);

最终我们能够得到如下的结果(图中分别对比了加与不加FUR_OFFSET的效果),注意,这里展示的效果仅仅是将diff的数值可视化

[ 无FUR_OFFSET vs 加入FUR_OFFSET ]

在有了漫反射后,我们已经完成了光照的第一步,接下来我们来完成开篇所说的毛发特征第三点,这可以让毛发边缘能够透过一部分光线从而显得亮度更高


第五步:光照——边缘透光

为了使边缘能够透光,即让光线只影响物体边缘,那么自然想到的就是菲涅尔反射。关于菲涅尔反射的计算公式有很多,这里我使用了下面的公式:
$Fresnal = max(0, min(1, (FresScale) + (1 - FresScale) · (1 - dot(ViewDir, normal)^{FresPower} )))$
这里可以根据效果多尝试几种方法,都是没问题的。
在代码实现时,我们引入两个变量FresnelScale和FresnelPower来帮助我们调整效果,具体实现如下,在顶点着色器中:

1
2
half fresnel = 1 - _FresnelScale + _FresnelScale * pow(1 - dot(worldNormal, worldView), _FresnelPower);
fresnel = max(0, min(1, fresnel));

片元着色器中,我将结果加上了AO和FUR_OFFSET的影响,可以让边缘部分的亮度稍高一些,此外,还加入了__FresnelColor来调整透光部分的颜色,具体如下:
1
2
float fresnelVal = i.extraParam.w + aoVal * FUR_OFFSET * 0.1;
result += fresnelVal * _FresnelColor;

最终我们得到的结果如下,左右分别是有无AO和FUR_OFFSET的区别,注意,这里的效果仅仅是将fresnel值可视化的结果:

[ 无AO/FUR_OFFSET vs 加入AO/FUR_OFFSET ]

拥有边缘光之后,我们已经完成了开头介绍的毛发特征三,接下来我们来完成最后的特征四,即各向异性高光


第六步:光照——各向异性高光

与一般的高光不同,我们在计算毛发类的高光时采用各向异性高光,Blinn-Phong高光模型中我们使用了法线与向量H进行点乘计算(注:向量H指视角方向与入射光方向的中间向量),而在各向异性高光计算的时候,我们采用切线方向来代替法线进行计算,如下图

不难想象,对于圆柱体而言,其切线方向是不变的,均沿着毛发生长方向。值得一提的是,在图形学中,不同于唯一的法线,切线一般是由物体的UV方向来定义的,因此有些引擎中我们用来计算各向异性高光时不一定使用切线,而也有可能使用副切线进行计算,其中副切线可以由法线与切线的叉乘来求得。
有了我们所需要的数据之后,我们便可以将数据代入公式计算,这里我使用的是Kajiya-kay模型,其公式如下
$StrandSpecular = (\sqrt{1 - dot(Tangent, H)^2})^{exponent}$
其中
$H = normalize(ViewDir + LightDir)$
代码实现如下:

1
2
3
4
5
6
float StrandSpec(float3 T, float3 L, float3 V, float exponet)
{
float3 H = normalize(L + V);
float TdotH = dot(T, H);
return pow(sqrt(1 - TdotH * TdotH), exponet);
}

我们将高光计算结果可视化,便能够得到如下的结果,这一结果就是模拟了动漫中常见的头发上的天使环高光

注意,为了性能考虑,我们在毛发渲染的所有光照计算均在顶点着色器中完成,因此我们能在高光上看到明显的几何形状,并且毛发根部也被完整照亮了,这不是我们想要的效果,因此我们对该高光结果乘以FUR_OFFSET来弱化毛发根部的亮度,除此之外,我还加入了__StrandParam参数来方便调整高光效果,代码如下:

1
2
3
4
5
6
7
8
Properties
{
...
_StrandParam ("高光参数:X-环区域,Y-高光亮度", Vector) = (25.0, 1.0, 0, 0)
...
}

half strandSpec = StrandSpec(worldBiTan, worldLight, worldView, _StrandParam.x) * FUR_OFFSET * _StrandParam.y;

最终我们得到了如下结果(左图是我用Blinn-Phong高光模型的计算结果,可以明显看出其与各向异性高光的差别):

[BlinnPhong vs 各向异性 ]

第七步——合并所有效果

最后,我们把上面的效果全部合并,得到最终的毛发渲染结果。

首先,我们对纹理采样,获得物体固有色,并对得到的固有色与毛发颜色进行插值,得到一个基础的着色。
然后我们在该着色的基础上,加入漫反射与高光反射的影响,并且这里的高光我乘上了漫反射的结果,这是为了不让背光处出现天使环高光。
在上述光照的基础上,我们再加上边缘光,并且这里我为边缘光设定了一个可选颜色,让该颜色影响边缘光颜色。
最后,我们把结果乘以AO值,来加入自投影带来的阴影。
完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
// 采样+插值
fixed3 baseCol = tex2D(_MainTex, i.uv.xy).rgb;
baseCol = lerp(baseCol, _BaseColor, FUR_OFFSET * FUR_OFFSET);

// 光照
fixed3 result = (diff + diff * strandSpec) * baseCol * _LightColor0.rgb;
result += fresnelVal * _FresnelColor;

// AO
result *= aoVal;

return fixed4(result.rgb, alpha);

在将所有的计算结果合并后,我们就能得到毛发渲染的最终效果。

至此,我们完成了多pass方式的毛发渲染,但是该结果仍然有许多需要调整和优化的地方,譬如不同物体间毛发的穿插毛发的动态等。


游戏设计与心理学(Game Design & Psychology)

这篇文章将会记录一些我在学习过程中了解到的心理学相关知识,这些知识大部分应用在游戏设计中,所以这篇取标题为游戏心理学。
记录在此的理论、实验等,大多为游戏设计以及玩家行为背后包含的心理学知识,它们或是影响着我们的心情、或是驱动着我们的行为,甚至影响了我们在游戏之外的日常生活。我将以小标题的形式简要地记录每一种心理行为。


功能固着(Functional Fixedness)

德国心理学家卡尔·邓克尔(Karl Duncker)提出了术语功能固着(Functional Fixedness),其指的是:当人们看到物品的一种常用的功能或关联后,就很难看出它的其它用途;如果初次接触时看到的功能越重要,就越难看出其它用途。
如下图是邓克尔的蜡烛问题:

即提供一板火柴,一个蜡烛,和一堆装在盒子里的图钉,要求修复墙上的烛台。这个问题的难点在于图钉一开始被装在盒子里,让人们固定地认为盒子的作用是装图钉而非作为烛台。

在游戏设计中,设计者往往会在给玩家提供新的道具的时候给与该道具的使用说明,除了设计者提供的方法之外,当玩家自己发现了游戏本身提供之外的新的使用方法时,往往会充满成就感并认为这是有趣的。如塞尔达传说荒野之息中的道具:

当玩家初次获得道具时,得知的使用方法是让物体停止,但是随着游戏进度的深入,玩家自己能够挖掘出许多被告知之外的功能。

除了对玩家之外,游戏设计者也能挑出功能固着来产生不同的作品,如《Dandara》不同于一般的2D平台跳跃类游戏,将方向键和A键作为移动和跳跃,而是作为调整方向与重力的手段。

《Dandara》的开发者最初是面向移动平台设计的游戏,这与面向PC或主机开发时的视角不同,但是他们发掘了手柄这一工具的不同使用方式,这告诉我们初始视角的不同有助于跳出功能固着心理。
意料之外的东西总是有趣的。


《蔚蓝(Celeste)》的镜之神庙如何创造“恐惧”

《蔚蓝》是一款出色的2D平台跳跃类游戏,在其硬核的游戏难度之外,开发者对于“恐惧”与“危险”这一要素的暗示也做得非常值得我们学习,尤其是第五章镜之神庙(Mirror Temple)。

1、镜子与危险的联系
主角的朋友被关在了镜子里,而在此前的关卡中,主角从镜子中看到了自己心中的梦魇,镜子这一意向已经被暗示了危险

2、蜡烛颜色的暗示
在章节开始的时候,玩家需要经过如下图的场景,在这一场景中,玩家见到了被关在镜子中的好友,而镜子预示着危险,镜子边上的两盏橙色蜡烛便自然能够让玩家将橙色与危险联系起来。此外,玩家通过移动来点亮蓝色的蜡烛并找到通往下一场景的路径,也自然地将蓝色安全联系在一起。

在之后的场景中,蜡烛与对应安全与危险的联系仍然会不断地被暗示和强调

3、环境的暗示
在关卡的进行过程中,周围环境也时刻给予玩家危险在附近的暗示,在该作中玩家没有攻击的手段,因此逃跑(Flee)变成了玩家的唯一选择。在该章节中,玩家只能看到自身周围的小范围区域,在玩家通过不同场景的过程中,黑暗中出现的各类怪异雕塑就成了危险就在身边的暗示源。


“停顿”的力量——首因&近因效应(Primacy&Recency Effect)

在2007年的一项实验中,受试者被关在一个完全黑暗的房间中48小时,在经过一天左右受试者出现了或多或少的幻觉,声称能够看见本不存在的东西

游戏也能够提供类似的环境。
游戏给我们的感官刺激主要来源于视觉和听觉,绝大部分时间我们都能够清晰地看到或听到游戏作品给玩家带来的感官刺激,但是在例如BOSS战、章节末、情感叙事等时候,常常会遇到渐渐消失的BGM、简单甚至全黑的场景等处理方式。这样的“停顿”处理和上述实验一样,除了让玩家能够“休息”一下之外,也给了让大脑自动补全这一段留白的环境,如回忆之前的冒险、为即将到来的危险做准备之类。下图分别为《铲子骑士(Shovel Knight)》、《任天堂全民星大乱斗(Super Smash Bros)》、《塞尔达传说:梅祖拉的假面(The Legend of Zelda: Majora’s Mask)》、《洛克人(Megaman)》中,运用“停顿”手法的场景举例。

除了上述原因,让玩家能留下深刻印象的另一个原因,便是首因、近因效应(Primacy&Recency Effect)
让人们在听完一系列顺序出现的单词后,重新尽可能多地复述出现过的单词时,在首位以及末尾出现的单词,被成功复述的几率是最高的,这便是首因、近因效应。

最先出现和最新出现的事物,能在人们大脑中停留的时间更久,可以理解为这分别是长期记忆和短期记忆的体现。如在教育界,将一堂课拆分为多个小章节、比从头到尾一次性说完效果要好。在游戏设计上,每一次简短的“停顿”,能让玩家对这次停顿附近的游戏内容印象更深刻(如一次激动人心的BOSS战)。
对于游戏设计者来说,这带给我们的启发是,我们需要合理安排阶段性的内容,与全程都是高潮的作品相比,有张有弛的作品将能够在玩家心中留下更多的内容

如上图,每一次波峰都应当是玩家振奋人心的游戏体验,而每次一波谷都应当留给玩家的大脑来填充内容,正如先前的实验所说,当什么都不存在时,大脑会自动想象出不存在的事物。
利用“停顿”带来的首因、近因效应,能让设计者把想让玩家记住的东西更多地停留在玩家的记忆中。


被注视的力量(Power of being watched)

在电子游戏中,“眼睛”常常被当作危险的信号,在极大多数有眼睛或者被注视感的场合中,往往会给人们带来恐惧、紧张的感觉。当设计者在暗示场景中潜伏着危险,或是在进行某些敌人形象的设计时,眼睛常常会成为一个重要参考,并且通常眼睛也会成为敌人的弱点之一,如下图。

人类学家克洛德·列维·斯特劳斯(Claude Levi-Strauss)认为,人们对面具、小丑等感到恐惧的原因是它阻断了人与人之间的表情交流。当人们被注视的时候,在来源不明的眼睛形象上无法推测出对方的意图,这会触发人们的自我保护,倾向性地认为对方的意图是对自己有害的
如下图,面具让我们无法根据面具后面的人的表情等判断其意图,让人们产生恐惧的感觉。当然,其中也包括由面具形象带来的恐怖谷(Uncanny Valley)效应,我打算把这一点放在另一篇恐怖相关的作品分析中讲述。

除了被不明意图的注视,还有另一种被注视的方式,即观众。James Geer的《恐惧调查表》中显示了人们除了死亡、未知、危险动物等之外,社交、演讲、出丑等行为也在其列。

人们发现,观众的存在会影响一个人的表现,譬如在别人的注视下完成一项工作的时候,有些人会表现出不愿意负面情绪,并且其完成质量、效率等都会明显下降。对应到电子游戏中,如周围NPC的反馈、团队竞技中队友的关注、线下观众的围观等,会明显地影响玩家的思考能力以及发挥水准。但同时,也有一部分人在拥有观众的时候,会表现得比平时更出色
通常,在人们水平不足时,注视会让他们发挥失常;而在水平较高时,注视能让他们更加出色。譬如,《宝可梦剑盾》中的道馆设计,周围观众的气氛能让玩家感觉自己的发挥比平时更加出色。

由此对设计者的启发是,当我们需要让玩家感到紧张焦虑时,我们可以在游戏初期,玩家技巧不成熟时加入观众;当我们需要让玩家感到自信、火热时,我们可以在玩家技巧已经成熟后加入观众;当我们需要营造恐惧、不适、危险等氛围时,我们可以加入视线来源不明的注视。


TODO

TODO

Ray Marching & Basic Lighting

最近去学习了一下Ray marching相关的知识,虽然仅仅是一个入门。于是我准备将raymarching和基础的光照模型放在一篇文章里学习记录一下。


Ray Marching(光线步进)

Ray Marching概念

先从Ray marching开始讲起,从名字可以看出,raymarching是一个模拟光线不断前进的过程。
我们想象从观察点(人眼或者相机)开始,对视线范围内的每一个方向都发射一条射线(Ray),那么这条射线如果与某样物体相交,则这个方向上我们需要绘制这个物体,最终当我们对每条射线都这样检查完毕时,所有相交点都绘制完毕了(对于计算机来说,这里的“每条射线”就变成了从相机到屏幕每个像素的方向射线)。


判断相交

显然,这里我们会遇到一个问题,就是如何判断射线是否与某样物体相交。在raymarching中,我们采用找最短距离步进的方式来计算,即,对于一个物体,我们每次沿射线步进的距离是从该点到该物体的最短距离,这样我们就保证了,在这个步进半径内的任何一个点,都不会出现在该物体的内部。当我们某一次寻找该距离的值小于阈值时,我们就判断这个射线方向与物体相交,当总距离大于最大阈值时,我们判断这条射线方向没有物体。

大体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
float RayMarching(float3 rayOrigin, float3 rayDir)
{
float total = 0.0f;
for(int i = 0; i < MAX_ITERATION; i++)
{
if(total > MAX_DISTANCE)
{
return -1.0f;
}
float3 pos = rayOrigin + rayDir * total;
float distance = GetDistance(pos);

total += distance;

if(distance < MIN_DISTANCE)
{
return total;
}
}
return -1.0f;
}

这里我们会遇到第二个问题,就是代码中的GetDistance函数,我们如何知道某一点到某物体的最短距离呢,这里需要用到有符号距离场(Signed Distance Field, SDF)来计算。


有符号距离场(SDF)

有符号距离场可以看做一个对空间的表达函数,我们用一个方式来表达离某一点最近的空间距离(一般是一个标量场函数或一张立体纹理)。似乎SDF可以用来做AO(环境光遮蔽)和软阴影(Soft Shadow),如UE4,但是目前没有学习到,留个(TODO)。
在raymarching中,我们使用标量场函数来计算某点到物体的最短距离,这里拿最简单的球体来举例。我们有$Param_{Sphere} = (x, y, z, w)$,这里记作S,其xyz分量为球心坐标,w分量为球的半径,又有$位置P(x,y,z)$,显然,从位置P到球S的最短距离为$\sqrt{(P.x - S.x)^2 + (P.y - S.y)^2 + (P.z - S.z)^2} - w$。
所以我们可以得到球体的标量场函数如下

1
2
3
4
float SDF_Sphere(float3 pos, float4 param)
{
return length(pos - param.xyz) - param.w;
}

除了球体,还有许多不同的标量场函数,如锥体、长方体、甜甜圈等,具体可以看📌InigoQuilez大佬的文章


SDF的其它计算

上述标量场函数仅仅是单个几何体的表达,通过对不同标量场的计算,我们可以得到多个几何体布尔运算结果,如交集、差集、并集等。
这里仍然以最简单的交集为例,我们只需要将两个SDF函数的结果求得最小值即可,代码如下

1
2
3
4
float Operation_Union(float sdf1, float sdf2)
{
return min(sdf1, sdf2);
}

此外,还有一些复杂的计算如平滑过渡的并集(Smooth Union)等算法,均可以在上述IQ的文章中找到。


SDF的其他应用

了解SDF之后,我们可以在raymarching的时候做一些trick来实现一些有趣的效果,如我们对raymarching时传入的位置坐标做周期性的取模,遍可以实现无限循环的模型效果,如下图所示,我将距离函数$f(x)=x$转换为周期为a的函数,即可以对空间内每一个$a^3$的立方体进行SDF计算,从而得到无限的空间。

如下图是对一个球体和立方体求差集后对齐周期性采样的结果

可以看到raymarching通过对SDF的简单计算便可以得到强大的结果。这里我是参考于📌油管上对ray marching的一个详细教程


着色(Shading)

在使用射线以及SDF处理完几何信息之后,我们需要对得到的几何信息(对应在计算机屏幕上则为像素)进行着色,这里我将使用最基础的光照模型来进行raymarching下着色的说明。

计算法线

在基础光照模型中我们对光的计算离不开几何体的表面法线,在一般的渲染流程中,法线信息通常来源于模型的顶点信息或是法线贴图,在raymarching中,我们没有这两个信息,因此需要自己计算表面法线。
这里我们通过求梯度的公式来获得表面法线,即计算点$P(x,y,z)$在三个坐标轴方向上SDF函数值的偏导,来得到该点的法线方向。
$n = normalize(\nabla f(p))$
$\nabla f(p) =\begin{Bmatrix} \frac{df(p)}{dx}, \frac{df(p)}{dy}, \frac{df(p)}{dz} \end{Bmatrix}$
$\frac{df(p)}{dx} \simeq \frac{f(p + (h, 0, 0)) - f(p)}{h}$

如上所示,我们便完成了raymarching某交点(屏幕像素)的法线计算,用代码简单计算如下:

1
2
3
4
5
6
7
8
float2 tinyVal = float2(0.00001, 0.0);
float3 normal = float3
(
GetDistance(pos + tinyVal.xyy) - GetDistance(pos),
GetDistance(pos + tinyVal.yxy) - GetDistance(pos),
GetDistance(pos + tinyVal.yyx) - GetDistance(pos)
);
return normalize(normal);


Lambert漫反射模型

这里我们使用基础的Lambert模型来计算漫反射,以平行光为例,我们认为从光源出射的光线在单位面积上的辐照度来表示,那么当光线是斜着入射到物体表面时,相同辐照度反应在物体表面的面积就增大了,因此亮度也会相应地降低,这里我们可以用表面法线与光线入射方向的点积来计算这个差异,如下图所示。

这里需要注意的是,对于模型背面的点,其法线与入射光方向的点积结果为负数,在图形学中负数对于颜色的影响与0一样(均为黑色),但是为了让后续的计算不出现问题,我们对结果取非负处理,因此最终漫反射颜色计算公式为
$C_{diffuse} = max(0, dot(normal, lightDir)) * C_{light} * I_{light}$
其中C为颜色,I为入射光辐照度。

半Lambert漫反射模型

从上述公式可以看出,Lambert漫反射模型会让模型背向光源的一半完全呈现黑色(因为点积为负),因此出现了半Lambert模型,其计算也很简单,即把上述公式中的max()部分改变为:
$dot(normal, lightDir) * 0.5 + 0.5$

$C_{diffuse} = (dot(normal, lightDir) * 0.5 + 0.5) * C_{light} * I_{light}$
我们将两者的函数图像及实际效果作比较,可以看出半Lambert模型的结果会比前者拥有更多层次。


Blinn-Phong高光模型

对于一些表面光滑的物体,除了漫反射,我们还需要高光反射,光线能够大部分经过法线而反射到我们的观察点(如相机)
最基础的高光模型是Phong式算法,我们将入射光方向经过法线反射的方向观察方向做点积,这样越接近恰好反射到观察点的表面位置,高光越强,如下图所示:

但是这个方法当观察方向与入射光方向在法线同侧时,反射方向与观察方向的夹角将会大于90°,导致点积为负数,这样当相机转到某一个角度时,会出现高光突然消失的情况,这是我们不希望看到的,于是便有了Blinn-Phong高光模型
在Phong式模型的基础上,我们计算了中间向量H,它是入射光方向与视线方向的平均,我们用向量H法线N求点积,这样就保证了H和N的夹角永远不会大于90°,如下图所示:

如果直接求得点积,那么cos函数的变化会太过平滑,导致我们不希望看到的高光面积过于大的情况,因此我们引入一个新的变量shininess,来对点积结果进行次幂操作,来缩小高光的面积。因此最终,我们能够得到高光的计算公式;
$C_{specular} = pow(max(0, dot(H, N)), shininess) · C_{light} · I_{light}$
计算完高光后,我们可以得到如下的效果:

可以看到随着角度、和shininess的变化,高光的强度和范围都会发生相应的变化。


结果合并

在得到了漫反射(Diffuse)高光(Specular)之后,我们便可以将他们相加后乘以物体的固有色,得到基础光照的最终结果$C_{final}=C_{origin}·C_{light}·I_{light}·(P_{diffuse} + P_{specular})$


深度缓存与遮挡

到目前为止,我们已经得到了屏幕每个像素的颜色,但是这里仅仅是获得了通过raymarching渲染得到的像素颜色,如果场景中还存在其余渲染结果,如最普遍的Forwardbase Rendering或Deferred Rendering等,则我们会得到不正确的结果,如下图所示

我在场景中放置了一个立方体,该立方体是用前向渲染(Forwardbase Rendering)完成渲染的,但是当旋转相机的时候,本应该挡住球体的立方体,被渲染在了球体之后,看起来就变得十分怪异。
为了解决这个问题,我们需要用到深度缓存,在进行画面渲染的时候,为了保证越靠近镜头的物体被渲染在越前面,我们需要用到一些算法,譬如画家算法(已经不再使用)或者深度缓存(Depth Buffer)
深度缓存为屏幕上的每一个像素都存储了一个深度值,该深度值的取值为[0, 1],我们可以将一个场景的深度缓存中的值输出为颜色,如下图,可以看到,越靠近相机的颜色越接近黑色,这是因为从近到远,深度值是从0到1。

我们将该深度值乘以相机视椎体的远平面,便可以得到[0, Far]取值的深度值,也就是说,我们得到了屏幕上每一个像素最接近相机的物体的距离,于是我们可以将该距离与raymarching的结果相比,便可以得到遮挡关系,如下图是加入了深度值比较后的正确结果

在代码实现上看,我们仅需把前文中RayMarching函数的判断部分增加上与深度值的比较即可,如下

1
2
3
4
if(total > MAX_DISTANCE || total >= depth)
{
return -1.0f;
}


硬阴影与软阴影

到目前,我们已经解决了排序问题和着色问题,现在我们创建几个SDF函数,分别是由两个球体和一个立方体交并运算得到的几何体,和一个平面,我们将他们求并集,得到的结果如下:

可以看到,虽然我将几何体放置在了平面的正上方,但是看上去仍然像是“漂浮”在平面之上,其中一个重要原因是这里缺少了投影。对于前向渲染等,展示投影的方式有譬如Shadow Map等。但是在Ray Marching中,我们可以更加方便地得到投影

再次使用文章开头的这张图,但是这次我们关注的是图中右边蓝色标注的Shadow Ray,在raymarching中,我们如果想得到一个像素是否处于阴影中,我们只需要从该处,向光源方向再做一次Ray Marching,如果射线与物体相交,则说明该像素处于阴影中,反之则不在阴影中,代码表示如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float HardShadow(float3 ro, float3 rd, float maxDis, float minDis)
{
for(float t = minDis; t < maxDis;)
{
float dis = GetDistance(ro + rd * t);
// Hit something
if(dis < minDis * 0.5)
{
return 0.0;
}
t += dis;
}
return 1.0;
}

注意这里我们传入的ro(Ray Origin),需要加上一个微小的偏移,一般是沿法线方向偏移一个极小的值,因为直接代入某点的话,该点已经处在某个几何体上,其GetDistance的结果就为0。
现在我们拥有了基础的阴影计算结果,把它代入到我们的着色函数中,可以得到如下图结果:

从结果中可以看出,阴影正确地投影在了地面以及几何体自身。但是这个阴影的结果有些锋利,其边缘非常硬(Hard Shadow),为了得到一个更柔化的边缘,我们可以在上述函数的基础上做出一些修改,来得到软阴影(Soft Shadow)
在raymarching中获得软阴影的思想是,对于没有被遮挡的像素,我们找到步进过程中,最靠近几何体的采样点,占总采样距离的比例的最小值。这样,稍偏离硬阴影的部分,其软阴影也最强。原理如下图

代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
float SoftShadow(float3 ro, float3 rd, float maxDis, float minDis, float k)
{
float result = 1.0;
for(float t = minDis; t < maxDis;)
{
float dis = GetDistance(ro + rd * t);
// Hit something
if(dis < minDis * 0.5)
{
return 0.0;
}
result = min(result, k * dis / t);
t += dis;
}
return result;
}

其中k为调整参数,k越小,result值越小,软阴影效果越强。
通过调整k参数,我们可以得到软阴影的效果如下图


AO环境光遮蔽

有了阴影,我们的几何体看上去更像是被放在了地面上,但是我们还可以做到更多,譬如看如下图的位置,在现实世界中,如墙角等垂直的平面上,都会比周围的亮度更低,而我们现在的结果还没有展现这一点。

这里我们可以使用环境光遮蔽(Ambient Occlusion)技术来实现这一点。
AO的定义如下:AO是来描绘物体和物体相交或靠近的时候遮挡周围漫反射光线的效果,可以解决或改善漏光、飘和阴影不实等问题,解决或改善场景中缝隙、褶皱与墙角、角线以及细小物体等的表现不清晰问题,综合改善细节尤其是暗部阴影,增强空间的层次感、真实感,同时加强和改善画面明暗对比,增强画面的艺术性。
如下图,AO让老人脸上的皱纹等区域的亮度减少,显得层次感比原图更加丰富。

本文中我们只讨论RayMarching下的AO实现,我们沿着法线方向一点点步进,每个点使用GetDistance采样一次,并把结果和步进总距离作比较,可以想象,当某像素的周围有其它遮挡物时,GetDistance的结果会比步进距离小,而当某像素的周围一片空旷时,GetDistance的结果和步进距离是相同的。我们可以用代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
float AmbientOcclusion(float3 pos, float3 normal)
{
float ao = 0.0;
float dist = 0.0;
for(int i = 1; i <= _AoIteration; i++)
{
dist = _AoStep * i;
ao += max(0.0, (dist - GetDistance(pos + normal * dist)) / dist);
}
return 1 - ao * _AoIntensity;
}

这里我将AO的计算结果直接作为颜色输出,得到如下结果:

可以看见,缝隙、细小处的阴影也被完整表达了。


总结

至此,我们把raymarching和光照等结果都处理完毕了,我们把每一步的结果拆解表达如下。

Ray Marching在实时渲染的领域中常常被用来处理体积相关的画面效果,如体积光(Volumetric Light)等。
有关体积光和云等的效果的实现将用单独的文章来介绍。
此外,在ShaderToy上还有许多出色的艺术家使用RayMarching来实现惊人的几何和光照等效果,如📌IQ大佬用RM实现的实时渲染蜗牛,都是非常优秀的效果。

Shader入门精要读书笔记——前置知识


数学篇

  • 二/三维笛卡尔坐标系
  • 左手坐标系和右手坐标系
  • 点和矢量
  • 矩阵、矩阵运算、特殊矩阵

矩阵的几何意义:变换

在游戏开发中,三维(二维)物体的变换即是矩阵的可视化方式。这里的变化一般包括:平移、旋转和缩放。例如Unity中的Transform即包括了这三个属性。

什么是变换?
变换指将数据(位置、方向、甚至颜色等)通过计算进行转换的过程。

变换中的一个重要类型是线性变换,线性变换指满足以下两个条件的变换:
$f(x)+f(y)=f(x+y)\quad(1)$
$kf(x)=f(kx)\quad(2)$
上面提到的旋转和缩放就是线性变换。
值得注意的是,平移并非线性变换,如我们从点(1, 1, 1)进行2次(1, 2, 3)的平移,代入上(1)式,得
$f(1,2,3)+f(1,2,3)=(4,6,8)$
$f((1,2,3)+(1,2,3))=(3,5,7)$
可见两式结果并不相等,因此我们不能仅使用3x3的矩阵来表示上面的所有变换。


齐次坐标

为了表示所有变换,我们将3x3的矩阵扩展到4x4的矩阵,相应地,方向的向量也需要从三维矢量扩展到四维矢量,扩展后的坐标便称为齐次坐标
我们把点和方向扩展到四维向量时,按以下方式填充它们的第四维元素w(这么填充的原因在后续变换时可以看到):
$点向量 P(x,y,z)—->P(x,y,z,1)$
$方向向量 D(x,y,z)—->D(x,y,z,0)$


变换矩阵

所有的变换矩阵都可以表示如下
$
\left[
\begin{matrix}
M_{3×3} & t_{3×1} \\
0_{1×3} & 1 \\
\end{matrix}
\right]
$
其中左上角的M用作缩放和旋转,右上角的t用作平移。


平移矩阵

平移矩阵如下,左右分别为对向量的计算结果

可见平移矩阵对点产生了正确的偏移,而不会对方向产生影响。
平移矩阵的逆矩阵即右上的t部分取符号相反。
平移矩阵并非正交矩阵。


缩放矩阵


缩放矩阵对点和方向均会产生影响。
缩放矩阵的逆矩阵即每项取倒数。
缩放矩阵并非正交矩阵。
缩放系数 $k_1=k_2=k_3$ 的称为统一缩放(uniform scale),否则称为非统一缩放(nonuniform scale)

  • 注意,非统一缩放会改变与模型相关的角度,如后续提到的法线变换

旋转矩阵


旋转矩阵分别根据物体绕的坐标轴,可以分为3部分。


复合变换

不同的变换可以通过矩阵乘法来进行组合,如
$
P_{new}=M_{translation}M_{rotation}M_{scale\theta}P_{old}
$
这里我们如上述图中使用的都是列矩阵,阅读顺序为从右到左,因此这里变换的顺序为先缩放,再旋转,再平移,这是符合直觉的(如果先平移,再缩放,则缩放会把平移的位移进一步缩放)。
这里的矩阵顺序必须严格按照变换顺序来计算,其根本原因是矩阵乘法不满足交换律


坐标空间

在游戏开发中,我们需要用到很多不同的坐标系。

为什么要用那么多坐标系?
因为不同场合使用不同的坐标系方便。所有坐标系理论上都是平等的,只有方便/麻烦之分,而没有对错之分。

坐标空间的转换

已知子坐标空间C的三个坐标轴在父坐标空间P下的表示$x_c,y_c,z_c$,以及原点位置$O_c$,当已知一个子坐标空间下的点$A(a,b,c)$,我们可以得到
$A_p=O_c+ax_c+by_c+cz_c$
$A_p=(x_{oc},y_{oc},z_{oc})+\left[
\begin{matrix}
x_{xc} & x_{yc} & x_{zc} \\
y_{xc} & y_{yc} & y_{zc} \\
z_{xc} & z_{yc} & z_{zc} \\
\end{matrix}
\right]
\left[
\begin{matrix}
a \\
b \\
c \\
\end{matrix}
\right]$
为了去掉这个加号(即平移),我们将其扩展到齐次坐标
$A_p=(x_{oc},y_{oc},z_{oc}, 1)+\left[
\begin{matrix}
x_{xc} & x_{yc} & x_{zc} & 0 \\
y_{xc} & y_{yc} & y_{zc} & 0 \\
z_{xc} & z_{yc} & z_{zc} & 0 \\
0 & 0 & 0 & 1 \\
\end{matrix}
\right]
\left[
\begin{matrix}
a \\
b \\
c \\
1 \\
\end{matrix}
\right]$
$=
\left[
\begin{matrix}
1 & 0 & 0 & x_{oc} \\
0 & 1 & 0 & y_{oc} \\
0 & 0 & 1 & z_{oc} \\
0 & 0 & 0 & 1 \\
\end{matrix}
\right]
\left[
\begin{matrix}
x_{xc} & x_{yc} & x_{zc} & 0 \\
y_{xc} & y_{yc} & y_{zc} & 0 \\
z_{xc} & z_{yc} & z_{zc} & 0 \\
0 & 0 & 0 & 1 \\
\end{matrix}
\right]
\left[
\begin{matrix}
a \\
b \\
c \\
1 \\
\end{matrix}
\right]$
$=
\left[
\begin{matrix}
x_{xc} & x_{yc} & x_{zc} & x_{oc} \\
y_{xc} & y_{yc} & y_{zc} & y_{oc} \\
z_{xc} & z_{yc} & z_{zc} & z_{oc} \\
0 & 0 & 0 & 1 \\
\end{matrix}
\right]
\left[
\begin{matrix}
a \\
b \\
c \\
1 \\
\end{matrix}
\right]$
$=M_{c->p}P_A$
于是我们便得到了从坐标空间C转换到坐标空间P的变换矩阵$M_{c->p}$。
现在我们只取左上角的3x3矩阵作为讨论,来求从P到C的变换矩阵。
因为坐标轴均为单位向量,所以$M_{c->p}$是一个正交矩阵,其逆矩阵等于其转置矩阵
所以我们有
$M_{p->c}=M^{-1}_{c->p}=M^T_{c->p}$
$=\left[
\begin{matrix}
x_{xc} & y_{xc} & z_{xc} \\
x_{yc} & y_{yc} & z_{yc} \\
x_{zc} & y_{zc} & z_{zc} \\
\end{matrix}
\right]$
这样我们就求出了从P空间转换到C空间的变换矩阵。


常用的坐标空间

模型空间(Model Space)

我们拿到的模型(网格数据Mesh),其中的顶点均是以模型坐标系存储的。
模型空间有时也被称作对象空间(Object Space)局部空间(Local Space)


世界空间(World Space)

世界空间是我们处理计算机图像时接触到的最大的坐标系,且其只有一个。
从模型空间变换到世界空间,本质上就是对每个顶点进行平移,旋转,缩放的过程。
其变换矩阵如下
$M_{model}=M_{translation}M_{rotation}M_{scale\theta}$
所以我们有$P_{world}=M_{model}P_{model}$


观察空间(View Space)

观察空间即摄像机空间(Camera Space),是以摄像机(也就是观察者)为原点的空间坐标系。

  • 如何得到顶点在观察空间的坐标?
  1. 计算观察空间的3个坐标轴在世界空间下的坐标,运用前述方法计算出世界空间到观察空间的变换矩阵。
  2. 想象相机在世界空间下的变换过程,即先旋转平移,我们用逆变换将其回到世界空间原点,就相当于把所有世界空间中的顶点变换到观察空间下。

显然,两者得到的结果是完全相同的


裁剪空间(Clip Space)

裁剪空间的目的是对顶点进行裁剪,以此来判断那些顶点需要被显示,那些顶点因为在屏幕外而需要被裁剪
这里的”屏幕外“由视锥体决定,最常用的是透视投影正交投影,来定义2种不同的视椎体,效果如下图。

  • 这个矩阵有什么用呢?
    我们用这两种投影矩阵变换世界空间下的坐标后,将会得到一组新的$(x, y, z, w)$坐标,这组坐标将会让我们在裁剪顶点时计算更方便。正常地裁剪,我们需要判断一个顶点的坐标是否被视椎体的6个平面包围,这个计算的消耗太大;而变换后,我们只需要用x,y,z分别与第四个分量w作比较即可。

如下图是透视矩阵以及变换后的顶点坐标

如下图是透视矩阵变换后的关键顶点的坐标

如下图是正交矩阵以及变换后的顶点坐标

如下图是正交矩阵变换后的关键顶点的坐标


屏幕空间(Screen Space)

屏幕空间坐标,即二维坐标,是显示器上的像素位置,其范围是$(0, 0)—(Screen_{Width}, Screen_{Height})$。
经过上一步的投影矩阵变换,现在我们计算屏幕空间坐标就变得非常简单了。
将上一步的x,y分别除以w分量后,我们得到了2个范围在(-1,1)的分量(注:OpenGL中这个值是(-1, 1),而DirectX中这个值的范围是(0, 1)),我们将这个范围remap到屏幕分辨率即可。

值得注意的是,这里的z分量除以w后,一般情况下都被用作了深度缓冲,这个值的范围是(0, 1)。深度缓冲将被用作深度检测等地方。

  • 重要的是,注意到上述透视矩阵变换后的顶点坐标,我们将z除以w分量后可以得到:
    $\frac{Far+Near}{Far-Near} + \frac{2*Near*Far}{z(Far-Near)}$
    简化换元后为
    $k\frac{1}{z}+C$
    可以看到,经过透视矩阵转换后,Z的值与转换前并非线性关系,即深度值非线性的
    此外,正交矩阵并不存在这个问题。

如下图,为变换后的深度值随原Z坐标变化的曲线


后续

在一系列变换之后,片元(fragment)还需要通过一系列测试,如深度测试模板测试等之后,才可以正式将颜色着色在该像素上。
此外,在着色的时候,也有着许多不同的混合方式,如不透明物体会将自身颜色直接替换掉当前像素的颜色缓冲,并更新深度值;而透明物体会将自身颜色与颜色缓冲中的颜色值进行混合,这里的混合又有多重计算方式,如常见的计算方式是
$Color_{Final} = Alpha * Color_{Fragment} + (1 - Alpha) * Color_{Origin}$


总结

这里整理一下上述提到的渲染管线的整体流程如下

经过上述的步骤,模型中的顶点就正式被渲染到了显示器的像素上。