# Display optimisation of your game (render.optimize) One set of switches over what an engine already knows to make a scene cheap to draw: models loaded once and shared, static scenery merged, what is off camera hidden, far zones of a level hidden, shadows of what does not move kept. Readable offline: `gripforge docs render-optimize`, or the MCP tool `gripforge_docs` (topic `render-optimize`). ## What it is for The kit **decides**, the engine **applies** with its own mechanisms. You do not write a batching or culling pass: you say which levers are on, and for the few objects that must be treated differently, how. Nothing is unloaded unless you ask for it (`streaming.unload`), and nothing is recompiled: the kit is data the engine reads. It is separate from `render.lod` (which mesh level to show): they do not depend on each other. They meet only in the engine, each reading its own data. The level of detail of one entity (forced, fixed, none) stays `render.lod`'s: tags `lod.level.` and `lod.off`. ## Turn it on `render.optimize` is installed, with the defaults below, in every new game, and strongly recommended; existing games receive it at their next opening. A game declares only what departs from the defaults: ```text gripforge_gamekit_install { project_id: "gkp_…", id: "render.optimize" } gripforge_gamekit_configure { project_id: "gkp_…", id: "render.optimize", config: { "merge": { "instanceTriangles": 0 }, "streaming": { "unload": true } } } gripforge_gamekit_configure { project_id: "gkp_…", id: "render.optimize", enabled: false } # off, kept installed ``` `enabled: false` inside the config turns every lever off for the game (the engine draws the files as they are). The full reference is `gripforge kit api render.optimize`. ## The levers and their defaults | Lever | What the engine does | Default | | --- | --- | --- | | `instancing` | a model is loaded once; every copy shares its meshes and materials (one handle, one batch); identical contents from different files share one handle too | on | | `merge` | the static parts of a scene that share a material become one mesh, by chunks | on | | `cull` | what is off camera is hidden (frustum culling); nothing is unloaded | on | | `streaming` | a level drawn zone by zone shows the zones around the camera; the others are hidden after `linger` seconds | on, `unload: false` | | `staticShadows` | the shadow of the static scenery is drawn once and kept (WebGPU) | off | | `warm` | the pipelines of what appears during play are made while the game loads | on | Each lever has `enabled` (default `true`; `false`: the engine does as if the lever did not exist) and these keys: | Key | Default | Meaning | | --- | --- | --- | | `merge.instanceTriangles` | 400 | a mesh drawn several times is merged only up to this many triangles; above it stays instanced; 0 = never merge copies | | `merge.partTriangles` | 4000 | no part above this many triangles is merged (it keeps its own culling) | | `merge.vertices` | 60000 | vertices of one merged mesh at most | | `streaming.unload` | `false` | `true`: a zone far from the camera is unloaded (spawned again on the way back) instead of hidden | | `streaming.linger` | 4 | seconds a zone that left the ring stays shown | | `streaming.perFrame` | 8 | looks of zone pieces made per frame at most | | `staticShadows.snap` | 0.125 | how far the kept shadow reaches past the view, as a fraction of its side | | `warm.models` | 32 | models drawn ahead at most | | `rules` | `{}` | policies by entity kind or tag (below) | `staticShadows` is off by default because the frame that draws the kept shadow again has no moving shadow: turn it on when the game can afford that frame. ## Departures per entity An entity departs from the levers with three switches: - **`alwaysShown`**: never hidden off camera, never streamed out (a sky, a landmark seen through a window); - **`instancing: false`**: its meshes and materials are its own and it is not merged (a model a kit recolours per copy); - **`static: true` or `false`** (`mode: "static"` / `"dynamic"`): static is merged with the scenery and its shadow kept; dynamic is never merged (anything that moves). They are read in this order, each over the previous one: 1. **the passport of the model**, section `kits.optimize` (keys `alwaysShown`, `instancing`, `static`, `mode`): the GripForge passport, then the game's override in its `asset_passports` document: `{ "id": "", "passport": { "kits": { "optimize": { "mode": "dynamic" } } } }`; 2. **the rules**: the config key `rules` and the registry `optimize_rules`, by entity kind or tag. A rule is `{ "id": "door", "match": ["door", "gate"], "mode": "dynamic" }` (when `match` is empty, `id` is what it matches). A kit, a game or a mod adds one; 3. **the tags of the entity**: `optimize.alwaysShown`, `optimize.noInstancing`, `optimize.static`, `optimize.dynamic`, and `optimize.off` (all three at once: shown, not instanced, dynamic); 4. **`setEntity(id, policy)`** at run time (`setEntity(id, null)` clears it); 5. **the hook `render.optimize.entity`**: a tap sees `{ entity, kind, alwaysShown, instancing, static }`, may change the three switches, or cancel the decision, in which case the entity follows the levers. ```json { "rules": { "door": { "static": false }, "skybox": { "alwaysShown": true } } } ``` ## Hooks, mods, registries | What | Name | Detail | | --- | --- | --- | | Hook | `render.optimize.entity` | mutable `alwaysShown`, `instancing`, `static`; cancellable; web and native player | | Registry | `optimize_rules` | policies by kind or tag; inherited by games, extended by kits and mods | | Moddable config | `enabled`, `instancing`, `merge`, `cull`, `streaming`, `staticShadows`, `warm`, `rules` | a mod changes them like the game does | | Event | `optimize.changed { epoch }` | a lever or the policy of an entity changed; engines read `state()` again | ## Service `state()` (`{ epoch, enabled, settings, entities }`: the levers in the engine's shape and only the entities that depart from them), `settings()`, `lever(name)`, `setLever(name, value | null)`, `enabled()`, `setEnabled(on)`, `policy(id)`, `setEntity(id, policy | null)`, `rules()`, `addRule(kindOrTag, policy)`, `stats()`, `epoch()`. The native player reads `state()` as its view `render.optimize.state`, again only on `optimize.changed`, never every frame. ## Engines The kit decides; each engine applies the levers with its own tools, and the three switches of an entity (`alwaysShown`, `instancing`, `static`) mean the same everywhere. | Engine | Instancing | Off camera | `alwaysShown` | `instancing: false` | | --- | --- | --- | --- | --- | | Web and native player | copies share one handle; static parts merged; zones streamed; kept static shadows; pipelines warmed | hidden, not unloaded | never hidden or streamed out | meshes and materials of its own | | Godot | copies share the meshes of a template and are drawn as instances | hidden by the engine's frustum and occlusion culling | bounds that cover the world, no occlusion | meshes of its own | | Unity | one template instantiated, materials with GPU instancing | hidden by the engine's culling | bounds that cover the world, no occlusion | meshes of its own | | Unreal | copies share the static mesh, drawn as instanced draws | hidden by the engine's culling | bounds that cover the world, no distance cull | a mesh of its own | Merging, zone streaming, kept shadows and warming are levers of the web and native players; the engines keep their own for those. ## Debug The debug inspector (F3) has a mode **Display optimisation**: the levers (switch them there to compare), the entities that depart from them, and the engine's own counters: triangles, draws, instances (copies sharing a mesh), meshes hidden off camera, zones hidden, parts merged. `setLever` from a script does the same. ## When it does not do what you expect | Symptom | Cause and fix | | --- | --- | | A moving object looks cut or stays in place | it was merged as static: mark it dynamic (rule, passport `mode`, tag `optimize.dynamic`) | | A recoloured model recolours every copy | copies share their material: `instancing: false` for that model | | Something disappears when seen through a window | culling saw it off camera: `alwaysShown` | | The moving shadow is missing for a frame | `staticShadows` redraws the kept shadow on that frame: leave it off, or accept it | | Memory does not go down | `streaming` hides zones, it unloads only with `streaming.unload: true` |