# Look and feel
A game that passes its tests can still look cheap. Most of the cheap look
comes from a few causes: flat lighting, random colors, plain boxes for
everything, and a HUD laid on top of the scene. This guide is a checklist
for each of them and the polish loop that finds them.
## The polish loop
Do this after the game works and before calling it done.
1. Capture. Add `capture` steps at the moments a player sees most: the
start, the middle of a round, the win and lose screens. Use
`"contact_sheet"` to see several frames in one image.
2. Look. Open every image. Describe what you see in one sentence, as a
player would. If you cannot tell what the player should look at, the
frame fails.
3. Critique. Go through the checklist below and write down every item the
frame fails.
4. Fix the worst item, capture again, and repeat. Two or three passes are
normal.
## Checklist
- **Silhouette.** The player, enemies and pickups read as shapes against
the background, even in grey. Raise contrast between them and what is
behind them; a rim of light or an emissive edge helps.
- **Palette.** Use the five colors of your art preset (`rusting preset
list`): dark, mid and light for the world, two accents for what matters
(player, goal, danger). Do not give every object its own random color.
Many random colors in a large crowd read as noise.
- **Value contrast.** The darkest and lightest parts of the frame differ
clearly. If the whole frame is mid grey, lower the ambient light and
raise the key light.
- **Warm key, cool fill.** One strong directional or spot light (the key)
in a warm color, and a weaker sky or ambient light (the fill) in a cool
color, or the reverse for night. Equal light from every side looks flat.
One light casts shadows: the directional light, or a spot light with
`shadows` when there is no shadowed directional light. At most 64 lights
draw a frame at `High` quality, 32 at `Balanced` and 16 at `Eco`;
`rusting docs show guide/lighting` says which one `Auto` picks.
- **Materials.** Not everything has roughness 0.5. Metal is metallic and
smoother; cloth, wood and stone are rough. Emissive is for lights,
screens and pickups only.
- **Shapes.** Use the rounded primitives (below) or real models for
anything the player looks at closely. Plain cubes are for floors and
walls.
- **HUD.** Text stays inside a safe margin (about 3% of the screen from
each edge) and has a backdrop or outline where it crosses the scene.
Show only what the player needs now; put long help text behind a key.
The center of the screen stays clear during play.
- **Motion and feedback.** Every action the player takes gets a response
within a few frames: a sound, a burst, a flash, a scale pop or a camera
shake. Idle objects that should feel alive move a little
(`rusting.tween`). Game code moves an object to a new place, turn or
size once with `scene.tween(name, TweenProperty::Position, to, seconds,
Easing::QuadOut)`, starting from where it is now.
- **Air.** An empty sky reads as a stage set. A few slow particles (dust
motes, falling leaves, snow, fireflies) make the space feel lived in;
keep them small, sparse and in the palette. `rusting effect apply
<scene> dust_motes` adds one; `rusting docs show guide/effects`.
## Presets and color grading
`rusting preset apply <scene> <name>` sets the sky, sun, ambient light,
fog, bloom, tone mapping and color grading in one patch. Presets:
`daylight`, `golden_hour`, `night`, `flat_toy`, `dark_interior` (horror,
night shifts) and `bright_stylized` (cartoon). `--only` limits it to some
parts. `rusting preset list --json` prints each preset's values and its
five-color palette.
Light units are on one renderer scale, not physical ones. A white surface
facing the light reflects 1 before exposure for a directional light of
100000 lux, a point or spot light of 1000 up close, or ambient intensity
1. A 3000 lux sun is therefore only 0.03 and almost invisible; moonlight
shafts through windows need 20000-40000 lux. Exposure multiplies
everything.
`rusting.color_grading` runs after tone mapping:
```json
"rusting.color_grading": {"contrast": 1.1, "saturation": 0.9,
"shadows": [0.92, 0.98, 1.1], "highlights": [1.08, 1.0, 0.9], "vignette": 0.3}
```
- `contrast` above 1 darkens shadows and brightens highlights around mid
grey.
- `saturation` 0 is greyscale, 1 is unchanged.
- `shadows` and `highlights` tint the dark and bright parts. Cool shadows
with warm highlights is the classic film look.
- `vignette` 0..1 darkens the corners and leads the eye to the center.
### Film and CRT/VHS effects
The same component carries film and old-screen effects. Each one is 0..1
and off at 0:
```json
"rusting.color_grading": {"grain": 0.15, "chromatic_aberration": 0.2}
```
Grading and fog can change by area. A `rusting.post_volume` on an object
that also has `rusting.fog` or `rusting.color_grading` makes them apply only
while the camera is inside its box, fading over `blend` metres at the edge;
grading and fog without a volume apply everywhere else:
```json
{"name": "Hall Mood", "transform": {"position": [0, 1.5, -12]},
"components": {"rusting.post_volume": {"extents": [3, 2, 10], "blend": 1.5},
"rusting.fog": {"color": [0.4, 0.45, 0.3], "density": 0.08},
"rusting.color_grading": {"saturation": 0.7, "shadows": [0.95, 1.05, 0.85]}}}
```
- `grain` adds film grain. The pattern comes from the fixed tick, so the
same tick always gives the same grain: replays and golden images stay
repeatable.
- `chromatic_aberration` splits red and blue toward the edges.
- `scanlines` darkens every other pixel row.
- `color_bleed` smears color to the right while brightness stays sharp,
like VHS tape.
- `noise_band` rolls a band of static down the image, once every four
seconds.
- `distortion` bulges the image like a curved tube and sways its rows over
time. The corners turn black.
`chromatic_aberration`, `color_bleed` and `distortion` read neighboring
pixels, so they add a copy of the HDR image to the frame (about one
full-screen copy; small next to bloom). The others cost nearly nothing.
For CCTV monitors, give each `rusting.camera_screen` its own `grading`
(see [cameras.md](cameras.md#screens-a-camera-on-a-material)) so only the
feeds look like old tape and the room stays clean:
```json
"rusting.camera_screen": {"camera": "Cam 3", "size": [320, 180],
"grading": {"scanlines": 0.8, "grain": 0.3, "color_bleed": 0.4,
"vignette": 0.4, "noise_band": 0.3, "distortion": 0.3, "saturation": 0.6}}
```
## Mesh kit
Built-in primitives (`{"BuiltinPrimitive": "<name>"}`): `Cube`, `Sphere`,
`Cylinder`, `Cone`, `Capsule`, `RoundedCube`, `Torus`, `Plane`, `Quad`,
`Triangle`, `Tetrahedron`, `Octahedron`, `Dodecahedron`, `Icosahedron`,
`Pyramid`. Cylinder, cone, capsule and sphere are smooth shaded.
Every primitive is centered on its origin. At scale 1:
| `Cube`, `RoundedCube` | 1 x 1 x 1 | box |
| `Sphere` | 1 x 1 x 1 | diameter 1 |
| `Cylinder` | 1 x 1 x 1 | diameter 1, runs along Y |
| `Cone` | 1 x 1 x 1 | base diameter 1 at y = -0.5, tip at y = +0.5 |
| `Capsule` | 1 x 2 x 1 | diameter 1, runs along Y |
| `Pyramid` | 1 x 1 x 1 | square base at y = -0.5, tip at y = +0.5 |
| `Torus` | 1 x 0.28 x 1 | ring lies flat in XZ around Y; tube radius 0.14 |
| `Plane` | 1 x 0 x 1 | flat in XZ, faces +Y |
| `Quad` | 1 x 1 x 0 | flat in XY, faces +Z |
| `Triangle` | 1 x 1 x 0 | flat in XY, tip at y = +0.5 |
| `Tetrahedron`, `Octahedron` | 1 x 1 x 1 | fits the unit box |
| `Dodecahedron` | 0.93 x 0.93 x 0.93 | |
| `Icosahedron` | 0.85 x 0.85 x 0.85 | |
So a scale of `[0.1, 2.0, 0.1]` on a `Cylinder` is a pole 2 m tall and
10 cm thick. To lay a cylinder on its side (a roller), rotate it by 1.5708
around X or Z.
Texture mapping (UVs): `Cube`, `RoundedCube` and `Plane` show the whole
texture on each face. `Sphere`, `Capsule`, and the sides of `Cylinder` and
`Cone` wrap it once around the Y axis and show its full height, the top
row of the image at the top of the shape, so horizontal bands in the image
become rings. The flat caps of
`Cylinder` and `Cone` show it as a disc, upright when seen from outside with
-Z at the top, so a thin cylinder turned to face the camera is a round
door, coin or clock face.
A child's transform is relative to its parent, scale included. Under a
parent scaled `[0.6, 1.8, 0.6]`, a child sphere at scale 1 is stretched
three times taller than wide; divide the child's scale by the parent's,
or keep the parent at scale 1 and scale only the mesh children, as `Hero`
does below.
- `RoundedCube` is a unit box with edges rounded by 0.1. Edges catch the
light, so crates, furniture and buttons stop looking like placeholders.
Large scales stretch the rounding; for a wall, keep a plain `Cube`.
- `Capsule` has diameter 1 and height 2, like a `Capsule` collider with
radius 0.5 and half height 0.5. Use it for limbs, characters and posts.
A simple character from the kit. Patch it in, then move `Hero` as one
object; the parts follow:
```json
{"operations": [
{"op": "create", "entity": {"name": "Hero",
"transform": {"position": [0, 0, 0], "rotation": [0, 0, 0], "scale": [1, 1, 1]}}},
{"op": "create", "entity": {"name": "Hero Torso", "parent": "Hero",
"transform": {"position": [0, 1.1, 0], "rotation": [0, 0, 0], "scale": [0.5, 0.6, 0.28]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "RoundedCube"},
"material": {"Inline": {"base_color": [0.18, 0.32, 0.6, 1], "roughness": 0.7}}}}},
{"op": "create", "entity": {"name": "Hero Head", "parent": "Hero",
"transform": {"position": [0, 1.62, 0], "rotation": [0, 0, 0], "scale": [0.34, 0.34, 0.34]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Sphere"},
"material": {"Inline": {"base_color": [0.9, 0.68, 0.52, 1], "roughness": 0.6}}}}},
{"op": "create", "entity": {"name": "Hero Arm L", "parent": "Hero",
"transform": {"position": [-0.34, 1.1, 0], "rotation": [0, 0, 0.08], "scale": [0.14, 0.3, 0.14]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Capsule"},
"material": {"Inline": {"base_color": [0.18, 0.32, 0.6, 1], "roughness": 0.7}}}}},
{"op": "create", "entity": {"name": "Hero Arm R", "parent": "Hero",
"transform": {"position": [0.34, 1.1, 0], "rotation": [0, 0, -0.08], "scale": [0.14, 0.3, 0.14]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Capsule"},
"material": {"Inline": {"base_color": [0.18, 0.32, 0.6, 1], "roughness": 0.7}}}}},
{"op": "create", "entity": {"name": "Hero Leg L", "parent": "Hero",
"transform": {"position": [-0.13, 0.4, 0], "rotation": [0, 0, 0], "scale": [0.18, 0.4, 0.18]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Capsule"},
"material": {"Inline": {"base_color": [0.15, 0.13, 0.12, 1], "roughness": 0.8}}}}},
{"op": "create", "entity": {"name": "Hero Leg R", "parent": "Hero",
"transform": {"position": [0.13, 0.4, 0], "rotation": [0, 0, 0], "scale": [0.18, 0.4, 0.18]},
"mesh_renderer": {"mesh": {"BuiltinPrimitive": "Capsule"},
"material": {"Inline": {"base_color": [0.15, 0.13, 0.12, 1], "roughness": 0.8}}}}}
]}
```
The feet are at the origin of `Hero`. Animate it by rotating the arm and
leg parts from game code each fixed tick (swing them with
`sin(tick * speed)`), or key their rotations in a `rusting.animation`
clip (`rusting docs show guide/animation`). A rigged glTF character keeps
its skin and clips through `scene add-model` (see "Skinned meshes" there).
Rename the parts for each copy, or use `spawn_copy` on a hidden template.
## Crowds of one object
Thousands of copies of one mesh and material, like shelves of identical toys,
draw in one instanced batch with GPU culling. No special component is needed:
give every copy the same mesh and the same material handle. Copies with
different materials batch separately, so vary a crowd with a handful of
shared materials, not one per object.
Moving some of them each frame is cheap: change their `Transform` as usual.
Measured with `render_bench` on an RTX 3060 at
1920x1080 (balanced quality, 600 frames, no GPU bodies), mean and 95th
percentile frame time:
| 5,000 copies | 7.4 ms | 8.2 ms |
| 5,000 copies, 500 moved every frame | 8.8 ms | 9.4 ms |
| 10,000 copies, 500 moved every frame | 12.4 ms | 13.4 ms |
| 5,000 copies, 500 moved, six 320x180 camera screens | 16.5 ms | 19.0 ms |
| the same, screens updating every other frame | 12.4 ms | 13.5 ms |
All of these hold 60 fps except the full scene with screens updating every
frame, which needs `update_every: 2` on the screens. Measure your own scene
with `cargo run --release --example render_bench -- balanced --no-bodies
--bears 5000 --bear-updates 500 --screens 6 --screen-every 2`. There is no
per-instance custom value yet; give a copy that must look different its own
material.
### Level of detail (LOD)
A crowd seen from far away does not need its full mesh. Put a `.rlod` file
beside the `.rmesh` the objects use, with the same name (`bear.mesh-0-0.rlod`
next to `bear.mesh-0-0.rmesh`), and every object drawing that mesh picks one
level per frame. Mesh paths are relative to the `.rlod` file:
```json
{"metric": "Distance", "levels": [
{"mesh": "bear.mesh-0-0.rmesh", "until": 8},
{"mesh": "bear_low.mesh-0-0.rmesh", "until": 30},
{"mesh": "bear_far.mesh-0-0.rmesh"}
]}
```
With `"metric": "Distance"` a level draws while the camera is closer than
its `until`, in metres. The default metric, `"ScreenSize"`, uses the
fraction of the view height the object covers instead, so a level draws while
it covers at least `until` (0.5 is half the screen); it keeps the same look
when the field of view changes. A level without `until` draws at any range;
give the last level an `until` to stop drawing the object past it. Make the
coarser meshes with `rusting scene add-model` from a lower-poly glTF, or with
your own generator. The scene does not change: the group loads with the mesh,
and an exported game carries it in `assets/`. Edit the `.rlod` and reload the
scene to retune it.
To measure your own game in its real window, run it with `RUSTING_PERF=1`
(for example `RUSTING_PERF=1 rusting run --release`). Once a second it
prints a `[rusting] perf` line with the frame rate, the p50, p95, p99 and
largest frame time over that second, and where the time went (update,
render, CPU and GPU passes), and shows the frame rate, frame time and p95
in the window's top-right corner. F3 turns the same report on and off
while the game runs; the game also sees the F3 press. The CPU part splits into `fixed` (physics and
fixed systems), `update` (the game's systems), `post_update` (transform
propagation and other engine work) and `extract`; a scenario run reports
the same split per tick as `perf.stages_ms_mean`. `rusting test` captures render offscreen and
read every frame back, so their frame times run higher than the window's;
use them as a regression guard, not as the frame budget.
To measure at a set size, put `"window": [1920, 1080]` in `project.json`:
the game asks for that window size when it runs from the project folder.
For one number to compare runs, use `rusting run --release --bench 1000
--json`. The game opens its window, skips 60 warm-up frames (pipeline
builds and uploads), measures the next 1000, closes itself, and reports
`timings.bench` with `frames`, `mean_ms`, `p50_ms`, `p95_ms`, `p99_ms` and
`max_ms`. It also gives `cpu_p50_ms` (update plus extraction, preparation
and recording, without waits on the GPU), `gpu_p50_ms` (GPU pass time) and
`bound`: `"cpu"` or `"gpu"`, whichever median is larger. Without `--json` it
prints one `bench:` line. Check `bound` before cutting cost: fewer triangles,
mesh LOD and smaller shadow maps only help a `"gpu"` frame. A `"cpu"` frame
needs fewer objects, fewer distinct meshes and materials, or less game code
per frame. Each LOD level is its own instanced batch, so a `.rlod` group adds
CPU preparation and draws; on a CPU-bound crowd it can cost more than its
triangles save.
Without a window, `rusting test` gives GPU frame times. A scenario with
`"gpu": true` and `"capture_size": [1920, 1080]` draws one offscreen frame
per tick and reports `perf.render.gpu_ms_p50`, `gpu_ms_p95` and
`gpu_ms_max` over those frames (`gpu_frames` counts them). These are GPU
pass times only, without present or CPU work.
`capture_size` is the render size (resolution) of every headless frame,
captures and `perf.render` included: `[1920, 1080]` for 1080p, `[2560,
1440]` for 1440p. It defaults to `[1280, 720]`. Windowed runs use the
window's size.
## Free models
A real model beats any pile of primitives. Many good game models are free
under CC0, which allows any use without credit. Sources that work well:
| [Kenney](https://kenney.nl/assets) | Large kits in one style: city, dungeon, furniture, vehicles, nature, characters | CC0 |
| [Quaternius](https://quaternius.com) | Low-poly packs: characters, animals, nature, sci-fi, weapons | CC0 |
| [Poly Pizza](https://poly.pizza) | Search across thousands of low-poly models | CC0 or CC-BY per model |
| [Poly Haven](https://polyhaven.com) | Realistic models, PBR textures and HDRIs | CC0 |
| [ambientCG](https://ambientcg.com) | PBR textures (color, normal, roughness maps) | CC0 |
Steps:
1. Download the glTF (`.glb` or `.gltf`) version. Pick models from one
kit or author so the style matches. Low-poly kits fit best: textures
are small and draws stay cheap.
2. Check the license on the model's page. CC0 needs no credit. CC-BY needs
the author's name in your README credits. Skip anything with
"non-commercial" or no license.
3. Import it into the project and record where it came from:
```sh
rusting asset import . ~/Downloads/barrel.glb --to models \
--license CC0-1.0 --author "Kenney" --url https://kenney.nl/assets/...
```
The license, author and URL go into the model's `.rmeta` next to it.
That file is the record; `scene add-model` takes no provenance flags.
4. Place it in a scene:
```sh
rusting scene add-model scenes/main.rscene assets/models/barrel.glb --name "Barrel 1"
```
This creates one object named `Barrel 1` with a child per glTF node
and keeps the model's materials and textures. It writes the converted
meshes (`.rmesh`) and textures (`.rtexture`) next to the model; keep
them with the project. The model's glTF animations become a
`rusting.animation` on `Barrel 1`; the first clip autoplays. Move,
scale or add a collider to `Barrel 1` with
a patch. Run `add-model` again with another `--name` for each copy, or
`spawn_copy` it from code.
5. Capture and look: imported models are often far larger or smaller than
the scene (check the scale) and may face +Z instead of -Z (turn the
parent by π).
Skinned meshes keep their skin and their clips play; see `guide/animation`,
"Skinned meshes". Child object names come from the glTF file, so look them
up under the parent you named rather than by their name alone. A child
name that is already taken in the scene, or repeated inside the file,
gets " 2", " 3" and so on.
A shared Rusting model library on a CDN is planned so agents can fetch a
small set of house models by name. It does not exist yet; use the sources
above until it does.