feat(layers): analytic antialiasing for PathLayer and LineLayer - #10520
feat(layers): analytic antialiasing for PathLayer and LineLayer#10520chrisgervang wants to merge 22 commits into
Conversation
PathLayer and LineLayer had no analytic antialiasing in their fragment shaders - edges relied entirely on the default framebuffer's MSAA. That works for a standalone deck.gl canvas, where the browser enables `antialias` by default, but not for interleaved rendering: MapLibre GL JS and Mapbox GL JS both create their context with `antialias: false`, so strokes drawn into it get no antialiasing from any source and look jagged next to the base map's own lines, which compute coverage in the shader. Add an opt-in `antialiasing` prop to both layers, following the ScatterplotLayer precedent. Coverage is derived from the existing normalized offsets (`vPathPosition.x` / `vCornerOffset` for paths, `uv.y` for lines) scaled by the stroke half-width, and feathered over exactly one device pixel centered on the edge. Only the across-width silhouette is feathered. Consecutive path segment instances each draw half of the shared joint and abut along the miter direction, so feathering lengthwise would leave a seam at every vertex - the same restriction MapLibre observes. The half-width is passed to the fragment stage already multiplied by the device pixel ratio, since the project shader module is vertex-stage only. It is read back after DECKGL_FILTER_SIZE so extensions that resize strokes stay consistent. Defaults to false, so existing render output is unchanged. PolygonLayer, GeoJsonLayer and TripsLayer inherit the prop via PathLayer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The initial implementation passed the stroke half-width to the fragment shader as a varying, read after DECKGL_FILTER_SIZE. PathStyleExtension's `offset` breaks that assumption: it inflates the width via DECKGL_FILTER_SIZE and separately rescales vPathPosition.x, so the varying reported a width `offsetWidth` times larger than the band it addresses. The feather collapsed to 1/offsetWidth of a device pixel - measured at 0.328x the normal feather for getOffset: 1, where offsetWidth is 3. The same varying was wrong for a second reason: a ground-plane path under pitch is foreshortened, so its on-screen width is smaller than widthPixels and the feather came out too narrow in any tilted view. Divide by the screen-space derivative of the silhouette coordinate instead. The derivative is that coordinate's rate of change per device pixel, so it absorbs extension rescaling, perspective foreshortening and device pixel ratio without the layer having to know about any of them. This removes the varying entirely, leaving both vertex shaders untouched, and drops the project.scale / project.devicePixelRatio plumbing that only existed because the project module is vertex-stage only. Both candidate coordinates are evaluated unconditionally before selecting: taking the derivative of a branched value would differentiate across the corner/body seam and corrupt every joint. The WGSL port hoists the derivatives above the discards, since derivatives require uniform control flow there. One limitation remains: with `offset`, the extension hard-discards outside the band before layer code runs, clipping the outer half of the ramp. Also scopes the prop's documentation to match ScatterplotLayer - core layers describe behavior only, with integration-specific notes kept in the @deck.gl/mapbox docs, moved under Remarks where they don't split the constructor section. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Reads back the framebuffer and asserts on the rendered coverage directly, rather than diffing a golden image. A golden image cannot cover this feature. The render canvas is created with the browser default `antialias: true`, so MSAA smooths the strokes whether or not the prop is set, and the residual difference is far below the diff threshold - a golden test was tried first and passed with the feature completely disabled. Two assertions, both verified to fail against the code they guard: - Analytic coverage is continuous while MSAA quantizes to its sample count. Deleting the feather drops the antialiased pass from ~200 distinct alpha levels to the same ~3 as the unantialiased one. - The feather survives PathStyleExtension's `offset`. Restoring the previous varying-based implementation reproduces the collapse to 0.331 of the un-offset feather, matching the predicted 1/offsetWidth for getOffset: 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The previous version of this test used the shared render-test device, which takes the browser default of `antialias: true`. That is the one condition where the prop is redundant: MSAA smooths the strokes either way, so the test could only assert on a subtle difference in coverage granularity. Create a device with `antialias: false` instead, matching how MapLibre and Mapbox create theirs. In that context the signal is unambiguous - measured on a 2px diagonal: antialiasing: false -> 0 partial pixels (a hard staircase) antialiasing: true -> 719 partial pixels across 104 alpha levels Deleting the feather now fails with "got 0 partial pixels" rather than a marginal ratio, and the offset regression guard still reproduces the 0.328 collapse against the previous varying-based implementation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The comment claimed that taking the derivative of the branched value "would corrupt every joint". Measured against a zigzag with rounded joints in a no-MSAA context, the branched form differs by 2 bytes with a maximum delta of 1/255 - a single pixel at 40x amplification. It does not corrupt anything. The reason is geometric: at the corner/body boundary vPathPosition.y is 0, so the offset vector is perpendicular to the segment direction and |vPathPosition.x| equals length(vCornerOffset). The two fields meet exactly at the seam, so differencing across a straddling quad blends two similar gradients rather than two unrelated values. Computing both is still worth one extra derivative - it keeps each derivative on a single smooth field instead of depending on that coincidence - but the comment should say so honestly. No behavior change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Records the motivation (interleaved base maps and WebGPU, the two cases where the framebuffer provides no MSAA), the measurements, the choice of screen-space derivatives over a half-width varying, the alternatives that were rejected, the known limitations, and why this feature cannot be covered by a golden image. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Derivatives are computed per 2x2 quad and are undefined once any invocation in the quad has been discarded. The GLSL shader took fwidth after the rounded joint/cap and miter-trim discards, so coverage at exactly those silhouettes was undefined on WebGL. The WGSL shader already hoisted the same math for this reason; this brings GLSL in line. Both now compute the coverage inside a branch on path.antialiasing, ahead of the discards. That uniform is the same for every invocation, so the branch keeps control flow uniform across the quad while also keeping the two derivatives off the default path when the feature is off. Reported by Cursor Bugbot on #10520. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
A golden diff can cover this feature after all - the earlier conclusion that it could not was wrong, and the reason it appeared to be true was not the one given. The decisive blocker was pixelmatch's `includeAA`, which defaults to false and makes it detect antialiased pixels and drop them from the mismatch count. This prop changes nothing but antialiased pixels, so the diff was blind to it regardless of MSAA or geometry. Thread it through TestCase.imageDiffOptions, which now also honours `tolerance` - declared but previously ignored. Two other things were needed. runRenderTestSuite now accepts webgl context attributes so a suite can run with `antialias: false`, matching how base maps create their context; the default device enables MSAA, which smooths the strokes either way. And the scene is dense with thin shallow diagonals against a tightened threshold, since the prop only moves edge pixels. With all three, disabling the feather drops the match to 99.06% against a 99.8% threshold. Without any one of them the test passes with the feature entirely removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The RFC named only interleaved base maps and WebGPU. Investigating the other integrations turned up a broader and partly unconditional set, grouped by mechanism: Externally-owned contexts, where deck does not pick the attributes: @deck.gl/mapbox (MapLibre and Mapbox both default antialias to false, source-verified) and @deck.gl/google-maps (attaches to the context from WebGLOverlayView; Google's attributes are not determinable from deck's source, so it is recorded as unknown rather than assumed). Offscreen render targets, where MSAA is absent regardless of the host context because luma's WebGL backend has no multisample renderbuffer support: @deck.gl/arcgis always renders into an auxiliary framebuffer, as does any app passing _framebuffer, as does any app using a PostProcessEffect - DeckRenderer redirects layers into plain renderBuffers. That last one affects standalone deck.gl with a default canvas and is easy to miss; measured, adding one effect to a context created with antialias: true takes a 2px diagonal from 1361 partial-coverage pixels to 0. WebGPU, which has no antialias canvas attribute at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Comments: the shaders had run to 6-16 line paragraphs, well outside the house style of one-line notes stating what the code does. Cut to the point that is load-bearing at the call site - the derivative ordering constraint and the width-only feathering - with a pointer to the RFC for the reasoning behind them. Golden image: the scene was 52 identical diagonals. Restructured to follow the path-rounded pattern of varying one prop across otherwise identical layers, and widened to cover rounded joints and caps and miter joints alongside the thin diagonals, which remain as the aliasing-worst case and the bulk of the diff's signal. Densified them to offset the dilution: removing the feather now drops the match to 99.20% against a 99.8% threshold, versus 99.40% with variety alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Searching both trackers turned up substantial prior art that changes what this RFC should claim. The maintainer answer since #5742 (2021) has been to construct the map with antialias: true. That is still right where it applies, and the RFC now positions this prop as complementary rather than a replacement - while noting the advice has narrowed, since MapLibre v5 moved the option into canvasContextAttributes and it never covered Google Maps, ArcGIS, offscreen targets or WebGPU. #7647 answers empirically what deck's source cannot: Google Maps vector interleaved is unantialiased, confirmed by several users since 2023 with no fix and no option to request MSAA. Recorded in place of the earlier "unknown". #8063 and #9395 report PathStyleExtension offset breaking antialiasing. The mechanism is that the extension defines the visible edge with a discard, and discard kills every sample, so MSAA cannot smooth it at all. Analytic coverage improves both without closing them. luma.gl#2741 proposes color-only MSAA for offscreen framebuffers. Mapped against the cases here it overlaps only on post-processing, where it is the better fix; it does not reach interleaved base maps, ArcGIS's depth-attached framebuffer, or WebGPU. Both should land. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Adds an "At a glance" table up front covering every situation where deck lacks MSAA against the three available remedies - host antialias, luma.gl#2741 offscreen MSAA, and this prop - with a recommendation per row. Three situations have no alternative at all; post-processing is better served by luma.gl#2741 than by this proposal. Removes the narrower luma.gl#2741 scope table from Prior art, now subsumed, keeping only the scope facts that explain the split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
A reviewer aware of the offscreen-MSAA proposal will ask whether this one can then be dropped. It cannot, and the reasons are structural rather than incidental, so they belong in the RFC rather than in a follow-up bullet. Interleaved base maps draw into the host's default framebuffer, not an offscreen target, and routing them through one would break the depth interaction interleaving exists for. WebGPU is deferred in that RFC. The PathStyleExtension offset edge is defined by a discard, which kills every sample, so no sample count smooths it. The one row it does cede is post-processing, and that is now stated with the evidence: deck's render buffers pass only colorAttachments, and luma auto-creates depth only when both attachment lists are empty, so they are genuinely color-only and match that RFC's initial scope. ArcGIS is depth-attached and would need a later phase. Follow-ups keeps a pointer to the actionable half - setting samples on those buffers to close #10404 for every layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
…ocument per integration Answering "should the docs guide users to the opt-in fix" turned up a correctness bug first. The RFC claimed PolygonLayer and GeoJsonLayer inherit `antialiasing` through PathLayer. They do not - both forward stroke props explicitly, so the prop never reached the sub layer. Only TripsLayer inherits, by subclassing. That made the feature unusable on the layers most often drawn over a base map. Both now expose `lineAntialiasing`, following the existing `pointAntialiasing` precedent in sub-layer-map.ts. GeoJsonLayer picks up the default automatically via getDefaultProps; PolygonLayer forwards it alongside jointRounded and miterLimit. Tests assert it reaches the PathLayer sub layer, and were confirmed to fail without the forwarding. Docs then follow the matrix, targeting the rows where a user has no alternative: @deck.gl/google-maps (Google's context provides no multisampling and exposes no option, #7647), @deck.gl/arcgis (renders into a non-multisampled auxiliary framebuffer), and PostProcessEffect (redirects layers into an offscreen buffer, #10404). Each is a short Remarks entry in that module's own docs, pointing at the layer prop - core layer docs stay free of integration commentary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The assertion was written as "do not regress to the implementation we tried and abandoned on this branch" - six lines of archaeology about a half-width varying that never shipped, and a 0.55 threshold that was just the midpoint between the two historical measurements. Nobody is going to reintroduce that varying, so as written the test guarded a closed path. The property underneath is live: antialiasing has to survive an extension that rescales the stroke, and this is the only coverage of that interaction. Restated in those terms - the offset stroke must still be feathered with continuous coverage, and at a comparable scale. The 0.5 floor is now derived rather than fitted: 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. Still fails at ratio 0.328 against the width-derived implementation, which remains a convenient stand-in for the mistake. The abandoned implementation stays documented in the RFC, where the history belongs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Matches path-layer.spec.ts and line-layer.spec.ts, which list 'webgpu' commented out alongside 'webgl' so the suite is one uncomment away when the WebGPU goldens are viable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
Dashes were resolved with one comparison per fragment, which aliases as soon as a dash period approaches a pixel: the stroke breaks into moire, and because the result depends on where the phase happens to land, its apparent density is wrong too. Zooming out far enough turned a dashed line into noise or into a solid line. Coverage is now the integral of the dash square wave over the fragment footprint, which is the closed form of what MapLibre's mipmapped LineAtlas SDF texture approximates, without needing a texture. Measured on a 10px stroke with an even duty cycle, sampling the interior of each strip: period before (mean alpha / stdev) after 40.0px 127.5 / 127.5 127.5 / 127.5 10.0px 127.5 / 127.5 127.5 / 127.5 4.0px 127.5 / 127.5 127.5 / 127.5 1.5px 114.8 / 126.9 127.4 / 51.8 0.6px 132.8 / 127.4 127.5 / 20.8 0.2px 107.7 / 125.9 127.5 / 0.5 Mean alpha should be 127.5 everywhere, since every pattern is half solid. Before, it drifted by up to 16% once the period went sub-pixel, and the stroke stayed full of binary noise at every scale. Now it is exact at every scale and the noise falls away as the period shrinks, so the stroke fades to a uniformly lighter line instead of tearing. Resolvable periods are unaffected apart from antialiased dash ends. Rounded caps resolve the 2D distance to the nearer solid end with smoothstep rather than a hard threshold, then fade to the duty cycle over the same range, since a per-end test stops meaning anything once a whole period fits in a pixel. Picking deliberately stays a hard in-or-out test: a blended picking colour decodes to the wrong index, and dashGapPickable is defined in terms of whole gaps rather than coverage. Fragments below 0.4% coverage are still discarded so they do not write depth and occlude what is behind them. Note the dash edge is produced by discard, which kills every sample of a fragment, so no amount of framebuffer MSAA smooths it. The render device has multisampling on, and these dash ends were still hard staircases before this change. Testing this needed care, and the first attempt did not work. pixelmatch drops antialiased pixels from the mismatch count unless includeAA is set, and TestCase.imageDiffOptions never threaded it through - so a golden diff was blind to precisely the pixels this feature creates. With the whole change reverted, only 2 of 22 goldens noticed. Two things fix that: - imageDiffOptions now carries includeAA, and honours tolerance, which was declared but ignored. Every dash case sets includeAA. (#10520 makes the same harness change for the same reason; whichever lands second should drop its copy.) - A new path-dash-diagonal case. Every other case draws horizontal strips, where a dash end is a vertical line landing on an exact pixel column and there is no partial coverage to produce at all. It is deliberately dense: one dash end perturbs only a handful of pixels, so a sparse case stays under the 1% mismatch the threshold requires and passes with the feature deleted. Reverting the change now fails 4 goldens, path-dash-diagonal among them at 97.80%. Also adds path-dash-subpixel-square and path-dash-subpixel-rounded, which sweep the dash period from 40px down to 0.2px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014GipN1niYRgRuLVat2wujw
|
There is a utility shader function |
|
You should enable WebGPU render test for line layer. Right now it fails due to lack of antialiasing. |
…r WebGPU Review feedback from @Pessimistress. smoothedge: the four coverage sites now call the shared helper from shaderlib/misc/geometry.ts instead of an inline clamp. I had passed on it originally because SMOOTH_EDGE_RADIUS is a fixed 0.5 CSS pixels and not DPR-aware, but that objection belonged to the varying-based implementation. With screen-space derivatives edgePixels is already a signed device-pixel distance, so smoothedge(0.0, edgePixels) is exactly smoothstep(-0.5, 0.5, edgePixels) and the units line up. Falloff changes from linear to cubic; the existing goldens absorb it and both regression guards still fire. LineLayer render case: antialiasing is now set per device, on for WebGPU only. WebGPU has no MSAA, so analytic coverage stands in for it and lets one golden serve both backends; WebGL keeps the prop off so the case still covers the default configuration and its golden is untouched. Setting it unconditionally dropped the WebGL match to 98.28%. The 'webgpu' entry stays commented out. Under the software renderer this suite uses, WebGPU rasterizes nothing at all - this case and every path-layer case return a blank frame, device created, no validation errors - so enabling it would fail for reasons unrelated to antialiasing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
|
Thanks — both addressed in 2d98227.
LineLayer WebGPU — the case is prepared but the suite entry is still commented out, and I want to be transparent about why rather than quietly not doing it. Setting What I couldn't verify is the WebGPU side, because WebGPU rasterizes nothing under the software renderer this suite runs on. Since Same root cause, for what it's worth, explains a dead end I hit earlier trying to assert on WGSL coverage directly: rendering deck layers into an app-supplied Generated by Claude Code |
|
@chrisgervang Very cool, this could have some potential.
For this technique I am thinking it might benefit from being done in premultiplied colors. |
Viewports build projection matrices for the WebGL clip volume, -w <= z <= w. WebGPU's is 0 <= z <= w, so every primitive whose depth landed in the near half of the WebGL range was clipped away and nothing rasterized. A default OrthographicView, for example, projects to z/w = -0.998. Remap depth where the clip position is assembled. Uniforms, attributes and pipeline state were all already correct, so this is the only place the convention differs; the GLSL path is a separate file and is untouched. Verified by reading back an offscreen framebuffer: LineLayer, PathLayer and ScatterplotLayer all went from zero painted pixels to within a few pixels of their WebGL counts, under both orthographic and lng/lat viewports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
The LineLayer WebGPU row stays off, but for a different reason than the comment claimed. WebGPU rasterizes fine here; what fails is canvas presentation under the headless software renderer, so the screenshot the suite diffs is blank regardless of what deck draws. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
|
Correcting myself: my earlier comment said WebGPU "rasterizes nothing under the software renderer." That was wrong, and it was hiding a real deck bug. Root causeViewports build projection matrices for the WebGL clip volume, How it was isolated
A valid pipeline issuing a valid draw and producing nothing. From there: CPU-side uniform bytes are byte-identical between backends; all attributes arrive (verified by encoding them into fragment color); a clip-space triangle bypassing the projection renders fine. Encoding the computed clip position as color gave Two things I chased that were dead ends, recorded so nobody repeats them:
ResultReading back an offscreen framebuffer, On enabling the LineLayer WebGPU render testStill can't, but for a different and narrower reason: the WebGPU canvas does not present under the headless software renderer, so the screenshot this suite diffs comes back blank no matter what deck draws. That reproduces with a plain luma triangle and no deck involved, and readback of the same content from an offscreen texture is correct. The Note the depth fix is Full render suite re-run: 168 passing, 4 pre-existing network failures (scenegraph duck ×2, terrain ×2), no goldens moved. Generated by Claude Code |
| **WebGPU** 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. |
There was a problem hiding this comment.
@ibgreen do canvas-based render tests work in CI on luma in your experience?
I'm a bit stuck with blank canvases.
Claude claims to have made a minimal reproduction of the issue in pure luma too, which I'll post for a deeper look
…erage WebGPU blends premultiplied, so analytic coverage has to reach alpha before deckgl_premultiplied_alpha. Applying it after leaves RGB too bright for its alpha - a light halo on every feathered edge - and nothing caught that. The new test renders on WebGPU into an offscreen framebuffer, sidestepping the canvas presentation that keeps the golden row disabled, and asserts red tracks alpha at partial-coverage pixels. Moving the multiply after premultiplication takes the worst overshoot from 2 to 126. Also records alpha-to-coverage in the RFC. It would fix the flat-cap and self-overlap limitations and the discard-defined edges together, but it is a no-op on WebGL (luma never maps sampleAlphaToCoverageEnabled), inert on WebGPU (needs a multisampled target - luma.gl#2741), and collides with deck's SRC_ALPHA blending. Parked with the reasons written down. Layer docs and the RFC now link luma.gl's antialiasing guide rather than restating the taxonomy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
|
Thanks @ibgreen-openai — the guide is a better home for the taxonomy than this RFC, so the RFC and both layer docs now link out to it instead of restating it. On premultiplied: coverage already lands before Alpha-to-coverage was the most useful thing in the guide — I hadn't considered it. Per-sample masks would fix the flat-cap and self-overlap limitations and the Generated by Claude Code |
WebGPU render tests captured blank frames because Dawn runs on Vulkan-SwiftShader while the compositor defaults to ANGLE/GL-SwiftShader, and the swapchain image never crosses that boundary. Adding Vulkan compositing plus --enable-gpu fixes it, but --enable-gpu falls back to driver autodetection that needs an X display on Linux, so the render project now runs under xvfb-run in CI. The flags are additive to the existing ones deliberately: dropping --use-angle=swiftshader shifts WebGL rasterization enough to fail 'column-lnglat-extruded-wireframe' at 97.88%. Verified the full suite is otherwise unmoved - 169 passing, same 4 pre-existing network failures. The webgpu row is enabled knowing it fails, so the gap is visible and tracked rather than invisible. It sits at 83.25% against a 99% threshold: the backends rasterize 2px lines differently, and it is not a misalignment, since shifting the image a pixel in any direction only reaches 84.98%. CI already uploads *-fail.png and *-diff.png on failure, so the diff is the measurement. Closing that gap is part of finishing the WebGPU port. Context: visgl/luma.gl#2874 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG
|
@Pessimistress I've enabled the line render tests so we can see where we're at - they render but don't match yet Also, I had to jump through some configuration hoops to make canvas-based headless WebGPU renders work. Rendering to offscreen textures was easy, but getting the entire pipeline to work requires a virtual display. I recorded findings and options in the linked luma issue |
|
I advocate for adding this prop in 9.4 to fix long standing WebGL issues like #7647, but also want to hear if there are any concerns. I'd land this PR with WebGPU render tests functional but disabled to keep CI green |
| // 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); |
There was a problem hiding this comment.
WebGPU line test fails CI
High Severity
The 'webgpu' line-layer render case is enabled while the comments document it as expected to fail (~83% vs a 99% threshold). That fails yarn test-ci under the new xvfb-run path, so the suite cannot stay green. The RFC still describes this row as commented out, matching the earlier plan to keep WebGPU goldens disabled until they match.
Reviewed by Cursor Bugbot for commit 241d28b. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 14434fe. Configure here.
| // 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); | ||
| } |
There was a problem hiding this comment.
AA misses outer coverage half
Medium Severity
Coverage is centered with smoothedge(0.0, edgePixels), but stroke geometry is not expanded by half a device pixel and round joints still discard past the silhouette. Pixel centers outside the edge never shade, so the outer half of the claimed one-pixel feather is lost and edges cut off around 50% alpha.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 14434fe. Configure here.


Background
PathLayerandLineLayerhave no antialiasing of their own; their edges are smoothed entirely by the framebuffer's MSAA. This adds an opt-inantialiasingprop 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; "this prop" isantialiasing: true(lineAntialiasingon composite layers).PostProcessEffect(#10404)@deck.gl/arcgis_framebufferPathStyleExtensionoffset (#8063, #9395)discardThree 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.
If luma.gl#2741 lands
No part of this proposal is descoped by it, and the reasons are structural. 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
PathStyleExtensionoffset edge is defined by adiscard, 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, 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 setsampleson them, closing #10404 for every layer rather than for these two.Measured impact
A 2px diagonal path rendered into a 240×180 context, counting pixels by alpha:
antialiasingfalsetruefalsePostProcessEffectfalseWhere 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 shows the honest comparison where MSAA genuinely is available; the fourth shows how easily that stops applying.
Change List
Layers
antialiasingprop onPathLayerandLineLayer, defaulting tofalse.TripsLayerinherits it by subclassingPathLayer.lineAntialiasingonPolygonLayerandGeoJsonLayer, following the existingpointAntialiasingprecedent. These are composites that forward stroke props explicitly rather than by inheritance, so the prop would not otherwise reach the stroke sub-layer — which is the case that matters most, since they are the layers most often drawn over a base map.Tests
test/render/test-cases/path-antialiasing.spec.ts) and coverage assertions (test/render/path-antialiasing.spec.ts), both on a device created withantialias: false.test/modules/layers/antialiasing.spec.ts) and composite forwarding (test/modules/layers/antialiasing-composite.spec.ts).runRenderTestSuiteacceptswebglcontext attributes, andTestCase.imageDiffOptionsthreadsincludeAAthrough (and now honourstolerance, previously declared but ignored).Docs
@deck.gl/google-maps,@deck.gl/arcgis,PostProcessEffect, and@deck.gl/mapbox. Each points inward at the layer prop; core layer docs carry no integration commentary.dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md, covering the design, prior art, alternatives, limitations and testing rationale.No CHANGELOG entry (maintained at release time).
Notes for reviewers
antialias: trueon the map? That has been the standing answer since #5742 and is still right where it applies — the RFC positions this prop as complementary, not a replacement. Two things have narrowed it: MapLibre v5 moved the option intocanvasContextAttributes, so the top-level form silently does nothing on current versions, and it was never available for Google Maps, ArcGIS, offscreen targets or WebGPU.falseeverywhere, so every existing render output is byte-identical.PathStyleExtension'soffset(feather collapsed to 0.328×) and under perspective foreshortening.includeAA: true— pixelmatch drops antialiased pixels from the mismatch count by default, and this prop changes nothing else, so a diff without it passes with the feature entirely removed.Note
Medium Risk
Shader and uniform changes affect core stroke rendering on both backends; default-off preserves existing output, but enabling the prop changes blending at edges and has documented overlap/flat-cap limitations.
Overview
Adds opt-in analytic antialiasing for stroked geometry when framebuffer MSAA is missing (interleaved maps, offscreen targets, WebGPU, ArcGIS, post-process effects).
PathLayerandLineLayergainantialiasing(defaultfalse);GeoJsonLayerandPolygonLayerforwardlineAntialiasingto strokePathLayersublayers.Fragment shaders (GLSL + WGSL) feather stroke width using
fwidth-based device-pixel edge distance andsmoothedge, applied before premultiplication on WebGPU. Path handling distinguishes body vs rounded corner silhouettes; line/path ends stay hard along the stroke direction.project.wgsl.tsremaps clip-space Z from WebGL convention to WebGPU so depth is not half-clipped.Render tests: optional
webgl: { antialias: false },includeAA/tolerancein image diffs, goldenpath-antialiasing, framebuffer coverage tests, WebGPU premultiplied-alpha check; CI runs tests underxvfb-runand extends Chromium flags for headless WebGPU capture. Docs/RFC describe when to use the prop vs host MSAA.Reviewed by Cursor Bugbot for commit 14434fe. Bugbot is set up for automated code reviews on this repo. Configure here.