Files
rfcp/RFCP-WebGL-Radial-Gradients-Task.md
2026-02-07 01:14:01 +02:00

10 KiB
Raw Blame History

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

Референси

  1. Mapbox GL Heatmap implementation
  2. deck.gl HeatmapLayer
  3. WebGL additive blending