Skip to content

Commit 63e9481

Browse files
authored
feat: add viewport-dependent density-based downsampling (#118)
Add GPU-accelerated viewport-dependent downsampling to the point cloud renderer. When rendering large datasets, the system automatically reduces the number of rendered points while preserving visual fidelity by biasing the sampling toward dense regions. This unlocks a much higher upper bound for the size of datasets the viewer can work with, in practice. See #118 for more details.
1 parent 962ed59 commit 63e9481

File tree

12 files changed

+714
-11
lines changed

12 files changed

+714
-11
lines changed

packages/component/src/demo/EmbeddingViewDemo.svelte

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
import EmbeddingView from "../lib/embedding_view/EmbeddingView.svelte";
77
8-
let dataset = generateSampleDataset({ numPoints: 500000, numCategories: 3, numSubClusters: 32 });
8+
let dataset = generateSampleDataset({ numPoints: 5000000, numCategories: 3, numSubClusters: 32 });
99
let data = {
1010
x: new Float32Array(dataset.map((r) => r.x)),
1111
y: new Float32Array(dataset.map((r) => r.y)),
@@ -19,6 +19,7 @@
1919
let mode: "points" | "density" = $state.raw("density");
2020
let colorScheme: "light" | "dark" = $state.raw("light");
2121
let minimumDensity: number = $state.raw(1 / 16);
22+
let downsampleMaxPoints: number | null = $state.raw(4000000);
2223
let viewportState: ViewportState | null = $state.raw(null);
2324
2425
async function querySelection(x: number, y: number, unitDistance: number): Promise<DataPoint | null> {
@@ -59,15 +60,37 @@
5960
</select>
6061
</label>
6162

62-
<input type="range" bind:value={minimumDensity} min={0} max={0.2} step={0.0001} />
63-
{minimumDensity.toFixed(4)}
63+
<label style="display:flex;align-items:center;gap:4px">
64+
Min Density:
65+
<input type="range" bind:value={minimumDensity} min={0} max={0.2} step={0.0001} />
66+
{minimumDensity.toFixed(4)}
67+
</label>
68+
69+
<label style="display:flex;align-items:center;gap:4px">
70+
Max Points:
71+
{#if downsampleMaxPoints != null}
72+
<input type="range" bind:value={downsampleMaxPoints} min={50000} max={50000000} step={50000} />
73+
{downsampleMaxPoints >= 1000000
74+
? (downsampleMaxPoints / 1000000).toFixed(1) + "M"
75+
: (downsampleMaxPoints / 1000).toFixed(0) + "K"}
76+
<button onclick={() => (downsampleMaxPoints = null)}>Disable</button>
77+
{:else}
78+
<span>Disabled</span>
79+
<button onclick={() => (downsampleMaxPoints = 4000000)}>Enable</button>
80+
{/if}
81+
</label>
6482
</div>
6583

6684
<div style="display:flex;gap:8px">
6785
<div style:border="1px solid black">
6886
<EmbeddingView
6987
data={data}
70-
config={{ mode: mode, colorScheme: colorScheme, minimumDensity: minimumDensity }}
88+
config={{
89+
mode: mode,
90+
colorScheme: colorScheme,
91+
minimumDensity: minimumDensity,
92+
downsampleMaxPoints: downsampleMaxPoints,
93+
}}
7194
tooltip={tooltip}
7295
onTooltip={(v) => {
7396
tooltip = v;

packages/component/src/lib/embedding_view/EmbeddingViewImpl.svelte

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@
225225
let userPointSize = $derived(config?.pointSize ?? null);
226226
let mode = $derived(config?.mode ?? "points");
227227
let autoLabelEnabled = $derived(config?.autoLabelEnabled);
228+
let downsampleMaxPoints = $derived(config?.downsampleMaxPoints ?? 4000000);
229+
let downsampleDensityWeight = $derived(config?.downsampleDensityWeight ?? 5);
228230
229231
let viewingParams = $derived(
230232
viewingParameters(
@@ -256,6 +258,8 @@
256258
category: data.category,
257259
categoryCount,
258260
categoryColors: resolvedCategoryColors,
261+
downsampleMaxPoints,
262+
downsampleDensityWeight,
259263
...viewingParams,
260264
});
261265

packages/component/src/lib/embedding_view/embedding_view_config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,14 @@ export interface EmbeddingViewConfig {
2828

2929
/** The stop words for automatic label generation. By default use NLTK stop words. */
3030
autoLabelStopWords?: string[] | null;
31+
32+
/** Maximum number of points to render when downsampling is active.
33+
* Points are sampled with bias toward sparse regions (fewer points kept in dense areas).
34+
* Default: 4000000. Set to null or Infinity to disable downsampling. */
35+
downsampleMaxPoints?: number | null;
36+
37+
/** Density weight for downsampling (0-10).
38+
* Higher values mean more aggressive culling in dense areas.
39+
* Default: 5 */
40+
downsampleDensityWeight?: number | null;
3141
}

packages/component/src/lib/renderer_interface.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export interface EmbeddingRendererProps {
3232
gamma: number;
3333
width: number;
3434
height: number;
35+
36+
/** Maximum points to render. null/Infinity = no limit. Default: 4000000 */
37+
downsampleMaxPoints: number | null;
38+
/** Density weight for downsampling (0-10). Default: 5 */
39+
downsampleDensityWeight: number;
3540
}
3641

3742
export interface DensityMap {

packages/component/src/lib/webgl2_renderer/renderer.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ export class EmbeddingRendererWebGL2 implements EmbeddingRenderer {
6161
gamma: 2.2,
6262
width: width,
6363
height: height,
64+
65+
downsampleMaxPoints: 4000000,
66+
downsampleDensityWeight: 5,
6467
};
6568

6669
this.viewport = new Viewport({ x: 0, y: 0, scale: 1 }, width, height);
@@ -81,6 +84,8 @@ export class EmbeddingRendererWebGL2 implements EmbeddingRenderer {
8184
height: df.value(height),
8285
pointSize: df.value(this.props.pointSize),
8386
densityBandwidth: df.value(this.props.densityBandwidth),
87+
downsampleMaxPoints: df.value(this.props.downsampleMaxPoints),
88+
downsampleDensityWeight: df.value(this.props.downsampleDensityWeight),
8489
};
8590
this.dataBuffers = dataBuffers(df, gl, this.renderInputs);
8691
this.renderer = renderCommand(df, gl, this.renderInputs, this.dataBuffers);
@@ -116,6 +121,8 @@ export class EmbeddingRendererWebGL2 implements EmbeddingRenderer {
116121
this.renderInputs.height.value = this.props.height;
117122
this.renderInputs.pointSize.value = this.props.pointSize;
118123
this.renderInputs.densityBandwidth.value = this.props.densityBandwidth;
124+
this.renderInputs.downsampleMaxPoints.value = this.props.downsampleMaxPoints;
125+
this.renderInputs.downsampleDensityWeight.value = this.props.downsampleDensityWeight;
119126
return needsRender;
120127
}
121128

@@ -161,6 +168,8 @@ interface RenderInputs {
161168
matrix: ValueNode<Matrix3>;
162169
width: ValueNode<number>;
163170
height: ValueNode<number>;
171+
downsampleMaxPoints: ValueNode<number | null>;
172+
downsampleDensityWeight: ValueNode<number>;
164173
}
165174

166175
interface DataBuffers {

packages/component/src/lib/webgpu_renderer/bind_groups.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface BindGroups {
1616
group2A: Node<GPUBindGroup>;
1717
group2B: Node<GPUBindGroup>;
1818
group3: Node<GPUBindGroup>;
19+
// Note: group4 for downsampling is managed separately in downsample.ts
1920
}
2021

2122
export function makeBindGroupLayouts(device: GPUDevice): {

0 commit comments

Comments
 (0)