API · 3D
3D charts
On this page
ChartGPU supports a separate 3D modality for spatial point clouds and uniform height-field surfaces. This is not 2D scatter mode: 'density' and not type: 'heatmap'.
Creating a 3D chart
const chart = await ChartGPU.create(container, {
coordinateSystem: 'cartesian3d', // required
camera: {
type: 'perspective', // or 'orthographic'
// eye / target / up optional → auto-fit to data AABB
},
interaction3d: {
orbit: true,
pan: true,
zoom: true,
},
axes3d: {
showBox: true,
showGrid: true, // wall/floor major grids (default true)
labelMode: 'auto', // prefer GPU glyph atlas; 'dom' for CSS font fidelity
x: { name: 'X (m)', tickCount: 5 },
y: { name: 'Height (m)' },
z: { name: 'Y (m)' }, // display name; world Z remains right-handed Y-up
},
series: [
{
type: 'pointCloud3d',
name: 'Samples',
data: { x, y, z, value }, // or interleaved Float32Array/Float64Array stride 3, or {x,y,z}[]
pointStyle: { size: 3, color: '#38bdf8', opacity: 0.9 },
colorBy: {
colormap: 'viridis',
min: 0,
max: 1,
},
},
],
tooltip: { show: true },
});
Default modality remains cartesian2d when coordinateSystem is omitted.
Hero demo: examples/3d-showcase/ — labeled axes, contours, cloud FIFO, surface strip scroll, pick.
Isolation rules
| Rule | Behavior |
|---|---|
| 2D series in 3D chart | Skipped with console.warn (resolved series[] is compacted) |
| 3D series in 2D chart | Skipped with console.warn |
| Depth buffer | Enabled only on the 3D path (depth24plus) |
| 2D MSAA / zoom / dataZoom | Unchanged; not wired to 3D camera |
You cannot mix line/area/scatter with pointCloud3d/surface3d in one instance. Use two charts if you need both.
Resolved series indices
OptionResolver filters invalid modality series and compacts the array.appendData(seriesIndex, …), updateSurface3D(seriesIndex, …), and hit-test seriesIndex use resolved indices (after filtering), not the original user array positions.
Example: user series [line, pointCloud3d] in a 3D chart → resolved [pointCloud3d] at index 0. Call appendData(0, …).
Axes (axes3d)
| Option | Default | Notes |
|---|---|---|
showBox | true | AABB edge wireframe |
showGrid | true | Floor + two walls of major grid lines |
labelMode | 'auto' | Where to draw tick numbers + axis titles. See below. |
x / y / z | — | name, optional fixed min/max, tickCount (~5), visible |
labelMode:
| Value | Behavior |
|---|---|
'auto' (default) | Prefer GPU glyph atlas when atlas bake succeeds; otherwise DOM |
'gpu' | WebGPU billboard quads from a canvas-baked Latin/units atlas (theme textColor); falls back to DOM + console.warn once if atlas init fails |
'dom' | DOM-projected spans (P6 path; best system-font fidelity) |
- GPU chromebox edges, wall/floor grids, short tick marks (line-list), and — when mode resolves to GPU — tick numbers + titles as depth-tested billboards (depth write off, slight bias; camera orbit updates
viewProjonly). - DOM labelsonly when mode resolves to DOM; not created on the GPU path (no
data-chartgpu-axes3d-labelsroot; root is detached if mode switches into GPU). - Modes are exclusive (never both DOM + GPU text).
- Atlas readinessis fixed at 3D coordinator create (bake + upload once). Failure falls back to DOM for the chart instance lifetime — not recovered mid-session without recreate.
- GPU glyph capat most 4096 glyph quads per frame; excess tick/title glyphs are dropped under dense
tickCount/ long titles. - GPU overlap cullinguses the same pixel-distance heuristic as DOM but is frozen at instance rebuild (plan/AABB/viewport change). Pure camera orbit does not re-cull; DOM re-culls every frame.
- Atlas charset is ASCII printable + common unit punctuation; missing glyphs warn once and substitute
?. - Tick marks still draw when
showBoxandshowGridare both false (as long as the axis isvisible). - World conventionY-up; map height to
y, ground plane tox/z. Display names are free-form.
Point cloud (pointCloud3d)
Spaceworld XYZ, Y-up, right-handed.Drawcamera-facing billboard quads (constant CSS-pixel size), not WebGPUpoint-list.- Data formats
- Split arrays
{ x, y, z, value?, size? }(min length of x/y/z; mismatch warns) - Interleaved
Float32ArrayorFloat64Array[x0,y0,z0, …](length multiple of 3; remainder truncated). Float64 is converted element-wise (not bit-cast).DataViewis unsupported. - Objects/tuples
{x,y,z}[]or[x,y,z][](nulls / non-finite skipped for all formats)
- Per-point
data.sizeignored (warn once if present). UsepointStyle.size(CSS px billboard diameter). ColorsolidpointStyle.colororcolorBy+ colormap (reuses heatmap named colormaps).- Depth + transparencydepth write is always on. Semi-transparent points can occlude farther samples (order-dependent). Prefer opacity near 1 for dense clouds; true OIT is out of scope.
appendDatasupported for pointCloud3d only.
Pack durability (seed keys): the GPU packed buffer (including appends) is kept acrosssetOptiononly when both of these identities are unchanged:- Data seed — series
dataobject / array / typed-array reference - Value channel — effective value identity used at pack time:
colorBy.valuesif set, else split-arraydata.valueif present, else “no value”
Theme-only / style-only updates that keep the samedataand value-channel refs preserve appends.
Re-pack (drops prior appends) when either key changes.
- Data seed — series
maxPointsFIFO:appendData(i, batch, { maxPoints: N })retains ≤ N points:- Capacity is opt-in per call (not sticky series construction state). Omitting
maxPointson a later call grows unbounded again (same contract as 2D). - If the batch alone ≥ N → keep the batch tail (strict replace).
- Else append then drop oldest until length ≤ N.
- Storage is a pack rewrite of the retained window (not a billboard ring shader). Equal-N rewrites allocate a new packed buffer identity so the GPU billboard path re-uploads (no stale geometry).
- AABB + pick use the post-window buffer.
dataAppendreports accepted new sample count + AABB-derivedxExtent.
- Capacity is opt-in per call (not sticky series construction state). Omitting
- Pick / tooltip / hitTestscreen-space spatial grid rebuilt when camera or packed content/identity changes (exact nearest sample within threshold; not stride-only at large N). Cold/stale path falls back to CPU scan (full ≤50k / fixed stride beyond).
hitTestmatch:kind: 'pointCloud3d',value: [x, y, z], optionalvalueChannel(scalar).
Surface (surface3d)
Uniform grid in the XZ plane, height along +Y:
x_i = xStart + i * xStep
z_j = zStart + j * zStep
height = y[j * columns + i] // full field is row-major
{
type: 'surface3d',
data: {
xStart: 0, xStep: 1,
zStart: 0, zStep: 1,
columns: 256, rows: 256,
y: heightFieldFloat32, // length columns*rows
},
colormap: 'viridis',
yMin: 0, // colormap domain; auto from data if omitted
yMax: 100,
wireframe: false, // exclusive: true = line-list only (no solid underlay)
lighting: 0.65, // 0 = unlit colormap
opacity: 1,
contours: {
show: true,
levels: 12, // or number[]
color: '#e2e8f0',
width: 1.5, // visual weight / alpha boost (not CSS px thickness)
opacity: 0.85,
},
}
- Meshtwo triangles per cell; normals from height gradients (GPU VS); simple ambient + directional lighting.
- GPU pathheights live in a storage buffer (4 B/cell). The vertex shader expands grid index → position + central-difference normal. Steady-state
replaceYdoes not rebuild a 32 B interleaved CPU vertex buffer. Fields larger thanmin(maxStorageBufferBindingSize, maxBufferSize)soft-fail (warn + skip draw). - Non-finite heightsstream
replaceYpreserves non-finite values on the zero-copy Float32 path (including±Infinity); the coerce path maps non-finite inputs to NaN. On the GPU, non-finite heights (NaN and |h| ≳ 1e30, including Inf) render as Y = 0 with a neutral normal — the vertex is still drawn, not discarded. Pick treats non-finite cells as non-pickable holes. setOptionupdates: pass a newdataobject and/or newdata.yarray reference so geometry invalidates. In-place mutation ofyunder stable refs is not detected (same as heatmapz). Colormap domain-only updates uniforms. Stream domain override is cleared when the user data identity changes, when series becomes explicit (yMin+yMax), or when seriesyMin/yMaxvalues change — not on pure style setOption while domain is already fixed (so update-levelreplaceYdomain is preserved).appendDatais not supported for surfaces (warn). UseupdateSurface3D.Wireframeexclusive mode —wireframe: truedraws line-list only.Contoursmarching-squares isolines in world space (Y = level + small bias vs z-fight), depth-tested; drawn after solid surface, before clouds. Regenerated when height identity / levels / domain change andcontours.show. Flat fields produce no segments.widthis relative visual weight (alpha), not multi-pixel stroke thickness.- Draw order when mixed with point clouds: surfaces → contours → clouds → axes.
updateSurface3D(seriesIndex, update)
3D instance method (no-op on 2D):
// Full field replace (row-major y[j * columns + i]), grid meta unchanged.
// Prefer a retained Float32Array of length columns*rows — zero-copy stream path.
const heights = new Float32Array(columns * rows);
// ...fill heights in place each frame...
chart.updateSurface3D(0, { mode: 'replaceY', y: heights });
// Optional update-level colormap domain
chart.updateSurface3D(0, { mode: 'replaceY', y: heights, yMin: 0, yMax: 1 });
// Spectrogram-style column append (payload is column-major strips y[c * rows + r])
chart.updateSurface3D(0, {
mode: 'appendColumns',
columns: 1,
y: newColumnHeights, // length === rows * columns
scrollX: true, // default true: drop oldest columns, xStart += columns * xStep
});
// Row append on +Z (payload row-major block)
chart.updateSurface3D(0, {
mode: 'appendRows',
rows: 1,
y: newRowBlock, // length === columns * rows
scrollZ: true,
});
- Index buffers stay dimension-stable when scroll/
replaceYkeepscolumns/rowsconstant; height storage re-uploads each update (not a full 32 B/vertex interleave). Each successfulreplaceYstill constructs a new data shell even whenyis zero-copy identity — that shell change is what invalidates the renderer upload gate. replaceYzero-copy: whenyis aFloat32Arraywithlength ≥ columns*rows, the stream retains that buffer (or asubarray(0, n)view) — no allocate-and-copy. Mutate in place, then callreplaceYwith the same reference for high-rate animation. Non-Float32Array/ short payloads still coerce into a stream-owned scratch (reused across frames).replaceYyMin/yMax: optional colormap domain on the update. When omitted:- If the series config already has both finite
yMinandyMax(user-supplied /yDomainExplicit), domain stays fixed — no full-field walk, and single-column strip expand is also skipped. - Otherwise domain is auto-recomputed from finite heights after the update.
- Single-column
appendColumns+scrollXexpands domain from the new strip only when the series domain is not explicit and the update is not requesting a full recompute.
- If the series config already has both finite
- Stream AABB framingon
replaceYwith stable dims, scene AABB reuses prior XZ extents and sets Y from the colormap domain (update domain, stream domain override, or series explicityMin/yMax) — not a full height-field min/max walk. Camera fit therefore tracks the colormap domain when domain is fixed, not necessarily true instantaneous height extrema. - Contours use the same domain as the solid mesh after stream updates; contour invalidate is skipped when contours are not shown.
Stream vs setOption (surface)
| Action | Behavior |
|---|---|
updateSurface3D | Owns a stream override (may change xStart/y buffer). Colormap domain tracked separately. |
setOption with same series[i].data object identity | Stream kept (style-only: contours, colormap, lighting, …). Update-level replaceY domain also kept unless series yMin/yMax change or domain becomes newly explicit. |
setOption with a new data object | Stream cleared; user data wins (even if stream had scrolled xStart). |
| Cloud FIFO | Independent: reuses pack when cloud data + value-channel identities are unchanged. Contour toggles must reuse the same cloud data ref or appends are dropped. |
Keep stable object refs in streaming UIs when only styling changes.
Camera interaction
| Input | Action |
|---|---|
| Primary drag | Orbit about target (pitch clamped) |
| Shift+drag / middle / right drag | Pan in camera plane |
| Wheel | Dolly (perspective) or ortho size |
Double-click / resetCamera() | Fit AABB |
| Touch | Single-pointer orbit/pan only; pinch zoom is not supported |
2D dataZoom / brush-zoom are not used in 3D.
Instance helpers (3D)
chart.resetCamera();
chart.setCamera({ type: 'orthographic', orthoSize: 2 });
const cam = chart.getCamera();
chart.updateSurface3D(0, { mode: 'replaceY', y: heights });
On 2D charts these methods are optional/no-op (getCamera → null).
Events on 3D charts
on('click' | 'mouseover' | 'mouseout') fire from the 3D canvas with pick payload:
| Event | Payload |
|---|---|
click | Chart3DPickResult | null — pick under cursor on pointerup when the gesture was not a drag |
mouseover | Chart3DPickResult — when the hovered sample/cell identity changes to a hit |
mouseout | previous Chart3DPickResult — when leaving a hit (or leaving the canvas) |
Point cloud pick:
{
kind: 'pointCloud3d',
seriesIndex: number, // resolved index
dataIndex: number,
x: number, y: number, z: number,
value: number,
seriesName: string | null,
color: string,
screenDistancePx: number,
}
Surface pick:
{
kind: 'surface3d',
seriesIndex: number,
i: number, j: number, // cell column/row
dataIndex: number, // j * columns + i
x: number, y: number, z: number, // ray hit (not only cell center)
height: number, // same as y under Y-up
seriesName: string | null,
color: string,
}
Pointer-move pick is throttled (~30 Hz) with a trailing rAF so a stop after a skipped move still updates. dataAppend and deviceLost work as on 2D.
Surface pick details
- Ray from camera through CSS pixel; bilinear heightfield intersection on the XZ grid.
- Hit reports ray intersection (not only cell center) plus cell indices
(i, j). - Non-finite heights are non-pickable.
Mixed surface + cloud pick preference
When both a surface cell and a cloud sample are under the cursor:
- Prefer the cloud if its screen distance ≤ 75% of the pick threshold (default threshold 12 CSS px → 9 px).
- Else prefer the cloud if screen distance < 6 CSS px.
- Otherwise report the surface (larger hit area).
Depth is not compared (billboard centers vs heightfield hits are not directly comparable).
Performance notes
- Profile against production dist, not Vite dev.
- Point cloud / surface FPS targets are unmeasured in CI — use showcase size selectors (up to 1M cloud) on your hardware with
bun run build+ preview.getPerformanceMetrics()is null on the 3D path. - 3D path uses sampleCount 1 (no MSAA) to keep depth simple; 2D MSAA is unchanged.
- Scene AABB for axes reuses cached surface bounds (invalidated on data/
ygeometry identity change). Point-cloud pack cache also keys value-channel identity.
API reference for the linked @chartgpu/chartgpu package (v0.4.0). Source on GitHub.