かすんだ夕暮れ時、街灯が独特の光のパターンを描き出す――そんな光景に見覚えはありませんか?私は昔からあの効果に魅了されてきました。そこで今回、Qt 6.11 のグラフィックス機能を活用し、QML のシェーダー統合と 3D レンダリングパイプラインを使って、実際の IES プロファイルに基づくリアルタイムのボリュメトリックライティングシステムを構築することにしました。
その結果、現実的な配光パターンを持つ大気中のライトシャフトを、すべてリアルタイムで描画できるシステムが完成しました。これは 2026 年 1 月の Qt ハッカソンで取り組んだ私のプロジェクトで、光輸送やシェーダープログラミングの世界に踏み込む、とても楽しい体験でした。その仕組みをぜひ共有したいと思います。
光が霧や埃の中を進むとき、空気中の粒子に当たって散乱します。理想的には、光が粒子に当たり、新しい方向に散乱し、さらに別の粒子に当たる……といったすべての反射をシミュレーションしたいところです。これは「多重散乱」と呼ばれ、非常に美しい結果が得られますが、計算コストが非常に高くなります。
単一散乱は、よりシンプルなアプローチです。粒子に一度だけ当たり、そのままあなたの目に届く光だけを考慮します。驚くことに、特に薄い霧や大気中の霞であれば、この方法でも十分に良好な結果が得られます。
単一散乱:光が粒子に一度だけ当たり、そのまま観測者へ直接届く
数式自体は実はとてもシンプルです。見ている任意の点について、次のことを求める必要があります。光源からどれくらい離れているか。
その方向にどれだけの光が出ているか(ここで IES が効いてきます)。その間にどれだけ霧があるか。
IESファイルは、人間が読めるテキスト形式のファイルで、照明器具の光強度分布(どの方向にどれくらいの強さで光が出るか)を実測値として格納したものです。データ量を減らすために、しばしば対称性(例:回転対称)を前提として記録されています。
GPUで扱いやすくするために、私はこのファイルをCPU側で解析し、全天球方向をカバーする 360 × 181 の2D画像 に展開してから、3Dテクスチャとして保存しました。各レイヤーには異なるIESプロファイルを格納でき、QMLのシェーダーから直接サンプリングできます。これはQtのカスタマイズ可能なテクスチャデータ機能のおかげです。
実行時に対称性を考慮する追加ロジックは不要で、単純なテクスチャ参照だけで済みます。測定角度間の滑らかな補間もハードウェアのテクスチャ補間に任せられるため、GPUのテクスチャサンプリングと非常に相性の良い方法です。
IES解析結果:水平方向・垂直方向ともに1度ごとに1テクセル配置
ここからが本題です。各ピクセルごとに、その視線レイに沿ってカメラへ散乱してくる光の量を求める必要があります。私が採用したアプローチは次のとおりです:
float volumetricScatter(vec3 cameraPos, vec3 worldPos, vec3 lightPos, vec3 lightDir,
float density, float iesIndex, float omnidirectionality) {
vec3 rayDir = worldPos - cameraPos;
float rayLen = length(rayDir);
rayDir /= rayLen;
// Find the point on our view ray that's closest to the light
float t = clamp(dot(lightPos - cameraPos, rayDir), 0.0, rayLen);
vec3 closest = cameraPos + rayDir * t;
float dist = length(lightPos - closest);
vec3 lightToPoint = normalize(closest - lightPos);
// Check the IES profile to see how much light goes this direction
float directionality = min(sampleIES(lightToPoint, lightDir, iesIndex) + omnidirectionality,
1.0);
// Classic inverse square falloff with density and ray length
return (directionality * density * rayLen / (dist * dist + 0.0001));
}
ここでの工夫は「最も近い点」を見つけることです。レイマーチング(レイに沿って少しずつ進みながら各ステップでサンプリングする方法)の代わりに、視線レイ上で光源に最も近い単一の点だけを求めます。これは近似ではありますが、高速で、実際の見た目も非常に良好です。
逆二乗則により距離減衰を扱います。光は伝播するにつれて広がるため、強度は距離の二乗に反比例して低下します。さらに、レイの長さ(長いレイほど散乱が蓄積される)と密度(霧の濃さを制御)を掛け合わせます。
IES強度のサンプリングも、3Dテクスチャの2Dスライスを使えば直感的に行えます。
float sampleIES(vec3 lightDir, vec3 lightForward, float iesIndex) {
// Vertical angle (theta): angle from the forward direction
float theta = acos(dot(lightDir, lightForward));
// Build tangent space for horizontal angle calculation
vec3 right = normalize(cross(lightForward, vec3(0.0, 0.0, 1.0)));
vec3 up = cross(right, lightForward);
// Project direction onto a plane perpendicular to the forward
vec3 projectedDir = lightDir - lightForward * dot(lightDir, lightForward);
// Horizontal angle (phi): rotation around forward axis
float phi = atan(dot(projectedDir, up), dot(projectedDir, right));
// Convert to UV coordinates [0,1]
vec2 uv = vec2(phi / (2.0 * PI), theta / PI);
// Sample 3D texture (multiple IES profiles)
return textureLod(lightIES, vec3(uv, iesIndex / iesCount), 0).r;
}
このシステムは、表面ライティング(投影)と体積効果の両方をレンダリングします:
// The fog effect
float scatter = volumetricScatter(cameraPosition, worldPos, lightPosition, lightForward,
scatterDensity, iesIndex, omnidirectionality);
vec3 volumetricLight = lightColor * lightIntensity * scatter;
// Regular surface lighting with the same IES profile
float iesIntensity = min(sampleIES(lightToSurface, lightForward, iesIndex) + omnidirectionality,
1.0);
float attenuation = 1.0 / (distanceToLight * distanceToLight + 0.0001);
vec3 projectedLight = lightColor * iesIntensity * attenuation * projectionIntensity;
// Add them up
finalColor += (projectedLight + volumetricLight) * distanceAttenuation;
その結果、表面にはシャープな投影ライトパターンが現れ、空気中には柔らかなボリューメトリックな光のにじみが生まれます。どちらも同じIES配光分布に従っているため、全体として一貫した見た目になります。
多数のライトを同時に扱う必要がありました。街灯、信号機、車のヘッドライトなど、それぞれが独自の IES プロファイルやプロパティを持っています。私が採用した方法は、カメラに対する単純な球境界チェックで可視(アクティブ)なライトのデータをまとめ、毎フレーム更新されるテクスチャにパックするというものです:
tempLights.push({
position: Qt.vector3d(x, y, z),
forward: Qt.vector3d(dx, dy, dz),
omnidirectionality: 0.0,
color: "lightblue",
intensity: 20,
scatterDensity: 0.4,
projectionIntensity: 100000.0,
iesIndex: 1,
visible: true
});
各ライトはそれぞれ固有の散乱密度(霧の強さ)、投影強度(表面照明の強さ)、および IES インデックスを持っています。これにより、街灯はボリューメトリック散乱が強く表面投影は比較的弱い、といった設定や、車のヘッドライトではその逆、といった表現が可能になります。
シェーダー側では、QML の ExtendedSceneEnvironment のポストプロセス効果を用いてすべてのライトを反復処理し、それぞれの寄与を加算しています。最先端のライト管理システムというわけではありませんが、数十個程度のライトを扱うには十分に機能します。
単一散乱は、真の光輸送と比べれば「正しくない」近似です。しかし、その誤差は主に今回狙っている視覚効果にはほとんど影響しない種類のものです。
ボリューメトリックライティングが動作するようになった後、適切なコンテキストでテストしたくなりました。そこで、手続き生成の道路システムを用いたバイクレースのデモを構築しました。道路は Catmull–Rom スプラインを使って制御点から滑らかなカーブを生成し、その経路に沿って一定間隔で街灯を配置しています。
この手続き的な道路ジオメトリでは、各点で接線(tangent)と従法線(binormal)ベクトルを計算しています。これにより、道路の進行方向に対して直交する形でライトを正しく配置できます。各ライトには読み込んだ IES プロファイル集合からランダムに1つが割り当てられ、さらにカラーパレットから色が設定されます。
デモ:コースを疾走すると色付きのライトシャフトが次々と流れ去り、臨場感を強く演出
レース中に何十本もの色付きライトシャフトが次々と視界を横切っていく様子は、まさにその場にいるかのような感覚を強く引き立ててくれます。さらに、Qt 6.11 の新しいモーションベクターバッファにもフックし、ExtendedSceneEnvironment のポストプロセス効果として本格的なモーションブラーを追加しました:
void MAIN() {
vec2 texcoord = TEXTURE_UV;
vec4 motionvector = texture(MOTION_VECTOR_TEXTURE, texcoord);
vec2 velocity = motionvector.xy;
const int SAMPLES = 16;
const float BLUR_STRENGTH = 1.5;
vec4 color = vec4(0.0);
for (int i = 0; i < SAMPLES; i++) {
float t = float(i) / float(SAMPLES - 1);
vec2 offset = velocity * t * BLUR_STRENGTH;
color += texture(INPUT, texcoord + offset);
}
color /= float(SAMPLES);
FRAGCOLOR = color;
}
モーションベクターは、各ピクセルが前フレームからどの方向にどれだけ移動したかを示します。これに沿ってブラーをかけることで、ボリューメトリックライティングと組み合わせた際に優れたスピード感が得られます。ライトシャフトがモーションブラーを伴って後方へ流れていくことで、霧の夜道を高速で駆け抜けている感覚がよりリアルに伝わります。
さらに面白さを加えるため、道路の曲率を変える難易度モードも追加しました。ノーマルモードでは緩やかなカーブ、ハードモードではよりタイトなコーナーになります。ラップタイマーやスコアリングシステムも備えています。結果として、当初の想定よりもゲーム性の強い内容になりましたが、ライティングシステムのストレステストとしては最適です。30以上のライトが有効な状態で高速走行し、信号機が変化し、さらにモーションブラーが動作する状況では、性能が十分かどうかがはっきりと分かります。
ソースコードはこちらから確認できます。