From 1ce58dd4db930ab59e668ddfd97351a591fc70d2 Mon Sep 17 00:00:00 2001 From: Jonas Windhager Date: Thu, 22 Jan 2026 15:15:50 +0100 Subject: [PATCH] refactor: switch to constant source type, render up to 2048 points objects, revert to premultiplying point sizes --- .../src/assets/shaders/points.vert | 28 +++---- .../src/controllers/WebGLPointsController.ts | 59 +++++++++++--- .../@tissuumaps-core/src/model/configs.ts | 80 ++++++++++++------- packages/@tissuumaps-core/src/model/labels.ts | 4 +- packages/@tissuumaps-core/src/model/points.ts | 10 +-- packages/@tissuumaps-core/src/model/shapes.ts | 12 +-- .../@tissuumaps-core/src/utils/LoadUtils.ts | 48 +++++++---- 7 files changed, 150 insertions(+), 91 deletions(-) diff --git a/packages/@tissuumaps-core/src/assets/shaders/points.vert b/packages/@tissuumaps-core/src/assets/shaders/points.vert index a7c20a4..fba8b18 100644 --- a/packages/@tissuumaps-core/src/assets/shaders/points.vert +++ b/packages/@tissuumaps-core/src/assets/shaders/points.vert @@ -1,7 +1,7 @@ #version 300 es // maximum number of objects -#define MAX_N_OBJECTS 256u +#define MAX_N_OBJECTS 2048u // marker atlas configuration #define MARKER_ATLAS_GRID_SIZE 4u @@ -13,7 +13,7 @@ #define DISCARD gl_PointSize = 0.0; gl_Position = vec4(2.0, 2.0, 0.0, 1.0); v_color = vec4(0.0); v_marker = uvec3(0); return; // uniforms -uniform float u_globalPointSizeFactor; +uniform float u_worldPointSizeFactor; uniform mat3x2 u_worldToViewportMatrix; uniform vec2 u_viewportSize; // in world units uniform vec2 u_canvasSize; // in browser pixels @@ -32,13 +32,12 @@ layout(std140) uniform ObjectsUBO { // is aligned to 4N. Since mat2x4 has 2 columns, its // base alignment is 2 * 4N = 8N = 32 bytes. mat2x4 transposedDataToWorldMatrices[MAX_N_OBJECTS]; - vec4 objectPointSizeFactors[MAX_N_OBJECTS / 4u]; }; // vertex attributes layout(location = 0) in float a_x; // in data units layout(location = 1) in float a_y; // in data units -layout(location = 2) in float a_size; // in data units +layout(location = 2) in float a_size; // in world units layout(location = 3) in uint a_color; // packed 8-bit RGBA layout(location = 4) in uint a_marker; // marker index layout(location = 5) in uint a_object; // object index @@ -71,21 +70,18 @@ void main() { DISCARD; } - // compute object-specific parameters - float objectPointSizeFactor = objectPointSizeFactors[a_object / 4u][a_object % 4u]; - mat3x2 dataToWorldMatrix = mat3x2(transpose(transposedDataToWorldMatrices[a_object])); - float dataToWorldScale = (length(dataToWorldMatrix[0]) + length(dataToWorldMatrix[1])) / 2.0; - float canvasPixelRatio = dot(u_canvasSize / u_viewportSize, vec2(0.5)); - // compute point size in device pixels and discard points with non-positive size - float worldPointSize = a_size * objectPointSizeFactor * u_globalPointSizeFactor * dataToWorldScale; + float canvasPixelRatio = dot(u_canvasSize / u_viewportSize, vec2(0.5)); + float worldPointSize = a_size * u_worldPointSizeFactor; float canvasPointSize = worldPointSize * canvasPixelRatio; // in browser pixels float devicePointSize = canvasPointSize * u_devicePixelRatio; // in device pixels if(devicePointSize <= 0.0) { DISCARD; } + gl_PointSize = devicePointSize; // compute point position in normalized device coordinates (NDCs) and discard points outside the viewport + mat3x2 dataToWorldMatrix = mat3x2(transpose(transposedDataToWorldMatrices[a_object])); vec2 worldPosition = dataToWorldMatrix * vec3(a_x, a_y, 1.0); vec2 viewportPosition = u_worldToViewportMatrix * vec3(worldPosition, 1.0); // in [0, 1] vec2 ndcPosition = (2.0 * viewportPosition - 1.0) * vec2(1.0, -1.0); // in [-1, 1], y flipped @@ -93,19 +89,15 @@ void main() { if(ndcPosition.x + 0.5 * ndcPointSize.x < -1.0 || ndcPosition.x - 0.5 * ndcPointSize.x > 1.0 || ndcPosition.y + 0.5 * ndcPointSize.y < -1.0 || ndcPosition.y - 0.5 * ndcPointSize.y > 1.0) { DISCARD; } + gl_Position = vec4(ndcPosition, 0.0, 1.0); // unpack color and discard fully transparent points vec4 color = unpackColor(a_color); if(color.a == 0.0) { DISCARD; } + v_color = color; // get marker atlas coordinates - uvec3 marker = markerAtlasCoords(a_marker); - - // set outputs - gl_PointSize = devicePointSize; - gl_Position = vec4(ndcPosition, 0.0, 1.0); - v_color = color; - v_marker = marker; + v_marker = markerAtlasCoords(a_marker); } diff --git a/packages/@tissuumaps-core/src/controllers/WebGLPointsController.ts b/packages/@tissuumaps-core/src/controllers/WebGLPointsController.ts index 32cadae..338e926 100644 --- a/packages/@tissuumaps-core/src/controllers/WebGLPointsController.ts +++ b/packages/@tissuumaps-core/src/controllers/WebGLPointsController.ts @@ -3,17 +3,25 @@ import { deepEqual } from "fast-equals"; import markersUrl from "../assets/markers/markers.png?url"; import pointsFragmentShader from "../assets/shaders/points.frag?raw"; import pointsVertexShader from "../assets/shaders/points.vert?raw"; +import { + getActiveConfigSource, + isConstantConfig, + isFromConfig, + isGroupByConfig, +} from "../model/configs"; import { defaultPointColor, defaultPointMarker, defaultPointOpacity, defaultPointSize, defaultPointVisibility, + defaultSizeUnit, } from "../model/constants"; import { type Layer } from "../model/layer"; import { type Points, type PointsLayerConfig } from "../model/points"; import { type Color, + type CoordinateSpace, type DrawOptions, Marker, type ValueMap, @@ -27,7 +35,7 @@ import { WebGLUtils } from "../utils/WebGLUtils"; import { WebGLControllerBase } from "./WebGLControllerBase"; export class WebGLPointsController extends WebGLControllerBase { - private static readonly _maxNumObjects = 256; // see vertex shader + private static readonly _maxNumObjects = 2048; // see vertex shader private static readonly _attribLocations = { X: 0, Y: 1, @@ -42,7 +50,7 @@ export class WebGLPointsController extends WebGLControllerBase { private readonly _program: WebGLProgram; private readonly _uniformLocations: { - globalPointSizeFactor: WebGLUniformLocation; + worldPointSizeFactor: WebGLUniformLocation; worldToViewportMatrix: WebGLUniformLocation; viewportSize: WebGLUniformLocation; canvasSize: WebGLUniformLocation; @@ -76,10 +84,10 @@ export class WebGLPointsController extends WebGLControllerBase { ); // get uniform locations this._uniformLocations = { - globalPointSizeFactor: WebGLUtils.getUniformLocation( + worldPointSizeFactor: WebGLUtils.getUniformLocation( this._gl, this._program, - "u_globalPointSizeFactor", + "u_worldPointSizeFactor", ), worldToViewportMatrix: WebGLUtils.getUniformLocation( this._gl, @@ -125,7 +133,7 @@ export class WebGLPointsController extends WebGLControllerBase { this._gl, this._gl.UNIFORM_BUFFER, this._buffers.objectsUBO, - WebGLPointsController._maxNumObjects * 9 * Float32Array.BYTES_PER_ELEMENT, + WebGLPointsController._maxNumObjects * 8 * Float32Array.BYTES_PER_ELEMENT, this._gl.DYNAMIC_DRAW, ); // create and configure VAO @@ -177,7 +185,7 @@ export class WebGLPointsController extends WebGLControllerBase { this._buffers.object, WebGLPointsController._attribLocations.OBJECT, 1, - this._gl.UNSIGNED_BYTE, + this._gl.UNSIGNED_SHORT, ); this._gl.bindVertexArray(null); } @@ -262,7 +270,7 @@ export class WebGLPointsController extends WebGLControllerBase { WebGLPointsController._bindingPoints.OBJECTS_UBO, ); this._gl.uniform1f( - this._uniformLocations.globalPointSizeFactor, + this._uniformLocations.worldPointSizeFactor, drawOptions.pointSizeFactor, ); this._gl.uniformMatrix3x2fv( @@ -347,7 +355,7 @@ export class WebGLPointsController extends WebGLControllerBase { this._gl, this._gl.ARRAY_BUFFER, this._buffers.object, - n * Uint8Array.BYTES_PER_ELEMENT, + n * Uint16Array.BYTES_PER_ELEMENT, this._gl.STATIC_DRAW, ); this._currentBufferSize = n; @@ -415,7 +423,7 @@ export class WebGLPointsController extends WebGLControllerBase { signal?.throwIfAborted(); let offset = 0; const objectsUBOData = new Float32Array( - WebGLPointsController._maxNumObjects * 9, + WebGLPointsController._maxNumObjects * 8, ); const newBufferSliceStates: PointsBufferSliceState[] = []; for (let i = 0; i < refs.length; i++) { @@ -494,13 +502,40 @@ export class WebGLPointsController extends WebGLControllerBase { ref.points.pointSize, ) ) { + let activeUnit: CoordinateSpace; + const activeSource = getActiveConfigSource(ref.points.pointSize); + if ( + activeSource === "constant" && + isConstantConfig(ref.points.pointSize) + ) { + activeUnit = ref.points.pointSize.constant.unit ?? defaultSizeUnit; + } else if ( + activeSource === "from" && + isFromConfig(ref.points.pointSize) + ) { + activeUnit = ref.points.pointSize.from.unit ?? defaultSizeUnit; + } else if ( + activeSource === "groupBy" && + isGroupByConfig(ref.points.pointSize) + ) { + activeUnit = ref.points.pointSize.groupBy.unit ?? defaultSizeUnit; + } else { + activeUnit = defaultSizeUnit; + } + let sizeFactor = ref.points.pointSizeFactor * ref.layer.pointSizeFactor; + if (activeUnit === "data") { + sizeFactor *= ref.layerConfig.transform.scale; + } + if (activeUnit === "data" || activeUnit === "layer") { + sizeFactor *= ref.layer.transform.scale; + } const sizeData = await LoadUtils.loadSizeData( ref.data.getIndex(), ref.points.pointSize, sizeMaps, defaultPointSize, loadTable, - { signal }, + { signal, sizeFactor }, ); signal?.throwIfAborted(); WebGLUtils.loadBuffer( @@ -582,7 +617,7 @@ export class WebGLPointsController extends WebGLControllerBase { this._gl, this._gl.ARRAY_BUFFER, this._buffers.object, - new Uint8Array(numPoints).fill(i), + new Uint16Array(numPoints).fill(i), { offset }, ); } @@ -619,8 +654,6 @@ export class WebGLPointsController extends WebGLControllerBase { ), i * 8, ); - objectsUBOData[WebGLPointsController._maxNumObjects * 8 + i] = - ref.points.pointSizeFactor * ref.layer.pointSizeFactor; offset += numPoints; } WebGLUtils.loadBuffer( diff --git a/packages/@tissuumaps-core/src/model/configs.ts b/packages/@tissuumaps-core/src/model/configs.ts index 8a9b0b0..954b5f7 100644 --- a/packages/@tissuumaps-core/src/model/configs.ts +++ b/packages/@tissuumaps-core/src/model/configs.ts @@ -18,7 +18,7 @@ export type Config = { * If a source is explicitly prioritized using {@link Config.source}, that source is returned. * * Otherwise, the active source is determined by checking for the presence of configuration-specific fields in the following order: - * - {@link ValueConfig} + * - {@link ConstantConfig} * - {@link FromConfig} * - {@link GroupByConfig} * - {@link RandomConfig} @@ -32,8 +32,8 @@ export function getActiveConfigSource( if (config.source !== undefined) { return config.source; } - if (isValueConfig(config)) { - return "value" as TSource; + if (isConstantConfig(config)) { + return "constant" as TSource; } if (isFromConfig(config)) { return "from" as TSource; @@ -47,23 +47,25 @@ export function getActiveConfigSource( return undefined; } -/** Configuration to use a single value */ -export type ValueConfig = Config<"value"> & { - /** Specification of a single value */ - value: NonNullable; +/** Configuration to use a constant value */ +export type ConstantConfig< + TValue, + TConstantExtra = unknown, +> = Config<"constant"> & { + /** Specification of a constant value */ + constant: { value: NonNullable } & TConstantExtra; }; /** - * Determines whether the given object is a {@link ValueConfig} + * Determines whether the given object is a {@link ConstantConfig} * * @param obj - The object to check - * @returns Whether the object is an (active) {@link ValueConfig} + * @returns Whether the object is an (active) {@link ConstantConfig} */ -export function isValueConfig( +export function isConstantConfig( obj: unknown, -): obj is ValueConfig { - const valueConfig = obj as ValueConfig; - return valueConfig.source === "value" || valueConfig.value !== undefined; +): obj is ConstantConfig { + return (obj as ConstantConfig).constant !== undefined; } /** Configuration to load values from a table column */ @@ -155,7 +157,7 @@ export function isRandomConfig( * Table values correspond to marker indices (see {@link Marker}) */ export type MarkerConfig = - | ValueConfig + | ConstantConfig | FromConfig | GroupByConfig; @@ -164,19 +166,37 @@ export type MarkerConfig = * * Table values correspond to sizes in the specified {@link SizeConfig.unit} */ -export type SizeConfig = ( - | ValueConfig - | FromConfig - | GroupByConfig -) & { - /** - * Coordinate space in which the sizes are specified - * - * @defaultValue {@link "./constants".defaultSizeUnit} - */ - - unit?: CoordinateSpace; -}; +export type SizeConfig = + | ConstantConfig< + number, + { + /** + * Coordinate space in which the size values are specified + * + * @defaultValue {@link "./constants".defaultSizeUnit} + */ + unit?: CoordinateSpace; + } + > + | FromConfig<{ + /** + * Coordinate space in which the size values are specified + * + * @defaultValue {@link "./constants".defaultSizeUnit} + */ + unit?: CoordinateSpace; + }> + | GroupByConfig< + number, + { + /** + * Coordinate space in which the size values are specified + * + * @defaultValue {@link "./constants".defaultSizeUnit} + */ + unit?: CoordinateSpace; + } + >; /** * Color configuration @@ -184,7 +204,7 @@ export type SizeConfig = ( * Numerical table values are linearly mapped to colors in the specified {@link ColorConfig.from.palette} using the specified {@link ColorConfig.from.range}. */ export type ColorConfig = - | ValueConfig + | ConstantConfig | FromConfig<{ /** * Value range that is linearly mapped to {@link ColorConfig.from.palette} @@ -210,7 +230,7 @@ export type ColorConfig = * Numerical table values are interpreted as booleans, where `0` is `false` and any other value is `true`. */ export type VisibilityConfig = - | ValueConfig + | ConstantConfig | FromConfig | GroupByConfig; @@ -220,6 +240,6 @@ export type VisibilityConfig = * Numerical table values are interpreted as opacities between `0` (fully transparent) and `1` (fully opaque). */ export type OpacityConfig = - | ValueConfig + | ConstantConfig | FromConfig | GroupByConfig; diff --git a/packages/@tissuumaps-core/src/model/labels.ts b/packages/@tissuumaps-core/src/model/labels.ts index b41aa47..868c0ff 100644 --- a/packages/@tissuumaps-core/src/model/labels.ts +++ b/packages/@tissuumaps-core/src/model/labels.ts @@ -25,8 +25,8 @@ import { */ export const labelsDefaults = { labelColor: { random: { palette: defaultRandomLabelColorPalette } }, - labelVisibility: { value: defaultLabelVisibility }, - labelOpacity: { value: defaultLabelOpacity }, + labelVisibility: { constant: { value: defaultLabelVisibility } }, + labelOpacity: { constant: { value: defaultLabelOpacity } }, } as const satisfies Partial; /** diff --git a/packages/@tissuumaps-core/src/model/points.ts b/packages/@tissuumaps-core/src/model/points.ts index 24ad27d..2a73bd1 100644 --- a/packages/@tissuumaps-core/src/model/points.ts +++ b/packages/@tissuumaps-core/src/model/points.ts @@ -28,11 +28,11 @@ import { * Default values for {@link RawPoints} */ export const pointsDefaults = { - pointMarker: { value: defaultPointMarker }, - pointSize: { value: defaultPointSize }, - pointColor: { value: defaultPointColor }, - pointVisibility: { value: defaultPointVisibility }, - pointOpacity: { value: defaultPointOpacity }, + pointMarker: { constant: { value: defaultPointMarker } }, + pointSize: { constant: { value: defaultPointSize } }, + pointColor: { constant: { value: defaultPointColor } }, + pointVisibility: { constant: { value: defaultPointVisibility } }, + pointOpacity: { constant: { value: defaultPointOpacity } }, pointSizeFactor: 1, } as const satisfies Partial; diff --git a/packages/@tissuumaps-core/src/model/shapes.ts b/packages/@tissuumaps-core/src/model/shapes.ts index 9ae8b1d..9d42e4d 100644 --- a/packages/@tissuumaps-core/src/model/shapes.ts +++ b/packages/@tissuumaps-core/src/model/shapes.ts @@ -27,12 +27,12 @@ import { * Default values for {@link RawShapes} */ export const shapesDefaults = { - shapeFillColor: { value: defaultShapeFillColor }, - shapeFillVisibility: { value: defaultShapeFillVisibility }, - shapeFillOpacity: { value: defaultShapeFillOpacity }, - shapeStrokeColor: { value: defaultShapeStrokeColor }, - shapeStrokeVisibility: { value: defaultShapeStrokeVisibility }, - shapeStrokeOpacity: { value: defaultShapeStrokeOpacity }, + shapeFillColor: { constant: { value: defaultShapeFillColor } }, + shapeFillVisibility: { constant: { value: defaultShapeFillVisibility } }, + shapeFillOpacity: { constant: { value: defaultShapeFillOpacity } }, + shapeStrokeColor: { constant: { value: defaultShapeStrokeColor } }, + shapeStrokeVisibility: { constant: { value: defaultShapeStrokeVisibility } }, + shapeStrokeOpacity: { constant: { value: defaultShapeStrokeOpacity } }, } as const satisfies Partial; /** diff --git a/packages/@tissuumaps-core/src/utils/LoadUtils.ts b/packages/@tissuumaps-core/src/utils/LoadUtils.ts index 4b5a46e..45a13a4 100644 --- a/packages/@tissuumaps-core/src/utils/LoadUtils.ts +++ b/packages/@tissuumaps-core/src/utils/LoadUtils.ts @@ -5,10 +5,10 @@ import { type SizeConfig, type VisibilityConfig, getActiveConfigSource, + isConstantConfig, isFromConfig, isGroupByConfig, isRandomConfig, - isValueConfig, } from "../model/configs"; import { type Color, Marker, type ValueMap } from "../model/types"; import { colorPalettes, markerPalette } from "../palettes"; @@ -36,8 +36,8 @@ export class LoadUtils { } const data = new Uint8Array(dataLength); const activeConfigSource = getActiveConfigSource(markerConfig); - if (activeConfigSource === "value" && isValueConfig(markerConfig)) { - const marker = markerConfig.value; + if (activeConfigSource === "constant" && isConstantConfig(markerConfig)) { + const marker = markerConfig.constant.value; const markerIndex = marker as number; data.fill(markerIndex, 0, ids.length); } else if (activeConfigSource === "from" && isFromConfig(markerConfig)) { @@ -140,9 +140,11 @@ export class LoadUtils { { signal, padding, + sizeFactor = 1, }: { signal?: AbortSignal; padding?: number; + sizeFactor?: number; } = {}, ): Promise { signal?.throwIfAborted(); @@ -152,8 +154,9 @@ export class LoadUtils { } const data = new Float32Array(dataLength); const activeConfigSource = getActiveConfigSource(sizeConfig); - if (activeConfigSource === "value" && isValueConfig(sizeConfig)) { - data.fill(sizeConfig.value, 0, ids.length); + if (activeConfigSource === "constant" && isConstantConfig(sizeConfig)) { + const scaledSize = sizeConfig.constant.value * sizeFactor; + data.fill(scaledSize, 0, ids.length); } else if (activeConfigSource === "from" && isFromConfig(sizeConfig)) { const tableData = await loadTable(sizeConfig.from.table, { signal }); signal?.throwIfAborted(); @@ -169,9 +172,12 @@ export class LoadUtils { const id = ids[i]!; const tableIndex = tableIndices.get(id); if (tableIndex !== undefined) { - data[i] = tableValues[tableIndex]!; + const size = tableValues[tableIndex]!; + const scaledSize = size * sizeFactor; + data[i] = scaledSize; } else { - data[i] = defaultSize; + const scaledSize = defaultSize * sizeFactor; + data[i] = scaledSize; e++; } } @@ -211,12 +217,15 @@ export class LoadUtils { const tableIndex = tableIndices.get(id); if (tableIndex !== undefined) { const group = JSON.stringify(tableGroups[tableIndex]!); - data[i] = + const size = sizeMap.values.get(group) ?? // first, try to get group-specific size sizeMap.defaultValue ?? // then, fallback to size map default defaultSize; // finally, fallback to default size + const scaledSize = size * sizeFactor; + data[i] = scaledSize; } else { - data[i] = defaultSize; + const scaledSize = defaultSize * sizeFactor; + data[i] = scaledSize; e++; } } @@ -224,11 +233,13 @@ export class LoadUtils { console.warn(`${e} IDs missing in table ${sizeConfig.groupBy.table}`); } } else { - data.fill(defaultSize, 0, ids.length); + const scaledSize = defaultSize * sizeFactor; + data.fill(scaledSize, 0, ids.length); } } else // activeConfigSource === undefined { - data.fill(defaultSize, 0, ids.length); + const scaledSize = defaultSize * sizeFactor; + data.fill(scaledSize, 0, ids.length); } return data; } @@ -253,8 +264,8 @@ export class LoadUtils { } const data = new Uint32Array(dataLength); const activeConfigSource = getActiveConfigSource(colorConfig); - if (activeConfigSource === "value" && isValueConfig(colorConfig)) { - const packedColor = ColorUtils.packColor(colorConfig.value); + if (activeConfigSource === "constant" && isConstantConfig(colorConfig)) { + const packedColor = ColorUtils.packColor(colorConfig.constant.value); data.fill(packedColor, 0, ids.length); } else if (activeConfigSource === "from" && isFromConfig(colorConfig)) { const colorPalette = colorPalettes[colorConfig.from.palette]; @@ -420,8 +431,11 @@ export class LoadUtils { } const data = new Uint8Array(dataLength); const activeConfigSource = getActiveConfigSource(visibilityConfig); - if (activeConfigSource === "value" && isValueConfig(visibilityConfig)) { - const numericVisibility = visibilityConfig.value ? 1 : 0; + if ( + activeConfigSource === "constant" && + isConstantConfig(visibilityConfig) + ) { + const numericVisibility = visibilityConfig.constant.value ? 1 : 0; data.fill(numericVisibility, 0, ids.length); } else if ( activeConfigSource === "from" && @@ -548,8 +562,8 @@ export class LoadUtils { } const data = new Uint8Array(dataLength); const activeConfigSource = getActiveConfigSource(opacityConfig); - if (activeConfigSource === "value" && isValueConfig(opacityConfig)) { - const scaledOpacity = opacityFactor * opacityConfig.value; + if (activeConfigSource === "constant" && isConstantConfig(opacityConfig)) { + const scaledOpacity = opacityFactor * opacityConfig.constant.value; const scaledOpacityInt = Math.round(scaledOpacity * 255); data.fill(scaledOpacityInt, 0, ids.length); } else if (activeConfigSource === "from" && isFromConfig(opacityConfig)) {