@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,611 @@
|
|
|
1
|
+
import { WebGPUContext } from './webgpu-context.js';
|
|
2
|
+
import { scatterVertexShader, filterComputeShader, updateIndirectShader } from './shaders.js';
|
|
3
|
+
// ビューポート境界のマージン(クリップ空間)
|
|
4
|
+
const VIEWPORT_MARGIN = 0.1;
|
|
5
|
+
/**
|
|
6
|
+
* WebGPUレンダリングを担当するレイヤー
|
|
7
|
+
* 責務:
|
|
8
|
+
* - WebGPUコンテキストとパイプラインの管理
|
|
9
|
+
* - GPUバッファの作成と管理
|
|
10
|
+
* - コンピュートシェーダーによるLODフィルタリング
|
|
11
|
+
* - Indirect Drawingによるレンダリング
|
|
12
|
+
*/
|
|
13
|
+
export class GpuLayer {
|
|
14
|
+
/**
|
|
15
|
+
* GpuLayerインスタンスを作成する
|
|
16
|
+
* @param options 設定オプション
|
|
17
|
+
*/
|
|
18
|
+
constructor(options) {
|
|
19
|
+
/** レンダーパイプライン */
|
|
20
|
+
this.renderPipeline = null;
|
|
21
|
+
/** フィルタリング用コンピュートパイプライン */
|
|
22
|
+
this.filterPipeline = null;
|
|
23
|
+
/** Indirect Buffer更新用コンピュートパイプライン */
|
|
24
|
+
this.updateIndirectPipeline = null;
|
|
25
|
+
/** クワッド頂点バッファ(stepMode: 'vertex') */
|
|
26
|
+
this.quadVertexBuffer = null;
|
|
27
|
+
/** 全ポイントデータバッファ (Storage) */
|
|
28
|
+
this.allPointsBuffer = null;
|
|
29
|
+
/** 可視ポイントインデックスバッファ (Storage) */
|
|
30
|
+
this.visibleIndicesBuffer = null;
|
|
31
|
+
/** アトミックカウンターバッファ (Storage) */
|
|
32
|
+
this.atomicCounterBuffer = null;
|
|
33
|
+
/** Indirect Drawingパラメータバッファ */
|
|
34
|
+
this.indirectBuffer = null;
|
|
35
|
+
/** インデックスバッファ */
|
|
36
|
+
this.indexBuffer = null;
|
|
37
|
+
/** レンダリング用ユニフォームバッファ */
|
|
38
|
+
this.renderUniformBuffer = null;
|
|
39
|
+
/** コンピュート用ユニフォームバッファ */
|
|
40
|
+
this.computeUniformBuffer = null;
|
|
41
|
+
/** GPUフィルターカラムデータバッファ */
|
|
42
|
+
this.filterColumnsBuffer = null;
|
|
43
|
+
/** GPUフィルター条件 */
|
|
44
|
+
this.gpuFilterConditions = [];
|
|
45
|
+
/** GPUフィルターカラム数 */
|
|
46
|
+
this.gpuFilterColumnCount = 0;
|
|
47
|
+
/** レンダリング用バインドグループ */
|
|
48
|
+
this.renderBindGroup = null;
|
|
49
|
+
/** フィルタリング用バインドグループ */
|
|
50
|
+
this.filterBindGroup = null;
|
|
51
|
+
/** Indirect更新用バインドグループ */
|
|
52
|
+
this.updateIndirectBindGroup = null;
|
|
53
|
+
/** 全ポイント数 */
|
|
54
|
+
this.totalPointCount = 0;
|
|
55
|
+
/** 背景色 */
|
|
56
|
+
this.backgroundColor = { r: 0, g: 0, b: 0, a: 0 };
|
|
57
|
+
/** 表示可能なポイントの最大数 */
|
|
58
|
+
this.visiblePointLimit = 5000000;
|
|
59
|
+
/** フィルタリング結果が有効かどうか */
|
|
60
|
+
this.filterResultValid = false;
|
|
61
|
+
/** 現在のズームレベル */
|
|
62
|
+
this.zoom = 1.0;
|
|
63
|
+
/** 現在のX方向パンオフセット */
|
|
64
|
+
this.panX = 0.0;
|
|
65
|
+
/** 現在のY方向パンオフセット */
|
|
66
|
+
this.panY = 0.0;
|
|
67
|
+
/** グローバル透明度 (0.0-1.0) */
|
|
68
|
+
this.pointAlpha = 1.0;
|
|
69
|
+
/** グローバルサイズスケール */
|
|
70
|
+
this.pointSizeScale = 1.0;
|
|
71
|
+
this.canvas = options.canvas;
|
|
72
|
+
this.context = new WebGPUContext();
|
|
73
|
+
this.backgroundColor = options.backgroundColor ?? { r: 0, g: 0, b: 0, a: 0 };
|
|
74
|
+
this.visiblePointLimit = options.visiblePointLimit ?? 5000000;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* WebGPUを初期化し、レンダリングリソースを作成する
|
|
78
|
+
* @param initialData 初期データ
|
|
79
|
+
*/
|
|
80
|
+
async initialize(initialData) {
|
|
81
|
+
await this.context.initialize(this.canvas);
|
|
82
|
+
this.createPipelines();
|
|
83
|
+
await this.createBuffers(initialData);
|
|
84
|
+
this.createBindGroups();
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* パイプラインを作成する
|
|
88
|
+
*/
|
|
89
|
+
createPipelines() {
|
|
90
|
+
if (!this.context.device) {
|
|
91
|
+
throw new Error('WebGPU device not initialized');
|
|
92
|
+
}
|
|
93
|
+
const filterShaderModule = this.context.device.createShaderModule({
|
|
94
|
+
code: filterComputeShader,
|
|
95
|
+
});
|
|
96
|
+
this.filterPipeline = this.context.device.createComputePipeline({
|
|
97
|
+
layout: 'auto',
|
|
98
|
+
compute: {
|
|
99
|
+
module: filterShaderModule,
|
|
100
|
+
entryPoint: 'main',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const updateIndirectShaderModule = this.context.device.createShaderModule({
|
|
104
|
+
code: updateIndirectShader,
|
|
105
|
+
});
|
|
106
|
+
this.updateIndirectPipeline = this.context.device.createComputePipeline({
|
|
107
|
+
layout: 'auto',
|
|
108
|
+
compute: {
|
|
109
|
+
module: updateIndirectShaderModule,
|
|
110
|
+
entryPoint: 'main',
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const renderShaderModule = this.context.device.createShaderModule({
|
|
114
|
+
code: scatterVertexShader,
|
|
115
|
+
});
|
|
116
|
+
const quadVertexBufferLayout = {
|
|
117
|
+
arrayStride: 8,
|
|
118
|
+
stepMode: 'vertex',
|
|
119
|
+
attributes: [
|
|
120
|
+
{
|
|
121
|
+
format: 'float32x2',
|
|
122
|
+
offset: 0,
|
|
123
|
+
shaderLocation: 0,
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
127
|
+
this.renderPipeline = this.context.device.createRenderPipeline({
|
|
128
|
+
layout: 'auto',
|
|
129
|
+
vertex: {
|
|
130
|
+
module: renderShaderModule,
|
|
131
|
+
entryPoint: 'vertexMain',
|
|
132
|
+
buffers: [quadVertexBufferLayout],
|
|
133
|
+
},
|
|
134
|
+
fragment: {
|
|
135
|
+
module: renderShaderModule,
|
|
136
|
+
entryPoint: 'fragmentMain',
|
|
137
|
+
targets: [
|
|
138
|
+
{
|
|
139
|
+
format: this.context.format,
|
|
140
|
+
blend: {
|
|
141
|
+
color: {
|
|
142
|
+
srcFactor: 'src-alpha',
|
|
143
|
+
dstFactor: 'one-minus-src-alpha',
|
|
144
|
+
operation: 'add',
|
|
145
|
+
},
|
|
146
|
+
alpha: {
|
|
147
|
+
srcFactor: 'one',
|
|
148
|
+
dstFactor: 'one-minus-src-alpha',
|
|
149
|
+
operation: 'add',
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
primitive: {
|
|
156
|
+
topology: 'triangle-list',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* バッファを作成する
|
|
162
|
+
* @param data 初期データ
|
|
163
|
+
*/
|
|
164
|
+
async createBuffers(data) {
|
|
165
|
+
if (!this.context.device)
|
|
166
|
+
return;
|
|
167
|
+
this.totalPointCount = data.totalCount;
|
|
168
|
+
const quadVertices = new Float32Array([-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0]);
|
|
169
|
+
this.quadVertexBuffer = this.context.device.createBuffer({
|
|
170
|
+
size: quadVertices.byteLength,
|
|
171
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
172
|
+
});
|
|
173
|
+
this.context.device.queue.writeBuffer(this.quadVertexBuffer, 0, quadVertices);
|
|
174
|
+
const pointsBufferSize = data.totalCount * 16;
|
|
175
|
+
this.allPointsBuffer = this.context.device.createBuffer({
|
|
176
|
+
size: pointsBufferSize,
|
|
177
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
178
|
+
});
|
|
179
|
+
this.context.device.queue.writeBuffer(this.allPointsBuffer, 0, data.instanceData.buffer, data.instanceData.byteOffset, data.instanceData.byteLength);
|
|
180
|
+
const indicesBufferSize = data.totalCount * 4;
|
|
181
|
+
this.visibleIndicesBuffer = this.context.device.createBuffer({
|
|
182
|
+
size: indicesBufferSize,
|
|
183
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
184
|
+
});
|
|
185
|
+
this.atomicCounterBuffer = this.context.device.createBuffer({
|
|
186
|
+
size: 4,
|
|
187
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
188
|
+
});
|
|
189
|
+
this.indirectBuffer = this.context.device.createBuffer({
|
|
190
|
+
size: 20,
|
|
191
|
+
usage: GPUBufferUsage.INDIRECT | GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
192
|
+
});
|
|
193
|
+
const indices = new Uint16Array([0, 1, 2, 2, 1, 3]);
|
|
194
|
+
this.indexBuffer = this.context.device.createBuffer({
|
|
195
|
+
size: indices.byteLength,
|
|
196
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
197
|
+
});
|
|
198
|
+
this.context.device.queue.writeBuffer(this.indexBuffer, 0, indices);
|
|
199
|
+
this.renderUniformBuffer = this.context.device.createBuffer({
|
|
200
|
+
size: 96,
|
|
201
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
202
|
+
});
|
|
203
|
+
this.computeUniformBuffer = this.context.device.createBuffer({
|
|
204
|
+
size: 64,
|
|
205
|
+
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
|
|
206
|
+
});
|
|
207
|
+
const filterColumnsBufferSize = Math.max(16, data.totalCount * 16);
|
|
208
|
+
this.filterColumnsBuffer = this.context.device.createBuffer({
|
|
209
|
+
size: filterColumnsBufferSize,
|
|
210
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
211
|
+
});
|
|
212
|
+
this.updateUniforms();
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* バインドグループを作成する
|
|
216
|
+
*/
|
|
217
|
+
createBindGroups() {
|
|
218
|
+
if (!this.context.device ||
|
|
219
|
+
!this.filterPipeline ||
|
|
220
|
+
!this.updateIndirectPipeline ||
|
|
221
|
+
!this.renderPipeline ||
|
|
222
|
+
!this.allPointsBuffer ||
|
|
223
|
+
!this.visibleIndicesBuffer ||
|
|
224
|
+
!this.atomicCounterBuffer ||
|
|
225
|
+
!this.indirectBuffer ||
|
|
226
|
+
!this.computeUniformBuffer ||
|
|
227
|
+
!this.renderUniformBuffer ||
|
|
228
|
+
!this.filterColumnsBuffer) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
this.filterBindGroup = this.context.device.createBindGroup({
|
|
232
|
+
layout: this.filterPipeline.getBindGroupLayout(0),
|
|
233
|
+
entries: [
|
|
234
|
+
{ binding: 0, resource: { buffer: this.allPointsBuffer } },
|
|
235
|
+
{ binding: 1, resource: { buffer: this.visibleIndicesBuffer } },
|
|
236
|
+
{ binding: 2, resource: { buffer: this.atomicCounterBuffer } },
|
|
237
|
+
{ binding: 3, resource: { buffer: this.computeUniformBuffer } },
|
|
238
|
+
{ binding: 4, resource: { buffer: this.filterColumnsBuffer } },
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
this.updateIndirectBindGroup = this.context.device.createBindGroup({
|
|
242
|
+
layout: this.updateIndirectPipeline.getBindGroupLayout(0),
|
|
243
|
+
entries: [
|
|
244
|
+
{ binding: 0, resource: { buffer: this.atomicCounterBuffer } },
|
|
245
|
+
{ binding: 1, resource: { buffer: this.indirectBuffer } },
|
|
246
|
+
],
|
|
247
|
+
});
|
|
248
|
+
this.renderBindGroup = this.context.device.createBindGroup({
|
|
249
|
+
layout: this.renderPipeline.getBindGroupLayout(0),
|
|
250
|
+
entries: [
|
|
251
|
+
{ binding: 0, resource: { buffer: this.renderUniformBuffer } },
|
|
252
|
+
{ binding: 1, resource: { buffer: this.allPointsBuffer } },
|
|
253
|
+
{ binding: 2, resource: { buffer: this.visibleIndicesBuffer } },
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* 全ポイントデータをアップロードする
|
|
259
|
+
* @param data 新しいポイントデータ
|
|
260
|
+
*/
|
|
261
|
+
uploadAllPoints(data) {
|
|
262
|
+
if (!this.context.device)
|
|
263
|
+
return;
|
|
264
|
+
const newTotalCount = data.totalCount;
|
|
265
|
+
if (newTotalCount > this.totalPointCount) {
|
|
266
|
+
this.allPointsBuffer?.destroy();
|
|
267
|
+
this.visibleIndicesBuffer?.destroy();
|
|
268
|
+
const pointsBufferSize = newTotalCount * 16;
|
|
269
|
+
this.allPointsBuffer = this.context.device.createBuffer({
|
|
270
|
+
size: pointsBufferSize,
|
|
271
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
272
|
+
});
|
|
273
|
+
const indicesBufferSize = newTotalCount * 4;
|
|
274
|
+
this.visibleIndicesBuffer = this.context.device.createBuffer({
|
|
275
|
+
size: indicesBufferSize,
|
|
276
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX,
|
|
277
|
+
});
|
|
278
|
+
this.createBindGroups();
|
|
279
|
+
}
|
|
280
|
+
this.totalPointCount = newTotalCount;
|
|
281
|
+
this.filterResultValid = false;
|
|
282
|
+
if (this.allPointsBuffer) {
|
|
283
|
+
this.context.device.queue.writeBuffer(this.allPointsBuffer, 0, data.instanceData.buffer, data.instanceData.byteOffset, data.instanceData.byteLength);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* ビュー行列を作成する
|
|
288
|
+
*/
|
|
289
|
+
createViewMatrix() {
|
|
290
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
291
|
+
return new Float32Array([
|
|
292
|
+
this.zoom / aspectRatio,
|
|
293
|
+
0,
|
|
294
|
+
0,
|
|
295
|
+
0,
|
|
296
|
+
0,
|
|
297
|
+
this.zoom,
|
|
298
|
+
0,
|
|
299
|
+
0,
|
|
300
|
+
0,
|
|
301
|
+
0,
|
|
302
|
+
1,
|
|
303
|
+
0,
|
|
304
|
+
this.panX,
|
|
305
|
+
this.panY,
|
|
306
|
+
0,
|
|
307
|
+
1,
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* LOD閾値を計算する
|
|
312
|
+
* ズームレベルに応じて表示するポイント数を制限する
|
|
313
|
+
*/
|
|
314
|
+
calculateLodThreshold() {
|
|
315
|
+
// 画面上に表示する最大ポイント数の目標値
|
|
316
|
+
let targetPoints = this.visiblePointLimit;
|
|
317
|
+
// ズームアウト(zoom < 1.0)時は、画面内に大量の点が密集するため、
|
|
318
|
+
// 重なり合いによるフラグメントシェーダーの過負荷を防ぐため目標点数を減らす。
|
|
319
|
+
if (this.zoom < 1.0) {
|
|
320
|
+
targetPoints = Math.floor(this.visiblePointLimit * Math.pow(this.zoom, 0.6));
|
|
321
|
+
// 最低限の分布が見えるライン(visiblePointLimitの10%)
|
|
322
|
+
targetPoints = Math.max(Math.floor(this.visiblePointLimit * 0.1), targetPoints);
|
|
323
|
+
}
|
|
324
|
+
// 全ポイント数が目標以下なら常に全表示(フィルタリング不要)
|
|
325
|
+
if (this.totalPointCount <= targetPoints) {
|
|
326
|
+
return 0xffffffff;
|
|
327
|
+
}
|
|
328
|
+
// ズームレベルに基づく表示領域の割合(概算)
|
|
329
|
+
// zoom=1.0を一単位として、ズームするほど表示範囲は狭くなる
|
|
330
|
+
const visibleAreaFraction = 1.0 / (this.zoom * this.zoom);
|
|
331
|
+
// その領域に含まれると予想されるポイント数
|
|
332
|
+
const expectedPointsInView = Math.min(this.totalPointCount, this.totalPointCount * visibleAreaFraction);
|
|
333
|
+
// 目標内なら間引きなし
|
|
334
|
+
if (expectedPointsInView <= targetPoints) {
|
|
335
|
+
return 0xffffffff;
|
|
336
|
+
}
|
|
337
|
+
// 目標点数に抑えるための維持率 (Keep Ratio)
|
|
338
|
+
let keepRatio = targetPoints / expectedPointsInView;
|
|
339
|
+
// 1.0(全表示)を超えないようにクランプ
|
|
340
|
+
keepRatio = Math.min(1.0, keepRatio);
|
|
341
|
+
// u32の最大値に対する閾値を計算
|
|
342
|
+
return Math.floor(0xffffffff * keepRatio);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* ユニフォームを更新する
|
|
346
|
+
*/
|
|
347
|
+
updateUniforms() {
|
|
348
|
+
if (!this.context.device || !this.renderUniformBuffer || !this.computeUniformBuffer) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
this.filterResultValid = false;
|
|
352
|
+
const viewMatrix = this.createViewMatrix();
|
|
353
|
+
const renderUniformData = new Float32Array(24);
|
|
354
|
+
renderUniformData.set(viewMatrix, 0);
|
|
355
|
+
// Vertex Shaderで pow(zoom, 0.3) を計算するコストを避けるため、CPUで事前に計算して渡す
|
|
356
|
+
renderUniformData[16] = Math.pow(this.zoom, 0.3);
|
|
357
|
+
renderUniformData[17] = this.canvas.width;
|
|
358
|
+
renderUniformData[18] = this.canvas.height;
|
|
359
|
+
renderUniformData[19] = this.pointAlpha;
|
|
360
|
+
renderUniformData[20] = this.pointSizeScale;
|
|
361
|
+
this.context.device.queue.writeBuffer(this.renderUniformBuffer, 0, renderUniformData);
|
|
362
|
+
// 逆変換を行ってワールド空間での境界を計算し、シェーダー内での行列演算を削除する
|
|
363
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
364
|
+
const clipMinX = -1 - VIEWPORT_MARGIN;
|
|
365
|
+
const clipMinY = -1 - VIEWPORT_MARGIN;
|
|
366
|
+
const clipMaxX = 1 + VIEWPORT_MARGIN;
|
|
367
|
+
const clipMaxY = 1 + VIEWPORT_MARGIN;
|
|
368
|
+
const scaleX = this.zoom / aspectRatio;
|
|
369
|
+
const scaleY = this.zoom;
|
|
370
|
+
const worldMinX = (clipMinX - this.panX) / scaleX;
|
|
371
|
+
const worldMaxX = (clipMaxX - this.panX) / scaleX;
|
|
372
|
+
const worldMinY = (clipMinY - this.panY) / scaleY;
|
|
373
|
+
const worldMaxY = (clipMaxY - this.panY) / scaleY;
|
|
374
|
+
const computeUniformData = new ArrayBuffer(64);
|
|
375
|
+
const computeFloatView = new Float32Array(computeUniformData);
|
|
376
|
+
const computeUint32View = new Uint32Array(computeUniformData);
|
|
377
|
+
computeFloatView[0] = worldMinX;
|
|
378
|
+
computeFloatView[1] = worldMinY;
|
|
379
|
+
computeFloatView[2] = worldMaxX;
|
|
380
|
+
computeFloatView[3] = worldMaxY;
|
|
381
|
+
computeUint32View[4] = this.calculateLodThreshold();
|
|
382
|
+
computeUint32View[5] = this.totalPointCount;
|
|
383
|
+
let activeFilterMask = 0;
|
|
384
|
+
const filterRangeMin = [-Infinity, -Infinity, -Infinity, -Infinity];
|
|
385
|
+
const filterRangeMax = [Infinity, Infinity, Infinity, Infinity];
|
|
386
|
+
for (const condition of this.gpuFilterConditions) {
|
|
387
|
+
if (condition.columnIndex >= 0 && condition.columnIndex < 4) {
|
|
388
|
+
activeFilterMask |= 1 << condition.columnIndex;
|
|
389
|
+
filterRangeMin[condition.columnIndex] = condition.min;
|
|
390
|
+
filterRangeMax[condition.columnIndex] = condition.max;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
computeUint32View[6] = activeFilterMask;
|
|
394
|
+
computeFloatView[8] = filterRangeMin[0];
|
|
395
|
+
computeFloatView[9] = filterRangeMin[1];
|
|
396
|
+
computeFloatView[10] = filterRangeMin[2];
|
|
397
|
+
computeFloatView[11] = filterRangeMin[3];
|
|
398
|
+
computeFloatView[12] = filterRangeMax[0];
|
|
399
|
+
computeFloatView[13] = filterRangeMax[1];
|
|
400
|
+
computeFloatView[14] = filterRangeMax[2];
|
|
401
|
+
computeFloatView[15] = filterRangeMax[3];
|
|
402
|
+
this.context.device.queue.writeBuffer(this.computeUniformBuffer, 0, computeUniformData);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* 散布図をレンダリングする
|
|
406
|
+
*/
|
|
407
|
+
render() {
|
|
408
|
+
if (!this.context.device ||
|
|
409
|
+
!this.context.context ||
|
|
410
|
+
!this.filterPipeline ||
|
|
411
|
+
!this.updateIndirectPipeline ||
|
|
412
|
+
!this.renderPipeline ||
|
|
413
|
+
!this.quadVertexBuffer ||
|
|
414
|
+
!this.filterBindGroup ||
|
|
415
|
+
!this.updateIndirectBindGroup ||
|
|
416
|
+
!this.renderBindGroup ||
|
|
417
|
+
!this.atomicCounterBuffer ||
|
|
418
|
+
!this.indirectBuffer) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const commandEncoder = this.context.device.createCommandEncoder();
|
|
422
|
+
if (!this.filterResultValid) {
|
|
423
|
+
this.context.device.queue.writeBuffer(this.atomicCounterBuffer, 0, new Uint32Array([0]));
|
|
424
|
+
{
|
|
425
|
+
const computePass = commandEncoder.beginComputePass();
|
|
426
|
+
computePass.setPipeline(this.filterPipeline);
|
|
427
|
+
computePass.setBindGroup(0, this.filterBindGroup);
|
|
428
|
+
const workgroupCount = Math.ceil(this.totalPointCount / 256);
|
|
429
|
+
computePass.dispatchWorkgroups(workgroupCount);
|
|
430
|
+
computePass.end();
|
|
431
|
+
}
|
|
432
|
+
{
|
|
433
|
+
const computePass = commandEncoder.beginComputePass();
|
|
434
|
+
computePass.setPipeline(this.updateIndirectPipeline);
|
|
435
|
+
computePass.setBindGroup(0, this.updateIndirectBindGroup);
|
|
436
|
+
computePass.dispatchWorkgroups(1);
|
|
437
|
+
computePass.end();
|
|
438
|
+
}
|
|
439
|
+
this.filterResultValid = true;
|
|
440
|
+
}
|
|
441
|
+
{
|
|
442
|
+
const textureView = this.context.context.getCurrentTexture().createView();
|
|
443
|
+
const renderPass = commandEncoder.beginRenderPass({
|
|
444
|
+
colorAttachments: [
|
|
445
|
+
{
|
|
446
|
+
view: textureView,
|
|
447
|
+
clearValue: {
|
|
448
|
+
r: this.backgroundColor.r,
|
|
449
|
+
g: this.backgroundColor.g,
|
|
450
|
+
b: this.backgroundColor.b,
|
|
451
|
+
a: this.backgroundColor.a,
|
|
452
|
+
},
|
|
453
|
+
loadOp: 'clear',
|
|
454
|
+
storeOp: 'store',
|
|
455
|
+
},
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
renderPass.setPipeline(this.renderPipeline);
|
|
459
|
+
renderPass.setVertexBuffer(0, this.quadVertexBuffer);
|
|
460
|
+
renderPass.setIndexBuffer(this.indexBuffer, 'uint16');
|
|
461
|
+
renderPass.setBindGroup(0, this.renderBindGroup);
|
|
462
|
+
renderPass.drawIndexedIndirect(this.indirectBuffer, 0);
|
|
463
|
+
renderPass.end();
|
|
464
|
+
}
|
|
465
|
+
this.context.device.queue.submit([commandEncoder.finish()]);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* キャンバスをリサイズし、ビューポートを更新する
|
|
469
|
+
*/
|
|
470
|
+
resize(width, height) {
|
|
471
|
+
this.canvas.width = width;
|
|
472
|
+
this.canvas.height = height;
|
|
473
|
+
this.updateUniforms();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* ズームレベルを設定する
|
|
477
|
+
*/
|
|
478
|
+
setZoom(zoom) {
|
|
479
|
+
this.zoom = Math.max(0.01, Math.min(100, zoom));
|
|
480
|
+
this.updateUniforms();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 現在のズームレベルを取得する
|
|
484
|
+
*/
|
|
485
|
+
getZoom() {
|
|
486
|
+
return this.zoom;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* パンオフセットを設定する
|
|
490
|
+
*/
|
|
491
|
+
setPan(x, y) {
|
|
492
|
+
this.panX = x;
|
|
493
|
+
this.panY = y;
|
|
494
|
+
this.updateUniforms();
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* 現在のパンオフセットを取得する
|
|
498
|
+
*/
|
|
499
|
+
getPan() {
|
|
500
|
+
return { x: this.panX, y: this.panY };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* 指定した画面座標を中心にズームする
|
|
504
|
+
*/
|
|
505
|
+
zoomToPoint(newZoom, screenX, screenY) {
|
|
506
|
+
const clampedZoom = Math.max(0.01, Math.min(100, newZoom));
|
|
507
|
+
const aspectRatio = this.canvas.width / this.canvas.height;
|
|
508
|
+
const ndcX = (screenX / this.canvas.width) * 2 - 1;
|
|
509
|
+
const ndcY = -((screenY / this.canvas.height) * 2 - 1);
|
|
510
|
+
const worldXBefore = ((ndcX - this.panX) * aspectRatio) / this.zoom;
|
|
511
|
+
const worldYBefore = (ndcY - this.panY) / this.zoom;
|
|
512
|
+
this.zoom = clampedZoom;
|
|
513
|
+
this.panX = ndcX - (worldXBefore * this.zoom) / aspectRatio;
|
|
514
|
+
this.panY = ndcY - worldYBefore * this.zoom;
|
|
515
|
+
this.updateUniforms();
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* GPUレイヤーの設定オプションを更新する
|
|
519
|
+
*/
|
|
520
|
+
updateOptions(options) {
|
|
521
|
+
if (options.backgroundColor !== undefined) {
|
|
522
|
+
this.backgroundColor = options.backgroundColor;
|
|
523
|
+
}
|
|
524
|
+
if (options.visiblePointLimit !== undefined) {
|
|
525
|
+
this.visiblePointLimit = options.visiblePointLimit;
|
|
526
|
+
// LOD閾値が変わるのでフィルタ結果を無効化
|
|
527
|
+
this.filterResultValid = false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* GPUフィルターカラムデータをアップロードする
|
|
532
|
+
* @param data フィルターカラムデータ(各ポイントに4カラム分のf32、totalPoints * 4 floats)
|
|
533
|
+
* @param columnCount 有効なカラム数(0-4)
|
|
534
|
+
*/
|
|
535
|
+
uploadFilterColumns(data, columnCount) {
|
|
536
|
+
if (!this.context.device)
|
|
537
|
+
return;
|
|
538
|
+
this.gpuFilterColumnCount = Math.min(4, columnCount);
|
|
539
|
+
const requiredSize = this.totalPointCount * 16;
|
|
540
|
+
if (!this.filterColumnsBuffer || data.byteLength > requiredSize) {
|
|
541
|
+
this.filterColumnsBuffer?.destroy();
|
|
542
|
+
this.filterColumnsBuffer = this.context.device.createBuffer({
|
|
543
|
+
size: Math.max(16, data.byteLength),
|
|
544
|
+
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
|
|
545
|
+
});
|
|
546
|
+
this.createBindGroups();
|
|
547
|
+
}
|
|
548
|
+
this.context.device.queue.writeBuffer(this.filterColumnsBuffer, 0, data.buffer, data.byteOffset, data.byteLength);
|
|
549
|
+
this.filterResultValid = false;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* GPUフィルター条件を設定する
|
|
553
|
+
* @param conditions フィルター条件の配列
|
|
554
|
+
*/
|
|
555
|
+
setGpuFilterConditions(conditions) {
|
|
556
|
+
this.gpuFilterConditions = conditions;
|
|
557
|
+
this.filterResultValid = false;
|
|
558
|
+
this.updateUniforms();
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* GPUフィルター条件をクリアする
|
|
562
|
+
*/
|
|
563
|
+
clearGpuFilterConditions() {
|
|
564
|
+
this.gpuFilterConditions = [];
|
|
565
|
+
this.filterResultValid = false;
|
|
566
|
+
this.updateUniforms();
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* グローバル透明度を設定する
|
|
570
|
+
* @param alpha 透明度 (0.0-1.0)
|
|
571
|
+
*/
|
|
572
|
+
setPointAlpha(alpha) {
|
|
573
|
+
this.pointAlpha = Math.max(0, Math.min(1, alpha));
|
|
574
|
+
this.updateUniforms();
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* 現在のグローバル透明度を取得する
|
|
578
|
+
*/
|
|
579
|
+
getPointAlpha() {
|
|
580
|
+
return this.pointAlpha;
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* グローバルサイズスケールを設定する
|
|
584
|
+
* @param scale サイズスケール (0.01以上)
|
|
585
|
+
*/
|
|
586
|
+
setPointSizeScale(scale) {
|
|
587
|
+
this.pointSizeScale = Math.max(0.01, scale);
|
|
588
|
+
this.updateUniforms();
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* 現在のグローバルサイズスケールを取得する
|
|
592
|
+
*/
|
|
593
|
+
getPointSizeScale() {
|
|
594
|
+
return this.pointSizeScale;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* リソースを破棄する
|
|
598
|
+
*/
|
|
599
|
+
destroy() {
|
|
600
|
+
this.quadVertexBuffer?.destroy();
|
|
601
|
+
this.allPointsBuffer?.destroy();
|
|
602
|
+
this.visibleIndicesBuffer?.destroy();
|
|
603
|
+
this.atomicCounterBuffer?.destroy();
|
|
604
|
+
this.indirectBuffer?.destroy();
|
|
605
|
+
this.indexBuffer?.destroy();
|
|
606
|
+
this.renderUniformBuffer?.destroy();
|
|
607
|
+
this.computeUniformBuffer?.destroy();
|
|
608
|
+
this.filterColumnsBuffer?.destroy();
|
|
609
|
+
this.context.destroy();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 散布図レンダリング用WGSLシェーダーコード
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* ビューポート境界とLODに基づいて可視ポイントをフィルタリングするコンピュートシェーダー
|
|
6
|
+
* ワークグループローカルのアトミック操作を使用してグローバルアトミックの競合を軽減
|
|
7
|
+
*/
|
|
8
|
+
export declare const filterComputeShader = "\nstruct Point {\n x: f32,\n y: f32,\n color: u32,\n size: f32,\n}\n\nstruct FilterUniforms {\n worldBoundsMin: vec2<f32>,\n worldBoundsMax: vec2<f32>,\n lodThreshold: u32,\n totalPoints: u32,\n activeFilterMask: u32,\n _padding: u32,\n filterRangeMin: vec4<f32>,\n filterRangeMax: vec4<f32>,\n}\n\n@group(0) @binding(0) var<storage, read> allPoints: array<Point>;\n@group(0) @binding(1) var<storage, read_write> visibleIndices: array<u32>;\n@group(0) @binding(2) var<storage, read_write> counter: atomic<u32>;\n@group(0) @binding(3) var<uniform> uniforms: FilterUniforms;\n@group(0) @binding(4) var<storage, read> filterColumns: array<vec4<f32>>;\n\nvar<workgroup> localCount: atomic<u32>;\nvar<workgroup> localIndices: array<u32, 256>;\nvar<workgroup> globalOffset: u32;\n\nfn pcgHash(input: u32) -> u32 {\n let state = input * 747796405u + 2891336453u;\n let word = ((state >> ((state >> 28u) + 4u)) ^ state) * 277803737u;\n return (word >> 22u) ^ word;\n}\n\n@compute @workgroup_size(256)\nfn main(\n @builtin(global_invocation_id) globalId: vec3<u32>,\n @builtin(local_invocation_id) localId: vec3<u32>\n) {\n let idx = globalId.x;\n let lid = localId.x;\n\n if (lid == 0u) {\n atomicStore(&localCount, 0u);\n }\n workgroupBarrier();\n\n var myLocalSlot: u32 = 0xFFFFFFFFu;\n if (idx < uniforms.totalPoints) {\n let hash = pcgHash(idx);\n var isVisible = hash <= uniforms.lodThreshold;\n\n if (isVisible) {\n let point = allPoints[idx];\n\n isVisible = point.x >= uniforms.worldBoundsMin.x && point.x <= uniforms.worldBoundsMax.x &&\n point.y >= uniforms.worldBoundsMin.y && point.y <= uniforms.worldBoundsMax.y;\n }\n\n if (isVisible && uniforms.activeFilterMask != 0u) {\n let filterData = filterColumns[idx];\n\n if ((uniforms.activeFilterMask & 1u) != 0u) {\n isVisible = isVisible &&\n filterData.x >= uniforms.filterRangeMin.x &&\n filterData.x <= uniforms.filterRangeMax.x;\n }\n if ((uniforms.activeFilterMask & 2u) != 0u) {\n isVisible = isVisible &&\n filterData.y >= uniforms.filterRangeMin.y &&\n filterData.y <= uniforms.filterRangeMax.y;\n }\n if ((uniforms.activeFilterMask & 4u) != 0u) {\n isVisible = isVisible &&\n filterData.z >= uniforms.filterRangeMin.z &&\n filterData.z <= uniforms.filterRangeMax.z;\n }\n if ((uniforms.activeFilterMask & 8u) != 0u) {\n isVisible = isVisible &&\n filterData.w >= uniforms.filterRangeMin.w &&\n filterData.w <= uniforms.filterRangeMax.w;\n }\n }\n\n if (isVisible) {\n myLocalSlot = atomicAdd(&localCount, 1u);\n localIndices[myLocalSlot] = idx;\n }\n }\n workgroupBarrier();\n\n let count = atomicLoad(&localCount);\n if (lid == 0u && count > 0u) {\n globalOffset = atomicAdd(&counter, count);\n }\n workgroupBarrier();\n\n if (lid < count) {\n visibleIndices[globalOffset + lid] = localIndices[lid];\n }\n}\n";
|
|
9
|
+
/**
|
|
10
|
+
* カウンターから間接描画バッファを更新するコンピュートシェーダー
|
|
11
|
+
*/
|
|
12
|
+
export declare const updateIndirectShader = "\nstruct DrawIndexedIndirect {\n indexCount: u32,\n instanceCount: u32,\n firstIndex: u32,\n baseVertex: u32,\n firstInstance: u32,\n}\n\n@group(0) @binding(0) var<storage, read> counter: u32;\n@group(0) @binding(1) var<storage, read_write> indirect: DrawIndexedIndirect;\n\n@compute @workgroup_size(1)\nfn main() {\n indirect.indexCount = 6u;\n indirect.instanceCount = counter;\n indirect.firstIndex = 0u;\n indirect.baseVertex = 0u;\n indirect.firstInstance = 0u;\n}\n";
|
|
13
|
+
export declare const scatterVertexShader = "\nstruct Point {\n x: f32,\n y: f32,\n color: u32,\n size: f32,\n}\n\nstruct Uniforms {\n viewMatrix: mat4x4<f32>,\n zoomScale: f32,\n viewportWidth: f32,\n viewportHeight: f32,\n pointAlpha: f32,\n pointSizeScale: f32,\n _padding1: f32,\n _padding2: f32,\n _padding3: f32,\n}\n\nstruct VertexOutput {\n @builtin(position) position: vec4<f32>,\n @location(0) color: vec4<f32>,\n @location(1) pointCoord: vec2<f32>,\n}\n\n@group(0) @binding(0) var<uniform> uniforms: Uniforms;\n@group(0) @binding(1) var<storage, read> allPoints: array<Point>;\n@group(0) @binding(2) var<storage, read> visibleIndices: array<u32>;\n\nfn unpackColor(argb: u32) -> vec4<f32> {\n let a = f32((argb >> 24u) & 0xFFu) / 255.0;\n let r = f32((argb >> 16u) & 0xFFu) / 255.0;\n let g = f32((argb >> 8u) & 0xFFu) / 255.0;\n let b = f32(argb & 0xFFu) / 255.0;\n return vec4<f32>(r, g, b, a);\n}\n\n@vertex\nfn vertexMain(\n @location(0) quadPosition: vec2<f32>,\n @builtin(instance_index) instanceIdx: u32\n) -> VertexOutput {\n var output: VertexOutput;\n\n let pointIdx = visibleIndices[instanceIdx];\n let point = allPoints[pointIdx];\n\n let clipPos = uniforms.viewMatrix * vec4<f32>(point.x, point.y, 0.0, 1.0);\n\n let pixelToClipX = 2.0 / uniforms.viewportWidth;\n let pixelToClipY = 2.0 / uniforms.viewportHeight;\n let zoomScale = uniforms.zoomScale;\n\n let scaledSize = point.size * uniforms.pointSizeScale;\n let offsetClip = vec2<f32>(\n quadPosition.x * scaledSize * pixelToClipX * zoomScale,\n quadPosition.y * scaledSize * pixelToClipY * zoomScale\n );\n\n output.position = clipPos + vec4<f32>(offsetClip, 0.0, 0.0);\n output.color = unpackColor(point.color);\n\n output.pointCoord = (quadPosition + 1.0) * 0.5;\n\n return output;\n}\n\n@fragment\nfn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {\n let d = input.pointCoord - vec2<f32>(0.5);\n let distSq = dot(d, d);\n\n if (distSq > 0.25) {\n discard;\n }\n\n let alpha = smoothstep(0.25, 0.23, distSq);\n\n return vec4<f32>(input.color.rgb, input.color.a * alpha * uniforms.pointAlpha);\n}\n";
|