Search

threejs shader 기본

subtitle
mix로 gradient를 구현해보자
Tags
threejs
webGL
Created
2025/03/03
2 more properties

배경

threejs, webgl에서 가장 어려운 부분을 shader라고 느끼고 마냥 두려움만 가지고 있다가, threejs 오픈소스 프로젝트를 시작하면서 공부하기 시작했다. shader에 대해 어렵다고 느끼는 다른 사람들에게도 도움이 되었으면 하는 마음에 정리해 보았다.
threejs의 기본에 대해서 알고 있기만 하다면 이해하기 쉬울 것이다!
shader를 통해 gradient 색상의 mesh를 만들어서 shader의 기본을 배워보자.
본격적으로 shader를 시작하기 전에 gradient를 만드는 데 필요한 mix 함수에 대해 먼저 설명한다. 만약 shader를 먼저 보고 싶다면  아래 링크 클릭!

WebGL Shader의 mix() 함수와 선형 보간 (Linear Interpolation) 개념 정리

WebGL shader를 작성하다 보면 색상, 좌표, 애니메이션 값 등을 부드럽게 연결해야 하는 경우가 많다. 이때 자주 사용하는 함수가 바로 mix() 함수이다. mix()는 두 값 사이에서 특정 비율로 중간값을 구해주는 선형 보간 함수로, shader에서 다양한 상황에서 유용하게 활용된다.

Interpolation (보간)이란?

보간(interpolation)이란, 두 값 사이에서 중간값을 계산하는 기법이다. 예를 들어, 값이 0과 10일 때, 50% 위치의 값은 5가 된다.
이처럼 두 값 사이의 연속적인 값을 구하는 것이 보간이다.
보간 방식은 여러 가지가 있지만, 그 중 가장 기본적인 방식이 바로 선형 보간(Linear Interpolation)이다.

Linear Interpolation (선형 보간)이란?

선형 보간은 말 그대로 두 점을 직선으로 연결하고, 그 직선 위에서 원하는 비율의 값을 구하는 방법이다.
두 값 x와 y가 있을 때, 보간 비율 a가 0이면 시작점인 x, 1이면 끝점인 y가 된다.
0과 1 사이 값이라면, 그 비율만큼 두 값을 섞어준다.
수식으로 표현하면 다음과 같다.
result=x×(1a)+y×aresult=x×(1−a)+y×a
x : 시작점 값
y : 끝점 값
a : 보간 비율 (0~1)

수식 해석

a=0일 때: x만 남고 y는 사라진다.
a=1일 때: y만 남고 x는 사라진다.
a=0.5일 때: x와 y가 정확히 반반 섞인다.
이 방식은 색상, 위치, 크기 등 어떤 값이든 두 값 사이를 부드럽게 연결하는 데 사용된다.

WebGL Shader의 mix() 함수

WebGL GLSL에서는 위 수식을 그대로 구현한 함수가 바로 mix()이다.
mix()는 다음과 같이 사용한다.
mix(x, y, a)
GLSL
복사
이 함수는 다음과 같은 수식을 내부적으로 수행한다.
mix(x,y,a)=x×(1a)+y×amix(x,y,a)=x×(1−a)+y×a

왜 x에는 1-a를 곱하고, y에는 a를 곱할까?

a값이 작을 때에는 x의 비중을 크게, y의 비중을 작게하고, a값이 클 때에는 x의 비중을 작게, y의 비중을 크게 한다. a값이 커지면서 x의 비중은 점점 작아지고, y의 비중이 점점 커지면서 x와 y 사이의 값을 보간한다.

색상 보간 예시

두 색상을 위치(position.x)에 따라 선형 보간하는 예제를 통해, mix() 동작을 살펴보자.
vec3 color1 = vec3(1.0, 0.0, 0.0); // 빨간색 vec3 color2 = vec3(0.0, 0.0, 1.0); // 파란색 float t = position.x; // x 위치 (0 ~ 1) vec3 mixedColor = mix(color1, color2, t);
GLSL
복사
position.x (t)
계산식
결과색 (R, G, B)
설명
0.0
(1, 0, 0) × 1 + (0, 0, 1) × 0
(1.0, 0.0, 0.0)
빨간색
0.25
(1, 0, 0) × 0.75 + (0, 0, 1) × 0.25
(0.75, 0.0, 0.25)
보라빛 붉은색
0.5
(1, 0, 0) × 0.5 + (0, 0, 1) × 0.5
(0.5, 0.0, 0.5)
보라색
0.75
(1, 0, 0) × 0.25 + (0, 0, 1) × 0.75
(0.25, 0.0, 0.75)
보라빛 푸른색
1.0
(1, 0, 0) × 0 + (0, 0, 1) × 1
(0.0, 0.0, 1.0)
파란색

설명

position.x가 0일 때는 빨간색(color1)
position.x가 1일 때는 파란색(color2)
x 위치에 따라 빨강 → 파랑으로 자연스럽게 변화하는 그라데이션을 만들 수 있다.

예제 코드를 통해 shader의 기본에 대해 배워보자.

예제 코드

threejs와 glsl로 직접 그라데이션을 구현해보자.
main.js
import * as THREE from "three"; import { vertexShader, fragmentShader } from "./shader.js"; // scene, camera, renderer 기본 설정 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.z = 2; const renderer = new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); // plane mesh 생성 const geometry = new THREE.PlaneGeometry(2, 2); const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, }); const plane = new THREE.Mesh(geometry, material); scene.add(plane); renderer.render(scene, camera);
JavaScript
복사
shader.js
export const vertexShader = ` out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; export const fragmentShader = ` in vec2 vUv; void main() { vec3 red = vec3(1.0, 0.0, 0.0); vec3 blue = vec3(0.0, 0.0, 1.0); float x = vUv.x; vec3 mixedColor = mix(red, blue, x); gl_FragColor = vec4(mixedColor, 1.0); } `;
JavaScript
복사

Geometry, Material

threejs의 mesh는 geomtery, material 즉, 형태와 재질로 구성된다.
geometry: 메시의 형태(shape)을 정의하는 요소.
material: 오브젝트의 재질을 정의하고 refelxibility, metalness, roughness 등과 같은 특정 속성을 부여할 수 있다.

Shader

const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, });
JavaScript
복사
Shader는 GPU에서 동작하는 프로그램이다. Vertex ShaderFragment Shader로 구성되며, 3D 오브젝트의 형태와 색상을 직접 제어할 수 있다. 내부적으로 먼저 Vertex Shader가 실행되어 오브젝트 각 정점(vertex)의 위치를 변형하고, 이후 Fragment Shader가 실행되어 화면에 출력될 각 픽셀의 색상을 결정한다.
Vertex Shader에서는 정점 위치를 변경해 geometry, 즉 메쉬의 형태(shape)를 변형할 수 있고,
Fragment Shader에서는 해당 geometry의 각 픽셀 색상을 원하는 대로 변경할 수 있다.

Vertex Shader

export const vertexShader = ` out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `;
JavaScript
복사
shader는 glsl로 작성된 string을 전달하면 된다.
GLSL(OpenGL Shading Language, OpenGL 셰이딩 언어)는 C 언어를 기초로 한, 상위 레벨 셰이딩 언어이다. - wikipedia opengl, webgl 용 shader 언어.

out

vertex shader에서는 fragment shader에 전달할 값을 담는다.
fragment shader에서는 in이라는 키워드를 통해 vertex shader에서 정의한 값을 가져올 때 사용한다.
webgl 1 버전에서는 varying이라는 키워드를 쓰는데, threejs에서는 webgl1 을 지원을 중단해서 webgl 2 버전을 쓰는 게 좋을 것 같다.
glsl 3 버전에서 설명된 inputs, outputs 키워드를 보면 더 자세히 알 수 있다.
varying을 사용해도 동작하긴 하는데, threejs 내부에서 호환성을 맞추기 위해 webgl-fallback을 갖추고 있기 때문이다.

uv

uv map: 3D model을 2D 평면에 펼쳐놓은 텍스쳐 이미지. 아래 그림을 보면 이해하기 쉽다.
uv: 3D 모델 표면에서 각 정점(vertex)이 텍스처에서 어떤 위치에 대응하는지 나타내는 2차원 좌표. u는 x축 방향(0 ~ 1), v는 y축 방향(0 ~ 1)이다.
포럼에서 누가 그려서 올려놓은 이미지 ㅎㅎ
v prefix: v(or v_) prefix를 통해 이 값이 varying 값임을 알려주는 glsl의 레거시 컨벤션이다. varying은 앞서 말했듯이 in, out으로 변경되었지만 이런 컨벤션이 많이 쓰이고 있어서 썼다. (요새는 in_, out_이렇게 쓰나? 잘 모르겠다.)
  swizzling: threejs 내부 코드를 보면 vec3( uv, 1 ).xy 이라는 문법을 볼 수 있다. 이런 문법을 swizzling이라고 하는데, swizzling은 영어로는 뒤섞다라는 뜻이다. x, y, z, w 4개의 값을 조합해서 사용할 수 있기 때문에 이런 이름이 붙었다. uv.xyzw 이런 식으로 사용할 수 있다.

gl_Position

gl_Position에 값을 전달하면 해당 shader가 적용된 mesh의 vertex들의 위치를 변경할 수 있다.

modelViewMatrix

모델 행렬을 뷰 행렬로 변환한 행렬이다. 카메라가 보는 공간 안에서 모델이 어떤 위치를 가지는지 행렬로 표현한 것이다.
clip space: 카메라가 보는 공간을 변환한 것을 clip space라고 한다. 3d 공간 안에서는 우리의 눈과 달리 카메라가 어디부터 어디까지 볼지 정해져있기 때문에 잘려진 공간으로 표현할 수 있는 것이다.
카메라가 보는 시야각과 깊이를 2*2 크기의 공간으로 변환해서 표현한 것이라고 생각하면 되는데, 이미지를 보면 이해하기 더 쉽다.
webgl에서 position, rotation, scale의 변경은 행렬로 변환된다. 이 행렬 데이터를 통해, 모델이 카메라가 보는 공간 안에서 어떤 위치를 가지는지 계산할 수 있다.

projectionMatrix

월드 좌표를 클립 좌표로 변환한 것이다.
월드 좌표: scene 전체에서 월드의 원점(0, 0, 0)을 기준으로 각 모델이 어디에 있는지 나타낸다.
클립 좌표: 월드 좌표를 뷰 좌표(카메라 기준 좌표)로 변환한 다음, 카메라의 시야각과 깊이를 반영해서 정규화된 좌표계(-1 ~ 1 범위)로 바꾼 좌표.
투영 방법(perspective, orthographic)에 따라 다르게 계산한다.

position

model의 position 값을 threejs로부터 넘겨받는다.

모델의 위치를 화면에 나타내는 과정

월드 좌표 → 뷰 좌표 → 클립 좌표 → 화면 좌표(픽셀 좌표)
월드 좌표: scene 전체를 기준으로 한 위치
뷰 좌표: 카메라가 보는 공간을 좌표로 나타낸 것. position, rotation, scale 등의 모델 행렬 데이터를 사용해 모델 행렬을 카메라가 보는 공간 안에서 어떤 위치를 가지는지 뷰 행렬로 변환한다. (modelViewMatrix)
클립 좌표: 투영 행렬(projectionMatrix)을 통해 월드 좌표를 뷰 좌표로 변환한 후, -1 ~ 1 범위의 정규화된 위치에 나타낸다.
화면 좌표: 우리가 실제로 보는 화면은 2D이다. 클립 좌표를 뷰포트에 반영해, 이 2D 화면에서 어떤 픽셀에 어떤 값을 나타낼지 정한다.
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
GLSL
복사
위 코드는 모델의 위치를 카메라 좌표에 표시하고 이걸 클립 좌표로 나태는 것을 수식으로 나타낸 것이다.

Fragment Shader

vertex shader가 실행되고 나면 fragment shader가 실행된다.
in vec2 vUv; void main() { vec3 red = vec3(1.0, 0.0, 0.0); vec3 blue = vec3(0.0, 0.0, 1.0); float x = vUv.x; vec3 mixedColor = mix(red, blue, x); gl_FragColor = vec4(mixedColor, 1.0); }
GLSL
복사

in

in 키워드를 통해 vertex shader에서 계산된 값을 넘겨 받는다. vertex shader에서 정의한 이름과 같은 이름을 사용해야 한다.

mix

mix는 위에서 설명했으니 넘어가겠다.

gl_FragColor

gl_FragColor에 값을 넘기면 mesh의 color를 변경할 수 있다.

Gradient Animation

예제 코드

main.js
import * as THREE from "three"; import { vertexShader, fragmentShader } from "./shader.js"; // scene, camera, renderer 기본 설정 const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 ); camera.position.z = 2; const renderer = new THREE.WebGLRenderer(); renderer.setSize(window.innerWidth, window.innerHeight); document.body.appendChild(renderer.domElement); // plane mesh 생성 const geometry = new THREE.PlaneGeometry(2, 2); const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, // 이 부분 추가 uniforms: { uTime: { value: 0 }, }, }); const plane = new THREE.Mesh(geometry, material); scene.add(plane); // 이 부분 추가 function animate(time) { material.uniforms.uTime.value = time * 0.001; renderer.render(scene, camera); requestAnimationFrame(animate); } animate();
JavaScript
복사
shader.js
export const vertexShader = ` out vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `; export const fragmentShader = ` in vec2 vUv; uniform float uTime; void main() { vec3 red = vec3(1.0, 0.0, 0.0); vec3 blue = vec3(0.0, 0.0, 1.0); float endPoint = 2.0; float speed = 0.5; float localTime = uTime * speed + (endPoint - vUv.x); float t = mod(localTime, endPoint); if (t > 1.0) { t = endPoint - t; } vec3 mixedColor = mix(red, blue, t); gl_FragColor = vec4(mixedColor, 1.0); } `;
JavaScript
복사

uniforms

threejs에서 shaderMaterial에 uniforms 객체를 넣어주면 원하는 변수를 shader에 넘겨줄 수 있다.
const material = new THREE.ShaderMaterial({ vertexShader, fragmentShader, // 이 부분 추가 uniforms: { uTime: { value: 0 }, }, });
JavaScript
복사
animate 함수에서 javascript의 시간 값으로 uniforms의 uTime.value에 업데이트해줄 수 있다.
material.uniforms.uTime.value = time * 0.001;
JavaScript
복사

animation

uTime 값을 활용해 시간과 x 좌표값에 따라 색상을 달리줘서 그라데이션이 이동하는 느낌의 애니메이션을 만들 수 있다.
fragment shader에 추가된 코드
float endPoint = 2.0; float speed = 0.5; float localTime = uTime * speed + (endPoint - vUv.x); float t = mod(localTime, endPoint); if (t > 1.0) { t = endPoint - t; }
GLSL
복사
endPoint: 시작점 기준으로, 빨강 → 파랑 → 빨강 이렇게 2 phase로 계속 이어져야하기 때문에 2로 잡음. 1로 잡으면 빨강 → 파랑, 빨강 → 파랑 이렇게 애니메이션이 다시 시작되면서 뚝 끊기는 느낌이 든다.
speed: 속도. uTime에 곱해서 가중치로 사용. 값을 줄일 수록 느려지고, 높일 수록 애니메이션이 빨라진다.
endPoint - vUv.x: endPoint는 빨강. x는 0일 때 빨강, x는 1일 때 파랑. 파랑부터 시작하고 싶으면 1 - vUv.x 로 변경하면 된다.
mod: 첫번째 파라미터가 두번째 파라미터 값으로 나눈 값의 나머지이다. 0 → 2로 가다가 2를 넘으면 다시 0부터 시작한다.
t가 1을 초과할 때 endPoint - t 로 다시 계산해서, 0.1, 0.2, 0.3, …, 1, 0.9, 08, 0.7, …과 같이 1에서부터 다시 값이 줄어들게 만든다.

github code

threejs-playground
roseline124
pnpm i pnpm dev
Plain Text
복사

References

https://ko.wikipedia.org/wiki/GLSL#cite_note-13 마지막에 glsl 버전 명시되어 있음