diff --git a/examples/source_file_geojson_raster_2.html b/examples/source_file_geojson_raster_2.html index 3215a697a0..89072cd100 100644 --- a/examples/source_file_geojson_raster_2.html +++ b/examples/source_file_geojson_raster_2.html @@ -130,7 +130,7 @@ view.addLayer(earthquakeLayer).then(FeatureToolTip.addLayer); // Request redraw - view.notifyChange(true); + view.notifyChange(); debug.createTileDebugUI(menuGlobe.gui, view); window.view = view; diff --git a/packages/Main/src/Converter/Feature2Texture.js b/packages/Main/src/Converter/Feature2Texture.js index b169fd00d9..3dd09db2b0 100644 --- a/packages/Main/src/Converter/Feature2Texture.js +++ b/packages/Main/src/Converter/Feature2Texture.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import { FEATURE_TYPES } from 'Core/Feature'; import { Extent, Coordinates } from '@itowns/geographic'; import Style, { StyleContext } from 'Core/Style'; +import { createContext2D } from 'Utils/CanvasUtils'; const defaultStyle = new Style(); const context = new StyleContext(); @@ -118,13 +119,9 @@ export default { // A texture is instancied drawn canvas // origin and dimension are used to transform the feature's coordinates to canvas's space extent.planarDimensions(dimension); - const c = document.createElement('canvas'); - coord.crs = extent.crs; - c.width = sizeTexture; - c.height = sizeTexture; - const ctx = c.getContext('2d', { willReadFrequently: true }); + const ctx = createContext2D(sizeTexture, sizeTexture); if (backgroundColor) { ctx.fillStyle = backgroundColor.getStyle(); ctx.fillRect(0, 0, sizeTexture, sizeTexture); @@ -168,7 +165,7 @@ export default { drawFeature(ctx, feature, featureExtent, invCtxScale); } - texture = new THREE.CanvasTexture(c); + texture = new THREE.CanvasTexture(ctx.canvas); texture.flipY = collection.isInverted; } else if (backgroundColor) { const data = new Uint8Array(3); diff --git a/packages/Main/src/Core/Style.js b/packages/Main/src/Core/Style.js index bc7860148c..61a1906343 100644 --- a/packages/Main/src/Core/Style.js +++ b/packages/Main/src/Core/Style.js @@ -1,19 +1,17 @@ import { Coordinates } from '@itowns/geographic'; import { LRUCache } from 'lru-cache'; import Fetcher from 'Provider/Fetcher'; -import { Color, EventDispatcher } from 'three'; -import { deltaE } from 'Renderer/Color'; +import { EventDispatcher } from 'three'; +import { sharedContext2D, sharedReadContext2D } from 'Utils/CanvasUtils'; import itowns_stroke_single_before from './StyleChunk/itowns_stroke_single_before.css'; const cachedImg = new LRUCache({ max: 500 }); let matrix; -let canvas; if (typeof document !== 'undefined') { matrix = document.createElementNS('http://www.w3.org/2000/svg', 'svg').createSVGMatrix(); - canvas = document.createElement('canvas'); } function baseAltitudeDefault(properties, ctx) { @@ -58,39 +56,10 @@ async function loadImage(url) { return (await promise).image; } -function cropImage(img, cropValues) { - const x = cropValues.x || 0; - const y = cropValues.y || 0; - const width = cropValues.width || img.naturalWidth; - const height = cropValues.height || img.naturalHeight; - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext('2d', { willReadFrequently: true }); +function cropImage(ctx, img, x, y, width, height) { ctx.drawImage(img, x, y, width, height, 0, 0, width, height); - return ctx.getImageData(0, 0, width, height); -} - -function replaceWhitePxl(imgd, color, id) { - if (!color) { - return imgd; - } - const imgdColored = cachedImg.get(`${id}_${color}`); - if (!imgdColored) { - const pix = imgd.data; - const newColor = new Color(color); - const colorToChange = new Color('white'); - for (let i = 0, n = pix.length; i < n; i += 4) { - const d = deltaE(pix.slice(i, i + 3), colorToChange) / 100; - pix[i] = (pix[i] * d + newColor.r * 255 * (1 - d)); - pix[i + 1] = (pix[i + 1] * d + newColor.g * 255 * (1 - d)); - pix[i + 2] = (pix[i + 2] * d + newColor.b * 255 * (1 - d)); - } - cachedImg.set(`${id}_${color}`, imgd); - return imgd; - } - return imgdColored; } const textAnchorPosition = { @@ -698,15 +667,17 @@ class Style extends EventDispatcher { async _applyFillToPolygon(txtrCtx, invCtxScale, polygon) { // if (this.fill.pattern && txtrCtx.fillStyle.src !== this.fill.pattern.src) { // need doc for the txtrCtx.fillStyle.src that seems to always be undefined - if (this.fill.pattern) { - let img = this.fill.pattern; - const cropValues = { ...this.fill.pattern.cropValues }; - if (this.fill.pattern.source) { - img = await loadImage(this.fill.pattern.source); - } - cropImage(img, cropValues); - - txtrCtx.fillStyle = txtrCtx.createPattern(canvas, 'repeat'); + const { pattern } = this.fill; + if (pattern) { + const cropValues = 'cropValues' in pattern ? pattern.cropValues : {}; + const img = 'source' in pattern ? await loadImage(pattern.source) : pattern; + const { x = 0, y = 0, width = img.naturalWidth, height = img.naturalHeight } = cropValues; + + const cropCtx = sharedContext2D(); + cropCtx.canvas.width = width; + cropCtx.canvas.height = height; + cropImage(cropCtx, img, x, y, width, height); + txtrCtx.fillStyle = txtrCtx.createPattern(cropCtx.canvas, 'repeat'); if (txtrCtx.fillStyle.setTransform) { txtrCtx.fillStyle.setTransform(matrix.scale(invCtxScale)); } else { @@ -755,16 +726,15 @@ class Style extends EventDispatcher { domElement.setAttribute('data-before', domElement.textContent); } - if (!this.icon.source) { + // Style properties are evaluated lazily within a shared and mutable + // context. Evaluating them after an await could yield incorrect values + // since the context has been mutated. + const { source, cropValues, color } = this.icon; + if (!source) { return; } - let icon; - - if (typeof document !== 'undefined') { - icon = document.createElement('img'); - } - + const icon = document.createElement('img'); const iconPromise = new Promise((resolve, reject) => { const opt = { size: this.icon.size, @@ -776,17 +746,25 @@ class Style extends EventDispatcher { icon.onerror = err => reject(err); }); - if (!this.icon.cropValues && !this.icon.color) { - icon.src = this.icon.source; + if (!cropValues && !color) { + icon.src = source; } else { - const cropValues = { ...this.icon.cropValues }; - const color = this.icon.color; - const id = this.icon.id || this.icon.source; - const img = await loadImage(this.icon.source); - const imgd = cropImage(img, cropValues); - const imgdColored = replaceWhitePxl(imgd, color, id); - canvas.getContext('2d').putImageData(imgdColored, 0, 0); - icon.src = canvas.toDataURL('image/png'); + const img = await loadImage(source); + const { x = 0, y = 0, width = img.naturalWidth, height = img.naturalHeight } = cropValues ?? {}; + const cropCtx = sharedReadContext2D(); + cropCtx.canvas.width = width; + cropCtx.canvas.height = height; + cropImage(cropCtx, img, x, y, width, height); + if (color) { + const oldGlobalCompositeOp = cropCtx.globalCompositeOperation; + cropCtx.globalCompositeOperation = 'multiply'; + cropCtx.fillStyle = color; + cropCtx.fillRect(0, 0, width, height); + cropCtx.globalCompositeOperation = 'destination-in'; + cropImage(cropCtx, img, x, y, width, height); + cropCtx.globalCompositeOperation = oldGlobalCompositeOp; + } + icon.src = cropCtx.canvas.toDataURL('image/png'); } return iconPromise; } diff --git a/packages/Main/src/Renderer/Color.js b/packages/Main/src/Renderer/Color.js deleted file mode 100644 index 1afc96a20f..0000000000 --- a/packages/Main/src/Renderer/Color.js +++ /dev/null @@ -1,69 +0,0 @@ -export function lab2rgb(lab) { - let y = (lab[0] + 16) / 116; - let x = lab[1] / 500 + y; - let z = y - lab[2] / 200; - let r; let g; let - b; - - x = 0.95047 * ((x * x * x > 0.008856) ? x * x * x : (x - 16 / 116) / 7.787); - y = 1.00000 * ((y * y * y > 0.008856) ? y * y * y : (y - 16 / 116) / 7.787); - z = 1.08883 * ((z * z * z > 0.008856) ? z * z * z : (z - 16 / 116) / 7.787); - - r = x * 3.2406 + y * -1.5372 + z * -0.4986; - g = x * -0.9689 + y * 1.8758 + z * 0.0415; - b = x * 0.0557 + y * -0.2040 + z * 1.0570; - - r = (r > 0.0031308) ? (1.055 * r ** (1 / 2.4) - 0.055) : 12.92 * r; - g = (g > 0.0031308) ? (1.055 * g ** (1 / 2.4) - 0.055) : 12.92 * g; - b = (b > 0.0031308) ? (1.055 * b ** (1 / 2.4) - 0.055) : 12.92 * b; - - return [Math.max(0, Math.min(1, r)) * 255, - Math.max(0, Math.min(1, g)) * 255, - Math.max(0, Math.min(1, b)) * 255]; -} - - -export function rgb2lab(rgb) { - let r = rgb.r || rgb[0] / 255; - let g = rgb.g || rgb[1] / 255; - let b = rgb.b || rgb[2] / 255; - let x; let y; let - z; - - r = (r > 0.04045) ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92; - g = (g > 0.04045) ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92; - b = (b > 0.04045) ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92; - - x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; - y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; - z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; - - x = (x > 0.008856) ? x ** (1 / 3) : (7.787 * x) + 16 / 116; - y = (y > 0.008856) ? y ** (1 / 3) : (7.787 * y) + 16 / 116; - z = (z > 0.008856) ? z ** (1 / 3) : (7.787 * z) + 16 / 116; - - return [(116 * y) - 16, 500 * (x - y), 200 * (y - z)]; -} - -// calculate the perceptual distance between colors in CIELAB -// https://github.com/THEjoezack/ColorMine/blob/master/ColorMine/ColorSpaces/Comparisons/Cie94Comparison.cs -export function deltaE(rgbA, rgbB) { - const labA = rgb2lab(rgbA); - const labB = rgb2lab(rgbB); - const deltaL = labA[0] - labB[0]; - const deltaA = labA[1] - labB[1]; - const deltaB = labA[2] - labB[2]; - const c1 = Math.sqrt(labA[1] * labA[1] + labA[2] * labA[2]); - const c2 = Math.sqrt(labB[1] * labB[1] + labB[2] * labB[2]); - const deltaC = c1 - c2; - let deltaH = deltaA * deltaA + deltaB * deltaB - deltaC * deltaC; - deltaH = deltaH < 0 ? 0 : Math.sqrt(deltaH); - const sc = 1.0 + 0.045 * c1; - const sh = 1.0 + 0.015 * c1; - const deltaLKlsl = deltaL / (1.0); - const deltaCkcsc = deltaC / (sc); - const deltaHkhsh = deltaH / (sh); - const i = deltaLKlsl * deltaLKlsl + deltaCkcsc * deltaCkcsc + deltaHkhsh * deltaHkhsh; - return i < 0 ? 0 : Math.sqrt(i); -} - diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index 05d2c2abff..246b97c16a 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -2,6 +2,7 @@ import * as THREE from 'three'; import PointsVS from 'Renderer/Shader/PointsVS.glsl'; import PointsFS from 'Renderer/Shader/PointsFS.glsl'; import CommonMaterial from 'Renderer/CommonMaterial'; +import { createContext2D } from 'Utils/CanvasUtils'; import Gradients from 'Utils/Gradients'; export const PNTS_MODE = { @@ -82,12 +83,7 @@ function generateGradientTexture(gradient) { const size = 64; // create canvas - const canvas = document.createElement('canvas'); - canvas.width = size; - canvas.height = size; - - // get context - const context = canvas.getContext('2d'); + const context = createContext2D(size, size); // draw gradient context.rect(0, 0, size, size); @@ -102,7 +98,7 @@ function generateGradientTexture(gradient) { context.fillStyle = ctxGradient; context.fill(); - const texture = new THREE.CanvasTexture(canvas); + const texture = new THREE.CanvasTexture(context.canvas); texture.needsUpdate = true; texture.minFilter = THREE.LinearFilter; diff --git a/packages/Main/src/Renderer/c3DEngine.js b/packages/Main/src/Renderer/c3DEngine.js index bc535e2e87..7f1dac3ab0 100644 --- a/packages/Main/src/Renderer/c3DEngine.js +++ b/packages/Main/src/Renderer/c3DEngine.js @@ -5,11 +5,11 @@ */ import * as THREE from 'three'; +import WEBGL from 'three/addons/capabilities/WebGL.js'; import Capabilities from 'Core/System/Capabilities'; import { unpack1K } from 'Renderer/LayeredMaterial'; import Label2DRenderer from 'Renderer/Label2DRenderer'; import { deprecatedC3DEngineWebGLOptions } from 'Core/Deprecated/Undeprecator'; -import WEBGL from 'three/addons/capabilities/WebGL.js'; import { EffectComposer } from 'postprocessing'; const depthRGBA = new THREE.Vector4(); @@ -230,28 +230,6 @@ class c3DEngine { return target; } - bufferToImage(pixelBuffer, width, height) { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - - // size the canvas to your desired image - canvas.width = width; - canvas.height = height; - - const imgData = ctx.getImageData(0, 0, width, height); - imgData.data.set(pixelBuffer); - - ctx.putImageData(imgData, 0, 0); - - // create a new img object - const image = new Image(); - - // set the img.src to the canvas data url - image.src = canvas.toDataURL(); - - return image; - } - depthBufferRGBAValueToOrthoZ(depthBufferRGBA, camera) { depthRGBA.fromArray(depthBufferRGBA).divideScalar(255.0); diff --git a/packages/Main/src/Utils/CanvasUtils.ts b/packages/Main/src/Utils/CanvasUtils.ts new file mode 100644 index 0000000000..04752e50cc --- /dev/null +++ b/packages/Main/src/Utils/CanvasUtils.ts @@ -0,0 +1,71 @@ +let ctx2D: CanvasRenderingContext2D; +let rCtx2D: CanvasRenderingContext2D; + +/** + * Creates a dedicated 2D canvas context. + * + * @remarks + * The caller has exclusive ownership of the returned context and may safely + * retains it across asynchronous work. + * + * @param width - Canvas width (in pixels) + * @param height - Canvas height (in pixels) + * @param options - Options passed to {@link HTMLCanvasElement.getContext} + * @returns A new 2D rendering context sized to `width` x `height` + */ +export function createContext2D( + width: number, height: number, + options?: CanvasRenderingContext2DSettings, +): CanvasRenderingContext2D { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas.getContext('2d', options) as CanvasRenderingContext2D; +} + +/** + * Returns a shared 2D canvas context for draw-only synchronous work. + * + * @remarks + * The canvas dimensions must be set before use. Setting them clears the canvas + * and resets its context state. + * + * **Warning**: The context is shared and reused between calls, it must not + * be retained or used across asynchronous work. + * + * Use {@link sharedReadContext2D} when frequent pixel access is required. + * + * @returns A shared 2D canvas context for draw-only synchronous work. + */ +export function sharedContext2D(): CanvasRenderingContext2D { + if (!ctx2D) { + ctx2D = createContext2D(1, 1); + } + return ctx2D; +} + +/** + * Returns a shared 2D canvas context for synchronous pixel readback work. + * + * @remarks + * The canvas dimensions must be set before use. Setting them clears the canvas + * and resets its context state. + * + * The context hints the browser that pixel data will be read back frequently + * (e.g. using {@link CanvasRenderingContext2D.getImageData}, + * {@link HTMLCanvasElement.toDataURL} or {@link HTMLCanvasElement.toBlob}). + * Browsers may optimize for read performance at the cost of drawing speed. + * + * **Warning**: The context is shared and reused between calls, it must not + * be retained or used across asynchronous work. + * + * Use {@link sharedContext2D} for draw-only operations. + * + * @returns A shared 2D canvas context for synchronous pixel readback work. + */ +export function sharedReadContext2D(): CanvasRenderingContext2D { + if (!rCtx2D) { + rCtx2D = createContext2D(1, 1, { willReadFrequently: true }); + } + return rCtx2D; +}