From 69a3ce658f09b9f9314e974546a359cf400c58d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 23:11:45 +0000 Subject: [PATCH 01/21] feat(layers): add antialiasing prop to PathLayer and LineLayer 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- CHANGELOG.md | 4 + docs/api-reference/layers/line-layer.md | 10 +++ docs/api-reference/layers/path-layer.md | 10 +++ docs/api-reference/mapbox/mapbox-overlay.md | 9 ++ .../line-layer/line-layer-fragment.glsl.ts | 9 ++ .../src/line-layer/line-layer-uniforms.ts | 3 + .../src/line-layer/line-layer-vertex.glsl.ts | 7 ++ modules/layers/src/line-layer/line-layer.ts | 17 +++- .../layers/src/line-layer/line-layer.wgsl.ts | 19 +++- .../path-layer/path-layer-fragment.glsl.ts | 22 ++++- .../src/path-layer/path-layer-uniforms.ts | 4 + .../src/path-layer/path-layer-vertex.glsl.ts | 8 ++ modules/layers/src/path-layer/path-layer.ts | 12 +++ .../layers/src/path-layer/path-layer.wgsl.ts | 34 +++++++- test/modules/layers/antialiasing.spec.ts | 86 +++++++++++++++++++ test/modules/layers/index.ts | 1 + 16 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 test/modules/layers/antialiasing.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eef056c244..22f761d95d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ Ref: http://keepachangelog.com/en/0.3.0/ ### deck.gl v9.4 Prereleases +#### Unreleased + +- feat(layers): add `antialiasing` prop to PathLayer and LineLayer for analytic edge smoothing, for contexts without MSAA such as interleaved base map rendering + #### deck.gl [v9.4.0-alpha.2] - Jul 29 2026 - fix: add npm metadata to all modules (#10358) diff --git a/docs/api-reference/layers/line-layer.md b/docs/api-reference/layers/line-layer.md index 7217e44a87d..9cdd71a2517 100644 --- a/docs/api-reference/layers/line-layer.md +++ b/docs/api-reference/layers/line-layer.md @@ -196,6 +196,16 @@ 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. Edge coverage is computed analytically from the line geometry, so it does not depend on the WebGL context being created with multisampling. + +Enable this when the context has antialiasing disabled. The most common case is [interleaved](../mapbox/mapbox-overlay.md) rendering into a base map: both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false`, so deck.gl layers drawn into it receive no antialiasing from the framebuffer. Alternatively, enable MSAA on the base map itself — in MapLibre GL JS v5 that is `canvasContextAttributes: {antialias: true}` on the `Map` constructor, which is more expensive at high resolutions. + +Only the edges along the width of the line are smoothed; the two ends are left hard. Antialiasing can cause artifacts where lines overlap, since the blended edges are composited twice. + ### Data Accessors diff --git a/docs/api-reference/layers/path-layer.md b/docs/api-reference/layers/path-layer.md index 1d8c94692af..98efc7f457e 100644 --- a/docs/api-reference/layers/path-layer.md +++ b/docs/api-reference/layers/path-layer.md @@ -217,6 +217,16 @@ 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. Edge coverage is computed analytically from the stroke geometry, so it does not depend on the WebGL context being created with multisampling. + +Enable this when the context has antialiasing disabled. The most common case is [interleaved](../mapbox/mapbox-overlay.md) rendering into a base map: both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false`, so deck.gl layers drawn into it receive no antialiasing from the framebuffer. Alternatively, enable MSAA on the base map itself — in MapLibre GL JS v5 that is `canvasContextAttributes: {antialias: true}` on the `Map` constructor, which is more expensive at high resolutions. + +Only the edges along the width of the path are smoothed. Flat (non-rounded) caps at the two ends of a path are left hard; set `capRounded` to `true` if those need smoothing too. Antialiasing can cause artifacts where a path overlaps itself, since the blended edges are composited twice. + #### `_pathType` (object, optional) {#_pathtype} * Default: `null` diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index c5fca987120..dfa6d07681c 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -115,6 +115,15 @@ new MapboxOverlay(props: MapboxOverlayProps); - `controller` - always disabled (to use Mapbox's interaction handlers). - `useDevicePixels` - ignored in interleaved mode, where the base map owns the WebGL context and the canvas drawing buffer size. To control pixel ratio in interleaved mode, use MapLibre's [`pixelRatio`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/#pixelratio) constructor option on the Map instance. Mapbox GL JS does not expose an equivalent option. +### Antialiasing in interleaved mode + +Both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false` as a performance optimization, so in interleaved mode deck.gl layers receive no multisampling from the framebuffer. Layers whose edges rely on it — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — will look aliased next to the crisp base map lines, which compute their own coverage in the shader. + +There are two ways to fix this: + +- Set `antialiasing: true` on the affected layers. Coverage is computed analytically in the shader, at no extra memory cost and independent of the context. +- Or enable MSAA on the base map. In MapLibre GL JS v5 this is `canvasContextAttributes: {antialias: true}` on the `Map` constructor — note that in v5 the top-level `antialias` option is no longer read, so it must be nested. This antialiases every layer at once, but multisampling the whole canvas is significantly more expensive at high resolutions. + The constructor additionally accepts the following options: - `interleaved` (boolean) - If `false`, a dedicated deck.gl canvas is added on top of the base map. If `true`, deck.gl layers are inserted into mapbox-gl's layer stack, and share the same `WebGL2RenderingContext` as the base map. Default is `false`. Note that interleaving with basemaps such as mapbox-gl-js v1 that only support WebGL 1 is not supported, see [compatibility](./overview#interleaved-renderer-compatibility). 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..533815fb3a8 100644 --- a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts @@ -10,6 +10,7 @@ precision highp float; in vec4 vColor; in vec2 uv; +in float vHalfWidthDevicePixels; out vec4 fragColor; @@ -18,6 +19,14 @@ void main(void) { fragColor = vColor; + if (line.antialiasing) { + // Distance to the edge of the line, in device pixels. Only the across-width silhouette is + // feathered - the ends butt against neighboring segments in a multi-segment path. + // Spread the transition over exactly one device pixel, centered on the edge. + float edgeDistance = (1.0 - abs(uv.y)) * vHalfWidthDevicePixels; + fragColor.a *= clamp(edgeDistance + 0.5, 0.0, 1.0); + } + 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-vertex.glsl.ts b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts index d07e21edca4..e0be5c29a23 100644 --- a/modules/layers/src/line-layer/line-layer-vertex.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts @@ -16,6 +16,10 @@ in float instanceWidths; out vec4 vColor; out vec2 uv; +// Half of the line width, in device pixels. uv.y runs [-1, 1] across the width, so multiplying by +// this converts it to a device-pixel distance for analytic edge coverage. Device pixel ratio is +// folded in here because the project module is vertex-stage only. +out float vHalfWidthDevicePixels; // offset vector by strokeWidth pixels // offset_direction is -1 (left) or 1 (right) @@ -87,6 +91,9 @@ void main(void) { getExtrusionOffset(target.xy - source.xy, positions.y, widthPixels), 0.0); DECKGL_FILTER_SIZE(offset, geometry); + // getExtrusionOffset returns a unit direction scaled by half the width, so the magnitude of the + // (possibly filtered) offset is the half-width in pixels + vHalfWidthDevicePixels = length(offset.xy) * project.devicePixelRatio; DECKGL_FILTER_GL_POSITION(p, geometry); gl_Position = p + vec4(project_pixel_size_to_clipspace(offset.xy), 0.0, 0.0); diff --git a/modules/layers/src/line-layer/line-layer.ts b/modules/layers/src/line-layer/line-layer.ts index 48cabff89ac..d31cd578370 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,16 @@ type _LineLayerProps = { */ widthMaxPixels?: number; + /** + * If `true`, lines are rendered with smoothed edges, computed analytically from the line + * geometry rather than relying on the framebuffer's MSAA. Useful when the WebGL context has + * antialiasing disabled — notably interleaved rendering into a base map, since MapLibre and + * Mapbox create their context with `antialias: false`. Antialiasing can cause artifacts where + * lines overlap. + * @default false + */ + antialiasing?: boolean; + /** * Source position of each object. * @default object => object.sourcePosition @@ -169,13 +180,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..6d021d13186 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, }; @@ -57,6 +58,10 @@ struct Varyings { @location(0) vColor: vec4, @location(1) uv: vec2, @location(2) pickingColor: vec3, + // Half of the line width, in device pixels. uv.y runs [-1, 1] across the width, so multiplying + // by this converts it to a device-pixel distance for analytic edge coverage. Device pixel ratio + // is folded in here because project is vertex-stage only. + @location(3) vHalfWidthDevicePixels: f32, }; // ---------- Vertex Shader Entry Point ---------- @@ -145,6 +150,9 @@ fn vertexMain( output.vColor = vColor; output.uv = uv; output.pickingColor = geometry.pickingColor; + // getExtrusionOffset returns a unit direction scaled by half the width, so the magnitude of the + // filtered offset is the half-width in pixels + output.vHalfWidthDevicePixels = length(filteredOffset.xy) * project.devicePixelRatio; return output; } @@ -152,7 +160,8 @@ fn vertexMain( fn fragmentMain( @location(0) vColor: vec4, @location(1) uv: vec2, - @location(2) pickingColor: vec3 + @location(2) pickingColor: vec3, + @location(3) vHalfWidthDevicePixels: f32 ) -> @location(0) vec4 { // Create and initialize geometry with the provided uv. var geometry: Geometry; @@ -161,6 +170,14 @@ fn fragmentMain( // Start with the input color. var fragColor: vec4 = vColor; + if (line.antialiasing != 0.0) { + // Distance to the edge of the line, in device pixels. Only the across-width silhouette is + // feathered - the ends butt against neighboring segments in a multi-segment path. + // Spread the transition over exactly one device pixel, centered on the edge. + let edgeDistance = (1.0 - abs(uv.y)) * vHalfWidthDevicePixels; + fragColor.a *= clamp(edgeDistance + 0.5, 0.0, 1.0); + } + 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..afc52e06d0b 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -19,24 +19,40 @@ in float vMiterLength; in vec2 vPathPosition; in float vPathLength; in float vJointType; +in float vHalfWidthDevicePixels; 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; + + 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) { + // Signed distance to the outer silhouette, in units of half-width. Rounded joints and caps + // are bounded by the corner offset; everywhere else the boundary is the edge of the stroke. + // Only the across-width silhouette is feathered - consecutive segment instances abut along + // the length of the path, so feathering there would leave a seam at every vertex. + float edgeDistance = isRound && isCorner + ? 1.0 - length(vCornerOffset) + : 1.0 - abs(vPathPosition.x); + // Spread the transition over exactly one device pixel, centered on the edge + fragColor.a *= clamp(edgeDistance * vHalfWidthDevicePixels + 0.5, 0.0, 1.0); + } + 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-vertex.glsl.ts b/modules/layers/src/path-layer/path-layer-vertex.glsl.ts index f9493a19a4f..759349f2603 100644 --- a/modules/layers/src/path-layer/path-layer-vertex.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-vertex.glsl.ts @@ -29,6 +29,10 @@ out float vMiterLength; out vec2 vPathPosition; out float vPathLength; out float vJointType; +// Half of the stroke width, in device pixels. vCornerOffset and vPathPosition.x are in units of +// half-width, so multiplying by this converts them to device-pixel distances for analytic edge +// coverage. Device pixel ratio is folded in here because the project module is vertex-stage only. +out float vHalfWidthDevicePixels; const float EPSILON = 0.001; const vec3 ZERO_OFFSET = vec3(0.0); @@ -181,6 +185,8 @@ void main() { width = vec3(widthPixels, 0.0); DECKGL_FILTER_SIZE(width, geometry); + // Already in pixels in this branch + vHalfWidthDevicePixels = width.x * project.devicePixelRatio; vec3 offset = getLineJoinOffset( prevPositionScreen.xyz / prevPositionScreen.w, @@ -199,6 +205,8 @@ void main() { width = vec3(project_pixel_size(widthPixels), 0.0); DECKGL_FILTER_SIZE(width, geometry); + // width is in common space here; project.scale is the inverse of project_pixel_size + vHalfWidthDevicePixels = width.x * project.scale * project.devicePixelRatio; vec3 offset = getLineJoinOffset(prevPosition, currPosition, nextPosition, width.xy); geometry.position = vec4(currPosition + offset, 1.0); diff --git a/modules/layers/src/path-layer/path-layer.ts b/modules/layers/src/path-layer/path-layer.ts index a7e905e0233..0e7146c2746 100644 --- a/modules/layers/src/path-layer/path-layer.ts +++ b/modules/layers/src/path-layer/path-layer.ts @@ -62,6 +62,15 @@ type _PathLayerProps = { * @default 4 */ miterLimit?: number; + /** + * If `true`, paths are rendered with smoothed edges, computed analytically from the stroke + * geometry rather than relying on the framebuffer's MSAA. Useful when the WebGL context has + * antialiasing disabled — notably interleaved rendering into a base map, since MapLibre and + * Mapbox create their context with `antialias: false`. Antialiasing can cause artifacts where + * a path overlaps itself. + * @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 +115,7 @@ const defaultProps: DefaultProps = { jointRounded: false, capRounded: false, miterLimit: {type: 'number', min: 0, value: 4}, + antialiasing: false, billboard: false, _pathType: null, @@ -335,6 +345,7 @@ export default class PathLayer extends jointRounded, capRounded, billboard, + antialiasing, miterLimit, widthUnits, widthScale, @@ -347,6 +358,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..1c46fae473d 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -39,6 +39,10 @@ struct Varyings { @location(3) vPathPosition: vec2, @location(4) vPathLength: f32, @location(5) vJointType: f32, + // Half of the stroke width, in device pixels. vCornerOffset and vPathPosition.x are in units + // of half-width, so multiplying by this converts them to device-pixel distances for analytic + // edge coverage. Device pixel ratio is folded in here because project is vertex-stage only. + @location(6) vHalfWidthDevicePixels: f32, }; fn flipIfTrue(flag: bool) -> f32 { @@ -177,6 +181,8 @@ fn vertexMain(attributes: Attributes) -> Varyings { path.widthMaxPixels ) / 2.0; + varyings.vHalfWidthDevicePixels = widthPixels * project.devicePixelRatio; + if (path.billboard != 0.0) { var prevPositionScreen = project_position_to_clipspace(prevPosition, prevPosition64Low, ZERO_OFFSET); var currPositionScreen = project_position_to_clipspace(currPosition, currPosition64Low, ZERO_OFFSET); @@ -244,15 +250,35 @@ 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) { + let isCorner = varyings.vPathPosition.y < 0.0 || varyings.vPathPosition.y > varyings.vPathLength; + let isRound = varyings.vJointType > 0.5; + + 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) { + // Signed distance to the outer silhouette, in units of half-width. Rounded joints and caps + // are bounded by the corner offset; everywhere else the boundary is the edge of the stroke. + // Only the across-width silhouette is feathered - consecutive segment instances abut along + // the length of the path, so feathering there would leave a seam at every vertex. + let edgeDistance = select( + 1.0 - abs(varyings.vPathPosition.x), + 1.0 - length(varyings.vCornerOffset), + isRound && isCorner + ); + // Spread the transition over exactly one device pixel, centered on the edge. + // Applied before premultiplication so the color channels scale with coverage too. + color.a *= clamp(edgeDistance * varyings.vHalfWidthDevicePixels + 0.5, 0.0, 1.0); + } + + return deckgl_premultiplied_alpha(color); } `; 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..55dc9c3bd19 100644 --- a/test/modules/layers/index.ts +++ b/test/modules/layers/index.ts @@ -21,3 +21,4 @@ import './column-geometry.spec'; import './column-layer.spec'; import './utils.spec'; import './scatterplot-layer.spec'; +import './antialiasing.spec'; From 6b7f3a1a9dc266e044d2b9c91c25f1b2e131e4cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:08:32 +0000 Subject: [PATCH 02/21] fix(layers): derive antialiasing coverage from screen-space derivatives 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- CHANGELOG.md | 4 --- docs/api-reference/layers/line-layer.md | 6 +--- docs/api-reference/layers/path-layer.md | 6 +--- docs/api-reference/mapbox/mapbox-overlay.md | 13 +++------ .../line-layer/line-layer-fragment.glsl.ts | 11 ++++---- .../src/line-layer/line-layer-vertex.glsl.ts | 7 ----- modules/layers/src/line-layer/line-layer.ts | 8 ++---- .../layers/src/line-layer/line-layer.wgsl.ts | 24 +++++++--------- .../path-layer/path-layer-fragment.glsl.ts | 22 +++++++++------ .../src/path-layer/path-layer-vertex.glsl.ts | 8 ------ modules/layers/src/path-layer/path-layer.ts | 8 ++---- .../layers/src/path-layer/path-layer.wgsl.ts | 28 +++++++++---------- 12 files changed, 56 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22f761d95d4..6eef056c244 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,6 @@ Ref: http://keepachangelog.com/en/0.3.0/ ### deck.gl v9.4 Prereleases -#### Unreleased - -- feat(layers): add `antialiasing` prop to PathLayer and LineLayer for analytic edge smoothing, for contexts without MSAA such as interleaved base map rendering - #### deck.gl [v9.4.0-alpha.2] - Jul 29 2026 - fix: add npm metadata to all modules (#10358) diff --git a/docs/api-reference/layers/line-layer.md b/docs/api-reference/layers/line-layer.md index 9cdd71a2517..4d5dad9ece5 100644 --- a/docs/api-reference/layers/line-layer.md +++ b/docs/api-reference/layers/line-layer.md @@ -200,11 +200,7 @@ The maximum line width in pixels. This prop can be used to prevent the line from * Default: `false` -If `true`, lines are rendered with smoothed edges. Edge coverage is computed analytically from the line geometry, so it does not depend on the WebGL context being created with multisampling. - -Enable this when the context has antialiasing disabled. The most common case is [interleaved](../mapbox/mapbox-overlay.md) rendering into a base map: both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false`, so deck.gl layers drawn into it receive no antialiasing from the framebuffer. Alternatively, enable MSAA on the base map itself — in MapLibre GL JS v5 that is `canvasContextAttributes: {antialias: true}` on the `Map` constructor, which is more expensive at high resolutions. - -Only the edges along the width of the line are smoothed; the two ends are left hard. Antialiasing can cause artifacts where lines overlap, since the blended edges are composited twice. +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. ### Data Accessors diff --git a/docs/api-reference/layers/path-layer.md b/docs/api-reference/layers/path-layer.md index 98efc7f457e..c27667528b3 100644 --- a/docs/api-reference/layers/path-layer.md +++ b/docs/api-reference/layers/path-layer.md @@ -221,11 +221,7 @@ Only works if `jointRounded` is `false`. * Default: `false` -If `true`, paths are rendered with smoothed edges. Edge coverage is computed analytically from the stroke geometry, so it does not depend on the WebGL context being created with multisampling. - -Enable this when the context has antialiasing disabled. The most common case is [interleaved](../mapbox/mapbox-overlay.md) rendering into a base map: both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false`, so deck.gl layers drawn into it receive no antialiasing from the framebuffer. Alternatively, enable MSAA on the base map itself — in MapLibre GL JS v5 that is `canvasContextAttributes: {antialias: true}` on the `Map` constructor, which is more expensive at high resolutions. - -Only the edges along the width of the path are smoothed. Flat (non-rounded) caps at the two ends of a path are left hard; set `capRounded` to `true` if those need smoothing too. Antialiasing can cause artifacts where a path overlaps itself, since the blended edges are composited twice. +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. #### `_pathType` (object, optional) {#_pathtype} diff --git a/docs/api-reference/mapbox/mapbox-overlay.md b/docs/api-reference/mapbox/mapbox-overlay.md index dfa6d07681c..9de8395e2aa 100644 --- a/docs/api-reference/mapbox/mapbox-overlay.md +++ b/docs/api-reference/mapbox/mapbox-overlay.md @@ -115,15 +115,6 @@ new MapboxOverlay(props: MapboxOverlayProps); - `controller` - always disabled (to use Mapbox's interaction handlers). - `useDevicePixels` - ignored in interleaved mode, where the base map owns the WebGL context and the canvas drawing buffer size. To control pixel ratio in interleaved mode, use MapLibre's [`pixelRatio`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/#pixelratio) constructor option on the Map instance. Mapbox GL JS does not expose an equivalent option. -### Antialiasing in interleaved mode - -Both MapLibre GL JS and Mapbox GL JS create their WebGL context with `antialias: false` as a performance optimization, so in interleaved mode deck.gl layers receive no multisampling from the framebuffer. Layers whose edges rely on it — most visibly [PathLayer](../layers/path-layer.md) and [LineLayer](../layers/line-layer.md) — will look aliased next to the crisp base map lines, which compute their own coverage in the shader. - -There are two ways to fix this: - -- Set `antialiasing: true` on the affected layers. Coverage is computed analytically in the shader, at no extra memory cost and independent of the context. -- Or enable MSAA on the base map. In MapLibre GL JS v5 this is `canvasContextAttributes: {antialias: true}` on the `Map` constructor — note that in v5 the top-level `antialias` option is no longer read, so it must be nested. This antialiases every layer at once, but multisampling the whole canvas is significantly more expensive at high resolutions. - The constructor additionally accepts the following options: - `interleaved` (boolean) - If `false`, a dedicated deck.gl canvas is added on top of the base map. If `true`, deck.gl layers are inserted into mapbox-gl's layer stack, and share the same `WebGL2RenderingContext` as the base map. Default is `false`. Note that interleaving with basemaps such as mapbox-gl-js v1 that only support WebGL 1 is not supported, see [compatibility](./overview#interleaved-renderer-compatibility). @@ -174,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/layers/src/line-layer/line-layer-fragment.glsl.ts b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts index 533815fb3a8..0bedfcac8f6 100644 --- a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts @@ -10,7 +10,6 @@ precision highp float; in vec4 vColor; in vec2 uv; -in float vHalfWidthDevicePixels; out vec4 fragColor; @@ -20,11 +19,13 @@ void main(void) { fragColor = vColor; if (line.antialiasing) { - // Distance to the edge of the line, in device pixels. Only the across-width silhouette is - // feathered - the ends butt against neighboring segments in a multi-segment path. + // uv.y runs [-1, 1] across the width. Dividing the distance to the edge by the screen-space + // derivative converts it to device pixels, which stays correct under perspective + // foreshortening and at any device pixel ratio. Only the across-width silhouette is feathered + // - the ends butt against neighboring segments in a multi-segment path. // Spread the transition over exactly one device pixel, centered on the edge. - float edgeDistance = (1.0 - abs(uv.y)) * vHalfWidthDevicePixels; - fragColor.a *= clamp(edgeDistance + 0.5, 0.0, 1.0); + float edgeCoord = abs(uv.y); + fragColor.a *= clamp((1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6) + 0.5, 0.0, 1.0); } DECKGL_FILTER_COLOR(fragColor, geometry); diff --git a/modules/layers/src/line-layer/line-layer-vertex.glsl.ts b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts index e0be5c29a23..d07e21edca4 100644 --- a/modules/layers/src/line-layer/line-layer-vertex.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-vertex.glsl.ts @@ -16,10 +16,6 @@ in float instanceWidths; out vec4 vColor; out vec2 uv; -// Half of the line width, in device pixels. uv.y runs [-1, 1] across the width, so multiplying by -// this converts it to a device-pixel distance for analytic edge coverage. Device pixel ratio is -// folded in here because the project module is vertex-stage only. -out float vHalfWidthDevicePixels; // offset vector by strokeWidth pixels // offset_direction is -1 (left) or 1 (right) @@ -91,9 +87,6 @@ void main(void) { getExtrusionOffset(target.xy - source.xy, positions.y, widthPixels), 0.0); DECKGL_FILTER_SIZE(offset, geometry); - // getExtrusionOffset returns a unit direction scaled by half the width, so the magnitude of the - // (possibly filtered) offset is the half-width in pixels - vHalfWidthDevicePixels = length(offset.xy) * project.devicePixelRatio; DECKGL_FILTER_GL_POSITION(p, geometry); gl_Position = p + vec4(project_pixel_size_to_clipspace(offset.xy), 0.0, 0.0); diff --git a/modules/layers/src/line-layer/line-layer.ts b/modules/layers/src/line-layer/line-layer.ts index d31cd578370..9f13bbcb5e5 100644 --- a/modules/layers/src/line-layer/line-layer.ts +++ b/modules/layers/src/line-layer/line-layer.ts @@ -70,11 +70,9 @@ type _LineLayerProps = { widthMaxPixels?: number; /** - * If `true`, lines are rendered with smoothed edges, computed analytically from the line - * geometry rather than relying on the framebuffer's MSAA. Useful when the WebGL context has - * antialiasing disabled — notably interleaved rendering into a base map, since MapLibre and - * Mapbox create their context with `antialias: false`. Antialiasing can cause artifacts where - * lines overlap. + * 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; diff --git a/modules/layers/src/line-layer/line-layer.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts index 6d021d13186..598918ae695 100644 --- a/modules/layers/src/line-layer/line-layer.wgsl.ts +++ b/modules/layers/src/line-layer/line-layer.wgsl.ts @@ -58,10 +58,6 @@ struct Varyings { @location(0) vColor: vec4, @location(1) uv: vec2, @location(2) pickingColor: vec3, - // Half of the line width, in device pixels. uv.y runs [-1, 1] across the width, so multiplying - // by this converts it to a device-pixel distance for analytic edge coverage. Device pixel ratio - // is folded in here because project is vertex-stage only. - @location(3) vHalfWidthDevicePixels: f32, }; // ---------- Vertex Shader Entry Point ---------- @@ -150,9 +146,6 @@ fn vertexMain( output.vColor = vColor; output.uv = uv; output.pickingColor = geometry.pickingColor; - // getExtrusionOffset returns a unit direction scaled by half the width, so the magnitude of the - // filtered offset is the half-width in pixels - output.vHalfWidthDevicePixels = length(filteredOffset.xy) * project.devicePixelRatio; return output; } @@ -160,8 +153,7 @@ fn vertexMain( fn fragmentMain( @location(0) vColor: vec4, @location(1) uv: vec2, - @location(2) pickingColor: vec3, - @location(3) vHalfWidthDevicePixels: f32 + @location(2) pickingColor: vec3 ) -> @location(0) vec4 { // Create and initialize geometry with the provided uv. var geometry: Geometry; @@ -170,12 +162,16 @@ fn fragmentMain( // Start with the input color. var fragColor: vec4 = vColor; + // uv.y runs [-1, 1] across the width. Dividing the distance to the edge by the screen-space + // derivative converts it to device pixels, which stays correct under perspective foreshortening + // and at any device pixel ratio. Taken in uniform control flow, ahead of the picking discard. + let edgeCoord = abs(uv.y); + let edgePixels = (1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6); + if (line.antialiasing != 0.0) { - // Distance to the edge of the line, in device pixels. Only the across-width silhouette is - // feathered - the ends butt against neighboring segments in a multi-segment path. - // Spread the transition over exactly one device pixel, centered on the edge. - let edgeDistance = (1.0 - abs(uv.y)) * vHalfWidthDevicePixels; - fragColor.a *= clamp(edgeDistance + 0.5, 0.0, 1.0); + // Only the across-width silhouette is feathered - the ends butt against neighboring segments + // in a multi-segment path. Spread the transition over one device pixel, centered on the edge. + fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } if (picking.isActive > 0.5) { 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 afc52e06d0b..77d6ae7130b 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -19,7 +19,6 @@ in float vMiterLength; in vec2 vPathPosition; in float vPathLength; in float vJointType; -in float vHalfWidthDevicePixels; out vec4 fragColor; @@ -42,15 +41,22 @@ void main(void) { fragColor = vColor; if (path.antialiasing) { - // Signed distance to the outer silhouette, in units of half-width. Rounded joints and caps - // are bounded by the corner offset; everywhere else the boundary is the edge of the stroke. + // 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 + // or remap vPathPosition (PathStyleExtension's offset does both). + // Both branches are evaluated unconditionally - taking the derivative of a branched value + // would differentiate across the corner/body seam and corrupt every joint. + float bodyCoord = abs(vPathPosition.x); + float cornerCoord = length(vCornerOffset); + float bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + float cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); // Only the across-width silhouette is feathered - consecutive segment instances abut along // the length of the path, so feathering there would leave a seam at every vertex. - float edgeDistance = isRound && isCorner - ? 1.0 - length(vCornerOffset) - : 1.0 - abs(vPathPosition.x); - // Spread the transition over exactly one device pixel, centered on the edge - fragColor.a *= clamp(edgeDistance * vHalfWidthDevicePixels + 0.5, 0.0, 1.0); + // Spread the transition over exactly one device pixel, centered on the edge. + float edgePixels = isRound && isCorner ? cornerPixels : bodyPixels; + fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } DECKGL_FILTER_COLOR(fragColor, geometry); diff --git a/modules/layers/src/path-layer/path-layer-vertex.glsl.ts b/modules/layers/src/path-layer/path-layer-vertex.glsl.ts index 759349f2603..f9493a19a4f 100644 --- a/modules/layers/src/path-layer/path-layer-vertex.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-vertex.glsl.ts @@ -29,10 +29,6 @@ out float vMiterLength; out vec2 vPathPosition; out float vPathLength; out float vJointType; -// Half of the stroke width, in device pixels. vCornerOffset and vPathPosition.x are in units of -// half-width, so multiplying by this converts them to device-pixel distances for analytic edge -// coverage. Device pixel ratio is folded in here because the project module is vertex-stage only. -out float vHalfWidthDevicePixels; const float EPSILON = 0.001; const vec3 ZERO_OFFSET = vec3(0.0); @@ -185,8 +181,6 @@ void main() { width = vec3(widthPixels, 0.0); DECKGL_FILTER_SIZE(width, geometry); - // Already in pixels in this branch - vHalfWidthDevicePixels = width.x * project.devicePixelRatio; vec3 offset = getLineJoinOffset( prevPositionScreen.xyz / prevPositionScreen.w, @@ -205,8 +199,6 @@ void main() { width = vec3(project_pixel_size(widthPixels), 0.0); DECKGL_FILTER_SIZE(width, geometry); - // width is in common space here; project.scale is the inverse of project_pixel_size - vHalfWidthDevicePixels = width.x * project.scale * project.devicePixelRatio; vec3 offset = getLineJoinOffset(prevPosition, currPosition, nextPosition, width.xy); geometry.position = vec4(currPosition + offset, 1.0); diff --git a/modules/layers/src/path-layer/path-layer.ts b/modules/layers/src/path-layer/path-layer.ts index 0e7146c2746..61d53999996 100644 --- a/modules/layers/src/path-layer/path-layer.ts +++ b/modules/layers/src/path-layer/path-layer.ts @@ -63,11 +63,9 @@ type _PathLayerProps = { */ miterLimit?: number; /** - * If `true`, paths are rendered with smoothed edges, computed analytically from the stroke - * geometry rather than relying on the framebuffer's MSAA. Useful when the WebGL context has - * antialiasing disabled — notably interleaved rendering into a base map, since MapLibre and - * Mapbox create their context with `antialias: false`. Antialiasing can cause artifacts where - * a path overlaps itself. + * 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; diff --git a/modules/layers/src/path-layer/path-layer.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index 1c46fae473d..dc0acfe5946 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -39,10 +39,6 @@ struct Varyings { @location(3) vPathPosition: vec2, @location(4) vPathLength: f32, @location(5) vJointType: f32, - // Half of the stroke width, in device pixels. vCornerOffset and vPathPosition.x are in units - // of half-width, so multiplying by this converts them to device-pixel distances for analytic - // edge coverage. Device pixel ratio is folded in here because project is vertex-stage only. - @location(6) vHalfWidthDevicePixels: f32, }; fn flipIfTrue(flag: bool) -> f32 { @@ -181,8 +177,6 @@ fn vertexMain(attributes: Attributes) -> Varyings { path.widthMaxPixels ) / 2.0; - varyings.vHalfWidthDevicePixels = widthPixels * project.devicePixelRatio; - if (path.billboard != 0.0) { var prevPositionScreen = project_position_to_clipspace(prevPosition, prevPosition64Low, ZERO_OFFSET); var currPositionScreen = project_position_to_clipspace(currPosition, currPosition64Low, ZERO_OFFSET); @@ -250,6 +244,18 @@ fn vertexMain(attributes: Attributes) -> Varyings { fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { geometry.uv = varyings.vPathPosition; + // 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. + // Derivatives must be taken in uniform control flow, so they are hoisted above the discards + // below. Both branches are evaluated for the same reason the GLSL version does: taking the + // derivative of a branched value would differentiate across the corner/body seam. + let bodyCoord = abs(varyings.vPathPosition.x); + let cornerCoord = length(varyings.vCornerOffset); + let bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + let cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); + let isCorner = varyings.vPathPosition.y < 0.0 || varyings.vPathPosition.y > varyings.vPathLength; let isRound = varyings.vJointType > 0.5; @@ -265,18 +271,12 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { var color = varyings.vColor; if (path.antialiasing != 0.0) { - // Signed distance to the outer silhouette, in units of half-width. Rounded joints and caps - // are bounded by the corner offset; everywhere else the boundary is the edge of the stroke. // Only the across-width silhouette is feathered - consecutive segment instances abut along // the length of the path, so feathering there would leave a seam at every vertex. - let edgeDistance = select( - 1.0 - abs(varyings.vPathPosition.x), - 1.0 - length(varyings.vCornerOffset), - isRound && isCorner - ); // Spread the transition over exactly one device pixel, centered on the edge. // Applied before premultiplication so the color channels scale with coverage too. - color.a *= clamp(edgeDistance * varyings.vHalfWidthDevicePixels + 0.5, 0.0, 1.0); + let edgePixels = select(bodyPixels, cornerPixels, isRound && isCorner); + color.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } return deckgl_premultiplied_alpha(color); From 50e6277cba6ebc4a588d41d4dbf662a83fe72fff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:20:42 +0000 Subject: [PATCH 03/21] test(layers): add coverage-based render test for PathLayer antialiasing 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- test/render/path-antialiasing.spec.ts | 116 ++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/render/path-antialiasing.spec.ts diff --git a/test/render/path-antialiasing.spec.ts b/test/render/path-antialiasing.spec.ts new file mode 100644 index 00000000000..8b5f99a42fb --- /dev/null +++ b/test/render/path-antialiasing.spec.ts @@ -0,0 +1,116 @@ +// deck.gl +// SPDX-License-Identifier: MIT +// Copyright (c) vis.gl contributors + +// Antialiasing cannot be covered by a golden image here: 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 golden diff threshold. A golden test passes even +// with the feature completely disabled. These tests read the framebuffer instead and assert on +// the coverage itself. + +import {describe, test, expect} from 'vitest'; +import {OrthographicView} from '@deck.gl/core'; +import {PathLayer} from '@deck.gl/layers'; +import {PathStyleExtension} from '@deck.gl/extensions'; +import {createContainer, removeContainer, createTestDevice, createDeck} from './deck-test-utils'; + +const DIAGONALS = [0, 1, 2, 3, 4].map(i => ({ + // 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. + path: [ + [-150, -120 + i * 60], + [150, -120 + i * 60 + 8 + i * 12] + ] +})); + +type Coverage = {solid: number; partial: number; levels: number}; + +function measure(deck, device, layers): Promise { + return new Promise(resolve => { + deck.setProps({ + views: new OrthographicView(), + viewState: {target: [0, 0, 0], zoom: 0}, + layers, + onAfterRender: () => { + const gl = device.gl; + const [w, h] = device.canvasContext.getDrawingBufferSize(); + 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}); + } + }); + }); +} + +const BASE = { + data: DIAGONALS, + getPath: d => d.path, + getColor: [255, 180, 0] as [number, number, number], + getWidth: 6, + widthUnits: 'pixels' as const +}; + +describe('PathLayer#antialiasing', () => { + test('produces continuous coverage, and survives PathStyleExtension offset', async () => { + const container = createContainer(); + const device = await createTestDevice('webgl', container); + const deck = createDeck(container, device); + + const off = await measure(deck, device, [ + new PathLayer({...BASE, id: 'off', antialiasing: false}) + ]); + const on = await measure(deck, device, [ + new PathLayer({...BASE, id: 'on', antialiasing: true}) + ]); + const onOffset = await measure(deck, device, [ + new PathLayer({ + ...BASE, + id: 'on-offset', + antialiasing: true, + getOffset: 1, + extensions: [new PathStyleExtension({offset: true})] + }) + ]); + + // Sanity: all three actually drew something + expect(off.solid, 'antialiasing:false drew strokes').toBeGreaterThan(1000); + expect(on.solid, 'antialiasing:true drew strokes').toBeGreaterThan(1000); + expect(onOffset.solid, 'offset stroke drew strokes').toBeGreaterThan(1000); + + // Analytic coverage is continuous; MSAA alone quantizes to its sample count. Observed ~20 + // levels off vs ~200 on, so 3x is a wide margin against driver differences in sample count. + expect( + on.levels, + `antialiasing:true should produce far more distinct alpha levels than MSAA alone ` + + `(off=${off.levels}, on=${on.levels})` + ).toBeGreaterThan(off.levels * 3); + + // Regression guard for the varying-based implementation, which scaled the feather by + // PathStyleExtension's inflated width and collapsed it to 1/offsetWidth of a pixel - measured + // at 0.33x the normal feather for getOffset: 1. Deriving the scale from screen-space + // derivatives instead keeps it close to the un-offset case. The extension hard-discards + // outside the band, clipping the outer half of the ramp, so this does not reach 1.0. + const featherRatio = onOffset.partial / on.partial; + expect( + featherRatio, + `offset feather should not collapse (partial on=${on.partial}, ` + + `onOffset=${onOffset.partial}, ratio=${featherRatio.toFixed(3)})` + ).toBeGreaterThan(0.55); + + deck.finalize(); + removeContainer(container); + }); +}); From f0f955a61af1d0c4765a4fcf9b3f1b62c4e332fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:35:47 +0000 Subject: [PATCH 04/21] test(layers): run antialiasing test without MSAA, where the prop applies 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- test/render/path-antialiasing.spec.ts | 188 ++++++++++++++++---------- 1 file changed, 113 insertions(+), 75 deletions(-) diff --git a/test/render/path-antialiasing.spec.ts b/test/render/path-antialiasing.spec.ts index 8b5f99a42fb..00eb352d7db 100644 --- a/test/render/path-antialiasing.spec.ts +++ b/test/render/path-antialiasing.spec.ts @@ -2,41 +2,82 @@ // SPDX-License-Identifier: MIT // Copyright (c) vis.gl contributors -// Antialiasing cannot be covered by a golden image here: 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 golden diff threshold. A golden test passes even -// with the feature completely disabled. These tests read the framebuffer instead and assert on -// the coverage itself. +// `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`. +// +// This cannot be covered by a golden image: with MSAA on, the strokes are smoothed whether or not +// the prop is set and the residual difference falls below the diff threshold - a golden test was +// tried and passed with the feature completely disabled. These tests read the framebuffer and +// assert on the coverage itself. import {describe, test, expect} from 'vitest'; -import {OrthographicView} from '@deck.gl/core'; +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'; -import {createContainer, removeContainer, createTestDevice, createDeck} from './deck-test-utils'; -const DIAGONALS = [0, 1, 2, 3, 4].map(i => ({ - // 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 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: [ - [-150, -120 + i * 60], - [150, -120 + i * 60 + 8 + i * 12] + [-110, -70 + i * 42], + [110, -70 + i * 42 + 6 + i * 9] ] })); type Coverage = {solid: number; partial: number; levels: number}; -function measure(deck, device, layers): Promise { - return new Promise(resolve => { +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({ - views: new OrthographicView(), - viewState: {target: [0, 0, 0], zoom: 0}, - layers, + 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.gl; - const [w, h] = device.canvasContext.getDrawingBufferSize(); - const px = new Uint8Array(w * h * 4); - gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px); - + 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(); @@ -53,64 +94,61 @@ function measure(deck, device, layers): Promise { } }); }); -} -const BASE = { - data: DIAGONALS, - getPath: d => d.path, - getColor: [255, 180, 0] as [number, number, number], - getWidth: 6, - widthUnits: 'pixels' as const -}; + deck.finalize(); + device.destroy(); + container.remove(); + return coverage; +} describe('PathLayer#antialiasing', () => { - test('produces continuous coverage, and survives PathStyleExtension offset', async () => { - const container = createContainer(); - const device = await createTestDevice('webgl', container); - const deck = createDeck(container, device); - - const off = await measure(deck, device, [ - new PathLayer({...BASE, id: 'off', antialiasing: false}) - ]); - const on = await measure(deck, device, [ - new PathLayer({...BASE, id: 'on', antialiasing: true}) - ]); - const onOffset = await measure(deck, device, [ - new PathLayer({ - ...BASE, - id: 'on-offset', - antialiasing: true, - getOffset: 1, - extensions: [new PathStyleExtension({offset: true})] - }) - ]); - - // Sanity: all three actually drew something - expect(off.solid, 'antialiasing:false drew strokes').toBeGreaterThan(1000); - expect(on.solid, 'antialiasing:true drew strokes').toBeGreaterThan(1000); - expect(onOffset.solid, 'offset stroke drew strokes').toBeGreaterThan(1000); - - // Analytic coverage is continuous; MSAA alone quantizes to its sample count. Observed ~20 - // levels off vs ~200 on, so 3x is a wide margin against driver differences in sample count. + 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, - `antialiasing:true should produce far more distinct alpha levels than MSAA alone ` + - `(off=${off.levels}, on=${on.levels})` - ).toBeGreaterThan(off.levels * 3); - - // Regression guard for the varying-based implementation, which scaled the feather by - // PathStyleExtension's inflated width and collapsed it to 1/offsetWidth of a pixel - measured - // at 0.33x the normal feather for getOffset: 1. Deriving the scale from screen-space - // derivatives instead keeps it close to the un-offset case. The extension hard-discards - // outside the band, clipping the outer half of the ramp, so this does not reach 1.0. - const featherRatio = onOffset.partial / on.partial; + `coverage should be continuous, not quantized (got ${on.levels} distinct alpha levels)` + ).toBeGreaterThan(40); + }, 60000); + + test('feather survives PathStyleExtension offset', 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); + + // Regression guard. An earlier implementation passed the stroke half-width to the fragment + // shader as a varying read after DECKGL_FILTER_SIZE. PathStyleExtension's `offset` inflates + // that width and separately rescales vPathPosition, so the feather collapsed to + // 1/offsetWidth of a pixel - measured at 0.33x for getOffset: 1. Deriving the pixel scale + // from screen-space derivatives instead keeps it close to the un-offset case. The extension + // hard-discards outside the band, clipping the outer half of the ramp, so this stays below 1. + const ratio = onOffset.partial / on.partial; expect( - featherRatio, - `offset feather should not collapse (partial on=${on.partial}, ` + - `onOffset=${onOffset.partial}, ratio=${featherRatio.toFixed(3)})` + ratio, + `offset feather should not collapse (on=${on.partial}, offset=${onOffset.partial}, ` + + `ratio=${ratio.toFixed(3)})` ).toBeGreaterThan(0.55); - - deck.finalize(); - removeContainer(container); - }); + }, 60000); }); From fed9c28ad4914b5bbda4b45c634eff6a7a794ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:45:12 +0000 Subject: [PATCH 05/21] docs(layers): correct the note on why both edge derivatives are computed 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- modules/layers/src/path-layer/path-layer-fragment.glsl.ts | 8 ++++++-- modules/layers/src/path-layer/path-layer.wgsl.ts | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) 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 77d6ae7130b..a34fe975d98 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -46,8 +46,12 @@ void main(void) { // 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 // or remap vPathPosition (PathStyleExtension's offset does both). - // Both branches are evaluated unconditionally - taking the derivative of a branched value - // would differentiate across the corner/body seam and corrupt every joint. + // Both are evaluated unconditionally so each derivative stays on a single smooth field. + // Derivatives are computed per 2x2 quad, so a quad straddling the corner/body boundary would + // otherwise difference two different fields. In practice the two agree exactly at that + // boundary - where vPathPosition.y is 0, the offset is perpendicular to the segment, so + // |vPathPosition.x| equals length(vCornerOffset) - which makes the branched form very nearly + // equivalent. Keeping them separate costs one extra derivative and avoids depending on that. float bodyCoord = abs(vPathPosition.x); float cornerCoord = length(vCornerOffset); float bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); diff --git a/modules/layers/src/path-layer/path-layer.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index dc0acfe5946..a434e26c739 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -249,8 +249,10 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { // 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. // Derivatives must be taken in uniform control flow, so they are hoisted above the discards - // below. Both branches are evaluated for the same reason the GLSL version does: taking the - // derivative of a branched value would differentiate across the corner/body seam. + // below. Both are evaluated unconditionally so each derivative stays on a single smooth field: + // derivatives are computed per 2x2 quad, and a quad straddling the corner/body boundary would + // otherwise difference two different fields. The two agree exactly at that boundary, so this is + // insurance rather than a fix for an observed artifact - see the GLSL shader for the details. let bodyCoord = abs(varyings.vPathPosition.x); let cornerCoord = length(varyings.vCornerOffset); let bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); From 37b33774c10ad46642bd54f1c2f1545678bc20fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 00:52:14 +0000 Subject: [PATCH 06/21] docs: add RFC for PathLayer and LineLayer analytic antialiasing 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md 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..a1ae0a1f62b --- /dev/null +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -0,0 +1,166 @@ +# 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 default framebuffer's MSAA. This RFC proposes an opt-in `antialiasing` prop that +computes edge coverage analytically in the fragment shader, for the two situations where MSAA is not +available: interleaved rendering into a base map, and WebGPU. + +## 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 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. + +There are two situations where that fallback does not exist. + +**Interleaved base maps.** MapLibre GL JS and Mapbox GL JS own the WebGL context in interleaved +mode and both create it with `antialias: false` as a performance optimization. deck.gl layers drawn +into that context receive no multisampling. 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. + +**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 | `false` | 1361 | 3 | + +Without MSAA and without the prop 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* available it does most of the work, and analytic coverage is a quality +and cost improvement (continuous vs. quantized to the sample count) rather than a fix. + +## Proposal + +Add an `antialiasing` prop to `PathLayer` and `LineLayer`, defaulting to `false`. + +```js +new PathLayer({ + // ... + antialiasing: true +}); +``` + +`PolygonLayer`, `GeoJsonLayer` and `TripsLayer` inherit it through `PathLayer`. 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.** Would benefit every layer rather than these two, but luma's WebGL +backend has no multisample renderbuffer support today (only the constants), so this is a much larger +change. + +## Limitations + +- **Flat caps.** The two ends of a path are not feathered, since that would require feathering along + the path length. `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. Fixing it fully means turning that discard into a coverage term inside the extension. + +## Testing + +Golden images cannot cover this feature. The render-test canvas takes the browser default +`antialias: true`, so the strokes are smoothed whether or not the prop is set and the residual +difference falls well below the diff threshold. A golden test was written first and **passed with +the feature completely disabled** — it is included in this RFC as a caution, not as a proposal. + +`test/render/path-antialiasing.spec.ts` instead creates its own device with `antialias: false`, +reads back the framebuffer and asserts on coverage directly. Both assertions were verified to fail +against the code they guard: + +- Deleting the feather drops the antialiased pass to 0 partial pixels. +- Restoring the previous varying-based implementation reproduces the 0.328 offset collapse. + +## 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. +- **Consider defaulting to `true` in a major release**, once the trade-offs have been exercised in + the wild. From 966b13f083e9b4df1b877cbf9c07d7d61340521b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:02:25 +0000 Subject: [PATCH 07/21] fix(layers): take edge derivatives before the discards in PathLayer 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../path-layer/path-layer-fragment.glsl.ts | 48 +++++++++++-------- .../layers/src/path-layer/path-layer.wgsl.ts | 31 +++++++----- 2 files changed, 47 insertions(+), 32 deletions(-) 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 a34fe975d98..d26c03da0b2 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -28,6 +28,34 @@ void main(void) { bool isCorner = vPathPosition.y < 0.0 || vPathPosition.y > vPathLength; bool isRound = vJointType > 0.5; + // 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 or remap + // vPathPosition (PathStyleExtension's offset does both). + // + // Derivatives are computed per 2x2 quad and are undefined once any invocation in the quad has + // been discarded, so they are taken before the discards below - the same ordering the WGSL + // shader uses. path.antialiasing is a uniform, so branching on it keeps control flow uniform + // across the quad and costs nothing when the feature is off. + // + // Both coordinates are evaluated so each derivative stays on a single smooth field: a quad + // straddling the corner/body boundary would otherwise difference two different fields. In + // practice the two agree exactly at that boundary - where vPathPosition.y is 0 the offset is + // perpendicular to the segment, so |vPathPosition.x| equals length(vCornerOffset) - which makes + // the branched form very nearly equivalent. Keeping them separate costs one extra derivative + // and avoids depending on that. + float edgePixels = 0.0; + if (path.antialiasing) { + float bodyCoord = abs(vPathPosition.x); + float cornerCoord = length(vCornerOffset); + float bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + float cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); + // Only the across-width silhouette is feathered - consecutive segment instances abut along + // the length of the path, so feathering there would leave a seam at every vertex. + edgePixels = isRound && isCorner ? cornerPixels : bodyPixels; + } + if (isCorner) { // if joint is rounded, test distance from the corner if (isRound && length(vCornerOffset) > 1.0) { @@ -41,25 +69,7 @@ void main(void) { fragColor = vColor; if (path.antialiasing) { - // 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 - // or remap vPathPosition (PathStyleExtension's offset does both). - // Both are evaluated unconditionally so each derivative stays on a single smooth field. - // Derivatives are computed per 2x2 quad, so a quad straddling the corner/body boundary would - // otherwise difference two different fields. In practice the two agree exactly at that - // boundary - where vPathPosition.y is 0, the offset is perpendicular to the segment, so - // |vPathPosition.x| equals length(vCornerOffset) - which makes the branched form very nearly - // equivalent. Keeping them separate costs one extra derivative and avoids depending on that. - float bodyCoord = abs(vPathPosition.x); - float cornerCoord = length(vCornerOffset); - float bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); - float cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); - // Only the across-width silhouette is feathered - consecutive segment instances abut along - // the length of the path, so feathering there would leave a seam at every vertex. - // Spread the transition over exactly one device pixel, centered on the edge. - float edgePixels = isRound && isCorner ? cornerPixels : bodyPixels; + // Spread the transition over exactly one device pixel, centered on the edge fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } diff --git a/modules/layers/src/path-layer/path-layer.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index a434e26c739..1534445bb28 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -248,19 +248,27 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { // 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. - // Derivatives must be taken in uniform control flow, so they are hoisted above the discards - // below. Both are evaluated unconditionally so each derivative stays on a single smooth field: - // derivatives are computed per 2x2 quad, and a quad straddling the corner/body boundary would - // otherwise difference two different fields. The two agree exactly at that boundary, so this is - // insurance rather than a fix for an observed artifact - see the GLSL shader for the details. - let bodyCoord = abs(varyings.vPathPosition.x); - let cornerCoord = length(varyings.vCornerOffset); - let bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); - let cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); - + // Derivatives must be taken in uniform control flow and are undefined once any invocation in + // the quad has been discarded, so they are hoisted above the discards below. path.antialiasing + // is a uniform, so branching on it stays uniform and costs nothing when the feature is off. + // Both coordinates are evaluated so each derivative stays on a single smooth field: a quad + // straddling the corner/body boundary would otherwise difference two different fields. The two + // agree exactly at that boundary, so this is insurance rather than a fix for an observed + // artifact - see the GLSL shader for the details. let isCorner = varyings.vPathPosition.y < 0.0 || varyings.vPathPosition.y > varyings.vPathLength; let isRound = varyings.vJointType > 0.5; + var edgePixels = 0.0; + if (path.antialiasing != 0.0) { + let bodyCoord = abs(varyings.vPathPosition.x); + let cornerCoord = length(varyings.vCornerOffset); + let bodyPixels = (1.0 - bodyCoord) / max(fwidth(bodyCoord), 1e-6); + let cornerPixels = (1.0 - cornerCoord) / max(fwidth(cornerCoord), 1e-6); + // Only the across-width silhouette is feathered - consecutive segment instances abut along + // the length of the path, so feathering there would leave a seam at every vertex. + edgePixels = select(bodyPixels, cornerPixels, isRound && isCorner); + } + if (isCorner) { if (isRound && length(varyings.vCornerOffset) > 1.0) { discard; @@ -273,11 +281,8 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { var color = varyings.vColor; if (path.antialiasing != 0.0) { - // Only the across-width silhouette is feathered - consecutive segment instances abut along - // the length of the path, so feathering there would leave a seam at every vertex. // Spread the transition over exactly one device pixel, centered on the edge. // Applied before premultiplication so the color channels scale with coverage too. - let edgePixels = select(bodyPixels, cornerPixels, isRound && isCorner); color.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } From f319dccb8be0d105e02532e0b7edb98f97d788c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:14:29 +0000 Subject: [PATCH 08/21] test(layers): add golden image coverage for antialiasing 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 35 ++++++--- test/render/deck-test-utils.ts | 20 +++++- .../golden-images/path-antialiasing.png | Bin 0 -> 18001 bytes test/render/path-antialiasing.spec.ts | 8 +-- test/render/render-test-suite.ts | 8 ++- .../test-cases/path-antialiasing.spec.ts | 68 ++++++++++++++++++ 6 files changed, 122 insertions(+), 17 deletions(-) create mode 100644 test/render/golden-images/path-antialiasing.png create mode 100644 test/render/test-cases/path-antialiasing.spec.ts diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index a1ae0a1f62b..bd8fe9eadd3 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -142,16 +142,31 @@ change. ## Testing -Golden images cannot cover this feature. The render-test canvas takes the browser default -`antialias: true`, so the strokes are smoothed whether or not the prop is set and the residual -difference falls well below the diff threshold. A golden test was written first and **passed with -the feature completely disabled** — it is included in this RFC as a caution, not as a proposal. - -`test/render/path-antialiasing.spec.ts` instead creates its own device with `antialias: false`, -reads back the framebuffer and asserts on coverage directly. Both assertions were verified to fail -against the code they guard: - -- Deleting the feather drops the antialiased pass to 0 partial pixels. +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. ## Follow-ups 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 0000000000000000000000000000000000000000..8a9a1a7538ce1dbe4ca1fa30d2241cf367e94922 GIT binary patch literal 18001 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 RAYT{&B>y14hRxAA{{c-(bW{KU literal 0 HcmV?d00001 diff --git a/test/render/path-antialiasing.spec.ts b/test/render/path-antialiasing.spec.ts index 00eb352d7db..7af4faa2e1b 100644 --- a/test/render/path-antialiasing.spec.ts +++ b/test/render/path-antialiasing.spec.ts @@ -7,10 +7,10 @@ // 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`. // -// This cannot be covered by a golden image: with MSAA on, the strokes are smoothed whether or not -// the prop is set and the residual difference falls below the diff threshold - a golden test was -// tried and passed with the feature completely disabled. These tests read the framebuffer and -// assert on the coverage itself. +// 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'; 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/path-antialiasing.spec.ts b/test/render/test-cases/path-antialiasing.spec.ts new file mode 100644 index 00000000000..17af9b26c3e --- /dev/null +++ b/test/render/test-cases/path-antialiasing.spec.ts @@ -0,0 +1,68 @@ +// 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'; + +// This suite runs on a device created WITHOUT multisampling, matching how MapLibre and Mapbox +// create their WebGL context. On the default render-test device MSAA smooths the strokes whether +// or not `antialiasing` is set, and a golden captured there passes even with the feature disabled. +// +// `includeAA: true` is essential. pixelmatch detects antialiased pixels and excludes them from +// the mismatch count by default, and this prop changes nothing else - without it the diff is +// blind to the feature and the test passes even when it is disabled entirely. +// +// The scene is deliberately dense with thin shallow diagonals: the prop only changes edge pixels, +// so the edges have to be a large enough fraction of the frame for the image diff to register. +// Shallow diagonals are also the worst case for aliasing. + +const ROWS = 26; +const SPACING = 16; + +function diagonals(xOffset: number) { + return Array.from({length: ROWS}, (_, i) => ({ + path: [ + [xOffset - 180, -200 + i * SPACING], + [xOffset + 180, -200 + i * SPACING + 11] + ] + })); +} + +const testCases: TestCase[] = [ + { + name: 'path-antialiasing', + views: new OrthographicView(), + viewState: {target: [0, 0, 0], zoom: 0}, + layers: [ + new PathLayer({ + id: 'path-antialiasing-off', + data: diagonals(-190), + getPath: d => d.path, + getColor: [20, 20, 20], + getWidth: 2, + widthUnits: 'pixels', + antialiasing: false + }), + new PathLayer({ + id: 'path-antialiasing-on', + data: diagonals(190), + getPath: d => d.path, + getColor: [20, 20, 20], + getWidth: 2, + widthUnits: 'pixels', + antialiasing: true + }) + ], + imageDiffOptions: {threshold: 0.998, includeAA: true}, + goldenImage: './test/render/golden-images/path-antialiasing.png' + } +]; + +describe.each(['webgl'] as const)('%s', deviceType => { + runRenderTestSuite(testCases as TestCase[], deviceType, {webgl: {antialias: false}}); +}); From d1446cb12348f5359f8c70a84f13c86ef22a9027 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:25:18 +0000 Subject: [PATCH 09/21] docs: enumerate every path where deck loses MSAA 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 67 ++++++++++++++----- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index bd8fe9eadd3..c1219b8a9c7 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -15,22 +15,48 @@ Both layers write a flat color per fragment. `path-layer-fragment.glsl.ts` ends `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 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. +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. -There are two situations where that fallback does not exist. +That fallback disappears in more places than it might seem, and they divide into three mechanisms. -**Interleaved base maps.** MapLibre GL JS and Mapbox GL JS own the WebGL context in interleaved -mode and both create it with `antialias: false` as a performance optimization. deck.gl layers drawn -into that context receive no multisampling. 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. +### 1. Externally-owned contexts -**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. +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`. Whether Google enables MSAA is not determinable + from deck's source and is not documented; treat as unknown rather than assuming either way. + +### 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 @@ -40,12 +66,17 @@ A 2px diagonal path rendered into a 240×180 context, counting pixels by alpha: | --- | --- | --- | --- | | no MSAA (base-map-like) | `false` | **0** | **0** | | no MSAA (base-map-like) | `true` | 719 | 104 | -| MSAA | `false` | 1361 | 3 | +| 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. -Without MSAA and without the prop 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* available it does most of the work, and analytic coverage is a quality -and cost improvement (continuous vs. quantized to the sample count) rather than a fix. +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. ## Proposal From dfc1619b5631a319d0a1f650c722bb134186c078 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:36:24 +0000 Subject: [PATCH 10/21] refactor(layers): tighten shader comments, add variety to the AA golden 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../line-layer/line-layer-fragment.glsl.ts | 7 +- .../layers/src/line-layer/line-layer.wgsl.ts | 8 +- .../path-layer/path-layer-fragment.glsl.ts | 25 +---- .../layers/src/path-layer/path-layer.wgsl.ts | 16 +-- .../golden-images/path-antialiasing.png | Bin 18001 -> 10265 bytes .../test-cases/path-antialiasing.spec.ts | 105 +++++++++++------- 6 files changed, 82 insertions(+), 79 deletions(-) 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 0bedfcac8f6..610e11401da 100644 --- a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts @@ -19,11 +19,8 @@ void main(void) { fragColor = vColor; if (line.antialiasing) { - // uv.y runs [-1, 1] across the width. Dividing the distance to the edge by the screen-space - // derivative converts it to device pixels, which stays correct under perspective - // foreshortening and at any device pixel ratio. Only the across-width silhouette is feathered - // - the ends butt against neighboring segments in a multi-segment path. - // Spread the transition over exactly one device pixel, centered on the edge. + // 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); fragColor.a *= clamp((1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6) + 0.5, 0.0, 1.0); } diff --git a/modules/layers/src/line-layer/line-layer.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts index 598918ae695..35e15e32109 100644 --- a/modules/layers/src/line-layer/line-layer.wgsl.ts +++ b/modules/layers/src/line-layer/line-layer.wgsl.ts @@ -162,15 +162,13 @@ fn fragmentMain( // Start with the input color. var fragColor: vec4 = vColor; - // uv.y runs [-1, 1] across the width. Dividing the distance to the edge by the screen-space - // derivative converts it to device pixels, which stays correct under perspective foreshortening - // and at any device pixel ratio. Taken in uniform control flow, ahead of the picking discard. + // 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) { - // Only the across-width silhouette is feathered - the ends butt against neighboring segments - // in a multi-segment path. Spread the transition over one device pixel, centered on the edge. + // Feather one device pixel across the width. The ends are left hard - they abut neighbors fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } 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 d26c03da0b2..c4989919705 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -28,31 +28,16 @@ void main(void) { bool isCorner = vPathPosition.y < 0.0 || vPathPosition.y > vPathLength; bool isRound = vJointType > 0.5; - // 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 or remap - // vPathPosition (PathStyleExtension's offset does both). - // - // Derivatives are computed per 2x2 quad and are undefined once any invocation in the quad has - // been discarded, so they are taken before the discards below - the same ordering the WGSL - // shader uses. path.antialiasing is a uniform, so branching on it keeps control flow uniform - // across the quad and costs nothing when the feature is off. - // - // Both coordinates are evaluated so each derivative stays on a single smooth field: a quad - // straddling the corner/body boundary would otherwise difference two different fields. In - // practice the two agree exactly at that boundary - where vPathPosition.y is 0 the offset is - // perpendicular to the segment, so |vPathPosition.x| equals length(vCornerOffset) - which makes - // the branched form very nearly equivalent. Keeping them separate costs one extra derivative - // and avoids depending on that. + // 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); - // Only the across-width silhouette is feathered - consecutive segment instances abut along - // the length of the path, so feathering there would leave a seam at every vertex. edgePixels = isRound && isCorner ? cornerPixels : bodyPixels; } @@ -69,7 +54,7 @@ void main(void) { fragColor = vColor; if (path.antialiasing) { - // Spread the transition over exactly one device pixel, centered on the edge + // Feather one device pixel across the width only - segments abut lengthwise, which would seam fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } diff --git a/modules/layers/src/path-layer/path-layer.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index 1534445bb28..dd15ef12a7f 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -248,24 +248,19 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { // 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. - // Derivatives must be taken in uniform control flow and are undefined once any invocation in - // the quad has been discarded, so they are hoisted above the discards below. path.antialiasing - // is a uniform, so branching on it stays uniform and costs nothing when the feature is off. - // Both coordinates are evaluated so each derivative stays on a single smooth field: a quad - // straddling the corner/body boundary would otherwise difference two different fields. The two - // agree exactly at that boundary, so this is insurance rather than a fix for an observed - // artifact - see the GLSL shader for the details. 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); - // Only the across-width silhouette is feathered - consecutive segment instances abut along - // the length of the path, so feathering there would leave a seam at every vertex. edgePixels = select(bodyPixels, cornerPixels, isRound && isCorner); } @@ -281,8 +276,7 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { var color = varyings.vColor; if (path.antialiasing != 0.0) { - // Spread the transition over exactly one device pixel, centered on the edge. - // Applied before premultiplication so the color channels scale with coverage too. + // Feather one device pixel across the width only, before premultiplication color.a *= clamp(edgePixels + 0.5, 0.0, 1.0); } diff --git a/test/render/golden-images/path-antialiasing.png b/test/render/golden-images/path-antialiasing.png index 8a9a1a7538ce1dbe4ca1fa30d2241cf367e94922..c156cb81ddc2761302897f530a713de04c18fe96 100644 GIT binary patch literal 10265 zcmeI2S6GwZ*5E@Cf&>Ug>AkB+RXPCz7MftE7X?A3*GNZV01<+s6zMipREpAj4Mc=|_=*WyEpjOuy?ie<^*VzfRKWc@=EWH

6A!f*+1T}~8Mu?qCg&JAbGVrR8bT%Ku zp(6^UXC@tP6x|?x`dltKKjPiB63qV6oM=7Z>NrC9v-@ya^WX~&1R|7RNe6*wOVC0g zkoZ^#Ed=5q8U`F3f&yo?mHzL<-;TtqFO}ZmR|wb`a^j>3n@hLfrTFY_8|`ji-wi;9 z>>2KEyX_%wt+Rhs-Z%RWNputyt6k-*c~Qq?t~U^r8JC=9w7Ow z9(w%!>shn;Fx)kRh0&=ngBkqT?;`!3h7C+V?XST`2o-Ntak6&Zt%Bomfpn(y31wKQ z`8py$Otq0b!9PF;A{??03uu~F4XN__8%W0Qdk2L)++EL|*p$WD=Ed0NsqAJXsVzzO zu{h<%Wu%nP8V&4cKIZHSBYG&g{GIQZ3QzqrKU#4;{#EjV=?UT75EO@8B*DFAFTG^g zt8G8!W5>X=WfRQ`-$^Gom5LL^nRVs+?PMjV93(eur^8+wDeu5tMI~Z=wjr

Ah$3 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 RAYT{&B>y14hRxAA{{c-(bW{KU diff --git a/test/render/test-cases/path-antialiasing.spec.ts b/test/render/test-cases/path-antialiasing.spec.ts index 17af9b26c3e..1b9c76d5413 100644 --- a/test/render/test-cases/path-antialiasing.spec.ts +++ b/test/render/test-cases/path-antialiasing.spec.ts @@ -9,28 +9,76 @@ import type {TestCase} from '../deck-test-utils'; import {OrthographicView} from '@deck.gl/core'; import {PathLayer} from '@deck.gl/layers'; -// This suite runs on a device created WITHOUT multisampling, matching how MapLibre and Mapbox -// create their WebGL context. On the default render-test device MSAA smooths the strokes whether -// or not `antialiasing` is set, and a golden captured there passes even with the feature disabled. -// -// `includeAA: true` is essential. pixelmatch detects antialiased pixels and excludes them from -// the mismatch count by default, and this prop changes nothing else - without it the diff is -// blind to the feature and the test passes even when it is disabled entirely. -// -// The scene is deliberately dense with thin shallow diagonals: the prop only changes edge pixels, -// so the edges have to be a large enough fraction of the frame for the image diff to register. -// Shallow diagonals are also the worst case for aliasing. +// 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 -const ROWS = 26; -const SPACING = 16; +// 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] + ] +})); -function diagonals(xOffset: number) { - return Array.from({length: ROWS}, (_, i) => ({ +// Sharp direction changes, to cover joints and caps as well as straight runs +const ZIGZAG = [ + { path: [ - [xOffset - 180, -200 + i * SPACING], - [xOffset + 180, -200 + i * SPACING + 11] + [-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[] = [ @@ -38,26 +86,7 @@ const testCases: TestCase[] = [ name: 'path-antialiasing', views: new OrthographicView(), viewState: {target: [0, 0, 0], zoom: 0}, - layers: [ - new PathLayer({ - id: 'path-antialiasing-off', - data: diagonals(-190), - getPath: d => d.path, - getColor: [20, 20, 20], - getWidth: 2, - widthUnits: 'pixels', - antialiasing: false - }), - new PathLayer({ - id: 'path-antialiasing-on', - data: diagonals(190), - getPath: d => d.path, - getColor: [20, 20, 20], - getWidth: 2, - widthUnits: 'pixels', - antialiasing: true - }) - ], + layers: [...variant(false), ...variant(true)], imageDiffOptions: {threshold: 0.998, includeAA: true}, goldenImage: './test/render/golden-images/path-antialiasing.png' } From ff8366176314d58101021c3b5d171d06cb6eb026 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:39:09 +0000 Subject: [PATCH 11/21] docs: relate the antialiasing RFC to existing tracker discussion 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 74 +++++++++++++++++-- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index c1219b8a9c7..e8ccfab0407 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -32,8 +32,11 @@ deck does not choose the context attributes; the host application or SDK does. `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`. Whether Google enables MSAA is not determinable - from deck's source and is not documented; treat as unknown rather than assuming either way. + `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 @@ -78,6 +81,55 @@ quantized to the sample count) rather than a fix. The fourth row shows how easil 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. + +**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). Mapping that RFC's scope against the +cases enumerated above: + +| case | fixed by luma.gl#2741? | +| --- | --- | +| Post-process `renderBuffers` | **Yes** — they are color-only, matching its initial scope | +| `@deck.gl/arcgis` | No — its framebuffer has a `depthStencilAttachment`, explicitly out of initial scope | +| Interleaved base maps | No — the host owns the default framebuffer; not an offscreen target | +| WebGPU | No — the WebGPU mapping is deferred as a follow-up | + +So the two efforts should both land. Neither subsumes the other, and the sole overlap is +post-processing, where luma.gl#2741 is the better fix because it covers every layer rather than two. + ## Proposal Add an `antialiasing` prop to `PathLayer` and `LineLayer`, defaulting to `false`. @@ -156,9 +208,10 @@ misbehaves. Beyond the plumbing, FXAA operates on the already-rasterized image a 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.** Would benefit every layer rather than these two, but luma's WebGL -backend has no multisample renderbuffer support today (only the constants), so this is a much larger -change. +**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. ## Limitations @@ -169,7 +222,9 @@ change. - **`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. Fixing it fully means turning that discard into a coverage term inside the extension. + 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 @@ -207,6 +262,11 @@ image diff cannot express: 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. +- **`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). +- **Coordinate with [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741).** If offscreen MSAA + lands, [#10404](https://github.com/visgl/deck.gl/issues/10404) is better fixed there than by asking + applications to set this prop. - **Consider defaulting to `true` in a major release**, once the trade-offs have been exercised in the wild. From 3a7d3d533b414426ba9d15d5db776f7332c1a1f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:45:12 +0000 Subject: [PATCH 12/21] docs: lead the antialiasing RFC with a remedy matrix 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index e8ccfab0407..2169380a1db 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -5,9 +5,29 @@ - **Status**: Proposed — implemented in this PR Summary: `PathLayer` and `LineLayer` have no antialiasing of their own; their edges are smoothed -entirely by the default framebuffer's MSAA. This RFC proposes an opt-in `antialiasing` prop that -computes edge coverage analytically in the fragment shader, for the two situations where MSAA is not -available: interleaved rendering into a base map, and WebGPU. +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 @@ -117,18 +137,10 @@ Limitations. A complete fix means turning that discard into a coverage term insi 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). Mapping that RFC's scope against the -cases enumerated above: - -| case | fixed by luma.gl#2741? | -| --- | --- | -| Post-process `renderBuffers` | **Yes** — they are color-only, matching its initial scope | -| `@deck.gl/arcgis` | No — its framebuffer has a `depthStencilAttachment`, explicitly out of initial scope | -| Interleaved base maps | No — the host owns the default framebuffer; not an offscreen target | -| WebGPU | No — the WebGPU mapping is deferred as a follow-up | - -So the two efforts should both land. Neither subsumes the other, and the sole overlap is -post-processing, where luma.gl#2741 is the better fix because it covers every layer rather than two. +[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. ## Proposal From 95e35c876f2786e696b8afcef064cecadc419ac8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 01:55:35 +0000 Subject: [PATCH 13/21] docs: record what changes if luma.gl#2741 lands 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index 2169380a1db..cba7dd1a7d7 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -142,6 +142,21 @@ attachments only, with depth/stencil explicitly rejected alongside `samples > 1` 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`. @@ -277,8 +292,9 @@ image diff cannot express: - **`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). -- **Coordinate with [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741).** If offscreen MSAA - lands, [#10404](https://github.com/visgl/deck.gl/issues/10404) is better fixed there than by asking - applications to set this prop. +- **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. From b7cdf19aad695df9bc2322f638eb982dc048e3a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:05:29 +0000 Subject: [PATCH 14/21] feat(layers): expose antialiasing on PolygonLayer and GeoJsonLayer, document 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 4 +- docs/api-reference/arcgis/overview.md | 6 ++ .../api-reference/core/post-process-effect.md | 8 ++ .../google-maps/google-maps-overlay.md | 8 ++ docs/api-reference/layers/geojson-layer.md | 6 ++ docs/api-reference/layers/polygon-layer.md | 6 ++ .../layers/src/geojson-layer/geojson-layer.ts | 9 ++ .../layers/src/geojson-layer/sub-layer-map.ts | 1 + .../layers/src/polygon-layer/polygon-layer.ts | 11 +++ .../layers/antialiasing-composite.spec.ts | 86 +++++++++++++++++++ test/modules/layers/index.ts | 1 + 11 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/modules/layers/antialiasing-composite.spec.ts diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index cba7dd1a7d7..1e5799ecaab 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -168,7 +168,9 @@ new PathLayer({ }); ``` -`PolygonLayer`, `GeoJsonLayer` and `TripsLayer` inherit it through `PathLayer`. The prop name, +`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. 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/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/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/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/index.ts b/test/modules/layers/index.ts index 55dc9c3bd19..437caf7b26d 100644 --- a/test/modules/layers/index.ts +++ b/test/modules/layers/index.ts @@ -22,3 +22,4 @@ import './column-layer.spec'; import './utils.spec'; import './scatterplot-layer.spec'; import './antialiasing.spec'; +import './antialiasing-composite.spec'; From d49ed7b92d25f810b16a2b12668e026e5655c79d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:10:13 +0000 Subject: [PATCH 15/21] test(layers): state the offset guard as behavior, not as history 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- test/render/path-antialiasing.spec.ts | 31 ++++++++++++++++++--------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/test/render/path-antialiasing.spec.ts b/test/render/path-antialiasing.spec.ts index 7af4faa2e1b..b05a42ee83c 100644 --- a/test/render/path-antialiasing.spec.ts +++ b/test/render/path-antialiasing.spec.ts @@ -128,7 +128,10 @@ describe('PathLayer#antialiasing', () => { ).toBeGreaterThan(40); }, 60000); - test('feather survives PathStyleExtension offset', async () => { + // 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, @@ -138,17 +141,25 @@ describe('PathLayer#antialiasing', () => { expect(onOffset.solid, 'offset strokes were drawn').toBeGreaterThan(200); - // Regression guard. An earlier implementation passed the stroke half-width to the fragment - // shader as a varying read after DECKGL_FILTER_SIZE. PathStyleExtension's `offset` inflates - // that width and separately rescales vPathPosition, so the feather collapsed to - // 1/offsetWidth of a pixel - measured at 0.33x for getOffset: 1. Deriving the pixel scale - // from screen-space derivatives instead keeps it close to the un-offset case. The extension - // hard-discards outside the band, clipping the outer half of the ramp, so this stays below 1. + // 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 not collapse (on=${on.partial}, offset=${onOffset.partial}, ` + - `ratio=${ratio.toFixed(3)})` - ).toBeGreaterThan(0.55); + `offset feather should be comparable to un-offset (on=${on.partial}, ` + + `offset=${onOffset.partial}, ratio=${ratio.toFixed(3)})` + ).toBeGreaterThan(0.5); }, 60000); }); From abb81eef075c2057146ec33fe1cdf430e1cc9aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 02:17:14 +0000 Subject: [PATCH 16/21] test(layers): carry the commented-out webgpu suite on the AA render test 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- test/render/test-cases/path-antialiasing.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/render/test-cases/path-antialiasing.spec.ts b/test/render/test-cases/path-antialiasing.spec.ts index 1b9c76d5413..a52c7f7c934 100644 --- a/test/render/test-cases/path-antialiasing.spec.ts +++ b/test/render/test-cases/path-antialiasing.spec.ts @@ -92,6 +92,9 @@ const testCases: TestCase[] = [ } ]; -describe.each(['webgl'] as const)('%s', deviceType => { +describe.each([ + 'webgl' + // 'webgpu' +] as const)('%s', deviceType => { runRenderTestSuite(testCases as TestCase[], deviceType, {webgl: {antialias: false}}); }); From 2d98227b17b800c5a107475d307b7fb1d585c55a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 02:13:33 +0000 Subject: [PATCH 17/21] refactor(layers): use the shared smoothedge helper, prep LineLayer for 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../line-layer/line-layer-fragment.glsl.ts | 3 +- .../layers/src/line-layer/line-layer.wgsl.ts | 2 +- .../path-layer/path-layer-fragment.glsl.ts | 6 +- .../layers/src/path-layer/path-layer.wgsl.ts | 5 +- test/render/test-cases/line-layer.spec.ts | 67 +++++++++++-------- 5 files changed, 49 insertions(+), 34 deletions(-) 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 610e11401da..09e40ea352e 100644 --- a/modules/layers/src/line-layer/line-layer-fragment.glsl.ts +++ b/modules/layers/src/line-layer/line-layer-fragment.glsl.ts @@ -22,7 +22,8 @@ void main(void) { // 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); - fragColor.a *= clamp((1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6) + 0.5, 0.0, 1.0); + 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.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts index 35e15e32109..0c64d1381a3 100644 --- a/modules/layers/src/line-layer/line-layer.wgsl.ts +++ b/modules/layers/src/line-layer/line-layer.wgsl.ts @@ -169,7 +169,7 @@ fn fragmentMain( if (line.antialiasing != 0.0) { // Feather one device pixel across the width. The ends are left hard - they abut neighbors - fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); + fragColor.a *= smoothedge(0.0, edgePixels); } if (picking.isActive > 0.5) { 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 c4989919705..93a56d527c3 100644 --- a/modules/layers/src/path-layer/path-layer-fragment.glsl.ts +++ b/modules/layers/src/path-layer/path-layer-fragment.glsl.ts @@ -54,8 +54,10 @@ void main(void) { fragColor = vColor; if (path.antialiasing) { - // Feather one device pixel across the width only - segments abut lengthwise, which would seam - fragColor.a *= clamp(edgePixels + 0.5, 0.0, 1.0); + // 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.wgsl.ts b/modules/layers/src/path-layer/path-layer.wgsl.ts index dd15ef12a7f..d96d983735f 100644 --- a/modules/layers/src/path-layer/path-layer.wgsl.ts +++ b/modules/layers/src/path-layer/path-layer.wgsl.ts @@ -276,8 +276,9 @@ fn fragmentMain(varyings: Varyings) -> @location(0) vec4 { var color = varyings.vColor; if (path.antialiasing != 0.0) { - // Feather one device pixel across the width only, before premultiplication - color.a *= clamp(edgePixels + 0.5, 0.0, 1.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/test/render/test-cases/line-layer.spec.ts b/test/render/test-cases/line-layer.spec.ts index 94f790dccae..28048a43206 100644 --- a/test/render/test-cases/line-layer.spec.ts +++ b/test/render/test-cases/line-layer.spec.ts @@ -4,42 +4,53 @@ 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' + } + ]; +} +// 'webgpu' is ready to enable - the case above supplies the analytic coverage that the backend's +// lack of MSAA otherwise costs it. It stays commented out because WebGPU rasterizes nothing under +// the software renderer this suite runs on: both this case and every path-layer case come back as +// a blank frame, with the device created and no validation errors. Uncomment on a machine with +// hardware WebGPU, or once CI has it. describe.each([ 'webgl' // 'webgpu' ] as const)('%s', deviceType => { - runRenderTestSuite(testCases as TestCase[], deviceType); + runRenderTestSuite(getTestCases(deviceType) as TestCase[], deviceType); }); From d7996ed214beef0f456ed8545d15f72d2e27aa98 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:23:20 +0000 Subject: [PATCH 18/21] fix(core): remap clip-space depth for WebGPU 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- modules/core/src/shaderlib/project/project.wgsl.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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. From 2c094364ab8e605834ec4a839b942ff153b1d4fd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 16:23:27 +0000 Subject: [PATCH 19/21] docs(rfc): record what WebGPU coverage is actually asserted 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md | 8 ++++++++ test/render/test-cases/line-layer.spec.ts | 10 +++++----- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index 1e5799ecaab..fc90e6251ba 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -284,6 +284,14 @@ 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. +**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. + ## Follow-ups - **`ScatterplotLayer` feather is not DPR-aware.** Its `SMOOTH_EDGE_RADIUS` is a fixed 0.5 CSS diff --git a/test/render/test-cases/line-layer.spec.ts b/test/render/test-cases/line-layer.spec.ts index 28048a43206..21b450f44e9 100644 --- a/test/render/test-cases/line-layer.spec.ts +++ b/test/render/test-cases/line-layer.spec.ts @@ -43,11 +43,11 @@ function getTestCases(deviceType: TestDeviceType) { ]; } -// 'webgpu' is ready to enable - the case above supplies the analytic coverage that the backend's -// lack of MSAA otherwise costs it. It stays commented out because WebGPU rasterizes nothing under -// the software renderer this suite runs on: both this case and every path-layer case come back as -// a blank frame, with the device created and no validation errors. Uncomment on a machine with -// hardware WebGPU, or once CI has it. +// 'webgpu' renders correctly now - the case above supplies the analytic coverage that the +// backend's lack of MSAA otherwise costs it, and layers draw as expected when read back from an +// offscreen framebuffer. It stays commented out because the screenshot this suite diffs against +// comes back blank: the WebGPU canvas does not present under the headless software renderer, which +// is independent of what deck draws. Uncomment once CI has hardware WebGPU. describe.each([ 'webgl' // 'webgpu' From 2f6f0b6ec34fb7f5f90a8c3ec488cf4a0147ceae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 17:13:36 +0000 Subject: [PATCH 20/21] test(layers): guard premultiplied alpha ordering, record alpha-to-coverage 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 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .../RFCs/v9.4/path-line-antialiasing-rfc.md | 53 ++++++- docs/api-reference/layers/line-layer.md | 2 + docs/api-reference/layers/path-layer.md | 2 + .../layers/src/line-layer/line-layer.wgsl.ts | 3 +- test/render/webgpu-antialiasing.spec.ts | 134 ++++++++++++++++++ 5 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 test/render/webgpu-antialiasing.spec.ts diff --git a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md index fc90e6251ba..ff3feb626f1 100644 --- a/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md +++ b/dev-docs/RFCs/v9.4/path-line-antialiasing-rfc.md @@ -106,6 +106,13 @@ off it. 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 @@ -242,10 +249,38 @@ in [luma.gl#2741](https://github.com/visgl/luma.gl/issues/2741). It should land, 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. `capRounded: true` gets smoothed ends. `LineLayer` ends are likewise unfeathered. + 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` @@ -284,7 +319,13 @@ 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. -**WebGPU** is asserted by hand rather than in CI. `test/render/test-cases/line-layer.spec.ts` wires +**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 @@ -301,7 +342,13 @@ once CI has hardware WebGPU. 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). + [#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 diff --git a/docs/api-reference/layers/line-layer.md b/docs/api-reference/layers/line-layer.md index 4d5dad9ece5..437b52e0d5f 100644 --- a/docs/api-reference/layers/line-layer.md +++ b/docs/api-reference/layers/line-layer.md @@ -202,6 +202,8 @@ The maximum line width in pixels. This prop can be used to prevent the line from 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 c27667528b3..89160180a19 100644 --- a/docs/api-reference/layers/path-layer.md +++ b/docs/api-reference/layers/path-layer.md @@ -223,6 +223,8 @@ Only works if `jointRounded` is `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/modules/layers/src/line-layer/line-layer.wgsl.ts b/modules/layers/src/line-layer/line-layer.wgsl.ts index 0c64d1381a3..23d959d4280 100644 --- a/modules/layers/src/line-layer/line-layer.wgsl.ts +++ b/modules/layers/src/line-layer/line-layer.wgsl.ts @@ -168,7 +168,8 @@ fn fragmentMain( let edgePixels = (1.0 - edgeCoord) / max(fwidth(edgeCoord), 1e-6); if (line.antialiasing != 0.0) { - // Feather one device pixel across the width. The ends are left hard - they abut neighbors + // Feather one device pixel across the width, before premultiplication below. The ends are left + // hard - they abut neighbors fragColor.a *= smoothedge(0.0, edgePixels); } 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); +}); From 241d28b3dc02135cbaddaf39555b3721537930c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 06:52:57 +0000 Subject: [PATCH 21/21] test(render): enable the WebGPU line-layer case, expected to fail 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: https://github.com/visgl/luma.gl/issues/2874 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01H96ePpLZEY1tUcTM9bWSoG --- .github/workflows/test.yml | 6 +++++- test/render/test-cases/line-layer.spec.ts | 16 +++++++--------- vitest.config.ts | 18 ++++++++++++++++-- 3 files changed, 28 insertions(+), 12 deletions(-) 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/test/render/test-cases/line-layer.spec.ts b/test/render/test-cases/line-layer.spec.ts index 21b450f44e9..f8c2206f1a9 100644 --- a/test/render/test-cases/line-layer.spec.ts +++ b/test/render/test-cases/line-layer.spec.ts @@ -43,14 +43,12 @@ function getTestCases(deviceType: TestDeviceType) { ]; } -// 'webgpu' renders correctly now - the case above supplies the analytic coverage that the -// backend's lack of MSAA otherwise costs it, and layers draw as expected when read back from an -// offscreen framebuffer. It stays commented out because the screenshot this suite diffs against -// comes back blank: the WebGPU canvas does not present under the headless software renderer, which -// is independent of what deck draws. Uncomment once CI has hardware WebGPU. -describe.each([ - 'webgl' - // 'webgpu' -] as const)('%s', 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/vitest.config.ts b/vitest.config.ts index 1d9dbe78467..e0eeaf4aadd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,10 +23,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}