Skip to content

perf(raster-layer): avoid re-compiling shader Model as much as possible#540

Merged
kylebarron merged 12 commits into
mainfrom
raster-layer-mesh-reshape
May 14, 2026
Merged

perf(raster-layer): avoid re-compiling shader Model as much as possible#540
kylebarron merged 12 commits into
mainfrom
raster-layer-mesh-reshape

Conversation

@kylebarron
Copy link
Copy Markdown
Member

@kylebarron kylebarron commented May 14, 2026

It's very important to reduce shader recompiles as much as possible. Changing a pixel filter slider should be nearly instant; it should just be updating the uniforms to pass into the existing GPU pipeline.

Previously we were re-compiling the shader model on every render, because the mesh instance was not passing instance equality.

Significant performance improvement in the NAIP mosaic pixel filter slider:

Before:

Screen.Recording.2026-05-14.at.1.06.47.PM.mov

After:

Screen.Recording.2026-05-14.at.1.14.07.PM.mov

Summary

Two-part fix that lets RasterLayer/MeshTextureLayer reuse the same luma.gl Model (and its compiled shader) across renders, while still rebuilding it the rare times the shader source actually has to change.

  1. RasterLayer — store the full {indices, attributes} mesh wrapper in state.mesh and pass it directly to MeshTextureLayer, so the reference stays stable across renders.
  2. MeshTextureLayer — override updateState to force a Model rebuild when the renderPipeline module list changes (which the upstream SimpleMeshLayer has no way to know about, since it only checks mesh and extensions).

Why

SimpleMeshLayer.updateState rebuilds its Model whenever props.mesh !== oldProps.mesh (reference equality check). Before this change, RasterLayer.renderLayers() built a fresh wrapper object on every render — so every tile sublayer destroyed and recreated its Model every frame, running full assembleGLSLShaderPair and vertex-buffer setup per tile per frame.

In the naip-mosaic example, moving the NDVI range slider dropped a single redraw frame to ~120ms in profiles, with warnIfGLSLUniformBlocksAreNotStd140 (11.5ms) and extractShaderUniformBlockFieldNames (9.0ms) dominating self-time — both functions only run inside assembleGLSLShaderPair.

Relation to #543

#543 fixed an upstream churn source (tileTransform returning fresh arrow functions every call) and ended the constant _generateMesh re-runs in RasterLayer. That was sufficient for scenarios where the changing prop didn't propagate to RasterLayer's renderPipeline (e.g. the debug overlay toggle tested there). It's not sufficient when the changing prop is renderPipeline itself — which is the common case for any interactive uniform (sliders, color pickers, etc.). The mesh wrapper is still rebuilt in renderLayers() for those, and the Model still churns. This PR closes that gap.

Why the mesh-only fix wasn't enough

The first iteration of this PR (18f32ca) only stabilized the mesh reference. That broke render-mode switching: with the Model preserved, the shader source froze at whatever renderPipeline was active when the Model was first built. Switching renderMode: trueColor → ndvi in the naip-mosaic example produced new modules in renderPipeline, but the GPU silently kept running the original RGB shader.

The second commit fixes this in MeshTextureLayer.updateState: it compares old vs. new module identities (plus image presence) and forces a single Model rebuild when they differ. Prop-only changes within the same module list — the slider case — skip the rebuild and update uniforms via shaderInputs.setProps in draw().

Verified

In the naip-mosaic example:

  • NDVI slider (same module list, different uniform values): no Model rebuilds. Frame cost drops to roughly uniform-update + redraw. Slider feels instantaneous.
  • Render-mode switch (different module list): one Model rebuild per visible tile, at the moment of the switch. Imagery updates correctly. No per-frame cost after.
  • Initial tile load: one Model build per tile, as before.

Test plan

  • Unit test asserts state.mesh reference is stable across renders when mesh-affecting props haven't changed (0d21f83)
  • Typecheck + 96 existing unit tests passing
  • End-to-end functional check in naip-mosaic: slider and mode-switch both work
  • Profile-driven verification in naip-mosaic: ~120ms frame → instant slider
  • Add unit test for MeshTextureLayer module-list-change rebuild path
  • Verify no visual regression in usgs-topo-cutline example
  • Investigate the DECKGL_FILTER_COLOR shader compile error previously seen in usgs-topo (likely orthogonal — that error was bisected as preexisting in initial testing of this PR)

🤖 Generated with Claude Code

Store the full `{indices, attributes}` mesh wrapper in `state.mesh` and pass
it directly to `MeshTextureLayer`, rather than rebuilding the wrapper on
every `renderLayers()` call.

`SimpleMeshLayer.updateState` rebuilds its `Model` whenever
`props.mesh !== oldProps.mesh` (reference equality). With the previous
implementation, every render produced a new wrapper object, so every tile
sublayer destroyed and recreated its Model every frame — incurring full
`assembleGLSLShaderPair` and vertex-buffer setup per tile per frame. With
many sublayers active (e.g. a multi-source mosaic of small-tile COGs), this
dominated frame time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
kylebarron and others added 2 commits May 14, 2026 10:35
Adds two unit tests for the mesh stabilization invariants:

1. `state.mesh` after `_generateMesh()` matches SimpleMeshLayer's
   `{indices: {value, size}, attributes: {POSITION, TEXCOORD_0}}` shape.
2. Two consecutive `renderLayers()` calls pass the *same* `state.mesh`
   reference to `MeshTextureLayer` — the property `SimpleMeshLayer` checks
   via `props.mesh !== oldProps.mesh` before rebuilding its Model.

The tests bypass deck.gl's `LayerManager` (constructing a bare layer with
stubbed `setState` / `getSubLayerProps`) and mock `MeshTextureLayer` so
its props are captured directly. Verifies the fix at the unit level
independent of any rendering context.

Also adds a temporary `console.log` in `MeshTextureLayer.getShaders()` so
the fix can be evaluated empirically in a real app: each call corresponds
to a Model construction, so a healthy after-state should show the log
fire only on tile load, not per frame.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…teState

SimpleMeshLayer rebuilds its Model when `props.mesh !== oldProps.mesh` or
`changeFlags.extensionsChanged`. Logging which trigger fires (and whether
oldProps.mesh existed) lets us distinguish first-time initialization,
mesh-reference instability, and unexpected extensions changes when
evaluating the mesh-reference fix in a real app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kylebarron
Copy link
Copy Markdown
Member Author

kylebarron commented May 14, 2026

The root cause was found and resolved in #543 It turns out this is a separate and also important root cause

@kylebarron kylebarron closed this May 14, 2026
@kylebarron kylebarron reopened this May 14, 2026
With the stable-mesh-ref fix in this branch, `SimpleMeshLayer` no longer
destroys and rebuilds its Model on every render. That's the perf win we
want — but the existing Model holds a shader compiled from the *initial*
renderPipeline module list. When the caller swaps modules (e.g. a render-
mode switch in the naip-mosaic example: trueColor → NDVI), the new
modules never make it into the shader, so the GPU silently keeps running
the previous pipeline.

Override `MeshTextureLayer.updateState` to detect renderPipeline module-
list changes and force a single Model rebuild in that case. Per-frame
prop-only changes (e.g. an NDVI slider updating `ndviFilter`'s
`ndviMin/ndviMax`) still take the cheap `shaderInputs.setProps` path
through `draw()`.

Also drops the temporary diagnostic `console.log` statements from
adee590 that were added to evaluate the mesh-ref fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kylebarron kylebarron changed the title perf(raster-layer): stabilize mesh prop reference across renders perf(raster-layer,mesh-layer): cache compiled Model across renders May 14, 2026
@kylebarron kylebarron changed the title perf(raster-layer,mesh-layer): cache compiled Model across renders perf(raster-layer): avoid re-compiling shader Model as much as possible May 14, 2026
@kylebarron kylebarron marked this pull request as ready for review May 14, 2026 17:26
kylebarron and others added 5 commits May 14, 2026 13:37
Switch the module-change detection in `hasRenderPipelineChanged` from
reference equality on the module object to equality on `module.name`.

Name is the canonical identifier luma.gl uses to dedupe modules during
shader assembly — two modules with the same name can't coexist in a
pipeline anyway — so "same names in the same order" is the more honest
proxy for "the shape of the assembled shader is the same."

It also avoids false-positive Model rebuilds on HMR / module
re-evaluation, where the user's `const fooModule = {...}` gets a new
identity but the same name and source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…edicate

Previously we mirrored SimpleMeshLayer's mesh/extensions rebuild predicate
inline to know whether super.updateState already replaced the Model.
Capture `this.state.model` before super.updateState and compare after
instead — directly meaningful ("did super swap the Model out?") and
robust if SimpleMeshLayer adds new rebuild triggers upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…line

Rather than duplicating SimpleMeshLayer's destroy/getModel/invalidateAll
sequence, just set `changeFlags.extensionsChanged = true` before calling
super.updateState when the renderPipeline module list changes. super
already runs that exact rebuild path on the flag, and that path calls our
overridden getShaders() — which composes the new shader from the current
renderPipeline. One source of truth for the rebuild dance, no `as any`
cast on `props.mesh`, and resilient to upstream changes in how the
rebuild is performed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kylebarron kylebarron enabled auto-merge (squash) May 14, 2026 17:47
@kylebarron kylebarron merged commit 06c3668 into main May 14, 2026
3 checks passed
@kylebarron kylebarron deleted the raster-layer-mesh-reshape branch May 14, 2026 17:50
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.

1 participant