@framefields/node-mask-math 0.0.0-stage → 2.0.3

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.
@@ -0,0 +1,832 @@
1
+ /// <reference types="webgpu" />
2
+ import type { VirtualMediaData } from "@framefields/core";
3
+ import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
4
+ import { signalRegistry } from "@framefields/webgpu-renderers";
5
+ import {
6
+ MAX_RADIUS,
7
+ type MaskChannel,
8
+ type MaskMathOp,
9
+ type MaskMathSignalData,
10
+ type MaskOperation,
11
+ type MaskOutputFormat,
12
+ } from "../shared/index.js";
13
+
14
+ const OPERATION_MAP: Record<MaskOperation, number> = {
15
+ Union: 0,
16
+ Intersect: 1,
17
+ Subtract: 2,
18
+ Difference: 3,
19
+ Invert: 4,
20
+ Dilate: 5,
21
+ Erode: 6,
22
+ Choke: 7,
23
+ Feather: 8,
24
+ };
25
+
26
+ const CHANNEL_MAP: Record<MaskChannel, number> = {
27
+ Alpha: 0,
28
+ Luminance: 1,
29
+ Red: 2,
30
+ Green: 3,
31
+ Blue: 4,
32
+ };
33
+
34
+ const OUTPUT_FORMAT_MAP: Record<MaskOutputFormat, number> = {
35
+ WhiteWithAlpha: 0,
36
+ GrayscaleRGB: 1,
37
+ AlphaOnly: 2,
38
+ PassthroughRGB: 3,
39
+ };
40
+
41
+ const WGSL_MASKMATH = `
42
+ struct MaskMathUniforms {
43
+ dirX : f32,
44
+ dirY : f32,
45
+ radius : f32,
46
+ threshold : f32,
47
+
48
+ clampMin : f32,
49
+ clampMax : f32,
50
+ operation : f32,
51
+ channelA : f32,
52
+
53
+ channelB : f32,
54
+ binarize : f32,
55
+ invertResult : f32,
56
+ outputFormat : f32,
57
+
58
+ hasMaskB : f32,
59
+ hasRadiusSig : f32,
60
+ hasThreshSig : f32,
61
+ hasClampMinSig : f32,
62
+
63
+ hasClampMaxSig : f32,
64
+ isSecondPass : f32,
65
+ pad0 : f32,
66
+ pad1 : f32,
67
+ };
68
+
69
+ @group(0) @binding(0) var<uniform> u : MaskMathUniforms;
70
+
71
+ @group(1) @binding(0) var texA : texture_2d<f32>;
72
+ @group(1) @binding(1) var texB : texture_2d<f32>;
73
+ @group(1) @binding(2) var origTexA : texture_2d<f32>;
74
+ @group(1) @binding(3) var samp : sampler;
75
+
76
+ @group(2) @binding(0) var radiusSigTex : texture_2d<f32>;
77
+ @group(2) @binding(1) var threshSigTex : texture_2d<f32>;
78
+ @group(2) @binding(2) var clampMinSigTex : texture_2d<f32>;
79
+ @group(2) @binding(3) var clampMaxSigTex : texture_2d<f32>;
80
+ @group(2) @binding(4) var signalSamp : sampler;
81
+
82
+ struct VSOut {
83
+ @builtin(position) pos : vec4<f32>,
84
+ @location(0) uv : vec2<f32>,
85
+ };
86
+
87
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
88
+ var pos = array<vec2<f32>, 4>(
89
+ vec2<f32>(-1.0, 1.0),
90
+ vec2<f32>(1.0, 1.0),
91
+ vec2<f32>(-1.0, -1.0),
92
+ vec2<f32>(1.0, -1.0)
93
+ );
94
+ var uv = array<vec2<f32>, 4>(
95
+ vec2<f32>(0.0, 0.0),
96
+ vec2<f32>(1.0, 0.0),
97
+ vec2<f32>(0.0, 1.0),
98
+ vec2<f32>(1.0, 1.0)
99
+ );
100
+ return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
101
+ }
102
+
103
+ fn extractChannel(color : vec4<f32>, chCode : f32) -> f32 {
104
+ if (chCode < 0.5) {
105
+ // Alpha
106
+ return color.a;
107
+ } else if (chCode < 1.5) {
108
+ // Luminance
109
+ let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
110
+ if (color.a > 1e-4) {
111
+ return min(1.0, luma / color.a);
112
+ }
113
+ return luma;
114
+ } else if (chCode < 2.5) {
115
+ // Red
116
+ if (color.a > 1e-4) {
117
+ return min(1.0, color.r / color.a);
118
+ }
119
+ return color.r;
120
+ } else if (chCode < 3.5) {
121
+ // Green
122
+ if (color.a > 1e-4) {
123
+ return min(1.0, color.g / color.a);
124
+ }
125
+ return color.g;
126
+ } else {
127
+ // Blue
128
+ if (color.a > 1e-4) {
129
+ return min(1.0, color.b / color.a);
130
+ }
131
+ return color.b;
132
+ }
133
+ }
134
+
135
+ @fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
136
+ let dimensions = vec2<f32>(textureDimensions(texA));
137
+ let texelSize = 1.0 / max(dimensions, vec2<f32>(1.0, 1.0));
138
+
139
+ var radius = u.radius;
140
+ if (u.hasRadiusSig > 0.5) {
141
+ radius = clamp(textureSampleLevel(radiusSigTex, signalSamp, vec2<f32>(0.5, 0.5), 0.0).r, 0.0, 200.0);
142
+ }
143
+ var threshold = u.threshold;
144
+ if (u.hasThreshSig > 0.5) {
145
+ threshold = clamp(textureSampleLevel(threshSigTex, signalSamp, vec2<f32>(0.5, 0.5), 0.0).r, 0.0, 1.0);
146
+ }
147
+ var clampMin = u.clampMin;
148
+ if (u.hasClampMinSig > 0.5) {
149
+ clampMin = clamp(textureSampleLevel(clampMinSigTex, signalSamp, vec2<f32>(0.5, 0.5), 0.0).r, 0.0, 1.0);
150
+ }
151
+ var clampMax = u.clampMax;
152
+ if (u.hasClampMaxSig > 0.5) {
153
+ clampMax = clamp(textureSampleLevel(clampMaxSigTex, signalSamp, vec2<f32>(0.5, 0.5), 0.0).r, 0.0, 1.0);
154
+ }
155
+
156
+ let op = u.operation;
157
+ let direction = vec2<f32>(u.dirX, u.dirY);
158
+
159
+ var val = 0.0;
160
+
161
+ if (u.isSecondPass <= 0.5) {
162
+ // -------------------------------------------------------------
163
+ // PASS 1: Horizontal pass / Boolean operation
164
+ // -------------------------------------------------------------
165
+ let sampleA = textureSampleLevel(texA, samp, in.uv, 0.0);
166
+ let sampleB = textureSampleLevel(texB, samp, in.uv, 0.0);
167
+
168
+ let a = extractChannel(sampleA, u.channelA);
169
+ let b = select(0.0, extractChannel(sampleB, u.channelB), u.hasMaskB > 0.5);
170
+
171
+ if (op < 0.5) {
172
+ // Union (A + B)
173
+ val = select(a, clamp(a + b, 0.0, 1.0), u.hasMaskB > 0.5);
174
+ } else if (op < 1.5) {
175
+ // Intersect (min(A, B))
176
+ val = select(a, min(a, b), u.hasMaskB > 0.5);
177
+ } else if (op < 2.5) {
178
+ // Subtract (clamp(A - B, 0, 1))
179
+ val = select(a, clamp(a - b, 0.0, 1.0), u.hasMaskB > 0.5);
180
+ } else if (op < 3.5) {
181
+ // Difference (|A - B|)
182
+ val = select(a, abs(a - b), u.hasMaskB > 0.5);
183
+ } else if (op < 4.5) {
184
+ // Invert (1 - A)
185
+ val = 1.0 - a;
186
+ } else if (op < 5.5) {
187
+ // Dilate (Horizontal Max)
188
+ if (radius <= 0.0) {
189
+ val = a;
190
+ } else {
191
+ let stepScale = max(1.0, radius / 20.0);
192
+ let sampleRadius = min(i32(ceil(radius / stepScale)), 48);
193
+ var maxVal = a;
194
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
195
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
196
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
197
+ maxVal = max(maxVal, extractChannel(s, u.channelA));
198
+ }
199
+ val = maxVal;
200
+ }
201
+ } else if (op < 7.5) {
202
+ // Erode & Choke (Horizontal Min)
203
+ if (radius <= 0.0) {
204
+ val = a;
205
+ } else {
206
+ let stepScale = max(1.0, radius / 20.0);
207
+ let sampleRadius = min(i32(ceil(radius / stepScale)), 48);
208
+ var minVal = a;
209
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
210
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
211
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
212
+ minVal = min(minVal, extractChannel(s, u.channelA));
213
+ }
214
+ val = minVal;
215
+ }
216
+ } else {
217
+ // Feather (Horizontal Gaussian Blur)
218
+ if (radius <= 0.0) {
219
+ val = a;
220
+ } else {
221
+ let spatial_sigma = max(0.1, radius / 2.0);
222
+ let stepScale = max(1.0, spatial_sigma / 20.0);
223
+ let effectiveSigma = spatial_sigma / stepScale;
224
+ let sampleRadius = min(i32(ceil(3.0 * effectiveSigma)), 48);
225
+ var accum = 0.0;
226
+ var totalWeight = 0.0;
227
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
228
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
229
+ let x = f32(i) * stepScale;
230
+ let weight = exp(-0.5 * (x / spatial_sigma) * (x / spatial_sigma));
231
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
232
+ accum += extractChannel(s, u.channelA) * weight;
233
+ totalWeight += weight;
234
+ }
235
+ val = accum / max(totalWeight, 1e-5);
236
+ }
237
+ }
238
+
239
+ return vec4<f32>(val, val, val, val);
240
+ }
241
+
242
+ // -------------------------------------------------------------
243
+ // PASS 2: Vertical pass & Post-processing (clamp, binarize, format)
244
+ // -------------------------------------------------------------
245
+ let samplePass1 = textureSampleLevel(texA, samp, in.uv, 0.0);
246
+ var matte = samplePass1.r;
247
+
248
+ // 1. Spatial kernel processing (Dilate, Erode, Choke, Feather) when radius > 0
249
+ if (radius > 0.0) {
250
+ if (op >= 4.5 && op < 5.5) {
251
+ // Dilate (Vertical Max)
252
+ let stepScale = max(1.0, radius / 20.0);
253
+ let sampleRadius = min(i32(ceil(radius / stepScale)), 48);
254
+ var maxVal = matte;
255
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
256
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
257
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
258
+ maxVal = max(maxVal, s.r);
259
+ }
260
+ matte = maxVal;
261
+ } else if (op >= 5.5 && op < 7.5) {
262
+ // Erode & Choke (Vertical Min)
263
+ let stepScale = max(1.0, radius / 20.0);
264
+ let sampleRadius = min(i32(ceil(radius / stepScale)), 48);
265
+ var minVal = matte;
266
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
267
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
268
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
269
+ minVal = min(minVal, s.r);
270
+ }
271
+ matte = minVal;
272
+ } else if (op >= 7.5) {
273
+ // Feather (Vertical Gaussian Blur)
274
+ let spatial_sigma = max(0.1, radius / 2.0);
275
+ let stepScale = max(1.0, spatial_sigma / 20.0);
276
+ let effectiveSigma = spatial_sigma / stepScale;
277
+ let sampleRadius = min(i32(ceil(3.0 * effectiveSigma)), 48);
278
+ var accum = 0.0;
279
+ var totalWeight = 0.0;
280
+ for (var i = -sampleRadius; i <= sampleRadius; i++) {
281
+ let offset = vec2<f32>(f32(i) * stepScale) * direction * texelSize;
282
+ let x = f32(i) * stepScale;
283
+ let weight = exp(-0.5 * (x / spatial_sigma) * (x / spatial_sigma));
284
+ let s = textureSampleLevel(texA, samp, in.uv + offset, 0.0);
285
+ accum += s.r * weight;
286
+ totalWeight += weight;
287
+ }
288
+ matte = accum / max(totalWeight, 1e-5);
289
+ }
290
+ }
291
+
292
+ // 2. Choke non-linear threshold contraction curve (applies even if radius == 0)
293
+ if (op >= 6.5 && op < 7.5) {
294
+ let threshRange = max(0.01, 1.0 - threshold);
295
+ matte = clamp((matte - threshold) / threshRange, 0.0, 1.0);
296
+ }
297
+
298
+ // Range clamping
299
+ let lowerLimit = min(clampMin, clampMax);
300
+ let upperLimit = max(clampMin, clampMax);
301
+ matte = clamp(matte, lowerLimit, upperLimit);
302
+
303
+ // Optional Binarization
304
+ if (u.binarize > 0.5) {
305
+ matte = select(0.0, 1.0, matte >= threshold);
306
+ }
307
+
308
+ // Optional Inversion
309
+ if (u.invertResult > 0.5) {
310
+ matte = 1.0 - matte;
311
+ }
312
+
313
+ // Output format conversion
314
+ let fmt = u.outputFormat;
315
+ if (fmt < 0.5) {
316
+ // WhiteWithAlpha: vec4(matte, matte, matte, matte) (premultiplied standard mask)
317
+ return vec4<f32>(matte, matte, matte, matte);
318
+ } else if (fmt < 1.5) {
319
+ // GrayscaleRGB: vec4(matte, matte, matte, 1.0)
320
+ return vec4<f32>(matte, matte, matte, 1.0);
321
+ } else if (fmt < 2.5) {
322
+ // AlphaOnly: vec4(0.0, 0.0, 0.0, matte)
323
+ return vec4<f32>(0.0, 0.0, 0.0, matte);
324
+ } else {
325
+ // PassthroughRGB: Mask A color with new alpha
326
+ let origCol = textureSampleLevel(origTexA, samp, in.uv, 0.0);
327
+ if (origCol.a < 1e-4) {
328
+ return vec4<f32>(0.0, 0.0, 0.0, 0.0);
329
+ }
330
+ let unpremult = origCol.rgb / origCol.a;
331
+ return vec4<f32>(unpremult * matte, matte);
332
+ }
333
+ }
334
+ `;
335
+
336
+ interface DeviceMaskMathResources {
337
+ uniformLayout: GPUBindGroupLayout;
338
+ textureLayout: GPUBindGroupLayout;
339
+ signalTextureLayout: GPUBindGroupLayout;
340
+ pipeline: GPURenderPipeline;
341
+ }
342
+
343
+ const deviceResourceCache = new WeakMap<GPUDevice, DeviceMaskMathResources>();
344
+
345
+ function getMaskMathResources(
346
+ device: GPUDevice,
347
+ targetFormat: GPUTextureFormat,
348
+ ): DeviceMaskMathResources {
349
+ const cached = deviceResourceCache.get(device);
350
+ if (cached) return cached;
351
+
352
+ const uniformLayout = device.createBindGroupLayout({
353
+ entries: [
354
+ {
355
+ binding: 0,
356
+ visibility: GPUShaderStage.FRAGMENT,
357
+ buffer: { type: "uniform" },
358
+ },
359
+ ],
360
+ });
361
+
362
+ const textureLayout = device.createBindGroupLayout({
363
+ entries: [
364
+ {
365
+ binding: 0,
366
+ visibility: GPUShaderStage.FRAGMENT,
367
+ texture: { sampleType: "float" },
368
+ },
369
+ {
370
+ binding: 1,
371
+ visibility: GPUShaderStage.FRAGMENT,
372
+ texture: { sampleType: "float" },
373
+ },
374
+ {
375
+ binding: 2,
376
+ visibility: GPUShaderStage.FRAGMENT,
377
+ texture: { sampleType: "float" },
378
+ },
379
+ {
380
+ binding: 3,
381
+ visibility: GPUShaderStage.FRAGMENT,
382
+ sampler: { type: "filtering" },
383
+ },
384
+ ],
385
+ });
386
+
387
+ const signalTextureLayout = device.createBindGroupLayout({
388
+ entries: [
389
+ {
390
+ binding: 0,
391
+ visibility: GPUShaderStage.FRAGMENT,
392
+ texture: { sampleType: "float" },
393
+ },
394
+ {
395
+ binding: 1,
396
+ visibility: GPUShaderStage.FRAGMENT,
397
+ texture: { sampleType: "float" },
398
+ },
399
+ {
400
+ binding: 2,
401
+ visibility: GPUShaderStage.FRAGMENT,
402
+ texture: { sampleType: "float" },
403
+ },
404
+ {
405
+ binding: 3,
406
+ visibility: GPUShaderStage.FRAGMENT,
407
+ texture: { sampleType: "float" },
408
+ },
409
+ {
410
+ binding: 4,
411
+ visibility: GPUShaderStage.FRAGMENT,
412
+ sampler: { type: "filtering" },
413
+ },
414
+ ],
415
+ });
416
+
417
+ const shaderModule = device.createShaderModule({
418
+ code: WGSL_MASKMATH,
419
+ });
420
+
421
+ const pipelineLayout = device.createPipelineLayout({
422
+ bindGroupLayouts: [uniformLayout, textureLayout, signalTextureLayout],
423
+ });
424
+
425
+ const pipeline = device.createRenderPipeline({
426
+ layout: pipelineLayout,
427
+ vertex: {
428
+ module: shaderModule,
429
+ entryPoint: "vs",
430
+ },
431
+ fragment: {
432
+ module: shaderModule,
433
+ entryPoint: "fs",
434
+ targets: [{ format: targetFormat }],
435
+ },
436
+ primitive: {
437
+ topology: "triangle-strip",
438
+ },
439
+ });
440
+
441
+ const res: DeviceMaskMathResources = {
442
+ uniformLayout,
443
+ textureLayout,
444
+ signalTextureLayout,
445
+ pipeline,
446
+ };
447
+ deviceResourceCache.set(device, res);
448
+ return res;
449
+ }
450
+
451
+ export const WebGPURenderer: WebGPUNodeRenderer = async (args) => {
452
+ const {
453
+ ctx,
454
+ encoder,
455
+ pass,
456
+ targetView,
457
+ targetTexture,
458
+ targetWidth,
459
+ targetHeight,
460
+ props,
461
+ drawChild,
462
+ } = args;
463
+
464
+ const op = props.virtualMedia?.operation as MaskMathOp | undefined;
465
+ if (op?.op !== "MaskMath" || !op) return;
466
+
467
+ const childMedia = props.virtualMedia?.children?.[0];
468
+ if (!childMedia) return;
469
+
470
+ // End incoming pass
471
+ pass.end();
472
+
473
+ const frame = props.frame ?? 0;
474
+ const fps = props.fps || 30;
475
+ const width = targetWidth;
476
+ const height = targetHeight;
477
+
478
+ const resolveField = (
479
+ handleId: string | null | undefined,
480
+ fallbackVal: number,
481
+ clampMin = 0,
482
+ clampMax = 1000,
483
+ ): {
484
+ val: number;
485
+ hasStaticSig: boolean;
486
+ sd: MaskMathSignalData | null;
487
+ handleId?: string | null;
488
+ } => {
489
+ if (handleId && op.inputs?.[handleId]?.connectionValid) {
490
+ const outputItem = op.inputs[handleId].outputItem;
491
+ if (outputItem?.type === "Number") {
492
+ const numVal =
493
+ typeof outputItem.data === "number"
494
+ ? outputItem.data
495
+ : ((outputItem.data as { value?: number })?.value ?? fallbackVal);
496
+ return {
497
+ val: Math.max(clampMin, Math.min(clampMax, numVal)),
498
+ hasStaticSig: false,
499
+ sd: null,
500
+ handleId,
501
+ };
502
+ }
503
+ if (outputItem?.type === "Signal" && outputItem.data) {
504
+ return {
505
+ val: fallbackVal,
506
+ hasStaticSig: true,
507
+ sd: outputItem.data as MaskMathSignalData,
508
+ handleId,
509
+ };
510
+ }
511
+ }
512
+ return {
513
+ val: Math.max(clampMin, Math.min(clampMax, fallbackVal)),
514
+ hasStaticSig: false,
515
+ sd: null,
516
+ handleId,
517
+ };
518
+ };
519
+
520
+ const radiusRes = resolveField(
521
+ op.radiusHandleId,
522
+ Number(op.radius ?? 0),
523
+ 0,
524
+ MAX_RADIUS,
525
+ );
526
+ const thresholdRes = resolveField(
527
+ op.thresholdHandleId,
528
+ Number(op.threshold ?? 0.5),
529
+ 0.0,
530
+ 1.0,
531
+ );
532
+ const clampMinRes = resolveField(
533
+ op.clampMinHandleId,
534
+ Number(op.clampMin ?? 0.0),
535
+ 0.0,
536
+ 1.0,
537
+ );
538
+ const clampMaxRes = resolveField(
539
+ op.clampMaxHandleId,
540
+ Number(op.clampMax ?? 1.0),
541
+ 0.0,
542
+ 1.0,
543
+ );
544
+
545
+ // 1. Render primary Mask A child media
546
+ const tmpATex = ctx.renderer.getTemporaryTexture(width, height, [
547
+ ...(props.excludeTextures || []),
548
+ targetTexture,
549
+ ]);
550
+ const tmpAView = tmpATex.createView();
551
+
552
+ const childAPass = ctx.renderer.beginFrame(
553
+ encoder,
554
+ tmpAView,
555
+ { r: 0, g: 0, b: 0, a: 0 },
556
+ width,
557
+ height,
558
+ "clear",
559
+ );
560
+ childAPass.end();
561
+
562
+ ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
563
+ ctx.renderer.pushIdentity();
564
+ await drawChild(
565
+ childMedia,
566
+ {
567
+ ...props,
568
+ virtualMedia: childMedia,
569
+ renderId: `${props.renderId}-maskA`,
570
+ excludeTextures: [...(props.excludeTextures || []), tmpATex],
571
+ },
572
+ tmpAView,
573
+ tmpATex,
574
+ width,
575
+ height,
576
+ );
577
+ ctx.renderer.popTransform();
578
+ ctx.renderer.popScissor();
579
+
580
+ // 2. Render optional Mask B child media if connected
581
+ let tmpBTex: GPUTexture | null = null;
582
+ if (op.maskBMedia) {
583
+ tmpBTex = ctx.renderer.getTemporaryTexture(width, height, [
584
+ ...(props.excludeTextures || []),
585
+ targetTexture,
586
+ tmpATex,
587
+ ]);
588
+ const tmpBView = tmpBTex.createView();
589
+
590
+ const childBPass = ctx.renderer.beginFrame(
591
+ encoder,
592
+ tmpBView,
593
+ { r: 0, g: 0, b: 0, a: 0 },
594
+ width,
595
+ height,
596
+ "clear",
597
+ );
598
+ childBPass.end();
599
+
600
+ ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
601
+ ctx.renderer.pushIdentity();
602
+ await drawChild(
603
+ op.maskBMedia as VirtualMediaData,
604
+ {
605
+ ...props,
606
+ virtualMedia: op.maskBMedia as VirtualMediaData,
607
+ renderId: `${props.renderId}-maskB`,
608
+ excludeTextures: [...(props.excludeTextures || []), tmpATex, tmpBTex],
609
+ },
610
+ tmpBView,
611
+ tmpBTex,
612
+ width,
613
+ height,
614
+ );
615
+ ctx.renderer.popTransform();
616
+ ctx.renderer.popScissor();
617
+ }
618
+
619
+ // 3. Compile pipeline resources
620
+ const { pipeline, uniformLayout, textureLayout, signalTextureLayout } =
621
+ getMaskMathResources(ctx.device, ctx.renderer.format);
622
+ const sampler = ctx.renderer.samplerCache.getSampler(ctx.device);
623
+
624
+ const elapsedSeconds =
625
+ props.elapsedMs !== undefined ? props.elapsedMs / 1000 : frame / fps;
626
+ const durationSeconds = props.virtualMedia?.metadata?.durationMs
627
+ ? props.virtualMedia.metadata.durationMs / 1000
628
+ : props.durationMs !== undefined
629
+ ? props.durationMs / 1000
630
+ : 0;
631
+
632
+ const getSignalView = (
633
+ res: {
634
+ val: number;
635
+ hasStaticSig: boolean;
636
+ sd: MaskMathSignalData | null;
637
+ handleId?: string | null;
638
+ },
639
+ suffix: string,
640
+ ) => {
641
+ if (res.hasStaticSig && res.sd) {
642
+ const cacheNodeId =
643
+ res.sd.nodeId ??
644
+ res.handleId ??
645
+ `${props.renderId ?? "maskmath"}_${suffix}`;
646
+ return signalRegistry.getOrCreate2DTextureView(
647
+ ctx.device,
648
+ encoder,
649
+ cacheNodeId,
650
+ elapsedSeconds,
651
+ durationSeconds,
652
+ res.sd,
653
+ width,
654
+ height,
655
+ props.renderId,
656
+ frame,
657
+ fps,
658
+ );
659
+ }
660
+ return signalRegistry.getDummy1x1TextureView(ctx.device);
661
+ };
662
+
663
+ const radiusView = getSignalView(radiusRes, "radius_sig");
664
+ const threshView = getSignalView(thresholdRes, "threshold_sig");
665
+ const clampMinView = getSignalView(clampMinRes, "clampMin_sig");
666
+ const clampMaxView = getSignalView(clampMaxRes, "clampMax_sig");
667
+
668
+ const sigBindGroup = ctx.device.createBindGroup({
669
+ layout: signalTextureLayout,
670
+ entries: [
671
+ { binding: 0, resource: radiusView },
672
+ { binding: 1, resource: threshView },
673
+ { binding: 2, resource: clampMinView },
674
+ { binding: 3, resource: clampMaxView },
675
+ { binding: 4, resource: sampler },
676
+ ],
677
+ });
678
+
679
+ const operationCode = OPERATION_MAP[op.operation ?? "Union"] ?? 0;
680
+ const channelACode = CHANNEL_MAP[op.channelA ?? "Alpha"] ?? 0;
681
+ const channelBCode = CHANNEL_MAP[op.channelB ?? "Alpha"] ?? 0;
682
+ const outputFormatCode =
683
+ OUTPUT_FORMAT_MAP[op.outputFormat ?? "WhiteWithAlpha"] ?? 0;
684
+
685
+ const dummyBView = tmpBTex ? tmpBTex.createView() : tmpAView;
686
+
687
+ // Populate separate uniform data buffers for each pass
688
+ const hUniformData = new Float32Array(20);
689
+ const vUniformData = new Float32Array(20);
690
+
691
+ const baseUniforms = [
692
+ 0.0, // [0] dirX (set per pass)
693
+ 0.0, // [1] dirY (set per pass)
694
+ radiusRes.val, // [2] radius
695
+ thresholdRes.val, // [3] threshold
696
+ clampMinRes.val, // [4] clampMin
697
+ clampMaxRes.val, // [5] clampMax
698
+ operationCode, // [6] operation
699
+ channelACode, // [7] channelA
700
+ channelBCode, // [8] channelB
701
+ op.binarize ? 1.0 : 0.0, // [9] binarize
702
+ op.invertResult ? 1.0 : 0.0, // [10] invertResult
703
+ outputFormatCode, // [11] outputFormat
704
+ tmpBTex ? 1.0 : 0.0, // [12] hasMaskB
705
+ radiusRes.hasStaticSig ? 1.0 : 0.0, // [13] hasRadiusSig
706
+ thresholdRes.hasStaticSig ? 1.0 : 0.0, // [14] hasThreshSig
707
+ clampMinRes.hasStaticSig ? 1.0 : 0.0, // [15] hasClampMinSig
708
+ clampMaxRes.hasStaticSig ? 1.0 : 0.0, // [16] hasClampMaxSig
709
+ 0.0, // [17] isSecondPass (set per pass)
710
+ 0.0, // [18] pad0
711
+ 0.0, // [19] pad1
712
+ ];
713
+
714
+ hUniformData.set(baseUniforms);
715
+ hUniformData[0] = 1.0; // dirX
716
+ hUniformData[1] = 0.0; // dirY
717
+ hUniformData[17] = 0.0; // isSecondPass = false
718
+
719
+ vUniformData.set(baseUniforms);
720
+ vUniformData[0] = 0.0; // dirX
721
+ vUniformData[1] = 1.0; // dirY
722
+ vUniformData[17] = 1.0; // isSecondPass = true
723
+
724
+ const outTex1 = ctx.renderer.getTemporaryTexture(width, height, [
725
+ tmpATex,
726
+ ...(tmpBTex ? [tmpBTex] : []),
727
+ targetTexture,
728
+ ...(props.excludeTextures || []),
729
+ ]);
730
+ const outTex2 = ctx.renderer.getTemporaryTexture(width, height, [
731
+ tmpATex,
732
+ ...(tmpBTex ? [tmpBTex] : []),
733
+ outTex1,
734
+ targetTexture,
735
+ ...(props.excludeTextures || []),
736
+ ]);
737
+
738
+ // Pass 1: Horizontal Pass (or direct combination)
739
+ const hBuffer = ctx.renderer.getTemporaryBuffer(hUniformData);
740
+
741
+ const hPass = encoder.beginRenderPass({
742
+ colorAttachments: [
743
+ {
744
+ view: outTex1.createView(),
745
+ loadOp: "clear",
746
+ storeOp: "store",
747
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
748
+ },
749
+ ],
750
+ });
751
+ hPass.setPipeline(pipeline);
752
+ hPass.setBindGroup(
753
+ 0,
754
+ ctx.device.createBindGroup({
755
+ layout: uniformLayout,
756
+ entries: [{ binding: 0, resource: { buffer: hBuffer } }],
757
+ }),
758
+ );
759
+ hPass.setBindGroup(
760
+ 1,
761
+ ctx.device.createBindGroup({
762
+ layout: textureLayout,
763
+ entries: [
764
+ { binding: 0, resource: tmpAView },
765
+ { binding: 1, resource: dummyBView },
766
+ { binding: 2, resource: tmpAView },
767
+ { binding: 3, resource: sampler },
768
+ ],
769
+ }),
770
+ );
771
+ hPass.setBindGroup(2, sigBindGroup);
772
+ hPass.draw(4);
773
+ hPass.end();
774
+
775
+ // Pass 2: Vertical Pass + post-processing
776
+ const vBuffer = ctx.renderer.getTemporaryBuffer(vUniformData);
777
+
778
+ const vPass = encoder.beginRenderPass({
779
+ colorAttachments: [
780
+ {
781
+ view: outTex2.createView(),
782
+ loadOp: "clear",
783
+ storeOp: "store",
784
+ clearValue: { r: 0, g: 0, b: 0, a: 0 },
785
+ },
786
+ ],
787
+ });
788
+ vPass.setPipeline(pipeline);
789
+ vPass.setBindGroup(
790
+ 0,
791
+ ctx.device.createBindGroup({
792
+ layout: uniformLayout,
793
+ entries: [{ binding: 0, resource: { buffer: vBuffer } }],
794
+ }),
795
+ );
796
+ vPass.setBindGroup(
797
+ 1,
798
+ ctx.device.createBindGroup({
799
+ layout: textureLayout,
800
+ entries: [
801
+ { binding: 0, resource: outTex1.createView() },
802
+ { binding: 1, resource: dummyBView },
803
+ { binding: 2, resource: tmpAView },
804
+ { binding: 3, resource: sampler },
805
+ ],
806
+ }),
807
+ );
808
+ vPass.setBindGroup(2, sigBindGroup);
809
+ vPass.draw(4);
810
+ vPass.end();
811
+
812
+ // 4. Final Pass: draw output into targetView
813
+ const finalPass = ctx.renderer.beginFrame(
814
+ encoder,
815
+ targetView,
816
+ { r: 0, g: 0, b: 0, a: 0 },
817
+ targetWidth,
818
+ targetHeight,
819
+ "load",
820
+ );
821
+
822
+ const finalOpacity = props.opacity ?? op.opacity ?? 1.0;
823
+
824
+ ctx.renderer.drawTexture(
825
+ finalPass,
826
+ outTex2,
827
+ { x: 0, y: 0, width: targetWidth, height: targetHeight },
828
+ { opacity: finalOpacity },
829
+ );
830
+
831
+ args.pass = finalPass;
832
+ };