raster-budget.mjs 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
  1. // Header-based allocation bounds only: not a raster decoder or measured GPU memory.
  2. import assert from 'node:assert/strict'
  3. export const rasterLimits = Object.freeze({ maxRasterBytes: 1048576, maxTextureDimension: 512,
  4. maxDecodedTextureBytes: 4 * 1048576 })
  5. export function rasterDimensions(bytes, mimeType) {
  6. let width, height
  7. if (mimeType === 'image/png') {
  8. assert.ok(bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), 'invalid PNG signature/header')
  9. assert.equal(bytes.readUInt32BE(8), 13, 'invalid PNG IHDR length')
  10. assert.equal(bytes.toString('ascii', 12, 16), 'IHDR', 'missing PNG IHDR')
  11. width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20)
  12. } else {
  13. assert.equal(mimeType, 'image/jpeg', 'unsupported raster MIME type')
  14. assert.ok(bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216, 'invalid JPEG signature')
  15. let offset = 2
  16. while (offset < bytes.length) {
  17. assert.equal(bytes[offset++], 255, 'invalid JPEG marker')
  18. while (bytes[offset] === 255) offset++
  19. const marker = bytes[offset++]
  20. assert.ok(marker !== undefined && marker !== 0 && marker !== 216, 'invalid JPEG marker code')
  21. if (marker === 217 || marker === 218) break // SOF must precede EOI/SOS.
  22. if (marker === 1 || (marker >= 208 && marker <= 215)) continue
  23. assert.ok(offset + 2 <= bytes.length, 'truncated JPEG segment length')
  24. const length = bytes.readUInt16BE(offset)
  25. assert.ok(length >= 2 && offset + length <= bytes.length, 'truncated JPEG segment')
  26. if ([192, 193, 194, 195, 197, 198, 199, 201, 202, 203, 205, 206, 207].includes(marker)) {
  27. assert.ok(length >= 8, 'truncated JPEG SOF')
  28. height = bytes.readUInt16BE(offset + 3); width = bytes.readUInt16BE(offset + 5)
  29. break
  30. }
  31. offset += length
  32. }
  33. assert.ok(width !== undefined, 'missing JPEG SOF dimensions')
  34. }
  35. assert.ok(Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0, 'invalid raster dimensions')
  36. return { width, height }
  37. }
  38. export function rasterAllocation(bytes, mimeType, limits = rasterLimits) {
  39. const { width, height } = rasterDimensions(bytes, mimeType)
  40. assert.ok(Math.max(width, height) <= limits.maxTextureDimension, 'raster dimension budget exceeded')
  41. const rgbaBytes = width * height * 4
  42. // Conservative RGBA8 full mip-chain estimate, independent of sampler's minFilter.
  43. let mipBytes = 0, w = width, h = height
  44. for (;;) {
  45. mipBytes += w * h * 4
  46. if (w === 1 && h === 1) break
  47. w = Math.max(1, Math.floor(w / 2)); h = Math.max(1, Math.floor(h / 2))
  48. }
  49. return { width, height, rgbaBytes, mipBytes }
  50. }
  51. export function tileRasterBudget(images, textures, limits = rasterLimits) {
  52. const allocations = images.map(({ bytes, mimeType }) => rasterAllocation(bytes, mimeType, limits))
  53. const rasterBytes = images.reduce((sum, image) => sum + image.bytes.length, 0)
  54. const rgbaBytes = allocations.reduce((sum, image) => sum + image.rgbaBytes, 0)
  55. // One allocation per glTF texture, even if multiple samplers share an image.
  56. const decodedTextureBytes = textures.reduce((sum, texture) => {
  57. assert.ok(Number.isInteger(texture.source) && allocations[texture.source], 'invalid texture image index')
  58. return sum + allocations[texture.source].mipBytes
  59. }, 0)
  60. assert.ok(rasterBytes <= limits.maxRasterBytes, 'embedded raster budget exceeded')
  61. assert.ok(decodedTextureBytes <= limits.maxDecodedTextureBytes, 'decoded texture budget exceeded')
  62. return { rasterBytes, rgbaBytes, decodedTextureBytes,
  63. maxTextureDimension: allocations.reduce((max, image) => Math.max(max, image.width, image.height), 0) }
  64. }