서브서피스 스캐터링

Subsurface scattering

빛이 표면에서 바로 반사되지 않고 안으로 스며들어 산란하다가 다른 지점으로 빠져나오는 현상 — 피부·왁스·잎이 뒤에서 비춰도 은은히 빛나는 이유입니다.

다른 이름: SSSTranslucency
···
html
<div class="sub-caps"><span>no SSS</span><span>SSS approx · backlit glow</span></div>
css
.sub-caps{position:absolute;left:0;right:0;bottom:6%;display:flex;justify-content:space-around;font-size:clamp(8px,2.4vmin,12px);color:#dfe3ffcc;text-align:center;padding:0 3%}
js
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0b14);
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.4, 6.2);

const vertexShader = [
  'varying vec3 vNormal;',
  'varying vec3 vViewDir;',
  'void main() {',
  '  vNormal = normalize(normalMatrix * normal);',
  '  vec4 mv = modelViewMatrix * vec4(position, 1.0);',
  '  vViewDir = normalize(-mv.xyz);',
  '  gl_Position = projectionMatrix * mv;',
  '}',
].join('\n');

const fragmentShader = [
  'uniform vec3 uLightDir;',
  'uniform vec3 uColor;',
  'uniform float uSss;',
  'varying vec3 vNormal;',
  'varying vec3 vViewDir;',
  'void main() {',
  '  vec3 N = normalize(vNormal);',
  '  vec3 L = normalize(uLightDir);',
  '  float wrap = uSss * 0.6;',
  '  float diff = clamp((dot(N, L) + wrap) / (1.0 + wrap), 0.0, 1.0);',
  '  float back = uSss * pow(clamp(dot(-N, L), 0.0, 1.0), 1.4) * clamp(dot(vViewDir, -L), 0.0, 1.0);',
  '  vec3 col = uColor * (0.12 + diff * 0.9) + vec3(1.0, 0.25, 0.15) * back * 1.4;',
  '  gl_FragColor = vec4(col, 1.0);',
  '}',
].join('\n');

function makeSphere(x, sss) {
  const mat = new THREE.ShaderMaterial({
    uniforms: { uLightDir: { value: new THREE.Vector3(1, 1, 1) }, uColor: { value: new THREE.Color(0xffb1a0) }, uSss: { value: sss } },
    vertexShader, fragmentShader,
  });
  const mesh = new THREE.Mesh(new THREE.SphereGeometry(1.05, 64, 64), mat);
  mesh.position.x = x;
  return mesh;
}
const plain = makeSphere(-1.7, 0.0);
const sss = makeSphere(1.7, 1.0);
scene.add(plain, sss);

function resize() { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); }
addEventListener('resize', resize); resize();
renderer.setAnimationLoop((t) => {
  const a = t * 0.0006;
  const dir = new THREE.Vector3(Math.cos(a) * 1.4, 0.5, Math.sin(a) * 1.4 - 0.6);
  plain.material.uniforms.uLightDir.value.copy(dir);
  sss.material.uniforms.uLightDir.value.copy(dir);
  plain.rotation.y = a * 0.4; sss.rotation.y = a * 0.4;
  renderer.render(scene, camera);
});

보통의 셰이딩(램버트·PBR)은 빛이 표면에 닿아 그 자리에서 바로 반사된다고 가정합니다. 하지만 피부·대리석·왁스·나뭇잎처럼 어느 정도 반투명한 재질은 빛이 표면 아래로 스며들어 내부에서 이리저리 튕기다가(산란) 다른 지점으로 빠져나옵니다. 그 결과가 얇은 부위(귀, 손가락, 잎맥)를 뒤에서 비췄을 때 나타나는 붉고 은은한 광채입니다.

진짜 SSS 시뮬레이션은 빛이 표면 아래에서 얼마나 멀리 퍼지는지를 색(파장)마다 다르게 계산하는 무거운 연산입니다. 실시간 그래픽은 훨씬 싼 근사를 씁니다 — 이 데모가 쓰는 "랩 라이팅(wrap lighting)"은 조명 경계선을 표면 뒤쪽으로 살짝 눌러 펴고(N·L에 오프셋을 더함), 카메라 반대편에서 오는 빛을 얇을수록 더 강하게 통과시키는 항을 더해 "빛이 뚫고 지나간다"는 인상을 만듭니다.

Blender의 Principled 셰이더, Maya의 aiStandardSurface, C4D의 서브서피스 채널은 모두 이런 근사를 실시간 미리보기에 쓰고, 최종 렌더는 더 정확하지만 느린 산란 모델로 전환합니다.

언제 쓰나

피부·왁스·과일·잎처럼 얇거나 반투명한 유기물 재질을 표현할 때. 금속·돌처럼 빛을 안 통과시키는 재질에는 불필요합니다.