tokimeki-image-editor 0.1.8 → 0.1.10
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/dist/components/AdjustTool.svelte +333 -299
- package/dist/components/Canvas.svelte +274 -115
- package/dist/components/Canvas.svelte.d.ts +1 -0
- package/dist/components/FilterTool.svelte +408 -298
- package/dist/components/ImageEditor.svelte +426 -423
- package/dist/i18n/locales/en.json +79 -68
- package/dist/i18n/locales/ja.json +79 -68
- package/dist/shaders/blur.wgsl +59 -0
- package/dist/shaders/composite.wgsl +46 -0
- package/dist/shaders/grain.wgsl +225 -0
- package/dist/shaders/image-editor.wgsl +296 -0
- package/dist/types.d.ts +3 -1
- package/dist/utils/adjustments.d.ts +2 -1
- package/dist/utils/adjustments.js +100 -13
- package/dist/utils/canvas.d.ts +7 -2
- package/dist/utils/canvas.js +48 -5
- package/dist/utils/filters.js +109 -2
- package/dist/utils/webgpu-render.d.ts +26 -0
- package/dist/utils/webgpu-render.js +1192 -0
- package/package.json +43 -42
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
import SHADER_CODE from '$lib/shaders/image-editor.wgsl?raw';
|
|
2
|
+
import BLUR_SHADER_CODE from '$lib/shaders/blur.wgsl?raw';
|
|
3
|
+
import COMPOSITE_SHADER_CODE from '$lib/shaders/composite.wgsl?raw';
|
|
4
|
+
import GRAIN_SHADER_CODE from '$lib/shaders/grain.wgsl?raw';
|
|
5
|
+
/**
|
|
6
|
+
* WebGPU Render Pipeline for image adjustments with viewport and transform support
|
|
7
|
+
* Uses fragment shader to apply adjustments in real-time
|
|
8
|
+
*/
|
|
9
|
+
// WebGPU state
|
|
10
|
+
let gpuDevice = null;
|
|
11
|
+
let gpuContext = null;
|
|
12
|
+
let gpuPipeline = null;
|
|
13
|
+
let gpuUniformBuffer = null;
|
|
14
|
+
let gpuSampler = null;
|
|
15
|
+
let gpuTexture = null;
|
|
16
|
+
let gpuBindGroup = null;
|
|
17
|
+
// Blur pipeline state
|
|
18
|
+
let gpuBlurPipeline = null;
|
|
19
|
+
let gpuBlurUniformBuffer = null;
|
|
20
|
+
let gpuIntermediateTexture = null;
|
|
21
|
+
let gpuIntermediateTexture2 = null;
|
|
22
|
+
// Composite pipeline state
|
|
23
|
+
let gpuCompositePipeline = null;
|
|
24
|
+
let gpuCompositeUniformBuffer = null;
|
|
25
|
+
let gpuIntermediateTexture3 = null;
|
|
26
|
+
let gpuIntermediateTexture4 = null; // 4th texture for blur temp
|
|
27
|
+
// Grain pipeline state
|
|
28
|
+
let gpuGrainPipeline = null;
|
|
29
|
+
let gpuGrainUniformBuffer = null;
|
|
30
|
+
// Helper functions and constants
|
|
31
|
+
const BLUR_UNIFORMS_ZERO = new Float32Array([1.0, 0.0, 0.0, 0.0]);
|
|
32
|
+
function clamp(value, min, max) {
|
|
33
|
+
return Math.max(min, Math.min(max, value));
|
|
34
|
+
}
|
|
35
|
+
function createRenderPass(commandEncoder, view, pipeline, bindGroup) {
|
|
36
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
37
|
+
colorAttachments: [{
|
|
38
|
+
view,
|
|
39
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
40
|
+
loadOp: 'clear',
|
|
41
|
+
storeOp: 'store',
|
|
42
|
+
}],
|
|
43
|
+
});
|
|
44
|
+
renderPass.setPipeline(pipeline);
|
|
45
|
+
renderPass.setBindGroup(0, bindGroup);
|
|
46
|
+
renderPass.draw(3, 1, 0, 0);
|
|
47
|
+
renderPass.end();
|
|
48
|
+
}
|
|
49
|
+
function createBlurBindGroup(textureView) {
|
|
50
|
+
if (!gpuDevice || !gpuBlurPipeline || !gpuSampler || !gpuBlurUniformBuffer) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return gpuDevice.createBindGroup({
|
|
54
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
55
|
+
entries: [
|
|
56
|
+
{ binding: 0, resource: gpuSampler },
|
|
57
|
+
{ binding: 1, resource: textureView },
|
|
58
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Initialize WebGPU for the given canvas
|
|
64
|
+
*/
|
|
65
|
+
export async function initWebGPUCanvas(canvas) {
|
|
66
|
+
try {
|
|
67
|
+
if (!navigator.gpu) {
|
|
68
|
+
console.warn('WebGPU not supported');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
72
|
+
if (!adapter) {
|
|
73
|
+
console.warn('No WebGPU adapter');
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
gpuDevice = await adapter.requestDevice();
|
|
77
|
+
// Get WebGPU context
|
|
78
|
+
gpuContext = canvas.getContext('webgpu');
|
|
79
|
+
if (!gpuContext) {
|
|
80
|
+
console.warn('Failed to get WebGPU context');
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
84
|
+
gpuContext.configure({
|
|
85
|
+
device: gpuDevice,
|
|
86
|
+
format: format,
|
|
87
|
+
alphaMode: 'premultiplied',
|
|
88
|
+
});
|
|
89
|
+
// Create uniform buffer
|
|
90
|
+
// 10 (adjustments) + 4 (viewport) + 4 (transform) + 2 (canvas dims) + 2 (image dims) + 4 (crop) = 26 floats
|
|
91
|
+
// Round up to 32 for alignment
|
|
92
|
+
gpuUniformBuffer = gpuDevice.createBuffer({
|
|
93
|
+
size: 128, // 32 floats * 4 bytes
|
|
94
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
95
|
+
});
|
|
96
|
+
// Create sampler
|
|
97
|
+
gpuSampler = gpuDevice.createSampler({
|
|
98
|
+
magFilter: 'linear',
|
|
99
|
+
minFilter: 'linear',
|
|
100
|
+
});
|
|
101
|
+
// Create shader module
|
|
102
|
+
const shaderModule = gpuDevice.createShaderModule({ code: SHADER_CODE });
|
|
103
|
+
// Create render pipeline
|
|
104
|
+
gpuPipeline = gpuDevice.createRenderPipeline({
|
|
105
|
+
layout: 'auto',
|
|
106
|
+
vertex: {
|
|
107
|
+
module: shaderModule,
|
|
108
|
+
entryPoint: 'vs_main',
|
|
109
|
+
},
|
|
110
|
+
fragment: {
|
|
111
|
+
module: shaderModule,
|
|
112
|
+
entryPoint: 'fs_main',
|
|
113
|
+
targets: [{ format: format }],
|
|
114
|
+
},
|
|
115
|
+
primitive: {
|
|
116
|
+
topology: 'triangle-list',
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
// Create blur pipeline
|
|
120
|
+
const blurShaderModule = gpuDevice.createShaderModule({ code: BLUR_SHADER_CODE });
|
|
121
|
+
gpuBlurPipeline = gpuDevice.createRenderPipeline({
|
|
122
|
+
layout: 'auto',
|
|
123
|
+
vertex: {
|
|
124
|
+
module: blurShaderModule,
|
|
125
|
+
entryPoint: 'vs_main',
|
|
126
|
+
},
|
|
127
|
+
fragment: {
|
|
128
|
+
module: blurShaderModule,
|
|
129
|
+
entryPoint: 'fs_main',
|
|
130
|
+
targets: [{ format: format }],
|
|
131
|
+
},
|
|
132
|
+
primitive: {
|
|
133
|
+
topology: 'triangle-list',
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
// Create blur uniform buffer
|
|
137
|
+
gpuBlurUniformBuffer = gpuDevice.createBuffer({
|
|
138
|
+
size: 16, // 2 floats (direction) + 1 float (radius) + 1 float (padding) = 4 floats * 4 bytes
|
|
139
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
140
|
+
});
|
|
141
|
+
// Create composite pipeline
|
|
142
|
+
const compositeShaderModule = gpuDevice.createShaderModule({ code: COMPOSITE_SHADER_CODE });
|
|
143
|
+
gpuCompositePipeline = gpuDevice.createRenderPipeline({
|
|
144
|
+
layout: 'auto',
|
|
145
|
+
vertex: {
|
|
146
|
+
module: compositeShaderModule,
|
|
147
|
+
entryPoint: 'vs_main',
|
|
148
|
+
},
|
|
149
|
+
fragment: {
|
|
150
|
+
module: compositeShaderModule,
|
|
151
|
+
entryPoint: 'fs_main',
|
|
152
|
+
targets: [{ format: format }],
|
|
153
|
+
},
|
|
154
|
+
primitive: {
|
|
155
|
+
topology: 'triangle-list',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
// Create composite uniform buffer
|
|
159
|
+
gpuCompositeUniformBuffer = gpuDevice.createBuffer({
|
|
160
|
+
size: 16, // 4 floats (minX, minY, maxX, maxY) = 4 floats * 4 bytes
|
|
161
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
162
|
+
});
|
|
163
|
+
// Create grain pipeline
|
|
164
|
+
const grainShaderModule = gpuDevice.createShaderModule({ code: GRAIN_SHADER_CODE });
|
|
165
|
+
gpuGrainPipeline = gpuDevice.createRenderPipeline({
|
|
166
|
+
layout: 'auto',
|
|
167
|
+
vertex: {
|
|
168
|
+
module: grainShaderModule,
|
|
169
|
+
entryPoint: 'vs_main',
|
|
170
|
+
},
|
|
171
|
+
fragment: {
|
|
172
|
+
module: grainShaderModule,
|
|
173
|
+
entryPoint: 'fs_main',
|
|
174
|
+
targets: [{ format: format }],
|
|
175
|
+
},
|
|
176
|
+
primitive: {
|
|
177
|
+
topology: 'triangle-list',
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// Create grain uniform buffer
|
|
181
|
+
// 1 (grain) + 4 (viewport) + 4 (transform) + 2 (canvas) + 2 (image) + 4 (crop) = 17 floats
|
|
182
|
+
// Padded to 20 floats for alignment (80 bytes)
|
|
183
|
+
gpuGrainUniformBuffer = gpuDevice.createBuffer({
|
|
184
|
+
size: 80, // 20 floats * 4 bytes
|
|
185
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
186
|
+
});
|
|
187
|
+
console.log('WebGPU render pipeline initialized successfully');
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
console.error('Failed to initialize WebGPU:', error);
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Upload image to GPU as texture
|
|
197
|
+
*/
|
|
198
|
+
export async function uploadImageToGPU(imageSource) {
|
|
199
|
+
if (!gpuDevice || !gpuPipeline)
|
|
200
|
+
return false;
|
|
201
|
+
try {
|
|
202
|
+
// Create texture from image
|
|
203
|
+
const bitmap = imageSource instanceof ImageBitmap
|
|
204
|
+
? imageSource
|
|
205
|
+
: await createImageBitmap(imageSource);
|
|
206
|
+
gpuTexture = gpuDevice.createTexture({
|
|
207
|
+
size: [bitmap.width, bitmap.height, 1],
|
|
208
|
+
format: 'rgba8unorm',
|
|
209
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
210
|
+
});
|
|
211
|
+
gpuDevice.queue.copyExternalImageToTexture({ source: bitmap }, { texture: gpuTexture }, [bitmap.width, bitmap.height]);
|
|
212
|
+
// Update bind group
|
|
213
|
+
updateBindGroup();
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
console.error('Failed to upload image to GPU:', error);
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Update bind group with current texture and uniforms
|
|
223
|
+
*/
|
|
224
|
+
function updateBindGroup() {
|
|
225
|
+
if (!gpuDevice || !gpuPipeline || !gpuTexture || !gpuSampler || !gpuUniformBuffer)
|
|
226
|
+
return;
|
|
227
|
+
gpuBindGroup = gpuDevice.createBindGroup({
|
|
228
|
+
layout: gpuPipeline.getBindGroupLayout(0),
|
|
229
|
+
entries: [
|
|
230
|
+
{ binding: 0, resource: gpuSampler },
|
|
231
|
+
{ binding: 1, resource: gpuTexture.createView() },
|
|
232
|
+
{ binding: 2, resource: { buffer: gpuUniformBuffer } },
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Ensure intermediate textures exist and match canvas size
|
|
238
|
+
*/
|
|
239
|
+
function ensureIntermediateTextures(width, height) {
|
|
240
|
+
if (!gpuDevice)
|
|
241
|
+
return;
|
|
242
|
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
243
|
+
// Check if textures need to be recreated
|
|
244
|
+
const needsRecreate = !gpuIntermediateTexture ||
|
|
245
|
+
gpuIntermediateTexture.width !== width ||
|
|
246
|
+
gpuIntermediateTexture.height !== height;
|
|
247
|
+
if (needsRecreate) {
|
|
248
|
+
// Destroy old textures
|
|
249
|
+
gpuIntermediateTexture?.destroy();
|
|
250
|
+
gpuIntermediateTexture2?.destroy();
|
|
251
|
+
gpuIntermediateTexture3?.destroy();
|
|
252
|
+
gpuIntermediateTexture4?.destroy();
|
|
253
|
+
// Create new intermediate textures
|
|
254
|
+
gpuIntermediateTexture = gpuDevice.createTexture({
|
|
255
|
+
size: [width, height, 1],
|
|
256
|
+
format: format,
|
|
257
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
258
|
+
});
|
|
259
|
+
gpuIntermediateTexture2 = gpuDevice.createTexture({
|
|
260
|
+
size: [width, height, 1],
|
|
261
|
+
format: format,
|
|
262
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
263
|
+
});
|
|
264
|
+
gpuIntermediateTexture3 = gpuDevice.createTexture({
|
|
265
|
+
size: [width, height, 1],
|
|
266
|
+
format: format,
|
|
267
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
268
|
+
});
|
|
269
|
+
gpuIntermediateTexture4 = gpuDevice.createTexture({
|
|
270
|
+
size: [width, height, 1],
|
|
271
|
+
format: format,
|
|
272
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Render with adjustments, viewport, transform, crop, and blur areas
|
|
278
|
+
*/
|
|
279
|
+
export function renderWithAdjustments(adjustments, viewport, transform, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea = null, blurAreas = []) {
|
|
280
|
+
if (!gpuDevice || !gpuContext || !gpuPipeline || !gpuBindGroup || !gpuUniformBuffer) {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
// Convert rotation from degrees to radians
|
|
285
|
+
const rotationRad = (transform.rotation * Math.PI) / 180;
|
|
286
|
+
// Check if we need multi-pass rendering (for blur or grain layering)
|
|
287
|
+
const hasGlobalBlur = adjustments.blur > 0;
|
|
288
|
+
const hasRegionalBlur = blurAreas.length > 0 && blurAreas.some(area => area.blurStrength > 0);
|
|
289
|
+
const hasGrain = adjustments.grain > 0;
|
|
290
|
+
const needsMultiPass = hasGlobalBlur || hasRegionalBlur || hasGrain;
|
|
291
|
+
// If grain is enabled, we apply it in a separate pass AFTER blur
|
|
292
|
+
// So pass grain=0 to the main shader
|
|
293
|
+
const mainShaderGrain = needsMultiPass ? 0 : adjustments.grain;
|
|
294
|
+
// Update uniforms
|
|
295
|
+
const uniformData = new Float32Array([
|
|
296
|
+
// Adjustments (11 floats)
|
|
297
|
+
adjustments.brightness,
|
|
298
|
+
adjustments.contrast,
|
|
299
|
+
adjustments.exposure,
|
|
300
|
+
adjustments.highlights,
|
|
301
|
+
adjustments.shadows,
|
|
302
|
+
adjustments.saturation,
|
|
303
|
+
adjustments.temperature,
|
|
304
|
+
adjustments.sepia,
|
|
305
|
+
adjustments.grayscale,
|
|
306
|
+
adjustments.vignette,
|
|
307
|
+
mainShaderGrain,
|
|
308
|
+
// Viewport (4 floats)
|
|
309
|
+
viewport.zoom,
|
|
310
|
+
viewport.offsetX,
|
|
311
|
+
viewport.offsetY,
|
|
312
|
+
viewport.scale,
|
|
313
|
+
// Transform (4 floats)
|
|
314
|
+
rotationRad,
|
|
315
|
+
transform.flipHorizontal ? -1.0 : 1.0,
|
|
316
|
+
transform.flipVertical ? -1.0 : 1.0,
|
|
317
|
+
transform.scale,
|
|
318
|
+
// Canvas dimensions (2 floats)
|
|
319
|
+
canvasWidth,
|
|
320
|
+
canvasHeight,
|
|
321
|
+
// Image dimensions (2 floats)
|
|
322
|
+
imageWidth,
|
|
323
|
+
imageHeight,
|
|
324
|
+
// Crop area (4 floats)
|
|
325
|
+
cropArea?.x ?? 0,
|
|
326
|
+
cropArea?.y ?? 0,
|
|
327
|
+
cropArea?.width ?? 0,
|
|
328
|
+
cropArea?.height ?? 0,
|
|
329
|
+
// Padding to 32 floats for alignment (11+4+4+2+2+4=27, need 5 padding)
|
|
330
|
+
0, 0, 0, 0, 0
|
|
331
|
+
]);
|
|
332
|
+
gpuDevice.queue.writeBuffer(gpuUniformBuffer, 0, uniformData);
|
|
333
|
+
if (!needsMultiPass) {
|
|
334
|
+
// No blur or grain - render directly to canvas
|
|
335
|
+
const commandEncoder = gpuDevice.createCommandEncoder();
|
|
336
|
+
const textureView = gpuContext.getCurrentTexture().createView();
|
|
337
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
338
|
+
colorAttachments: [{
|
|
339
|
+
view: textureView,
|
|
340
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
341
|
+
loadOp: 'clear',
|
|
342
|
+
storeOp: 'store',
|
|
343
|
+
}],
|
|
344
|
+
});
|
|
345
|
+
renderPass.setPipeline(gpuPipeline);
|
|
346
|
+
renderPass.setBindGroup(0, gpuBindGroup);
|
|
347
|
+
renderPass.draw(3, 1, 0, 0);
|
|
348
|
+
renderPass.end();
|
|
349
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
350
|
+
return true;
|
|
351
|
+
}
|
|
352
|
+
// Has blur or grain - use multi-pass rendering
|
|
353
|
+
return renderWithBlur(blurAreas, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea, viewport, transform, adjustments.blur, adjustments.grain);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
console.error('WebGPU render failed:', error);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Render with blur (global and/or regional) and grain using multi-pass compositing
|
|
362
|
+
*/
|
|
363
|
+
function renderWithBlur(blurAreas, canvasWidth, canvasHeight, imageWidth, imageHeight, cropArea, viewport, transform, globalBlurStrength = 0, grainAmount = 0) {
|
|
364
|
+
if (!gpuDevice || !gpuContext || !gpuPipeline || !gpuBindGroup ||
|
|
365
|
+
!gpuBlurPipeline || !gpuBlurUniformBuffer ||
|
|
366
|
+
!gpuCompositePipeline || !gpuCompositeUniformBuffer ||
|
|
367
|
+
!gpuGrainPipeline || !gpuGrainUniformBuffer || !gpuSampler) {
|
|
368
|
+
console.error('Missing WebGPU resources for multi-pass rendering');
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
// Ensure all intermediate textures exist
|
|
372
|
+
ensureIntermediateTextures(canvasWidth, canvasHeight);
|
|
373
|
+
if (!gpuIntermediateTexture || !gpuIntermediateTexture2 ||
|
|
374
|
+
!gpuIntermediateTexture3 || !gpuIntermediateTexture4) {
|
|
375
|
+
console.error('Failed to create intermediate textures');
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
const intermediateView1 = gpuIntermediateTexture.createView();
|
|
379
|
+
const intermediateView2 = gpuIntermediateTexture2.createView();
|
|
380
|
+
const intermediateView3 = gpuIntermediateTexture3.createView();
|
|
381
|
+
const intermediateView4 = gpuIntermediateTexture4.createView();
|
|
382
|
+
// === Pass 1: Render adjustments to intermediate texture 1 (base image) ===
|
|
383
|
+
let commandEncoder = gpuDevice.createCommandEncoder();
|
|
384
|
+
const renderPass1 = commandEncoder.beginRenderPass({
|
|
385
|
+
colorAttachments: [{
|
|
386
|
+
view: intermediateView1,
|
|
387
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
388
|
+
loadOp: 'clear',
|
|
389
|
+
storeOp: 'store',
|
|
390
|
+
}],
|
|
391
|
+
});
|
|
392
|
+
renderPass1.setPipeline(gpuPipeline);
|
|
393
|
+
renderPass1.setBindGroup(0, gpuBindGroup);
|
|
394
|
+
renderPass1.draw(3, 1, 0, 0);
|
|
395
|
+
renderPass1.end();
|
|
396
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
397
|
+
// === Pass 2: Copy base image to accumulator (intermediate1 -> intermediate4) ===
|
|
398
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
399
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
|
|
400
|
+
const copyBindGroup = gpuDevice.createBindGroup({
|
|
401
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
402
|
+
entries: [
|
|
403
|
+
{ binding: 0, resource: gpuSampler },
|
|
404
|
+
{ binding: 1, resource: intermediateView1 },
|
|
405
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
406
|
+
],
|
|
407
|
+
});
|
|
408
|
+
const copyPass = commandEncoder.beginRenderPass({
|
|
409
|
+
colorAttachments: [{
|
|
410
|
+
view: intermediateView4,
|
|
411
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
412
|
+
loadOp: 'clear',
|
|
413
|
+
storeOp: 'store',
|
|
414
|
+
}],
|
|
415
|
+
});
|
|
416
|
+
copyPass.setPipeline(gpuBlurPipeline);
|
|
417
|
+
copyPass.setBindGroup(0, copyBindGroup);
|
|
418
|
+
copyPass.draw(3, 1, 0, 0);
|
|
419
|
+
copyPass.end();
|
|
420
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
421
|
+
// === Pass 3a: Apply global blur if enabled ===
|
|
422
|
+
if (globalBlurStrength > 0) {
|
|
423
|
+
// Map blur 0-100 to radius 0-10
|
|
424
|
+
const globalBlurRadius = Math.ceil((globalBlurStrength / 100) * 10);
|
|
425
|
+
// Horizontal blur pass (intermediate4 accumulator -> intermediate2)
|
|
426
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
427
|
+
const blurUniformsH = new Float32Array([1.0, 0.0, globalBlurRadius, 0.0]);
|
|
428
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsH);
|
|
429
|
+
const blurBindGroupH = gpuDevice.createBindGroup({
|
|
430
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
431
|
+
entries: [
|
|
432
|
+
{ binding: 0, resource: gpuSampler },
|
|
433
|
+
{ binding: 1, resource: intermediateView4 },
|
|
434
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
435
|
+
],
|
|
436
|
+
});
|
|
437
|
+
const renderPassH = commandEncoder.beginRenderPass({
|
|
438
|
+
colorAttachments: [{
|
|
439
|
+
view: intermediateView2,
|
|
440
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
441
|
+
loadOp: 'clear',
|
|
442
|
+
storeOp: 'store',
|
|
443
|
+
}],
|
|
444
|
+
});
|
|
445
|
+
renderPassH.setPipeline(gpuBlurPipeline);
|
|
446
|
+
renderPassH.setBindGroup(0, blurBindGroupH);
|
|
447
|
+
renderPassH.draw(3, 1, 0, 0);
|
|
448
|
+
renderPassH.end();
|
|
449
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
450
|
+
// Vertical blur pass (intermediate2 -> intermediate4)
|
|
451
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
452
|
+
const blurUniformsV = new Float32Array([0.0, 1.0, globalBlurRadius, 0.0]);
|
|
453
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsV);
|
|
454
|
+
const blurBindGroupV = gpuDevice.createBindGroup({
|
|
455
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
456
|
+
entries: [
|
|
457
|
+
{ binding: 0, resource: gpuSampler },
|
|
458
|
+
{ binding: 1, resource: intermediateView2 },
|
|
459
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
460
|
+
],
|
|
461
|
+
});
|
|
462
|
+
const renderPassV = commandEncoder.beginRenderPass({
|
|
463
|
+
colorAttachments: [{
|
|
464
|
+
view: intermediateView4,
|
|
465
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
466
|
+
loadOp: 'clear',
|
|
467
|
+
storeOp: 'store',
|
|
468
|
+
}],
|
|
469
|
+
});
|
|
470
|
+
renderPassV.setPipeline(gpuBlurPipeline);
|
|
471
|
+
renderPassV.setBindGroup(0, blurBindGroupV);
|
|
472
|
+
renderPassV.draw(3, 1, 0, 0);
|
|
473
|
+
renderPassV.end();
|
|
474
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
475
|
+
}
|
|
476
|
+
// === Pass 3b: Process each regional blur area ===
|
|
477
|
+
// Determine source dimensions based on crop
|
|
478
|
+
const sourceWidth = cropArea ? cropArea.width : imageWidth;
|
|
479
|
+
const sourceHeight = cropArea ? cropArea.height : imageHeight;
|
|
480
|
+
const cropOffsetX = cropArea ? cropArea.x : 0;
|
|
481
|
+
const cropOffsetY = cropArea ? cropArea.y : 0;
|
|
482
|
+
// Calculate transform parameters (same as 2D canvas)
|
|
483
|
+
const totalScale = viewport.scale * viewport.zoom * transform.scale;
|
|
484
|
+
const centerX = canvasWidth / 2;
|
|
485
|
+
const centerY = canvasHeight / 2;
|
|
486
|
+
for (const blurArea of blurAreas) {
|
|
487
|
+
if (blurArea.blurStrength <= 0)
|
|
488
|
+
continue;
|
|
489
|
+
// Calculate blur radius to match Canvas2D behavior
|
|
490
|
+
const imageBlurPx = (blurArea.blurStrength / 100) * 100;
|
|
491
|
+
const blurRadius = Math.ceil(imageBlurPx * totalScale);
|
|
492
|
+
// Blur areas are in IMAGE space, need to convert to canvas space, then to UV
|
|
493
|
+
// Following the same logic as 2D canvas implementation
|
|
494
|
+
// 1. Convert blur area to crop-relative coordinates
|
|
495
|
+
const relativeX = blurArea.x - cropOffsetX;
|
|
496
|
+
const relativeY = blurArea.y - cropOffsetY;
|
|
497
|
+
// 2. Transform from image space to canvas space
|
|
498
|
+
const canvasBlurX = (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX;
|
|
499
|
+
const canvasBlurY = (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY;
|
|
500
|
+
const canvasBlurWidth = blurArea.width * totalScale;
|
|
501
|
+
const canvasBlurHeight = blurArea.height * totalScale;
|
|
502
|
+
// 3. Convert canvas space to normalized UV coordinates (0-1)
|
|
503
|
+
const minX = clamp(canvasBlurX / canvasWidth, 0, 1);
|
|
504
|
+
const minY = clamp(canvasBlurY / canvasHeight, 0, 1);
|
|
505
|
+
const maxX = clamp((canvasBlurX + canvasBlurWidth) / canvasWidth, 0, 1);
|
|
506
|
+
const maxY = clamp((canvasBlurY + canvasBlurHeight) / canvasHeight, 0, 1);
|
|
507
|
+
// Pass 3a: Horizontal blur (intermediate1 base -> intermediate2)
|
|
508
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
509
|
+
const blurUniformsH = new Float32Array([1.0, 0.0, blurRadius, 0.0]);
|
|
510
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsH);
|
|
511
|
+
const blurBindGroupH = gpuDevice.createBindGroup({
|
|
512
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
513
|
+
entries: [
|
|
514
|
+
{ binding: 0, resource: gpuSampler },
|
|
515
|
+
{ binding: 1, resource: intermediateView1 },
|
|
516
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
517
|
+
],
|
|
518
|
+
});
|
|
519
|
+
const renderPassH = commandEncoder.beginRenderPass({
|
|
520
|
+
colorAttachments: [{
|
|
521
|
+
view: intermediateView2,
|
|
522
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
523
|
+
loadOp: 'clear',
|
|
524
|
+
storeOp: 'store',
|
|
525
|
+
}],
|
|
526
|
+
});
|
|
527
|
+
renderPassH.setPipeline(gpuBlurPipeline);
|
|
528
|
+
renderPassH.setBindGroup(0, blurBindGroupH);
|
|
529
|
+
renderPassH.draw(3, 1, 0, 0);
|
|
530
|
+
renderPassH.end();
|
|
531
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
532
|
+
// Pass 3b: Vertical blur (intermediate2 -> intermediate3)
|
|
533
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
534
|
+
const blurUniformsV = new Float32Array([0.0, 1.0, blurRadius, 0.0]);
|
|
535
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, blurUniformsV);
|
|
536
|
+
const blurBindGroupV = gpuDevice.createBindGroup({
|
|
537
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
538
|
+
entries: [
|
|
539
|
+
{ binding: 0, resource: gpuSampler },
|
|
540
|
+
{ binding: 1, resource: intermediateView2 },
|
|
541
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
542
|
+
],
|
|
543
|
+
});
|
|
544
|
+
const renderPassV = commandEncoder.beginRenderPass({
|
|
545
|
+
colorAttachments: [{
|
|
546
|
+
view: intermediateView3,
|
|
547
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
548
|
+
loadOp: 'clear',
|
|
549
|
+
storeOp: 'store',
|
|
550
|
+
}],
|
|
551
|
+
});
|
|
552
|
+
renderPassV.setPipeline(gpuBlurPipeline);
|
|
553
|
+
renderPassV.setBindGroup(0, blurBindGroupV);
|
|
554
|
+
renderPassV.draw(3, 1, 0, 0);
|
|
555
|
+
renderPassV.end();
|
|
556
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
557
|
+
// Pass 3c: Composite blurred region with accumulator (intermediate3 blurred + intermediate4 accumulator -> intermediate2)
|
|
558
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
559
|
+
const compositeUniforms = new Float32Array([minX, minY, maxX, maxY]);
|
|
560
|
+
gpuDevice.queue.writeBuffer(gpuCompositeUniformBuffer, 0, compositeUniforms);
|
|
561
|
+
const compositeBindGroup = gpuDevice.createBindGroup({
|
|
562
|
+
layout: gpuCompositePipeline.getBindGroupLayout(0),
|
|
563
|
+
entries: [
|
|
564
|
+
{ binding: 0, resource: gpuSampler },
|
|
565
|
+
{ binding: 1, resource: intermediateView3 }, // blurred
|
|
566
|
+
{ binding: 2, resource: intermediateView4 }, // accumulator
|
|
567
|
+
{ binding: 3, resource: { buffer: gpuCompositeUniformBuffer } },
|
|
568
|
+
],
|
|
569
|
+
});
|
|
570
|
+
const renderPassComp = commandEncoder.beginRenderPass({
|
|
571
|
+
colorAttachments: [{
|
|
572
|
+
view: intermediateView2,
|
|
573
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
574
|
+
loadOp: 'clear',
|
|
575
|
+
storeOp: 'store',
|
|
576
|
+
}],
|
|
577
|
+
});
|
|
578
|
+
renderPassComp.setPipeline(gpuCompositePipeline);
|
|
579
|
+
renderPassComp.setBindGroup(0, compositeBindGroup);
|
|
580
|
+
renderPassComp.draw(3, 1, 0, 0);
|
|
581
|
+
renderPassComp.end();
|
|
582
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
583
|
+
// Pass 3d: Copy result back to accumulator (intermediate2 -> intermediate4)
|
|
584
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
585
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
|
|
586
|
+
const copyBackBindGroup = gpuDevice.createBindGroup({
|
|
587
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
588
|
+
entries: [
|
|
589
|
+
{ binding: 0, resource: gpuSampler },
|
|
590
|
+
{ binding: 1, resource: intermediateView2 },
|
|
591
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
592
|
+
],
|
|
593
|
+
});
|
|
594
|
+
const copyBackPass = commandEncoder.beginRenderPass({
|
|
595
|
+
colorAttachments: [{
|
|
596
|
+
view: intermediateView4,
|
|
597
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
598
|
+
loadOp: 'clear',
|
|
599
|
+
storeOp: 'store',
|
|
600
|
+
}],
|
|
601
|
+
});
|
|
602
|
+
copyBackPass.setPipeline(gpuBlurPipeline);
|
|
603
|
+
copyBackPass.setBindGroup(0, copyBackBindGroup);
|
|
604
|
+
copyBackPass.draw(3, 1, 0, 0);
|
|
605
|
+
copyBackPass.end();
|
|
606
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
607
|
+
}
|
|
608
|
+
// === Pass 4: Apply grain (if enabled) or copy final result to canvas ===
|
|
609
|
+
commandEncoder = gpuDevice.createCommandEncoder();
|
|
610
|
+
const canvasView = gpuContext.getCurrentTexture().createView();
|
|
611
|
+
if (grainAmount > 0) {
|
|
612
|
+
// Apply grain using grain pipeline
|
|
613
|
+
const rotationRad = (transform.rotation * Math.PI) / 180;
|
|
614
|
+
const grainUniforms = new Float32Array([
|
|
615
|
+
// Grain parameter (1 float)
|
|
616
|
+
grainAmount,
|
|
617
|
+
// Viewport (4 floats)
|
|
618
|
+
viewport.zoom,
|
|
619
|
+
viewport.offsetX,
|
|
620
|
+
viewport.offsetY,
|
|
621
|
+
viewport.scale,
|
|
622
|
+
// Transform (4 floats)
|
|
623
|
+
rotationRad,
|
|
624
|
+
transform.flipHorizontal ? -1.0 : 1.0,
|
|
625
|
+
transform.flipVertical ? -1.0 : 1.0,
|
|
626
|
+
transform.scale,
|
|
627
|
+
// Canvas dimensions (2 floats)
|
|
628
|
+
canvasWidth,
|
|
629
|
+
canvasHeight,
|
|
630
|
+
// Image dimensions (2 floats)
|
|
631
|
+
imageWidth,
|
|
632
|
+
imageHeight,
|
|
633
|
+
// Crop area (4 floats)
|
|
634
|
+
cropArea?.x ?? 0,
|
|
635
|
+
cropArea?.y ?? 0,
|
|
636
|
+
cropArea?.width ?? 0,
|
|
637
|
+
cropArea?.height ?? 0,
|
|
638
|
+
// Padding to 20 floats (17 used, 3 padding)
|
|
639
|
+
0, 0, 0
|
|
640
|
+
]);
|
|
641
|
+
gpuDevice.queue.writeBuffer(gpuGrainUniformBuffer, 0, grainUniforms);
|
|
642
|
+
const grainBindGroup = gpuDevice.createBindGroup({
|
|
643
|
+
layout: gpuGrainPipeline.getBindGroupLayout(0),
|
|
644
|
+
entries: [
|
|
645
|
+
{ binding: 0, resource: gpuSampler },
|
|
646
|
+
{ binding: 1, resource: intermediateView4 },
|
|
647
|
+
{ binding: 2, resource: { buffer: gpuGrainUniformBuffer } },
|
|
648
|
+
],
|
|
649
|
+
});
|
|
650
|
+
const renderPassGrain = commandEncoder.beginRenderPass({
|
|
651
|
+
colorAttachments: [{
|
|
652
|
+
view: canvasView,
|
|
653
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
654
|
+
loadOp: 'clear',
|
|
655
|
+
storeOp: 'store',
|
|
656
|
+
}],
|
|
657
|
+
});
|
|
658
|
+
renderPassGrain.setPipeline(gpuGrainPipeline);
|
|
659
|
+
renderPassGrain.setBindGroup(0, grainBindGroup);
|
|
660
|
+
renderPassGrain.draw(3, 1, 0, 0);
|
|
661
|
+
renderPassGrain.end();
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
// No grain - simple copy to canvas
|
|
665
|
+
gpuDevice.queue.writeBuffer(gpuBlurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
|
|
666
|
+
const finalBindGroup = gpuDevice.createBindGroup({
|
|
667
|
+
layout: gpuBlurPipeline.getBindGroupLayout(0),
|
|
668
|
+
entries: [
|
|
669
|
+
{ binding: 0, resource: gpuSampler },
|
|
670
|
+
{ binding: 1, resource: intermediateView4 },
|
|
671
|
+
{ binding: 2, resource: { buffer: gpuBlurUniformBuffer } },
|
|
672
|
+
],
|
|
673
|
+
});
|
|
674
|
+
const renderPassFinal = commandEncoder.beginRenderPass({
|
|
675
|
+
colorAttachments: [{
|
|
676
|
+
view: canvasView,
|
|
677
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
678
|
+
loadOp: 'clear',
|
|
679
|
+
storeOp: 'store',
|
|
680
|
+
}],
|
|
681
|
+
});
|
|
682
|
+
renderPassFinal.setPipeline(gpuBlurPipeline);
|
|
683
|
+
renderPassFinal.setBindGroup(0, finalBindGroup);
|
|
684
|
+
renderPassFinal.draw(3, 1, 0, 0);
|
|
685
|
+
renderPassFinal.end();
|
|
686
|
+
}
|
|
687
|
+
gpuDevice.queue.submit([commandEncoder.finish()]);
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Check if WebGPU is initialized for canvas
|
|
692
|
+
*/
|
|
693
|
+
export function isWebGPUInitialized() {
|
|
694
|
+
return gpuDevice !== null && gpuContext !== null && gpuPipeline !== null;
|
|
695
|
+
}
|
|
696
|
+
/**
|
|
697
|
+
* Export image using WebGPU rendering at full resolution
|
|
698
|
+
* Creates an offscreen canvas and renders the final image with all adjustments
|
|
699
|
+
*/
|
|
700
|
+
export async function exportWithWebGPU(imageSource, adjustments, transform, cropArea = null, blurAreas = []) {
|
|
701
|
+
try {
|
|
702
|
+
// Get adapter and device
|
|
703
|
+
if (!navigator.gpu) {
|
|
704
|
+
console.warn('WebGPU not supported for export');
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
708
|
+
if (!adapter) {
|
|
709
|
+
console.warn('No WebGPU adapter for export');
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
const device = await adapter.requestDevice();
|
|
713
|
+
const format = navigator.gpu.getPreferredCanvasFormat();
|
|
714
|
+
// Calculate output dimensions based on crop and rotation
|
|
715
|
+
const sourceWidth = cropArea ? cropArea.width : imageSource.width;
|
|
716
|
+
const sourceHeight = cropArea ? cropArea.height : imageSource.height;
|
|
717
|
+
const needsSwap = transform.rotation === 90 || transform.rotation === 270;
|
|
718
|
+
const outputWidth = needsSwap ? sourceHeight : sourceWidth;
|
|
719
|
+
const outputHeight = needsSwap ? sourceWidth : sourceHeight;
|
|
720
|
+
// Create offscreen canvas at full resolution
|
|
721
|
+
const canvas = document.createElement('canvas');
|
|
722
|
+
canvas.width = outputWidth;
|
|
723
|
+
canvas.height = outputHeight;
|
|
724
|
+
const context = canvas.getContext('webgpu');
|
|
725
|
+
if (!context) {
|
|
726
|
+
console.warn('Failed to get WebGPU context for export');
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
context.configure({ device, format, alphaMode: 'premultiplied' });
|
|
730
|
+
// Upload image as texture
|
|
731
|
+
const bitmap = imageSource instanceof ImageBitmap
|
|
732
|
+
? imageSource
|
|
733
|
+
: await createImageBitmap(imageSource);
|
|
734
|
+
const texture = device.createTexture({
|
|
735
|
+
size: [bitmap.width, bitmap.height, 1],
|
|
736
|
+
format: 'rgba8unorm',
|
|
737
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
738
|
+
});
|
|
739
|
+
device.queue.copyExternalImageToTexture({ source: bitmap }, { texture }, [bitmap.width, bitmap.height]);
|
|
740
|
+
// Create sampler
|
|
741
|
+
const sampler = device.createSampler({
|
|
742
|
+
magFilter: 'linear',
|
|
743
|
+
minFilter: 'linear',
|
|
744
|
+
addressModeU: 'clamp-to-edge',
|
|
745
|
+
addressModeV: 'clamp-to-edge',
|
|
746
|
+
});
|
|
747
|
+
// Create pipelines
|
|
748
|
+
const mainShaderModule = device.createShaderModule({ code: SHADER_CODE });
|
|
749
|
+
const mainPipeline = device.createRenderPipeline({
|
|
750
|
+
layout: 'auto',
|
|
751
|
+
vertex: { module: mainShaderModule, entryPoint: 'vs_main' },
|
|
752
|
+
fragment: {
|
|
753
|
+
module: mainShaderModule,
|
|
754
|
+
entryPoint: 'fs_main',
|
|
755
|
+
targets: [{ format: 'rgba8unorm' }],
|
|
756
|
+
},
|
|
757
|
+
primitive: { topology: 'triangle-list' },
|
|
758
|
+
});
|
|
759
|
+
const blurShaderModule = device.createShaderModule({ code: BLUR_SHADER_CODE });
|
|
760
|
+
const blurPipeline = device.createRenderPipeline({
|
|
761
|
+
layout: 'auto',
|
|
762
|
+
vertex: { module: blurShaderModule, entryPoint: 'vs_main' },
|
|
763
|
+
fragment: {
|
|
764
|
+
module: blurShaderModule,
|
|
765
|
+
entryPoint: 'fs_main',
|
|
766
|
+
targets: [{ format: 'rgba8unorm' }],
|
|
767
|
+
},
|
|
768
|
+
primitive: { topology: 'triangle-list' },
|
|
769
|
+
});
|
|
770
|
+
const grainShaderModule = device.createShaderModule({ code: GRAIN_SHADER_CODE });
|
|
771
|
+
const grainPipeline = device.createRenderPipeline({
|
|
772
|
+
layout: 'auto',
|
|
773
|
+
vertex: { module: grainShaderModule, entryPoint: 'vs_main' },
|
|
774
|
+
fragment: {
|
|
775
|
+
module: grainShaderModule,
|
|
776
|
+
entryPoint: 'fs_main',
|
|
777
|
+
targets: [{ format }],
|
|
778
|
+
},
|
|
779
|
+
primitive: { topology: 'triangle-list' },
|
|
780
|
+
});
|
|
781
|
+
const compositeShaderModule = device.createShaderModule({ code: COMPOSITE_SHADER_CODE });
|
|
782
|
+
const compositePipeline = device.createRenderPipeline({
|
|
783
|
+
layout: 'auto',
|
|
784
|
+
vertex: { module: compositeShaderModule, entryPoint: 'vs_main' },
|
|
785
|
+
fragment: {
|
|
786
|
+
module: compositeShaderModule,
|
|
787
|
+
entryPoint: 'fs_main',
|
|
788
|
+
targets: [{ format: 'rgba8unorm' }],
|
|
789
|
+
},
|
|
790
|
+
primitive: { topology: 'triangle-list' },
|
|
791
|
+
});
|
|
792
|
+
// Create uniform buffers
|
|
793
|
+
const mainUniformBuffer = device.createBuffer({
|
|
794
|
+
size: 128,
|
|
795
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
796
|
+
});
|
|
797
|
+
const blurUniformBuffer = device.createBuffer({
|
|
798
|
+
size: 16,
|
|
799
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
800
|
+
});
|
|
801
|
+
const grainUniformBuffer = device.createBuffer({
|
|
802
|
+
size: 80,
|
|
803
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
804
|
+
});
|
|
805
|
+
// Create intermediate textures
|
|
806
|
+
const intermediate1 = device.createTexture({
|
|
807
|
+
size: [outputWidth, outputHeight, 1],
|
|
808
|
+
format: 'rgba8unorm',
|
|
809
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
810
|
+
});
|
|
811
|
+
const intermediate2 = device.createTexture({
|
|
812
|
+
size: [outputWidth, outputHeight, 1],
|
|
813
|
+
format: 'rgba8unorm',
|
|
814
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
815
|
+
});
|
|
816
|
+
const intermediate3 = device.createTexture({
|
|
817
|
+
size: [outputWidth, outputHeight, 1],
|
|
818
|
+
format: 'rgba8unorm',
|
|
819
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
820
|
+
});
|
|
821
|
+
const intermediate4 = device.createTexture({
|
|
822
|
+
size: [outputWidth, outputHeight, 1],
|
|
823
|
+
format: 'rgba8unorm',
|
|
824
|
+
usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.RENDER_ATTACHMENT,
|
|
825
|
+
});
|
|
826
|
+
// Setup viewport and transform for export (no zoom/pan, centered)
|
|
827
|
+
const viewport = {
|
|
828
|
+
zoom: 1.0,
|
|
829
|
+
offsetX: 0,
|
|
830
|
+
offsetY: 0,
|
|
831
|
+
scale: 1.0,
|
|
832
|
+
};
|
|
833
|
+
// Prepare uniforms (grain=0 for main pass)
|
|
834
|
+
const rotationRad = (transform.rotation * Math.PI) / 180;
|
|
835
|
+
const uniformData = new Float32Array([
|
|
836
|
+
// Adjustments (11 floats) - grain set to 0
|
|
837
|
+
adjustments.brightness, adjustments.contrast, adjustments.exposure,
|
|
838
|
+
adjustments.highlights, adjustments.shadows, adjustments.saturation,
|
|
839
|
+
adjustments.temperature, adjustments.sepia, adjustments.grayscale,
|
|
840
|
+
adjustments.vignette, 0, // grain = 0
|
|
841
|
+
// Viewport (4 floats)
|
|
842
|
+
viewport.zoom, viewport.offsetX, viewport.offsetY, viewport.scale,
|
|
843
|
+
// Transform (4 floats)
|
|
844
|
+
rotationRad,
|
|
845
|
+
transform.flipHorizontal ? -1.0 : 1.0,
|
|
846
|
+
transform.flipVertical ? -1.0 : 1.0,
|
|
847
|
+
transform.scale,
|
|
848
|
+
// Canvas dimensions (2 floats)
|
|
849
|
+
outputWidth, outputHeight,
|
|
850
|
+
// Image dimensions (2 floats)
|
|
851
|
+
bitmap.width, bitmap.height,
|
|
852
|
+
// Crop area (4 floats)
|
|
853
|
+
cropArea?.x ?? 0, cropArea?.y ?? 0,
|
|
854
|
+
cropArea?.width ?? 0, cropArea?.height ?? 0,
|
|
855
|
+
// Padding
|
|
856
|
+
0, 0, 0, 0, 0
|
|
857
|
+
]);
|
|
858
|
+
device.queue.writeBuffer(mainUniformBuffer, 0, uniformData);
|
|
859
|
+
// Create bind group for main pass
|
|
860
|
+
const mainBindGroup = device.createBindGroup({
|
|
861
|
+
layout: mainPipeline.getBindGroupLayout(0),
|
|
862
|
+
entries: [
|
|
863
|
+
{ binding: 0, resource: sampler },
|
|
864
|
+
{ binding: 1, resource: texture.createView() },
|
|
865
|
+
{ binding: 2, resource: { buffer: mainUniformBuffer } },
|
|
866
|
+
],
|
|
867
|
+
});
|
|
868
|
+
// Pass 1: Render with adjustments
|
|
869
|
+
let commandEncoder = device.createCommandEncoder();
|
|
870
|
+
const renderPass1 = commandEncoder.beginRenderPass({
|
|
871
|
+
colorAttachments: [{
|
|
872
|
+
view: intermediate1.createView(),
|
|
873
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
874
|
+
loadOp: 'clear',
|
|
875
|
+
storeOp: 'store',
|
|
876
|
+
}],
|
|
877
|
+
});
|
|
878
|
+
renderPass1.setPipeline(mainPipeline);
|
|
879
|
+
renderPass1.setBindGroup(0, mainBindGroup);
|
|
880
|
+
renderPass1.draw(3, 1, 0, 0);
|
|
881
|
+
renderPass1.end();
|
|
882
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
883
|
+
// Pass 2: Copy to accumulator
|
|
884
|
+
commandEncoder = device.createCommandEncoder();
|
|
885
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
|
|
886
|
+
const copyBindGroup = device.createBindGroup({
|
|
887
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
888
|
+
entries: [
|
|
889
|
+
{ binding: 0, resource: sampler },
|
|
890
|
+
{ binding: 1, resource: intermediate1.createView() },
|
|
891
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
892
|
+
],
|
|
893
|
+
});
|
|
894
|
+
const copyPass = commandEncoder.beginRenderPass({
|
|
895
|
+
colorAttachments: [{
|
|
896
|
+
view: intermediate4.createView(),
|
|
897
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
898
|
+
loadOp: 'clear',
|
|
899
|
+
storeOp: 'store',
|
|
900
|
+
}],
|
|
901
|
+
});
|
|
902
|
+
copyPass.setPipeline(blurPipeline);
|
|
903
|
+
copyPass.setBindGroup(0, copyBindGroup);
|
|
904
|
+
copyPass.draw(3, 1, 0, 0);
|
|
905
|
+
copyPass.end();
|
|
906
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
907
|
+
// Pass 3a: Apply global blur if needed
|
|
908
|
+
if (adjustments.blur > 0) {
|
|
909
|
+
const globalBlurRadius = Math.ceil((adjustments.blur / 100) * 10);
|
|
910
|
+
// Horizontal pass
|
|
911
|
+
commandEncoder = device.createCommandEncoder();
|
|
912
|
+
const blurUniformsH = new Float32Array([1.0, 0.0, globalBlurRadius, 0.0]);
|
|
913
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsH);
|
|
914
|
+
const blurBindGroupH = device.createBindGroup({
|
|
915
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
916
|
+
entries: [
|
|
917
|
+
{ binding: 0, resource: sampler },
|
|
918
|
+
{ binding: 1, resource: intermediate4.createView() },
|
|
919
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
920
|
+
],
|
|
921
|
+
});
|
|
922
|
+
const blurPassH = commandEncoder.beginRenderPass({
|
|
923
|
+
colorAttachments: [{
|
|
924
|
+
view: intermediate2.createView(),
|
|
925
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
926
|
+
loadOp: 'clear',
|
|
927
|
+
storeOp: 'store',
|
|
928
|
+
}],
|
|
929
|
+
});
|
|
930
|
+
blurPassH.setPipeline(blurPipeline);
|
|
931
|
+
blurPassH.setBindGroup(0, blurBindGroupH);
|
|
932
|
+
blurPassH.draw(3, 1, 0, 0);
|
|
933
|
+
blurPassH.end();
|
|
934
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
935
|
+
// Vertical pass
|
|
936
|
+
commandEncoder = device.createCommandEncoder();
|
|
937
|
+
const blurUniformsV = new Float32Array([0.0, 1.0, globalBlurRadius, 0.0]);
|
|
938
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsV);
|
|
939
|
+
const blurBindGroupV = device.createBindGroup({
|
|
940
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
941
|
+
entries: [
|
|
942
|
+
{ binding: 0, resource: sampler },
|
|
943
|
+
{ binding: 1, resource: intermediate2.createView() },
|
|
944
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
945
|
+
],
|
|
946
|
+
});
|
|
947
|
+
const blurPassV = commandEncoder.beginRenderPass({
|
|
948
|
+
colorAttachments: [{
|
|
949
|
+
view: intermediate4.createView(),
|
|
950
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
951
|
+
loadOp: 'clear',
|
|
952
|
+
storeOp: 'store',
|
|
953
|
+
}],
|
|
954
|
+
});
|
|
955
|
+
blurPassV.setPipeline(blurPipeline);
|
|
956
|
+
blurPassV.setBindGroup(0, blurBindGroupV);
|
|
957
|
+
blurPassV.draw(3, 1, 0, 0);
|
|
958
|
+
blurPassV.end();
|
|
959
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
960
|
+
}
|
|
961
|
+
// Pass 3b: Apply regional blur areas
|
|
962
|
+
if (blurAreas.length > 0) {
|
|
963
|
+
// Create composite uniform buffer for blur area compositing
|
|
964
|
+
const compositeUniformBuffer = device.createBuffer({
|
|
965
|
+
size: 16,
|
|
966
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
967
|
+
});
|
|
968
|
+
// For export, we use a centered viewport with no zoom/pan
|
|
969
|
+
// Image is rendered at full resolution, centered
|
|
970
|
+
const totalScale = viewport.scale * viewport.zoom * transform.scale; // All 1.0 for export
|
|
971
|
+
const centerX = outputWidth / 2;
|
|
972
|
+
const centerY = outputHeight / 2;
|
|
973
|
+
// Determine source dimensions based on crop
|
|
974
|
+
const sourceWidth = cropArea ? cropArea.width : bitmap.width;
|
|
975
|
+
const sourceHeight = cropArea ? cropArea.height : bitmap.height;
|
|
976
|
+
const cropOffsetX = cropArea ? cropArea.x : 0;
|
|
977
|
+
const cropOffsetY = cropArea ? cropArea.y : 0;
|
|
978
|
+
for (const blurArea of blurAreas) {
|
|
979
|
+
if (blurArea.blurStrength <= 0)
|
|
980
|
+
continue;
|
|
981
|
+
// Calculate blur radius to match Canvas2D behavior
|
|
982
|
+
// For export, totalScale = 1.0, so blur is in image pixels
|
|
983
|
+
const imageBlurPx = (blurArea.blurStrength / 100) * 100;
|
|
984
|
+
const blurRadius = Math.ceil(imageBlurPx * totalScale);
|
|
985
|
+
// Convert blur area from image space to canvas space, then to UV coordinates
|
|
986
|
+
// 1. Convert to crop-relative coordinates
|
|
987
|
+
const relativeX = blurArea.x - cropOffsetX;
|
|
988
|
+
const relativeY = blurArea.y - cropOffsetY;
|
|
989
|
+
// 2. Transform from image space to canvas space
|
|
990
|
+
// For export: viewport offset = 0, zoom = 1, scale = 1
|
|
991
|
+
const canvasBlurX = (relativeX - sourceWidth / 2) * totalScale + centerX + viewport.offsetX;
|
|
992
|
+
const canvasBlurY = (relativeY - sourceHeight / 2) * totalScale + centerY + viewport.offsetY;
|
|
993
|
+
const canvasBlurWidth = blurArea.width * totalScale;
|
|
994
|
+
const canvasBlurHeight = blurArea.height * totalScale;
|
|
995
|
+
// 3. Convert to normalized UV coordinates (0-1)
|
|
996
|
+
const minX = clamp(canvasBlurX / outputWidth, 0, 1);
|
|
997
|
+
const minY = clamp(canvasBlurY / outputHeight, 0, 1);
|
|
998
|
+
const maxX = clamp((canvasBlurX + canvasBlurWidth) / outputWidth, 0, 1);
|
|
999
|
+
const maxY = clamp((canvasBlurY + canvasBlurHeight) / outputHeight, 0, 1);
|
|
1000
|
+
// Horizontal blur: intermediate1 (base) -> intermediate2
|
|
1001
|
+
commandEncoder = device.createCommandEncoder();
|
|
1002
|
+
const blurUniformsH = new Float32Array([1.0, 0.0, blurRadius, 0.0]);
|
|
1003
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsH);
|
|
1004
|
+
const blurBindGroupH = device.createBindGroup({
|
|
1005
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
1006
|
+
entries: [
|
|
1007
|
+
{ binding: 0, resource: sampler },
|
|
1008
|
+
{ binding: 1, resource: intermediate1.createView() },
|
|
1009
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
1010
|
+
],
|
|
1011
|
+
});
|
|
1012
|
+
const blurPassH = commandEncoder.beginRenderPass({
|
|
1013
|
+
colorAttachments: [{
|
|
1014
|
+
view: intermediate2.createView(),
|
|
1015
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
1016
|
+
loadOp: 'clear',
|
|
1017
|
+
storeOp: 'store',
|
|
1018
|
+
}],
|
|
1019
|
+
});
|
|
1020
|
+
blurPassH.setPipeline(blurPipeline);
|
|
1021
|
+
blurPassH.setBindGroup(0, blurBindGroupH);
|
|
1022
|
+
blurPassH.draw(3, 1, 0, 0);
|
|
1023
|
+
blurPassH.end();
|
|
1024
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1025
|
+
// Vertical blur: intermediate2 -> intermediate3
|
|
1026
|
+
commandEncoder = device.createCommandEncoder();
|
|
1027
|
+
const blurUniformsV = new Float32Array([0.0, 1.0, blurRadius, 0.0]);
|
|
1028
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, blurUniformsV);
|
|
1029
|
+
const blurBindGroupV = device.createBindGroup({
|
|
1030
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
1031
|
+
entries: [
|
|
1032
|
+
{ binding: 0, resource: sampler },
|
|
1033
|
+
{ binding: 1, resource: intermediate2.createView() },
|
|
1034
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
1035
|
+
],
|
|
1036
|
+
});
|
|
1037
|
+
const blurPassV = commandEncoder.beginRenderPass({
|
|
1038
|
+
colorAttachments: [{
|
|
1039
|
+
view: intermediate3.createView(),
|
|
1040
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
1041
|
+
loadOp: 'clear',
|
|
1042
|
+
storeOp: 'store',
|
|
1043
|
+
}],
|
|
1044
|
+
});
|
|
1045
|
+
blurPassV.setPipeline(blurPipeline);
|
|
1046
|
+
blurPassV.setBindGroup(0, blurBindGroupV);
|
|
1047
|
+
blurPassV.draw(3, 1, 0, 0);
|
|
1048
|
+
blurPassV.end();
|
|
1049
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1050
|
+
// Composite: blend intermediate3 (blurred region) with intermediate4 (accumulator)
|
|
1051
|
+
commandEncoder = device.createCommandEncoder();
|
|
1052
|
+
const compositeUniforms = new Float32Array([minX, minY, maxX, maxY]);
|
|
1053
|
+
device.queue.writeBuffer(compositeUniformBuffer, 0, compositeUniforms);
|
|
1054
|
+
const compositeBindGroup = device.createBindGroup({
|
|
1055
|
+
layout: compositePipeline.getBindGroupLayout(0),
|
|
1056
|
+
entries: [
|
|
1057
|
+
{ binding: 0, resource: sampler },
|
|
1058
|
+
{ binding: 1, resource: intermediate3.createView() }, // blurred
|
|
1059
|
+
{ binding: 2, resource: intermediate4.createView() }, // accumulator
|
|
1060
|
+
{ binding: 3, resource: { buffer: compositeUniformBuffer } },
|
|
1061
|
+
],
|
|
1062
|
+
});
|
|
1063
|
+
const compositePass = commandEncoder.beginRenderPass({
|
|
1064
|
+
colorAttachments: [{
|
|
1065
|
+
view: intermediate2.createView(),
|
|
1066
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
1067
|
+
loadOp: 'clear',
|
|
1068
|
+
storeOp: 'store',
|
|
1069
|
+
}],
|
|
1070
|
+
});
|
|
1071
|
+
compositePass.setPipeline(compositePipeline);
|
|
1072
|
+
compositePass.setBindGroup(0, compositeBindGroup);
|
|
1073
|
+
compositePass.draw(3, 1, 0, 0);
|
|
1074
|
+
compositePass.end();
|
|
1075
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1076
|
+
// Copy back to accumulator: intermediate2 -> intermediate4
|
|
1077
|
+
commandEncoder = device.createCommandEncoder();
|
|
1078
|
+
device.queue.writeBuffer(blurUniformBuffer, 0, BLUR_UNIFORMS_ZERO);
|
|
1079
|
+
const copyBackBindGroup = device.createBindGroup({
|
|
1080
|
+
layout: blurPipeline.getBindGroupLayout(0),
|
|
1081
|
+
entries: [
|
|
1082
|
+
{ binding: 0, resource: sampler },
|
|
1083
|
+
{ binding: 1, resource: intermediate2.createView() },
|
|
1084
|
+
{ binding: 2, resource: { buffer: blurUniformBuffer } },
|
|
1085
|
+
],
|
|
1086
|
+
});
|
|
1087
|
+
const copyBackPass = commandEncoder.beginRenderPass({
|
|
1088
|
+
colorAttachments: [{
|
|
1089
|
+
view: intermediate4.createView(),
|
|
1090
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
1091
|
+
loadOp: 'clear',
|
|
1092
|
+
storeOp: 'store',
|
|
1093
|
+
}],
|
|
1094
|
+
});
|
|
1095
|
+
copyBackPass.setPipeline(blurPipeline);
|
|
1096
|
+
copyBackPass.setBindGroup(0, copyBackBindGroup);
|
|
1097
|
+
copyBackPass.draw(3, 1, 0, 0);
|
|
1098
|
+
copyBackPass.end();
|
|
1099
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1100
|
+
}
|
|
1101
|
+
// Cleanup composite buffer
|
|
1102
|
+
compositeUniformBuffer.destroy();
|
|
1103
|
+
}
|
|
1104
|
+
// Pass 4: Apply grain or copy to canvas
|
|
1105
|
+
// Note: Always use grain pipeline to render to canvas because it has the correct format
|
|
1106
|
+
// When grain is 0, the grain shader will just pass through the image
|
|
1107
|
+
commandEncoder = device.createCommandEncoder();
|
|
1108
|
+
const canvasView = context.getCurrentTexture().createView();
|
|
1109
|
+
const grainUniforms = new Float32Array([
|
|
1110
|
+
adjustments.grain, // Use actual grain value (0 if no grain)
|
|
1111
|
+
viewport.zoom, viewport.offsetX, viewport.offsetY, viewport.scale,
|
|
1112
|
+
rotationRad,
|
|
1113
|
+
transform.flipHorizontal ? -1.0 : 1.0,
|
|
1114
|
+
transform.flipVertical ? -1.0 : 1.0,
|
|
1115
|
+
transform.scale,
|
|
1116
|
+
outputWidth, outputHeight,
|
|
1117
|
+
bitmap.width, bitmap.height,
|
|
1118
|
+
cropArea?.x ?? 0, cropArea?.y ?? 0,
|
|
1119
|
+
cropArea?.width ?? 0, cropArea?.height ?? 0,
|
|
1120
|
+
0, 0, 0
|
|
1121
|
+
]);
|
|
1122
|
+
device.queue.writeBuffer(grainUniformBuffer, 0, grainUniforms);
|
|
1123
|
+
const grainBindGroup = device.createBindGroup({
|
|
1124
|
+
layout: grainPipeline.getBindGroupLayout(0),
|
|
1125
|
+
entries: [
|
|
1126
|
+
{ binding: 0, resource: sampler },
|
|
1127
|
+
{ binding: 1, resource: intermediate4.createView() },
|
|
1128
|
+
{ binding: 2, resource: { buffer: grainUniformBuffer } },
|
|
1129
|
+
],
|
|
1130
|
+
});
|
|
1131
|
+
const grainPass = commandEncoder.beginRenderPass({
|
|
1132
|
+
colorAttachments: [{
|
|
1133
|
+
view: canvasView,
|
|
1134
|
+
clearValue: { r: 0, g: 0, b: 0, a: 1 },
|
|
1135
|
+
loadOp: 'clear',
|
|
1136
|
+
storeOp: 'store',
|
|
1137
|
+
}],
|
|
1138
|
+
});
|
|
1139
|
+
grainPass.setPipeline(grainPipeline);
|
|
1140
|
+
grainPass.setBindGroup(0, grainBindGroup);
|
|
1141
|
+
grainPass.draw(3, 1, 0, 0);
|
|
1142
|
+
grainPass.end();
|
|
1143
|
+
device.queue.submit([commandEncoder.finish()]);
|
|
1144
|
+
// Wait for rendering to complete
|
|
1145
|
+
await device.queue.onSubmittedWorkDone();
|
|
1146
|
+
// Cleanup
|
|
1147
|
+
texture.destroy();
|
|
1148
|
+
intermediate1.destroy();
|
|
1149
|
+
intermediate2.destroy();
|
|
1150
|
+
intermediate3.destroy();
|
|
1151
|
+
intermediate4.destroy();
|
|
1152
|
+
mainUniformBuffer.destroy();
|
|
1153
|
+
blurUniformBuffer.destroy();
|
|
1154
|
+
grainUniformBuffer.destroy();
|
|
1155
|
+
return canvas;
|
|
1156
|
+
}
|
|
1157
|
+
catch (error) {
|
|
1158
|
+
console.error('Failed to export with WebGPU:', error);
|
|
1159
|
+
return null;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Cleanup WebGPU resources
|
|
1164
|
+
*/
|
|
1165
|
+
export function cleanupWebGPU() {
|
|
1166
|
+
gpuTexture?.destroy();
|
|
1167
|
+
gpuUniformBuffer?.destroy();
|
|
1168
|
+
gpuBlurUniformBuffer?.destroy();
|
|
1169
|
+
gpuCompositeUniformBuffer?.destroy();
|
|
1170
|
+
gpuGrainUniformBuffer?.destroy();
|
|
1171
|
+
gpuIntermediateTexture?.destroy();
|
|
1172
|
+
gpuIntermediateTexture2?.destroy();
|
|
1173
|
+
gpuIntermediateTexture3?.destroy();
|
|
1174
|
+
gpuIntermediateTexture4?.destroy();
|
|
1175
|
+
gpuDevice = null;
|
|
1176
|
+
gpuContext = null;
|
|
1177
|
+
gpuPipeline = null;
|
|
1178
|
+
gpuUniformBuffer = null;
|
|
1179
|
+
gpuSampler = null;
|
|
1180
|
+
gpuTexture = null;
|
|
1181
|
+
gpuBindGroup = null;
|
|
1182
|
+
gpuBlurPipeline = null;
|
|
1183
|
+
gpuBlurUniformBuffer = null;
|
|
1184
|
+
gpuCompositePipeline = null;
|
|
1185
|
+
gpuCompositeUniformBuffer = null;
|
|
1186
|
+
gpuGrainPipeline = null;
|
|
1187
|
+
gpuGrainUniformBuffer = null;
|
|
1188
|
+
gpuIntermediateTexture = null;
|
|
1189
|
+
gpuIntermediateTexture2 = null;
|
|
1190
|
+
gpuIntermediateTexture3 = null;
|
|
1191
|
+
gpuIntermediateTexture4 = null;
|
|
1192
|
+
}
|