10 KiB
10 KiB
RFCP: WebGL Radial Gradients Coverage Layer
Мета
Переробити WebGL coverage layer з texture-based підходу на radial gradients — як працює Canvas GeographicHeatmap, але на GPU.
Чому radial gradients краще для візуалізації
Texture-based (поточний):
- Кожна точка = 1 pixel в grid
- Nearest neighbor fill → blocky квадрати
- Навіть з smoothstep — видно grid структуру
- ✅ Добре для: terrain detail, точні значення
- ❌ Погано для: красива візуалізація
Radial gradients (Canvas heatmap):
- Кожна точка = круг з radial falloff
- Smooth blending між точками
- Природній вигляд coverage
- ✅ Добре для: красива візуалізація, презентації
- ❌ Погано для: точні значення (blending спотворює)
Архітектура WebGL Radial Gradients
Підхід: Multi-pass additive blending
Pass 1-N: Для кожної точки (або batch точок)
├── Малюємо full-screen quad
├── Fragment shader: radial falloff від центру точки
├── Output: (weight * value, weight, 0, 1)
└── Blending: GL_ONE, GL_ONE (additive)
Final Pass:
├── Читаємо accumulated texture
├── Normalize: value = R / G (weighted average)
└── Apply colormap
Альтернатива: Single-pass з texture atlas
Замість N проходів, закодувати всі точки в texture і в одному fragment shader пройтись по всіх:
// Fragment shader
uniform sampler2D u_points; // texture з точками: (lat, lon, rsrp, radius)
uniform int u_pointCount;
void main() {
vec2 worldPos = getWorldPosition(v_uv);
float totalWeight = 0.0;
float totalValue = 0.0;
for (int i = 0; i < MAX_POINTS; i++) {
if (i >= u_pointCount) break;
vec4 point = texelFetch(u_points, ivec2(i, 0), 0);
vec2 pointPos = point.xy;
float rsrp = point.z;
float radius = point.w;
float dist = distance(worldPos, pointPos);
float weight = smoothstep(radius, 0.0, dist);
totalWeight += weight;
totalValue += weight * rsrp;
}
if (totalWeight < 0.001) discard;
float avgRsrp = totalValue / totalWeight;
vec3 color = rsrpToColor(avgRsrp);
gl_FragColor = vec4(color, smoothstep(0.0, 0.1, totalWeight));
}
Проблема: Loop по 6,675 точках в кожному fragment = дуже повільно.
Рекомендований підхід: Batched additive blending
1. Створити offscreen framebuffer (float texture)
2. Для кожної точки (або batch по 100-500):
- Малювати quad розміром з radius точки
- Additive blend: (weight * rsrp, weight)
3. Final pass: normalize + colormap
Це як Mapbox heatmap працює.
Імплементація
Крок 1: Створити offscreen framebuffer
// Accumulation texture (RG float for weighted sum)
const accumTexture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RG32F, width, height, 0, gl.RG, gl.FLOAT, null);
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, accumTexture, 0);
Примітка: Потрібен EXT_color_buffer_float extension для float framebuffer.
Крок 2: Point rendering shader
Vertex shader:
attribute vec2 a_position; // quad vertices
attribute vec2 a_pointCenter; // point lat/lon (instanced)
attribute float a_pointRsrp; // point RSRP (instanced)
attribute float a_pointRadius; // point radius in pixels (instanced)
uniform mat4 u_matrix; // world to clip transform
varying vec2 v_localPos; // position relative to point center
varying float v_rsrp;
void main() {
// Expand quad around point center
vec2 worldPos = a_pointCenter + a_position * a_pointRadius;
gl_Position = u_matrix * vec4(worldPos, 0.0, 1.0);
v_localPos = a_position; // -1 to 1
v_rsrp = a_pointRsrp;
}
Fragment shader:
precision highp float;
varying vec2 v_localPos;
varying float v_rsrp;
void main() {
// Radial distance from center (0 at center, 1 at edge)
float dist = length(v_localPos);
// Discard outside circle
if (dist > 1.0) discard;
// Radial falloff (smooth at edges)
float weight = 1.0 - smoothstep(0.0, 1.0, dist);
// Or gaussian: weight = exp(-dist * dist * 2.0);
// Output: (weight * normalized_rsrp, weight)
float normalizedRsrp = (v_rsrp + 130.0) / 80.0; // -130 to -50 → 0 to 1
gl_FragColor = vec4(weight * normalizedRsrp, weight, 0.0, 1.0);
}
Крок 3: Final compositing shader
precision highp float;
uniform sampler2D u_accumTexture;
varying vec2 v_uv;
vec3 rsrpToColor(float t) {
// t: 0 = weak (red), 1 = strong (cyan)
if (t < 0.25) return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 0.5, 0.0), t / 0.25);
if (t < 0.5) return mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 1.0, 0.0), (t - 0.25) / 0.25);
if (t < 0.75) return mix(vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), (t - 0.5) / 0.25);
return mix(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), (t - 0.75) / 0.25);
}
void main() {
vec2 accum = texture2D(u_accumTexture, v_uv).rg;
float totalValue = accum.r;
float totalWeight = accum.g;
// No coverage
if (totalWeight < 0.001) discard;
// Weighted average RSRP
float avgRsrp = totalValue / totalWeight;
// Color mapping
vec3 color = rsrpToColor(avgRsrp);
// Alpha based on weight (fade at edges)
float alpha = smoothstep(0.0, 0.1, totalWeight) * 0.85;
gl_FragColor = vec4(color, alpha);
}
Крок 4: Rendering loop
function render() {
const canvas = canvasRef.current;
const gl = glRef.current;
// 1. Position canvas over map
const nw = map.latLngToLayerPoint([bounds.maxLat, bounds.minLon]);
const se = map.latLngToLayerPoint([bounds.minLat, bounds.maxLon]);
canvas.style.transform = `translate(${nw.x}px, ${nw.y}px)`;
canvas.style.width = `${se.x - nw.x}px`;
canvas.style.height = `${se.y - nw.y}px`;
// 2. Clear accumulation buffer
gl.bindFramebuffer(gl.FRAMEBUFFER, accumFramebuffer);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 3. Render points with additive blending
gl.useProgram(pointProgram);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE); // Additive
// Set uniforms (matrix, etc.)
const matrix = calculateWorldToClipMatrix(bounds, canvas.width, canvas.height);
gl.uniformMatrix4fv(u_matrix, false, matrix);
// Draw all points (instanced if supported, or batched)
drawPoints(gl, points);
// 4. Final composite pass
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.useProgram(compositeProgram);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // Normal blend
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, accumTexture);
drawFullscreenQuad(gl);
}
Оптимізації
1. Instanced rendering (якщо підтримується)
const ext = gl.getExtension('ANGLE_instanced_arrays');
if (ext) {
// Use instanced rendering - draw all points in one call
ext.drawArraysInstancedANGLE(gl.TRIANGLE_STRIP, 0, 4, points.length);
}
2. Spatial culling
Малювати тільки точки що потрапляють у viewport:
const visiblePoints = points.filter(p => {
const screenPos = map.latLngToContainerPoint([p.lat, p.lon]);
return screenPos.x > -radius && screenPos.x < canvas.width + radius &&
screenPos.y > -radius && screenPos.y < canvas.height + radius;
});
3. Dynamic radius based on zoom
const zoom = map.getZoom();
const metersPerPixel = 40075016.686 * Math.cos(centerLat * Math.PI / 180) / Math.pow(2, zoom + 8);
const radiusPixels = (settings.resolution * 1.5) / metersPerPixel;
4. Resolution scaling
На низьких zoom рівнях, рендерити в менший framebuffer і upscale:
const scale = zoom < 10 ? 0.5 : zoom < 12 ? 0.75 : 1.0;
const fbWidth = Math.round(canvas.width * scale);
const fbHeight = Math.round(canvas.height * scale);
Порівняння з поточним texture-based
| Аспект | Texture-based | Radial gradients |
|---|---|---|
| Візуалізація | Blocky | Smooth |
| Terrain detail | Добре | Менш точно |
| Performance | Швидко (1 draw call) | Повільніше (N points) |
| Memory | Texture size | Framebuffer + points |
| Код складність | Середня | Висока |
Чеклист імплементації
Phase 1: Basic setup
- Створити новий файл
WebGLRadialCoverageLayer.tsx - Setup WebGL context з float extensions
- Створити accumulation framebuffer
- Базовий vertex/fragment shader для точок
Phase 2: Point rendering
- Implement point quad rendering
- Radial falloff function
- Additive blending
- Test з кількома точками
Phase 3: Compositing
- Final pass shader
- Weighted average calculation
- Color mapping
- Alpha/transparency
Phase 4: Integration
- Map positioning (як в поточному WebGL layer)
- Map event listeners (move/zoom)
- Opacity control
- Toggle в UI
Phase 5: Optimization
- Instanced rendering
- Spatial culling
- Dynamic radius
- Resolution scaling
Fallback
Якщо WebGL radial не працює (older GPU, missing extensions):
- Fallback до Canvas GeographicHeatmap
- Або до поточного texture-based WebGL