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