Worlds made of several places
A world package may hold several places made of points and one backdrop (the sky and the far distance), all in one frame. The engine draws them as one object with one depth order, brings the nearest in first, and holds only so many points at once. A world with one place is read and loaded exactly as before; nothing on this page applies to it.
This page is the format's additions and what the loader does with each. It is outside the docs bundles on purpose: a game never names any of it. engine.get('world').load(address) is the whole of a game's part.
Why one object
Points sort only against points of the same object (Splats, Draw order). Two places that overlap, drawn as two objects, have one draw order for the whole overlap, and one of them is wrong somewhere on it. So every layer of a joined world is put in the world's own frame once, when it arrives, and written into one splat: the backdrop, each place, and the loose things that come as points. What is in front hides what is behind, whichever place either belongs to.
The record
Everything is additive to format 1 (version stays 1). An older engine refuses a joined world (its place has nothing to draw); that is accepted.
jsonc
{
"version": 1,
"id": "estate-forecourt",
"kind": "open",
// The world's own facts, in the world's frame. No points: they are in `backdrop` and `places`.
"place": {
"forms": {},
"use": null,
"fileTransform": { "scale": 1, "flipY": false, "floorOffset": 0 },
"collision": { "shape": "mesh", "file": "place/collision.glb" }, // ONE collision, in the world's frame
"panorama": "place/panorama.webp",
"spawn": { "position": [56.9, 0.56, 85.2], "facing": 10 },
"eyeHeight": 1.6,
"walk": { "shape": "radius", "centre": [65, 0, 86], "radius": 93 },
"light": {},
},
// The world layer: always loaded, first.
"backdrop": {
"forms": {
"splat": {
"file": "backdrop/backdrop.spz",
"points": 1769760,
"bytes": 28617751,
"lite": "backdrop/backdrop.lite.spz",
"litePoints": 204785,
"liteBytes": 3308312,
},
},
"fileTransform": {
"scale": 2.44,
"flipY": true,
"floorOffset": 1.35,
"position": [56.9, 0.585, 85.2],
"yaw": -170,
},
"bounds": { "min": [-50, 0, 9], "max": [180, 132, 222] },
},
"places": [
{
"id": "house-front",
"name": "House Front",
"forms": {
"splat": {
"file": "places/house-front/place.spz",
"points": 2496355,
"bytes": 37463690,
"lite": "places/house-front/place.lite.spz",
"litePoints": 288980,
"liteBytes": 4324347,
},
},
"fileTransform": {
"scale": 2.44,
"flipY": true,
"floorOffset": 1.35,
"position": [56.9, 0.585, 85.2],
"yaw": -170,
},
"bounds": { "min": [47, 0, 65], "max": [76, 41, 105] },
"good": {
"points": [
[66.25, 61.75],
[65, 63.75],
],
"more": [],
}, // rings of [x, z]
"priority": 0,
},
],
"routes": [{ "id": "lap", "name": "The lap", "surface": "gravel", "points": [[50, 0.5, 70, 6]] }],
"things": [],
"sounds": { "room": [] },
}| Field | What the loader does with it |
|---|---|
place | The world's collision, sky (panorama), light, start and walk limit, as in any world. forms is empty and that is right when places or backdrop are there |
backdrop | Drawn first, before the world is playable; never let go |
places[].forms.splat | The file, its lighter copy (lite, taken by a load with lite: true), and how many points and bytes each holds: the budget counts points before a file is asked for |
fileTransform | Where the file stands: p = position + Q * ((0, floorOffset, 0) + F(scale * p_file)). F is half a turn about x when flipY. Q is rotation (a quaternion, xyzw) when there is one, else a turn of yaw degrees about y |
bounds | With the backdrop's, the places' and the things' boxes: one sphere that holds the whole world, told to the sort before anything has arrived |
places[].good | The ground the place is the one to show on: points is a ring of [x, z] corners, more further rings. The place whose ground holds the start loads first; the others are ranked by how far their ground is. Nothing is faded by the engine: the maker fades each place's edge in its own points |
places[].priority | Among places equally far, the lower comes first |
routes[0] | The way the rider will go: the places after the first are ranked by how far along it the rider reaches them (a route that ends within ten metres of its start is a lap). A place it never reaches, or a world with no route, is ranked by the straight line |
things[].of, seams, frame | Not read. A thing loads as in any world, whether its place is in or not |
Sizes: fileTransform has one scale for every axis. A file is .spz version 2 or 3; a made place has no view-dependent colour, and any a file carries is not drawn.
What loads when
- The record, the light, the sky and the collision, as in any world.
- The backdrop and the place the rider starts in, fetched together, drawn in that order.
onProgresstellsbackdrop, thenplacewith that place's id. - The things named in
thingsFirst(or all of them), thenplayable. - The other places, one at a time, the nearest first, ranked again against where the rider is each time one has landed. Each tells
placewith its id as it is drawn. The rider is where the engine's camera is.
current.places says how each stands (waiting, loading or in, its points and bytes, when it was asked for and drawn); current.backdrop the same for the backdrop; current.placesIn resolves when every place is in.
ts
const made = await engine.get('world').load('worlds/estate-forecourt/', { lite: onPhone });
await made.placesIn;
console.log(made.places.map((place) => `${place.id}: ${place.state}`));The budget
A load holds at most placesBudget points at once, the backdrop and the places together: 12,000,000 by default (DESK_PLACES_BUDGET: a world of three or four made places whole, about 0.8 GB on the card), 1,500,000 with lite: true (PHONE_PLACES_BUDGET: the same world's lighter copies are 1.1 million). A place that does not fit waits. When a waiting place is clearly nearer than one that is in (ten metres and a quarter nearer), the farthest one in is let go and the near one takes its room. The backdrop and the place the rider started in are held whatever the budget says. With a budget too small for every place, placesIn resolves when every place it has room for is in.
Near and far are judged four seconds ahead of the rider (the engine's camera, looked at six times a second): along whichever of routes the rider is riding (within fifteen metres of it and moving along its line), else straight along the rider's own movement. The place under the rider is under the rider; any other is as near as that way ahead comes to it, so a place just left is already behind. A waiting place the way ahead reaches (within fifteen metres) has its file fetched at once, room or no room; the far place is let go only when the near one's file is in hand, and the near one is drawn about a third of a second later. Measured on the first world of three places with a budget that holds only two, at 20 m/s: the place ahead was in 16 m before the rider reached its ground.
A place that is let go is plainly missing if it is in sight: the budget is for worlds larger than a view, not a way to save memory inside one. Size it so that what a rider can see fits.
The card's own limit comes first: one buffer holds about 8.3 million points on a card that binds 128 MB a buffer (the engine asks the card for all it can bind, and most bind far more), the things' points included. The loader says so in the console when it holds fewer than asked.
The collision comes in by piece
The physics has 128 MB that cannot grow, a mesh needs eight to ten times its kept size for the moment it is built, and building is on the page's own thread at about 2.5 ms a thousand triangles. So a joined world's collision is never one mesh, nor a mesh a place. It is pieces of at most 1,500 triangles, and a piece is in the physics only while something can touch it.
- What it is cut from.
place.collision.tiles = { size: 16, cap: 1500, files: [{ file, min, max, triangles }] }names a file for each 16 m square of the world's frame, already cut into meshes under the cap. A file is fetched when something comes near what it reaches over (minandmax, never the square's number: a triangle is in the square its middle lies in, so a file reaches past its square). Each mesh is then built by its own reach. An entry withfar: true(the level ground round a single open place) is always in. With notiles, the oneplace.collision.fileis fetched, read and cut off the page's thread as the world loads. Files are plain.glb: points as 32-bit floats, triangles as 16- or 32-bit numbers, no compression. - What counts as moving. Every thing that drives (a player's or not, at rest or not), every character, and every other body the physics has awake. Round each: 12 m (6 m for what does not drive), and the same width along the next second and a half of its travel. A piece nothing has wanted for a second is taken out.
- A machine out of sight needs less.
world.players(eyes, within)names where the players look from (once, or every frame). A thing that drives further thanwithinmetres (60) from every eye keeps only 3 m of ground round it and along its strip; it is aimed where the machine is and heads every step, so one knocked out of it has its new ground asked for at once, and waits for it if it must. Within 60 m its band is the wide one again. With no eyes named, every machine is near. - A thing at rest sleeps where it is when the ground under it goes. Whatever could reach it is itself moving, so its ground is back first.
- A body whose ground is not in yet is held asleep where it is, with the speed it had, and let go with that speed when it is in.
world.isHeld(thing)says so, andheldandfreedare told (onNotice): a game decides what a machine nobody steers does meanwhile. - Rays see only what is in. A ray cast far ahead of a machine meets no ground beyond its strip. For what lies further, read the record: the height grid (
place.ground), and the lines a package gives for a game's own drivers (place.collision.lines, and each tile'sroutes), which the loaded world'srecordcarries as the maker wrote them. - How it stands.
world.collision()gives the pieces and triangles in, what waits, the longest build and how often anything was held; null in a world whose collision is one piece.
A world of one place whose package has no tiles loads as it always did: one mesh.
How it is drawn
- WebGPU. One splat with room for the budget and for the things' points. A place's file is fetched, unpacked and put in the world's frame in a worker; the page's own thread only copies it to the card, 131,072 points a frame. There is no second copy on the card and nothing runs every frame for a place that is in.
- The order. One object spans the whole world, hundreds of metres, and the sort has 4,096 depth bins. They are spent near the eye (2.8 mm wide at a metre, 1.6 cm at ten metres, 7 cm at fifty), so two surfaces a hand apart sort right where they are looked at closely. Points out of view are not drawn. Both are worked out when the object is sorted, which happens when the view turns 1.8 degrees, moves a quarter metre sideways or back, or two metres towards a point on the screen.
- The WebGL fallback. No part of a buffer can be written there, so the one splat is built again on the page's thread from every layer that is in, each time a place comes or goes. It is right from every view and costs one long frame a change, so it is for the lighter copies (
lite: true). The loose things that come as points are drawn over the place's points there, as in any world on the fallback. - The collision is one file in the world's frame. Each of its meshes is built by itself and is a static body of its own (the first is
WORLD_BODY_BASE, the others the ids below it): the physics has a fixed 128 MB to build a mesh in, and the collision of several places is several times one place's.