@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
1,398 lines (1,173 loc) • 48.8 kB
text/typescript
/*
* Copyright (c) 2015-2018, IGN France.
* Copyright (c) 2018-2026, Giro3D team.
* SPDX-License-Identifier: MIT
*/
import type {
BufferAttribute,
BufferGeometry,
ColorRepresentation,
Side,
InstancedMesh,
} from 'three';
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,
type Object3D,
} from 'three';
import { BufferGeometryUtils, Lut, MeshSurfaceSampler } from 'three/examples/jsm/Addons.js';
import type Disposable from '../core/Disposable';
import type Instance from '../core/Instance';
import type Progress from '../core/Progress';
import type Entity3D from '../entities/Entity3D';
import type Map from '../entities/Map';
import type { PointCloudAttribute } from '../sources/PointCloudSource';
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,
});
interface Limits {
probes: Box3;
shadowCasters: Box3;
}
/**
* 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.
*/
export type VariableName = 'meanIrradiance' | 'irradiation' | 'hoursOfSunlight';
const attributeDescriptors: Record<VariableName, PointCloudAttribute> = {
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.
*/
export interface ComputationResult {
/**
* The generated point cloud. This is the same entity
* as seen in the preview during computation. This point
* cloud contains one attribute per solar variable.
*/
entity: PointCloud;
/**
* The computed variables. Note that those variables are already
* set up in the {@link entity}. However if for some reason you
* want to use them for other purposes, you can access them directly here.
*/
variables: Record<VariableName, SolarVariable>;
}
interface SimulationStep {
date: Date;
sunDirection: Vector3;
/** The duration, in seconds */
duration: number;
}
function createSimulationStep(
observer: Coordinates,
date: Date,
duration: number,
isGlobe: boolean,
): SimulationStep {
const sunDirection = isGlobe
? Sun.getDirection(date)
: Sun.getLocalFrameDirection(observer, date);
return {
date,
sunDirection,
duration,
};
}
function getBoxCorners(box: Box3): Vector3[] {
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: Coordinates,
start: Date,
end: Date | undefined,
stepDurationSeconds: number,
isGlobe: boolean,
): SimulationStep[] {
const result: SimulationStep[] = [];
const timeIncrementMs = stepDurationSeconds * 1000;
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 += timeIncrementMs;
}
result.push(createSimulationStep(observer, end, stepDurationSeconds, isGlobe));
} else {
result.push(createSimulationStep(observer, start, stepDurationSeconds, isGlobe));
}
return result;
}
function iterateTriangles(
objects: Object3D[],
callback: (triangle: Readonly<Triangle>, worldMatrix: Matrix4, side: Side) => void,
): void {
const tri = new Triangle(new Vector3(), new Vector3(), new Vector3());
const visitor = (obj: Object3D): void => {
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.
*/
export interface SolarVariable {
/** The variable values for all probes. Can directly be used
* as a buffer attribute for a geometry.
*/
buffer: BufferAttribute;
/** The average value of the variable across all probes */
mean: number;
/** The minimum value of the variable across all probes */
min: number;
/** The maximum value of the variable across all probes */
max: number;
}
interface ProbeCollection {
origin: Vector3;
/** The number of probes in this collection */
length: number;
/** Probe positions, expressed relative to the origin */
positions: Vector3Array;
normals: Vector3Array;
numIterations: number;
variables: Record<VariableName, SolarVariable>;
}
function createTerrainGeometry(params: {
map: Map;
spatialResolution: number;
areaOfInterest: Extent;
}): {
geometry: BufferGeometry;
widthSegments: number;
heightSegments: number;
isFlat: boolean;
center: Vector3;
} {
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: BufferGeometry,
matrix: Matrix4,
limits: Box3 | Sphere,
): BufferGeometry {
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: Vector3Array): void {
if (array.length === array.capacity) {
const length = array.length;
array.expand(Math.round(array.capacity * 1.5));
array.length = length;
}
}
function createNonInstancedMesh(mesh: InstancedMesh): Mesh {
const geometry: Readonly<BufferGeometry> = mesh.geometry;
const geometries: BufferGeometry[] = [];
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: Mesh,
sampleArea: number,
limits: Box3 | Sphere,
origin: Vector3,
positions: Vector3Array,
normals: Vector3Array,
): void {
// 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: Object3D,
origin: Vector3,
limits: Box3 | Sphere,
spatialResolution: number,
positions: Vector3Array,
normals: Vector3Array,
): void {
const meshes: Mesh[] = [];
const sampleArea = spatialResolution * spatialResolution;
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, sampleArea, limits, origin, positions, normals);
}
}
async function collectProbes(params: {
objects: (Object3D | Entity3D)[];
origin: Vector3;
limits: Box3 | Sphere;
spatialResolution: number;
signal?: AbortSignal;
}): Promise<ProbeCollection> {
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: (Object3D | Entity3D)[],
origin: Vector3,
limitsAsExtent: Extent,
limits: Box3,
spatialResolution: number,
): { meshes: Mesh[]; disposeFn: VoidFunction } {
const simulationMaterial = new MeshStandardMaterial({
color: 'red',
side: DoubleSide,
});
const objectsToDispose: BufferGeometry[] = [];
const result: Mesh[] = [];
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 res = spatialResolution * 2;
const terrain = createTerrainGeometry({
map: obj,
spatialResolution: res,
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: 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()) };
}
export interface SunExposureOptions {
/**
* The Giro3D instance to use. This must be the same instance
* as the one that will host the resulting point cloud.
*/
instance: Instance;
/**
* The objects to include in the computation.
*/
objects: Array<Entity3D | Object3D>;
/**
* The area of interest to limit the simulation.
* The smaller the area of interest, the faster the simulation will be.
*/
limits: Extent | Box3 | Sphere;
/**
* The date at the start of the simulation time range.
*/
start: Date;
/**
* The date at the end of the simulation time range.
* If unspecified, then the time range will be [start, start]
* and only one simulation step will be performed.
*/
end?: Date;
/**
* The color map to use on the point cloud preview to display irradiation.
* Note that once the computation is finished, you
* can modify the colormaps on the resulting point cloud.
* @defaultValue a rainbow colormap
*/
colorMap?: ColorMap;
/**
* The spatial resolution, in scene units. This is the
* average space between simulation probes. If unspecified,
* a default value is computed from the dimensions of {@link limits}.
*/
spatialResolution?: number;
/**
* If true, show helpers to help visualize the computation steps.
* Helpers will remain visible until the dispose() method is called.
* @defaultValue false
*/
showHelpers?: boolean;
/**
* The temporal resolution, in seconds. This is the interval
* between simulation steps. If {@link end} was not set,
* then this parameter has no effect.
* @defaultValue 3600
*/
temporalResolution?: number;
}
export interface SunExposureEventMap {
/** Raised when the simulation progress changes */
progress: { progress: number };
}
interface Output {
pointCloud: PointCloud;
source: StaticPointCloudSource;
irradiance: Float32BufferAttribute;
hoursOfSunlight: Float32BufferAttribute;
irradiation: Float32BufferAttribute;
}
function getDefaultSpatialResolution(limits: Extent | Box3 | Sphere): number {
let size: number;
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: Extent | Box3 | Sphere): Box3 {
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<SunExposureEventMap>
implements Progress, Disposable
{
private readonly _opCounter = new OperationCounter();
private readonly _start: Date;
private readonly _end: Date | undefined;
private readonly _temporalResolution: number;
private readonly _limits: Extent | Sphere | Box3;
private readonly _instance: Instance;
private readonly _root: Group;
private readonly _showHelpers: boolean;
private readonly _objects: Array<Object3D | Entity3D>;
private readonly _spatialResolution: number;
private readonly _colorMap: ColorMap;
private readonly _toDispose: Array<VoidFunction> = [];
public get loading(): boolean {
return this._opCounter.loading;
}
public get progress(): number {
return this._opCounter.progress;
}
public constructor(params: SunExposureOptions) {
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 }),
);
}
private createShadowMapCamera(
limits: Limits,
origin: Vector3,
direction: Vector3,
): OrthographicCamera {
const diagonal = limits.shadowCasters.min.distanceTo(limits.shadowCasters.max);
const distanceToOrigin = diagonal * 5;
const sunPos = temp.position.copy(origin).addScaledVector(direction, distanceToOrigin);
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;
}
private createDepthMapHelper(
camera: OrthographicCamera,
depths: Float32Array,
width: number,
height: number,
): void {
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);
}
private async processStep(params: {
step: SimulationStep;
origin: Vector3;
limits: Limits;
probes: ProbeCollection;
scene: Object3D;
}): Promise<void> {
// 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.
const tolerance = 0.01;
// Note: atmospheric absorption could be an input of the computation
// so that users can set it to different weather situations (cloudy day).
const baseIrradiance = SOLAR_CONSTANT * (1 - ATMOSPHERIC_ABSORPTION);
// 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.
const sunLit = probeDepth - tolerance <= depth;
// 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 (!sunLit) {
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 = baseIrradiance * 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();
}
private computeTightBounds(): Box3 {
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;
}
private createBoundsHelper(bounds: Box3, color: ColorRepresentation): void {
const boxHelper = new Box3Helper(bounds, color);
this._root.add(boxHelper);
boxHelper.updateMatrixWorld(true);
this._instance.notifyChange();
}
private async createOutputPointCloud(
bounds: Box3,
origin: Vector3,
probes: ProbeCollection,
): Promise<Output> {
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 };
}
private computeVariableStatistics(variable: SolarVariable): void {
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;
}
private async runSimulationStep(params: {
step: SimulationStep;
probes: ProbeCollection;
limits: Limits;
origin: Vector3;
output: Output;
scene: Object3D;
signal?: AbortSignal;
}): Promise<void> {
const { limits, step, output, probes, signal, scene, origin } = params;
await PromiseUtils.delay(15)
.then(async (): Promise<void> => {
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.
*/
public async compute(options?: {
/** An optional signal to abort the computation */
signal?: AbortSignal;
}): Promise<ComputationResult> {
// This array will store dispose functions for early cancellations.
const cancellationDisposals: Array<VoidFunction> = [];
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: 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,
};
}
public dispose(): void {
this._toDispose.forEach(fn => fn());
this._toDispose.length = 0;
this._instance.remove(this._root);
}
}
export default SunExposure;