@uoa-css-lab/duckscatter 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +42 -0
- package/.github/workflows/ci.yaml +111 -0
- package/.github/workflows/release.yml +55 -0
- package/.prettierrc +11 -0
- package/LICENSE +22 -0
- package/README.md +250 -0
- package/dist/data/data-layer.d.ts +169 -0
- package/dist/data/data-layer.js +402 -0
- package/dist/data/index.d.ts +2 -0
- package/dist/data/index.js +2 -0
- package/dist/data/repository.d.ts +48 -0
- package/dist/data/repository.js +109 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +71 -0
- package/dist/errors.d.ts +22 -0
- package/dist/errors.js +58 -0
- package/dist/event-emitter.d.ts +62 -0
- package/dist/event-emitter.js +82 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +13 -0
- package/dist/renderer/gpu-layer.d.ts +204 -0
- package/dist/renderer/gpu-layer.js +611 -0
- package/dist/renderer/index.d.ts +3 -0
- package/dist/renderer/index.js +3 -0
- package/dist/renderer/shaders.d.ts +13 -0
- package/dist/renderer/shaders.js +216 -0
- package/dist/renderer/webgpu-context.d.ts +20 -0
- package/dist/renderer/webgpu-context.js +88 -0
- package/dist/scatter-plot.d.ts +210 -0
- package/dist/scatter-plot.js +450 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.js +1 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/label-layer.d.ts +176 -0
- package/dist/ui/label-layer.js +488 -0
- package/docs/image.png +0 -0
- package/eslint.config.js +72 -0
- package/examples/next/README.md +36 -0
- package/examples/next/app/components/ColorExpressionInput.tsx +41 -0
- package/examples/next/app/components/ControlPanel.tsx +30 -0
- package/examples/next/app/components/HoverControlPanel.tsx +69 -0
- package/examples/next/app/components/HoverInfoDisplay.tsx +40 -0
- package/examples/next/app/components/LabelFilterInput.tsx +46 -0
- package/examples/next/app/components/LabelList.tsx +106 -0
- package/examples/next/app/components/PointAlphaSlider.tsx +21 -0
- package/examples/next/app/components/PointLimitSlider.tsx +23 -0
- package/examples/next/app/components/PointList.tsx +105 -0
- package/examples/next/app/components/PointSizeScaleSlider.tsx +22 -0
- package/examples/next/app/components/ScatterPlotCanvas.tsx +150 -0
- package/examples/next/app/components/SearchBox.tsx +46 -0
- package/examples/next/app/components/Slider.tsx +76 -0
- package/examples/next/app/components/StatsDisplay.tsx +15 -0
- package/examples/next/app/components/TimeFilterSlider.tsx +169 -0
- package/examples/next/app/context/ScatterPlotContext.tsx +402 -0
- package/examples/next/app/favicon.ico +0 -0
- package/examples/next/app/globals.css +23 -0
- package/examples/next/app/layout.tsx +35 -0
- package/examples/next/app/page.tsx +15 -0
- package/examples/next/eslint.config.mjs +18 -0
- package/examples/next/next.config.ts +7 -0
- package/examples/next/package-lock.json +6572 -0
- package/examples/next/package.json +27 -0
- package/examples/next/postcss.config.mjs +7 -0
- package/examples/next/scripts/generate_labels.py +167 -0
- package/examples/next/tsconfig.json +34 -0
- package/package.json +43 -0
- package/src/data/data-layer.ts +515 -0
- package/src/data/index.ts +2 -0
- package/src/data/repository.ts +146 -0
- package/src/diagnostics.ts +108 -0
- package/src/errors.ts +69 -0
- package/src/event-emitter.ts +88 -0
- package/src/index.ts +40 -0
- package/src/renderer/gpu-layer.ts +757 -0
- package/src/renderer/index.ts +3 -0
- package/src/renderer/shaders.ts +219 -0
- package/src/renderer/webgpu-context.ts +98 -0
- package/src/scatter-plot.ts +533 -0
- package/src/types.ts +218 -0
- package/src/ui/index.ts +1 -0
- package/src/ui/label-layer.ts +648 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { WebGPUContext } from './webgpu-context.js';
|
|
2
|
+
import { scatterVertexShader, filterComputeShader, updateIndirectShader } from './shaders.js';
|
|
3
|
+
import type { ColorRGBA } from '../types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GPU用に処理されたポイントデータ
|
|
7
|
+
*/
|
|
8
|
+
export interface AllPointsData {
|
|
9
|
+
/** インスタンスデータ (x, y, color, size) の Float32Array */
|
|
10
|
+
instanceData: Float32Array;
|
|
11
|
+
/** 全ポイント数 */
|
|
12
|
+
totalCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* GpuLayerの設定オプション
|
|
17
|
+
*/
|
|
18
|
+
export interface GpuLayerOptions {
|
|
19
|
+
/** 描画対象のHTMLCanvasElement */
|
|
20
|
+
canvas: HTMLCanvasElement;
|
|
21
|
+
/** 背景色(デフォルト: 透明な黒) */
|
|
22
|
+
backgroundColor?: ColorRGBA;
|
|
23
|
+
/** 表示可能なポイントの最大数(デフォルト: 5000000) */
|
|
24
|
+
visiblePointLimit?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ビューポート境界のマージン(クリップ空間)
|
|
28
|
+
const VIEWPORT_MARGIN = 0.1;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* WebGPUレンダリングを担当するレイヤー
|
|
32
|
+
* 責務:
|
|
33
|
+
* - WebGPUコンテキストとパイプラインの管理
|
|
34
|
+
* - GPUバッファの作成と管理
|
|
35
|
+
* - コンピュートシェーダーによるLODフィルタリング
|
|
36
|
+
* - Indirect Drawingによるレンダリング
|
|
37
|
+
*/
|
|
38
|
+
export class GpuLayer {
|
|
39
|
+
/** WebGPUコンテキスト */
|
|
40
|
+
private context: WebGPUContext;
|
|
41
|
+
/** 描画対象のキャンバス */
|
|
42
|
+
private readonly canvas: HTMLCanvasElement;
|
|
43
|
+
|
|
44
|
+
/** レンダーパイプライン */
|
|
45
|
+
private renderPipeline: GPURenderPipeline | null = null;
|
|
46
|
+
/** フィルタリング用コンピュートパイプライン */
|
|
47
|
+
private filterPipeline: GPUComputePipeline | null = null;
|
|
48
|
+
/** Indirect Buffer更新用コンピュートパイプライン */
|
|
49
|
+
private updateIndirectPipeline: GPUComputePipeline | null = null;
|
|
50
|
+
|
|
51
|
+
/** クワッド頂点バッファ(stepMode: 'vertex') */
|
|
52
|
+
private quadVertexBuffer: GPUBuffer | null = null;
|
|
53
|
+
/** 全ポイントデータバッファ (Storage) */
|
|
54
|
+
private allPointsBuffer: GPUBuffer | null = null;
|
|
55
|
+
/** 可視ポイントインデックスバッファ (Storage) */
|
|
56
|
+
private visibleIndicesBuffer: GPUBuffer | null = null;
|
|
57
|
+
/** アトミックカウンターバッファ (Storage) */
|
|
58
|
+
private atomicCounterBuffer: GPUBuffer | null = null;
|
|
59
|
+
/** Indirect Drawingパラメータバッファ */
|
|
60
|
+
private indirectBuffer: GPUBuffer | null = null;
|
|
61
|
+
/** インデックスバッファ */
|
|
62
|
+
private indexBuffer: GPUBuffer | null = null;
|
|
63
|
+
/** レンダリング用ユニフォームバッファ */
|
|
64
|
+
private renderUniformBuffer: GPUBuffer | null = null;
|
|
65
|
+
/** コンピュート用ユニフォームバッファ */
|
|
66
|
+
private computeUniformBuffer: GPUBuffer | null = null;
|
|
67
|
+
/** GPUフィルターカラムデータバッファ */
|
|
68
|
+
private filterColumnsBuffer: GPUBuffer | null = null;
|
|
69
|
+
|
|
70
|
+
/** GPUフィルター条件 */
|
|
71
|
+
private gpuFilterConditions: { columnIndex: number; min: number; max: number }[] = [];
|
|
72
|
+
/** GPUフィルターカラム数 */
|
|
73
|
+
private gpuFilterColumnCount: number = 0;
|
|
74
|
+
|
|
75
|
+
/** レンダリング用バインドグループ */
|
|
76
|
+
private renderBindGroup: GPUBindGroup | null = null;
|
|
77
|
+
/** フィルタリング用バインドグループ */
|
|
78
|
+
private filterBindGroup: GPUBindGroup | null = null;
|
|
79
|
+
/** Indirect更新用バインドグループ */
|
|
80
|
+
private updateIndirectBindGroup: GPUBindGroup | null = null;
|
|
81
|
+
|
|
82
|
+
/** 全ポイント数 */
|
|
83
|
+
private totalPointCount: number = 0;
|
|
84
|
+
/** 背景色 */
|
|
85
|
+
private backgroundColor: ColorRGBA = { r: 0, g: 0, b: 0, a: 0 };
|
|
86
|
+
/** 表示可能なポイントの最大数 */
|
|
87
|
+
private visiblePointLimit: number = 5000000;
|
|
88
|
+
/** フィルタリング結果が有効かどうか */
|
|
89
|
+
private filterResultValid: boolean = false;
|
|
90
|
+
|
|
91
|
+
/** 現在のズームレベル */
|
|
92
|
+
private zoom: number = 1.0;
|
|
93
|
+
/** 現在のX方向パンオフセット */
|
|
94
|
+
private panX: number = 0.0;
|
|
95
|
+
/** 現在のY方向パンオフセット */
|
|
96
|
+
private panY: number = 0.0;
|
|
97
|
+
/** グローバル透明度 (0.0-1.0) */
|
|
98
|
+
private pointAlpha: number = 1.0;
|
|
99
|
+
/** グローバルサイズスケール */
|
|
100
|
+
private pointSizeScale: number = 1.0;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* GpuLayerインスタンスを作成する
|
|
104
|
+
* @param options 設定オプション
|
|
105
|
+
*/
|
|
106
|
+
constructor(options: GpuLayerOptions) {
|
|
107
|
+
this.canvas = options.canvas;
|
|
108
|
+
this.context = new WebGPUContext();
|
|
109
|
+
this.backgroundColor = options.backgroundColor ?? { r: 0, g: 0, b: 0, a: 0 };
|
|
110
|
+
this.visiblePointLimit = options.visiblePointLimit ?? 5000000;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* WebGPUを初期化し、レンダリングリソースを作成する
|
|
115
|
+
* @param initialData 初期データ
|
|
116
|
+
*/
|
|
117
|
+
async initialize(initialData: AllPointsData): Promise<void> {
|
|
118
|
+
await this.context.initialize(this.canvas);
|
|
119
|
+
this.createPipelines();
|
|
120
|
+
await this.createBuffers(initialData);
|
|
121
|
+
this.createBindGroups();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* パイプラインを作成する
|
|
126
|
+
*/
|
|
127
|
+
private createPipelines(): void {
|
|
128
|
+
if (!this.context.device) {
|
|
129
|
+
throw new Error('WebGPU device not initialized');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const filterShaderModule = this.context.device.createShaderModule({
|
|
133
|
+
code: filterComputeShader,
|
|
134
|
+
});
|
|
135
|
+
this.filterPipeline = this.context.device.createComputePipeline({
|
|
136
|
+
layout: 'auto',
|
|
137
|
+
compute: {
|
|
138
|
+
module: filterShaderModule,
|
|
139
|
+
entryPoint: 'main',
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const updateIndirectShaderModule = this.context.device.createShaderModule({
|
|
144
|
+
code: updateIndirectShader,
|
|
145
|
+
});
|
|
146
|
+
this.updateIndirectPipeline = this.context.device.createComputePipeline({
|
|
147
|
+
layout: 'auto',
|
|
148
|
+
compute: {
|
|
149
|
+
module: updateIndirectShaderModule,
|
|
150
|
+
entryPoint: 'main',
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const renderShaderModule = this.context.device.createShaderModule({
|
|
155
|
+
code: scatterVertexShader,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const quadVertexBufferLayout: GPUVertexBufferLayout = {
|
|
159
|
+
arrayStride: 8,
|
|
160
|
+
stepMode: 'vertex',
|
|
161
|
+
attributes: [
|
|
162
|
+
{
|
|
163
|
+
format: 'float32x2',
|
|
164
|
+
offset: 0,
|
|
165
|
+
shaderLocation: 0,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
this.renderPipeline = this.context.device.createRenderPipeline({
|
|
171
|
+
layout: 'auto',
|
|
172
|
+
vertex: {
|
|
173
|
+
module: renderShaderModule,
|
|
174
|
+
entryPoint: 'vertexMain',
|
|
175
|
+
buffers: [quadVertexBufferLayout],
|
|
176
|
+
},
|
|
177
|
+
fragment: {
|
|
178
|
+
module: renderShaderModule,
|
|
179
|
+
entryPoint: 'fragmentMain',
|
|
180
|
+
targets: [
|
|
181
|
+
{
|
|
182
|
+
format: this.context.format,
|
|
183
|
+
blend: {
|
|
184
|
+
color: {
|
|
185
|
+
srcFactor: 'src-alpha',
|
|
186
|
+
dstFactor: 'one-minus-src-alpha',
|
|
187
|
+
operation: 'add',
|
|
188
|
+
},
|
|
189
|
+
alpha: {
|
|
190
|
+
srcFactor: 'one',
|
|
191
|
+
dstFactor: 'one-minus-src-alpha',
|
|
192
|
+
operation: 'add',
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
primitive: {
|
|
199
|
+
topology: 'triangle-list',
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* バッファを作成する
|
|
206
|
+
* @param data 初期データ
|
|
207
|
+
*/
|
|
208
|
+
private async createBuffers(data: AllPointsData): Promise<void> {
|
|
209
|
+
if (!this.context.device) return;
|
|
210
|
+
|
|
211
|
+
this.totalPointCount = data.totalCount;
|
|
212
|
+
|
|
213
|
+
const quadVertices = new Float32Array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
|
|
214
|
+
this.quadVertexBuffer = this.context.device.createBuffer({
|
|
215
|
+
size: quadVertices.byteLength,
|
|
216
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
217
|
+
});
|
|
218
|
+
this.context.device.queue.writeBuffer(this.quadVertexBuffer, 0, quadVertices);
|
|
219
|
+
|
|
220
|
+
const pointsBufferSize = data.totalCount * 16;
|
|
221
|
+
this.allPointsBuffer = this.context.device.createBuffer({
|
|
222
|
+
size: pointsBufferSize,
|
|
223
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
224
|
+
});
|
|
225
|
+
this.context.device.queue.writeBuffer(
|
|
226
|
+
this.allPointsBuffer,
|
|
227
|
+
0,
|
|
228
|
+
data.instanceData.buffer,
|
|
229
|
+
data.instanceData.byteOffset,
|
|
230
|
+
data.instanceData.byteLength
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const indicesBufferSize = data.totalCount * 4;
|
|
234
|
+
this.visibleIndicesBuffer = this.context.device.createBuffer({
|
|
235
|
+
size: indicesBufferSize,
|
|
236
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.atomicCounterBuffer = this.context.device.createBuffer({
|
|
240
|
+
size: 4,
|
|
241
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
this.indirectBuffer = this.context.device.createBuffer({
|
|
245
|
+
size: 20,
|
|
246
|
+
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const indices = new Uint16Array([0, 1, 2, 2, 1, 3]);
|
|
250
|
+
this.indexBuffer = this.context.device.createBuffer({
|
|
251
|
+
size: indices.byteLength,
|
|
252
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
253
|
+
});
|
|
254
|
+
this.context.device.queue.writeBuffer(this.indexBuffer, 0, indices);
|
|
255
|
+
|
|
256
|
+
this.renderUniformBuffer = this.context.device.createBuffer({
|
|
257
|
+
size: 96,
|
|
258
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
this.computeUniformBuffer = this.context.device.createBuffer({
|
|
262
|
+
size: 64,
|
|
263
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const filterColumnsBufferSize = Math.max(16, data.totalCount * 16);
|
|
267
|
+
this.filterColumnsBuffer = this.context.device.createBuffer({
|
|
268
|
+
size: filterColumnsBufferSize,
|
|
269
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
this.updateUniforms();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* バインドグループを作成する
|
|
277
|
+
*/
|
|
278
|
+
private createBindGroups(): void {
|
|
279
|
+
if (
|
|
280
|
+
!this.context.device ||
|
|
281
|
+
!this.filterPipeline ||
|
|
282
|
+
!this.updateIndirectPipeline ||
|
|
283
|
+
!this.renderPipeline ||
|
|
284
|
+
!this.allPointsBuffer ||
|
|
285
|
+
!this.visibleIndicesBuffer ||
|
|
286
|
+
!this.atomicCounterBuffer ||
|
|
287
|
+
!this.indirectBuffer ||
|
|
288
|
+
!this.computeUniformBuffer ||
|
|
289
|
+
!this.renderUniformBuffer ||
|
|
290
|
+
!this.filterColumnsBuffer
|
|
291
|
+
) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
this.filterBindGroup = this.context.device.createBindGroup({
|
|
296
|
+
layout: this.filterPipeline.getBindGroupLayout(0),
|
|
297
|
+
entries: [
|
|
298
|
+
{ binding: 0, resource: { buffer: this.allPointsBuffer } },
|
|
299
|
+
{ binding: 1, resource: { buffer: this.visibleIndicesBuffer } },
|
|
300
|
+
{ binding: 2, resource: { buffer: this.atomicCounterBuffer } },
|
|
301
|
+
{ binding: 3, resource: { buffer: this.computeUniformBuffer } },
|
|
302
|
+
{ binding: 4, resource: { buffer: this.filterColumnsBuffer } },
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.updateIndirectBindGroup = this.context.device.createBindGroup({
|
|
307
|
+
layout: this.updateIndirectPipeline.getBindGroupLayout(0),
|
|
308
|
+
entries: [
|
|
309
|
+
{ binding: 0, resource: { buffer: this.atomicCounterBuffer } },
|
|
310
|
+
{ binding: 1, resource: { buffer: this.indirectBuffer } },
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.renderBindGroup = this.context.device.createBindGroup({
|
|
315
|
+
layout: this.renderPipeline.getBindGroupLayout(0),
|
|
316
|
+
entries: [
|
|
317
|
+
{ binding: 0, resource: { buffer: this.renderUniformBuffer } },
|
|
318
|
+
{ binding: 1, resource: { buffer: this.allPointsBuffer } },
|
|
319
|
+
{ binding: 2, resource: { buffer: this.visibleIndicesBuffer } },
|
|
320
|
+
],
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 全ポイントデータをアップロードする
|
|
326
|
+
* @param data 新しいポイントデータ
|
|
327
|
+
*/
|
|
328
|
+
uploadAllPoints(data: AllPointsData): void {
|
|
329
|
+
if (!this.context.device) return;
|
|
330
|
+
|
|
331
|
+
const newTotalCount = data.totalCount;
|
|
332
|
+
|
|
333
|
+
if (newTotalCount > this.totalPointCount) {
|
|
334
|
+
this.allPointsBuffer?.destroy();
|
|
335
|
+
this.visibleIndicesBuffer?.destroy();
|
|
336
|
+
|
|
337
|
+
const pointsBufferSize = newTotalCount * 16;
|
|
338
|
+
this.allPointsBuffer = this.context.device.createBuffer({
|
|
339
|
+
size: pointsBufferSize,
|
|
340
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const indicesBufferSize = newTotalCount * 4;
|
|
344
|
+
this.visibleIndicesBuffer = this.context.device.createBuffer({
|
|
345
|
+
size: indicesBufferSize,
|
|
346
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
this.createBindGroups();
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
this.totalPointCount = newTotalCount;
|
|
353
|
+
this.filterResultValid = false;
|
|
354
|
+
|
|
355
|
+
if (this.allPointsBuffer) {
|
|
356
|
+
this.context.device.queue.writeBuffer(
|
|
357
|
+
this.allPointsBuffer,
|
|
358
|
+
0,
|
|
359
|
+
data.instanceData.buffer,
|
|
360
|
+
data.instanceData.byteOffset,
|
|
361
|
+
data.instanceData.byteLength
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* ビュー行列を作成する
|
|
368
|
+
*/
|
|
369
|
+
private createViewMatrix(): Float32Array {
|
|
370
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
371
|
+
return new Float32Array([
|
|
372
|
+
this.zoom / aspectRatio,
|
|
373
|
+
0,
|
|
374
|
+
0,
|
|
375
|
+
0,
|
|
376
|
+
0,
|
|
377
|
+
this.zoom,
|
|
378
|
+
0,
|
|
379
|
+
0,
|
|
380
|
+
0,
|
|
381
|
+
0,
|
|
382
|
+
1,
|
|
383
|
+
0,
|
|
384
|
+
this.panX,
|
|
385
|
+
this.panY,
|
|
386
|
+
0,
|
|
387
|
+
1,
|
|
388
|
+
]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* LOD閾値を計算する
|
|
393
|
+
* ズームレベルに応じて表示するポイント数を制限する
|
|
394
|
+
*/
|
|
395
|
+
private calculateLodThreshold(): number {
|
|
396
|
+
// 画面上に表示する最大ポイント数の目標値
|
|
397
|
+
let targetPoints = this.visiblePointLimit;
|
|
398
|
+
|
|
399
|
+
// ズームアウト(zoom < 1.0)時は、画面内に大量の点が密集するため、
|
|
400
|
+
// 重なり合いによるフラグメントシェーダーの過負荷を防ぐため目標点数を減らす。
|
|
401
|
+
if (this.zoom < 1.0) {
|
|
402
|
+
targetPoints = Math.floor(this.visiblePointLimit * Math.pow(this.zoom, 0.6));
|
|
403
|
+
// 最低限の分布が見えるライン(visiblePointLimitの10%)
|
|
404
|
+
targetPoints = Math.max(Math.floor(this.visiblePointLimit * 0.1), targetPoints);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 全ポイント数が目標以下なら常に全表示(フィルタリング不要)
|
|
408
|
+
if (this.totalPointCount <= targetPoints) {
|
|
409
|
+
return 0xffffffff;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ズームレベルに基づく表示領域の割合(概算)
|
|
413
|
+
// zoom=1.0を一単位として、ズームするほど表示範囲は狭くなる
|
|
414
|
+
const visibleAreaFraction = 1.0 / (this.zoom * this.zoom);
|
|
415
|
+
|
|
416
|
+
// その領域に含まれると予想されるポイント数
|
|
417
|
+
const expectedPointsInView = Math.min(
|
|
418
|
+
this.totalPointCount,
|
|
419
|
+
this.totalPointCount * visibleAreaFraction
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// 目標内なら間引きなし
|
|
423
|
+
if (expectedPointsInView <= targetPoints) {
|
|
424
|
+
return 0xffffffff;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 目標点数に抑えるための維持率 (Keep Ratio)
|
|
428
|
+
let keepRatio = targetPoints / expectedPointsInView;
|
|
429
|
+
|
|
430
|
+
// 1.0(全表示)を超えないようにクランプ
|
|
431
|
+
keepRatio = Math.min(1.0, keepRatio);
|
|
432
|
+
|
|
433
|
+
// u32の最大値に対する閾値を計算
|
|
434
|
+
return Math.floor(0xffffffff * keepRatio);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* ユニフォームを更新する
|
|
439
|
+
*/
|
|
440
|
+
updateUniforms(): void {
|
|
441
|
+
if (!this.context.device || !this.renderUniformBuffer || !this.computeUniformBuffer) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
this.filterResultValid = false;
|
|
446
|
+
|
|
447
|
+
const viewMatrix = this.createViewMatrix();
|
|
448
|
+
|
|
449
|
+
const renderUniformData = new Float32Array(24);
|
|
450
|
+
renderUniformData.set(viewMatrix, 0);
|
|
451
|
+
// Vertex Shaderで pow(zoom, 0.3) を計算するコストを避けるため、CPUで事前に計算して渡す
|
|
452
|
+
renderUniformData[16] = Math.pow(this.zoom, 0.3);
|
|
453
|
+
renderUniformData[17] = this.canvas.width;
|
|
454
|
+
renderUniformData[18] = this.canvas.height;
|
|
455
|
+
renderUniformData[19] = this.pointAlpha;
|
|
456
|
+
renderUniformData[20] = this.pointSizeScale;
|
|
457
|
+
this.context.device.queue.writeBuffer(this.renderUniformBuffer, 0, renderUniformData);
|
|
458
|
+
|
|
459
|
+
// 逆変換を行ってワールド空間での境界を計算し、シェーダー内での行列演算を削除する
|
|
460
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
461
|
+
const clipMinX = -1 - VIEWPORT_MARGIN;
|
|
462
|
+
const clipMinY = -1 - VIEWPORT_MARGIN;
|
|
463
|
+
const clipMaxX = 1 + VIEWPORT_MARGIN;
|
|
464
|
+
const clipMaxY = 1 + VIEWPORT_MARGIN;
|
|
465
|
+
|
|
466
|
+
const scaleX = this.zoom / aspectRatio;
|
|
467
|
+
const scaleY = this.zoom;
|
|
468
|
+
|
|
469
|
+
const worldMinX = (clipMinX - this.panX) / scaleX;
|
|
470
|
+
const worldMaxX = (clipMaxX - this.panX) / scaleX;
|
|
471
|
+
const worldMinY = (clipMinY - this.panY) / scaleY;
|
|
472
|
+
const worldMaxY = (clipMaxY - this.panY) / scaleY;
|
|
473
|
+
|
|
474
|
+
const computeUniformData = new ArrayBuffer(64);
|
|
475
|
+
const computeFloatView = new Float32Array(computeUniformData);
|
|
476
|
+
const computeUint32View = new Uint32Array(computeUniformData);
|
|
477
|
+
|
|
478
|
+
computeFloatView[0] = worldMinX;
|
|
479
|
+
computeFloatView[1] = worldMinY;
|
|
480
|
+
computeFloatView[2] = worldMaxX;
|
|
481
|
+
computeFloatView[3] = worldMaxY;
|
|
482
|
+
computeUint32View[4] = this.calculateLodThreshold();
|
|
483
|
+
computeUint32View[5] = this.totalPointCount;
|
|
484
|
+
|
|
485
|
+
let activeFilterMask = 0;
|
|
486
|
+
const filterRangeMin = [-Infinity, -Infinity, -Infinity, -Infinity];
|
|
487
|
+
const filterRangeMax = [Infinity, Infinity, Infinity, Infinity];
|
|
488
|
+
|
|
489
|
+
for (const condition of this.gpuFilterConditions) {
|
|
490
|
+
if (condition.columnIndex >= 0 && condition.columnIndex < 4) {
|
|
491
|
+
activeFilterMask |= 1 << condition.columnIndex;
|
|
492
|
+
filterRangeMin[condition.columnIndex] = condition.min;
|
|
493
|
+
filterRangeMax[condition.columnIndex] = condition.max;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
computeUint32View[6] = activeFilterMask;
|
|
498
|
+
|
|
499
|
+
computeFloatView[8] = filterRangeMin[0];
|
|
500
|
+
computeFloatView[9] = filterRangeMin[1];
|
|
501
|
+
computeFloatView[10] = filterRangeMin[2];
|
|
502
|
+
computeFloatView[11] = filterRangeMin[3];
|
|
503
|
+
|
|
504
|
+
computeFloatView[12] = filterRangeMax[0];
|
|
505
|
+
computeFloatView[13] = filterRangeMax[1];
|
|
506
|
+
computeFloatView[14] = filterRangeMax[2];
|
|
507
|
+
computeFloatView[15] = filterRangeMax[3];
|
|
508
|
+
|
|
509
|
+
this.context.device.queue.writeBuffer(this.computeUniformBuffer, 0, computeUniformData);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* 散布図をレンダリングする
|
|
514
|
+
*/
|
|
515
|
+
render(): void {
|
|
516
|
+
if (
|
|
517
|
+
!this.context.device ||
|
|
518
|
+
!this.context.context ||
|
|
519
|
+
!this.filterPipeline ||
|
|
520
|
+
!this.updateIndirectPipeline ||
|
|
521
|
+
!this.renderPipeline ||
|
|
522
|
+
!this.quadVertexBuffer ||
|
|
523
|
+
!this.filterBindGroup ||
|
|
524
|
+
!this.updateIndirectBindGroup ||
|
|
525
|
+
!this.renderBindGroup ||
|
|
526
|
+
!this.atomicCounterBuffer ||
|
|
527
|
+
!this.indirectBuffer
|
|
528
|
+
) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const commandEncoder = this.context.device.createCommandEncoder();
|
|
533
|
+
|
|
534
|
+
if (!this.filterResultValid) {
|
|
535
|
+
this.context.device.queue.writeBuffer(this.atomicCounterBuffer, 0, new Uint32Array([0]));
|
|
536
|
+
|
|
537
|
+
{
|
|
538
|
+
const computePass = commandEncoder.beginComputePass();
|
|
539
|
+
computePass.setPipeline(this.filterPipeline);
|
|
540
|
+
computePass.setBindGroup(0, this.filterBindGroup);
|
|
541
|
+
const workgroupCount = Math.ceil(this.totalPointCount / 256);
|
|
542
|
+
computePass.dispatchWorkgroups(workgroupCount);
|
|
543
|
+
computePass.end();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
{
|
|
547
|
+
const computePass = commandEncoder.beginComputePass();
|
|
548
|
+
computePass.setPipeline(this.updateIndirectPipeline);
|
|
549
|
+
computePass.setBindGroup(0, this.updateIndirectBindGroup);
|
|
550
|
+
computePass.dispatchWorkgroups(1);
|
|
551
|
+
computePass.end();
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
this.filterResultValid = true;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
{
|
|
558
|
+
const textureView = this.context.context.getCurrentTexture().createView();
|
|
559
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
560
|
+
colorAttachments: [
|
|
561
|
+
{
|
|
562
|
+
view: textureView,
|
|
563
|
+
clearValue: {
|
|
564
|
+
r: this.backgroundColor.r,
|
|
565
|
+
g: this.backgroundColor.g,
|
|
566
|
+
b: this.backgroundColor.b,
|
|
567
|
+
a: this.backgroundColor.a,
|
|
568
|
+
},
|
|
569
|
+
loadOp: 'clear',
|
|
570
|
+
storeOp: 'store',
|
|
571
|
+
},
|
|
572
|
+
],
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
renderPass.setPipeline(this.renderPipeline);
|
|
576
|
+
renderPass.setVertexBuffer(0, this.quadVertexBuffer);
|
|
577
|
+
renderPass.setIndexBuffer(this.indexBuffer!, 'uint16');
|
|
578
|
+
renderPass.setBindGroup(0, this.renderBindGroup);
|
|
579
|
+
renderPass.drawIndexedIndirect(this.indirectBuffer, 0);
|
|
580
|
+
renderPass.end();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
this.context.device.queue.submit([commandEncoder.finish()]);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* キャンバスをリサイズし、ビューポートを更新する
|
|
588
|
+
*/
|
|
589
|
+
resize(width: number, height: number): void {
|
|
590
|
+
this.canvas.width = width;
|
|
591
|
+
this.canvas.height = height;
|
|
592
|
+
this.updateUniforms();
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* ズームレベルを設定する
|
|
597
|
+
*/
|
|
598
|
+
setZoom(zoom: number): void {
|
|
599
|
+
this.zoom = Math.max(0.01, Math.min(100, zoom));
|
|
600
|
+
this.updateUniforms();
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* 現在のズームレベルを取得する
|
|
605
|
+
*/
|
|
606
|
+
getZoom(): number {
|
|
607
|
+
return this.zoom;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* パンオフセットを設定する
|
|
612
|
+
*/
|
|
613
|
+
setPan(x: number, y: number): void {
|
|
614
|
+
this.panX = x;
|
|
615
|
+
this.panY = y;
|
|
616
|
+
this.updateUniforms();
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* 現在のパンオフセットを取得する
|
|
621
|
+
*/
|
|
622
|
+
getPan(): { x: number; y: number } {
|
|
623
|
+
return { x: this.panX, y: this.panY };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* 指定した画面座標を中心にズームする
|
|
628
|
+
*/
|
|
629
|
+
zoomToPoint(newZoom: number, screenX: number, screenY: number): void {
|
|
630
|
+
const clampedZoom = Math.max(0.01, Math.min(100, newZoom));
|
|
631
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
632
|
+
|
|
633
|
+
const ndcX = (screenX / this.canvas.width) * 2 - 1;
|
|
634
|
+
const ndcY = -((screenY / this.canvas.height) * 2 - 1);
|
|
635
|
+
|
|
636
|
+
const worldXBefore = ((ndcX - this.panX) * aspectRatio) / this.zoom;
|
|
637
|
+
const worldYBefore = (ndcY - this.panY) / this.zoom;
|
|
638
|
+
|
|
639
|
+
this.zoom = clampedZoom;
|
|
640
|
+
|
|
641
|
+
this.panX = ndcX - (worldXBefore * this.zoom) / aspectRatio;
|
|
642
|
+
this.panY = ndcY - worldYBefore * this.zoom;
|
|
643
|
+
|
|
644
|
+
this.updateUniforms();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* GPUレイヤーの設定オプションを更新する
|
|
649
|
+
*/
|
|
650
|
+
updateOptions(options: Partial<GpuLayerOptions>): void {
|
|
651
|
+
if (options.backgroundColor !== undefined) {
|
|
652
|
+
this.backgroundColor = options.backgroundColor;
|
|
653
|
+
}
|
|
654
|
+
if (options.visiblePointLimit !== undefined) {
|
|
655
|
+
this.visiblePointLimit = options.visiblePointLimit;
|
|
656
|
+
// LOD閾値が変わるのでフィルタ結果を無効化
|
|
657
|
+
this.filterResultValid = false;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* GPUフィルターカラムデータをアップロードする
|
|
663
|
+
* @param data フィルターカラムデータ(各ポイントに4カラム分のf32、totalPoints * 4 floats)
|
|
664
|
+
* @param columnCount 有効なカラム数(0-4)
|
|
665
|
+
*/
|
|
666
|
+
uploadFilterColumns(data: Float32Array, columnCount: number): void {
|
|
667
|
+
if (!this.context.device) return;
|
|
668
|
+
|
|
669
|
+
this.gpuFilterColumnCount = Math.min(4, columnCount);
|
|
670
|
+
const requiredSize = this.totalPointCount * 16;
|
|
671
|
+
|
|
672
|
+
if (!this.filterColumnsBuffer || data.byteLength > requiredSize) {
|
|
673
|
+
this.filterColumnsBuffer?.destroy();
|
|
674
|
+
this.filterColumnsBuffer = this.context.device.createBuffer({
|
|
675
|
+
size: Math.max(16, data.byteLength),
|
|
676
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
677
|
+
});
|
|
678
|
+
this.createBindGroups();
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
this.context.device.queue.writeBuffer(
|
|
682
|
+
this.filterColumnsBuffer,
|
|
683
|
+
0,
|
|
684
|
+
data.buffer,
|
|
685
|
+
data.byteOffset,
|
|
686
|
+
data.byteLength
|
|
687
|
+
);
|
|
688
|
+
this.filterResultValid = false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* GPUフィルター条件を設定する
|
|
693
|
+
* @param conditions フィルター条件の配列
|
|
694
|
+
*/
|
|
695
|
+
setGpuFilterConditions(conditions: { columnIndex: number; min: number; max: number }[]): void {
|
|
696
|
+
this.gpuFilterConditions = conditions;
|
|
697
|
+
this.filterResultValid = false;
|
|
698
|
+
this.updateUniforms();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* GPUフィルター条件をクリアする
|
|
703
|
+
*/
|
|
704
|
+
clearGpuFilterConditions(): void {
|
|
705
|
+
this.gpuFilterConditions = [];
|
|
706
|
+
this.filterResultValid = false;
|
|
707
|
+
this.updateUniforms();
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* グローバル透明度を設定する
|
|
712
|
+
* @param alpha 透明度 (0.0-1.0)
|
|
713
|
+
*/
|
|
714
|
+
setPointAlpha(alpha: number): void {
|
|
715
|
+
this.pointAlpha = Math.max(0, Math.min(1, alpha));
|
|
716
|
+
this.updateUniforms();
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 現在のグローバル透明度を取得する
|
|
721
|
+
*/
|
|
722
|
+
getPointAlpha(): number {
|
|
723
|
+
return this.pointAlpha;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* グローバルサイズスケールを設定する
|
|
728
|
+
* @param scale サイズスケール (0.01以上)
|
|
729
|
+
*/
|
|
730
|
+
setPointSizeScale(scale: number): void {
|
|
731
|
+
this.pointSizeScale = Math.max(0.01, scale);
|
|
732
|
+
this.updateUniforms();
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* 現在のグローバルサイズスケールを取得する
|
|
737
|
+
*/
|
|
738
|
+
getPointSizeScale(): number {
|
|
739
|
+
return this.pointSizeScale;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* リソースを破棄する
|
|
744
|
+
*/
|
|
745
|
+
destroy(): void {
|
|
746
|
+
this.quadVertexBuffer?.destroy();
|
|
747
|
+
this.allPointsBuffer?.destroy();
|
|
748
|
+
this.visibleIndicesBuffer?.destroy();
|
|
749
|
+
this.atomicCounterBuffer?.destroy();
|
|
750
|
+
this.indirectBuffer?.destroy();
|
|
751
|
+
this.indexBuffer?.destroy();
|
|
752
|
+
this.renderUniformBuffer?.destroy();
|
|
753
|
+
this.computeUniformBuffer?.destroy();
|
|
754
|
+
this.filterColumnsBuffer?.destroy();
|
|
755
|
+
this.context.destroy();
|
|
756
|
+
}
|
|
757
|
+
}
|