@woosh/meep-engine 3.14.1 → 3.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +31 -24
  2. package/package.json +1 -1
  3. package/src/avif/native/DECISIONS.md +544 -536
  4. package/src/avif/native/heif/parse_avif_file.d.ts.map +1 -1
  5. package/src/avif/native/heif/parse_avif_file.js +384 -383
  6. package/src/avif/native/heif/parse_image_grid.d.ts.map +1 -1
  7. package/src/avif/native/heif/parse_image_grid.js +49 -48
  8. package/src/basis/BasisBitReader.d.ts +77 -0
  9. package/src/basis/BasisBitReader.d.ts.map +1 -0
  10. package/src/basis/BasisBitReader.js +231 -0
  11. package/src/basis/BasisHuffmanTable.d.ts +53 -0
  12. package/src/basis/BasisHuffmanTable.d.ts.map +1 -0
  13. package/src/basis/BasisHuffmanTable.js +263 -0
  14. package/src/basis/basis_read_huffman_table.d.ts +20 -0
  15. package/src/basis/basis_read_huffman_table.d.ts.map +1 -0
  16. package/src/basis/basis_read_huffman_table.js +187 -0
  17. package/src/basis/etc1s/Etc1sSelectorHistory.d.ts +50 -0
  18. package/src/basis/etc1s/Etc1sSelectorHistory.d.ts.map +1 -0
  19. package/src/basis/etc1s/Etc1sSelectorHistory.js +89 -0
  20. package/src/basis/etc1s/etc1s_block_colors.d.ts +14 -0
  21. package/src/basis/etc1s/etc1s_block_colors.d.ts.map +1 -0
  22. package/src/basis/etc1s/etc1s_block_colors.js +75 -0
  23. package/src/basis/etc1s/etc1s_decode_slice.d.ts +57 -0
  24. package/src/basis/etc1s/etc1s_decode_slice.d.ts.map +1 -0
  25. package/src/basis/etc1s/etc1s_decode_slice.js +258 -0
  26. package/src/basis/etc1s/etc1s_read_endpoint_codebook.d.ts +48 -0
  27. package/src/basis/etc1s/etc1s_read_endpoint_codebook.d.ts.map +1 -0
  28. package/src/basis/etc1s/etc1s_read_endpoint_codebook.js +111 -0
  29. package/src/basis/etc1s/etc1s_read_selector_codebook.d.ts +42 -0
  30. package/src/basis/etc1s/etc1s_read_selector_codebook.d.ts.map +1 -0
  31. package/src/basis/etc1s/etc1s_read_selector_codebook.js +127 -0
  32. package/src/basis/etc1s/etc1s_read_slice_tables.d.ts +54 -0
  33. package/src/basis/etc1s/etc1s_read_slice_tables.d.ts.map +1 -0
  34. package/src/basis/etc1s/etc1s_read_slice_tables.js +47 -0
  35. package/src/basis/transcode/build_range_plan.js +54 -0
  36. package/src/basis/transcode/etc1s_to_bc1.d.ts +24 -0
  37. package/src/basis/transcode/etc1s_to_bc1.d.ts.map +1 -0
  38. package/src/basis/transcode/etc1s_to_bc1.js +205 -0
  39. package/src/basis/transcode/etc1s_to_bc7.d.ts +30 -0
  40. package/src/basis/transcode/etc1s_to_bc7.d.ts.map +1 -0
  41. package/src/basis/transcode/etc1s_to_bc7.js +496 -0
  42. package/src/basis/transcode/fit_endpoints.d.ts +59 -0
  43. package/src/basis/transcode/fit_endpoints.d.ts.map +1 -0
  44. package/src/basis/transcode/fit_endpoints.js +226 -0
  45. package/src/{avif/native/isobmff → core/binary}/ByteCursor.d.ts +19 -6
  46. package/src/core/binary/ByteCursor.d.ts.map +1 -0
  47. package/src/{avif/native/isobmff → core/binary}/ByteCursor.js +41 -14
  48. package/src/engine/EngineHarness.js +1 -1
  49. package/src/engine/asset/codec/Codec.d.ts.map +1 -0
  50. package/src/engine/asset/codec/CodecWithFallback.d.ts.map +1 -0
  51. package/src/engine/asset/{loaders/image/codec → codec}/CodecWithFallback.js +2 -2
  52. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +4 -4
  53. package/src/engine/asset/loaders/image/ImageRGBADataLoader.d.ts +1 -1
  54. package/src/engine/asset/loaders/image/ImageRGBADataLoader.d.ts.map +1 -1
  55. package/src/engine/asset/loaders/image/ImageRGBADataLoader.js +1 -1
  56. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.d.ts +1 -1
  57. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.d.ts.map +1 -1
  58. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.js +1 -1
  59. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.d.ts +1 -1
  60. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.d.ts.map +1 -1
  61. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.js +1 -1
  62. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
  63. package/src/engine/graphics/texture/atlas/TextureAtlas.js +13 -0
  64. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  65. package/src/engine/physics/broadphase/generate_pairs.js +7 -1
  66. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  67. package/src/ktx2/KTX2_IDENTIFIER.d.ts +14 -0
  68. package/src/ktx2/KTX2_IDENTIFIER.d.ts.map +1 -0
  69. package/src/ktx2/KTX2_IDENTIFIER.js +15 -0
  70. package/src/ktx2/KhrDfModel.d.ts +35 -0
  71. package/src/ktx2/KhrDfModel.d.ts.map +1 -0
  72. package/src/ktx2/KhrDfModel.js +49 -0
  73. package/src/ktx2/SupercompressionScheme.d.ts +16 -0
  74. package/src/ktx2/SupercompressionScheme.d.ts.map +1 -0
  75. package/src/ktx2/SupercompressionScheme.js +23 -0
  76. package/src/ktx2/ktx2_read.d.ts +71 -0
  77. package/src/ktx2/ktx2_read.d.ts.map +1 -0
  78. package/src/ktx2/ktx2_read.js +581 -0
  79. package/src/ktx2/ktx2_read_basis_lz_global_data.d.ts +27 -0
  80. package/src/ktx2/ktx2_read_basis_lz_global_data.d.ts.map +1 -0
  81. package/src/ktx2/ktx2_read_basis_lz_global_data.js +189 -0
  82. package/src/ktx2/vk_format_to_texture_format.d.ts +55 -0
  83. package/src/ktx2/vk_format_to_texture_format.d.ts.map +1 -0
  84. package/src/ktx2/vk_format_to_texture_format.js +290 -0
  85. package/src/shade/descriptor/GPUTextureUsage.d.ts +23 -0
  86. package/src/shade/descriptor/GPUTextureUsage.d.ts.map +1 -0
  87. package/src/shade/descriptor/GPUTextureUsage.js +24 -0
  88. package/src/shade/descriptor/WebGPUExtensionType.d.ts +3 -0
  89. package/src/shade/descriptor/WebGPUExtensionType.js +26 -0
  90. package/src/shade/descriptor/texture/TextureDescriptor.d.ts +2 -6
  91. package/src/shade/descriptor/texture/TextureDescriptor.d.ts.map +1 -1
  92. package/src/shade/descriptor/texture/TextureDescriptor.js +24 -40
  93. package/src/shade/descriptor/texture/format/TextureFormat.d.ts +125 -0
  94. package/src/shade/descriptor/texture/format/TextureFormat.d.ts.map +1 -0
  95. package/src/shade/descriptor/texture/format/TextureFormat.js +146 -0
  96. package/src/shade/descriptor/texture/format/gpu_texture_format_info.d.ts +60 -0
  97. package/src/shade/descriptor/texture/format/gpu_texture_format_info.d.ts.map +1 -1
  98. package/src/shade/descriptor/texture/format/gpu_texture_format_info.js +102 -0
  99. package/src/shade/device/graph/TextureResourceDescriptor.d.ts.map +1 -1
  100. package/src/shade/device/graph/TextureResourceDescriptor.js +160 -155
  101. package/src/shade/device/mock/SoftwareGPUQueue.d.ts +11 -4
  102. package/src/shade/device/mock/SoftwareGPUQueue.d.ts.map +1 -1
  103. package/src/shade/device/mock/SoftwareGPUQueue.js +31 -37
  104. package/src/shade/device/mock/SoftwareGPUTexture.d.ts.map +1 -1
  105. package/src/shade/device/mock/SoftwareGPUTexture.js +8 -4
  106. package/src/shade/device/timing/profile/frame_graph_extract_topology.d.ts.map +1 -1
  107. package/src/shade/device/timing/profile/frame_graph_extract_topology.js +164 -174
  108. package/src/shade/playground/ground_seam/README.md +235 -0
  109. package/src/shade/playground/ground_seam/capture_scene_color.d.ts +45 -0
  110. package/src/shade/playground/ground_seam/capture_scene_color.d.ts.map +1 -0
  111. package/src/shade/playground/ground_seam/capture_scene_color.js +242 -0
  112. package/src/shade/playground/ground_seam/depth_step_across_seam.d.ts +11 -0
  113. package/src/shade/playground/ground_seam/depth_step_across_seam.d.ts.map +1 -0
  114. package/src/shade/playground/ground_seam/depth_step_across_seam.js +185 -0
  115. package/src/shade/playground/ground_seam/index.html +195 -0
  116. package/src/shade/playground/ground_seam/main.d.ts +2 -0
  117. package/src/shade/playground/ground_seam/main.d.ts.map +1 -0
  118. package/src/shade/playground/ground_seam/main.js +879 -0
  119. package/src/shade/playground/ground_seam/make_grid_texture.d.ts +29 -0
  120. package/src/shade/playground/ground_seam/make_grid_texture.d.ts.map +1 -0
  121. package/src/shade/playground/ground_seam/make_grid_texture.js +105 -0
  122. package/src/shade/playground/ground_seam/make_ground.d.ts +70 -0
  123. package/src/shade/playground/ground_seam/make_ground.d.ts.map +1 -0
  124. package/src/shade/playground/ground_seam/make_ground.js +161 -0
  125. package/src/shade/playground/ground_seam/measure_flicker.d.ts +71 -0
  126. package/src/shade/playground/ground_seam/measure_flicker.d.ts.map +1 -0
  127. package/src/shade/playground/ground_seam/measure_flicker.js +531 -0
  128. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  129. package/src/shade/renderer/Renderer.js +2519 -2511
  130. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  131. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +275 -274
  132. package/src/shade/renderer/light/GPULightCollection.d.ts.map +1 -1
  133. package/src/shade/renderer/light/GPULightCollection.js +323 -314
  134. package/src/shade/renderer/lightmap/GPULightMap.js +336 -336
  135. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  136. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +12 -2
  137. package/src/shade/renderer/material/standard/fragment_gbuffer.d.ts.map +1 -1
  138. package/src/shade/renderer/material/standard/fragment_gbuffer.js +3 -1
  139. package/src/shade/renderer/material/standard/fragment_gbuffer_vt.js +169 -169
  140. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.d.ts.map +1 -1
  141. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.js +3 -1
  142. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  143. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_brick4.d.ts.map +1 -1
  144. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_brick4.js +3 -1
  145. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_ibl.d.ts.map +1 -1
  146. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_ibl.js +3 -1
  147. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.d.ts +28 -0
  148. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.d.ts.map +1 -0
  149. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.js +39 -0
  150. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.d.ts +45 -0
  151. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.d.ts.map +1 -1
  152. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.js +94 -61
  153. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.d.ts +24 -0
  154. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.d.ts.map +1 -0
  155. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.js +43 -0
  156. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +1462 -0
  157. package/src/shade/renderer/texture/TextureManager.d.ts.map +1 -1
  158. package/src/shade/renderer/texture/TextureManager.js +20 -8
  159. package/src/shade/renderer/texture/TextureRole.d.ts +19 -0
  160. package/src/shade/renderer/texture/TextureRole.d.ts.map +1 -0
  161. package/src/shade/renderer/texture/TextureRole.js +26 -0
  162. package/src/shade/renderer/texture/TextureVariantSet.d.ts +71 -0
  163. package/src/shade/renderer/texture/TextureVariantSet.d.ts.map +1 -0
  164. package/src/shade/renderer/texture/TextureVariantSet.js +105 -0
  165. package/src/shade/renderer/texture/download_texture_data.d.ts.map +1 -1
  166. package/src/shade/renderer/texture/download_texture_data.js +22 -0
  167. package/src/shade/renderer/texture/format/gpu_texture_compression_support.d.ts +15 -0
  168. package/src/shade/renderer/texture/format/gpu_texture_compression_support.d.ts.map +1 -0
  169. package/src/shade/renderer/texture/format/gpu_texture_compression_support.js +50 -0
  170. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.d.ts +27 -0
  171. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.d.ts.map +1 -0
  172. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.js +65 -0
  173. package/src/shade/renderer/texture/format/infer_format_from_parameters.d.ts +11 -0
  174. package/src/shade/renderer/texture/format/infer_format_from_parameters.d.ts.map +1 -0
  175. package/src/shade/renderer/texture/format/infer_format_from_parameters.js +88 -0
  176. package/src/shade/renderer/texture/format/texture_format_from_shade_image.d.ts +24 -0
  177. package/src/shade/renderer/texture/format/texture_format_from_shade_image.d.ts.map +1 -0
  178. package/src/shade/renderer/texture/format/texture_format_from_shade_image.js +71 -0
  179. package/src/shade/renderer/texture/format/texture_format_preference.d.ts +22 -0
  180. package/src/shade/renderer/texture/format/texture_format_preference.d.ts.map +1 -0
  181. package/src/shade/renderer/texture/format/texture_format_preference.js +89 -0
  182. package/src/shade/renderer/texture/format/texture_variant_select.d.ts +24 -0
  183. package/src/shade/renderer/texture/format/texture_variant_select.d.ts.map +1 -0
  184. package/src/shade/renderer/texture/format/texture_variant_select.js +38 -0
  185. package/src/shade/renderer/texture/shade_texture_might_have_alpha.d.ts +9 -1
  186. package/src/shade/renderer/texture/shade_texture_might_have_alpha.d.ts.map +1 -1
  187. package/src/shade/renderer/texture/shade_texture_might_have_alpha.js +73 -8
  188. package/src/shade/renderer/texture/source/AlphaMode.d.ts +27 -0
  189. package/src/shade/renderer/texture/source/AlphaMode.d.ts.map +1 -0
  190. package/src/shade/renderer/texture/source/AlphaMode.js +52 -0
  191. package/src/shade/renderer/texture/source/ShadeImage.d.ts +33 -0
  192. package/src/shade/renderer/texture/source/ShadeImage.d.ts.map +1 -1
  193. package/src/shade/renderer/texture/source/ShadeImage.js +74 -0
  194. package/src/shade/renderer/texture/source/ShadeTexelData.d.ts +78 -0
  195. package/src/shade/renderer/texture/source/ShadeTexelData.d.ts.map +1 -0
  196. package/src/shade/renderer/texture/source/ShadeTexelData.js +158 -0
  197. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.d.ts +0 -10
  198. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.d.ts.map +1 -1
  199. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.js +73 -175
  200. package/src/shade/renderer/texture/texture_write_level.d.ts +32 -0
  201. package/src/shade/renderer/texture/texture_write_level.d.ts.map +1 -0
  202. package/src/shade/renderer/texture/texture_write_level.js +77 -0
  203. package/src/shade/renderer/texture/texture_write_to_gpu.d.ts +8 -1
  204. package/src/shade/renderer/texture/texture_write_to_gpu.d.ts.map +1 -1
  205. package/src/shade/renderer/texture/texture_write_to_gpu.js +62 -37
  206. package/src/shade/renderer/texture/virtual/source/VTSourceImage.d.ts +8 -0
  207. package/src/shade/renderer/texture/virtual/source/VTSourceImage.d.ts.map +1 -1
  208. package/src/shade/renderer/texture/virtual/source/VTSourceImage.js +8 -0
  209. package/src/shade/util/texture_mip_extent.d.ts +49 -0
  210. package/src/shade/util/texture_mip_extent.d.ts.map +1 -0
  211. package/src/shade/util/texture_mip_extent.js +87 -0
  212. package/src/avif/native/isobmff/ByteCursor.d.ts.map +0 -1
  213. package/src/engine/asset/loaders/image/codec/Codec.d.ts.map +0 -1
  214. package/src/engine/asset/loaders/image/codec/CodecWithFallback.d.ts.map +0 -1
  215. package/src/shade/util/compute_max_mip_count.d.ts +0 -8
  216. package/src/shade/util/compute_max_mip_count.d.ts.map +0 -1
  217. package/src/shade/util/compute_max_mip_count.js +0 -27
  218. /package/src/engine/asset/{loaders/image/codec → codec}/Codec.d.ts +0 -0
  219. /package/src/engine/asset/{loaders/image/codec → codec}/Codec.js +0 -0
  220. /package/src/engine/asset/{loaders/image/codec → codec}/CodecWithFallback.d.ts +0 -0
@@ -0,0 +1,1462 @@
1
+ # Block-compressed textures — implementation plan
2
+
3
+ **Status:** proposal — revision 5
4
+ **Date:** 2026-09-01
5
+ **Author:** Alex Goldring / Company Named Limited
6
+ **Scope:** `src/shade/renderer/texture/`, `src/shade/descriptor/texture/format/`,
7
+ `src/shade/device/mock/`, `src/engine/asset/`
8
+
9
+ > **Revision 2** rewrites §4 and §5, which had the wrong shape. Revision 1 treated transcoding as a
10
+ > rejected alternative and per-format delivery as a container feature; both were wrong. Transcoding
11
+ > is a **supported path** — KTX2/Basis is what the web's model pipeline emits, and the engine has to
12
+ > read it — and format selection is a **network concern**, not a packaging one, which also reverses
13
+ > revision 1's "do not ship ETC2" (§5.2). §3.4 gains one-level-at-a-time upload, which is what
14
+ > streaming actually requires, and §7 is new: what compressed virtual textures need the rest of the
15
+ > design not to preclude.
16
+ >
17
+ > **Revision 3** fixes §3.2, which was wrong in the way this document spends §0 warning against.
18
+ > Revision 2 had `GenerateMipMaps` mean "generate, unless levels were supplied, in which case
19
+ > quietly do nothing" — an explicit instruction silently reinterpreted from the data. It is now
20
+ > honoured or it throws (§3.2), which also removes revision 2's separate rule about single-level
21
+ > compressed payloads by subsuming it. §2 loses a module that turned out to already exist (§5.3).
22
+ >
23
+ > **Revision 4** adds §2.1. A `TextureFormat` enum is worth having, though not for the reason it was
24
+ > asked for — `GPUTextureFormat` is a string and never throws. The load-time `GPUTextureUsage` read
25
+ > in `TextureDescriptor` does throw, outside vitest, which is where the node-side consumers this
26
+ > plan adds will all be standing.
27
+ >
28
+ > **Revision 5** records an adversarial pass (§11) and applies it. Six findings: "no shader changes"
29
+ > was false (§11.1 — BC5 normals need Z reconstruction at five sites), `ShadeImage.format` cannot be
30
+ > memoised (§11.2), `alpha_mode` needed a third state or it would premultiply every data texture
31
+ > (§11.3), the uploader could not see the flag it was told to honour (§11.4), per-format resolution
32
+ > needs a manifest (§11.5), plus corrections of fact (§11.6). §0's central move survived all six.
33
+
34
+ Assumes the payload is already an `ArrayBuffer` in JS memory. Fetching is not discussed, except in
35
+ §5, where *which* bytes to fetch is the whole point.
36
+
37
+ ---
38
+
39
+ ## 0. The one thing this document is about
40
+
41
+ There is an obvious way to do this and it is wrong: give `ShadeImage` an `is_compressed` flag, add
42
+ `if (compressed)` to the descriptor builder and the uploader, and let the two halves drift for a
43
+ year. The reason that is tempting is that the engine currently decides a texture's **format at
44
+ upload time** by inspecting the pixel data — and compressed data has no pixels to inspect.
45
+
46
+ So the plan is one structural move, and the rest follows from it:
47
+
48
+ > **`ShadeImage` stops describing pixels and starts describing texels.** It carries a
49
+ > `GPUTextureFormat` and a list of mip levels. Uncompressed data is the degenerate case — a
50
+ > 1×1 block — exactly as [`gpu_texture_format_info`](../../descriptor/texture/format/gpu_texture_format_info.js)
51
+ > already models it.
52
+
53
+ The invariant that keeps this honest:
54
+
55
+ > **Below `ShadeImage`, nothing knows whether a texture is compressed.** Every difference is either
56
+ > (a) a value read out of the one format table, or (b) a decision taken *above* `ShadeImage` — in
57
+ > the asset layer, before the image exists.
58
+
59
+ **The axis that legitimately forks is not compressed/uncompressed.** It is *how the bytes reach the
60
+ GPU*, and it forks into three (§4): an external image, host bytes, or a device-side producer. That
61
+ split exists today, has nothing to do with compression, and is where transcoding and — later —
62
+ runtime compression belong.
63
+
64
+ **No shader *variants*.** A `bc7-rgba-unorm` texture binds as `texture_2d<f32>` and samples like
65
+ `rgba8unorm` — no pipeline variants, no codegen, nothing in [wgsl](../../wgsl) that branches on
66
+ format. One shared decode change is needed, and only because two-channel normal formats exist:
67
+ Z reconstruction, applied identically to compressed and uncompressed sources (§11.1).
68
+
69
+ ---
70
+
71
+ ## 1. What is there now
72
+
73
+ Five places encode "a texture's bytes are `channel_count` samples of `data_type` per texel".
74
+
75
+ | Place | Assumption |
76
+ |---|---|
77
+ | [`ShadeImage`](source/ShadeImage.js) | pixels are `(data_type, channel_count, normalized)` plus a `color_space`. No format, no mip levels. |
78
+ | [`inferFormatFromImage`](texture_descriptor_from_texture_shade.js:99) | the format is *derived at upload* from those four fields, by string-building `${prefix}${bit_size}${ending}` |
79
+ | [`texture_descriptor_from_texture_shade`](texture_descriptor_from_texture_shade.js:149) | `GenerateMipMaps` implies `RENDER_ATTACHMENT` **and** a full generated chain. Both are illegal for a block format. |
80
+ | [`texture_write_raw`](texture_write_to_gpu.js:93) | one `writeTexture` hard-coded to `mipLevel: 0`, with `bytesPerRow = channels × bytes × width`; plus RGB→RGBA padding and CPU premultiplication, neither of which can be done to a block |
81
+ | [`TextureDescriptor.bits_per_sample`](../../descriptor/texture/TextureDescriptor.js:100) | a per-channel bit table. Every block format misses it, so `memory_footprint` warns and reports **0 bytes**. |
82
+
83
+ Second order, all of them one-liners once the above is fixed: the resident material atlas
84
+ ([`GPUResidentMaterialContext.js:338`](../material/resident/GPUResidentMaterialContext.js:338)), the
85
+ software device ([`SoftwareGPUQueue.js:220`](../../device/mock/SoftwareGPUQueue.js:220)), the
86
+ bindless allocator ([`GPUBindlessTextureManager.js:114`](bindless/GPUBindlessTextureManager.js:114)),
87
+ [`shade_texture_might_have_alpha`](shade_texture_might_have_alpha.js), and the VT cache's
88
+ `bytes_per_row ?? this.slot_size * 4` ([`VTPhysicalCache.js:324`](virtual/VTPhysicalCache.js:324)).
89
+
90
+ **What is already right, and is the reason this is cheap:**
91
+
92
+ - [`gpu_texture_format_info`](../../descriptor/texture/format/gpu_texture_format_info.js) already
93
+ tabulates every BC, ETC2/EAC and ASTC format with correct block dimensions and byte sizes, and
94
+ already exposes `gpu_texture_format_row_bytes` / `gpu_texture_format_row_count`, which round up to
95
+ whole blocks. This is the table the whole plan runs on. It has three consumers today.
96
+ - [`texture_copy.js`](../../device/mock/texture_copy.js:221) in the software device is already
97
+ block-aware, and the mock already implements `copyBufferToTexture`
98
+ ([`SoftwareGPUCommandEncoder.js:329`](../../device/mock/SoftwareGPUCommandEncoder.js:329)) — which
99
+ no engine code uses yet, and which the GPU transcode path (§4.4) will be the first to want.
100
+ - [`VTPhysicalCache`](virtual/VTPhysicalCache.js:69) is **already parameterised by a per-layer
101
+ `GPUTextureFormat`**. §7 is smaller than it looks.
102
+ - [`SamplerDescriptor`](../../descriptor/texture/SamplerDescriptor.js:17) already carries
103
+ `lodMinClamp`, which is the mechanism mip streaming needs (§3.4).
104
+ - [`gpu_texture_format_srg_to_linear`](format/gpu_texture_format_srg_to_linear.js) and
105
+ [`gpu_texture_format_to_color_space`](format/gpu_texture_format_to_color_space.js) already
106
+ enumerate every BC, ETC2 and ASTC srgb pair — written compressed-aware, never yet handed a
107
+ compressed format (§5.3).
108
+
109
+ ---
110
+
111
+ ## 2. Modules
112
+
113
+ ```
114
+ shade/renderer/texture/source/
115
+ ShadeTexelData.js NEW format + size + mip levels. The compressed payload,
116
+ and the general case of the uncompressed one.
117
+ ShadeImage.js CHG gains .format, .level_count, .alpha_mode
118
+ AlphaMode.js NEW @enum Opaque | Straight | Premultiplied (§11.3)
119
+
120
+ shade/renderer/texture/format/
121
+ gpu_texture_format_is_block_compressed.js NEW one-liner over gpu_texture_format_info
122
+ gpu_texture_format_can_generate_mipmaps.js NEW can the generator render into it (§3.2)
123
+ gpu_texture_format_srg_to_linear.js CHG gains its inverse; already knows BC/ETC2/ASTC
124
+ gpu_texture_compression_support.js NEW device -> Set<GPUTextureFormat>
125
+ texture_format_preference.js NEW (role, support) -> ordered format list
126
+
127
+ shade/renderer/texture/
128
+ texture_write_level.js NEW one level, block-driven. The primitive.
129
+ texture_write_to_gpu.js CHG dispatch on source kind; loops levels
130
+ texture_descriptor_from_texture_shade.js CHG reads image.format; stops inventing one
131
+ shade_texture_might_have_alpha.js CHG answers from the format
132
+
133
+ shade/descriptor/texture/
134
+ TextureDescriptor.js CHG memory_footprint via gpu_texture_format_info
135
+
136
+ shade/device/mock/
137
+ SoftwareGPUQueue.js CHG block-aware writeTexture
138
+ ```
139
+
140
+ Container readers (KTX2, DDS, whatever ships next) are **not** engine code in this list: each is a
141
+ free function `bytes -> ShadeTexelData` living with its container, the way
142
+ [`GltfBufferViewExtension`](../loader/gltf/ext/GltfBufferViewExtension.js) keeps meshopt out of the
143
+ glTF parser. The engine names no container. Transcoders (§4) are likewise their own tree.
144
+
145
+ ### 2.1 `TextureFormat` — and the load-time globals underneath it
146
+
147
+ ```
148
+ shade/descriptor/texture/format/
149
+ TextureFormat.js NEW @enum {string} — every GPUTextureFormat, by name
150
+ shade/descriptor/
151
+ GPUTextureUsage.js NEW the usage bits, beside the existing GPUShaderStage.js
152
+ ```
153
+
154
+ The precise state of this, because the failure is not quite where it looks:
155
+
156
+ **`GPUTextureFormat` never throws.** It is a WebIDL enum, which in JavaScript is a plain string — no
157
+ global is read, and `gpu_texture_format_info("bc7-rgba-unorm")` runs in node today. A grep confirms
158
+ it: the identifier appears in this tree only inside JSDoc and inside one error message. So a
159
+ `TextureFormat` enum is not what keeps node running.
160
+
161
+ **What it does buy is worth having anyway**, and the third item is a real hole:
162
+
163
+ 1. One enumeration instead of a convention. The names already exist as the keys of `FORMATS` in
164
+ [`gpu_texture_format_info.js`](../../descriptor/texture/format/gpu_texture_format_info.js), which
165
+ even exports `gpu_texture_format_names()` for tests to sweep. Promoting that key set to a named
166
+ enum makes it the source rather than an implementation detail of one table.
167
+ 2. A typo becomes `undefined` at the call site instead of `'rgba8'` reaching
168
+ `gpu_texture_format_info` and throwing one layer down — which is a thing that has already been
169
+ worth writing a test for ([`SoftwareGPUValidation.spec.js:95`](../../device/mock/SoftwareGPUValidation.spec.js:95)).
170
+ 3. **`@webgpu/types` is not a dependency of this package.** Every `@param {GPUTextureFormat}` in the
171
+ tree therefore resolves to nothing, and `npm run generate-types` emits `any` for all of them. An
172
+ in-tree `@enum {string}` resolves without taking that dependency, which is the cheaper of the two
173
+ fixes and the one that does not put a browser typings package in a node tool's path.
174
+
175
+ **The enum must be a proxy, not a parallel identifier space.** Its values are the WebGPU strings
176
+ verbatim, so `TextureFormat.BC7_RGBA_UNORM` is passable straight to `createTexture` and no
177
+ translation exists at any boundary — the same discipline
178
+ [`WebGPUExtensionType`](../WebGPUExtensionType.js) already keeps (`Subgroups: "subgroups"`). A spec
179
+ asserting `Object.values(TextureFormat)` and `gpu_texture_format_names()` are the same set is what
180
+ stops the two drifting (§10.1).
181
+
182
+ **Keys are `SCREAMING_SNAKE_CASE`** — `BC7_RGBA_UNORM`, `ASTC_6X6_UNORM_SRGB`. Settled, so it is not
183
+ re-argued at review: this enum mirrors a specification's namespace rather than naming an engine
184
+ concept, which is the case [`GPUShaderStage`](../GPUShaderStage.js) already answers the same way,
185
+ and `Bc7RgbaUnorm` reads worse than the name every specification and tool writes.
186
+ [`WebGPUExtensionType`](../WebGPUExtensionType.js)'s PascalCase is the deliberate exception, not the
187
+ rule this follows — its keys are engine-facing labels for capabilities, not format identifiers.
188
+
189
+ **Now the thing that actually throws in node.** [`TextureDescriptor.js:61`](../../descriptor/texture/TextureDescriptor.js:61)
190
+ reads `GPUTextureUsage.TEXTURE_BINDING` as a **class field initializer** — at module load, not at
191
+ call time. Under vitest that is covered, by a global stubbed in
192
+ [`vitest.setup.mjs`](../../vitest.setup.mjs). Outside vitest nothing stubs it, so `import`ing
193
+ `TextureDescriptor` from a CLI, an asset-pipeline script, or the offline encoder of §8 throws
194
+ `ReferenceError` before a line of it runs.
195
+
196
+ That matters *because of this plan*: §4.3's CPU transcoder, the container readers, and the offline
197
+ encoder are all node-side consumers of the texture stack, and none of them is a test. The existing
198
+ in-tree precedent is [`descriptor/GPUShaderStage.js`](../GPUShaderStage.js) — "useful for when WebGPU
199
+ constants are not available" — which does exactly this for shader stages and was never extended to
200
+ the other three namespaces. Add `GPUTextureUsage.js` beside it and import it rather than reading the
201
+ global.
202
+
203
+ The vitest stub stays regardless: it covers modules this plan does not touch, and a shim that is
204
+ imported and a global that is stubbed do not conflict.
205
+
206
+ ---
207
+
208
+ ## 3. The design
209
+
210
+ ### 3.1 `ShadeTexelData` — the payload, and `ShadeImage.format` — the seam
211
+
212
+ ```js
213
+ export class ShadeTexelData {
214
+ /** @type {GPUTextureFormat} */
215
+ format;
216
+
217
+ /** texels, of the base level */
218
+ width; height; depth;
219
+
220
+ /**
221
+ * One entry per mip, index 0 = base. Rows of blocks, tightly packed, no padding
222
+ * between rows or levels — exactly what gpu_texture_format_row_bytes describes.
223
+ * A hole is a level that has not arrived yet (§3.4).
224
+ * @type {(Uint8Array|undefined)[]}
225
+ */
226
+ levels;
227
+ }
228
+ ```
229
+
230
+ `ShadeImage.source` may now also be a `ShadeTexelData`, alongside the existing
231
+ `ImageBitmap | Sampler2D | ArrayBuffer`. Two new members on `ShadeImage`, and one free function:
232
+
233
+ ```js
234
+ get level_count() // 1 for everything that exists today
235
+ get alpha_mode() // §3.3
236
+
237
+ texture_format_from_shade_image(image) -> GPUTextureFormat // format/, not a member
238
+ ```
239
+
240
+ **The format is a function, not a getter.** `ShadeImage` describes data — a channel count, a data
241
+ type, a colour space, some bytes — and what WebGPU calls that arrangement is not the data's business.
242
+ Keeping it off the class is what lets the encoder, the container readers and the asset pipeline hold
243
+ an image without any of them importing a graphics API, and it is the same rule the colour package
244
+ already follows: free functions over the model, not methods on it.
245
+
246
+ For a `ShadeTexelData`, the answer is the payload's. For everything else it is exactly what
247
+ [`inferFormatFromImage`](texture_descriptor_from_texture_shade.js:99) computes today — both of its
248
+ arms, the `ImageBitmap` one included — [moved from the descriptor builder to the
249
+ image](texture_descriptor_from_texture_shade.js:12). That move is the whole trick: format stops being
250
+ something the upload path *guesses from the data* and becomes something the image *states*, and a
251
+ compressed image states it the same way an uncompressed one does.
252
+
253
+ **A plain getter, not memoised.** `color_space` is mutated after construction — see §11.2, where
254
+ caching it silently strips sRGB from every glTF albedo map.
255
+
256
+ [`texture_descriptor_from_texture_shade`](texture_descriptor_from_texture_shade.js) then loses
257
+ `inferFormatFromImage` entirely and reads `image.format`. It is the only caller.
258
+
259
+ Keeping `ShadeTexelData` as an object in `source` rather than as fields on `ShadeImage` is
260
+ deliberate: [`optimize_texture_set`](../scene/optimization/optimize_texture_set.js:15) and
261
+ [`serialize_scene`](../scene/serialization/serialize_scene.js:321) key maps on `image.source` by
262
+ identity to deduplicate. A payload is one object, so both keep working untouched.
263
+
264
+ ### 3.2 Mip levels are data; `GenerateMipMaps` is an instruction
265
+
266
+ Two independent facts, currently welded together.
267
+
268
+ **Where a texture's mip levels come from** is a property of the image: it has `level_count` of them,
269
+ and that is how many the texture gets. Nothing infers, nothing fills in.
270
+
271
+ **Whether to run the mip generator** is an instruction the caller gives, and
272
+ `ShadeTextureFlags.GenerateMipMaps` is that instruction. It is honoured or it throws:
273
+
274
+ ```js
275
+ // texture_descriptor_from_texture_shade
276
+ const generate = (shade.flags & ShadeTextureFlags.GenerateMipMaps) !== 0;
277
+
278
+ let mipLevelCount;
279
+
280
+ if (generate) {
281
+ if (!gpu_texture_format_can_generate_mipmaps(format)) {
282
+ throw new Error(
283
+ `${shade.label}: mipmap generation requested for '${format}', which cannot be a render`
284
+ + ` attachment. Mips for this format come from the encoder — supply them in the payload`
285
+ + ` and clear ShadeTextureFlags.GenerateMipMaps.`
286
+ );
287
+ }
288
+
289
+ mipLevelCount = compute_max_mip_count(image.width, image.height);
290
+ usage |= GPUTextureUsage.RENDER_ATTACHMENT;
291
+ } else {
292
+ mipLevelCount = image.level_count;
293
+ }
294
+ ```
295
+
296
+ and [`TextureManager.contextFromShadeTexture`](TextureManager.js:128) schedules the generator if and
297
+ only if `generate` — not if some level count comparison suggests it might be useful.
298
+
299
+ Three consequences, and none of them is a fallback:
300
+
301
+ - **The flag always means what it says.** Set it on an image that already has a chain and the chain
302
+ is regenerated; the supplied levels above 0 are not uploaded, because you asked for generated ones
303
+ instead. That is a real use — a DDS or KTX2 whose mips were built with a filter you do not want,
304
+ re-derived through [`mipmaps/filters/`](mipmaps/filters) — and not merely a pedantic reading.
305
+ - **The flag on a block format is an error, always**, whatever the level count. Nothing on the device
306
+ turns a BC7 base into BC7 mips (that is §4.5, and it is not built). The message names the format
307
+ and says what to do instead.
308
+ - **An uncompressed image carrying its own mips works**, which the engine cannot do today, by simply
309
+ not setting the flag.
310
+
311
+ `gpu_texture_format_can_generate_mipmaps` is the honesty predicate: the generator renders between mip
312
+ levels, so the format must be usable as a colour attachment. Block-compressed is the case this plan
313
+ adds; `rgb9e5ufloat` and the `snorm` formats are non-renderable *today* and would already fail deeper
314
+ in [`WebGPUMipmapGenerator`](mipmaps/WebGPUMipmapGenerator.js) with a worse message. One predicate
315
+ over the format table closes both.
316
+
317
+ **On `DEFAULT_FLAGS`.** [`ShadeTexture`](ShadeTexture.js:8) defaults the flag *on*, so a loader
318
+ building a compressed texture must clear it, and forgetting to is a hard error naming the texture.
319
+ The tempting alternative — have `ShadeTexture.from` derive the default from the image, the way it
320
+ already derives `dimensions` — is rejected for the same reason revision 2's §3.2 was: it makes
321
+ behaviour depend on data. An uncompressed asset that ships with mips would silently stop generating
322
+ them, and re-exporting it without mips would silently start again. A two-line `clearFlag` at each
323
+ compressed loader is the cheaper price.
324
+
325
+ **Rule, unchanged:** `level_count` is either `1` or the full chain for the base size. A partial chain
326
+ is a malformed payload, rejected in `ShadeTexelData`, which keeps
327
+ [`WebGPUMipmapGenerator`](mipmaps/WebGPUMipmapGenerator.js) unchanged — it is never asked to start
328
+ from a level other than 0. Not to be confused with a chain whose *levels have not all arrived yet*,
329
+ which is §3.4 and is fine.
330
+
331
+ A single-level block-compressed payload is **not** an error. A LUT, a UI sprite, a 4×4 icon
332
+ legitimately has one level, and rejecting content because it might alias somewhere is the engine
333
+ overruling the asset pipeline about a decision that is not its own. Whether a wall texture should
334
+ have shipped with mips is a validator's question.
335
+
336
+ ### 3.3 Alpha mode is declared, not inferred
337
+
338
+ [`texture_write_to_gpu:217`](texture_write_to_gpu.js:217) decides to premultiply from
339
+ `image.color_space !== ColorSpace.None`. That is a proxy for "this is colour, so it probably has
340
+ straight alpha", and it is already wrong in one direction — an `ArrayBuffer` image that arrives
341
+ already premultiplied gets premultiplied twice. For a block payload it is unfixable: you cannot
342
+ premultiply BC7 without decoding it.
343
+
344
+ Make it a property of the data:
345
+
346
+ ```js
347
+ export const AlphaMode = {
348
+ Opaque: 0, // no meaningful alpha — data. Never premultiply.
349
+ Straight: 1, // colour with straight alpha
350
+ Premultiplied: 2, // already done, by the encoder or by copyExternalImageToTexture
351
+ };
352
+ ```
353
+
354
+ Three states, not two: premultiplication is not a property of the alpha channel but of whether that
355
+ channel *means* opacity, and collapsing `Opaque` into `Straight` premultiplies every height field and
356
+ ORM map on upload (§11.3). `ShadeImage.alpha_mode` defaults from `color_space` where the image is
357
+ built — `ColorSpace.None` to `Opaque`, otherwise `Straight` — which is today's rule, preserved. At
358
+ upload:
359
+
360
+ | declared | format | action |
361
+ |---|---|---|
362
+ | `Opaque` | either | nothing. Today's `color_space === None` behaviour. |
363
+ | `Straight` | uncompressed | premultiply on the CPU — today's code, unchanged |
364
+ | `Straight` | block | **throw.** The producer had to do this; nothing here can. |
365
+ | `Premultiplied` | either | nothing |
366
+
367
+ `ImageBitmap` sources keep using `copyExternalImageToTexture`'s `premultipliedAlpha`, which is the
368
+ same decision expressed through the API that owns it.
369
+
370
+ This is the one place the compressed path imposes a requirement on the *producer* rather than on the
371
+ engine, and it is better stated loudly than discovered as dark fringes on foliage.
372
+
373
+ ### 3.4 One level at a time — the primitive, and what streaming needs
374
+
375
+ [`texture_write_raw`](texture_write_to_gpu.js:93) computes its own layout from
376
+ `channel_count × dataTypeByteSize(data_type)` and writes exactly one thing, at `mipLevel: 0`. Both
377
+ have to go, and the replacement is not "a loop over levels" but **a function that writes one level**,
378
+ with the loop as its caller:
379
+
380
+ `texture_write_to_gpu` gains a level range alongside it — without one it cannot honour §3.2's rule
381
+ that generated mips replace supplied ones, because the flag lives on the `ShadeTexture` and the
382
+ uploader never receives it (§11.4). The same parameter is what lets a streaming arrival write exactly
383
+ one level, so it is one addition serving both.
384
+
385
+ ```js
386
+ // texture_write_level.js — the primitive
387
+ export function texture_write_level(queue, texture, format, level, bytes, size) {
388
+ const w = Math.max(size[0] >> level, 1);
389
+ const h = Math.max(size[1] >> level, 1);
390
+ const d = is_3d ? Math.max(size[2] >> level, 1) : size[2];
391
+
392
+ queue.writeTexture(
393
+ { texture, mipLevel: level, origin: { x: 0, y: 0, z: 0 } },
394
+ bytes,
395
+ {
396
+ offset: 0,
397
+ bytesPerRow: gpu_texture_format_row_bytes(format, w),
398
+ rowsPerImage: gpu_texture_format_row_count(format, h),
399
+ },
400
+ [w, h, d]
401
+ );
402
+ }
403
+ ```
404
+
405
+ The level, not the image, is the unit — because **meep streams**. A texture is not a thing that
406
+ arrives; it is a thing that keeps arriving. The descriptor is built from a header (format, base size,
407
+ level count) before any texel bytes exist, the texture is allocated once at full size, and levels
408
+ land as the network delivers them, coarsest first.
409
+
410
+ Two mechanics make that work, and both already exist:
411
+
412
+ - **Sampling a partly-resident texture.** Unwritten mips read as zero, so a texture with only its
413
+ coarse levels present must not be sampled at its fine ones. Clamp with
414
+ [`SamplerDescriptor.lodMinClamp`](../../descriptor/texture/SamplerDescriptor.js:17), lowered as
415
+ levels arrive. This keeps the texture's dimensions and UV mapping correct, which a narrowed
416
+ `TextureViewDescriptor` would not — a view starting at `baseMipLevel: 8` makes the texture claim to
417
+ be 1/256 the size and puts every LOD computation downstream out by eight.
418
+ - **Level-tail ordering.** The coarse levels of a mip chain are a rounding error in bytes — level 0
419
+ is 3/4 of the pyramid — so "everything but level 0" is a cheap first request that makes a texture
420
+ usable, and level 0 is a second one. Block formats make that tail *exact*: the 1×1 mip of a BC7
421
+ texture is one 16-byte block, which `gpu_texture_format_row_bytes` already rounds up to correctly
422
+ and a hand-rolled `width * bytes_per_texel` does not.
423
+
424
+ Two properties of `writeTexture` worth recording, because the GPU transcode path (§4.4) has neither:
425
+
426
+ - **No `bytesPerRow` alignment requirement.** That is `copyBufferToTexture`'s rule (256 bytes), not
427
+ `writeTexture`'s. Tightly-packed levels upload directly, with no staging buffer and no padding.
428
+ This is what makes "the bytes are already in JS memory" the easy case it should be.
429
+ - **`rowsPerImage` is now always supplied**; today it is set only when `depth > 1`
430
+ ([:165](texture_write_to_gpu.js:165)). Harmless for 2D, required for arrays.
431
+
432
+ The RGB→RGBA padding and the premultiply pass stop being branches *inside* the writer and become a
433
+ transform producing a payload *before* it.
434
+
435
+ ### 3.5 Capability is the engine's; selection is the asset layer's
436
+
437
+ Add to [`WebGPUExtensionType`](../../descriptor/WebGPUExtensionType.js):
438
+
439
+ ```js
440
+ TextureCompressionBC: "texture-compression-bc",
441
+ TextureCompressionETC2: "texture-compression-etc2",
442
+ TextureCompressionASTC: "texture-compression-astc",
443
+ ```
444
+
445
+ and to `optional_desired` in [`Renderer.js:1165`](../Renderer.js:1165) — all three, taken when
446
+ offered, exactly as `TextureFormatsTier1` already is. None is required.
447
+
448
+ The engine then exposes two things and stops:
449
+
450
+ ```js
451
+ // what this device can sample
452
+ gpu_texture_compression_support(device) -> Set<GPUTextureFormat>
453
+
454
+ // what this engine would prefer for this kind of content, best first,
455
+ // filtered to what the device has
456
+ texture_format_preference(role, support) -> GPUTextureFormat[]
457
+ ```
458
+
459
+ `role` is albedo / normal / orm / emissive — the same distinction
460
+ [`image_compression_quality_from_usage`](../scene/serialization/serialize_scene.js:168) already
461
+ draws. The renderer owns this because the renderer is what knows BC5 is right for a normal map and
462
+ BC1 is not.
463
+
464
+ **It does not own what happens next.** The asset layer takes that ordered list, resolves the first
465
+ entry to a concrete resource, and fetches only that one (§5). The engine never sees the formats it
466
+ did not ask for.
467
+
468
+ ---
469
+
470
+ ## 4. Three ways bytes reach a texture
471
+
472
+ ### 4.1 The axis that actually forks
473
+
474
+ `texture_write_to_gpu` already dispatches on *source kind*: an `ImageBitmap` goes through
475
+ `copyExternalImageToTexture`, a `Sampler2D` or `ArrayBuffer` through `writeTexture`. So does
476
+ [`VTPhysicalCache.upload`](virtual/VTPhysicalCache.js:306), independently, in the same shape.
477
+
478
+ Adding compression does not add a fourth kind. It adds **texels the host cannot look inside**, which
479
+ the `writeTexture` arm handles by asking the format table for the layout instead of computing it.
480
+ What *does* add a third kind is a producer that runs on the device:
481
+
482
+ | kind | mechanism | why it exists |
483
+ |---|---|---|
484
+ | external image | `copyExternalImageToTexture` | the browser decoded it; never compressed |
485
+ | host bytes | `writeTexture` | the common case, compressed or not |
486
+ | device-side producer | compute pass → `copyBufferToTexture` | GPU transcode (§4.4), and later runtime compression (§4.5) |
487
+
488
+ The third row is not a compressed-texture feature. Procedural and GPU-generated content wants the
489
+ same shape, and the mock device already implements the API for it
490
+ ([`SoftwareGPUCommandEncoder.js:329`](../../device/mock/SoftwareGPUCommandEncoder.js:329)) while no
491
+ engine code uses it yet. Naming the three explicitly is worth doing regardless of this plan; doing it
492
+ here is what keeps the transcoder from becoming a private path inside `TextureManager`.
493
+
494
+ ### 4.2 Transcoding is supported, and is not the main path
495
+
496
+ KTX2 with Basis Universal (ETC1S or UASTC) is what the web's model pipeline emits.
497
+ `KHR_texture_basisu` is a ratified glTF extension, `gltf-transform` and `toktx` produce it by
498
+ default, and a viewer that cannot read it cannot read the ecosystem's models. Support it.
499
+
500
+ But it is a **compatibility path, not the delivery path**. Transcoding pays CPU or GPU time on load
501
+ to recover a format the device could have been handed directly, and meep's own pipeline hands it
502
+ directly (§5). The distinction to hold on to:
503
+
504
+ - **Third-party content** — a glTF someone exported, a model dropped on the canvas — arrives as
505
+ KTX2/Basis and is transcoded.
506
+ - **First-party content** — anything meep's own tools produced — arrives already in a device format
507
+ and is never transcoded.
508
+
509
+ Both produce a compressed texture, and below that they are the same texture.
510
+
511
+ ### 4.3 The CPU transcoder
512
+
513
+ Signature, and the whole of its contract:
514
+
515
+ ```js
516
+ // bytes in, texels out. No device, no queue, no engine.
517
+ transcode_basis(payload, target_format) -> ShadeTexelData
518
+ ```
519
+
520
+ That is the §3.1 seam exactly, so a CPU-transcoded texture enters through the ordinary door: a
521
+ `ShadeImage` over a `ShadeTexelData`, uploaded by `texture_write_level` with no knowledge that a
522
+ transcoder was involved. It is worker-safe by construction — it imports nothing outside `core/` —
523
+ which is where it should run.
524
+
525
+ ETC1S→BC1/BC7 and UASTC→BC7/ASTC are block-to-block transforms with no cross-block dependencies, and
526
+ the Basis format specification is public. This repo's convention — meshopt, AVIF — is to implement
527
+ from a specification rather than vendor a wasm blob. That is a real cost, and it should be paid
528
+ deliberately, which is why §9 sequences it last.
529
+
530
+ ### 4.4 The GPU transcoder
531
+
532
+ The same transform, per block, with no dependencies between blocks — which is a compute shader with
533
+ one invocation per output block. It cannot write its result as a storage texture, because block
534
+ formats are not storage-bindable, so it writes a buffer and copies:
535
+
536
+ ```
537
+ source blocks (storage buffer, uploaded once)
538
+ -> compute pass, one invocation per output block
539
+ -> output buffer
540
+ -> copyBufferToTexture, one call per mip level
541
+ -> the ordinary compressed texture
542
+ ```
543
+
544
+ Three constraints shape the output buffer's layout, and are worth writing down now rather than
545
+ discovering at the copy:
546
+
547
+ 1. **`copyBufferToTexture` requires `bytesPerRow % 256 === 0`.** `writeTexture` does not (§3.4). For
548
+ BC7 a row is `ceil(w / 4) × 16` bytes, so alignment needs `ceil(w / 4)` to be a multiple of 16 —
549
+ which for the power-of-two widths of a mip chain means `w >= 64`, and every level below that needs
550
+ its rows padded. Lay the buffer out padded from the start.
551
+ 2. **Origin and extent must be block multiples**, as everywhere else. Trivially satisfied for a
552
+ whole-level copy, since the physical extent of a mip is its size rounded up to whole blocks.
553
+ 3. **One `copyBufferToTexture` per level**, since each level has its own row padding. They share one
554
+ encoder and one buffer.
555
+
556
+ The output of this path is a `GPUTexture`, not a `ShadeTexelData` — it never touches host memory,
557
+ which is the point. So it enters as §4.1's third kind, not through `ShadeImage`. That is a genuine
558
+ second route into a texture, and it is justified by *where the bytes are*, not by whether they are
559
+ compressed: an uncompressed procedural texture takes the same route.
560
+
561
+ ### 4.5 Runtime compression, later
562
+
563
+ Not being built now. Recorded because the seam either admits it or does not, and it costs nothing to
564
+ make sure it does.
565
+
566
+ Compressing at runtime — an ordinary decoded image in, block data out — is the same shape as §4.4
567
+ with a different shader: a compute pass over 4×4 texel neighbourhoods, an output buffer of blocks, a
568
+ `copyBufferToTexture`. Everything in §4.4's numbered list applies unchanged. Fixed-rate formats make
569
+ this tractable in a way variable-rate ones never are: the output size is known before the shader
570
+ runs, so the buffer is allocated up front and every invocation knows exactly where to write.
571
+
572
+ The thing that would preclude it is treating "compressed" as a property of *how a texture was
573
+ loaded*. It is a property of the texture's format, and a texture compressed on the device five
574
+ seconds ago is indistinguishable from one that arrived that way. §3.1 is what buys that.
575
+
576
+ ---
577
+
578
+ ## 5. Delivery: one format per resource, chosen at request time
579
+
580
+ ### 5.1 The philosophy point
581
+
582
+ meep streams. It is not a packaged engine that loads a level file and starts; it fetches what it
583
+ needs, when it needs it, at the granularity it needs. The SHADE scene container
584
+ ([`serialize_scene.js`](../scene/serialization/serialize_scene.js)) is one delivery mechanism among
585
+ several — useful, and not the model to design around.
586
+
587
+ So: **a texture is not one resource with variants inside it. It is several resources, one per format,
588
+ and the client fetches the one it wants.** The engine's part is §3.5's ordered preference list; the
589
+ asset layer's part is turning the first entry into a URL.
590
+
591
+ ```js
592
+ const formats = texture_format_preference(role, support); // e.g. [bc7, astc-4x4]
593
+
594
+ for (const format of formats) {
595
+ const description = resolve(logical_name, format); // may not exist
596
+ if (description !== undefined) {
597
+ return load(description);
598
+ }
599
+ }
600
+
601
+ return load(resolve(logical_name, undefined)); // raw — AVIF, PNG, whatever ships
602
+ ```
603
+
604
+ `resolve` must answer from a **manifest known before the first request** — which formats were built
605
+ for this texture — or a device whose preferred format was never built pays a 404 round-trip per
606
+ preference-list entry before reaching the fallback (§11.5). That list belongs beside whatever already
607
+ names the texture.
608
+
609
+ [`AssetManager.assignAlias`](../../../engine/asset/AssetManager.js:1396) is the mechanism that
610
+ already exists for exactly this indirection — a logical name resolving to a concrete
611
+ `AssetDescription`. Making that resolution format-aware, rather than inventing a parallel one, is the
612
+ smaller change and the one that lets a texture be requested by name from anywhere.
613
+
614
+ The uncompressed resource is not a fallback bolted on: it is the entry with no format constraint, and
615
+ it is what a device with no compression support, a tool, or a test asks for.
616
+
617
+ ### 5.2 Which formats to build — and why revision 1 had this backwards
618
+
619
+ Revision 1 argued against shipping ETC2 because "every variant added is bytes every user downloads".
620
+ Under per-format delivery that is simply false: **a format nobody requests costs origin storage and
621
+ build time, and zero bytes of anyone's download.** The calculus is entirely different, and the answer
622
+ becomes "build whatever the audience's devices can sample":
623
+
624
+ | Content | Format | Note |
625
+ |---|---|---|
626
+ | albedo, emissive | `bc7-rgba-unorm` | desktop. Best quality-per-byte for colour with alpha. |
627
+ | normal maps | `bc5-rg-unorm` | two channels, **and Z reconstruction in the shader** — the one shader change this plan carries, §11.1 |
628
+ | ORM, single-channel masks | `bc4-r-unorm` / `bc7-rgba-unorm` | linear |
629
+ | anything, mobile / Apple | `astc-4x4-unorm`, coarser blocks per role | see §7.2 for why 4×4 specifically |
630
+ | ETC2 | `etc2-rgba8unorm` | build it if the audience has it — it costs everyone else nothing |
631
+
632
+ ### 5.3 sRGB is not a separate resource
633
+
634
+ `bc7-rgba-unorm` and `bc7-rgba-unorm-srgb` are **the same encoded bytes**; the suffix changes how the
635
+ sampler decodes them, not what is stored. The same holds for every BC/ETC2/ASTC pair.
636
+
637
+ So the delivery key is the non-srgb name, and the `-srgb` variant is chosen when the texture is
638
+ created, from the image's `color_space` — which `ShadeImage` already carries and
639
+ [`gltf_create_material.js:54`](../loader/gltf/gltf_create_material.js:54) already sets. This halves
640
+ the artifact count for free, and keeps the sRGB decision where it belongs: with the content role, not
641
+ with the bytes.
642
+
643
+ The mapping that does it already exists.
644
+ [`gpu_texture_format_srg_to_linear`](format/gpu_texture_format_srg_to_linear.js) enumerates every
645
+ BC/ETC2/ASTC srgb pair, and so does
646
+ [`gpu_texture_format_to_color_space`](format/gpu_texture_format_to_color_space.js) — both were
647
+ written compressed-aware and neither has ever been handed a compressed format. What is missing is
648
+ only the inverse direction, in the file that already holds the forward one.
649
+
650
+ ---
651
+
652
+ ## 6. Everything that breaks, and what it becomes
653
+
654
+ ### 6.1 Memory accounting reports zero
655
+
656
+ [`TextureDescriptor.bits_per_sample`](../../descriptor/texture/TextureDescriptor.js:100) misses every
657
+ block format, so `memory_footprint` warns and returns 0 — a compressed scene would report an empty
658
+ texture budget through [`TextureManager.gpu_memory_usage`](TextureManager.js:172) and through the
659
+ transient pool's `sizeOf` in [`GPUTextureAllocator`](GPUTextureAllocator.js).
660
+
661
+ Rewrite `memory_footprint` on `gpu_texture_format_info`:
662
+
663
+ ```js
664
+ const info = gpu_texture_format_info(this.format);
665
+
666
+ total += Math.ceil(w / info.block_width) * Math.ceil(h / info.block_height) * d * info.bytes_per_block;
667
+ ```
668
+
669
+ which is verbatim what
670
+ [`frame_graph_extract_topology.js:42`](../../device/timing/profile/frame_graph_extract_topology.js:42)
671
+ already computes. Two implementations of one thing become one — as
672
+ `gpu_texture_format_memory_footprint`, exported from the table's own module beside
673
+ `gpu_texture_format_row_bytes`, since that is where this file already keeps derived helpers.
674
+
675
+ **`bits_per_sample` is deleted, not kept.** An earlier revision said it stays for
676
+ [`ColorTargetState`](../../descriptor/pipeline/render/ColorTargetState.js:49); that was a misreading.
677
+ `ColorTargetState` computes its own local sum straight from
678
+ `gpu_texture_format_channel_bit_count` and has never called the getter, whose only caller was
679
+ `memory_footprint` itself. A public accessor that warns and returns 0 for sixty-odd formats is worth
680
+ removing rather than leaving for someone to find. The table it read stays, for `ColorTargetState`.
681
+
682
+ **The signature is positional and every argument is required** —
683
+ `(format, size, dimension, mip_level_count, sample_count)`. Not an options object: the two callers
684
+ hold their fields under different names (`size` against `resolution`), so a destructured parameter
685
+ would have been satisfied by passing a whole descriptor and would have coupled this function to the
686
+ shape of both. And no defaults, because there is no sensible default for a mip or sample count — a
687
+ caller that does not know them is not in a position to ask the question, and a defaulted `1` answers
688
+ a different one convincingly. The counts are asserted instead, so a missing argument is loud rather
689
+ than `NaN`.
690
+
691
+ **A second bug, found while writing it.** Both implementations halved the *third size component*
692
+ per mip level. That is right for a 3D texture and wrong for an array one, whose layers each carry the
693
+ whole chain — a six-layer cube map was under-reported by nearly half. The shared function takes
694
+ `dimension` and halves only for `"3d"`. Neither descriptor had to change to supply it; both already
695
+ carry the field.
696
+
697
+ ### 6.2 The resident material atlas's fast path
698
+
699
+ [`GPUResidentMaterialContext.js:338`](../material/resident/GPUResidentMaterialContext.js:338) takes
700
+ `copyTextureToTexture` from the source into an `rgba8unorm` array when the source is square, POT and
701
+ not sRGB — otherwise it re-renders through `render_copy_texture`. The `!is_srgb` term is standing in
702
+ for "the formats are copy-compatible", which happens to be true today because every material texture
703
+ is `rgba8unorm`. A BC7 source would reach it and fail validation.
704
+
705
+ Replace the proxy with the fact: `source_ctx.gpu_texture.format === target.format`. Compressed
706
+ sources then take the render path, which is correct and already exists — sampling a BC7 texture into
707
+ an `rgba8unorm` attachment is legal, and re-encoding to fit the atlas is what that path is for.
708
+
709
+ ### 6.3 The software device cannot write blocks
710
+
711
+ [`SoftwareGPUQueue.writeTexture:220`](../../device/mock/SoftwareGPUQueue.js:220) computes
712
+ `texture.bytes_per_texel`, which
713
+ [throws for block formats by design](../../device/mock/SoftwareGPUTexture.js:105). Restate the row
714
+ loop in blocks using `gpu_texture_format_row_bytes` / `gpu_texture_format_row_count` — the same shape
715
+ [`texture_copy.js:221`](../../device/mock/texture_copy.js:221) already uses for
716
+ `copyTextureToTexture`. This is what makes the orchestration tier testable without a GPU, and it is
717
+ why step 5 of §9 is not optional.
718
+
719
+ ### 6.4 Things that want CPU pixels
720
+
721
+ | Caller | Resolution |
722
+ |---|---|
723
+ | [`shade_texture_might_have_alpha`](shade_texture_might_have_alpha.js) | **Improved, not broken.** It currently guesses from `source.itemSize` and otherwise defaults to `true`. The format answers exactly: `bc4`/`bc5`/`etc2-rgb8unorm` have no alpha, while `bc7`/`bc3`/`astc` may — and `bc1-rgba-unorm` and `etc2-rgb8a1unorm` carry 1-bit alpha and must answer **true** (§11.6). |
724
+ | [`download_texture_data`](download_texture_data.js) | Debug readback, via `parse_gpu_texture_format`, whose regex cannot parse a block name. Throw a message naming the format rather than fail inside a regex. |
725
+ | [`VTSourceImage`](virtual/source/VTSourceImage.js) | Builds mip pyramids on `OffscreenCanvas` from `ImageBitmap`. Structurally incompatible with block data — §7.3. |
726
+
727
+ ### 6.5 Bindless
728
+
729
+ [`GPUBindlessTextureManager.js:114`](bindless/GPUBindlessTextureManager.js:114) sizes its allocation
730
+ via `parse_gpu_texture_format(...).getByteSize()`. Its `upload` throws `Not implemented`, so nothing
731
+ regresses today — but the arithmetic should move to `gpu_texture_format_info` when it is finished,
732
+ for the same reason as §6.1.
733
+
734
+ ---
735
+
736
+ ## 7. Virtual textures — what this must not preclude
737
+
738
+ Compressed VT is **out of scope to build** and in scope to not design out. What follows is the list
739
+ of things that would have made it impossible, and what the plan does about each.
740
+
741
+ ### 7.1 WebGPU permits it
742
+
743
+ Writing compressed tiles into a compressed atlas is legal. The rules, from the copy-validation
744
+ sections of the specification, are the same three for `writeTexture`, `copyBufferToTexture` and
745
+ `copyTextureToTexture`:
746
+
747
+ - `origin.x` a multiple of the format's `block_width`, `origin.y` of `block_height`
748
+ - `copySize.width` a multiple of `block_width`, `copySize.height` of `block_height`
749
+ - the copy fits within the *physical* extent of the subresource, which for a compressed format is the
750
+ level's size rounded up to whole blocks
751
+
752
+ No decompression, no intermediate, no per-tile texture. A tile upload stays exactly what it is today:
753
+ one `writeTexture` at an offset.
754
+
755
+ ### 7.2 The cache is already most of the way there
756
+
757
+ [`VTPhysicalCache`](virtual/VTPhysicalCache.js:69) takes a `VTCacheLayer[]`, each with its own
758
+ `GPUTextureFormat`, and creates one atlas texture per layer. Albedo as BC7 beside normals as BC5 is
759
+ already expressible. Three things stand between that and working:
760
+
761
+ 1. **`bytes_per_row ?? this.slot_size * 4`** ([:324](virtual/VTPhysicalCache.js:324)) — the one
762
+ uncompressed assumption in the upload. Becomes
763
+ `gpu_texture_format_row_bytes(layer.format, this.slot_size)`, the same call §3.4 makes.
764
+ 2. **`RENDER_ATTACHMENT`** ([:164](virtual/VTPhysicalCache.js:164)) is requested unconditionally, for
765
+ the `copyExternalImageToTexture` path. Illegal on a compressed format, and unnecessary — a
766
+ compressed layer has no external-image path to serve. Condition it on the layer's format.
767
+ 3. **`slot_size = page_size + border × 2` must be a multiple of the block size.** Slot origins are
768
+ `slot_index × slot_size`, so aligning the slot size aligns every origin for free — one assertion
769
+ in the constructor covers the whole system.
770
+
771
+ That last constraint binds unevenly, which is worth spelling out. For a 4×4 block and the default
772
+ 128-texel page, any even border works (128 + 2·4 = 136 = 34 blocks). For `astc-6x6` it needs
773
+ `(128 + 2·border) ≡ 0 (mod 6)`, i.e. `border ≡ 2 (mod 3)` — satisfiable, and a trap. **This is the
774
+ argument for `astc-4x4` in the VT path** even where a coarser block would be chosen for an ordinary
775
+ texture: it makes the alignment rule the same one BC already imposes.
776
+
777
+ ### 7.3 Borders must be baked in, which rules out one source
778
+
779
+ [`VTSourceImage`](virtual/source/VTSourceImage.js) builds pyramids on `OffscreenCanvas` and resolves
780
+ each page's border at runtime by blitting from neighbouring content. That is impossible for block
781
+ data — border and interior share blocks at the seam, and re-encoding a block at runtime is precisely
782
+ the machinery this is not building.
783
+
784
+ So a compressed stack must come from a source whose tiles are **produced with their borders already
785
+ inside them**, which is what [`VTSourceTiled`](virtual/source/VTSourceTiled.js) is for and what
786
+ `VTPhysicalCache.upload` already documents ("border already baked in"). `VTSourceImage` stays the
787
+ uncompressed runtime-virtualization path and should say so rather than produce blank pages.
788
+
789
+ This is not a new limitation so much as §3.2's rule restated at tile granularity: **blocks are
790
+ produced by an encoder that can see the texels, and nothing downstream re-derives them.**
791
+
792
+ ### 7.4 What this plan owes VT
793
+
794
+ Nothing structural — which is the finding. Block-aware row arithmetic through
795
+ `gpu_texture_format_row_bytes` (§3.4), format-carrying payloads (§3.1), and per-format delivery (§5)
796
+ are what the tile path needs, and it needs them in the same form the texture path does. The tile
797
+ becomes the streaming unit instead of the mip level, and §3.4's primitive is already written at the
798
+ right granularity for that.
799
+
800
+ ---
801
+
802
+ ## 8. Out of scope
803
+
804
+ - **Compressed 3D textures** — they need `texture-compression-bc-sliced-3d` or
805
+ `texture-compression-astc-sliced-3d`, and nothing in the engine wants one.
806
+ - **Compressed virtual textures** — §7. Not built; not designed out.
807
+ - **Runtime compression** — §4.5. Not built; not designed out.
808
+ - **On-GPU BC/ASTC encoders** — the shader behind §4.5.
809
+ - **The offline encoder.** First-party content has to be compressed by something, and that something
810
+ is a tool, not the engine. It belongs beside the AVIF encoder
811
+ ([`avif/threaded_image_encoder.js`](../../../avif/threaded_image_encoder.js)), reusing its worker
812
+ pool. Mips are generated *before* compression, from the source texels, with the same filters
813
+ [`mipmaps/filters/`](mipmaps/filters) uses — compressing level 0 and decompressing it to build
814
+ level 1 is how a pyramid rots.
815
+
816
+ ---
817
+
818
+ ## 9. Staging
819
+
820
+ Each step is independently committable and leaves the engine working.
821
+
822
+ | # | Change | Risk |
823
+ |---|---|---|
824
+ | 0 | **Landed.** `TextureFormat` enum; `GPUTextureUsage.js` shim beside `GPUShaderStage.js`; `TextureDescriptor` **and `TextureResourceDescriptor`** import it (§2.1, §9.1) | none. Unblocks every node-side consumer this plan later adds. The `{GPUTextureFormat}` → `{TextureFormat}` JSDoc sweep is mechanical, changes no behaviour — both are strings — and still lags. |
825
+ | 1 | **Landed.** `memory_footprint` onto `gpu_texture_format_info`; duplicate in `frame_graph_extract_topology` dropped; `bits_per_sample` deleted; array-vs-3D mip depth fixed (§6.1) | none — it fixes two wrong numbers |
826
+ | 2 | **Landed.** `AlphaMode`, `ShadeTexelData`, `ShadeImage.format` / `.level_count` / `.alpha_mode`; descriptor builder and uploader read them (§3.1, §3.3, §9.2) | pure refactor. **No new formats.** Every existing texture test passed with its assertions untouched — `"color_space None skips premultiplication entirely"` included, which is the §11.3 guard. |
827
+ | 3 | **Landed.** `texture_write_level` as the primitive; `texture_write_to_gpu` becomes its caller (§3.4, §11.4, §9.3) | as above — the existing upload tests pass with their assertions untouched |
828
+ | 3b | **Landed.** `chunk_decode_tangent_normal` at the five normal-sample sites; `texture_format_preference` now leads with BC5 (§11.1, §9.8) | one shared chunk, verified on today's RGB normal maps — the stored blue channel is redundant, so the decode agrees with it to 5 decimal places |
829
+ | 4 | **Landed.** `GenerateMipMaps` honoured or thrown; levels otherwise come from the image; `texture_write_to_gpu` gains a required `level_count` (§3.2, §9.5) | behaviour identical for every image that exists today — nothing the engine currently builds can reach the new throw |
830
+ | 5 | **Landed.** `writeTexture` delegates to `texture_copy` (§6.3, §9.6). A BC7 texture now goes end to end with no GPU. | test tier only |
831
+ | 6 | **Landed.** Device features, `gpu_texture_compression_support`, `TextureRole`, `texture_format_preference`; §6.2 and §6.4 guards (§3.5, §9.7) | none until a compressed texture exists |
832
+ | 7 | **Landed.** `TextureVariantSet` (the manifest) and `texture_variant_select`; **not** in `AssetManager` (§5.1, §9.9) | additive — an unbuilt format falls through to the fallback, and nothing consumes this until step 8 |
833
+ | 8 | **Landed.** KTX2 reader in [`src/ktx2/`](../../../ktx2/ktx2_read.js) — container to `ShadeTexelData`, for files already in a device format; ZLIB supercompression read rather than refused (§9.10) | additive — nothing imports it yet, and nothing existing changed except the cursor it reuses |
834
+ | 9 | CPU Basis transcoder (§4.3), then `KHR_texture_basisu` | the expensive one. Sequenced last deliberately. |
835
+ | 10 | GPU transcoder (§4.4) — the engine's first `copyBufferToTexture` | an optimization of step 9, not a prerequisite |
836
+
837
+ Steps 1–6 introduce **no new capability and no new branch** — they are the refactor that makes
838
+ everything after them a data change rather than a second code path. If the project stops after step
839
+ 6, the engine is strictly better than it is now (correct memory accounting, one format table,
840
+ declared alpha, level-at-a-time upload, mips from data) and nothing has been added that must be
841
+ maintained for a feature that did not land.
842
+
843
+ ### 9.1 What step 0 actually shipped
844
+
845
+ Two class fields read `GPUTextureUsage` at module load, not one:
846
+ [`TextureDescriptor.js:61`](../../descriptor/texture/TextureDescriptor.js:61) and
847
+ [`TextureResourceDescriptor.js:56`](../../device/graph/TextureResourceDescriptor.js:56). Both now
848
+ import the shim. Everything else in the tree that touches the namespace — 139 sites — reads it inside
849
+ a function body or a default parameter, which is call-time and only reachable from a node tool that
850
+ gets that far; those stay on the global for now and convert with the JSDoc sweep.
851
+
852
+ [`TextureFormat`](../../descriptor/texture/format/TextureFormat.js) is generated from the key set of
853
+ `FORMATS`, so the two cannot start out disagreeing. 101 formats, and the key transform is mechanical
854
+ for every one of them — no exceptions to hand-tune, which is what made the reversibility test worth
855
+ writing.
856
+
857
+ The node-side test was checked against its own absence: with the shim import removed from
858
+ `TextureDescriptor`, it fails with `ReferenceError: GPUTextureUsage is not defined`, which is the
859
+ regression it exists to catch. A test that passes with and without the fix would have been the §11
860
+ failure mode applied to the test suite.
861
+
862
+ ### 9.3 What step 3 actually shipped
863
+
864
+ **`texture_write_level(queue, texture, level, bytes)`** — and nothing else. §3.4 sketched it taking
865
+ `format` and `size` too; both come off the texture, which is the authority on all three because it is
866
+ the thing being written into, and passing them would be one more pair of things that can disagree.
867
+
868
+ **No level range, and no optional parameter.** §11.4 asked for one so the uploader could honour
869
+ §3.2's "generated mips replace supplied ones". Two named functions answer that better than one
870
+ function with an optional argument: `texture_write_to_gpu` writes the whole image,
871
+ `texture_write_level` writes one level, and step 4 calls whichever the flag calls for. The decision
872
+ belongs where the flag is, which is `TextureManager`, not threaded through the uploader as a
873
+ parameter it would have to interpret.
874
+
875
+ The row stride the raw path used to compute for itself is gone — it comes from the format now, which
876
+ also fixes the padded case honestly: after RGB→RGBA padding the stride is the texture's four
877
+ channels because the *texture's format* says four, rather than because the padding code remembered to
878
+ recompute it.
879
+
880
+ **One test fixture changed, no assertions.** `texture_write_to_gpu.spec.js`'s stand-in `GPUTexture`
881
+ had no `mipLevelCount` or `dimension`; every real one has both, and the primitive reads them. The
882
+ fake was wrong, not the test.
883
+
884
+ ### 9.10 What step 8 actually shipped
885
+
886
+ [`ktx2_read`](../../../ktx2/ktx2_read.js) is the whole public surface: `bytes -> ShadeTexelData`, one
887
+ free function, no device and no queue. It lives at `src/ktx2/`, beside `src/avif/` rather than inside
888
+ the renderer, which is §2's "living with its container" taken literally — the same function is what a
889
+ worker, a node tool and the offline encoder will each want, and none of them should have to import a
890
+ graphics API to get it.
891
+
892
+ **It reads what first-party tooling emits and refuses everything else by name**, one refusal per
893
+ feature, each naming the scheme or the format: BasisLZ, Zstandard, an unknown scheme number, ETC1S,
894
+ UASTC, an unmapped `vkFormat`, array textures, cube maps, 1D textures, 3D block-compressed payloads,
895
+ `levelCount == 0` on a block format, a flipped `KTXorientation`, a non-identity `KTXswizzle`.
896
+
897
+ #### The reuse sweep, and what it moved
898
+
899
+ §9.4's lesson was applied before writing rather than discovered afterwards. Three things came out of
900
+ it, and two of them are changes to existing code:
901
+
902
+ - **`ByteCursor` moved to [`core/binary/`](../../../core/binary/ByteCursor.js) and takes its byte
903
+ order at `init`.** It was exactly the cursor this needed — bounds-checked, zero-copy, a named error
904
+ per truncated field — and it was big-endian and living in `avif/native/isobmff/`. The alternatives
905
+ were both worse: `BinaryBuffer` is a growable stream whose reads throw `RangeError` from a
906
+ `DataView` rather than naming the field that ran out, and a second cursor in `ktx2/` would have
907
+ been the fourth copy of bounds-checked reading in this tree. **Endianness is a property of the
908
+ container, so it is stated once at `init`** rather than at each of the eighty-odd accessor calls —
909
+ which is also why no AVIF accessor call site changed, only the ten places that construct a
910
+ cursor, two of them outside tests. (The commit message says "four". It is wrong; this is the
911
+ count.)
912
+ [D10](../../../avif/native/DECISIONS.md) is amended to record this; the decision it made — read
913
+ with a cursor, write with `BinaryBuffer` — is untouched.
914
+ - **`gpu_texture_format_is_block_compressed` exists, and not where §2 put it.** §2 listed it under
915
+ `renderer/texture/format/`. That was wrong twice over: it is a fact about the format table, so it
916
+ belongs in the table's own module beside `gpu_texture_format_row_bytes` — which is what §6.1
917
+ already decided for derived helpers — and a container reader cannot import from
918
+ `shade/renderer/`. The three sites that had it written out inline (`SoftwareGPUTexture`,
919
+ `gpu_texture_format_can_generate_mipmaps`, `download_texture_data`) now call it, so the reader's
920
+ two uses are the fourth and fifth callers of one predicate rather than copies five and six.
921
+ - **Nothing else was written from scratch that already existed.** Level byte lengths are
922
+ `ShadeTexelData`'s, which checks them against the format table; the chain-length rule is
923
+ `ShadeTexelData`'s; the identifier comparison is `isArrayEqualStrict`; the key/value strings are
924
+ `utf8_decode`. The reader computes no extent and no row stride at all — it reads `byteLength` from
925
+ the level index and lets the payload type disagree with it if the file is wrong.
926
+
927
+ **It is not behind [`Codec`](../../../engine/asset/codec/Codec.js).** That interface is
928
+ `async decode(Uint8Array) -> T` and lives under `engine/asset/loaders/image/`, so subclassing it
929
+ would put engine code in a container reader's import graph and make a synchronous byte-shuffle a
930
+ promise for no reason. The image-codec review is still right that every image format should reach the
931
+ engine through one door — but the door is an adapter, written when the compressed-texture *loader*
932
+ lands and has somewhere to put the `ShadeImage` it builds. Nothing consumes this reader yet, exactly
933
+ as nothing consumed step 7 until now.
934
+
935
+ #### Where the plan was overturned, and the judgement calls
936
+
937
+ **ZLIB is read, not refused.** §11.6 says "transport compression is the delivery layer's business",
938
+ and it is right — about what meep *writes*. It says nothing about what meep must be able to *read*,
939
+ and a KTX2 file with `supercompressionScheme` 3 holds device-format blocks behind an RFC 1950 stream
940
+ that `pako` — already a dependency, already inflating every PNG — undoes in one call. Refusing it
941
+ would have meant refusing a file the engine can use completely, over a wrapper it can already remove.
942
+ The declared `uncompressedByteLength` is checked against what the stream actually inflates to, and
943
+ then `ShadeTexelData` checks that against the format table, so a mislabelled file fails twice rather
944
+ than uploading garbage. Zstandard stays refused because there is no decompressor here at all, and
945
+ BasisLZ stays refused because it is not a wrapper — it is an encoding with its own codebooks, and
946
+ undoing it is a transcoder.
947
+
948
+ **`pako.inflate` directly rather than
949
+ [`png_inflate`](../../../engine/asset/loaders/image/png/png_inflate.js).** What that function adds
950
+ over the bare call is PNG's two peculiarities: a stream split across `IDAT` chunks, and a fallback
951
+ that abandons the adler32 check because real encoders get it wrong. A KTX2 level is one stream, and a
952
+ texture payload whose checksum fails should fail. Reusing it would have meant a KTX2 reader importing
953
+ from the PNG loader for the sake of a wrapper whose whole content it wants to opt out of.
954
+
955
+ **`levelCount == 0` is refused for a block format and read as one level otherwise.** It means
956
+ "generate the mip chain at load", which is an *instruction*, and §3.1's payload carries data. The
957
+ engine's instruction is `ShadeTextureFlags.GenerateMipMaps`, honoured or thrown by §3.2, and it is
958
+ the caller's to state — so the reader returns the level the file physically contains and does not
959
+ pretend to carry the request. For a block-compressed payload the request cannot be honoured by
960
+ anything at any layer, so it is refused here, naming the format, rather than left to fail later with
961
+ a message about a flag the file never mentioned.
962
+
963
+ **Both BC1 spellings map to `bc1-rgba-unorm`.** `VK_FORMAT_BC1_RGB_*` and `VK_FORMAT_BC1_RGBA_*` are
964
+ the same sixty-four bits; the difference is whether a three-colour-mode block's fourth index reads as
965
+ opaque black or transparent, and WebGPU exposes only the RGBA name. Mapped rather than refused,
966
+ because an encoder targeting the RGB spelling has no alpha to store and so no reason to emit that
967
+ index — refusing would reject a large share of real BC1 content over a difference the content does
968
+ not contain. Written down in the table rather than left as a coincidence.
969
+
970
+ **Two refusals the plan never mentioned**, both because the failure is a *silent misread* rather than
971
+ an error: `KTXorientation` other than right-down loads the texture upside down, and `KTXswizzle`
972
+ other than `rgba` loads it in the wrong channels. Neither looks like a bug in the reader. Reading the
973
+ key/value section costs forty lines and turns both into a named refusal quoting the value. The third
974
+ axis of a 3D orientation is checked strictly — `rdi` and not `rdo` — which may be stricter than real
975
+ files need; the refusal names the value, so a file that is genuinely fine is a one-line change rather
976
+ than a mystery.
977
+
978
+ **The `vkFormat` table names its gaps.** 97 numbers map to 95 formats — 54 rows onto 52 distinct
979
+ block-compressed ones, the two BC1 spellings collapsing, and 43 onto the uncompressed ones.
980
+ Twenty-three more are *named* with a reason they cannot be mapped — three-channel formats,
981
+ single-channel sRGB, `A2R10G10B10` and `A8B8G8R8_PACK32` byte order, depth and stencil — so a file
982
+ carrying one is told what is wrong with it rather than handed a number. Anything outside both tables
983
+ is refused by number, which is the honest answer for a format nobody here has heard of.
984
+
985
+ A spec asserts that **every colour format in `gpu_texture_format_info` is reachable from some
986
+ `vkFormat`**, which is the tripwire for a format landing in the engine's table and not in this one.
987
+ That assertion started out scoped to block-compressed formats, on the stated theory that several
988
+ uncompressed formats had no Vulkan counterpart; measuring it during review showed the theory false —
989
+ all 43 are mapped — so the tripwire is now as wide as the mapping, and depth and stencil are the only
990
+ exclusions.
991
+
992
+ #### Testing, and the gap in it
993
+
994
+ Forty specs over the reader and its `vkFormat` table, and six more over the cursor's new parameter —
995
+ the same bytes reading differently in each order, and `init` refusing to guess. The two that could
996
+ have passed against a broken reader were checked against their own absence:
997
+
998
+ 1. **Level ordering.** The level index is base-first and the image data is conventionally stored
999
+ smallest-first, so a reader that walked the file would return a plausible payload with the pyramid
1000
+ upside down — right byte counts, right level count, every texture blurred to its 1×1 mip. The
1001
+ fixture builder lays the data out either way; with `cursor.seek(byte_offset)` removed the
1002
+ smallest-first test fails and the base-first ones still pass, which is exactly the asymmetry that
1003
+ makes it worth having.
1004
+ 2. **Truncation.** Removing the per-level bounds check fails both the truncated-file test and the
1005
+ level-index-past-the-end test.
1006
+
1007
+ **The fixtures are synthesised, and that is the gap.** `ktx2_build` writes the container from the
1008
+ specification, so the reader and the fixture share one reading of it: a misreading would be
1009
+ reproduced identically on both sides and pass. What synthesis buys is every malformed variant as a
1010
+ one-line override, which a committed binary cannot give, and it is why the whole refusal surface is
1011
+ covered at all. The missing check is a file from `toktx` — an independent authority on the layout —
1012
+ and until one is read the byte offsets in this reader are believed rather than known. First contact
1013
+ with a real file is the test that has not been run.
1014
+
1015
+ #### What an adversarial pass found afterwards
1016
+
1017
+ Three things, all in the parts that were argued for hardest.
1018
+
1019
+ **`pako` throws a bare string, and the ZLIB path let it escape.** `pako.inflate` on a corrupt stream
1020
+ throws `"incorrect header check"` — a `String`, not an `Error`, with no `.message` and nothing naming
1021
+ the file or the level. So the one capability added beyond what this step was asked for was the one
1022
+ path that did not refuse by name, and the JSDoc's `@throws {Error}` was false for it. Now wrapped,
1023
+ and the message names the level. Worth recording *why* it was missed: the comment justifying
1024
+ `pako.inflate` over `png_inflate` listed two differences and was satisfied with them — multi-chunk
1025
+ streams and lenient adler32 — when there was a third, and the third was the one that mattered. An
1026
+ argument for not reusing something is only as good as its enumeration of what the thing does.
1027
+
1028
+ **`ShadeTexelData` refuses a legitimate 3D file with a full mip chain**, and this reader is the first
1029
+ code able to reach it. Its per-level byte lengths are 3D-correct — `level_byte_length` goes through
1030
+ `texture_mip_extent(…, "3d", …)`, which halves depth — but its chain-length rule is
1031
+ `compute_max_mip_count(width, height)`, which does not see depth at all. A 4×4×16 volume has a
1032
+ five-level chain and the constructor insists on three; `level_byte_length` will meanwhile happily
1033
+ size the two levels the constructor rejects. The type disagrees with itself.
1034
+
1035
+ **And it is not only there.** `texture_descriptor_from_texture_shade.js:50` computes the *generate*
1036
+ path's `mipLevelCount` the same way, as do `TextureResourceDescriptor` and `GPULightCollection` — so
1037
+ a 3D texture asking for generated mips gets a short chain today, with no container reader involved.
1038
+ That makes it §9.4's finding again, one axis over: the depth rule was given a home in
1039
+ `texture_mip_extent` and the *count* rule never was. Deliberately not fixed in that commit — it is a
1040
+ change to step 2's contract and to call sites that have nothing to do with KTX2, and burying it in a
1041
+ container reader's commit is how a shared invariant gets changed without anyone reviewing it. The
1042
+ reader refuses rather than misreads in the meantime, which is the behaviour that matters.
1043
+
1044
+ **Fixed since, on its own.** `texture_mip_level_count(width, height, depth_or_layers, dimension)` is
1045
+ now the counterpart §9.4 was missing, beside the extent in the same file, and
1046
+ `compute_max_mip_count` — which took no depth and so could not be told — is gone rather than
1047
+ deprecated, because a depth-blind mip count left reachable is a thing someone reaches for. All six
1048
+ callers went through it, and the sweep is the reason to have done it as its own change rather than
1049
+ only patching the two sites that were wrong:
1050
+
1051
+ - `ShadeTexelData` and `texture_descriptor_from_texture_shade` were the live bugs — a volume's chain
1052
+ and a volume's *generated* chain, both truncated to what width and height implied.
1053
+ - `TextureResourceDescriptor.from` had the dimension in its own argument list and was not passing it,
1054
+ which mattered: `graph_import_texture` re-derives `mipLevelCount` from resolution for anything with
1055
+ mips, so a 3D import would have had its chain recomputed short. It now also reads the padded
1056
+ `r.resolution` rather than the raw argument, so a two-axis caller reads as depth 1 and not
1057
+ `undefined`.
1058
+ - `GPULightCollection` (equirectangular environment), `HierarchicalZBuffer` (screen-space depth
1059
+ pyramid) and `GPULightMap` are 2D and were never wrong. The first now asks its descriptor for its
1060
+ dimension instead of the call site assuming one; the other two say `"2d"` at the call, which is the
1061
+ whole of what they know.
1062
+
1063
+ A spec pins the part that matters more than any single call site: for a range of shapes, the level
1064
+ the count ends on is the one where the halving axes reach 1, and the level before it is not. The
1065
+ count and the extent agree because they are derived from one rule, rather than because both call
1066
+ sites were written by someone who remembered.
1067
+
1068
+ Also corrected: an exported constant with no importers (`BC1_RGB_NOTE`, now prose beside the rows it
1069
+ explains), a `supercompression_scheme_name` whose only caller could never reach anything but its
1070
+ fallback — producing "supercompressionScheme 7 is an unknown scheme, which this reader does not
1071
+ know" — and an unused `truncate_to` option in the fixture builder. Three tests were added for
1072
+ promises nothing checked: an `ArrayBuffer` and an offset view as input, and a multi-level ZLIB file.
1073
+ The new ZLIB guard was checked against its own absence like the other two.
1074
+
1075
+ ### 9.9 What step 7 actually shipped, and where §5.1 was wrong
1076
+
1077
+ §5.1 said `AssetManager.assignAlias` "is the mechanism that already exists for exactly this
1078
+ indirection" and proposed making alias resolution format-aware. **That was wrong, for the reason
1079
+ `ShadeImage.format` was wrong:** it would push `GPUTextureFormat`, `TextureRole` and a device
1080
+ capability set into the generic asset system, which loads sounds and fonts and JSON and has no
1081
+ business knowing what a texel block is.
1082
+
1083
+ The indirection belongs in the texture layer, and once it is there `AssetManager` needs no change at
1084
+ all. [`TextureVariantSet`](TextureVariantSet.js) is §11.5's manifest — which encodings exist and
1085
+ where, known before the first request. [`texture_variant_select`](format/texture_variant_select.js)
1086
+ walks the preference list against it and returns a path, which the existing `AssetManager.promise`
1087
+ loads like anything else.
1088
+
1089
+ **It returns a path and nothing else.** Not the format it chose: a reader determines a payload's
1090
+ format from the payload's own header, which is the only source that cannot be wrong, and handing back
1091
+ a format alongside would invite someone to trust the manifest over the bytes. That also means the
1092
+ manifest is deliberately dumb about content — a manifest that lies produces a failed read rather than
1093
+ a texture full of garbage.
1094
+
1095
+ **It always answers.** There is no "nothing suitable" case for a caller to handle: a device with no
1096
+ compression, or one whose preferred formats were never built, gets the fallback — the same path a
1097
+ tool or a test asks for. A texture with no universally readable encoding is a broken manifest, not a
1098
+ runtime condition.
1099
+
1100
+ **Nothing consumes this yet**, which is the plan's shape rather than an oversight: step 8's KTX2
1101
+ reader is the first caller, and this is the contract it is written against.
1102
+
1103
+ ### 9.8 What step 3b actually shipped
1104
+
1105
+ [`chunk_decode_tangent_normal`](../../shader/chunk/geometry/normal/chunk_decode_tangent_normal.js)
1106
+ reconstructs Z from XY instead of reading the blue channel, at all five sites. §11.1's resolution,
1107
+ unchanged by contact with the code: one decode serving both channel counts, so nothing branches on
1108
+ the format and no pipeline is compiled twice.
1109
+
1110
+ Verified three ways, because the failure mode is a shader that renders wrong rather than one that
1111
+ fails to build:
1112
+
1113
+ 1. **Against the blue channel it ignores.** The chunk spec runs in the WGSL emulator and asserts the
1114
+ reconstruction agrees with the stored Z to five decimals over a set of normals, and that a
1115
+ two-channel texel decodes identically to a three-channel one. That is what makes this safe to land
1116
+ before any compressed texture exists — every RGB normal map in the engine should look exactly as
1117
+ it did.
1118
+ 2. **Against a missing registration.**
1119
+ [`normal_decode_call_sites.spec.js`](../../shader/chunk/geometry/normal/normal_decode_call_sites.spec.js)
1120
+ compiles each site and asserts the function is declared exactly once and called. Checked against
1121
+ its own absence: removing one registration fails it with an `undeclared-call` diagnostic. Three of
1122
+ the four sites had **no WGSL compile coverage at all** before this — only the VT shader did.
1123
+ 3. **Against the `max(0, …)` guard.** Filtering and lossy compression push samples off the unit
1124
+ circle; without it `sqrt` of a negative gives `NaN` rather than a normal in the tangent plane.
1125
+
1126
+ `texture_format_preference` now leads with `bc5-rg-unorm` for normals, and §9.7's tripwire — the test
1127
+ asserting two-channel formats were *absent* — has fired and been replaced by its opposite. That is the
1128
+ payoff: two channels of budget where four were being spent, on the one map type where a colour codec
1129
+ bands visibly.
1130
+
1131
+ ### 9.7 What step 6 actually shipped
1132
+
1133
+ The three features are requested when offered and never required, beside `TextureFormatsTier1`.
1134
+ [`gpu_texture_compression_support`](format/gpu_texture_compression_support.js) turns "which features
1135
+ did we get" into "which formats may we ask for", deriving the families from the format table's own
1136
+ names rather than a second list — 14 BC, 10 ETC2/EAC, 28 ASTC.
1137
+
1138
+ [`texture_format_preference`](format/texture_format_preference.js) is the engine's whole part in
1139
+ choosing an encoding, over a new [`TextureRole`](../TextureRole.js). It answers what would be good and
1140
+ stops: no fetching, no knowledge of what was built, no fallback — all of which are the asset layer's,
1141
+ in step 7.
1142
+
1143
+ **It deliberately offers no two-channel format for normals.** `bc5-rg-unorm` is the right answer and
1144
+ handing it out now would render black, because §11.1's Z reconstruction has not landed: BC5 samples as
1145
+ `(x, y, 0, 1)` and the five normal reads are still `.rgb * 2.0 - 1.0`. There is a test asserting the
1146
+ absence, which is a tripwire meant to fail — and be deleted — in the change that fixes the shader.
1147
+ Until then the list offers three-channel formats, which are worse for normals and correct.
1148
+
1149
+ **§6.2's guard found the real invariant.** `!is_srgb` was standing in for "the formats are
1150
+ copy-compatible", true only because every material texture was `rgba8unorm`. It is now
1151
+ `source.format === target.format`, which covers every format at once.
1152
+
1153
+ **§6.4's alpha rewrite regressed a case, and the test caught it.** Answering purely from the format
1154
+ called every three-channel image possibly-transparent: the upload pads RGB to RGBA because WebGPU has
1155
+ no three-channel format, so the *texture* has alpha while the *content* has none. Loose pixels are now
1156
+ answered by their channel count first, and only a texel payload — whose encoder already chose a
1157
+ format that says how many channels there are — is answered by the format alone. `bc1-rgba-unorm`
1158
+ correctly reports alpha (§11.6).
1159
+
1160
+ ### 9.6 What step 5 actually shipped
1161
+
1162
+ §6.3 proposed restating the queue's row loop in blocks. §9.4's lesson said to look at the mock first,
1163
+ and looking found that no restating was needed: `writeTexture` is `copyBufferToTexture` with the bytes
1164
+ supplied inline, and [`texture_copy.js`](../../device/mock/texture_copy.js) already had both halves —
1165
+ `texture_copy_refusal` and `texture_copy_execute`, block-aware since they were written, and already
1166
+ used by `SoftwareGPUCommandEncoder` for the buffer version.
1167
+
1168
+ So `writeTexture` now wraps its `data` and `dataLayout` as a buffer endpoint and calls the same two
1169
+ functions. Three things came with that beyond block support: multi-layer writes (the
1170
+ `depth === 1` assertion is gone), `rowsPerImage` honoured rather than ignored, and stricter
1171
+ validation — `texture_endpoint_refusal` turned out to be a superset of the queue's private
1172
+ `texture_write_refusal`, adding a `copyable` check, a mip-range check and a bounds check, with the
1173
+ same wording for the three they shared.
1174
+
1175
+ [`compressed_texture_upload.spec.js`](compressed_texture_upload.spec.js) is §10.4: a BC7 payload
1176
+ through `TextureManager`, the descriptor builder and the uploader, asserted byte-for-byte at every
1177
+ level, with no GPU. Checked against its own absence — against the pre-step-5 queue it fails with
1178
+ `bytes_per_texel: 'bc7-rgba-unorm' is block-compressed`.
1179
+
1180
+ Its "no validation error" assertion is itself guarded: the mock reports refusals through an error
1181
+ scope rather than by throwing, so a test that watched the wrong channel would pass against a texture
1182
+ nothing had been written to. A second test writes to a texture without `COPY_DST` and asserts the
1183
+ error does land, which is what makes the first one mean something.
1184
+
1185
+ ### 9.5 What step 4 actually shipped
1186
+
1187
+ The flag is now an instruction. `generate` is read once, the format is checked against
1188
+ [`gpu_texture_format_can_generate_mipmaps`](format/gpu_texture_format_can_generate_mipmaps.js), and a
1189
+ request that cannot be honoured throws with a message naming the texture, the format and what to do
1190
+ instead. Otherwise `mipLevelCount` is `image.level_count` — no inference, no comparison.
1191
+
1192
+ **The predicate covers three classes and says so.** Block-compressed and depth/stencil come out of
1193
+ `gpu_texture_format_info`; `snorm` and `rgb9e5ufloat` are a listed set. It is deliberately *not* a
1194
+ general renderability table — `rg11b10ufloat` is renderable only behind a feature and this does not
1195
+ check for it — because a partial table presented as complete is worse than a predicate whose scope is
1196
+ written down. Everything outside those classes answers `true`, which is correct for every format the
1197
+ engine currently produces.
1198
+
1199
+ **`texture_write_to_gpu` did get a `level_count` after all** — required, not optional, so every caller
1200
+ states it. Step 3 argued two named functions were enough; that was wrong for one case, which is a
1201
+ `ShadeTexelData` carrying a chain *and* the generate flag. `texture_write_level` cannot serve it,
1202
+ because an `ImageBitmap` still has to go through `copyExternalImageToTexture` and `TextureManager`
1203
+ should not be re-deriving which source kind it holds. A required argument keeps the decision at the
1204
+ caller without reintroducing an optional one.
1205
+
1206
+ **Nothing existing can reach the new throw.** `infer_format_from_parameters` only ever produces
1207
+ `unorm`, `unorm-srgb`, `uint` and `float` endings — never `snorm`, never a packed format — so no
1208
+ image the engine builds today has a format the generator would refuse.
1209
+
1210
+ ### 9.4 One mip-arithmetic rule, four copies
1211
+
1212
+ `SoftwareGPUTexture` has had `mip_width` / `mip_height` / `row_bytes` / `row_count` / `layer_bytes`
1213
+ all along — the oldest and most complete statement of per-level layout in the tree, and one this plan
1214
+ walked straight past. Steps 1, 2 and 3 each added another copy of the same arithmetic
1215
+ (`memory_footprint`'s loop, `ShadeTexelData.level_byte_length`, `texture_write_level`), which is
1216
+ precisely the outcome §0 exists to prevent, committed three times in a row.
1217
+
1218
+ Each rule now has one home:
1219
+
1220
+ - [`texture_mip_size(size, level)`](../../util/texture_mip_extent.js) — halve, floor at one.
1221
+ - [`texture_mip_extent(width, height, depth_or_layers, dimension, level)`](../../util/texture_mip_extent.js)
1222
+ — that, plus **only a 3D texture's depth halves**. This is the one worth naming: written by hand it
1223
+ reads as an obvious `>> level` on all three components, and that mistake is what §6.1 found already
1224
+ shipped in two places.
1225
+ - [`texture_mip_level_count(width, height, depth_or_layers, dimension)`](../../util/texture_mip_extent.js)
1226
+ — how long the chain the other two describe actually is: as long as the longest axis that halves,
1227
+ which is the *same* distinction one derivative up. Added after step 8, once §9.10 found that the
1228
+ extent rule having a home and the count rule not having one is a gap and not a tidiness complaint —
1229
+ `ShadeTexelData` had ended up sizing levels it then refused. It lives in the same file as the extent
1230
+ so the two cannot drift.
1231
+ - [`gpu_texture_format_level_bytes`](../../descriptor/texture/format/gpu_texture_format_info.js) —
1232
+ `row_bytes × row_count × layers`, which was written out in four.
1233
+
1234
+ `SoftwareGPUTexture` keeps its API — `texture_copy` and `SoftwareGPUQueue` both use it — and delegates
1235
+ to these. Nothing in `src/shade/` computes a mip dimension inline any more; the only surviving
1236
+ `>> level` is inside `texture_mip_size`, and the only `log2` is inside `texture_mip_level_count`.
1237
+
1238
+ The general lesson for the rest of the plan: **the mock is not a lesser implementation.** It was
1239
+ written against the format table from the start and had the block-aware answer to three of these
1240
+ questions before the renderer did.
1241
+
1242
+ ### 9.2 What step 2 actually shipped
1243
+
1244
+ `alpha_mode` **derives lazily, and cannot be captured at construction.** §11.3 said the default comes
1245
+ from `color_space` "at the point that constructs the image", which would have been wrong for the same
1246
+ reason §11.2 was: nine places assign `color_space` *after* the image exists, and two of them assign
1247
+ `ColorSpace.None` — `shade_load_data_image` and `build_lut_fog_parameters_jendersie_deon`. A value
1248
+ captured at construction would premultiply exactly the data textures the rule exists to protect. It
1249
+ is a getter over a private override: unset, it derives; set, it wins and keeps winning.
1250
+
1251
+ **`AlphaMode.Unspecified` is a member of the enum**, not a `undefined` sitting in a field typed as
1252
+ one. "Nothing has said yet" is a state the field genuinely has, so it gets a name; assigning it is
1253
+ also how a caller goes back to deriving after an override. The getter resolves and never returns it,
1254
+ which is its own test.
1255
+
1256
+ **The format lives in `format/texture_format_from_shade_image.js`**, not on `ShadeImage`. After the
1257
+ move the image imports neither `isImageBitmap` nor the inference — it is back to describing data and
1258
+ nothing else.
1259
+
1260
+ `infer_format_from_parameters` moved to
1261
+ [`format/infer_format_from_parameters.js`](format/infer_format_from_parameters.js), and its spec with
1262
+ it. `ShadeImage` needed it, and a data model importing from the descriptor *builder* is a dependency
1263
+ pointing the wrong way. The spec's assertions are byte-identical; only its import paths changed.
1264
+
1265
+ `texture_write_to_gpu` now asks `image.alpha_mode` instead of re-deriving `color_space !== None`
1266
+ inline. Provably the same decision today — which is what the untouched regression test shows — and
1267
+ the point is that it is now a decision an image can be *told*.
1268
+
1269
+ Steps 8 and 9 are separable on purpose: **a KTX2 file that already holds BC7 needs no transcoder**,
1270
+ and that is what first-party tooling produces. The transcoder is only for ETC1S/UASTC payloads, which
1271
+ is third-party content, which is why it can wait behind everything else.
1272
+
1273
+ ---
1274
+
1275
+ ## 10. Testing
1276
+
1277
+ Per the tiers this repo uses:
1278
+
1279
+ 1. **The enum does not drift.** `Object.values(TextureFormat)` and `gpu_texture_format_names()` are
1280
+ the same set — the one check that keeps §2.1's enum a view of the format table rather than a
1281
+ second copy of it. Cheap, and it fails the moment a new format lands in one and not the other.
1282
+ 2. **Node-side import.** A spec that imports `TextureDescriptor` with the vitest globals *removed*
1283
+ proves the §2.1 shim actually replaced the load-time global read, rather than the stub quietly
1284
+ continuing to cover for it. Without this the fix is untested by construction.
1285
+ 3. **Pure functions.** `gpu_texture_format_row_bytes` against hand-computed BC7/BC5/ASTC sizes
1286
+ including non-block-multiple widths — a 5-texel-wide BC7 row is two blocks, the case the current
1287
+ uploader's arithmetic gets wrong. `ShadeTexelData` rejecting a partial chain, and a payload whose
1288
+ byte length disagrees with its declared format and size. `texture_format_preference` returning
1289
+ only formats in the support set, and returning empty rather than guessing.
1290
+ 4. **Software device.** Build a `ShadeImage` over a synthetic BC7 payload, run it through
1291
+ `TextureManager`, and assert the mock texture's per-level bytes are exactly the input — end to end
1292
+ through `texture_descriptor_from_texture_shade` and `texture_write_level`, with no GPU. This is
1293
+ the test that proves the two paths really are one path.
1294
+ 5. **Streaming.** The same, but delivering levels out of order, coarsest first: each arrival writes
1295
+ only its own level, and `lodMinClamp` tracks the finest resident one.
1296
+ 6. **Descriptor, and the §3.2 instruction.** Over a compressed image with the flag clear:
1297
+ `RENDER_ATTACHMENT` is not set and `mipLevelCount` equals the supplied level count. With the flag
1298
+ set: throws, **for one supplied level and for a full chain alike** — the case that would regress
1299
+ silently if anyone reintroduced a level-count comparison. Over an uncompressed image with a full
1300
+ chain and the flag set: the generator is scheduled and only level 0 is uploaded.
1301
+ 7. **Alignment, for §7's benefit.** `VTPhysicalCache` constructed with a compressed layer asserts on a
1302
+ `slot_size` that is not a block multiple — the one check that keeps the tile path honest, worth
1303
+ writing before the tile path exists.
1304
+ 8. **Playground.** One scene, loaded with support forced to bc / astc / none, compared visually and by
1305
+ `TextureManager.gpu_memory_usage` — which, after §6.1, is the number that shows the feature
1306
+ working: the same scene at roughly a quarter of the VRAM.
1307
+
1308
+ ---
1309
+
1310
+ ## 11. Adversarial pass
1311
+
1312
+ Written against revision 4, before any code. Four findings change the design; two are corrections of
1313
+ fact. Each was checked against the tree rather than against the argument that produced it.
1314
+
1315
+ ### 11.1 "No shader changes" is false, and §5.2 is what makes it false
1316
+
1317
+ **§0 claims no shader changes. §5.2 recommends `bc5-rg-unorm` for normal maps. These cannot both
1318
+ stand.**
1319
+
1320
+ BC5 has two channels. A sample returns `(x, y, 0, 1)`. Every normal-map read in the tree is
1321
+ `.rgb * 2.0 - 1.0`, at five sites:
1322
+
1323
+ | Site | |
1324
+ |---|---|
1325
+ | [`fragment_gbuffer.js:325`](../material/standard/fragment_gbuffer.js:325) | the main deferred path |
1326
+ | [`fragment_gbuffer_vt.js:129`](../material/standard/fragment_gbuffer_vt.js:129) | virtual textures |
1327
+ | [`chunk_sample_material_data.js:205`](../path_tracer/shader/chunk_sample_material_data.js:205) | path tracer |
1328
+ | [`chunk_forward_shade_standard_fragment_ibl.js:40`](../rasterize/native/oit/chunk_forward_shade_standard_fragment_ibl.js:40) | forward / OIT |
1329
+ | [`chunk_forward_shade_standard_fragment_brick4.js:37`](../rasterize/native/oit/chunk_forward_shade_standard_fragment_brick4.js:37) | forward / OIT |
1330
+
1331
+ Against a BC5 texture each of those computes `z = 0 * 2 - 1 = -1`: every normal points directly into
1332
+ the surface. Not subtly wrong — no lighting at all.
1333
+
1334
+ **Resolution: always reconstruct Z, never read it.** A tangent-space normal has `z > 0` by
1335
+ construction, so `z = sqrt(max(0, 1 - x² - y²))` is exact for a unit normal and is *equally correct
1336
+ for an RGB source*, whose stored blue channel is redundant. One chunk, applied at all five sites,
1337
+ serving BC5 and RGB identically — no variant, no branch on format, nothing that would need
1338
+ [per-format codegen](../../wgsl).
1339
+
1340
+ So the honest statement is not "no shader changes" but:
1341
+
1342
+ > **No shader *variants*.** One decode change, shared by compressed and uncompressed alike, and after
1343
+ > it the sampling path does not know which it has.
1344
+
1345
+ Which is the invariant of §0 restated, rather than a hole in it — but it is a change to five shaders
1346
+ and it belongs in §9, not in a sentence claiming it does not exist. Two caveats worth stating: this
1347
+ assumes tangent-space normals (what glTF's `normalTexture` is, by specification), and the VT normal
1348
+ default `vec4(0.5, 0.5, 1.0, 1.0)` still reconstructs to `z = 1` unchanged.
1349
+
1350
+ The alternative — BC7 for normals, keeping `.rgb` — costs no shader work and real quality: BC7 spends
1351
+ its budget on three channels where BC5 spends the same 8 bpp on two. Recommendation stands with BC5,
1352
+ now that its actual price is on the table rather than hidden behind a false claim.
1353
+
1354
+ ### 11.2 `ShadeImage.format` cannot be memoised — the inputs are mutated after construction
1355
+
1356
+ §3.1 says format is `infer_format_from_parameters(...)` "and memoised". It cannot be.
1357
+ [`gltf_create_material.js:86`](../loader/gltf/gltf_create_material.js:86) and
1358
+ [`:54`](../loader/gltf/gltf_create_material.js:54) set `texture.image.color_space = ColorSpace.SRGB`
1359
+ **after** [`load_gltf.js:404`](../loader/gltf/load_gltf.js:404) has already built the `ShadeImage`.
1360
+ A format cached at construction is `rgba8unorm` forever, and every albedo and emissive texture in
1361
+ every glTF silently loses its sRGB decode.
1362
+
1363
+ `format` is therefore a plain getter, recomputed. It is a table lookup and a string concat on a path
1364
+ that runs once per texture, not per frame.
1365
+
1366
+ §3.1 was also inaccurate about *what* it computes:
1367
+ [`inferFormatFromImage`](texture_descriptor_from_texture_shade.js:99) has two arms, and the
1368
+ `ImageBitmap` one does **not** call `infer_format_from_parameters` — it returns `rgba16float`,
1369
+ `rgba8unorm-srgb` or `rgba8unorm` directly. Both arms move to `ShadeImage.format` unchanged.
1370
+
1371
+ **A pre-existing bug this surfaces, worth its own fix.** Images are deduplicated
1372
+ ([`load_gltf.js:403`](../loader/gltf/load_gltf.js:403) indexes by glTF image index), so one
1373
+ `ShadeImage` can back two textures. `gltf_create_material` then assigns `color_space` per *material
1374
+ slot*. An image used as both a base-colour and an ORM map gets whichever material was visited last —
1375
+ today that flips a decode, and under this plan it flips a format. Not caused by this plan, made
1376
+ load-bearing by it.
1377
+
1378
+ ### 11.3 The `alpha_mode` default silently premultiplies every data texture
1379
+
1380
+ §3.3 defaults `alpha_mode` to `Straight` and premultiplies `Straight` + uncompressed. Today's rule is
1381
+ `premultiplyAlpha = image.color_space !== ColorSpace.None`
1382
+ ([texture_write_to_gpu.js:217](texture_write_to_gpu.js:217)) — so **non-colour data is never
1383
+ premultiplied**, and under §3.3 as written it would be. Height fields, masks, ORM maps, LUTs: all
1384
+ scaled by their alpha channel on upload.
1385
+
1386
+ There is a regression test named for exactly this — `"color_space None skips premultiplication
1387
+ entirely"` ([texture_write_to_gpu.spec.js:84](texture_write_to_gpu.spec.js:84)) — which §9 step 2
1388
+ claims will pass unmodified. It would not.
1389
+
1390
+ **Resolution: three states, not two.** Premultiplication is not a property of the alpha channel; it
1391
+ is a property of whether the channel *means* opacity.
1392
+
1393
+ ```js
1394
+ export const AlphaMode = {
1395
+ Opaque: 0, // there is no meaningful alpha — data. Never premultiply. (ColorSpace.None today)
1396
+ Straight: 1, // colour with straight alpha. Premultiply if uncompressed; throw if block.
1397
+ Premultiplied: 2, // already done, by the encoder or by copyExternalImageToTexture
1398
+ };
1399
+ ```
1400
+
1401
+ The default is derived from `color_space` at the point that constructs the image — which preserves
1402
+ today's behaviour exactly, keeps the regression test green, and gives a compressed payload somewhere
1403
+ truthful to declare itself. Note this is a *default*, not an inference at use time; §3.2's rule
1404
+ against data-dependent behaviour concerns instructions being reinterpreted, and there is no
1405
+ instruction here to reinterpret.
1406
+
1407
+ ### 11.4 `texture_write_to_gpu` cannot honour §3.2, because it cannot see the flag
1408
+
1409
+ §3.2 says that when generation is requested the supplied levels above 0 are not uploaded. Nothing
1410
+ implements that: the signature is `texture_write_to_gpu(image, texture, queue)`
1411
+ ([:189](texture_write_to_gpu.js:189)), with two non-spec callers
1412
+ ([`TextureManager.js:147`](TextureManager.js:147),
1413
+ [`GPULightCollection.js:201`](../light/GPULightCollection.js:201)), and `ShadeTextureFlags` lives on
1414
+ the `ShadeTexture`, which it never receives.
1415
+
1416
+ **Resolution:** the uploader takes an explicit level range — `texture_write_to_gpu(image, texture,
1417
+ queue, { levels })` — defaulting to all of them. `TextureManager` passes `[0, 1)` when it is about to
1418
+ schedule the generator. This is the same parameter mip streaming needs (§3.4) in order to write one
1419
+ arriving level, so it is one addition serving both, not a special case for generation.
1420
+
1421
+ ### 11.5 Per-format resolution needs a manifest, which §5.1 does not have
1422
+
1423
+ §5.1's loop asks `resolve(logical_name, format)` and says a description "may not exist". It does not
1424
+ say how the client knows. If existence is discovered by requesting, every texture on a device whose
1425
+ preferred format was not built costs a 404 round-trip — possibly several, walking the preference
1426
+ list — before the fallback. On a streaming engine that is the worst place to put a stall.
1427
+
1428
+ The plan needs a **per-texture set of available formats, known before the first request**: a field in
1429
+ whatever index already names the texture, or in the alias list that
1430
+ [`loadAssetAliasList`](../../../engine/asset/loadAssetAliasList.js) reads. Cheap, but it is a
1431
+ prerequisite of §5.1 rather than a detail of it, and §9 step 7 currently reads as though the
1432
+ resolution rule were the whole job.
1433
+
1434
+ ### 11.6 Corrections of fact
1435
+
1436
+ - **BC1 has alpha.** §6.4 lists `bc1` among formats with "no usable alpha". WebGPU exposes it only as
1437
+ `bc1-rgba-unorm`, which decodes 1-bit punch-through alpha. `shade_texture_might_have_alpha` must
1438
+ answer **true** for it. `bc4`, `bc5` and `etc2-rgb8unorm` in that list are correct;
1439
+ `etc2-rgb8a1unorm` would be another true.
1440
+ - **The 256-alignment arithmetic in §4.4 is stated too strongly.** `ceil(w/4) × 16 ≡ 0 (mod 256)`
1441
+ requires `ceil(w/4)` to be a multiple of 16, which `w = 61…64` also satisfies — not only multiples
1442
+ of 64. For a mip chain, where widths are powers of two, the practical rule is `w ≥ 64`. The
1443
+ conclusion (pad the rows) is unchanged; the stated condition was wrong.
1444
+ - **`gpu_texture_format_info` has three consumers, not two** — `SoftwareGPUDevice`,
1445
+ `SoftwareGPUTexture`, `frame_graph_extract_topology`. Said twice, wrong twice.
1446
+ - **The LZ4 reference in §1 is vestigial.** It survives from revision 1's container, which revision 2
1447
+ deleted in favour of per-format resources (§5). Nothing in the plan now wraps block payloads in
1448
+ anything — transport compression is the delivery layer's business, and a BC7 payload compresses
1449
+ ~10–15 % anyway. Drop the bullet rather than leave a dependency the plan does not use.
1450
+
1451
+ ### 11.7 What survived
1452
+
1453
+ The load-bearing claims were checked and hold: `gpu_texture_format_info` is block-correct and is the
1454
+ right single table; `VTPhysicalCache` really is per-layer format-parameterised, so §7 stays small;
1455
+ `lodMinClamp` exists; `gpu_texture_format_srg_to_linear` and `gpu_texture_format_to_color_space`
1456
+ really are already compressed-aware; `@webgpu/types` really is absent (`"webgpu"` in `package.json`
1457
+ is a keyword, not a dependency); `GPUTextureFormat` really is never read as a runtime value; and
1458
+ `TextureDescriptor.js:61` really does read `GPUTextureUsage` at module load.
1459
+
1460
+ §0's central move — format on the image rather than inferred at upload — is untouched by all six
1461
+ findings. Three of them (11.2, 11.3, 11.4) are cases where the *transition* was underspecified rather
1462
+ than the destination being wrong, which is the failure mode a plan of this shape should expect.