@clipkit/runtime 1.0.0

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 (219) hide show
  1. package/LICENSE +54 -0
  2. package/README.md +98 -0
  3. package/dist/animation/easings.d.ts +9 -0
  4. package/dist/animation/easings.d.ts.map +1 -0
  5. package/dist/animation/easings.js +230 -0
  6. package/dist/animation/easings.js.map +1 -0
  7. package/dist/animation/expr.d.ts +44 -0
  8. package/dist/animation/expr.d.ts.map +1 -0
  9. package/dist/animation/expr.js +236 -0
  10. package/dist/animation/expr.js.map +1 -0
  11. package/dist/animation/keyframes.d.ts +23 -0
  12. package/dist/animation/keyframes.d.ts.map +1 -0
  13. package/dist/animation/keyframes.js +117 -0
  14. package/dist/animation/keyframes.js.map +1 -0
  15. package/dist/animation/motion-path.d.ts +18 -0
  16. package/dist/animation/motion-path.d.ts.map +1 -0
  17. package/dist/animation/motion-path.js +269 -0
  18. package/dist/animation/motion-path.js.map +1 -0
  19. package/dist/animation/noise1d.d.ts +5 -0
  20. package/dist/animation/noise1d.d.ts.map +1 -0
  21. package/dist/animation/noise1d.js +27 -0
  22. package/dist/animation/noise1d.js.map +1 -0
  23. package/dist/animation/presets.d.ts +60 -0
  24. package/dist/animation/presets.d.ts.map +1 -0
  25. package/dist/animation/presets.js +221 -0
  26. package/dist/animation/presets.js.map +1 -0
  27. package/dist/assets/cache.d.ts +18 -0
  28. package/dist/assets/cache.d.ts.map +1 -0
  29. package/dist/assets/cache.js +56 -0
  30. package/dist/assets/cache.js.map +1 -0
  31. package/dist/assets/fonts.d.ts +20 -0
  32. package/dist/assets/fonts.d.ts.map +1 -0
  33. package/dist/assets/fonts.js +127 -0
  34. package/dist/assets/fonts.js.map +1 -0
  35. package/dist/assets/loader.d.ts +18 -0
  36. package/dist/assets/loader.d.ts.map +1 -0
  37. package/dist/assets/loader.js +87 -0
  38. package/dist/assets/loader.js.map +1 -0
  39. package/dist/assets/lut.d.ts +5 -0
  40. package/dist/assets/lut.d.ts.map +1 -0
  41. package/dist/assets/lut.js +77 -0
  42. package/dist/assets/lut.js.map +1 -0
  43. package/dist/assets/media-time.d.ts +31 -0
  44. package/dist/assets/media-time.d.ts.map +1 -0
  45. package/dist/assets/media-time.js +65 -0
  46. package/dist/assets/media-time.js.map +1 -0
  47. package/dist/assets/mp4-frame-source.d.ts +44 -0
  48. package/dist/assets/mp4-frame-source.d.ts.map +1 -0
  49. package/dist/assets/mp4-frame-source.js +387 -0
  50. package/dist/assets/mp4-frame-source.js.map +1 -0
  51. package/dist/audio/encoder.d.ts +31 -0
  52. package/dist/audio/encoder.d.ts.map +1 -0
  53. package/dist/audio/encoder.js +96 -0
  54. package/dist/audio/encoder.js.map +1 -0
  55. package/dist/audio/fades.d.ts +16 -0
  56. package/dist/audio/fades.d.ts.map +1 -0
  57. package/dist/audio/fades.js +43 -0
  58. package/dist/audio/fades.js.map +1 -0
  59. package/dist/audio/limiter.d.ts +8 -0
  60. package/dist/audio/limiter.d.ts.map +1 -0
  61. package/dist/audio/limiter.js +39 -0
  62. package/dist/audio/limiter.js.map +1 -0
  63. package/dist/audio/loader.d.ts +6 -0
  64. package/dist/audio/loader.d.ts.map +1 -0
  65. package/dist/audio/loader.js +42 -0
  66. package/dist/audio/loader.js.map +1 -0
  67. package/dist/audio/mixer.d.ts +17 -0
  68. package/dist/audio/mixer.d.ts.map +1 -0
  69. package/dist/audio/mixer.js +204 -0
  70. package/dist/audio/mixer.js.map +1 -0
  71. package/dist/audio/varispeed.d.ts +24 -0
  72. package/dist/audio/varispeed.d.ts.map +1 -0
  73. package/dist/audio/varispeed.js +114 -0
  74. package/dist/audio/varispeed.js.map +1 -0
  75. package/dist/audio/wav.d.ts +6 -0
  76. package/dist/audio/wav.d.ts.map +1 -0
  77. package/dist/audio/wav.js +62 -0
  78. package/dist/audio/wav.js.map +1 -0
  79. package/dist/backend/backend.d.ts +579 -0
  80. package/dist/backend/backend.d.ts.map +1 -0
  81. package/dist/backend/backend.js +17 -0
  82. package/dist/backend/backend.js.map +1 -0
  83. package/dist/backend/webgl-backend.d.ts +97 -0
  84. package/dist/backend/webgl-backend.d.ts.map +1 -0
  85. package/dist/backend/webgl-backend.js +2142 -0
  86. package/dist/backend/webgl-backend.js.map +1 -0
  87. package/dist/backend/webgpu-backend.d.ts +121 -0
  88. package/dist/backend/webgpu-backend.d.ts.map +1 -0
  89. package/dist/backend/webgpu-backend.js +2481 -0
  90. package/dist/backend/webgpu-backend.js.map +1 -0
  91. package/dist/compositor/bitfont.d.ts +8 -0
  92. package/dist/compositor/bitfont.d.ts.map +1 -0
  93. package/dist/compositor/bitfont.js +52 -0
  94. package/dist/compositor/bitfont.js.map +1 -0
  95. package/dist/compositor/camera.d.ts +5 -0
  96. package/dist/compositor/camera.d.ts.map +1 -0
  97. package/dist/compositor/camera.js +114 -0
  98. package/dist/compositor/camera.js.map +1 -0
  99. package/dist/compositor/color.d.ts +26 -0
  100. package/dist/compositor/color.d.ts.map +1 -0
  101. package/dist/compositor/color.js +189 -0
  102. package/dist/compositor/color.js.map +1 -0
  103. package/dist/compositor/element-renderers/caption.d.ts +4 -0
  104. package/dist/compositor/element-renderers/caption.d.ts.map +1 -0
  105. package/dist/compositor/element-renderers/caption.js +376 -0
  106. package/dist/compositor/element-renderers/caption.js.map +1 -0
  107. package/dist/compositor/element-renderers/group.d.ts +12 -0
  108. package/dist/compositor/element-renderers/group.d.ts.map +1 -0
  109. package/dist/compositor/element-renderers/group.js +259 -0
  110. package/dist/compositor/element-renderers/group.js.map +1 -0
  111. package/dist/compositor/element-renderers/image.d.ts +4 -0
  112. package/dist/compositor/element-renderers/image.d.ts.map +1 -0
  113. package/dist/compositor/element-renderers/image.js +97 -0
  114. package/dist/compositor/element-renderers/image.js.map +1 -0
  115. package/dist/compositor/element-renderers/lit.d.ts +6 -0
  116. package/dist/compositor/element-renderers/lit.d.ts.map +1 -0
  117. package/dist/compositor/element-renderers/lit.js +82 -0
  118. package/dist/compositor/element-renderers/lit.js.map +1 -0
  119. package/dist/compositor/element-renderers/particles.d.ts +4 -0
  120. package/dist/compositor/element-renderers/particles.d.ts.map +1 -0
  121. package/dist/compositor/element-renderers/particles.js +212 -0
  122. package/dist/compositor/element-renderers/particles.js.map +1 -0
  123. package/dist/compositor/element-renderers/shape.d.ts +4 -0
  124. package/dist/compositor/element-renderers/shape.d.ts.map +1 -0
  125. package/dist/compositor/element-renderers/shape.js +171 -0
  126. package/dist/compositor/element-renderers/shape.js.map +1 -0
  127. package/dist/compositor/element-renderers/svg.d.ts +4 -0
  128. package/dist/compositor/element-renderers/svg.d.ts.map +1 -0
  129. package/dist/compositor/element-renderers/svg.js +210 -0
  130. package/dist/compositor/element-renderers/svg.js.map +1 -0
  131. package/dist/compositor/element-renderers/text.d.ts +25 -0
  132. package/dist/compositor/element-renderers/text.d.ts.map +1 -0
  133. package/dist/compositor/element-renderers/text.js +1358 -0
  134. package/dist/compositor/element-renderers/text.js.map +1 -0
  135. package/dist/compositor/element-renderers/video.d.ts +12 -0
  136. package/dist/compositor/element-renderers/video.d.ts.map +1 -0
  137. package/dist/compositor/element-renderers/video.js +109 -0
  138. package/dist/compositor/element-renderers/video.js.map +1 -0
  139. package/dist/compositor/fit.d.ts +18 -0
  140. package/dist/compositor/fit.d.ts.map +1 -0
  141. package/dist/compositor/fit.js +106 -0
  142. package/dist/compositor/fit.js.map +1 -0
  143. package/dist/compositor/lighting.d.ts +63 -0
  144. package/dist/compositor/lighting.d.ts.map +1 -0
  145. package/dist/compositor/lighting.js +141 -0
  146. package/dist/compositor/lighting.js.map +1 -0
  147. package/dist/compositor/mat4.d.ts +88 -0
  148. package/dist/compositor/mat4.d.ts.map +1 -0
  149. package/dist/compositor/mat4.js +245 -0
  150. package/dist/compositor/mat4.js.map +1 -0
  151. package/dist/compositor/project.d.ts +24 -0
  152. package/dist/compositor/project.d.ts.map +1 -0
  153. package/dist/compositor/project.js +105 -0
  154. package/dist/compositor/project.js.map +1 -0
  155. package/dist/compositor/render-context.d.ts +194 -0
  156. package/dist/compositor/render-context.d.ts.map +1 -0
  157. package/dist/compositor/render-context.js +10 -0
  158. package/dist/compositor/render-context.js.map +1 -0
  159. package/dist/compositor/resolve.d.ts +80 -0
  160. package/dist/compositor/resolve.d.ts.map +1 -0
  161. package/dist/compositor/resolve.js +276 -0
  162. package/dist/compositor/resolve.js.map +1 -0
  163. package/dist/compositor/scene.d.ts +10 -0
  164. package/dist/compositor/scene.d.ts.map +1 -0
  165. package/dist/compositor/scene.js +658 -0
  166. package/dist/compositor/scene.js.map +1 -0
  167. package/dist/compositor/transform.d.ts +73 -0
  168. package/dist/compositor/transform.d.ts.map +1 -0
  169. package/dist/compositor/transform.js +229 -0
  170. package/dist/compositor/transform.js.map +1 -0
  171. package/dist/compositor/unit.d.ts +27 -0
  172. package/dist/compositor/unit.d.ts.map +1 -0
  173. package/dist/compositor/unit.js +74 -0
  174. package/dist/compositor/unit.js.map +1 -0
  175. package/dist/encoder/exporter.d.ts +95 -0
  176. package/dist/encoder/exporter.d.ts.map +1 -0
  177. package/dist/encoder/exporter.js +341 -0
  178. package/dist/encoder/exporter.js.map +1 -0
  179. package/dist/encoder/index.d.ts +3 -0
  180. package/dist/encoder/index.d.ts.map +1 -0
  181. package/dist/encoder/index.js +2 -0
  182. package/dist/encoder/index.js.map +1 -0
  183. package/dist/index.d.ts +12 -0
  184. package/dist/index.d.ts.map +1 -0
  185. package/dist/index.js +27 -0
  186. package/dist/index.js.map +1 -0
  187. package/dist/logger.d.ts +13 -0
  188. package/dist/logger.d.ts.map +1 -0
  189. package/dist/logger.js +32 -0
  190. package/dist/logger.js.map +1 -0
  191. package/dist/runtime.d.ts +216 -0
  192. package/dist/runtime.d.ts.map +1 -0
  193. package/dist/runtime.js +1012 -0
  194. package/dist/runtime.js.map +1 -0
  195. package/dist/svg/morph.d.ts +6 -0
  196. package/dist/svg/morph.d.ts.map +1 -0
  197. package/dist/svg/morph.js +62 -0
  198. package/dist/svg/morph.js.map +1 -0
  199. package/dist/svg/svg-renderer.d.ts +18 -0
  200. package/dist/svg/svg-renderer.d.ts.map +1 -0
  201. package/dist/svg/svg-renderer.js +142 -0
  202. package/dist/svg/svg-renderer.js.map +1 -0
  203. package/dist/text/caption-chunk.d.ts +17 -0
  204. package/dist/text/caption-chunk.d.ts.map +1 -0
  205. package/dist/text/caption-chunk.js +76 -0
  206. package/dist/text/caption-chunk.js.map +1 -0
  207. package/dist/text/font-atlas.d.ts +63 -0
  208. package/dist/text/font-atlas.d.ts.map +1 -0
  209. package/dist/text/font-atlas.js +225 -0
  210. package/dist/text/font-atlas.js.map +1 -0
  211. package/dist/text/measure.d.ts +38 -0
  212. package/dist/text/measure.d.ts.map +1 -0
  213. package/dist/text/measure.js +164 -0
  214. package/dist/text/measure.js.map +1 -0
  215. package/dist/text/text-animation.d.ts +52 -0
  216. package/dist/text/text-animation.d.ts.map +1 -0
  217. package/dist/text/text-animation.js +133 -0
  218. package/dist/text/text-animation.js.map +1 -0
  219. package/package.json +47 -0
@@ -0,0 +1,2481 @@
1
+ // WebGPU implementation of the Backend interface.
2
+ //
3
+ // Design choices made explicitly to avoid every v0 bug class:
4
+ // - Corner radius is normalized (0..0.5) at the interface boundary, not pixels.
5
+ // - Shape type is encoded as f32 (0.0 / 1.0), compared with > 0.5 in the shader.
6
+ // Avoids the v0 u32-vs-f32 buffer-write mismatch.
7
+ // - Premultiplied alpha throughout: textures uploaded with `premultipliedAlpha: true`,
8
+ // shaders assume premultiplied input, swap chain configured for premultiplied output.
9
+ // - Per-draw uniform buffer via `mappedAtCreation: true` — single allocation per
10
+ // draw call, no queue.writeBuffer overhead, no per-frame buffer pool to maintain.
11
+ // - Single shared vertex buffer (unit quad with default UVs). UV sub-rect is
12
+ // passed as a uniform and applied in the vertex shader. No per-character
13
+ // vertex buffer creation (the v0 pattern that may have caused the text bug).
14
+ // - All shaders inline here. <200 lines of WGSL total; splitting across files
15
+ // adds indirection without payoff at this size.
16
+ import { composeQuadTransform, homographyToPhysical, invertHomography, projectPixelMatrix } from '../compositor/transform.js';
17
+ import { getLogger } from '../logger.js';
18
+ import { STYLIZE_MODE_INDEX } from './backend.js';
19
+ // ─── Shaders ────────────────────────────────────────────────────────────────
20
+ // Shape pipeline: solid-color rectangles + ellipses + rounded rectangles,
21
+ // with optional shader-level stroke. The stroke band is painted directly
22
+ // from the SDF — no compositing through the fill — so it stays clean
23
+ // against semi-transparent fills.
24
+ // Shadow pipeline: see SHADOW_FS in webgl-backend.ts for the algorithm.
25
+ // Quad is sized to (shape + 2*blur). The SDF computes distance to the
26
+ // inner shape; alpha = 1 - smoothstep(0, blur, dist), so the shadow
27
+ // reaches full opacity at the shape edge and fades over `blur` pixels.
28
+ const SHADOW_SHADER = /* wgsl */ `
29
+ struct ShadowUniforms {
30
+ transform: mat4x4<f32>, // 64 bytes, offset 0
31
+ color: vec4<f32>, // 16 bytes, offset 64 — shadow color, premultiplied
32
+ cornerRadius: f32, // 4 bytes, offset 80
33
+ shapeType: f32, // 4 bytes, offset 84
34
+ blur: f32, // 4 bytes, offset 88
35
+ _pad0: f32, // 4 bytes, offset 92 — std140 alignment
36
+ size: vec2<f32>, // 8 bytes, offset 96 — shape size
37
+ quadSize: vec2<f32>, // 8 bytes, offset 104 — rendered-quad size
38
+ _pad1: vec4<f32>, // 16 bytes, offset 112 — pad to 128
39
+ }
40
+ @group(0) @binding(0) var<uniform> u: ShadowUniforms;
41
+
42
+ struct VsOut {
43
+ @builtin(position) position: vec4<f32>,
44
+ @location(0) uv: vec2<f32>,
45
+ }
46
+
47
+ @vertex
48
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
49
+ var out: VsOut;
50
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
51
+ out.uv = uv;
52
+ return out;
53
+ }
54
+
55
+ @fragment
56
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
57
+ let p = in.uv * u.quadSize;
58
+ let shapeHalf = u.size * 0.5;
59
+ let quadHalf = u.quadSize * 0.5;
60
+ let ps = p - quadHalf + shapeHalf;
61
+ var dist: f32;
62
+ if (u.shapeType > 0.5) {
63
+ let d = (ps - shapeHalf) / shapeHalf;
64
+ dist = (sqrt(dot(d, d)) - 1.0) * min(shapeHalf.x, shapeHalf.y);
65
+ } else {
66
+ let r = u.cornerRadius;
67
+ let q = abs(ps - shapeHalf) - shapeHalf + vec2<f32>(r, r);
68
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
69
+ }
70
+ if (dist > u.blur) { discard; }
71
+ // Symmetric falloff around the shape edge — matches CSS box-shadow's
72
+ // Gaussian-blur erfc shape closely enough: alpha ~1.0 deep inside,
73
+ // ~0.5 at the edge, ~0 at +blur past the edge.
74
+ let alpha = 1.0 - smoothstep(-u.blur, u.blur, dist);
75
+ if (alpha < 0.001) { discard; }
76
+ return u.color * alpha;
77
+ }
78
+ `;
79
+ const SHAPE_SHADER = /* wgsl */ `
80
+ struct ShapeUniforms {
81
+ transform: mat4x4<f32>, // 64 bytes, offset 0
82
+ color: vec4<f32>, // 16 bytes, offset 64 — fill, premultiplied
83
+ strokeColor: vec4<f32>, // 16 bytes, offset 80 — stroke, premultiplied
84
+ cornerRadius: f32, // 4 bytes, offset 96 — PIXELS
85
+ shapeType: f32, // 4 bytes, offset 100 — 0.0 = rect, 1.0 = ellipse
86
+ size: vec2<f32>, // 8 bytes, offset 104 — pixel (width, height)
87
+ strokeWidth: f32, // 4 bytes, offset 112 — PIXELS; 0 disables
88
+ _pad: f32, // 4 bytes, offset 116 — std140 alignment pad
89
+ } // total: 120 bytes (round to 128 for uniform alignment)
90
+ @group(0) @binding(0) var<uniform> u: ShapeUniforms;
91
+
92
+ struct VsOut {
93
+ @builtin(position) position: vec4<f32>,
94
+ @location(0) uv: vec2<f32>,
95
+ }
96
+
97
+ @vertex
98
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
99
+ var out: VsOut;
100
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
101
+ out.uv = uv;
102
+ return out;
103
+ }
104
+
105
+ @fragment
106
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
107
+ let p = in.uv * u.size;
108
+ let half = u.size * 0.5;
109
+ var dist: f32;
110
+ if (u.shapeType > 0.5) {
111
+ // Ellipse — approximate signed pixel distance via normalized space.
112
+ let d = (p - half) / half;
113
+ dist = (sqrt(dot(d, d)) - 1.0) * min(half.x, half.y);
114
+ } else {
115
+ // Rectangle / rounded rectangle SDF. r = 0 collapses to sharp rect.
116
+ let r = u.cornerRadius;
117
+ let q = abs(p - half) - half + vec2<f32>(r, r);
118
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
119
+ }
120
+ // Anti-aliased boundary via screen-space derivative — same approach
121
+ // as the WebGL backend. Band width = 2 × fwidth(dist) for visibly
122
+ // smooth edges even when the canvas is downsampled to a smaller
123
+ // preview.
124
+ let aa = fwidth(dist);
125
+ let outerAlpha = 1.0 - smoothstep(-aa, aa, dist);
126
+ if (outerAlpha < 0.001) { discard; }
127
+
128
+ var base: vec4<f32>;
129
+ if (u.strokeWidth > 0.0) {
130
+ // strokeAlpha = 0 in the fill interior, 1 in the stroke band.
131
+ let strokeAlpha = smoothstep(-u.strokeWidth - aa, -u.strokeWidth + aa, dist);
132
+ base = mix(u.color, u.strokeColor, strokeAlpha);
133
+ } else {
134
+ base = u.color;
135
+ }
136
+ return base * outerAlpha;
137
+ }
138
+ `;
139
+ // Lit pipeline (§4.8): PBR direct-light shading for a shape. Lambert
140
+ // diffuse + GGX/Cook-Torrance specular + Schlick Fresnel, evaluated in
141
+ // WORLD space (the camera-free worldMatrix) so the specular hot-spot is
142
+ // view-dependent and sweeps as the camera moves. Must match the WebGL
143
+ // LIT_SHAPE_FS byte-for-byte in math — preview and export differ by
144
+ // backend. Output is premultiplied.
145
+ // Shared lit uniform block (§4.8). Reused by BOTH the lit-shape and
146
+ // lit-textured shaders. The lit-textured path reinterprets a few leading
147
+ // slots (see drawLitTexturedQuad): `albedo`→premultiplied tint,
148
+ // `strokeAlbedo`→uvRect, `params0.x`→cornerRadius. The PBR fields
149
+ // (normal..envAvg) are identical, which lets PBR_WGSL be shared verbatim.
150
+ const LIT_UNIFORMS_WGSL = /* wgsl */ `
151
+ struct LitUniforms {
152
+ transform: mat4x4<f32>, // offset 0 — clip-space projection
153
+ worldMatrix: mat4x4<f32>, // offset 64 — unit quad → world (camera-free)
154
+ albedo: vec4<f32>, // offset 128 — shape: straight albedo | textured: premul tint
155
+ strokeAlbedo: vec4<f32>, // offset 144 — shape: straight stroke | textured: uvRect
156
+ normal: vec4<f32>, // offset 160 — world face normal (xyz)
157
+ eye: vec4<f32>, // offset 176 — world eye position (xyz)
158
+ ambient: vec4<f32>, // offset 192 — summed ambient color (xyz)
159
+ params0: vec4<f32>, // offset 208 — (cornerRadius, shapeType, strokeWidth, numLights)
160
+ params1: vec4<f32>, // offset 224 — (roughness, metalness, reflectivity, emissive)
161
+ size: vec4<f32>, // offset 240 — (width_px, height_px, _, _)
162
+ lightDir: array<vec4<f32>, 4>, // offset 256 — world light directions (xyz)
163
+ lightColor: array<vec4<f32>, 4>, // offset 320 — light color × intensity (xyz)
164
+ envColor: array<vec4<f32>, 4>, // offset 384 — environment gradient stops (xyz)
165
+ envParams: vec4<f32>, // offset 448 — (stopCount, normalScale, hasNormalMap, _)
166
+ envOffsets: vec4<f32>, // offset 464 — up to 4 stop offsets
167
+ envAvg: vec4<f32>, // offset 480 — mean env color (xyz)
168
+ tangent: vec4<f32>, // offset 496 — world +U for normal mapping (xyz)
169
+ bitangent: vec4<f32>, // offset 512 — world +V (xyz)
170
+ } // total: 528 bytes
171
+ @group(0) @binding(0) var<uniform> u: LitUniforms;
172
+ `;
173
+ // Shared PBR functions: helpers + shadePBR(albedo, N, V). Math-identical
174
+ // to the WebGL PBR_FS_LIB. References the module-scope `u` from
175
+ // LIT_UNIFORMS_WGSL, so it must be concatenated AFTER it.
176
+ const PBR_WGSL = /* wgsl */ `
177
+ const PI = 3.14159265;
178
+ fn ggxD(NdotH: f32, a: f32) -> f32 {
179
+ let a2 = a * a;
180
+ let d = NdotH * NdotH * (a2 - 1.0) + 1.0;
181
+ return a2 / (PI * d * d);
182
+ }
183
+ fn gSchlick(x: f32, k: f32) -> f32 { return x / (x * (1.0 - k) + k); }
184
+ fn sampleEnv(t: f32, count: i32) -> vec3<f32> {
185
+ var c = u.envColor[0].xyz;
186
+ if (count > 1) {
187
+ var last = u.envColor[1].xyz;
188
+ if (count > 2) { last = u.envColor[2].xyz; }
189
+ if (count > 3) { last = u.envColor[3].xyz; }
190
+ let o0 = u.envOffsets.x; let o1 = u.envOffsets.y; let o2 = u.envOffsets.z; let o3 = u.envOffsets.w;
191
+ if (t <= o1) {
192
+ c = mix(u.envColor[0].xyz, u.envColor[1].xyz, clamp((t - o0) / max(o1 - o0, 1e-4), 0.0, 1.0));
193
+ } else if (count > 2 && t <= o2) {
194
+ c = mix(u.envColor[1].xyz, u.envColor[2].xyz, clamp((t - o1) / max(o2 - o1, 1e-4), 0.0, 1.0));
195
+ } else if (count > 3 && t <= o3) {
196
+ c = mix(u.envColor[2].xyz, u.envColor[3].xyz, clamp((t - o2) / max(o3 - o2, 1e-4), 0.0, 1.0));
197
+ } else {
198
+ c = last;
199
+ }
200
+ }
201
+ return c;
202
+ }
203
+ // Shade a fragment given its straight albedo, world normal, view vector.
204
+ fn shadePBR(albedo: vec3<f32>, Nin: vec3<f32>, V: vec3<f32>) -> vec3<f32> {
205
+ var N = Nin;
206
+ if (dot(N, V) < 0.0) { N = -N; } // two-sided
207
+ let NdotV = max(dot(N, V), 1e-4);
208
+ let rough = u.params1.x;
209
+ let metal = u.params1.y;
210
+ let F0 = mix(vec3<f32>(0.04), albedo, metal);
211
+ let a = rough * rough;
212
+ let k = (rough + 1.0) * (rough + 1.0) / 8.0;
213
+ let numLights = i32(u.params0.w);
214
+
215
+ var color = albedo * u.ambient.xyz; // ambient (flat fill) term
216
+ for (var i: i32 = 0; i < 4; i = i + 1) {
217
+ if (i >= numLights) { break; }
218
+ let L = normalize(u.lightDir[i].xyz);
219
+ let H = normalize(V + L);
220
+ let NdotL = max(dot(N, L), 0.0);
221
+ let NdotH = max(dot(N, H), 0.0);
222
+ let VdotH = max(dot(V, H), 0.0);
223
+ let F = F0 + (vec3<f32>(1.0) - F0) * pow(1.0 - VdotH, 5.0);
224
+ let D = ggxD(NdotH, a);
225
+ let G = gSchlick(NdotL, k) * gSchlick(NdotV, k);
226
+ let spec = (D * G) * F / max(4.0 * NdotL * NdotV, 1e-3);
227
+ let kd = (vec3<f32>(1.0) - F) * (1.0 - metal);
228
+ color = color + (kd * albedo + spec) * u.lightColor[i].xyz * NdotL;
229
+ }
230
+ let envCount = i32(u.envParams.x);
231
+ let envIsImage = u.envParams.w > 0.5;
232
+ if (envCount > 0 || envIsImage) {
233
+ let R = reflect(-V, N);
234
+ var sharp: vec3<f32>;
235
+ if (envIsImage) {
236
+ // Equirect (lat-long) sample along the reflection ray. Up = −y.
237
+ let Rn = normalize(R);
238
+ let euv = vec2<f32>(atan2(Rn.x, Rn.z) / (2.0 * PI) + 0.5, acos(clamp(-Rn.y, -1.0, 1.0)) / PI);
239
+ sharp = textureSampleLevel(envTex, samp, euv, 0.0).rgb;
240
+ } else {
241
+ let t = clamp(0.5 - 0.5 * (R.y / max(length(R), 1e-4)), 0.0, 1.0); // up→1, down→0
242
+ sharp = sampleEnv(t, envCount);
243
+ }
244
+ let envc = mix(sharp, u.envAvg.xyz, rough);
245
+ let Fr = F0 + (max(vec3<f32>(1.0 - rough), F0) - F0) * pow(1.0 - NdotV, 5.0);
246
+ let kdEnv = (vec3<f32>(1.0) - Fr) * (1.0 - metal);
247
+ color = color + (kdEnv * albedo * u.envAvg.xyz + envc * Fr) * u.params1.z;
248
+ }
249
+ color = mix(color, albedo, clamp(u.params1.w, 0.0, 1.0)); // emissive
250
+ return clamp(color, vec3<f32>(0.0), vec3<f32>(1.0));
251
+ }
252
+ `;
253
+ // Normal-map perturbation (§4.8 Phase 2). Texture + sampler are passed in
254
+ // so the same helper serves both lit shaders (their bindings differ).
255
+ // envParams.y = normalScale, envParams.z = hasNormalMap.
256
+ const NORMAL_PERTURB_WGSL = /* wgsl */ `
257
+ fn perturbNormal(N: vec3<f32>, uv: vec2<f32>, nmap: texture_2d<f32>, nsamp: sampler) -> vec3<f32> {
258
+ if (u.envParams.z < 0.5) { return N; }
259
+ let s = textureSample(nmap, nsamp, uv).rgb * 2.0 - 1.0;
260
+ let sc = s.xy * u.envParams.y;
261
+ return normalize(sc.x * normalize(u.tangent.xyz) + sc.y * normalize(u.bitangent.xyz) + s.z * N);
262
+ }
263
+ `;
264
+ const LIT_SHAPE_SHADER = /* wgsl */ LIT_UNIFORMS_WGSL + `
265
+ @group(0) @binding(1) var samp: sampler;
266
+ @group(0) @binding(2) var normalTex: texture_2d<f32>;
267
+ @group(0) @binding(3) var envTex: texture_2d<f32>;
268
+
269
+ struct VsOut {
270
+ @builtin(position) position: vec4<f32>,
271
+ @location(0) uv: vec2<f32>,
272
+ @location(1) worldPos: vec3<f32>,
273
+ }
274
+
275
+ @vertex
276
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
277
+ var out: VsOut;
278
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
279
+ out.uv = uv;
280
+ let wp = u.worldMatrix * vec4<f32>(pos, 0.0, 1.0);
281
+ out.worldPos = wp.xyz;
282
+ return out;
283
+ }
284
+ ` + PBR_WGSL + NORMAL_PERTURB_WGSL + `
285
+ @fragment
286
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
287
+ let p = in.uv * u.size.xy;
288
+ let half = u.size.xy * 0.5;
289
+ let cornerRadius = u.params0.x;
290
+ let shapeType = u.params0.y;
291
+ let strokeWidth = u.params0.z;
292
+
293
+ var dist: f32;
294
+ if (shapeType > 0.5) {
295
+ let d = (p - half) / half;
296
+ dist = (sqrt(dot(d, d)) - 1.0) * min(half.x, half.y);
297
+ } else {
298
+ let r = cornerRadius;
299
+ let q = abs(p - half) - half + vec2<f32>(r, r);
300
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
301
+ }
302
+ let aa = fwidth(dist);
303
+ let outerAlpha = 1.0 - smoothstep(-aa, aa, dist);
304
+ if (outerAlpha < 0.001) { discard; }
305
+
306
+ var alb = u.albedo;
307
+ if (strokeWidth > 0.0) {
308
+ let sa = smoothstep(-strokeWidth - aa, -strokeWidth + aa, dist);
309
+ alb = mix(u.albedo, u.strokeAlbedo, sa);
310
+ }
311
+ let N = perturbNormal(normalize(u.normal.xyz), in.uv, normalTex, samp);
312
+ let color = shadePBR(alb.rgb, N, normalize(u.eye.xyz - in.worldPos));
313
+ let outA = alb.a * outerAlpha;
314
+ return vec4<f32>(color * outA, outA); // premultiplied
315
+ }
316
+ `;
317
+ // Lit textured quad (§4.8): images, video, flattened group cards shaded
318
+ // as one surface. Albedo = the texture's own (straight) pixels.
319
+ const LIT_TEXTURED_SHADER = /* wgsl */ LIT_UNIFORMS_WGSL + `
320
+ @group(0) @binding(1) var samp: sampler;
321
+ @group(0) @binding(2) var tex: texture_2d<f32>;
322
+ @group(0) @binding(3) var normalTex: texture_2d<f32>;
323
+ @group(0) @binding(4) var envTex: texture_2d<f32>;
324
+
325
+ struct VsOut {
326
+ @builtin(position) position: vec4<f32>,
327
+ @location(0) uv: vec2<f32>,
328
+ @location(1) quadPos: vec2<f32>,
329
+ @location(2) worldPos: vec3<f32>,
330
+ }
331
+
332
+ @vertex
333
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
334
+ var out: VsOut;
335
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
336
+ out.uv = mix(u.strokeAlbedo.xy, u.strokeAlbedo.zw, uv); // strokeAlbedo = uvRect
337
+ out.quadPos = uv;
338
+ let wp = u.worldMatrix * vec4<f32>(pos, 0.0, 1.0);
339
+ out.worldPos = wp.xyz;
340
+ return out;
341
+ }
342
+ ` + PBR_WGSL + NORMAL_PERTURB_WGSL + `
343
+ @fragment
344
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
345
+ let s = textureSample(tex, samp, in.uv); // premultiplied
346
+ let cov = s.a;
347
+ var albedo = select(s.rgb, s.rgb / cov, cov > 0.0); // straight albedo
348
+ let tint = u.albedo; // premultiplied tint
349
+ let tintRgb = select(vec3<f32>(1.0), tint.rgb / tint.a, tint.a > 0.0);
350
+ albedo = albedo * tintRgb;
351
+
352
+ var maskAlpha = 1.0;
353
+ let cornerRadius = u.params0.x;
354
+ if (cornerRadius > 0.0) {
355
+ let p = in.quadPos * u.size.xy;
356
+ let half = u.size.xy * 0.5;
357
+ let r = cornerRadius;
358
+ let q = abs(p - half) - half + vec2<f32>(r, r);
359
+ let dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
360
+ let aa = fwidth(dist);
361
+ maskAlpha = 1.0 - smoothstep(-aa, aa, dist);
362
+ if (maskAlpha < 0.001) { discard; }
363
+ }
364
+ let N = perturbNormal(normalize(u.normal.xyz), in.quadPos, normalTex, samp);
365
+ let color = shadePBR(albedo, N, normalize(u.eye.xyz - in.worldPos));
366
+ let outA = cov * tint.a * maskAlpha;
367
+ return vec4<f32>(color * outA, outA); // premultiplied
368
+ }
369
+ `;
370
+ // Gradient pipeline: shape filled with a linear or radial gradient.
371
+ // Up to 4 stops. Linear is direction-based (cos, sin of angle); radial is
372
+ // distance-from-center based.
373
+ //
374
+ // Stops are declared as four individual vec4 fields and the offset lookup is
375
+ // fully unrolled. WGSL technically allows runtime indexing into arrays and
376
+ // vector swizzles, but Chrome's tint validator has been finicky about it in
377
+ // uniform contexts. Const-indexed access is unambiguously safe.
378
+ const GRADIENT_SHADER = /* wgsl */ `
379
+ struct GradientUniforms {
380
+ transform: mat4x4<f32>, // offset 0, size 64
381
+ flags: vec4<f32>, // offset 64, size 16 — cornerRadius (PIXELS), shapeType, fillType, numStops ("meta" is a reserved WGSL keyword)
382
+ params: vec4<f32>, // offset 80, size 16 — linear:(cos,sin,_,_) | radial:(cx,cy,radius,_)
383
+ size: vec4<f32>, // offset 96, size 16 — (width_px, height_px, _, _)
384
+ stop0: vec4<f32>, // offset 112, size 16
385
+ stop1: vec4<f32>, // offset 128, size 16
386
+ stop2: vec4<f32>, // offset 144, size 16
387
+ stop3: vec4<f32>, // offset 160, size 16
388
+ stopOffsets: vec4<f32>, // offset 176, size 16
389
+ } // total: 192 bytes
390
+ @group(0) @binding(0) var<uniform> u: GradientUniforms;
391
+
392
+ struct VsOut {
393
+ @builtin(position) position: vec4<f32>,
394
+ @location(0) uv: vec2<f32>,
395
+ }
396
+
397
+ @vertex
398
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
399
+ var out: VsOut;
400
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
401
+ out.uv = uv;
402
+ return out;
403
+ }
404
+
405
+ @fragment
406
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
407
+ let uv = in.uv;
408
+ let cornerRadius = u.flags.x;
409
+ let shapeType = u.flags.y;
410
+ let fillType = u.flags.z;
411
+
412
+ // Shape masking — SDF in PIXEL space so corners are circular even on
413
+ // non-square rectangles. Gradient parameter t still runs in UV space.
414
+ let pxSize = u.size.xy;
415
+ let p = uv * pxSize;
416
+ let half = pxSize * 0.5;
417
+
418
+ if (shapeType > 0.5) {
419
+ let d = (p - half) / half;
420
+ if (dot(d, d) > 1.0) { discard; }
421
+ } else if (cornerRadius > 0.0) {
422
+ let r = cornerRadius;
423
+ let q = abs(p - half) - (half - vec2<f32>(r, r));
424
+ let outside = max(q, vec2<f32>(0.0, 0.0));
425
+ if (length(outside) > r) { discard; }
426
+ }
427
+
428
+ // Compute gradient parameter t in [0, 1] (still in UV space — gradient
429
+ // directions are expressed relative to the shape's normalized bounding box).
430
+ var t: f32 = 0.0;
431
+ if (fillType > 0.5) {
432
+ let radius = max(u.params.z, 0.0001);
433
+ t = clamp(distance(uv, u.params.xy) / radius, 0.0, 1.0);
434
+ } else {
435
+ let dir = u.params.xy;
436
+ let centered = uv - vec2<f32>(0.5, 0.5);
437
+ t = clamp(dot(centered, dir) + 0.5, 0.0, 1.0);
438
+ }
439
+
440
+ let off0 = u.stopOffsets.x;
441
+ let off1 = u.stopOffsets.y;
442
+ let off2 = u.stopOffsets.z;
443
+ let off3 = u.stopOffsets.w;
444
+
445
+ var color: vec4<f32>;
446
+ if (t <= off1) {
447
+ let denom = max(off1 - off0, 0.0001);
448
+ let segT = clamp((t - off0) / denom, 0.0, 1.0);
449
+ color = mix(u.stop0, u.stop1, segT);
450
+ } else if (t <= off2) {
451
+ let denom = max(off2 - off1, 0.0001);
452
+ let segT = clamp((t - off1) / denom, 0.0, 1.0);
453
+ color = mix(u.stop1, u.stop2, segT);
454
+ } else if (t <= off3) {
455
+ let denom = max(off3 - off2, 0.0001);
456
+ let segT = clamp((t - off2) / denom, 0.0, 1.0);
457
+ color = mix(u.stop2, u.stop3, segT);
458
+ } else {
459
+ color = u.stop3;
460
+ }
461
+
462
+ return color;
463
+ }
464
+ `;
465
+ // Textured-quad pipeline: images, video frames, text atlas glyphs.
466
+ const TEXTURED_SHADER = /* wgsl */ `
467
+ struct TexturedUniforms {
468
+ transform: mat4x4<f32>, // 64 bytes, offset 0
469
+ uvRect: vec4<f32>, // 16 bytes, offset 64 — (u0, v0, u1, v1)
470
+ tint: vec4<f32>, // 16 bytes, offset 80 — premultiplied
471
+ cornerRadius: f32, // 4 bytes, offset 96
472
+ alphaGamma: f32, // 4 bytes, offset 100 — coverage exponent; 1 = no-op
473
+ size: vec2<f32>, // 8 bytes, offset 104 — pixel (w, h) of the quad
474
+ _pad1: vec4<f32>, // 16 bytes, offset 112 — pad to 128
475
+ } // total: 128 bytes (aligned to 16)
476
+ @group(0) @binding(0) var<uniform> u: TexturedUniforms;
477
+ @group(0) @binding(1) var samp: sampler;
478
+ @group(0) @binding(2) var tex: texture_2d<f32>;
479
+
480
+ struct VsOut {
481
+ @builtin(position) position: vec4<f32>,
482
+ @location(0) uv: vec2<f32>,
483
+ @location(1) quadPos: vec2<f32>,
484
+ }
485
+
486
+ @vertex
487
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
488
+ var out: VsOut;
489
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
490
+ // Remap default 0..1 UVs into the sub-rect specified by uvRect.
491
+ out.uv = mix(u.uvRect.xy, u.uvRect.zw, uv);
492
+ out.quadPos = uv;
493
+ return out;
494
+ }
495
+
496
+ @fragment
497
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
498
+ var sample = textureSample(tex, samp, in.uv); // already premultiplied (texture upload set premultipliedAlpha: true)
499
+ if (u.alphaGamma != 1.0) {
500
+ // Reshape coverage: a' = a^g. Premultiplied, so scale the whole
501
+ // sample by a^(g-1); the max() guard keeps g<1 finite at a=0.
502
+ sample = sample * pow(max(sample.a, 1e-5), u.alphaGamma - 1.0);
503
+ }
504
+ var maskAlpha: f32 = 1.0;
505
+ if (u.cornerRadius > 0.0) {
506
+ // Rounded-rect SDF in quad-local pixel space (matches SHAPE_FS).
507
+ let p = in.quadPos * u.size;
508
+ let half = u.size * 0.5;
509
+ let r = u.cornerRadius;
510
+ let q = abs(p - half) - half + vec2<f32>(r, r);
511
+ let dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
512
+ let aa = fwidth(dist);
513
+ maskAlpha = 1.0 - smoothstep(-aa, aa, dist);
514
+ if (maskAlpha < 0.001) { discard; }
515
+ }
516
+ return sample * u.tint * maskAlpha;
517
+ }
518
+ `;
519
+ // Masked composite: content gated by a second texture's alpha or
520
+ // luminance. Both premultiplied; scaling the whole premultiplied
521
+ // content color by the mask factor is the correct premultiplied op.
522
+ const MASKED_SHADER = /* wgsl */ `
523
+ struct MaskedUniforms {
524
+ transform: mat4x4<f32>, // 64 bytes, offset 0
525
+ tint: vec4<f32>, // 16 bytes, offset 64 — premultiplied
526
+ mode: f32, // 4 bytes, offset 80 — 0 alpha, 1 alpha-inv, 2 luma, 3 luma-inv
527
+ _pad0: f32,
528
+ _pad1: vec2<f32>, // pad to 96
529
+ }
530
+ @group(0) @binding(0) var<uniform> u: MaskedUniforms;
531
+ @group(0) @binding(1) var samp: sampler;
532
+ @group(0) @binding(2) var contentTex: texture_2d<f32>;
533
+ @group(0) @binding(3) var maskTex: texture_2d<f32>;
534
+
535
+ struct VsOut {
536
+ @builtin(position) position: vec4<f32>,
537
+ @location(0) uv: vec2<f32>,
538
+ }
539
+
540
+ @vertex
541
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
542
+ var out: VsOut;
543
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
544
+ out.uv = uv;
545
+ return out;
546
+ }
547
+
548
+ @fragment
549
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
550
+ let c = textureSample(contentTex, samp, in.uv) * u.tint;
551
+ let m = textureSample(maskTex, samp, in.uv);
552
+ var f: f32;
553
+ if (u.mode < 0.5) {
554
+ f = m.a;
555
+ } else if (u.mode < 1.5) {
556
+ f = 1.0 - m.a;
557
+ } else {
558
+ let luma = dot(m.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
559
+ f = select(1.0 - luma, luma, u.mode < 2.5);
560
+ }
561
+ return c * f;
562
+ }
563
+ `;
564
+ // Filter composite: a layer texture drawn 1:1 with an optional separable
565
+ // Gaussian blur pass plus color ops. 25 taps spread over ±3σ; weights
566
+ // computed in-shader and normalized by their sum. Color ops run on
567
+ // STRAIGHT alpha (unpremultiply → brightness → contrast → saturation →
568
+ // re-premultiply). Must match the WebGL FILTERED_FS exactly — preview
569
+ // and export run different backends.
570
+ // Backdrop-blend composite (§4.5) — piecewise blend modes. Reads the
571
+ // isolated element layer + a backdrop snapshot (both premultiplied,
572
+ // surface-sized), runs the W3C separable composite, REPLACES the
573
+ // target (pipeline uses replace blend). Must match WebGL
574
+ // BACKDROP_BLEND_FS exactly. Shares the masked bind-group shape.
575
+ const BACKDROP_BLEND_SHADER = /* wgsl */ `
576
+ struct BBUniforms {
577
+ transform: mat4x4<f32>, // 64 bytes
578
+ mode: f32, // 0 overlay, 1 hard-light, 2 soft-light
579
+ backdropFlipY: f32,
580
+ _pad: vec2<f32>, // pad to 80
581
+ }
582
+ @group(0) @binding(0) var<uniform> u: BBUniforms;
583
+ @group(0) @binding(1) var samp: sampler;
584
+ @group(0) @binding(2) var srcTex: texture_2d<f32>;
585
+ @group(0) @binding(3) var backdropTex: texture_2d<f32>;
586
+
587
+ struct VsOut {
588
+ @builtin(position) position: vec4<f32>,
589
+ @location(0) uv: vec2<f32>,
590
+ }
591
+
592
+ @vertex
593
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
594
+ var out: VsOut;
595
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
596
+ out.uv = uv;
597
+ return out;
598
+ }
599
+
600
+ fn blendCh(mode: f32, cb: f32, cs: f32) -> f32 {
601
+ if (mode < 0.5) { // overlay
602
+ return select(1.0 - 2.0*(1.0-cb)*(1.0-cs), 2.0*cb*cs, cb <= 0.5);
603
+ } else if (mode < 1.5) { // hard-light = overlay(src, backdrop)
604
+ return select(1.0 - 2.0*(1.0-cs)*(1.0-cb), 2.0*cs*cb, cs <= 0.5);
605
+ } else { // soft-light (W3C)
606
+ if (cs <= 0.5) {
607
+ return cb - (1.0 - 2.0*cs) * cb * (1.0 - cb);
608
+ }
609
+ let d = select(sqrt(cb), ((16.0*cb - 12.0)*cb + 4.0)*cb, cb <= 0.25);
610
+ return cb + (2.0*cs - 1.0) * (d - cb);
611
+ }
612
+ }
613
+
614
+ @fragment
615
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
616
+ let s = textureSample(srcTex, samp, in.uv);
617
+ let buv = vec2<f32>(in.uv.x, select(in.uv.y, 1.0 - in.uv.y, u.backdropFlipY > 0.5));
618
+ let b = textureSample(backdropTex, samp, buv);
619
+ let sa = s.a;
620
+ let ba = b.a;
621
+ let Cs = select(vec3<f32>(0.0), s.rgb / sa, sa > 0.0);
622
+ let Cb = select(vec3<f32>(0.0), b.rgb / ba, ba > 0.0);
623
+ let Bc = vec3<f32>(blendCh(u.mode, Cb.r, Cs.r), blendCh(u.mode, Cb.g, Cs.g), blendCh(u.mode, Cb.b, Cs.b));
624
+ let co = sa*(1.0-ba)*Cs + sa*ba*Bc + (1.0-sa)*ba*Cb; // premultiplied
625
+ let ao = sa + ba*(1.0-sa);
626
+ return vec4<f32>(co, ao);
627
+ }
628
+ `;
629
+ const FILTERED_SHADER = /* wgsl */ `
630
+ struct FilteredUniforms {
631
+ transform: mat4x4<f32>, // 64 bytes, offset 0
632
+ tint: vec4<f32>, // 16 bytes, offset 64 — premultiplied
633
+ texel: vec2<f32>, // 8 bytes, offset 80 — blur dir ÷ tex physical dims
634
+ sigma: f32, // 4 bytes, offset 88 — Gaussian σ in PHYSICAL px; 0 = off
635
+ _pad0: f32, // 4 bytes, offset 92
636
+ colorOps: vec4<f32>, // 16 bytes, offset 96 — (brightness, contrast, saturation, hue radians)
637
+ } // total: 112, buffer rounded to 128
638
+ @group(0) @binding(0) var<uniform> u: FilteredUniforms;
639
+ @group(0) @binding(1) var samp: sampler;
640
+ @group(0) @binding(2) var tex: texture_2d<f32>;
641
+
642
+ struct VsOut {
643
+ @builtin(position) position: vec4<f32>,
644
+ @location(0) uv: vec2<f32>,
645
+ }
646
+
647
+ @vertex
648
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
649
+ var out: VsOut;
650
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
651
+ out.uv = uv;
652
+ return out;
653
+ }
654
+
655
+ @fragment
656
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
657
+ var acc: vec4<f32>;
658
+ if (u.sigma > 0.0) {
659
+ acc = vec4<f32>(0.0);
660
+ var wsum: f32 = 0.0;
661
+ for (var i: i32 = -12; i <= 12; i++) {
662
+ let d = f32(i) * u.sigma * 0.25; // taps cover ±3σ
663
+ let w = exp(-0.5 * d * d / (u.sigma * u.sigma));
664
+ acc += textureSampleLevel(tex, samp, in.uv + u.texel * d, 0.0) * w;
665
+ wsum += w;
666
+ }
667
+ acc /= wsum;
668
+ } else {
669
+ acc = textureSampleLevel(tex, samp, in.uv, 0.0);
670
+ }
671
+ let a = acc.a;
672
+ var c = select(vec3<f32>(0.0), acc.rgb / a, a > 0.0);
673
+ c *= u.colorOps.x; // brightness
674
+ c = (c - 0.5) * u.colorOps.y + 0.5; // contrast
675
+ let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722)); // Rec. 709 luma
676
+ c = mix(vec3<f32>(l), c, u.colorOps.z); // saturation
677
+ if (u.colorOps.w != 0.0) { // hue rotate (SVG matrix)
678
+ let hc = cos(u.colorOps.w);
679
+ let hs = sin(u.colorOps.w);
680
+ c = mat3x3<f32>(
681
+ vec3<f32>(0.213 + 0.787*hc - 0.213*hs, 0.213 - 0.213*hc + 0.143*hs, 0.213 - 0.213*hc - 0.787*hs),
682
+ vec3<f32>(0.715 - 0.715*hc - 0.715*hs, 0.715 + 0.285*hc + 0.140*hs, 0.715 - 0.715*hc + 0.715*hs),
683
+ vec3<f32>(0.072 - 0.072*hc + 0.928*hs, 0.072 - 0.072*hc - 0.283*hs, 0.072 + 0.928*hc + 0.072*hs)
684
+ ) * c;
685
+ }
686
+ c = clamp(c, vec3<f32>(0.0), vec3<f32>(1.0));
687
+ return vec4<f32>(c * a, a) * u.tint;
688
+ }
689
+ `;
690
+ // Stylize composite: one effects-array pass (§4.7) — pixelate, dither,
691
+ // halftone, or ascii — drawn 1:1 like the filter composite. Color math
692
+ // runs on STRAIGHT alpha; dot/glyph "ink" scales BOTH color and alpha.
693
+ // Must match the WebGL STYLIZED_FS exactly.
694
+ const STYLIZED_SHADER = /* wgsl */ `
695
+ struct StylizedUniforms {
696
+ transform: mat4x4<f32>, // 64 bytes, offset 0
697
+ tint: vec4<f32>, // 16 bytes, offset 64 — premultiplied
698
+ texSize: vec2<f32>, // 8 bytes, offset 80 — layer PHYSICAL dims
699
+ mode: f32, // 4 bytes, offset 88 — 0 pixelate, 1 dither, 2 halftone, 3 ascii
700
+ p0: f32, // 4 bytes, offset 92 — px params pre-scaled to PHYSICAL
701
+ p1: f32, // 4 bytes, offset 96
702
+ pixelRatio: f32, // 100 — for resolution-independent dither cells
703
+ _pad1: f32, // 104
704
+ _pad2: f32, // 108 — struct size 112
705
+ }
706
+ @group(0) @binding(0) var<uniform> u: StylizedUniforms;
707
+ @group(0) @binding(1) var samp: sampler;
708
+ @group(0) @binding(2) var tex: texture_2d<f32>;
709
+ @group(0) @binding(3) var aux: texture_2d<f32>;
710
+
711
+ const BAYER = array<f32, 16>(
712
+ 0., 8., 2., 10.,
713
+ 12., 4., 14., 6.,
714
+ 3., 11., 1., 9.,
715
+ 15., 7., 13., 5.);
716
+
717
+ struct VsOut {
718
+ @builtin(position) position: vec4<f32>,
719
+ @location(0) uv: vec2<f32>,
720
+ }
721
+
722
+ @vertex
723
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
724
+ var out: VsOut;
725
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
726
+ out.uv = uv;
727
+ return out;
728
+ }
729
+
730
+ fn straight(s: vec4<f32>) -> vec3<f32> {
731
+ return select(vec3<f32>(0.0), s.rgb / s.a, s.a > 0.0);
732
+ }
733
+
734
+ // ── Normative noise (§4.7 fractal_noise / turbulent_displace) ──
735
+ // Must match the WebGL helpers exactly: PCG hash → value noise
736
+ // (quintic fade) → fBM (lacunarity 2, gain 0.5, per-octave seed+o).
737
+ fn pcg(v: u32) -> u32 {
738
+ let s = v * 747796405u + 2891336453u;
739
+ let w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u;
740
+ return (w >> 22u) ^ w;
741
+ }
742
+ fn h01(c: vec3<i32>, seed: u32) -> f32 {
743
+ return f32(pcg(bitcast<u32>(c.x) ^ pcg(bitcast<u32>(c.y) ^ pcg(bitcast<u32>(c.z) ^ pcg(seed))))) / 4294967295.0;
744
+ }
745
+ fn vnoise(p: vec3<f32>, seed: u32) -> f32 {
746
+ let i = floor(p);
747
+ let f = p - i;
748
+ let uu = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
749
+ let c = vec3<i32>(i);
750
+ let n000 = h01(c, seed);
751
+ let n100 = h01(c + vec3<i32>(1, 0, 0), seed);
752
+ let n010 = h01(c + vec3<i32>(0, 1, 0), seed);
753
+ let n110 = h01(c + vec3<i32>(1, 1, 0), seed);
754
+ let n001 = h01(c + vec3<i32>(0, 0, 1), seed);
755
+ let n101 = h01(c + vec3<i32>(1, 0, 1), seed);
756
+ let n011 = h01(c + vec3<i32>(0, 1, 1), seed);
757
+ let n111 = h01(c + vec3<i32>(1, 1, 1), seed);
758
+ return mix(
759
+ mix(mix(n000, n100, uu.x), mix(n010, n110, uu.x), uu.y),
760
+ mix(mix(n001, n101, uu.x), mix(n011, n111, uu.x), uu.y), uu.z);
761
+ }
762
+ fn fbm(p0: vec3<f32>, octaves: i32, seed: u32) -> f32 {
763
+ var p = p0;
764
+ var v = 0.0;
765
+ var amp = 1.0;
766
+ var wsum = 0.0;
767
+ for (var o = 0; o < 8; o++) {
768
+ if (o >= octaves) { break; }
769
+ v += amp * vnoise(p, seed + u32(o));
770
+ wsum += amp;
771
+ p *= 2.0;
772
+ amp *= 0.5;
773
+ }
774
+ return v / wsum;
775
+ }
776
+
777
+ @fragment
778
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {
779
+ let px = in.uv * u.texSize;
780
+ if (u.mode < 0.5) {
781
+ // pixelate — every pixel takes its cell's center sample.
782
+ let cell = max(u.p0, 1.0);
783
+ let center = (floor(px / cell) + 0.5) * cell;
784
+ return textureSampleLevel(tex, samp, center / u.texSize, 0.0) * u.tint;
785
+ } else if (u.mode < 1.5) {
786
+ // dither — per-channel quantize to N levels, 4×4 Bayer threshold.
787
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
788
+ let a = s.a;
789
+ var c = straight(s);
790
+ // Bayer cells of u.p1 (pixel_size) LOGICAL px: divide device px by
791
+ // (pixelRatio · pixel_size). Resolution-independent — stable across
792
+ // preview DPI / export and survives the editor fit-to-stage downscale.
793
+ let ip = vec2<i32>(px / max(u.pixelRatio * u.p1, 1.0));
794
+ var bayer = BAYER; // const arrays can't be dynamically indexed
795
+ let t = (bayer[(ip.y % 4) * 4 + (ip.x % 4)] + 0.5) / 16.0;
796
+ let n = max(u.p0, 2.0) - 1.0;
797
+ c = clamp(floor(c * n + t) / n, vec3<f32>(0.0), vec3<f32>(1.0));
798
+ return vec4<f32>(c * a, a) * u.tint;
799
+ } else if (u.mode < 2.5) {
800
+ // halftone — rotated dot grid, radius ∝ sqrt(luma), cell-color dots.
801
+ let cell = max(u.p0, 2.0);
802
+ let ang = radians(u.p1);
803
+ let cs = cos(ang);
804
+ let sn = sin(ang);
805
+ let rot = mat2x2<f32>(vec2<f32>(cs, -sn), vec2<f32>(sn, cs));
806
+ let inv = mat2x2<f32>(vec2<f32>(cs, sn), vec2<f32>(-sn, cs));
807
+ let rp = rot * px;
808
+ let centerR = (floor(rp / cell) + 0.5) * cell;
809
+ let s = textureSampleLevel(tex, samp, (inv * centerR) / u.texSize, 0.0);
810
+ let a = s.a;
811
+ let c = straight(s);
812
+ let luma = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722)) * a;
813
+ let r = 0.5 * cell * sqrt(luma);
814
+ let d = length(rp - centerR);
815
+ let ink = (1.0 - smoothstep(r - 1.0, r + 1.0, d)) * clamp(r, 0.0, 1.0);
816
+ return vec4<f32>(c, 1.0) * (a * ink) * u.tint;
817
+ } else if (u.mode < 3.5) {
818
+ // ascii — 10-glyph density ramp from the atlas, cell-color tint.
819
+ let cell = max(u.p0, 4.0);
820
+ let cellOrigin = floor(px / cell) * cell;
821
+ let s = textureSampleLevel(tex, samp, (cellOrigin + 0.5 * cell) / u.texSize, 0.0);
822
+ let a = s.a;
823
+ let c = straight(s);
824
+ let luma = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722)) * a;
825
+ let idx = clamp(floor(luma * 10.0), 0.0, 9.0);
826
+ let g = clamp(floor((px - cellOrigin) / cell * 8.0), vec2<f32>(0.0), vec2<f32>(7.0));
827
+ let auxUv = vec2<f32>((idx * 8.0 + g.x + 0.5) / 80.0, (g.y + 0.5) / 8.0);
828
+ let ink = textureSampleLevel(aux, samp, auxUv, 0.0).a;
829
+ return vec4<f32>(c, 1.0) * (a * ink) * u.tint;
830
+ } else if (u.mode < 4.5) {
831
+ // drop_shadow — aux is the ladder-blurred layer; its alpha, offset
832
+ // and tinted, composites UNDER the content.
833
+ let c = textureSampleLevel(tex, samp, in.uv, 0.0);
834
+ let texel = 1.0 / u.texSize;
835
+ let ouv = clamp(in.uv - vec2<f32>(u.p0, u.p1) * texel, vec2<f32>(0.0), vec2<f32>(1.0));
836
+ let sa = textureSampleLevel(aux, samp, ouv, 0.0).a;
837
+ return c + u.tint * (sa * (1.0 - c.a));
838
+ } else if (u.mode < 5.5) {
839
+ // glow — blurred silhouette × intensity × color, under the content.
840
+ let c = textureSampleLevel(tex, samp, in.uv, 0.0);
841
+ let ga = clamp(textureSampleLevel(aux, samp, in.uv, 0.0).a * u.p0, 0.0, 1.0);
842
+ return c + u.tint * (ga * (1.0 - c.a));
843
+ } else if (u.mode < 6.5) {
844
+ // stroke — outline band outside the silhouette: max alpha over a
845
+ // 16-tap ring at the stroke width, under the content.
846
+ let c = textureSampleLevel(tex, samp, in.uv, 0.0);
847
+ let texel = 1.0 / u.texSize;
848
+ let w = max(u.p0, 1.0);
849
+ var s = 0.0;
850
+ for (var i = 0; i < 16; i++) {
851
+ let ang = 6.2831853 * f32(i) / 16.0;
852
+ let tuv = clamp(in.uv + vec2<f32>(cos(ang), sin(ang)) * w * texel, vec2<f32>(0.0), vec2<f32>(1.0));
853
+ s = max(s, textureSampleLevel(tex, samp, tuv, 0.0).a);
854
+ }
855
+ return c + u.tint * (s * (1.0 - c.a));
856
+ } else if (u.mode < 7.5) {
857
+ // chroma_key — BT.709 CbCr distance ramp (§4.7). u.tint.rgb = key
858
+ // color (STRAIGHT), u.tint.a = spill; p0 tolerance, p1 softness.
859
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
860
+ var c = straight(s);
861
+ let k = u.tint.rgb;
862
+ let LUMA = vec3<f32>(0.2126, 0.7152, 0.0722);
863
+ let cy = dot(c, LUMA);
864
+ let ky = dot(k, LUMA);
865
+ let cc = vec2<f32>((c.b - cy) / 1.8556, (c.r - cy) / 1.5748);
866
+ let kc = vec2<f32>((k.b - ky) / 1.8556, (k.r - ky) / 1.5748);
867
+ let d = distance(cc, kc);
868
+ var a = select(
869
+ select(1.0, 0.0, d <= u.p0),
870
+ clamp((d - u.p0) / u.p1, 0.0, 1.0),
871
+ u.p1 > 0.0);
872
+ // Spill suppression: cap the key's dominant channel (ties g→r→b)
873
+ // at the max of the other two, scaled by spill.
874
+ if (k.g >= k.r && k.g >= k.b) {
875
+ c.g -= u.tint.a * max(0.0, c.g - max(c.r, c.b));
876
+ } else if (k.r >= k.b) {
877
+ c.r -= u.tint.a * max(0.0, c.r - max(c.g, c.b));
878
+ } else {
879
+ c.b -= u.tint.a * max(0.0, c.b - max(c.r, c.g));
880
+ }
881
+ let ao = s.a * a;
882
+ return vec4<f32>(c * ao, ao);
883
+ } else if (u.mode < 8.5) {
884
+ // luma_key — p0 threshold, p1 softness, u.tint.r = invert flag.
885
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
886
+ let c = straight(s);
887
+ let y = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
888
+ var a = select(
889
+ select(1.0, 0.0, y <= u.p0),
890
+ clamp((y - u.p0) / u.p1, 0.0, 1.0),
891
+ u.p1 > 0.0);
892
+ if (u.tint.x > 0.5) { a = 1.0 - a; }
893
+ let ao = s.a * a;
894
+ return vec4<f32>(c * ao, ao);
895
+ } else if (u.mode < 9.5) {
896
+ // levels — per-channel remap (§4.7): u.tint = (in_black, in_white,
897
+ // out_black, out_white), p0 = gamma; y = x^(1/gamma).
898
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
899
+ let c = straight(s);
900
+ var x = clamp((c - u.tint.x) / max(u.tint.y - u.tint.x, 1e-5), vec3<f32>(0.0), vec3<f32>(1.0));
901
+ x = pow(x, vec3<f32>(1.0 / max(u.p0, 1e-5)));
902
+ let o = clamp(u.tint.z + x * (u.tint.w - u.tint.z), vec3<f32>(0.0), vec3<f32>(1.0));
903
+ return vec4<f32>(o * s.a, s.a);
904
+ } else if (u.mode < 10.5) {
905
+ // lut — 3D lattice packed as N slices along x in a 2D atlas (aux,
906
+ // N²×N, slice index = blue). Manual trilinear: two bilinear taps
907
+ // mixed across the blue axis. p0 = N, p1 = intensity.
908
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
909
+ let c = straight(s);
910
+ let n = max(u.p0, 2.0);
911
+ let b = clamp(c.b, 0.0, 1.0) * (n - 1.0);
912
+ let b0 = floor(b);
913
+ let b1 = min(b0 + 1.0, n - 1.0);
914
+ let cellUv = vec2<f32>(
915
+ (clamp(c.r, 0.0, 1.0) * (n - 1.0) + 0.5) / (n * n),
916
+ (clamp(c.g, 0.0, 1.0) * (n - 1.0) + 0.5) / n);
917
+ let lo = textureSampleLevel(aux, samp, cellUv + vec2<f32>(b0 / n, 0.0), 0.0).rgb;
918
+ let hi = textureSampleLevel(aux, samp, cellUv + vec2<f32>(b1 / n, 0.0), 0.0).rgb;
919
+ let graded = mix(c, mix(lo, hi, b - b0), clamp(u.p1, 0.0, 1.0));
920
+ return vec4<f32>(clamp(graded, vec3<f32>(0.0), vec3<f32>(1.0)) * s.a, s.a);
921
+ } else if (u.mode < 11.5) {
922
+ // fractal_noise — grayscale fBM over the element's footprint.
923
+ // p0 = scale px, p1 = evolution,
924
+ // u.tint = (offset_x/scale, offset_y/scale, octaves, seed).
925
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
926
+ let v = fbm(
927
+ vec3<f32>(px / max(u.p0, 1e-3) + u.tint.xy, u.p1),
928
+ i32(u.tint.z + 0.5), u32(u.tint.w + 0.5));
929
+ return vec4<f32>(vec3<f32>(v) * s.a, s.a);
930
+ } else if (u.mode < 12.5) {
931
+ // turbulent_displace — sample the layer at p + noise vector.
932
+ // p0 = amount px, p1 = scale px, u.tint = (evolution, octaves, seed, 0).
933
+ let sc = max(u.p1, 1e-3);
934
+ let oct = i32(u.tint.y + 0.5);
935
+ let sd = u32(u.tint.z + 0.5);
936
+ let dx = fbm(vec3<f32>(px / sc, u.tint.x), oct, sd) - 0.5;
937
+ let dy = fbm(vec3<f32>(px / sc, u.tint.x), oct, sd + 7919u) - 0.5;
938
+ let duv = vec2<f32>(dx, dy) * 2.0 * u.p0 / u.texSize;
939
+ return textureSampleLevel(tex, samp, clamp(in.uv + duv, vec2<f32>(0.0), vec2<f32>(1.0)), 0.0);
940
+ } else {
941
+ // bloom_bright — extract pixels above a soft luma threshold for a
942
+ // whole-frame bloom pass. p0 = threshold, p1 = knee. Straight bright
943
+ // color, alpha 1, so the subsequent blur spreads it cleanly.
944
+ let s = textureSampleLevel(tex, samp, in.uv, 0.0);
945
+ let c = select(vec3<f32>(0.0), s.rgb / s.a, s.a > 0.0);
946
+ let l = dot(c, vec3<f32>(0.2126, 0.7152, 0.0722));
947
+ let f = clamp((l - u.p0) / max(u.p1, 1e-3), 0.0, 1.0);
948
+ return vec4<f32>(c * f, 1.0);
949
+ }
950
+ }
951
+ `;
952
+ // Glass composite (§4.7 'glass') — faithful port of the
953
+ // ybouane/liquidglass FS_GLASS shader onto our conventions. Analytic
954
+ // rounded-rect SDF + half-circle bevel, biconvex/dome refraction,
955
+ // Fresnel + Blinn-Phong lighting, inner stroke, outside-only drop
956
+ // shadow. Must match the WebGL GLASS_FS exactly.
957
+ // Two variants from one template — see the WebGL twin (glassFsSource)
958
+ // for the CKP/1.0 projective rationale. The non-projective source is
959
+ // byte-identical to the CKP/1.0 shader (the equivalence gate).
960
+ const glassShaderSource = (projective) => /* wgsl */ `
961
+ struct GlassUniforms {
962
+ transform: mat4x4<f32>, // 64 bytes, offset 0
963
+ tint: vec4<f32>, // 16 bytes, offset 64 — STRAIGHT rgba
964
+ texSize: vec2<f32>, // 8 bytes, offset 80 — surface PHYSICAL dims
965
+ paneCenter: vec2<f32>, // 8 bytes, offset 88 — PHYSICAL px
966
+ paneHalf: vec2<f32>, // 8 bytes, offset 96 — PHYSICAL px
967
+ rot: vec2<f32>, // 8 bytes, offset 104 — (cos θ, sin θ)
968
+ geo: vec4<f32>, // 16 bytes, offset 112 — (radius, zRadius, bevelMode, bdFlip)
969
+ optics: vec4<f32>, // 16 bytes, offset 128 — (refract, chroma, edgeHL, fresnel)
970
+ look: vec4<f32>, // 16 bytes, offset 144 — (specular, saturation, alpha, 0)
971
+ shadow: vec4<f32>, // 16 bytes, offset 160 — (alpha, spread, offY, 0)${projective ? `
972
+ hcol0: vec4<f32>, // 16 bytes, offset 176 — pane→surface H, column 0 (xyz)
973
+ hcol1: vec4<f32>, // 16 bytes, offset 192
974
+ hcol2: vec4<f32>, // 16 bytes, offset 208
975
+ hicol0: vec4<f32>, // 16 bytes, offset 224 — inverse H columns
976
+ hicol1: vec4<f32>, // 16 bytes, offset 240
977
+ hicol2: vec4<f32>, // 16 bytes, offset 256
978
+ } // total: 272` : `
979
+ } // total: 176`}
980
+ @group(0) @binding(0) var<uniform> u: GlassUniforms;
981
+ @group(0) @binding(1) var samp: sampler;
982
+ @group(0) @binding(2) var backdropTex: texture_2d<f32>; // frosted
983
+ @group(0) @binding(3) var sharpTex: texture_2d<f32>; // unblurred
984
+
985
+ struct VsOut {
986
+ @builtin(position) position: vec4<f32>,
987
+ @location(0) uv: vec2<f32>,
988
+ }
989
+
990
+ @vertex
991
+ fn vsMain(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>) -> VsOut {
992
+ var out: VsOut;
993
+ out.position = u.transform * vec4<f32>(pos, 0.0, 1.0);
994
+ out.uv = uv;
995
+ return out;
996
+ }
997
+
998
+ fn rrSDF(p: vec2<f32>, b: vec2<f32>, r: f32) -> f32 {
999
+ let q = abs(p) - b + vec2<f32>(r, r);
1000
+ return min(max(q.x, q.y), 0.0) + length(max(q, vec2<f32>(0.0, 0.0))) - r;
1001
+ }
1002
+
1003
+ // Half-circle bevel height field (reference bevelHeight).
1004
+ fn bevelHeight(d: f32, zR: f32) -> f32 {
1005
+ if (d <= 0.0) { return 0.0; }
1006
+ if (d >= zR) { return zR; }
1007
+ return sqrt(d * (2.0 * zR - d));
1008
+ }
1009
+
1010
+ fn straight3(s: vec4<f32>) -> vec3<f32> {
1011
+ return select(vec3<f32>(0.0), s.rgb / s.a, s.a > 0.0);
1012
+ }
1013
+
1014
+ @fragment
1015
+ fn fsMain(in: VsOut) -> @location(0) vec4<f32> {${projective ? `
1016
+ // Pane-local coordinates: invert the pane→surface homography. A
1017
+ // non-positive w means the fragment looks past the plane's horizon
1018
+ // (behind the camera) — nothing there.
1019
+ let px = in.uv * u.texSize;
1020
+ let Hi = mat3x3<f32>(u.hicol0.xyz, u.hicol1.xyz, u.hicol2.xyz);
1021
+ let lh = Hi * vec3<f32>(px, 1.0);
1022
+ if (lh.z <= 0.0) { return vec4<f32>(0.0, 0.0, 0.0, 0.0); }
1023
+ let p = lh.xy / lh.z;` : `
1024
+ // Pane-local coordinates (rotate surface px by −θ around the centre).
1025
+ let px = in.uv * u.texSize;
1026
+ let rel = px - u.paneCenter;
1027
+ let p = vec2<f32>(rel.x * u.rot.x + rel.y * u.rot.y,
1028
+ -rel.x * u.rot.y + rel.y * u.rot.x);`}
1029
+ let half_ = u.paneHalf;
1030
+ let r = min(u.geo.x, min(half_.x, half_.y));
1031
+ let sdf = rrSDF(p, half_, r);
1032
+
1033
+ // ── Drop shadow — OUTSIDE the panel only ──
1034
+ if (sdf > 0.0) {
1035
+ var a = 0.0;
1036
+ if (u.shadow.x > 0.0) {
1037
+ let sdfShadow = rrSDF(p - vec2<f32>(0.0, u.shadow.z), half_, r);
1038
+ let d = max(sdfShadow - 1.0, 0.0);
1039
+ let spread = max(u.shadow.y, 1.0);
1040
+ let falloff = 1.0 / (spread * spread);
1041
+ let outerShadow = exp(-d * d * falloff) * 0.65;
1042
+ let contactShadow = exp(-d * 0.08 / max(spread * 0.04, 0.01)) * 0.35;
1043
+ a = (outerShadow + contactShadow) * u.shadow.x;
1044
+ }
1045
+ return vec4<f32>(0.0, 0.0, 0.0, a);
1046
+ }
1047
+
1048
+ let mask = 1.0 - smoothstep(-1.5, 0.5, sdf);
1049
+
1050
+ let maxD = min(half_.x, half_.y);
1051
+ let inside = -sdf;
1052
+ let edge = smoothstep(maxD * 0.35, 0.0, inside);
1053
+
1054
+ // ── Surface normal via the bevel height field (e = 2px, analytic) ──
1055
+ let zR = u.geo.y;
1056
+ let e = 2.0;
1057
+ let hC = bevelHeight(inside, zR);
1058
+ let hGrad = vec2<f32>(
1059
+ bevelHeight(-rrSDF(p + vec2<f32>(e, 0.0), half_, r), zR) -
1060
+ bevelHeight(-rrSDF(p - vec2<f32>(e, 0.0), half_, r), zR),
1061
+ bevelHeight(-rrSDF(p + vec2<f32>(0.0, e), half_, r), zR) -
1062
+ bevelHeight(-rrSDF(p - vec2<f32>(0.0, e), half_, r), zR)) / (2.0 * e);
1063
+ let N = normalize(vec3<f32>(-hGrad, 1.0));
1064
+
1065
+ let depth = smoothstep(0.0, zR, inside);
1066
+
1067
+ // ── Refraction ──
1068
+ let refrPow = 1.0 - 1.0 / 1.5;
1069
+ let thickNorm = (hC * 2.0) / max(zR * 2.0, 1.0);
1070
+ var refrPx: vec2<f32>;
1071
+ if (u.geo.z < 0.5) {
1072
+ // Biconvex pill: entry + exit + through-thickness refraction,
1073
+ // plus a depth-scaled magnification pull toward the centre.
1074
+ let surfRefr = hGrad * refrPow;
1075
+ refrPx = (surfRefr * 2.0 + surfRefr * thickNorm * 0.5) * u.optics.x * 30.0;
1076
+ let centerDir = -p / max(half_, vec2<f32>(1.0, 1.0));
1077
+ refrPx += centerDir * u.optics.x * 4.0 * depth;
1078
+ } else {
1079
+ // Dome: uniform magnification — contract sampling toward centre.
1080
+ refrPx = -p * u.optics.x * depth * 0.35;
1081
+ }
1082
+
1083
+ // ── Chromatic aberration ──
1084
+ let caS = u.optics.y * 18.0 * (edge * 0.7 + 0.3) * 2.0;
1085
+ let caD = N.xy * caS;
1086
+
1087
+ ${projective ? ` // Pane-local sample points → surface px via the FORWARD homography
1088
+ // (refraction and aberration computed in the pane's frame).
1089
+ let Hm = mat3x3<f32>(u.hcol0.xyz, u.hcol1.xyz, u.hcol2.xyz);
1090
+ let fR = Hm * vec3<f32>(p + refrPx + caD, 1.0);
1091
+ let fG = Hm * vec3<f32>(p + refrPx, 1.0);
1092
+ let fB = Hm * vec3<f32>(p + refrPx - caD, 1.0);
1093
+ var uvR = clamp(fR.xy / (max(fR.z, 1e-4) * u.texSize), vec2<f32>(0.0), vec2<f32>(1.0));
1094
+ var uvG = clamp(fG.xy / (max(fG.z, 1e-4) * u.texSize), vec2<f32>(0.0), vec2<f32>(1.0));
1095
+ var uvB = clamp(fB.xy / (max(fB.z, 1e-4) * u.texSize), vec2<f32>(0.0), vec2<f32>(1.0));` : ` // Pane-local offsets → surface space (rotate by +θ) → uv.
1096
+ let refrW = vec2<f32>(refrPx.x * u.rot.x - refrPx.y * u.rot.y,
1097
+ refrPx.x * u.rot.y + refrPx.y * u.rot.x);
1098
+ let caW = vec2<f32>(caD.x * u.rot.x - caD.y * u.rot.y,
1099
+ caD.x * u.rot.y + caD.y * u.rot.x);
1100
+ let base = in.uv + refrW / u.texSize;
1101
+ let oCA = caW / u.texSize;
1102
+ var uvR = clamp(base + oCA, vec2<f32>(0.0), vec2<f32>(1.0));
1103
+ var uvG = clamp(base, vec2<f32>(0.0), vec2<f32>(1.0));
1104
+ var uvB = clamp(base - oCA, vec2<f32>(0.0), vec2<f32>(1.0));`}
1105
+ if (u.geo.w > 0.5) { // GL-canvas snapshots are bottom-up
1106
+ uvR.y = 1.0 - uvR.y; uvG.y = 1.0 - uvG.y; uvB.y = 1.0 - uvB.y;
1107
+ }
1108
+
1109
+ let sharpC = vec3<f32>(
1110
+ straight3(textureSampleLevel(sharpTex, samp, uvR, 0.0)).r,
1111
+ straight3(textureSampleLevel(sharpTex, samp, uvG, 0.0)).g,
1112
+ straight3(textureSampleLevel(sharpTex, samp, uvB, 0.0)).b);
1113
+ let blurC = vec3<f32>(
1114
+ straight3(textureSampleLevel(backdropTex, samp, uvR, 0.0)).r,
1115
+ straight3(textureSampleLevel(backdropTex, samp, uvG, 0.0)).g,
1116
+ straight3(textureSampleLevel(backdropTex, samp, uvB, 0.0)).b);
1117
+ // Edge-weighted blur mix: centre fully frosted, rim 15% sharp.
1118
+ let edgeMix = 1.0 - edge * 0.15;
1119
+ var col = mix(sharpC, blurC, edgeMix);
1120
+
1121
+ // ── Saturation (0 = unchanged) ──
1122
+ let lum = dot(col, vec3<f32>(0.299, 0.587, 0.114));
1123
+ col = mix(vec3<f32>(lum), col, 1.0 + u.look.y);
1124
+
1125
+ // ── Tint ──
1126
+ col = mix(col, u.tint.rgb, u.tint.a);
1127
+ col *= 1.0 + 0.06 * depth;
1128
+
1129
+ // ── Fresnel ──
1130
+ let fres = pow(1.0 - abs(N.z), 4.0) * u.optics.w;
1131
+
1132
+ // ── Specular highlights (multi-light Blinn-Phong, reference lights) ──
1133
+ let V = vec3<f32>(0.0, 0.0, 1.0);
1134
+ let L1 = normalize(vec3<f32>(0.4, 0.7, 1.0));
1135
+ var sp = pow(max(dot(N, normalize(L1 + V)), 0.0), 90.0);
1136
+ let L2 = normalize(vec3<f32>(-0.3, -0.5, 1.0));
1137
+ sp += pow(max(dot(N, normalize(L2 + V)), 0.0), 50.0) * 0.3;
1138
+ let L3 = normalize(vec3<f32>(0.1, 0.3, 1.0));
1139
+ sp += pow(max(dot(N, L3), 0.0), 6.0) * 0.1;
1140
+ let L4 = normalize(vec3<f32>(0.0, 0.9, 0.4));
1141
+ sp += pow(max(dot(N, normalize(L4 + V)), 0.0), 120.0) * 0.6;
1142
+ let totalSpec = sp * u.look.x;
1143
+
1144
+ // ── Inner border / stroke highlight ──
1145
+ let borderWidth = 1.5;
1146
+ var innerStroke = smoothstep(-borderWidth - 1.0, -borderWidth, sdf)
1147
+ * (1.0 - smoothstep(-1.0, 0.0, sdf));
1148
+ let topBias = 0.5 + 0.5 * (-p.y / half_.y);
1149
+ innerStroke *= (0.4 + 0.6 * topBias);
1150
+
1151
+ // ── Edge highlight & inner glow ──
1152
+ let rim = edge * u.optics.z * 0.22;
1153
+ let innerGlow = smoothstep(5.0, 0.0, -sdf) * u.optics.z * 0.15;
1154
+
1155
+ // ── Environment-like reflection (fake) ──
1156
+ let envRefl = (N.y * 0.5 + 0.5) * fres * 0.08;
1157
+
1158
+ // ── Composite ──
1159
+ var fin = col;
1160
+ fin += vec3<f32>(totalSpec);
1161
+ fin += vec3<f32>(rim + innerGlow);
1162
+ fin += vec3<f32>(innerStroke * u.optics.z * 0.55);
1163
+ fin += vec3<f32>(envRefl);
1164
+ fin = mix(fin, vec3<f32>(1.0), fres * 0.2);
1165
+
1166
+ let outA = mask * u.look.z;
1167
+ return vec4<f32>(clamp(fin, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0) * outA;
1168
+ }
1169
+ `;
1170
+ // ─── Unit quad geometry ─────────────────────────────────────────────────────
1171
+ //
1172
+ // 6 vertices, 2 triangles. Each vertex: (pos.xy, uv.xy), 16 bytes.
1173
+ // UVs match the quad's screen orientation: position y=+1 (top) → uv v=0 (top of texture).
1174
+ //
1175
+ // (-1, +1) uv (0, 0) ────────── (+1, +1) uv (1, 0)
1176
+ // │ Top-left │ Top-right
1177
+ // │ │
1178
+ // (-1, -1) uv (0, 1) ────────── (+1, -1) uv (1, 1)
1179
+ // Bottom-left Bottom-right
1180
+ // prettier-ignore
1181
+ const UNIT_QUAD_VERTICES = new Float32Array([
1182
+ // tri 1
1183
+ -1, -1, 0, 1, // BL → uv (0, 1)
1184
+ 1, -1, 1, 1, // BR → uv (1, 1)
1185
+ -1, 1, 0, 0, // TL → uv (0, 0)
1186
+ // tri 2
1187
+ -1, 1, 0, 0, // TL
1188
+ 1, -1, 1, 1, // BR
1189
+ 1, 1, 1, 0, // TR
1190
+ ]);
1191
+ const VERTEX_STRIDE = 16;
1192
+ // ─── Implementation ─────────────────────────────────────────────────────────
1193
+ export class WebGPUBackend {
1194
+ canvas;
1195
+ width;
1196
+ height;
1197
+ capabilities;
1198
+ device;
1199
+ context;
1200
+ format;
1201
+ sampler;
1202
+ vertexBuffer;
1203
+ shapePipeline;
1204
+ litShapePipeline;
1205
+ litShapeBindGroupLayout;
1206
+ litTexturedPipeline;
1207
+ litTexturedBindGroupLayout;
1208
+ flatNormalView = null;
1209
+ shadowPipeline;
1210
+ gradientPipeline;
1211
+ texturedPipeline;
1212
+ maskedPipeline;
1213
+ filteredPipeline;
1214
+ stylizedPipeline;
1215
+ glassPipeline;
1216
+ backdropBlendPipeline;
1217
+ // Lazy projective variant (CKP/1.0 glass under 3D) — created on
1218
+ // first use so 2D documents never pay for it.
1219
+ glass3dPipeline = null;
1220
+ /**
1221
+ * Non-normal blend variants, keyed `${pipelineName}:${blendMode}`.
1222
+ * WebGPU bakes blend state into the pipeline at creation time (unlike
1223
+ * GL's mutable blendFunc), so each blendable pipeline gets a variant
1224
+ * per supported mode, built eagerly at init. Shadow stays normal-only.
1225
+ */
1226
+ blendVariants = new Map();
1227
+ shapeBindGroupLayout;
1228
+ shadowBindGroupLayout;
1229
+ gradientBindGroupLayout;
1230
+ texturedBindGroupLayout;
1231
+ maskedBindGroupLayout;
1232
+ // Per-frame command recording state.
1233
+ commandEncoder = null;
1234
+ passEncoder = null;
1235
+ /** The swap-chain view of the frame in progress (popTarget resumes onto it). */
1236
+ canvasView = null;
1237
+ /** The swap-chain texture itself — copySurfaceTo's source at the root. */
1238
+ canvasTexture = null;
1239
+ /** Physical backing-store dims ÷ logical dims (renderResolution). */
1240
+ pixelRatio = 1;
1241
+ /**
1242
+ * Offscreen-surface stack. WebGPU can't redirect a pass mid-flight,
1243
+ * so push/pop END the current render pass and BEGIN a new one on the
1244
+ * next surface (loadOp 'load' preserves prior contents on resume).
1245
+ */
1246
+ surfaceStack = [];
1247
+ renderTargets = new Set();
1248
+ currentSurface() {
1249
+ const top = this.surfaceStack[this.surfaceStack.length - 1];
1250
+ if (top)
1251
+ return top;
1252
+ return { view: this.canvasView, width: this.width, height: this.height };
1253
+ }
1254
+ // Uniform buffer pool. Pre-allocate GPUBuffers and reuse them across frames
1255
+ // (via queue.writeBuffer). Without this we'd allocate ~50+ GPUBuffers per
1256
+ // frame for a caption-heavy source, generating enough GC pressure to stall
1257
+ // the main thread and visibly stutter playback.
1258
+ //
1259
+ // Sized to fit the largest uniform struct (gradient = 176 bytes, rounded
1260
+ // up to 192 for 16-byte alignment). Solid shape (96 B) and textured-quad
1261
+ // (96 B) write the first 96 bytes only; remainder is unused.
1262
+ uniformBufferPool = [];
1263
+ uniformBufferIndex = 0;
1264
+ uniformScratch = new Float32Array(136); // 544 bytes (lit pass is the largest, 528 used)
1265
+ // Sized for the largest uniform struct (glass3d: 272 bytes, padded).
1266
+ static UNIFORM_SIZE = 544;
1267
+ nextTextureId = 1;
1268
+ /** Set after the first failed direct VideoFrame copy — see uploadToTexture. */
1269
+ videoDirectCopyBroken = false;
1270
+ videoBlitCanvas = null;
1271
+ liveTextures = new Set();
1272
+ disposed = false;
1273
+ constructor(canvas) {
1274
+ this.canvas = canvas;
1275
+ this.width = canvas.width;
1276
+ this.height = canvas.height;
1277
+ }
1278
+ async init() {
1279
+ const log = getLogger();
1280
+ if (typeof navigator === 'undefined' || !('gpu' in navigator) || !navigator.gpu) {
1281
+ log.warn('WebGPU not available in this environment');
1282
+ return false;
1283
+ }
1284
+ try {
1285
+ const adapter = await navigator.gpu.requestAdapter();
1286
+ if (!adapter) {
1287
+ log.warn('No WebGPU adapter available');
1288
+ return false;
1289
+ }
1290
+ this.device = await adapter.requestDevice();
1291
+ this.device.addEventListener('uncapturederror', (event) => {
1292
+ log.error('WebGPU uncaptured error:', event.error.message);
1293
+ });
1294
+ this.device.lost.then((info) => {
1295
+ log.error('WebGPU device lost:', info.message, info.reason);
1296
+ });
1297
+ // Canvas context.
1298
+ const ctx = this.canvas.getContext('webgpu');
1299
+ if (!ctx) {
1300
+ log.error('Failed to get WebGPU canvas context');
1301
+ return false;
1302
+ }
1303
+ this.context = ctx;
1304
+ this.format = navigator.gpu.getPreferredCanvasFormat();
1305
+ this.context.configure({
1306
+ device: this.device,
1307
+ format: this.format,
1308
+ alphaMode: 'premultiplied',
1309
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, // COPY_SRC: glass backdrop snapshots
1310
+ });
1311
+ // Shared sampler (linear filtering for both image and text).
1312
+ this.sampler = this.device.createSampler({
1313
+ magFilter: 'linear',
1314
+ minFilter: 'linear',
1315
+ addressModeU: 'clamp-to-edge',
1316
+ addressModeV: 'clamp-to-edge',
1317
+ });
1318
+ // Shared unit-quad vertex buffer.
1319
+ this.vertexBuffer = this.device.createBuffer({
1320
+ size: UNIT_QUAD_VERTICES.byteLength,
1321
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
1322
+ });
1323
+ this.device.queue.writeBuffer(this.vertexBuffer, 0, UNIT_QUAD_VERTICES);
1324
+ // Pipelines.
1325
+ await this.buildShapePipeline();
1326
+ await this.buildShadowPipeline();
1327
+ await this.buildGradientPipeline();
1328
+ await this.buildTexturedPipeline();
1329
+ this.capabilities = {
1330
+ api: 'webgpu',
1331
+ maxTextureSize: this.device.limits.maxTextureDimension2D,
1332
+ };
1333
+ log.info(`WebGPU backend ready (maxTextureSize=${this.capabilities.maxTextureSize})`);
1334
+ return true;
1335
+ }
1336
+ catch (err) {
1337
+ log.error('WebGPU init failed:', err instanceof Error ? err.message : String(err));
1338
+ return false;
1339
+ }
1340
+ }
1341
+ resize(width, height, pixelRatio = 1) {
1342
+ if (this.disposed)
1343
+ return;
1344
+ const physW = Math.max(1, Math.round(width * pixelRatio));
1345
+ const physH = Math.max(1, Math.round(height * pixelRatio));
1346
+ if (width === this.width &&
1347
+ height === this.height &&
1348
+ this.canvas.width === physW &&
1349
+ this.canvas.height === physH)
1350
+ return;
1351
+ this.width = width;
1352
+ this.height = height;
1353
+ this.pixelRatio = pixelRatio;
1354
+ this.canvas.width = physW;
1355
+ this.canvas.height = physH;
1356
+ // WebGPU canvases auto-resize the swap chain to canvas.{width,height},
1357
+ // but reconfiguring is the safest way to ensure the next frame uses
1358
+ // the new dimensions.
1359
+ this.context.configure({
1360
+ device: this.device,
1361
+ format: this.format,
1362
+ alphaMode: 'premultiplied',
1363
+ usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, // COPY_SRC: glass backdrop snapshots
1364
+ });
1365
+ }
1366
+ // ─── Pipelines ────────────────────────────────────────────────────────────
1367
+ /** Build one render pipeline over the shared unit quad. */
1368
+ makePipeline(label, module, bindGroupLayout, blend) {
1369
+ return this.device.createRenderPipeline({
1370
+ label,
1371
+ layout: this.device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
1372
+ vertex: {
1373
+ module,
1374
+ entryPoint: 'vsMain',
1375
+ buffers: [
1376
+ {
1377
+ arrayStride: VERTEX_STRIDE,
1378
+ attributes: [
1379
+ { shaderLocation: 0, offset: 0, format: 'float32x2' },
1380
+ { shaderLocation: 1, offset: 8, format: 'float32x2' },
1381
+ ],
1382
+ },
1383
+ ],
1384
+ },
1385
+ fragment: {
1386
+ module,
1387
+ entryPoint: 'fsMain',
1388
+ targets: [{ format: this.format, blend }],
1389
+ },
1390
+ primitive: { topology: 'triangle-list' },
1391
+ });
1392
+ }
1393
+ /**
1394
+ * Build the normal-blend pipeline plus multiply/screen/add variants
1395
+ * (registered in `blendVariants` under `${name}:${mode}`).
1396
+ */
1397
+ makeBlendablePipeline(name, module, bindGroupLayout) {
1398
+ for (const mode of ['multiply', 'screen', 'add']) {
1399
+ this.blendVariants.set(`${name}:${mode}`, this.makePipeline(`${name} pipeline (${mode})`, module, bindGroupLayout, BLEND_STATES[mode]));
1400
+ }
1401
+ return this.makePipeline(`${name} pipeline`, module, bindGroupLayout, PREMUL_BLEND);
1402
+ }
1403
+ /** Pick the pipeline for a draw's blend mode (missing/normal → base). */
1404
+ pipelineFor(base, name, blend) {
1405
+ if (!blend || blend === 'normal')
1406
+ return base;
1407
+ return this.blendVariants.get(`${name}:${blend}`) ?? base;
1408
+ }
1409
+ async buildShapePipeline() {
1410
+ const module = this.device.createShaderModule({ code: SHAPE_SHADER, label: 'shape' });
1411
+ await this.checkShaderCompilation(module, 'shape');
1412
+ this.shapeBindGroupLayout = this.device.createBindGroupLayout({
1413
+ label: 'shape bgl',
1414
+ entries: [
1415
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1416
+ ],
1417
+ });
1418
+ this.shapePipeline = this.makeBlendablePipeline('shape', module, this.shapeBindGroupLayout);
1419
+ // Lit variant (§4.8) — same single-uniform bind-group shape, larger
1420
+ // uniform struct. Built alongside the shape pipeline; unlit documents
1421
+ // simply never bind it.
1422
+ const litModule = this.device.createShaderModule({ code: LIT_SHAPE_SHADER, label: 'litShape' });
1423
+ await this.checkShaderCompilation(litModule, 'litShape');
1424
+ // uniform + sampler + normal-map texture + env texture (all bound;
1425
+ // defaults to a 1×1 flat texture when absent).
1426
+ this.litShapeBindGroupLayout = this.device.createBindGroupLayout({
1427
+ label: 'litShape bgl',
1428
+ entries: [
1429
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1430
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
1431
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1432
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1433
+ ],
1434
+ });
1435
+ this.litShapePipeline = this.makeBlendablePipeline('litShape', litModule, this.litShapeBindGroupLayout);
1436
+ }
1437
+ async buildShadowPipeline() {
1438
+ const module = this.device.createShaderModule({ code: SHADOW_SHADER, label: 'shadow' });
1439
+ await this.checkShaderCompilation(module, 'shadow');
1440
+ this.shadowBindGroupLayout = this.device.createBindGroupLayout({
1441
+ label: 'shadow bgl',
1442
+ entries: [
1443
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1444
+ ],
1445
+ });
1446
+ // Shadows always composite normally — they sit behind their shape.
1447
+ this.shadowPipeline = this.makePipeline('shadow pipeline', module, this.shadowBindGroupLayout, PREMUL_BLEND);
1448
+ }
1449
+ async buildGradientPipeline() {
1450
+ const module = this.device.createShaderModule({ code: GRADIENT_SHADER, label: 'gradient' });
1451
+ await this.checkShaderCompilation(module, 'gradient');
1452
+ this.gradientBindGroupLayout = this.device.createBindGroupLayout({
1453
+ label: 'gradient bgl',
1454
+ entries: [
1455
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1456
+ ],
1457
+ });
1458
+ this.gradientPipeline = this.makeBlendablePipeline('gradient', module, this.gradientBindGroupLayout);
1459
+ }
1460
+ async buildTexturedPipeline() {
1461
+ const module = this.device.createShaderModule({ code: TEXTURED_SHADER, label: 'textured' });
1462
+ await this.checkShaderCompilation(module, 'textured');
1463
+ this.texturedBindGroupLayout = this.device.createBindGroupLayout({
1464
+ label: 'textured bgl',
1465
+ entries: [
1466
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1467
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
1468
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1469
+ ],
1470
+ });
1471
+ this.texturedPipeline = this.makeBlendablePipeline('textured', module, this.texturedBindGroupLayout);
1472
+ // Lit textured variant (§4.8) — same bind-group shape (uniform +
1473
+ // sampler + texture), larger uniform struct. Lit images / video /
1474
+ // group cards.
1475
+ const litTexModule = this.device.createShaderModule({ code: LIT_TEXTURED_SHADER, label: 'litTextured' });
1476
+ await this.checkShaderCompilation(litTexModule, 'litTextured');
1477
+ // uniform + sampler + albedo + normal-map + env texture.
1478
+ this.litTexturedBindGroupLayout = this.device.createBindGroupLayout({
1479
+ label: 'litTextured bgl',
1480
+ entries: [
1481
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1482
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
1483
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1484
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1485
+ { binding: 4, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1486
+ ],
1487
+ });
1488
+ this.litTexturedPipeline = this.makeBlendablePipeline('litTextured', litTexModule, this.litTexturedBindGroupLayout);
1489
+ const maskedModule = this.device.createShaderModule({ code: MASKED_SHADER, label: 'masked' });
1490
+ await this.checkShaderCompilation(maskedModule, 'masked');
1491
+ this.maskedBindGroupLayout = this.device.createBindGroupLayout({
1492
+ label: 'masked bgl',
1493
+ entries: [
1494
+ { binding: 0, visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT, buffer: { type: 'uniform' } },
1495
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
1496
+ { binding: 2, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1497
+ { binding: 3, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
1498
+ ],
1499
+ });
1500
+ this.maskedPipeline = this.makeBlendablePipeline('masked', maskedModule, this.maskedBindGroupLayout);
1501
+ // Filtered composite — same bind-group shape as the textured
1502
+ // pipeline (uniform + sampler + one texture), so the layout is
1503
+ // shared rather than duplicated.
1504
+ const filteredModule = this.device.createShaderModule({ code: FILTERED_SHADER, label: 'filtered' });
1505
+ await this.checkShaderCompilation(filteredModule, 'filtered');
1506
+ this.filteredPipeline = this.makeBlendablePipeline('filtered', filteredModule, this.texturedBindGroupLayout);
1507
+ // Stylize pass — same bind-group shape as masked (uniform +
1508
+ // sampler + two textures), so that layout is shared.
1509
+ const stylizedModule = this.device.createShaderModule({ code: STYLIZED_SHADER, label: 'stylized' });
1510
+ await this.checkShaderCompilation(stylizedModule, 'stylized');
1511
+ this.stylizedPipeline = this.makeBlendablePipeline('stylized', stylizedModule, this.maskedBindGroupLayout);
1512
+ // Glass — two textures (frosted + sharp backdrop snapshots); the
1513
+ // bind-group shape matches masked, so that layout is shared.
1514
+ const glassModule = this.device.createShaderModule({ code: glassShaderSource(false), label: 'glass' });
1515
+ await this.checkShaderCompilation(glassModule, 'glass');
1516
+ this.glassPipeline = this.makeBlendablePipeline('glass', glassModule, this.maskedBindGroupLayout);
1517
+ // Backdrop-blend — outputs the full composite, so REPLACE blend
1518
+ // (not over). Shares the masked bind-group shape (uniform + sampler
1519
+ // + 2 textures). Single pipeline; the piecewise mode is a uniform.
1520
+ const bbModule = this.device.createShaderModule({ code: BACKDROP_BLEND_SHADER, label: 'backdropBlend' });
1521
+ await this.checkShaderCompilation(bbModule, 'backdropBlend');
1522
+ this.backdropBlendPipeline = this.makePipeline('backdropBlend', bbModule, this.maskedBindGroupLayout, REPLACE_BLEND);
1523
+ }
1524
+ async checkShaderCompilation(module, label) {
1525
+ const info = await module.getCompilationInfo();
1526
+ const log = getLogger();
1527
+ for (const msg of info.messages) {
1528
+ const where = `${label}.wgsl:${msg.lineNum}:${msg.linePos}`;
1529
+ if (msg.type === 'error')
1530
+ log.error(`Shader ${where}: ${msg.message}`);
1531
+ else if (msg.type === 'warning')
1532
+ log.warn(`Shader ${where}: ${msg.message}`);
1533
+ }
1534
+ }
1535
+ // ─── Textures ─────────────────────────────────────────────────────────────
1536
+ createTexture(source) {
1537
+ const { width, height } = sourceDimensions(source);
1538
+ const gpuTexture = this.device.createTexture({
1539
+ size: { width, height, depthOrArrayLayers: 1 },
1540
+ format: 'rgba8unorm',
1541
+ usage: GPUTextureUsage.TEXTURE_BINDING |
1542
+ GPUTextureUsage.COPY_DST |
1543
+ GPUTextureUsage.RENDER_ATTACHMENT,
1544
+ });
1545
+ this.uploadToTexture(gpuTexture, source, width, height);
1546
+ const texture = {
1547
+ id: this.nextTextureId++,
1548
+ width,
1549
+ height,
1550
+ gpuTexture,
1551
+ view: gpuTexture.createView(),
1552
+ };
1553
+ this.liveTextures.add(texture);
1554
+ return texture;
1555
+ }
1556
+ updateTexture(texture, source) {
1557
+ const t = texture;
1558
+ this.uploadToTexture(t.gpuTexture, source, t.width, t.height);
1559
+ }
1560
+ uploadToTexture(gpuTexture, source, width, height) {
1561
+ // VideoFrame uses a different copy call.
1562
+ if (typeof VideoFrame !== 'undefined' && source instanceof VideoFrame) {
1563
+ if (!this.videoDirectCopyBroken) {
1564
+ try {
1565
+ this.device.queue.copyExternalImageToTexture({ source }, { texture: gpuTexture, premultipliedAlpha: true }, { width, height });
1566
+ return;
1567
+ }
1568
+ catch {
1569
+ // Chromium rejects the direct copy for some VideoDecoder
1570
+ // output frames ("Copy rect is out of bounds of external
1571
+ // image"). Remember and route every video upload through the
1572
+ // 2D blit below — without this, video preload throws and the
1573
+ // runtime degrades to the approximate <video>-seek path.
1574
+ this.videoDirectCopyBroken = true;
1575
+ getLogger().warn('Direct VideoFrame→GPUTexture copy unavailable; using canvas blit.');
1576
+ }
1577
+ }
1578
+ let canvas = this.videoBlitCanvas;
1579
+ if (!canvas) {
1580
+ canvas = new OffscreenCanvas(width, height);
1581
+ this.videoBlitCanvas = canvas;
1582
+ }
1583
+ if (canvas.width !== width || canvas.height !== height) {
1584
+ canvas.width = width;
1585
+ canvas.height = height;
1586
+ }
1587
+ const ctx2d = canvas.getContext('2d');
1588
+ ctx2d.drawImage(source, 0, 0, width, height);
1589
+ this.device.queue.copyExternalImageToTexture({ source: canvas }, { texture: gpuTexture, premultipliedAlpha: true }, { width, height });
1590
+ return;
1591
+ }
1592
+ this.device.queue.copyExternalImageToTexture({ source: source }, { texture: gpuTexture, premultipliedAlpha: true }, { width, height });
1593
+ }
1594
+ destroyTexture(texture) {
1595
+ const t = texture;
1596
+ if (this.liveTextures.delete(t)) {
1597
+ t.gpuTexture.destroy();
1598
+ }
1599
+ }
1600
+ // ─── Frame lifecycle ──────────────────────────────────────────────────────
1601
+ beginFrame(clearColor = [0, 0, 0, 1]) {
1602
+ if (this.passEncoder) {
1603
+ getLogger().warn('beginFrame called while another frame is in progress; ending the previous one');
1604
+ this.endFrame();
1605
+ }
1606
+ this.commandEncoder = this.device.createCommandEncoder({ label: 'frame' });
1607
+ this.canvasTexture = this.context.getCurrentTexture();
1608
+ this.canvasView = this.canvasTexture.createView();
1609
+ this.surfaceStack.length = 0;
1610
+ this.passEncoder = this.commandEncoder.beginRenderPass({
1611
+ label: 'main pass',
1612
+ colorAttachments: [
1613
+ {
1614
+ view: this.canvasView,
1615
+ loadOp: 'clear',
1616
+ storeOp: 'store',
1617
+ clearValue: { r: clearColor[0], g: clearColor[1], b: clearColor[2], a: clearColor[3] },
1618
+ },
1619
+ ],
1620
+ });
1621
+ this.passEncoder.setVertexBuffer(0, this.vertexBuffer);
1622
+ // Reset uniform pool — buffers from previous frames are now eligible for reuse.
1623
+ this.uniformBufferIndex = 0;
1624
+ }
1625
+ /** End the active pass and begin a new one on `view`. */
1626
+ restartPass(view, loadOp, clearColor = [0, 0, 0, 0]) {
1627
+ if (!this.commandEncoder)
1628
+ return;
1629
+ this.passEncoder?.end();
1630
+ this.passEncoder = this.commandEncoder.beginRenderPass({
1631
+ label: loadOp === 'clear' ? 'target pass' : 'resume pass',
1632
+ colorAttachments: [
1633
+ {
1634
+ view,
1635
+ loadOp,
1636
+ storeOp: 'store',
1637
+ clearValue: { r: clearColor[0], g: clearColor[1], b: clearColor[2], a: clearColor[3] },
1638
+ },
1639
+ ],
1640
+ });
1641
+ this.passEncoder.setVertexBuffer(0, this.vertexBuffer);
1642
+ }
1643
+ // ─── Offscreen render targets ─────────────────────────────────────────────
1644
+ createRenderTarget(width, height) {
1645
+ const physW = Math.max(1, Math.round(width * this.pixelRatio));
1646
+ const physH = Math.max(1, Math.round(height * this.pixelRatio));
1647
+ const gpuTexture = this.device.createTexture({
1648
+ label: 'render target',
1649
+ size: { width: physW, height: physH },
1650
+ format: this.format,
1651
+ // COPY_SRC/COPY_DST: targets are both source (when a glass element
1652
+ // sits inside a clipped group) and destination of backdrop
1653
+ // snapshots (copySurfaceTo).
1654
+ usage: GPUTextureUsage.RENDER_ATTACHMENT |
1655
+ GPUTextureUsage.TEXTURE_BINDING |
1656
+ GPUTextureUsage.COPY_SRC |
1657
+ GPUTextureUsage.COPY_DST,
1658
+ });
1659
+ const texture = {
1660
+ id: this.nextTextureId++,
1661
+ width: physW,
1662
+ height: physH,
1663
+ gpuTexture,
1664
+ view: gpuTexture.createView(),
1665
+ };
1666
+ this.liveTextures.add(texture);
1667
+ const target = { texture, width, height };
1668
+ this.renderTargets.add(target);
1669
+ return target;
1670
+ }
1671
+ destroyRenderTarget(target) {
1672
+ this.renderTargets.delete(target);
1673
+ this.destroyTexture(target.texture);
1674
+ }
1675
+ pushTarget(target, clearColor = [0, 0, 0, 0]) {
1676
+ if (!this.passEncoder)
1677
+ return;
1678
+ if (!this.renderTargets.has(target)) {
1679
+ getLogger().warn('pushTarget with unknown / destroyed target — ignored');
1680
+ return;
1681
+ }
1682
+ const tex = target.texture;
1683
+ this.surfaceStack.push({ view: tex.view, texture: tex.gpuTexture, width: target.width, height: target.height });
1684
+ this.restartPass(tex.view, 'clear', clearColor);
1685
+ }
1686
+ popTarget() {
1687
+ if (this.surfaceStack.length === 0) {
1688
+ getLogger().warn('popTarget without matching pushTarget — ignored');
1689
+ return;
1690
+ }
1691
+ this.surfaceStack.pop();
1692
+ const s = this.currentSurface();
1693
+ if (!s.view)
1694
+ return;
1695
+ // Resume on the previous surface, PRESERVING what's already drawn.
1696
+ this.restartPass(s.view, 'load');
1697
+ }
1698
+ /**
1699
+ * Acquire the next free uniform buffer from the pool. Grows the pool by
1700
+ * one buffer when exhausted. Buffers are reused across frames; the queue
1701
+ * serializes writes so it's safe to overwrite them once beginFrame resets
1702
+ * the index.
1703
+ */
1704
+ acquireUniformBuffer() {
1705
+ let buffer = this.uniformBufferPool[this.uniformBufferIndex];
1706
+ if (!buffer) {
1707
+ buffer = this.device.createBuffer({
1708
+ label: `pooled uniform [${this.uniformBufferIndex}]`,
1709
+ size: WebGPUBackend.UNIFORM_SIZE,
1710
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
1711
+ });
1712
+ this.uniformBufferPool.push(buffer);
1713
+ }
1714
+ this.uniformBufferIndex++;
1715
+ return buffer;
1716
+ }
1717
+ endFrame() {
1718
+ if (!this.passEncoder || !this.commandEncoder)
1719
+ return;
1720
+ this.passEncoder.end();
1721
+ this.device.queue.submit([this.commandEncoder.finish()]);
1722
+ this.passEncoder = null;
1723
+ this.commandEncoder = null;
1724
+ }
1725
+ // ─── Drawing ──────────────────────────────────────────────────────────────
1726
+ drawShapeShadow(params) {
1727
+ if (!this.passEncoder)
1728
+ return;
1729
+ if (params.blur <= 0 && params.offsetX === 0 && params.offsetY === 0)
1730
+ return;
1731
+ const blur = Math.max(0, params.blur);
1732
+ const quadW = params.width + blur * 2;
1733
+ const quadH = params.height + blur * 2;
1734
+ const surface = this.currentSurface();
1735
+ const transform = params.transform
1736
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, false)
1737
+ : composeQuadTransform(params.cx + params.offsetX, params.cy + params.offsetY, quadW, quadH, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0);
1738
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1739
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1740
+ // 128-byte layout matching SHADOW_SHADER ShadowUniforms:
1741
+ // 0..15 transform
1742
+ // 16..19 color
1743
+ // 20 cornerRadius
1744
+ // 21 shapeType
1745
+ // 22 blur
1746
+ // 23 _pad0
1747
+ // 24..25 size
1748
+ // 26..27 quadSize
1749
+ // 28..31 _pad1
1750
+ const data = this.uniformScratch;
1751
+ data.set(transform, 0);
1752
+ data.set(params.color, 16);
1753
+ data[20] = cornerRadius;
1754
+ data[21] = shapeType;
1755
+ data[22] = blur;
1756
+ data[23] = 0;
1757
+ data[24] = params.width;
1758
+ data[25] = params.height;
1759
+ data[26] = quadW;
1760
+ data[27] = quadH;
1761
+ data[28] = 0;
1762
+ data[29] = 0;
1763
+ data[30] = 0;
1764
+ data[31] = 0;
1765
+ const buffer = this.acquireUniformBuffer();
1766
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 128);
1767
+ const bindGroup = this.device.createBindGroup({
1768
+ layout: this.shadowBindGroupLayout,
1769
+ entries: [{ binding: 0, resource: { buffer } }],
1770
+ });
1771
+ this.passEncoder.setPipeline(this.shadowPipeline);
1772
+ this.passEncoder.setBindGroup(0, bindGroup);
1773
+ this.passEncoder.draw(6, 1, 0, 0);
1774
+ }
1775
+ drawShape(params) {
1776
+ if (!this.passEncoder)
1777
+ return;
1778
+ if (params.gradient) {
1779
+ this.drawGradientShape(params);
1780
+ return;
1781
+ }
1782
+ if (params.lit) {
1783
+ this.drawLitShape(params);
1784
+ return;
1785
+ }
1786
+ const surfaceA = this.currentSurface();
1787
+ const transform = params.transform
1788
+ ? projectPixelMatrix(params.transform, surfaceA.width, surfaceA.height, false)
1789
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surfaceA.width, surfaceA.height, params.skewX ?? 0, params.skewY ?? 0);
1790
+ // cornerRadius is now PIXELS (no longer normalized). Clamp to half the
1791
+ // smaller dimension so a radius bigger than the quad doesn't overflow.
1792
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1793
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1794
+ // 128-byte layout (rounded up from 120 for uniform-buffer alignment):
1795
+ // 0..15 transform (mat4)
1796
+ // 16..19 color (fill)
1797
+ // 20..23 strokeColor
1798
+ // 24 cornerRadius
1799
+ // 25 shapeType
1800
+ // 26..27 size (w, h)
1801
+ // 28 strokeWidth
1802
+ // 29 _pad
1803
+ const sw = params.strokeWidth ?? 0;
1804
+ const sc = params.strokeColor ?? params.color;
1805
+ const data = this.uniformScratch;
1806
+ data.set(transform, 0);
1807
+ data.set(params.color, 16);
1808
+ data.set(sc, 20);
1809
+ data[24] = cornerRadius;
1810
+ data[25] = shapeType;
1811
+ data[26] = params.width;
1812
+ data[27] = params.height;
1813
+ data[28] = sw;
1814
+ data[29] = 0; // pad
1815
+ const buffer = this.acquireUniformBuffer();
1816
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 128);
1817
+ const bindGroup = this.device.createBindGroup({
1818
+ layout: this.shapeBindGroupLayout,
1819
+ entries: [{ binding: 0, resource: { buffer } }],
1820
+ });
1821
+ this.passEncoder.setPipeline(this.pipelineFor(this.shapePipeline, 'shape', params.blend));
1822
+ this.passEncoder.setBindGroup(0, bindGroup);
1823
+ this.passEncoder.draw(6, 1, 0, 0);
1824
+ }
1825
+ drawLitShape(params) {
1826
+ if (!this.passEncoder || !params.lit)
1827
+ return;
1828
+ const lit = params.lit;
1829
+ const surface = this.currentSurface();
1830
+ const transform = params.transform
1831
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, false)
1832
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0);
1833
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1834
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1835
+ const sw = params.strokeWidth ?? 0;
1836
+ const salb = lit.strokeAlbedo ?? lit.albedo;
1837
+ // 496-byte layout matching LitUniforms (see LIT_SHAPE_SHADER).
1838
+ const data = this.uniformScratch;
1839
+ data.set(transform, 0); // transform @ 0
1840
+ data.set(lit.albedo, 32); // albedo @ 128
1841
+ data.set(salb, 36); // strokeAlbedo @ 144
1842
+ data[52] = cornerRadius;
1843
+ data[53] = shapeType;
1844
+ data[54] = sw; // params0.xyz @ 208 (.w = numLights set below)
1845
+ data[60] = params.width;
1846
+ data[61] = params.height;
1847
+ data[62] = 0;
1848
+ data[63] = 0; // size @ 240
1849
+ this.packLitPbr(data, lit);
1850
+ const buffer = this.acquireUniformBuffer();
1851
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 528);
1852
+ const normalView = lit.normalMap ? lit.normalMap.texture.view : this.getFlatNormalView();
1853
+ const envView = lit.env?.image ? lit.env.image.view : this.getFlatNormalView();
1854
+ const bindGroup = this.device.createBindGroup({
1855
+ layout: this.litShapeBindGroupLayout,
1856
+ entries: [
1857
+ { binding: 0, resource: { buffer } },
1858
+ { binding: 1, resource: this.sampler },
1859
+ { binding: 2, resource: normalView },
1860
+ { binding: 3, resource: envView },
1861
+ ],
1862
+ });
1863
+ this.passEncoder.setPipeline(this.pipelineFor(this.litShapePipeline, 'litShape', params.blend));
1864
+ this.passEncoder.setBindGroup(0, bindGroup);
1865
+ this.passEncoder.draw(6, 1, 0, 0);
1866
+ }
1867
+ // Fill the PBR-shared LitUniforms slots (worldMatrix, normal, eye,
1868
+ // ambient, params0.w numLights, params1, lights, environment). Callers
1869
+ // write the variant slots (transform, albedo/tint, strokeAlbedo/uvRect,
1870
+ // params0.xyz, size) before calling.
1871
+ packLitPbr(data, lit) {
1872
+ data.set(lit.worldMatrix, 16); // worldMatrix @ 64
1873
+ data[40] = lit.normal[0];
1874
+ data[41] = lit.normal[1];
1875
+ data[42] = lit.normal[2];
1876
+ data[43] = 0; // normal @ 160
1877
+ data[44] = lit.eye[0];
1878
+ data[45] = lit.eye[1];
1879
+ data[46] = lit.eye[2];
1880
+ data[47] = 0; // eye @ 176
1881
+ data[48] = lit.ambient[0];
1882
+ data[49] = lit.ambient[1];
1883
+ data[50] = lit.ambient[2];
1884
+ data[51] = 0; // ambient @ 192
1885
+ data[55] = Math.min(4, lit.lightDirs.length); // params0.w numLights @ 220
1886
+ data[56] = lit.roughness;
1887
+ data[57] = lit.metalness;
1888
+ data[58] = lit.reflectivity;
1889
+ data[59] = lit.emissive; // params1 @ 224
1890
+ for (let i = 0; i < 4; i++) { // lightDir[4] @ 256
1891
+ const d = lit.lightDirs[i];
1892
+ const base = 64 + i * 4;
1893
+ data[base] = d ? d[0] : 0;
1894
+ data[base + 1] = d ? d[1] : 0;
1895
+ data[base + 2] = d ? d[2] : 0;
1896
+ data[base + 3] = 0;
1897
+ }
1898
+ for (let i = 0; i < 4; i++) { // lightColor[4] @ 320
1899
+ const c = lit.lightColors[i];
1900
+ const base = 80 + i * 4;
1901
+ data[base] = c ? c[0] : 0;
1902
+ data[base + 1] = c ? c[1] : 0;
1903
+ data[base + 2] = c ? c[2] : 0;
1904
+ data[base + 3] = 0;
1905
+ }
1906
+ const env = lit.env;
1907
+ const ec = env ? Math.min(4, env.stopColors.length) : 0;
1908
+ for (let i = 0; i < 4; i++) { // envColor[4] @ 384
1909
+ const c = env && i < ec ? env.stopColors[i] : undefined;
1910
+ const base = 96 + i * 4;
1911
+ data[base] = c ? c[0] : 0;
1912
+ data[base + 1] = c ? c[1] : 0;
1913
+ data[base + 2] = c ? c[2] : 0;
1914
+ data[base + 3] = 0;
1915
+ }
1916
+ // envParams: x=stopCount, y=normalScale, z=hasNormalMap, w=envIsImage.
1917
+ const nm = lit.normalMap;
1918
+ const envIsImage = lit.env?.image ? 1 : 0;
1919
+ data[112] = ec;
1920
+ data[113] = nm ? nm.scale : 1;
1921
+ data[114] = nm ? 1 : 0;
1922
+ data[115] = envIsImage; // envParams @ 448
1923
+ for (let i = 0; i < 4; i++)
1924
+ data[116 + i] = env && i < ec ? env.stopOffsets[i] : 0; // envOffsets @ 464
1925
+ data[120] = env ? env.avg[0] : 0;
1926
+ data[121] = env ? env.avg[1] : 0;
1927
+ data[122] = env ? env.avg[2] : 0;
1928
+ data[123] = 0; // envAvg @ 480
1929
+ // tangent @ 496 (float 124), bitangent @ 512 (float 128).
1930
+ data[124] = nm ? nm.tangent[0] : 1;
1931
+ data[125] = nm ? nm.tangent[1] : 0;
1932
+ data[126] = nm ? nm.tangent[2] : 0;
1933
+ data[127] = 0;
1934
+ data[128] = nm ? nm.bitangent[0] : 0;
1935
+ data[129] = nm ? nm.bitangent[1] : 1;
1936
+ data[130] = nm ? nm.bitangent[2] : 0;
1937
+ data[131] = 0;
1938
+ }
1939
+ // 1×1 flat tangent-space normal (#8080ff) bound when a lit draw has no
1940
+ // normal map, so the sampler binding is always valid.
1941
+ getFlatNormalView() {
1942
+ if (!this.flatNormalView) {
1943
+ const tex = this.device.createTexture({
1944
+ size: { width: 1, height: 1 },
1945
+ format: 'rgba8unorm',
1946
+ usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST,
1947
+ });
1948
+ this.device.queue.writeTexture({ texture: tex }, new Uint8Array([128, 128, 255, 255]), { bytesPerRow: 4, rowsPerImage: 1 }, { width: 1, height: 1 });
1949
+ this.flatNormalView = tex.createView();
1950
+ }
1951
+ return this.flatNormalView;
1952
+ }
1953
+ drawLitTexturedQuad(params) {
1954
+ if (!this.passEncoder || !params.lit)
1955
+ return;
1956
+ const lit = params.lit;
1957
+ const surface = this.currentSurface();
1958
+ const transform = params.transform
1959
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, false)
1960
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0);
1961
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1962
+ const uvRect = params.uvRect ?? [0, 0, 1, 1];
1963
+ const tint = params.tint ?? [1, 1, 1, 1];
1964
+ // Reuse LitUniforms: albedo slot = premultiplied tint, strokeAlbedo
1965
+ // slot = uvRect, params0.x = cornerRadius. (See LIT_TEXTURED_SHADER.)
1966
+ const data = this.uniformScratch;
1967
+ data.set(transform, 0); // transform @ 0
1968
+ data[32] = tint[0];
1969
+ data[33] = tint[1];
1970
+ data[34] = tint[2];
1971
+ data[35] = tint[3]; // tint @ 128
1972
+ data[36] = uvRect[0];
1973
+ data[37] = uvRect[1];
1974
+ data[38] = uvRect[2];
1975
+ data[39] = uvRect[3]; // uvRect @ 144
1976
+ data[52] = cornerRadius;
1977
+ data[53] = 0;
1978
+ data[54] = 0; // params0.xyz @ 208
1979
+ data[60] = params.width;
1980
+ data[61] = params.height;
1981
+ data[62] = 0;
1982
+ data[63] = 0; // size @ 240
1983
+ this.packLitPbr(data, lit);
1984
+ const buffer = this.acquireUniformBuffer();
1985
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 528);
1986
+ const tex = params.texture;
1987
+ const normalView = lit.normalMap ? lit.normalMap.texture.view : this.getFlatNormalView();
1988
+ const envView = lit.env?.image ? lit.env.image.view : this.getFlatNormalView();
1989
+ const bindGroup = this.device.createBindGroup({
1990
+ layout: this.litTexturedBindGroupLayout, // uniform + sampler + albedo + normal + env
1991
+ entries: [
1992
+ { binding: 0, resource: { buffer } },
1993
+ { binding: 1, resource: this.sampler },
1994
+ { binding: 2, resource: tex.view },
1995
+ { binding: 3, resource: normalView },
1996
+ { binding: 4, resource: envView },
1997
+ ],
1998
+ });
1999
+ this.passEncoder.setPipeline(this.pipelineFor(this.litTexturedPipeline, 'litTextured', params.blend));
2000
+ this.passEncoder.setBindGroup(0, bindGroup);
2001
+ this.passEncoder.draw(6, 1, 0, 0);
2002
+ }
2003
+ drawGradientShape(params) {
2004
+ if (!this.passEncoder || !params.gradient)
2005
+ return;
2006
+ const surfaceB = this.currentSurface();
2007
+ const transform = params.transform
2008
+ ? projectPixelMatrix(params.transform, surfaceB.width, surfaceB.height, false)
2009
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surfaceB.width, surfaceB.height, params.skewX ?? 0, params.skewY ?? 0);
2010
+ // cornerRadius in PIXELS (no longer normalized). See drawShape comment.
2011
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
2012
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
2013
+ const g = params.gradient;
2014
+ const fillType = g.type === 'radial' ? 1 : 0;
2015
+ const stops = g.stops.slice(0, 4);
2016
+ const nStops = Math.max(2, stops.length);
2017
+ // 192-byte layout:
2018
+ // 0..63 transform (16 floats)
2019
+ // 64..79 flags (cornerRadius_PX, shapeType, fillType, numStops)
2020
+ // 80..95 params (linear: cos, sin, 0, 0 | radial: cx, cy, radius, 0)
2021
+ // 96..111 size (width_px, height_px, 0, 0)
2022
+ // 112..175 stops[4] colors (4 × vec4)
2023
+ // 176..191 stopOffsets (4 floats)
2024
+ const data = this.uniformScratch;
2025
+ data.set(transform, 0);
2026
+ data[16] = cornerRadius;
2027
+ data[17] = shapeType;
2028
+ data[18] = fillType;
2029
+ data[19] = nStops;
2030
+ if (g.type === 'linear') {
2031
+ data[20] = Math.cos(g.angle);
2032
+ data[21] = Math.sin(g.angle);
2033
+ data[22] = 0;
2034
+ data[23] = 0;
2035
+ }
2036
+ else {
2037
+ data[20] = g.cx;
2038
+ data[21] = g.cy;
2039
+ data[22] = g.radius;
2040
+ data[23] = 0;
2041
+ }
2042
+ // size @ floats 24..27
2043
+ data[24] = params.width;
2044
+ data[25] = params.height;
2045
+ data[26] = 0;
2046
+ data[27] = 0;
2047
+ // Stop colors @ offsets 28..43 (4 floats each, 4 stops).
2048
+ for (let i = 0; i < 4; i++) {
2049
+ const stop = stops[i] ?? stops[stops.length - 1]; // pad with last stop
2050
+ const base = 28 + i * 4;
2051
+ data[base] = stop.color[0];
2052
+ data[base + 1] = stop.color[1];
2053
+ data[base + 2] = stop.color[2];
2054
+ data[base + 3] = stop.color[3];
2055
+ }
2056
+ // Stop offsets @ floats 44..47.
2057
+ for (let i = 0; i < 4; i++) {
2058
+ const stop = stops[i];
2059
+ data[44 + i] = stop ? stop.offset : 1;
2060
+ }
2061
+ const buffer = this.acquireUniformBuffer();
2062
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 192);
2063
+ const bindGroup = this.device.createBindGroup({
2064
+ layout: this.gradientBindGroupLayout,
2065
+ entries: [{ binding: 0, resource: { buffer } }],
2066
+ });
2067
+ this.passEncoder.setPipeline(this.pipelineFor(this.gradientPipeline, 'gradient', params.blend));
2068
+ this.passEncoder.setBindGroup(0, bindGroup);
2069
+ this.passEncoder.draw(6, 1, 0, 0);
2070
+ }
2071
+ drawTexturedQuad(params) {
2072
+ if (!this.passEncoder)
2073
+ return;
2074
+ if (params.lit) {
2075
+ this.drawLitTexturedQuad(params);
2076
+ return;
2077
+ }
2078
+ const surfaceC = this.currentSurface();
2079
+ const transform = params.transform
2080
+ ? projectPixelMatrix(params.transform, surfaceC.width, surfaceC.height, false)
2081
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surfaceC.width, surfaceC.height, params.skewX ?? 0, params.skewY ?? 0);
2082
+ const uvRect = params.uvRect ?? [0, 0, 1, 1];
2083
+ const tint = params.tint ?? [1, 1, 1, 1];
2084
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
2085
+ // 128-byte layout matching TEXTURED_SHADER TexturedUniforms:
2086
+ // 0..15 transform
2087
+ // 16..19 uvRect
2088
+ // 20..23 tint
2089
+ // 24 cornerRadius
2090
+ // 25 alphaGamma
2091
+ // 26..27 size (w, h)
2092
+ // 28..31 _pad1
2093
+ const data = this.uniformScratch;
2094
+ data.set(transform, 0);
2095
+ data.set(uvRect, 16);
2096
+ data.set(tint, 20);
2097
+ data[24] = cornerRadius;
2098
+ data[25] = params.alphaGamma ?? 1;
2099
+ data[26] = params.width;
2100
+ data[27] = params.height;
2101
+ data[28] = 0;
2102
+ data[29] = 0;
2103
+ data[30] = 0;
2104
+ data[31] = 0;
2105
+ const buffer = this.acquireUniformBuffer();
2106
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 128);
2107
+ const t = params.texture;
2108
+ const bindGroup = this.device.createBindGroup({
2109
+ layout: this.texturedBindGroupLayout,
2110
+ entries: [
2111
+ { binding: 0, resource: { buffer } },
2112
+ { binding: 1, resource: this.sampler },
2113
+ { binding: 2, resource: t.view },
2114
+ ],
2115
+ });
2116
+ this.passEncoder.setPipeline(this.pipelineFor(this.texturedPipeline, 'textured', params.blend));
2117
+ this.passEncoder.setBindGroup(0, bindGroup);
2118
+ this.passEncoder.draw(6, 1, 0, 0);
2119
+ }
2120
+ drawMaskedQuad(params) {
2121
+ if (!this.passEncoder)
2122
+ return;
2123
+ const surface = this.currentSurface();
2124
+ const transform = params.transform
2125
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, false)
2126
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height);
2127
+ const tint = params.tint ?? [1, 1, 1, 1];
2128
+ const mode = params.mode === 'alpha' ? 0 :
2129
+ params.mode === 'alpha-inverted' ? 1 :
2130
+ params.mode === 'luma' ? 2 : 3;
2131
+ // 96-byte layout matching MASKED_SHADER MaskedUniforms:
2132
+ // 0..15 transform
2133
+ // 16..19 tint
2134
+ // 20 mode
2135
+ // 21..23 padding
2136
+ const data = this.uniformScratch;
2137
+ data.set(transform, 0);
2138
+ data.set(tint, 16);
2139
+ data[20] = mode;
2140
+ data[21] = 0;
2141
+ data[22] = 0;
2142
+ data[23] = 0;
2143
+ const buffer = this.acquireUniformBuffer();
2144
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 96);
2145
+ const content = params.content;
2146
+ const mask = params.mask;
2147
+ const bindGroup = this.device.createBindGroup({
2148
+ layout: this.maskedBindGroupLayout,
2149
+ entries: [
2150
+ { binding: 0, resource: { buffer } },
2151
+ { binding: 1, resource: this.sampler },
2152
+ { binding: 2, resource: content.view },
2153
+ { binding: 3, resource: mask.view },
2154
+ ],
2155
+ });
2156
+ this.passEncoder.setPipeline(this.pipelineFor(this.maskedPipeline, 'masked', params.blend));
2157
+ this.passEncoder.setBindGroup(0, bindGroup);
2158
+ this.passEncoder.draw(6, 1, 0, 0);
2159
+ }
2160
+ drawBackdropBlend(params) {
2161
+ if (!this.passEncoder)
2162
+ return;
2163
+ const surface = this.currentSurface();
2164
+ const transform = composeQuadTransform(params.width / 2, params.height / 2, params.width, params.height, 0, surface.width, surface.height);
2165
+ const mode = params.mode === 'overlay' ? 0 : params.mode === 'hard-light' ? 1 : 2;
2166
+ // 80-byte layout matching BBUniforms: transform[0..15], mode[16],
2167
+ // backdropFlipY[17], pad[18..19].
2168
+ const data = this.uniformScratch;
2169
+ data.set(transform, 0);
2170
+ data[16] = mode;
2171
+ data[17] = params.backdropFlipY ? 1 : 0;
2172
+ data[18] = 0;
2173
+ data[19] = 0;
2174
+ const buffer = this.acquireUniformBuffer();
2175
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 80);
2176
+ const src = params.src;
2177
+ const backdrop = params.backdrop;
2178
+ const bindGroup = this.device.createBindGroup({
2179
+ layout: this.maskedBindGroupLayout,
2180
+ entries: [
2181
+ { binding: 0, resource: { buffer } },
2182
+ { binding: 1, resource: this.sampler },
2183
+ { binding: 2, resource: src.view },
2184
+ { binding: 3, resource: backdrop.view },
2185
+ ],
2186
+ });
2187
+ this.passEncoder.setPipeline(this.backdropBlendPipeline);
2188
+ this.passEncoder.setBindGroup(0, bindGroup);
2189
+ this.passEncoder.draw(6, 1, 0, 0);
2190
+ }
2191
+ drawFilteredQuad(params) {
2192
+ if (!this.passEncoder)
2193
+ return;
2194
+ const surface = this.currentSurface();
2195
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height);
2196
+ const tint = params.tint ?? [1, 1, 1, 1];
2197
+ const t = params.texture;
2198
+ // blurRadius is logical px; texture dims are physical, so σ scales
2199
+ // by the pixel ratio and texel offsets divide by physical dims.
2200
+ const sigma = params.blurRadius * this.pixelRatio;
2201
+ // 128-byte layout matching FILTERED_SHADER FilteredUniforms:
2202
+ // 0..15 transform
2203
+ // 16..19 tint
2204
+ // 20..21 texel
2205
+ // 22 sigma
2206
+ // 23 _pad0
2207
+ // 24..27 colorOps (brightness, contrast, saturation, hue radians)
2208
+ const data = this.uniformScratch;
2209
+ data.set(transform, 0);
2210
+ data.set(tint, 16);
2211
+ data[20] = params.blurDir[0] / t.width;
2212
+ data[21] = params.blurDir[1] / t.height;
2213
+ data[22] = sigma;
2214
+ data[23] = 0;
2215
+ data[24] = params.brightness;
2216
+ data[25] = params.contrast;
2217
+ data[26] = params.saturation;
2218
+ data[27] = ((params.hueRotate ?? 0) * Math.PI) / 180;
2219
+ data[28] = 0;
2220
+ data[29] = 0;
2221
+ data[30] = 0;
2222
+ data[31] = 0;
2223
+ const buffer = this.acquireUniformBuffer();
2224
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 128);
2225
+ const bindGroup = this.device.createBindGroup({
2226
+ layout: this.texturedBindGroupLayout,
2227
+ entries: [
2228
+ { binding: 0, resource: { buffer } },
2229
+ { binding: 1, resource: this.sampler },
2230
+ { binding: 2, resource: t.view },
2231
+ ],
2232
+ });
2233
+ this.passEncoder.setPipeline(this.pipelineFor(this.filteredPipeline, 'filtered', params.blend));
2234
+ this.passEncoder.setBindGroup(0, bindGroup);
2235
+ this.passEncoder.draw(6, 1, 0, 0);
2236
+ }
2237
+ drawStylizedQuad(params) {
2238
+ if (!this.passEncoder)
2239
+ return;
2240
+ const surface = this.currentSurface();
2241
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height);
2242
+ const tint = params.tint ?? [1, 1, 1, 1];
2243
+ const t = params.texture;
2244
+ const aux = (params.aux ?? params.texture);
2245
+ // px-dimensioned params scale to PHYSICAL pixels; counts/angles/
2246
+ // intensities don't.
2247
+ const p0Px = params.mode !== 'dither' && params.mode !== 'glow'
2248
+ && params.mode !== 'chroma_key' && params.mode !== 'luma_key'
2249
+ && params.mode !== 'levels' && params.mode !== 'lut';
2250
+ const p1Px = params.mode === 'drop_shadow' || params.mode === 'turbulent_displace';
2251
+ const p0 = p0Px ? params.p0 * this.pixelRatio : params.p0;
2252
+ const p1 = p1Px ? (params.p1 ?? 0) * this.pixelRatio : (params.p1 ?? 0);
2253
+ const modeIdx = STYLIZE_MODE_INDEX[params.mode];
2254
+ // 112-byte layout matching STYLIZED_SHADER StylizedUniforms:
2255
+ // 0..15 transform
2256
+ // 16..19 tint
2257
+ // 20..21 texSize
2258
+ // 22 mode
2259
+ // 23 p0
2260
+ // 24 p1
2261
+ // 25..27 padding
2262
+ const data = this.uniformScratch;
2263
+ data.set(transform, 0);
2264
+ data.set(tint, 16);
2265
+ data[20] = t.width;
2266
+ data[21] = t.height;
2267
+ data[22] = modeIdx;
2268
+ data[23] = p0;
2269
+ data[24] = p1;
2270
+ data[25] = this.pixelRatio;
2271
+ data[26] = 0;
2272
+ data[27] = 0; // pixelRatio @ offset 100
2273
+ const buffer = this.acquireUniformBuffer();
2274
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, 112);
2275
+ const bindGroup = this.device.createBindGroup({
2276
+ layout: this.maskedBindGroupLayout,
2277
+ entries: [
2278
+ { binding: 0, resource: { buffer } },
2279
+ { binding: 1, resource: this.sampler },
2280
+ { binding: 2, resource: t.view },
2281
+ { binding: 3, resource: aux.view },
2282
+ ],
2283
+ });
2284
+ this.passEncoder.setPipeline(this.pipelineFor(this.stylizedPipeline, 'stylized', params.blend));
2285
+ this.passEncoder.setBindGroup(0, bindGroup);
2286
+ this.passEncoder.draw(6, 1, 0, 0);
2287
+ }
2288
+ drawGlassQuad(params) {
2289
+ if (!this.passEncoder)
2290
+ return;
2291
+ const surface = this.currentSurface();
2292
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height);
2293
+ const backdrop = params.backdrop;
2294
+ const sharp = params.backdropSharp;
2295
+ const pr = this.pixelRatio;
2296
+ const rad = (params.rotation * Math.PI) / 180;
2297
+ // 176-byte layout matching GLASS_SHADER GlassUniforms.
2298
+ const data = this.uniformScratch;
2299
+ data.set(transform, 0);
2300
+ data.set(params.tint, 16);
2301
+ // Surface dims, NOT the frosted texture's — the blur ladder
2302
+ // downsamples it; normalized UVs sample it fine either way.
2303
+ data[20] = surface.width * pr;
2304
+ data[21] = surface.height * pr;
2305
+ data[22] = params.paneCx * pr;
2306
+ data[23] = params.paneCy * pr;
2307
+ data[24] = params.paneHalfW * pr;
2308
+ data[25] = params.paneHalfH * pr;
2309
+ data[26] = Math.cos(rad);
2310
+ data[27] = Math.sin(rad);
2311
+ data[28] = params.cornerRadius * pr;
2312
+ data[29] = params.zRadius * pr;
2313
+ data[30] = params.bevelMode;
2314
+ data[31] = params.backdropFlipY ? 1 : 0;
2315
+ data[32] = params.refract;
2316
+ data[33] = params.chroma;
2317
+ data[34] = params.edgeHighlight;
2318
+ data[35] = params.fresnel;
2319
+ data[36] = params.specular;
2320
+ data[37] = params.saturation;
2321
+ data[38] = params.alpha;
2322
+ data[39] = 0;
2323
+ data[40] = params.shadowAlpha;
2324
+ data[41] = params.shadowSpread * pr;
2325
+ data[42] = params.shadowOffY * pr;
2326
+ data[43] = 0;
2327
+ // CKP/1.0 glass under 3D (§4.7): a pane homography selects the
2328
+ // lazily-created projective variant. A singular homography is the
2329
+ // edge-on degenerate case — the pane is invisible, draw nothing.
2330
+ let projective = false;
2331
+ if (params.paneHomography) {
2332
+ const h = homographyToPhysical(params.paneHomography, pr);
2333
+ const hinv = invertHomography(h);
2334
+ if (!hinv)
2335
+ return;
2336
+ projective = true;
2337
+ for (let c = 0; c < 3; c++) {
2338
+ data[44 + c * 4] = h[c * 3];
2339
+ data[45 + c * 4] = h[c * 3 + 1];
2340
+ data[46 + c * 4] = h[c * 3 + 2];
2341
+ data[47 + c * 4] = 0;
2342
+ data[56 + c * 4] = hinv[c * 3];
2343
+ data[57 + c * 4] = hinv[c * 3 + 1];
2344
+ data[58 + c * 4] = hinv[c * 3 + 2];
2345
+ data[59 + c * 4] = 0;
2346
+ }
2347
+ if (!this.glass3dPipeline) {
2348
+ const module = this.device.createShaderModule({
2349
+ code: glassShaderSource(true), label: 'glass3d',
2350
+ });
2351
+ this.glass3dPipeline = this.makeBlendablePipeline('glass3d', module, this.maskedBindGroupLayout);
2352
+ }
2353
+ }
2354
+ const buffer = this.acquireUniformBuffer();
2355
+ this.device.queue.writeBuffer(buffer, 0, data.buffer, data.byteOffset, projective ? 272 : 176);
2356
+ const bindGroup = this.device.createBindGroup({
2357
+ layout: this.maskedBindGroupLayout,
2358
+ entries: [
2359
+ { binding: 0, resource: { buffer } },
2360
+ { binding: 1, resource: this.sampler },
2361
+ { binding: 2, resource: backdrop.view },
2362
+ { binding: 3, resource: sharp.view },
2363
+ ],
2364
+ });
2365
+ this.passEncoder.setPipeline(projective
2366
+ ? this.pipelineFor(this.glass3dPipeline, 'glass3d', params.blend)
2367
+ : this.pipelineFor(this.glassPipeline, 'glass', params.blend));
2368
+ this.passEncoder.setBindGroup(0, bindGroup);
2369
+ this.passEncoder.draw(6, 1, 0, 0);
2370
+ }
2371
+ copySurfaceTo(target) {
2372
+ if (!this.commandEncoder)
2373
+ return { flippedY: false };
2374
+ if (!this.renderTargets.has(target)) {
2375
+ getLogger().warn('copySurfaceTo with unknown / destroyed target — ignored');
2376
+ return { flippedY: false };
2377
+ }
2378
+ const top = this.surfaceStack[this.surfaceStack.length - 1];
2379
+ const srcTexture = top ? top.texture : this.canvasTexture;
2380
+ const srcView = top ? top.view : this.canvasView;
2381
+ if (!srcTexture || !srcView)
2382
+ return { flippedY: false };
2383
+ const dst = target.texture;
2384
+ // Texture copies can't be recorded inside a render pass — end the
2385
+ // current pass, copy, and resume on the same surface (loadOp
2386
+ // 'load' preserves everything drawn so far).
2387
+ this.passEncoder?.end();
2388
+ this.passEncoder = null;
2389
+ this.commandEncoder.copyTextureToTexture({ texture: srcTexture }, { texture: dst.gpuTexture }, {
2390
+ width: Math.min(srcTexture.width, dst.gpuTexture.width),
2391
+ height: Math.min(srcTexture.height, dst.gpuTexture.height),
2392
+ });
2393
+ this.restartPass(srcView, 'load');
2394
+ // WebGPU textures are top-down everywhere — never flipped.
2395
+ return { flippedY: false };
2396
+ }
2397
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
2398
+ async finish() {
2399
+ await this.device.queue.onSubmittedWorkDone();
2400
+ }
2401
+ dispose() {
2402
+ if (this.disposed)
2403
+ return;
2404
+ this.disposed = true;
2405
+ if (this.passEncoder) {
2406
+ this.passEncoder.end();
2407
+ this.passEncoder = null;
2408
+ }
2409
+ this.commandEncoder = null;
2410
+ for (const t of this.liveTextures)
2411
+ t.gpuTexture.destroy();
2412
+ this.liveTextures.clear();
2413
+ for (const buf of this.uniformBufferPool)
2414
+ buf.destroy();
2415
+ this.uniformBufferPool.length = 0;
2416
+ // Vertex buffer + pipelines + sampler are released when the device is GC'd.
2417
+ // We don't explicitly destroy() them because GPUBuffer.destroy() exists
2418
+ // but pipelines/samplers don't have an explicit destroy.
2419
+ if (this.vertexBuffer)
2420
+ this.vertexBuffer.destroy();
2421
+ }
2422
+ }
2423
+ // ─── Helpers ────────────────────────────────────────────────────────────────
2424
+ const PREMUL_BLEND = {
2425
+ // Source is premultiplied: out = src + dst * (1 - src.a).
2426
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
2427
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
2428
+ };
2429
+ // Overwrite the target: out = src. The backdrop-blend shader emits the
2430
+ // full composite (incl. backdrop where the element is transparent), so
2431
+ // the existing destination must be replaced, not blended into.
2432
+ const REPLACE_BLEND = {
2433
+ color: { srcFactor: 'one', dstFactor: 'zero', operation: 'add' },
2434
+ alpha: { srcFactor: 'one', dstFactor: 'zero', operation: 'add' },
2435
+ };
2436
+ // Non-normal blend modes, fixed-function over premultiplied sources.
2437
+ // Alpha channel always composites normally so coverage stays correct;
2438
+ // only the color math changes. Must match the WebGL backend's
2439
+ // applyBlend() factors exactly — preview and export run different
2440
+ // backends and the protocol demands identical pixels.
2441
+ const PREMUL_ALPHA = {
2442
+ srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add',
2443
+ };
2444
+ const BLEND_STATES = {
2445
+ // out = src + dst (linear dodge); transparent source pixels add 0.
2446
+ add: {
2447
+ color: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
2448
+ alpha: PREMUL_ALPHA,
2449
+ },
2450
+ // out = src * dst + dst * (1 - src.a) — darkens; white is neutral,
2451
+ // and uncovered (alpha 0) source pixels leave the destination alone.
2452
+ multiply: {
2453
+ color: { srcFactor: 'dst', dstFactor: 'one-minus-src-alpha', operation: 'add' },
2454
+ alpha: PREMUL_ALPHA,
2455
+ },
2456
+ // out = src + dst * (1 - src) — lightens; black is neutral.
2457
+ screen: {
2458
+ color: { srcFactor: 'one', dstFactor: 'one-minus-src', operation: 'add' },
2459
+ alpha: PREMUL_ALPHA,
2460
+ },
2461
+ };
2462
+ function sourceDimensions(source) {
2463
+ if ('codedWidth' in source && 'codedHeight' in source) {
2464
+ // VideoFrame
2465
+ return { width: source.codedWidth, height: source.codedHeight };
2466
+ }
2467
+ if ('videoWidth' in source && 'videoHeight' in source) {
2468
+ // HTMLVideoElement
2469
+ return { width: source.videoWidth, height: source.videoHeight };
2470
+ }
2471
+ if ('naturalWidth' in source && 'naturalHeight' in source) {
2472
+ // HTMLImageElement
2473
+ return { width: source.naturalWidth, height: source.naturalHeight };
2474
+ }
2475
+ // ImageBitmap / HTMLCanvasElement / OffscreenCanvas
2476
+ return { width: source.width, height: source.height };
2477
+ }
2478
+ function clamp(n, lo, hi) {
2479
+ return Math.max(lo, Math.min(hi, n));
2480
+ }
2481
+ //# sourceMappingURL=webgpu-backend.js.map