From 86441e9eb8c7a7c135391a5c2bd34b50f659dc0a Mon Sep 17 00:00:00 2001 From: calixteman Date: Sun, 15 Mar 2026 13:02:03 +0100 Subject: [PATCH] Implement Gouraud-based shading using WebGPU. The WebGPU feature hasn't been released yet but it's interesting to see how we can use it in order to speed up the rendering of some objects. This patch allows to render mesh patterns using WebGPU. I didn't see any significant performance improvement on my machine (mac M2) but it may be different on other platforms. --- src/core/evaluator.js | 4 +- src/core/pattern.js | 24 ++- src/core/pdf_manager.js | 14 ++ src/display/api.js | 10 + src/display/pattern_helper.js | 80 ++++--- src/display/webgpu_mesh.js | 382 ++++++++++++++++++++++++++++++++++ web/app_options.js | 5 + 7 files changed, 475 insertions(+), 44 deletions(-) create mode 100644 src/display/webgpu_mesh.js diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 6ed0c0319beb3..ed64ecf3d8507 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,