UNPKG

@giro3d/giro3d

Version:

A JS/WebGL framework for 3D geospatial data visualization

997 lines (942 loc) 37.7 kB
/* * Copyright (c) 2015-2018, IGN France. * Copyright (c) 2018-2026, Giro3D team. * SPDX-License-Identifier: MIT */ import { Matrix4 } from 'three'; import { Uint16BufferAttribute, Uint32BufferAttribute } from 'three'; import { Box3, Box3Helper, CameraHelper, DataTexture, DepthTexture, DoubleSide, EventDispatcher, Float32BufferAttribute, FloatType, Group, MathUtils, Mesh, MeshBasicMaterial, MeshStandardMaterial, OrthographicCamera, Plane, PlaneGeometry, RedFormat, Sphere, Triangle, UnsignedIntType, Vector2, Vector3, WebGLRenderTarget } from 'three'; import { BufferGeometryUtils, Lut, MeshSurfaceSampler } from 'three/examples/jsm/Addons.js'; import ColorMap from '../core/ColorMap'; import Coordinates from '../core/geographic/Coordinates'; import CoordinateSystem from '../core/geographic/CoordinateSystem'; import Extent from '../core/geographic/Extent'; import Sun from '../core/geographic/Sun'; import OperationCounter from '../core/OperationCounter'; import TypedArrayVector from '../core/TypedArrayVector'; import { Vector3Array } from '../core/VectorArray'; import { isEntity3D } from '../entities/Entity3D'; import { isMap } from '../entities/Map'; import PointCloud from '../entities/PointCloud'; import StaticPointCloudSource from '../sources/StaticPointCloudSource'; import { isInstancedMesh, isMesh } from '../utils/predicates'; import PromiseUtils from '../utils/PromiseUtils'; import TextureGenerator from '../utils/TextureGenerator'; import { nonNull } from '../utils/tsutils'; const DEFAULT_COLOR_MAP = new ColorMap({ colors: new Lut('rainbow').lut, min: 0, max: 1 }); /** * The names of the computed variables. * - `meanIrradiance` (in Watts/square meter) is the mean irradiance received by the surface over the * time period. * - `irradiation` (in Watt-hours/square meter) is the cumulated energy received by the surface over * the time period * - `hoursOfSunlight` is the total number of hours that the surface was exposed to direct sunlight. */ const attributeDescriptors = { meanIrradiance: { name: 'meanIrradiance', dimension: 1, interpretation: 'unknown', size: 4, type: 'float' }, irradiation: { name: 'irradiation', dimension: 1, interpretation: 'unknown', size: 4, type: 'float' }, hoursOfSunlight: { name: 'hoursOfSunlight', dimension: 1, interpretation: 'unknown', size: 4, type: 'float' } }; const temp = { x: new Vector3(), y: new Vector3(), z: new Vector3(), sphere: new Sphere(), coordinates: new Coordinates(CoordinateSystem.unknown, 0, 0), dimensions: new Vector2(), box: new Box3(), plane: new Plane(), position: new Vector3(), normal: new Vector3(), matrix: new Matrix4() }; /** * The solar constant, in Watts / m². * Taken from https://en.wikipedia.org/wiki/Solar_constant */ export const SOLAR_CONSTANT = 1361; /** * The amount of solar energy that is absorbed by the atmosphere. * 25% is typical for a clear sky. */ export const ATMOSPHERIC_ABSORPTION = 0.25; /** * The result of the computation. */ function createSimulationStep(observer, date, duration, isGlobe) { const sunDirection = isGlobe ? Sun.getDirection(date) : Sun.getLocalFrameDirection(observer, date); return { date, sunDirection, duration }; } function getBoxCorners(box) { const c0 = new Vector3(box.min.x, box.min.y, box.min.z); const c1 = new Vector3(box.min.x, box.min.y, box.max.z); const c2 = new Vector3(box.max.x, box.min.y, box.min.z); const c3 = new Vector3(box.max.x, box.min.y, box.max.z); const c4 = new Vector3(box.max.x, box.max.y, box.min.z); const c5 = new Vector3(box.max.x, box.max.y, box.max.z); const c6 = new Vector3(box.min.x, box.max.y, box.min.z); const c7 = new Vector3(box.min.x, box.max.y, box.max.z); return [c0, c1, c2, c3, c4, c5, c6, c7]; } function createSimulationSteps(observer, start, end, stepDurationSeconds, isGlobe) { const result = []; if (end != null) { const interval = end.valueOf() - start.valueOf(); let current = 0; while (current < interval) { const date = new Date(start.getTime() + current); result.push(createSimulationStep(observer, date, stepDurationSeconds, isGlobe)); current += stepDurationSeconds * 1000; } result.push(createSimulationStep(observer, end, stepDurationSeconds, isGlobe)); } else { result.push(createSimulationStep(observer, start, stepDurationSeconds, isGlobe)); } return result; } function iterateTriangles(objects, callback) { const tri = new Triangle(new Vector3(), new Vector3(), new Vector3()); const visitor = obj => { if (isMesh(obj)) { const geom = obj.geometry; const material = Array.isArray(obj.material) ? obj.material[0] : obj.material; if (!material.visible) { return; } const positions = geom.getAttribute('position'); if (geom.index == null) { for (let i = 0; i < positions.count; i += 3) { const ia = i + 0; const ib = i + 1; const ic = i + 2; tri.a.set(positions.getX(ia), positions.getY(ia), positions.getZ(ia)); tri.b.set(positions.getX(ib), positions.getY(ib), positions.getZ(ib)); tri.c.set(positions.getX(ic), positions.getY(ic), positions.getZ(ic)); tri.a.applyMatrix4(obj.matrixWorld); tri.b.applyMatrix4(obj.matrixWorld); tri.c.applyMatrix4(obj.matrixWorld); callback(tri, obj.matrixWorld, material.side); } } else { const indices = geom.index.array; for (let i = 0; i < indices.length; i += 3) { const ia = indices[i + 0]; const ib = indices[i + 1]; const ic = indices[i + 2]; tri.a.set(positions.getX(ia), positions.getY(ia), positions.getZ(ia)); tri.b.set(positions.getX(ib), positions.getY(ib), positions.getZ(ib)); tri.c.set(positions.getX(ic), positions.getY(ic), positions.getZ(ic)); tri.a.applyMatrix4(obj.matrixWorld); tri.b.applyMatrix4(obj.matrixWorld); tri.c.applyMatrix4(obj.matrixWorld); callback(tri, obj.matrixWorld, material.side); } } } }; for (const obj of objects) { obj.updateMatrixWorld(true); obj.traverseVisible(visitor); } } /** * Describes a single measured value for all probes. */ function createTerrainGeometry(params) { const { map, spatialResolution, areaOfInterest } = params; const extent = areaOfInterest.clone().intersect(map.extent); const { width, height } = extent.dimensions(temp.dimensions); const layers = map.getElevationLayers(); // If there are no (visible) elevation layers, then the terrain is simply a flat plane, // in that case we want the simplest geometry (2 triangles) to speedup computation. if (layers.length === 0 || layers.every(l => !l.visible)) { return { geometry: new PlaneGeometry(width, height), isFlat: true, widthSegments: 1, heightSegments: 1, center: extent.centerAsVector3() }; } const res = spatialResolution; const MAX_SEGMENTS = 500; const widthSegments = Math.min(Math.ceil(width / res), MAX_SEGMENTS); const heightSegments = Math.min(Math.ceil(height / res), MAX_SEGMENTS); const result = new PlaneGeometry(width, height, widthSegments, heightSegments); const uv = result.getAttribute('uv'); const pos = result.getAttribute('position'); for (let i = 0; i < uv.count; i++) { const u = uv.getX(i); const v = uv.getY(i); const coordinates = extent.sampleUV(u, v, temp.coordinates); const elev = map.getElevationFast(coordinates.x, coordinates.y); pos.setZ(i, elev?.elevation ?? 0); } result.computeVertexNormals(); return { geometry: result, widthSegments, heightSegments, isFlat: false, center: extent.centerAsVector3() }; } function createTruncatedGeometry(input, matrix, limits) { const result = input.clone(); // The optimization is currently only done on indexed meshes. if (input.index == null) { return result; } const box = limits instanceof Box3 ? limits : limits.getBoundingBox(new Box3()); const vertices = input.getAttribute('position'); const triangle = new Triangle(); const indices = nonNull(result.index).array; const isUint32 = indices instanceof Uint32Array; const indexBufferCtor = isUint32 ? Uint32Array : Uint16Array; const filteredIndices = new TypedArrayVector(indices.length, cap => new indexBufferCtor(cap)); // Let's keep only triangles that interesect with the limits. for (let i = 0; i < indices.length - 2; i += 3) { const a = indices[i + 0]; const b = indices[i + 1]; const c = indices[i + 2]; triangle.a.set(vertices.getX(a), vertices.getY(a), vertices.getZ(a)); triangle.b.set(vertices.getX(b), vertices.getY(b), vertices.getZ(b)); triangle.c.set(vertices.getX(c), vertices.getY(c), vertices.getZ(c)); triangle.a.applyMatrix4(matrix); triangle.b.applyMatrix4(matrix); triangle.c.applyMatrix4(matrix); if (triangle.intersectsBox(box)) { filteredIndices.push(a); filteredIndices.push(b); filteredIndices.push(c); } } const filteredArray = filteredIndices.getArray(); const indexAttribute = isUint32 ? new Uint32BufferAttribute(filteredArray, 1) : new Uint16BufferAttribute(filteredArray, 1); result.setIndex(indexAttribute); return result; } function expandProbeArray(array) { if (array.length === array.capacity) { const length = array.length; array.expand(Math.round(array.capacity * 1.5)); array.length = length; } } function createNonInstancedMesh(mesh) { const geometry = mesh.geometry; const geometries = []; for (let i = 0; i < mesh.count; i++) { const geom = geometry.clone(); mesh.getMatrixAt(i, temp.matrix); geom.applyMatrix4(temp.matrix); geometries.push(geom); } const staticGeometry = BufferGeometryUtils.mergeGeometries(geometries); const result = new Mesh(staticGeometry, mesh.material); mesh.matrixWorld.decompose(result.position, result.quaternion, result.scale); staticGeometry.computeBoundingBox(); result.updateMatrix(); result.updateMatrixWorld(true); return result; } function collectMeshProbes(mesh, sampleArea, limits, origin, positions, normals) { // To avoid spending too much time sampling the mesh, // we remove all triangles that do not intersect with the limits. const truncatedGeometry = createTruncatedGeometry(mesh.geometry, mesh.matrixWorld, limits); const truncatedMesh = new Mesh(truncatedGeometry); let area = 0; iterateTriangles([truncatedMesh], tri => { area += tri.getArea(); }); const numSamples = Math.ceil(area / sampleArea) + 1; const sampler = new MeshSurfaceSampler(truncatedMesh); sampler.build(); for (let i = 0; i < numSamples; i++) { sampler.sample(temp.position, temp.normal); temp.position.applyMatrix4(mesh.matrixWorld); if (limits.containsPoint(temp.position)) { temp.position.sub(origin); positions.pushVector(temp.position); normals.pushVector(temp.normal); // Here we don't let the array auto-expand because the expansion // strategy is too slow for our needs, leading to many undecessary // intermediate allocations. So we expand with our own strategy. if (positions.length === positions.capacity) { expandProbeArray(positions); expandProbeArray(normals); } } } truncatedGeometry.dispose(); } function collectObjectProbe(obj, origin, limits, spatialResolution, positions, normals) { const meshes = []; obj.updateMatrixWorld(true); // Let's collect the meshes within the volume obj.traverseVisible(o => { if (isMesh(o)) { const worldBox = temp.box.setFromObject(o); if (limits.intersectsBox(worldBox)) { meshes.push(o); } } }); for (const mesh of meshes) { collectMeshProbes(mesh, spatialResolution * spatialResolution, limits, origin, positions, normals); } } async function collectProbes(params) { const INITIAL_SIZE = 65536 * 3; const positions = new Vector3Array(new Float32Array(INITIAL_SIZE)); positions.length = 0; const normals = new Vector3Array(new Float32Array(INITIAL_SIZE)); normals.length = 0; const { objects, limits, spatialResolution, signal, origin } = params; let start = performance.now(); for (const obj of objects) { signal?.throwIfAborted(); const root = isEntity3D(obj) ? obj.object3d : obj; collectObjectProbe(root, origin, limits, spatialResolution, positions, normals); const now = performance.now(); if (now - start > 30) { await PromiseUtils.nextFrame(); start = now; } } const numProbes = positions.length; return { length: normals.length, origin, positions, normals, numIterations: 0, variables: { meanIrradiance: { buffer: new Float32BufferAttribute(new Float32Array(numProbes), 1), mean: 0, min: +Infinity, max: -Infinity }, irradiation: { buffer: new Float32BufferAttribute(new Float32Array(numProbes), 1), mean: 0, min: +Infinity, max: -Infinity }, hoursOfSunlight: { buffer: new Float32BufferAttribute(new Float32Array(numProbes), 1), mean: 0, min: +Infinity, max: -Infinity } } }; } function collectOptimizedMeshes(objects, origin, limitsAsExtent, limits, spatialResolution) { const simulationMaterial = new MeshStandardMaterial({ color: 'red', side: DoubleSide }); const objectsToDispose = []; const result = []; for (const obj of objects) { if (isMap(obj)) { // We don't need the full spatial resolution for // terrains meshe as we rely on vertex interpolation. const terrain = createTerrainGeometry({ map: obj, spatialResolution: spatialResolution * 2, areaOfInterest: limitsAsExtent }); const mesh = new Mesh(terrain.geometry, simulationMaterial); mesh.position.copy(terrain.center); mesh.updateMatrixWorld(true); result.push(mesh); objectsToDispose.push(terrain.geometry); } else { obj.traverse(o => { if (o.visible && isMesh(o)) { o.updateMatrixWorld(); const bounds = temp.box.setFromObject(o); if (bounds.intersectsBox(limits)) { const geometry = o.geometry; geometry.computeBoundingBox(); let mesh; if (isInstancedMesh(o)) { // Probe sampling only work on regular meshes, // so we have to convert it beforehand. mesh = createNonInstancedMesh(o); } else { mesh = new Mesh(geometry, simulationMaterial); } o.matrixWorld.decompose(mesh.position, mesh.quaternion, mesh.scale); mesh.updateMatrixWorld(true); objectsToDispose.push(geometry); result.push(mesh); } } }); } } return { meshes: result, disposeFn: () => objectsToDispose.forEach(obj => obj.dispose()) }; } function getDefaultSpatialResolution(limits) { let size; if (limits instanceof Extent) { const dims = limits.dimensions(temp.dimensions); size = Math.max(dims.width, dims.height); } else if (limits instanceof Sphere) { size = limits.radius * 2; } else if (limits instanceof Box3) { const size3 = limits.getSize(temp.position); size = Math.max(size3.x, size3.y, size3.z); } else { throw new Error('unsupported limits'); } return Math.ceil(size / 1000); } function getBoxFromLimits(limits) { if (limits instanceof Extent) { return limits.toBox3(-10000, +10000); } else if (limits instanceof Sphere) { return limits.getBoundingBox(new Box3()); } else if (limits instanceof Box3) { return limits; } else { throw new Error('unsupported limits'); } } /** * Simulates sun exposure on meshes and produces various sun-related measures (see {@link VariableName}). * * The output is a point cloud that covers the area of interest. * Each point represents a _sun probe_ that samples sun exposure at this location. * * Computation can occurs on a single point in time or within a time range. In that case, * the time range is discretized into snapshots that are one `temporalResolution` apart. * * ### Irradiance and irradiation * * Irradiance (in Watts / square meter) represents the amount of solar power that reaches a surface at a given time. * * We first compute the cosine between the probe's normal and the sun direction. If the cosine * is zero or less, it means the surface is not exposed to sunlight at all. It thus receives * zero watts of solar power. * * If the cosine is greater than zero, it is used to compute the solar power with a simple formula: * * irradiance = cos(angle) * SolarConstant * AtmosphereAbsorption * * where SolarConstant is the {@link SOLAR_CONSTANT} and AtmosphereAbsorption is the {@link * ATMOSPHERIC_ABSORPTION} constant. * * Thus, at noon UTC during summer solstice and at the northern tropic (23.43° N, 0° E), * the irradiance of an horizontal surface will be at its maximum value, which is * (SolarConstant * AtmosphereAbsorption), since the cosine of the angle will be 1. * * Irradiation (in Watt-hours / square meter) is then computed as the integral of the * irradiance over the time period (in hours). * * ### Hours of sunlight * * This variable is computed by counting the number of time increments that a given probe * receives sunlight (i.e is not in the shadow of another object). Those increments do not * need to be consecutive. Thus, if a probe receives 0.5 hours of sunlight in the morning, * then is in the shade until 16:00, then receives another 2 hours of sunlight in the afternoon, * then is occluded by shadow again, then receives 1.5 hours until sunset, its hours of sunlight * will be 4 hours (0.5 + 2 + 1.5). * * ### Remarks and caveats * * - Be careful when passing `Date` parameters. By default, dates are using the local * time zone. It is advised to pass UTC dates to avoid ambiguity. * * - Only mesh-like objects (3D models, maps, 3D tiles, etc) are supported. * Point clouds are not supported, as they don't expose surfaces and normals required * for solar exposure computation. * * - You must include "ground" like meshes so that other meshes (like buildings) are properly * shaded (especially in morning/evening periods) when the sun is low. A simple flat plane * is enough if you don't have anything else. Otherwise you can use a Map with terrain. * * - Be _very_ careful with the `spatialResolution` parameter. It must be reasonable * and consistent with the dimensions of the area of interest. For example, if the area * of interest is 1000m long, and the spatial resolution is 0.1, then this will create * millions of sun probes, making computation much longer than expected, and using a lot * of memory. It is recommended to start with a high value and then reduce it afterwards. */ export class SunExposure extends EventDispatcher { _opCounter = new OperationCounter(); _toDispose = []; get loading() { return this._opCounter.loading; } get progress() { return this._opCounter.progress; } constructor(params) { super(); this._instance = params.instance; this._start = params.start; this._end = params.end; this._colorMap = params.colorMap ?? DEFAULT_COLOR_MAP; this._objects = params.objects; this._limits = params.limits; this._temporalResolution = params.temporalResolution ?? 3600; this._root = new Group(); this._root.name = 'SunExposure'; this._instance.add(this._root); this._showHelpers = params.showHelpers ?? false; this._spatialResolution = params.spatialResolution ?? getDefaultSpatialResolution(this._limits); this._opCounter.addEventListener('changed', () => this.dispatchEvent({ type: 'progress', progress: this.progress })); } createShadowMapCamera(limits, origin, direction) { const diagonal = limits.shadowCasters.min.distanceTo(limits.shadowCasters.max); const sunPos = temp.position.copy(origin).addScaledVector(direction, diagonal * 5); const camera = new OrthographicCamera(); camera.position.copy(sunPos); camera.lookAt(origin); camera.updateMatrixWorld(true); camera.matrixWorld.extractBasis(temp.x, temp.y, temp.z); const rightPlane = new Plane().setFromNormalAndCoplanarPoint(temp.x, origin); const leftPlane = rightPlane.clone().negate(); const topPlane = new Plane().setFromNormalAndCoplanarPoint(temp.y, origin); const depthPlane = new Plane().setFromNormalAndCoplanarPoint(temp.z, camera.getWorldPosition(temp.position)); const bottomPlane = topPlane.clone().negate(); const corners = getBoxCorners(limits.probes); let left = 0; let right = 0; let top = 0; let bottom = 0; let near = +Infinity; let far = 0; // Let's compute the tightest frustum around the bounding box // in order to limit the number of useless pixels in the depth texture. for (let i = 0; i < corners.length; i++) { const p = corners[i]; right = Math.max(right, Math.abs(rightPlane.distanceToPoint(p))); left = Math.max(left, Math.abs(leftPlane.distanceToPoint(p))); top = Math.max(top, Math.abs(topPlane.distanceToPoint(p))); bottom = Math.max(bottom, Math.abs(bottomPlane.distanceToPoint(p))); const depth = Math.abs(depthPlane.distanceToPoint(p)); far = Math.max(far, depth); } // The near plane is special because we want to ensure that // objects that are just outside the probe limits still cast // shadows on the probes. So we have to make sure the near // plane is not too close to the probes. near = limits.shadowCasters.distanceToPoint(sunPos); const margin = 1; camera.right = right + margin; camera.left = -left - margin; camera.top = top + margin; camera.bottom = -bottom - margin; camera.near = near - margin; camera.far = far + margin; camera.updateProjectionMatrix(); return camera; } createDepthMapHelper(camera, depths, width, height) { const tex = new DataTexture(depths, width, height, RedFormat, FloatType); tex.needsUpdate = true; this._toDispose.push(() => tex.dispose()); const textureHelper = new Mesh(new PlaneGeometry(), new MeshBasicMaterial({ map: tex })); const dir = camera.getWorldDirection(temp.normal); const helperPosition = camera.position.clone().addScaledVector(dir, camera.near); textureHelper.position.copy(helperPosition); textureHelper.lookAt(camera.position); textureHelper.scale.set(camera.right - camera.left, camera.top - camera.bottom, 1); textureHelper.updateMatrixWorld(true); textureHelper.name = 'depth texture'; this._root.add(textureHelper); } async processStep(params) { // The general algorithm follows those steps: // // 1. Create a depth/shadow map at the location of the "sun" // 2. For each probe, compare the "depth" of the probe with the value in the depth map // a) if the probe depth is smaller that the value in the depth map, the probe is // exposed to sunlight. We can then compute solar values (irradiance, irradiation, // hours of sunshine...). Solar values are computed from the angle between the normal // of the probe (which represents the normal of the original surface that was sampled // to create the probe) and the sun ray direction. // b) if the probe depth is greater than the value in the depth map, the probe receives // 0 watts of solar power this step. const { step, origin, limits, probes, scene } = params; // We negate the vector because we want the vectors that // come from the scene and looks at the sun to compute // angle between surface normals and the sun rays. const direction = step.sunDirection.clone().negate(); const camera = this.createShadowMapCamera(limits, origin, direction); if (this._showHelpers) { const helper = new CameraHelper(camera); helper.update(); helper.updateMatrixWorld(true); this._root.add(helper); } // The base depth map texture size. // This should be sufficent for most use cases, but can be // adjusted up to 4096 (which is the upper limit that WebGL // guarantees for platform-independent texture size). const BASE_SIZE = 2048; const frustumWidth = camera.right - camera.left; const frustumHeight = camera.top - camera.bottom; const aspect = frustumWidth / frustumHeight; const width = aspect > 1 ? BASE_SIZE : Math.round(BASE_SIZE * aspect); const height = aspect > 1 ? Math.round(BASE_SIZE / aspect) : BASE_SIZE; const depthTexture = new DepthTexture(width, height, UnsignedIntType); const target = new WebGLRenderTarget(width, height, { depthTexture }); this._toDispose.push(() => target.dispose()); this._toDispose.push(() => depthTexture.dispose()); const renderer = this._instance.renderer; // Let's render the simplified simulation scene to the depth texture. renderer.setRenderTarget(target); // This clear seems necessary on chromium/Windows only, see #680 renderer.clear(); renderer.render(scene, camera); // Since we have to actually sample the texture CPU-side, we have to read it back. const depths = await TextureGenerator.readDepthTexture(depthTexture, renderer); if (this._showHelpers) { this.createDepthMapHelper(camera, depths, width, height); } const intervalHour = step.duration / 3600; // 1% tolerance to avoid artifacts where probes look like their are behind // their own surface due to floating point precision issues. // Note: atmospheric absorption could be an input of the computation // so that users can set it to different weather situations (cloudy day). // Now we iterate over each probe and ask 3 questions: // 1. Is the probe even possibly lit ? // 2. Is the probe in the shadow area ? // 3. How much power does the probe receive this step ? for (let i = 0; i < probes.length; i++) { const nx = probes.normals.array[i * 3 + 0]; const ny = probes.normals.array[i * 3 + 1]; const nz = probes.normals.array[i * 3 + 2]; const normal = temp.normal.set(nx, ny, nz); const dot = normal.dot(direction); // Is the probe even lit at all ? // The probe is pointing away from sunlight, // don't even bother with depth map lookup. if (dot < 0) { continue; } // Get the world space position of the probe. const px = probes.positions.array[i * 3 + 0]; const py = probes.positions.array[i * 3 + 1]; const pz = probes.positions.array[i * 3 + 2]; const position = temp.position.set(px + origin.x, py + origin.y, pz + origin.z); // Project the probe position into the camera's NDC space. const ndc = position.project(camera); // Get the pixel coordinate in the depth map that this probe belongs to. const x = Math.round(MathUtils.mapLinear(position.x, -1, +1, 0, width - 1)); const y = Math.round(MathUtils.mapLinear(position.y, -1, +1, 0, height - 1)); // Sample the depth map at this pixel. const depth = depths[y * width + x]; // The NDC goes from -1 to +1, so we have to normalize it to [0, 1] to have // the correct probe depth. const probeDepth = MathUtils.mapLinear(ndc.z, -1, +1, 0, 1); // If the probe depth is smaller than the depth map value, it means that // the probe is directly exposed to the sunlight. // Here we use the tolerance to allow a typical case where probes are seen // as "behind" their own sample surface due to floating point precision. // The probe is in the shadow, it receives zero energy this step. // We can skip solar parameter computation since they would be equal to zero anyway. if (!(probeDepth - 0.01 <= depth)) { continue; } // The probe is in the sunlight. We can compute the solar parameters: // 1. irradiance (in W/m² how much power does it receive this step ?) // 2. irradiation (in Wh/m², cumulated irradiance over the time range) // 3. hours of sunshine (in hours, the total duration this probe was // under the sunlight). // For irradiance, we use a very simple model. // Let's compute the irradiance of the probe at this moment in time. const irradiance = SOLAR_CONSTANT * (1 - ATMOSPHERIC_ABSORPTION) * dot; // Now we can compute a bunch of sun-related parameters: // - irradiance (how much energy hits the surface at a given time) // - irradiation (cumulated energy over time) // - exposure time (number of hours that a given probe has been sunlit) // Irradiance (Watt / m²) // Note that we are interested in the mean irradiance per probe, // so we will compute it at the end of the simulation. probes.variables.meanIrradiance.buffer.array[i] += irradiance; // Irradiation (Watt-hour / m²) // Irradiation is the integral of irradiance over the period of time. // We can compute it as we iterate over the interval. const irradiation = probes.variables.irradiation; const newValue = irradiation.buffer.array[i] + irradiance * intervalHour; irradiation.buffer.array[i] = newValue; irradiation.max = Math.max(irradiation.max, newValue); irradiation.min = Math.min(irradiation.min, newValue); // Hours of sunlight probes.variables.hoursOfSunlight.buffer.array[i] += intervalHour; } // This value will be used later to compute the mean irradiance. probes.numIterations++; // Give the opportunity to update the preview point cloud. this._instance.notifyChange(); } computeTightBounds() { const limits = this._objects.map(obj => { if (isEntity3D(obj)) { return new Box3().setFromObject(obj.object3d); } else { obj.updateMatrixWorld(true); return new Box3().setFromObject(obj, true); } }); const limit = limits.reduce((prev, curr) => prev.union(curr)); limit.expandByScalar(10); const inputLimits = getBoxFromLimits(this._limits); const box = limit.intersect(inputLimits); return box; } createBoundsHelper(bounds, color) { const boxHelper = new Box3Helper(bounds, color); this._root.add(boxHelper); boxHelper.updateMatrixWorld(true); this._instance.notifyChange(); } async createOutputPointCloud(bounds, origin, probes) { const irradiation = probes.variables.irradiation.buffer; const irradiance = probes.variables.meanIrradiance.buffer; const hoursOfSunlight = probes.variables.hoursOfSunlight.buffer; const source = new StaticPointCloudSource({ spacing: this._spatialResolution, positions: new Float32BufferAttribute(probes.positions.toFloat32Array(), 3), origin: origin, bounds, attributes: [{ attribute: attributeDescriptors['meanIrradiance'], data: irradiance }, { attribute: attributeDescriptors['irradiation'], data: irradiation }, { attribute: attributeDescriptors['hoursOfSunlight'], data: hoursOfSunlight }] }); const pointCloud = new PointCloud({ source }); await this._instance.add(pointCloud); pointCloud.setActiveAttribute('irradiation'); pointCloud.setAttributeColorMap('irradiation', this._colorMap); pointCloud.setAttributeColorMap('meanIrradiance', this._colorMap); pointCloud.setAttributeColorMap('hoursOfSunlight', this._colorMap); return { pointCloud, source, irradiation, irradiance, hoursOfSunlight }; } computeVariableStatistics(variable) { let min = +Infinity; let max = -Infinity; let mean = 0; for (let i = 0; i < variable.buffer.array.length; i++) { const v = variable.buffer.array[i]; min = Math.min(min, v); max = Math.max(max, v); mean += v; } variable.mean = mean / variable.buffer.array.length; variable.min = min - 0.01; variable.max = max + 0.01; } async runSimulationStep(params) { const { limits, step, output, probes, signal, scene, origin } = params; await PromiseUtils.delay(15).then(async () => { if (signal?.aborted === true) { return; } await this.processStep({ step, limits, origin, probes, scene }); // Let's update the preview point cloud this._colorMap.min = probes.variables.irradiation.min - 0.01; this._colorMap.max = probes.variables.irradiation.max + 0.01; // the epsilon to avoid null intervals output.irradiation.needsUpdate = true; output.source.update(); this._instance.notifyChange(); }).finally(() => this._opCounter.decrement()); } /** * Starts the computation. */ async compute(options) { // This array will store dispose functions for early cancellations. const cancellationDisposals = []; const signal = options?.signal; signal?.addEventListener('abort', () => { this.dispose(); cancellationDisposals.forEach(f => f()); console.log('computation aborted'); }); const crs = this._instance.coordinateSystem; // Start the sun exposure computation. // The first step is to compute the limit volumes // of computation. We define two volumes: // - the tight volume that contains the probes // - a bigger volume for shadow casters const probeBounds = this.computeTightBounds(); // The bounds to collect meshes is bigger than the bounds used for probes // because we want to ensure that neighbouring meshes do contribute to shadows. // For example if the neighbouring area has high-rise buildings, they must be included. const scale = probeBounds.getSize(new Vector3()); const shadowCasterBounds = probeBounds.clone().expandByVector(scale); const meshBoundsAsExtent = Extent.fromBox3(crs, shadowCasterBounds); const limits = { probes: probeBounds, shadowCasters: shadowCasterBounds }; const origin = limits.probes.getCenter(new Vector3()); const originAsCoordinates = new Coordinates(crs, origin.x, origin.y, origin.z); const isGlobe = this._instance.coordinateSystem.isEpsg(4978); // Then, discretize the time interval into separate // steps, each with a date and sun direction. const steps = createSimulationSteps(originAsCoordinates, this._start, this._end, this._temporalResolution, isGlobe); // Let's collect the meshes that will be used in the simulation. // We limit the meshes that intersect the bounds. // Note that we are not altering the original objects at all. const { meshes, disposeFn } = collectOptimizedMeshes(this._objects, origin, meshBoundsAsExtent, shadowCasterBounds, this._spatialResolution); // Then, collect probes on the surface of the meshes // within the tight bounds. const probes = await collectProbes({ objects: meshes, origin, limits: limits.probes, spatialResolution: this._spatialResolution }); this._toDispose.push(disposeFn); const scene = new Group(); scene.name = 'meshes'; scene.add(...meshes); if (this._showHelpers) { this.createBoundsHelper(limits.probes, 'red'); this.createBoundsHelper(limits.shadowCasters, 'green'); // The simulation scene is added to the scenegraph to be visualized. this._root.add(scene); scene.updateMatrixWorld(true); this._instance.notifyChange(); await PromiseUtils.delay(50); } this._opCounter.increment(steps.length); // Now we build the point cloud that will display the probes. const output = await this.createOutputPointCloud(limits.probes, origin, probes); cancellationDisposals.push(() => this._instance.remove(output.pointCloud)); signal?.throwIfAborted(); // Let's run the simulation steps for (const step of steps) { await this.runSimulationStep({ step, probes, origin, limits, output, scene, signal: options?.signal }); signal?.throwIfAborted(); } signal?.throwIfAborted(); // Now that the computation is finished, we can compute the mean irradiance // from the cumulated irradiances. const irradiances = probes.variables.meanIrradiance.buffer.array; for (let i = 0; i < irradiances.length; i++) { const cumulated = irradiances[i]; irradiances[i] = cumulated / probes.numIterations; } this.computeVariableStatistics(probes.variables.meanIrradiance); this.computeVariableStatistics(probes.variables.irradiation); this.computeVariableStatistics(probes.variables.hoursOfSunlight); output.irradiation.needsUpdate = true; output.irradiance.needsUpdate = true; output.hoursOfSunlight.needsUpdate = true; output.source.update(); return { entity: output.pointCloud, variables: probes.variables }; } dispose() { this._toDispose.forEach(fn => fn()); this._toDispose.length = 0; this._instance.remove(this._root); } } export default SunExposure;