본문 바로가기
Graphic/ThreeJS

31 MeshStandardMaterial 셰이더 확장(Modified materials)

by curious week 2025. 8. 5.

MeshStandardMaterial 셰이더 확장 및 Twist 애니메이션 적용

✨ 개요

Three.js의 MeshStandardMaterial은 조명, 그림자, 텍스처, PBR(물리 기반 렌더링) 등을 내장한 강력한 재질입니다. 하지만 고유 셰이더를 완전히 새로 작성하지 않고도 이 재질에 사용자 정의 애니메이션을 적용할 수 있습니다. 이 문서에서는 Three.js의 onBeforeCompile 훅을 활용하여 머리 모델을 비틀어(twist) 애니메이션을 적용하는 방법을 단계별로 설명합니다.


⚖️ 셋업

  • 모델: Lee Perry-Smith의 고해상도 얼굴 모델
  • 재질: MeshStandardMaterial (map, normalMap 포함)
  • 목표: 머리 메시에 비틀림(twist) 애니메이션을 적용하고, 그림자와 노멀도 일관성 있게 수정
const material = new THREE.MeshStandardMaterial({
  map: texture,
  normalMap: normalTexture,
});

🔨 onBeforeCompile 훅을 이용한 셰이더 수정

onBeforeCompile(shader)는 재질이 컴파일되기 전에 셰이더 코드에 접근할 수 있게 해주는 훅입니다.

material.onBeforeCompile = (shader) => {
  console.log(shader.vertexShader);
};

셰이더 내부에는 #include <begin_vertex>, #include <common>과 같은 chunk가 있으며, 이는 Three.js 내부 ShaderChunk 디렉터리에 정의되어 있습니다. 이 chunk들을 replace()하여 필요한 GLSL 코드를 삽입할 수 있습니다.


🌪️ Twist 효과 적용 (Vertex Shader)

1. 2D 회전 행렬 함수 정의

mat2 get2dRotateMatrix(float angle) {
  return mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
}

2. common chunk에 회전 행렬 추가

shader.vertexShader = shader.vertexShader.replace(
  '#include <common>',
  `#include <common>
   uniform float uTime;
   mat2 get2dRotateMatrix(float angle) {
     return mat2(cos(angle), -sin(angle), sin(angle), cos(angle));
   }`,
);

3. begin_vertex chunk에 twist 적용

shader.vertexShader = shader.vertexShader.replace(
  '#include <begin_vertex>',
  `#include <begin_vertex>
   float angle = (position.y + uTime) * 0.9;
   mat2 rotateMatrix = get2dRotateMatrix(angle);
   transformed.xz = rotateMatrix * transformed.xz;`,
);

🕐 시간 애니메이션 적용 (uniform 전달)

const customUniforms = {
  uTime: { value: 0 },
};

material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = customUniforms.uTime;
  // ... 위 셰이더 수정 코드 포함
};

const clock = new THREE.Clock();
function tick() {
  customUniforms.uTime.value = clock.getElapsedTime();
  // renderer.render(...)
}

☁️ 그림자 반영을 위한 customDepthMaterial 설정

Three.js는 그림자 렌더링 시 MeshDepthMaterial을 사용합니다. 이 재질도 동일하게 비틀어야 합니다.

const depthMaterial = new THREE.MeshDepthMaterial({
  depthPacking: THREE.RGBADepthPacking
})

depthMaterial.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = customUniforms.uTime;
  shader.vertexShader = shader.vertexShader.replace(... 동일하게 twist 적용)
}

mesh.customDepthMaterial = depthMaterial;

⚡️ 노멀도 회전시켜서 조명 버그 수정

objectNormal을 twist 시키기 위해 beginnormal_vertex를 수정합니다.

1. beginnormal_vertex 수정

shader.vertexShader = shader.vertexShader.replace(
  '#include <beginnormal_vertex>',
  `#include <beginnormal_vertex>
   float angle = (position.y + uTime) * 0.9;
   mat2 rotateMatrix = get2dRotateMatrix(angle);
   objectNormal.xz = rotateMatrix * objectNormal.xz;`,
);

2. 중복 선언 방지 위해 begin_vertex에서는 rotateMatrix 재사용

shader.vertexShader = shader.vertexShader.replace(
  '#include <begin_vertex>',
  `#include <begin_vertex>
   transformed.xz = rotateMatrix * transformed.xz;`,
);

🚀 응용

  • angle = sin(position.y + uTime) * 0.4 와 같이 더욱 다양한 패턴 적용 가능
  • uTime을 GUI 컨트롤러와 연동하여 사용자 조작 지원 가능
  • get2dRotateMatrix를 다른 변환으로 바꾸면 확장 가능

관련 GLSL 함수 설명

cos(x) / sin(x) x 라디안 값에 대한 삼각 함수 계산
mat2(...) 2x2 행렬 생성
transform.xz = mat2 * vec2 2D 회전 적용
objectNormal 버텍스 노멀 값, 조명 계산에 사용

🌟 결론

Three.js의 MeshStandardMaterial을 직접 재작성하지 않고도 onBeforeCompile을 통해 커스터마이징된 셰이더 애니메이션을 적용할 수 있습니다. 이 방식은 Three.js의 모든 PBR 기능을 유지하면서도 GLSL 수준의 유연함을 제공합니다.

Tip: depthMaterial, beginnormal_vertex, begin_vertex는 그림자, 조명, 위치 등에 핵심적으로 작용하는 부분이므로 chunk 명을 정확히 파악하여 변경해야 합니다.


import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import GUI from 'lil-gui';

/**
 * Debug GUI
 */
const gui = new GUI();

/**
 * Canvas & Scene
 */
const canvas = document.querySelector('canvas.webgl');
const scene = new THREE.Scene();

/**
 * Loaders
 */
const textureLoader = new THREE.TextureLoader();
const gltfLoader = new GLTFLoader();
const cubeTextureLoader = new THREE.CubeTextureLoader();

/**
 * 환경 맵 설정 - 씬의 배경 및 조명 환경 반사에 사용
 */
const environmentMap = cubeTextureLoader.load([
  '/textures/environmentMaps/0/px.jpg',
  '/textures/environmentMaps/0/nx.jpg',
  '/textures/environmentMaps/0/py.jpg',
  '/textures/environmentMaps/0/ny.jpg',
  '/textures/environmentMaps/0/pz.jpg',
  '/textures/environmentMaps/0/nz.jpg',
]);
scene.background = environmentMap;
scene.environment = environmentMap;

/**
 * 텍스처 로드 - 색상 및 노멀 맵
 */
const mapTexture = textureLoader.load('/models/LeePerrySmith/color.jpg');
mapTexture.colorSpace = THREE.SRGBColorSpace;
const normalTexture = textureLoader.load('/models/LeePerrySmith/normal.jpg');

/**
 * 커스텀 uniform 정의
 */
const customUniforms = {
  uTime: { value: 0 }, // 시간에 따라 회전 변형을 적용할 때 사용
};

/**
 * 기본 재질 (쉐이더 확장 예정)
 */
const material = new THREE.MeshStandardMaterial({
  map: mapTexture,
  normalMap: normalTexture,
});

/**
 * 깊이 전용 재질 (섀도우 계산에 사용) + 커스텀 쉐이더 적용
 */
const depthMaterial = new THREE.MeshDepthMaterial({
  depthPacking: THREE.RGBADepthPacking,
});

/**
 * 모든 재질에 환경광 반응 및 그림자 설정 적용
 */
const updateAllMaterials = () => {
  scene.traverse((child) => {
    if (
      child instanceof THREE.Mesh &&
      child.material instanceof THREE.MeshStandardMaterial
    ) {
      child.material.envMapIntensity = 1;
      child.material.needsUpdate = true;
      child.castShadow = true;
      child.receiveShadow = true;
    }
  });
};

/**
 * 재질에 커스텀 셰이더 삽입 (회전 변형 추가)
 */
material.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = customUniforms.uTime;

  // 회전 행렬 함수 추가
  shader.vertexShader = shader.vertexShader.replace(
    '#include <common>',
    `#include <common>
     uniform float uTime;
     mat2 get2dRotateMatrix(float _angle) {
       return mat2(cos(_angle), -sin(_angle), sin(_angle), cos(_angle));
     }`,
  );

  // 노멀 회전
  shader.vertexShader = shader.vertexShader.replace(
    '#include <beginnormal_vertex>',
    `#include <beginnormal_vertex>
     float angle = (position.y + uTime) * 0.5;
     mat2 rotateMatrix = get2dRotateMatrix(angle);
     objectNormal.xz = rotateMatrix * objectNormal.xz;`,
  );

  // 위치 회전
  shader.vertexShader = shader.vertexShader.replace(
    '#include <begin_vertex>',
    `#include <begin_vertex>
     transformed.xz = rotateMatrix * transformed.xz;`,
  );
};

/**
 * 그림자용 depth material에도 동일한 쉐이더 삽입
 */
depthMaterial.onBeforeCompile = (shader) => {
  shader.uniforms.uTime = customUniforms.uTime;
  shader.vertexShader = shader.vertexShader.replace(
    '#include <common>',
    `#include <common>
     uniform float uTime;
     mat2 get2dRotateMatrix(float _angle) {
       return mat2(cos(_angle), -sin(_angle), sin(_angle), cos(_angle));
     }`,
  );
  shader.vertexShader = shader.vertexShader.replace(
    '#include <begin_vertex>',
    `#include <begin_vertex>
     float angle = (position.y + uTime) * 0.5;
     mat2 rotateMatrix = get2dRotateMatrix(angle);
     transformed.xz = rotateMatrix * transformed.xz;`,
  );
};

/**
 * 모델 로딩 및 장면에 추가
 */
gltfLoader.load('/models/LeePerrySmith/LeePerrySmith.glb', (gltf) => {
  const mesh = gltf.scene.children[0];
  mesh.rotation.y = Math.PI * 0.5;
  mesh.material = material; // 커스텀 쉐이더 적용
  mesh.customDepthMaterial = depthMaterial; // 그림자에도 동일한 쉐이더 사용
  scene.add(mesh);

  updateAllMaterials();
});

/**
 * 바닥 Plane
 */
const plane = new THREE.Mesh(
  new THREE.PlaneGeometry(15, 15),
  new THREE.MeshStandardMaterial(),
);
plane.rotation.y = Math.PI;
plane.position.set(0, -5, 5);
scene.add(plane);

/**
 * 조명
 */
const directionalLight = new THREE.DirectionalLight('#ffffff', 3);
directionalLight.castShadow = true;
directionalLight.shadow.mapSize.set(1024, 1024);
directionalLight.shadow.camera.far = 15;
directionalLight.shadow.normalBias = 0.05;
directionalLight.position.set(0.25, 2, -2.25);
scene.add(directionalLight);

/**
 * 창 크기 변화 대응
 */
const sizes = {
  width: window.innerWidth,
  height: window.innerHeight,
};
window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});

/**
 * 카메라 및 컨트롤
 */
const camera = new THREE.PerspectiveCamera(
  75,
  sizes.width / sizes.height,
  0.1,
  100,
);
camera.position.set(4, 1, -4);
scene.add(camera);

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

/**
 * 렌더러 설정
 */
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

/**
 * 애니메이션 루프
 */
const clock = new THREE.Clock();
const tick = () => {
  const elapsedTime = clock.getElapsedTime();
  customUniforms.uTime.value = elapsedTime;
  controls.update();
  renderer.render(scene, camera);
  window.requestAnimationFrame(tick);
};

tick();

'Graphic > ThreeJS' 카테고리의 다른 글

46 퍼포먼스 최적화  (6) 2025.08.06
45 후처리(Post-Processing)  (5) 2025.08.06
30 Animated galaxy  (5) 2025.08.05
29 Animated Water Shader  (2) 2025.08.04
28 Shader patterns  (4) 2025.08.04