From 4a5ca7cb9cb5d2e11e984ad343404faaa4427104 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Wed, 24 Dec 2025 14:28:47 +0100 Subject: [PATCH 01/14] chore(pointcloud): Add adaptive size mode # Conflicts: # packages/Main/src/Layer/PointCloudLayer.ts --- packages/Debug/src/PointCloudDebug.js | 2 +- packages/Main/src/Layer/PointCloudLayer.ts | 128 +++++++++++++++++- packages/Main/src/Renderer/PointsMaterial.js | 14 ++ .../Main/src/Renderer/Shader/PointsVS.glsl | 77 ++++++++++- 4 files changed, 216 insertions(+), 5 deletions(-) diff --git a/packages/Debug/src/PointCloudDebug.js b/packages/Debug/src/PointCloudDebug.js index cce114ab5f..1bd7dca93d 100644 --- a/packages/Debug/src/PointCloudDebug.js +++ b/packages/Debug/src/PointCloudDebug.js @@ -299,7 +299,7 @@ export default { styleUI.add(layer, 'opacity', 0, 1).name('Layer opacity').onChange(update); styleUI.add(layer, 'pointSize', 0, 15).name('Point size').onChange(update); if (layer.material.sizeMode != undefined && view.camera.camera3D.isPerspectiveCamera) { - styleUI.add(layer.material, 'sizeAttenuation').name('Size attenuation') + styleUI.add(layer.material, 'sizeMode', PNTS_SIZE_MODE).name('Size mode') .onChange(update); styleUI.add(layer.material, 'minAttenuatedSize', 0, 15).name('Min size') .onChange((value) => { diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index d1e9ae8959..217076628f 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -1,7 +1,7 @@ import * as THREE from 'three'; import TinyQueue from 'tinyqueue'; import GeometryLayer from 'Layer/GeometryLayer'; -import PointsMaterial, { PNTS_MODE } from 'Renderer/PointsMaterial'; +import PointsMaterial, { PNTS_MODE, PNTS_SIZE_MODE } from 'Renderer/PointsMaterial'; import Picking from 'Core/Picking'; import type PointCloudNode from 'Core/PointCloudNode'; @@ -535,6 +535,56 @@ abstract class PointCloudLayer } } + // @ts-expect-error PointsMaterial is not typed yet + if (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { + const visibilityTextureData = this.computeVisibilityTextureData(Array.from(this._visibleNodes)); + + // @ts-expect-error PointsMaterial is not typed yet + const vnt = this.material.visibleNodes; + const data = vnt.image.data; + data.set(visibilityTextureData.data); + vnt.needsUpdate = true; + + // Use natBox (native octree space) for octreeSize, as the + // octree hierarchy is defined in the source CRS coordinate system + const rootSize = this.root!.voxelOBB.natBox.getSize(new THREE.Vector3()); + const octreeSize = Math.max(rootSize.x, rootSize.y, rootSize.z); + + for (const pts of this.group.children) { + const node = pts.userData.node; + const depth = node.depth; + const nodeStartOffset = visibilityTextureData.offsets.get(node.voxelKey); + const octreeSpacing = node.source.spacing; + + // Compute the bounding box min of the node's octree cell + // in the local space of the THREE.Points geometry. + // We must use voxelOBB.natBox.min (the octree cell boundary + // in source CRS) rather than geomBBox.min (which only covers + // the actual points, not the full octree cell). + const natMin = node.voxelOBB.natBox.min; + const origin = node.obj.position; + const bboxMin = new THREE.Vector3( + natMin.x - origin.x, + natMin.y - origin.y, + natMin.z - origin.z, + ); + + pts.onBeforeRender = (_renderer, _scene, _camera, _geometry, material) => { + // @ts-expect-error Material is not typed yet + material.uniforms.nodeStartOffset.value = nodeStartOffset; + // @ts-expect-error Material is not typed yet + material.uniforms.octreeSize.value = octreeSize; + // @ts-expect-error Material is not typed yet + material.uniforms.octreeSpacing.value = octreeSpacing; + // @ts-expect-error Material is not typed yet + material.uniforms.nodeDepth.value = depth; + // @ts-expect-error Material is not typed yet + material.uniforms.nodeBBoxMin.value.copy(bboxMin); + // @ts-expect-error Material is not typed yet + material.uniformsNeedUpdate = true; + }; + } + } this.dispatchEvent({ type: 'post-update' }); } @@ -559,6 +609,82 @@ abstract class PointCloudLayer } } } + + // Encoding the octree hierarchy in breadth-first order + // into a texture for adaptive point size rendering + // Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf + computeVisibilityTextureData(nodes: PointCloudNode[]) { + // sort by level and hierarchy order + const sort = function sortNodes(a: PointCloudNode, b: PointCloudNode) { + if (a.depth !== b.depth) { return a.depth - b.depth; } + // @ts-expect-error PointCloudNode has x properties + if (a.x !== b.x) { return a.x - b.x; } + // @ts-expect-error PointCloudNode has y properties + if (a.y !== b.y) { return a.y - b.y; } + // @ts-expect-error PointCloudNode has z properties + return a.z - b.z; + }; + // breadth-first order + const orderedNodes = nodes.toSorted(sort); + + const data = new Uint8Array(orderedNodes.length * 4); + const visibleNodeTextureOffsets = new Map(); + const offsetsToChild: number[] = new Array(orderedNodes.length).fill(Infinity); + + // Helper function to get octree child index from node + const getChildIndex = (node: PointCloudNode): number => { + if (!node.parent) { + return 0; + } + const parent = node.parent; + // @ts-expect-error PointCloudNode has x properties + const dx = node.x - parent.x * 2; + // @ts-expect-error PointCloudNode has y properties + const dy = node.y - parent.y * 2; + // @ts-expect-error PointCloudNode has z properties + const dz = node.z - parent.z * 2; + // Octree child index (Potree convention): 4*x + 2*y + z + return 4 * dx + 2 * dy + dz; + }; + + for (let nodeIndex = 0; nodeIndex < orderedNodes.length; nodeIndex++) { + const node = orderedNodes[nodeIndex]; + // @ts-expect-error PointCloudNode has voxelKey properties + visibleNodeTextureOffsets.set(node.voxelKey, nodeIndex); + + if (node.parent) { + const childIndex = getChildIndex(node); + // @ts-expect-error PointCloudNode has voxelKey properties + const parentIndex = visibleNodeTextureOffsets.get(node.parent.voxelKey); + + if (parentIndex === undefined) { + continue; + } + + const parentOffsetToChild = nodeIndex - parentIndex; + const offsetToFirstChild = + Math.min(offsetsToChild[parentIndex], parentOffsetToChild); + offsetsToChild[parentIndex] = offsetToFirstChild; + + // The 8 bits of the red value indicate + // which of the children are visible + data[parentIndex * 4] = data[parentIndex * 4] | (1 << childIndex); + // Offset to child is stored on 2 bytes, + // so it can support up to 65536 nodes per subtree. + // The green channel contains the relative offset + // to the node’s first child (8 most significant bits) + data[parentIndex * 4 + 1] = offsetToFirstChild >> 8; + // The blue channel contains the relative offset + // to the node’s first child (8 least significant bits) + data[parentIndex * 4 + 2] = offsetToFirstChild % 256; + } + } + + return { + data, + offsets: visibleNodeTextureOffsets, + }; + } } export default PointCloudLayer; diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index 05d2c2abff..759da147bc 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -25,6 +25,7 @@ export const PNTS_SHAPE = { export const PNTS_SIZE_MODE = { VALUE: 0, ATTENUATED: 1, + ADAPTIVE: 2, }; const white = new THREE.Color(1.0, 1.0, 1.0); @@ -245,6 +246,13 @@ class PointsMaterial extends THREE.ShaderMaterial { CommonMaterial.setUniformProperty(this, 'gamma', gamma); CommonMaterial.setUniformProperty(this, 'ambientBoost', ambientBoost); + // Adaptive point size uniforms + CommonMaterial.setUniformProperty(this, 'octreeSpacing', 1.0); + CommonMaterial.setUniformProperty(this, 'octreeSize', 1.0); + CommonMaterial.setUniformProperty(this, 'nodeDepth', 0.0); + CommonMaterial.setUniformProperty(this, 'nodeStartOffset', 0.0); + CommonMaterial.setUniformProperty(this, 'nodeBBoxMin', new THREE.Vector3()); + // add classification texture to apply classification lut. const data = new Uint8Array(256 * 4); const texture = new THREE.DataTexture(data, 256, 1, THREE.RGBAFormat); @@ -267,6 +275,12 @@ class PointsMaterial extends THREE.ShaderMaterial { textureVisi.magFilter = THREE.NearestFilter; CommonMaterial.setUniformProperty(this, 'visibilityTexture', textureVisi); + const dataNodes = new Uint8Array(2048 * 4); + const visibleNodesTexture = new THREE.DataTexture(dataNodes, 2048, 1, THREE.RGBAFormat); + visibleNodesTexture.needsUpdate = true; + visibleNodesTexture.magFilter = THREE.NearestFilter; + CommonMaterial.setUniformProperty(this, 'visibleNodes', visibleNodesTexture); + // Classification and other discrete values scheme this.classificationScheme = classificationScheme; this.discreteScheme = discreteScheme; diff --git a/packages/Main/src/Renderer/Shader/PointsVS.glsl b/packages/Main/src/Renderer/Shader/PointsVS.glsl index 3b272b431e..348d61c417 100644 --- a/packages/Main/src/Renderer/Shader/PointsVS.glsl +++ b/packages/Main/src/Renderer/Shader/PointsVS.glsl @@ -31,6 +31,14 @@ uniform int sizeMode; uniform float minAttenuatedSize; uniform float maxAttenuatedSize; +// Adaptive point size uniforms +uniform sampler2D visibleNodes; +uniform float octreeSpacing; +uniform float octreeSize; +uniform float nodeDepth; +uniform float nodeStartOffset; +uniform vec3 nodeBBoxMin; + attribute vec4 unique_id; attribute float intensity; attribute float classification; @@ -40,6 +48,61 @@ attribute float returnNumber; attribute float numberOfReturns; attribute float scanAngle; +// Adaptive point size calculation functions (from Potree) +/** + * number of 1-bits up to inclusive index position + * number is treated as if it were an integer in the range 0-255 + * + */ +int numberOfOnes(int number, int index) { + int numOnes = 0; + int tmp = 128; + for (int i = 7; i >= 0; i--) { + if (number >= tmp) { + number = number - tmp; + if (i <= index) { + numOnes++; + } + } + tmp = tmp / 2; + } + return numOnes; +} + +float getLOD() { + // Transform position from local space (relative to node origin) + // to octree space (relative to node bbox min) + vec3 pos = position - nodeBBoxMin; + vec3 offset = vec3(0.0, 0.0, 0.0); + int iOffset = int(nodeStartOffset); + float depth = nodeDepth; + for (float i = 0.0; i <= 30.0; i++) { + float nodeSizeAtLevel = octreeSize / pow(2.0, i + nodeDepth); + + vec3 index3d = (pos - offset) / nodeSizeAtLevel; + index3d = floor(index3d + 0.5); + int index = int(round(4.0 * index3d.x + 2.0 * index3d.y + index3d.z)); + + vec4 value = texture2D(visibleNodes, vec2(float(iOffset) / 2048.0, 0.0)); + int mask = int(round(value.r * 255.0)); + bool childNodeExist = bool(((mask >> index) & 1) != 0); + + if (childNodeExist) { + int greenChannelOffset = int(round(value.g * 255.0)) * 256; + int blueChannelOffset = int(round(value.b * 255.0)); + int childIndexOffset = numberOfOnes(mask, index - 1); + int totalOffset = greenChannelOffset + blueChannelOffset + childIndexOffset; + iOffset = iOffset + totalOffset; + depth++; + } else { + // no more visible child nodes at this position + return depth; + } + offset = offset + (vec3(1.0, 1.0, 1.0) * nodeSizeAtLevel * 0.5) * index3d; + } + return depth; +} + void main() { vec2 uv = vec2(classification/255., 0.5); @@ -117,13 +180,21 @@ void main() { gl_PointSize = size; - if (sizeMode == PNTS_SIZE_MODE_ATTENUATED) { - bool isPerspective = isPerspectiveMatrix(projectionMatrix); + bool isPerspective = isPerspectiveMatrix(projectionMatrix); + float depthAttenuationFactor = scale / -mvPosition.z; + if (sizeMode == PNTS_SIZE_MODE_ATTENUATED) { if (isPerspective) { - gl_PointSize *= scale / -mvPosition.z; + gl_PointSize *= depthAttenuationFactor; gl_PointSize = clamp(gl_PointSize, minAttenuatedSize, maxAttenuatedSize); } + } else if (sizeMode == PNTS_SIZE_MODE_ADAPTIVE) { + if (isPerspective) { + float r = octreeSpacing * 1.7; + float pointSizeAttenuation = pow(2.0, getLOD()); + float worldSpaceSize = size * r / pointSizeAttenuation; + gl_PointSize = worldSpaceSize * depthAttenuationFactor; + } } #include From 33d1352f24872ef963c58a375ec3edc827fcb74f Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Fri, 10 Apr 2026 14:20:23 +0200 Subject: [PATCH 02/14] chore(pointcloud): Compute visibility texture only if node visibility change # Conflicts: # packages/Main/src/Layer/PointCloudLayer.ts # Conflicts: # packages/Main/src/Layer/PointCloudLayer.ts --- packages/Main/src/Layer/PointCloudLayer.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 217076628f..3d500df89d 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -228,6 +228,8 @@ abstract class PointCloudLayer private _candidateNodes: TinyQueue; private _visibleNodes = new Set(); + private _visibilityTextureNeedsUpdate: boolean = true; + private _visibilityTextureData: { data: Uint8Array; offsets: Map } | undefined; /** * Constructs a new instance of point cloud layer. @@ -333,6 +335,7 @@ abstract class PointCloudLayer node.notVisibleSince = Date.now(); node.sse = -1; } + this._visibilityTextureNeedsUpdate = true; } preUpdate(context: Context) { @@ -537,12 +540,20 @@ abstract class PointCloudLayer // @ts-expect-error PointsMaterial is not typed yet if (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { - const visibilityTextureData = this.computeVisibilityTextureData(Array.from(this._visibleNodes)); + if (this._visibilityTextureNeedsUpdate) { + this._visibilityTextureData = + this.computeVisibilityTextureData(Array.from(this._visibleNodes)); + this._visibilityTextureNeedsUpdate = false; + } + + if (this._visibilityTextureData === undefined) { + return; + } // @ts-expect-error PointsMaterial is not typed yet const vnt = this.material.visibleNodes; const data = vnt.image.data; - data.set(visibilityTextureData.data); + data.set(this._visibilityTextureData.data); vnt.needsUpdate = true; // Use natBox (native octree space) for octreeSize, as the @@ -553,7 +564,7 @@ abstract class PointCloudLayer for (const pts of this.group.children) { const node = pts.userData.node; const depth = node.depth; - const nodeStartOffset = visibilityTextureData.offsets.get(node.voxelKey); + const nodeStartOffset = this._visibilityTextureData.offsets.get(node.voxelKey); const octreeSpacing = node.source.spacing; // Compute the bounding box min of the node's octree cell From 98e54092df848a83060ffc1598f350c7b0864f09 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Mon, 20 Apr 2026 19:37:47 +0200 Subject: [PATCH 03/14] chore(pointcloud): Move computeVisibility method to utils --- packages/Main/src/Layer/PointCloudLayer.ts | 79 +--------------------- packages/Main/src/Main.js | 1 + packages/Main/src/Utils/PointCloudUtils.ts | 77 +++++++++++++++++++++ 3 files changed, 80 insertions(+), 77 deletions(-) create mode 100644 packages/Main/src/Utils/PointCloudUtils.ts diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 3d500df89d..be176afbac 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -3,6 +3,7 @@ import TinyQueue from 'tinyqueue'; import GeometryLayer from 'Layer/GeometryLayer'; import PointsMaterial, { PNTS_MODE, PNTS_SIZE_MODE } from 'Renderer/PointsMaterial'; import Picking from 'Core/Picking'; +import { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; import type PointCloudNode from 'Core/PointCloudNode'; @@ -542,7 +543,7 @@ abstract class PointCloudLayer if (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { if (this._visibilityTextureNeedsUpdate) { this._visibilityTextureData = - this.computeVisibilityTextureData(Array.from(this._visibleNodes)); + computeVisibilityTextureData(Array.from(this._visibleNodes)); this._visibilityTextureNeedsUpdate = false; } @@ -620,82 +621,6 @@ abstract class PointCloudLayer } } } - - // Encoding the octree hierarchy in breadth-first order - // into a texture for adaptive point size rendering - // Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf - computeVisibilityTextureData(nodes: PointCloudNode[]) { - // sort by level and hierarchy order - const sort = function sortNodes(a: PointCloudNode, b: PointCloudNode) { - if (a.depth !== b.depth) { return a.depth - b.depth; } - // @ts-expect-error PointCloudNode has x properties - if (a.x !== b.x) { return a.x - b.x; } - // @ts-expect-error PointCloudNode has y properties - if (a.y !== b.y) { return a.y - b.y; } - // @ts-expect-error PointCloudNode has z properties - return a.z - b.z; - }; - // breadth-first order - const orderedNodes = nodes.toSorted(sort); - - const data = new Uint8Array(orderedNodes.length * 4); - const visibleNodeTextureOffsets = new Map(); - const offsetsToChild: number[] = new Array(orderedNodes.length).fill(Infinity); - - // Helper function to get octree child index from node - const getChildIndex = (node: PointCloudNode): number => { - if (!node.parent) { - return 0; - } - const parent = node.parent; - // @ts-expect-error PointCloudNode has x properties - const dx = node.x - parent.x * 2; - // @ts-expect-error PointCloudNode has y properties - const dy = node.y - parent.y * 2; - // @ts-expect-error PointCloudNode has z properties - const dz = node.z - parent.z * 2; - // Octree child index (Potree convention): 4*x + 2*y + z - return 4 * dx + 2 * dy + dz; - }; - - for (let nodeIndex = 0; nodeIndex < orderedNodes.length; nodeIndex++) { - const node = orderedNodes[nodeIndex]; - // @ts-expect-error PointCloudNode has voxelKey properties - visibleNodeTextureOffsets.set(node.voxelKey, nodeIndex); - - if (node.parent) { - const childIndex = getChildIndex(node); - // @ts-expect-error PointCloudNode has voxelKey properties - const parentIndex = visibleNodeTextureOffsets.get(node.parent.voxelKey); - - if (parentIndex === undefined) { - continue; - } - - const parentOffsetToChild = nodeIndex - parentIndex; - const offsetToFirstChild = - Math.min(offsetsToChild[parentIndex], parentOffsetToChild); - offsetsToChild[parentIndex] = offsetToFirstChild; - - // The 8 bits of the red value indicate - // which of the children are visible - data[parentIndex * 4] = data[parentIndex * 4] | (1 << childIndex); - // Offset to child is stored on 2 bytes, - // so it can support up to 65536 nodes per subtree. - // The green channel contains the relative offset - // to the node’s first child (8 most significant bits) - data[parentIndex * 4 + 1] = offsetToFirstChild >> 8; - // The blue channel contains the relative offset - // to the node’s first child (8 least significant bits) - data[parentIndex * 4 + 2] = offsetToFirstChild % 256; - } - } - - return { - data, - offsets: visibleNodeTextureOffsets, - }; - } } export default PointCloudLayer; diff --git a/packages/Main/src/Main.js b/packages/Main/src/Main.js index 5261034d8a..fbe77ce103 100644 --- a/packages/Main/src/Main.js +++ b/packages/Main/src/Main.js @@ -32,6 +32,7 @@ export { default as Feature2Mesh } from 'Converter/Feature2Mesh'; export { default as FeaturesUtils } from 'Utils/FeaturesUtils'; export { default as DEMUtils } from 'Utils/DEMUtils'; export { default as CameraUtils } from 'Utils/CameraUtils'; +export { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; export { default as ShaderChunk } from 'Renderer/Shader/ShaderChunk'; export { getMaxColorSamplerUnitsCount, colorLayerEffects } from 'Renderer/LayeredMaterial'; export { default as Capabilities } from 'Core/System/Capabilities'; diff --git a/packages/Main/src/Utils/PointCloudUtils.ts b/packages/Main/src/Utils/PointCloudUtils.ts new file mode 100644 index 0000000000..4d6f6233a2 --- /dev/null +++ b/packages/Main/src/Utils/PointCloudUtils.ts @@ -0,0 +1,77 @@ +import type PointCloudNode from '../Core/PointCloudNode'; + +// Encoding the octree hierarchy in breadth-first order +// into a texture for adaptive point size rendering +// Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf +export function computeVisibilityTextureData(nodes: PointCloudNode[]) { + // sort by level and hierarchy order + const sort = function sortNodes(a: PointCloudNode, b: PointCloudNode) { + if (a.depth !== b.depth) { return a.depth - b.depth; } + // @ts-expect-error PointCloudNode has x properties + if (a.x !== b.x) { return a.x - b.x; } + // @ts-expect-error PointCloudNode has y properties + if (a.y !== b.y) { return a.y - b.y; } + // @ts-expect-error PointCloudNode has z properties + return a.z - b.z; + }; + // breadth-first order + const orderedNodes = [...nodes].sort(sort); + + const data = new Uint8Array(orderedNodes.length * 4); + const visibleNodeTextureOffsets = new Map(); + const offsetsToChild: number[] = new Array(orderedNodes.length).fill(Infinity); + + // Helper function to get octree child index from node + const getChildIndex = (node: PointCloudNode): number => { + if (!node.parent) { + return 0; + } + const parent = node.parent; + // @ts-expect-error PointCloudNode has x properties + const dx = node.x - parent.x * 2; + // @ts-expect-error PointCloudNode has y properties + const dy = node.y - parent.y * 2; + // @ts-expect-error PointCloudNode has z properties + const dz = node.z - parent.z * 2; + // Octree child index (Potree convention): 4*x + 2*y + z + return 4 * dx + 2 * dy + dz; + }; + + for (let nodeIndex = 0; nodeIndex < orderedNodes.length; nodeIndex++) { + const node = orderedNodes[nodeIndex]; + // @ts-expect-error PointCloudNode has voxelKey properties + visibleNodeTextureOffsets.set(node.voxelKey, nodeIndex); + + if (node.parent) { + const childIndex = getChildIndex(node); + // @ts-expect-error PointCloudNode has voxelKey properties + const parentIndex = visibleNodeTextureOffsets.get(node.parent.voxelKey); + + if (parentIndex === undefined) { + continue; + } + + const parentOffsetToChild = nodeIndex - parentIndex; + const offsetToFirstChild = + Math.min(offsetsToChild[parentIndex], parentOffsetToChild); + offsetsToChild[parentIndex] = offsetToFirstChild; + + // The 8 bits of the red value indicate + // which of the children are visible + data[parentIndex * 4] = data[parentIndex * 4] | (1 << childIndex); + // Offset to child is stored on 2 bytes, + // so it can support up to 65536 nodes per subtree. + // The green channel contains the relative offset + // to the node’s first child (8 most significant bits) + data[parentIndex * 4 + 1] = offsetToFirstChild >> 8; + // The blue channel contains the relative offset + // to the node’s first child (8 least significant bits) + data[parentIndex * 4 + 2] = offsetToFirstChild % 256; + } + } + + return { + data, + offsets: visibleNodeTextureOffsets, + }; +} From b8607cfe94db3ecf82a9d3b40c7fcd76fe47061a Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Tue, 12 May 2026 08:26:06 +0200 Subject: [PATCH 04/14] chore(pointcloud): Use nodeToIndex --- packages/Main/src/Layer/PointCloudLayer.ts | 4 ++-- packages/Main/src/Utils/PointCloudUtils.ts | 16 ++++------------ 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index be176afbac..38fe6ce6a6 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -230,7 +230,7 @@ abstract class PointCloudLayer private _candidateNodes: TinyQueue; private _visibleNodes = new Set(); private _visibilityTextureNeedsUpdate: boolean = true; - private _visibilityTextureData: { data: Uint8Array; offsets: Map } | undefined; + private _visibilityTextureData: { data: Uint8Array; nodeToIndex: Map } | undefined; /** * Constructs a new instance of point cloud layer. @@ -565,7 +565,7 @@ abstract class PointCloudLayer for (const pts of this.group.children) { const node = pts.userData.node; const depth = node.depth; - const nodeStartOffset = this._visibilityTextureData.offsets.get(node.voxelKey); + const nodeStartOffset = this._visibilityTextureData.nodeToIndex.get(node); const octreeSpacing = node.source.spacing; // Compute the bounding box min of the node's octree cell diff --git a/packages/Main/src/Utils/PointCloudUtils.ts b/packages/Main/src/Utils/PointCloudUtils.ts index 4d6f6233a2..26c974977e 100644 --- a/packages/Main/src/Utils/PointCloudUtils.ts +++ b/packages/Main/src/Utils/PointCloudUtils.ts @@ -7,18 +7,15 @@ export function computeVisibilityTextureData(nodes: PointCloudNode[]) { // sort by level and hierarchy order const sort = function sortNodes(a: PointCloudNode, b: PointCloudNode) { if (a.depth !== b.depth) { return a.depth - b.depth; } - // @ts-expect-error PointCloudNode has x properties if (a.x !== b.x) { return a.x - b.x; } - // @ts-expect-error PointCloudNode has y properties if (a.y !== b.y) { return a.y - b.y; } - // @ts-expect-error PointCloudNode has z properties return a.z - b.z; }; // breadth-first order const orderedNodes = [...nodes].sort(sort); const data = new Uint8Array(orderedNodes.length * 4); - const visibleNodeTextureOffsets = new Map(); + const nodeToIndex = new Map(); const offsetsToChild: number[] = new Array(orderedNodes.length).fill(Infinity); // Helper function to get octree child index from node @@ -27,11 +24,8 @@ export function computeVisibilityTextureData(nodes: PointCloudNode[]) { return 0; } const parent = node.parent; - // @ts-expect-error PointCloudNode has x properties const dx = node.x - parent.x * 2; - // @ts-expect-error PointCloudNode has y properties const dy = node.y - parent.y * 2; - // @ts-expect-error PointCloudNode has z properties const dz = node.z - parent.z * 2; // Octree child index (Potree convention): 4*x + 2*y + z return 4 * dx + 2 * dy + dz; @@ -39,13 +33,11 @@ export function computeVisibilityTextureData(nodes: PointCloudNode[]) { for (let nodeIndex = 0; nodeIndex < orderedNodes.length; nodeIndex++) { const node = orderedNodes[nodeIndex]; - // @ts-expect-error PointCloudNode has voxelKey properties - visibleNodeTextureOffsets.set(node.voxelKey, nodeIndex); + nodeToIndex.set(node, nodeIndex); if (node.parent) { const childIndex = getChildIndex(node); - // @ts-expect-error PointCloudNode has voxelKey properties - const parentIndex = visibleNodeTextureOffsets.get(node.parent.voxelKey); + const parentIndex = nodeToIndex.get(node.parent); if (parentIndex === undefined) { continue; @@ -72,6 +64,6 @@ export function computeVisibilityTextureData(nodes: PointCloudNode[]) { return { data, - offsets: visibleNodeTextureOffsets, + nodeToIndex, }; } From ac72acc5ed06f0cd1a89e647c67700f2ef9a071d Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 30 Apr 2026 07:01:11 +0200 Subject: [PATCH 05/14] chore(pointcloud): refactor pointCloudNode sort --- packages/Main/src/Core/PointCloudNode.ts | 20 ++-- packages/Main/src/Layer/PointCloudLayer.ts | 2 +- packages/Main/src/Utils/PointCloudUtils.ts | 115 ++++++++++++--------- 3 files changed, 78 insertions(+), 59 deletions(-) diff --git a/packages/Main/src/Core/PointCloudNode.ts b/packages/Main/src/Core/PointCloudNode.ts index 6666544584..02c8aa37b6 100644 --- a/packages/Main/src/Core/PointCloudNode.ts +++ b/packages/Main/src/Core/PointCloudNode.ts @@ -123,14 +123,18 @@ abstract class PointCloudNode extends THREE.EventDispatcher { const y = this.y * 2; const z = this.z * 2; - this.findAndCreateChild(depth, x, y, z); - this.findAndCreateChild(depth, x + 1, y, z); - this.findAndCreateChild(depth, x, y + 1, z); - this.findAndCreateChild(depth, x + 1, y + 1, z); - this.findAndCreateChild(depth, x, y, z + 1); - this.findAndCreateChild(depth, x + 1, y, z + 1); - this.findAndCreateChild(depth, x, y + 1, z + 1); - this.findAndCreateChild(depth, x + 1, y + 1, z + 1); + // Order follows ascending Potree child index (4*dx + 2*dy + dz), + // i.e. 0→7, so that node.children is always in Potree index order. + // This is required by the visibility texture encoding used for + // adaptive point-size rendering in the shader. + this.findAndCreateChild(depth, x, y, z); // 0 + this.findAndCreateChild(depth, x, y, z + 1); // 1 + this.findAndCreateChild(depth, x, y + 1, z); // 2 + this.findAndCreateChild(depth, x, y + 1, z + 1); // 3 + this.findAndCreateChild(depth, x + 1, y, z); // 4 + this.findAndCreateChild(depth, x + 1, y, z + 1); // 5 + this.findAndCreateChild(depth, x + 1, y + 1, z); // 6 + this.findAndCreateChild(depth, x + 1, y + 1, z + 1); // 7 this._childrenCreated = true; } diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 38fe6ce6a6..6bf07aade3 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -543,7 +543,7 @@ abstract class PointCloudLayer if (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { if (this._visibilityTextureNeedsUpdate) { this._visibilityTextureData = - computeVisibilityTextureData(Array.from(this._visibleNodes)); + computeVisibilityTextureData(this.root!, Array.from(this._visibleNodes)); this._visibilityTextureNeedsUpdate = false; } diff --git a/packages/Main/src/Utils/PointCloudUtils.ts b/packages/Main/src/Utils/PointCloudUtils.ts index 26c974977e..04fb88c3c9 100644 --- a/packages/Main/src/Utils/PointCloudUtils.ts +++ b/packages/Main/src/Utils/PointCloudUtils.ts @@ -1,65 +1,80 @@ import type PointCloudNode from '../Core/PointCloudNode'; -// Encoding the octree hierarchy in breadth-first order -// into a texture for adaptive point size rendering -// Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf -export function computeVisibilityTextureData(nodes: PointCloudNode[]) { - // sort by level and hierarchy order - const sort = function sortNodes(a: PointCloudNode, b: PointCloudNode) { - if (a.depth !== b.depth) { return a.depth - b.depth; } - if (a.x !== b.x) { return a.x - b.x; } - if (a.y !== b.y) { return a.y - b.y; } - return a.z - b.z; - }; - // breadth-first order - const orderedNodes = [...nodes].sort(sort); +// Returns the Potree child index (0-7) of a node relative to its parent +// based on coordinates (convention: 4*dx + 2*dy + dz) +function getPotreeChildIndex(node: PointCloudNode): number { + const parent = node.parent!; + const dx = node.x - parent.x * 2; + const dy = node.y - parent.y * 2; + const dz = node.z - parent.z * 2; + return 4 * dx + 2 * dy + dz; +} - const data = new Uint8Array(orderedNodes.length * 4); - const nodeToIndex = new Map(); - const offsetsToChild: number[] = new Array(orderedNodes.length).fill(Infinity); +function encodeChildrenVisibility(node: PointCloudNode, nodeIndex: number, + nodeToIndex: Map): { minOffset: number, childVisibilityMask: number } { + let minOffset = Infinity; + let childVisibilityMask = 0; - // Helper function to get octree child index from node - const getChildIndex = (node: PointCloudNode): number => { - if (!node.parent) { - return 0; + for (const child of node.children) { + const childIndex = nodeToIndex.get(child); + if (childIndex === undefined) { + continue; } - const parent = node.parent; - const dx = node.x - parent.x * 2; - const dy = node.y - parent.y * 2; - const dz = node.z - parent.z * 2; - // Octree child index (Potree convention): 4*x + 2*y + z - return 4 * dx + 2 * dy + dz; + + childVisibilityMask |= (1 << getPotreeChildIndex(child)); + + const offset = childIndex - nodeIndex; + if (offset < minOffset) { + minOffset = offset; + } + } + + return { + minOffset, + childVisibilityMask, }; +} - for (let nodeIndex = 0; nodeIndex < orderedNodes.length; nodeIndex++) { - const node = orderedNodes[nodeIndex]; - nodeToIndex.set(node, nodeIndex); +// Encoding the octree hierarchy in breadth-first order +// into a texture for adaptive point size rendering +// Explanation p36: https://www.cg.tuwien.ac.at/research/publications/2016/SCHUETZ-2016-POT/SCHUETZ-2016-POT-thesis.pdf +export function computeVisibilityTextureData(root: PointCloudNode, visibleNodes: PointCloudNode[]) { + const visibleSet = new Set(visibleNodes); - if (node.parent) { - const childIndex = getChildIndex(node); - const parentIndex = nodeToIndex.get(node.parent); + const nodeToIndex = new Map(); + const queue: PointCloudNode[] = [root]; + let index = 0; + while (queue.length > 0) { + const node = queue.shift() as PointCloudNode; + if (!visibleSet.has(node)) { + continue; + } + nodeToIndex.set(node, index++); + for (const child of node.children) { + queue.push(child); + } + } - if (parentIndex === undefined) { - continue; - } + const data = new Uint8Array(visibleNodes.length * 4); - const parentOffsetToChild = nodeIndex - parentIndex; - const offsetToFirstChild = - Math.min(offsetsToChild[parentIndex], parentOffsetToChild); - offsetsToChild[parentIndex] = offsetToFirstChild; + for (const [node, nodeIndex] of nodeToIndex) { + const { minOffset, childVisibilityMask } = + encodeChildrenVisibility(node, nodeIndex, nodeToIndex); - // The 8 bits of the red value indicate - // which of the children are visible - data[parentIndex * 4] = data[parentIndex * 4] | (1 << childIndex); - // Offset to child is stored on 2 bytes, - // so it can support up to 65536 nodes per subtree. - // The green channel contains the relative offset - // to the node’s first child (8 most significant bits) - data[parentIndex * 4 + 1] = offsetToFirstChild >> 8; - // The blue channel contains the relative offset - // to the node’s first child (8 least significant bits) - data[parentIndex * 4 + 2] = offsetToFirstChild % 256; + if (minOffset === Infinity) { + continue; } + + // Red channel: bitmask of which children are visible + data[nodeIndex * 4] = childVisibilityMask; + // Offset to child is stored on 2 bytes, + // so it can support up to 65536 nodes per subtree. + // The green channel contains the relative offset + // to the node's first child (8 most significant bits) + data[nodeIndex * 4 + 1] = minOffset >> 8; + // The blue channel contains the relative offset + // to the node's first child (8 least significant bits) + data[nodeIndex * 4 + 2] = minOffset % 256; } return { From f0086fd673972c17fde56b5736cb2a4ad07f2312 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Tue, 12 May 2026 17:29:58 +0200 Subject: [PATCH 06/14] chore(pointcloud): Fix pointCloudLayer (produce wrong LODs for point clouds whose source CRS differs from the scene CRS) --- packages/Main/src/Layer/PointCloudLayer.ts | 33 ++++++++++++---------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 6bf07aade3..168f210d4a 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -557,9 +557,14 @@ abstract class PointCloudLayer data.set(this._visibilityTextureData.data); vnt.needsUpdate = true; - // Use natBox (native octree space) for octreeSize, as the - // octree hierarchy is defined in the source CRS coordinate system - const rootSize = this.root!.voxelOBB.natBox.getSize(new THREE.Vector3()); + // Use box3D (projected in the local geometry coordinate frame) for + // octreeSize and bboxMin, because the vertex shader positions are + // expressed in that same local frame after CRS reprojection and + // quaternion rotation applied by the parsers. + // Using natBox (native source CRS) would mix coordinate systems + // (e.g. Lambert93 metres vs. ECEF metres) and produce wrong LODs + // for point clouds whose source CRS differs from the scene CRS. + const rootSize = this.root!.voxelOBB.box3D.getSize(new THREE.Vector3()); const octreeSize = Math.max(rootSize.x, rootSize.y, rootSize.z); for (const pts of this.group.children) { @@ -568,18 +573,16 @@ abstract class PointCloudLayer const nodeStartOffset = this._visibilityTextureData.nodeToIndex.get(node); const octreeSpacing = node.source.spacing; - // Compute the bounding box min of the node's octree cell - // in the local space of the THREE.Points geometry. - // We must use voxelOBB.natBox.min (the octree cell boundary - // in source CRS) rather than geomBBox.min (which only covers - // the actual points, not the full octree cell). - const natMin = node.voxelOBB.natBox.min; - const origin = node.obj.position; - const bboxMin = new THREE.Vector3( - natMin.x - origin.x, - natMin.y - origin.y, - natMin.z - origin.z, - ); + // Compute the bounding box min of the node's octree cell in + // the local coordinate frame used by the THREE.Points geometry. + // The parsers store each point as: + // quat * (forward(nativePos) - centerZ0) + // where centerZ0 = voxelOBB.position and quat is computed at + // that same position. projOBB() builds box3D with the exact + // same origin and the same quaternion, so box3D.min is already + // expressed in the geometry's local frame — no further + // transformation is needed. + const bboxMin = node.voxelOBB.box3D.min.clone(); pts.onBeforeRender = (_renderer, _scene, _camera, _geometry, material) => { // @ts-expect-error Material is not typed yet From fce77c825d51acf512b41681b8f30178985ec2b2 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Wed, 13 May 2026 09:59:03 +0200 Subject: [PATCH 07/14] chore(pointcloud): Add unit test for adaptivePointSize --- packages/Main/test/unit/adaptivePointSize.js | 122 +++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/Main/test/unit/adaptivePointSize.js diff --git a/packages/Main/test/unit/adaptivePointSize.js b/packages/Main/test/unit/adaptivePointSize.js new file mode 100644 index 0000000000..1103307a94 --- /dev/null +++ b/packages/Main/test/unit/adaptivePointSize.js @@ -0,0 +1,122 @@ +import assert from 'assert'; +import PointCloudNode, { buildVoxelKey } from 'Core/PointCloudNode'; +import { computeVisibilityTextureData } from 'Utils/PointCloudUtils'; + +function createNode(depth, x, y, z) { + const node = new PointCloudNode(depth); + node.x = x; + node.y = y; + node.z = z; + node.voxelKey = buildVoxelKey(depth, x, y, z); + // Override setOBBes so node.add() doesn't require a real source/crs. + node.setOBBes = () => {}; + return node; +} + +// Counts the number of set bits in `mask` at positions 0..maxIndex (inclusive). +// Mirrors the GLSL helper numberOfOnes() used in PointsVS.glsl. +function numberOfOnes(mask, maxIndex) { + let count = 0; + for (let i = 0; i <= maxIndex; i++) { + if ((mask >> i) & 1) { count++; } + } + return count; +} + +describe('Adaptive point size', function () { + describe('computeVisibilityTextureData', function () { + it('Single visible root → nodeToIndex has one entry', function () { + const root = createNode(0, 0, 0, 0); + + const { data, nodeToIndex } = computeVisibilityTextureData(root, [root]); + + // BFS should have assigned index 0 to the root. + assert.strictEqual(nodeToIndex.size, 1); + assert.strictEqual(nodeToIndex.get(root), 0); + + // Output buffer: 1 node × 4 channels. + assert.strictEqual(data.length, 4); + + // No visible children → all channels 0. + assert.strictEqual(data[0], 0, 'R channel (child mask) should be 0'); + assert.strictEqual(data[1], 0, 'G channel (offset high byte) should be 0'); + assert.strictEqual(data[2], 0, 'B channel (offset low byte) should be 0'); + }); + + it('3-level tree → correct BFS indices, mask and offset', function () { + const root = createNode(0, 0, 0, 0); + // childA : potree index 0 (dx=0, dy=0, dz=0) + const childA = createNode(1, 0, 0, 0); + // childB : potree index 4 (dx=1, dy=0, dz=0) + const childB = createNode(1, 1, 0, 0); + // grandChildren of childA (potree indices 0 and 1 relative to childA) + const childAA = createNode(2, 0, 0, 0); + const childAB = createNode(2, 0, 0, 1); + + root.add(childA); + root.add(childB); + childA.add(childAA); + childA.add(childAB); + + const visibleNodes = [root, childA, childAA, childAB, childB]; + const { data, nodeToIndex } = computeVisibilityTextureData(root, visibleNodes); + + // BFS assigns root=0, childA=1, childB=2. + assert.equal(nodeToIndex.get(root), 0); + assert.equal(nodeToIndex.get(childA), 1); + assert.equal(nodeToIndex.get(childB), 2); + assert.equal(nodeToIndex.get(childAA), 3, 'childAA BFS index'); + assert.equal(nodeToIndex.get(childAB), 4, 'childAB BFS index'); + + // Root encoding: + // mask = (1 << 0) | (1 << 4) = 1 + 16 = 17 + // minOffset = min(1-0, 2-0) = 1 + assert.equal(data[0], 17, 'root R channel (child mask)'); + assert.equal(data[1], 0, 'root G channel (offset high byte)'); + assert.equal(data[2], 1, 'root B channel (offset low byte = 1)'); + + // childA: both childAA (potree idx 0) and childAB (potree idx 1) are visible. + // mask = (1 << 0) | (1 << 1) = 3 + // minOffset = min(3-1, 4-1) = 2 + assert.equal(data[4], 3, 'childA R channel (mask for childAA and childAB)'); + assert.equal(data[5], 0, 'childA G channel (offset high byte)'); + assert.equal(data[6], 2, 'childA B channel (offset low byte = 2)'); + + // childB has no visible sub-children → its channels remain 0. + assert.equal(data[8], 0, 'childB R channel'); + + // grandChildren have no visible sub-children. + assert.strictEqual(data[12], 0, 'childAA R channel'); + assert.strictEqual(data[16], 0, 'childAB R channel'); + + // --- Octree reconstruction from texture data --- + // Invert nodeToIndex to get indexToNode + const indexToNode = new Map(); + for (const [node, idx] of nodeToIndex) { + indexToNode.set(idx, node); + } + + // For each visible node, decode its children list from the texture + const reconstructed = new Map(); + for (const [node, nodeIdx] of nodeToIndex) { + const mask = data[nodeIdx * 4]; + const minOffset = (data[nodeIdx * 4 + 1] << 8) | data[nodeIdx * 4 + 2]; + const children = []; + for (let bit = 0; bit < 8; bit++) { + if ((mask >> bit) & 1) { + const childOffset = numberOfOnes(mask, bit - 1); + const childIdx = nodeIdx + minOffset + childOffset; + children.push(indexToNode.get(childIdx)); + } + } + reconstructed.set(node, children); + } + + assert.deepStrictEqual(reconstructed.get(root), [childA, childB], 'reconstructed root children'); + assert.deepStrictEqual(reconstructed.get(childA), [childAA, childAB], 'reconstructed childA children'); + assert.deepStrictEqual(reconstructed.get(childB), [], 'reconstructed childB has no children'); + assert.deepStrictEqual(reconstructed.get(childAA), [], 'reconstructed childAA has no children'); + assert.deepStrictEqual(reconstructed.get(childAB), [], 'reconstructed childAB has no children'); + }); + }); +}); From b41e8fbaf612fc908050e1376a59d44869f77e33 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 10:41:43 +0200 Subject: [PATCH 08/14] chore(pointcloud): Hide not required filed in PointCloudDebug, fix clear texture --- packages/Debug/src/PointCloudDebug.js | 2 +- packages/Main/src/Layer/PointCloudLayer.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/Debug/src/PointCloudDebug.js b/packages/Debug/src/PointCloudDebug.js index 1bd7dca93d..fb609a763b 100644 --- a/packages/Debug/src/PointCloudDebug.js +++ b/packages/Debug/src/PointCloudDebug.js @@ -40,7 +40,7 @@ function setupControllerVisibily(gui, displayMode, sizeMode) { } sizeMode = parseInt(sizeMode, 10); - if (sizeMode === PNTS_SIZE_MODE.VALUE) { + if (sizeMode === PNTS_SIZE_MODE.VALUE || sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { getController(gui, 'minAttenuatedSize').hide(); getController(gui, 'maxAttenuatedSize').hide(); } else { diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 168f210d4a..cdd9660c3c 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -554,6 +554,7 @@ abstract class PointCloudLayer // @ts-expect-error PointsMaterial is not typed yet const vnt = this.material.visibleNodes; const data = vnt.image.data; + data.fill(0); data.set(this._visibilityTextureData.data); vnt.needsUpdate = true; From aaaf60be41bec9fd2131b23891602f98a13094e1 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 10:54:12 +0200 Subject: [PATCH 09/14] chore(pointcloud): Fix sample at mid-pixel in case of a small floating-point error to not sample the neighbour texel --- packages/Main/src/Renderer/Shader/PointsVS.glsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/Main/src/Renderer/Shader/PointsVS.glsl b/packages/Main/src/Renderer/Shader/PointsVS.glsl index 348d61c417..a12536bf2c 100644 --- a/packages/Main/src/Renderer/Shader/PointsVS.glsl +++ b/packages/Main/src/Renderer/Shader/PointsVS.glsl @@ -83,7 +83,7 @@ float getLOD() { index3d = floor(index3d + 0.5); int index = int(round(4.0 * index3d.x + 2.0 * index3d.y + index3d.z)); - vec4 value = texture2D(visibleNodes, vec2(float(iOffset) / 2048.0, 0.0)); + vec4 value = texture2D(visibleNodes, vec2((float(iOffset) + 0.5) / 2048.0, 0.0)); int mask = int(round(value.r * 255.0)); bool childNodeExist = bool(((mask >> index) & 1) != 0); From 12185a020c859d522fc58df73530565800d25a8c Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 10:56:42 +0200 Subject: [PATCH 10/14] chore(pointcloud): Add clamp to be sure index3d can't be outside [0,1] --- packages/Main/src/Renderer/Shader/PointsVS.glsl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/Main/src/Renderer/Shader/PointsVS.glsl b/packages/Main/src/Renderer/Shader/PointsVS.glsl index a12536bf2c..a4a93bdd87 100644 --- a/packages/Main/src/Renderer/Shader/PointsVS.glsl +++ b/packages/Main/src/Renderer/Shader/PointsVS.glsl @@ -80,7 +80,7 @@ float getLOD() { float nodeSizeAtLevel = octreeSize / pow(2.0, i + nodeDepth); vec3 index3d = (pos - offset) / nodeSizeAtLevel; - index3d = floor(index3d + 0.5); + index3d = clamp(floor(index3d + 0.5), 0.0, 1.0); int index = int(round(4.0 * index3d.x + 2.0 * index3d.y + index3d.z)); vec4 value = texture2D(visibleNodes, vec2((float(iOffset) + 0.5) / 2048.0, 0.0)); From ab79645c0fa3339630907b82306423c1dadfd67a Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 11:34:46 +0200 Subject: [PATCH 11/14] chore(pointcloud): Use vec3 for octreeSize (hack to handle globe view reprojection) --- packages/Main/src/Layer/PointCloudLayer.ts | 3 +-- packages/Main/src/Renderer/PointsMaterial.js | 2 +- packages/Main/src/Renderer/Shader/PointsVS.glsl | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index cdd9660c3c..00c22d9106 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -566,7 +566,6 @@ abstract class PointCloudLayer // (e.g. Lambert93 metres vs. ECEF metres) and produce wrong LODs // for point clouds whose source CRS differs from the scene CRS. const rootSize = this.root!.voxelOBB.box3D.getSize(new THREE.Vector3()); - const octreeSize = Math.max(rootSize.x, rootSize.y, rootSize.z); for (const pts of this.group.children) { const node = pts.userData.node; @@ -589,7 +588,7 @@ abstract class PointCloudLayer // @ts-expect-error Material is not typed yet material.uniforms.nodeStartOffset.value = nodeStartOffset; // @ts-expect-error Material is not typed yet - material.uniforms.octreeSize.value = octreeSize; + material.uniforms.octreeSize.value.copy(rootSize); // @ts-expect-error Material is not typed yet material.uniforms.octreeSpacing.value = octreeSpacing; // @ts-expect-error Material is not typed yet diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index 759da147bc..ba67ab48dd 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -248,7 +248,7 @@ class PointsMaterial extends THREE.ShaderMaterial { // Adaptive point size uniforms CommonMaterial.setUniformProperty(this, 'octreeSpacing', 1.0); - CommonMaterial.setUniformProperty(this, 'octreeSize', 1.0); + CommonMaterial.setUniformProperty(this, 'octreeSize', new THREE.Vector3()); CommonMaterial.setUniformProperty(this, 'nodeDepth', 0.0); CommonMaterial.setUniformProperty(this, 'nodeStartOffset', 0.0); CommonMaterial.setUniformProperty(this, 'nodeBBoxMin', new THREE.Vector3()); diff --git a/packages/Main/src/Renderer/Shader/PointsVS.glsl b/packages/Main/src/Renderer/Shader/PointsVS.glsl index a4a93bdd87..afe02b999b 100644 --- a/packages/Main/src/Renderer/Shader/PointsVS.glsl +++ b/packages/Main/src/Renderer/Shader/PointsVS.glsl @@ -34,7 +34,7 @@ uniform float maxAttenuatedSize; // Adaptive point size uniforms uniform sampler2D visibleNodes; uniform float octreeSpacing; -uniform float octreeSize; +uniform vec3 octreeSize; uniform float nodeDepth; uniform float nodeStartOffset; uniform vec3 nodeBBoxMin; @@ -77,7 +77,7 @@ float getLOD() { int iOffset = int(nodeStartOffset); float depth = nodeDepth; for (float i = 0.0; i <= 30.0; i++) { - float nodeSizeAtLevel = octreeSize / pow(2.0, i + nodeDepth); + vec3 nodeSizeAtLevel = octreeSize / pow(2.0, i + nodeDepth); vec3 index3d = (pos - offset) / nodeSizeAtLevel; index3d = clamp(floor(index3d + 0.5), 0.0, 1.0); From c2975c1b71dee70e8420efbe5df8401ca6d1c03c Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 14:04:35 +0200 Subject: [PATCH 12/14] chore(pointcloud): Fix lint --- packages/Main/src/Layer/PointCloudLayer.ts | 12 ++++++++---- packages/Main/src/Utils/PointCloudUtils.ts | 13 ++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/Main/src/Layer/PointCloudLayer.ts b/packages/Main/src/Layer/PointCloudLayer.ts index 00c22d9106..e594aae7df 100644 --- a/packages/Main/src/Layer/PointCloudLayer.ts +++ b/packages/Main/src/Layer/PointCloudLayer.ts @@ -229,7 +229,7 @@ abstract class PointCloudLayer private _candidateNodes: TinyQueue; private _visibleNodes = new Set(); - private _visibilityTextureNeedsUpdate: boolean = true; + private _visibilityTextureNeedsUpdate = true; private _visibilityTextureData: { data: Uint8Array; nodeToIndex: Map } | undefined; /** @@ -540,10 +540,10 @@ abstract class PointCloudLayer } // @ts-expect-error PointsMaterial is not typed yet - if (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { + if (this.root && (this.material.sizeMode === PNTS_SIZE_MODE.ADAPTIVE)) { if (this._visibilityTextureNeedsUpdate) { this._visibilityTextureData = - computeVisibilityTextureData(this.root!, Array.from(this._visibleNodes)); + computeVisibilityTextureData(this.root, Array.from(this._visibleNodes)); this._visibilityTextureNeedsUpdate = false; } @@ -565,7 +565,7 @@ abstract class PointCloudLayer // Using natBox (native source CRS) would mix coordinate systems // (e.g. Lambert93 metres vs. ECEF metres) and produce wrong LODs // for point clouds whose source CRS differs from the scene CRS. - const rootSize = this.root!.voxelOBB.box3D.getSize(new THREE.Vector3()); + const rootSize = this.root.voxelOBB.box3D.getSize(new THREE.Vector3()); for (const pts of this.group.children) { const node = pts.userData.node; @@ -573,6 +573,10 @@ abstract class PointCloudLayer const nodeStartOffset = this._visibilityTextureData.nodeToIndex.get(node); const octreeSpacing = node.source.spacing; + if (nodeStartOffset === undefined) { + continue; + } + // Compute the bounding box min of the node's octree cell in // the local coordinate frame used by the THREE.Points geometry. // The parsers store each point as: diff --git a/packages/Main/src/Utils/PointCloudUtils.ts b/packages/Main/src/Utils/PointCloudUtils.ts index 04fb88c3c9..dbd6147fd7 100644 --- a/packages/Main/src/Utils/PointCloudUtils.ts +++ b/packages/Main/src/Utils/PointCloudUtils.ts @@ -2,16 +2,15 @@ import type PointCloudNode from '../Core/PointCloudNode'; // Returns the Potree child index (0-7) of a node relative to its parent // based on coordinates (convention: 4*dx + 2*dy + dz) -function getPotreeChildIndex(node: PointCloudNode): number { - const parent = node.parent!; - const dx = node.x - parent.x * 2; - const dy = node.y - parent.y * 2; - const dz = node.z - parent.z * 2; +function getPotreeChildIndex(parent: PointCloudNode, child: PointCloudNode): number { + const dx = child.x - parent.x * 2; + const dy = child.y - parent.y * 2; + const dz = child.z - parent.z * 2; return 4 * dx + 2 * dy + dz; } function encodeChildrenVisibility(node: PointCloudNode, nodeIndex: number, - nodeToIndex: Map): { minOffset: number, childVisibilityMask: number } { + nodeToIndex: Map): { minOffset: number; childVisibilityMask: number } { let minOffset = Infinity; let childVisibilityMask = 0; @@ -21,7 +20,7 @@ function encodeChildrenVisibility(node: PointCloudNode, nodeIndex: number, continue; } - childVisibilityMask |= (1 << getPotreeChildIndex(child)); + childVisibilityMask |= (1 << getPotreeChildIndex(node, child)); const offset = childIndex - nodeIndex; if (offset < minOffset) { From a75b492a24e450b4d7f4b6e04a3600aa5142d61d Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Thu, 18 Jun 2026 14:55:26 +0200 Subject: [PATCH 13/14] chore(pointcloud): Add documentation --- packages/Main/src/Layer/C3DTilesLayer.js | 5 ++++- packages/Main/src/Layer/OGC3DTilesLayer.js | 5 +++-- packages/Main/src/Renderer/PointsMaterial.js | 4 +++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/Main/src/Layer/C3DTilesLayer.js b/packages/Main/src/Layer/C3DTilesLayer.js index 05a1ccc406..64b227b0fb 100644 --- a/packages/Main/src/Layer/C3DTilesLayer.js +++ b/packages/Main/src/Layer/C3DTilesLayer.js @@ -99,7 +99,10 @@ class C3DTilesLayer extends GeometryLayer { * Only 'COLOR' or 'CLASSIFICATION' are possible. COLOR uses RGB colors of the points, * CLASSIFICATION uses a classification property of the batch table to color points. * @param {string} [config.pntsShape= PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible. - * @param {string} [config.pntsSizeMode= PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode. Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance from point to camera. + * @param {string} [config.pntsSizeMode= PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode. + * Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. VALUE use constant size, ATTENUATED compute size depending on distance + * from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [config.pntsMinAttenuatedSize=3] Minimum scale used by 'ATTENUATED' size mode * @param {number} [config.pntsMaxAttenuatedSize=10] Maximum scale used by 'ATTENUATED' size mode * @param {Style} [config.style=null] - style used for this layer diff --git a/packages/Main/src/Layer/OGC3DTilesLayer.js b/packages/Main/src/Layer/OGC3DTilesLayer.js index 50ca7696fb..c0f7775cd8 100644 --- a/packages/Main/src/Layer/OGC3DTilesLayer.js +++ b/packages/Main/src/Layer/OGC3DTilesLayer.js @@ -370,8 +370,9 @@ class OGC3DTilesLayer extends GeometryLayer { * @param {string} [config.pntsShape = PNTS_SHAPE.CIRCLE] Point cloud point shape. Only 'CIRCLE' or 'SQUARE' are possible. * (passed to {@link PointsMaterial}). * @param {string} [config.pntsSizeMode = PNTS_SIZE_MODE.VALUE] {@link PointsMaterial} Point cloud size mode (passed to {@link PointsMaterial}). - * Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance - * from point to camera. + * Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. VALUE use constant size, ATTENUATED compute size depending on distance + * from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [config.pntsMinAttenuatedSize = 3] Minimum scale used by 'ATTENUATED' size mode. * @param {number} [config.pntsMaxAttenuatedSize = 10] Maximum scale used by 'ATTENUATED' size mode. */ diff --git a/packages/Main/src/Renderer/PointsMaterial.js b/packages/Main/src/Renderer/PointsMaterial.js index ba67ab48dd..bbaa20d3ac 100644 --- a/packages/Main/src/Renderer/PointsMaterial.js +++ b/packages/Main/src/Renderer/PointsMaterial.js @@ -162,7 +162,9 @@ class PointsMaterial extends THREE.ShaderMaterial { * @param {Scheme} [options.discreteScheme] LUT for other discret point values colorization. * @param {string} [options.gradient] Descrition of the gradient to use for continuous point values. * (Default value will be the 'SPECTRAL' gradient from Utils/Gradients) - * @param {number} [options.sizeMode=PNTS_SIZE_MODE.VALUE] point cloud size mode. Only 'VALUE' or 'ATTENUATED' are possible. VALUE use constant size, ATTENUATED compute size depending on distance from point to camera. + * @param {number} [options.sizeMode=PNTS_SIZE_MODE.VALUE] point cloud size mode. Only 'VALUE' or 'ATTENUATED' or 'ADAPTIVE' are possible. + * VALUE use constant size, ATTENUATED compute size depending on distance from point to camera and ADAPTIVE computes the point size on a per-point basis rather than on a per-node basis. + * **Warning:** ADAPTIVE work only if point cloud is an octree * @param {number} [options.minAttenuatedSize=3] minimum scale used by 'ATTENUATED' size mode * @param {number} [options.maxAttenuatedSize=10] maximum scale used by 'ATTENUATED' size mode * From ab341b2adc1997c64eb65850ee71d31ebb9b9276 Mon Sep 17 00:00:00 2001 From: Kevin ETOURNEAU Date: Tue, 23 Jun 2026 10:16:19 +0200 Subject: [PATCH 14/14] chore(pointcloud): Show pointSize below sizeMode button and only in VALUE or ADAPTIVE mode --- packages/Debug/src/PointCloudDebug.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/Debug/src/PointCloudDebug.js b/packages/Debug/src/PointCloudDebug.js index fb609a763b..17217a1bb0 100644 --- a/packages/Debug/src/PointCloudDebug.js +++ b/packages/Debug/src/PointCloudDebug.js @@ -40,10 +40,12 @@ function setupControllerVisibily(gui, displayMode, sizeMode) { } sizeMode = parseInt(sizeMode, 10); - if (sizeMode === PNTS_SIZE_MODE.VALUE || sizeMode === PNTS_SIZE_MODE.ADAPTIVE) { + if ([PNTS_SIZE_MODE.VALUE, PNTS_SIZE_MODE.ADAPTIVE].includes(sizeMode)) { + getController(gui, 'pointSize').show(); getController(gui, 'minAttenuatedSize').hide(); getController(gui, 'maxAttenuatedSize').hide(); } else { + getController(gui, 'pointSize').hide(); getController(gui, 'minAttenuatedSize').show(); getController(gui, 'maxAttenuatedSize').show(); } @@ -297,7 +299,6 @@ export default { } styleUI.add(layer, 'opacity', 0, 1).name('Layer opacity').onChange(update); - styleUI.add(layer, 'pointSize', 0, 15).name('Point size').onChange(update); if (layer.material.sizeMode != undefined && view.camera.camera3D.isPerspectiveCamera) { styleUI.add(layer.material, 'sizeMode', PNTS_SIZE_MODE).name('Size mode') .onChange(update); @@ -318,6 +319,7 @@ export default { update(); }); } + styleUI.add(layer, 'pointSize', 0, 15).name('Point size').onChange(update); if (layer.material.picking != undefined) { styleUI.add(layer.material, 'picking').name('Display picking id').onChange(update);