分类: 技術博客

  • 日语初级下-语法笔记

    日语初级下-语法笔记

    14動詞1.0

    动词分类

    动词的敬体及动词的ます形 <V ます形>

    五段動詞:

    ウ段变为イ段假名,再接ます

    一段動詞:

    去掉,再接ます

    三段动词:

    去掉“ます”后所剩下的动词词干部分即为“动词的ます形”,也称为:动词第一连用形。

    N(时间)に <时间点>

    强调动作或事件发生的具体时间。

    时间点可以是具体的时刻、日期、星期等。

    时间+ごろ

    表示大致的时间。

    但是不能表示持续时间,不能说3時間ごろ』,这个时候要用『くらい/ぐらい』。

    • 7ごろに起きます。大约7点起床。
    • (はる)ごろ(さくら)()きます。大约在春天樱花开放。
    • 午後5時ごろに電話をください。请在下午5点左右打电话给我。

    〜くらい(ぐらい)

    大约···。···左右。

    大约的数量、时间、程度或范围。

    前面音为清音时用「くらい」,而前面音为浊音或拗音时用「ぐらい」

    なる <变化>

    成为。变成。

    描述从一种状态变化为另一种状态。

    名词 + に + なる

    い形容词去「い」+ + なる

    な形容词 + に + なる

    N+になる表示身份、地位或角色的变化。

    A+〜なる表示性质、状态的变化。

    V ましょう。/V ましょうか。 <劝诱(V ましょう-Ⅰ)>

    我们···吧。

    动词的「ます形」(去掉「ます」),加上「ましょう」。

    用于提出建议、邀约或表达一种确定的意向,常用于提议一起做某事。

    〜ほうがいい <建议>

    最好···。应该···。

    ···(做)比较好。

    名词+のほうがいい。

    い形容词:直接加「ほうがいい」。

    • 安い(やすい)(やすい)ほうがいい → (东西)便宜点比较好

    な形容词:加「なほうがいい」。

    动词的过去形+「ほうがいい」,表示最好做某事。

    动词的未然形+「ないほうがいい」,表示最好不要做某事

    常用来劝告对方采取某种行动。提出建议或表达主观评价。

    时间读音

    小时(じかん):小时数时,「時」后面加「かん」表示时间段,例如「いちじかん」。

    分钟(ふん/ぷん):表示分钟的读音有「ふん」和「ぷん」,具体根据数字变化。例如「いっぷん」(1分钟)、「にふん」(2分钟)。

    小时读音分钟读音
    1小时いちじかん1分钟いっぷん
    2小时にじかん2分钟にふん
    3小时さんじかん3分钟さんぷん
    4小时よじかん4分钟よんぷん
    5小时ごじかん5分钟ごふん
    6小时ろくじかん6分钟ろっぷん
    7小时しちじかん7分钟ななふん
    8小时はちじかん8分钟はっぷん
    9小时くじかん9分钟きゅうふん
    10小时じゅうじかん10分钟じゅっぷん
    11小时じゅういちじかん11分钟じゅういっぷん
    12小时じゅうにじかん12分钟じゅうにふん
    20分钟20分钟にじゅっぷん
    30分钟30分钟さんじゅっぷん
    40分钟40分钟よんじゅっぷん
    50分钟50分钟ごじゅっぷん

    特别读音提示:

    • 4小时:よじかん(不是「しじかん」)。
    • 7小时:しちじかん或ななじかん(一般读作しちじかん)。
    • 9小时:くじかん(不是「きゅうじかん」)。
    • 1、6、8、10、20、30分钟等特殊读法:いっぷん、ろっぷん、はっぷん、じゅっぷん等。

    日期(日にち):表示某一天的日期,用于标记具体的日子。例如「1月1日」读作「いちがつついたち」。

    天数(ていすう):表示经过的天数,用于描述时间长度。例如,「1天」表示为「いちにち」。

    日期(日にち)读音天数(ていすう)读音
    1日(1号)ついたち1天いちにち
    2日(2号)ふつか2天ふつか
    3日(3号)みっか3天みっか
    4日(4号)よっか4天よっか
    5日(5号)いつか5天いつか
    6日(6号)むいか6天むいか
    7日(7号)なのか7天なのか
    8日(8号)ようか8天ようか
    9日(9号)ここのか9天ここのか
    10日(10号)とおか10天とおか
    11日(11号)じゅういちにち11天じゅういちにち
    12日(12号)じゅうににち12天じゅうににち
    13日(13号)じゅうさんにち13天じゅうさんにち
    14日(14号)じゅうよっか14天じゅうよっか
    15日(15号)じゅうごにち15天じゅうごにち
    16日(16号)じゅうろくにち16天じゅうろくにち
    17日(17号)じゅうしちにち17天じゅうしちにち
    18日(18号)じゅうはちにち18天じゅうはちにち
    19日(19号)じゅうくにち19天じゅうくにち
    20日(20号)はつか20天はつか
    21日(21号)にじゅういちにち21天にじゅういちにち
    22日(22号)にじゅうににち22天にじゅうににち
    23日(23号)にじゅうさんにち23天にじゅうさんにち
    24日(24号)にじゅうよっか24天にじゅうよっか
    25日(25号)にじゅうごにち25天にじゅうごにち
    26日(26号)にじゅうろくにち26天にじゅうろくにち
    27日(27号)にじゅうしちにち27天にじゅうしちにち
    28日(28号)にじゅうはちにち28天にじゅうはちにち
    29日(29号)にじゅうくにち29天にじゅうくにち
    30日(30号)さんじゅうにち30天さんじゅうにち
    31日(31号)さんじゅういちにち31天さんじゅういちにち

    特别读音:

    15動詞2.0

    日语的自他动词与助词“を” <“を”:提示宾语>

    自动词表示状态或动作的自然发生,而他动词则需要宾语。

    助词「を」在句子中用于提示他动词的宾语。

    N で

    「で」用于提示<场所>

    在···(地点)。

    N(场所) + で + 动作,表示动作或事件发生的场所。

    「で」用于提示<工具、手段>

    用···(工具/方式)。

    N(工具/手段) + で + 动作,表示动作的工具或手段。

    「で」用于提示<原因、理由>

    因为···。

    N(原因/理由) + で + 状态/结果,表示事件或状态的原因、理由。

    N に <动作的对象>

    给···。对···。

    “に”接在名词后,表示传达、给予等动作的对象。

    N なら

    如果是···。至于···。要是···(N)···的话,···。

    接在名词后,表示条件或假设,限定语境和表达更具体的意思。

    N について

    关于···。

    关于……的(某物)。

    N(名词) + について + 动词,表示谈论、描述、调查、询问的主题表示。

    N についての 后接名词,表示「关于……的(某物)」。具体跳转至17课。

    16去某地做某事

    V ます形+に+行く/来る/帰る <有目的的移动>

    去/来/回去···(某地)···做···(某事)···。

    V(ます形)+に+行く/来る/帰る,「去/来/回」为了完成某个动作。

    动词一般是带目的的动词,比如说:『買う』、『見る』、『勉強する』等等。

    V ます形+ながら、〜

    一边···,一边···。

    V(ます形)+ながら、〜,表示主语在同一时间内做两个动作。后面的才是主要动作。

    数量词も <主观认为其多>

    足足···。竟然有···。

    数量词+も,表示说话人主观认为数量很多,带有惊讶、强调的语气。

    〜でしょ(う) <确认、反问-Ⅰ>

    ···吧?不是···吗?

    名词/形容词+でしょ(う),表示和对方确认某件事情的真实性或表达带有怀疑、推测的语气。

    ため <目的>

    为了···。以便···。

    N(名词) + の + ため

    V 辞书形 + ため

    表示某人做某事的目的或原因。后面通常接「に」来表明目的的方向或行为。

    目的一般是积极的。

    〜の(/ん)です。 <解释说明−Ⅱ(动词)>

    是为了···。因为···。

    动词普通形+のです / んです,用于解释说明,进一步补充。

    一般有如下使用情况:

    1. 解释原因,回答、解释对方的疑问。
    2. 补充说明,让听者更好地理解当前情况。
    3. 强调,强调某个动作的正当性或不可避免性。
    4. 遅れたのです。因为迟到了。
      1. 这里のです是:解释原因
      1. 遅れる→遅れた,二类动词过去。
    5. 来週、日本に行くんです。下周要去日本(所以……)。
      1. 这里のです是:补充说明
    6. 止めたくないんです。我不想放弃。
      1. 这里のです是:强调
      1. やめる→止めたい→止めたくない,意愿形的否定,表示不想放弃。

    V ませんか。 <劝诱>

    要不要···?可以一起···吗?

    V(ます形)+ませんか,用于劝诱或邀请对方一起做某事。

    以礼貌的否定形式表达,显得比较委婉客气。

    17简体

    动词的简体形式

    日语中的动词有敬体简体两种形式。在动词句中,敬体主要用于正式或礼貌的场合,而简体则用于随意或日常对话。

    动词敬体、简体四象限对照表

     肯定否定
    现在/将来敬体:〜ます敬体:〜ません
     简体:辞书形简体:〜ない
    过去敬体:〜ました敬体:〜ませんでした
     简体:〜た形简体:〜なかった

    例子:

    「読む」肯定否定
    现在/将来敬体:読みます敬体:読みません
     简体:読む简体:読まない
    过去敬体:読みました敬体:読みませんでした
     简体:読んだ简体:読まなかった
    「来る」肯定否定
    现在/将来敬体:来ます(きます)敬体:来ません(きません)
     简体:来る(くる)简体:来ない(こない)
    过去敬体:来ました(きました)敬体:来ませんでした(きませんでした)
     简体:来た(きた)简体:来なかった(こなかった)

    V る(动词字典形)

    动词字典形(辞书形),也称为「Vる」形,相当于动词的原形。表示一般现在时和将来时。

    用于陈述事实、描述习惯、表达将来的动作等:

    用于简单、随意的陈述:

    与其他语法组合:

    V ない(动词未然形)

    不做某事。不会发生某事。不要···。

    动词的未然形或否定形,用于表示否定。

    一类动词(五段动词)

    「う段」→「あ段」+「ない」

    特别注意:「ある」变成「ない」,表示“没有”。

    二类动词(一段动词)

    「る」去掉,加上「ない」

    三类动词(不规则动词)

    矩形

    “V ませんでした”的简体形式是“V ない”的过去时,即“V なかった”。

    动词连体形

    ···的。···时候的。

    与动词的辞书形(字典形)相同。用于修饰名词或在句子中充当定语。

    N1(周期)に N2(数量)V <频率>

    每···(时间)···几次。

    N1(周期)+ に + N2(数量)+ V,表示某个动作或行为的频率。

    N1 についての N2 <关于>

    关于…(名词 1)…的…(名词 2)…。

    N について,表示「关于···」。具体跳转至15课。

    N についての后接名词,表示「关于……的(某物)」。

    ほとんど(/あまり)V ない

    几乎不···。差不多不···。(不太)···。

    ほとんど + V ない,强调某个动作几乎不发生,或者数量极少,接近于没有。

    あまり + V ない,表示“不是很……”,不太频繁或数量不多,语气上比「ほとんど」稍弱。

    N+助词+の N

    助词「の」通常用于表示所属关系或修饰关系,即“···的···”。

    「の」还可以连接一些带有具体意义的助词(如「で」「と」「から」「へ」「まで」等),形成「N+助词+の+N」的结构。

    「N で」表示动作或事件发生的场所、手段:

    「N と」表示动作的共同参与者或关系:

    「N から」表示动作的起点或来源:

    「N へ」表示方向或目的地:

    「N まで」表示动作的终点或时间的截止:

    注意,没有“NにのN”。要用“へ”来替换。“NへのN”来表示方向或目标。

    N1や N2など <列举>

    ···呀···之类的。例如···和···等。

    N1+や+N2+など,列举多个名词中的几个,但不包括所有的内容。

    18授受动词、连用形

    あげる/くれる/もらう <授受动词>

    在日语中,需要根据不同主语区分“给”。这一系列动词用于表达物品的授受关系,成为“授受动词”。

    『あげる』

    给予他人。N1把物品N给予N2

    N1(给予者/わたし)+ は + N2(接受者)+ に + N(物品)+ を + あげる,N1将物品或帮助给予N2

    通常是我(说话人)将某物给予他人。

    但当N1(给予者)和N2(接受者)都不是“我”时,具体分为下面两种情况:

    N1 和 N2 在说话人心理上的距离相同。

    说话人心理上比较倾向于N1

    注意事项,使用「あげる」时需考虑说话人的心理倾向,心理距离上有显著差异,则可能无法使用「あげる」。

    除“あげる”之外, “やる”也是“给”的意思,与“あげる”的用法基本相同。

    『くれる』

    给“我”或“我们”。N1把物品N给予“我”或“我们”。

    N1(给予者/わたし)+ は + N2(接受者)+ に + N(物品)+ を + あげる,接受者是“我”或“我们”。接受者默认是“我”。

    N1 和 N2 都不是“我”,但说话人心理上更倾向 N2 时:

    『もらう』

    从别人那里得到。N1从N2那里得到物品N。

    N1(接受者/わたし)+ は + N2(给予者)+ に/から + N(物品)+ を + もらう,主语是接受者,用「から」强调来源。「もらう」表示得到。

    N1 和 N2 都不是“我”,但说话人心理上更倾向 N1

    「いただく」是「もらう」的敬语形式,并带有自谦的意思。

    V ます形+たい

    我想···。想要做某事。

    V(ます形)+ たい,表示愿望或想做某事。

    「たい」的活用(变化)遵循Ⅰ类形容词的规则。

    当动词是他动词且表达愿望时,宾语通常由助词「を」来提示。但是,用「たい」形的时候,助词可以用「が」。

    一般来说,「V ます形+たいです」用于第一人称。用「V ます形+たがっています」来间接询问或描述他人(通常是第三人称)的意愿是比较礼貌的。

    〜か分かりません。

    不知道···。

    动词(简体)+ か分かりません。 い形容词(简体)+ か分かりません。 な形容词(简体)+ か分かりません。 名词(简体)+ か分かりません。

    「〜か分かりません」用于表达不确定或不知道某事。

    ずつ

    数量词、程度副词+ずつ,表示某个动作以一定分量、数量反复进行。

    N にする <选择>

    选择···。决定···。

    N + にする,表示在众多选项中选择某一事物。常用于点餐、购物等场合。

    形容词连用形

    い形容词将词尾的「い」变成「く」+动词。

    な形容词在词干后加「に」+ 动词。

    “形容词连用形”是指形容词后续用言(即动词)的形式。

    也就是形容词修饰动词。

    〜ので、〜 <因果关系-Ⅱ>

    动词(简体)+ ので い形容词(简体)+ ので な形容词(简体) 去掉「だ」 + な + ので
    名词(简体)去掉「だ」 + な + ので

    「ので」用于表示因果关系,即用于陈述原因。语气比「から」更为礼貌、温和。

    19动词て形、た形

    V て (动词第二连用形)

    可以表示动作的连续、因果关系、请求、状态的进行等。

    一类动词(五段动词)的「て形」

    词尾为「う、つ、る」 → 变成「って」
    词尾为「む、ぶ、ぬ」 → 变成「んで」
    词尾为「く」 → 变成「いて」
    词尾为「ぐ」 → 变成「いで」
    词尾为「す」 → 变成「して」

    二类动词(一段动词)的「て形」

    「る」去掉,加上「て」

    三类动词(不规则动词)的「て形」

    「する」 → 变成「して」
    「来る(くる)」 → 变成「来て(きて)」

    V た

    「V た」的变化规则与动词的て形相同。

    一类动词(五段动词)的「た形」

    词尾为「う、つ、る」 → 变成「った」
    词尾为「む、ぶ、ぬ」 → 变成「んだ」
    词尾为「く」 → 变成「いた」
    词尾为「ぐ」 → 变成「いだ」
    词尾为「す」 → 变成「した」

    二类动词(一段动词)的「た形」

    「る」去掉,加上「た」。

    三类动词(不规则动词)的「た形」

    矩形

    V て、V て、V ます。 <动词的中顿>

    ···然后···,最后···。

    V1 て、V2 て、V3 ます。表示一连串的动作依次发生或连续进行,通常最后一个动作使用「ます」形,表示整个动作序列的完成。只有最后一处能体现时态。

    V てください。 <请求>

    请···(做某事)。

    V(て形)+ ください,表示请求对方做某事。日常会话、服务场所和与上司、长辈的交流中都可以使用。

    〜つもりだ。 <打算>

    (我)打算···。计划···。

    肯定形式:V る + つもりだ
    否定形式:V ない + つもりだ

    说话人打算做某事或不打算做某事。

    ~予定(よてい)だ。 <计划>

    动词接续:V る + 予定だ,用于表示计划做某事。
    名词接续:N + の + 予定だ,表示某个事件或事项的预定计划。

    比「〜つもりだ」更正式、客观,多用于已经确定的安排或计划。

    〜 中

    「中(ちゅう)」多接在带有动作意义的汉语词后,表示动作正在进行中。比如,“会議中(かいぎちゅう)”、“勉強中(べんきょうちゅう)”、”仕事中(しごとちゅう)”、”営業中(えいぎょうちゅう)”、”移動中(いどうちゅう)”、”準備中(じゅんびちゅう)” 等。

    20日语的体

    日语动词的体及其敬体、简体形式

    动词的体(アスペクト)用于表示动作的状态。体的表达包括动词的持续、完成和否定等状态。

    以「V ている」为基础,可以构成其他的动词体形式,如「V ていない」(否定形式)、「V ていた」(过去进行)和「V ていなかった」(过去否定)。

    敬体:『Vています。』、『Vていません』、『Vていました』、『Vていませんでした』。

    V ている < 动作的持续(V ている-Ⅰ)>

    正在···。

    用于描述当前正在发生的动作(动态动词)。

    V ている < 结果的持续(V ている–Ⅱ)>

    表示状态的持续(静态动词),用于描述某种状态的持续,即动作已经完成,但状态保持不变。

    V ている < 习惯性的行为(V ている −Ⅲ)>

    表示反复或习惯性动作。

    (まだ)V ていない

    还没有···。尚未···。

    まだ + V ていない(简体) まだ + V ていません(敬体)

    表示某个动作尚未发生或尚未完成。

    敬体形式多用于回答询问某个动作是否已经完成的句型「もう V ましたか。」(是否已经……了?)。

    V る前(に) < 在…之前 >

    在···之前。

    Vる(动词基本形/辞书形)+ 前(に),在做某事之前。通常表示需要在主要动作发生之前,先完成另一动作。

    V てから <…之后 >

    ···之后。···完了之后。

    V(动词て形)+ から,表示动作的先后顺序,即在完成某个动作之后,再进行下一个动作。

    (第三人称)V たがる/N をほしがる

    (第三者)想做某事。/(第三者)想要某物。

    第三人称 + V(动词ます形)+ たがる,表示第三人称“想要做某事”。

    「-たがる」是「-たい」的派生动词形式,专门用于第三人称。需要注意的是,在「V たがる」结构中,动词的宾语需要用「を」来提示,不能改为「が」。

    并且,日本人比较常用的是『たがる』的て形,也就是『たがる→たがっています』。

    第三人称 + N(名词)+ をほしがる,于表示第三人称“想要某物”。

    N が見える

    看得见···。

    N(名词)+ が見える,表示“能看到……”或“看得见……”。描述视觉上可以看见的情况,常用于表达自然景色、建筑物、特定场所等的可视性。与表示主动去看某物的「N を見る」不同,「N が見える」更多是描述自然映入眼帘的情况。

    21许可、禁止或要求

    V ないでください。 <否定性请求(禁止)>

    请勿···。

    V(动词ない形)+ でください,是「V てください」的否定形式,通常用来委婉地请求他人不要做某事。

    V てもいい <许可>

    (我)可以···吗?

    V(动词て形)+ もいい,表示允许或许可。

    V なくてもいい <非必要>

    可以不···。不必···。

    V(动词ない形)+ なくてもいい,表示非必要,允许对方不做某事。

    V てはいけない <禁止>

    禁止···。不可以···。

    V(动词て形)+ てはいけない,表示禁止或不允许做某事。适合用于传达纪律、规章制度或上下级关系中的命令。

    V なければならない <不得不,义务>

    必须···。不得不···。

    V(动词ない形)+ なければならない,强调某个行为的必要性或义务性。

    如果不 + 禁止 = 不得不、义务、必须

    なくては+ならない
    なくては+いけない
    なければ+ならない
    なければ+いけない
    なくちゃ(口语)+ならない
    なくちゃ(口语)+いけない
    なきゃ(口语)+ならない
    なきゃ(口语)+いけない

    V ることができる <能够>

    能够···。可以···。

    Vる形 + ことができる,表示“能够”或“可以”。
    ことができる→ことができない,表示“不能够”或“不可以”。

    N だけ+助词 <限定>

    只···。仅仅···。

    N+だけ+で、に、は、を、が等助词。当 “だけ” 后接 “を” 或 “が” 时,这两个助词通常可以省略。强调只限于某个对象或范围。

    だけで:表示仅凭某个条件就足够。“只用···。”

    だけに:表示原因或背景,强调“正因为……所以……”

    だけは:强调限定对象。

    だけを:表示只限定于特定的对象, 常省略。

    22时态意义

    V る/V た時(に)

    在……的时候

    动词字典形(V る)+ 時
    动词た形(V た)+ 時

    分别用于描述 动作尚未发生或正在进行中 和 动作已经完成 的时间点。

    V たことがある < 经历 >

    (曾经)···过···。

    V(动词た形)+ ことがある,表示曾经有过某种经历,相当于“曾经……过……”
    V(动词た形)+ ことがない,否定形式,表示没有过某种经历。

    用于描述个人的过去经历或体验,通常指曾经做过某事,或在某时间之前有过该经历。

    主要用于描述很久以前曾经拥有过的经历,近期发生的事情不用这个句型

    V る/V ないことがある < 有时、偶尔 >

    有时会···。偶尔会···。

    有时候不···。偶尔不···。

    V ることがある:表示“有时会……”或“偶尔会……”
    V ないことがある:表示“有时不……”或“偶尔不……”

    V ます形 + 方

    (做)···的方法。

    V(动词ます形去掉「ます」)+ 方(かた),表示“……的方法”或“……的方式”。

    N がする

    有……的味道/声音/气味。

    N(名词)+ がする,表达某种感官体验,是自然感受到的结果,而不是主动去感知。

    (おと)がする(某种环境、物体的声音)、声(こえ)がする(某种人的声音)、匂い(におい)がする(发出某种气味)、味(あじ)がする(具有某种气味)、臭い(くさい)がする(闻到异味)。。。

    N(场所)を < 出发点、通过 >

    N(场所)+ を + 自动词,表示从某地点出发或通过某路径移动的含义。

    〜でしょう < 推测 >

    ···吧。应该···。可能···。

    动词(V 简体)+ でしょう
    类形容词(简体)+ でしょう
    类形容词(词干)+ でしょう(去掉「だ」,直接接「でしょう」)
    名词(N)+ でしょう(去掉「だ」,直接接「でしょう」)

    表示推测或可能性。语气上带有一定的不确定性,但基于一定的信息或判断。这个句型也可以用于询问,带有确认或探询的意味。简体形式「だろう」。

    〜 間、〜< 在…期间,…>

    在···期间,(一直)···。

    名词 + の + 間:表示在某个时间段或期间内
    V ている + 間:表示在动作或状态持续期间内发生的情况

    かな < 语气助词 >

    是不是···呢?我在想···。···吧?

    动词、形容词:用简体句接「かな」
    名词:直接接「かな」

    表达轻微感叹或不确定的自言自语,经常用于自言自语或带有猜测的场合,表示说话人对某事物的不确定或犹豫。

    23授受动词作助动词

    〜ても

    就算···,也···。即使···,也···。

    动词:V て形 + も
    类形容词:形容词(い形容词去「い」+ く)+ ても
    类形容词:形容词(な形容词词干)+ でも
    名词:名词 + でも

    表示即使在某种情况下,结果也不改变,多用于表达某种坚定的结果或状态,即无论条件如何,结果依旧。

    N(时间点)までに < 期限 >

    (在)···之前。

    名词 + までに:表示在该名词时间点之前。
    动词字典形 + までに:表示在该动作或事件发生之前。

    表示截止时间或期限,强调时间的最后期限,要求动作在指定的时间点前完成。

    〜と言う < 引用 >

    引用的句子』+ と + 言う/聞く/答える,用于引用对方的原话内容,带有较强的客观描述感。
    简体句 + と + 言う/聞く/答える,将原话转换为简体句,语气更自然,适合在日常会话中使用。

    用于引用别人的话或表达间接引述,表示听到或得到的信息。

    〜と思う

    (我)认为···。(我)觉得···。(我)想···。

    简体句 + と思う,用于表达自己的想法、意见或推测。助词「と」用于提示想法的内容,表示「思う」的对象。主要用于第一人称,即表达自己主观的判断或看法,一般不使用直接引语的形式,因此「と」前的内容要用简体形式来表达。无论句子的语气如何,「と」前的内容都需用简体。

    〜かもしれない

    也许···。可能···。

    动词简体形 + かもしれない
    い形容词简体形 + かもしれない
    な形容词词干 + かもしれない
    名词 N(简体形去「だ」)+ かもしれない

    表示说话人对某个情况并不完全确定,但认为有可能发生的语气。语气上比「〜でしょう」更加不确定,带有一种猜测的意味。

    〜後(で)、〜

    ···之后,···。

    动词过去形(V た)+ 後(で):表示在某个动作完成之后。
    名词 + の + 後(で):表示在某个时间点或事件之后。

    N しか V ない < 限定 >

    只。仅仅。

    N + しか + V(否定形),表示“只有……”、“仅仅……”,强调数量少或范围有限。虽然谓语动词使用了否定形式,但整个句子的意思是肯定的,强调数量少或范围有限。

    N1 だけでなく N2

    不止···N1···,···N2(也)···。不只···,而且···。

    N1 + だけでなく + N2 + ,表示不仅限于 N1,还有 N2,可以用于描述物品、人物、特征等多个方面的列举,也适合用在需要强调范围扩大的句子中。

    V てあげる/くれる/もらう

    『あげる/くれる/もらう <授受动词>』 是物品的接受,而本节语法强调动作的授受

    『V てあげる』

    我为别人做某事。

    N1(我/给予者)は N2(接受者)に V てあげる,对对方的帮助,带有施恩之感,因此在正式场合或对不亲近的人应避免使用。

    『V てくれる』

    他人为我做某事。

    N1(给予者)は N2(我/接受者)に V てくれる,表达别人为自己(或自己一方的人)做某事,接受者(通常是“我”)使用「に」提示,但「私に」可以省略。

    『V てもらう』

    他人为我做某事。

    N1(给予者)は N2(我/接受者)に V てくれる,用于表达别人为自己(或自己一方的人)做某事,「私に」也可以省略。

    貰う
    → 貰います(ます形)
    → もらって(て形)
    → もらった(た形)

    24动词可能态

    动词可能态

    能够做某事/有能力做某事

    五段动词(う段动词)

    ウ → エ + る

    返す(かえす)→ 返せる(かえせる)

    書く(かく)→ 書ける(かける)

    泳ぐ(およぐ)→ 泳げる(およげる)

    待つ(まつ)→ 待てる(まてる)

    読む(よむ)→ 読める(よめる)

    遊ぶ(あそぶ)→ 遊べる(あそべる)

    送る(おくる)→ 送れる(おくれる)

    一段动词(る动词)

    る → られる

    辞める(やめる)→ 辞められる(やめられる)

    寝る(寝る)→ 寝られる(ねられる)

    不规则动词

    する → できる

    勉強する → 勉強できる

    来る(くる)→ 来られる(こられる)

    ら抜き言葉

    网络常见。一段不规则动词的 ら 直接省略。

    〜ところだ。

    形式名词「ところ」用于表示动作所处的阶段。

    V るところだ。

    表示即将开始某个动作,相当于“正要……”、“马上要……”。

    V ているところだ。

    表示正在进行某个动作,相当于“正在……”。

    V たところだ。

    表示刚刚完成某个动作,相当于“刚……完”。

    V たばかりだ。

    刚刚···。刚···不久。

    动词た形 + ばかりだ,强调事件刚完成,时间过去得很短,带有新鲜感或“刚结束”的意味。

    时间主观性较强,取决于说话者的感受。

    N でも < 示例 >

    ···之类的怎么样?

    名词 + でも,用于提出一个建议或例子,供对方考虑。

    N で < 限定(数量)>

    助词“で”接在数量词或关于数量词的疑问词后。

    N に)V てほしい / V ないでほしい

    希望你···。希望你不要···。

    V て形 + ほしい:希望对方做某事
    V ない形 + でほしい:希望对方不要做某事
    (N に)V てほしい / V ないでほしい:指明希望的对象

    当说话人希望某种状态或现象产生时,用“が”提示。谓语动词一般是自动词。

    〜みたいだ < 推測 >

    好像···。似乎···。像···一样。

    V 简体 + みたいだ。
    A 简体 + みたいだ。
    AⅠⅠ 简体(〜)+ みたいだ。
    N 简体(〜)+ みたいだ。

    表达说话人对当前情况的主观推测,不确定是否属实,但从外表或状况判断出一个可能性。

    言っていた < 转述 >

    他说/她说···。

    V(简体形)+ と言っていた
    A(い形容词)+ と言っていた
    A(な形容词词干 + )+ と言っていた
    N(名词 + )+ と言っていた

    转述第三人称的发言或意见,它用「言っていた」的形式,带有回顾的意味,即转述发生在过去的信息。

    25书面语简体、连体形

    N ではなく(て)

    不是···,而是···。

    名词 + ではなく(て)。

    表示否定前面的内容,并且在后面加上正确的内容。ではなくて、じゃなくて多用于口语。

    N である

    简体书面语版本的 “です”“だ” 。

     肯定否定
    现在、将来N である。N ではない。
    过去N であった。N ではなかった。

    〜からだ。< 原因、理由 >

    是因为···

    句子 + からだ。

    否定形式是“〜からではない”,“并不是因为···”。

    N にとって

    对···来说。对···而言。因为···的缘故。

    N+ にとって。

    表达对某个事物、情况或人从特定主体(N)的角度去看待时的评价或立场。

    〜という N

    叫做……的 N。所谓的 N。

    〜という + 名词(N)。

    当需要谈及自己或对方所不了解的人或事物时,可以使用 “という” 进行提示。

    句子 + という + 名词(N)。

    也可以接在句子的后面,用于解释某个名词的具体内容。

    疑问词でも

    无论···都···。

    疑问词 + でも。

    任何情况、任何对象都适用。

    〜ようだ。< 推测 >

    似乎···。大概···吧。好像···。

    动词普通型 + ようだ。

    い形容词普通形 + ようだ。

    な形容词词干 + な + ようだ。

    名词 + の + ようだ。

    表示说话人根据某种证据(视觉、听觉、感觉等)所做的推测或判断。

    Nのようだ。<比喻>

    像···一样。

    名词(N)+のようだ。

    一般和副词“まるで”“いかにも”等搭配使用,“(简直)像···一样”。

    こと<形式体言>

    SOV→(动词简体 + S)はOをV
    (買ったN)はパソコンです。
    (飲むN)が好きです。
    N→こと,此处填入一个形式体言,用于抽象客观的陈述,习惯规则行为。

    “こと” 除了作为一般名词用来表示“事情”外,还可以用于做 “形式体言”,将用言(动词或形容词)的连体形名词化,使其能够充当句子的主语、宾语等成分。除了“こと”,形式体言还有“の”“もの”等。

    〜すぎる <过度>

    太···。过于···。

    动词连用形+すぎる:表示动作过度

    い形容词去『い』+すぎる:表示性质过度

    な形容词词干+すぎる:表示性质过度

    注意,形容词加上すぎる之后,就变成一个动词了。

    常用于表达不好的结果,暗示“某事过头了”,带有负面的评价,有时也会有夸张的效果。

  • 波动光学渲染:全波参考模拟-学习笔记-4

    波动光学渲染:全波参考模拟-学习笔记-4

    今天继续来粗略看看Sig23这篇计算表面反射的全波参考模拟器paper。

    关键词:图形学入门、波动光学渲染、BEM、AIM、BRDF
    Wave Optics Render, Full Wave Reference Simulator

    原文:https://zhuanlan.zhihu.com/p/1471147574

    前言

    稍微总结一下目前的理解。

    基于波动光学的计算机图形学对图形学研究者而言无疑是巨大的挑战。它不仅要在计算上处理电磁波,还涉及一些量子力学的理论,想要打通物理学和图形学之间的隔阂属实不易。在基于波动光学的图形学中,光在介质中的传播不再只是直线,而是会因为波长不同,表现出自旋、偏折和衍射等多种特性。从肥皂泡、偏振镜、油膜光泽或糖分介质导致的旋光再到无线电辐射如何在城市建筑之间传播,都离不开波动光学理论。

    在知乎上也有很多大佬分享了非常详细的波动光学渲染理论基础教学。

    但是奈何阅读门槛都较高,且距离实际应用太远。波动光学渲染涉及的数学物理知识太多,就算是一行一行细看闫老师和他博士生的paper,也是寸步难行。

    图形学的圣杯是光线追踪,而全局波动光学渲染则是图形学圣杯中的圣杯。

    在Sig21上,Shlomi提出了将路径追踪与物理光学相结合实现电磁辐射传播的真实模拟。目前已经有不少方法计算电磁波的传输,从高精度但计算密集的波动求解器到快速但不够精确的几何光学方法。如下图所示,展示了当前电磁波传输的计算方法。左边最准确,右边最快。

    波动求解器(Wave Solvers)专注于求解麦克斯韦方程的精确解,但对于大型场景来说并不实用,一般用FDTD、BEM或FEM来做。

    PO基于高频近似的电磁波计算,但是对于可见光这个频率来说其实勉强够用了。PS:[Xia 2023]的黑狗毛就属于Physical Optics方法。而本文的全波参考应该还是属于Wave Solvers的方式,但是在BEM的基础上使用了PO的一些思想(等效电流等)。

    除了PO,还有一种方法介于Wave Solvers和Geometrical Optics之间,称为Hybrid GO-PO,我个人觉得应该叫做几何光学-物理光学混合方法。统一衍射理论(Uniform Theory of Diffraction, UTD)将衍射效应纳入几何光学来计算高频条件下的电磁波传输。个人理解,UTD通过计算绕射系数来补偿几何光学射线边界条件的不足,也就是说几何光学的射线也可以转弯了。这种操作在雷达探测天线设计领域非常实用。除了UTD,Hybrid GO-PO还涉及一种叫射线发射和反弹方法(Shooting and Bouncing Rays, SBR)的技术。这种技术模拟射线在物体表面多次反射,同样基于几何光学。

    我个人认为,把光理解为一种平面正弦波其实有些局限。例如3b1b的光学系列视频就把光的电场看成是一个平面正弦波。

    虽然可以解释绝大多数现象,并且非常适合入门学习,但是对于波动光学渲染的研究而言,把光的电场简单描述为平面正弦波没办法进一步解释例如本篇paper提及的高斯光束。

    但是我依旧强烈推荐不了解波动光学的读者先看这个系列的视频。

    视频通过右旋手性介质会使光“旋转”这个特殊波动光学现象,科普了一系列波动光学的有趣现象以及背后的原理。

    非常引人深思。

    最后甚至还讲到了如何利用物质波构建全息影像。

    在经典电磁场理论中,我们通常使用平面波展开电磁场,每个模式的光子在空间上是非定域的,具有无限的空间延展。这种展开方式下,一个光子处于一个频率和波矢明确的模式上,因此它在空间中“充满”了整个平面波的区域。对于自由空间中的电磁场模式非常常见,但这种平面波并不适合描述局域性。

    另一种展开方式是局域的波包(wave packet)。这种理解方式,允许我们在空间中对电磁场进行位置上的排布,即形成一系列具有一定宽度的波包。

    img

    把光单纯理解为一个正弦波其实违反了不确定性原理。如果光被视为正弦波,也就说明这个光是完全的单色光,但是现实中不可能有单色光。光信号作用基本是频域作用,空域到频域要用傅立叶变换。带宽-时间不确定性原理指出,带宽越窄,信号在时间上会越长。相反,带宽越宽,信号的时间长度越短。

    在经典电磁理论中,一个波包可以对应于一个能量集中的区域。

    但是注意光子也不能单纯理解为一种波包,而是理解为一种概率波。量子力学中的概率波函数描述了光子的存在几率。波包越集中,粒子性就越明显。这就是量子电动力学解释的波粒二象性。一个光子并不一定只能在一个波包中,它也可以描述为多个波包的叠加。因为光子状态本质上是量子场的激发,允许在不同位置的波包上进行叠加

    也就是说,一个光子可以“跨越”多个波包,即它的波函数可以在空间上以多个波包的形式存在,而不局限于某个特定位置。当一个波包模式上有一个光子时,这个波包可以看作是最低激发态;而如果波包上有多个光子(高阶激发态),则会体现出更高的能量。

    本文中提到的一种电磁波束高斯光束是一种特定的电磁波解,可以视为一种波包。高斯光束是一个具有稳定振幅和相位结构的单一波包,它在横截面上表现为高斯分布。不同于我们平常在经典电磁理论中常用的平面波解,因为高斯光束的波前是曲率变化的,不是无限延伸的平面。

    波的系综(ensemble of waves)指的是多个波的集合,这些波可能具有不同的频率、相位或传播方向。如果考虑一个系统中多个独立波包(例如多个脉冲激光束)相互叠加,可以将这些波包理解为一个波的系综。换句话说,如果有多个不相关的波包,尤其是随机相位的波包叠加在一起,它们在统计上可构成波的系综。

    当我们把电磁场展开为一系列波包,那么就可以将每个波包视为一个随机事件,他们的到达时间、相位都是随机变量。对于多个波包的集合,我们可以在不同时间、不同位置观察到这些波包的特征。在统计意义上,使用系综平均来分析光的能量分布和波动行为。


    $$
    \langle U(\vec{r}, t) \rangle = \lim_{T \to \infty} \frac{1}{2T} \int_{-T}^{T} U(\vec{r}, t) \, dt
    $$

    由于不是物理壬,微元写在积分式前我是不认可的(

    上面说到,光可以视为多个波包的集合。而不同波包之间可能会存在相位差和时间差,因此需要引入互相关函数互相干函数来描述两个不同位置的波包之间的相干性和相对相位,提供了对这些波包在时空上相似性的量化描述。如果说系综平均用于描述一个信号在统计意义下的平均行为,那么互相关函数是在时间平均的角度上,描述了不同位置和不同时间之间的相干关系波动关联性

    具体而言,互相关函数描述了位置 $\vec{r}_1$ 和 $\vec{r}_2$ 之间的波动相干性。如果两个波相干,那么这个Gamma值就会比较大:


    $$
    \Gamma(\vec{r}_1, \vec{r}_2, \tau) = \langle U(\vec{r}_1, t) U^*(\vec{r}_2, t + \tau) \rangle
    $$


    交叉谱密度(CSD)矩阵 $W(\vec{r}_1, \vec{r}_2, \omega)$ 是互相关函数的傅里叶变换,表示在频率域中两个位置之间的相干性

    还记得我们之前在Xia2023那篇黑狗毛中讨论的自相关函数吗?互相关函数需要两个信号来描述,而自相关函数只有一个信号。另一个角度来理解,自相关函数(ACF)是互相关函数的一种特殊情况。自相关函数描述的是信号与自身在不同时刻或不同位置的相关性,而互相关函数则是两个不同信号或同一信号在不同位置的相关性。互相关函数与交叉谱密度为一对傅里叶变换对,自相关函数与功率谱密度为一对傅里叶变换对。

    交叉谱密度(CSD)和互相干函数描述在不同位置间波动的相干性。而辐射互谱密度(Radiance Cross-Spectral Density, RCSD)是CSD的推广,描述的是光辐射(即能量密度)在不同位置和不同方向之间的相关性。可以理解为,RCSD在辐射度量的基础上提供了类似于CSD的相干性描述。公式中, $L(\vec{r}_1, \vec{r}_2, \omega)$ 表示在频率 $\omega$ 下,位置 $\vec{r}_1$ 和 $\vec{r}_2$ 之间的辐射强度相干性。

    辐射互谱密度传输方程(SDTE)与经典的光传输方程(Light Transport Equation, LTE)相似,但是更加适合波动光学。LTE描述的是从点到点的光辐射度传输,而SDTE则使用RCSD函数来描述光辐射的传播,相当于将传输视为区域间的相干传输。

    SDTE中的RCSD通过区域间的积分形式表达辐射的传播,可以理解为用RCSD矩阵和衍射算子代替了传统的反射和散射。并且注意,SDTE基于RCSD函数,而不是具体的光强值,这一点和LTE是有较大区别的。

    在进一步讨论利用边界元方法(Boundary Element Method, BEM)自适应积分法(Adaptive Integral Method, AIM)加速的全波参考模拟器之前,首先简单回顾下前文的内容。前文介绍了PO方法和SDTE/RCSD理论。这些方法用于不同的散射计算需求,但它们的基本理论和适用范围不同。本文将讨论一种通过BEM和AIM结合提供高精度的面散射模拟的方法。

    用原作者的话来总结,Wave optics在基于物理渲染里面属于很新的分支。虽然波动光学现象在生活中随处可见,但是对画面的影响并不是很大。这个方向其实还有很多可以做的地方。

    其他相关参考:


    A Full-Wave Reference Simulator for Computing Surface Reflectance

    Paper主页:

    https://blaire9989.github.io/assets/1_BEMsim3D/project.html

    ACM主页:

    https://dl.acm.org/doi/10.1145/3592414

    ACM Citations:

    Yunchen Yu, Mengqi Xia, Bruce Walter, Eric Michielssen, and Steve Marschner. 2023. A Full-Wave Reference Simulator for Computing Surface Reflectance. ACM Trans. Graph. 42, 4, Article 109 (August 2023), 17 pages. https://doi.org/10.1145/3592414

    演讲报告

    与更常用的光线追踪技术相比,波动光模拟更费时间但也更精确。

    比方说金属表面的微观划痕、毛发纤维结构,传统的光学模型渲染的,无法渲染出我们现实生活中观察到的五彩斑斓的色彩效果。

    基于波动光学的渲染是一个难题,因为解决麦克斯韦方程组需要大量复杂的计算。现有的基于波的外观模型通常采用一些近似方法。

    一种近似方法是标量场近似(scalar field approximation)。

    在波散射问题(wave scattering problem)中,电场和磁场是不同的矢量场量,具有不同极化方向(polarizations)的光由指向不同方向的场量组成。

    有些近似模型将这两个矢量场量替换为单一标量函数(single scalar function),因此可以计算光能的强度,但放弃了对极化的建模(modeling polarization)。

    另一种近似是一阶近似(first-order approximation)。假设光线仅与模型结构的每个部分发生一次反射,忽略了多次反射。然而,许多情况下这些近似都不适用。

    例如 Yu 等人与 Dr. Lawrence 团队的合作,Penn State University 制作了带有圆柱形横截面的表面,这些表面会引起多次光反射并产生结构色彩,使用近似模型无法很好地理解或预测这些现象。

    作者希望通过计算双向反射分布函数(BRDF)来尽可能精确地表征表面散射。

    现有模型都采用各种近似方法,比如基于光线、标量或一次近似的模型,在没有参考质量(reference quality)的BRDF的情况下,很难看出每种反射模型缺少了什么或适用于什么场景。

    作者的解决方案是构建一个三维四波模拟器(3D 4-way simulation),用于计算具有明确微观几何结构的表面的BRDF。

    声称不使用任何近似方法,为表面样本计算出参考质量的BRDF。

    速度方面dddd。

    作者接下来介绍他们的模拟如何工作以及它与BRDF的关系。

    首先,在左边这幅图,Input一个表面样本(定义为高度场)以及一个入射方向(在投影半球上表示)。定义了一个向表面传播的入射场,并从表面计算出一个散射场。

    中间这幅图,光束是入射场,背景中的散射场也显示在此图中。

    输出是对应给定入射方向的BRDF模式,半球每个点代表一个出射方向,颜色代表相应方向上反射光的颜色。BRDF以RGB颜色表示,这些颜色是从光谱数据转换而来。

    对于很多粗糙度不高的表面,反射模式围绕镜面方向对称并随入射光方向的移动而变化。

    接下来讲的是如何使用边界元法来解决仅在表面上的信号散射问题,从而降低了问题的维度。

    表面信号是从麦克斯韦方程解出的表面电流,在离散化后,将问题构建为一个线性系统,并求解出表面电流和散射场。

    为了使计算可行,作者将线性系统对称化,并使用一个适合对称矩阵的最小残差求解器。

    此外,使用自适应积分方法加速矩阵向量乘法,这是一种基于快速傅里叶变换的加速方法,最初用于雷达计算。

    代码大部分使用了Cuda C++包进行加速。

    接下来,展示了一些结果,说明其计算的BRDF与之前方法得出的BRDF的比较。

    [Yan 2018] 使用标量场近似的BRDF模型,这些模型只考虑一次折射。

    [Xia 2023] 这篇使用矢量场量但也只考虑一次折射。

    最精确的方法还得是咱们的。不仅使用矢量场量(vector field quantities),而且考虑了所有次序的反射(reflections of all orders)。

    上图每个入射方向对应五种BRDF,分别代表不同的计算方法。

    表面相对平滑的材料,表面上覆盖了一些各向同性的凸起(a bunch of isotropic bombs)。

    第一行显示的是法线入射(normal instance),反射模式基本上居中(reflection pattern is pretty much centered)。

    第二行由于入射光方向从某个倾斜方向来,模式向左偏移。

    由于表面不太粗糙,五种方法的结果非常相似。

    另一种材质呢,有一些棱棱角角(corner cubes)。每个corner cubes的三个面会让光多次反射,使反射光沿入射方向返回。叫做逆向反射(retroreflection)。

    咱们的模拟器也可以模拟这种现象。左边四种方法都败下阵来。

    原因在于如果只考虑一次反射,当光线击中corner cubes的一个面后,会被预测为向下进入下半球。

    最后的例子是一个表面覆盖了一些球形坑。

    由于坑边缘的高坡度(high slopes of the surface at the edges of the pits),导致多次反射的出现。

    不同的方法看到明显差异。

    可以看到预测的额外反射峰(extra lobe predicting)。(中间偏右那个部分)

    另外整体更亮了。

    这些差异源于不同次序反射间的干涉效果(interference between reflections of different orders)。

    最后,简单介绍高效计算非常多的密集采样方向的BRDF的技术。

    如果需要模拟的表面很大,速度就会很慢,并需要大量的GPU内存。

    但是计算是线性的,可以将大面积表面分解为多个较小的子区域。

    每个子区域上投射入射场,首先执行较小的子区域模拟,然后将散射场整合,得出整个大面积表面的BRDF。

    对不同子区域的散射场应用不同的复数值缩放因子(different complex value scale factors),就可以综合出对应不同入射方向的大面积表面的BRDF。

    这是因为对每个子区域的局部入射场,应用适当的相移会在表面上产生具有不同净方向的总入射场。在这张图中,入射场垂直传播。如果将五个焦点在不同空间位置的相同场叠加,得到一个空间上更宽的场,仍沿垂直方向传播。如果线性组合这些场,并对每个子区域的场应用适当的复数值缩放因子,整体场可以以略微倾斜的方向传播。

    这里解释一下为啥复数缩放因子(different complex value scale factors)可以产生不同的入射方向。

    • 这个因子可以调整波的幅度和相位。就比如往水面丢石头,两颗石头同时丢,水面的波就会更强。如果一颗稍微晚一些丢,波纹就可能会相互抵消(相消干涉)。这个因子就是控制石头投掷的时间。通过调整相位控制波的叠加方式,进而“引导”波向不同的方向传播。详细的去搜索「Beamforming」,雷达、无线通信和声纳等领域应用很广泛。

    这三张图代表光波的波前。即波峰。可以理解为光在传播中的波形截面。

    上方的图,垂直地射向表面,入射场分布集中在中心。光场集中在中心,沿垂直方向传播(即中间的黄线方向)。

    左下方,多个相同入射场的叠加效果,但叠加时相位保持一致(即没有相位差)。多个入射场相加,使得整个场在空间上分布更宽了,但传播方向还是保持垂直。

    右下方,多个入射场的叠加效果,但是在叠加时加入了相位差。相当于“偏移”了入射场的方向。呈现出一个倾斜方向。

    演示中,两个视频展示了BRDF模式的移动。(我懒得截GIF了)

    最后是大佬比心合影。

    论文

    我的第一篇文章已经粗略介绍了这项工作的内容和成果,接下来直接进入理论推导(Section3-5)。

    3 FULL-WAVE SIMULATION

    整体方法从一个表面模型开始,表面由高度场及其材料属性(如复折射率)描述,并指定一个目标点。

    为了计算给定入射方向的BRDF,定义了一个入射场,该场从特定方向传播至目标点。

    这个入射场作为输入,通过表面散射模拟进行处理,从而求解出相应的散射电磁场。

    在本节(FULL-WAVE SIMULATION)中,将重点介绍BEM在应用场景中的原理。下一节会讲解如何高效实现BEM算法,最后一节会介绍如何结合多个模拟结果,以合成在入射和散射方向上密集采样的BRDF。

    开局先来一张符号表吓一吓你。

    3.1 Boundary Element Method: The Basics

    边界元法(BEM)主要解决单一频率的散射问题,即特定频率的电磁波(包括电场和磁场)如何在不同介质的边界上反射和散射。这里的边界将空间分成了两个均匀区域,两个区域的材料特性(入射场所处的介质参数)用( $\epsilon_1, \mu_1$ )和( $\epsilon_2, \mu_2$ )表示。其中, $\epsilon$ 代表介电常数(介电率),$\mu$ 代表磁导率(磁导系数)。

    在这种方法中,我们处理的是复数值场量,这些场量既包含振幅信息,也包含相位信息(即波的传播状态)。为了简化公式,我们假设所有波都是“时间谐和”的——也就是波随时间按照特定的周期变化。在整个文中, $e^{j\omega t}$ 项被省略,以简化表述。

    3.1.1 Maxwell’s Equations and Surface Currents

    首先,麦克斯韦方程描述了电场(E)和磁场(H)是如何相互影响的,决定了光波如何在不同材料之间传播和散射。为了化简,这里用“时间谐和”的形式:

    $$ \begin{align} \nabla \times \mathbf{E} &= -\mathbf{M} – j \omega \mu \mathbf{H} \\ \nabla \times \mathbf{H} &= \mathbf{J} + j \omega \epsilon \mathbf{E} \tag{1} \end{align} $$


    等号左边描述电场和磁场在空间中的“旋转”程度。 $M$ 和 $J$ 是表面电流密度(假想的电流),分别表示磁流和电流的密度(electric and magnetic current densities)。这个公式可以理解为,电场在边界附近“旋转”时,产生磁流和磁场的变化;磁场的旋转也会产生电场和电流的变化。

    边界元法的核心思想是:在边界上引入表面电流,用这些电流来间接描述场的分布,而不需要计算每个区域中的所有点。三维问题化简为边界上的二维问题。

    3.1.2 Source-Field Relationships

    表面上的假想电流(电磁波的“源”)是如何产生散射的电磁场(“场”)的呢?

    如Fig2.所示,在区域 $R_1$ ,总场(入射场和散射场)分别表示为 $E_1$ 和 $H_1$ ;

    $$ \begin{align} &\mathbf{E}_1 = \mathbf{E}_i + \mathbf{E}_s \\ &\mathbf{H}_1 = \mathbf{H}_i + \mathbf{H}_s \tag{2} \end{align} $$


    在区域 $R_2$ ,总场表示为 $E_2$ 和 $H_2$ 。上方的散射场由上方的电磁流产生;下面的散射场由下方的电磁流产生。

    在均匀介质中,麦克斯韦方程组可以写成积分的形式,描述电场和磁场的生成方式。

    \begin{align} \mathbf{E}(\mathbf{r}) &= -j \omega \mu (\mathcal{L} \mathbf{J})(\mathbf{r}) – (\mathcal{K} \mathbf{M})(\mathbf{r}) \\ \mathbf{H}(\mathbf{r}) &= -j \omega \epsilon (\mathcal{L} \mathbf{M})(\mathbf{r}) + (\mathcal{K} \mathbf{J})(\mathbf{r}) \tag{3} \end{align}


    等号左边分别表示电磁场在 $r$ 处的电磁场强度,即描述的是场的“作用效果”。 $\mathcal{L}$ 和 $\mathcal{K}$ 是积分算子,表示场如何从表面电流和磁化强度产生。这两个算子定义为:

    $$ \begin{aligned} & (\mathcal{L} \mathbf{X})(\mathbf{r})=\left[1+\frac{1}{k^2} \nabla \nabla \cdot\right] \int_V G\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \mathbf{X}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} \\ & (\mathcal{K} \mathbf{X})(\mathbf{r})=\nabla \times \int_V G\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \mathbf{X}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} \end{aligned} \tag{4} $$


    $G(r, r{\prime})$ 是用于标量亥姆霍兹方程的三维格林函数,定义为:

    $$
    G(\mathbf{r}, \mathbf{r}^{\prime}) = \frac{e^{-jkr}}{4 \pi r} \quad \text{where } r = |\mathbf{r} – \mathbf{r}^{\prime}|
    \tag{5}
    $$


    这个函数把散射表面的源场转换为散射区域内电磁场的分布。

    本论文这里和[Xia 2023]中的公式(11)其实是一样的,但本论文将格林函数隐含在算子中,且积分域更为广泛。本质上都是描述如何从电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 生成散射电场 $E(r)$。

    求解麦克斯韦方程时,通过格林函数来整合各处的源(如电流、电荷)对空间中电磁场的影响。假设电磁场以 $e^{j\omega t}$ 的形式随时间变化(单频),就可以得到类似于亥姆霍兹方程的形式:$(\nabla^2 + k^2) \mathbf{E} = -j \omega \mu \mathbf{J}$ ,实际上这是一种“频域”形式的麦克斯韦方程。引入格林函数建立一种源-场关系,即将电流 $\mathbf{J}$ 和电荷 $\rho$ 作为“源”来计算电磁场 $\mathbf{E}$ 和 $\mathbf{H}$ 的分布。那么格林函数是满足如下方程的:$(\nabla^2 + k^2) G(\mathbf{r}, \mathbf{r}{\prime}) = -\delta(\mathbf{r} – \mathbf{r}{\prime})$ ,$\delta$ 是狄拉克δ函数,表示“点源”在空间中产生的标准波形,描述的是在空间中由一个“点源”激发的波场。通过格林函数,可以表达任意电流分布 $\mathbf{J}$ 在空间中对场点 $\mathbf{r}$ 产生的影响了!接着,每个源点上的“电流”或“电荷”都通过格林函数扩散到整个空间,对每一个场点产生累积影响。$\mathbf{E}(\mathbf{r}) = \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{J}(\mathbf{r}{\prime}) d \mathbf{r}{\prime}$ 这公式就是电场表示为源电流的积分叠加。最后,结合麦克斯韦方程组的积分形式和格林函数的思想。比如电场的积分形式可以表示为:$\mathbf{E}(\mathbf{r}) = -j \omega \mu \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{J}(\mathbf{r}{\prime}) d \mathbf{r}{\prime} – \nabla \times \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{M}(\mathbf{r}{\prime}) d \mathbf{r}{\prime}$ ,通过卷积可以获得每个源点上的微小电流分布对场点的累积影响,这是通过格林函数的积分在空间中传播和叠加的结果。格林函数作为卷积核,将空间中各点的电流分布通过积分传播到目标场点,实现了空间中各源点电流对整个场的累积影响。

    介质区域 $R_1$ 和 $R_2$ 内的电场就分别表示为公式(6)(7),包含了两个算子。

    在区域 $R_2$ 内:

    $$ \begin{align} & \mathbf{E}_1(\mathbf{r}) = -j \omega \mu_1 (\mathcal{L}_1 \mathbf{J}_1)(\mathbf{r}) – (\mathcal{K}_1 \mathbf{M}_1)(\mathbf{r}) \\ & \mathbf{H}_1(\mathbf{r}) = -j \omega \epsilon_1 (\mathcal{L}_1 \mathbf{M}_1)(\mathbf{r}) + (\mathcal{K}_1 \mathbf{J}_1)(\mathbf{r}) \end{align} \tag{6} $$


    在区域 $R_2$ 内:

    \begin{align} \mathbf{E}_2(\mathbf{r}) &= -j \omega \mu_2 (\mathcal{L}_2 \mathbf{J}_2)(\mathbf{r}) – (\mathcal{K}_2 \mathbf{M}_2)(\mathbf{r}) \\ \mathbf{H}_2(\mathbf{r}) &= -j \omega \epsilon_2 (\mathcal{L}_2 \mathbf{M}_2)(\mathbf{r}) + (\mathcal{K}_2 \mathbf{J}_2)(\mathbf{r}) \tag{7} \end{align}


    这样就得到了在不同介质区域中电场和磁场的表现形式。

    总结一下,这一小节通过假想的表面电流产生散射电磁场,将麦克斯韦方程组转化为积分表达,格林函数将电流和电荷分布转化为电磁场的积分叠加,展示了源-场关系的具体实现方式。最后给出了区域 $R_1$ 和 $R_2$ 内电场和磁场的表达式,展示了不同介质参数对场的影响。

    3.1.3 Boundary Conditions

    当电磁波在两种不同介质的边界传播时,会发生反射和折射。此时波的能量不可能凭空消失,而是在界面上平滑过渡的。如果电场或磁场在边界上不连续,就会出现不符合实际的能量跃变(即能量突然消失或增加),这违反了能量守恒。

    具体可以搜索:「Interface conditions for electromagnetic fields」。

    因此,必须在介质边界上满足一定的边界条件,以确保电磁场的连续性和能量守恒。具体而言,当电磁波在两种不同介质的界面上传播时,电场和磁场的切向分量(tangential component)需要在边界上保持连续性:


    $$
    \begin{aligned}
    & \mathbf{n} \times (\mathbf{E}_1 – \mathbf{E}_2) = 0 \\
    & \mathbf{n} \times (\mathbf{H}_1 – \mathbf{H}_2) = 0
    \end{aligned}
    \tag{8}
    $$


    并且边界上的净电磁流密度为零,即边界两侧电磁流密度方向相反大小相等。


    $$
    \begin{aligned}
    & \mathbf{J} = \mathbf{J}_1 = -\mathbf{J}_2 \\
    & \mathbf{M} = \mathbf{M}_1 = -\mathbf{M}_2
    \end{aligned}
    \tag{9}
    $$


    这两个条件同时满足,才不会破坏物理守恒。

    3.1.4 Integral Equations

    结合上面公式 (6)、(7)、(8) 和 (9),得到关于电场和磁场的积分方程,称之为 PMCHWT(Poggio-Miller-Chang-Harrington-Wu-Tsai)方程:

    $$
    \begin{aligned}
    & {\left[j \omega \mu_1\left(\mathcal{L}1 \mathbf{J}\right)(\mathbf{r})+j \omega \mu_2\left(\mathcal{L}_2 \mathbf{J}\right)(\mathbf{r})+\left(\mathcal{K}_1 \mathbf{M}\right)(\mathbf{r})+\right.}\left.\left(\mathcal{K}_2 \mathbf{M}\right)(\mathbf{r})\right]{\tan } \\
    &=\left[\mathbf{E}i(\mathbf{r})\right]{\tan } \\
    & {\left[\left(\mathcal{K}1 \mathbf{J}\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{J}\right)(\mathbf{r})-j \omega \varepsilon_1\left(\mathcal{L}_1 \mathbf{M}\right)(\mathbf{r})-j \omega \varepsilon_2\left(\mathcal{L}_2 \mathbf{M}\right)(\mathbf{r})\right]{\tan } } \\
    &=-\left[\mathbf{H}i(\mathbf{r})\right]{\tan }
    \end{aligned}
    \tag{10}
    $$


    这两个方程分别还有个名字:

    • EFIE(Electric Field Integral Equation),电场积分方程。 $j \omega \mu_1 (\mathcal{L}_1 \mathbf{J})(\mathbf{r})$ 和 $j \omega \mu_2 (\mathcal{L}_2 \mathbf{J})(\mathbf{r})$ 表示在介质 1 和 介质 2 中,电流密度 $\mathbf{J}$ 对电场的贡献。 $\mathcal{K}_1 \mathbf{M}$ 和 $\mathcal{K}_2 \mathbf{M}$ 表示在介质 1 和介质 2 中,磁流密度 $\mathbf{M}$ 对电场的贡献。
    • MFIE(Magnetic Field Integral Equation),磁场积分方程。

    总的来说,这是一种边界积分方程,专门用来求介电物体引起的电磁散射问题。有了这个PMCHWT方程,就可以准确计算电磁场的分布了。

    3.1.5 Solving for Current Densities

    这一节,需要通过上面的PMCHWT方程计算物体表面上的“电流”和“磁流”分布,这些分布决定了电磁波碰到物体后会怎么“反射”或者“折射”。

    求解表面电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 是通过将边界元离散化。对于离散单元定义一个基函数 $f_m(\mathbf{r})$ ,用基函数展开法表示电流密度和磁流密度分布。


    $$
    \mathbf{J}(\mathbf{r}) = \sum_{m=1}^{N} I_{J_m} f_m(\mathbf{r}); \quad \mathbf{M}(\mathbf{r}) = \sum_{n=1}^{N} I_{M_n} f_n(\mathbf{r})
    \tag{11}
    $$


    N 是基函数的总数;$ I_{J_m}$ 和 $I_{M_n}$ 是对应基函数的未知系数,代表了每个单元上的电流和磁流强度。

    通过这种基函数展开,连续的表面电流密度和磁流密度被分解成一系列基函数的线性组合。

    为了求解对应基函数的未知系数,将电场积分方程 (EFIE) 和磁场积分方程 (MFIE) 转化为一个线性方程组。具体使用伽辽金法(Galerkin Method)完成。这个方法基本思想是,将积分方程作用在每个基函数上,加权平均使得积分方程在每个基函数的投影方向上都成立。该方法简单的说就是离散化、找基底、算系数。一个高维的线性方程组可以用线性代数方法简化。

    这样可以将原本连续形式的PMCHWT方程的EFIE部分转换为有限个线性方程,把问题转化为解如下矩阵方程。


    $$
    \begin{bmatrix} A_{EJ} & A_{EM} \ A_{HJ} & A_{HM} \end{bmatrix} \begin{bmatrix} I_J \ I_M \end{bmatrix} = \begin{bmatrix} V_E \ V_H \end{bmatrix}
    \tag{12}
    $$


    其中


    $$
    A_{\mathrm{EJ}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[j \omega \mu_1\left(\mathcal{L}_1 \mathbf{f}_n\right)(\mathbf{r})+j \omega \mu_2\left(\mathcal{L}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{13}
    $$

    $$
    A_{\mathrm{EM}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[\left(\mathcal{K}_1 \mathbf{f}_n\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{14}
    $$

    $$
    A_{\mathrm{HJ}}^{m n} =\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[\left(\mathcal{K}_1 \mathbf{f}_n\right)(\mathbf{r})+\left(\mathcal{K}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{15}
    $$

    $$
    A_{\mathrm{HM}}^{m n} =-\int_S \mathbf{f}_m(\mathbf{r}) \cdot\left[j \omega \varepsilon_1\left(\mathcal{L}_1 \mathbf{f}_n\right)(\mathbf{r})+j \omega \varepsilon_2\left(\mathcal{L}_2 \mathbf{f}_n\right)(\mathbf{r})\right] d \mathbf{r} \tag{16}
    $$


    $$
    V_{\mathrm{E}}^m =\int_S \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{E}_i(\mathbf{r}) d \mathbf{r}\tag{17}
    $$

    $$
    V_{\mathrm{H}}^m =-\int_S \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{H}_i(\mathbf{r}) d \mathbf{r}\tag{18}
    $$

    公式(12)中,需要求出 $I_J$ 和 $I_M$ 。

    不严谨地讲,公式(13)-(16)分别表示每一小块的电流密度对产生电场的贡献、每个小块的磁流密度对产生电场的贡献、每个小块的电流密度对产生磁场的贡献和每个小块的磁流密度对产生磁场的贡献。公式(17)(18)分别表示外界入射电场对这个小块电流的“推动力”和外界入射磁场对这个小块磁流的“推动力”。强调一下矩阵里面的元素,比如 $A_{EJ}^{mn}$ ,这其实是一个二重积分。由于对源点 $\mathbf{r}{\prime}$ 的积分已经在 $\mathcal{L}_1$ 和 $\mathcal{L}_2$ 中完成了,因此导致原paper看起来是个一重积分。

    虽然原论文没有写,但是建议聪明的读者自己推导一下。尝试根据上文提到的公式(4)把公式(13)展开。我这里尝试推导了一下,有错误请指正。首先把两个算子代入,注意这里梯度算子的位置:


    $$
    \begin{aligned}
    A_{\mathrm{EJ}}^{mn} &= j \omega \mu_1 \int_S \mathbf{f}_m(\mathbf{r}) \cdot \left\{ \int_V G_1(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} + \frac{1}{k_1^2} \nabla \left[ \nabla \cdot \int_V G_1(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] \right\} d\mathbf{r} \\
    &\quad + j \omega \mu_2 \int_S \mathbf{f}_m(\mathbf{r}) \cdot \left\{ \int_V G_2(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} + \frac{1}{k_2^2} \nabla \left[ \nabla \cdot \int_V G_2(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] \right\} d\mathbf{r}
    \end{aligned}
    $$

    先考虑其中一个梯度项:

    $$
    \int_S \mathbf{f}_m(\mathbf{r}) \cdot \nabla \left[ \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} \right] d\mathbf{r}
    $$

    用向量分布积分展开:

    $$
    \int_S \mathbf{f}m \cdot \nabla B \, d r = -\int_S B (\nabla \cdot \mathbf{f}_m) \, dr + \int{\partial S} B (\mathbf{A} \cdot \mathbf{n}) \, dr
    $$

    其中,散度项 $B$ :

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime}
    $$

    但是很抱歉,这里是物理。在边界条件下,边界项直接简化为零,得到:

    $$
    \int_S \mathbf{f}_m \cdot \nabla B \, dS = -\int_S B (\nabla \cdot \mathbf{f}_m) \, dS
    $$

    对于散度项 $B$ ,可以直接展开散度:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V (\nabla \cdot G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    一个是标量函数,一个是矩阵函数,因此根据散度的乘积法则:

    $$
    \nabla \cdot (G \mathbf{f}_n) = (\nabla G) \cdot \mathbf{f}_n + G (\nabla \cdot \mathbf{f}_n)
    $$

    但是很抱歉,这里是物理。由于基函数满足无散度条件,因此这里直接简化:

    $$
    \nabla \cdot (G \mathbf{f}_n) = (\nabla G) \cdot \mathbf{f}_n
    $$

    同时我们注意到格林函数的对称性 $\nabla G(\mathbf{r}, \mathbf{r}{\prime}) = -\nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime})$ ,于是有:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = -\int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime}
    $$

    对这一项也进行分布积分:

    $$
    \int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V \nabla{\prime} \cdot (G \mathbf{f}_n) d\mathbf{r}{\prime} – \int_V G (\nabla{\prime} \cdot \mathbf{f}_n) d\mathbf{r}{\prime}
    $$

    但是很抱歉,这里是物理。边界项再次化简:

    $$
    \int_V \nabla{\prime} G(\mathbf{r}, \mathbf{r}{\prime}) \cdot \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = -\int_V G(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    最终得到:

    $$
    B = \nabla \cdot \int_V G(\mathbf{r}, \mathbf{r}{\prime}) \mathbf{f}_n(\mathbf{r}{\prime}) d\mathbf{r}{\prime} = \int_V G(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime}
    $$

    波数 $k_i$ 于介质参数的关系:

    $$
    k_i^2 = \omega^2 \mu_i \varepsilon_i \quad \Rightarrow \quad \frac{1}{k_i^2} = \frac{1}{\omega^2 \mu_i \varepsilon_i}
    $$

    另一个梯度项也是同理,最后得到最终的表达式 $A_{\mathrm{EJ}}^{mn}$ :

    $$
    \begin{aligned} A_{\mathrm{EJ}}^{mn} &= j \omega \mu_1 \int_S \int_{V_1} \mathbf{f}m(\mathbf{r}) \cdot \mathbf{f}n(\mathbf{r}{\prime}) G_1(\mathbf{r}, \mathbf{r}{\prime}) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad – \frac{j}{\omega \varepsilon_1} \int_S \int{V_1} (\nabla \cdot \mathbf{f}m(\mathbf{r})) G_1(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}n(\mathbf{r}{\prime})) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad + j \omega \mu_2 \int_S \int{V_2} \mathbf{f}m(\mathbf{r}) \cdot \mathbf{f}n(\mathbf{r}{\prime}) G_2(\mathbf{r}, \mathbf{r}{\prime}) d\mathbf{r}{\prime} d\mathbf{r} \\ &\quad – \frac{j}{\omega \varepsilon_2} \int_S \int{V_2} (\nabla \cdot \mathbf{f}_m(\mathbf{r})) G_2(\mathbf{r}, \mathbf{r}{\prime}) (\nabla{\prime} \cdot \mathbf{f}_n(\mathbf{r}{\prime})) d\mathbf{r}{\prime} d\mathbf{r} \end{aligned}
    $$

    同样的操作,得到剩下的矩阵元素,这里直接抄论文附加材料的内容了:

    $$
    \begin{aligned} A_{\mathrm{EM}}^{m n}= & A_{\mathrm{HJ}}^{m n}=\int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot\left[\nabla G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \times \mathbf{f}_n\left(\mathbf{r}^{\prime}\right)\right] d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot\left[\nabla G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \times \mathbf{f}_n\left(\mathbf{r}^{\prime}\right)\right] d \mathbf{r}^{\prime} d \mathbf{r} \\ A_{\mathrm{HM}}^{m n} & =-j \omega \varepsilon_1 \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_1} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) G_1\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_2 \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_m(\mathbf{r}) \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_2} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) G_2\left(\mathbf{r}, \mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r}\end{aligned}
    $$

    得到矩阵的每一个元素之后,引入平移不变函数(Shift-invariant Functions)。帮助得到在不同坐标系下表示格林函数及其梯度。

    $$
    \begin{aligned}
    & g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=\frac{e^{-j k_i r}}{4 \pi r} \\
    & g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{x}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(x-x^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \\
    & g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{y}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(y-y^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \\
    & g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right)=\hat{\mathbf{z}} \cdot \nabla G_i\left(\mathbf{r}, \mathbf{r}^{\prime}\right)=-\left(z-z^{\prime}\right)\left(\frac{1+j k_i r}{4 \pi r^3}\right) e^{-j k_i r} \quad \text { where } r=\left|\mathbf{r}-\mathbf{r}^{\prime}\right|
    \end{aligned}
    $$

    然后将矩阵元素展开为基函数的不同分量的组合,分量作用在平移不变函数上。最终矩阵的所有元素,都有如下形式。这样的形式可以加速边界元矩阵的构造和求解。

    $$
    \int_{\mathbf{f}m} \int{\mathbf{f}_n} \psi_m(\mathbf{r}) g\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \xi_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r}
    $$

    最终得到:


    Here, $x, y, z, x^{\prime}, y^{\prime}, z^{\prime}$ are the Cartesian components of $\mathbf{r}, \mathbf{r}^{\prime}$. Now we have for $i=1,2$ :

    $$ \begin{aligned} A_{\mathrm{EJ}, i}^{m n} &= j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m x}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n x}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad + j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m y}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n y}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad + j \omega \mu_i \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \mathbf{f}_{m z}(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \mathbf{f}_{n z}\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \\ & \quad – \frac{j}{\omega \varepsilon_i} \int_{\mathbf{f}_m} \int_{\mathbf{f}_n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) \, g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \, \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) \, d \mathbf{r}^{\prime} \, d \mathbf{r} \end{aligned} \tag{S.18} $$

    where $\mathbf{f}{m x}, \mathbf{f}{m y}, \mathbf{f}_{m z}$ are the $x, y, z$ components of the vector basis function $\mathbf{f}_m$ . Similarly, we have:

    $$ \begin{aligned} & A_{\mathrm{EM}, i}^{m n}=A_{\mathrm{HJ}, i}^{m n}=\int_{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{2, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{3, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -\int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{4, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}_{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \end{aligned} \tag{S.19} $$

    Lastly, we also have:

    $$ \begin{aligned} A_{\mathrm{HM}, i}^{m n} & =-j \omega \varepsilon_i \int_{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m x}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n x}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_i \int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m y}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n y}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & -j \omega \varepsilon_i \int{\mathbf{f}m} \int{\mathbf{f}n} \mathbf{f}{m z}(\mathbf{r}) g_{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \mathbf{f}{n z}\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \\ & +\frac{j}{\omega \mu_i} \int{\mathbf{f}m} \int{\mathbf{f}n} \nabla \cdot \mathbf{f}_m(\mathbf{r}) g{1, i}\left(\mathbf{r}-\mathbf{r}^{\prime}\right) \nabla^{\prime} \cdot \mathbf{f}_n\left(\mathbf{r}^{\prime}\right) d \mathbf{r}^{\prime} d \mathbf{r} \end{aligned} \tag{S.20} $$


    这部分代码在MVProd类中,看不懂也没问题,因为上面都是我瞎写瞎抄的,已经超出图形学的研究范畴了。

    另外作者还讨论了EFIE和MFIE的对称性。正是这种对称性使得计算效率和空间利用率更低。注意到:


    $$
    A_{EJ} = A_{EJ}^T, \quad A_{HM} = A_{HM}^T \tag{19}
    $$

    $$
    A_{EM} = A_{EM}^T, \quad A_{HJ} = A_{HJ}^T, \quad A_{EM} = A_{HJ}\tag{20}
    $$

    由于矩阵是对称的,我们不需要计算所有的矩阵元素,也不用存储所有矩阵元素。

    在解出表面电流密度后,可以再使用公式(6)来计算从散射表面向外传播的散射场。

    3.2 Rough Surface Scattering: The Specifics

    在模拟电磁波与粗糙表面之间的相互作用时,表面的不规则几何结构会对散射特性造成显著影响。作者对粗糙表面离散化,将连续的表面划分为多个单元,每个单元都可以应用PMCHWT方程做数值计算的方法。

    3.2.1 Rough Surface Samples

    将粗糙表面表示为一个二维的高度场(height field),然后将该高度场进行离散化处理,划分为多个矩形单元。

    每次模拟只考虑尺寸为 $$L_x \times L_y$$ 的表面样本。并且选择一个步进 $d$ 来定义离散化的网格。


    $$
    x_s = s \cdot d, \quad s = 0, 1, \ldots, N_x
    \
    y_t = t \cdot d, \quad t = 0, 1, \ldots, N_y
    \tag{21}
    $$


    其中,$ N_x = L_x / d$ 和 $N_y = L_y / d$ ,分别表示在 $x$ 和 $y$ 方向上被划分成的单元数。在每个离散点 $(x_s, y_t)$ 都有一个高度场 $h(x_s, y_t)$ 函数。

    作者总结了,粗糙表面的高度变化尺度非常小,通常只有几微米。也就是和可见光电磁波波长相当。

    3.2.2 Basis Elements and Functions

    每个基元有四个角,每个角有不同的高度,并且每个角对电流磁流贡献都不同。因此在每个小方块上,都定义了四个基函数,近似表示每个小方块上的情况。

    在大多数模拟中,步长 $d$ 选取为波长的 $\lambda / 16$ 左右,以确保精度。

    每个基元由两个参数 u 和 v 参数化,范围均在 [-1, 1]。

    基元的形状通过一个双线性函数 $\mathbf{r}(u, v)$ 来表示,其中 $(s, t)$ 表示当前基元的索引,且基元由四个顶点的坐标决定:

    $$ \begin{aligned} \mathbf{r}(u, v) = &\frac{(1 – u)(1 – v)}{4} \mathbf{p}_{s-1, t-1} + \\ &\frac{(1 – u)(1 + v)}{4} \mathbf{p}_{s-1, t} + \\ &\frac{(1 + u)(1 – v)}{4} \mathbf{p}_{s, t-1} + \\ &\frac{(1 + u)(1 + v)}{4} \mathbf{p}_{s, t} \end{aligned} \tag{22} $$

    其中, $\mathbf{p}{s, t} = (x_s, y_t, z{s, t})$ 是基元的四个顶点坐标。

    在每个矩形基元上定义四个基函数 $f_1(u, v), f_2(u, v), f_3(u, v), f_4(u, v)$ ,它们的形式为:


    $$
    \begin{aligned}
    & f_1(u, v) = \frac{(1 – u)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial u}, \quad f_2(u, v) = \frac{(1 + u)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial u} \\
    & f_3(u, v) = \frac{(1 – v)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial v}, \quad f_4(u, v) = \frac{(1 + v)}{J(u, v)} \frac{\partial \mathbf{r}(u, v)}{\partial v}
    \end{aligned}
    \tag{23}
    $$


    这里的 Jacobian $J(u, v)$ 表示如下:


    $$
    J(u, v) = \left| \frac{\partial \mathbf{r}(u, v)}{\partial u} \times \frac{\partial \mathbf{r}(u, v)}{\partial v} \right|
    \tag{24}
    $$


    Jacobian 的引入用于转换坐标系并确保基函数在不同的 $u, v$ 方向上具有合适的比例关系。

    3.2.3 Gaussian Beam Incidence

    由于不太了解光学,以下为个人理解。高斯光束是激光发射出去的时候,在行波场中间部分出现往内部凹陷的一种现象,换一句话说高斯光束是描述光在横截面上的能量分布。而平面波和球面波的重点是描述能量传播的方向。在传播过程中,高斯光束的波前形状近似为球面波。

    Gouy 相位(Gouy phase)是高斯光束传播中的一种相位延迟效应,光束通过焦点后相位会额外增加。另一方面,高斯光束满足麦克斯韦方程组在傍轴条件下的一个解,可以近似为非均匀的球面波。感觉目前也不用太深入研究。

    有关高斯光束的资料:

    回到原论文,高斯光束好处在于可以控制入射场的大小,进而能够控制一个稍微比照射区域大的区域的表面感应电流密度是非零数值。

    高斯光束是一种电磁波,其振幅在垂直于传播方向的平面上呈二维高斯分布[Paschotta 2008],它的能量主要集中在光束中心附近。看上方图(a),高斯光束可以用焦平面 $P$ 、中心点 $o$ 、和光束腰径(beam waist) $w$ 来描述。场强随位置的衰减关系为 $e^{-r^2 / w^2}$ ,当距离中心超过 $2.5w$ 时,场强衰减至极小,可认为几乎为零。

    虽然但是,高斯光束也具有一定的发散性。发散角 $\theta$ 近似与波长 $\lambda$ 成正比,与光束腰径 $w$ 成反比。公式:


    $$
    \theta = \frac{\lambda}{\pi \eta w} \tag{25}
    $$


    当光束斜着射入表面时,高斯光束在焦平面上有一个椭圆形的横截面。如下图所示。在两个垂直方向上有不同的腰斑尺寸(beam waist):一个平行于入射方向和表面法线的平面,另一个与之垂直。

    为了保证不同方向上的高斯光束在表面上的照射区域相同,这里引入了两个横向方向的束腰宽度:


    $$
    w_1 = w, \quad w_2 = w \cos \theta_i\tag{26}
    $$


    即使在不同入射角度下,表面上的照射面积保持一致。这对推导BRDF而言非常重要。

    4. IMPLEMENTATION AND ACCELERATION

    但是如果按上面的方法硬算,是不可能会有结果的。因此需要一些加速手段。

    4.1 The Adaptive Integral Method

    想要直接计算上面公式(12)的方程组,计算量是不可接受的。

    $$ \begin{bmatrix} A_{EJ} & A_{EM} \ A_{HJ} & A_{HM} \end{bmatrix} \begin{bmatrix} I_J \ I_M \end{bmatrix} = \begin{bmatrix} V_E \ V_H \end{bmatrix} \tag{12} $$


    按照原本的思路,用 $N$ 个基函数来表示电流磁流密度,矩阵的规模就是 $2N \times 2N$ 。如果直接求解矩阵(LU分解,Cholesky分解等),总复杂度可能在 $\mathcal{O}(N^3)$ 。就算用共轭梯度法总复杂度也在 $\mathcal{O}(N^2)$ 。在一些小规模的仿真中,基函数的规模大概在 $960*960$ ,存储需求大概就在 29.4GB 。用了Adaptive Integral Method, AIM,按8字节来算总存储需求约在 76.8 MB。究竟是什么方法这么神奇呢?小编接下来就带大家一起看看吧!

    4.1.1 Approximating Matrix Elements

    AIM最初是由Bleszynski等人[1996]提出的。AIM的核心思想是将每个基函数的作用近似为一组点源的作用,避免直接计算每一对基函数之间的精确相互作用,同时通过FFT将各基函数的影响传播开来,提升计算效率。

    AIM中矩阵元素的计算方式是对矩阵元素的某些项的线性组合来近似计算。这就是为什么在上面公式(13)-(16)后面让读者们进一步推导,推导最终的结果使其符合AIM的形式。

    $$
    \int_{f_m} \int_{f_n} \psi_m(r) g(r – r{\prime}) \xi_n(r{\prime}) \, dr{\prime} \, dr
    \tag{27}
    $$


    AIM首先会在包含电磁场和场源的空间内创建一个全局3D笛卡尔网格,如下图(6)所示。

    为了进一步化简公式(27),AIM算法会让原本的基函数近似为在这个三维笛卡尔坐标上一组网格点上的点源。说白了就是连续变离散,并且方便后续FFT。

    $$ \psi_m(r) \approx \tilde{\psi}m(r) := \sum{p \in S_m} \Lambda_{mp} \delta^3(r – p) \\ \xi_n(r{\prime}) \approx \tilde{\xi}n(r{\prime}) := \sum{q \in S_n} \Lambda{\prime}_{nq} \delta^3(r{\prime} – q) \tag{28} $$

    将公式(27)代入公式(28),换句话说,就是将双重积分的形式转化为了一个双重求和的形式。

    $$
    \sum{p \in S_m} \sum_{q \in S_n} \Lambda_{mp} g(p – q) \Lambda{\prime}_{nq}
    \tag{29}
    $$

     

    方法详细参考:

    Kai Yang and Ali E Yilmaz. 2011. Comparison of precorrected FFT/adaptive integral method matching schemes. Microwave and Optical Technology Letters 53, 6 (2011), 1368–1372.

    4.1.2 Base and Correction Matrices

    基于公式(29)定义一组基础近似(base approximation)矩阵 $B_{EJ}, B_{EM}, B_{HJ}, B_{HM}$ 作为近似,专门处理距离较远的基函数对。这些矩阵通过引入 $\Lambda$ 矩阵和卷积等操作化简计算。同时,对于距离较近($d_{near}$)的基函数对,再引入修正矩阵(Correction Matrices)来减少误差。 $C_{EJ}, C_{EM}, C_{HJ}, C_{HM}$ 是一种稀疏矩阵,定义如下:

    $$ C_{\mathrm{X}}^{m n}=\left\{\begin{array}{ll} A_{\mathrm{X}}^{m n}-B_{\mathrm{X}}^{m n} & d_{m n} \leq d_{\text {near }} \\ 0 & \text { otherwise } \end{array} \quad \mathrm{X} \in\{\mathrm{EJ}, \mathrm{EM}, \mathrm{HJ}, \mathrm{HM}\}\right. \tag{30} $$

    $A_X^{mn}$ 是原始矩阵的精确值,而 $B_X^{mn}$ 是基础矩阵的近似值。通过减去基础矩阵的近似值,得到一个较准确的修正项,用于补偿近距离基函数对的误差。

    综上所述,AIM方法中每个矩阵的最终近似形式可以写成如下关系:

    $$ \begin{aligned} A_{\mathrm{EJ}} \approx B_{\mathrm{EJ}}+C_{\mathrm{EJ}} ; & A_{\mathrm{EM}} \approx B_{\mathrm{EM}}+C_{\mathrm{EM}} ; \\ A_{\mathrm{HJ}} \approx B_{\mathrm{HJ}}+C_{\mathrm{HJ}} ; & A_{\mathrm{HM}} \approx B_{\mathrm{HM}}+C_{\mathrm{HM}} \end{aligned} $$


    换句话说,原始矩阵可以通过基础矩阵和修正矩阵的组合来近似。

    4.1.3 Fast Matrix-Vector Multiplication

    快速矩阵-向量乘法(Fast Matrix-Vector Multiplication)是AIM的核心。

    由于上面得到的修正矩阵 $C$ 是稀疏矩阵, $C$ 只在近距离的基函数上有非零值,因此矩阵 $C$ 的乘法操作是很快的。

    利用基础近似矩阵 $B$ 的卷积特性,计算了矩阵 $B$ 与向量的乘积。计算过程分为三步:


    $$
    y_1 = \Lambda_2^T x, \quad y_2 = G y_1, \quad y_3 = \Lambda_1 y_2
    \tag{32}
    $$


    第一步把向量投影到一个稀疏矩阵网格上。

    第二步也是核心步骤,把网格点的数据传播到整个网络,也就是计算每个点对其他点的影响。两个点比较接近,矩阵 $G$ 中的传播函数就大。通过FFT来做加速。


    $$
    y_2 = \mathcal{F}^{-1} { \mathcal{F}(g) \mathcal{F}(y_1) }
    \tag{33}
    $$


    第三步骤将结果映射回原来的基函数空间。

    4.2 GPU-Accelerated Iterative Solving

    在GPU上将AIM方法中的计算重点转移到快速傅里叶变换(FFT)和稀疏矩阵操作上。总结一下,目前将大型矩阵分为基础矩阵 $B$ 和修正矩阵 $C$ ,分别处理远距离和近距离的基函数对。

    • cuFFT:将基础矩阵的乘法操作转换为频域中的卷积计算
    • cuSPARSE:算稀疏矩阵加速修正矩阵 C

    并且优化计算策略:

    • 对于小规模仿真任务,只需要使用1个GPU
    • 对于大规模仿真任务,将任务分配到4个GPU
    4.2.1 Small-Scale Simulations

    对于小规模仿真任务(例如 $12 \mu m \times 12 \mu m$), 必须事先计算并存储传播函数的傅里叶变换值(即矩阵 $G$ 的傅里叶变换)。在小规模任务中稀疏修正矩阵 C 占用的显存不到5GB,一张GPU就可以搞掂。

    4.2.2 Large-Scale Simulations

    对于大规模仿真任务(例如 $24 \mu m \times 24 \mu m$),由于单个GPU的显存不足以存储所有数据,作者将计算任务分配到4个GPU上。在这个尺度的仿真上,基函数的个数会达到960 × 960个,存储所有修正矩阵的非零元素(包括行列索引和复数浮点数值)大约需要20GB显存。策略还是和小规模一样,每个GPU分配大约5GB内存来存储修正矩阵 $C$ 。

    MINRES求解器在主机CPU上执行,而矩阵-向量乘积 $y = Ax$ 的计算在GPU上完成。但是不需要担心传输时间,这个向量大概只有30MB。

    4.3 FFT-Accelerated Scattered Field Evaluation

    用FFT加速计算散射场。在远场区域上评估从表面散射的场最终求出表面BRDF。

    在求解BEM后,得到了表面的电流密度 $\mathbf{J}$ 和磁流密度 $\mathbf{M}$ 。这些密度分布定义了表面上的电磁源,可以用来计算在远场区域上的散射场。公式很简单,随着距离衰弱同时还会具有一定的相位变化:


    $$
    \mathbf{E_s}(r) \approx \mathbf{E}(\hat{r}) \frac{e^{-jkr}}{r}; \quad \mathbf{H_s}(r) \approx \mathbf{H}(\hat{r}) \frac{e^{-jkr}}{r}
    \tag{36}
    $$


    公式右边的 $\mathbf{E}(\hat{r})$ 和 $\mathbf{H}(\hat{r})$ 是在远场上特定方向 $\hat{r}$ 的振幅。在不同的方向上,散射场的强度可能不同。


    $$
    \begin{aligned}
    F_1(\hat{\mathbf{r}})=\int_V J_x\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_2(\hat{\mathbf{r}})=\int_V J_y\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} \\
    F_3(\hat{\mathbf{r}})=\int_V J_z\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_4(\hat{\mathbf{r}})=\int_V M_x\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} \\
    F_5(\hat{\mathbf{r}})=\int_V M_y\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime} ; & F_6(\hat{\mathbf{r}})=\int_V M_z\left(\mathbf{r}^{\prime}\right) e^{j k \mathbf{r}^{\prime} \cdot \hat{\mathbf{r}}} d \mathbf{r}^{\prime}
    \end{aligned}
    \tag{37}
    $$


    为了避免直接求解这些积分,作者利用先前(4.1.2)对 $\mathbf{J}$ 和 $\mathbf{M}$ 的点源近似($\Lambda$ 矩阵),将每个积分项 $F_i(\hat{r})$ 离散化并重写为傅里叶变换的形式,如公式 (38) 所示:


    $$
    F_i(\hat{r}) = \sum_{p \in S} h_i(p) e^{jp \cdot k \hat{r}}
    \tag{38}
    $$


    将连续的场强计算转化为离散的求和,这样就可以用FFT快速计算了。并且从上式可以观察到, $F_i(\hat{r})$ 实际上是 $h_i(p)$ 在空间频率 $-k\hat{r}$ 上的傅里叶分量。

    The required spatial frequencies are not on the FFT grid but can be interpolated; we add zero padding prior to the FFT step, to ensure enough resolution in the frequency domain for the trilinear interpolation to be sufficiently accurate.

    5 HIGH RESOLUTION BRDF GENERATION

    搞了一大堆,终于回到熟悉的BRDF计算了。这里关键在于使用小尺度模拟的线性叠加来重建大尺度入射场的远场散射,而不是一口吃成大胖子。$N^2$个小尺度的高斯光束构成的网格来线性组合成近似大尺度的入射场。

    这里用到“波束引导”(beam steering)技术。这种方法不用对每个方向都进行一次模拟,从而大幅降低计算成本。

    5.1 Basic and Derived Incident Directions

    首先,沿某方向 $\mathbf{u}$ 传播的 $N^2$ 个高斯光束组成在接收平面的一个 $N \times N$ 点的网格。这些光束组合后能够生成一个大的总场。

    然后给每个高斯光束引入复数缩放因子,调整每个光束的相位,进而调整组合场的传播方向,这些方向称为desired direction。


    $$
    a_{st} = e^{j k \mathbf{p}{st} \cdot \omega_i} \tag{39}
    $$

    当目标入射方向 $-\omega_i$ 与基本方向 $\mathbf{u}$ 之间的夹角接近各高斯光束的发散角(divergence of the small beams)时,aliasing artifacts begin to appear. An example is shown in Fig. 8 (d).

    In our framework, we decide on a primary waist w and choose a collection of basic incident directions. In general, 较小的腰宽意味着更大的发散角,这样可以从每个基本方向派生出更多的入射方向,从而减少所需的基本方向数量。较大的腰宽会降低每个高斯光束的发散角,使得组合后的总场发散更小,从而产生更精确的入射方向。

    每个六边形的中心对应一个基本入射方向。整个半球的所有入射方向划分为若干territories,每个territories归属于一个基本入射方向。在半球投影中,这种比例关系与余弦因子相互抵消,因此可以使用大小相等的六边形来表示。

    对于每个基本方向,可以通过“波束引导”产生一些偏离该基本方向的派生方向。

    当光的入射角很小时(例如接近表面法线方向),基本入射方向附近的派生方向范围很小,因为小角度的光更集中,不会有很大的扩散。反之,派生方向范围会更大。总结就是,入射角越大(角度越接近水平),派生方向的覆盖范围就越大。公式作者也提到了:$1/\cos \theta_i$。

    5.2 Individual Simulations and Synthesized Results

    为了计算BRDF,我们需要知道这个大面积入射光的散射情况。然而,直接模拟这样一个大面积的光会需要很高的计算成本。因此,我们:

    使用小尺度模拟的叠加来模拟大尺度入射场。

    可以理解为,用很多个小的手电筒(高斯光束)来覆盖一个区域,而不是用一个巨大的探照灯。

    首先决定手电筒的尺寸(光束的大小,即腰宽 $w$),并在表面上均匀地分布这些手电筒。写成公式,这个手电筒的排列就是网格点${x_s}, {y_t}$,代表每个高斯光束的中心位置。网格间距一般和腰宽一致,确保光的均匀覆盖,并保持较低的发散角。

    让每个高斯光束在其中心区域产生相同的电磁场,只是在不同的位置上重复这一效果。

    接下来,想要得到大光束的总散射场,这里需要相位因子来进行“调整”和“叠加”,详细请回看公式(39)。

    最终,Combining with Eq. 39, the scattered fields in the far field region corresponding to the pair of directions $(w_i,w_o)$ are given by:

    $$ \begin{aligned} \mathbf{E}\left(\omega_i, \omega_o\right) & =\sum_{s=1}^n \sum_{t=1}^n e^{j k \mathbf{p}{s t} \cdot\left(\omega_i+\omega_o\right)} \mathbf{E}{s t}\left(\omega_o\right) \\ \mathbf{H}\left(\omega_i, \omega_o\right) & =\sum_{s=1}^n \sum_{t=1}^n e^{j k \mathbf{p}{s t} \cdot\left(\omega_i+\omega_o\right)} \mathbf{H}{s t}\left(\omega_o\right) \end{aligned} \tag{41} $$

    where $\mathbf{E}, \mathbf{H}$ refer to the far field quantities only associated with directions (without the $e^{-j k r} / r$ term).

    Lastly, we can compute the surface BRDF value as

    $$
    f_r\left(\omega_i, \omega_o\right)=\frac{\frac{1}{2}\left|\mathbf{E}\left(\omega_i, \omega_o\right) \times \mathbf{H}\left(\omega_i, \omega_o\right)^*\right|}{\Phi_i \cos \theta_r}
    \tag{42}
    $$

    where the incident power $\Phi_i$ is computed by integrating the incident irradiance over the surface:

    $$
    \Phi_i=\frac{1}{2} \int_S\left|\left[\mathbf{E}_i\left(\mathbf{r}^{\prime}\right) \times \mathbf{H}_i\left(\mathbf{r}^{\prime}\right)^*\right] \cdot \mathbf{n}\right| d \mathbf{r}^{\prime}
    \tag{43}
    $$

    where $\mathbf{n}$ is the surface normal at the macro scale ( $+\mathbf{z}$ ). Note that Eq. 42 and Eq. 43 can also be applied in single simulations, where $\Phi_i$ is computed from a single Gaussian beam.

  • 波动光学毛发渲染:相关论文汇总整理(一)-学习笔记-3

    波动光学毛发渲染:相关论文汇总整理(一)-学习笔记-3

    声明:这一篇就是纯纯的垃圾文章了,当作前两篇文章的补充。随便整理的供自己复习用。所有标题都可以跳转论文主页。专有词尽量都中英文给出了。若有错误恳请指正万分感谢。

    原文:https://zhuanlan.zhihu.com/p/830617613

    目录

    1. [Xia 2023] A Practical Wave Optics Reflection Model for Hair and Fur
    2. [Xia 2023] Iridescent Water Droplets Beyond Mie Scattering
    3. [Aakash 2023] Accelerating Hair Rendering by Learning High-Order Scattered Radiance
    4. [Kneiphof and Klein 2024] Real-Time Rendering of Glints in the Presence of Area Lights
    5. [Huang 2024] Real-time Level-of-detail Strand-based Hair Rendering
    6. [Xing 2024] A Tiny Example-Based Procedural Model for Real-Time Glinty Appearance Rendering
    7. [Zhu 2022] Practical Level-of-Detail Aggregation of Fur Appearance
    8. [Clausen 2024] Importance of multi-modal data for predictive rendering
    9. [Shlomi 2024] A Free-Space Diffraction BSDF
    10. [Kaminaka 2024] Efficient and Accurate Physically Based Rendering of Periodic Multilayer Structures with Iridescence
    11. [Yu 2023] A Full-Wave Reference Simulator for Computing Surface Reflectance
    12. [Shlomi 2022] Towards Practical Physical-Optics Rendering
    13. [Huang 2022] A Microfacet-based Hair Scattering Model
    14. [Shlomi 2021] A Generic Framework for Physical Light Transport
    15. [Shlomi 2024] A Generalized Ray Formulation For Wave-Optics Rendering
    16. [Shlomi 2021] Physical Light-Matter Interaction in Hermite-Gauss Space
    17. [GUILLÉN 2020] A general framework for pearlescent materials
    18. [Werner 2017] Scratch iridescence: Wave-optical rendering of diffractive surface structure
    19. [Fourneau 2024] Interactive Exploration of Vivid Material Iridescence using Bragg Mirrors
    20. [Chen 2020] Rendering Near-Field Speckle Statistics in Scattering Media
    21. [Kajiya and Kay 1989] Kajiya-Kay Model
    22. [Marschner 2003] Light Scattering from Human Hair Fibers
    23. [Benamira 2021] A Combined Scattering and Diffraction Model for Elliptical Hair Rendering
    24. [Zinke 2008] Dual Scattering Approximation for Fast Multiple Scattering in Hair

    [Xia 2023] A Practical Wave Optics Reflection Model for Hair and Fur

    波动光学、毛发渲染、表面电磁计算远散射场

    利用波动光学渲染毛发。计算表面电磁场得到散射场,再加入噪声模拟Glints效果。

    我发现这个系列的作者颜值都好高。(划掉)

    img

    1. 背景

    毛发渲染一直以来主要基于光线追踪技术,无法处理波动光学效应(Wave Optics),例如毛发表面强烈的前向散射和微妙的颜色变化。之前的研究【Xia et al. 2020】证明了衍射效应在纤维的颜色和散射方向上起到了关键作用。然而,这项研究并没有考虑如表面粗糙度和纤维表皮层的微观结构(例如倾斜的角质鳞片)。

    2. 动机

    为了弥补现有的光线光学模型缺少对衍射和前向散射的处理(例如Glints现象)。

    尽管全波模拟可以得到非常细致的散射数据,但是计算量仍然太高。必须通过某种方式加速或简化,以实现大规模场景中的毛发或皮毛渲染。

    想要开发一种能够高效处理各种纤维几何变化的模型。

    3. 方法

    毛发建模是基于毛发的扫描电子显微镜(SEM)图像。

    用「WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY」计算粗糙纤维表面的反射和衍射。即PO。

    引入了散斑理论来分析散射模式的统计特性,用噪声来加速。

    [Xia 2023] Iridescent Water Droplets Beyond Mie Scattering

    波动光学、虹彩效应、水面水滴 Quetelet 散射模型

    结合 Mie 散射、Quetelet 散射(光干涉)以及水滴动态变化,逼真地渲染出水面上和蒸汽中彩虹般的水滴色彩效应,超越了传统的单一 Mie 散射模型。

    img

    1. 背景

    虹彩现象在自然界中很常见,尤其是在水滴、雾气和蒸汽等情况下。一般可以通过Mie散射来解释。Mie 散射描述了当光线遇到与波长相当的球形颗粒时发生的散射效应,是目前用于模拟水滴、云层、雨雾等自然现象的重要理论之一。

    然而,尽管 Mie 散射能够解释孤立水滴的光学特性, Mie 散射不能完全解释诸如水面彩虹色水滴和蒸汽中复杂的彩虹图案等现象。现象不仅依赖于单个粒子如何散射光线,还依赖表面反射、干涉效应以及颗粒大小的动态变化。

    2. 动机

    Mie 散射只能处理孤立的光散射现象,而无法解释更复杂的光学干涉效应。

    准确模拟这些自然现象能够极大提升图像渲染的真实性和观感。

    现有的计算机光学模型和渲染方法大多局限于 Mie 散射,无法解释光线在多颗粒环境下的交互现象,比如水滴之间或水滴与表面之间的光干涉和反射。

    3. 方法

    用「水面上的 Quetelet 散射模型」来解释水面漂浮水滴产生的彩虹色效果。通过建立经验模型,使用热成像技术将温度与水滴的尺寸和高度相关联。使用 Quetelet 散射相函数和 BRDF(双向反射分布函数)来渲染粒子群和水面。

    开发了一个水滴生长和蒸发模型,模拟水滴在蒸汽中的动态变化。与 Mie 散射结合,利用非均匀尺寸的水滴来模拟蒸汽中的彩虹色变化。为了提高渲染效率,采用了基于运动模糊的加速算法,相比于传统方法提升了 10 倍的计算速度。

    [Aakash 2023] Accelerating Hair Rendering by Learning High-Order Scattered Radiance

    毛发渲染、MLP、加速毛发散射

    结合了小型多层感知器(MLP,Multilayer Perceptron)的在线学习毛发高阶散射辐射(higher-order scattered radiance online)的方法(这个应该如何翻译?),在路径追踪(path tracing)框架下显著加速了毛发渲染,减少了计算时间并且只引入了少量偏差。

    img

    1. 背景

    毛发的多次散射(multiple scattering)非常复杂,特别是在路径追踪(path tracing)过程中,由于需要模拟光线在毛发之间的多次散射,导致难以收敛。

    2. 动机

    开发一种方法,在提升计算效率的同时,保持对多次散射效果的高质量模拟。

    现有技术中,一些方法对场景或光照做出简化假设。该论文希望提出一种不对场景做任何假设的通用方法。

    3. 方法

    使用一个小型多层感知器(MLP,Multilayer Perceptron)来在线学习高阶散射辐射(learning higher-order scattered radiance online),这个 MLP 网络在渲染过程中实时学习毛发的散射特性,不依赖于预先计算的表格或模拟。

    在路径追踪框架中集成了 MLP,用来推测和计算高阶散射辐射贡献。

    渲染器的偏差和速度(renderer’s bias & speedup)可以实时调节,以便在计算效率和渲染质量之间找到最优平衡。

    [Kneiphof and Klein 2024] Real-Time Rendering of Glints in the Presence of Area Lights

    加速区域光源Glints、微表面模型、实时渲染

    在区域光源(area lights)下渲染闪光(glints)效果,通过结合线性变换余弦(LTC,Linearly Transformed Cosines)和基于二项分布的微表面计数模型,实现实时渲染。

    img

    1. 背景

    许多现实材料(如金属、宝石等)具有闪闪发光(Glints)的外观,效果源于微表面的反射。然而闪光是离散现象,涉及波动光学模拟计算量太大。

    以往的研究大多集中在使用无穷小的点光源来渲染闪光效果,对于像太阳这样远距离的光源来说这是合理的简化,但现实中大多数光源本质上都是区域光源。现有技术未能有效处理区域光源下的闪光渲染。

    2. 动机

    处理区域光源下的闪光(glint rendering under area lights)。面积光源(例如通过窗口照射进房间的灯光)是常见的光源类型,如何在这种光源下高效地渲染闪光效果是一个尚未完全解决的问题。希望开发一种方法,能够在面积光源下准确渲染闪光效果,同时满足实时渲染的需求。

    希望能够轻松集成到现有的实时渲染框架中,且不对已有的面积光源着色方法带来显著额外开销。

    3. 方法

    闪光反射概率估算(estimating glint reflection probability)来计算微表面(microfacet)正确定向以反射来自光源的光线到观察者的概率。大光源通过线性变换余弦(LTC,Linearly Transformed Cosines),小光源用局部常量法(locally constant approximation)。

    基于二项分布的计数模型(binomial distribution-based counting model)来计算反射微表面的数量。

    与现有框架的集成(integration with existing frameworks)。

    [Huang 2024] Real-time Level-of-detail Strand-based Hair Rendering

    毛发渲染、LoD、基于发束、BCSDF

    提出了一种创新的实时基于发束(strand-based)毛发渲染框架,通过无缝的细节层次(LoD,Level-of-Detail)过渡,确保毛发在不同视距下保持一致的外观,并实现显著的渲染加速。

    img

    1. 背景

    在影视和游戏制作中,基于发束的毛发渲染(strand-based hair rendering)因其逼真的外观而越来越受欢迎,但它的计算成本非常高,尤其是在远视距下。

    当前 LoD 方法,从发束到卡片的过渡中容易出现明显的不连续导致的外观不一致。

    2. 动机

    解决动态和视觉不连续性(discontinuity in dynamics and appearance)。现有的从发束到毛发卡片的转化方案在外观和动画表现上有显著差异。论文的目标是实现从远到近无缝的 LoD 过渡,消除过渡时的外观变化,同时保持计算效率。

    3. 方法

    使用椭圆形厚毛发模型(elliptical thick hair model)将多个发束封装在一个椭圆形的体积内。在不同 LoD 下保持毛发簇(hair cluster)的形状和整体结构,从而在视距变化时提供一致的外观。

    椭圆双向曲线散射分布函数(elliptical Bidirectional Curve Scattering Distribution Function, BCSDF)模拟毛发簇内的单次和多次散射现象,适用于从稀疏到密集、从静态到动态的毛发分布场景。

    动态 LoD 调整与毛发宽度计算(dynamic LoD adjustment and hair width calculation)。

    [Xing 2024] A Tiny Example-Based Procedural Model for Real-Time Glinty Appearance Rendering

    Glints、材料自相似性

    一种基于小型样本微结构(tiny example microstructures)的模型,实时渲染glinty的效果,大幅减少了内存占用和计算开销,同时保持了高频反射细节的真实感。

    img

    1. 背景

    复杂微观结构产生的闪光细节能够显著提升渲染的真实感,特别是在金属、宝石等材质上。这些细节通常需要高分辨率的法线贴图(normal maps)来定义每个微观几何形状,然而这类方法对内存需求很高,不适合实时渲染应用。

    2. 动机

    降低内存和计算开销(reduce memory and computational overhead)。

    利用材料的自相似性(leveraging material self-similarity)。观察到许多材质具有独立的结构特征和自相似性,通过小型样本来隐式表示复杂微结构从而降低内存需求。

    3. 方法

    基于小型样本微结构的程序化模型(tiny example-based procedural model),基于材质的自相似性,能够通过重复利用少量样本来生成复杂的闪光细节。

    法线分布函数预计算(precomputed Normal Distribution Functions, NDFs),使用四维高斯分布(4D Gaussians)预计算并存储小型样本的法线分布函数(NDFs)。存储在多尺度 NDF 图中(multi-scale NDF maps),并在渲染时通过简单的纹理采样调用。

    基于小型样本的 NDF 评估方法(tiny example-based NDF evaluation method),通过纹理采样结合小型样本 NDF 评估方法,快速生成复杂表面的闪光外观。

    [Zhu 2022] Practical Level-of-Detail Aggregation of Fur Appearance

    毛发渲染、简化毛发数量、神经网络

    一种实用的毛发外观聚合模型,通过减少几何毛发数量并结合光线多次散射,利用神经网络实现实时动态简化,显著加速毛发渲染,同时保持逼真的视觉效果。

    img

    1. 背景

    毛发数量太多,每根毛发的光线散射和反射会大幅增加计算量,尤其是在模拟多次光线散射时。

    现有简化方法大多通过减少毛发数量来提高渲染效率,局限性(limitations of existing simplification methods)很大。这种方法会导致毛发看起来过于粗糙或干燥,光线的反射和散射效果也不真实。

    2. 动机

    减少几何复杂性(reducing geometric complexity)。

    提高渲染效率(improving rendering efficiency)。

    3. 方法

    提出一个聚合毛发外观模型(aggregated fur appearance model),用一个粗大的圆柱体来表示一组毛发簇的光学行为。通过分析单根毛发的光学属性(如光线入射角度等),该模型能够准确反映毛发簇的聚合外观。

    使用一个轻量级神经网络(lightweight neural network),将单根毛发的光学特性映射到聚合模型中的参数。

    提出了一个基于视距和光线反弹次数的动态细节层次方案(dynamic level-of-detail scheme),动态简化毛发的几何结构。

    [Clausen 2024] Importance of multi-modal data for predictive rendering

    预测性渲染、光谱渲染、微表面几何结构

    多模态数据(multi-modal data)对于预测性渲染(predictive rendering)很重要,特别是在准确建模材料反射行为方面,通过结合光谱、空间信息及微观几何细节,提升反射模型的真实性和计算效率。

    img

    1. 背景

    预测性渲染的需求(need for predictive rendering)旨在精确模拟材料的外观。

    目前的材料反射行为数据库大多局限于单一维度,通常只涵盖光谱域(spectral domain)或空间域(spatial domain),并且缺乏对微观几何细节(microgeometry)的描述。

    2. 动机

    为了弥补数据不足(addressing data limitations),通过多模态数据,不仅能够更好地模拟材料在不同光照条件下的反射,还能揭示材料表面微观几何对光散射的影响。

    多模态反射数据有助于开发更加真实且高效的反射模型。

    3. 方法

    多模态反射数据的构建(building a multi-modal reflection database),包含材料的光谱信息(spectral data)、空间分布信息(spatial distribution data)以及微观几何特征(microgeometry details)。

    对材料表面的微观几何进行微观几何模拟(simulating microgeometry)。

    光谱和空间域的综合(integrating spectral and spatial domains)。

    [Shlomi 2024] A Free-Space Diffraction BSDF

    波动光学、电磁计算、自由空间衍射、重要性采样、PT集成、

    一种基于自由空间衍射的双向散射分布函数(BSDF),通过光线追踪在不需要几何预处理的情况下,能够高效模拟复杂场景中光绕过物体边缘的衍射现象,特别适用于路径追踪技术。

    img

    1. 背景

    自由空间衍射(free-space diffraction)是一种光学现象:当光线遇到物体的边缘或角落时会发生绕射,部分能量弯曲进入阴影区。这种现象对模拟光波的传播非常重要,尤其是在长波长(如雷达、WiFi 和蜂窝信号)条件下。

    传统方法如几何衍射理论(GTD)和统一衍射理论(UTD)的局限性(limitations of traditional methods)在于需要处理尤其是在复杂几何场景中互相干涉的光线导致的极高计算复杂性。现有方法依赖于场景简化和特定几何结构,无法有效处理复杂的三维场景。

    2. 动机

    解决复杂场景中的衍射渲染问题(addressing diffraction in complex scenes)。现有的衍射模拟方法难以扩展并与路径追踪技术兼容。

    现有衍射方法往往依赖复杂的非线性干涉计算,而路径追踪采用线性渲染方程。本文希望设计一种在路径追踪框架内高效工作的自由空间衍射 BSDF,不需要对路径追踪器做大修改。

    3. 方法

    基于 Fraunhofer 衍射的边缘衍射模型(Fraunhofer diffraction edge model)。在光线与几何物体交点附近,识别相关的边缘并计算绕射效应。当光线照射到物体时,通过几何分析构建自由空间衍射的 BSDF,量化光线如何在几何物体周围传播,以及有多少能量发生衍射。

    重要性采样策略(importance sampling strategy)评估光线与物体的交互点周围的几何边缘,并对绕射光线进行采样和追踪。

    路径追踪中的无缝集成(seamless integration in path tracing)

    [Kaminaka 2024] Efficient and Accurate Physically Based Rendering of Periodic Multilayer Structures with Iridescence

    多层油膜渲染、虹彩效应、波动光学

    一种多层干涉渲染方法。表现周期性多层结构的虹彩效应,通过引入生物学中的 Huxley 方法,实现了独立于层数的高效计算.

    img

    1. 背景

    薄膜干涉(thin-film interference)是一种光波的波动特性引起光学现象,在视角或光照角度变化时会产生虹彩效果(iridescence)。通常出现在自然界中的单层或多层结构中,比如蝴蝶翅膀、甲虫壳和介电镜等。

    现有方法例如递归计算法和转移矩阵法(Transfer Matrix Method, TMM)的局限性(limitations of existing methods)在于计算复杂度随着层数的增加而显著提高。简化方法则会忽略薄膜中的多重反射。

    2. 动机

    提高多层结构的渲染效率(improving efficiency for multilayer structures)。

    应用于复杂材料的物理渲染(physical rendering of complex materials)。

    3. 方法

    提出一种基于 Huxley 方法的多层干涉模型(multilayer interference model based on Huxley’s approach)。高效计算周期性多层结构中的反射和透射系数。且支持多种材料和吸收效应(support for multiple materials and absorption effects)。

    基于 BRDF 实现(BRDF implementation)。被实现为一个 BRDF(双向反射分布函数),可以集成到传统渲染系统中,如 PBRT-v3。

    [Yu 2023] A Full-Wave Reference Simulator for Computing Surface Reflectance

    波动光学、全波模拟

    基于边界元法(BEM)的全波模拟器,可以高精度计算粗糙表面上的光散射,用于评估和改进计算机图形学中的近似反射模型,特别是在多次散射、干涉和衍射效应显著时。

    img

    1. 背景

    表面反射模型通常基于几何光学(geometric optics),假定光以光线的形式传播。对于表面特征与光波长相当的场景,几何光学模型无法准确捕捉如衍射和干涉等波动效应(wave effects)。

    基于波动光学近似,如 Beckmann-Kirchhoff 理论和 Harvey-Shack 模型,它们在多次散射和复杂几何结构下仍然会产生误差。

    2. 动机

    由于现有的反射模型在不同情况下精度各异,缺乏可靠的基准来验证其准确性。本文的目标是开发一个基于全波理论的模拟器以最大限度地减少近似并通过数值离散化实现高精度的表面反射计算,从而提供一个可以用于评估各种反射模型准确性的参考工具。

    解决多次散射和波动效应(addressing multiple scattering and wave effects)。

    3. 方法

    边界元法(Boundary Element Method, BEM),用自适应积分法(Adaptive Integral Method, AIM)来加速。

    模拟器的全波模拟(full-wave simulation)完整解决了 Maxwell 方程,能够准确模拟光的传播、干涉、散射等波动现象。

    并且能高效计算 BRDF(efficient BRDF computation)。

    [Shlomi 2022] Towards Practical Physical-Optics Rendering

    波动光学、PLT

    提出了一个高效的物理光传输(PLT)框架,利用部分相干光和波动光学的原理,通过改进的渲染算法,在复杂场景中实现了精确的干涉、衍射和极化效应的渲染,并使其性能接近经典的“物理基础”渲染方法。

    img

    1. 背景

    现有的渲染方法大多忽略了光的波动特性,特别是在复杂场景中,这导致无法渲染诸如光的干涉、衍射等物理现象,这些现象在某些材料(如彩虹色涂层、光盘等)上尤为重要。为了解决这一问题提出一种基于麦克斯韦电磁理论的渲染框架。

    虽然 PLT 提供了理论上的全波模型,能够模拟光的相干性、干涉和衍射,但现有的方法计算难度非常大。

    2. 动机

    简化物理光传输模型(simplifying the physical light transport model)。

    引入新材料(introducing new coherence-aware materials),开发能够感知光相干性的材料模型,提高 PLT 在实际场景中的可用性。

    3. 方法

    限制光的相干性形状(restricting the coherence shape of light),通过热力学推导,证明这种近似在大多数自然光源下是合理的。

    采用了扩展的斯托克斯-穆勒方法(Stokes-Mueller calculus),将光的辐射、极化和相干性属性结合在一起,作为新的渲染原语。广义的斯托克斯参数可以完整量化光的所有属性,并能精确模拟由这些属性引起的复杂光学现象,如干涉和衍射。

    波动 BSDF 和重要性采样(wave BSDF and importance sampling)。

    新相干感知材料模型(new coherence-aware material models)充分利用光的相干属性扩展了 PLT 的适用范围。

    [Huang 2022] A Microfacet-based Hair Scattering Model

    毛发渲染、散射瓣、BCSDF

    提出了首个基于微表面理论的毛发散射模型准确地描述毛发的散射行为。非可分离的散射瓣结构、椭圆形截面、高效的重要性采样和前向散射光斑(glint-like)效果。

    img

    1. 背景

    毛发渲染的复杂性(complexity of hair rendering)。大多数现有的毛发散射模型通过可分离的散射瓣(separable lobes)来简化数学计算,虽然快但并不是Ground Truth的。

    大多数毛发散射模型基于几何简化,将毛发当作光滑圆柱体处理。导致了散射行为的偏差。

    2. 动机

    引入物理合理的微表面模型(introducing a physically-plausible microfacet model)更准确地描述毛发的散射行为:表面的微观粗糙度、倾斜的鳞片结构以及非可分离的散射瓣形状。

    改善采样效率和物理精度(improving sampling efficiency and physical accuracy)。

    3. 方法

    将毛发建模结合微表面(microfacet)理论,应用 GGX 或 Beckmann 正态分布来描述表面的微观粗糙度。并且是非可分离的散射瓣(non-separable lobes)。

    毛发散射的分布函数(bidirectional curve scattering distribution function, BCSDF)描述光线在毛发表面上的复杂交互行为。

    支持椭圆截面和高效采样(support for elliptical cross-sections and efficient sampling)。支持椭圆的毛发截面。

    [Shlomi 2021] A Generic Framework for Physical Light Transport

    波动光学、PLT

    提出了首个基于麦克斯韦电磁理论能够处理部分相干光的全局光传输框架,准确模拟光的干涉和衍射效应,并将传统的基于辐射度的光传输理论扩展到波动光学领域。

    img

    1. 背景

    现有的光传输模型通常基于几何光学和辐射度(radiometry)忽略了光的波动特性,无法模拟干涉和衍射等现象。不能准确再现如彩虹色效应、光栅、薄膜干涉、光偏振等波动光学效应,也就是经典辐射度光传输模型的局限性(limitations of classical radiometric light transport models)。

    目前的模型只能处理局部处理波动效应(local treatment of wave effects),对于全局场景中光的传输和相干性搞不了。

    2. 动机

    实现全局光传输中波动效应的物理一致性(achieving global wave-optics consistency in light transport),也就是结合麦克斯韦电磁理论。

    结合波动光学与传统几何光学(integrating wave optics with classical geometric optics),处理光的波动效应,又能在短波长极限下与经典几何光学一致。

    3. 方法

    光的部分相干性建模(modelling partially-coherent light)分为两个部分。两点相干性描述(two-point coherence description)和光源模型(light source model)。与传统的辐射度不同,本文基于光的部分相干性引入了“交叉谱密度函数”(cross-spectral density function),能够捕捉光的干涉特性。自然光源的物理模型,基于量子力学中的自发辐射原理。

    光传输方程的推广(generalizing the light transport equation)。利用谱密度传输方程(spectral-density transport equation)计算光在传播过程中的干涉和衍射效应。并且本文证明了该框架在短波长极限下简化为经典的几何光学,因而可以与现有的光传输方法无缝衔接。

    衍射与传播模型(diffraction and propagation model)。

    [Shlomi 2024] A Generalized Ray Formulation For Wave-Optics Rendering

    波动光学、波动采样理论、双向路径追踪

    提出了一种广义光线(generalized ray)形式化模型,用于波动光学渲染,通过解决采样问题,使得弱局部性、线性和完备性在波动光学中同时成立。实现了复杂场景下的双向波动光学路径追踪和高效渲染。

    img

    1. 背景

    光传输的经典模型基于射线光学(ray optics),假设光作为线性传播的点查询。然而,射线光学无法捕捉光的波动特性,忽略了干涉、衍射等现象,例如虹彩效应、薄膜干涉和长波辐射的绕射等。

    虽然波动光学能够精确描述光的干涉和衍射效应,但由于其非线性行为,传统的采样和路径追踪技术难以应用。

    2. 动机

    解决波动光学中的采样问题(solving the sampling problem in wave optics)。为了在双向光传输中应用波动光学,需要解决弱局部性下的采样问题。

    开发一种新型的波动光学形式化,使其能够在逆向路径追踪和双向光传输中有效应用,同时保持线性和完备性。

    提高波动光学渲染效率(improving wave-optics rendering efficiency),使得波动光学渲染的收敛速度能够接近经典射线光学的渲染系统。

    3. 方法

    广义射线的引入(introduction of the generalized ray)。进行弱局部的线性查询。广义射线不再局限于单一位置的点查询,而是占据一个小的空间区域。能够捕捉光的干涉和衍射效应。

    弱局部性和线性化(weak locality and linearization)。在波动光学中,完美的局部性和线性化是无法同时实现的。因此放弃完全局部性。采用弱局部性确保广义射线可以线性叠加。

    逆向波动光传输模型(backward wave-optical light transport model)。

    双向路径追踪中的应用(application in bidirectional path tracing)。

    [Shlomi 2021] Physical Light-Matter Interaction in Hermite-Gauss Space

    波动光学、PLT

    提出了一种新的光-物质相互作用框架,通过将部分相干光分解到 Hermite-Gauss 空间,并将物质建模为局部平稳的随机过程,从而统一了散射和衍射的公式,并实现了对复杂光学现象的高效计算和描述。

    img

    1. 背景

    日常观察到的光通常由许多独立的电磁波组成。由于部分相干光的复杂性(complexity of partially-coherent light),部分相干光的相干特性在微观几何表面的反射、涂层材料的外观、光栅效应等现象经典的辐射度理论无法解释。

    现有工具的局限性(limitations of existing tools)只能渲染特定材料,难以推广。

    2. 动机

    构建通用的光-物质相互作用框架(building a general-purpose light-matter interaction framework),高效处理部分相干光,简化现有计算工具的复杂性。

    分解光的相干特性(decomposing light coherence properties),引入 Hermite-Gauss 空间,希望以计算可行的方式分解和表示光的相干性,从而广泛适用于各种光学现象。

    3. 方法

    Hermite-Gauss 空间中的光传输(light transport in Hermite-Gauss space)。

    局部平稳物质模型(locally-stationary matter model)。

    光-物质相互作用的分析(analysis of light-matter interaction)。

    光-物质相互作用公式的统一(unifying light-matter interaction formulae)。

    [GUILLÉN 2020] A general framework for pearlescent materials

    波动光学、干涉颜料光学、逆渲染

    模拟珍珠光材料的光学特性。为珍珠光材质的设计和逆向渲染提供了理论基础。

    img

    1. 背景

    珍珠光材料的广泛应用(Wide Applications of Pearlescent Materials),并且这些材质具有独特的光泽和变色效果,广泛应用于包装、陶瓷、印刷和化妆品等领域。

    珍珠光效应的复杂光学过程(Complex Optical Processes of Pearlescence)源于颜料片之间的多重散射和波动光学干涉。现有的模型难以全面描述这些复杂的光学行为。

    2. 动机

    建立更广泛适用的珍珠光材质模型(Building a More Comprehensive Model for Pearlescent Materials)。现有的珍珠光材质模型对颜料的复杂结构和制造过程的影响考虑不足。目标是通过引入新的光学模拟模型,扩展可表示的珍珠光外观范围。

    一个通用的珍珠光材质模型还可以用于逆向渲染中。

    3. 方法

    提出基于干涉颜料的光学模型(Optical Model Based on Interference Pigments)。将颜料片的多层结构、粒子的方向相关性、厚度变化等特性纳入考虑。

    参数空间的系统研究(Systematic Study of Parameter Space),探讨了颜料片的方向性、厚度和排列对材料外观的影响。

    逆向渲染(Inverse Rendering)帮助解读真实世界中的光散射现象。

    [Werner 2017] Scratch iridescence: Wave-optical rendering of diffractive surface structure

    波动光学、非旁轴标量衍射理论、虹彩效应、微观划痕

    基于非旁轴标量衍射理论的波动光学模型,用于模拟微观划痕表面的虹彩效应,从局部光斑到远距离下的光滑反射。

    img

    1. 背景

    划痕引起的光学效应(Optical Effects of Scratches),定向光照下(如阳光或卤素灯),这些划痕表面会显示出复杂的虹彩图案,是由划痕结构对入射光的衍射引起的。在几何光学模型中无法再现。

    虽然现有的解析模型能够重现一些微结构(如光盘)的虹彩效果,但对于局部分辨的划痕光学行为的模拟仍然是一个难题。

    2. 动机

    提供一个波动光学的划痕渲染框架(Provide a Wave-Optical Scratch Rendering Framework)。能够精确模拟划痕引起的光学效应,包括光斑、虹彩等视觉现象。

    3. 方法

    基于非旁轴标量衍射理论的波动光学模型(Wave-Optical Model Based on Non-Paraxial Scalar Diffraction Theory)。本文的方法能够在入射和反射大角度下准确模拟光在微尺度表面特征上的衍射行为。

    划痕表面结构的矢量图形表示(Vector Graphics Representation of Scratch Surfaces)。

    多尺度行为的 BRDF 模型(Multi-Scale BRDF Model)。

    物理基础渲染系统中的集成与优化(Integration and Optimization in Physically-Based Rendering Systems)。

    [Fourneau 2024] Interactive Exploration of Vivid Material Iridescence using Bragg Mirrors

    波动光学、虹彩效应、Bragg镜、光谱近似

    描述 1D 光子晶体(即 Bragg 镜)的材料虹彩效果。特定条件下简化为快速计算的单次反射BRDF。

    img

    1. 背景

    自然界中的虹彩现象(Iridescence in Nature)体现在生物、植物或宝石中。这是由特定的微观几何结构引起的,这些结构的尺寸与可见光波长相当。最显著的例子是光子晶体(photonic crystals),在一维、二维或三维结构中重复排列产生结构色。

    1D 光子晶体,即 Bragg 镜的光学特性(Optical Properties of Bragg Mirrors)。现有的工作大多使用经典的传递矩阵法(transfer matrix method)来计算多层薄膜的光学效应,但随着薄膜数量的增加,计算复杂度也显著增加。

    2. 动机

    简化 Bragg 镜反射计算(Simplifying the computation of Bragg mirror reflectance),引入一个更简洁、封闭形式的反射公式探索在 RGB 光谱渲染中的快速近似方法。

    研究粗糙 Bragg 层的效果(Investigating the effects of rough Bragg layers),探究表面粗糙度对光学表现的影响。

    3. 方法

    引入封闭形式的反射公式(Closed-form Reflectance Formula)。基于 Yeh 的公式(Yeh88 Formula),做 RGB 光谱近似(RGB Spectral Approximation)。

    分析粗糙度对光学传输的影响(Effect of Roughness on Optical Transmission)。

    利用单次反射 BRDF 模型(Single-reflection BRDF Model)高效地渲染粗糙 Bragg 层的外观。

    [Chen 2020] Rendering Near-Field Speckle Statistics in Scattering Media

    MC路径积分、重要性采样、记忆效应、散斑、生物组织成像

    在散射介质中模拟近场成像条件下的散斑统计,加速生物组织成像应用中的散斑渲染,为基于散斑的成像技术提供支持。

    img

    1. 背景

    在生物组织中进行深层成像时,由于光在组织内部的多重散射,成像变得非常困难。在相干光(如激光)照射下,组织内部会产生高频的散斑图案。散斑图案的统计学特性,特别是记忆效应(memory effect),为组织成像技术(如荧光成像和自适应光学聚焦)提供了基础。

    现有模型的局限性(Limitations of Existing Models)在于当前主要是集中在远场成像。近场条件都被忽略了。

    2. 动机

    提供物理精确且高效的近场散斑渲染模型(Developing a Physically Accurate and Efficient Model for Near-Field Speckle Rendering)。

    提升散斑模拟的计算效率(Improving Computational Efficiency of Speckle Simulations),波动方程求解器计算量太大了。

    3. 方法

    基于蒙特卡洛路径积分的渲染框架(Monte Carlo Path Integral Rendering Framework)。

    光学孔径和散射相函数的近似(Aperture and Phase Function Approximations)。

    重要性采样(Importance Sampling)。

    [Kajiya and Kay 1989] Kajiya-Kay Model

    毛发鼻祖,无需多言

    毛发简化为细长圆柱体,并通过扩展 Phong 模型模拟毛发表面的光反射行为。

    img

    1. 背景

    基于 Phong 光照模型的概念,将其扩展为适应毛发渲染的经验模型。

    2. 动机

    毛发具有非常独特的光学特性,如高光反射、次表面散射等,而这些现象的出现与毛发的几何形状和表面结构密切相关。

    3. 方法

    Kajiya-Kay 模型基于 Phong 模型的思想,扩展 Phong 模型(Extension of the Phong Model)。

    细长圆柱体假设(Cylindrical Hair Representation)。

    [Marschner 2003] Light Scattering from Human Hair Fibers

    毛发鼻祖,无需多言+1

    能够捕捉现有Kajiya-Kay模型无法描述的关键视觉效果,如多个高光和与纤维轴旋转相关的散射变化。

    img

    1. 背景

    Kajiya-Kay模型的局限性(Limitations of the Kajiya-Kay Model)仅假设毛发为不透明的圆柱体,忽略了内部反射和透射等关键现象.

    2. 动机

    毛发是介电材料,尤其是浅色头发(如金色、棕色、红色)具有显著的半透明性。因此更真实的毛发散射模型需求(Need for a More Accurate Hair Scattering Model)。

    3. 方法

    测量了单根毛发的3D全半球光散射。

    提出透明椭圆柱体模型(Transparent Elliptical Cylinder Model)。

    简化的阴影模型(Simplified Shading Model)。

    [Benamira 2021] A Combined Scattering and Diffraction Model for Elliptical Hair Rendering

    波动光学、毛发渲染、椭圆毛发、衍射散射瓣函数、免预计算

    一个结合散射和衍射的新模型,能够在无需预计算的情况下模拟具有椭圆形截面的毛发的光散射和衍射现象。

    img

    1. 背景

    依旧是波动光学为背景。当光与尺寸接近光波长的物体相互作用时,干涉和衍射效应变得显著。

    毛发渲染需要考虑其几何特性以及光的波动效应。虽然射线追踪可以模拟大部分散射现象,但对于毛发中的衍射现象,它显得不足。

    2. 动机

    解决衍射和椭圆形截面问题(Addressing Diffraction and Elliptical Cross-sections)。提出了一个结合光的波动和射线特性的模型,能够在无需预计算的情况下处理毛发的光衍射现象。支持任意椭圆形截面的毛发纤维。

    3. 方法

    射线部分(Ray Interaction with Elliptical Fibers)引入完整的光传输模型,延续了传统的射线模型,处理了大多数散射效应。

    波动部分(Wave Diffraction by Elliptical Fibers)引入了一个新的衍射散射瓣函数,捕捉光与毛发相互作用时产生的强向前散射效应。

    免预计算(Precomputation-free Approach)。

    现代离线渲染器集成(Integration with Modern Ray Tracers)。

    [Zinke 2008] Dual Scattering Approximation for Fast Multiple Scattering in Hair

    毛发渲染、多重散射

    “双重散射”模型广泛运用于实时渲染,经典模型无需多言。

    img

    1. 背景

    在浅色密集的头发中,多次散射是决定整体头发颜色的关键因素。

    现有的基于路径追踪或光子映射的方法渲染太慢,且往往忽略了头发纤维的圆形截面。

    2. 动机

    一个物理精确且高效的多次散射模型需求(Need for Physically Accurate and Efficient Multiple Scattering Model)。

    3. 方法

    双重散射模型(Dual Scattering Model),全局多重散射和局部多重散射。全局多重散射部分旨在计算穿过头发体积并到达目标点邻域的光,而局部多重散射则考虑该邻域内的散射事件。

  • 毛发渲染研究:波动光学的毛发渲染-学习笔记-2

    毛发渲染研究:波动光学的毛发渲染-学习笔记-2

    声明:这篇文章主要是关于SIG23年这篇黑狗毛论文的个人笔记。应该也没啥阅读门槛,因为我自己也没有入门图形学。公式有但是不多。公式tag跟原论文一样。所有内容都是我瞎捣鼓的,公式如果有理解错误请多多指正,感谢感谢!

    原文:https://zhuanlan.zhihu.com/p/809636731

    关键词:图形学入门、离线渲染、基于波动光学渲染、毛发渲染

    Mengqi Xia, Bruce Walter, Christophe Hery, Olivier Maury, Eric Michielssen, and Steve Marschner, “A Practical Wave Optics Reflection Model for Hair and Fur,” ACM Transactions on Graphics (TOG), vol. 42, no. 4, article 39, pp. 1-15, Jul. 2023.

    1. Related Work

    • 基于Ray的纤维模型 在人类毛发的研究中,Marschner[2003]的模型广泛应用于业界,通过分析介电圆柱和圆锥中的光线路径,将散射分为R、TT和TRT。Zinke[2009]加入了漫反射成分。Sadeghi[2010]提出了艺术家控制参数化方法。d’Eon[2014]和Huang[2022]提出了非可分离的表征方法,也就是说方位角和纵向角度之间有耦合效应,不可简单分离。Chiang[2016] 进一步优化了该模型,使其适用于生产级渲染。 除了人类毛发,Khungurn和Marschner[2017]探讨了椭圆形毛发的建模。Yan[2015, 2017]研究了带有内部髓质的动物毛发。Aliaga[2017]将模型推广至有更加复杂截面的纺织纤维。
    • 基于波动光学的纤维模型 Linder[2014]解析了具有完全圆形截面的圆柱纤维,并且探讨了散射行为。Xia[2020]通过在具有任意截面的圆柱上进行二维波动模拟,展示了与几何光学模型相比的若干重要差异。然而这个模型是规则的圆形,并没有譬如毛鳞片等微观几何结构。Benamira和Pattanaik[2021]提出了更加快速的混合模型,这个模型仅仅在前向散射使用波动光学求解,其余部份则是依赖几何光学。
    • 基于物理光学的平面模型模拟 物理光学平面模型使用物理光学近似来模拟光线在接近平面、可以表示为高度场的粗糙表面上的散射行为,包括高斯随机、周期性、预计算和划痕表面。它们结合了Kirchhoff标量衍射理论和路径追踪方法来处理散射与反射,并通过各种模型(如Beckmann-Kirchhoff和Harvey-Shack)计算光在不同粗糙表面上的衍射。尽管这些模型在平面表面上有效,但纤维几何的封闭性和复杂的光线交互需要更复杂的处理方法。
    • 计算电磁学Spickle Effect程式化噪声 上一篇文章已经提及了,这里略过。

    https://zhuanlan.zhihu.com/p/776529221

    2. Background Research

    概述

    毛发建模是基于毛发的扫描电子显微镜(SEM)图像。可以准确还原毛发的微观结构,例如毛鳞片、粗糙度。

    接下来,用「WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY」计算粗糙纤维表面的反射和衍射。

    在这个基础上,引入了散斑理论来分析散射模式的统计特性。并且用噪声来描述这些散斑,大幅优化模型。

    然后通过与实际测量数据的对比,推导出合理的纤维参数(例如尺寸、表皮角度和表面粗糙度)。最后集成进渲染系统中。

    3. OVERVIEW

    3.1 Fiber scattering models

    这个我翻译成纤维散射模型。这个东西描述的是单根纤维发生相互作用。这里的关键核心是BCSDF(双向曲线散射分布函数)。有别于在表面反射和折射中常用的双向散射分布函数(BSDF),BCSDF是专门为曲线形状的纤维设计的。下面这个公式讲的是,给定某波长的光照射在一根纤维上时,它从某个方向射入后,会从另一个方向反射或透射出来。
    $$
    L_r(\omega_r, \lambda) = \int L_i(\omega_i, \lambda) S(\omega_i, \omega_r, \lambda) \cos \theta_i d\omega_i \tag{1}
    $$
    公式左边表示给定波长 $\lambda$ ,出射方向 $\omega_r$ 下的辐射亮度。$ L_i(\omega_i, \lambda)$ 是入射方向 $\omega_i$ 的辐射亮度。 $S(\omega_i, \omega_r, \lambda)$ 是双向曲线散射分布函数,描述了光线如何被纤维”打散”。$\cos \theta_i$ 是为了考虑入射角度的影响,如果光线以一个很平的角度照射到纤维上,它的影响会比垂直照射时小。

    把上面这个公式写成球坐标,并且把每一种不同的光线与毛发纤维的交互当作不同的模式,然后把不同的散射项累加起来:
    $$
    S(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = \sum_{p=0}^{\infty} S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda)
    \tag{2}
    $$
    第一散射项 $S_0$ 描述表面反射,即以前经常说的直接反射项 $R$ 。这一项是一般代表从光滑纤维的反射或者粗糙纤维表面反射的统计平均值。论文这里能更加精确地计算这一项。

    回想以前的渲染方法(比如Marschner[2003]),通常是将每一个散射模式 $S_p$ 分解为两个独立的函数:longitudinal function $M_p$ 和anazimuthal function $N_p$ ,这样的方法已经被批判过是不准确的,因此应该避免使用下面这种可分离的近似方法。
    $$
    S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = M_p(\theta_i, \theta_r)N_p(\theta_i, \phi_i, \phi_r, \lambda)
    \tag{3}
    $$
    因此,XIA[2023]采样了多个粗糙纤维的散射参数并且取均值,记为 $S_0,avg$ 。 $f(\theta_h, \phi_h, \lambda)$ 是噪声分量,通过两个半程向量角度和波长来表示,用来修正当前的特定纤维实例和均值的偏差。
    $$
    S_{0,\text{sim}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) \approx S_{0,\text{avg}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)
    \tag{4}
    $$
    当前,完整的纤维散射模型如下。第一项表示纤维表面的反射的散射模式,结合了3D波动模拟。后面的求和项是其他高阶的散射模式之和。
    $$
    S_{\text{prac}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) = S_{0,\text{prac}}(\theta_i, \theta_r, \phi_i, \phi_r, \lambda) + \sum_{p=1}^{\infty} S_p(\theta_i, \theta_r, \phi_i, \phi_r, \lambda)
    \tag{5}
    $$
    实际上,最终的散射公式更为简洁,以适应有更复杂几何结构的表面。

    3.2 Speckle theory

    散斑理论(Speckle Theory)描述光与粗糙表面相互作用时产生的随机光强分布现象。Goodman[2007]公式中的 $A$ 表示所有相位向量的叠加,即结果相位向量(Phasor):
    $$
    \mathbf{A} = \frac{1}{\sqrt{N}} \sum_{n=1}^{N} a_n = \frac{1}{\sqrt{N}} \sum_{n=1}^{N} a_n e^{i\phi_n}
    $$
    上式为散斑强度的综合表达,换而言之,用来表示散射后的光线强度。产生该现象的原因是光线会在许多微小表面之间相互反射、折射并相互干涉。光线传播发生的相位差导致明暗不均的图案。通过该公式,可以统计性地计算出光线如何在纤维表面相互干涉,得到散斑图案。

    4. WAVE SIMULATION WITH 3D FIBER MICROGEOMETRY

    在波动光学模拟中,光看作一种电磁波。由相互垂直的磁场和电场组成。光线与毛发的相互作用(如散射)可以转化为分析电磁场如何受到物体的影响。

    4.1 Wave optics

    首先需要明白,当电磁场随着时间周期变化时,称该场为时偕场(Time-Harmonic Fields)。在时谐场中,电场和磁场可以通过复数表示为相位向量(phasors)。巧妙的是,电场和磁场本身就是相互垂直。
    $$
    E_{\text{inst}} = \Re(E e^{j\omega t}), \quad H_{\text{inst}} = \Re(H e^{j\omega t})
    \tag{6}
    $$
    分别是电场和磁场,只不过是分别取了实数部分。复数部分包含了场的振幅和相位信息。

    下面这两组方程是麦克斯韦方程组在时谐场中的形式:
    $$
    \nabla \times \mathbf{E} = -\mathbf{M} – j\omega \mu \mathbf{H}
    \
    \nabla \times \mathbf{H} = \mathbf{J} + j\omega \varepsilon \mathbf{E}
    \tag{7}
    $$
    其中, $\varepsilon$ 是介电常数(影响电场), $\mu$ 是磁导率(影响磁场)。这两项描述了材料怎么影响电磁场的传播。

    当物体(如纤维)被一束入射波照射时,入射电场和磁场分别用 $\mathbf{E}_i$ 和 $\mathbf{H}_i$ 表示。但是物体的存在改变了这些场,使得我们观察到的是总场
    $$
    \mathbf{E}_1 = \mathbf{E}_i + \mathbf{E}_s, \quad \mathbf{H}_1 = \mathbf{H}_i + \mathbf{H}_s
    \tag{8}
    $$
    简而言之,总场 = 入射场 + 散射场。

    光能量随着散射场 $\mathbf{E}_s$ 和 $\mathbf{H}_s$ 向外传播,因此计算纤维的散射函数是关键。怎么算呢?

    全波模拟是最为准确的。进行全波模拟需要将物体离散化为网格,并且需要高分辨率(一般来说,每个波长至少10个网格单元),这导致需要处理百万级别的网格单元。在上一篇文章也有提及。

    也就是说,即便是模拟一个仅仅几十微米长的短纤维段,也需要处理数百万个网格单元。

    因此,采用 物理光学近似(PO)。在PO中,物体表面的电流和磁流(分别记作 $J$ 和 $M$ )可以看作是散射场的次级源头。电磁流产生了二次辐射,形成散射波。PO假设物体表面只会发生单次反射,忽略多次反射和复杂的衍射效应。得到表面的电流和磁流之后,计算他们产生的散射波。通过从表面电流和磁流中推导出这些远场波的性质。

    光一个PO还不够,还得揉个八叉树算法进去加速远场计算。

    4.2 Physical Optics Approximation

    具体地,PO做了两个简化假设:单次散射与局部平面假设。这个方法也足够通用。

    如上图,某物体表面采样了多个点,近似为平面。计算切平面电流和磁场,进而产生一个散射场。通过这个散射场,计算这些电流和磁流产生的远场散射波。与此同时,使用Octree结构划分为更小的体素。每个叶节点的计算结果会聚合至父节点,得到总的辐射贡献。

    Surface current calculation.

    表面电流、磁流的计算是PO模拟中非常关键的一步。对于每一个采样点,存储其法向量 $n(r{\prime})$ 和面积信息。接下来再每个小平面计算入射波和表面电流的相互作用。

    根据入射波的方向 $e_i$ 和表面法向 $n(r{\prime})$ ,将入射电场分解为平行极化和垂直极化分量。分解的原因是,平行极化和垂直极化在菲涅尔方程中,是完全不同的表达。

    平行极化分量:光的电场平行于入射光线的反射面。
    垂直极化分量:光的电场垂直于入射光线的反射面。

    因此,得到入射场 $E_i$ 被分解为平行极化的分量 $E_i^p$ 和垂直极化的分量 $E_i^s$ ,长这个样子: $E_i = E_i^p + E_i^s$ 。

    反射场 $E_r$ 被表示为平行极化和垂直极化的分量之和,入射场旁边的系数表示菲涅尔方程中的反射:
    $$
    E_r = E_r^p + E_r^s = F^p E_i^p + F^s E_i^s
    \tag{9-1}
    $$
    然后,总电场 $E_1$ 是入射场和反射场的总和:
    $$
    E_1 = E_i + E_r
    \tag{9-2}
    $$
    根据中学物理,我们知道电会生磁,磁也会生电。因此有如下表达:
    $$
    M = -n \times E_1,
    J = n \times H_1
    \tag{10}
    $$
    因此,根据入射场和反射场就可以计算出表面的感应电流和磁流。得到电流和磁流后,计算远场的散射波。

    在理论上,入射场可以是任意的。但是这里作者用了Gaussian-windowed平面波。这种波的振幅遵循正态分布,好算。

    总结一下,这里将入射光分解为两个分量,用菲涅尔方程算反射场。进而将反射场与入射场相加,得到总场。通过电/磁场与电/磁流的关系,计算表面的感应电/磁流。这样就可以模拟纤维的散射了。

    也就是说,计算纤维表面的电流和磁流的确可以得到光的散射行为。

    Far-field radiation in 3D

    上一节通过表面电流得到表面电流和磁流。本节用这个信息来计算远场散射。

    基于惠更斯原理(Huygens’s Principle)将原本的散射问题转化为辐射问题。

    惠更斯原理指出,每个波前的电都可以看作新的次级波源。一环扣一环的感觉。

    毛发纤维表面的电流 $J$ 和 磁流 $M$ 被视为光线的次级光源,然后重新辐射出来,得到散射场。

    这里就有点难了,涉及到电磁学中的矩量法(Method of Moments, MoM)。读者可以深入研究一下Gibson[2021]这本书,学会了一定要好好教我。但是没关系,我从闫老师2021年的paper中找了一张图,保证你能看懂。

    图中的红色小球表示的次级辐射源,各自向外辐射,类似于表面电流和磁流产生的次级辐射波。
    次级辐射源向各个方向发射等强度的波,这与本文散射公式中每个表面点对所有方向都有散射贡献的思想一致。
    图中观察了距离光源远场区域(距离为 $r$ )的一小片区域( $\delta \mathbf{r}$ )。在远场区域中,分析波在不同位置 $\mathbf{r}_1$ 和 $\mathbf{r}_2$ 的行为,分别沿着 $\hat{r}_1$ 和 $\hat{r}_2$ 两个方向观察。这与散射公式中,在远场处计算散射电场的行为非常相似。
    图中右侧的光波会在远场叠加形成复杂的干涉条纹,这类似于散射公式中的积分项,将次级辐射源的电磁波在远处叠加。

    用公式则表示为:
    $$
    E_s(\mathbf{r}) = j \omega \mu_0 \frac{e^{-jk_0 R}}{4\pi R} \hat{r} \times \int_\Gamma \left[ \hat{r} \times \mathbf{J}(r{\prime}) + \frac{1}{Z_0} \mathbf{M}(r{\prime}) \right] e^{jk_0 r{\prime} \cdot \hat{r}} d\mathbf{r{\prime}}
    \tag{11}
    $$
    结合图来理解,公式描述的是散射电场 $E_s(\mathbf{r})$ 在远处(远场)某一点 $\mathbf{r}$ 处的表现,与距离呈 $1/R$ 关系衰减。这个公式是一个二维复数平面,也就是把麦克斯韦方程组的解看作时谐电磁波的形式。

    看这个上式结构,形式上跟 $\mathbf{E}(\mathbf{r}, t) = \mathbf{E}_0 e^{j(\omega t – k \cdot \mathbf{r})}$ 很像,叫电场的常见时谐解。

    对应起来。 $ j \omega \mu_0$ 这虚数项描述磁场对电场的影响,与电磁场的频率 $\omega$ 和真空中的磁导率 $\mu_0$ 相关。光波的频率越高,电场越强。 $e^{-jk_0 R}$ 是相位因子,表示光波在传播中的相位变化。波数 $k_0 = \frac{2\pi}{\lambda}$ , $\lambda$ 是电磁波的波长。表示波传播到距离 R 的地方时,电场的相位会发生变化。 $\hat{r}$ 是从散射物体指向观察点的单位向量,表示波的传播方向。叉乘 $\times$ 操作符表示计算的是电场的方向。确保计算出的电场和波传播的方向一致。

    积分项 $ \int_\Gamma \left[ \hat{r} \times \mathbf{J}(r{\prime}) + \frac{1}{Z_0} \mathbf{M}(r{\prime}) \right] e^{jk_0 r{\prime} \cdot \hat{r}} d\mathbf{r{\prime}} $ 是该公式的核心。对物体表面 $\Gamma$ 进行积分,得到毛发表面每个点对散射电场的贡献。每个点的表面电流加表面磁流之和,再点乘相位因子。进一步的, $\mathbf{J}(r{\prime})$ 是表面电流密度, $\hat{r} \times$ 确保了电流产生的散射电场与波的传播方向 $\hat{r}$ 正交。 $Z_0$ 是自由空间阻抗(free space impedance),具体数值 $Z_0 = \sqrt{\frac{\mu_0}{\varepsilon_0}} \approx 377 \, \Omega$ ,个人理解该常数是真空中电场和磁场的比例关系,电场和磁场的抗阻不同因此需要统一尺度才能线性相加,即将磁流规范化到和电流类似的形式。指数项也是相位因子,不做过多解释。

    细心的读者可能发现,为何表面电流密度 $\mathbf{J}(r{\prime})$ 有叉乘 $\hat{r} \times \mathbf{J}(r{\prime})$ ,而表面磁流密度 $\mathbf{M}(r{\prime})$ 没有叉乘。

    根据麦克斯韦方程组,电场和磁场是互相正交的。电流 $\mathbf{J}$ 会产生磁场,变化的磁场再反过来产生电场。电场和磁场是相互耦合的。但是,我们重新看看麦克斯韦方程组的下面两条。
    $$
    \nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}
    \
    \nabla \times \mathbf{B} = \mu_0 \mathbf{J} + \mu_0 \varepsilon_0 \frac{\partial \mathbf{E}}{\partial t}
    $$
    磁场的生成是直接通过位移电场来实现的,方向已经和电场正交。而电场的产生是磁场的变化率,方向性需要调整。

    换一个角度来理解,积分项左边描述的是电磁场强度随距离衰减,也考虑了相位变化和方向性。积分项描述每个表面点的电磁流与相位贡献

    总结,公式(11)是一个精确的表达,描述物体表面电磁场如何在距离 $r$ 处散射。得到了电场的公式,磁场就很容易计算了。

    当散射距离 $R$ 足够大时,远场的简化表达式如下,可以直接忽略近场效应。换句话说,在远场中,传播特性已经趋于稳定,因此可以把积分项简化为与散射方向 $\hat{r}$ 相关的项,即 $E_s^{\text{far}}(\hat{r})$ 。
    $$
    E_s(r) = \frac{e^{-jk_0 R}}{R} E_s^{\text{far}}(\hat{r}), \quad H_s(r) = \frac{e^{-jk_0 R}}{R} H_s^{\text{far}}(\hat{r})
    \tag{12}
    $$
    此外,若直接计算每个点的贡献,时间复杂度为 $O(MN)$ ,即离散点数$M$ $*$ 散射方向数$N$ 。此处引入八叉树,通过空间划分减少计算表面的离散点数,时间复杂度降到了 $O(M+log(M)N)$ 。具体实现方式在4.3。

    4.3 Multilevel fast Physical Optics

    用多层快速物理光学(Multilevel Fast Physical Optics, MFPO)加速远场散射计算。

    Chew[2001]提出的多层快速多极子算法(MLFMA),用于加速求解电磁场散射问题。这个算法首先为例如毛发表面构建一个八叉树结构,每个叶节点表示一个采样点。构建完Octree后计算每个叶节点的表面电流、磁流。接着从叶节点出发逐层向上累积。从原本的 $O(MN)$ 降低到 $O(M + \log(M)N)$ 。

    接着作者介绍了八叉树加速算法的三个关键部分。首先是远场散射核(far-field scattering kernel)。
    $$
    e^{jk_0 r{\prime} \cdot \hat{r}} = e^{jk_0 (r{\prime} – c_L) \cdot \hat{r}} \prod_{i=1}^{L} e^{jk_0 (c_i – c_{i-1}) \cdot \hat{r}}
    \tag{13}
    $$
    最终目标是计算物体表面每个点的电磁波对某个远场观察区域(距离为 $R$ ,方向为 $\hat{r}$ )的散射贡献。具体是求散射电场 $E_s(r)$ 和磁场 $H_s(r)$ ,即从毛发表面上的每个点 $r{\prime}$ 发出的电磁波在远场中的贡献。公式等号左边是某表面点 $r{\prime}$ 到远场观察方向 $\hat{r}$ 的相位变化。最终的时间复杂度为 $O(MN)$ 。作者把表面分为不同的区域,每个区域指定一个参考中心点,公式中的 $c_0, c_1, …, c_L$ 是八叉树中不同层次的节点中心。

    光是这样也没办法减少计算量。因此需要将距离较近的表面点,由于它们的相位变化差别很小,通过八叉树的高层节点合并这些点的贡献。

    还是用闫老师这幅图来解释。对于远场的某个区域 $\delta \mathbf{r} $ ,首先还是会精确计算所有采样点对参考点 $\mathbf{r}$ 的贡献。但是该区域的其他点则用八叉树的父节点进行近似计算。也就是说,旁边的 $\mathbf{r}_1$ 和 $\mathbf{r}_2$ 就不再需要考虑这么多的采样点了,而是直接用八叉树得到的总贡献。

    作者定义了一个方向集,八叉树每个父节点都存储了关于不同方向集的累积贡献数据。因此,父节点不仅包含空间上的信息,还包含多个散射方向上的累积信息。最终在根节点得到完整的360度散射场分布。

    接下来,从树的第二层开始,依次向上合并。上采样具体方法是对子节点的方向集中的散射贡献做正向FFT,接着zero-padding扩充频域数据最后逆向FFT转换为空间域。最终,使得父节点可以在不同方向上得到更加精确的散射信息。

    • Performance

    八叉树加速效果很好。三层树效果最佳。越复杂的纤维优化效果越好。

    • Fiber microgeometry and scattering patterns

    毛发纤维横截面一般不是完美的圆形,而是椭圆形。通过椭圆的主半径 $r_1$ 和次半径 $r_2$ 来定义纤维的几何参数。为了模拟纤维表面的微观粗糙度,作者在椭圆柱体的表面上叠加了一个高斯随机高度场(Gaussian random height field),模拟真实的纤维表面。进一步地,加入了角质层倾斜(cuticle tilt),模拟薄片在纤维表面倾斜排列。

    通过对比传统的基于光线的毛发模型,波动光学模拟发现,除了XIA[2020]预测到的显著的向前散射(forward-scattering)现象,还观察到了复杂的波长依赖颗粒图案(wavelength-dependent granular patterns)。在转换为RGB颜色时,会生成丰富的色彩效果。

    研究指出了一些从模拟中观察到的规律:

    • 无论它们的实际位置如何,具有相同几何参数(如半径、粗糙度、角质层倾斜等)的纤维,在散射行为上会生成相似的颗粒图案
    • 如果纤维的几何参数不同,则它们会生成不同统计特性的颗粒图案,即散射模式明显不同。
    • 散射斑点的位置依赖于光线的入射角度,偏移的方向跟随half vector方向。
    • 斑点(speckle)图案的大小随光波波长的增加而增大。这一现象与Goodman[2007]结果一致。

    5. A PRACTICAL FIBER SCATTERING MODEL

    实用纤维散射模型(A PRACTICAL FIBER SCATTERING MODEL)旨在解决纤维的微观几何变化和复杂散射行为。

    以前的研究一般是用Lut存散射分布函数,这种方法空间消耗巨大,需要同时记录纵向和方位角散射分布。

    现在,作者提出了一种基于小波噪声表示的紧凑纤维散射模型,可表示的几何复杂度提高了,散射效果更好了。

    简单的说,作者想要紧凑的统计散斑现象,使其可以通过均值方差自相关函数(ACF)等统计量来表示。因此作者借助了散斑理论(Speckle Theory)来描述光随机干涉所产生的图样。

    5.1 Speckle statistics

    这里作者上来就提到完全发展的散斑 (Fully Developed Speckle)这个概念。以下是个人理解。在光线照射到一个粗糙的表面(比如纤维/毛发表面)时,表面上的每个小区域都会散射光线。由于表面的微小不规则性,散射的光线之间会相互干涉,产生一个复杂的光强分布。这种分布表现为一系列亮点和暗点,我们称之为散斑。表面的微小特征(比如粗糙度)在整个照射区域(相对于光线的波长来说)变得足够不规则,导致散射光线在各个点上的相位和强度是随机的,这就是所谓的完全发展的散斑

    这个时候,可以用Goodman[2007]的complex Gaussian distribution来描述Fully Developed Speckle。即,电磁场的实部 $\mathcal{R}$ 和虚部 $\mathcal{I}$ 在空间上服从复高斯分布(complex Gaussian distribution)。
    $$
    p_{\mathcal{R},\mathcal{I}}(\mathcal{R}, \mathcal{I}) = \frac{1}{2 \pi \sigma^2} \exp\left( – \frac{\mathcal{R}^2 + \mathcal{I}^2}{2\sigma^2} \right)
    \tag{14}
    $$
    场的实部和虚部是独立且正态分布的。均值为零,方差相同。

    电磁场光强 $I$ 和该光强分布的服从指数分布(exponential distribution)的概率密度函数:
    $$
    I = \mathcal{R}^2 + \mathcal{I}^2 \ p_I(I) = \frac{1}{2\sigma^2} \exp\left( -\frac{I}{2\sigma^2} \right)
    \tag{15}
    $$
    这些公式没啥可讲的,总之散斑场就是很随机哒!

    作者让光强遵循指数分布,确保在单个方向的统计特性。其次,通过研究两个点之间的光强统计关系,衡量两个点的ensemble average。这里用autocorrelation function (ACF)。
    $$
    C(I_{p_1}, I_{p_2}) = \frac{\overline{(I_{p_1} – \overline{I_{p_1}})(I_{p_2} – \overline{I_{p_2}})}}{\sigma(I_{p_1}) \sigma(I_{p_2})}
    \tag{16}
    $$
    自相关函数的值介于 -1 和 1 之间,当值接近 1 时,说明两个光强的散射行为非常相近。这是高效再现纤维散射场中颗粒结构的关键。

    5.2 Wavelet noise representation of the speckles

    作者引入小波噪声(Wavelet noise)来表示散斑的噪声分量 $f(\theta_h, \phi_h, \lambda)$ 。具体公式如下:
    $$
    f(\mathbf{x}) = \sum_{b=0}^{n-1} w_b(\mathbf{x}) I\left(2^b g_{\lambda}(\mathbf{x})\right)
    \tag{17}
    $$
    公式的核心思想是将散斑的光强分解为不同频率层次的噪声,并且加权组合。

    通过调整不同频率带的权重,生成自相关函数 $ C_f(\mathbf{x}_1, \mathbf{x}_2)$ 接近与目标自相关函数的最终噪声。

    根据维纳-辛钦定理(Wiener-Khinchin theorem),自相关函数可以通过傅里叶变换(Fourier Transform)来计算。具体公式如下:

    $$
    C_f(\mathbf{x}_1, \mathbf{x}2) = \mathcal{F} \left( \mathcal{F}^{-1} \left( \sum{b=0}^{n-1} w_b I_b \right)^2 \right)
    \tag{18}
    $$
    这表示我们可以通过计算小波噪声的傅里叶变换,来获得自相关函数。自相关函数和功率谱密度函数是一对傅里叶变换,太Amazing了。

    这里原论文给了一个通过频率带加权求和近似计算ACF的证明。

    省流:通过对噪声的各个频率带进行加权,可以近似得到整个噪声的自相关函数,而不用处理不同频率带之间的交互。

    通过最小二乘法找到非负权重 $v_b$ ,以使得每个频率带的自相关函数 $C_b(\mathbf{x}1, \mathbf{x}_2)$ 的加权和可以逼近目标自相关函数 $C_t(\mathbf{x}_1, \mathbf{x}_2)$ 。

    $$ C_t(\mathbf{x}_1, \mathbf{x}2) \approx \sum{b=0}^{n-1} v_b C_b(\mathbf{x}_1, \mathbf{x}_2) \tag{19} $$ 计算完权重后,要确保能量守恒。也就是调整噪声函数 $f(\mathbf{x})$ 的期望值 $\mathbb{E}[f(\mathbf{x})] = 1$ 。 $$ \begin{aligned} & \mathrm{E}\left[S{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right) f\left(\theta_h, \phi_h, \lambda\right)\right] \\\
    & =S_{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right) \mathrm{E}[f(\mathbf{x})] \
    & \approx S_{\text {avg }}\left(\theta_i, \theta_r, \phi_r, \phi_r, \lambda\right)
    \end{aligned}
    \tag{20}
    $$


    虽然效果好,但是有局限性。比如在grazing incidence angles的地方(光线几乎平行表面的情况),拟合准确性下降,导致散射准确度下降。Degeneration in the Forward Direction问题,当光线处于正前方方向(光线方向与表面法线一致)时,半向量方向会退化。

    6. Validation

    6.1 Wave simulation validation

    计算了x-y平面中的散射强度,并通过3600个方位角 $\phi_r$ 进行计算,最终将这些角度平均到360个方向上。

    首先对比Mie散射。在grazing angles不如Mie散射。物体的半径相对于波长较大时,PO近似更为精确。当物体曲率较小,就不准。

    然后对比BEM。模拟一个小椭球体的波散射。BEM用了三个小时,PO花了两秒。

    最后对比2D BEM。用一维高斯高度场(1D Gaussian height fields)包裹在圆形和椭圆形的横截面上。PO完胜。

    6.2 Measurement

    使用了波长为 633nm 的 HeNe 激光器,光束的光斑大小为 0.7mm(沿着头发的长度方向)× 3mm(垂直于头发方向)。激光通过一个小孔照射在人类头发样本的一个小区域。

    总之就是效果好!

    6.3 Noise representation validation

    一个字,好!

    7. Rendering

    整合进PBRT-v3。原本的基于光线的模型记作 $S_{\text{ray}}$ ,而衍射模型记作 $S_{\text{diffract}}$ 。

    对于衍射,这里近似为单缝衍射,而缝的宽度等于圆柱体(纤维)的直径。
    $$
    f_{\text{diffract}}(\theta_i, \phi_d, a) = a \cos \theta_i \cdot \text{sinc}^2(a \cos \theta_i \sin \phi_d)
    \tag{22}
    $$
    这种衍射模型被用来和纵向函数结合,得到一个完整的双向曲面散射分布函数(BCSDF)。将衍射因子以 $50 \times 50 \times 200$ 的表格形式进行预计算。并使用同样大小的表格进行重要性采样。

    消光截面可以简单理解为光与物体相互作用的“有效面积”。 纤维的消光截面会比它的实际几何截面大。消光截面会接近于几何截面的两倍。光在纤维上既发生反射也发生衍射,因此我们需要把总能量分配给这两种现象。根据经验,可以把一半的能量用于衍射,另一半用于反射。
    $$
    S_{\text{diffract}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \frac{1}{2} \left[S_{\text{ray}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) + f_{\text{diffract}}(\theta_i, \phi_d, D/\lambda)\right]
    \tag{23}
    $$
    通过重要性采样,公平地考虑光的反射和衍射。

    接下来,最终的描述光散射现象的渲染公式,形式整得挺好:
    $$
    S_{0,\text{prac}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = S_{0,\text{avg}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)\ \\
    = \frac{1}{2} \left[ S_{\text{ray}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) + f_{\text{diffract}}(\theta_i, \phi_d, D/\lambda) \right] f(\theta_h, \phi_h, \lambda)\
    \tag{24}
    $$
    反射+衍射+噪声。噪声函数 $f(\theta_h, \phi_h, \lambda)$ 在最后,使得散射的光线不在集中在少数方向。

    重点看这个部分:
    $$
    S_{0,\text{prac}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = S_{0,\text{avg}}(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) f(\theta_h, \phi_h, \lambda)
    $$
    $S_{0,\text{avg}}$ 表示毛发表面平均反射/折射和衍射的行为,这是通过预处理(BSDF表格)计算得到的。

    接下来讲讲如何从PO提取BCSDF。

    通过麦克斯韦方程得到散射的电场和磁场($E_s^{\text{far}}$ 和 $H_s^{\text{far}}$)。

    接着用坡印廷矢量(Poynting vector)计算能量流。相当于计算光的强度。


    $$
    \langle S \rangle = \frac{1}{2} \text{Re}(E \times H^) \tag{25}
    $$

    为了将模拟结果用于渲染,将散射强度与入射功率关联起来。散射强度的计算公式,其中 $R^2$ 是远场球面面积:

    $$
    I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \langle S(\mathbf{r}) \rangle \cdot \hat{n} R^2 \tag{26}
    $$

    散射功率 $P_s$ 和吸收功率 $P_a$ 分别通过积分散射光和吸收光的强度来计算。

    $$
    P_s = \int_{\Omega} I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) \, d\omega \tag{27}
    $$

    对于吸收功率 $P_a$ ,通过表面上的坡印廷矢量积分计算。

    $$
    \begin{aligned}
    P_a & =\int_{\Gamma} \frac{1}{2} \operatorname{Re}\left(\mathbf{E}1 \times \mathbf{H}_1^{}\right) \cdot \hat{\mathbf{n}}_1(A) d A \ & = \\
    \int{\Gamma} \frac{1}{2} \operatorname{Re}\left(\mathbf{J}^{} \times \mathbf{M}\right) \cdot \hat{\mathbf{n}}_1(s) d s
    \end{aligned}
    \tag{28}
    $$


    最终,BCSDF搞出来了。

    $$
    S(\theta_i, \phi_i, \theta_r, \phi_r, \lambda) = \frac{I_s(\theta_i, \phi_i, \theta_r, \phi_r, \lambda)}{|P_a – P_s|}
    \tag{30}
    $$

    最后用了一个5D的表格。

    1D 维度描述波长。
    2D 描述入射光的方向。
    2D 描述出射光的方向。

    可以通过散射的记忆效应减少存储需求。

    渲染时,对于每个入射光方向,查询相邻的表格数据,应用相应的角度偏移(记忆效应中的角度偏移量)。

    最终使用的表格大小为 $25 \times 32 \times 72 \times 180 \times 360$ ,非常大,约15GB的内存需求。

    8. Result

    与XIA[2020]相比,新模型更加鲜艳。色彩光斑(colorful glints)更好。体现多种波长对毛发产生的光斑。

    9. DISCUSSION AND CONCLUSION

    第一篇能够实际应用的 3D 波动光学纤维散射模型。

    能够生成光学散射中常见的细微光斑(speckle patterns)。

    虽然 3D 模拟的精度很高,但它的内存占用和计算成本非常大。提出了噪声实用模型,通过捕捉纤维散射光斑的统计特性(如自相关函数),减少计算量。

    目前该模型主要用于第一阶反射模式的模拟。未来可以扩展到更高阶的散射模式。

    高阶模式时可能需要进一步开发新技术来预测或测量高阶模式的统计特性。

  • 毛发渲染研究:从基于光线到波动光学-学习笔记-1

    毛发渲染研究:从基于光线到波动光学-学习笔记-1

    原文:https://zhuanlan.zhihu.com/p/776529221


    毛发渲染研究历史演变

    在1989年,Kajiya 和 Kay 将Phong模型扩展到毛发绘制,提出了绘制毛发的经验模型——Kajiya-Kay 模型。该模型将毛发简化为一系列细长的圆柱体,并假设光线只在毛发表面发生简单的反射。镜面反射:高光。漫反射:模拟光线在毛发内部的散射,毛发的整体亮度。

    Marschner等人在2003年发表了论文《Light Scattering from Human Hair Fibers》,提出了一个基于物理的毛发反射模型,被称为Marschner模型。

    传统的毛发渲染模型,如Marschner模型和Kajiya-Kay模型,通常将毛发简化为单层圆柱体,忽略了毛髓质(medulla)的影响。Yan等人在2015年发表的论文《Physically-Accurate Fur Reflectance: Modeling, Measurement and Rendering》针对这一问题提出了一种更为精确和高效的毛发渲染模型,即将每根毛发建模为两个同心圆柱体。其建模结合了完整的毛发双向散射分布函数(BSDF),精确地描述光线在毛发中的多路径传播和散射行为。此外,为了确保物理真实性,进行了大量物理测量工作,包括九种不同的毛发样本,最后开放了数据库的部分反射参数供艺术家调整。为了提高渲染效率,Yan[2015]结合了[Zinke and Weber 2007]近场散射(R, TT, TRT)的考虑,对常见的散射路径进行预计算,存在Lut中,实现了单毛纤维的高效光线散射计算。对于渲染管线的优化,Yan[2015]主要集中在双圆柱模型上。

    从Marschner[2003]到d’Eon[2011]和Chiang[2016]的拓展模型,虽然不断增加毛发的参数(如d’Eon[2011]的方位角粗糙度数值积分、Chiang[2016]的近场方位角散射)使得渲染精度增加,但是其复杂的散射路径和大量的预计算限制了其实用性、实时性。Yan[2015]提出的双圆柱毛发反射模型也存在计算成本高、实用性低的问题。因此Yan[2017]提出了一个简化版本,通过解析的方式实现快速积分并且大幅减少Lobes数量。

    上面这幅图中,清晰的指出了,Marschner[2003]使用的是纵向-方位角的分解表示,将复杂的三维光散射过程简化为两个较为独立的维度。纵向散射函数(Longitudinal Scattering Function)描述光线沿着毛发纤维轴线的传播和散射。方位角散射函数(Azimuthal Scattering Function)描述光线在毛发纤维横截面(垂直于纤维轴线的平面)内的散射。这个模型考虑T、TT和TRT。在d’Eon[2011]修正了能量守恒问题。Yan[2015]的双圆柱模型(毛小皮和毛髓质)复杂了光线交互,考虑了R、TrT、TtT和TrRrT。Yan[2017]中引入了统一的折射率(IORs),化简光路传播,不再区分不同材料的折射率,即R、TT、TRT、TT^s和TRT^s(^s表示化简路径)。Yan[2017]指出统一IORs实际上并不会显著影响渲染结果,依旧能保持较高的真实性,原因是毛皮质(cortex)和毛髓质(medulla)之间的折射率非常接近

    Yan[2017]为了解决以前毛发渲染中计算复杂度较高的问题,提出了近场与远场的划分,并引入了分层渲染策略(Layered Rendering Strategy)。近场主要是光线在单根毛发上的精细散射和反射。此区域需要高精度的物理模型渲染毛发,如涉及干涉、衍射等波动光学现象。远场则是描述光线在大量毛发纤维集体作用下的整体散射效应。在此区域,单根毛发的微观结构影响可以被平均化,更加适合采用统计/近似等方法计算,优化提高渲染效率。划分标准:基于光与毛发纤维之间的距离和毛发纤维的尺寸。也就是设定一个Threshold,当光线与毛发的距离小于该阈值时,归类为近场;反之,归类为远场。分层渲染的流程首先是光线追踪,判断近场抑或是远场,近场结合预计算散射表用Mie散射理论和Fresnel方程算反射透射,远场用统计散射函数+预计算散射表+MC积分降低复杂度,最终两者叠加,并且做归一化处理。这里按照论文的三点来详细讲讲:

    • Simple Reflectance Model(简单的反射模型)Yan[2015]的模型虽然引入了毛髓质(medulla),并考虑了更多的物理细节,但仍然具有较高的计算复杂度,特别是在近场与远场的转换上。Yan[2017]提出简化的分层反射模型,保留了反射的关键物理现象,减少了不必要的复杂光散射路径。他们将反射描述为三个主要的光散射路径(R:光线在毛小皮表面的镜面反射、TT:光线透过毛小皮层,并在内部散射后从另一侧透射出来、TRT:光线进入毛发后在内部反射一次,最终透射出来)。最后结合简化的双向散射分布函数(BSDF)捕捉路径的反射,减少了计算中所需的lobes(通常用于描述不同散射方向的分布曲线)。相比于之前的模型,计算过程中的lobes从原来的9个减少到5个。
    • Improved Accuracy and Practicality(提高的精度和实用性)高精度模型(如Marschner模型、Yan[2015]等)需要复杂的数值计算和大量的预计算数据,因此难以在实时应用中实现。而低精度的经验模型则缺乏足够的物理真实性。于是在Yan[2017]中,做了很多改进。尽管模型简化了光散射路径,但通过结合物理现象(如毛小皮的多层反射结构、各向异性散射等),保持了毛发和毛皮的物理精度。通过对光的纵向和方位角散射进行合理简化,该模型在精确性上优于传统的经验模型。此外就是近场与远场的过渡处理。传统模型往往无法平滑地处理近场和远场之间的光学过渡问题。Yan等人引入了一个近场-远场的解析解决方案,在光线靠近毛发纤维时精确模拟反射,同时在远场时快速近似光线的整体反射行为。使得渲染效率可用于实时渲染。
    • Analytic Near/Far Field Solution(解析的近场/远场求解)近场(光线与单根毛发纤维之间的短距离交互,即散射行为)和远场(光线与大量毛发纤维之间的远距离集体效应)的处理存在巨大差异。为了实现近场和远场的无缝过渡,作者使用了解析积分方法,而非繁琐的数值积分。解析积分能够直接计算反射函数,不需要通过复杂的数值求解或预计算,极大地减少了计算时间。
    • Significant Speed Up(显著加速)
      • 减少用于描述散射路径的lobes数量
      • 解析积分和预计算结合
      • 采用了简化的BSDF和解析的反射计算公式,将光线追踪和反射计算在GPU上并行化,模型的渲染速度比之前的方法提升了6-8倍。

    简单总结一下,Yan[2017]提出的反射模型效果和性能都不错,通过统一皮层和髓质的IOR,使得模型只需要5个lobes就可以表示皮毛的复杂散射,利用张量近似最小化存储开销。在这个模型的基础上,提出远近场的解析积分,将模型拓展至多尺度渲染。想要在实时渲染中实现BCSDF模型是非常简单的,当前已经有不少实现方式了,并且已经应用于影视行业。[The Lion King (HD). 2017 movie] (2019 Oscar Nominee for Best Visual Effects)

    XIA[2023]提出了基于波动光学的毛发反射模型。传统的毛发渲染模型大多基于几何光学近似,这些模型在处理较大的毛发纤维时效果较好,但在细微光学现象(如毛发上的彩色光点,即glints)方面表现欠佳。这些散射效应,包括反射、透射和多次散射,难以通过简单的几何光学模型准确描述。随着毛发纤维的直径接近或小于光(可见光)波长时,波动光学效应变得越来越重要,而几何光学模型则无法捕捉这些效应。

    毛发的波动光学效应,如光的干涉、衍射等计算量非常大。波动光学模拟需要计算电磁场的传播,而不仅仅是光线的路径。毛发、皮毛具有高度不规则的微观结构,这些结构会进一步影响光的散射。基于几何光学的方法无法处理这些波动现象,而全波模拟需要高昂的计算资源。

    早在XIA[2020]就已经提出了利用波动光学准确描述光与纤维的相互作用,用边界元法(Boundary Element Method, BEM)模拟光线在任意截面的纤维散射。并且XIA[2020]指出由于衍射效应,纤维表现出极强的前向散射效应。因此波动光学效应应该让光线专注集中前向散射的方向。还指出了,小的纤维散射效应显著依赖光的波长,导致强烈的波长散射。此外波动场带来的奇异性软化现象也是决定真实焦散效果的关键。为了BEM模拟的计算量可控,纤维的形状理想为具有规则的横截面形状。但是Marschner[2003]指出毛发表面的不规则对毛发外观有重要的影响,在波动光学中这样的效应是否显著仍然是一个需要探讨和解决的问题。

    传统的几何光学方法基于光线追踪(Ray Tracing),通过模拟光线在毛发纤维上的反射和折射来预测光的传播路径。然而,这种方法在处理波长与纤维尺寸相当的光波时显得不足,无法捕捉到由于衍射产生的复杂光学效应。实际测量中,纤维散射表现出一些尖锐的光学特征,这是由于光的衍射效应造成的。包括黑色狗毛中表现的略微颜色偏移,也是由于光的干涉和衍射引起的。

    为了处理这些几何光学无法解释的现象,XIA[2023]开发了一个基于物理光学近似(Physical Optics Approximation,PO)的三维波动光学模拟器,并且利用GPU加速计算效率。通过八叉树结构来处理空间。该模拟器具有一定的通用性,能够处理任意的三维几何形状,也就是说可以处理到纤维表面的微观结构。

    但是XIA[2023]中指出,因为计算复杂度高的原因,直接将这个模拟器运用到当前主流渲染框架是不现实的。因此需要先将该模型迁移到现有的毛发散射模型中,然后加入一个基于基础衍射理论(elementary diffraction theory)的衍射lobe。最后提出了一个基于随机过程的调制方法来捕捉散斑(optical speckle)效应,尽管是程序噪声但是依然与物理模拟结果一致,视觉效果与现实相近。

    XIA[2023]将当前的毛发/纤维渲染分为两种,一种是传统的Ray-based Fiber Models(基于光线的纤维模型),另一种则是Wave-based Fiber Models(基于波动光学的纤维模型)

    Linder[2014]提出了一种解析解用于处理圆柱形纤维的散射行为,但是只适用于完美的圆形截面,无法处理复杂的毛发表面结构。XIA[2020]通过二维波动光学渲染研究了任意横截面形状的纤维散射行为,展现出衍射效应的表现,但是该论文假设前提是完美的挤出结构,即纤维表面是规则的。Bennamira&Pattanaik[2021]提出了一个混合模型,在只有前向衍射的情况下使用波动光学求解,在其他散射模式中使用传统的几何光学。但是XIA[2023]进一步考虑了光线的入射纵向角依赖性

    论文最后将程序噪声拟合到波动光学中的散斑模式,通过统计特性拟合,产生非常真实的效果。

    XIA[2023]还提到了电磁计算工具(Computational Electromagnetics Tools)在处理光线和纤维复杂交互时的重要作用,尤其是使用像是BEM等数值方法时。计算电磁学是用于研究电磁现象的计算方法,因为光是一种电磁波,光学中许多现象可以通过电磁学的工具来分析。CEM在光学中经常用于计算光与物体表面(如毛发纤维)之间的交互。CEM的常见算法有:

    • 有限差分时域法(Finite-Difference Time-Domain, FDTD):一种通过空间和时间离散化求解麦克斯韦方程的数值方法,最早由Kane Yee于1966年提出,是一种直接的时域方法Kane Yee[1966], Taflove[2005]。
    • 有限元法(Finite Element Method, FEM):通过将求解区域划分为有限个元素,用来求解复杂几何结构中的电磁场分布Jin[2015]。
    • 边界元法(Boundary Element Method, BEM):也称为矩量法(Method of Moments, MoM),这是一种通过只处理物体表面的电磁场来减少计算量的数值方法Gibson[2021], Huddleston[1986], Wu[1977]。

    虽然CEM有很多加速算法,比如Song[1997]的多层快速多极子算法(Multilevel Fast Multipole Algorithm, MLFMA),但是在毛发皮毛模拟中,提升依旧杯水车薪。

    由于全波模拟计算成本高昂,因此XIA[2023]提出物理光学近似(PO)来简化例如毛发纤维表面的反射和衍射过程。

    物理光学平面模型物理光学近似(Physical Optics, PO)的一种应用,它专门用于模拟光在平面或接近平面的表面上的散射和衍射效应。Beckmann-Kirchhoff[1987]和Harvey-Shack[1979]可以有效计算粗糙表面的散射光。He[1991], Kajiya[1985]的高斯随机表面、Stam[1999]的周期性静态表面和Werner[2017]的划痕表面都利用了物理光学近似来处理表面反射和衍射。

    对于更加复杂的衍射,Krywonos[2006], Krywonos[2011]则提出了改进粗糙表面衍射的处理方法。Holzschuch, Pacanowski[2017]提出了双尺度微表面模型,结合反射和衍射来模拟粗糙表面。最近Falster[2020]结合了Kirchhoff标量衍射理论和路径追踪处理二次反射、散射。Yan[2018]利用物理光学渲染粗糙表面的镜面微几何结构。

    和平面表面不同的是,纤维表面是闭合的曲面,毛发纤维的几何形状对光的相互作用更加复杂。除了反射和散射,还需要处理前向衍射散射和大范围阴影效应。

    XIA[2023]还讨论了散斑(speckle)效应程序噪声(procedural noise)在毛发渲染中的应用。

    Speckle是当光线和粗糙的表面相互作用时,产生的具有颗粒感结构的图像或衍射图案。其统计特性已经被广泛研究。当相干光(如激光)照射到粗糙表面或通过散射介质时,产生的一种随机的明暗斑点图案。通俗地讲,就像你用激光笔照射在一堵粗糙的墙上,原本应该是一个光滑的光点,却会看到一个颗粒状、闪烁的图案。由于光在微小的表面不平整处发生了散射,不同路径的光波相互干涉,有的加强(形成亮斑),有的相互抵消(形成暗斑),从而产生了这种斑点状的图案。

    之前的研究已经探索了如何通过蒙特卡洛方法来模拟体积散射中的散斑效应Bar[2019, 2020]。然而,这些模型主要适用于均匀介质(homogeneous media),不适用于毛发纤维等异质结构。Steinberg, Yan [2022]研究了平面粗糙表面的散斑渲染。然而作者指出,纤维表面的散斑效应与平面表面不同,表现出不同的统计特性。

    因此XIA[2023]提出了精确捕捉纤维散斑模式统计特性的渲染模型。通过研究纤维表面的特殊几何结构和散斑分布,来模拟毛发纤维的散射效应,提供更优质的散斑效果。

    需要注意的是,薄膜干涉(Thin-film interference)和散斑效应(Speckle Effect)虽然都是由光的干涉现象引起,但它们在物理机制、视觉表现以及在计算机图形学中的渲染方法上存在显著差异。薄膜干涉的蒙特卡洛方法,如随机薄膜厚度采样,可以借鉴到散斑效应的随机斑点生成中,以提高渲染的真实感。层次化的厚度采样、预计算干涉图案等近似算法也可以相互借鉴。薄膜干涉往往涉及不同尺度的光波干涉,散斑效应也涉及微观表面结构的多尺度散射。

    两者之间,薄膜干涉渲染复杂度相对较低,可以充分使用预计算规避实时计算的负担。但是散斑效应具有高度随机、统计特性,需要处理大量随机干涉的路径,尤其是模拟毛发这种异质结构。目前的研究如XIA[2023]正致力于提高其效率,但相比薄膜干涉仍有较大差距。

    XIA[2023]使用Cook, DeRose[2005]的Wavelet带限噪声控制处理毛发纤维的微观几何变化。这种噪声不同于常规的程序噪声,例如Perlin[1985], Olano[2002], Perlin, Neyret[2001]等,Wavelet噪声的一个显著优点是其统计分布可以计算和控制

    XIA[2023]的实用波动光学纤维散射模型的优势在于其逼真的彩色高光(glints)。以往的几何光学模型通常假设纤维表面是光滑的介电柱体,没有考虑到光波在表面不规则结构上的复杂交互。在实际测试中,XIA[2023]的模型在渲染时间上表现良好,能够在生产环境中应用,并且相比于传统模型生成的光学效果更加细腻和真实。

    XIA[2023]是一个重要的突破,构建了首个三维波动光学纤维散射模拟器。以往的纤维模型(包括早期的波动光学模型如Xia等人2020年)大多假设纵向和方位角方向上的散射是可分离的,这大大简化了计算。然而,作者的模拟结果显示,高光在纵向和方位角上是不可分离的,这是之前的模型无法准确处理的现象。该模拟器还预测了散斑模式(speckle patterns),这是之前所有基于几何光学和波动光学的纤维散射模型都没有捕捉到的现象。并且以往的存储和查找模拟生成的5维散射分布的方法是通过表格法(tabulation),特别消耗内存。因此用程序噪声直接取代一个五维的表格。

    XIA[2023]目前只模拟了一次反射模式中的散斑效应,更高阶的反射模式仍然在研究中。并且浅色的毛发可能需求更高的计算要求才能完美模拟。该研究的波动光学纤维散射模型可以轻松与之前的纤维模型结合

    References

    Zotero一键生成,需修正。

    [1] J. T. Kajiya and T. L. Kay, “RENDERING FUR WITIt THREE DIMENSIONAL TEXTURES,” 1989.

    [2] S. R. Marschner, H. W. Jensen, and M. Cammarano, “Light Scattering from Human Hair Fibers,” 2003.

    [3] A. Zinke and A. Weber, “Light Scattering from Filaments,” IEEE Trans. Visual. Comput. Graphics, vol. 13, no. 2, pp. 342–356, Mar. 2007.

    [4] L.-Q. Yan, C.-W. Tseng, H. W. Jensen, and R. Ramamoorthi, “Physically-accurate fur reflectance: modeling, measurement and rendering,” ACM Trans. Graph., vol. 34, no. 6, pp. 1–13, Nov. 2015.

    [5] L.-Q. Yan, H. W. Jensen, and R. Ramamoorthi, “An efficient and practical near and far field fur reflectance model,” ACM Trans. Graph., vol. 36, no. 4, pp. 1–13, Aug. 2017.

    [6] M. Xia, B. Walter, C. Hery, O. Maury, E. Michielssen, and S. Marschner, “A Practical Wave Optics Reflection Model for Hair and Fur,” ACM Trans. Graph., vol. 42, no. 4, pp. 1–15, Aug. 2023.

    Glints效果探究

    传统的基于几何光学的渲染方法,例如Yan[2014, 2016]使用双向反射分布函数(BRDF)来模拟镜面反射表面,存在一定的局限性。

    Yan[2014, 2016]指出,传统BRDF模型通常使用光滑的法线分布函数(NDF),假设微平面是无限小的。但实际上,现实中的表面往往具有明显的几何特征,例如微米级别的凹凸、金属漆中的片状物等,这些特征在强定向光源(如日光)下会引发显著的Glints效果。Yan等人通过高分辨率的法线贴图,更精确地模拟这些小尺度的表面几何特征,并提出了一种新的方法来有效渲染这些复杂的镜面高光。

    传统的均匀像素采样技术在捕捉这些小范围内的高光时方差过大,导致渲染效率低下,且无法有效处理由于光路复杂性引起的高光分布不均现象。因此Yan[2014, 2016]引入了基于法线分布的搜索和针对性采样。

    在毛发渲染中可以观察到毛发和毛皮在强定向光源照射下会显示出颜色变化的闪烁效果。

    XIA[2023]中使用光学散斑理论(Optical Speckle)来模拟高光噪声,加入了基础衍射理论的衍射瓣(diffraction lobe)处理光在毛发等纤维结构表面的衍射效应,进而渲染出彩色高光效果(colored glints)。

    XIA[2023]第八章提到,在阳光下可以很容易观察到Glints现象。这些颜色效应虽然在远距离观察时显得微妙,但在近距离观察时能够显著增强毛发的外观效果,有时还会导致纤维的色调发生轻微变化。

    在图9中,XIA[2023]的模型在浅色毛发上也能产生彩色闪光效果。相比于深色纤维,浅色毛发的闪光更加细微,因为多次散射会使颜色平均化,导致颜色对比度降低。与XIA[2020]相比,XIA[2023]不仅更好地处理了波长相关的反射,还提升了对毛鳞片角度的处理能力,能够捕捉到由毛鳞片倾斜引起的高光移动。

    References

    [1] L.-Q. Yan, M. Hašan, W. Jakob, J. Lawrence, S. Marschner, and R. Ramamoorthi, “Rendering glints on high-resolution normal-mapped specular surfaces,” ACM Trans. Graph., vol. 33, no. 4, pp. 1–9, Jul. 2014.

    [2] L.-Q. Yan, M. Hašan, S. Marschner, and R. Ramamoorthi, “Position-normal distributions for efficient rendering of specular microstructure,” ACM Trans. Graph., vol. 35, no. 4, pp. 1–9, Jul. 2016.

    全波参考模拟器

    https://dl.acm.org/doi/10.1145/3592414

    1. Intro

    这篇paper讨论的是渲染黑狗毛论文「A Practical Wave Optics Reflection Model for Hair and Fur」中用来生成高精度光散射模拟数据的物理波模拟三维波动光学纤维散射模拟器的理论基础。

    计算粗糙表面的光反射是一个重要课题。例如毛发纤维的微小特征等小尺度的几何结构,对光的反射行为有显著影响。BRDF描述的是给定入射、出射方向表面如何反射光线。几何光学的局限已经重复多次,这种将光看作直线传播的模型在微观结构与光波长相近时则无法捕捉到光的波动性。

    使用波动光学来近似衍射的理论模型有[Beckmann and Spizzichino 1987]的Beckmann-Kirchhoff theory, [Krywonos 2006]的Harvey-Shack模型。前者描述粗糙表面的光反射行为,后者则是一系列基于波动光学的模型,更加精确地描述光在复杂表面的散射行为。

    现有的模型都是针对大面积表面的平均反射行为,忽略局部的细节变化。Yan[2016, 2018]的则是能够在空间的不同区域捕捉微观结构对光反射的变化。即使是基于电磁波传播的模型,也由于计算复杂性,仍然需要进行一定的近似处理。这些方法其实并不是ground truth的。

    为了准确捕捉干涉效应,XIA[2023]目标开发一个参考模拟工具,忠实按照麦克斯韦方程组模拟光的传播。唯一的近似仅是数值离散化,最终生成传统的双向反射分布函数(BRDF)作为输出。而这个模拟器则是真正做到了ground truth

    也就是说这个模拟器能够准确模拟光的波动特性,包括干涉、衍射和多重散射等等。模拟器中运用到的近似也仅仅是网格划分和数值积分误差。

    通过高精度的全波模拟,能够生成具有高角度和空间分辨率的BRDF数据

    同时,模拟器能够处理大规模的表面区域(如60 × 60 × 10波长)。比方说使用波长约500纳米的可见光,60波长就相当于30微米。也就是说,模拟器的计算是基于光波长尺度进行的,在相同的真实物理尺寸下,不同波长的光会对应不同的离散化单元数量。波长越大(例如红光的波长比蓝光大),对于同样的物理尺寸,所需的离散化单元(如网格划分)就会相对更少,因此处理起来的计算量会相对更小,处理速度也可能更快

    具体地,将表面表示为一个高度场,每个网格点对应一个高度值,且用四边形作为基元。

    对于散射场,用边界积分公式(Boundary Integral Formulation)将电磁波的散射问题转化为仅在表面边界上求解的积分方程,关键实现方法是边界元方法(BEM)。再用基于三维快速傅里叶变换(3D FFT)的自适应积分方法(Adaptive Integral Method, AIM)加速边界积分的计算过程。

    并且利用GPU加速并行处理大规模的表面散射问题。

    并且paper中采用了一种组合小规模模拟结果来表征表面双向散射行为。

    相关工作

    基于波动光学的反射模型

    老生常谈的几何光学 vs 波动光学。本文主要对比表面散射模型。几何光学的经典模型包括:Cook-Torrance模型 [Cook and Torrance 1982]、Oren-Nayar模型[Michael 1994]。波动光学这边,主要是用物理光学近似(Physical Optics Approximations)来简化全波方程。也就是黑毛狗中的一阶近似(单次散射)来估计表面反射。经典模型包括Beckmann-Kirchoff理论Harvey-Shack模型,它们使用标量形式的近似方程来模拟波动光学效应。它们被广泛应用于各种表面类型的反射估计,比如高斯随机表面周期性表面等。但是这些方法的计算结果常常是空间上的平均结果,没办法进行高分辨率的细节反射。

    • He等人(1991)和Lanari等人(2017)的高斯随机表面模型。
    • Dhillon等人(2014)、Stam(1999)以及Toisoul和Ghosh(2017)的周期性表面模型。
    • Levin等人(2013)的多层平面表面模型。
    • Dong等人(2016)的表面数据表模型。
    • Werner等人(2017)对划痕表面的研究。

    此外,物理光学近似还被用于估计某些特殊表面的空间变化外观,例如:

    • Yan等人(2018)的表面数据表
    • Steinberg和Yan(2022)的随机表面模型。

    一些混合表面模型将物理光学模型应用于某些表面成分(例如小尺度粗糙度),而对较大尺度使用几何光学模型。这些混合模型的应用包括:

    • Falster等人(2020)和Holzschuch与Pacanowski(2017)的表面粗糙度模型。
    • Belcour和Barla(2017)的薄膜干涉模型。
    • Guillén等人(2020)的悬浮颗粒模型。

    此外,物理光学模型还被用于处理更长距离的表面间效应。例如:

    • Cuypers等人(2012)和Steinberg等人(2022)的研究探索了这些长程效应。

    基于波动光学的散射方法,如Lorenz-Mie理论T矩阵法,也被用于体积散射的计算,例如:

    • Bohren和Huffman(2008)及Mishchenko等人(2002)的理论。
    • Frisvad等人(2007)和Guo等人(2021)的体积散射应用。

    此外,Sadeghi等人(2012)和Shimada与Kawaguchi(2005)提出的复杂值光线追踪技术被应用于渲染自然现象和结构色效应。然而,表面间的长程效应体积散射等问题目前超出了本研究的范围。

    计算电磁学(CEM)的数值计算方法

    数值计算有很多方法:

    • Oskooi等人(2010)提出了基于差分求解麦克斯韦方程组的数值方法有限差分时域法(FDTD)。FDTD已经被用于预测波长尺度结构的外观(如Auzinger等人(2018),Musbach等人(2013)的研究)。但是随着模拟区域增加,开销相当大!
    • 有限元法(FEM)是一种广泛用于求解偏微分方程的数值方法,也可以用于电磁学问题中。它通过对模拟域的三维离散化来解决问题。与FDTD类似,计算量太大,更加不用说用于实时渲染。
    • Gibson(2021)详细介绍了边界元法(BEM)。BEM的主要优势在于,它通过将散射问题转化为物体表面的积分方程,降低了离散化的维度。在FDTD和FEM中,整个三维空间都需要离散化,而BEM只需要对物体的表面进行离散化,这显著降低了计算的维度和复杂性。

    论文选择了BEM。主要原因是其拓展性。有利于复杂表面结构的处理。

    加速BEM有很多方法:

    • Liu和Nishimura(2006),White和Head-Gordon(1994)提出的快速多极子法(FMM)
    • Bleszynski等人(1996)提出的基于三维快速傅里叶变换(3D FFT)的自适应积分法(AIM)
    • Liao等人(2016),Pak等人(1997)提出的稀疏矩阵规范网格法(SMCG)

    论文选择了AIM。原因是AIM适合处理一个轴向尺寸比较小的区域。

    结果

    BRDF值以半球图的形式显示。使用了标准的光谱数据到XYZ到RGB的转换,生成了彩色的BRDF图。

    即随着高度场分辨率的增加,BRDF输出逐渐趋于稳定。在高度场的每波长8个样本的分辨率已经足够产生准确的结果。

    通过与现有波动光学模型的比较,本文的模拟器精度最高,能够处理复杂的光学现象和几何结构,适合高精度需求的场景,适用于多次反射、干涉和复杂表面。然而,计算成本较高,是效率与精度间的折衷。

    • OHS和GHS模型计算简单,适合平滑表面和中等粗糙度表面,但在大入射角和复杂表面上误差较大。GHS相比OHS在大角度下精度有提升。
    • 基尔霍夫模型精度相对较高,但是只能在一阶的范围保持精度。
    • 切平面方法计算高效,适合较为简单的表面几何。复杂的就不行。
    • 本文的精度最牛,适合高精度场景。

    相干区域的比较,随着照明相干度的增加,BRDF的分辨率和细节变得更加丰富。相干度较高时,BRDF中包含更多的高分辨率细节。

    另外,论文还展示了论文中用于加速BRDF计算的光束引导技术(beam steering)。如图14,表面沿一个方向呈镜面反射,而在另一个方向上则表现出回射效应。论文计算了一系列逐渐变化的入射角的BRDF值。如下图所示。

    每个入射方向上的BRDF图像缩减为一条细线段。图15中的对比显示,Tangent Plane无法准确建模表面的二阶反射(即经过多次反射后出射的光线)。但是如果表面光滑,用Tangent Plane还是非常快速准确的。

    此外,论文将模拟器的结果与实际表面的BRDF测量进行了对比,特别是在多重反射效应显著的表面上。如图17所示,上面是实际测量,中间是论文的理论模型,下面是Tangent Plane。

    你可能会问为什么差这么多?论文中使用的是理想化的几何模型,而实验中的表面可能具有一些微小的几何偏差,这可能会影响反射的精确性。一句话概括,就是论文的模拟器可以描述更加高阶的反射了!

    未来工作

    当处理更复杂、结构化表面时,本文的模拟器能更加精确地模拟光在表面上的传播和散射行为。

    未来工作方向当然就是降低计算开销,同时保证精度。

    然后将这个BRDF的近似模型的处理面积扩大。

    同时,该论文的模拟器可以作为一个基准参考。

    References

    A Full-Wave Reference Simulator for Computing Surface Reflectance

    Petr Beckmann and Andre Spizzichino. 1987. The scattering of electromagnetic waves from rough surfaces. Artech House.

    Andrey Krywonos. 2006. Predicting Surface Scatter using a Linear Systems Formulation of Non-Paraxial Scalar Diffraction. Ph. D. Dissertation. University of Central Florida.

    Ling-Qi Yan, Miloš Hašan, Bruce Walter, Steve Marschner, and Ravi Ramamoorthi. 2018. Rendering Specular Microgeometry with Wave Optics. ACM Trans. Graph. 37, 4 (2018).

    Ling-Qi Yan, Miloš Hašan, Steve Marschner, and Ravi Ramamoorthi. 2016. Positionnormal distributions for efficient rendering of specular microstructure. ACM Transactions on Graphics (TOG) 35, 4 (2016), 1–9.

    R. L. Cook and K. E. Torrance. 1982. A Reflectance Model for Computer Graphics. ACM Trans. Graph. 1, 1 (jan 1982). https://doi.org/10.1145/357290.357293

    Michael Oren and Shree K. Nayar. 1994. Generalization of Lambert’s Reflectance Model (SIGGRAPH ’94). https://doi.org/10.1145/192161.192213

  • Unity 曲面細分詳解

    Unity 曲面細分詳解

    标签 :入门/Shader/曲面细分着色器/Displacement贴图/LOD/平滑轮廓/Early Culling

    tessellation(镶嵌)一词是指一大类设计活动,通常是指在平坦的表面上,用各种几何形状的瓷砖相邻排列以形成图案。它的目的可以是艺术性的或实用性的,很多例子可以追溯到几千年前。 — Tessellation, Wikipedia, accessed July 2020.


    本文主要參考:

    https://nedmakesgames.medium.com/mastering-tessellation-shaders-and-their-many-uses-in-unity-9caeb760150e

    游戏开发中的曲面细分一般是在一个三角形平面(或者是Quad)中做细分(增加顶点数量),然后用Displacement贴图来做顶点位移,或者是用本文实现的Phong细分或者PN triangles细分来做顶点位移。

    Phong细分不需要知道相邻的拓扑信息,仅仅用插值计算,比PN triangles等算法效率更高。GAMES101上提到的Loop and Schaefer利用低度数四边形曲面近似Catmull-Clark曲面,这些方法输入的多边形都被一个多项式曲面替代。而本文的Phong细分不需要任何修正额外的几何区域的操作。

    一、曲面细分流程概述

    这章内容是曲面细分在渲染管线流程的介绍。

    曲面细分着色器位于顶点着色器之后,且曲面细分分为三个步骤:Hull、Tesselllator和Domain,其中Tessellator不可编程。

    曲面细分的第一个步骤是曲面细分控制着色器(也称为Tessellation Control Shader,TCS),这个着色器将会输出控制点和细分因子。这个阶段主要由两个并行的函数组成:Hull Function和Patch Constant Function。

    这两个函数都接收一个个的Patch,即一组顶点索引,比如三角形则用三个数字表示顶点的索引。其中一个Patch就可以组成一个片元,比方说一个三角形片元就是由三个顶点索引组成的。

    并且,Hull Function每个顶点执行一次,Path Constant Function每个Patch执行一次,前者输出修改后的控制点数据(通常包括顶点位置、可能的法线、纹理坐标等属性),后者则输出整个片元相关的常量数据,即细分因子。细分因子会告诉下一个阶段(镶嵌器Tessellator)如何对每个片元进行细分。

    笼统地讲,Hull Function修改每个控制点,而Patch Constant Function确定基于摄像机距离的细分级别。

    接下来进入不可编程阶段,镶嵌器(tessellator)。他接收Patch和刚刚得到的细分因子。镶嵌器会为每一个顶点数据生成一个重心坐标(Barycentric coordinates)。

    紧接着来到最后一步,域阶段(Domain Stage,也称为Tessellation Evaluation Shader,TES),这是可编程的。这个部分由域函数组成,每个顶点执行一次。接收重心坐标、Patch和Hull Stage中两个函数生成的结果。大多数逻辑都在这个地方编写。最重要的是你可以在这个阶段重新定位顶点,这是曲面细分中最重要的环节。

    如果有几何着色器,他将会在Domain Stage后执行。但是如果不用,则来到光栅化阶段。

    总结,最开始是顶点着色器。Hull阶段接受顶点数据,决定如何细分Mesh。然后通过tessellator阶段处理细分网格,最后由Domain阶段为片元着色器输出顶点。

    二、曲面细分分析

    这章内容是Unity曲面细分的代码分析,实际例子效果展示和底层原理概述。

    2.1 关键代码分析

    2.1.1 Unity曲面细分基本设置

    首先曲面细分着色器需要使用shader target 5.0。

    HLSLPROGRAM
    #pragma target 5.0 // 5.0 required for tessellation
    
    #pragma vertex Vertex
    #pragma hull Hull
    #pragma domain Domain
    #pragma fragment Fragment
    
    ENDHLSL

    2.1.2 Hull Stage代码1 – Hull Function

    经典的流程,顶点着色器将位置和法线信息转为世界空间。然后将输出结果传递到Hull Stage中。需要注意的是,和顶点着色器不同,Hull着色器的顶点使用 INTERNALTESSPOS 语义而不是 POSITION 语义来表示。原因在于Hull不需要将这些顶点位置输出到下一个渲染流程,而是用于自身内部曲面细分的算法,所以会将这些顶点转换到更适合曲面细分的坐标系统。除此之外开发者也能更加清晰区分。

    struct Attributes {
        float3 positionOS : POSITION;
        float3 normalOS : NORMAL;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };
    
    struct TessellationControlPoint {
        float3 positionWS : INTERNALTESSPOS;
        float3 normalWS : NORMAL;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };
    
    TessellationControlPoint Vertex(Attributes input) {
        TessellationControlPoint output;
    
        UNITY_SETUP_INSTANCE_ID(input);
        UNITY_TRANSFER_INSTANCE_ID(input, output);
    
        VertexPositionInputs posnInputs = GetVertexPositionInputs(input.positionOS);
        VertexNormalInputs normalInputs = GetVertexNormalInputs(input.normalOS);
    
        output.positionWS = posnInputs.positionWS;
        output.normalWS = normalInputs.normalWS;
        return output;
    }

    下面是Hull Shader的一些设置参数。

    第一行domain是定义曲面细分着色器的域类型,意味着输入输出都是三角形图元。可以选tri(三角形)、quad(四边形)等。

    第二行outputcontrolpoints 则表示输出控制点的数量,3对应三角形的三个顶点。

    第三行outputtopology表示细分后图元的拓扑结构,triangle_cw意思是输出三角形的顶点按照顺时针排序,正确的顺序可以确保表面正面朝外。triangle_cw(顺时针环绕三角形)、triangle_ccw(逆时针环绕三角形)、line(线段)

    第四行patchconstantfunc就是Hull Stage的另外一个函数,输出的是细分因子等常量数据。一个Patch只执行一次。

    第五行partitioning,分割模式,指定了如何分配额外的顶点到原始Path图元的边上,这一步可以让细分过程更加的平滑均匀。integer,fractional_even,fractional_odd。

    第六行的maxtessfactor表示最大细分因子,限制最大的细分可以控制渲染负担。

    [domain("tri")]
    [outputcontrolpoints(3)]
    [outputtopology("triangle_cw")]
    [patchconstantfunc("patchconstant")]
    [partitioning("fractional_even")]
    [maxtessfactor(64.0)]

    在Hull Shader中,每一个控制点都会被独立调用一次,所以这个函数要执行控制点数量的次数。要知道当前正在处理的是哪一个顶点,我们用语义为 SV_OutputControlPointID 的变量 id 来判断。函数还传入一个特殊的结构,该结构可以像使用数组一样方便的取用Patch里面的任意一个控制点。

    TessellationControlPoint Hull(
        InputPatch<TessellationControlPoint, 3> patch, uint id : SV_OutputControlPointID) {
        TessellationControlPoint h;
        // Hull shader code here
    
        return patch[id];
    }

    2.1.3 Hull Stage代码2 – Patch Constant Function

    除了Hull Shader,Hull Stage里还有一个函数与之并行,patch constant function。这个函数的签名比较简单,输入一个patch,输出计算后的细分因子。输出结构包含了为三角形每条边指定的鑲嵌因子。这些因子通过特殊的系统值语义 SV_TessFactor 进行标识。每个鑲嵌因子定义了相对应边应该被细分成多少小段,从而影响最终生成的网格的密度和细节。下面具体来看看这个因子具体包含了什么。

    struct TessellationFactors {
        float edge[3] : SV_TessFactor;
        float inside : SV_InsideTessFactor;
    };
    // The patch constant function runs once per triangle, or "patch"
    // It runs in parallel to the hull function
    TessellationFactors PatchConstantFunction(
        InputPatch<TessellationControlPoint, 3> patch) {
        UNITY_SETUP_INSTANCE_ID(patch[0]); // Set up instancing
        // Calculate tessellation factors
        TessellationFactors f;
        f.edge[0] = _FactorEdge1.x;
        f.edge[1] = _FactorEdge1.y;
        f.edge[2] = _FactorEdge1.z;
        f.inside = _FactorInside;
        return f;
    }

    首先TessellationFactors结构体里面有一个边缘镶嵌因子 edge[3] ,标记为 SV_TessFactor 。当使用三角形作为基本图元细分时,每条边被定义为位于与具有相同索引的顶点相对的位置。具体说是:边0对应顶点1和顶点2之间。边1对应顶点2和顶点0之间。边2对应顶点0和顶点1之间。为什么这样?直观解释是,边的索引与它不连接的那个顶点的索引相同。这有助于在编写Shader代码时快速识别和处理与特定顶点相对应的边。

    还有一个中心镶嵌因子 inside 标记为 SV_InsideTessFactor 。这个因子直观改变最终镶嵌的图案,更本质的说是决定了边缘细分的次数,用于控制三角形内部的细分密度。与边的细分因子相比,中心镶嵌因子控制的是三角形内部如何被进一步细分成更小的三角形,而边缘镶嵌因子影响边缘细分的次数。

    Patch Constant Function还可以输出其他有用的数据,但是必须标注正确的语义。比方说BEZIERPOS语义就非常有用,可以表示float3的数据。稍后将会使用这个语义输出基于贝塞尔曲线的平滑算法控制点。

    2.1.4 Domain Stage代码

    接下来就进入Domain Stage。Domain Function也有一个Domain属性,应该与Hull Function的输出拓扑类型相同,该例子设置为三角形。这个函数输入来自Hull Function的Patch、Patch Constant Function的输出以及最重要的顶点重心坐标。输出结构非常接近顶点着色器的输出结构,包含Clip空间的位置,以及片元着色器所需要的照明数据。

    暂时不知道干嘛的没关系,读到本文第四章再跳回来研究。

    简单的说就是,细分出来的每一个新顶点都会跑一边这个domain函数。

    struct Interpolators {
        float3 normalWS                 : TEXCOORD0;
        float3 positionWS               : TEXCOORD1;
        float4 positionCS               : SV_POSITION;
    };
    
    // Call this macro to interpolate between a triangle patch, passing the field name
    #define BARYCENTRIC_INTERPOLATE(fieldName) \
            patch[0].fieldName * barycentricCoordinates.x + \
            patch[1].fieldName * barycentricCoordinates.y + \
            patch[2].fieldName * barycentricCoordinates.z
    
    // The domain function runs once per vertex in the final, tessellated mesh
    // Use it to reposition vertices and prepare for the fragment stage
    [domain("tri")] // Signal we're inputting triangles
    Interpolators Domain(
        TessellationFactors factors, // The output of the patch constant function
        OutputPatch<TessellationControlPoint, 3> patch, // The Input triangle
        float3 barycentricCoordinates : SV_DomainLocation) { // The barycentric coordinates of the vertex on the triangle
    
        Interpolators output;
    
        // Setup instancing and stereo support (for VR)
        UNITY_SETUP_INSTANCE_ID(patch[0]);
        UNITY_TRANSFER_INSTANCE_ID(patch[0], output);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
    
        float3 positionWS = BARYCENTRIC_INTERPOLATE(positionWS);
        float3 normalWS = BARYCENTRIC_INTERPOLATE(normalWS);
    
        output.positionCS = TransformWorldToHClip(positionWS);
        output.normalWS = normalWS;
        output.positionWS = positionWS;
    
        return output;
    }

    这个函数,Unity会给我们细分因子、Patch的三个顶点还有当前的新顶点的重心坐标。我们可使用这些数据做位移处理等。

    2.2 细分因子与划分模式详解

    从这个链接 拷贝代码,然后制作对应的材质,并且开启线框模式。我们目前只为Mesh绘制了顶点,并没有在片元着色器应用任何操作,因此看上去是透明的。

    如果将Edge因子任意一个分量设置为0或者小于0,那么Mesh就会完全消失。下图就是消失后的样子(打开了Unity编辑器的物体边框描边),这个特性十分重要。

    2.2.1 细分因子概述

    说白了,这些个因子在Hull Stage设置了之后,就只是简单粗暴的在Tessellation Stage中写进重心坐标里,比如说边缘因子、内部因子。(假设都是tri,如果是quad则是用uv来计算,可能会更加复杂,我不知道)这个简单粗暴的阶段并不可编程。

    以“整数(均匀)切割模式”为例子。(暂时) [partitioning(“integer”)] domain都是三角形 [domain(“tri”)] 输出的顶点数量也是3。 [outputcontrolpoints(3)] 并且输出的拓扑结构是三角形顺时针。 [outputtopology(“triangle_cw”)]

    2.2.2 准备工作与潜在的并行问题

    将代码修改改为如下:

    // .shader
    _FactorEdge1("[Float3]Edge factors,[Float]Inside factor", Vector) = (1, 1, 1, 1) // --  Edited  -- 
    
    // .hlsl
    float4 _FactorEdge1; // --  Edited  -- 
    ...
    f.edge[0] = _FactorEdge1.x;
    f.edge[1] = _FactorEdge1.y; // --  Edited  -- 
    f.edge[2] = _FactorEdge1.z; // --  Edited  -- 
    f.inside = _FactorEdge1.w; // --  Edited  --

    这里可能会存在一个问题。有时候编译器会拆分Patch Constant Function并行计算每一个因子,这就导致有时候一些因子被删除了,可能会到看因子会莫名其妙等于0。解决方法是将这些因子打包成一个向量,这样编译器就不会使用未定义的量。下面简单复现一下可能会发生的情况。

    修改Path Constant Function如下,并且在面板中开放两个新的属性。

    修改的代码行后注释了 // — Edited — 。

    // The patch constant function runs once per triangle, or "patch"
    // It runs in parallel to the hull function
    TessellationFactors PatchConstantFunction(
    InputPatch<TessellationControlPoint, 3> patch) {
    UNITY_SETUP_INSTANCE_ID(patch[0]); // Set up instancing
    // Calculate tessellation factors
        TessellationFactors f;
        f.edge[0] = _FactorEdge1.x;
        f.edge[1] = _FactorEdge2; // --  Edited  --
        f.edge[2] = _FactorEdge3; // --  Edited  --
        f.inside = _FactorInside;
    return f;
    }
    _FactorEdge2("Edge 2 factor", Float) = 1 // --  Edited  --
    _FactorEdge3("Edge 3 factor", Float) = 1 // --  Edited  --

    2.2.3 边缘因子效果 Edge Factor – SV_TessFactor

    可以看到边缘因子Edge Factors大约对应于对应边缘被分割的次数,内部因子Inside Factor对应中心的复杂度。

    边缘因子只会影响在原本三角形边上的细分。至于内部复杂的图案,就交给内部因子Inside Factor和划分模式来控制。

    需要注意的是,“整数切割模式”的曲面细分都是向上取整。比如2.1取3。

    一张图说明一切。

    2.2.4 内部因子 Inside Factor – SV_InsideTessFactor

    还是INTEGER模式举例子。内部因子只会影响内部图案的复杂程度,具体怎么影响,下面详细介绍。概括一下就是,边缘因子会影响最外层与第一层之间的三角形细分,内部因子会影响到底有多少层,而划分模式则是会影响内部每层是怎么细分的。

    假设Edge Factors设置为 (2,3,4) ,只修改Insider Factor,可以观察到一个有趣的性质:当内部因子 n 是偶数时,可以找到一个顶点的坐标恰好位于重心的位置 (13,13,13) 。

    一般边缘因子Edge Factors设置为一样的数就好了。这里设置成不同的数,图可能会比较混乱,但是可以看到最本质的规律。

    进一步还能观察到,任意一条最靠近最外层三角形的边的顶点数量和内部因子Inside Factor ( n )有一个等量关系: n=Numpoint−1 。即,这条边上的顶点数永远等于细分因子减 1 。

    每一层的顶点数量都会减少1。也就是说,第一层(最外围的不算,因为不会细分)会有 n 个顶点,向内第二层会有 n−2 个顶点,以此类推。

    综合上面三个观察,我们可以得到一个猜测和结论(没啥用,但是闲着没事算了一下)。内部总顶点数量可以用公式计算,这里的n对应内部因子的n-1,需要注意一下,因为内部因子是从2开始取的: a2n=3n2a2n−1=3n(n−1)+1 最终可以化简合并为: ak=−0.125(−1)k+0.75k2+0.125 全部为整数int运算的公式如下: ak=⌊−(−1)k+6k2+18⌋

    2.2.5 划分模式 – [partitioning(“_”)]

    上面只说了最简单的均匀划分integer,这种情况会使用整数倍数进行细分。接下来说说其他几种。简单的说,Fractional Odd 和 Fractional Even是Integer的进阶版,但是前者是Integer取奇数情况下的进阶版,后者是Integer取偶数情况下的进阶版。具体进阶在可以用小数部分使得划分不再是平均的。

    Fractional Odd (分数奇数):Inside Factor可以是分数(不会被Ceil),且分母为奇数。注意这里说的分母其实是每一个顶点的重心坐标所表示的分母。奇数作为分母的的划分方式一定会让一个顶点落在三角形的重心上,偶数的就不是。这里搬运一下凯奥斯的图。

    动图

    Fractional Even (分数偶数):与fractional_odd类似,但分母为偶数。具体怎么选我也不清楚。

    动图

    Pow2 (2的幂次方):此模式仅允许使用2的幂次方(如1, 2, 4, 8等)作为细分级别。一般用在纹理映射或阴影计算。

    三、细分优化

    3.1 视锥体剔除

    生成如此多的顶点会导致性能相当糟糕!因此需要采用一些方法提高渲染效率。虽然在T光栅化之前,会将在视锥体之外的顶点进行剔除,但是如果在TCS中提前把没必要进行细分的Patch剔除了,这样就会减少曲面细分着色器的计算压力。

    在Patch Constant Function种将曲面细分因子设置为0,那么曲面细分器就会忽略这个Patch。也就是说这里的剔除是对一整个Patch剔除,而不是视锥体剔除中精细到顶点的剔除。

    我们测试Patch中的每一个点,看看他们是否都在视野之外。为此,将Patch的每一个点转换到裁剪空间中。因此我们需要在顶点着色器中计算出每一个点的裁切空间坐标并且将其传给Hull Stage。使用 GetVertexPositionInputs 就可以得到我们想要的了。

    struct TessellationControlPoint {
        float4 positionCS : SV_POSITION; // --  Edited  -- 
        ...
    };
    
    TessellationControlPoint Vertex(Attributes input) {
        TessellationControlPoint output;
        ...
        VertexPositionInputs posnInputs = GetVertexPositionInputs(input.positionOS);
        ...
        output.positionCS = posnInputs.positionCS; // --  Edited  -- 
        ...
        return output;
    }

    然后在Patch Constant Function上方写一个测试函数,用于判断是否剔除该补丁。这里暂时传false。该函数传进来三个裁切空间的点。

    // Returns true if it should be clipped due to frustum or winding culling
    bool ShouldClipPatch(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        return false;
    }

    然后再编写 IsOutOfBounds 函数测试某个点是否超过边界。边界也是可以指定,在另一个函数中将这个方法利用起来,判断某个点是否在视锥体之外。

    // Returns true if the point is outside the bounds set by lower and higher
    bool IsOutOfBounds(float3 p, float3 lower, float3 higher) {
        return p.x < lower.x || p.x > higher.x || p.y < lower.y || p.y > higher.y || p.z < lower.z || p.z > higher.z;
    }
    
    // Returns true if the given vertex is outside the camera fustum and should be culled
    bool IsPointOutOfFrustum(float4 positionCS) {
        float3 culling = positionCS.xyz;
        float w = positionCS.w;
        // UNITY_RAW_FAR_CLIP_VALUE is either 0 or 1, depending on graphics API
        // Most use 0, however OpenGL uses 1
        float3 lowerBounds = float3(-w, -w, -w * UNITY_RAW_FAR_CLIP_VALUE);
        float3 higherBounds = float3(w, w, w);
        return IsOutOfBounds(culling, lowerBounds, higherBounds);
    }

    在裁切空间(Clip Space)中,W分量是其次坐标,可以决定点是否在视锥体中。如果xyz超出了 [-w, w] 的范围,这些点就会被剔除,因为他们在视锥体之外。不同的API在深度的处理上有不同的逻辑,我们用这个分量作为边界的时候需要注意。DirectX和Vulkan使用左手系,Clip深度是 [0, 1] ,所以UNITY_RAW_FAR_CLIP_VALUE是0。OpenGL是右手系,Clip深度范围 [-1, 1] ,UNITY_RAW_FAR_CLIP_VALUE是1。

    准备好这些后,就可以判断一个Patch是否需要剔除了。回到刚开始的函数,在这个函数中判断一个Patch的所有点是否需要剔除。

    // Returns true if it should be clipped due to frustum or winding culling
    bool ShouldClipPatch(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        bool allOutside = IsPointOutOfFrustum(p0PositionCS) &&
            IsPointOutOfFrustum(p1PositionCS) &&
            IsPointOutOfFrustum(p2PositionCS); // --  Edited  -- 
        return allOutside; // --  Edited  -- 
    }

    3.2 背面剔除

    Patch除了经历视锥体剔除,还可以做一个背面剔除。用法向量来判断Patch是否需要剔除。

    img

    用两个向量做叉积就得到法向量。由于当前在Clip空间,需要做一个透视除法,得到NDC,这个范围应该是 [-1,1] 的。需要转换到NDC的原因是,在Clip空间中的位置是非线性的,这有可能导致顶点的位置的扭曲,转换到NDC这样的线性空间能更加准确的判断顶点的前后关系。

    // Returns true if the points in this triangle are wound counter-clockwise
    bool ShouldBackFaceCull(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        float3 point0 = p0PositionCS.xyz / p0PositionCS.w;
        float3 point1 = p1PositionCS.xyz / p1PositionCS.w;
        float3 point2 = p2PositionCS.xyz / p2PositionCS.w;
        float3 normal = cross(point1 - point0, point2 - point0);
        return dot(normal, float3(0, 0, 1)) < 0;
    }

    上面的代码还存在一个跨平台问题。观察方向在不同API的朝向是不同的,因此修改一下代码。

    // In clip space, the view direction is float3(0, 0, 1), so we can just test the z coord
    #if UNITY_REVERSED_Z
        return cross(point1 - point0, point2 - point0).z < 0;
    #else // In OpenGL, the test is reversed
        return cross(point1 - point0, point2 - point0).z > 0;
    #endif

    最后的最后,在 ShouldClipPatch 中添加刚写好的函数用于判断背面剔除。

    // Returns true if it should be clipped due to frustum or winding culling
    bool ShouldClipPatch(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        bool allOutside = IsPointOutOfFrustum(p0PositionCS) &&
            IsPointOutOfFrustum(p1PositionCS) &&
            IsPointOutOfFrustum(p2PositionCS);
        return allOutside || ShouldBackFaceCull(p0PositionCS, p1PositionCS, p2PositionCS); // --  Edited  -- 
    }

    然后在 PatchConstantFunction 中将需要剔除的Patch的顶点因子设置为0 。

    ...
    if (ShouldClipPatch(patch[0].positionCS, patch[1].positionCS, patch[2].positionCS)) {
            f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0; // Cull the patch
    }
    ...

    3.3 增加容差

    你可能想验证代码正确性,也可能会有一些意外剔除的情况。此时增加一个容差tolerance是一个灵活的办法。

    首先是视锥体剔除容差。如果容差是正值,那么剔除边界会扩展,这样一些位于视锥体边缘附近的物体即使部分越界也不会被剔除。这种方法可以减少因为小的视角变动或物体动态而频繁变化的剔除状态。

    // Returns true if the given vertex is outside the camera fustum and should be culled
    bool IsPointOutOfFrustum(float4 positionCS, float tolerance) {
        float3 culling = positionCS.xyz;
        float w = positionCS.w;
        // UNITY_RAW_FAR_CLIP_VALUE is either 0 or 1, depending on graphics API
        // Most use 0, however OpenGL uses 1
        float3 lowerBounds = float3(-w - tolerance, -w - tolerance, -w * UNITY_RAW_FAR_CLIP_VALUE - tolerance);
        float3 higherBounds = float3(w + tolerance, w + tolerance, w + tolerance);
        return IsOutOfBounds(culling, lowerBounds, higherBounds);
    }

    接着调整背面剔除。在实际操作中,通过与容差而不是零进行比较,可以避免由于数值计算精度带来的问题。如果点积结果小于某个小的正值(容差),而不是严格小于零,那么图元被视为背面。这种方法提供了额外的缓冲区,确保只有明确的背面图元被剔除。

    // Returns true if the points in this triangle are wound counter-clockwise
    bool ShouldBackFaceCull(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS, float tolerance) {
        float3 point0 = p0PositionCS.xyz / p0PositionCS.w;
        float3 point1 = p1PositionCS.xyz / p1PositionCS.w;
        float3 point2 = p2PositionCS.xyz / p2PositionCS.w;
        // In clip space, the view direction is float3(0, 0, 1), so we can just test the z coord
    #if UNITY_REVERSED_Z
        return cross(point1 - point0, point2 - point0).z < -tolerance;
    #else // In OpenGL, the test is reversed
        return cross(point1 - point0, point2 - point0).z > tolerance;
    #endif
    }

    可以在材质面板中暴露一个Range。

    // .shader
    Properties{
        _tolerance("_tolerance",Range(-0.002,0.001)) = 0
        ...
    }
    // .hlsl
    float _tolerance;
    ...
    // Returns true if it should be clipped due to frustum or winding culling
    bool ShouldClipPatch(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        bool allOutside = IsPointOutOfFrustum(p0PositionCS, _tolerance) &&
            IsPointOutOfFrustum(p1PositionCS, _tolerance) &&
            IsPointOutOfFrustum(p2PositionCS, _tolerance); // --  Edited  -- 
        return allOutside || ShouldBackFaceCull(p0PositionCS, p1PositionCS, p2PositionCS,_tolerance); // --  Edited  -- 
    }

    3.4 动态细分因子

    目前为止,我们的算法是无差别地细分所有的表面。但在一个复杂的Mesh中,可能会出现大小面的情况,即Mesh面积不均的情况。大面由于面积大,在视觉上更为明显,需要更多的细分来保证表面的平滑度和细节。小面由于面积小,可以考虑减少这个部分的细分程度,不会对视觉效果带来太大的影响。根据变长来动态改变因子是比较常见的方法。设置一个算法,让边长较长的面拥有更高的细分因子。

    除了Mesh自身的大小面以外,摄像机与Patch的距离也可以作为动态改变因子的因素。距离摄像机较远的对象可以降低细分因子,因为在屏幕上占据的像素数较少。还可以根据用户的视角和视线方向,可以优先细分那些面向摄像机的面,而对背对摄像机或侧面的部分降低细分程度。

    3.4.1 固定的细分缩放

    获取两个顶点的距离。距离越大,细分的因子就越大。scale暴露在控制面板将其设置为 [0,1] ,scale是1时,细分因子直接由两点距离贡献。scale越接近0,细分因子越大。另外加上一个初值bias。最后让因此取1或以上的数,确保准确性。

    // Calculate the tessellation factor for an edge
    float EdgeTessellationFactor(float scale, float bias, float3 p0PositionWS, float3 p1PositionWS) {
        float factor = distance(p0PositionWS, p1PositionWS) / scale;
    
        return max(1, factor + bias);
    }

    然后修改材质面板和Patch Constant Function。一般来说,采用边缘细分因子的平均值作为内部细分因子,视觉效果比较连贯。

    // .shader
    Properties{
        ...
        _TessellationBias("_TessellationBias", Range(-1,5)) = 1
         _TessellationFactor("_TessellationFactor", Range(0,1)) = 0
    }
    
    // .hlsl
    
    f.edge[0] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[1].positionWS, patch[2].positionWS);
    f.edge[1] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[2].positionWS, patch[0].positionWS);
    f.edge[2] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[0].positionWS, patch[1].positionWS);
    f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) / 3.0;

    不同尺寸的片元其细分程度会动态变化,效果如下。

    对了,如果发现你的内部因子图案非常奇怪,这可能是编译器导致的,尝试将内部因子代码修改为以下就可以解决。

    f.inside = ( // If the compiler doesn't play nice...
      EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[1].positionWS, patch[2].positionWS) + 
      EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[2].positionWS, patch[0].positionWS) + 
      EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[0].positionWS, patch[1].positionWS)
      ) / 3.0;

    3.4.2 屏幕空间细分缩放

    接下来加入摄像机距离的判断。我们可以直接用屏幕空间的距离来调整细分程度,这样完美地同时处理了大小面+屏幕距离的问题!

    由于我们已经有了Clip空间的数据。由于屏幕空间与NDC空间非常相似,只需要换到NDC就可以了,即做一个透视除法。

    float EdgeTessellationFactor(float scale, float bias, float3 p0PositionWS, float4 p0PositionCS, float3 p1PositionWS, float4 p1PositionCS) {
        float factor = distance(p0PositionCS.xyz / p0PositionCS.w, p1PositionCS.xyz / p1PositionCS.w) / scale;
    
        return max(1, factor + bias);
    }

    接下来在Patch Constant Function中传入Clip空间的坐标。

    f.edge[0] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, 
      patch[1].positionWS, patch[1].positionCS, patch[2].positionWS, patch[2].positionCS);
    f.edge[1] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, 
      patch[2].positionWS, patch[2].positionCS, patch[0].positionWS, patch[0].positionCS);
    f.edge[2] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, 
      patch[0].positionWS, patch[0].positionCS, patch[1].positionWS, patch[1].positionCS);
    f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) / 3.0;

    当前的效果相当的不错,随着摄像机的距离(屏幕空间的距离)的变化,细分程度也会动态变化。如果使用INTEGER意外的划分模式,会得到更连贯的效果。

    还有一些地方可以改进。比如缩放系数的单位。方才我们将其控制在 [0,1] ,其实并不是很适合我们去调整。我们乘上一个屏幕分辨率,然后将缩放系数范围改为 [0,1080] ,更方便我们调整。然后修改一下材质面板属性。现在就是以像素为单位的比例了。

    // .hlsl
    float factor = distance(p0PositionCS.xyz / p0PositionCS.w, p1PositionCS.xyz / p1PositionCS.w) * _ScreenParams.y / scale;
    
    // .shader
    _TessellationFactor("_TessellationFactor",Range(0,1080)) = 320

    3.4.3 相机距离细分缩放

    我们怎么采用相机距离缩放呢?非常简单,计算 「两点间的距离」与「两顶点的中点与相机位置的距离」的比值。比值越大说明占据屏幕的空间就越大,需要更多的细分程度。

    // .hlsl
    float EdgeTessellationFactor(float scale, float bias, float3 p0PositionWS, float3 p1PositionWS) {
        float length = distance(p0PositionWS, p1PositionWS);
        float distanceToCamera = distance(GetCameraPositionWS(), (p0PositionWS + p1PositionWS) * 0.5);
        float factor = length / (scale * distanceToCamera * distanceToCamera);
        return max(1, factor + bias);
    }
    ...
            f.edge[0] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[1].positionWS, patch[2].positionWS);
            f.edge[1] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[2].positionWS, patch[0].positionWS);
            f.edge[2] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, patch[0].positionWS, patch[1].positionWS);
    
    // .shader
    _TessellationFactor("_TessellationFactor",Range(0, 1)) = 0.02

    注意,此时的缩放因子单位不再是像素,而是用最开始的 [0,1] 。因为这个方法,屏幕像素意义不是特别大,所以就不用了。并且用回了世界坐标。

    屏幕空间细分缩放和相机距离细分缩放的结果比较相似,一般可以开放一个宏来切换上面几种动态因子的模式。这里就留给读者自行完成。

    3.5 指定细分因子

    3.5.1 顶点存储细分因子

    上一节中,我们使用不同的策略猜测适当的细分因子。如果我们确切知道该Mesh应该怎么细分,那么可以在Mesh中存储这些细分因子的系数。由于系数只需要一个float,因此只需要用到一个颜色通道就可以了。下面是一个伪代码,感受一下就行。

    float EdgeTessellationFactor(float scale, float bias, float multiplier) {
        ...
        return max(1, (factor + bias) * multiplier);
    }
    
    ...
    // PCF()
    [unroll] for (int i = 0; i < 3; i++) {
        multipliers[i] = patch[i].color.g;
    }
    // Calculate tessellation factors
    f.edge[0] = EdgeTessellationFactor(_TessellationFactor, _TessellationBias, (multipliers[1] + multipliers[2]) / 2);

    3.5.2 SDF控制曲面细分因子

    结合有符号距离场(Signed Distance Field, SDF)来控制曲面细分(Tessellation)因子,相当的酷炫。当然本节不涉及SDF的生成,假设能够直接通过现成的函数 CalculateSDFDistance 获取。

    对于给定的Mesh,用 CalculateSDFDistance 计算出每个Patch中各个顶点到SDF表示的形状(例如球体)的距离。得到距离后再评估该Patch的细分需求,进行细分。

    TessellationFactors PatchConstantFunction(
        InputPatch<TessellationControlPoint, 3> patch) {
        float multipliers[3];
    
        // 循环处理每个顶点
        [unroll] for (int i = 0; i < 3; i++) {
            // 计算每个顶点到SDF表面的距离
            float sdfDistance = CalculateSDFDistance(patch[i].positionWS);
    
            // 根据SDF距离调整细分因子
            if (sdfDistance < _TessellationDistanceThreshold) {
                multipliers[i] = lerp(_MinTessellationFactor, _MaxTessellationFactor, (1 - sdfDistance / _TessellationDistanceThreshold));
            } else {
                multipliers[i] = _MinTessellationFactor;
            }
        }
    
        // 计算最终的细分因子
        TessellationFactors f;
        f.Edge[0] = max(multipliers[0], multipliers[1]);
        f.Edge[1] = max(multipliers[1], multipliers[2]);
        f.Edge[2] = max(multipliers[2], multipliers[0]);
        f.Inside = (multipliers[0] + multipliers[1] + multipliers[2]) / 3;
    
        return f;
    }

    具体实现我也不会,先庄懂一下。

    四、顶点偏移 – 轮廓平滑

    为一个Mesh添加细节最简单的方法是上各种高分辨率贴图。但是底大一级压死人,说的就是增加Mesh顶点的效果比增加贴图分辨率的效果要好。举个例子,法线贴图虽然可以改变每一个片元的法线方向,但是并不会改变几何外观。就算是128K的纹理也无法消除锯齿和pointy的边缘。

    因此需要上曲面细分,然后偏移顶点。刚刚提到的所有曲面细分操作都是在Patch所在的平面上操作的。如果我们想要弯曲这些顶点,一个最简单的操作就是Phong细分。

    4.1 Phong细分

    首先附上原论文。https://perso.telecom-paristech.fr/boubek/papers/PhongTessellation/PhongTessellation.pdf

    Phong着色应该很熟悉,是一种利用法向量线性差值得到平滑的着色的技术。Phong细分的灵感来自Phong着色,将Phong着色这一概念扩展到空间域。

    Phong细分的核心思想是利用三角形每个角的顶点法线来影响细分过程中新顶点的位置,从而创造出曲面而非平面。

    值得注意一下,这里很多教程会用triangle corner(三角形的角)来表示顶点,我觉得都差不多,本文还是用回顶点。

    首先,在Domain函数内unity会给我们当前需要处理的新顶点的重心坐标。假设我们现在处理的是 (13,13,13) 。

    Patch的每一个顶点都有法线。想象从每一个顶点发出一个切平面,垂直于各自的法向量。

    然后将当前的顶点分别投影到这三个切平面上。

    用数学语言描述。 P′=P−((P−V)⋅N)N

    其中 :

    • $P$ 是最初插值的平面位置。
    • $V$ 是平面上的一个顶点位置。
    • $N$ 是顶点 $V$ 处的法线。
    • ⋅ 表示点积。
    • P′ 是 $P$ 在平面上的投影。

    得到三个 $P’$ 。

    投影在三个切平面的三个点重新组成一个新的三角形,再用回当前顶点的重心坐标应用到新的三角形上,计算出新的点。

    // Calculate Phong projection offset
    float3 PhongProjectedPosition(float3 flatPositionWS, float3 cornerPositionWS, float3 normalWS) {
        return flatPositionWS - dot(flatPositionWS - cornerPositionWS, normalWS) * normalWS;
    }
    
    // Apply Phong smoothing
    float3 CalculatePhongPosition(float3 bary, float3 p0PositionWS, float3 p0NormalWS,
        float3 p1PositionWS, float3 p1NormalWS, float3 p2PositionWS, float3 p2NormalWS) {
        float3 smoothedPositionWS =
            bary.x * PhongProjectedPosition(flatPositionWS, p0PositionWS, p0NormalWS) +
            bary.y * PhongProjectedPosition(flatPositionWS, p1PositionWS, p1NormalWS) +
            bary.z * PhongProjectedPosition(flatPositionWS, p2PositionWS, p2NormalWS);
        return smoothedPositionWS;
    }
    
    // The domain function runs once per vertex in the final, tessellated mesh
    // Use it to reposition vertices and prepare for the fragment stage
    [domain("tri")] // Signal we're inputting triangles
    Interpolators Domain(
        TessellationFactors factors, // The output of the patch constant function
        OutputPatch<TessellationControlPoint, 3> patch, // The Input triangle
        float3 barycentricCoordinates : SV_DomainLocation) { // The barycentric coordinates of the vertex on the triangle
    
        Interpolators output;
        ...
        float3 positionWS = CalculatePhongPosition(barycentricCoordinates, 
          patch[0].positionWS, patch[0].normalWS, 
          patch[1].positionWS, patch[1].normalWS, 
          patch[2].positionWS, patch[2].normalWS);
        float3 normalWS = BARYCENTRIC_INTERPOLATE(normalWS);
        float3 tangentWS = BARYCENTRIC_INTERPOLATE(tangentWS.xyz);
        ...
        output.positionCS = TransformWorldToHClip(positionWS);
        output.normalWS = normalWS;
        output.positionWS = positionWS;
        output.tangentWS = float4(tangentWS, patch[0].tangentWS.w);
        ...
    }

    注意这里需要添加法线向量,然后写进Vertex和Domain。再写一个计算算 $P’$ 重心坐标的函数。

    struct Attributes {
        ...
        float4 tangentOS : TANGENT;
    };
    struct TessellationControlPoint {
        ...
        float4 tangentWS : TANGENT;
    };
    struct Interpolators {
        ...
        float4 tangentWS : TANGENT;
    };
    TessellationControlPoint Vertex(Attributes input) {
        TessellationControlPoint output;
        ...
        // .....最后一个是符号系数
        output.tangentWS = float4(normalInputs.tangentWS, input.tangentOS.w); // tangent.w containts bitangent multiplier
    }
    // Barycentric interpolation as a function
    float3 BarycentricInterpolate(float3 bary, float3 a, float3 b, float3 c) {
        return bary.x * a + bary.y * b + bary.z * c;
    }

    在Phong细分原论文中,还加入了一个 α 因子,用于控制弯曲的程度。原文作者推荐将这个数值全局地设置为四分之三,这样的视觉效果最好。将含有 α 因子的算法展开后可以得到二次贝塞尔曲线,虽然不能提供拐点但是实际开发中已经足够使用。

    首先看看原论文的公式。

    本质上就是控制插值的程度,定量分析一下就知道,当 α=0 的时候,所有顶点都在原来的平面上,也就相当于没有任何位移。当 α=1 的时候,新的顶点完全依赖于Phong细分弯曲顶点。当然,你也可以尝试小于零或者大于一的数值,效果也是比较有趣的。~~看不懂原文的数学公式没关系,我反手直接上一个lerp,主打一个胡乱插值。~~

    // Apply Phong smoothing
    float3 CalculatePhongPosition(float3 bary, float smoothing, float3 p0PositionWS, float3 p0NormalWS,
        float3 p1PositionWS, float3 p1NormalWS, float3 p2PositionWS, float3 p2NormalWS) {
        float3 flatPositionWS = BarycentricInterpolate(bary, p0PositionWS, p1PositionWS, p2PositionWS);
        float3 smoothedPositionWS =
            bary.x * PhongProjectedPosition(flatPositionWS, p0PositionWS, p0NormalWS) +
            bary.y * PhongProjectedPosition(flatPositionWS, p1PositionWS, p1NormalWS) +
            bary.z * PhongProjectedPosition(flatPositionWS, p2PositionWS, p2NormalWS);
        return lerp(flatPositionWS, smoothedPositionWS, smoothing);
    }
    
    // Apply Phong smoothing
    float3 CalculatePhongPosition(float3 bary, float smoothing, float3 p0PositionWS, float3 p0NormalWS,
        float3 p1PositionWS, float3 p1NormalWS, float3 p2PositionWS, float3 p2NormalWS) {
        float3 flatPositionWS = BarycentricInterpolate(bary, p0PositionWS, p1PositionWS, p2PositionWS);
        float3 smoothedPositionWS =
            bary.x * PhongProjectedPosition(flatPositionWS, p0PositionWS, p0NormalWS) +
            bary.y * PhongProjectedPosition(flatPositionWS, p1PositionWS, p1NormalWS) +
            bary.z * PhongProjectedPosition(flatPositionWS, p2PositionWS, p2NormalWS);
        return lerp(flatPositionWS, smoothedPositionWS, smoothing);
    }

    别忘了暴露在材质面板中。

    // .shader
    _TessellationSmoothing("_TessellationSmoothing", Range(0,1)) = 0.5
    
    // .hlsl
    float _TessellationSmoothing;
    
    
    
    Interpolators Domain( .... ) {
        ...
        float smoothing = _TessellationSmoothing;
        float3 positionWS = CalculatePhongPosition(barycentricCoordinates, smoothing,
          patch[0].positionWS, patch[0].normalWS, 
          patch[1].positionWS, patch[1].normalWS, 
          patch[2].positionWS, patch[2].normalWS);
        ...
    }

    需要特别注意的是,有些模型需要一些修饰。如果模型的边缘非常锐利,那么就说明这个顶点的法线和所在面的法线几乎平行。在Phong Tessellation中,这会导致顶点在切平面上的投影非常接近于原始的顶点位置,从而使得细分的影响减少。

    为了解决这个问题,可以在建模软件中进行所谓的“添加环边”(adding loop edges)或“环切割”(loop cut),以添加更多的几何细节。在原模型的边缘附近插入额外的边缘环,从而增加细分密度。具体操作这里就不展开了。

    总的来说,Phong细分的效果和性能都相对不错。但是如果希望得到更高品质的平滑效果,可以考虑 PN triangles。该技术基于贝塞尔曲线弯曲三角形。

    4.2 PN triangles 细分

    首先附上原论文。http://alex.vlachos.com/graphics/CurvedPNTriangles.pdf

    PN Triangles不需要邻近三角形的信息,并且成本较低。PN Triangles算法只需要Patch里的三个顶点的位置和法线信息。剩下的数据都可以通过计算得到。注意,所有数据都在重心坐标。

    在PN算法中,需要先计算出10个控制点用于曲面细分,如下图所示。三个三角形的顶点,一个重心,还有三对边上的控制点组成所有控制点。计算得到的贝塞尔曲线控制点,会传给Domain。由于每个三角形Patch的控制点都是一致的,因此计算控制点的步骤放在Patch Constant Function非常合适。

    论文中的计算方式如下:

    $$
    \begin{aligned}
    b_{300} & =P_1 \
    b_{030} & =P_2 \
    b_{003} & =P_3 \
    w_{i j} & =\left(P_j-P_i\right) \cdot N_i \in \mathbf{R} \quad \text { here ‘ } \cdot \text { ‘ is the scalar product, } \
    b_{210} & =\left(2 P_1+P_2-w_{12} N_1\right) / 3 \
    b_{120} & =\left(2 P_2+P_1-w_{21} N_2\right) / 3 \
    b_{021} & =\left(2 P_2+P_3-w_{23} N_2\right) / 3 \
    b_{012} & =\left(2 P_3+P_2-w_{32} N_3\right) / 3 \
    b_{102} & =\left(2 P_3+P_1-w_{31} N_3\right) / 3, \
    b_{201} & =\left(2 P_1+P_3-w_{13} N_1\right) / 3, \
    E & =\left(b_{210}+b_{120}+b_{021}+b_{012}+b_{102}+b_{201}\right) / 6 \
    V & =\left(P_1+P_2+P_3\right) / 3, \
    b_{111} & =E+(E-V) / 2 .
    \end{aligned}
    $$

    公式中的 $w_{i j}$ 每条边都会计算两次,因此一共会计算6次。比如 $w_{1 2}$ 的意义就是,$P_1$ 到 $P_2$ 的向量在 $P_1$ 法线方向上的投影长度。再乘上对应的法线方向就表示 $w$ 为长度的投影向量。

    还是计算靠近 $P_1$ 的因子为例,当前位置点的权重应该较大,乘上一个 $2$ 使得计算出来的控制点更加靠近当前的顶点。减去投影向量的原因是为了修正因 $P_2$ 位置不在 $P_1$​​ 法线定义的平面上而导致的误差。让三角形平面更加吻合,减少扭曲效果。最后再除3,为了标准化。

    接着计算平均贝塞尔控制点 $E$​ ,表示六个控制点的平均位置。这个平均位置代表了边界控制点的集中趋势。然后算一下三角形顶点的平均位置。然后求出这两个平均位置的中点位置,加到贝塞尔平均控制点。这就是最终要求的第十个参数了。

    总结一下,前三个是三角形的顶点位置(因此不用写在结构体里面),有六个是通过权重计算,最后一个是集合前面计算的平均起来。代码书写非常简单。

    struct TessellationFactors {
        float edge[3] : SV_TessFactor;
        float inside : SV_InsideTessFactor;
        float3 bezierPoints[7] : BEZIERPOS;
    };
    
    //Bezier control point calculations
    float3 CalculateBezierControlPoint(float3 p0PositionWS, float3 aNormalWS, float3 p1PositionWS, float3 bNormalWS) {
        float w = dot(p1PositionWS - p0PositionWS, aNormalWS);
        return (p0PositionWS * 2 + p1PositionWS - w * aNormalWS) / 3.0;
    }
    
    void CalculateBezierControlPoints(inout float3 bezierPoints[7],
        float3 p0PositionWS, float3 p0NormalWS, float3 p1PositionWS, float3 p1NormalWS, float3 p2PositionWS, float3 p2NormalWS) {
        bezierPoints[0] = CalculateBezierControlPoint(p0PositionWS, p0NormalWS, p1PositionWS, p1NormalWS);
        bezierPoints[1] = CalculateBezierControlPoint(p1PositionWS, p1NormalWS, p0PositionWS, p0NormalWS);
        bezierPoints[2] = CalculateBezierControlPoint(p1PositionWS, p1NormalWS, p2PositionWS, p2NormalWS);
        bezierPoints[3] = CalculateBezierControlPoint(p2PositionWS, p2NormalWS, p1PositionWS, p1NormalWS);
        bezierPoints[4] = CalculateBezierControlPoint(p2PositionWS, p2NormalWS, p0PositionWS, p0NormalWS);
        bezierPoints[5] = CalculateBezierControlPoint(p0PositionWS, p0NormalWS, p2PositionWS, p2NormalWS);
        float3 avgBezier = 0;
        [unroll] for (int i = 0; i < 6; i++) {
            avgBezier += bezierPoints[i];
        }
        avgBezier /= 6.0;
        float3 avgControl = (p0PositionWS + p1PositionWS + p2PositionWS) / 3.0;
        bezierPoints[6] = avgBezier + (avgBezier - avgControl) / 2.0;
    }
    
    // The patch constant function runs once per triangle, or "patch"
    // It runs in parallel to the hull function
    TessellationFactors PatchConstantFunction(
        InputPatch<TessellationControlPoint, 3> patch) {
        ...
        TessellationFactors f = (TessellationFactors)0;
        // Check if this patch should be culled (it is out of view)
        if (ShouldClipPatch(...)) {
            ...
        } else {
            ...
            CalculateBezierControlPoints(f.bezierPoints, patch[0].positionWS, patch[0].normalWS, 
              patch[1].positionWS, patch[1].normalWS, patch[2].positionWS, patch[2].normalWS);
        }
        return f;
    }

    接着在domain函数中,使用Hull Function输出的十个因子。根据论文给出的公式,计算出最终的立方贝塞尔曲面坐标。然后再插值一下,暴露到材质面板上。

    $$
    \begin{aligned}
    & b: \quad R^2 \mapsto R^3, \quad \text { for } w=1-u-v, \quad u, v, w \geq 0 \
    & b(u, v)= \sum_{i+j+k=3} b_{i j k} \frac{3!}{i!j!k!} u^i v^j w^k \
    &= b_{300} w^3+b_{030} u^3+b_{003} v^3 \
    &+b_{210} 3 w^2 u+b_{120} 3 w u^2+b_{201} 3 w^2 v \
    &+b_{021} 3 u^2 v+b_{102} 3 w v^2+b_{012} 3 u v^2 \
    &+b_{111} 6 w u v .
    \end{aligned}
    $$

    // Barycentric interpolation as a function
    float3 BarycentricInterpolate(float3 bary, float3 a, float3 b, float3 c) {
        return bary.x * a + bary.y * b + bary.z * c;
    }
    
    float3 CalculateBezierPosition(float3 bary, float smoothing, float3 bezierPoints[7],
        float3 p0PositionWS, float3 p1PositionWS, float3 p2PositionWS) {
        float3 flatPositionWS = BarycentricInterpolate(bary, p0PositionWS, p1PositionWS, p2PositionWS);
        float3 smoothedPositionWS =
            p0PositionWS * (bary.x * bary.x * bary.x) +
            p1PositionWS * (bary.y * bary.y * bary.y) +
            p2PositionWS * (bary.z * bary.z * bary.z) +
            bezierPoints[0] * (3 * bary.x * bary.x * bary.y) +
            bezierPoints[1] * (3 * bary.y * bary.y * bary.x) +
            bezierPoints[2] * (3 * bary.y * bary.y * bary.z) +
            bezierPoints[3] * (3 * bary.z * bary.z * bary.y) +
            bezierPoints[4] * (3 * bary.z * bary.z * bary.x) +
            bezierPoints[5] * (3 * bary.x * bary.x * bary.z) +
            bezierPoints[6] * (6 * bary.x * bary.y * bary.z);
        return lerp(flatPositionWS, smoothedPositionWS, smoothing);
    }
    
    // The domain function runs once per vertex in the final, tessellated mesh
    // Use it to reposition vertices and prepare for the fragment stage
    [domain("tri")] // Signal we're inputting triangles
    Interpolators Domain(
        TessellationFactors factors, // The output of the patch constant function
        OutputPatch<TessellationControlPoint, 3> patch, // The Input triangle
        float3 barycentricCoordinates : SV_DomainLocation) { // The barycentric coordinates of the vertex on the triangle
    
        Interpolators output;
        ...
        // Calculate tessellation smoothing multipler
        float smoothing = _TessellationSmoothing;
    #ifdef _TESSELLATION_SMOOTHING_VCOLORS
        smoothing *= BARYCENTRIC_INTERPOLATE(color.r); // Multiply by the vertex's red channel
    #endif
    
        float3 positionWS = CalculateBezierPosition(barycentricCoordinates,
          smoothing, factors.bezierPoints, 
          patch[0].positionWS, patch[1].positionWS, patch[2].positionWS);
        float3 normalWS = BARYCENTRIC_INTERPOLATE(normalWS);
        float3 tangentWS = BARYCENTRIC_INTERPOLATE(tangentWS.xyz);
        ...
    }

    对比效果,关闭与开启PN triangles。

    4.3 改进版 PN triangles – 输出细分的法线

    传统的PN triangles只改变了顶点的位置信息,我们可以再结合顶点的法线信息,输出动态变化的法线信息,提供更好的光线反射效果。

    在原本的的算法中,法线的变化是非常离散的。如下图(上)所示,利用原本三角形的两个顶点提供的法线也许不能很好的表现原本曲面的法线变化。我们想要达到下图(下)的效果,因此需要利用二次插值得到单个Patch中可能的曲面变化。

    由于曲面是三次贝塞尔面,所以法线应该是二次贝塞尔曲面插值。因此需要额外的三个法线控制点。TheTus的文章已经讲得比较清晰了,详细的数学原理请移步Ref10.链接

    下面简单介绍一下如何获取细分的法线方向。

    首先获取点AB的两个法线信息。然后求出他们的平均法向。

    构造一个垂直于线段AB过中点的平面。

    取刚刚平均法向对于该平面的反射向量。

    每条边都算一下,算三个。

    struct TessellationFactors {
        float edge[3] : SV_TessFactor;
        float inside : SV_InsideTessFactor;
        float3 bezierPoints[10] : BEZIERPOS;
    };
    
    float3 CalculateBezierControlNormal(float3 p0PositionWS, float3 aNormalWS, float3 p1PositionWS, float3 bNormalWS) {
        float3 d = p1PositionWS - p0PositionWS;
        float v = 2 * dot(d, aNormalWS + bNormalWS) / dot(d, d);
        return normalize(aNormalWS + bNormalWS - v * d);
    }
    
    void CalculateBezierNormalPoints(inout float3 bezierPoints[10],
        float3 p0PositionWS, float3 p0NormalWS, float3 p1PositionWS, float3 p1NormalWS, float3 p2PositionWS, float3 p2NormalWS) {
        bezierPoints[7] = CalculateBezierControlNormal(p0PositionWS, p0NormalWS, p1PositionWS, p1NormalWS);
        bezierPoints[8] = CalculateBezierControlNormal(p1PositionWS, p1NormalWS, p2PositionWS, p2NormalWS);
        bezierPoints[9] = CalculateBezierControlNormal(p2PositionWS, p2NormalWS, p0PositionWS, p0NormalWS);
    }
    
    // The patch constant function runs once per triangle, or "patch"
    // It runs in parallel to the hull function
    TessellationFactors PatchConstantFunction(
        InputPatch<TessellationControlPoint, 3> patch) {
        ...
        TessellationFactors f = (TessellationFactors)0;
        // Check if this patch should be culled (it is out of view)
        if (ShouldClipPatch(...)) {
            ..
        } else {
            ...
            CalculateBezierControlPoints(f.bezierPoints, 
              patch[0].positionWS, patch[0].normalWS, patch[1].positionWS, 
              patch[1].normalWS, patch[2].positionWS, patch[2].normalWS);
            CalculateBezierNormalPoints(f.bezierPoints, 
              patch[0].positionWS, patch[0].normalWS, patch[1].positionWS, 
              patch[1].normalWS, patch[2].positionWS, patch[2].normalWS);
        }
        return f;
    }

    并且需要注意,所有插值得到的法线向量都需要标准化。

    float3 CalculateBezierNormal(float3 bary, float3 bezierPoints[10],
        float3 p0NormalWS, float3 p1NormalWS, float3 p2NormalWS) {
        return p0NormalWS * (bary.x * bary.x) +
            p1NormalWS * (bary.y * bary.y) +
            p2NormalWS * (bary.z * bary.z) +
            bezierPoints[7] * (2 * bary.x * bary.y) +
            bezierPoints[8] * (2 * bary.y * bary.z) +
            bezierPoints[9] * (2 * bary.z * bary.x);
    }
    
    float3 CalculateBezierNormalWithSmoothFactor(float3 bary, float smoothing, float3 bezierPoints[10],
        float3 p0NormalWS, float3 p1NormalWS, float3 p2NormalWS) {
        float3 flatNormalWS = BarycentricInterpolate(bary, p0NormalWS, p1NormalWS, p2NormalWS);
        float3 smoothedNormalWS = CalculateBezierNormal(bary, bezierPoints, p0NormalWS, p1NormalWS, p2NormalWS);
        return normalize(lerp(flatNormalWS, smoothedNormalWS, smoothing));
    }
    
    // The domain function runs once per vertex in the final, tessellated mesh
    // Use it to reposition vertices and prepare for the fragment stage
    [domain("tri")] // Signal we're inputting triangles
    Interpolators Domain(
        TessellationFactors factors, // The output of the patch constant function
        OutputPatch<TessellationControlPoint, 3> patch, // The Input triangle
        float3 barycentricCoordinates : SV_DomainLocation) { // The barycentric coordinates of the vertex on the triangle
    
        Interpolators output;
        ...
        // Calculate tessellation smoothing multipler
        float smoothing = _TessellationSmoothing;
        float3 positionWS = CalculateBezierPosition(barycentricCoordinates, smoothing, factors.bezierPoints, patch[0].positionWS, patch[1].positionWS, patch[2].positionWS);
        float3 normalWS = CalculateBezierNormalWithSmoothFactor(
            barycentricCoordinates, smoothing, factors.bezierPoints,
            patch[0].normalWS, patch[1].normalWS, patch[2].normalWS);
        float3 tangentWS = BARYCENTRIC_INTERPOLATE(tangentWS.xyz);
        ...
    }

    还有一个问题需要注意,当我们使用了插值得到的法线,与之一一对应的切线向量就不再与插值得到的法线向量正交。为了保持正交性,需要重新计算一个切线向量。

    void CalculateBezierNormalAndTangent(
        float3 bary, float smoothing, float3 bezierPoints[10],
        float3 p0NormalWS, float3 p0TangentWS, 
        float3 p1NormalWS, float3 p1TangentWS, 
        float3 p2NormalWS, float3 p2TangentWS,
        out float3 normalWS, out float3 tangentWS) {
    
        float3 flatNormalWS = BarycentricInterpolate(bary, p0NormalWS, p1NormalWS, p2NormalWS);
        float3 smoothedNormalWS = CalculateBezierNormal(bary, bezierPoints, p0NormalWS, p1NormalWS, p2NormalWS);
        normalWS = normalize(lerp(flatNormalWS, smoothedNormalWS, smoothing));
    
        float3 flatTangentWS = BarycentricInterpolate(bary, p0TangentWS, p1TangentWS, p2TangentWS);
        float3 flatBitangentWS = cross(flatNormalWS, flatTangentWS);
        tangentWS = normalize(cross(flatBitangentWS, normalWS));
    }
    
    [domain("tri")] // Signal we're inputting triangles
    Interpolators Domain(
        TessellationFactors factors, // The output of the patch constant function
        OutputPatch<TessellationControlPoint, 3> patch, // The Input triangle
        float3 barycentricCoordinates : SV_DomainLocation) { // The barycentric coordinates of the vertex on the triangle
        ...
        float3 normalWS, tangentWS;
        CalculateBezierNormalAndTangent(
            barycentricCoordinates, smoothing, factors.bezierPoints,
            patch[0].normalWS, patch[0].tangentWS.xyz, 
            patch[1].normalWS, patch[1].tangentWS.xyz, 
            patch[2].normalWS, patch[2].tangentWS.xyz,
            normalWS, tangentWS);
        ...
    }

    References

    1. https://www.youtube.com/watch?v=63ufydgBcIk
    2. https://nedmakesgames.medium.com/mastering-tessellation-shaders-and-their-many-uses-in-unity-9caeb760150e
    3. https://zhuanlan.zhihu.com/p/148247621
    4. https://zhuanlan.zhihu.com/p/124235713
    5. https://zhuanlan.zhihu.com/p/141099616
    6. https://zhuanlan.zhihu.com/p/42550699
    7. https://en.wikipedia.org/wiki/Barycentric_coordinate_system
    8. https://zhuanlan.zhihu.com/p/359999755
    9. https://zhuanlan.zhihu.com/p/629364817
    10. https://zhuanlan.zhihu.com/p/629202115
    11. https://perso.telecom-paristech.fr/boubek/papers/PhongTessellation/PhongTessellation.pdf
    12. http://alex.vlachos.com/graphics/CurvedPNTriangles.pdf
  • Unity可互动可砍断八叉树草海渲染 – 几何、计算着色器(BIRP/URP)

    Unity可互动可砍断八叉树草海渲染 – 几何、计算着色器(BIRP/URP)

    项目(BIRP)在Github:

    https://github.com/Remyuu/Unity-Interactive-Grass

    先放一张10, 0500棵草在Compute Shader上未经任何优化在我的M1 pro上运行的截图,能跑个两百多帧。

    加入八叉树视锥体剔除、距离渐隐等操作,帧数反而没有这么稳定了(想死),我猜测是CPU端每一帧的操作压力太大,需要维护这么大量的草地信息。但是只要剔除得足够多,跑个700帧+是没问题的(安慰)。另外,八叉树的深度也需要根据实际做优化,下图八叉树的深度我设置为了5。

    前言

    这篇文章已经越来越长了,主要留给自己回顾知识用,大佬们阅读的时候可能会感觉很多基础的内容。我是纯新手,恳求各位大佬的讨论和指正。

    本文主要有两阶段:

    • GS + TS的方法实现草地渲染最基础的效果
    • 然后用CS重新实现草海渲染,加上了各种优化手段

    几何着色器+曲面细分着色器的渲染方式应该是比较简单的,但是性能上限比较低,且平台兼容性差。

    计算着色器配合GPU Instancing的方法应该才是当前业界的主流方法,并且在移动端上也能很好的运行。

    本文的CS渲染草海Demo主要参考了Colin和Minions Art的实现,更类似两者的杂交低级版(前者知乎上已经有大佬解析过了基于GPU Instance的草地渲染学习笔记)。用三组ComputeBuffer,一组是包含所有草的Buffer,一个是Append丢进Material的Buffer,另一组是一个可见Buffer(根据视锥剔除实时得到)。实现了用一颗四八叉树(奇偶深度)来做空间划分,加上通过视锥剔除得到当前视锥体内的所有草的索引,传给Compute Shader做进一步的处理(例如Mesh生成、四元数计算旋转、LoD等操作),然后再用一个变长的ComputeBuffer(ComputeBufferType.Append)将需要渲染的草,通过Instancing传给Material做最终的渲染。

    还可以用Hi-Z的方案做剔除,挖一个坑,努力学习中。

    另外参考了Minions Art大佬的文章复刻了一套编辑器刷草的工具(残缺版),通过维护一个顶点列表,存储所有的草地顶点位置。

    再进一步的,通过另外维护一组Cut Buffer,如果被标记为 -1 值的草,则不做处理。如果标记为砍刀高度的非 -1 数值,则会传到Material中,通过WorldPos + Split.y再加上lerp的操作,将草的上半部分变得不可见,并且再修改草的颜色,最后加上一些草屑的例子效果,实现一个砍草的效果。

    上一篇文章已经详细介绍了什么是曲面细分着色器,以及各种优化方法。接下来将曲面细分融入实际开发。另外,结合了几天速成的Compute Shader,捣鼓出了基于计算着色器的草地,详细可以这一篇笔记。以下是本文将要实现的小效果,并附完整代码:

    • 草地渲染
    • 草地渲染 – 几何着色器 (BIRP/URP)
    • 定义草宽高朝向倾倒曲率渐变颜色带法向
    • INTEGER曲面细分
    • URP新增Visibility Map
    • 草地渲染 – Compute Shader(BIRP/URP)work on MacOS
    • 八叉树视锥体剔除
    • 距离渐隐
    • 草地交互
    • 交互性几何着色器(BIRP/URP)
    • 交互性Compute Shader(BIRP)work on MacOS
    • Unity自定义草地生成工具
    • 砍草系统

    主要参考(抄袭)文章:

    草地渲染有很多种方案,本文中的两种:

    • 几何着色器+曲面细分着色器
    • 计算着色器+GPU Instancing

    首先,第一种方案局限性很大。很多移动设备还有Metal不支持GS,而且GS每一帧都会重新计算一次Mesh,开销还是挺大的。

    其次,MacOS就不能跑几何着色器了吗?也不是。想要用GS,就必须使用OpenGL,而不是Metal。但是需要注意,Apple对OpenGL最高支持到OpenGL 4.1。也就是说,这个版本不支持Compute Shader。当然,Intel时期的MacOS可以支持到OpenGL 4.3,可以同时跑CS和GS。M系列芯片就没这个命运了,要么用4.1,要么老老实实用Metal。在我的M1p mbp上,即使选择虚拟机(Parallels 18+ 提供了DX11和Vulkan),但是运行在macOS上的Vulkan是经过转译的,本质还是Metal,所以还是没GS。因此macOS M1之后就没有原生的GS了。

    再者,Metal 甚至不直接支持 Tessellation 着色器。Apple压根不想在芯片上对这两个东西做支持。为什么呢?因为效率太低了。在M芯片上,TS甚至都是用CS模拟的!

    总结一下,几何着色器是一个没有出路的技术,尤其是在Mesh Shader问世之后。虽然GS在Unity中很流行,但任何类似的效果都可以在CS上Instance出来,并且效率更高。现在的新显卡虽然还是会支持GS,因为目前市面上还是有相当多的游戏在用GS。只是Apple不考虑兼容性,直接砍掉了。

    这篇文章详细讲述了为啥GS这么慢:http://www.joshbarczak.com/blog/?p=667。简单的说就是,Intel通过阻塞线程等方式优化了GS,其他芯片则没有这种优化。

    本文作为学习笔记,很有可能会出错。

    一、几何着色器渲染草概述(BIRP)

    本章节是Roystan的精简概括。需要工程文件或者最终代码的可以去原文下载。或者阅读苏格拉没有底的文章

    1.1 概述

    Domain Stage之后,可以选择使用几何着色器。

    几何着色器将整个基元作为输入,并能够在输出上生成顶点。几何着色器的输入是完整基元的顶点(三角形为三个顶点,线为两个顶点或点为单个顶点)。每个基元都将调用一次几何着色器。

    网页下载初始工程。

    1.2 绘制三角形

    绘制一个三角形。

    // Add inside the CGINCLUDE block.
    struct geometryOutput
    {
        float4 pos : SV_POSITION;
    };
    
    ...
        //顶点着色器
    return vertex;
    ...
    
    [maxvertexcount(3)]
    void geo(triangle float4 IN[3] : SV_POSITION, inout TriangleStream<geometryOutput> triStream)
    {
        geometryOutput o;
    
        o.pos = UnityObjectToClipPos(float4(0.5, 0, 0, 1));
        triStream.Append(o);
    
        o.pos = UnityObjectToClipPos(float4(-0.5, 0, 0, 1));
        triStream.Append(o);
    
        o.pos = UnityObjectToClipPos(float4(0, 1, 0, 1));
        triStream.Append(o);
    }
    
    
    
    // Add inside the SubShader Pass, just below the #pragma fragment frag line.
    #pragma geometry geo

    實際上,我們為網格中的每個頂點繪製了一個三角形,但我們分配給三角形頂點的位置是恆定的 – 它們不會針對每個輸入頂點而改變 – 將所有三角形放置在彼此之上了。

    1.3 顶点偏移

    因此,根据每一个顶点位置做偏移即可。

    C#
    // Add to the top of the geometry shader.
    float3 pos = IN[0];
    
    
    
    // Update each assignment of o.pos.
    o.pos = UnityObjectToClipPos(pos + float3(0.5, 0, 0));
    
    
    
    o.pos = UnityObjectToClipPos(pos + float3(-0.5, 0, 0));
    
    
    
    o.pos = UnityObjectToClipPos(pos + float3(0, 1, 0));

    1.4 旋转叶片

    但是需要注意,目前三角形都是一个方向发射,因此加入法线修正。构建TBN矩阵,与当前给的方向做乘积。并且整理代码。

    float3 vNormal = IN[0].normal;
    float4 vTangent = IN[0].tangent;
    float3 vBinormal = cross(vNormal, vTangent) * vTangent.w;
    
    float3x3 tangentToLocal = float3x3(
        vTangent.x, vBinormal.x, vNormal.x,
        vTangent.y, vBinormal.y, vNormal.y,
        vTangent.z, vBinormal.z, vNormal.z
        );
    
    triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0.5, 0, 0))));
    triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(-0.5, 0, 0))));
    triStream.Append(VertexOutput(pos + mul(tangentToLocal, float3(0, 0, 1))));

    1.5 上色

    然后定义草的上下两个颜色,用uv做lerp渐变。

    return lerp(_BottomColor, _TopColor, i.uv.y);
    C#

    1.6 旋转矩阵原理

    做随机朝向。这里构建了一个旋转矩阵。原理在GAMES101也有讲到哦。B站还有一个公式推导的视频,讲得也很清晰!简单的推导思路就是,假設是向量 $a$ 繞著n軸旋轉至 $b$ ,則將 $a$​ 分解為平行於n軸的分量(發現是不變的)加上垂直於n軸的分量。

    float3x3 AngleAxis3x3(float angle, float3 axis)
    {
        float c, s;
        sincos(angle, s, c);
    
        float t = 1 - c;
        float x = axis.x;
        float y = axis.y;
        float z = axis.z;
    
        return float3x3(
            t * x * x + c, t * x * y - s * z, t * x * z + s * y,
            t * x * y + s * z, t * y * y + c, t * y * z - s * x,
            t * x * z - s * y, t * y * z + s * x, t * z * z + c
            );
    }

    旋转矩阵 $R$ 这里用罗德里格旋转公式(Rodrigues’ rotation formula)来计算: $$R=I+sin⁡(θ)⋅[k]×+(1−cos⁡(θ))⋅[k]×2$$

    其中, $\theta$ 是旋转角。$k$ 是单位旋转轴。 $I$ 是单位矩阵。 $[k]_{\times}$ 是轴 $k$ 对应的反对称矩阵。

    对于一个单位向量 $k=(x,y,z)$ , 反对称矩阵 $[k]_{\times}=\left[\begin{array}{ccc} 0 & -z & y \\ z & 0 & -x \\ -y & x & 0 \end{array}\right]$ 最后得到的矩阵元素:

    $$ \begin{array}{ccc} tx^2 + c & txy – sz & txz + sy \\ txy + sz & ty^2 + c & tyz – sx \\ txz – sy & tyz + sx & tz^2 + c \\ \end{array} $$

    float3x3 facingRotationMatrix = AngleAxis3x3(rand(pos) * UNITY_TWO_PI, float3(0, 0, 1));

    1.7 叶片倾倒

    得到随机方向朝向的草,接下来在x或者y轴任意随机方向倾倒。

    float3x3 bendRotationMatrix = AngleAxis3x3(rand(pos.zzx) * _BendRotationRandom * UNITY_PI * 0.5, float3(-1, 0, 0));

    1.8 叶片大小

    调整草的宽与高。原本我们默认高和宽都是一个单位。为了让草更加自然,这个步骤再加入rand,显得更加自然。

    _BladeWidth("Blade Width", Float) = 0.05
    _BladeWidthRandom("Blade Width Random", Float) = 0.02
    _BladeHeight("Blade Height", Float) = 0.5
    _BladeHeightRandom("Blade Height Random", Float) = 0.3
    
    
    float height = (rand(pos.zyx) * 2 - 1) * _BladeHeightRandom + _BladeHeight;
    float width = (rand(pos.xzy) * 2 - 1) * _BladeWidthRandom + _BladeWidth;
    
    
    triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(width, 0, 0)), float2(0, 0)));
    triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(-width, 0, 0)), float2(1, 0)));
    triStream.Append(VertexOutput(pos + mul(transformationMatrix, float3(0, 0, height)), float2(0.5, 1)));

    1.9 曲面细分

    由于数量太少,此处上曲面细分。

    1.10 扰动

    让草动起来,加法线随着 _Time 扰动。采样贴图,然后计算风的旋转矩阵,应用到草上。

    float2 uv = pos.xz * _WindDistortionMap_ST.xy + _WindDistortionMap_ST.zw + _WindFrequency * _Time.y;
    
    float2 windSample = (tex2Dlod(_WindDistortionMap, float4(uv, 0, 0)).xy * 2 - 1) * _WindStrength;
    
    float3 wind = normalize(float3(windSample.x, windSample.y, 0));
    
    float3x3 windRotation = AngleAxis3x3(UNITY_PI * windSample, wind);
    
    float3x3 transformationMatrix = mul(mul(mul(tangentToLocal, windRotation), facingRotationMatrix), bendRotationMatrix);

    1.11 修正叶片旋转问题

    此时风可能会沿着x和y轴的旋转,具体表现就是:

    将脚下的两个点单独写一个只沿着z旋转的矩阵。

    float3x3 transformationMatrixFacing = mul(tangentToLocal, facingRotationMatrix);
    
    
    
    triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(width, 0, 0)), float2(0, 0)));
    triStream.Append(VertexOutput(pos + mul(transformationMatrixFacing, float3(-width, 0, 0)), float2(1, 0)));

    1.12 叶片曲率

    为了让叶子具有曲率,就只能增加顶点。另外,由于当前开启了双面渲染,顶点的顺序就没什么所谓了。这里手动插值for loop构建三角形。计算一个 forward 用于弯曲叶片。

    float forward = rand(pos.yyz) * _BladeForward;
    
    
    for (int i = 0; i < BLADE_SEGMENTS; i++)
    {
        float t = i / (float)BLADE_SEGMENTS;
        // Add below the line declaring float t.
        float segmentHeight = height * t;
        float segmentWidth = width * (1 - t);
        float segmentForward = pow(t, _BladeCurve) * forward;
        float3x3 transformMatrix = i == 0 ? transformationMatrixFacing : transformationMatrix;
        triStream.Append(GenerateGrassVertex(pos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix));
        triStream.Append(GenerateGrassVertex(pos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix));
    }
    
    triStream.Append(GenerateGrassVertex(pos, 0, height, forward, float2(0.5, 1), transformationMatrix));

    1.13 制造阴影

    在另外一个Pass中制造阴影,输出。

    Pass{
        Tags{
            "LightMode" = "ShadowCaster"
        }
    
        CGPROGRAM
        #pragma vertex vert
        #pragma geometry geo
        #pragma fragment frag
        #pragma hull hull
        #pragma domain domain
        #pragma target 4.6
        #pragma multi_compile_shadowcaster
    
        float4 frag(geometryOutput i) : SV_Target{
            SHADOW_CASTER_FRAGMENT(i)
        }
    
        ENDCG
    }

    1.14 接收阴影

    直接在Frag用 SHADOW_ATTENUATION 判断阴影。

    // geometryOutput struct.
    unityShadowCoord4 _ShadowCoord : TEXCOORD1;
    ...
    o._ShadowCoord = ComputeScreenPos(o.pos);
    ...
    #pragma multi_compile_fwdbase
    ...
    return SHADOW_ATTENUATION(i);

    1.15 去除阴影痤疮

    去除表面痤疮。

    #if UNITY_PASS_SHADOWCASTER
        o.pos = UnityApplyLinearShadowBias(o.pos);
    #endif

    1.16 增加法线

    给几何着色器生成的顶点加法线信息。

    struct geometryOutput
    {
        float4 pos : SV_POSITION;
        float2 uv : TEXCOORD0;
        unityShadowCoord4 _ShadowCoord : TEXCOORD1;
        float3 normal : NORMAL;
    };
    ...
    o.normal = UnityObjectToWorldNormal(normal);

    1.17 完整代码‼️(BIRP)

    最终效果。

    代码:

    https://pastebin.com/8u1ytGgU

    完整的:https://pastebin.com/U14m1Nu0

    二、几何着色器渲染草(URP)

    2.1 参考

    刚才已经写了BIRP版本,现在只需要移植一下就好了。

    • URP代码规范参考:https://www.cyanilux.com/tutorials/urp-shader-code/
    • BIRP->URP速查表:https://cuihongzhi1991.github.io/blog/2020/05/27/builtinttourp/

    大家可以跟着Daniel的这篇文章从头写一遍,也可以跟着我修改刚刚的代码。需要注意的是,原repo的空间变换代码是存在问题的,可以在Pull request中找到解决方案。

    现将上面BIRP的曲面细分着色器整理到一起。

    • Tags改为URP
    • 头文件引入替换为URP版本
    • 变量用CBuffer包围
    • 阴影投射、接收代码

    2.2 开始改

    声明URP管线。

    LOD 100
    Cull Off
    Pass{
        Tags{
            "RenderType" = "Opaque"
            "Queue" = "Geometry"
            "RenderPipeline" = "UniversalPipeline"
        }

    导入URP的库。

    #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/ShaderVariablesFunctions.hlsl"
    
    o._ShadowCoord = ComputeScreenPos(o.pos);

    改一下函数。

    // o.normal = UnityObjectToWorldNormal(normal);
    o.normal = TransformObjectToWorldNormal(normal);

    URP接收阴影。这里最好在顶点着色器计算,但是为了方便就全放在几何着色器计算了。

    然后生成阴影。ShadowCaster Pass。

    Pass{
        Name "ShadowCaster"
        Tags{ "LightMode" = "ShadowCaster" }
    
        ZWrite On
        ZTest LEqual
    
        HLSLPROGRAM
    
            half4 frag(geometryOutput input) : SV_TARGET{
                return 1;
            }
    
        ENDHLSL
    }

    2.3 完整代码‼️(URP)

    https://pastebin.com/6KveEKMZ

    三、优化曲面细分逻辑(BIRP/URP)

    3.1 整理代码

    上面我们都只是采用固定数量的细分等级,我不能接受。如果不了解曲面细分原理的可以看我的曲面细分文章,里面详细讲了几种优化细分的方案。

    我用第一节完成的BIRP版本的代码为例子。当前版本只有Uniform的细分。

    _TessellationUniform("Tessellation Uniform", Range(1, 64)) = 1

    当前各个阶段输出的结构体相当混乱,重新整理一下。

    3.1 划分模式

    [KeywordEnum(INTEGER, FRAC_EVEN, FRAC_ODD, POW2)] _PARTITIONING("Partition algoritm", Float) = 0
    
    #pragma shader_feature_local _PARTITIONING_INTEGER _PARTITIONING_FRAC_EVEN _PARTITIONING_FRAC_ODD _PARTITIONING_POW2
    
    #if defined(_PARTITIONING_INTEGER)
        [partitioning("integer")]
    #elif defined(_PARTITIONING_FRAC_EVEN)
        [partitioning("fractional_even")]
    #elif defined(_PARTITIONING_FRAC_ODD)
        [partitioning("fractional_odd")]
    #elif defined(_PARTITIONING_POW2)
        [partitioning("pow2")]
    #else 
        [partitioning("integer")]
    #endif

    3.2 细分的视锥体剔除

    在BIRP中,使用 _ProjectionParams.z 表示远平面,URP中使用UNITY_RAW_FAR_CLIP_VALUE 。

    bool IsOutOfBounds(float3 p, float3 lower, float3 higher) { //给定矩形判断
        return p.x < lower.x || p.x > higher.x || p.y < lower.y || p.y > higher.y || p.z < lower.z || p.z > higher.z;
    }
    bool IsPointOutOfFrustum(float4 positionCS) { //视锥体判断
        float3 culling = positionCS.xyz;
        float w = positionCS.w;
        float3 lowerBounds = float3(-w, -w, -w * _ProjectionParams.z);
        float3 higherBounds = float3(w, w, w);
        return IsOutOfBounds(culling, lowerBounds, higherBounds);
    }
    bool ShouldClipPatch(float4 p0PositionCS, float4 p1PositionCS, float4 p2PositionCS) {
        bool allOutside = IsPointOutOfFrustum(p0PositionCS) &&
            IsPointOutOfFrustum(p1PositionCS) &&
            IsPointOutOfFrustum(p2PositionCS);
        return allOutside;
    }
    
    TessellationControlPoint vert(Attributes v)
    {
        ...
        o.positionCS = UnityObjectToClipPos(v.vertex);
        ...
    }
    
    TessellationFactors patchConstantFunction (InputPatch<TessellationControlPoint, 3> patch)
    {
        TessellationFactors f;
        if(ShouldClipPatch(patch[0].positionCS, patch[1].positionCS, patch[2].positionCS)){
            f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0;
        }else{
            f.edge[0] = _TessellationFactor;
            f.edge[1] = _TessellationFactor;
            f.edge[2] = _TessellationFactor;
            f.inside = _TessellationFactor;
        }
        return f;
    }

    但是需要注意的是,這裡傳入的判斷是草皮的CS座標。如果三角形草皮完全離開屏幕,但是草長得高還可能會在屏幕中,就會導致草突然消失的畫面BUG。這就看項目的需求了,如果是仰視角並且草地比較矮的項目,就可以使用這個操作。

    仰視角問題不大。

    如果是伏地魔視角,草地並不完整,過度剔除了。

    3.3 屏幕距離的細分控制

    實現近處的草密集,遠處的草稀疏,但是基於屏幕距離(CS空間)。這個方法會受到分辨率的影響。

    float EdgeTessellationFactor(float scale, float4 p0PositionCS, float4 p1PositionCS) {
        float factor = distance(p0PositionCS.xyz / p0PositionCS.w, p1PositionCS.xyz / p1PositionCS.w) / scale;
        return max(1, factor);
    }
    
    TessellationFactors patchConstantFunction (InputPatch<TessellationControlPoint, 3> patch)
    {
        TessellationFactors f;
    
        f.edge[0] = EdgeTessellationFactor(_TessellationFactor, 
            patch[1].positionCS, patch[2].positionCS);
        f.edge[1] = EdgeTessellationFactor(_TessellationFactor, 
            patch[2].positionCS, patch[0].positionCS);
        f.edge[2] = EdgeTessellationFactor(_TessellationFactor, 
            patch[0].positionCS, patch[1].positionCS);
        f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) / 3.0;
    
    
        #if defined(_CUTTESS_TRUE)
            if(ShouldClipPatch(patch[0].positionCS, patch[1].positionCS, patch[2].positionCS))
                f.edge[0] = f.edge[1] = f.edge[2] = f.inside = 0;
        #endif
    
        return f;
    }

    Tessellation Factor = 0.08

    並且劃分模式不建議選取Frac,不然就會有強烈的抖動,非常晃眼睛。這種方法我不太喜歡。

    3.4 相機距離細分

    计算 「两点间的距离」与「两顶点的中点与相机位置的距离」的比值。比值越大说明占据屏幕的空间就越大,需要更多的细分程度。

    float EdgeTessellationFactor_WorldBase(float scale, float3 p0PositionWS, float3 p1PositionWS) {
        float length = distance(p0PositionWS, p1PositionWS);
        float distanceToCamera = distance(_WorldSpaceCameraPos, (p0PositionWS + p1PositionWS) * 0.5);
        float factor = length / (scale * distanceToCamera * distanceToCamera);
        return max(1, factor);
    }
    ...
    f.edge[0] = EdgeTessellationFactor_WorldBase(_TessellationFactor_WORLD_BASE, 
        patch[1].vertex, patch[2].vertex);
    f.edge[1] = EdgeTessellationFactor_WorldBase(_TessellationFactor_WORLD_BASE, 
        patch[2].vertex, patch[0].vertex);
    f.edge[2] = EdgeTessellationFactor_WorldBase(_TessellationFactor_WORLD_BASE, 
        patch[0].vertex, patch[1].vertex);
    f.inside = (f.edge[0] + f.edge[1] + f.edge[2]) / 3.0;

    还有改进空间。调整草地的密集度,使得近距离的草地不太密集,而中距离的草地曲线更为平滑,引入非线性因子来控制距离与镶嵌因子的关系。

    float EdgeTessellationFactor_WorldBase(float scale, float3 p0PositionWS, float3 p1PositionWS) {
        float length = distance(p0PositionWS, p1PositionWS);
        float distanceToCamera = distance(_WorldSpaceCameraPos, (p0PositionWS + p1PositionWS) * 0.5);
        // 使用平方根函数调整距离的影响,使中距离的镶嵌因子变化更平滑
        float adjustedDistance = sqrt(distanceToCamera);
        // 调整 scale 的影响,可能需要根据实际效果进一步微调这里的系数
        float factor = length / (scale * adjustedDistance);
        return max(1, factor);
    }

    这样就比较合适了。

    3.5 Visibility Map 控制草地细分

    顶点着色器读取贴图,传给曲面细分着色器,在PCF计算细分逻辑。

    以FIXED模式为例:

    _VisibilityMap("Visibility Map", 2D) = "white" {}
    TEXTURE2D (_VisibilityMap);SAMPLER(sampler_VisibilityMap);
    struct Attributes
    {
        ...
        float2 uv : TEXCOORD0;
    };
    struct TessellationControlPoint
    {
        ...
        float visibility : TEXCOORD1;
    };
    TessellationControlPoint vert(Attributes v){
        ...
        float visibility = SAMPLE_TEXTURE2D_LOD(_VisibilityMap, sampler_VisibilityMap, v.uv, 0).r; 
        o.visibility    = visibility;
        ...
    }
    TessellationFactors patchConstantFunction (InputPatch<TessellationControlPoint, 3> patch){
        ...
        float averageVisibility = (patch[0].visibility + patch[1].visibility + patch[2].visibility) / 3; // 计算三个顶点灰度值的平均值
        float baseTessellationFactor = _TessellationFactor_FIXED; 
        float tessellationMultiplier = lerp(0.1, 1.0, averageVisibility); // 根据平均灰度值调整因子
        #if defined(_DYNAMIC_FIXED)
            f.edge[0] = _TessellationFactor_FIXED * tessellationMultiplier;
            f.edge[1] = _TessellationFactor_FIXED * tessellationMultiplier;
            f.edge[2] = _TessellationFactor_FIXED * tessellationMultiplier;
            f.inside  = _TessellationFactor_FIXED * tessellationMultiplier;
        ...

    3.6 完整代码‼️(BIRP)

    Grass Shader:

    https://pastebin.com/TD0AupGz

    3.7 完整代码‼️(URP)

    URP有一些地方不太一样,比如说计算ShadowBias,就需要下面这样,不展开了,自己看代码吧。

    #if UNITY_PASS_SHADOWCASTER
        // o.pos = UnityApplyLinearShadowBias(o.pos);
        o.shadowCoord = TransformWorldToShadowCoord(ApplyShadowBias(posWS, norWS, 0));
    #endif

    Grass Shader:

    https://pastebin.com/2ZX2aVm9

    四、互动草地

    URP和BIRP完全一致。

    4.1 实现步骤

    原理很简单,脚本传角色的世界坐标进来,然后根据设定好的半径、互动强度,将草压弯。

    uniform float3 _PositionMoving; // 物体的位置
    float _Radius; // 物体的交互半径
    float _Strength; // 交互强度

    在草地生成的循环中,计算每个草片段与物体之间的距离,并根据这个距离调整草地的位置。

    float dis = distance(_PositionMoving, posWS); // 计算距离
    float radiusEffect = 1 - saturate(dis / _Radius); // 根据距离计算效果衰减
    float3 sphereDisp = pos - _PositionMoving; // 计算位置差
    sphereDisp *= radiusEffect * _Strength; // 应用衰减和强度
    sphereDisp = clamp(sphereDisp, -0.8, 0.8); // 限制最大位移

    然后在各个草叶中计算新的位置。

    // 应用交互效果
    float3 newPos = i == 0 ? pos : pos + (sphereDisp * t);
    triStream.Append(GenerateGrassVertex(newPos, segmentWidth, segmentHeight, segmentForward, float2(0, t), transformMatrix));
    triStream.Append(GenerateGrassVertex(newPos, -segmentWidth, segmentHeight, segmentForward, float2(1, t), transformMatrix));

    别忘了for loop外面,也就是最上面的顶点。

    // 最后的草片段
    float3 newPosTop = pos + sphereDisp;
    triStream.Append(GenerateGrassVertex(newPosTop, 0, height, forward, float2(0.5, 1), transformationMatrix));
    triStream.RestartStrip();

    在URP中,使用 uniform float3 _PositionMoving 可能会导致SRP Batcher失败。

    4.2 脚本代码

    哪个物体需要添加交互,就绑定上去。

    using UnityEngine;
    
    public class ShaderInteractor : MonoBehaviour
    {
        // Update is called once per frame
        void Update()
        {
            Shader.SetGlobalVector("_PositionMoving", transform.position);
        }
    }

    4.3 完整代码‼️(URP)

    Grass shader:

    https://pastebin.com/Zs77EQgy

    五、计算着色器渲染草 v1.0

    为什么是 v1.0 呢,因为我觉得这个计算着色器渲染草海的难度比较大,很多目前不会的以后可以慢慢完善进来。我也写了一些Compute Shader的笔记。

    1. Compute Shader学习笔记(一)
    2. Compute Shader学习笔记(二)之 后处理效果
    3. Compute Shader学习笔记(二)之 粒子效果与群集行为模拟
    4. Compute Shader学习笔记(三)之 草地渲染

    5.1 回顾/整理

    上面的Compute Shader笔记里面完整的写了如何从零用CS写一个程式化的草海。如果忘记了在这里重新回顾一下。

    在初始化阶段CPU要做的事情还是很多的,首先定义草的Mesh、Buffer传递(草的宽度、高度随机、每个草生成的位置、草地的随机朝向、草的随机色深)、还要专门向Compute Shader传递最大的弯曲值、草地互动半径。

    每一帧CPU还要向Compute Shader传递时间变量、风向、风力/速、风场缩放因子。

    Compute Shader利用CPU传递的信息计算出草应该怎么转向,使用了四元数作为输出。

    最后Shader通过实例化标示ID和所有计算结果,首先计算顶点偏移,然后应用四元数旋转,最后修改法线信息。

    这个Demo其实可以进一步优化,比如将更多的计算放在Compute Shader中进行,比如生成Mesh的过程、草地的宽高、随机朝向倾倒等。还可以优化一下更多实时的参数调节变量。还可以将做各种优化剔除,比如传入相机位置通过距离来剔除、或者用视锥体剔除等等,这个剔除的过程就需要使用到一些原子操作。还可以多物体交互。还可以优化交互草地变形的逻辑,比如交互的程度与交互物体的距离呈次方的关系等。还可以增加引擎功能,开发出笔刷刷草的功能,这就有可能需要一套四叉树存储系统等等。

    并且在Compute Shader中,能用向量一把梭哈就不要用标量。

    首先先整理一下代码。将不需要每帧都发给Compute Shader的变量都放在一个函数统一初始化。将Inspector面板整理一下。(代码改动很多)

    首先将基本上所有的计算都放在GPU上运行了,除了每个草的世界坐标在CPU中计算,通过一个Buffer传给GPU。

    Buffer传输的大小则完全取决于地面Mesh的大小与设置的密度。也就是说,如果是超级大的开放世界,这个Buffer就会变得超级大。一个 5*5 大小的草地,将Density设置为0.5,就大约会发送 312576 个草数据,实际数据就会达到 4*312576*4=5001216 字节,按照CPU->GPU的传输速度为8 GB/s 来计算,大约需要传10毫秒左右。

    万幸这个Buffer并不是每一帧都需要传输,但是也足够引起我们的重视。假如当前草地大小变大到 100*100,所需时间将翻数倍,很吓人。而且这其中很多顶点我们都可能用不到,这就造成了很大的性能浪费。

    我在Compute Shader里面加入了生成perlin噪声的函数,还有xorshift128随机数生成算法。

    // Perlin 随机数算法
    float hash(float x, float y) {
        return frac(abs(sin(sin(123.321 + x) * (y + 321.123)) * 456.654));
    }
    float perlin(float x, float y){
        float col = 0.0;
        for (int i = 0; i < 8; i++) {
            float fx = floor(x); float fy = floor(y);
            float cx = ceil(x); float cy = ceil(y);
            float a = hash(fx, fy); float b = hash(fx, cy);
            float c = hash(cx, fy); float d = hash(cx, cy);
            col += lerp(lerp(a, b, frac(y)), lerp(c, d, frac(y)), frac(x));
            col /= 2.0; x /= 2.0; y /= 2.0;
        }
        return col;
    }
    // XorShift128 随机数算法 -- Edited 直接输出归一化数据
    uint state[4];
    void xorshift_init(uint s) {
        state[0] = s; state[1] = s | 0xffff0000u;
        state[2] = s << 16; state[3] = s >> 16;
    }
    float xorshift128() {
        uint t = state[3]; uint s = state[0];
        state[3] = state[2]; state[2] = state[1]; state[1] = s;
        t ^= t << 11u; t ^= t >> 8u;
        state[0] = t ^ s ^ (s >> 19u);
        return (float)state[0] / float(0xffffffffu);
    }
    
    [numthreads(THREADGROUPSIZE,1,1)]
    void BendGrass (uint3 id : SV_DispatchThreadID)
    {
        xorshift_init(id.x * 73856093u ^ id.y * 19349663u ^ id.z * 83492791u);
        ...
    }

    复盘一下,目前,在CPU用的是草地的一个AABB平均铺草的逻辑生成所有可能的草的顶点,然后传给GPU,在Compute Shader中做一些剔除、LoD等操作。

    目前为止我搞了三个Buffer。

    m_InputBuffer就是将所有的草一股脑传给GPU,没有任何剔除的。上图左边的结构体。

    m_OutputBuffer是一个变长的Buffer,在Compute Shader中慢慢增加的。如果当前线程ID的草适合,就会被加到这个Buffer中,用于一会的Instanced渲染。上图右边的结构体。

    m_argsBuffer是一个参数化的Buffer,类型和其他Buffer都不同的。最后用于Draw传参,具体内容就是指定了批量渲染的顶点数量、渲染实例数量等等。详细来看看:

    第一个参数,我的草Mesh有七个三角形,所以要渲染21个顶点。

    第二个参数暂时设置为0,表示啥也不需要渲染。这个数字会在Compute Shader计算结束后,根据m_OutputBuffer的长度来动态设置。也就是说,Compute Shader里Append了多少个草,这里就会变成多少。

    第三第四个参数分别表示:第一个渲染的顶点的索引、第一个实例化的索引。

    后面第五个参数我没用过,不知道有啥用。

    最后一步长这样,把Mesh、材质、AABB还有参数Buffer传进去了。

    5.2 自定义Unity工具

    新建一个C#脚本,存在项目的Editor目录下(没有就创建一个)。脚本继承自Editor,然后写上 [CustomEditor(typeof(XXX))] 。表示你是为XXX工作。我为GrassControl工作,然后可以将现在这个写的东西附加到XXX上。当然也可以单独一个窗口,应该就是继承自EditorWindow。

    在 OnInspectorGUI() 函数中写工具。比方说写一个Label。

    GUILayout.Label("== Remo Grass Generator ==");

    想要在Inspector居中,加一段参数。

    GUILayout.Label("== Remo Grass Generator ==", new GUIStyle(EditorStyles.boldLabel) { alignment = TextAnchor.MiddleCenter });

    位置太挤了?加一行空格就好。

    EditorGUILayout.Space();

    想在XXX的上方附加工具,那所有逻辑就写在OnInspectorGUI的上方。

    ... // 写在这
    // 默认的 GrassControl 的 Inspector 界面
    base.OnInspectorGUI();

    创建按钮,并且按下的代码:

    if (GUILayout.Button("xxx"))
    {
        ...//按下后的代码

    反正目前我用到的就这些。

    5.3 Editor选中对象生成草

    获取当前服务的脚本的Object,并且显示在Inspector上,也很简单。

    [SerializeField] private GameObject grassObject;
    ...
    grassObject = (GameObject)EditorGUILayout.ObjectField("名字随便写", grassObject, typeof(GameObject), true);
    if (grassObject == null)
    {
        grassObject = FindObjectOfType<GrassControl>()?.gameObject;
    }

    获取完了之后,就可以通过GameObject访问当前脚本里边的东西了。

    如何获取在Editor窗口选中的对象呢?一行代码就搞掂。

    foreach (GameObject obj in Selection.gameObjects)

    将选中的物体展示在Inspector面板上。注意,这里需要处理多选物体的情况,否则会Warning。

    // 实时显示当前Editor选中对象并控制按钮的可用性
    EditorGUILayout.LabelField("Selection Info:", EditorStyles.boldLabel);
    bool hasSelection = Selection.activeGameObject != null;
    GUI.enabled = hasSelection;
    if (hasSelection)
        foreach (GameObject obj in Selection.gameObjects)
            EditorGUILayout.LabelField(obj.name);
    else
        EditorGUILayout.LabelField("No active object selected.");

    接下来获取选中对象的MeshFilter和Renderer,由于要Raycast检测,就再获取个Collider。若没有就创建一个。

    然后写生草的代码,这里就不说了。

    5.4 处理AABB

    生成完一堆草后,要将每个草加到AABB里面,最后传给Instancing。

    我假设每个草都是一个单位立方体的大小,所以是Vector3.one。如果草特别高,这里应该是需要修改的。

    将每个草都塞进大的AABB中,将新的AABB传回给脚本的m_LocalBounds,给Instancing用。

    Graphics.DrawMeshInstancedIndirect(blade, 0, m_Material, m_LocalBounds, m_argsBuffer);

    5.5 Surface Shader – 踩坑

    这里有个小问题,由于当前Material是Surface Shader,Surface Shader的Vertex已经默认计算了AABB的center做了顶点偏移,所以之前传进去的世界坐标就不能直接用。还需要传AABB的center进去,减掉才行。好奇怪啊,不知道有没有什么优雅的方法。

    5.6 简单的摄像机距离剔除+渐隐

    目前在CPU将所有生成的草都传进了Compute Shader中,然后所有的草都会加进AppendBuffer中。也就是说没有任何剔除逻辑可言。

    最简单的剔除方案就是根据摄像机与草地的距离做剔除。在Inspector面板开放一个数值表示剔除距离。计算摄像机与当前草实例的距离,如果大于设定的数值,则不添加到AppendBuffer中。

    首先在 C# 中传入相机的世界坐标。下面是半伪代码:

    // 获取摄像机
    private Camera m_MainCamera;
    
    m_MainCamera = Camera.main;
    
    if (m_MainCamera != null)
        m_ComputeShader.SetVector(ID_camreaPos, m_MainCamera.transform.position);

    CS中,计算草地和摄像机的距离:

    float distanceFromCamera = distance(input.position, _CameraPositionWS);

    距离函数代码如下:

    float distanceFade = 1 - saturate((distanceFromCamera - _MinFadeDist) / (_MaxFadeDist - _MinFadeDist));

    如果数值小于0,就直接return。

    // skip if out of fading range too
    if (distanceFade < 0.001f)
    {
        return;
    }

    在剔除与不剔除之间的部分,设置一下草的宽度+Fade值,达到渐隐的效果。

    result.height = (bladeHeight + bladeHeightOffset * (xorshift128()*2-1)) * distanceFade;
    result.width = (bladeWeight + bladeWeightOffset * (xorshift128()*2-1)) * distanceFade;
    ...
    result.fade = xorshift128() * distanceFade;

    下图为了方便演示,把两个都设置得比较小。

    实际效果我觉得还是很不错的,十分流畅。如果不修改草的宽高,效果就会大打折扣。

    当然了,也可以修改一下逻辑:不要完全剔除超过最大绘制范围的草,而是减少绘制的数量;或者是在过渡区的草选择性的绘制。

    两种逻辑都可以,如果是我我会选择后者。

    5.7 维护一组可视ID Buffer

    所谓视锥体剔除,就是在CPU阶段,通过各种方法减少GPU多余的计算。

    那怎么让Compute Shader知道哪些草需要渲染,哪些需要Cull呢?我的做法是维护一组ID List。长度是所有草的数量。如果当前草需要被剔除,否则就记录需要渲染的草的索引值。

    List<uint> grassVisibleIDList = new List<uint>();
    
    // buffer that contains the ids of all visible instances
    private ComputeBuffer m_VisibleIDBuffer;
    
    private const int VISIBLE_ID_STRIDE        =  1 * sizeof(uint);
    
    m_VisibleIDBuffer = new ComputeBuffer(grassData.Count, VISIBLE_ID_STRIDE,
        ComputeBufferType.Structured); //uint only, per visible grass
    m_ComputeShader.SetBuffer(m_ID_GrassKernel, "_VisibleIDBuffer", m_VisibleIDBuffer);
    
    m_VisibleIDBuffer?.Release();

    既然在传入Compute Shader之前,就已经有一部分草被剔除了,那么Dispatch的数量就不再是所有草的数量,而是当前List的数量。

    // m_ComputeShader.Dispatch(m_ID_GrassKernel, m_DispatchSize, 1, 1);
    
    m_DispatchSize = Mathf.CeilToInt(grassVisibleIDList.Count / threadGroupSize);

    生成一个全部可视的ID序列。

    void GrassFastList(int count)
    {
        grassVisibleIDList = Enumerable.Range(0, count).ToArray().ToList();
    }

    并且每一帧都应用上传到GPU中。准备工作就完成了,接下来用Quad树操作这个数组。

    5.8 四/八叉树存储草索引

    可以考虑将一个AABB划分为多个子AABB,然后用四叉树存储管理。

    目前,所有的草都在一个AABB里面。接下来构建一个八叉树,将这个AABB中的所有草都放进各个分支中。这样就很方便的在CPU前期做视锥体剔除。

    怎么存呢?如果当前的草地垂直落差较小,那么用四叉树就足够了。那如果是开放世界,山脉高低起伏的,那就用八叉树。但是考虑到草是水平的密度比较高,我这里使用了一个四叉树+八叉树的结构。根据深度的奇偶来决定当前深度是分四个节点还是八个节点。如果不需要强烈的高度划分,就全用八叉树也行,我感觉效率可能会低一点点。这里直接一把平均分配,后期优化可以考虑根据变长动态变化的划分AABB方式。

    if (depth % 2 == 0)
    {
        ...
        m_children.Add(new CullingTreeNode(topLeftSingle, depth - 1));
        m_children.Add(new CullingTreeNode(bottomRightSingle, depth - 1));
        m_children.Add(new CullingTreeNode(topRightSingle, depth - 1));
        m_children.Add(new CullingTreeNode(bottomLeftSingle, depth - 1));
    }
    else
    {
        ...
        m_children.Add(new CullingTreeNode(topLeft, depth - 1));
        m_children.Add(new CullingTreeNode(bottomRight, depth - 1));
        m_children.Add(new CullingTreeNode(topRight, depth - 1));
        m_children.Add(new CullingTreeNode(bottomLeft, depth - 1));
    
        m_children.Add(new CullingTreeNode(topLeft2, depth - 1));
        m_children.Add(new CullingTreeNode(bottomRight2, depth - 1));
        m_children.Add(new CullingTreeNode(topRight2, depth - 1));
        m_children.Add(new CullingTreeNode(bottomLeft2, depth - 1));
    }

    视锥体与AABB的检测用 GeometryUtility.TestPlanesAABB 就好了。

    public void RetrieveLeaves(Plane[] frustum, List<Bounds> list, List<int> visibleIDList)
    {
        if (GeometryUtility.TestPlanesAABB(frustum, m_bounds))
        {
            if (m_children.Count == 0)
            {
                if (grassIDHeld.Count > 0)
                {
                    list.Add(m_bounds);
                    visibleIDList.AddRange(grassIDHeld);
                }
            }
            else
            {
                foreach (CullingTreeNode child in m_children)
                {
                    child.RetrieveLeaves(frustum, list, visibleIDList);
                }
            }
        }
    }

    这段代码是关键部分,传入:

    • 摄像机视锥体的六个平面 Plane[]
    • 存储所有在视锥体内节点的 Bounds 对象的列表
    • 存储所有在视锥体内节点包含的草地索引的列表

    调用这个四/八叉树的方法,就可以得到所有在视锥体内的包围盒列表、草地列表。

    然后就可以将得到的所有草地索引做成一个Buffer传给Compute Shader。

    m_VisibleIDBuffer.SetData(grassVisibleIDList);

    为了得到可视化的AABB,可以用 OnDrawGizmos() 方法。

    将刚刚视锥体剔除得到的所有AABB传进这个函数。这样就可以直观看到AABB了。

    还要将所有在视锥体内的写入可见草中。

    5.9 草叶闪烁问题 – 踩坑

    在这里我踩了一个小坑。当我完整了八叉树的编写,并且成功像上图一样划分出了诸多子AABB。但是当我移动摄像头的时候,草在疯狂闪烁。GIF视频啥的我有点懒不想弄,观察一下下面两张图,我只是稍微移动了一下视角,并且改变了当前Visibility List。草的位置就会大跳跃,连续地看就是草在闪烁。

    我百思不得其解,Compute Shader的剔除也没问题。

    Dispatch数量也是根据Visibility List的长度来运算的,因此计算着色器的线程肯定是开够的。

    并且DrawMeshInstancedIndirect也没问题。

    问题出在哪呢?

    经过漫长的调试,我发现问题出在Compute Shader的Xorshift取随机数的过程。

    在使用_VisibleIDBuffer之前,一个草对应一个线程ID,这是从草出生那一刻就已经决定的了。而现在加入了这一组索引,又不将传入随机值的ID改成 Visible ID ,就会出现随机数字非常离散的感觉。

    也就是将之前的id全部都换成从_VisibleIDBuffer 取的索引值!

    5.10 多物体交互

    目前只有一个trampler传入。不传还会报错,不能忍。

    关于交互的参数有三个:

    • pos – Vector3
    • trampleStrength – Float
    • trampleRadius – Float

    现在将trampleRadius塞进pos(Vector4)里面(塞另外一个也行,看需求),用SetVectorArray将位置数组传进去。这样每个交互对象都可以拥有一个专用的交互半径。肥肥的交互物体半径调大一些,瘦瘦的就小一些。也就是将下面这行去掉:

    // SetGrassDataBase中,不需要每帧上传
    // m_ComputeShader.SetFloat("trampleRadius", trampleRadius);

    变成:

    // SetGrassDataUpdate中,每帧都要上传
    // 设置多交互物体
    if (trampler.Length > 0)
    {
        Vector4[] positions = new Vector4[trampler.Length];
        for (int i = 0; i < trampler.Length; i++)
        {
            positions[i] = new Vector4(trampler[i].transform.position.x, trampler[i].transform.position.y, trampler[i].transform.position.z,
                trampleRadius);
        }
        m_ComputeShader.SetVectorArray(ID_tramplePos, positions);
    }

    然后还得传一个交互物体的数量,让Compute Shader知道需要处理多少个交互物体。这个也是需要每一帧更新的。我习惯为每一帧都更新的物体存储一个ID索引,这样效率更高。

    // 初始化中
    ID_trampleLength = Shader.PropertyToID("_trampleLength");
    // 每帧中
    m_ComputeShader.SetFloat(ID_trampleLength, trampler.Length);

    我再包装了一下:

    对应代码再修改一下,就可以在面板上随便调整每个交互物体的半径了。如果要丰富这个调节功能,可以考虑单独传一个Buffer进去。

    在Compute Shader中,并且多个旋转组合起来,还是比较简单的。

    // Trampler
    float4 qt = float4(0, 0, 0, 1); // 四元数里的1就是这样的,虚部都是0
    for (int trampleIndex = 0; trampleIndex < trampleLength; trampleIndex++)
    {
        float trampleRadius = tramplePos[trampleIndex].a;
        float3 relativePosition = input.position - tramplePos[trampleIndex].xyz;
        float dist = length(relativePosition);
        if (dist < trampleRadius) {
            // 使用次方增强近距离的效果
            float eff = pow((trampleRadius - dist) / trampleRadius, 2) * trampleStrength;
            float3 direction = normalize(relativePosition);
            float3 newTargetDirection = float3(direction.x * eff, 1, direction.z * eff);
            qt = quatMultiply(MapVector(float3(0, 1, 0), newTargetDirection), qt);
        }
    }

    5.11 Editor实时预览

    当前传给Compute Shader的摄像机是主相机,也就是游戏窗口那个。现在想要在编辑(Scene窗口)暂时得到主摄像机的镜头,启动游戏之后复原。可以使用 Scene View GUI 绘制事件。

    以下是改造我当前代码的例子:

    #if UNITY_EDITOR
        SceneView view;
    
        void OnDestroy()
        {
            // When the window is destroyed, remove the delegate
            // so that it will no longer do any drawing.
            SceneView.duringSceneGui -= this.OnScene;
        }
    
        void OnScene(SceneView scene)
        {
            view = scene;
            if (!Application.isPlaying)
            {
                if (view.camera != null)
                {
                    m_MainCamera = view.camera;
                }
            }
            else
            {
                m_MainCamera = Camera.main;
            }
        }
        private void OnValidate()
        {
            // Set up components
            if (!Application.isPlaying)
            {
                if (view != null)
                {
                    m_MainCamera = view.camera;
                }
            }
            else
            {
                m_MainCamera = Camera.main;
            }
        }
    #endif

    在初始化着色器的时候,在开头订阅事件,然后判断当前是否为游戏状态,是才传递一个摄像机。如果是编辑模式,那m_MainCamera这一项还是NULL。

    void InitShader()
    {
    #if UNITY_EDITOR
        SceneView.duringSceneGui += this.OnScene;
        if (!Application.isPlaying)
        {
            if (view != null && view.camera != null)
            {
                m_MainCamera = view.camera;
            }
        }
    #endif
        if (Application.isPlaying)
        {
            m_MainCamera = Camera.main;
        }
        ...

    在逐帧Update的函数中,如果检测到m_MainCamera是NULL,那么断定当前是编辑模式:

    // 传入摄像机坐标
            if (m_MainCamera != null)
                m_ComputeShader.SetVector(ID_camreaPos, m_MainCamera.transform.position);
    #if UNITY_EDITOR
            else if (view != null && view.camera != null)
            {
                m_ComputeShader.SetVector(ID_camreaPos, view.camera.transform.position);
            }
    
    #endif

    六、砍草

    维护一组Cut Buffer

    // added for cutting
    private ComputeBuffer m_CutBuffer;
    float[] cutIDs;

    初始化Buffer

    private const int CUT_ID_STRIDE            =  1 * sizeof(float);
    // added for cutting
    m_CutBuffer = new ComputeBuffer(grassData.Count, CUT_ID_STRIDE, ComputeBufferType.Structured);
    // added for cutting
    m_ComputeShader.SetBuffer(m_ID_GrassKernel, "_CutBuffer", m_CutBuffer);
    m_CutBuffer.SetData(cutIDs);

    别忘了在Disable的时候释放。

    // added for cutting
    m_CutBuffer?.Release();

    定义一个方法,传入当前位置和半径,计算草的位置。将对应cutID设为-1。

    // newly added for cutting
    public void UpdateCutBuffer(Vector3 hitPoint, float radius)
    {
        // can't cut grass if there is no grass in the scene
        if (grassData.Count > 0)
        {
            List<int> grasslist = new List<int>();
            // Get the list of IDS that are near the hitpoint within the radius
            cullingTree.ReturnLeafList(hitPoint, grasslist, radius);
            Vector3 brushPosition = this.transform.position;
            // Compute the squared radius to avoid square root calculations
            float squaredRadius = radius * radius;
    
            for (int i = 0; i < grasslist.Count; i++)
            {
                int currentIndex = grasslist[i];
                Vector3 grassPosition = grassData[currentIndex].position + brushPosition;
    
                // Calculate the squared distance
                float squaredDistance = (hitPoint - grassPosition).sqrMagnitude;
    
                // Check if the squared distance is within the squared radius
                // Check if there is grass to cut, or of the grass is uncut(-1)
                if (squaredDistance <= squaredRadius && (cutIDs[currentIndex] > hitPoint.y || cutIDs[currentIndex] == -1))
                {
                    // store cutting point
                    cutIDs[currentIndex] = hitPoint.y;
                }
    
            }
        }
        m_CutBuffer.SetData(cutIDs);
    }

    然后在需要砍草的对象身上绑一个脚本:

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    
    public class Cutgrass : MonoBehaviour
    {
        [SerializeField]
        GrassControl grassComputeScript;
    
        [SerializeField]
        float radius = 1f;
    
        public bool updateCuts;
    
        Vector3 cachedPos;
        // Start is called before the first frame update
    
    
        // Update is called once per frame
        void Update()
        {
            if (updateCuts && transform.position != cachedPos)
            {
                Debug.Log("Cutting");
                grassComputeScript.UpdateCutBuffer(transform.position, radius);
                cachedPos = transform.position;
    
            }
        }
    
        private void OnDrawGizmos()
        {
            Gizmos.color = new Color(1, 0, 0, 0.3f);
            Gizmos.DrawWireSphere(transform.position, radius);
        }
    }

    在Compute Shader中,直接修改草的高度。(非常直截了当。。。)想改啥效果就随意了。

    StructuredBuffer<float> _CutBuffer;// added for cutting
    
        float cut = _CutBuffer[usableID];
        result.height = (bladeHeight + bladeHeightOffset * (xorshift128()*2-1)) * distanceFade;
        if(cut != -1){
            result.height *= 0.1f;
        }

    完工!

    References

    1. https://learn.microsoft.com/zh-cn/windows/uwp/graphics-concepts/geometry-shader-stage–gs-
    2. https://roystan.net/articles/grass-shader/
    3. https://danielilett.com/2021-08-24-tut5-17-stylised-grass/
    4. https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
    5. 筆記-初探compute-shader
    6. https://www.patreon.com/posts/53587750
    7. https://www.youtube.com/watch?v=xKJHL8nQiuM
    8. https://www.patreon.com/posts/40090373
    9. https://www.patreon.com/posts/47447321
    10. https://www.patreon.com/posts/wip-patron-only-83683483
    11. https://www.youtube.com/watch?v=DeATXF4Szqo
    12. https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
    13. https://docs.unity3d.com/Manual/class-ComputeShader.html
    14. https://docs.unity3d.com/ScriptReference/ComputeShader.html
    15. https://learn.microsoft.com/en-us/windows/win32/api/D3D11/nf-d3d11-id3d11devicecontext-dispatch
    16. https://zhuanlan.zhihu.com/p/102104374
    17. unity-compute-shader-基礎認識
    18. https://kylehalladay.com/blog/tutorial/2014/06/27/Compute-Shaders-Are-Nifty.html
    19. https://cuihongzhi1991.github.io/blog/2020/05/27/builtinttourp/
    20. https://jadkhoury.github.io/files/MasterThesisFinal.pdf

  • Compute Shader学习笔记(四)之 草地渲染

    Compute Shader学习笔记(四)之 草地渲染

    项目地址:

    https://github.com/Remyuu/Unity-Compute-Shader-Learngithub.com/Remyuu/Unity-Compute-Shader-Learn

    img

    视频封面

    L5 草地渲染

    当前做的效果非常丑陋,还有很多细节没有完善,仅仅是“实现”了。由于我也是菜鸡,写/做的不够好的地方望各位指正。

    img

    知识点小结:

    • 草地渲染方案
    • UNITY_PROCEDURAL_INSTANCING_ENABLED
    • bounds.extents
    • 射线检测
    • 罗德里格旋转
    • 四元数旋转

    前言1

    前言参考文章:

    img

    草地渲染有很多方法。

    最简单的是直接一张草地的纹理贴上去。

    img

    除此之外,将一个个Mesh草拖到场景中也很常见。这种方法操作空间大,每一颗草都在掌控中。虽然可以用Batching等方法优化,减少CPU到GPU的传输时间,但是这会损耗您键盘上的Ctrl、C、V和D键的寿命。不过可以在Transform组件里面用 L(a, b) 让选中的物体平均分布在 a 和 b 之间。想随机,可以用 R(a, b) 。更多相关的操作可以看官方文档

    img

    还可以结合几何着色器和曲面细分着色器,这个方法看起来不错的,但是一个着色器只能对应一种几何(草),如果想要在这个网格生成花或者岩石,就需要在几何着色器中修改代码。这个问题其实不是最关键的,更要命的问题是很多移动设备还有Metal根本就不支持几何着色器,就算支持也只是软件模拟的,性能差劲。并且每一帧都会重新计算一次草地Mesh,浪费性能。

    img

    广告牌技术渲染草也是一种广泛流传经久不衰的方法。当我们不需要高保真的画面时,这个方法非常奏效。这个方法是简单的渲一个Quad+贴图(Alpha裁切)。用DrawProcedural就可以了。但是这个方法只可远观不可近看,否则就会大露馅。

    img

    用Unity的地形系统也可以画出非常nice的草。并且Unity使用了instancing技术确保了性能。其中最好用的地方莫过于他的笔刷工具,但是如果你的工作流没有地形系统的身影,那么你还可以用第三方插件做到。

    img

    在搜索资料的时候我还发现了一种叫Impostors「冒名顶替」技术。结合了广告牌的顶点节省优势和从多个角度真实重现对象的能力,还挺有意思。这个技术通过预先从多个角度“拍下”一个真实草的Mesh照片,通过Texture存起来。运行的时候根据当前相机的观看方向选择合适的纹理进行渲染。相当于广告牌技术的升级版。我认为Impostors技术非常适合用于那些大型但玩家可能需要从多个角度查看的对象,如树木或复杂建筑。然而,当相机非常接近或者在两个角度之间变换时,这种方法可能会出现问题。比较合理的方案是:在距离非常近用基于Mesh的方法,中等距离用Impostors,远距离用广告牌。

    img

    本文要实现的方法是基于GPU Instancing的,应该称之为「per-blade mesh grass」。在《對馬島之魂》、《原神》和《薩爾達傳說:曠野之息》等游戏上都是使用这种方案。每个草都有自己的实体,光影效果也相当真实。

    img

    渲染流程:

    img

    前言2

    Unity的Instancing技术比较复杂,我也只是管中窥豹,出现错误请指正。目前的代码都是仿照文档写的。GPU instancing目前支持的平台:

    • Windows: DX11 and DX12 with SM 4.0 and above / OpenGL 4.1 and above
    • OS X and Linux: OpenGL 4.1 and above
    • Mobile: OpenGL ES 3.0 and above / Metal
    • PlayStation 4
    • Xbox One

    另外Graphics.DrawMeshInstancedIndirect目前已经淘汰了,应该使用 Graphics.RenderMeshIndirect ,这个函数会自动计算Bounding Box,这个就是后话了。详细请看官方文档:RenderMeshIndirect 。这篇文章也很有帮助:

    https://zhuanlan.zhihu.com/p/403885438

    GPU Instancing原理是将多个具有相同Mesh的对象发一次Draw Call。CPU首先收集好所有信息,然后放到数组里一次性发给GPU。局限就是这些对象的Material和Mesh都要相同。这就是一次能绘制这么多草而保持高性能的原理。要实现GPU Instancing绘制上百万的Mesh,就需要遵循一些规定:

    • 所有的网格需使用相同的Material
    • 勾选GPU Instancing
    • Shader需支持实例化
    • 不支持Skin Mesh Renderer

    由于不支持Skin Mesh Renderer,在上一篇文章中,我们绕过了SMR,直接取了不同关键帧的Mesh出来传给GPU,这也是上一篇文章最后提出那个问题的原因。

    Unity中的Instancing分为两种主要类型:GPU Instancing和Procedural Instancing(涉及到Compute Shaders和Indirect Drawing技术),还有一种是立体渲染路径(UNITY_STEREO_INSTANCING_ENABLED),这里就不深入了。在Shader中,前者用#pragma multi_compile_instancing 后者用#pragma instancing_options procedural:setup 。具体的请看官方文档Creating shaders that support GPU instancing

    然后目前SRP管线不支持自定义的GPU Instancing Shader,只有BIRP可以。

    然后就是UNITY_PROCEDURAL_INSTANCING_ENABLED 。这个宏用于表示是否启用了Procedural Instancing。在使用Compute Shader或Indirect Drawing API时,实例的属性(如位置、颜色等)可以在GPU上实时计算并直接用于渲染,无需CPU的介入。在源代码中,关于这个宏的核心代码是:

    #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        #ifndef UNITY_INSTANCING_PROCEDURAL_FUNC
            #error "UNITY_INSTANCING_PROCEDURAL_FUNC must be defined."
        #else
            void UNITY_INSTANCING_PROCEDURAL_FUNC(); // 前向声明程序化函数
            #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input)      { UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input)); UNITY_INSTANCING_PROCEDURAL_FUNC();}
        #endif
    #else
        #define DEFAULT_UNITY_SETUP_INSTANCE_ID(input)          { UnitySetupInstanceID(UNITY_GET_INSTANCE_ID(input));}
    #endif

    要求Shader定义一个UNITY_INSTANCING_PROCEDURAL_FUNC函数,其实就是 setup() 函数。没有这个setup()函数,就会报错。

    一般来说,setup()函数要做的就是从Buffer中取出对应(unity_InstanceID) 的数据,然后计算当前实例的位置、变换矩阵、颜色、金属度或者是自定义数据等属性。

    GPU Instancing只是Unity众多优化手段的一种,仍然需要继续学习。

    1. 摇曳的3-Quad草

    这一章所运用关于CS的知识点在上一篇文章都已全部涉及,只不过换一个背景罢了。简单画一个示意图。

    img

    实现是使用GPU Instancing,也就是一次性渲染一大片Mesh。核心的代码就一句:

    Graphics.DrawMeshInstancedIndirect(mesh, 0, material, bounds, argsBuffer);

    Mesh采用三个Quad共六个三角形组成。

    img

    然后上一张贴图+Alpha Test。

    img

    草的数据结构:

    • 位置
    • 倾斜角度
    • 随机噪声值(用于计算随机的倾斜角度)
    public Vector3 position; // 世界坐标,需要计算
    public float lean;
    public float noise;
    public GrassClump( Vector3 pos){
        position.x = pos.x;
        position.y = pos.y;
        position.z = pos.z;
        lean = 0;
        noise = Random.Range(0.5f, 1);
        if (Random.value < 0.5f) noise = -noise;
    }

    将需要渲染的草的Buffer(世界坐标需要计算)传给GPU。首先确定草在哪里生成、生成多少。获取当前物体的Mesh(暂时假设是一个Plane Mesh)的AABB。

    Bounds bounds = mf.sharedMesh.bounds;
    Vector3 clumps = bounds.extents;
    img

    确定草的范围,然后在xOz平面上随机生成草。

    img

    添加图片注释,不超过 140 字(可选)

    需要注意,当前还是在物体空间,因此需要将Object Space转换到World Space。

    pos = transform.TransformPoint(pos);

    再结合密度density参数和物体缩放系数,计算出一共要渲染多少个草。

    Vector3 vec = transform.localScale / 0.1f * density;
    clumps.x *= vec.x;
    clumps.z *= vec.z;
    int total = (int)clumps.x * (int)clumps.z;

    由于Compute Shader的逻辑是每个线程计算一棵草,极有可能需要渲染的草的数量不是线程的倍数。因此将需要渲染的草的数量向上取整到线程的倍数。也就是说,当密度因子=1的时候,渲染的草的数量等于一个线程组中线程的数量。

    groupSize = Mathf.CeilToInt((float)total / (float)threadGroupSize);
    int count = groupSize * (int)threadGroupSize;

    让Compute Shader计算每个草的倾斜角度。

    GrassClump clump = clumpsBuffer[id.x];
    clump.lean = sin(time) * maxLean * clump.noise;
    clumpsBuffer[id.x] = clump;

    将草的位置、旋转角度传给GPU Buffer还没完,还得拜托Material决定渲染实例的最终外观,才能最终执行Graphics.DrawMeshInstancedIndirect。

    渲染流程中,在实例化阶段之前(也就是procedural:setup函数内),使用unity_InstanceID确定现在渲的是哪个草。获取当前草的世界空间,草的倾倒值。

    GrassClump clump = clumpsBuffer[unity_InstanceID];
    _Position = clump.position;
    _Matrix = create_matrix(clump.position, clump.lean);

    具体的旋转+位移矩阵:

    float4x4 create_matrix(float3 pos, float theta){
        float c = cos(theta); // 计算旋转角度的余弦值
        float s = sin(theta); // 计算旋转角度的正弦值
        // 返回一个4x4变换矩阵
        return float4x4(
            c, -s, 0, pos.x, // 第一行:X轴旋转和位移
            s,  c, 0, pos.y, // 第二行:Y轴旋转(对于2D足够,但草丛可能不使用)
            0,  0, 1, pos.z, // 第三行:Z轴不变
            0,  0, 0, 1     // 第四行:均匀坐标(保持不变)
        );
    }

    这个公式怎么推的呢?将(0,0,1)带入罗德里格斯公式得到一个的旋转矩阵,然后扩展到重心坐标。带入 就是代码的公式了。

    img

    用这个矩阵乘上Object Space的顶点,得到倾倒+位移的顶点坐标。

    v.vertex.xyz *= _Scale;
    float4 rotatedVertex = mul(_Matrix, v.vertex);
    v.vertex = rotatedVertex;

    这时候问题来了。目前草并不是一个平面,而是三组Quad组成的立体图形。

    img

    如果简单的将所有顶点按照z轴旋转,就会出现草根大偏移的问题。

    img

    因此借助 v.texcoord.y ,将旋转前后的顶点位置lerp起来。这样,纹理坐标的Y值越高(即顶点在模型上的位置越靠近顶部),顶点受到的旋转影响就越大。由于草根的Y值为0,lerp之后草根就不会乱晃了。

    v.vertex.xyz *= _Scale;
    float4 rotatedVertex = mul(_Matrix, v.vertex);
    // v.vertex = rotatedVertex;
    v.vertex.xyz += _Position;
    v.vertex = lerp(v.vertex, rotatedVertex, v.texcoord.y);

    效果很差,草太假了。这种Quad草只有在远处用用。

    • 摆动僵硬
    • 叶片僵硬
    • 光影效果很差
    img

    当前版本代码:

    2. 程式化草叶

    上一节用几个Quad和带Alpha贴图的草,用sin wave做扰动,效果非常一般。现在用程式化的草和Perlin噪声改善。

    在 C# 中定义草的顶点、法线和uv作为Mesh传到GPU上。

    Vector3[] vertices =
    {
        new Vector3(-halfWidth, 0, 0),
        new Vector3( halfWidth, 0, 0),
        new Vector3(-halfWidth, rowHeight, 0),
        new Vector3( halfWidth, rowHeight, 0),
        new Vector3(-halfWidth*0.9f, rowHeight*2, 0),
        new Vector3( halfWidth*0.9f, rowHeight*2, 0),
        new Vector3(-halfWidth*0.8f, rowHeight*3, 0),
        new Vector3( halfWidth*0.8f, rowHeight*3, 0),
        new Vector3( 0, rowHeight*4, 0)
    };
    Vector3 normal = new Vector3(0, 0, -1);
    Vector3[] normals =
    {
        normal, normal, normal, normal, normal, normal, normal, normal, normal
    };
    Vector2[] uvs =
    {
        new Vector2(0,0),
        new Vector2(1,0),
        new Vector2(0,0.25f),
        new Vector2(1,0.25f),
        new Vector2(0,0.5f),
        new Vector2(1,0.5f),
        new Vector2(0,0.75f),
        new Vector2(1,0.75f),
        new Vector2(0.5f,1)
    };

    Unity的Mesh还有一个顶点顺序需要设定,默认是逆时针。如果顺时针写并且开启背面剔除,那就啥也看不见了。

    img
    int[] indices =
    {
        0,1,2,1,3,2,//row 1
        2,3,4,3,5,4,//row 2
        4,5,6,5,7,6,//row 3
        6,7,8//row 4
    };
    mesh.SetIndices(indices, MeshTopology.Triangles, 0);

    在代码那边设置好风的方向、大小还有噪声比重,打包进一个float4里面,传给Compute Shader计算一片草叶的摆动方向。

    Vector4 wind = new Vector4(Mathf.Cos(theta), Mathf.Sin(theta), windSpeed, windScale);

    一个草叶的数据结构

    struct GrassBlade
    {
        public Vector3 position;
        public float bend; // 随机草叶倾倒
        public float noise;// CS计算噪声值
        public float fade; // 随机草叶明暗
        public float face; // 叶片朝向
        public GrassBlade( Vector3 pos)
        {
            position.x = pos.x;
            position.y = pos.y;
            position.z = pos.z;
            bend = 0;
            noise = Random.Range(0.5f, 1) * 2 - 1;
            fade = Random.Range(0.5f, 1);
            face = Random.Range(0, Mathf.PI);
        }
    }

    当前的草叶都是一个方向的。Setup函数里,先修改叶片朝向。

    // 创建绕Y轴的旋转矩阵(面向)
    float4x4 rotationMatrixY = AngleAxis4x4(blade.position, blade.face, float3(0,1,0));
    img

    将草叶倾倒的逻辑(由于AngleAxis4x4是包含了位移,下图只是单独演示了叶片倾倒而没有随机朝向,如果要得到下图的效果代码中记得加入位移):

    // 创建绕X轴的旋转矩阵(倾倒)
    float4x4 rotationMatrixX = AngleAxis4x4(float3(0,0,0), blade.bend, float3(1,0,0));
    img

    然后合成两个旋转矩阵。

    _Matrix = mul(rotationMatrixY, rotationMatrixX);
    img

    现在的光照是非常奇怪的。因为法线没有修改。

    // 计算逆转置矩阵用于法线变换
    float3x3 normalMatrix = (float3x3)transpose(((float3x3)_Matrix));
    // 变换法线
    v.normal = mul(normalMatrix, v.normal);

    这里逆矩阵的代码:

    float3x3 transpose(float3x3 m)
    {
        return float3x3(
            float3(m[0][0], m[1][0], m[2][0]), // Column 1
            float3(m[0][1], m[1][1], m[2][1]), // Column 2
            float3(m[0][2], m[1][2], m[2][2])  // Column 3
        );
    }

    为了代码可读性,再补上齐次坐标变换矩阵,这里升级为那个著名的旋转公式:

    float4x4 AngleAxis4x4(float3 pos, float angle, float3 axis){
        float c, s;
        sincos(angle*2*3.14, s, c);
        float t = 1 - c;
        float x = axis.x;
        float y = axis.y;
        float z = axis.z;
        return float4x4(
            t * x * x + c    , t * x * y - s * z, t * x * z + s * y, pos.x,
            t * x * y + s * z, t * y * y + c    , t * y * z - s * x, pos.y,
            t * x * z - s * y, t * y * z + s * x, t * z * z + c    , pos.z,
            0,0,0,1
            );
    }
    img
    img
    img

    想要在不平坦的地面生成怎么办?

    img

    只需要修改生成草地初始位置高度的逻辑,用MeshCollider加射线检测,

    bladesArray = new GrassBlade[count];
    gameObject.AddComponent<MeshCollider>();
    RaycastHit hit;
    Vector3 v = new Vector3();
    Debug.Log(bounds.center.y + bounds.extents.y);
    v.y = (bounds.center.y + bounds.extents.y);
    v = transform.TransformPoint(v);
    float heightWS = v.y + 0.01f; // 浮点数误差
    v.Set(0, 0, 0);
    v.y = (bounds.center.y - bounds.extents.y);
    v = transform.TransformPoint(v);
    float neHeightWS = v.y;
    float range = heightWS - neHeightWS;
    // heightWS += 10; // 稍微调高一点 误差自行调整
    int index = 0;
    int loopCount = 0;
    while (index < count && loopCount < (count * 10))
    {
        loopCount++;
        Vector3 pos = new Vector3( Random.value * bounds.extents.x * 2 - bounds.extents.x + bounds.center.x,
            0,
            Random.value * bounds.extents.z * 2 - bounds.extents.z + bounds.center.z);
        pos = transform.TransformPoint(pos);
        pos.y = heightWS;
        if (Physics.Raycast(pos, Vector3.down, out hit))
        {
            pos.y = hit.point.y;
            GrassBlade blade = new GrassBlade(pos);
            bladesArray[index++] = blade;
        }
    }

    这里用射线检测每个草的位置,计算其正确高度。

    img

    还可以调整一下,海拔越高,草地越稀疏。

    img

    如上图。计算两个绿色箭头的比值,越高的海拔生成的概率越低。

    float deltaHeight = (pos.y - neHeightWS) / range;
    if (Random.value > deltaHeight)
    {
        // 生草
    }
    img
    img

    当前代码链接:

    现在光影啥的都没问题了。

    3. 交互草

    上一节中,我们先是旋转了草的朝向,又是改变了草的倾倒。现在我们还要加上一个旋转,当一个物体靠近草,就让草朝着与物体相反的方向伏倒。这意味着又来一个旋转。这个旋转并不好设置,因此改为四元数进行。而四元数的计算在Compute Shader进行。传给材质的也是四元数,存在草片的结构体中。最后在顶点着色器中将四元数转换回仿射矩阵应用旋转。

    这里再加入草的随机宽和身高。因为目前每个草Mesh都是一样的,没办法通过修改Mesh的方法修改草的高度。因此只能在Vert做顶点偏移了。

    // C#
    [Range(0,0.5f)]
    public float width = 0.2f;
    [Range(0,1f)]
    public float rd_width = 0.1f;
    [Range(0,2)]
    public float height = 1f;
    [Range(0,1f)]
    public float rd_height = 0.2f;
        GrassBlade blade = new GrassBlade(pos);
        blade.height = Random.Range(-rd_height, rd_height);
        blade.width = Random.Range(-rd_width, rd_width);
        bladesArray[index++] = blade;
    // Setup 开头
    GrassBlade blade = bladesBuffer[unity_InstanceID];
    _HeightOffset = blade.height_offset;
    _WidthOffset = blade.width_offset;
    // Vert 开头
    float tempHeight = v.vertex.y * _HeightOffset;
    float tempWidth = v.vertex.x * _WidthOffset;
    v.vertex.y += tempHeight;
    v.vertex.x += tempWidth;

    整理一下,当前的一个草Buffer存了:

    struct GrassBlade{
        public Vector3 position; // 世界坐标位置 - 需初始化
        public float height; // 草的身高偏移 - 需初始化
        public float width; // 草的宽度偏移 - 需初始化
        public float dir; // 叶片朝向 - 需初始化
        public float fade; // 随机草叶明暗 - 需初始化
        public Quaternion quaternion; // 旋转参数 - CS计算->Vert
        public float padding;
        public GrassBlade( Vector3 pos){
            position.x = pos.x;
            position.y = pos.y;
            position.z = pos.z;
            height = width = 0;
            dir = Random.Range(0, 180);
            fade = Random.Range(0.99f, 1);
            quaternion = Quaternion.identity;
            padding = 0;
        }
    }
    int SIZE_GRASS_BLADE = 12 * sizeof(float);

    用来表示从向量 v1 旋转到向量 v2 的四元数 q :

    float4 MapVector(float3 v1, float3 v2){
        v1 = normalize(v1);
        v2 = normalize(v2);
        float3 v = v1+v2;
        v = normalize(v);
        float4 q = 0;
        q.w = dot(v, v2);
        q.xyz = cross(v, v2);
        return q;
    }

    想要组合两个旋转的四元数,需要用乘法(注意顺序)。

    假设有两个四元数 和 。它们的乘积 计算公式是 :

    其中 是 的实部和虚部分量, 是 的实部和虚部分量。

    float4 quatMultiply(float4 q1, float4 q2) {
        // q1 = a + bi + cj + dk
        // q2 = x + yi + zj + wk
        // Result = q1 * q2
        return float4(
            q1.w * q2.x + q1.x * q2.w + q1.y * q2.z - q1.z * q2.y, // X component
            q1.w * q2.y - q1.x * q2.z + q1.y * q2.w + q1.z * q2.x, // Y component
            q1.w * q2.z + q1.x * q2.y - q1.y * q2.x + q1.z * q2.w, // Z component
            q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z  // W (real) component
        );
    }

    要确定草是往哪个地方倒,就需要获取交互物体trampler的Pos,也就是其Transform组件。并且每一帧都通过SetVector传到GPU Buffer中,给Compute Shader用,所以把GPU的内存地址当作ID存着,不需要每次都用字符串访问。还要确定多大范围内的草要倒下,倒与不倒之间怎么过渡,给GPU传一个 trampleRadius ,由于这个是常数,就不用每一帧都修改,因此直接用字符串Set一下就好了。

    // CSharp
    public Transform trampler;
    [Range(0.1f,5f)]
    public float trampleRadius = 3f;
    ...
    Init(){
        shader.SetFloat("trampleRadius", trampleRadius);
        tramplePosID = Shader.PropertyToID("tramplePos");
    }
    Update(){
        shader.SetVector(tramplePosID, pos);
    }

    本节把所有旋转的操作都丢进Compute Shader里面一次算完,直接返回一个四元数给材质。首先是q1计算随机朝向的四元数,q2计算随机倾倒,qt计算交互的倾倒。这里可以在Inspector开放一个交互的系数。

    [numthreads(THREADGROUPSIZE,1,1)]
    void BendGrass (uint3 id : SV_DispatchThreadID)
    {
        GrassBlade blade = bladesBuffer[id.x];
        float3 relativePosition = blade.position - tramplePos.xyz;
        float dist = length(relativePosition);
        float4 qt;
        if (dist<trampleRadius){
            float eff = ((trampleRadius - dist)/trampleRadius) * 0.6;
            qt = MapVector(float3(0,1,0), float3(relativePosition.x*eff,1,relativePosition.z*eff));
        }else{
            qt = MapVector(float3(0,1,0),float3(0,1,0));
        }
        float2 offset = (blade.position.xz + wind.xy * time * wind.z) * wind.w;
        float noise = perlin(offset.x, offset.y) * 2 - 1;
        noise *= maxBend;
        float4 q1 = MapVector(float3(0,1,0), (float3(wind.x * noise,1,wind.y*noise)));
        float faceTheta = blade.dir * 3.1415f / 180.0f;
        float4 q2 = MapVector(float3(1,0,0),float3(cos(faceTheta),0,sin(faceTheta)));
        blade.quaternion = quatMultiply(qt,quatMultiply(q2,q1));
        bladesBuffer[id.x] = blade;
    }

    然后四元数到旋转矩阵的方法是:

    float4x4 quaternion_to_matrix(float4 quat)
    {
        float4x4 m = float4x4(float4(0, 0, 0, 0), float4(0, 0, 0, 0), float4(0, 0, 0, 0), float4(0, 0, 0, 0));
        float x = quat.x, y = quat.y, z = quat.z, w = quat.w;
        float x2 = x + x, y2 = y + y, z2 = z + z;
        float xx = x * x2, xy = x * y2, xz = x * z2;
        float yy = y * y2, yz = y * z2, zz = z * z2;
        float wx = w * x2, wy = w * y2, wz = w * z2;
        m[0][0] = 1.0 - (yy + zz);
        m[0][1] = xy - wz;
        m[0][2] = xz + wy;
        m[1][0] = xy + wz;
        m[1][1] = 1.0 - (xx + zz);
        m[1][2] = yz - wx;
        m[2][0] = xz - wy;
        m[2][1] = yz + wx;
        m[2][2] = 1.0 - (xx + yy);
        m[0][3] = _Position.x;
        m[1][3] = _Position.y;
        m[2][3] = _Position.z;
        m[3][3] = 1.0;
        return m;
    }

    然后应用一下。

    void vert(inout appdata_full v, out Input data)
    {
        UNITY_INITIALIZE_OUTPUT(Input, data);
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        float tempHeight = v.vertex.y * _HeightOffset;
        float tempWidth = v.vertex.x * _WidthOffset;
        v.vertex.y += tempHeight;
        v.vertex.x += tempWidth;
        // 应用模型顶点变换
        v.vertex = mul(_Matrix, v.vertex);
        v.vertex.xyz += _Position;
        // 计算逆转置矩阵用于法线变换
        v.normal = mul((float3x3)transpose(_Matrix), v.normal);
        #endif
    }
    void setup()
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            // 获取Compute Shader计算结果
            GrassBlade blade = bladesBuffer[unity_InstanceID];
            _HeightOffset = blade.height_offset;
            _WidthOffset = blade.width_offset;
            _Fade = blade.fade; // 设置明暗
            _Matrix = quaternion_to_matrix(blade.quaternion); // 设置最终转转矩阵  
            _Position = blade.position; // 设置位置
        #endif
    }
    img
    img

    当前代码链接:

    4. 总结/小测试

    How do you programmatically get the thread group sizes of a kernel?

    img

    When defining a Mesh in code, the number of normals must be the same as the number of vertex positions. True or false.

    img
zh_CNCN