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
| Property | Value |
|---|---|
| Handedness | Right-handed |
| Up axis | +Y |
| Into the screen | −Z |
| Units | Arbitrary world units, not pixels |
| Origin | Centre 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.
| Call | Arguments | Defaults |
|---|---|---|
camera::perspective | fov_deg / fov_rad, near, far | 60°, 0.1, 500 |
camera::orthographic | height, near, far | 10, 0.1, 500 |
camera::position | x, y, z | 0, 0, 10 |
camera::look_at | x, y, z | 0, 0, 0 |
camera::direction | x, y, z | 0, 0, −1 |
camera::up | x, y, z | 0, 1, 0 |
camera::orbit | target_x, target_y, target_z, distance, yaw_deg/yaw_rad, pitch_deg/pitch_rad | 0, 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.
| Call | Arguments |
|---|---|
transform::push | — |
transform::pop | — |
transform::identity | — resets the current matrix |
transform::translate | x, y, z (default 0) |
transform::rotate_x | deg / rad |
transform::rotate_y | deg / rad |
transform::rotate_z | deg / rad |
transform::scale | x, 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.
| Call | Arguments | Defaults |
|---|---|---|
light::ambient | color | black |
light::directional | x, y, z (direction), color, intensity | 0/−1/0, white, 1.0 |
light::point | x, y, z, color, intensity, range | 0/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.
| Call | Arguments | Defaults |
|---|---|---|
draw::cube | size, or width, height, depth | 1 |
draw::sphere | radius, resolution | 0.5, 24 |
draw::plane | width, depth, subdivisions | 1, 1, 1 |
draw::cylinder | radius, height, segments | 0.5, 1, 32 |
draw::cone | radius, height, segments | 0.5, 1, 32 |
draw::torus | radius, tube_radius, segments, tube_segments | 0.5, 0.15, 32, 16 |
draw::sprite | size, or width, height — always camera-facing | 1 |
draw::mesh | vertices (required), indices, normals, uvs | — |
draw::model | asset (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
| Argument | Meaning | Default |
|---|---|---|
rotation_x_deg, rotation_y_deg, rotation_z_deg | Per-primitive rotation in degrees (rotation_x_rad etc. also accepted) | 0 |
color | A color:: expression | white |
shading | "unlit", "flat" or "lambert" | see light:: above |
wireframe | boolean | false |
opacity | 0.0–1.0 | 1.0 |
texture | An asset::bitmap or asset::vector reference — see Assets | none |
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.
| Call | Arguments | Default |
|---|---|---|
gfx::depth | enabled, write | true, true |
gfx::blend | mode: "alpha", "additive", "multiply", "none" | "alpha" |
gfx::cull | mode: "none", "back", "front" | "none" |
gfx::clear | color | black |
gfx::overlay | enabled | false |
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:
| Limit | Value |
|---|---|
| Draw commands per frame | 8192 |
| Triangles per frame | 2,000,000 |
| Mesh vertices per call | 65536 |
| Transform stack depth | 64 |
| Directional / point lights | 8 / 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:
| Value | Meaning |
|---|---|
$GRID_COLUMN | Zero-based column |
$GRID_ROW | Zero-based row |
$GRID_INDEX | row * 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.