💧

レイマーチングで3Dモデルにトロッとしたぼかし&グラデーション表現を入れる

に公開


はじめに

以前試作の絵をツイートしてみたら反響があったので、コードをまとめて残しておきたくAIに解説してもらいました。プロトタイプの過程でできたんだけど、この感じでは使わなさそう。
立体なんだけど輪郭がぼけて背景と溶け合うような表現をしてみました。普通にGLSLでもブラーをかければ近いことはできそうですが、大きくぼかそうとするほど負荷が高いので、レイマーチングなら良い感じになるんじゃない?と試したら良い感じになったように思えます。

完成コードは記事末尾にまとめています。

全体構成

シェーダーの処理フローは次のとおりです。

UV座標 → カメラ構築 → レイマーチング → ヒット判定
  ├─ ヒット → 法線計算 → ライティング(diffuse / spec / shadow / AO)
  └─ ミス  → 背景色 + Perlinノイズで"にじみ"を合成
最後にガンマ補正して出力

SDFプリミティブとSmooth Union

基本のSDF

シーンには3つのプリミティブを配置しています。

float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sdTorus(vec3 p, vec2 t) {
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

Sphere・Box・Torusそれぞれに u_time ベースのアニメーションを付け、rot() で回転も加えています。

smin で有機的に融合する

min() の代わりに smin(Smooth Minimum) を使うと、オブジェクト同士の境界がなめらかに溶け合います。

float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

パラメータ k が融合の"柔らかさ"を制御します。今回は k = 0.3 で、ほどよく溶け合う質感にしています。

float merged = box;
merged = smin(merged, box, 0.3);
merged = smin(merged, sphere, 0.3);
merged = smin(merged, torus, 0.3);

ライティング:影とAOで立体感を出す

ソフトシャドウ

レイマーチングのソフトシャドウは、光源方向にレイを飛ばしながら「どれだけSDF表面に近づいたか」で影の濃さを決める手法です。

float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0, t = mint;
  for (int i = 0; i < 24; i++) {
    float h = map(ro + rd * t).x;
    if (h < 0.001) return 0.0;
    res = min(res, k * h / t);
    t += h;
    if (t > maxt) break;
  }
  return clamp(res, 0.0, 1.0);
}

k を大きくするほどシャープな影、小さくするほどぼんやりした影になります。

アンビエントオクルージョン(AO)

法線方向に少しずつ離れた点でSDFを評価し、「本来の距離との差」を蓄積することで、くぼみや隙間の暗さを近似します。

float calcAO(vec3 pos, vec3 nor) {
  float occ = 0.0, sca = 1.0;
  for (int i = 0; i < 4; i++) {
    float h = 0.01 + 0.12 * float(i) / 3.0;
    occ += (h - map(pos + h * nor).x) * sca;
    sca *= 0.95;
  }
  return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

sminで融合した部分は谷状の形状になるため、AOが効いて自然な陰影がつきます。

Perlin Noise と fBm で輪郭を溶かす

ここがこのシェーダーの核心部分です。

なぜ輪郭を溶かしたいのか

レイマーチングの出力は、ヒット/ミスの二値で分かれます。そのままだと輪郭がパキッとしすぎて、背景との一体感が出ません。「物体が空間に溶けていく」ような表現をするために、ミスレイの最小距離(minDist) をノイズで歪ませます。

Perlin Noiseの実装

グラジェントノイズを使っています。ポイントは Quintic fade(5次の滑らかな補間)で、C2連続にすることでグリッド状のアーティファクトを抑えます。

vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);

3次Hermite(3t² - 2t³)と比べると、導関数の連続性が上がり、ノイズの見た目がなめらかになります。

fBm(fractal Brownian motion)

Perlinノイズを周波数を倍にしながら重ね合わせることで、マクロな揺らぎとミクロなディテールを両立します。

float fbm(vec3 p, int octaves) {
  float val = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < 8; i++) {
    if (i >= octaves) break;
    val  += amp * perlin(p * freq);
    freq *= 2.0;
    amp  *= 0.5;
  }
  return val;
}

輪郭のにじみ処理

ミスレイ(物体に当たらなかったピクセル)に対して、minDist(レイが物体表面に最も近づいた距離)を使ってグラデーションをかけます。

float pl = max(0.1, perlin(vec3(uv * 3.0, u_time * 0.5) * 0.5) * 3.0);
pl = pow(pl, 1.5);
float outerFade = smoothstep(0.001 + 1.0 * pl, 0.0, minDist);

if (matID <= 0.0) {
  col = mix(col, albedo, outerFade);
}

やっていることを分解すると:

  1. Perlinノイズ pl を UV座標+時間で生成 → 輪郭の溶け幅が場所ごとに変わる
  2. pow(pl, 1.5) でコントラストを強調 → 溶ける部分とくっきりした部分のメリハリ
  3. smoothstepminDist を閾値と比較 → 物体に近いほど outerFade が1に近づく
  4. ミスレイのピクセルだけ albedo 色を混ぜる → 背景から物体色がにじみ出す表現

OKLCh色空間でカラーリング

なぜOKLChを使うのか

HSLやHSVで色相を回すと、知覚的な明度が一定になりません(黄色は明るく、青は暗く見える)。OKLChはOKLab色空間の極座標表現で、L(明度)・C(彩度)・h(色相角)を独立に操作しても知覚的な均一性が保たれます。

vec3 oklch(float L, float C, float h) {
  float a = C * cos(h);
  float b = C * sin(h);
  // OKLab → LMS → linear sRGB の変換
  // ...
}

使い方

空の色やアルベドの生成に使っています。

// 空の色:高明度・低彩度、UV座標で色相が微妙に変化
vec3 skyTop = oklch(0.95, 0.08, uv.x * 0.5 + uv.y * 0.5);

// アルベド:UV座標ベースで色相を変える
vec3 albedo = oklch(0.95, 0.08, uv.x + uv.y * 2.0);

C = 0.08 という低彩度に設定しているため、パステル調の淡い色味になります。色相 h をUV座標に連動させることで、画面全体にグラデーションが生まれます。

カメラとレイの構築

カメラは原点を中心にゆっくり周回します。

float camAngle = u_time * 0.2;
vec3  ro = vec3(cos(camAngle) * 3.5, 1.2, sin(camAngle) * 3.5);
vec3  fwd = normalize(-ro);

fwd * 1.8 の係数が焦点距離に相当し、値を大きくするほど望遠(狭い画角)、小さくするほど広角になります。

コメントアウトされたコード(実験の痕跡)

コード中にいくつかコメントアウトされた行がありますが、これらは表現を探る過程の実験コードです。

// col = albedo * diff * shadow;        // フルライティング版
// col *= max(0.75, shadow);            // 影を控えめにする版
// float edgeFade = grazeAngle;         // フレネル的な輪郭消し
// col = mix(col, skyCol, edgeFade);    // 輪郭を空色に溶かす
// col = mix(col, skyBottom, fogFactor); // 距離フォグ

最終的には albedo * max(0.5, ao) というシンプルなシェーディングに落ち着いています。AO だけで陰影をつけ、diffuse や shadow を外すことで、フラットだけど立体感のある独特の質感になっています。

まとめ

このシェーダーで使った主なテクニックを整理します。

テクニック 役割
SDF + smin プリミティブを有機的に融合
Soft Shadow + AO 計算コストを抑えつつ立体感を演出
Perlin Noise 輪郭の溶け幅を非均一に歪ませる
fBm 複雑な質感生成(今回は予備実装)
OKLCh 知覚均一なパステルカラー生成
minDist + smoothstep ミスレイを利用した輪郭にじみ

レイマーチングは「SDFを返す関数を書き換えるだけ」でシーンを自由に変えられるのが強みです。sminの k やPerlinノイズのスケールを変えるだけでも見た目が大きく変わるので、パラメータをいじりながら遊んでみてください。

全コード

全コード(クリックで展開)
precision highp float;

uniform float u_time;
uniform vec2  u_resolution;

// --- SDF primitives ---
float sdSphere(vec3 p, float r) {
  return length(p) - r;
}

float sdBox(vec3 p, vec3 b) {
  vec3 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0);
}

float sdTorus(vec3 p, vec2 t) {
  vec2 q = vec2(length(p.xz) - t.x, p.y);
  return length(q) - t.y;
}

float smin(float a, float b, float k) {
  float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
  return mix(b, a, h) - k * h * (1.0 - h);
}

mat2 rot(float a) {
  float c = cos(a), s = sin(a);
  return mat2(c, -s, s, c);
}

// --- Scene ---
vec2 map(vec3 p) {
  float t = u_time;

  vec3 ps = p - vec3(sin(t * 0.7) * 1.2, abs(sin(t * 1.1)) * 0.8 - 0.4, 0.0);
  float sphere = sdSphere(ps, 0.55);

  vec3 pb = p - vec3(cos(t * 0.4) * 1.5, 0.3, sin(t * 0.4) * 0.6);
  pb.xy = rot(t * 0.6) * pb.xy;
  pb.yz = rot(t * 0.4) * pb.yz;
  float box = sdBox(pb, vec3(0.3));

  float torus = sdTorus(p - vec3(0.0, sin(t * 0.5) * 0.5, 0.0), vec2(0.5, 0.1));

  float merged = box;
  merged = smin(merged, box, 0.3);
  merged = smin(merged, sphere, 0.3);
  merged = smin(merged, torus, 0.3);
  return vec2(merged, 1.0);
}

// --- Normal ---
vec3 calcNormal(vec3 p) {
  const float e = 0.001;
  return normalize(vec3(
    map(p + vec3(e, 0, 0)).x - map(p - vec3(e, 0, 0)).x,
    map(p + vec3(0, e, 0)).x - map(p - vec3(0, e, 0)).x,
    map(p + vec3(0, 0, e)).x - map(p - vec3(0, 0, e)).x
  ));
}

// --- Soft shadow / AO ---
float softShadow(vec3 ro, vec3 rd, float mint, float maxt, float k) {
  float res = 1.0, t = mint;
  for (int i = 0; i < 24; i++) {
    float h = map(ro + rd * t).x;
    if (h < 0.001) return 0.0;
    res = min(res, k * h / t);
    t += h;
    if (t > maxt) break;
  }
  return clamp(res, 0.0, 1.0);
}

float calcAO(vec3 pos, vec3 nor) {
  float occ = 0.0, sca = 1.0;
  for (int i = 0; i < 4; i++) {
    float h = 0.01 + 0.12 * float(i) / 3.0;
    occ += (h - map(pos + h * nor).x) * sca;
    sca *= 0.95;
  }
  return clamp(1.0 - 3.0 * occ, 0.0, 1.0);
}

// --- Raymarching ---
vec3 rayMarch(vec3 ro, vec3 rd) {
  float t = 0.0, matID = -1.0, minDist = 1e9;
  for (int i = 0; i < 128; i++) {
    vec2 res = map(ro + rd * t);
    minDist = min(minDist, res.x);
    if (res.x < 0.0005 * t) { matID = res.y; break; }
    if (t > 30.0) break;
    t += res.x;
  }
  return vec3(t, matID, minDist);
}

// --- Noise ---
vec3 perlinGrad(vec3 i) {
  vec3 h = fract(sin(vec3(
    dot(i, vec3(127.1, 311.7,  74.7)),
    dot(i, vec3(269.5, 183.3, 246.1)),
    dot(i, vec3(113.5, 271.9, 124.6))
  )) * 43758.5453);
  return normalize(h * 2.0 - 1.0);
}

float perlin(vec3 p) {
  vec3 i = floor(p);
  vec3 f = fract(p);
  vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
  return mix(
    mix(mix(dot(perlinGrad(i + vec3(0,0,0)), f - vec3(0,0,0)),
            dot(perlinGrad(i + vec3(1,0,0)), f - vec3(1,0,0)), u.x),
        mix(dot(perlinGrad(i + vec3(0,1,0)), f - vec3(0,1,0)),
            dot(perlinGrad(i + vec3(1,1,0)), f - vec3(1,1,0)), u.x), u.y),
    mix(mix(dot(perlinGrad(i + vec3(0,0,1)), f - vec3(0,0,1)),
            dot(perlinGrad(i + vec3(1,0,1)), f - vec3(1,0,1)), u.x),
        mix(dot(perlinGrad(i + vec3(0,1,1)), f - vec3(0,1,1)),
            dot(perlinGrad(i + vec3(1,1,1)), f - vec3(1,1,1)), u.x), u.y),
    u.z);
}

float fbm(vec3 p, int octaves) {
  float val = 0.0, amp = 0.5, freq = 1.0;
  for (int i = 0; i < 8; i++) {
    if (i >= octaves) break;
    val  += amp * perlin(p * freq);
    freq *= 2.0;
    amp  *= 0.5;
  }
  return val;
}

// --- OKLCh ---
vec3 oklch(float L, float C, float h) {
  float a = C * cos(h);
  float b = C * sin(h);
  float l_ = L + 0.3963377774 * a + 0.2158037573 * b;
  float m_ = L - 0.1055613458 * a - 0.0638541728 * b;
  float s_ = L - 0.0894841775 * a - 1.2914855480 * b;
  float l = l_ * l_ * l_;
  float m = m_ * m_ * m_;
  float s = s_ * s_ * s_;
  return vec3(
     4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
    -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
    -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
  );
}

// --- Main ---
void main() {
  vec2 uv = (gl_FragCoord.xy * 2.0 - u_resolution) / u_resolution.y;

  float camAngle = u_time * 0.2;
  vec3  ro       = vec3(cos(camAngle) * 3.5, 1.2, sin(camAngle) * 3.5);
  vec3  fwd      = normalize(-ro);
  vec3  camRight = normalize(cross(vec3(0, 1, 0), fwd));
  vec3  camUp    = cross(fwd, camRight);
  vec3  rd       = normalize(fwd * 1.8 + uv.x * camRight + uv.y * camUp);

  vec3 skyTop    = oklch(0.95, 0.08, uv.x * 0.5 + uv.y * 0.5);
  vec3 skyBottom = oklch(0.95, 0.00, uv.x * 0.5 + uv.y * 0.5 + 0.1);
  vec3 skyCol    = mix(skyBottom, skyTop, clamp(rd.y * 0.5 + 0.5, 0.0, 1.0));
  vec3 col       = skyCol;

  vec3  hit    = rayMarch(ro, rd);
  float t      = hit.x;
  float matID  = hit.y;
  float minDst = hit.z;
  vec3 pos = ro + rd * t;

  vec3 nor = calcNormal(pos);
  vec3 albedo = oklch(0.95, 0.08, uv.x + uv.y * 2.0);
  vec3  lightDir = normalize(vec3(1.5, 2.5, 1.0));
  float diff     = clamp(dot(nor, lightDir), 0.0, 1.0);
  float spec     = pow(clamp(dot(reflect(-lightDir, nor), -rd), 0.0, 1.0), 32.0);
  float shadow   = softShadow(pos + nor * 0.002, lightDir, 0.02, 8.0, 12.0);
  float ao       = calcAO(pos, nor);

  if (matID > 0.0) {
    col = albedo * max(0.5, ao);
  }

  float pl = max(0.1, perlin(vec3(uv.x * 3.0, uv.y * 3.0, u_time * 0.5) * 0.5) * 3.0);
  pl = pow(pl, 1.5);
  float outerFade = smoothstep(0.001 + 1.0 * pl, 0.0, minDst);

  if (matID <= 0.0) {
    col = mix(col, albedo, outerFade);
  }

  col = pow(clamp(col, 0.0, 1.0), vec3(0.4545));
  gl_FragColor = vec4(col, 1.0);
}

Discussion