いつ撮っても映える!『hololive Dreams(ホロライブドリームス)』の背景表現

いつ撮っても映える!『hololive Dreams(ホロライブドリームス)』の背景表現

---
いつ撮っても映える!『hololive Dreams(ホロライブドリームス)』の背景表現

はじめに

私たちが開発する『hololive Dreams(ホロライブドリームス)』(略称「ホロドリ」)では、ゲーム内の背景が昼・夕・夜の時間帯によって変化します。 昼の青空・夕暮れ・夜の星空といった時間帯ごとの景色が、ゲームの世界観を彩る大切な要素になっています。

一方で、単純に太陽を回してライトの色を変えるだけでは、それらしい空の色や光にはならず、「いつ写真を撮っても映える」状態を保つのは簡単ではありません。

本記事では、この時間帯ごとの空とライティングをどう作っているのか、「映える」画作りとして成立させるために行った設計と実装の工夫を紹介します。

時間帯背景表現の全体像

まずは、実際の画をご覧ください。 以下は、ゲームの舞台となる「パーク」の1日を早送りで再生した映像です。

パークの背景が昼・夕・夜と変化していく様子

※この映像は確認用に時刻だけを直接操作して撮影したものです。そのため、実際のゲーム内とは見た目が異なる部分があります。

昼の青空から夕暮れ、そして星空へと、同じ場所がまったく違う表情を見せているのがわかるかと思います。

さて、ここからは実際にどのように実現していったのかを順に紹介していきます。

時間帯によって表情が変わる要素は、空・ライトシャフト・雲・ライティングと多岐にわたりますが、そのすべてが「現在の時刻」という共通の入力から作られています。 まずは、その土台となっている空のシステムの設計から見ていきます。

設計思想

全体構造 — Core・Module・Profileの3層

空のシステム(社内ではDynamicSkySystemと呼んでいます)は、大きく「Core」「Module」「Profile」の3層と、評価結果をUnityの世界へ反映する出口の「DynamicSky」で構成されています。

DynamicSkySystemの構成要素と毎フレームの更新の流れを示す図

それぞれの役割は次のとおりです。

  • CoreSkySystem — システム全体の駆動役です。ゲーム側から描画直前のタイミングで Update() を呼んでもらい、イベントを発火して各Moduleに更新を促します。評価済みのパラメータを集約して保持するのもCoreの役目です
  • Module — 機能単位の更新担当です。時刻を進める TimeModule、大気のパラメータを評価する AtmosphereModule などがあり、イベントを購読して自分の担当分だけを更新します
  • Profile — デザイナーが調整するデータ(ScriptableObject)です。空の色や太陽光の強さといったパラメータを、時刻を入力とするカーブ・グラデーションとして持ちます
  • DynamicSky — 評価結果をUnityの世界へ反映する出口です。ライトの回転・色、空のシェーダー変数などをここで更新します

毎フレームの流れは「ゲームがCoreを駆動 → TimeModuleが時刻を進める → 各ModuleがProfileを現在時刻で評価する → 評価結果をCoreに集約する → DynamicSkyがライトやシェーダーに反映」という順序で進みます。 Coreは駆動役と評価結果の置き場を兼ねているため図では戻る矢印になっていますが、データはあくまで「Profile → 評価 → 反映」の一方向にしか流れません。 機能を足したいときはModuleを追加して同じイベントに乗せるだけでよく、Moduleどうしが直接参照し合うことはありません。

この構造を踏まえて、設計上の判断や工夫を順に紹介します。

時間帯を「点」ではなく「連続した1日」として扱う

ゲームの仕様上、パークの時間帯は昼・夕・夜の3つです。 しかし、DynamicSkySystemの内部には「昼・夕・夜」という離散的な状態を持たせず、1日を0〜1の連続値として表現した時刻だけを持つ設計にしました。

以下は、時刻を表す構造体を記事用に整理して抜き出したものです。

[Serializable]
public struct SkyTime
{
    // 1日を0〜1で表す。実体はこのdouble1本のみ
    public double timeAsPercentage;

    public SkyTime(long hour, long minute)
        => timeAsPercentage = (hour * 3600d + minute * 60d) / 86400d;

    public static implicit operator double(SkyTime time) => time.timeAsPercentage;
}

実体は double 1本なので、時刻同士の加算や重み付きブレンドがそのまま書けます。 一方でエディタや演出からは「時:分」として扱えるため、調整時に0〜1の値を意識する必要はありません。 なお、日をまたぐ際に0〜1へラップする処理などは実装にありますが、抜粋では省略しています。

離散的な3状態ではなく連続値にした理由は、大きく2つあります。

1つ目は、時間帯の切り替え演出です。 昼から夜に切り替える際、空を実際に動かして夕暮れを経由させる演出を入れています。 時間帯が離散的な状態だと中間の絵を別途用意する必要がありますが、連続値であれば「時刻の値を目標の時刻へ向けて少しずつ変化させる」だけで、間にあるすべての中間状態が自動的に得られます。

2つ目は、作り込みのしやすさです。 3つの状態を個別に作るのではなく、1日分の変化をひと続きのデータとして作り込めるため、時間帯を後から増やしたり、切り替えタイミングを調整したりする変更に強くなります。

すべてのパラメータを「時刻の関数」にする

空の色、太陽光の強さ、星の明るさといったパラメータは、プロファイル(ScriptableObject)に「定数」または「時刻を入力とするカーブ・グラデーション」として持たせています。

[Serializable]
public class FloatCurveData
{
    public bool isConstant = true;

    [Range(0, 1)] public float floatVal = 1;
    public AnimationCurve curveVal;

    // 時刻t(0〜1)を入力として値を返す
    public float GetFloatValue(float time)
        => isConstant ? floatVal : curveVal.Evaluate(time);
}

毎フレーム、現在時刻t(0〜1)でプロファイルを評価し、その結果をシェーダーやライトに流し込みます。 「プロファイル → 時刻tで評価 → シェーダー・ライトへ反映」という一方向のデータフローに揃えることで、どの時間帯の見た目もプロファイルを見れば把握でき、デバッグや調整がしやすくなっています。

Profileはデザイナーの作業場

Profileには、空の散乱・太陽と月・星と銀河・雲のライト・ライトシャフトまで、空に関わるパラメータが1つのアセットに集約されています。 デザイナーはこのアセットだけを触ればよく、シーンやコードに手を入れる必要はありません。

「1日分の変化をカーブで作り込む」という作業を支えるために、エディタ拡張にもいくつか手を入れています。

  • 定数とカーブをワンクリックで切り替え — すべてのパラメータは、まず定数として設定し、時間変化が必要になったらボタン1つでカーブ・グラデーションに切り替えられます。最初から全項目をカーブで作る必要はありません
  • グラデーションを16キーに拡張 — Unity標準のGradientはキーが8個までという制限があり、1日分の色変化を作り込むには足りません。そこで最大16キーまで置ける拡張グラデーションと専用の編集ウィンドウを自作しました
  • 編集すると即プレビュー — Profileの値を変えると、再生中でなくてもシーンの空にリアルタイムで反映されます。時刻をスライダーで動かしながら、気になる時間帯の見た目をその場で確認して調整できます
ProfileのInspector画面。散乱・ライト・ライトシャフトのパラメータが並び、太陽光の色は時刻に応じたグラデーション、強度は時刻カーブとして設定されている

実際のInspector画面がこちらです。 たとえばSunlight Color(太陽光の色)は夜明けのオレンジから昼の白、日没の赤までを1本のグラデーションで、Sunlight Intensity(強度)は日中だけ持ち上がるカーブで表現されています。 横軸はすべて「1日の時刻」です。

「昼・夕・夜」への変換はゲーム側の責務にする

空のシステム自体はゲームの仕様を一切知らず、外部から触れる入口は「現在時刻」と「プロファイルの差し替え」の2つだけに絞っています。 昼・夕・夜という離散の時間帯から連続時刻への変換は、ゲーム側の薄い変換レイヤーが担います。

この分離により、パークの時間帯切り替え・ストーリー演出・エディタ上のプレビューツールといった複数の利用側が、同じ入口を通して空を制御できるようになっています。 複数のシステムが同時に時刻を触って競合しないよう、「外部が時刻を制御中かどうか」を表すフラグを設け、外部の制御中はシステム内部からの時刻操作を止める、というシンプルな方法で競合を防いでいます。

空の表現

空はテクスチャを貼ったスカイボックスではなく、1つのシェーダーで大気の色をリアルタイムに計算しています。 その中心にあるのが、今回の空の表現で私がもっとも大切にしたレイリー散乱ミー散乱です。

ゲーム内の夕暮れ。水平線に沈む太陽の周りが赤く染まり、上空にいくほど青いグラデーションになっている

空の色を決める2つの散乱

現実の空の色は、太陽光が大気中の粒子にぶつかって散らばる「散乱」で決まっています。

  • レイリー散乱 — 空気の分子のような、光の波長より小さい粒子による散乱です。波長の短い青い光ほど強く散乱されるため、昼の空は青く見えます。逆に夕方は光が大気を通る距離が長くなり、青が散乱しきって波長の長い赤やオレンジだけが届くため、夕焼けになります
  • ミー散乱 — 小さな水滴やちりのような、波長と同じくらいの大きさの粒子による散乱です。波長によらずほぼ均等に散乱し、進行方向に強く散る性質があるため、太陽の周りの明るいハローや、空のかすみ・白っぽさを作ります

レイリー散乱とミー散乱の違い。レイリー散乱は青い光ほど四方八方に散乱して青空や夕焼けを作り、ミー散乱はすべての色を前方に強く散乱して太陽周りのハローやかすみを作る

冒頭の夕暮れの映像で、太陽に近い地平線際が赤く染まり、視線を上げるほど青みが残っていたのは、まさにこの2つの散乱が作っているグラデーションです。

つまり「昼の青空」も「夕暮れのグラデーション」も、この2つの散乱をシェーダーで計算すれば、テクスチャを何枚も用意しなくても1つの式から自然に生まれてくれるわけです。 これが、時間帯ごとに空のテクスチャを差し替えるのではなく、散乱計算を選んだ最大の理由です。

シェーダーでの実装

実装は物理式を厳密に解くのではなく、リアルタイム向けの解析的な近似を使っています。 流れは次の3ステップです。

  1. 光学的深度の計算 — 視線の天頂角から「光が大気をどれだけの距離通ってくるか」を求めます。地平線に近いほど距離が長くなり、夕焼けの赤みの源になります
  2. 減衰の計算 — その距離をもとに、レイリー・ミーそれぞれの係数で光がどれだけ弱まるかを求めます(大気に吸収・散乱されて減る分で、消衰やextinctionとも呼ばれます)
  3. インスキャッタ(in-scatter)の合成 — 位相関数(視線と太陽のなす角に応じた散乱の強さ)を掛けて、視線方向に散乱してくる光を足し込みます

以下は、処理の流れを疑似コードにしたものです。

// ※疑似コードです

// 1. 光学的深度: 天頂角が大きい(地平線に近い)ほど大気を通る距離が伸びる
float zenith = AngleFromZenith(viewDir);
float depthRayleigh = RayleighOpticalDepth(zenith);
float depthMie = MieOpticalDepth(zenith);

// 2. 消衰: 距離に応じて光が減衰する
float3 extinction = exp(-(rayleighCoeff * depthRayleigh + mieCoeff * depthMie));

// 3. インスキャッタ: 位相関数を掛けて視線方向へ散る光を求める
float cosTheta = dot(viewDir, sunDirection);
float3 rayleighTerm = RayleighPhase(cosTheta) * rayleighCoeff * _RayleighColor;
float3 mieTerm = MiePhase(cosTheta) * mieCoeff * _MieColor;

// 太陽の明るさと「散乱に使われた光の量」を掛け合わせて空の色にする
float3 skyColor = (rayleighTerm + mieTerm) * sunEnergy * (1.0 - extinction);

レイリー位相関数はどの方向にも比較的まんべんなく散る形、ミー位相関数は太陽方向に鋭いピークを持つ形になっており、前者が空全体のグラデーションを、後者が太陽周りの明るさを担当します。

また、同じ式を太陽だけでなく月にも適用しています。 太陽のインスキャッタ・月のインスキャッタ・どちらも地平線下にあるときのベースの明るさ、という3つの項を合成することで、昼から夜までの空が途切れなくつながります。

夜空を彩る星と銀河

散乱の計算だけでは、夜の空はただ暗いだけになってしまいます。 夜ならではの「映え」を担うのが、星と銀河です。

星は、空全体をぐるりと覆う星空を焼き込んだHDRテクスチャを、視線の方向ベクトルからサンプリングして描いています。 さらにノイズテクスチャを時間でスクロールさせて明るさを揺らすことで、星の瞬きを表現しています。 天頂に近いほどはっきり、地平線際では控えめになるマスクも掛けており、現実の夜空の見え方に寄せています。

銀河は星とは別のテクスチャレイヤーとして重ねていて、明るさを独立に調整できます。 星だけでは寂しい夜空に、天の川の淡い光が加わることで、夜の写真の背景としての情報量がぐっと増えます。

夜のパークの上に広がる星空。無数の星が瞬き、右上には銀河の淡い光が見えている

そして星空全体は、時刻に合わせてゆっくりと回転します。 現実の日周運動と同じように星が夜のあいだに少しずつ動いていくもので、回転軸と速度は星・銀河それぞれに設定できます。 星や銀河の明るさももちろん時刻カーブなので、日没後にすっと星が現れ、夜明けとともに消えていく遷移も、カーブを描くだけで作れます。

物理式に「映え」の余地を残す

散乱計算をそのまま使うと、リアルではあるものの画としては地味になりがちです。 そこで、物理的には存在しないパラメータをあえて式に足しています。

先ほどの散乱の式に登場した _RayleighColor_MieColor がそれで、散乱の結果に掛け合わせる「アート用の色」です。 これらはProfileの時刻カーブ・グラデーションとしてデザイナーが自由に調整できるため、「物理ベースの自然な空」を土台にしつつ、「夕焼けをもう少しピンクに寄せたい」といったアートディレクションの要求に応えられます。

物理式で土台の説得力を作り、その上にデザイナーが色を乗せられるようにする。 この二段構えが、「いつ撮っても映える」空を支えるもっとも重要な仕組みだと考えています。

光の筋を描くライトシャフト

夕方の斜めに差し込む光や、建物の隙間からこぼれる光の筋(ライトシャフト)は、時間帯の変化をもっとも印象的に見せてくれる要素のひとつです。 「映える」写真の主役になることも多いため、これも空・ライトと同じ仕組みの上で動くように実装しています。

仕組み: 視線に沿って「影の中か外か」を数える

ライトシャフトの正体は、「空気中のどこに光が当たっていて、どこが影になっているか」の可視化です。 実装はスクリーンスペースのレイマーチングで、各ピクセルから視線に沿ってレイを進めながら、各サンプル点が太陽の影の中かどうかをシャドウマップで判定していきます。

float3 currentPosition = rayStart + step * ditherOffset;
float vlight = 0.0;

for (float i = 0; i < stepCount; ++i)
{
    // 自作ヘルパーでシャドウマップを1回参照し、影の中なら0、光が当たっていれば1を返す
    float atten = MainLightShadow(currentPosition);
    vlight += atten * stepSize;
    currentPosition += step;
}

return vlight / rayLength; // このピクセルの視線が「どれだけ光に当たっていたか」

こうして得られた「光の当たり具合」のマスクを、大気散乱(フォグ)の散乱光に掛け合わせて合成します。 影になっている空気は光らず、日の当たっている空気だけがふわっと明るくなる。その明暗の差が、光の筋として画面に浮かび上がります。 筋の色には乗算カラーを用意してあり、ここもデザイナーが時間帯に合わせて調整できます。

掛け合わせる側の大気散乱にも、ちょっとした工夫があります。 距離に応じた散乱・消衰の積分計算は本来ピクセルごとに行うと高くつくため、「画面位置 × 距離」を軸にした8×8×64テクセルの小さな3Dテクスチャへコンピュートシェーダーで書き込んでおき、フォグの描画時はそれをサンプルするだけにしています。 さらに、時刻が変わっても変化しない空気中の粒子密度の分布は、エディタであらかじめベイクしてテクスチャアセット化してあるので、実機では計算せず読み込むだけです。

モバイルでも成立させるための工夫

レイマーチングは1ピクセルあたり何度もシャドウマップ参照が走る、本来はモバイルに厳しい処理です。 そこで、次の組み合わせで負荷を抑えています。

  • 半解像度で計算する — レイマーチは半解像度のテクスチャに対して行い、結果だけを全解像度へ戻します
  • 深度を考慮したブラーと拡大 — 単純に拡大するとキャラクターや建物の輪郭に光が滲むため、深度差を見ながらブラー・アップサンプルし、輪郭の破綻を防ぎます
  • ディザリングでステップの縞を隠す — サンプル開始位置をピクセルごとに少しずつずらすことで、少ないステップ数でも縞模様が目立たないようにしています
  • 昼間だけ実行する — 太陽光の強度がほぼゼロの夜間は筋が見えないため、昼夜判定(後述の IsDaytime())でパスごとスキップします

雲の表現

空のグラデーションだけでは、どうしても画面が単調になります。 空に表情と奥行きを与えてくれるのが雲です。

光が透けるSix-Wayライティング

雲には、UnityのSix-Wayライティングをベースにした表現を使っています。 Six-Wayライティングは、雲のテクスチャに上下前後左右の6方向から光を当てたときのライティング結果(陰影や透け方)をあらかじめ焼き込んでおき、実行時のライト方向に応じてそれらをブレンドする手法です。 レイマーチングのような重い計算をせずに、「夕日が雲の裏から透ける」ボリューム感のある画が得られます。 Six-Wayライティングの詳細はUnity公式ブログの解説記事を参照してください。

雲はメッシュとして空に配置され、ゆっくりと回転しながら流れていきます。 太陽や月の位置が変わると光の透け方も追従するため、時間帯ごとにまったく違う表情を見せてくれます。

雲の色を時刻に追従させる

雲のライティングで悩ましいのが、「影の向きは太陽と一致させたいが、色は空とは別に調整したい」という要求です。 そこで雲には、方向はメインライト(太陽・月)をそのまま使い、色と強さだけを雲専用のパラメータで上書きするという構成を取りました。

// 色は雲専用のグラデーション(時刻カーブ)から、方向は実際の太陽から取る
var cloudMainLight = coreSky.IsDaytime()
    ? coreSky.cloudSubLightColor * coreSky.cloudSubLightIntensity
    : coreSky.cloudSubMoonLightColor * coreSky.cloudSubMoonLightIntensity;

propertyBlock.SetColor(CloudMainLightColorId, cloudMainLight);
propertyBlock.SetVector(CloudMainLightDirectionId, -sun.transform.forward);

雲専用の色はProfileの時刻グラデーションとしてデザイナーが調整できるため、「夕方の雲だけもっと赤く染めたい」といった要求に、空の散乱計算へ影響を与えずに応えられます。

この仕組みのおかげで、次の映像のような「背景はまだ照らされていないのに、雲だけが先に赤みを帯びてライティングされる」という、時間帯の境目ならではの印象的な絵も表現できます。

背景のパークはまだ暗い中、空の雲だけが夕日を受けて赤く染まっている様子

ライティングの時間変化

空と雲が時刻に追従しても、ライトが置いてけぼりでは画になりません。 シーンを照らすライティングも、同じ時刻を入力として変化させています。

太陽と月はひとつのライトで演じ分ける

シーンに置いているDirectional Lightは、実は1本だけです。 1日の進行率に合わせてライトを回転させ、昼は太陽として使い、夜になったら180度反転させて月として使い回します。

// 1日の進行率(0〜1)からライトの角度を決める
float rotationAngle = (float)coreSky.dayPercentage * 360f - 90f;

// 夜は反対側から照らす = 同じライトを月として使う
if (coreSky.IsDaytime() == false)
{
    rotationAngle += 180f;
}

light.transform.localEulerAngles = new Vector3(rotationAngle, 0f, 0f);
light.color = coreSky.IsDaytime() ? coreSky.sunlightColor : coreSky.moonlightColor;
light.intensity = coreSky.IsDaytime() ? coreSky.sunlightIntensity : coreSky.moonlightIntensity;
RenderSettings.sun = light;

URPでは、Main Lightとして扱えるのは1本だけで、それ以外のライトは本数に制限のある追加ライト扱いになります(モバイル既定では画面全体で32灯まで)。 太陽用と月用でDirectional Lightを2本置くと、片方が追加ライトの貴重な枠を消費してしまいます。 1本を使い回せば、常にMain Light1本分のコストのまま1日ぶんのライティングを表現でき、追加ライトの枠を他の演出に回せます。 色と強さは昼用(太陽光)と夜用(月光)で切り替えており、どちらもデザイナーが時刻グラデーションで調整できます。

昼と夜の境界もカーブが決める

では「夜になったら」の夜は、どこで判定しているのでしょうか。 実装はこれだけです。

public bool IsDaytime() => sunlightIntensity >= 0.01f;

「18時を過ぎたら夜」のような時刻のしきい値は、コードのどこにもありません。 太陽光の強度はデザイナーが描く時刻カーブなので、カーブの値がほぼゼロに落ちた瞬間が、そのまま夜の始まりになります。 昼夜の境界を調整したくなったら、コードを直すのではなくカーブを描き替えるだけです。

この作りには副次的なメリットもあります。 太陽と月の切り替え(ライトの180度反転)が起きるのは太陽光の強度がほぼゼロのときだけなので、月光側の強度カーブも境界付近でゼロに揃えておけば、ライトが反対側へ跳ぶ瞬間が画面上に見えることはありません。

パーティクルにも同じ光を当てる

時間帯の変化で意外と浮きやすいのがパーティクル(エフェクト)です。 昼を基準に作られた明るいエフェクトを夜のシーンでそのまま再生すると、そこだけ貼り付けたように悪目立ちしてしまいます。

そこで、パーティクル用シェーダーにもライティングを受けるLit版を用意しています。 メインライト(つまり太陽・月ライト)と環境光を受けるため、同じエフェクトでも昼は昼の光で、夕方は夕日の色で発色し、シーンに自然に馴染みます。 ライトとGIそれぞれの寄与率はエフェクトごとにパラメータで調整できるので、「ライティングには馴染ませつつ、発光感は残したい」といったエフェクトらしい要求にも対応できます。

このシェーダーを開発したことによる制作面のメリットも大きく、時間帯ごとにエフェクトのアセットを複数持つ必要がなくなりました。 ライティングを受けない従来のエフェクトのままなら、昼用・夕用・夜用と色味を変えたバリエーションを用意することになりますが、1つのアセットがライティングによって自動的に時間帯へ馴染むため、制作コストもアセットの管理コストも大きく削減できています。

また、煙のようなボリューム感が欲しいエフェクトには、雲と同じSix-Wayライティングを使えるパーティクルシェーダーも用意しています。 夕日を受けた煙が裏から透ける、といった表現がパーティクルでもそのまま成立します。

マンホールから立ち上る同じ蒸気のパーティクルが、夜・昼・夕とライティングの変化に合わせて発色を変えていく様子

まとめ

本記事では、ホロドリにおける昼・夕・夜と変化する背景表現について、その全体像と「映える」画作りのための工夫を紹介しました。

物理ベースの散乱計算で土台の説得力を作り、その上にデザイナーが時刻カーブで色を乗せていく。この二段構えが、どの時間帯を切り取っても絵になる背景を支えています。

同じように時間帯で変化する背景表現に取り組む開発者の方々にとって、この記事が参考になれば幸いです。

著者

QualiArtsのプロフィール画像

QualiArtsエンジニアブログ編集部です。

この記事をシェア

目次