diff --git a/src/core/evaluator.js b/src/core/evaluator.js index e5bfcd4771d4e..d0595ac7d93ec 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -105,6 +105,7 @@ const DefaultPartialEvaluatorOptions = Object.freeze({ iccUrl: null, standardFontDataUrl: null, wasmUrl: null, + prepareWebGPU: null, }); const PatternType = { @@ -1513,7 +1514,8 @@ class PartialEvaluator { resources, this._pdfFunctionFactory, this.globalColorSpaceCache, - localColorSpaceCache + localColorSpaceCache, + this.options.prepareWebGPU ); patternIR = shadingFill.getIR(); } catch (reason) { diff --git a/src/core/pattern.js b/src/core/pattern.js index 62f67618afe13..5974fded41cee 100644 --- a/src/core/pattern.js +++ b/src/core/pattern.js @@ -55,7 +55,8 @@ class Pattern { res, pdfFunctionFactory, globalColorSpaceCache, - localColorSpaceCache + localColorSpaceCache, + prepareWebGPU = null ) { const dict = shading instanceof BaseStream ? shading.dict : shading; const type = dict.get("ShadingType"); @@ -76,6 +77,7 @@ class Pattern { case ShadingType.LATTICE_FORM_MESH: case ShadingType.COONS_PATCH_MESH: case ShadingType.TENSOR_PATCH_MESH: + prepareWebGPU?.(); return new MeshShading( shading, xref, @@ -934,7 +936,7 @@ class MeshShading extends BaseShading { } _packData() { - let i, ii, j, jj; + let i, ii, j; const coords = this.coords; const coordsPacked = new Float32Array(coords.length * 2); @@ -945,25 +947,27 @@ class MeshShading extends BaseShading { } this.coords = coordsPacked; + // Stride 4 (RGBA layout, alpha unused) so the buffer maps directly to + // array in the WebGPU vertex shader without any repacking. const colors = this.colors; - const colorsPacked = new Uint8Array(colors.length * 3); + const colorsPacked = new Uint8Array(colors.length * 4); for (i = 0, j = 0, ii = colors.length; i < ii; i++) { const c = colors[i]; colorsPacked[j++] = c[0]; colorsPacked[j++] = c[1]; colorsPacked[j++] = c[2]; + j++; // alpha — unused, stays 0 } this.colors = colorsPacked; + // Store raw vertex indices (not byte offsets) so the GPU shader can + // address coords / colors without knowing their strides, and so the + // arrays are transferable Uint32Arrays. const figures = this.figures; for (i = 0, ii = figures.length; i < ii; i++) { - const figure = figures[i], - ps = figure.coords, - cs = figure.colors; - for (j = 0, jj = ps.length; j < jj; j++) { - ps[j] *= 2; - cs[j] *= 3; - } + const figure = figures[i]; + figure.coords = new Uint32Array(figure.coords); + figure.colors = new Uint32Array(figure.colors); } } diff --git a/src/core/pdf_manager.js b/src/core/pdf_manager.js index 83ea377bc47a2..82938dc0cb854 100644 --- a/src/core/pdf_manager.js +++ b/src/core/pdf_manager.js @@ -71,6 +71,20 @@ class BasePdfManager { FeatureTest.isOffscreenCanvasSupported; evaluatorOptions.isImageDecoderSupported &&= FeatureTest.isImageDecoderSupported; + + // Set up a one-shot callback so evaluators can notify the main thread that + // WebGPU-acceleratable content was found. The flag ensures the message is + // sent at most once per document. + if (evaluatorOptions.enableWebGPU) { + let prepareWebGPUSent = false; + evaluatorOptions.prepareWebGPU = () => { + if (!prepareWebGPUSent) { + prepareWebGPUSent = true; + handler.send("PrepareWebGPU", null); + } + }; + } + delete evaluatorOptions.enableWebGPU; this.evaluatorOptions = Object.freeze(evaluatorOptions); // Initialize image-options once per document. diff --git a/src/display/api.js b/src/display/api.js index de7b512d5422b..dcecc4855ca44 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -79,6 +79,7 @@ import { DOMFilterFactory } from "./filter_factory.js"; import { DOMStandardFontDataFactory } from "display-standard_fontdata_factory"; import { DOMWasmFactory } from "display-wasm_factory"; import { GlobalWorkerOptions } from "./worker_options.js"; +import { initWebGPUMesh } from "./webgpu_mesh.js"; import { Metadata } from "./metadata.js"; import { OptionalContentConfig } from "./optional_content_config.js"; import { PagesMapper } from "./pages_mapper.js"; @@ -347,6 +348,7 @@ function getDocument(src = {}) { ? NodeFilterFactory : DOMFilterFactory); const enableHWA = src.enableHWA === true; + const enableWebGPU = src.enableWebGPU === true; const useWasm = src.useWasm !== false; const pagesMapper = src.pagesMapper || new PagesMapper(); @@ -440,6 +442,7 @@ function getDocument(src = {}) { iccUrl, standardFontDataUrl, wasmUrl, + enableWebGPU, }, }; const transportParams = { @@ -2926,6 +2929,13 @@ class WorkerTransport { this.#onProgress(data); }); + messageHandler.on("PrepareWebGPU", () => { + if (this.destroyed) { + return; + } + initWebGPUMesh(); + }); + if (typeof PDFJSDev === "undefined" || !PDFJSDev.test("MOZCENTRAL")) { messageHandler.on("FetchBinaryData", async data => { if (this.destroyed) { diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index f66050d1bf495..6690b711b1f05 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -13,6 +13,7 @@ * limitations under the License. */ +import { drawMeshWithGPU, isWebGPUMeshReady } from "./webgpu_mesh.js"; import { FormatError, info, @@ -282,7 +283,7 @@ function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) { const bytes = data.data, rowSize = data.width * 4; let tmp; - if (coords[p1 + 1] > coords[p2 + 1]) { + if (coords[p1 * 2 + 1] > coords[p2 * 2 + 1]) { tmp = p1; p1 = p2; p2 = tmp; @@ -290,7 +291,7 @@ function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) { c1 = c2; c2 = tmp; } - if (coords[p2 + 1] > coords[p3 + 1]) { + if (coords[p2 * 2 + 1] > coords[p3 * 2 + 1]) { tmp = p2; p2 = p3; p3 = tmp; @@ -298,7 +299,7 @@ function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) { c2 = c3; c3 = tmp; } - if (coords[p1 + 1] > coords[p2 + 1]) { + if (coords[p1 * 2 + 1] > coords[p2 * 2 + 1]) { tmp = p1; p1 = p2; p2 = tmp; @@ -306,24 +307,24 @@ function drawTriangle(data, context, p1, p2, p3, c1, c2, c3) { c1 = c2; c2 = tmp; } - const x1 = (coords[p1] + context.offsetX) * context.scaleX; - const y1 = (coords[p1 + 1] + context.offsetY) * context.scaleY; - const x2 = (coords[p2] + context.offsetX) * context.scaleX; - const y2 = (coords[p2 + 1] + context.offsetY) * context.scaleY; - const x3 = (coords[p3] + context.offsetX) * context.scaleX; - const y3 = (coords[p3 + 1] + context.offsetY) * context.scaleY; + const x1 = (coords[p1 * 2] + context.offsetX) * context.scaleX; + const y1 = (coords[p1 * 2 + 1] + context.offsetY) * context.scaleY; + const x2 = (coords[p2 * 2] + context.offsetX) * context.scaleX; + const y2 = (coords[p2 * 2 + 1] + context.offsetY) * context.scaleY; + const x3 = (coords[p3 * 2] + context.offsetX) * context.scaleX; + const y3 = (coords[p3 * 2 + 1] + context.offsetY) * context.scaleY; if (y1 >= y3) { return; } - const c1r = colors[c1], - c1g = colors[c1 + 1], - c1b = colors[c1 + 2]; - const c2r = colors[c2], - c2g = colors[c2 + 1], - c2b = colors[c2 + 2]; - const c3r = colors[c3], - c3g = colors[c3 + 1], - c3b = colors[c3 + 2]; + const c1r = colors[c1 * 4], + c1g = colors[c1 * 4 + 1], + c1b = colors[c1 * 4 + 2]; + const c2r = colors[c2 * 4], + c2g = colors[c2 * 4 + 1], + c2b = colors[c2 * 4 + 2]; + const c3r = colors[c3 * 4], + c3g = colors[c3 * 4 + 1], + c3b = colors[c3 * 4 + 2]; const minY = Math.round(y1), maxY = Math.round(y3); @@ -494,26 +495,39 @@ class MeshShadingPattern extends BaseShadingPattern { paddedWidth, paddedHeight ); - const tmpCtx = tmpCanvas.context; - const data = tmpCtx.createImageData(width, height); - if (backgroundColor) { - const bytes = data.data; - for (let i = 0, ii = bytes.length; i < ii; i += 4) { - bytes[i] = backgroundColor[0]; - bytes[i + 1] = backgroundColor[1]; - bytes[i + 2] = backgroundColor[2]; - bytes[i + 3] = 255; + if (isWebGPUMeshReady()) { + tmpCanvas.context.drawImage( + drawMeshWithGPU( + this._figures, + context, + backgroundColor, + paddedWidth, + paddedHeight, + BORDER_SIZE + ), + 0, + 0 + ); + } else { + const data = tmpCanvas.context.createImageData(width, height); + if (backgroundColor) { + const bytes = data.data; + for (let i = 0, ii = bytes.length; i < ii; i += 4) { + bytes[i] = backgroundColor[0]; + bytes[i + 1] = backgroundColor[1]; + bytes[i + 2] = backgroundColor[2]; + bytes[i + 3] = 255; + } } + for (const figure of this._figures) { + drawFigure(data, figure, context); + } + tmpCanvas.context.putImageData(data, BORDER_SIZE, BORDER_SIZE); } - for (const figure of this._figures) { - drawFigure(data, figure, context); - } - tmpCtx.putImageData(data, BORDER_SIZE, BORDER_SIZE); - const canvas = tmpCanvas.canvas; return { - canvas, + canvas: tmpCanvas.canvas, offsetX: offsetX - BORDER_SIZE * scaleX, offsetY: offsetY - BORDER_SIZE * scaleY, scaleX, diff --git a/src/display/webgpu_mesh.js b/src/display/webgpu_mesh.js new file mode 100644 index 0000000000000..17a1ef8f63fc8 --- /dev/null +++ b/src/display/webgpu_mesh.js @@ -0,0 +1,382 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MeshFigureType } from "../shared/util.js"; + +// WGSL shader for Gouraud-shaded triangle mesh rasterization. +// Vertices arrive in PDF content-space coordinates; the vertex shader +// applies the affine transform supplied via a uniform buffer to map them +// to NDC (X: -1..1 left→right, Y: -1..1 bottom→top). +// Colors are delivered as unorm8x4 (r,g,b,_) and passed through as-is. +const WGSL = /* wgsl */ ` +struct Uniforms { + offsetX : f32, + offsetY : f32, + scaleX : f32, + scaleY : f32, + paddedWidth : f32, + paddedHeight : f32, + borderSize : f32, + _pad : f32, +}; + +@group(0) @binding(0) var u : Uniforms; + +struct VertexInput { + @location(0) position : vec2, + @location(1) color : vec4, +}; + +struct VertexOutput { + @builtin(position) position : vec4, + @location(0) color : vec3, +}; + +@vertex +fn vs_main(in : VertexInput) -> VertexOutput { + var out : VertexOutput; + let cx = (in.position.x + u.offsetX) * u.scaleX; + let cy = (in.position.y + u.offsetY) * u.scaleY; + out.position = vec4( + ((cx + u.borderSize) / u.paddedWidth) * 2.0 - 1.0, + 1.0 - ((cy + u.borderSize) / u.paddedHeight) * 2.0, + 0.0, + 1.0 + ); + out.color = in.color.rgb; + return out; +} + +@fragment +fn fs_main(in : VertexOutput) -> @location(0) vec4 { + return vec4(in.color, 1.0); +} +`; + +class WebGPUMesh { + #initPromise = null; + + #device = null; + + #pipeline = null; + + // Format chosen to match the OffscreenCanvas swapchain on this device. + #preferredFormat = null; + + async #initGPU() { + if (!globalThis.navigator?.gpu) { + return false; + } + try { + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + return false; + } + this.#preferredFormat = navigator.gpu.getPreferredCanvasFormat(); + const device = (this.#device = await adapter.requestDevice()); + const shaderModule = device.createShaderModule({ code: WGSL }); + + this.#pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { + module: shaderModule, + entryPoint: "vs_main", + buffers: [ + { + // Buffer 0: PDF content-space coords, 2 × float32 per vertex. + arrayStride: 2 * 4, + attributes: [ + { shaderLocation: 0, offset: 0, format: "float32x2" }, + ], + }, + { + // Buffer 1: vertex colors, 4 × unorm8 per vertex (r, g, b, _). + arrayStride: 4, + attributes: [ + { shaderLocation: 1, offset: 0, format: "unorm8x4" }, + ], + }, + ], + }, + fragment: { + module: shaderModule, + entryPoint: "fs_main", + // Use the canvas-preferred format so the OffscreenCanvas swapchain + // and the pipeline output format always agree. + targets: [{ format: this.#preferredFormat }], + }, + primitive: { topology: "triangle-list" }, + }); + + return true; + } catch { + return false; + } + } + + init() { + if (this.#initPromise === null) { + this.#initPromise = this.#initGPU(); + } + } + + get isReady() { + return this.#device !== null; + } + + /** + * Build flat Float32Array (positions) and Uint8Array (colors) vertex + * streams for non-indexed triangle-list rendering. + * + * Coords and colors intentionally use separate lookup indices. For patch + * mesh figures (types 6/7 converted to LATTICE in the worker), the coord + * index-space and color index-space differ because the stream interleaves + * them at different densities (12 coords but 4 colors per flag-0 patch). + * A single shared index buffer cannot represent both simultaneously, so we + * expand each triangle vertex individually into the two flat streams. + * + * @param {Array} figures + * @param {Object} context coords/colors/offsetX/offsetY/scaleX/scaleY + * @returns {{ posData: Float32Array, colData: Uint8Array, + * vertexCount: number }} + */ + #buildVertexStreams(figures, context) { + const { coords, colors } = context; + + // Count vertices first so we can allocate the typed arrays exactly once. + let vertexCount = 0; + for (const figure of figures) { + const ps = figure.coords; + if (figure.type === MeshFigureType.TRIANGLES) { + vertexCount += ps.length; + } else if (figure.type === MeshFigureType.LATTICE) { + const vpr = figure.verticesPerRow; + // 2 triangles × 3 vertices per quad cell + vertexCount += (Math.floor(ps.length / vpr) - 1) * (vpr - 1) * 6; + } + } + + // posData: 2 × float32 per vertex (raw PDF content-space x, y). + // colData: 4 × uint8 per vertex (r, g, b, unused — required by unorm8x4). + const posData = new Float32Array(vertexCount * 2); + const colData = new Uint8Array(vertexCount * 4); + let pOff = 0, + cOff = 0; + + // pi and ci are raw vertex indices; coords is stride-2, colors stride-4. + const addVertex = (pi, ci) => { + posData[pOff++] = coords[pi * 2]; + posData[pOff++] = coords[pi * 2 + 1]; + colData[cOff++] = colors[ci * 4]; + colData[cOff++] = colors[ci * 4 + 1]; + colData[cOff++] = colors[ci * 4 + 2]; + cOff++; // alpha channel — unused in the fragment shader + }; + + for (const figure of figures) { + const ps = figure.coords; + const cs = figure.colors; + if (figure.type === MeshFigureType.TRIANGLES) { + for (let i = 0, ii = ps.length; i < ii; i += 3) { + addVertex(ps[i], cs[i]); + addVertex(ps[i + 1], cs[i + 1]); + addVertex(ps[i + 2], cs[i + 2]); + } + } else if (figure.type === MeshFigureType.LATTICE) { + const vpr = figure.verticesPerRow; + const rows = Math.floor(ps.length / vpr) - 1; + const cols = vpr - 1; + for (let i = 0; i < rows; i++) { + let q = i * vpr; + for (let j = 0; j < cols; j++, q++) { + // Upper-left triangle: q, q+1, q+vpr + addVertex(ps[q], cs[q]); + addVertex(ps[q + 1], cs[q + 1]); + addVertex(ps[q + vpr], cs[q + vpr]); + // Lower-right triangle: q+vpr+1, q+1, q+vpr + addVertex(ps[q + vpr + 1], cs[q + vpr + 1]); + addVertex(ps[q + 1], cs[q + 1]); + addVertex(ps[q + vpr], cs[q + vpr]); + } + } + } + } + + return { posData, colData, vertexCount }; + } + + /** + * Render a mesh shading to an ImageBitmap using WebGPU. + * + * Two flat vertex streams (positions and colors) are uploaded from the + * packed IR typed arrays. A uniform buffer carries the affine transform + * so the vertex shader maps PDF content-space coordinates to NDC without + * any CPU arithmetic per vertex. + * + * After `device.queue.submit()`, `transferToImageBitmap()` presents the + * current GPU frame synchronously – the browser ensures all submitted GPU + * commands are complete before returning. The resulting ImageBitmap stays + * GPU-resident; `ctx2d.drawImage(bitmap)` is a zero-copy GPU-to-GPU blit. + * + * The GPU device must already be initialized (`this.isReady === true`). + * + * @param {Array} figures + * @param {Object} context coords/colors/offsetX/offsetY/… + * @param {Uint8Array|null} backgroundColor [r,g,b] or null for transparent + * @param {number} paddedWidth render-target width + * @param {number} paddedHeight render-target height + * @param {number} borderSize transparent border size in pixels + * @returns {ImageBitmap} + */ + draw( + figures, + context, + backgroundColor, + paddedWidth, + paddedHeight, + borderSize + ) { + const device = this.#device; + const { offsetX, offsetY, scaleX, scaleY } = context; + const { posData, colData, vertexCount } = this.#buildVertexStreams( + figures, + context + ); + + // Upload vertex positions (raw PDF coords) and colors as separate buffers. + // GPUBufferUsage requires size > 0. + const posBuffer = device.createBuffer({ + size: Math.max(posData.byteLength, 4), + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + if (posData.byteLength > 0) { + device.queue.writeBuffer(posBuffer, 0, posData); + } + + const colBuffer = device.createBuffer({ + size: Math.max(colData.byteLength, 4), + usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, + }); + if (colData.byteLength > 0) { + device.queue.writeBuffer(colBuffer, 0, colData); + } + + // Uniform buffer: affine transform parameters for the vertex shader. + const uniformBuffer = device.createBuffer({ + size: 8 * 4, // 8 × float32 = 32 bytes + usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST, + }); + device.queue.writeBuffer( + uniformBuffer, + 0, + new Float32Array([ + offsetX, + offsetY, + scaleX, + scaleY, + paddedWidth, + paddedHeight, + borderSize, + 0, // padding to 32 bytes + ]) + ); + + const bindGroup = device.createBindGroup({ + layout: this.#pipeline.getBindGroupLayout(0), + entries: [{ binding: 0, resource: { buffer: uniformBuffer } }], + }); + + // The canvas covers the full padded area so the border is naturally clear. + const offscreen = new OffscreenCanvas(paddedWidth, paddedHeight); + const gpuCtx = offscreen.getContext("webgpu"); + gpuCtx.configure({ + device, + format: this.#preferredFormat, + // "premultiplied" allows fully transparent border pixels when there is + // no backgroundColor; "opaque" is slightly more efficient otherwise. + alphaMode: backgroundColor ? "opaque" : "premultiplied", + }); + + const clearValue = backgroundColor + ? { + r: backgroundColor[0] / 255, + g: backgroundColor[1] / 255, + b: backgroundColor[2] / 255, + a: 1, + } + : { r: 0, g: 0, b: 0, a: 0 }; + + const commandEncoder = device.createCommandEncoder(); + const renderPass = commandEncoder.beginRenderPass({ + colorAttachments: [ + { + view: gpuCtx.getCurrentTexture().createView(), + clearValue, + loadOp: "clear", + storeOp: "store", + }, + ], + }); + if (vertexCount > 0) { + renderPass.setPipeline(this.#pipeline); + renderPass.setBindGroup(0, bindGroup); + renderPass.setVertexBuffer(0, posBuffer); + renderPass.setVertexBuffer(1, colBuffer); + renderPass.draw(vertexCount); + } + renderPass.end(); + + device.queue.submit([commandEncoder.finish()]); + posBuffer.destroy(); + colBuffer.destroy(); + uniformBuffer.destroy(); + + // Present the current GPU frame and capture it as an ImageBitmap. + // The browser flushes all pending GPU commands before returning, so this + // is synchronous from the JavaScript perspective. The ImageBitmap is + // GPU-resident; drawing it onto a 2D canvas is a GPU-to-GPU blit. + return offscreen.transferToImageBitmap(); + } +} + +const _webGPUMesh = new WebGPUMesh(); + +function initWebGPUMesh() { + _webGPUMesh.init(); +} + +function isWebGPUMeshReady() { + return _webGPUMesh.isReady; +} + +function drawMeshWithGPU( + figures, + context, + backgroundColor, + paddedWidth, + paddedHeight, + borderSize +) { + return _webGPUMesh.draw( + figures, + context, + backgroundColor, + paddedWidth, + paddedHeight, + borderSize + ); +} + +export { drawMeshWithGPU, initWebGPUMesh, isWebGPUMeshReady }; diff --git a/web/app_options.js b/web/app_options.js index 181b06eecdaae..313f6bb9c37ee 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -460,6 +460,11 @@ const defaultOptions = { value: typeof PDFJSDev !== "undefined" && !PDFJSDev.test("MOZCENTRAL"), kind: OptionKind.API + OptionKind.PREFERENCE, }, + enableWebGPU: { + /** @type {boolean} */ + value: true, + kind: OptionKind.API + OptionKind.PREFERENCE, + }, enableXfa: { /** @type {boolean} */ value: true,