346 lines
10 KiB
Markdown
346 lines
10 KiB
Markdown
# 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 пройтись по всіх:
|
||
|
||
```glsl
|
||
// 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
|
||
|
||
```typescript
|
||
// 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:**
|
||
```glsl
|
||
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:**
|
||
```glsl
|
||
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
|
||
|
||
```glsl
|
||
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
|
||
|
||
```typescript
|
||
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 (якщо підтримується)
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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:
|
||
|
||
```typescript
|
||
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
|
||
|
||
---
|
||
|
||
## Референси
|
||
|
||
1. [Mapbox GL Heatmap implementation](https://github.com/mapbox/mapbox-gl-js/blob/main/src/render/draw_heatmap.js)
|
||
2. [deck.gl HeatmapLayer](https://deck.gl/docs/api-reference/aggregation-layers/heatmap-layer)
|
||
3. [WebGL additive blending](https://webglfundamentals.org/webgl/lessons/webgl-text-texture.html)
|