В стандартном стеке Three.js разработчики обычно оперируют высокоуровневыми материалами: MeshStandardMaterial, MeshPhysicalMaterial или MeshBasicMaterial. Эти абстракции инкапсулируют физически корректный рендеринг (PBR) и скрывают под капотом сотни строк низкоуровневого кода. Однако при решении задач генеративной графики, визуализации физических полей, рендеринга процедурных органических поверхностей или высокопроизводительных микро-деформаций стандартные шейдеры накладывают критические ограничения по вычислительной сложности и гибкости.
Каждый графический вызов отрисовки (Draw Call) проходит через фиксированный графический конвейер WebGL:
┌─────────────────────────────────────────────────────────────────────────────┐
│ WEBGL 2.0 GRAPHICS PIPELINE │
│ │
│ [Vertex Attributes] (Positions, Normals, UVs in VBO) │
│ │ │
│ ▼ │
│ [Vertex Shader] ──► gl_Position = projectionMatrix * modelViewMatrix * v │
│ │ ──► Расчет смещения вершин, расчет нормалей, varying │
│ ▼ │
│ [Primitive Assembly & Rasterization] ──► Преобразование в фрагменты (screen)│
│ │ ──► Интерполяция varying (vUv, vNormal)│
│ ▼ │
│ [Fragment Shader] ──► Вычисление цвета каждого фрагмента (SDF, BRDF, Light)│
│ │ ──► gl_FragColor / outColor │
│ ▼ │
│ [Per-Sample Operations] ──► Depth Test, Stencil Test, Blending (Framebuffer)│
└─────────────────────────────────────────────────────────────────────────────┘Исполнение шейдерного кода на языке GLSL (OpenGL Shading Language) выполняется параллельно на тысячах потоковых процессоров видеокарты. В отличие от однопоточного JavaScript в V8, где цикл по миллиону вершин блокирует поток выполнения на десятки миллисекунд, GPU выполняет параллельную обработку вершин и фрагментов с пропускной способностью памяти в сотни гигабайт в секунду.
В архитектуре React Three Fiber интеграция кастомного GLSL строится вокруг декларативного связывания Three.js-класса THREE.ShaderMaterial с деревом компонентов React Fiber. Для устранения накладных расходов синхронизации реактивного состояния и видеопамяти R3F использует специализированные примитивы генерации материалов и прямой доступ к структурам Uniform.
Ментальная модель работы с кастомными шейдерами в React Three Fiber разделяет жизненный цикл приложения на три изолированных слоя:
useFrame происходит мутацией полей по прямой ссылке, исключая вызовы setState и ререндеры виртуального дерева.┌──────────────────────────────────────────────────────────────┐
│ CPU: REACT FIBER RUNTIME │
│ const matRef = useRef<CustomShaderMaterialImpl>(null); │
│ useFrame((state) => { │
│ matRef.current.uTime = state.clock.getElapsedTime(); │
│ }); │
└──────────────────────────────┬───────────────────────────────┘
│ (Прямая мутация uniform-буфера)
▼
┌──────────────────────────────────────────────────────────────┐
│ DRIVER / UNIFORM BUFFER │
│ uniform float uTime; │
│ uniform vec3 uColor; │
└──────────────────────────────┬───────────────────────────────┘
│
┌────────────────┴────────────────┐
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ VERTEX SHADER (SIMD) │ │ FRAGMENT SHADER (SIMD) │
│ - Смещение сетки (Noise) │────►│ - Процедурный SDF-градиент│
│ - Деформация нормалей │ vUv │ - Расчет освещения Френеля│
│ - gl_Position матрицы │ │ - gl_FragColor = outColor │
└───────────────────────────┘ └───────────────────────────┘Для упрощения фабрикации кастомных материалов в экосистеме Drei применяется функция shaderMaterial. Она генерирует типизированный класс Three.js-материала с автоматическим созданием геттеров и сеттеров для всех переданных Uniform-переменных. Регистрация материала через функцию extend делает его доступным в виде нативного JSX-тега в дереве компонентов.
Написание вертексного и фрагментного шейдеров на GLSL с явным определением матриц трансформаций и uniform-параметров.
Генерация класса материала с автоматическим маппингом свойств uniform в свойства JavaScript-объекта.
Интеграция класса в reconciler R3F для декларативного использования в виде JSX-тега с авто-управлением жизненным циклом.
Обновление динамических параметров (время, координаты курсора) прямой мутацией ref.current без вызова React setState.
Принудительная прекомпиляция шейдерной программы через gl.compile перед добавлением объекта в активный фрустум камеры.
Каким образом данные о текстурных координатах (UV) и модифицированных в вертексном шейдере нормалях передаются во фрагментный шейдер для расчета освещения?
В морских симуляторах и 3D-конфигураторах водная гладь не анимируется морфингом тысяч ключевых кадров в JavaScript. Вертексный шейдер принимает координаты плоской сетки (PlaneGeometry высокой плотности) и на лету смещает координату position.z суммой нескольких синусоид Герстнера (Gerstner waves) или 3D-шумом Перлина. На фрагментном этапе рассчитывается вектор Френеля (Fresnel factor), создающий фотореалистичное отражение неба под острыми углами обзора и прозрачность на мелководье.
Для создания научно-фантастических интерфейсов, щитов и энергетических барьеров стандартные материалы непригодны из-за отсутствия сканирующих линий и краевого свечения. Кастомный фрагментный шейдер рассчитывает скалярное произведение вектора взгляда камеры и нормали поверхности (dot(viewDirection, normal)). На краях объекта значение стремится к нулю, что позволяет сгенерировать яркое неоновое свечение (Rim light), промодулированное бегущей полосой синусоиды от uTime.
В геоинформационных дашбордах и симуляторах рельефа передача терабайтов полигональных данных по сети невозможна. Вместо этого передается низкополигональная базовая сетка, а детализация высот вычисляется процедурным фрактальным броуновским движением (fBm — Fractal Brownian Motion) непосредственно в вертексном шейдере на GPU. Фрагментный шейдер в зависимости от рассчитанной высоты и наклона полигона процедурно смешивает цвета пород, травы и снега без единой внешней текстуры.
В инженерном софте требуется отображать динамические градиенты распределения температур или напряжений. Шейдер получает массив контрольных точек через Data Texture или Uniform Array. Фрагментный шейдер рассчитывает аналитическое знакопеременное поле расстояний (Signed Distance Field) и отображает изолинии (контурные линии равного потенциала) с субпиксельным сглаживанием через деривативы fwidth().
При смещении вершин в вертексном шейдере критически важно скорректировать нормали геометрии. Если изменить position.xyz без пересчета normal, расчет освещения во фрагментном шейдере будет опираться на старые плоские нормали, что приведет к визуальному разрыву между геометрической формой и тенями.
Для аналитического или процедурного шума нормаль вычисляется методом конечных разностей (finite difference method): смещаются две соседние бесконечно близкие точки сетки по осям X и Y, после чего берется их векторное произведение (cross product):
// Расчет скорректированной нормали после смещения шумом
vec3 displacedPosition = position + normal * getDisplacement(position);
float eps = 0.001;
vec3 tangentX = vec3(eps, 0.0, getDisplacement(position + vec3(eps, 0.0, 0.0)) - getDisplacement(position));
vec3 tangentY = vec3(0.0, eps, getDisplacement(position + vec3(0.0, eps, 0.0)) - getDisplacement(position));
vec3 correctedNormal = normalize(cross(tangentX, tangentY));Создадим процедурный пульсирующий материал с шумом Перлина и эффектом Френеля с использованием shaderMaterial и R3F.
import * as THREE from 'three';
import { shaderMaterial } from '@react-three/drei';
import { extend, ReactThreeFiber } from '@react-three/fiber';
// 1. GLSL код вертексного шейдера
const vertexShader = /* glsl */ `
uniform float uTime;
uniform float uDistortionScale;
uniform float uDistortionFrequency;
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vViewPosition;
// 3D Simplex / Perlin noise генератор
vec4 permute(vec4 x) { return mod(((x*34.0)+1.0)*x, 289.0); }
vec4 taylorInvSqrt(vec4 r) { return 1.79284291400159 - 0.85373472095314 * r; }
float snoise(vec3 v){
const vec2 C = vec2(1.0/6.0, 1.0/3.0);
const vec4 D = vec4(0.0, 0.5, 1.0, 2.0);
vec3 i = floor(v + dot(v, C.yyy));
vec3 x0 = v - i + dot(i, C.xxx);
vec3 g = step(x0.yzx, x0.xyz);
vec3 l = 1.0 - g;
vec3 i1 = min(g.xyz, l.zxy);
vec3 i2 = max(g.xyz, l.zxy);
vec3 x1 = x0 - i1 + 1.0 * C.xxx;
vec3 x2 = x0 - i2 + 2.0 * C.xxx;
vec3 x3 = x0 - 1.0 + 3.0 * C.xxx;
i = mod(i, 289.0);
vec4 p = permute(permute(permute(
i.z + vec4(0.0, i1.z, i2.z, 1.0))
+ i.y + vec4(0.0, i1.y, i2.y, 1.0))
+ i.x + vec4(0.0, i1.x, i2.x, 1.0));
float n_ = 0.142857142857;
vec3 ns = n_ * D.wyz - D.xzx;
vec4 j = p - 49.0 * floor(p * ns.z *ns.z);
vec4 x_ = floor(j * ns.z);
vec4 y_ = floor(j - 7.0 * x_);
vec4 x = x_ *ns.x + ns.yyyy;
vec4 y = y_ *ns.x + ns.yyyy;
vec4 h = 1.0 - abs(x) - abs(y);
vec4 b0 = vec4(x.xy, y.xy);
vec4 b1 = vec4(x.zw, y.zw);
vec4 s0 = floor(b0)*2.0 + 1.0;
vec4 s1 = floor(b1)*2.0 + 1.0;
vec4 sh = -step(h, vec4(0.0));
vec4 a0 = b0.xzyw + s0.xzyw*sh.xxyy;
vec4 a1 = b1.xzyw + s1.xzyw*sh.zzww;
vec3 p0 = vec3(a0.xy, h.x);
vec3 p1 = vec3(a0.zw, h.y);
vec3 p2 = vec3(a1.xy, h.z);
vec3 p3 = vec3(a1.zw, h.w);
vec4 norm = taylorInvSqrt(vec4(dot(p0,p0), dot(p1,p1), dot(p2, p2), dot(p3,p3)));
p0 *= norm.x; p1 *= norm.y; p2 *= norm.z; p3 *= norm.w;
vec4 m = max(0.6 - vec4(dot(x0,x0), dot(x1,x1), dot(x2,x2), dot(x3,x3)), 0.0);
m = m * m;
return 42.0 * dot(m*m, vec4(dot(p0,x0), dot(p1,x1), dot(p2,x2), dot(p3,x3)));
}
void main() {
vUv = uv;
vNormal = normalize(normalMatrix * normal);
// Вычисление процедурного 3D-смещения вершины
vec3 coords = position * uDistortionFrequency + vec3(uTime * 0.4);
float elevation = snoise(coords) * uDistortionScale;
vec3 transformedPosition = position + normal * elevation;
// Преобразование вершины в видовое и проекционное пространство
vec4 mvPosition = modelViewMatrix * vec4(transformedPosition, 1.0);
vViewPosition = -mvPosition.xyz;
gl_Position = projectionMatrix * mvPosition;
}
`;
// 2. GLSL код фрагментного шейдера
const fragmentShader = /* glsl */ `
uniform vec3 uColorBase;
uniform vec3 uColorGlow;
uniform float uFresnelPower;
varying vec2 vUv;
varying vec3 vNormal;
varying vec3 vViewPosition;
void main() {
vec3 normal = normalize(vNormal);
vec3 viewDir = normalize(vViewPosition);
// Вычисление эффекта Френеля (свечение под острыми углами)
float fresnel = dot(viewDir, normal);
fresnel = clamp(1.0 - fresnel, 0.0, 1.0);
fresnel = pow(fresnel, uFresnelPower);
// Смешивание базового цвета и неонового контура
vec3 finalColor = mix(uColorBase, uColorGlow, fresnel);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
// 3. Создание кастомного ShaderMaterial через drei
export const WaveShaderMaterial = shaderMaterial(
{
uTime: 0,
uDistortionScale: 0.3,
uDistortionFrequency: 1.5,
uFresnelPower: 2.5,
uColorBase: new THREE.Color('#0f172a'),
uColorGlow: new THREE.Color('#38bdf8'),
},
vertexShader,
fragmentShader
);
// 4. Регистрация материала в каталоге JSX Fiber
extend({ WaveShaderMaterial });
// 5. Декларация типов для TypeScript JSX
declare module '@react-three/fiber' {
interface ThreeElements {
waveShaderMaterial: ReactThreeFiber.Object3DNode<
THREE.ShaderMaterial & {
uTime?: number;
uDistortionScale?: number;
uDistortionFrequency?: number;
uFresnelPower?: number;
uColorBase?: THREE.Color;
uColorGlow?: THREE.Color;
},
typeof WaveShaderMaterial
>;
}
}Теперь интегрируем скомпилированный шейдерный материал в React-компонент с передачей покадровых данных через useFrame:
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
import * as THREE from 'three';
import './WaveShaderMaterial';
interface ProceduralSphereProps {
scale?: number;
glowColor?: string;
}
export function ProceduralSphere({ scale = 1.5, glowColor = '#38bdf8' }: ProceduralSphereProps) {
// Типизированная ссылка на экземпляр ShaderMaterial
const materialRef = useRef<THREE.ShaderMaterial & { uTime: number }>(null);
// Высокочастотный цикл: обновление uTime без ререндеров React
useFrame((state, delta) => {
if (materialRef.current) {
// Инкремент с учетом прошедшего времени (time-delta)
materialRef.current.uTime += delta * 1.2;
}
});
return (
<mesh scale={scale}>
{/* Сетка сферы с достаточной плотностью для вертексного смещения */}
<icosahedronGeometry args={[1, 64]} />
<waveShaderMaterial
ref={materialRef}
uDistortionScale={0.35}
uDistortionFrequency={1.8}
uFresnelPower={3.0}
uColorGlow={new THREE.Color(glowColor)}
transparent={false}
wireframe={false}
/>
</mesh>
);
}Хук extend регистрирует прототип материала в R3F Reconciler, сохраняя сигнатуру uniforms и исходный текст шейдера.
Исходный код GLSL хранится в JavaScript-куче в виде строк.
При первом вызове отрисовки WebGL компилирует GLSL в аппаратный микрокод GPU через gl.compileShader и gl.linkProgram.
Операция синхронная и может вызвать задержку кадра до 100 мс.
Драйвер кеширует целочисленные указатели регистров uniform-переменных в видеопамяти графического адаптера.
Быстрый доступ при передаче матриц и скаляров.
JavaScript напрямую перезаписывает ячейки uniforms (gl.uniform1f, gl.uniform3fv) без участия React Fiber.
Время накладных расходов JS составляет менее 0.05 мс на вызов.
Критическая проблема кастомных материалов в WebGL — Shader Compilation Stutter. Компиляция GLSL драйвером видеокарты происходит лениво (lazy compilation): только в тот момент, когда объект с новым материалом впервые попадает в конус видимости камеры (View Frustum) и вызывается функция gl.drawElements(). Это приводит к заметному визуальному зависанию интерфейса на 50–200 мс.
Для устранения задержек применяется техника Shader Pre-warming с помощью метода gl.compile:
import { useEffect } from 'react';
import { useThree } from '@react-three/fiber';
export function ShaderWarmup() {
const { gl, scene, camera } = useThree();
useEffect(() => {
// Принудительная фоновая компиляция всех материалов сцены
// до старта первой пользовательской анимации
gl.compile(scene, camera);
}, [gl, scene, camera]);
return null;
}Разработчик создал кастомный шейдер и передает динамическое положение курсора мыши в шейдер через компонент: const [mouse, setMouse] = useState([0,0]); <waveShaderMaterial uMouse={mouse} />. В событии onPointerMove вызывается setMouse. Почему сцена теряет плавность и падает до 15 FPS?
Если в сцене присутствуют сложные кастомные шейдеры, появляющиеся по условию (например, взрыв или появление портала), драйвер GPU начнет компилировать шейдер прямо в кадре его первого отображения. Это заморозит кадр на срок от 50 до 150 мс.
Решение: Прогревайте все условные материалы заранее при загрузке уровня, вызывая gl.compile(scene, camera), либо рендерите объект за пределами видимости (например, со шкалой scale={0.0001}).
После интерполяции растровым блоком графического процессора векторы нормалей vNormal и направления взгляда vViewPosition теряют единичную длину. Использование ненормализованных векторов в скалярном произведении dot(viewDir, normal) искажает эффект Френеля и приводит к появлению черных полос.
Решение: Всегда вызывайте normalize() внутри фрагментного шейдера перед выполнением векторных операций: vec3 normal = normalize(vNormal);.
Попытка обновлять цвет материала через materialRef.current.uColor = new THREE.Color(hex) внутри хука useFrame создает до 120 новых JavaScript-объектов каждую секунду. Это моментально переполняет Young Generation кучи V8 и активирует сборщик мусора (GC Pause).
Решение: Мутируйте существующий экземпляр цвета на месте: materialRef.current.uColor.set(hex) или materialRef.current.uColor.lerp(targetColor, delta * 2).
В отличие от JavaScript, возвращающего Infinity, деление на ноль или взятие корня из отрицательного числа (sqrt(-1.0)) в GLSL на различных мобильных GPU (Mali, Adreno) приводит к аппаратному сбою рендера пикселя: объект заливается черным или белым цветом целиком.
Решение: Защищайте знаменатели функцией max(denominator, 0.00001) и ограничивайте аргументы функции pow() и sqrt() через clamp().
shaderMaterial из @react-three/drei в связке с extend позволяет декларативно регистрировать кастомные GLSL шейдеры в качестве нативных тегов JSX.uniforms обязана производиться прямой мутацией по ссылке ref внутри useFrame, исключая вызовы setState.gl.compile(scene, camera).