v0.4.0

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

TypeScript
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

RuleBehavior
2D series in 3D chartSkipped with console.warn (resolved series[] is compacted)
3D series in 2D chartSkipped with console.warn
Depth bufferEnabled only on the 3D path (depth24plus)
2D MSAA / zoom / dataZoomUnchanged; 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)

OptionDefaultNotes
showBoxtrueAABB edge wireframe
showGridtrueFloor + two walls of major grid lines
labelMode'auto'Where to draw tick numbers + axis titles. See below.
x / y / zname, optional fixed min/max, tickCount (~5), visible

labelMode:

ValueBehavior
'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 chrome
    box 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 viewProj only).
  • DOM labels
    only when mode resolves to DOM; not created on the GPU path (no data-chartgpu-axes3d-labels root; root is detached if mode switches into GPU).
  • Modes are exclusive (never both DOM + GPU text).
  • Atlas readiness
    is 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 cap
    at most 4096 glyph quads per frame; excess tick/title glyphs are dropped under dense tickCount / long titles.
  • GPU overlap culling
    uses 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 showBox and showGrid are both false (as long as the axis is visible).
  • World convention
    Y-up; map height to y, ground plane to x/z. Display names are free-form.

Point cloud (pointCloud3d)

  • Space
    world XYZ, Y-up, right-handed.
  • Draw
    camera-facing billboard quads (constant CSS-pixel size), not WebGPU point-list.
  • Data formats
    • Split arrays
      { x, y, z, value?, size? } (min length of x/y/z; mismatch warns)
    • Interleaved
      Float32Array or Float64Array [x0,y0,z0, …] (length multiple of 3; remainder truncated). Float64 is converted element-wise (not bit-cast). DataView is unsupported.
    • Objects/tuples
      {x,y,z}[] or [x,y,z][] (nulls / non-finite skipped for all formats)
  • Per-point data.size
    ignored (warn once if present). Use pointStyle.size (CSS px billboard diameter).
  • Color
    solid pointStyle.color or colorBy + colormap (reuses heatmap named colormaps).
  • Depth + transparency
    depth 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.
  • appendData
    supported for pointCloud3d only.
    Pack durability (seed keys): the GPU packed buffer (including appends) is kept across setOption only when both of these identities are unchanged:
    1. Data seed — series data object / array / typed-array reference
    2. Value channel — effective value identity used at pack time: colorBy.values if set, else split-array data.value if present, else “no value”
      Theme-only / style-only updates that keep the same data and value-channel refs preserve appends.
      Re-pack (drops prior appends) when either key changes.
  • maxPoints
    FIFO: appendData(i, batch, { maxPoints: N }) retains ≤ N points:
    • Capacity is opt-in per call (not sticky series construction state). Omitting maxPoints on 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.
    • dataAppend reports accepted new sample count + AABB-derived xExtent.
  • Pick / tooltip / hitTest
    screen-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).
    hitTest match: kind: 'pointCloud3d', value: [x, y, z], optional valueChannel (scalar).

Surface (surface3d)

Uniform grid in the XZ plane, height along +Y:

Code
x_i = xStart + i * xStep
z_j = zStart + j * zStep
height = y[j * columns + i]   // full field is row-major
TypeScript
{
  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,
  },
}
  • Mesh
    two triangles per cell; normals from height gradients (GPU VS); simple ambient + directional lighting.
  • GPU path
    heights live in a storage buffer (4 B/cell). The vertex shader expands grid index → position + central-difference normal. Steady-state replaceY does not rebuild a 32 B interleaved CPU vertex buffer. Fields larger than min(maxStorageBufferBindingSize, maxBufferSize) soft-fail (warn + skip draw).
  • Non-finite heights
    stream replaceY preserves 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.
  • setOption
    updates: pass a new data object and/or new data.y array reference so geometry invalidates. In-place mutation of y under stable refs is not detected (same as heatmap z). Colormap domain-only updates uniforms. Stream domain override is cleared when the user data identity changes, when series becomes explicit (yMin+yMax), or when series yMin/yMax values change — not on pure style setOption while domain is already fixed (so update-level replaceY domain is preserved).
  • appendData
    is not supported for surfaces (warn). Use updateSurface3D.
  • Wireframe
    exclusive mode — wireframe: true draws line-list only.
  • Contours
    marching-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 and contours.show. Flat fields produce no segments. width is 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):

TypeScript
// 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/replaceY keeps columns/rows constant; height storage re-uploads each update (not a full 32 B/vertex interleave). Each successful replaceY still constructs a new data shell even when y is zero-copy identity — that shell change is what invalidates the renderer upload gate.
  • replaceY
    zero-copy: when y is a Float32Array with length ≥ columns*rows, the stream retains that buffer (or a subarray(0, n) view) — no allocate-and-copy. Mutate in place, then call replaceY with the same reference for high-rate animation. Non-Float32Array / short payloads still coerce into a stream-owned scratch (reused across frames).
  • replaceY
    yMin/yMax: optional colormap domain on the update. When omitted:
    • If the series config already has both finite yMin and yMax (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+scrollX expands domain from the new strip only when the series domain is not explicit and the update is not requesting a full recompute.
  • Stream AABB framing
    on replaceY with stable dims, scene AABB reuses prior XZ extents and sets Y from the colormap domain (update domain, stream domain override, or series explicit yMin/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)

ActionBehavior
updateSurface3DOwns a stream override (may change xStart/y buffer). Colormap domain tracked separately.
setOption with same series[i].data object identityStream 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 objectStream cleared; user data wins (even if stream had scrolled xStart).
Cloud FIFOIndependent: 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

InputAction
Primary dragOrbit about target (pitch clamped)
Shift+drag / middle / right dragPan in camera plane
WheelDolly (perspective) or ortho size
Double-click / resetCamera()Fit AABB
TouchSingle-pointer orbit/pan only; pinch zoom is not supported

2D dataZoom / brush-zoom are not used in 3D.

Instance helpers (3D)

TypeScript
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 (getCameranull).

Events on 3D charts

on('click' | 'mouseover' | 'mouseout') fire from the 3D canvas with pick payload:

EventPayload
clickChart3DPickResult | null — pick under cursor on pointerup when the gesture was not a drag
mouseoverChart3DPickResult — when the hovered sample/cell identity changes to a hit
mouseoutprevious Chart3DPickResult — when leaving a hit (or leaving the canvas)

Point cloud pick:

TypeScript
{
  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:

TypeScript
{
  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:

  1. Prefer the cloud if its screen distance ≤ 75% of the pick threshold (default threshold 12 CSS px → 9 px).
  2. Else prefer the cloud if screen distance < 6 CSS px.
  3. 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/y geometry 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.