Skip to content

Performance Guide ​

Makio MeshLine uses TSL core for efficient GPU rendering, supporting both WebGPU and WebGL2 backends.

The core approach minimizes overhead by generating only necessary vertex attributes and uniforms based on active features, like skipping UVs if no textures are used.

It also supports Instancing and optimized CPU batching.

MeshLineCompute is the experimental exception to the cross-backend contract: it requires WebGPU compute support and does not provide a WebGL2 simulation fallback.

Without instancingCPU · render loopGPU12 draw calls · 12 state changesWith instancingCPU · render loop1× draw · ×12GPU1 draw call · 1 state change

Include Optimizations ​

  • Selective Attributes: Only creates buffers for what's needed (for example, no previous/next for GPU-driven positions and no endpoint buffer for ordinary CPU billboard paths). Procedural positions and position hooks retain vec4 endpoint metadata so neighbouring path samples remain exact.
  • Stable billboard reconstruction: Regular CPU billboards use four physical vertices per logical point—an incoming and outgoing +/- pair—plus one internal joinRole float whose sign stores body ownership and magnitude stores side. Ordinary joins weld in the shader, while projected reversals and nearly end-on segments retain segment-local offsets instead of opening into bowties. The packed metadata also survives toNonIndexed() without a separate side buffer. Budget custom attribute storage against 4 * pointCount, not the old paired-strip count.
  • Instancing: Smaller footprint & one drawcall + custom behavior by instance.
  • Batching: Draw multiple lines in one call by passing an array to lines.
  • CPU->GPU Fast Updates: setPositions() modifies existing buffers in-place without recreation. Pass true as the second argument when movement changes bounds enough for frustum culling to matter. Facade position/range updates remain current through later rebuilds.
  • Range Updates: setPositionRange() edits a fixed span of a CPU billboard line and marks only that span plus its one-point neighbor halo for upload. Use it for brushes, tails, and local path deformation; surfaces and smoothed topology still need setPositions().
  • Bulk Instance Writes: Fill the attribute returned by addInstanceAttribute() directly before its first draw to avoid a staging copy. After upload, setInstanceValues() updates a whole attribute (or contiguous subrange) with one version change instead of one change per instance.
  • Portable Vertex-Buffer Budget: WebGPU guarantees eight vertex-buffer slots. Each independent geometry or node-backed custom attribute consumes one; interleaved attributes share a slot. Use getVertexBufferUsage() to inspect the geometry layout, pass reservedBuffers when your application supplies additional streams, and use addVertexAttributes() / addInstanceAttributes() to interleave related values. The facade rejects known over-budget builds before pipeline creation.
  • Lazy progress & UV varyings: The MeshLine facade omits the progress attribute and its arc-length pass until gradients, dashes, width callbacks, GPU positions, or hooks actually consume progress. In the shader graph, vProgress, vSide, and uvCoords are emitted as varyings only when a fragment hook or map actually references them, and opaque lines without alpha/discard features skip opacityNode altogether. Set needsProgress: true when a custom low-level consumer reads it directly.
  • Early-Z Preserving Join Folds (join.fold = 'geometry'): Projected 3D folds default to 'geometry' mode, which projects the stroke radius once in NDC and splits/overlaps folded join rows in the vertex shader with zero fragment discard. This preserves hardware early-Z and tile-based Hidden Surface Removal (HSR). Opt into join: { fold: 'exact' } or join: { type: 'round' } only when pixel-exact circular fold silhouettes are needed.
  • Lean Surface Buffers: Roads always emit positions and normals. UVs, tangents, progress, and side are added only when maps, normal mapping, or an explicit low-level consumer needs them.
  • Surface topology on demand: Ordinary miter/butt roads keep the compact two-row strip. Bevel wedges, round fans, caps, and reversal splits allocate extra vertices only when requested or mathematically required. join.segments is a half-turn budget and shallow corners automatically use fewer triangles; cap.segments controls a full semicircle.
  • Miter Clamp + Optional Corner Smoothing: The shader miter is always on (no branch cost, simpler than the old opt-in path). For static sharp polylines, opt into smoothSharpBends to split near-hairpin corners on the CPU before they reach the shader. Leave it off for dynamic lines or custom per-vertex attributes that need stable topology.
  • Cached Unpadded Bounds ($O(\text{lines})$ Padding Refresh): MeshLineGeometry caches each line's unpadded bounding box and sphere so animating lineWidth(), miterLimit(), join(), or sizeAttenuation() updates world-space padding in $O(\text{lines})$ microseconds (0.25 µs on a 200K-point line—6,780× faster) instead of rescanning every vertex.
  • Hybrid Culling: The optional GPU culling system keeps small paths on Three's CPU object test and can compact offscreen chunks of a very large opaque path into one native-WebGPU indirect draw.
  • Point-centric GPU path: The optional experimental GPU path stores source points once and renders one non-indexed indirect draw. Raw mode derives neighbours without a segment buffer or compute dispatch; simplification emits stable records with a 256-lane prefix above 256 chunks. The complete CPU line remains its fail-open reference.
  • WebGPU Compute Curves: MeshLineCompute.step() dispatches a motion pass and a cumulative arc-length pass over storage-buffer curve state, avoiding per-frame JavaScript point uploads for autonomous curve fields. Measure the complete simulation and render workload on target hardware; moving state to the GPU is not itself a performance guarantee.
  • GPU topology staging: Advanced GPU-path records are counted first and written directly into final typed arrays. This avoids transient boxed-number arrays; adapter storage limits and integer products are checked before allocation.

Best Practices ​

  • Use Instancing when all your lines have the same number of segments.
  • Use Float32Array for initial positions to avoid conversions.
  • Reuse arrays in hot loops to reduce GC pressure.
  • Prefer progress and lineCoord.y / side over uv() in fragment hooks: In discardFn, fragmentColorFn, and fragmentAlphaFn, use the hook's progress (0..1) and side (-1..1) arguments or lineCoord.y instead of calling uv(), and pass .needsUV( false ) when no texture map is used. This avoids allocating a vec2 uv vertex buffer and interpolating an extra vec2 varying.
  • Share compiled shaders across multiple lines: Three.js caches compiled ProgrammableStage shaders by exact WGSL/GLSL string. When rendering multiple MeshLine instances, define reusable Fn() hooks at module scope and drive per-line differences via uniforms (color, lineWidth, dash, or custom uniform()) instead of baking different literal constants into per-line hooks.
  • Warm up multi-shader scenes before reveal animations: Call .build() on every line, render one frame (renderer.render( scene, camera )), and await renderer.backend?.device?.queue?.onSubmittedWorkDone?.() before starting entrance timelines so pipelines compile up front without first-frame stalls.
  • Leave .antialias( false ) when using MSAA: Hardware MSAA smooths the billboard geometry silhouette natively while keeping the line in the opaque pass (depthWrite: true). Enable .antialias( true ) only on non-MSAA targets or for sub-pixel lines.
  • For a local edit, reuse a small Float32Array and call line.setPositionRange( patch, startPoint ). If progress, UV, endpoint metadata, or a width callback depends on arc length, those derived values must still refresh for the affected line.
  • For custom vertex data, create the attribute with addVertexAttribute() and upload bulk changes with setVertexValues(). Point-scoped data on a regular billboard must be duplicated over [incoming+, incoming-, outgoing+, outgoing-]; startVertex addresses those physical rows. Same-topology rebuilds preserve it, while point/line-count changes remove custom attributes, so recreate and refill them after an intentional topology change.
  • Keep automatic frustum culling disabled for CSS-pixel width, GPU-positioned lines, and arbitrary position/neighbour/width/normal/final-vertex hooks unless you maintain conservative bounds yourself.
  • For initial instance data, fill line.addInstanceAttribute( name, size ).array before adding the line to the scene. For later bulk edits, call setInstanceValues() once. Keep setInstanceValue() for occasional edits.
  • When several custom values are read by the same hook, create them together with addVertexAttributes({ ... }) or addInstanceAttributes({ ... }). Use the bulk setter methods for interleaved attributes; their direct backing arrays include the other packed attributes between rows.
  • Call dispose() on unused lines to free GPU memory.
  • For massive repeated lines, combine gpuPositionNode with instancing to skip CPU position uploads.
  • For autonomous trails or flow-field curves on WebGPU, use MeshLineCompute so point state stays on the GPU. Keep plain gpuPositionNode for deterministic parametric curves like circles and waves, and use regular MeshLine when a WebGL2-compatible path is required.
  • For picking or hover, set raycaster.params.Line.firstHitOnly = true when you only need the nearest hit.
  • Test both backends on target devices. Backend wins depend on the scene, driver, and workload.

Reproducible V2 Benchmarks ​

See the dedicated V2 Benchmarks page for full tables across every workload, or explore the live MeshLine-rendered benchmark diagram below:

Rendered with MeshLine

Partial Range Edits vs Full Buffer Upload (200,000 Points)

setPositionRange() updates only the edited slice plus its 1-point neighbor halo instead of re-uploading all 800,000 billboard vertices.

Full setPositions() (200K pts)1.47 ms CPU · 28.80 MB upload
Partial setPositionRange() (128-pt brush)0.0055 ms CPU · 18.62 KB upload (267× faster · -99.94% bytes)
Live setPositionRange() halo windowOnly the bracketed 128-point span + 1-point neighbor halo uploads per frame
267×Faster CPU update0.0055 ms vs 1.47 ms on 200K points
-99.94%Less GPU upload traffic18.6 KB halo range vs 28.8 MB full attributes
128 ptsBrush edit windowUpdates position, previous & next in-place
pnpm bench:hotpathsReproducible scriptbenchmarks/meshline-geometry-hotpaths.mjs

Run the repository benchmark suite on your target machine:

bash
pnpm bench:v2
pnpm bench:hotpaths
pnpm bench:bounds
pnpm bench:surface-topology -- 50000 7
  • pnpm bench:v2 reports median screen build/update time, update time with bounds, surface build/update time, and exact generated buffer bytes from 1,000 to 200,000 points.
  • pnpm bench:hotpaths compares full setPositions() against partial setPositionRange() CPU times and pending upload bytes. On a 200K-point default billboard path, a 128-point edit reduces the position/neighbor upload from 28.8 MB to 18.6 KB (-99.94%) and median CPU update time from 1.28 ms to 0.0039 ms (331× faster).
  • pnpm bench:bounds measures padding-only bound refreshes (lineWidth(), join({ limit }), sizeAttenuation()) against full point rescans (setPositions(..., true)), dropping a 200K-point single-line width update from 1.58 ms to 0.00025 ms (6,300× faster).
  • pnpm bench:surface-topology -- 50000 7 compares compact road geometry with bevel, round-join, and round-cap topology. Angle-adaptive round fans reduce the 50K-point round8-butt case from 549,982 to 199,996 vertices and from 18.31 MiB to 6.29 MiB (65.6% less geometry storage), compact miter-round8 adds only 16 cap vertices (100,016 total), and bisector frame transport + orthonormal join basis + flat scratch pool cuts 50K-point surface builds to 3.32–3.92 ms (3.3×–5.6× faster).

GPU LOD break-even ​

Do not rebuild a camera-dependent LOD list every frame by default. In the 262,144-point stress prototype on an Apple M3 Max, a cached static adaptive list retained 64,409 points (24.6%) and reduced unlocked mean frame time from about 0.594 ms to 0.328 ms. Recomputing three passes on every tiny camera change erased most of that gain; capping refinement to 15 rebuilds per second recovered a small steady moving-camera win, but did not meet the production promotion gate.

Start automatic GPU LOD only around 250k source points and a measured or conservatively predicted retained ratio of 25% or lower. Cache unchanged results, add hysteresis, and require repeated target-device measurements. A candidate should improve median mean frame time by at least 10%, improve p95 by at least 5%, leak no resources, preserve stable segment order, and never exceed its full-detail capacity. The stress demo is a sampled heuristic rather than the tolerance-guaranteed production contract; its numbers demonstrate the point-centric dispatch topology, not a universal threshold.

The production MeshLineGPUPath demo is a second, narrower measurement. Its guaranteed local dyadic oracle selected 1,646 of 199,999 segments; the native indirect path reduced tracked buffers by 65.9% and mean frame time by about 10% versus its CPU fallback. Its current GPU timestamp sample was higher despite the overall frame improvement, so promote it per workload only after paired target-device runs rather than from retained count alone.