Osy#betaa language · its runtime Osyrin · a hosted platform
Why Osy#Built for agentsAgents as declarationsWorkflows that waitRuns exactly onceSecure by defaultNothing to mockThe editor is the compilerUI in the languageDocuments are dataOne program

Reference / UI

Canvas 3D

Draw.Camera(ex, ey, ez, tx, ty, tz, fovDeg) · Draw.Light(dx, dy, dz, sun, ambient) · Draw.Fog(color, near, far) · Draw.Mesh(mesh, x, y, z[, rx, ry, rz, scale | sx, sy, sz], color) · Mesh.Box(w, h, d) · Mesh.Sphere(radius, detail) · Mesh.Cylinder(rTop, rBottom, height, segments) · Mesh.Cone(radius, height, segments) · Mesh.Plane(w, d) · Mesh.From(vertices, indices)

A lit, shadowed 3D scene on the same `Canvas` the 2D verbs paint. Build meshes once into fields, then each frame place a camera, a sun and some fog, and draw each mesh where it is now. The scene is rendered on the GPU and composited into the canvas, so 2D verbs draw a sky behind it and a score over it in the same frame body.

preview2 examples compiled by CIuicanvas3ddrawing

Summary#

Canvas paints flat shapes. A game that wants depth — hills that recede, a bird lit from one side, a shadow under it, haze at the horizon — cannot get it from rectangles at any frame rate, so the canvas carries a second layer: a scene of meshes, drawn with the same immediate-mode discipline as everything else. Nothing is retained between frames except the meshes themselves; each frame you say where the camera is, where the light comes from, and where every mesh sits now.

The look is deliberately low-poly and flat-shaded: every face is one flat colour, lit by a sun with a soft shadow, an ambient sky, filmic tone mapping and distance fog. That is the look a few hundred well-placed boxes, cones and spheres produce best, and it is what runs at 60fps on an ordinary laptop.

Signature#

Mesh ground = Mesh.Plane(200, 200);            // a field — build a mesh ONCE, in `on mount` or an initializer
Mesh trunk  = Mesh.Cylinder(0.3, 0.4, 2, 8);   // (radiusTop, radiusBottom, height, segments), along y
Mesh crown  = Mesh.Cone(1.4, 3, 8);            // (radius, height, segments), point on top
Mesh rock   = Mesh.Sphere(1, 1);               // (radius, detail 0..4) — an icosphere, 20 × 4^detail faces
Mesh crate  = Mesh.Box(1, 1, 1);               // (width, height, depth), centred on the origin
Mesh hill   = Mesh.From(vertices, indices);    // x, y, z per vertex; three vertex numbers per triangle
Mesh ball   = Mesh.Smooth(rock);               // the same triangles, SMOOTH-shaded — nothing moves
Mesh ball2  = Mesh.Smooth(rock, 30);           // ...sharing a normal only across faces within 30 degrees

Draw.Camera(ex, ey, ez, tx, ty, tz, fovDeg);   // eye position, the point it looks at, vertical field of view
Draw.Light(dx, dy, dz, sun, ambient);          // the direction the sun SHINES ALONG, its colour, the sky colour
Draw.Fog(color, near, far);                    // haze from `near` to fully `color` at `far`, in scene units

Draw.Mesh(crate, x, y, z, color);                                // placed
Draw.Mesh(crate, x, y, z, rx, ry, rz, scale, color);             // rotated (radians) and scaled uniformly
Draw.Mesh(crate, x, y, z, rx, ry, rz, sx, sy, sz, color);        // …or per axis

Draw.Mesh(ground, x, y, z, Textures.Rock);                       // …or one of the app's TEXTURES instead of a colour
Draw.Mesh(ground, x, y, z, Textures.Rock, 0.25);                 // …tiled every four world units
Draw.Mesh(ground, x, y, z, Textures.Rock, 0.25, "#9bba66");      // ...and TINTED: the texture times this colour

Draw.Sprite(Textures.Coin, x, y, z, w, h);                       // a BILLBOARD - always faces the camera
Draw.Sprite(Textures.Sheet, sx, sy, sw, sh, x, y, z, w, h);      // ...a frame out of a sprite SHEET
Draw.Sprite(Textures.Sheet, sx, sy, sw, sh, x, y, z, w, h, "#f80");   // ...tinted

Description#

A scene is drawn each frame, like everything else on a canvas#

The meshes live in fields because building one costs a buffer upload; everything else is said again every frame. The 2D verbs and the 3D verbs share the frame body: the sky is a Draw.Rect, the scene is drawn over it, and the score is a Draw.Text over that — in the order they are written.

[Page("/tree")]
[AllowAnonymous]
component Tree() {
  Mesh ground;
  Mesh trunk;
  Mesh crown;
  double t = 0;

  on mount {
    ground = Mesh.Plane(60, 60);
    trunk = Mesh.Cylinder(0.25, 0.35, 1.6, 8);
    crown = Mesh.Cone(1.3, 3.2, 8);
  }

  on frame (double dt) {
    t += dt;
    Draw.Rect(0, 0, 640, 360, "#bfe3ff");                    // the sky, in 2D, BEHIND the scene
    Draw.Camera(0, 3, 12, 0, 1.5, 0, 45);
    Draw.Light(-0.5, -1, -0.4, "#fff4d6", "#9ec5ff");
    Draw.Fog("#bfe3ff", 20, 60);
    Draw.Mesh(ground, 0, 0, 0, "#7bbf5a");
    Draw.Mesh(trunk, 0, 0.8, 0, "#8a5a3c");
    Draw.Mesh(crown, 0, 3.2, 0, 0, t * 0.3, 0, 1, "#3f8f4a");   // slowly turning about y
    Draw.Text("a tree", 12, 12, "#204020", 20);              // the HUD, in 2D, OVER the scene
  }

  render { Canvas(w: 640, h: 360); }
}

Which way is up, where is the origin, and how big is one unit?#

The scene is right-handed with y up: +x is screen-right for a camera looking along −z, and +y is up. Units are whatever you choose — the builders, the camera and the fog all speak the same ones. Rotations are in radians, like Math.Sin, and apply x, then y, then z — pitch a bird, then yaw it to its heading.

A mesh's origin is its centre (a Plane is centred at y = 0, a Cylinder/Cone runs from −height/2 to +height/2), so a tree of height 3 standing on the ground is drawn at y = 1.5.

The light is a direction, and the second colour is the sky#

Draw.Light(dx, dy, dz, sun, ambient) takes the direction the sun shines along(−0.5, −1, −0.4) is a sun high and to the right, casting shadows down and to the left. Faces turned toward it get sun; faces turned away get the ambient colour, stronger on faces that look up (the sky) than on faces that look down. Shadows are cast by every mesh onto every mesh, softened at the edge, within the range the fog reaches — a shadow far beyond the fog would never be seen, and the shadow map's resolution is spent where it shows.

Fog is what makes distance read#

Draw.Fog(color, near, far) blends every surface toward color from near (no fog) to far (only fog). Give it the sky's colour and a far hill dissolves into the horizon the way it does outdoors; it also sets how far the shadow map reaches, so the two are tuned together.

Mesh.From builds anything the builders cannot#

A heightfield, a bird's body, a rock: give it every vertex as x, y, z and every triangle as three vertex numbers. Faces are flat-shaded from their own winding, so wind each triangle counter-clockwise seen from the outside — a face wound the other way is culled as a back face and simply is not there. An index outside the vertex list, or a list whose length is not a multiple of three, is an error that names the position.

[Page("/ridge")]
[AllowAnonymous]
component Ridge() {
  Mesh ridge;

  on mount {
    var verts = new List<double>();
    var idx = new List<int>();
    var cols = 24;
    var rows = 6;
    for (var r = 0; r <= rows; r++) {
      for (var c = 0; c <= cols; c++) {
        var x = (c - cols / 2.0) * 2;
        var z = (r - rows / 2.0) * 2;
        var y = Math.Sin(c * 0.5) * 1.5 + Math.Cos(r * 0.9) * 0.6 + 2;
        verts.Add(x); verts.Add(y); verts.Add(z);
      }
    }
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) {
        var a = r * (cols + 1) + c;
        var b = a + 1;
        var d = a + cols + 1;
        var e = d + 1;
        idx.Add(a); idx.Add(d); idx.Add(b);   // counter-clockwise seen from above (+y)
        idx.Add(b); idx.Add(d); idx.Add(e);
      }
    }
    ridge = Mesh.From(verts, idx);
  }

  on frame (double dt) {
    Draw.Rect(0, 0, 640, 360, "#cfe8ff");
    Draw.Camera(0, 8, 22, 0, 2, 0, 40);
    Draw.Light(-0.4, -1, -0.6, "#fff3d0", "#a9cbff");
    Draw.Fog("#cfe8ff", 25, 70);
    Draw.Mesh(ridge, 0, 0, 0, "#6faf58");
  }

  render { Canvas(w: 640, h: 360); }
}

Colours are the 2D verbs' colours#

#rgb, #rrggbb, rgb(…) and rgba(…) — the alpha is ignored, a mesh is opaque. Anything else draws magenta, the colour every renderer uses to mean "this is not a colour", so a typo is loud rather than dark.

A texture where the colour goes#

Put one of the app's own textures (textures) where a mesh's colour would be and it is projected onto the surface — a rock face, a brick wall, a grass floor:

Draw.Mesh(ground, 0, 0, 0, Textures.Grass, 0.25);   // a tile every four world units
Draw.Mesh(crate, 2, 0.5, 0, Textures.Crate);        // one tile per unit, the default

The trailing number is tiles per world unit. It is on the draw rather than on the scene because a ground plane and a crate want different densities in the same frame.

⚠️ Tiling is per world unit, and getting it too low is the commonest way a texture goes missing. A hill seven units across at 0.12 gets less than one tile — the texture is there and stretched until nothing reads. Something like 0.5 puts three or four across the slope, which is what reads as ground cover.

One texture, many tones — the tint#

A colour after the tiling is a tint: the texture is multiplied by it, so "#ffffff" is the texture untouched and anything darker or warmer shades it.

Draw.Mesh(hill, x, y, z, Textures.Grass, 0.5, "#9bba66");   // near: green
Draw.Mesh(hill, x, y, z, Textures.Grass, 0.5, "#c9cf8a");   // far: washed toward the haze

This is what lets one texture carry a whole scene. Without a tint every textured surface is the texture's own colours exactly, so a valley of seventeen hills is seventeen identical hills. With it, the same moss reads as near-green and far-gold — which is most of what makes a landscape recede.

A Gradient is refused here rather than quietly ignored: a mesh's colour is one value for the whole surface, and a canvas fill has nowhere to go in it.

⚠️ It is projected, not UV-mapped, and the difference is worth knowing. The texture is a property of the world position: the renderer samples it down the three world axes and blends by the surface normal. That means it works on every mesh — including one you built with Mesh.From, which has no texture coordinates to map with — and it tiles seamlessly across the joint between two meshes, which is what a floor made of several planes wants.

What it cannot do is a decal: there is no way to put a label on one face of a crate, or a face on a character. For that, draw the mark with the 2D verbs over the scene — they paint on the same canvas, after it.

Smooth shading, without changing the geometry#

Every mesh is flat-shaded by default: each triangle carries one normal, so a sphere reads as facets. That is the low-poly look, and it is right for a crate or a crystal. For a hill, a cloud, a tree or a character it is what makes a scene read as blocky.

Mesh.Smooth averages the shading normals of faces that meet at the same point:

Mesh hills  = Mesh.Smooth(Mesh.From(vertices, indices));   // rolling, not faceted
Mesh cloud  = Mesh.Smooth(Mesh.Sphere(1, 2));              // a round ball at 320 faces
Mesh trunk  = Mesh.Smooth(Mesh.Cylinder(1, 1, 2, 18));     // round SIDE, flat CAPS - one call

Nothing moves. The positions, the silhouette and the number of triangles are identical; only what the lighting is handed changes. So it costs nothing per frame - do it once, where you build the mesh.

  • The angle is what makes one verb safe on any mesh. Two faces share a normal only if they meet within 60 degrees, so the same call that rounds a sphere leaves a box untouched - every edge of a box is 90 degrees. Pass your own angle as a second argument when you want more or less: Mesh.Smooth(m, 100) will round a box's corners, and Mesh.Smooth(m, 20) keeps all but the gentlest creases sharp.

Because the threshold is per pair of faces, one call gives a cylinder a round side and flat caps - the answer you would otherwise have to build by hand.

Vertices are welded by position, to within a hundredth of a millimetre at world scale. Two vertices genuinely closer together than that are treated as one point.

Sprites — a picture standing in the world#

A Draw.Sprite is a flat rectangle of one of your textures that always faces the camera, placed at a world position and sized in world units:

Draw.Sprite(Textures.Coin, 4, 1.5, -2, 0.8, 0.8);               // the whole image
Draw.Sprite(Textures.Sparks, 128, 0, 128, 128, x, y, z, s, s);  // one FRAME of a 2x2 sheet

The nine-number form takes a source rectangle in the image's own pixels, exactly as Canvas's Draw.Image does — so one sprite sheet is cut up the same way in 2D and in 3D, and an animation is just picking sx from a frame counter.

This is what a textured mesh cannot do. A mesh's texture is projected by world position; a sprite carries the picture itself, so a specific image lands on it wherever it stands. Particles, pickups, distant trees, motes of pollen and world-space markers are all sprites.

They behave correctly against the scene, which is most of the work:

hidden by what is in fronta sprite behind a hill is not drawn — it is depth-tested like everything else
transparentthe image's alpha is respected, so a soft-edged mote is a mote and not a square
drawn in the right orderoverlapping sprites blend back-to-front, and none of them hides another
foggeda distant sprite fades into the haze with the rest of the scene

A sprite is unlit, and that is deliberate: it is a painted image — a flame, a coin, a marker — and shading it by a surface normal it does not have would only mean fighting the sun. A trailing colour tints it (multiplied, so "#ffffff" is the image untouched), which is how one flame sheet serves an orange flame and a blue one.

It lowers to a kernel like the rest of the frame body#

Every 3D verb and every builder is part of the host contract a frame body is lowered against, so a body that uses them still runs as a compiled JavaScript kernel — the hot loop of a game pays no interpreter cost for being 3D. A mesh in a field is a plain value to the kernel; build it in on mount, read it in on frame.

Where WebGL is missing, the scene is missing#

The scene is rendered by the browser's GPU. In an environment with no WebGL the 3D verbs log one error and draw nothing, while the 2D verbs keep working — so a HUD and a backdrop still appear over a blank scene rather than the page failing. Real browsers all have it; the headless DOM the unit tests run in does not, which is why the pixels are proven by the visual harness and not by a unit test.

What a frame can afford#

The cost is per mesh drawn and per face — not per pixel — plus one shadow pass over the same meshes. A few hundred Draw.Mesh calls over meshes of tens to low hundreds of faces each is comfortably 60fps; a Sphere at detail 4 is 5,120 faces and is the wrong choice for anything smaller than a planet.

See also#

  • Canvas — the surface itself and the 2D verbs, which draw under and over the scene
  • on mount / on unmounton frame (double dt), the clock that drives it
  • component — component state, where the meshes and the world live

Related

Canvas

A drawing surface, and the verbs that paint on it. Put a `Canvas` in a render block, call `Draw.*` from an `on frame`…

on mount / on unmount

`on mount { … }` runs a block ONCE, the first time a component appears — before its first paint; `on unmount { … }`…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…