Skip to content

Fix moire pattern#10518

Open
jorge-ferreira-pii wants to merge 7 commits into
The-OpenROAD-Project:masterfrom
The-OpenROAD-Project-staging:fix-moire-pattern
Open

Fix moire pattern#10518
jorge-ferreira-pii wants to merge 7 commits into
The-OpenROAD-Project:masterfrom
The-OpenROAD-Project-staging:fix-moire-pattern

Conversation

@jorge-ferreira-pii
Copy link
Copy Markdown
Contributor

Fix #10463

@jorge-ferreira-pii jorge-ferreira-pii requested a review from a team as a code owner May 26, 2026 21:55
@jorge-ferreira-pii jorge-ferreira-pii self-assigned this May 26, 2026
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Welcome to OpenROAD! Thanks for opening your first PR.
Before we review:

Please ensure:

  • CI passes
  • Code is properly formatted
  • Tests are included where applicable
    A maintainer will review shortly!

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces sub-pixel Level of Detail (LOD) thresholds and anti-aliasing techniques to the tile generator to prevent Moiré patterns when zooming out on dense regular arrays. Specifically, it collapses sub-pixel instances to single points, skips fine details for small instances, and implements sub-pixel coverage anti-aliasing for drawing borders. The reviewer suggested optimizing the edge-drawing functions (draw_v_edge and draw_h_edge) by avoiding redundant blendPixel calls when the alpha coverage is zero, and caching the result of std::floor in the coverage lambda to improve rendering performance.

Comment thread src/web/src/tile_generator.cpp Outdated
Comment on lines +1920 to +1952
auto coverage = [](const double v) {
const double frac = v - std::floor(v);
return std::make_pair(
static_cast<int>(std::floor(v)),
static_cast<unsigned char>(std::lround(frac * 255.0)));
};

auto draw_v_edge = [&](const double xf) {
const auto [xi, frac_a] = coverage(xf);
Color near_col = gray;
Color far_col = gray;
near_col.a = static_cast<unsigned char>(gray.a - frac_a);
far_col.a = static_cast<unsigned char>(frac_a);
for (int iy = loop_yl; iy < loop_yh; ++iy) {
const int draw_y = (255 - iy);
setPixel(image_buffer, draw_xl, draw_y, gray);
blendPixel(image_buffer, xi, draw_y, near_col);
blendPixel(image_buffer, xi + 1, draw_y, far_col);
}
};

auto draw_h_edge = [&](const double yf) {
const auto [yi, frac_a] = coverage(yf);
Color near_col = gray;
Color far_col = gray;
near_col.a = static_cast<unsigned char>(gray.a - frac_a);
far_col.a = static_cast<unsigned char>(frac_a);
for (int ix = loop_xl; ix < loop_xh; ++ix) {
// Cartesian y -> screen y flip happens here; the "far"
// pixel (yi + 1 in cartesian) is one screen row above.
blendPixel(image_buffer, ix, 255 - yi, near_col);
blendPixel(image_buffer, ix, 255 - (yi + 1), far_col);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The current implementation of draw_v_edge and draw_h_edge calls blendPixel twice for every pixel along the edge, even when the sub-pixel coverage alpha (frac_a or 255 - frac_a) is 0. Since blendPixel performs expensive floating-point operations and coordinate transformations, avoiding these redundant calls when the alpha is 0 can significantly improve the rendering performance of the instances-only layer.

Additionally, std::floor(v) is called twice in coverage. We can optimize this by caching the result of std::floor(v).

            auto coverage = [](const double v) {
              const double floor_v = std::floor(v);
              const double frac = v - floor_v;
              return std::make_pair(
                  static_cast<int>(floor_v),
                  static_cast<unsigned char>(std::lround(frac * 255.0)));
            };

            auto draw_v_edge = [&](const double xf) {
              const auto [xi, frac_a] = coverage(xf);
              if (frac_a > 0) {
                Color far_col = gray;
                far_col.a = frac_a;
                for (int iy = loop_yl; iy < loop_yh; ++iy) {
                  blendPixel(image_buffer, xi + 1, 255 - iy, far_col);
                }
              }
              if (frac_a < 255) {
                Color near_col = gray;
                near_col.a = static_cast<unsigned char>(gray.a - frac_a);
                for (int iy = loop_yl; iy < loop_yh; ++iy) {
                  blendPixel(image_buffer, xi, 255 - iy, near_col);
                }
              }
            };

            auto draw_h_edge = [&](const double yf) {
              const auto [yi, frac_a] = coverage(yf);
              if (frac_a > 0) {
                Color far_col = gray;
                far_col.a = frac_a;
                for (int ix = loop_xl; ix < loop_xh; ++ix) {
                  blendPixel(image_buffer, ix, 255 - (yi + 1), far_col);
                }
              }
              if (frac_a < 255) {
                Color near_col = gray;
                near_col.a = static_cast<unsigned char>(gray.a - frac_a);
                for (int ix = loop_xl; ix < loop_xh; ++ix) {
                  blendPixel(image_buffer, ix, 255 - yi, near_col);
                }
              }
            };

@maliberty
Copy link
Copy Markdown
Member

Reduced but I still see
image

…ROAD-Project#10463)

Dense regular geometry (flip-chip / 3DBlox microbumps) aliased into a
moiré "beat" when zoomed out in the Web GUI 2D viewer.  The fix closes
both aliasing stages:

Server band-limit (tile_generator.cpp): render each tile into a
supersampled RGBA buffer (kCoverageSupersample=2) and decimate back with
a separable, premultiplied-alpha Lanczos-2 filter (lanczos2Downsample).
Drawing primitives take a runtime buffer dimension; fonts, strokes and
visibility thresholds scale by super_per_css so output is dpr-invariant.

HiDPI: the client sends devicePixelRatio; tiles render at 256*dpr
physical pixels (quantizeDpr clamps/snaps it) and Leaflet lays them out
at 256 CSS px, so the image maps 1:1 onto the device grid -- no browser
resampling re-aliases the raster.

Client 1:1 display: buildMapOptions sets zoomSnap/zoomDelta=1 and a
floorClampZoom override makes the tile pane upscale-only (never
downscale), on both the layout and heatmap layers.

Performance: an LRU tile cache on TileGenerator (clean tiles only) and a
best-effort render-cancel protocol (kCancel handler, run inline) skip and
reuse work during pan/zoom.

Tests: an FFT beat-band metric on a central window (the full-tile profile
is dominated by a benign edge envelope) plus block-CV; dense sub-pixel
array shows no beat, a resolved grid stays sharp, HiDPI renders at 512²,
and the cache/cancel paths are covered.  C++ 45+45 and the JS tile-layer
suite pass.

Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
…AD-Project#10463)

After the band-limit, a dense bump array whose pitch is near ~1px still
shows a residual moiré beat / busy grid that supersampling alone cannot
remove -- only collapsing it to a flat tint does.

Add a level-of-detail rule in renderTileBuffer: a bump instance
(kPhysBump) that renders smaller than kBumpLodMaxPx (6 CSS px) is not
drawn individually.  Its footprint is collected and, after the instance
loop, the array region is painted as one solid block of the layer color
(covering bumps AND the gaps between them), so no grid/beat survives.

To avoid slabbing over empty space, a solid block is only used when at
least kBumpLodMinCount bumps fall in the tile (a real array); a few
scattered bumps are drawn as individual solid rects.  On tech-layer
tiles a bump is collected only when it actually has obstruction/pin
geometry on that layer (bumpHasLayerGeom), so layers where the bump
draws nothing are not painted.  Bumps >= 6 px keep their resolved,
band-limited rendering.

Test: a dense COVER_BUMP array at z=0 (bumps ~1-2 px) collapses to a
uniform block (central block-CV < 0.03, non-transparent); the existing
ResolvedArrayStaysSharp confirms larger bumps still render as structure.

Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
…AD-Project#10463)

The sub-resolution bump LOD block filled the per-tile union of bump
bboxes, which stops ~1 pitch short of the tile edge when the tile
boundary falls in a gap between bumps.  Adjacent tiles then each left a
transparent strip at the shared edge, so the dark map background showed
through as a persistent black grid aligned to tile boundaries when
zoomed in (reproduced at dpr=1, at rest).

Snap each block edge that comes within ~1.5x the estimated bump pitch
of the tile edge out to that edge, so the blocks of adjacent tiles abut
seamlessly where the array continues into the neighbor.  A true array
edge (bumps far from that side) is left untouched, so empty space beyond
the array is not over-filled.

Test: BumpBlockTilesAbutWithoutSeam renders an interior tile of a dense
COVER_BUMP array and asserts the tile's right-edge column is mostly
opaque (the block reaches the edge); without the snap that column is
transparent.

Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
…t#10463)

No behavior change (all C++ and JS tests still green); review follow-ups:

- Reattach the "Store a Selected..." doc comment to storeSelectable in
  request_handler.cpp (quantizeDpr had been inserted between them); move
  the lazy-`app` doc comment back onto createWebSocketTileLayer in
  websocket-tile-layer.js.
- Classify each instance once in renderTileBuffer: add
  TileVisibility::isCategoryVisible(InstCategory) and have isInstVisible
  delegate to it, so the render loop no longer calls classifyInstance
  twice per instance (visibility gate + bump LOD).
- Drop selectability (s_* flags and selectable_layers) from the tile
  cache key — it does not affect the rendered tile, so toggling
  "selectable" no longer needlessly invalidates the cache.
- Simplify lanczos2Downsample: remove the always-full dirty-region
  window parameters (the dirty-region optimization was never wired up);
  it now always decimates the whole tile.

Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
…AD-Project#10463)

Post-review improvements (all server-side, tile_generator.cpp):

- F3 LOD crossfade: replace the hard 6px bump cut with a band
  [kBumpLodLoPx=5, kBumpLodHiPx=8].  Below the band a bump is collapsed
  into the solid block (as before); inside it the bump is ALSO drawn
  individually and the block's opacity ramps with the mean factor t, so
  zooming across the threshold fades block<->detail instead of popping.
  (Under integer zoom the band is hit on at most one level, so it softens
  rather than fully removes the inter-level step.)

- F1 perf: memoize the Lanczos-2 taps by (src_dim,dst_dim) (they depend
  only on dpr, but were rebuilt with sin/cos every tile); reuse the
  super-buffer and the Lanczos intermediate via thread_local scratch
  (renders run one-per-thread) instead of allocating them per tile.

- F2 perf: skip the Lanczos decimation entirely when the super-buffer is
  empty (common while panning) — the output is already a transparent
  tile and an empty buffer cannot alias.

Test: LodCrossfadeFillsGapsInBand renders a bump array sized to the band
and asserts the interior stays covered (the faded block tints the gaps),
which the old hard cut would not. 48 TileGenerator + 45 RequestHandler
green.

Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
@github-actions github-actions Bot added size/XL and removed size/M labels Jun 2, 2026
Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
Signed-off-by: Jorge Ferreira <jorge.ferreira@precisioninno.com>
@jorge-ferreira-pii
Copy link
Copy Markdown
Contributor Author

  • Anti-Moiré Filter (Supersampling & Lanczos-2): The backend now performs supersampling on tiles, followed by decimation and low-pass filtering using a Lanczos-2 filter. This rescales the image to the exact resolution while minimizing moiré noise before PNG conversion.

  • Dynamic LOD with Crossfade: For highly dense arrays, a dynamic Level of Detail (LOD) system was implemented to collapse very small instances into unified color blocks. The transition uses a smooth opacity crossfade to eliminate visual pop-in during zooming.

@maliberty
Copy link
Copy Markdown
Member

This sounds good from your description. How does it impact render time? Is the LOD only for instances or all object types?

@gadfort FYI

@jorge-ferreira-pii
Copy link
Copy Markdown
Contributor Author

This sounds good from your description. How does it impact render time? Is the LOD only for instances or all object types?

@gadfort FYI

At zoomed-out levels where the LOD is active, there is considerable savings in both time and processing power, as thousands of individual bumps are replaced by simply drawing solid blocks. At more zoomed-in levels where objects are drawn normally, there is a slight increase in backend rendering time due to the 2x supersampling.
The LOD implementation is strictly and specifically applied to instances of the kPhysBump (physical bumps) category, not to all object types.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Web GUI: Moiré pattern on bump arrays in the Web GUI 2D layout`

2 participants