# Level of detail in your game (render.lod) Decide, for every object of a game, which mesh level to show, whether to draw it at all, whether it casts a shadow and how often it updates, from one model that behaves the same on the web, Godot, Unity, Unreal and the native player. Readable offline: `gripforge docs render-lod`, or the MCP tool `gripforge_docs` (topic `render-lod`). ## What it is for A model drawn far away should not cost what it costs up close. `render.lod` is the run-time half of that: - it **decides**: for each managed object and the position of the viewer, the level (0 is the full mesh), visible or culled, impostor or not, shadow or not, updated every how many ticks; - it **does not draw and does not simplify**: each engine applies the answer with its own mechanism, and the levels themselves come from the build (`gripforge lod`, see `gripforge docs lod`). With a single mesh the kit still culls, cuts shadows and slows updates. It needs no other kit. It is separate from `render.optimize` (instancing, merging, culling off camera): the two do not depend on each other. ## Turn it on `render.lod` is installed, with the defaults below, in every new game. Existing games receive it the next time they are opened. To add it, tune it or remove it on a game of your own: ```text gripforge_gamekit_install { project_id: "gkp_…", id: "render.lod" } gripforge_gamekit_configure { project_id: "gkp_…", id: "render.lod", config: { "profile": "medium" } } gripforge_gamekit_configure { project_id: "gkp_…", id: "render.lod", enabled: false } # off, kept installed gripforge_gamekit_remove { project_id: "gkp_…", id: "render.lod" } ``` The same calls are in the MCP of your agent. You only write the keys that differ from the defaults. The full reference of the kit (config, service, events) is `gripforge kit api render.lod`. ## What decides a level For an object at a given size on the screen, with its rule: - **Level**: the number of thresholds it has passed. By default (`metric: screen`) the thresholds are fractions of the height of the view the object covers (its bounding sphere over the height the view spans at its distance), so a big wall far away keeps its detail and a small barrier near loses it, at every resolution. `metric: distance` reads distances in metres instead. - **Culling**: beyond `drawDistance`, or smaller than `minScreenSize`. - **Impostor**: beyond `impostorDistance`, an object that has one shows it; one that has none keeps its last level. - **Shadows**: none beyond `shadowDistance`. - **Update rate**: beyond each `updateDistances[i]` the object updates every `updateIntervals[i]` ticks (animation, AI). - **Instance budget**: per category, only the `maxInstances` nearest visible instances stay. Every threshold is a band (`hysteresis`, 0.1 by default) so an object at the edge does not flicker between two levels. ### Rules: profiles, categories, tags One rule shape everywhere (`-1` and `[]` mean "inherit"). The rule of an object is the chain built-in profile, then `profiles[profile]`, then `categories[category]`, then `tags[tag]` (alphabetical), then the rule of the model's own passport. `scale` multiplies along the chain; every other key replaces. | Key | Meaning | | --- | --- | | `scale` | multiplies every distance (divides the screen sizes) | | `distances` / `screenSizes` | metres / fractions of the screen height at which level 1, 2, 3… start | | `drawDistance` | metres; 0 = never culled by distance | | `minScreenSize` | fraction of the screen height under which the object is culled; 0 = off | | `impostorDistance` | metres; 0 = no impostor | | `shadowDistance` | metres; 0 = no limit | | `updateDistances`, `updateIntervals` | metres, and ticks between two updates beyond each | | `maxInstances` | visible instances of the category; 0 = unlimited | | `minLevel`, `maxLevel` | the most and the least detailed level allowed | Built-in profiles (the player picks one in the Graphics tab when `settings.graphics` is installed): | Profile | `screenSizes` | `drawDistance` | `minScreenSize` | `shadowDistance` | | --- | --- | --- | --- | --- | | `low` | 0.5, 0.22, 0.1 | 200 | 0.015 | 35 | | `medium` | 0.4, 0.16, 0.07 | 300 | 0.008 | 70 | | `high`, `custom` | 0.3, 0.12, 0.05 | 400 | 0.004 | 120 | By default vegetation uses `scale` 0.75, vehicles 1.25 and structures 2.5 (structures are never culled for their size); props and characters use the profile as it is. An entity belongs to the category named by its description, else by a tag `lod.` (`props`, `vegetation`, `characters`, `vehicles`, `structures`), else by the first category whose `match` list holds its kind or one of its tags. An entity that matches nothing is left alone (`manageAll: true` manages it as a prop). The entity tagged `player` and any entity tagged `lod.off` are never managed. ## Settings and their defaults | Key | Default | Meaning | | --- | --- | --- | | `profile` | `high` | profile before the player chooses one | | `metric` | `screen` | `screen` or `distance` | | `viewer` | `camera` | measure from the camera (the player until the renderer gives it), or always from the player | | `fov` | 60 | vertical field of view for screen sizes, until the renderer gives the camera's | | `hysteresis` | 0.1 | width of the threshold bands | | `interval` | 0.1 | seconds between two evaluations | | `followViewDistance` | `true` | draw distances follow the player's view distance (`settings.graphics`) | | `manageAll` | `false` | manage entities that match no category, as props | | `playerOption` | `true` | show the profile row in the Graphics tab | | `debug` | `false` | publish the stats variables and show the overlay | | `match` | lists per category | kinds and tags that put an entity in a category | | `profiles`, `categories`, `tags` | inherit | rule overrides | | `budget` | off | frame-time budget (below) | A forest game on modest machines: ```json { "profile": "medium", "categories": { "vegetation": { "scale": 0.6, "shadowDistance": 25, "maxInstances": 1500 } }, "tags": { "landmark": { "drawDistance": 0, "maxLevel": 1 } }, "budget": { "enabled": true, "targetMs": 16.7 } } ``` ### Frame-time budget Off by default. With `budget.enabled`, the frame times are averaged over `window` frames: an average above `targetMs * (1 + tolerance)` lowers the scale of every distance by `step`, down to `minScale`; one below `targetMs * (1 - headroom)` raises it, up to `maxScale`. With `budget.measure: false` the engine measures nothing and only `reportFrame(ms)` feeds it (tests, replays), so the same inputs always give the same decisions. ## Per object, per model - **Tags on an entity**: `lod.off` (full mesh, never managed), `lod.level.` (always level n), `lod.`. At run time: `forceEntity(id, n)`, `forceEntity(id, 'off')`, and `forceEntity(id, null)` to give it back to its size. - **The passport of the model** (section `kits.lod`, from `gripforge lod` and the Library): the levels of the model (`ratios`, `fit`), `impostor` (the model may be shown as an impostor), `castShadows: false` (never casts a shadow), and its own `rule`. A game overrides it key by key in its `asset_passports` document. See `gripforge docs lod`. ## Where the levels come from The kit consumes levels, it makes none. Make them with `gripforge lod` (or the MCP tool `gripforge_lod_build`) when the asset arrives, then each engine finds them: on the web the bundle lists the levels of each slot and a level is loaded the first time it is asked; a Godot, Unity or Unreal project made by `gripforge game-export` carries them in the model it imports. ## Engines The kit decides; each engine applies the levels baked into the model (extra meshes `gf_lod..`, made by `gripforge lod`) with its own mechanism: | Engine | How the levels are switched | A level fixed by tag or `forceEntity` | | --- | --- | --- | | Web | children swapped per level, a level loaded the first time it is asked; `visible` and `castShadow` for culling and shadows; the animation mixer advances every `updateEvery` ticks | the level shown | | Native player | the same decisions, from the Rust module | the level shown | | Godot | sibling mesh instances of the master, switched by the engine's visibility ranges: the kit's screen sizes become distances for the camera's field of view, the hysteresis band becomes the range margin | the only one shown, without range | | Unity | sibling renderers of the master switched by a `LODGroup` whose transition heights are the kit's screen sizes | `LODGroup.ForceLOD` | | Unreal | the LODs of the slot's static mesh, made at import with the screen sizes of the passport; the engine's own LOD selection switches them for every field of view | the component's forced LOD | Godot and Unity show a child named `Impostor` when the model says so; the web and Unreal draw no impostor yet (the object keeps its last level, the decision is the same everywhere). Unreal meshes that use Nanite keep their own geometry level; the kit still culls, cuts shadows and slows updates. ## Hooks, mods and events `render.lod` is a pure service: it decides nothing of the game, so it has **no hook**. A mod may override `profile`, `metric`, `viewer`, `fov`, `hysteresis`, `interval`, `followViewDistance`, `manageAll`, `match`, `profiles`, `categories`, `tags` and `budget` (not `playerOption` nor `debug`); the profile the player chose in the Graphics tab still wins over a modded one. Scripts and rules can listen to: | Event | Payload | | --- | --- | | `lod.changed` | `entity, level, visible, shadows, impostor, updateEvery, category, reason` | | `lod.group.changed` | the same for a registered group of instances | | `lod.profile` | `profile` | | `lod.budget` | `scale, averageMs` | | `lod.pins` | `epoch` (the fixed levels changed) | ## Service For a HUD, an instanced renderer or a test: `profile()`, `setProfile(p)`, `rule(category, tags)`, `state(entity)` (`{ category, distance, level, visible, shadows, impostor, updateEvery, reason }`), `levelOf(entity)` (-1 when culled), `isVisible`, `castsShadow`, `updateEvery`, `shouldUpdate`, `evaluate(category, distance, radius, levels)` (a one-off decision), `addGroup(id, { category, position, radius, count, levels, impostor, tags })` with `moveGroup`, `removeGroup`, `groupState`, `setViewer([x, y, z])`, `setFov(deg)`, `reportFrame(ms)`, `budget()`, `stats()`, `statsLines(lang)`, `setDebug(on)`, `refresh()`. Variables: `lod.profile`, `lod.scale`, `lod.debug`, and with debug on `lod.objects`, `lod.visible`, `lod.culled`, `lod.triangles`. ## Debug `debug: true` (or `setDebug(true)`) publishes the stats variables and shows an overlay: objects per level, culled, impostors, shadow casters, estimated triangles. `stats()` always answers, for a HUD of your own. ## When it does not do what you expect | Symptom | Cause and fix | | --- | --- | | Nothing changes level | the model has no levels: run `gripforge lod`; or the entity matches no category (`manageAll`, a tag `lod.`) | | An object never loses detail | tagged `lod.off`, pinned by `lod.level.`, or `maxLevel: 0` in a rule | | Far objects disappear too early | `minScreenSize` or `drawDistance` of its category; raise `scale` or set them to 0 | | Levels flicker at the edge | raise `hysteresis` | | The player's choice is ignored by a mod | on purpose: the profile chosen in the Graphics tab wins |