BioGTK  6.7.0
A .NET library & program for annotating, editing various microscopy imaging formats using Bioformats supported images. Including whole slide, pyramidal, and series.
Loading...
Searching...
No Matches
SlideGLArea.cs
1using System;
2using System.Collections.Generic;
3using System.Linq;
4using System.Runtime.InteropServices;
5using BruTile;
6using Gtk;
7using OpenTK.Graphics.OpenGL;
8using SkiaSharp;
9using AForge;
10using PixelFormat = OpenTK.Graphics.OpenGL.PixelFormat;
11
12namespace BioGTK
13{
19 public class SlideGLArea : GLArea
20 {
21 private const bool VerboseLogging = false;
22 private static readonly bool DiagnosticLogging = true;
23 // ============================================================================
24 // GL Resources
25 // ============================================================================
26
27 private bool _glInitialized;
28 private int _vao;
29 private int _vbo;
30 private int _shaderProgram;
31 private int _completeFrameTexture;
32 private int _completeFrameWidth;
33 private int _completeFrameHeight;
34 private bool _tileFrameComplete;
35
36 // Uniform locations (cached for performance)
37 private int _locPos;
38 private int _locSize;
39 private int _locViewportSize;
40 private int _locTex;
41 private int _locUvMin;
42 private int _locUvMax;
43
44 // ============================================================================
45 // Texture Cache - tiles uploaded to GPU
46 // ============================================================================
47
48 private Dictionary<TileIndex, int> _textureCache = new();
49 private Dictionary<TileIndex, (int w, int h)> _textureSizes = new();
50 private readonly HashSet<TileIndex> _protectedTileIndices = new();
51 private long _textureBytes;
52 private const int MAX_CACHED_TEXTURES = 128;
53 private const long MAX_CACHED_TEXTURE_BYTES = 96L * 1024 * 1024;
54
55 // ============================================================================
56 // Skia GL Backend for annotations
57 // ============================================================================
58
59 private GRContext _grContext;
60 private GRBackendRenderTarget _renderTarget;
61 private SKSurface _skSurface;
62
63 // ============================================================================
64 // Rendering State
65 // ============================================================================
66
67 public List<TileRenderInfo> TilesToRender { get; } = new();
68 public bool NeedsRedraw { get; set; } = true;
69 public bool PreservePreviousFrameWhileIncomplete { get; set; } = false;
70 private Action<byte[], int, int> _pendingWholeImageCapture;
71
72 // Screen-space boundary of the image (in pixels from top-left).
73 // Tiles are scissored to this rect so nothing renders outside the image.
74 // Set by SlideRenderer before each draw call. (-1 means no clipping.)
75 public float ImageScreenX { get; set; } = -1;
76 public float ImageScreenY { get; set; } = -1;
77 public float ImageScreenW { get; set; } = -1;
78 public float ImageScreenH { get; set; } = -1;
79
83 public event Action<SKCanvas, int, int> OnSkiaRender;
84
85 // ============================================================================
86 // Shaders
87 // ============================================================================
88
89 private const string VertexShaderSource = @"
90#version 330 core
91
92layout(location=0) in vec2 aPos;
93layout(location=1) in vec2 aUV;
94
95uniform vec2 pos; // pixel-space top-left of tile
96uniform vec2 size; // pixel-space size of tile
97uniform vec2 viewportSize; // (width, height) in pixels
98uniform vec2 uvMin; // UV sub-region min (for boundary clipping)
99uniform vec2 uvMax; // UV sub-region max (for boundary clipping)
100
101out vec2 uv;
102
103void main()
104{
105 // aPos is in [0,1] x [0,1]
106 vec2 pixelPos = aPos * size + pos;
107
108 // Convert from pixel coords to NDC (-1..1), with Y flipped for GL
109 vec2 ndc;
110 ndc.x = (pixelPos.x / viewportSize.x) * 2.0 - 1.0;
111 ndc.y = 1.0 - (pixelPos.y / viewportSize.y) * 2.0;
112
113 gl_Position = vec4(ndc, 0.0, 1.0);
114
115 // Map aUV [0,1] to the sub-region [uvMin, uvMax]
116 uv = uvMin + aUV * (uvMax - uvMin);
117}
118";
119
120 private const string FragmentShaderSource = @"
121#version 330 core
122
123in vec2 uv;
124out vec4 FragColor;
125uniform sampler2D tex;
126
127void main()
128{
129 // Fix: Removed '1.0 - uv.y'.
130 // Image data is usually Top-Down. OpenGL Textures are Bottom-Up.
131 // This implicit flip means UV(0,0) (Top-Left Quad) maps to Texture Bottom (Image Top).
132 // This is the correct orientation for Top-Left origin rendering.
133 FragColor = texture(tex, vec2(uv.x, uv.y));
134}
135";
136
137 // ============================================================================
138 // Construction
139 // ============================================================================
140
141 public SlideGLArea()
142 {
143 HasDepthBuffer = false;
144 HasStencilBuffer = false;
145 // Skia wraps this framebuffer as GL_RGBA8, so GTK must allocate an
146 // alpha-capable color attachment too. The final render pass explicitly
147 // writes alpha=1 across the surface, keeping presentation opaque while
148 // avoiding the undefined RGB-buffer-as-RGBA mismatch.
149 HasAlpha = true;
150 AutoRender = false; // We control when to render
151
152 Realized += OnRealized;
153 //Unrealize += OnUnrealize;
154 Render += OnRender;
155 Resize += OnResized;
156 }
157 // ============================================================================
158 // GLArea Lifecycle
159 // ============================================================================
160
161 private void OnRealized(object sender, EventArgs e)
162 {
163 MakeCurrent();
164
165 if (Error != 0)
166 {
167 if (VerboseLogging) Console.WriteLine($"GLArea error on realize.");
168 return;
169 }
170
171 InitializeGL();
172 InitializeSkia();
173
174 _glInitialized = true;
175 Gtk.Application.Invoke((s, a) => App.viewer?.RequestDeferredRender());
176 }
177
178 private void OnResized(object sender, EventArgs e)
179 {
180 if (!_glInitialized) return;
181
182 MakeCurrent();
183
184 // Recreate Skia surface for new size
185 CleanupSkia();
186 InitializeSkia();
187
188 NeedsRedraw = true;
189 Gtk.Application.Invoke((s, a) => App.viewer?.RequestDeferredRender());
190 }
191
192 // ============================================================================
193 // GL Initialization
194 // ============================================================================
195
196 private void InitializeGL()
197 {
198 // Compile shaders
199 int vertexShader = CompileShader(ShaderType.VertexShader, VertexShaderSource);
200 int fragmentShader = CompileShader(ShaderType.FragmentShader, FragmentShaderSource);
201
202 _shaderProgram = GL.CreateProgram();
203 GL.AttachShader(_shaderProgram, vertexShader);
204 GL.AttachShader(_shaderProgram, fragmentShader);
205 GL.LinkProgram(_shaderProgram);
206
207 GL.GetProgram(_shaderProgram, GetProgramParameterName.LinkStatus, out int linkStatus);
208 if (linkStatus == 0)
209 {
210 string infoLog = GL.GetProgramInfoLog(_shaderProgram);
211 throw new Exception($"Shader link failed: {infoLog}");
212 }
213
214 GL.DeleteShader(vertexShader);
215 GL.DeleteShader(fragmentShader);
216
217 // Cache uniform locations
218 _locPos = GL.GetUniformLocation(_shaderProgram, "pos");
219 _locSize = GL.GetUniformLocation(_shaderProgram, "size");
220 _locViewportSize = GL.GetUniformLocation(_shaderProgram, "viewportSize");
221 _locTex = GL.GetUniformLocation(_shaderProgram, "tex");
222 _locUvMin = GL.GetUniformLocation(_shaderProgram, "uvMin");
223 _locUvMax = GL.GetUniformLocation(_shaderProgram, "uvMax");
224
225 // Create quad VAO/VBO for tile rendering
226 float[] quadVertices =
227 {
228 // pos uv
229 0, 0, 0, 0,
230 1, 0, 1, 0,
231 1, 1, 1, 1,
232
233 0, 0, 0, 0,
234 1, 1, 1, 1,
235 0, 1, 0, 1
236 };
237
238 _vao = GL.GenVertexArray();
239 _vbo = GL.GenBuffer();
240
241 GL.BindVertexArray(_vao);
242 GL.BindBuffer(BufferTarget.ArrayBuffer, _vbo);
243 GL.BufferData(BufferTarget.ArrayBuffer, quadVertices.Length * sizeof(float),
244 quadVertices, BufferUsageHint.StaticDraw);
245
246 // Position attribute
247 GL.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), 0);
248 GL.EnableVertexAttribArray(0);
249
250 // UV attribute
251 GL.VertexAttribPointer(1, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), 2 * sizeof(float));
252 GL.EnableVertexAttribArray(1);
253
254 GL.BindVertexArray(0);
255 }
256
257 private int CompileShader(ShaderType type, string source)
258 {
259 int shader = GL.CreateShader(type);
260 GL.ShaderSource(shader, source);
261 GL.CompileShader(shader);
262
263 GL.GetShader(shader, ShaderParameter.CompileStatus, out int status);
264 if (status == 0)
265 {
266 string infoLog = GL.GetShaderInfoLog(shader);
267 throw new Exception($"Shader compilation failed ({type}): {infoLog}");
268 }
269
270 return shader;
271 }
272
273 private void CleanupGL()
274 {
275 if (_shaderProgram != 0)
276 {
277 GL.DeleteProgram(_shaderProgram);
278 _shaderProgram = 0;
279 }
280
281 if (_vbo != 0)
282 {
283 GL.DeleteBuffer(_vbo);
284 _vbo = 0;
285 }
286
287 if (_vao != 0)
288 {
289 GL.DeleteVertexArray(_vao);
290 _vao = 0;
291 }
292
294 DiscardCompleteFrame();
295 }
296
297 // ============================================================================
298 // Skia Initialization
299 // ============================================================================
300
301 private void InitializeSkia(int framebuffer = -1)
302 {
303 // Capture GTK's framebuffer before disposing the previous Skia target;
304 // target disposal is allowed to change GL bindings.
305 if (framebuffer < 0)
306 GL.GetInteger(GetPName.FramebufferBinding, out framebuffer);
307
308 // Keep one GPU context for the lifetime of the GLArea. Creating a new
309 // GRContext on every frame leaks native GL resources and eventually
310 // causes allocations such as GL.GenTexture to return 0.
311 _grContext ??= GRContext.CreateGl();
312
313 // GTK may rotate the GLArea framebuffer between renders. Recreate only
314 // the lightweight target/surface wrappers for the currently bound FBO.
315 _skSurface?.Dispose();
316 _skSurface = null;
317 _renderTarget?.Dispose();
318 _renderTarget = null;
319
320 int targetWidth = AllocatedWidth * ScaleFactor;
321 int targetHeight = AllocatedHeight * ScaleFactor;
322 if (_grContext == null || targetWidth <= 0 || targetHeight <= 0)
323 return;
324
325 // Ensure the format matches what Gtk.GLArea provides (usually GL_RGBA8)
326 var framebufferInfo = new GRGlFramebufferInfo((uint)framebuffer, 0x8058); // GL_RGBA8
327
328 _renderTarget = new GRBackendRenderTarget(
329 targetWidth,
330 targetHeight,
331 0, 8, framebufferInfo);
332
333 _skSurface = SKSurface.Create(_grContext, _renderTarget, GRSurfaceOrigin.BottomLeft, SKColorType.Rgba8888);
334 }
335
336 private void CleanupSkia()
337 {
338 _skSurface?.Dispose();
339 _skSurface = null;
340
341 _renderTarget?.Dispose();
342 _renderTarget = null;
343
344 _grContext?.Dispose();
345 _grContext = null;
346 }
347
348 // ============================================================================
349 // Rendering
350 // ============================================================================
351
352 private void OnRender(object sender, RenderArgs args)
353 {
354 if (!_glInitialized)
355 {
356 args.RetVal = true;
357 return;
358 }
359
360 MakeCurrent();
361 GL.GetInteger(GetPName.FramebufferBinding, out int presentationFramebuffer);
362
363 int width = AllocatedWidth;
364 int height = AllocatedHeight;
365
366 if (width <= 0 || height <= 0)
367 {
368 args.RetVal = true;
369 return;
370 }
371
372 // Phase 1: Render tiles with OpenGL
373 RenderTiles(width, height);
374 if (_tileFrameComplete)
375 CaptureCompleteTileFrame(width, height);
376
377 // The initial fit-to-window frame already contains everything needed
378 // by the minimap. Capture it before Skia adds the overview and ROIs so
379 // ImageView does not perform a second remote pyramid read.
380 CaptureWholeImageFrameIfRequested(width, height);
381
382 // Phase 2: Render annotations with Skia
383 try
384 {
385 RenderSkiaOverlay(width, height, presentationFramebuffer);
386 }
387 catch (Exception ex)
388 {
389 Console.WriteLine($"Skia overlay render error: {ex.Message}");
390 }
391
392 // Skia surfaces are alpha-capable even though the viewer window is not.
393 // Some Windows GL/GDK combinations leave untouched overlay pixels with
394 // alpha zero. Rewrite only the alpha channel after the overlay so the
395 // compositor can never expose windows behind the viewer.
396 GL.BindFramebuffer(FramebufferTarget.Framebuffer, presentationFramebuffer);
397 ForceOpaqueFramebuffer();
398 GL.Flush();
399 args.RetVal = true;
400 }
401
402 private static void ForceOpaqueFramebuffer()
403 {
404 GL.Disable(EnableCap.ScissorTest);
405 GL.ColorMask(false, false, false, true);
406 GL.ClearColor(0f, 0f, 0f, 1f);
407 GL.Clear(ClearBufferMask.ColorBufferBit);
408 GL.ColorMask(true, true, true, true);
409 }
410
411 private void RenderTiles(int width, int height)
412 {
413 _tileFrameComplete = false;
414 int scale = ScaleFactor;
415 GL.Viewport(0, 0, width * scale, height * scale);
416
417 // GLArea may provide a different framebuffer for each render. Its
418 // previous contents are undefined, so returning early in an attempt
419 // to preserve the prior frame can present transparent pixels to the
420 // window compositor. Establish a fully opaque frame on every pass;
421 // cached fallback tiles are drawn over it below when available.
422 GL.Disable(EnableCap.ScissorTest);
423 GL.ColorMask(true, true, true, true);
424 GL.ClearColor(0.2f, 0.2f, 0.2f, 1f);
425 GL.Clear(ClearBufferMask.ColorBufferBit);
426
427 if (TilesToRender.Count == 0)
428 {
429 if (PreservePreviousFrameWhileIncomplete)
430 RenderCompleteFrameFallback(width, height);
431 LogDiag($"[RenderTiles] no tiles to render viewport={width}x{height} imageScreen=({ImageScreenX},{ImageScreenY},{ImageScreenW},{ImageScreenH})");
432 return;
433 }
434
435 bool hasAllRenderableTextures = TilesToRender.All(tile => _textureCache.ContainsKey(tile.Index));
436 if (!hasAllRenderableTextures)
437 {
438 // Never expose a partially assembled target frame. Retain the last
439 // complete GPU snapshot until all visible tiles are resident, then
440 // replace the frame atomically. On first load, keep the opaque
441 // background instead of popping tiles in one at a time.
442 if (_completeFrameTexture != 0)
443 RenderCompleteFrameFallback(width, height);
444 LogDiag($"[RenderTiles] waiting for atomic tile frame viewport={width}x{height} tileCount={TilesToRender.Count}");
445 return;
446 }
447
448 LogDiag($"[RenderTiles] tiles={TilesToRender.Count} viewport={width}x{height} imageScreen=({ImageScreenX},{ImageScreenY},{ImageScreenW},{ImageScreenH})");
449
450 GL.Enable(EnableCap.Blend);
451 GL.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
452
453 // Scissor to the image boundary so tiles cannot render pixels outside the image.
454 // GL scissor Y is from bottom; convert from top-left screen coords.
455 if (ImageScreenW > 0 && ImageScreenH > 0)
456 {
457 GL.Enable(EnableCap.ScissorTest);
458 // Apply (int) cast AFTER multiplying by the HiDPI scale factor so
459 // sub-pixel rounding errors don't compound.
460 int sx = (int)(Math.Max(0, ImageScreenX) * scale);
461 int sy = (int)(Math.Max(0, (height - ImageScreenY - ImageScreenH)) * scale);
462 int sw = (int)(Math.Min(ImageScreenW, width - ImageScreenX) * scale);
463 int sh = (int)(Math.Min(ImageScreenH, height - ImageScreenY) * scale);
464 GL.Scissor(sx, sy, Math.Max(0, sw), Math.Max(0, sh));
465 }
466
467 GL.UseProgram(_shaderProgram);
468 GL.BindVertexArray(_vao);
469
470 GL.Uniform2(_locViewportSize, (float)width, (float)height);
471 GL.Uniform1(_locTex, 0);
472 GL.ActiveTexture(TextureUnit.Texture0);
473
474 int renderedCount = 0;
475 foreach (var tile in TilesToRender)
476 {
477 if (!_textureCache.TryGetValue(tile.Index, out int texId))
478 {
479 LogDiag($"[RenderTiles] missing texture tile={tile.Index}");
480 continue;
481 }
482
483 GL.Uniform2(_locPos, tile.ScreenX, tile.ScreenY);
484 GL.Uniform2(_locSize, tile.ScreenWidth, tile.ScreenHeight);
485 GL.Uniform2(_locUvMin, tile.U0, tile.V0);
486 GL.Uniform2(_locUvMax, tile.U1, tile.V1);
487
488 LogDiag($"[RenderTiles] draw tile={tile.Index} pos=({tile.ScreenX:F1},{tile.ScreenY:F1}) size=({tile.ScreenWidth:F1},{tile.ScreenHeight:F1}) tex={texId}");
489
490 GL.BindTexture(TextureTarget.Texture2D, texId);
491 GL.DrawArrays(PrimitiveType.Triangles, 0, 6);
492 renderedCount++;
493 }
494
495 LogDiag($"[RenderTiles] rendered={renderedCount}");
496
497 GL.Disable(EnableCap.ScissorTest);
498 GL.BindVertexArray(0);
499 GL.UseProgram(0);
500 GL.Disable(EnableCap.Blend);
501 _tileFrameComplete = true;
502 }
503
504 private void RenderCompleteFrameFallback(int width, int height)
505 {
506 if (_completeFrameTexture == 0 || _completeFrameWidth <= 0 || _completeFrameHeight <= 0)
507 return;
508
509 GL.Disable(EnableCap.ScissorTest);
510 GL.Disable(EnableCap.Blend);
511 GL.UseProgram(_shaderProgram);
512 GL.BindVertexArray(_vao);
513 GL.Uniform2(_locViewportSize, (float)width, (float)height);
514 GL.Uniform1(_locTex, 0);
515 GL.Uniform2(_locPos, 0f, 0f);
516 GL.Uniform2(_locSize, (float)width, (float)height);
517 // Framebuffer copies use bottom-left texture origin, unlike uploaded
518 // top-down tile bytes, so reverse V while drawing the snapshot.
519 GL.Uniform2(_locUvMin, 0f, 1f);
520 GL.Uniform2(_locUvMax, 1f, 0f);
521 GL.ActiveTexture(TextureUnit.Texture0);
522 GL.BindTexture(TextureTarget.Texture2D, _completeFrameTexture);
523 GL.DrawArrays(PrimitiveType.Triangles, 0, 6);
524 GL.BindTexture(TextureTarget.Texture2D, 0);
525 GL.BindVertexArray(0);
526 GL.UseProgram(0);
527 }
528
529 private void CaptureCompleteTileFrame(int width, int height)
530 {
531 int physicalWidth = width * Math.Max(1, ScaleFactor);
532 int physicalHeight = height * Math.Max(1, ScaleFactor);
533 if (physicalWidth <= 0 || physicalHeight <= 0)
534 return;
535
536 if (_completeFrameTexture == 0)
537 _completeFrameTexture = GL.GenTexture();
538 if (_completeFrameTexture == 0)
539 return;
540
541 GL.BindTexture(TextureTarget.Texture2D, _completeFrameTexture);
542 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
543 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
544 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
545 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
546
547 if (_completeFrameWidth != physicalWidth || _completeFrameHeight != physicalHeight)
548 {
549 GL.CopyTexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba,
550 0, 0, physicalWidth, physicalHeight, 0);
551 _completeFrameWidth = physicalWidth;
552 _completeFrameHeight = physicalHeight;
553 }
554 else
555 {
556 GL.CopyTexSubImage2D(TextureTarget.Texture2D, 0, 0, 0,
557 0, 0, physicalWidth, physicalHeight);
558 }
559 GL.BindTexture(TextureTarget.Texture2D, 0);
560 }
561
562 public void DiscardCompleteFrame()
563 {
564 if (_completeFrameTexture != 0 && _glInitialized)
565 {
566 MakeCurrent();
567 GL.DeleteTexture(_completeFrameTexture);
568 }
569 _completeFrameTexture = 0;
570 _completeFrameWidth = 0;
571 _completeFrameHeight = 0;
572 }
573
574 private void CaptureWholeImageFrameIfRequested(int width, int height)
575 {
576 var callback = _pendingWholeImageCapture;
577 if (callback == null || TilesToRender.Count == 0 ||
578 !TilesToRender.All(tile => _textureCache.ContainsKey(tile.Index)))
579 return;
580
581 // Only use a frame in which the complete image fits in the viewport.
582 // A later zoomed/panned frame is not a valid overview source.
583 const float tolerance = 1.5f;
584 if (ImageScreenW <= 0 || ImageScreenH <= 0 ||
585 ImageScreenX < -tolerance || ImageScreenY < -tolerance ||
586 ImageScreenX + ImageScreenW > width + tolerance ||
587 ImageScreenY + ImageScreenH > height + tolerance)
588 return;
589
590 int scale = Math.Max(1, ScaleFactor);
591 int framebufferWidth = width * scale;
592 int framebufferHeight = height * scale;
593 int left = Math.Clamp((int)Math.Round(ImageScreenX * scale), 0, framebufferWidth - 1);
594 int top = Math.Clamp((int)Math.Round(ImageScreenY * scale), 0, framebufferHeight - 1);
595 int captureWidth = Math.Clamp((int)Math.Round(ImageScreenW * scale), 1, framebufferWidth - left);
596 int captureHeight = Math.Clamp((int)Math.Round(ImageScreenH * scale), 1, framebufferHeight - top);
597 int glBottom = framebufferHeight - top - captureHeight;
598
599 byte[] rgbaBottomUp = new byte[captureWidth * captureHeight * 4];
600 GL.ReadPixels(left, glBottom, captureWidth, captureHeight,
601 PixelFormat.Rgba, PixelType.UnsignedByte, rgbaBottomUp);
602
603 // AForge's 32-bit bitmap path expects BGRA rows from top to bottom.
604 byte[] bgraTopDown = new byte[rgbaBottomUp.Length];
605 int rowBytes = captureWidth * 4;
606 for (int y = 0; y < captureHeight; y++)
607 {
608 int sourceRow = (captureHeight - 1 - y) * rowBytes;
609 int destinationRow = y * rowBytes;
610 for (int x = 0; x < captureWidth; x++)
611 {
612 int source = sourceRow + x * 4;
613 int destination = destinationRow + x * 4;
614 bgraTopDown[destination] = rgbaBottomUp[source + 2];
615 bgraTopDown[destination + 1] = rgbaBottomUp[source + 1];
616 bgraTopDown[destination + 2] = rgbaBottomUp[source];
617 bgraTopDown[destination + 3] = 255;
618 }
619 }
620
621 _pendingWholeImageCapture = null;
622 callback(bgraTopDown, captureWidth, captureHeight);
623 }
624
625 private void RenderSkiaOverlay(int width, int height, int presentationFramebuffer)
626 {
627 if (OnSkiaRender == null) return;
628 InitializeSkia(presentationFramebuffer);
629 if (_skSurface == null)
630 return;
631
632 // Tile rendering uses raw OpenGL immediately before Skia. Tell Skia
633 // that every cached GL binding/state may have changed so it rebinds
634 // the current GTK framebuffer, program, viewport and blend state.
635 _grContext.ResetContext(GRGlBackendState.All);
636 var canvas = _skSurface.Canvas;
637 // Fire event for annotation drawing
638 OnSkiaRender?.Invoke(canvas, width, height);
639 _grContext.Flush();
640 _grContext.Submit(true);
641 }
642
643 // ============================================================================
644 // Texture Management
645 // ============================================================================
646
650 public void UploadTileTexture(TileIndex index, byte[] pixelData, int tileWidth, int tileHeight)
651 {
652 if (!_glInitialized)
653 {
654 LogDiag($"[UploadTileTexture] gl-not-initialized tile={index}");
655 return;
656 }
657
658 if (pixelData == null || pixelData.Length == 0)
659 {
660 LogDiag($"[UploadTileTexture] empty pixel data tile={index}");
661 return;
662 }
663
664 // The buffer is always BGRA = 4 bytes per pixel.
665 // GetTile pads every tile — including edge tiles — to exactly
666 // tileWidth x tileHeight pixels, so the dimensions passed by SlideRenderer
667 // (the schema tile size) always match the buffer. We just validate and bail
668 // on any genuine mismatch rather than trying to guess new dimensions.
669 if (tileWidth <= 0 || tileHeight <= 0)
670 {
671 LogDiag($"[UploadTileTexture] invalid dimensions tile={index} size={tileWidth}x{tileHeight}");
672 return;
673 }
674
675 int expectedBytes = tileWidth * tileHeight * 4;
676 if (pixelData.Length < expectedBytes)
677 {
678 LogDiag($"[UploadTileTexture] buffer too small tile={index} need={expectedBytes} got={pixelData.Length}");
679 return;
680 }
681
682 MakeCurrent();
683
684 // Check if already cached
685 if (_textureCache.ContainsKey(index))
686 {
687 LogDiag($"[UploadTileTexture] already cached tile={index}");
688 return;
689 }
690
691 long newTextureBytes = (long)tileWidth * tileHeight * 4;
692
693 // Evict old textures if the cache is full or if the new upload would
694 // push the total texture budget over its byte cap.
695 while (_textureCache.Count > 0 &&
696 (_textureCache.Count >= MAX_CACHED_TEXTURES ||
697 _textureBytes + newTextureBytes > MAX_CACHED_TEXTURE_BYTES))
698 {
699 EvictOldestTextures(1);
700 }
701
702 // Create and upload texture.
703 // Pin the managed array explicitly so the GC cannot relocate it while the
704 // driver is reading from the pointer (avoids ExecutionEngineException).
705 // PixelFormat.Bgra matches the byte order produced by ReadRegionAsync.
706 int tex = GL.GenTexture();
707 if (tex == 0)
708 {
709 Console.WriteLine($"OpenGL failed to allocate texture for tile {index}.");
710 return;
711 }
712 GL.BindTexture(TextureTarget.Texture2D, tex);
713 GL.PixelStore(PixelStoreParameter.UnpackAlignment, 4);
714
715 var handle = System.Runtime.InteropServices.GCHandle.Alloc(pixelData, System.Runtime.InteropServices.GCHandleType.Pinned);
716 try
717 {
718 GL.TexImage2D(TextureTarget.Texture2D, 0, PixelInternalFormat.Rgba,
719 tileWidth, tileHeight, 0,
720 PixelFormat.Bgra, PixelType.UnsignedByte,
721 handle.AddrOfPinnedObject());
722 }
723 finally
724 {
725 handle.Free();
726 }
727
728 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
729 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
730 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge);
731 GL.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge);
732
733 GL.BindTexture(TextureTarget.Texture2D, 0);
734
735 _textureCache[index] = tex;
736 _textureSizes[index] = (tileWidth, tileHeight);
737 _textureBytes += newTextureBytes;
738 LogDiag($"[UploadTileTexture] uploaded tile={index} tex={tex} size={tileWidth}x{tileHeight} bytes={pixelData.Length}");
739 }
740
745 public void GetUploadedTileSize(TileIndex index, out int w, out int h)
746 {
747 if (_textureSizes.TryGetValue(index, out var sz))
748 { w = sz.w; h = sz.h; }
749 else
750 { w = 0; h = 0; }
751 }
752
756 public bool HasTileTexture(TileIndex index)
757 {
758 return _textureCache.ContainsKey(index);
759 }
760
764 public void ReleaseTileTexture(TileIndex index)
765 {
766 if (_textureCache.TryGetValue(index, out int tex))
767 {
768 MakeCurrent();
769 GL.DeleteTexture(tex);
770 _textureCache.Remove(index);
771 if (_textureSizes.TryGetValue(index, out var sz))
772 {
773 _textureBytes -= (long)sz.w * sz.h * 4;
774 _textureSizes.Remove(index);
775 }
776 }
777 }
778
782 public void ReleaseLevelTextures(int level)
783 {
784 if (!_glInitialized) return;
785
786 MakeCurrent();
787
788 var toRemove = _textureCache.Where(kvp => kvp.Key.Level == level).ToList();
789 foreach (var kvp in toRemove)
790 {
791 GL.DeleteTexture(kvp.Value);
792 if (_textureSizes.TryGetValue(kvp.Key, out var sz))
793 {
794 _textureBytes -= (long)sz.w * sz.h * 4;
795 _textureSizes.Remove(kvp.Key);
796 }
797 _textureCache.Remove(kvp.Key);
798 }
799 }
800
804 public void ClearTextureCache()
805 {
806 if (!_glInitialized) return;
807
808 MakeCurrent();
809
810 foreach (var tex in _textureCache.Values)
811 {
812 GL.DeleteTexture(tex);
813 }
814 _textureCache.Clear();
815 _textureSizes.Clear();
816 _protectedTileIndices.Clear();
817 _textureBytes = 0;
818 }
819
820 private void EvictOldestTextures(int count)
821 {
822 // Prefer off-screen textures. Tiles in the displayed viewport and
823 // incoming tiles for a pending level switch must remain resident long
824 // enough to form a complete frame.
825 var toRemove = _textureCache
826 .Where(kvp => !_protectedTileIndices.Contains(kvp.Key))
827 .Take(count)
828 .ToList();
829
830 // This should only be needed if a viewport itself exceeds the cache.
831 if (toRemove.Count < count)
832 {
833 var selected = new HashSet<TileIndex>(toRemove.Select(kvp => kvp.Key));
834 toRemove.AddRange(_textureCache
835 .Where(kvp => !selected.Contains(kvp.Key))
836 .Take(count - toRemove.Count));
837 }
838 foreach (var kvp in toRemove)
839 {
840 GL.DeleteTexture(kvp.Value);
841 if (_textureSizes.TryGetValue(kvp.Key, out var sz))
842 {
843 _textureBytes -= (long)sz.w * sz.h * 4;
844 _textureSizes.Remove(kvp.Key);
845 }
846 _textureCache.Remove(kvp.Key);
847 }
848 }
849
850 // ============================================================================
851 // Public API
852 // ============================================================================
853
857 public void RequestRedraw()
858 {
859 NeedsRedraw = true;
860 QueueRender();
861 QueueDraw();
862 }
863
868 public void CaptureNextWholeImageFrame(Action<byte[], int, int> callback)
869 {
870 _pendingWholeImageCapture = callback;
872 }
873
874 public void CancelWholeImageFrameCapture(Action<byte[], int, int> callback)
875 {
876 if (ReferenceEquals(_pendingWholeImageCapture, callback))
877 _pendingWholeImageCapture = null;
878 }
879
883 public void SetTilesToRender(IEnumerable<TileRenderInfo> tiles)
884 {
885 TilesToRender.Clear();
886 TilesToRender.AddRange(tiles);
887 LogDiag($"[SetTilesToRender] count={TilesToRender.Count}");
888 }
889
895 public void SetProtectedTileIndices(IEnumerable<TileIndex> indices)
896 {
897 _protectedTileIndices.Clear();
898 foreach (var index in indices)
899 _protectedTileIndices.Add(index);
900 foreach (var tile in TilesToRender)
901 _protectedTileIndices.Add(tile.Index);
902 }
903
904 public int CachedTextureCount => _textureCache.Count;
905
910 public byte[] ReadPixels()
911 {
912 if (!_glInitialized) return null;
913
914 MakeCurrent();
915
916 int width = AllocatedWidth;
917 int height = AllocatedHeight;
918
919 byte[] pixels = new byte[width * height * 4];
920 GL.ReadPixels(0, 0, width, height, PixelFormat.Rgba, PixelType.UnsignedByte, pixels);
921
922 return pixels;
923 }
924
925 private static void LogDiag(string message)
926 {
927 if (!DiagnosticLogging)
928 return;
929
930 AppLog.Append(message);
931 }
932 }
933
934 // ============================================================================
935 // Supporting Types
936 // ============================================================================
937
941 public struct TileRenderInfo
942 {
943 public TileIndex Index;
944
945 // Screen-space position and size in pixels
946 public float ScreenX;
947 public float ScreenY;
948 public float ScreenWidth;
949 public float ScreenHeight;
950
951 // UV sub-region within the tile texture (for boundary clipping)
952 public float U0, V0, U1, V1;
953
954 public TileRenderInfo(TileIndex index, float x, float y, float w, float h)
955 {
956 Index = index;
957 ScreenX = x;
958 ScreenY = y;
959 ScreenWidth = w;
960 ScreenHeight = h;
961 U0 = 0f; V0 = 0f; U1 = 1f; V1 = 1f;
962 }
963
964 public TileRenderInfo(TileIndex index, float x, float y, float w, float h,
965 float u0, float v0, float u1, float v1)
966 {
967 Index = index;
968 ScreenX = x;
969 ScreenY = y;
970 ScreenWidth = w;
971 ScreenHeight = h;
972 U0 = u0; V0 = v0; U1 = u1; V1 = v1;
973 }
974 }
975}
A GTK GLArea widget that renders pyramidal slide tiles directly on the GPU. Eliminates ReadPixels ove...
void RequestRedraw()
Request a redraw of the GLArea.
void UploadTileTexture(TileIndex index, byte[] pixelData, int tileWidth, int tileHeight)
Upload a tile texture to the GPU. Call from the main thread.
bool HasTileTexture(TileIndex index)
Check if a tile texture is already in the GPU cache.
byte[] ReadPixels()
Read pixels from the current framebuffer (for export/save operations). This is slow - only use for ex...
Action< SKCanvas, int, int > OnSkiaRender
Event fired during Skia rendering phase for annotation drawing.
void SetProtectedTileIndices(IEnumerable< TileIndex > indices)
Protects incoming visible tiles from FIFO eviction. The currently displayed set is included automatic...
void GetUploadedTileSize(TileIndex index, out int w, out int h)
Get the uploaded texture dimensions for a cached tile. Returns (0,0) if the tile is not in the cache.
void CaptureNextWholeImageFrame(Action< byte[], int, int > callback)
Captures the next complete fit-to-window image frame. A newer request replaces an older one because o...
void ClearTextureCache()
Clear all cached textures.
void ReleaseLevelTextures(int level)
Release all textures for a specific pyramid level.
void SetTilesToRender(IEnumerable< TileRenderInfo > tiles)
Prepare tiles for rendering. Call before RequestRedraw().
void ReleaseTileTexture(TileIndex index)
Release a specific tile texture.
Definition ML.cs:17
Information needed to render a single tile.