|
1 | 1 | using System; |
| 2 | +using System.Collections.Generic; |
2 | 3 | using System.IO; |
3 | 4 | using System.Linq; |
4 | 5 | using UnityEngine; |
@@ -270,6 +271,232 @@ public static (string base64, int width, int height) RenderCameraToBase64(Camera |
270 | 271 | } |
271 | 272 | } |
272 | 273 |
|
| 274 | + /// <summary> |
| 275 | + /// Renders a camera to a Texture2D without saving to disk. |
| 276 | + /// Caller owns the returned texture and must destroy it. |
| 277 | + /// </summary> |
| 278 | + public static Texture2D RenderCameraToTexture(Camera camera, int maxResolution = 640) |
| 279 | + { |
| 280 | + if (camera == null) throw new ArgumentNullException(nameof(camera)); |
| 281 | + |
| 282 | + int width = Mathf.Max(1, camera.pixelWidth > 0 ? camera.pixelWidth : Screen.width); |
| 283 | + int height = Mathf.Max(1, camera.pixelHeight > 0 ? camera.pixelHeight : Screen.height); |
| 284 | + |
| 285 | + RenderTexture prevRT = camera.targetTexture; |
| 286 | + RenderTexture prevActive = RenderTexture.active; |
| 287 | + var rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32); |
| 288 | + Texture2D tex = null; |
| 289 | + try |
| 290 | + { |
| 291 | + camera.targetTexture = rt; |
| 292 | + camera.Render(); |
| 293 | + |
| 294 | + RenderTexture.active = rt; |
| 295 | + tex = new Texture2D(width, height, TextureFormat.RGBA32, false); |
| 296 | + tex.ReadPixels(new Rect(0, 0, width, height), 0, 0); |
| 297 | + tex.Apply(); |
| 298 | + |
| 299 | + int targetMax = maxResolution > 0 ? maxResolution : 640; |
| 300 | + if (width > targetMax || height > targetMax) |
| 301 | + { |
| 302 | + var downscaled = DownscaleTexture(tex, targetMax); |
| 303 | + DestroyTexture(tex); |
| 304 | + tex = null; |
| 305 | + return downscaled; |
| 306 | + } |
| 307 | + var result = tex; |
| 308 | + tex = null; // transfer ownership to caller |
| 309 | + return result; |
| 310 | + } |
| 311 | + finally |
| 312 | + { |
| 313 | + camera.targetTexture = prevRT; |
| 314 | + RenderTexture.active = prevActive; |
| 315 | + RenderTexture.ReleaseTemporary(rt); |
| 316 | + DestroyTexture(tex); |
| 317 | + } |
| 318 | + } |
| 319 | + |
| 320 | + /// <summary> |
| 321 | + /// Composites a list of tile textures into a single contact-sheet grid image. |
| 322 | + /// Labels are drawn as white text on a dark banner at the bottom of each tile. |
| 323 | + /// Returns base64 PNG plus dimensions. Destroys all input tile textures. |
| 324 | + /// </summary> |
| 325 | + public static (string base64, int width, int height) ComposeContactSheet( |
| 326 | + List<Texture2D> tiles, List<string> labels, int padding = 4) |
| 327 | + { |
| 328 | + if (tiles == null || tiles.Count == 0) |
| 329 | + throw new ArgumentException("No tiles to compose.", nameof(tiles)); |
| 330 | + |
| 331 | + int tileW = tiles[0].width; |
| 332 | + int tileH = tiles[0].height; |
| 333 | + int count = tiles.Count; |
| 334 | + |
| 335 | + // Calculate grid: prefer wider than tall (cols >= rows) |
| 336 | + int cols = Mathf.CeilToInt(Mathf.Sqrt(count)); |
| 337 | + int rows = Mathf.CeilToInt((float)count / cols); |
| 338 | + |
| 339 | + int labelHeight = Mathf.Max(14, tileH / 12); |
| 340 | + int cellW = tileW + padding; |
| 341 | + int cellH = tileH + labelHeight + padding; |
| 342 | + |
| 343 | + int sheetW = cols * cellW + padding; |
| 344 | + int sheetH = rows * cellH + padding; |
| 345 | + |
| 346 | + Texture2D sheet = null; |
| 347 | + try |
| 348 | + { |
| 349 | + sheet = new Texture2D(sheetW, sheetH, TextureFormat.RGBA32, false); |
| 350 | + |
| 351 | + // Build the full sheet in a Color32[] buffer, then upload once |
| 352 | + var bgColor = new Color32(30, 30, 30, 255); |
| 353 | + Color32[] sheetPixels = new Color32[sheetW * sheetH]; |
| 354 | + for (int i = 0; i < sheetPixels.Length; i++) sheetPixels[i] = bgColor; |
| 355 | + |
| 356 | + // Track label draw requests so we can apply them after the bulk upload |
| 357 | + var labelDraws = new List<(string text, int x, int y, int h)>(); |
| 358 | + |
| 359 | + for (int idx = 0; idx < count; idx++) |
| 360 | + { |
| 361 | + int col = idx % cols; |
| 362 | + int row = idx / cols; |
| 363 | + // Place tiles top-left to bottom-right (Unity Texture2D y=0 is bottom) |
| 364 | + int x = padding + col * cellW; |
| 365 | + int y = sheetH - padding - (row + 1) * cellH + padding; |
| 366 | + |
| 367 | + // Copy tile pixels row-by-row using bulk operations |
| 368 | + Color32[] tilePixels = tiles[idx].GetPixels32(); |
| 369 | + for (int ty = 0; ty < tileH; ty++) |
| 370 | + { |
| 371 | + int srcOffset = ty * tileW; |
| 372 | + int dstOffset = (y + labelHeight + ty) * sheetW + x; |
| 373 | + System.Array.Copy(tilePixels, srcOffset, sheetPixels, dstOffset, tileW); |
| 374 | + } |
| 375 | + |
| 376 | + // Draw label banner (dark background strip below tile) |
| 377 | + var bannerColor = new Color32(20, 20, 20, 220); |
| 378 | + for (int ly = 0; ly < labelHeight; ly++) |
| 379 | + { |
| 380 | + int dstOffset = (y + ly) * sheetW + x; |
| 381 | + for (int lx = 0; lx < tileW; lx++) |
| 382 | + { |
| 383 | + sheetPixels[dstOffset + lx] = bannerColor; |
| 384 | + } |
| 385 | + } |
| 386 | + |
| 387 | + // Queue label text drawing (applied after bulk pixel upload) |
| 388 | + if (labels != null && idx < labels.Count && !string.IsNullOrEmpty(labels[idx])) |
| 389 | + { |
| 390 | + labelDraws.Add((labels[idx], x + 3, y + 2, labelHeight - 4)); |
| 391 | + } |
| 392 | + } |
| 393 | + |
| 394 | + // Upload all tile + banner pixels in one SetPixels32 call |
| 395 | + sheet.SetPixels32(sheetPixels); |
| 396 | + |
| 397 | + // Draw label text on top (small glyph-based writes, negligible cost) |
| 398 | + foreach (var (text, lx, ly, lh) in labelDraws) |
| 399 | + { |
| 400 | + DrawText(sheet, text, lx, ly, lh, Color.white); |
| 401 | + } |
| 402 | + |
| 403 | + sheet.Apply(); |
| 404 | + |
| 405 | + byte[] png = sheet.EncodeToPNG(); |
| 406 | + string b64 = System.Convert.ToBase64String(png); |
| 407 | + return (b64, sheetW, sheetH); |
| 408 | + } |
| 409 | + finally |
| 410 | + { |
| 411 | + foreach (var tile in tiles) DestroyTexture(tile); |
| 412 | + DestroyTexture(sheet); |
| 413 | + } |
| 414 | + } |
| 415 | + |
| 416 | + private static void DrawText(Texture2D tex, string text, int startX, int startY, int charHeight, Color color) |
| 417 | + { |
| 418 | + // Simple 5x7 bitmap font for basic ASCII characters |
| 419 | + int charWidth = Mathf.Max(4, charHeight * 5 / 7); |
| 420 | + int spacing = Mathf.Max(1, charWidth / 5); |
| 421 | + int x = startX; |
| 422 | + |
| 423 | + foreach (char c in text) |
| 424 | + { |
| 425 | + if (x + charWidth > tex.width) break; |
| 426 | + ulong glyph = GetGlyph(c); |
| 427 | + if (glyph != 0) |
| 428 | + { |
| 429 | + for (int row = 0; row < 7; row++) |
| 430 | + { |
| 431 | + for (int col = 0; col < 5; col++) |
| 432 | + { |
| 433 | + bool on = ((glyph >> ((6 - row) * 5 + (4 - col))) & 1) == 1; |
| 434 | + if (!on) continue; |
| 435 | + // Scale the 5x7 glyph to charWidth x charHeight |
| 436 | + int px0 = x + col * charWidth / 5; |
| 437 | + int px1 = x + (col + 1) * charWidth / 5; |
| 438 | + int py0 = startY + (6 - row) * charHeight / 7; |
| 439 | + int py1 = startY + (7 - row) * charHeight / 7; |
| 440 | + for (int py = py0; py < py1 && py < tex.height; py++) |
| 441 | + for (int px = px0; px < px1 && px < tex.width; px++) |
| 442 | + tex.SetPixel(px, py, color); |
| 443 | + } |
| 444 | + } |
| 445 | + } |
| 446 | + x += charWidth + spacing; |
| 447 | + } |
| 448 | + } |
| 449 | + |
| 450 | + private static ulong GetGlyph(char c) |
| 451 | + { |
| 452 | + // 5x7 pixel font stored as 35-bit values (row0=bits34-30 ... row6=bits4-0) |
| 453 | + // Each row is 5 wide, MSB=left. Row 0 is top. |
| 454 | + switch (char.ToUpperInvariant(c)) |
| 455 | + { |
| 456 | + case 'A': return 0b01110_10001_10001_11111_10001_10001_10001UL; |
| 457 | + case 'B': return 0b11110_10001_10001_11110_10001_10001_11110UL; |
| 458 | + case 'C': return 0b01110_10001_10000_10000_10000_10001_01110UL; |
| 459 | + case 'D': return 0b11100_10010_10001_10001_10001_10010_11100UL; |
| 460 | + case 'E': return 0b11111_10000_10000_11110_10000_10000_11111UL; |
| 461 | + case 'F': return 0b11111_10000_10000_11110_10000_10000_10000UL; |
| 462 | + case 'G': return 0b01110_10001_10000_10111_10001_10001_01110UL; |
| 463 | + case 'H': return 0b10001_10001_10001_11111_10001_10001_10001UL; |
| 464 | + case 'I': return 0b01110_00100_00100_00100_00100_00100_01110UL; |
| 465 | + case 'K': return 0b10001_10010_10100_11000_10100_10010_10001UL; |
| 466 | + case 'L': return 0b10000_10000_10000_10000_10000_10000_11111UL; |
| 467 | + case 'M': return 0b10001_11011_10101_10101_10001_10001_10001UL; |
| 468 | + case 'N': return 0b10001_11001_10101_10011_10001_10001_10001UL; |
| 469 | + case 'O': return 0b01110_10001_10001_10001_10001_10001_01110UL; |
| 470 | + case 'R': return 0b11110_10001_10001_11110_10100_10010_10001UL; |
| 471 | + case 'S': return 0b01110_10001_10000_01110_00001_10001_01110UL; |
| 472 | + case 'T': return 0b11111_00100_00100_00100_00100_00100_00100UL; |
| 473 | + case 'U': return 0b10001_10001_10001_10001_10001_10001_01110UL; |
| 474 | + case 'V': return 0b10001_10001_10001_10001_01010_01010_00100UL; |
| 475 | + case 'W': return 0b10001_10001_10001_10101_10101_11011_10001UL; |
| 476 | + case 'Y': return 0b10001_10001_01010_00100_00100_00100_00100UL; |
| 477 | + case '0': return 0b01110_10011_10101_10101_10101_11001_01110UL; |
| 478 | + case '1': return 0b00100_01100_00100_00100_00100_00100_01110UL; |
| 479 | + case '2': return 0b01110_10001_00001_00010_00100_01000_11111UL; |
| 480 | + case '3': return 0b01110_10001_00001_00110_00001_10001_01110UL; |
| 481 | + case '4': return 0b00010_00110_01010_10010_11111_00010_00010UL; |
| 482 | + case '5': return 0b11111_10000_11110_00001_00001_10001_01110UL; |
| 483 | + case '6': return 0b01110_10001_10000_11110_10001_10001_01110UL; |
| 484 | + case '7': return 0b11111_00001_00010_00100_01000_01000_01000UL; |
| 485 | + case '8': return 0b01110_10001_10001_01110_10001_10001_01110UL; |
| 486 | + case '9': return 0b01110_10001_10001_01111_00001_10001_01110UL; |
| 487 | + case 'J': return 0b00111_00010_00010_00010_00010_10010_01100UL; |
| 488 | + case 'P': return 0b11110_10001_10001_11110_10000_10000_10000UL; |
| 489 | + case 'Q': return 0b01110_10001_10001_10001_10101_10010_01101UL; |
| 490 | + case 'X': return 0b10001_01010_00100_00100_00100_01010_10001UL; |
| 491 | + case 'Z': return 0b11111_00001_00010_00100_01000_10000_11111UL; |
| 492 | + case '-': return 0b00000_00000_00000_11111_00000_00000_00000UL; |
| 493 | + case '_': return 0b00000_00000_00000_00000_00000_00000_11111UL; |
| 494 | + case ' ': return 0UL; |
| 495 | + case '+': return 0b00000_00100_00100_11111_00100_00100_00000UL; |
| 496 | + default: return 0UL; |
| 497 | + } |
| 498 | + } |
| 499 | + |
273 | 500 | /// <summary> |
274 | 501 | /// Downscales a Texture2D so that its longest edge is at most <paramref name="maxEdge"/> pixels. |
275 | 502 | /// Uses bilinear filtering via a temporary RenderTexture blit. |
|
0 commit comments