From 19fec3bbc15d20d1990519be245d4f2c21a551a2 Mon Sep 17 00:00:00 2001 From: Ludovic Autin Date: Sat, 2 May 2026 10:48:31 -0700 Subject: [PATCH 1/3] Order DOT spheres by Morton index Add DOT sphere impostors in Morton order so sphere LOD stride sampling remains spatially distributed. --- src/mol-repr/volume/_spec/dot.spec.ts | 42 ++++++++++++++++ src/mol-repr/volume/dot.ts | 71 +++++++++++++++++---------- 2 files changed, 88 insertions(+), 25 deletions(-) create mode 100644 src/mol-repr/volume/_spec/dot.spec.ts diff --git a/src/mol-repr/volume/_spec/dot.spec.ts b/src/mol-repr/volume/_spec/dot.spec.ts new file mode 100644 index 000000000..193efc6ff --- /dev/null +++ b/src/mol-repr/volume/_spec/dot.spec.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2026 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Ludovic Autin + */ + +import { CustomProperties } from '../../../mol-model/custom-property'; +import { Grid, Volume } from '../../../mol-model/volume'; +import { Mat4, Tensor } from '../../../mol-math/linear-algebra'; +import { createVolumeSphereImpostor } from '../dot'; + +function createTestVolume(dimensions: [number, number, number], data: number[]): Volume { + return { + grid: { + transform: { kind: 'matrix', matrix: Mat4.identity() }, + cells: Tensor.create(Tensor.Space(dimensions, [2, 1, 0]), Tensor.Data1(data)), + stats: { min: 0, max: 1, mean: 0.5, sigma: 0.5 }, + } satisfies Grid, + instances: [{ transform: Mat4.identity() }], + sourceData: { kind: 'test', name: 'test', data: {} } as any, + customProperties: new CustomProperties(), + _propertyData: Object.create(null), + _localPropertyData: Object.create(null), + }; +} + +describe('volume dot representation', () => { + it('adds sphere impostor dots in Morton order for LOD sampling', () => { + const volume = createTestVolume([2, 2, 2], [ + 1, 1, + 1, 1, + 1, 1, + 1, 1, + ]); + const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, { + isoValue: Volume.IsoValue.absolute(0.5), + perturbPositions: false, + } as any); + + expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 4, 2, 6, 1, 5, 3, 7]); + }); +}); diff --git a/src/mol-repr/volume/dot.ts b/src/mol-repr/volume/dot.ts index c0e350c22..e3ec06dce 100644 --- a/src/mol-repr/volume/dot.ts +++ b/src/mol-repr/volume/dot.ts @@ -29,7 +29,7 @@ import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder'; import { Mat4 } from '../../mol-math/linear-algebra'; import { Interval } from '../../mol-data/int/interval'; import { OrderedSet } from '../../mol-data/int/ordered-set'; -import { PCG } from '../../mol-data/util/hash-functions'; +import { mortonOrder3d, PCG } from '../../mol-data/util/hash-functions'; import { VolumeKey, VolumeVisual } from './visual'; export const VolumeDotParams = { @@ -121,21 +121,16 @@ function getRandomOffsetFromBasis({ x, y, z, maxScale }: Basis): Vec3 { return offset; } -export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeSphereProps, spheres?: Spheres): Spheres { - const { cells: { space, data }, stats } = volume.grid; - const gridToCartn = Grid.getGridToCartesianTransform(volume.grid); - const isoVal = Volume.IsoValue.toAbsolute(props.isoValue, stats).absoluteValue; +type OrderedDotCell = { + readonly order: number + readonly offset: number +} - const p = Vec3(); +function getOrderedDotCells(volume: Volume, isoVal: number): OrderedDotCell[] { + const { cells: { space, data } } = volume.grid; const [xn, yn, zn] = space.dimensions; - - const count = Math.ceil((xn * yn * zn) / 10); - const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres); - const invert = isoVal < 0; - - // Precompute basis vectors and largest cell axis length - const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined; + const cells: OrderedDotCell[] = []; for (let z = 0; z < zn; ++z) { for (let y = 0; y < yn; ++y) { @@ -143,21 +138,47 @@ export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, k const value = space.get(data, x, y, z); if (!invert && value < isoVal || invert && value > isoVal) continue; - const cellIdx = space.dataOffset(x, y, z); - if (basis) { - Vec3.set(p, x, y, z); - Vec3.transformMat4(p, p, gridToCartn); - const offset = getRandomOffsetFromBasis(basis); - Vec3.add(p, p, offset); - } else { - Vec3.set(p, x, y, z); - Vec3.transformMat4(p, p, gridToCartn); - } - builder.add(p[0], p[1], p[2], cellIdx); + cells.push({ + order: mortonOrder3d(x, y, z) >>> 0, + offset: space.dataOffset(x, y, z) + }); } } } + cells.sort((a, b) => a.order - b.order || a.offset - b.offset); + return cells; +} + +export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeSphereProps, spheres?: Spheres): Spheres { + const { cells: { space }, stats } = volume.grid; + const gridToCartn = Grid.getGridToCartesianTransform(volume.grid); + const isoVal = Volume.IsoValue.toAbsolute(props.isoValue, stats).absoluteValue; + + const p = Vec3(); + const coords = [0, 0, 0]; + const orderedCells = getOrderedDotCells(volume, isoVal); + + const count = orderedCells.length; + const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres); + + // Precompute basis vectors and largest cell axis length + const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined; + + for (const cell of orderedCells) { + space.getCoords(cell.offset, coords); + if (basis) { + Vec3.set(p, coords[0], coords[1], coords[2]); + Vec3.transformMat4(p, p, gridToCartn); + const offset = getRandomOffsetFromBasis(basis); + Vec3.add(p, p, offset); + } else { + Vec3.set(p, coords[0], coords[1], coords[2]); + Vec3.transformMat4(p, p, gridToCartn); + } + builder.add(p[0], p[1], p[2], cell.offset); + } + const s = builder.getSpheres(); s.setBoundingSphere(Volume.Isosurface.getBoundingSphere(volume, props.isoValue)); return s; @@ -346,4 +367,4 @@ export const DotRepresentationProvider = VolumeRepresentationProvider({ defaultSizeTheme: { name: 'uniform' }, locationKinds: ['cell-location', 'position-location'], isApplicable: (volume: Volume) => !Volume.isEmpty(volume) && !Volume.Segmentation.get(volume) -}); \ No newline at end of file +}); From bccf54fabe81b6dc835fa03bcb33f1400ffe29c6 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sat, 9 May 2026 12:36:32 -0700 Subject: [PATCH 2/3] avoid extra allocations --- src/mol-repr/volume/_spec/dot.spec.ts | 17 ++++ src/mol-repr/volume/dot.ts | 108 +++++++++++++++----------- 2 files changed, 78 insertions(+), 47 deletions(-) diff --git a/src/mol-repr/volume/_spec/dot.spec.ts b/src/mol-repr/volume/_spec/dot.spec.ts index 193efc6ff..d1af9efc4 100644 --- a/src/mol-repr/volume/_spec/dot.spec.ts +++ b/src/mol-repr/volume/_spec/dot.spec.ts @@ -35,8 +35,25 @@ describe('volume dot representation', () => { const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, { isoValue: Volume.IsoValue.absolute(0.5), perturbPositions: false, + lodLevels: [{ minDistance: 0, maxDistance: 0, overlap: 0, stride: 0, scaleBias: 3 }], } as any); expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 4, 2, 6, 1, 5, 3, 7]); }); + + it('adds sphere impostor dots in row-major order when no LOD levels are configured', () => { + const volume = createTestVolume([2, 2, 2], [ + 1, 1, + 1, 1, + 1, 1, + 1, 1, + ]); + const spheres = createVolumeSphereImpostor(undefined as any, volume, 0, undefined as any, { + isoValue: Volume.IsoValue.absolute(0.5), + perturbPositions: false, + lodLevels: [], + } as any); + + expect(Array.from(spheres.groupBuffer.ref.value)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + }); }); diff --git a/src/mol-repr/volume/dot.ts b/src/mol-repr/volume/dot.ts index e3ec06dce..671f0c842 100644 --- a/src/mol-repr/volume/dot.ts +++ b/src/mol-repr/volume/dot.ts @@ -29,7 +29,7 @@ import { PointsBuilder } from '../../mol-geo/geometry/points/points-builder'; import { Mat4 } from '../../mol-math/linear-algebra'; import { Interval } from '../../mol-data/int/interval'; import { OrderedSet } from '../../mol-data/int/ordered-set'; -import { mortonOrder3d, PCG } from '../../mol-data/util/hash-functions'; +import { PCG } from '../../mol-data/util/hash-functions'; import { VolumeKey, VolumeVisual } from './visual'; export const VolumeDotParams = { @@ -67,7 +67,8 @@ export function VolumeSphereImpostorVisual(materialId: number): VolumeVisual, currentProps: PD.Values, newTheme: Theme, currentTheme: Theme) => { state.createGeometry = ( !Volume.IsoValue.areSame(newProps.isoValue, currentProps.isoValue, newVolume.grid.stats) || - newProps.perturbPositions !== currentProps.perturbPositions + newProps.perturbPositions !== currentProps.perturbPositions || + newProps.lodLevels.length > 0 && currentProps.lodLevels.length === 0 ); }, geometryUtils: Spheres.Utils, @@ -121,62 +122,74 @@ function getRandomOffsetFromBasis({ x, y, z, maxScale }: Basis): Vec3 { return offset; } -type OrderedDotCell = { - readonly order: number - readonly offset: number -} - -function getOrderedDotCells(volume: Volume, isoVal: number): OrderedDotCell[] { - const { cells: { space, data } } = volume.grid; - const [xn, yn, zn] = space.dimensions; - const invert = isoVal < 0; - const cells: OrderedDotCell[] = []; - - for (let z = 0; z < zn; ++z) { - for (let y = 0; y < yn; ++y) { - for (let x = 0; x < xn; ++x) { - const value = space.get(data, x, y, z); - if (!invert && value < isoVal || invert && value > isoVal) continue; - - cells.push({ - order: mortonOrder3d(x, y, z) >>> 0, - offset: space.dataOffset(x, y, z) - }); - } - } - } - - cells.sort((a, b) => a.order - b.order || a.offset - b.offset); - return cells; -} - export function createVolumeSphereImpostor(ctx: VisualContext, volume: Volume, key: number, theme: Theme, props: VolumeSphereProps, spheres?: Spheres): Spheres { - const { cells: { space }, stats } = volume.grid; + const { cells: { space, data }, stats } = volume.grid; const gridToCartn = Grid.getGridToCartesianTransform(volume.grid); const isoVal = Volume.IsoValue.toAbsolute(props.isoValue, stats).absoluteValue; const p = Vec3(); - const coords = [0, 0, 0]; - const orderedCells = getOrderedDotCells(volume, isoVal); - - const count = orderedCells.length; - const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres); + const [xn, yn, zn] = space.dimensions; + const invert = isoVal < 0; // Precompute basis vectors and largest cell axis length const basis = props.perturbPositions ? getBasis(gridToCartn) : undefined; - for (const cell of orderedCells) { - space.getCoords(cell.offset, coords); + const count = Math.ceil((xn * yn * zn) / 10); + const builder = SpheresBuilder.create(count, Math.ceil(count / 2), spheres); + + const add = (x: number, y: number, z: number) => { + const value = space.get(data, x, y, z); + if (!invert && value < isoVal || invert && value > isoVal) return; + + const cellIdx = space.dataOffset(x, y, z); + Vec3.set(p, x, y, z); + Vec3.transformMat4(p, p, gridToCartn); if (basis) { - Vec3.set(p, coords[0], coords[1], coords[2]); - Vec3.transformMat4(p, p, gridToCartn); - const offset = getRandomOffsetFromBasis(basis); - Vec3.add(p, p, offset); - } else { - Vec3.set(p, coords[0], coords[1], coords[2]); - Vec3.transformMat4(p, p, gridToCartn); + Vec3.add(p, p, getRandomOffsetFromBasis(basis)); + } + builder.add(p[0], p[1], p[2], cellIdx); + }; + + // Morton ordering keeps stride-based LOD sampling spatially balanced. + // Only worthwhile when LOD levels are configured; otherwise use the + // direct row-major path to avoid the extra allocations and sort. + const useMortonOrder = props.lodLevels.length > 0; + + if (useMortonOrder) { + // Recursive octree traversal over the bounding power-of-two cube, + // visiting children in Morton order (octant bit2=x, bit1=y, bit0=z). + // Octants whose origin already exceeds the grid extent are pruned, + // so out-of-range subtrees of non-cube grids cost ~O(log) per skip. + let size = 1; + while (size < xn || size < yn || size < zn) size <<= 1; + + const visit = (x0: number, y0: number, z0: number, s: number): void => { + if (x0 >= xn || y0 >= yn || z0 >= zn) return; + + if (s === 1) { + add(x0, y0, z0); + return; + } + const h = s >> 1; + visit(x0, y0, z0, h); + visit(x0, y0, z0 + h, h); + visit(x0, y0 + h, z0, h); + visit(x0, y0 + h, z0 + h, h); + visit(x0 + h, y0, z0, h); + visit(x0 + h, y0, z0 + h, h); + visit(x0 + h, y0 + h, z0, h); + visit(x0 + h, y0 + h, z0 + h, h); + }; + + visit(0, 0, 0, size); + } else { + for (let z = 0; z < zn; ++z) { + for (let y = 0; y < yn; ++y) { + for (let x = 0; x < xn; ++x) { + add(x, y, z); + } + } } - builder.add(p[0], p[1], p[2], cell.offset); } const s = builder.getSpheres(); @@ -341,6 +354,7 @@ const DotVisuals = { export const DotParams = { ...VolumeSphereParams, ...VolumePointParams, + sizeFactor: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }), visuals: PD.MultiSelect(['sphere'], PD.objectToOptions(DotVisuals)), bumpFrequency: PD.Numeric(1, { min: 0, max: 10, step: 0.1 }, BaseGeometry.ShadingCategory), }; From ca5a50bd5394d9cbc7a27089f3309b5b864d88c5 Mon Sep 17 00:00:00 2001 From: Alexander Rose Date: Sat, 9 May 2026 12:36:38 -0700 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd345ac9..ff377f632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Note that since we don't clearly distinguish between a public and private interf ## [Unreleased] - Fix empty transforms default in `ShapeFromPly` +- Use morton order for spheres in dot visual with lod-levels ## [v5.9.0] - 2026-05-03 - Fix edge case when `PluginSpec.animations` is empty