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.
Include Optimizations
- Selective Attributes: Only creates buffers for what's needed (for example, no
previous/nextfor 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 internaljoinRolefloat 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 survivestoNonIndexed()without a separate side buffer. Budget custom attribute storage against4 * 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. Passtrueas 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 needsetPositions(). - 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, passreservedBufferswhen your application supplies additional streams, and useaddVertexAttributes()/addInstanceAttributes()to interleave related values. The facade rejects known over-budget builds before pipeline creation. - Lazy progress & UV varyings: The
MeshLinefacade omits theprogressattribute and its arc-length pass until gradients, dashes, width callbacks, GPU positions, or hooks actually consume progress. In the shader graph,vProgress,vSide, anduvCoordsare emitted as varyings only when a fragment hook or map actually references them, and opaque lines without alpha/discard features skipopacityNodealtogether. SetneedsProgress: truewhen 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 fragmentdiscard. This preserves hardware early-Z and tile-based Hidden Surface Removal (HSR). Opt intojoin: { fold: 'exact' }orjoin: { 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.segmentsis a half-turn budget and shallow corners automatically use fewer triangles;cap.segmentscontrols 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
smoothSharpBendsto 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):
MeshLineGeometrycaches each line's unpadded bounding box and sphere so animatinglineWidth(),miterLimit(),join(), orsizeAttenuation()updates world-space padding in $O(\text{lines})$ microseconds (0.25 µson 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
Float32Arrayfor initial positions to avoid conversions. - Reuse arrays in hot loops to reduce GC pressure.
- Prefer
progressandlineCoord.y/sideoveruv()in fragment hooks: IndiscardFn,fragmentColorFn, andfragmentAlphaFn, use the hook'sprogress(0..1) andside(-1..1) arguments orlineCoord.yinstead of callinguv(), and pass.needsUV( false )when no texture map is used. This avoids allocating avec2uvvertex buffer and interpolating an extravec2varying. - Share compiled shaders across multiple lines: Three.js caches compiled
ProgrammableStageshaders by exact WGSL/GLSL string. When rendering multipleMeshLineinstances, define reusableFn()hooks at module scope and drive per-line differences via uniforms (color,lineWidth,dash, or customuniform()) 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 )), andawait 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
Float32Arrayand callline.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 withsetVertexValues(). Point-scoped data on a regular billboard must be duplicated over[incoming+, incoming-, outgoing+, outgoing-];startVertexaddresses 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 ).arraybefore adding the line to the scene. For later bulk edits, callsetInstanceValues()once. KeepsetInstanceValue()for occasional edits. - When several custom values are read by the same hook, create them together with
addVertexAttributes({ ... })oraddInstanceAttributes({ ... }). 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
gpuPositionNodewith instancing to skip CPU position uploads. - For autonomous trails or flow-field curves on WebGPU, use
MeshLineComputeso point state stays on the GPU. Keep plaingpuPositionNodefor deterministic parametric curves like circles and waves, and use regularMeshLinewhen a WebGL2-compatible path is required. - For picking or hover, set
raycaster.params.Line.firstHitOnly = truewhen 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:
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.
Run the repository benchmark suite on your target machine:
pnpm bench:v2
pnpm bench:hotpaths
pnpm bench:bounds
pnpm bench:surface-topology -- 50000 7pnpm bench:v2reports median screen build/update time, update time with bounds, surface build/update time, and exact generated buffer bytes from1,000to200,000points.pnpm bench:hotpathscompares fullsetPositions()against partialsetPositionRange()CPU times and pending upload bytes. On a 200K-point default billboard path, a 128-point edit reduces the position/neighbor upload from28.8 MBto18.6 KB(-99.94%) and median CPU update time from1.28 msto0.0039 ms(331× faster).pnpm bench:boundsmeasures padding-only bound refreshes (lineWidth(),join({ limit }),sizeAttenuation()) against full point rescans (setPositions(..., true)), dropping a 200K-point single-line width update from1.58 msto0.00025 ms(6,300× faster).pnpm bench:surface-topology -- 50000 7compares compact road geometry with bevel, round-join, and round-cap topology. Angle-adaptive round fans reduce the 50K-pointround8-buttcase from549,982to199,996vertices and from18.31 MiBto6.29 MiB(65.6% less geometry storage), compactmiter-round8adds only16cap vertices (100,016total), and bisector frame transport + orthonormal join basis + flat scratch pool cuts 50K-point surface builds to3.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.