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