@mytec: WebGL works
This commit is contained in:
516
RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md
Normal file
516
RFCP-Iteration-3.10.5-WebGL-Smooth-Coverage.md
Normal file
@@ -0,0 +1,516 @@
|
||||
# RFCP — Iteration 3.10.5: WebGL Smooth Coverage Interpolation
|
||||
|
||||
**Date:** February 6, 2026
|
||||
**Priority:** P1 (Major Visual Improvement)
|
||||
**Estimated Time:** 3-4 hours
|
||||
**Author:** Claude (Opus 4.5) for Олег @ UMTC
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current grid-based square coverage visualization with smooth WebGL-interpolated rendering. Currently coverage is displayed as discrete colored squares which looks "pixelated" and unrealistic. Professional RF tools like CloudRF use smooth gradients that interpolate between measurement points.
|
||||
|
||||
**Current State:** Grid squares at 50m/200m resolution → blocky appearance
|
||||
**Target State:** Smooth bilinear/bicubic interpolation → professional gradient appearance
|
||||
|
||||
---
|
||||
|
||||
## Problem Description
|
||||
|
||||
### Current Implementation
|
||||
- Coverage points are rendered as discrete squares on a Leaflet canvas layer
|
||||
- Each grid point (lat, lon, rsrp) → one colored square
|
||||
- Resolution determines square size (50m = small squares, 200m = large squares)
|
||||
- Result: Looks like Minecraft, not like professional RF planning software
|
||||
|
||||
### Desired Outcome
|
||||
- Smooth color transitions between coverage points
|
||||
- GPU-accelerated rendering via WebGL
|
||||
- No visible grid artifacts
|
||||
- Performance maintained or improved (GPU does interpolation)
|
||||
- Same data, better visualization
|
||||
|
||||
---
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Option A: WebGL Fragment Shader (RECOMMENDED)
|
||||
|
||||
Use a WebGL fragment shader that:
|
||||
1. Receives coverage points as a texture or uniform array
|
||||
2. For each screen pixel, finds nearest coverage points
|
||||
3. Performs bilinear interpolation between them
|
||||
4. Outputs smoothly interpolated color
|
||||
|
||||
**Pros:**
|
||||
- Best visual quality
|
||||
- GPU-accelerated (fast)
|
||||
- Scales to any resolution
|
||||
- Industry standard approach
|
||||
|
||||
**Cons:**
|
||||
- More complex implementation
|
||||
- Requires WebGL knowledge
|
||||
|
||||
### Option B: Canvas with Gaussian Blur
|
||||
|
||||
Apply Gaussian blur to the existing canvas after rendering squares.
|
||||
|
||||
**Pros:**
|
||||
- Simple to implement
|
||||
- Works with existing code
|
||||
|
||||
**Cons:**
|
||||
- Blurs edges (coverage boundary becomes fuzzy)
|
||||
- Not true interpolation
|
||||
- Performance overhead
|
||||
|
||||
### Option C: Pre-interpolate on CPU
|
||||
|
||||
Generate more points by interpolating between existing ones before rendering.
|
||||
|
||||
**Pros:**
|
||||
- Simpler rendering
|
||||
- Works with existing canvas
|
||||
|
||||
**Cons:**
|
||||
- Much slower (CPU-bound)
|
||||
- Memory intensive
|
||||
- Not scalable
|
||||
|
||||
**DECISION: Implement Option A (WebGL Fragment Shader)**
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: WebGL Layer Setup
|
||||
|
||||
**File:** `frontend/src/components/map/WebGLCoverageLayer.tsx`
|
||||
|
||||
Create a new Leaflet layer that uses WebGL for rendering:
|
||||
|
||||
```typescript
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
|
||||
interface CoveragePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
}
|
||||
|
||||
interface WebGLCoverageLayerProps {
|
||||
points: CoveragePoint[];
|
||||
opacity: number;
|
||||
minRsrp: number;
|
||||
maxRsrp: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function WebGLCoverageLayer({
|
||||
points,
|
||||
opacity,
|
||||
minRsrp,
|
||||
maxRsrp,
|
||||
visible
|
||||
}: WebGLCoverageLayerProps) {
|
||||
const map = useMap();
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const glRef = useRef<WebGLRenderingContext | null>(null);
|
||||
const programRef = useRef<WebGLProgram | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || points.length === 0) return;
|
||||
|
||||
// Create canvas overlay
|
||||
const canvas = document.createElement('canvas');
|
||||
const container = map.getContainer();
|
||||
canvas.width = container.clientWidth;
|
||||
canvas.height = container.clientHeight;
|
||||
canvas.style.position = 'absolute';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '400'; // Above tiles, below markers
|
||||
canvas.style.opacity = String(opacity);
|
||||
container.appendChild(canvas);
|
||||
canvasRef.current = canvas;
|
||||
|
||||
// Initialize WebGL
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (!gl) {
|
||||
console.error('WebGL not supported, falling back to canvas');
|
||||
return;
|
||||
}
|
||||
glRef.current = gl as WebGLRenderingContext;
|
||||
|
||||
// Setup shaders and render
|
||||
initShaders(gl as WebGLRenderingContext);
|
||||
render();
|
||||
|
||||
// Handle map move/zoom
|
||||
const onMove = () => render();
|
||||
map.on('move', onMove);
|
||||
map.on('zoom', onMove);
|
||||
map.on('resize', onResize);
|
||||
|
||||
return () => {
|
||||
map.off('move', onMove);
|
||||
map.off('zoom', onMove);
|
||||
map.off('resize', onResize);
|
||||
canvas.remove();
|
||||
};
|
||||
}, [points, visible, opacity, minRsrp, maxRsrp, map]);
|
||||
|
||||
// ... shader init and render functions
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: WebGL Shaders
|
||||
|
||||
**Vertex Shader:**
|
||||
```glsl
|
||||
attribute vec2 a_position;
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
void main() {
|
||||
gl_Position = vec4(a_position, 0.0, 1.0);
|
||||
v_texCoord = (a_position + 1.0) / 2.0;
|
||||
}
|
||||
```
|
||||
|
||||
**Fragment Shader (Bilinear Interpolation):**
|
||||
```glsl
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_coverageTexture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform vec4 u_bounds; // minLat, minLon, maxLat, maxLon
|
||||
uniform float u_minRsrp;
|
||||
uniform float u_maxRsrp;
|
||||
|
||||
varying vec2 v_texCoord;
|
||||
|
||||
// RSRP to color gradient (matches existing palette)
|
||||
vec3 rsrpToColor(float rsrp) {
|
||||
float t = clamp((rsrp - u_minRsrp) / (u_maxRsrp - u_minRsrp), 0.0, 1.0);
|
||||
|
||||
// Color stops: red -> orange -> yellow -> green -> cyan -> blue
|
||||
// Reversed: strong signal = green/cyan, weak = red/orange
|
||||
if (t < 0.2) {
|
||||
return mix(vec3(0.5, 0.0, 0.0), vec3(1.0, 0.0, 0.0), t / 0.2); // maroon -> red
|
||||
} else if (t < 0.4) {
|
||||
return mix(vec3(1.0, 0.0, 0.0), vec3(1.0, 0.5, 0.0), (t - 0.2) / 0.2); // red -> orange
|
||||
} else if (t < 0.6) {
|
||||
return mix(vec3(1.0, 0.5, 0.0), vec3(1.0, 1.0, 0.0), (t - 0.4) / 0.2); // orange -> yellow
|
||||
} else if (t < 0.8) {
|
||||
return mix(vec3(1.0, 1.0, 0.0), vec3(0.0, 1.0, 0.0), (t - 0.6) / 0.2); // yellow -> green
|
||||
} else {
|
||||
return mix(vec3(0.0, 1.0, 0.0), vec3(0.0, 1.0, 1.0), (t - 0.8) / 0.2); // green -> cyan
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
// Convert screen coords to geographic coords
|
||||
vec2 geoCoord = mix(u_bounds.xy, u_bounds.zw, v_texCoord);
|
||||
|
||||
// Sample coverage texture (contains RSRP values encoded as colors)
|
||||
vec4 sample = texture2D(u_coverageTexture, v_texCoord);
|
||||
|
||||
// Decode RSRP from texture (R channel = normalized RSRP)
|
||||
float rsrp = mix(u_minRsrp, u_maxRsrp, sample.r);
|
||||
|
||||
// Skip if no coverage (alpha = 0)
|
||||
if (sample.a < 0.1) {
|
||||
discard;
|
||||
}
|
||||
|
||||
vec3 color = rsrpToColor(rsrp);
|
||||
gl_FragColor = vec4(color, sample.a);
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Coverage Data → Texture
|
||||
|
||||
Convert coverage points array to a WebGL texture for GPU sampling:
|
||||
|
||||
```typescript
|
||||
function createCoverageTexture(
|
||||
gl: WebGLRenderingContext,
|
||||
points: CoveragePoint[],
|
||||
bounds: L.LatLngBounds,
|
||||
textureSize: number = 512
|
||||
): WebGLTexture {
|
||||
// Create a grid texture from sparse points
|
||||
const data = new Uint8Array(textureSize * textureSize * 4);
|
||||
|
||||
const minLat = bounds.getSouth();
|
||||
const maxLat = bounds.getNorth();
|
||||
const minLon = bounds.getWest();
|
||||
const maxLon = bounds.getEast();
|
||||
|
||||
// For each texture pixel, find nearest coverage point and interpolate
|
||||
for (let y = 0; y < textureSize; y++) {
|
||||
for (let x = 0; x < textureSize; x++) {
|
||||
const lat = minLat + (maxLat - minLat) * (y / textureSize);
|
||||
const lon = minLon + (maxLon - minLon) * (x / textureSize);
|
||||
|
||||
// Find nearest points and interpolate (IDW - Inverse Distance Weighting)
|
||||
const { value, weight } = interpolateIDW(points, lat, lon, 4);
|
||||
|
||||
const idx = (y * textureSize + x) * 4;
|
||||
if (weight > 0) {
|
||||
// Encode normalized RSRP in R channel, weight in A channel
|
||||
const normalized = (value - minRsrp) / (maxRsrp - minRsrp);
|
||||
data[idx] = Math.floor(normalized * 255); // R = RSRP
|
||||
data[idx + 1] = 0; // G = unused
|
||||
data[idx + 2] = 0; // B = unused
|
||||
data[idx + 3] = Math.floor(Math.min(weight, 1) * 255); // A = coverage mask
|
||||
} else {
|
||||
data[idx + 3] = 0; // No coverage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, textureSize, textureSize, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||
|
||||
// Enable bilinear filtering for smooth interpolation
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
|
||||
return texture!;
|
||||
}
|
||||
|
||||
// Inverse Distance Weighting interpolation
|
||||
function interpolateIDW(
|
||||
points: CoveragePoint[],
|
||||
lat: number,
|
||||
lon: number,
|
||||
k: number = 4,
|
||||
power: number = 2
|
||||
): { value: number; weight: number } {
|
||||
// Find k nearest points
|
||||
const distances = points.map((p, i) => ({
|
||||
index: i,
|
||||
dist: Math.sqrt(Math.pow(p.lat - lat, 2) + Math.pow(p.lon - lon, 2))
|
||||
}));
|
||||
|
||||
distances.sort((a, b) => a.dist - b.dist);
|
||||
const nearest = distances.slice(0, k);
|
||||
|
||||
// If very close to a point, use its value directly
|
||||
if (nearest[0].dist < 0.0001) {
|
||||
return { value: points[nearest[0].index].rsrp, weight: 1 };
|
||||
}
|
||||
|
||||
// IDW formula: weighted average where weight = 1 / distance^power
|
||||
let sumWeights = 0;
|
||||
let sumValues = 0;
|
||||
|
||||
for (const n of nearest) {
|
||||
const w = 1 / Math.pow(n.dist, power);
|
||||
sumWeights += w;
|
||||
sumValues += w * points[n.index].rsrp;
|
||||
}
|
||||
|
||||
// Limit interpolation range (don't extrapolate too far from data)
|
||||
const maxDist = nearest[nearest.length - 1].dist;
|
||||
const coverage = maxDist < 0.01 ? 1 : Math.max(0, 1 - maxDist * 50);
|
||||
|
||||
return {
|
||||
value: sumValues / sumWeights,
|
||||
weight: coverage
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Integration with Existing Code
|
||||
|
||||
**Modify:** `frontend/src/components/map/MapView.tsx`
|
||||
|
||||
Add toggle between old canvas layer and new WebGL layer:
|
||||
|
||||
```typescript
|
||||
import WebGLCoverageLayer from './WebGLCoverageLayer';
|
||||
|
||||
// In MapView component:
|
||||
const [useWebGL, setUseWebGL] = useState(true);
|
||||
|
||||
// In render:
|
||||
{useWebGL ? (
|
||||
<WebGLCoverageLayer
|
||||
points={coveragePoints}
|
||||
opacity={heatmapOpacity}
|
||||
minRsrp={-130}
|
||||
maxRsrp={-50}
|
||||
visible={showCoverage}
|
||||
/>
|
||||
) : (
|
||||
<GeographicHeatmap ... /> // Existing canvas implementation
|
||||
)}
|
||||
```
|
||||
|
||||
**Add setting:** `frontend/src/components/panels/SettingsPanel.tsx`
|
||||
|
||||
```typescript
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Smooth Coverage (WebGL)</span>
|
||||
<Toggle
|
||||
checked={useWebGL}
|
||||
onChange={setUseWebGL}
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Phase 5: Performance Optimizations
|
||||
|
||||
1. **Texture Caching:** Only regenerate texture when coverage data changes
|
||||
2. **Resolution Scaling:** Use smaller texture on zoom out, larger on zoom in
|
||||
3. **Frustum Culling:** Don't render points outside visible bounds
|
||||
4. **Web Worker:** Move IDW interpolation to background thread
|
||||
|
||||
```typescript
|
||||
// Memoize texture generation
|
||||
const coverageTexture = useMemo(() => {
|
||||
if (!gl || points.length === 0) return null;
|
||||
return createCoverageTexture(gl, points, bounds, textureSize);
|
||||
}, [points, bounds, textureSize]);
|
||||
|
||||
// Dynamic texture size based on zoom
|
||||
const textureSize = useMemo(() => {
|
||||
const zoom = map.getZoom();
|
||||
if (zoom < 10) return 256;
|
||||
if (zoom < 14) return 512;
|
||||
return 1024;
|
||||
}, [map.getZoom()]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
| File | Action | Description |
|
||||
|------|--------|-------------|
|
||||
| `frontend/src/components/map/WebGLCoverageLayer.tsx` | CREATE | New WebGL rendering component |
|
||||
| `frontend/src/components/map/shaders/coverage.vert` | CREATE | Vertex shader (optional, can inline) |
|
||||
| `frontend/src/components/map/shaders/coverage.frag` | CREATE | Fragment shader (optional, can inline) |
|
||||
| `frontend/src/components/map/MapView.tsx` | MODIFY | Add WebGL layer toggle |
|
||||
| `frontend/src/store/settings.ts` | MODIFY | Add useWebGL setting |
|
||||
| `frontend/src/components/panels/CoverageSettingsPanel.tsx` | MODIFY | Add WebGL toggle UI |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Visual Quality
|
||||
- [ ] No visible grid squares at any zoom level
|
||||
- [ ] Smooth color gradients between coverage points
|
||||
- [ ] Coverage boundary is smooth, not jagged
|
||||
- [ ] Colors match existing palette (weak = red, strong = cyan/green)
|
||||
- [ ] Opacity control works correctly
|
||||
|
||||
### Performance
|
||||
- [ ] 60 FPS during map pan/zoom
|
||||
- [ ] Initial render < 500ms for 6000 points
|
||||
- [ ] Memory usage reasonable (< 100MB for large coverage)
|
||||
- [ ] No GPU memory leaks on repeated calculations
|
||||
|
||||
### Compatibility
|
||||
- [ ] Works on systems without dedicated GPU (falls back gracefully)
|
||||
- [ ] Works in Chrome, Firefox, Edge
|
||||
- [ ] Works on both high-DPI and standard displays
|
||||
|
||||
### Integration
|
||||
- [ ] Toggle between WebGL and canvas modes works
|
||||
- [ ] Coverage data updates correctly after recalculation
|
||||
- [ ] Settings persist across sessions
|
||||
- [ ] No console errors or warnings
|
||||
|
||||
---
|
||||
|
||||
## Fallback Strategy
|
||||
|
||||
If WebGL fails to initialize:
|
||||
1. Log warning to console
|
||||
2. Fall back to existing canvas implementation
|
||||
3. Show toast notification to user
|
||||
|
||||
```typescript
|
||||
const gl = canvas.getContext('webgl');
|
||||
if (!gl) {
|
||||
console.warn('WebGL not available, using canvas fallback');
|
||||
setUseWebGL(false);
|
||||
toast.warning('WebGL not supported, using standard rendering');
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Visual:** Coverage looks like CloudRF/professional tools — smooth gradients, no grid
|
||||
2. **Performance:** Same or better than current canvas implementation
|
||||
3. **Reliability:** Graceful fallback if WebGL unavailable
|
||||
4. **UX:** User can toggle between modes in settings
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### Color Gradient Reference
|
||||
|
||||
Current RSRP color mapping (from `colorGradient.ts`):
|
||||
```
|
||||
-130 dBm → Maroon (no service)
|
||||
-110 dBm → Red (very weak)
|
||||
-100 dBm → Orange (weak)
|
||||
-85 dBm → Yellow (fair)
|
||||
-70 dBm → Green (good)
|
||||
-50 dBm → Cyan (excellent)
|
||||
```
|
||||
|
||||
### Coordinate Systems
|
||||
|
||||
- **Geographic:** Latitude/Longitude (EPSG:4326)
|
||||
- **Screen:** Pixels from top-left
|
||||
- **WebGL:** Normalized device coordinates (-1 to 1)
|
||||
- **Texture:** UV coordinates (0 to 1)
|
||||
|
||||
All conversions must account for Web Mercator projection distortion.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- WebGL Fundamentals: https://webglfundamentals.org/
|
||||
- Leaflet Custom Layers: https://leafletjs.com/examples/extending/extending-2-layers.html
|
||||
- IDW Interpolation: https://en.wikipedia.org/wiki/Inverse_distance_weighting
|
||||
- CloudRF visualization: https://cloudrf.com (for visual reference)
|
||||
|
||||
---
|
||||
|
||||
## Commit Message
|
||||
|
||||
```
|
||||
feat(coverage): WebGL smooth interpolation rendering
|
||||
|
||||
- Add WebGLCoverageLayer with GPU-accelerated rendering
|
||||
- Implement IDW interpolation for smooth gradients
|
||||
- Add toggle between WebGL and canvas modes
|
||||
- Graceful fallback for systems without WebGL support
|
||||
|
||||
Closes #coverage-interpolation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Ready for Implementation!**
|
||||
|
||||
Reference in New Issue
Block a user