トゥーンの世界に点光源を馴染ませたい
アニメ調トゥーンシェーダーの世界に、数が動的に変化する複数の点光源を追加したときの設計と実装をまとめます。単に「ポイントライトの計算式を足す」だけではトゥーンの画作りが簡単に崩れてしまうので、本稿では
- 点光源の陰影をどうトゥーン化するか
- 平行光の影と点光源をどう共存させるか(2 灯合成)
- SDF 顔影・Ramp 肌・スペキュラ・リムライトとの馴染ませ方
の順に解説します。前提となる顔影(SDF)と Ramp テクスチャの実装は過去記事をご参照ください。
目次
- なぜ「ただ加算」ではダメなのか
- 点光源のトゥーン化 ── N·L だけ 2 値化する
- 2 灯合成 ── 点光源のマスクで平行光の影を晴らす
- Ramp 肌への適用 ── サンプル位置ごと持ち上げる
- SDF 顔影との融合 ── 判定を逆向きに使う
- スペキュラとリムライト
- まとめ
1. なぜ「ただ加算」ではダメなのか
点光源の教科書的な実装は、ライトごとに
I = C_{light} \cdot \mathrm{saturate}(\vec{N} \cdot \vec{L}) \cdot atten
を計算して足し込むだけです。しかしトゥーンシェーディングの世界でこれをやると、2 つの問題が起きます。
問題 1:陰影が滑らかすぎて浮く
トゥーンの面は smoothstep で「光/影」の 2 段階に量子化されているのに、点光源の寄与だけが連続的なグラデーションで乗るため、そこだけ写実的な質感になって浮きます。
問題 2:「照らされているのに影のまま」になる
平行光(太陽・月)の影側に点光源の色を加算しても、下地のトゥーン暗部や落ち影は暗いままです。松明のすぐ隣なのに顔が夜影に沈んでいる、という違和感が残ります。
問題 1 は点光源側のトゥーン化で、問題 2 はマスクによる 2 灯合成で解決します。これが本稿の主題です。
2. 点光源のトゥーン化 ── N·L だけ 2 値化する
陰影のトゥーン化には「N·L を 2 値化する」「距離減衰もリング状に段階化する」の 2 つの軸がありますが、本実装では N·L だけを 2 値化し、距離減衰は滑らかなままにしました。減衰までバンド化すると複数光源が重なったとき等高線同士が干渉して汚くなりやすいためです。
なお点光源のリスト(位置・範囲・色・強度)は StructuredBuffer で全ピクセルシェーダーへ渡しています。転送部分の詳細は本稿では割愛し、以降のコードではリストから 1 灯取り出すヘルパ GetPointLightAt(i, worldPos, out L, out colorAtten) を前提にします。
// common.hlsl (抜粋)
static const float PL_TOON_SOFTNESS = 0.05f; // トゥーン境界のぼかし幅
float toonLit = smoothstep(toonThreshold, toonThreshold + PL_TOON_SOFTNESS, dot(N, L));
平行光のトゥーンと同じ smoothstep なので、面の上では平行光の明暗と点光源の明暗が同じ境界で切り替わります。距離減衰は窓付き逆二乗風の
atten = \left( \mathrm{saturate}\!\left( 1 - \left( \frac{d}{R} \right)^2 \right) \right)^2
で、レンジ端で滑らかに 0 へ落ちます。地面には滑らかな光の円、面にはトゥーンの明暗、という住み分けです。
なお草のように法線を持たない素材のために、向き非依存で点灯させる wrap パラメータも用意しました(草は上向き法線の代用+wrap=1)。
3. 2 灯合成 ── 点光源のマスクで平行光の影を晴らす
3.1 参考にした方式
冒頭の問題 2(照らされているのに影のまま)の解決は、『Xenoblade3』の 2 灯トゥーンシェーディング(CEDEC+KYUSHU 2022)を参考にしました。要点は「点光源側の明るさをマスクにして、平行光の陰影と合成する」ことです。
3.2 マスクの生成と適用
点光源の寄与を合算したあと、その最大成分を saturate した値をマスク plMask として一緒に返します。
// common.hlsl (抜粋)
float3 AccumulatePointLights(float3 N, float3 worldPos, float wrap, float toonThreshold,
out float plMask)
{
float3 sum = float3(0.0f, 0.0f, 0.0f);
[loop] for (int i = 0; i < Light.PointLightCount; ++i)
{
float3 L, colorAtten;
if (!GetPointLightAt(i, worldPos, L, colorAtten)) continue;
float toonLit = smoothstep(toonThreshold, toonThreshold + PL_TOON_SOFTNESS, dot(N, L));
sum += colorAtten * lerp(toonLit, 1.0f, wrap);
}
plMask = saturate(max(sum.r, max(sum.g, sum.b)));
return sum;
}
トゥーン 2 値化・距離減衰を通した後の値から作るので、マスクの形がそのまま点光源のトゥーン境界と一致するのがポイントです。
受け取った側のピクセルシェーダーでは、平行光の陰影係数(トゥーン暗部+シャドウマップの落ち影)をこのマスクで持ち上げてから、点光源の色を加算します。
// vertexLightingPS.hlsl (抜粋)
float plMask;
float3 pointLight = AccumulatePointLights(N, In.WorldPos, 0.0f,
PL_TOON_THRESHOLD_DEFAULT, plMask);
diffuseIntensity = lerp(diffuseIntensity, 1.0f, plMask); // 影を晴らす
float3 diffuse = baseColor.rgb * Material.Diffuse.rgb * diffuseIntensity * Light.Diffuse.rgb;
float3 pointDiffuse = baseColor.rgb * Material.Diffuse.rgb * pointLight; // 色を乗せる
「影を晴らしてから色が付く」ため、松明のそばでは夜影が晴れ、その上に暖色が乗る──という 2 灯の共存が成立します。点光源はシャドウマップの影響を受けないので、「建物の落ち影の中でも松明は照らせる」挙動も同時に手に入ります。
【画像】平行光の影の中に点光源を置いたとき影が晴れる比較
4. Ramp 肌への適用 ── サンプル位置ごと持ち上げる
肌・体は Ramp テクスチャ(影側=暖色 → 光側=白のグラデーション)で陰影に色を付けています。ここで数値の掛け算でマスクを効かせると暖色が濁ります。そこで Ramp 系は数値減衰ではなく、Ramp のサンプル位置そのものを明端へ寄せる方式にしました。
// vertexLightingPS_BodyRamp.hlsl (抜粋)
float rampU = lerp(0.0f, litU, shadowLit); // 従来の落ち影処理
rampU = lerp(rampU, 1.0f, plMask); // 点光源が照らす所は明端へ寄せる
float3 rampColor = g_RampTexture.Sample(g_SamplerState, float2(rampU, 0.5f)).rgb;
落ち影を Ramp のサンプル位置で表現しているのと同じ流儀なので、点光源による「影晴れ」も暖色影と同じ色経路を通り、色の一貫性が保たれます。
5. SDF 顔影との融合 ── 判定を逆向きに使う
顔は SDF テクスチャによる様式化された影を使っています(詳細は過去記事参照)。平行光の判定は「SDF の輝度 sdfValue がライト角度由来のしきい値 lightFactor より大きければ明部」でした。
点光源ではこれを逆向きに使います。すなわち、しきい値側を SDF に固定し、点光源との N·L が SDF の輝度を超えた画素だけ点灯させます。
// common.hlsl (抜粋)
float2 Lxz = L.xz / max(length(L.xz), 1e-4f); // 高さ成分を無視して水平射影
float2 Nxz = N.xz / max(length(N.xz), 1e-4f);
float ndl = dot(Nxz, Lxz);
float toonLit = smoothstep(sdfValue, sdfValue + PL_TOON_SOFTNESS, ndl);
こうすると点光源の点灯領域が SDF の陰影パターンに沿った形で広がり、「顔だけ写実的に照らされる」事故を防げます。
もう 1 つのミソが高さ成分の無視です。平行光の SDF 影はライトと顔の水平角度だけで決まる設計なので、点光源側も N と L を XZ 平面へ射影してから内積を取ります。これで光源の高さを上下させても顔の点灯パターンが上下にブレません(距離減衰だけは 3D 距離のままなので、離せば素直に暗くなります)。
6. スペキュラとリムライト
6.1 スペキュラ ── バンド化関数を平行光と共用する
金属(3 バンド段階ハイライト)とプラスチック(2 値化ハイライト)には点光源のスペキュラも入れました。ポイントは、既存のバンド化ロジックを関数に切り出して平行光と点光源で共用することです。
// vertexLightingPS_MetalSpec.hlsl (抜粋)
float3 pointSpec = float3(0.0f, 0.0f, 0.0f);
[loop] for (int i = 0; i < Light.PointLightCount; ++i)
{
float3 Lp, cp;
if (!GetPointLightAt(i, In.WorldPos, Lp, cp)) continue;
float3 Hp = normalize(Lp + V); // 光源ごとのハーフベクトル
float spM = MetalSpecBandMask(pow(saturate(dot(N, Hp)), shininess));
float litP = smoothstep(0.0f, PL_TOON_SOFTNESS, dot(N, Lp)); // 裏側に出さない
pointSpec += specTint * cp * spM * SPEC_INTENSITY * litP;
}
ハイライト色は光源色(金属はさらにベース色で着色)で、N·L トゥーンでゲートして光源の裏側には出しません。バンドの形が平行光のハイライトと同じなので、質感の統一が崩れません。
6.2 リムライト ── 光源色を輪郭に乗せる
Xenoblade3 の講演には「リムライトに点光源の色を反映する」という一節があり、これも取り入れました。リムの形状($\mathrm{pow}(1 - \vec{N} \cdot \vec{V},\ power)$)は共通で、光源ごとの色×減衰×光側マスクを合算します。
// common.hlsl (抜粋)
float3 AccumulatePointLightRim(float3 N, float3 V, float3 worldPos,
float power, float intensity)
{
float rimFactor = pow(1.0f - saturate(dot(N, V)), power);
...
sum += colorAtten * rimFactor * saturate(dot(N, L)) * intensity;
...
}
赤い松明側の輪郭は赤く、青い魔法の光側は青く縁取られます。地味ですがキャラの「その場にいる感」が一段上がる、費用対効果の高い一手でした。
7. まとめ
本稿では、トゥーンシェーディングの世界に複数・動的な点光源を馴染ませる画作りを解説しました。要点を再掲します。
- 点光源の陰影は N·L だけ smoothstep で 2 値化し、距離減衰は滑らかなまま(多灯の重なりに強い)
- 点光源の明るさマスクで平行光の影を晴らしてから色を加算する(Xenoblade3 方式の 2 灯合成)。「照らされているのに影のまま」を防ぐ本稿最大のポイント
- Ramp 肌はサンプル位置を明端へ寄せて色経路ごと持ち上げる。SDF 顔は判定を逆向きに使い、高さ成分を無視した水平 N·L で比較する
- スペキュラはバンド化関数を平行光と共用し、リムには光源色を反映する
技術スタック:DirectX 11 / HLSL (Shader Model 5.0) / 自作エンジン
参考資料



