Canvas3D User Guide

Overview

The Canvas3D plugin provides components for creating 3D visualizations in Clayground applications. It offers primitives for 3D boxes, lines, and voxel-based structures with support for custom edge rendering, toon shading, and efficient batch rendering.

Getting Started

To use Canvas3D components, import the module in your QML file:

import Clayground.Canvas3D

Minimal Example

Here's a simple example showing a red box with dark edges:

import QtQuick
import QtQuick3D
import Clayground.Canvas3D

View3D {
    anchors.fill: parent

    PerspectiveCamera {
        position: Qt.vector3d(0, 200, 300)
        eulerRotation.x: -30
    }

    DirectionalLight {
        eulerRotation.x: -30
        castsShadow: true
        shadowFactor: 78
        shadowMapQuality: Light.ShadowMapQualityVeryHigh
    }

    Box3D {
        width: 100
        height: 100
        depth: 100
        color: "red"
        useToonShading: true
    }
}

Core Components

Box3D

The Box3D component creates a 3D box with customizable dimensions, edge rendering, and cartoon-style shading. #### Creating Non-Uniform Shapes Use scale properties to create pyramids, trapezoids, and other shapes:

// Pyramid
Box3D {
    width: 100; height: 100; depth: 100
    scaledFace: Box3DGeometry.TopFace
    faceScale: Qt.vector2d(0.1, 0.1)
}
#### Edge Mask Usage Control which edges are visible:

Box3D {
    edgeMask: topEdges | bottomEdges  // Only horizontal edges
}

Lines

Canvas3D provides several components for drawing lines in 3D space:
  • LineBatch3D: Instanced renderer for very large sets of independently
  • styled polylines in a single draw call. Supports pixel- or world-width ribbons with round caps, a per-line styleId selecting a row of the styles table (dash pattern in world units, cap shape, opacity), and cheap per-frame updates via updateLinePoints / updateEndpointsBulk.
  • Line3D: Simple wrapper for drawing a single line (batched backend)
  • MultiLine3D: Draws multiple line paths with one color/width (batched backend)
  • BoxLine3D: Creates a line using connected box segments for thicker, more visible lines
  • #### Dynamic Connectors
  • ConnectorLayer3D: Owns one LineBatch3D and draws all of its connectors
  • as a single instanced draw call, patching only the endpoints that moved each frame.
  • Connector3D: A declarative link between two scene nodes (from/to);
  • it follows their scene positions and registers into a ConnectorLayer3D (either declared inside one, or via an explicit layer reference for Repeater3D delegates). N connectors cost one draw call. #### Styled bulk lines with LineBatch3D Give each line its own colour, width and dash style, all in one instanced draw call. The styles table holds dash/cap/opacity rows; a line's styleId selects one:
    
    LineBatch3D {
        viewportSize: Qt.vector2d(view.width, view.height)
        widthUnits: LineBatch3D.Pixel   // or LineBatch3D.World
        styles: [
            { dash: [0, 0],  capRound: true,  opacity: 1.0 },  // 0: solid
            { dash: [12, 8], capRound: false, opacity: 1.0 }   // 1: dashed
        ]
        lines: [
            { points: [Qt.vector3d(0,0,0), Qt.vector3d(100,0,0)], color: "#00d9ff", width: 2, styleId: 0 },
            { points: [Qt.vector3d(0,0,0), Qt.vector3d(0,0,100)], color: "#ff3366", width: 2, styleId: 1 }
        ]
    }
    
    For very large sets, skip the per-object lines list and push packed binary buffers via setBulk(positions, startIndices, colors, widths, styleIds) — the optional fifth styleIds (uint16 per line) is backward-compatible; omit it and every line renders solid. Move lines cheaply per frame with updateLinePoints(lineIndex, points) or updateEndpointsBulk(positions) — both patch the instance table in place instead of rebuilding geometry.
    
    // N moving links as a single instanced batch, endpoints patched each frame.
    ConnectorLayer3D {
        id: links
        viewportSize: Qt.vector2d(view.width, view.height)
        widthUnits: LineBatch3D.Pixel
        color: "#00d9ff"; width: 1.5
    }
    Repeater3D {
        model: satellites
        delegate: Node {
            required property int index
            Connector3D { layer: links; from: this; to: hubs[index % hubs.length] }
        }
    }
    

    Labels

    Three components put readable text into a 3D scene the way technical illustrations and maps do. All render unlit (never toon-shaded, never shadow-casting) and stay crisp under the camera. Pick by role:
  • Label3D - a camera-facing callout pill anchored to a thing. Reach for it to
  • annotate individual entities or points - dozens - with rich, always-readable UI-style tags, optionally with a leader line.
  • PathLabel3D - text laid flat along a line, the street-name look. Reach for
  • it to paint a name onto a road or route from a LineBatch3D, read from above.
  • LabelBatch3D - thousands of instanced SDF text labels. Reach for it when you
  • need many cheap, uniform world-anchored labels (map features, data points, particle tags) in a couple of draw calls. They compose: Label3D is the rich end (one QObject per label), LabelBatch3D is the mass-scale end (one GPU instance per glyph), and PathLabel3D sits on top of LineBatch3D for the flat-on-ground case. #### Label3D - anchored callouts A rounded "pill" with optional icon, anchored to a moving anchorNode or a fixed anchorPosition. It billboards to the camera every frame and, by default, holds a constant on-screen size (sizeMode: Label3D.Screen); switch to Label3D.World to scale with the scene. An optional showLeader draws a thin line from the pill to the anchor - the offset-callout look. A single shared per-view ticker (via Label3DRegistry) drives all labels in one pass and skips the work entirely while the camera and anchors are still, so many labels stay cheap and hidden ones are dormant.
    
    Label3D {
        view: view
        anchorNode: reactor
        text: "REACTOR CORE"
        labelOffset: Qt.vector3d(0, 40, 0)
        showLeader: true
    }
    
    #### PathLabel3D - names along a line Street-name style text laid flat on the ground along a line of a LineBatch3D, the way a map paints a road name. Placement rides the line via pathLength() / positionAt(): the label centers on at (or the path middle), one flip-to-read decision per placement keeps text from ever appearing upside-down, and repeatEvery stamps the name at a fixed spacing along a long road. It offers two carriers, chosen with glyphPlacement:
  • Word mode (default, glyphPlacement: false): each word rides its own
  • 2x-oversampled texture quad tangent to the path. Cheap; bends happen at word boundaries. Crisp down to roughly the oversample factor.
  • Glyph mode (glyphPlacement: true): the text is shaped once and each glyph
  • is placed and rotated individually along the curve (maplibre's model), so the baseline hugs tight bends smoothly and the whole name - not just each word - reads correctly on a doubling-back leg. Rendering routes through an internal LabelBatch3D created lazily on first use, so word-mode labels pay for no SDF atlas (pay-per-use). Being SDF-baked, glyph mode stays crisp at any zoom. A curvature guard skips a placement whose baseline would wrap too sharp an arc (skippedPlacements reports how many); a background pill is not supported in glyph mode v1. As a ground decal the glyphs write depth with a small toward-camera bias so they draw above the line they sit on from any camera - the layering contract is lines first, labels above.
    
    LineBatch3D { id: roads / ... / }
    
    PathLabel3D {
        lines: roads
        lineId: 0
        text: "CLAY STREET"
        worldHeight: 30
        repeatEvery: 900
        glyphPlacement: true   // per-glyph text-on-curve; omit for cheap word mode
    }
    
    #### LabelBatch3D - mass-scale SDF labels Draws very large sets of short labels - tens of thousands - as instanced signed-distance-field glyphs over a shared atlas (deck.gl's TextLayer model): N labels cost one draw call for the glyphs plus one for the optional pills. Each label is a world-anchored point with its own text, color and size; glyphs stay crisp at any zoom. sizeMode picks screen-constant (billboard) or world units, orientation picks billboard or flat-on-ground, and moving labels can be repositioned without re-shaping via updatePositionsBulk. It is the mass-scale sibling of Label3D, not a replacement - rich per-label content, icons and leaders stay Label3D's job.
    
    LabelBatch3D {
        viewportSize: Qt.vector2d(view.width, view.height)
        sizeMode: LabelBatch3D.Screen
        halo: true
        labels: [
            { position: Qt.vector3d(0, 0, 0),  text: "ALPHA", color: "#00d9ff", size: 22 },
            { position: Qt.vector3d(80, 0, 0), text: "BETA",  color: "#ff3366", size: 22 }
        ]
    }
    

    Voxel Maps

    Voxel maps create 3D structures composed of cubic voxels. Both backends share a compact palette-index store (8-bit per voxel, auto-upgraded to 16-bit past 255 distinct colours) with an incrementally-maintained (O(1)) solid count — no per-change recount.
  • StaticVoxelMap: greedy-meshed triangle geometry, chunked and remeshed
  • off the main thread. A single set() dirties only its ~32³ chunk, which is remeshed on a worker; the call returns immediately. Best for large maps, and — since the remesh no longer blocks — now perfectly usable under frequent edits.
  • DynamicVoxelMap: GPU-instanced cubes; each set() marks the instance table
  • dirty and the rebuild lands at render time. Best for maps that churn (full clear+refill) or need per-voxel instance semantics.

    Dynamic Instancing

    DynamicInstances3D renders many copies of one base mesh (cars, crowds, projectiles) through a single GPU instance table whose per-entry transforms are updated every frame from a packed binary buffer — no per-instance QObject and no CPU table rebuild on the hot path. It is the C++-backed alternative to a declarative InstanceList of InstanceListEntry objects, which pays a property write per entry per changed field and a full table rebuild.
  • Set per-entry statics once with setBulk(scales, colors, customData) — one
  • vector3d scale and one color per entry.
  • Push movement each frame with updatePoses(first, poses), where poses is a
  • reused Float32Array of [x, y, z, yawRad] per entry (pass .buffer). The transform is translate(x,y,z) rotateY(yaw) scale, so the base mesh's local +Z axis points along yaw — matching an InstanceListEntry with eulerRotation (0, yawDeg, 0), so migrations keep their orientation.
  • setEntryColor(i, c) recolors a single entry; setExtents(min, max) declares
  • the roaming volume so the table skips the per-upload bounds rescan.
  • Read-only count, bytesLastUpload, packMsLast, uploadsPerSecond feed a
  • HUD.
    
    Model {
        source: "#Cube"
        instancing: DynamicInstances3D {
            id: fleet
            Component.onCompleted: {
                var scales = [], colors = []
                for (var i = 0; i < 500; ++i) { scales.push(Qt.vector3d(0.02, 0.01, 0.03)); colors.push("#00d9ff") }
                setBulk(scales, colors)
                setExtents(Qt.vector3d(-200, 0, -200), Qt.vector3d(200, 10, 200))
            }
        }
        materials: PrincipledMaterial { lighting: PrincipledMaterial.NoLighting }
    }
    
    // per frame, from a reused Float32Array poseBuf (4 floats per entry):
    fleet.updatePoses(0, poseBuf.buffer)
    
    The neoncity demo drives its whole car fleet (body + cabin + window + 4 wheels = four tables) this way.

    Toon Shading

    Canvas3D implements cartoon-style rendering using a half-lambert lighting model, providing flat, stylized lighting with distinct shadow boundaries.

    Optimal Lighting Setup

    Toon shading requires specific shadow settings for the characteristic cartoon look:
    
    DirectionalLight {
        eulerRotation.x: -35  // Optimal lighting angle
        castsShadow: true
        shadowFactor: 78                        // Strong shadows
        shadowMapQuality: Light.ShadowMapQualityVeryHigh  // Crisp edges
        pcfFactor: 2                           // Minimal softening
        shadowBias: 18                         // Artifact prevention
    }
    

    Usage

    Enable toon shading on any Canvas3D component:
    
    Box3D {
        useToonShading: true
        edgeColorFactor: 2.0  // Increase edge contrast for cartoon look
    }
    
    StaticVoxelMap {
        useToonShading: true  // Creates Minecraft-like blocky aesthetics
    }
    

    Coordinate System

    Canvas3D uses Qt Quick 3D's coordinate system:
  • X-axis: Points right
  • Y-axis: Points up
  • Z-axis: Points toward the viewer
  • Voxel Coordinates

    The relationship between voxel coordinates and world positions: worldPosition = voxelCoordinate * (voxelSize + spacing) + voxelOffset Example dimensions calculation:
    
    StaticVoxelMap {
        voxelCountX: 10
        voxelCountY: 5
        voxelCountZ: 10
        voxelSize: 2.0
        spacing: 0.5
    
        // width = 10 * (2.0 + 0.5) - 0.5 = 24.5
        // height = 5 * (2.0 + 0.5) - 0.5 = 12.0
        // depth = 10 * (2.0 + 0.5) - 0.5 = 24.5
    }
    

    Edge Rendering

    Edge Artifacts with Greedy Meshing

    When using StaticVoxelMap with fill() operations (spheres, cylinders), disable edges to avoid visual artifacts:
    
    StaticVoxelMap {
        voxelCountX: 50
        voxelCountY: 50
        voxelCountZ: 50
        showEdges: false  // Prevents grid artifacts with meshed geometry
    
        Component.onCompleted: {
            fill({ shape: "sphere", pos: [25,25,25], radius: 10, colors: ["red"] })
        }
    }
    

    Performance Considerations

    Choosing Voxel Map Types

  • StaticVoxelMap: large maps and edit-heavy scenes alike — chunked
  • off-thread greedy meshing means a single voxel edit only remeshes its chunk on a worker thread (per-edit cost is effectively free on the main thread). It even outperforms DynamicVoxelMap for a single-edit-per-frame storm.
  • DynamicVoxelMap: full clear+refill churn, or when you need per-voxel
  • instanced cubes rather than meshed geometry.

    Edit costs

  • A single set() on StaticVoxelMap dirties ~one chunk; the greedy remesh runs
  • on a QtConcurrent worker, so it does not stall the frame.
  • model.commit() after a batch (e.g. fillBox/fillSphere) dispatches the
  • full build to the worker too — it schedules rather than blocks.
  • Still prefer the batch path (fillBox + one commit()) over thousands of
  • individual set() calls when generating terrain: it is a single build instead of one per dirtied chunk.

    Optimization Tips

  • Batch Operations: Call model.commit() once after multiple voxel changes
  • Edge Control: Disable showEdges for large voxel maps
  • Shadow Quality: Balance shadow settings with performance needs
  • Instrumentation & Benchmarks

    Three helper types measure real cost from QML:
  • PerfHud: a compact always-on-top overlay for a View3D (view3D: +
  • optional extended: true) showing fps and frame time — drop it into any scene while tuning. It automatically appends any PerfRegistry section averages and counter rates beneath the render stats.
  • PerfRegistry: an app-wide singleton for timing named code sections and
  • counting events, independent of the renderer. Wrap a block with PerfRegistry.begin("name") / PerfRegistry.end("name") for a rolling-average section time, or call PerfRegistry.tick("name") for a per-second rate; snapshot() returns the current readings. Cheap when unused. The neoncity car sim instruments "carSim" (control logic) and "carPack" (pose packing + upload) so the HUD shows the split.
  • BenchLogger: samples a View3D's renderStats at a fixed intervalMs and
  • writes a CSV to outputPath; extra adds custom columns and `annotate(key, value)` tags the next sample (e.g. step boundaries).
    
    PerfHud   { view3D: view; anchors.right: parent.right }
    BenchLogger {
        view3D: view
        outputPath: "file:///tmp/mybench.csv"
        intervalMs: 250
        running: true
    }
    
    The benchmarks/ directory holds stepped, auto-running scenarios (Sandbox.qml loads them) covering static/dynamic lines, moving connectors, animated instances (InstanceList vs DynamicInstances3D), and voxel edit-storm/churn. Recorded results, the baseline, and the optimized-vs-baseline comparison live in benchmarks/results/ — see results/COMPARISON-2026-07-18.md for the line/voxel headline numbers and results/instances-2026-07-19.md for the instancing comparison.

    Examples

    Toon-Shaded Voxel Terrain

    
    import QtQuick
    import QtQuick3D
    import Clayground.Canvas3D
    
    View3D {
        environment: SceneEnvironment {
            clearColor: "#87CEEB"  // Sky blue
            backgroundMode: SceneEnvironment.Color
        }
    
        PerspectiveCamera {
            position: Qt.vector3d(200, 300, 400)
            eulerRotation.x: -30
        }
    
        DirectionalLight {
            eulerRotation.x: -35
            eulerRotation.y: -70
            castsShadow: true
            shadowFactor: 78
            shadowMapQuality: Light.ShadowMapQualityVeryHigh
            pcfFactor: 2
            shadowBias: 18
        }
    
        StaticVoxelMap {
            id: terrain
            voxelCountX: 80
            voxelCountY: 30
            voxelCountZ: 80
            voxelSize: 5
            useToonShading: true
            showEdges: false  // Avoid artifacts with terrain
    
            Component.onCompleted: {
                // Create layered terrain
                for (let x = 0; x < voxelCountX; x++) {
                    for (let z = 0; z < voxelCountZ; z++) {
                        let h = Math.sin(x  0.1)  Math.cos(z  0.1)  8 + 15
    
                        for (let y = 0; y < h; y++) {
                            let color = y < 5 ? "#8B4513" :   // Dirt
                                       y < 12 ? "#228B22" :   // Grass
                                               "#708090"       // Stone
                            set(x, y, z, color)
                        }
                    }
                }
                model.commit()
            }
        }
    }
    

    Mixed Rendering Styles

    
    Row {
        spacing: 200
    
        // Standard PBR rendering
        Box3D {
            width: 100; height: 100; depth: 100
            color: "#e74c3c"
            useToonShading: false
        }
    
        // Cartoon rendering
        Box3D {
            width: 100; height: 100; depth: 100
            color: "#e74c3c"
            useToonShading: true
            edgeColorFactor: 2.0  // Enhanced edges for cartoon look
        }
    }
    

    Best Practices

    Toon Shading

  • Use strong directional lighting with high shadow factor (70-80)
  • Enable crisp shadow maps (VeryHigh quality, low PCF factor)
  • Increase edgeColorFactor for enhanced cartoon aesthetics
  • Consider scene ambient lighting balance
  • Performance

  • Choose appropriate voxel map type based on update frequency
  • Batch voxel operations when possible
  • Use edge rendering selectively on large scenes
  • Profile shadow quality vs. performance trade-offs
  • Visual Design

  • Consistent lighting setup across toon-shaded objects
  • Use the demos (Box3DDemo.qml, VoxelDemo.qml) as implementation references
  • Test both rendering modes during development
  • Code Organization

  • Study shader implementations in .frag files for custom lighting
  • Refer to geometry classes for edge rendering algorithms
  • Use the demo control panels as UI pattern examples
  • Implementation References

    For developers wanting to understand or extend the system:
  • Toon Shading: box3d.frag, voxel_map.frag - complete shader implementations
  • Edge Rendering: shouldShowEdge() function, grid line calculations
  • Greedy Meshing: VoxelMapGeometry::generateGreedyMesh()
  • Demo Implementation: Box3DDemo.qml, VoxelDemo.qml - complete working examples