Урок
⏱️ ~50 хвилин 🎓 Середній 🛠️ GLSL · Three.js · ShaderMaterial

Вступ до шейдерів WebGL — вершинні та фрагментні GLSL

Шейдери — це невеликі програми, що виконуються на GPU для кожної вершини й кожного пікселя. Цей урок навчає GLSL з нуля: вбудовані змінні, типи, varying, uniform-змінні, а також те, як реалізувати зміщення вершин і кольорові градієнти за допомогою Three.js ShaderMaterial.

Передумови

Швидкий довідник типів даних GLSL

GLSL — це C-подібна мова. Вона має скалярні, векторні та матричні типи. Ось ті, які ви використовуватимете найчастіше:

Тип Опис Приклад
float 32-бітне число з плаваючою комою float t = 2.0;
int Ціле число (для арифметики використовуйте float) int i = 3;
bool Булеве значення bool flag = true;
vec2 2D-вектор vec2 uv = vec2(0.5, 0.5);
vec3 3D-вектор (також колір RGB) vec3 pos = position;
vec4 4D-вектор (RGBA, однорідні координати) gl_FragColor = vec4(1,0,0,1);
mat3 Матриця 3×3 mat3 m = mat3(1.0); (одинична)
mat4 Матриця 4×4 Матриці проєкції/моделі
sampler2D Дескриптор 2D-текстури texture2D(uTex, vUv)

Доступ до компонентів здійснюється через .xyzw або .rgba (псевдоніми): vec3 c = vec3(1,0.5,0); c.r = 1.0; c.xy = vec2(0, 1);

Свізлінг дозволяє переставляти компоненти: vec4 v = pos.xyzz; або vec2 flip = v.yx;

Ваш перший ShaderMaterial

ShaderMaterial очікує два GLSL-рядки: вершинний шейдер (виконується для кожної вершини) та фрагментний шейдер (виконується для кожного пікселя/фрагмента).

import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

const material = new THREE.ShaderMaterial({
  vertexShader: /* glsl */`
    void main() {
      // projectionMatrix * modelViewMatrix = матриця MVP
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    void main() {
      // gl_FragColor = vec4(R, G, B, A)
      gl_FragColor = vec4(0.2, 0.6, 1.0, 1.0); // суцільний синій
    }
  `,
});

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), material);
scene.add(mesh);

Three.js автоматично додає у ваш шейдер вбудовані uniform-змінні: projectionMatrix, modelViewMatrix, modelMatrix, viewMatrix, normalMatrix. Вбудовані атрибути: position, normal, uv, color.

Varying — передача даних вершинний → фрагментний

Вершинний шейдер виводить змінну varying; GPU інтерполює її по трикутнику, а фрагментний шейдер отримує інтерпольоване значення:

const material = new THREE.ShaderMaterial({
  vertexShader: /* glsl */`
    varying vec2 vUv;     // ← оголошуємо як varying (вихід)
    void main() {
      vUv = uv;           // uv — вбудований атрибут (0–1 по поверхні)
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    varying vec2 vUv;     // ← той самий varying (вхід, тепер інтерпольований)
    void main() {
      // vUv.x = 0 на лівому краю, 1 на правому
      // vUv.y = 0 знизу, 1 зверху
      gl_FragColor = vec4(vUv.x, vUv.y, 0.5, 1.0);
    }
  `,
});

Ви побачите градієнт: червоний зростає зліва→направо, зелений зростає знизу→вгору, синій сталий — 0.5. Це основа всього UV-текстурування та градієнтів.

Uniform-змінні — анімація на основі часу

Змінні uniform надходять із JavaScript і є однаковими для кожної вершини/фрагмента в межах одного виклику відмалювання:

const material = new THREE.ShaderMaterial({
  uniforms: {
    uTime:  { value: 0.0 },
    uColor: { value: new THREE.Color(0x00ffcc) },
  },
  vertexShader: /* glsl */`
    uniform float uTime;
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    uniform float uTime;
    uniform vec3 uColor;
    varying vec2 vUv;
    void main() {
      float pulse = 0.5 + 0.5 * sin(uTime * 2.0 + vUv.y * 6.28);
      gl_FragColor = vec4(uColor * pulse, 1.0);
    }
  `,
});

// Оновлюємо в animate():
material.uniforms.uTime.value = time * 0.001; // переводимо мс у секунди

Зміщення вершин

Вершинний шейдер може змінювати форму меша під час виконання — це відбувається повністю на GPU і нічого не коштує CPU:

vertexShader: /* glsl */`
  uniform float uTime;
  varying vec3 vNormal;

  void main() {
    vNormal = normal;

    // Зміщуємо вздовж нормалі за допомогою синусоїди
    float freq = 3.0;
    float amp  = 0.15;
    float wave = sin(position.y * freq + uTime * 2.0)
               * cos(position.x * freq + uTime * 1.5);
    vec3 displaced = position + normal * amp * wave;

    gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
  }
`

Важливо: зміщення вершин не оновлює нормалі меша для освітлення. Для коректного освітлення зміщеної геометрії перерахуйте нормалі в шейдері або скористайтеся MeshNormalMaterial, щоб спершу налагодити нормалі.

Прийоми фарбування у фрагментному шейдері

Корисні вбудовані функції GLSL для роботи з кольором:

Функція Що робить Діапазон
sin(x) / cos(x) Осцилятор. Поєднуйте з uTime для анімації. ℝ → [-1,1]
smoothstep(e0, e1, x) Плавний перехід 0→1 між e0 та e1. Чудово для країв. [e0,e1] → [0,1]
mod(x, y) x за модулем y. Створює повторювані візерунки. ℝ → [0,y)
fract(x) Дробова частина x. fract(uv*10.) → тайлінг. ℝ → [0,1)
mix(a, b, t) Лінійна інтерполяція. mix(red, blue, vUv.x). t ∈ [0,1]
length(v) Довжина вектора. Для радіальних візерунків. vec → float ≥ 0
dot(a, b) Скалярний добуток. Використовується для дифузного освітлення. vec, vec → float
normalize(v) Одиничний вектор. Напрямок без довжини. vec → одиничний vec

Приклад — радіальний градієнт з анімованою пульсацією:

fragmentShader: /* glsl */`
  uniform float uTime;
  varying vec2 vUv;
  void main() {
    vec2 centered = vUv - 0.5;       // зсуваємо початок координат до центру
    float dist = length(centered);   // 0 у центрі, 0.5 на краю

    float rings = sin(dist * 20.0 - uTime * 3.0); // анімовані кільця
    float brightness = smoothstep(-1.0, 1.0, rings);

    vec3 col = mix(vec3(0.0, 0.1, 0.3), vec3(0.0, 0.8, 1.0), brightness);
    gl_FragColor = vec4(col, 1.0);
  }
`

Повний хвильовий шейдер

Об'єднуємо все разом — сфера з анімованим зміщенням вершин і відповідним кольором у фрагментному шейдері:

<script type="module">
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.168/build/three.module.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x04080f);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 100);
camera.position.set(0, 0, 3);

const mat = new THREE.ShaderMaterial({
  uniforms: { uTime: { value: 0 } },
  vertexShader: /* glsl */`
    uniform float uTime;
    varying float vDisplace;
    void main() {
      float wave = sin(position.y * 4.0 + uTime * 2.0)
                 * cos(position.x * 3.0 + uTime * 1.5);
      vDisplace = wave; // передаємо у фрагментний шейдер
      vec3 newPos = position + normal * 0.12 * wave;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
    }
  `,
  fragmentShader: /* glsl */`
    varying float vDisplace;
    void main() {
      float t = 0.5 + 0.5 * vDisplace;
      vec3 col = mix(vec3(0.0, 0.2, 0.5), vec3(0.1, 0.9, 0.7), t);
      gl_FragColor = vec4(col, 1.0);
    }
  `,
});

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 64, 64), mat);
scene.add(mesh);

window.addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

let prev = performance.now();
(function loop(t) {
  requestAnimationFrame(loop);
  mat.uniforms.uTime.value = t * 0.001;
  mesh.rotation.y += (t - prev) / 1000 * 0.3;
  prev = t;
  renderer.render(scene, camera);
})(performance.now());
</script>

Продовжуйте навчання

🛠

Експериментуйте у Playground

Пишіть, запускайте та налаштовуйте шейдери Three.js / GLSL прямо у браузері — без жодного налаштування.

Відкрити Playground → Переглянути симуляцію ↗