From 96ee175f96585578a1f3ea4779949a727e24bb0f Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 25 Aug 2026 21:08:39 +0200 Subject: [PATCH 01/78] feat: add geoportal shadow simulation --- ...wuppertal-mesh-2024.three-tiles.style.json | 28 + apps/geoportal/src/app/App.tsx | 7 + .../geoportalLayersToLibreLayers.spec.ts | 66 +++ .../geoportalLayersToLibreLayers.ts | 67 +++ .../layers/GeoportalLayerButtonSlot.tsx | 29 + .../app/components/layers/SecondaryView.tsx | 144 +++-- .../src/app/components/layers/items.tsx | 3 + .../src/app/constants/fachzwillinge/addons.ts | 5 + .../useShadowSimulationLayerButton.spec.tsx | 157 ++++++ .../hooks/useShadowSimulationLayerButton.tsx | 107 ++++ libraries/mapping/addons/README.md | 3 +- libraries/mapping/addons/project.json | 7 + .../ShadowSimulation/SolarDayTimeControl.tsx | 321 +++++++++++ .../src/addons/ShadowSimulation/index.tsx | 229 ++++++++ .../ShadowSimulation/shadow-scene.spec.ts | 138 +++++ .../addons/ShadowSimulation/shadow-scene.ts | 252 +++++++++ .../ShadowSimulation/solar-position.spec.ts | 64 +++ .../addons/ShadowSimulation/solar-position.ts | 304 +++++++++++ libraries/mapping/addons/src/index.ts | 11 +- .../addons/src/lib/TargetAddonHost.tsx | 2 +- .../mapping/addons/src/lib/addon-overrides.ts | 1 + libraries/mapping/addons/src/lib/registry.ts | 22 + .../mapping/addons/src/lib/target-addons.ts | 14 + libraries/mapping/addons/tsconfig.json | 3 + libraries/mapping/addons/tsconfig.spec.json | 21 + libraries/mapping/addons/vite.config.ts | 21 + libraries/mapping/engines/maplibre/README.md | 19 + .../maplibre/src/components/LibreMap.tsx | 38 +- .../SharedThreeTilesLayerManager.tsx | 56 ++ .../src/components/ThreeLayerManager.tsx | 51 +- .../mapping/engines/maplibre/src/index.ts | 50 ++ .../generic-three-layer-registry.spec.ts | 47 ++ .../generic-three-layer-registry.ts | 84 +++ .../integrations/gltf1-upgrade-plugin.ts | 0 .../shadow-simulation-content-status.spec.ts | 121 +++++ .../shadow-simulation-content-status.ts | 80 +++ .../shared-three-scene-layer.spec.ts | 38 ++ .../integrations/shared-three-scene-layer.ts | 56 +- .../shared-three-scene-registry.spec.ts | 89 +++ .../shared-three-scene-registry.ts | 133 +++++ .../runtime/integrations/three-tiles-layer.ts | 25 + .../integrations/three-tiles-runtime.spec.ts | 60 +++ .../integrations/three-tiles-runtime.ts | 204 ++++--- .../runtime/integrations}/tiles-camera-set.ts | 0 .../mapping/layers/src/hooks/useHandleDrop.ts | 4 +- .../app/pointcloud/PointCloudPlayground.tsx | 510 ++++++++++-------- .../src/app/pointcloud/copcPointsLayer.ts | 83 ++- .../pointcloud/pointTilesetSceneRuntime.ts | 10 +- .../src/app/pointcloud/tiles3dLayer.spec.ts | 23 - 49 files changed, 3335 insertions(+), 472 deletions(-) create mode 100644 apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json create mode 100644 apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts create mode 100644 apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx create mode 100644 apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/SolarDayTimeControl.tsx create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.spec.ts create mode 100644 libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts create mode 100644 libraries/mapping/addons/tsconfig.spec.json create mode 100644 libraries/mapping/addons/vite.config.ts create mode 100644 libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts rename playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts => libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts (100%) create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.spec.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts rename playgrounds/ng-topicmap-playground/src/app/pointcloud/pointcloudSceneLayer.ts => libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts (77%) create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts create mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts rename playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles3dLayer.ts => libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts (82%) rename {playgrounds/ng-topicmap-playground/src/app/pointcloud => libraries/mapping/engines/maplibre/src/lib/runtime/integrations}/tiles-camera-set.ts (100%) delete mode 100644 playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles3dLayer.spec.ts diff --git a/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json b/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json new file mode 100644 index 0000000000..6f7a444582 --- /dev/null +++ b/apps/geoportal/public/data/wuppertal-mesh-2024.three-tiles.style.json @@ -0,0 +1,28 @@ +{ + "version": 8, + "name": "Wuppertal Mesh 2024", + "sources": {}, + "layers": [], + "metadata": { + "carmaConf": { + "instant": true, + "layerInfo": { + "title": "Wuppertal Mesh 2024", + "description": "3D-Tiles-Mesh mit Clay-Material für die gemeinsame Three.js-Szene", + "keywords": [] + }, + "threeTiles": { + "url": "https://wupp-3d-data.cismet.de/mesh2024/tileset.json", + "origin": [7.15, 51.256], + "shader": { + "kind": "clay", + "color": "#d8d1c4", + "roughness": 0.92, + "metalness": 0 + }, + "errorTarget": 8, + "requestConcurrency": 2 + } + } + } +} diff --git a/apps/geoportal/src/app/App.tsx b/apps/geoportal/src/app/App.tsx index 5fd38f2339..3f538fb4ce 100644 --- a/apps/geoportal/src/app/App.tsx +++ b/apps/geoportal/src/app/App.tsx @@ -66,6 +66,7 @@ import { useManageLayers } from "./hooks/useManageLayers"; import { useSyncToken } from "./hooks/useSyncToken"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import { useMeasurementLayerButton } from "./hooks/useMeasurementLayerButton"; +import { useShadowSimulationLayerButton } from "./hooks/useShadowSimulationLayerButton"; import { useGeoportalAppSearchParams } from "./hooks/use-geoportal-app-search-params"; import { useAdhocFeatureRehydrate } from "./hooks/use-adhoc-feature-rehydrate"; @@ -174,6 +175,11 @@ function MeasurementLayerSyncInner() { return null; } +function ShadowSimulationLayerSyncInner() { + useShadowSimulationLayerButton(); + return null; +} + function GeoportalAppSearchParamsIntegration() { useGeoportalAppSearchParams(); return null; @@ -276,6 +282,7 @@ function App({ > +
diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts new file mode 100644 index 0000000000..67a759f59d --- /dev/null +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import type { Layer } from "@carma-mapping/layers"; + +import { + geoportalLayersToLibreLayers, + parseThreeTilesLayer, +} from "./geoportalLayersToLibreLayers"; + +const buildLayer = (threeTiles: unknown): Layer => + ({ + id: "custom:mesh-2024", + title: "Wuppertal Mesh 2024", + visible: true, + opacity: 0.75, + layerType: "vector", + conf: { threeTiles }, + props: { style: { version: 8, sources: {}, layers: [] } }, + } as unknown as Layer); + +describe("Geoportal 3D Tiles layer conversion", () => { + it("maps a declared clay shader into the shared Three.js layer contract", () => { + const layer = buildLayer({ + url: "https://example.test/tileset.json", + origin: [7.15, 51.256], + shader: { + kind: "clay", + color: "#d8d1c4", + roughness: 0.92, + metalness: 0, + }, + errorTarget: 8, + requestConcurrency: 2, + }); + + expect(geoportalLayersToLibreLayers([layer])).toEqual([ + { + type: "three-tiles", + name: "Wuppertal Mesh 2024", + carmaLayerId: "custom:mesh-2024", + url: "https://example.test/tileset.json", + origin: [7.15, 51.256], + shader: { + kind: "clay", + color: "#d8d1c4", + roughness: 0.92, + metalness: 0, + }, + errorTarget: 8, + requestConcurrency: 2, + opacity: 0.75, + }, + ]); + }); + + it("rejects unknown shader contracts", () => { + expect( + parseThreeTilesLayer( + buildLayer({ + url: "https://example.test/tileset.json", + shader: { kind: "custom-glsl", color: "#fff" }, + }) + ) + ).toBeNull(); + }); +}); diff --git a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts index 01fef59816..c486722be2 100644 --- a/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts +++ b/apps/geoportal/src/app/components/GeoportalMap/geoportalLayersToLibreLayers.ts @@ -2,11 +2,73 @@ import type { StyleSpecification } from "maplibre-gl"; import type { DynamicStylingOptionsConfig, Layer } from "@carma-mapping/layers"; import type { LibreLayer } from "@carma-mapping/core"; +import { + THREE_TILES_LAYER_TYPE, + THREE_TILES_SHADER_KIND, +} from "@carma-mapping/engines/maplibre"; import { applyDynamicStylingToStylesheet, buildFilterExpression, } from "@carma-mapping/components"; +type ThreeTilesLibreLayer = Extract; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +export const parseThreeTilesLayer = ( + layer: Layer +): ThreeTilesLibreLayer | null => { + const candidate = (layer.conf as Record | undefined) + ?.threeTiles; + if (!candidate || typeof candidate !== "object") return null; + + const config = candidate as Record; + const shader = config.shader; + if (typeof config.url !== "string" || !shader || typeof shader !== "object") { + return null; + } + + const shaderConfig = shader as Record; + if ( + shaderConfig.kind !== THREE_TILES_SHADER_KIND.CLAY || + typeof shaderConfig.color !== "string" + ) { + return null; + } + + const origin = config.origin; + const validOrigin = + Array.isArray(origin) && origin.length === 2 && origin.every(isFiniteNumber) + ? ([origin[0], origin[1]] as [number, number]) + : undefined; + + return { + type: THREE_TILES_LAYER_TYPE, + name: layer.title || layer.id, + carmaLayerId: layer.id, + url: config.url, + shader: { + kind: THREE_TILES_SHADER_KIND.CLAY, + color: shaderConfig.color, + ...(isFiniteNumber(shaderConfig.roughness) + ? { roughness: shaderConfig.roughness } + : {}), + ...(isFiniteNumber(shaderConfig.metalness) + ? { metalness: shaderConfig.metalness } + : {}), + }, + ...(validOrigin ? { origin: validOrigin } : {}), + ...(isFiniteNumber(config.errorTarget) + ? { errorTarget: config.errorTarget } + : {}), + ...(isFiniteNumber(config.requestConcurrency) + ? { requestConcurrency: config.requestConcurrency } + : {}), + opacity: layer.opacity ?? 1, + }; +}; + const collectDynamicStylingConfigs = ( layer: Layer ): DynamicStylingOptionsConfig[] => { @@ -66,6 +128,11 @@ export const geoportalLayersToLibreLayers = (layers: Layer[]): LibreLayer[] => { if (!layer.visible) { continue; } + const threeTilesLayer = parseThreeTilesLayer(layer); + if (threeTilesLayer) { + result.push(threeTilesLayer); + continue; + } if (!layer.props) { continue; } diff --git a/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx b/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx index 5e7c434bc5..a1bd244caa 100644 --- a/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx +++ b/apps/geoportal/src/app/components/layers/GeoportalLayerButtonSlot.tsx @@ -25,6 +25,7 @@ import { useAnnotationsRuntime, } from "@carma-mapping/annotations/runtime"; import { useMeasurements } from "@carma-mapping/measurements"; +import { useAddonState } from "@carma-mapping/addons"; import { useLibreMapEnabled } from "../../hooks/useLibreMapEnabled"; import { geoportalAnnotationModeText } from "../../config/geoportalTextConfig"; @@ -43,6 +44,7 @@ import { } from "../annotations/cesium-annotations.constants"; import { MeasurementDeleteConfirmationModal } from "../annotations/MeasurementDeleteConfirmationModal"; import { MEASUREMENT_LAYER_ID } from "../../hooks/useMeasurementLayerButton"; +import { SHADOW_SIMULATION_LAYER_ID } from "../../hooks/useShadowSimulationLayerButton"; import { AdhocModelFlyToLayerbarAction, AdhocModelLayerbarActions, @@ -388,6 +390,29 @@ const MeasurementLayerButton = (props: GeoportalLayerButtonProps) => { ); }; +const ShadowSimulationLayerButton = (props: GeoportalLayerButtonProps) => { + const dispatch = useDispatch(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); + + const handleClose = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + if (shadowState) { + setShadowState({ ...shadowState, enabled: false }); + } + dispatch(removeLayer(SHADOW_SIMULATION_LAYER_ID)); + }, + [dispatch, setShadowState, shadowState] + ); + + return ( + + ); +}; + const SavedCesiumMeasurementLayerButton = ( props: GeoportalLayerButtonProps & { annotationsGeoJson: AnnotationsRuntimeGeoJsonFeatureCollection; @@ -424,6 +449,10 @@ const GeoportalLayerButtonSlot = (props: GeoportalLayerButtonProps) => { return ; } + if (props.id === SHADOW_SIMULATION_LAYER_ID) { + return ; + } + const isAdhocModelLayer = props.layer.type === "object" && !!props.layer.props?.style; const layerServiceName = diff --git a/apps/geoportal/src/app/components/layers/SecondaryView.tsx b/apps/geoportal/src/app/components/layers/SecondaryView.tsx index 4a44fdf9e3..29ccbe23e5 100644 --- a/apps/geoportal/src/app/components/layers/SecondaryView.tsx +++ b/apps/geoportal/src/app/components/layers/SecondaryView.tsx @@ -17,6 +17,11 @@ import { TopicMapContext } from "react-cismap/contexts/TopicMapContextProvider"; import { useDispatch, useSelector } from "react-redux"; import { SELECTED_LAYER_INDEX } from "@carma-appframeworks/portals"; import { cn } from "@carma-commons/utils"; +import { + resolveSecondaryViewTargetAddon, + TargetAddonHost, + useAddonState, +} from "@carma-mapping/addons"; import { changeBackgroundVisibility, @@ -62,11 +67,28 @@ import DynamicStylingLayerIcon from "./DynamicStylingLayerIcon"; import { hasLayerFilterControl } from "./LayerFilterControl"; import { InteractionContent } from "./InteractionView"; import { DEFAULT_LAYER_VISIBILITY_TOGGLE_LABELS } from "./layer-visibility-toggle-props"; +import { SHADOW_SIMULATION_LAYER_ID } from "../../hooks/useShadowSimulationLayerButton"; type Ref = HTMLDivElement; interface SecondaryViewProps {} +const formatShadowSelection = (selection: { + year: number; + dayOfYear: number; + minutes: number; +}) => { + const date = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "short", + timeZone: "UTC", + }).format(new Date(Date.UTC(selection.year, 0, selection.dayOfYear))); + const roundedMinutes = Math.round(selection.minutes); + const hours = String(Math.floor(roundedMinutes / 60)).padStart(2, "0"); + const minutes = String(roundedMinutes % 60).padStart(2, "0"); + return `${date} · ${hours}:${minutes}`; +}; + const SecondaryView = forwardRef(({}, _ref) => { void _ref; const { routedMapRef } = useContext(TopicMapContext); @@ -79,6 +101,7 @@ const SecondaryView = forwardRef(({}, _ref) => { const selectedEntry = useSelector(getSelectedStackEntry); const backgroundLayer = useSelector(getBackgroundLayer); const { favorites, addFavorite, removeFavorite } = useLayerCatalog(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); const activeInteractionLayerID = useSelector(getActiveInteractionLayerID); const entry = (selectedLayerIndex >= 0 ? selectedEntry : backgroundLayer) ?? @@ -113,6 +136,13 @@ const SecondaryView = forwardRef(({}, _ref) => { ? "gärten" : undefined; const isBaseLayer = selectedLayerIndex === -1; + const secondaryViewAddon = + !isBaseLayer && !group + ? resolveSecondaryViewTargetAddon(layer as Layer) + : undefined; + const isShadowSimulationLayer = + entry.id === SHADOW_SIMULATION_LAYER_ID && + secondaryViewAddon?.kind === "shadowSimulation"; const isInteractionActive = activeInteractionLayerID === entry.id; const canFilter = @@ -120,7 +150,9 @@ const SecondaryView = forwardRef(({}, _ref) => { const filterInfo = (layer as Layer)?.filterInfo; const canFavorite = - !isBaseLayer && (entry.type === "layer" || entry.type === "object"); + !isBaseLayer && + !secondaryViewAddon && + (entry.type === "layer" || entry.type === "object"); const isFavorite = canFavorite && favorites.some( @@ -321,7 +353,9 @@ const SecondaryView = forwardRef(({}, _ref) => { "min-w-[280px] sm:max-w-[560px] md:max-w-[720px] lg:w-full w-[100vw] sm:w-3/4 sm:mx-0 shrink-0", "h-fit bg-white button-shadow rounded-[10px] flex flex-col relative secondary-view gap-2 py-2 transition-all duration-300", showInfo - ? "sm:max-h-[600px] sm:h-[70vh] h-[80vh]" + ? secondaryViewAddon + ? "max-h-[min(600px,80vh)]" + : "sm:max-h-[600px] sm:h-[70vh] h-[80vh]" : isBaseLayer ? "h-fit" : "h-fit sm:h-12" @@ -360,7 +394,7 @@ const SecondaryView = forwardRef(({}, _ref) => { ) : ( (({}, _ref) => { {isBaseLayer ? "Hintergrund" : entry.title}
-
- -
- + {!secondaryViewAddon && ( +
+ +
+ +
-
+ )} + {isShadowSimulationLayer && shadowState && ( +
+ {formatShadowSelection(shadowState.selection)} +
+ )} {canFilter && (
-
- -
- + {!secondaryViewAddon && ( +
+ +
+ +
+ + {Math.round((1 - (entry.opacity ?? 1)) * 100)}% +
- - {Math.round((1 - (entry.opacity ?? 1)) * 100)}% - -
+ )} {isInteractionActive && !group && (
@@ -504,6 +554,15 @@ const SecondaryView = forwardRef(({}, _ref) => {
)} + {showInfo && secondaryViewAddon && !group && ( +
+ +
+ )} + {isBaseLayer && (
@@ -513,10 +572,11 @@ const SecondaryView = forwardRef(({}, _ref) => {
)} - {showInfoText && ( + {showInfoText && !secondaryViewAddon && (
)} {showInfoText && + !secondaryViewAddon && (isBaseLayer ? ( ) : group ? ( diff --git a/apps/geoportal/src/app/components/layers/items.tsx b/apps/geoportal/src/app/components/layers/items.tsx index 81d878bbb2..53fece4896 100644 --- a/apps/geoportal/src/app/components/layers/items.tsx +++ b/apps/geoportal/src/app/components/layers/items.tsx @@ -3,6 +3,7 @@ import { faGlobe, faLayerGroup, faSquare, + faSun, } from "@fortawesome/free-solid-svg-icons"; import { Layer } from "@carma-mapping/layers"; @@ -81,10 +82,12 @@ export const iconMap = { gärten: faSquare, ortho: faGlobe, background: faLayerGroup, + "shadow-simulation": faSun, }; export const iconColorMap = { bäume: "green", gärten: "purple", ortho: "black", + "shadow-simulation": "#d97706", }; diff --git a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts index 3f8e21ae77..2c4131a21b 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts @@ -30,6 +30,7 @@ export const addonsFachzwilling: FachzwillingRoute = { // circle that is dragged over it, and wheeled larger or smaller. Two panels // and no more, which the shared state holds the layout to. { kind: "compareSpyglass", config: {} }, + { kind: "cameraRestriction", config: { mode: "never" } }, { kind: "vectorHighlight", config: { @@ -59,6 +60,10 @@ export const addonsFachzwilling: FachzwillingRoute = { kind: "libreTerrain", config: { appKey: "geoportal", show: "while3dLayersActive" }, }, + { + kind: "shadowSimulation", + config: { initialMinutes: 15 * 60 }, + }, // dev harness for highlightByIds; this route is localDev/dev/pr only { kind: "vectorHighlightDebug", diff --git a/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx new file mode 100644 index 0000000000..2a7141d2e7 --- /dev/null +++ b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.spec.tsx @@ -0,0 +1,157 @@ +import type { PropsWithChildren } from "react"; + +import { act, renderHook, waitFor } from "@testing-library/react"; +import { configureStore } from "@reduxjs/toolkit"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const addonStateMock = vi.hoisted(() => ({ + routeAddons: [] as unknown[], + overrides: undefined as + | { suspended: string[]; enabled: string[] } + | undefined, + setShadowState: vi.fn(), + shadowState: undefined as + | { enabled: boolean; selection: Record } + | undefined, +})); + +vi.mock("@carma-mapping/addons", () => ({ + applyAddonOverrides: ( + entries: Array<{ kind: string }>, + overrides?: { suspended: string[] } + ) => entries.filter((entry) => !overrides?.suspended.includes(entry.kind)), + resolveAddonEntries: (entries?: unknown[]) => entries ?? [], + useAddonState: (key: string) => + key === "shadowSimulation" + ? [addonStateMock.shadowState, addonStateMock.setShadowState] + : [undefined, vi.fn()], + usePersistedAddonOverrides: () => [addonStateMock.overrides, vi.fn()], + useRouteAddons: () => addonStateMock.routeAddons, +})); + +import mappingReducer from "../store/slices/mapping"; +import uiReducer from "../store/slices/ui"; +import { + SHADOW_SIMULATION_LAYER_ID, + useShadowSimulationLayerButton, +} from "./useShadowSimulationLayerButton"; + +const createTestStore = () => + configureStore({ + reducer: { + mapping: mappingReducer, + ui: uiReducer, + }, + }); + +type TestStore = ReturnType; + +const createWrapper = + (store: TestStore) => + ({ children }: PropsWithChildren) => + {children}; + +const findShadowLayer = (store: TestStore) => + store + .getState() + .mapping.layers.find((layer) => layer.id === SHADOW_SIMULATION_LAYER_ID); + +describe("useShadowSimulationLayerButton", () => { + beforeEach(() => { + addonStateMock.overrides = undefined; + addonStateMock.setShadowState.mockReset(); + addonStateMock.routeAddons = [ + { kind: "shadowSimulation", config: { initialMinutes: 900 } }, + ]; + addonStateMock.shadowState = { + enabled: false, + selection: { year: 2026, dayOfYear: 172, minutes: 900 }, + }; + }); + + it("adds the top-level layer and opens its options when enabled", async () => { + const store = createTestStore(); + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + expect(findShadowLayer(store)).toBeUndefined(); + + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + rerender(); + + await waitFor(() => { + expect(findShadowLayer(store)).toEqual( + expect.objectContaining({ + id: SHADOW_SIMULATION_LAYER_ID, + pinned: "last", + visible: true, + tools: [ + expect.objectContaining({ + kind: "shadowSimulation", + config: { initialMinutes: 900 }, + }), + ], + }) + ); + expect(store.getState().mapping.selectedLayerIndex).toBe(0); + expect(store.getState().ui.showInfo).toBe(true); + expect(store.getState().ui.showInfoText).toBe(false); + }); + }); + + it("keeps the layer entry but hides it when the simulation is disabled", async () => { + const store = createTestStore(); + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => expect(findShadowLayer(store)).toBeDefined()); + + act(() => { + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: false, + }; + rerender(); + }); + + await waitFor(() => { + expect(findShadowLayer(store)?.visible).toBe(false); + }); + }); + + it("removes and disables the layer when the addon manager suspends it", async () => { + const store = createTestStore(); + addonStateMock.shadowState = { + ...addonStateMock.shadowState!, + enabled: true, + }; + const { rerender } = renderHook(() => useShadowSimulationLayerButton(), { + wrapper: createWrapper(store), + }); + + await waitFor(() => expect(findShadowLayer(store)).toBeDefined()); + + addonStateMock.overrides = { + suspended: ["shadowSimulation"], + enabled: [], + }; + rerender(); + + await waitFor(() => { + expect(findShadowLayer(store)).toBeUndefined(); + expect(addonStateMock.setShadowState).toHaveBeenCalledWith( + expect.objectContaining({ enabled: false }) + ); + }); + }); +}); diff --git a/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx new file mode 100644 index 0000000000..576985b08f --- /dev/null +++ b/apps/geoportal/src/app/hooks/useShadowSimulationLayerButton.tsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useRef } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { + applyAddonOverrides, + resolveAddonEntries, + useAddonState, + usePersistedAddonOverrides, + useRouteAddons, +} from "@carma-mapping/addons"; +import type { Layer } from "@carma-mapping/layers"; + +import { + appendLayer, + getLayerStack, + removeLayer, + setSelectedLayerIndex, + updateLayer, +} from "../store/slices/mapping"; +import { setUIShowInfo, setUIShowInfoText } from "../store/slices/ui"; + +export const SHADOW_SIMULATION_LAYER_ID = "__shadow_simulation__"; + +export const useShadowSimulationLayerButton = () => { + const dispatch = useDispatch(); + const layerStack = useSelector(getLayerStack); + const routeAddons = useRouteAddons(); + const [addonOverrides] = usePersistedAddonOverrides(); + const [shadowState, setShadowState] = useAddonState("shadowSimulation"); + const wasEnabled = useRef(false); + + const shadowAddon = useMemo( + () => + applyAddonOverrides( + resolveAddonEntries(routeAddons), + addonOverrides + ).find((entry) => entry.kind === "shadowSimulation"), + [addonOverrides, routeAddons] + ); + const shadowLayer = useMemo( + () => + shadowAddon + ? { + id: SHADOW_SIMULATION_LAYER_ID, + title: "Schattensimulation", + description: + "Sonnenstand und Schattenwurf in der gemeinsamen Three.js-Szene.", + type: "object", + icon: "shadow-simulation", + iconColor: "#d97706", + visible: shadowState?.enabled ?? false, + pinned: "last", + tools: [shadowAddon], + } + : null, + [shadowAddon, shadowState?.enabled] + ); + + useEffect(() => { + const layerIndex = layerStack.findIndex( + (entry) => entry.id === SHADOW_SIMULATION_LAYER_ID + ); + const currentLayer = layerIndex >= 0 ? layerStack[layerIndex] : undefined; + const enabled = shadowState?.enabled ?? false; + const justEnabled = enabled && !wasEnabled.current; + wasEnabled.current = enabled; + + if (!shadowAddon || !shadowLayer) { + if (enabled && shadowState) { + setShadowState({ ...shadowState, enabled: false }); + } + if (currentLayer) { + dispatch(removeLayer(SHADOW_SIMULATION_LAYER_ID)); + } + return; + } + + if (enabled && !currentLayer) { + dispatch(appendLayer(shadowLayer)); + dispatch(setSelectedLayerIndex(layerStack.length)); + dispatch(setUIShowInfo(true)); + dispatch(setUIShowInfoText(false)); + return; + } + + if (!currentLayer || currentLayer.type === "group") { + return; + } + + if (currentLayer.visible !== enabled) { + dispatch(updateLayer({ ...currentLayer, visible: enabled })); + } + + if (justEnabled) { + dispatch(setSelectedLayerIndex(layerIndex)); + dispatch(setUIShowInfo(true)); + dispatch(setUIShowInfoText(false)); + } + }, [ + dispatch, + layerStack, + setShadowState, + shadowAddon, + shadowLayer, + shadowState, + ]); +}; diff --git a/libraries/mapping/addons/README.md b/libraries/mapping/addons/README.md index 1b8468269f..776cfa5018 100644 --- a/libraries/mapping/addons/README.md +++ b/libraries/mapping/addons/README.md @@ -47,7 +47,8 @@ so the second folder is the list of what actually exists: | `addons/OriginSearch/` | the "von wo?" search: where the user starts from (see below) | | `addons/VectorHighlight.tsx` | highlight/dim mode for the maplibre map | | `addons/LayerVisibility.tsx` | per-member visibility toggles for a group | -| `addons/LibreTerrain.tsx` | terrain toggle button for the maplibre map | +| `addons/LibreTerrain.tsx` | terrain toggle button for the maplibre map | +| `addons/ShadowSimulation/` | daylight-clamped sun control for MapLibre and Three.js content | An addon that needs more than one file gets its own folder there (`addons/CameraTour/index.tsx` plus its parts). diff --git a/libraries/mapping/addons/project.json b/libraries/mapping/addons/project.json index f2543d6afe..8e1f3567a0 100644 --- a/libraries/mapping/addons/project.json +++ b/libraries/mapping/addons/project.json @@ -5,6 +5,13 @@ "projectType": "library", "tags": [], "targets": { + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../coverage/libraries/mapping/addons" + } + }, "lint": { "executor": "@nx/eslint:lint" } diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/SolarDayTimeControl.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/SolarDayTimeControl.tsx new file mode 100644 index 0000000000..0d6c53bd90 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/SolarDayTimeControl.tsx @@ -0,0 +1,321 @@ +import { useId, useMemo, useRef } from "react"; +import type { KeyboardEvent, PointerEvent } from "react"; + +import { + clampSelectionToDaylight, + getDaylightWindow, + getDaysInYear, + type SolarLocation, + type SolarPosition, + type SolarSelection, +} from "./solar-position"; + +const SVG_WIDTH = 336; +const SVG_HEIGHT = 112; +const PLOT_LEFT = 36; +const PLOT_RIGHT = 10; +const PLOT_TOP = 4; +const PLOT_BOTTOM = 18; +const PLOT_WIDTH = SVG_WIDTH - PLOT_LEFT - PLOT_RIGHT; +const PLOT_HEIGHT = SVG_HEIGHT - PLOT_TOP - PLOT_BOTTOM; +const MINUTES_PER_DAY = 24 * 60; + +export type SolarDayTimeControlProps = { + expanded?: boolean; + location: SolarLocation; + selection: SolarSelection; + position: SolarPosition; + onChange: (selection: SolarSelection) => void; +}; + +const pad2 = (value: number) => String(value).padStart(2, "0"); + +const formatMinutes = (minutes: number) => { + const rounded = Math.round(minutes); + return `${pad2(Math.floor(rounded / 60))}:${pad2(rounded % 60)}`; +}; + +const formatDay = (year: number, dayOfYear: number) => + new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "short", + timeZone: "UTC", + }).format(new Date(Date.UTC(year, 0, dayOfYear))); + +const getMonthTicks = (year: number) => + Array.from({ length: 12 }, (_, month) => { + const date = new Date(Date.UTC(year, month, 1)); + const dayOfYear = + Math.floor((date.getTime() - Date.UTC(year, 0, 1)) / 86_400_000) + 1; + return { + dayOfYear, + label: new Intl.DateTimeFormat("de-DE", { + month: "short", + timeZone: "UTC", + }).format(date), + }; + }); + +export const SolarDayTimeControl = ({ + expanded = false, + location, + selection, + position, + onChange, +}: SolarDayTimeControlProps) => { + const dragging = useRef(false); + const clipId = useId().replaceAll(":", ""); + const dayCount = getDaysInYear(selection.year); + const monthTicks = useMemo( + () => getMonthTicks(selection.year), + [selection.year] + ); + const daylight = useMemo( + () => + Array.from({ length: dayCount }, (_, index) => + getDaylightWindow(selection.year, index + 1, location) + ), + [dayCount, location, selection.year] + ); + + const toX = (dayOfYear: number) => + PLOT_LEFT + ((dayOfYear - 1) / Math.max(1, dayCount - 1)) * PLOT_WIDTH; + const toY = (minutes: number) => + PLOT_TOP + (1 - minutes / MINUTES_PER_DAY) * PLOT_HEIGHT; + + const sunrisePath = daylight + .map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunriseMinutes + ).toFixed(2)}` + ) + .join(" "); + const sunsetPath = daylight + .map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunsetMinutes + ).toFixed(2)}` + ) + .join(" "); + const daylightAreaPath = [ + ...daylight.map( + (window, index) => + `${index === 0 ? "M" : "L"}${toX(index + 1).toFixed(2)},${toY( + window.sunriseMinutes + ).toFixed(2)}` + ), + ...daylight + .map((window, index) => ({ window, day: index + 1 })) + .reverse() + .map( + ({ window, day }) => + `L${toX(day).toFixed(2)},${toY(window.sunsetMinutes).toFixed(2)}` + ), + "Z", + ].join(" "); + + const publishCandidate = (dayOfYear: number, minutes: number) => { + const next = clampSelectionToDaylight( + { year: selection.year, dayOfYear, minutes }, + location + ); + if (next) onChange(next); + }; + + const updateFromPointer = (event: PointerEvent) => { + const bounds = event.currentTarget.getBoundingClientRect(); + const scaleX = SVG_WIDTH / bounds.width; + const scaleY = SVG_HEIGHT / bounds.height; + const x = (event.clientX - bounds.left) * scaleX; + const y = (event.clientY - bounds.top) * scaleY; + const dayOfYear = Math.round( + 1 + ((x - PLOT_LEFT) / PLOT_WIDTH) * (dayCount - 1) + ); + const minutes = (1 - (y - PLOT_TOP) / PLOT_HEIGHT) * MINUTES_PER_DAY; + publishCandidate(dayOfYear, minutes); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + const dayStep = event.shiftKey ? 7 : 1; + const minuteStep = event.shiftKey ? 60 : 10; + let nextDay = selection.dayOfYear; + let nextMinutes = selection.minutes; + switch (event.key) { + case "ArrowLeft": + nextDay -= dayStep; + break; + case "ArrowRight": + nextDay += dayStep; + break; + case "ArrowDown": + nextMinutes -= minuteStep; + break; + case "ArrowUp": + nextMinutes += minuteStep; + break; + case "Home": + nextDay = 1; + break; + case "End": + nextDay = dayCount; + break; + default: + return; + } + event.preventDefault(); + publishCandidate(nextDay, nextMinutes); + }; + + const activeX = toX(selection.dayOfYear); + const activeY = toY(selection.minutes); + const activeDaylight = daylight[selection.dayOfYear - 1]; + + return ( +
+
+
+ Sonne {position.azimuthDegrees.toFixed(0)}° /{" "} + {position.elevationDegrees.toFixed(1)}° +
+ + { + event.stopPropagation(); + dragging.current = true; + event.currentTarget.setPointerCapture(event.pointerId); + updateFromPointer(event); + }} + onPointerMove={(event) => { + if (!dragging.current) return; + event.stopPropagation(); + updateFromPointer(event); + }} + onPointerUp={(event) => { + event.stopPropagation(); + dragging.current = false; + event.currentTarget.releasePointerCapture(event.pointerId); + }} + onPointerCancel={() => { + dragging.current = false; + }} + > + + + + + + + + + {[0, 6, 12, 18, 24].map((hour) => { + const y = toY(hour * 60); + return ( + + + + {pad2(hour)} + + + ); + })} + + {monthTicks.map(({ dayOfYear, label }) => { + const x = toX(dayOfYear); + return ( + + + + {label} + + + ); + })} + + + + + + + + + +
+
+ ); +}; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx new file mode 100644 index 0000000000..7d8cc5bfb0 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -0,0 +1,229 @@ +import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; + +import { faSun } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tooltip } from "antd"; + +import { + getShadowSimulationContentStatus, + subscribeShadowSimulationContentStatus, +} from "@carma-mapping/engines/maplibre"; +import { + Control, + ControlButtonStyler, + type Positions, +} from "@carma-mapping/map-controls-layout"; + +import { useAddonState } from "../../lib/AddonStateContext"; +import type { AddonComponentProps } from "../../lib/registry"; +import { SolarDayTimeControl } from "./SolarDayTimeControl"; +import { buildShadowSimulationScene } from "./shadow-scene"; +import type { ShadowSimulationScene } from "./shadow-scene"; +import { + clampSelectionToDaylight, + getSolarPosition, + getSolarSelectionForInstant, + type SolarLocation, + type SolarSelection, +} from "./solar-position"; + +const DEFAULT_LOCATION: SolarLocation = { + latitude: 51.256, + longitude: 7.15, + timeZone: "Europe/Berlin", +}; +const ACTIVE_CONTROL_COLOR = "#1677ff"; + +export type ShadowSimulationConfig = { + year?: number; + initialDayOfYear?: number; + initialMinutes?: number; + latitude?: number; + longitude?: number; + timeZone?: string; + shadowAreaMeters?: number; + controlPosition?: Positions; + controlOrder?: number; +}; + +export type ShadowSimulationState = { + enabled: boolean; + selection: SolarSelection; +}; + +const ShadowSimulationSettings = ({ + location, + state, + setState, +}: { + location: SolarLocation; + state: ShadowSimulationState; + setState: (state: ShadowSimulationState) => void; +}) => { + const solarPosition = useMemo( + () => getSolarPosition(state.selection, location), + [location, state.selection] + ); + + return ( +
+ setState({ ...state, selection })} + /> +
+ ); +}; + +const ShadowSimulationRuntime = ({ + libreMap, + shadowAreaMeters, + location, + state, + available, +}: { + libreMap: AddonComponentProps<"shadowSimulation">["libreMap"]; + shadowAreaMeters?: number; + location: SolarLocation; + state: ShadowSimulationState; + available: boolean; +}) => { + const shadowScene = useRef(null); + const solarPosition = useMemo( + () => getSolarPosition(state.selection, location), + [location, state.selection] + ); + + useEffect(() => { + if (!libreMap || !state.enabled || !available) return; + const scene = buildShadowSimulationScene(libreMap, { shadowAreaMeters }); + shadowScene.current = scene; + return () => { + shadowScene.current = null; + scene.dispose(); + }; + }, [available, libreMap, shadowAreaMeters, state.enabled]); + + useEffect(() => { + shadowScene.current?.updateSolarPosition(solarPosition); + }, [solarPosition]); + + return null; +}; + +export const ShadowSimulation = ({ + config, + libreMap, + target, +}: AddonComponentProps<"shadowSimulation">) => { + const { + year, + initialDayOfYear, + initialMinutes, + latitude = DEFAULT_LOCATION.latitude, + longitude = DEFAULT_LOCATION.longitude, + timeZone = DEFAULT_LOCATION.timeZone, + shadowAreaMeters, + controlPosition = "topleft", + controlOrder = 70, + } = config ?? {}; + const location = useMemo( + () => ({ latitude, longitude, timeZone }), + [latitude, longitude, timeZone] + ); + const initialState = useMemo(() => { + const now = getSolarSelectionForInstant(new Date(), timeZone); + const candidate = { + year: year ?? now.year, + dayOfYear: initialDayOfYear ?? now.dayOfYear, + minutes: initialMinutes ?? now.minutes, + }; + return { + enabled: false, + selection: clampSelectionToDaylight(candidate, location) ?? { + ...candidate, + minutes: 12 * 60, + }, + }; + }, [initialDayOfYear, initialMinutes, location, timeZone, year]); + const [sharedState, setSharedState] = useAddonState("shadowSimulation"); + const state = sharedState ?? initialState; + const shadowAvailable = useSyncExternalStore( + (listener) => + libreMap + ? subscribeShadowSimulationContentStatus(libreMap, listener) + : () => undefined, + () => { + if (!libreMap) return false; + return getShadowSimulationContentStatus(libreMap).available; + }, + () => false + ); + + useEffect(() => { + if (!sharedState) setSharedState(initialState); + }, [initialState, setSharedState, sharedState]); + + if (target) { + return ( + + ); + } + + return ( + <> + {libreMap && ( + + + + setSharedState({ ...state, enabled: !state.enabled }) + } + dataTestId="shadow-simulation-control-button" + disabled={!shadowAvailable} + useDisabledStyle + aria-label={ + state.enabled + ? "Schattensimulation ausschalten" + : "Schattensimulation einschalten" + } + aria-pressed={state.enabled && shadowAvailable} + > + + + + + )} + + + ); +}; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts new file mode 100644 index 0000000000..8b006bf215 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -0,0 +1,138 @@ +// @vitest-environment node + +import * as THREE from "three"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@carma-mapping/engines/maplibre", () => ({ + acquireSharedThreeScene: vi.fn(), + getGenericThreeLayers: vi.fn(() => []), + subscribeGenericThreeLayers: vi.fn(() => vi.fn()), +})); + +import { + acquireSharedThreeScene, + getGenericThreeLayers, + subscribeGenericThreeLayers, +} from "@carma-mapping/engines/maplibre"; + +import { + buildShadowSimulationScene, + solarPositionToSceneDirection, +} from "./shadow-scene"; + +describe("shadow scene sun direction", () => { + const position = (azimuthDegrees: number, elevationDegrees: number) => ({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees, + elevationDegrees, + }); + + it("maps north to the shared scene's negative Z axis", () => { + const direction = solarPositionToSceneDirection(position(0, 0)); + + expect(direction.x).toBeCloseTo(0); + expect(direction.y).toBeCloseTo(0); + expect(direction.z).toBeCloseTo(-1); + }); + + it("maps east and zenith into the shared local frame", () => { + expect(solarPositionToSceneDirection(position(90, 0)).x).toBeCloseTo(1); + expect(solarPositionToSceneDirection(position(180, 90)).y).toBeCloseTo(1); + }); +}); + +describe("shadow scene lighting integration", () => { + const releaseScene = vi.fn(); + let scene: THREE.Scene; + + beforeEach(() => { + vi.clearAllMocks(); + scene = new THREE.Scene(); + vi.mocked(getGenericThreeLayers).mockReturnValue([]); + vi.mocked(subscribeGenericThreeLayers).mockReturnValue(vi.fn()); + vi.mocked(acquireSharedThreeScene).mockReturnValue({ + layer: { getScene: () => scene } as never, + release: releaseScene, + }); + }); + + it("drives MapLibre and the Three.js sun from the same solar position", () => { + const setLight = vi.fn(); + const map = { + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight, + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + const controller = buildShadowSimulationScene(map as never); + const solarPosition = { + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }; + + controller.updateSolarPosition(solarPosition); + + expect(acquireSharedThreeScene).toHaveBeenCalledWith(map); + expect(setLight).toHaveBeenLastCalledWith( + expect.objectContaining({ + anchor: "map", + position: [1.5, 135, 45], + }) + ); + const sun = scene.getObjectByName( + "shadow-simulation-sun" + ) as THREE.DirectionalLight; + expect(sun.castShadow).toBe(true); + expect(sun.position.clone().normalize().x).toBeCloseTo(0.5); + expect(sun.position.clone().normalize().y).toBeCloseTo(Math.SQRT1_2); + expect(sun.position.clone().normalize().z).toBeCloseTo(0.5); + + controller.dispose(); + expect(scene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); + expect(releaseScene).toHaveBeenCalledOnce(); + }); + + it("enables and restores shadows for registered ALKIS Three.js layers", () => { + const alkisScene = new THREE.Scene(); + const building = new THREE.Mesh( + new THREE.BoxGeometry(10, 20, 10), + new THREE.MeshLambertMaterial() + ); + alkisScene.add(building); + const renderer = { + shadowMap: { enabled: false, type: THREE.BasicShadowMap }, + }; + vi.mocked(getGenericThreeLayers).mockReturnValue([ + { scene: alkisScene, renderer } as never, + ]); + const map = { + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + + expect(renderer.shadowMap.enabled).toBe(true); + expect(renderer.shadowMap.type).toBe(THREE.PCFSoftShadowMap); + expect(building.castShadow).toBe(true); + expect(building.receiveShadow).toBe(true); + expect(alkisScene.getObjectByName("shadow-simulation-sun")).toBeDefined(); + + controller.dispose(); + expect(renderer.shadowMap.enabled).toBe(false); + expect(renderer.shadowMap.type).toBe(THREE.BasicShadowMap); + expect(alkisScene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); + }); +}); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts new file mode 100644 index 0000000000..ed6dfbb712 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -0,0 +1,252 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; +import * as THREE from "three"; + +import { + acquireSharedThreeScene, + getGenericThreeLayers, + subscribeGenericThreeLayers, +} from "@carma-mapping/engines/maplibre"; + +import type { SolarPosition } from "./solar-position"; + +const DEFAULT_SHADOW_AREA_METERS = 900; +const DEFAULT_LIGHT_DISTANCE_METERS = 2_500; +const SHADOW_SIMULATION_SUN_NAME = "shadow-simulation-sun"; + +type GenericThreeLayer = ReturnType[number]; + +type ShadowLightBinding = { + scene: THREE.Scene; + sunLight: THREE.DirectionalLight; + lightTarget: THREE.Object3D; + center: THREE.Vector3; + renderer?: THREE.WebGLRenderer; + previousShadowMapEnabled?: boolean; + previousShadowMapType?: THREE.ShadowMapType; +}; + +export type ShadowSceneOptions = { + shadowAreaMeters?: number; +}; + +export type ShadowSimulationScene = { + updateSolarPosition: (position: SolarPosition) => void; + dispose: () => void; +}; + +export const solarPositionToSceneDirection = ({ + azimuthDegrees, + elevationDegrees, +}: SolarPosition): THREE.Vector3 => { + const azimuth = THREE.MathUtils.degToRad(azimuthDegrees); + const elevation = THREE.MathUtils.degToRad(elevationDegrees); + const horizontal = Math.cos(elevation); + // Shared scene axes: +X east, +Y up, -Z north. + return new THREE.Vector3( + Math.sin(azimuth) * horizontal, + Math.sin(elevation), + -Math.cos(azimuth) * horizontal + ).normalize(); +}; + +const configureShadowCamera = ( + light: THREE.DirectionalLight, + shadowAreaMeters: number +) => { + light.castShadow = true; + light.shadow.mapSize.set(2_048, 2_048); + light.shadow.bias = -0.00008; + light.shadow.normalBias = 0.45; + light.shadow.radius = 2; + const halfShadowArea = shadowAreaMeters / 2; + const shadowCamera = light.shadow.camera; + shadowCamera.left = -halfShadowArea; + shadowCamera.right = halfShadowArea; + shadowCamera.top = halfShadowArea; + shadowCamera.bottom = -halfShadowArea; + shadowCamera.near = 10; + shadowCamera.far = DEFAULT_LIGHT_DISTANCE_METERS * 2; + shadowCamera.updateProjectionMatrix(); +}; + +const makeSceneMeshesShadeable = (scene: THREE.Scene) => { + scene.traverseVisible((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; + mesh.castShadow = true; + mesh.receiveShadow = true; + }); +}; + +const updateBindingCenter = (binding: ShadowLightBinding) => { + const bounds = new THREE.Box3().setFromObject(binding.scene); + if (bounds.isEmpty()) binding.center.set(0, 0, 0); + else bounds.getCenter(binding.center); +}; + +const buildShadowLightBinding = ( + scene: THREE.Scene, + shadowAreaMeters: number, + renderer?: THREE.WebGLRenderer +): ShadowLightBinding => { + const lightTarget = new THREE.Object3D(); + const sunLight = new THREE.DirectionalLight(0xfff2d8, 2.5); + sunLight.name = SHADOW_SIMULATION_SUN_NAME; + sunLight.target = lightTarget; + configureShadowCamera(sunLight, shadowAreaMeters); + const binding: ShadowLightBinding = { + scene, + sunLight, + lightTarget, + center: new THREE.Vector3(), + renderer, + previousShadowMapEnabled: renderer?.shadowMap.enabled, + previousShadowMapType: renderer?.shadowMap.type, + }; + if (renderer) { + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + } + scene.add(lightTarget, sunLight); + makeSceneMeshesShadeable(scene); + updateBindingCenter(binding); + return binding; +}; + +const refreshShadowLightBinding = (binding: ShadowLightBinding) => { + makeSceneMeshesShadeable(binding.scene); + updateBindingCenter(binding); +}; + +const applySolarPositionToBinding = ( + binding: ShadowLightBinding, + direction: THREE.Vector3 +) => { + binding.lightTarget.position.copy(binding.center); + binding.sunLight.position + .copy(direction) + .multiplyScalar(DEFAULT_LIGHT_DISTANCE_METERS) + .add(binding.center); + const daylightStrength = THREE.MathUtils.clamp(direction.y, 0, 1); + binding.sunLight.intensity = 1.2 + Math.sqrt(daylightStrength) * 2.2; + binding.sunLight.shadow.needsUpdate = true; +}; + +const disposeShadowLightBinding = (binding: ShadowLightBinding) => { + binding.scene.remove(binding.sunLight, binding.lightTarget); + if (binding.renderer) { + binding.renderer.shadowMap.enabled = + binding.previousShadowMapEnabled ?? false; + if (binding.previousShadowMapType != null) { + binding.renderer.shadowMap.type = binding.previousShadowMapType; + } + } +}; + +export const buildShadowSimulationScene = ( + map: MaplibreMap, + options: ShadowSceneOptions = {} +): ShadowSimulationScene => { + const { shadowAreaMeters = DEFAULT_SHADOW_AREA_METERS } = options; + const previousLight = map.getLight(); + const sceneLease = acquireSharedThreeScene(map); + const sharedBinding = buildShadowLightBinding( + sceneLease.layer.getScene(), + shadowAreaMeters + ); + const genericBindings = new Map(); + + let latestSolarPosition: SolarPosition | null = null; + let disposed = false; + + const syncGenericBindings = () => { + if (disposed) return; + const currentLayers = new Set(getGenericThreeLayers(map)); + for (const [layer, binding] of genericBindings) { + if (currentLayers.has(layer)) continue; + disposeShadowLightBinding(binding); + genericBindings.delete(layer); + } + for (const layer of currentLayers) { + let binding = genericBindings.get(layer); + if (!binding) { + if (!layer.scene || !layer.renderer) continue; + binding = buildShadowLightBinding( + layer.scene, + shadowAreaMeters, + layer.renderer + ); + genericBindings.set(layer, binding); + } else { + refreshShadowLightBinding(binding); + } + if (latestSolarPosition) { + applySolarPositionToBinding( + binding, + solarPositionToSceneDirection(latestSolarPosition) + ); + } + } + map.triggerRepaint(); + }; + + const unsubscribeGenericLayers = subscribeGenericThreeLayers( + map, + syncGenericBindings + ); + syncGenericBindings(); + + const applyMapLibreLight = (position: SolarPosition) => { + if (!map.isStyleLoaded()) return; + const daylightStrength = THREE.MathUtils.clamp( + Math.sin(THREE.MathUtils.degToRad(position.elevationDegrees)), + 0, + 1 + ); + map.setLight({ + anchor: "map", + position: [1.5, position.azimuthDegrees, 90 - position.elevationDegrees], + color: "#fff3df", + intensity: 0.35 + daylightStrength * 0.55, + }); + }; + + const updateSolarPosition = (position: SolarPosition) => { + latestSolarPosition = position; + const direction = solarPositionToSceneDirection(position); + applySolarPositionToBinding(sharedBinding, direction); + for (const binding of genericBindings.values()) { + applySolarPositionToBinding(binding, direction); + } + applyMapLibreLight(position); + map.triggerRepaint(); + }; + + const restoreLighting = () => { + if (disposed) return; + if (latestSolarPosition) applyMapLibreLight(latestSolarPosition); + }; + + map.on("styledata", restoreLighting); + + return { + updateSolarPosition, + dispose() { + if (disposed) return; + disposed = true; + map.off("styledata", restoreLighting); + unsubscribeGenericLayers(); + for (const binding of genericBindings.values()) { + disposeShadowLightBinding(binding); + } + genericBindings.clear(); + disposeShadowLightBinding(sharedBinding); + sceneLease.release(); + try { + if (map.isStyleLoaded()) map.setLight(previousLight); + } catch { + // Nothing remains to restore after map teardown. + } + }, + }; +}; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.spec.ts new file mode 100644 index 0000000000..933c8767b5 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.spec.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + clampSelectionToDaylight, + getDaylightWindow, + getSolarPosition, + solarSelectionToInstant, + type SolarLocation, +} from "./solar-position"; + +const WUPPERTAL: SolarLocation = { + latitude: 51.256, + longitude: 7.15, + timeZone: "Europe/Berlin", +}; + +describe("solar position", () => { + it("models the long Wuppertal summer day in local civil time", () => { + const daylight = getDaylightWindow(2026, 172, WUPPERTAL); + + expect(daylight.sunriseMinutes).toBeGreaterThan(300); + expect(daylight.sunriseMinutes).toBeLessThan(340); + expect(daylight.sunsetMinutes).toBeGreaterThan(1_290); + expect(daylight.sunsetMinutes).toBeLessThan(1_330); + }); + + it("clamps night input to the daylight curve", () => { + const selection = clampSelectionToDaylight( + { year: 2026, dayOfYear: 172, minutes: 120 }, + WUPPERTAL + ); + const daylight = getDaylightWindow(2026, 172, WUPPERTAL); + + expect(selection).not.toBeNull(); + expect(selection?.minutes).toBeGreaterThan(daylight.sunriseMinutes); + expect(selection?.minutes).toBeLessThan(daylight.sunsetMinutes); + }); + + it("keeps local wall-clock time stable across the named time zone", () => { + expect( + solarSelectionToInstant( + { year: 2026, dayOfYear: 172, minutes: 12 * 60 }, + WUPPERTAL.timeZone + ).toISOString() + ).toBe("2026-06-21T10:00:00.000Z"); + }); + + it("places the summer-noon sun high in the southern sky", () => { + const daylight = getDaylightWindow(2026, 172, WUPPERTAL); + const position = getSolarPosition( + { + year: 2026, + dayOfYear: 172, + minutes: daylight.solarNoonMinutes, + }, + WUPPERTAL + ); + + expect(position.azimuthDegrees).toBeGreaterThan(175); + expect(position.azimuthDegrees).toBeLessThan(185); + expect(position.elevationDegrees).toBeGreaterThan(60); + expect(position.elevationDegrees).toBeLessThan(64); + }); +}); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts new file mode 100644 index 0000000000..e840766d01 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts @@ -0,0 +1,304 @@ +const MINUTES_PER_DAY = 24 * 60; +const DEGREES_TO_RADIANS = Math.PI / 180; +const RADIANS_TO_DEGREES = 180 / Math.PI; + +export type SolarLocation = { + latitude: number; + longitude: number; + timeZone: string; +}; + +export type SolarSelection = { + year: number; + dayOfYear: number; + minutes: number; +}; + +export type DaylightWindow = { + sunriseMinutes: number; + solarNoonMinutes: number; + sunsetMinutes: number; + polarDay: boolean; + polarNight: boolean; +}; + +export type SolarPosition = { + instant: Date; + azimuthDegrees: number; + elevationDegrees: number; +}; + +const clamp = (value: number, minimum: number, maximum: number) => + Math.min(maximum, Math.max(minimum, value)); + +const normalizeMinutes = (minutes: number) => + ((minutes % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; + +export const getDaysInYear = (year: number): number => + new Date(Date.UTC(year, 1, 29)).getUTCMonth() === 1 ? 366 : 365; + +const getDatePartsForDayOfYear = (year: number, dayOfYear: number) => { + const date = new Date( + Date.UTC(year, 0, clamp(Math.round(dayOfYear), 1, getDaysInYear(year))) + ); + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + }; +}; + +const formatterCache = new Map(); + +const getTimeZoneFormatter = (timeZone: string) => { + let formatter = formatterCache.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat("en-CA", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + formatterCache.set(timeZone, formatter); + } + return formatter; +}; + +const getZonedParts = (instant: Date, timeZone: string) => { + const values = Object.fromEntries( + getTimeZoneFormatter(timeZone) + .formatToParts(instant) + .filter(({ type }) => type !== "literal") + .map(({ type, value }) => [type, Number(value)]) + ); + return { + year: values.year, + month: values.month, + day: values.day, + hour: values.hour, + minute: values.minute, + second: values.second, + }; +}; + +const getTimeZoneOffsetMinutes = (instant: Date, timeZone: string): number => { + const parts = getZonedParts(instant, timeZone); + const representedAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second + ); + return Math.round((representedAsUtc - instant.getTime()) / 60_000); +}; + +export const solarSelectionToInstant = ( + selection: SolarSelection, + timeZone: string +): Date => { + const date = getDatePartsForDayOfYear(selection.year, selection.dayOfYear); + const safeMinutes = clamp( + Math.round(selection.minutes), + 0, + MINUTES_PER_DAY - 1 + ); + const hour = Math.floor(safeMinutes / 60); + const minute = safeMinutes % 60; + const wallClockUtc = Date.UTC( + date.year, + date.month - 1, + date.day, + hour, + minute + ); + let instantMs = wallClockUtc; + + // Resolve the local wall-clock time against the named IANA zone. Iterating + // handles the offset changing between the initial UTC guess and local noon. + for (let iteration = 0; iteration < 3; iteration += 1) { + const offsetMinutes = getTimeZoneOffsetMinutes( + new Date(instantMs), + timeZone + ); + const nextInstantMs = wallClockUtc - offsetMinutes * 60_000; + if (nextInstantMs === instantMs) break; + instantMs = nextInstantMs; + } + return new Date(instantMs); +}; + +export const getSolarSelectionForInstant = ( + instant: Date, + timeZone: string +): SolarSelection => { + const parts = getZonedParts(instant, timeZone); + const dayOfYear = + Math.floor( + (Date.UTC(parts.year, parts.month - 1, parts.day) - + Date.UTC(parts.year, 0, 1)) / + 86_400_000 + ) + 1; + return { + year: parts.year, + dayOfYear, + minutes: parts.hour * 60 + parts.minute, + }; +}; + +const getSolarTerms = (year: number, dayOfYear: number, minutes: number) => { + const fractionalYear = + ((2 * Math.PI) / getDaysInYear(year)) * + (dayOfYear - 1 + (minutes - 720) / MINUTES_PER_DAY); + const equationOfTimeMinutes = + 229.18 * + (0.000075 + + 0.001868 * Math.cos(fractionalYear) - + 0.032077 * Math.sin(fractionalYear) - + 0.014615 * Math.cos(2 * fractionalYear) - + 0.040849 * Math.sin(2 * fractionalYear)); + const declinationRadians = + 0.006918 - + 0.399912 * Math.cos(fractionalYear) + + 0.070257 * Math.sin(fractionalYear) - + 0.006758 * Math.cos(2 * fractionalYear) + + 0.000907 * Math.sin(2 * fractionalYear) - + 0.002697 * Math.cos(3 * fractionalYear) + + 0.00148 * Math.sin(3 * fractionalYear); + return { equationOfTimeMinutes, declinationRadians }; +}; + +export const getDaylightWindow = ( + year: number, + dayOfYear: number, + location: SolarLocation +): DaylightWindow => { + const safeDay = clamp(Math.round(dayOfYear), 1, getDaysInYear(year)); + const { equationOfTimeMinutes, declinationRadians } = getSolarTerms( + year, + safeDay, + 720 + ); + const noonInstant = solarSelectionToInstant( + { year, dayOfYear: safeDay, minutes: 720 }, + location.timeZone + ); + const timeZoneOffsetHours = + getTimeZoneOffsetMinutes(noonInstant, location.timeZone) / 60; + const latitudeRadians = location.latitude * DEGREES_TO_RADIANS; + const hourAngleCosine = + -Math.tan(latitudeRadians) * Math.tan(declinationRadians); + const solarNoonMinutes = + 720 - + 4 * location.longitude - + equationOfTimeMinutes + + 60 * timeZoneOffsetHours; + + if (hourAngleCosine >= 1) { + return { + sunriseMinutes: solarNoonMinutes, + solarNoonMinutes, + sunsetMinutes: solarNoonMinutes, + polarDay: false, + polarNight: true, + }; + } + if (hourAngleCosine <= -1) { + return { + sunriseMinutes: 0, + solarNoonMinutes, + sunsetMinutes: MINUTES_PER_DAY, + polarDay: true, + polarNight: false, + }; + } + + const hourAngleDegrees = Math.acos(hourAngleCosine) * RADIANS_TO_DEGREES; + return { + sunriseMinutes: clamp( + solarNoonMinutes - 4 * hourAngleDegrees, + 0, + MINUTES_PER_DAY + ), + solarNoonMinutes, + sunsetMinutes: clamp( + solarNoonMinutes + 4 * hourAngleDegrees, + 0, + MINUTES_PER_DAY + ), + polarDay: false, + polarNight: false, + }; +}; + +export const clampSelectionToDaylight = ( + selection: SolarSelection, + location: SolarLocation, + edgePaddingMinutes = 1 +): SolarSelection | null => { + const dayOfYear = clamp( + Math.round(selection.dayOfYear), + 1, + getDaysInYear(selection.year) + ); + const daylight = getDaylightWindow(selection.year, dayOfYear, location); + if (daylight.polarNight) return null; + const minimum = daylight.polarDay + ? 0 + : daylight.sunriseMinutes + edgePaddingMinutes; + const maximum = daylight.polarDay + ? MINUTES_PER_DAY - 1 + : daylight.sunsetMinutes - edgePaddingMinutes; + return { + year: selection.year, + dayOfYear, + minutes: clamp(Math.round(selection.minutes), minimum, maximum), + }; +}; + +export const getSolarPosition = ( + selection: SolarSelection, + location: SolarLocation +): SolarPosition => { + const instant = solarSelectionToInstant(selection, location.timeZone); + const offsetHours = getTimeZoneOffsetMinutes(instant, location.timeZone) / 60; + const { equationOfTimeMinutes, declinationRadians } = getSolarTerms( + selection.year, + selection.dayOfYear, + selection.minutes + ); + const trueSolarMinutes = normalizeMinutes( + selection.minutes + + equationOfTimeMinutes + + 4 * location.longitude - + 60 * offsetHours + ); + const hourAngleRadians = (trueSolarMinutes / 4 - 180) * DEGREES_TO_RADIANS; + const latitudeRadians = location.latitude * DEGREES_TO_RADIANS; + const zenithCosine = clamp( + Math.sin(latitudeRadians) * Math.sin(declinationRadians) + + Math.cos(latitudeRadians) * + Math.cos(declinationRadians) * + Math.cos(hourAngleRadians), + -1, + 1 + ); + const elevationDegrees = 90 - Math.acos(zenithCosine) * RADIANS_TO_DEGREES; + const azimuthDegrees = + (Math.atan2( + Math.sin(hourAngleRadians), + Math.cos(hourAngleRadians) * Math.sin(latitudeRadians) - + Math.tan(declinationRadians) * Math.cos(latitudeRadians) + ) * + RADIANS_TO_DEGREES + + 180 + + 360) % + 360; + + return { instant, azimuthDegrees, elevationDegrees }; +}; diff --git a/libraries/mapping/addons/src/index.ts b/libraries/mapping/addons/src/index.ts index eb4f3299be..d824414287 100644 --- a/libraries/mapping/addons/src/index.ts +++ b/libraries/mapping/addons/src/index.ts @@ -4,6 +4,7 @@ export { getTargetAddonsWithTrigger, hasTargetAddonsWithTrigger, resolveActiveTargetAddon, + resolveSecondaryViewTargetAddon, toAddonButtonId, } from "./lib/target-addons"; export { @@ -78,7 +79,11 @@ export { } from "./addons/TimeSlider"; export { useHasAddonStateProducer } from "./lib/addon-channels"; -export { useAddonState, useAddonStateSnapshot } from "./lib/AddonStateContext"; +export { + useAddonState, + useAddonStateSnapshot, + useRouteAddons, +} from "./lib/AddonStateContext"; export type { AddonStateAction } from "./lib/AddonStateContext"; export { AddonManager, type AddonManagerConfig } from "./addons/AddonManager"; @@ -166,6 +171,10 @@ export { type LayerVisibilityConfig, } from "./addons/LayerVisibility"; export { LibreTerrain, type LibreTerrainConfig } from "./addons/LibreTerrain"; +export { + ShadowSimulation, + type ShadowSimulationConfig, +} from "./addons/ShadowSimulation"; export { OutletAddon, type OutletConfig } from "./addons/outlet/Outlet"; export { CompareSwipe, diff --git a/libraries/mapping/addons/src/lib/TargetAddonHost.tsx b/libraries/mapping/addons/src/lib/TargetAddonHost.tsx index f9ebf96432..cf1f8252a3 100644 --- a/libraries/mapping/addons/src/lib/TargetAddonHost.tsx +++ b/libraries/mapping/addons/src/lib/TargetAddonHost.tsx @@ -16,7 +16,7 @@ import { /** * Renders one addon declared on a stack entry, with the same interaction * inputs `AddonHost` hands route addons. Placement is the caller's business: - * the interaction view supplies the slot. + * interaction and secondary views can each supply a slot. */ export const TargetAddonHost = ({ addon, diff --git a/libraries/mapping/addons/src/lib/addon-overrides.ts b/libraries/mapping/addons/src/lib/addon-overrides.ts index 1c592bcb62..a36b598cda 100644 --- a/libraries/mapping/addons/src/lib/addon-overrides.ts +++ b/libraries/mapping/addons/src/lib/addon-overrides.ts @@ -49,6 +49,7 @@ export const SWITCHABLE_KINDS = [ "vectorHighlightControl", "vectorHighlightDebug", "libreTerrain", + "shadowSimulation", "visibleFeatureStatsSource", "visibleFeatureStatsPanel", "timeSlider", diff --git a/libraries/mapping/addons/src/lib/registry.ts b/libraries/mapping/addons/src/lib/registry.ts index 8a119cd151..0575aaffd2 100644 --- a/libraries/mapping/addons/src/lib/registry.ts +++ b/libraries/mapping/addons/src/lib/registry.ts @@ -53,6 +53,11 @@ import { type VectorHighlightDebugPanelConfig, } from "../addons/VectorHighlight"; import { LibreTerrain, type LibreTerrainConfig } from "../addons/LibreTerrain"; +import { + ShadowSimulation, + type ShadowSimulationConfig, + type ShadowSimulationState, +} from "../addons/ShadowSimulation"; import { LayerVisibility, layerVisibilityTrigger, @@ -120,6 +125,7 @@ export type AddonConfigMap = { vectorHighlightDebug: VectorHighlightDebugPanelConfig; layerVisibility: LayerVisibilityConfig; libreTerrain: LibreTerrainConfig; + shadowSimulation: ShadowSimulationConfig; infoBoxZoomImage: InfoBoxZoomImageConfig; outlet: OutletConfig; visibleFeatureStatsSource: VisibleFeatureStatsSourceConfig; @@ -177,6 +183,8 @@ export type AddonStateMap = { * addon, which is why it has no consumer among the registry's `requires`. */ addonOverrides: AddonOverridesState; + /** enabled state and daylight selection shared by the control and layer pane */ + shadowSimulation: ShadowSimulationState; }; export type AddonStateKey = keyof AddonStateMap; @@ -235,6 +243,13 @@ export type AddonComponentProps = { target: LayerStackEntry | null; }; +export const ADDON_TARGET_PLACEMENT = { + SECONDARY_VIEW: "secondary-view", +} as const; + +export type AddonTargetPlacement = + (typeof ADDON_TARGET_PLACEMENT)[keyof typeof ADDON_TARGET_PLACEMENT]; + export type AddonContext = { config?: AddonConfigMap[K]; target: LayerStackEntry | null; @@ -262,6 +277,8 @@ export type AddonTrigger = { export type AddonRegistryEntry = { Component?: ComponentType>; trigger?: AddonTrigger; + /** Render this target-bound component inside the host's secondary view. */ + targetPlacement?: AddonTargetPlacement; /** state channels this addon writes (headless producers declare these) */ provides?: readonly AddonStateKey[]; /** @@ -329,6 +346,11 @@ export const addonRegistry: { trigger: layerVisibilityTrigger, }, libreTerrain: { Component: LibreTerrain }, + shadowSimulation: { + Component: ShadowSimulation, + targetPlacement: ADDON_TARGET_PLACEMENT.SECONDARY_VIEW, + provides: ["shadowSimulation"], + }, infoBoxZoomImage: { Component: InfoBoxZoomImage, provides: ["infoBoxImage"], diff --git a/libraries/mapping/addons/src/lib/target-addons.ts b/libraries/mapping/addons/src/lib/target-addons.ts index 6b3309291a..21ffe412f0 100644 --- a/libraries/mapping/addons/src/lib/target-addons.ts +++ b/libraries/mapping/addons/src/lib/target-addons.ts @@ -1,6 +1,8 @@ import type { LayerStackEntry } from "@carma-mapping/layers"; import { + ADDON_TARGET_PLACEMENT, + addonRegistry, resolveAddonEntries, resolveAddonTrigger, type AddonEntry, @@ -36,3 +38,15 @@ export const resolveActiveTargetAddon = ( (entry) => toAddonButtonId(entry.kind) === activeButtonId ) : undefined; + +/** The first addon a target asks the host to place in its secondary view. */ +export const resolveSecondaryViewTargetAddon = ( + target?: LayerStackEntry | null +): ResolvedAddon | undefined => + resolveAddonEntries( + (target as { tools?: AddonEntry[] } | null | undefined)?.tools + ).find( + (entry) => + addonRegistry[entry.kind].targetPlacement === + ADDON_TARGET_PLACEMENT.SECONDARY_VIEW + ); diff --git a/libraries/mapping/addons/tsconfig.json b/libraries/mapping/addons/tsconfig.json index f474c15225..b7aecea93c 100644 --- a/libraries/mapping/addons/tsconfig.json +++ b/libraries/mapping/addons/tsconfig.json @@ -5,6 +5,9 @@ "references": [ { "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" } ] } diff --git a/libraries/mapping/addons/tsconfig.spec.json b/libraries/mapping/addons/tsconfig.spec.json new file mode 100644 index 0000000000..726684cbb9 --- /dev/null +++ b/libraries/mapping/addons/tsconfig.spec.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest" + ] + }, + "include": [ + "vite.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.d.ts" + ] +} diff --git a/libraries/mapping/addons/vite.config.ts b/libraries/mapping/addons/vite.config.ts new file mode 100644 index 0000000000..2379be3f60 --- /dev/null +++ b/libraries/mapping/addons/vite.config.ts @@ -0,0 +1,21 @@ +/// + +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../../node_modules/.vite/libraries/mapping/addons", + plugins: [react(), nxViteTsPaths()], + test: { + globals: true, + environment: "jsdom", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: "../../../coverage/libraries/mapping/addons", + provider: "v8", + }, + }, +}); diff --git a/libraries/mapping/engines/maplibre/README.md b/libraries/mapping/engines/maplibre/README.md index 27ac9df7a6..a32b44ac78 100644 --- a/libraries/mapping/engines/maplibre/README.md +++ b/libraries/mapping/engines/maplibre/README.md @@ -17,3 +17,22 @@ nx test engines/maplibre ```sh nx lint engines/maplibre ``` + +## Shared Three.js scene + +`buildSharedThreeSceneLayer` creates one MapLibre custom layer whose Three.js +scene can host multiple `SharedThreeSceneRuntime` roots. Point clouds, 3D Tiles, +and simulation geometry can therefore share one renderer and scene graph. +`buildThreeTilesRuntime` adds a streamed Cesium 3D Tiles tileset to that scene; +callers can retain its `root` or use the layer's `getScene()` accessor for +custom shadow or simulation passes. + +Existing `carma3d` building and vegetation layers still own their established +custom scenes for selection and overlays. Their generic-layer registry emits +lifecycle and geometry changes so simulation addons can discover and light +that content consistently while it is visible. + +MapLibre raster-DEM terrain is not Three.js geometry in this scene. The shared +layer currently queries its elevation only to align the frame camera target. +A simulation that needs terrain as a shadow receiver must add an explicit +terrain-mesh runtime to the shared scene. diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 58b0978148..0bd811aaf5 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -78,6 +78,11 @@ import { useMapHashRouting } from "@carma-appframeworks/portals"; import { ThreeLayerManager, get3dLayers } from "./ThreeLayerManager"; import { Tiles3dLayerManager } from "./Tiles3dLayerManager"; import type { Tiles3dConfig } from "./Tiles3dLayerManager"; +import { SharedThreeTilesLayerManager } from "./SharedThreeTilesLayerManager"; +import { + THREE_TILES_LAYER_TYPE, + type ThreeTilesLayer, +} from "../lib/runtime/integrations/three-tiles-layer"; const buildGazetteerRouteInfobox = (pos: number[], label: string) => ({ properties: { @@ -165,6 +170,7 @@ export interface RasterPaintOverrides { export type LibreLayer = | ({ type: "vector" } & VectorStyle) + | ThreeTilesLayer | { type: "geojson"; name: string; @@ -417,6 +423,18 @@ export const LibreMap = ({ }: LibreMapProps) => { const mapContainer = useRef(null); const map = useRef(null); + const mapStyleLayers = useMemo( + () => layers?.filter((layer) => layer.type !== THREE_TILES_LAYER_TYPE), + [layers] + ); + const threeTilesLayers = useMemo( + () => + (layers ?? []).filter( + (layer): layer is ThreeTilesLayer => + layer.type === THREE_TILES_LAYER_TYPE + ), + [layers] + ); const hidingManagerRef = useRef(null); const detachNonTiledRef = useRef<(() => void) | null>(null); const selectedFeaturesRef = useRef< @@ -847,7 +865,7 @@ export const LibreMap = ({ useImperativeStyle({ enabled: layerMode === "imperative", map: map.current, - layers, + layers: mapStyleLayers, backgroundStyle, vectorBackgroundLayers, clusteringEnabled, @@ -1461,17 +1479,17 @@ export const LibreMap = ({ // catch below can release them; otherwise an unexpected throw leaves every // layer button spinning forever. let preparedLayerIds: string[] = []; - const trackerRef = { current: null as ReturnType< - typeof ensureLayerLoadingTracker - > | null }; + const trackerRef = { + current: null as ReturnType | null, + }; const updateMapStyle = async () => { try { // Prepend vector background layers before data layers const effectiveLayers = vectorBackgroundLayers.length > 0 - ? [...vectorBackgroundLayers, ...(layers || [])] - : layers; + ? [...vectorBackgroundLayers, ...(mapStyleLayers || [])] + : mapStyleLayers; if (effectiveLayers) { // The style (re)build below refetches vector styles before any source @@ -1773,7 +1791,7 @@ export const LibreMap = ({ } // Get mapping for vector layers (only from user-provided layers, not backgrounds) - const vectorLayers = (layers || []).filter( + const vectorLayers = (mapStyleLayers || []).filter( (layer) => layer.type === "vector" ); let mapping = {}; @@ -1805,7 +1823,7 @@ export const LibreMap = ({ if (filterFunction && map.current) { const applyFilter = () => { if (map.current) { - filterFunction(map.current, layers); + filterFunction(map.current, mapStyleLayers); } }; @@ -1879,7 +1897,7 @@ export const LibreMap = ({ }, [ backgroundStyle, vectorBackgroundLayers, - layers, + mapStyleLayers, clusteringEnabled, markerSymbolSize, filterFunction, @@ -2214,7 +2232,6 @@ export const LibreMap = ({ perfRef={threePerfRef} /> ))} - {/* Tilesets named by a style's own metadata, see Tiles3dLayerManager */} {detectedTiles3dConfigs.map((config) => ( ))} + ); }; diff --git a/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx new file mode 100644 index 0000000000..8fa990dd4b --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/components/SharedThreeTilesLayerManager.tsx @@ -0,0 +1,56 @@ +import { useEffect } from "react"; + +import { buildThreeTilesRuntime } from "../lib/runtime/integrations/three-tiles-runtime"; +import { acquireSharedThreeScene } from "../lib/runtime/integrations/shared-three-scene-registry"; +import type { ThreeTilesLayer } from "../lib/runtime/integrations/three-tiles-layer"; +import { useLibreContext } from "../contexts/LibreContext"; + +const runtimeId = (name: string) => + `three-tiles-${name.replace(/[^a-zA-Z0-9_-]+/g, "-")}`; + +export const SharedThreeTilesLayerManager = ({ + layers, +}: { + layers: readonly ThreeTilesLayer[]; +}) => { + const { map } = useLibreContext(); + const layerKey = JSON.stringify(layers); + + useEffect(() => { + if (!map || layers.length === 0) return; + + const lease = acquireSharedThreeScene(map); + const runtimes = layers.map((layer) => { + const center = map.getCenter(); + const origin = layer.origin ?? [center.lng, center.lat]; + const runtime = buildThreeTilesRuntime( + runtimeId(layer.carmaLayerId ?? layer.name), + layer.url, + origin, + { requestConcurrency: layer.requestConcurrency ?? 2 } + ); + runtime.setClayMaterial({ + color: layer.shader.color, + roughness: layer.shader.roughness, + metalness: layer.shader.metalness, + }); + runtime.setWhiteShading(true); + runtime.setOpacity(layer.opacity ?? 1); + runtime.setErrorTarget(layer.errorTarget ?? 8); + lease.layer.addRuntime(runtime); + return runtime; + }); + + return () => { + for (const runtime of runtimes) { + lease.layer.removeRuntime(runtime.id); + } + lease.release(); + }; + // layerKey is the serializable lifecycle contract; depending on the array + // identity would reload tiles after unrelated host rerenders. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layerKey, map]); + + return null; +}; diff --git a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx index e8357f4a9d..8ee40ec209 100644 --- a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx +++ b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx @@ -32,7 +32,12 @@ import type { } from "@carma-mapping/engines/threejs"; import { useLibreContext } from "../contexts/LibreContext"; -import { add3dPresence, remove3dPresence } from "../utils/threeDPresence"; +import { + getGenericThreeLayers, + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + unregisterGenericThreeLayer, +} from "../lib/runtime/integrations/generic-three-layer-registry"; // ───────────────────────────────────────────────────────────── // ThreeLayerManager: bridges carma3d configs to the threejs engine // ───────────────────────────────────────────────────────────── @@ -65,35 +70,6 @@ export interface ThreeLayerManagerProps { perfRef?: React.MutableRefObject; } -// ───────────────────────────────────────────────────────────── -// Registry: expose 3D layers on the map instance for click handling -// ───────────────────────────────────────────────────────────── - -const LAYER_REGISTRY_KEY = "__carma3dLayers"; - -function register3dLayer(map: MaplibreMap, layer: GenericCustomLayer): void { - const registry = - ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; - if (!registry.includes(layer)) { - registry.push(layer); - } - (map as any)[LAYER_REGISTRY_KEY] = registry; - add3dPresence(map, layer.id); - // console.log("[3D-SELECT] registered layer:", layer.id, "total:", registry.length); -} - -function unregister3dLayer(map: MaplibreMap, layer: GenericCustomLayer): void { - const registry = - ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; - const idx = registry.indexOf(layer); - remove3dPresence(map, layer.id); - if (idx >= 0) { - registry.splice(idx, 1); - // console.log("[3D-SELECT] unregistered layer:", layer.id, "remaining:", registry.length); - } - (map as any)[LAYER_REGISTRY_KEY] = registry; -} - /** * The colour a feature gets: a colour it carries wins, then a colour its * category is mapped to. @@ -129,9 +105,7 @@ function resolveFeatureColor( } /** Get all registered 3D layers from a map instance. */ -export function get3dLayers(map: MaplibreMap): GenericCustomLayer[] { - return ((map as any)[LAYER_REGISTRY_KEY] as GenericCustomLayer[] | undefined) ?? []; -} +export const get3dLayers = getGenericThreeLayers; /** Apply building color/opacity overrides to existing building meshes in-place. */ function applyBuildingAppearance( @@ -219,7 +193,7 @@ export function ThreeLayerManager({ } if (layerRef.current) { layerRef.current.unhighlight(); - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); const layerId = layerRef.current.id; if (map.getLayer(layerId)) { map.removeLayer(layerId); @@ -300,7 +274,7 @@ export function ThreeLayerManager({ } if (layerRef.current) { layerRef.current.unhighlight(); - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); if (map.getLayer(layerRef.current.id)) { map.removeLayer(layerRef.current.id); } @@ -402,7 +376,7 @@ export function ThreeLayerManager({ console.log("[3D-ZORDER] addLayer", layerId, "beforeId:", initialBeforeId, "source:", config.sourceId); map.addLayer(customLayer, initialBeforeId); - register3dLayer(map, customLayer); + registerGenericThreeLayer(map, customLayer); const oId = layerId + "-overlay"; const overlay = buildOverlayLayer(customLayer, oId); @@ -800,6 +774,7 @@ export function ThreeLayerManager({ if (color || opacity != null) { applyBuildingAppearance(layer, color, opacity); } + notifyGenericThreeLayerContentChanged(map); // Build the spatial grid for raycast pre-filtering (reuse rebuild() logic) // We call rebuild() which rebuilds the grid from _features, but for extrusion @@ -870,6 +845,7 @@ export function ThreeLayerManager({ layerRef.current, radiusMix ); + if (result) notifyGenericThreeLayerContentChanged(map); if (result && perfRef) { perfRef.current = { ...result, @@ -924,7 +900,7 @@ export function ThreeLayerManager({ // Also re-checks visibility so toggling a layer back on re-creates the 3D layer const handleStyleData = () => { if (layerRef.current && !map.getLayer(layerRef.current.id)) { - unregister3dLayer(map, layerRef.current); + unregisterGenericThreeLayer(map, layerRef.current); overlayIdRef.current = null; layerRef.current = null; addingRef.current = false; @@ -998,6 +974,7 @@ export function ThreeLayerManager({ useEffect(() => { if (!map || (config.renderMode !== "extrusion" && config.renderMode !== "lod2")) return; applyBuildingAppearance(layerRef.current, buildingColor, buildingOpacity); + notifyGenericThreeLayerContentChanged(map); map.triggerRepaint(); }, [map, config.renderMode, buildingColor, buildingOpacity]); diff --git a/libraries/mapping/engines/maplibre/src/index.ts b/libraries/mapping/engines/maplibre/src/index.ts index 13610b1650..e67e70ceb4 100644 --- a/libraries/mapping/engines/maplibre/src/index.ts +++ b/libraries/mapping/engines/maplibre/src/index.ts @@ -231,5 +231,55 @@ export { ThreeLayerManager, get3dLayers } from "./components/ThreeLayerManager"; export { has3dLayers } from "./utils/threeDPresence"; export type { ThreeLayerManagerProps } from "./components/ThreeLayerManager"; +export { buildSharedThreeSceneLayer } from "./lib/runtime/integrations/shared-three-scene-layer"; +export type { + SharedThreeSceneFrame, + SharedThreeSceneLayer, + SharedThreeSceneLayerOptions, + SharedThreeSceneRuntime, +} from "./lib/runtime/integrations/shared-three-scene-layer"; +export { + acquireSharedThreeScene, + getSharedThreeSceneStatus, + subscribeSharedThreeSceneStatus, +} from "./lib/runtime/integrations/shared-three-scene-registry"; +export type { + SharedThreeSceneLease, + SharedThreeSceneStatus, +} from "./lib/runtime/integrations/shared-three-scene-registry"; +export { + genericThreeLayerHasShadeableContent, + getGenericThreeLayers, + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + subscribeGenericThreeLayers, + unregisterGenericThreeLayer, +} from "./lib/runtime/integrations/generic-three-layer-registry"; +export { + getShadowSimulationContentStatus, + subscribeShadowSimulationContentStatus, +} from "./lib/runtime/integrations/shadow-simulation-content-status"; +export type { ShadowSimulationContentStatus } from "./lib/runtime/integrations/shadow-simulation-content-status"; +export type { + ThreeTilesClayShader, + ThreeTilesLayer, +} from "./lib/runtime/integrations/three-tiles-layer"; +export { + THREE_TILES_LAYER_TYPE, + THREE_TILES_SHADER_KIND, +} from "./lib/runtime/integrations/three-tiles-layer"; +export { + buildThreeTilesRuntime, + TILES_ERROR_TARGET_DEFAULT_PIXELS, + TILES_ERROR_TARGET_MAX_PIXELS, + TILES_ERROR_TARGET_MIN_PIXELS, +} from "./lib/runtime/integrations/three-tiles-runtime"; +export type { + ClayMaterialOptions, + ImageProjector, + ThreeTilesRuntime, + ThreeTilesRuntimeOptions, +} from "./lib/runtime/integrations/three-tiles-runtime"; + // Styles (CSS should be imported by consumers) // import '@carma-mapping/engines/maplibre/styles/map.css'; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts new file mode 100644 index 0000000000..45bde9bf50 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.spec.ts @@ -0,0 +1,47 @@ +// @vitest-environment node + +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + genericThreeLayerHasShadeableContent, + getGenericThreeLayers, + notifyGenericThreeLayerContentChanged, + registerGenericThreeLayer, + subscribeGenericThreeLayers, + unregisterGenericThreeLayer, +} from "./generic-three-layer-registry"; + +describe("generic Three.js layer registry", () => { + it("publishes layer lifecycle and content changes", () => { + const map = {} as never; + const layer = { scene: new THREE.Scene() } as never; + const listener = vi.fn(); + const unsubscribe = subscribeGenericThreeLayers(map, listener); + + registerGenericThreeLayer(map, layer); + notifyGenericThreeLayerContentChanged(map); + expect(getGenericThreeLayers(map)).toEqual([layer]); + expect(listener).toHaveBeenCalledTimes(2); + + unregisterGenericThreeLayer(map, layer); + expect(getGenericThreeLayers(map)).toEqual([]); + expect(listener).toHaveBeenCalledTimes(3); + + unsubscribe(); + }); + + it("recognizes visible mesh geometry with a visible material", () => { + const scene = new THREE.Scene(); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshLambertMaterial() + ); + scene.add(mesh); + const layer = { scene } as never; + + expect(genericThreeLayerHasShadeableContent(layer)).toBe(true); + mesh.visible = false; + expect(genericThreeLayerHasShadeableContent(layer)).toBe(false); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts new file mode 100644 index 0000000000..a40809beda --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/generic-three-layer-registry.ts @@ -0,0 +1,84 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; +import type * as THREE from "three"; + +import type { GenericCustomLayer } from "@carma-mapping/engines/threejs"; + +import { add3dPresence, remove3dPresence } from "../../../utils/threeDPresence"; + +const layersByMap = new WeakMap(); +const listenersByMap = new WeakMap void>>(); + +const emitGenericThreeLayerChange = (map: MaplibreMap) => { + for (const listener of listenersByMap.get(map) ?? []) listener(); +}; + +const materialIsVisible = (material: THREE.Material): boolean => + material.visible && material.opacity > 0; + +const objectHasRenderableGeometry = (object: THREE.Object3D): boolean => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) { + return false; + } + if (!mesh.geometry?.getAttribute("position")?.count) return false; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + return materials.some(materialIsVisible); +}; + +export const genericThreeLayerHasShadeableContent = ( + layer: GenericCustomLayer +): boolean => { + if (!layer.scene) return false; + let hasContent = false; + layer.scene.traverseVisible((object) => { + if (!hasContent && objectHasRenderableGeometry(object)) hasContent = true; + }); + return hasContent; +}; + +export const getGenericThreeLayers = (map: MaplibreMap): GenericCustomLayer[] => + layersByMap.get(map) ?? []; + +export const registerGenericThreeLayer = ( + map: MaplibreMap, + layer: GenericCustomLayer +): void => { + const layers = layersByMap.get(map) ?? []; + if (layers.includes(layer)) return; + layersByMap.set(map, [...layers, layer]); + // What lets the camera restriction and terrain button know the map has + // become three dimensional, see utils/threeDPresence. + add3dPresence(map, layer.id); + emitGenericThreeLayerChange(map); +}; + +export const unregisterGenericThreeLayer = ( + map: MaplibreMap, + layer: GenericCustomLayer +): void => { + const layers = layersByMap.get(map) ?? []; + const nextLayers = layers.filter((candidate) => candidate !== layer); + if (nextLayers.length === layers.length) return; + remove3dPresence(map, layer.id); + if (nextLayers.length > 0) layersByMap.set(map, nextLayers); + else layersByMap.delete(map); + emitGenericThreeLayerChange(map); +}; + +export const notifyGenericThreeLayerContentChanged = (map: MaplibreMap): void => + emitGenericThreeLayerChange(map); + +export const subscribeGenericThreeLayers = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const listeners = listenersByMap.get(map) ?? new Set<() => void>(); + listeners.add(listener); + listenersByMap.set(map, listeners); + return () => { + listeners.delete(listener); + if (listeners.size === 0) listenersByMap.delete(map); + }; +}; diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts similarity index 100% rename from playgrounds/ng-topicmap-playground/src/app/pointcloud/gltf1UpgradePlugin.ts rename to libraries/mapping/engines/maplibre/src/lib/runtime/integrations/gltf1-upgrade-plugin.ts diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.spec.ts new file mode 100644 index 0000000000..1392707ae4 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./shared-three-scene-registry", () => ({ + getSharedThreeSceneStatus: vi.fn(), + subscribeSharedThreeSceneStatus: vi.fn(() => vi.fn()), +})); + +vi.mock("./generic-three-layer-registry", () => ({ + genericThreeLayerHasShadeableContent: vi.fn(), + getGenericThreeLayers: vi.fn(() => []), + subscribeGenericThreeLayers: vi.fn(() => vi.fn()), +})); + +import { + getSharedThreeSceneStatus, + subscribeSharedThreeSceneStatus, +} from "./shared-three-scene-registry"; +import { + genericThreeLayerHasShadeableContent, + getGenericThreeLayers, + subscribeGenericThreeLayers, +} from "./generic-three-layer-registry"; +import { + getShadowSimulationContentStatus, + subscribeShadowSimulationContentStatus, +} from "./shadow-simulation-content-status"; + +describe("shadow simulation content status", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSharedThreeSceneStatus).mockReturnValue({ + layerVisible: false, + hasShadeableContent: false, + }); + vi.mocked(getGenericThreeLayers).mockReturnValue([]); + vi.mocked(genericThreeLayerHasShadeableContent).mockReturnValue(false); + }); + + it("accepts a visible native fill extrusion without a Three.js runtime", () => { + const map = { + getStyle: vi.fn(() => ({ + layers: [{ id: "alkis-buildings", type: "fill-extrusion" }], + })), + getLayoutProperty: vi.fn(() => "visible"), + getPaintProperty: vi.fn(() => 0.8), + }; + + expect(getShadowSimulationContentStatus(map as never)).toEqual({ + hasThreeShadowContent: false, + hasMapLibreLitExtrusions: true, + available: true, + }); + }); + + it("ignores hidden or transparent fill extrusions", () => { + const map = { + getStyle: vi.fn(() => ({ + layers: [{ id: "alkis-buildings", type: "fill-extrusion" }], + })), + getLayoutProperty: vi.fn(() => "none"), + getPaintProperty: vi.fn(() => 0.8), + }; + + expect( + getShadowSimulationContentStatus(map as never).hasMapLibreLitExtrusions + ).toBe(false); + + map.getLayoutProperty.mockReturnValue("visible"); + map.getPaintProperty.mockReturnValue(0); + expect(getShadowSimulationContentStatus(map as never).available).toBe( + false + ); + }); + + it("accepts visible ALKIS geometry from the generic Three.js manager", () => { + vi.mocked(getGenericThreeLayers).mockReturnValue([ + { id: "3d-extrusion-alkis" } as never, + ]); + vi.mocked(genericThreeLayerHasShadeableContent).mockReturnValue(true); + const map = { + getStyle: vi.fn(() => ({ layers: [] })), + getLayer: vi.fn(() => ({ id: "3d-extrusion-alkis" })), + getLayoutProperty: vi.fn(() => "visible"), + }; + + expect(getShadowSimulationContentStatus(map as never)).toEqual({ + hasThreeShadowContent: true, + hasMapLibreLitExtrusions: false, + available: true, + }); + }); + + it("subscribes to native style and shared Three.js changes", () => { + const unsubscribeThree = vi.fn(); + const unsubscribeGenericThree = vi.fn(); + vi.mocked(subscribeSharedThreeSceneStatus).mockReturnValue( + unsubscribeThree + ); + vi.mocked(subscribeGenericThreeLayers).mockReturnValue( + unsubscribeGenericThree + ); + const map = { on: vi.fn(), off: vi.fn() }; + const listener = vi.fn(); + + const unsubscribe = subscribeShadowSimulationContentStatus( + map as never, + listener + ); + + expect(map.on).toHaveBeenCalledWith("styledata", listener); + expect(subscribeSharedThreeSceneStatus).toHaveBeenCalledWith(map, listener); + expect(subscribeGenericThreeLayers).toHaveBeenCalledWith(map, listener); + + unsubscribe(); + expect(map.off).toHaveBeenCalledWith("styledata", listener); + expect(unsubscribeGenericThree).toHaveBeenCalledOnce(); + expect(unsubscribeThree).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.ts new file mode 100644 index 0000000000..51106885de --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.ts @@ -0,0 +1,80 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { + getSharedThreeSceneStatus, + subscribeSharedThreeSceneStatus, +} from "./shared-three-scene-registry"; +import { + genericThreeLayerHasShadeableContent, + getGenericThreeLayers, + subscribeGenericThreeLayers, +} from "./generic-three-layer-registry"; + +export type ShadowSimulationContentStatus = { + /** Three.js geometry that can cast and receive the custom shadow map. */ + hasThreeShadowContent: boolean; + /** Native MapLibre extrusions that react to MapLibre's light settings. */ + hasMapLibreLitExtrusions: boolean; + available: boolean; +}; + +const hasVisibleMapLibreExtrusions = (map: MaplibreMap): boolean => { + try { + return Boolean( + map.getStyle().layers?.some((layer) => { + if (layer.type !== "fill-extrusion") return false; + const visible = + map.getLayoutProperty(layer.id, "visibility") !== "none"; + const opacity = map.getPaintProperty( + layer.id, + "fill-extrusion-opacity" + ); + return visible && opacity !== 0; + }) + ); + } catch { + return false; + } +}; + +const hasVisibleGenericThreeContent = (map: MaplibreMap): boolean => + getGenericThreeLayers(map).some((layer) => { + try { + if (!map.getLayer(layer.id)) return false; + if (map.getLayoutProperty(layer.id, "visibility") === "none") { + return false; + } + } catch { + return false; + } + return genericThreeLayerHasShadeableContent(layer); + }); + +export const getShadowSimulationContentStatus = ( + map: MaplibreMap +): ShadowSimulationContentStatus => { + const threeStatus = getSharedThreeSceneStatus(map); + const hasThreeShadowContent = + (threeStatus.layerVisible && threeStatus.hasShadeableContent) || + hasVisibleGenericThreeContent(map); + const hasMapLibreLitExtrusions = hasVisibleMapLibreExtrusions(map); + return { + hasThreeShadowContent, + hasMapLibreLitExtrusions, + available: hasThreeShadowContent || hasMapLibreLitExtrusions, + }; +}; + +export const subscribeShadowSimulationContentStatus = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const unsubscribeThree = subscribeSharedThreeSceneStatus(map, listener); + const unsubscribeGenericThree = subscribeGenericThreeLayers(map, listener); + map.on("styledata", listener); + return () => { + map.off("styledata", listener); + unsubscribeGenericThree(); + unsubscribeThree(); + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts new file mode 100644 index 0000000000..b4254a1eb3 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts @@ -0,0 +1,38 @@ +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { buildSharedThreeSceneLayer } from "./shared-three-scene-layer"; + +describe("shared Three.js scene layer", () => { + it("exposes attached roots and reports visible shadeable content", () => { + const onContentChange = vi.fn(); + const layer = buildSharedThreeSceneLayer("shared-three-scene", { + onContentChange, + }); + const root = new THREE.Group(); + const dispose = vi.fn(); + + layer.addRuntime({ + id: "mesh-runtime", + originLngLat: [7.15, 51.25], + root, + supportsShadows: true, + update: vi.fn(), + dispose, + }); + + expect(layer.getScene().children).toContain(root); + expect(layer.hasRuntime("mesh-runtime")).toBe(true); + expect(layer.hasShadeableContent()).toBe(true); + expect(onContentChange).toHaveBeenCalledOnce(); + + root.visible = false; + expect(layer.hasShadeableContent()).toBe(false); + + layer.removeRuntime("mesh-runtime"); + + expect(layer.getScene().children).not.toContain(root); + expect(dispose).toHaveBeenCalledOnce(); + expect(onContentChange).toHaveBeenCalledTimes(2); + }); +}); diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/pointcloudSceneLayer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts similarity index 77% rename from playgrounds/ng-topicmap-playground/src/app/pointcloud/pointcloudSceneLayer.ts rename to libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts index 6bf43cc6dd..4988fcbbb0 100644 --- a/playgrounds/ng-topicmap-playground/src/app/pointcloud/pointcloudSceneLayer.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts @@ -7,31 +7,40 @@ import type { } from "maplibre-gl"; import * as THREE from "three"; -export interface PointcloudSceneFrame { +export interface SharedThreeSceneFrame { map: MaplibreMap; renderCamera: THREE.Camera; lodCamera: THREE.PerspectiveCamera; viewport: THREE.Vector2; } -export interface PointcloudSceneRuntime { +export interface SharedThreeSceneRuntime { id: string; originLngLat: [number, number]; root: THREE.Object3D; + /** Runtime contains visible geometry that can cast or receive shadows. */ + supportsShadows?: boolean; onAdd?: (map: MaplibreMap) => void; - update: (frame: PointcloudSceneFrame) => void; + update: (frame: SharedThreeSceneFrame) => void; dispose: () => void; } -export interface PointcloudSceneLayer extends CustomLayerInterface { - addRuntime: (runtime: PointcloudSceneRuntime) => void; +export interface SharedThreeSceneLayer extends CustomLayerInterface { + addRuntime: (runtime: SharedThreeSceneRuntime) => void; removeRuntime: (runtimeId: string) => void; hasRuntime: (runtimeId: string) => boolean; + hasShadeableContent: () => boolean; + getScene: () => THREE.Scene; /** Detach the custom layer without destroying runtimes preserved across HMR. */ detach: () => void; dispose: () => void; } +export interface SharedThreeSceneLayerOptions { + ambientLightIntensity?: number; + onContentChange?: () => void; +} + const rotationX = new THREE.Matrix4().makeRotationAxis( new THREE.Vector3(1, 0, 0), Math.PI / 2 @@ -42,23 +51,26 @@ const rotationX = new THREE.Matrix4().makeRotationAxis( * mesh content. Opaque meshes and transparent splats therefore share Three's * render ordering and MapLibre's existing depth buffer in a single draw. */ -export const buildPointcloudSceneLayer = ( - layerId: string -): PointcloudSceneLayer => { +export const buildSharedThreeSceneLayer = ( + layerId: string, + options: SharedThreeSceneLayerOptions = {} +): SharedThreeSceneLayer => { const scene = new THREE.Scene(); - scene.add(new THREE.AmbientLight(0xffffff, 2.4)); + scene.add( + new THREE.AmbientLight(0xffffff, options.ambientLightIntensity ?? 2.4) + ); const renderCamera = new THREE.Camera(); const lodCamera = new THREE.PerspectiveCamera(); const viewport = new THREE.Vector2(1, 1); const lookTarget = new THREE.Vector3(); - const runtimes = new Map(); + const runtimes = new Map(); let map: MaplibreMap | null = null; let renderer: THREE.WebGLRenderer | null = null; let originMerc: MercatorCoordinate | null = null; let meterScale = 0; let disposed = false; - const placeRuntime = (runtime: PointcloudSceneRuntime) => { + const placeRuntime = (runtime: SharedThreeSceneRuntime) => { if (!originMerc || meterScale <= 0) return; const runtimeOrigin = MercatorCoordinate.fromLngLat( runtime.originLngLat, @@ -74,7 +86,7 @@ export const buildPointcloudSceneLayer = ( runtime.root.updateMatrixWorld(true); }; - const layer: PointcloudSceneLayer = { + const layer: SharedThreeSceneLayer = { id: layerId, type: "custom", renderingMode: "3d", @@ -88,6 +100,7 @@ export const buildPointcloudSceneLayer = ( scene.add(runtime.root); placeRuntime(runtime); if (map) runtime.onAdd?.(map); + options.onContentChange?.(); map?.triggerRepaint(); }, @@ -97,6 +110,7 @@ export const buildPointcloudSceneLayer = ( runtimes.delete(runtimeId); scene.remove(runtime.root); runtime.dispose(); + options.onContentChange?.(); map?.triggerRepaint(); }, @@ -104,6 +118,16 @@ export const buildPointcloudSceneLayer = ( return runtimes.has(runtimeId); }, + hasShadeableContent() { + return [...runtimes.values()].some( + (runtime) => runtime.supportsShadows && runtime.root.visible + ); + }, + + getScene() { + return scene; + }, + detach() { for (const runtime of runtimes.values()) scene.remove(runtime.root); renderer?.dispose(); @@ -123,10 +147,14 @@ export const buildPointcloudSceneLayer = ( context: gl, }); renderer.autoClear = false; + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; for (const runtime of runtimes.values()) { + if (runtime.root.parent !== scene) scene.add(runtime.root); placeRuntime(runtime); runtime.onAdd?.(mapInstance); } + options.onContentChange?.(); }, render(gl, options: CustomRenderMethodInput) { @@ -158,7 +186,7 @@ export const buildPointcloudSceneLayer = ( return; } - const frame: PointcloudSceneFrame = { + const frame: SharedThreeSceneFrame = { map, renderCamera, lodCamera, @@ -178,6 +206,7 @@ export const buildPointcloudSceneLayer = ( renderer?.dispose(); renderer = null; map = null; + options.onContentChange?.(); }, dispose() { @@ -189,6 +218,7 @@ export const buildPointcloudSceneLayer = ( renderer?.dispose(); renderer = null; map = null; + options.onContentChange?.(); }, }; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts new file mode 100644 index 0000000000..5991f5d725 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts @@ -0,0 +1,89 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./shared-three-scene-layer", () => ({ + buildSharedThreeSceneLayer: vi.fn(), +})); + +import { buildSharedThreeSceneLayer } from "./shared-three-scene-layer"; +import { + acquireSharedThreeScene, + getSharedThreeSceneStatus, + subscribeSharedThreeSceneStatus, +} from "./shared-three-scene-registry"; + +describe("shared Three.js scene registry", () => { + const dispose = vi.fn(); + const sharedLayer = { + id: "carma-shared-three-scene", + dispose, + hasShadeableContent: vi.fn(() => true), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(buildSharedThreeSceneLayer).mockReturnValue(sharedLayer as never); + }); + + it("shares one layer and disposes it after the final lease", () => { + const listeners = new Map void>(); + const addLayer = vi.fn(); + const removeLayer = vi.fn(); + const getLayoutProperty = vi.fn(() => "visible"); + let attached = false; + addLayer.mockImplementation(() => { + attached = true; + }); + removeLayer.mockImplementation(() => { + attached = false; + }); + const map = { + isStyleLoaded: vi.fn(() => true), + getStyle: vi.fn(() => ({ layers: [{ id: "labels", type: "symbol" }] })), + getLayer: vi.fn(() => (attached ? sharedLayer : undefined)), + getLayoutProperty, + addLayer, + removeLayer, + on: vi.fn((event: string, handler: () => void) => { + listeners.set(event, handler); + }), + off: vi.fn((event: string) => { + listeners.delete(event); + }), + }; + + const first = acquireSharedThreeScene(map as never); + const second = acquireSharedThreeScene(map as never); + + expect(first.layer).toBe(second.layer); + expect(buildSharedThreeSceneLayer).toHaveBeenCalledOnce(); + expect(addLayer).toHaveBeenCalledWith(sharedLayer, "labels"); + expect(getSharedThreeSceneStatus(map as never)).toEqual({ + layerVisible: true, + hasShadeableContent: true, + }); + getLayoutProperty.mockReturnValue("none"); + expect(getSharedThreeSceneStatus(map as never).layerVisible).toBe(false); + getLayoutProperty.mockReturnValue("visible"); + + const statusListener = vi.fn(); + const unsubscribe = subscribeSharedThreeSceneStatus( + map as never, + statusListener + ); + const onContentChange = vi.mocked(buildSharedThreeSceneLayer).mock + .calls[0]?.[1]?.onContentChange; + onContentChange?.(); + expect(statusListener).toHaveBeenCalledOnce(); + unsubscribe(); + + first.release(); + expect(dispose).not.toHaveBeenCalled(); + + second.release(); + expect(removeLayer).toHaveBeenCalledWith(sharedLayer.id); + expect(dispose).toHaveBeenCalledOnce(); + expect(listeners.has("styledata")).toBe(false); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts new file mode 100644 index 0000000000..86deba2a60 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts @@ -0,0 +1,133 @@ +import type { Map as MaplibreMap } from "maplibre-gl"; + +import { buildSharedThreeSceneLayer } from "./shared-three-scene-layer"; +import type { SharedThreeSceneLayer } from "./shared-three-scene-layer"; + +const SHARED_SCENE_LAYER_ID = "carma-shared-three-scene"; + +type SharedSceneEntry = { + layer: SharedThreeSceneLayer; + references: number; + disposed: boolean; + ensureLayer: () => void; +}; + +export type SharedThreeSceneLease = { + layer: SharedThreeSceneLayer; + release: () => void; +}; + +const entries = new WeakMap(); +const listeners = new WeakMap void>>(); + +const emitStatusChange = (map: MaplibreMap) => { + for (const listener of listeners.get(map) ?? []) listener(); +}; + +export type SharedThreeSceneStatus = { + layerVisible: boolean; + hasShadeableContent: boolean; +}; + +export const getSharedThreeSceneStatus = ( + map: MaplibreMap +): SharedThreeSceneStatus => { + const entry = entries.get(map); + if (!entry || entry.disposed) { + return { layerVisible: false, hasShadeableContent: false }; + } + let layerVisible = false; + try { + layerVisible = Boolean(map.getLayer(entry.layer.id)); + if (layerVisible && map.getLayoutProperty) { + layerVisible = + map.getLayoutProperty(entry.layer.id, "visibility") !== "none"; + } + } catch { + layerVisible = false; + } + return { + layerVisible, + hasShadeableContent: entry.layer.hasShadeableContent(), + }; +}; + +export const subscribeSharedThreeSceneStatus = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const mapListeners = listeners.get(map) ?? new Set<() => void>(); + mapListeners.add(listener); + listeners.set(map, mapListeners); + return () => { + mapListeners.delete(listener); + if (mapListeners.size === 0) listeners.delete(map); + }; +}; + +const getFirstSymbolLayerId = (map: MaplibreMap): string | undefined => + map.getStyle().layers?.find(({ type }) => type === "symbol")?.id; + +/** + * Acquire the one shared Three.js custom layer belonging to a MapLibre map. + * Consumers contribute runtimes or lights and release their lease on cleanup; + * the last release removes and disposes the shared renderer and scene. + */ +export const acquireSharedThreeScene = ( + map: MaplibreMap +): SharedThreeSceneLease => { + let entry = entries.get(map); + if (!entry) { + const layer = buildSharedThreeSceneLayer(SHARED_SCENE_LAYER_ID, { + ambientLightIntensity: 0.58, + onContentChange: () => emitStatusChange(map), + }); + const nextEntry: SharedSceneEntry = { + layer, + references: 0, + disposed: false, + ensureLayer: () => undefined, + }; + nextEntry.ensureLayer = () => { + if (nextEntry.disposed || !map.isStyleLoaded()) return; + try { + if (!map.getLayer(layer.id)) { + map.addLayer(layer, getFirstSymbolLayerId(map)); + } + } catch { + // A style replacement or map teardown can race this callback. + } + emitStatusChange(map); + }; + entries.set(map, nextEntry); + map.on("styledata", nextEntry.ensureLayer); + nextEntry.ensureLayer(); + entry = nextEntry; + } + + entry.references += 1; + let released = false; + + return { + layer: entry.layer, + release() { + if (released) return; + released = true; + const current = entries.get(map); + if (!current || current !== entry) return; + current.references -= 1; + if (current.references > 0) return; + + current.disposed = true; + map.off("styledata", current.ensureLayer); + try { + if (map.getLayer(current.layer.id)) map.removeLayer(current.layer.id); + } catch { + // The host may already have disposed or replaced its style. + } + current.layer.dispose(); + entries.delete(map); + emitStatusChange(map); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts new file mode 100644 index 0000000000..30f84f7955 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-layer.ts @@ -0,0 +1,25 @@ +export const THREE_TILES_LAYER_TYPE = "three-tiles" as const; + +export const THREE_TILES_SHADER_KIND = { + CLAY: "clay", +} as const; + +export type ThreeTilesClayShader = { + kind: typeof THREE_TILES_SHADER_KIND.CLAY; + color: string; + roughness?: number; + metalness?: number; +}; + +/** Serializable 3D Tiles layer contract used by catalog/drop integrations. */ +export type ThreeTilesLayer = { + type: typeof THREE_TILES_LAYER_TYPE; + name: string; + url: string; + carmaLayerId?: string; + origin?: [longitude: number, latitude: number]; + shader: ThreeTilesClayShader; + opacity?: number; + errorTarget?: number; + requestConcurrency?: number; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts new file mode 100644 index 0000000000..954fd45c75 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.spec.ts @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +describe("tiles3d layer visibility", () => { + it("can be hidden and shown again without disposing its scene root", async () => { + Object.defineProperty(window.URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); + const { buildThreeTilesRuntime } = await import("./three-tiles-runtime"); + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const root = layer.root; + + layer.setVisible(false); + expect(layer.root).toBe(root); + expect(root.visible).toBe(false); + + layer.setVisible(true); + expect(layer.root).toBe(root); + expect(root.visible).toBe(true); + + layer.dispose(); + }); + + it("applies the declared clay material to meshes in the shared scene", async () => { + Object.defineProperty(window.URL, "createObjectURL", { + configurable: true, + value: () => "blob:vitest-maplibre-worker", + }); + const { buildThreeTilesRuntime } = await import("./three-tiles-runtime"); + const layer = buildThreeTilesRuntime("mesh", "tileset.json", [7.15, 51.25]); + const mesh = new THREE.Mesh( + new THREE.BoxGeometry(1, 1, 1), + new THREE.MeshBasicMaterial() + ); + layer.root.add(mesh); + + layer.setClayMaterial({ + color: "#d8d1c4", + roughness: 0.7, + metalness: 0.1, + }); + layer.setWhiteShading(true); + + const material: THREE.Material = mesh.material; + expect(material).toBeInstanceOf(THREE.MeshStandardMaterial); + if (!(material instanceof THREE.MeshStandardMaterial)) { + throw new Error("clay shader did not replace the source material"); + } + expect(material.color.getHexString()).toBe("d8d1c4"); + expect(material.roughness).toBe(0.7); + expect(material.metalness).toBe(0.1); + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + + layer.dispose(); + }); +}); diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles3dLayer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts similarity index 82% rename from playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles3dLayer.ts rename to libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts index 2c630924aa..5124c8bc8f 100644 --- a/playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles3dLayer.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts @@ -11,13 +11,13 @@ import type { Map as MaplibreMap } from "maplibre-gl"; import * as THREE from "three"; import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js"; -import { Gltf1UpgradePlugin } from "./gltf1UpgradePlugin"; +import { Gltf1UpgradePlugin } from "./gltf1-upgrade-plugin"; import { createTilesCameraSet } from "./tiles-camera-set"; import type { TilesCameraSet } from "./tiles-camera-set"; import type { - PointcloudSceneFrame, - PointcloudSceneRuntime, -} from "./pointcloudSceneLayer"; + SharedThreeSceneFrame, + SharedThreeSceneRuntime, +} from "./shared-three-scene-layer"; // Match the direct screen-space-error control used by the official // 3DTilesRendererJS kitchen-sink demo. Lower values request more detail. @@ -33,8 +33,8 @@ const MINIMUM_CACHE_BYTES = 16 * 1024 ** 2; const CLAY_COLOR = 0xd6d2ca; // ───────────────────────────────────────────────────────────── -// Cesium 3D Tiles (b3dm meshes) inside the shared MapLibre -// pointcloudSceneLayer, using NASA-AMMOS 3d-tiles-renderer (Apache-2.0). +// Cesium 3D Tiles (b3dm meshes) inside the shared MapLibre Three.js scene, +// using NASA-AMMOS 3d-tiles-renderer (Apache-2.0). // // The tilesets are georeferenced in ECEF; the ReorientationPlugin // maps them into the same local scene frame the point cloud @@ -62,14 +62,15 @@ export type ImageProjector = opacity: number; }; -export interface Tiles3dLayer extends PointcloudSceneRuntime { +export interface ThreeTilesRuntime extends SharedThreeSceneRuntime { /** Pause/resume traversal and drawing without destroying the tileset cache. */ setVisible: (visible: boolean) => void; /** Vertical offset in meters (datum corrections included by caller) */ setHeightOffset: (offsetMeters: number) => void; setErrorTarget: (errorTarget: number) => void; - /** Override textures with flat white shading (reversible) */ + /** Override textures with physically lit clay shading (reversible). */ setWhiteShading: (white: boolean) => void; + setClayMaterial: (options: ClayMaterialOptions) => void; setClayColor: (color: string) => void; setOpacity: (opacity: number) => void; setWireframe: (enabled: boolean) => void; @@ -83,18 +84,24 @@ export interface Tiles3dLayer extends PointcloudSceneRuntime { mScale: number; } -export interface Tiles3dLayerOptions { +export interface ClayMaterialOptions { + color?: string; + roughness?: number; + metalness?: number; +} + +export interface ThreeTilesRuntimeOptions { cacheBudgetBytes?: number; requestConcurrency?: number; onRequestStateChange?: () => void; } -export function buildTiles3dLayer( +export function buildThreeTilesRuntime( layerId: string, tilesetUrl: string, originLngLat: [number, number], - options: Tiles3dLayerOptions = {} -): Tiles3dLayer { + options: ThreeTilesRuntimeOptions = {} +): ThreeTilesRuntime { const originMerc = MercatorCoordinate.fromLngLat(originLngLat, 0); const mScale = originMerc.meterInMercatorCoordinateUnits(); @@ -123,7 +130,9 @@ export function buildTiles3dLayer( const offsetGroup = new THREE.Group(); orientationGroup.add(offsetGroup); let whiteShading = false; - let clayColor = new THREE.Color(CLAY_COLOR); + let clayColor = new THREE.Color(CLAY_COLOR); + let clayRoughness = 0.92; + let clayMetalness = 0; let opacity = 1; let wireframe = false; let tileBoundsVisible = false; @@ -142,8 +151,6 @@ export function buildTiles3dLayer( uProjHeading: { value: 0 }, uProjMatrix: { value: new THREE.Matrix4() }, tProj: { value: null as THREE.Texture | null }, - uClayEnabled: { value: 0 }, - uClayColor: { value: clayColor.clone() }, }; const patchMaterialForProjection = (material: THREE.Material) => { @@ -170,38 +177,7 @@ uniform float uProjOpacity; uniform vec3 uProjPos; uniform float uProjHeading; uniform mat4 uProjMatrix; -uniform sampler2D tProj; -uniform float uClayEnabled; -uniform vec3 uClayColor;` - ) - .replace( - "#include ", - `if (uClayEnabled > 0.5) { - // The source tiles are intentionally unlit because illumination is baked - // into their photographs. For the textureless clay view, reconstruct a - // stable face normal from world-position derivatives so the same relief - // lighting also works for those MeshBasicMaterial payloads. - vec3 clayGradient = cross(dFdx(vProjWorld), dFdy(vProjWorld)); - float clayGradientLength = max(length(clayGradient), 1e-5); - vec3 clayNormal = clayGradient / clayGradientLength; - if (!gl_FrontFacing) clayNormal = -clayNormal; - - float claySky = clamp(clayNormal.y * 0.5 + 0.5, 0.0, 1.0); - float clayHemisphere = mix(0.38, 0.72, claySky); - float clayKey = max( - dot(clayNormal, normalize(vec3(-0.45, 0.82, -0.35))), - 0.0 - ) * 0.36; - float clayFill = max( - dot(clayNormal, normalize(vec3(0.55, 0.35, 0.75))), - 0.0 - ) * 0.16; - // Replace either unlit or PBR output with the same predictable clay - // response. This keeps glTF 1 and glTF 2 tiles visually consistent. - outgoingLight = uClayColor * - clamp(clayHemisphere + clayKey + clayFill, 0.3, 1.08); -} -#include ` +uniform sampler2D tProj;` ) .replace( "#include ", @@ -233,13 +209,67 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { material.needsUpdate = true; }; + type ClayMaterialState = { + original: THREE.Material | THREE.Material[]; + clay: THREE.Material | THREE.Material[]; + }; + + const clayMaterialStates = new Map(); + const asMaterialArray = ( + material: THREE.Material | THREE.Material[] + ): THREE.Material[] => (Array.isArray(material) ? material : [material]); + + const buildClayMaterial = (source: THREE.Material) => { + const material = new THREE.MeshStandardMaterial({ + color: clayColor, + roughness: clayRoughness, + metalness: clayMetalness, + side: source.side, + opacity: source.opacity, + transparent: source.transparent, + depthTest: true, + depthWrite: source.depthWrite, + alphaTest: source.alphaTest, + }); + material.name = source.name ? `${source.name} · clay` : "tileset-clay"; + return material; + }; + + const disposeClayState = (mesh: THREE.Mesh, state: ClayMaterialState) => { + mesh.material = state.original; + for (const material of asMaterialArray(state.clay)) material.dispose(); + clayMaterialStates.delete(mesh); + }; + + const restoreClayMaterials = (root: THREE.Object3D) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + const state = mesh.isMesh ? clayMaterialStates.get(mesh) : undefined; + if (state) disposeClayState(mesh, state); + }); + }; + const applyMaterialFlags = (root: THREE.Object3D) => { root.traverse((object) => { const mesh = object as THREE.Mesh; if (!mesh.isMesh) return; - const materials = Array.isArray(mesh.material) - ? mesh.material - : [mesh.material]; + mesh.castShadow = true; + mesh.receiveShadow = true; + let clayState = clayMaterialStates.get(mesh); + if (whiteShading && !clayState) { + const original = mesh.material; + const clay = Array.isArray(original) + ? original.map(buildClayMaterial) + : buildClayMaterial(original); + clayState = { original, clay }; + clayMaterialStates.set(mesh, clayState); + mesh.material = clay; + } else if (!whiteShading && clayState) { + disposeClayState(mesh, clayState); + clayState = undefined; + } + + const materials = asMaterialArray(mesh.material); for (const material of materials) { // The reorientation parent keeps tile coordinates in the same local // meter frame and projection as the point layers. Write that shared @@ -261,27 +291,10 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { (material as THREE.Material & { wireframe: boolean }).wireframe = wireframe; } - const textured = material as THREE.MeshStandardMaterial; - if (whiteShading) { - if (textured.map) { - textured.userData.__originalMap = textured.map; - textured.map = null; - } - if (textured.color && !textured.userData.__originalColor) { - textured.userData.__originalColor = textured.color.clone(); - } - textured.color?.set(CLAY_COLOR); - } else { - if (textured.userData.__originalMap) { - textured.map = textured.userData.__originalMap as THREE.Texture; - delete textured.userData.__originalMap; - } - if (textured.color && textured.userData.__originalColor) { - textured.color.copy( - textured.userData.__originalColor as THREE.Color - ); - delete textured.userData.__originalColor; - } + if (whiteShading && "color" in material) { + (material as THREE.Material & { color: THREE.Color }).color.copy( + clayColor + ); } material.needsUpdate = true; patchMaterialForProjection(material); @@ -304,6 +317,14 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { ); }; const notifyRequestStateChange = () => options.onRequestStateChange?.(); + const handleModelLoad = (event: { scene?: THREE.Object3D }) => { + if (event.scene) applyMaterialFlags(event.scene); + notifyRequestStateChange(); + requestRender(); + }; + const handleModelDispose = (event: { scene?: THREE.Object3D }) => { + if (event.scene) restoreClayMaterials(event.scene); + }; const syncProjector = () => { const projector = activeProjector; if (!projector) { @@ -431,10 +452,11 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { }); }; - const layer: Tiles3dLayer = { + const layer: ThreeTilesRuntime = { id: layerId, originLngLat, root: orientationGroup, + supportsShadows: true, originMerc, mScale, @@ -507,12 +529,8 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { tiles.addEventListener("needs-update", requestRender); tiles.addEventListener("load-tile-set", requestRender); tiles.addEventListener("update-after", handleUpdateAfter); - tiles.addEventListener("load-model", (event) => { - const modelScene = (event as { scene?: THREE.Object3D }).scene; - if (modelScene) applyMaterialFlags(modelScene); - notifyRequestStateChange(); - requestRender(); - }); + tiles.addEventListener("load-model", handleModelLoad); + tiles.addEventListener("dispose-model", handleModelDispose); tiles.addEventListener("load-error", notifyRequestStateChange); tiles.addEventListener("tiles-load-end", notifyRequestStateChange); map.on("movestart", handleViewStart); @@ -520,7 +538,7 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { map.on("resize", handleViewEnd); }, - update(frame: PointcloudSceneFrame) { + update(frame: SharedThreeSceneFrame) { if (!runtimeVisible || !tiles || !map) return; syncProjector(); try { @@ -605,18 +623,35 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { setWhiteShading(white: boolean) { whiteShading = white; - projUniforms.uClayEnabled.value = white ? 1 : 0; applyMaterialFlags(orientationGroup); map?.triggerRepaint(); }, - setClayColor(color: string) { - clayColor.set(color); - projUniforms.uClayColor.value.copy(clayColor); + setClayMaterial(options: ClayMaterialOptions) { + if (options.color !== undefined) clayColor.set(options.color); + if (options.roughness !== undefined) { + clayRoughness = THREE.MathUtils.clamp(options.roughness, 0, 1); + } + if (options.metalness !== undefined) { + clayMetalness = THREE.MathUtils.clamp(options.metalness, 0, 1); + } + for (const state of clayMaterialStates.values()) { + for (const material of asMaterialArray(state.clay)) { + if (!(material instanceof THREE.MeshStandardMaterial)) continue; + material.color.copy(clayColor); + material.roughness = clayRoughness; + material.metalness = clayMetalness; + material.needsUpdate = true; + } + } applyMaterialFlags(orientationGroup); map?.triggerRepaint(); }, + setClayColor(color: string) { + layer.setClayMaterial({ color }); + }, + setOpacity(nextOpacity: number) { opacity = THREE.MathUtils.clamp(nextOpacity, 0, 1); applyMaterialFlags(orientationGroup); @@ -672,8 +707,11 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { tiles?.removeEventListener("needs-update", requestRender); tiles?.removeEventListener("load-tile-set", requestRender); tiles?.removeEventListener("update-after", handleUpdateAfter); + tiles?.removeEventListener("load-model", handleModelLoad); + tiles?.removeEventListener("dispose-model", handleModelDispose); cameraSet?.dispose(); cameraSet = null; + restoreClayMaterials(orientationGroup); tiles?.dispose(); tiles = null; debugTilesPlugin = null; diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles-camera-set.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.ts similarity index 100% rename from playgrounds/ng-topicmap-playground/src/app/pointcloud/tiles-camera-set.ts rename to libraries/mapping/engines/maplibre/src/lib/runtime/integrations/tiles-camera-set.ts diff --git a/libraries/mapping/layers/src/hooks/useHandleDrop.ts b/libraries/mapping/layers/src/hooks/useHandleDrop.ts index aa473671c2..fcae7b7ff6 100644 --- a/libraries/mapping/layers/src/hooks/useHandleDrop.ts +++ b/libraries/mapping/layers/src/hooks/useHandleDrop.ts @@ -156,7 +156,7 @@ export const useHandleDrop = ({ .then((data) => { if (data.metadata && data.metadata.carmaConf.layerInfo) { const layerInfo = data.metadata.carmaConf.layerInfo; - instant = instant || (data.metaData?.carmaConf?.instant ?? false); + instant = instant || (data.metadata?.carmaConf?.instant ?? false); newItem = { ...newItem, id: importedId, @@ -239,7 +239,7 @@ export const useHandleDrop = ({ .then((data) => { if (data.metadata && data.metadata.carmaConf.layerInfo) { const layerInfo = data.metadata.carmaConf.layerInfo; - instant = data.metaData?.carmaConf?.instant ?? false; + instant = data.metadata?.carmaConf?.instant ?? false; newItem = { ...newItem, ...layerInfo, diff --git a/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx b/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx index 61790d1a27..12765a2beb 100644 --- a/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx +++ b/playgrounds/ng-topicmap-playground/src/app/pointcloud/PointCloudPlayground.tsx @@ -13,17 +13,35 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Icon from "react-cismap/commons/Icon"; -import { Button, Checkbox, InputNumber, Radio, Select, Slider, Switch, Tabs } from "antd"; +import { + Button, + Checkbox, + InputNumber, + Radio, + Select, + Slider, + Switch, + Tabs, +} from "antd"; import { MercatorCoordinate } from "maplibre-gl"; import type { Map as MaplibreMap } from "maplibre-gl"; import { CarmaMap } from "@carma-mapping/core"; import { useHashState } from "@carma-providers/hash-state"; import { + buildSharedThreeSceneLayer, + buildThreeTilesRuntime, slugifyUrl, + TILES_ERROR_TARGET_DEFAULT_PIXELS, + TILES_ERROR_TARGET_MAX_PIXELS, + TILES_ERROR_TARGET_MIN_PIXELS, useLibreContext, WUPPERTAL_CONFIG, } from "@carma-mapping/engines/maplibre"; +import type { + ImageProjector, + ThreeTilesRuntime, +} from "@carma-mapping/engines/maplibre"; import { WUPP_LOD2_TILESET, @@ -46,13 +64,6 @@ import { utmToScene, } from "./orientedImagery"; import type { OrientedImageryLayer } from "./orientedImagery"; -import { - buildTiles3dLayer, - TILES_ERROR_TARGET_DEFAULT_PIXELS, - TILES_ERROR_TARGET_MAX_PIXELS, - TILES_ERROR_TARGET_MIN_PIXELS, -} from "./tiles3dLayer"; -import type { ImageProjector, Tiles3dLayer } from "./tiles3dLayer"; import * as THREE from "three"; import { buildCloudFieldInfos, openCopcPointSource } from "./copcLoader"; import type { @@ -108,7 +119,6 @@ import { parsePointCloudMicroCorrections, } from "./pointCloudMicroCorrections"; import type { PointCloudMicroCorrection } from "./pointCloudMicroCorrections"; -import { buildPointcloudSceneLayer } from "./pointcloudSceneLayer"; import { mergePersistedSettings, readPointcloudViewState, @@ -291,7 +301,8 @@ for (const feature of POINT_CLOUD_PRESET_FEATURE_COLLECTION.features) { continue; } const base = CLOUD_ASSETS.find( - (asset) => asset.artifactFileName && pointcloud.fields === asset.fieldDimensions + (asset) => + asset.artifactFileName && pointcloud.fields === asset.fieldDimensions ); CLOUD_ASSETS.push({ ...(base ?? CLOUD_ASSETS[0]), @@ -393,7 +404,7 @@ interface MeshSettings { enabled: boolean; zOffset: number; white: boolean; - clayColor: string; + clayColor: string; errorTarget: number; opacity: number; wireframe: boolean; @@ -468,7 +479,7 @@ const defaultMeshSettings = (definition?: MeshAssetDef): MeshSettings => ({ enabled: false, zOffset: 0, white: definition?.defaultClay ?? false, - clayColor: "#d6d2ca", + clayColor: "#d6d2ca", errorTarget: TILES_ERROR_TARGET_DEFAULT_PIXELS, opacity: 1, wireframe: false, @@ -805,10 +816,10 @@ const SceneManager = memo(function SceneManager({ }) { const { map } = useLibreContext(); const cloudSlotsRef = useRef>(new Map()); - const meshLayersRef = useRef>(new Map()); + const meshLayersRef = useRef>(new Map()); const terrainActiveRef = useRef(false); const sharedSceneLayer = useMemo( - () => (map ? buildPointcloudSceneLayer("pointcloud-three-scene") : null), + () => (map ? buildSharedThreeSceneLayer("pointcloud-three-scene") : null), [map] ); const onApiRef = useRef(onApi); @@ -823,9 +834,9 @@ const SceneManager = memo(function SceneManager({ new Map() ); - const activeCloudIds = cloudAssets.filter( - (def) => cloudSettings[def.id]?.enabled - ).map((def) => def.id); + const activeCloudIds = cloudAssets + .filter((def) => cloudSettings[def.id]?.enabled) + .map((def) => def.id); const activeMeshIds = MESH_ASSETS.filter( (def) => meshSettings[def.id]?.enabled ).map((def) => def.id); @@ -1758,11 +1769,11 @@ const SceneManager = memo(function SceneManager({ Math.floor(pointCapacity / Math.max(1, active.size)) ) ); - layer.setMinimumSpacingPixels( - cameraMovingRef.current - ? CAMERA_MOVE_MINIMUM_SPACING_PIXELS - : NORMAL_MINIMUM_SPACING_PIXELS - ); + layer.setMinimumSpacingPixels( + cameraMovingRef.current + ? CAMERA_MOVE_MINIMUM_SPACING_PIXELS + : NORMAL_MINIMUM_SPACING_PIXELS + ); layer.setFrustumNodeSource(source.nodes, reconcileFrustumNodes); applyCloudSettings(slot); scheduleSceneRequestAllocation(); @@ -1847,7 +1858,7 @@ const SceneManager = memo(function SceneManager({ for (const def of MESH_ASSETS) { if (!active.has(def.id) || layers.has(def.id)) continue; const center = map.getCenter(); - const tilesLayer = buildTiles3dLayer( + const tilesLayer = buildThreeTilesRuntime( `tiles3d-${def.id}`, def.url, [center.lng, center.lat], @@ -2232,10 +2243,7 @@ const buildAnchorMarker = (): THREE.Group => { return group; }; -const updateAnchorMarker = ( - slot: CloudSlot, - settings: CloudSettings -): void => { +const updateAnchorMarker = (slot: CloudSlot, settings: CloudSettings): void => { if (!slot.layer || !slot.meta) { disposeAnchorMarker(slot); return; @@ -2250,7 +2258,10 @@ const updateAnchorMarker = ( const originMerc = MercatorCoordinate.fromLngLat([lng, lat], 0); const meterScale = originMerc.meterInMercatorCoordinateUnits(); const merc = MercatorCoordinate.fromLngLat( - getFromUTM32ToWGS84([resolvedAnchor.easting, resolvedAnchor.northing]) as [number, number], + getFromUTM32ToWGS84([resolvedAnchor.easting, resolvedAnchor.northing]) as [ + number, + number + ], resolvedAnchor.height - slot.meta.zBase ); const localAnchor = [ @@ -2380,12 +2391,16 @@ export function PointCloudPlayground({ const { map } = useLibreContext(); const { getHashStateValues, updateHashState } = useHashState(); const hashView = getHashStateValues(); - const { addFeature, features: adhocFeatures, removeFeature } = - useAdhocFeatureDisplay(); - const [importedCloudAssets, setImportedCloudAssets] = useState( - [] - ); - const [adhocPointCloudsHydrated, setAdhocPointCloudsHydrated] = useState(false); + const { + addFeature, + features: adhocFeatures, + removeFeature, + } = useAdhocFeatureDisplay(); + const [importedCloudAssets, setImportedCloudAssets] = useState< + CloudAssetDef[] + >([]); + const [adhocPointCloudsHydrated, setAdhocPointCloudsHydrated] = + useState(false); useEffect(() => { try { const raw = localStorage.getItem(ADHOC_POINTCLOUD_STORAGE_KEY); @@ -2403,14 +2418,20 @@ export function PointCloudPlayground({ const asset: CloudAssetDef = { format: config.format, id: feature.id, - label: feature.metadata?.title ?? config.url.split("/").pop() ?? feature.id, + label: + feature.metadata?.title ?? + config.url.split("/").pop() ?? + feature.id, artifactFileName: config.url, sourceTag: "Import", acquiredOn: null, fieldDimensions: config.fields ?? [], hasRgb: config.hasRgb ?? false, runtimeEnabled: true, - defaultDatum: config.source?.verticalDatum === "ellipsoidal" ? "ellipsoidal" : "dhhn", + defaultDatum: + config.source?.verticalDatum === "ellipsoidal" + ? "ellipsoidal" + : "dhhn", source: config.source, transform: config.transform, url: config.url, @@ -2421,7 +2442,10 @@ export function PointCloudPlayground({ setCloudSettings((current) => ({ ...current, ...Object.fromEntries( - restoredAssets.map((asset) => [asset.id, defaultCloudSettings(asset, true)]) + restoredAssets.map((asset) => [ + asset.id, + defaultCloudSettings(asset, true), + ]) ), })); parsed.features.forEach((feature) => @@ -2434,7 +2458,10 @@ export function PointCloudPlayground({ ); } } catch (error) { - console.warn("Gespeicherte Pointcloud-Imports konnten nicht geladen werden.", error); + console.warn( + "Gespeicherte Pointcloud-Imports konnten nicht geladen werden.", + error + ); } finally { setAdhocPointCloudsHydrated(true); } @@ -2455,14 +2482,20 @@ export function PointCloudPlayground({ ); } } catch (error) { - console.warn("Pointcloud-Imports konnten nicht gespeichert werden.", error); + console.warn( + "Pointcloud-Imports konnten nicht gespeichert werden.", + error + ); } }, [adhocFeatures, adhocPointCloudsHydrated]); const handlePointCloudDrop = useCallback( async (event: React.DragEvent) => { event.preventDefault(); const file = event.dataTransfer.files[0]; - if (!file || (!file.name.endsWith(".json") && !file.name.endsWith(".geojson"))) { + if ( + !file || + (!file.name.endsWith(".json") && !file.name.endsWith(".geojson")) + ) { console.warn("Bitte eine Pointcloud-GeoJSON-Datei ablegen."); return; } @@ -2473,7 +2506,9 @@ export function PointCloudPlayground({ ...parsed.features.map((feature) => { const config = pointCloudFeatureToConfig(feature); const label = - feature.metadata?.title ?? config.url.split("/").pop() ?? feature.id; + feature.metadata?.title ?? + config.url.split("/").pop() ?? + feature.id; const nextAsset: CloudAssetDef = { format: config.format, id: feature.id, @@ -2660,7 +2695,9 @@ export function PointCloudPlayground({ const [sceneApi, setSceneApi] = useState(null); const [cloudOptionsIds, setCloudOptionsIds] = useState([]); const [meshOptionsIds, setMeshOptionsIds] = useState([]); - const [cloudDetailsOpen, setCloudDetailsOpen] = useState>({}); + const [cloudDetailsOpen, setCloudDetailsOpen] = useState< + Record + >({}); /** Cloud whose colorizer floats as a draggable expert panel */ const [colorizerCloudId, setColorizerCloudId] = useState(null); useEffect(() => { @@ -2988,19 +3025,19 @@ export function PointCloudPlayground({
{state.error}
)} {!isTilesetDelivery && ( - - - {(() => { - const source = settings.colorization.layers[0].source; - return source - ? formatColorizerSourceLabel(source) - : "nicht konfiguriert"; - })()} - - - + + + {(() => { + const source = settings.colorization.layers[0].source; + return source + ? formatColorizerSourceLabel(source) + : "nicht konfiguriert"; + })()} + + + )} {!isTilesetDelivery && ( @@ -3089,22 +3126,22 @@ export function PointCloudPlayground({ )} {!isTilesetDelivery && ( - - - patchCloud(def.id, { shape: event.target.value }) - } - options={[ - { value: POINT_SHAPES.SQUARE, label: "Quadrat" }, - { value: POINT_SHAPES.CIRCLE, label: "Kreis" }, - { value: POINT_SHAPES.DOME, label: "Kugel" }, - { value: POINT_SHAPES.SOFT_SPLAT, label: "Gradient" }, - ]} - /> - + + + patchCloud(def.id, { shape: event.target.value }) + } + options={[ + { value: POINT_SHAPES.SQUARE, label: "Quadrat" }, + { value: POINT_SHAPES.CIRCLE, label: "Kreis" }, + { value: POINT_SHAPES.DOME, label: "Kugel" }, + { value: POINT_SHAPES.SOFT_SPLAT, label: "Gradient" }, + ]} + /> + )}
{settings.nodeBoundsVisible && state && (
- Knoten: {state.visibleNodes} im Sichtfeld · {state.renderedNodes} gerendert · {state.loadedNodes} geladen + Knoten: {state.visibleNodes} im Sichtfeld ·{" "} + {state.renderedNodes} gerendert · {state.loadedNodes}{" "} + geladen
)} - - - patchMesh(def.id, { clayColor: event.target.value }) - } - className="h-7 w-12 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" - /> - + + + patchMesh(def.id, { clayColor: event.target.value }) + } + className="h-7 w-12 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" + /> + cloudSettings[def.id]?.enabled - ).length - }/${cloudAssets.length})`, - children: ( -
- {cloudItems.map((item) => ( -
{item.label}
- ))} -
- ), - }, - { - key: "meshes", - label: `3D-Meshes (${ - MESH_ASSETS.filter( - (def) => meshSettings[def.id]?.enabled - ).length - }/${MESH_ASSETS.length})`, - children: ( -
- {meshItems.map((item) => ( -
{item.label}
- ))} -
- - - - ALKIS-Extrusion via Gelände - - -
-
- ), - }, - { - key: "scene", - label: "Szene", - children: ( -
- + { + key: "pointclouds", + label: `Punktwolken (${ + cloudAssets.filter( + (def) => cloudSettings[def.id]?.enabled + ).length + }/${cloudAssets.length})`, + children: ( +
+ {cloudItems.map((item) => ( +
{item.label}
+ ))} +
+ ), + }, + { + key: "meshes", + label: `3D-Meshes (${ + MESH_ASSETS.filter( + (def) => meshSettings[def.id]?.enabled + ).length + }/${MESH_ASSETS.length})`, + children: ( +
+ {meshItems.map((item) => ( +
{item.label}
+ ))} +
+ - {pitchLimiterEnabled ? "60°" : "frei"} + ALKIS-Extrusion via Gelände - -
- `${value ?? 0} FPS`, +
+
+ ), + }, + { + key: "scene", + label: "Szene", + children: ( +
+ + + + {pitchLimiterEnabled ? "60°" : "frei"} + + + +
+ `${value ?? 0} FPS`, + }} + /> +
+ + {targetFrameRate} FPS + +
+ +
+ {( + [ + ["white", "Weiß"], + ["black", "Schwarz"], + ["gray50", "50 % Grau"], + ] as const + ).map(([preset, label]) => ( +
- - {targetFrameRate} FPS - -
- -
- {( - [ - ["white", "Weiß"], - ["black", "Schwarz"], - ["gray50", "50 % Grau"], - ] as const - ).map(([preset, label]) => ( -
-
- - - - - { + setSceneBackground("gray50"); + setSceneBackgroundColor(event.target.value); + }} + className="size-7 cursor-pointer rounded border border-gray-300 bg-transparent p-0.5" /> - {!terrainActive && ( - - Gelände über den Berg-Button links - - )} - -
- ), - }, - ]} - /> -
- )} +
+
+ + + + + + setState({ + ...state, + terrainColor: event.currentTarget.value, + }) + } + className="h-7 w-10 cursor-pointer rounded border border-slate-300 bg-transparent p-0.5" + aria-label="Terrainfarbe" + data-test-id="shadow-simulation-terrain-color" + /> + + {terrainColor.toUpperCase()} + + + )}
); }; @@ -81,12 +119,14 @@ const ShadowSimulationSettings = ({ const ShadowSimulationRuntime = ({ libreMap, shadowAreaMeters, + terrain, location, state, available, }: { libreMap: AddonComponentProps<"shadowSimulation">["libreMap"]; shadowAreaMeters?: number; + terrain?: ShadowTerrainOptions; location: SolarLocation; state: ShadowSimulationState; available: boolean; @@ -99,18 +139,27 @@ const ShadowSimulationRuntime = ({ useEffect(() => { if (!libreMap || !state.enabled || !available) return; - const scene = buildShadowSimulationScene(libreMap, { shadowAreaMeters }); + const scene = buildShadowSimulationScene(libreMap, { + shadowAreaMeters, + terrain, + }); shadowScene.current = scene; return () => { shadowScene.current = null; scene.dispose(); }; - }, [available, libreMap, shadowAreaMeters, state.enabled]); + }, [available, libreMap, shadowAreaMeters, state.enabled, terrain]); useEffect(() => { shadowScene.current?.updateSolarPosition(solarPosition); }, [solarPosition]); + useEffect(() => { + shadowScene.current?.updateTerrainColor( + state.terrainColor ?? DEFAULT_TERRAIN_COLOR + ); + }, [state.terrainColor]); + return null; }; @@ -123,10 +172,11 @@ export const ShadowSimulation = ({ year, initialDayOfYear, initialMinutes, - latitude = DEFAULT_LOCATION.latitude, - longitude = DEFAULT_LOCATION.longitude, - timeZone = DEFAULT_LOCATION.timeZone, + latitude = DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, + longitude = DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, + timeZone = DEFAULT_SHADOW_SIMULATION_LOCATION.timeZone, shadowAreaMeters, + terrain, controlPosition = "topleft", controlOrder = 70, } = config ?? {}; @@ -143,12 +193,13 @@ export const ShadowSimulation = ({ }; return { enabled: false, + terrainColor: resolveTerrainColor(terrain?.material?.color), selection: clampSelectionToDaylight(candidate, location) ?? { ...candidate, minutes: 12 * 60, }, }; - }, [initialDayOfYear, initialMinutes, location, timeZone, year]); + }, [initialDayOfYear, initialMinutes, location, terrain, timeZone, year]); const [sharedState, setSharedState] = useAddonState("shadowSimulation"); const state = sharedState ?? initialState; const shadowAvailable = useSyncExternalStore( @@ -173,6 +224,7 @@ export const ShadowSimulation = ({ location={location} state={state} setState={setSharedState} + showTerrainColor={!!terrain} /> ); } @@ -220,6 +272,7 @@ export const ShadowSimulation = ({ ({ acquireSharedThreeScene: vi.fn(), + buildCesiumTerrainRuntime: vi.fn(), getGenericThreeLayers: vi.fn(() => []), subscribeGenericThreeLayers: vi.fn(() => vi.fn()), + suppressMapLibreTerrainRendering: vi.fn(() => vi.fn()), })); import { acquireSharedThreeScene, + buildCesiumTerrainRuntime, getGenericThreeLayers, subscribeGenericThreeLayers, + suppressMapLibreTerrainRendering, } from "@carma-mapping/engines/maplibre"; import { buildShadowSimulationScene, solarPositionToSceneDirection, } from "./shadow-scene"; +import { getDaylightWindow, getSolarPosition } from "./solar-position"; describe("shadow scene sun direction", () => { const position = (azimuthDegrees: number, elevationDegrees: number) => ({ @@ -39,19 +44,72 @@ describe("shadow scene sun direction", () => { expect(solarPositionToSceneDirection(position(90, 0)).x).toBeCloseTo(1); expect(solarPositionToSceneDirection(position(180, 90)).y).toBeCloseTo(1); }); + + it("maps Berlin civil time at Wuppertal into the local tangent plane", () => { + const location = { + latitude: 51.256, + longitude: 7.15, + timeZone: "Europe/Berlin", + }; + const daylight = getDaylightWindow(2026, 172, location); + const solarPosition = getSolarPosition( + { + year: 2026, + dayOfYear: 172, + minutes: daylight.solarNoonMinutes, + }, + location + ); + const direction = solarPositionToSceneDirection(solarPosition); + + expect(solarPosition.instant.toISOString()).toContain("T11:"); + expect(direction.y).toBeGreaterThan(0.85); + expect(direction.z).toBeGreaterThan(0); + expect(Math.abs(direction.x)).toBeLessThan(0.05); + }); }); describe("shadow scene lighting integration", () => { const releaseScene = vi.fn(); let scene: THREE.Scene; + let sharedRuntimes: Map< + string, + { root: THREE.Object3D; dispose: () => void } + >; + let sharedLayer: { + getScene: () => THREE.Scene; + addRuntime: ReturnType; + hasRuntime: ReturnType; + removeRuntime: ReturnType; + projectLngLatToScene?: ( + lngLat: [number, number], + altitude?: number + ) => THREE.Vector3; + }; beforeEach(() => { vi.clearAllMocks(); scene = new THREE.Scene(); + sharedRuntimes = new Map(); + sharedLayer = { + getScene: () => scene, + addRuntime: vi.fn((runtime) => { + sharedRuntimes.set(runtime.id, runtime); + scene.add(runtime.root); + }), + hasRuntime: vi.fn((runtimeId) => sharedRuntimes.has(runtimeId)), + removeRuntime: vi.fn((runtimeId) => { + const runtime = sharedRuntimes.get(runtimeId); + if (!runtime) return; + scene.remove(runtime.root); + runtime.dispose(); + sharedRuntimes.delete(runtimeId); + }), + }; vi.mocked(getGenericThreeLayers).mockReturnValue([]); vi.mocked(subscribeGenericThreeLayers).mockReturnValue(vi.fn()); vi.mocked(acquireSharedThreeScene).mockReturnValue({ - layer: { getScene: () => scene } as never, + layer: sharedLayer as never, release: releaseScene, }); }); @@ -59,6 +117,7 @@ describe("shadow scene lighting integration", () => { it("drives MapLibre and the Three.js sun from the same solar position", () => { const setLight = vi.fn(); const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), getLight: vi.fn(() => ({ anchor: "viewport" })), isStyleLoaded: vi.fn(() => true), setLight, @@ -95,20 +154,29 @@ describe("shadow scene lighting integration", () => { expect(releaseScene).toHaveBeenCalledOnce(); }); - it("enables and restores shadows for registered ALKIS Three.js layers", () => { + it("moves ALKIS buildings into the shared terrain shadow scene", () => { + const terrain = new THREE.Mesh( + new THREE.PlaneGeometry(100, 100), + new THREE.MeshLambertMaterial() + ); + terrain.name = "terrain"; + scene.add(terrain); const alkisScene = new THREE.Scene(); const building = new THREE.Mesh( new THREE.BoxGeometry(10, 20, 10), new THREE.MeshLambertMaterial() ); + building.name = "alkis-building"; alkisScene.add(building); - const renderer = { - shadowMap: { enabled: false, type: THREE.BasicShadowMap }, - }; vi.mocked(getGenericThreeLayers).mockReturnValue([ - { scene: alkisScene, renderer } as never, + { + id: "3d-extrusion-alkis", + scene: alkisScene, + _originMerc: { toLngLat: () => ({ lng: 7.15, lat: 51.256 }) }, + } as never, ]); const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), getLight: vi.fn(() => ({ anchor: "viewport" })), isStyleLoaded: vi.fn(() => true), setLight: vi.fn(), @@ -124,15 +192,117 @@ describe("shadow scene lighting integration", () => { elevationDegrees: 45, }); - expect(renderer.shadowMap.enabled).toBe(true); - expect(renderer.shadowMap.type).toBe(THREE.PCFSoftShadowMap); - expect(building.castShadow).toBe(true); - expect(building.receiveShadow).toBe(true); - expect(alkisScene.getObjectByName("shadow-simulation-sun")).toBeDefined(); + const buildingCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + expect(building.visible).toBe(false); + expect(buildingCopy.castShadow).toBe(true); + expect(buildingCopy.receiveShadow).toBe(true); + expect(terrain.castShadow).toBe(true); + expect(terrain.receiveShadow).toBe(true); + expect(buildingCopy.parent?.parent).toBe(scene); + expect(terrain.parent).toBe(scene); + + controller.dispose(); + expect(building.visible).toBe(true); + expect(scene.getObjectByName(buildingCopy.name)).toBeUndefined(); + }); + + it("keeps the full map viewport inside the shadow camera", () => { + sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => + new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); + const map = { + getCenter: vi.fn(() => ({ lng: 0, lat: 0 })), + getBounds: vi.fn(() => ({ + getWest: () => -1, + getSouth: () => -2, + getEast: () => 1, + getNorth: () => 2, + })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + const sun = scene.getObjectByName( + "shadow-simulation-sun" + ) as THREE.DirectionalLight; + const cornerRadius = Math.hypot(1_000, 2_000); + + expect(sun.shadow.camera.right).toBeGreaterThan(cornerRadius); + expect(sun.shadow.camera.top).toBeGreaterThan(cornerRadius); + expect(map.on).toHaveBeenCalledWith("move", expect.any(Function)); + + controller.dispose(); + }); + + it("adds configured Cesium terrain to the shared scene", async () => { + const restoreTerrain = vi.fn(); + vi.mocked(suppressMapLibreTerrainRendering).mockReturnValue(restoreTerrain); + const terrainRoot = new THREE.Group(); + const terrainRuntime = { + id: "terrain", + originLngLat: [7.15, 51.256] as [number, number], + root: terrainRoot, + supportsShadows: true, + ready: Promise.resolve(true), + update: vi.fn(), + setVisible: vi.fn(), + setShadowCamera: vi.fn(), + setMaterialColor: vi.fn(), + getElevation: vi.fn(() => 150), + dispose: vi.fn(), + }; + vi.mocked(buildCesiumTerrainRuntime).mockReturnValue(terrainRuntime); + const addRuntime = vi.fn(); + const removeRuntime = vi.fn(); + vi.mocked(acquireSharedThreeScene).mockReturnValue({ + layer: { + getScene: () => scene, + addRuntime, + hasRuntime: vi.fn(() => true), + removeRuntime, + } as never, + release: releaseScene, + }); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never, { + shadowAreaMeters: 600, + terrain: { + url: "https://example.test/terrain", + minimumLevel: 10, + maximumLevel: 16, + }, + }); + await terrainRuntime.ready; + + expect(buildCesiumTerrainRuntime).toHaveBeenCalledWith( + "shadow-simulation-cesium-terrain", + "https://example.test/terrain", + [7.15, 51.256], + expect.objectContaining({ minimumLevel: 10, maximumLevel: 16 }) + ); + expect(addRuntime).toHaveBeenCalledWith(terrainRuntime); + expect(suppressMapLibreTerrainRendering).toHaveBeenCalledWith(map); + + controller.updateTerrainColor("#8c7a66"); + expect(terrainRuntime.setMaterialColor).toHaveBeenCalledWith("#8c7a66"); controller.dispose(); - expect(renderer.shadowMap.enabled).toBe(false); - expect(renderer.shadowMap.type).toBe(THREE.BasicShadowMap); - expect(alkisScene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); + expect(restoreTerrain).toHaveBeenCalledOnce(); + expect(removeRuntime).toHaveBeenCalledWith("terrain"); }); }); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index ed6dfbb712..832ed86d8d 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -3,8 +3,15 @@ import * as THREE from "three"; import { acquireSharedThreeScene, + buildCesiumTerrainRuntime, getGenericThreeLayers, subscribeGenericThreeLayers, + suppressMapLibreTerrainRendering, +} from "@carma-mapping/engines/maplibre"; +import type { + CesiumTerrainRuntimeOptions, + SharedThreeSceneLayer, + SharedThreeSceneRuntime, } from "@carma-mapping/engines/maplibre"; import type { SolarPosition } from "./solar-position"; @@ -12,6 +19,7 @@ import type { SolarPosition } from "./solar-position"; const DEFAULT_SHADOW_AREA_METERS = 900; const DEFAULT_LIGHT_DISTANCE_METERS = 2_500; const SHADOW_SIMULATION_SUN_NAME = "shadow-simulation-sun"; +const SHADOW_SIMULATION_TERRAIN_RUNTIME_ID = "shadow-simulation-cesium-terrain"; type GenericThreeLayer = ReturnType[number]; @@ -20,17 +28,25 @@ type ShadowLightBinding = { sunLight: THREE.DirectionalLight; lightTarget: THREE.Object3D; center: THREE.Vector3; - renderer?: THREE.WebGLRenderer; - previousShadowMapEnabled?: boolean; - previousShadowMapType?: THREE.ShadowMapType; + lightDistanceMeters: number; +}; + +type GenericThreeShadowBridge = { + runtime: SharedThreeSceneRuntime; + sync: () => void; }; export type ShadowSceneOptions = { shadowAreaMeters?: number; + terrain?: ShadowTerrainOptions; }; +export type ShadowTerrainOptions = Readonly<{ url: string }> & + Omit; + export type ShadowSimulationScene = { updateSolarPosition: (position: SolarPosition) => void; + updateTerrainColor: (color: string) => void; dispose: () => void; }; @@ -64,9 +80,14 @@ const configureShadowCamera = ( shadowCamera.right = halfShadowArea; shadowCamera.top = halfShadowArea; shadowCamera.bottom = -halfShadowArea; - shadowCamera.near = 10; - shadowCamera.far = DEFAULT_LIGHT_DISTANCE_METERS * 2; + const lightDistanceMeters = Math.max( + DEFAULT_LIGHT_DISTANCE_METERS, + shadowAreaMeters * 1.5 + ); + shadowCamera.near = 1; + shadowCamera.far = lightDistanceMeters * 2 + shadowAreaMeters; shadowCamera.updateProjectionMatrix(); + return lightDistanceMeters; }; const makeSceneMeshesShadeable = (scene: THREE.Scene) => { @@ -78,6 +99,92 @@ const makeSceneMeshesShadeable = (scene: THREE.Scene) => { }); }; +const materialIsVisible = (material: THREE.Material): boolean => + material.visible && material.opacity > 0; + +const meshIsVisible = (mesh: THREE.Mesh, scene: THREE.Scene): boolean => { + let current: THREE.Object3D | null = mesh; + while (current && current !== scene) { + if (!current.visible) return false; + current = current.parent; + } + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + return materials.some(materialIsVisible); +}; + +const buildGenericThreeShadowBridge = ( + sharedLayer: SharedThreeSceneLayer, + layer: GenericThreeLayer +): GenericThreeShadowBridge | null => { + const origin = layer._originMerc?.toLngLat(); + if (!origin) return null; + + const root = new THREE.Group(); + root.name = `shadow-simulation-copy-${layer.id}`; + const originalVisibility = new Map(); + let disposed = false; + + const restoreOriginals = () => { + for (const [object, visible] of originalVisibility) { + object.visible = visible; + } + originalVisibility.clear(); + }; + + const sync = () => { + if (disposed) return; + restoreOriginals(); + root.clear(); + layer.scene.updateMatrixWorld(true); + const sourceMeshes: THREE.Mesh[] = []; + layer.scene.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) { + return; + } + if (!mesh.geometry?.getAttribute("position")?.count) return; + if (!meshIsVisible(mesh, layer.scene)) return; + sourceMeshes.push(mesh); + }); + for (const source of sourceMeshes) { + const copy = source.clone(false) as THREE.Mesh; + copy.name = `${source.name || "mesh"}-shadow-simulation-copy`; + copy.castShadow = true; + copy.receiveShadow = true; + copy.visible = true; + copy.matrixAutoUpdate = false; + copy.matrix.copy(source.matrixWorld); + originalVisibility.set(source, source.visible); + source.visible = false; + root.add(copy); + } + root.visible = root.children.length > 0; + }; + + const runtime: SharedThreeSceneRuntime = { + id: `shadow-simulation-generic-${layer.id}`, + originLngLat: [origin.lng, origin.lat], + root, + supportsShadows: true, + update: () => undefined, + dispose: () => { + if (disposed) return; + disposed = true; + restoreOriginals(); + root.clear(); + }, + }; + sync(); + if (!root.visible) { + runtime.dispose(); + return null; + } + sharedLayer.addRuntime(runtime); + return { runtime, sync }; +}; + const updateBindingCenter = (binding: ShadowLightBinding) => { const bounds = new THREE.Box3().setFromObject(binding.scene); if (bounds.isEmpty()) binding.center.set(0, 0, 0); @@ -86,38 +193,25 @@ const updateBindingCenter = (binding: ShadowLightBinding) => { const buildShadowLightBinding = ( scene: THREE.Scene, - shadowAreaMeters: number, - renderer?: THREE.WebGLRenderer + shadowAreaMeters: number ): ShadowLightBinding => { const lightTarget = new THREE.Object3D(); const sunLight = new THREE.DirectionalLight(0xfff2d8, 2.5); sunLight.name = SHADOW_SIMULATION_SUN_NAME; sunLight.target = lightTarget; - configureShadowCamera(sunLight, shadowAreaMeters); const binding: ShadowLightBinding = { scene, sunLight, lightTarget, center: new THREE.Vector3(), - renderer, - previousShadowMapEnabled: renderer?.shadowMap.enabled, - previousShadowMapType: renderer?.shadowMap.type, + lightDistanceMeters: configureShadowCamera(sunLight, shadowAreaMeters), }; - if (renderer) { - renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; - } scene.add(lightTarget, sunLight); makeSceneMeshesShadeable(scene); updateBindingCenter(binding); return binding; }; -const refreshShadowLightBinding = (binding: ShadowLightBinding) => { - makeSceneMeshesShadeable(binding.scene); - updateBindingCenter(binding); -}; - const applySolarPositionToBinding = ( binding: ShadowLightBinding, direction: THREE.Vector3 @@ -125,76 +219,126 @@ const applySolarPositionToBinding = ( binding.lightTarget.position.copy(binding.center); binding.sunLight.position .copy(direction) - .multiplyScalar(DEFAULT_LIGHT_DISTANCE_METERS) + .multiplyScalar(binding.lightDistanceMeters) .add(binding.center); const daylightStrength = THREE.MathUtils.clamp(direction.y, 0, 1); binding.sunLight.intensity = 1.2 + Math.sqrt(daylightStrength) * 2.2; + binding.lightTarget.updateMatrixWorld(true); + binding.sunLight.updateMatrixWorld(true); + binding.sunLight.shadow.updateMatrices(binding.sunLight); binding.sunLight.shadow.needsUpdate = true; }; const disposeShadowLightBinding = (binding: ShadowLightBinding) => { binding.scene.remove(binding.sunLight, binding.lightTarget); - if (binding.renderer) { - binding.renderer.shadowMap.enabled = - binding.previousShadowMapEnabled ?? false; - if (binding.previousShadowMapType != null) { - binding.renderer.shadowMap.type = binding.previousShadowMapType; - } - } }; export const buildShadowSimulationScene = ( map: MaplibreMap, options: ShadowSceneOptions = {} ): ShadowSimulationScene => { - const { shadowAreaMeters = DEFAULT_SHADOW_AREA_METERS } = options; + const { shadowAreaMeters = DEFAULT_SHADOW_AREA_METERS, terrain } = options; const previousLight = map.getLight(); + let latestSolarPosition: SolarPosition | null = null; + let disposed = false; + let restoreMapLibreTerrain: (() => void) | null = null; const sceneLease = acquireSharedThreeScene(map); + const terrainRuntime = terrain + ? (() => { + const mapCenter = map.getCenter(); + const { url, ...runtimeOptions } = terrain; + return buildCesiumTerrainRuntime( + SHADOW_SIMULATION_TERRAIN_RUNTIME_ID, + url, + [mapCenter.lng, mapCenter.lat], + runtimeOptions + ); + })() + : null; + if (terrainRuntime) sceneLease.layer.addRuntime(terrainRuntime); const sharedBinding = buildShadowLightBinding( sceneLease.layer.getScene(), shadowAreaMeters ); - const genericBindings = new Map(); + const genericBridges = new Map(); - let latestSolarPosition: SolarPosition | null = null; - let disposed = false; + const updateSharedShadowCoverage = () => { + const mapCenter = map.getCenter(); + const center = sceneLease.layer.projectLngLatToScene?.( + [mapCenter.lng, mapCenter.lat], + terrainRuntime?.getElevation(mapCenter.lng, mapCenter.lat) ?? 0 + ); + if (!center) { + terrainRuntime?.setShadowCamera(sharedBinding.sunLight.shadow.camera); + return; + } + sharedBinding.center.copy(center); + const bounds = map.getBounds(); + let radiusMeters = 0; + for (const lngLat of [ + [bounds.getWest(), bounds.getSouth()], + [bounds.getWest(), bounds.getNorth()], + [bounds.getEast(), bounds.getSouth()], + [bounds.getEast(), bounds.getNorth()], + ] as [number, number][]) { + const corner = sceneLease.layer.projectLngLatToScene?.(lngLat); + if (corner) + radiusMeters = Math.max(radiusMeters, corner.distanceTo(center)); + } + sharedBinding.lightDistanceMeters = configureShadowCamera( + sharedBinding.sunLight, + Math.max(shadowAreaMeters, radiusMeters * 2.4) + ); + if (latestSolarPosition) { + applySolarPositionToBinding( + sharedBinding, + solarPositionToSceneDirection(latestSolarPosition) + ); + } + terrainRuntime?.setShadowCamera(sharedBinding.sunLight.shadow.camera); + map.triggerRepaint(); + }; + + map.on("move", updateSharedShadowCoverage); + map.on("resize", updateSharedShadowCoverage); + updateSharedShadowCoverage(); + + if (terrainRuntime) { + void terrainRuntime.ready.then((loaded) => { + if (!loaded || disposed) return; + restoreMapLibreTerrain = suppressMapLibreTerrainRendering(map); + updateSharedShadowCoverage(); + }); + } - const syncGenericBindings = () => { + const syncGenericBridges = () => { if (disposed) return; const currentLayers = new Set(getGenericThreeLayers(map)); - for (const [layer, binding] of genericBindings) { + for (const [layer, bridge] of genericBridges) { if (currentLayers.has(layer)) continue; - disposeShadowLightBinding(binding); - genericBindings.delete(layer); + sceneLease.layer.removeRuntime(bridge.runtime.id); + genericBridges.delete(layer); } for (const layer of currentLayers) { - let binding = genericBindings.get(layer); - if (!binding) { - if (!layer.scene || !layer.renderer) continue; - binding = buildShadowLightBinding( - layer.scene, - shadowAreaMeters, - layer.renderer - ); - genericBindings.set(layer, binding); - } else { - refreshShadowLightBinding(binding); - } - if (latestSolarPosition) { - applySolarPositionToBinding( - binding, - solarPositionToSceneDirection(latestSolarPosition) - ); + const bridge = genericBridges.get(layer); + if (bridge) { + bridge.sync(); + continue; } + if (!layer.scene) continue; + const nextBridge = buildGenericThreeShadowBridge(sceneLease.layer, layer); + if (nextBridge) genericBridges.set(layer, nextBridge); } + makeSceneMeshesShadeable(sceneLease.layer.getScene()); + updateSharedShadowCoverage(); map.triggerRepaint(); }; const unsubscribeGenericLayers = subscribeGenericThreeLayers( map, - syncGenericBindings + syncGenericBridges ); - syncGenericBindings(); + syncGenericBridges(); const applyMapLibreLight = (position: SolarPosition) => { if (!map.isStyleLoaded()) return; @@ -215,9 +359,7 @@ export const buildShadowSimulationScene = ( latestSolarPosition = position; const direction = solarPositionToSceneDirection(position); applySolarPositionToBinding(sharedBinding, direction); - for (const binding of genericBindings.values()) { - applySolarPositionToBinding(binding, direction); - } + terrainRuntime?.setShadowCamera(sharedBinding.sunLight.shadow.camera); applyMapLibreLight(position); map.triggerRepaint(); }; @@ -231,15 +373,31 @@ export const buildShadowSimulationScene = ( return { updateSolarPosition, + updateTerrainColor(color) { + terrainRuntime?.setMaterialColor(color); + }, dispose() { if (disposed) return; disposed = true; map.off("styledata", restoreLighting); + map.off("move", updateSharedShadowCoverage); + map.off("resize", updateSharedShadowCoverage); unsubscribeGenericLayers(); - for (const binding of genericBindings.values()) { - disposeShadowLightBinding(binding); + for (const bridge of genericBridges.values()) { + if (sceneLease.layer.hasRuntime(bridge.runtime.id)) { + sceneLease.layer.removeRuntime(bridge.runtime.id); + } + } + genericBridges.clear(); + try { + restoreMapLibreTerrain?.(); + } catch { + // The style or terrain source may already be gone during map teardown. + } + restoreMapLibreTerrain = null; + if (terrainRuntime && sceneLease.layer.hasRuntime(terrainRuntime.id)) { + sceneLease.layer.removeRuntime(terrainRuntime.id); } - genericBindings.clear(); disposeShadowLightBinding(sharedBinding); sceneLease.release(); try { diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts index e840766d01..9eca2edd0a 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/solar-position.ts @@ -8,6 +8,12 @@ export type SolarLocation = { timeZone: string; }; +export const DEFAULT_SHADOW_SIMULATION_LOCATION: SolarLocation = { + latitude: 51.256, + longitude: 7.15, + timeZone: "Europe/Berlin", +}; + export type SolarSelection = { year: number; dayOfYear: number; @@ -302,3 +308,14 @@ export const getSolarPosition = ( return { instant, azimuthDegrees, elevationDegrees }; }; + +export const getShadowSimulationSolarPosition = ( + selection: SolarSelection, + location: Partial = {} +): SolarPosition => + getSolarPosition(selection, { + latitude: location.latitude ?? DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, + longitude: + location.longitude ?? DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, + timeZone: location.timeZone ?? DEFAULT_SHADOW_SIMULATION_LOCATION.timeZone, + }); diff --git a/libraries/mapping/addons/src/index.ts b/libraries/mapping/addons/src/index.ts index d824414287..a484e0eb15 100644 --- a/libraries/mapping/addons/src/index.ts +++ b/libraries/mapping/addons/src/index.ts @@ -175,6 +175,7 @@ export { ShadowSimulation, type ShadowSimulationConfig, } from "./addons/ShadowSimulation"; +export { getShadowSimulationSolarPosition } from "./addons/ShadowSimulation/solar-position"; export { OutletAddon, type OutletConfig } from "./addons/outlet/Outlet"; export { CompareSwipe, diff --git a/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx b/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx index d2cd978db4..5694a4bf93 100644 --- a/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx +++ b/libraries/mapping/components/src/lib/components/LibreTerrainControl.tsx @@ -100,8 +100,9 @@ export const LibreTerrainControl = ({ return; } map.setTerrain({ source: terrainSource, exaggeration }); - setShowTerrain(true); - persistTerrainState(storageKey, true); + const enabled = map.getTerrain() != null; + setShowTerrain(enabled); + persistTerrainState(storageKey, enabled); } }, [map, terrainSource, exaggeration, storageKey]); diff --git a/libraries/mapping/engines/cesium/terrain/project.json b/libraries/mapping/engines/cesium/terrain/project.json new file mode 100644 index 0000000000..46955f88af --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/project.json @@ -0,0 +1,26 @@ +{ + "name": "cesium-terrain", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libraries/mapping/engines/cesium/terrain/src", + "projectType": "library", + "tags": ["type:library", "scope:cesium"], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libraries/mapping/engines/cesium/terrain" + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../../../../coverage/libraries/mapping/engines/cesium/terrain" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/libraries/mapping/engines/cesium/terrain/src/index.ts b/libraries/mapping/engines/cesium/terrain/src/index.ts new file mode 100644 index 0000000000..2c320cb8d0 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/index.ts @@ -0,0 +1,11 @@ +export { + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey, +} from "./lib/cesium-terrain-tile-source"; +export type { + CesiumTerrainTile, + CesiumTerrainTileBounds, + CesiumTerrainTileId, + CesiumTerrainTileSource, + CesiumTerrainTileSourceOptions, +} from "./lib/cesium-terrain-tile-source"; diff --git a/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts new file mode 100644 index 0000000000..4a380110fa --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; + +const { fromUrl } = vi.hoisted(() => ({ fromUrl: vi.fn() })); + +vi.mock("@carma-cesium", () => { + class Cartographic { + constructor(public longitude: number, public latitude: number) {} + + static fromDegrees(longitude: number, latitude: number) { + return new Cartographic( + (longitude * Math.PI) / 180, + (latitude * Math.PI) / 180 + ); + } + } + + return { + Cartographic, + CesiumTerrainProvider: { fromUrl }, + }; +}); + +import { acquireCesiumTerrainTileSource } from "./cesium-terrain-tile-source"; + +const buildProvider = () => { + const terrainData = { + _quantizedVertices: new Uint16Array([ + 0, 0, 32767, 32767, 0, 32767, 0, 32767, 0, 16384, 16384, 32767, + ]), + _indices: new Uint16Array([0, 3, 1, 0, 2, 3]), + _minimumHeight: 100, + _maximumHeight: 200, + _westIndices: [0, 1], + _southIndices: [0, 2], + _eastIndices: [2, 3], + _northIndices: [1, 3], + _childTileMask: 15, + interpolateHeight: vi.fn(() => 151.5), + }; + const tileXYToRectangle = (x: number, y: number, level: number) => { + const width = (Math.PI * 2) / 2 ** (level + 1); + const height = Math.PI / 2 ** level; + const west = -Math.PI + x * width; + const north = Math.PI / 2 - y * height; + return { west, east: west + width, north, south: north - height }; + }; + const provider = { + requestTileGeometry: vi.fn(async () => terrainData), + getLevelMaximumGeometricError: vi.fn((level: number) => 64 / 2 ** level), + getTileDataAvailable: vi.fn(() => true), + tilingScheme: { + tileXYToRectangle, + positionToTileXY: ( + position: { longitude: number; latitude: number }, + level: number + ) => { + const xTiles = 2 ** (level + 1); + const yTiles = 2 ** level; + return { + x: Math.min( + xTiles - 1, + Math.max( + 0, + Math.floor( + ((position.longitude + Math.PI) / (2 * Math.PI)) * xTiles + ) + ) + ), + y: Math.min( + yTiles - 1, + Math.max( + 0, + Math.floor(((Math.PI / 2 - position.latitude) / Math.PI) * yTiles) + ) + ), + }; + }, + }, + }; + return { provider, terrainData }; +}; + +describe("Cesium terrain tile source", () => { + it("decodes and caches native quantized-mesh tiles", async () => { + const { provider } = buildProvider(); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-a" + ); + + const first = await source.requestTile({ level: 2, x: 4, y: 1 }); + const second = await source.requestTile({ level: 2, x: 4, y: 1 }); + + expect(second).toBe(first); + expect(provider.requestTileGeometry).toHaveBeenCalledTimes(1); + expect([...first.u]).toEqual([0, 0, 1, 1]); + expect([...first.v]).toEqual([0, 1, 0, 1]); + expect(first.heightMeters[0]).toBeCloseTo(100); + expect(first.heightMeters[3]).toBeCloseTo(200); + expect([...first.indices]).toEqual([0, 3, 1, 0, 2, 3]); + expect(first.geometricErrorMeters).toBe(16); + expect(source.cachedTileCount).toBe(1); + }); + + it("maps bounds to the provider pyramid and samples cached heights", async () => { + const { provider, terrainData } = buildProvider(); + fromUrl.mockResolvedValueOnce(provider); + const source = await acquireCesiumTerrainTileSource( + "https://example.test/terrain-b" + ); + const ids = source.getTileIdsForBounds( + { west: 0, south: 0, east: 20, north: 20 }, + 2 + ); + expect(ids.length).toBeGreaterThan(0); + + await source.requestTile(ids[0]); + expect(source.sampleHeight(10, 10)).toBe(151.5); + expect(terrainData.interpolateHeight).toHaveBeenCalledOnce(); + }); +}); diff --git a/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts new file mode 100644 index 0000000000..057d245076 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/src/lib/cesium-terrain-tile-source.ts @@ -0,0 +1,355 @@ +import { Cartographic, CesiumTerrainProvider } from "@carma-cesium"; + +const MAX_QUANTIZED_VALUE = 32_767; +const DEFAULT_MAX_CACHE_BYTES = 96 * 1024 ** 2; + +export type CesiumTerrainTileId = Readonly<{ + level: number; + x: number; + y: number; +}>; + +export type CesiumTerrainTileBounds = Readonly<{ + west: number; + south: number; + east: number; + north: number; +}>; + +export type CesiumTerrainTile = Readonly<{ + id: CesiumTerrainTileId; + bounds: CesiumTerrainTileBounds; + u: Float32Array; + v: Float32Array; + heightMeters: Float32Array; + indices: Uint32Array; + westIndices: Uint32Array; + southIndices: Uint32Array; + eastIndices: Uint32Array; + northIndices: Uint32Array; + childTileMask: number; + geometricErrorMeters: number; + byteLength: number; +}>; + +export type CesiumTerrainTileSourceOptions = Readonly<{ + maxCacheBytes?: number; +}>; + +export interface CesiumTerrainTileSource { + terrainUrl: string; + requestTile: ( + id: CesiumTerrainTileId, + signal?: AbortSignal + ) => Promise; + getTileIdsForBounds: ( + bounds: CesiumTerrainTileBounds, + level: number + ) => CesiumTerrainTileId[]; + getTileBounds: (id: CesiumTerrainTileId) => CesiumTerrainTileBounds; + getLevelMaximumGeometricError: (level: number) => number; + getTileDataAvailable: (id: CesiumTerrainTileId) => boolean | undefined; + sampleHeight: (longitude: number, latitude: number) => number | undefined; + trimCache: (retainedKeys?: ReadonlySet) => void; + clearCache: () => void; + readonly cachedTileCount: number; + readonly cachedBytes: number; +} + +type QuantizedMeshTerrainData = { + _quantizedVertices?: Uint16Array; + _indices?: Uint16Array | Uint32Array; + _minimumHeight?: number; + _maximumHeight?: number; + _westIndices?: number[]; + _southIndices?: number[]; + _eastIndices?: number[]; + _northIndices?: number[]; + _childTileMask?: number; + interpolateHeight: ( + rectangle: unknown, + longitudeRadians: number, + latitudeRadians: number + ) => number | undefined; +}; + +type CacheEntry = { + tile: CesiumTerrainTile; + terrainData: QuantizedMeshTerrainData; + rectangle: unknown; + lastUsed: number; +}; + +const sourcePromises = new Map>(); + +export const cesiumTerrainTileKey = ({ level, x, y }: CesiumTerrainTileId) => + `${level}/${x}/${y}`; + +const radiansToDegrees = (value: number) => (value * 180) / Math.PI; + +const assertTileId = ({ level, x, y }: CesiumTerrainTileId) => { + if (![level, x, y].every(Number.isInteger) || level < 0 || x < 0 || y < 0) { + throw new RangeError( + "Terrain tile coordinates must be non-negative integers" + ); + } +}; + +const decodeTile = ( + provider: CesiumTerrainProvider, + id: CesiumTerrainTileId, + terrainData: QuantizedMeshTerrainData, + rectangle: { + west: number; + south: number; + east: number; + north: number; + } +): CesiumTerrainTile => { + const quantized = terrainData._quantizedVertices; + const sourceIndices = terrainData._indices; + const minimumHeight = terrainData._minimumHeight; + const maximumHeight = terrainData._maximumHeight; + if ( + !quantized || + !sourceIndices || + !Number.isFinite(minimumHeight) || + !Number.isFinite(maximumHeight) + ) { + throw new TypeError("Terrain endpoint did not return quantized-mesh data"); + } + + const vertexCount = quantized.length / 3; + const u = new Float32Array(vertexCount); + const v = new Float32Array(vertexCount); + const heightMeters = new Float32Array(vertexCount); + const heightRange = maximumHeight! - minimumHeight!; + for (let index = 0; index < vertexCount; index += 1) { + u[index] = quantized[index] / MAX_QUANTIZED_VALUE; + v[index] = quantized[vertexCount + index] / MAX_QUANTIZED_VALUE; + heightMeters[index] = + minimumHeight! + + (quantized[vertexCount * 2 + index] / MAX_QUANTIZED_VALUE) * heightRange; + } + + const indices = Uint32Array.from(sourceIndices); + const westIndices = Uint32Array.from(terrainData._westIndices ?? []); + const southIndices = Uint32Array.from(terrainData._southIndices ?? []); + const eastIndices = Uint32Array.from(terrainData._eastIndices ?? []); + const northIndices = Uint32Array.from(terrainData._northIndices ?? []); + const arrays = [ + u, + v, + heightMeters, + indices, + westIndices, + southIndices, + eastIndices, + northIndices, + ]; + + return { + id, + bounds: { + west: radiansToDegrees(rectangle.west), + south: radiansToDegrees(rectangle.south), + east: radiansToDegrees(rectangle.east), + north: radiansToDegrees(rectangle.north), + }, + u, + v, + heightMeters, + indices, + westIndices, + southIndices, + eastIndices, + northIndices, + childTileMask: terrainData._childTileMask ?? 15, + geometricErrorMeters: provider.getLevelMaximumGeometricError(id.level), + byteLength: arrays.reduce((sum, array) => sum + array.byteLength, 0), + }; +}; + +const buildSource = async ( + terrainUrl: string, + options: CesiumTerrainTileSourceOptions +): Promise => { + const provider = await CesiumTerrainProvider.fromUrl(terrainUrl, { + requestVertexNormals: false, + requestWaterMask: false, + requestMetadata: false, + }); + const maxCacheBytes = Math.max( + 1, + Math.floor(options.maxCacheBytes ?? DEFAULT_MAX_CACHE_BYTES) + ); + const cache = new Map(); + const pending = new Map>(); + let cachedBytes = 0; + let useClock = 0; + + const trimCache = (retainedKeys: ReadonlySet = new Set()) => { + if (cachedBytes <= maxCacheBytes) return; + const candidates = [...cache.entries()] + .filter(([key]) => !retainedKeys.has(key)) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed); + for (const [key, entry] of candidates) { + cache.delete(key); + cachedBytes -= entry.tile.byteLength; + if (cachedBytes <= maxCacheBytes) break; + } + }; + + const requestTile = async ( + id: CesiumTerrainTileId, + signal?: AbortSignal + ): Promise => { + assertTileId(id); + signal?.throwIfAborted(); + const key = cesiumTerrainTileKey(id); + const cached = cache.get(key); + if (cached) { + cached.lastUsed = ++useClock; + return cached.tile; + } + const inFlight = pending.get(key); + if (inFlight) return inFlight; + + const load = (async () => { + let requested = provider.requestTileGeometry(id.x, id.y, id.level); + while (!requested) { + await new Promise((resolve) => setTimeout(resolve, 0)); + signal?.throwIfAborted(); + requested = provider.requestTileGeometry(id.x, id.y, id.level); + } + const terrainData = + (await requested) as unknown as QuantizedMeshTerrainData; + signal?.throwIfAborted(); + const rectangle = provider.tilingScheme.tileXYToRectangle( + id.x, + id.y, + id.level + ); + const tile = decodeTile(provider, id, terrainData, rectangle); + cache.set(key, { + tile, + terrainData, + rectangle, + lastUsed: ++useClock, + }); + cachedBytes += tile.byteLength; + trimCache(); + return tile; + })(); + pending.set(key, load); + try { + return await load; + } finally { + pending.delete(key); + } + }; + + const getTileIdsForBounds = ( + bounds: CesiumTerrainTileBounds, + level: number + ) => { + if (!Number.isInteger(level) || level < 0) { + throw new RangeError("Terrain level must be a non-negative integer"); + } + const epsilon = 1e-10; + const northWest = provider.tilingScheme.positionToTileXY( + Cartographic.fromDegrees(bounds.west, bounds.north - epsilon), + level + ); + const southEast = provider.tilingScheme.positionToTileXY( + Cartographic.fromDegrees(bounds.east - epsilon, bounds.south + epsilon), + level + ); + if (!northWest || !southEast) return []; + const result: CesiumTerrainTileId[] = []; + for (let y = northWest.y; y <= southEast.y; y += 1) { + for (let x = northWest.x; x <= southEast.x; x += 1) { + const id = { level, x, y }; + if (provider.getTileDataAvailable(x, y, level) !== false) + result.push(id); + } + } + return result; + }; + + return { + terrainUrl, + requestTile, + getTileIdsForBounds, + getTileBounds(id) { + assertTileId(id); + const rectangle = provider.tilingScheme.tileXYToRectangle( + id.x, + id.y, + id.level + ); + return { + west: radiansToDegrees(rectangle.west), + south: radiansToDegrees(rectangle.south), + east: radiansToDegrees(rectangle.east), + north: radiansToDegrees(rectangle.north), + }; + }, + getLevelMaximumGeometricError: (level) => + provider.getLevelMaximumGeometricError(level), + getTileDataAvailable: ({ x, y, level }) => + provider.getTileDataAvailable(x, y, level), + sampleHeight(longitude, latitude) { + const longitudeRadians = (longitude * Math.PI) / 180; + const latitudeRadians = (latitude * Math.PI) / 180; + const candidates = [...cache.values()] + .filter(({ tile }) => { + const { bounds } = tile; + return ( + longitude >= bounds.west && + longitude <= bounds.east && + latitude >= bounds.south && + latitude <= bounds.north + ); + }) + .sort((left, right) => right.tile.id.level - left.tile.id.level); + for (const entry of candidates) { + const height = entry.terrainData.interpolateHeight( + entry.rectangle, + longitudeRadians, + latitudeRadians + ); + if (Number.isFinite(height)) { + entry.lastUsed = ++useClock; + return height; + } + } + return undefined; + }, + trimCache, + clearCache() { + cache.clear(); + cachedBytes = 0; + }, + get cachedTileCount() { + return cache.size; + }, + get cachedBytes() { + return cachedBytes; + }, + }; +}; + +export const acquireCesiumTerrainTileSource = ( + terrainUrl: string, + options: CesiumTerrainTileSourceOptions = {} +): Promise => { + const normalizedUrl = terrainUrl.trim().replace(/\/+$/, ""); + if (!normalizedUrl) throw new TypeError("Terrain URL must not be empty"); + const cached = sourcePromises.get(normalizedUrl); + if (cached) return cached; + const pending = buildSource(normalizedUrl, options); + sourcePromises.set(normalizedUrl, pending); + void pending.catch(() => sourcePromises.delete(normalizedUrl)); + return pending; +}; diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.json b/libraries/mapping/engines/cesium/terrain/tsconfig.json new file mode 100644 index 0000000000..cca841b2b0 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "files": [], + "references": [ + { "path": "./tsconfig.lib.json" }, + { "path": "./tsconfig.spec.json" } + ] +} diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json b/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json new file mode 100644 index 0000000000..28d3b767b3 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "noEmit": false, + "types": ["node", "vite/client"], + "skipLibCheck": true, + "lib": ["ES2021", "DOM"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"], + "exclude": ["vite.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json b/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json new file mode 100644 index 0000000000..7531e996e9 --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "types": ["vitest/globals", "node", "vite/client"], + "skipLibCheck": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.d.ts"] +} diff --git a/libraries/mapping/engines/cesium/terrain/vite.config.ts b/libraries/mapping/engines/cesium/terrain/vite.config.ts new file mode 100644 index 0000000000..2483be275c --- /dev/null +++ b/libraries/mapping/engines/cesium/terrain/vite.config.ts @@ -0,0 +1,45 @@ +/// +import * as path from "path"; + +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import { defineConfig } from "vite"; +import dts from "vite-plugin-dts"; + +export default defineConfig({ + root: __dirname, + cacheDir: + "../../../../../node_modules/.vite/libraries/mapping/engines/cesium/terrain", + plugins: [ + nxViteTsPaths(), + dts({ + entryRoot: "src", + tsconfigPath: path.join(__dirname, "tsconfig.lib.json"), + }), + ], + build: { + outDir: "../../../../../dist/libraries/mapping/engines/cesium/terrain", + emptyOutDir: true, + reportCompressedSize: true, + lib: { + entry: "src/index.ts", + name: "carma-cesium-terrain", + fileName: "index", + formats: ["es"], + }, + rollupOptions: { + external: ["@carma-cesium"], + }, + }, + test: { + watch: false, + globals: true, + environment: "node", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + reporters: ["default"], + coverage: { + reportsDirectory: + "../../../../../coverage/libraries/mapping/engines/cesium/terrain", + provider: "v8", + }, + }, +}); diff --git a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx index 8ee40ec209..d830bc353e 100644 --- a/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx +++ b/libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.tsx @@ -3,7 +3,6 @@ import { useEffect, useMemo, useRef } from "react"; import { LngLat, MercatorCoordinate } from "maplibre-gl"; import type { Map as MaplibreMap } from "maplibre-gl"; import * as THREE from "three"; -import type { Scene } from "three"; import { buildGenericLayer, @@ -38,6 +37,10 @@ import { registerGenericThreeLayer, unregisterGenericThreeLayer, } from "../lib/runtime/integrations/generic-three-layer-registry"; +import { + getSharedThreeTerrainElevation, + subscribeSharedThreeTerrain, +} from "../lib/runtime/integrations/shared-three-terrain-registry"; // ───────────────────────────────────────────────────────────── // ThreeLayerManager: bridges carma3d configs to the threejs engine // ───────────────────────────────────────────────────────────── @@ -243,6 +246,7 @@ export function ThreeLayerManager({ // read from the terrain, and the same layer, selection and idle-sync // plumbing. What the roof is built from is decided inside syncBuildings. const isExtrusion = config.renderMode === "extrusion" || isLod2; + const buildingElevationCache = new Map(); const rebuildFn = useLoft ? ( @@ -457,16 +461,23 @@ export function ThreeLayerManager({ const terrain = map.terrain; const terrainZoom = Math.floor(map.getZoom()); const elevationAt = (lng: number, lat: number): number => { - if (!hasTerrain) { - return 0; - } - if (terrain) { - return terrain.getElevationForLngLatZoom( + const cacheKey = `${lng.toFixed(7)}/${lat.toFixed(7)}`; + let elevation: number | undefined; + if (hasTerrain && terrain) { + elevation = terrain.getElevationForLngLatZoom( new LngLat(lng, lat), terrainZoom ); + } else if (hasTerrain) { + elevation = map.queryTerrainElevation({ lng, lat }) ?? undefined; + } else { + elevation = getSharedThreeTerrainElevation(map, lng, lat); + } + if (Number.isFinite(elevation)) { + buildingElevationCache.set(cacheKey, elevation!); + return elevation!; } - return map.queryTerrainElevation({ lng, lat }) ?? 0; + return buildingElevationCache.get(cacheKey) ?? 0; }; const raw = map.querySourceFeatures(config.sourceId, { @@ -895,6 +906,7 @@ export function ThreeLayerManager({ trySync(); }; map.on("terrain", handleTerrain); + const unsubscribeSharedTerrain = subscribeSharedThreeTerrain(map, trySync); // Re-add layer after background style change (style swap removes custom layers) // Also re-checks visibility so toggling a layer back on re-creates the 3D layer @@ -925,6 +937,7 @@ export function ThreeLayerManager({ if (handleIdle) map.off("idle", handleIdle); map.off("sourcedata", handleSourceData); map.off("terrain", handleTerrain); + unsubscribeSharedTerrain(); map.off("styledata", handleStyleData); if (perfRef) { perfRef.current = EMPTY_PERF; diff --git a/libraries/mapping/engines/maplibre/src/index.ts b/libraries/mapping/engines/maplibre/src/index.ts index e67e70ceb4..ad5368b253 100644 --- a/libraries/mapping/engines/maplibre/src/index.ts +++ b/libraries/mapping/engines/maplibre/src/index.ts @@ -280,6 +280,13 @@ export type { ThreeTilesRuntime, ThreeTilesRuntimeOptions, } from "./lib/runtime/integrations/three-tiles-runtime"; +export { buildCesiumTerrainRuntime } from "./lib/runtime/integrations/cesium-terrain-tile-runtime"; +export type { + CesiumTerrainMaterialOptions, + CesiumTerrainRuntime, + CesiumTerrainRuntimeOptions, +} from "./lib/runtime/integrations/cesium-terrain-tile-runtime"; +export { suppressMapLibreTerrainRendering } from "./lib/runtime/integrations/shared-three-terrain-registry"; // Styles (CSS should be imported by consumers) // import '@carma-mapping/engines/maplibre/styles/map.css'; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts new file mode 100644 index 0000000000..212b98a2e7 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -0,0 +1,188 @@ +import { + BufferGeometry, + Camera, + Float32BufferAttribute, + OrthographicCamera, + PerspectiveCamera, + Vector2, + Vector3, +} from "three"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { + acquireCesiumTerrainTileSource, + createProjectedTerrainTileGeometry, + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, +} = vi.hoisted(() => ({ + acquireCesiumTerrainTileSource: vi.fn(), + createProjectedTerrainTileGeometry: vi.fn(), + notifySharedThreeTerrainChanged: vi.fn(), + registerSharedThreeTerrainSampler: vi.fn(() => vi.fn()), +})); + +vi.mock("@carma-mapping/engines/three/primitives", () => ({ + createProjectedTerrainTileGeometry, +})); + +vi.mock("@carma-mapping/engines/cesium/terrain", () => ({ + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey: ({ level, x, y }: Record) => + `${level}/${x}/${y}`, +})); + +vi.mock("./shared-three-terrain-registry", () => ({ + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, +})); + +import { buildCesiumTerrainRuntime } from "./cesium-terrain-tile-runtime"; + +describe("buildCesiumTerrainRuntime", () => { + beforeEach(() => { + vi.clearAllMocks(); + createProjectedTerrainTileGeometry.mockImplementation(({ tile }) => { + const geometry = new BufferGeometry(); + const farHeight = tile.id.x === 533 ? 1 : 0; + geometry.setAttribute( + "position", + new Float32BufferAttribute( + new Float32Array([ + tile.id.x === 533 ? 1 : 0, + 0, + 0, + tile.id.x === 533 ? 1 : 0, + 0, + -1, + tile.id.x === 533 ? 2 : 1, + farHeight, + 0, + tile.id.x === 533 ? 2 : 1, + farHeight, + -1, + ]), + 3 + ) + ); + geometry.setIndex([0, 2, 1, 1, 2, 3]); + geometry.computeVertexNormals(); + return geometry; + }); + }); + + it("adds a shadeable terrain mesh in the shared local-meter frame", async () => { + const tileId = { level: 10, x: 532, y: 218 }; + const sunTileId = { level: 10, x: 533, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + westIndices: + id.x === sunTileId.x ? new Uint32Array([0, 1]) : new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: + id.x === tileId.x ? new Uint32Array([2, 3]) : new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileIdsForBounds: vi.fn((bounds) => + bounds.east > 7.25 ? [tileId, sunTileId] : [tileId] + ), + getTileBounds: vi.fn((id) => + id.x === sunTileId.x + ? { west: 7.4, south: 51, east: 7.5, north: 51.3 } + : { west: 7, south: 51, east: 7.2, north: 51.3 } + ), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(() => 150), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const runtime = buildCesiumTerrainRuntime( + "terrain", + "https://example.test/terrain", + [7.15, 51.256], + { minimumLevel: 10, maximumLevel: 10, shadowLevelOffset: 0 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7, + getSouth: () => 51, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 10_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(runtime.supportsShadows).toBe(true); + expect(runtime.root.children).toHaveLength(1); + const mesh = runtime.root.children[0] as { + castShadow: boolean; + receiveShadow: boolean; + geometry: { getAttribute: (name: string) => { count: number } }; + }; + expect(mesh.castShadow).toBe(true); + expect(mesh.receiveShadow).toBe(true); + expect(mesh.geometry.getAttribute("position").count).toBe(4); + expect(source.requestTile).toHaveBeenCalledWith(tileId); + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + expect(notifySharedThreeTerrainChanged).toHaveBeenCalledWith(map); + + const shadowCamera = new OrthographicCamera( + -1_000, + 1_000, + 1_000, + -1_000, + 1, + 5_000 + ); + shadowCamera.position.set(20_000, 1_000, 0); + shadowCamera.lookAt(20_000, 0, 0); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowCamera(shadowCamera); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + await vi.waitFor(() => { + expect(source.requestTile).toHaveBeenCalledWith(sunTileId); + expect(runtime.root.children).toHaveLength(2); + }); + const viewportNormal = ( + runtime.root.children.find((child) => + child.name.endsWith("10/532/218") + ) as { + geometry: BufferGeometry; + } + ).geometry.getAttribute("normal"); + const occluderNormal = ( + runtime.root.children.find((child) => + child.name.endsWith("10/533/218") + ) as { + geometry: BufferGeometry; + } + ).geometry.getAttribute("normal"); + expect(viewportNormal.getX(2)).toBeCloseTo(occluderNormal.getX(0)); + expect(viewportNormal.getY(2)).toBeCloseTo(occluderNormal.getY(0)); + + runtime.dispose(); + expect(runtime.root.children).toHaveLength(0); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts new file mode 100644 index 0000000000..1c259ea693 --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -0,0 +1,594 @@ +import { MercatorCoordinate } from "maplibre-gl"; +import type { Map as MaplibreMap } from "maplibre-gl"; +import { + Camera, + Group, + Matrix4, + Mesh, + MeshStandardMaterial, + Vector3, + type ColorRepresentation, +} from "three"; + +import { + acquireCesiumTerrainTileSource, + cesiumTerrainTileKey, + type CesiumTerrainTile, + type CesiumTerrainTileBounds, + type CesiumTerrainTileId, + type CesiumTerrainTileSource, +} from "@carma-mapping/engines/cesium/terrain"; +import { createProjectedTerrainTileGeometry } from "@carma-mapping/engines/three/primitives"; + +import { + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, +} from "./shared-three-terrain-registry"; +import type { + SharedThreeSceneFrame, + SharedThreeSceneRuntime, +} from "./shared-three-scene-layer"; + +const DEFAULT_TERRAIN_COLOR = 0xd8d1c4; +const DEFAULT_ERROR_TARGET_PIXELS = 2.5; +const DEFAULT_SHADOW_LEVEL_OFFSET = 2; +const DEFAULT_MINIMUM_LEVEL = 8; +const DEFAULT_MAXIMUM_LEVEL = 17; +const DEFAULT_MAX_SELECTION_TILES = 192; +const DEFAULT_REQUEST_CONCURRENCY = 6; +const DEFAULT_MAX_CACHED_MESHES = 256; + +export type CesiumTerrainMaterialOptions = Readonly<{ + color?: ColorRepresentation; + roughness?: number; + metalness?: number; +}>; + +export type CesiumTerrainRuntimeOptions = Readonly<{ + errorTargetPixels?: number; + shadowLevelOffset?: number; + minimumLevel?: number; + maximumLevel?: number; + maxSelectionTiles?: number; + requestConcurrency?: number; + maxCacheBytes?: number; + maxCachedMeshes?: number; + material?: CesiumTerrainMaterialOptions; + onError?: (error: unknown) => void; +}>; + +export interface CesiumTerrainRuntime extends SharedThreeSceneRuntime { + ready: Promise; + setVisible: (visible: boolean) => void; + setShadowCamera: (camera: Camera | null) => void; + setMaterialColor: (color: ColorRepresentation) => void; + getElevation: (longitude: number, latitude: number) => number | undefined; +} + +type TerrainMeshRecord = { + mesh: Mesh; + boundaryIndices: Uint32Array; + lastUsed: number; +}; + +type TerrainSelection = { + ids: CesiumTerrainTileId[]; + signature: string; +}; + +type TerrainCandidate = { + id: CesiumTerrainTileId; + errorRatio: number; +}; + +const clampInteger = ( + value: number | undefined, + fallback: number, + minimum: number +) => Math.max(minimum, Math.floor(value ?? fallback)); + +const boundsIntersect = ( + left: CesiumTerrainTileBounds, + right: CesiumTerrainTileBounds +) => + left.west <= right.east && + left.east >= right.west && + left.south <= right.north && + left.north >= right.south; + +const unionBounds = ( + left: CesiumTerrainTileBounds, + right: CesiumTerrainTileBounds +): CesiumTerrainTileBounds => ({ + west: Math.min(left.west, right.west), + south: Math.min(left.south, right.south), + east: Math.max(left.east, right.east), + north: Math.max(left.north, right.north), +}); + +const getViewportBounds = (map: MaplibreMap): CesiumTerrainTileBounds => { + const bounds = map.getBounds(); + return { + west: bounds.getWest(), + south: bounds.getSouth(), + east: bounds.getEast(), + north: bounds.getNorth(), + }; +}; + +const cameraFrustumBounds = ( + camera: Camera, + root: Group, + origin: MercatorCoordinate, + meterScale: number +): CesiumTerrainTileBounds | null => { + camera.updateMatrixWorld(true); + camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert(); + root.updateMatrixWorld(true); + const localFromWorld = new Matrix4().copy(root.matrixWorld).invert(); + let west = Number.POSITIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const x of [-1, 1]) { + for (const y of [-1, 1]) { + for (const z of [-1, 1]) { + const local = new Vector3(x, y, z) + .unproject(camera) + .applyMatrix4(localFromWorld); + const lngLat = new MercatorCoordinate( + origin.x + local.x * meterScale, + origin.y + local.z * meterScale, + 0 + ).toLngLat(); + west = Math.min(west, lngLat.lng); + south = Math.min(south, lngLat.lat); + east = Math.max(east, lngLat.lng); + north = Math.max(north, lngLat.lat); + } + } + } + return [west, south, east, north].every(Number.isFinite) + ? { + west: Math.max(-180, west), + south: Math.max(-90, south), + east: Math.min(180, east), + north: Math.min(90, north), + } + : null; +}; + +const loadWithConcurrency = async ( + values: readonly T[], + concurrency: number, + load: (value: T) => Promise +): Promise => { + const results = new Array(values.length); + let cursor = 0; + const worker = async () => { + while (cursor < values.length) { + const index = cursor; + cursor += 1; + results[index] = await load(values[index]); + } + }; + await Promise.all( + Array.from({ length: Math.min(concurrency, values.length) }, worker) + ); + return results; +}; + +export const buildCesiumTerrainRuntime = ( + runtimeId: string, + terrainUrl: string, + originLngLat: [number, number], + options: CesiumTerrainRuntimeOptions = {} +): CesiumTerrainRuntime => { + const errorTargetPixels = Math.max( + 0.1, + options.errorTargetPixels ?? DEFAULT_ERROR_TARGET_PIXELS + ); + const minimumLevel = clampInteger( + options.minimumLevel, + DEFAULT_MINIMUM_LEVEL, + 0 + ); + const maximumLevel = Math.max( + minimumLevel, + clampInteger(options.maximumLevel, DEFAULT_MAXIMUM_LEVEL, 0) + ); + const shadowLevelOffset = clampInteger( + options.shadowLevelOffset, + DEFAULT_SHADOW_LEVEL_OFFSET, + 0 + ); + const maxSelectionTiles = clampInteger( + options.maxSelectionTiles, + DEFAULT_MAX_SELECTION_TILES, + 1 + ); + const requestConcurrency = clampInteger( + options.requestConcurrency, + DEFAULT_REQUEST_CONCURRENCY, + 1 + ); + const maxCachedMeshes = clampInteger( + options.maxCachedMeshes, + DEFAULT_MAX_CACHED_MESHES, + 1 + ); + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const root = new Group(); + root.name = `${runtimeId}-root`; + const material = new MeshStandardMaterial({ + color: options.material?.color ?? DEFAULT_TERRAIN_COLOR, + roughness: options.material?.roughness ?? 0.96, + metalness: options.material?.metalness ?? 0, + }); + const sourcePromise = acquireCesiumTerrainTileSource(terrainUrl, { + maxCacheBytes: options.maxCacheBytes, + }); + const meshes = new Map(); + let source: CesiumTerrainTileSource | null = null; + let map: MaplibreMap | null = null; + let shadowCamera: Camera | null = null; + let unregisterSampler: (() => void) | null = null; + let disposed = false; + let meshUseClock = 0; + let selectionGeneration = 0; + let requestedSignature = ""; + let resolveReady: (loaded: boolean) => void = () => undefined; + let readySettled = false; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + + const settleReady = (loaded: boolean) => { + if (readySettled) return; + readySettled = true; + resolveReady(loaded); + }; + + const projectToLocalWorld = ( + longitude: number, + latitude: number, + height: number, + target: Vector3 + ) => { + const coordinate = MercatorCoordinate.fromLngLat( + [longitude, latitude], + height + ); + return target.set( + (coordinate.x - origin.x) / meterScale, + (coordinate.z - origin.z) / meterScale, + (coordinate.y - origin.y) / meterScale + ); + }; + + const getScreenSpaceError = ( + terrainSource: CesiumTerrainTileSource, + frame: SharedThreeSceneFrame, + id: CesiumTerrainTileId + ) => { + const bounds = terrainSource.getTileBounds(id); + const center = projectToLocalWorld( + (bounds.west + bounds.east) / 2, + (bounds.south + bounds.north) / 2, + 0, + new Vector3() + ); + const corner = projectToLocalWorld( + bounds.east, + bounds.north, + 0, + new Vector3() + ); + const radius = center.distanceTo(corner); + const distance = Math.max( + 1, + frame.lodCamera.position.distanceTo(center) - radius + ); + const focalLengthPixels = + frame.viewport.y / (2 * Math.tan((frame.lodCamera.fov * Math.PI) / 360)); + return ( + (terrainSource.getLevelMaximumGeometricError(id.level) * + focalLengthPixels) / + distance + ); + }; + + const getRelevantChildren = ( + terrainSource: CesiumTerrainTileSource, + parentId: CesiumTerrainTileId, + viewportBounds: CesiumTerrainTileBounds, + shadowBounds: CesiumTerrainTileBounds | null + ) => { + const childLevel = parentId.level + 1; + const children: CesiumTerrainTileId[] = []; + for (let yOffset = 0; yOffset < 2; yOffset += 1) { + for (let xOffset = 0; xOffset < 2; xOffset += 1) { + const id = { + level: childLevel, + x: parentId.x * 2 + xOffset, + y: parentId.y * 2 + yOffset, + }; + const bounds = terrainSource.getTileBounds(id); + if ( + !boundsIntersect(bounds, viewportBounds) && + (!shadowBounds || !boundsIntersect(bounds, shadowBounds)) + ) { + continue; + } + if (terrainSource.getTileDataAvailable(id) === false) return null; + children.push(id); + } + } + return children; + }; + + const ensureMesh = (tile: CesiumTerrainTile) => { + const key = cesiumTerrainTileKey(tile.id); + const cached = meshes.get(key); + if (cached) { + cached.lastUsed = ++meshUseClock; + return cached.mesh; + } + const geometry = createProjectedTerrainTileGeometry({ + tile, + projectToWorld: projectToLocalWorld, + }); + const mesh = new Mesh(geometry, material); + mesh.name = `${runtimeId}-${key}`; + mesh.castShadow = true; + mesh.receiveShadow = true; + mesh.visible = false; + root.add(mesh); + const boundaryIndices = Uint32Array.from( + new Set([ + ...(tile.westIndices ?? []), + ...(tile.southIndices ?? []), + ...(tile.eastIndices ?? []), + ...(tile.northIndices ?? []), + ]) + ); + meshes.set(key, { + mesh, + boundaryIndices, + lastUsed: ++meshUseClock, + }); + return mesh; + }; + + const smoothActiveBoundaryNormals = (activeKeys: ReadonlySet) => { + const boundaries = new Map< + string, + { + normal: Vector3; + vertices: Array<{ record: TerrainMeshRecord; index: number }>; + } + >(); + for (const [key, record] of meshes) { + if (!activeKeys.has(key)) continue; + record.mesh.geometry.computeVertexNormals(); + const position = record.mesh.geometry.getAttribute("position"); + const normal = record.mesh.geometry.getAttribute("normal"); + for (const index of record.boundaryIndices) { + const positionKey = `${Math.round( + position.getX(index) * 1_000 + )}/${Math.round(position.getY(index) * 1_000)}/${Math.round( + position.getZ(index) * 1_000 + )}`; + const entry = boundaries.get(positionKey) ?? { + normal: new Vector3(), + vertices: [], + }; + entry.normal.add( + new Vector3( + normal.getX(index), + normal.getY(index), + normal.getZ(index) + ) + ); + entry.vertices.push({ record, index }); + boundaries.set(positionKey, entry); + } + } + for (const { normal, vertices } of boundaries.values()) { + if (vertices.length < 2 || normal.lengthSq() === 0) continue; + normal.normalize(); + for (const { record, index } of vertices) { + const attribute = record.mesh.geometry.getAttribute("normal"); + attribute.setXYZ(index, normal.x, normal.y, normal.z); + attribute.needsUpdate = true; + } + } + }; + + const trimMeshCache = (activeKeys: ReadonlySet) => { + if (meshes.size <= maxCachedMeshes) return; + const candidates = [...meshes.entries()] + .filter(([key]) => !activeKeys.has(key)) + .sort(([, left], [, right]) => left.lastUsed - right.lastUsed); + for (const [key, record] of candidates) { + root.remove(record.mesh); + record.mesh.geometry.dispose(); + meshes.delete(key); + if (meshes.size <= maxCachedMeshes) break; + } + }; + + const buildSelection = ( + terrainSource: CesiumTerrainTileSource, + frame: SharedThreeSceneFrame + ): TerrainSelection => { + const viewportBounds = getViewportBounds(frame.map); + const shadowBounds = shadowCamera + ? cameraFrustumBounds(shadowCamera, root, origin, meterScale) + : null; + const coverageBounds = shadowBounds + ? unionBounds(viewportBounds, shadowBounds) + : viewportBounds; + let rootLevel = minimumLevel; + let rootIds = terrainSource.getTileIdsForBounds(coverageBounds, rootLevel); + while (rootIds.length > maxSelectionTiles && rootLevel > 0) { + rootLevel -= 1; + rootIds = terrainSource.getTileIdsForBounds(coverageBounds, rootLevel); + } + + const selected = new Map( + rootIds.map((id) => [cesiumTerrainTileKey(id), id]) + ); + const toCandidate = (id: CesiumTerrainTileId): TerrainCandidate => { + const bounds = terrainSource.getTileBounds(id); + const targetPixels = boundsIntersect(bounds, viewportBounds) + ? errorTargetPixels + : errorTargetPixels * 2 ** shadowLevelOffset; + return { + id, + errorRatio: + getScreenSpaceError(terrainSource, frame, id) / targetPixels, + }; + }; + const candidates = rootIds.map(toCandidate); + while (candidates.length > 0) { + candidates.sort((left, right) => left.errorRatio - right.errorRatio); + const candidate = candidates.pop()!; + if (candidate.errorRatio <= 1) break; + if (candidate.id.level >= maximumLevel) continue; + const children = getRelevantChildren( + terrainSource, + candidate.id, + viewportBounds, + shadowBounds + ); + if (!children?.length) continue; + if (selected.size + children.length - 1 > maxSelectionTiles) continue; + selected.delete(cesiumTerrainTileKey(candidate.id)); + for (const child of children) { + selected.set(cesiumTerrainTileKey(child), child); + candidates.push(toCandidate(child)); + } + } + + const ids = [...selected.values()]; + return { + ids, + signature: ids.map(cesiumTerrainTileKey).sort().join("|"), + }; + }; + + const loadSelection = ( + terrainSource: CesiumTerrainTileSource, + selection: TerrainSelection + ) => { + const generation = ++selectionGeneration; + void loadWithConcurrency(selection.ids, requestConcurrency, (id) => + terrainSource.requestTile(id) + ) + .then((tiles) => { + if (disposed || generation !== selectionGeneration) return; + const activeKeys = new Set(); + for (const tile of tiles) { + const key = cesiumTerrainTileKey(tile.id); + activeKeys.add(key); + ensureMesh(tile).visible = root.visible; + } + smoothActiveBoundaryNormals(activeKeys); + for (const [key, record] of meshes) { + record.mesh.visible = root.visible && activeKeys.has(key); + } + terrainSource.trimCache(activeKeys); + trimMeshCache(activeKeys); + settleReady(true); + if (map) notifySharedThreeTerrainChanged(map); + map?.triggerRepaint(); + }) + .catch((error) => { + if (disposed || generation !== selectionGeneration) return; + options.onError?.(error); + settleReady(false); + }); + }; + + void sourcePromise + .then((terrainSource) => { + if (disposed) return; + source = terrainSource; + if (map) { + unregisterSampler = registerSharedThreeTerrainSampler( + map, + runtimeId, + terrainSource.sampleHeight + ); + map.triggerRepaint(); + } + }) + .catch((error) => { + if (disposed) return; + options.onError?.(error); + settleReady(false); + }); + + return { + id: runtimeId, + originLngLat, + root, + supportsShadows: true, + ready, + onAdd(mapInstance) { + map = mapInstance; + if (source && !unregisterSampler) { + unregisterSampler = registerSharedThreeTerrainSampler( + mapInstance, + runtimeId, + source.sampleHeight + ); + } + map.triggerRepaint(); + }, + update(frame) { + if (!source || disposed || !root.visible) return; + const selection = buildSelection(source, frame); + if (!selection.ids.length || selection.signature === requestedSignature) { + return; + } + requestedSignature = selection.signature; + loadSelection(source, selection); + }, + setVisible(visible) { + root.visible = visible; + if (!visible) { + for (const record of meshes.values()) record.mesh.visible = false; + } else { + requestedSignature = ""; + } + map?.triggerRepaint(); + }, + setShadowCamera(camera) { + shadowCamera = camera; + map?.triggerRepaint(); + }, + setMaterialColor(color) { + material.color.set(color); + map?.triggerRepaint(); + }, + getElevation(longitude, latitude) { + return source?.sampleHeight(longitude, latitude); + }, + dispose() { + if (disposed) return; + disposed = true; + selectionGeneration += 1; + unregisterSampler?.(); + unregisterSampler = null; + for (const record of meshes.values()) record.mesh.geometry.dispose(); + meshes.clear(); + material.dispose(); + root.clear(); + map = null; + settleReady(false); + }, + }; +}; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts index 4988fcbbb0..89850ea9ea 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts @@ -7,10 +7,13 @@ import type { } from "maplibre-gl"; import * as THREE from "three"; +import { getSharedThreeTerrainElevation } from "./shared-three-terrain-registry"; + export interface SharedThreeSceneFrame { map: MaplibreMap; renderCamera: THREE.Camera; lodCamera: THREE.PerspectiveCamera; + lookTarget: THREE.Vector3; viewport: THREE.Vector2; } @@ -31,6 +34,11 @@ export interface SharedThreeSceneLayer extends CustomLayerInterface { hasRuntime: (runtimeId: string) => boolean; hasShadeableContent: () => boolean; getScene: () => THREE.Scene; + projectLngLatToScene: ( + lngLat: [number, number], + altitudeMeters?: number, + target?: THREE.Vector3 + ) => THREE.Vector3 | null; /** Detach the custom layer without destroying runtimes preserved across HMR. */ detach: () => void; dispose: () => void; @@ -128,6 +136,20 @@ export const buildSharedThreeSceneLayer = ( return scene; }, + projectLngLatToScene( + lngLat, + altitudeMeters = 0, + target = new THREE.Vector3() + ) { + if (!originMerc || meterScale <= 0) return null; + const coordinate = MercatorCoordinate.fromLngLat(lngLat, altitudeMeters); + return target.set( + (coordinate.x - originMerc.x) / meterScale, + (coordinate.z - originMerc.z) / meterScale, + (coordinate.y - originMerc.y) / meterScale + ); + }, + detach() { for (const runtime of runtimes.values()) scene.remove(runtime.root); renderer?.dispose(); @@ -175,11 +197,24 @@ export const buildSharedThreeSceneLayer = ( renderer.getDrawingBufferSize(viewport); // Same pose the MapLibre 3D Tiles layer works out for itself, so it // lives in the engine rather than here, see synthesizeLodCamera. + const centerLngLat = map.getCenter(); if ( !synthesizeLodCamera( lodCamera, map, - { originMerc, meterScale, viewport }, + { + originMerc, + meterScale, + viewport, + centerElevationMeters: + map.queryTerrainElevation(centerLngLat) ?? + getSharedThreeTerrainElevation( + map, + centerLngLat.lng, + centerLngLat.lat + ) ?? + 0, + }, lookTarget ) ) { @@ -190,6 +225,7 @@ export const buildSharedThreeSceneLayer = ( map, renderCamera, lodCamera, + lookTarget, viewport, }; scene.updateMatrixWorld(true); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts new file mode 100644 index 0000000000..0f7416aa6b --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + getSharedThreeTerrainElevation, + notifySharedThreeTerrainChanged, + registerSharedThreeTerrainSampler, + subscribeSharedThreeTerrain, + suppressMapLibreTerrainRendering, +} from "./shared-three-terrain-registry"; + +describe("shared Three terrain registry", () => { + it("samples registered decoded terrain and notifies consumers", () => { + const map = {} as never; + const listener = vi.fn(); + const unsubscribe = subscribeSharedThreeTerrain(map, listener); + const unregister = registerSharedThreeTerrainSampler( + map, + "terrain", + () => 157.25 + ); + + expect(getSharedThreeTerrainElevation(map, 7.15, 51.25)).toBe(157.25); + expect(listener).toHaveBeenCalledOnce(); + + notifySharedThreeTerrainChanged(map); + expect(listener).toHaveBeenCalledTimes(2); + + unregister(); + expect(getSharedThreeTerrainElevation(map, 7.15, 51.25)).toBeUndefined(); + expect(listener).toHaveBeenCalledTimes(3); + unsubscribe(); + }); + + it("keeps MapLibre terrain off until the final suppression is released", () => { + const terrainSpec = { source: "terrain", exaggeration: 1 }; + const handlers = new Map void>>(); + let terrain: typeof terrainSpec | null = terrainSpec; + const map = { + getTerrain: vi.fn(() => terrain), + isStyleLoaded: vi.fn(() => true), + setTerrain: vi.fn((next: typeof terrainSpec | null) => { + terrain = next; + for (const handler of handlers.get("terrain") ?? []) handler(); + }), + on: vi.fn((event: string, handler: () => void) => { + const eventHandlers = handlers.get(event) ?? new Set(); + eventHandlers.add(handler); + handlers.set(event, eventHandlers); + }), + off: vi.fn((event: string, handler: () => void) => { + handlers.get(event)?.delete(handler); + }), + }; + + const releaseFirst = suppressMapLibreTerrainRendering(map as never); + const releaseSecond = suppressMapLibreTerrainRendering(map as never); + expect(terrain).toBeNull(); + + map.setTerrain({ source: "other", exaggeration: 2 }); + expect(terrain).toBeNull(); + + releaseFirst(); + expect(terrain).toBeNull(); + releaseSecond(); + expect(terrain).toEqual(terrainSpec); + }); +}); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts new file mode 100644 index 0000000000..1d3eea3aeb --- /dev/null +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-terrain-registry.ts @@ -0,0 +1,118 @@ +import type { Map as MaplibreMap, TerrainSpecification } from "maplibre-gl"; + +type TerrainHeightSampler = ( + longitude: number, + latitude: number +) => number | undefined; + +type SuppressedTerrainEntry = { + references: number; + terrain: TerrainSpecification | null; + clearTerrain: () => void; +}; + +const samplers = new WeakMap>(); +const listeners = new WeakMap void>>(); +const suppressedTerrain = new WeakMap(); + +export const notifySharedThreeTerrainChanged = (map: MaplibreMap) => { + for (const listener of listeners.get(map) ?? []) listener(); +}; + +export const subscribeSharedThreeTerrain = ( + map: MaplibreMap, + listener: () => void +): (() => void) => { + const mapListeners = listeners.get(map) ?? new Set<() => void>(); + mapListeners.add(listener); + listeners.set(map, mapListeners); + return () => { + mapListeners.delete(listener); + if (mapListeners.size === 0) listeners.delete(map); + }; +}; + +export const registerSharedThreeTerrainSampler = ( + map: MaplibreMap, + id: string, + sampler: TerrainHeightSampler +): (() => void) => { + const mapSamplers = + samplers.get(map) ?? new Map(); + mapSamplers.set(id, sampler); + samplers.set(map, mapSamplers); + notifySharedThreeTerrainChanged(map); + return () => { + mapSamplers.delete(id); + if (mapSamplers.size === 0) samplers.delete(map); + notifySharedThreeTerrainChanged(map); + }; +}; + +export const getSharedThreeTerrainElevation = ( + map: MaplibreMap, + longitude: number, + latitude: number +): number | undefined => { + for (const sampler of samplers.get(map)?.values() ?? []) { + const height = sampler(longitude, latitude); + if (Number.isFinite(height)) return height; + } + return undefined; +}; + +export const isMapLibreTerrainRenderingSuppressed = (map: MaplibreMap) => + suppressedTerrain.has(map); + +/** + * Removes MapLibre's raster-DEM surface while retaining its specification for + * restoration. Shared Three terrain samplers remain available to building + * generation while the raster terrain renderer and tile manager are unloaded. + */ +export const suppressMapLibreTerrainRendering = ( + map: MaplibreMap +): (() => void) => { + const existing = suppressedTerrain.get(map); + if (existing) { + existing.references += 1; + } else { + let clearingTerrain = false; + const clearTerrain = () => { + if (clearingTerrain || !map.getTerrain() || !map.isStyleLoaded()) return; + clearingTerrain = true; + try { + map.setTerrain(null); + } finally { + clearingTerrain = false; + } + }; + const entry = { + references: 1, + terrain: map.getTerrain() ?? null, + clearTerrain, + }; + suppressedTerrain.set(map, entry); + map.on("terrain", clearTerrain); + map.on("styledata", clearTerrain); + clearTerrain(); + notifySharedThreeTerrainChanged(map); + } + + let restored = false; + return () => { + if (restored) return; + restored = true; + const entry = suppressedTerrain.get(map); + if (!entry) return; + entry.references -= 1; + if (entry.references > 0) return; + map.off("terrain", entry.clearTerrain); + map.off("styledata", entry.clearTerrain); + suppressedTerrain.delete(map); + try { + if (entry.terrain && map.isStyleLoaded()) map.setTerrain(entry.terrain); + } finally { + notifySharedThreeTerrainChanged(map); + } + }; +}; diff --git a/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts new file mode 100644 index 0000000000..d0e3402afd --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { createProjectedTerrainTileGeometry } from "./terrain-tile-geometry"; + +const tile = { + bounds: { west: 0, south: 0, east: 1, north: 1 }, + u: [0, 0, 1, 1], + v: [0, 1, 0, 1], + heightMeters: [10, 10, 10, 10], + indices: [0, 3, 1, 0, 2, 3], +}; + +describe("createProjectedTerrainTileGeometry", () => { + it("projects the native TIN with upward-facing normals", () => { + const geometry = createProjectedTerrainTileGeometry({ + tile, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }); + + expect([...(geometry.getIndex()?.array ?? [])]).toEqual([0, 3, 1, 0, 2, 3]); + expect(geometry.getAttribute("position").count).toBe(4); + expect(geometry.getAttribute("uv")).toBeUndefined(); + expect(geometry.getAttribute("normal").getY(0)).toBeCloseTo(1); + }); + + it("does not create skirt geometry from Cesium edge metadata", () => { + const geometry = createProjectedTerrainTileGeometry({ + tile: { + ...tile, + westIndices: [0, 1], + westSkirtHeight: 4, + }, + projectToWorld: (longitude, latitude, height, target) => + target.set(longitude, height, -latitude), + }); + + expect(geometry.getAttribute("position").count).toBe(4); + expect(geometry.getIndex()?.count).toBe(6); + }); +}); diff --git a/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts new file mode 100644 index 0000000000..60b3e38f38 --- /dev/null +++ b/libraries/mapping/engines/three/primitives/src/lib/common/terrain-tile-geometry.ts @@ -0,0 +1,82 @@ +import { BufferGeometry, Float32BufferAttribute, Vector3 } from "three"; + +export type ProjectedTerrainTileBounds = Readonly<{ + west: number; + south: number; + east: number; + north: number; +}>; + +export type ProjectedTerrainTileSource = Readonly<{ + bounds: ProjectedTerrainTileBounds; + u: ArrayLike; + v: ArrayLike; + heightMeters: ArrayLike; + indices: ArrayLike; +}>; + +export type TerrainTileProjector = ( + longitudeDegrees: number, + latitudeDegrees: number, + heightMeters: number, + target: Vector3 +) => Vector3; + +export type ProjectedTerrainTileGeometryOptions = Readonly<{ + tile: ProjectedTerrainTileSource; + projectToWorld: TerrainTileProjector; +}>; + +const assertTile = ({ tile }: ProjectedTerrainTileGeometryOptions) => { + const vertexCount = tile.u.length; + if ( + vertexCount === 0 || + tile.v.length !== vertexCount || + tile.heightMeters.length !== vertexCount + ) { + throw new RangeError("Terrain tile vertex arrays have different sizes"); + } + if (tile.indices.length % 3 !== 0) { + throw new RangeError("Terrain tile indices must describe triangles"); + } +}; + +/** Projects one native quantized-mesh TIN tile without synthetic skirts. */ +export const createProjectedTerrainTileGeometry = ( + options: ProjectedTerrainTileGeometryOptions +): BufferGeometry => { + assertTile(options); + const { tile, projectToWorld } = options; + const positions = new Float32Array(tile.u.length * 3); + const projected = new Vector3(); + + const projectVertex = (sourceIndex: number) => { + const longitude = + tile.bounds.west + + tile.u[sourceIndex] * (tile.bounds.east - tile.bounds.west); + const latitude = + tile.bounds.south + + tile.v[sourceIndex] * (tile.bounds.north - tile.bounds.south); + projectToWorld( + longitude, + latitude, + tile.heightMeters[sourceIndex], + projected + ); + positions[sourceIndex * 3] = projected.x; + positions[sourceIndex * 3 + 1] = projected.y; + positions[sourceIndex * 3 + 2] = projected.z; + }; + + for (let index = 0; index < tile.u.length; index += 1) { + projectVertex(index); + } + + const geometry = new BufferGeometry(); + geometry.setAttribute("position", new Float32BufferAttribute(positions, 3)); + geometry.setIndex(Array.from(tile.indices)); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + return geometry; +}; diff --git a/libraries/mapping/engines/three/primitives/src/lib/index.ts b/libraries/mapping/engines/three/primitives/src/lib/index.ts index 3ac902e406..0777d8c911 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/index.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/index.ts @@ -5,3 +5,10 @@ export type { UtmGridSurface, UtmGridSurfaceOptions, } from "./common/utm-grid-surface"; +export { createProjectedTerrainTileGeometry } from "./common/terrain-tile-geometry"; +export type { + ProjectedTerrainTileBounds, + ProjectedTerrainTileGeometryOptions, + ProjectedTerrainTileSource, + TerrainTileProjector, +} from "./common/terrain-tile-geometry"; diff --git a/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts b/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts index 4d945c95f2..c77c00c1b2 100644 --- a/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts +++ b/libraries/mapping/engines/threejs/src/tiles3d/lodCamera.ts @@ -19,6 +19,8 @@ export interface LodCameraFrame { meterScale: number; /** Drawing buffer size in pixels. */ viewport: THREE.Vector2; + /** Optional elevation for the map centre when terrain lives outside MapLibre. */ + centerElevationMeters?: number; } /** @@ -68,7 +70,7 @@ export function synthesizeLodCamera( const centerLngLat = map.getCenter(); const centerMerc = MercatorCoordinate.fromLngLat( centerLngLat, - map.queryTerrainElevation(centerLngLat) ?? 0 + frame.centerElevationMeters ?? map.queryTerrainElevation(centerLngLat) ?? 0 ); lookTarget.set( (centerMerc.x - originMerc.x) / meterScale, From 9cbc0bee2e0a01fc6d377b6821904d7d3756ef06 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Tue, 25 Aug 2026 23:12:32 +0200 Subject: [PATCH 03/78] feat: refine shadow simulation rendering --- .../src/addons/ShadowSimulation/index.tsx | 104 ++++++++++++ .../ShadowSimulation/shadow-scene.spec.ts | 45 ++++- .../addons/ShadowSimulation/shadow-scene.ts | 117 ++++++++++++- .../src/lib/components/iconMapping.ts | 3 + .../src/factories/ExtrusionFactory.spec.ts | 101 +++++++++++ .../threejs/src/factories/ExtrusionFactory.ts | 160 ++++++++++++++---- 6 files changed, 485 insertions(+), 45 deletions(-) create mode 100644 libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index 7e15536382..f007c58597 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -19,6 +19,7 @@ import type { AddonComponentProps } from "../../lib/registry"; import { SolarDayTimeControl } from "./SolarDayTimeControl"; import { buildShadowSimulationScene } from "./shadow-scene"; import type { + ShadowQualityMultiplier, ShadowSimulationScene, ShadowTerrainOptions, } from "./shadow-scene"; @@ -33,6 +34,7 @@ import { const ACTIVE_CONTROL_COLOR = "#1677ff"; const DEFAULT_TERRAIN_COLOR = "#d8d1c4"; +const DEFAULT_BUILDING_COLOR = "#d8d1c4"; const resolveTerrainColor = (value: unknown) => { if (typeof value === "string" && /^#[\da-f]{6}$/i.test(value)) return value; @@ -61,6 +63,10 @@ export type ShadowSimulationState = { enabled: boolean; selection: SolarSelection; terrainColor: string; + buildingsFullOpacity: boolean; + useUniformBuildingColor: boolean; + buildingColor: string; + shadowQuality: ShadowQualityMultiplier; }; const ShadowSimulationSettings = ({ @@ -75,6 +81,10 @@ const ShadowSimulationSettings = ({ showTerrainColor: boolean; }) => { const terrainColor = state.terrainColor ?? DEFAULT_TERRAIN_COLOR; + const buildingsFullOpacity = state.buildingsFullOpacity ?? true; + const useUniformBuildingColor = state.useUniformBuildingColor ?? false; + const buildingColor = state.buildingColor ?? DEFAULT_BUILDING_COLOR; + const shadowQuality = state.shadowQuality ?? 1; const solarPosition = useMemo( () => getSolarPosition(state.selection, location), [location, state.selection] @@ -91,6 +101,27 @@ const ShadowSimulationSettings = ({ setState({ ...state, selection, terrainColor }) } /> + {showTerrainColor && ( )} +
+ Gebäude + + + {useUniformBuildingColor && ( + + )} +
); }; @@ -154,12 +236,30 @@ const ShadowSimulationRuntime = ({ shadowScene.current?.updateSolarPosition(solarPosition); }, [solarPosition]); + useEffect(() => { + shadowScene.current?.updateShadowQuality(state.shadowQuality ?? 1); + }, [state.shadowQuality]); + useEffect(() => { shadowScene.current?.updateTerrainColor( state.terrainColor ?? DEFAULT_TERRAIN_COLOR ); }, [state.terrainColor]); + useEffect(() => { + shadowScene.current?.updateBuildingAppearance({ + fullOpacity: state.buildingsFullOpacity ?? true, + uniformColor: + state.useUniformBuildingColor ?? false + ? state.buildingColor ?? DEFAULT_BUILDING_COLOR + : null, + }); + }, [ + state.buildingColor, + state.buildingsFullOpacity, + state.useUniformBuildingColor, + ]); + return null; }; @@ -194,6 +294,10 @@ export const ShadowSimulation = ({ return { enabled: false, terrainColor: resolveTerrainColor(terrain?.material?.color), + buildingsFullOpacity: true, + useUniformBuildingColor: false, + buildingColor: DEFAULT_BUILDING_COLOR, + shadowQuality: 1, selection: clampSelectionToDaylight(candidate, location) ?? { ...candidate, minutes: 12 * 60, diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts index e57358556e..63c1f3852c 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -145,10 +145,16 @@ describe("shadow scene lighting integration", () => { "shadow-simulation-sun" ) as THREE.DirectionalLight; expect(sun.castShadow).toBe(true); + expect(sun.shadow.mapSize.toArray()).toEqual([2_048, 2_048]); expect(sun.position.clone().normalize().x).toBeCloseTo(0.5); expect(sun.position.clone().normalize().y).toBeCloseTo(Math.SQRT1_2); expect(sun.position.clone().normalize().z).toBeCloseTo(0.5); + controller.updateShadowQuality(4); + expect(sun.shadow.mapSize.toArray()).toEqual([4_096, 4_096]); + controller.updateShadowQuality(16); + expect(sun.shadow.mapSize.toArray()).toEqual([8_192, 8_192]); + controller.dispose(); expect(scene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); expect(releaseScene).toHaveBeenCalledOnce(); @@ -162,11 +168,18 @@ describe("shadow scene lighting integration", () => { terrain.name = "terrain"; scene.add(terrain); const alkisScene = new THREE.Scene(); + const sourceBuildingMaterial = new THREE.MeshLambertMaterial({ + color: 0xffffff, + opacity: 0.45, + transparent: true, + vertexColors: true, + }); const building = new THREE.Mesh( new THREE.BoxGeometry(10, 20, 10), - new THREE.MeshLambertMaterial() + sourceBuildingMaterial ); building.name = "alkis-building"; + building.userData.isBuilding = true; alkisScene.add(building); vi.mocked(getGenericThreeLayers).mockReturnValue([ { @@ -198,14 +211,42 @@ describe("shadow scene lighting integration", () => { expect(building.visible).toBe(false); expect(buildingCopy.castShadow).toBe(true); expect(buildingCopy.receiveShadow).toBe(true); + expect(buildingCopy.material).not.toBe(sourceBuildingMaterial); + expect((buildingCopy.material as THREE.Material).opacity).toBe(1); + expect((buildingCopy.material as THREE.Material).transparent).toBe(false); expect(terrain.castShadow).toBe(true); expect(terrain.receiveShadow).toBe(true); expect(buildingCopy.parent?.parent).toBe(scene); expect(terrain.parent).toBe(scene); + controller.updateBuildingAppearance({ + fullOpacity: true, + uniformColor: "#8c7a66", + }); + const uniformCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + const uniformMaterial = uniformCopy.material as THREE.MeshLambertMaterial; + expect(uniformMaterial.color.getHexString()).toBe("8c7a66"); + expect(uniformMaterial.vertexColors).toBe(false); + + controller.updateBuildingAppearance({ + fullOpacity: false, + uniformColor: null, + }); + const styledCopy = scene.getObjectByName( + "alkis-building-shadow-simulation-copy" + ) as THREE.Mesh; + const styledMaterial = styledCopy.material as THREE.MeshLambertMaterial; + expect(styledMaterial.opacity).toBe(0.45); + expect(styledMaterial.transparent).toBe(true); + expect(styledMaterial.vertexColors).toBe(true); + controller.dispose(); expect(building.visible).toBe(true); - expect(scene.getObjectByName(buildingCopy.name)).toBeUndefined(); + expect( + scene.getObjectByName("alkis-building-shadow-simulation-copy") + ).toBeUndefined(); }); it("keeps the full map viewport inside the shadow camera", () => { diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index 832ed86d8d..e0c7a9c96f 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -18,6 +18,7 @@ import type { SolarPosition } from "./solar-position"; const DEFAULT_SHADOW_AREA_METERS = 900; const DEFAULT_LIGHT_DISTANCE_METERS = 2_500; +const DEFAULT_SHADOW_MAP_SIZE = 2_048; const SHADOW_SIMULATION_SUN_NAME = "shadow-simulation-sun"; const SHADOW_SIMULATION_TERRAIN_RUNTIME_ID = "shadow-simulation-cesium-terrain"; @@ -29,11 +30,13 @@ type ShadowLightBinding = { lightTarget: THREE.Object3D; center: THREE.Vector3; lightDistanceMeters: number; + shadowQuality: ShadowQualityMultiplier; }; type GenericThreeShadowBridge = { runtime: SharedThreeSceneRuntime; sync: () => void; + updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; }; export type ShadowSceneOptions = { @@ -44,9 +47,18 @@ export type ShadowSceneOptions = { export type ShadowTerrainOptions = Readonly<{ url: string }> & Omit; +export type ShadowBuildingAppearance = Readonly<{ + fullOpacity: boolean; + uniformColor: string | null; +}>; + +export type ShadowQualityMultiplier = 1 | 4 | 16; + export type ShadowSimulationScene = { updateSolarPosition: (position: SolarPosition) => void; updateTerrainColor: (color: string) => void; + updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; + updateShadowQuality: (quality: ShadowQualityMultiplier) => void; dispose: () => void; }; @@ -65,12 +77,25 @@ export const solarPositionToSceneDirection = ({ ).normalize(); }; +const configureShadowMapQuality = ( + light: THREE.DirectionalLight, + quality: ShadowQualityMultiplier +) => { + const size = DEFAULT_SHADOW_MAP_SIZE * Math.sqrt(quality); + if (light.shadow.mapSize.x === size && light.shadow.mapSize.y === size) + return; + light.shadow.map?.dispose(); + light.shadow.map = null; + light.shadow.mapSize.set(size, size); +}; + const configureShadowCamera = ( light: THREE.DirectionalLight, - shadowAreaMeters: number + shadowAreaMeters: number, + quality: ShadowQualityMultiplier ) => { light.castShadow = true; - light.shadow.mapSize.set(2_048, 2_048); + configureShadowMapQuality(light, quality); light.shadow.bias = -0.00008; light.shadow.normalBias = 0.45; light.shadow.radius = 2; @@ -114,9 +139,50 @@ const meshIsVisible = (mesh: THREE.Mesh, scene: THREE.Scene): boolean => { return materials.some(materialIsVisible); }; +const disposeCopiedMaterials = (root: THREE.Object3D) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (const material of materials) material.dispose(); + }); +}; + +const applyBuildingAppearance = ( + root: THREE.Object3D, + appearance: ShadowBuildingAppearance +) => { + root.traverse((object) => { + const mesh = object as THREE.Mesh; + if (!mesh.userData.isBuilding) return; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (const material of materials) { + if (appearance.fullOpacity) { + material.opacity = 1; + material.transparent = false; + material.depthWrite = true; + } + const colorMaterial = material as THREE.Material & { + color?: THREE.Color; + vertexColors?: boolean; + }; + if (appearance.uniformColor && colorMaterial.color) { + colorMaterial.color.set(appearance.uniformColor); + colorMaterial.vertexColors = false; + } + material.needsUpdate = true; + } + }); +}; + const buildGenericThreeShadowBridge = ( sharedLayer: SharedThreeSceneLayer, - layer: GenericThreeLayer + layer: GenericThreeLayer, + initialBuildingAppearance: ShadowBuildingAppearance ): GenericThreeShadowBridge | null => { const origin = layer._originMerc?.toLngLat(); if (!origin) return null; @@ -124,6 +190,7 @@ const buildGenericThreeShadowBridge = ( const root = new THREE.Group(); root.name = `shadow-simulation-copy-${layer.id}`; const originalVisibility = new Map(); + let buildingAppearance = initialBuildingAppearance; let disposed = false; const restoreOriginals = () => { @@ -136,6 +203,7 @@ const buildGenericThreeShadowBridge = ( const sync = () => { if (disposed) return; restoreOriginals(); + disposeCopiedMaterials(root); root.clear(); layer.scene.updateMatrixWorld(true); const sourceMeshes: THREE.Mesh[] = []; @@ -156,11 +224,15 @@ const buildGenericThreeShadowBridge = ( copy.visible = true; copy.matrixAutoUpdate = false; copy.matrix.copy(source.matrixWorld); + copy.material = Array.isArray(source.material) + ? source.material.map((material) => material.clone()) + : source.material.clone(); originalVisibility.set(source, source.visible); source.visible = false; root.add(copy); } root.visible = root.children.length > 0; + applyBuildingAppearance(root, buildingAppearance); }; const runtime: SharedThreeSceneRuntime = { @@ -173,6 +245,7 @@ const buildGenericThreeShadowBridge = ( if (disposed) return; disposed = true; restoreOriginals(); + disposeCopiedMaterials(root); root.clear(); }, }; @@ -182,7 +255,14 @@ const buildGenericThreeShadowBridge = ( return null; } sharedLayer.addRuntime(runtime); - return { runtime, sync }; + return { + runtime, + sync, + updateBuildingAppearance(appearance) { + buildingAppearance = appearance; + sync(); + }, + }; }; const updateBindingCenter = (binding: ShadowLightBinding) => { @@ -204,7 +284,8 @@ const buildShadowLightBinding = ( sunLight, lightTarget, center: new THREE.Vector3(), - lightDistanceMeters: configureShadowCamera(sunLight, shadowAreaMeters), + lightDistanceMeters: configureShadowCamera(sunLight, shadowAreaMeters, 1), + shadowQuality: 1, }; scene.add(lightTarget, sunLight); makeSceneMeshesShadeable(scene); @@ -240,6 +321,10 @@ export const buildShadowSimulationScene = ( const { shadowAreaMeters = DEFAULT_SHADOW_AREA_METERS, terrain } = options; const previousLight = map.getLight(); let latestSolarPosition: SolarPosition | null = null; + let latestBuildingAppearance: ShadowBuildingAppearance = { + fullOpacity: true, + uniformColor: null, + }; let disposed = false; let restoreMapLibreTerrain: (() => void) | null = null; const sceneLease = acquireSharedThreeScene(map); @@ -287,7 +372,8 @@ export const buildShadowSimulationScene = ( } sharedBinding.lightDistanceMeters = configureShadowCamera( sharedBinding.sunLight, - Math.max(shadowAreaMeters, radiusMeters * 2.4) + Math.max(shadowAreaMeters, radiusMeters * 2.4), + sharedBinding.shadowQuality ); if (latestSolarPosition) { applySolarPositionToBinding( @@ -326,7 +412,11 @@ export const buildShadowSimulationScene = ( continue; } if (!layer.scene) continue; - const nextBridge = buildGenericThreeShadowBridge(sceneLease.layer, layer); + const nextBridge = buildGenericThreeShadowBridge( + sceneLease.layer, + layer, + latestBuildingAppearance + ); if (nextBridge) genericBridges.set(layer, nextBridge); } makeSceneMeshesShadeable(sceneLease.layer.getScene()); @@ -376,6 +466,19 @@ export const buildShadowSimulationScene = ( updateTerrainColor(color) { terrainRuntime?.setMaterialColor(color); }, + updateBuildingAppearance(appearance) { + latestBuildingAppearance = appearance; + for (const bridge of genericBridges.values()) { + bridge.updateBuildingAppearance(appearance); + } + map.triggerRepaint(); + }, + updateShadowQuality(quality) { + sharedBinding.shadowQuality = quality; + configureShadowMapQuality(sharedBinding.sunLight, quality); + sharedBinding.sunLight.shadow.needsUpdate = true; + map.triggerRepaint(); + }, dispose() { if (disposed) return; disposed = true; diff --git a/libraries/mapping/components/src/lib/components/iconMapping.ts b/libraries/mapping/components/src/lib/components/iconMapping.ts index b16ec04ca5..e8b92fdb67 100644 --- a/libraries/mapping/components/src/lib/components/iconMapping.ts +++ b/libraries/mapping/components/src/lib/components/iconMapping.ts @@ -6,6 +6,7 @@ import { faObjectGroup, faRuler, faSquare, + faSun, faTableColumns, } from "@fortawesome/free-solid-svg-icons"; @@ -14,6 +15,7 @@ export const iconMap = { highlight: faObjectGroup, comparing: faTableColumns, timeSeries: faClock, + "shadow-simulation": faSun, background: faLayerGroup, ortho: faGlobe, }; @@ -22,4 +24,5 @@ export const iconColorMap = { bäume: "green", gärten: "purple", ortho: "black", + "shadow-simulation": "#d97706", }; diff --git a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts new file mode 100644 index 0000000000..6301f1446c --- /dev/null +++ b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.spec.ts @@ -0,0 +1,101 @@ +import { MercatorCoordinate } from "maplibre-gl"; +import * as THREE from "three"; +import { describe, expect, it } from "vitest"; + +import { buildExtrusionMeshes } from "./ExtrusionFactory"; + +const counterClockwiseRing = [ + [7.2, 51.2], + [7.2002, 51.2], + [7.2002, 51.2002], + [7.2, 51.2002], +]; + +const getFaceNormal = ( + geometry: THREE.BufferGeometry, + faceIndex: number +): THREE.Vector3 => { + const positions = geometry.getAttribute("position"); + const indices = geometry.getIndex(); + if (!indices) throw new Error("Expected indexed extrusion geometry"); + const offset = faceIndex * 3; + const a = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset) + ); + const b = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset + 1) + ); + const c = new THREE.Vector3().fromBufferAttribute( + positions, + indices.getX(offset + 2) + ); + return b.sub(a).cross(c.sub(a)).normalize(); +}; + +describe("buildExtrusionMeshes", () => { + it.each([ + ["counter-clockwise", counterClockwiseRing], + ["clockwise", [...counterClockwiseRing].reverse()], + ])("builds outward, single-sided faces from a %s ring", (_label, ring) => { + const scene = new THREE.Scene(); + const origin = MercatorCoordinate.fromLngLat([7.2, 51.2], 0); + buildExtrusionMeshes( + [ + { + ring, + height: 12, + elevation: 150, + isPublic: false, + sourceIndex: 0, + }, + ], + scene, + origin, + origin.meterInMercatorCoordinateUnits() + ); + + const wall = scene.children.find( + (child) => (child as THREE.Mesh).userData.isBuildingWall + ) as THREE.Mesh; + const roof = scene.children.find( + (child) => + (child as THREE.Mesh).userData.isBuilding && + !(child as THREE.Mesh).userData.isBuildingWall + ) as THREE.Mesh; + + expect((wall.material as THREE.Material).side).toBe(THREE.FrontSide); + expect((roof.material as THREE.Material).side).toBe(THREE.FrontSide); + + const roofIndex = roof.geometry.getIndex(); + expect(roofIndex).not.toBeNull(); + for (let face = 0; face < (roofIndex?.count ?? 0) / 3; face += 1) { + expect(getFaceNormal(roof.geometry, face).y).toBeGreaterThan(0.999); + } + + const wallPositions = wall.geometry.getAttribute("position"); + const center = new THREE.Vector3(); + for (let index = 0; index < wallPositions.count; index += 1) { + center.add(new THREE.Vector3().fromBufferAttribute(wallPositions, index)); + } + center.divideScalar(wallPositions.count); + const wallIndex = wall.geometry.getIndex(); + expect(wallIndex).not.toBeNull(); + for (let edge = 0; edge < wallPositions.count / 4; edge += 1) { + const edgeBase = edge * 4; + const edgeMidpoint = new THREE.Vector3() + .fromBufferAttribute(wallPositions, edgeBase) + .add( + new THREE.Vector3().fromBufferAttribute(wallPositions, edgeBase + 1) + ) + .multiplyScalar(0.5); + const outward = edgeMidpoint.sub(center).setY(0).normalize(); + for (const face of [edge * 2, edge * 2 + 1]) { + expect(getFaceNormal(wall.geometry, face).dot(outward)).toBeGreaterThan( + 0.999 + ); + } + } + }); +}); diff --git a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts index a6fdb530bd..55714d929e 100644 --- a/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts +++ b/libraries/mapping/engines/threejs/src/factories/ExtrusionFactory.ts @@ -71,6 +71,19 @@ const WALL_DARKEN = 0.85; const COLOR_DEFAULT_WALL = COLOR_DEFAULT.clone().multiplyScalar(WALL_DARKEN); const COLOR_PUBLIC_WALL = COLOR_PUBLIC.clone().multiplyScalar(WALL_DARKEN); +/** GeoJSON exterior rings are counter-clockwise in longitude/latitude. The + * MapLibre-local X/Z plane flips latitude to south, making that orientation + * clockwise there: roof faces point up and wall faces point out. */ +const orientExteriorRing = (ring: number[][]): number[][] => { + let signedAreaTwice = 0; + for (let index = 0; index < ring.length; index += 1) { + const current = ring[index]; + const next = ring[(index + 1) % ring.length]; + signedAreaTwice += current[0] * next[1] - next[0] * current[1]; + } + return signedAreaTwice < 0 ? [...ring].reverse() : ring; +}; + /** * What a colour resolver is actually allowed to look at. * @@ -143,7 +156,9 @@ const HEX = /^#?(?:[0-9a-f]{3}|[0-9a-f]{6})$/i; */ const colorCache = new Map(); -const parseHexColor = (value: string | null | undefined): THREE.Color | null => { +const parseHexColor = ( + value: string | null | undefined +): THREE.Color | null => { if (!value) { return null; } @@ -154,9 +169,7 @@ const parseHexColor = (value: string | null | undefined): THREE.Color | null => const trimmed = value.trim(); let parsed: THREE.Color | null = null; if (HEX.test(trimmed)) { - parsed = new THREE.Color( - trimmed.startsWith("#") ? trimmed : `#${trimmed}` - ); + parsed = new THREE.Color(trimmed.startsWith("#") ? trimmed : `#${trimmed}`); } else { console.warn( `[3D-BUILDINGS] not a hex colour, falling back: ${JSON.stringify(value)}` @@ -264,7 +277,6 @@ export const wallRunsForRing = ( return walls; }; - /** * The material both building meshes are made of. * @@ -279,7 +291,7 @@ export function createBuildingMaterial(): THREE.MeshLambertMaterial { transparent: true, opacity: DEFAULT_BUILDING_OPACITY, depthWrite: true, - side: THREE.DoubleSide, + side: THREE.FrontSide, }); } @@ -291,7 +303,7 @@ export function removeBuildingMeshes(scene: THREE.Scene): void { const toRemove = scene.children.filter( (c): c is THREE.Mesh => (c as THREE.Mesh).isMesh === true && - (c as THREE.Mesh).userData.isBuilding === true, + (c as THREE.Mesh).userData.isBuilding === true ); for (const m of toRemove) { m.geometry.dispose(); @@ -333,6 +345,7 @@ export function buildExtrusionMeshes( ring = ring.slice(0, -1); } if (ring.length < 3) continue; + ring = orientExteriorRing(ring); validFeatures.push({ f, ring }); } @@ -409,16 +422,25 @@ export function buildExtrusionMeshes( for (let i = 0; i < n; i++) { const [lng, lat] = ring[i]; - const mH = MercatorCoordinate.fromLngLat([lng, lat], f.elevation + f.height); + const mH = MercatorCoordinate.fromLngLat( + [lng, lat], + f.elevation + f.height + ); const rx = (mH.x - originMerc.x) / mScale; const ry = (mH.z - originMerc.z) / mScale; const rz = (mH.y - originMerc.y) / mScale; // Roof vertex let v3 = rv * 3; - rP[v3] = rx; rP[v3 + 1] = ry; rP[v3 + 2] = rz; - rN[v3] = 0; rN[v3 + 1] = 1; rN[v3 + 2] = 0; - rC[v3] = cr; rC[v3 + 1] = cg; rC[v3 + 2] = cb; + rP[v3] = rx; + rP[v3 + 1] = ry; + rP[v3 + 2] = rz; + rN[v3] = 0; + rN[v3 + 1] = 1; + rN[v3 + 2] = 0; + rC[v3] = cr; + rC[v3 + 1] = cg; + rC[v3 + 2] = cb; rv++; // earcut uses 2D coords: we project onto XZ plane @@ -437,7 +459,10 @@ export function buildExtrusionMeshes( const m0 = MercatorCoordinate.fromLngLat([lng, lat], f.elevation); const m1 = MercatorCoordinate.fromLngLat([lng1, lat1], f.elevation); - const mH1 = MercatorCoordinate.fromLngLat([lng1, lat1], f.elevation + f.height); + const mH1 = MercatorCoordinate.fromLngLat( + [lng1, lat1], + f.elevation + f.height + ); const ax = (m0.x - originMerc.x) / mScale; const ay = (m0.z - originMerc.z) / mScale; @@ -466,27 +491,51 @@ export function buildExtrusionMeshes( const wallBase = wv; v3 = wv * 3; - wP[v3] = ax; wP[v3 + 1] = ay; wP[v3 + 2] = az; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = ax; + wP[v3 + 1] = ay; + wP[v3 + 2] = az; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = bx; wP[v3 + 1] = by; wP[v3 + 2] = bz; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = bx; + wP[v3 + 1] = by; + wP[v3 + 2] = bz; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = dx; wP[v3 + 1] = dy; wP[v3 + 2] = dz; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = dx; + wP[v3 + 1] = dy; + wP[v3 + 2] = dz; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; v3 = wv * 3; - wP[v3] = ex; wP[v3 + 1] = ey; wP[v3 + 2] = ez; - wN[v3] = nx; wN[v3 + 1] = 0; wN[v3 + 2] = nz; - wC[v3] = wcr; wC[v3 + 1] = wcg; wC[v3 + 2] = wcb; + wP[v3] = ex; + wP[v3 + 1] = ey; + wP[v3 + 2] = ez; + wN[v3] = nx; + wN[v3 + 1] = 0; + wN[v3 + 2] = nz; + wC[v3] = wcr; + wC[v3 + 1] = wcg; + wC[v3 + 2] = wcb; wv++; // Wall indices: A-B-E, A-E-D @@ -500,31 +549,70 @@ export function buildExtrusionMeshes( // Roof triangulation via earcut (handles concave polygons, winding-insensitive) const roofIndices = Earcut.triangulate(flatXZ, undefined, 2); - for (const idx of roofIndices) { - rI[ri++] = roofBaseIdx + idx; + // Earcut faces down when its 2D X/Z output is interpreted in Three's + // Y-up space. Reverse every triangle so its face winding agrees with the + // explicit +Y roof normals and FrontSide material below. + for (let index = 0; index < roofIndices.length; index += 3) { + rI[ri++] = roofBaseIdx + roofIndices[index]; + rI[ri++] = roofBaseIdx + roofIndices[index + 2]; + rI[ri++] = roofBaseIdx + roofIndices[index + 1]; } // Record face/vertex ranges for this building (selection metadata) const wallFaceEnd = wi / 3; - wallFaceRanges.push({ faceStart: wallFaceStart, faceEnd: wallFaceEnd, sourceIndex: f.sourceIndex }); - wallVertexRanges.push({ vertexStart: wallVertStart, vertexEnd: wv, sourceIndex: f.sourceIndex }); + wallFaceRanges.push({ + faceStart: wallFaceStart, + faceEnd: wallFaceEnd, + sourceIndex: f.sourceIndex, + }); + wallVertexRanges.push({ + vertexStart: wallVertStart, + vertexEnd: wv, + sourceIndex: f.sourceIndex, + }); const roofFaceEnd = ri / 3; - roofFaceRanges.push({ faceStart: roofFaceStart, faceEnd: roofFaceEnd, sourceIndex: f.sourceIndex }); - roofVertexRanges.push({ vertexStart: roofVertStart, vertexEnd: rv, sourceIndex: f.sourceIndex }); + roofFaceRanges.push({ + faceStart: roofFaceStart, + faceEnd: roofFaceEnd, + sourceIndex: f.sourceIndex, + }); + roofVertexRanges.push({ + vertexStart: roofVertStart, + vertexEnd: rv, + sourceIndex: f.sourceIndex, + }); } // Build BufferGeometry objects const wallGeo = new THREE.BufferGeometry(); - wallGeo.setAttribute("position", new THREE.BufferAttribute(wP.subarray(0, wv * 3), 3)); - wallGeo.setAttribute("normal", new THREE.BufferAttribute(wN.subarray(0, wv * 3), 3)); - wallGeo.setAttribute("color", new THREE.BufferAttribute(wC.subarray(0, wv * 3), 3)); + wallGeo.setAttribute( + "position", + new THREE.BufferAttribute(wP.subarray(0, wv * 3), 3) + ); + wallGeo.setAttribute( + "normal", + new THREE.BufferAttribute(wN.subarray(0, wv * 3), 3) + ); + wallGeo.setAttribute( + "color", + new THREE.BufferAttribute(wC.subarray(0, wv * 3), 3) + ); wallGeo.setIndex(new THREE.BufferAttribute(wI.subarray(0, wi), 1)); const roofGeo = new THREE.BufferGeometry(); - roofGeo.setAttribute("position", new THREE.BufferAttribute(rP.subarray(0, rv * 3), 3)); - roofGeo.setAttribute("normal", new THREE.BufferAttribute(rN.subarray(0, rv * 3), 3)); - roofGeo.setAttribute("color", new THREE.BufferAttribute(rC.subarray(0, rv * 3), 3)); + roofGeo.setAttribute( + "position", + new THREE.BufferAttribute(rP.subarray(0, rv * 3), 3) + ); + roofGeo.setAttribute( + "normal", + new THREE.BufferAttribute(rN.subarray(0, rv * 3), 3) + ); + roofGeo.setAttribute( + "color", + new THREE.BufferAttribute(rC.subarray(0, rv * 3), 3) + ); roofGeo.setIndex(new THREE.BufferAttribute(rI.subarray(0, ri), 1)); const wallMat = createBuildingMaterial(); From a98fc9e70507567fdddf4014eb96ab4a3da26849 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Wed, 26 Aug 2026 00:59:05 +0200 Subject: [PATCH 04/78] feat(geoportal): harden shadow simulation --- .../src/app/constants/fachzwillinge/addons.ts | 14 +- .../wuppertal-collab-submodule | 2 +- .../src/addons/ShadowSimulation/index.tsx | 207 ++++++---- .../ShadowSimulation/shadow-scene.spec.ts | 99 ++++- .../addons/ShadowSimulation/shadow-scene.ts | 283 ++++++++++++-- .../ShadowSimulation/solar-position.spec.ts | 12 + .../lib/cesium-terrain-tile-source.spec.ts | 18 + .../src/lib/cesium-terrain-tile-source.ts | 19 +- .../src/components/ThreeLayerManager.spec.ts | 124 ++++++ .../src/components/ThreeLayerManager.tsx | 353 ++++++++++++++---- .../mapping/engines/maplibre/src/index.ts | 17 +- .../cesium-terrain-runtime.spec.ts | 211 ++++++++++- .../cesium-terrain-tile-runtime.ts | 187 +++++++--- .../generic-three-layer-registry.spec.ts | 18 +- .../generic-three-layer-registry.ts | 27 -- .../shadow-simulation-content-status.spec.ts | 121 ------ .../shadow-simulation-content-status.ts | 80 ---- .../shared-three-scene-layer.spec.ts | 51 ++- .../integrations/shared-three-scene-layer.ts | 83 +++- .../shared-three-scene-registry.spec.ts | 27 +- .../shared-three-scene-registry.ts | 49 --- .../integrations/three-tiles-runtime.ts | 1 - .../lib/common/terrain-tile-geometry.spec.ts | 28 ++ .../src/lib/common/terrain-tile-geometry.ts | 64 +++- .../src/factories/ExtrusionFactory.spec.ts | 10 +- .../threejs/src/factories/ExtrusionFactory.ts | 39 +- 26 files changed, 1540 insertions(+), 604 deletions(-) create mode 100644 libraries/mapping/engines/maplibre/src/components/ThreeLayerManager.spec.ts delete mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.spec.ts delete mode 100644 libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shadow-simulation-content-status.ts diff --git a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts index aec375bda5..f568eda5a1 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts @@ -68,14 +68,14 @@ export const addonsFachzwilling: FachzwillingRoute = { initialMinutes: 15 * 60, terrain: { url: WUPP_TERRAIN_PROVIDER.url, - errorTargetPixels: 2.5, - shadowLevelOffset: 2, - minimumLevel: 8, + errorTargetPixels: 0.5, + shadowLevelOffset: 3, + minimumLevel: 15, maximumLevel: 17, - maxSelectionTiles: 192, - requestConcurrency: 6, - maxCacheBytes: 100_663_296, - maxCachedMeshes: 256, + maxSelectionTiles: 1_536, + requestConcurrency: 12, + maxCacheBytes: 268_435_456, + maxCachedMeshes: 2_048, material: { color: "#d8d1c4", roughness: 0.96, diff --git a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule index 46ee4a8da5..4b78762041 160000 --- a/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule +++ b/libraries/collaboration/carma-wuppertal-collab/wuppertal-collab-submodule @@ -1 +1 @@ -Subproject commit 46ee4a8da5295233637c201b2ad67510818b0864 +Subproject commit 4b78762041439cfef55c29b7d7640c01aeaed3a4 diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index f007c58597..f15d44524f 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -1,13 +1,9 @@ -import { useEffect, useMemo, useRef, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { faSun } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tooltip } from "antd"; -import { - getShadowSimulationContentStatus, - subscribeShadowSimulationContentStatus, -} from "@carma-mapping/engines/maplibre"; import { Control, ControlButtonStyler, @@ -17,7 +13,10 @@ import { import { useAddonState } from "../../lib/AddonStateContext"; import type { AddonComponentProps } from "../../lib/registry"; import { SolarDayTimeControl } from "./SolarDayTimeControl"; -import { buildShadowSimulationScene } from "./shadow-scene"; +import { + buildShadowSimulationScene, + DEFAULT_SHADOW_QUALITY, +} from "./shadow-scene"; import type { ShadowQualityMultiplier, ShadowSimulationScene, @@ -67,6 +66,63 @@ export type ShadowSimulationState = { useUniformBuildingColor: boolean; buildingColor: string; shadowQuality: ShadowQualityMultiplier; + showSunDebugVector: boolean; +}; + +const getMapCenterSolarLocation = ( + libreMap: AddonComponentProps<"shadowSimulation">["libreMap"], + fallbackLatitude: number, + fallbackLongitude: number, + timeZone: string +): SolarLocation => { + const center = libreMap?.getCenter(); + return { + latitude: center?.lat ?? fallbackLatitude, + longitude: center?.lng ?? fallbackLongitude, + timeZone, + }; +}; + +const useMapCenterSolarLocation = ( + libreMap: AddonComponentProps<"shadowSimulation">["libreMap"], + fallbackLatitude: number, + fallbackLongitude: number, + timeZone: string +): SolarLocation => { + const [location, setLocation] = useState(() => + getMapCenterSolarLocation( + libreMap, + fallbackLatitude, + fallbackLongitude, + timeZone + ) + ); + + useEffect(() => { + const updateLocation = () => { + const next = getMapCenterSolarLocation( + libreMap, + fallbackLatitude, + fallbackLongitude, + timeZone + ); + setLocation((current) => + current.latitude === next.latitude && + current.longitude === next.longitude && + current.timeZone === next.timeZone + ? current + : next + ); + }; + updateLocation(); + if (!libreMap) return; + libreMap.on("moveend", updateLocation); + return () => { + libreMap.off("moveend", updateLocation); + }; + }, [fallbackLatitude, fallbackLongitude, libreMap, timeZone]); + + return location; }; const ShadowSimulationSettings = ({ @@ -82,9 +138,10 @@ const ShadowSimulationSettings = ({ }) => { const terrainColor = state.terrainColor ?? DEFAULT_TERRAIN_COLOR; const buildingsFullOpacity = state.buildingsFullOpacity ?? true; - const useUniformBuildingColor = state.useUniformBuildingColor ?? false; + const useUniformBuildingColor = state.useUniformBuildingColor ?? true; const buildingColor = state.buildingColor ?? DEFAULT_BUILDING_COLOR; - const shadowQuality = state.shadowQuality ?? 1; + const shadowQuality = state.shadowQuality ?? DEFAULT_SHADOW_QUALITY; + const showSunDebugVector = state.showSunDebugVector ?? false; const solarPosition = useMemo( () => getSolarPosition(state.selection, location), [location, state.selection] @@ -101,27 +158,43 @@ const ShadowSimulationSettings = ({ setState({ ...state, selection, terrainColor }) } /> - +
+ + +
{showTerrainColor && ( - - @@ -765,7 +622,6 @@ export const ShadowSimulationControlSurface = ({ latitude = DEFAULT_SHADOW_SIMULATION_LOCATION.latitude, longitude = DEFAULT_SHADOW_SIMULATION_LOCATION.longitude, timeZone = DEFAULT_SHADOW_SIMULATION_LOCATION.timeZone, - terrain, } = config ?? {}; const location = useMapCenterSolarLocation( libreMap, @@ -847,8 +703,10 @@ export const ShadowSimulationControlSurface = ({ isAnimating: false, shadowIntensity: 0.45, showSunDebugVector: false, + showShadowBuffers: false, showProjectionDebugView: false, - showShadowDuration: false, + useTransmittanceLut: true, + useSkyIrradianceLut: true, }); }} > @@ -868,7 +726,6 @@ export const ShadowSimulationControlSurface = ({ location={location} state={state} setState={setState} - showTerrainColor={!!terrain} /> )} @@ -920,6 +777,13 @@ const ShadowSimulationRuntime = ({ ); }, [state.enabled, state.shadowQuality]); + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateShadowMode( + state.shadowMode ?? DEFAULT_SHADOW_MODE + ); + }, [state.enabled, state.shadowMode]); + useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 0.45); @@ -932,10 +796,25 @@ const ShadowSimulationRuntime = ({ ); }, [state.enabled, state.showSunDebugVector]); + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateShadowBufferDebugVisibility( + state.showShadowBuffers ?? false + ); + }, [state.enabled, state.showShadowBuffers]); + + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateAtmosphericLutUsage({ + useTransmittanceLut: state.useTransmittanceLut ?? true, + useIrradianceLut: state.useSkyIrradianceLut ?? true, + }); + }, [state.enabled, state.useSkyIrradianceLut, state.useTransmittanceLut]); + useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateTerrainColor( - state.terrainColor ?? DEFAULT_TERRAIN_COLOR + state.terrainColor ?? DEFAULT_SHADOW_SURFACE_COLOR ); }, [state.enabled, state.terrainColor]); @@ -945,7 +824,7 @@ const ShadowSimulationRuntime = ({ fullOpacity: state.buildingsFullOpacity ?? true, uniformColor: state.useUniformBuildingColor ?? true - ? state.buildingColor ?? DEFAULT_BUILDING_COLOR + ? state.buildingColor ?? DEFAULT_SHADOW_SURFACE_COLOR : null, }); }, [ @@ -993,16 +872,19 @@ export const ShadowSimulation = ({ terrainColor: resolveTerrainColor(terrain?.material?.color), buildingsFullOpacity: true, useUniformBuildingColor: true, - buildingColor: DEFAULT_BUILDING_COLOR, + buildingColor: DEFAULT_SHADOW_SURFACE_COLOR, shadowQuality: DEFAULT_SHADOW_QUALITY, + shadowMode: DEFAULT_SHADOW_MODE, showSunDebugVector: false, + showShadowBuffers: false, showProjectionDebugView: false, + useTransmittanceLut: true, + useSkyIrradianceLut: true, controlStyle: SHADOW_CONTROL_STYLE.QUICK, animationMode: SHADOW_ANIMATION_MODE.DAY, animationSpeed: 4, isAnimating: false, shadowIntensity: 0.45, - showShadowDuration: false, selection: clampSelectionToDaylight(candidate, location) ?? { ...candidate, minutes: 12 * 60, @@ -1082,7 +964,6 @@ export const ShadowSimulation = ({ location={location} state={state} setState={setSharedState} - showTerrainColor={!!terrain} /> ); } @@ -1138,6 +1019,19 @@ export const ShadowSimulation = ({ setSharedState({ ...state, ...patch })} /> )} diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-model.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-model.ts index b04b62b22c..2c24a1c65a 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-model.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-model.ts @@ -1,9 +1,11 @@ import type { Map as MaplibreMap } from "maplibre-gl"; -import { Matrix4 } from "three"; +import { Matrix4, Quaternion, Vector3 } from "three"; import { CAMERA_TYPE } from "@carma-commons/camera/model"; +import { enuOffsetToEcef } from "@carma-geo/utils"; import { buildViewState, + buildViewStateFromEcef, readFromMaplibre, type ViewState, } from "@carma-mapping/engines-interop/view-state"; @@ -17,20 +19,173 @@ const DEBUG_SOURCE_ID = "shadow-simulation-projection-debug"; type GeographicPoint = readonly [longitude: number, latitude: number]; +export type ShadowProjectionDebugTile = Readonly<{ + id: string; + row: number; + column: number; + receiverLeftMeters: number; + receiverRightMeters: number; + receiverBottomMeters: number; + receiverTopMeters: number; + leftMeters: number; + rightMeters: number; + bottomMeters: number; + topMeters: number; + widthMeters: number; + heightMeters: number; + guardMeters: number; + texelMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; +}>; + export type ShadowProjectionDebugModel = { - viewStates: readonly [camera: ViewState, sun: ViewState]; + viewStates: readonly ViewState[]; viewportWidthMeters: number; viewportHeightMeters: number; - shadowWidthMeters: number; - shadowHeightMeters: number; + receiverCoverageWidthMeters: number; + receiverCoverageHeightMeters: number; shadowTexelWidthMeters: number; shadowTexelHeightMeters: number; horizontalProjectionPerHeight: number; elevationSpanMeters: number; + shadowTiles: readonly ShadowProjectionDebugTile[]; + activeShadowTileCount: number; + shadowTilePoolSize: number; + shadowTilePoolCapacityTexels: number; + casterReachMeters: number; +}; + +export type ShadowTileCoreInsetsPercent = Readonly<{ + left: number; + right: number; + top: number; + bottom: number; +}>; + +const clampPercent = (value: number) => Math.min(100, Math.max(0, value)); + +export const buildShadowTileCoreInsetsPercent = ( + tile: Pick< + ShadowProjectionDebugTile, + | "leftMeters" + | "rightMeters" + | "bottomMeters" + | "topMeters" + | "receiverLeftMeters" + | "receiverRightMeters" + | "receiverBottomMeters" + | "receiverTopMeters" + > +): ShadowTileCoreInsetsPercent => { + const widthMeters = Math.max( + Number.EPSILON, + tile.rightMeters - tile.leftMeters + ); + const heightMeters = Math.max( + Number.EPSILON, + tile.topMeters - tile.bottomMeters + ); + + return { + left: clampPercent( + ((tile.receiverLeftMeters - tile.leftMeters) / widthMeters) * 100 + ), + right: clampPercent( + ((tile.rightMeters - tile.receiverRightMeters) / widthMeters) * 100 + ), + top: clampPercent( + ((tile.topMeters - tile.receiverTopMeters) / heightMeters) * 100 + ), + bottom: clampPercent( + ((tile.receiverBottomMeters - tile.bottomMeters) / heightMeters) * 100 + ), + }; }; const degreesToRadians = (value: number) => (value * Math.PI) / 180; +const isFiniteMatrix = (matrix: Matrix4) => { + const determinant = matrix.determinant(); + return ( + matrix.elements.every(Number.isFinite) && + Number.isFinite(determinant) && + determinant !== 0 + ); +}; + +export const buildShadowCameraViewState = ({ + referenceViewState, + sceneAnchorPosition, + viewMatrixElements, + projectionMatrixElements, + nearMeters, + farMeters, + shadowMapWidth, + shadowMapHeight, + sourceSuffix, +}: { + referenceViewState: ViewState; + sceneAnchorPosition: Vector3; + viewMatrixElements: readonly number[]; + projectionMatrixElements: readonly number[]; + nearMeters: number; + farMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; + sourceSuffix: string; +}): ViewState | null => { + if ( + viewMatrixElements.length !== 16 || + projectionMatrixElements.length !== 16 + ) { + return null; + } + const viewMatrix = new Matrix4().fromArray([...viewMatrixElements]); + const projectionMatrix = new Matrix4().fromArray([ + ...projectionMatrixElements, + ]); + if (!isFiniteMatrix(viewMatrix) || !isFiniteMatrix(projectionMatrix)) { + return null; + } + const matrixWorld = viewMatrix.clone().invert(); + const scenePosition = new Vector3().setFromMatrixPosition(matrixWorld); + const relativePosition = scenePosition.sub(sceneAnchorPosition); + const cameraPosition = enuOffsetToEcef( + relativePosition.x, + -relativePosition.z, + relativePosition.y, + referenceViewState.anchor + ); + const orientation = new Quaternion() + .setFromRotationMatrix(matrixWorld) + .normalize(); + + return buildViewStateFromEcef({ + anchor: referenceViewState.anchor.clone(), + cameraPosition, + orientation, + intrinsics: { + type: CAMERA_TYPE.ORTHOGRAPHIC, + projectionMatrix, + frustum: { + near: nearMeters as Meters, + far: farMeters as Meters, + }, + }, + metadata: { + frameId: referenceViewState.metadata.frameId, + timestampMs: Date.now(), + sourceId: `${DEBUG_SOURCE_ID}-${sourceSuffix}`, + source: "sync", + viewport: { + widthPx: shadowMapWidth, + heightPx: shadowMapHeight, + }, + }, + }); +}; + const measureGeographicDistance = ( first: GeographicPoint, second: GeographicPoint @@ -84,53 +239,151 @@ export const buildShadowProjectionDebugModel = ( if (!cameraViewState) return null; const footprint = readViewportFootprint(map); - const shadowWidthMeters = snapshot.rightMeters - snapshot.leftMeters; - const shadowHeightMeters = snapshot.topMeters - snapshot.bottomMeters; + const tiles = snapshot.tiledShadow?.tiles ?? []; + const shadowLeftMeters = + tiles.length > 0 + ? Math.min(...tiles.map(({ receiverLeftMeters }) => receiverLeftMeters)) + : snapshot.leftMeters; + const shadowRightMeters = + tiles.length > 0 + ? Math.max(...tiles.map(({ receiverRightMeters }) => receiverRightMeters)) + : snapshot.rightMeters; + const shadowBottomMeters = + tiles.length > 0 + ? Math.min( + ...tiles.map(({ receiverBottomMeters }) => receiverBottomMeters) + ) + : snapshot.bottomMeters; + const shadowTopMeters = + tiles.length > 0 + ? Math.max(...tiles.map(({ receiverTopMeters }) => receiverTopMeters)) + : snapshot.topMeters; + const receiverCoverageWidthMeters = shadowRightMeters - shadowLeftMeters; + const receiverCoverageHeightMeters = shadowTopMeters - shadowBottomMeters; + const shadowTilePoolCapacityTexels = + snapshot.tiledShadow?.totalShadowTexels ?? + snapshot.shadowMapWidth * snapshot.shadowMapHeight; + const shadowTileTexels = Math.max( + 1, + (tiles[0]?.shadowMapWidth ?? snapshot.shadowMapWidth) * + (tiles[0]?.shadowMapHeight ?? snapshot.shadowMapHeight) + ); + const shadowTilePoolSize = Math.max( + 1, + Math.round(shadowTilePoolCapacityTexels / shadowTileTexels) + ); const sunCameraRangeMeters = Math.max(snapshot.cameraRangeMeters, 1); - const elevationRadians = degreesToRadians( - Math.max(0.01, solarPosition.elevationDegrees) + const azimuthDegrees = + snapshot.atmosphericSunlight?.azimuthDegrees ?? + solarPosition.azimuthDegrees; + const elevationDegrees = + snapshot.atmosphericSunlight?.elevationDegrees ?? + solarPosition.elevationDegrees; + const elevationRadians = degreesToRadians(Math.max(0.01, elevationDegrees)); + const sceneAnchorPosition = new Vector3().fromArray( + snapshot.sceneAnchorPositionElements ?? [0, 0, 0] ); - const sunViewState = buildViewState({ - longitude: cameraViewState.anchorCartographic.longitude, - latitude: cameraViewState.anchorCartographic.latitude, - altitude: cameraViewState.anchorCartographic.altitude, - bearing: degreesToRadians((solarPosition.azimuthDegrees + 180) % 360), - pitch: degreesToRadians(90 - solarPosition.elevationDegrees), - range: sunCameraRangeMeters, - intrinsics: { - type: CAMERA_TYPE.ORTHOGRAPHIC, - projectionMatrix: new Matrix4().fromArray([ - ...snapshot.projectionMatrixElements, - ]), - frustum: { - near: snapshot.nearMeters as Meters, - far: snapshot.farMeters as Meters, + const buildSunViewState = ( + projectionMatrixElements: readonly number[], + nearMeters: number, + farMeters: number, + shadowMapWidth: number, + shadowMapHeight: number, + sourceSuffix: string + ) => + buildViewState({ + longitude: cameraViewState.anchorCartographic.longitude, + latitude: cameraViewState.anchorCartographic.latitude, + altitude: cameraViewState.anchorCartographic.altitude, + bearing: degreesToRadians((azimuthDegrees + 180) % 360), + pitch: degreesToRadians(90 - elevationDegrees), + range: sunCameraRangeMeters, + intrinsics: { + type: CAMERA_TYPE.ORTHOGRAPHIC, + projectionMatrix: new Matrix4().fromArray([ + ...projectionMatrixElements, + ]), + frustum: { + near: nearMeters as Meters, + far: farMeters as Meters, + }, }, - }, - metadata: { - frameId: cameraViewState.metadata.frameId, - timestampMs: Date.now(), - sourceId: `${DEBUG_SOURCE_ID}-sun`, - source: "sync", - viewport: { - widthPx: snapshot.shadowMapWidth, - heightPx: snapshot.shadowMapHeight, + metadata: { + frameId: cameraViewState.metadata.frameId, + timestampMs: Date.now(), + sourceId: `${DEBUG_SOURCE_ID}-${sourceSuffix}`, + source: "sync", + viewport: { + widthPx: shadowMapWidth, + heightPx: shadowMapHeight, + }, }, - }, + }); + const shadowTileViewStates = tiles.flatMap((tile) => { + const viewState = buildShadowCameraViewState({ + referenceViewState: cameraViewState, + sceneAnchorPosition, + viewMatrixElements: tile.viewMatrixElements, + projectionMatrixElements: tile.projectionMatrixElements, + nearMeters: tile.nearMeters, + farMeters: tile.farMeters, + shadowMapWidth: tile.shadowMapWidth, + shadowMapHeight: tile.shadowMapHeight, + sourceSuffix: `sun-${tile.id}`, + }); + return viewState ? [viewState] : []; }); + const sunViewStates = + shadowTileViewStates.length > 0 + ? shadowTileViewStates + : [ + buildSunViewState( + snapshot.projectionMatrixElements, + snapshot.nearMeters, + snapshot.farMeters, + snapshot.shadowMapWidth, + snapshot.shadowMapHeight, + "sun" + ), + ]; return { - viewStates: [cameraViewState, sunViewState], + viewStates: [cameraViewState, ...sunViewStates], viewportWidthMeters: footprint.widthMeters, viewportHeightMeters: footprint.heightMeters, - shadowWidthMeters, - shadowHeightMeters, + receiverCoverageWidthMeters, + receiverCoverageHeightMeters, shadowTexelWidthMeters: - shadowWidthMeters / Math.max(1, snapshot.shadowMapWidth), + tiles[0]?.statistics.effectiveMetersPerTexel ?? + receiverCoverageWidthMeters / Math.max(1, snapshot.shadowMapWidth), shadowTexelHeightMeters: - shadowHeightMeters / Math.max(1, snapshot.shadowMapHeight), + tiles[0]?.statistics.effectiveMetersPerTexel ?? + receiverCoverageHeightMeters / Math.max(1, snapshot.shadowMapHeight), horizontalProjectionPerHeight: 1 / Math.tan(elevationRadians), elevationSpanMeters: snapshot.maximumElevationMeters - snapshot.minimumElevationMeters, + shadowTiles: tiles.map((tile) => ({ + id: tile.id, + row: tile.row, + column: tile.column, + receiverLeftMeters: tile.receiverLeftMeters, + receiverRightMeters: tile.receiverRightMeters, + receiverBottomMeters: tile.receiverBottomMeters, + receiverTopMeters: tile.receiverTopMeters, + leftMeters: tile.leftMeters, + rightMeters: tile.rightMeters, + bottomMeters: tile.bottomMeters, + topMeters: tile.topMeters, + widthMeters: tile.rightMeters - tile.leftMeters, + heightMeters: tile.topMeters - tile.bottomMeters, + guardMeters: tile.statistics.casterGuardMeters, + texelMeters: tile.statistics.effectiveMetersPerTexel, + shadowMapWidth: tile.shadowMapWidth, + shadowMapHeight: tile.shadowMapHeight, + })), + activeShadowTileCount: tiles.length, + shadowTilePoolSize, + shadowTilePoolCapacityTexels, + casterReachMeters: snapshot.tiledShadow?.casterReachMeters ?? 0, }; }; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts index d3b6de8343..f86aa6b153 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts @@ -1,5 +1,7 @@ import type { Map as MaplibreMap } from "maplibre-gl"; +import type { TiledShadowSnapshot } from "./tiled-shadow-controller"; + export type ShadowProjectionDebugSnapshot = Readonly<{ cameraRangeMeters: number; leftMeters: number; @@ -13,6 +15,16 @@ export type ShadowProjectionDebugSnapshot = Readonly<{ shadowMapHeight: number; minimumElevationMeters: number; maximumElevationMeters: number; + sceneAnchorPositionElements?: readonly [number, number, number]; + tiledShadow?: TiledShadowSnapshot | null; + atmosphericSunlight?: Readonly<{ + azimuthDegrees: number; + elevationDegrees: number; + relativeIntensity: number; + color: string; + transmittanceReady: boolean; + irradianceReady: boolean; + }> | null; }>; type ShadowProjectionDebugEntry = { diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts index 7d3c547636..5dafa51e94 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -29,6 +29,8 @@ import { buildShadowSimulationScene, solarPositionToSceneDirection, } from "./shadow-scene"; +import { evaluateAtmosphericSunlight } from "./atmospheric-sunlight"; +import { readShadowProjectionDebugSnapshot } from "./shadow-projection-debug-store"; import { getDaylightWindow, getSolarPosition } from "./solar-position"; describe("shadow scene sun direction", () => { @@ -81,6 +83,7 @@ describe("shadow scene lighting integration", () => { type SharedRuntimeFixture = { id: string; root: THREE.Object3D; + update?: (frame: unknown) => void; dispose: () => void; }; let sharedRuntimes: Map; @@ -89,6 +92,7 @@ describe("shadow scene lighting integration", () => { addRuntime: (runtime: SharedRuntimeFixture) => void; hasRuntime: (runtimeId: string) => boolean; removeRuntime: (runtimeId: string) => void; + getRenderer: () => THREE.WebGLRenderer | null; projectLngLatToScene?: ( lngLat: [number, number], altitude?: number @@ -101,6 +105,7 @@ describe("shadow scene lighting integration", () => { sharedRuntimes = new Map(); sharedLayer = { getScene: () => scene, + getRenderer: () => null, addRuntime: vi.fn((runtime) => { sharedRuntimes.set(runtime.id, runtime); scene.add(runtime.root); @@ -124,6 +129,21 @@ describe("shadow scene lighting integration", () => { }); }); + const updateTiledShadows = ( + map: unknown, + camera: THREE.PerspectiveCamera + ) => { + const runtime = sharedRuntimes.get("shadow-simulation-tiled-controller"); + expect(runtime?.update).toBeTypeOf("function"); + runtime?.update?.({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(), + viewport: new THREE.Vector2(800, 600), + }); + }; + it("drives MapLibre and the Three.js sun from the same solar position", () => { const setLight = vi.fn(); const map = { @@ -144,11 +164,22 @@ describe("shadow scene lighting integration", () => { controller.updateSolarPosition(solarPosition); + const atmosphere = evaluateAtmosphericSunlight( + solarPosition.instant, + { longitude: 7.15, latitude: 51.256, altitudeMeters: 0 }, + null + ); + expect(acquireSharedThreeScene).toHaveBeenCalledWith(map); expect(setLight).toHaveBeenLastCalledWith( expect.objectContaining({ anchor: "map", - position: [1.5, 135, 45], + position: [ + 1.5, + atmosphere.azimuthDegrees, + 90 - atmosphere.elevationDegrees, + ], + color: `#${atmosphere.color.getHexString()}`, }) ); const sun = scene.getObjectByName( @@ -159,31 +190,34 @@ describe("shadow scene lighting integration", () => { ) as THREE.ArrowHelper; const defaultSunIntensity = sun.intensity; const defaultMapIntensity = setLight.mock.lastCall?.[0].intensity as number; + const mapLightUpdateCount = setLight.mock.calls.length; controller.updateShadowIntensity(1); - expect(sun.intensity).toBeGreaterThan(defaultSunIntensity); - expect(setLight.mock.lastCall?.[0].intensity).toBeGreaterThan( - defaultMapIntensity - ); + expect(sun.intensity).toBe(defaultSunIntensity); + expect(setLight).toHaveBeenCalledTimes(mapLightUpdateCount); + expect(setLight.mock.lastCall?.[0].intensity).toBe(defaultMapIntensity); expect(sun.isDirectionalLight).toBe(true); expect(sun.shadow.camera).toBeInstanceOf(THREE.OrthographicCamera); expect(sun.shadow.camera.projectionMatrix.elements[11]).toBe(0); expect(sun.shadow.camera.projectionMatrix.elements[15]).toBe(1); - expect(sun.shadow.camera.near).toBe(1); - expect(sun.shadow.camera.far).toBe(3_950); expect(sun.castShadow).toBe(true); expect(sun.shadow.autoUpdate).toBe(false); - expect(sun.shadow.mapSize.toArray()).toEqual([4_096, 4_096]); expect(sun.shadow.radius).toBe(0); - const shadowTexelMeters = 900 / 4_096; - expect(sun.shadow.bias).toBeCloseTo( - -(shadowTexelMeters * 0.5) / - (sun.shadow.camera.far - sun.shadow.camera.near), - 10 + const tileLights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); + expect(tileLights).toHaveLength(4); + expect(tileLights.every((light) => light.shadow.intensity === 1)).toBe( + true ); - expect(sun.shadow.normalBias).toBeCloseTo(0.10986, 5); - expect(sun.position.clone().normalize().x).toBeCloseTo(0.5); - expect(sun.position.clone().normalize().y).toBeCloseTo(Math.SQRT1_2); - expect(sun.position.clone().normalize().z).toBeCloseTo(0.5); + expect( + sun.position + .clone() + .sub(sun.target.position) + .normalize() + .dot(atmosphere.directionToSun) + ).toBeCloseTo(1, 10); const vectorDirection = new THREE.Vector3(0, 1, 0) .applyQuaternion(sunVector.quaternion) .normalize(); @@ -231,19 +265,15 @@ describe("shadow scene lighting integration", () => { scene.getObjectByName("shadow-simulation-sun-vector-elevation-arc") ).toBeDefined(); - controller.updateShadowQuality(1); - expect(sun.shadow.mapSize.toArray()).toEqual([2_048, 2_048]); - expect(sun.shadow.normalBias).toBeCloseTo(0.21973, 5); - controller.updateShadowQuality(16); - expect(sun.shadow.mapSize.toArray()).toEqual([8_192, 8_192]); - expect(sun.shadow.normalBias).toBeCloseTo(0.1, 5); - controller.dispose(); expect(suppressMapLibreRegularStyleLayers).toHaveBeenCalledWith(map); expect( vi.mocked(suppressMapLibreRegularStyleLayers).mock.results[0]?.value ).toHaveBeenCalledOnce(); expect(scene.getObjectByName("shadow-simulation-sun")).toBeUndefined(); + expect( + scene.getObjectByName("shadow-simulation-sun-tile-3") + ).toBeUndefined(); expect( scene.getObjectByName("shadow-simulation-sun-vector") ).toBeUndefined(); @@ -306,6 +336,11 @@ describe("shadow scene lighting integration", () => { expect((buildingCopy.material as THREE.Material).opacity).toBe(1); expect((buildingCopy.material as THREE.Material).transparent).toBe(false); expect((buildingCopy.material as THREE.Material).shadowSide).toBeNull(); + expect((buildingCopy.material as THREE.Material).defines).toMatchObject({ + USE_CSM: 1, + CSM_CASCADES: 4, + }); + const firstCopiedMaterial = buildingCopy.material as THREE.Material; expect(terrain.castShadow).toBe(true); expect(terrain.receiveShadow).toBe(true); expect((terrain.material as THREE.Material).shadowSide).toBeNull(); @@ -322,6 +357,8 @@ describe("shadow scene lighting integration", () => { const uniformMaterial = uniformCopy.material as THREE.MeshLambertMaterial; expect(uniformMaterial.color.getHexString()).toBe("8c7a66"); expect(uniformMaterial.vertexColors).toBe(false); + expect(uniformMaterial.defines).toMatchObject({ USE_CSM: 1 }); + expect(firstCopiedMaterial.defines).toBeUndefined(); controller.updateBuildingAppearance({ fullOpacity: false, @@ -334,6 +371,8 @@ describe("shadow scene lighting integration", () => { expect(styledMaterial.opacity).toBe(0.45); expect(styledMaterial.transparent).toBe(true); expect(styledMaterial.vertexColors).toBe(true); + expect(styledMaterial.defines).toMatchObject({ USE_CSM: 1 }); + expect(uniformMaterial.defines).toBeUndefined(); controller.dispose(); expect(building.visible).toBe(true); @@ -376,6 +415,62 @@ describe("shadow scene lighting integration", () => { expect(setShadowSimulationStyle).toHaveBeenLastCalledWith(null); }); + it("re-hooks CSM after a shared runtime replaces its materials", () => { + const building = new THREE.Mesh( + new THREE.BoxGeometry(10, 20, 10), + new THREE.MeshLambertMaterial() + ); + building.userData.isBuilding = true; + scene.add(building); + const runtimeMaterials: Array<{ + material: THREE.MeshLambertMaterial; + hook: ReturnType; + }> = []; + const setShadowSimulationStyle = vi.fn(() => { + const material = new THREE.MeshLambertMaterial(); + const hook = vi.fn(); + material.onBeforeCompile = hook; + runtimeMaterials.push({ material, hook }); + building.material = material; + }); + vi.mocked(getSharedThreeSceneRuntimes).mockReturnValue([ + { setShadowSimulationStyle } as never, + ]); + const map = { + getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getLight: vi.fn(() => ({ anchor: "viewport" })), + isStyleLoaded: vi.fn(() => true), + setLight: vi.fn(), + on: vi.fn(), + off: vi.fn(), + triggerRepaint: vi.fn(), + }; + + const controller = buildShadowSimulationScene(map as never); + const initialRuntimeMaterial = + runtimeMaterials[runtimeMaterials.length - 1]; + expect(initialRuntimeMaterial?.material.defines).toMatchObject({ + USE_CSM: 1, + }); + + controller.updateBuildingAppearance({ + fullOpacity: true, + uniformColor: "#d8d1c4", + }); + + const replacement = runtimeMaterials[runtimeMaterials.length - 1]; + expect(replacement?.material).not.toBe(initialRuntimeMaterial?.material); + expect(replacement?.material.defines).toMatchObject({ USE_CSM: 1 }); + expect(initialRuntimeMaterial?.material.defines).toBeUndefined(); + expect(initialRuntimeMaterial?.material.onBeforeCompile).toBe( + initialRuntimeMaterial?.hook + ); + + controller.dispose(); + building.geometry.dispose(); + for (const { material } of runtimeMaterials) material.dispose(); + }); + it("keeps the full map viewport inside the shadow camera", () => { sharedLayer.projectLngLatToScene = ([lng, lat], altitude = 0) => new THREE.Vector3(lng * 1_000, altitude, lat * 1_000); @@ -411,9 +506,6 @@ describe("shadow scene lighting integration", () => { }; const controller = buildShadowSimulationScene(map as never); - const sun = scene.getObjectByName( - "shadow-simulation-sun" - ) as THREE.DirectionalLight; controller.updateSolarPosition({ instant: new Date("2026-06-21T10:00:00Z"), azimuthDegrees: 135, @@ -422,29 +514,87 @@ describe("shadow scene lighting integration", () => { const sunVector = scene.getObjectByName( "shadow-simulation-sun-vector" ) as THREE.ArrowHelper; - const expectCurrentViewportInsideShadowCamera = () => { - sun.shadow.updateMatrices(sun); + const updateAndExpectViewportInsideTileUnion = () => { + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set( + mapCenter.lng * 1_000, + 4_000, + mapCenter.lat * 1_000 + 4_000 + ); + camera.lookAt(mapCenter.lng * 1_000, 0, mapCenter.lat * 1_000); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateTiledShadows(map, camera); + const lights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); for (const [lng, lat] of [ [mapCenter.lng - viewportHalfWidth, mapCenter.lat - viewportHalfHeight], [mapCenter.lng - viewportHalfWidth, mapCenter.lat + viewportHalfHeight], [mapCenter.lng + viewportHalfWidth, mapCenter.lat - viewportHalfHeight], [mapCenter.lng + viewportHalfWidth, mapCenter.lat + viewportHalfHeight], ]) { - const cameraPoint = new THREE.Vector3( - lng * 1_000, - 0, - lat * 1_000 - ).applyMatrix4(sun.shadow.camera.matrixWorldInverse); - expect(cameraPoint.x).toBeGreaterThanOrEqual(sun.shadow.camera.left); - expect(cameraPoint.x).toBeLessThanOrEqual(sun.shadow.camera.right); - expect(cameraPoint.y).toBeGreaterThanOrEqual(sun.shadow.camera.bottom); - expect(cameraPoint.y).toBeLessThanOrEqual(sun.shadow.camera.top); + const worldPoint = new THREE.Vector3(lng * 1_000, 0, lat * 1_000); + const contained = lights.some((light) => { + const shadowCamera = light.shadow.camera; + const cameraPoint = worldPoint + .clone() + .applyMatrix4(shadowCamera.matrixWorldInverse); + return ( + cameraPoint.x >= shadowCamera.left - 1e-6 && + cameraPoint.x <= shadowCamera.right + 1e-6 && + cameraPoint.y >= shadowCamera.bottom - 1e-6 && + cameraPoint.y <= shadowCamera.top + 1e-6 + ); + }); + expect(contained).toBe(true); } + const snapshot = readShadowProjectionDebugSnapshot(map as never); + expect(snapshot?.tiledShadow?.tiles).toHaveLength(4); + return snapshot?.tiledShadow?.tiles + .slice() + .sort( + (left, right) => right.receiverPointCount - left.receiverPointCount + )[0]; }; - const wideViewportCameraWidth = - sun.shadow.camera.right - sun.shadow.camera.left; - expectCurrentViewportInsideShadowCamera(); + const wideViewportTile = updateAndExpectViewportInsideTileUnion(); + const shadowBufferBoxes = scene.children.filter( + (object): object is THREE.LineSegments => + object instanceof THREE.LineSegments && + object.name.startsWith("shadow-simulation-shadow-buffer-") + ); + expect(shadowBufferBoxes).toHaveLength(4); + expect(shadowBufferBoxes.every(({ visible }) => !visible)).toBe(true); + controller.updateShadowBufferDebugVisibility(true); + expect(shadowBufferBoxes.every(({ visible }) => visible)).toBe(true); + const shadowLights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); + shadowBufferBoxes.forEach((box, index) => { + const shadowCamera = shadowLights[index]!.shadow.camera; + const position = box.geometry.getAttribute("position"); + expect(position.count).toBe(48); + for ( + let cornerIndex = 0; + cornerIndex < position.count; + cornerIndex += 1 + ) { + const projected = new THREE.Vector3() + .fromBufferAttribute(position, cornerIndex) + .project(shadowCamera); + expect(Math.abs(projected.x)).toBeCloseTo(1, 5); + expect(Math.abs(projected.y)).toBeCloseTo(1, 5); + expect(Math.abs(projected.z)).toBeCloseTo(1, 5); + } + expect((box.material as THREE.Material).depthTest).toBe(false); + }); + controller.updateShadowBufferDebugVisibility(false); + expect(shadowBufferBoxes.every(({ visible }) => !visible)).toBe(true); expect(getBounds).not.toHaveBeenCalled(); expect(sunVector.position.toArray()).toEqual([0, 0, 0]); expect(sunVector.cone.position.y).toBeCloseTo(1_000); @@ -457,19 +607,45 @@ describe("shadow scene lighting integration", () => { moveHandler(); expect(sunVector.position.toArray()).toEqual([500, 0, 1_000]); - expect(sun.target.position.toArray()).toEqual([500, 0, 1_000]); - expectCurrentViewportInsideShadowCamera(); + expect( + ( + scene.getObjectByName("shadow-simulation-sun") as THREE.DirectionalLight + ).target.position.toArray() + ).toEqual([500, 0, 1_000]); + updateAndExpectViewportInsideTileUnion(); viewportHalfWidth = 0.05; viewportHalfHeight = 0.1; moveHandler(); - const zoomedViewportCameraWidth = - sun.shadow.camera.right - sun.shadow.camera.left; - expectCurrentViewportInsideShadowCamera(); - expect(zoomedViewportCameraWidth).toBeLessThan(wideViewportCameraWidth); + const zoomedViewportTile = updateAndExpectViewportInsideTileUnion(); + expect( + (zoomedViewportTile?.rightMeters ?? 0) - + (zoomedViewportTile?.leftMeters ?? 0) + ).toBeLessThan( + (wideViewportTile?.rightMeters ?? 0) - (wideViewportTile?.leftMeters ?? 0) + ); + + controller.updateShadowMode("single"); + const singleCamera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + singleCamera.position.set(500, 4_000, 5_000); + singleCamera.lookAt(500, 0, 1_000); + singleCamera.updateProjectionMatrix(); + singleCamera.updateMatrixWorld(true); + updateTiledShadows(map, singleCamera); + expect( + readShadowProjectionDebugSnapshot(map as never)?.tiledShadow + ).toMatchObject({ + strategy: "single-viewport", + tileCount: 1, + }); + controller.updateShadowBufferDebugVisibility(true); + expect(shadowBufferBoxes.filter(({ visible }) => visible)).toHaveLength(1); controller.dispose(); + expect( + scene.getObjectByName("shadow-simulation-shadow-buffer-0") + ).toBeUndefined(); }); it("includes visible elevation relief when fitting the viewport", () => { @@ -497,11 +673,22 @@ describe("shadow scene lighting integration", () => { }; const controller = buildShadowSimulationScene(map as never); - const sun = scene.getObjectByName( - "shadow-simulation-sun" - ) as THREE.DirectionalLight; - - expect(sun.shadow.camera.right).toBeGreaterThan(300); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(0, 4_000, 4_000); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + updateTiledShadows(map, camera); + const snapshot = readShadowProjectionDebugSnapshot(map as never); + + expect(snapshot?.maximumElevationMeters).toBeGreaterThanOrEqual(300); + expect(snapshot?.minimumElevationMeters).toBeLessThanOrEqual(0); + expect(snapshot?.tiledShadow?.casterReachMeters).toBeGreaterThan(300); controller.dispose(); elevatedReceiver.geometry.dispose(); @@ -519,6 +706,7 @@ describe("shadow scene lighting integration", () => { ready: Promise.resolve(true), update: vi.fn(), setVisible: vi.fn(), + setShadowCameras: vi.fn(), setShadowCamera: vi.fn(), setMaterialColor: vi.fn(), getElevation: vi.fn(() => 150), @@ -530,9 +718,14 @@ describe("shadow scene lighting integration", () => { vi.mocked(acquireSharedThreeScene).mockReturnValue({ layer: { getScene: () => scene, + getRenderer: () => null, addRuntime, hasRuntime: vi.fn(() => true), removeRuntime, + projectLngLatToScene: ( + [longitude, latitude]: [number, number], + altitude = 0 + ) => new THREE.Vector3(longitude * 1_000, altitude, latitude * 1_000), } as never, release: releaseScene, }); @@ -540,6 +733,11 @@ describe("shadow scene lighting integration", () => { const backgroundPaint = new Map(); const map = { getCenter: vi.fn(() => ({ lng: 7.15, lat: 51.256 })), + getCanvas: vi.fn(() => ({ clientWidth: 800, clientHeight: 600 })), + unproject: vi.fn(([x, y]: [number, number]) => ({ + lng: 7.15 + (x / 800 - 0.5) * 0.02, + lat: 51.256 + (0.5 - y / 600) * 0.02, + })), getLayer: vi.fn(() => backgroundLayerPresent ? { id: "__shadow-simulation-background", type: "background" } @@ -594,7 +792,7 @@ describe("shadow scene lighting integration", () => { id: "__shadow-simulation-background", type: "background", paint: expect.objectContaining({ - "background-color": "#d8d1c4", + "background-color": "#d3d3d3", "background-opacity": 1, }), }), @@ -609,6 +807,44 @@ describe("shadow scene lighting integration", () => { "#8c7a66" ); + controller.updateSolarPosition({ + instant: new Date("2026-06-21T10:00:00Z"), + azimuthDegrees: 135, + elevationDegrees: 45, + }); + const shadowRuntime = addRuntime.mock.calls.find( + ([runtime]) => runtime.id === "shadow-simulation-tiled-controller" + )?.[0] as SharedRuntimeFixture; + const camera = new THREE.PerspectiveCamera(60, 4 / 3, 1, 20_000); + camera.position.set(7_150, 4_000, 55_256); + camera.lookAt(7_150, 150, 51_256); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + shadowRuntime.update?.({ + map, + renderCamera: camera, + lodCamera: camera, + lookTarget: new THREE.Vector3(7_150, 150, 51_256), + viewport: new THREE.Vector2(800, 600), + }); + const shadowViews = terrainRuntime.setShadowCameras.mock.lastCall?.[0]; + const tileLights = scene.children.filter( + (object): object is THREE.DirectionalLight => + (object as THREE.DirectionalLight).isDirectionalLight && + object.name.startsWith("shadow-simulation-sun") + ); + expect(shadowViews).toHaveLength(tileLights.length); + for (let index = 0; index < tileLights.length; index += 1) { + expect(shadowViews[index]).toEqual({ + camera: tileLights[index].shadow.camera, + shadowMapSize: { + width: tileLights[index].shadow.mapSize.x, + height: tileLights[index].shadow.mapSize.y, + }, + }); + } + expect(shadowViews[0].shadowMapSize.width).not.toBe(800); + controller.dispose(); expect(restoreTerrain).toHaveBeenCalledOnce(); expect(removeRuntime).toHaveBeenCalledWith("terrain"); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index d8e7df24f6..6b7dae5d94 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -18,6 +18,12 @@ import type { } from "@carma-mapping/engines/maplibre"; import type { SolarPosition } from "./solar-position"; +import { + AtmosphericSunlightEvaluator, + type AtmosphericSunlightSample, + type AtmosphericSunlightOptions, +} from "./atmospheric-sunlight"; +import { TiledShadowController } from "./tiled-shadow-controller"; import { clearShadowProjectionDebugSnapshot, publishShadowProjectionDebugSnapshot, @@ -25,17 +31,7 @@ import { const FALLBACK_SHADOW_AREA_METERS = 900; const MIN_VIEWPORT_SHADOW_AREA_METERS = 10; -const VIEWPORT_SHADOW_PADDING_TEXELS = 2; const DEFAULT_SHADOW_CAMERA_OFFSET_METERS = 2_500; -const SHADOW_CAMERA_DEPTH_PADDING_METERS = 1_000; -const DEFAULT_SHADOW_MAP_SIZE = 2_048; -const SHADOW_DEPTH_BIAS_TEXELS = 0.5; -const SHADOW_NORMAL_BIAS_TEXELS = 0.5; -const MIN_SHADOW_DEPTH_BIAS_METERS = 0.05; -const MAX_SHADOW_DEPTH_BIAS_METERS = 0.5; -const MIN_SHADOW_NORMAL_BIAS_METERS = 0.1; -const MAX_SHADOW_NORMAL_BIAS_METERS = 0.75; -const SHADOW_SIMULATION_SUN_NAME = "shadow-simulation-sun"; const SHADOW_SIMULATION_SUN_VECTOR_NAME = "shadow-simulation-sun-vector"; const SHADOW_SIMULATION_TERRAIN_RUNTIME_ID = "shadow-simulation-cesium-terrain"; const SUN_VECTOR_COLOR = 0xf59e0b; @@ -45,6 +41,22 @@ const SUN_VECTOR_VIEWPORT_LENGTH_FACTOR = 0.5; const SUN_VECTOR_ANGLE_RADIUS_FACTOR = 0.22; const SUN_VECTOR_ANGLE_SEGMENTS = 24; const SHADOW_OVERLAY_MARKER = "isShadowSimulationOverlay"; +// Takram returns relative photometric direct radiance and sky irradiance. Keep +// their physical ratio intact by applying one scene exposure to both, entirely +// independent of the UI's shadow-opacity control. +const ATMOSPHERIC_LIGHT_EXPOSURE = 2; +const SHADOW_SIMULATION_SKY_LIGHT_NAME = "shadow-simulation-sky-light"; +const SHADOW_BUFFER_BORDER_COLORS = [ + 0xf59e0b, 0xea580c, 0xdc2626, 0x9333ea, +] as const; +const SHADOW_BUFFER_BOX_SEGMENTS = [ + // near/far rectangles + 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, + // connecting edges + 0, 4, 1, 5, 2, 6, 3, 7, + // X diagonals on all six faces + 0, 2, 1, 3, 4, 6, 5, 7, 0, 7, 3, 4, 1, 6, 2, 5, 0, 5, 1, 4, 3, 6, 2, 7, +] as const; type GenericThreeLayer = ReturnType[number]; @@ -59,16 +71,29 @@ type SunVectorGizmo = { type ShadowLightBinding = { scene: THREE.Scene; - sunLight: THREE.DirectionalLight; + controller: TiledShadowController; + skyLight: THREE.LightProbe; + ambientLightIntensities: Map; lightTarget: THREE.Object3D; sunVector: SunVectorGizmo; + shadowBufferBoxes: THREE.LineSegments[]; center: THREE.Vector3; shadowCameraOffsetMeters: number; shadowAreaMeters: number; sunVectorLengthMeters: number; sunVectorVisible: boolean; + projectionDebugVisible: boolean; + activeShadowTileCount: number; shadowQuality: ShadowQualityMultiplier; + shadowMode: ShadowMode; shadowIntensity: number; + directionToSun: THREE.Vector3; + sunColor: THREE.Color; + sunIntensity: number; + receiverWorldPoints: THREE.Vector3[]; + minimumElevationMeters: number; + maximumElevationMeters: number; + dirty: boolean; }; type GenericThreeShadowBridge = { @@ -91,13 +116,12 @@ export type ShadowBuildingAppearance = Readonly<{ }>; export type ShadowQualityMultiplier = 1 | 4 | 16; +export type ShadowMode = "single" | "advanced"; export const DEFAULT_SHADOW_QUALITY: ShadowQualityMultiplier = 4; +export const DEFAULT_SHADOW_MODE: ShadowMode = "single"; +export const DEFAULT_SHADOW_SURFACE_COLOR = "#d3d3d3"; -export const getShadowMapSize = (quality: ShadowQualityMultiplier) => - DEFAULT_SHADOW_MAP_SIZE * Math.sqrt(quality); - -const DEFAULT_TERRAIN_COLOR = "#d8d1c4"; const SHADOW_SIMULATION_BACKGROUND_LAYER_ID = "__shadow-simulation-background"; export type ShadowSimulationScene = { @@ -105,8 +129,11 @@ export type ShadowSimulationScene = { updateTerrainColor: (color: string) => void; updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; updateShadowQuality: (quality: ShadowQualityMultiplier) => void; + updateShadowMode: (mode: ShadowMode) => void; updateShadowIntensity: (intensity: number) => void; updateSunDebugVectorVisibility: (visible: boolean) => void; + updateShadowBufferDebugVisibility: (visible: boolean) => void; + updateAtmosphericLutUsage: (options: AtmosphericSunlightOptions) => void; dispose: () => void; }; @@ -125,126 +152,21 @@ export const solarPositionToSceneDirection = ({ ).normalize(); }; -const configureShadowMapQuality = ( - light: THREE.DirectionalLight, - quality: ShadowQualityMultiplier -) => { - const size = getShadowMapSize(quality); - if (light.shadow.mapSize.x === size && light.shadow.mapSize.y === size) - return; - light.shadow.map?.dispose(); - light.shadow.map = null; - light.shadow.mapSize.set(size, size); -}; - -const configureShadowCamera = ( - light: THREE.DirectionalLight, - shadowAreaMeters: number, - quality: ShadowQualityMultiplier +const makeMeshShadeable = ( + mesh: THREE.Mesh, + shadowController?: TiledShadowController ) => { - light.castShadow = true; - configureShadowMapQuality(light, quality); - light.shadow.radius = 0; - const halfShadowArea = shadowAreaMeters / 2; - const shadowCamera = light.shadow.camera; - shadowCamera.left = -halfShadowArea; - shadowCamera.right = halfShadowArea; - shadowCamera.top = halfShadowArea; - shadowCamera.bottom = -halfShadowArea; - // DirectionalLight rays are parallel. This offset only positions its - // orthographic depth camera; it is not a finite light-source distance. - const shadowCameraOffsetMeters = Math.max( - DEFAULT_SHADOW_CAMERA_OFFSET_METERS, - shadowAreaMeters * 1.5 - ); - shadowCamera.near = 1; - shadowCamera.far = - shadowCameraOffsetMeters + - halfShadowArea + - SHADOW_CAMERA_DEPTH_PADDING_METERS; - const shadowTexelMeters = - shadowAreaMeters / Math.max(1, light.shadow.mapSize.x); - light.shadow.normalBias = THREE.MathUtils.clamp( - shadowTexelMeters * SHADOW_NORMAL_BIAS_TEXELS, - MIN_SHADOW_NORMAL_BIAS_METERS, - MAX_SHADOW_NORMAL_BIAS_METERS - ); - const depthBiasMeters = THREE.MathUtils.clamp( - shadowTexelMeters * SHADOW_DEPTH_BIAS_TEXELS, - MIN_SHADOW_DEPTH_BIAS_METERS, - MAX_SHADOW_DEPTH_BIAS_METERS - ); - light.shadow.bias = -depthBiasMeters / (shadowCamera.far - shadowCamera.near); - shadowCamera.updateProjectionMatrix(); - return shadowCameraOffsetMeters; -}; - -const fitShadowCameraToPoints = ( - light: THREE.DirectionalLight, - points: readonly THREE.Vector3[], - minimumAreaMeters: number, - quality: ShadowQualityMultiplier -) => { - if (points.length === 0) return; - configureShadowMapQuality(light, quality); - light.shadow.updateMatrices(light); - const camera = light.shadow.camera; - camera.updateMatrixWorld(true); - let left = Number.POSITIVE_INFINITY; - let right = Number.NEGATIVE_INFINITY; - let bottom = Number.POSITIVE_INFINITY; - let top = Number.NEGATIVE_INFINITY; - for (const point of points) { - const cameraPoint = point.clone().applyMatrix4(camera.matrixWorldInverse); - left = Math.min(left, cameraPoint.x); - right = Math.max(right, cameraPoint.x); - bottom = Math.min(bottom, cameraPoint.y); - top = Math.max(top, cameraPoint.y); - } - const width = right - left; - const height = top - bottom; - const baseWidth = Math.max(width, minimumAreaMeters); - const baseHeight = Math.max(height, minimumAreaMeters); - const mapWidth = Math.max(1, light.shadow.mapSize.x); - const mapHeight = Math.max(1, light.shadow.mapSize.y); - const fittedWidth = - baseWidth + (baseWidth / mapWidth) * VIEWPORT_SHADOW_PADDING_TEXELS * 2; - const fittedHeight = - baseHeight + (baseHeight / mapHeight) * VIEWPORT_SHADOW_PADDING_TEXELS * 2; - const texelWidth = fittedWidth / mapWidth; - const texelHeight = fittedHeight / mapHeight; - // Keep only a filtering-sized guard outside the real screen footprint and - // snap the light-space center to texels so camera motion does not shimmer. - const centerX = Math.round((left + right) / 2 / texelWidth) * texelWidth; - const centerY = Math.round((bottom + top) / 2 / texelHeight) * texelHeight; - camera.left = centerX - fittedWidth / 2; - camera.right = centerX + fittedWidth / 2; - camera.bottom = centerY - fittedHeight / 2; - camera.top = centerY + fittedHeight / 2; - const shadowTexelMeters = Math.max( - fittedWidth / Math.max(1, light.shadow.mapSize.x), - fittedHeight / Math.max(1, light.shadow.mapSize.y) - ); - light.shadow.normalBias = THREE.MathUtils.clamp( - shadowTexelMeters * SHADOW_NORMAL_BIAS_TEXELS, - MIN_SHADOW_NORMAL_BIAS_METERS, - MAX_SHADOW_NORMAL_BIAS_METERS - ); - const depthBiasMeters = THREE.MathUtils.clamp( - shadowTexelMeters * SHADOW_DEPTH_BIAS_TEXELS, - MIN_SHADOW_DEPTH_BIAS_METERS, - MAX_SHADOW_DEPTH_BIAS_METERS - ); - light.shadow.bias = -depthBiasMeters / (camera.far - camera.near); - camera.updateProjectionMatrix(); - light.shadow.updateMatrices(light); - light.shadow.needsUpdate = true; -}; - -const makeMeshShadeable = (mesh: THREE.Mesh) => { if (mesh.userData[SHADOW_OVERLAY_MARKER]) return; mesh.castShadow = mesh.userData.disableShadowCasting !== true; mesh.receiveShadow = true; + if (shadowController) { + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + for (const material of materials) { + shadowController.setupMaterial(material); + } + } }; const buildSunVector = () => { @@ -326,12 +248,16 @@ const buildSunVector = () => { }; }; -const makeSceneMeshesShadeable = (scene: THREE.Scene) => { +const makeSceneMeshesShadeable = ( + scene: THREE.Scene, + shadowController?: TiledShadowController +) => { scene.traverseVisible((object) => { const mesh = object as THREE.Mesh; if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; makeMeshShadeable(mesh); }); + shadowController?.syncSceneMaterials(scene); }; const materialIsVisible = (material: THREE.Material): boolean => @@ -349,14 +275,20 @@ const meshIsVisible = (mesh: THREE.Mesh, scene: THREE.Scene): boolean => { return materials.some(materialIsVisible); }; -const disposeCopiedMaterials = (root: THREE.Object3D) => { +const disposeCopiedMaterials = ( + root: THREE.Object3D, + shadowController?: TiledShadowController +) => { root.traverse((object) => { const mesh = object as THREE.Mesh; if (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) return; const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - for (const material of materials) material.dispose(); + for (const material of materials) { + shadowController?.releaseMaterial(material); + material.dispose(); + } }); }; @@ -418,7 +350,8 @@ const applyBuildingAppearance = ( const buildGenericThreeShadowBridge = ( sharedLayer: SharedThreeSceneLayer, layer: GenericThreeLayer, - initialBuildingAppearance: ShadowBuildingAppearance + initialBuildingAppearance: ShadowBuildingAppearance, + shadowController?: TiledShadowController ): GenericThreeShadowBridge | null => { const origin = layer._originMerc?.toLngLat(); if (!origin) return null; @@ -439,7 +372,7 @@ const buildGenericThreeShadowBridge = ( const sync = () => { if (disposed) return; restoreOriginals(); - disposeCopiedMaterials(root); + disposeCopiedMaterials(root, shadowController); root.clear(); layer.scene.updateMatrixWorld(true); const sourceMeshes: THREE.Mesh[] = []; @@ -455,13 +388,13 @@ const buildGenericThreeShadowBridge = ( for (const source of sourceMeshes) { const copy = source.clone(false) as THREE.Mesh; copy.name = `${source.name || "mesh"}-shadow-simulation-copy`; - makeMeshShadeable(copy); copy.visible = true; copy.matrixAutoUpdate = false; copy.matrix.copy(source.matrixWorld); copy.material = Array.isArray(source.material) ? source.material.map((material) => material.clone()) : source.material.clone(); + makeMeshShadeable(copy, shadowController); originalVisibility.set(source, source.visible); source.visible = false; root.add(copy); @@ -479,7 +412,7 @@ const buildGenericThreeShadowBridge = ( if (disposed) return; disposed = true; restoreOriginals(); - disposeCopiedMaterials(root); + disposeCopiedMaterials(root, shadowController); root.clear(); }, }; @@ -509,49 +442,167 @@ const buildShadowLightBinding = ( scene: THREE.Scene, shadowAreaMeters: number ): ShadowLightBinding => { - const lightTarget = new THREE.Object3D(); - const sunLight = new THREE.DirectionalLight(0xfff2d8, 2.5); + const controller = new TiledShadowController(scene); + const sunLight = controller.lights[0]; + const lightTarget = sunLight.target; const sunVector = buildSunVector(); - sunLight.name = SHADOW_SIMULATION_SUN_NAME; - // MapLibre can repaint this shared canvas for unrelated style, UI, or tile - // activity. Only actual caster, camera, and sun changes below should pay for - // regenerating the (normally 4096 square) shadow map. - sunLight.shadow.autoUpdate = false; - sunLight.target = lightTarget; + const shadowBufferBoxes = controller.lights.map((_light, index) => { + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.BufferAttribute( + new Float32Array(SHADOW_BUFFER_BOX_SEGMENTS.length * 3), + 3 + ) + ); + const material = new THREE.LineBasicMaterial({ + color: + SHADOW_BUFFER_BORDER_COLORS[index % SHADOW_BUFFER_BORDER_COLORS.length], + depthTest: false, + depthWrite: false, + transparent: true, + opacity: 0.9, + toneMapped: false, + }); + const box = new THREE.LineSegments(geometry, material); + box.name = `shadow-simulation-shadow-buffer-${index}`; + box.userData[SHADOW_OVERLAY_MARKER] = true; + box.visible = false; + box.frustumCulled = false; + box.renderOrder = 10_000; + return box; + }); + const skyLight = new THREE.LightProbe(undefined, 0); + skyLight.name = SHADOW_SIMULATION_SKY_LIGHT_NAME; + const ambientLightIntensities = new Map(); + scene.traverse((object) => { + const light = object as THREE.AmbientLight; + if (light.isAmbientLight) { + ambientLightIntensities.set(light, light.intensity); + } + }); const binding: ShadowLightBinding = { scene, - sunLight, + controller, + skyLight, + ambientLightIntensities, lightTarget, sunVector, + shadowBufferBoxes, center: new THREE.Vector3(), - shadowCameraOffsetMeters: configureShadowCamera( - sunLight, - shadowAreaMeters, - DEFAULT_SHADOW_QUALITY + shadowCameraOffsetMeters: Math.max( + DEFAULT_SHADOW_CAMERA_OFFSET_METERS, + shadowAreaMeters * 1.5 ), shadowAreaMeters, sunVectorLengthMeters: shadowAreaMeters * SUN_VECTOR_VIEWPORT_LENGTH_FACTOR, sunVectorVisible: false, + projectionDebugVisible: false, + activeShadowTileCount: 0, shadowQuality: DEFAULT_SHADOW_QUALITY, + shadowMode: DEFAULT_SHADOW_MODE, shadowIntensity: 0.45, + directionToSun: new THREE.Vector3(0, 1, 0), + sunColor: new THREE.Color(0xfff2d8), + sunIntensity: ATMOSPHERIC_LIGHT_EXPOSURE, + receiverWorldPoints: [], + minimumElevationMeters: 0, + maximumElevationMeters: 0, + dirty: true, }; - scene.add(lightTarget, sunLight); - makeSceneMeshesShadeable(scene); + makeSceneMeshesShadeable(scene, controller); updateBindingCenter(binding); + scene.add(skyLight); scene.add(sunVector.root); + scene.add(...shadowBufferBoxes); return binding; }; +const updateShadowBufferBorders = ( + binding: ShadowLightBinding, + activeTileCount = binding.activeShadowTileCount +) => { + binding.activeShadowTileCount = activeTileCount; + binding.shadowBufferBoxes.forEach((box, index) => { + box.visible = binding.projectionDebugVisible && index < activeTileCount; + if (!box.visible) return; + + const light = binding.controller.lights[index]; + const shadowCamera = light?.shadow.camera; + if (!light || !shadowCamera) { + box.visible = false; + return; + } + light.target.updateMatrixWorld(true); + shadowCamera.updateMatrixWorld(true); + const corners = [ + [-1, -1, -1], + [1, -1, -1], + [1, 1, -1], + [-1, 1, -1], + [-1, -1, 1], + [1, -1, 1], + [1, 1, 1], + [-1, 1, 1], + ].map(([x, y, z]) => new THREE.Vector3(x, y, z).unproject(shadowCamera)); + const position = box.geometry.getAttribute( + "position" + ) as THREE.BufferAttribute; + SHADOW_BUFFER_BOX_SEGMENTS.forEach((cornerIndex, vertexIndex) => { + const point = corners[cornerIndex]!; + position.setXYZ(vertexIndex, point.x, point.y, point.z); + }); + position.needsUpdate = true; + box.geometry.computeBoundingSphere(); + }); +}; + +const applyAtmosphericSkyLightToBinding = ( + binding: ShadowLightBinding, + sample: AtmosphericSunlightSample +) => { + binding.scene.traverse((object) => { + const light = object as THREE.AmbientLight; + if (light.isAmbientLight && !binding.ambientLightIntensities.has(light)) { + binding.ambientLightIntensities.set(light, light.intensity); + } + }); + const coefficients = sample.skyIrradianceCoefficients; + if (coefficients?.length === binding.skyLight.sh.coefficients.length) { + coefficients.forEach((coefficient, index) => { + binding.skyLight.sh.coefficients[index].copy(coefficient); + }); + binding.skyLight.intensity = ATMOSPHERIC_LIGHT_EXPOSURE; + for (const ambientLight of binding.ambientLightIntensities.keys()) { + ambientLight.intensity = 0; + } + return; + } + binding.skyLight.sh.zero(); + binding.skyLight.intensity = 0; + for (const [ambientLight, intensity] of binding.ambientLightIntensities) { + ambientLight.intensity = intensity; + } +}; + const applySolarPositionToBinding = ( binding: ShadowLightBinding, - direction: THREE.Vector3 + direction: THREE.Vector3, + color: THREE.ColorRepresentation = 0xfff2d8, + intensity?: number ) => { const normalizedDirection = direction.clone().normalize(); + binding.directionToSun.copy(normalizedDirection); + binding.sunColor.set(color); binding.lightTarget.position.copy(binding.center); - binding.sunLight.position - .copy(normalizedDirection) - .multiplyScalar(binding.shadowCameraOffsetMeters) - .add(binding.center); + for (const sunLight of binding.controller.lights) { + sunLight.target.position.copy(binding.center); + sunLight.position + .copy(normalizedDirection) + .multiplyScalar(binding.shadowCameraOffsetMeters) + .add(binding.center); + sunLight.color.copy(binding.sunColor); + } const vectorLength = binding.sunVectorLengthMeters; const headLength = vectorLength * SUN_VECTOR_HEAD_LENGTH_FACTOR; const shaftLength = vectorLength - headLength; @@ -607,23 +658,34 @@ const applySolarPositionToBinding = ( ); binding.sunVector.root.visible = binding.sunVectorVisible; binding.sunVector.root.updateMatrixWorld(true); - const daylightStrength = THREE.MathUtils.clamp(normalizedDirection.y, 0, 1); - const intensityScale = 0.55 + binding.shadowIntensity; - binding.sunLight.intensity = - (1.2 + Math.sqrt(daylightStrength) * 2.2) * intensityScale; + binding.sunIntensity = intensity ?? ATMOSPHERIC_LIGHT_EXPOSURE; + for (const sunLight of binding.controller.lights) { + sunLight.intensity = binding.sunIntensity; + } binding.lightTarget.updateMatrixWorld(true); - binding.sunLight.updateMatrixWorld(true); - binding.sunLight.shadow.updateMatrices(binding.sunLight); - binding.sunLight.shadow.needsUpdate = true; + for (const sunLight of binding.controller.lights) { + sunLight.updateMatrixWorld(true); + } + binding.controller.invalidate(); + binding.dirty = true; }; const disposeShadowLightBinding = (binding: ShadowLightBinding) => { - binding.scene.remove( - binding.sunLight, - binding.lightTarget, - binding.sunVector.root - ); + for (const [ambientLight, intensity] of binding.ambientLightIntensities) { + ambientLight.intensity = intensity; + } + binding.scene.remove(binding.skyLight); + binding.scene.remove(binding.sunVector.root); + for (const box of binding.shadowBufferBoxes) { + binding.scene.remove(box); + box.geometry.dispose(); + const materials = Array.isArray(box.material) + ? box.material + : [box.material]; + materials.forEach((material) => material.dispose()); + } binding.sunVector.dispose(); + binding.controller.dispose(); }; export const buildShadowSimulationScene = ( @@ -640,10 +702,15 @@ export const buildShadowSimulationScene = ( uniformColor: null, }; let latestShadowIntensity = 0.45; + let latestAtmosphericSunlight: AtmosphericSunlightSample | null = null; + let atmosphericSunlightOptions: AtmosphericSunlightOptions = { + useTransmittanceLut: true, + useIrradianceLut: true, + }; let disposed = false; const restoreMapLibreStyleLayers = suppressMapLibreRegularStyleLayers(map); let terrainColor = new THREE.Color( - terrain?.material?.color ?? DEFAULT_TERRAIN_COLOR + terrain?.material?.color ?? DEFAULT_SHADOW_SURFACE_COLOR ); const ensureShadowBackground = () => { if (disposed || !map.isStyleLoaded()) return; @@ -696,7 +763,9 @@ export const buildShadowSimulationScene = ( ensureShadowBackground(); let restoreMapLibreTerrain: (() => void) | null = null; const sceneLease = acquireSharedThreeScene(map); + const atmosphericSunlight = new AtmosphericSunlightEvaluator(); let invalidateShadowMap = () => undefined; + let refreshTerrainShadowState = () => invalidateShadowMap(); const terrainRuntime = terrain ? (() => { const mapCenter = map.getCenter(); @@ -707,7 +776,7 @@ export const buildShadowSimulationScene = ( [mapCenter.lng, mapCenter.lat], { ...runtimeOptions, - onContentChanged: () => invalidateShadowMap(), + onContentChanged: () => refreshTerrainShadowState(), } ); })() @@ -717,9 +786,67 @@ export const buildShadowSimulationScene = ( sceneLease.layer.getScene(), initialShadowAreaMeters ); + const applyMapLibreLightSample = (sample: AtmosphericSunlightSample) => { + if (!map.isStyleLoaded()) return; + const nextPosition: [number, number, number] = [ + 1.5, + sample.azimuthDegrees, + 90 - sample.elevationDegrees, + ]; + const nextColor = `#${sample.color.getHexString()}`; + const nextIntensity = THREE.MathUtils.clamp(sample.relativeIntensity, 0, 1); + const currentLight = map.getLight(); + const currentPosition = currentLight.position; + if ( + currentLight.anchor === "map" && + Array.isArray(currentPosition) && + currentPosition.length === nextPosition.length && + currentPosition.every((value, index) => value === nextPosition[index]) && + currentLight.color === nextColor && + currentLight.intensity === nextIntensity + ) { + return; + } + map.setLight({ + anchor: "map", + position: nextPosition, + color: nextColor, + intensity: nextIntensity, + }); + }; + const evaluateAtmosphericSunlightForMap = (position: SolarPosition) => { + const mapCenter = map.getCenter(); + const altitudeMeters = + terrainRuntime?.getElevation(mapCenter.lng, mapCenter.lat) ?? 0; + atmosphericSunlight.ensure(() => { + if (disposed || !latestSolarPosition) return; + const sample = evaluateAtmosphericSunlightForMap(latestSolarPosition); + applyMapLibreLightSample(sample); + map.triggerRepaint(); + }, atmosphericSunlightOptions); + const sample = atmosphericSunlight.evaluate( + position.instant, + { + longitude: mapCenter.lng, + latitude: mapCenter.lat, + altitudeMeters, + }, + atmosphericSunlightOptions + ); + latestAtmosphericSunlight = sample; + applyAtmosphericSkyLightToBinding(sharedBinding, sample); + applySolarPositionToBinding( + sharedBinding, + sample.directionToSun, + sample.radiance, + ATMOSPHERIC_LIGHT_EXPOSURE + ); + return sample; + }; invalidateShadowMap = () => { if (disposed) return; - sharedBinding.sunLight.shadow.needsUpdate = true; + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; }; const genericBridges = new Map(); let cachedElevationRange: readonly [number, number] | null = null; @@ -734,12 +861,8 @@ export const buildShadowSimulationScene = ( ); if (!center) { if (latestSolarPosition) { - applySolarPositionToBinding( - sharedBinding, - solarPositionToSceneDirection(latestSolarPosition) - ); + evaluateAtmosphericSunlightForMap(latestSolarPosition); } - terrainRuntime?.setShadowCamera(sharedBinding.sunLight.shadow.camera); map.triggerRepaint(); return; } @@ -810,57 +933,123 @@ export const buildShadowSimulationScene = ( viewportRadiusMeters * 2 ); } - sharedBinding.shadowCameraOffsetMeters = configureShadowCamera( - sharedBinding.sunLight, - sharedBinding.shadowAreaMeters, - sharedBinding.shadowQuality + sharedBinding.shadowCameraOffsetMeters = Math.max( + DEFAULT_SHADOW_CAMERA_OFFSET_METERS, + sharedBinding.shadowAreaMeters * 1.5 ); + const coveragePoints = viewportLngLats.flatMap((lngLat) => + [minimumElevation, maximumElevation].flatMap((elevation) => { + const point = sceneLease.layer.projectLngLatToScene?.( + lngLat, + elevation + ); + return point ? [point] : []; + }) + ); + sharedBinding.receiverWorldPoints = coveragePoints; + sharedBinding.minimumElevationMeters = minimumElevation; + sharedBinding.maximumElevationMeters = maximumElevation; + sharedBinding.dirty = true; if (latestSolarPosition) { - applySolarPositionToBinding( - sharedBinding, - solarPositionToSceneDirection(latestSolarPosition) - ); - const coveragePoints = viewportLngLats.flatMap((lngLat) => - [minimumElevation, maximumElevation].flatMap((elevation) => { - const point = sceneLease.layer.projectLngLatToScene?.( - lngLat, - elevation - ); - return point ? [point] : []; - }) - ); - fitShadowCameraToPoints( - sharedBinding.sunLight, - coveragePoints, - configuredShadowAreaMeters ?? MIN_VIEWPORT_SHADOW_AREA_METERS, - sharedBinding.shadowQuality - ); - const shadowCamera = sharedBinding.sunLight.shadow.camera; - publishShadowProjectionDebugSnapshot(map, { - cameraRangeMeters: shadowCamera.position.distanceTo( - sharedBinding.lightTarget.position - ), - leftMeters: shadowCamera.left, - rightMeters: shadowCamera.right, - bottomMeters: shadowCamera.bottom, - topMeters: shadowCamera.top, - nearMeters: shadowCamera.near, - farMeters: shadowCamera.far, - projectionMatrixElements: [...shadowCamera.projectionMatrix.elements], - shadowMapWidth: sharedBinding.sunLight.shadow.mapSize.x, - shadowMapHeight: sharedBinding.sunLight.shadow.mapSize.y, - minimumElevationMeters: minimumElevation, - maximumElevationMeters: maximumElevation, - }); + evaluateAtmosphericSunlightForMap(latestSolarPosition); } - terrainRuntime?.setShadowCamera(sharedBinding.sunLight.shadow.camera); map.triggerRepaint(); }; + const shadowControllerRuntime: SharedThreeSceneRuntime = { + id: "shadow-simulation-tiled-controller", + originLngLat: [map.getCenter().lng, map.getCenter().lat], + root: new THREE.Group(), + update(frame) { + if (!sharedBinding.dirty) return; + if ( + sharedBinding.receiverWorldPoints.length === 0 || + !latestSolarPosition + ) { + clearShadowProjectionDebugSnapshot(map); + updateShadowBufferBorders(sharedBinding, 0); + terrainRuntime?.setShadowCameras([]); + return; + } + sharedBinding.controller.syncSceneMaterials(sharedBinding.scene); + const snapshot = sharedBinding.controller.update({ + camera: frame.lodCamera, + receiverWorldPoints: sharedBinding.receiverWorldPoints, + minimumElevationMeters: sharedBinding.minimumElevationMeters, + maximumElevationMeters: sharedBinding.maximumElevationMeters, + directionToSun: sharedBinding.directionToSun, + color: sharedBinding.sunColor, + intensity: sharedBinding.sunIntensity, + shadowIntensity: sharedBinding.shadowIntensity, + quality: sharedBinding.shadowQuality, + }); + sharedBinding.dirty = false; + if (!snapshot) { + clearShadowProjectionDebugSnapshot(map); + updateShadowBufferBorders(sharedBinding, 0); + terrainRuntime?.setShadowCameras([]); + return; + } + updateShadowBufferBorders(sharedBinding, snapshot.tileCount); + const primary = snapshot?.tiles[0]; + const primaryCamera = sharedBinding.controller.lights[0].shadow.camera; + if (primary) { + publishShadowProjectionDebugSnapshot(map, { + cameraRangeMeters: primaryCamera.position.distanceTo( + sharedBinding.controller.lights[0].target.position + ), + leftMeters: primary.leftMeters, + rightMeters: primary.rightMeters, + bottomMeters: primary.bottomMeters, + topMeters: primary.topMeters, + nearMeters: primary.nearMeters, + farMeters: primary.farMeters, + projectionMatrixElements: primary.projectionMatrixElements, + shadowMapWidth: primary.shadowMapWidth, + shadowMapHeight: primary.shadowMapHeight, + minimumElevationMeters: sharedBinding.minimumElevationMeters, + maximumElevationMeters: sharedBinding.maximumElevationMeters, + sceneAnchorPositionElements: sharedBinding.center.toArray(), + tiledShadow: snapshot, + atmosphericSunlight: latestAtmosphericSunlight + ? { + azimuthDegrees: latestAtmosphericSunlight.azimuthDegrees, + elevationDegrees: latestAtmosphericSunlight.elevationDegrees, + relativeIntensity: latestAtmosphericSunlight.relativeIntensity, + color: `#${latestAtmosphericSunlight.color.getHexString()}`, + transmittanceReady: + latestAtmosphericSunlight.atmosphericTransmittanceReady, + irradianceReady: + latestAtmosphericSunlight.atmosphericIrradianceReady, + } + : null, + }); + } + terrainRuntime?.setShadowCameras( + sharedBinding.controller.lights + .slice(0, snapshot.tileCount) + .map(({ shadow }) => ({ + camera: shadow.camera, + shadowMapSize: { + width: shadow.mapSize.x, + height: shadow.mapSize.y, + }, + })) + ); + map.triggerRepaint(); + }, + dispose: () => undefined, + }; + sceneLease.layer.addRuntime(shadowControllerRuntime); + const refreshSharedShadowCoverage = () => { cachedElevationRange = null; updateSharedShadowCoverage(); }; + refreshTerrainShadowState = () => { + invalidateShadowMap(); + refreshSharedShadowCoverage(); + }; map.on("move", updateSharedShadowCoverage); map.on("moveend", refreshSharedShadowCoverage); @@ -893,11 +1082,15 @@ export const buildShadowSimulationScene = ( const nextBridge = buildGenericThreeShadowBridge( sceneLease.layer, layer, - latestBuildingAppearance + latestBuildingAppearance, + sharedBinding.controller ); if (nextBridge) genericBridges.set(layer, nextBridge); } - makeSceneMeshesShadeable(sceneLease.layer.getScene()); + makeSceneMeshesShadeable( + sceneLease.layer.getScene(), + sharedBinding.controller + ); refreshSharedShadowCoverage(); map.triggerRepaint(); }; @@ -913,8 +1106,12 @@ export const buildShadowSimulationScene = ( for (const runtime of getSharedThreeSceneRuntimes(map)) { runtime.setShadowSimulationStyle?.(latestBuildingAppearance); } - makeSceneMeshesShadeable(sceneLease.layer.getScene()); - sharedBinding.sunLight.shadow.needsUpdate = true; + makeSceneMeshesShadeable( + sceneLease.layer.getScene(), + sharedBinding.controller + ); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; refreshSharedShadowCoverage(); }; const unsubscribeSharedSceneContent = subscribeSharedThreeSceneContent( @@ -924,37 +1121,9 @@ export const buildShadowSimulationScene = ( handleSharedSceneContentChanged(); const applyMapLibreLight = (position: SolarPosition) => { - if (!map.isStyleLoaded()) return; - const daylightStrength = THREE.MathUtils.clamp( - Math.sin(THREE.MathUtils.degToRad(position.elevationDegrees)), - 0, - 1 - ); - const nextPosition: [number, number, number] = [ - 1.5, - position.azimuthDegrees, - 90 - position.elevationDegrees, - ]; - const nextIntensity = - (0.35 + daylightStrength * 0.55) * (0.55 + latestShadowIntensity); - const currentLight = map.getLight(); - const currentPosition = currentLight.position; - if ( - currentLight.anchor === "map" && - Array.isArray(currentPosition) && - currentPosition.length === nextPosition.length && - currentPosition.every((value, index) => value === nextPosition[index]) && - currentLight.color === "#fff3df" && - currentLight.intensity === nextIntensity - ) { - return; - } - map.setLight({ - anchor: "map", - position: nextPosition, - color: "#fff3df", - intensity: nextIntensity, - }); + const sample = + latestAtmosphericSunlight ?? evaluateAtmosphericSunlightForMap(position); + applyMapLibreLightSample(sample); }; const updateSolarPosition = (position: SolarPosition) => { @@ -988,34 +1157,32 @@ export const buildShadowSimulationScene = ( for (const runtime of getSharedThreeSceneRuntimes(map)) { runtime.setShadowSimulationStyle?.(appearance); } + makeSceneMeshesShadeable( + sceneLease.layer.getScene(), + sharedBinding.controller + ); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; map.triggerRepaint(); }, updateShadowQuality(quality) { sharedBinding.shadowQuality = quality; - sharedBinding.shadowCameraOffsetMeters = configureShadowCamera( - sharedBinding.sunLight, - sharedBinding.shadowAreaMeters, - quality - ); + sharedBinding.dirty = true; updateSharedShadowCoverage(); - if (latestSolarPosition) { - applySolarPositionToBinding( - sharedBinding, - solarPositionToSceneDirection(latestSolarPosition) - ); - } else { - sharedBinding.sunLight.shadow.needsUpdate = true; - } + sharedBinding.controller.invalidate(); + }, + updateShadowMode(mode) { + sharedBinding.shadowMode = mode; + sharedBinding.controller.setMode(mode); + sharedBinding.dirty = true; + updateSharedShadowCoverage(); + sharedBinding.controller.invalidate(); }, updateShadowIntensity(intensity) { latestShadowIntensity = THREE.MathUtils.clamp(intensity, 0, 1); sharedBinding.shadowIntensity = latestShadowIntensity; - if (latestSolarPosition) { - applySolarPositionToBinding( - sharedBinding, - solarPositionToSceneDirection(latestSolarPosition) - ); - applyMapLibreLight(latestSolarPosition); + for (const light of sharedBinding.controller.lights) { + light.shadow.intensity = latestShadowIntensity; } map.triggerRepaint(); }, @@ -1024,6 +1191,20 @@ export const buildShadowSimulationScene = ( sharedBinding.sunVector.root.visible = visible && !!latestSolarPosition; map.triggerRepaint(); }, + updateShadowBufferDebugVisibility(visible) { + sharedBinding.projectionDebugVisible = visible; + updateShadowBufferBorders(sharedBinding); + map.triggerRepaint(); + }, + updateAtmosphericLutUsage(options) { + atmosphericSunlightOptions = options; + latestAtmosphericSunlight = null; + if (latestSolarPosition) { + evaluateAtmosphericSunlightForMap(latestSolarPosition); + invalidateShadowMap(); + } + map.triggerRepaint(); + }, dispose() { if (disposed) return; disposed = true; @@ -1062,9 +1243,13 @@ export const buildShadowSimulationScene = ( // The style or terrain source may already be gone during map teardown. } restoreMapLibreTerrain = null; + if (sceneLease.layer.hasRuntime(shadowControllerRuntime.id)) { + sceneLease.layer.removeRuntime(shadowControllerRuntime.id); + } if (terrainRuntime && sceneLease.layer.hasRuntime(terrainRuntime.id)) { sceneLease.layer.removeRuntime(terrainRuntime.id); } + atmosphericSunlight.dispose(); disposeShadowLightBinding(sharedBinding); sceneLease.release(); try { diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.spec.ts new file mode 100644 index 0000000000..9a2efd2d33 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.spec.ts @@ -0,0 +1,211 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest"; + +import { + buildShadowTileLayout, + type ShadowTileLayoutOptions, +} from "./shadow-tile-layout"; + +const baseOptions = (): ShadowTileLayoutOptions => ({ + receiverBounds: { + left: 0.2, + right: 19.7, + bottom: -5.8, + top: 6.1, + near: 100, + far: 500, + }, + casterPaddingMeters: 1, + casterReliefMeters: 20, + casterReachMeters: 80, + targetMetersPerTexel: 1, + maxShadowMapDimension: 8, + maxTileCount: 12, +}); + +describe("buildShadowTileLayout", () => { + it("builds texel-snapped, overlapping rectangular tiles", () => { + const layout = buildShadowTileLayout(baseOptions()); + + expect(layout.statistics).toMatchObject({ + columnCount: 4, + rowCount: 3, + tileCount: 12, + casterGuardTexels: 1, + effectiveMetersPerTexel: 1, + budgetLimited: false, + }); + expect(layout.snappedReceiverBounds).toMatchObject({ + left: 0, + right: 20, + bottom: -6, + top: 7, + }); + expect(layout.tiles.map(({ id }) => id)).toEqual([ + "r0-c0", + "r0-c1", + "r0-c2", + "r0-c3", + "r1-c0", + "r1-c1", + "r1-c2", + "r1-c3", + "r2-c0", + "r2-c1", + "r2-c2", + "r2-c3", + ]); + for (const tile of layout.tiles) { + expect(tile.widthPixels).toBe(8); + expect(tile.heightPixels).toBe(8); + expect(tile.right - tile.left).toBe(8); + expect(tile.top - tile.bottom).toBe(8); + expect(tile.left).toBe(tile.receiverBounds.left - 1); + } + expect(layout.tiles[0].receiverBounds.right).toBe( + layout.tiles[1].receiverBounds.left + ); + expect(layout.tiles[0].right).toBeGreaterThan(layout.tiles[1].left); + expect(layout.debugPolygons).toHaveLength(2 + layout.tiles.length * 2); + }); + + it("keeps the layout stable while bounds remain in the same texels", () => { + const options = baseOptions(); + const first = buildShadowTileLayout(options); + const shifted = buildShadowTileLayout({ + ...options, + receiverBounds: { + ...options.receiverBounds, + left: 0.35, + right: 19.85, + bottom: -5.65, + top: 6.25, + }, + }); + + expect(shifted.snappedReceiverBounds).toEqual(first.snappedReceiverBounds); + expect(shifted.tiles).toEqual(first.tiles); + }); + + it("degrades resolution deterministically to respect the tile budget", () => { + const options: ShadowTileLayoutOptions = { + ...baseOptions(), + receiverBounds: { + left: -50, + right: 50, + bottom: -50, + top: 50, + near: 100, + far: 500, + }, + casterPaddingMeters: 0, + targetMetersPerTexel: 1, + maxShadowMapDimension: 10, + maxTileCount: 4, + }; + const first = buildShadowTileLayout(options); + const second = buildShadowTileLayout(options); + + expect(first).toEqual(second); + expect(first.statistics.tileCount).toBeLessThanOrEqual(4); + expect(first.statistics.columnCount).toBe(2); + expect(first.statistics.rowCount).toBe(2); + expect(first.statistics.effectiveMetersPerTexel).toBeGreaterThan(5); + expect(first.statistics.budgetLimited).toBe(true); + for (const tile of first.tiles) { + expect(tile.widthPixels).toBe(10); + expect(tile.heightPixels).toBe(10); + expect(tile.right - tile.left).toBeCloseTo( + first.statistics.effectiveMetersPerTexel * 10 + ); + expect(tile.top - tile.bottom).toBeCloseTo( + first.statistics.effectiveMetersPerTexel * 10 + ); + } + }); + + it("extends partial edge tiles outward while preserving their receiver core", () => { + const layout = buildShadowTileLayout(baseOptions()); + const bottomRight = layout.tiles.at(-1); + + expect(bottomRight).toBeDefined(); + expect(bottomRight?.receiverBounds.right).toBe( + layout.snappedReceiverBounds.right + ); + expect(bottomRight?.receiverBounds.bottom).toBe( + layout.snappedReceiverBounds.bottom + ); + expect(bottomRight?.right).toBeGreaterThan( + (bottomRight?.receiverBounds.right ?? 0) + + layout.statistics.casterGuardMeters + ); + expect(bottomRight?.bottom).toBeLessThan( + (bottomRight?.receiverBounds.bottom ?? 0) - + layout.statistics.casterGuardMeters + ); + }); + + it("uses rectangular tile budgets for long, narrow receiver regions", () => { + const layout = buildShadowTileLayout({ + ...baseOptions(), + receiverBounds: { + left: 0, + right: 300, + bottom: 0, + top: 30, + near: 50, + far: 200, + }, + casterPaddingMeters: 0, + targetMetersPerTexel: 1, + maxShadowMapDimension: 32, + maxTileCount: 4, + }); + + expect(layout.statistics.columnCount).toBe(4); + expect(layout.statistics.rowCount).toBe(1); + expect(layout.statistics.tileCount).toBe(4); + }); + + it("extends depth, not the tile footprint, for a low sun caster reach", () => { + const options = baseOptions(); + const shortReach = buildShadowTileLayout({ + ...options, + casterReachMeters: 10, + }); + const horizonReach = buildShadowTileLayout({ + ...options, + casterReachMeters: 20_000, + }); + + expect( + horizonReach.tiles.map(({ left, right, bottom, top }) => ({ + left, + right, + bottom, + top, + })) + ).toEqual( + shortReach.tiles.map(({ left, right, bottom, top }) => ({ + left, + right, + bottom, + top, + })) + ); + expect(horizonReach.statistics.nearMeters).toBe(0); + expect(horizonReach.statistics.farMeters).toBe(520); + expect(horizonReach.statistics.depthMeters).toBe(520); + expect(horizonReach.statistics.clippedCasterDepthMeters).toBe(19_920); + }); + + it("rejects guard bands that leave no useful receiver area", () => { + expect(() => + buildShadowTileLayout({ + ...baseOptions(), + casterPaddingMeters: 4, + }) + ).toThrow(/fewer than two receiver texels/); + }); +}); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.ts new file mode 100644 index 0000000000..2105df749e --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-tile-layout.ts @@ -0,0 +1,438 @@ +export type LightSpaceBounds = Readonly<{ + left: number; + right: number; + bottom: number; + top: number; + near: number; + far: number; +}>; + +export type LightSpacePoint = readonly [x: number, y: number]; + +export type ShadowTileLayoutOptions = Readonly<{ + receiverBounds: LightSpaceBounds; + casterPaddingMeters: number; + casterReliefMeters: number; + casterReachMeters: number; + targetMetersPerTexel: number; + maxShadowMapDimension: number; + maxTileCount: number; +}>; + +export type ShadowTileBounds = LightSpaceBounds & + Readonly<{ + id: string; + row: number; + column: number; + widthPixels: number; + heightPixels: number; + receiverBounds: LightSpaceBounds; + }>; + +export type ShadowTileDebugPolygon = Readonly<{ + id: string; + kind: "receiver" | "snapped-receiver" | "tile" | "tile-receiver"; + tileId?: string; + points: readonly LightSpacePoint[]; +}>; + +export type ShadowTileLayoutStatistics = Readonly<{ + tileCount: number; + rowCount: number; + columnCount: number; + requestedMetersPerTexel: number; + effectiveMetersPerTexel: number; + resolutionScale: number; + budgetLimited: boolean; + maxShadowMapDimension: number; + maxTileCount: number; + tileBudgetUtilization: number; + casterGuardTexels: number; + casterPaddingMeters: number; + casterGuardMeters: number; + casterReliefMeters: number; + casterReachMeters: number; + receiverWidthMeters: number; + receiverHeightMeters: number; + snappedWidthMeters: number; + snappedHeightMeters: number; + receiverTexelCount: number; + allocatedShadowTexelCount: number; + nearMeters: number; + farMeters: number; + depthMeters: number; + clippedCasterDepthMeters: number; +}>; + +export type ShadowTileLayout = Readonly<{ + receiverBounds: LightSpaceBounds; + snappedReceiverBounds: LightSpaceBounds; + casterBounds: LightSpaceBounds; + tiles: readonly ShadowTileBounds[]; + debugPolygons: readonly ShadowTileDebugPolygon[]; + statistics: ShadowTileLayoutStatistics; +}>; + +const GRID_TOLERANCE = 1e-10; +const RESOLUTION_TOLERANCE = 1e-9; + +const assertFinite = (name: string, value: number) => { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be finite`); + } +}; + +const assertNonNegative = (name: string, value: number) => { + assertFinite(name, value); + if (value < 0) { + throw new RangeError(`${name} must be greater than or equal to zero`); + } +}; + +const assertPositive = (name: string, value: number) => { + assertFinite(name, value); + if (value <= 0) { + throw new RangeError(`${name} must be greater than zero`); + } +}; + +const assertPositiveInteger = (name: string, value: number) => { + if (!Number.isInteger(value) || value <= 0) { + throw new RangeError(`${name} must be a positive integer`); + } +}; + +const validateBounds = (bounds: LightSpaceBounds) => { + for (const [name, value] of Object.entries(bounds)) { + assertFinite(`receiverBounds.${name}`, value); + } + if (bounds.left >= bounds.right) { + throw new RangeError("receiverBounds.left must be less than right"); + } + if (bounds.bottom >= bounds.top) { + throw new RangeError("receiverBounds.bottom must be less than top"); + } + if (bounds.near < 0 || bounds.near >= bounds.far) { + throw new RangeError( + "receiverBounds.near must be non-negative and less than far" + ); + } +}; + +const gridFloor = (value: number, resolution: number) => + Math.floor(value / resolution + GRID_TOLERANCE); + +const gridCeil = (value: number, resolution: number) => + Math.ceil(value / resolution - GRID_TOLERANCE); + +const rectanglePoints = ( + left: number, + right: number, + bottom: number, + top: number +): readonly LightSpacePoint[] => [ + [left, bottom], + [right, bottom], + [right, top], + [left, top], +]; + +type ResolutionLayout = Readonly<{ + resolution: number; + leftIndex: number; + rightIndex: number; + bottomIndex: number; + topIndex: number; + widthTexels: number; + heightTexels: number; + columnCount: number; + rowCount: number; +}>; + +const buildResolutionLayout = ( + bounds: LightSpaceBounds, + resolution: number, + receiverTileDimension: number +): ResolutionLayout => { + const leftIndex = gridFloor(bounds.left, resolution); + const rightIndex = gridCeil(bounds.right, resolution); + const bottomIndex = gridFloor(bounds.bottom, resolution); + const topIndex = gridCeil(bounds.top, resolution); + const widthTexels = rightIndex - leftIndex; + const heightTexels = topIndex - bottomIndex; + return { + resolution, + leftIndex, + rightIndex, + bottomIndex, + topIndex, + widthTexels, + heightTexels, + columnCount: Math.ceil(widthTexels / receiverTileDimension), + rowCount: Math.ceil(heightTexels / receiverTileDimension), + }; +}; + +const chooseResolution = ( + bounds: LightSpaceBounds, + targetMetersPerTexel: number, + receiverTileDimension: number, + maxTileCount: number +): ResolutionLayout => { + const targetLayout = buildResolutionLayout( + bounds, + targetMetersPerTexel, + receiverTileDimension + ); + if (targetLayout.columnCount * targetLayout.rowCount <= maxTileCount) { + return targetLayout; + } + + const width = bounds.right - bounds.left; + const height = bounds.top - bounds.bottom; + let best: ResolutionLayout | null = null; + + // Enumerating the small tile budget gives a deterministic, aspect-aware + // fallback. The one-texel allowance guarantees that outward snapping still + // fits in the selected number of maps regardless of the world-grid phase. + for (let rows = 1; rows <= maxTileCount; rows += 1) { + const maximumColumns = Math.floor(maxTileCount / rows); + for (let columns = 1; columns <= maximumColumns; columns += 1) { + const horizontalCapacity = columns * receiverTileDimension; + const verticalCapacity = rows * receiverTileDimension; + if (horizontalCapacity <= 1 || verticalCapacity <= 1) continue; + const resolution = + Math.max( + targetMetersPerTexel, + width / (horizontalCapacity - 1), + height / (verticalCapacity - 1) + ) * + (1 + Number.EPSILON * 8); + const candidate = buildResolutionLayout( + bounds, + resolution, + receiverTileDimension + ); + const candidateTileCount = candidate.columnCount * candidate.rowCount; + if ( + candidateTileCount > maxTileCount || + candidate.columnCount > columns || + candidate.rowCount > rows + ) { + continue; + } + const bestTileCount = best + ? best.columnCount * best.rowCount + : Number.POSITIVE_INFINITY; + if ( + !best || + candidate.resolution < best.resolution - RESOLUTION_TOLERANCE || + (Math.abs(candidate.resolution - best.resolution) <= + RESOLUTION_TOLERANCE && + candidateTileCount < bestTileCount) + ) { + best = candidate; + } + } + } + + if (!best) { + throw new Error("Unable to fit the receiver bounds into the tile budget"); + } + return best; +}; + +/** + * Partitions a light-space receiver rectangle into overlapping orthographic + * shadow-map tiles. Receiver edges are snapped outwards to a stable texel grid; + * the caster padding becomes a guard band in every map. Relief and low-sun + * reach extend the depth prism towards the light instead of widening its + * projected footprint, because directional-light rays are parallel in light + * space. + */ +export const buildShadowTileLayout = ( + options: ShadowTileLayoutOptions +): ShadowTileLayout => { + const { + receiverBounds, + casterPaddingMeters, + casterReliefMeters, + casterReachMeters, + targetMetersPerTexel, + maxShadowMapDimension, + maxTileCount, + } = options; + validateBounds(receiverBounds); + assertNonNegative("casterPaddingMeters", casterPaddingMeters); + assertNonNegative("casterReliefMeters", casterReliefMeters); + assertNonNegative("casterReachMeters", casterReachMeters); + assertPositive("targetMetersPerTexel", targetMetersPerTexel); + assertPositiveInteger("maxShadowMapDimension", maxShadowMapDimension); + assertPositiveInteger("maxTileCount", maxTileCount); + + // Keep the guard in requested-resolution texels. If the tile budget forces a + // coarser layout, the physical guard grows rather than becoming unsafe. + const casterGuardTexels = Math.ceil( + casterPaddingMeters / targetMetersPerTexel - GRID_TOLERANCE + ); + const receiverTileDimension = maxShadowMapDimension - casterGuardTexels * 2; + if (receiverTileDimension < 2) { + throw new RangeError( + "casterPaddingMeters leaves fewer than two receiver texels per tile" + ); + } + + const grid = chooseResolution( + receiverBounds, + targetMetersPerTexel, + receiverTileDimension, + maxTileCount + ); + const resolution = grid.resolution; + const guardMeters = casterGuardTexels * resolution; + const clippedNear = + receiverBounds.near - casterReliefMeters - casterReachMeters; + const near = Math.max(0, clippedNear); + const far = receiverBounds.far + casterReliefMeters; + const snappedLeft = grid.leftIndex * resolution; + const snappedRight = grid.rightIndex * resolution; + const snappedBottom = grid.bottomIndex * resolution; + const snappedTop = grid.topIndex * resolution; + const snappedReceiverBounds: LightSpaceBounds = { + left: snappedLeft, + right: snappedRight, + bottom: snappedBottom, + top: snappedTop, + near: receiverBounds.near, + far: receiverBounds.far, + }; + const tiles: ShadowTileBounds[] = []; + for (let row = 0; row < grid.rowCount; row += 1) { + const receiverTopIndex = grid.topIndex - row * receiverTileDimension; + const receiverBottomIndex = Math.max( + grid.bottomIndex, + receiverTopIndex - receiverTileDimension + ); + for (let column = 0; column < grid.columnCount; column += 1) { + const receiverLeftIndex = grid.leftIndex + column * receiverTileDimension; + const receiverRightIndex = Math.min( + grid.rightIndex, + receiverLeftIndex + receiverTileDimension + ); + const tileReceiverBounds: LightSpaceBounds = { + left: receiverLeftIndex * resolution, + right: receiverRightIndex * resolution, + bottom: receiverBottomIndex * resolution, + top: receiverTopIndex * resolution, + near: receiverBounds.near, + far: receiverBounds.far, + }; + const fullReceiverRightIndex = receiverLeftIndex + receiverTileDimension; + const fullReceiverBottomIndex = receiverTopIndex - receiverTileDimension; + tiles.push({ + id: `r${row}-c${column}`, + row, + column, + left: tileReceiverBounds.left - guardMeters, + right: fullReceiverRightIndex * resolution + guardMeters, + bottom: fullReceiverBottomIndex * resolution - guardMeters, + top: tileReceiverBounds.top + guardMeters, + near, + far, + widthPixels: maxShadowMapDimension, + heightPixels: maxShadowMapDimension, + receiverBounds: tileReceiverBounds, + }); + } + } + const casterBounds: LightSpaceBounds = { + left: Math.min(...tiles.map(({ left }) => left)), + right: Math.max(...tiles.map(({ right }) => right)), + bottom: Math.min(...tiles.map(({ bottom }) => bottom)), + top: Math.max(...tiles.map(({ top }) => top)), + near, + far, + }; + + const debugPolygons: ShadowTileDebugPolygon[] = [ + { + id: "receiver", + kind: "receiver", + points: rectanglePoints( + receiverBounds.left, + receiverBounds.right, + receiverBounds.bottom, + receiverBounds.top + ), + }, + { + id: "receiver-snapped", + kind: "snapped-receiver", + points: rectanglePoints( + snappedLeft, + snappedRight, + snappedBottom, + snappedTop + ), + }, + ]; + for (const tile of tiles) { + debugPolygons.push( + { + id: `${tile.id}-receiver`, + kind: "tile-receiver", + tileId: tile.id, + points: rectanglePoints( + tile.receiverBounds.left, + tile.receiverBounds.right, + tile.receiverBounds.bottom, + tile.receiverBounds.top + ), + }, + { + id: tile.id, + kind: "tile", + tileId: tile.id, + points: rectanglePoints(tile.left, tile.right, tile.bottom, tile.top), + } + ); + } + + const allocatedShadowTexelCount = + tiles.length * maxShadowMapDimension * maxShadowMapDimension; + return { + receiverBounds, + snappedReceiverBounds, + casterBounds, + tiles, + debugPolygons, + statistics: { + tileCount: tiles.length, + rowCount: grid.rowCount, + columnCount: grid.columnCount, + requestedMetersPerTexel: targetMetersPerTexel, + effectiveMetersPerTexel: resolution, + resolutionScale: resolution / targetMetersPerTexel, + budgetLimited: + resolution > targetMetersPerTexel * (1 + RESOLUTION_TOLERANCE), + maxShadowMapDimension, + maxTileCount, + tileBudgetUtilization: tiles.length / maxTileCount, + casterGuardTexels, + casterPaddingMeters, + casterGuardMeters: guardMeters, + casterReliefMeters, + casterReachMeters, + receiverWidthMeters: receiverBounds.right - receiverBounds.left, + receiverHeightMeters: receiverBounds.top - receiverBounds.bottom, + snappedWidthMeters: snappedRight - snappedLeft, + snappedHeightMeters: snappedTop - snappedBottom, + receiverTexelCount: grid.widthTexels * grid.heightTexels, + allocatedShadowTexelCount, + nearMeters: near, + farMeters: far, + depthMeters: far - near, + clippedCasterDepthMeters: Math.max(0, -clippedNear), + }, + }; +}; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.spec.ts new file mode 100644 index 0000000000..43bcf80530 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.spec.ts @@ -0,0 +1,348 @@ +// @vitest-environment node + +import * as THREE from "three"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildTiledLightSpaceLightsFragment, + TiledShadowController, + SHADOW_TILE_COUNT, + type TiledShadowUpdate, +} from "./tiled-shadow-controller"; + +const buildCamera = () => { + const camera = new THREE.PerspectiveCamera(50, 16 / 9, 1, 2_000); + camera.position.set(0, 300, 500); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + return camera; +}; + +const buildReceiverPoints = () => + [-300, 300].flatMap((x) => + [100, 250].flatMap((y) => + [-300, 300].map((z) => new THREE.Vector3(x, y, z)) + ) + ); + +const buildUpdate = ( + camera: THREE.PerspectiveCamera, + receiverWorldPoints = buildReceiverPoints() +): TiledShadowUpdate => ({ + camera, + receiverWorldPoints, + minimumElevationMeters: 100, + maximumElevationMeters: 250, + directionToSun: new THREE.Vector3(0.4, 0.6, -0.7).normalize(), + color: "#ffd6a0", + intensity: 2, + shadowIntensity: 0.45, + quality: 4, +}); + +describe("buildTiledLightSpaceLightsFragment", () => { + it("selects one receiver-core tile and evaluates the sun once", () => { + const controller = new TiledShadowController( + new THREE.Scene(), + buildCamera() + ); + const source = buildTiledLightSpaceLightsFragment( + THREE.ShaderChunk.lights_fragment_begin + ); + const selectionBlock = source.slice( + source.indexOf("// CARMA light-space tile selection begin"), + source.indexOf("// CARMA light-space tile selection end") + ); + + expect(selectionBlock).toContain("CSM_tileReceiverUv[ i ]"); + expect(selectionBlock).toContain("! CSM_tileSelected"); + expect(selectionBlock.match(/getShadow\(/g)).toHaveLength(1); + expect(selectionBlock.match(/RE_Direct\(/g)).toHaveLength(1); + expect(source).not.toContain("float linearDepth"); + + controller.dispose(); + }); +}); + +describe("TiledShadowController", () => { + it("keeps a prior material compiler hook while enabling tiled shadows", () => { + const scene = new THREE.Scene(); + const controller = new TiledShadowController(scene, buildCamera()); + const material = new THREE.MeshLambertMaterial(); + const previousHook = vi.fn(); + material.onBeforeCompile = previousHook; + + controller.setupMaterial(material); + const shader = { + uniforms: {}, + fragmentShader: + "#include \n#include ", + } as unknown as THREE.WebGLProgramParametersWithUniforms; + material.onBeforeCompile(shader, {} as THREE.WebGLRenderer); + + expect(previousHook).toHaveBeenCalledOnce(); + expect(material.defines).toMatchObject({ + USE_CSM: 1, + CSM_CASCADES: SHADOW_TILE_COUNT, + }); + expect(shader.uniforms).toHaveProperty("CSM_cascades"); + expect(shader.uniforms).toHaveProperty("CSM_tileReceiverUv"); + expect(shader.fragmentShader).toContain( + "CARMA light-space tile selection begin" + ); + + controller.dispose(); + expect(material.onBeforeCompile).toBe(previousHook); + }); + + it("leaves unlit and custom materials outside the tiled-light shader path", () => { + const controller = new TiledShadowController( + new THREE.Scene(), + buildCamera() + ); + const basic = new THREE.MeshBasicMaterial(); + const custom = new THREE.ShaderMaterial({ + fragmentShader: "void main() { gl_FragColor = vec4(1.0); }", + }); + const basicHook = basic.onBeforeCompile; + const customHook = custom.onBeforeCompile; + const basicDefines = basic.defines; + const customDefines = custom.defines; + + controller.setupMaterial(basic); + controller.setupMaterial(custom); + + expect(basic.onBeforeCompile).toBe(basicHook); + expect(custom.onBeforeCompile).toBe(customHook); + expect(basic.defines).toBe(basicDefines); + expect(custom.defines).toBe(customDefines); + expect(controller.csm.shaders.has(basic)).toBe(false); + expect(controller.csm.shaders.has(custom)).toBe(false); + + controller.dispose(); + basic.dispose(); + custom.dispose(); + }); + + it("rejects a lit built-in shader that loses the required light includes", () => { + const controller = new TiledShadowController( + new THREE.Scene(), + buildCamera() + ); + const material = new THREE.MeshStandardMaterial(); + controller.setupMaterial(material); + const shader = { + uniforms: {}, + fragmentShader: "void main() {}", + } as unknown as THREE.WebGLProgramParametersWithUniforms; + + expect(() => + material.onBeforeCompile(shader, {} as THREE.WebGLRenderer) + ).toThrow(/must retain the Three\.js light shader includes/); + + controller.dispose(); + }); + + it("prunes replaced materials and releases disposed streamed materials", () => { + const scene = new THREE.Scene(); + const controller = new TiledShadowController(scene, buildCamera()); + const firstMaterial = new THREE.MeshLambertMaterial(); + const firstHook = vi.fn(); + firstMaterial.onBeforeCompile = firstHook; + firstMaterial.defines = { SOURCE_DEFINE: 1 }; + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), firstMaterial); + scene.add(mesh); + + controller.syncSceneMaterials(scene); + expect(controller.csm.shaders.has(firstMaterial)).toBe(true); + expect(firstMaterial.defines).toMatchObject({ USE_CSM: 1 }); + + const replacementMaterial = new THREE.MeshLambertMaterial(); + const replacementHook = vi.fn(); + replacementMaterial.onBeforeCompile = replacementHook; + mesh.material = replacementMaterial; + controller.syncSceneMaterials(scene); + + expect(controller.csm.shaders.has(firstMaterial)).toBe(false); + expect(firstMaterial.onBeforeCompile).toBe(firstHook); + expect(firstMaterial.defines).toEqual({ SOURCE_DEFINE: 1 }); + expect(controller.csm.shaders.has(replacementMaterial)).toBe(true); + expect(replacementMaterial.defines).toMatchObject({ USE_CSM: 1 }); + + replacementMaterial.dispose(); + + expect(controller.csm.shaders.has(replacementMaterial)).toBe(false); + expect(replacementMaterial.onBeforeCompile).toBe(replacementHook); + expect(replacementMaterial.defines).toBeUndefined(); + + controller.dispose(); + mesh.geometry.dispose(); + firstMaterial.dispose(); + }); + + it("fits a true two-dimensional four-map layout to the receiver prism", () => { + const scene = new THREE.Scene(); + const camera = buildCamera(); + const controller = new TiledShadowController(scene, camera); + + const snapshot = controller.update(buildUpdate(camera)); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.strategy).toBe("tiled-light-space"); + expect(snapshot?.tileCount).toBe(SHADOW_TILE_COUNT); + expect(snapshot?.totalShadowTexels).toBe(SHADOW_TILE_COUNT * 2_048 * 2_048); + expect(snapshot?.tiles.map(({ id }) => id)).toEqual([ + "r0-c0", + "r0-c1", + "r1-c0", + "r1-c1", + ]); + for (const tile of snapshot?.tiles ?? []) { + expect(tile.leftMeters).toBeLessThan(tile.receiverLeftMeters); + expect(tile.rightMeters).toBeGreaterThan(tile.receiverRightMeters); + expect(tile.bottomMeters).toBeLessThan(tile.receiverBottomMeters); + expect(tile.topMeters).toBeGreaterThan(tile.receiverTopMeters); + expect(tile.nearMeters).toBeLessThan(tile.farMeters); + expect(tile.statistics).toMatchObject({ + tileCount: SHADOW_TILE_COUNT, + rowCount: 2, + columnCount: 2, + maxTileCount: SHADOW_TILE_COUNT, + }); + expect(tile.shadowMapWidth).toBe(2_048); + expect(tile.shadowMapHeight).toBe(2_048); + } + expect(snapshot?.tiles[0].receiverRightMeters).toBeCloseTo( + snapshot?.tiles[1].receiverLeftMeters ?? Number.NaN + ); + expect(snapshot?.tiles[0].receiverBottomMeters).toBeCloseTo( + snapshot?.tiles[2].receiverTopMeters ?? Number.NaN + ); + expect(controller.lights.every((light) => light.intensity === 2)).toBe( + true + ); + expect( + controller.lights.every((light) => light.shadow.intensity === 0.45) + ).toBe(true); + + controller.dispose(); + }); + + it("restores the legacy single viewport buffer without tiled shaders", () => { + const scene = new THREE.Scene(); + const camera = buildCamera(); + const controller = new TiledShadowController(scene, camera); + const material = new THREE.MeshLambertMaterial(); + const originalHook = material.onBeforeCompile; + const mesh = new THREE.Mesh(new THREE.BoxGeometry(), material); + scene.add(mesh); + + controller.syncSceneMaterials(scene); + expect(controller.csm.shaders.has(material)).toBe(true); + + controller.setMode("single"); + controller.syncSceneMaterials(scene); + const snapshot = controller.update(buildUpdate(camera)); + + expect(controller.csm.shaders.has(material)).toBe(false); + expect(material.onBeforeCompile).toBe(originalHook); + expect(snapshot?.strategy).toBe("single-viewport"); + expect(snapshot?.tileCount).toBe(1); + expect(snapshot?.totalShadowTexels).toBe(4_096 * 4_096); + expect(snapshot?.tiles[0]?.id).toBe("single"); + const singleTile = snapshot!.tiles[0]!; + expect( + (singleTile.rightMeters - singleTile.leftMeters) / + (singleTile.topMeters - singleTile.bottomMeters) + ).toBeCloseTo(singleTile.shadowMapWidth / singleTile.shadowMapHeight, 10); + expect(controller.lights[0].visible).toBe(true); + expect(controller.lights[0].castShadow).toBe(true); + expect(controller.lights.slice(1).every((light) => !light.visible)).toBe( + true + ); + expect(controller.lights.slice(1).every((light) => !light.castShadow)).toBe( + true + ); + + controller.setMode("advanced"); + controller.syncSceneMaterials(scene); + expect(controller.csm.shaders.has(material)).toBe(true); + expect(controller.lights.every((light) => light.visible)).toBe(true); + expect(controller.lights.every((light) => light.castShadow)).toBe(true); + + controller.dispose(); + mesh.geometry.dispose(); + material.dispose(); + }); + + it("keeps the fixed map budget while disabling unused receiver tiles", () => { + const scene = new THREE.Scene(); + const camera = buildCamera(); + const controller = new TiledShadowController(scene, camera); + const update = buildUpdate(camera); + + const activeSnapshot = controller.update(update); + expect(activeSnapshot?.totalShadowTexels).toBe( + SHADOW_TILE_COUNT * 2_048 * 2_048 + ); + + expect( + controller.update({ ...update, receiverWorldPoints: [] }) + ).toBeNull(); + controller.invalidate(); + + expect(controller.lights.every((light) => light.intensity === 0)).toBe( + true + ); + expect( + controller.lights.every( + (light) => light.shadow.needsUpdate === (light.shadow.map === null) + ) + ).toBe(true); + + controller.dispose(); + }); + + it("keeps all sampler slots valid for a three-tile layout and quality reset", () => { + const camera = buildCamera(); + const controller = new TiledShadowController(new THREE.Scene(), camera); + const receiverWorldPoints = [-5, 5].flatMap((x) => + [100, 250].flatMap((y) => + [-55, 55].map((z) => new THREE.Vector3(x, y, z)) + ) + ); + const update = buildUpdate(camera, receiverWorldPoints); + + expect(controller.update(update)?.tileCount).toBe(3); + for (const light of controller.lights) { + light.shadow.map = new THREE.WebGLRenderTarget(2_048, 2_048); + light.shadow.needsUpdate = false; + } + + expect(controller.update(update)?.tileCount).toBe(3); + expect( + controller.lights.slice(0, 3).every(({ shadow }) => shadow.needsUpdate) + ).toBe(true); + expect(controller.lights[3].intensity).toBe(0); + expect(controller.lights[3].shadow.map).not.toBeNull(); + expect(controller.lights[3].shadow.needsUpdate).toBe(false); + + expect(controller.update({ ...update, quality: 16 })?.tileCount).toBe(3); + expect( + controller.lights.every( + ({ shadow }) => shadow.map === null && shadow.needsUpdate + ) + ).toBe(true); + + for (const light of controller.lights) { + light.shadow.map = new THREE.WebGLRenderTarget(4_096, 4_096); + light.shadow.needsUpdate = false; + } + controller.update({ ...update, quality: 16 }); + expect(controller.lights[3].shadow.map).not.toBeNull(); + expect(controller.lights[3].shadow.needsUpdate).toBe(false); + + controller.dispose(); + }); +}); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts new file mode 100644 index 0000000000..aa3f4c8c59 --- /dev/null +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -0,0 +1,731 @@ +/** Fixed-pool, receiver-fitted light-space shadow tiling. */ +import * as THREE from "three"; +import { CSM } from "three/addons/csm/CSM.js"; + +import type { ShadowMode, ShadowQualityMultiplier } from "./shadow-scene"; +import { + buildShadowTileLayout, + type LightSpaceBounds, + type ShadowTileLayoutStatistics, +} from "./shadow-tile-layout"; + +export const SHADOW_TILE_COUNT = 4; +const BASE_SHADOW_TILE_MAP_SIZE = 1_024; +const BASE_SINGLE_SHADOW_MAP_SIZE = 2_048; +const SHADOW_TILE_PROGRAM_CACHE_KEY = "carma-light-space-tiles-v1"; +const SHADOW_FILTER_GUARD_TEXELS = 3; +const MIN_SHADOW_TILE_AREA_METERS = 2; +const MIN_CASTER_REACH_METERS = 50; +const MAX_CASTER_REACH_METERS = 10_000; +const CASTER_REACH_ELEVATION_EPSILON = 0.04; +const LIGHT_CAMERA_SAFETY_METERS = 25; +const SHADOW_NORMAL_BIAS_TEXELS = 0.5; +const SHADOW_DEPTH_BIAS_TEXELS = 0.5; +const MIN_SHADOW_DEPTH_BIAS_METERS = 0.01; +const MAX_SHADOW_DEPTH_BIAS_METERS = 0.35; +const MIN_SHADOW_NORMAL_BIAS_METERS = 0.02; +const MAX_SHADOW_NORMAL_BIAS_METERS = 0.5; + +export type TiledShadowTileSnapshot = Readonly<{ + id: string; + row: number; + column: number; + receiverPointCount: number; + receiverLeftMeters: number; + receiverRightMeters: number; + receiverBottomMeters: number; + receiverTopMeters: number; + leftMeters: number; + rightMeters: number; + bottomMeters: number; + topMeters: number; + nearMeters: number; + farMeters: number; + shadowMapWidth: number; + shadowMapHeight: number; + viewMatrixElements: readonly number[]; + projectionMatrixElements: readonly number[]; + statistics: Pick< + ShadowTileLayoutStatistics, + "casterGuardMeters" | "effectiveMetersPerTexel" + >; +}>; + +export type TiledShadowSnapshot = Readonly<{ + strategy: "single-viewport" | "tiled-light-space"; + tileCount: number; + totalShadowTexels: number; + casterReachMeters: number; + tiles: readonly TiledShadowTileSnapshot[]; +}>; + +export type TiledShadowUpdate = Readonly<{ + camera: THREE.PerspectiveCamera; + receiverWorldPoints: readonly THREE.Vector3[]; + minimumElevationMeters: number; + maximumElevationMeters: number; + directionToSun: THREE.Vector3; + color: THREE.ColorRepresentation; + intensity: number; + shadowIntensity: number; + quality: ShadowQualityMultiplier; +}>; + +type MaterialState = Readonly<{ + onBeforeCompile: THREE.Material["onBeforeCompile"]; + customProgramCacheKey: THREE.Material["customProgramCacheKey"]; + defines: Record | undefined; + disposeListener: () => void; +}>; + +const CSM_DIRECTIONAL_BLOCK_START = + "#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) && defined( USE_CSM ) && defined( CSM_CASCADES )"; +const STANDARD_DIRECTIONAL_BLOCK_START = + "#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct ) && !defined( USE_CSM ) && !defined( CSM_CASCADES )"; +const LIGHTS_PARS_INCLUDE = "#include "; +const LIGHTS_FRAGMENT_INCLUDE = "#include "; +const TILE_RECEIVER_UNIFORM_DECLARATION = + "#if defined( USE_CSM ) && defined( CSM_CASCADES )\nuniform vec4 CSM_tileReceiverUv[ CSM_CASCADES ];\n#endif"; + +const TILED_DIRECTIONAL_LIGHT_BLOCK = /* glsl */ `${CSM_DIRECTIONAL_BLOCK_START} + + DirectionalLight directionalLight; + #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0 + DirectionalLightShadow directionalLightShadow; + #endif + + bool CSM_tileSelected = false; + directionalLight = directionalLights[ 0 ]; + getDirectionalLightInfo( directionalLight, directLight ); + + #if defined( USE_SHADOWMAP ) && NUM_DIR_LIGHT_SHADOWS > 0 + + // CARMA light-space tile selection begin + #pragma unroll_loop_start + for ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) { + + #if ( UNROLLED_LOOP_INDEX < NUM_DIR_LIGHT_SHADOWS ) && ( UNROLLED_LOOP_INDEX < CSM_CASCADES ) + { + vec4 tileReceiverUv = CSM_tileReceiverUv[ i ]; + vec4 tileShadowCoord = vDirectionalShadowCoord[ i ]; + bool tileActive = tileReceiverUv.x <= tileReceiverUv.z && tileReceiverUv.y <= tileReceiverUv.w; + bool tileValidW = tileShadowCoord.w > 0.0; + vec2 tileUv = tileValidW ? tileShadowCoord.xy / tileShadowCoord.w : vec2( -2.0 ); + bool tileContainsReceiver = tileActive && tileValidW && all( greaterThanEqual( tileUv, tileReceiverUv.xy ) ) && all( lessThanEqual( tileUv, tileReceiverUv.zw ) ); + if ( ! CSM_tileSelected && tileActive && tileContainsReceiver ) { + + directionalLight = directionalLights[ i ]; + getDirectionalLightInfo( directionalLight, directLight ); + directionalLightShadow = directionalLightShadows[ i ]; + directLight.color *= ( directLight.visible && receiveShadow ) ? getShadow( directionalShadowMap[ i ], directionalLightShadow.shadowMapSize, directionalLightShadow.shadowIntensity, directionalLightShadow.shadowBias, directionalLightShadow.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0; + CSM_tileSelected = true; + + } + } + #endif + + } + #pragma unroll_loop_end + + #endif + + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + // CARMA light-space tile selection end + + #if ( NUM_DIR_LIGHTS > NUM_DIR_LIGHT_SHADOWS ) + + #pragma unroll_loop_start + for ( int i = NUM_DIR_LIGHT_SHADOWS; i < NUM_DIR_LIGHTS; i ++ ) { + + directionalLight = directionalLights[ i ]; + getDirectionalLightInfo( directionalLight, directLight ); + RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight ); + + } + #pragma unroll_loop_end + + #endif + +#endif + + +`; + +export const buildTiledLightSpaceLightsFragment = (source: string): string => { + const blockStart = source.indexOf(CSM_DIRECTIONAL_BLOCK_START); + const nextBlockStart = source.indexOf( + STANDARD_DIRECTIONAL_BLOCK_START, + blockStart + ); + const repeatedBlockStart = source.indexOf( + CSM_DIRECTIONAL_BLOCK_START, + blockStart + CSM_DIRECTIONAL_BLOCK_START.length + ); + const repeatedNextBlockStart = source.indexOf( + STANDARD_DIRECTIONAL_BLOCK_START, + nextBlockStart + STANDARD_DIRECTIONAL_BLOCK_START.length + ); + if ( + blockStart < 0 || + nextBlockStart < 0 || + repeatedBlockStart >= 0 || + repeatedNextBlockStart >= 0 + ) { + throw new Error( + "Unable to locate the Three.js CSM directional-light block" + ); + } + return `${source.slice( + 0, + blockStart + )}${TILED_DIRECTIONAL_LIGHT_BLOCK}${source.slice(nextBlockStart)}`; +}; + +const patchMaterialFragmentShader = (source: string) => { + if ( + !source.includes(LIGHTS_PARS_INCLUDE) || + !source.includes(LIGHTS_FRAGMENT_INCLUDE) + ) { + throw new Error( + "Tiled shadow materials must retain the Three.js light shader includes" + ); + } + const tiledLightsFragment = buildTiledLightSpaceLightsFragment( + THREE.ShaderChunk.lights_fragment_begin + ); + return source + .replace( + LIGHTS_PARS_INCLUDE, + `${LIGHTS_PARS_INCLUDE}\n${TILE_RECEIVER_UNIFORM_DECLARATION}` + ) + .replace(LIGHTS_FRAGMENT_INCLUDE, tiledLightsFragment); +}; + +const asMaterials = ( + material: THREE.Material | THREE.Material[] +): readonly THREE.Material[] => + Array.isArray(material) ? material : [material]; + +type BuiltInLitMaterial = THREE.Material & + Partial< + Record< + | "isMeshLambertMaterial" + | "isMeshPhongMaterial" + | "isMeshToonMaterial" + | "isMeshStandardMaterial" + | "isMeshPhysicalMaterial", + boolean + > + >; + +const isBuiltInLitMaterial = ( + material: THREE.Material +): material is BuiltInLitMaterial => { + const candidate = material as BuiltInLitMaterial; + return Boolean( + candidate.isMeshLambertMaterial || + candidate.isMeshPhongMaterial || + candidate.isMeshToonMaterial || + candidate.isMeshStandardMaterial || + candidate.isMeshPhysicalMaterial + ); +}; + +const getShadowTileMapSize = (quality: ShadowQualityMultiplier) => + BASE_SHADOW_TILE_MAP_SIZE * Math.sqrt(quality); + +const getReceiverBoundsInLightCamera = ( + points: readonly THREE.Vector3[], + camera: THREE.OrthographicCamera +): LightSpaceBounds | null => { + if (points.length === 0) return null; + camera.updateMatrixWorld(true); + const projected = points.map((point) => + point.clone().applyMatrix4(camera.matrixWorldInverse) + ); + const left = Math.min(...projected.map(({ x }) => x)); + const right = Math.max(...projected.map(({ x }) => x)); + const bottom = Math.min(...projected.map(({ y }) => y)); + const top = Math.max(...projected.map(({ y }) => y)); + const depths = projected.map(({ z }) => -z); + const near = Math.max(0, Math.min(...depths)); + const far = Math.max(near + 0.01, Math.max(...depths)); + const centerX = (left + right) / 2; + const centerY = (bottom + top) / 2; + const halfWidth = Math.max( + (right - left) / 2, + MIN_SHADOW_TILE_AREA_METERS / 2 + ); + const halfHeight = Math.max( + (top - bottom) / 2, + MIN_SHADOW_TILE_AREA_METERS / 2 + ); + return { + left: centerX - halfWidth, + right: centerX + halfWidth, + bottom: centerY - halfHeight, + top: centerY + halfHeight, + near, + far, + }; +}; + +const copyDefines = (defines: Record | undefined) => + defines ? { ...defines } : undefined; + +export class TiledShadowController { + readonly csm: CSM; + readonly lights: readonly THREE.DirectionalLight[]; + + private readonly materialStates = new Map(); + private readonly tileReceiverUvs = Array.from( + { length: SHADOW_TILE_COUNT }, + () => new THREE.Vector4(2, 2, -1, -1) + ); + private activeTileCount = 0; + private mode: ShadowMode = "advanced"; + private disposed = false; + + constructor(scene: THREE.Scene, camera = new THREE.PerspectiveCamera()) { + this.csm = new CSM({ + camera, + parent: scene, + cascades: SHADOW_TILE_COUNT, + mode: "practical", + maxFar: 5_000, + shadowMapSize: BASE_SHADOW_TILE_MAP_SIZE, + lightNear: 0.1, + lightFar: 10_000, + lightMargin: 1_000, + lightIntensity: 1, + }); + // CSM remains the material/light lifecycle shell. Tile selection is handled + // by the receiver-core shader patch, so depth blending must stay disabled. + this.csm.fade = false; + this.lights = this.csm.lights; + for (let index = 0; index < this.lights.length; index += 1) { + const light = this.lights[index]; + light.name = + index === 0 + ? "shadow-simulation-sun" + : `shadow-simulation-sun-tile-${index}`; + light.shadow.autoUpdate = false; + // Every entry in the fixed sampler array must have a depth texture, even + // while its logical tile is inactive. WebGL validates all sampler-array + // bindings before executing the receiver-core branch, so one null map + // would invalidate every shaded draw. The first shadow pass allocates the + // pool; inactive entries stay at zero intensity afterwards. + light.shadow.needsUpdate = true; + light.shadow.radius = 0; + } + } + + setupMaterial(material: THREE.Material): void { + if ( + this.disposed || + this.mode === "single" || + this.materialStates.has(material) || + !isBuiltInLitMaterial(material) + ) { + return; + } + const state: MaterialState = { + onBeforeCompile: material.onBeforeCompile, + customProgramCacheKey: material.customProgramCacheKey, + defines: copyDefines(material.defines), + disposeListener: () => this.releaseMaterial(material), + }; + this.csm.setupMaterial(material); + const csmOnBeforeCompile = material.onBeforeCompile; + const tileReceiverUvs = this.tileReceiverUvs; + material.onBeforeCompile = function (shader, renderer) { + state.onBeforeCompile.call(this, shader, renderer); + csmOnBeforeCompile.call(this, shader, renderer); + shader.uniforms.CSM_tileReceiverUv = { value: tileReceiverUvs }; + shader.fragmentShader = patchMaterialFragmentShader( + shader.fragmentShader + ); + }; + material.customProgramCacheKey = function () { + return `${state.customProgramCacheKey.call( + this + )}|${SHADOW_TILE_PROGRAM_CACHE_KEY}`; + }; + this.materialStates.set(material, state); + material.addEventListener("dispose", state.disposeListener); + material.needsUpdate = true; + } + + releaseMaterial(material: THREE.Material): void { + const state = this.materialStates.get(material); + if (!state) return; + material.removeEventListener("dispose", state.disposeListener); + this.csm.shaders.delete(material); + material.onBeforeCompile = state.onBeforeCompile; + material.customProgramCacheKey = state.customProgramCacheKey; + material.defines = copyDefines(state.defines); + material.needsUpdate = true; + this.materialStates.delete(material); + } + + syncSceneMaterials(scene: THREE.Scene): void { + if (this.disposed) return; + if (this.mode === "single") { + for (const material of [...this.materialStates.keys()]) { + this.releaseMaterial(material); + } + return; + } + const retainedMaterials = new Set(); + scene.traverse((object) => { + const mesh = object as THREE.Mesh; + if ( + object.userData.isShadowSimulationOverlay || + (!mesh.isMesh && !(mesh as THREE.InstancedMesh).isInstancedMesh) + ) { + return; + } + for (const material of asMaterials(mesh.material)) { + retainedMaterials.add(material); + this.setupMaterial(material); + } + }); + for (const material of [...this.materialStates.keys()]) { + if (!retainedMaterials.has(material)) this.releaseMaterial(material); + } + } + + invalidate(): void { + for (let index = 0; index < this.activeTileCount; index += 1) { + this.lights[index].shadow.needsUpdate = true; + } + } + + setMode(mode: ShadowMode): void { + if (this.disposed || this.mode === mode) return; + this.mode = mode; + if (mode === "single") { + for (const material of [...this.materialStates.keys()]) { + this.releaseMaterial(material); + } + } + this.lights.forEach((light, index) => { + const active = mode === "advanced" || index === 0; + light.visible = active; + light.castShadow = active; + light.shadow.needsUpdate = active; + if (!active) { + light.intensity = 0; + light.shadow.map?.dispose(); + light.shadow.map = null; + } + }); + this.activeTileCount = mode === "single" ? 1 : 0; + } + + update({ + camera, + receiverWorldPoints, + minimumElevationMeters, + maximumElevationMeters, + directionToSun, + color, + intensity, + shadowIntensity, + quality, + }: TiledShadowUpdate): TiledShadowSnapshot | null { + if (this.disposed) return null; + if (receiverWorldPoints.length === 0) { + this.activeTileCount = 0; + for (let index = 0; index < this.lights.length; index += 1) { + const active = this.mode === "advanced" || index === 0; + this.tileReceiverUvs[index].set(2, 2, -1, -1); + this.lights[index].intensity = 0; + this.lights[index].shadow.intensity = THREE.MathUtils.clamp( + shadowIntensity, + 0, + 1 + ); + this.lights[index].shadow.needsUpdate = + active && this.lights[index].shadow.map === null; + } + return null; + } + camera.updateMatrixWorld(true); + const normalizedDirectionToSun = directionToSun.clone().normalize(); + const reliefMeters = Math.max( + 0, + maximumElevationMeters - minimumElevationMeters + ); + const elevationSine = Math.max( + CASTER_REACH_ELEVATION_EPSILON, + normalizedDirectionToSun.y + ); + const casterReachMeters = THREE.MathUtils.clamp( + reliefMeters / elevationSine + MIN_CASTER_REACH_METERS, + MIN_CASTER_REACH_METERS, + MAX_CASTER_REACH_METERS + ); + const lightMargin = + casterReachMeters + reliefMeters + LIGHT_CAMERA_SAFETY_METERS; + const mapSize = + this.mode === "single" + ? BASE_SINGLE_SHADOW_MAP_SIZE * Math.sqrt(quality) + : getShadowTileMapSize(quality); + + this.csm.camera = camera; + this.csm.lightMargin = lightMargin; + this.csm.lightNear = 0.1; + this.csm.lightFar = lightMargin * 2; + this.csm.shadowMapSize = mapSize; + this.csm.lightDirection.copy(directionToSun).multiplyScalar(-1).normalize(); + + const resolvedColor = new THREE.Color(color); + const receiverBox = new THREE.Box3().setFromPoints([ + ...receiverWorldPoints, + ]); + const receiverSphere = receiverBox.getBoundingSphere(new THREE.Sphere()); + const lightTargetPosition = receiverSphere.center; + const lightPosition = normalizedDirectionToSun + .clone() + .multiplyScalar(receiverSphere.radius + lightMargin) + .add(lightTargetPosition); + for (let index = 0; index < this.lights.length; index += 1) { + const light = this.lights[index]; + const active = this.mode === "advanced" || index === 0; + light.visible = active; + light.castShadow = active; + light.color.copy(resolvedColor); + light.intensity = active ? intensity : 0; + light.shadow.intensity = THREE.MathUtils.clamp(shadowIntensity, 0, 1); + if ( + light.shadow.mapSize.x !== mapSize || + light.shadow.mapSize.y !== mapSize + ) { + light.shadow.map?.dispose(); + light.shadow.map = null; + light.shadow.mapSize.set(mapSize, mapSize); + } + light.position.copy(lightPosition); + light.target.position.copy(lightTargetPosition); + light.updateMatrixWorld(true); + light.target.updateMatrixWorld(true); + light.shadow.updateMatrices(light); + } + + const referenceShadowCamera = this.lights[0].shadow.camera; + const receiverBounds = getReceiverBoundsInLightCamera( + receiverWorldPoints, + referenceShadowCamera + ); + if (!receiverBounds) return null; + if (this.mode === "single") { + const light = this.lights[0]; + const shadowCamera = light.shadow.camera; + const usableMapWidth = Math.max( + 1, + light.shadow.mapSize.x - SHADOW_FILTER_GUARD_TEXELS * 2 + ); + const usableMapHeight = Math.max( + 1, + light.shadow.mapSize.y - SHADOW_FILTER_GUARD_TEXELS * 2 + ); + const receiverWidth = receiverBounds.right - receiverBounds.left; + const receiverHeight = receiverBounds.top - receiverBounds.bottom; + const texelMeters = Math.max( + receiverWidth / usableMapWidth, + receiverHeight / usableMapHeight, + Number.EPSILON + ); + const guardMeters = texelMeters * SHADOW_FILTER_GUARD_TEXELS; + // Preserve square world-space texels. The orthographic camera therefore + // has exactly the same aspect ratio as its actual shadow-map buffer. + const fittedWidth = texelMeters * light.shadow.mapSize.x; + const fittedHeight = texelMeters * light.shadow.mapSize.y; + const centerX = + Math.round( + (receiverBounds.left + receiverBounds.right) / 2 / texelMeters + ) * texelMeters; + const centerY = + Math.round( + (receiverBounds.bottom + receiverBounds.top) / 2 / texelMeters + ) * texelMeters; + shadowCamera.left = centerX - fittedWidth / 2; + shadowCamera.right = centerX + fittedWidth / 2; + shadowCamera.bottom = centerY - fittedHeight / 2; + shadowCamera.top = centerY + fittedHeight / 2; + shadowCamera.near = Math.max( + 0.01, + receiverBounds.near - LIGHT_CAMERA_SAFETY_METERS + ); + shadowCamera.far = Math.max( + shadowCamera.near + 1, + receiverBounds.far + casterReachMeters + reliefMeters + ); + light.shadow.normalBias = THREE.MathUtils.clamp( + texelMeters * SHADOW_NORMAL_BIAS_TEXELS, + MIN_SHADOW_NORMAL_BIAS_METERS, + MAX_SHADOW_NORMAL_BIAS_METERS + ); + const depthBiasMeters = THREE.MathUtils.clamp( + texelMeters * SHADOW_DEPTH_BIAS_TEXELS, + MIN_SHADOW_DEPTH_BIAS_METERS, + MAX_SHADOW_DEPTH_BIAS_METERS + ); + light.shadow.bias = + -depthBiasMeters / (shadowCamera.far - shadowCamera.near); + shadowCamera.updateProjectionMatrix(); + light.shadow.updateMatrices(light); + light.shadow.needsUpdate = true; + this.activeTileCount = 1; + this.tileReceiverUvs[0].set(0, 0, 1, 1); + for (let index = 1; index < this.lights.length; index += 1) { + this.tileReceiverUvs[index].set(2, 2, -1, -1); + this.lights[index].intensity = 0; + this.lights[index].shadow.needsUpdate = false; + } + return { + strategy: "single-viewport", + tileCount: 1, + totalShadowTexels: mapSize * mapSize, + casterReachMeters, + tiles: [ + { + id: "single", + row: 0, + column: 0, + receiverPointCount: receiverWorldPoints.length, + receiverLeftMeters: receiverBounds.left, + receiverRightMeters: receiverBounds.right, + receiverBottomMeters: receiverBounds.bottom, + receiverTopMeters: receiverBounds.top, + leftMeters: shadowCamera.left, + rightMeters: shadowCamera.right, + bottomMeters: shadowCamera.bottom, + topMeters: shadowCamera.top, + nearMeters: shadowCamera.near, + farMeters: shadowCamera.far, + shadowMapWidth: light.shadow.mapSize.x, + shadowMapHeight: light.shadow.mapSize.y, + viewMatrixElements: [...shadowCamera.matrixWorldInverse.elements], + projectionMatrixElements: [ + ...shadowCamera.projectionMatrix.elements, + ], + statistics: { + casterGuardMeters: guardMeters, + effectiveMetersPerTexel: texelMeters, + }, + }, + ], + }; + } + const usableMapDimension = Math.max( + 1, + mapSize - SHADOW_FILTER_GUARD_TEXELS * 2 + ); + const oneMapFitMetersPerTexel = Math.max( + (receiverBounds.right - receiverBounds.left) / usableMapDimension, + (receiverBounds.top - receiverBounds.bottom) / usableMapDimension + ); + const requestedMetersPerTexel = Math.max( + oneMapFitMetersPerTexel / SHADOW_TILE_COUNT, + Number.EPSILON + ); + const layout = buildShadowTileLayout({ + receiverBounds, + casterPaddingMeters: requestedMetersPerTexel * SHADOW_FILTER_GUARD_TEXELS, + casterReliefMeters: reliefMeters, + casterReachMeters, + targetMetersPerTexel: requestedMetersPerTexel, + maxShadowMapDimension: mapSize, + maxTileCount: SHADOW_TILE_COUNT, + }); + this.activeTileCount = layout.tiles.length; + + const tiles: TiledShadowTileSnapshot[] = []; + for (let index = 0; index < this.lights.length; index += 1) { + const light = this.lights[index]; + const shadowCamera = light.shadow.camera; + const tile = layout.tiles[index]; + const tileReceiverUv = this.tileReceiverUvs[index]; + if (!tile) { + tileReceiverUv.set(2, 2, -1, -1); + light.intensity = 0; + light.shadow.needsUpdate = light.shadow.map === null; + continue; + } + light.intensity = intensity; + shadowCamera.left = tile.left; + shadowCamera.right = tile.right; + shadowCamera.bottom = tile.bottom; + shadowCamera.top = tile.top; + shadowCamera.near = Math.max(0.01, tile.near); + shadowCamera.far = Math.max(shadowCamera.near + 1, tile.far); + const texelMeters = layout.statistics.effectiveMetersPerTexel; + light.shadow.normalBias = THREE.MathUtils.clamp( + texelMeters * SHADOW_NORMAL_BIAS_TEXELS, + MIN_SHADOW_NORMAL_BIAS_METERS, + MAX_SHADOW_NORMAL_BIAS_METERS + ); + const depthBiasMeters = THREE.MathUtils.clamp( + texelMeters * SHADOW_DEPTH_BIAS_TEXELS, + MIN_SHADOW_DEPTH_BIAS_METERS, + MAX_SHADOW_DEPTH_BIAS_METERS + ); + light.shadow.bias = + -depthBiasMeters / (shadowCamera.far - shadowCamera.near); + shadowCamera.updateProjectionMatrix(); + light.shadow.updateMatrices(light); + light.shadow.needsUpdate = true; + const tileWidthMeters = tile.right - tile.left; + const tileHeightMeters = tile.top - tile.bottom; + tileReceiverUv.set( + (tile.receiverBounds.left - tile.left) / tileWidthMeters, + (tile.receiverBounds.bottom - tile.bottom) / tileHeightMeters, + (tile.receiverBounds.right - tile.left) / tileWidthMeters, + (tile.receiverBounds.top - tile.bottom) / tileHeightMeters + ); + tiles.push({ + id: tile.id, + row: tile.row, + column: tile.column, + receiverPointCount: receiverWorldPoints.length, + receiverLeftMeters: tile.receiverBounds.left, + receiverRightMeters: tile.receiverBounds.right, + receiverBottomMeters: tile.receiverBounds.bottom, + receiverTopMeters: tile.receiverBounds.top, + leftMeters: shadowCamera.left, + rightMeters: shadowCamera.right, + bottomMeters: shadowCamera.bottom, + topMeters: shadowCamera.top, + nearMeters: shadowCamera.near, + farMeters: shadowCamera.far, + shadowMapWidth: light.shadow.mapSize.x, + shadowMapHeight: light.shadow.mapSize.y, + viewMatrixElements: [...shadowCamera.matrixWorldInverse.elements], + projectionMatrixElements: [...shadowCamera.projectionMatrix.elements], + statistics: layout.statistics, + }); + } + return { + strategy: "tiled-light-space", + tileCount: tiles.length, + // The four-map GPU pool is fixed. Inactive logical tiles skip shadow-map + // updates but remain part of the stable compile/allocation budget. + totalShadowTexels: this.lights.length * mapSize * mapSize, + casterReachMeters, + tiles, + }; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const material of [...this.materialStates.keys()]) { + this.releaseMaterial(material); + } + this.csm.dispose(); + for (const light of this.lights) light.shadow.map?.dispose(); + this.csm.remove(); + } +} diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts index e488207290..c868b09e58 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -1,3 +1,4 @@ +import { MercatorCoordinate } from "maplibre-gl"; import { BufferGeometry, Camera, @@ -328,6 +329,188 @@ describe("buildCesiumTerrainRuntime", () => { expect(runtime.root.children).toHaveLength(0); }); + it("selects terrain from the union of all shadow-camera frusta", async () => { + const westId = { level: 10, x: 531, y: 218 }; + const viewportId = { level: 10, x: 532, y: 218 }; + const eastId = { level: 10, x: 533, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileIdsForBounds: vi.fn(() => [viewportId]), + getTileGridIdsForBounds: vi.fn((bounds) => { + const ids = [viewportId]; + if (bounds.west < 7.05) ids.unshift(westId); + if (bounds.east > 7.25) ids.push(eastId); + return ids; + }), + getTileBounds: vi.fn((id) => + id.x === westId.x + ? { west: 6.8, south: 51.2, east: 7, north: 51.3 } + : id.x === eastId.x + ? { west: 7.4, south: 51.2, east: 7.5, north: 51.3 } + : { west: 7.1, south: 51.2, east: 7.2, north: 51.3 } + ), + getLevelMaximumGeometricError: vi.fn(() => 0.01), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const originLngLat: [number, number] = [7.15, 51.256]; + const runtime = buildCesiumTerrainRuntime( + "multi-shadow-terrain", + "https://example.test/multi-shadow-terrain", + originLngLat, + { minimumLevel: 10, maximumLevel: 10 } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.1, + getSouth: () => 51.2, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + const frame = { + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }; + runtime.update(frame); + await expect(runtime.ready).resolves.toBe(true); + + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const makeShadowCamera = (longitude: number) => { + const coordinate = MercatorCoordinate.fromLngLat( + [longitude, originLngLat[1]], + 0 + ); + const x = (coordinate.x - origin.x) / meterScale; + const z = (coordinate.y - origin.y) / meterScale; + const camera = new OrthographicCamera(-500, 500, 500, -500, 1, 5_000); + camera.position.set(x, 1_000, z); + camera.lookAt(x, 0, z); + camera.updateProjectionMatrix(); + camera.updateMatrixWorld(true); + return camera; + }; + runtime.setShadowCameras([makeShadowCamera(6.9), makeShadowCamera(7.45)]); + runtime.update(frame); + + await vi.waitFor(() => { + expect(source.requestTile).toHaveBeenCalledWith(westId); + expect(source.requestTile).toHaveBeenCalledWith(eastId); + }); + runtime.dispose(); + }); + + it("uses orthographic shadow resolution to refine offscreen occluders", async () => { + const parentId = { level: 10, x: 532, y: 218 }; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileIdsForBounds: vi.fn(() => [parentId]), + getTileGridIdsForBounds: vi.fn(() => [parentId]), + getTileBounds: vi.fn((id) => { + if (id.level === parentId.level) { + return { west: 7.4, south: 51.24, east: 7.46, north: 51.27 }; + } + const west = id.x % 2 === 0 ? 7.4 : 7.43; + const north = id.y % 2 === 0 ? 51.27 : 51.255; + return { west, south: north - 0.015, east: west + 0.03, north }; + }), + getLevelMaximumGeometricError: vi.fn(() => 100), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const originLngLat: [number, number] = [7.15, 51.256]; + const runtime = buildCesiumTerrainRuntime( + "orthographic-shadow-terrain", + "https://example.test/orthographic-shadow-terrain", + originLngLat, + { + minimumLevel: 10, + maximumLevel: 11, + errorTargetPixels: 2.5, + shadowLevelOffset: 2, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.1, + getSouth: () => 51.24, + getEast: () => 7.2, + getNorth: () => 51.27, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const coordinate = MercatorCoordinate.fromLngLat( + [7.43, originLngLat[1]], + 0 + ); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const x = (coordinate.x - origin.x) / meterScale; + const z = (coordinate.y - origin.y) / meterScale; + const shadowCamera = new OrthographicCamera(-500, 500, 500, -500, 1, 5_000); + shadowCamera.position.set(x, 1_000, z); + shadowCamera.lookAt(x, 0, z); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowCameras([ + { + camera: shadowCamera, + shadowMapSize: { width: 1_000, height: 1_000 }, + }, + ]); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 1_000, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + // Deliberately unrelated to the 1,000 px shadow map: terrain shadow LOD + // must follow the raster it is rendered into, not the browser viewport. + viewport: new Vector2(1, 1), + }); + + await expect(runtime.ready).resolves.toBe(true); + expect(source.requestTile.mock.calls.some(([id]) => id.level === 11)).toBe( + true + ); + expect(source.requestTile).not.toHaveBeenCalledWith(parentId); + runtime.dispose(); + }); + it("fills unavailable viewport cells with zero-elevation receiver tiles", async () => { const zeroSourceId = { level: 10, x: 531, y: 218 }; const sourceId = { level: 10, x: 532, y: 218 }; @@ -507,6 +690,19 @@ describe("buildCesiumTerrainRuntime", () => { }); const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); lodCamera.position.set(0, 1_000, 0); + const lowResolutionShadowCamera = new OrthographicCamera( + -50_000, + 50_000, + 50_000, + -50_000, + 1, + 5_000 + ); + lowResolutionShadowCamera.position.set(0, 1_000, 0); + lowResolutionShadowCamera.lookAt(0, 0, 0); + lowResolutionShadowCamera.updateProjectionMatrix(); + lowResolutionShadowCamera.updateMatrixWorld(true); + runtime.setShadowCameras([lowResolutionShadowCamera]); runtime.update({ map: map as never, renderCamera: new Camera(), diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index 469f94bd4f..9230b62d72 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -69,10 +69,24 @@ export type CesiumTerrainRuntimeOptions = Readonly<{ onError?: (error: unknown) => void; }>; +export type CesiumTerrainShadowView = Readonly<{ + camera: Camera; + shadowMapSize: Readonly<{ + width: number; + height: number; + }>; +}>; + export interface CesiumTerrainRuntime extends SharedThreeSceneRuntime { ready: Promise; setVisible: (visible: boolean) => void; - setShadowCamera: (camera: Camera | null) => void; + setShadowCameras: ( + cameras: readonly (Camera | CesiumTerrainShadowView)[] + ) => void; + setShadowCamera: ( + camera: Camera | null, + shadowMapSize?: CesiumTerrainShadowView["shadowMapSize"] + ) => void; setMaterialColor: (color: ColorRepresentation) => void; getElevation: (longitude: number, latitude: number) => number | undefined; } @@ -125,6 +139,11 @@ type TerrainCandidate = { errorRatio: number; }; +type TerrainShadowView = Readonly<{ + camera: Camera; + shadowMapSize: CesiumTerrainShadowView["shadowMapSize"] | null; +}>; + const FLAT_TERRAIN_U = new Float32Array([0, 0, 1, 1]); const FLAT_TERRAIN_V = new Float32Array([0, 1, 0, 1]); const FLAT_TERRAIN_HEIGHTS = new Float32Array(4); @@ -270,6 +289,19 @@ const clampInteger = ( minimum: number ) => Math.max(minimum, Math.floor(value ?? fallback)); +const normalizeShadowMapSize = ( + shadowMapSize: CesiumTerrainShadowView["shadowMapSize"] +): CesiumTerrainShadowView["shadowMapSize"] => ({ + width: + Number.isFinite(shadowMapSize.width) && shadowMapSize.width > 0 + ? shadowMapSize.width + : 1, + height: + Number.isFinite(shadowMapSize.height) && shadowMapSize.height > 0 + ? shadowMapSize.height + : 1, +}); + const boundsIntersect = ( left: CesiumTerrainTileBounds, right: CesiumTerrainTileBounds @@ -461,7 +493,7 @@ export const buildCesiumTerrainRuntime = ( let coverageBounds: CesiumTerrainTileBounds | null = null; let source: CesiumTerrainTileSource | null = null; let map: MaplibreMap | null = null; - let shadowCamera: Camera | null = null; + let shadowViews: readonly TerrainShadowView[] = []; let unregisterSampler: (() => void) | null = null; let disposed = false; let meshUseClock = 0; @@ -548,11 +580,45 @@ export const buildCesiumTerrainRuntime = ( ); }; + const getOrthographicShadowScreenSpaceError = ( + terrainSource: CesiumTerrainTileSource, + camera: Camera, + pixelWidth: number, + pixelHeight: number, + id: CesiumTerrainTileId + ) => { + if ( + !(camera as Camera & { isOrthographicCamera?: boolean }) + .isOrthographicCamera + ) { + return 0; + } + camera.updateMatrixWorld(true); + root.updateMatrixWorld(true); + const clipFromRoot = new Matrix4() + .multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse) + .multiply(root.matrixWorld); + const elements = clipFromRoot.elements; + const pixelsPerMeterForAxis = (offset: number) => + Math.hypot( + (elements[offset] * pixelWidth) / 2, + (elements[offset + 1] * pixelHeight) / 2 + ); + const pixelsPerMeter = Math.max( + pixelsPerMeterForAxis(0), + pixelsPerMeterForAxis(4), + pixelsPerMeterForAxis(8) + ); + return ( + terrainSource.getLevelMaximumGeometricError(id.level) * pixelsPerMeter + ); + }; + const getRelevantChildren = ( terrainSource: CesiumTerrainTileSource, parent: TerrainSelectionEntry, viewportBounds: CesiumTerrainTileBounds, - shadowBounds: CesiumTerrainTileBounds | null + shadowBounds: readonly CesiumTerrainTileBounds[] ) => { if (parent.kind === "flat") return []; const childLevel = parent.id.level + 1; @@ -566,8 +632,9 @@ export const buildCesiumTerrainRuntime = ( }; const bounds = terrainSource.getTileBounds(id); const intersectsViewport = boundsIntersect(bounds, viewportBounds); - const intersectsShadow = - !!shadowBounds && boundsIntersect(bounds, shadowBounds); + const intersectsShadow = shadowBounds.some((candidateBounds) => + boundsIntersect(bounds, candidateBounds) + ); if (!intersectsViewport && !intersectsShadow) continue; const kind = terrainSource.getTileDataAvailable(id) === false ? "flat" : "source"; @@ -807,24 +874,30 @@ export const buildCesiumTerrainRuntime = ( frame: SharedThreeSceneFrame ): TerrainSelection => { const viewportBounds = getViewportBounds(frame.map); - const shadowBounds = shadowCamera - ? cameraFrustumBounds(shadowCamera, root, origin, meterScale) - : null; - const coverageBounds = shadowBounds - ? unionBounds(viewportBounds, shadowBounds) - : viewportBounds; + const shadowCoverages = shadowViews.flatMap((view) => { + const bounds = cameraFrustumBounds(view.camera, root, origin, meterScale); + return bounds ? [{ ...view, bounds }] : []; + }); + const shadowBounds = shadowCoverages.map(({ bounds }) => bounds); + const coverageBounds = shadowBounds.reduce( + (combined, bounds) => unionBounds(combined, bounds), + viewportBounds + ); const getRootEntries = (level: number): TerrainSelectionEntry[] => terrainSource .getTileGridIdsForBounds(coverageBounds, level) .flatMap((id) => { + const bounds = terrainSource.getTileBounds(id); + const intersectsViewport = boundsIntersect(bounds, viewportBounds); + const intersectsShadow = shadowBounds.some((candidateBounds) => + boundsIntersect(bounds, candidateBounds) + ); + if (!intersectsViewport && !intersectsShadow) return []; const kind = terrainSource.getTileDataAvailable(id) === false ? "flat" : "source"; - if ( - kind === "flat" && - !boundsIntersect(terrainSource.getTileBounds(id), viewportBounds) - ) { + if (kind === "flat" && !intersectsViewport) { return []; } return [{ id, kind }]; @@ -841,16 +914,34 @@ export const buildCesiumTerrainRuntime = ( ); const toCandidate = (entry: TerrainSelectionEntry): TerrainCandidate => { const bounds = terrainSource.getTileBounds(entry.id); - const targetPixels = boundsIntersect(bounds, viewportBounds) - ? errorTargetPixels - : errorTargetPixels * 2 ** shadowLevelOffset; + const intersectsViewport = boundsIntersect(bounds, viewportBounds); + const viewportErrorRatio = intersectsViewport + ? getScreenSpaceError(terrainSource, frame, entry.id) / + errorTargetPixels + : 0; + const shadowTargetPixels = errorTargetPixels * 2 ** shadowLevelOffset; + const shadowErrorRatio = shadowCoverages.reduce( + (maximum, coverage) => + boundsIntersect(bounds, coverage.bounds) + ? Math.max( + maximum, + getOrthographicShadowScreenSpaceError( + terrainSource, + coverage.camera, + coverage.shadowMapSize?.width ?? frame.viewport.x, + coverage.shadowMapSize?.height ?? frame.viewport.y, + entry.id + ) / shadowTargetPixels + ) + : maximum, + 0 + ); return { entry, errorRatio: entry.kind === "flat" ? 0 - : getScreenSpaceError(terrainSource, frame, entry.id) / - targetPixels, + : Math.max(viewportErrorRatio, shadowErrorRatio), }; }; const candidates = rootEntries.map(toCandidate); @@ -1021,8 +1112,29 @@ export const buildCesiumTerrainRuntime = ( } map?.triggerRepaint(); }, - setShadowCamera(camera) { - shadowCamera = camera; + setShadowCameras(cameras) { + shadowViews = cameras.map((entry) => + (entry as Camera & { isCamera?: boolean }).isCamera + ? { camera: entry as Camera, shadowMapSize: null } + : { + camera: (entry as CesiumTerrainShadowView).camera, + shadowMapSize: normalizeShadowMapSize( + (entry as CesiumTerrainShadowView).shadowMapSize + ), + } + ); + }, + setShadowCamera(camera, shadowMapSize) { + shadowViews = camera + ? [ + { + camera, + shadowMapSize: shadowMapSize + ? normalizeShadowMapSize(shadowMapSize) + : null, + }, + ] + : []; }, setMaterialColor(color) { material.color.set(color); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts index 4ca9d8a243..f5a5c62ef0 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.spec.ts @@ -3,11 +3,58 @@ import { describe, expect, it, vi } from "vitest"; import { buildSharedThreeSceneLayer, + configureSharedRenderCamera, installRenderTargetDepthRangeBridge, syncSharedCanvasViewport, } from "./shared-three-scene-layer"; +const expectMatrixToBeCloseTo = ( + actual: THREE.Matrix4, + expected: THREE.Matrix4 +): void => { + actual.elements.forEach((value, index) => { + expect(value).toBeCloseTo(expected.elements[index], 10); + }); +}; + describe("shared Three.js scene layer", () => { + it("uses a real camera view without changing MapLibre's scene-to-clip matrix", () => { + const lodCamera = new THREE.PerspectiveCamera(52, 16 / 9, 2, 1_000_000); + lodCamera.position.set(1_250, 840, -430); + lodCamera.up.set(0, 1, 0); + lodCamera.lookAt(new THREE.Vector3(140, 210, 380)); + lodCamera.updateMatrixWorld(true); + + const sceneToClipMatrix = new THREE.Matrix4() + .makePerspective(-0.7, 0.9, 0.6, -0.5, 0.5, 2_000) + .multiply(new THREE.Matrix4().makeTranslation(0.15, -0.25, 0.4)); + const renderCamera = new THREE.PerspectiveCamera(); + + configureSharedRenderCamera(renderCamera, lodCamera, sceneToClipMatrix); + + expectMatrixToBeCloseTo(renderCamera.matrixWorld, lodCamera.matrixWorld); + expectMatrixToBeCloseTo( + renderCamera.matrixWorldInverse, + lodCamera.matrixWorldInverse + ); + expectMatrixToBeCloseTo( + new THREE.Matrix4().multiplyMatrices( + renderCamera.projectionMatrix, + renderCamera.matrixWorldInverse + ), + sceneToClipMatrix + ); + + renderCamera.updateMatrixWorld(true); + expectMatrixToBeCloseTo( + new THREE.Matrix4().multiplyMatrices( + renderCamera.projectionMatrix, + renderCamera.matrixWorldInverse + ), + sceneToClipMatrix + ); + }); + it("tracks MapLibre canvas resizes in Three's main framebuffer viewport", () => { const renderer = { setViewport: vi.fn(), diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts index d91f84ad4b..770e9417ca 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts @@ -39,6 +39,8 @@ export interface SharedThreeSceneLayer extends CustomLayerInterface { removeRuntime: (runtimeId: string) => void; hasRuntime: (runtimeId: string) => boolean; getScene: () => THREE.Scene; + /** Renderer owned by the mounted MapLibre custom layer, if it is active. */ + getRenderer: () => THREE.WebGLRenderer | null; projectLngLatToScene: ( lngLat: [number, number], altitudeMeters?: number, @@ -62,6 +64,43 @@ type RenderTargetDepthRangeBridge = { type SharedCanvasViewportRenderer = Pick; +/** + * Give the render camera the real local-scene pose while retaining MapLibre's + * exact scene-to-clip transform. + * + * MapLibre supplies the complete scene-to-clip matrix, whereas Three expects + * separate projection and view matrices. Compensating the projection by the + * camera world matrix keeps `projection * view` unchanged and makes Three's + * view-space shader inputs describe the synthesized map camera correctly. + */ +export const configureSharedRenderCamera = ( + renderCamera: THREE.PerspectiveCamera, + lodCamera: THREE.PerspectiveCamera, + sceneToClipMatrix: THREE.Matrix4 +): void => { + renderCamera.position.copy(lodCamera.position); + renderCamera.quaternion.copy(lodCamera.quaternion); + renderCamera.scale.copy(lodCamera.scale); + renderCamera.up.copy(lodCamera.up); + renderCamera.fov = lodCamera.fov; + renderCamera.aspect = lodCamera.aspect; + renderCamera.near = lodCamera.near; + renderCamera.far = lodCamera.far; + renderCamera.zoom = lodCamera.zoom; + renderCamera.focus = lodCamera.focus; + renderCamera.filmGauge = lodCamera.filmGauge; + renderCamera.filmOffset = lodCamera.filmOffset; + renderCamera.matrix.copy(lodCamera.matrix); + renderCamera.matrixWorld.copy(lodCamera.matrixWorld); + renderCamera.matrixWorldInverse.copy(lodCamera.matrixWorldInverse); + renderCamera.projectionMatrix + .copy(sceneToClipMatrix) + .multiply(renderCamera.matrixWorld); + renderCamera.projectionMatrixInverse + .copy(renderCamera.projectionMatrix) + .invert(); +}; + /** * Keep Three's main-framebuffer viewport in sync with the canvas MapLibre owns. * @@ -143,7 +182,7 @@ export const buildSharedThreeSceneLayer = ( scene.add( new THREE.AmbientLight(0xffffff, options.ambientLightIntensity ?? 2.4) ); - const renderCamera = new THREE.Camera(); + const renderCamera = new THREE.PerspectiveCamera(); const lodCamera = new THREE.PerspectiveCamera(); const viewport = new THREE.Vector2(1, 1); const lookTarget = new THREE.Vector3(); @@ -205,6 +244,10 @@ export const buildSharedThreeSceneLayer = ( return scene; }, + getRenderer() { + return renderer; + }, + projectLngLatToScene( lngLat, altitudeMeters = 0, @@ -242,7 +285,7 @@ export const buildSharedThreeSceneLayer = ( depthRangeBridge = installRenderTargetDepthRangeBridge(renderer, gl); renderer.autoClear = false; renderer.shadowMap.enabled = true; - renderer.shadowMap.type = THREE.PCFSoftShadowMap; + renderer.shadowMap.type = THREE.PCFShadowMap; for (const runtime of runtimes.values()) { if (runtime.root.parent !== scene) scene.add(runtime.root); placeRuntime(runtime); @@ -260,10 +303,7 @@ export const buildSharedThreeSceneLayer = ( .makeTranslation(originMerc.x, originMerc.y, originMerc.z) .scale(new THREE.Vector3(meterScale, -meterScale, meterScale)) .multiply(rotationX); - renderCamera.projectionMatrix = mainMatrix.multiply(localFromScene); - renderCamera.projectionMatrixInverse - .copy(renderCamera.projectionMatrix) - .invert(); + const sceneToClipMatrix = mainMatrix.multiply(localFromScene); syncSharedCanvasViewport(renderer, map.getCanvas(), viewport); // Same pose the MapLibre 3D Tiles layer works out for itself, so it @@ -291,6 +331,7 @@ export const buildSharedThreeSceneLayer = ( ) { return; } + configureSharedRenderCamera(renderCamera, lodCamera, sceneToClipMatrix); const frame: SharedThreeSceneFrame = { map, diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts index 3888c2ee2a..14365555ef 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.spec.ts @@ -16,6 +16,7 @@ describe("shared Three.js scene registry", () => { addRuntime: vi.fn(), removeRuntime: vi.fn(), getScene: vi.fn(), + getRenderer: vi.fn(), dispose, }; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts index d6406b2fc3..e697298762 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-registry.ts @@ -36,7 +36,8 @@ const isSharedThreeSceneLayer = ( candidate.id === SHARED_SCENE_LAYER_ID && typeof candidate.addRuntime === "function" && typeof candidate.removeRuntime === "function" && - typeof candidate.getScene === "function" + typeof candidate.getScene === "function" && + typeof candidate.getRenderer === "function" ); }; diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts index 7cbd74af18..0256eb45db 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.spec.ts @@ -1,11 +1,15 @@ -import { OrthographicCamera, type Matrix4, Vector3 } from "three"; +import { OrthographicCamera, Quaternion, type Matrix4, Vector3 } from "three"; import { describe, expect, it } from "vitest"; import { buildOrthographicScale, CAMERA_TYPE, } from "@carma-commons/camera/model"; -import { buildViewState } from "@carma-mapping/engines-interop/view-state"; +import { + buildViewState, + buildViewStateFromEcef, + deriveOrbitAngles, +} from "@carma-mapping/engines-interop/view-state"; import type { ViewState } from "@carma-mapping/engines-interop/view-state"; import { degToRadNumeric } from "@carma-units"; @@ -44,7 +48,10 @@ const rotateAroundUp = (point: Vector3, angleRad: number): Vector3 => point.clone().applyAxisAngle(WORLD_UP, angleRad); const expectVectorClose = (actual: Vector3, expected: Vector3) => { - expect(actual.distanceTo(expected)).toBeLessThan(1e-8); + expect( + actual.distanceTo(expected), + `${actual.toArray().join(",")} != ${expected.toArray().join(",")}` + ).toBeLessThan(1e-8); }; const buildOptionalFrustum = ({ @@ -154,12 +161,16 @@ const buildOrthographicViewState = ({ }, }); -const buildGeometry = (viewState: ViewState) => +const buildGeometry = ( + viewState: ViewState, + { useCameraPosition = false }: { useCameraPosition?: boolean } = {} +) => buildImagePlaneGeometry({ viewState, visualized: { maxPitch: null, imagePlaneDistance: null, + useCameraPosition, }, hemisphereRadius: SPEC_HEMISPHERE_RADIUS, imagePlaneDefaults: SPEC_GEOMETRY_DEFAULTS, @@ -170,6 +181,40 @@ const buildProjectionPolygon = (viewState: ViewState) => buildGeometry(viewState).projectionPlanePolygon; describe("camera-view-geometry ground projection", () => { + it("uses the stored camera position independently of its orientation", () => { + const base = buildPerspectiveViewState({ bearingDeg: 0, pitchDeg: 0 }); + const { longitude, latitude } = base.anchorCartographic; + const east = new Vector3(-Math.sin(longitude), Math.cos(longitude), 0); + const north = new Vector3( + -Math.sin(latitude) * Math.cos(longitude), + -Math.sin(latitude) * Math.sin(longitude), + Math.cos(latitude) + ); + const localUp = new Vector3( + Math.cos(latitude) * Math.cos(longitude), + Math.cos(latitude) * Math.sin(longitude), + Math.sin(latitude) + ); + const cameraPosition = base.anchor + .clone() + .addScaledVector(east, 3) + .addScaledVector(north, 4) + .addScaledVector(localUp, 12); + const viewState = buildViewStateFromEcef({ + anchor: base.anchor, + cameraPosition, + orientation: new Quaternion(), + intrinsics: base.intrinsics, + metadata: base.metadata, + }); + + const geometry = buildGeometry(viewState, { useCameraPosition: true }); + expectVectorClose( + geometry.cameraPosition, + new Vector3(3, 12, -4).normalize() + ); + }); + it("derives the visible camera form in a local bearing-zero frame before rotating it back out", () => { const baseGeometry = buildGeometry( buildPerspectiveViewState({ @@ -177,34 +222,33 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, }) ); - const bearingRad = degToRadNumeric(62); - const rotatedGeometry = buildGeometry( - buildPerspectiveViewState({ - bearingDeg: 62, - pitchDeg: 63, - }) - ); + const rotatedViewState = buildPerspectiveViewState({ + bearingDeg: 62, + pitchDeg: 63, + }); + const bearingRad = deriveOrbitAngles(rotatedViewState).bearing; + const rotatedGeometry = buildGeometry(rotatedViewState); expectVectorClose( - rotateAroundUp(rotatedGeometry.cameraPosition, -bearingRad), + rotateAroundUp(rotatedGeometry.cameraPosition, bearingRad), baseGeometry.cameraPosition ); expectVectorClose( - rotateAroundUp(rotatedGeometry.forward, -bearingRad), + rotateAroundUp(rotatedGeometry.forward, bearingRad), baseGeometry.forward ); expectVectorClose( - rotateAroundUp(rotatedGeometry.right, -bearingRad), + rotateAroundUp(rotatedGeometry.right, bearingRad), baseGeometry.right ); expectVectorClose( - rotateAroundUp(rotatedGeometry.up, -bearingRad), + rotateAroundUp(rotatedGeometry.up, bearingRad), baseGeometry.up ); baseGeometry.imagePlaneCorners.forEach((corner, index) => { expectVectorClose( - rotateAroundUp(rotatedGeometry.imagePlaneCorners[index]!, -bearingRad), + rotateAroundUp(rotatedGeometry.imagePlaneCorners[index]!, bearingRad), corner ); }); @@ -360,7 +404,7 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, rangeM: 500, nearM: 50, - farM: 250, + farM: 1_000, }) ); const polygonLongRange = buildProjectionPolygon( @@ -369,7 +413,7 @@ describe("camera-view-geometry ground projection", () => { pitchDeg: 63, rangeM: 1000, nearM: 100, - farM: 500, + farM: 2_000, }) ); diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts index 8660ee318d..b667098006 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/derived/camera-view-geometry.ts @@ -6,10 +6,6 @@ import { readMetersPerCssPixelFromIntrinsics, readLocalCameraBasis, } from "@carma-commons/camera/model"; -import { - deriveOrbitAngles, - type ViewState, -} from "@carma-mapping/engines-interop/view-state"; import { clamp, intersectRayWithPlane, @@ -18,6 +14,10 @@ import { PI_OVER_THREE, PI_OVER_TWO, } from "@carma-commons/math"; +import { + deriveOrbitAngles, + type ViewState, +} from "@carma-mapping/engines-interop/view-state"; import { zeroToTwoPi } from "@carma-units"; import type { Radians } from "@carma-units"; @@ -527,16 +527,42 @@ export const computeUnitHemisphereCameraPosition = ({ const resolveCameraBasis = ({ viewState, hemisphereRadius, + useCameraPosition, }: { viewState: ViewState; hemisphereRadius: number; + useCameraPosition: boolean; }) => { const { bearing, pitch } = deriveOrbitAngles(viewState); - const cameraPosition = viewingBearingPitchToCameraSpherePosition({ - viewingBearing: bearing, - pitch, - hemisphereRadius, - }); + const exactCameraPosition = (() => { + if (!useCameraPosition) return null; + const { longitude, latitude } = viewState.anchorCartographic; + const delta = viewState.cameraPosition.clone().sub(viewState.anchor); + const sinLongitude = Math.sin(longitude); + const cosLongitude = Math.cos(longitude); + const sinLatitude = Math.sin(latitude); + const cosLatitude = Math.cos(latitude); + const east = new Vector3(-sinLongitude, cosLongitude, 0); + const north = new Vector3( + -sinLatitude * cosLongitude, + -sinLatitude * sinLongitude, + cosLatitude + ); + const localUp = new Vector3( + cosLatitude * cosLongitude, + cosLatitude * sinLongitude, + sinLatitude + ); + return new Vector3(delta.dot(east), delta.dot(localUp), -delta.dot(north)); + })(); + const cameraPosition = + exactCameraPosition && exactCameraPosition.lengthSq() > Number.EPSILON + ? exactCameraPosition.normalize().multiplyScalar(hemisphereRadius) + : viewingBearingPitchToCameraSpherePosition({ + viewingBearing: bearing, + pitch, + hemisphereRadius, + }); const { forward, right, up } = readLocalCameraBasis(viewState.orientation); return { @@ -1123,6 +1149,7 @@ export const buildImagePlaneGeometry = ({ const { bearing, cameraPosition, forward, right, up } = resolveCameraBasis({ viewState, hemisphereRadius, + useCameraPosition: visualized.useCameraPosition, }); const inverseBearingRotation = CAMERA_GEOMETRY_SCRATCH.inverseBearingRotation.setFromAxisAngle( diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts index b9aac58824..a3be3bc8c5 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-defaults.ts @@ -35,6 +35,7 @@ export const DEFAULT_VIEW_STATE_VISUALIZER_INTERACTIVE = false; export const DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS = Object.freeze({ maxPitch: null, imagePlaneDistance: null, + useCameraPosition: false, }) satisfies Readonly; export const DEFAULT_VIEW_STATE_VISUALIZER_DISPLAY_OPTIONS = Object.freeze({ @@ -142,6 +143,9 @@ export const mergeViewStateVisualizerVisualizedOptions = ( Number.isFinite(merged.imagePlaneDistance) ? merged.imagePlaneDistance : DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS.imagePlaneDistance, + useCameraPosition: + merged.useCameraPosition ?? + DEFAULT_VIEW_STATE_VISUALIZER_VISUALIZED_OPTIONS.useCameraPosition, }; }; diff --git a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts index 2eadd862dc..2b902da88f 100644 --- a/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts +++ b/libraries/mapping/engines/three/primitives/src/lib/visualizers/view-state/view-state-visualizer-types.ts @@ -41,6 +41,8 @@ export type ViewStateVisualizerOverviewOptions = { export type ViewStateVisualizerVisualizedOptions = { maxPitch?: Radians; imagePlaneDistance?: number; + /** Place the marker from the stored ECEF camera position, not orbit angles. */ + useCameraPosition?: boolean; }; export type ViewStateVisualizerSurfaceDisplayOptions = { @@ -140,6 +142,7 @@ export type ResolvedViewStateVisualizerOverviewOptions = { export type ResolvedViewStateVisualizerVisualizedOptions = { maxPitch: Radians | null; imagePlaneDistance: number | null; + useCameraPosition: boolean; }; export type ResolvedViewStateVisualizerSurfaceDisplayOptions = { diff --git a/package-lock.json b/package-lock.json index 3e58fdf670..f2efcf2a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,10 +31,13 @@ "@motis-project/motis-client": "^2.7.6", "@react-hook/window-size": "^3.1.1", "@react-pdf/renderer": "^3.4.4", + "@react-three/fiber": "8.17.10", "@reduxjs/toolkit": "^2.2.7", "@rehooks/component-size": "^1.0.3", "@rehooks/online-status": "^1.1.2", "@sindresorhus/slugify": "^2.2.1", + "@takram/three-atmosphere": "0.10.3", + "@takram/three-geospatial": "0.1.0", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.51.15", "@tanstack/react-query-persist-client": "^5.101.2", @@ -77,6 +80,7 @@ "match-sorter": "^6.3.4", "md5": "^2.3.0", "postcss": "8.4.40", + "postprocessing": "6.39.4", "prismjs": "^1.29.0", "proj4": "^2.19.4", "rbush": "^4.0.1", @@ -14684,6 +14688,89 @@ "integrity": "sha512-XsVRkt0hQ60I4e3leAVt+aZR3KJCaJd179BfJHAv4F4x6Vq3yqkry8lcbUWKGKDw1j3/8sW4FsgGR41SFvsG9A==", "license": "MIT" }, + "node_modules/@react-three/fiber": { + "version": "8.17.10", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.17.10.tgz", + "integrity": "sha512-S6bqa4DqUooEkInYv/W+Jklv2zjSYCXAhm6qKpAQyOXhTEt5gBXnA7W6aoJ0bjmp9pAeaSj/AZUoz1HCSof/uA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/debounce": "^1.2.1", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "debounce": "^1.2.1", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18.0", + "react-dom": ">=18.0", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@reactflow/background": { "version": "11.3.14", "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", @@ -17988,6 +18075,81 @@ "node": ">=14.16" } }, + "node_modules/@takram/three-atmosphere": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@takram/three-atmosphere/-/three-atmosphere-0.10.3.tgz", + "integrity": "sha512-j1MuQvhdYsDef56yhNybAEopsb8K1Vn2e+NIafBlflEbBlFyCN5V1K8fNYVm48CPcERT5JGKbVW5RHxI0FAPhQ==", + "license": "MIT", + "dependencies": { + "@takram/three-geospatial": "0.1.0", + "astronomy-engine": "^2.1.19", + "react-merge-refs": "^2.1.1", + "three-stdlib": "2.35.14", + "tiny-invariant": "^1.3.3", + "url-join": "^5.0.0" + }, + "peerDependencies": { + "@react-three/drei": ">=9.117.3", + "@react-three/fiber": ">=8.17.10", + "@react-three/postprocessing": ">=2.16.3", + "postprocessing": ">=6.36.4", + "react": ">=18.0", + "three": ">=0.170.0" + }, + "peerDependenciesMeta": { + "@react-three/drei": { + "optional": true + }, + "@react-three/fiber": { + "optional": true + }, + "@react-three/postprocessing": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@takram/three-atmosphere/node_modules/url-join": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-5.0.0.tgz", + "integrity": "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/@takram/three-geospatial": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@takram/three-geospatial/-/three-geospatial-0.1.0.tgz", + "integrity": "sha512-u8eS2DddXX7b+scSe0tXwvI20AEF7lTXeVcq7csfHHhzhbInAEdbKhdBP1p9B6VlsGGMc2F1mZ0rc8ILau5FRg==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.1", + "react-merge-refs": "^2.1.1", + "three-stdlib": "2.35.14", + "tiny-invariant": "^1.3.3", + "type-fest": "^4.33.0" + }, + "peerDependencies": { + "@react-three/fiber": ">=8.17.10", + "react": ">=18.0", + "three": ">=0.170.0" + } + }, + "node_modules/@takram/three-geospatial/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@tanstack/query-async-storage-persister": { "version": "5.101.2", "resolved": "https://registry.npmjs.org/@tanstack/query-async-storage-persister/-/query-async-storage-persister-5.101.2.tgz", @@ -27099,6 +27261,18 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/debounce": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@types/debounce/-/debounce-1.2.4.tgz", + "integrity": "sha512-jBqiORIzKDOToaF63Fm//haOCHuwQuLa2202RK4MozpA6lh93eCBc+/8+wZn5OzjJt3ySdc+74SXWXB55Ewtyw==", + "license": "MIT" + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "8.56.10", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.10.tgz", @@ -27361,6 +27535,12 @@ "@types/node": "*" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", @@ -27425,6 +27605,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -27639,7 +27828,6 @@ "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "dev": true, "license": "MIT" }, "node_modules/@types/whatwg-url": { @@ -29660,6 +29848,12 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "license": "MIT" }, + "node_modules/astronomy-engine": { + "version": "2.1.19", + "resolved": "https://registry.npmjs.org/astronomy-engine/-/astronomy-engine-2.1.19.tgz", + "integrity": "sha512-8yWKNf7UeNbH458h3sAJ6ZgAjE5jTXp/mNNRFoC20j2SHwZIjAQeEsBB2Q3uCFRaTCCJRv33K2XhkhZQMXoX6w==", + "license": "MIT" + }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -34877,6 +35071,12 @@ "dev": true, "license": "MIT" }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -41923,6 +42123,27 @@ "node": ">= 0.4" } }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -50406,6 +50627,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/postprocessing": { + "version": "6.39.4", + "resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz", + "integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==", + "license": "Zlib", + "peerDependencies": { + "three": ">= 0.168.0 < 0.186.0" + } + }, "node_modules/potpack": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", @@ -52860,6 +53090,16 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/react-merge-refs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/react-merge-refs/-/react-merge-refs-2.1.1.tgz", + "integrity": "sha512-jLQXJ/URln51zskhgppGJ2ub7b2WFKGq3cl3NYKtlHoTG+dN2q7EzWrn3hN3EgPsTMvpR9tpq5ijdp7YwFZkag==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, "node_modules/react-modal": { "version": "3.16.1", "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", @@ -52949,6 +53189,31 @@ "integrity": "sha512-kzmNjIgU32mO4mmH5+iUyrqlpFQhF8K2k7eZ4fdLSOPFrD1XgEuSBv9LDEgxRXTMBqMd8ppT0x6TIzqE5pdGdw==", "license": "MIT" }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-redux": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", @@ -56622,6 +56887,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/svg-arc-to-cubic-bezier": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/svg-arc-to-cubic-bezier/-/svg-arc-to-cubic-bezier-3.2.0.tgz", @@ -57191,6 +57465,35 @@ "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", "license": "MIT" }, + "node_modules/three-stdlib": { + "version": "2.35.14", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.35.14.tgz", + "integrity": "sha512-kpCaEg59M9usFTgHC+YZNKvx7nMoLI2zQxZBV8pjoNW6vNZmGyXpaLBL09A2oLCsS3KepgMFkOuk6lRoebTNvA==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, + "node_modules/three-stdlib/node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", diff --git a/package.json b/package.json index a4a693d8b3..857eec7c61 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,13 @@ "@motis-project/motis-client": "^2.7.6", "@react-hook/window-size": "^3.1.1", "@react-pdf/renderer": "^3.4.4", + "@react-three/fiber": "8.17.10", "@reduxjs/toolkit": "^2.2.7", "@rehooks/component-size": "^1.0.3", "@rehooks/online-status": "^1.1.2", "@sindresorhus/slugify": "^2.2.1", + "@takram/three-atmosphere": "0.10.3", + "@takram/three-geospatial": "0.1.0", "@tanstack/query-async-storage-persister": "^5.101.2", "@tanstack/react-query": "^5.51.15", "@tanstack/react-query-persist-client": "^5.101.2", @@ -78,6 +81,7 @@ "match-sorter": "^6.3.4", "md5": "^2.3.0", "postcss": "8.4.40", + "postprocessing": "6.39.4", "prismjs": "^1.29.0", "proj4": "^2.19.4", "rbush": "^4.0.1", From 2542b8749e4a102d419f70a2db49ea05a8a14a5f Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 01:12:27 +0200 Subject: [PATCH 12/78] perf(geoportal): keep the shadow simulation off the per-frame hot path The frame cost had crept from ~16 ms to over 500 ms while the camera stood still. Four causes, all fixed: - The terrain LoD selection ran on every rendered frame, and each run cost hundreds of milliseconds: the orthographic shadow screen-space error recomputed a full matrix chain per candidate tile, including an updateMatrixWorld over every loaded terrain mesh. The pixels-per-meter term is tile-independent and is now computed once per selection, the refinement loop uses a binary heap instead of re-sorting per split, and the whole selection only runs when a quantized camera/shadow-fit signature changes. It is frozen entirely while a gesture is in flight and re-selects on moveend. - The fitted shadow cameras were handed to the terrain on every dirty update, and the terrain fed elevations back into the next fit, so the two re-triggered each other; the hand-over now waits for the camera to rest and is quantized to a couple of metres so it converges. - Every move event re-sampled the atmosphere and rebuilt the sun gizmo geometry; both now happen on moveend and time changes only, while a pan just carries the anchor along. Content changes coming from streaming tiles are debounced, materials re-sync only after content changes, and the runtime no longer schedules an extra repaint from inside its own render frame. - Projection debug snapshots published (and re-rendered the debug panel) even while nothing listened; publishing is now gated on subscribers and throttled to 10 Hz during gestures. While a camera gesture is in flight the shadow buffer drops to half resolution and the moveend update restores it. The shadow terrain's shadowLevelOffset returns to 3: offset 0 demands full-resolution terrain across the whole shadow frustum and was the single biggest regression against the fast state. The camera restriction addon now allows tilting to 5 degrees above the horizon (maxPitch 85) whenever it decides the camera is free, and the shadow scene waits for the map style before building, so enabling the simulation from a URL or persisted state no longer crashes the app on a slow style load. Dev builds expose the map and per-phase frame timings for console profiling. --- .../src/app/constants/fachzwillinge/addons.ts | 5 +- .../addons/src/addons/CameraRestriction.tsx | 14 +- .../src/addons/ShadowSimulation/index.tsx | 55 +++-- .../shadow-projection-debug-store.ts | 4 + .../ShadowSimulation/shadow-scene.spec.ts | 9 +- .../addons/ShadowSimulation/shadow-scene.ts | 111 +++++++-- .../tiled-shadow-controller.ts | 12 +- .../maplibre/src/components/LibreMap.tsx | 4 +- .../cesium-terrain-tile-runtime.ts | 227 ++++++++++++++++-- .../integrations/shared-three-scene-layer.ts | 27 ++- 10 files changed, 399 insertions(+), 69 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts index c80b4996de..32a349deca 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts @@ -69,7 +69,10 @@ export const addonsFachzwilling: FachzwillingRoute = { terrain: { url: WUPP_TERRAIN_PROVIDER.url, errorTargetPixels: 0.5, - shadowLevelOffset: 0, + // 0 asks for full-resolution terrain across the entire shadow + // frustum, which explodes the tile selection (hundreds of ms per + // frame). 3 is the value the fast state used. + shadowLevelOffset: 3, minimumLevel: 15, maximumLevel: 17, noDataHeightMeters: 0, diff --git a/libraries/mapping/addons/src/addons/CameraRestriction.tsx b/libraries/mapping/addons/src/addons/CameraRestriction.tsx index 73ba4a46e9..56d7293c93 100644 --- a/libraries/mapping/addons/src/addons/CameraRestriction.tsx +++ b/libraries/mapping/addons/src/addons/CameraRestriction.tsx @@ -2,10 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import type { Map as MaplibreMap } from "maplibre-gl"; import type { carma as carmaApi } from "@carma-api"; -import { - DEFAULT_MAX_PITCH, - setCameraRestrictionOverride, -} from "@carma-mapping/engines/maplibre"; +import { setCameraRestrictionOverride } from "@carma-mapping/engines/maplibre"; import type { AddonComponentProps } from "../lib/registry"; import { use3dLayers } from "../lib/use3dLayers"; @@ -120,6 +117,13 @@ const useZoom = (map: MaplibreMap | null, enabled: boolean): number | null => { return zoom; }; +/** + * How far the addon lets the camera tilt once it decides the view is free: + * 5 degrees above the horizon. MapLibre's stock cap of 60 stays the base for + * maps without this addon; a config `maxPitch` still overrides. + */ +const ADDON_UNRESTRICTED_MAX_PITCH = 85; + export const CameraRestriction = ({ carma, config, @@ -131,7 +135,7 @@ export const CameraRestriction = ({ requireVisible = true, restrictBelowZoom, restrictAboveZoom, - maxPitch = DEFAULT_MAX_PITCH, + maxPitch = ADDON_UNRESTRICTED_MAX_PITCH, } = config ?? {}; const usesLayers = diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index 02124a3870..911680190f 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -747,6 +747,10 @@ const ShadowSimulationRuntime = ({ state: ShadowSimulationState; }) => { const shadowScene = useRef(null); + // Bumped when the scene is (re)built. The build can be deferred until the + // map's style has loaded, and by then the update effects below have already + // run against a null ref; the revision runs them again for the new scene. + const [sceneRevision, setSceneRevision] = useState(0); const solarPosition = useMemo( () => getSolarPosition(state.selection, location), [location, state.selection] @@ -754,54 +758,76 @@ const ShadowSimulationRuntime = ({ useEffect(() => { if (!libreMap || !state.enabled) return; - const scene = buildShadowSimulationScene(libreMap, { - shadowAreaMeters, - terrain, - }); - shadowScene.current = scene; + // The simulation can be switched on before the map's style has loaded + // (URL param or persisted state hydrating at startup). `map.getLight()` + // throws until the style's light exists, so wait for the style instead of + // crashing the tree, and build the moment it is there. + let scene: ShadowSimulationScene | null = null; + const styleReady = () => + (libreMap as unknown as { style?: { light?: unknown } | null }).style + ?.light != null; + const tryBuild = () => { + if (scene || !styleReady()) return; + libreMap.off("styledata", tryBuild); + console.debug("[SHADOW] building shadow scene"); + scene = buildShadowSimulationScene(libreMap, { + shadowAreaMeters, + terrain, + }); + shadowScene.current = scene; + setSceneRevision((revision) => revision + 1); + }; + tryBuild(); + if (!scene) { + console.debug("[SHADOW] style not ready, waiting for styledata"); + libreMap.on("styledata", tryBuild); + } return () => { + libreMap.off("styledata", tryBuild); shadowScene.current = null; - scene.dispose(); + if (scene) console.debug("[SHADOW] disposing shadow scene"); + scene?.dispose(); + scene = null; }; }, [libreMap, shadowAreaMeters, state.enabled, terrain]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateSolarPosition(solarPosition); - }, [solarPosition, state.enabled]); + }, [solarPosition, state.enabled, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowQuality( state.shadowQuality ?? DEFAULT_SHADOW_QUALITY ); - }, [state.enabled, state.shadowQuality]); + }, [state.enabled, state.shadowQuality, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowMode( state.shadowMode ?? DEFAULT_SHADOW_MODE ); - }, [state.enabled, state.shadowMode]); + }, [state.enabled, state.shadowMode, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 0.45); - }, [state.enabled, state.shadowIntensity]); + }, [state.enabled, state.shadowIntensity, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateSunDebugVectorVisibility( state.showSunDebugVector ?? false ); - }, [state.enabled, state.showSunDebugVector]); + }, [state.enabled, state.showSunDebugVector, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowBufferDebugVisibility( state.showShadowBuffers ?? false ); - }, [state.enabled, state.showShadowBuffers]); + }, [state.enabled, state.showShadowBuffers, sceneRevision]); useEffect(() => { if (!state.enabled) return; @@ -809,14 +835,14 @@ const ShadowSimulationRuntime = ({ useTransmittanceLut: state.useTransmittanceLut ?? true, useIrradianceLut: state.useSkyIrradianceLut ?? true, }); - }, [state.enabled, state.useSkyIrradianceLut, state.useTransmittanceLut]); + }, [state.enabled, state.useSkyIrradianceLut, state.useTransmittanceLut, sceneRevision]); useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateTerrainColor( state.terrainColor ?? DEFAULT_SHADOW_SURFACE_COLOR ); - }, [state.enabled, state.terrainColor]); + }, [state.enabled, state.terrainColor, sceneRevision]); useEffect(() => { if (!state.enabled) return; @@ -832,6 +858,7 @@ const ShadowSimulationRuntime = ({ state.buildingsFullOpacity, state.enabled, state.useUniformBuildingColor, + sceneRevision, ]); return null; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts index f86aa6b153..b09ac9bf3f 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-projection-debug-store.ts @@ -57,6 +57,10 @@ export const subscribeShadowProjectionDebugSnapshot = ( }; }; +/** Whether anything is listening; publishing without a reader is waste. */ +export const hasShadowProjectionDebugListeners = (map: MaplibreMap): boolean => + (entries.get(map)?.listeners.size ?? 0) > 0; + export const publishShadowProjectionDebugSnapshot = ( map: MaplibreMap, snapshot: ShadowProjectionDebugSnapshot diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts index 5dafa51e94..3aa12d87b4 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -30,7 +30,10 @@ import { solarPositionToSceneDirection, } from "./shadow-scene"; import { evaluateAtmosphericSunlight } from "./atmospheric-sunlight"; -import { readShadowProjectionDebugSnapshot } from "./shadow-projection-debug-store"; +import { + readShadowProjectionDebugSnapshot, + subscribeShadowProjectionDebugSnapshot, +} from "./shadow-projection-debug-store"; import { getDaylightWindow, getSolarPosition } from "./solar-position"; describe("shadow scene sun direction", () => { @@ -506,6 +509,8 @@ describe("shadow scene lighting integration", () => { }; const controller = buildShadowSimulationScene(map as never); + // Snapshots publish only while something listens, as the debug panel does. + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); controller.updateSolarPosition({ instant: new Date("2026-06-21T10:00:00Z"), azimuthDegrees: 135, @@ -673,6 +678,8 @@ describe("shadow scene lighting integration", () => { }; const controller = buildShadowSimulationScene(map as never); + // Snapshots publish only while something listens, as the debug panel does. + subscribeShadowProjectionDebugSnapshot(map as never, () => undefined); controller.updateSolarPosition({ instant: new Date("2026-06-21T10:00:00Z"), azimuthDegrees: 135, diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index 6b7dae5d94..f633be5e80 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -26,6 +26,7 @@ import { import { TiledShadowController } from "./tiled-shadow-controller"; import { clearShadowProjectionDebugSnapshot, + hasShadowProjectionDebugListeners, publishShadowProjectionDebugSnapshot, } from "./shadow-projection-debug-store"; @@ -850,8 +851,15 @@ export const buildShadowSimulationScene = ( }; const genericBridges = new Map(); let cachedElevationRange: readonly [number, number] | null = null; + // Whether a camera gesture is in flight. While it is, the shadow buffer is + // rendered at half resolution so the pan stays fluid; moveend restores it. + let mapInMotion = false; + // Set when meshes or materials may have appeared or vanished; the material + // sync walks the whole scene, which is not per-frame work. + let sceneMaterialsDirty = true; + let lastDebugPublishMs = 0; - const updateSharedShadowCoverage = () => { + const updateSharedShadowCoverage = (reevaluateSun = true) => { const mapCenter = map.getCenter(); const centerElevation = terrainRuntime?.getElevation(mapCenter.lng, mapCenter.lat) ?? 0; @@ -950,8 +958,20 @@ export const buildShadowSimulationScene = ( sharedBinding.minimumElevationMeters = minimumElevation; sharedBinding.maximumElevationMeters = maximumElevation; sharedBinding.dirty = true; - if (latestSolarPosition) { + // Re-sampling the atmosphere and rebuilding the sun gizmo is only due + // when the sun itself may have changed: a new time, or a viewport that + // came to rest somewhere else. A frame-by-frame pan reuses the sample and + // only carries the anchor along, which is a handful of vector copies. + if (latestSolarPosition && (reevaluateSun || !latestAtmosphericSunlight)) { evaluateAtmosphericSunlightForMap(latestSolarPosition); + } else { + sharedBinding.lightTarget.position.copy(sharedBinding.center); + for (const sunLight of sharedBinding.controller.lights) { + sunLight.target.position.copy(sharedBinding.center); + sunLight.target.updateMatrixWorld(true); + } + sharedBinding.sunVector.root.position.copy(sharedBinding.center); + sharedBinding.sunVector.root.updateMatrixWorld(true); } map.triggerRepaint(); }; @@ -971,7 +991,10 @@ export const buildShadowSimulationScene = ( terrainRuntime?.setShadowCameras([]); return; } - sharedBinding.controller.syncSceneMaterials(sharedBinding.scene); + if (sceneMaterialsDirty) { + sceneMaterialsDirty = false; + sharedBinding.controller.syncSceneMaterials(sharedBinding.scene); + } const snapshot = sharedBinding.controller.update({ camera: frame.lodCamera, receiverWorldPoints: sharedBinding.receiverWorldPoints, @@ -982,6 +1005,7 @@ export const buildShadowSimulationScene = ( intensity: sharedBinding.sunIntensity, shadowIntensity: sharedBinding.shadowIntensity, quality: sharedBinding.shadowQuality, + interactive: mapInMotion, }); sharedBinding.dirty = false; if (!snapshot) { @@ -993,7 +1017,17 @@ export const buildShadowSimulationScene = ( updateShadowBufferBorders(sharedBinding, snapshot.tileCount); const primary = snapshot?.tiles[0]; const primaryCamera = sharedBinding.controller.lights[0].shadow.camera; - if (primary) { + // Publishing re-renders the debug panel's React tree; while nothing + // subscribes the snapshot has no reader and building it is pure + // overhead, and during a gesture 10 Hz is plenty for numbers meant for + // a human. + const nowMs = performance.now(); + const publishDue = !mapInMotion || nowMs - lastDebugPublishMs >= 100; + const publishWanted = + sharedBinding.projectionDebugVisible || + hasShadowProjectionDebugListeners(map); + if (primary && publishWanted && publishDue) { + lastDebugPublishMs = nowMs; publishShadowProjectionDebugSnapshot(map, { cameraRangeMeters: primaryCamera.position.distanceTo( sharedBinding.controller.lights[0].target.position @@ -1025,18 +1059,22 @@ export const buildShadowSimulationScene = ( : null, }); } - terrainRuntime?.setShadowCameras( - sharedBinding.controller.lights - .slice(0, snapshot.tileCount) - .map(({ shadow }) => ({ - camera: shadow.camera, - shadowMapSize: { - width: shadow.mapSize.x, - height: shadow.mapSize.y, - }, - })) - ); - map.triggerRepaint(); + // While a gesture is in flight the fit follows the viewport frame by + // frame, and every hand-over would re-trigger the terrain's shadow + // refinement. The moveend refresh hands over the settled fit. + if (!mapInMotion) { + terrainRuntime?.setShadowCameras( + sharedBinding.controller.lights + .slice(0, snapshot.tileCount) + .map(({ shadow }) => ({ + camera: shadow.camera, + shadowMapSize: { + width: shadow.mapSize.x, + height: shadow.mapSize.y, + }, + })) + ); + } }, dispose: () => undefined, }; @@ -1051,9 +1089,20 @@ export const buildShadowSimulationScene = ( refreshSharedShadowCoverage(); }; - map.on("move", updateSharedShadowCoverage); - map.on("moveend", refreshSharedShadowCoverage); - map.on("resize", updateSharedShadowCoverage); + const handleMoveStart = () => { + mapInMotion = true; + terrainRuntime?.setInteractive(true); + }; + const handleMove = () => updateSharedShadowCoverage(false); + const handleMoveEnd = () => { + mapInMotion = false; + terrainRuntime?.setInteractive(false); + refreshSharedShadowCoverage(); + }; + map.on("movestart", handleMoveStart); + map.on("move", handleMove); + map.on("moveend", handleMoveEnd); + map.on("resize", handleMove); updateSharedShadowCoverage(); if (terrainRuntime) { @@ -1110,13 +1159,26 @@ export const buildShadowSimulationScene = ( sceneLease.layer.getScene(), sharedBinding.controller ); + sceneMaterialsDirty = true; sharedBinding.controller.invalidate(); sharedBinding.dirty = true; refreshSharedShadowCoverage(); }; + // Tiles stream in one model at a time and every arrival announces itself. + // The handler walks the whole scene and refits the shadow coverage, so it + // runs once per lull rather than once per tile. + let contentChangeTimer = 0; + const scheduleSharedSceneContentChanged = () => { + if (disposed) return; + if (contentChangeTimer) window.clearTimeout(contentChangeTimer); + contentChangeTimer = window.setTimeout(() => { + contentChangeTimer = 0; + handleSharedSceneContentChanged(); + }, 120); + }; const unsubscribeSharedSceneContent = subscribeSharedThreeSceneContent( map, - handleSharedSceneContentChanged + scheduleSharedSceneContentChanged ); handleSharedSceneContentChanged(); @@ -1193,6 +1255,7 @@ export const buildShadowSimulationScene = ( }, updateShadowBufferDebugVisibility(visible) { sharedBinding.projectionDebugVisible = visible; + if (visible) sharedBinding.dirty = true; updateShadowBufferBorders(sharedBinding); map.triggerRepaint(); }, @@ -1208,12 +1271,14 @@ export const buildShadowSimulationScene = ( dispose() { if (disposed) return; disposed = true; + if (contentChangeTimer) window.clearTimeout(contentChangeTimer); clearShadowProjectionDebugSnapshot(map); map.off("style.load", restoreLighting); map.off("styledata", ensureShadowBackground); - map.off("move", updateSharedShadowCoverage); - map.off("moveend", refreshSharedShadowCoverage); - map.off("resize", updateSharedShadowCoverage); + map.off("movestart", handleMoveStart); + map.off("move", handleMove); + map.off("moveend", handleMoveEnd); + map.off("resize", handleMove); unsubscribeGenericLayers(); unsubscribeSharedSceneContent(); for (const runtime of getSharedThreeSceneRuntimes(map)) { diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts index aa3f4c8c59..1bce036607 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -69,6 +69,12 @@ export type TiledShadowUpdate = Readonly<{ intensity: number; shadowIntensity: number; quality: ShadowQualityMultiplier; + /** + * True while a camera gesture is in flight. The shadow buffer then drops to + * half resolution, which quarters the texels the depth pass writes per + * frame; the moveend update restores the full-quality buffer. + */ + interactive?: boolean; }>; type MaterialState = Readonly<{ @@ -433,6 +439,7 @@ export class TiledShadowController { intensity, shadowIntensity, quality, + interactive = false, }: TiledShadowUpdate): TiledShadowSnapshot | null { if (this.disposed) return null; if (receiverWorldPoints.length === 0) { @@ -468,10 +475,11 @@ export class TiledShadowController { ); const lightMargin = casterReachMeters + reliefMeters + LIGHT_CAMERA_SAFETY_METERS; + const motionScale = interactive ? 0.5 : 1; const mapSize = - this.mode === "single" + (this.mode === "single" ? BASE_SINGLE_SHADOW_MAP_SIZE * Math.sqrt(quality) - : getShadowTileMapSize(quality); + : getShadowTileMapSize(quality)) * motionScale; this.csm.camera = camera; this.csm.lightMargin = lightMargin; diff --git a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx index 0bd811aaf5..7f88d98d1d 100644 --- a/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx +++ b/libraries/mapping/engines/maplibre/src/components/LibreMap.tsx @@ -943,7 +943,9 @@ export const LibreMap = ({ publishMapThreeRuntimeParams(mapInstance, threeRuntimeParams); setLibreMap?.(mapInstance); setContextMap(mapInstance); - if (exposeMapToWindow) { + if (exposeMapToWindow || import.meta.env?.DEV) { + // Always exposed in dev builds: the perf and shadow debugging flows + // drive the map from the console. (window as unknown as Record).__carmaMap = mapInstance; } diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index 9230b62d72..de97b8946c 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -80,6 +80,11 @@ export type CesiumTerrainShadowView = Readonly<{ export interface CesiumTerrainRuntime extends SharedThreeSceneRuntime { ready: Promise; setVisible: (visible: boolean) => void; + /** + * Freeze tile selection while a camera gesture is in flight. The loaded + * meshes keep drawing; the next update after the freeze lifts re-selects. + */ + setInteractive: (active: boolean) => void; setShadowCameras: ( cameras: readonly (Camera | CesiumTerrainShadowView)[] ) => void; @@ -500,6 +505,17 @@ export const buildCesiumTerrainRuntime = ( let selectionGeneration = 0; let requestedSignature = ""; let activeViewportElevationSignature = ""; + // What the last full LoD selection was computed from. `buildSelection` walks + // and sorts every candidate tile, which is far too expensive to repeat on + // every rendered frame while nothing moved; this cheap signature of its + // inputs decides whether it runs at all. + let selectionInputSignature = ""; + let interactive = false; + // Quantized fingerprint of the shadow cameras. The shadow fit follows the + // terrain it shades, so an unquantized feedback (camera -> selection -> + // tiles -> elevation range -> camera ...) would never settle; rounding to a + // couple of metres makes it converge. + let shadowViewsSignature = ""; let resolveReady: (loaded: boolean) => void = () => undefined; let readySettled = false; const ready = new Promise((resolve) => { @@ -580,12 +596,18 @@ export const buildCesiumTerrainRuntime = ( ); }; - const getOrthographicShadowScreenSpaceError = ( - terrainSource: CesiumTerrainTileSource, + /** + * Pixels one metre covers in the given orthographic shadow buffer. + * + * Tile-independent by construction, so it is computed once per selection + * and multiplied with each tile's level error. Doing the matrix work per + * candidate used to dominate the whole selection: `updateMatrixWorld(true)` + * on the root recursed over every loaded terrain mesh, per tile. + */ + const getOrthographicPixelsPerMeter = ( camera: Camera, pixelWidth: number, - pixelHeight: number, - id: CesiumTerrainTileId + pixelHeight: number ) => { if ( !(camera as Camera & { isOrthographicCamera?: boolean }) @@ -604,14 +626,11 @@ export const buildCesiumTerrainRuntime = ( (elements[offset] * pixelWidth) / 2, (elements[offset + 1] * pixelHeight) / 2 ); - const pixelsPerMeter = Math.max( + return Math.max( pixelsPerMeterForAxis(0), pixelsPerMeterForAxis(4), pixelsPerMeterForAxis(8) ); - return ( - terrainSource.getLevelMaximumGeometricError(id.level) * pixelsPerMeter - ); }; const getRelevantChildren = ( @@ -876,7 +895,13 @@ export const buildCesiumTerrainRuntime = ( const viewportBounds = getViewportBounds(frame.map); const shadowCoverages = shadowViews.flatMap((view) => { const bounds = cameraFrustumBounds(view.camera, root, origin, meterScale); - return bounds ? [{ ...view, bounds }] : []; + if (!bounds) return []; + const pixelsPerMeter = getOrthographicPixelsPerMeter( + view.camera, + view.shadowMapSize?.width ?? frame.viewport.x, + view.shadowMapSize?.height ?? frame.viewport.y + ); + return [{ ...view, bounds, pixelsPerMeter }]; }); const shadowBounds = shadowCoverages.map(({ bounds }) => bounds); const coverageBounds = shadowBounds.reduce( @@ -902,12 +927,18 @@ export const buildCesiumTerrainRuntime = ( } return [{ id, kind }]; }); + const selPerf = (globalThis as unknown as Record) + .__carmaTerrainSelPerf as + | { runs: number; rootMs: number; refineMs: number; roots: number; picked: number } + | undefined; + const selT0 = performance.now(); let rootLevel = minimumLevel; let rootEntries = getRootEntries(rootLevel); while (rootEntries.length > maxSelectionTiles && rootLevel > 0) { rootLevel -= 1; rootEntries = getRootEntries(rootLevel); } + const selT1 = performance.now(); const selected = new Map( rootEntries.map((entry) => [terrainSelectionKey(entry), entry]) @@ -920,18 +951,16 @@ export const buildCesiumTerrainRuntime = ( errorTargetPixels : 0; const shadowTargetPixels = errorTargetPixels * 2 ** shadowLevelOffset; + const levelErrorMeters = terrainSource.getLevelMaximumGeometricError( + entry.id.level + ); const shadowErrorRatio = shadowCoverages.reduce( (maximum, coverage) => boundsIntersect(bounds, coverage.bounds) ? Math.max( maximum, - getOrthographicShadowScreenSpaceError( - terrainSource, - coverage.camera, - coverage.shadowMapSize?.width ?? frame.viewport.x, - coverage.shadowMapSize?.height ?? frame.viewport.y, - entry.id - ) / shadowTargetPixels + (levelErrorMeters * coverage.pixelsPerMeter) / + shadowTargetPixels ) : maximum, 0 @@ -944,10 +973,81 @@ export const buildCesiumTerrainRuntime = ( : Math.max(viewportErrorRatio, shadowErrorRatio), }; }; - const candidates = rootEntries.map(toCandidate); - while (candidates.length > 0) { - candidates.sort((left, right) => left.errorRatio - right.errorRatio); - const candidate = candidates.pop()!; + // A binary max-heap on errorRatio. The refinement loop pops the worst + // tile, splits it and pushes its children; re-sorting the whole array on + // every iteration made the selection quadratic and cost hundreds of + // milliseconds once a shadow camera joined the coverage. + const heap = rootEntries.map(toCandidate); + const heapSwap = (a: number, b: number) => { + const held = heap[a]; + heap[a] = heap[b]; + heap[b] = held; + }; + const heapPush = (candidate: TerrainCandidate) => { + heap.push(candidate); + let index = heap.length - 1; + while (index > 0) { + const parent = (index - 1) >> 1; + if (heap[parent].errorRatio >= heap[index].errorRatio) break; + heapSwap(parent, index); + index = parent; + } + }; + const heapPop = (): TerrainCandidate => { + const top = heap[0]; + const last = heap.pop()!; + if (heap.length > 0) { + heap[0] = last; + let index = 0; + for (;;) { + const left = 2 * index + 1; + const right = left + 1; + let largest = index; + if ( + left < heap.length && + heap[left].errorRatio > heap[largest].errorRatio + ) { + largest = left; + } + if ( + right < heap.length && + heap[right].errorRatio > heap[largest].errorRatio + ) { + largest = right; + } + if (largest === index) break; + heapSwap(largest, index); + index = largest; + } + } + return top; + }; + for (let index = (heap.length >> 1) - 1; index >= 0; index -= 1) { + // heapify the roots in place + let current = index; + for (;;) { + const left = 2 * current + 1; + const right = left + 1; + let largest = current; + if ( + left < heap.length && + heap[left].errorRatio > heap[largest].errorRatio + ) { + largest = left; + } + if ( + right < heap.length && + heap[right].errorRatio > heap[largest].errorRatio + ) { + largest = right; + } + if (largest === current) break; + heapSwap(largest, current); + current = largest; + } + } + while (heap.length > 0) { + const candidate = heapPop(); if (candidate.errorRatio <= 1) break; if (candidate.entry.id.level >= maximumLevel) continue; const children = getRelevantChildren( @@ -961,10 +1061,17 @@ export const buildCesiumTerrainRuntime = ( selected.delete(terrainSelectionKey(candidate.entry)); for (const child of children) { selected.set(terrainSelectionKey(child), child); - candidates.push(toCandidate(child)); + heapPush(toCandidate(child)); } } + if (selPerf) { + selPerf.runs += 1; + selPerf.rootMs += selT1 - selT0; + selPerf.refineMs += performance.now() - selT1; + selPerf.roots += rootEntries.length; + selPerf.picked += selected.size; + } const entries = [...selected.values()]; return { entries, @@ -984,6 +1091,58 @@ export const buildCesiumTerrainRuntime = ( }; }; + const quantize = (value: number, step: number) => + Math.round(value / step) * step; + + const computeShadowViewsSignature = ( + views: readonly TerrainShadowView[] + ): string => + views + .map(({ camera, shadowMapSize }) => { + const ortho = camera as unknown as { + left?: number; + right?: number; + top?: number; + bottom?: number; + near?: number; + far?: number; + }; + const position = camera.position; + return [ + quantize(position.x, 2), + quantize(position.y, 2), + quantize(position.z, 2), + quantize(ortho.left ?? 0, 2), + quantize(ortho.right ?? 0, 2), + quantize(ortho.top ?? 0, 2), + quantize(ortho.bottom ?? 0, 2), + quantize(ortho.near ?? 0, 4), + quantize(ortho.far ?? 0, 4), + shadowMapSize ? `${shadowMapSize.width}x${shadowMapSize.height}` : "v", + ].join(","); + }) + .join("|"); + + const computeSelectionInputSignature = ( + frame: SharedThreeSceneFrame + ): string => { + // The synthesized LoD camera already encodes centre, zoom, bearing and + // pitch; quantizing its pose keeps a slow pan from re-selecting on every + // frame while staying independent of the host map's API surface. + const { position, quaternion } = frame.lodCamera; + return [ + quantize(position.x, 5), + quantize(position.y, 5), + quantize(position.z, 5), + quantize(quaternion.x, 0.005), + quantize(quaternion.y, 0.005), + quantize(quaternion.z, 0.005), + quantize(quaternion.w, 0.005), + `${frame.viewport.x}x${frame.viewport.y}`, + shadowViewsSignature, + ].join(";"); + }; + const updateViewportCoverage = (frame: SharedThreeSceneFrame) => { const viewportBounds = getViewportBounds(frame.map); if (coverageBounds && boundsContain(coverageBounds, viewportBounds)) return; @@ -1096,19 +1255,35 @@ export const buildCesiumTerrainRuntime = ( if (disposed || !root.visible) return; updateViewportCoverage(frame); if (!source) return; + // The full selection walk is only worth its cost when something it + // depends on has moved: the map camera (quantized so a slow pan + // re-selects in steps rather than per frame) or the shadow fit. + if (interactive) return; + const inputSignature = computeSelectionInputSignature(frame); + if (inputSignature === selectionInputSignature) return; const selection = buildSelection(source, frame); + selectionInputSignature = inputSignature; if (selection.signature === requestedSignature) { return; } requestedSignature = selection.signature; loadSelection(source, selection); }, + setInteractive(active) { + if (interactive === active) return; + interactive = active; + if (!active) { + selectionInputSignature = ""; + map?.triggerRepaint(); + } + }, setVisible(visible) { root.visible = visible; if (!visible) { for (const record of meshes.values()) record.node.visible = false; } else { requestedSignature = ""; + selectionInputSignature = ""; } map?.triggerRepaint(); }, @@ -1123,6 +1298,11 @@ export const buildCesiumTerrainRuntime = ( ), } ); + const nextSignature = computeShadowViewsSignature(shadowViews); + if (nextSignature !== shadowViewsSignature) { + shadowViewsSignature = nextSignature; + selectionInputSignature = ""; + } }, setShadowCamera(camera, shadowMapSize) { shadowViews = camera @@ -1135,6 +1315,11 @@ export const buildCesiumTerrainRuntime = ( }, ] : []; + const nextSignature = computeShadowViewsSignature(shadowViews); + if (nextSignature !== shadowViewsSignature) { + shadowViewsSignature = nextSignature; + selectionInputSignature = ""; + } }, setMaterialColor(color) { material.color.set(color); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts index 770e9417ca..4a66dca7e2 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/shared-three-scene-layer.ts @@ -340,9 +340,33 @@ export const buildSharedThreeSceneLayer = ( lookTarget, viewport, }; + // Dev-only frame profiling: window.__carmaSharedScenePerf accumulates + // per-phase millisecond totals, so a slow frame can be attributed from + // the console without a profiler. + const perf = import.meta.env?.DEV + ? ((window as unknown as Record).__carmaSharedScenePerf ??= + { frames: 0, phases: {} as Record }) + : null; + const mark = perf + ? (() => { + let last = performance.now(); + return (name: string) => { + const now = performance.now(); + const phases = (perf as { phases: Record }).phases; + phases[name] = (phases[name] ?? 0) + (now - last); + last = now; + }; + })() + : null; + if (perf) (perf as { frames: number }).frames += 1; scene.updateMatrixWorld(true); - for (const runtime of runtimes.values()) runtime.update(frame); + mark?.("updateMatrixWorld1"); + for (const runtime of runtimes.values()) { + runtime.update(frame); + mark?.(`runtime:${runtime.id}`); + } scene.updateMatrixWorld(true); + mark?.("updateMatrixWorld2"); const currentDepthRange = gl.getParameter(gl.DEPTH_RANGE) as Float32Array; const savedDepthRange: DepthRange = [ @@ -354,6 +378,7 @@ export const buildSharedThreeSceneLayer = ( depthRangeBridge?.render(savedDepthRange, () => { renderer?.render(scene, renderCamera); }); + mark?.("threeRender"); }, onRemove() { From 01392127e3b1fc9fd1d23b1c728a725b33dd504c Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 01:33:37 +0200 Subject: [PATCH 13/78] fix(geoportal): sweep terrain shadow coverage from the view, not the fit Which ground has to stay loaded for shadows is a question about the view and the sun. Deriving it from the fitted render camera - as before - coupled the tile selection to a camera that follows every fit, empties on a failed fit and rests during gestures, which unloaded sun-side tiles while their shadows were on screen: the bright hole in the middle of a dusk view. The coverage is now one analytic orthographic volume, the light-space box of the visible extent swept toward the sun by the caster reach, handed to the terrain runtime whenever view or sun move. A relief margin in the reach accounts for ridges beyond the viewport that still shade into it, in the render fit as well as in the coverage. Shadow resolution: screen corners near the horizon unproject kilometres away and used to stretch the single buffer across the whole valley. The receiver extent is now clamped to a 4 km radius around the view anchor, which keeps the texels where the viewer looks. The shading itself sheds its per-texel bias battery. Acne is answered where it comes from: buildings render their far walls into the depth map (shadowSide BackSide), which also removes the bright leak line along their bases, and terrain gets a slope-scaled polygon offset in its own depth pass. One small constant normal bias and a one-texel filter radius remain. Shadow intensity defaults to 100 percent. A gesture no longer coarsens streamed building tiles: the progressive error target only tightens after load, so detail already on screen stays through a pan. --- .../src/addons/ShadowSimulation/index.tsx | 10 +- .../ShadowSimulation/shadow-scene.spec.ts | 38 ++-- .../addons/ShadowSimulation/shadow-scene.ts | 174 +++++++++++++++--- .../tiled-shadow-controller.ts | 62 +++---- .../cesium-terrain-tile-runtime.ts | 3 + .../integrations/three-tiles-runtime.ts | 5 +- 6 files changed, 210 insertions(+), 82 deletions(-) diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index 911680190f..7de4f25c23 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -288,7 +288,7 @@ const ShadowSimulationRibbon = ({ () => getSolarPosition(state.selection, location), [location, state.selection] ); - const intensity = state.shadowIntensity ?? 0.45; + const intensity = state.shadowIntensity ?? 1; const minimumMinutes = Math.ceil(daylight.sunriseMinutes); const maximumMinutes = Math.floor(daylight.sunsetMinutes); const selectedDate = useMemo( @@ -451,7 +451,7 @@ const ShadowQuickSettings = ({ }) => { const animationMode = state.animationMode ?? SHADOW_ANIMATION_MODE.DAY; const animationSpeed = state.animationSpeed ?? 4; - const intensity = state.shadowIntensity ?? 0.45; + const intensity = state.shadowIntensity ?? 1; const publishSelection = (candidate: SolarSelection) => { const selection = clampSelectionToDaylight(candidate, location); @@ -701,7 +701,7 @@ export const ShadowSimulationControlSurface = ({ animationMode: SHADOW_ANIMATION_MODE.DAY, animationSpeed: 4, isAnimating: false, - shadowIntensity: 0.45, + shadowIntensity: 1, showSunDebugVector: false, showShadowBuffers: false, showProjectionDebugView: false, @@ -812,7 +812,7 @@ const ShadowSimulationRuntime = ({ useEffect(() => { if (!state.enabled) return; - shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 0.45); + shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 1); }, [state.enabled, state.shadowIntensity, sceneRevision]); useEffect(() => { @@ -911,7 +911,7 @@ export const ShadowSimulation = ({ animationMode: SHADOW_ANIMATION_MODE.DAY, animationSpeed: 4, isAnimating: false, - shadowIntensity: 0.45, + shadowIntensity: 1, selection: clampSelectionToDaylight(candidate, location) ?? { ...candidate, minutes: 12 * 60, diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts index 3aa12d87b4..a635f5f2c5 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -204,7 +204,7 @@ describe("shadow scene lighting integration", () => { expect(sun.shadow.camera.projectionMatrix.elements[15]).toBe(1); expect(sun.castShadow).toBe(true); expect(sun.shadow.autoUpdate).toBe(false); - expect(sun.shadow.radius).toBe(0); + expect(sun.shadow.radius).toBe(1); const tileLights = scene.children.filter( (object): object is THREE.DirectionalLight => (object as THREE.DirectionalLight).isDirectionalLight && @@ -289,6 +289,8 @@ describe("shadow scene lighting integration", () => { new THREE.MeshLambertMaterial() ); terrain.name = "terrain"; + // What the cesium terrain runtime stamps on every surface it builds. + terrain.userData.isShadowTerrainSurface = true; scene.add(terrain); const alkisScene = new THREE.Scene(); const sourceBuildingMaterial = new THREE.MeshLambertMaterial({ @@ -338,7 +340,11 @@ describe("shadow scene lighting integration", () => { expect(buildingCopy.material).not.toBe(sourceBuildingMaterial); expect((buildingCopy.material as THREE.Material).opacity).toBe(1); expect((buildingCopy.material as THREE.Material).transparent).toBe(false); - expect((buildingCopy.material as THREE.Material).shadowSide).toBeNull(); + // Closed buildings write their far walls into the depth map, which is + // what keeps their bases free of leak lines and their walls of acne. + expect((buildingCopy.material as THREE.Material).shadowSide).toBe( + THREE.BackSide + ); expect((buildingCopy.material as THREE.Material).defines).toMatchObject({ USE_CSM: 1, CSM_CASCADES: 4, @@ -347,6 +353,10 @@ describe("shadow scene lighting integration", () => { expect(terrain.castShadow).toBe(true); expect(terrain.receiveShadow).toBe(true); expect((terrain.material as THREE.Material).shadowSide).toBeNull(); + // Terrain acne is answered with a slope-scaled offset in its depth pass. + expect( + (terrain.customDepthMaterial as THREE.MeshDepthMaterial).polygonOffset + ).toBe(true); expect(buildingCopy.parent?.parent).toBe(scene); expect(terrain.parent).toBe(scene); @@ -834,22 +844,18 @@ describe("shadow scene lighting integration", () => { lookTarget: new THREE.Vector3(7_150, 150, 51_256), viewport: new THREE.Vector2(800, 600), }); + // Terrain coverage no longer follows the fitted render cameras: it is + // one analytic volume swept from the visible extent toward the sun, so + // the selection never loses sun-side tiles to a resting or failed fit. const shadowViews = terrainRuntime.setShadowCameras.mock.lastCall?.[0]; - const tileLights = scene.children.filter( - (object): object is THREE.DirectionalLight => - (object as THREE.DirectionalLight).isDirectionalLight && - object.name.startsWith("shadow-simulation-sun") + expect(shadowViews).toHaveLength(1); + expect(shadowViews[0].camera.name).toBe( + "shadow-simulation-terrain-coverage" ); - expect(shadowViews).toHaveLength(tileLights.length); - for (let index = 0; index < tileLights.length; index += 1) { - expect(shadowViews[index]).toEqual({ - camera: tileLights[index].shadow.camera, - shadowMapSize: { - width: tileLights[index].shadow.mapSize.x, - height: tileLights[index].shadow.mapSize.y, - }, - }); - } + expect( + (shadowViews[0].camera as THREE.OrthographicCamera).isOrthographicCamera + ).toBe(true); + expect(shadowViews[0].shadowMapSize.width).toBeGreaterThan(0); expect(shadowViews[0].shadowMapSize.width).not.toBe(800); controller.dispose(); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index f633be5e80..4b49be88e2 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -23,7 +23,11 @@ import { type AtmosphericSunlightSample, type AtmosphericSunlightOptions, } from "./atmospheric-sunlight"; -import { TiledShadowController } from "./tiled-shadow-controller"; +import { + CASTER_RELIEF_MARGIN_METERS, + restingShadowMapSize, + TiledShadowController, +} from "./tiled-shadow-controller"; import { clearShadowProjectionDebugSnapshot, hasShadowProjectionDebugListeners, @@ -31,6 +35,14 @@ import { } from "./shadow-projection-debug-store"; const FALLBACK_SHADOW_AREA_METERS = 900; +/** + * How far from the view anchor the single shadow buffer still resolves + * shadows. Screen corners near the horizon unproject kilometres away; letting + * them stretch the fit spreads the buffer's texels over the whole valley and + * every shadow turns to mush. Beyond this radius the ground keeps its sun + * term but receives no mapped shadow. + */ +const MAX_RECEIVER_DISTANCE_METERS = 4_000; const MIN_VIEWPORT_SHADOW_AREA_METERS = 10; const DEFAULT_SHADOW_CAMERA_OFFSET_METERS = 2_500; const SHADOW_SIMULATION_SUN_VECTOR_NAME = "shadow-simulation-sun-vector"; @@ -153,6 +165,19 @@ export const solarPositionToSceneDirection = ({ ).normalize(); }; +/** + * Slope-scaled depth bias for the terrain's shadow pass. GL polygon offset + * grows with the surface's depth slope, which is exactly where heightfield + * acne comes from, so the open terrain needs no large constant bias. + */ +const buildTerrainDepthMaterial = () => + new THREE.MeshDepthMaterial({ + depthPacking: THREE.RGBADepthPacking, + polygonOffset: true, + polygonOffsetFactor: 2, + polygonOffsetUnits: 4, + }); + const makeMeshShadeable = ( mesh: THREE.Mesh, shadowController?: TiledShadowController @@ -160,10 +185,22 @@ const makeMeshShadeable = ( if (mesh.userData[SHADOW_OVERLAY_MARKER]) return; mesh.castShadow = mesh.userData.disableShadowCasting !== true; mesh.receiveShadow = true; + const materials = Array.isArray(mesh.material) + ? mesh.material + : [mesh.material]; + if (mesh.userData.isShadowTerrainSurface) { + if (!mesh.customDepthMaterial) { + mesh.customDepthMaterial = buildTerrainDepthMaterial(); + } + } else { + // Closed solids write their far walls into the depth map. The stored + // depth then sits behind the lit wall, which removes both self-shadow + // acne and the bright leak line along the building's base in one go. + for (const material of materials) { + material.shadowSide = THREE.BackSide; + } + } if (shadowController) { - const materials = Array.isArray(mesh.material) - ? mesh.material - : [mesh.material]; for (const material of materials) { shadowController.setupMaterial(material); } @@ -502,7 +539,7 @@ const buildShadowLightBinding = ( activeShadowTileCount: 0, shadowQuality: DEFAULT_SHADOW_QUALITY, shadowMode: DEFAULT_SHADOW_MODE, - shadowIntensity: 0.45, + shadowIntensity: 1, directionToSun: new THREE.Vector3(0, 1, 0), sunColor: new THREE.Color(0xfff2d8), sunIntensity: ATMOSPHERIC_LIGHT_EXPOSURE, @@ -702,7 +739,7 @@ export const buildShadowSimulationScene = ( fullOpacity: true, uniformColor: null, }; - let latestShadowIntensity = 0.45; + let latestShadowIntensity = 1; let latestAtmosphericSunlight: AtmosphericSunlightSample | null = null; let atmosphericSunlightOptions: AtmosphericSunlightOptions = { useTransmittanceLut: true, @@ -851,6 +888,93 @@ export const buildShadowSimulationScene = ( }; const genericBridges = new Map(); let cachedElevationRange: readonly [number, number] | null = null; + + // ── Terrain shadow coverage ──────────────────────────────────────────── + // + // Which ground has to be loaded for shadows is a question about the view + // and the sun, nothing else: sweep the visible extent toward the sun and + // everything inside that volume can cast into it. Deriving the coverage + // from the *fitted* render camera instead — as this used to work — couples + // the tile selection to a camera that follows every fit, vanishes on failed + // fits and rests during gestures, which is what unloaded sun-side tiles + // mid-view. This camera is analytic, always defined, and only ever moves + // when the view or the sun does. + const terrainCoverageCamera = new THREE.OrthographicCamera(); + terrainCoverageCamera.name = "shadow-simulation-terrain-coverage"; + const coverageBasisX = new THREE.Vector3(); + const coverageBasisY = new THREE.Vector3(); + const coverageBasisZ = new THREE.Vector3(); + const coverageRotation = new THREE.Matrix4(); + const updateTerrainShadowCoverage = () => { + if (!terrainRuntime) return; + const points = sharedBinding.receiverWorldPoints; + const toSun = sharedBinding.directionToSun; + if (points.length === 0 || toSun.lengthSq() < 1e-6) return; + + coverageBasisZ.copy(toSun).normalize(); + if (Math.abs(coverageBasisZ.y) > 0.99) { + coverageBasisX.set(1, 0, 0); + } else { + coverageBasisX.crossVectors(new THREE.Vector3(0, 1, 0), coverageBasisZ); + coverageBasisX.normalize(); + } + coverageBasisY.crossVectors(coverageBasisZ, coverageBasisX); + + let minX = Infinity, maxX = -Infinity; + let minY = Infinity, maxY = -Infinity; + let minZ = Infinity, maxZ = -Infinity; + for (const point of points) { + const x = point.dot(coverageBasisX); + const y = point.dot(coverageBasisY); + const z = point.dot(coverageBasisZ); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + const reliefMeters = Math.max( + 0, + sharedBinding.maximumElevationMeters - sharedBinding.minimumElevationMeters + ); + const elevationSine = Math.max(0.04, coverageBasisZ.y); + const casterReachMeters = THREE.MathUtils.clamp( + (reliefMeters + CASTER_RELIEF_MARGIN_METERS) / elevationSine + 50, + 50, + 10_000 + ); + maxZ += casterReachMeters; + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + terrainCoverageCamera.position + .set(0, 0, 0) + .addScaledVector(coverageBasisX, centerX) + .addScaledVector(coverageBasisY, centerY) + .addScaledVector(coverageBasisZ, maxZ); + coverageRotation.makeBasis(coverageBasisX, coverageBasisY, coverageBasisZ); + terrainCoverageCamera.quaternion.setFromRotationMatrix(coverageRotation); + terrainCoverageCamera.left = -(maxX - minX) / 2; + terrainCoverageCamera.right = (maxX - minX) / 2; + terrainCoverageCamera.bottom = -(maxY - minY) / 2; + terrainCoverageCamera.top = (maxY - minY) / 2; + terrainCoverageCamera.near = 0; + terrainCoverageCamera.far = Math.max(1, maxZ - minZ); + terrainCoverageCamera.updateProjectionMatrix(); + terrainCoverageCamera.updateMatrixWorld(true); + + const mapSize = restingShadowMapSize( + sharedBinding.shadowMode, + sharedBinding.shadowQuality + ); + terrainRuntime.setShadowCameras([ + { + camera: terrainCoverageCamera, + shadowMapSize: { width: mapSize, height: mapSize }, + }, + ]); + }; // Whether a camera gesture is in flight. While it is, the shadow buffer is // rendered at half resolution so the pan stays fluid; moveend restores it. let mapInMotion = false; @@ -892,14 +1016,27 @@ export const buildShadowSimulationScene = ( const lngLat = map.unproject(point as [number, number]); return [lngLat.lng, lngLat.lat] as [number, number]; }); + // A screen corner near the horizon unprojects kilometres out. Pulling it + // back onto the receiver radius keeps the shadow buffer's texels where + // the viewer actually looks. + const clampToReceiverRadius = (point: THREE.Vector3) => { + const offset = point.clone().sub(center); + const horizontal = Math.hypot(offset.x, offset.z); + if (horizontal <= MAX_RECEIVER_DISTANCE_METERS) return point; + const scale = MAX_RECEIVER_DISTANCE_METERS / horizontal; + offset.x *= scale; + offset.z *= scale; + return offset.add(center); + }; for (const lngLat of viewportLngLats) { const corner = sceneLease.layer.projectLngLatToScene?.( lngLat, terrainRuntime?.getElevation(lngLat[0], lngLat[1]) ?? centerElevation ); if (corner) { - projectedCorners.push(corner); - radiusMeters = Math.max(radiusMeters, corner.distanceTo(center)); + const clamped = clampToReceiverRadius(corner); + projectedCorners.push(clamped); + radiusMeters = Math.max(radiusMeters, clamped.distanceTo(center)); } } if (projectedCorners.length === 4) { @@ -951,13 +1088,14 @@ export const buildShadowSimulationScene = ( lngLat, elevation ); - return point ? [point] : []; + return point ? [clampToReceiverRadius(point)] : []; }) ); sharedBinding.receiverWorldPoints = coveragePoints; sharedBinding.minimumElevationMeters = minimumElevation; sharedBinding.maximumElevationMeters = maximumElevation; sharedBinding.dirty = true; + updateTerrainShadowCoverage(); // Re-sampling the atmosphere and rebuilding the sun gizmo is only due // when the sun itself may have changed: a new time, or a viewport that // came to rest somewhere else. A frame-by-frame pan reuses the sample and @@ -988,7 +1126,6 @@ export const buildShadowSimulationScene = ( ) { clearShadowProjectionDebugSnapshot(map); updateShadowBufferBorders(sharedBinding, 0); - terrainRuntime?.setShadowCameras([]); return; } if (sceneMaterialsDirty) { @@ -1011,7 +1148,6 @@ export const buildShadowSimulationScene = ( if (!snapshot) { clearShadowProjectionDebugSnapshot(map); updateShadowBufferBorders(sharedBinding, 0); - terrainRuntime?.setShadowCameras([]); return; } updateShadowBufferBorders(sharedBinding, snapshot.tileCount); @@ -1059,22 +1195,6 @@ export const buildShadowSimulationScene = ( : null, }); } - // While a gesture is in flight the fit follows the viewport frame by - // frame, and every hand-over would re-trigger the terrain's shadow - // refinement. The moveend refresh hands over the settled fit. - if (!mapInMotion) { - terrainRuntime?.setShadowCameras( - sharedBinding.controller.lights - .slice(0, snapshot.tileCount) - .map(({ shadow }) => ({ - camera: shadow.camera, - shadowMapSize: { - width: shadow.mapSize.x, - height: shadow.mapSize.y, - }, - })) - ); - } }, dispose: () => undefined, }; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts index 1bce036607..df281c4e6d 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -19,12 +19,22 @@ const MIN_CASTER_REACH_METERS = 50; const MAX_CASTER_REACH_METERS = 10_000; const CASTER_REACH_ELEVATION_EPSILON = 0.04; const LIGHT_CAMERA_SAFETY_METERS = 25; -const SHADOW_NORMAL_BIAS_TEXELS = 0.5; -const SHADOW_DEPTH_BIAS_TEXELS = 0.5; -const MIN_SHADOW_DEPTH_BIAS_METERS = 0.01; -const MAX_SHADOW_DEPTH_BIAS_METERS = 0.35; -const MIN_SHADOW_NORMAL_BIAS_METERS = 0.02; -const MAX_SHADOW_NORMAL_BIAS_METERS = 0.5; +/** + * Ground that can shade the view is not limited to the relief visible in it: + * at a low sun, a ridge beyond the viewport throws its shadow in. The margin + * stands in for how much higher the surroundings may be than what is on + * screen. + */ +export const CASTER_RELIEF_MARGIN_METERS = 150; +/** + * One fixed normal bias instead of the former per-texel battery. Acne is + * handled where it comes from: buildings render their far walls into the + * depth map (`shadowSide: BackSide`), terrain gets a slope-scaled polygon + * offset in its depth pass. What remains is a small constant to absorb + * filter-kernel wobble. + */ +const SHADOW_NORMAL_BIAS_METERS = 0.1; +const SHADOW_FILTER_RADIUS = 1; export type TiledShadowTileSnapshot = Readonly<{ id: string; @@ -279,6 +289,14 @@ const getReceiverBoundsInLightCamera = ( const copyDefines = (defines: Record | undefined) => defines ? { ...defines } : undefined; +/** The buffer edge a mode/quality pair settles on once the camera rests. */ +export const restingShadowMapSize = ( + mode: ShadowMode, + quality: ShadowQualityMultiplier +): number => + (mode === "single" ? BASE_SINGLE_SHADOW_MAP_SIZE : BASE_SHADOW_TILE_MAP_SIZE) * + Math.sqrt(quality); + export class TiledShadowController { readonly csm: CSM; readonly lights: readonly THREE.DirectionalLight[]; @@ -322,7 +340,9 @@ export class TiledShadowController { // would invalidate every shaded draw. The first shadow pass allocates the // pool; inactive entries stay at zero intensity afterwards. light.shadow.needsUpdate = true; - light.shadow.radius = 0; + light.shadow.radius = SHADOW_FILTER_RADIUS; + light.shadow.bias = 0; + light.shadow.normalBias = SHADOW_NORMAL_BIAS_METERS; } } @@ -469,7 +489,8 @@ export class TiledShadowController { normalizedDirectionToSun.y ); const casterReachMeters = THREE.MathUtils.clamp( - reliefMeters / elevationSine + MIN_CASTER_REACH_METERS, + (reliefMeters + CASTER_RELIEF_MARGIN_METERS) / elevationSine + + MIN_CASTER_REACH_METERS, MIN_CASTER_REACH_METERS, MAX_CASTER_REACH_METERS ); @@ -570,18 +591,6 @@ export class TiledShadowController { shadowCamera.near + 1, receiverBounds.far + casterReachMeters + reliefMeters ); - light.shadow.normalBias = THREE.MathUtils.clamp( - texelMeters * SHADOW_NORMAL_BIAS_TEXELS, - MIN_SHADOW_NORMAL_BIAS_METERS, - MAX_SHADOW_NORMAL_BIAS_METERS - ); - const depthBiasMeters = THREE.MathUtils.clamp( - texelMeters * SHADOW_DEPTH_BIAS_TEXELS, - MIN_SHADOW_DEPTH_BIAS_METERS, - MAX_SHADOW_DEPTH_BIAS_METERS - ); - light.shadow.bias = - -depthBiasMeters / (shadowCamera.far - shadowCamera.near); shadowCamera.updateProjectionMatrix(); light.shadow.updateMatrices(light); light.shadow.needsUpdate = true; @@ -669,19 +678,6 @@ export class TiledShadowController { shadowCamera.top = tile.top; shadowCamera.near = Math.max(0.01, tile.near); shadowCamera.far = Math.max(shadowCamera.near + 1, tile.far); - const texelMeters = layout.statistics.effectiveMetersPerTexel; - light.shadow.normalBias = THREE.MathUtils.clamp( - texelMeters * SHADOW_NORMAL_BIAS_TEXELS, - MIN_SHADOW_NORMAL_BIAS_METERS, - MAX_SHADOW_NORMAL_BIAS_METERS - ); - const depthBiasMeters = THREE.MathUtils.clamp( - texelMeters * SHADOW_DEPTH_BIAS_TEXELS, - MIN_SHADOW_DEPTH_BIAS_METERS, - MAX_SHADOW_DEPTH_BIAS_METERS - ); - light.shadow.bias = - -depthBiasMeters / (shadowCamera.far - shadowCamera.near); shadowCamera.updateProjectionMatrix(); light.shadow.updateMatrices(light); light.shadow.needsUpdate = true; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index de97b8946c..e547c9c227 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -711,6 +711,7 @@ export const buildCesiumTerrainRuntime = ( let baseMesh: Mesh | null = null; if (baseGeometry) { baseMesh = new Mesh(baseGeometry, material); + baseMesh.userData.isShadowTerrainSurface = true; baseMesh.name = `${node.name}-base`; baseMesh.castShadow = false; baseMesh.receiveShadow = true; @@ -720,6 +721,7 @@ export const buildCesiumTerrainRuntime = ( let reliefMesh: Mesh | null = null; if (reliefGeometry) { reliefMesh = new Mesh(reliefGeometry, material); + reliefMesh.userData.isShadowTerrainSurface = true; reliefMesh.name = `${node.name}-relief`; reliefMesh.castShadow = true; reliefMesh.receiveShadow = true; @@ -1154,6 +1156,7 @@ export const buildCesiumTerrainRuntime = ( ); if (!coverageMesh) { coverageMesh = new Mesh(geometry, coverageMaterial); + coverageMesh.userData.isShadowTerrainSurface = true; coverageMesh.name = `${runtimeId}-viewport-coverage`; coverageMesh.castShadow = false; coverageMesh.receiveShadow = true; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts index fbdc43789a..99539da54a 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/three-tiles-runtime.ts @@ -501,7 +501,10 @@ if (uProjKind > 0.5 && uProjOpacity > 0.001) { window.clearTimeout(refinementTimer); refinementTimer = 0; } - restartProgressiveLod(); + // Deliberately no error-target reset here: raising it mid-gesture told + // the traversal that the fine tiles already on screen were no longer + // needed, and they visibly unloaded the moment a pan began. Loaded detail + // stays; only the refinement timer restarts once the camera rests. tiles?.dispatchEvent({ type: "needs-update" }); }; const handleViewEnd = () => { From bcd09dc10a94851bdf280af82885f853474df0c9 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 01:57:39 +0200 Subject: [PATCH 14/78] fix(geoportal): keep the shadow terrain sweep static and hole-free Three ways the ground between the view and the sun could still go missing, all closed: - The sweep took its vertical extent and caster reach from the elevation range of the tiles currently visible. That range grows as tiles stream in, the sweep reshaped, and every reshape superseded the tile batch that was still loading - at low sun the up-sun tiles never settled. The sweep now spans a fixed regional elevation band (0-500 m over Wuppertal) and is a pure function of view and sun; its box is snapped to a 50 m grid on top. Superseded batches keep their fetched tiles as hidden meshes, so the selection that follows activates them instead of re-fetching. - The visible relief also underestimated the reach itself: the ridges that shade a valley at dusk are usually not in the viewport. The relief margin rises to 300 m of regional headroom, in the coverage and in the render fit's far plane alike; at 10 degrees of sun that is roughly a 5 km sweep. - Refining an available parent whose child quadrant has no data swapped real coarse ground for a sea-level plate - a hole in the view, and up-sun a hole in the shadow. Such parents now stay whole; refinement ends at the availability boundary. Dev builds expose the sweep as window.__carmaShadowCoverage. --- .../addons/ShadowSimulation/shadow-scene.ts | 67 +++++++++++++++---- .../tiled-shadow-controller.ts | 8 ++- .../cesium-terrain-runtime.spec.ts | 22 +++--- .../cesium-terrain-tile-runtime.ts | 24 +++++-- 4 files changed, 86 insertions(+), 35 deletions(-) diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index 4b49be88e2..8b2df42394 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -43,6 +43,13 @@ const FALLBACK_SHADOW_AREA_METERS = 900; * term but receives no mapped shadow. */ const MAX_RECEIVER_DISTANCE_METERS = 4_000; +/** + * The absolute elevation band the terrain shadow coverage sweeps, metres + * above sea level. A regional constant on purpose, see + * updateTerrainShadowCoverage: Wuppertal's ground lies between roughly 100 + * and 350 m, with headroom on both sides. + */ +const COVERAGE_ELEVATION_BAND_METERS: readonly [number, number] = [0, 500]; const MIN_VIEWPORT_SHADOW_AREA_METERS = 10; const DEFAULT_SHADOW_CAMERA_OFFSET_METERS = 2_500; const SHADOW_SIMULATION_SUN_VECTOR_NAME = "shadow-simulation-sun-vector"; @@ -905,6 +912,7 @@ export const buildShadowSimulationScene = ( const coverageBasisY = new THREE.Vector3(); const coverageBasisZ = new THREE.Vector3(); const coverageRotation = new THREE.Matrix4(); + const coverageProbe = new THREE.Vector3(); const updateTerrainShadowCoverage = () => { if (!terrainRuntime) return; const points = sharedBinding.receiverWorldPoints; @@ -920,32 +928,55 @@ export const buildShadowSimulationScene = ( } coverageBasisY.crossVectors(coverageBasisZ, coverageBasisX); + // The sweep deliberately spans a fixed regional elevation band instead of + // the elevation range of the currently visible tiles. The visible range + // depends on which tiles are loaded, and a sweep built from it would + // reshape with every arriving batch, superseding the selection that + // requested them - the tile set never settles. A static band keeps the + // sweep a pure function of view and sun. let minX = Infinity, maxX = -Infinity; let minY = Infinity, maxY = -Infinity; let minZ = Infinity, maxZ = -Infinity; for (const point of points) { - const x = point.dot(coverageBasisX); - const y = point.dot(coverageBasisY); - const z = point.dot(coverageBasisZ); - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - if (z < minZ) minZ = z; - if (z > maxZ) maxZ = z; + for (const elevation of COVERAGE_ELEVATION_BAND_METERS) { + coverageProbe.set(point.x, elevation, point.z); + const x = coverageProbe.dot(coverageBasisX); + const y = coverageProbe.dot(coverageBasisY); + const z = coverageProbe.dot(coverageBasisZ); + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } } - const reliefMeters = Math.max( - 0, - sharedBinding.maximumElevationMeters - sharedBinding.minimumElevationMeters - ); const elevationSine = Math.max(0.04, coverageBasisZ.y); const casterReachMeters = THREE.MathUtils.clamp( - (reliefMeters + CASTER_RELIEF_MARGIN_METERS) / elevationSine + 50, + (COVERAGE_ELEVATION_BAND_METERS[1] - + COVERAGE_ELEVATION_BAND_METERS[0] + + CASTER_RELIEF_MARGIN_METERS) / + elevationSine + + 50, 50, 10_000 ); maxZ += casterReachMeters; + // Coarsely quantized on purpose. Streaming tiles widen the visible + // elevation range, the range feeds this box, and an unquantized box would + // supersede the terrain's in-flight tile batch on every arrival - a + // livelock in which no batch ever finishes. On a 50 m grid the box only + // moves for changes that matter at coverage scale. + const gridStep = 50; + const snapDown = (value: number) => Math.floor(value / gridStep) * gridStep; + const snapUp = (value: number) => Math.ceil(value / gridStep) * gridStep; + minX = snapDown(minX); + maxX = snapUp(maxX); + minY = snapDown(minY); + maxY = snapUp(maxY); + minZ = snapDown(minZ); + maxZ = snapUp(maxZ); const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; terrainCoverageCamera.position @@ -968,6 +999,14 @@ export const buildShadowSimulationScene = ( sharedBinding.shadowMode, sharedBinding.shadowQuality ); + if (import.meta.env?.DEV && typeof window !== "undefined") { + // Console handle for checking what the sweep actually covers. + (window as unknown as Record).__carmaShadowCoverage = { + camera: terrainCoverageCamera, + casterReachMeters, + mapSize, + }; + } terrainRuntime.setShadowCameras([ { camera: terrainCoverageCamera, diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts index df281c4e6d..9c59276c21 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -23,9 +23,13 @@ const LIGHT_CAMERA_SAFETY_METERS = 25; * Ground that can shade the view is not limited to the relief visible in it: * at a low sun, a ridge beyond the viewport throws its shadow in. The margin * stands in for how much higher the surroundings may be than what is on - * screen. + * screen — a regional relief bound, not a view-relative one. Wuppertal spans + * roughly 100 m valley floor to 350 m ridge, and the visible slice of that is + * often just the valley, so the margin has to carry most of it: at 10 degrees + * of sun elevation every 100 m of unseen ridge is another kilometre of + * caster reach. */ -export const CASTER_RELIEF_MARGIN_METERS = 150; +export const CASTER_RELIEF_MARGIN_METERS = 300; /** * One fixed normal bias instead of the former per-texel battery. Acne is * handled where it comes from: buildings render their far walls into the diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts index c868b09e58..666bc231ca 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -639,7 +639,7 @@ describe("buildCesiumTerrainRuntime", () => { runtime.dispose(); }); - it("replaces a coarse parent with mixed source and flat children", async () => { + it("keeps a coarse parent whole when a child quadrant has no data", async () => { const parentId = { level: 10, x: 532, y: 218 }; const sourceChildId = { level: 11, x: 1_064, y: 436 }; const source = { @@ -712,20 +712,18 @@ describe("buildCesiumTerrainRuntime", () => { }); await expect(runtime.ready).resolves.toBe(true); - expect(source.requestTile).toHaveBeenCalledTimes(2); - expect(source.requestTile).toHaveBeenCalledWith(sourceChildId); - expect(source.requestTile).toHaveBeenCalledWith({ - level: 11, - x: 1_064, - y: 437, - }); + // Splitting would trade the parent's real ground for sea-level plates in + // the quadrants without data - a hole in the view, and up-sun a hole in + // the shadow. The parent stays whole; refinement ends at the + // availability boundary. + expect(source.requestTile).toHaveBeenCalledTimes(1); + expect(source.requestTile).toHaveBeenCalledWith(parentId); expect( runtime.root.children.some((child) => child.name.includes("source:10/")) - ).toBe(false); + ).toBe(true); expect( - runtime.root.children.filter((child) => child.name.includes("flat:11/")) - ).toHaveLength(2); - expect(runtime.root.children).toHaveLength(5); + runtime.root.children.some((child) => child.name.includes("flat:11/")) + ).toBe(false); runtime.dispose(); }); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index e547c9c227..a2df27d473 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -655,12 +655,13 @@ export const buildCesiumTerrainRuntime = ( boundsIntersect(bounds, candidateBounds) ); if (!intersectsViewport && !intersectsShadow) continue; - const kind = - terrainSource.getTileDataAvailable(id) === false ? "flat" : "source"; - // A flat 0 m tile cannot occlude elevated terrain from outside the - // viewport, so only real terrain consumes the sun-frustum budget. - if (kind === "flat" && !intersectsViewport) continue; - children.push({ id, kind }); + // The parent has data - it is selected - but this child quadrant does + // not. Splitting anyway would swap real coarse ground for a sea-level + // plate: a hole in the view, and up-sun a hole in the shadow. The + // parent stays whole instead; refinement simply ends at the + // availability boundary. + if (terrainSource.getTileDataAvailable(id) === false) return []; + children.push({ id, kind: "source" }); } } return children; @@ -1182,7 +1183,16 @@ export const buildCesiumTerrainRuntime = ( : terrainSource.requestTile(entry.id).then((tile) => ({ entry, tile })) ) .then((loadedEntries) => { - if (disposed || generation !== selectionGeneration) return; + if (disposed) return; + if (generation !== selectionGeneration) { + // A newer selection superseded this batch while it loaded. The + // fetched tiles still become meshes - hidden - so the successor + // activates them instantly instead of fetching them again. + for (const { entry, tile } of loadedEntries) { + ensureMesh(tile, entry).visible = false; + } + return; + } const activeKeys = new Set(); const retainedSourceKeys = new Set(); for (const { entry, tile } of loadedEntries) { From 535a64455e0e02c62a10c0b3be7fb452b7be4191 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 02:57:52 +0200 Subject: [PATCH 15/78] fix(geoportal): make the single shadow buffer's depth test hold up Three textbook corrections to the shadow pass: - The single-buffer fit extended the FAR plane by the caster reach. In light space the ground between the view and the sun sits at SMALLER depth than the receivers, so every up-sun ridge was clipped at the near plane and its shadow arrived with a lit hole in it. The reach now extends the near plane, as the tiled layout always did. - Acne control returns to a receiver-side normal offset scaled with the world size of one shadow texel - one texel is exactly the sampling error the depth comparison must absorb, and unlike the slope-scaled polygon offset it cannot blow up at grazing sun angles, which is what had punched lit patches into self-shadowed hillsides and dotted the shadow edges with acne chains. - The terrain's custom shadow depth material is gone entirely. It carried BasicDepthPacking while Three.js's shadow sampler unpacks RGBA, so every relief mesh wrote garbage depth; the standard pipeline needs no substitute once the normal bias is scaled right. Buildings keep rendering their far walls into the depth map. --- .../ShadowSimulation/shadow-scene.spec.ts | 7 ++-- .../addons/ShadowSimulation/shadow-scene.ts | 27 +++--------- .../tiled-shadow-controller.ts | 41 +++++++++++++++---- .../cesium-terrain-runtime.spec.ts | 14 +++---- 4 files changed, 46 insertions(+), 43 deletions(-) diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts index a635f5f2c5..0282a4ce7c 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.spec.ts @@ -353,10 +353,9 @@ describe("shadow scene lighting integration", () => { expect(terrain.castShadow).toBe(true); expect(terrain.receiveShadow).toBe(true); expect((terrain.material as THREE.Material).shadowSide).toBeNull(); - // Terrain acne is answered with a slope-scaled offset in its depth pass. - expect( - (terrain.customDepthMaterial as THREE.MeshDepthMaterial).polygonOffset - ).toBe(true); + // The open heightfield keeps the standard depth pass; acne is absorbed by + // the texel-scaled receiver normal bias, not a custom depth material. + expect(terrain.customDepthMaterial).toBeUndefined(); expect(buildingCopy.parent?.parent).toBe(scene); expect(terrain.parent).toBe(scene); diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index 8b2df42394..720396a235 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -172,19 +172,6 @@ export const solarPositionToSceneDirection = ({ ).normalize(); }; -/** - * Slope-scaled depth bias for the terrain's shadow pass. GL polygon offset - * grows with the surface's depth slope, which is exactly where heightfield - * acne comes from, so the open terrain needs no large constant bias. - */ -const buildTerrainDepthMaterial = () => - new THREE.MeshDepthMaterial({ - depthPacking: THREE.RGBADepthPacking, - polygonOffset: true, - polygonOffsetFactor: 2, - polygonOffsetUnits: 4, - }); - const makeMeshShadeable = ( mesh: THREE.Mesh, shadowController?: TiledShadowController @@ -195,14 +182,12 @@ const makeMeshShadeable = ( const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; - if (mesh.userData.isShadowTerrainSurface) { - if (!mesh.customDepthMaterial) { - mesh.customDepthMaterial = buildTerrainDepthMaterial(); - } - } else { - // Closed solids write their far walls into the depth map. The stored - // depth then sits behind the lit wall, which removes both self-shadow - // acne and the bright leak line along the building's base in one go. + // Closed solids write their far walls into the depth map. The stored depth + // then sits behind the lit wall, which removes both self-shadow acne and + // the bright leak line along the building's base in one go. The terrain is + // an open heightfield and keeps its front faces; the texel-scaled normal + // bias absorbs its acne. + if (!mesh.userData.isShadowTerrainSurface) { for (const material of materials) { material.shadowSide = THREE.BackSide; } diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts index 9c59276c21..92ee1c244d 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -31,13 +31,18 @@ const LIGHT_CAMERA_SAFETY_METERS = 25; */ export const CASTER_RELIEF_MARGIN_METERS = 300; /** - * One fixed normal bias instead of the former per-texel battery. Acne is - * handled where it comes from: buildings render their far walls into the - * depth map (`shadowSide: BackSide`), terrain gets a slope-scaled polygon - * offset in its depth pass. What remains is a small constant to absorb - * filter-kernel wobble. + * Acne control, the standard way: a receiver-side normal offset proportional + * to the world size of one shadow texel. One texel is exactly the sampling + * error the depth comparison has to absorb; scaling with it stays correct + * whether the buffer spans a courtyard or a valley. Unlike a slope-scaled + * depth offset it cannot blow up at grazing sun angles - that is what + * punched lit holes into self-shadowed hillsides. Buildings additionally + * render their far walls into the depth map (`shadowSide: BackSide`), which + * keeps their bases free of leak lines at any bias. */ -const SHADOW_NORMAL_BIAS_METERS = 0.1; +const SHADOW_NORMAL_BIAS_TEXELS = 1.2; +const MIN_SHADOW_NORMAL_BIAS_METERS = 0.05; +const MAX_SHADOW_NORMAL_BIAS_METERS = 8; const SHADOW_FILTER_RADIUS = 1; export type TiledShadowTileSnapshot = Readonly<{ @@ -346,7 +351,7 @@ export class TiledShadowController { light.shadow.needsUpdate = true; light.shadow.radius = SHADOW_FILTER_RADIUS; light.shadow.bias = 0; - light.shadow.normalBias = SHADOW_NORMAL_BIAS_METERS; + light.shadow.normalBias = MIN_SHADOW_NORMAL_BIAS_METERS; } } @@ -587,13 +592,26 @@ export class TiledShadowController { shadowCamera.right = centerX + fittedWidth / 2; shadowCamera.bottom = centerY - fittedHeight / 2; shadowCamera.top = centerY + fittedHeight / 2; + // Casters that shade the view stand between it and the sun, which in + // light space means at SMALLER depth than the receivers. The reach + // therefore extends the near plane; extending far - as this used to - + // clipped every up-sun ridge out of the depth buffer and punched lit + // holes into the shadow. (The tiled layout always had this right.) shadowCamera.near = Math.max( 0.01, - receiverBounds.near - LIGHT_CAMERA_SAFETY_METERS + receiverBounds.near - + casterReachMeters - + reliefMeters - + LIGHT_CAMERA_SAFETY_METERS ); shadowCamera.far = Math.max( shadowCamera.near + 1, - receiverBounds.far + casterReachMeters + reliefMeters + receiverBounds.far + reliefMeters + LIGHT_CAMERA_SAFETY_METERS + ); + light.shadow.normalBias = THREE.MathUtils.clamp( + texelMeters * SHADOW_NORMAL_BIAS_TEXELS, + MIN_SHADOW_NORMAL_BIAS_METERS, + MAX_SHADOW_NORMAL_BIAS_METERS ); shadowCamera.updateProjectionMatrix(); light.shadow.updateMatrices(light); @@ -682,6 +700,11 @@ export class TiledShadowController { shadowCamera.top = tile.top; shadowCamera.near = Math.max(0.01, tile.near); shadowCamera.far = Math.max(shadowCamera.near + 1, tile.far); + light.shadow.normalBias = THREE.MathUtils.clamp( + layout.statistics.effectiveMetersPerTexel * SHADOW_NORMAL_BIAS_TEXELS, + MIN_SHADOW_NORMAL_BIAS_METERS, + MAX_SHADOW_NORMAL_BIAS_METERS + ); shadowCamera.updateProjectionMatrix(); light.shadow.updateMatrices(light); light.shadow.needsUpdate = true; diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts index 666bc231ca..ec5865ba10 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -262,11 +262,7 @@ describe("buildCesiumTerrainRuntime", () => { castShadow: boolean; receiveShadow: boolean; material: { side: number; shadowSide: number | null }; - customDepthMaterial: { - polygonOffset: boolean; - polygonOffsetFactor: number; - polygonOffsetUnits: number; - }; + customDepthMaterial?: unknown; geometry: { getAttribute: (name: string) => { count: number } }; }; expect(mesh.castShadow).toBe(true); @@ -274,9 +270,9 @@ describe("buildCesiumTerrainRuntime", () => { expect(mesh.material).toBeInstanceOf(MeshLambertMaterial); expect(mesh.material.side).toBe(FrontSide); expect(mesh.material.shadowSide).toBe(FrontSide); - expect(mesh.customDepthMaterial.polygonOffset).toBe(true); - expect(mesh.customDepthMaterial.polygonOffsetFactor).toBe(2); - expect(mesh.customDepthMaterial.polygonOffsetUnits).toBe(4); + // Standard depth pass: acne control lives in the light's texel-scaled + // normal bias, not in a per-mesh depth material. + expect(mesh.customDepthMaterial).toBeUndefined(); expect(mesh.geometry.getAttribute("position").count).toBe(4); expect(source.requestTile).toHaveBeenCalledWith(tileId); expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); @@ -625,7 +621,7 @@ describe("buildCesiumTerrainRuntime", () => { } expect(reliefSourceMesh.castShadow).toBe(true); expect(reliefSourceMesh.receiveShadow).toBe(true); - expect(reliefSourceMesh.customDepthMaterial).toBeDefined(); + expect(reliefSourceMesh.customDepthMaterial).toBeUndefined(); expect(reliefSourceMesh.geometry.getAttribute("position").count).toBe(4); expect(Array.from(reliefSourceMesh.geometry.getIndex()!.array)).toEqual([ 0, 2, 1, From b3f2b8ea8c762402da4d5b2b7e88d1153150181e Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 02:57:53 +0200 Subject: [PATCH 16/78] feat(geoportal): keep a resident base blanket of the terrain The DEM covers a finite extent, and ground that once streamed in could still be trimmed away later. Tiles at or below a configured level are now persistent: never trimmed, prefetched across the whole dataset extent when the source becomes ready (walking the availability tree, so no bounds metadata is needed), and drawn as backfill - slightly lowered - wherever the active selection has nothing finer, stepping aside only for quadrant-complete refinement. Superseded in-flight batches feed the same cache. The geoportal opts in at level 15, which blankets the Wuppertal DEM in roughly 1,600 tiles - a few megabytes of quantized-mesh transfer and tens of megabytes of geometry - and raises the refinement ceiling to level 18, the deepest level the dataset publishes. --- .../src/app/constants/fachzwillinge/addons.ts | 8 +- .../cesium-terrain-tile-runtime.ts | 208 ++++++++++++++++-- 2 files changed, 199 insertions(+), 17 deletions(-) diff --git a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts index 32a349deca..bcf574af98 100644 --- a/apps/geoportal/src/app/constants/fachzwillinge/addons.ts +++ b/apps/geoportal/src/app/constants/fachzwillinge/addons.ts @@ -74,7 +74,13 @@ export const addonsFachzwilling: FachzwillingRoute = { // frame). 3 is the value the fast state used. shadowLevelOffset: 3, minimumLevel: 15, - maximumLevel: 17, + // The level-15 blanket of the whole DEM (~1,600 tiles, tens of MB) + // stays resident once loaded, so ground between the view and the + // sun never unloads again. + persistentBaseLevel: 15, + // The dataset publishes availability down to level 18 (~48 m tiles + // with sub-metre sampling); let refinement use all of it. + maximumLevel: 18, noDataHeightMeters: 0, maxSelectionTiles: 1_536, requestConcurrency: 12, diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index a2df27d473..291d1afbd7 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -6,7 +6,6 @@ import { Group, Matrix4, Mesh, - MeshDepthMaterial, MeshLambertMaterial, Vector3, type BufferGeometry, @@ -43,9 +42,16 @@ const DEFAULT_MAXIMUM_LEVEL = 17; const DEFAULT_MAX_SELECTION_TILES = 192; const DEFAULT_REQUEST_CONCURRENCY = 6; const DEFAULT_MAX_CACHED_MESHES = 256; +const MAX_PERSISTENT_BASE_TILES = 4_096; +/** + * Backfill sits a hair below the true surface so an active finer tile that + * partially overlaps its persistent parent wins the depth test cleanly + * instead of z-fighting. Invisible at terrain scale. + */ +const PERSISTENT_BACKFILL_LOWERING_METERS = 1; +const PERSISTENT_BASE_PREFETCH_CONCURRENCY = 2; +const PERSISTENT_BASE_REPAINT_BATCH = 64; const ZERO_ELEVATION_EPSILON_METERS = 1e-3; -const TERRAIN_SHADOW_POLYGON_OFFSET_FACTOR = 2; -const TERRAIN_SHADOW_POLYGON_OFFSET_UNITS = 4; const VIEWPORT_COVERAGE_PADDING_FACTOR = 0.25; export type CesiumTerrainMaterialOptions = Readonly<{ @@ -61,6 +67,17 @@ export type CesiumTerrainRuntimeOptions = Readonly<{ requestConcurrency?: number; maxCacheBytes?: number; maxCachedMeshes?: number; + /** + * Tiles at or below this level stay in the scene once downloaded: they are + * never trimmed and keep drawing as backfill wherever no finer tile is + * active, so ground the camera or the sun once reached never vanishes + * again. On becoming ready the runtime also prefetches this level across + * the whole dataset extent in the background (bounded by an enumeration + * cap for sources without a finite extent). Off unless configured; for the + * Wuppertal DEM level 15 blankets the dataset in ~1,600 tiles, in the tens + * of megabytes. + */ + persistentBaseLevel?: number | false; /** Source-specific height that denotes missing terrain coverage. */ noDataHeightMeters?: number; material?: CesiumTerrainMaterialOptions; @@ -102,6 +119,9 @@ type TerrainMeshRecord = { baseMesh: Mesh | null; boundaryEdges: TerrainBoundaryEdges; lastUsed: number; + id: CesiumTerrainTileId; + /** Never trimmed; keeps drawing as backfill where nothing finer is active. */ + persistent: boolean; }; type TerrainBoundarySide = "west" | "south" | "east" | "north"; @@ -461,6 +481,11 @@ export const buildCesiumTerrainRuntime = ( DEFAULT_MAX_CACHED_MESHES, 1 ); + const persistentBaseLevel = + options.persistentBaseLevel === undefined || + options.persistentBaseLevel === false + ? null + : clampInteger(options.persistentBaseLevel, minimumLevel, 0); if ( options.noDataHeightMeters !== undefined && !Number.isFinite(options.noDataHeightMeters) @@ -484,12 +509,6 @@ export const buildCesiumTerrainRuntime = ( coverageMaterial.polygonOffset = true; coverageMaterial.polygonOffsetFactor = 1; coverageMaterial.polygonOffsetUnits = 1; - const shadowDepthMaterial = new MeshDepthMaterial({ - polygonOffset: true, - polygonOffsetFactor: TERRAIN_SHADOW_POLYGON_OFFSET_FACTOR, - polygonOffsetUnits: TERRAIN_SHADOW_POLYGON_OFFSET_UNITS, - }); - shadowDepthMaterial.name = `${runtimeId}-shadow-depth`; const sourcePromise = acquireCesiumTerrainTileSource(terrainUrl, { maxCacheBytes: options.maxCacheBytes, }); @@ -726,7 +745,6 @@ export const buildCesiumTerrainRuntime = ( reliefMesh.name = `${node.name}-relief`; reliefMesh.castShadow = true; reliefMesh.receiveShadow = true; - reliefMesh.customDepthMaterial = shadowDepthMaterial; node.add(reliefMesh); } node.visible = false; @@ -747,6 +765,11 @@ export const buildCesiumTerrainRuntime = ( baseMesh, boundaryEdges, lastUsed: ++meshUseClock, + id: entry.id, + persistent: + entry.kind === "source" && + persistentBaseLevel !== null && + entry.id.level <= persistentBaseLevel, }); return node; }; @@ -877,17 +900,97 @@ export const buildCesiumTerrainRuntime = ( for (const attribute of updatedAttributes) attribute.needsUpdate = true; }; + let activeMeshKeys: ReadonlySet = new Set(); + + /** + * Which persistent base tiles the active selection covers completely. + * Counted in exact integer units of the deepest level, so a base tile only + * steps aside when every one of its quadrants is refined; a partially + * refined one keeps drawing as backfill underneath (slightly lowered, so + * the finer tiles win the depth test). + */ + const fullyCoveredBaseKeys = (): Set => { + const covered = new Set(); + if (persistentBaseLevel === null) return covered; + const units = new Map(); + let deepestLevel = persistentBaseLevel; + for (const key of activeMeshKeys) { + const record = meshes.get(key); + if (record && record.id.level > deepestLevel) { + deepestLevel = record.id.level; + } + } + const fullUnits = 4 ** (deepestLevel - persistentBaseLevel); + for (const key of activeMeshKeys) { + const record = meshes.get(key); + if (!record) continue; + const { level, x, y } = record.id; + if (level < persistentBaseLevel) { + // Coarser than the base: it stands in for all its base descendants. + const span = 2 ** (persistentBaseLevel - level); + for (let dy = 0; dy < span; dy += 1) { + for (let dx = 0; dx < span; dx += 1) { + covered.add( + cesiumTerrainTileKey({ + level: persistentBaseLevel, + x: x * span + dx, + y: y * span + dy, + }) + ); + } + } + continue; + } + const shift = level - persistentBaseLevel; + const baseKey = cesiumTerrainTileKey({ + level: persistentBaseLevel, + x: x >> shift, + y: y >> shift, + }); + units.set( + baseKey, + (units.get(baseKey) ?? 0) + 4 ** (deepestLevel - level) + ); + } + for (const [baseKey, sum] of units) { + if (sum >= fullUnits) covered.add(baseKey); + } + return covered; + }; + + const applyMeshVisibility = () => { + const covered = fullyCoveredBaseKeys(); + for (const [key, record] of meshes) { + const active = activeMeshKeys.has(key); + const backfill = + !active && + record.persistent && + !covered.has(cesiumTerrainTileKey(record.id)); + record.node.visible = root.visible && (active || backfill); + record.node.position.y = backfill + ? -PERSISTENT_BACKFILL_LOWERING_METERS + : 0; + record.node.updateMatrixWorld(); + } + }; + const trimMeshCache = (activeKeys: ReadonlySet) => { - if (meshes.size <= maxCachedMeshes) return; + let persistentCount = 0; + for (const record of meshes.values()) { + if (record.persistent) persistentCount += 1; + } + let excess = meshes.size - persistentCount - maxCachedMeshes; + if (excess <= 0) return; const candidates = [...meshes.entries()] - .filter(([key]) => !activeKeys.has(key)) + .filter(([key, record]) => !record.persistent && !activeKeys.has(key)) .sort(([, left], [, right]) => left.lastUsed - right.lastUsed); for (const [key, record] of candidates) { + if (excess <= 0) break; root.remove(record.node); record.reliefMesh?.geometry.dispose(); record.baseMesh?.geometry.dispose(); meshes.delete(key); - if (meshes.size <= maxCachedMeshes) break; + excess -= 1; } }; @@ -1204,8 +1307,12 @@ export const buildCesiumTerrainRuntime = ( ensureMesh(tile, entry).visible = root.visible; } smoothActiveBoundaryNormals(activeKeys); - for (const [key, record] of meshes) { - record.node.visible = root.visible && activeKeys.has(key); + activeMeshKeys = activeKeys; + applyMeshVisibility(); + for (const record of meshes.values()) { + if (record.persistent) { + retainedSourceKeys.add(cesiumTerrainTileKey(record.id)); + } } terrainSource.trimCache(retainedSourceKeys); trimMeshCache(activeKeys); @@ -1229,6 +1336,74 @@ export const buildCesiumTerrainRuntime = ( }); }; + /** + * Walk the availability tree down from the two level-0 roots and collect + * every available tile of the requested level: the dataset's own extent, + * without needing bounds metadata (the layer.json bounds of this DEM claim + * half the globe). + */ + const enumerateAvailableTiles = ( + terrainSource: CesiumTerrainTileSource, + level: number + ): CesiumTerrainTileId[] => { + let frontier: CesiumTerrainTileId[] = [ + { level: 0, x: 0, y: 0 }, + { level: 0, x: 1, y: 0 }, + ].filter((id) => terrainSource.getTileDataAvailable(id) === true); + for (let current = 0; current < level; current += 1) { + const next: CesiumTerrainTileId[] = []; + // A source claiming availability everywhere would make this walk the + // whole planet; a dataset that big has no meaningful blanket anyway. + if (frontier.length > MAX_PERSISTENT_BASE_TILES) return []; + for (const parent of frontier) { + for (let dy = 0; dy < 2; dy += 1) { + for (let dx = 0; dx < 2; dx += 1) { + const child = { + level: parent.level + 1, + x: parent.x * 2 + dx, + y: parent.y * 2 + dy, + }; + if (terrainSource.getTileDataAvailable(child) === true) { + next.push(child); + } + } + } + } + frontier = next; + } + return frontier; + }; + + const prefetchPersistentBase = (terrainSource: CesiumTerrainTileSource) => { + if (persistentBaseLevel === null) return; + const ids = enumerateAvailableTiles(terrainSource, persistentBaseLevel); + if (ids.length === 0 || ids.length > MAX_PERSISTENT_BASE_TILES) return; + let sinceRepaint = 0; + void loadWithConcurrency(ids, PERSISTENT_BASE_PREFETCH_CONCURRENCY, (id) => + terrainSource + .requestTile(id) + .then((tile) => { + if (disposed) return; + ensureMesh(tile, { id, kind: "source" }).visible = false; + sinceRepaint += 1; + if (sinceRepaint >= PERSISTENT_BASE_REPAINT_BATCH) { + sinceRepaint = 0; + applyMeshVisibility(); + options.onContentChanged?.(); + map?.triggerRepaint(); + } + }) + .catch(() => { + // A missing base tile only means no backfill there. + }) + ).then(() => { + if (disposed) return; + applyMeshVisibility(); + options.onContentChanged?.(); + map?.triggerRepaint(); + }); + }; + void sourcePromise .then((terrainSource) => { if (disposed) return; @@ -1241,6 +1416,7 @@ export const buildCesiumTerrainRuntime = ( ); map.triggerRepaint(); } + prefetchPersistentBase(terrainSource); }) .catch((error) => { if (disposed) return; @@ -1297,6 +1473,7 @@ export const buildCesiumTerrainRuntime = ( } else { requestedSignature = ""; selectionInputSignature = ""; + applyMeshVisibility(); } map?.triggerRepaint(); }, @@ -1356,7 +1533,6 @@ export const buildCesiumTerrainRuntime = ( material.dispose(); coverageMesh?.geometry.dispose(); coverageMaterial.dispose(); - shadowDepthMaterial.dispose(); root.clear(); map = null; settleReady(false); From 1132b18aba0826a3f88b1d1b842c13b7894ae39b Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 03:14:56 +0200 Subject: [PATCH 17/78] feat(geoportal): sample the sun as a disc for real penumbras The sun subtends about half a degree, so a real shadow sharpens at the caster's foot and widens with every metre of throw - a chimney's shadow tip hundreds of metres out is visibly soft. The single buffer's one point light rendered it razor sharp for its whole length. Classic PCSS is off the table with Three's hardware-compare shadow samplers (no raw depths to blocker-search), so this uses the other established area-light method: the four lights the controller already owns become four samples on the solar disc - a fixed rotated-square pattern at the disc's RMS radius, quarter intensity each, every sample fitted to the same receiver box and smoothed by its own hardware PCF. Around three dozen effective taps; the per-sample buffers drop to half resolution, which the penumbra hides, keeping the texel budget flat. Optional as 'Weiche Schatten' in the settings (default on), and only active while the camera rests: a gesture folds the samples back into one full-intensity light, moveend fans them out again. The projection debug panel also renders again: it only draws once a snapshot arrives, and at rest nothing published one - opening the panel now requests a snapshot instead of waiting for the next camera move. --- .../src/addons/ShadowSimulation/index.tsx | 30 +++++ .../addons/ShadowSimulation/shadow-scene.ts | 18 +++ .../tiled-shadow-controller.ts | 123 ++++++++++++++++-- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index 7de4f25c23..e9ae4fc4e5 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -131,6 +131,8 @@ export type ShadowSimulationState = { showSunDebugVector: boolean; showProjectionDebugView?: boolean; showShadowBuffers?: boolean; + /** Sample the sun as a disc for realistic, distance-widening penumbras. */ + softSunShadows?: boolean; useTransmittanceLut?: boolean; useSkyIrradianceLut?: boolean; controlStyle?: ShadowControlStyle; @@ -608,6 +610,21 @@ const ShadowQuickSettings = ({ {Math.round(intensity * 100)}% + @@ -810,6 +827,18 @@ const ShadowSimulationRuntime = ({ ); }, [state.enabled, state.shadowMode, sceneRevision]); + useEffect(() => { + if (!state.enabled) return; + shadowScene.current?.updateSoftSunShadows(state.softSunShadows ?? true); + }, [state.enabled, state.softSunShadows, sceneRevision]); + + // The projection debug panel only renders once a snapshot arrives, and at + // rest nothing publishes one; opening the panel therefore asks for one. + useEffect(() => { + if (!state.enabled || !state.showProjectionDebugView) return; + shadowScene.current?.refreshProjectionDebug(); + }, [state.enabled, state.showProjectionDebugView, sceneRevision]); + useEffect(() => { if (!state.enabled) return; shadowScene.current?.updateShadowIntensity(state.shadowIntensity ?? 1); @@ -905,6 +934,7 @@ export const ShadowSimulation = ({ showSunDebugVector: false, showShadowBuffers: false, showProjectionDebugView: false, + softSunShadows: true, useTransmittanceLut: true, useSkyIrradianceLut: true, controlStyle: SHADOW_CONTROL_STYLE.QUICK, diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts index 720396a235..bb5b0aae7c 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/shadow-scene.ts @@ -150,6 +150,10 @@ export type ShadowSimulationScene = { updateBuildingAppearance: (appearance: ShadowBuildingAppearance) => void; updateShadowQuality: (quality: ShadowQualityMultiplier) => void; updateShadowMode: (mode: ShadowMode) => void; + /** Sample the sun as a disc for distance-widening penumbras (single mode). */ + updateSoftSunShadows: (enabled: boolean) => void; + /** Ask for a fresh projection-debug snapshot, e.g. when the panel opens. */ + refreshProjectionDebug: () => void; updateShadowIntensity: (intensity: number) => void; updateSunDebugVectorVisibility: (visible: boolean) => void; updateShadowBufferDebugVisibility: (visible: boolean) => void; @@ -1236,6 +1240,9 @@ export const buildShadowSimulationScene = ( const handleMoveStart = () => { mapInMotion = true; terrainRuntime?.setInteractive(true); + // The next dirty update runs with interactive=true and folds the disc + // samples back into one light for the duration of the gesture. + sharedBinding.dirty = true; }; const handleMove = () => updateSharedShadowCoverage(false); const handleMoveEnd = () => { @@ -1384,6 +1391,17 @@ export const buildShadowSimulationScene = ( updateSharedShadowCoverage(); sharedBinding.controller.invalidate(); }, + updateSoftSunShadows(enabled) { + sharedBinding.controller.setSoftSun(enabled); + sharedBinding.controller.invalidate(); + sharedBinding.dirty = true; + map.triggerRepaint(); + }, + refreshProjectionDebug() { + lastDebugPublishMs = 0; + sharedBinding.dirty = true; + map.triggerRepaint(); + }, updateShadowIntensity(intensity) { latestShadowIntensity = THREE.MathUtils.clamp(intensity, 0, 1); sharedBinding.shadowIntensity = latestShadowIntensity; diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts index 92ee1c244d..fc695302e8 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/tiled-shadow-controller.ts @@ -41,6 +41,22 @@ export const CASTER_RELIEF_MARGIN_METERS = 300; * keeps their bases free of leak lines at any bias. */ const SHADOW_NORMAL_BIAS_TEXELS = 1.2; +/** + * The sun is a disc of about 0.53 degrees, not a point. Sampling that disc + * with several jittered shadow passes and summing quarter-intensity lights is + * the classic area-light method: the penumbra widens with distance from the + * caster, exactly as a chimney's shadow does in reality. Four samples on a + * fixed rotated-square pattern at the disc's RMS radius, each additionally + * smoothed by hardware PCF, come out at roughly three dozen effective taps. + */ +const SUN_ANGULAR_RADIUS_RAD = THREE.MathUtils.degToRad(0.53 / 2); +const SUN_DISC_SAMPLE_PATTERN: ReadonlyArray = ( + [45, 135, 225, 315] as const +).map((degrees) => { + const radians = THREE.MathUtils.degToRad(degrees + 22.5); + const radius = SUN_ANGULAR_RADIUS_RAD * Math.SQRT1_2; + return [Math.cos(radians) * radius, Math.sin(radians) * radius] as const; +}); const MIN_SHADOW_NORMAL_BIAS_METERS = 0.05; const MAX_SHADOW_NORMAL_BIAS_METERS = 8; const SHADOW_FILTER_RADIUS = 1; @@ -317,6 +333,7 @@ export class TiledShadowController { ); private activeTileCount = 0; private mode: ShadowMode = "advanced"; + private softSun = false; private disposed = false; constructor(scene: THREE.Scene, camera = new THREE.PerspectiveCamera()) { @@ -430,6 +447,25 @@ export class TiledShadowController { } } + /** + * Sample the sun as a disc (single mode only). Expensive - one depth pass + * per sample - so the caller keeps it off while the camera moves. + */ + setSoftSun(enabled: boolean): void { + if (this.disposed || this.softSun === enabled) return; + this.softSun = enabled; + if (!enabled && this.mode === "single") { + for (let index = 1; index < this.lights.length; index += 1) { + const light = this.lights[index]; + light.visible = false; + light.castShadow = false; + light.intensity = 0; + light.shadow.map?.dispose(); + light.shadow.map = null; + } + } + } + invalidate(): void { for (let index = 0; index < this.activeTileCount; index += 1) { this.lights[index].shadow.needsUpdate = true; @@ -558,15 +594,35 @@ export class TiledShadowController { ); if (!receiverBounds) return null; if (this.mode === "single") { + // Disc sampling spreads the texel budget over several buffers; the + // soft penumbra hides the halved per-buffer resolution. + const softSampling = this.softSun && !interactive; + const sampleCount = softSampling ? SUN_DISC_SAMPLE_PATTERN.length : 1; + const sampleMapSize = softSampling ? Math.max(256, mapSize / 2) : mapSize; const light = this.lights[0]; const shadowCamera = light.shadow.camera; + for (let index = 0; index < this.lights.length; index += 1) { + const sampleLight = this.lights[index]; + const active = index < sampleCount; + sampleLight.visible = active; + sampleLight.castShadow = active; + if ( + active && + (sampleLight.shadow.mapSize.x !== sampleMapSize || + sampleLight.shadow.mapSize.y !== sampleMapSize) + ) { + sampleLight.shadow.map?.dispose(); + sampleLight.shadow.map = null; + sampleLight.shadow.mapSize.set(sampleMapSize, sampleMapSize); + } + } const usableMapWidth = Math.max( 1, - light.shadow.mapSize.x - SHADOW_FILTER_GUARD_TEXELS * 2 + sampleMapSize - SHADOW_FILTER_GUARD_TEXELS * 2 ); const usableMapHeight = Math.max( 1, - light.shadow.mapSize.y - SHADOW_FILTER_GUARD_TEXELS * 2 + sampleMapSize - SHADOW_FILTER_GUARD_TEXELS * 2 ); const receiverWidth = receiverBounds.right - receiverBounds.left; const receiverHeight = receiverBounds.top - receiverBounds.bottom; @@ -578,8 +634,8 @@ export class TiledShadowController { const guardMeters = texelMeters * SHADOW_FILTER_GUARD_TEXELS; // Preserve square world-space texels. The orthographic camera therefore // has exactly the same aspect ratio as its actual shadow-map buffer. - const fittedWidth = texelMeters * light.shadow.mapSize.x; - const fittedHeight = texelMeters * light.shadow.mapSize.y; + const fittedWidth = texelMeters * sampleMapSize; + const fittedHeight = texelMeters * sampleMapSize; const centerX = Math.round( (receiverBounds.left + receiverBounds.right) / 2 / texelMeters @@ -608,25 +664,66 @@ export class TiledShadowController { shadowCamera.near + 1, receiverBounds.far + reliefMeters + LIGHT_CAMERA_SAFETY_METERS ); - light.shadow.normalBias = THREE.MathUtils.clamp( + const normalBias = THREE.MathUtils.clamp( texelMeters * SHADOW_NORMAL_BIAS_TEXELS, MIN_SHADOW_NORMAL_BIAS_METERS, MAX_SHADOW_NORMAL_BIAS_METERS ); shadowCamera.updateProjectionMatrix(); light.shadow.updateMatrices(light); - light.shadow.needsUpdate = true; - this.activeTileCount = 1; - this.tileReceiverUvs[0].set(0, 0, 1, 1); - for (let index = 1; index < this.lights.length; index += 1) { + // A basis across the sun direction to place the disc samples in. + const discTangentA = new THREE.Vector3(); + const discTangentB = new THREE.Vector3(); + if (Math.abs(normalizedDirectionToSun.y) > 0.99) { + discTangentA.set(1, 0, 0); + } else { + discTangentA + .crossVectors(new THREE.Vector3(0, 1, 0), normalizedDirectionToSun) + .normalize(); + } + discTangentB.crossVectors(normalizedDirectionToSun, discTangentA); + const lightDistance = receiverSphere.radius + lightMargin; + for (let index = 0; index < sampleCount; index += 1) { + const sampleLight = this.lights[index]; + const sampleDirection = normalizedDirectionToSun.clone(); + if (softSampling) { + const [offsetA, offsetB] = SUN_DISC_SAMPLE_PATTERN[index]; + sampleDirection + .addScaledVector(discTangentA, offsetA) + .addScaledVector(discTangentB, offsetB) + .normalize(); + } + sampleLight.position + .copy(sampleDirection) + .multiplyScalar(lightDistance) + .add(lightTargetPosition); + sampleLight.target.position.copy(lightTargetPosition); + sampleLight.intensity = intensity / sampleCount; + sampleLight.shadow.normalBias = normalBias; + const sampleCamera = sampleLight.shadow.camera; + sampleCamera.left = shadowCamera.left; + sampleCamera.right = shadowCamera.right; + sampleCamera.bottom = shadowCamera.bottom; + sampleCamera.top = shadowCamera.top; + sampleCamera.near = shadowCamera.near; + sampleCamera.far = shadowCamera.far; + sampleCamera.updateProjectionMatrix(); + sampleLight.updateMatrixWorld(true); + sampleLight.target.updateMatrixWorld(true); + sampleLight.shadow.updateMatrices(sampleLight); + sampleLight.shadow.needsUpdate = true; + this.tileReceiverUvs[index].set(0, 0, 1, 1); + } + this.activeTileCount = sampleCount; + for (let index = sampleCount; index < this.lights.length; index += 1) { this.tileReceiverUvs[index].set(2, 2, -1, -1); this.lights[index].intensity = 0; this.lights[index].shadow.needsUpdate = false; } return { strategy: "single-viewport", - tileCount: 1, - totalShadowTexels: mapSize * mapSize, + tileCount: sampleCount, + totalShadowTexels: sampleCount * sampleMapSize * sampleMapSize, casterReachMeters, tiles: [ { @@ -644,8 +741,8 @@ export class TiledShadowController { topMeters: shadowCamera.top, nearMeters: shadowCamera.near, farMeters: shadowCamera.far, - shadowMapWidth: light.shadow.mapSize.x, - shadowMapHeight: light.shadow.mapSize.y, + shadowMapWidth: sampleMapSize, + shadowMapHeight: sampleMapSize, viewMatrixElements: [...shadowCamera.matrixWorldInverse.elements], projectionMatrixElements: [ ...shadowCamera.projectionMatrix.elements, From d8ef2bdffa9a15d9bb53b263ede7a05545b2c4b9 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 12:11:16 +0200 Subject: [PATCH 18/78] fix(geoportal): the view refines and downloads before the sun coverage Terrain refinement mixed the view's pixel error and the sun coverage's coarser one in a single worst-first heap over one shared tile budget. Once the sweep grew - a low sun, or the day animation swinging the coverage around - its tiles could drain the budget before the viewport finished refining, so ground in plain view sat below its own error target and visibly stepped between quality levels as the sun moved. Refinement now runs in two phases over the same budget: first everything intersecting the viewport, against the view's own target and with first claim on the whole budget; then the sun coverage with whatever is left. The phase-one result depends only on camera and availability, never on the sun, so an animated sun cannot change a single tile in view. The download queue is ordered the same way: in-view tiles first, caster tiles after. A regression spec pins both: under a budget that only fits the viewport split, the viewport reaches level 11 while the coverage stays at its root level, and every in-view request precedes every coverage request. --- .../cesium-terrain-runtime.spec.ts | 134 ++++++++++++ .../cesium-terrain-tile-runtime.ts | 192 ++++++++++-------- 2 files changed, 246 insertions(+), 80 deletions(-) diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts index ec5865ba10..3001670994 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-runtime.spec.ts @@ -723,4 +723,138 @@ describe("buildCesiumTerrainRuntime", () => { runtime.dispose(); }); + + it("refines the viewport to its error target before the sun coverage", async () => { + // One viewport tile and three sun-coverage tiles west of it compete for a + // budget that only fits the viewport split. The view must reach its own + // pixel-error target regardless of how demanding the sun coverage is, and + // its tiles must be first in the download order. + const viewportId = { level: 10, x: 532, y: 218 }; + // A one-tile gap to the viewport keeps edge-touching out of the picture. + const westIds = [ + { level: 10, x: 528, y: 218 }, + { level: 10, x: 529, y: 218 }, + { level: 10, x: 530, y: 218 }, + ]; + const boundsOf = (id: { level: number; x: number; y: number }) => { + const scale = 2 ** (id.level - 10); + const width = 0.1 / scale; + const west = 7.1 + (id.x - 532 * scale) * width; + const north = 51.3 - (id.y - 218 * scale) * width; + return { west, south: north - width, east: west + width, north }; + }; + const intersects = ( + a: { west: number; south: number; east: number; north: number }, + b: { west: number; south: number; east: number; north: number } + ) => a.west < b.east && a.east > b.west && a.south < b.north && a.north > b.south; + const source = { + requestTile: vi.fn(async (id) => ({ + id, + heightMeters: new Float32Array([100]), + westIndices: new Uint32Array(), + southIndices: new Uint32Array(), + eastIndices: new Uint32Array(), + northIndices: new Uint32Array(), + })), + getTileIdsForBounds: vi.fn(() => [viewportId]), + getTileGridIdsForBounds: vi.fn((bounds) => + [...westIds, viewportId].filter((id) => intersects(boundsOf(id), bounds)) + ), + getTileBounds: vi.fn(boundsOf), + getLevelMaximumGeometricError: vi.fn((level) => 200 / 2 ** (level - 10)), + getTileDataAvailable: vi.fn(() => true), + sampleHeight: vi.fn(), + trimCache: vi.fn(), + }; + acquireCesiumTerrainTileSource.mockResolvedValue(source); + const originLngLat: [number, number] = [7.15, 51.25]; + const runtime = buildCesiumTerrainRuntime( + "viewport-priority-terrain", + "https://example.test/viewport-priority-terrain", + originLngLat, + { + minimumLevel: 10, + maximumLevel: 11, + errorTargetPixels: 0.1, + shadowLevelOffset: 0, + // Roots (4) plus the viewport split (net +3) fit; the sun-coverage + // split (net +3 more) must not. + maxSelectionTiles: 7, + } + ); + const map = { + getBounds: vi.fn(() => ({ + getWest: () => 7.1, + getSouth: () => 51.2, + getEast: () => 7.2, + getNorth: () => 51.3, + })), + triggerRepaint: vi.fn(), + }; + runtime.onAdd?.(map as never); + await vi.waitFor(() => { + expect(registerSharedThreeTerrainSampler).toHaveBeenCalled(); + }); + const origin = MercatorCoordinate.fromLngLat(originLngLat, 0); + const meterScale = origin.meterInMercatorCoordinateUnits(); + const westCenter = MercatorCoordinate.fromLngLat([6.85, 51.25], 0); + const shadowCamera = new OrthographicCamera( + -12_000, + 12_000, + 12_000, + -12_000, + 1, + 20_000 + ); + shadowCamera.position.set( + (westCenter.x - origin.x) / meterScale, + 2_000, + (westCenter.y - origin.y) / meterScale + ); + shadowCamera.lookAt( + (westCenter.x - origin.x) / meterScale, + 0, + (westCenter.y - origin.y) / meterScale + ); + shadowCamera.updateProjectionMatrix(); + shadowCamera.updateMatrixWorld(true); + runtime.setShadowCameras([shadowCamera]); + const lodCamera = new PerspectiveCamera(60, 1, 1, 100_000); + lodCamera.position.set(0, 500, 0); + runtime.update({ + map: map as never, + renderCamera: new Camera(), + lodCamera, + lookTarget: new Vector3(), + viewport: new Vector2(1_000, 1_000), + }); + await expect(runtime.ready).resolves.toBe(true); + + const requestedIds = source.requestTile.mock.calls.map(([id]) => id); + // The viewport root split into its level-11 children ... + expect( + requestedIds.filter((id) => id.level === 11 && id.x >> 1 === 532) + ).toHaveLength(4); + // ... while the sun coverage stayed at its root level: the leftover + // budget cannot fit another split. + expect(requestedIds.filter((id) => id.level === 11 && id.x >> 1 !== 532)) + .toHaveLength(0); + for (const westId of westIds) { + expect(requestedIds).toContainEqual(westId); + } + // Download order: everything in view comes before the sun coverage. + const viewportIndices = requestedIds + .map((id, index) => ({ id, index })) + .filter(({ id }) => id.level === 11 || (id.level === 10 && id.x === 532)) + .map(({ index }) => index); + const coverageIndices = requestedIds + .map((id, index) => ({ id, index })) + .filter(({ id }) => id.level === 10 && id.x !== 532) + .map(({ index }) => index); + expect(Math.max(...viewportIndices)).toBeLessThan( + Math.min(...coverageIndices) + ); + + runtime.dispose(); + }); }); diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index 291d1afbd7..70c52aeb22 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -161,7 +161,11 @@ type TerrainSelectionEntry = { type TerrainCandidate = { entry: TerrainSelectionEntry; - errorRatio: number; + /** Error against the view's own pixel target; 0 outside the viewport. */ + viewportErrorRatio: number; + /** Error against the coarser shadow target; 0 outside the sun coverage. */ + shadowErrorRatio: number; + intersectsViewport: boolean; }; type TerrainShadowView = Readonly<{ @@ -1073,103 +1077,115 @@ export const buildCesiumTerrainRuntime = ( ); return { entry, - errorRatio: - entry.kind === "flat" - ? 0 - : Math.max(viewportErrorRatio, shadowErrorRatio), + viewportErrorRatio: entry.kind === "flat" ? 0 : viewportErrorRatio, + shadowErrorRatio: entry.kind === "flat" ? 0 : shadowErrorRatio, + intersectsViewport, }; }; - // A binary max-heap on errorRatio. The refinement loop pops the worst - // tile, splits it and pushes its children; re-sorting the whole array on - // every iteration made the selection quadratic and cost hundreds of - // milliseconds once a shadow camera joined the coverage. - const heap = rootEntries.map(toCandidate); - const heapSwap = (a: number, b: number) => { - const held = heap[a]; - heap[a] = heap[b]; - heap[b] = held; - }; - const heapPush = (candidate: TerrainCandidate) => { - heap.push(candidate); - let index = heap.length - 1; - while (index > 0) { - const parent = (index - 1) >> 1; - if (heap[parent].errorRatio >= heap[index].errorRatio) break; - heapSwap(parent, index); - index = parent; - } - }; - const heapPop = (): TerrainCandidate => { - const top = heap[0]; - const last = heap.pop()!; - if (heap.length > 0) { - heap[0] = last; - let index = 0; + // Refinement runs in two phases over one shared tile budget, each phase a + // binary max-heap on its own error measure (re-sorting per split was + // quadratic and cost hundreds of milliseconds). + // + // Phase one refines everything the viewer actually looks at, against the + // view's own pixel-error target. Phase two spends whatever budget is left + // on the sun's coverage. Ordering them - rather than mixing both errors + // in one heap - is what guarantees a tile in view never sits below its + // target quality just because it also intersects the sun frustum, or + // because thousands of up-sun caster tiles drained the budget first. + const makeHeap = (ratioOf: (candidate: TerrainCandidate) => number) => { + const heap: TerrainCandidate[] = []; + const swap = (a: number, b: number) => { + const held = heap[a]; + heap[a] = heap[b]; + heap[b] = held; + }; + const siftDown = (from: number) => { + let index = from; for (;;) { const left = 2 * index + 1; const right = left + 1; let largest = index; - if ( - left < heap.length && - heap[left].errorRatio > heap[largest].errorRatio - ) { + if (left < heap.length && ratioOf(heap[left]) > ratioOf(heap[largest])) { largest = left; } if ( right < heap.length && - heap[right].errorRatio > heap[largest].errorRatio + ratioOf(heap[right]) > ratioOf(heap[largest]) ) { largest = right; } if (largest === index) break; - heapSwap(largest, index); + swap(largest, index); index = largest; } - } - return top; + }; + return { + get size() { + return heap.length; + }, + push(candidate: TerrainCandidate) { + heap.push(candidate); + let index = heap.length - 1; + while (index > 0) { + const parent = (index - 1) >> 1; + if (ratioOf(heap[parent]) >= ratioOf(heap[index])) break; + swap(parent, index); + index = parent; + } + }, + pop(): TerrainCandidate { + const top = heap[0]; + const last = heap.pop()!; + if (heap.length > 0) { + heap[0] = last; + siftDown(0); + } + return top; + }, + }; }; - for (let index = (heap.length >> 1) - 1; index >= 0; index -= 1) { - // heapify the roots in place - let current = index; - for (;;) { - const left = 2 * current + 1; - const right = left + 1; - let largest = current; - if ( - left < heap.length && - heap[left].errorRatio > heap[largest].errorRatio - ) { - largest = left; - } - if ( - right < heap.length && - heap[right].errorRatio > heap[largest].errorRatio - ) { - largest = right; - } - if (largest === current) break; - heapSwap(largest, current); - current = largest; - } + + const viewportHeap = makeHeap((candidate) => candidate.viewportErrorRatio); + const shadowHeap = makeHeap((candidate) => candidate.shadowErrorRatio); + for (const entry of rootEntries) { + const candidate = toCandidate(entry); + if (candidate.intersectsViewport) viewportHeap.push(candidate); + else shadowHeap.push(candidate); } - while (heap.length > 0) { - const candidate = heapPop(); - if (candidate.errorRatio <= 1) break; - if (candidate.entry.id.level >= maximumLevel) continue; - const children = getRelevantChildren( - terrainSource, - candidate.entry, - viewportBounds, - shadowBounds - ); - if (!children.length) continue; - if (selected.size + children.length - 1 > maxSelectionTiles) continue; - selected.delete(terrainSelectionKey(candidate.entry)); - for (const child of children) { - selected.set(terrainSelectionKey(child), child); - heapPush(toCandidate(child)); + + const refine = ( + heap: ReturnType, + ratioOf: (candidate: TerrainCandidate) => number + ) => { + while (heap.size > 0) { + const candidate = heap.pop(); + if (ratioOf(candidate) <= 1) break; + if (candidate.entry.id.level >= maximumLevel) continue; + const children = getRelevantChildren( + terrainSource, + candidate.entry, + viewportBounds, + shadowBounds + ); + if (!children.length) continue; + if (selected.size + children.length - 1 > maxSelectionTiles) continue; + selected.delete(terrainSelectionKey(candidate.entry)); + for (const child of children) { + selected.set(terrainSelectionKey(child), child); + const childCandidate = toCandidate(child); + // A child that falls outside the viewport (its parent straddled the + // edge) still belongs to the sun coverage and queues for phase two. + if (childCandidate.intersectsViewport) { + viewportHeap.push(childCandidate); + } else { + shadowHeap.push(childCandidate); + } + } } - } + }; + + refine(viewportHeap, (candidate) => candidate.viewportErrorRatio); + refine(shadowHeap, (candidate) => candidate.shadowErrorRatio); if (selPerf) { selPerf.runs += 1; @@ -1178,7 +1194,23 @@ export const buildCesiumTerrainRuntime = ( selPerf.roots += rootEntries.length; selPerf.picked += selected.size; } - const entries = [...selected.values()]; + // What the viewer looks at downloads first: the sweep can queue hundreds + // of up-sun tiles, and they must never sit in front of the view's own. + const entries = [...selected.values()].sort((left, right) => { + const leftInView = boundsIntersect( + terrainSource.getTileBounds(left.id), + viewportBounds + ) + ? 0 + : 1; + const rightInView = boundsIntersect( + terrainSource.getTileBounds(right.id), + viewportBounds + ) + ? 0 + : 1; + return leftInView - rightInView; + }); return { entries, signature: entries.map(terrainSelectionKey).sort().join("|"), From 17a1509253364f70c1631d2523d9735119d64422 Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 13:01:03 +0200 Subject: [PATCH 19/78] fix(geoportal): judge terrain error against the surface, not sea level The screen-space error anchored every tile at elevation zero while the camera stands hundreds of metres above it. For the ground closest to the viewer - the lower frustum edge - that inflated the measured distance several times over, and the reported pixel error came out that many times too small: coarse tiles exactly where the eye is nearest. The metric now samples the loaded terrain for the tile's surface height and keeps measuring to the closest point of the tile's bounding sphere, so a tile partly in the foreground is judged by its near edge rather than its centre. Heights are only known once tiles are loaded, so the first pass still anchors unknown tiles low; when a selection's viewport heights settle into a new signature, the selection re-arms once and the foreground refines against the real surface, then stops re-arming. --- .../cesium-terrain-tile-runtime.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts index 70c52aeb22..b8edfed2e1 100644 --- a/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts +++ b/libraries/mapping/engines/maplibre/src/lib/runtime/integrations/cesium-terrain-tile-runtime.ts @@ -593,18 +593,29 @@ export const buildCesiumTerrainRuntime = ( id: CesiumTerrainTileId ) => { const bounds = terrainSource.getTileBounds(id); + const centerLongitude = (bounds.west + bounds.east) / 2; + const centerLatitude = (bounds.south + bounds.north) / 2; + // Measure against the actual surface, not sea level. The camera stands + // hundreds of metres above y=0 here, so a sea-level anchor inflated the + // distance to the tiles right under it - the foreground at the lower + // frustum edge - by several times, and their error came out that many + // times too small: coarse ground exactly where the viewer is closest. + const centerElevation = + terrainSource.sampleHeight(centerLongitude, centerLatitude) ?? 0; const center = projectToLocalWorld( - (bounds.west + bounds.east) / 2, - (bounds.south + bounds.north) / 2, - 0, + centerLongitude, + centerLatitude, + centerElevation, new Vector3() ); const corner = projectToLocalWorld( bounds.east, bounds.north, - 0, + centerElevation, new Vector3() ); + // Closest point of the tile's bounding sphere, so a tile only partly in + // the foreground is judged by its near edge, not its centre. const radius = center.distanceTo(corner); const distance = Math.max( 1, @@ -1358,6 +1369,11 @@ export const buildCesiumTerrainRuntime = ( activeViewportElevationSignature = selection.viewportElevationSignature; notifySharedThreeTerrainChanged(map); + // Newly learned heights sharpen the screen-space error - the first + // pass had to anchor unknown tiles at sea level. One more selection + // round lets the foreground refine against the real surface; once + // the heights stop changing, this stops re-arming. + selectionInputSignature = ""; } map?.triggerRepaint(); }) From a5726c5fc2a9ec997120e3c5e8e529eafaff5d0e Mon Sep 17 00:00:00 2001 From: Friedrich Hartmann Date: Thu, 27 Aug 2026 13:06:51 +0200 Subject: [PATCH 20/78] feat(geoportal): quality ladder in the main panel, leaner time ribbon The time ribbon drops its little sun-position dial and the made-up 'Schatten x' factor - neither said anything the sun's elevation and azimuth don't. What remains is the two numbers. Shadow quality moves out of the debug panel into the main settings, right under the soft-shadow switch: Niedrig, Mittel, Hoch, Max - buffer edges of 1024, 2048, 4096 and the device's texture limit (8192 on a typical desktop), halved per tile in advanced mode and per disc sample while soft shadows are on. The controller clamps every buffer to the renderer's reported maximum, so 'Max' means exactly what the hardware allows. The debug panel's quality switch uses the same four levels. --- .../ShadowProjectionDebugView.tsx | 22 +++++-- .../src/addons/ShadowSimulation/index.tsx | 64 +++++++++++-------- .../addons/ShadowSimulation/shadow-scene.ts | 17 ++++- .../tiled-shadow-controller.ts | 30 +++++++-- 4 files changed, 93 insertions(+), 40 deletions(-) diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/ShadowProjectionDebugView.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/ShadowProjectionDebugView.tsx index c61ff26f6e..fa82160ea4 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/ShadowProjectionDebugView.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/ShadowProjectionDebugView.tsx @@ -65,7 +65,15 @@ const SHADOW_MODES: readonly { { value: "single", label: "Single Buffer" }, { value: "advanced", label: "Advanced Tiles" }, ]; -const SHADOW_QUALITIES: readonly ShadowQualityMultiplier[] = [1, 4, 16]; +const SHADOW_QUALITIES: ReadonlyArray<{ + label: string; + value: ShadowQualityMultiplier; +}> = [ + { label: "Niedrig", value: 0.25 }, + { label: "Mittel", value: 1 }, + { label: "Hoch", value: 4 }, + { label: "Max", value: 16 }, +]; export type ShadowProjectionDebugSettings = Readonly<{ shadowMode: ShadowMode; @@ -270,15 +278,15 @@ const ShadowDebugControls = ({ Qualität
- {SHADOW_QUALITIES.map((quality) => ( + {SHADOW_QUALITIES.map(({ label, value }) => ( ))}
diff --git a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx index e9ae4fc4e5..059ff11d75 100644 --- a/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx +++ b/libraries/mapping/addons/src/addons/ShadowSimulation/index.tsx @@ -230,6 +230,20 @@ const QUICK_BUTTON_CLASS_NAME = const SEGMENT_BUTTON_CLASS_NAME = "h-9 whitespace-nowrap border-r border-neutral-300 px-4 text-sm text-neutral-700 transition-colors last:border-r-0 hover:text-amber-700"; +/** + * The quality ladder: buffer edges 1024 / 2048 / 4096 / up to the device's + * texture limit (8192 on typical desktops). + */ +const SHADOW_QUALITY_LEVELS: ReadonlyArray<{ + label: string; + value: ShadowQualityMultiplier; +}> = [ + { label: "Niedrig", value: 0.25 }, + { label: "Mittel", value: 1 }, + { label: "Hoch", value: 4 }, + { label: "Max", value: 16 }, +]; + const pad2 = (value: number) => String(value).padStart(2, "0"); const formatMinutes = (minutes: number) => { @@ -290,7 +304,6 @@ const ShadowSimulationRibbon = ({ () => getSolarPosition(state.selection, location), [location, state.selection] ); - const intensity = state.shadowIntensity ?? 1; const minimumMinutes = Math.ceil(daylight.sunriseMinutes); const maximumMinutes = Math.floor(daylight.sunsetMinutes); const selectedDate = useMemo( @@ -408,36 +421,12 @@ const ShadowSimulationRibbon = ({ -