diff --git a/examples/naip-mosaic/src/App.tsx b/examples/naip-mosaic/src/App.tsx index 5d4d8764..e5b1420a 100644 --- a/examples/naip-mosaic/src/App.tsx +++ b/examples/naip-mosaic/src/App.tsx @@ -433,11 +433,12 @@ export default function App() { colormapReversed: colormapChoice.reversed, }), signal, + maxCacheSize: 10, }); }, // Smaller cache for MosaicLayer cache, since it caches full COGLayer // instances - maxCacheSize: 5, + maxCacheSize: 0, // @ts-expect-error beforeId is injected by @deck.gl/mapbox; LayerProps // doesn't know about it. beforeId: "boundary_country_outline", diff --git a/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts b/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts index eee08639..1424ed39 100644 --- a/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts +++ b/packages/deck.gl-raster/src/mesh-layer/mesh-layer.ts @@ -1,4 +1,8 @@ -import type { DefaultProps, TextureSource } from "@deck.gl/core"; +import type { + DefaultProps, + TextureSource, + UpdateParameters, +} from "@deck.gl/core"; import type { SimpleMeshLayerProps } from "@deck.gl/mesh-layers"; import { SimpleMeshLayer } from "@deck.gl/mesh-layers"; import type { Texture } from "@luma.gl/core"; @@ -55,6 +59,40 @@ export class MeshTextureLayer extends SimpleMeshLayer< return [...imageModule, ...(renderPipeline ?? [])]; } + override updateState(params: UpdateParameters): void { + // Ensure the SimpleMeshLayer rebuilds the model when the renderPipeline has + // changed. + if (this.hasRenderPipelineChanged(params)) { + // Setting extensionsChanged to true causes recompiling the shader + // https://github.com/visgl/deck.gl/blob/70adde2f1fcdf5e99195df81512e6d01ee7a5edc/modules/mesh-layers/src/simple-mesh-layer/simple-mesh-layer.ts#L284-L297 + params.changeFlags.extensionsChanged = true; + } + + super.updateState(params); + } + + /** Returns true if the render pipeline has changed between the old and new props. */ + private hasRenderPipelineChanged(params: UpdateParameters): boolean { + const { oldProps, props: newProps } = params; + if (Boolean(oldProps.image) !== Boolean(newProps.image)) { + return true; + } + + const oldPipeline = oldProps.renderPipeline ?? []; + const newPipeline = newProps.renderPipeline ?? []; + if (oldPipeline.length !== newPipeline.length) { + return true; + } + + for (let i = 0; i < oldPipeline.length; i++) { + if (oldPipeline[i]?.module.name !== newPipeline[i]?.module.name) { + return true; + } + } + + return false; + } + override getShaders() { const upstreamShaders = super.getShaders(); diff --git a/packages/deck.gl-raster/src/raster-layer.ts b/packages/deck.gl-raster/src/raster-layer.ts index 6899b3df..a8db5a0f 100644 --- a/packages/deck.gl-raster/src/raster-layer.ts +++ b/packages/deck.gl-raster/src/raster-layer.ts @@ -132,10 +132,19 @@ export class RasterLayer extends CompositeLayer { declare state: { reprojector?: RasterReprojector; + /** + * Mesh in the exact shape SimpleMeshLayer expects. + * + * It's important for this to be passed to MeshTextureLayer as a stable + * reference so `props.mesh` equality holds across renders. This avoids + * unnecessarily recreating the model. + */ mesh?: { - positions: Float32Array; - indices: Uint32Array; - texCoords: Float32Array; + indices: { value: Uint32Array; size: number }; + attributes: { + POSITION: { value: Float32Array; size: number }; + TEXCOORD_0: { value: Float32Array; size: number }; + }; }; }; @@ -199,9 +208,11 @@ export class RasterLayer extends CompositeLayer { this.setState({ reprojector, mesh: { - positions, - indices, - texCoords, + indices: { value: indices, size: 1 }, + attributes: { + POSITION: { value: positions, size: 3 }, + TEXCOORD_0: { value: texCoords, size: 2 }, + }, }, }); } @@ -275,8 +286,6 @@ export class RasterLayer extends CompositeLayer { return null; } - const { indices, positions, texCoords } = mesh; - const meshLayer = new MeshTextureLayer( this.getSubLayerProps({ id: "raster", @@ -285,19 +294,7 @@ export class RasterLayer extends CompositeLayer { // Dummy data because we're only rendering _one_ instance of this mesh // https://github.com/visgl/deck.gl/blob/93111b667b919148da06ff1918410cf66381904f/modules/geo-layers/src/terrain-layer/terrain-layer.ts#L241 data: [1], - mesh: { - indices: { value: indices, size: 1 }, - attributes: { - POSITION: { - value: positions, - size: 3, - }, - TEXCOORD_0: { - value: texCoords, - size: 2, - }, - }, - }, + mesh, // We're only rendering a single mesh, without instancing // https://github.com/visgl/deck.gl/blob/93111b667b919148da06ff1918410cf66381904f/modules/geo-layers/src/terrain-layer/terrain-layer.ts#L244 _instanced: false, diff --git a/packages/deck.gl-raster/tests/raster-layer.test.ts b/packages/deck.gl-raster/tests/raster-layer.test.ts new file mode 100644 index 00000000..bfb2374d --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-layer.test.ts @@ -0,0 +1,86 @@ +import type { ReprojectionFns } from "@developmentseed/raster-reproject"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../src/mesh-layer/mesh-layer.js", () => ({ + MeshTextureLayer: class CapturingMeshTextureLayer { + public props: Record; + constructor(props: Record) { + this.props = props; + } + }, +})); + +const { RasterLayer } = await import("../src/raster-layer.js"); + +const identity = (x: number, y: number): [number, number] => [x, y]; +const REPROJECTION_FNS: ReprojectionFns = { + forwardTransform: identity, + inverseTransform: identity, + forwardReproject: identity, + inverseReproject: identity, +}; + +/** + * Build a {@link RasterLayer} ready for direct lifecycle invocation: bypasses + * deck.gl's `LayerManager` by replacing `state` and `setState` with a plain + * object + assign, and short-circuits `getSubLayerProps` so MeshTextureLayer + * receives the exact prop shape we hand it. + */ +function makeBareLayer() { + const layer = new RasterLayer({ + id: "test", + width: 4, + height: 4, + reprojectionFns: REPROJECTION_FNS, + image: {} as never, + }); + const internalState: Record = {}; + Object.assign(layer as object, { state: internalState }); + Object.assign(layer as object, { + setState: (updates: Record) => + Object.assign(internalState, updates), + getSubLayerProps: (props: T) => props, + }); + return { layer, internalState }; +} + +type WrappedMesh = { + indices: { value: Uint32Array; size: number }; + attributes: { + POSITION: { value: Float32Array; size: number }; + TEXCOORD_0: { value: Float32Array; size: number }; + }; +}; + +describe("RasterLayer.state.mesh", () => { + it("stores the mesh in SimpleMeshLayer's expected wrapper shape", () => { + const { layer, internalState } = makeBareLayer(); + + (layer as unknown as { _generateMesh: () => void })._generateMesh(); + + const mesh = internalState.mesh as WrappedMesh; + expect(mesh.indices.value).toBeInstanceOf(Uint32Array); + expect(mesh.indices.size).toBe(1); + expect(mesh.attributes.POSITION.value).toBeInstanceOf(Float32Array); + expect(mesh.attributes.POSITION.size).toBe(3); + expect(mesh.attributes.TEXCOORD_0.value).toBeInstanceOf(Float32Array); + expect(mesh.attributes.TEXCOORD_0.size).toBe(2); + }); + + it("passes state.mesh to MeshTextureLayer by reference across renders", () => { + const { layer, internalState } = makeBareLayer(); + + (layer as unknown as { _generateMesh: () => void })._generateMesh(); + const meshRef = internalState.mesh; + + const renderOnce = layer as unknown as { + renderLayers: () => { props: { mesh: unknown } }[]; + }; + const layers1 = renderOnce.renderLayers(); + const layers2 = renderOnce.renderLayers(); + + expect(layers1[0]!.props.mesh).toBe(meshRef); + expect(layers2[0]!.props.mesh).toBe(meshRef); + expect(layers1[0]!.props.mesh).toBe(layers2[0]!.props.mesh); + }); +});