Вступ до шейдерів WebGL — вершинні та фрагментні GLSL
Шейдери — це невеликі програми, що виконуються на GPU для кожної вершини
й кожного пікселя. Цей урок навчає GLSL з нуля: вбудовані
змінні, типи, varying, uniform-змінні, а також те, як реалізувати зміщення
вершин і кольорові градієнти за допомогою Three.js
ShaderMaterial.
- Пройдений урок Основи Three.js (або еквівалентний досвід)
- Базова алгебра — зустрінуться скалярні добутки та нормалізація
- Попередні знання GLSL не потрібні
Швидкий довідник типів даних 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>