Skip to content

feat(layers): analytic antialiasing for PathLayer and LineLayer - #10520

Open
chrisgervang wants to merge 22 commits into
masterfrom
claude/deckgl-path-aliasing-4tx9a3
Open

feat(layers): analytic antialiasing for PathLayer and LineLayer#10520
chrisgervang wants to merge 22 commits into
masterfrom
claude/deckgl-path-aliasing-4tx9a3

Conversation

@chrisgervang

@chrisgervang chrisgervang commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Background

PathLayer and LineLayer have no antialiasing of their own; their edges are smoothed entirely by the framebuffer's MSAA. This adds 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; "this prop" is antialiasing: true (lineAntialiasing on composite layers).

Situation MSAA today Host MSAA luma.gl#2741 This prop Recommended
Standalone canvas yes on by default optional nothing needed
Standalone + PostProcessEffect (#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) 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, #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.

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 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, 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 for every layer rather than for these two.

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 shows the honest comparison where MSAA genuinely is available; the fourth shows how easily that stops applying.

Change List

Layers

  • antialiasing prop on PathLayer and LineLayer, defaulting to false. TripsLayer inherits it by subclassing PathLayer.
  • lineAntialiasing on PolygonLayer and GeoJsonLayer, following the existing pointAntialiasing precedent. 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.
  • Analytic edge coverage in the GLSL and WGSL fragment shaders for both leaf layers; no vertex shader changes.

Tests

  • Golden image (test/render/test-cases/path-antialiasing.spec.ts) and coverage assertions (test/render/path-antialiasing.spec.ts), both on a device created with antialias: false.
  • Uniform plumbing (test/modules/layers/antialiasing.spec.ts) and composite forwarding (test/modules/layers/antialiasing-composite.spec.ts).
  • Render harness: runRenderTestSuite accepts webgl context attributes, and TestCase.imageDiffOptions threads includeAA through (and now honours tolerance, previously declared but ignored).

Docs

No CHANGELOG entry (maintained at release time).

Notes for reviewers

  • Why not just antialias: true on 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 into canvasContextAttributes, so the top-level form silently does nothing on current versions, and it was never available for Google Maps, ArcGIS, offscreen targets or WebGPU.
  • Default is false everywhere, so every existing render output is byte-identical.
  • Coverage comes from screen-space derivatives, not a stroke-width varying. The varying approach was implemented first and was wrong under PathStyleExtension's offset (feather collapsed to 0.328×) and under perspective foreshortening.
  • A golden image needs 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). PathLayer and LineLayer gain antialiasing (default false); GeoJsonLayer and PolygonLayer forward lineAntialiasing to stroke PathLayer sublayers.

Fragment shaders (GLSL + WGSL) feather stroke width using fwidth-based device-pixel edge distance and smoothedge, applied before premultiplication on WebGPU. Path handling distinguishes body vs rounded corner silhouettes; line/path ends stay hard along the stroke direction.

project.wgsl.ts remaps clip-space Z from WebGL convention to WebGPU so depth is not half-clipped.

Render tests: optional webgl: { antialias: false }, includeAA / tolerance in image diffs, golden path-antialiasing, framebuffer coverage tests, WebGPU premultiplied-alpha check; CI runs tests under xvfb-run and 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.

claude added 6 commits August 1, 2026 23:11
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
Comment thread modules/layers/src/path-layer/path-layer-fragment.glsl.ts Outdated
@chrisgervang
chrisgervang marked this pull request as draft August 2, 2026 00:59
@coveralls

coveralls commented Aug 2, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 83.914% (+0.8%) from 83.077% — claude/deckgl-path-aliasing-4tx9a3 into master

claude added 7 commits August 2, 2026 01:02
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
claude added 3 commits August 2, 2026 02:05
…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
chrisgervang pushed a commit that referenced this pull request Aug 2, 2026
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
@Pessimistress

Copy link
Copy Markdown
Collaborator

There is a utility shader function smoothedge defined in https://github.com/visgl/deck.gl/blob/master/modules/core/src/shaderlib/misc/geometry.ts I believe scatterplot layer is using it.

@Pessimistress

Copy link
Copy Markdown
Collaborator

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

Copy link
Copy Markdown
Collaborator Author

Thanks — both addressed in 2d98227.

smoothedge — now used at all four coverage sites. I'd looked at it early and passed because SMOOTH_EDGE_RADIUS is a fixed 0.5 CSS pixels and not DPR-aware, but that objection belonged to an earlier implementation that carried the stroke half-width down as a varying. That's gone; coverage now comes from screen-space derivatives, so edgePixels is already a signed device-pixel distance and smoothedge(0.0, edgePixels) is exactly smoothstep(-0.5, 0.5, edgePixels). Units line up. Falloff goes from linear to cubic, the existing goldens absorb it, and both regression guards still fire.

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 antialiasing: true unconditionally dropped the WebGL match to 98.28%, since it layers analytic coverage on top of MSAA that's already there. So it's now set per device — on for WebGPU only. That way one golden serves both backends, and the WebGL case keeps covering the default configuration with its golden untouched.

What I couldn't verify is the WebGPU side, because WebGPU rasterizes nothing under the software renderer this suite runs on. line-lnglat comes back as a completely blank frame — and so does every path-layer case. The device is created, navigator.gpu is present, commands submit with no validation errors, and the output is pure white. The 84.32% match is that blank frame against the golden, and it's identical before and after this change, so antialiasing isn't what that number is measuring.

Since test-ci includes --project render and CI uses the same --use-angle=swiftshader --enable-unsafe-webgpu chromium, enabling it looked likely to red CI for a reason unrelated to this PR. If WebGPU does rasterize in your environment, it's a one-line uncomment and I'd expect it to pass now — happy to flip it if you confirm, or if you'd rather see the CI result I can enable it and we can judge from that.

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 _framebuffer also came back empty on WebGPU. I'd attributed that to the incomplete post-processing area of #9504, but blank-everything is the simpler explanation.


Generated by Claude Code

@ibgreen-openai

Copy link
Copy Markdown
Collaborator

@chrisgervang Very cool, this could have some potential.

For this technique I am thinking it might benefit from being done in premultiplied colors.
WebGPU shaders already return pre-multiplied colors but in most cases as a last step, they don't do blending math in premultiplied space, which could make this less effective than it could be

claude added 2 commits August 3, 2026 16:23
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

Copy link
Copy Markdown
Collaborator Author

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 cause

Viewports build projection matrices for the WebGL clip volume, -w ≤ z ≤ w. WebGPU's is 0 ≤ z ≤ w. A default OrthographicView projects to z/w = -0.998, so every primitive fell in the half of the range WebGPU clips, and nothing rasterized. Fixed in d7996ed by remapping depth where the clip position is assembled — the only place in WGSL where the convention differs. The GLSL path is a separate file and is untouched.

How it was isolated

probe result
Raw WebGPU — clear + readback, no luma [255,0,0,255] — rasterizes
luma Model triangle → offscreen FBO → readback [0,0,255,255] — draws
deck ScatterplotLayer / LineLayer blank
deck's own model, driven manually in a plain luma pass blank, with drawResult=true, pipelineErrored=false, validationError=null

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 w = 1, z/w ≈ -0.998 — outside WebGPU's clip volume.

Two things I chased that were dead ends, recorded so nobody repeats them:

  • Chromium flags. Adding --ignore-gpu-blocklist and --use-webgpu-adapter=swiftshader to match luma's set changed nothing. Reverted.
  • Uniform buffers reading back all-zero. An artifact — UniformStore allocates them UNIFORM | COPY_DST, and readAsync copies through a staging buffer needing COPY_SRC. The copy fails inside an error scope and the staging buffer stays zero. Uniform buffers can't be read back that way.

Result

Reading back an offscreen framebuffer, LineLayer, PathLayer and ScatterplotLayer all now paint on WebGPU within a few pixels of their WebGL counts, under both orthographic and lng/lat viewports:

webgl/line 450    webgpu/line 450
webgl/path 700    webgpu/path 700
webgl/scatterplot 1304    webgpu/scatterplot 1264   (WebGL's MSAA edges)

On enabling the LineLayer WebGPU render test

Still 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 antialiasing: deviceType === 'webgpu' wiring is committed and ready; the row just needs CI with hardware WebGPU. Comment and RFC updated to say this accurately.

Note the depth fix is @deck.gl/core and unrelated to antialiasing — happy to split d7996ed into its own PR if you'd prefer to review it separately.

Full render suite re-run: 168 passing, 4 pre-existing network failures (scenegraph duck ×2, terrain ×2), no goldens moved.


Generated by Claude Code

Comment on lines +287 to +293
**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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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

Copy link
Copy Markdown
Collaborator Author

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 deckgl_premultiplied_alpha in both layers (path-layer.wgsl.ts:278-284, line-layer.wgsl.ts:170-172:200), giving (rgb·a·c, a·c) — algebraically the same as scaling the premultiplied vec4 by coverage. Nothing defended that ordering though, so 2f6f0b6 adds a guard: 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.

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 discard-defined edges together. Written up in the RFC as parked, for three reasons: luma never maps sampleAlphaToCoverageEnabled on WebGL (declared in core/.../parameters.ts, no case in webgl/.../device-parameters.ts — silent no-op); on WebGPU it maps but needs sampleCount > 1, so it waits on luma.gl#2741; and the mask comes from fragment alpha while deck blends SRC_ALPHA, so coverage gets counted twice unless an explicit @builtin(sample_mask) separates it from opacity. Worth revisiting once #2741 lands.


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
@chrisgervang

chrisgervang commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

@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

@chrisgervang

Copy link
Copy Markdown
Collaborator Author

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

@chrisgervang
chrisgervang marked this pull request as ready for review August 5, 2026 17:14
// 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 241d28b. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 14434fe. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants