Offload canvas drawing operations to a worker - #21860
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #21860 +/- ##
==========================================
- Coverage 90.26% 89.46% -0.80%
==========================================
Files 264 265 +1
Lines 67287 67618 +331
==========================================
- Hits 60738 60497 -241
- Misses 6549 7121 +572
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
| } | ||
|
|
||
| handleTransferFunction(tr) { | ||
| _getTransferFunctions(tr) { |
There was a problem hiding this comment.
Can't this method be actually private?
| _getTransferFunctions(tr) { | |
| #getTransferFunctions(tr) { |
| destroy(keepHCM = false) {} | ||
| } | ||
|
|
||
| class WorkerFilterFactory extends BaseFilterFactory {} |
There was a problem hiding this comment.
This should be placed after the DOMFilterFactory class.
| let objs = this.#objsMap.get(pageId); | ||
| if (!objs) { | ||
| objs = new PDFObjects(); | ||
| this.#objsMap.set(pageId, objs); | ||
| } | ||
| return objs; |
There was a problem hiding this comment.
| let objs = this.#objsMap.get(pageId); | |
| if (!objs) { | |
| objs = new PDFObjects(); | |
| this.#objsMap.set(pageId, objs); | |
| } | |
| return objs; | |
| return this.#objsMap.getOrInsertComputed(pageId, () => new PDFObjects()); |
| return false; | ||
| } | ||
|
|
||
| hasCanvasFilters(resources, nonCanvasFiltersSet) { |
There was a problem hiding this comment.
It seems really unfortunate to more-or-less duplicate the parsing in hasBlendModes, since this isn't exactly free given that it requires looking up Stream-instances (and those cannot be cached on the XRef-instance).
Could we somehow get https://bugzilla.mozilla.org/show_bug.cgi?id=2011237 prioritized, such that all of this new pre-parsing isn't necessary?
/cc @calixteman
There was a problem hiding this comment.
Yes it's a bit painful to have to do that...
| } | ||
|
|
||
| static async #sendFrame(handler, renderTaskState, isFinal) { | ||
| const { canvas, renderTaskId } = renderTaskState; |
There was a problem hiding this comment.
If transparency is true then cnavas.js::beginDrawing set a scratch canvas so all the intermediate canvases you send here will be blank.
There was a problem hiding this comment.
Fixed in ac59e14, the final frame should be unaffected since it's taken after endDrawing.
| } | ||
|
|
||
| static #cleanupPage(pageId) { | ||
| this.#cleanedPages.add(pageId); |
There was a problem hiding this comment.
I need to check to be sure, but I think it could be broken with a pdf where some pages have been removed. I'll have a look on that, but for a follow-up.
| // #maybeSendInterimFrame ensures that at most one frame is sent | ||
| // when nothing was painted in between. | ||
| await this.#maybeSendInterimFrame(handler, renderTaskState); | ||
| await promise; |
There was a problem hiding this comment.
The promise is already settled when we reach this await because gfx.executeOperatorList is sync and resolve has been called (as the continueCallback function).
And the 2 calls to #maybeSendInterimFrame are a bit strange and they can return before the async part, so maybe I'm wrong but the while loop could just unbreakable (at least not breakable with a renderTaskState.aborted).
| if (!renderTaskState) { | ||
| // A render task can be cleaned up before queued | ||
| // ExecuteOperatorList messages for that task are processed. | ||
| return { operatorListIdx }; |
There was a problem hiding this comment.
Isn't there a risk for spinning forever ?
There was a problem hiding this comment.
So, worker does not have the task state in the following cases:
- CleanupRenderTask, only happens when we cancel the rendering, so cancelled is already set.
- Final chunk: this is safe as well.
- InitializeGraphics fails: we fallback to main-thread rendering, so no new messages here.
- Both callsites of cleanupPage are safe as well.
Nothing else should delete the renderTaskState.
However, if we have a wrongly targeted page, then it might happen, which is the case here: https://github.com/mozilla/pdf.js/pull/21860/changes#r3904812097
But in any case, infinite retry problem for any case should be fixed by: bd758b0
| static #renderTaskId = 0; | ||
|
|
||
| static handleRenderFrame(frame) { | ||
| const internalTask = InternalRenderTask.#activeRenderTasks.get( |
There was a problem hiding this comment.
We don't close the annotationBitmaps. Should we ?
| return false; | ||
| } | ||
|
|
||
| hasCanvasFilters(resources, nonCanvasFiltersSet) { |
There was a problem hiding this comment.
What about annotations ?
There was a problem hiding this comment.
Well, hmm, how likely are we to encounter TR inside annotations, because then parsing/rendering is blocked on the annotations being both loaded and parsed before the page can render.
I skipped Type3 glyph streams too, because I don't expect us to encounter TR filters inside them? What do you think?
| if ( | ||
| !isLastShow && | ||
| this.minDurationToUpdateCanvas > 0 && | ||
| !this.renderTask?.isWorkerRendering |
There was a problem hiding this comment.
Probably it'd require to have:
#renderContinueCallback = cont => {
- this.#showCanvas?.(false);
+ // In the worker path the canvas only gains pixels in `onFrame`.
+ if (!this.renderTask?.isWorkerRendering) {
+ this.#showCanvas?.(false);
+ }
if (this.renderingQueue && !this.renderingQueue.isHighestPriority(this)) {| GlobalWorkerOptions.rendererSrc = new URL( | ||
| "./build/pdf.renderer.mjs", | ||
| import.meta.url | ||
| ).href; |
There was a problem hiding this comment.
Remove this, since this file is a mere helper which shouldn't unconditionally enable a (big) new feature that's still in active development.
| const { rendererHandler } = this; | ||
| if (!rendererHandler) { | ||
| throw new Error("Renderer worker was destroyed during rendering."); | ||
| } | ||
| const operatorListArgsArrayLen = operatorList.argsArray.length; | ||
| const sentLength = this._sentOperatorListLength; | ||
| const hasNewOps = sentLength < operatorListArgsArrayLen; | ||
| const fnArray = hasNewOps | ||
| ? operatorList.fnArray.slice(sentLength, operatorListArgsArrayLen) | ||
| : null; | ||
| const argsArray = hasNewOps | ||
| ? operatorList.argsArray.slice(sentLength, operatorListArgsArrayLen) | ||
| : null; | ||
| // Since operationsFilter is a function and cannot be structured-cloned, | ||
| // precomputing the results for the ops being sent as a mask that the | ||
| // worker can index into. | ||
| let operationsFilterMask = null; | ||
| if (fnArray && this._operationsFilter) { | ||
| operationsFilterMask = new Uint8Array(fnArray.length); | ||
| for (let i = 0, ii = fnArray.length; i < ii; i++) { | ||
| operationsFilterMask[i] = this._operationsFilter(sentLength + i) | ||
| ? 1 | ||
| : 0; | ||
| } | ||
| } | ||
| const sentLastChunk = operatorList.lastChunk; | ||
| const response = await rendererHandler.sendWithPromise( | ||
| "ExecuteOperatorList", | ||
| { | ||
| renderTaskId: this._renderTaskId, | ||
| fnArray, | ||
| argsArray, | ||
| operatorListIdx, | ||
| operationsFilterMask, | ||
| lastChunk: sentLastChunk, | ||
| } | ||
| ); | ||
| this.operatorListIdx = response.operatorListIdx; | ||
| // Only the final chunk carries `recordedBBoxes` / `imageCoordinates`. | ||
| if (response.recordedBBoxesBuffer) { | ||
| this.recordedBBoxes = BBoxReader.fromBuffer( | ||
| response.recordedBBoxesBuffer | ||
| ); | ||
| } | ||
| if (response.imageCoordinates) { | ||
| this.imageCoordinates = response.imageCoordinates; | ||
| } | ||
| this._sentOperatorListLength = operatorListArgsArrayLen; | ||
| if (this.cancelled) { | ||
| return; | ||
| } | ||
| if (response.aborted) { | ||
| throw new Error("Render task was aborted in the renderer worker."); | ||
| } | ||
|
|
||
| if (this.operatorListIdx === operatorList.argsArray.length) { | ||
| this.running = false; | ||
| if (sentLastChunk) { | ||
| InternalRenderTask.#activeRenderTasks.delete(this._renderTaskId); | ||
| InternalRenderTask.#canvasInUse.delete(this._canvas); | ||
| this.callback(); | ||
| } else if (this.operatorList.lastChunk) { | ||
| this._continue(); | ||
| } | ||
| } else { | ||
| this._continue(); | ||
| } |
There was a problem hiding this comment.
This seems like something that could be placed in its own private method, to improve readability.
| messageHandler.on("RenderFrame", frame => { | ||
| InternalRenderTask.handleRenderFrame(frame); | ||
| }); |
There was a problem hiding this comment.
It looks quite strange (or even wrong) to place this here, since at this point there's no guarantee that renderer-worker initialization will even succeed.
Perhaps doing this e.g. just after this.setupMessageHandler(); would make more sense?
There was a problem hiding this comment.
Moved it to this.setupMessageHandler();
Move the commonobj/obj resolution logic from WorkerTransport.setupMessageHandler into a reusable ObjectHandler class. This enables sharing object resolution between the main thread and other workers. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
This commit walks the page's resource tree, same as hasBlendModes, to detect the Transfer functions which are renderered with SVG filters, and the result is included in StartRenderPage data. The validation half of the handleTransferFunction is extracted into _getTransferFunctions so that it can be reused without building transfer maps. The negative results are cached in nonCanvasFilterSet. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Materializing a path currently stores the Path2D in argsArray[i][0], i.e. in the operator list's arguments. Cache it in a Map on the operator list instead, keyed by the operator index, so that the arguments stay structured-cloneable while re-renders of the same list still reuse the materialized paths. Nested operator-list executions share one CanvasGraphics, so executeOperatorList restores the previous cache when it returns. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Introduces a new RendererWorker class that spawns a dedicated worker intended for canvas rendering, mirroring PDFWorker. The worker is initialised via the new GlobalWorkerOptions.rendererSrc option. In this commit, the worker only performs the transfer handshake. getDocument creates one worker per document unless the new disableWorkerRendering option, an unset rendererSrc, or an unsupported environment disables it. A worker that fails to start is discarded with a warning. The rendererSrc is deliberately left unset in this commit so, no worker is spawned anywhere until worker rendering is enabled. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Fonts, images and other objects are forwarded to the renderer worker as they arrive from the core worker, before the main thread resolves them, and are kept in per-page stores keyed by the stable page id. Page and document cleanup is mirrored so that the worker's stores don't outlive their main-thread counterparts, and a forwarding failure rejects the object in the worker so that nothing ever waits on it. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
The renderer worker accepts a page's operator list in chunks with ExecuteOperatorList, appends them to a per-task list, and draws them into an OffscreenCanvas of its own that InitializeGraphics sets up, resolving object dependencies from the stores that the forwarding keeps filled. A rejected object aborts the render task instead of hanging it. The main thread keeps ownership of the destination canvas: the worker returns its finished drawing as an ImageBitmap in a one-way RenderFrame message and the main thread blits it. Renders that the worker cannot handle yet (a caller-supplied canvas context, a non-string background, annotation canvases, operation recording, pageColors, the pdfBug stepper) fall back to main-thread rendering. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Pages whose annotations render onto separate canvases no longer fall back to the main thread: the renderer worker creates the annotation canvases as OffscreenCanvases, flattens them into [id, name, bitmap] tuples alongside the final page frame, and the main thread rebuilds them as DOM canvases for the annotation layer. Named canvases replace any same-named entry from a previous render, exactly as beginAnnotation does on the main thread. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Renders that record operation bounding boxes or image locations, used for detail views and partial redraws, no longer fall back to the main thread. The renderer worker builds the bbox/dependency/images trackers itself and returns the recorded data with the final ExecuteOperatorList response; debug metadata stays main-thread only, since the pdfBug stepper disables worker rendering anyway. This commit is a part of the renderer-worker series, the worker rendering stays disabled until the final commit in this series.
Render pages in the renderer worker whenever the environment and the render parameters allow it, gated by the new disableWorkerRendering preference. Worker rendering is disabled by default in built targets and enabled for local development; the test suites opt in explicitly. Pages that use TR-based canvas filters keep rendering on the main thread, since OffscreenCanvasRenderingContext2D ignores `.filter` values set from a data URL (bug 2011237): the detection added earlier in this series is now reported through StartRenderPage and consumed by the render gate. The same holds for the pdfBug stepper, whose debug recording needs the graphics on the main thread, and for pageColors, which needs DOM-based SVG filters. Rendering a page that was moved or copied is covered by a new integration test, since the renderer worker's object stores are keyed by the stable page id. This commit is a part of the renderer-worker series.
While the renderer worker is executing a long operator list, snapshot the canvas at most every PARTIAL_FRAME_TIME milliseconds - at operator list chunk boundaries and when a chunk pauses on a dependency - and send the intermediate result to the main thread, so that long pages paint progressively instead of appearing all at once. Interim frames use createImageBitmap, since transferToImageBitmap would clear the canvas that is still being drawn into. The new RenderTask.onFrame callback fires after each frame, the final one included. The viewer opts in with the new partialFrames render parameter and skips its own temporary-canvas throttling for worker renders, since each frame handed back is already a complete, worker-throttled snapshot. This commit is a part of the renderer-worker series.
When the enableWebGPU option is set, the renderer worker initialises its own GPU device before creating its rendering surfaces, in the same way that getDocument starts the GPU initialisation on the main thread. This commit is a part of the renderer-worker series.
1e30152 to
fae5181
Compare
This follow-up aims to achieve the same goal as PR #20729 but with a slight change in the approach, as suggested by @calixteman, instead of transferring the page's canvas to the renderer worker with transferControlToOffscreen, the worker now draws into an OffscreenCanvas of its own and returns the result to the main thread as an ImageBitmap, so the main thread keeps ownership of the destination canvas.
For long-running pages the worker emits partial frames, while the operator list is executing, the canvas is snapshotted at most every 500ms (at chunk boundaries and dependency pauses), so pages paint progressively.
Returning bitmaps instead of taking over the canvas simplifies things quite a lot compared to the transferred-canvas approach.
transferControlToOffscreenwas irreversible and detached the canvas from the main thread, so every re-render needed a fresh canvas and a new transfer, the viewer had to track worker-owned canvases specially (thumbnails, detail views, print), falling back to main-thread rendering mid-document meant the original canvas was no longer usable, and the test harness needed workarounds for canvases it could no longer read back. With the bitmap approach the visible canvas stays an ordinary main-thread canvas at all times: the viewer, thumbnails, and fallback paths work unchanged, either rendering path can draw to the same canvas, the harness reverts to reading canvases directly etc.Worker rendering falls back to the main thread when the render can't be handled there: a non-string background (gradients/patterns don't structured-clone), pages using TR-based canvas filters (OffscreenCanvasRenderingContext2D ignores .filter values set from a data URL, bug 2011237), pageColors (needs DOM-based SVG filters), and the pdfBug stepper (needs the graphics on the main thread). Worker rendering is disabled by default in built targets via the new
disableWorkerRenderingpreference and enabled for local development; the test suites opt in explicitly.Also, rendering is disabled by default in the production viewer builds: the disableWorkerRendering preference defaults to true everywhere except the local dev viewer and the TESTING builds, so Firefox and the generic viewer keep rendering on the main thread until we explicitly flip the pref. The test suites opt in through GlobalWorkerOptions.rendererSrc directly, so all of this is still covered by CI, and API consumers can opt in the same way.