Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Visamp Docs

3D Mode

Adding context 3d switches the coordinate model from flat pixels to world space, and unlocks a camera, a transform stack, lights and solid primitives.

Visript 5.1: planar shapes, thick strokes and final screen-space overlays are supported. See Creative Drawing Tools for anchors, units, budgets and context-specific limits.

context 3d

render {
  draw::cube()
}

A bare draw::cube() is visible with no camera setup — every primitive is unit-sized at the origin by default, and there is a sensible default camera.

Coordinate system

PropertyValue
HandednessRight-handed
Up axis+Y
Into the screen−Z
UnitsArbitrary world units, not pixels
OriginCentre of the view

A 2D sketch’s x/y map straight onto the z = 0 plane, so porting one is mostly a matter of scale.

Aspect ratio is never set in script. The runtime derives it from the canvas. Your script has to look right at any viewport size — the same discipline the canvas-relative sizing advice asks for in 2D.

Angles: always say the unit

Every angle argument names its unit. Both spellings work everywhere:

transform::rotate_y(deg: $TIME_SEC * 30.0)
transform::rotate_y(rad: $TIME_SEC * 0.52)

Where a call takes several angles, the unit suffixes each name:

camera::orbit(yaw_deg: $TIME_SEC * 20.0, pitch_deg: 15.0)
camera::orbit(yaw_rad: $TIME_SEC * 0.35, pitch_rad: 0.26)

Giving both units for the same angle is an error — there is no sensible way to reconcile them, and silently preferring one would make the other look like it worked:

transform::rotate_y(deg: 90.0, rad: 1.57)
// error: transform::rotate_y: specify deg or rad, not both

camera::

Camera state resets to the default at the start of every render block. Later calls override earlier ones.

CallArgumentsDefaults
camera::perspectivefov_deg / fov_rad, near, far60°, 0.1, 500
camera::orthographicheight, near, far10, 0.1, 500
camera::positionx, y, z0, 0, 10
camera::look_atx, y, z0, 0, 0
camera::directionx, y, z0, 0, −1
camera::upx, y, z0, 1, 0
camera::orbittarget_x, target_y, target_z, distance, yaw_deg/yaw_rad, pitch_deg/pitch_rad0, 0, 0, 10, 0, 0

fov_deg is the vertical field of view.

look_at and direction are two ways of saying the same thing — the last call wins, and mixing them is not an error. camera::orbit is sugar that sets position and orientation together; a later camera::position overrides the position it worked out.

The default camera is perspective, 60° vertical, at (0, 0, 10), looking at the origin.

transform::

A matrix stack, reset to a single identity at the start of every render block.

CallArguments
transform::push
transform::pop
transform::identity— resets the current matrix
transform::translatex, y, z (default 0)
transform::rotate_xdeg / rad
transform::rotate_ydeg / rad
transform::rotate_zdeg / rad
transform::scalex, y, z (default 1), or all for uniform

Transforms apply to every draw call, including the 2D primitives.

context 3d

render {
  for i in 0..12 {
    transform::push()
    transform::rotate_y(deg: i * 30.0)
    draw::cube(x: 4.0, width: 0.3, height: 1.0, depth: 0.3)
    transform::pop()
  }
}

Popping an empty stack is an error, and so is leaving a render block with the stack unbalanced — state must not leak into the next frame. Maximum depth 64.

light::

Lights are additive and reset every frame.

CallArgumentsDefaults
light::ambientcolorblack
light::directionalx, y, z (direction), color, intensity0/−1/0, white, 1.0
light::pointx, y, z, color, intensity, range0/0/0, white, 1.0, 50

At most 8 directional and 16 point lights per frame. Going over is a warning in the log, not an error — a visualisation adding lights in a loop should degrade, not die.

Lights must be declared before the draws they affect. Shading defaults to lambert if any light:: call has been made earlier in the current frame, and unlit otherwise, and that is decided at the moment of each draw.

draw:: — 3D primitives

All unit-sized at the origin, so a bare call renders something.

CallArgumentsDefaults
draw::cubesize, or width, height, depth1
draw::sphereradius, resolution0.5, 24
draw::planewidth, depth, subdivisions1, 1, 1
draw::cylinderradius, height, segments0.5, 1, 32
draw::coneradius, height, segments0.5, 1, 32
draw::torusradius, tube_radius, segments, tube_segments0.5, 0.15, 32, 16
draw::spritesize, or width, height — always camera-facing1
draw::meshvertices (required), indices, normals, uvs
draw::modelasset (required) — an asset::model reference

draw::plane lies in the XZ plane facing +Y — a floor.

draw::sprite is the particle workhorse: a quad that always faces the camera.

draw::mesh takes nested arrays. Without indices the vertices are read as a triangle list; without normals they are computed per face. Capped at 65536 vertices per call.

draw::mesh(
  vertices: [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]
)

draw::model draws geometry from an uploaded model instead of an inline array. It is a separate call rather than an argument on draw::mesh so that one keeps its “vertices are required” guarantee. See Assets.

draw::model(asset: asset::model(id: "a1b2c3d4-…"))

draw:: — the 2D primitives in 3D

They stay legal and are promoted into world space, gaining z and the rotation arguments. draw::rect(x: 0.0, y: 0.0, width: 2.0, height: 1.0) is a flat quad on the z = 0 plane, which is what porting a 2D sketch should look like — and a floor needs no new builtin:

context 3d

render {
  transform::rotate_x(deg: 90.0)
  draw::rect(x: 0.0, y: 0.0, width: 20.0, height: 20.0)
}

Planar circles, ellipses, rectangles and polygons render on the XY plane. Connected strokes render as camera-facing triangle ribbons. Text and images require the final screen-space overlay. See the availability table for supported styling.

draw::line is the one call whose argument changes meaning between modes. It gains z1 and z2, and its stroke_width is read as screen pixels in 2d and world units in 3d, so lines recede correctly with perspective.

Arguments every draw:: call takes in 3D

ArgumentMeaningDefault
rotation_x_deg, rotation_y_deg, rotation_z_degPer-primitive rotation in degrees (rotation_x_rad etc. also accepted)0
colorA color:: expressionwhite
shading"unlit", "flat" or "lambert"see light:: above
wireframebooleanfalse
opacity0.0–1.01.0
textureAn asset::bitmap or asset::vector reference — see Assetsnone

The 3D primitives are additionally positioned with x, y, z, applied before the transform stack. The 2D primitives keep their own positioning arguments and gain z.

gfx:: — render state

Per-frame, reset at the start of every render block.

CallArgumentsDefault
gfx::depthenabled, writetrue, true
gfx::blendmode: "alpha", "additive", "multiply", "none""alpha"
gfx::cullmode: "none", "back", "front""none"
gfx::clearcolorblack
gfx::overlayenabledfalse

Culling defaults to none on purpose: drawing a plane and looking at it from below should show you the plane, not a debugging session.

Additive blending with depth writes off is the most common setup for music visualisation:

gfx::blend(mode: "additive")
gfx::depth(enabled: true, write: false)

Final screen-space overlay

gfx::overlay(enabled: true) begins the final screen-space section. Its 2D primitives use pixel coordinates above the world scene, with a separate 2D transform stack. Filters and scramble apply after the overlay. World draws cannot follow it; world text remains unsupported.

gfx::overlay(enabled: true)
draw::text(content: "spectrum city", x: 20.0, y: 40.0, size: 24.0, color: $COLOR_WHITE)
gfx::overlay(enabled: false)

World geometry and camera/light calls cannot run inside the overlay. Shared 2D transforms, blend state, filters and whole-frame effects remain available.

Supported combinations

See the availability table. Literal unsupported usage is rejected by the validator; dynamically chosen overlay state and invalid values are checked at runtime. The 2D transform subset and blending are also available inside an overlay.

Limits

Enforced by the runtime, so a heavy script degrades instead of dying:

LimitValue
Draw commands per frame8192
Triangles per frame2,000,000
Mesh vertices per call65536
Transform stack depth64
Directional / point lights8 / 16

Going past the per-frame draw or triangle limit drops the rest of that frame’s commands and warns in the log.

Further capabilities

Whole-frame effects, including bloom, kaleidoscope and pixelation, process the finished scene plus overlay. Textures are supported on solid/planar meshes, sprites and model points; see Assets.

Shadows, richer materials, spot lights and camera paths remain future work.

Point clouds

draw::point_cloud (engine 2.3.0) draws a dense set of square points in one GPU call. Use $POINT_INDEX and $POINT_COUNT directly in its position, colour and size expressions; other values, including audio arrays, are captured once per frame.

context 3d
render {
  draw::point_cloud(
    count: 10000,
    x: ($POINT_INDEX % 100) / 10.0 - 5.0,
    y: math::sin(rad: $POINT_INDEX / 100.0 + $TIME_SEC),
    z: math::floor(value: $POINT_INDEX / 100.0) / 10.0 - 5.0,
    color: color::hsl(h: $POINT_INDEX / $POINT_COUNT, s: 1.0, l: 0.5),
    size: 2.0
  )
}

count is required unless a model is supplied (engine 2.5.0). Coordinates default to zero, colour to white, and size to 2 drawing-buffer pixels. size_attenuation: true scales size by viewport_height / (2 * camera_depth) in perspective; orthographic size stays in pixels. Device point-size limits apply. The current transform, camera, depth and blend settings apply; points are unlit and follow draw order.

Per-point fields support arithmetic, math:: functions and numeric array reads, using 32-bit floats. Array indices outside bounds or not whole numbers read zero. User functions and other Visript logic must run outside the fields. The per-point system values cannot be assigned to a let for later use. Colour supports RGB and HSL, including transparency. texture: asset::bitmap(id: "…") or a vector reference applies the whole image to each point. alpha_test (0–1, default 0) discards fragments below that alpha; fully transparent fragments never write depth. Wireframe and shading are not supported.

Limits: 1,000,000 points and 64 clouds per frame, 32,768 input numbers per cloud, 1,000,000 input numbers per frame, and expression limits of 512 nodes / 128 levels. Inputs must be finite; points with nonfinite computed positions/colours are culled; nonpositive or nonfinite per-point sizes are also culled. Use valid domains for maths. Clouds count as one draw command each and use no triangle budget.

Model positions (engine 2.5.0)

context 3d
render {
  draw::point_cloud(
    model: asset::model(id: "your-model-id"),
    y: $POINT_Y + math::sin(rad: $TIME_SEC + $POINT_INDEX / 100.0) * 0.1,
    size: 2.0 + math::abs(value: $POINT_Y)
  )
}

With model, omitted count uses the model’s point count and omitted x, y, z use its original coordinates. $POINT_X, $POINT_Y, $POINT_Z are the current source position. $MODEL_X[index], $MODEL_Y[index], $MODEL_Z[index] read any source position, allowing interpolation with neighbouring vertices. For a closed sequence, wrap explicitly with ($POINT_INDEX + 1) % $POINT_COUNT. Invalid indices read zero. These values are only available inside point fields with a model; they are read-only and cannot be copied into ordinary Visript variables. If you override count, $POINT_COUNT is that draw count, not the source length.

The model is decoded once and its positions are cached on the GPU. They do not consume the per-frame field-input limit. Distinct model sources used in one frame are limited to 1,000,000 positions in total, independently of the drawn-point limit.

Animated triangle grids

draw::grid (engine 2.6.0) connects a rectangular grid of vertices into a solid, unlit surface. Positions and colours are evaluated on the GPU with the same arithmetic, math functions and numeric array reads supported by point clouds. Colours interpolate across each triangle.

context 3d
render {
  camera::position(x: 0.0, y: 2.0, z: 3.0)
  camera::look_at(x: 0.0, y: 0.0, z: 0.0)
  draw::grid(
    columns: 128, rows: 128,
    y: math::sin(rad: $GRID_COLUMN / 8.0 + $TIME_SEC) * 0.15,
    color: color::hsl(h: $GRID_ROW / 128.0, s: 1.0, l: 0.5)
  )
}

columns and rows count vertices, not cells, and are required whole numbers from 2 to 4096. Defaults form a unit square in XZ: x and z range from -0.5 to 0.5, y is zero, and colour is white. Each cell contains two triangles facing +Y. Override x, y, z and color to deform and colour the surface. The current camera, transform, culling, depth and blend settings apply. Grids are unlit; textures, normals, wireframe and material arguments are not supported.

Inside vertex fields:

ValueMeaning
$GRID_COLUMNZero-based column
$GRID_ROWZero-based row
$GRID_INDEXrow * columns + column

Use these values directly inside the fields, rather than assigning them to a let outside the draw call. For example, y: heights[$GRID_INDEX] reads stored heights. Invalid array indices read zero. Fields use 32-bit floats and must stay within valid math domains.

Grids and point clouds share a per-frame budget of 1,000,000 unique vertices, 64 calls and 1,000,000 input numbers. A grid can supply up to 524,288 input numbers; point clouds retain their 32,768-number limit. Expression limits remain 512 nodes and 128 levels. Connectivity is generated on the GPU, so the script does not construct triangle arrays on each frame. Frame capture supports grids.