The Scene
In many engines, game code draws things: it calls a draw function for each object, every frame. That ties your game to the display rate, and it makes the drawing code hard to test.
In rocco, your game never draws. Instead, view returns a Scene each
fixed step: a plain value that describes what should be on the screen now,
and which shapes can touch. The host keeps the last two Scenes, compares
them and does the drawing. Because it has two Scenes, the host can draw
smooth frames between steps at whatever rate the display runs, and your
game never has to think about frames.
Your game builds a new Scene from the Model at each step. It never changes an old Scene, and it doesn’t need to keep one.
A first Scene
Pong’s view draws six cubes, each with its own id, scale and tint. It
adds a Camera from the camera package, two Lights and its UI:
box : U32, U64, Vec3, Vec3, Rgb -> Vocabulary.Draw
box = |mesh, id, pos, scale, tint| { ..Draw.default, id, mesh, pos, scale, tint }
view : Model -> Vocabulary.Scene
view = |m| {
c = m.cube
nodes = [
Light(ambient),
Light(sun),
Draw(box(c, 10, { x: 0.0, y: -0.1, z: 0.0 }, { x: 2.0 * half_width, y: 0.1, z: 2.0 * half_depth }, { r: 0.01, g: 0.013, b: 0.02 })),
Draw(box(c, 11, { x: 0.0, y: 0.1, z: -half_depth - 0.15 }, { x: 2.0 * half_width, y: 0.3, z: 0.3 }, white)),
Draw(box(c, 12, { x: 0.0, y: 0.1, z: half_depth + 0.15 }, { x: 2.0 * half_width, y: 0.3, z: 0.3 }, white)),
Draw(box(c, 0, { x: -paddle_x, y: 0.2, z: m.left }, { x: 2.0 * paddle_half_thick, y: 0.4, z: 2.0 * paddle_half_len }, { r: 0.073, g: 0.448, b: 1.0 })),
Draw(box(c, 1, { x: paddle_x, y: 0.2, z: m.right }, { x: 2.0 * paddle_half_thick, y: 0.4, z: 2.0 * paddle_half_len }, { r: 1.0, g: 0.214, b: 0.073 })),
Draw(box(c, m.ball.id, { x: m.ball.x, y: 0.2, z: m.ball.z }, { x: 2.0 * ball_half, y: 2.0 * ball_half, z: 2.0 * ball_half }, white)),
]
{ ..Scene.empty, camera: Cam.look_at({ x: 0.0, y: 14.0, z: 7.0 }, { x: 0.0, y: 0.0, z: 0.0 }), nodes, ui: hud(m) }
}Pong starts each Draw from Draw.default and its Scene from Scene.empty,
then sets only the fields it cares about. It keeps the default queries,
sky and cursor. Start your own records the same way.
Defaults to start from lists every default
record.
A Scene is a Description: it says what should be true now, and the host works out what to change. A Command is different, because it asks the host to do one thing once.
The fields
| Field | Holds | Page |
|---|---|---|
camera |
the Camera | this page |
nodes |
the root Nodes of the 3D tree: Groups, Draws, Lights, Colliders and Speakers | this page |
sky |
a sky id | Assets and Packs |
queries |
the Querys for the next step | Collisions and Queries |
cursor |
the Cursor | Input |
ui |
the root Elements of the UI tree over the 3D view | The UI |
The tree of Nodes
Scene.nodes holds the 3D part of the Scene as a tree. A Node is one of
five tags, and your game writes the tags directly:
Draw(draw): a Draw, a mesh to draw. See Pairing by id.Light(light): a Light. See Lights.Collider(collider): a Collider, a shape that Queries can test. See Collisions and Queries.Speaker(speaker): a Speaker, a Sound that plays while the Scene holds it. See Sound.Group({ id, pos, rot }, children): a Group id, a Pose and a list of child Nodes. See Groups.
A game with no Groups writes a flat list of leaves, such as
[Light(Light.ambient(grey)), Draw(floor), Draw(player)]. The order of the
list matters only for Lights. A Roc test can read the tree with a pattern,
such as [Light(_), Light(_), Draw(d), ..].
Groups
A Group holds the Nodes that belong to one thing, such as a player and the
drops it carries. It has a Group id and a Pose: a pos and a rot. The
Pose of each child is local to the Group, so a child at pos (0, 0, 1)
sits one unit in front of its Group and turns with it. Groups can nest.
A root Node’s Pose is in the world.
A Group has no scale. The scale of a Draw acts on that Draw only.
Group ids and draw ids are apart. A Group pairs only with a Group, so an entity can use its own id for both its Group and its Draw. Keep a Group’s id the same from one step to the next, as you do for a Draw.
To draw a frame between two steps, the host pairs each Group with the first Group of the same id in the previous Scene, in tree order. Then, for each Node:
- If the Node has the same parent Group id in both Scenes, the host interpolates its local Pose and then composes it with the interpolated Pose of its parent. A drop on a turning player follows an arc, not a straight line between the two steps.
- If the parent changed, the host composes the world Pose in each Scene and interpolates between those. A Node that moves to a new Group does not jump.
If two Groups in a Scene share an id, both draw their children. The first
one pairs, and the host logs once. Launch.capacity.groups, 1024 by
default, is the number of Groups the host pairs in one Scene. Past it, the
host pairs the first Groups and logs once. See Launch.
A Light has no id. It takes the interpolated Pose of its Group, and its own
local values come from the current Scene. A Group moves and turns a Point
light, turns the dir of a Directional light and does not change an
Ambient light.
The host composes each Collider’s world Pose from the current Scene, with no interpolation, before it answers the Queries. Queries, Contacts, RayHits and OverlapHits stay in world space.
When your game moves a Node to a new Group and wants it to stay where it
is, give it the local Pose that Pose.relative returns.
Pose.compose gives the world Pose of a local one, with
the rule the host uses. Both are in packages/linalg.
Pairing by id
The host draws frames at the rate of the display, not at the step rate. To draw a frame that falls between two steps, it interpolates between the previous Scene and the current one.
To do that, the host pairs each Draw in the current Scene with the Draw
that has the same id in the previous Scene. It interpolates pos,
scale, rot and scale_rot between the two, but not mesh or tint.
A Draw with no partner simply draws where it is.
The host blends the pose too. The time blends when the clip is the
same in both Scenes and the time did not go back. The fade_time blends
by the same rule with the fade_clip. The fade blends only when both
the clip and the fade_clip are the same. Anything else takes the
current value, so a looping time that wraps back to the start does not
play the Clip backwards for a frame.
So the ids matter. Follow these rules:
- Give each Draw an id that no other Draw in the Scene has.
- Keep the id of a thing the same from one step to the next.
- Give a new thing a new id, so that it does not slide from where an old thing was.
- Give fixed scenery ids that your entities never get.
If two Draws in a Scene have the same id, both draw, and the host logs the id once. A Draw keeps its id when it moves to a new Group, so it pairs with its previous self.
Pong gives the ball a new id at each serve. So the new ball appears at the centre, and doesn’t slide back across the court:
serve : Model, F32 -> Model
serve = |m, dir| {
rng = next_rng(m.rng)
serves = m.serves + 1
ball = { id: ball_id_base + serves, x: 0.0, z: 0.0, vx: dir * serve_speed, vz: (unit(rng) - 0.5) * 6.0 }
{ ..m, ball, serves, rng }
}The entity game gives each entity an id from a counter that only goes up. Its scenery ids count down from the top, so the two sets never meet:
# Scenery ids count down from the top, so next_id never reaches them.
floor_id : U64
floor_id = U64.highestThe camera
The camera package makes a Camera. Add
cam: "https://github.com/Pixelsprout/rocco-engine/releases/download/v0.6.0/camera-QiJqL4WFPQGUbBCMZ5bzjdLHhgRz7BiUw1ngwxWwHtP.tar.zst" to the header of your app, then write
import cam.Camera as Cam. On GitHub, get the URL as
Shipping a game explains. Pong uses
Cam.look_at above. Cam.follow keeps
the eye at an offset from a target, such as the player.
The host interpolates the eye, target and up of the Camera, but not
fov_y. If up is zero or points along the view, the host uses +y and
logs once.
Lights
The host has no light of its own, so a Scene with no Lights draws black.
The Light type lists the kinds of Light. Put each one in Scene.nodes
with the Light tag.
Light colours are linear, and they carry the intensity, so a channel can go above 1. The surface divides the diffuse light by pi. So a Directional colour of 3.14 lights a white matte surface that faces it to about 1, before the tone map.
Use Light.sun for the one light that casts a shadow. The
shadow map covers each Draw of the Scene.
The host uses the first Ambient light, and the first 8 Directional and
Point lights. “First” is the order of a depth-first walk of the tree: a
Group’s children come right after the Group, before the Nodes that follow
it. The first Directional light that sets shadow is the Sun.
A few rules to know:
- The host drops a Directional light with a zero
dir, and a Point light with arangeof 0 or less. It logs each reason once. - Lights have no id, so the host does not interpolate them. A light that moves inside its Group jumps once each fixed step. A light moves smoothly when its Group does.
Colours
Every colour is linear, not sRGB. The host multiplies the colour of the
mesh by the tint of the Draw. The fallback mesh ignores the tint. Then the
host tone maps the lit colour and encodes it to sRGB for the screen.
Defaults to start from
The platform gives a default record for each large record. Start from it with a record update, and set only the fields that you need.
| Record | Default | Use |
|---|---|---|
| Scene | Scene.empty |
the start of each Scene in view |
| Draw | Draw.default |
the start of each Draw. Set id and mesh. |
| Speaker | Speaker.default |
the start of each Speaker. Set id and sound. |
| Launch | Launch.default |
the value of launch, or the start of it |
| BoxConfig | Ui.default_box |
the config of each Box in the UI |
| Text | Ui.default_text |
the start of each Text in the UI. Ui.label(str) sets the string. |
| Config | Config.empty |
the Config in a test |
| Input | Input.empty |
the Input in a test |
| QueryResults | QueryResults.empty |
the QueryResults in a test |