@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,2142 @@
1
+ // WebGL2 implementation of the Backend interface.
2
+ //
3
+ // Same shape as the WebGPU backend (deliberately) so the runtime can swap
4
+ // between them transparently. WebGL2 + GLSL ES 3.0 shaders, premultiplied
5
+ // alpha throughout, shared unit-quad VBO, individual uniforms (no UBOs —
6
+ // simpler and our uniform set is small).
7
+ //
8
+ // Premultiplied alpha discipline:
9
+ // - Source is configured with `premultipliedAlpha: true`
10
+ // - Blend func: ONE / ONE_MINUS_SRC_ALPHA (premultiplied)
11
+ // - Texture upload uses UNPACK_PREMULTIPLY_ALPHA_WEBGL
12
+ // - Shaders assume premultiplied input
13
+ import { composeQuadTransform, homographyToPhysical, invertHomography, projectPixelMatrix } from '../compositor/transform.js';
14
+ import { getLogger } from '../logger.js';
15
+ import { STYLIZE_MODE_INDEX } from './backend.js';
16
+ // ─── Shaders ────────────────────────────────────────────────────────────────
17
+ const SHAPE_VS = `#version 300 es
18
+ in vec2 a_pos;
19
+ in vec2 a_uv;
20
+ out vec2 v_uv;
21
+ uniform mat4 u_transform;
22
+ void main() {
23
+ gl_Position = u_transform * vec4(a_pos, 0.0, 1.0);
24
+ v_uv = a_uv;
25
+ }
26
+ `;
27
+ // Shadow pipeline: draw a quad sized to the shape PLUS blur padding,
28
+ // then in the fragment shader compute the signed distance from each
29
+ // pixel to the un-padded shape and fade alpha to 0 over `blur` pixels.
30
+ // Pixels inside the shape's SDF (negative distance) get full shadow
31
+ // alpha — the companion shape draw paints over them after.
32
+ const SHADOW_FS = `#version 300 es
33
+ precision highp float;
34
+ in vec2 v_uv;
35
+ out vec4 fragColor;
36
+ uniform vec4 u_color; // shadow color, premultiplied
37
+ uniform float u_blur; // PIXELS; falloff distance past edge
38
+ uniform float u_cornerRadius; // PIXELS, of the SHAPE (not quad)
39
+ uniform float u_shapeType; // 0.0 rect, 1.0 ellipse
40
+ uniform vec2 u_size; // pixel (width, height) of the SHAPE
41
+ uniform vec2 u_quadSize; // pixel (width, height) of the rendered quad
42
+ void main() {
43
+ // Pixel position in quad-local space. The shape sits centered, with
44
+ // blur-sized margins on every side, so subtracting half the quad's
45
+ // size and adding half the shape's size positions us in shape-local
46
+ // coords for the SDF calculation.
47
+ vec2 p = v_uv * u_quadSize;
48
+ vec2 shapeHalf = u_size * 0.5;
49
+ vec2 quadHalf = u_quadSize * 0.5;
50
+ vec2 ps = p - quadHalf + shapeHalf; // pixel position in SHAPE's local frame
51
+ float dist;
52
+ if (u_shapeType > 0.5) {
53
+ vec2 d = (ps - shapeHalf) / shapeHalf;
54
+ dist = (sqrt(dot(d, d)) - 1.0) * min(shapeHalf.x, shapeHalf.y);
55
+ } else {
56
+ float r = u_cornerRadius;
57
+ vec2 q = abs(ps - shapeHalf) - shapeHalf + vec2(r);
58
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
59
+ }
60
+ // CSS box-shadow is a Gaussian BLUR of an offset shape, not a
61
+ // hard-edge falloff: alpha is ~1.0 deep inside the shape, ~0.5
62
+ // right at the edge, and tails to 0 about u_blur pixels past the
63
+ // edge. Symmetric smoothstep approximates the erfc shape closely
64
+ // enough — without it the shape-edge alpha is 1.0 instead of 0.5,
65
+ // making shadows look much darker and extend further than CSS.
66
+ if (dist > u_blur) discard;
67
+ float alpha = 1.0 - smoothstep(-u_blur, u_blur, dist);
68
+ if (alpha < 0.001) discard;
69
+ fragColor = u_color * alpha;
70
+ }
71
+ `;
72
+ const SHAPE_FS = `#version 300 es
73
+ precision highp float;
74
+ in vec2 v_uv;
75
+ out vec4 fragColor;
76
+ uniform vec4 u_color; // fill, premultiplied
77
+ uniform vec4 u_strokeColor; // stroke, premultiplied
78
+ uniform float u_strokeWidth; // PIXELS; 0 disables stroke
79
+ uniform float u_cornerRadius; // PIXELS
80
+ uniform float u_shapeType; // 0.0 rect, 1.0 ellipse
81
+ uniform vec2 u_size; // pixel (width, height)
82
+ void main() {
83
+ // Signed pixel distance from the shape boundary: negative inside,
84
+ // positive outside. Used both to discard outside pixels and to
85
+ // decide whether a pixel falls in the stroke band (boundary-side
86
+ // strokeWidth pixels deep).
87
+ vec2 p = v_uv * u_size;
88
+ vec2 half_ = u_size * 0.5;
89
+ float dist;
90
+ if (u_shapeType > 0.5) {
91
+ // Ellipse — exact pixel SDF is hard; use a normalized-space
92
+ // approximation scaled by the smaller half-axis. Exact for
93
+ // circles; underestimates the boundary distance for elongated
94
+ // ellipses, which means the stroke band reads slightly thin near
95
+ // the long-axis ends. Good enough for icon-shaped uses.
96
+ vec2 d = (p - half_) / half_;
97
+ dist = (sqrt(dot(d, d)) - 1.0) * min(half_.x, half_.y);
98
+ } else {
99
+ // Rectangle / rounded rectangle. r = 0 collapses to a sharp rect.
100
+ float r = u_cornerRadius;
101
+ vec2 q = abs(p - half_) - half_ + vec2(r);
102
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
103
+ }
104
+ // Anti-aliased boundary: use screen-space derivative to find the
105
+ // AA band around dist = 0, and blend out as we exit the shape.
106
+ // Without this, rotated rectangles show jagged stairstep edges
107
+ // (the rotated geometry no longer aligns with pixel rows). Band
108
+ // width = 2 × fwidth(dist) — wider than the standard 1 px so edges
109
+ // stay visibly smooth even when the canvas is downsampled to a
110
+ // smaller preview or the display has higher pixel density than the
111
+ // canvas backing store.
112
+ float aa = fwidth(dist);
113
+ float outerAlpha = 1.0 - smoothstep(-aa, aa, dist);
114
+ if (outerAlpha < 0.001) discard;
115
+
116
+ vec4 base;
117
+ if (u_strokeWidth > 0.0) {
118
+ // Stroke band is the [-strokeWidth, 0] interval of dist; the fill
119
+ // interior is dist <= -strokeWidth. strokeAlpha rises from 0 (fill)
120
+ // to 1 (stroke) as dist crosses -strokeWidth, with the same ~1px
121
+ // AA softness applied at the inner boundary so the stroke doesn't
122
+ // stair-step against the fill.
123
+ float strokeAlpha = smoothstep(-u_strokeWidth - aa, -u_strokeWidth + aa, dist);
124
+ base = mix(u_color, u_strokeColor, strokeAlpha);
125
+ } else {
126
+ base = u_color;
127
+ }
128
+ // Premultiplied output: scaling all channels by outerAlpha is correct.
129
+ fragColor = base * outerAlpha;
130
+ }
131
+ `;
132
+ // ── Lit shape path (CKP/1.0 §4.8 PBR) ───────────────────────────────────────
133
+ // Same SDF as SHAPE_FS, but the fill is shaded: Lambert diffuse + GGX
134
+ // specular + Schlick Fresnel from directional lights, in WORLD space, so
135
+ // the highlight is view-dependent and sweeps as the camera moves. Albedo
136
+ // is the shape's straight-alpha fill. u_worldMatrix maps the unit quad to
137
+ // world (pre-camera) for the per-fragment position; u_transform still maps
138
+ // to clip.
139
+ const LIT_SHAPE_VS = `#version 300 es
140
+ in vec2 a_pos;
141
+ in vec2 a_uv;
142
+ out vec2 v_uv;
143
+ out vec3 v_worldPos;
144
+ uniform mat4 u_transform;
145
+ uniform mat4 u_worldMatrix;
146
+ void main() {
147
+ gl_Position = u_transform * vec4(a_pos, 0.0, 1.0);
148
+ v_uv = a_uv;
149
+ vec4 wp = u_worldMatrix * vec4(a_pos, 0.0, 1.0);
150
+ v_worldPos = wp.xyz;
151
+ }
152
+ `;
153
+ // Shared PBR fragment library (§4.8): common uniforms + helpers +
154
+ // shadePBR(albedo, N, V). Concatenated into BOTH the lit-shape and
155
+ // lit-textured fragment shaders so the lighting math can never diverge
156
+ // between vector and textured surfaces. Must stay math-identical to the
157
+ // WGSL pbrLibWGSL() in the WebGPU backend.
158
+ const PBR_FS_LIB = `
159
+ uniform vec3 u_normal;
160
+ uniform vec3 u_eye;
161
+ uniform float u_rough;
162
+ uniform float u_metal;
163
+ uniform float u_reflect;
164
+ uniform float u_emissive;
165
+ uniform vec3 u_ambient;
166
+ uniform int u_numLights;
167
+ uniform vec3 u_lightDir[4];
168
+ uniform vec3 u_lightColor[4];
169
+ uniform int u_envCount; // 0 ⇒ no environment reflection
170
+ uniform vec3 u_envColor[4]; // straight RGB, sorted by offset
171
+ uniform float u_envOffset[4];
172
+ uniform vec3 u_envAvg; // mean env color (irradiance / rough fallback)
173
+ uniform vec3 u_tangent; // world +U (normal mapping)
174
+ uniform vec3 u_bitangent; // world +V
175
+ uniform float u_normalScale;
176
+ uniform int u_hasNormalMap; // 0 ⇒ flat face normal
177
+ uniform sampler2D u_normalMap;
178
+ uniform int u_envIsImage; // 1 ⇒ sample u_envMap as equirect
179
+ uniform sampler2D u_envMap;
180
+ const float PI = 3.14159265;
181
+ float ggxD(float NdotH, float a) { float a2 = a * a; float d = NdotH * NdotH * (a2 - 1.0) + 1.0; return a2 / (PI * d * d); }
182
+ float gSchlick(float x, float k) { return x / (x * (1.0 - k) + k); }
183
+ // Sample the gradient environment at parameter t∈[0,1] (const-indexed for
184
+ // portability — matches the WGSL path). Stops are sorted by offset.
185
+ vec3 sampleEnv(float t) {
186
+ vec3 c = u_envColor[0];
187
+ if (u_envCount > 1) {
188
+ vec3 last = u_envColor[1];
189
+ if (u_envCount > 2) last = u_envColor[2];
190
+ if (u_envCount > 3) last = u_envColor[3];
191
+ if (t <= u_envOffset[1]) {
192
+ c = mix(u_envColor[0], u_envColor[1], clamp((t - u_envOffset[0]) / max(u_envOffset[1] - u_envOffset[0], 1e-4), 0.0, 1.0));
193
+ } else if (u_envCount > 2 && t <= u_envOffset[2]) {
194
+ c = mix(u_envColor[1], u_envColor[2], clamp((t - u_envOffset[1]) / max(u_envOffset[2] - u_envOffset[1], 1e-4), 0.0, 1.0));
195
+ } else if (u_envCount > 3 && t <= u_envOffset[3]) {
196
+ c = mix(u_envColor[2], u_envColor[3], clamp((t - u_envOffset[2]) / max(u_envOffset[3] - u_envOffset[2], 1e-4), 0.0, 1.0));
197
+ } else {
198
+ c = last;
199
+ }
200
+ }
201
+ return c;
202
+ }
203
+ // Perturb the face normal by a tangent-space normal map (flat = #8080ff).
204
+ vec3 perturbNormal(vec3 N, vec2 uv) {
205
+ if (u_hasNormalMap == 0) return N;
206
+ vec3 s = texture(u_normalMap, uv).rgb * 2.0 - 1.0;
207
+ s.xy *= u_normalScale;
208
+ return normalize(s.x * normalize(u_tangent) + s.y * normalize(u_bitangent) + s.z * N);
209
+ }
210
+ // Shade a fragment given its straight-alpha albedo, world normal, view
211
+ // vector. Returns clamped straight RGB (ambient + direct + env + emissive).
212
+ vec3 shadePBR(vec3 albedo, vec3 Nin, vec3 V) {
213
+ vec3 N = Nin;
214
+ if (dot(N, V) < 0.0) N = -N; // two-sided
215
+ float NdotV = max(dot(N, V), 1e-4);
216
+ vec3 F0 = mix(vec3(0.04), albedo, u_metal);
217
+ float a = u_rough * u_rough;
218
+ float k = (u_rough + 1.0) * (u_rough + 1.0) / 8.0;
219
+
220
+ vec3 color = albedo * u_ambient; // ambient (flat fill) term
221
+ for (int i = 0; i < 4; i++) {
222
+ if (i >= u_numLights) break;
223
+ vec3 L = normalize(u_lightDir[i]);
224
+ vec3 H = normalize(V + L);
225
+ float NdotL = max(dot(N, L), 0.0);
226
+ float NdotH = max(dot(N, H), 0.0);
227
+ float VdotH = max(dot(V, H), 0.0);
228
+ vec3 F = F0 + (1.0 - F0) * pow(1.0 - VdotH, 5.0);
229
+ float D = ggxD(NdotH, a);
230
+ float G = gSchlick(NdotL, k) * gSchlick(NdotV, k);
231
+ vec3 spec = (D * G) * F / max(4.0 * NdotL * NdotV, 1e-3);
232
+ vec3 kd = (1.0 - F) * (1.0 - u_metal);
233
+ color += (kd * albedo + spec) * u_lightColor[i] * NdotL;
234
+ }
235
+ // Environment reflection: mirror the gradient sky along R, roughness-
236
+ // blurred toward the average; IBL split (diffuse + Fresnel specular).
237
+ if (u_envCount > 0 || u_envIsImage == 1) {
238
+ vec3 R = reflect(-V, N);
239
+ vec3 sharp;
240
+ if (u_envIsImage == 1) {
241
+ // Equirect (lat-long) sample along the reflection ray. Up = −y.
242
+ vec3 Rn = normalize(R);
243
+ vec2 euv = vec2(atan(Rn.x, Rn.z) / (2.0 * PI) + 0.5, acos(clamp(-Rn.y, -1.0, 1.0)) / PI);
244
+ sharp = texture(u_envMap, euv).rgb;
245
+ } else {
246
+ float t = clamp(0.5 - 0.5 * (R.y / max(length(R), 1e-4)), 0.0, 1.0); // up→1, down→0
247
+ sharp = sampleEnv(t);
248
+ }
249
+ vec3 envc = mix(sharp, u_envAvg, u_rough);
250
+ vec3 Fr = F0 + (max(vec3(1.0 - u_rough), F0) - F0) * pow(1.0 - NdotV, 5.0);
251
+ vec3 kdEnv = (1.0 - Fr) * (1.0 - u_metal);
252
+ color += (kdEnv * albedo * u_envAvg + envc * Fr) * u_reflect;
253
+ }
254
+ color = mix(color, albedo, clamp(u_emissive, 0.0, 1.0));
255
+ return clamp(color, 0.0, 1.0);
256
+ }
257
+ `;
258
+ const LIT_SHAPE_FS = `#version 300 es
259
+ precision highp float;
260
+ in vec2 v_uv;
261
+ in vec3 v_worldPos;
262
+ out vec4 fragColor;
263
+ uniform vec4 u_albedo; // straight (non-premultiplied)
264
+ uniform vec4 u_strokeAlbedo; // straight
265
+ uniform float u_strokeWidth;
266
+ uniform float u_cornerRadius;
267
+ uniform float u_shapeType;
268
+ uniform vec2 u_size;
269
+ ${PBR_FS_LIB}
270
+ void main() {
271
+ vec2 p = v_uv * u_size;
272
+ vec2 half_ = u_size * 0.5;
273
+ float dist;
274
+ if (u_shapeType > 0.5) {
275
+ vec2 d = (p - half_) / half_;
276
+ dist = (sqrt(dot(d, d)) - 1.0) * min(half_.x, half_.y);
277
+ } else {
278
+ float r = u_cornerRadius;
279
+ vec2 q = abs(p - half_) - half_ + vec2(r);
280
+ dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
281
+ }
282
+ float aa = fwidth(dist);
283
+ float outerAlpha = 1.0 - smoothstep(-aa, aa, dist);
284
+ if (outerAlpha < 0.001) discard;
285
+
286
+ vec4 alb = u_albedo;
287
+ if (u_strokeWidth > 0.0) {
288
+ float sa = smoothstep(-u_strokeWidth - aa, -u_strokeWidth + aa, dist);
289
+ alb = mix(u_albedo, u_strokeAlbedo, sa);
290
+ }
291
+ vec3 N = perturbNormal(normalize(u_normal), v_uv);
292
+ vec3 color = shadePBR(alb.rgb, N, normalize(u_eye - v_worldPos));
293
+ float outA = alb.a * outerAlpha;
294
+ fragColor = vec4(color * outA, outA); // premultiplied
295
+ }`;
296
+ // Lit textured quad (§4.8): images, video, and flattened group-card
297
+ // layers shaded as one surface. Albedo = the texture's own (straight)
298
+ // pixels; same shadePBR as shapes.
299
+ const LIT_TEXTURED_VS = `#version 300 es
300
+ in vec2 a_pos;
301
+ in vec2 a_uv;
302
+ out vec2 v_uv;
303
+ out vec2 v_quadPos;
304
+ out vec3 v_worldPos;
305
+ uniform mat4 u_transform;
306
+ uniform mat4 u_worldMatrix;
307
+ uniform vec4 u_uvRect; // (u0, v0, u1, v1)
308
+ void main() {
309
+ gl_Position = u_transform * vec4(a_pos, 0.0, 1.0);
310
+ v_uv = mix(u_uvRect.xy, u_uvRect.zw, a_uv);
311
+ v_quadPos = a_uv;
312
+ vec4 wp = u_worldMatrix * vec4(a_pos, 0.0, 1.0);
313
+ v_worldPos = wp.xyz;
314
+ }`;
315
+ const LIT_TEXTURED_FS = `#version 300 es
316
+ precision highp float;
317
+ in vec2 v_uv;
318
+ in vec2 v_quadPos;
319
+ in vec3 v_worldPos;
320
+ out vec4 fragColor;
321
+ uniform sampler2D u_tex;
322
+ uniform vec4 u_tint; // premultiplied
323
+ uniform float u_cornerRadius; // PIXELS; 0 disables masking
324
+ uniform vec2 u_size; // pixel (width, height) of the quad
325
+ ${PBR_FS_LIB}
326
+ void main() {
327
+ vec4 s = texture(u_tex, v_uv); // premultiplied
328
+ float cov = s.a;
329
+ vec3 albedo = cov > 0.0 ? s.rgb / cov : s.rgb; // straight albedo
330
+ // tint as straight color × opacity (group layers pass (o,o,o,o)).
331
+ vec3 tintRgb = u_tint.a > 0.0 ? u_tint.rgb / u_tint.a : vec3(1.0);
332
+ albedo *= tintRgb;
333
+
334
+ float maskAlpha = 1.0;
335
+ if (u_cornerRadius > 0.0) {
336
+ vec2 p = v_quadPos * u_size;
337
+ vec2 half_ = u_size * 0.5;
338
+ float r = u_cornerRadius;
339
+ vec2 q = abs(p - half_) - half_ + vec2(r);
340
+ float dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
341
+ float aa = fwidth(dist);
342
+ maskAlpha = 1.0 - smoothstep(-aa, aa, dist);
343
+ if (maskAlpha < 0.001) discard;
344
+ }
345
+ vec3 N = perturbNormal(normalize(u_normal), v_quadPos);
346
+ vec3 color = shadePBR(albedo, N, normalize(u_eye - v_worldPos));
347
+ float outA = cov * u_tint.a * maskAlpha;
348
+ fragColor = vec4(color * outA, outA); // premultiplied
349
+ }`;
350
+ // Gradient pipeline: shape filled with a linear or radial gradient.
351
+ const GRADIENT_VS = `#version 300 es
352
+ in vec2 a_pos;
353
+ in vec2 a_uv;
354
+ out vec2 v_uv;
355
+ uniform mat4 u_transform;
356
+ void main() {
357
+ gl_Position = u_transform * vec4(a_pos, 0.0, 1.0);
358
+ v_uv = a_uv;
359
+ }
360
+ `;
361
+ const GRADIENT_FS = `#version 300 es
362
+ precision highp float;
363
+ in vec2 v_uv;
364
+ out vec4 fragColor;
365
+ uniform vec4 u_meta; // cornerRadius (PIXELS), shapeType, fillType, numStops
366
+ uniform vec4 u_params; // linear:(cos,sin,_,_) | radial:(cx,cy,radius,_)
367
+ uniform vec2 u_size; // pixel (width, height)
368
+ uniform vec4 u_stops[4]; // 4 stop colors (premultiplied)
369
+ uniform vec4 u_stopOffsets; // 4 stop offsets
370
+
371
+ void main() {
372
+ vec2 uv = v_uv;
373
+ float cornerRadius = u_meta.x;
374
+ float shapeType = u_meta.y;
375
+ float fillType = u_meta.z;
376
+ int nStops = int(u_meta.w);
377
+
378
+ // Shape masking — SDF in pixel space (corners stay circular).
379
+ vec2 p_px = uv * u_size;
380
+ vec2 half_ = u_size * 0.5;
381
+ if (shapeType > 0.5) {
382
+ vec2 d = (p_px - half_) / half_;
383
+ if (dot(d, d) > 1.0) discard;
384
+ } else if (cornerRadius > 0.0) {
385
+ float r = cornerRadius;
386
+ vec2 q = abs(p_px - half_) - (half_ - vec2(r));
387
+ vec2 outside = max(q, vec2(0.0));
388
+ if (length(outside) > r) discard;
389
+ }
390
+
391
+ // Gradient parameter t — runs in UV space (gradient directions are
392
+ // relative to the shape's normalized bounding box).
393
+ float t;
394
+ if (fillType > 0.5) {
395
+ float radius = max(u_params.z, 0.0001);
396
+ t = clamp(distance(uv, u_params.xy) / radius, 0.0, 1.0);
397
+ } else {
398
+ vec2 dir = u_params.xy;
399
+ vec2 centered = uv - vec2(0.5);
400
+ t = clamp(dot(centered, dir) + 0.5, 0.0, 1.0);
401
+ }
402
+
403
+ vec4 color = u_stops[0];
404
+ for (int i = 0; i < 3; i++) {
405
+ if (i >= nStops - 1) break;
406
+ float off0 = u_stopOffsets[i];
407
+ float off1 = u_stopOffsets[i + 1];
408
+ if (t >= off0 && t <= off1) {
409
+ float segT = (t - off0) / max(off1 - off0, 0.0001);
410
+ color = mix(u_stops[i], u_stops[i + 1], segT);
411
+ break;
412
+ }
413
+ }
414
+ if (t >= u_stopOffsets[nStops - 1]) {
415
+ color = u_stops[nStops - 1];
416
+ }
417
+
418
+ fragColor = color;
419
+ }
420
+ `;
421
+ const TEXTURED_VS = `#version 300 es
422
+ in vec2 a_pos;
423
+ in vec2 a_uv;
424
+ out vec2 v_uv;
425
+ out vec2 v_quadPos;
426
+ uniform mat4 u_transform;
427
+ uniform vec4 u_uvRect; // (u0, v0, u1, v1)
428
+ void main() {
429
+ gl_Position = u_transform * vec4(a_pos, 0.0, 1.0);
430
+ v_uv = mix(u_uvRect.xy, u_uvRect.zw, a_uv);
431
+ v_quadPos = a_uv;
432
+ }
433
+ `;
434
+ const TEXTURED_FS = `#version 300 es
435
+ precision highp float;
436
+ in vec2 v_uv;
437
+ in vec2 v_quadPos;
438
+ out vec4 fragColor;
439
+ uniform sampler2D u_tex;
440
+ uniform vec4 u_tint; // premultiplied
441
+ uniform float u_cornerRadius; // PIXELS; 0 disables masking
442
+ uniform vec2 u_size; // pixel (width, height) of the quad
443
+ uniform float u_alphaGamma; // coverage exponent; 1 = no-op (see backend.ts)
444
+ void main() {
445
+ vec4 s = texture(u_tex, v_uv); // already premultiplied (UNPACK_PREMULTIPLY_ALPHA_WEBGL)
446
+ if (u_alphaGamma != 1.0) {
447
+ // Reshape coverage: a' = a^g. Premultiplied, so scale the whole
448
+ // sample by a^(g-1); the max() guard keeps g<1 finite at a=0.
449
+ s *= pow(max(s.a, 1e-5), u_alphaGamma - 1.0);
450
+ }
451
+ float maskAlpha = 1.0;
452
+ if (u_cornerRadius > 0.0) {
453
+ // Same rounded-rect SDF as SHAPE_FS, evaluated in the quad's
454
+ // local pixel space (v_quadPos is the un-remapped 0..1 vertex
455
+ // attribute, not the sampling UV).
456
+ vec2 p = v_quadPos * u_size;
457
+ vec2 half_ = u_size * 0.5;
458
+ float r = u_cornerRadius;
459
+ vec2 q = abs(p - half_) - half_ + vec2(r);
460
+ float dist = min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
461
+ float aa = fwidth(dist);
462
+ maskAlpha = 1.0 - smoothstep(-aa, aa, dist);
463
+ if (maskAlpha < 0.001) discard;
464
+ }
465
+ fragColor = s * u_tint * maskAlpha;
466
+ }
467
+ `;
468
+ // Masked composite: content sampled through a second texture's alpha or
469
+ // luminance. Shares TEXTURED_VS. Both textures are premultiplied; the
470
+ // whole premultiplied content color scales by the mask factor, which is
471
+ // the correct premultiplied masking operation.
472
+ const MASKED_FS = `#version 300 es
473
+ precision highp float;
474
+ in vec2 v_uv;
475
+ in vec2 v_quadPos;
476
+ out vec4 fragColor;
477
+ uniform sampler2D u_tex; // content (premultiplied)
478
+ uniform sampler2D u_mask; // mask (premultiplied)
479
+ uniform vec4 u_tint; // premultiplied
480
+ uniform float u_mode; // 0 alpha, 1 alpha-inverted, 2 luma, 3 luma-inverted
481
+ void main() {
482
+ vec4 c = texture(u_tex, v_uv) * u_tint;
483
+ vec4 m = texture(u_mask, v_uv);
484
+ float f;
485
+ if (u_mode < 0.5) f = m.a;
486
+ else if (u_mode < 1.5) f = 1.0 - m.a;
487
+ else {
488
+ float luma = dot(m.rgb, vec3(0.2126, 0.7152, 0.0722));
489
+ f = (u_mode < 2.5) ? luma : (1.0 - luma);
490
+ }
491
+ fragColor = c * f;
492
+ }
493
+ `;
494
+ // Backdrop-blend composite (§4.5): piecewise blend modes that can't be
495
+ // fixed-function. Reads the isolated element layer (u_src) and a
496
+ // backdrop snapshot (u_backdrop), both premultiplied + surface-sized,
497
+ // runs the W3C separable composite, and REPLACES the target (caller
498
+ // sets blendFunc to ONE,ZERO). Where the element is transparent the
499
+ // output equals the backdrop, so replacing is a no-op there.
500
+ const BACKDROP_BLEND_FS = `#version 300 es
501
+ precision highp float;
502
+ in vec2 v_uv;
503
+ out vec4 fragColor;
504
+ uniform sampler2D u_src; // element layer, premultiplied
505
+ uniform sampler2D u_backdrop; // backdrop snapshot, premultiplied
506
+ uniform int u_mode; // 0 overlay, 1 hard-light, 2 soft-light
507
+ uniform float u_backdropFlipY; // 1.0 flips backdrop v
508
+ float blendCh(int mode, float cb, float cs) {
509
+ if (mode == 0) { // overlay
510
+ return cb <= 0.5 ? (2.0*cb*cs) : (1.0 - 2.0*(1.0-cb)*(1.0-cs));
511
+ } else if (mode == 1) { // hard-light = overlay(src,backdrop)
512
+ return cs <= 0.5 ? (2.0*cs*cb) : (1.0 - 2.0*(1.0-cs)*(1.0-cb));
513
+ } else { // soft-light (W3C)
514
+ if (cs <= 0.5) return cb - (1.0 - 2.0*cs) * cb * (1.0 - cb);
515
+ float d = cb <= 0.25 ? (((16.0*cb - 12.0)*cb + 4.0)*cb) : sqrt(cb);
516
+ return cb + (2.0*cs - 1.0) * (d - cb);
517
+ }
518
+ }
519
+ void main() {
520
+ vec4 s = texture(u_src, v_uv);
521
+ vec2 buv = vec2(v_uv.x, mix(v_uv.y, 1.0 - v_uv.y, u_backdropFlipY));
522
+ vec4 b = texture(u_backdrop, buv);
523
+ float as = s.a, ab = b.a;
524
+ vec3 Cs = as > 0.0 ? s.rgb / as : vec3(0.0);
525
+ vec3 Cb = ab > 0.0 ? b.rgb / ab : vec3(0.0);
526
+ vec3 Bc = vec3(blendCh(u_mode, Cb.r, Cs.r), blendCh(u_mode, Cb.g, Cs.g), blendCh(u_mode, Cb.b, Cs.b));
527
+ vec3 co = as*(1.0-ab)*Cs + as*ab*Bc + (1.0-as)*ab*Cb; // premultiplied
528
+ float ao = as + ab*(1.0-as);
529
+ fragColor = vec4(co, ao);
530
+ }
531
+ `;
532
+ // Filter composite: a layer texture drawn 1:1 with an optional separable
533
+ // Gaussian blur pass plus color ops. Shares TEXTURED_VS. 25 taps spread
534
+ // over ±3σ; weights computed in-shader and normalized by their sum so
535
+ // edge-clamped taps don't darken. Color ops run on STRAIGHT alpha
536
+ // (unpremultiply → brightness → contrast → saturation → re-premultiply);
537
+ // premultiplied math would drag translucent pixels toward black on the
538
+ // contrast midpoint. Must match the WebGPU FILTERED_SHADER exactly.
539
+ const FILTERED_FS = `#version 300 es
540
+ precision highp float;
541
+ in vec2 v_uv;
542
+ out vec4 fragColor;
543
+ uniform sampler2D u_tex; // layer (premultiplied)
544
+ uniform vec2 u_texel; // blur direction ÷ texture PHYSICAL dims
545
+ uniform float u_sigma; // Gaussian σ in PHYSICAL pixels; 0 = no blur
546
+ uniform vec4 u_colorOps; // (brightness, contrast, saturation, hue radians)
547
+ uniform vec4 u_tint; // premultiplied
548
+ void main() {
549
+ vec4 acc;
550
+ if (u_sigma > 0.0) {
551
+ acc = vec4(0.0);
552
+ float wsum = 0.0;
553
+ for (int i = -12; i <= 12; i++) {
554
+ float d = float(i) * u_sigma * 0.25; // taps cover ±3σ
555
+ float w = exp(-0.5 * d * d / (u_sigma * u_sigma));
556
+ acc += texture(u_tex, v_uv + u_texel * d) * w;
557
+ wsum += w;
558
+ }
559
+ acc /= wsum;
560
+ } else {
561
+ acc = texture(u_tex, v_uv);
562
+ }
563
+ float a = acc.a;
564
+ vec3 c = a > 0.0 ? acc.rgb / a : vec3(0.0);
565
+ c *= u_colorOps.x; // brightness
566
+ c = (c - 0.5) * u_colorOps.y + 0.5; // contrast
567
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722)); // Rec. 709 luma
568
+ c = mix(vec3(l), c, u_colorOps.z); // saturation
569
+ if (u_colorOps.w != 0.0) { // hue rotate (SVG matrix)
570
+ float hc = cos(u_colorOps.w);
571
+ float hs = sin(u_colorOps.w);
572
+ c = mat3(
573
+ 0.213 + 0.787*hc - 0.213*hs, 0.213 - 0.213*hc + 0.143*hs, 0.213 - 0.213*hc - 0.787*hs,
574
+ 0.715 - 0.715*hc - 0.715*hs, 0.715 + 0.285*hc + 0.140*hs, 0.715 - 0.715*hc + 0.715*hs,
575
+ 0.072 - 0.072*hc + 0.928*hs, 0.072 - 0.072*hc - 0.283*hs, 0.072 + 0.928*hc + 0.072*hs
576
+ ) * c;
577
+ }
578
+ c = clamp(c, 0.0, 1.0);
579
+ fragColor = vec4(c * a, a) * u_tint;
580
+ }
581
+ `;
582
+ // Stylize composite: one effects-array pass (§4.7) — pixelate, dither,
583
+ // halftone, or ascii — drawn 1:1 like the filter composite. Shares
584
+ // TEXTURED_VS. The mode is a uniform, so all branches are uniform
585
+ // control flow. Color math runs on STRAIGHT alpha; dot/glyph "ink"
586
+ // scales BOTH color and alpha (premultiplied output). Must match the
587
+ // WebGPU STYLIZED_SHADER exactly.
588
+ const STYLIZED_FS = `#version 300 es
589
+ precision highp float;
590
+ in vec2 v_uv;
591
+ out vec4 fragColor;
592
+ uniform sampler2D u_tex; // layer (premultiplied)
593
+ uniform sampler2D u_aux; // ascii glyph atlas (80×8); layer tex when unused
594
+ uniform vec2 u_texSize; // layer PHYSICAL dims
595
+ uniform vec4 u_params; // (mode, p0, p1, 0) — px params pre-scaled to PHYSICAL
596
+ uniform vec4 u_tint; // premultiplied
597
+
598
+ const float BAYER[16] = float[16](
599
+ 0., 8., 2., 10.,
600
+ 12., 4., 14., 6.,
601
+ 3., 11., 1., 9.,
602
+ 15., 7., 13., 5.);
603
+
604
+ // ── Normative noise (§4.7 fractal_noise / turbulent_displace) ──
605
+ // PCG integer hash → value noise (quintic fade) → fBM (lacunarity 2,
606
+ // gain 0.5, per-octave seed+o). Integer ops are bit-exact everywhere.
607
+ uint pcg(uint v) {
608
+ uint s = v * 747796405u + 2891336453u;
609
+ uint w = ((s >> ((s >> 28) + 4u)) ^ s) * 277803737u;
610
+ return (w >> 22) ^ w;
611
+ }
612
+ float h01(ivec3 c, uint seed) {
613
+ return float(pcg(uint(c.x) ^ pcg(uint(c.y) ^ pcg(uint(c.z) ^ pcg(seed))))) / 4294967295.0;
614
+ }
615
+ float vnoise(vec3 p, uint seed) {
616
+ vec3 i = floor(p);
617
+ vec3 f = p - i;
618
+ vec3 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
619
+ ivec3 c = ivec3(i);
620
+ float n000 = h01(c, seed);
621
+ float n100 = h01(c + ivec3(1, 0, 0), seed);
622
+ float n010 = h01(c + ivec3(0, 1, 0), seed);
623
+ float n110 = h01(c + ivec3(1, 1, 0), seed);
624
+ float n001 = h01(c + ivec3(0, 0, 1), seed);
625
+ float n101 = h01(c + ivec3(1, 0, 1), seed);
626
+ float n011 = h01(c + ivec3(0, 1, 1), seed);
627
+ float n111 = h01(c + ivec3(1, 1, 1), seed);
628
+ return mix(
629
+ mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y),
630
+ mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y), u.z);
631
+ }
632
+ float fbm(vec3 p, int octaves, uint seed) {
633
+ float v = 0.0;
634
+ float amp = 1.0;
635
+ float wsum = 0.0;
636
+ for (int o = 0; o < 8; o++) {
637
+ if (o >= octaves) break;
638
+ v += amp * vnoise(p, seed + uint(o));
639
+ wsum += amp;
640
+ p *= 2.0;
641
+ amp *= 0.5;
642
+ }
643
+ return v / wsum;
644
+ }
645
+
646
+ void main() {
647
+ float mode = u_params.x;
648
+ vec2 px = v_uv * u_texSize;
649
+ if (mode < 0.5) {
650
+ // pixelate — every pixel takes its cell's center sample.
651
+ float cell = max(u_params.y, 1.0);
652
+ vec2 center = (floor(px / cell) + 0.5) * cell;
653
+ fragColor = texture(u_tex, center / u_texSize) * u_tint;
654
+ } else if (mode < 1.5) {
655
+ // dither — per-channel quantize to N levels, 4×4 Bayer threshold
656
+ // indexed by output pixel coords. Alpha (coverage) is untouched.
657
+ vec4 s = texture(u_tex, v_uv);
658
+ float a = s.a;
659
+ vec3 c = a > 0.0 ? s.rgb / a : vec3(0.0);
660
+ // Bayer cells of u_params.z (pixel_size) LOGICAL px: divide device px
661
+ // by (pixelRatio · pixel_size). Resolution-independent — the dot size
662
+ // is stable across preview DPI / export and survives the editor's
663
+ // fit-to-stage downscale instead of smearing into mush.
664
+ ivec2 ip = ivec2(px / max(u_params.w * u_params.z, 1.0));
665
+ float t = (BAYER[(ip.y % 4) * 4 + (ip.x % 4)] + 0.5) / 16.0;
666
+ float n = max(u_params.y, 2.0) - 1.0;
667
+ c = clamp(floor(c * n + t) / n, 0.0, 1.0);
668
+ fragColor = vec4(c * a, a) * u_tint;
669
+ } else if (mode < 2.5) {
670
+ // halftone — rotated dot grid; dot radius ∝ sqrt(luma) so ink AREA
671
+ // tracks luminance; dots tinted with the cell's color. The
672
+ // clamp(r,0,1) factor fades sub-pixel dots instead of popping.
673
+ float cell = max(u_params.y, 2.0);
674
+ float ang = radians(u_params.z);
675
+ mat2 rot = mat2(cos(ang), -sin(ang), sin(ang), cos(ang));
676
+ mat2 inv = mat2(cos(ang), sin(ang), -sin(ang), cos(ang));
677
+ vec2 rp = rot * px;
678
+ vec2 centerR = (floor(rp / cell) + 0.5) * cell;
679
+ vec4 s = texture(u_tex, (inv * centerR) / u_texSize);
680
+ float a = s.a;
681
+ vec3 c = a > 0.0 ? s.rgb / a : vec3(0.0);
682
+ float luma = dot(c, vec3(0.2126, 0.7152, 0.0722)) * a;
683
+ float r = 0.5 * cell * sqrt(luma);
684
+ float d = length(rp - centerR);
685
+ float ink = (1.0 - smoothstep(r - 1.0, r + 1.0, d)) * clamp(r, 0.0, 1.0);
686
+ fragColor = vec4(c, 1.0) * (a * ink) * u_tint;
687
+ } else if (mode < 3.5) {
688
+ // ascii — cells map to the 10-glyph density ramp in the atlas,
689
+ // tinted with the cell's sampled color.
690
+ float cell = max(u_params.y, 4.0);
691
+ vec2 cellOrigin = floor(px / cell) * cell;
692
+ vec4 s = texture(u_tex, (cellOrigin + 0.5 * cell) / u_texSize);
693
+ float a = s.a;
694
+ vec3 c = a > 0.0 ? s.rgb / a : vec3(0.0);
695
+ float luma = dot(c, vec3(0.2126, 0.7152, 0.0722)) * a;
696
+ float idx = clamp(floor(luma * 10.0), 0.0, 9.0);
697
+ vec2 g = clamp(floor((px - cellOrigin) / cell * 8.0), 0.0, 7.0);
698
+ vec2 auxUv = vec2((idx * 8.0 + g.x + 0.5) / 80.0, (g.y + 0.5) / 8.0);
699
+ float ink = texture(u_aux, auxUv).a;
700
+ fragColor = vec4(c, 1.0) * (a * ink) * u_tint;
701
+ } else if (mode < 4.5) {
702
+ // drop_shadow — aux is the ladder-blurred layer; its alpha,
703
+ // offset and tinted, composites UNDER the content (premultiplied
704
+ // under-operator: dst × (1 − src.a)).
705
+ vec4 c = texture(u_tex, v_uv);
706
+ vec2 texel = 1.0 / u_texSize;
707
+ vec2 ouv = clamp(v_uv - vec2(u_params.y, u_params.z) * texel, vec2(0.0), vec2(1.0));
708
+ float sa = texture(u_aux, ouv).a;
709
+ fragColor = c + u_tint * (sa * (1.0 - c.a));
710
+ } else if (mode < 5.5) {
711
+ // glow — blurred silhouette × intensity × color, under the content.
712
+ vec4 c = texture(u_tex, v_uv);
713
+ float ga = clamp(texture(u_aux, v_uv).a * u_params.y, 0.0, 1.0);
714
+ fragColor = c + u_tint * (ga * (1.0 - c.a));
715
+ } else if (mode < 6.5) {
716
+ // stroke — outline band outside the silhouette: max alpha over a
717
+ // 16-tap ring at the stroke width, under the content.
718
+ vec4 c = texture(u_tex, v_uv);
719
+ vec2 texel = 1.0 / u_texSize;
720
+ float w = max(u_params.y, 1.0);
721
+ float s = 0.0;
722
+ for (int i = 0; i < 16; i++) {
723
+ float ang = 6.2831853 * float(i) / 16.0;
724
+ s = max(s, texture(u_tex, clamp(v_uv + vec2(cos(ang), sin(ang)) * w * texel, vec2(0.0), vec2(1.0))).a);
725
+ }
726
+ fragColor = c + u_tint * (s * (1.0 - c.a));
727
+ } else if (mode < 7.5) {
728
+ // chroma_key — BT.709 CbCr distance ramp (§4.7). u_tint.rgb = key
729
+ // color (STRAIGHT), u_tint.a = spill; p0 tolerance, p1 softness.
730
+ vec4 s = texture(u_tex, v_uv);
731
+ vec3 c = s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
732
+ vec3 k = u_tint.rgb;
733
+ const vec3 LUMA = vec3(0.2126, 0.7152, 0.0722);
734
+ float cy = dot(c, LUMA);
735
+ float ky = dot(k, LUMA);
736
+ vec2 cc = vec2((c.b - cy) / 1.8556, (c.r - cy) / 1.5748);
737
+ vec2 kc = vec2((k.b - ky) / 1.8556, (k.r - ky) / 1.5748);
738
+ float d = distance(cc, kc);
739
+ float a = u_params.z > 0.0
740
+ ? clamp((d - u_params.y) / u_params.z, 0.0, 1.0)
741
+ : (d > u_params.y ? 1.0 : 0.0);
742
+ // Spill suppression: cap the key's dominant channel (ties g→r→b)
743
+ // at the max of the other two, scaled by spill.
744
+ if (k.g >= k.r && k.g >= k.b) c.g -= u_tint.a * max(0.0, c.g - max(c.r, c.b));
745
+ else if (k.r >= k.b) c.r -= u_tint.a * max(0.0, c.r - max(c.g, c.b));
746
+ else c.b -= u_tint.a * max(0.0, c.b - max(c.r, c.g));
747
+ float ao = s.a * a;
748
+ fragColor = vec4(c * ao, ao);
749
+ } else if (mode < 8.5) {
750
+ // luma_key — p0 threshold, p1 softness, u_tint.r = invert flag.
751
+ vec4 s = texture(u_tex, v_uv);
752
+ vec3 c = s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
753
+ float y = dot(c, vec3(0.2126, 0.7152, 0.0722));
754
+ float a = u_params.z > 0.0
755
+ ? clamp((y - u_params.y) / u_params.z, 0.0, 1.0)
756
+ : (y > u_params.y ? 1.0 : 0.0);
757
+ if (u_tint.x > 0.5) a = 1.0 - a;
758
+ float ao = s.a * a;
759
+ fragColor = vec4(c * ao, ao);
760
+ } else if (mode < 9.5) {
761
+ // levels — per-channel remap (§4.7): u_tint = (in_black, in_white,
762
+ // out_black, out_white), p0 = gamma; y = x^(1/gamma).
763
+ vec4 s = texture(u_tex, v_uv);
764
+ vec3 c = s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
765
+ vec3 x = clamp((c - u_tint.x) / max(u_tint.y - u_tint.x, 1e-5), 0.0, 1.0);
766
+ x = pow(x, vec3(1.0 / max(u_params.y, 1e-5)));
767
+ c = clamp(u_tint.z + x * (u_tint.w - u_tint.z), 0.0, 1.0);
768
+ fragColor = vec4(c * s.a, s.a);
769
+ } else if (mode < 10.5) {
770
+ // lut — 3D lattice packed as N slices along x in a 2D atlas (aux,
771
+ // N²×N, slice index = blue). Manual trilinear: two bilinear taps
772
+ // mixed across the blue axis. p0 = N, p1 = intensity.
773
+ vec4 s = texture(u_tex, v_uv);
774
+ vec3 c = s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
775
+ float n = max(u_params.y, 2.0);
776
+ float b = clamp(c.b, 0.0, 1.0) * (n - 1.0);
777
+ float b0 = floor(b);
778
+ float b1 = min(b0 + 1.0, n - 1.0);
779
+ vec2 cellUv = vec2(
780
+ (clamp(c.r, 0.0, 1.0) * (n - 1.0) + 0.5) / (n * n),
781
+ (clamp(c.g, 0.0, 1.0) * (n - 1.0) + 0.5) / n);
782
+ vec3 lo = texture(u_aux, cellUv + vec2(b0 / n, 0.0)).rgb;
783
+ vec3 hi = texture(u_aux, cellUv + vec2(b1 / n, 0.0)).rgb;
784
+ c = mix(c, mix(lo, hi, b - b0), clamp(u_params.z, 0.0, 1.0));
785
+ fragColor = vec4(clamp(c, 0.0, 1.0) * s.a, s.a);
786
+ } else if (mode < 11.5) {
787
+ // fractal_noise — grayscale fBM over the element's footprint.
788
+ // p0 = scale px, p1 = evolution,
789
+ // u_tint = (offset_x/scale, offset_y/scale, octaves, seed).
790
+ vec4 s = texture(u_tex, v_uv);
791
+ float v = fbm(
792
+ vec3(px / max(u_params.y, 1e-3) + u_tint.xy, u_params.z),
793
+ int(u_tint.z + 0.5), uint(u_tint.w + 0.5));
794
+ fragColor = vec4(vec3(v) * s.a, s.a);
795
+ } else if (mode < 12.5) {
796
+ // turbulent_displace — sample the layer at p + noise vector.
797
+ // p0 = amount px, p1 = scale px, u_tint = (evolution, octaves, seed, 0).
798
+ float sc = max(u_params.z, 1e-3);
799
+ int oct = int(u_tint.y + 0.5);
800
+ uint sd = uint(u_tint.z + 0.5);
801
+ float dx = fbm(vec3(px / sc, u_tint.x), oct, sd) - 0.5;
802
+ float dy = fbm(vec3(px / sc, u_tint.x), oct, sd + 7919u) - 0.5;
803
+ vec2 duv = vec2(dx, dy) * 2.0 * u_params.y / u_texSize;
804
+ fragColor = texture(u_tex, clamp(v_uv + duv, vec2(0.0), vec2(1.0)));
805
+ } else {
806
+ // bloom_bright — extract pixels brighter than a soft threshold for a
807
+ // whole-frame bloom pass. p0 = threshold, p1 = knee. Output is the
808
+ // straight bright color with alpha 1 so the subsequent blur spreads
809
+ // it cleanly; the composite adds it back × intensity.
810
+ vec4 s = texture(u_tex, v_uv);
811
+ vec3 c = s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
812
+ float l = dot(c, vec3(0.2126, 0.7152, 0.0722));
813
+ float f = clamp((l - u_params.y) / max(u_params.z, 1e-3), 0.0, 1.0);
814
+ fragColor = vec4(c * f, 1.0);
815
+ }
816
+ }
817
+ `;
818
+ // Glass composite (§4.7 'glass') — faithful port of the
819
+ // ybouane/liquidglass FS_GLASS shader onto our conventions (full-
820
+ // surface quad via TEXTURED_VS, premultiplied snapshot textures,
821
+ // premultiplied output). The pane geometry is ANALYTIC: rounded-rect
822
+ // SDF + half-circle bevel height field, dual-surface (biconvex)
823
+ // refraction or dome magnification, Fresnel + Blinn-Phong lighting,
824
+ // inner stroke, and an outside-only drop shadow. Must match the WebGPU
825
+ // GLASS_SHADER exactly.
826
+ //
827
+ // Two variants from one template (CKP/1.0 glass under 3D, §4.7): the
828
+ // PROJECTIVE variant maps surface px → pane-local through the inverse
829
+ // of the pane's plane homography (exact ray/plane intersection in
830
+ // projective form) and forward-maps refracted sample points back —
831
+ // everything between (SDF, bevel, refraction, light rig) runs in the
832
+ // pane's local frame and tilts with it. The non-projective source is
833
+ // byte-identical to the CKP/1.0 shader (the equivalence gate).
834
+ const glassFsSource = (projective) => `#version 300 es
835
+ precision highp float;
836
+ in vec2 v_uv;
837
+ out vec4 fragColor;
838
+ uniform sampler2D u_backdrop; // FROSTED snapshot (premultiplied)
839
+ uniform sampler2D u_sharp; // UNBLURRED snapshot (premultiplied)
840
+ uniform vec2 u_texSize; // surface PHYSICAL dims
841
+ uniform vec2 u_paneCenter; // pane centre, PHYSICAL px
842
+ uniform vec2 u_paneHalf; // pane half-size, PHYSICAL px
843
+ uniform vec2 u_rot; // (cos θ, sin θ) of pane rotation
844
+ uniform vec4 u_geo; // (cornerRadius, zRadius, bevelMode, bdFlip) PHYSICAL
845
+ uniform vec4 u_optics; // (refract, chroma, edgeHL, fresnel)
846
+ uniform vec4 u_look; // (specular, saturation −1..1, alpha, 0)
847
+ uniform vec4 u_shadow; // (alpha, spread, offY, 0) PHYSICAL
848
+ uniform vec4 u_tint; // STRAIGHT rgba — alpha = strength${projective ? `
849
+ uniform mat3 u_h; // pane-local → surface px (projective)
850
+ uniform mat3 u_hinv; // surface px → pane-local` : ''}
851
+
852
+ float rrSDF(vec2 p, vec2 b, float r) {
853
+ vec2 q = abs(p) - b + vec2(r);
854
+ return min(max(q.x, q.y), 0.0) + length(max(q, vec2(0.0))) - r;
855
+ }
856
+
857
+ // Half-circle bevel height field (reference bevelHeight).
858
+ float bevelHeight(float d, float zR) {
859
+ if (d <= 0.0) return 0.0;
860
+ if (d >= zR) return zR;
861
+ return sqrt(d * (2.0 * zR - d));
862
+ }
863
+
864
+ vec3 straight3(vec4 s) {
865
+ return s.a > 0.0 ? s.rgb / s.a : vec3(0.0);
866
+ }
867
+
868
+ void main() {${projective ? `
869
+ // Pane-local coordinates: invert the pane→surface homography. A
870
+ // non-positive w means the fragment looks past the plane's horizon
871
+ // (behind the camera) — nothing there.
872
+ vec2 px = v_uv * u_texSize;
873
+ vec3 lh = u_hinv * vec3(px, 1.0);
874
+ if (lh.z <= 0.0) { fragColor = vec4(0.0); return; }
875
+ vec2 p = lh.xy / lh.z;` : `
876
+ // Pane-local coordinates (rotate surface px by −θ around the centre).
877
+ vec2 px = v_uv * u_texSize;
878
+ vec2 rel = px - u_paneCenter;
879
+ vec2 p = vec2(rel.x * u_rot.x + rel.y * u_rot.y,
880
+ -rel.x * u_rot.y + rel.y * u_rot.x);`}
881
+ vec2 half_ = u_paneHalf;
882
+ float r = min(u_geo.x, min(half_.x, half_.y));
883
+ float sdf = rrSDF(p, half_, r);
884
+
885
+ // ── Drop shadow — OUTSIDE the panel only ──
886
+ if (sdf > 0.0) {
887
+ float a = 0.0;
888
+ if (u_shadow.x > 0.0) {
889
+ float sdfShadow = rrSDF(p - vec2(0.0, u_shadow.z), half_, r);
890
+ float d = max(sdfShadow - 1.0, 0.0);
891
+ float spread = max(u_shadow.y, 1.0);
892
+ float falloff = 1.0 / (spread * spread);
893
+ float outerShadow = exp(-d * d * falloff) * 0.65;
894
+ float contactShadow = exp(-d * 0.08 / max(spread * 0.04, 0.01)) * 0.35;
895
+ a = (outerShadow + contactShadow) * u_shadow.x;
896
+ }
897
+ fragColor = vec4(0.0, 0.0, 0.0, a);
898
+ return;
899
+ }
900
+
901
+ float mask = 1.0 - smoothstep(-1.5, 0.5, sdf);
902
+
903
+ float maxD = min(half_.x, half_.y);
904
+ float inside = -sdf;
905
+ float edge = smoothstep(maxD * 0.35, 0.0, inside);
906
+
907
+ // ── Surface normal via the bevel height field (e = 2px, analytic SDF
908
+ // — no blurred-field facets, no measured-gradient lip) ──
909
+ float zR = u_geo.y;
910
+ float e = 2.0;
911
+ float hC = bevelHeight(inside, zR);
912
+ vec2 hGrad = vec2(
913
+ bevelHeight(-rrSDF(p + vec2(e, 0.0), half_, r), zR) -
914
+ bevelHeight(-rrSDF(p - vec2(e, 0.0), half_, r), zR),
915
+ bevelHeight(-rrSDF(p + vec2(0.0, e), half_, r), zR) -
916
+ bevelHeight(-rrSDF(p - vec2(0.0, e), half_, r), zR)) / (2.0 * e);
917
+ vec3 N = normalize(vec3(-hGrad, 1.0));
918
+
919
+ float depth = smoothstep(0.0, zR, inside);
920
+
921
+ // ── Refraction ──
922
+ float refrPow = 1.0 - 1.0 / 1.5;
923
+ float thickNorm = (hC * 2.0) / max(zR * 2.0, 1.0);
924
+ vec2 refrPx;
925
+ if (u_geo.z < 0.5) {
926
+ // Biconvex pill: entry + exit + through-thickness refraction,
927
+ // plus a depth-scaled magnification pull toward the centre.
928
+ vec2 surfRefr = hGrad * refrPow;
929
+ refrPx = (surfRefr * 2.0 + surfRefr * thickNorm * 0.5) * u_optics.x * 30.0;
930
+ vec2 centerDir = -p / max(half_, vec2(1.0));
931
+ refrPx += centerDir * u_optics.x * 4.0 * depth;
932
+ } else {
933
+ // Dome: uniform magnification — contract sampling toward centre.
934
+ refrPx = -p * u_optics.x * depth * 0.35;
935
+ }
936
+
937
+ // ── Chromatic aberration ──
938
+ float caS = u_optics.y * 18.0 * (edge * 0.7 + 0.3) * 2.0;
939
+ vec2 caD = N.xy * caS;
940
+
941
+ ${projective ? ` // Pane-local sample points → surface px via the FORWARD homography
942
+ // (refraction and aberration computed in the pane's frame).
943
+ vec3 fR = u_h * vec3(p + refrPx + caD, 1.0);
944
+ vec3 fG = u_h * vec3(p + refrPx, 1.0);
945
+ vec3 fB = u_h * vec3(p + refrPx - caD, 1.0);
946
+ vec2 uvR = clamp(fR.xy / (max(fR.z, 1e-4) * u_texSize), vec2(0.0), vec2(1.0));
947
+ vec2 uvG = clamp(fG.xy / (max(fG.z, 1e-4) * u_texSize), vec2(0.0), vec2(1.0));
948
+ vec2 uvB = clamp(fB.xy / (max(fB.z, 1e-4) * u_texSize), vec2(0.0), vec2(1.0));` : ` // Pane-local offsets → surface space (rotate by +θ) → uv.
949
+ vec2 refrW = vec2(refrPx.x * u_rot.x - refrPx.y * u_rot.y,
950
+ refrPx.x * u_rot.y + refrPx.y * u_rot.x);
951
+ vec2 caW = vec2(caD.x * u_rot.x - caD.y * u_rot.y,
952
+ caD.x * u_rot.y + caD.y * u_rot.x);
953
+ vec2 base = v_uv + refrW / u_texSize;
954
+ vec2 oCA = caW / u_texSize;
955
+ vec2 uvR = clamp(base + oCA, vec2(0.0), vec2(1.0));
956
+ vec2 uvG = clamp(base, vec2(0.0), vec2(1.0));
957
+ vec2 uvB = clamp(base - oCA, vec2(0.0), vec2(1.0));`}
958
+ if (u_geo.w > 0.5) { // GL-canvas snapshots are bottom-up
959
+ uvR.y = 1.0 - uvR.y; uvG.y = 1.0 - uvG.y; uvB.y = 1.0 - uvB.y;
960
+ }
961
+
962
+ vec3 sharp = vec3(
963
+ straight3(texture(u_sharp, uvR)).r,
964
+ straight3(texture(u_sharp, uvG)).g,
965
+ straight3(texture(u_sharp, uvB)).b);
966
+ vec3 blur = vec3(
967
+ straight3(texture(u_backdrop, uvR)).r,
968
+ straight3(texture(u_backdrop, uvG)).g,
969
+ straight3(texture(u_backdrop, uvB)).b);
970
+ // Edge-weighted blur mix: centre fully frosted, rim 15% sharp.
971
+ float edgeMix = 1.0 - edge * 0.15;
972
+ vec3 col = mix(sharp, blur, edgeMix);
973
+
974
+ // ── Saturation (reference: 0 = unchanged) ──
975
+ float lum = dot(col, vec3(0.299, 0.587, 0.114));
976
+ col = mix(vec3(lum), col, 1.0 + u_look.y);
977
+
978
+ // ── Tint (our color param in place of the reference's fixed cool tint) ──
979
+ col = mix(col, u_tint.rgb, u_tint.a);
980
+ col *= 1.0 + 0.06 * depth;
981
+
982
+ // ── Fresnel ──
983
+ float fres = pow(1.0 - abs(N.z), 4.0) * u_optics.w;
984
+
985
+ // ── Specular highlights (multi-light Blinn-Phong, reference lights) ──
986
+ vec3 V = vec3(0.0, 0.0, 1.0);
987
+ vec3 L1 = normalize(vec3(0.4, 0.7, 1.0));
988
+ float sp = pow(max(dot(N, normalize(L1 + V)), 0.0), 90.0);
989
+ vec3 L2 = normalize(vec3(-0.3, -0.5, 1.0));
990
+ sp += pow(max(dot(N, normalize(L2 + V)), 0.0), 50.0) * 0.3;
991
+ vec3 L3 = normalize(vec3(0.1, 0.3, 1.0));
992
+ sp += pow(max(dot(N, L3), 0.0), 6.0) * 0.1;
993
+ vec3 L4 = normalize(vec3(0.0, 0.9, 0.4));
994
+ sp += pow(max(dot(N, normalize(L4 + V)), 0.0), 120.0) * 0.6;
995
+ float totalSpec = sp * u_look.x;
996
+
997
+ // ── Inner border / stroke highlight ──
998
+ float borderWidth = 1.5;
999
+ float innerStroke = smoothstep(-borderWidth - 1.0, -borderWidth, sdf)
1000
+ * (1.0 - smoothstep(-1.0, 0.0, sdf));
1001
+ float topBias = 0.5 + 0.5 * (-p.y / half_.y);
1002
+ innerStroke *= (0.4 + 0.6 * topBias);
1003
+
1004
+ // ── Edge highlight & inner glow ──
1005
+ float rim = edge * u_optics.z * 0.22;
1006
+ float innerGlow = smoothstep(5.0, 0.0, -sdf) * u_optics.z * 0.15;
1007
+
1008
+ // ── Environment-like reflection (fake) ──
1009
+ float envRefl = (N.y * 0.5 + 0.5) * fres * 0.08;
1010
+
1011
+ // ── Composite ──
1012
+ vec3 fin = col;
1013
+ fin += vec3(totalSpec);
1014
+ fin += vec3(rim + innerGlow);
1015
+ fin += vec3(innerStroke * u_optics.z * 0.55);
1016
+ fin += vec3(envRefl);
1017
+ fin = mix(fin, vec3(1.0), fres * 0.2);
1018
+
1019
+ float outA = mask * u_look.z;
1020
+ fragColor = vec4(clamp(fin, 0.0, 1.0), 1.0) * outA;
1021
+ }
1022
+ `;
1023
+ // ─── Unit quad — same convention as the WebGPU backend ─────────────────────
1024
+ // prettier-ignore
1025
+ const UNIT_QUAD_VERTICES = new Float32Array([
1026
+ -1, -1, 0, 1,
1027
+ 1, -1, 1, 1,
1028
+ -1, 1, 0, 0,
1029
+ -1, 1, 0, 0,
1030
+ 1, -1, 1, 1,
1031
+ 1, 1, 1, 0,
1032
+ ]);
1033
+ export class WebGL2Backend {
1034
+ canvas;
1035
+ width;
1036
+ height;
1037
+ capabilities;
1038
+ gl;
1039
+ vbo;
1040
+ vao;
1041
+ shapeProgram;
1042
+ shapeLocs;
1043
+ shadowProgram;
1044
+ shadowLocs;
1045
+ gradientProgram;
1046
+ gradientLocs;
1047
+ texturedProgram;
1048
+ texturedLocs;
1049
+ maskedProgram;
1050
+ maskedLocs;
1051
+ backdropBlendProgram;
1052
+ backdropBlendLocs;
1053
+ filteredProgram;
1054
+ filteredLocs;
1055
+ stylizedProgram;
1056
+ stylizedLocs;
1057
+ glassProgram;
1058
+ glassLocs;
1059
+ // Lazy projective variant (CKP/1.0 glass under 3D) — compiled on
1060
+ // first use so 2D documents never pay for it.
1061
+ glass3dProgram = null;
1062
+ glass3dLocs = null;
1063
+ // Lazy PBR lit-shape program (§4.8) — only compiled when a lit shape
1064
+ // is first drawn; unlit documents never pay for it.
1065
+ litShapeProgram = null;
1066
+ litShapeLocs = null;
1067
+ // Lazy PBR lit-textured program (§4.8) — lit images / video / group
1068
+ // cards. Compiled on first lit textured draw.
1069
+ litTexturedProgram = null;
1070
+ litTexturedLocs = null;
1071
+ nextTextureId = 1;
1072
+ liveTextures = new Set();
1073
+ framingActive = false;
1074
+ disposed = false;
1075
+ /** Physical backing-store dims ÷ logical dims (renderResolution). */
1076
+ pixelRatio = 1;
1077
+ /**
1078
+ * Offscreen-surface stack. Empty = drawing to the canvas. Each entry
1079
+ * redirects draws into a framebuffer; flipY compensates for GL's
1080
+ * bottom-up framebuffer textures so layers sample top-down like
1081
+ * uploaded images.
1082
+ */
1083
+ surfaceStack = [];
1084
+ renderTargetFbos = new Map();
1085
+ /**
1086
+ * Set the blend function for the next draw. All draw methods call
1087
+ * this with their params' blend (or undefined → premultiplied over),
1088
+ * so state never leaks between draws. Alpha always composites with
1089
+ * source-over so blended elements still build coverage normally.
1090
+ */
1091
+ applyBlend(mode) {
1092
+ const gl = this.gl;
1093
+ switch (mode) {
1094
+ case 'add':
1095
+ gl.blendFuncSeparate(gl.ONE, gl.ONE, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1096
+ break;
1097
+ case 'multiply':
1098
+ gl.blendFuncSeparate(gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1099
+ break;
1100
+ case 'screen':
1101
+ gl.blendFuncSeparate(gl.ONE, gl.ONE_MINUS_SRC_COLOR, gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1102
+ break;
1103
+ default:
1104
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1105
+ }
1106
+ }
1107
+ /** Dims + flip of whatever we're currently drawing into. */
1108
+ currentSurface() {
1109
+ const top = this.surfaceStack[this.surfaceStack.length - 1];
1110
+ if (top)
1111
+ return top;
1112
+ return {
1113
+ fbo: null,
1114
+ width: this.width,
1115
+ height: this.height,
1116
+ physWidth: this.canvas.width,
1117
+ physHeight: this.canvas.height,
1118
+ flipY: false,
1119
+ };
1120
+ }
1121
+ constructor(canvas) {
1122
+ this.canvas = canvas;
1123
+ this.width = canvas.width;
1124
+ this.height = canvas.height;
1125
+ }
1126
+ async init() {
1127
+ const log = getLogger();
1128
+ const gl = this.canvas.getContext('webgl2', {
1129
+ alpha: true,
1130
+ premultipliedAlpha: true,
1131
+ preserveDrawingBuffer: false,
1132
+ antialias: true,
1133
+ });
1134
+ if (!gl) {
1135
+ log.warn('WebGL2 not available in this environment');
1136
+ return false;
1137
+ }
1138
+ this.gl = gl;
1139
+ // Build pipelines.
1140
+ try {
1141
+ this.shapeProgram = this.buildProgram(SHAPE_VS, SHAPE_FS, 'shape');
1142
+ this.shapeLocs = {
1143
+ aPos: gl.getAttribLocation(this.shapeProgram, 'a_pos'),
1144
+ aUv: gl.getAttribLocation(this.shapeProgram, 'a_uv'),
1145
+ uTransform: gl.getUniformLocation(this.shapeProgram, 'u_transform'),
1146
+ uColor: gl.getUniformLocation(this.shapeProgram, 'u_color'),
1147
+ uStrokeColor: gl.getUniformLocation(this.shapeProgram, 'u_strokeColor'),
1148
+ uStrokeWidth: gl.getUniformLocation(this.shapeProgram, 'u_strokeWidth'),
1149
+ uCornerRadius: gl.getUniformLocation(this.shapeProgram, 'u_cornerRadius'),
1150
+ uShapeType: gl.getUniformLocation(this.shapeProgram, 'u_shapeType'),
1151
+ uSize: gl.getUniformLocation(this.shapeProgram, 'u_size'),
1152
+ };
1153
+ this.shadowProgram = this.buildProgram(SHAPE_VS, SHADOW_FS, 'shadow');
1154
+ this.shadowLocs = {
1155
+ aPos: gl.getAttribLocation(this.shadowProgram, 'a_pos'),
1156
+ aUv: gl.getAttribLocation(this.shadowProgram, 'a_uv'),
1157
+ uTransform: gl.getUniformLocation(this.shadowProgram, 'u_transform'),
1158
+ uColor: gl.getUniformLocation(this.shadowProgram, 'u_color'),
1159
+ uBlur: gl.getUniformLocation(this.shadowProgram, 'u_blur'),
1160
+ uCornerRadius: gl.getUniformLocation(this.shadowProgram, 'u_cornerRadius'),
1161
+ uShapeType: gl.getUniformLocation(this.shadowProgram, 'u_shapeType'),
1162
+ uSize: gl.getUniformLocation(this.shadowProgram, 'u_size'),
1163
+ uQuadSize: gl.getUniformLocation(this.shadowProgram, 'u_quadSize'),
1164
+ };
1165
+ this.gradientProgram = this.buildProgram(GRADIENT_VS, GRADIENT_FS, 'gradient');
1166
+ this.gradientLocs = {
1167
+ aPos: gl.getAttribLocation(this.gradientProgram, 'a_pos'),
1168
+ aUv: gl.getAttribLocation(this.gradientProgram, 'a_uv'),
1169
+ uTransform: gl.getUniformLocation(this.gradientProgram, 'u_transform'),
1170
+ uMeta: gl.getUniformLocation(this.gradientProgram, 'u_meta'),
1171
+ uParams: gl.getUniformLocation(this.gradientProgram, 'u_params'),
1172
+ uSize: gl.getUniformLocation(this.gradientProgram, 'u_size'),
1173
+ uStops: gl.getUniformLocation(this.gradientProgram, 'u_stops'),
1174
+ uStopOffsets: gl.getUniformLocation(this.gradientProgram, 'u_stopOffsets'),
1175
+ };
1176
+ this.texturedProgram = this.buildProgram(TEXTURED_VS, TEXTURED_FS, 'textured');
1177
+ this.texturedLocs = {
1178
+ aPos: gl.getAttribLocation(this.texturedProgram, 'a_pos'),
1179
+ aUv: gl.getAttribLocation(this.texturedProgram, 'a_uv'),
1180
+ uTransform: gl.getUniformLocation(this.texturedProgram, 'u_transform'),
1181
+ uUvRect: gl.getUniformLocation(this.texturedProgram, 'u_uvRect'),
1182
+ uTint: gl.getUniformLocation(this.texturedProgram, 'u_tint'),
1183
+ uTex: gl.getUniformLocation(this.texturedProgram, 'u_tex'),
1184
+ uCornerRadius: gl.getUniformLocation(this.texturedProgram, 'u_cornerRadius'),
1185
+ uSize: gl.getUniformLocation(this.texturedProgram, 'u_size'),
1186
+ uAlphaGamma: gl.getUniformLocation(this.texturedProgram, 'u_alphaGamma'),
1187
+ };
1188
+ this.maskedProgram = this.buildProgram(TEXTURED_VS, MASKED_FS, 'masked');
1189
+ this.maskedLocs = {
1190
+ aPos: gl.getAttribLocation(this.maskedProgram, 'a_pos'),
1191
+ aUv: gl.getAttribLocation(this.maskedProgram, 'a_uv'),
1192
+ uTransform: gl.getUniformLocation(this.maskedProgram, 'u_transform'),
1193
+ uUvRect: gl.getUniformLocation(this.maskedProgram, 'u_uvRect'),
1194
+ uTex: gl.getUniformLocation(this.maskedProgram, 'u_tex'),
1195
+ uMask: gl.getUniformLocation(this.maskedProgram, 'u_mask'),
1196
+ uTint: gl.getUniformLocation(this.maskedProgram, 'u_tint'),
1197
+ uMode: gl.getUniformLocation(this.maskedProgram, 'u_mode'),
1198
+ };
1199
+ this.filteredProgram = this.buildProgram(TEXTURED_VS, FILTERED_FS, 'filtered');
1200
+ this.filteredLocs = {
1201
+ aPos: gl.getAttribLocation(this.filteredProgram, 'a_pos'),
1202
+ aUv: gl.getAttribLocation(this.filteredProgram, 'a_uv'),
1203
+ uTransform: gl.getUniformLocation(this.filteredProgram, 'u_transform'),
1204
+ uUvRect: gl.getUniformLocation(this.filteredProgram, 'u_uvRect'),
1205
+ uTex: gl.getUniformLocation(this.filteredProgram, 'u_tex'),
1206
+ uTexel: gl.getUniformLocation(this.filteredProgram, 'u_texel'),
1207
+ uSigma: gl.getUniformLocation(this.filteredProgram, 'u_sigma'),
1208
+ uColorOps: gl.getUniformLocation(this.filteredProgram, 'u_colorOps'),
1209
+ uTint: gl.getUniformLocation(this.filteredProgram, 'u_tint'),
1210
+ };
1211
+ this.stylizedProgram = this.buildProgram(TEXTURED_VS, STYLIZED_FS, 'stylized');
1212
+ this.stylizedLocs = {
1213
+ aPos: gl.getAttribLocation(this.stylizedProgram, 'a_pos'),
1214
+ aUv: gl.getAttribLocation(this.stylizedProgram, 'a_uv'),
1215
+ uTransform: gl.getUniformLocation(this.stylizedProgram, 'u_transform'),
1216
+ uUvRect: gl.getUniformLocation(this.stylizedProgram, 'u_uvRect'),
1217
+ uTex: gl.getUniformLocation(this.stylizedProgram, 'u_tex'),
1218
+ uAux: gl.getUniformLocation(this.stylizedProgram, 'u_aux'),
1219
+ uTexSize: gl.getUniformLocation(this.stylizedProgram, 'u_texSize'),
1220
+ uParams: gl.getUniformLocation(this.stylizedProgram, 'u_params'),
1221
+ uTint: gl.getUniformLocation(this.stylizedProgram, 'u_tint'),
1222
+ };
1223
+ this.glassProgram = this.buildProgram(TEXTURED_VS, glassFsSource(false), 'glass');
1224
+ this.glassLocs = this.glassLocsOf(this.glassProgram);
1225
+ this.backdropBlendProgram = this.buildProgram(TEXTURED_VS, BACKDROP_BLEND_FS, 'backdropBlend');
1226
+ this.backdropBlendLocs = {
1227
+ aPos: gl.getAttribLocation(this.backdropBlendProgram, 'a_pos'),
1228
+ aUv: gl.getAttribLocation(this.backdropBlendProgram, 'a_uv'),
1229
+ uTransform: gl.getUniformLocation(this.backdropBlendProgram, 'u_transform'),
1230
+ uUvRect: gl.getUniformLocation(this.backdropBlendProgram, 'u_uvRect'),
1231
+ uSrc: gl.getUniformLocation(this.backdropBlendProgram, 'u_src'),
1232
+ uBackdrop: gl.getUniformLocation(this.backdropBlendProgram, 'u_backdrop'),
1233
+ uMode: gl.getUniformLocation(this.backdropBlendProgram, 'u_mode'),
1234
+ uBackdropFlipY: gl.getUniformLocation(this.backdropBlendProgram, 'u_backdropFlipY'),
1235
+ };
1236
+ }
1237
+ catch (err) {
1238
+ log.error('WebGL2 shader compile failed:', err instanceof Error ? err.message : String(err));
1239
+ return false;
1240
+ }
1241
+ // Vertex buffer + VAO.
1242
+ this.vbo = gl.createBuffer();
1243
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
1244
+ gl.bufferData(gl.ARRAY_BUFFER, UNIT_QUAD_VERTICES, gl.STATIC_DRAW);
1245
+ this.vao = gl.createVertexArray();
1246
+ gl.bindVertexArray(this.vao);
1247
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.vbo);
1248
+ // Two attributes share the same VBO; we re-enable them per program because
1249
+ // the attribute indices may differ between shape and textured programs.
1250
+ // We bind them here for the shape program; in drawTexturedQuad we re-bind
1251
+ // for the textured program if locations differ.
1252
+ this.setupVertexAttribs(this.shapeLocs.aPos, this.shapeLocs.aUv);
1253
+ gl.bindVertexArray(null);
1254
+ // Premultiplied alpha blending.
1255
+ gl.enable(gl.BLEND);
1256
+ gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
1257
+ // Texture upload defaults.
1258
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
1259
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
1260
+ this.capabilities = {
1261
+ api: 'webgl2',
1262
+ maxTextureSize: gl.getParameter(gl.MAX_TEXTURE_SIZE),
1263
+ };
1264
+ log.info(`WebGL2 backend ready (maxTextureSize=${this.capabilities.maxTextureSize})`);
1265
+ return true;
1266
+ }
1267
+ buildProgram(vsSrc, fsSrc, label) {
1268
+ const gl = this.gl;
1269
+ const vs = this.compileShader(gl.VERTEX_SHADER, vsSrc, `${label}.vs`);
1270
+ const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSrc, `${label}.fs`);
1271
+ const program = gl.createProgram();
1272
+ gl.attachShader(program, vs);
1273
+ gl.attachShader(program, fs);
1274
+ gl.linkProgram(program);
1275
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
1276
+ const info = gl.getProgramInfoLog(program);
1277
+ throw new Error(`Link error (${label}): ${info}`);
1278
+ }
1279
+ gl.deleteShader(vs);
1280
+ gl.deleteShader(fs);
1281
+ return program;
1282
+ }
1283
+ compileShader(type, src, label) {
1284
+ const gl = this.gl;
1285
+ const shader = gl.createShader(type);
1286
+ gl.shaderSource(shader, src);
1287
+ gl.compileShader(shader);
1288
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
1289
+ const info = gl.getShaderInfoLog(shader);
1290
+ throw new Error(`Compile error (${label}): ${info}`);
1291
+ }
1292
+ return shader;
1293
+ }
1294
+ setupVertexAttribs(aPos, aUv) {
1295
+ const gl = this.gl;
1296
+ gl.enableVertexAttribArray(aPos);
1297
+ gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0);
1298
+ gl.enableVertexAttribArray(aUv);
1299
+ gl.vertexAttribPointer(aUv, 2, gl.FLOAT, false, 16, 8);
1300
+ }
1301
+ resize(width, height, pixelRatio = 1) {
1302
+ if (this.disposed)
1303
+ return;
1304
+ const physW = Math.max(1, Math.round(width * pixelRatio));
1305
+ const physH = Math.max(1, Math.round(height * pixelRatio));
1306
+ if (width === this.width &&
1307
+ height === this.height &&
1308
+ this.canvas.width === physW &&
1309
+ this.canvas.height === physH)
1310
+ return;
1311
+ this.width = width;
1312
+ this.height = height;
1313
+ this.pixelRatio = pixelRatio;
1314
+ this.canvas.width = physW;
1315
+ this.canvas.height = physH;
1316
+ this.gl.viewport(0, 0, physW, physH);
1317
+ }
1318
+ // ─── Textures ─────────────────────────────────────────────────────────────
1319
+ createTexture(source) {
1320
+ const { width, height } = sourceDimensions(source);
1321
+ const gl = this.gl;
1322
+ const handle = gl.createTexture();
1323
+ gl.bindTexture(gl.TEXTURE_2D, handle);
1324
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1325
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1326
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1327
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1328
+ this.uploadToTexture(handle, source);
1329
+ const texture = { id: this.nextTextureId++, width, height, handle };
1330
+ this.liveTextures.add(texture);
1331
+ return texture;
1332
+ }
1333
+ updateTexture(texture, source) {
1334
+ const t = texture;
1335
+ this.uploadToTexture(t.handle, source);
1336
+ }
1337
+ uploadToTexture(handle, source) {
1338
+ const gl = this.gl;
1339
+ gl.bindTexture(gl.TEXTURE_2D, handle);
1340
+ // texImage2D accepts each of our source types directly.
1341
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE,
1342
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1343
+ source);
1344
+ }
1345
+ destroyTexture(texture) {
1346
+ const t = texture;
1347
+ if (this.liveTextures.delete(t)) {
1348
+ this.gl.deleteTexture(t.handle);
1349
+ }
1350
+ }
1351
+ // ─── Frame lifecycle ──────────────────────────────────────────────────────
1352
+ beginFrame(clearColor = [0, 0, 0, 1]) {
1353
+ if (this.framingActive) {
1354
+ getLogger().warn('beginFrame called while another frame is in progress');
1355
+ this.endFrame();
1356
+ }
1357
+ this.framingActive = true;
1358
+ // Defensive: a frame never starts mid-target.
1359
+ this.surfaceStack.length = 0;
1360
+ const gl = this.gl;
1361
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
1362
+ // Viewport covers the PHYSICAL backing store — using logical dims
1363
+ // here broke hi-res (pixelRatio > 1) rendering by drawing into the
1364
+ // bottom-left fraction of the canvas.
1365
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
1366
+ gl.clearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]);
1367
+ gl.clear(gl.COLOR_BUFFER_BIT);
1368
+ gl.bindVertexArray(this.vao);
1369
+ }
1370
+ endFrame() {
1371
+ if (!this.framingActive)
1372
+ return;
1373
+ this.framingActive = false;
1374
+ if (this.surfaceStack.length > 0) {
1375
+ getLogger().warn('endFrame with unbalanced pushTarget — restoring canvas');
1376
+ this.surfaceStack.length = 0;
1377
+ this.gl.bindFramebuffer(this.gl.FRAMEBUFFER, null);
1378
+ }
1379
+ // WebGL has no explicit submit; the browser presents after the current
1380
+ // RAF callback returns.
1381
+ this.gl.bindVertexArray(null);
1382
+ }
1383
+ // ─── Offscreen render targets ─────────────────────────────────────────────
1384
+ createRenderTarget(width, height) {
1385
+ const gl = this.gl;
1386
+ const physW = Math.max(1, Math.round(width * this.pixelRatio));
1387
+ const physH = Math.max(1, Math.round(height * this.pixelRatio));
1388
+ const handle = gl.createTexture();
1389
+ gl.bindTexture(gl.TEXTURE_2D, handle);
1390
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
1391
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
1392
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
1393
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
1394
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, physW, physH, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
1395
+ const texture = { id: this.nextTextureId++, width: physW, height: physH, handle };
1396
+ this.liveTextures.add(texture);
1397
+ const fbo = gl.createFramebuffer();
1398
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1399
+ gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, handle, 0);
1400
+ const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);
1401
+ gl.bindFramebuffer(gl.FRAMEBUFFER, this.currentSurface().fbo);
1402
+ if (status !== gl.FRAMEBUFFER_COMPLETE) {
1403
+ throw new Error(`render target framebuffer incomplete (0x${status.toString(16)})`);
1404
+ }
1405
+ const target = { texture, width, height };
1406
+ this.renderTargetFbos.set(target, fbo);
1407
+ return target;
1408
+ }
1409
+ destroyRenderTarget(target) {
1410
+ const fbo = this.renderTargetFbos.get(target);
1411
+ if (fbo) {
1412
+ this.gl.deleteFramebuffer(fbo);
1413
+ this.renderTargetFbos.delete(target);
1414
+ }
1415
+ this.destroyTexture(target.texture);
1416
+ }
1417
+ pushTarget(target, clearColor = [0, 0, 0, 0]) {
1418
+ const fbo = this.renderTargetFbos.get(target);
1419
+ if (!fbo) {
1420
+ getLogger().warn('pushTarget with unknown / destroyed target — ignored');
1421
+ return;
1422
+ }
1423
+ const gl = this.gl;
1424
+ const tex = target.texture;
1425
+ this.surfaceStack.push({
1426
+ fbo,
1427
+ width: target.width,
1428
+ height: target.height,
1429
+ physWidth: tex.width,
1430
+ physHeight: tex.height,
1431
+ flipY: true,
1432
+ });
1433
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
1434
+ gl.viewport(0, 0, tex.width, tex.height);
1435
+ gl.clearColor(clearColor[0], clearColor[1], clearColor[2], clearColor[3]);
1436
+ gl.clear(gl.COLOR_BUFFER_BIT);
1437
+ }
1438
+ popTarget() {
1439
+ if (this.surfaceStack.length === 0) {
1440
+ getLogger().warn('popTarget without matching pushTarget — ignored');
1441
+ return;
1442
+ }
1443
+ this.surfaceStack.pop();
1444
+ const s = this.currentSurface();
1445
+ const gl = this.gl;
1446
+ gl.bindFramebuffer(gl.FRAMEBUFFER, s.fbo);
1447
+ gl.viewport(0, 0, s.physWidth, s.physHeight);
1448
+ }
1449
+ // ─── Drawing ──────────────────────────────────────────────────────────────
1450
+ drawShapeShadow(params) {
1451
+ if (!this.framingActive)
1452
+ return;
1453
+ this.applyBlend(undefined);
1454
+ if (params.blur <= 0 && params.offsetX === 0 && params.offsetY === 0)
1455
+ return;
1456
+ const gl = this.gl;
1457
+ const blur = Math.max(0, params.blur);
1458
+ // The shadow quad spans the shape PLUS `blur` pixels on every
1459
+ // side. Center it on the shape, then displace by the user's offset.
1460
+ const quadW = params.width + blur * 2;
1461
+ const quadH = params.height + blur * 2;
1462
+ const surface = this.currentSurface();
1463
+ const transform = params.transform
1464
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1465
+ : composeQuadTransform(params.cx + params.offsetX, params.cy + params.offsetY, quadW, quadH, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1466
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1467
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1468
+ gl.useProgram(this.shadowProgram);
1469
+ this.setupVertexAttribs(this.shadowLocs.aPos, this.shadowLocs.aUv);
1470
+ if (this.shadowLocs.uTransform)
1471
+ gl.uniformMatrix4fv(this.shadowLocs.uTransform, false, transform);
1472
+ if (this.shadowLocs.uColor)
1473
+ gl.uniform4f(this.shadowLocs.uColor, params.color[0], params.color[1], params.color[2], params.color[3]);
1474
+ if (this.shadowLocs.uBlur)
1475
+ gl.uniform1f(this.shadowLocs.uBlur, blur);
1476
+ if (this.shadowLocs.uCornerRadius)
1477
+ gl.uniform1f(this.shadowLocs.uCornerRadius, cornerRadius);
1478
+ if (this.shadowLocs.uShapeType)
1479
+ gl.uniform1f(this.shadowLocs.uShapeType, shapeType);
1480
+ if (this.shadowLocs.uSize)
1481
+ gl.uniform2f(this.shadowLocs.uSize, params.width, params.height);
1482
+ if (this.shadowLocs.uQuadSize)
1483
+ gl.uniform2f(this.shadowLocs.uQuadSize, quadW, quadH);
1484
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1485
+ }
1486
+ drawShape(params) {
1487
+ if (!this.framingActive)
1488
+ return;
1489
+ if (params.gradient) {
1490
+ this.drawGradientShape(params);
1491
+ return;
1492
+ }
1493
+ if (params.lit) {
1494
+ this.drawLitShape(params);
1495
+ return;
1496
+ }
1497
+ const gl = this.gl;
1498
+ this.applyBlend(params.blend);
1499
+ const surface = this.currentSurface();
1500
+ const transform = params.transform
1501
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1502
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1503
+ // cornerRadius is PIXELS — clamp to half the smaller side so overflowing
1504
+ // values still produce a sensible shape (full-corner pill or circle).
1505
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1506
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1507
+ gl.useProgram(this.shapeProgram);
1508
+ this.setupVertexAttribs(this.shapeLocs.aPos, this.shapeLocs.aUv);
1509
+ if (this.shapeLocs.uTransform)
1510
+ gl.uniformMatrix4fv(this.shapeLocs.uTransform, false, transform);
1511
+ if (this.shapeLocs.uColor)
1512
+ gl.uniform4f(this.shapeLocs.uColor, params.color[0], params.color[1], params.color[2], params.color[3]);
1513
+ const sw = params.strokeWidth ?? 0;
1514
+ const sc = params.strokeColor ?? params.color;
1515
+ if (this.shapeLocs.uStrokeColor)
1516
+ gl.uniform4f(this.shapeLocs.uStrokeColor, sc[0], sc[1], sc[2], sc[3]);
1517
+ if (this.shapeLocs.uStrokeWidth)
1518
+ gl.uniform1f(this.shapeLocs.uStrokeWidth, sw);
1519
+ if (this.shapeLocs.uCornerRadius)
1520
+ gl.uniform1f(this.shapeLocs.uCornerRadius, cornerRadius);
1521
+ if (this.shapeLocs.uShapeType)
1522
+ gl.uniform1f(this.shapeLocs.uShapeType, shapeType);
1523
+ if (this.shapeLocs.uSize)
1524
+ gl.uniform2f(this.shapeLocs.uSize, params.width, params.height);
1525
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1526
+ }
1527
+ getLitShapeProgram() {
1528
+ if (!this.litShapeProgram) {
1529
+ const gl = this.gl;
1530
+ const prog = this.buildProgram(LIT_SHAPE_VS, LIT_SHAPE_FS, 'litShape');
1531
+ const u = (n) => gl.getUniformLocation(prog, n);
1532
+ this.litShapeProgram = prog;
1533
+ this.litShapeLocs = {
1534
+ aPos: gl.getAttribLocation(prog, 'a_pos'),
1535
+ aUv: gl.getAttribLocation(prog, 'a_uv'),
1536
+ u: {
1537
+ transform: u('u_transform'), worldMatrix: u('u_worldMatrix'),
1538
+ albedo: u('u_albedo'), strokeAlbedo: u('u_strokeAlbedo'),
1539
+ strokeWidth: u('u_strokeWidth'), cornerRadius: u('u_cornerRadius'),
1540
+ shapeType: u('u_shapeType'), size: u('u_size'),
1541
+ normal: u('u_normal'), eye: u('u_eye'),
1542
+ rough: u('u_rough'), metal: u('u_metal'), reflect: u('u_reflect'),
1543
+ emissive: u('u_emissive'), ambient: u('u_ambient'),
1544
+ numLights: u('u_numLights'), lightDir: u('u_lightDir'), lightColor: u('u_lightColor'),
1545
+ envCount: u('u_envCount'), envColor: u('u_envColor'), envOffset: u('u_envOffset'), envAvg: u('u_envAvg'),
1546
+ tangent: u('u_tangent'), bitangent: u('u_bitangent'), normalScale: u('u_normalScale'),
1547
+ hasNormalMap: u('u_hasNormalMap'), normalMap: u('u_normalMap'),
1548
+ envIsImage: u('u_envIsImage'), envMap: u('u_envMap'),
1549
+ },
1550
+ };
1551
+ }
1552
+ return this.litShapeProgram;
1553
+ }
1554
+ drawLitShape(params) {
1555
+ const lit = params.lit;
1556
+ const gl = this.gl;
1557
+ this.applyBlend(params.blend);
1558
+ const surface = this.currentSurface();
1559
+ const transform = params.transform
1560
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1561
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1562
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1563
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1564
+ this.getLitShapeProgram();
1565
+ const { aPos, aUv, u } = this.litShapeLocs;
1566
+ gl.useProgram(this.litShapeProgram);
1567
+ this.setupVertexAttribs(aPos, aUv);
1568
+ if (u.transform)
1569
+ gl.uniformMatrix4fv(u.transform, false, transform);
1570
+ if (u.worldMatrix)
1571
+ gl.uniformMatrix4fv(u.worldMatrix, false, Array.from(lit.worldMatrix));
1572
+ const alb = lit.albedo;
1573
+ if (u.albedo)
1574
+ gl.uniform4f(u.albedo, alb[0], alb[1], alb[2], alb[3]);
1575
+ const salb = lit.strokeAlbedo ?? alb;
1576
+ if (u.strokeAlbedo)
1577
+ gl.uniform4f(u.strokeAlbedo, salb[0], salb[1], salb[2], salb[3]);
1578
+ if (u.strokeWidth)
1579
+ gl.uniform1f(u.strokeWidth, params.strokeWidth ?? 0);
1580
+ if (u.cornerRadius)
1581
+ gl.uniform1f(u.cornerRadius, cornerRadius);
1582
+ if (u.shapeType)
1583
+ gl.uniform1f(u.shapeType, shapeType);
1584
+ if (u.size)
1585
+ gl.uniform2f(u.size, params.width, params.height);
1586
+ this.setLitPbrUniforms(u, lit);
1587
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1588
+ }
1589
+ // Set the PBR uniforms shared by the lit-shape and lit-textured
1590
+ // programs (material, normal/eye, lights, environment). The env count
1591
+ // is set every draw — uniforms persist per-program, so a prior
1592
+ // env-bearing draw must not leak into one without an environment.
1593
+ setLitPbrUniforms(u, lit) {
1594
+ const gl = this.gl;
1595
+ if (u.normal)
1596
+ gl.uniform3f(u.normal, lit.normal[0], lit.normal[1], lit.normal[2]);
1597
+ if (u.eye)
1598
+ gl.uniform3f(u.eye, lit.eye[0], lit.eye[1], lit.eye[2]);
1599
+ if (u.rough)
1600
+ gl.uniform1f(u.rough, lit.roughness);
1601
+ if (u.metal)
1602
+ gl.uniform1f(u.metal, lit.metalness);
1603
+ if (u.reflect)
1604
+ gl.uniform1f(u.reflect, lit.reflectivity);
1605
+ if (u.emissive)
1606
+ gl.uniform1f(u.emissive, lit.emissive);
1607
+ if (u.ambient)
1608
+ gl.uniform3f(u.ambient, lit.ambient[0], lit.ambient[1], lit.ambient[2]);
1609
+ const n = Math.min(4, lit.lightDirs.length);
1610
+ if (u.numLights)
1611
+ gl.uniform1i(u.numLights, n);
1612
+ if (n > 0) {
1613
+ const dirs = new Float32Array(12);
1614
+ const cols = new Float32Array(12);
1615
+ for (let i = 0; i < n; i++) {
1616
+ dirs[i * 3] = lit.lightDirs[i][0];
1617
+ dirs[i * 3 + 1] = lit.lightDirs[i][1];
1618
+ dirs[i * 3 + 2] = lit.lightDirs[i][2];
1619
+ cols[i * 3] = lit.lightColors[i][0];
1620
+ cols[i * 3 + 1] = lit.lightColors[i][1];
1621
+ cols[i * 3 + 2] = lit.lightColors[i][2];
1622
+ }
1623
+ if (u.lightDir)
1624
+ gl.uniform3fv(u.lightDir, dirs.subarray(0, n * 3));
1625
+ if (u.lightColor)
1626
+ gl.uniform3fv(u.lightColor, cols.subarray(0, n * 3));
1627
+ }
1628
+ const env = lit.env;
1629
+ const ec = env ? Math.min(4, env.stopColors.length) : 0;
1630
+ if (u.envCount)
1631
+ gl.uniform1i(u.envCount, ec);
1632
+ if (ec > 0 && env) {
1633
+ const ecol = new Float32Array(12);
1634
+ const eoff = new Float32Array(4);
1635
+ for (let i = 0; i < ec; i++) {
1636
+ ecol[i * 3] = env.stopColors[i][0];
1637
+ ecol[i * 3 + 1] = env.stopColors[i][1];
1638
+ ecol[i * 3 + 2] = env.stopColors[i][2];
1639
+ eoff[i] = env.stopOffsets[i];
1640
+ }
1641
+ if (u.envColor)
1642
+ gl.uniform3fv(u.envColor, ecol.subarray(0, ec * 3));
1643
+ if (u.envOffset)
1644
+ gl.uniform1fv(u.envOffset, eoff.subarray(0, ec));
1645
+ }
1646
+ // avg is the roughness-blur fallback for BOTH gradient and image envs.
1647
+ if (env && u.envAvg)
1648
+ gl.uniform3f(u.envAvg, env.avg[0], env.avg[1], env.avg[2]);
1649
+ // §4.8 Phase 2 normal map — bound to texture unit 1 (default flat
1650
+ // when absent so the sampler is always valid). Restore unit 0 after.
1651
+ const nm = lit.normalMap;
1652
+ if (u.hasNormalMap)
1653
+ gl.uniform1i(u.hasNormalMap, nm ? 1 : 0);
1654
+ if (u.normalScale)
1655
+ gl.uniform1f(u.normalScale, nm ? nm.scale : 1);
1656
+ if (nm) {
1657
+ if (u.tangent)
1658
+ gl.uniform3f(u.tangent, nm.tangent[0], nm.tangent[1], nm.tangent[2]);
1659
+ if (u.bitangent)
1660
+ gl.uniform3f(u.bitangent, nm.bitangent[0], nm.bitangent[1], nm.bitangent[2]);
1661
+ }
1662
+ gl.activeTexture(gl.TEXTURE1);
1663
+ gl.bindTexture(gl.TEXTURE_2D, nm ? nm.texture.handle : this.getFlatNormalTexture());
1664
+ if (u.normalMap)
1665
+ gl.uniform1i(u.normalMap, 1);
1666
+ // §4.8 Phase 3 image environment — bound to unit 2 (default flat when
1667
+ // absent; only sampled when u_envIsImage = 1).
1668
+ const envImg = env?.image;
1669
+ if (u.envIsImage)
1670
+ gl.uniform1i(u.envIsImage, envImg ? 1 : 0);
1671
+ gl.activeTexture(gl.TEXTURE2);
1672
+ gl.bindTexture(gl.TEXTURE_2D, envImg ? envImg.handle : this.getFlatNormalTexture());
1673
+ if (u.envMap)
1674
+ gl.uniform1i(u.envMap, 2);
1675
+ gl.activeTexture(gl.TEXTURE0);
1676
+ }
1677
+ // 1×1 flat tangent-space normal (#8080ff = (0,0,1)) bound when a lit
1678
+ // draw has no normal map, so the sampler always references a texture.
1679
+ flatNormalTex = null;
1680
+ getFlatNormalTexture() {
1681
+ if (!this.flatNormalTex) {
1682
+ const gl = this.gl;
1683
+ const tex = gl.createTexture();
1684
+ gl.bindTexture(gl.TEXTURE_2D, tex);
1685
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([128, 128, 255, 255]));
1686
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
1687
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
1688
+ this.flatNormalTex = tex;
1689
+ }
1690
+ return this.flatNormalTex;
1691
+ }
1692
+ getLitTexturedProgram() {
1693
+ if (!this.litTexturedProgram) {
1694
+ const gl = this.gl;
1695
+ const prog = this.buildProgram(LIT_TEXTURED_VS, LIT_TEXTURED_FS, 'litTextured');
1696
+ const u = (n) => gl.getUniformLocation(prog, n);
1697
+ this.litTexturedProgram = prog;
1698
+ this.litTexturedLocs = {
1699
+ aPos: gl.getAttribLocation(prog, 'a_pos'),
1700
+ aUv: gl.getAttribLocation(prog, 'a_uv'),
1701
+ u: {
1702
+ transform: u('u_transform'), worldMatrix: u('u_worldMatrix'), uvRect: u('u_uvRect'),
1703
+ tex: u('u_tex'), tint: u('u_tint'), cornerRadius: u('u_cornerRadius'), size: u('u_size'),
1704
+ normal: u('u_normal'), eye: u('u_eye'),
1705
+ rough: u('u_rough'), metal: u('u_metal'), reflect: u('u_reflect'),
1706
+ emissive: u('u_emissive'), ambient: u('u_ambient'),
1707
+ numLights: u('u_numLights'), lightDir: u('u_lightDir'), lightColor: u('u_lightColor'),
1708
+ envCount: u('u_envCount'), envColor: u('u_envColor'), envOffset: u('u_envOffset'), envAvg: u('u_envAvg'),
1709
+ tangent: u('u_tangent'), bitangent: u('u_bitangent'), normalScale: u('u_normalScale'),
1710
+ hasNormalMap: u('u_hasNormalMap'), normalMap: u('u_normalMap'),
1711
+ envIsImage: u('u_envIsImage'), envMap: u('u_envMap'),
1712
+ },
1713
+ };
1714
+ }
1715
+ return this.litTexturedProgram;
1716
+ }
1717
+ drawLitTexturedQuad(params) {
1718
+ const lit = params.lit;
1719
+ const gl = this.gl;
1720
+ this.applyBlend(params.blend);
1721
+ const surface = this.currentSurface();
1722
+ const transform = params.transform
1723
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1724
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1725
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1726
+ const uvRect = params.uvRect ?? [0, 0, 1, 1];
1727
+ const tint = params.tint ?? [1, 1, 1, 1];
1728
+ this.getLitTexturedProgram();
1729
+ const { aPos, aUv, u } = this.litTexturedLocs;
1730
+ gl.useProgram(this.litTexturedProgram);
1731
+ this.setupVertexAttribs(aPos, aUv);
1732
+ if (u.transform)
1733
+ gl.uniformMatrix4fv(u.transform, false, transform);
1734
+ if (u.worldMatrix)
1735
+ gl.uniformMatrix4fv(u.worldMatrix, false, Array.from(lit.worldMatrix));
1736
+ if (u.uvRect)
1737
+ gl.uniform4f(u.uvRect, uvRect[0], uvRect[1], uvRect[2], uvRect[3]);
1738
+ if (u.tint)
1739
+ gl.uniform4f(u.tint, tint[0], tint[1], tint[2], tint[3]);
1740
+ if (u.cornerRadius)
1741
+ gl.uniform1f(u.cornerRadius, cornerRadius);
1742
+ if (u.size)
1743
+ gl.uniform2f(u.size, params.width, params.height);
1744
+ gl.activeTexture(gl.TEXTURE0);
1745
+ gl.bindTexture(gl.TEXTURE_2D, params.texture.handle);
1746
+ if (u.tex)
1747
+ gl.uniform1i(u.tex, 0);
1748
+ this.setLitPbrUniforms(u, lit);
1749
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1750
+ }
1751
+ drawGradientShape(params) {
1752
+ if (!this.framingActive || !params.gradient)
1753
+ return;
1754
+ const gl = this.gl;
1755
+ this.applyBlend(params.blend);
1756
+ const surface = this.currentSurface();
1757
+ const transform = params.transform
1758
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1759
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1760
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1761
+ const shapeType = params.shape === 'ellipse' ? 1 : 0;
1762
+ const g = params.gradient;
1763
+ const fillType = g.type === 'radial' ? 1 : 0;
1764
+ const stops = g.stops.slice(0, 4);
1765
+ const nStops = Math.max(2, stops.length);
1766
+ // Flatten stop colors into a 16-float array (4 stops × 4 floats); pad
1767
+ // missing slots with the last stop's color.
1768
+ const stopColors = new Float32Array(16);
1769
+ for (let i = 0; i < 4; i++) {
1770
+ const stop = stops[i] ?? stops[stops.length - 1];
1771
+ stopColors[i * 4] = stop.color[0];
1772
+ stopColors[i * 4 + 1] = stop.color[1];
1773
+ stopColors[i * 4 + 2] = stop.color[2];
1774
+ stopColors[i * 4 + 3] = stop.color[3];
1775
+ }
1776
+ const stopOffsets = new Float32Array(4);
1777
+ for (let i = 0; i < 4; i++)
1778
+ stopOffsets[i] = stops[i] ? stops[i].offset : 1;
1779
+ gl.useProgram(this.gradientProgram);
1780
+ this.setupVertexAttribs(this.gradientLocs.aPos, this.gradientLocs.aUv);
1781
+ if (this.gradientLocs.uTransform)
1782
+ gl.uniformMatrix4fv(this.gradientLocs.uTransform, false, transform);
1783
+ if (this.gradientLocs.uMeta)
1784
+ gl.uniform4f(this.gradientLocs.uMeta, cornerRadius, shapeType, fillType, nStops);
1785
+ if (this.gradientLocs.uSize)
1786
+ gl.uniform2f(this.gradientLocs.uSize, params.width, params.height);
1787
+ if (this.gradientLocs.uParams) {
1788
+ if (g.type === 'linear') {
1789
+ gl.uniform4f(this.gradientLocs.uParams, Math.cos(g.angle), Math.sin(g.angle), 0, 0);
1790
+ }
1791
+ else {
1792
+ gl.uniform4f(this.gradientLocs.uParams, g.cx, g.cy, g.radius, 0);
1793
+ }
1794
+ }
1795
+ if (this.gradientLocs.uStops)
1796
+ gl.uniform4fv(this.gradientLocs.uStops, stopColors);
1797
+ if (this.gradientLocs.uStopOffsets)
1798
+ gl.uniform4fv(this.gradientLocs.uStopOffsets, stopOffsets);
1799
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1800
+ }
1801
+ drawTexturedQuad(params) {
1802
+ if (!this.framingActive)
1803
+ return;
1804
+ if (params.lit) {
1805
+ this.drawLitTexturedQuad(params);
1806
+ return;
1807
+ }
1808
+ const gl = this.gl;
1809
+ this.applyBlend(params.blend);
1810
+ const surface = this.currentSurface();
1811
+ const transform = params.transform
1812
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1813
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, params.skewX ?? 0, params.skewY ?? 0, surface.flipY);
1814
+ const uvRect = params.uvRect ?? [0, 0, 1, 1];
1815
+ const tint = params.tint ?? [1, 1, 1, 1];
1816
+ gl.useProgram(this.texturedProgram);
1817
+ this.setupVertexAttribs(this.texturedLocs.aPos, this.texturedLocs.aUv);
1818
+ if (this.texturedLocs.uTransform)
1819
+ gl.uniformMatrix4fv(this.texturedLocs.uTransform, false, transform);
1820
+ if (this.texturedLocs.uUvRect)
1821
+ gl.uniform4f(this.texturedLocs.uUvRect, uvRect[0], uvRect[1], uvRect[2], uvRect[3]);
1822
+ if (this.texturedLocs.uTint)
1823
+ gl.uniform4f(this.texturedLocs.uTint, tint[0], tint[1], tint[2], tint[3]);
1824
+ const cornerRadius = Math.max(0, Math.min(params.cornerRadius ?? 0, Math.min(params.width, params.height) * 0.5));
1825
+ if (this.texturedLocs.uCornerRadius)
1826
+ gl.uniform1f(this.texturedLocs.uCornerRadius, cornerRadius);
1827
+ if (this.texturedLocs.uSize)
1828
+ gl.uniform2f(this.texturedLocs.uSize, params.width, params.height);
1829
+ if (this.texturedLocs.uAlphaGamma)
1830
+ gl.uniform1f(this.texturedLocs.uAlphaGamma, params.alphaGamma ?? 1);
1831
+ const t = params.texture;
1832
+ gl.activeTexture(gl.TEXTURE0);
1833
+ gl.bindTexture(gl.TEXTURE_2D, t.handle);
1834
+ if (this.texturedLocs.uTex)
1835
+ gl.uniform1i(this.texturedLocs.uTex, 0);
1836
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1837
+ }
1838
+ drawMaskedQuad(params) {
1839
+ if (!this.framingActive)
1840
+ return;
1841
+ const gl = this.gl;
1842
+ this.applyBlend(params.blend);
1843
+ const surface = this.currentSurface();
1844
+ const transform = params.transform
1845
+ ? projectPixelMatrix(params.transform, surface.width, surface.height, surface.flipY)
1846
+ : composeQuadTransform(params.cx, params.cy, params.width, params.height, params.rotation, surface.width, surface.height, 0, 0, surface.flipY);
1847
+ const tint = params.tint ?? [1, 1, 1, 1];
1848
+ const mode = params.mode === 'alpha' ? 0 :
1849
+ params.mode === 'alpha-inverted' ? 1 :
1850
+ params.mode === 'luma' ? 2 : 3;
1851
+ gl.useProgram(this.maskedProgram);
1852
+ this.setupVertexAttribs(this.maskedLocs.aPos, this.maskedLocs.aUv);
1853
+ if (this.maskedLocs.uTransform)
1854
+ gl.uniformMatrix4fv(this.maskedLocs.uTransform, false, transform);
1855
+ if (this.maskedLocs.uUvRect)
1856
+ gl.uniform4f(this.maskedLocs.uUvRect, 0, 0, 1, 1);
1857
+ if (this.maskedLocs.uTint)
1858
+ gl.uniform4f(this.maskedLocs.uTint, tint[0], tint[1], tint[2], tint[3]);
1859
+ if (this.maskedLocs.uMode)
1860
+ gl.uniform1f(this.maskedLocs.uMode, mode);
1861
+ const content = params.content;
1862
+ const mask = params.mask;
1863
+ gl.activeTexture(gl.TEXTURE0);
1864
+ gl.bindTexture(gl.TEXTURE_2D, content.handle);
1865
+ if (this.maskedLocs.uTex)
1866
+ gl.uniform1i(this.maskedLocs.uTex, 0);
1867
+ gl.activeTexture(gl.TEXTURE1);
1868
+ gl.bindTexture(gl.TEXTURE_2D, mask.handle);
1869
+ if (this.maskedLocs.uMask)
1870
+ gl.uniform1i(this.maskedLocs.uMask, 1);
1871
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1872
+ // Restore the conventional active unit so later single-texture
1873
+ // draws bind where they expect.
1874
+ gl.activeTexture(gl.TEXTURE0);
1875
+ }
1876
+ drawFilteredQuad(params) {
1877
+ if (!this.framingActive)
1878
+ return;
1879
+ const gl = this.gl;
1880
+ this.applyBlend(params.blend);
1881
+ const surface = this.currentSurface();
1882
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height, 0, 0, surface.flipY);
1883
+ const tint = params.tint ?? [1, 1, 1, 1];
1884
+ const t = params.texture;
1885
+ // blurRadius is logical px; texture dims are physical, so σ scales
1886
+ // by the pixel ratio and texel offsets divide by physical dims.
1887
+ const sigma = params.blurRadius * this.pixelRatio;
1888
+ gl.useProgram(this.filteredProgram);
1889
+ this.setupVertexAttribs(this.filteredLocs.aPos, this.filteredLocs.aUv);
1890
+ if (this.filteredLocs.uTransform)
1891
+ gl.uniformMatrix4fv(this.filteredLocs.uTransform, false, transform);
1892
+ if (this.filteredLocs.uUvRect)
1893
+ gl.uniform4f(this.filteredLocs.uUvRect, 0, 0, 1, 1);
1894
+ if (this.filteredLocs.uTexel)
1895
+ gl.uniform2f(this.filteredLocs.uTexel, params.blurDir[0] / t.width, params.blurDir[1] / t.height);
1896
+ if (this.filteredLocs.uSigma)
1897
+ gl.uniform1f(this.filteredLocs.uSigma, sigma);
1898
+ if (this.filteredLocs.uColorOps)
1899
+ gl.uniform4f(this.filteredLocs.uColorOps, params.brightness, params.contrast, params.saturation, ((params.hueRotate ?? 0) * Math.PI) / 180);
1900
+ if (this.filteredLocs.uTint)
1901
+ gl.uniform4f(this.filteredLocs.uTint, tint[0], tint[1], tint[2], tint[3]);
1902
+ gl.activeTexture(gl.TEXTURE0);
1903
+ gl.bindTexture(gl.TEXTURE_2D, t.handle);
1904
+ if (this.filteredLocs.uTex)
1905
+ gl.uniform1i(this.filteredLocs.uTex, 0);
1906
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1907
+ }
1908
+ drawBackdropBlend(params) {
1909
+ if (!this.framingActive)
1910
+ return;
1911
+ const gl = this.gl;
1912
+ const surface = this.currentSurface();
1913
+ const transform = composeQuadTransform(params.width / 2, params.height / 2, params.width, params.height, 0, surface.width, surface.height, 0, 0, surface.flipY);
1914
+ const mode = params.mode === 'overlay' ? 0 : params.mode === 'hard-light' ? 1 : 2;
1915
+ gl.useProgram(this.backdropBlendProgram);
1916
+ this.setupVertexAttribs(this.backdropBlendLocs.aPos, this.backdropBlendLocs.aUv);
1917
+ // Output already carries the composited backdrop where src is
1918
+ // transparent, so REPLACE the target rather than blend over it.
1919
+ gl.blendFunc(gl.ONE, gl.ZERO);
1920
+ if (this.backdropBlendLocs.uTransform)
1921
+ gl.uniformMatrix4fv(this.backdropBlendLocs.uTransform, false, transform);
1922
+ if (this.backdropBlendLocs.uUvRect)
1923
+ gl.uniform4f(this.backdropBlendLocs.uUvRect, 0, 0, 1, 1);
1924
+ if (this.backdropBlendLocs.uMode)
1925
+ gl.uniform1i(this.backdropBlendLocs.uMode, mode);
1926
+ if (this.backdropBlendLocs.uBackdropFlipY)
1927
+ gl.uniform1f(this.backdropBlendLocs.uBackdropFlipY, params.backdropFlipY ? 1 : 0);
1928
+ const src = params.src;
1929
+ const bd = params.backdrop;
1930
+ gl.activeTexture(gl.TEXTURE0);
1931
+ gl.bindTexture(gl.TEXTURE_2D, src.handle);
1932
+ if (this.backdropBlendLocs.uSrc)
1933
+ gl.uniform1i(this.backdropBlendLocs.uSrc, 0);
1934
+ gl.activeTexture(gl.TEXTURE1);
1935
+ gl.bindTexture(gl.TEXTURE_2D, bd.handle);
1936
+ if (this.backdropBlendLocs.uBackdrop)
1937
+ gl.uniform1i(this.backdropBlendLocs.uBackdrop, 1);
1938
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1939
+ gl.activeTexture(gl.TEXTURE0);
1940
+ }
1941
+ drawStylizedQuad(params) {
1942
+ if (!this.framingActive)
1943
+ return;
1944
+ const gl = this.gl;
1945
+ this.applyBlend(params.blend);
1946
+ const surface = this.currentSurface();
1947
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height, 0, 0, surface.flipY);
1948
+ const tint = params.tint ?? [1, 1, 1, 1];
1949
+ const t = params.texture;
1950
+ const aux = (params.aux ?? params.texture);
1951
+ // px-dimensioned params scale to PHYSICAL pixels; counts/angles/
1952
+ // intensities don't.
1953
+ const p0Px = params.mode !== 'dither' && params.mode !== 'glow'
1954
+ && params.mode !== 'chroma_key' && params.mode !== 'luma_key'
1955
+ && params.mode !== 'levels' && params.mode !== 'lut';
1956
+ const p1Px = params.mode === 'drop_shadow' || params.mode === 'turbulent_displace';
1957
+ const p0 = p0Px ? params.p0 * this.pixelRatio : params.p0;
1958
+ const p1 = p1Px ? (params.p1 ?? 0) * this.pixelRatio : (params.p1 ?? 0);
1959
+ const modeIdx = STYLIZE_MODE_INDEX[params.mode];
1960
+ gl.useProgram(this.stylizedProgram);
1961
+ this.setupVertexAttribs(this.stylizedLocs.aPos, this.stylizedLocs.aUv);
1962
+ if (this.stylizedLocs.uTransform)
1963
+ gl.uniformMatrix4fv(this.stylizedLocs.uTransform, false, transform);
1964
+ if (this.stylizedLocs.uUvRect)
1965
+ gl.uniform4f(this.stylizedLocs.uUvRect, 0, 0, 1, 1);
1966
+ if (this.stylizedLocs.uTexSize)
1967
+ gl.uniform2f(this.stylizedLocs.uTexSize, t.width, t.height);
1968
+ // u_params.w carries the pixel ratio so device-pixel-indexed effects
1969
+ // (dither's Bayer cell) can stay a stable LOGICAL-pixel size — i.e.
1970
+ // preview (hi-DPI) matches export (1×) instead of going sub-pixel.
1971
+ if (this.stylizedLocs.uParams)
1972
+ gl.uniform4f(this.stylizedLocs.uParams, modeIdx, p0, p1, this.pixelRatio);
1973
+ if (this.stylizedLocs.uTint)
1974
+ gl.uniform4f(this.stylizedLocs.uTint, tint[0], tint[1], tint[2], tint[3]);
1975
+ gl.activeTexture(gl.TEXTURE0);
1976
+ gl.bindTexture(gl.TEXTURE_2D, t.handle);
1977
+ if (this.stylizedLocs.uTex)
1978
+ gl.uniform1i(this.stylizedLocs.uTex, 0);
1979
+ gl.activeTexture(gl.TEXTURE1);
1980
+ gl.bindTexture(gl.TEXTURE_2D, aux.handle);
1981
+ if (this.stylizedLocs.uAux)
1982
+ gl.uniform1i(this.stylizedLocs.uAux, 1);
1983
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
1984
+ gl.activeTexture(gl.TEXTURE0);
1985
+ }
1986
+ drawGlassQuad(params) {
1987
+ if (!this.framingActive)
1988
+ return;
1989
+ const gl = this.gl;
1990
+ this.applyBlend(params.blend);
1991
+ const surface = this.currentSurface();
1992
+ const transform = composeQuadTransform(params.cx, params.cy, params.width, params.height, 0, surface.width, surface.height, 0, 0, surface.flipY);
1993
+ const backdrop = params.backdrop;
1994
+ const sharp = params.backdropSharp;
1995
+ const pr = this.pixelRatio;
1996
+ const rad = (params.rotation * Math.PI) / 180;
1997
+ // CKP/1.0 glass under 3D (§4.7): a pane homography selects the
1998
+ // lazily-compiled projective variant. A singular homography is the
1999
+ // edge-on degenerate case — the pane is invisible, draw nothing.
2000
+ let h = null;
2001
+ let hinv = null;
2002
+ if (params.paneHomography) {
2003
+ h = homographyToPhysical(params.paneHomography, pr);
2004
+ hinv = invertHomography(h);
2005
+ if (!hinv)
2006
+ return;
2007
+ if (!this.glass3dProgram) {
2008
+ this.glass3dProgram = this.buildProgram(TEXTURED_VS, glassFsSource(true), 'glass3d');
2009
+ this.glass3dLocs = this.glassLocsOf(this.glass3dProgram);
2010
+ }
2011
+ }
2012
+ const program = h ? this.glass3dProgram : this.glassProgram;
2013
+ const locs = h ? this.glass3dLocs : this.glassLocs;
2014
+ gl.useProgram(program);
2015
+ this.setupVertexAttribs(locs.aPos, locs.aUv);
2016
+ if (locs.uTransform)
2017
+ gl.uniformMatrix4fv(locs.uTransform, false, transform);
2018
+ if (locs.uUvRect)
2019
+ gl.uniform4f(locs.uUvRect, 0, 0, 1, 1);
2020
+ // Surface dims, NOT the frosted texture's — the blur ladder
2021
+ // downsamples it; normalized UVs sample it fine either way.
2022
+ if (locs.uTexSize)
2023
+ gl.uniform2f(locs.uTexSize, surface.physWidth, surface.physHeight);
2024
+ if (locs.uPaneCenter)
2025
+ gl.uniform2f(locs.uPaneCenter, params.paneCx * pr, params.paneCy * pr);
2026
+ if (locs.uPaneHalf)
2027
+ gl.uniform2f(locs.uPaneHalf, params.paneHalfW * pr, params.paneHalfH * pr);
2028
+ if (locs.uRot)
2029
+ gl.uniform2f(locs.uRot, Math.cos(rad), Math.sin(rad));
2030
+ if (locs.uGeo) {
2031
+ gl.uniform4f(locs.uGeo, params.cornerRadius * pr, params.zRadius * pr, params.bevelMode, params.backdropFlipY ? 1 : 0);
2032
+ }
2033
+ if (locs.uOptics)
2034
+ gl.uniform4f(locs.uOptics, params.refract, params.chroma, params.edgeHighlight, params.fresnel);
2035
+ if (locs.uLook)
2036
+ gl.uniform4f(locs.uLook, params.specular, params.saturation, params.alpha, 0);
2037
+ if (locs.uShadow)
2038
+ gl.uniform4f(locs.uShadow, params.shadowAlpha, params.shadowSpread * pr, params.shadowOffY * pr, 0);
2039
+ if (locs.uTint)
2040
+ gl.uniform4f(locs.uTint, params.tint[0], params.tint[1], params.tint[2], params.tint[3]);
2041
+ if (h && locs.uH)
2042
+ gl.uniformMatrix3fv(locs.uH, false, h);
2043
+ if (hinv && locs.uHinv)
2044
+ gl.uniformMatrix3fv(locs.uHinv, false, hinv);
2045
+ gl.activeTexture(gl.TEXTURE0);
2046
+ gl.bindTexture(gl.TEXTURE_2D, backdrop.handle);
2047
+ if (locs.uBackdrop)
2048
+ gl.uniform1i(locs.uBackdrop, 0);
2049
+ gl.activeTexture(gl.TEXTURE1);
2050
+ gl.bindTexture(gl.TEXTURE_2D, sharp.handle);
2051
+ if (locs.uSharp)
2052
+ gl.uniform1i(locs.uSharp, 1);
2053
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
2054
+ gl.activeTexture(gl.TEXTURE0);
2055
+ }
2056
+ glassLocsOf(program) {
2057
+ const gl = this.gl;
2058
+ return {
2059
+ aPos: gl.getAttribLocation(program, 'a_pos'),
2060
+ aUv: gl.getAttribLocation(program, 'a_uv'),
2061
+ uTransform: gl.getUniformLocation(program, 'u_transform'),
2062
+ uUvRect: gl.getUniformLocation(program, 'u_uvRect'),
2063
+ uBackdrop: gl.getUniformLocation(program, 'u_backdrop'),
2064
+ uSharp: gl.getUniformLocation(program, 'u_sharp'),
2065
+ uTexSize: gl.getUniformLocation(program, 'u_texSize'),
2066
+ uPaneCenter: gl.getUniformLocation(program, 'u_paneCenter'),
2067
+ uPaneHalf: gl.getUniformLocation(program, 'u_paneHalf'),
2068
+ uRot: gl.getUniformLocation(program, 'u_rot'),
2069
+ uGeo: gl.getUniformLocation(program, 'u_geo'),
2070
+ uOptics: gl.getUniformLocation(program, 'u_optics'),
2071
+ uLook: gl.getUniformLocation(program, 'u_look'),
2072
+ uShadow: gl.getUniformLocation(program, 'u_shadow'),
2073
+ uTint: gl.getUniformLocation(program, 'u_tint'),
2074
+ uH: gl.getUniformLocation(program, 'u_h'),
2075
+ uHinv: gl.getUniformLocation(program, 'u_hinv'),
2076
+ };
2077
+ }
2078
+ copySurfaceTo(target) {
2079
+ const gl = this.gl;
2080
+ const fbo = this.renderTargetFbos.get(target);
2081
+ if (!fbo) {
2082
+ getLogger().warn('copySurfaceTo with unknown / destroyed target — ignored');
2083
+ return { flippedY: false };
2084
+ }
2085
+ const surface = this.currentSurface();
2086
+ const tex = target.texture;
2087
+ // Same-rect blit (required when the canvas read buffer is
2088
+ // multisampled — the blit doubles as the MSAA resolve). The canvas
2089
+ // default framebuffer stores rows bottom-up; render-target FBOs are
2090
+ // drawn with a flipped projection so their rows are top-down. The
2091
+ // sampler compensates via the returned flag instead of flipping
2092
+ // here, which a multisample resolve blit would not allow.
2093
+ gl.bindFramebuffer(gl.READ_FRAMEBUFFER, surface.fbo);
2094
+ gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, fbo);
2095
+ gl.blitFramebuffer(0, 0, surface.physWidth, surface.physHeight, 0, 0, tex.width, tex.height, gl.COLOR_BUFFER_BIT, gl.NEAREST);
2096
+ gl.bindFramebuffer(gl.FRAMEBUFFER, surface.fbo);
2097
+ return { flippedY: surface.fbo === null };
2098
+ }
2099
+ // ─── Cleanup ──────────────────────────────────────────────────────────────
2100
+ async finish() {
2101
+ // gl.finish blocks until the pipeline drains.
2102
+ this.gl.finish();
2103
+ }
2104
+ dispose() {
2105
+ if (this.disposed)
2106
+ return;
2107
+ this.disposed = true;
2108
+ const gl = this.gl;
2109
+ if (!gl)
2110
+ return;
2111
+ for (const t of this.liveTextures)
2112
+ gl.deleteTexture(t.handle);
2113
+ this.liveTextures.clear();
2114
+ if (this.vbo)
2115
+ gl.deleteBuffer(this.vbo);
2116
+ if (this.vao)
2117
+ gl.deleteVertexArray(this.vao);
2118
+ if (this.shapeProgram)
2119
+ gl.deleteProgram(this.shapeProgram);
2120
+ if (this.gradientProgram)
2121
+ gl.deleteProgram(this.gradientProgram);
2122
+ if (this.texturedProgram)
2123
+ gl.deleteProgram(this.texturedProgram);
2124
+ }
2125
+ }
2126
+ // ─── Helpers ────────────────────────────────────────────────────────────────
2127
+ function sourceDimensions(source) {
2128
+ if ('codedWidth' in source && 'codedHeight' in source) {
2129
+ return { width: source.codedWidth, height: source.codedHeight };
2130
+ }
2131
+ if ('videoWidth' in source && 'videoHeight' in source) {
2132
+ return { width: source.videoWidth, height: source.videoHeight };
2133
+ }
2134
+ if ('naturalWidth' in source && 'naturalHeight' in source) {
2135
+ return { width: source.naturalWidth, height: source.naturalHeight };
2136
+ }
2137
+ return { width: source.width, height: source.height };
2138
+ }
2139
+ function clamp(n, lo, hi) {
2140
+ return Math.max(lo, Math.min(hi, n));
2141
+ }
2142
+ //# sourceMappingURL=webgl-backend.js.map