diff --git a/RFCP-WebGL-Radial-Gradients-Task.md b/RFCP-WebGL-Radial-Gradients-Task.md new file mode 100644 index 0000000..0ad1b17 --- /dev/null +++ b/RFCP-WebGL-Radial-Gradients-Task.md @@ -0,0 +1,345 @@ +# 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) diff --git a/SESSION-2026-02-06-WebGL-Radial-Summary.md b/SESSION-2026-02-06-WebGL-Radial-Summary.md new file mode 100644 index 0000000..91eb90c --- /dev/null +++ b/SESSION-2026-02-06-WebGL-Radial-Summary.md @@ -0,0 +1,193 @@ +# RFCP v3.10.5 Session Summary - 2026-02-06 + +## Що зробили сьогодні + +### 1. WebGL Texture-Based Coverage (ЗАВЕРШЕНО ✅) + +**Проблема:** Canvas heatmap був blocky, хотіли smooth interpolation. + +**Рішення:** Texture-based WebGL з smoothstep shader + nearest neighbor fill. + +**Файл:** `frontend/src/components/map/WebGLCoverageLayer.tsx` + +**Як працює:** +1. Створюємо texture де кожен pixel = RSRP value +2. Nearest neighbor fill для заповнення gaps (circular coverage → rectangular texture) +3. Smoothstep shader для C2 continuity interpolation +4. Colormap applied AFTER interpolation + +**Статус:** Працює, але все ще blocky на zoom in через nearest neighbor fill. + +--- + +### 2. WebGL Radial Gradients Coverage (В ПРОЦЕСІ 🔄) + +**Мета:** Красиві smooth gradients як Canvas heatmap, але GPU-accelerated. + +**Файл:** `frontend/src/components/map/WebGLRadialCoverageLayer.tsx` + +**Як працює:** +1. Кожна точка = quad з Gaussian radial falloff +2. Additive blending в float framebuffer: (weight × rsrp, weight) +3. Final composite pass: normalize (R/G) + colormap + +**Поточний статус:** +- ✅ Framebuffer створюється правильно +- ✅ Points рендеряться (framebuffer має дані) +- ✅ Composite pass працює (final pixel має колір) +- ✅ 50m показує beautiful smooth gradients! +- ✅ 200m тепер теж показує (після radius fix) +- ⚠️ Coverage radius не повний (обрізається раніше ніж 10km) +- ⚠️ Темне коло на периферії (falloff занадто різкий?) +- ⚠️ Selector dropdown сірий на білому (CSS issue) + +--- + +### 3. Coverage Renderer Selector (ЗАВЕРШЕНО ✅) + +**Файл:** `frontend/src/store/settings.ts` + +**Додано:** `coverageRenderer: 'radial' | 'texture' | 'canvas'` + +**UI:** Dropdown в Coverage Settings panel + +**Fallback chain:** +- Radial fails → Texture +- Texture fails → Canvas + +--- + +## Залишилось зробити (Next Session) + +### Priority 1: Fix Radial Coverage Radius + +**Симптом:** Coverage не покриває повні 10km, обрізається раніше. + +**Можливі причини:** +1. Canvas bounds не включають padding для point radius +2. Points на краю мають gradient що виходить за canvas +3. Normalized coordinates calculation wrong at edges + +**Debug:** +```javascript +// Перевірити bounds vs actual coverage extent +console.log('Canvas bounds:', bounds); +console.log('Points extent:', { + minLat: Math.min(...points.map(p => p.lat)), + maxLat: Math.max(...points.map(p => p.lat)), + // ... +}); +``` + +**Fix approach:** +1. Додати padding до canvas bounds = point radius +2. Або clip points що виходять за межі + +--- + +### Priority 2: Fix Dark Ring on Periphery + +**Симптом:** Темне коло на краю coverage area. + +**Причина:** Точки на периферії мають менше сусідів → менший total weight → темніший колір після normalization. + +**Fix options:** +1. Збільшити radius multiplier (3.0× замість 2.5×) +2. Або додати edge detection і boost alpha там +3. Або використати min weight threshold перед normalization + +--- + +### Priority 3: Fix Selector Dropdown Styling + +**Симптом:** Сірий текст на білому фоні (погано видно). + +**Fix:** Update CSS classes в App.tsx для dropdown. + +--- + +### Priority 4: Performance Testing + +Протестувати з великою кількістю точок: +- 10,000+ points +- 50,000+ points +- Measure frame time + +Якщо повільно — implement instanced rendering. + +--- + +## Files Changed Today + +``` +frontend/src/components/map/ +├── WebGLCoverageLayer.tsx # Texture-based (updated with NN fill) +├── WebGLRadialCoverageLayer.tsx # NEW - Radial gradients +└── GeographicHeatmap.tsx # Canvas fallback (unchanged) + +frontend/src/store/ +└── settings.ts # Added coverageRenderer option + +frontend/src/ +└── App.tsx # Integrated renderer selector +``` + +--- + +## Console Debug Commands + +```javascript +// Check which renderer is active +document.querySelectorAll('canvas').forEach(c => + console.log(c.className, c.width, c.height) +); + +// Check WebGL errors +const canvas = document.querySelector('.webgl-radial-coverage'); +const gl = canvas?.getContext('webgl'); +console.log('WebGL error:', gl?.getError()); + +// Read center pixel +gl?.readPixels(canvas.width/2, canvas.height/2, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array(4)); +``` + +--- + +## Key Insights Learned + +1. **Texture-based vs Radial:** Texture good for terrain detail accuracy, Radial good for beautiful visualization. + +2. **Float framebuffer:** Need `EXT_color_buffer_float` extension. Fallback: use RGBA8 with encoding. + +3. **Additive blending:** `gl.blendFunc(gl.ONE, gl.ONE)` for accumulation, then `gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)` for final composite. + +4. **Weighted average in shader:** Store (weight × value, weight), then normalize: value = R / G. + +5. **Radius scaling:** Higher resolution = more points = smaller radius. Lower resolution = fewer points = larger radius to compensate. + +--- + +## Git Status + +- ✅ Pushed working WebGL texture-based coverage +- 🔄 WebGL radial in progress (functional but incomplete) + +--- + +## Next Session Start Point + +1. Відкрити RFCP project +2. `npm run dev` в frontend +3. Test radial coverage з 50m і 200m +4. Fix radius issue (Priority 1) +5. Fix dark ring (Priority 2) +6. Polish UI (Priority 3) + +--- + +## Session Stats + +- **Duration:** ~6 hours +- **Iterations:** 15+ fix attempts +- **Final result:** Working radial gradients renderer (90% complete) +- **Key breakthrough:** Discovering framebuffer had data but composite pass wasn't reading it diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5eca995..15b6b89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -15,6 +15,7 @@ import { db } from '@/db/schema.ts'; import MapView from '@/components/map/Map.tsx'; import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx'; import WebGLCoverageLayer from '@/components/map/WebGLCoverageLayer.tsx'; +import WebGLRadialCoverageLayer from '@/components/map/WebGLRadialCoverageLayer.tsx'; import CoverageBoundary from '@/components/map/CoverageBoundary.tsx'; import HeatmapLegend from '@/components/map/HeatmapLegend.tsx'; import SiteList from '@/components/panels/SiteList.tsx'; @@ -126,8 +127,8 @@ export default function App() { const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay); const elevationOpacity = useSettingsStore((s) => s.elevationOpacity); const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity); - const useWebGLCoverage = useSettingsStore((s) => s.useWebGLCoverage); - const setUseWebGLCoverage = useSettingsStore((s) => s.setUseWebGLCoverage); + const coverageRenderer = useSettingsStore((s) => s.coverageRenderer); + const setCoverageRenderer = useSettingsStore((s) => s.setCoverageRenderer); // History (undo/redo) const canUndo = useHistoryStore((s) => s.canUndo); @@ -698,8 +699,20 @@ export default function App() { {/* Show partial results during tiled calculation, or final result */} {(coverageResult || (isCalculating && partialPoints.length > 0)) && ( <> - {/* Only render ONE layer - WebGL or Canvas, never both */} - {useWebGLCoverage && ( + {/* Render coverage layer based on selected renderer */} + {coverageRenderer === 'webgl-radial' && ( + 0 ? partialPoints : (coverageResult?.points ?? [])} + visible={heatmapVisible} + opacity={settings.heatmapOpacity} + minRsrp={-130} + maxRsrp={-50} + radiusMeters={settings.heatmapRadius} + onWebGLFailed={() => setCoverageRenderer('webgl-texture')} + /> + )} + {coverageRenderer === 'webgl-texture' && ( 0 ? partialPoints : (coverageResult?.points ?? [])} @@ -707,10 +720,10 @@ export default function App() { opacity={settings.heatmapOpacity} minRsrp={-130} maxRsrp={-50} - onWebGLFailed={() => setUseWebGLCoverage(false)} + onWebGLFailed={() => setCoverageRenderer('canvas')} /> )} - {!useWebGLCoverage && ( + {coverageRenderer === 'canvas' && ( 0 ? partialPoints : (coverageResult?.points ?? [])} @@ -866,29 +879,24 @@ export default function App() { unit="%" hint="Transparency of the RF coverage overlay" /> -
-
- -

- WebGL interpolation for smooth gradients -

-
-