WebGL-візуалізація: шейдери, частинки та оптимізація
Відвідувач заходить на сторінку з картою, а браузер починає гальмувати: скролл сіпається, Tooltip з'являється через секунду. Якщо ви стикалися з таким — дані занадто великі для SVG або Canvas 2D. WebGL вирішує цю проблему: він рендерить сотні тисяч точок за один draw call, використовуючи GPU клієнта. При 500 000+ точках Canvas 2D падає до 3 fps, а WebGL тримає 60 fps. Це не просто покращення — це можливість створювати дашборди реального часу, які раніше були неможливі.
Одного разу до нас прийшов проект з візуалізації геоданих для логістичної компанії. У них було 800 тисяч точок з координатами та вагами. Canvas 2D видавав 5 кадрів на секунду. Ми переписали все на WebGL — отримали стабільні 60 fps. Замовник зекономив на залізі та прискорив завантаження дашборду в 12 разів.
Ми спеціалізуємося на WebGL-візуалізаціях більше 10 років. За цей час реалізували десятки проектів: від карт з мільйонами об'єктів до анімованих particle-ефектів. Наш досвід гарантує, що візуалізація буде працювати плавно на будь-яких пристроях — від десктопа до mid-range мобільних.
Як WebGL забезпечує 60 fps на великих даних?
WebGL використовує GPU та мінімізує кількість draw calls. На відміну від Canvas 2D, який відмальовує кожну точку окремо, WebGL передає весь масив даних одним викликом. Це знижує накладні витрати CPU і дозволяє досягти 60 fps навіть при 1 млн точок. Ключові фактори: використання буферів з типом float, відмова від надлишкових uniform-ів, та правильне налаштування blending. Згідно з WebGL API, кожен draw call може обробляти до 2^32 вершин, що дає величезний запас продуктивності.
Коли потрібен саме WebGL
- Scatter plot з 500 000+ точок (фінансові дані, геопросторові)
- Particle systems: інтерактивні фони, візуалізації фізичних процесів
- Heatmaps у реальному часі (дані біржових торгів, теплові карти кліків)
- Процедурні анімації (шум Перліна, математичні поверхні)
- Обробка зображень через шейдери (фільтри, ефекти)
Для стандартних графіків (100–10 000 точок) — D3.js або Recharts достатньо.
Порівняння WebGL, Canvas 2D та SVG для великих даних
| Параметр | WebGL | Canvas 2D | SVG |
|---|---|---|---|
| Макс. кількість точок (60 fps) | 1 000 000+ | 10 000 | 1 000 |
| Тип рендерингу | GPU, draw call | CPU, immediate | DOM, retained |
| Пікова продуктивність | 60 fps | ~30 fps при 50k | ~15 fps при 10k |
| Складність реалізації | Висока | Середня | Низька |
| Підтримка старих браузерів | WebGL1 fallback | Хороша | Відмінна |
WebGL у 10 разів швидше Canvas 2D при 500k точках — це різниця між 60 fps та 3 fps. Вибір інструменту залежить від обсягу даних та вимог до візуалізації.
Порівняння популярних бібліотек WebGL
| Бібліотека | Типові випадки | Складність написання шейдерів | Продуктивність |
|---|---|---|---|
| deck.gl | Геодані, scatter, heatmap | Низька (готові шари) | Висока для 2D |
| regl | Кастомні ефекти, particles | Середня | Висока |
| Three.js | 3D-сцени, анімації | Висока | Висока для 3D |
| raw WebGL2 | Максимальна гнучкість | Висока | Максимальна |
Як вибрати між deck.gl та кастомними шейдерами?
Для геопросторових даних (карти, scatter plots, heatmaps) використовуємо deck.gl — бібліотека від Uber, оптимізована для великих датасетів. Вона надає готові Layer-компоненти (ScatterplotLayer, HeatmapLayer, ColumnLayer) і підтримує WebGL2. Для кастомних візуалізацій (particle-системи, процедурна графіка) пишемо шейдери напряму або через обгортку regl.
deck.gl: візуалізація геопросторових даних
npm install deck.gl @deck.gl/layers @deck.gl/react react-map-gl maplibre-gl
import DeckGL from '@deck.gl/react'
import { ScatterplotLayer, HeatmapLayer, ColumnLayer } from '@deck.gl/layers'
import Map from 'react-map-gl/maplibre'
interface DataPoint {
coordinates: [number, number]
value: number
category: string
}
function GeoVisualization({ data }: { data: DataPoint[] }) {
const [viewState, setViewState] = useState({
longitude: 37.6,
latitude: 55.75,
zoom: 10,
pitch: 45,
bearing: 0,
})
const layers = [
new ScatterplotLayer({
id: 'scatter',
data,
getPosition: (d) => d.coordinates,
getRadius: (d) => Math.sqrt(d.value) * 10,
getFillColor: (d) => {
const t = d.value / 1000
return [255 * t, 100, 255 * (1 - t), 200]
},
pickable: true,
radiusMinPixels: 2,
radiusMaxPixels: 50,
}),
new HeatmapLayer({
id: 'heatmap',
data,
getPosition: (d) => d.coordinates,
getWeight: (d) => d.value,
radiusPixels: 40,
intensity: 1,
threshold: 0.1,
colorRange: [
[0, 0, 255, 0],
[0, 255, 255, 128],
[0, 255, 0, 200],
[255, 255, 0, 220],
[255, 0, 0, 255],
],
}),
]
return (
<DeckGL
viewState={viewState}
onViewStateChange={({ viewState }) => setViewState(viewState as any)}
layers={layers}
getTooltip={({ object }: { object: DataPoint }) =>
object && { html: `<b>Значение:</b> ${object.value}`, style: { background: '#fff' } }
}
style={{ position: 'relative', height: '600px' }}
controller={true}
>
<Map
mapStyle="https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"
/>
</DeckGL>
)
}
WebGL шейдери напряму: GLSL
function WebGLCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
const canvas = canvasRef.current!
const gl = canvas.getContext('webgl2')!
const vertexShaderSrc = `#version 300 es
in vec2 a_position;
in float a_value;
out float v_value;
uniform vec2 u_resolution;
void main() {
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
gl_PointSize = max(2.0, sqrt(a_value) * 3.0);
v_value = a_value;
}
`
const fragmentShaderSrc = `#version 300 es
precision highp float;
in float v_value;
out vec4 outColor;
vec3 viridis(float t) {
const vec3 c0 = vec3(0.267, 0.004, 0.329);
const vec3 c1 = vec3(0.127, 0.566, 0.551);
const vec3 c2 = vec3(0.993, 0.906, 0.144);
return mix(mix(c0, c1, t), mix(c1, c2, t), t);
}
void main() {
vec2 coord = gl_PointCoord - 0.5;
if (length(coord) > 0.5) discard;
outColor = vec4(viridis(v_value), 0.8);
}
`
function createShader(type: number, source: string): WebGLShader {
const shader = gl.createShader(type)!
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
throw new Error(gl.getShaderInfoLog(shader) ?? 'Shader error')
}
return shader
}
const program = gl.createProgram()!
gl.attachShader(program, createShader(gl.VERTEX_SHADER, vertexShaderSrc))
gl.attachShader(program, createShader(gl.FRAGMENT_SHADER, fragmentShaderSrc))
gl.linkProgram(program)
const N = 100_000
const positions = new Float32Array(N * 2)
const values = new Float32Array(N)
for (let i = 0; i < N; i++) {
positions[i * 2] = Math.random() * canvas.width
positions[i * 2 + 1] = Math.random() * canvas.height
values[i] = Math.random()
}
const posBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, posBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW)
const aPosition = gl.getAttribLocation(program, 'a_position')
gl.enableVertexAttribArray(aPosition)
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0)
const valBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, valBuffer)
gl.bufferData(gl.ARRAY_BUFFER, values, gl.STATIC_DRAW)
const aValue = gl.getAttribLocation(program, 'a_value')
gl.enableVertexAttribArray(aValue)
gl.vertexAttribPointer(aValue, 1, gl.FLOAT, false, 0, 0)
gl.useProgram(program)
gl.uniform2f(gl.getUniformLocation(program, 'u_resolution'), canvas.width, canvas.height)
gl.clearColor(0.05, 0.05, 0.1, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
gl.drawArrays(gl.POINTS, 0, N)
}, [])
return <canvas ref={canvasRef} width={800} height={600} />
}
Regl: зручна обгортка над WebGL
npm install regl
npm install -D @types/regl
import createREGL from 'regl'
const regl = createREGL(canvasRef.current!)
const drawParticles = regl({
vert: `
precision mediump float;
attribute vec2 position;
attribute float age;
uniform float time;
void main() {
vec2 pos = position + vec2(cos(time + age), sin(time * 0.7 + age)) * 0.05;
gl_Position = vec4(pos, 0, 1);
gl_PointSize = 3.0;
}
`,
frag: `
precision mediump float;
void main() {
gl_FragColor = vec4(0.4, 0.8, 1.0, 0.7);
}
`,
attributes: {
position: particlePositions,
age: particleAges,
},
uniforms: {
time: regl.context('time'),
},
count: PARTICLE_COUNT,
primitive: 'points',
})
regl.frame(({ time }) => {
regl.clear({ color: [0, 0, 0.1, 1], depth: 1 })
drawParticles()
})
Що входить в роботу
- Аналіз даних та вибір підходящої технології (deck.gl, raw WebGL, regl)
- Реалізація візуалізації з урахуванням продуктивності (асинхронне завантаження, LOD)
- Оптимізація під 60 fps на mid-range мобільних пристроях
- Написання кастомних шейдерів, якщо потрібно
- Документація з інтеграції та використання
- Навчання команди замовника роботі з кодом
- Безкоштовна підтримка 14 днів після здачі
Процес роботи
- Аналітика — вивчаємо обсяг і структуру даних, визначаємо тип візуалізації
- Проектування — вибираємо бібліотеку або raw WebGL, проектуємо шейдери
- Реалізація — пишемо код, інтегруємо з CMS/React/Next.js
- Тестування — перевіряємо продуктивність на десктопі, планшеті, мобільному
- Деплой — публікуємо, налаштовуємо CDN для текстур та даних
Строки орієнтовно
- Базова візуалізація з deck.gl (scatter plot, heatmap) — 3–4 дні
- Кастомні шейдери та particle-системи — 7–10 днів
- Комплексна візуалізація з кількома шарами та анімацією — до 3 тижнів
Вартість розраховується індивідуально, виходячи зі складності шейдерів, обсягу даних та необхідності підтримки старих браузерів.
Типові помилки при впровадженні WebGL
- Використовувати WebGL для простих графіків (до 10k точок) — надлишково, підійде Canvas 2D.
- Не враховувати продуктивність на мобільних пристроях — mid-range телефони часто мають слабке GPU.
- Забувати про fallback на WebGL1 для Safari <15 або старих Android.
Замовте WebGL-візуалізацію — отримайте консультацію протягом дня. Наші інженери з 10+ річним досвідом допоможуть вибрати оптимальне рішення та реалізують проект під ключ. Отримайте консультацію по вашому проекту — зв'яжіться з нами для обговорення.







