@framefields/node-displacement-map 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.
- package/.turbo/turbo-build.log +21 -0
- package/CHANGELOG.md +25 -0
- package/LICENCE +202 -0
- package/dist/index.d.mts +70 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +54 -0
- package/dist/index.mjs.map +1 -0
- package/dist/renderer.d.mts +7 -0
- package/dist/renderer.d.mts.map +1 -0
- package/dist/renderer.mjs +3 -0
- package/dist/renderers-Dm9Btn8h.mjs +390 -0
- package/dist/renderers-Dm9Btn8h.mjs.map +1 -0
- package/package.json +43 -3
- package/src/index.ts +2 -0
- package/src/renderers/index.ts +6 -0
- package/src/renderers/webgpu-renderer.ts +631 -0
- package/src/shared/config.ts +67 -0
- package/src/shared/index.ts +1 -0
- package/tsconfig.json +12 -0
- package/tsdown.config.ts +13 -0
- package/README.md +0 -3
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/// <reference types="webgpu" />
|
|
2
|
+
import type { SignalData, VirtualMediaData } from "@framefields/core";
|
|
3
|
+
import type { WebGPUNodeRenderer } from "@framefields/node-sdk";
|
|
4
|
+
import { signalRegistry } from "@framefields/webgpu-renderers";
|
|
5
|
+
|
|
6
|
+
// FIX (P1): `mapMedia` is now a first-class, typed field instead of being
|
|
7
|
+
// reached via `(operation as any).mapMedia`. Replace `unknown` with the
|
|
8
|
+
// SDK's actual virtual-media child type if one is exported.
|
|
9
|
+
interface DisplacementMapOp {
|
|
10
|
+
op: "DisplacementMap";
|
|
11
|
+
strengthX?: number;
|
|
12
|
+
strengthY?: number;
|
|
13
|
+
xChannel?: "Red" | "Green" | "Blue" | "Alpha" | "Luminance";
|
|
14
|
+
yChannel?: "Red" | "Green" | "Blue" | "Alpha" | "Luminance";
|
|
15
|
+
wrapMode?: "Clamp" | "Repeat" | "Mirror";
|
|
16
|
+
opacity?: number;
|
|
17
|
+
mapMedia?: VirtualMediaData | null;
|
|
18
|
+
|
|
19
|
+
strengthXHandleId?: string | null;
|
|
20
|
+
strengthYHandleId?: string | null;
|
|
21
|
+
|
|
22
|
+
inputs?: Record<
|
|
23
|
+
string,
|
|
24
|
+
{
|
|
25
|
+
connectionValid: boolean;
|
|
26
|
+
outputItem: {
|
|
27
|
+
type: string;
|
|
28
|
+
data: unknown;
|
|
29
|
+
} | null;
|
|
30
|
+
}
|
|
31
|
+
>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type DisplacementSignalData = SignalData & { nodeId?: string };
|
|
35
|
+
|
|
36
|
+
// Schema bounds mirrored from displacementMapConfig.ts (`z.number().min(0).max(500)`).
|
|
37
|
+
// FIX (P2): kept local rather than imported to avoid pulling zod/config-builder
|
|
38
|
+
// into the render path; ideally these are exported as shared constants from
|
|
39
|
+
// displacementMapConfig.ts so the two can't drift.
|
|
40
|
+
const STRENGTH_MIN = 0;
|
|
41
|
+
const STRENGTH_MAX = 500;
|
|
42
|
+
|
|
43
|
+
function clamp(value: number, min: number, max: number): number {
|
|
44
|
+
return Math.min(max, Math.max(min, value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DISPLACEMENT_MAP_SHADER = `
|
|
48
|
+
struct DisplacementUniforms {
|
|
49
|
+
strengthX : f32,
|
|
50
|
+
strengthY : f32,
|
|
51
|
+
xChannel : f32, // 0=R, 1=G, 2=B, 3=A, 4=Luma
|
|
52
|
+
yChannel : f32, // 0=R, 1=G, 2=B, 3=A, 4=Luma
|
|
53
|
+
|
|
54
|
+
wrapMode : f32, // 0=Clamp, 1=Repeat, 2=Mirror
|
|
55
|
+
hasSignalX : f32,
|
|
56
|
+
hasSignalY : f32,
|
|
57
|
+
_pad : f32,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
@group(0) @binding(0) var<uniform> u : DisplacementUniforms;
|
|
61
|
+
@group(1) @binding(0) var srcTex : texture_2d<f32>;
|
|
62
|
+
@group(1) @binding(1) var srcSamp : sampler;
|
|
63
|
+
@group(2) @binding(0) var mapTex : texture_2d<f32>;
|
|
64
|
+
@group(2) @binding(1) var mapSamp : sampler;
|
|
65
|
+
@group(3) @binding(0) var sigXTex : texture_2d<f32>;
|
|
66
|
+
@group(3) @binding(1) var sigYTex : texture_2d<f32>;
|
|
67
|
+
@group(3) @binding(2) var sigSamp : sampler;
|
|
68
|
+
|
|
69
|
+
struct VSOut {
|
|
70
|
+
@builtin(position) pos : vec4<f32>,
|
|
71
|
+
@location(0) uv : vec2<f32>,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
@vertex fn vs(@builtin(vertex_index) vi: u32) -> VSOut {
|
|
75
|
+
var pos = array<vec2<f32>, 4>(
|
|
76
|
+
vec2<f32>(-1.0, 1.0),
|
|
77
|
+
vec2<f32>(1.0, 1.0),
|
|
78
|
+
vec2<f32>(-1.0, -1.0),
|
|
79
|
+
vec2<f32>(1.0, -1.0)
|
|
80
|
+
);
|
|
81
|
+
var uv = array<vec2<f32>, 4>(
|
|
82
|
+
vec2<f32>(0.0, 0.0),
|
|
83
|
+
vec2<f32>(1.0, 0.0),
|
|
84
|
+
vec2<f32>(0.0, 1.0),
|
|
85
|
+
vec2<f32>(1.0, 1.0)
|
|
86
|
+
);
|
|
87
|
+
return VSOut(vec4<f32>(pos[vi], 0.0, 1.0), uv[vi]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
fn extractChannel(color: vec4<f32>, channel: f32) -> f32 {
|
|
91
|
+
if (channel < 0.5) { return color.r; }
|
|
92
|
+
if (channel < 1.5) { return color.g; }
|
|
93
|
+
if (channel < 2.5) { return color.b; }
|
|
94
|
+
if (channel < 3.5) { return color.a; }
|
|
95
|
+
// Luminance
|
|
96
|
+
return dot(color.rgb, vec3<f32>(0.299, 0.587, 0.114));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
fn applyWrap(coord: f32, mode: f32) -> f32 {
|
|
100
|
+
if (mode < 0.5) {
|
|
101
|
+
// Clamp
|
|
102
|
+
return clamp(coord, 0.0, 1.0);
|
|
103
|
+
}
|
|
104
|
+
if (mode < 1.5) {
|
|
105
|
+
// Repeat
|
|
106
|
+
return fract(coord);
|
|
107
|
+
}
|
|
108
|
+
// Mirror
|
|
109
|
+
let t = fract(coord * 0.5) * 2.0;
|
|
110
|
+
if (t > 1.0) {
|
|
111
|
+
return 2.0 - t;
|
|
112
|
+
}
|
|
113
|
+
return t;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@fragment fn fs(in : VSOut) -> @location(0) vec4<f32> {
|
|
117
|
+
let mapColor = textureSampleLevel(mapTex, mapSamp, in.uv, 0.0);
|
|
118
|
+
|
|
119
|
+
// Extract displacement values from chosen channels, centered around 0.5 and gated by alpha
|
|
120
|
+
// NOTE (P3, not changed here): this yields a max offset of strength/2 px at
|
|
121
|
+
// either extreme (peak-to-peak swing == strength). Confirm with design whether
|
|
122
|
+
// "strength in pixels" should mean peak amplitude instead — see review doc.
|
|
123
|
+
let dispX = (extractChannel(mapColor, u.xChannel) - 0.5) * mapColor.a;
|
|
124
|
+
let dispY = (extractChannel(mapColor, u.yChannel) - 0.5) * mapColor.a;
|
|
125
|
+
|
|
126
|
+
// Get strength, potentially from signal textures
|
|
127
|
+
var sX = u.strengthX;
|
|
128
|
+
var sY = u.strengthY;
|
|
129
|
+
if (u.hasSignalX > 0.5) {
|
|
130
|
+
sX = textureSampleLevel(sigXTex, sigSamp, in.uv, 0.0).r;
|
|
131
|
+
}
|
|
132
|
+
if (u.hasSignalY > 0.5) {
|
|
133
|
+
sY = textureSampleLevel(sigYTex, sigSamp, in.uv, 0.0).r;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Compute displacement in UV space (strength is in pixels, convert to UV)
|
|
137
|
+
let srcDims = vec2<f32>(textureDimensions(srcTex));
|
|
138
|
+
let offset = vec2<f32>(
|
|
139
|
+
dispX * sX / srcDims.x,
|
|
140
|
+
dispY * sY / srcDims.y,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
let displaced = vec2<f32>(
|
|
144
|
+
applyWrap(in.uv.x + offset.x, u.wrapMode),
|
|
145
|
+
applyWrap(in.uv.y + offset.y, u.wrapMode),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return textureSampleLevel(srcTex, srcSamp, displaced, 0.0);
|
|
149
|
+
}
|
|
150
|
+
`;
|
|
151
|
+
|
|
152
|
+
interface DeviceDisplacementResources {
|
|
153
|
+
uniformLayout: GPUBindGroupLayout;
|
|
154
|
+
srcTextureLayout: GPUBindGroupLayout;
|
|
155
|
+
mapTextureLayout: GPUBindGroupLayout;
|
|
156
|
+
signalTextureLayout: GPUBindGroupLayout;
|
|
157
|
+
pipelineCache: Map<string, GPURenderPipeline>;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const deviceResourceCache = new WeakMap<
|
|
161
|
+
GPUDevice,
|
|
162
|
+
DeviceDisplacementResources
|
|
163
|
+
>();
|
|
164
|
+
// NOTE (P3, not changed): module-scoped and reused across every invocation
|
|
165
|
+
// (including recursive/nested displacement nodes). Currently safe because
|
|
166
|
+
// there is no `await` between filling this array and the synchronous
|
|
167
|
+
// getTemporaryBuffer() call that consumes it — if a future refactor adds an
|
|
168
|
+
// await in between, this becomes a cross-call data race. Consider making
|
|
169
|
+
// this function-scoped if that invariant ever changes.
|
|
170
|
+
const uniformData = new Float32Array(8);
|
|
171
|
+
|
|
172
|
+
function getDeviceLayouts(device: GPUDevice): DeviceDisplacementResources {
|
|
173
|
+
let res = deviceResourceCache.get(device);
|
|
174
|
+
if (res) return res;
|
|
175
|
+
|
|
176
|
+
const uniformLayout = device.createBindGroupLayout({
|
|
177
|
+
entries: [
|
|
178
|
+
{
|
|
179
|
+
binding: 0,
|
|
180
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
181
|
+
buffer: { type: "uniform" },
|
|
182
|
+
},
|
|
183
|
+
],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const srcTextureLayout = device.createBindGroupLayout({
|
|
187
|
+
entries: [
|
|
188
|
+
{
|
|
189
|
+
binding: 0,
|
|
190
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
191
|
+
texture: { sampleType: "float" },
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
binding: 1,
|
|
195
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
196
|
+
sampler: { type: "filtering" },
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const mapTextureLayout = device.createBindGroupLayout({
|
|
202
|
+
entries: [
|
|
203
|
+
{
|
|
204
|
+
binding: 0,
|
|
205
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
206
|
+
texture: { sampleType: "float" },
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
binding: 1,
|
|
210
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
211
|
+
sampler: { type: "filtering" },
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const signalTextureLayout = device.createBindGroupLayout({
|
|
217
|
+
entries: [
|
|
218
|
+
{
|
|
219
|
+
binding: 0,
|
|
220
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
221
|
+
texture: { sampleType: "float" },
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
binding: 1,
|
|
225
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
226
|
+
texture: { sampleType: "float" },
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
binding: 2,
|
|
230
|
+
visibility: GPUShaderStage.FRAGMENT,
|
|
231
|
+
sampler: { type: "filtering" },
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
res = {
|
|
237
|
+
uniformLayout,
|
|
238
|
+
srcTextureLayout,
|
|
239
|
+
mapTextureLayout,
|
|
240
|
+
signalTextureLayout,
|
|
241
|
+
pipelineCache: new Map(),
|
|
242
|
+
};
|
|
243
|
+
deviceResourceCache.set(device, res);
|
|
244
|
+
return res;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getDisplacementResources(device: GPUDevice, format: GPUTextureFormat) {
|
|
248
|
+
const layouts = getDeviceLayouts(device);
|
|
249
|
+
|
|
250
|
+
const cacheKey = `displacement_${format}`;
|
|
251
|
+
let pipeline = layouts.pipelineCache.get(cacheKey);
|
|
252
|
+
if (!pipeline) {
|
|
253
|
+
const module = device.createShaderModule({
|
|
254
|
+
label: `displacement_${format}.wgsl`,
|
|
255
|
+
code: DISPLACEMENT_MAP_SHADER,
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
pipeline = device.createRenderPipeline({
|
|
259
|
+
label: `DisplacementMapPipeline_${format}`,
|
|
260
|
+
layout: device.createPipelineLayout({
|
|
261
|
+
bindGroupLayouts: [
|
|
262
|
+
layouts.uniformLayout,
|
|
263
|
+
layouts.srcTextureLayout,
|
|
264
|
+
layouts.mapTextureLayout,
|
|
265
|
+
layouts.signalTextureLayout,
|
|
266
|
+
],
|
|
267
|
+
}),
|
|
268
|
+
vertex: { module, entryPoint: "vs" },
|
|
269
|
+
fragment: {
|
|
270
|
+
module,
|
|
271
|
+
entryPoint: "fs",
|
|
272
|
+
targets: [{ format }],
|
|
273
|
+
},
|
|
274
|
+
primitive: { topology: "triangle-strip" },
|
|
275
|
+
});
|
|
276
|
+
layouts.pipelineCache.set(cacheKey, pipeline);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
pipeline,
|
|
281
|
+
uniformLayout: layouts.uniformLayout,
|
|
282
|
+
srcTextureLayout: layouts.srcTextureLayout,
|
|
283
|
+
mapTextureLayout: layouts.mapTextureLayout,
|
|
284
|
+
signalTextureLayout: layouts.signalTextureLayout,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function channelToFloat(
|
|
289
|
+
ch: "Red" | "Green" | "Blue" | "Alpha" | "Luminance" | undefined,
|
|
290
|
+
): number {
|
|
291
|
+
switch (ch) {
|
|
292
|
+
case "Green":
|
|
293
|
+
return 1;
|
|
294
|
+
case "Blue":
|
|
295
|
+
return 2;
|
|
296
|
+
case "Alpha":
|
|
297
|
+
return 3;
|
|
298
|
+
case "Luminance":
|
|
299
|
+
return 4;
|
|
300
|
+
default:
|
|
301
|
+
return 0; // Red
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function wrapModeToFloat(
|
|
306
|
+
mode: "Clamp" | "Repeat" | "Mirror" | undefined,
|
|
307
|
+
): number {
|
|
308
|
+
switch (mode) {
|
|
309
|
+
case "Repeat":
|
|
310
|
+
return 1;
|
|
311
|
+
case "Mirror":
|
|
312
|
+
return 2;
|
|
313
|
+
default:
|
|
314
|
+
return 0; // Clamp
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function resolveSignalField(
|
|
319
|
+
op: DisplacementMapOp,
|
|
320
|
+
fieldName: "strengthX" | "strengthY",
|
|
321
|
+
defaultValue: number,
|
|
322
|
+
): { hasSignal: boolean; sd: DisplacementSignalData | null; value: number } {
|
|
323
|
+
const handleIdKey = `${fieldName}HandleId` as keyof DisplacementMapOp;
|
|
324
|
+
const handleId = op[handleIdKey] as string | null | undefined;
|
|
325
|
+
const signalInput = handleId ? op.inputs?.[handleId] : null;
|
|
326
|
+
|
|
327
|
+
// FIX (P2): the config's dataTypes are exactly ["Number", "Signal"] — there
|
|
328
|
+
// is no "Numeric" type. Checking for it here made this branch either dead
|
|
329
|
+
// code or a landmine for any output item that happened to use that string.
|
|
330
|
+
// Only "Signal" should route through the per-pixel signal-texture path.
|
|
331
|
+
const hasSignal = !!(
|
|
332
|
+
signalInput?.connectionValid && signalInput.outputItem?.type === "Signal"
|
|
333
|
+
);
|
|
334
|
+
const sd = hasSignal
|
|
335
|
+
? (signalInput?.outputItem?.data as DisplacementSignalData | null)
|
|
336
|
+
: null;
|
|
337
|
+
|
|
338
|
+
let value = defaultValue;
|
|
339
|
+
if (!hasSignal) {
|
|
340
|
+
if (
|
|
341
|
+
signalInput?.connectionValid &&
|
|
342
|
+
signalInput.outputItem?.type === "Number"
|
|
343
|
+
) {
|
|
344
|
+
// FIX (P2): clamp static Number-connection values to the same bounds
|
|
345
|
+
// the field enforces when unbound, so binding a value behaves
|
|
346
|
+
// consistently with typing one in directly.
|
|
347
|
+
value = clamp(
|
|
348
|
+
Number(signalInput.outputItem.data ?? defaultValue),
|
|
349
|
+
STRENGTH_MIN,
|
|
350
|
+
STRENGTH_MAX,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
value = Number(op[fieldName] ?? defaultValue);
|
|
354
|
+
}
|
|
355
|
+
} else if (sd && typeof sd === "object" && "offset" in sd) {
|
|
356
|
+
value = Number(sd.offset ?? defaultValue);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { hasSignal, sd, value };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
export const DisplacementMapWebGPURenderer: WebGPUNodeRenderer = async (
|
|
363
|
+
args,
|
|
364
|
+
) => {
|
|
365
|
+
const {
|
|
366
|
+
ctx,
|
|
367
|
+
encoder,
|
|
368
|
+
pass,
|
|
369
|
+
targetView,
|
|
370
|
+
targetTexture,
|
|
371
|
+
targetWidth,
|
|
372
|
+
targetHeight,
|
|
373
|
+
props,
|
|
374
|
+
drawChild,
|
|
375
|
+
} = args;
|
|
376
|
+
|
|
377
|
+
const op = props.virtualMedia?.operation as DisplacementMapOp | undefined;
|
|
378
|
+
if (op?.op !== "DisplacementMap" || !op) return;
|
|
379
|
+
|
|
380
|
+
const sourceMedia = props.virtualMedia.children?.[0];
|
|
381
|
+
// FIX (P1): typed field access instead of `(operation as any)?.mapMedia`.
|
|
382
|
+
const mapMedia = op.mapMedia;
|
|
383
|
+
if (!sourceMedia || !mapMedia) return;
|
|
384
|
+
|
|
385
|
+
// End previous pass
|
|
386
|
+
pass.end();
|
|
387
|
+
|
|
388
|
+
const width = targetWidth;
|
|
389
|
+
const height = targetHeight;
|
|
390
|
+
const frame = props.frame ?? 0;
|
|
391
|
+
const fps = props.fps || 30;
|
|
392
|
+
|
|
393
|
+
// Resolve signal fields
|
|
394
|
+
const strengthXInfo = resolveSignalField(op, "strengthX", 50);
|
|
395
|
+
const strengthYInfo = resolveSignalField(op, "strengthY", 50);
|
|
396
|
+
|
|
397
|
+
// --- Draw source child into srcTex ---
|
|
398
|
+
const srcTex = ctx.renderer.getTemporaryTexture(width, height, [
|
|
399
|
+
...(props.excludeTextures || []),
|
|
400
|
+
targetTexture,
|
|
401
|
+
]);
|
|
402
|
+
const srcView = srcTex.createView();
|
|
403
|
+
|
|
404
|
+
const srcClearPass = ctx.renderer.beginFrame(
|
|
405
|
+
encoder,
|
|
406
|
+
srcView,
|
|
407
|
+
{ r: 0, g: 0, b: 0, a: 0 },
|
|
408
|
+
width,
|
|
409
|
+
height,
|
|
410
|
+
"clear",
|
|
411
|
+
);
|
|
412
|
+
srcClearPass.end();
|
|
413
|
+
|
|
414
|
+
ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
|
|
415
|
+
ctx.renderer.pushIdentity();
|
|
416
|
+
await drawChild(
|
|
417
|
+
sourceMedia,
|
|
418
|
+
{
|
|
419
|
+
...props,
|
|
420
|
+
virtualMedia: sourceMedia,
|
|
421
|
+
renderId: `${props.renderId}-c0`,
|
|
422
|
+
excludeTextures: [...(props.excludeTextures || []), srcTex],
|
|
423
|
+
},
|
|
424
|
+
srcView,
|
|
425
|
+
srcTex,
|
|
426
|
+
width,
|
|
427
|
+
height,
|
|
428
|
+
);
|
|
429
|
+
ctx.renderer.popTransform();
|
|
430
|
+
ctx.renderer.popScissor();
|
|
431
|
+
|
|
432
|
+
// FIX (P0 - CRITICAL): drawChild leaves its own last-active pass open on
|
|
433
|
+
// `args.pass` (targeting srcView), matching the same "caller closes it"
|
|
434
|
+
// convention this function itself uses on exit (`args.pass = finalPass`
|
|
435
|
+
// below, with no `.end()`). Without this line, the next `beginFrame` call
|
|
436
|
+
// (for mapClearPass) opens a second render pass on the same encoder while
|
|
437
|
+
// this one is still open, which is invalid WebGPU encoder usage — it will
|
|
438
|
+
// throw or push the encoder into an error state that silently drops
|
|
439
|
+
// subsequent commands. This reproduces whenever `sourceMedia` renders
|
|
440
|
+
// through the GPU, i.e. essentially always.
|
|
441
|
+
args.pass.end();
|
|
442
|
+
|
|
443
|
+
// --- Draw map child into mapTex ---
|
|
444
|
+
const mapTex = ctx.renderer.getTemporaryTexture(width, height, [
|
|
445
|
+
...(props.excludeTextures || []),
|
|
446
|
+
targetTexture,
|
|
447
|
+
srcTex,
|
|
448
|
+
]);
|
|
449
|
+
const mapView = mapTex.createView();
|
|
450
|
+
|
|
451
|
+
const mapClearPass = ctx.renderer.beginFrame(
|
|
452
|
+
encoder,
|
|
453
|
+
mapView,
|
|
454
|
+
{ r: 0, g: 0, b: 0, a: 0 },
|
|
455
|
+
width,
|
|
456
|
+
height,
|
|
457
|
+
"clear",
|
|
458
|
+
);
|
|
459
|
+
mapClearPass.end();
|
|
460
|
+
|
|
461
|
+
ctx.renderer.pushScissor({ x: 0, y: 0, width, height });
|
|
462
|
+
ctx.renderer.pushIdentity();
|
|
463
|
+
await drawChild(
|
|
464
|
+
mapMedia,
|
|
465
|
+
{
|
|
466
|
+
...props,
|
|
467
|
+
virtualMedia: mapMedia,
|
|
468
|
+
renderId: `${props.renderId}-c1`,
|
|
469
|
+
excludeTextures: [...(props.excludeTextures || []), srcTex, mapTex],
|
|
470
|
+
},
|
|
471
|
+
mapView,
|
|
472
|
+
mapTex,
|
|
473
|
+
width,
|
|
474
|
+
height,
|
|
475
|
+
);
|
|
476
|
+
ctx.renderer.popTransform();
|
|
477
|
+
ctx.renderer.popScissor();
|
|
478
|
+
|
|
479
|
+
// End active pass from child drawing
|
|
480
|
+
args.pass.end();
|
|
481
|
+
|
|
482
|
+
// --- Fetch pipeline ---
|
|
483
|
+
const {
|
|
484
|
+
pipeline,
|
|
485
|
+
uniformLayout: uLayout,
|
|
486
|
+
srcTextureLayout: srcLayout,
|
|
487
|
+
mapTextureLayout: mapLayout,
|
|
488
|
+
signalTextureLayout: sigLayout,
|
|
489
|
+
} = getDisplacementResources(ctx.device, ctx.renderer.format);
|
|
490
|
+
|
|
491
|
+
const sampler = ctx.renderer.samplerCache.getSampler(ctx.device);
|
|
492
|
+
|
|
493
|
+
// --- Signal textures ---
|
|
494
|
+
let sigXView: GPUTextureView;
|
|
495
|
+
let sigYView: GPUTextureView;
|
|
496
|
+
|
|
497
|
+
const elapsedSeconds =
|
|
498
|
+
props.elapsedMs !== undefined ? props.elapsedMs / 1000 : frame / fps;
|
|
499
|
+
const durationSeconds = props.virtualMedia?.metadata?.durationMs
|
|
500
|
+
? props.virtualMedia.metadata.durationMs / 1000
|
|
501
|
+
: props.durationMs !== undefined
|
|
502
|
+
? props.durationMs / 1000
|
|
503
|
+
: 0;
|
|
504
|
+
|
|
505
|
+
if (strengthXInfo.hasSignal && strengthXInfo.sd) {
|
|
506
|
+
sigXView = signalRegistry.getOrCreate2DTextureView(
|
|
507
|
+
ctx.device,
|
|
508
|
+
encoder,
|
|
509
|
+
strengthXInfo.sd.nodeId ?? "displace_sx_sig",
|
|
510
|
+
elapsedSeconds,
|
|
511
|
+
durationSeconds,
|
|
512
|
+
strengthXInfo.sd,
|
|
513
|
+
width,
|
|
514
|
+
height,
|
|
515
|
+
props.renderId,
|
|
516
|
+
frame,
|
|
517
|
+
fps,
|
|
518
|
+
);
|
|
519
|
+
} else {
|
|
520
|
+
sigXView = signalRegistry.getDummy1x1TextureView(ctx.device);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (strengthYInfo.hasSignal && strengthYInfo.sd) {
|
|
524
|
+
sigYView = signalRegistry.getOrCreate2DTextureView(
|
|
525
|
+
ctx.device,
|
|
526
|
+
encoder,
|
|
527
|
+
strengthYInfo.sd.nodeId ?? "displace_sy_sig",
|
|
528
|
+
elapsedSeconds,
|
|
529
|
+
durationSeconds,
|
|
530
|
+
strengthYInfo.sd,
|
|
531
|
+
width,
|
|
532
|
+
height,
|
|
533
|
+
props.renderId,
|
|
534
|
+
frame,
|
|
535
|
+
fps,
|
|
536
|
+
);
|
|
537
|
+
} else {
|
|
538
|
+
sigYView = signalRegistry.getDummy1x1TextureView(ctx.device);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// --- Fill uniforms ---
|
|
542
|
+
uniformData[0] = strengthXInfo.value;
|
|
543
|
+
uniformData[1] = strengthYInfo.value;
|
|
544
|
+
uniformData[2] = channelToFloat(op.xChannel);
|
|
545
|
+
uniformData[3] = channelToFloat(op.yChannel);
|
|
546
|
+
uniformData[4] = wrapModeToFloat(op.wrapMode);
|
|
547
|
+
uniformData[5] = strengthXInfo.hasSignal ? 1.0 : 0.0;
|
|
548
|
+
uniformData[6] = strengthYInfo.hasSignal ? 1.0 : 0.0;
|
|
549
|
+
uniformData[7] = 0.0; // padding
|
|
550
|
+
|
|
551
|
+
const uniformBuffer = ctx.renderer.getTemporaryBuffer(uniformData);
|
|
552
|
+
|
|
553
|
+
// --- Output texture ---
|
|
554
|
+
const outTex = ctx.renderer.getTemporaryTexture(width, height, [
|
|
555
|
+
srcTex,
|
|
556
|
+
mapTex,
|
|
557
|
+
targetTexture,
|
|
558
|
+
...(props.excludeTextures || []),
|
|
559
|
+
]);
|
|
560
|
+
|
|
561
|
+
// --- Render displacement pass ---
|
|
562
|
+
const renderPass = encoder.beginRenderPass({
|
|
563
|
+
colorAttachments: [
|
|
564
|
+
{
|
|
565
|
+
view: outTex.createView(),
|
|
566
|
+
loadOp: "clear",
|
|
567
|
+
storeOp: "store",
|
|
568
|
+
clearValue: { r: 0, g: 0, b: 0, a: 0 },
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
renderPass.setPipeline(pipeline);
|
|
574
|
+
renderPass.setBindGroup(
|
|
575
|
+
0,
|
|
576
|
+
ctx.device.createBindGroup({
|
|
577
|
+
layout: uLayout,
|
|
578
|
+
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
|
|
579
|
+
}),
|
|
580
|
+
);
|
|
581
|
+
renderPass.setBindGroup(
|
|
582
|
+
1,
|
|
583
|
+
ctx.renderer.bindGroupCache.getBindGroup(
|
|
584
|
+
ctx.device,
|
|
585
|
+
srcLayout,
|
|
586
|
+
srcTex,
|
|
587
|
+
sampler,
|
|
588
|
+
),
|
|
589
|
+
);
|
|
590
|
+
renderPass.setBindGroup(
|
|
591
|
+
2,
|
|
592
|
+
ctx.renderer.bindGroupCache.getBindGroup(
|
|
593
|
+
ctx.device,
|
|
594
|
+
mapLayout,
|
|
595
|
+
mapTex,
|
|
596
|
+
sampler,
|
|
597
|
+
),
|
|
598
|
+
);
|
|
599
|
+
renderPass.setBindGroup(
|
|
600
|
+
3,
|
|
601
|
+
ctx.device.createBindGroup({
|
|
602
|
+
layout: sigLayout,
|
|
603
|
+
entries: [
|
|
604
|
+
{ binding: 0, resource: sigXView },
|
|
605
|
+
{ binding: 1, resource: sigYView },
|
|
606
|
+
{ binding: 2, resource: sampler },
|
|
607
|
+
],
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
renderPass.draw(4);
|
|
612
|
+
renderPass.end();
|
|
613
|
+
|
|
614
|
+
// --- Final pass: draw result to targetView ---
|
|
615
|
+
const finalPass = ctx.renderer.beginFrame(
|
|
616
|
+
encoder,
|
|
617
|
+
targetView,
|
|
618
|
+
{ r: 0, g: 0, b: 0, a: 0 },
|
|
619
|
+
targetWidth,
|
|
620
|
+
targetHeight,
|
|
621
|
+
"load",
|
|
622
|
+
);
|
|
623
|
+
ctx.renderer.drawTexture(
|
|
624
|
+
finalPass,
|
|
625
|
+
outTex,
|
|
626
|
+
{ x: 0, y: 0, width: targetWidth, height: targetHeight },
|
|
627
|
+
{ opacity: op.opacity ?? 1 },
|
|
628
|
+
);
|
|
629
|
+
|
|
630
|
+
args.pass = finalPass;
|
|
631
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
configBuilder,
|
|
3
|
+
ImageResultSchema,
|
|
4
|
+
VideoResultSchema,
|
|
5
|
+
} from "@framefields/node-sdk";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
export const displacementMapConfig = configBuilder()
|
|
9
|
+
.field("strengthX", z.number().min(0).max(500).default(50), {
|
|
10
|
+
bindable: true,
|
|
11
|
+
dataTypes: ["Number", "Signal"],
|
|
12
|
+
label: "Strength X Signal",
|
|
13
|
+
description:
|
|
14
|
+
"Horizontal displacement strength in pixels. Can be modulated by a static number or a dynamic signal.",
|
|
15
|
+
})
|
|
16
|
+
.field("strengthY", z.number().min(0).max(500).default(50), {
|
|
17
|
+
bindable: true,
|
|
18
|
+
dataTypes: ["Number", "Signal"],
|
|
19
|
+
label: "Strength Y Signal",
|
|
20
|
+
description:
|
|
21
|
+
"Vertical displacement strength in pixels. Can be modulated by a static number or a dynamic signal.",
|
|
22
|
+
})
|
|
23
|
+
.field(
|
|
24
|
+
"xChannel",
|
|
25
|
+
z.enum(["Red", "Green", "Blue", "Alpha", "Luminance"]).default("Red"),
|
|
26
|
+
{
|
|
27
|
+
bindable: false,
|
|
28
|
+
label: "X Channel",
|
|
29
|
+
},
|
|
30
|
+
)
|
|
31
|
+
.field(
|
|
32
|
+
"yChannel",
|
|
33
|
+
z.enum(["Red", "Green", "Blue", "Alpha", "Luminance"]).default("Green"),
|
|
34
|
+
{
|
|
35
|
+
bindable: false,
|
|
36
|
+
label: "Y Channel",
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
.field("wrapMode", z.enum(["Clamp", "Repeat", "Mirror"]).default("Clamp"), {
|
|
40
|
+
bindable: false,
|
|
41
|
+
label: "Wrap Mode",
|
|
42
|
+
})
|
|
43
|
+
.build();
|
|
44
|
+
|
|
45
|
+
export const DisplacementMapNodeConfigSchema = displacementMapConfig.schema;
|
|
46
|
+
|
|
47
|
+
export type DisplacementMapNodeConfig = z.infer<
|
|
48
|
+
typeof DisplacementMapNodeConfigSchema
|
|
49
|
+
>;
|
|
50
|
+
|
|
51
|
+
export const DisplacementMapResultSchema = z.union([
|
|
52
|
+
ImageResultSchema,
|
|
53
|
+
VideoResultSchema,
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
export type DisplacementMapResult = z.infer<typeof DisplacementMapResultSchema>;
|
|
57
|
+
|
|
58
|
+
export const DISPLACEMENT_OUTPUT_TYPE_MAP: Record<
|
|
59
|
+
string,
|
|
60
|
+
"Image" | "Video" | "GIF"
|
|
61
|
+
> = {
|
|
62
|
+
Video: "Video",
|
|
63
|
+
Lottie: "Video",
|
|
64
|
+
GIF: "GIF",
|
|
65
|
+
Image: "Image",
|
|
66
|
+
SVG: "Image",
|
|
67
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./config.js";
|
package/tsconfig.json
ADDED