Display optimisation
Usage guide of the render.optimize kit: the levers (instancing, merge, cull, streaming, kept static shadows, warm) and their defaults, departures per entity (passport section, rules, tags, setEntity, the hook render.optimize.entity), how each engine applies them, debug.
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.<n> 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:
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: trueorfalse(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:
- the passport of the model, section
kits.optimize(keysalwaysShown,instancing,static,mode): the GripForge passport, then the game's override in itsasset_passportsdocument:{ "id": "<slot>", "passport": { "kits": { "optimize": { "mode": "dynamic" } } } }; - the rules: the config key
rulesand the registryoptimize_rules, by entity kind or tag. A rule is{ "id": "door", "match": ["door", "gate"], "mode": "dynamic" }(whenmatchis empty,idis what it matches). A kit, a game or a mod adds one; - the tags of the entity:
optimize.alwaysShown,optimize.noInstancing,optimize.static,optimize.dynamic, andoptimize.off(all three at once: shown, not instanced, dynamic); setEntity(id, policy)at run time (setEntity(id, null)clears it);- 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.
{ "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 |