diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7cc41d334cd..f47b430a61b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,9 +36,13 @@ jobs: run: yarn build - name: Run tests + # Render tests run under a virtual display. Chromium's --enable-gpu stops headless forcing + # software rendering, but the driver autodetection it falls back to needs an X display on + # Linux - without one the WebGPU canvas is never composited and captures as a blank frame. + # WebGL is unaffected either way. See https://github.com/visgl/luma.gl/issues/2874 run: | yarn lint - yarn test-ci + xvfb-run -a --server-args="-screen 0 1280x1024x24" yarn test-ci - name: Upload render test failure images if: failure() diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md new file mode 100644 index 00000000000..ff3feb626f1 --- /dev/null +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -0,0 +1,357 @@ +# RFC: Analytic antialiasing for PathLayer and LineLayer + +- **Authors**: Chris Gervang +- **Date**: Aug 2, 2026 +- **Status**: Proposed — implemented in this PR + +Summary: `PathLayer` and `LineLayer` have no antialiasing of their own; their edges are smoothed +entirely by the framebuffer's MSAA. This RFC proposes an opt-in `antialiasing` prop that computes +edge coverage analytically in the fragment shader, for the situations where MSAA is unavailable. + +## At a glance + +Where deck.gl has multisampling today, and which remedy applies. "Host MSAA" is asking the base map +or canvas for `antialias: true`; "luma.gl#2741" is the proposed +[color-only MSAA for offscreen framebuffers](https://github.com/visgl/luma.gl/issues/2741); "this +prop" is `antialiasing: true`. + +| Situation | MSAA today | Host MSAA | luma.gl#2741 | This prop | Recommended | +| --- | --- | --- | --- | --- | --- | +| Standalone canvas | yes | on by default | — | optional | nothing needed | +| Standalone + `PostProcessEffect` ([#10404](https://github.com/visgl/deck.gl/issues/10404)) | **no** | no — bypassed | **yes** | yes | luma.gl#2741; this prop meanwhile | +| Interleaved MapLibre / Mapbox | **no** | **yes** | no | yes | either; this prop is cheaper at 4K | +| Interleaved Google Maps vector ([#7647](https://github.com/visgl/deck.gl/issues/7647)) | **no** | no option exposed | no | yes | **this prop — only avenue** | +| `@deck.gl/arcgis` | **no** | no | no — depth attachment | yes | **this prop — only avenue** | +| App-supplied `_framebuffer` | **no** | no | if color-only | yes | whichever fits the target | +| WebGPU, any target | **no** | no such attribute | no — deferred | yes | **this prop — only avenue** | +| `PathStyleExtension` offset ([#8063](https://github.com/visgl/deck.gl/issues/8063), [#9395](https://github.com/visgl/deck.gl/issues/9395)) | **no** — edge is a `discard` | no | no | partial | this prop; full fix needs an extension change | + +Three rows have no alternative at all, and one — post-processing — is better served by luma.gl#2741 +than by this proposal. The two efforts overlap only there; neither subsumes the other. + +## Background + +Both layers write a flat color per fragment. `path-layer-fragment.glsl.ts` ends in +`fragColor = vColor` with hard `discard`s at the joints; `line-layer-fragment.glsl.ts` is the same. +There is no coverage computation anywhere, so edge quality is inherited from the render target. + +For a plain standalone deck.gl canvas that is fine — luma passes context attributes straight through +and deck never sets `antialias`, so the browser default of `true` applies and MSAA smooths the +strokes. + +That fallback disappears in more places than it might seem, and they divide into three mechanisms. + +### 1. Externally-owned contexts + +deck does not choose the context attributes; the host application or SDK does. + +- **`@deck.gl/mapbox`, interleaved.** MapLibre GL JS and Mapbox GL JS both default + `canvasContextAttributes.antialias` to `false` as a performance optimization (verifiable in + maplibre-gl `src/ui/map.ts` — `defaultOptions.canvasContextAttributes`). The base map's own lines + stay crisp because MapLibre computes analytic coverage in `line.fragment.glsl`, scaled by + `1.0 / u_device_pixel_ratio`, so deck.gl strokes look conspicuously aliased directly against + smooth base map geometry. +- **`@deck.gl/google-maps`, interleaved.** deck attaches to the context handed to + `google.maps.WebGLOverlayView.onContextRestored`. Google's context attributes are not documented + and not determinable from deck's source, but the behaviour is established by report: + [#7647](https://github.com/visgl/deck.gl/issues/7647) shows vector maps unantialiased with + `interleaved: true` and antialiased with `interleaved: false`, confirmed by several users over + two years. Unlike MapLibre there is no option to request MSAA. + +### 2. Offscreen render targets + +Here MSAA is absent *unconditionally*, whatever the host context was created with, because luma's +WebGL backend has no multisample renderbuffer support — `device.createFramebuffer` always produces a +single-sample target. + +- **`@deck.gl/arcgis`.** Always renders into an auxiliary framebuffer (`_framebuffer`) and + composites it with a fullscreen quad, so deck content is never multisampled regardless of the + ArcGIS SDK's own context attributes. +- **Any application passing `_framebuffer`** to render into its own target. +- **Any application using a `PostProcessEffect`.** `DeckRenderer._preRender` redirects layer + rendering into `renderBuffers`, which are plain framebuffers, then blits to the target. This one + is easy to miss because it affects plain standalone deck.gl with a default canvas: measured on a + context created with `antialias: true`, adding a single effect takes a 2px diagonal from 1361 + partial-coverage pixels to **0**. + +### 3. WebGPU + +There is no `antialias` canvas attribute. MSAA requires an explicitly multisampled render target and +a matching pipeline `sampleCount`, and luma's WebGPU canvas context does not configure one — +`RenderPipelineParameters.sampleCount` defaults to `0` and `RenderBundle` only supports `1`. The +`path-layer.wgsl.ts` port inherits the missing coverage with no escape hatch at all. + +### Measured impact + +A 2px diagonal path rendered into a 240×180 context, counting pixels by alpha: + +| context | `antialiasing` | partial-coverage pixels | distinct alpha levels | +| --- | --- | --- | --- | +| no MSAA (base-map-like) | `false` | **0** | **0** | +| no MSAA (base-map-like) | `true` | 719 | 104 | +| MSAA canvas | `false` | 1361 | 3 | +| MSAA canvas + `PostProcessEffect` | `false` | **0** | **0** | + +Where the framebuffer provides no multisampling there is no antialiasing from any source — every +covered pixel is fully opaque and the edge is a hard staircase. + +The third row is included to show the honest comparison: where MSAA *is* genuinely available it does +most of the work, and analytic coverage is then a quality and cost improvement (continuous vs. +quantized to the sample count) rather than a fix. The fourth row shows how easily that row stops +applying — the canvas still has `antialias: true`, but a post-process effect has moved rasterization +off it. + +## Prior art + +This is long-standing and well-reported ground. The tracker history also shapes what this proposal +should and should not claim. + +**luma.gl is the canonical reference for the techniques themselves.** +[Antialiasing and Multisampling](https://luma.gl/docs/api-guide/gpu/gpu-antialiasing) maps artifacts +to remedies across both backends, and is where that taxonomy is being consolidated. It reaches the +same conclusion this proposal rests on: "for analytic shapes such as circles, lines, and +signed-distance-field text, shader-computed coverage with a smooth transition can be more precise +than postprocessing." The sections below cover only what is specific to deck.gl. + +**The established answer has been "turn on MSAA in the host."** In +[#5742](https://github.com/visgl/deck.gl/issues/5742) (2021, closed) the guidance was to construct +the `Map` with `antialias: true`, which resolved it for that reporter. That advice is still correct +where it applies, and this proposal does not replace it — see Alternatives. Two things have narrowed +it since: MapLibre v5 moved the option into `canvasContextAttributes`, so the top-level form quietly +does nothing on current versions, and it was never available for Google Maps, ArcGIS, offscreen +targets or WebGPU. + +**Google Maps interleaved has been unresolved for over two years.** +[#7647](https://github.com/visgl/deck.gl/issues/7647) (open since Feb 2023) reports exactly this +symptom on vector maps, with multiple independent confirmations through 2025 and no fix. Users' +only workaround is `interleaved: false`, which costs them interleaving and reportedly introduces +z-fighting. This answers empirically what deck's source cannot: the `WebGLOverlayView` context does +not provide multisampling, and unlike MapLibre there is no documented option to ask for it. That +makes an in-shader solution the only avenue there. + +**`PathStyleExtension` offset already breaks antialiasing.** +[#8063](https://github.com/visgl/deck.gl/issues/8063) (2023) and +[#9395](https://github.com/visgl/deck.gl/issues/9395) (2025) are both open. The mechanism is worth +stating because it is not obvious: the extension defines the stroke's visible edge with a `discard` +rather than with geometry, and `discard` kills every sample of a fragment, so MSAA cannot smooth +that edge at all — no context attribute will fix those two issues. Analytic coverage does improve +them, since it computes coverage in the shader instead of relying on the rasterizer. The improvement +is partial: the extension's discard still clips the outer half of the ramp, as recorded under +Limitations. A complete fix means turning that discard into a coverage term inside the extension. + +**Offscreen MSAA is being addressed separately, and is complementary rather than overlapping.** +[deck.gl#10404](https://github.com/visgl/deck.gl/issues/10404) tracks post-process effects losing +MSAA — independently reproduced for this RFC — and +[luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741) proposes color-only MSAA for offscreen +framebuffers with automatic resolve, superseding +[luma.gl#2702](https://github.com/visgl/luma.gl/issues/2702). Its initial scope is WebGL2, color +attachments only, with depth/stencil explicitly rejected alongside `samples > 1` — which is what +keeps it clear of ArcGIS, and its deferred WebGPU mapping is what keeps it clear of that backend. +Both efforts should land; see the matrix above for the split. + +### If luma.gl#2741 lands + +No part of this proposal is descoped by it. Interleaved base maps draw into the host's default +framebuffer rather than an offscreen target, and routing them through one would break the depth +interaction that interleaving exists for — the same reason post-process effects cannot be used in +interleaved mode. WebGPU is a deferred follow-up in that RFC. And the `PathStyleExtension` offset +edge is defined by a `discard`, which kills every sample of a fragment, so no sample count smooths +it. ArcGIS could move once multisampled depth is supported, since its framebuffer is depth-attached. + +What does change is post-processing. deck's render buffers pass only `colorAttachments` +(`DeckRenderer._prepareRenderBuffers`), and luma auto-creates a depth attachment only when both +attachment lists are empty, so they are genuinely color-only and fall squarely in that RFC's initial +scope. The better fix there is to set `samples` on them, closing +[#10404](https://github.com/visgl/deck.gl/issues/10404) for every layer rather than for these two. + +## Proposal + +Add an `antialiasing` prop to `PathLayer` and `LineLayer`, defaulting to `false`. + +```js +new PathLayer({ + // ... + antialiasing: true +}); +``` + +`TripsLayer` inherits it by subclassing `PathLayer`. `PolygonLayer` and `GeoJsonLayer` forward their +stroke props explicitly rather than by inheritance, so they expose it as `lineAntialiasing`, +following the existing `pointAntialiasing` precedent in `sub-layer-map.ts`. The prop name, +default-off ergonomics and documentation register follow the existing `ScatterplotLayer.antialiasing` +precedent. + +Defaulting to `false` keeps every existing render output byte-identical and leaves the choice with +applications that know whether their context has MSAA. + +## Design + +### Coverage from screen-space derivatives + +Both layers already carry a normalized silhouette coordinate as a varying: `vPathPosition.x` runs +`[-1, 1]` across the stroke width (with `length(vCornerOffset)` bounding rounded joints and caps), +and `LineLayer`'s `uv.y` runs `[-1, 1]`. The distance to the edge in those units is +`1.0 - abs(coord)`. + +Converting that to pixels is done by dividing by the coordinate's screen-space derivative: + +```glsl +float edgePixels = (1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6); +fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); +``` + +`fwidth` is the coordinate's rate of change per device pixel, so the result is a device-pixel +distance to the boundary, and the `+ 0.5` centers a one-pixel transition on the edge. + +The derivative approach was chosen over passing the stroke half-width down as a varying, which was +the first implementation and was wrong in two ways: + +- **Extensions that rescale the stroke.** `PathStyleExtension`'s `offset` inflates the width via + `DECKGL_FILTER_SIZE` and separately rescales `vPathPosition.x`, so a half-width varying read after + the filter overstates the band it addresses. The feather collapsed to `1/offsetWidth` of a pixel — + measured at 0.328× the un-offset feather for `getOffset: 1`, where `offsetWidth` is 3. +- **Perspective foreshortening.** A ground-plane path under pitch is narrower on screen than + `widthPixels`, so the feather came out too tight in any tilted view. + +Derivatives absorb both automatically, along with device pixel ratio. That last point also removes a +plumbing problem: the `project` shader module is registered for the vertex stage only, so +`project.devicePixelRatio` is not reachable from the fragment shader. The varying approach had to +fold DPR in at the vertex stage; the derivative approach needs nothing from the vertex stage at all, +and both vertex shaders are untouched by this change. + +### Width-only feathering + +Only the across-width silhouette is feathered. Consecutive `PathLayer` segment instances each draw +half of the shared joint and abut along the miter direction, so feathering along the path length +would leave a seam at every vertex. This is the same restriction MapLibre observes — its coverage is +purely a function of `v_normal`. + +## Alternatives considered + +**Enable MSAA on the base map.** In MapLibre v5 this is `canvasContextAttributes: {antialias: true}` +on the `Map` constructor. This works and is the right first move for an affected application, but it +multisamples the entire canvas — at a 3840×2160 CSS canvas with `devicePixelRatio: 2` that is a +7680×4320 multisampled buffer — and still quantizes coverage for sub-pixel strokes. It is also not +available on WebGPU. + +**FXAA or TAA post-processing.** luma.gl ships both (`fxaa`, `createTAAShaderPassPipeline`). Neither +fits. Both are full-screen passes, and deck routes those through `DeckRenderer._preRender/_postRender`, +which redirects layer rendering into an offscreen buffer and blits to the target. Interleaved mode +does the opposite — it draws directly into the base map's bound framebuffer, once per layer group, so +base map layers can depth-interact with deck layers. A post-process pass collapses that into a flat +composited quad and runs over a mostly-transparent buffer, where FXAA's luminance edge detection +misbehaves. Beyond the plumbing, FXAA operates on the already-rasterized image and cannot recover +coverage that was never captured, and TAA needs several frames to converge, which is wrong for +one-shot high-resolution export. + +**Offscreen MSAA in luma.gl.** Benefits every layer rather than these two, and is actively proposed +in [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741). It should land, and it is the +better fix for the post-processing case. It does not reach interleaved base maps, ArcGIS's +depth-attached framebuffer, or WebGPU — see Prior art for the breakdown. + +**Alpha-to-coverage.** The most interesting alternative, and the only one that could beat this +proposal on quality. Turning coverage into a sample mask rather than an alpha value would fix the two +Limitations below — per-sample masks compose without alpha blending's conflation artifact, so ends +and self-overlaps would stop needing special treatment — and it is the standard remedy for +`discard`-defined edges, which MSAA cannot touch at all. It is parked rather than adopted, for three +separate reasons: + +1. **It is a no-op on WebGL.** luma declares `sampleAlphaToCoverageEnabled` + (`core/src/adapter/types/parameters.ts`) but never maps it — + `webgl/src/adapter/converters/device-parameters.ts` handles neighbouring parameters and warns on + unsupported ones, and this one silently falls through. +2. **It is inert on WebGPU.** It does map there + (`webgpu/src/adapter/helpers/webgpu-parameters.ts` → `multisample.alphaToCoverageEnabled`), but + that requires `sampleCount > 1` and deck has no multisampled WebGPU target: the canvas has none, + and offscreen MSAA is luma.gl#2741 again. +3. **It collides with deck's blending.** The mask is derived from fragment alpha, and deck blends + with `SRC_ALPHA`, so both consume the same value and a translucent layer is counted twice. + Separating geometric coverage from object opacity needs an explicit sample mask — + `@builtin(sample_mask)` in WGSL, blocked by (2); in GLSL ES 3.00 it does not exist at all, and the + `OES_sample_variables` extension that supplies it is unused by luma. + +Worth revisiting once luma.gl#2741 lands, since that clears (2) and makes the WGSL path viable. + +## Limitations + +Both of the first two are conflation artifacts — consequences of expressing coverage as alpha and +compositing it — rather than anything specific to this design. Per-sample coverage avoids them; see +alpha-to-coverage under Alternatives for why that is not available yet. + +- **Flat caps.** The two ends of a path are not feathered, since that would require feathering along + the path length, and abutting segments would then seam. `capRounded: true` gets smoothed ends. + `LineLayer` ends are likewise unfeathered. +- **Self-overlap.** Where a path overlaps itself the blended edges composite twice, the same + trade-off `ScatterplotLayer.antialiasing` already documents. +- **`PathStyleExtension` offset.** The extension hard-`discard`s outside `|vPathPosition.x| > 1` + before layer code runs, clipping the outer half of the centered ramp — coverage reaches ~0.5 at the + boundary and then cuts. Measured at 0.730 of the un-offset feather, versus 0.328 before this + design. This improves [#8063](https://github.com/visgl/deck.gl/issues/8063) and + [#9395](https://github.com/visgl/deck.gl/issues/9395) without closing them; a complete fix means + turning that discard into a coverage term inside the extension. + +## Testing + +Two complementary tests. Every assertion below was verified by breaking the code it guards and +confirming the test fails. + +**Golden image** — `test/render/test-cases/path-antialiasing.spec.ts`. A golden diff can cover this, +but only with three changes to the render-test setup. Without any one of them the test passes with +the feature completely disabled, which is how the first attempt behaved: + +1. **A device created with `antialias: false`**, so MSAA is not doing the smoothing. The render test + canvas otherwise takes the browser default of `true`, which is the one condition where the prop + is redundant. `runRenderTestSuite` now accepts `webgl` context attributes. +2. **`includeAA: true` in the image diff.** This is the decisive one. pixelmatch detects + antialiased pixels and excludes them from the mismatch count by default, and this prop changes + nothing *but* antialiased pixels — so the diff is structurally blind to it regardless of MSAA or + geometry. `TestCase.imageDiffOptions` now threads it through, and also honours `tolerance`, which + was previously declared but ignored. +3. **A scene dense with thin shallow diagonals, and a tightened `threshold`.** The prop only changes + edge pixels, so edges must be a large enough fraction of the frame to register. + +With all three, disabling the feather drops the match to 99.06% against a 99.8% threshold. + +**Coverage assertions** — `test/render/path-antialiasing.spec.ts`. Creates its own `antialias: false` +device, reads back the framebuffer and asserts on coverage numerically, which catches things an +image diff cannot express: + +- Deleting the feather drops the antialiased pass from 719 partial pixels to 0. +- Restoring the previous varying-based implementation reproduces the 0.328 offset collapse. + +**Premultiplied alpha** — `test/render/webgpu-antialiasing.spec.ts`. WebGPU blends premultiplied, so +coverage has to reach alpha *before* `deckgl_premultiplied_alpha`; applying it after would leave RGB +too bright for its alpha, a light halo on every edge. The test renders on WebGPU into an offscreen +framebuffer and asserts red tracks alpha at partial-coverage pixels. Moving the multiply after +premultiplication takes the worst overshoot from 2 to 126. + +**WebGPU golden coverage** is asserted by hand rather than in CI. `test/render/test-cases/line-layer.spec.ts` wires +`antialiasing: deviceType === 'webgpu'` so one golden can serve both backends, but the `'webgpu'` +row stays commented out: the WebGPU canvas does not present under the headless software renderer CI +runs on, so the screenshot the suite diffs comes back blank whatever deck draws. Reading back an +offscreen framebuffer instead — which sidesteps canvas presentation — `LineLayer`, `PathLayer` and +`ScatterplotLayer` all paint on WebGPU, within a few pixels of their WebGL counts. Enable the row +once CI has hardware WebGPU. + +## Follow-ups + +- **`ScatterplotLayer` feather is not DPR-aware.** Its `SMOOTH_EDGE_RADIUS` is a fixed 0.5 CSS + pixels, so at `devicePixelRatio: 2` circles get a two-device-pixel feather. Aligning it with the + derivative approach used here would make it crisper, at the cost of changing its render baselines. +- **Other stroked layers.** `ArcLayer` and `SolidPolygonLayer` edges have the same gap and are not + covered by this change. +- **`PathStyleExtension` offset ramp clipping**, above — the remaining half of + [#8063](https://github.com/visgl/deck.gl/issues/8063) / + [#9395](https://github.com/visgl/deck.gl/issues/9395). Same underlying problem as the next item: a + `discard` that alpha cannot soften. +- **Revisit alpha-to-coverage** once [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741) + gives WebGPU a multisampled target, which unblocks the `@builtin(sample_mask)` route. That would + address the flat-cap and self-overlap limitations and the `discard`-defined edges together. Closing + the WebGL side additionally needs luma to map `sampleAlphaToCoverageEnabled`, which it currently + ignores. See Alternatives. +- **Set `samples` on the post-process render buffers** once + [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741) lands, closing + [#10404](https://github.com/visgl/deck.gl/issues/10404) for every layer. See Prior art for why + that is the only row this proposal cedes. +- **Consider defaulting to `true` in a major release**, once the trade-offs have been exercised in + the wild. diff --git a/docs/api-reference/arcgis/overview.md b/docs/api-reference/arcgis/overview.md index bd6446e4c23..0ba776b3946 100644 --- a/docs/api-reference/arcgis/overview.md +++ b/docs/api-reference/arcgis/overview.md @@ -64,3 +64,9 @@ Not supported features: - Multiple views - Controller - React integration + +### Antialiasing + +deck.gl renders into an auxiliary framebuffer here and composites the result into the ArcGIS scene. That framebuffer is not multisampled, so layers whose edges depend on MSAA — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — render with hard, aliased edges regardless of how the ArcGIS API created its context. + +Set `antialiasing: true` on those layers to have them compute edge coverage in the shader instead. On composite layers the prop is named `lineAntialiasing` ([GeoJsonLayer](../layers/geojson-layer.md#lineantialiasing), [PolygonLayer](../layers/polygon-layer.md#lineantialiasing)). diff --git a/docs/api-reference/core/post-process-effect.md b/docs/api-reference/core/post-process-effect.md index 484221acce5..66d4b5c2836 100644 --- a/docs/api-reference/core/post-process-effect.md +++ b/docs/api-reference/core/post-process-effect.md @@ -43,6 +43,14 @@ const deckgl = new Deck({ }); ``` +## Remarks + +### Antialiasing + +Adding a post-processing effect redirects layer rendering into an offscreen framebuffer, which is not multisampled. Layers whose edges depend on the canvas' MSAA — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — therefore render with hard, aliased edges once any effect is added, even though the canvas itself was created with `antialias: true`. See [#10404](https://github.com/visgl/deck.gl/issues/10404). + +Set `antialiasing: true` on those layers to have them compute edge coverage in the shader instead. On composite layers the prop is named `lineAntialiasing` ([GeoJsonLayer](../layers/geojson-layer.md#lineantialiasing), [PolygonLayer](../layers/polygon-layer.md#lineantialiasing)). + ## Source [/modules/core/src/effects/post-process-effect.ts](https://github.com/visgl/deck.gl/tree/master/modules/core/src/effects/post-process-effect.ts) diff --git a/docs/api-reference/google-maps/google-maps-overlay.md b/docs/api-reference/google-maps/google-maps-overlay.md index 4e70ed5df92..be1a1d33279 100644 --- a/docs/api-reference/google-maps/google-maps-overlay.md +++ b/docs/api-reference/google-maps/google-maps-overlay.md @@ -119,6 +119,14 @@ The constructor additionally accepts the following option: - `interleaved` (boolean) - When set to `false`, a dedicated deck.gl canvas is layered on top of the base map. If set to `true` and the Google Map is configured for Vector rendering, deck.gl layers are inserted into the Google Maps layer stack, sharing the same WebGL2RenderingContext. Default is `true`. +## Remarks + +### Antialiasing + +With `interleaved: true` on a Vector map, deck.gl shares the WebGL context created by Google Maps, which does not provide multisampling and exposes no option to request it. Layers whose edges depend on it — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — render with hard, aliased edges. See [#7647](https://github.com/visgl/deck.gl/issues/7647). + +Set `antialiasing: true` on those layers to have them compute edge coverage in the shader instead. On composite layers the prop is named `lineAntialiasing` ([GeoJsonLayer](../layers/geojson-layer.md#lineantialiasing), [PolygonLayer](../layers/polygon-layer.md#lineantialiasing)). + ## Methods #### `setMap` {#setmap} diff --git a/docs/api-reference/layers/geojson-layer.md b/docs/api-reference/layers/geojson-layer.md index 5a076a19abf..a114dbe947f 100644 --- a/docs/api-reference/layers/geojson-layer.md +++ b/docs/api-reference/layers/geojson-layer.md @@ -311,6 +311,12 @@ Type of line joint. If `true`, draw round joints. Otherwise draw miter joints. The maximum extent of a joint in ratio to the stroke width. Only works if `lineJointRounded` is `false`. +#### `lineAntialiasing` (boolean, optional) {#lineantialiasing} + +* Default: `false` + +If `true`, lines are rendered with smoothed edges. If `false`, they are rendered with rough edges. Antialiasing can cause artifacts where a line overlaps itself. Forwarded to the underlying [PathLayer](./path-layer.md#antialiasing). + #### `lineBillboard` (boolean, optional) {#linebillboard} * Default: `false` diff --git a/docs/api-reference/layers/line-layer.md b/docs/api-reference/layers/line-layer.md index 7217e44a87d..437b52e0d5f 100644 --- a/docs/api-reference/layers/line-layer.md +++ b/docs/api-reference/layers/line-layer.md @@ -196,6 +196,14 @@ The minimum line width in pixels. This prop can be used to prevent the line from The maximum line width in pixels. This prop can be used to prevent the line from getting to thick when zoomed in. +#### `antialiasing` (boolean, optional) {#antialiasing} + +* Default: `false` + +If `true`, lines are rendered with smoothed edges. If `false`, lines are rendered with rough edges. Antialiasing can cause artifacts where lines overlap. Only the edges along the width of the line are smoothed — the two ends are not. + +This computes coverage in the shader, which is one of several antialiasing techniques with different trade-offs. See [Antialiasing and Multisampling](https://luma.gl/docs/api-guide/gpu/gpu-antialiasing) in the luma.gl docs for choosing between them. + ### Data Accessors diff --git a/docs/api-reference/layers/path-layer.md b/docs/api-reference/layers/path-layer.md index 1d8c94692af..89160180a19 100644 --- a/docs/api-reference/layers/path-layer.md +++ b/docs/api-reference/layers/path-layer.md @@ -217,6 +217,14 @@ If `false`, the width always faces up. The maximum extent of a joint in ratio to the stroke width. Only works if `jointRounded` is `false`. +#### `antialiasing` (boolean, optional) {#antialiasing} + +* Default: `false` + +If `true`, paths are rendered with smoothed edges. If `false`, paths are rendered with rough edges. Antialiasing can cause artifacts where a path overlaps itself. Only the edges along the width of the path are smoothed — flat caps at the two ends of a path are not; set `capRounded` to `true` if those need smoothing. + +This computes coverage in the shader, which is one of several antialiasing techniques with different trade-offs. See [Antialiasing and Multisampling](https://luma.gl/docs/api-guide/gpu/gpu-antialiasing) in the luma.gl docs for choosing between them. + #### `_pathType` (object, optional) {#_pathtype} * Default: `null` diff --git a/docs/api-reference/layers/polygon-layer.md b/docs/api-reference/layers/polygon-layer.md index bdac3c3a606..c9e5603c835 100644 --- a/docs/api-reference/layers/polygon-layer.md +++ b/docs/api-reference/layers/polygon-layer.md @@ -245,6 +245,12 @@ Type of joint. If `true`, draw round joints. Otherwise draw miter joints. The maximum extent of a joint in ratio to the stroke width. Only works if `lineJointRounded` is `false`. +#### `lineAntialiasing` (boolean, optional) {#lineantialiasing} + +* Default: `false` + +If `true`, the stroke is rendered with smoothed edges. If `false`, it is rendered with rough edges. Antialiasing can cause artifacts where the stroke overlaps itself. Forwarded to the underlying [PathLayer](./path-layer.md#antialiasing). + #### `material` (Material, optional) {#material} * Default: `true` diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index c5fca987120..9de8395e2aa 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -165,6 +165,10 @@ See [Deck.getCanvas](../core/deck.md#getcanvas). When using `interleaved: true`, ## Remarks +### Antialiasing + +Base maps create their WebGL context with `antialias: false`, so in interleaved mode deck.gl layers receive no multisampling. Layers that rely on it — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — will look aliased against the base map. Set `antialiasing: true` on those layers, or enable MSAA on the base map itself. + ### Multi-view usage When using `MapboxOverlay` with multiple views passed to the `views` prop, only one of the views can match the base map and receive interaction. diff --git a/modules/core/src/shaderlib/project/project.wgsl.ts b/modules/core/src/shaderlib/project/project.wgsl.ts index db22f291b34..c4592391e32 100644 --- a/modules/core/src/shaderlib/project/project.wgsl.ts +++ b/modules/core/src/shaderlib/project/project.wgsl.ts @@ -280,7 +280,10 @@ fn project_position_vec2_f32(position: vec2) -> vec2 { // Transforms a common space position to clip space. fn project_common_position_to_clipspace_with_projection(position: vec4, viewProjectionMatrix: mat4x4, center: vec4) -> vec4 { - return viewProjectionMatrix * position + center; + let clipPosition = viewProjectionMatrix * position + center; + // Viewports build WebGL-convention matrices, whose clip volume is -w <= z <= w. WebGPU's is + // 0 <= z <= w, so remap depth here - without it the near half of the range is clipped away. + return vec4(clipPosition.xy, (clipPosition.z + clipPosition.w) * 0.5, clipPosition.w); } // Uses the project viewProjectionMatrix and center. diff --git a/modules/layers/src/geojson-layer/geojson-layer.ts b/modules/layers/src/geojson-layer/geojson-layer.ts index 8d31c9da825..39c4396a855 100644 --- a/modules/layers/src/geojson-layer/geojson-layer.ts +++ b/modules/layers/src/geojson-layer/geojson-layer.ts @@ -165,6 +165,15 @@ type _GeoJsonLayerStrokeProps = { */ lineCapRounded?: boolean; + /** + * If `true`, lines are rendered with smoothed edges. If `false`, lines are rendered with rough + * edges. Antialiasing can cause artifacts where a line overlaps itself. Only the edges along the + * width of the line are smoothed - flat caps at the two ends are not. + * + * @default false + */ + lineAntialiasing?: boolean; + /** * If `true`, extrude the line in screen space (width always faces the camera). * If `false`, the width always faces up. diff --git a/modules/layers/src/geojson-layer/sub-layer-map.ts b/modules/layers/src/geojson-layer/sub-layer-map.ts index 08aaa79dd7f..4ac19c87953 100644 --- a/modules/layers/src/geojson-layer/sub-layer-map.ts +++ b/modules/layers/src/geojson-layer/sub-layer-map.ts @@ -100,6 +100,7 @@ export const LINE_LAYER = { lineCapRounded: 'capRounded', lineMiterLimit: 'miterLimit', lineBillboard: 'billboard', + lineAntialiasing: 'antialiasing', getLineColor: 'getColor', getLineWidth: 'getWidth' diff --git a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts index f6e366dd09b..09e40ea352e 100644 --- a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts @@ -18,6 +18,14 @@ void main(void) { fragColor = vColor; + if (line.antialiasing) { + // Feather one device pixel across the width, from the derivative of uv.y. The ends are left + // hard - they abut neighboring segments. See dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md + float edgeCoord = abs(uv.y); + float edgePixels = (1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6); + fragColor.a *= smoothedge(0.0, edgePixels); + } + DECKGL_FILTER_COLOR(fragColor, geometry); } `; diff --git a/modules/layers/src/line-layer/line-layer-uniforms.ts b/modules/layers/src/line-layer/line-layer-uniforms.ts index 80405b91e4e..c1155f66e58 100644 --- a/modules/layers/src/line-layer/line-layer-uniforms.ts +++ b/modules/layers/src/line-layer/line-layer-uniforms.ts @@ -10,6 +10,7 @@ layout(std140) uniform lineUniforms { float widthMinPixels; float widthMaxPixels; float useShortestPath; + bool antialiasing; highp int widthUnits; } line; `; @@ -19,6 +20,7 @@ export type LineProps = { widthMinPixels: number; widthMaxPixels: number; useShortestPath: number; + antialiasing: boolean; widthUnits: number; }; @@ -32,6 +34,7 @@ export const lineUniforms = { widthMinPixels: 'f32', widthMaxPixels: 'f32', useShortestPath: 'f32', + antialiasing: 'f32', widthUnits: 'i32' } } as const satisfies ShaderModule; diff --git a/modules/layers/src/line-layer/line-layer.ts b/modules/layers/src/line-layer/line-layer.ts index 48cabff89ac..9f13bbcb5e5 100644 --- a/modules/layers/src/line-layer/line-layer.ts +++ b/modules/layers/src/line-layer/line-layer.ts @@ -35,7 +35,8 @@ const defaultProps: DefaultProps = { widthUnits: 'pixels', widthScale: {type: 'number', value: 1, min: 0}, widthMinPixels: {type: 'number', value: 0, min: 0}, - widthMaxPixels: {type: 'number', value: Number.MAX_SAFE_INTEGER, min: 0} + widthMaxPixels: {type: 'number', value: Number.MAX_SAFE_INTEGER, min: 0}, + antialiasing: false }; /** All properties supported by LineLayer. */ @@ -68,6 +69,14 @@ type _LineLayerProps = { */ widthMaxPixels?: number; + /** + * If `true`, lines are rendered with smoothed edges. If `false`, lines are rendered with rough + * edges. Antialiasing can cause artifacts where lines overlap. Only the edges along the width of + * the line are smoothed - the two ends are not. + * @default false + */ + antialiasing?: boolean; + /** * Source position of each object. * @default object => object.sourcePosition @@ -169,13 +178,15 @@ export default class LineLayer extends } draw({uniforms}): void { - const {widthUnits, widthScale, widthMinPixels, widthMaxPixels, wrapLongitude} = this.props; + const {widthUnits, widthScale, widthMinPixels, widthMaxPixels, wrapLongitude, antialiasing} = + this.props; const model = this.state.model!; const lineProps: LineProps = { widthUnits: UNIT[widthUnits], widthScale, widthMinPixels, widthMaxPixels, + antialiasing, useShortestPath: wrapLongitude ? 1 : 0 }; model.shaderInputs.setProps({line: lineProps}); diff --git a/modules/layers/src/line-layer/line-layer.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts index 1e397d9b2b0..23d959d4280 100644 --- a/modules/layers/src/line-layer/line-layer.wgsl.ts +++ b/modules/layers/src/line-layer/line-layer.wgsl.ts @@ -42,6 +42,7 @@ struct LineUniforms { widthMinPixels: f32, widthMaxPixels: f32, useShortestPath: f32, + antialiasing: f32, widthUnits: i32, }; @@ -161,6 +162,17 @@ fn fragmentMain( // Start with the input color. var fragColor: vec4 = vColor; + // Distance to the edge in device pixels, from the derivative of uv.y. Taken in uniform control + // flow, ahead of the picking discard below + let edgeCoord = abs(uv.y); + let edgePixels = (1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6); + + if (line.antialiasing != 0.0) { + // Feather one device pixel across the width, before premultiplication below. The ends are left + // hard - they abut neighbors + fragColor.a *= smoothedge(0.0, edgePixels); + } + if (picking.isActive > 0.5) { if (!picking_isColorValid(pickingColor)) { discard; diff --git a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts index 986c944d4f5..93a56d527c3 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -25,18 +25,41 @@ out vec4 fragColor; void main(void) { geometry.uv = vPathPosition; - if (vPathPosition.y < 0.0 || vPathPosition.y > vPathLength) { + bool isCorner = vPathPosition.y < 0.0 || vPathPosition.y > vPathLength; + bool isRound = vJointType > 0.5; + + // Distance to the silhouette in device pixels, from the derivative of the coordinate that + // bounds it. Computed before the discards below: derivatives are undefined once an invocation + // in the quad has been discarded. See dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md + float edgePixels = 0.0; + if (path.antialiasing) { + float bodyCoord = abs(vPathPosition.x); + float cornerCoord = length(vCornerOffset); + // Both evaluated so each derivative stays on one field across the corner/body boundary + float bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + float cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); + edgePixels = isRound && isCorner ? cornerPixels : bodyPixels; + } + + if (isCorner) { // if joint is rounded, test distance from the corner - if (vJointType > 0.5 && length(vCornerOffset) > 1.0) { + if (isRound && length(vCornerOffset) > 1.0) { discard; } // trim miter - if (vJointType < 0.5 && vMiterLength > path.miterLimit + 1.0) { + if (!isRound && vMiterLength > path.miterLimit + 1.0) { discard; } } fragColor = vColor; + if (path.antialiasing) { + // Feather one device pixel across the width only - segments abut lengthwise, which would seam. + // edgePixels is a signed device-pixel distance, and SMOOTH_EDGE_RADIUS is 0.5, so smoothedge + // ramps across exactly one pixel centered on the edge. + fragColor.a *= smoothedge(0.0, edgePixels); + } + DECKGL_FILTER_COLOR(fragColor, geometry); } `; diff --git a/modules/layers/src/path-layer/path-layer-uniforms.ts b/modules/layers/src/path-layer/path-layer-uniforms.ts index 6cf1a8a5153..4d658eef09d 100644 --- a/modules/layers/src/path-layer/path-layer-uniforms.ts +++ b/modules/layers/src/path-layer/path-layer-uniforms.ts @@ -12,6 +12,7 @@ struct PathUniforms { jointType: f32, capType: f32, miterLimit: f32, + antialiasing: f32, billboard: f32, widthUnits: i32, }; @@ -28,6 +29,7 @@ layout(std140) uniform pathUniforms { float jointType; float capType; float miterLimit; + bool antialiasing; bool billboard; highp int widthUnits; } path; @@ -40,6 +42,7 @@ export type PathProps = { jointType: number; capType: number; miterLimit: number; + antialiasing: boolean; billboard: boolean; widthUnits: number; }; @@ -56,6 +59,7 @@ export const pathUniforms = { jointType: 'f32', capType: 'f32', miterLimit: 'f32', + antialiasing: 'f32', billboard: 'f32', widthUnits: 'i32' } diff --git a/modules/layers/src/path-layer/path-layer.ts b/modules/layers/src/path-layer/path-layer.ts index e29acd2a9ee..e8b2b7c8814 100644 --- a/modules/layers/src/path-layer/path-layer.ts +++ b/modules/layers/src/path-layer/path-layer.ts @@ -62,6 +62,13 @@ type _PathLayerProps = { * @default 4 */ miterLimit?: number; + /** + * If `true`, paths are rendered with smoothed edges. If `false`, paths are rendered with rough + * edges. Antialiasing can cause artifacts where a path overlaps itself. Only the edges along the + * width of the path are smoothed - flat caps at the two ends of a path are not. + * @default false + */ + antialiasing?: boolean; /** * If `true`, extrude the path in screen space (width always faces the camera). * If `false`, the width always faces up (z). @@ -106,6 +113,7 @@ const defaultProps: DefaultProps = { jointRounded: false, capRounded: false, miterLimit: {type: 'number', min: 0, value: 4}, + antialiasing: false, billboard: false, _pathType: null, @@ -335,6 +343,7 @@ export default class PathLayer extends jointRounded, capRounded, billboard, + antialiasing, miterLimit, widthUnits, widthScale, @@ -347,6 +356,7 @@ export default class PathLayer extends jointType: Number(jointRounded), capType: Number(capRounded), billboard, + antialiasing, widthUnits: UNIT[widthUnits], widthScale, miterLimit, diff --git a/modules/layers/src/path-layer/path-layer.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index 66a5f02ce96..d96d983735f 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -244,15 +244,43 @@ fn vertexMain(attributes: Attributes) -> Varyings { fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { geometry.uv = varyings.vPathPosition; - if (varyings.vPathPosition.y < 0.0 || varyings.vPathPosition.y > varyings.vPathLength) { - if (varyings.vJointType > 0.5 && length(varyings.vCornerOffset) > 1.0) { + // Coordinates of the outer silhouette, in units of half-width: rounded joints and caps are + // bounded by the corner offset, everywhere else by the edge of the stroke. Dividing by the + // screen-space derivative converts the distance to the boundary into device pixels, which stays + // correct under perspective foreshortening and under extensions that rescale the stroke. + let isCorner = varyings.vPathPosition.y < 0.0 || varyings.vPathPosition.y > varyings.vPathLength; + let isRound = varyings.vJointType > 0.5; + + // Distance to the silhouette in device pixels, from the derivative of the coordinate that + // bounds it. Computed before the discards below: derivatives need uniform control flow and are + // undefined after a discard in the quad. See dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md + var edgePixels = 0.0; + if (path.antialiasing != 0.0) { + let bodyCoord = abs(varyings.vPathPosition.x); + let cornerCoord = length(varyings.vCornerOffset); + // Both evaluated so each derivative stays on one field across the corner/body boundary + let bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + let cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); + edgePixels = select(bodyPixels, cornerPixels, isRound && isCorner); + } + + if (isCorner) { + if (isRound && length(varyings.vCornerOffset) > 1.0) { discard; } - if (varyings.vJointType < 0.5 && varyings.vMiterLength > path.miterLimit + 1.0) { + if (!isRound && varyings.vMiterLength > path.miterLimit + 1.0) { discard; } } - return deckgl_premultiplied_alpha(varyings.vColor); + var color = varyings.vColor; + + if (path.antialiasing != 0.0) { + // Feather one device pixel across the width only, before premultiplication. edgePixels is a + // signed device-pixel distance and SMOOTH_EDGE_RADIUS is 0.5, so this ramps across one pixel. + color.a *= smoothedge(0.0, edgePixels); + } + + return deckgl_premultiplied_alpha(color); } `; diff --git a/modules/layers/src/polygon-layer/polygon-layer.ts b/modules/layers/src/polygon-layer/polygon-layer.ts index 04aef46b947..bb563f09c56 100644 --- a/modules/layers/src/polygon-layer/polygon-layer.ts +++ b/modules/layers/src/polygon-layer/polygon-layer.ts @@ -129,6 +129,14 @@ type _PolygonLayerProps = { */ lineMiterLimit?: number; + /** + * If `true`, the stroke is rendered with smoothed edges. If `false`, it is rendered with rough + * edges. Antialiasing can cause artifacts where the stroke overlaps itself. + * + * @default false + */ + lineAntialiasing?: boolean; + lineDashJustified?: boolean; /** Called on each object in the data stream to retrieve its corresponding polygon. */ @@ -216,6 +224,7 @@ const defaultProps: DefaultProps = { lineWidthMaxPixels: Number.MAX_SAFE_INTEGER, lineJointRounded: false, lineMiterLimit: 4, + lineAntialiasing: false, getPolygon: {type: 'accessor', value: (f: any) => f.polygon}, // Polygon fill color @@ -340,6 +349,7 @@ export default class PolygonLayer exten lineWidthMaxPixels, lineJointRounded, lineMiterLimit, + lineAntialiasing, lineDashJustified } = this.props; @@ -414,6 +424,7 @@ export default class PolygonLayer exten widthMaxPixels: lineWidthMaxPixels, jointRounded: lineJointRounded, miterLimit: lineMiterLimit, + antialiasing: lineAntialiasing, dashJustified: lineDashJustified, // Already normalized diff --git a/test/modules/layers/antialiasing-composite.spec.ts b/test/modules/layers/antialiasing-composite.spec.ts new file mode 100644 index 00000000000..18949c32fff --- /dev/null +++ b/test/modules/layers/antialiasing-composite.spec.ts @@ -0,0 +1,86 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {test, expect} from 'vitest'; + +import {testLayer} from '@deck.gl/test-utils/vitest'; + +import {PolygonLayer, GeoJsonLayer} from '@deck.gl/layers'; + +const POLYGON = [ + { + polygon: [ + [-122.45, 37.78], + [-122.44, 37.79], + [-122.43, 37.78], + [-122.45, 37.78] + ] + } +]; + +const GEOJSON = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: {}, + geometry: { + type: 'LineString', + coordinates: [ + [-122.45, 37.78], + [-122.44, 37.79] + ] + } + } + ] +}; + +/** Read the `antialiasing` uniform off whichever sub layer renders the stroke. */ +function strokeAntialiasing(subLayers) { + const stroke = subLayers.find(l => l.constructor.layerName === 'PathLayer'); + expect(stroke, 'a PathLayer sub layer was rendered').toBeTruthy(); + return stroke.getModels()[0].shaderInputs.getUniformValues().path.antialiasing; +} + +test('PolygonLayer#lineAntialiasing forwards to the stroke sub layer', () => { + testLayer({ + Layer: PolygonLayer, + onError: error => expect(error, error?.message).toBeFalsy(), + testCases: [ + { + props: {data: POLYGON, getPolygon: d => d.polygon, stroked: true, filled: false}, + onAfterUpdate: ({subLayers}) => { + expect(strokeAntialiasing(subLayers), 'defaults to false').toBeFalsy(); + } + }, + { + updateProps: {lineAntialiasing: true}, + onAfterUpdate: ({subLayers}) => { + expect(strokeAntialiasing(subLayers), 'reaches the PathLayer sub layer').toBe(true); + } + } + ] + }); +}); + +test('GeoJsonLayer#lineAntialiasing forwards to the stroke sub layer', () => { + testLayer({ + Layer: GeoJsonLayer, + onError: error => expect(error, error?.message).toBeFalsy(), + testCases: [ + { + props: {data: GEOJSON}, + onAfterUpdate: ({subLayers}) => { + expect(strokeAntialiasing(subLayers), 'defaults to false').toBeFalsy(); + } + }, + { + updateProps: {lineAntialiasing: true}, + onAfterUpdate: ({subLayers}) => { + expect(strokeAntialiasing(subLayers), 'reaches the PathLayer sub layer').toBe(true); + } + } + ] + }); +}); diff --git a/test/modules/layers/antialiasing.spec.ts b/test/modules/layers/antialiasing.spec.ts new file mode 100644 index 00000000000..15fbabfc469 --- /dev/null +++ b/test/modules/layers/antialiasing.spec.ts @@ -0,0 +1,86 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {test, expect} from 'vitest'; + +import {testLayer} from '@deck.gl/test-utils/vitest'; + +import {PathLayer, LineLayer} from '@deck.gl/layers'; + +const PATH_DATA = [ + { + path: [ + [-122.45, 37.78], + [-122.44, 37.79], + [-122.43, 37.78] + ] + } +]; + +const LINE_DATA = [{sourcePosition: [-122.45, 37.78], targetPosition: [-122.44, 37.79]}]; + +test('PathLayer#antialiasing uniform', () => { + testLayer({ + Layer: PathLayer, + onError: error => expect(error, error?.message).toBeFalsy(), + testCases: [ + { + props: { + data: PATH_DATA, + getPath: d => d.path + }, + onAfterUpdate: ({layer}) => { + const {path} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(path.antialiasing, 'antialiasing defaults to false').toBeFalsy(); + } + }, + { + updateProps: {antialiasing: true}, + onAfterUpdate: ({layer}) => { + const {path} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(path.antialiasing, 'antialiasing is passed to the shader').toBe(true); + } + }, + { + updateProps: {antialiasing: false}, + onAfterUpdate: ({layer}) => { + const {path} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(path.antialiasing, 'antialiasing can be turned back off').toBeFalsy(); + } + } + ] + }); +}); + +test('LineLayer#antialiasing uniform', () => { + testLayer({ + Layer: LineLayer, + onError: error => expect(error, error?.message).toBeFalsy(), + testCases: [ + { + props: {data: LINE_DATA}, + onAfterUpdate: ({layer}) => { + const {line} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(line.antialiasing, 'antialiasing defaults to false').toBeFalsy(); + } + }, + { + updateProps: {antialiasing: true}, + onAfterUpdate: ({layer}) => { + const {line} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(line.antialiasing, 'antialiasing is passed to the shader').toBeTruthy(); + } + }, + { + // wrapLongitude issues a second draw call with useShortestPath: -1 - make sure the + // antialiasing flag survives that prop override + updateProps: {wrapLongitude: true}, + onAfterUpdate: ({layer}) => { + const {line} = layer.getModels()[0].shaderInputs.getUniformValues(); + expect(line.antialiasing, 'antialiasing survives the wrapLongitude draw').toBeTruthy(); + } + } + ] + }); +}); diff --git a/test/modules/layers/index.ts b/test/modules/layers/index.ts index 36bf322f38c..437caf7b26d 100644 --- a/test/modules/layers/index.ts +++ b/test/modules/layers/index.ts @@ -21,3 +21,5 @@ import './column-geometry.spec'; import './column-layer.spec'; import './utils.spec'; import './scatterplot-layer.spec'; +import './antialiasing.spec'; +import './antialiasing-composite.spec'; diff --git a/test/render/deck-test-utils.ts b/test/render/deck-test-utils.ts index 9c3b967b78c..d933026f963 100644 --- a/test/render/deck-test-utils.ts +++ b/test/render/deck-test-utils.ts @@ -27,6 +27,12 @@ export interface TestCase { imageDiffOptions?: { threshold?: number; tolerance?: number; + /** + * pixelmatch detects antialiased pixels and excludes them from the mismatch count by + * default, which makes a diff blind to changes that only affect edge coverage. Set to + * `true` when antialiasing itself is what the test is checking. + */ + includeAA?: boolean; }; } @@ -39,10 +45,19 @@ export interface DeckTestContext { /** * Creates a device and canvas for a render test. */ -export function createTestDevice(type: TestDeviceType, container: HTMLDivElement): Promise { +export function createTestDevice( + type: TestDeviceType, + container: HTMLDivElement, + /** + * WebGL context attributes. Defaults to the browser's, which enables MSAA - pass + * `{antialias: false}` to test what applications get when a base map owns the context. + */ + webgl?: {antialias?: boolean} +): Promise { return luma.createDevice({ type, adapters: type === 'webgl' ? [webgl2Adapter] : [webgpuAdapter], + webgl, createCanvasContext: { container, width: WIDTH, @@ -265,7 +280,8 @@ async function captureAndDiffScreenshot(testCase: TestCase, ctx: DeckTestContext goldenImage: resolvedGoldenImage, region, threshold: imageDiffOptions?.threshold ?? 0.99, - tolerance: 0.1, + tolerance: imageDiffOptions?.tolerance ?? 0.1, + includeAA: imageDiffOptions?.includeAA ?? false, includeEmpty: false, platform: OS, saveOnFail: true, diff --git a/test/render/golden-images/path-antialiasing.png b/test/render/golden-images/path-antialiasing.png new file mode 100644 index 00000000000..c156cb81ddc Binary files /dev/null and b/test/render/golden-images/path-antialiasing.png differ diff --git a/test/render/path-antialiasing.spec.ts b/test/render/path-antialiasing.spec.ts new file mode 100644 index 00000000000..b05a42ee83c --- /dev/null +++ b/test/render/path-antialiasing.spec.ts @@ -0,0 +1,165 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// `antialiasing` exists for contexts created without multisampling - notably interleaved rendering +// into a base map, since MapLibre and Mapbox create their WebGL context with `antialias: false`. +// These tests therefore create their own device with MSAA disabled, rather than using the shared +// render-test device, which takes the browser default of `antialias: true`. +// +// A golden image covers the visual result - see test/render/test-cases/path-antialiasing.spec.ts, +// which needs a no-MSAA device plus `includeAA: true` to see this feature at all. These tests +// complement it by reading the framebuffer and asserting on coverage numerically, which catches +// things an image diff cannot express, such as the feather surviving PathStyleExtension's offset. + +import {describe, test, expect} from 'vitest'; +import {luma} from '@luma.gl/core'; +import {webgl2Adapter} from '@luma.gl/webgl'; +import {Deck, OrthographicView} from '@deck.gl/core'; +import {PathLayer} from '@deck.gl/layers'; +import {PathStyleExtension} from '@deck.gl/extensions'; + +const W = 240; +const H = 180; + +// Shallow diagonals at varying slope - the worst case for aliasing. An axis-aligned edge would +// land on exact pixel boundaries and never produce partial coverage at all. +const DIAGONALS = [0, 1, 2, 3].map(i => ({ + path: [ + [-110, -70 + i * 42], + [110, -70 + i * 42 + 6 + i * 9] + ] +})); + +type Coverage = {solid: number; partial: number; levels: number}; + +function createContainer(): HTMLDivElement { + const el = document.createElement('div'); + el.style.cssText = `position:absolute;top:0;left:0;width:${W}px;height:${H}px;`; + document.body.appendChild(el); + return el; +} + +/** Render one PathLayer into a context without MSAA and measure the resulting coverage. */ +async function measure(layerProps: Record): Promise { + const container = createContainer(); + const device = await luma.createDevice({ + type: 'webgl', + adapters: [webgl2Adapter], + // The condition under test: no multisampling, as base maps create their context + webgl: {antialias: false}, + createCanvasContext: {container, width: W, height: H, useDevicePixels: false, autoResize: true} + }); + + const deck = new Deck({ + device, + container, + width: W, + height: H, + useDevicePixels: false, + views: new OrthographicView(), + viewState: {target: [0, 0, 0], zoom: 0} + }); + + const coverage = await new Promise(resolve => { + deck.setProps({ + layers: [ + new PathLayer({ + id: 'path-antialiasing', + data: DIAGONALS, + getPath: d => d.path, + getColor: [20, 20, 20], + getWidth: 2, + widthUnits: 'pixels', + ...layerProps + }) + ], + onAfterRender: () => { + const gl = (device as any).gl; + const px = new Uint8Array(W * H * 4); + gl.readPixels(0, 0, W, H, gl.RGBA, gl.UNSIGNED_BYTE, px); + let solid = 0; + let partial = 0; + const levels = new Set(); + for (let i = 3; i < px.length; i += 4) { + const a = px[i]; + if (a === 255) { + solid++; + } else if (a > 0) { + partial++; + levels.add(a); + } + } + resolve({solid, partial, levels: levels.size}); + } + }); + }); + + deck.finalize(); + device.destroy(); + container.remove(); + return coverage; +} + +describe('PathLayer#antialiasing', () => { + test('adds analytic coverage where the context provides none', async () => { + const off = await measure({antialiasing: false}); + const on = await measure({antialiasing: true}); + + expect(off.solid, 'strokes were drawn').toBeGreaterThan(500); + expect(on.solid, 'strokes were drawn').toBeGreaterThan(200); + + // Without MSAA and without the prop there is no antialiasing from any source: every covered + // pixel is fully opaque and the edges are a hard staircase. + expect( + off.partial, + `antialiasing:false in a non-MSAA context should produce no partial coverage ` + + `(got ${off.partial} partial pixels)` + ).toBe(0); + + // With the prop, edges are feathered over roughly one device pixel with continuous coverage + expect( + on.partial, + `antialiasing:true should feather the edges (got ${on.partial} partial pixels)` + ).toBeGreaterThan(300); + expect( + on.levels, + `coverage should be continuous, not quantized (got ${on.levels} distinct alpha levels)` + ).toBeGreaterThan(40); + }, 60000); + + // PathStyleExtension's `offset` inflates the stroke width through DECKGL_FILTER_SIZE and + // separately rescales vPathPosition, so any implementation that derives the pixel scale from the + // stroke width rather than from screen-space derivatives gets the feather wrong here. + test('feather survives an extension that rescales the stroke', async () => { + const on = await measure({antialiasing: true}); + const onOffset = await measure({ + antialiasing: true, + getOffset: 1, + extensions: [new PathStyleExtension({offset: true})] + }); + + expect(onOffset.solid, 'offset strokes were drawn').toBeGreaterThan(200); + + // Coverage is still continuous rather than collapsed back to a hard edge + expect( + onOffset.partial, + `offset stroke should still be feathered (got ${onOffset.partial} partial pixels)` + ).toBeGreaterThan(200); + expect( + onOffset.levels, + `offset coverage should be continuous (got ${onOffset.levels} distinct alpha levels)` + ).toBeGreaterThan(40); + + // ...and at the right scale. The extension discards outside the band, clipping the outer half + // of a centered one-pixel ramp, so a correct feather keeps roughly the inner half. Materially + // below that means the pixel scale is being derived from the inflated stroke width rather than + // from screen-space derivatives. + const ratio = onOffset.partial / on.partial; + expect( + ratio, + `offset feather should be comparable to un-offset (on=${on.partial}, ` + + `offset=${onOffset.partial}, ratio=${ratio.toFixed(3)})` + ).toBeGreaterThan(0.5); + }, 60000); +}); diff --git a/test/render/render-test-suite.ts b/test/render/render-test-suite.ts index 5451ea7a801..74fe4cf9e62 100644 --- a/test/render/render-test-suite.ts +++ b/test/render/render-test-suite.ts @@ -18,6 +18,12 @@ import { type RenderTestSuiteOptions = { beforeAll?: () => void | Promise; + /** + * WebGL context attributes for the suite's device. Defaults to the browser's, which enables + * MSAA - pass `{antialias: false}` to test what applications get when a base map owns the + * context. + */ + webgl?: {antialias?: boolean}; }; function cloneTestCases(testCases: TestCase[]): TestCase[] { @@ -54,7 +60,7 @@ export function runRenderTestSuite( beforeAll(async () => { ctx.container = createContainer(); - ctx.device = await createTestDevice(deviceType, ctx.container); + ctx.device = await createTestDevice(deviceType, ctx.container, options.webgl); await options.beforeAll?.(); }); diff --git a/test/render/test-cases/line-layer.spec.ts b/test/render/test-cases/line-layer.spec.ts index 94f790dccae..f8c2206f1a9 100644 --- a/test/render/test-cases/line-layer.spec.ts +++ b/test/render/test-cases/line-layer.spec.ts @@ -4,42 +4,51 @@ import {describe} from 'vitest'; import {runRenderTestSuite} from '../render-test-suite'; -import type {TestCase} from '../deck-test-utils'; +import type {TestCase, TestDeviceType} from '../deck-test-utils'; /* eslint-disable callback-return */ import {LineLayer} from '@deck.gl/layers'; import {routes} from 'deck.gl-test/data'; -const testCases = [ - { - name: 'line-lnglat', - viewState: { - latitude: 37.751537058389985, - longitude: -122.42694203247012, - zoom: 11.5, - pitch: 0, - bearing: 0 - }, - layers: [ - new LineLayer({ - id: 'line-lnglat', - data: routes, - opacity: 0.8, - getWidth: 0, - widthMinPixels: 2, - getSourcePosition: d => d.START, - getTargetPosition: d => d.END, - getColor: d => (d.SERVICE === 'WEEKDAY' ? [255, 64, 0] : [255, 200, 0]), - pickable: true - }) - ], - goldenImage: './test/render/golden-images/line-lnglat.png' - } -]; +function getTestCases(deviceType: TestDeviceType) { + return [ + { + name: 'line-lnglat', + viewState: { + latitude: 37.751537058389985, + longitude: -122.42694203247012, + zoom: 11.5, + pitch: 0, + bearing: 0 + }, + layers: [ + new LineLayer({ + id: 'line-lnglat', + data: routes, + opacity: 0.8, + getWidth: 0, + widthMinPixels: 2, + getSourcePosition: d => d.START, + getTargetPosition: d => d.END, + getColor: d => (d.SERVICE === 'WEEKDAY' ? [255, 64, 0] : [255, 200, 0]), + // WebGPU provides no MSAA, so analytic coverage stands in for it and lets one golden + // serve both backends. The WebGL canvas is already multisampled, so leaving the prop + // off there keeps this case covering the default configuration. + antialiasing: deviceType === 'webgpu', + pickable: true + }) + ], + goldenImage: './test/render/golden-images/line-lnglat.png' + } + ]; +} -describe.each([ - 'webgl' - // 'webgpu' -] as const)('%s', deviceType => { - runRenderTestSuite(testCases as TestCase[], deviceType); +// 'webgpu' is expected to fail against the shared golden for now, and is enabled deliberately so +// that gap is visible and tracked rather than invisible. Last measured 83.25% against a 99% +// threshold - the two backends rasterize 2px lines differently (WebGPU is crisper: 73.8k ink pixels +// vs WebGL's 90.0k), and it is not a misalignment, since shifting the image a pixel in any direction +// only reaches 84.98%. Closing that gap is part of finishing the WebGPU port; the diff image CI +// uploads on failure is the measurement. Requires a virtual display - see the test workflow. +describe.each(['webgl', 'webgpu'] as const)('%s', deviceType => { + runRenderTestSuite(getTestCases(deviceType) as TestCase[], deviceType); }); diff --git a/test/render/test-cases/path-antialiasing.spec.ts b/test/render/test-cases/path-antialiasing.spec.ts new file mode 100644 index 00000000000..a52c7f7c934 --- /dev/null +++ b/test/render/test-cases/path-antialiasing.spec.ts @@ -0,0 +1,100 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +import {describe} from 'vitest'; +import {runRenderTestSuite} from '../render-test-suite'; +import type {TestCase} from '../deck-test-utils'; + +import {OrthographicView} from '@deck.gl/core'; +import {PathLayer} from '@deck.gl/layers'; + +// Runs on a device created without multisampling, matching how base maps create their context. +// On the default render-test device MSAA smooths the strokes either way and this test passes even +// with the feature removed. `includeAA: true` is equally load-bearing - pixelmatch drops +// antialiased pixels from the mismatch count by default, and this prop changes nothing else. +// See dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md + +// Thin shallow diagonals: the worst case for aliasing, and the bulk of the edge pixels the image +// diff depends on. Axis-aligned edges land on pixel boundaries and never partially cover. +const DIAGONALS = Array.from({length: 20}, (_, i) => ({ + path: [ + [-170, -210 + i * 10], + [170, -210 + i * 10 + 6] + ] +})); + +// Sharp direction changes, to cover joints and caps as well as straight runs +const ZIGZAG = [ + { + path: [ + [-170, 20], + [-60, 100], + [50, 20], + [170, 90] + ] + } +]; + +function column(xOffset: number, paths: {path: number[][]}[]) { + return paths.map(d => ({path: d.path.map(([x, y]) => [x + xOffset, y])})); +} + +/** Same scene twice, differing only in `antialiasing` - left column off, right column on. */ +function variant(antialiasing: boolean) { + const xOffset = antialiasing ? 200 : -200; + const suffix = antialiasing ? 'on' : 'off'; + return [ + new PathLayer({ + id: `path-aa-diagonals-${suffix}`, + data: column(xOffset, DIAGONALS), + getPath: d => d.path, + getColor: [20, 20, 20], + getWidth: 2, + widthUnits: 'pixels', + antialiasing + }), + new PathLayer({ + id: `path-aa-rounded-${suffix}`, + data: column(xOffset, ZIGZAG), + getPath: d => d.path, + getColor: [200, 60, 0], + getWidth: 7, + widthUnits: 'pixels', + jointRounded: true, + capRounded: true, + antialiasing + }), + new PathLayer({ + id: `path-aa-miter-${suffix}`, + data: column(xOffset, ZIGZAG).map(d => ({ + path: d.path.map(([x, y]) => [x, y + 110]) + })), + getPath: d => d.path, + getColor: [0, 90, 200], + getWidth: 7, + widthUnits: 'pixels', + jointRounded: false, + capRounded: false, + antialiasing + }) + ]; +} + +const testCases: TestCase[] = [ + { + name: 'path-antialiasing', + views: new OrthographicView(), + viewState: {target: [0, 0, 0], zoom: 0}, + layers: [...variant(false), ...variant(true)], + imageDiffOptions: {threshold: 0.998, includeAA: true}, + goldenImage: './test/render/golden-images/path-antialiasing.png' + } +]; + +describe.each([ + 'webgl' + // 'webgpu' +] as const)('%s', deviceType => { + runRenderTestSuite(testCases as TestCase[], deviceType, {webgl: {antialias: false}}); +}); diff --git a/test/render/webgpu-antialiasing.spec.ts b/test/render/webgpu-antialiasing.spec.ts new file mode 100644 index 00000000000..e756808e4f4 --- /dev/null +++ b/test/render/webgpu-antialiasing.spec.ts @@ -0,0 +1,134 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// WebGPU blends premultiplied - `WEBGPU_DEFAULT_DRAW_PARAMETERS` uses `one` / +// `one-minus-src-alpha` - so the WGSL layers premultiply as their last step. Analytic coverage must +// therefore be folded into alpha *before* `deckgl_premultiplied_alpha`, giving (rgb*a*c, a*c). +// Applying it after would leave (rgb*a, a*c): RGB too bright for its alpha, a light halo along every +// feathered edge. The goldens cannot see this - the WebGPU canvas does not present under the +// headless software renderer - so this test reads back an offscreen framebuffer instead. + +import {describe, test, expect} from 'vitest'; +import {luma, Texture, Buffer} from '@luma.gl/core'; +import {webgpuAdapter} from '@luma.gl/webgpu'; +import {Deck, OrthographicView} from '@deck.gl/core'; +import {PathLayer} from '@deck.gl/layers'; + +const W = 240; +const H = 180; + +// Shallow diagonals at varying slope, as in path-antialiasing.spec.ts - an axis-aligned edge would +// land on exact pixel boundaries and never produce partial coverage at all. +const DIAGONALS = [0, 1, 2, 3].map(i => ({ + path: [ + [-110, -70 + i * 42], + [110, -70 + i * 42 + 6 + i * 9] + ] +})); + +// Saturated red so any RGB overshoot at the edges is unambiguous +const STROKE_COLOR: [number, number, number] = [255, 0, 0]; + +type EdgeStats = {partial: number; worstOvershoot: number}; + +/** Render a feathered PathLayer on WebGPU and measure premultiplication at partial-coverage pixels */ +async function measureEdges(): Promise { + const container = document.createElement('div'); + container.style.cssText = `position:absolute;top:0;left:0;width:${W}px;height:${H}px;`; + document.body.appendChild(container); + + const device = await luma.createDevice({ + type: 'webgpu', + adapters: [webgpuAdapter], + createCanvasContext: {container, width: W, height: H, useDevicePixels: false}, + alphaMode: 'premultiplied' + }); + + // Render offscreen rather than to the canvas: the swapchain has no COPY_SRC, and the canvas does + // not present under the software renderer. Framebuffer dimensions are not derived from + // attachments, so pass them explicitly. + const texture = device.createTexture({ + format: 'rgba8unorm', + width: W, + height: H, + usage: Texture.RENDER | Texture.COPY_SRC + }); + const framebuffer = device.createFramebuffer({ + width: W, + height: H, + colorAttachments: [texture], + depthStencilAttachment: 'depth24plus' + }); + + const deck = new Deck({ + device, + container, + width: W, + height: H, + useDevicePixels: false, + views: new OrthographicView(), + viewState: {target: [0, 0, 0], zoom: 0}, + _framebuffer: framebuffer, + layers: [ + new PathLayer({ + id: 'webgpu-antialiasing', + data: DIAGONALS, + getPath: d => d.path, + getColor: STROKE_COLOR, + getWidth: 2, + widthUnits: 'pixels', + antialiasing: true + }) + ] + }); + + await new Promise(resolve => { + deck.setProps({onAfterRender: () => resolve()}); + }); + + const layout = texture.computeMemoryLayout(); + const readback = device.createBuffer({ + byteLength: layout.byteLength, + usage: Buffer.COPY_DST | Buffer.MAP_READ + }); + texture.readBuffer({}, readback); + const px = await readback.readAsync(); + + let partial = 0; + let worstOvershoot = 0; + for (let i = 0; i < px.length; i += 4) { + const a = px[i + 3]; + // Only the feathered edges carry partial coverage; interiors and background say nothing here + if (a === 0 || a === 255) { + continue; + } + partial++; + // Premultiplied red at coverage c is (255*c, 0, 0, 255*c), so red should track alpha. Straight + // (un-premultiplied) output would hold red near 255 while alpha falls off. + worstOvershoot = Math.max(worstOvershoot, px[i] - a); + } + + deck.finalize(); + device.destroy(); + container.remove(); + return {partial, worstOvershoot}; +} + +describe('PathLayer#antialiasing on WebGPU', () => { + test('coverage is applied before premultiplication', async () => { + const {partial, worstOvershoot} = await measureEdges(); + + expect(partial, `strokes should be feathered (got ${partial} partial pixels)`).toBeGreaterThan( + 300 + ); + + // Allow a little slack for 8-bit rounding. Applying coverage after premultiplication pins red + // near 255 regardless of alpha, so the overshoot runs into the hundreds. + expect( + worstOvershoot, + `premultiplied red should track alpha at feathered edges (worst red-alpha ` + + `overshoot ${worstOvershoot} across ${partial} partial pixels)` + ).toBeLessThanOrEqual(2); + }, 60000); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 4ec57b9c8a6..ce17af52856 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -24,10 +24,24 @@ const browserPlaywright = playwright({ } }); -// Playwright provider with viewport configured for render tests +// Playwright provider with viewport configured for render tests. +// The WebGPU flags below are additive to chromiumLaunchArgs on purpose: dropping +// --use-angle=swiftshader shifts WebGL rasterization enough to fail +// 'column-lnglat-extruded-wireframe' at 97.88%. Vulkan compositing is what lets Dawn's swapchain +// reach the screenshot compositor, and --enable-gpu stops headless forcing software rendering - +// but its driver autodetection needs an X display, so this project must run under xvfb-run to +// capture WebGPU. Without a display WebGL is unaffected and WebGPU captures blank. +// See https://github.com/visgl/luma.gl/issues/2874 const renderPlaywright = playwright({ launchOptions: { - args: [...chromiumLaunchArgs, '--enable-unsafe-webgpu'] + args: [ + ...chromiumLaunchArgs, + '--enable-unsafe-webgpu', + '--ignore-gpu-blocklist', + '--enable-gpu', + '--enable-features=Vulkan', + '--use-vulkan=swiftshader' + ] }, contextOptions: { viewport: {width: 1024, height: 768}